@niivue/niivue 0.67.1-dev.0 → 0.67.1-dev.1

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.
@@ -95,9 +95,11 @@ function createTypedVoxelArray(datatypeCode: number, size: number): TypedVoxelAr
95
95
  case NiiDataType.DT_UINT16:
96
96
  return new Uint16Array(size)
97
97
  case NiiDataType.DT_INT16:
98
- case NiiDataType.DT_INT32:
99
98
  return new Int16Array(size)
99
+ case NiiDataType.DT_INT32:
100
+ return new Int32Array(size)
100
101
  case NiiDataType.DT_UINT32:
102
+ return new Uint32Array(size)
101
103
  case NiiDataType.DT_FLOAT32:
102
104
  return new Float32Array(size)
103
105
  case NiiDataType.DT_FLOAT64:
@@ -249,19 +251,23 @@ export class NVZarrHelper {
249
251
  helper.updateLevelInfo()
250
252
 
251
253
  // Center on the level
252
- helper.centerX = helper.levelDims.width / 2
253
- helper.centerY = helper.levelDims.height / 2
254
- helper.centerZ = helper.levelDims.depth / 2
254
+ helper.centerX = helper.volumeDims.width / 2
255
+ helper.centerY = helper.volumeDims.height / 2
256
+ helper.centerZ = helper.volumeDims.depth / 2
255
257
 
256
258
  // Configure the host NVImage
257
259
  helper.configureHostImage()
258
260
 
259
- // Load initial chunks
260
- await helper.updateVolume()
261
+ // Don't load chunks here — loadInitialChunks() must be called after
262
+ // the onChunksUpdated callback is registered (see addVolume in Niivue).
261
263
 
262
264
  return helper
263
265
  }
264
266
 
267
+ async loadInitialChunks(): Promise<void> {
268
+ await this.updateVolume()
269
+ }
270
+
265
271
  private updateLevelInfo(): void {
266
272
  const levelInfo = this.pyramidInfo.levels[this.pyramidLevel]
267
273
  const shape = levelInfo.shape
@@ -352,12 +358,12 @@ export class NVZarrHelper {
352
358
  // Store original affine so resetVolumeAffine() works on zarr volumes
353
359
  img.originalAffine = copyAffine(hdr.affine)
354
360
 
355
- img.cal_min = NaN
356
- img.cal_max = NaN
357
- img.robust_min = NaN
358
- img.robust_max = NaN
359
- img.global_min = NaN
360
- img.global_max = NaN
361
+ img.cal_min = 0
362
+ img.cal_max = 0
363
+ img.robust_min = 0
364
+ img.robust_max = 0
365
+ img.global_min = 0
366
+ img.global_max = 0
361
367
  }
362
368
 
363
369
  /** Get unit-converted voxel scales */
@@ -505,6 +511,10 @@ export class NVZarrHelper {
505
511
  this.centerAtDragStart = { x: this.centerX, y: this.centerY, z: this.centerZ }
506
512
  }
507
513
 
514
+ endDrag(): void {
515
+ this.centerAtDragStart = null
516
+ }
517
+
508
518
  async panBy(dx: number, dy: number, dz: number = 0): Promise<void> {
509
519
  this.centerX -= dx
510
520
  this.centerY -= dy
@@ -515,12 +525,22 @@ export class NVZarrHelper {
515
525
  }
516
526
 
517
527
  async panTo(newCenterX: number, newCenterY: number, newCenterZ?: number): Promise<void> {
528
+ const prevX = this.centerX
529
+ const prevY = this.centerY
530
+ const prevZ = this.centerZ
531
+
518
532
  this.centerX = newCenterX
519
533
  this.centerY = newCenterY
520
534
  if (newCenterZ !== undefined) {
521
535
  this.centerZ = newCenterZ
522
536
  }
523
537
  this.clampCenter()
538
+
539
+ // Skip if clamped position is same as before (prevents infinite loop at boundaries)
540
+ if (Math.abs(this.centerX - prevX) < 0.001 && Math.abs(this.centerY - prevY) < 0.001 && Math.abs(this.centerZ - prevZ) < 0.001) {
541
+ return
542
+ }
543
+
524
544
  this.updateAffine()
525
545
  await this.updateVolume()
526
546
  }
@@ -571,6 +591,77 @@ export class NVZarrHelper {
571
591
  return { ...this.volumeDims }
572
592
  }
573
593
 
594
+ /**
595
+ * Convert physical (mm) coordinates back to real zarr level pixel coordinates.
596
+ * Inverts the affine: levelPixel = (mm - OME_translation) / scale
597
+ */
598
+ mmToLevelCoords(
599
+ mmX: number,
600
+ mmY: number,
601
+ mmZ: number
602
+ ): {
603
+ width: number
604
+ height: number
605
+ depth: number
606
+ level: number
607
+ levelDims: { width: number; height: number; depth: number }
608
+ } {
609
+ const { scaleW, scaleH, scaleD } = this.getConvertedScales()
610
+ const axisNames = this.pyramidInfo.axisMapping.spatialAxisNames
611
+
612
+ // Map physical mm (x/y/z) to OME spatial dimensions (width/height/depth)
613
+ // The affine maps NIfTI cols to physical rows by axis name:
614
+ // col 0 (width) → axis[-1], col 1 (height) → axis[-2], col 2 (depth) → axis[-3]
615
+ // We need the reverse: given physical x/y/z, find which OME dim it maps to
616
+ const mmByAxis: Record<string, number> = { x: mmX, y: mmY, z: mmZ }
617
+
618
+ let levelW = 0
619
+ let levelH = 0
620
+ let levelD = 0
621
+
622
+ if (axisNames.length >= 2) {
623
+ // Width maps to last spatial axis name
624
+ const wAxisName = axisNames[axisNames.length - 1]
625
+ const hAxisName = axisNames[axisNames.length - 2]
626
+ const dAxisName = axisNames.length >= 3 ? axisNames[axisNames.length - 3] : 'z'
627
+
628
+ const mmW = mmByAxis[wAxisName] ?? mmX
629
+ const mmH = mmByAxis[hAxisName] ?? mmY
630
+ const mmD = mmByAxis[dAxisName] ?? mmZ
631
+
632
+ if (this.hasTranslations) {
633
+ const { transW, transH, transD } = this.getConvertedTranslations()
634
+ levelW = scaleW !== 0 ? (mmW - transW) / scaleW : 0
635
+ levelH = scaleH !== 0 ? (mmH - transH) / scaleH : 0
636
+ levelD = scaleD !== 0 ? (mmD - transD) / scaleD : 0
637
+ } else {
638
+ levelW = scaleW !== 0 ? mmW / scaleW : 0
639
+ levelH = scaleH !== 0 ? mmH / scaleH : 0
640
+ levelD = scaleD !== 0 ? mmD / scaleD : 0
641
+ }
642
+ } else {
643
+ // Fallback: simple diagonal
644
+ if (this.hasTranslations) {
645
+ const { transW, transH, transD } = this.getConvertedTranslations()
646
+ levelW = scaleW !== 0 ? (mmX - transW) / scaleW : 0
647
+ levelH = scaleH !== 0 ? (mmY - transH) / scaleH : 0
648
+ levelD = scaleD !== 0 ? (mmZ - transD) / scaleD : 0
649
+ } else {
650
+ levelW = scaleW !== 0 ? mmX / scaleW : 0
651
+ levelH = scaleH !== 0 ? mmY / scaleH : 0
652
+ levelD = scaleD !== 0 ? mmZ / scaleD : 0
653
+ }
654
+ }
655
+
656
+ return {
657
+ width: Math.round(levelW),
658
+ height: Math.round(levelH),
659
+ depth: Math.round(levelD),
660
+ level: this.pyramidLevel,
661
+ levelDims: { ...this.levelDims }
662
+ }
663
+ }
664
+
574
665
  private clampCenter(): void {
575
666
  const halfW = this.volumeDims.width / 2
576
667
  const halfH = this.volumeDims.height / 2
@@ -1,152 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta http-equiv="X-UA-Compatible" content="IE=edge" />
6
- <meta name="viewport" content="width=device-width,initial-scale=1.0" />
7
- <title>NiiVue</title>
8
- <link rel="stylesheet" href="niivue.css" />
9
- <link rel="icon" href="data:;base64,iVBORw0KGgo=" />
10
- </head>
11
- <header>
12
- &nbsp;
13
- <label for="dragMode">Drag mode</label>
14
- <select id="dragMode">
15
- <option value="contrast">contrast</option>
16
- <option value="measurement">measurement</option>
17
- <option value="none">none</option>
18
- <option value="pan" selected>pan/zoom</option>
19
- </select>
20
- &nbsp;
21
- <label for="thickSelect">&nbsp; Thickness</label>
22
- <select id="thickSelect">
23
- <option>0.5</option>
24
- <option>1.0</option>
25
- <option>1.5</option>
26
- <option selected>2.0</option>
27
- <option>2.5</option>
28
- <option>3.0</option>
29
- </select>
30
- </header>
31
- <main id="canvas-container">
32
- <div style="display: flex; width: 100%; height: 100%">
33
- <canvas id="gl1"></canvas>
34
- </div>
35
- </main>
36
- <footer>
37
- <label id="statusBar">&nbsp; </label>
38
- </footer>
39
- </html>
40
- <script type="module" async>
41
- import { Niivue, NVImage, NVMesh, NVMeshLoaders, SHOW_RENDER, DRAG_MODE, SLICE_TYPE } from './niivue/index.ts'
42
- thickSelect.onchange = function() {
43
- nv1.setMeshThicknessOn2D(parseFloat(this.options[this.selectedIndex].text))
44
- }
45
- dragMode.onchange = function () {
46
- switch (this.value) {
47
- case "none":
48
- nv1.opts.dragMode = nv1.dragModes.none
49
- break
50
- case "contrast":
51
- nv1.opts.dragMode = nv1.dragModes.contrast
52
- break
53
- case "measurement":
54
- nv1.opts.dragMode = nv1.dragModes.measurement
55
- break
56
- case "pan":
57
- nv1.opts.dragMode = nv1.dragModes.pan
58
- break
59
- }
60
- }
61
- var volumeList1 = [
62
- { url: "../demos/images/fs/brainmask.mgz" },
63
- ]
64
- const onLocationChange = (data) => {
65
- statusBar.innerHTML = data.string
66
- }
67
- let defaults = {
68
- loglevel: 'debug',
69
- show3Dcrosshair: true,
70
- onLocationChange: onLocationChange,
71
- }
72
- var nv1 = new Niivue(defaults)
73
- await nv1.attachToCanvas(gl1)
74
- nv1.opts.multiplanarShowRender = SHOW_RENDER.ALWAYS
75
- nv1.opts.isOrientCube = true
76
- nv1.opts.yoke3Dto2DZoom = true
77
- nv1.opts.dragMode = nv1.dragModes.pan
78
- nv1.opts.crosshairGap = 6
79
- await nv1.loadVolumes(volumeList1)
80
-
81
- await nv1.loadMeshes([
82
- { url: "../demos/images/fs/rh.white", rgba255: [255, 0, 255, 255] },
83
- { url: "../demos/images/fs/rh.pial", rgba255: [0, 255, 64, 255] },
84
- ])
85
- nv1.setMeshShader(nv1.meshes[0].id, 'crosscut')
86
- nv1.setMeshShader(nv1.meshes[1].id, 'crosscut')
87
- nv1.setClipPlane([0.0, 180, 20])
88
-
89
- // Chunk-based random volume data animation
90
- function startChunkAnimation() {
91
- const vol = nv1.volumes[0]
92
- if (!vol || !vol.img) return
93
-
94
- const nx = vol.hdr.dims[1]
95
- const ny = vol.hdr.dims[2]
96
- const nz = vol.hdr.dims[3]
97
- const datatype = vol.hdr.datatypeCode
98
-
99
- const CHUNK_SIZE = 8
100
-
101
- // Pre-compute chunks (only include voxels with value > 0)
102
- const chunks = []
103
- for (let cz = 0; cz < nz; cz += CHUNK_SIZE) {
104
- for (let cy = 0; cy < ny; cy += CHUNK_SIZE) {
105
- for (let cx = 0; cx < nx; cx += CHUNK_SIZE) {
106
- const indices = []
107
- for (let z = cz; z < Math.min(cz + CHUNK_SIZE, nz); z++) {
108
- for (let y = cy; y < Math.min(cy + CHUNK_SIZE, ny); y++) {
109
- for (let x = cx; x < Math.min(cx + CHUNK_SIZE, nx); x++) {
110
- const idx = x + y * nx + z * nx * ny
111
- if (vol.img[idx] > 0) {
112
- indices.push(idx)
113
- }
114
- }
115
- }
116
- }
117
- // Only add chunk if it has non-zero voxels
118
- if (indices.length > 0) {
119
- chunks.push(indices)
120
- }
121
- }
122
- }
123
- }
124
-
125
- // Get random value generator based on data type
126
- function getRandomValue() {
127
- switch (datatype) {
128
- case 2: return Math.floor(Math.random() * 256) // UINT8
129
- case 4: return Math.floor(Math.random() * 65536) - 32768 // INT16
130
- case 512: return Math.floor(Math.random() * 65536) // UINT16
131
- case 16: return Math.random() // FLOAT32
132
- default: return Math.floor(Math.random() * 256)
133
- }
134
- }
135
-
136
- // Animation loop - run as fast as possible
137
- function animate() {
138
- for (const chunkIndices of chunks) {
139
- const value = getRandomValue()
140
- for (const idx of chunkIndices) {
141
- vol.img[idx] = value
142
- }
143
- }
144
- nv1.updateGLVolume()
145
- requestAnimationFrame(animate)
146
- }
147
- requestAnimationFrame(animate)
148
- }
149
-
150
- // Auto-execute on page load
151
- //startChunkAnimation()
152
- </script>