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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,114 +1,114 @@
1
1
  {
2
- "name": "@niivue/niivue",
3
- "version": "0.67.1-dev.1",
4
- "description": "minimal webgl2 nifti image viewer",
5
- "types": "./build/niivue/index.d.ts",
6
- "main": "./build/niivue/index.js",
7
- "type": "module",
8
- "unpkg": "./dist/index.min.js",
9
- "module": "./build/niivue/index.js",
10
- "exports": {
11
- ".": {
12
- "import": "./build/niivue/index.js",
13
- "types": "./build/niivue/index.d.ts"
2
+ "name": "@niivue/niivue",
3
+ "version": "0.67.1-dev.2",
4
+ "description": "minimal webgl2 nifti image viewer",
5
+ "types": "./build/niivue/index.d.ts",
6
+ "main": "./build/niivue/index.js",
7
+ "type": "module",
8
+ "unpkg": "./dist/index.min.js",
9
+ "module": "./build/niivue/index.js",
10
+ "exports": {
11
+ ".": {
12
+ "import": "./build/niivue/index.js",
13
+ "types": "./build/niivue/index.d.ts"
14
+ },
15
+ "./drawing": {
16
+ "import": "./build/drawing/index.js",
17
+ "types": "./build/drawing/index.d.ts"
18
+ },
19
+ "./utils": {
20
+ "import": "./build/utils/index.js",
21
+ "types": "./build/utils/index.d.ts"
22
+ },
23
+ "./min": {
24
+ "import": "./build/index.min.js"
25
+ }
14
26
  },
15
- "./drawing": {
16
- "import": "./build/drawing/index.js",
17
- "types": "./build/drawing/index.d.ts"
27
+ "scripts": {
28
+ "dev": "vite",
29
+ "build:umd": "vite build --config vite.config.js --base=./ && vite build --config vite.config_inject.js --base=./",
30
+ "build": "tsup --config tsup.config.ts && npm run build:forTests && npm run build:min && npm run build:umd",
31
+ "build:forTests": "tsup --config tsup.config.tests.ts && npm run build:mindemos",
32
+ "build:min": "node bundle.js",
33
+ "build:mindemos": "node bundleForDemos.js",
34
+ "demo": "npm run build:forTests && rm -rf demos/dist && cp -r dist demos/dist && npx http-server demos/ --cors",
35
+ "demo-win": "npm run build && npx http-server demos/",
36
+ "test": "npm run build:forTests && npm run test:unit && jest --maxWorkers=1",
37
+ "test-win": "npm run build-win && jest",
38
+ "test-playwright": "npx playwright test --grep-invert \"niivue demo file:\"",
39
+ "test-demos": "npx playwright test playwright/e2e/test.demos.spec.ts",
40
+ "docs": "typedoc && rm -rf ../docs/docs/api && cp -r devdocs ../docs/docs/api",
41
+ "test:unit": "vitest --run --coverage",
42
+ "serve-docs": "npx http-server devdocs",
43
+ "pub": "npm run build && npm publish --access public",
44
+ "lint": "eslint . --ext .ts",
45
+ "lint:ts": "tsc --noEmit",
46
+ "lint:fix": "eslint --fix . --ext .ts",
47
+ "lint:debug": "DEBUG=eslint:cli-engine eslint . --ext .ts",
48
+ "pretest-playwright": "npm run build:forTests && node preplaywrighttest.cjs && tsc --incremental -p playwright/e2e/tsconfig.json",
49
+ "pretest-demos": "npm run build:forTests && node preplaywrighttest.cjs && tsc --incremental -p playwright/e2e/tsconfig.json"
18
50
  },
19
- "./utils": {
20
- "import": "./build/utils/index.js",
21
- "types": "./build/utils/index.d.ts"
51
+ "files": [
52
+ "src",
53
+ "build",
54
+ "dist"
55
+ ],
56
+ "repository": {
57
+ "type": "git",
58
+ "url": "git+https://github.com/niivue/niivue.git"
22
59
  },
23
- "./min": {
24
- "import": "./build/index.min.js"
60
+ "keywords": [
61
+ "niivue",
62
+ "webgl2",
63
+ "nifti",
64
+ "image",
65
+ "viewer"
66
+ ],
67
+ "author": "niivue authors",
68
+ "license": "BSD-2-Clause",
69
+ "bugs": {
70
+ "url": "https://github.com/niivue/niivue/issues"
71
+ },
72
+ "homepage": "https://github.com/niivue/niivue#readme",
73
+ "dependencies": {
74
+ "@lukeed/uuid": "^2.0.1",
75
+ "@ungap/structured-clone": "^1.2.0",
76
+ "array-equal": "^1.0.2",
77
+ "fflate": "^0.8.2",
78
+ "gl-matrix": "^3.4.3",
79
+ "nifti-reader-js": "^0.8.0",
80
+ "zarrita": "^0.5.0"
81
+ },
82
+ "devDependencies": {
83
+ "@playwright/test": "^1.45.2",
84
+ "@rollup/plugin-commonjs": "^26.0.1",
85
+ "@types/array-equal": "^1.0.2",
86
+ "@types/node": "^20.14.11",
87
+ "@types/ungap__structured-clone": "^1.2.0",
88
+ "@typescript-eslint/eslint-plugin": "^7.16.1",
89
+ "@typescript-eslint/parser": "^7.16.1",
90
+ "@vitest/coverage-v8": "^3.1.3",
91
+ "esbuild": "^0.25.12",
92
+ "eslint": "^8.57.0",
93
+ "eslint-config-prettier": "^9.1.0",
94
+ "eslint-config-standard": "^17.1.0",
95
+ "eslint-plugin-prettier": "^5.2.1",
96
+ "eslint-plugin-tsdoc": "^0.3.0",
97
+ "express": "^4.19.2",
98
+ "happy-dom": "^20.0.11",
99
+ "http-server": "^14.1.1",
100
+ "prettier": "^3.3.3",
101
+ "regenerator-runtime": "^0.14.1",
102
+ "terser": "^5.31.3",
103
+ "tsup": "^8.1.2",
104
+ "typedoc": "^0.28.5",
105
+ "typedoc-docusaurus-theme": "^1.4.0",
106
+ "typedoc-plugin-markdown": "^4.7.0",
107
+ "typescript": "^5.5.3",
108
+ "vite": "^7.2.7",
109
+ "vitest": "^3.1.3"
110
+ },
111
+ "optionalDependencies": {
112
+ "@rollup/rollup-linux-x64-gnu": "^4.18.1"
25
113
  }
26
- },
27
- "scripts": {
28
- "dev": "vite",
29
- "build:umd": "vite build --config vite.config.js --base=./ && vite build --config vite.config_inject.js --base=./",
30
- "build": "tsup --config tsup.config.ts && npm run build:forTests && npm run build:min && npm run build:umd",
31
- "build:forTests": "tsup --config tsup.config.tests.ts && npm run build:mindemos",
32
- "build:min": "node bundle.js",
33
- "build:mindemos": "node bundleForDemos.js",
34
- "demo": "npm run build:forTests && rm -rf demos/dist && cp -r dist demos/dist && npx http-server demos/ --cors",
35
- "demo-win": "npm run build && npx http-server demos/",
36
- "test": "npm run build:forTests && npm run test:unit && jest --maxWorkers=1",
37
- "test-win": "npm run build-win && jest",
38
- "test-playwright": "npx playwright test --grep-invert \"niivue demo file:\"",
39
- "test-demos": "npx playwright test playwright/e2e/test.demos.spec.ts",
40
- "docs": "typedoc && rm -rf ../docs/docs/api && cp -r devdocs ../docs/docs/api",
41
- "test:unit": "vitest --run --coverage",
42
- "serve-docs": "npx http-server devdocs",
43
- "pub": "npm run build && npm publish --access public",
44
- "lint": "eslint . --ext .ts",
45
- "lint:ts": "tsc --noEmit",
46
- "lint:fix": "eslint --fix . --ext .ts",
47
- "lint:debug": "DEBUG=eslint:cli-engine eslint . --ext .ts",
48
- "pretest-playwright": "npm run build:forTests && node preplaywrighttest.cjs && tsc --incremental -p playwright/e2e/tsconfig.json",
49
- "pretest-demos": "npm run build:forTests && node preplaywrighttest.cjs && tsc --incremental -p playwright/e2e/tsconfig.json"
50
- },
51
- "files": [
52
- "src",
53
- "build",
54
- "dist"
55
- ],
56
- "repository": {
57
- "type": "git",
58
- "url": "git+https://github.com/niivue/niivue.git"
59
- },
60
- "keywords": [
61
- "niivue",
62
- "webgl2",
63
- "nifti",
64
- "image",
65
- "viewer"
66
- ],
67
- "author": "niivue authors",
68
- "license": "BSD-2-Clause",
69
- "bugs": {
70
- "url": "https://github.com/niivue/niivue/issues"
71
- },
72
- "homepage": "https://github.com/niivue/niivue#readme",
73
- "dependencies": {
74
- "@lukeed/uuid": "^2.0.1",
75
- "@ungap/structured-clone": "^1.2.0",
76
- "array-equal": "^1.0.2",
77
- "fflate": "^0.8.2",
78
- "gl-matrix": "^3.4.3",
79
- "nifti-reader-js": "^0.8.0",
80
- "zarrita": "^0.5.0"
81
- },
82
- "devDependencies": {
83
- "@playwright/test": "^1.45.2",
84
- "@rollup/plugin-commonjs": "^26.0.1",
85
- "@types/array-equal": "^1.0.2",
86
- "@types/node": "^20.14.11",
87
- "@types/ungap__structured-clone": "^1.2.0",
88
- "@typescript-eslint/eslint-plugin": "^7.16.1",
89
- "@typescript-eslint/parser": "^7.16.1",
90
- "@vitest/coverage-v8": "^3.1.3",
91
- "esbuild": "^0.25.12",
92
- "eslint": "^8.57.0",
93
- "eslint-config-prettier": "^9.1.0",
94
- "eslint-config-standard": "^17.1.0",
95
- "eslint-plugin-prettier": "^5.2.1",
96
- "eslint-plugin-tsdoc": "^0.3.0",
97
- "express": "^4.19.2",
98
- "happy-dom": "^20.0.11",
99
- "http-server": "^14.1.1",
100
- "prettier": "^3.3.3",
101
- "regenerator-runtime": "^0.14.1",
102
- "terser": "^5.31.3",
103
- "tsup": "^8.1.2",
104
- "typedoc": "^0.28.5",
105
- "typedoc-docusaurus-theme": "^1.4.0",
106
- "typedoc-plugin-markdown": "^4.7.0",
107
- "typescript": "^5.5.3",
108
- "vite": "^7.2.7",
109
- "vitest": "^3.1.3"
110
- },
111
- "optionalDependencies": {
112
- "@rollup/rollup-linux-x64-gnu": "^4.18.1"
113
- }
114
- }
114
+ }
@@ -2508,7 +2508,8 @@ export class Niivue {
2508
2508
  zarrLevel: imageItem.zarrLevel,
2509
2509
  zarrMaxVolumeSize: imageItem.zarrMaxVolumeSize,
2510
2510
  zarrChannel: imageItem.zarrChannel,
2511
- zarrConvertUnits: imageItem.zarrConvertUnits
2511
+ zarrConvertUnits: imageItem.zarrConvertUnits,
2512
+ zarrCenterMM: imageItem.zarrCenterMM
2512
2513
  }
2513
2514
  const volume = await NVImage.loadFromUrl(imageOptions)
2514
2515
  this.document.addImageOptions(volume, imageOptions)
@@ -3165,6 +3166,22 @@ export class Niivue {
3165
3166
  // Register the callback BEFORE loading initial chunks so the first
3166
3167
  // batch of data triggers a proper GPU upload and render.
3167
3168
  if (volume.zarrHelper) {
3169
+ // Auto-center zarr overlay on background volume if no explicit position was given
3170
+ if (!volume._hasExplicitZarrCenter && this.back && this.back !== volume && this.back.hdr?.dims) {
3171
+ const bg = this.back
3172
+ const dims = bg.hdr.dims
3173
+ const affine = bg.hdr.affine
3174
+ const ci = (dims[1] - 1) / 2
3175
+ const cj = (dims[2] - 1) / 2
3176
+ const ck = (dims[3] - 1) / 2
3177
+ const bgCenter: [number, number, number] = [
3178
+ affine[0][0] * ci + affine[0][1] * cj + affine[0][2] * ck + affine[0][3],
3179
+ affine[1][0] * ci + affine[1][1] * cj + affine[1][2] * ck + affine[1][3],
3180
+ affine[2][0] * ci + affine[2][1] * cj + affine[2][2] * ck + affine[2][3]
3181
+ ]
3182
+ volume.zarrHelper.setWorldCenter(bgCenter)
3183
+ }
3184
+
3168
3185
  volume.zarrHelper.onChunksUpdated = (): void => {
3169
3186
  this.updateGLVolume()
3170
3187
  this._skipDragInDraw = true
@@ -89,6 +89,7 @@ export class NVImage {
89
89
  onOpacityChange: (img: NVImage) => void = () => {}
90
90
 
91
91
  zarrHelper: NVZarrHelper | null = null
92
+ _hasExplicitZarrCenter = false
92
93
 
93
94
  mm000?: vec3
94
95
  mm100?: vec3
@@ -865,7 +866,8 @@ export class NVImage {
865
866
  zarrLevel,
866
867
  zarrMaxVolumeSize,
867
868
  zarrChannel,
868
- zarrConvertUnits
869
+ zarrConvertUnits,
870
+ zarrCenterMM
869
871
  }: Partial<Omit<ImageFromUrlOptions, 'url'>> & { url?: string | Uint8Array | ArrayBuffer } = {}): Promise<NVImage> {
870
872
  if (url === '') {
871
873
  throw Error('url must not be empty')
@@ -924,7 +926,8 @@ export class NVImage {
924
926
  channel: zarrChannel,
925
927
  convertUnitsToMm: zarrConvertUnits,
926
928
  colormap,
927
- opacity
929
+ opacity,
930
+ zarrCenterMM
928
931
  })
929
932
  }
930
933
  // Light path: load entire array (unchanged)
@@ -1014,6 +1017,7 @@ export class NVImage {
1014
1017
  convertUnitsToMm?: boolean
1015
1018
  colormap?: string
1016
1019
  opacity?: number
1020
+ zarrCenterMM?: [number, number, number]
1017
1021
  }
1018
1022
  ): Promise<NVImage> {
1019
1023
  const nvimage = new NVImage()
@@ -1026,6 +1030,10 @@ export class NVImage {
1026
1030
  cacheSize: options.cacheSize,
1027
1031
  convertUnitsToMm: options.convertUnitsToMm
1028
1032
  })
1033
+ if (options.zarrCenterMM) {
1034
+ nvimage.zarrHelper.setWorldCenter(options.zarrCenterMM)
1035
+ nvimage._hasExplicitZarrCenter = true
1036
+ }
1029
1037
  if (options.colormap) {
1030
1038
  nvimage._colormap = options.colormap
1031
1039
  }
@@ -210,6 +210,8 @@ export type ImageFromUrlOptions = {
210
210
  zarrChannel?: number
211
211
  /** Convert OME spatial units to millimeters for NIfTI compatibility (default: true) */
212
212
  zarrConvertUnits?: boolean
213
+ /** World-space center [x, y, z] in mm where the zarr volume center should be positioned */
214
+ zarrCenterMM?: [number, number, number]
213
215
  }
214
216
 
215
217
  // TODO centralize shared options
@@ -158,6 +158,7 @@ export class NVZarrHelper {
158
158
  private voxelTranslations: { width: number; height: number; depth: number }
159
159
  private hasTranslations: boolean
160
160
  private convertUnitsToMm: boolean
161
+ private worldOffsetMM: [number, number, number] = [0, 0, 0]
161
162
 
162
163
  private centerX: number
163
164
  private centerY: number
@@ -171,6 +172,7 @@ export class NVZarrHelper {
171
172
  private currentAbortController: AbortController | null = null
172
173
  private runningMin = Infinity
173
174
  private runningMax = -Infinity
175
+ private calibrationDone = false
174
176
 
175
177
  // Debounce state for batching chunk updates
176
178
  private updateDebounceTimer: ReturnType<typeof setTimeout> | null = null
@@ -250,10 +252,10 @@ export class NVZarrHelper {
250
252
  helper.pyramidLevel = level
251
253
  helper.updateLevelInfo()
252
254
 
253
- // Center on the level
254
- helper.centerX = helper.volumeDims.width / 2
255
- helper.centerY = helper.volumeDims.height / 2
256
- helper.centerZ = helper.volumeDims.depth / 2
255
+ // Center on the level (use levelDims so initial view shows the center of the zarr)
256
+ helper.centerX = helper.levelDims.width / 2
257
+ helper.centerY = helper.levelDims.height / 2
258
+ helper.centerZ = helper.levelDims.depth / 2
257
259
 
258
260
  // Configure the host NVImage
259
261
  helper.configureHostImage()
@@ -457,6 +459,9 @@ export class NVZarrHelper {
457
459
  affine[row][col] = niftiCols[col].scale
458
460
  affine[row][3] = niftiCols[col].trans
459
461
  }
462
+ affine[0][3] += this.worldOffsetMM[0]
463
+ affine[1][3] += this.worldOffsetMM[1]
464
+ affine[2][3] += this.worldOffsetMM[2]
460
465
  hdr.affine = affine
461
466
  } else {
462
467
  // No OME translations — calculate offset based on pan position
@@ -487,13 +492,16 @@ export class NVZarrHelper {
487
492
  affine[row][col] = niftiCols[col].scale
488
493
  affine[row][3] = niftiCols[col].trans
489
494
  }
495
+ affine[0][3] += this.worldOffsetMM[0]
496
+ affine[1][3] += this.worldOffsetMM[1]
497
+ affine[2][3] += this.worldOffsetMM[2]
490
498
  hdr.affine = affine
491
499
  } else {
492
500
  // Fallback: simple diagonal affine with pan position
493
501
  hdr.affine = [
494
- [scaleW, 0, 0, volStartW * scaleW],
495
- [0, -scaleH, 0, volStartH * scaleH],
496
- [0, 0, -scaleD, volStartD * scaleD],
502
+ [scaleW, 0, 0, volStartW * scaleW + this.worldOffsetMM[0]],
503
+ [0, -scaleH, 0, volStartH * scaleH + this.worldOffsetMM[1]],
504
+ [0, 0, -scaleD, volStartD * scaleD + this.worldOffsetMM[2]],
497
505
  [0, 0, 0, 1]
498
506
  ]
499
507
  }
@@ -591,6 +599,48 @@ export class NVZarrHelper {
591
599
  return { ...this.volumeDims }
592
600
  }
593
601
 
602
+ getWorldOffset(): [number, number, number] {
603
+ return [...this.worldOffsetMM]
604
+ }
605
+
606
+ /**
607
+ * Set the world-space offset so the full level's center maps to targetMM in world space.
608
+ * Computes the native physical center of the zarr level, then sets worldOffsetMM
609
+ * so that center aligns with targetMM. Also centers the viewport on the level center.
610
+ */
611
+ setWorldCenter(targetMM: [number, number, number]): void {
612
+ const { scaleW, scaleH, scaleD } = this.getConvertedScales()
613
+ const { transW, transH, transD } = this.getConvertedTranslations()
614
+ const axisNames = this.pyramidInfo.axisMapping.spatialAxisNames
615
+
616
+ // Native physical center per OME dim (width=last, height=2nd-to-last, depth=3rd-to-last)
617
+ const physW = (this.levelDims.width / 2) * scaleW + transW
618
+ const physH = (this.levelDims.height / 2) * scaleH + transH
619
+ const physD = (this.levelDims.depth / 2) * scaleD + transD
620
+
621
+ // Map OME dims to x/y/z rows using axis names
622
+ const nativeCenter: [number, number, number] = [0, 0, 0]
623
+ const dims = [
624
+ { name: axisNames[axisNames.length - 1], phys: physW },
625
+ { name: axisNames[axisNames.length - 2], phys: physH },
626
+ { name: axisNames.length >= 3 ? axisNames[axisNames.length - 3] : 'z', phys: physD }
627
+ ]
628
+ for (const d of dims) {
629
+ const row = d.name === 'x' ? 0 : d.name === 'y' ? 1 : 2
630
+ nativeCenter[row] = d.phys
631
+ }
632
+
633
+ this.worldOffsetMM = [targetMM[0] - nativeCenter[0], targetMM[1] - nativeCenter[1], targetMM[2] - nativeCenter[2]]
634
+
635
+ // Center viewport on level center so visible region is at the target
636
+ this.centerX = this.levelDims.width / 2
637
+ this.centerY = this.levelDims.height / 2
638
+ this.centerZ = this.levelDims.depth / 2
639
+ this.clampCenter()
640
+
641
+ this.updateAffine()
642
+ }
643
+
594
644
  /**
595
645
  * Convert physical (mm) coordinates back to real zarr level pixel coordinates.
596
646
  * Inverts the affine: levelPixel = (mm - OME_translation) / scale
@@ -606,6 +656,11 @@ export class NVZarrHelper {
606
656
  level: number
607
657
  levelDims: { width: number; height: number; depth: number }
608
658
  } {
659
+ // Subtract world offset to convert from offset world-space back to native zarr space
660
+ mmX -= this.worldOffsetMM[0]
661
+ mmY -= this.worldOffsetMM[1]
662
+ mmZ -= this.worldOffsetMM[2]
663
+
609
664
  const { scaleW, scaleH, scaleD } = this.getConvertedScales()
610
665
  const axisNames = this.pyramidInfo.axisMapping.spatialAxisNames
611
666
 
@@ -742,9 +797,6 @@ export class NVZarrHelper {
742
797
  const abortController = new AbortController()
743
798
  this.currentAbortController = abortController
744
799
 
745
- this.runningMin = Infinity
746
- this.runningMax = -Infinity
747
-
748
800
  await new Promise<void>((resolve) => {
749
801
  requestAnimationFrame(() => resolve())
750
802
  })
@@ -891,15 +943,17 @@ export class NVZarrHelper {
891
943
  return
892
944
  }
893
945
 
894
- // Track min/max for calibration
895
- const step = Math.max(1, Math.floor(data.length / 2000))
896
- for (let i = 0; i < data.length; i += step) {
897
- const val = data[i]
898
- if (val < this.runningMin) {
899
- this.runningMin = val
900
- }
901
- if (val > this.runningMax) {
902
- this.runningMax = val
946
+ // Track min/max for initial calibration (skipped once calibration is done)
947
+ if (!this.calibrationDone) {
948
+ const step = Math.max(1, Math.floor(data.length / 2000))
949
+ for (let i = 0; i < data.length; i += step) {
950
+ const val = data[i]
951
+ if (val < this.runningMin) {
952
+ this.runningMin = val
953
+ }
954
+ if (val > this.runningMax) {
955
+ this.runningMax = val
956
+ }
903
957
  }
904
958
  }
905
959
 
@@ -940,6 +994,9 @@ export class NVZarrHelper {
940
994
  }
941
995
 
942
996
  private updateCalibration(): void {
997
+ if (this.calibrationDone) {
998
+ return
999
+ }
943
1000
  if (this.runningMin < Infinity && this.runningMax > -Infinity) {
944
1001
  this.hostImage.cal_min = this.runningMin
945
1002
  this.hostImage.cal_max = this.runningMax
@@ -947,6 +1004,7 @@ export class NVZarrHelper {
947
1004
  this.hostImage.robust_max = this.runningMax
948
1005
  this.hostImage.global_min = this.runningMin
949
1006
  this.hostImage.global_max = this.runningMax
1007
+ this.calibrationDone = true
950
1008
  }
951
1009
  }
952
1010