// Umbilici: the center lines of the prize-eligible Herculaneum scans and the other public scans, over live X-ray CT. // Apache 2.0, Chase Hendrick. CT is read in the visitor's browser from the Vesuvius Challenge open-data bucket. import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js'; import { RenderPass } from 'three/addons/postprocessing/RenderPass.js'; import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js'; import { OutputPass } from 'three/addons/postprocessing/OutputPass.js'; const VERSION = '1.12.0'; const DATASET = 'https://huggingface.co/datasets/SharpMeow/vesuvius-center-lines'; const LASAGNA_SCROLL = 'PHerc0175A'; const ICE = new THREE.Color('#89f0ff'); const AMBER = new THREE.Color('#ffb347'); // one color per maker, so two people's lines on one scan never share a color (Chase, 24 Oct 2026) const SRC_HEX = { bucket: '#89f0ff', drobkov: '#ff7eb6', tauil: '#b69cff', dopico: '#f2f5f7', finder: '#ffb347', 'finder-polished': '#c6f36b', 'finder-v2': '#ff7a59' }; const SRC_OTHER = '#d8d0c0'; // a maker the dataset added after this page was built const SRC_COL = Object.fromEntries(Object.entries(SRC_HEX).map(([k, v]) => [k, new THREE.Color(v)])); const colOf = (l) => SRC_COL[l.source] || ICE; const hexOf = (l) => SRC_HEX[l.source] || '#89f0ff'; const COARSE = matchMedia('(pointer: coarse)').matches; // the Array view's opening camera: high enough that its three rows (people, Center Finder, not on the prize page) stand apart const ARRAY_CAM = [-620, 760, 2300]; const $ = (id) => document.getElementById(id); // ---------- state ---------- const S = { data: null, las: null, mode: 'array', sel: null, hot: null, ex: 3, z: null, ct: true, render: 'xray', gain: 44, sheets: true, needles: true, ct2: false, ori: false, blend: 70, dirty: true, status: '', }; // ---------- renderer ---------- const canvas = $('gl'); // No canvas antialias or preserved buffer: the composer draws into its own targets, so both only cost memory and a // copy per frame; image() reads the canvas in the same task as its render. const renderer = new THREE.WebGLRenderer({ canvas, antialias: false, preserveDrawingBuffer: false, powerPreference: 'high-performance' }); const DPR = Math.min(devicePixelRatio || 1, 2); renderer.setPixelRatio(DPR); renderer.setSize(innerWidth, innerHeight); const scene = new THREE.Scene(); scene.background = new THREE.Color('#02060a'); const camera = new THREE.PerspectiveCamera(32, innerWidth / innerHeight, 1, 20000); camera.position.set(...ARRAY_CAM).multiplyScalar(Math.max(1, 1.25 / camera.aspect)); const controls = new OrbitControls(camera, canvas); controls.enableDamping = true; controls.dampingFactor = 0.08; controls.target.set(0, 90, 0); controls.autoRotate = false; controls.autoRotateSpeed = 0.35; controls.addEventListener('change', () => { S.dirty = true; }); // While the camera moves, the X-ray takes fewer ray steps instead of dropping resolution: changing the pixel ratio // mid-drag resized the bloom buffers and made the glow pop and flicker. let moving = false, settleT = 0; let userMoved = false; controls.addEventListener('start', () => { moving = true; userMoved = true; clearTimeout(settleT); controls.autoRotate = false; applyVolUniforms(); }); controls.addEventListener('end', () => { clearTimeout(settleT); settleT = setTimeout(() => { moving = false; applyVolUniforms(); S.dirty = true; }, 150); }); const composer = new EffectComposer(renderer); composer.addPass(new RenderPass(scene, camera)); // ---------- settings (glow, quality, film), kept per viewer ---------- const SET_DEFAULT = { glow: 0.25, radius: 0.5, quality: 'auto', film: true, spin: true, inset: true, turn: false }; const SET = { ...SET_DEFAULT }; // a saved glow of 0.8 is the old default (1.11 and earlier saved every setting on any change), so it gives way to the new one try { const saved = JSON.parse(localStorage.getItem('umbilici.settings') || '{}'); if (saved.glow === 0.8) delete saved.glow; Object.assign(SET, saved); } catch { /* storage blocked */ } { const q = new URLSearchParams(location.search); if (q.has('glow')) SET.glow = Math.max(0, Math.min(3, +q.get('glow') || 0)); } // only the settings a viewer changed are kept, so a later default reaches everyone else function saveSet() { try { localStorage.setItem('umbilici.settings', JSON.stringify(Object.fromEntries(Object.entries(SET).filter(([k, v]) => v !== SET_DEFAULT[k])))); } catch { /* storage blocked */ } } // auto quality starts at 1.25 pixels per CSS pixel (1 on touch screens) and steps down, at most twice, when frames // run slow; 'high' is the screen's full density and 'low' is 0.75 let autoLevel = 0; function autoSteps() { const d = Math.min(devicePixelRatio || 1, 2); return [Math.min(d, COARSE ? 1 : 1.25), Math.min(d, 1), 0.75]; } function pixelRatio() { const d = Math.min(devicePixelRatio || 1, 2); return SET.quality === 'low' ? 0.75 : SET.quality === 'high' ? d : autoSteps()[autoLevel]; } const bloom = new UnrealBloomPass(new THREE.Vector2(innerWidth, innerHeight), SET.glow, SET.radius, 0.55); composer.addPass(bloom); composer.addPass(new OutputPass()); addEventListener('resize', () => { camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix(); renderer.setSize(innerWidth, innerHeight); composer.setSize(innerWidth, innerHeight); // the Hub sizes the Space's frame after the page loads; keep the opening view framed until the visitor moves it if (S.mode === 'array' && !userMoved) fitArray(); S.dirty = true; }); function fitArray() { const k = Math.max(1, 1.25 / camera.aspect); const a = controls.getAzimuthalAngle(); const r = Math.hypot(ARRAY_CAM[0], ARRAY_CAM[2]) * k; camera.position.set(Math.sin(a) * r, ARRAY_CAM[1] * k, Math.cos(a) * r); camera.far = 20000; camera.updateProjectionMatrix(); } // ---------- helpers ---------- const mm = (s, v) => v * s.voxel_um / 1000; const fmt = (v, d = 1) => Number(v).toFixed(d); async function getJSON(url) { const r = await fetch(url); if (!r.ok) throw new Error(`${r.status} ${url}`); return r.json(); } function linePts(l) { const p = l.points, out = []; for (let i = 0; i < p.length; i += 3) out.push([p[i], p[i + 1], p[i + 2]]); return out; } function lineAt(l, z) { const p = linePts(l); if (z < p[0][2] || z > p[p.length - 1][2]) return null; for (let i = 1; i < p.length; i++) { if (p[i][2] >= z) { const a = p[i - 1], b = p[i], t = b[2] === a[2] ? 0 : (z - a[2]) / (b[2] - a[2]); return [a[0] + t * (b[0] - a[0]), a[1] + t * (b[1] - a[1])]; } } return null; } function lineMean(l) { const p = linePts(l); let x = 0, y = 0; for (const q of p) { x += q[0]; y += q[1]; } return [x / p.length, y / p.length]; } function drift(s, l) { const [mx, my] = lineMean(l); let d = 0; for (const q of linePts(l)) d = Math.max(d, Math.hypot(q[0] - mx, q[1] - my)); return mm(s, d); } const isFinder = (l) => l.source === 'finder'; // an older version (superseded_by names the newer line's file) or a withdrawn line is drawn faded const isOld = (l) => !!l.superseded_by || l.note.includes('withdrawn'); const isNewest = (s, l) => s.lines.some((o) => o.superseded_by === l.file); // lines the visitor switched off: a whole maker (legend) or one line (scan card); kept in the URL as off= const OFF = { src: new Set(), line: new Set() }; const lineKey = (s, l) => `${s.scroll}:${l.source}`; const shown = (s, l) => !OFF.src.has(l.source) && !OFF.line.has(lineKey(s, l)); const hasFinder = (s) => s.lines.some(isFinder); function gradeOf(s) { const f = s.lines.find(isFinder); return f ? f.grade : null; } // traffic light for a Center Finder line: red when an end is flagged, yellow (caution) when the check could not judge // an end, the scan is one the check was never tuned for, or a pass rests on a CT recap; green when both ends passed function lightOf(l) { if (!l || !l.grade) return null; if (l.grade === 'FLAG') return 'bad'; const recap = l.ends && Object.values(l.ends).some((e) => e.recapped); return l.grade === 'VOID' || l.untested || recap ? 'caution' : 'good'; } const LIGHT_WORD = { good: 'both ends passed the CT end check', caution: 'caution: a CT recap, an end the check could not judge, or a scan the check was not tuned for', bad: 'an end was flagged by the CT end check' }; const onPrize = (s) => s.first_letters || s.grand_prize; function numberWords(n) { const ones = ['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten', 'eleven', 'twelve', 'thirteen', 'fourteen', 'fifteen', 'sixteen', 'seventeen', 'eighteen', 'nineteen']; const tens = ['', '', 'twenty', 'thirty', 'forty', 'fifty', 'sixty', 'seventy', 'eighty', 'ninety']; return n < 20 ? ones[n] : n < 100 ? tens[Math.floor(n / 10)] + (n % 10 ? '-' + ones[n % 10] : '') : String(n); } // the prize page's word for a scan: GP, FL, both in the card kicker, or none for a scan not on the page const prizeTag = (s) => s.grand_prize ? 'GP' : s.first_letters ? 'FL' : ''; const prizeWords = (s) => !onPrize(s) ? 'NOT ON THE PRIZE PAGE' : [s.grand_prize && 'GRAND PRIZE', s.first_letters && 'FIRST LETTERS'].filter(Boolean).join(' · '); // the pyramid level of scan s that stands in for level L of a 1.2 m (about 9 µm) scan: a 2.4 µm scan reads two levels // coarser where its pyramid has them; every pyramid here stops at level 5, so its slices are read at level 5 function lv(s, L) { return Math.min(s.pyramid.length - 1, L + Math.max(0, Math.round(Math.log2(8.64 / s.voxel_um)))); } function usable(l) { if (Array.isArray(l.usable)) return l.usable.length ? l.usable.join(', ') : 'none'; const m = /usable ends: ([a-z, ]+)/.exec(l.note || ''); return m ? m[1] : null; } // one end's grade as the dataset words it, e.g. "low PASS (CT recap)" function endText(l, k) { const e = l.ends && l.ends[k]; return e ? `${k} ${e.grade}${e.recapped ? ' (CT recap)' : ''}` : ''; } // the same, as colored HTML function endHTML(l, k) { const e = l.ends && l.ends[k]; return e ? `${k} ${e.grade}${e.recapped ? 'CT recap' : ''}` : ''; } function endsHTML(l) { return `${['low', 'high'].map((k) => endHTML(l, k)).filter(Boolean).join('')}`; } function endsText(l) { return l.ends && Object.keys(l.ends).length ? ['low', 'high'].map((k) => endText(l, k)).filter(Boolean).join(', ') : ''; } const GRADE_TEXT = { PASS: 'both ends passed the automatic CT end check', FLAG: 'the automatic CT end check flagged an end: there the line sits more than 4 mm from the center the CT shows', VOID: 'the end check could not judge this line', }; const UNTESTED = 'the CT end check was tuned on 1.2 m (about 9 µm) scans only; on this scan its grades are untested, so no end is marked usable'; // ---------- statistics (definitions: Guide, For researchers) ---------- const RAW = (l) => `${DATASET}/resolve/main/${l.file}`; // a line's license, under the key the dataset publishes (index.json, and data/lines.json built from it); kept as // published so every reader of those files keeps working. The page's own tables and labels say license. const lic = (l) => l.licence; const median = (a) => { if (!a.length) return null; const b = [...a].sort((u, v) => u - v); const k = b.length >> 1; return b.length % 2 ? b[k] : (b[k - 1] + b[k]) / 2; }; function lineStats(s, l) { const p = linePts(l), n = p.length; const dz = []; for (let i = 1; i < n; i++) dz.push(p[i][2] - p[i - 1][2]); const [mx, my] = lineMean(l); // least-squares x(z), y(z) let sz = 0, szz = 0, sx = 0, sy = 0, sxz = 0, syz = 0; for (const [x, y, z] of p) { sz += z; szz += z * z; sx += x; sy += y; sxz += x * z; syz += y * z; } const den = n * szz - sz * sz || 1; const bx = (n * sxz - sz * sx) / den, by = (n * syz - sz * sy) / den, ax = (sx - bx * sz) / n, ay = (sy - by * sz) / n; let r2 = 0, path = 0; for (let i = 0; i < n; i++) { const [x, y, z] = p[i]; r2 += (x - ax - bx * z) ** 2 + (y - ay - by * z) ** 2; if (i) path += Math.hypot(x - p[i - 1][0], y - p[i - 1][1], z - p[i - 1][2]); } const st = l.stamp || {}; const stamped = st.volume_width === s.shape[2] && st.volume_height === s.shape[1] && st.volume_slices === s.shape[0]; const z0 = p[0][2], z1 = p[n - 1][2]; return { points: n, z_start: z0, z_end: z1, height_mm: mm(s, z1 - z0), coverage: (z1 - z0) / s.shape[0], median_step_vox: median(dz), max_step_vox: dz.length ? Math.max(...dz) : null, mean_x: mx, mean_y: my, off_center_mm: mm(s, Math.hypot(mx - s.shape[2] / 2, my - s.shape[1] / 2)), stray_mm: drift(s, l), tilt_deg: Math.atan(Math.hypot(bx, by)) * 180 / Math.PI, wiggle_mm: mm(s, Math.sqrt(r2 / n)), path_mm: mm(s, path), path_over_height: path / Math.max(1, z1 - z0), stamped, }; } function pairStats(s, a, b) { const lo = Math.max(a.z[0], b.z[0]), hi = Math.min(a.z[1], b.z[1]); const g = []; for (let z = lo; z <= hi; z += 64) { const p = lineAt(a, z), q = lineAt(b, z); if (p && q) g.push(Math.hypot(p[0] - q[0], p[1] - q[1])); } if (!g.length) return null; return { a: a.source, b: b.source, overlap_mm: mm(s, hi - lo), samples: g.length, median_mm: mm(s, median(g)), min_mm: mm(s, Math.min(...g)), max_mm: mm(s, Math.max(...g)) }; } function pairsOf(s) { const out = []; for (let i = 0; i < s.lines.length; i++) for (let j = i + 1; j < s.lines.length; j++) { const q = pairStats(s, s.lines[i], s.lines[j]); if (q) out.push(q); } return out; } const STAT_CACHE = new Map(); function statsOf(s, l) { const k = s.scroll + '|' + l.file; if (!STAT_CACHE.has(k)) STAT_CACHE.set(k, lineStats(s, l)); return STAT_CACHE.get(k); } function lineRows() { return S.data.scrolls.flatMap((s) => s.lines.map((l) => { const t = statsOf(s, l); return { scroll: s.scroll, scan: s.scan, volume: s.volume, voxel_um: s.voxel_um, source: l.source, maker: S.data.sources[l.source].label, kind: l.kind, grade: l.grade || '', ct_untested: !!l.untested, ct_end_low: endText(l, 'low'), ct_end_high: endText(l, 'high'), usable_ends: isFinder(l) ? (usable(l) || '') : '', withdrawn: l.note.includes('withdrawn'), superseded: !!l.superseded_by, ...t, license: lic(l), sha256: l.sha256, file: RAW(l), upstream: l.upstream, note: l.note, }; })); } function scanRows() { return S.data.scrolls.map((s) => { const pr = pairsOf(s)[0]; return { scroll: s.scroll, scan: s.scan, volume: s.volume, voxel_um: s.voxel_um, slices: s.shape[0], height_px: s.shape[1], width_px: s.shape[2], height_mm: mm(s, s.shape[0]), width_mm: mm(s, s.shape[2]), levels: s.levels, first_letters: s.first_letters, grand_prize: s.grand_prize, lines: s.lines.length, sources: s.lines.map((l) => l.source).join(' '), grade: gradeOf(s) || '', pair_median_mm: pr ? pr.median_mm : null, pair_max_mm: pr ? pr.max_mm : null, zarr: s.zarr, }; }); } function toCSV(rows) { if (!rows.length) return ''; const cols = Object.keys(rows[0]); const cell = (v) => { if (v == null) return ''; if (typeof v === 'number') v = Number.isInteger(v) ? v : +v.toFixed(4); v = String(v); return /[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v; }; return [cols.join(','), ...rows.map((r) => cols.map((c) => cell(r[c])).join(','))].join('\n') + '\n'; } function pointRows(only) { return S.data.scrolls.filter((s) => !only || s === only).flatMap((s) => s.lines.flatMap((l) => linePts(l).map(([x, y, z]) => ({ scroll: s.scroll, scan: s.scan, source: l.source, x, y, z })))); } function scanJSON(s) { return { scroll: s.scroll, scan: s.scan, volume: s.volume, zarr: s.zarr, voxel_um: s.voxel_um, shape_zyx: s.shape, pyramid: s.pyramid, first_letters: s.first_letters, grand_prize: s.grand_prize, frame: S.data.frame, release: S.data.made, lines: s.lines.map((l) => ({ ...l, url: RAW(l), maker: S.data.sources[l.source].label, stats: statsOf(s, l), points: linePts(l) })), pairs: pairsOf(s), }; } function download(name, text, type = 'text/csv') { const a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([text], { type })); a.download = name; document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 500); } let toastT = 0; function toast(msg) { const t = $('toast'); t.textContent = msg; t.hidden = false; clearTimeout(toastT); toastT = setTimeout(() => { t.hidden = true; }, 1600); } async function copy(text, what = 'copied') { try { await navigator.clipboard.writeText(text); } catch { const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); } catch { /* nothing */ } ta.remove(); } toast(what); } // ---------- lines as glowing tubes ---------- function tube(points3, color, radius, opacity = 1) { const curve = new THREE.CatmullRomCurve3(points3.map((p) => new THREE.Vector3(...p)), false, 'centripetal'); const g = new THREE.TubeGeometry(curve, Math.min(900, points3.length * 4), radius, 8, false); const m = new THREE.MeshBasicMaterial({ color: color.clone().multiplyScalar(1.35), transparent: true, opacity, depthWrite: true, toneMapped: false }); const mesh = new THREE.Mesh(g, m); mesh.renderOrder = 3; mesh.userData.base = opacity; return mesh; } function ring(r, y, color, opacity, seg = 96) { const pts = []; for (let i = 0; i <= seg; i++) { const a = i / seg * Math.PI * 2; pts.push(new THREE.Vector3(Math.cos(a) * r, y, Math.sin(a) * r)); } const l = new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts), new THREE.LineBasicMaterial({ color, transparent: true, opacity, toneMapped: false })); l.renderOrder = 2; return l; } // ---------- ARRAY view ---------- const arrayGroup = new THREE.Group(); scene.add(arrayGroup); const hitMeshes = []; const labels = []; function buildArray() { arrayGroup.clear(); hitMeshes.length = 0; $('labels').innerHTML = ''; labels.length = 0; const prize = S.data.scrolls.filter(onPrize); const people = prize.filter((s) => !hasFinder(s)); const finder = prize.filter(hasFinder); const other = S.data.scrolls.filter((s) => !onPrize(s)); // not on the prize page: a row behind the two prize rows const rows = [[people, -150], [finder, 170], [other, -560]]; for (const [list, rz] of rows) { const step = 112, x0 = -(list.length - 1) * step / 2; list.forEach((s, i) => { const g = new THREE.Group(); g.position.set(x0 + i * step, 0, rz); g.userData.scroll = s.scroll; const H = mm(s, s.shape[0]); const [cx, cy] = lineMean(s.lines[0]); for (const l of s.lines) { if (!shown(s, l)) continue; const pts = linePts(l).map(([x, y, z]) => [mm(s, x - cx) * S.ex, mm(s, z), mm(s, y - cy) * S.ex]); g.add(tube(pts, colOf(l), 0.9, isOld(l) ? 0.45 : 1)); } const tint = hasFinder(s) ? AMBER : ICE; g.add(ring(26, 0, tint, 0.28)); g.add(ring(26, H, tint, 0.12)); for (let k = 0; k < 4; k++) { const a = k / 4 * Math.PI * 2 + Math.PI / 4; const v = new THREE.Line(new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(Math.cos(a) * 26, 0, Math.sin(a) * 26), new THREE.Vector3(Math.cos(a) * 26, H, Math.sin(a) * 26)]), new THREE.LineBasicMaterial({ color: tint, transparent: true, opacity: 0.06 })); g.add(v); } const hit = new THREE.Mesh(new THREE.CylinderGeometry(40, 40, H, 12), new THREE.MeshBasicMaterial({ visible: false })); hit.position.y = H / 2; hit.userData.scroll = s.scroll; g.add(hit); hitMeshes.push(hit); arrayGroup.add(g); const lt = lightOf(s.lines.find(isFinder)); const el = document.createElement('div'); el.className = 'lbl' + (hasFinder(s) ? ' cf' : '') + (lt ? ` ${lt}` : '') + (onPrize(s) ? '' : ' np'); el.innerHTML = `${s.scroll.replace('PHerc', '')}${fmt(H, 0)} mm`; $('labels').appendChild(el); labels.push({ el, g, s }); }); } // floor grid, faint const grid = new THREE.GridHelper(2400, 48, 0x0f3440, 0x0a1f28); grid.material.transparent = true; grid.material.opacity = 0.35; grid.position.y = -0.5; arrayGroup.add(grid); S.dirty = true; } function placeLabels() { const show = S.mode === 'array'; const w = innerWidth, h = innerHeight, v = new THREE.Vector3(); for (const L of labels) { if (!show) { L.el.style.display = 'none'; continue; } v.set(0, -14, 0).applyMatrix4(L.g.matrixWorld).project(camera); if (v.z > 1) { L.el.style.display = 'none'; continue; } L.el.style.display = ''; L.el.style.left = `${(v.x * 0.5 + 0.5) * w}px`; L.el.style.top = `${(-v.y * 0.5 + 0.5) * h}px`; L.el.classList.toggle('hot', S.hot === L.s.scroll); L.el.classList.toggle('sel', S.sel && S.sel.scroll === L.s.scroll); } } // ---------- CT streaming from the open-data bucket ---------- const zarrMeta = new Map(); // The page reads one axial plane of a chunk per request, which only works on uncompressed chunks. A scan stored with a // compressor (PHerc0172's 7.91 µm scan, Blosc zstd, from center lines v2.6) keeps its lines but gets no CT here. const streamable = (s) => !s.pyramid.some((p) => p.compressor); const NO_CT = (s) => `no CT here: this scan's chunks are ${s.pyramid.find((p) => p.compressor)?.compressor || 'compressed'}-compressed and the page reads only uncompressed ones`; async function zmeta(s, L) { const k = `${s.zarr}/${L}`; if (!zarrMeta.has(k)) zarrMeta.set(k, getJSON(`${k}/.zarray`)); return zarrMeta.get(k); } let inflight = 0; const waiters = []; const MAXQ = 10; const MAX_PX = 640, READS = 2400; // the X-ray volume: widest plane kept, range reads per scan (see loadVolume) async function slot() { if (inflight < MAXQ) { inflight++; return; } await new Promise((r) => waiters.push(r)); inflight++; } function free() { inflight--; const w = waiters.shift(); if (w) w(); } async function rangeGet(url, off, len, signal) { await slot(); try { const r = await fetch(url, { headers: { Range: `bytes=${off}-${off + len - 1}` }, signal }); if (!r.ok) return null; return new Uint8Array(await r.arrayBuffer()); } catch (e) { if (e.name === 'AbortError') throw e; return null; } finally { free(); } } // one axial slice of level L at level-0 height z0, as a Uint8Array (Y by X) const sliceCache = new Map(); // `${zarr}/${L}/${z}` -> slice, last 48 kept async function ctSlice(s, L, z0, signal) { const m = await zmeta(s, L); const [Z, Y, X] = m.shape; const [cz, cy, cx] = m.chunks; const z = Math.min(Z - 1, Math.max(0, Math.floor(z0 / 2 ** L))); const key = `${s.zarr}/${L}/${z}`; const hit = sliceCache.get(key); if (hit) { sliceCache.delete(key); sliceCache.set(key, hit); return hit; } const res = await ctSliceFetch(s, L, z, m, signal); if (!signal?.aborted) { sliceCache.set(key, res); if (sliceCache.size > 48) sliceCache.delete(sliceCache.keys().next().value); } return res; } async function ctSliceFetch(s, L, z, m, signal) { const [, Y, X] = m.shape; const [cz, cy, cx] = m.chunks; const out = new Uint8Array(X * Y); const jobs = []; for (let by = 0; by * cy < Y; by++) for (let bx = 0; bx * cx < X; bx++) { jobs.push(rangeGet(`${s.zarr}/${L}/${Math.floor(z / cz)}/${by}/${bx}`, (z % cz) * cy * cx, cy * cx, signal).then((b) => { if (!b) return; const h = Math.min(cy, Y - by * cy), w = Math.min(cx, X - bx * cx); for (let r = 0; r < h; r++) out.set(b.subarray(r * cx, r * cx + w), (by * cy + r) * X + bx * cx); })); } await Promise.all(jobs); return { data: out, X, Y }; } // ---------- volume (radiograph / MIP) ---------- const VOL_VS = `varying vec3 vPos; void main(){ vPos = position; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`; const VOL_FS = ` precision highp float; precision highp sampler3D; uniform sampler3D uVol; uniform vec3 uCam; uniform float uGain; uniform int uMode; uniform float uFill; uniform int uN; varying vec3 vPos; vec2 box(vec3 o, vec3 d){ vec3 iv = 1.0 / d; vec3 a = (vec3(-0.5) - o) * iv, b = (vec3(0.5) - o) * iv; vec3 lo = min(a, b), hi = max(a, b); return vec2(max(max(lo.x, lo.y), lo.z), min(min(hi.x, hi.y), hi.z)); } void main(){ vec3 o = uCam, d = normalize(vPos - uCam); vec2 t = box(o, d); t.x = max(t.x, 0.0); if (t.x >= t.y) discard; const int N = 220; float dt = (t.y - t.x) / float(uN); float j = fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233))) * 43758.5453); float acc = 0.0, mx = 0.0; for (int i = 0; i < N; i++) { if (i >= uN) break; vec3 p = o + d * (t.x + (float(i) + j) * dt) + 0.5; float v = texture(uVol, vec3(p.x, p.z, p.y)).r; acc += v * dt; mx = max(mx, v); if (uMode == 0 ? acc * uGain > 7.0 : mx > 0.995) break; // the pixel is already white } float I; if (uMode == 0) I = 1.0 - exp(-max(acc - 0.015, 0.0) * uGain); else I = smoothstep(0.18, 0.18 + 1.6 / (uGain * 0.12 + 1.0), mx); I = clamp(I, 0.0, 1.0); vec3 film = mix(vec3(0.02, 0.10, 0.16), vec3(0.62, 0.86, 0.95), I); gl_FragColor = vec4(film * pow(I, 1.25) * 0.82 * uFill, 1.0); }`; let vol = null; // { s, mesh, tex, abort } function disposeVol() { if (!vol) return; vol.abort.abort(); vol.mesh.geometry.dispose(); vol.mesh.material.dispose(); vol.tex.dispose(); vol.mesh.parent?.remove(vol.mesh); vol = null; } async function loadVolume(s, parent) { disposeVol(); const L = 5; const m = await zmeta(s, L); const [Z, Y0, X0] = m.shape; const [cz, cy, cx] = m.chunks; // A 2.4 µm scan's level-5 plane is up to 1,326 px across (a 1.2 m scan's about 250): it is read whole, kept every // f-th pixel, and fewer heights are read (at least 24), so one scan costs at most about READS range reads. const f = Math.max(1, Math.ceil(Math.max(X0, Y0) / MAX_PX)); const X = Math.ceil(X0 / f), Y = Math.ceil(Y0 / f); const per = Math.ceil(Y0 / cy) * Math.ceil(X0 / cx); const stride = Math.max(1, Math.ceil(Z / Math.max(24, Math.min(COARSE ? 72 : 120, Math.floor(READS / per))))); const zs = []; for (let z = 0; z < Z; z += stride) zs.push(z); const D = zs.length; const data = new Uint8Array(X * Y * D); const tex = new THREE.Data3DTexture(data, X, Y, D); tex.format = THREE.RedFormat; tex.type = THREE.UnsignedByteType; tex.minFilter = tex.magFilter = THREE.LinearFilter; tex.unpackAlignment = 1; tex.needsUpdate = true; const mat = new THREE.ShaderMaterial({ vertexShader: VOL_VS, fragmentShader: VOL_FS, side: THREE.BackSide, transparent: true, depthWrite: false, depthTest: false, blending: THREE.AdditiveBlending, uniforms: { uVol: { value: tex }, uCam: { value: new THREE.Vector3() }, uGain: { value: S.gain }, uMode: { value: 0 }, uFill: { value: 1 }, uN: { value: COARSE ? 140 : 220 } }, }); const mesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), mat); mesh.scale.set(mm(s, X * f * 32), mm(s, zs[D - 1] * 32 + stride * 32), mm(s, Y * f * 32)); mesh.position.set(0, mesh.scale.y / 2, 0); mesh.renderOrder = 1; mesh.onBeforeRender = (r, sc, cam) => { mat.uniforms.uCam.value.copy(cam.position).applyMatrix4(new THREE.Matrix4().copy(mesh.matrixWorld).invert()); }; parent.add(mesh); const abort = new AbortController(); vol = { s, mesh, tex, abort, loaded: 0, total: D, data, zs, X, Y, f, stride, done: null }; applyVolUniforms(); // coarse to fine: every 4th slice first (copied into its gap), then the rest const order = []; for (let i = 0; i < D; i += 4) order.push(i); for (let i = 0; i < D; i++) if (i % 4) order.push(i); const done = new Uint8Array(D); vol.done = done; let lastUp = 0; const one = async (i) => { const z = zs[i]; const plane = X * Y; const tasks = []; for (let by = 0; by * cy < Y0; by++) for (let bx = 0; bx * cx < X0; bx++) { tasks.push(rangeGet(`${s.zarr}/${L}/${Math.floor(z / cz)}/${by}/${bx}`, (z % cz) * cy * cx, cy * cx, abort.signal).then((b) => { if (!b) return; const h = Math.min(cy, Y0 - by * cy), w = Math.min(cx, X0 - bx * cx); if (f === 1) { for (let r = 0; r < h; r++) data.set(b.subarray(r * cx, r * cx + w), i * plane + (by * cy + r) * X + bx * cx); return; } // keep the pixels whose level-5 row and column are multiples of f for (let r = (f - (by * cy) % f) % f; r < h; r += f) { const row = i * plane + ((by * cy + r) / f) * X; for (let q = (f - (bx * cx) % f) % f; q < w; q += f) data[row + (bx * cx + q) / f] = b[r * cx + q]; } })); } await Promise.all(tasks); done[i] = 1; if (i % 4 === 0) for (let k = i + 1; k < Math.min(D, i + 4); k++) if (!done[k]) data.copyWithin(k * plane, i * plane, (i + 1) * plane); vol && vol.tex === tex && (vol.loaded++); const now = performance.now(); if (now - lastUp > 1200 || vol?.loaded === D) { lastUp = now; tex.needsUpdate = true; S.dirty = true; setStatus(); } }; try { let k = 0; const lanes = Array.from({ length: 3 }, async () => { while (k < order.length) { await one(order[k++]); } }); await Promise.all(lanes); } catch (e) { if (e.name !== 'AbortError') console.warn(e); } } function applyVolUniforms() { if (!vol) return; const u = vol.mesh.material.uniforms; u.uGain.value = S.render === 'mip' ? S.gain / 10 + 0.5 : 0.5 + S.gain * S.gain / 700; u.uMode.value = S.render === 'mip' ? 1 : 0; vol.mesh.visible = S.mode === 'scan' ? S.ct : S.ct2; u.uFill.value = S.mode === 'lasagna' ? 0.55 : 1; const full = SET.quality === 'low' ? 140 : COARSE && SET.quality !== 'high' ? 140 : 220; u.uN.value = moving ? Math.round(full / 2) : full; S.dirty = true; } function setStatus() { const el = $('ct-status'); if (!el) return; el.textContent = vol ? (vol.loaded < vol.total ? `streaming CT ${Math.round(100 * vol.loaded / vol.total)}%` : `CT level 5${vol.f > 1 ? `, every ${vol.f === 2 ? 'second' : vol.f === 3 ? 'third' : `${vol.f}th`} pixel` : ''} (${fmt(vol.s.voxel_um * 32 * vol.f, 0)} µm voxels), ${vol.total} heights`) : S.sel && !streamable(S.sel) ? NO_CT(S.sel) : ''; } // ---------- SCAN view ---------- const scanGroup = new THREE.Group(); scene.add(scanGroup); let slicePlane = null, sliceTex = null; let lasGroup = null; function scanOrigin(s) { return [s.shape[2] / 2, s.shape[1] / 2]; } function toWorld(s, x, y, z) { const [ox, oy] = scanOrigin(s); return [mm(s, x - ox), mm(s, z), mm(s, y - oy)]; } const scanLines = new THREE.Group(); function buildScanLines(s) { scanLines.clear(); for (const l of s.lines) if (shown(s, l)) scanLines.add(tube(linePts(l).map(([x, y, z]) => toWorld(s, x, y, z)), colOf(l), 0.55, isOld(l) ? 0.5 : 1)); S.dirty = true; } function buildScan(s) { disposeVol(); scanGroup.clear(); lasGroup = null; scanGroup.add(scanLines); buildScanLines(s); const W = mm(s, s.shape[2]), D = mm(s, s.shape[1]), H = mm(s, s.shape[0]); const frame = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.BoxGeometry(W, H, D)), new THREE.LineBasicMaterial({ color: ICE, transparent: true, opacity: 0.12 })); frame.position.y = H / 2; scanGroup.add(frame); // slice plane, textured from the inset canvas sliceTex = new THREE.CanvasTexture(tc); sliceTex.colorSpace = THREE.SRGBColorSpace; slicePlane = new THREE.Mesh(new THREE.PlaneGeometry(W, D), new THREE.MeshBasicMaterial({ map: sliceTex, transparent: true, opacity: 0.85, blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide, toneMapped: false })); slicePlane.rotation.x = -Math.PI / 2; slicePlane.renderOrder = 2; scanGroup.add(slicePlane); const edge = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.PlaneGeometry(W, D)), new THREE.LineBasicMaterial({ color: ICE, transparent: true, opacity: 0.5, toneMapped: false })); slicePlane.add(edge); if (s.scroll === LASAGNA_SCROLL && S.las) buildLasagna(s); if (streamable(s)) loadVolume(s, scanGroup); setStatus(); } // ---------- LASAGNA layers (PHerc0175A) ---------- const SHEET_FS = `uniform sampler2D map; uniform float uA; uniform vec3 uTint; varying vec2 vUv; void main(){ float c = texture2D(map, vUv).r; float a = smoothstep(0.42, 0.9, c) * uA; gl_FragColor = vec4(uTint * a, 1.0); }`; const SHEET_VS = `varying vec2 vUv; void main(){ vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`; const texLoader = new THREE.TextureLoader(); function buildLasagna(s) { lasGroup = new THREE.Group(); scanGroup.add(lasGroup); const W = mm(s, s.shape[2]), D = mm(s, s.shape[1]); const sheets = new THREE.Group(); sheets.name = 'sheets'; lasGroup.add(sheets); S.las.slice_z.forEach((z, i) => { const t = texLoader.load(`data/lasagna/cos_${z}.webp`, () => { S.dirty = true; }); const mat = new THREE.ShaderMaterial({ vertexShader: SHEET_VS, fragmentShader: SHEET_FS, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, side: THREE.DoubleSide, uniforms: { map: { value: t }, uA: { value: 0.5 }, uTint: { value: new THREE.Color().lerpColors(ICE, AMBER, i / (S.las.slice_z.length - 1)).multiplyScalar(0.9) } } }); const p = new THREE.Mesh(new THREE.PlaneGeometry(W, D), mat); p.rotation.x = -Math.PI / 2; p.position.y = mm(s, z); p.renderOrder = 1; sheets.add(p); }); fetch('data/lasagna/needles.bin').then((r) => r.arrayBuffer()).then((buf) => { const n = S.las.needles.count; const pos = new Uint16Array(buf, 0, n * 3); const nr = new Int8Array(buf, n * 6, n * 3); const gm = new Uint8Array(buf, n * 9, n); const keep = COARSE ? 6 : 3; const m = Math.ceil(n / keep); const P = new Float32Array(m * 6), C = new Float32Array(m * 6); const half = 26; const col = new THREE.Color(); let k = 0; for (let i = 0; i < n; i += keep, k++) { const a = nr[i * 3] / 127, b = nr[i * 3 + 1] / 127, c = nr[i * 3 + 2] / 127; const [x, y, z] = [pos[i * 3], pos[i * 3 + 1], pos[i * 3 + 2]]; const p0 = toWorld(s, x - a * half, y - b * half, z - c * half), p1 = toWorld(s, x + a * half, y + b * half, z + c * half); P.set(p0, k * 6); P.set(p1, k * 6 + 3); const hue = ((Math.atan2(b, a) * 2) / (Math.PI * 2) + 1) % 1; const sat = Math.min(1, Math.hypot(a, b)); col.setHSL(hue, 0.35 + 0.65 * sat, 0.1 + 0.26 * (gm[i] / 255)); C.set([col.r, col.g, col.b], k * 6); C.set([col.r, col.g, col.b], k * 6 + 3); } const g = new THREE.BufferGeometry(); g.setAttribute('position', new THREE.BufferAttribute(P, 3)); g.setAttribute('color', new THREE.BufferAttribute(C, 3)); const ls = new THREE.LineSegments(g, new THREE.LineBasicMaterial({ vertexColors: true, transparent: true, opacity: 0.55, blending: THREE.AdditiveBlending, depthWrite: false, toneMapped: false })); ls.name = 'needles'; ls.renderOrder = 2; lasGroup.add(ls); applyLasVis(); }); applyLasVis(); } function applyLasVis() { if (!lasGroup) return; const on = S.mode === 'lasagna'; lasGroup.visible = on; const sh = lasGroup.getObjectByName('sheets'); if (sh) sh.visible = S.sheets; const gl = lasGroup.getObjectByName('needles'); if (gl) gl.visible = S.needles; if (slicePlane) slicePlane.visible = !on; S.dirty = true; } // ---------- slice inset ---------- // The slice is composed off screen (CT and lasagna only) and shown in one step by present(), so the inset never shows // a cleared black frame while CT is still streaming. present() also turns it with the 3D view when asked and draws // the line marks last, upright. const sc = $('slice'), vx = sc.getContext('2d'); const buf = document.createElement('canvas'); buf.width = sc.width; buf.height = sc.height; const sx = buf.getContext('2d'); let shownAngle = null; function viewAngle() { return SET.turn && S.mode !== 'array' ? controls.getAzimuthalAngle() : 0; } // the 3D slice plane keeps its own unturned copy, since it already sits in the scene's frame const tc = document.createElement('canvas'); tc.width = sc.width; tc.height = sc.height; const tx = tc.getContext('2d'); function present(turnOnly = false) { const s = S.sel; if (!s) return; const a = viewAngle(); shownAngle = a; const W = sc.width, c = W / 2; vx.setTransform(1, 0, 0, 1, 0, 0); vx.fillStyle = '#000'; vx.fillRect(0, 0, W, W); vx.translate(c, c); vx.rotate(a); vx.translate(-c, -c); vx.drawImage(buf, 0, 0); vx.setTransform(1, 0, 0, 1, 0, 0); drawMarks(vx, s, S.z, a); if (turnOnly) return; tx.drawImage(buf, 0, 0); drawMarks(tx, s, S.z, 0); if (sliceTex) sliceTex.needsUpdate = true; S.dirty = true; } let sliceJob = 0, sliceAbort = null; const sheetImgs = new Map(); function sheetImg(path) { if (!sheetImgs.has(path)) { const im = new Image(); im.src = path; sheetImgs.set(path, new Promise((r) => { im.onload = () => r(im); im.onerror = () => r(null); })); } return sheetImgs.get(path); } function lut(v) { // x-ray film: black to ice to white const t = v / 255; return [Math.min(255, 255 * Math.pow(t, 1.35) * 0.85), Math.min(255, 255 * Math.pow(t, 1.05) * 0.97), Math.min(255, 255 * Math.pow(t, 0.8))]; } const LUT = Array.from({ length: 256 }, (_, i) => lut(i)); function drawCT(sl, alpha = 1) { // percentile window over non-empty pixels const h = new Uint32Array(256); let n = 0; for (let i = 0; i < sl.data.length; i += 7) { const v = sl.data[i]; if (v > 2) { h[v]++; n++; } } let lo = 0, hi = 255, c = 0; for (let v = 0; v < 256; v++) { c += h[v]; if (c < n * 0.01) lo = v; if (c < n * 0.995) hi = v; } const span = Math.max(8, hi - lo); const im = new ImageData(sl.X, sl.Y); const d32 = new Uint32Array(im.data.buffer); const map = new Uint32Array(256); for (let v = 0; v < 256; v++) { const t = v <= 2 ? 0 : Math.max(0, Math.min(255, (v - lo) * 255 / span)); const q = LUT[t | 0]; map[v] = (255 << 24 | (q[2] & 255) << 16 | (q[1] & 255) << 8 | (q[0] & 255)) >>> 0; } for (let i = 0, n = sl.data.length; i < n; i++) d32[i] = map[sl.data[i]]; const tmp = new OffscreenCanvas(sl.X, sl.Y); tmp.getContext('2d').putImageData(im, 0, 0); sx.globalAlpha = alpha; sx.drawImage(tmp, 0, 0, sc.width, sc.height); sx.globalAlpha = 1; } function drawMarks(sx, s, z, a = 0) { // the slice fills the square inset whatever the scan's x and y extent, so x and y scale apart (PHerc0172 is not square) const kx = sc.width / s.shape[2], ky = sc.height / s.shape[1], c = sc.width / 2, ca = Math.cos(a), sa = Math.sin(a); for (const l of s.lines) { if (!shown(s, l)) continue; const p = lineAt(l, z); if (!p) continue; const u = p[0] * kx - c, v = p[1] * ky - c; const x = c + u * ca - v * sa, y = c + u * sa + v * ca; const col = hexOf(l); sx.strokeStyle = col; sx.lineWidth = 1.6; sx.shadowColor = col; sx.shadowBlur = 10; sx.beginPath(); sx.arc(x, y, 9, 0, Math.PI * 2); sx.stroke(); sx.beginPath(); sx.moveTo(x - 22, y); sx.lineTo(x - 12, y); sx.moveTo(x + 12, y); sx.lineTo(x + 22, y); sx.moveTo(x, y - 22); sx.lineTo(x, y - 12); sx.moveTo(x, y + 12); sx.lineTo(x, y + 22); sx.stroke(); sx.fillStyle = col; sx.beginPath(); sx.arc(x, y, 1.6, 0, Math.PI * 2); sx.fill(); sx.shadowBlur = 0; sx.font = '500 12px JetBrains Mono, monospace'; sx.fillText(S.data.sources[l.source].short, x + 14, y - 13); } } async function drawSlice() { const s = S.sel; if (!s) return; const job = ++sliceJob; sliceAbort?.abort(); sliceAbort = new AbortController(); const sig = sliceAbort.signal; const z = S.z; const las = S.mode === 'lasagna' && s.scroll === LASAGNA_SCROLL && S.las; $('i-z').textContent = `z ${Math.round(z)} · ${fmt(mm(s, z), 1)} mm`; $('i-title').textContent = las ? 'LASAGNA SHEETS' : 'AXIAL CT SLICE'; if (slicePlane) { slicePlane.position.y = mm(s, z); } try { if (las) { const im = await sheetImg(`data/lasagna/${S.ori ? 'ori' : 'cos'}_${z}.webp`); if (job !== sliceJob) return; sx.fillStyle = '#000'; sx.fillRect(0, 0, sc.width, sc.height); const ctOn = S.blend < 100; if (ctOn) { $('i-status').textContent = 'streaming CT…'; const sl = await ctSlice(s, 3, z, sig); if (job !== sliceJob) return; drawCT(sl, 1); } if (im) { sx.globalCompositeOperation = ctOn ? 'screen' : 'source-over'; sx.globalAlpha = S.blend / 100; if (!S.ori) sx.filter = 'sepia(1) saturate(2.2) hue-rotate(-12deg) brightness(1.05)'; sx.drawImage(im, 0, 0, sc.width, sc.height); sx.filter = 'none'; sx.globalAlpha = 1; sx.globalCompositeOperation = 'source-over'; } present(); $('i-status').textContent = 'model output · villa lasagna'; } else if (!streamable(s)) { sx.fillStyle = '#000'; sx.fillRect(0, 0, sc.width, sc.height); present(); $('i-status').textContent = `no CT: ${s.pyramid.find((p) => p.compressor).compressor}-compressed scan`; } else { $('i-status').textContent = 'streaming…'; const Lq = lv(s, 4), Lf = lv(s, 3); const quick = await ctSlice(s, Lq, z, sig); if (job !== sliceJob) return; sx.fillStyle = '#000'; sx.fillRect(0, 0, sc.width, sc.height); drawCT(quick); present(); if (Lf !== Lq) { await new Promise((r) => setTimeout(r, 220)); if (job !== sliceJob) return; const fine = await ctSlice(s, Lf, z, sig); if (job !== sliceJob) return; sx.fillStyle = '#000'; sx.fillRect(0, 0, sc.width, sc.height); drawCT(fine); present(); } $('i-status').textContent = `CT level ${Lf} · ${fmt(s.voxel_um * 2 ** Lf, 0)} µm px`; } } catch (e) { if (e.name !== 'AbortError') { $('i-status').textContent = 'CT unavailable'; console.warn(e); } } } let zTimer = 0; function setZ(z, now = false) { const s = S.sel; if (!s) return; if (S.mode === 'lasagna' && S.las && s.scroll === LASAGNA_SCROLL) { const zs = S.las.slice_z; z = zs.reduce((a, b) => Math.abs(b - z) < Math.abs(a - z) ? b : a, zs[0]); } S.z = Math.max(0, Math.min(s.shape[0] - 1, z)); $('z').value = Math.round(1000 * S.z / s.shape[0]); clearTimeout(zTimer); if (now) drawSlice(); else { livePreview(s); zTimer = setTimeout(drawSlice, 90); } if (slicePlane) slicePlane.position.y = mm(s, S.z); S.dirty = true; drawHere(); syncURLSoon(); } // While the height slider moves, the inset follows at once from the level-5 CT already in memory for the 3D view // (or from a cached finer slice), and the finer levels replace it when the slider rests. function livePreview(s) { if (S.mode === 'lasagna') return; const z = S.z; $('i-z').textContent = `z ${Math.round(z)} · ${fmt(mm(s, z), 1)} mm`; for (const L of new Set([lv(s, 3), lv(s, 4)])) { const hit = sliceCache.get(`${s.zarr}/${L}/${Math.floor(z / 2 ** L)}`); if (hit) { sx.fillStyle = '#000'; sx.fillRect(0, 0, sc.width, sc.height); drawCT(hit); present(); return; } } const v = vol && vol.s === s && vol.done ? vol : null; if (!v) return; const i = Math.max(0, Math.min(v.zs.length - 1, Math.round(z / 32 / v.stride))); if (!v.done[i] && !v.done[i - (i % 4)]) return; const plane = v.X * v.Y; const sl = { data: v.data.subarray(i * plane, (i + 1) * plane), X: v.X, Y: v.Y }; sx.fillStyle = '#000'; sx.fillRect(0, 0, sc.width, sc.height); drawCT(sl); present(); } $('z').addEventListener('input', (e) => { if (S.sel) setZ(e.target.value / 1000 * S.sel.shape[0]); }); sc.addEventListener('wheel', (e) => { if (!S.sel) return; e.preventDefault(); const step = S.mode === 'lasagna' ? 1280 * Math.sign(e.deltaY) : S.sel.shape[0] / 200 * Math.sign(e.deltaY); setZ(S.z - step); }, { passive: false }); // ---------- showing and hiding lines ---------- function buildLegend() { const used = Object.keys(S.data.sources).filter((k) => S.data.scrolls.some((s) => s.lines.some((l) => l.source === k))); $('legend').innerHTML = used.map((k) => ``).join(''); } function linesChanged() { buildLegend(); buildArray(); if (S.sel) { buildScanLines(S.sel); buildCard(S.sel); drawSlice(); } syncURLSoon(); } // show(kind, name, on): kind 'source' (a maker, such as drobkov) or 'line' (SCROLL:SOURCE); on omitted toggles function showLines(kind, name, on) { const set = kind === 'source' ? OFF.src : OFF.line; const now = on === undefined ? set.has(name) : !!on; if (now) set.delete(name); else set.add(name); linesChanged(); return { off: [...OFF.src, ...OFF.line] }; } document.addEventListener('click', (e) => { const b = e.target.closest('#legend [data-src]'); if (b) showLines('source', b.dataset.src); }); document.addEventListener('change', (e) => { const c = e.target.closest('input[data-line]'); if (c) showLines('line', c.dataset.line, c.checked); }); // ---------- UI ---------- // The header text and the view buttons wrap at different widths (on a phone the buttons take two rows), so the panels // under them are placed from what is measured here, not from fixed offsets: --nav-w (the buttons' width, which the // header's kicker keeps clear of on wide screens), --head-b (where the header text ends) and --nav-b (where the // buttons end; the scan card, the first-visit hint and the settings sit under it on phones), and --bl-h (the height // of the controls at bottom left, which the slice panel sits above in X-ray and Lasagna on phones). style.css has // fallbacks. const NAV = document.querySelector('nav.tr'); const BL = document.querySelector('.hud.bl'); function placeHud() { const css = document.documentElement.style; css.setProperty('--nav-w', `${Math.ceil(NAV.getBoundingClientRect().width)}px`); const head = Math.max(0, ...[...document.querySelectorAll('.tl .kicker, .tl h1')].map((e) => e.getBoundingClientRect().bottom)); css.setProperty('--head-b', `${Math.ceil(head)}px`); css.setProperty('--nav-b', `${Math.ceil(NAV.getBoundingClientRect().bottom)}px`); css.setProperty('--bl-h', `${Math.ceil(BL.getBoundingClientRect().height)}px`); } let hudQueued = false; const placeHudSoon = () => { if (!hudQueued) { hudQueued = true; requestAnimationFrame(() => { hudQueued = false; placeHud(); }); } }; if (window.ResizeObserver) { const ro = new ResizeObserver(placeHudSoon); ro.observe(document.querySelector('.tl')); ro.observe(NAV); ro.observe(BL); } addEventListener('resize', placeHudSoon); placeHud(); function railRow(s) { const li = document.createElement('li'); li.dataset.scroll = s.scroll; const srcs = s.lines.map((l) => `${S.data.sources[l.source].short}`).join(''); const g = gradeOf(s); const f = s.lines.find(isFinder); const ut = !!(f && f.untested); const lt = lightOf(f); li.innerHTML = `${s.scroll}${prizeTag(s)}${srcs}${g || '·'}`; li.addEventListener('click', () => select(s.scroll, S.mode === 'array' ? 'scan' : S.mode)); li.addEventListener('mouseenter', () => { S.hot = s.scroll; S.dirty = true; }); li.addEventListener('mouseleave', () => { S.hot = null; S.dirty = true; }); return li; } function buildRail() { const ol = $('rail-list'); ol.innerHTML = ''; let sep = false; for (const s of S.data.scrolls) { if (!onPrize(s) && !sep) { sep = true; const li = document.createElement('li'); li.className = 'sep mono'; li.textContent = 'NOT ON THE PRIZE PAGE'; ol.appendChild(li); } ol.appendChild(railRow(s)); } } // the scan menu on the card: in X-ray and Lasagna the list is hidden, so the page keeps one scrolling panel (the card) function buildPick() { const opt = (s) => ``; const on = S.data.scrolls.filter(onPrize), off = S.data.scrolls.filter((s) => !onPrize(s)); $('c-pick').innerHTML = `` + (off.length ? `` : ''); } $('c-pick').addEventListener('change', (e) => { const name = e.target.value; select(name, S.mode === 'lasagna' && name !== LASAGNA_SCROLL ? 'scan' : S.mode === 'array' ? 'scan' : S.mode); e.target.blur(); }); function buildCard(s) { $('c-pick').value = s.scroll; $('c-kick').textContent = `SCAN ${s.scan} · ${s.voxel_um} µm · ${prizeWords(s)}`; $('c-title').textContent = s.scroll; $('c-lines').innerHTML = s.lines.map((l, i) => { const src = S.data.sources[l.source]; const f = isFinder(l); const t = statsOf(s, l); const up = /^https?:/.test(l.upstream) ? `upstream` : ''; const grade = l.grade ? `END CHECK ${l.grade}${lightOf(l) === 'caution' ? ' · CAUTION' : ''}` : `${l.kind === 'person' ? 'made by a person' : ''}`; const ends = endsText(l); const ver = l.superseded_by ? 'SUPERSEDED' : (isNewest(s, l) ? 'NEWEST' : ''); const was = l.superseded_by ? '
| level | shape z, y, x | voxel |
|---|---|---|
| ${p.path} | ${p.shape.join(' x ')} | ${fmt(s.voxel_um * 2 ** i, i ? 1 : 3)} µm |
${fmt(r.x, 1)}, ${fmt(r.y, 1)}, ${z}