Spaces:
Running
Running
Download static/scroll3d.js from SharpMeow/paris4-ink-map: direct link, hf CLI and curl.
- Browser
- Download file 35 kB
-
https://huggingface.co/spaces/SharpMeow/paris4-ink-map/resolve/main/static/scroll3d.js
- Command line
-
hf download hf://spaces/SharpMeow/paris4-ink-map/static/scroll3d.js
-
curl -L -o scroll3d.js https://huggingface.co/spaces/SharpMeow/paris4-ink-map/resolve/main/static/scroll3d.js
35 kB
| /* 3D Scroll: the fit's mesh of PHerc. Paris 4 as a point cloud that rolls and unrolls. | |
| * | |
| * Plain browser script, no bundler and no modules. It defines window.Scroll3D = { mount, parse }. | |
| * | |
| * var view = Scroll3D.mount(document.getElementById('scroll3d'), { | |
| * url: '/api/mesh3d.bin?stride=8', // or stride: 8, or data: an ArrayBuffer already fetched | |
| * onPick: function (p) { ... }, // p.x, p.y in map pixels (also p.winding, p.ink) | |
| * }); | |
| * view.setCursor({x: 71000, y: 1200}); // a marker at a map pixel; null hides it | |
| * view.setT(0.5); // 0 rolled, 1 unrolled | |
| * | |
| * The binary layout is documented in scroll3d.py and at /api/mesh3d.json. three.js r128 comes from cdnjs when | |
| * window.THREE is missing. An ink map is model output, not a reading. | |
| */ | |
| (function () { | |
| 'use strict'; | |
| var THREE_URL = 'https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js'; | |
| var HEADER_BYTES = 72; | |
| var threePromise = null; | |
| var last = null; | |
| function loadThree(src) { | |
| if (window.THREE) return Promise.resolve(window.THREE); | |
| if (threePromise) return threePromise; | |
| threePromise = new Promise(function (resolve, reject) { | |
| var s = document.createElement('script'); | |
| s.src = src || THREE_URL; | |
| s.async = true; | |
| s.onload = function () { | |
| if (window.THREE) resolve(window.THREE); else reject(new Error('three.js did not load')); | |
| }; | |
| s.onerror = function () { threePromise = null; reject(new Error('three.js could not be loaded')); }; | |
| document.head.appendChild(s); | |
| }); | |
| return threePromise; | |
| } | |
| /* ------------------------------------------------------------------ the binary */ | |
| function parse(buf) { | |
| var dv = new DataView(buf); | |
| var magic = String.fromCharCode(dv.getUint8(0), dv.getUint8(1), dv.getUint8(2), dv.getUint8(3)); | |
| if (magic !== 'P4S3') throw new Error('not a mesh3d file'); | |
| var f32 = function (o) { return dv.getFloat32(o, true); }; | |
| var h = { | |
| version: dv.getUint16(4, true), stride: dv.getUint16(6, true), n: dv.getUint32(8, true), | |
| rows: dv.getUint32(12, true), cols: dv.getUint32(16, true), | |
| mapW: dv.getUint32(20, true), mapH: dv.getUint32(24, true), | |
| lo: [f32(28), f32(32), f32(36)], hi: [f32(40), f32(44), f32(48)], | |
| flatPx: f32(52), mmPerPx: f32(56), mmPerVoxel: f32(60), | |
| inkLevel: dv.getUint16(64, true), inkWindow: dv.getUint16(66, true) | |
| }; | |
| var n = h.n, o = HEADER_BYTES; | |
| if (buf.byteLength < o + 13 * n) throw new Error('mesh3d file is cut short'); | |
| h.q = new Uint16Array(buf, o, 3 * n); | |
| h.flat = new Uint16Array(buf, o + 6 * n, 2 * n); | |
| h.wind = new Int16Array(buf, o + 10 * n, n); | |
| h.ink = new Uint8Array(buf, o + 12 * n, n); | |
| return h; | |
| } | |
| function hsv(h, s, v) { | |
| var i = Math.floor(h * 6) % 6, f = h * 6 - Math.floor(h * 6); | |
| var p = v * (1 - s), q = v * (1 - f * s), t = v * (1 - (1 - f) * s); | |
| return [[v, t, p], [q, v, p], [p, v, t], [p, q, v], [t, p, v], [v, p, q]][i]; | |
| } | |
| /* Build the arrays the shader reads: rolled position (mm, centered, y up = high scan z), flat position, order | |
| * along the sheet (0 outer end, 1 inner end), ink and a color per winding. */ | |
| function prepare(h, opts) { | |
| var n = h.n, mv = h.mmPerVoxel; | |
| var c = [0, 1, 2].map(function (a) { return (h.lo[a] + h.hi[a]) / 2; }); | |
| var sc = [0, 1, 2].map(function (a) { return (h.hi[a] - h.lo[a]) / 65535; }); | |
| var pos = new Float32Array(3 * n), flat = new Float32Array(2 * n), u = new Float32Array(n); | |
| var wcol = new Uint8Array(3 * n), mapX = new Float32Array(n), mapY = new Float32Array(n); | |
| var rows = Math.max(1, opts.rows | 0 || 1); | |
| var dx = (h.hi[0] - h.lo[0]) * mv, dy = (h.hi[1] - h.lo[1]) * mv, dz = (h.hi[2] - h.lo[2]) * mv; | |
| var height = Math.max(dz, 1e-3), diameter = Math.max(dx, dy, 1e-3); | |
| var rowLen = h.mapW / rows, gap = h.mapH * 0.25; | |
| var stripLen = (opts.stripLength || 3) * height; // the unrolled strip, scaled to fit beside the roll | |
| var s = stripLen / rowLen; // world mm per map px when flat | |
| var totalH = rows * h.mapH + (rows - 1) * gap; | |
| var wc = {}; | |
| for (var i = 0; i < n; i++) { | |
| var vx = h.lo[0] + h.q[3 * i] * sc[0], vy = h.lo[1] + h.q[3 * i + 1] * sc[1], vz = h.lo[2] + h.q[3 * i + 2] * sc[2]; | |
| pos[3 * i] = (vx - c[0]) * mv; | |
| pos[3 * i + 1] = (vz - c[2]) * mv; | |
| pos[3 * i + 2] = -(vy - c[1]) * mv; | |
| var x = h.flat[2 * i] * h.flatPx, y = h.flat[2 * i + 1] * h.flatPx; | |
| mapX[i] = x; mapY[i] = y; | |
| var r = Math.min(rows - 1, Math.floor(x / rowLen)); | |
| flat[2 * i] = (x - r * rowLen - rowLen / 2) * s; | |
| flat[2 * i + 1] = (totalH / 2 - (r * (h.mapH + gap) + y)) * s; | |
| u[i] = x / h.mapW; | |
| var w = h.wind[i], col = wc[w]; | |
| if (!col) { | |
| if (w >= 0) col = hsv((w * 0.6180339887) % 1, 0.72, 1).map(function (v) { return Math.round(v * 255); }); | |
| else if (w === -2) col = [150, 150, 150]; | |
| else col = [70, 76, 84]; | |
| wc[w] = col; | |
| } | |
| wcol[3 * i] = col[0]; wcol[3 * i + 1] = col[1]; wcol[3 * i + 2] = col[2]; | |
| } | |
| var noInk = 0; | |
| for (i = 0; i < n; i++) if (h.ink[i] === 0) noInk++; // no ink data at these nodes | |
| var gridStep = h.stride * 2; // full mesh grid steps between nodes | |
| var index = new Int32Array(h.rows * h.cols).fill(-1); | |
| for (i = 0; i < n; i++) { | |
| var gi = Math.round(h.flat[2 * i + 1] / gridStep), gj = Math.round(h.flat[2 * i] / gridStep); | |
| if (gi < h.rows && gj < h.cols) index[gi * h.cols + gj] = i; | |
| } | |
| return { | |
| n: n, noInk: noInk, pos: pos, flat: flat, u: u, wcol: wcol, mapX: mapX, mapY: mapY, index: index, | |
| height: height, diameter: diameter, radius3d: 0.5 * Math.sqrt(dx * dx + dy * dy + dz * dz), | |
| stripLen: stripLen, stripH: totalH * s, scale: s, rowLen: rowLen, rows: rows, gap: gap, totalH: totalH, | |
| spacing3d: gridStep * h.flatPx * h.mmPerPx, // mm between nodes along the sheet | |
| spacingFlat: gridStep * h.flatPx * s, | |
| gridStep: gridStep | |
| }; | |
| } | |
| /* ------------------------------------------------------------------ shaders */ | |
| var MORPH = [ | |
| 'attribute vec2 aFlat;', | |
| 'attribute float aU;', | |
| 'uniform float uT;', | |
| 'uniform float uSpread;', | |
| 'float localT() {', | |
| ' float lt = clamp(uT * (1.0 + uSpread) - aU * uSpread, 0.0, 1.0);', | |
| ' return lt * lt * (3.0 - 2.0 * lt);', | |
| '}' | |
| ].join('\n'); | |
| var VERT = MORPH + '\n' + [ | |
| 'attribute float aInk;', | |
| 'attribute vec3 aWcol;', | |
| 'uniform float uSize, uProj, uS3, uSF, uMode, uMinPx, uMaxPx, uGamma, uNear, uRange;', | |
| 'uniform vec3 uC0, uC1, uC2, uC3, uCnd;', | |
| 'varying vec3 vColor;', | |
| 'vec3 ramp(float v) {', | |
| ' if (v < 0.33) return mix(uC0, uC1, v / 0.33);', | |
| ' if (v < 0.7) return mix(uC1, uC2, (v - 0.33) / 0.37);', | |
| ' return mix(uC2, uC3, (v - 0.7) / 0.3);', | |
| '}', | |
| 'void main() {', | |
| ' float lt = localT();', | |
| ' vec3 p = mix(position, vec3(aFlat, 0.0), lt);', | |
| ' vec4 mv = modelViewMatrix * vec4(p, 1.0);', | |
| ' gl_Position = projectionMatrix * mv;', | |
| ' float sp = mix(uS3, uSF, lt);', | |
| // ink 0 is where the map has no ink data: a faint fine dot in ink mode, so the turns behind show through | |
| ' float nd = (aInk < 0.5 / 255.0 ? 1.0 : 0.0) * (1.0 - uMode);', | |
| ' gl_PointSize = mix(clamp(uSize * sp * uProj / max(-mv.z, 1e-4), uMinPx, uMaxPx), uMinPx * 1.6, nd);', | |
| ' vec3 c = mix(mix(ramp(pow(aInk, uGamma)), uCnd, nd), aWcol, uMode);', | |
| ' float d = clamp((-mv.z - uNear) / uRange, 0.0, 1.0);', | |
| ' c *= mix(1.0, mix(1.0, 0.42, d), 1.0 - lt);', | |
| ' vColor = c;', | |
| '}' | |
| ].join('\n'); | |
| var FRAG = [ | |
| 'varying vec3 vColor;', | |
| 'void main() {', | |
| ' vec2 q = gl_PointCoord - 0.5;', | |
| ' if (dot(q, q) > 0.25) discard;', | |
| ' gl_FragColor = vec4(vColor, 1.0);', | |
| '}' | |
| ].join('\n'); | |
| var MARK_VERT = MORPH + '\n' + [ | |
| 'uniform float uPx;', | |
| 'void main() {', | |
| ' vec3 p = mix(position, vec3(aFlat, 0.0), localT());', | |
| ' gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);', | |
| ' gl_PointSize = uPx;', | |
| '}' | |
| ].join('\n'); | |
| var MARK_FRAG = [ | |
| 'uniform vec3 uColor;', | |
| 'void main() {', | |
| ' float r = length(gl_PointCoord - 0.5) * 2.0;', | |
| ' if (r > 1.0) discard;', | |
| ' float ring = smoothstep(0.62, 0.72, r) * (1.0 - smoothstep(0.9, 1.0, r));', | |
| ' float dot_ = 1.0 - smoothstep(0.18, 0.26, r);', | |
| ' float a = max(ring, dot_);', | |
| ' if (a < 0.05) discard;', | |
| ' gl_FragColor = vec4(uColor, a);', | |
| '}' | |
| ].join('\n'); | |
| /* ------------------------------------------------------------------ page parts */ | |
| var CSS = [ | |
| '.s3d{position:relative;width:100%;height:100%;min-height:360px;overflow:hidden;border-radius:inherit;', | |
| 'background:var(--bg,#05070a);color:var(--text,#e6e9ee);font:13px/1.4 var(--sans,Inter,system-ui,sans-serif)}', | |
| '.s3d canvas{display:block;position:absolute;inset:0;width:100%;height:100%;cursor:grab;touch-action:none;outline:none}', | |
| '.s3d canvas.s3d-drag{cursor:grabbing}', | |
| '.s3d-head{position:absolute;left:14px;top:10px;right:14px;display:flex;gap:10px;align-items:baseline;pointer-events:none}', | |
| '.s3d-head h3{margin:0;font:400 19px/1.2 var(--serif,Georgia,serif);color:var(--text,#e6e9ee);letter-spacing:0;text-transform:none}', | |
| '.s3d-status{color:var(--dim,#8b96a5);font-size:12px}', | |
| '.s3d-bar{position:absolute;left:10px;right:10px;bottom:10px;display:flex;flex-wrap:wrap;gap:8px 12px;align-items:center;', | |
| 'padding:8px 10px;border:1px solid var(--line,#1c2530);border-radius:10px;background:rgba(11,15,20,.84);', | |
| '-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px)}', | |
| '.s3d-bar label{display:flex;align-items:center;gap:8px;color:var(--dim,#8b96a5);flex:1 1 180px;min-width:150px}', | |
| '.s3d-bar input[type=range]{flex:1;min-width:80px;accent-color:var(--amber,#f4b860)}', | |
| '.s3d-btn{padding:5px 11px;border-radius:8px;border:1px solid var(--line,#1c2530);background:var(--panel2,#10161d);', | |
| 'color:var(--text,#e6e9ee);font:500 12.5px var(--sans,Inter,system-ui,sans-serif);cursor:pointer;white-space:nowrap}', | |
| '.s3d-btn:hover{border-color:#33404f;background:#151d26}', | |
| '.s3d-btn.s3d-play{background:#2a2010;border-color:#4a3614;color:var(--amber2,#ffd89a);min-width:64px}', | |
| '.s3d-seg{display:inline-flex;border:1px solid var(--line,#1c2530);border-radius:8px;overflow:hidden}', | |
| '.s3d-seg .s3d-btn{border:0;border-radius:0}', | |
| '.s3d-seg .s3d-btn[aria-pressed=true]{background:#2a2010;color:var(--amber2,#ffd89a)}', | |
| '.s3d-key{display:flex;align-items:center;gap:6px;color:var(--dim,#8b96a5);font-size:11.5px}', | |
| '.s3d-ramp{width:64px;height:8px;border-radius:4px;', | |
| 'background:linear-gradient(90deg,#3b424b 0%,#6e4c1f 33%,#f4b860 70%,#ffd89a 100%)}', | |
| '.s3d-nd{width:4px;height:4px;margin-left:8px;border-radius:50%;background:#5f7f9f;box-shadow:5px 0 #5f7f9f,10px 0 #5f7f9f;margin-right:10px}', | |
| '.s3d-hues{background:linear-gradient(90deg,#ff6b47,#ffd447,#73ff47,#47ffd0,#4782ff,#c847ff,#ff4796)}', | |
| '.s3d-hint{position:absolute;right:14px;top:12px;color:var(--dim,#8b96a5);font-size:11.5px;text-align:right;', | |
| 'max-width:46%;pointer-events:none}', | |
| '.s3d-pick{color:var(--cyan,#7fd6ff);font:12px var(--mono,ui-monospace,monospace)}', | |
| '@media (max-width:640px){.s3d-hint{display:none}.s3d-key{display:none}}' | |
| ].join(''); | |
| function addStyle() { | |
| if (document.getElementById('s3d-style')) return; | |
| var st = document.createElement('style'); | |
| st.id = 's3d-style'; | |
| st.textContent = CSS; | |
| document.head.appendChild(st); | |
| } | |
| function el(tag, cls, text) { | |
| var e = document.createElement(tag); | |
| if (cls) e.className = cls; | |
| if (text != null) e.textContent = text; | |
| return e; | |
| } | |
| function fmt(v) { return Math.round(v).toLocaleString('en-US'); } | |
| /* ------------------------------------------------------------------ mount */ | |
| function mount(host, opts) { | |
| opts = opts || {}; | |
| addStyle(); | |
| var root = el('div', 's3d'); | |
| var canvas = el('canvas'); | |
| canvas.tabIndex = 0; | |
| canvas.setAttribute('aria-label', '3D view of the scroll; drag to turn, scroll to zoom'); | |
| var head = el('div', 's3d-head'); | |
| if (opts.title !== false) head.appendChild(el('h3', null, opts.title || '3D Scroll')); | |
| var status = el('span', 's3d-status', 'Loading the mesh...'); | |
| head.appendChild(status); | |
| var hint = el('div', 's3d-hint', | |
| 'Drag to turn, scroll to zoom, right-drag or Shift-drag to pan. Click a point to open it in the map.'); | |
| var bar = el('div', 's3d-bar'); | |
| var lab = el('label', null, 'Unroll'); | |
| var slider = el('input'); | |
| slider.type = 'range'; slider.min = '0'; slider.max = '100'; slider.step = '0.1'; slider.value = '0'; | |
| slider.setAttribute('aria-label', 'Unroll'); | |
| lab.appendChild(slider); | |
| var play = el('button', 's3d-btn s3d-play', 'Play'); | |
| play.type = 'button'; | |
| var seg = el('div', 's3d-seg'); | |
| seg.setAttribute('role', 'group'); | |
| seg.setAttribute('aria-label', 'Color'); | |
| var bInk = el('button', 's3d-btn', 'Ink'), bWind = el('button', 's3d-btn', 'Windings'); | |
| bInk.type = bWind.type = 'button'; | |
| seg.appendChild(bInk); seg.appendChild(bWind); | |
| var reset = el('button', 's3d-btn', 'Reset view'); | |
| reset.type = 'button'; | |
| var key = el('div', 's3d-key'); | |
| var keyBar = el('span', 's3d-ramp'), keyText = el('span', null, 'Ink, low to high'); | |
| var keyNd = el('span', 's3d-nd'), keyNdText = el('span', null, 'no ink data'); | |
| key.appendChild(keyBar); key.appendChild(keyText); key.appendChild(keyNd); key.appendChild(keyNdText); | |
| var picked = el('span', 's3d-pick'); | |
| bar.appendChild(lab); bar.appendChild(play); bar.appendChild(seg); bar.appendChild(reset); | |
| bar.appendChild(key); bar.appendChild(picked); | |
| root.appendChild(canvas); root.appendChild(head); root.appendChild(hint); root.appendChild(bar); | |
| host.appendChild(root); | |
| var api = { | |
| el: root, ready: null, t: 0, | |
| setT: function () {}, setMode: function () {}, setCursor: function () {}, play: function () {}, | |
| stop: function () {}, resetView: function () {}, destroy: function () {} | |
| }; | |
| var pendingCursor = null, pendingT = null, pendingMode = null; | |
| api.setCursor = function (p) { pendingCursor = p; }; | |
| api.setT = function (t) { pendingT = t; }; | |
| api.setMode = function (m) { pendingMode = m; }; | |
| var dead = false; | |
| api.destroy = function () { dead = true; root.remove(); }; | |
| last = api; | |
| function getData() { | |
| if (opts.data) return Promise.resolve(opts.data); | |
| var url = opts.url || ((opts.base || '') + '/api/mesh3d.bin?stride=' + (opts.stride || 8)); | |
| var tries = 0; | |
| return new Promise(function (resolve, reject) { | |
| (function go() { | |
| fetch(url).then(function (r) { | |
| if (r.status === 503 && tries++ < 200 && !dead) { | |
| status.textContent = 'The map is still loading on the server...'; | |
| setTimeout(go, opts.retryMs || 3000); | |
| return; | |
| } | |
| if (!r.ok) throw new Error('mesh request failed (' + r.status + ')'); | |
| return r.arrayBuffer().then(resolve); | |
| }).catch(reject); | |
| })(); | |
| }); | |
| } | |
| api.ready = Promise.all([loadThree(opts.threeUrl), getData()]).then(function (res) { | |
| if (dead) return api; | |
| start(res[0], parse(res[1])); | |
| return api; | |
| }).catch(function (err) { | |
| status.textContent = 'Could not show the 3D view: ' + (err && err.message || err); | |
| throw err; | |
| }); | |
| function start(THREE, h) { | |
| var G = prepare(h, opts); | |
| var renderer; | |
| try { | |
| renderer = new THREE.WebGLRenderer({ canvas: canvas, antialias: true, powerPreference: 'high-performance' }); | |
| } catch (e) { | |
| status.textContent = 'The 3D view needs WebGL, which this browser has turned off.'; | |
| return; | |
| } | |
| var cs = getComputedStyle(root); | |
| renderer.setClearColor(new THREE.Color((cs.backgroundColor && cs.backgroundColor !== 'rgba(0, 0, 0, 0)') ? cs.backgroundColor : '#05070a'), 1); | |
| var dpr = 1; | |
| var scene = new THREE.Scene(); | |
| var camera = new THREE.PerspectiveCamera(40, 1, 0.1, 1e5); | |
| var geo = new THREE.BufferGeometry(); | |
| geo.setAttribute('position', new THREE.BufferAttribute(G.pos, 3)); | |
| geo.setAttribute('aFlat', new THREE.BufferAttribute(G.flat, 2)); | |
| geo.setAttribute('aU', new THREE.BufferAttribute(G.u, 1)); | |
| geo.setAttribute('aInk', new THREE.BufferAttribute(h.ink, 1, true)); | |
| geo.setAttribute('aWcol', new THREE.BufferAttribute(G.wcol, 3, true)); | |
| var col = function (s) { return new THREE.Color(s); }; | |
| var U = { | |
| uT: { value: 0 }, uSpread: { value: opts.spread == null ? 0.7 : opts.spread }, | |
| uSize: { value: opts.pointSize || 1.5 }, uProj: { value: 1 }, | |
| uS3: { value: G.spacing3d }, uSF: { value: G.spacingFlat }, uMode: { value: 0 }, | |
| uMinPx: { value: 1 }, uMaxPx: { value: 48 }, uGamma: { value: opts.gamma || 1 }, | |
| uNear: { value: 0 }, uRange: { value: 1 }, | |
| uC0: { value: col('#3b424b') }, uC1: { value: col('#6e4c1f') }, uC2: { value: col('#f4b860') }, | |
| uC3: { value: col('#ffd89a') }, uCnd: { value: col('#5f7f9f') } | |
| }; | |
| var mat = new THREE.ShaderMaterial({ uniforms: U, vertexShader: VERT, fragmentShader: FRAG }); | |
| var points = new THREE.Points(geo, mat); | |
| points.frustumCulled = false; | |
| scene.add(points); | |
| // the cursor marker: one point that morphs with the cloud, drawn on top | |
| var mgeo = new THREE.BufferGeometry(); | |
| var mPos = new Float32Array(3), mFlat = new Float32Array(2), mU = new Float32Array(1); | |
| mgeo.setAttribute('position', new THREE.BufferAttribute(mPos, 3)); | |
| mgeo.setAttribute('aFlat', new THREE.BufferAttribute(mFlat, 2)); | |
| mgeo.setAttribute('aU', new THREE.BufferAttribute(mU, 1)); | |
| var MU = { uT: U.uT, uSpread: U.uSpread, uPx: { value: 22 }, uColor: { value: col('#7fd6ff') } }; | |
| var mmat = new THREE.ShaderMaterial({ uniforms: MU, vertexShader: MARK_VERT, fragmentShader: MARK_FRAG, | |
| transparent: true, depthTest: false, depthWrite: false }); | |
| var marker = new THREE.Points(mgeo, mmat); | |
| marker.frustumCulled = false; | |
| marker.renderOrder = 10; | |
| marker.visible = false; | |
| scene.add(marker); | |
| status.textContent = fmt(G.n) + ' points, one every ' + G.spacing3d.toFixed(1) + ' mm of sheet' + | |
| (G.noInk ? '; ' + fmt(G.noInk) + ' have no ink data' : ''); | |
| /* ---------------- camera: a small orbit controller with eased framing */ | |
| var cam = { target: new THREE.Vector3(), theta: 0.6, phi: 1.25, radius: 300 }; | |
| var goal = null, userMoved = false; | |
| var W = 1, H = 1, Heff = 1; | |
| function vfov() { return camera.fov * Math.PI / 180; } | |
| function hfov() { return 2 * Math.atan(Math.tan(vfov() / 2) * camera.aspect); } | |
| function frameFor(t) { | |
| var e = t * t * (3 - 2 * t); | |
| var tv = Math.tan(vfov() / 2) * Heff / H, th = Math.tan(hfov() / 2) * 0.96; // the free band between head and bar | |
| var d0 = Math.max((G.height / 2) / tv, (G.diameter / 2) / th) * 1.04 + G.diameter / 2; | |
| var d1 = Math.max((G.stripLen / 2) / th, (G.stripH / 2) / tv) * 1.04; | |
| return { | |
| target: new THREE.Vector3(0, 0, 0), theta: 0.6 * (1 - e), phi: 1.25 + (Math.PI / 2 - 1.25) * e, | |
| radius: Math.exp(Math.log(d0) * (1 - e) + Math.log(d1) * e) | |
| }; | |
| } | |
| function cloudRadius(t) { | |
| var e = t * t * (3 - 2 * t); | |
| return G.radius3d * (1 - e) + (G.stripLen / 2) * e; | |
| } | |
| function applyCamera() { | |
| var sp = Math.sin(cam.phi); | |
| camera.position.set(cam.target.x + cam.radius * sp * Math.sin(cam.theta), | |
| cam.target.y + cam.radius * Math.cos(cam.phi), | |
| cam.target.z + cam.radius * sp * Math.cos(cam.theta)); | |
| camera.up.set(0, 1, 0); | |
| camera.lookAt(cam.target); | |
| camera.near = Math.max(cam.radius * 0.005, 0.01); | |
| camera.far = cam.radius * 50 + G.stripLen * 4; | |
| camera.updateProjectionMatrix(); | |
| camera.updateMatrixWorld(); | |
| var R = cloudRadius(api.t); | |
| U.uNear.value = cam.radius - R; | |
| U.uRange.value = 2 * R; | |
| } | |
| function snap(f) { | |
| cam.target.copy(f.target); cam.theta = f.theta; cam.phi = f.phi; cam.radius = f.radius; | |
| } | |
| function aim(instant) { | |
| userMoved = false; | |
| goal = frameFor(api.t); | |
| if (instant) { snap(goal); goal = null; } | |
| dirty = true; kick(); | |
| } | |
| function easeCamera(dt) { | |
| if (!goal) return false; | |
| var k = 1 - Math.exp(-dt * 5); | |
| cam.target.lerp(goal.target, k); | |
| var dth = Math.atan2(Math.sin(goal.theta - cam.theta), Math.cos(goal.theta - cam.theta)); | |
| cam.theta += dth * k; | |
| cam.phi += (goal.phi - cam.phi) * k; | |
| cam.radius = Math.exp(Math.log(cam.radius) + (Math.log(goal.radius) - Math.log(cam.radius)) * k); | |
| if (Math.abs(dth) < 1e-4 && Math.abs(goal.phi - cam.phi) < 1e-4 && | |
| Math.abs(goal.radius / cam.radius - 1) < 1e-4 && cam.target.distanceTo(goal.target) < cam.radius * 1e-4) { | |
| snap(goal); goal = null; | |
| } | |
| return true; | |
| } | |
| /* ---------------- size, visibility and the frame loop */ | |
| var dirty = true, raf = 0, visible = true, lastTime = 0; | |
| var playing = false, playDir = 1, hold = 0; | |
| function resize() { | |
| var r = root.getBoundingClientRect(); | |
| W = Math.max(1, Math.round(r.width)); H = Math.max(1, Math.round(r.height)); | |
| dpr = Math.min(window.devicePixelRatio || 1, 2); | |
| renderer.setPixelRatio(dpr); | |
| renderer.setSize(W, H, false); | |
| camera.aspect = W / H; | |
| // frame the scroll in the band between the heading and the control bar: shift the projection center | |
| var padTop = head.offsetHeight + 16, padBottom = bar.offsetHeight + 22; | |
| Heff = Math.max(H - padTop - padBottom, H * 0.4); | |
| camera.setViewOffset(W, H, 0, Math.round((padBottom - padTop) / 2), W, H); | |
| camera.updateProjectionMatrix(); | |
| U.uProj.value = (H * dpr) / (2 * Math.tan(vfov() / 2)); | |
| U.uMinPx.value = 1.2 * dpr; | |
| U.uMaxPx.value = 48 * dpr; | |
| MU.uPx.value = 22 * dpr; | |
| if (!userMoved) { goal = frameFor(api.t); snap(goal); goal = null; } | |
| dirty = true; kick(); | |
| } | |
| function frame(now) { | |
| raf = 0; | |
| if (dead || !visible) return; | |
| var dt = lastTime ? Math.min((now - lastTime) / 1000, 0.1) : 1 / 60; | |
| lastTime = now; | |
| var busy = false; | |
| if (playing) { | |
| busy = true; | |
| if (hold > 0) hold -= dt; | |
| else { | |
| var t = api.t + playDir * dt / (opts.playSeconds || 5); | |
| if (t >= 1) { t = 1; playDir = -1; hold = 1.2; } | |
| if (t <= 0 && playDir < 0) { t = 0; stopPlay(); } | |
| setT(t, false, true); | |
| } | |
| } | |
| if (easeCamera(dt)) busy = true; | |
| if (dirty || busy) { | |
| applyCamera(); | |
| renderer.render(scene, camera); | |
| dirty = false; | |
| } | |
| if (busy) kick(); else lastTime = 0; | |
| } | |
| function kick() { if (!raf && visible && !dead) raf = requestAnimationFrame(frame); } | |
| function setT(t, instant, fromPlay) { | |
| t = Math.min(1, Math.max(0, +t || 0)); | |
| api.t = t; | |
| U.uT.value = t; | |
| slider.value = String(Math.round(t * 1000) / 10); | |
| if (!fromPlay && playing) stopPlay(); | |
| aim(instant); | |
| } | |
| function setMode(m) { | |
| var w = m === 'winding' || m === 'windings' || m === 'wind'; | |
| U.uMode.value = w ? 1 : 0; | |
| bInk.setAttribute('aria-pressed', String(!w)); | |
| bWind.setAttribute('aria-pressed', String(w)); | |
| keyBar.className = w ? 's3d-ramp s3d-hues' : 's3d-ramp'; | |
| keyText.textContent = w ? 'One hue per winding' : 'Ink, low to high'; | |
| keyNd.style.display = keyNdText.style.display = w ? 'none' : ''; | |
| dirty = true; kick(); | |
| } | |
| function startPlay() { | |
| playing = true; playDir = api.t >= 1 ? -1 : 1; hold = 0; | |
| play.textContent = 'Pause'; | |
| lastTime = 0; kick(); | |
| } | |
| function stopPlay() { playing = false; play.textContent = 'Play'; } | |
| /* ---------------- cursor and picking */ | |
| function nearestNode(x, y) { | |
| var gi = Math.round(y / h.flatPx / G.gridStep), gj = Math.round(x / h.flatPx / G.gridStep); | |
| for (var r = 0; r <= 6; r++) { | |
| var best = -1, bd = Infinity; | |
| for (var di = -r; di <= r; di++) { | |
| for (var dj = -r; dj <= r; dj++) { | |
| if (Math.max(Math.abs(di), Math.abs(dj)) !== r) continue; | |
| var i = gi + di, j = gj + dj; | |
| if (i < 0 || j < 0 || i >= h.rows || j >= h.cols) continue; | |
| var k = G.index[i * h.cols + j]; | |
| if (k < 0) continue; | |
| var d = (G.mapX[k] - x) * (G.mapX[k] - x) + (G.mapY[k] - y) * (G.mapY[k] - y); | |
| if (d < bd) { bd = d; best = k; } | |
| } | |
| } | |
| if (best >= 0) return best; | |
| } | |
| var b = -1, bb = Infinity; | |
| for (var q = 0; q < G.n; q++) { | |
| var e = (G.mapX[q] - x) * (G.mapX[q] - x) + (G.mapY[q] - y) * (G.mapY[q] - y); | |
| if (e < bb) { bb = e; b = q; } | |
| } | |
| return b; | |
| } | |
| function setCursor(p) { | |
| if (!p || !isFinite(p.x) || !isFinite(p.y) || !G.n) { marker.visible = false; dirty = true; kick(); return; } | |
| var k = nearestNode(p.x, p.y); | |
| if (k < 0) { marker.visible = false; dirty = true; kick(); return; } | |
| mPos[0] = G.pos[3 * k]; mPos[1] = G.pos[3 * k + 1]; mPos[2] = G.pos[3 * k + 2]; | |
| var r = Math.min(G.rows - 1, Math.max(0, Math.floor(p.x / G.rowLen))); | |
| mFlat[0] = (p.x - r * G.rowLen - G.rowLen / 2) * G.scale; | |
| mFlat[1] = (G.totalH / 2 - (r * (h.mapH + G.gap) + p.y)) * G.scale; | |
| mU[0] = p.x / h.mapW; | |
| mgeo.attributes.position.needsUpdate = true; | |
| mgeo.attributes.aFlat.needsUpdate = true; | |
| mgeo.attributes.aU.needsUpdate = true; | |
| marker.visible = true; | |
| dirty = true; kick(); | |
| } | |
| function pickAt(cx, cy) { | |
| applyCamera(); | |
| var m = new THREE.Matrix4().multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse).elements; | |
| var t = U.uT.value, sp = U.uSpread.value, best = -1, bestZ = Infinity; | |
| var proj = U.uProj.value, size = U.uSize.value, lo = U.uMinPx.value, hi = U.uMaxPx.value; | |
| for (var i = 0; i < G.n; i++) { | |
| var lt = Math.min(1, Math.max(0, t * (1 + sp) - G.u[i] * sp)); | |
| lt = lt * lt * (3 - 2 * lt); | |
| var x = G.pos[3 * i] + (G.flat[2 * i] - G.pos[3 * i]) * lt; | |
| var y = G.pos[3 * i + 1] + (G.flat[2 * i + 1] - G.pos[3 * i + 1]) * lt; | |
| var z = G.pos[3 * i + 2] * (1 - lt); | |
| var w = m[3] * x + m[7] * y + m[11] * z + m[15]; | |
| if (w <= 0) continue; | |
| var sx = ((m[0] * x + m[4] * y + m[8] * z + m[12]) / w * 0.5 + 0.5) * W; | |
| var sy = (0.5 - (m[1] * x + m[5] * y + m[9] * z + m[13]) / w * 0.5) * H; | |
| var px = Math.min(hi, Math.max(lo, size * (G.spacing3d + (G.spacingFlat - G.spacing3d) * lt) * proj / w)) / dpr; | |
| var rr = Math.max(px / 2, 6); | |
| var dx = sx - cx, dy = sy - cy; | |
| if (dx * dx + dy * dy > rr * rr) continue; | |
| var zz = (m[2] * x + m[6] * y + m[10] * z + m[14]) / w; | |
| if (zz < bestZ) { bestZ = zz; best = i; } | |
| } | |
| return best; | |
| } | |
| /* ---------------- pointer, wheel, touch and keys */ | |
| var pointers = {}, drag = null; | |
| function count() { return Object.keys(pointers).length; } | |
| function panBy(dx, dy) { | |
| var per = 2 * cam.radius * Math.tan(vfov() / 2) / H; | |
| var right = new THREE.Vector3().setFromMatrixColumn(camera.matrixWorld, 0); | |
| var up = new THREE.Vector3().setFromMatrixColumn(camera.matrixWorld, 1); | |
| cam.target.addScaledVector(right, -dx * per).addScaledVector(up, dy * per); | |
| } | |
| function rotateBy(dx, dy) { | |
| cam.theta -= dx * 2 * Math.PI / H * 0.6; | |
| cam.phi = Math.min(Math.PI - 0.02, Math.max(0.02, cam.phi - dy * Math.PI / H * 0.9)); | |
| } | |
| function zoomAt(factor, cx, cy) { | |
| applyCamera(); | |
| var nr = Math.min(Math.max(cam.radius * factor, G.spacingFlat * 4), frameFor(1).radius * 6 + frameFor(0).radius * 6); | |
| var k = nr / cam.radius; | |
| // keep the point under the cursor fixed, on the plane through the target facing the camera | |
| var ndc = new THREE.Vector3(cx / W * 2 - 1, 1 - cy / H * 2, 0.5).unproject(camera); | |
| var dir = ndc.sub(camera.position).normalize(); | |
| var n = new THREE.Vector3().subVectors(cam.target, camera.position).normalize(); | |
| var denom = dir.dot(n); | |
| if (denom > 1e-6) { | |
| var dist = new THREE.Vector3().subVectors(cam.target, camera.position).dot(n) / denom; | |
| var P = camera.position.clone().addScaledVector(dir, dist); | |
| cam.target.sub(P).multiplyScalar(k).add(P); | |
| } | |
| cam.radius = nr; | |
| } | |
| function userMove() { userMoved = true; goal = null; dirty = true; kick(); } | |
| function local(e) { | |
| var r = canvas.getBoundingClientRect(); | |
| return [e.clientX - r.left, e.clientY - r.top]; | |
| } | |
| canvas.addEventListener('contextmenu', function (e) { e.preventDefault(); }); | |
| canvas.addEventListener('pointerdown', function (e) { | |
| canvas.focus({ preventScroll: true }); | |
| try { canvas.setPointerCapture(e.pointerId); } catch (err) { /* old browsers */ } | |
| var p = local(e); | |
| pointers[e.pointerId] = p; | |
| if (count() === 1) { | |
| drag = { x: p[0], y: p[1], sx: p[0], sy: p[1], time: performance.now(), moved: 0, | |
| pan: e.button === 2 || e.shiftKey || e.button === 1, button: e.button }; | |
| } else { | |
| drag = null; | |
| } | |
| canvas.classList.add('s3d-drag'); | |
| }); | |
| canvas.addEventListener('pointermove', function (e) { | |
| if (!(e.pointerId in pointers)) return; | |
| var p = local(e); | |
| var prev = pointers[e.pointerId]; | |
| if (count() >= 2) { | |
| var ids = Object.keys(pointers); | |
| var a = pointers[ids[0]], b = pointers[ids[1]]; | |
| var d0 = Math.hypot(a[0] - b[0], a[1] - b[1]), m0 = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]; | |
| pointers[e.pointerId] = p; | |
| a = pointers[ids[0]]; b = pointers[ids[1]]; | |
| var d1 = Math.hypot(a[0] - b[0], a[1] - b[1]), m1 = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]; | |
| if (d0 > 0 && d1 > 0) zoomAt(d0 / d1, m1[0], m1[1]); | |
| panBy(m1[0] - m0[0], m1[1] - m0[1]); | |
| userMove(); | |
| return; | |
| } | |
| pointers[e.pointerId] = p; | |
| if (!drag) return; | |
| var dx = p[0] - prev[0], dy = p[1] - prev[1]; | |
| drag.moved = Math.max(drag.moved, Math.hypot(p[0] - drag.sx, p[1] - drag.sy)); | |
| if (drag.moved < 3) return; | |
| if (drag.pan || e.shiftKey) panBy(dx, dy); else rotateBy(dx, dy); | |
| userMove(); | |
| }); | |
| function up(e) { | |
| if (!(e.pointerId in pointers)) return; | |
| var p = local(e); | |
| delete pointers[e.pointerId]; | |
| if (drag && count() === 0 && drag.moved < 5 && drag.button === 0 && !drag.pan && | |
| performance.now() - drag.time < 700 && e.type === 'pointerup') { | |
| var k = pickAt(p[0], p[1]); | |
| if (k >= 0) { | |
| var hit = { x: G.mapX[k], y: G.mapY[k], winding: h.wind[k] >= 0 ? h.wind[k] : null, ink: h.ink[k] }; | |
| picked.textContent = 'x ' + fmt(hit.x) + ', y ' + fmt(hit.y) + | |
| (hit.winding != null ? ', winding ' + hit.winding : ''); | |
| setCursor(hit); | |
| if (typeof opts.onPick === 'function') opts.onPick(hit); | |
| } | |
| } | |
| if (count() === 0) { drag = null; canvas.classList.remove('s3d-drag'); } | |
| } | |
| canvas.addEventListener('pointerup', up); | |
| canvas.addEventListener('pointercancel', up); | |
| canvas.addEventListener('wheel', function (e) { | |
| e.preventDefault(); | |
| var dy = e.deltaY * (e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? H : 1); | |
| var p = local(e); | |
| zoomAt(Math.exp(Math.max(-1, Math.min(1, dy * 0.0015))), p[0], p[1]); | |
| userMove(); | |
| }, { passive: false }); | |
| canvas.addEventListener('keydown', function (e) { | |
| var k = e.key, s = 24; | |
| if (k === 'ArrowLeft') rotateBy(-s, 0); | |
| else if (k === 'ArrowRight') rotateBy(s, 0); | |
| else if (k === 'ArrowUp') rotateBy(0, -s); | |
| else if (k === 'ArrowDown') rotateBy(0, s); | |
| else if (k === '+' || k === '=') zoomAt(0.85, W / 2, H / 2); | |
| else if (k === '-' || k === '_') zoomAt(1 / 0.85, W / 2, H / 2); | |
| else return; | |
| e.preventDefault(); | |
| userMove(); | |
| }); | |
| /* ---------------- controls */ | |
| slider.addEventListener('input', function () { setT(slider.value / 100); }); | |
| play.addEventListener('click', function () { if (playing) stopPlay(); else startPlay(); }); | |
| bInk.addEventListener('click', function () { setMode('ink'); }); | |
| bWind.addEventListener('click', function () { setMode('winding'); }); | |
| reset.addEventListener('click', function () { aim(false); }); | |
| var ro = window.ResizeObserver ? new ResizeObserver(resize) : null; | |
| if (ro) ro.observe(root); else window.addEventListener('resize', resize); | |
| var io = window.IntersectionObserver ? new IntersectionObserver(function (es) { | |
| visible = es[es.length - 1].isIntersecting; | |
| if (visible) { dirty = true; lastTime = 0; kick(); } | |
| }) : null; | |
| if (io) io.observe(root); | |
| api.setT = function (t, instant) { setT(t, instant); }; | |
| api.setMode = setMode; | |
| api.setCursor = setCursor; | |
| api.play = startPlay; | |
| api.stop = stopPlay; | |
| api.resetView = function (instant) { aim(instant); }; | |
| api.render = function () { applyCamera(); renderer.render(scene, camera); }; | |
| api.info = function () { return { nodes: G.n, stride: h.stride, spacingMm: G.spacing3d, header: h }; }; | |
| api.destroy = function () { | |
| dead = true; | |
| if (raf) cancelAnimationFrame(raf); | |
| if (ro) ro.disconnect(); else window.removeEventListener('resize', resize); | |
| if (io) io.disconnect(); | |
| geo.dispose(); mat.dispose(); mgeo.dispose(); mmat.dispose(); renderer.dispose(); | |
| root.remove(); | |
| if (last === api) last = null; | |
| }; | |
| setMode(pendingMode || opts.mode || 'ink'); | |
| resize(); | |
| setT(pendingT != null ? pendingT : (opts.t || 0), true); | |
| if (pendingCursor) setCursor(pendingCursor); | |
| } | |
| return api; | |
| } | |
| window.Scroll3D = { | |
| mount: mount, | |
| parse: parse, | |
| loadThree: loadThree, | |
| setCursor: function (p) { if (last) last.setCursor(p); } | |
| }; | |
| })(); | |