@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 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
+ }