Download app.js from SharpMeow/umbilici: direct link, hf CLI and curl.
- Browser
- Download file 84.9 kB
-
https://huggingface.co/spaces/SharpMeow/umbilici/resolve/main/app.js
- Command line
-
hf download hf://spaces/SharpMeow/umbilici/app.js
-
curl -L -o app.js https://huggingface.co/spaces/SharpMeow/umbilici/resolve/main/app.js
84.9 kB
| // 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 ? `<span class="end">${k} <span class="grade ${e.grade}">${e.grade}</span>${e.recapped ? '<span class="rc">CT recap</span>' : ''}</span>` : ''; } | |
| function endsHTML(l) { return `<span class="ends">${['low', 'high'].map((k) => endHTML(l, k)).filter(Boolean).join('')}</span>`; } | |
| 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 = `<b>${s.scroll.replace('PHerc', '')}</b>${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) => `<button class="lg${OFF.src.has(k) ? ' off' : ''}" data-src="${k}" aria-pressed="${!OFF.src.has(k)}" title="show or hide every line by ${S.data.sources[k].label}"><i class="sw" style="background:${SRC_HEX[k]};box-shadow:0 0 6px ${SRC_HEX[k]}"></i>${k === 'finder' ? 'Center Finder (model output)' : k === 'finder-polished' ? 'Center Finder, polished (model output)' : k === 'finder-v2' ? 'Center Finder v2 (model output)' : S.data.sources[k].label}</button>`).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) => `<span class="src s-${l.source}" title="${S.data.sources[l.source].label}">${S.data.sources[l.source].short}</span>`).join(''); | |
| const g = gradeOf(s); | |
| const f = s.lines.find(isFinder); const ut = !!(f && f.untested); const lt = lightOf(f); | |
| li.innerHTML = `<span class="nm">${s.scroll}<small>${prizeTag(s)}</small></span><span class="srcs">${srcs}</span><span class="grade ${g || ''} ${lt ? 'lt-' + lt : ''}${ut ? ' ut' : ''}" title="${f ? endsText(f) + (ut ? `; ${UNTESTED}` : '') + (lt ? `; ${LIGHT_WORD[lt]}` : '') : ''}">${g || '·'}</span>`; | |
| 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) => `<option value="${s.scroll}">${s.scroll}${prizeTag(s) ? ' ' + prizeTag(s) : ''}</option>`; | |
| const on = S.data.scrolls.filter(onPrize), off = S.data.scrolls.filter((s) => !onPrize(s)); | |
| $('c-pick').innerHTML = `<optgroup label="Prize page">${on.map(opt).join('')}</optgroup>` + (off.length ? `<optgroup label="Not on the prize page">${off.map(opt).join('')}</optgroup>` : ''); | |
| } | |
| $('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) ? `<a href="${l.upstream}" target="_blank" rel="noopener">upstream</a>` : ''; | |
| const grade = l.grade ? `<span class="grade ${l.grade} lt-${lightOf(l)}" title="${GRADE_TEXT[l.grade]}; ${LIGHT_WORD[lightOf(l)]}">END CHECK ${l.grade}${lightOf(l) === 'caution' ? ' · CAUTION' : ''}</span>` : `<span class="dim">${l.kind === 'person' ? 'made by a person' : ''}</span>`; | |
| const ends = endsText(l); | |
| const ver = l.superseded_by ? '<span class="ver old" title="a newer version of this line exists; it is kept for comparison">SUPERSEDED</span>' : (isNewest(s, l) ? '<span class="ver new" title="the newest version of this line; kit takes it by default">NEWEST</span>' : ''); | |
| const was = l.superseded_by ? '<div class="note">Superseded: the newer version of this line is on this card, marked NEWEST. This one is kept for comparison and drawn faded.</div>' : ''; | |
| const note = f ? `<div class="note">${ends ? `CT end check: ${endsHTML(l)}` : `${GRADE_TEXT[l.grade] || 'No CT end check'}.`}</div><div class="note">${l.untested ? `Untested: ${UNTESTED}. ` : ''}Usable ends: ${usable(l) || 'see note'}. Model output: no end has been checked on CT by a person.</div>` | |
| : (l.note ? `<div class="note">${l.note}${/[.!?]$/.test(l.note.trim()) ? '' : '.'}</div>` : ''); | |
| const row = (k, v) => `<tr><th>${k}</th><td>${v}</td></tr>`; | |
| const k = lineKey(s, l); | |
| return `<div class="ln${shown(s, l) ? '' : ' off'}"><div class="who"><span class="name"><label class="show" title="show or hide this line"><input type="checkbox" data-line="${k}"${OFF.line.has(k) ? '' : ' checked'}${OFF.src.has(l.source) ? ' disabled' : ''}></label><b style="color:${hexOf(l)}">${src.label}</b></span><span class="tags">${ver}${grade}</span></div> | |
| <div class="meta">${t.points} points · z ${t.z_start} to ${t.z_end} (${fmt(t.height_mm, 0)} mm, ${fmt(t.coverage * 100, 0)}% of the scan) · strays up to ${fmt(t.stray_mm, 1)} mm from its mean · ${lic(l)}</div>${was}${note} | |
| <div class="links"><a href="${RAW(l)}" download>download</a><button class="lk" data-copy="${RAW(l)}" data-what="file URL copied">copy URL</button><button class="lk" data-copy="python -m kit centerline get ${s.scroll} --source ${l.source} --into YOUR_PROJECT_FOLDER" data-what="kit command copied: it writes YOUR_PROJECT_FOLDER/umbilicus.json" title="A command for the Scrolls kit that downloads this line, checks its sha256 and writes it as umbilicus.json where VC3D looks">copy kit command</button><a href="${DATASET}/blob/main/${l.file}" target="_blank" rel="noopener">view</a>${up}</div> | |
| <details class="full"><summary>every number</summary><table class="kv"> | |
| ${row('credit', l.credit)}${row('kind', l.kind)}${row('points', t.points)} | |
| ${row('z range', `${t.z_start} to ${t.z_end} voxels, ${fmt(mm(s, t.z_start), 2)} to ${fmt(mm(s, t.z_end), 2)} mm`)} | |
| ${row('coverage', `${fmt(t.coverage * 100, 1)}% of ${s.shape[0]} slices`)} | |
| ${row('step', `median ${fmt(t.median_step_vox, 0)}, max ${fmt(t.max_step_vox, 0)} voxels (${fmt(mm(s, t.max_step_vox), 1)} mm)`)} | |
| ${row('mean x, y', `${fmt(t.mean_x, 0)}, ${fmt(t.mean_y, 0)} voxels, ${fmt(t.off_center_mm, 2)} mm off grid center`)} | |
| ${row('stray', `${fmt(t.stray_mm, 2)} mm`)}${row('tilt', `${fmt(t.tilt_deg, 2)}°`)}${row('wiggle', `${fmt(t.wiggle_mm, 3)} mm RMS`)} | |
| ${row('path', `${fmt(t.path_mm, 1)} mm, ${fmt(t.path_over_height, 4)} times its height`)} | |
| ${row('stamped', t.stamped ? `yes: ${l.stamp.voxelsize_um} µm, ${l.stamp.volume_width} x ${l.stamp.volume_height} x ${l.stamp.volume_slices}` : 'no: bare points')} | |
| ${row('license', lic(l))} | |
| ${row('sha256', `<button class="lk mono" data-copy="${l.sha256}" data-what="sha256 copied" title="copy">${l.sha256.slice(0, 16)}…</button>`)} | |
| ${row('file', `<code>${l.file}</code>`)} | |
| ${l.ends && Object.keys(l.ends).length ? row('CT end check', ['low', 'high'].filter((k) => l.ends[k]).map((k) => `${endHTML(l, k)} ${fmt(l.ends[k].D_mm, 2)} mm from the CT center`).join('<br>')) : ''} | |
| ${l.note ? row('note', l.note) : ''} | |
| </table></details></div>`; | |
| }).join(''); | |
| $('c-pair').innerHTML = pairsOf(s).map((q) => `<div class="ln meta">${S.data.sources[q.a].short} and ${S.data.sources[q.b].short} sit a median <b>${fmt(q.median_mm, 2)} mm</b> apart (min ${fmt(q.min_mm, 2)}, max ${fmt(q.max_mm, 2)}) over the ${fmt(q.overlap_mm, 0)} mm where both exist.</div>`).join(''); | |
| $('c-pyr').innerHTML = `<table class="kv"><tr><th>level</th><td>shape z, y, x</td><td>voxel</td></tr>${s.pyramid.map((p, i) => `<tr><th>${p.path}</th><td>${p.shape.join(' x ')}</td><td>${fmt(s.voxel_um * 2 ** i, i ? 1 : 3)} µm</td></tr>`).join('')}</table> | |
| <div class="dim">every level ${s.pyramid[0].dtype === '|u1' ? 'uint8' : s.pyramid[0].dtype}, chunks ${s.pyramid[0].chunks.join(' x ')}, ${s.pyramid[0].compressor || 'no compressor'}</div>${streamable(s) ? '' : `<div class="note">This page reads one plane of an uncompressed chunk at a time, so it shows no CT for this scan; its line is drawn in the scan's frame. zarr, VC3D and the neuroglancer link read the compressed chunks.</div>`}`; | |
| $('c-vol').innerHTML = `CT <a href="${s.zarr}/.zattrs" target="_blank" rel="noopener">${s.volume}</a> · Vesuvius Challenge open data, CC BY-NC 4.0`; | |
| } | |
| function hereRows(s, z) { return s.lines.map((l) => { const p = lineAt(l, z); return p ? { source: l.source, x: p[0], y: p[1], z } : null; }).filter(Boolean); } | |
| function drawHere() { | |
| const s = S.sel; if (!s || S.z == null) return; | |
| const z = Math.round(S.z); const rows = hereRows(s, z); | |
| $('c-here').innerHTML = `<div class="mono small dim">AT z ${z} (${fmt(mm(s, z), 2)} mm), LEVEL-0 VOXELS</div>` + (rows.length | |
| ? rows.map((r) => `<div class="hr"><span class="${r.source.startsWith('finder') ? 'amber' : 'ice'}">${S.data.sources[r.source].short}</span> <code>${fmt(r.x, 1)}, ${fmt(r.y, 1)}, ${z}</code><button class="lk" data-copy="${fmt(r.x, 1)}, ${fmt(r.y, 1)}, ${z}" data-what="x, y, z copied">copy</button></div>`).join('') | |
| : '<div class="dim small">no line reaches this height</div>'); | |
| } | |
| function setMode(m) { | |
| if (m === 'lasagna' && !S.las) m = 'scan'; | |
| if ((m === 'scan' || m === 'lasagna') && !S.sel) { select(m === 'lasagna' ? LASAGNA_SCROLL : S.data.scrolls[0].scroll, m); return; } | |
| if (m === 'lasagna' && S.sel.scroll !== LASAGNA_SCROLL) { select(LASAGNA_SCROLL, 'lasagna'); return; } | |
| const prev = S.mode; S.mode = m; document.body.classList.toggle('focus', m !== 'array'); placeHud(); | |
| document.querySelectorAll('.chip').forEach((c) => c.classList.toggle('on', c.dataset.mode === m)); | |
| arrayGroup.visible = m === 'array'; scanGroup.visible = m !== 'array'; | |
| $('card').hidden = m === 'array'; showInset(); | |
| $('ctl-array').hidden = m !== 'array'; $('ctl-scan').hidden = m !== 'scan'; $('ctl-las').hidden = m !== 'lasagna'; | |
| $('blend-wrap').hidden = m !== 'lasagna'; $('t-ori').parentElement.hidden = m !== 'lasagna'; | |
| document.querySelectorAll('#rail li').forEach((li) => li.classList.toggle('sel', !!S.sel && li.dataset.scroll === S.sel.scroll && m !== 'array')); | |
| if (m === 'array') { controls.target.set(0, 90, 0); if (prev !== 'array') { const k = Math.max(1, 1.25 / camera.aspect); camera.position.set(ARRAY_CAM[0] * k, ARRAY_CAM[1] * k, ARRAY_CAM[2] * k); camera.far = 20000; camera.updateProjectionMatrix(); } controls.autoRotate = false; } | |
| applyVolUniforms(); applyLasVis(); | |
| if (m !== 'array' && S.sel) setZ(S.z ?? defaultZ(S.sel), true); | |
| syncURL(); S.dirty = true; | |
| } | |
| function defaultZ(s) { const l = s.lines[0]; return (l.z[0] + l.z[1]) / 2; } | |
| function frameScan(s) { | |
| const H = mm(s, s.shape[0]), W = mm(s, Math.max(s.shape[1], s.shape[2])); | |
| controls.target.set(0, H * 0.5, 0); | |
| const d = Math.max(H, W * 1.4) * 2.3 * Math.max(1, 1.1 / camera.aspect); camera.position.set(d * 0.5, H * 1.05, d * 0.86); camera.near = 1; camera.far = d * 10; camera.updateProjectionMatrix(); | |
| } | |
| function select(name, mode = 'scan') { | |
| const s = S.data.scrolls.find((q) => q.scroll === name); if (!s) return; | |
| const changed = S.sel !== s; S.sel = s; | |
| if (changed) { S.z = null; buildScan(s); buildCard(s); frameScan(s); } | |
| S.z = S.z ?? (mode === 'lasagna' && S.las ? S.las.slice_z[4] : defaultZ(s)); | |
| setMode(mode); | |
| } | |
| document.querySelectorAll('.chip').forEach((c) => c.addEventListener('click', () => setMode(c.dataset.mode))); | |
| $('c-close').addEventListener('click', () => setMode('array')); | |
| $('b-about').addEventListener('click', () => { markSeen(); openGuide(); }); | |
| $('b-connect').addEventListener('click', () => { markSeen(); openGuide('connect'); }); | |
| let exQueued = false; | |
| $('ex').addEventListener('input', (e) => { S.ex = +e.target.value; $('ex-v').textContent = S.ex; if (!exQueued) { exQueued = true; requestAnimationFrame(() => { exQueued = false; buildArray(); }); } syncURLSoon(); }); | |
| $('t-ct').addEventListener('change', (e) => { S.ct = e.target.checked; applyVolUniforms(); }); | |
| $('t-ct2').addEventListener('change', (e) => { S.ct2 = e.target.checked; applyVolUniforms(); }); | |
| $('render').addEventListener('change', (e) => { S.render = e.target.value; applyVolUniforms(); }); | |
| $('gain').addEventListener('input', (e) => { S.gain = +e.target.value; applyVolUniforms(); }); | |
| $('t-sheets').addEventListener('change', (e) => { S.sheets = e.target.checked; applyLasVis(); }); | |
| $('t-needle').addEventListener('change', (e) => { S.needles = e.target.checked; applyLasVis(); }); | |
| $('t-ori').addEventListener('change', (e) => { S.ori = e.target.checked; drawSlice(); }); | |
| $('blend').addEventListener('input', (e) => { S.blend = +e.target.value; clearTimeout(zTimer); zTimer = setTimeout(drawSlice, 120); }); | |
| addEventListener('keydown', (e) => { if (e.key === 'Escape' && S.mode !== 'array' && !document.querySelector('dialog[open]')) setMode('array'); }); | |
| // neuroglancer link for an OME-Zarr URL, as kit/spaceconnect.py makes them | |
| const ngLink = (url, name) => 'https://neuroglancer-demo.appspot.com/#!' + encodeURIComponent(JSON.stringify({ layers: [{ type: 'image', source: `zarr://${url.replace(/\/+$/, '')}/`, name }], layout: '4panel' })); | |
| // copy buttons anywhere, card actions | |
| document.addEventListener('click', (e) => { const b = e.target.closest('[data-copy]'); if (b) { e.preventDefault(); copy(b.dataset.copy, b.dataset.what); } }); | |
| $('c-acts').addEventListener('click', (e) => { | |
| const b = e.target.closest('[data-act]'); const s = S.sel; if (!b || !s) return; | |
| const act = b.dataset.act; | |
| if (act === 'view') copy(location.href, 'view link copied'); | |
| else if (act === 'zarr') copy(s.zarr, 'OME-Zarr URL copied'); | |
| else if (act === 'ng') open(ngLink(s.zarr, `${s.scroll} CT`), '_blank', 'noopener'); | |
| else if (act === 'csv') download(`${s.scroll}_center_lines.csv`, toCSV(pointRows(s))); | |
| else if (act === 'json') download(`${s.scroll}_umbilici.json`, JSON.stringify(scanJSON(s), null, 1), 'application/json'); | |
| else if (act === 'use') openGuide('vc3d'); | |
| }); | |
| $('i-copy').addEventListener('click', () => { const s = S.sel; if (!s) return; const rows = hereRows(s, Math.round(S.z)); copy(rows.map((r) => `${s.scroll} ${r.source} ${fmt(r.x, 1)}, ${fmt(r.y, 1)}, ${r.z}`).join('\n') || 'none', 'x, y, z copied'); }); | |
| // guide | |
| function tabs(dlg, name) { | |
| dlg.querySelectorAll('.tabs [data-tab]').forEach((b) => b.classList.toggle('on', b.dataset.tab === name)); | |
| dlg.querySelectorAll('[data-pane]').forEach((p) => { p.hidden = p.dataset.pane !== name; }); | |
| } | |
| function snippets() { | |
| const s = S.sel || S.data.scrolls.find((q) => q.scroll === 'PHerc0846A') || S.data.scrolls[0]; const l = s.lines[0]; | |
| const z = Math.round(S.sel && S.z != null ? S.z : (l.z[0] + l.z[1]) / 2); | |
| $('g-py1').textContent = `import json, urllib.request | |
| import numpy as np | |
| URL = "${RAW(l)}" | |
| pts = sorted(json.load(urllib.request.urlopen(URL))["control_points"], key=lambda p: p["z"]) | |
| z = np.array([p["z"] for p in pts], float) | |
| x = np.array([p["x"] for p in pts], float) | |
| y = np.array([p["y"] for p in pts], float) | |
| Z = ${z} # a height in level-0 voxels | |
| print(np.interp(Z, z, x), np.interp(Z, z, y)) # holds the end values outside the line, like VC3D`; | |
| $('g-py2').textContent = !streamable(s) ? `# pip install numcodecs | |
| from numcodecs import Blosc | |
| VOL = "${s.zarr}" | |
| L = 3 # pyramid level: voxels are 2**L times level 0 | |
| meta = json.load(urllib.request.urlopen(f"{VOL}/{L}/.zarray")) # uint8, 128^3 chunks, Blosc: read whole chunks | |
| cz, cy, cx = meta["chunks"] | |
| X, Y = np.interp(Z, z, x), np.interp(Z, z, y) | |
| zl, yl, xl = int(Z) >> L, int(Y) >> L, int(X) >> L | |
| raw = urllib.request.urlopen(f"{VOL}/{L}/{zl // cz}/{yl // cy}/{xl // cx}").read() | |
| chunk = np.frombuffer(Blosc().decode(raw), np.uint8).reshape(cz, cy, cx) | |
| print(chunk[zl % cz, yl % cy, xl % cx])` : `VOL = "${s.zarr}" | |
| L = 3 # pyramid level: voxels are 2**L times level 0 | |
| meta = json.load(urllib.request.urlopen(f"{VOL}/{L}/.zarray")) # uint8, 128^3 chunks, no compressor | |
| cz, cy, cx = meta["chunks"] | |
| X, Y = np.interp(Z, z, x), np.interp(Z, z, y) | |
| zl, yl, xl = int(Z) >> L, int(Y) >> L, int(X) >> L | |
| off = (zl % cz) * cy * cx # one axial plane of the chunk | |
| req = urllib.request.Request(f"{VOL}/{L}/{zl // cz}/{yl // cy}/{xl // cx}", | |
| headers={"Range": f"bytes={off}-{off + cy * cx - 1}"}) | |
| plane = np.frombuffer(urllib.request.urlopen(req).read(), np.uint8).reshape(cy, cx) | |
| print(plane[yl % cy, xl % cx])`; | |
| } | |
| // while a dialog is open it is the one scrolling panel: the list and the card behind it stop scrolling (style.css body.modal) | |
| function modalState() { document.body.classList.toggle('modal', !!document.querySelector('dialog[open]')); } | |
| document.querySelectorAll('dialog').forEach((d) => d.addEventListener('close', modalState)); | |
| function openGuide(tab = 'basics') { snippets(); tabs($('about'), tab); if (!$('about').open) $('about').showModal(); modalState(); } | |
| $('about').querySelector('.tabs').addEventListener('click', (e) => { const b = e.target.closest('[data-tab]'); if (b) { if (b.dataset.tab === 'vc3d') snippets(); tabs($('about'), b.dataset.tab); } }); | |
| // data sheet | |
| const COLS = { | |
| lines: [['scroll', 'scroll'], ['maker', 'maker'], ['grade', 'grade'], ['ct_end_low', 'low end'], ['ct_end_high', 'high end'], ['points', 'pts'], ['z_start', 'z start'], ['z_end', 'z end'], ['height_mm', 'height mm'], | |
| ['coverage', 'cover'], ['median_step_vox', 'step'], ['max_step_vox', 'max step'], ['off_center_mm', 'off ctr mm'], ['stray_mm', 'stray mm'], ['tilt_deg', 'tilt °'], | |
| ['wiggle_mm', 'wiggle mm'], ['path_over_height', 'path/ht'], ['stamped', 'stamp'], ['usable_ends', 'usable ends'], ['superseded', 'superseded'], ['license', 'license'], ['sha256', 'sha256']], | |
| scans: [['scroll', 'scroll'], ['scan', 'scan'], ['voxel_um', 'µm'], ['slices', 'z'], ['height_px', 'y'], ['width_px', 'x'], ['height_mm', 'height mm'], ['width_mm', 'width mm'], | |
| ['levels', 'levels'], ['grand_prize', 'GP'], ['first_letters', 'FL'], ['lines', 'lines'], ['sources', 'sources'], ['grade', 'grade'], ['pair_median_mm', 'pair gap mm'], ['pair_max_mm', 'pair max mm']], | |
| }; | |
| const DEC = { height_mm: 1, coverage: 2, median_step_vox: 0, max_step_vox: 0, off_center_mm: 2, stray_mm: 2, tilt_deg: 2, wiggle_mm: 3, path_over_height: 4, width_mm: 1, voxel_um: 3, pair_median_mm: 2, pair_max_mm: 2 }; | |
| const SHEET = { tab: 'lines', key: null, dir: 1 }; | |
| function cellHTML(k, v) { | |
| if (v == null || v === '') return '<span class="dim">·</span>'; | |
| if (k === 'grade') return `<span class="grade ${v}">${v}</span>`; | |
| if (k === 'ct_end_low' || k === 'ct_end_high') { const m = /^(\w+) (PASS|FLAG)( \(CT recap\))?$/.exec(v); if (m) return `<span class="end"><span class="grade ${m[2]}">${m[2]}</span>${m[3] ? '<span class="rc">CT recap</span>' : ''}</span>`; } | |
| if (k === 'sha256') return `<button class="lk mono" data-copy="${v}" data-what="sha256 copied">${v.slice(0, 10)}…</button>`; | |
| if (typeof v === 'boolean') return v ? 'yes' : '<span class="dim">no</span>'; | |
| if (k === 'coverage') return `${fmt(v * 100, 0)}%`; | |
| if (typeof v === 'number' && k in DEC) return fmt(v, DEC[k]); | |
| return String(v); | |
| } | |
| function renderSheet() { | |
| const rows = SHEET.tab === 'lines' ? lineRows() : scanRows(); const cols = COLS[SHEET.tab]; | |
| if (SHEET.key) rows.sort((a, b) => { const u = a[SHEET.key], v = b[SHEET.key]; return (u > v ? 1 : u < v ? -1 : 0) * SHEET.dir; }); | |
| $('tbl').innerHTML = `<thead><tr>${cols.map(([k, h]) => `<th data-k="${k}" class="${SHEET.key === k ? (SHEET.dir > 0 ? 'up' : 'dn') : ''}">${h}</th>`).join('')}</tr></thead> | |
| <tbody>${rows.map((r) => `<tr data-scroll="${r.scroll}" class="${r.source?.startsWith('finder') ? 'cf' : ''}${r.withdrawn || r.superseded ? ' wd' : ''}">${cols.map(([k]) => `<td>${cellHTML(k, r[k])}</td>`).join('')}</tr>`).join('')}</tbody>`; | |
| $('sheet-note').textContent = `${rows.length} ${SHEET.tab}. Click a column to sort, a row to open its scan. Definitions: Guide, For researchers. Downloads hold every column and the full notes.`; | |
| } | |
| function openSheet(tab = 'lines') { SHEET.tab = tab; tabs($('sheet'), tab); renderSheet(); if (!$('sheet').open) $('sheet').showModal(); modalState(); } | |
| $('sheet').querySelector('.tabs').addEventListener('click', (e) => { | |
| const t = e.target.closest('[data-tab]'); if (t) { SHEET.tab = t.dataset.tab; SHEET.key = null; tabs($('sheet'), SHEET.tab); renderSheet(); return; } | |
| const d = e.target.closest('[data-dl]'); if (!d) return; | |
| if (d.dataset.dl === 'lines') download('umbilici_lines.csv', toCSV(lineRows())); | |
| else if (d.dataset.dl === 'scans') download('umbilici_scans.csv', toCSV(scanRows())); | |
| else if (d.dataset.dl === 'points') download('umbilici_points.csv', toCSV(pointRows())); | |
| else download('umbilici.json', JSON.stringify({ release: S.data.made, frame: S.data.frame, sources: S.data.sources, scans: S.data.scrolls.map(scanJSON) }, null, 1), 'application/json'); | |
| }); | |
| $('tbl').addEventListener('click', (e) => { | |
| if (e.target.closest('[data-copy]')) return; | |
| const th = e.target.closest('th[data-k]'); if (th) { const k = th.dataset.k; SHEET.dir = SHEET.key === k ? -SHEET.dir : 1; SHEET.key = k; renderSheet(); return; } | |
| const tr = e.target.closest('tr[data-scroll]'); if (tr) { $('sheet').close(); select(tr.dataset.scroll, S.mode === 'lasagna' && tr.dataset.scroll === LASAGNA_SCROLL ? 'lasagna' : 'scan'); } | |
| }); | |
| $('b-sheet').addEventListener('click', () => openSheet(SHEET.tab)); | |
| // settings | |
| function applySettings(save = true) { | |
| bloom.strength = SET.glow; bloom.radius = SET.radius; | |
| const pr = pixelRatio(); if (Math.abs(renderer.getPixelRatio() - pr) > 1e-3) { renderer.setPixelRatio(pr); composer.setPixelRatio(pr); } | |
| document.body.classList.toggle('nofilm', !SET.film); | |
| $('s-glow').value = Math.round(SET.glow * 100); $('s-glow-v').textContent = SET.glow.toFixed(2); | |
| $('s-radius').value = Math.round(SET.radius * 100); $('s-radius-v').textContent = SET.radius.toFixed(2); | |
| $('s-quality').value = SET.quality; $('s-film').checked = SET.film; $('s-spin').checked = SET.spin; | |
| $('s-inset').checked = SET.inset; $('s-turn').checked = SET.turn; $('t-turn').checked = SET.turn; | |
| if (!SET.spin) controls.autoRotate = false; | |
| showInset(); if (S.sel && S.mode !== 'array') present(true); | |
| applyVolUniforms(); S.dirty = true; if (save) saveSet(); | |
| } | |
| function showInset() { const off = S.mode === 'array'; $('inset').hidden = off || !SET.inset; $('i-show').hidden = off || SET.inset; } | |
| $('b-set').addEventListener('click', (e) => { e.stopPropagation(); $('settings').hidden = !$('settings').hidden; }); | |
| document.addEventListener('pointerdown', (e) => { if (!$('settings').hidden && !e.target.closest('#settings, #b-set')) $('settings').hidden = true; }); | |
| $('s-glow').addEventListener('input', (e) => { SET.glow = e.target.value / 100; applySettings(); }); | |
| $('s-radius').addEventListener('input', (e) => { SET.radius = e.target.value / 100; applySettings(); }); | |
| $('s-quality').addEventListener('change', (e) => { SET.quality = e.target.value; applySettings(); }); | |
| $('s-film').addEventListener('change', (e) => { SET.film = e.target.checked; applySettings(); }); | |
| $('s-spin').addEventListener('change', (e) => { SET.spin = e.target.checked; applySettings(); }); | |
| $('s-inset').addEventListener('change', (e) => { SET.inset = e.target.checked; applySettings(); }); | |
| $('s-turn').addEventListener('change', (e) => { SET.turn = e.target.checked; applySettings(); }); | |
| $('t-turn').addEventListener('change', (e) => { SET.turn = e.target.checked; applySettings(); }); | |
| $('i-hide').addEventListener('click', () => { SET.inset = false; applySettings(); }); | |
| $('i-show').addEventListener('click', () => { SET.inset = true; applySettings(); }); | |
| $('s-reset').addEventListener('click', () => { Object.assign(SET, SET_DEFAULT); applySettings(); }); | |
| // first visit | |
| function seen() { try { return localStorage.getItem('umbilici.seen') === '1'; } catch { return true; } } | |
| function markSeen() { try { localStorage.setItem('umbilici.seen', '1'); } catch { /* private window */ } $('hint').hidden = true; } | |
| $('hint-go').addEventListener('click', () => { markSeen(); openGuide('basics'); }); | |
| $('hint-x').addEventListener('click', markSeen); | |
| // hover and pick in the array | |
| const ray = new THREE.Raycaster(); const ndc = new THREE.Vector2(); let downAt = null; | |
| canvas.addEventListener('pointermove', (e) => { | |
| if (S.mode !== 'array') return; | |
| if (e.buttons || moving) { if (S.hot) { S.hot = null; S.dirty = true; } $('tip').hidden = true; return; } | |
| ndc.set(e.clientX / innerWidth * 2 - 1, -e.clientY / innerHeight * 2 + 1); ray.setFromCamera(ndc, camera); | |
| const h = ray.intersectObjects(hitMeshes, false)[0]; const name = h ? h.object.userData.scroll : null; | |
| if (name !== S.hot) { S.hot = name; S.dirty = true; document.querySelectorAll('#rail li').forEach((li) => li.classList.toggle('hot', li.dataset.scroll === name)); } | |
| const tip = $('tip'); | |
| if (name) { | |
| const s = S.data.scrolls.find((q) => q.scroll === name); const g = gradeOf(s); | |
| tip.innerHTML = `<b>${name}</b><br>${s.lines.map((l) => S.data.sources[l.source].label).join(', ')}${g ? `<br>end check ${(() => { const f = s.lines.find(isFinder); return f && f.ends && Object.keys(f.ends).length ? endsHTML(f) : `<span class="grade ${g}">${g}</span>`; })()}` : ''}<br><span class="dim">click to open in X-ray</span>`; | |
| tip.style.left = `${e.clientX + 14}px`; tip.style.top = `${e.clientY + 14}px`; tip.hidden = false; canvas.style.cursor = 'pointer'; | |
| } else { tip.hidden = true; canvas.style.cursor = ''; } | |
| }); | |
| canvas.addEventListener('pointerdown', (e) => { downAt = [e.clientX, e.clientY]; }); | |
| canvas.addEventListener('pointerup', (e) => { | |
| if (!downAt || Math.hypot(e.clientX - downAt[0], e.clientY - downAt[1]) > 5) return; | |
| if (S.mode === 'array' && S.hot) { $('tip').hidden = true; select(S.hot, 'scan'); } | |
| }); | |
| // highlight the hovered scan | |
| // Hover dims the other scans, easing over a few frames; it never jumps, and it waits while the camera is dragged, | |
| // since a drag sweeping the pointer across scans used to halve and restore the whole glow many times a second. | |
| function applyHot() { | |
| let busy = false; | |
| for (const g of arrayGroup.children) { | |
| if (!g.userData.scroll) continue; const on = !S.hot || g.userData.scroll === S.hot; | |
| g.traverse((o) => { | |
| if (!o.isMesh || !o.material || o.material.visible === false || o.material.opacity === undefined) return; | |
| const base = o.userData.base ?? 1, goal = on ? base : base * 0.4, cur = o.material.opacity; | |
| if (Math.abs(goal - cur) > 0.004) { o.material.opacity = cur + (goal - cur) * 0.18; busy = true; } else o.material.opacity = goal; | |
| }); | |
| } | |
| if (busy) S.dirty = true; | |
| } | |
| // ---------- URL and agent API ---------- | |
| let urlT = 0; | |
| function syncURLSoon() { clearTimeout(urlT); urlT = setTimeout(syncURL, 300); } | |
| function syncURL() { | |
| const q = new URLSearchParams(); | |
| if (S.mode !== 'array') { q.set('mode', S.mode); q.set('scan', S.sel.scroll); q.set('z', Math.round(S.z)); } | |
| if (S.ex !== 3) q.set('ex', S.ex); | |
| const off = [...OFF.src, ...OFF.line]; if (off.length) q.set('off', off.join(',')); | |
| history.replaceState(null, '', q.toString() ? `?${q}` : location.pathname); | |
| } | |
| window.umbilici = { | |
| version: () => VERSION, | |
| show: (kind, name, on) => showLines(kind, name, on), | |
| scans: () => S.data.scrolls.map((s) => ({ scroll: s.scroll, scan: s.scan, volume: s.volume, voxel_um: s.voxel_um, shape_zyx: s.shape, grand_prize: s.grand_prize, first_letters: s.first_letters, sources: s.lines.map((l) => l.source), grade: gradeOf(s) })), | |
| lines: (name) => { const s = S.data.scrolls.find((q) => q.scroll === name); return s ? s.lines.map((l) => ({ ...l, points: linePts(l) })) : null; }, | |
| at: (name, z) => { const s = S.data.scrolls.find((q) => q.scroll === name); return s ? s.lines.map((l) => ({ source: l.source, xy: lineAt(l, z) })) : null; }, | |
| select: (name, mode = 'scan') => select(name, mode), | |
| mode: (m) => setMode(m), | |
| setZ: (z) => setZ(z, true), | |
| state: () => ({ mode: S.mode, scan: S.sel?.scroll ?? null, z: S.z, ex: S.ex, ct: { loaded: vol?.loaded ?? 0, total: vol?.total ?? 0 }, render: S.render, pixel_ratio: renderer.getPixelRatio(), auto_level: autoLevel }), | |
| image: () => { composer.render(); return renderer.domElement.toDataURL('image/png'); }, | |
| slice: () => sc.toDataURL('image/png'), | |
| stats: (name) => { const s = S.data.scrolls.find((q) => q.scroll === name); return s ? { lines: s.lines.map((l) => ({ source: l.source, file: RAW(l), ...statsOf(s, l) })), pairs: pairsOf(s) } : null; }, | |
| table: (which = 'lines') => (which === 'scans' ? scanRows() : lineRows()), | |
| csv: (which = 'lines') => toCSV(which === 'scans' ? scanRows() : which === 'points' ? pointRows() : lineRows()), | |
| guide: (tab) => openGuide(tab), | |
| bench: (n = 20) => { const t0 = performance.now(); const gl = renderer.getContext(), px = new Uint8Array(4); for (let i = 0; i < n; i++) { composer.render(); gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, px); } return { ms_per_frame: +((performance.now() - t0) / n).toFixed(1), pixel_ratio: renderer.getPixelRatio(), size: [innerWidth, innerHeight] }; }, | |
| settings: (s) => { if (s) { Object.assign(SET, s); applySettings(); } return { ...SET }; }, | |
| data: (tab) => openSheet(tab), | |
| }; | |
| // ---------- loop ---------- | |
| // Frames are capped at 60 a second, and at 30 while the array only spins by itself: a 120 Hz screen (MacBook Pro) | |
| // otherwise draws the full X-ray and glow twice as often for no visible gain. The spin is timed, not per frame. | |
| const frames = []; let lastFrame = 0, lastTick = 0, stepPending = false; | |
| function tick(now = performance.now()) { | |
| requestAnimationFrame(tick); | |
| const idleSpin = controls.autoRotate && !S.dirty && !moving; | |
| const gap = idleSpin ? 1000 / 30 : 1000 / 60; | |
| if (now - lastTick < gap - 2) return; | |
| const dt = lastTick ? Math.min(0.1, (now - lastTick) / 1000) : 1 / 60; lastTick = now; | |
| controls.update(dt); | |
| if (S.mode === 'array') applyHot(); | |
| else if (SET.turn && S.sel && !$('inset').hidden && shownAngle !== null && Math.abs(controls.getAzimuthalAngle() - shownAngle) > 0.003) present(true); | |
| if (S.dirty || controls.autoRotate) { | |
| composer.render(); placeLabels(); S.dirty = false; | |
| // slow means frames arriving well after the cap allows, not the cap itself | |
| if (now - lastFrame < 250) frames.push(now - lastFrame - gap); | |
| lastFrame = now; | |
| if (frames.length >= 24) { | |
| const m = median(frames); frames.length = 0; | |
| if (SET.quality === 'auto' && m > 9 && autoLevel < 2) stepPending = true; | |
| } | |
| if (stepPending && !moving) { stepPending = false; const s = autoSteps(); let k = autoLevel + 1; while (k < 2 && s[k] >= s[autoLevel]) k++; if (s[k] < s[autoLevel]) { autoLevel = k; applySettings(false); } } | |
| } | |
| } | |
| // ---------- live lines ---------- | |
| // The page reads the dataset's newest revision when it opens, so a new release of SharpMeow/vesuvius-center-lines | |
| // shows here without a redeploy. data/lines.json, built at deploy, is the fallback and supplies what a line file | |
| // does not change: the points and frame stamp of a file it already holds (dataset files never change in place; a | |
| // new version is a new file) and each scan's pyramid. A file or scan it lacks is read from the dataset and the bucket. | |
| const LINE_KEYS = ['source', 'kind', 'credit', 'licence', 'upstream', 'file', 'sha256', 'z', 'note']; | |
| const STAMP_KEYS = ['voxelsize_um', 'volume', 'volume_width', 'volume_height', 'volume_slices']; | |
| async function liveData(bundle) { | |
| try { | |
| const api = await fetch(`${DATASET.replace('/datasets/', '/api/datasets/')}`, { credentials: 'omit' }).then((r) => (r.ok ? r.json() : null)); | |
| const rev = api?.sha || 'main', at = (f) => `${DATASET}/resolve/${rev}/${f}`; | |
| const ix = await getJSON(at('index.json')); | |
| const held = new Map(bundle.scrolls.flatMap((s) => s.lines.map((l) => [`${l.file}:${l.sha256}`, l]))); | |
| const scanOf = new Map(bundle.scrolls.map((s) => [s.scroll, s])); | |
| const sources = { ...bundle.sources }; | |
| for (const f of ix.files) sources[f.source] ||= { label: f.credit, short: f.source.slice(0, 2).toUpperCase() }; | |
| const order = Object.keys(sources); | |
| const scrolls = await Promise.all(Object.entries(ix.scrolls) | |
| .sort(([a, x], [b, y]) => (!(x.first_letters || x.grand_prize)) - (!(y.first_letters || y.grand_prize)) || (a < b ? -1 : a > b ? 1 : 0)) | |
| .map(async ([name, sc]) => { | |
| const rows = ix.files.filter((f) => f.scroll === name); | |
| const vol = rows.find((f) => f.scan === sc.scan && f.volume)?.volume; | |
| let s = scanOf.get(name); | |
| if (!s || s.scan !== sc.scan || s.volume !== vol) s = await newScan(name, sc, vol); | |
| const lines = await Promise.all(rows.map((f) => liveLine(f, held.get(`${f.file}:${f.sha256}`), at))); | |
| lines.sort((a, b) => order.indexOf(a.source) - order.indexOf(b.source)); | |
| return { ...s, first_letters: sc.first_letters, grand_prize: sc.grand_prize, lines }; | |
| })); | |
| return { ...bundle, made: ix.made, frame: ix.frame, sources, scrolls, revision: rev, live: true }; | |
| } catch (e) { | |
| console.warn('center lines: using the copy built with this page', e); | |
| return { ...bundle, live: false }; | |
| } | |
| } | |
| async function newScan(name, sc, vol) { | |
| const zarr = `https://vesuvius-challenge-open-data.s3.us-east-1.amazonaws.com/${name}/volumes/${vol}`; | |
| const levels = (await getJSON(`${zarr}/.zattrs`)).multiscales[0].datasets.map((d) => d.path); | |
| const pyramid = await Promise.all(levels.map(async (path) => { | |
| const za = await getJSON(`${zarr}/${path}/.zarray`); | |
| return { path, shape: za.shape, chunks: za.chunks, dtype: za.dtype, compressor: za.compressor ? za.compressor.id : null }; | |
| })); | |
| return { scroll: name, scan: sc.scan, volume: vol, zarr, voxel_um: parseFloat(vol.match(/-([0-9.]+)um-/)[1]), | |
| shape: pyramid[0].shape, levels: levels.length, pyramid }; | |
| } | |
| // one line as data/lines.json holds it (tools/build_lines.py makes the same fields) | |
| async function liveLine(f, had, at) { | |
| const ends = {}; | |
| for (const [k, e] of Object.entries(f.ct_ends || {})) { ends[k] = {}; for (const q of ['grade', 'recapped', 'D_mm']) if (q in e) ends[k][q] = e[q]; } | |
| let grade = null; | |
| if (Object.keys(ends).length) grade = Object.values(ends).some((e) => e.grade !== 'PASS') ? 'FLAG' : 'PASS'; | |
| else { const m = (f.note || '').match(/end check (PASS|FLAG|VOID)/); if (m) grade = m[1]; } | |
| let points = had?.points, stamp = had?.stamp, usable = had ? had.usable : null; | |
| if (!had) { | |
| const doc = await getJSON(at(f.file)), md = doc.metadata || {}; | |
| points = [...doc.control_points].sort((a, b) => a.z - b.z).flatMap((p) => [Math.round(p.x), Math.round(p.y), Math.round(p.z)]); | |
| stamp = Object.fromEntries(STAMP_KEYS.filter((k) => k in md).map((k) => [k, md[k]])); | |
| const use = md.usable_ends; | |
| usable = use && typeof use === 'object' ? ['low', 'high'].filter((k) => use[k]) : null; | |
| } | |
| const l = Object.fromEntries(LINE_KEYS.map((k) => [k, f[k] ?? (k === 'note' ? '' : f[k])])); | |
| return { ...l, grade, ends, untested: !!f.ct_untested_scan, usable, stamp, points, superseded_by: f.superseded_by ?? null }; | |
| } | |
| // ---------- boot ---------- | |
| (async () => { | |
| S.data = await liveData(await getJSON('data/lines.json')); | |
| for (const k of Object.keys(S.data.sources)) SRC_HEX[k] ||= SRC_OTHER; | |
| try { S.las = await getJSON('data/lasagna/meta.json'); } catch { S.las = null; document.querySelector('.chip.las').disabled = true; } | |
| const nPrize = S.data.scrolls.filter(onPrize).length, nOther = S.data.scrolls.length - nPrize; | |
| $('k-count').textContent = nPrize; $('k-other').innerHTML = nOther ? `<span class="sep">/</span>${nOther} OTHER PUBLIC SCANS` : ''; | |
| $('h-count').textContent = numberWords(S.data.scrolls.length); | |
| $('a-sources').innerHTML = Object.entries(S.data.sources).map(([k, v]) => { | |
| const l = S.data.scrolls.flatMap((s) => s.lines).find((q) => q.source === k); const n = S.data.scrolls.filter((s) => s.lines.some((q) => q.source === k)).length; | |
| const names = S.data.scrolls.filter((s) => s.lines.some((q) => q.source === k)).map((s) => s.scroll).join(', '); | |
| return `<li><b class="${k.startsWith('finder') ? 'amber' : 'ice'}">${v.label}</b> (${v.short}): ${n} scan${n === 1 ? '' : 's'}, ${l ? lic(l) : ''}. <span class="dim">Credit as given: ${l ? l.credit : ''}. ${names}.</span></li>`; | |
| }).join(''); | |
| const all = S.data.scrolls.flatMap((s) => s.lines.map((l) => [s, l])); | |
| const bare = all.filter(([s, l]) => !statsOf(s, l).stamped); | |
| $('g-nscan').textContent = S.data.scrolls.length; $('g-nline').textContent = all.length; $('g-nsrc').textContent = Object.keys(S.data.sources).length; | |
| $('g-stamped').textContent = all.length - bare.length; | |
| const bareBy = {}; for (const [, l] of bare) bareBy[S.data.sources[l.source].label] = (bareBy[S.data.sources[l.source].label] || 0) + 1; | |
| $('g-unstamped').textContent = bare.length ? `the ${bare.length} without it (${Object.entries(bareBy).map(([k, n]) => `${k}: ${n}`).join('; ')})` : 'none'; | |
| $('g-made').textContent = S.data.made + (S.data.live ? `, read live at revision ${S.data.revision.slice(0, 8)}` : ', the copy built with this page'); $('g-frame').textContent = S.data.frame; | |
| $('g-nlev').textContent = S.data.scrolls.reduce((n, s) => n + s.pyramid.length, 0); | |
| $('g-las').textContent = S.las ? `cos at ${S.las.slice_z.length} heights (z ${S.las.slice_z.join(', ')}) as ${S.las.slice_px} px WebP; an orientation picture per height at ${S.las.ori_px} px (hue from the doubled in-plane angle, so a normal and its opposite match); ${S.las.needles.count.toLocaleString('en-US')} normals on a ${S.las.needles.grid_voxels.join(' x ')} voxel grid where cos > 40 and grad_mag > 20, with nz rebuilt as the square root of 1 - nx² - ny². Frame: ${S.las.frame}.` : 'not loaded.'; | |
| // the lasagna dataset may still be on its way to the Hub: say so instead of linking a 404 | |
| fetch('https://huggingface.co/api/datasets/SharpMeow/pherc0175a-lasagna', { credentials: 'omit' }).then((r) => { | |
| if (r.ok) return; | |
| document.querySelectorAll('a[href*="pherc0175a-lasagna"]').forEach((a) => { a.removeAttribute('href'); a.classList.add('soon'); a.title = 'being published to the Hub; check back soon'; a.textContent += ' (soon)'; }); | |
| }).catch(() => {}); | |
| const q = new URLSearchParams(location.search); | |
| if (q.get('ex')) { S.ex = Math.max(1, Math.min(30, +q.get('ex'))); $('ex').value = S.ex; $('ex-v').textContent = S.ex; } | |
| for (const k of (q.get('off') || '').split(',').filter(Boolean)) (k.includes(':') ? OFF.line : OFF.src).add(k); | |
| buildLegend(); | |
| applySettings(false); buildRail(); buildPick(); buildArray(); | |
| $('boot').remove(); | |
| tick(); | |
| if (q.get('scan')) { const m = q.get('mode') || 'scan'; select(q.get('scan'), m); if (q.get('z')) setZ(+q.get('z'), true); } | |
| else { setMode('array'); controls.autoRotate = SET.spin && !matchMedia('(prefers-reduced-motion: reduce)').matches; } | |
| if (q.get('guide')) openGuide(q.get('guide')); | |
| else if (q.get('data')) openSheet(q.get('data') === 'scans' ? 'scans' : 'lines'); | |
| else if (!seen() && !navigator.webdriver) $('hint').hidden = false; | |
| })().catch((e) => { $('boot').textContent = `could not load: ${e.message}`; console.error(e); }); | |