Spaces:
Running
Running
Download static/app.js from SharpMeow/paris4-ink-map: direct link, hf CLI and curl.
- Browser
- Download file 45.6 kB
-
https://huggingface.co/spaces/SharpMeow/paris4-ink-map/resolve/main/static/app.js
- Command line
-
hf download hf://spaces/SharpMeow/paris4-ink-map/static/app.js
-
curl -L -o app.js https://huggingface.co/spaces/SharpMeow/paris4-ink-map/resolve/main/static/app.js
45.6 kB
| ; | |
| // 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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(); | |