SharpMeow's picture
Paris 4 Ink Map 1.2: 3D scroll, neighboring turns, VC3D project, saved views
d3405a2 verified
Raw History Blame Contribute Delete
45.6 kB
'use strict';
// Paris 4 Ink Map: deep-zoom viewer, whole-scroll strip, scroll cross-section over CT, readout, measure, downloads.
const $ = (id) => document.getElementById(id);
const MM = 0.0768; // mm per map pixel
const OSD_IMAGES = 'https://cdnjs.cloudflare.com/ajax/libs/openseadragon/4.1.0/images/';
const NG = 'https://neuroglancer-demo.appspot.com/#!';
const S = {
info: null, viewer: null, cmap: 'gray', gamma: 1, contrast: 1, bright: 1, invert: false,
windOn: false, windOp: 0.45, grid: false, tool: 'pan', pin: null, hover: null, measure: [],
follow: true, xsZ: 105, section: null, ctImg: null, xsView: { s: 1, tx: 0, ty: 0 }, profile: null, ovRows: 4,
secCache: new Map(), lastPoint: 0, pointTimer: null, ready: false, nbAt: null, nbTag: 0, nbSeen: false, s3d: null,
};
const dpr = () => Math.min(window.devicePixelRatio || 1, 2);
const fmt = (v, d = 1) => (v == null || Number.isNaN(v) ? 'n/a' : Number(v).toFixed(d));
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
const BM_MAX = 200; // bookmarks kept in this browser
const esc = (t) => String(t).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
function toast(msg) {
const t = $('toast'); t.textContent = msg; t.classList.add('on');
clearTimeout(toast.t); toast.t = setTimeout(() => t.classList.remove('on'), 1800);
}
async function copy(text, what) {
try { await navigator.clipboard.writeText(text); toast(what || 'Copied'); }
catch (e) { prompt('Copy this:', text); }
}
function store(key, val) {
try { if (val === undefined) return JSON.parse(localStorage.getItem(key) || 'null'); localStorage.setItem(key, JSON.stringify(val)); }
catch (e) { return null; }
return null;
}
// winding colors: the same golden-ratio hues as the server's overlay
function windColor(w, a = 1) {
if (w == null || w < 0) return `rgba(150,150,150,${a})`;
const h = ((w * 0.6180339887) % 1) * 360;
return `hsla(${h.toFixed(0)},100%,${a < 1 ? 64 : 62}%,${a})`;
}
// ---------------------------------------------------------------- loading
async function poll() {
let s;
try { s = await (await fetch('api/status', { cache: 'no-store' })).json(); }
catch (e) { setTimeout(poll, 2000); return; }
const pill = $('status');
if (s.error) {
pill.textContent = 'Failed'; pill.className = 'pill bad';
$('load-phase').textContent = 'The map could not be read';
$('load-detail').textContent = s.error; return;
}
if (!s.ready) {
const pct = s.total_bytes ? Math.min(100, (100 * s.done_bytes) / s.total_bytes) : 0;
$('load-bar').style.width = (s.phase === 'downloading' ? pct * 0.8 : s.phase === 'sizing' ? 2 : 85) + '%';
$('load-phase').textContent = s.phase === 'downloading' ? 'Reading the map from the open data' : s.phase[0].toUpperCase() + s.phase.slice(1);
$('load-detail').textContent = s.total_bytes ? `${(s.done_bytes / 1e6).toFixed(0)} of ${(s.total_bytes / 1e6).toFixed(0)} MB 路 ${s.seconds} s` : `${s.seconds} s`;
pill.textContent = s.phase[0].toUpperCase() + s.phase.slice(1);
setTimeout(poll, 900); return;
}
pill.textContent = 'Live'; pill.className = 'pill ok';
await init();
}
async function init() {
S.info = await (await fetch('api/info')).json();
S.profile = (await (await fetch('api/profile')).json()).values;
$('loading').hidden = true; $('app').hidden = false;
readHash();
buildViewer();
buildControls();
buildOverview();
buildCrossSection();
buildFiles();
renderBookmarks();
build3d();
watchNeighbors();
S.ready = true;
}
// ---------------------------------------------------------------- 3D scroll, mounted when it comes into view
function build3d() {
const box = $('scroll3d-box'); if (!box || !window.Scroll3D) return;
const go = () => {
if (S.s3d) return;
box.innerHTML = '';
S.s3d = Scroll3D.mount(box, {
url: `api/mesh3d.bin?stride=${window.innerWidth < 700 ? 16 : 8}`, title: false, rows: 3,
onPick: (p) => {
const v = S.viewer; v.viewport.panTo(ink().imageToViewportCoordinates(p.x, p.y));
pointAt(p.x, p.y, true); toast(`Winding ${p.winding != null && p.winding >= 0 ? p.winding : 'n/a'}, ${(p.x * MM).toFixed(0)} mm from the outer end`);
},
});
if (S.s3d.ready) S.s3d.ready.catch(() => {}); // the view shows its own message when it cannot load
const c = S.pin || S.hover; if (c) S.s3d.setCursor({ x: c.x, y: c.y });
};
if (!('IntersectionObserver' in window)) { go(); return; }
const io = new IntersectionObserver((es) => { if (es.some((e) => e.isIntersecting)) { io.disconnect(); go(); } }, { rootMargin: '300px' });
io.observe(box);
}
// ---------------------------------------------------------------- the deep-zoom viewer
function inkSource() {
const { width, height, dzi_levels } = S.info;
return { width, height, tileSize: 256, tileOverlap: 0, minLevel: 0, maxLevel: dzi_levels - 1,
getTileUrl: (l, x, y) => `tiles/ink_files/${l}/${x}_${y}.jpg?cmap=${S.cmap}&gamma=${S.gamma}` };
}
function windSource() {
const { width, height, dzi_levels } = S.info;
return { width, height, tileSize: 256, tileOverlap: 0, minLevel: 0, maxLevel: dzi_levels - 1,
getTileUrl: (l, x, y) => `tiles/wind_files/${l}/${x}_${y}.png` };
}
// the ink image, so coordinates stay exact while the winding overlay is a second image
const ink = () => S.viewer.world.getItemAt(0) || S.viewer.viewport;
function buildViewer() {
const v = OpenSeadragon({
element: $('osd'), prefixUrl: OSD_IMAGES, tileSources: inkSource(), showNavigationControl: false,
maxZoomPixelRatio: 8, minZoomImageRatio: 0.9, visibilityRatio: 0.1, constrainDuringPan: false,
animationTime: 0.5, springStiffness: 9, imageLoaderLimit: 10, timeout: 90000, immediateRender: false,
gestureSettingsMouse: { clickToZoom: false, dblClickToZoom: true, scrollToZoom: true },
gestureSettingsTouch: { pinchRotate: false }, preserveImageSizeOnResize: true, zoomPerScroll: 1.35,
});
S.viewer = v;
v.addOnceHandler('open', () => {
if (S.start) {
const p = ink().imageToViewportCoordinates(S.start.x, S.start.y);
v.viewport.zoomTo(S.start.z, null, true); v.viewport.panTo(p, true);
} else {
// a readable first view: 60 mm wide, centered on the middle of the map
showMm(S.info.width * MM / 2, 60, true);
}
if (S.windOn) addWind();
applyFilter();
updateViewDeps();
});
v.addHandler('update-viewport', drawOverlay);
v.addHandler('animation', () => { drawOv(); });
v.addHandler('animation-finish', () => { updateViewDeps(); });
v.addHandler('resize', () => { drawOverlay(); });
v.addHandler('canvas-click', (e) => {
if (!e.quick) return;
e.preventDefaultAction = true;
const ip = ink().viewportToImageCoordinates(v.viewport.pointFromPixel(e.position));
if (S.tool === 'measure') {
if (S.measure.length >= 2) S.measure = [];
S.measure.push({ x: ip.x, y: ip.y }); drawOverlay();
} else if (S.pin) { S.pin = null; renderReadout(S.hover); drawOverlay(); }
else { pointAt(ip.x, ip.y, true); }
});
new OpenSeadragon.MouseTracker({
element: v.canvas,
moveHandler: (e) => {
const ip = ink().viewportToImageCoordinates(v.viewport.pointFromPixel(e.position));
S.mouse = ip; schedulePoint(ip.x, ip.y);
if (S.tool === 'measure' && S.measure.length === 1) drawOverlay();
},
leaveHandler: () => { S.mouse = null; $('hud').textContent = ''; },
});
}
function showMm(centerMm, widthMm, immediately) {
const v = S.viewer; const W = S.info.width;
const wpx = widthMm / MM; const cx = centerMm / MM;
const r = ink().imageToViewportRectangle(clamp(cx - wpx / 2, 0, W), 0, wpx, S.info.height);
v.viewport.fitBoundsWithConstraints(r, immediately);
}
function viewRect() {
const v = S.viewer; if (!v || !v.world.getItemCount()) return null;
return ink().viewportToImageRectangle(v.viewport.getBounds(true));
}
function reloadInk() {
const v = S.viewer; if (!v) return;
v.addTiledImage({ tileSource: inkSource(), index: 0, replace: true });
loadOvImage();
}
function addWind() {
const v = S.viewer;
if (S.windItem) { S.windItem.setOpacity(S.windOn ? S.windOp : 0); return; }
v.addTiledImage({ tileSource: windSource(), opacity: S.windOp, index: 1,
success: (e) => { S.windItem = e.item; S.windItem.setOpacity(S.windOn ? S.windOp : 0); } });
}
function applyFilter() {
const c = S.viewer && S.viewer.drawer && S.viewer.drawer.canvas;
if (c) c.style.filter = `contrast(${S.contrast}) brightness(${S.bright})${S.invert ? ' invert(1)' : ''}`;
}
// overlay: mm grid, measurement, pin, scale bar
function niceStep(mmPerCss, minCss) {
const steps = [0.1, 0.2, 0.5, 1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000];
return steps.find((s) => s / mmPerCss >= minCss) || 5000;
}
function drawOverlay() {
const v = S.viewer; const b = viewRect(); if (!b) return;
const c = $('osd-overlay'); const el = $('osd'); const k = dpr();
const cw = el.clientWidth, ch = el.clientHeight;
if (c.width !== Math.round(cw * k) || c.height !== Math.round(ch * k)) { c.width = Math.round(cw * k); c.height = Math.round(ch * k); }
const g = c.getContext('2d'); g.setTransform(k, 0, 0, k, 0, 0); g.clearRect(0, 0, cw, ch);
const sx = cw / b.width; // css px per map px
const X = (x) => (x - b.x) * sx, Y = (y) => (y - b.y) * sx;
const mmPerCss = MM / sx;
// scale bar
const bar = niceStep(mmPerCss, 70);
$('sb-bar').style.width = (bar / mmPerCss).toFixed(0) + 'px';
$('sb-label').textContent = bar >= 1000 ? `${bar / 1000} m` : bar >= 10 ? `${bar / 10} cm` : `${bar} mm`;
if (S.grid) {
const st = niceStep(mmPerCss, 48); const stp = st / MM;
g.strokeStyle = 'rgba(127,214,255,0.28)'; g.lineWidth = 1; g.beginPath();
for (let x = Math.ceil(b.x / stp) * stp; x < b.x + b.width; x += stp) { g.moveTo(Math.round(X(x)) + 0.5, 0); g.lineTo(Math.round(X(x)) + 0.5, ch); }
for (let y = Math.ceil(b.y / stp) * stp; y < b.y + b.height; y += stp) { g.moveTo(0, Math.round(Y(y)) + 0.5); g.lineTo(cw, Math.round(Y(y)) + 0.5); }
g.stroke();
g.fillStyle = 'rgba(127,214,255,0.8)'; g.font = '11px JetBrains Mono, monospace';
for (let x = Math.ceil(b.x / stp) * stp; x < b.x + b.width; x += stp) g.fillText(`${(x * MM).toFixed(st < 1 ? 1 : 0)}`, X(x) + 3, 12);
}
if (S.pin) {
const px = X(S.pin.x), py = Y(S.pin.y);
g.strokeStyle = '#f4b860'; g.lineWidth = 1.5; g.beginPath();
g.arc(px, py, 9, 0, Math.PI * 2); g.moveTo(px - 16, py); g.lineTo(px - 5, py); g.moveTo(px + 5, py); g.lineTo(px + 16, py);
g.moveTo(px, py - 16); g.lineTo(px, py - 5); g.moveTo(px, py + 5); g.lineTo(px, py + 16); g.stroke();
}
const m = S.measure.slice();
if (S.tool === 'measure' && m.length === 1 && S.mouse) m.push(S.mouse);
if (m.length) {
g.strokeStyle = '#ffd89a'; g.fillStyle = '#ffd89a'; g.lineWidth = 2;
g.beginPath(); g.moveTo(X(m[0].x), Y(m[0].y)); if (m[1]) g.lineTo(X(m[1].x), Y(m[1].y)); g.stroke();
m.forEach((p) => { g.beginPath(); g.arc(X(p.x), Y(p.y), 4, 0, Math.PI * 2); g.fill(); });
if (m[1]) {
const d = Math.hypot(m[1].x - m[0].x, m[1].y - m[0].y) * MM;
const tx = (X(m[0].x) + X(m[1].x)) / 2 + 8, ty = (Y(m[0].y) + Y(m[1].y)) / 2 - 8;
const label = d >= 10 ? `${d.toFixed(1)} mm` : `${d.toFixed(2)} mm`;
g.font = '600 13px JetBrains Mono, monospace'; const w = g.measureText(label).width;
g.fillStyle = 'rgba(5,7,10,.85)'; g.fillRect(tx - 5, ty - 15, w + 10, 21);
g.fillStyle = '#ffd89a'; g.fillText(label, tx, ty);
}
}
}
// ---------------------------------------------------------------- readout
function schedulePoint(x, y) {
S.next = { x, y };
if (S.pointTimer) return;
const wait = Math.max(0, 110 - (performance.now() - S.lastPoint));
S.pointTimer = setTimeout(() => { S.pointTimer = null; S.lastPoint = performance.now(); const n = S.next; pointAt(n.x, n.y, false); }, wait);
}
async function pointAt(x, y, pin) {
const { width, height } = S.info;
if (x < 0 || y < 0 || x >= width || y >= height) { if (!pin) $('hud').textContent = ''; return; }
let p;
try { p = await (await fetch(`api/point?x=${x.toFixed(1)}&y=${y.toFixed(1)}`)).json(); } catch (e) { return; }
if (pin) { S.pin = p; drawOverlay(); renderReadout(p); if (p.winding != null && S.nbSeen) runNeighbors(p); }
else { S.hover = p; if (!S.pin) renderReadout(p); }
$('hud').textContent = `${fmt(p.mm_from_outer_end, 1)} mm 路 ${p.winding != null ? 'winding ' + p.winding : p.ambiguous_winding ? 'two windings' : 'off the mesh'}${p.scan_z_mm != null ? ' 路 z ' + fmt(p.scan_z_mm, 1) + ' mm' : ''}`;
if (S.follow && p.scan_z_mm != null && !pin) setXsZ(p.scan_z_mm, false);
S.cursor = p; drawXs();
if (S.s3d && (pin || !S.pin)) S.s3d.setCursor({ x: p.x, y: p.y });
}
function renderReadout(p) {
const r = $('readout');
if (!p) { r.innerHTML = '<dt>Pointer</dt><dd class="dim">Move over the ink map</dd>'; return; }
const row = (k, v, cls = '') => `<dt>${k}</dt><dd class="${cls}">${v}</dd>`;
r.innerHTML = [
S.pin === p ? row('Pinned', 'Click the map again to unpin', 'pin') : '',
row('Winding', p.winding != null ? p.winding : p.ambiguous_winding ? 'Two windings overlap here' : 'Off the mesh'),
row('From outer end', `${fmt(p.mm_from_outer_end, 2)} mm`),
row('From inner end', `${fmt(p.mm_from_inner_end, 2)} mm`),
row('Down the map', `${fmt(p.mm_down_map, 2)} mm`),
row('Scan height', p.scan_z_mm != null ? `${fmt(p.scan_z_mm, 3)} mm` : 'n/a'),
row('Voxel 2.4 碌m', p.voxel_2_4um_xyz ? p.voxel_2_4um_xyz.join(' ') : 'n/a'),
row('Voxel 9.6 碌m', p.voxel_9_6um_xyz ? p.voxel_9_6um_xyz.map((v) => v.toFixed(1)).join(' ') : 'n/a'),
row('Map pixel', `${fmt(p.x, 0)} ${fmt(p.y, 0)}`),
row('Ink value', `${p.ink} of 255`),
].join('');
const ng = $('ng-link');
if (p.voxel_2_4um_xyz) {
const [x, y, z] = p.voxel_2_4um_xyz;
const state = { dimensions: { x: [2.4e-6, 'm'], y: [2.4e-6, 'm'], z: [2.4e-6, 'm'] }, position: [x, y, z],
crossSectionScale: 4, layers: [{ type: 'image', source: `zarr://${S.info.ct.zarr}/`, name: 'PHercParis4 2.4um 78keV' }], layout: '4panel' };
ng.href = NG + encodeURIComponent(JSON.stringify(state)); ng.removeAttribute('aria-disabled');
} else { ng.removeAttribute('href'); ng.setAttribute('aria-disabled', 'true'); }
}
// ---------------------------------------------------------------- controls, hash, bookmarks
function buildControls() {
const bind = (id, key, outId, after) => {
const el = $(id); el.value = S[key]; if (outId) $(outId).textContent = Number(S[key]).toFixed(2);
el.addEventListener('input', () => { S[key] = Number(el.value); if (outId) $(outId).textContent = Number(el.value).toFixed(2); after(); });
};
$('cmap').value = S.cmap;
$('cmap').addEventListener('change', () => { S.cmap = $('cmap').value; reloadInk(); writeHash(); });
let gt = null;
bind('gamma', 'gamma', 'gamma-v', () => { clearTimeout(gt); gt = setTimeout(() => { reloadInk(); writeHash(); }, 250); });
bind('contrast', 'contrast', 'contrast-v', () => { applyFilter(); writeHash(); });
bind('bright', 'bright', 'bright-v', () => { applyFilter(); writeHash(); });
bind('wind-op', 'windOp', null, () => { if (S.windItem && S.windOn) S.windItem.setOpacity(S.windOp); writeHash(); });
$('invert').checked = S.invert; $('invert').addEventListener('change', () => { S.invert = $('invert').checked; applyFilter(); writeHash(); });
$('wind-on').checked = S.windOn; $('wind-on').addEventListener('change', () => { S.windOn = $('wind-on').checked; addWind(); writeHash(); });
$('grid-on').checked = S.grid; $('grid-on').addEventListener('change', () => { S.grid = $('grid-on').checked; drawOverlay(); writeHash(); });
$('reset-view').addEventListener('click', () => S.viewer.viewport.goHome());
document.querySelectorAll('[data-tool]').forEach((b) => b.addEventListener('click', () => setTool(b.dataset.tool)));
$('help-btn').addEventListener('click', () => $('help').showModal());
// inside Hugging Face the page runs in a frame whose address carries a short-lived sign-in token: link the Space page
$('copy-link').addEventListener('click', () => {
writeHash();
const base = location.hostname.endsWith('.hf.space') ? 'https://huggingface.co/spaces/SharpMeow/paris4-ink-map' : location.origin + location.pathname;
copy(base + location.hash, 'View link copied');
});
$('copy-xyz').addEventListener('click', () => {
const p = S.pin || S.hover;
if (p && p.voxel_2_4um_xyz) copy(p.voxel_2_4um_xyz.join(' '), 'X Y Z (2.4 碌m voxels) copied'); else toast('No scan position here');
});
$('go-w-btn').addEventListener('click', goWinding);
$('go-w').addEventListener('keydown', (e) => { if (e.key === 'Enter') goWinding(); });
$('go-mm-btn').addEventListener('click', () => { const mm = Number($('go-mm').value); if (Number.isFinite(mm)) showMm(mm, viewRect().width * MM, false); });
$('bm-add').addEventListener('click', addBookmark);
$('bm-export').addEventListener('click', exportBookmarks);
$('bm-import').addEventListener('change', (e) => { const f = e.target.files[0]; if (f) f.text().then((t) => importBookmarks(t, 'file')); e.target.value = ''; });
$('bm-share').addEventListener('click', shareBookmarks);
$('save-view').addEventListener('click', saveView);
$('full-btn').addEventListener('click', toggleFull);
document.addEventListener('fullscreenchange', () => { $('full-btn').textContent = document.fullscreenElement ? 'Exit Fullscreen' : 'Fullscreen'; setTimeout(drawOverlay, 60); });
$('nb-run').addEventListener('click', () => runNeighbors(S.pin || S.hover, true));
$('nb-half').addEventListener('change', () => { if (S.nbAt) runNeighbors(S.nbAt, true); });
document.querySelectorAll('.nb-go').forEach((b) => b.addEventListener('click', () => {
const t = b.closest('.nb').dataset; if (!t.x) return;
const v = S.viewer; v.viewport.panTo(ink().imageToViewportCoordinates(Number(t.x), Number(t.y)));
pointAt(Number(t.x), Number(t.y), true); $('map').scrollIntoView({ behavior: 'smooth' });
}));
$('dl-scale').addEventListener('change', updateDownloads);
$('xs-follow').checked = S.follow;
$('xs-follow').addEventListener('change', () => { S.follow = $('xs-follow').checked; writeHash(); });
document.addEventListener('keydown', onKey);
renderReadout(null);
}
function setTool(t) {
S.tool = t; S.measure = [];
document.querySelectorAll('[data-tool]').forEach((b) => b.classList.toggle('on', b.dataset.tool === t));
$('osd').classList.toggle('measure', t === 'measure'); drawOverlay();
}
function goWinding() {
const w = Number($('go-w').value);
const b = S.info.windings.find((q) => q.winding === w);
if (!b) { toast('Windings run from 10 to 135'); return; }
const v = S.viewer; v.viewport.fitBoundsWithConstraints(ink().imageToViewportRectangle(b.x0, b.y0, b.x1 - b.x0, b.y1 - b.y0));
}
function onKey(e) {
if (e.target.closest('input, select, textarea') || e.metaKey || e.ctrlKey || e.altKey) return;
const k = e.key;
if (k === '?') $('help').showModal();
else if (k === 'w' || k === 'W') { $('wind-on').click(); }
else if (k === 'g' || k === 'G') { $('grid-on').click(); }
else if (k === 'm' || k === 'M') setTool(S.tool === 'measure' ? 'pan' : 'measure');
else if (k === 'i' || k === 'I') { $('invert').click(); }
else if (k === '[' || k === ']') { const g = $('gamma'); g.value = clamp(Number(g.value) + (k === ']' ? 0.1 : -0.1), 0.4, 2.5); g.dispatchEvent(new Event('input')); }
else if (k === 'b' || k === 'B') addBookmark();
else if (k === 'h' || k === 'H') S.viewer.viewport.goHome();
else if (k === 'l' || k === 'L') { $('xs-follow').click(); }
else if (k === 'f' || k === 'F') toggleFull();
else if (k === 's' || k === 'S') saveView();
else if (k === 'n' || k === 'N') { runNeighbors(S.pin || S.hover, true); $('neighbors').scrollIntoView({ behavior: 'smooth' }); }
else if (k === 'Escape') { S.measure = []; S.pin = null; renderReadout(S.hover); drawOverlay(); }
else return;
e.preventDefault();
}
function readHash() {
const h = new URLSearchParams(location.hash.slice(1));
const num = (k) => (h.has(k) && Number.isFinite(Number(h.get(k))) ? Number(h.get(k)) : null);
if (num('x') != null && num('y') != null) S.start = { x: num('x'), y: num('y'), z: num('z') || 50 };
if (h.get('c') && ['gray', 'ember', 'ice', 'viridis'].includes(h.get('c'))) S.cmap = h.get('c');
if (num('g')) S.gamma = clamp(num('g'), 0.4, 2.5);
if (num('ct')) S.contrast = clamp(num('ct'), 0.5, 3);
if (num('br')) S.bright = clamp(num('br'), 0.4, 2);
if (h.get('w') === '1') S.windOn = true;
if (num('wo')) S.windOp = clamp(num('wo'), 0.1, 1);
if (h.get('gr') === '1') S.grid = true;
if (h.get('inv') === '1') S.invert = true;
if (h.get('f') === '0') S.follow = false;
if (num('xz')) S.xsZ = num('xz');
if (h.get('bm')) {
try { importBookmarks(decodeURIComponent(escape(atob(h.get('bm').replace(/-/g, '+').replace(/_/g, '/')))), 'link'); } catch (e) { /* a broken link adds nothing */ }
}
}
function writeHash() {
const v = S.viewer; if (!v || !v.world.getItemCount()) return;
const c = ink().viewportToImageCoordinates(v.viewport.getCenter(true));
const h = new URLSearchParams({ x: c.x.toFixed(0), y: c.y.toFixed(0), z: v.viewport.getZoom(true).toFixed(2) });
if (S.cmap !== 'gray') h.set('c', S.cmap);
if (S.gamma !== 1) h.set('g', S.gamma.toFixed(2));
if (S.contrast !== 1) h.set('ct', S.contrast.toFixed(2));
if (S.bright !== 1) h.set('br', S.bright.toFixed(2));
if (S.windOn) h.set('w', '1');
if (S.windOp !== 0.45) h.set('wo', S.windOp.toFixed(2));
if (S.grid) h.set('gr', '1');
if (S.invert) h.set('inv', '1');
if (!S.follow) h.set('f', '0');
h.set('xz', S.xsZ.toFixed(1));
history.replaceState(null, '', '#' + h.toString());
}
function addBookmark() {
const v = S.viewer; const c = ink().viewportToImageCoordinates(v.viewport.getCenter(true));
const p = S.pin || S.hover;
const name = `${(c.x * MM).toFixed(0)} mm${p && p.winding != null ? ', winding ' + p.winding : ''}`;
const list = store('p4map.bookmarks') || [];
list.unshift({ name, x: Math.round(c.x), y: Math.round(c.y), z: v.viewport.getZoom(true) });
store('p4map.bookmarks', list.slice(0, BM_MAX)); renderBookmarks(); toast('Bookmark added');
}
function renderBookmarks() {
const list = store('p4map.bookmarks') || []; const ol = $('bm-list');
ol.innerHTML = list.map((b, i) => `<li><span data-i="${i}" title="Go there">${esc(b.name)}</span><button data-del="${i}" title="Remove">脳</button></li>`).join('');
ol.onclick = (e) => {
const go = e.target.closest('[data-i]'), del = e.target.closest('[data-del]');
const l = store('p4map.bookmarks') || [];
if (go) { const b = l[Number(go.dataset.i)]; const v = S.viewer; v.viewport.zoomTo(b.z); v.viewport.panTo(ink().imageToViewportCoordinates(b.x, b.y)); }
if (del) { l.splice(Number(del.dataset.del), 1); store('p4map.bookmarks', l); renderBookmarks(); }
};
}
// bookmarks travel as JSON: a file, or a link whose #bm= holds them
function cleanBookmarks(arr) {
if (!Array.isArray(arr)) return [];
return arr.filter((b) => b && [b.x, b.y, b.z].every((n) => Number.isFinite(Number(n))))
.map((b) => ({ name: String(b.name || `${(Number(b.x) * MM).toFixed(0)} mm`).slice(0, 80), x: Math.round(Number(b.x)), y: Math.round(Number(b.y)), z: clamp(Number(b.z), 0.01, 5000) }));
}
function importBookmarks(text, from) {
let got = [];
try { const j = JSON.parse(text); got = cleanBookmarks(Array.isArray(j) ? j : j.bookmarks); } catch (e) { got = []; }
if (!got.length) { if (from === 'file') toast('No bookmarks in that file'); return; }
const list = store('p4map.bookmarks') || [];
const key = (b) => `${b.x},${b.y},${b.z.toFixed(2)}`; const have = new Set(list.map(key));
const fresh = got.filter((b) => !have.has(key(b))).slice(0, Math.max(0, BM_MAX - list.length));
// the visitor's own bookmarks stay first; imports fill the room left, never push them out
store('p4map.bookmarks', list.concat(fresh)); renderBookmarks();
toast(fresh.length ? `${fresh.length} bookmark${fresh.length > 1 ? 's' : ''} added` : list.length >= BM_MAX ? `Bookmarks are full (${BM_MAX}): remove some first` : 'Those bookmarks are already here');
}
function exportBookmarks() {
const list = store('p4map.bookmarks') || [];
if (!list.length) { toast('No bookmarks yet: press B to add this view'); return; }
const body = JSON.stringify({ map: 'PHerc. Paris 4, spiral-output-generation-19', mm_per_px: MM, bookmarks: list }, null, 1);
const a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([body], { type: 'application/json' }));
a.download = 'paris4-ink-map-bookmarks.json'; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 2000);
}
function shareBookmarks() {
const list = store('p4map.bookmarks') || [];
if (!list.length) { toast('No bookmarks yet: press B to add this view'); return; }
const b64 = btoa(unescape(encodeURIComponent(JSON.stringify(list.slice(0, 30))))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
writeHash();
const base = location.hostname.endsWith('.hf.space') ? 'https://huggingface.co/spaces/SharpMeow/paris4-ink-map' : location.origin + location.pathname;
copy(`${base}${location.hash}&bm=${b64}`, 'Link with your bookmarks copied');
}
// ---------------------------------------------------------------- fullscreen and saving the view
function toggleFull() {
const el = document.querySelector('.viewer-col');
if (document.fullscreenElement) document.exitFullscreen();
else if (el.requestFullscreen) el.requestFullscreen().catch(() => toast('Fullscreen is not allowed here'));
else toast('Fullscreen is not available in this browser');
}
function saveView() {
const v = S.viewer; const b = viewRect(); const src = v && v.drawer && v.drawer.canvas; if (!b || !src) return;
const W = src.width, H = src.height, foot = Math.round(44 * W / src.clientWidth);
const c = document.createElement('canvas'); c.width = W; c.height = H + foot; const g = c.getContext('2d');
g.fillStyle = '#05070a'; g.fillRect(0, 0, c.width, c.height);
g.filter = src.style.filter || 'none'; g.drawImage(src, 0, 0); g.filter = 'none';
g.drawImage($('osd-overlay'), 0, 0, W, H);
const k = W / src.clientWidth; const mmPerPx = (b.width * MM) / W;
const bar = niceStep(mmPerPx / k, 90); const barPx = bar / mmPerPx;
g.fillStyle = 'rgba(5,7,10,.72)'; g.fillRect(14 * k, H - 40 * k, barPx + 24 * k, 28 * k);
g.fillStyle = '#f4b860'; g.fillRect(26 * k, H - 22 * k, barPx, 4 * k);
g.font = `${600} ${12 * k}px JetBrains Mono, monospace`;
g.fillText(bar >= 1000 ? `${bar / 1000} m` : bar >= 10 ? `${bar / 10} cm` : `${bar} mm`, 26 * k, H - 27 * k);
const p = S.pin || S.hover;
g.fillStyle = '#e6e9ee'; g.font = `${500} ${13 * k}px Inter, system-ui, sans-serif`;
const where = `PHerc. Paris 4 ink map 路 ${(b.x * MM).toFixed(1)} to ${((b.x + b.width) * MM).toFixed(1)} mm from the outer end${p && p.winding != null ? ' 路 winding ' + p.winding : ''}`;
g.fillText(where, 14 * k, H + 18 * k);
g.fillStyle = '#8b96a5'; g.font = `${12 * k}px Inter, system-ui, sans-serif`;
g.fillText('Organizers\' ink map, Vesuvius Challenge open data, CC BY-NC 4.0 路 model output, not a reading 路 huggingface.co/spaces/SharpMeow/paris4-ink-map', 14 * k, H + 36 * k);
c.toBlob((blob) => {
if (!blob) { toast('Could not save this view'); return; }
const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
a.download = `paris4-ink-map-${Math.round(b.x)}-${Math.round(b.y)}.png`; a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 2000); toast('View saved as PNG');
}, 'image/png');
}
// ---------------------------------------------------------------- neighboring turns
// a pin compares only while the section is on screen; the button and N always do
function watchNeighbors() {
const sec = $('neighbors'); if (!sec) return;
if (!('IntersectionObserver' in window)) { S.nbSeen = true; return; }
new IntersectionObserver((es) => {
S.nbSeen = es.some((e) => e.isIntersecting);
if (S.nbSeen && S.pin && S.pin.winding != null && S.nbAt !== S.pin) runNeighbors(S.pin);
}).observe(sec);
}
async function runNeighbors(p, loud) {
if (!p || p.winding == null) { if (loud) toast('Pin a place on a winding first: click the ink map'); return; }
S.nbAt = p; const half = Number($('nb-half').value); const tag = ++S.nbTag;
$('nb-msg').textContent = `Winding ${p.winding}, ${fmt(p.mm_from_outer_end, 1)} mm from the outer end: comparing`;
let n;
try {
const r = await fetch(`api/neighbors?x=${p.x}&y=${p.y}&half_mm=${half}`);
n = await r.json(); if (!r.ok) throw new Error(n.detail || r.statusText);
} catch (e) { if (tag === S.nbTag) $('nb-msg').textContent = `Could not compare: ${e.message}`; return; }
if (tag !== S.nbTag) return;
const hp = Math.round(half / MM);
const img = (x, y) => `api/crop?x0=${Math.round(x - hp)}&y0=${Math.round(y - hp)}&x1=${Math.round(x + hp)}&y1=${Math.round(y + hp)}&scale=${hp > 200 ? 2 : 1}&cmap=${S.cmap}`;
const fill = (id, t) => {
const f = $(id); const im = f.querySelector('img'); const meta = f.querySelector('.nb-meta'); const sc = f.querySelector('.nb-score');
if (!t.found) {
im.removeAttribute('src'); f.classList.add('none'); delete f.dataset.x; if (sc) sc.hidden = true;
meta.textContent = t.nearest_mm != null ? `Winding ${t.winding}: nearest crossing ${fmt(t.nearest_mm, 1)} mm away, a gap in the fit` : `Winding ${t.winding}: not in the fit at this height`;
return;
}
f.classList.remove('none'); f.dataset.x = t.x; f.dataset.y = t.y; im.src = img(t.x, t.y);
meta.textContent = `Winding ${t.winding} 路 ${fmt(t.distance_mm, 2)} mm away 路 alike ${fmt(t.similarity, 2)}`;
if (sc) {
sc.hidden = false; const i = sc.querySelector('i'); const bl = t.baseline;
i.style.width = `${clamp(t.similarity, 0, 1) * 100}%`;
sc.classList.toggle('high', !!t.above_baseline);
sc.title = bl ? `This pair of turns elsewhere at this height: median ${bl.median}, 95th percentile ${bl.p95}` : '';
sc.style.setProperty('--p95', bl ? `${clamp(bl.p95, 0, 1) * 100}%` : '-10px');
if (bl) meta.textContent += ` (usual ${bl.median})`;
}
};
fill('nb-outer', n.turns[0]); fill('nb-inner', n.turns[1]);
const h = $('nb-here'); h.querySelector('img').src = img(n.x, n.y);
h.querySelector('.nb-meta').textContent = `Winding ${n.winding} 路 z ${fmt(n.scan_z_mm, 1)} mm`;
const hi = n.turns.filter((t) => t.above_baseline);
const nb = n.turns.map((t) => (t.baseline ? t.baseline.n : 0)).reduce((a, b) => Math.max(a, b), 0);
$('nb-msg').textContent = hi.length
? `Here the ${hi.map((t) => t.side).join(' and ')} turn is more alike than at 95% of other places along the same pair of turns.`
: nb ? 'Neither neighbor is more alike here than usual for these turns.' : 'Too little of this turn at this height for a baseline.';
$('nb-null').textContent = `Alike: the best normalized cross-correlation of the two windows (${2 * half} mm square) within ${n.shift_mm} mm of shift. Neighboring turns are always somewhat alike, so the tick on each bar is the 95th percentile of the same score at up to ${nb || 24} other places along the same two turns at this height.`;
}
// ---------------------------------------------------------------- whole-scroll strip
function ovGeom() {
const img = $('ov-img'); const R = S.ovRows; const { width: W, height: H } = S.info;
const nw = img.naturalWidth || 1; const seg = Math.ceil(W / R); const scale = nw / seg;
const rh = Math.max(1, Math.round(H * scale)); const css = img.clientWidth / nw;
return { R, seg, scale, rh, css, gap: 6 };
}
function buildOverview() {
const sel = $('ov-rows');
S.ovRows = window.innerWidth < 600 ? 6 : 3; sel.value = String(S.ovRows);
sel.addEventListener('change', () => { S.ovRows = Number(sel.value); loadOvImage(); });
$('ov-img').addEventListener('load', drawOv);
const cv = $('ov-canvas'); let drag = false;
const go = (e) => {
const r = cv.getBoundingClientRect(); const g = ovGeom();
const xi = (e.clientX - r.left) / g.css, yi = (e.clientY - r.top) / g.css;
const row = clamp(Math.floor(yi / (g.rh + g.gap)), 0, g.R - 1);
const x = clamp(row * g.seg + xi / g.scale, 0, S.info.width - 1);
const y = clamp((yi - row * (g.rh + g.gap)) / g.scale, 0, S.info.height - 1);
const v = S.viewer; v.viewport.panTo(ink().imageToViewportCoordinates(x, y), drag);
};
cv.addEventListener('pointerdown', (e) => { drag = true; cv.setPointerCapture(e.pointerId); go(e); });
cv.addEventListener('pointermove', (e) => { if (drag) go(e); else ovHover(e); });
cv.addEventListener('pointerup', () => { drag = false; updateViewDeps(); });
let rt = null;
window.addEventListener('resize', () => { clearTimeout(rt); rt = setTimeout(() => { loadOvImage(); drawXs(); }, 250); });
loadOvImage();
}
function loadOvImage() {
const w = Math.min(3200, Math.round($('ov-wrap').clientWidth * dpr()));
$('ov-img').src = `api/overview.png?rows=${S.ovRows}&width=${w}&cmap=${S.cmap}`;
}
function ovHover(e) {
const cv = $('ov-canvas'); const r = cv.getBoundingClientRect(); const g = ovGeom();
const xi = (e.clientX - r.left) / g.css, yi = (e.clientY - r.top) / g.css;
const row = clamp(Math.floor(yi / (g.rh + g.gap)), 0, g.R - 1);
const x = row * g.seg + xi / g.scale;
const w = S.info.windings.filter((q) => x >= q.x0 && x < q.x1).map((q) => q.winding);
cv.title = `${(x * MM / 1000).toFixed(3)} m from the outer end${w.length ? ', winding ' + w.join(' or ') : ''}`;
}
function drawOv() {
const img = $('ov-img'); const cv = $('ov-canvas'); if (!img.naturalWidth || !S.info) return;
const k = dpr(); const cw = img.clientWidth, ch = img.clientHeight;
if (cv.width !== Math.round(cw * k) || cv.height !== Math.round(ch * k)) { cv.width = Math.round(cw * k); cv.height = Math.round(ch * k); }
const g = cv.getContext('2d'); g.setTransform(k, 0, 0, k, 0, 0); g.clearRect(0, 0, cw, ch);
const o = ovGeom(); const f = o.scale * o.css; const rowH = o.rh * o.css, gap = o.gap * o.css;
const pos = (x) => { const row = Math.min(o.R - 1, Math.floor(x / o.seg)); return { row, px: (x - row * o.seg) * f, py: row * (rowH + gap) }; };
// ink density along each strip
if (S.profile) {
g.strokeStyle = 'rgba(244,184,96,0.55)'; g.lineWidth = 1;
for (let row = 0; row < o.R; row++) {
g.beginPath(); let first = true;
const b0 = Math.floor((row * o.seg) / 16), b1 = Math.min(S.profile.length, Math.ceil(((row + 1) * o.seg) / 16));
const stepB = Math.max(1, Math.floor((b1 - b0) / cw));
for (let b = b0; b < b1; b += stepB) {
const px = (b * 16 - row * o.seg) * f; const py = row * (rowH + gap) + rowH - 2 - (S.profile[b] / 255) * rowH * 0.35;
if (first) { g.moveTo(px, py); first = false; } else g.lineTo(px, py);
}
g.stroke();
}
}
// winding ticks
g.font = '10px JetBrains Mono, monospace'; g.textBaseline = 'top';
for (const q of S.info.windings) {
if (q.winding % 5 && q.winding !== 10 && q.winding !== 135) continue;
const p = pos(q.x0);
g.fillStyle = 'rgba(127,214,255,0.9)'; g.fillRect(p.px, p.py, 1, 6);
g.fillStyle = 'rgba(0,0,0,0.65)'; const t = String(q.winding); const tw = g.measureText(t).width;
g.fillRect(p.px + 2, p.py + 1, tw + 4, 12); g.fillStyle = '#bfe9ff'; g.fillText(t, p.px + 4, p.py + 2);
}
// the view
const b = viewRect(); if (!b) return;
const x0 = clamp(b.x, 0, S.info.width), x1 = clamp(b.x + b.width, 0, S.info.width);
const y0 = clamp(b.y, 0, S.info.height) * f, y1 = clamp(b.y + b.height, 0, S.info.height) * f;
g.strokeStyle = '#f4b860'; g.lineWidth = 2; g.fillStyle = 'rgba(244,184,96,0.16)';
for (let row = Math.floor(x0 / o.seg); row <= Math.min(o.R - 1, Math.floor((x1 - 1) / o.seg)); row++) {
const a = Math.max(x0, row * o.seg), z = Math.min(x1, (row + 1) * o.seg);
const rx = (a - row * o.seg) * f, rw = Math.max(2, (z - a) * f), ry = row * (rowH + gap) + y0, rh = Math.max(2, y1 - y0);
g.fillRect(rx, ry, rw, rh); g.strokeRect(rx, ry, rw, rh);
}
}
// ---------------------------------------------------------------- scroll cross-section
function buildCrossSection() {
const z = S.info.scan_z_mm; const sl = $('xs-z');
sl.min = Math.ceil(z[0] * 10) / 10; sl.max = Math.floor(z[1] * 10) / 10;
S.xsZ = clamp(S.xsZ, Number(sl.min), Number(sl.max)); sl.value = S.xsZ;
sl.addEventListener('input', () => { setXsZ(Number(sl.value), true); });
const cv = $('xs'); let drag = null;
cv.addEventListener('wheel', (e) => {
e.preventDefault(); const r = cv.getBoundingClientRect(); const mx = e.clientX - r.left, my = e.clientY - r.top;
const f = Math.exp(-e.deltaY * 0.0015); const v = S.xsView; const s2 = clamp(v.s * f, 1, 40); const ff = s2 / v.s;
v.tx = mx - (mx - v.tx) * ff; v.ty = my - (my - v.ty) * ff; v.s = s2; drawXs();
}, { passive: false });
cv.addEventListener('pointerdown', (e) => { drag = { x: e.clientX, y: e.clientY, tx: S.xsView.tx, ty: S.xsView.ty, moved: false }; cv.setPointerCapture(e.pointerId); });
cv.addEventListener('pointermove', (e) => {
if (drag) {
const dx = e.clientX - drag.x, dy = e.clientY - drag.y; if (Math.hypot(dx, dy) > 3) drag.moved = true;
S.xsView.tx = drag.tx + dx; S.xsView.ty = drag.ty + dy; drawXs(); return;
}
const n = nearestXs(e); $('xs-msg').textContent = n ? `Winding ${n.w >= 0 ? n.w : '?'} 路 ${(n.mx * MM).toFixed(0)} mm from the outer end` : xsLabel();
});
cv.addEventListener('pointerup', (e) => {
const d = drag; drag = null; if (!d || d.moved) return;
const n = nearestXs(e); if (!n) return;
const v = S.viewer; v.viewport.panTo(ink().imageToViewportCoordinates(n.mx, n.my));
});
cv.addEventListener('dblclick', () => { S.xsView = { s: 1, tx: 0, ty: 0 }; drawXs(); });
loadSection(S.xsZ);
}
function xsLabel() { return `Height ${S.xsZ.toFixed(1)} mm 路 ${S.section ? S.section.n.toLocaleString() + ' crossings' : 'loading'}`; }
function setXsZ(z, fromSlider) {
S.xsZ = Math.round(z * 10) / 10; $('xs-z-v').textContent = `${S.xsZ.toFixed(1)} mm`;
if (!fromSlider) $('xs-z').value = S.xsZ;
clearTimeout(setXsZ.t); setXsZ.t = setTimeout(() => { loadSection(S.xsZ); writeHash(); }, fromSlider ? 180 : 350);
}
async function loadSection(z) {
const key = z.toFixed(1); $('xs-z-v').textContent = `${z.toFixed(1)} mm`;
if (!S.secCache.has(key)) {
$('xs-msg').textContent = 'Loading the CT at this height';
const img = new Image(); img.decoding = 'async';
const ctReady = new Promise((res) => { img.onload = () => res(img); img.onerror = () => res(null); });
img.src = `api/ct.png?z_mm=${key}`;
const sec = fetch(`api/section?z_mm=${key}&step=2`).then((r) => r.json());
const [s, im] = await Promise.all([sec, ctReady]);
S.secCache.set(key, { s, im });
if (S.secCache.size > 24) S.secCache.delete(S.secCache.keys().next().value);
}
if (S.xsZ.toFixed(1) !== key && S.section) return; // a newer height was asked for meanwhile
const e = S.secCache.get(key); S.section = e.s; S.ctImg = e.im; $('xs-msg').textContent = xsLabel(); drawXs();
}
function xsTransform() {
const cv = $('xs'); const cw = cv.clientWidth; const [x0, y0, x1, y1] = S.info.ct.crop;
const base = cw / Math.max(x1 - x0, y1 - y0); const v = S.xsView;
return { k: base * v.s, ox: v.tx - x0 * base * v.s, oy: v.ty - y0 * base * v.s, x0, y0 };
}
function nearestXs(e) {
const s = S.section; if (!s) return null;
const cv = $('xs'); const r = cv.getBoundingClientRect(); const t = xsTransform();
const mx = (e.clientX - r.left - t.ox) / t.k, my = (e.clientY - r.top - t.oy) / t.k;
let best = -1, bd = (10 / t.k) ** 2;
for (let i = 0; i < s.n; i++) { const d = (s.x[i] - mx) ** 2 + (s.y[i] - my) ** 2; if (d < bd) { bd = d; best = i; } }
return best < 0 ? null : { w: s.winding[best], mx: s.map_x[best], my: s.map_y[best] };
}
function drawXs() {
const cv = $('xs'); if (!cv || !S.info) return;
const k = dpr(); const cw = cv.clientWidth, ch = cv.clientHeight;
if (cv.width !== Math.round(cw * k) || cv.height !== Math.round(ch * k)) { cv.width = Math.round(cw * k); cv.height = Math.round(ch * k); }
const g = cv.getContext('2d'); g.setTransform(k, 0, 0, k, 0, 0); g.fillStyle = '#000'; g.fillRect(0, 0, cw, ch);
const t = xsTransform();
if (S.ctImg) { g.imageSmoothingEnabled = t.k < 2; g.globalAlpha = 0.9; g.drawImage(S.ctImg, t.ox + t.x0 * t.k, t.oy + t.y0 * t.k, S.ctImg.naturalWidth * t.k, S.ctImg.naturalHeight * t.k); g.globalAlpha = 1; }
const s = S.section; if (!s) return;
const P = (i) => [t.ox + s.x[i] * t.k, t.oy + s.y[i] * t.k];
const lw = clamp(0.6 * Math.sqrt(t.k), 0.7, 2.4);
// the spiral, colored by winding
g.lineWidth = lw; let prevW = null;
for (let i = 1; i < s.n; i++) {
if (Math.hypot(s.x[i] - s.x[i - 1], s.y[i] - s.y[i - 1]) > 20) continue;
if (s.winding[i] !== prevW) { g.strokeStyle = windColor(s.winding[i], 0.75); prevW = s.winding[i]; }
const a = P(i - 1), b = P(i); g.beginPath(); g.moveTo(a[0], a[1]); g.lineTo(b[0], b[1]); g.stroke();
}
// the papyrus in the ink view, at this height
const b = viewRect();
if (b) {
g.strokeStyle = '#ffd89a'; g.lineWidth = lw + 2.2; g.shadowColor = 'rgba(244,184,96,.9)'; g.shadowBlur = 8; g.beginPath();
let open = false;
for (let i = 0; i < s.n; i++) {
const inside = s.map_x[i] >= b.x && s.map_x[i] <= b.x + b.width && s.map_y[i] >= b.y && s.map_y[i] <= b.y + b.height;
if (!inside) { open = false; continue; }
const p = P(i);
if (open && Math.hypot(s.x[i] - s.x[i - 1], s.y[i] - s.y[i - 1]) <= 20) g.lineTo(p[0], p[1]); else g.moveTo(p[0], p[1]);
open = true;
}
g.stroke(); g.shadowBlur = 0;
}
// the pointer
const c = S.cursor;
if (c && c.voxel_9_6um_xyz && Math.abs(c.scan_z_mm - S.xsZ) < 1.5) {
const lv = 2 ** (S.info.ct.level - 2); const px = t.ox + (c.voxel_9_6um_xyz[0] / lv) * t.k, py = t.oy + (c.voxel_9_6um_xyz[1] / lv) * t.k;
g.strokeStyle = '#7fd6ff'; g.lineWidth = 2; g.beginPath(); g.arc(px, py, 7, 0, Math.PI * 2); g.stroke();
}
// scale bar
const mmPerCss = (2.4e-3 * 2 ** S.info.ct.level) / t.k; const bar = niceStep(mmPerCss, 50);
g.fillStyle = '#fff'; g.fillRect(10, ch - 14, bar / mmPerCss, 3); g.font = '11px JetBrains Mono, monospace'; g.textBaseline = 'bottom';
g.fillText(bar >= 10 ? `${bar / 10} cm` : `${bar} mm`, 10, ch - 17);
}
// ---------------------------------------------------------------- downloads
function buildFiles() {
const human = (n) => (n == null ? '' : n > 1e9 ? `${(n / 1e9).toFixed(2)} GB` : n > 1e6 ? `${(n / 1e6).toFixed(1)} MB` : `${(n / 1e3).toFixed(1)} KB`);
$('files').innerHTML = S.info.sources.map((s) => {
const folder = s.url.endsWith('/');
const href = folder ? s.url.replace('vesuvius-challenge-open-data.s3.us-east-1.amazonaws.com/', 'vesuvius-challenge-open-data.s3.amazonaws.com/index.html#') : s.url;
const sha = s.sha256 ? `<code class="sha">${s.sha256}</code><button type="button" class="icon-btn" data-copy="${s.sha256}" title="Copy the SHA-256">Copy</button>` : '';
return `<tr><td class="f-name"><a href="${href}" target="_blank" rel="noopener">${s.title}</a></td>` +
`<td class="f-size mono">${folder ? 'Folder' : human(s.bytes)}</td><td class="f-sha">${sha}</td></tr>`;
}).join('');
}
const PNG_MAX = 60e6;
function updateDownloads() {
const b = viewRect(); if (!b) return;
const W = S.info.width, H = S.info.height;
const x0 = Math.floor(clamp(b.x, 0, W)), x1 = Math.ceil(clamp(b.x + b.width, 0, W));
const y0 = Math.floor(clamp(b.y, 0, H)), y1 = Math.ceil(clamp(b.y + b.height, 0, H));
const sc = Number($('dl-scale').value); const w = Math.max(1, Math.floor((x1 - x0) / sc)), h = Math.max(1, Math.floor((y1 - y0) / sc));
const big = w * h > PNG_MAX;
$('dl-size').textContent = `${w.toLocaleString()} 脳 ${h.toLocaleString()} px 路 ${((x1 - x0) * MM).toFixed(1)} 脳 ${((y1 - y0) * MM).toFixed(1)} mm`;
$('dl-note').textContent = big ? 'Over 60 Mpx: PNG is off at this size; the TIFF has no limit.' : 'PNG keeps the current colors; TIFF is the raw map, 0 to 255.';
const q = `x0=${x0}&y0=${y0}&x1=${x1}&y1=${y1}&scale=${sc}`;
const png = $('dl-png'), tif = $('dl-tif');
if (big) { png.removeAttribute('href'); png.setAttribute('aria-disabled', 'true'); } else { png.href = `api/crop?${q}&fmt=png&cmap=${S.cmap}`; png.removeAttribute('aria-disabled'); }
tif.href = `api/crop?${q}&fmt=tif`;
}
function updateViewDeps() { drawOv(); drawXs(); updateDownloads(); writeHash(); const b = viewRect(); if (b) $('where').textContent = `${(b.x * MM).toFixed(0)} to ${((b.x + b.width) * MM).toFixed(0)} mm from the outer end`; }
document.addEventListener('click', (e) => {
const b = e.target.closest('[data-copy]'); if (!b) return;
copy(b.dataset.copy.startsWith('/') ? location.origin + b.dataset.copy : b.dataset.copy, b.dataset.what || 'Copied');
});
{
const ngState = { dimensions: { x: [7.68e-5, 'm'], y: [7.68e-5, 'm'] }, position: [71141, 1195], crossSectionScale: 8,
layers: [{ type: 'image', source: 'zarr://https://sharpmeow-paris4-ink-map.hf.space/zarr/', name: 'Paris 4 ink map' }], layout: 'xy' };
const a = $('ng-zarr'); if (a) a.href = NG + encodeURIComponent(JSON.stringify(ngState));
}
poll();