@niivue/nvreact 0.1.0
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/README.md +223 -0
- package/dist/lib.js +692 -0
- package/dist/types/context.d.ts +7 -0
- package/dist/types/hooks.d.ts +10 -0
- package/dist/types/layouts.d.ts +20 -0
- package/dist/types/lib.d.ts +9 -0
- package/dist/types/nvcontainer.d.ts +10 -0
- package/dist/types/nvscene-controller.d.ts +129 -0
- package/dist/types/nvscene.d.ts +10 -0
- package/dist/types/nvviewer.d.ts +14 -0
- package/dist/types/types.d.ts +18 -0
- package/package.json +47 -0
package/README.md
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
# @niivue/nvreact
|
|
2
|
+
|
|
3
|
+
Lightweight React bindings for [Niivue](https://github.com/niivue/niivue) with multi-instance scene management, declarative hooks, and a standalone viewer component.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @niivue/nvreact @niivue/niivue react react-dom
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
```tsx
|
|
14
|
+
import { NvScene, NvSceneProvider, useScene } from "@niivue/nvreact";
|
|
15
|
+
|
|
16
|
+
function App() {
|
|
17
|
+
const { scene, snapshot } = useScene();
|
|
18
|
+
return (
|
|
19
|
+
<NvSceneProvider scene={scene}>
|
|
20
|
+
<p>{snapshot.viewerCount} viewers</p>
|
|
21
|
+
<button onClick={() => scene.addViewer()}>Add</button>
|
|
22
|
+
<NvScene scene={scene} style={{ height: 600 }} />
|
|
23
|
+
</NvSceneProvider>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Standalone viewer
|
|
29
|
+
|
|
30
|
+
`NvViewer` is a single-instance component with declarative volume management — pass a `volumes` array and it handles loading/diffing automatically.
|
|
31
|
+
|
|
32
|
+
```tsx
|
|
33
|
+
import { NvViewer } from "@niivue/nvreact";
|
|
34
|
+
|
|
35
|
+
function App() {
|
|
36
|
+
return (
|
|
37
|
+
<NvViewer
|
|
38
|
+
volumes={[{ url: "https://example.com/brain.nii.gz", name: "brain" }]}
|
|
39
|
+
style={{ height: 600 }}
|
|
40
|
+
onImageLoaded={(vol) => console.log("loaded:", vol.name)}
|
|
41
|
+
onLocationChange={(data) => console.log("location:", data)}
|
|
42
|
+
onError={(err) => console.error(err)}
|
|
43
|
+
/>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### NvViewer props
|
|
49
|
+
|
|
50
|
+
| Prop | Type | Description |
|
|
51
|
+
|------|------|-------------|
|
|
52
|
+
| `volumes` | `ImageFromUrlOptions[]` | Volumes to load (diffed automatically) |
|
|
53
|
+
| `options` | `Partial<NVConfigOptions>` | Niivue config overrides |
|
|
54
|
+
| `sliceType` | `number` | Slice type (default: `SLICE_TYPE.AXIAL`) |
|
|
55
|
+
| `className` | `string` | CSS class for the container div |
|
|
56
|
+
| `style` | `CSSProperties` | Inline styles for the container div |
|
|
57
|
+
| `onLocationChange` | `(data) => void` | Crosshair location callback |
|
|
58
|
+
| `onImageLoaded` | `(volume: NVImage) => void` | Volume loaded callback |
|
|
59
|
+
| `onError` | `(error) => void` | Error callback |
|
|
60
|
+
|
|
61
|
+
## Hooks
|
|
62
|
+
|
|
63
|
+
### `useScene(controller?, layouts?, viewerDefaults?)`
|
|
64
|
+
|
|
65
|
+
Creates (or wraps) an `NvSceneController` and subscribes to its snapshot via `useSyncExternalStore`.
|
|
66
|
+
|
|
67
|
+
```tsx
|
|
68
|
+
const { scene, snapshot } = useScene();
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Pass an existing controller to wrap it instead of creating a new one:
|
|
72
|
+
|
|
73
|
+
```tsx
|
|
74
|
+
const controller = useMemo(() => new NvSceneController(defaultLayouts), []);
|
|
75
|
+
const { scene, snapshot } = useScene(controller);
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### `useNiivue(scene, index)`
|
|
79
|
+
|
|
80
|
+
Returns the raw `Niivue` instance at the given viewer index, or `undefined` if the index is out of range.
|
|
81
|
+
|
|
82
|
+
```tsx
|
|
83
|
+
const nv = useNiivue(scene, 0);
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### `useSceneEvent(scene, event, callback)`
|
|
87
|
+
|
|
88
|
+
Subscribes to scene events with automatic cleanup. The callback ref is stable so it always calls the latest function.
|
|
89
|
+
|
|
90
|
+
```tsx
|
|
91
|
+
useSceneEvent(scene, "viewerCreated", (nv, index) => {
|
|
92
|
+
console.log(`viewer ${index} created`);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
useSceneEvent(scene, "imageLoaded", (viewerIndex, volume) => {
|
|
96
|
+
console.log(`loaded ${volume.name} in viewer ${viewerIndex}`);
|
|
97
|
+
});
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
#### Events
|
|
101
|
+
|
|
102
|
+
| Event | Callback signature |
|
|
103
|
+
|-------|-------------------|
|
|
104
|
+
| `viewerCreated` | `(nv: Niivue, index: number) => void` |
|
|
105
|
+
| `viewerRemoved` | `(index: number) => void` |
|
|
106
|
+
| `locationChange` | `(viewerIndex: number, data: unknown) => void` |
|
|
107
|
+
| `imageLoaded` | `(viewerIndex: number, volume: NVImage) => void` |
|
|
108
|
+
| `error` | `(viewerIndex: number, error: unknown) => void` |
|
|
109
|
+
| `volumeAdded` | `(viewerIndex: number, imageOptions, image: NVImage) => void` |
|
|
110
|
+
| `volumeRemoved` | `(viewerIndex: number, url: string) => void` |
|
|
111
|
+
|
|
112
|
+
## Context
|
|
113
|
+
|
|
114
|
+
`NvSceneProvider` makes a scene controller available to any descendant via `useSceneContext()`.
|
|
115
|
+
|
|
116
|
+
```tsx
|
|
117
|
+
import { NvSceneProvider, useSceneContext } from "@niivue/nvreact";
|
|
118
|
+
|
|
119
|
+
function Toolbar() {
|
|
120
|
+
const scene = useSceneContext();
|
|
121
|
+
return <button onClick={() => scene.addViewer()}>Add</button>;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function App() {
|
|
125
|
+
const { scene, snapshot } = useScene();
|
|
126
|
+
return (
|
|
127
|
+
<NvSceneProvider scene={scene}>
|
|
128
|
+
<Toolbar />
|
|
129
|
+
<NvScene scene={scene} />
|
|
130
|
+
</NvSceneProvider>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Scene controller
|
|
136
|
+
|
|
137
|
+
### Viewer management
|
|
138
|
+
|
|
139
|
+
```tsx
|
|
140
|
+
scene.addViewer();
|
|
141
|
+
scene.removeViewer(0);
|
|
142
|
+
scene.canAddViewer(); // false when at layout capacity
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### Layouts
|
|
146
|
+
|
|
147
|
+
```tsx
|
|
148
|
+
import { defaultLayouts } from "@niivue/nvreact";
|
|
149
|
+
|
|
150
|
+
// Layout keys are rows x columns (e.g. "2x2", "1x2", "3x3")
|
|
151
|
+
scene.setLayout("2x2");
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### Per-viewer slice layouts
|
|
155
|
+
|
|
156
|
+
```tsx
|
|
157
|
+
import { defaultSliceLayouts } from "@niivue/nvreact";
|
|
158
|
+
|
|
159
|
+
scene.setViewerSliceLayout(0, defaultSliceLayouts["axial-hero"].layout);
|
|
160
|
+
scene.setViewerSliceLayout(0, null); // reset to default
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Broadcast interactions
|
|
164
|
+
|
|
165
|
+
```tsx
|
|
166
|
+
scene.setBroadcasting(true); // sync crosshair across all viewers
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### Volume management
|
|
170
|
+
|
|
171
|
+
```tsx
|
|
172
|
+
await scene.loadVolume(0, { url: "https://example.com/brain.nii.gz", name: "brain" });
|
|
173
|
+
await scene.loadVolumes(0, [
|
|
174
|
+
{ url: "https://example.com/t1.nii.gz", name: "t1" },
|
|
175
|
+
{ url: "https://example.com/overlay.nii.gz", name: "overlay" },
|
|
176
|
+
]);
|
|
177
|
+
scene.removeVolume(0, "https://example.com/brain.nii.gz");
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## API reference
|
|
181
|
+
|
|
182
|
+
### Components
|
|
183
|
+
|
|
184
|
+
- **`NvScene`** — multi-viewer container bound to an `NvSceneController`
|
|
185
|
+
- **`NvViewer`** — standalone single-instance viewer with declarative volumes
|
|
186
|
+
|
|
187
|
+
### Hooks
|
|
188
|
+
|
|
189
|
+
- **`useScene(controller?, layouts?, viewerDefaults?)`** — create/wrap a controller + subscribe to snapshots
|
|
190
|
+
- **`useNiivue(scene, index)`** — access a raw `Niivue` instance by index
|
|
191
|
+
- **`useSceneEvent(scene, event, callback)`** — subscribe to controller events
|
|
192
|
+
|
|
193
|
+
### Context
|
|
194
|
+
|
|
195
|
+
- **`NvSceneProvider`** — provides an `NvSceneController` via React context
|
|
196
|
+
- **`useSceneContext()`** — consume the nearest `NvSceneProvider`
|
|
197
|
+
|
|
198
|
+
### Controller
|
|
199
|
+
|
|
200
|
+
- **`NvSceneController`** — manages multiple Niivue instances, layout, broadcasting, and volumes
|
|
201
|
+
- **`NvSceneController#on(event, handler)`** / **`off(event, handler)`** — event subscription
|
|
202
|
+
- **`NvSceneController#loadVolume(index, opts)`** / **`loadVolumes(index, opts[])`** — load volumes into a viewer
|
|
203
|
+
- **`NvSceneController#removeVolume(index, url)`** — remove a volume by URL
|
|
204
|
+
|
|
205
|
+
### Presets
|
|
206
|
+
|
|
207
|
+
- **`defaultLayouts`** — grid layout presets (1x1, 1x2, 2x2, etc.)
|
|
208
|
+
- **`defaultSliceLayouts`** — per-viewer slice layout presets
|
|
209
|
+
- **`defaultViewerOptions`** — default Niivue config
|
|
210
|
+
- **`defaultMouseConfig`** — default mouse interaction config
|
|
211
|
+
|
|
212
|
+
## Build (local)
|
|
213
|
+
|
|
214
|
+
```bash
|
|
215
|
+
bun run build:package
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
## Publish
|
|
219
|
+
|
|
220
|
+
```bash
|
|
221
|
+
bun run build:package
|
|
222
|
+
bun publish --access public
|
|
223
|
+
```
|
package/dist/lib.js
ADDED
|
@@ -0,0 +1,692 @@
|
|
|
1
|
+
// src/nvscene.tsx
|
|
2
|
+
import { useEffect, useRef } from "react";
|
|
3
|
+
import { jsxDEV } from "react/jsx-dev-runtime";
|
|
4
|
+
var NvScene = ({
|
|
5
|
+
scene,
|
|
6
|
+
className,
|
|
7
|
+
style,
|
|
8
|
+
initialLayout = "1x1"
|
|
9
|
+
}) => {
|
|
10
|
+
const containerRef = useRef(null);
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
if (containerRef.current) {
|
|
13
|
+
scene.setContainerElement(containerRef.current);
|
|
14
|
+
if (scene.viewers.length === 0) {
|
|
15
|
+
scene.setLayout(initialLayout);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return () => {
|
|
19
|
+
scene.setContainerElement(null);
|
|
20
|
+
};
|
|
21
|
+
}, [scene, initialLayout]);
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
const el = containerRef.current;
|
|
24
|
+
if (!el)
|
|
25
|
+
return;
|
|
26
|
+
const ro = new ResizeObserver(() => scene.updateLayout());
|
|
27
|
+
ro.observe(el);
|
|
28
|
+
return () => ro.disconnect();
|
|
29
|
+
}, [scene]);
|
|
30
|
+
return /* @__PURE__ */ jsxDEV("div", {
|
|
31
|
+
ref: containerRef,
|
|
32
|
+
className,
|
|
33
|
+
style
|
|
34
|
+
}, undefined, false, undefined, this);
|
|
35
|
+
};
|
|
36
|
+
// src/nvviewer.tsx
|
|
37
|
+
import { useEffect as useEffect2, useRef as useRef2 } from "react";
|
|
38
|
+
import {
|
|
39
|
+
Niivue as Niivue2,
|
|
40
|
+
SLICE_TYPE as SLICE_TYPE2
|
|
41
|
+
} from "@niivue/niivue";
|
|
42
|
+
|
|
43
|
+
// src/nvscene-controller.ts
|
|
44
|
+
import {
|
|
45
|
+
DRAG_MODE,
|
|
46
|
+
Niivue,
|
|
47
|
+
SLICE_TYPE,
|
|
48
|
+
SHOW_RENDER
|
|
49
|
+
} from "@niivue/niivue";
|
|
50
|
+
|
|
51
|
+
// src/layouts.ts
|
|
52
|
+
var layout1x1 = () => ({
|
|
53
|
+
top: "0",
|
|
54
|
+
left: "0",
|
|
55
|
+
width: "100%",
|
|
56
|
+
height: "100%"
|
|
57
|
+
});
|
|
58
|
+
var layout2x2 = (_containerElement, index) => {
|
|
59
|
+
const row = Math.floor(index / 2);
|
|
60
|
+
const col = index % 2;
|
|
61
|
+
return {
|
|
62
|
+
top: `${row * 50}%`,
|
|
63
|
+
left: `${col * 50}%`,
|
|
64
|
+
width: "50%",
|
|
65
|
+
height: "50%"
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
var layout1x2 = (_containerElement, index) => ({
|
|
69
|
+
top: "0",
|
|
70
|
+
left: `${index * 50}%`,
|
|
71
|
+
width: "50%",
|
|
72
|
+
height: "100%"
|
|
73
|
+
});
|
|
74
|
+
var layout2x1 = (_containerElement, index) => ({
|
|
75
|
+
top: `${index * 50}%`,
|
|
76
|
+
left: "0",
|
|
77
|
+
width: "100%",
|
|
78
|
+
height: "50%"
|
|
79
|
+
});
|
|
80
|
+
var layout1x3 = (_containerElement, index) => ({
|
|
81
|
+
top: "0",
|
|
82
|
+
left: `${index * 33.333}%`,
|
|
83
|
+
width: "33.333%",
|
|
84
|
+
height: "100%"
|
|
85
|
+
});
|
|
86
|
+
var layout3x1 = (_containerElement, index) => ({
|
|
87
|
+
top: `${index * 33.333}%`,
|
|
88
|
+
left: "0",
|
|
89
|
+
width: "100%",
|
|
90
|
+
height: "33.333%"
|
|
91
|
+
});
|
|
92
|
+
var layout3x3 = (_containerElement, index) => {
|
|
93
|
+
const row = Math.floor(index / 3);
|
|
94
|
+
const col = index % 3;
|
|
95
|
+
return {
|
|
96
|
+
top: `${row * 33.333}%`,
|
|
97
|
+
left: `${col * 33.333}%`,
|
|
98
|
+
width: "33.333%",
|
|
99
|
+
height: "33.333%"
|
|
100
|
+
};
|
|
101
|
+
};
|
|
102
|
+
var defaultLayouts = {
|
|
103
|
+
"1x1": {
|
|
104
|
+
slots: 1,
|
|
105
|
+
label: "1x1",
|
|
106
|
+
layoutFunction: layout1x1
|
|
107
|
+
},
|
|
108
|
+
"1x2": {
|
|
109
|
+
slots: 2,
|
|
110
|
+
label: "1x2",
|
|
111
|
+
layoutFunction: layout1x2
|
|
112
|
+
},
|
|
113
|
+
"2x1": {
|
|
114
|
+
slots: 2,
|
|
115
|
+
label: "2x1",
|
|
116
|
+
layoutFunction: layout2x1
|
|
117
|
+
},
|
|
118
|
+
"1x3": {
|
|
119
|
+
slots: 3,
|
|
120
|
+
label: "1x3",
|
|
121
|
+
layoutFunction: layout1x3
|
|
122
|
+
},
|
|
123
|
+
"3x1": {
|
|
124
|
+
slots: 3,
|
|
125
|
+
label: "3x1",
|
|
126
|
+
layoutFunction: layout3x1
|
|
127
|
+
},
|
|
128
|
+
"2x2": {
|
|
129
|
+
slots: 4,
|
|
130
|
+
label: "2x2",
|
|
131
|
+
layoutFunction: layout2x2
|
|
132
|
+
},
|
|
133
|
+
"3x3": {
|
|
134
|
+
slots: 9,
|
|
135
|
+
label: "3x3",
|
|
136
|
+
layoutFunction: layout3x3
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
// src/nvscene-controller.ts
|
|
141
|
+
var defaultSliceLayout = [
|
|
142
|
+
{ sliceType: SLICE_TYPE.AXIAL, position: [0, 0, 1, 0.8] },
|
|
143
|
+
{ sliceType: SLICE_TYPE.CORONAL, position: [0, 0.8, 0.5, 0.2] },
|
|
144
|
+
{ sliceType: SLICE_TYPE.SAGITTAL, position: [0.5, 0.8, 0.5, 0.2] }
|
|
145
|
+
];
|
|
146
|
+
var splitSliceLayout = [
|
|
147
|
+
{ sliceType: SLICE_TYPE.SAGITTAL, position: [0, 0, 0.5, 1] },
|
|
148
|
+
{ sliceType: SLICE_TYPE.CORONAL, position: [0.5, 0, 0.5, 0.5] },
|
|
149
|
+
{ sliceType: SLICE_TYPE.AXIAL, position: [0.5, 0.5, 0.5, 0.5] }
|
|
150
|
+
];
|
|
151
|
+
var triSliceLayout = [
|
|
152
|
+
{ sliceType: SLICE_TYPE.AXIAL, position: [0, 0, 0.333, 1] },
|
|
153
|
+
{ sliceType: SLICE_TYPE.CORONAL, position: [0.333, 0, 0.333, 1] },
|
|
154
|
+
{ sliceType: SLICE_TYPE.SAGITTAL, position: [0.666, 0, 0.334, 1] }
|
|
155
|
+
];
|
|
156
|
+
var stackedSliceLayout = [
|
|
157
|
+
{ sliceType: SLICE_TYPE.AXIAL, position: [0, 0, 1, 0.333] },
|
|
158
|
+
{ sliceType: SLICE_TYPE.CORONAL, position: [0, 0.333, 1, 0.333] },
|
|
159
|
+
{ sliceType: SLICE_TYPE.SAGITTAL, position: [0, 0.666, 1, 0.334] }
|
|
160
|
+
];
|
|
161
|
+
var quadSliceLayout = [
|
|
162
|
+
{ sliceType: SLICE_TYPE.AXIAL, position: [0, 0, 0.5, 0.5] },
|
|
163
|
+
{ sliceType: SLICE_TYPE.CORONAL, position: [0.5, 0, 0.5, 0.5] },
|
|
164
|
+
{ sliceType: SLICE_TYPE.SAGITTAL, position: [0, 0.5, 0.5, 0.5] },
|
|
165
|
+
{ sliceType: SLICE_TYPE.RENDER, position: [0.5, 0.5, 0.5, 0.5] }
|
|
166
|
+
];
|
|
167
|
+
var heroRenderSliceLayout = [
|
|
168
|
+
{ sliceType: SLICE_TYPE.RENDER, position: [0, 0, 1, 0.7] },
|
|
169
|
+
{ sliceType: SLICE_TYPE.AXIAL, position: [0, 0.7, 0.333, 0.3] },
|
|
170
|
+
{ sliceType: SLICE_TYPE.CORONAL, position: [0.333, 0.7, 0.333, 0.3] },
|
|
171
|
+
{ sliceType: SLICE_TYPE.SAGITTAL, position: [0.666, 0.7, 0.334, 0.3] }
|
|
172
|
+
];
|
|
173
|
+
var defaultSliceLayouts = {
|
|
174
|
+
"axial-hero": {
|
|
175
|
+
label: "Axial Hero",
|
|
176
|
+
layout: defaultSliceLayout
|
|
177
|
+
},
|
|
178
|
+
"sag-left": {
|
|
179
|
+
label: "Sag Left Split",
|
|
180
|
+
layout: splitSliceLayout
|
|
181
|
+
},
|
|
182
|
+
"tri-h": {
|
|
183
|
+
label: "Tri Horizontal",
|
|
184
|
+
layout: triSliceLayout
|
|
185
|
+
},
|
|
186
|
+
"tri-v": {
|
|
187
|
+
label: "Tri Stacked",
|
|
188
|
+
layout: stackedSliceLayout
|
|
189
|
+
},
|
|
190
|
+
"quad-render": {
|
|
191
|
+
label: "Quad Render",
|
|
192
|
+
layout: quadSliceLayout
|
|
193
|
+
},
|
|
194
|
+
"render-hero": {
|
|
195
|
+
label: "Render Hero",
|
|
196
|
+
layout: heroRenderSliceLayout
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
var defaultViewerOptions = {
|
|
200
|
+
crosshairGap: 5
|
|
201
|
+
};
|
|
202
|
+
var defaultMouseConfig = {
|
|
203
|
+
leftButton: {
|
|
204
|
+
primary: DRAG_MODE.crosshair
|
|
205
|
+
},
|
|
206
|
+
rightButton: DRAG_MODE.pan,
|
|
207
|
+
centerButton: DRAG_MODE.slicer3D
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
class NvSceneController {
|
|
211
|
+
containerElement = null;
|
|
212
|
+
viewers = [];
|
|
213
|
+
currentLayout = "1x1";
|
|
214
|
+
slots;
|
|
215
|
+
layouts;
|
|
216
|
+
onViewerCreated;
|
|
217
|
+
listeners = new Set;
|
|
218
|
+
snapshotCache = null;
|
|
219
|
+
nextId = 0;
|
|
220
|
+
viewersById = new Map;
|
|
221
|
+
broadcasting = false;
|
|
222
|
+
broadcastOptions = { "2d": true, "3d": true };
|
|
223
|
+
viewerSliceLayouts = new Map;
|
|
224
|
+
viewerDefaults;
|
|
225
|
+
eventListeners = new Map;
|
|
226
|
+
loadingCounts = new Map;
|
|
227
|
+
viewerErrors = new Map;
|
|
228
|
+
constructor(layouts = defaultLayouts, viewerDefaults = {}) {
|
|
229
|
+
this.layouts = layouts;
|
|
230
|
+
this.slots = this.layouts[this.currentLayout]?.slots ?? 1;
|
|
231
|
+
this.viewerDefaults = viewerDefaults;
|
|
232
|
+
}
|
|
233
|
+
on(event, cb) {
|
|
234
|
+
if (!this.eventListeners.has(event)) {
|
|
235
|
+
this.eventListeners.set(event, new Set);
|
|
236
|
+
}
|
|
237
|
+
this.eventListeners.get(event).add(cb);
|
|
238
|
+
return () => this.off(event, cb);
|
|
239
|
+
}
|
|
240
|
+
off(event, cb) {
|
|
241
|
+
this.eventListeners.get(event)?.delete(cb);
|
|
242
|
+
}
|
|
243
|
+
emit(event, ...args) {
|
|
244
|
+
const listeners = this.eventListeners.get(event);
|
|
245
|
+
if (!listeners)
|
|
246
|
+
return;
|
|
247
|
+
for (const cb of listeners) {
|
|
248
|
+
cb(...args);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
subscribe = (listener) => {
|
|
252
|
+
this.listeners.add(listener);
|
|
253
|
+
return () => this.listeners.delete(listener);
|
|
254
|
+
};
|
|
255
|
+
getSnapshot = () => {
|
|
256
|
+
if (!this.snapshotCache) {
|
|
257
|
+
const viewerStates = this.viewers.map((v) => ({
|
|
258
|
+
id: v.id,
|
|
259
|
+
loading: this.loadingCounts.get(v.id) ?? 0,
|
|
260
|
+
errors: this.viewerErrors.get(v.id) ?? []
|
|
261
|
+
}));
|
|
262
|
+
this.snapshotCache = {
|
|
263
|
+
currentLayout: this.currentLayout,
|
|
264
|
+
viewerCount: this.viewers.length,
|
|
265
|
+
slots: this.slots,
|
|
266
|
+
isBroadcasting: this.broadcasting,
|
|
267
|
+
isLoading: viewerStates.some((s) => s.loading > 0),
|
|
268
|
+
viewerStates
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
return this.snapshotCache;
|
|
272
|
+
};
|
|
273
|
+
notify() {
|
|
274
|
+
this.snapshotCache = null;
|
|
275
|
+
this.listeners.forEach((listener) => listener());
|
|
276
|
+
}
|
|
277
|
+
setContainerElement(element) {
|
|
278
|
+
this.containerElement = element;
|
|
279
|
+
if (element) {
|
|
280
|
+
this.updateLayout();
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
setLayout(layoutName) {
|
|
284
|
+
const layoutConfig = this.layouts[layoutName];
|
|
285
|
+
if (!layoutConfig)
|
|
286
|
+
return;
|
|
287
|
+
this.currentLayout = layoutName;
|
|
288
|
+
this.slots = layoutConfig.slots;
|
|
289
|
+
while (this.viewers.length > this.slots) {
|
|
290
|
+
this.removeViewer(this.viewers.length - 1, false);
|
|
291
|
+
}
|
|
292
|
+
if (this.viewers.length === 0 && this.containerElement) {
|
|
293
|
+
this.addViewer();
|
|
294
|
+
}
|
|
295
|
+
this.updateLayout();
|
|
296
|
+
this.notify();
|
|
297
|
+
}
|
|
298
|
+
updateLayout() {
|
|
299
|
+
if (!this.containerElement)
|
|
300
|
+
return;
|
|
301
|
+
const layoutConfig = this.layouts[this.currentLayout];
|
|
302
|
+
if (!layoutConfig)
|
|
303
|
+
return;
|
|
304
|
+
this.viewers.forEach((viewer, index) => {
|
|
305
|
+
const position = layoutConfig.layoutFunction(this.containerElement, index, this.viewers.length);
|
|
306
|
+
Object.assign(viewer.containerDiv.style, {
|
|
307
|
+
position: "absolute",
|
|
308
|
+
top: position.top,
|
|
309
|
+
left: position.left,
|
|
310
|
+
width: position.width,
|
|
311
|
+
height: position.height
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
canAddViewer() {
|
|
316
|
+
return this.viewers.length < this.slots;
|
|
317
|
+
}
|
|
318
|
+
getNiivue(index) {
|
|
319
|
+
return this.viewers[index]?.niivue;
|
|
320
|
+
}
|
|
321
|
+
getAllNiivue() {
|
|
322
|
+
return this.viewers.map((viewer) => viewer.niivue);
|
|
323
|
+
}
|
|
324
|
+
forEachNiivue(callback) {
|
|
325
|
+
this.viewers.forEach((viewer, i) => callback(viewer.niivue, i));
|
|
326
|
+
}
|
|
327
|
+
setBroadcasting(enabled, options) {
|
|
328
|
+
this.broadcasting = enabled;
|
|
329
|
+
if (options) {
|
|
330
|
+
this.broadcastOptions = { ...this.broadcastOptions, ...options };
|
|
331
|
+
}
|
|
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
|
+
}
|
|
342
|
+
this.notify();
|
|
343
|
+
}
|
|
344
|
+
isBroadcasting() {
|
|
345
|
+
return this.broadcasting;
|
|
346
|
+
}
|
|
347
|
+
setViewerSliceLayout(index, layout) {
|
|
348
|
+
const viewer = this.viewers[index];
|
|
349
|
+
if (!viewer)
|
|
350
|
+
return;
|
|
351
|
+
this.viewerSliceLayouts.set(viewer.id, layout);
|
|
352
|
+
this.applySliceLayout(viewer.niivue, layout);
|
|
353
|
+
this.notify();
|
|
354
|
+
}
|
|
355
|
+
getViewerSliceLayout(index) {
|
|
356
|
+
const viewer = this.viewers[index];
|
|
357
|
+
if (!viewer)
|
|
358
|
+
return null;
|
|
359
|
+
return this.viewerSliceLayouts.get(viewer.id) ?? null;
|
|
360
|
+
}
|
|
361
|
+
applySliceLayout(nv, layout) {
|
|
362
|
+
if (layout) {
|
|
363
|
+
nv.setCustomLayout(layout);
|
|
364
|
+
} else {
|
|
365
|
+
nv.clearCustomLayout();
|
|
366
|
+
nv.setSliceType(SLICE_TYPE.AXIAL);
|
|
367
|
+
nv.opts.multiplanarShowRender = SHOW_RENDER.NEVER;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
getNiivueById(id) {
|
|
371
|
+
return this.viewersById.get(id)?.niivue;
|
|
372
|
+
}
|
|
373
|
+
getViewerById(id) {
|
|
374
|
+
return this.viewersById.get(id);
|
|
375
|
+
}
|
|
376
|
+
async loadVolume(index, opts) {
|
|
377
|
+
const viewer = this.viewers[index];
|
|
378
|
+
if (!viewer)
|
|
379
|
+
throw new Error(`No viewer at index ${index}`);
|
|
380
|
+
this.incrementLoading(viewer.id);
|
|
381
|
+
try {
|
|
382
|
+
const image = await viewer.niivue.addVolumeFromUrl(opts);
|
|
383
|
+
this.emit("volumeAdded", index, opts, image);
|
|
384
|
+
return image;
|
|
385
|
+
} catch (err) {
|
|
386
|
+
this.addError(viewer.id, err);
|
|
387
|
+
this.emit("error", index, err);
|
|
388
|
+
throw err;
|
|
389
|
+
} finally {
|
|
390
|
+
this.decrementLoading(viewer.id);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
async loadVolumes(index, opts) {
|
|
394
|
+
const results = [];
|
|
395
|
+
for (const o of opts) {
|
|
396
|
+
results.push(await this.loadVolume(index, o));
|
|
397
|
+
}
|
|
398
|
+
return results;
|
|
399
|
+
}
|
|
400
|
+
removeVolume(index, url) {
|
|
401
|
+
const viewer = this.viewers[index];
|
|
402
|
+
if (!viewer)
|
|
403
|
+
return;
|
|
404
|
+
const nv = viewer.niivue;
|
|
405
|
+
const vol = nv.volumes.find((v) => v.url === url || v.name === url);
|
|
406
|
+
if (vol) {
|
|
407
|
+
nv.removeVolume(vol);
|
|
408
|
+
this.emit("volumeRemoved", index, url);
|
|
409
|
+
this.notify();
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
incrementLoading(id) {
|
|
413
|
+
this.loadingCounts.set(id, (this.loadingCounts.get(id) ?? 0) + 1);
|
|
414
|
+
this.notify();
|
|
415
|
+
}
|
|
416
|
+
decrementLoading(id) {
|
|
417
|
+
const count = (this.loadingCounts.get(id) ?? 1) - 1;
|
|
418
|
+
this.loadingCounts.set(id, Math.max(0, count));
|
|
419
|
+
this.notify();
|
|
420
|
+
}
|
|
421
|
+
addError(id, error) {
|
|
422
|
+
const errors = this.viewerErrors.get(id) ?? [];
|
|
423
|
+
errors.push(error);
|
|
424
|
+
this.viewerErrors.set(id, errors);
|
|
425
|
+
}
|
|
426
|
+
addViewer(options) {
|
|
427
|
+
if (!this.containerElement) {
|
|
428
|
+
throw new Error("Container element not set");
|
|
429
|
+
}
|
|
430
|
+
if (!this.canAddViewer()) {
|
|
431
|
+
throw new Error(`Cannot add viewer: slot limit of ${this.slots} reached`);
|
|
432
|
+
}
|
|
433
|
+
const containerDiv = document.createElement("div");
|
|
434
|
+
containerDiv.className = "niivue-canvas-container";
|
|
435
|
+
const canvas = document.createElement("canvas");
|
|
436
|
+
canvas.className = "niivue-canvas";
|
|
437
|
+
containerDiv.appendChild(canvas);
|
|
438
|
+
this.containerElement.appendChild(containerDiv);
|
|
439
|
+
const mergedOptions = {
|
|
440
|
+
...defaultViewerOptions,
|
|
441
|
+
...this.viewerDefaults,
|
|
442
|
+
...options
|
|
443
|
+
};
|
|
444
|
+
const niivue = new Niivue(mergedOptions);
|
|
445
|
+
niivue.setMouseEventConfig(defaultMouseConfig);
|
|
446
|
+
niivue.attachToCanvas(canvas);
|
|
447
|
+
const id = `nv-${this.nextId++}`;
|
|
448
|
+
this.viewerSliceLayouts.set(id, null);
|
|
449
|
+
this.loadingCounts.set(id, 0);
|
|
450
|
+
this.viewerErrors.set(id, []);
|
|
451
|
+
const viewer = {
|
|
452
|
+
id,
|
|
453
|
+
niivue,
|
|
454
|
+
canvasElement: canvas,
|
|
455
|
+
containerDiv
|
|
456
|
+
};
|
|
457
|
+
this.viewers.push(viewer);
|
|
458
|
+
this.viewersById.set(id, viewer);
|
|
459
|
+
this.updateLayout();
|
|
460
|
+
this.applySliceLayout(niivue, null);
|
|
461
|
+
const index = this.viewers.length - 1;
|
|
462
|
+
niivue.onLocationChange = (data) => {
|
|
463
|
+
this.emit("locationChange", index, data);
|
|
464
|
+
};
|
|
465
|
+
niivue.onImageLoaded = (vol) => {
|
|
466
|
+
this.emit("imageLoaded", index, vol);
|
|
467
|
+
};
|
|
468
|
+
if (this.broadcasting) {
|
|
469
|
+
this.setBroadcasting(true);
|
|
470
|
+
}
|
|
471
|
+
this.onViewerCreated?.(niivue, index);
|
|
472
|
+
this.emit("viewerCreated", niivue, index);
|
|
473
|
+
this.notify();
|
|
474
|
+
return viewer;
|
|
475
|
+
}
|
|
476
|
+
removeViewer(index, shouldNotify = true) {
|
|
477
|
+
if (index < 0 || index >= this.viewers.length)
|
|
478
|
+
return;
|
|
479
|
+
const viewer = this.viewers[index];
|
|
480
|
+
if (!viewer)
|
|
481
|
+
return;
|
|
482
|
+
this.viewersById.delete(viewer.id);
|
|
483
|
+
this.viewerSliceLayouts.delete(viewer.id);
|
|
484
|
+
this.loadingCounts.delete(viewer.id);
|
|
485
|
+
this.viewerErrors.delete(viewer.id);
|
|
486
|
+
this.disposeViewer(viewer);
|
|
487
|
+
viewer.containerDiv.remove();
|
|
488
|
+
this.viewers.splice(index, 1);
|
|
489
|
+
this.updateLayout();
|
|
490
|
+
if (this.broadcasting && this.viewers.length > 0) {
|
|
491
|
+
this.setBroadcasting(true);
|
|
492
|
+
}
|
|
493
|
+
this.emit("viewerRemoved", index);
|
|
494
|
+
if (shouldNotify) {
|
|
495
|
+
this.notify();
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
disposeViewer(viewer) {
|
|
499
|
+
const nv = viewer.niivue;
|
|
500
|
+
let gl = nv._gl;
|
|
501
|
+
if (gl) {
|
|
502
|
+
const ext = gl.getExtension("WEBGL_lose_context");
|
|
503
|
+
if (ext) {
|
|
504
|
+
ext.loseContext();
|
|
505
|
+
}
|
|
506
|
+
gl = null;
|
|
507
|
+
}
|
|
508
|
+
viewer.canvasElement.width = 0;
|
|
509
|
+
viewer.canvasElement.height = 0;
|
|
510
|
+
}
|
|
511
|
+
clearViewers() {
|
|
512
|
+
this.broadcasting = false;
|
|
513
|
+
this.viewers.forEach((viewer) => {
|
|
514
|
+
this.disposeViewer(viewer);
|
|
515
|
+
viewer.containerDiv.remove();
|
|
516
|
+
});
|
|
517
|
+
this.viewers = [];
|
|
518
|
+
this.viewersById.clear();
|
|
519
|
+
this.loadingCounts.clear();
|
|
520
|
+
this.viewerErrors.clear();
|
|
521
|
+
this.notify();
|
|
522
|
+
}
|
|
523
|
+
reset() {
|
|
524
|
+
this.clearViewers();
|
|
525
|
+
this.currentLayout = "1x1";
|
|
526
|
+
this.slots = this.layouts[this.currentLayout]?.slots ?? 1;
|
|
527
|
+
this.notify();
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// src/nvviewer.tsx
|
|
532
|
+
import { jsxDEV as jsxDEV2 } from "react/jsx-dev-runtime";
|
|
533
|
+
var NvViewer = ({
|
|
534
|
+
volumes,
|
|
535
|
+
options,
|
|
536
|
+
sliceType = SLICE_TYPE2.AXIAL,
|
|
537
|
+
className,
|
|
538
|
+
style,
|
|
539
|
+
onLocationChange,
|
|
540
|
+
onImageLoaded,
|
|
541
|
+
onError
|
|
542
|
+
}) => {
|
|
543
|
+
const containerRef = useRef2(null);
|
|
544
|
+
const nvRef = useRef2(null);
|
|
545
|
+
const loadedUrlsRef = useRef2(new Set);
|
|
546
|
+
const onLocationChangeRef = useRef2(onLocationChange);
|
|
547
|
+
onLocationChangeRef.current = onLocationChange;
|
|
548
|
+
const onImageLoadedRef = useRef2(onImageLoaded);
|
|
549
|
+
onImageLoadedRef.current = onImageLoaded;
|
|
550
|
+
const onErrorRef = useRef2(onError);
|
|
551
|
+
onErrorRef.current = onError;
|
|
552
|
+
useEffect2(() => {
|
|
553
|
+
const container = containerRef.current;
|
|
554
|
+
if (!container)
|
|
555
|
+
return;
|
|
556
|
+
const canvas = document.createElement("canvas");
|
|
557
|
+
canvas.className = "niivue-canvas";
|
|
558
|
+
canvas.style.position = "absolute";
|
|
559
|
+
canvas.style.top = "0";
|
|
560
|
+
canvas.style.left = "0";
|
|
561
|
+
canvas.style.width = "100%";
|
|
562
|
+
canvas.style.height = "100%";
|
|
563
|
+
container.appendChild(canvas);
|
|
564
|
+
const mergedOptions = {
|
|
565
|
+
...defaultViewerOptions,
|
|
566
|
+
...options
|
|
567
|
+
};
|
|
568
|
+
const nv = new Niivue2(mergedOptions);
|
|
569
|
+
nv.setMouseEventConfig(defaultMouseConfig);
|
|
570
|
+
nv.onLocationChange = (data) => {
|
|
571
|
+
onLocationChangeRef.current?.(data);
|
|
572
|
+
};
|
|
573
|
+
nv.onImageLoaded = (vol) => {
|
|
574
|
+
onImageLoadedRef.current?.(vol);
|
|
575
|
+
};
|
|
576
|
+
nv.attachToCanvas(canvas);
|
|
577
|
+
nv.setSliceType(sliceType);
|
|
578
|
+
nvRef.current = nv;
|
|
579
|
+
const ro = new ResizeObserver(() => {
|
|
580
|
+
nv.resizeListener();
|
|
581
|
+
});
|
|
582
|
+
ro.observe(container);
|
|
583
|
+
return () => {
|
|
584
|
+
ro.disconnect();
|
|
585
|
+
const gl = nv._gl;
|
|
586
|
+
if (gl) {
|
|
587
|
+
const ext = gl.getExtension("WEBGL_lose_context");
|
|
588
|
+
if (ext)
|
|
589
|
+
ext.loseContext();
|
|
590
|
+
}
|
|
591
|
+
canvas.width = 0;
|
|
592
|
+
canvas.height = 0;
|
|
593
|
+
canvas.remove();
|
|
594
|
+
nvRef.current = null;
|
|
595
|
+
loadedUrlsRef.current.clear();
|
|
596
|
+
};
|
|
597
|
+
}, []);
|
|
598
|
+
useEffect2(() => {
|
|
599
|
+
nvRef.current?.setSliceType(sliceType);
|
|
600
|
+
}, [sliceType]);
|
|
601
|
+
useEffect2(() => {
|
|
602
|
+
const nv = nvRef.current;
|
|
603
|
+
if (!nv)
|
|
604
|
+
return;
|
|
605
|
+
const desiredUrls = new Set((volumes ?? []).map((v) => v.url));
|
|
606
|
+
const currentUrls = loadedUrlsRef.current;
|
|
607
|
+
for (const url of currentUrls) {
|
|
608
|
+
if (!desiredUrls.has(url)) {
|
|
609
|
+
const vol = nv.volumes.find((v) => v.url === url || v.name === url);
|
|
610
|
+
if (vol)
|
|
611
|
+
nv.removeVolume(vol);
|
|
612
|
+
currentUrls.delete(url);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
for (const opts of volumes ?? []) {
|
|
616
|
+
if (!currentUrls.has(opts.url)) {
|
|
617
|
+
currentUrls.add(opts.url);
|
|
618
|
+
nv.addVolumeFromUrl(opts).catch((err) => {
|
|
619
|
+
currentUrls.delete(opts.url);
|
|
620
|
+
onErrorRef.current?.(err);
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}, [volumes]);
|
|
625
|
+
return /* @__PURE__ */ jsxDEV2("div", {
|
|
626
|
+
ref: containerRef,
|
|
627
|
+
className,
|
|
628
|
+
style: { position: "relative", ...style }
|
|
629
|
+
}, undefined, false, undefined, this);
|
|
630
|
+
};
|
|
631
|
+
// src/hooks.ts
|
|
632
|
+
import { useEffect as useEffect3, useMemo, useSyncExternalStore, useRef as useRef3 } from "react";
|
|
633
|
+
function useScene(controller, layouts, viewerDefaults) {
|
|
634
|
+
const scene = useMemo(() => controller ?? new NvSceneController(layouts, viewerDefaults), [controller]);
|
|
635
|
+
const snapshot = useSyncExternalStore(scene.subscribe, scene.getSnapshot, scene.getSnapshot);
|
|
636
|
+
return { scene, snapshot };
|
|
637
|
+
}
|
|
638
|
+
function useNiivue(scene, index) {
|
|
639
|
+
const snapshot = useSyncExternalStore(scene.subscribe, scene.getSnapshot, scene.getSnapshot);
|
|
640
|
+
return index < snapshot.viewerCount ? scene.getNiivue(index) : undefined;
|
|
641
|
+
}
|
|
642
|
+
function useSceneEvent(scene, event, callback) {
|
|
643
|
+
const callbackRef = useRef3(callback);
|
|
644
|
+
callbackRef.current = callback;
|
|
645
|
+
useEffect3(() => {
|
|
646
|
+
const handler = (...args) => {
|
|
647
|
+
callbackRef.current(...args);
|
|
648
|
+
};
|
|
649
|
+
return scene.on(event, handler);
|
|
650
|
+
}, [scene, event]);
|
|
651
|
+
}
|
|
652
|
+
// src/context.tsx
|
|
653
|
+
import { createContext, useContext } from "react";
|
|
654
|
+
import { jsxDEV as jsxDEV3 } from "react/jsx-dev-runtime";
|
|
655
|
+
var NvSceneContext = createContext(null);
|
|
656
|
+
function NvSceneProvider({
|
|
657
|
+
scene,
|
|
658
|
+
children
|
|
659
|
+
}) {
|
|
660
|
+
return /* @__PURE__ */ jsxDEV3(NvSceneContext.Provider, {
|
|
661
|
+
value: scene,
|
|
662
|
+
children
|
|
663
|
+
}, undefined, false, undefined, this);
|
|
664
|
+
}
|
|
665
|
+
function useSceneContext() {
|
|
666
|
+
const scene = useContext(NvSceneContext);
|
|
667
|
+
if (!scene) {
|
|
668
|
+
throw new Error("useSceneContext must be used within an NvSceneProvider");
|
|
669
|
+
}
|
|
670
|
+
return scene;
|
|
671
|
+
}
|
|
672
|
+
export {
|
|
673
|
+
useSceneEvent,
|
|
674
|
+
useSceneContext,
|
|
675
|
+
useScene,
|
|
676
|
+
useNiivue,
|
|
677
|
+
triSliceLayout,
|
|
678
|
+
stackedSliceLayout,
|
|
679
|
+
splitSliceLayout,
|
|
680
|
+
quadSliceLayout,
|
|
681
|
+
heroRenderSliceLayout,
|
|
682
|
+
defaultViewerOptions,
|
|
683
|
+
defaultSliceLayouts,
|
|
684
|
+
defaultSliceLayout,
|
|
685
|
+
defaultMouseConfig,
|
|
686
|
+
defaultLayouts,
|
|
687
|
+
SLICE_TYPE,
|
|
688
|
+
NvViewer,
|
|
689
|
+
NvSceneProvider,
|
|
690
|
+
NvSceneController,
|
|
691
|
+
NvScene
|
|
692
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { NvSceneController } from "./nvscene-controller";
|
|
3
|
+
export declare function NvSceneProvider({ scene, children, }: {
|
|
4
|
+
scene: NvSceneController;
|
|
5
|
+
children: ReactNode;
|
|
6
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
7
|
+
export declare function useSceneContext(): NvSceneController;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { NVConfigOptions } from "@niivue/niivue";
|
|
2
|
+
import type { LayoutConfig } from "./layouts";
|
|
3
|
+
import { NvSceneController } from "./nvscene-controller";
|
|
4
|
+
import type { NvSceneEventMap } from "./types";
|
|
5
|
+
export declare function useScene(controller?: NvSceneController, layouts?: Record<string, LayoutConfig>, viewerDefaults?: Partial<NVConfigOptions>): {
|
|
6
|
+
scene: NvSceneController;
|
|
7
|
+
snapshot: import("./nvscene-controller").NvSceneControllerSnapshot;
|
|
8
|
+
};
|
|
9
|
+
export declare function useNiivue(scene: NvSceneController, index: number): import("@niivue/niivue").Niivue | undefined;
|
|
10
|
+
export declare function useSceneEvent<E extends keyof NvSceneEventMap>(scene: NvSceneController, event: E, callback: NvSceneEventMap[E]): void;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface CanvasPosition {
|
|
2
|
+
top: string;
|
|
3
|
+
left: string;
|
|
4
|
+
width: string;
|
|
5
|
+
height: string;
|
|
6
|
+
}
|
|
7
|
+
export type LayoutFunction = (containerElement: HTMLElement, index: number, total: number) => CanvasPosition;
|
|
8
|
+
export interface LayoutConfig {
|
|
9
|
+
slots: number;
|
|
10
|
+
label: string;
|
|
11
|
+
layoutFunction: LayoutFunction;
|
|
12
|
+
}
|
|
13
|
+
export declare const layout1x1: LayoutFunction;
|
|
14
|
+
export declare const layout2x2: LayoutFunction;
|
|
15
|
+
export declare const layout1x2: LayoutFunction;
|
|
16
|
+
export declare const layout2x1: LayoutFunction;
|
|
17
|
+
export declare const layout1x3: LayoutFunction;
|
|
18
|
+
export declare const layout3x1: LayoutFunction;
|
|
19
|
+
export declare const layout3x3: LayoutFunction;
|
|
20
|
+
export declare const defaultLayouts: Record<string, LayoutConfig>;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { NvScene } from "./nvscene";
|
|
2
|
+
export { NvViewer } from "./nvviewer";
|
|
3
|
+
export type { NvViewerProps } from "./nvviewer";
|
|
4
|
+
export { NvSceneController, SLICE_TYPE, defaultSliceLayout, splitSliceLayout, triSliceLayout, stackedSliceLayout, quadSliceLayout, heroRenderSliceLayout, defaultSliceLayouts, defaultViewerOptions, defaultMouseConfig, } from "./nvscene-controller";
|
|
5
|
+
export type { NvSceneControllerSnapshot, ViewerSlot, SliceLayoutTile, SliceLayoutConfig, BroadcastOptions, NiivueCallback, } from "./nvscene-controller";
|
|
6
|
+
export { defaultLayouts } from "./layouts";
|
|
7
|
+
export { useScene, useNiivue, useSceneEvent } from "./hooks";
|
|
8
|
+
export { NvSceneProvider, useSceneContext } from "./context";
|
|
9
|
+
export type { NvSceneEventMap, ViewerState, NVImage, ImageFromUrlOptions, NVConfigOptions, } from "./types";
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
import { NvSceneController } from "./nvscene-controller";
|
|
3
|
+
interface NiivueControllerProps {
|
|
4
|
+
scene: NvSceneController;
|
|
5
|
+
className?: string;
|
|
6
|
+
style?: CSSProperties;
|
|
7
|
+
initialLayout?: string;
|
|
8
|
+
}
|
|
9
|
+
export declare const NvScene: ({ scene, className, style, initialLayout, }: NiivueControllerProps) => import("react/jsx-runtime").JSX.Element;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { DRAG_MODE, Niivue, SLICE_TYPE, type NVConfigOptions, type NVImage } from "@niivue/niivue";
|
|
2
|
+
import type { LayoutConfig } from "./layouts";
|
|
3
|
+
import type { NvSceneEventMap, ViewerState, ImageFromUrlOptions } from "./types";
|
|
4
|
+
export { SLICE_TYPE };
|
|
5
|
+
export type NiivueCallback = (nv: Niivue, index: number) => void;
|
|
6
|
+
export interface ViewerSlot {
|
|
7
|
+
id: string;
|
|
8
|
+
niivue: Niivue;
|
|
9
|
+
canvasElement: HTMLCanvasElement;
|
|
10
|
+
containerDiv: HTMLDivElement;
|
|
11
|
+
}
|
|
12
|
+
export interface NvSceneControllerSnapshot {
|
|
13
|
+
currentLayout: string;
|
|
14
|
+
viewerCount: number;
|
|
15
|
+
slots: number;
|
|
16
|
+
isBroadcasting: boolean;
|
|
17
|
+
isLoading: boolean;
|
|
18
|
+
viewerStates: ViewerState[];
|
|
19
|
+
}
|
|
20
|
+
export interface BroadcastOptions {
|
|
21
|
+
"2d": boolean;
|
|
22
|
+
"3d": boolean;
|
|
23
|
+
}
|
|
24
|
+
export interface SliceLayoutTile {
|
|
25
|
+
sliceType: number;
|
|
26
|
+
position: [number, number, number, number];
|
|
27
|
+
}
|
|
28
|
+
export interface SliceLayoutConfig {
|
|
29
|
+
label: string;
|
|
30
|
+
layout: SliceLayoutTile[];
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Default slice layout: Axial as hero (80% height), coronal and sagittal below (20% height each, side by side)
|
|
34
|
+
*/
|
|
35
|
+
export declare const defaultSliceLayout: SliceLayoutTile[];
|
|
36
|
+
/**
|
|
37
|
+
* Split view: sagittal left, coronal/axial stacked on the right.
|
|
38
|
+
*/
|
|
39
|
+
export declare const splitSliceLayout: SliceLayoutTile[];
|
|
40
|
+
/**
|
|
41
|
+
* Tri-fan: three equal horizontal panels.
|
|
42
|
+
*/
|
|
43
|
+
export declare const triSliceLayout: SliceLayoutTile[];
|
|
44
|
+
/**
|
|
45
|
+
* Stacked: three equal vertical stacks.
|
|
46
|
+
*/
|
|
47
|
+
export declare const stackedSliceLayout: SliceLayoutTile[];
|
|
48
|
+
/**
|
|
49
|
+
* Quad: axial/coronal/sagittal with a render tile.
|
|
50
|
+
*/
|
|
51
|
+
export declare const quadSliceLayout: SliceLayoutTile[];
|
|
52
|
+
/**
|
|
53
|
+
* Hero render with three small orthogonal slices below.
|
|
54
|
+
*/
|
|
55
|
+
export declare const heroRenderSliceLayout: SliceLayoutTile[];
|
|
56
|
+
export declare const defaultSliceLayouts: Record<string, SliceLayoutConfig>;
|
|
57
|
+
type Listener = () => void;
|
|
58
|
+
export declare const defaultViewerOptions: Partial<NVConfigOptions>;
|
|
59
|
+
export declare const defaultMouseConfig: {
|
|
60
|
+
leftButton: {
|
|
61
|
+
primary: DRAG_MODE;
|
|
62
|
+
};
|
|
63
|
+
rightButton: DRAG_MODE;
|
|
64
|
+
centerButton: DRAG_MODE;
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* An NvSceneController is a declarative representation of what we want to render with Niivue.
|
|
68
|
+
*
|
|
69
|
+
* A scene can contain multiple Niivue instances. Each Niivue instance has its own
|
|
70
|
+
* canvas element for the WebGL2 context. All canvas elements are wrapped in container
|
|
71
|
+
* divs that are children of the main scene container element.
|
|
72
|
+
*
|
|
73
|
+
* Canvas elements are added/removed on-demand based on the scene definition.
|
|
74
|
+
*
|
|
75
|
+
* The scene layout controls the position of each canvas via layout functions that
|
|
76
|
+
* return absolute positioning styles. The scene container is responsive and maintains
|
|
77
|
+
* the requested layout proportionally.
|
|
78
|
+
*/
|
|
79
|
+
export declare class NvSceneController {
|
|
80
|
+
containerElement: HTMLElement | null;
|
|
81
|
+
viewers: ViewerSlot[];
|
|
82
|
+
currentLayout: string;
|
|
83
|
+
slots: number;
|
|
84
|
+
layouts: Record<string, LayoutConfig>;
|
|
85
|
+
onViewerCreated?: NiivueCallback;
|
|
86
|
+
private listeners;
|
|
87
|
+
private snapshotCache;
|
|
88
|
+
private nextId;
|
|
89
|
+
private viewersById;
|
|
90
|
+
private broadcasting;
|
|
91
|
+
private broadcastOptions;
|
|
92
|
+
private viewerSliceLayouts;
|
|
93
|
+
private viewerDefaults;
|
|
94
|
+
private eventListeners;
|
|
95
|
+
private loadingCounts;
|
|
96
|
+
private viewerErrors;
|
|
97
|
+
constructor(layouts?: Record<string, LayoutConfig>, viewerDefaults?: Partial<NVConfigOptions>);
|
|
98
|
+
on<E extends keyof NvSceneEventMap>(event: E, cb: NvSceneEventMap[E]): () => void;
|
|
99
|
+
off<E extends keyof NvSceneEventMap>(event: E, cb: NvSceneEventMap[E]): void;
|
|
100
|
+
private emit;
|
|
101
|
+
subscribe: (listener: Listener) => (() => void);
|
|
102
|
+
getSnapshot: () => NvSceneControllerSnapshot;
|
|
103
|
+
private notify;
|
|
104
|
+
setContainerElement(element: HTMLElement | null): void;
|
|
105
|
+
setLayout(layoutName: string): void;
|
|
106
|
+
updateLayout(): void;
|
|
107
|
+
canAddViewer(): boolean;
|
|
108
|
+
getNiivue(index: number): Niivue | undefined;
|
|
109
|
+
getAllNiivue(): Niivue[];
|
|
110
|
+
forEachNiivue(callback: NiivueCallback): void;
|
|
111
|
+
setBroadcasting(enabled: boolean, options?: Partial<BroadcastOptions>): void;
|
|
112
|
+
isBroadcasting(): boolean;
|
|
113
|
+
setViewerSliceLayout(index: number, layout: SliceLayoutTile[] | null): void;
|
|
114
|
+
getViewerSliceLayout(index: number): SliceLayoutTile[] | null;
|
|
115
|
+
private applySliceLayout;
|
|
116
|
+
getNiivueById(id: string): Niivue | undefined;
|
|
117
|
+
getViewerById(id: string): ViewerSlot | undefined;
|
|
118
|
+
loadVolume(index: number, opts: ImageFromUrlOptions): Promise<NVImage>;
|
|
119
|
+
loadVolumes(index: number, opts: ImageFromUrlOptions[]): Promise<NVImage[]>;
|
|
120
|
+
removeVolume(index: number, url: string): void;
|
|
121
|
+
private incrementLoading;
|
|
122
|
+
private decrementLoading;
|
|
123
|
+
private addError;
|
|
124
|
+
addViewer(options?: Partial<NVConfigOptions>): ViewerSlot;
|
|
125
|
+
removeViewer(index: number, shouldNotify?: boolean): void;
|
|
126
|
+
private disposeViewer;
|
|
127
|
+
clearViewers(): void;
|
|
128
|
+
reset(): void;
|
|
129
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
import { NvSceneController } from "./nvscene-controller";
|
|
3
|
+
interface NiivueControllerProps {
|
|
4
|
+
scene: NvSceneController;
|
|
5
|
+
className?: string;
|
|
6
|
+
style?: CSSProperties;
|
|
7
|
+
initialLayout?: string;
|
|
8
|
+
}
|
|
9
|
+
export declare const NvScene: ({ scene, className, style, initialLayout, }: NiivueControllerProps) => import("react/jsx-runtime").JSX.Element;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
import { type NVConfigOptions, type NVImage } from "@niivue/niivue";
|
|
3
|
+
import type { ImageFromUrlOptions } from "./types";
|
|
4
|
+
export interface NvViewerProps {
|
|
5
|
+
volumes?: ImageFromUrlOptions[];
|
|
6
|
+
options?: Partial<NVConfigOptions>;
|
|
7
|
+
sliceType?: number;
|
|
8
|
+
className?: string;
|
|
9
|
+
style?: CSSProperties;
|
|
10
|
+
onLocationChange?: (data: unknown) => void;
|
|
11
|
+
onImageLoaded?: (volume: NVImage) => void;
|
|
12
|
+
onError?: (error: unknown) => void;
|
|
13
|
+
}
|
|
14
|
+
export declare const NvViewer: ({ volumes, options, sliceType, className, style, onLocationChange, onImageLoaded, onError, }: NvViewerProps) => import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Niivue, NVImage, NVConfigOptions } from "@niivue/niivue";
|
|
2
|
+
export type { NVImage, NVConfigOptions };
|
|
3
|
+
/** Extract the ImageFromUrlOptions type from Niivue's addVolumeFromUrl method */
|
|
4
|
+
export type ImageFromUrlOptions = Parameters<Niivue["addVolumeFromUrl"]>[0];
|
|
5
|
+
export interface NvSceneEventMap {
|
|
6
|
+
viewerCreated: (nv: Niivue, index: number) => void;
|
|
7
|
+
viewerRemoved: (index: number) => void;
|
|
8
|
+
locationChange: (viewerIndex: number, data: unknown) => void;
|
|
9
|
+
imageLoaded: (viewerIndex: number, volume: NVImage) => void;
|
|
10
|
+
error: (viewerIndex: number, error: unknown) => void;
|
|
11
|
+
volumeAdded: (viewerIndex: number, imageOptions: ImageFromUrlOptions, image: NVImage) => void;
|
|
12
|
+
volumeRemoved: (viewerIndex: number, url: string) => void;
|
|
13
|
+
}
|
|
14
|
+
export interface ViewerState {
|
|
15
|
+
id: string;
|
|
16
|
+
loading: number;
|
|
17
|
+
errors: unknown[];
|
|
18
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@niivue/nvreact",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/lib.js",
|
|
7
|
+
"module": "./dist/lib.js",
|
|
8
|
+
"types": "./dist/types/lib.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/types/lib.d.ts",
|
|
12
|
+
"import": "./dist/lib.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"dev": "bun --hot src/index.ts",
|
|
23
|
+
"build": "bun build ./src/index.html --outdir=dist --sourcemap --target=browser --minify --define:process.env.NODE_ENV='\"production\"' --env='BUN_PUBLIC_*'",
|
|
24
|
+
"start": "NODE_ENV=production bun src/index.ts",
|
|
25
|
+
"build:lib": "bun build ./src/lib.ts --outdir=dist --target=browser --format=esm --external react --external react-dom --external @niivue/niivue",
|
|
26
|
+
"build:types": "tsc -p tsconfig.build.json",
|
|
27
|
+
"build:package": "bun run build:lib && bun run build:types",
|
|
28
|
+
"pack:local": "bun run build:package && bun pm pack",
|
|
29
|
+
"example:setup": "bun run pack:local && cd example-app && rm -rf node_modules bun.lock && bun install",
|
|
30
|
+
"example:dev": "bun run example:setup && cd example-app && bun dev"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"@niivue/niivue": "0.67.1-dev.2",
|
|
35
|
+
"react": "^19",
|
|
36
|
+
"react-dom": "^19"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@niivue/niivue": "0.67.1-dev.2",
|
|
40
|
+
"@types/bun": "latest",
|
|
41
|
+
"@types/react": "^19",
|
|
42
|
+
"@types/react-dom": "^19",
|
|
43
|
+
"react": "^19",
|
|
44
|
+
"react-dom": "^19",
|
|
45
|
+
"typescript": "^5.9.3"
|
|
46
|
+
}
|
|
47
|
+
}
|