MNutils 1.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. mnutils/GEExam.py +495 -0
  2. mnutils/GESeries.py +1578 -0
  3. mnutils/__init__.py +54 -0
  4. mnutils/_scripts.py +177 -0
  5. mnutils/fitting/AMARES.py +831 -0
  6. mnutils/fitting/__init__.py +5 -0
  7. mnutils/matlab/__init__.py +13 -0
  8. mnutils/matlab/setup_matlab.py +116 -0
  9. mnutils/plotting/__init__.py +38 -0
  10. mnutils/plotting/_widgets/__init__.py +5 -0
  11. mnutils/plotting/_widgets/_html.py +34 -0
  12. mnutils/plotting/_widgets/_shared/__init__.py +50 -0
  13. mnutils/plotting/_widgets/_shared/dom.js +124 -0
  14. mnutils/plotting/_widgets/_shared/theme.css +140 -0
  15. mnutils/plotting/_widgets/image_grid.css +202 -0
  16. mnutils/plotting/_widgets/image_grid.js +212 -0
  17. mnutils/plotting/_widgets/image_grid.py +86 -0
  18. mnutils/plotting/_widgets/mrsi_inspector.css +376 -0
  19. mnutils/plotting/_widgets/mrsi_inspector.js +681 -0
  20. mnutils/plotting/_widgets/mrsi_inspector.py +70 -0
  21. mnutils/plotting/_widgets/slice_viewer.css +38 -0
  22. mnutils/plotting/_widgets/slice_viewer.js +41 -0
  23. mnutils/plotting/_widgets/slice_viewer.py +35 -0
  24. mnutils/plotting/images.py +1399 -0
  25. mnutils/plotting/saving.py +138 -0
  26. mnutils/plotting/spectra.py +357 -0
  27. mnutils/py.typed +0 -0
  28. mnutils/testing/__init__.py +3 -0
  29. mnutils/testing/_spectra.py +338 -0
  30. mnutils/testing/synthetic.py +373 -0
  31. mnutils/utils/__init__.py +25 -0
  32. mnutils/utils/data_loaders.py +218 -0
  33. mnutils/utils/file_helpers.py +708 -0
  34. mnutils/utils/images.py +202 -0
  35. mnutils/utils/nifti.py +379 -0
  36. mnutils/utils/spectra.py +149 -0
  37. mnutils-1.2.0.dist-info/METADATA +161 -0
  38. mnutils-1.2.0.dist-info/RECORD +41 -0
  39. mnutils-1.2.0.dist-info/WHEEL +4 -0
  40. mnutils-1.2.0.dist-info/entry_points.txt +7 -0
  41. mnutils-1.2.0.dist-info/licenses/LICENSE +28 -0
@@ -0,0 +1,681 @@
1
+ // Preact + htm (JSX-free templating) + Chart.js, loaded straight from a CDN:
2
+ // no bundler in this project (see _shared/__init__.py), and this widget's
3
+ // interaction (dual-range zoom, per-voxel redraw) is enough state to want a
4
+ // real component model rather than hand-rolled DOM diffing.
5
+ import Chart from 'https://esm.sh/chart.js@4.4.1/auto'
6
+ import htm from 'https://esm.sh/htm@3.1.1'
7
+ import noUiSlider from 'https://esm.sh/nouislider@15.8.1'
8
+ import { h, render } from 'https://esm.sh/preact@10.19.6'
9
+ import {
10
+ useEffect,
11
+ useMemo,
12
+ useRef,
13
+ useState,
14
+ } from 'https://esm.sh/preact@10.19.6/hooks'
15
+
16
+ const html = htm.bind(h)
17
+
18
+ // Plot width (px) under which the spectrum drops its y-axis labels.
19
+ const Y_AXIS_MIN_WIDTH = 480
20
+
21
+ const NOUISLIDER_CSS_HREF =
22
+ 'https://cdn.jsdelivr.net/npm/nouislider@15.8.1/dist/nouislider.min.css'
23
+
24
+ // nouislider ships its own stylesheet rather than being themeable via JS
25
+ // options; inject it once per document (widgets render into plain divs, not
26
+ // shadow roots, so a single document-level <link> covers every instance).
27
+ function ensureNoUiSliderCss() {
28
+ if (document.querySelector(`link[href="${NOUISLIDER_CSS_HREF}"]`)) return
29
+ const link = document.createElement('link')
30
+ link.rel = 'stylesheet'
31
+ link.href = NOUISLIDER_CSS_HREF
32
+ document.head.appendChild(link)
33
+ }
34
+
35
+ // Apply a row-major 4x4 affine (flattened to 16 floats) to a 3-vector.
36
+ // MRSI-specific (the affine and its inverse are baked into this widget's
37
+ // data payload), so kept local rather than promoted to _shared/dom.js.
38
+ function applyAffine(affine, point) {
39
+ const [x, y, z] = point
40
+ return [
41
+ affine[0] * x + affine[1] * y + affine[2] * z + affine[3],
42
+ affine[4] * x + affine[5] * y + affine[6] * z + affine[7],
43
+ affine[8] * x + affine[9] * y + affine[10] * z + affine[11],
44
+ ]
45
+ }
46
+
47
+ // Dual-thumb range slider, backed by nouislider (dedicated dual-handle range
48
+ // library) instead of the two-overlaid-native-inputs hack. Used twice: the
49
+ // horizontal ppm window under the chart, and the vertical y-limit slider
50
+ // beside it. The instance is created once and driven imperatively thereafter:
51
+ // external prop changes go through `.set()`, while user gestures come back out
52
+ // through nouislider's 'slide' event (see the listener below for why that one).
53
+ // `onStart` fires once per gesture, before any value moves -- the y slider uses
54
+ // it to claim the axis on the first touch.
55
+ function DualRangeSlider({
56
+ min,
57
+ max,
58
+ step,
59
+ valueMin,
60
+ valueMax,
61
+ onChange,
62
+ onStart,
63
+ orientation = 'horizontal',
64
+ className = 'mnutils-mrsi-ppm-slider',
65
+ }) {
66
+ const containerRef = useRef(null)
67
+ const sliderRef = useRef(null)
68
+ const draggingRef = useRef(false)
69
+ const onChangeRef = useRef(onChange)
70
+ onChangeRef.current = onChange
71
+ const onStartRef = useRef(onStart)
72
+ onStartRef.current = onStart
73
+
74
+ useEffect(() => {
75
+ ensureNoUiSliderCss()
76
+ if (!containerRef.current) return undefined
77
+
78
+ const slider = noUiSlider.create(containerRef.current, {
79
+ start: [valueMin, valueMax],
80
+ connect: true,
81
+ // nouislider throws on a zero-width range (a degenerate dataset: one ppm
82
+ // point, or a grid of constant amplitude). Widen it here and let the
83
+ // disable effect below make the control inert instead.
84
+ range: { min, max: max > min ? max : min + 1 },
85
+ step,
86
+ orientation,
87
+ behaviour: 'drag',
88
+ })
89
+ sliderRef.current = slider
90
+
91
+ // 'slide', not 'update': nouislider fires 'update' on programmatic `set`
92
+ // as well (unconditionally, ahead of the `fireSetEvent` guard), and once
93
+ // more on create -- so listening to it turns the sync effect below into a
94
+ // feedback loop that writes every externally-driven value straight back
95
+ // into `onChange`. 'slide' fires only for drags, taps and keyboard steps.
96
+ //
97
+ // It still fires on every mousemove rather than once per step, so the same
98
+ // snapped pair arrives dozens of times per drag. Forwarding each one cost
99
+ // a re-render, a Chart.js repaint and (via the sync effect below) a
100
+ // nouislider `set` that reads geometry back -- i.e. a forced reflow of the
101
+ // whole panel per mouse event, which is what made dragging feel sticky.
102
+ let lastLo = null
103
+ let lastHi = null
104
+ slider.on('slide', (values) => {
105
+ const [lo, hi] = values.map(Number)
106
+ if (lo === lastLo && hi === lastHi) return
107
+ lastLo = lo
108
+ lastHi = hi
109
+ onChangeRef.current(lo, hi)
110
+ })
111
+
112
+ // While the user drags, nouislider's own handle positions are already
113
+ // authoritative; writing them back mid-gesture only thrashes layout.
114
+ slider.on('start', () => {
115
+ draggingRef.current = true
116
+ onStartRef.current?.()
117
+ })
118
+ slider.on('end', () => {
119
+ draggingRef.current = false
120
+ })
121
+
122
+ return () => {
123
+ slider.destroy()
124
+ sliderRef.current = null
125
+ }
126
+ // Range bounds/step come from the dataset and never change after mount;
127
+ // only valueMin/valueMax move thereafter, synced imperatively below.
128
+ // eslint-disable-next-line react-hooks/exhaustive-deps
129
+ }, [min, max, step, orientation])
130
+
131
+ useEffect(() => {
132
+ if (draggingRef.current) return
133
+ sliderRef.current?.set([valueMin, valueMax], false)
134
+ }, [valueMin, valueMax])
135
+
136
+ useEffect(() => {
137
+ const slider = sliderRef.current
138
+ if (!slider) return
139
+ if (max <= min) slider.disable()
140
+ else slider.enable()
141
+ }, [min, max])
142
+
143
+ return html`<div
144
+ ref=${containerRef}
145
+ className="mnutils-mrsi-slider ${className}"></div>`
146
+ }
147
+
148
+ // Chart.js spectrum plot. The chart instance is created once (empty deps
149
+ // effect) and mutated in place on every voxel/range change rather than
150
+ // recreated, so panning/zooming/voxel-picking stays cheap.
151
+ function SpectrumPlot({ ppm, spectrumData, ppmMin, ppmMax, label, yBounds }) {
152
+ const canvasRef = useRef(null)
153
+ const chartRef = useRef(null)
154
+
155
+ // {x, y} points against a linear x scale, NOT scalars against `labels`.
156
+ // With `labels` + scalar data Chart.js picks a *category* scale, where
157
+ // `scales.x.min/max` are category indices rather than data values -- so a
158
+ // ppm window of [-2, 10] silently rendered array indices 0..10 instead
159
+ // (11 points near -122 ppm, out of 700), while the slider readout beside
160
+ // the chart still showed the ppm the user asked for.
161
+ const points = useMemo(
162
+ () => ppm.map((p, i) => ({ x: p, y: spectrumData[i] })),
163
+ [ppm, spectrumData],
164
+ )
165
+
166
+ // Below this the y ticks and their title are dropped: on a phone they eat
167
+ // most of the plot to label an axis whose units are arbitrary anyway, and
168
+ // the shape of the spectrum is what the reader came for. Measured on the
169
+ // plot box rather than the viewport, so a narrow VS Code panel gets the same
170
+ // treatment as a narrow screen.
171
+ const [compactY, setCompactY] = useState(false)
172
+ useEffect(() => {
173
+ const box = canvasRef.current?.parentElement
174
+ if (!box || typeof ResizeObserver === 'undefined') return undefined
175
+ const observer = new ResizeObserver(([entry]) =>
176
+ setCompactY(entry.contentRect.width < Y_AXIS_MIN_WIDTH),
177
+ )
178
+ observer.observe(box)
179
+ return () => observer.disconnect()
180
+ }, [])
181
+
182
+ useEffect(() => {
183
+ if (!canvasRef.current) return undefined
184
+ const ctx = canvasRef.current.getContext('2d')
185
+ // Canvas 2D strokeStyle doesn't resolve CSS var() the way an element's
186
+ // style does -- read the theme token's computed value instead.
187
+ const accentColor =
188
+ getComputedStyle(canvasRef.current)
189
+ .getPropertyValue('--mnu-accent')
190
+ .trim() || '#0055aa'
191
+
192
+ chartRef.current = new Chart(ctx, {
193
+ type: 'line',
194
+ data: {
195
+ datasets: [
196
+ {
197
+ label: label || 'Spectrum',
198
+ data: points,
199
+ borderColor: accentColor,
200
+ borderWidth: 1.5,
201
+ pointRadius: 0,
202
+ fill: false,
203
+ tension: 0,
204
+ },
205
+ ],
206
+ },
207
+ options: {
208
+ responsive: true,
209
+ maintainAspectRatio: false,
210
+ animation: false,
211
+ scales: {
212
+ x: {
213
+ type: 'linear', // ppm is a continuous axis, not 700 categories
214
+ title: { display: true, text: 'ppm' },
215
+ reverse: true, // NMR/MRS convention: high ppm on the left
216
+ min: Math.min(ppmMin, ppmMax),
217
+ max: Math.max(ppmMin, ppmMax),
218
+ ticks: {
219
+ maxTicksLimit: 8,
220
+ callback: (val) => Number(val).toFixed(2),
221
+ },
222
+ },
223
+ y: {
224
+ min: yBounds.min,
225
+ max: yBounds.max,
226
+ title: { display: !compactY, text: 'Intensity (a.u.)' },
227
+ ticks: {
228
+ display: !compactY,
229
+ callback: (val) => val.toExponential(1),
230
+ },
231
+ },
232
+ },
233
+ plugins: {
234
+ legend: { display: false },
235
+ tooltip: {
236
+ callbacks: {
237
+ title: (items) => `${Number(items[0].parsed.x).toFixed(3)} ppm`,
238
+ label: (ctx) => `Intensity: ${ctx.parsed.y.toExponential(3)}`,
239
+ },
240
+ },
241
+ },
242
+ },
243
+ })
244
+
245
+ return () => {
246
+ chartRef.current?.destroy()
247
+ chartRef.current = null
248
+ }
249
+ // Chart created once; per-update mutation happens in the effect below.
250
+ }, [])
251
+
252
+ useEffect(() => {
253
+ const chart = chartRef.current
254
+ if (!chart) return
255
+ chart.data.datasets[0].data = points
256
+ chart.options.scales.x.min = Math.min(ppmMin, ppmMax)
257
+ chart.options.scales.x.max = Math.max(ppmMin, ppmMax)
258
+ chart.options.scales.y.min = yBounds.min
259
+ chart.options.scales.y.max = yBounds.max
260
+ chart.options.scales.y.ticks.display = !compactY
261
+ chart.options.scales.y.title.display = !compactY
262
+ chart.update('none') // instant, no animation overhead
263
+ }, [points, ppmMin, ppmMax, yBounds, compactY])
264
+
265
+ return html`<canvas ref=${canvasRef}></canvas>`
266
+ }
267
+
268
+ function MRSIInspector({ data }) {
269
+ const containerRef = useRef(null)
270
+
271
+ const [nx, ny, nMrsiSlices] = data.grid_shape
272
+ const [dimsI, dimsJ] = data.mrsi_dims
273
+
274
+ const [sliceIdx, setSliceIdx] = useState(data.initial_slice)
275
+ const [voxel, setVoxel] = useState({
276
+ x: data.initial_voxel[0],
277
+ y: data.initial_voxel[1],
278
+ mrsiSliceIdx: data.initial_voxel[2],
279
+ })
280
+
281
+ const ppmDataMin = Math.min(data.ppm[0], data.ppm[data.npts - 1])
282
+ const ppmDataMax = Math.max(data.ppm[0], data.ppm[data.npts - 1])
283
+
284
+ // Whole-ppm steps only: a window is something you read off in ppm ("2 to 8"),
285
+ // and stepping by the sample spacing (~0.02 ppm) gave the slider hundreds of
286
+ // indistinguishable positions. The bounds move inwards to the nearest
287
+ // integers so the steps actually land on round numbers rather than on
288
+ // `ppmDataMin + k`. Data spanning less than 1 ppm has no integer grid to
289
+ // snap to, so it keeps the sample spacing.
290
+ const ppmIntMin = Math.ceil(ppmDataMin)
291
+ const ppmIntMax = Math.floor(ppmDataMax)
292
+ const wholePpmSteps = ppmIntMax > ppmIntMin
293
+ const ppmSliderMin = wholePpmSteps ? ppmIntMin : ppmDataMin
294
+ const ppmSliderMax = wholePpmSteps ? ppmIntMax : ppmDataMax
295
+ const ppmStep = wholePpmSteps
296
+ ? 1
297
+ : data.npts > 1
298
+ ? Math.abs(data.ppm[1] - data.ppm[0]) || 0.01
299
+ : 0.01
300
+
301
+ // The chart's x axis is reversed (NMR convention: high ppm left), so the
302
+ // slider has to run the same way or dragging the left thumb moves the right
303
+ // edge of the window. nouislider's own `direction: 'rtl'` also reverses
304
+ // handle order in every get/set payload; mirroring the value about the
305
+ // midpoint of the slider range instead keeps the slider a plain ascending
306
+ // ltr one and confines the flip to this single involution (mirror(mirror(p))
307
+ // === p, so the same helper converts both ways). Mirroring about the
308
+ // *slider* bounds, not the data bounds, is what keeps integers integral.
309
+ const mirrorPpm = (p) => ppmSliderMin + ppmSliderMax - p
310
+ const ppmDecimals = wholePpmSteps ? 0 : 2
311
+
312
+ // Default to the conventional 10 to -2 ppm display window (matches
313
+ // plotting/spectra.py's default xlim), clamped to what the data covers.
314
+ const [ppmRange, setPpmRange] = useState({
315
+ min: clamp(-2, ppmSliderMin, ppmSliderMax),
316
+ max: clamp(10, ppmSliderMin, ppmSliderMax),
317
+ })
318
+
319
+ // `data.spectra` is a Uint16Array of raw float16 bit patterns and
320
+ // `data.spectra_scale` undoes the range scaling applied before the cast --
321
+ // both prepared by `renderWidget` below, which owns the async decode.
322
+ const currentSpectrum = useMemo(() => {
323
+ const { x, y, mrsiSliceIdx } = voxel
324
+ const offset = ((x * ny + y) * nMrsiSlices + mrsiSliceIdx) * data.npts
325
+ // Only this voxel's points are converted, not the whole grid: a 16x16x16
326
+ // grid at 700 points is 2.9M values, and all but `npts` of them would be
327
+ // thrown away on every voxel change.
328
+ const out = new Array(data.npts)
329
+ for (let i = 0; i < data.npts; i++) {
330
+ out[i] = halfToFloat(data.spectra[offset + i]) * data.spectra_scale
331
+ }
332
+ return out
333
+ }, [voxel, data.spectra, data.spectra_scale, ny, nMrsiSlices, data.npts])
334
+
335
+ // Per-point min/max across *every* voxel, computed once on first use and
336
+ // cached: the fixed-y axis has to cover the loudest voxel anywhere in the
337
+ // grid, and recomputing that on each ppm-slider step (2.9M half-float
338
+ // conversions for a 16^3 grid at 700 points) would stall the drag. Reducing
339
+ // to two length-`npts` envelopes up front makes every later window change an
340
+ // O(npts) scan in `SpectrumPlot`.
341
+ const [fixedY, setFixedY] = useState(false)
342
+ const envelopeRef = useRef(null)
343
+ const yEnvelope = useMemo(() => {
344
+ if (!fixedY) return null
345
+ if (envelopeRef.current?.source === data.spectra) return envelopeRef.current
346
+ const { npts, spectra, spectra_scale } = data
347
+ const min = new Float64Array(npts).fill(Infinity)
348
+ const max = new Float64Array(npts).fill(-Infinity)
349
+ const nVoxels = Math.floor(spectra.length / npts)
350
+ for (let v = 0; v < nVoxels; v++) {
351
+ const offset = v * npts
352
+ for (let i = 0; i < npts; i++) {
353
+ const val = halfToFloat(spectra[offset + i]) * spectra_scale
354
+ if (val < min[i]) min[i] = val
355
+ if (val > max[i]) max[i] = val
356
+ }
357
+ }
358
+ envelopeRef.current = { min, max, source: spectra }
359
+ return envelopeRef.current
360
+ }, [fixedY, data.spectra, data.spectra_scale, data.npts])
361
+
362
+ // Manual y limits, set by dragging the vertical slider; `null` means the
363
+ // axis is still derived from the data (per voxel, or -- with `fixedY` --
364
+ // from the whole-grid envelope).
365
+ const [yLim, setYLim] = useState(null)
366
+
367
+ // Bounds of whatever is visible in [ppmMin, ppmMax], not of the full sweep:
368
+ // otherwise zooming the ppm window in doesn't reveal any more vertical
369
+ // detail. With `yEnvelope` given (fixed-y mode) they come from the whole
370
+ // grid's per-point min/max instead of this one voxel, so the axis stops
371
+ // moving as you step between voxels and their amplitudes stay comparable.
372
+ // Still restricted to the ppm window: "fixed" means independent of the
373
+ // voxel, not of the zoom -- a window-wide axis would flatten every peak
374
+ // whenever one voxel somewhere has a huge off-window artefact.
375
+ const autoYBounds = useMemo(() => {
376
+ const ppm = data.ppm
377
+ const lo = Math.min(ppmRange.min, ppmRange.max)
378
+ const hi = Math.max(ppmRange.min, ppmRange.max)
379
+ let min = Infinity
380
+ let max = -Infinity
381
+ for (let i = 0; i < ppm.length; i++) {
382
+ if (ppm[i] < lo || ppm[i] > hi) continue
383
+ const vLo = yEnvelope ? yEnvelope.min[i] : currentSpectrum[i]
384
+ const vHi = yEnvelope ? yEnvelope.max[i] : currentSpectrum[i]
385
+ if (vLo < min) min = vLo
386
+ if (vHi > max) max = vHi
387
+ }
388
+ // A window narrower than one ppm step can contain no sample at all, which
389
+ // leaves min/max at +/-Infinity -- widening those by 1 keeps them infinite
390
+ // and hands Chart.js an unrenderable y axis. Fall back to a finite range.
391
+ if (!Number.isFinite(min) || !Number.isFinite(max)) {
392
+ return { min: 0, max: 1 }
393
+ }
394
+ if (!(min < max)) {
395
+ min -= 1
396
+ max += 1
397
+ }
398
+ const pad = (max - min) * 0.05
399
+ return { min: min - pad, max: max + pad }
400
+ }, [data.ppm, currentSpectrum, ppmRange.min, ppmRange.max, yEnvelope])
401
+
402
+ // Manual limits win outright -- no 5% padding on top of them, since they are
403
+ // the numbers the user dialled in.
404
+ const yBounds = yLim ?? autoYBounds
405
+
406
+ // The y slider travels over the grid's full amplitude range, which Python
407
+ // measured while encoding the buffer: the browser would otherwise have to
408
+ // scan every voxel before it could draw the control. 400 steps is finer than
409
+ // the track is wide in pixels, so the thumb never feels notched.
410
+ const yDataMin = data.spectra_min
411
+ const yDataMax = data.spectra_max
412
+ const yStep = (yDataMax - yDataMin) / 400 || 1
413
+
414
+ // Vertical nouislider runs top-down (lowest value at the top) under
415
+ // `direction: 'ltr'`, the opposite of a y axis. Same fix as the ppm slider:
416
+ // keep the slider plain-ascending and mirror the values about the range
417
+ // midpoint, an involution that converts in both directions.
418
+ const mirrorY = (v) => yDataMin + yDataMax - v
419
+
420
+ // With no manual limits set the slider sits fully open rather than tracking
421
+ // the automatic bounds. Tracking them looks tidier but is unusable: a quiet
422
+ // voxel's window is a couple of percent of the grid's amplitude range, so
423
+ // both handles land inside the same 16 px and read as a single one. Fully
424
+ // open also says the right thing -- nothing is being clamped yet.
425
+ const ySliderLo = yLim
426
+ ? clamp(mirrorY(yLim.max), yDataMin, yDataMax)
427
+ : yDataMin
428
+ const ySliderHi = yLim
429
+ ? clamp(mirrorY(yLim.min), yDataMin, yDataMax)
430
+ : yDataMax
431
+
432
+ // Taking hold of the slider claims the axis: hand-set limits only mean
433
+ // anything if it stops rescaling itself. The limits start at what the handles
434
+ // already show (the full range), so the axis matches the control from the
435
+ // first frame of the gesture instead of jumping to it on the first step.
436
+ const claimYAxis = () => {
437
+ setFixedY(true)
438
+ setYLim((prev) => prev ?? { min: yDataMin, max: yDataMax })
439
+ }
440
+
441
+ const voxelPolygonPoints = useMemo(() => {
442
+ const cornerOffsets = [
443
+ [-0.5, -0.5, 0],
444
+ [0.5, -0.5, 0],
445
+ [0.5, 0.5, 0],
446
+ [-0.5, 0.5, 0],
447
+ ]
448
+ return cornerOffsets
449
+ .map(([dx, dy, dz]) => {
450
+ const [row, col] = applyAffine(data.mrsi_to_display_affine, [
451
+ voxel.x + dx,
452
+ voxel.y + dy,
453
+ voxel.mrsiSliceIdx + dz,
454
+ ])
455
+ return `${col},${row}`
456
+ })
457
+ .join(' ')
458
+ }, [voxel, data.mrsi_to_display_affine])
459
+
460
+ const handleSliceChange = (newSliceIdx) => {
461
+ const clampedSlice = clamp(newSliceIdx, 0, data.n_anat_slices - 1)
462
+ setSliceIdx(clampedSlice)
463
+
464
+ const [, , mrsiSlice] = applyAffine(data.display_to_mrsi_affine, [
465
+ 0,
466
+ 0,
467
+ clampedSlice,
468
+ ])
469
+ setVoxel((v) => ({
470
+ ...v,
471
+ mrsiSliceIdx: clamp(Math.round(mrsiSlice), 0, nMrsiSlices - 1),
472
+ }))
473
+ }
474
+
475
+ const handleImageClick = (e) => {
476
+ containerRef.current?.focus()
477
+ const rect = e.currentTarget.getBoundingClientRect()
478
+ const px = e.clientX - rect.left
479
+ const py = e.clientY - rect.top
480
+ const col = -0.5 + (px / rect.width) * data.image_width
481
+ const row = -0.5 + (py / rect.height) * data.image_height
482
+
483
+ const [rawX, rawY, rawSlice] = applyAffine(data.display_to_mrsi_affine, [
484
+ row,
485
+ col,
486
+ sliceIdx,
487
+ ])
488
+ setVoxel({
489
+ x: clamp(Math.round(rawX), 0, nx - 1),
490
+ y: clamp(Math.round(rawY), 0, ny - 1),
491
+ mrsiSliceIdx: clamp(Math.round(rawSlice), 0, nMrsiSlices - 1),
492
+ })
493
+ }
494
+
495
+ // `Capture`-suffixed Preact event props register on the capture phase, not
496
+ // bubble: notebook hosts (VS Code, JupyterLab) bind ArrowUp/ArrowDown at
497
+ // the document level for cell navigation, and a plain bubble-phase
498
+ // listener plus preventDefault() doesn't stop that outer handler from
499
+ // also firing on the same event.
500
+ const handleKeyDownCapture = (e) => {
501
+ if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key))
502
+ return
503
+ e.preventDefault()
504
+ e.stopPropagation()
505
+ setVoxel((v) => {
506
+ let nextX = v.x
507
+ let nextY = v.y
508
+ if (e.key === 'ArrowDown') nextY = Math.max(0, v.y - 1)
509
+ if (e.key === 'ArrowUp') nextY = Math.min(ny - 1, v.y + 1)
510
+ if (e.key === 'ArrowRight') nextX = Math.max(0, v.x - 1)
511
+ if (e.key === 'ArrowLeft') nextX = Math.min(nx - 1, v.x + 1)
512
+ return { ...v, x: nextX, y: nextY }
513
+ })
514
+ }
515
+
516
+ // Image and chart share one fixed-height media row (`--mnu-mrsi-media-h`, set
517
+ // in CSS) so they line up top and bottom regardless of the frame's aspect
518
+ // ratio. That only holds if nothing but the title sits above either of them,
519
+ // which is why the fixed-y toggle renders under the ppm bar rather than
520
+ // between the title and the chart.
521
+ const specI = dimsI - 1 - voxel.y
522
+ const specJ = dimsJ - 1 - voxel.x
523
+
524
+ return html`
525
+ <div
526
+ ref=${containerRef}
527
+ className="mnu-viewer mnutils-mrsi-inspector"
528
+ tabindex="0"
529
+ onMouseEnter=${() => containerRef.current?.focus()}
530
+ onKeyDownCapture=${handleKeyDownCapture}>
531
+ <div className="mnutils-mrsi-left-panel">
532
+ <div className="mnu-lbl mnutils-mrsi-slice-title">
533
+ ${data.slice_titles[sliceIdx]}
534
+ </div>
535
+ <div className="mnutils-mrsi-media">
536
+ <div
537
+ className="mnutils-mrsi-image-wrap"
538
+ style="--mnu-mrsi-ar: ${data.image_width / data.image_height}">
539
+ <img
540
+ className="mnutils-mrsi-image"
541
+ src=${data.frame_urls[sliceIdx]}
542
+ onClick=${handleImageClick}
543
+ title="Click to select voxel" />
544
+ <svg
545
+ className="mnutils-mrsi-overlay"
546
+ viewBox="-0.5 -0.5 ${data.image_width} ${data.image_height}">
547
+ <polygon
548
+ className="mnutils-mrsi-voxel-box-halo"
549
+ points=${voxelPolygonPoints} />
550
+ <polygon
551
+ className="mnutils-mrsi-voxel-box"
552
+ points=${voxelPolygonPoints} />
553
+ </svg>
554
+ </div>
555
+ </div>
556
+ <div className="mnu-bar mnutils-mrsi-control-bar">
557
+ <span className="mnu-lbl">slice:</span>
558
+ <input
559
+ type="range"
560
+ className="mnu-slider"
561
+ min="0"
562
+ max=${data.n_anat_slices - 1}
563
+ value=${sliceIdx}
564
+ disabled=${data.n_anat_slices <= 1}
565
+ onInput=${(e) => handleSliceChange(Number(e.target.value))} />
566
+ <span className="mnu-readout"
567
+ >${sliceIdx + 1} / ${data.n_anat_slices}</span
568
+ >
569
+ </div>
570
+ </div>
571
+
572
+ <div className="mnutils-mrsi-right-panel">
573
+ <div className="mnu-lbl mnutils-mrsi-spectrum-title">
574
+ Spectrum at voxel (i:${specI}, j:${specJ},
575
+ slice:${voxel.mrsiSliceIdx})
576
+ </div>
577
+ <div className="mnutils-mrsi-media">
578
+ <${DualRangeSlider}
579
+ orientation="vertical"
580
+ className="mnutils-mrsi-ylim-slider"
581
+ min=${yDataMin}
582
+ max=${yDataMax}
583
+ step=${yStep}
584
+ valueMin=${ySliderLo}
585
+ valueMax=${ySliderHi}
586
+ onStart=${claimYAxis}
587
+ onChange=${(lo, hi) => {
588
+ // Also here, not just in `onStart`: a tap on the track moves a
589
+ // handle without a preceding drag gesture on some inputs.
590
+ claimYAxis()
591
+ setYLim({ min: mirrorY(hi), max: mirrorY(lo) })
592
+ }} />
593
+ <div className="mnutils-mrsi-spectrum-container">
594
+ <${SpectrumPlot}
595
+ ppm=${data.ppm}
596
+ spectrumData=${currentSpectrum}
597
+ ppmMin=${ppmRange.min}
598
+ ppmMax=${ppmRange.max}
599
+ label=${data.spectrum_label}
600
+ yBounds=${yBounds} />
601
+ </div>
602
+ </div>
603
+ <div
604
+ className="mnu-bar mnutils-mrsi-control-bar mnutils-mrsi-range-bar">
605
+ <span className="mnu-lbl">ppm:</span>
606
+ <${DualRangeSlider}
607
+ min=${ppmSliderMin}
608
+ max=${ppmSliderMax}
609
+ step=${ppmStep}
610
+ valueMin=${mirrorPpm(ppmRange.max)}
611
+ valueMax=${mirrorPpm(ppmRange.min)}
612
+ onChange=${(lo, hi) =>
613
+ setPpmRange({ min: mirrorPpm(hi), max: mirrorPpm(lo) })} />
614
+ <span className="mnu-readout"
615
+ >${ppmRange.max.toFixed(ppmDecimals)} –
616
+ ${ppmRange.min.toFixed(ppmDecimals)}</span
617
+ >
618
+ </div>
619
+ <label className="mnutils-mrsi-ylim-toggle">
620
+ <input
621
+ type="checkbox"
622
+ checked=${fixedY}
623
+ onChange=${(e) => {
624
+ setFixedY(e.target.checked)
625
+ // Unticking is the only way back to the per-voxel autoscale, so
626
+ // it has to drop any manual limits as well -- they would
627
+ // otherwise keep the axis pinned with the box unticked.
628
+ if (!e.target.checked) setYLim(null)
629
+ }} />
630
+ <span
631
+ >Fixed y-axis
632
+ (${yLim ? 'manual limits' : 'max over all voxels'})</span
633
+ >
634
+ </label>
635
+ </div>
636
+ </div>
637
+ `
638
+ }
639
+
640
+ /**
641
+ * Turn the raw payload baked in by `mrsi_inspector.py` into the shape
642
+ * `MRSIInspector` consumes.
643
+ *
644
+ * The two heavy fields arrive base64-encoded: WebP frames, and the spectra as
645
+ * a zlib-compressed block of float16 bit patterns. Frames become blob: URLs
646
+ * rather than data: URLs so the browser holds one copy of each frame instead
647
+ * of a base64 string plus its decoded form.
648
+ */
649
+ async function decodePayload(data) {
650
+ const frame_urls = data.left_frames.map((frame) =>
651
+ URL.createObjectURL(new Blob([b64ToBytes(frame)], { type: 'image/webp' })),
652
+ )
653
+ const inflated = await inflate(b64ToBytes(data.spectra_bytes))
654
+ // Copy through a fresh buffer: the inflated bytes are not guaranteed to sit
655
+ // at a 2-byte-aligned offset, which a Uint16Array view requires.
656
+ const spectra = new Uint16Array(
657
+ inflated.byteOffset % 2 === 0
658
+ ? inflated.buffer.slice(
659
+ inflated.byteOffset,
660
+ inflated.byteOffset + inflated.byteLength,
661
+ )
662
+ : Uint8Array.from(inflated).buffer,
663
+ )
664
+ return { ...data, frame_urls, spectra }
665
+ }
666
+
667
+ // Deliberately not exported: `render_html` calls this directly from the same
668
+ // module scope.
669
+ function renderWidget(data, el) {
670
+ el.textContent = 'Loading MRSI inspector…'
671
+ decodePayload(data).then(
672
+ (decoded) => {
673
+ el.textContent = ''
674
+ render(html`<${MRSIInspector} data=${decoded} />`, el)
675
+ },
676
+ (err) => {
677
+ el.textContent = `Failed to decode MRSI inspector data: ${err}`
678
+ throw err
679
+ },
680
+ )
681
+ }