paris4-ink-map / static /scroll3d.js
SharpMeow's picture
3D view: draw nodes with no ink data as fine dots, not a gray slab
f521c52 verified
Raw History Blame Contribute Delete
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); }
};
})();