@niivue/nvreact 0.1.0 → 0.1.2

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.
package/LICENSE ADDED
@@ -0,0 +1,24 @@
1
+ BSD 2-Clause License
2
+
3
+ Copyright (c) 2026, hanayik
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
16
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
17
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
18
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
19
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
20
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
21
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
22
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
23
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
24
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
package/README.md CHANGED
@@ -95,6 +95,10 @@ useSceneEvent(scene, "viewerCreated", (nv, index) => {
95
95
  useSceneEvent(scene, "imageLoaded", (viewerIndex, volume) => {
96
96
  console.log(`loaded ${volume.name} in viewer ${viewerIndex}`);
97
97
  });
98
+
99
+ useSceneEvent(scene, "locationChange", (viewerIndex, data) => {
100
+ console.log(`viewer ${viewerIndex}: ${data.string}`);
101
+ });
98
102
  ```
99
103
 
100
104
  #### Events
package/dist/lib.js CHANGED
@@ -324,21 +324,43 @@ class NvSceneController {
324
324
  forEachNiivue(callback) {
325
325
  this.viewers.forEach((viewer, i) => callback(viewer.niivue, i));
326
326
  }
327
+ hasSyncableContent(nv) {
328
+ return nv.volumes.length > 0 || nv.meshes.length > 0;
329
+ }
330
+ safeBroadcastTo(index, targets) {
331
+ const viewer = this.viewers[index];
332
+ if (!viewer)
333
+ return;
334
+ try {
335
+ viewer.niivue.broadcastTo(targets, this.broadcastOptions);
336
+ } catch (err) {
337
+ this.addError(viewer.id, err);
338
+ this.emit("error", index, err);
339
+ }
340
+ }
341
+ rewireBroadcasting() {
342
+ if (!this.broadcasting) {
343
+ this.viewers.forEach((_viewer, index) => {
344
+ this.safeBroadcastTo(index, []);
345
+ });
346
+ return;
347
+ }
348
+ const syncableViewers = this.viewers.filter((viewer) => this.hasSyncableContent(viewer.niivue));
349
+ this.viewers.forEach((viewer, index) => {
350
+ if (!this.hasSyncableContent(viewer.niivue)) {
351
+ this.safeBroadcastTo(index, []);
352
+ return;
353
+ }
354
+ const others = syncableViewers.filter((v) => v.id !== viewer.id).map((v) => v.niivue);
355
+ this.safeBroadcastTo(index, others);
356
+ });
357
+ }
327
358
  setBroadcasting(enabled, options) {
328
359
  this.broadcasting = enabled;
329
360
  if (options) {
330
361
  this.broadcastOptions = { ...this.broadcastOptions, ...options };
331
362
  }
332
- if (enabled) {
333
- this.viewers.forEach((viewer) => {
334
- const others = this.viewers.filter((v) => v.id !== viewer.id).map((v) => v.niivue);
335
- viewer.niivue.broadcastTo(others, this.broadcastOptions);
336
- });
337
- } else {
338
- this.viewers.forEach((viewer) => {
339
- viewer.niivue.broadcastTo([], this.broadcastOptions);
340
- });
341
- }
363
+ this.rewireBroadcasting();
342
364
  this.notify();
343
365
  }
344
366
  isBroadcasting() {
@@ -381,6 +403,9 @@ class NvSceneController {
381
403
  try {
382
404
  const image = await viewer.niivue.addVolumeFromUrl(opts);
383
405
  this.emit("volumeAdded", index, opts, image);
406
+ if (this.broadcasting) {
407
+ this.rewireBroadcasting();
408
+ }
384
409
  return image;
385
410
  } catch (err) {
386
411
  this.addError(viewer.id, err);
@@ -406,9 +431,49 @@ class NvSceneController {
406
431
  if (vol) {
407
432
  nv.removeVolume(vol);
408
433
  this.emit("volumeRemoved", index, url);
434
+ if (this.broadcasting) {
435
+ this.rewireBroadcasting();
436
+ }
409
437
  this.notify();
410
438
  }
411
439
  }
440
+ findVolume(viewerIndex, volumeIndex) {
441
+ const viewer = this.viewers[viewerIndex];
442
+ if (!viewer)
443
+ return;
444
+ const nv = viewer.niivue;
445
+ const vol = nv.volumes[volumeIndex];
446
+ if (!vol)
447
+ return;
448
+ return { nv, vol, volumeIndex };
449
+ }
450
+ setColormap(viewerIndex, volumeIndex, colormap) {
451
+ const found = this.findVolume(viewerIndex, volumeIndex);
452
+ if (!found)
453
+ return;
454
+ found.vol.colormap = colormap;
455
+ found.nv.updateGLVolume();
456
+ this.emit("colormapChanged", viewerIndex, volumeIndex, colormap);
457
+ this.notify();
458
+ }
459
+ setCalMinMax(viewerIndex, volumeIndex, cal_min, cal_max) {
460
+ const found = this.findVolume(viewerIndex, volumeIndex);
461
+ if (!found)
462
+ return;
463
+ found.vol.cal_min = cal_min;
464
+ found.vol.cal_max = cal_max;
465
+ found.nv.updateGLVolume();
466
+ this.emit("intensityChanged", viewerIndex, volumeIndex, cal_min, cal_max);
467
+ this.notify();
468
+ }
469
+ setOpacity(viewerIndex, volumeIndex, opacity) {
470
+ const found = this.findVolume(viewerIndex, volumeIndex);
471
+ if (!found)
472
+ return;
473
+ found.nv.setOpacity(volumeIndex, opacity);
474
+ this.emit("opacityChanged", viewerIndex, volumeIndex, opacity);
475
+ this.notify();
476
+ }
412
477
  incrementLoading(id) {
413
478
  this.loadingCounts.set(id, (this.loadingCounts.get(id) ?? 0) + 1);
414
479
  this.notify();
@@ -464,6 +529,14 @@ class NvSceneController {
464
529
  };
465
530
  niivue.onImageLoaded = (vol) => {
466
531
  this.emit("imageLoaded", index, vol);
532
+ if (this.broadcasting) {
533
+ this.rewireBroadcasting();
534
+ }
535
+ };
536
+ niivue.onMeshLoaded = (_mesh) => {
537
+ if (this.broadcasting) {
538
+ this.rewireBroadcasting();
539
+ }
467
540
  };
468
541
  if (this.broadcasting) {
469
542
  this.setBroadcasting(true);
@@ -530,6 +603,14 @@ class NvSceneController {
530
603
 
531
604
  // src/nvviewer.tsx
532
605
  import { jsxDEV as jsxDEV2 } from "react/jsx-dev-runtime";
606
+ function extractVisualProps(opts) {
607
+ return {
608
+ colormap: opts.colormap ?? opts.colorMap,
609
+ cal_min: opts.cal_min,
610
+ cal_max: opts.cal_max,
611
+ opacity: opts.opacity
612
+ };
613
+ }
533
614
  var NvViewer = ({
534
615
  volumes,
535
616
  options,
@@ -542,7 +623,7 @@ var NvViewer = ({
542
623
  }) => {
543
624
  const containerRef = useRef2(null);
544
625
  const nvRef = useRef2(null);
545
- const loadedUrlsRef = useRef2(new Set);
626
+ const loadedVolumesRef = useRef2(new Map);
546
627
  const onLocationChangeRef = useRef2(onLocationChange);
547
628
  onLocationChangeRef.current = onLocationChange;
548
629
  const onImageLoadedRef = useRef2(onImageLoaded);
@@ -592,7 +673,7 @@ var NvViewer = ({
592
673
  canvas.height = 0;
593
674
  canvas.remove();
594
675
  nvRef.current = null;
595
- loadedUrlsRef.current.clear();
676
+ loadedVolumesRef.current.clear();
596
677
  };
597
678
  }, []);
598
679
  useEffect2(() => {
@@ -603,24 +684,52 @@ var NvViewer = ({
603
684
  if (!nv)
604
685
  return;
605
686
  const desiredUrls = new Set((volumes ?? []).map((v) => v.url));
606
- const currentUrls = loadedUrlsRef.current;
607
- for (const url of currentUrls) {
687
+ const currentVolumes = loadedVolumesRef.current;
688
+ for (const url of currentVolumes.keys()) {
608
689
  if (!desiredUrls.has(url)) {
609
690
  const vol = nv.volumes.find((v) => v.url === url || v.name === url);
610
691
  if (vol)
611
692
  nv.removeVolume(vol);
612
- currentUrls.delete(url);
693
+ currentVolumes.delete(url);
613
694
  }
614
695
  }
696
+ let needsGLUpdate = false;
615
697
  for (const opts of volumes ?? []) {
616
- if (!currentUrls.has(opts.url)) {
617
- currentUrls.add(opts.url);
698
+ if (!currentVolumes.has(opts.url)) {
699
+ const props = extractVisualProps(opts);
700
+ currentVolumes.set(opts.url, props);
618
701
  nv.addVolumeFromUrl(opts).catch((err) => {
619
- currentUrls.delete(opts.url);
702
+ currentVolumes.delete(opts.url);
620
703
  onErrorRef.current?.(err);
621
704
  });
705
+ } else {
706
+ const prev = currentVolumes.get(opts.url);
707
+ const next = extractVisualProps(opts);
708
+ const vol = nv.volumes.find((v) => v.url === opts.url || v.name === opts.url);
709
+ if (!vol)
710
+ continue;
711
+ if (next.colormap !== undefined && next.colormap !== prev.colormap) {
712
+ vol.colormap = next.colormap;
713
+ needsGLUpdate = true;
714
+ }
715
+ if (next.cal_min !== undefined && next.cal_min !== prev.cal_min) {
716
+ vol.cal_min = next.cal_min;
717
+ needsGLUpdate = true;
718
+ }
719
+ if (next.cal_max !== undefined && next.cal_max !== prev.cal_max) {
720
+ vol.cal_max = next.cal_max;
721
+ needsGLUpdate = true;
722
+ }
723
+ if (next.opacity !== undefined && next.opacity !== prev.opacity) {
724
+ const volIdx = nv.volumes.indexOf(vol);
725
+ nv.setOpacity(volIdx, next.opacity);
726
+ }
727
+ currentVolumes.set(opts.url, next);
622
728
  }
623
729
  }
730
+ if (needsGLUpdate) {
731
+ nv.updateGLVolume();
732
+ }
624
733
  }, [volumes]);
625
734
  return /* @__PURE__ */ jsxDEV2("div", {
626
735
  ref: containerRef,
@@ -108,6 +108,9 @@ export declare class NvSceneController {
108
108
  getNiivue(index: number): Niivue | undefined;
109
109
  getAllNiivue(): Niivue[];
110
110
  forEachNiivue(callback: NiivueCallback): void;
111
+ private hasSyncableContent;
112
+ private safeBroadcastTo;
113
+ private rewireBroadcasting;
111
114
  setBroadcasting(enabled: boolean, options?: Partial<BroadcastOptions>): void;
112
115
  isBroadcasting(): boolean;
113
116
  setViewerSliceLayout(index: number, layout: SliceLayoutTile[] | null): void;
@@ -118,6 +121,17 @@ export declare class NvSceneController {
118
121
  loadVolume(index: number, opts: ImageFromUrlOptions): Promise<NVImage>;
119
122
  loadVolumes(index: number, opts: ImageFromUrlOptions[]): Promise<NVImage[]>;
120
123
  removeVolume(index: number, url: string): void;
124
+ /**
125
+ * Look up the Niivue instance and NVImage at the given viewer and volume indices.
126
+ * Returns undefined if either index is out of bounds.
127
+ */
128
+ private findVolume;
129
+ /** Set the colormap for a volume at the given viewer and volume index. */
130
+ setColormap(viewerIndex: number, volumeIndex: number, colormap: string): void;
131
+ /** Set the intensity range (cal_min / cal_max) for a volume at the given viewer and volume index. */
132
+ setCalMinMax(viewerIndex: number, volumeIndex: number, cal_min: number, cal_max: number): void;
133
+ /** Set the opacity for a volume at the given viewer and volume index. */
134
+ setOpacity(viewerIndex: number, volumeIndex: number, opacity: number): void;
121
135
  private incrementLoading;
122
136
  private decrementLoading;
123
137
  private addError;
@@ -10,6 +10,9 @@ export interface NvSceneEventMap {
10
10
  error: (viewerIndex: number, error: unknown) => void;
11
11
  volumeAdded: (viewerIndex: number, imageOptions: ImageFromUrlOptions, image: NVImage) => void;
12
12
  volumeRemoved: (viewerIndex: number, url: string) => void;
13
+ colormapChanged: (viewerIndex: number, volumeIndex: number, colormap: string) => void;
14
+ intensityChanged: (viewerIndex: number, volumeIndex: number, cal_min: number, cal_max: number) => void;
15
+ opacityChanged: (viewerIndex: number, volumeIndex: number, opacity: number) => void;
13
16
  }
14
17
  export interface ViewerState {
15
18
  id: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@niivue/nvreact",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./dist/lib.js",
@@ -31,12 +31,16 @@
31
31
  },
32
32
  "dependencies": {},
33
33
  "peerDependencies": {
34
- "@niivue/niivue": "0.67.1-dev.2",
34
+ "@niivue/niivue": "^0.68.1",
35
35
  "react": "^19",
36
36
  "react-dom": "^19"
37
37
  },
38
38
  "devDependencies": {
39
- "@niivue/niivue": "0.67.1-dev.2",
39
+ "@happy-dom/global-registrator": "^20.6.1",
40
+ "@niivue/niivue": "^0.68.1",
41
+ "@testing-library/dom": "^10.4.1",
42
+ "@testing-library/jest-dom": "^6.9.1",
43
+ "@testing-library/react": "^16.3.2",
40
44
  "@types/bun": "latest",
41
45
  "@types/react": "^19",
42
46
  "@types/react-dom": "^19",