reflex-niivue 0.0.1.dev3__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.
- reflex_niivue/__init__.py +45 -0
- reflex_niivue/assets/niivue.jsx +646 -0
- reflex_niivue/colormaps.py +158 -0
- reflex_niivue/niivue.py +569 -0
- reflex_niivue/niivue.pyi +204 -0
- reflex_niivue/py.typed +0 -0
- reflex_niivue-0.0.1.dev3.dist-info/METADATA +260 -0
- reflex_niivue-0.0.1.dev3.dist-info/RECORD +11 -0
- reflex_niivue-0.0.1.dev3.dist-info/WHEEL +4 -0
- reflex_niivue-0.0.1.dev3.dist-info/licenses/LICENSE +201 -0
- reflex_niivue-0.0.1.dev3.dist-info/licenses/NOTICE +15 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Reflex component wrapping NiiVue, the WebGL2 medical image viewer."""
|
|
2
|
+
|
|
3
|
+
from . import niivue
|
|
4
|
+
from .colormaps import COLORMAPS, colormaps, label_colormap
|
|
5
|
+
from .niivue import (
|
|
6
|
+
DRAG_MODES,
|
|
7
|
+
MULTIPLANAR_LAYOUTS,
|
|
8
|
+
NIIVUE_VERSION,
|
|
9
|
+
SLICE_TYPES,
|
|
10
|
+
Niivue,
|
|
11
|
+
clear_drawing,
|
|
12
|
+
load_drawing_from_url,
|
|
13
|
+
move_crosshair_to_mm,
|
|
14
|
+
niivue_options,
|
|
15
|
+
reset_view,
|
|
16
|
+
save_image,
|
|
17
|
+
save_scene,
|
|
18
|
+
set_frame_4d,
|
|
19
|
+
set_slice_mm,
|
|
20
|
+
undo_draw,
|
|
21
|
+
view,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"COLORMAPS",
|
|
26
|
+
"DRAG_MODES",
|
|
27
|
+
"MULTIPLANAR_LAYOUTS",
|
|
28
|
+
"NIIVUE_VERSION",
|
|
29
|
+
"SLICE_TYPES",
|
|
30
|
+
"Niivue",
|
|
31
|
+
"clear_drawing",
|
|
32
|
+
"colormaps",
|
|
33
|
+
"label_colormap",
|
|
34
|
+
"load_drawing_from_url",
|
|
35
|
+
"move_crosshair_to_mm",
|
|
36
|
+
"niivue",
|
|
37
|
+
"niivue_options",
|
|
38
|
+
"reset_view",
|
|
39
|
+
"save_image",
|
|
40
|
+
"save_scene",
|
|
41
|
+
"set_frame_4d",
|
|
42
|
+
"set_slice_mm",
|
|
43
|
+
"undo_draw",
|
|
44
|
+
"view",
|
|
45
|
+
]
|
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
// React wrapper around NiiVue (@niivue/niivue) for Reflex.
|
|
2
|
+
//
|
|
3
|
+
// One Niivue instance per mounted component, kept in a ref and attached to a canvas created on
|
|
4
|
+
// mount (a canvas whose WebGL context was lost can never get a new one, and React StrictMode
|
|
5
|
+
// mounts twice in development). Every prop maps to NiiVue methods or options through its own
|
|
6
|
+
// effect; volumes and meshes are diffed by url/name so unchanged ones are not downloaded again.
|
|
7
|
+
// The tables below are mirrored in reflex_niivue/niivue.py and compared by the tests.
|
|
8
|
+
import { useEffect, useRef, useState } from "react";
|
|
9
|
+
import { Niivue, INITIAL_SCENE_DATA } from "@niivue/niivue";
|
|
10
|
+
|
|
11
|
+
// <tables>
|
|
12
|
+
export const SLICE_TYPES = { axial: 0, coronal: 1, sagittal: 2, multiplanar: 3, render: 4 };
|
|
13
|
+
export const MULTIPLANAR_LAYOUTS = { auto: 0, column: 1, grid: 2, row: 3 };
|
|
14
|
+
export const DRAG_MODES = {
|
|
15
|
+
none: 0,
|
|
16
|
+
contrast: 1,
|
|
17
|
+
measurement: 2,
|
|
18
|
+
pan: 3,
|
|
19
|
+
slicer3d: 4,
|
|
20
|
+
callback_only: 5,
|
|
21
|
+
roi_selection: 6,
|
|
22
|
+
angle: 7,
|
|
23
|
+
crosshair: 8,
|
|
24
|
+
windowing: 9,
|
|
25
|
+
};
|
|
26
|
+
// Component prop (camelCase, as Reflex renders it) -> NiiVue constructor option.
|
|
27
|
+
export const PROP_OPTIONS = {
|
|
28
|
+
sliceType: "sliceType",
|
|
29
|
+
multiplanarLayout: "multiplanarLayout",
|
|
30
|
+
show3dCrosshair: "show3Dcrosshair",
|
|
31
|
+
crosshairColor: "crosshairColor",
|
|
32
|
+
crosshairWidth: "crosshairWidth",
|
|
33
|
+
backColor: "backColor",
|
|
34
|
+
dragMode: "dragMode",
|
|
35
|
+
isColorbar: "isColorbar",
|
|
36
|
+
isOrientCube: "isOrientCube",
|
|
37
|
+
isRadiological: "isRadiologicalConvention",
|
|
38
|
+
penValue: "penValue",
|
|
39
|
+
};
|
|
40
|
+
// snake_case keys accepted in volume, mesh and mesh layer descriptors -> NiiVue's names.
|
|
41
|
+
export const VOLUME_KEYS = {
|
|
42
|
+
colormap_label: "colormapLabel",
|
|
43
|
+
colormap_negative: "colormapNegative",
|
|
44
|
+
cal_min_neg: "cal_minNeg",
|
|
45
|
+
cal_max_neg: "cal_maxNeg",
|
|
46
|
+
frame_4d: "frame4D",
|
|
47
|
+
limit_frames_4d: "limitFrames4D",
|
|
48
|
+
ignore_zero_voxels: "ignoreZeroVoxels",
|
|
49
|
+
trust_cal_min_max: "trustCalMinMax",
|
|
50
|
+
percentile_frac: "percentileFrac",
|
|
51
|
+
colorbar_visible: "colorbarVisible",
|
|
52
|
+
alpha_threshold: "alphaThreshold",
|
|
53
|
+
use_qform_not_sform: "useQFormNotSForm",
|
|
54
|
+
url_image_data: "urlImageData",
|
|
55
|
+
image_type: "imageType",
|
|
56
|
+
};
|
|
57
|
+
export const MESH_KEYS = {
|
|
58
|
+
mesh_shader_index: "meshShaderIndex",
|
|
59
|
+
colorbar_visible: "colorbarVisible",
|
|
60
|
+
};
|
|
61
|
+
export const LAYER_KEYS = {
|
|
62
|
+
colormap_negative: "colormapNegative",
|
|
63
|
+
use_negative_cmap: "useNegativeCmap",
|
|
64
|
+
cal_min_neg: "cal_minNeg",
|
|
65
|
+
cal_max_neg: "cal_maxNeg",
|
|
66
|
+
colorbar_visible: "colorbarVisible",
|
|
67
|
+
show_legend: "showLegend",
|
|
68
|
+
outline_border: "outlineBorder",
|
|
69
|
+
};
|
|
70
|
+
// </tables>
|
|
71
|
+
|
|
72
|
+
const DEFAULT_SYNC = { "2d": true, "3d": true };
|
|
73
|
+
const NO_WEBGL =
|
|
74
|
+
"WebGL2 is not available in this browser. Turn on hardware acceleration, or use a current " +
|
|
75
|
+
"Chrome, Edge, Firefox or Safari.";
|
|
76
|
+
|
|
77
|
+
const rename = (obj, table) => {
|
|
78
|
+
const out = {};
|
|
79
|
+
for (const [k, v] of Object.entries(obj || {})) out[table[k] ?? k] = v;
|
|
80
|
+
return out;
|
|
81
|
+
};
|
|
82
|
+
const toArray = (v) => (v == null ? v : Array.from(v, Number));
|
|
83
|
+
const sig = (v) => JSON.stringify(v ?? null);
|
|
84
|
+
const message = (e) => String(e?.message ?? e);
|
|
85
|
+
/** Key used to diff descriptors: the url, or the name for sources without one. */
|
|
86
|
+
const keyOf = (d) => String(d.url ?? d.name ?? "");
|
|
87
|
+
|
|
88
|
+
/** NiiVue constructor options for the given (camelCase) component props. */
|
|
89
|
+
export function niivueOptions(props) {
|
|
90
|
+
const opts = { ...(props.options || {}) };
|
|
91
|
+
for (const [prop, opt] of Object.entries(PROP_OPTIONS)) {
|
|
92
|
+
const v = props[prop];
|
|
93
|
+
if (v === undefined || v === null) continue;
|
|
94
|
+
if (prop === "sliceType") opts[opt] = SLICE_TYPES[v] ?? v;
|
|
95
|
+
else if (prop === "multiplanarLayout") opts[opt] = MULTIPLANAR_LAYOUTS[v] ?? v;
|
|
96
|
+
else if (prop === "dragMode") opts[opt] = DRAG_MODES[v] ?? v;
|
|
97
|
+
else opts[opt] = v;
|
|
98
|
+
}
|
|
99
|
+
return opts;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ------------------------------------------------------------- shared registries
|
|
103
|
+
// Viewers by id, for the imperative helpers (rx.call_script), and sync groups.
|
|
104
|
+
const registry = (() => {
|
|
105
|
+
if (typeof window === "undefined") return null;
|
|
106
|
+
if (window.reflexNiivue) return window.reflexNiivue;
|
|
107
|
+
const viewers = new Map(); // id -> Niivue
|
|
108
|
+
const groups = new Map(); // sync group -> Set<Niivue>
|
|
109
|
+
const get = (id) => (id == null ? [...viewers.values()].at(-1) : viewers.get(id));
|
|
110
|
+
const api = {
|
|
111
|
+
viewers,
|
|
112
|
+
groups,
|
|
113
|
+
get,
|
|
114
|
+
/** Run a command (see COMMANDS) on viewer ``id`` (null: the most recently mounted). */
|
|
115
|
+
async command(name, id, ...args) {
|
|
116
|
+
const nv = get(id);
|
|
117
|
+
const fn = COMMANDS[name];
|
|
118
|
+
if (!nv || !fn) {
|
|
119
|
+
console.warn(`reflex-niivue: no viewer ${id ?? ""} for ${name}`);
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
return fn(nv, ...args);
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
window.reflexNiivue = api;
|
|
126
|
+
return api;
|
|
127
|
+
})();
|
|
128
|
+
|
|
129
|
+
function joinGroup(name, nv, syncOpts) {
|
|
130
|
+
const members = registry.groups.get(name) ?? new Set();
|
|
131
|
+
registry.groups.set(name, members);
|
|
132
|
+
nv.reflexSyncOpts = syncOpts;
|
|
133
|
+
members.add(nv);
|
|
134
|
+
rewire(members);
|
|
135
|
+
}
|
|
136
|
+
function leaveGroup(name, nv) {
|
|
137
|
+
const members = registry.groups.get(name);
|
|
138
|
+
if (!members) return;
|
|
139
|
+
members.delete(nv);
|
|
140
|
+
nv.broadcastTo([]);
|
|
141
|
+
if (members.size === 0) registry.groups.delete(name);
|
|
142
|
+
else rewire(members);
|
|
143
|
+
}
|
|
144
|
+
function rewire(members) {
|
|
145
|
+
for (const nv of members) {
|
|
146
|
+
nv.broadcastTo([...members].filter((o) => o !== nv), nv.reflexSyncOpts ?? DEFAULT_SYNC);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const volumeIndex = (nv, ref) => {
|
|
151
|
+
if (ref == null) return nv.reflexSelected ?? 0;
|
|
152
|
+
if (typeof ref === "number") return ref;
|
|
153
|
+
return nv.volumes.findIndex((v) => v.name === ref || nv.mediaUrlMap?.get(v) === ref);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const COMMANDS = {
|
|
157
|
+
moveCrosshairToMm(nv, mm) {
|
|
158
|
+
nv.scene.crosshairPos = nv.mm2frac(mm);
|
|
159
|
+
nv.createOnLocationChange();
|
|
160
|
+
nv.drawScene();
|
|
161
|
+
return true;
|
|
162
|
+
},
|
|
163
|
+
setFrame4D(nv, frame, volume) {
|
|
164
|
+
const vol = nv.volumes[volumeIndex(nv, volume)];
|
|
165
|
+
if (!vol) return false;
|
|
166
|
+
nv.setFrame4D(vol.id, frame);
|
|
167
|
+
return true;
|
|
168
|
+
},
|
|
169
|
+
async saveImage(nv, filename, drawing, volume) {
|
|
170
|
+
const isSaveDrawing = drawing ?? !!nv.drawBitmap;
|
|
171
|
+
const ok = await nv.saveImage({ filename, isSaveDrawing, volumeByIndex: volumeIndex(nv, volume) });
|
|
172
|
+
return ok !== false;
|
|
173
|
+
},
|
|
174
|
+
async saveScene(nv, filename) {
|
|
175
|
+
await nv.saveScene(filename);
|
|
176
|
+
return true;
|
|
177
|
+
},
|
|
178
|
+
setSliceMM(nv, isSliceMM) {
|
|
179
|
+
nv.setSliceMM(isSliceMM);
|
|
180
|
+
return true;
|
|
181
|
+
},
|
|
182
|
+
undoDraw(nv) {
|
|
183
|
+
nv.drawUndo();
|
|
184
|
+
return true;
|
|
185
|
+
},
|
|
186
|
+
clearDrawing(nv) {
|
|
187
|
+
nv.closeDrawing();
|
|
188
|
+
if (nv.opts.drawingEnabled) nv.createEmptyDrawing();
|
|
189
|
+
return true;
|
|
190
|
+
},
|
|
191
|
+
loadDrawingFromUrl(nv, url, binarize) {
|
|
192
|
+
return nv.loadDrawingFromUrl(url, !!binarize);
|
|
193
|
+
},
|
|
194
|
+
resetView(nv) {
|
|
195
|
+
nv.scene.pan2Dxyzmm = [0, 0, 0, 1];
|
|
196
|
+
nv.scene.crosshairPos = [0.5, 0.5, 0.5];
|
|
197
|
+
nv.scene.volScaleMultiplier = 1;
|
|
198
|
+
nv.setRenderAzimuthElevation(INITIAL_SCENE_DATA.azimuth, INITIAL_SCENE_DATA.elevation);
|
|
199
|
+
nv.createOnLocationChange();
|
|
200
|
+
nv.drawScene();
|
|
201
|
+
return true;
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
// ---------------------------------------------------------------- volume / mesh diffing
|
|
206
|
+
/** Split a volume descriptor into NiiVue load options and the properties applied afterwards. */
|
|
207
|
+
function normalizeVolume(d) {
|
|
208
|
+
const { visible = true, colormapLabel = null, ...load } = rename(d, VOLUME_KEYS);
|
|
209
|
+
if (load.colorMap && !load.colormap) load.colormap = load.colorMap;
|
|
210
|
+
delete load.colorMap;
|
|
211
|
+
return { key: keyOf(d), load, visible, colormapLabel, label: displayName(load) };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* NiiVue picks the file reader from the extension of ``name`` (else ``url``), so a display name
|
|
216
|
+
* without one ("t1") is applied after loading: load under the url's file name, return the label.
|
|
217
|
+
*/
|
|
218
|
+
function displayName(load) {
|
|
219
|
+
const label = load.name;
|
|
220
|
+
if (!label || !load.url || /\.[a-z0-9]+$/i.test(label)) return null;
|
|
221
|
+
load.name = decodeURIComponent(new URL(load.url, window.location.href).pathname.split("/").pop());
|
|
222
|
+
return label;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const nameVolume = (img, want) => {
|
|
226
|
+
if (want.label) img.name = want.label;
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
function applyVolume(nv, img, want, have) {
|
|
230
|
+
const v = want.load;
|
|
231
|
+
let dirty = false;
|
|
232
|
+
// Setting a colormap recomputes the display range, so explicit ranges are applied again.
|
|
233
|
+
let rangeReset = false;
|
|
234
|
+
if (v.colormap && v.colormap !== have?.load.colormap) {
|
|
235
|
+
img.colormap = v.colormap;
|
|
236
|
+
dirty = rangeReset = true;
|
|
237
|
+
}
|
|
238
|
+
if ((v.colormapNegative ?? "") !== (have?.load.colormapNegative ?? "")) {
|
|
239
|
+
img.colormapNegative = v.colormapNegative ?? "";
|
|
240
|
+
dirty = true;
|
|
241
|
+
}
|
|
242
|
+
for (const k of ["cal_min", "cal_max", "cal_minNeg", "cal_maxNeg"]) {
|
|
243
|
+
if (v[k] != null && (rangeReset || v[k] !== have?.load[k])) {
|
|
244
|
+
img[k] = v[k];
|
|
245
|
+
dirty = true;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
const opacity = want.visible === false ? 0 : (v.opacity ?? 1);
|
|
249
|
+
if (opacity !== img.opacity) {
|
|
250
|
+
img.opacity = opacity;
|
|
251
|
+
dirty = true;
|
|
252
|
+
}
|
|
253
|
+
const labelSig = sig(want.colormapLabel);
|
|
254
|
+
if (labelSig !== img.reflexLabelSig) {
|
|
255
|
+
if (want.colormapLabel) img.setColormapLabel(want.colormapLabel);
|
|
256
|
+
else img.colormapLabel = null;
|
|
257
|
+
img.reflexLabelSig = labelSig;
|
|
258
|
+
dirty = true;
|
|
259
|
+
}
|
|
260
|
+
return dirty;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function syncVolumes(nv, wanted, state, onError) {
|
|
264
|
+
const want = wanted.map(normalizeVolume);
|
|
265
|
+
for (const w of want) if (w.label) nv.reflexLabels.set(w.load.name, w.label);
|
|
266
|
+
const keys = want.map((w) => w.key);
|
|
267
|
+
const haveKeys = [...state.keys()];
|
|
268
|
+
const kept = haveKeys.filter((k) => keys.includes(k));
|
|
269
|
+
const added = keys.filter((k) => !state.has(k));
|
|
270
|
+
// Removing layers and appending new ones keeps NiiVue's order only if the kept volumes stay
|
|
271
|
+
// in order before every new one; otherwise (new background, reordering) reload everything.
|
|
272
|
+
const incremental =
|
|
273
|
+
haveKeys.length > 0 &&
|
|
274
|
+
kept.length > 0 &&
|
|
275
|
+
kept.join("\n") === keys.slice(0, kept.length).join("\n") &&
|
|
276
|
+
added.every((k) => keys.indexOf(k) >= kept.length);
|
|
277
|
+
if (!incremental) {
|
|
278
|
+
state.clear();
|
|
279
|
+
const list = want.map((w) => w.load);
|
|
280
|
+
try {
|
|
281
|
+
await nv.loadVolumes(list);
|
|
282
|
+
} catch (e) {
|
|
283
|
+
onError(`Could not load the volumes: ${message(e)}`);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
nv.volumes.forEach((img, i) => {
|
|
287
|
+
if (!want[i]) return;
|
|
288
|
+
img.reflexLabelSig = sig(null);
|
|
289
|
+
nameVolume(img, want[i]);
|
|
290
|
+
applyVolume(nv, img, want[i], { load: want[i].load });
|
|
291
|
+
state.set(want[i].key, { img, ...want[i] });
|
|
292
|
+
});
|
|
293
|
+
} else {
|
|
294
|
+
for (const k of haveKeys) {
|
|
295
|
+
if (keys.includes(k)) continue;
|
|
296
|
+
nv.removeVolume(state.get(k).img);
|
|
297
|
+
state.delete(k);
|
|
298
|
+
}
|
|
299
|
+
for (const w of want) {
|
|
300
|
+
if (state.has(w.key)) continue;
|
|
301
|
+
try {
|
|
302
|
+
const img = await nv.addVolumeFromUrl(w.load);
|
|
303
|
+
img.reflexLabelSig = sig(null);
|
|
304
|
+
nameVolume(img, w);
|
|
305
|
+
applyVolume(nv, img, w, { load: w.load });
|
|
306
|
+
state.set(w.key, { img, ...w });
|
|
307
|
+
} catch (e) {
|
|
308
|
+
onError(`Could not load ${w.key}: ${message(e)}`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
for (const w of want) {
|
|
312
|
+
const have = state.get(w.key);
|
|
313
|
+
if (have && applyVolume(nv, have.img, w, have)) state.set(w.key, { img: have.img, ...w });
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
nv.updateGLVolume();
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function normalizeMesh(d) {
|
|
320
|
+
const { layers = [], ...load } = rename(d, MESH_KEYS);
|
|
321
|
+
const label = displayName(load);
|
|
322
|
+
return { key: keyOf(d), load, label, layers: layers.map((l) => rename(l, LAYER_KEYS)) };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function syncMeshes(nv, wanted, state, onError) {
|
|
326
|
+
const want = wanted.map(normalizeMesh);
|
|
327
|
+
for (const w of want) if (w.label) nv.reflexLabels.set(w.load.name, w.label);
|
|
328
|
+
const keys = new Set(want.map((w) => w.key));
|
|
329
|
+
const layerUrls = (w) => w.layers.map((l) => l.url).join("\n");
|
|
330
|
+
for (const [k, have] of [...state]) {
|
|
331
|
+
const w = want.find((x) => x.key === k);
|
|
332
|
+
if (w && layerUrls(w) === layerUrls(have)) continue;
|
|
333
|
+
nv.removeMesh(have.mesh);
|
|
334
|
+
state.delete(k);
|
|
335
|
+
}
|
|
336
|
+
for (const w of want) {
|
|
337
|
+
const have = state.get(w.key);
|
|
338
|
+
if (!have) {
|
|
339
|
+
try {
|
|
340
|
+
const mesh = await nv.addMeshFromUrl({ ...w.load, layers: w.layers });
|
|
341
|
+
if (w.label) mesh.name = w.label;
|
|
342
|
+
state.set(w.key, { mesh, ...w });
|
|
343
|
+
} catch (e) {
|
|
344
|
+
onError(`Could not load ${w.key}: ${message(e)}`);
|
|
345
|
+
}
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
const id = have.mesh.id;
|
|
349
|
+
for (const k of ["opacity", "visible", "rgba255", "meshShaderIndex"]) {
|
|
350
|
+
if (w.load[k] !== undefined && sig(w.load[k]) !== sig(have.load[k])) {
|
|
351
|
+
if (k === "meshShaderIndex") nv.setMeshShader(id, w.load[k]);
|
|
352
|
+
else nv.setMeshProperty(id, k, w.load[k]);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
for (let i = 0; i < w.layers.length; i++) {
|
|
356
|
+
for (const [k, v] of Object.entries(w.layers[i])) {
|
|
357
|
+
if (k !== "url" && sig(v) !== sig(have.layers[i]?.[k])) await nv.setMeshLayerProperty(id, i, k, v);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
state.set(w.key, { mesh: have.mesh, ...w });
|
|
361
|
+
}
|
|
362
|
+
if (keys.size === 0) nv.drawScene();
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ------------------------------------------------------------------------- component
|
|
366
|
+
export function NiivueView(props) {
|
|
367
|
+
const {
|
|
368
|
+
volumes = [],
|
|
369
|
+
meshes = [],
|
|
370
|
+
sliceType,
|
|
371
|
+
multiplanarLayout,
|
|
372
|
+
show3dCrosshair,
|
|
373
|
+
crosshairColor,
|
|
374
|
+
crosshairWidth,
|
|
375
|
+
backColor,
|
|
376
|
+
dragMode,
|
|
377
|
+
isColorbar,
|
|
378
|
+
isOrientCube,
|
|
379
|
+
isRadiological,
|
|
380
|
+
clipPlane,
|
|
381
|
+
renderAzimuthElevation,
|
|
382
|
+
selectedVolume,
|
|
383
|
+
drawingEnabled,
|
|
384
|
+
penValue,
|
|
385
|
+
drawOpacity,
|
|
386
|
+
drawingFillOverwrites,
|
|
387
|
+
options,
|
|
388
|
+
syncGroup,
|
|
389
|
+
syncOpts,
|
|
390
|
+
id,
|
|
391
|
+
className,
|
|
392
|
+
style,
|
|
393
|
+
ref: outerRef, // React 19 passes refs as props; Reflex sets one for components with an id
|
|
394
|
+
} = props;
|
|
395
|
+
const holderRef = useRef(null);
|
|
396
|
+
const nvRef = useRef(null);
|
|
397
|
+
const volState = useRef(new Map()); // key -> { img, load, visible, colormapLabel }
|
|
398
|
+
const meshState = useRef(new Map()); // key -> { mesh, load, layers }
|
|
399
|
+
const queue = useRef(Promise.resolve()); // serialises volume/mesh updates
|
|
400
|
+
const [ready, setReady] = useState(false);
|
|
401
|
+
const [error, setError] = useState(null);
|
|
402
|
+
|
|
403
|
+
// Latest callbacks for the NiiVue callbacks, which are set once.
|
|
404
|
+
const cb = useRef({});
|
|
405
|
+
cb.current = props;
|
|
406
|
+
const emit = (name, ...args) => cb.current[name]?.(...args);
|
|
407
|
+
const fail = (msg) => {
|
|
408
|
+
setError(msg);
|
|
409
|
+
emit("onError", msg);
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
// ---------------------------------------------------------- mount / unmount
|
|
413
|
+
useEffect(() => {
|
|
414
|
+
const holder = holderRef.current;
|
|
415
|
+
const canvas = document.createElement("canvas");
|
|
416
|
+
canvas.dataset.niivue = "canvas";
|
|
417
|
+
holder.appendChild(canvas);
|
|
418
|
+
const nv = new Niivue({ logLevel: "warn", ...niivueOptions(cb.current) });
|
|
419
|
+
nv.reflexLabels = new Map(); // file name NiiVue loads under -> display name
|
|
420
|
+
nvRef.current = nv;
|
|
421
|
+
let alive = true;
|
|
422
|
+
let locTimer = null;
|
|
423
|
+
let lastLoc = null;
|
|
424
|
+
let frames = { n: 0, ms: 0, since: performance.now() };
|
|
425
|
+
|
|
426
|
+
nv.onLocationChange = (loc) => {
|
|
427
|
+
lastLoc = loc;
|
|
428
|
+
if (locTimer) return;
|
|
429
|
+
locTimer = setTimeout(() => {
|
|
430
|
+
locTimer = null;
|
|
431
|
+
const l = lastLoc;
|
|
432
|
+
if (!l || !alive) return;
|
|
433
|
+
const values = (l.values || []).map((v) => ({
|
|
434
|
+
name: v.name,
|
|
435
|
+
id: v.id,
|
|
436
|
+
value: Number(v.value),
|
|
437
|
+
vox: toArray(v.vox),
|
|
438
|
+
mm: toArray(v.mm)?.slice(0, 3),
|
|
439
|
+
}));
|
|
440
|
+
emit("onLocation", toArray(l.mm).slice(0, 3), toArray(l.vox), values, toArray(l.frac));
|
|
441
|
+
}, 50);
|
|
442
|
+
};
|
|
443
|
+
nv.onImageLoaded = (vol) => {
|
|
444
|
+
const dims = vol.hdr?.dims ?? [];
|
|
445
|
+
emit("onImageLoaded", nv.reflexLabels.get(vol.name) ?? vol.name, Array.from(dims.slice(1, 1 + (dims[0] || 3)), Number));
|
|
446
|
+
};
|
|
447
|
+
nv.onMeshLoaded = (mesh) => emit("onMeshLoaded", nv.reflexLabels.get(mesh.name) ?? mesh.name);
|
|
448
|
+
nv.onIntensityChange = (vol) =>
|
|
449
|
+
emit("onIntensityChange", {
|
|
450
|
+
name: vol.name,
|
|
451
|
+
id: vol.id,
|
|
452
|
+
index: nv.volumes.indexOf(vol),
|
|
453
|
+
cal_min: vol.cal_min,
|
|
454
|
+
cal_max: vol.cal_max,
|
|
455
|
+
});
|
|
456
|
+
nv.onDrawingChanged = (action) => emit("onDrawingChanged", action);
|
|
457
|
+
nv.onVolumeUpdated = () => emit("onVolumeUpdated");
|
|
458
|
+
nv.onFrameChange = (vol, frame) => emit("onFrameChange", vol.name, frame);
|
|
459
|
+
nv.onError = (e) => emit("onError", message(e ?? "NiiVue error"));
|
|
460
|
+
// Frame time: the mean drawScene() duration, reported at most once per second.
|
|
461
|
+
const draw = nv.drawScene.bind(nv);
|
|
462
|
+
nv.drawScene = (...args) => {
|
|
463
|
+
const t0 = performance.now();
|
|
464
|
+
const out = draw(...args);
|
|
465
|
+
const t1 = performance.now();
|
|
466
|
+
frames.n += 1;
|
|
467
|
+
frames.ms += t1 - t0;
|
|
468
|
+
if (cb.current.onFrameTimeMs && t1 - frames.since > 1000) {
|
|
469
|
+
emit("onFrameTimeMs", Math.round((frames.ms / frames.n) * 100) / 100);
|
|
470
|
+
frames = { n: 0, ms: 0, since: t1 };
|
|
471
|
+
}
|
|
472
|
+
return out;
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
const key = id ?? `niivue-${Math.random().toString(36).slice(2)}`;
|
|
476
|
+
registry?.viewers.set(key, nv);
|
|
477
|
+
holder.niivue = nv; // for browser tests and debugging
|
|
478
|
+
nv.attachToCanvas(canvas)
|
|
479
|
+
.then(() => {
|
|
480
|
+
if (alive) setReady(true);
|
|
481
|
+
})
|
|
482
|
+
.catch((e) => {
|
|
483
|
+
if (!alive) return;
|
|
484
|
+
console.error("NiiVue could not start:", e);
|
|
485
|
+
fail(/webgl2/i.test(message(e)) ? NO_WEBGL : `NiiVue could not start: ${message(e)}`);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
return () => {
|
|
489
|
+
alive = false;
|
|
490
|
+
clearTimeout(locTimer);
|
|
491
|
+
if (registry?.viewers.get(key) === nv) registry.viewers.delete(key);
|
|
492
|
+
for (const [name, members] of registry?.groups ?? []) if (members.has(nv)) leaveGroup(name, nv);
|
|
493
|
+
try {
|
|
494
|
+
nv.cleanup();
|
|
495
|
+
nv.gl?.getExtension("WEBGL_lose_context")?.loseContext();
|
|
496
|
+
} catch (e) {
|
|
497
|
+
console.warn("NiiVue teardown:", e);
|
|
498
|
+
}
|
|
499
|
+
canvas.remove();
|
|
500
|
+
delete holder.niivue;
|
|
501
|
+
volState.current.clear();
|
|
502
|
+
meshState.current.clear();
|
|
503
|
+
nvRef.current = null;
|
|
504
|
+
setReady(false);
|
|
505
|
+
};
|
|
506
|
+
}, []);
|
|
507
|
+
|
|
508
|
+
useEffect(() => {
|
|
509
|
+
if (typeof outerRef === "function") {
|
|
510
|
+
outerRef(holderRef.current);
|
|
511
|
+
return () => outerRef(null);
|
|
512
|
+
}
|
|
513
|
+
if (outerRef) outerRef.current = holderRef.current;
|
|
514
|
+
}, [outerRef]);
|
|
515
|
+
|
|
516
|
+
// Run ``fn(nv)`` once the viewer is attached; NiiVue methods need the GL context.
|
|
517
|
+
const live = (fn) => {
|
|
518
|
+
const nv = nvRef.current;
|
|
519
|
+
if (ready && nv) fn(nv);
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
// ------------------------------------------------------------ volumes / meshes
|
|
523
|
+
const volSig = sig(volumes);
|
|
524
|
+
useEffect(() => {
|
|
525
|
+
const nv = nvRef.current;
|
|
526
|
+
if (!ready || !nv) return;
|
|
527
|
+
queue.current = queue.current.then(() => syncVolumes(nv, volumes || [], volState.current, fail));
|
|
528
|
+
}, [volSig, ready]);
|
|
529
|
+
|
|
530
|
+
const meshSig = sig(meshes);
|
|
531
|
+
useEffect(() => {
|
|
532
|
+
const nv = nvRef.current;
|
|
533
|
+
if (!ready || !nv) return;
|
|
534
|
+
queue.current = queue.current.then(() => syncMeshes(nv, meshes || [], meshState.current, fail));
|
|
535
|
+
}, [meshSig, ready]);
|
|
536
|
+
|
|
537
|
+
// ------------------------------------------------------------ display props
|
|
538
|
+
useEffect(() => live((nv) => {
|
|
539
|
+
if (sliceType != null) nv.setSliceType(SLICE_TYPES[sliceType] ?? sliceType);
|
|
540
|
+
}), [sliceType, ready]);
|
|
541
|
+
useEffect(() => live((nv) => {
|
|
542
|
+
if (multiplanarLayout != null) nv.setMultiplanarLayout(MULTIPLANAR_LAYOUTS[multiplanarLayout] ?? multiplanarLayout);
|
|
543
|
+
}), [multiplanarLayout, ready]);
|
|
544
|
+
useEffect(() => live((nv) => {
|
|
545
|
+
if (show3dCrosshair == null) return;
|
|
546
|
+
nv.opts.show3Dcrosshair = show3dCrosshair;
|
|
547
|
+
nv.drawScene();
|
|
548
|
+
}), [show3dCrosshair, ready]);
|
|
549
|
+
useEffect(() => live((nv) => {
|
|
550
|
+
if (crosshairColor != null) nv.setCrosshairColor(crosshairColor);
|
|
551
|
+
}), [sig(crosshairColor), ready]);
|
|
552
|
+
useEffect(() => live((nv) => {
|
|
553
|
+
if (crosshairWidth != null) nv.setCrosshairWidth(crosshairWidth);
|
|
554
|
+
}), [crosshairWidth, ready]);
|
|
555
|
+
useEffect(() => live((nv) => {
|
|
556
|
+
if (backColor == null) return;
|
|
557
|
+
nv.opts.backColor = backColor;
|
|
558
|
+
nv.drawScene();
|
|
559
|
+
}), [sig(backColor), ready]);
|
|
560
|
+
useEffect(() => live((nv) => {
|
|
561
|
+
if (dragMode != null) nv.setDragMode(DRAG_MODES[dragMode] ?? dragMode);
|
|
562
|
+
}), [dragMode, ready]);
|
|
563
|
+
useEffect(() => live((nv) => {
|
|
564
|
+
if (isColorbar == null) return;
|
|
565
|
+
nv.opts.isColorbar = isColorbar;
|
|
566
|
+
nv.drawScene();
|
|
567
|
+
}), [isColorbar, ready]);
|
|
568
|
+
useEffect(() => live((nv) => {
|
|
569
|
+
if (isOrientCube == null) return;
|
|
570
|
+
nv.opts.isOrientCube = isOrientCube;
|
|
571
|
+
nv.drawScene();
|
|
572
|
+
}), [isOrientCube, ready]);
|
|
573
|
+
useEffect(() => live((nv) => {
|
|
574
|
+
if (isRadiological != null) nv.setRadiologicalConvention(isRadiological);
|
|
575
|
+
}), [isRadiological, ready]);
|
|
576
|
+
useEffect(() => live((nv) => {
|
|
577
|
+
if (clipPlane != null) nv.setClipPlane(clipPlane);
|
|
578
|
+
}), [sig(clipPlane), ready]);
|
|
579
|
+
useEffect(() => live((nv) => {
|
|
580
|
+
if (renderAzimuthElevation != null) nv.setRenderAzimuthElevation(...renderAzimuthElevation);
|
|
581
|
+
}), [sig(renderAzimuthElevation), ready]);
|
|
582
|
+
// The selected layer is the default target of set_frame_4d / save_image and the only one
|
|
583
|
+
// whose colorbar is shown. Re-applied when the volumes change (new layers show theirs).
|
|
584
|
+
useEffect(() => live((nv) => {
|
|
585
|
+
if (selectedVolume == null) return;
|
|
586
|
+
nv.reflexSelected = selectedVolume;
|
|
587
|
+
queue.current = queue.current.then(() => {
|
|
588
|
+
nv.volumes.forEach((v, i) => { v.colorbarVisible = i === selectedVolume; });
|
|
589
|
+
nv.updateGLVolume();
|
|
590
|
+
});
|
|
591
|
+
}), [selectedVolume, volSig, ready]);
|
|
592
|
+
useEffect(() => live((nv) => {
|
|
593
|
+
if (sig(options) === "null") return;
|
|
594
|
+
Object.assign(nv.opts, niivueOptions({ options }));
|
|
595
|
+
nv.updateGLVolume();
|
|
596
|
+
}), [sig(options), ready]);
|
|
597
|
+
|
|
598
|
+
// ------------------------------------------------------------------ drawing
|
|
599
|
+
// After the volumes: enabling the pen creates a drawing sized to the background image.
|
|
600
|
+
useEffect(() => live((nv) => {
|
|
601
|
+
if (drawingEnabled == null) return;
|
|
602
|
+
queue.current = queue.current.then(() => nv.setDrawingEnabled(drawingEnabled));
|
|
603
|
+
}), [drawingEnabled, volSig, ready]);
|
|
604
|
+
useEffect(() => live((nv) => {
|
|
605
|
+
if (penValue != null) nv.setPenValue(penValue, nv.opts.isFilledPen);
|
|
606
|
+
}), [penValue, ready]);
|
|
607
|
+
useEffect(() => live((nv) => {
|
|
608
|
+
if (drawOpacity != null) nv.setDrawOpacity(drawOpacity);
|
|
609
|
+
}), [drawOpacity, ready]);
|
|
610
|
+
useEffect(() => live((nv) => {
|
|
611
|
+
if (drawingFillOverwrites != null) nv.drawFillOverwrites = drawingFillOverwrites;
|
|
612
|
+
}), [drawingFillOverwrites, ready]);
|
|
613
|
+
|
|
614
|
+
// --------------------------------------------------------------------- sync
|
|
615
|
+
const syncSig = sig(syncOpts);
|
|
616
|
+
useEffect(() => {
|
|
617
|
+
const nv = nvRef.current;
|
|
618
|
+
if (!ready || !nv || !syncGroup || !registry) return;
|
|
619
|
+
joinGroup(syncGroup, nv, syncOpts ?? DEFAULT_SYNC);
|
|
620
|
+
return () => leaveGroup(syncGroup, nv);
|
|
621
|
+
}, [syncGroup, syncSig, ready]);
|
|
622
|
+
|
|
623
|
+
return (
|
|
624
|
+
<div
|
|
625
|
+
ref={holderRef}
|
|
626
|
+
id={id}
|
|
627
|
+
className={`reflex-niivue${className ? ` ${className}` : ""}`}
|
|
628
|
+
style={{ position: "relative", overflow: "hidden", ...style }}
|
|
629
|
+
data-niivue-ready={ready ? "true" : "false"}
|
|
630
|
+
>
|
|
631
|
+
{error && (
|
|
632
|
+
<div
|
|
633
|
+
role="alert"
|
|
634
|
+
data-niivue-error=""
|
|
635
|
+
style={{
|
|
636
|
+
position: "absolute", inset: 0, display: "grid", placeItems: "center", padding: 16,
|
|
637
|
+
background: "rgba(0,0,0,0.75)", color: "#f87171", font: "13px/1.45 system-ui, sans-serif",
|
|
638
|
+
textAlign: "center", zIndex: 2,
|
|
639
|
+
}}
|
|
640
|
+
>
|
|
641
|
+
{error}
|
|
642
|
+
</div>
|
|
643
|
+
)}
|
|
644
|
+
</div>
|
|
645
|
+
);
|
|
646
|
+
}
|