/* 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); } }; })();