File size: 7,546 Bytes
e31bb23 757768f e31bb23 757768f e31bb23 757768f e31bb23 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 | // 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]) => `<a class="ui-term" href="#g-${k}" data-term="${k}" data-testid="gloss-${gid}-${k}" data-api="ui.glossary('${k}')">${txt}</a>`).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();
}
|