// SPDX-License-Identifier: Apache-2.0 // window.unroll.ct: the CT tools (lib/dragres.js, lib/ctwl.js, lib/ctvolume.js, lib/ctlabels.js, lib/ctsource.js) // for agents and tests, plus data-testid / data-api on their controls and glossary links in their panel groups. // CT_SPECS is plain data so tools/build_api_json.mjs can write it into api.json. const p = (name, type, desc, extra = {}) => ({ name, type, desc, ...extra }); export const CT_SPECS = { dragRes: { summary: 'Lower render resolution while the camera moves: "off", "light" (0.75x) or "strong" (0.5x). Without an argument, returns the state.', params: [p('mode', 'string', 'off, light or strong', { required: false, enum: ['off', 'light', 'strong'] })] }, setWindow: { summary: 'X-ray window and level on the 3D scroll, both 0..1 (low = level - window / 2).', params: [p('window', 'number', 'Width, 0.005..1'), p('level', 'number', 'Center, 0..1')] }, autoContrast: { summary: 'X-ray auto contrast from the CT atlas percentiles (default from Settings, 1 and 99).', params: [p('pLow', 'number', 'Low percentile', { required: false }), p('pHigh', 'number', 'High percentile', { required: false })] }, fullRange: { summary: 'Show the full X-ray brightness range.', params: [] }, contrast: { summary: 'X-ray window/level state.', params: [] }, open: { summary: 'Open the CT volume (MIP in a slab) around a papyrus point: { winding, u, v } or { voxel: [x, y, z] } or { x, y } screen point, with level? (0..5) and size? (64, 128, 192, 256). Streams OME-Zarr chunks from the public S3 bucket.', params: [p('where', 'object', 'Point'), ] }, close: { summary: 'Close the CT volume panel.', params: [] }, state: { summary: 'CT volume state: point, level, size, voxel size, source URL, slab, window, load stats, labels.', params: [] }, setSlab: { summary: 'Slab clip of the CT volume: { on, thickness (voxels), offset (voxels), tiltX, tiltY (degrees), face (point the camera down the normal) }.', params: [p('slab', 'object', 'Slab fields to change')] }, volumeWindow: { summary: 'Window and level of the CT volume MIP, both 0..1.', params: [p('window', 'number', 'Width'), p('level', 'number', 'Center')] }, volumeAuto: { summary: 'Auto contrast of the CT volume from its own histogram.', params: [] }, setDetail: { summary: 'Detail level of the CT volume: 0 is the full 9.4 µm scan, each step halves the resolution. Reloads.', params: [p('level', 'number', '0..5')] }, setSize: { summary: 'Cube size of the CT volume in voxels. Reloads.', params: [p('size', 'number', '64, 128, 192 or 256')] }, pick: { summary: 'Turn pick mode on or off: the next click on the papyrus opens the CT volume there.', params: [p('on', 'boolean', 'On', { required: false })] }, image: { summary: 'The CT volume view as a PNG data URL.', params: [] }, labels: { summary: 'Labeled segments: color every traced segment inside the CT volume cube. Returns the segments found with their colors and voxel counts.', params: [p('on', 'boolean', 'On', { required: false })] }, highlight: { summary: 'Highlight one labeled segment by winding id (null clears).', params: [p('id', 'string|null', 'Winding id, for example w035')] }, source: { summary: 'Where the CT volume of the current scroll streams from (OME-Zarr on s3://vesuvius-challenge-open-data) and the levels it has.', params: [] }, }; const PANEL = { // control id -> [testid, data-api] dragResSel: ['dragres-select', 'ct.dragRes(mode)'], ctwlWin: ['ct-window', 'ct.setWindow(window, level)'], ctwlLev: ['ct-level', 'ct.setWindow(window, level)'], ctwlAuto: ['ct-auto', 'ct.autoContrast()'], ctwlReset: ['ct-full-range', 'ct.fullRange()'], ctvolPick: ['ctvol-pick', 'ct.pick(on)'], ctvolBtn: ['ctvol-show', 'ct.open(where)'], cvClose: ['ctvol-close', 'ct.close()'], ctVolCanvas: ['ctvol-canvas', 'ct.image()'], cvSlabOn: ['ctvol-slab-on', 'ct.setSlab(slab)'], cvThick: ['ctvol-slab-thickness', 'ct.setSlab(slab)'], cvOff: ['ctvol-slab-offset', 'ct.setSlab(slab)'], cvTiltX: ['ctvol-tilt-across', 'ct.setSlab(slab)'], cvTiltY: ['ctvol-tilt-along', 'ct.setSlab(slab)'], cvWin: ['ctvol-window', 'ct.volumeWindow(window, level)'], cvLev: ['ctvol-level', 'ct.volumeWindow(window, level)'], cvSize: ['ctvol-size', 'ct.setSize(size)'], cvLevel: ['ctvol-detail', 'ct.setDetail(level)'], cvAuto: ['ctvol-auto', 'ct.volumeAuto()'], cvFace: ['ctvol-face', 'ct.setSlab(slab)'], cvRepick: ['ctvol-repick', 'ct.pick(on)'], }; const TERMS = { uiPerf: [['pixelratio', 'pixel ratio'], ['resolution', 'resolution']], uiCT: [['windowlevel', 'window/level'], ['percentile', 'percentiles'], ['ct', 'X-ray CT']], uiCTVol: [['ctvolume', 'CT volume'], ['mip', 'MIP'], ['slab', 'slab'], ['raymarching', 'raymarching'], ['zarr', 'OME-Zarr'], ['labels', 'labeled segments']] }; function tag() { for (const [id, [t, a]] of Object.entries(PANEL)) { const el = document.getElementById(id); if (!el) continue; if (!el.dataset.testid) { el.dataset.testid = t; el.dataset.api = a; } const lab = el.closest('label'); if (lab && !lab.dataset.testid) { lab.dataset.testid = t + '-label'; lab.dataset.api = a; } } for (const [gid, list] of Object.entries(TERMS)) { const g = document.getElementById(gid); if (!g || g.querySelector('.ct-terms')) continue; const pEl = document.createElement('p'); pEl.className = 'mono dim small ct-terms'; pEl.innerHTML = 'Glossary: ' + list.map(([k, txt]) => `${txt}`).join(', '); g.append(pEl); } } if (typeof window !== 'undefined') { const V = () => window.unrollView, vol = () => V()?.ct?.volume, need = (x, what) => { if (!x) throw new Error(`${what} is not ready yet`); return x; }; const methods = { dragRes: (m) => { const d = need(V()?.dragRes, 'dragRes'); if (m !== undefined) d.set(m); return d.state(); }, setWindow: (w, l) => need(V()?.ct?.setWindow, 'X-ray contrast')(w, l), autoContrast: (a, b) => need(V()?.ct?.auto, 'X-ray contrast')(a, b), fullRange: () => need(V()?.ct?.reset, 'X-ray contrast')(), contrast: () => need(V()?.ct?.state, 'X-ray contrast')(), open: (w = {}) => need(vol(), 'CT volume').open(w), close: () => need(vol(), 'CT volume').close(), state: () => ({ ...need(vol(), 'CT volume').state(), labels: vol().labels?.state() || null }), setSlab: (o = {}) => need(vol(), 'CT volume').setSlab(o), volumeWindow: (w, l) => need(vol(), 'CT volume').setWindow(w, l), volumeAuto: () => need(vol(), 'CT volume').auto(), setDetail: (l) => need(vol(), 'CT volume').setLevel(l), setSize: (n) => need(vol(), 'CT volume').setSize(n), pick: (on) => ({ picking: need(vol(), 'CT volume').pick(on ?? true) }), image: () => ({ png: need(vol(), 'CT volume').image() }), labels: (on) => need(vol()?.labels, 'labeled segments').show(on ?? true), highlight: (id) => need(vol()?.labels, 'labeled segments').highlight(id), source: async () => { const m = await import('./ctsource.js'); const s = m.volumeFor(V()?.scanGeom?.()?.scroll, null); return { bucket: m.BUCKET, zarr: s?.zarr || null, voxelUM: s?.voxelUM || null }; }, }; const reg = () => { const u = window.unroll; if (!u?.register) return setTimeout(reg, 200); if (!u.ct) u.register('ct', methods, CT_SPECS); }; reg(); let q = false; const go = () => { if (q) return; q = true; requestAnimationFrame(() => { q = false; tag(); }); }; new MutationObserver(go).observe(document.documentElement, { childList: true, subtree: true }); go(); }