@fideus-labs/fidnii 0.1.0 → 0.2.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.
Files changed (45) hide show
  1. package/dist/BufferManager.d.ts.map +1 -1
  2. package/dist/BufferManager.js +2 -5
  3. package/dist/BufferManager.js.map +1 -1
  4. package/dist/ClipPlanes.d.ts.map +1 -1
  5. package/dist/ClipPlanes.js +11 -15
  6. package/dist/ClipPlanes.js.map +1 -1
  7. package/dist/OMEZarrNVImage.d.ts +33 -3
  8. package/dist/OMEZarrNVImage.d.ts.map +1 -1
  9. package/dist/OMEZarrNVImage.js +129 -50
  10. package/dist/OMEZarrNVImage.js.map +1 -1
  11. package/dist/RegionCoalescer.d.ts.map +1 -1
  12. package/dist/RegionCoalescer.js +1 -1
  13. package/dist/RegionCoalescer.js.map +1 -1
  14. package/dist/ResolutionSelector.d.ts.map +1 -1
  15. package/dist/ResolutionSelector.js +2 -4
  16. package/dist/ResolutionSelector.js.map +1 -1
  17. package/dist/ViewportBounds.d.ts.map +1 -1
  18. package/dist/ViewportBounds.js +7 -5
  19. package/dist/ViewportBounds.js.map +1 -1
  20. package/dist/events.d.ts.map +1 -1
  21. package/dist/events.js.map +1 -1
  22. package/dist/index.d.ts +11 -11
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +17 -16
  25. package/dist/index.js.map +1 -1
  26. package/dist/types.d.ts.map +1 -1
  27. package/dist/types.js.map +1 -1
  28. package/dist/utils/affine.d.ts +1 -1
  29. package/dist/utils/affine.d.ts.map +1 -1
  30. package/dist/utils/affine.js.map +1 -1
  31. package/dist/utils/coordinates.d.ts +1 -1
  32. package/dist/utils/coordinates.d.ts.map +1 -1
  33. package/dist/utils/coordinates.js.map +1 -1
  34. package/package.json +1 -1
  35. package/src/BufferManager.ts +45 -45
  36. package/src/ClipPlanes.ts +131 -130
  37. package/src/OMEZarrNVImage.ts +685 -606
  38. package/src/RegionCoalescer.ts +48 -47
  39. package/src/ResolutionSelector.ts +66 -67
  40. package/src/ViewportBounds.ts +120 -118
  41. package/src/events.ts +36 -35
  42. package/src/index.ts +59 -69
  43. package/src/types.ts +95 -94
  44. package/src/utils/affine.ts +65 -65
  45. package/src/utils/coordinates.ts +70 -70
@@ -1,13 +1,42 @@
1
1
  // SPDX-FileCopyrightText: Copyright (c) Fideus Labs LLC
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
- import { NIFTI1 } from "nifti-reader-js";
5
- import { NVImage, SLICE_TYPE } from "@niivue/niivue";
6
- import type { Niivue } from "@niivue/niivue";
7
- import type { Multiscales, NgffImage, Omero } from "@fideus-labs/ngff-zarr";
8
- import { computeOmeroFromNgffImage } from "@fideus-labs/ngff-zarr/browser";
9
- import { LRUCache } from "lru-cache";
10
-
4
+ import type { Multiscales, NgffImage, Omero } from "@fideus-labs/ngff-zarr"
5
+ import { Methods } from "@fideus-labs/ngff-zarr"
6
+ import {
7
+ computeOmeroFromNgffImage,
8
+ GLASBEY_COLORS,
9
+ } from "@fideus-labs/ngff-zarr/browser"
10
+ import type { Niivue } from "@niivue/niivue"
11
+ import { NVImage, SLICE_TYPE } from "@niivue/niivue"
12
+ import { LRUCache } from "lru-cache"
13
+ import { NIFTI1 } from "nifti-reader-js"
14
+
15
+ import { BufferManager } from "./BufferManager.js"
16
+ import {
17
+ alignToChunks,
18
+ clipPlanesToNiivue,
19
+ clipPlanesToPixelRegion,
20
+ createDefaultClipPlanes,
21
+ MAX_CLIP_PLANES,
22
+ normalizeVector,
23
+ validateClipPlanes,
24
+ } from "./ClipPlanes.js"
25
+ import {
26
+ OMEZarrNVImageEvent,
27
+ type OMEZarrNVImageEventListener,
28
+ type OMEZarrNVImageEventListenerOptions,
29
+ type OMEZarrNVImageEventMap,
30
+ type PopulateTrigger,
31
+ } from "./events.js"
32
+ import { RegionCoalescer } from "./RegionCoalescer.js"
33
+ import type { OrthogonalAxis } from "./ResolutionSelector.js"
34
+ import {
35
+ getChunkShape,
36
+ getVolumeShape,
37
+ select2DResolution,
38
+ selectResolution,
39
+ } from "./ResolutionSelector.js"
11
40
  import type {
12
41
  AttachedNiivueState,
13
42
  ChunkAlignedRegion,
@@ -18,53 +47,29 @@ import type {
18
47
  PixelRegion,
19
48
  SlabBufferState,
20
49
  SlabSliceType,
50
+ TypedArray,
21
51
  VolumeBounds,
22
52
  ZarrDtype,
23
- } from "./types.js";
53
+ } from "./types.js"
24
54
  import {
25
55
  getBytesPerPixel,
26
56
  getNiftiDataType,
27
57
  parseZarritaDtype,
28
- } from "./types.js";
29
- import { BufferManager } from "./BufferManager.js";
30
- import { RegionCoalescer } from "./RegionCoalescer.js";
31
- import {
32
- getChunkShape,
33
- getVolumeShape,
34
- select2DResolution,
35
- selectResolution,
36
- } from "./ResolutionSelector.js";
37
- import type { OrthogonalAxis } from "./ResolutionSelector.js";
38
- import {
39
- alignToChunks,
40
- clipPlanesToNiivue,
41
- clipPlanesToPixelRegion,
42
- createDefaultClipPlanes,
43
- MAX_CLIP_PLANES,
44
- normalizeVector,
45
- validateClipPlanes,
46
- } from "./ClipPlanes.js";
58
+ } from "./types.js"
47
59
  import {
48
60
  affineToNiftiSrows,
49
61
  calculateWorldBounds,
50
62
  createAffineFromNgffImage,
51
- } from "./utils/affine.js";
52
- import { worldToPixel } from "./utils/coordinates.js";
63
+ } from "./utils/affine.js"
64
+ import { worldToPixel } from "./utils/coordinates.js"
53
65
  import {
54
66
  boundsApproxEqual,
55
67
  computeViewportBounds2D,
56
68
  computeViewportBounds3D,
57
- } from "./ViewportBounds.js";
58
- import {
59
- OMEZarrNVImageEvent,
60
- OMEZarrNVImageEventListener,
61
- OMEZarrNVImageEventListenerOptions,
62
- OMEZarrNVImageEventMap,
63
- PopulateTrigger,
64
- } from "./events.js";
69
+ } from "./ViewportBounds.js"
65
70
 
66
- const DEFAULT_MAX_PIXELS = 50_000_000;
67
- const DEFAULT_MAX_CACHE_ENTRIES = 200;
71
+ const DEFAULT_MAX_PIXELS = 50_000_000
72
+ const DEFAULT_MAX_CACHE_ENTRIES = 200
68
73
 
69
74
  /**
70
75
  * OMEZarrNVImage extends NVImage to support rendering OME-Zarr images in NiiVue.
@@ -78,179 +83,188 @@ const DEFAULT_MAX_CACHE_ENTRIES = 200;
78
83
  */
79
84
  export class OMEZarrNVImage extends NVImage {
80
85
  /** The OME-Zarr multiscales data */
81
- readonly multiscales: Multiscales;
86
+ readonly multiscales: Multiscales
82
87
 
83
88
  /** Maximum number of pixels to use */
84
- readonly maxPixels: number;
89
+ readonly maxPixels: number
90
+
91
+ /**
92
+ * True when `multiscales.method` is `Methods.ITKWASM_LABEL_IMAGE`.
93
+ *
94
+ * Label images are rendered with a discrete colormap
95
+ * (`setColormapLabel()`) instead of a continuous colormap, and
96
+ * OMERO intensity windowing is skipped.
97
+ */
98
+ readonly isLabelImage: boolean
85
99
 
86
100
  /** Reference to NiiVue instance */
87
- private readonly niivue: Niivue;
101
+ private readonly niivue: Niivue
88
102
 
89
103
  /** Buffer manager for dynamically-sized pixel data */
90
- private readonly bufferManager: BufferManager;
104
+ private readonly bufferManager: BufferManager
91
105
 
92
106
  /** Region coalescer for efficient chunk fetching */
93
- private readonly coalescer: RegionCoalescer;
107
+ private readonly coalescer: RegionCoalescer
94
108
 
95
109
  /** Decoded-chunk cache shared across 3D and 2D slab loads. */
96
- private readonly _chunkCache: ChunkCache | undefined;
110
+ private readonly _chunkCache: ChunkCache | undefined
97
111
 
98
112
  /** Current clip planes in world space */
99
- private _clipPlanes: ClipPlanes;
113
+ private _clipPlanes: ClipPlanes
100
114
 
101
115
  /** Target resolution level index (based on maxPixels) */
102
- private targetLevelIndex: number;
116
+ private targetLevelIndex: number
103
117
 
104
118
  /** Current resolution level index during progressive loading */
105
- private currentLevelIndex: number;
119
+ private currentLevelIndex: number
106
120
 
107
121
  /** True if currently loading data */
108
- private isLoading: boolean = false;
122
+ private isLoading: boolean = false
109
123
 
110
124
  /** Data type of the volume */
111
- private readonly dtype: ZarrDtype;
125
+ private readonly dtype: ZarrDtype
112
126
 
113
127
  /** Full volume bounds in world space */
114
- private readonly _volumeBounds: VolumeBounds;
128
+ private readonly _volumeBounds: VolumeBounds
115
129
 
116
130
  /** Current buffer bounds in world space (may differ from full volume when clipped) */
117
- private _currentBufferBounds: VolumeBounds;
131
+ private _currentBufferBounds: VolumeBounds
118
132
 
119
133
  /** Previous clip plane change handler (to restore later) */
120
- private previousOnClipPlaneChange?: (clipPlane: number[]) => void;
134
+ private previousOnClipPlaneChange?: (clipPlane: number[]) => void
121
135
 
122
136
  /** Debounce delay for clip plane updates (ms) */
123
- private readonly clipPlaneDebounceMs: number;
137
+ private readonly clipPlaneDebounceMs: number
124
138
 
125
139
  /** Timeout handle for debounced clip plane refetch */
126
- private clipPlaneRefetchTimeout: ReturnType<typeof setTimeout> | null = null;
140
+ private clipPlaneRefetchTimeout: ReturnType<typeof setTimeout> | null = null
127
141
 
128
142
  /** Previous clip planes state for direction comparison */
129
- private _previousClipPlanes: ClipPlanes = [];
143
+ private _previousClipPlanes: ClipPlanes = []
130
144
 
131
145
  /** Previous pixel count at current resolution (for direction comparison) */
132
- private _previousPixelCount: number = 0;
146
+ private _previousPixelCount: number = 0
133
147
 
134
148
  /** Cached/computed OMERO metadata for visualization (cal_min/cal_max) */
135
- private _omero: Omero | undefined;
149
+ private _omero: Omero | undefined
136
150
 
137
151
  /** Active channel index for OMERO window selection (default: 0) */
138
- private _activeChannel: number = 0;
152
+ private _activeChannel: number = 0
139
153
 
140
154
  /** Resolution level at which OMERO was last computed (to track recomputation) */
141
- private _omeroComputedForLevel: number = -1;
155
+ private _omeroComputedForLevel: number = -1
142
156
 
143
157
  /** Internal EventTarget for event dispatching (composition pattern) */
144
- private readonly _eventTarget = new EventTarget();
158
+ private readonly _eventTarget = new EventTarget()
145
159
 
146
160
  /** Pending populate request (latest wins - replaces any previous pending) */
147
161
  private _pendingPopulateRequest: {
148
- skipPreview: boolean;
149
- trigger: PopulateTrigger;
150
- } | null = null;
162
+ skipPreview: boolean
163
+ trigger: PopulateTrigger
164
+ } | null = null
151
165
 
152
166
  /** Current populate trigger (set at start of populateVolume, used by events) */
153
- private _currentPopulateTrigger: PopulateTrigger = "initial";
167
+ private _currentPopulateTrigger: PopulateTrigger = "initial"
154
168
 
155
169
  // ============================================================
156
170
  // Multi-NV / Slab Buffer State
157
171
  // ============================================================
158
172
 
159
173
  /** Attached Niivue instances and their state */
160
- private _attachedNiivues: Map<Niivue, AttachedNiivueState> = new Map();
174
+ private _attachedNiivues: Map<Niivue, AttachedNiivueState> = new Map()
161
175
 
162
176
  /** Per-slice-type slab buffers (lazily created) */
163
- private _slabBuffers: Map<SlabSliceType, SlabBufferState> = new Map();
177
+ private _slabBuffers: Map<SlabSliceType, SlabBufferState> = new Map()
164
178
 
165
179
  /** Debounce timeout for slab reload per slice type */
166
180
  private _slabReloadTimeouts: Map<
167
181
  SlabSliceType,
168
182
  ReturnType<typeof setTimeout>
169
- > = new Map();
183
+ > = new Map()
170
184
 
171
185
  // ============================================================
172
186
  // Viewport-Aware Resolution State
173
187
  // ============================================================
174
188
 
175
189
  /** Whether viewport-aware resolution selection is enabled */
176
- private _viewportAwareEnabled: boolean = false;
190
+ private _viewportAwareEnabled: boolean = false
177
191
 
178
192
  /**
179
193
  * Viewport bounds for the 3D render volume (union of all RENDER/MULTIPLANAR NVs).
180
194
  * Null = full volume, no viewport constraint.
181
195
  */
182
- private _viewportBounds3D: VolumeBounds | null = null;
196
+ private _viewportBounds3D: VolumeBounds | null = null
183
197
 
184
198
  /**
185
199
  * Per-slab viewport bounds (from the NV instance that displays each slab).
186
200
  * Null entry = full volume, no viewport constraint for that slab.
187
201
  */
188
202
  private _viewportBoundsPerSlab: Map<SlabSliceType, VolumeBounds | null> =
189
- new Map();
203
+ new Map()
190
204
 
191
205
  /** Timeout handle for debounced viewport update */
192
- private _viewportUpdateTimeout: ReturnType<typeof setTimeout> | null = null;
206
+ private _viewportUpdateTimeout: ReturnType<typeof setTimeout> | null = null
193
207
 
194
208
  /** Per-slab AbortController to cancel in-flight progressive loads */
195
- private _slabAbortControllers: Map<SlabSliceType, AbortController> =
196
- new Map();
209
+ private _slabAbortControllers: Map<SlabSliceType, AbortController> = new Map()
197
210
 
198
211
  // ============================================================
199
212
  // 3D Zoom Override
200
213
  // ============================================================
201
214
 
202
215
  /** Maximum 3D render zoom level for scroll-wheel zoom */
203
- private readonly _max3DZoom: number;
216
+ private readonly _max3DZoom: number
204
217
 
205
218
  /** Minimum 3D render zoom level for scroll-wheel zoom */
206
- private readonly _min3DZoom: number;
219
+ private readonly _min3DZoom: number
207
220
 
208
221
  /**
209
222
  * Debounce delay for viewport-aware reloads (ms).
210
223
  * Higher than clip plane debounce to avoid excessive reloads during
211
224
  * continuous zoom/pan interactions.
212
225
  */
213
- private static readonly VIEWPORT_DEBOUNCE_MS = 500;
226
+ private static readonly VIEWPORT_DEBOUNCE_MS = 500
214
227
 
215
228
  /**
216
229
  * Private constructor. Use OMEZarrNVImage.create() for instantiation.
217
230
  */
218
231
  private constructor(options: OMEZarrNVImageOptions) {
219
232
  // Call NVImage constructor with no data buffer
220
- super();
233
+ super()
221
234
 
222
- this.multiscales = options.multiscales;
223
- this.maxPixels = options.maxPixels ?? DEFAULT_MAX_PIXELS;
224
- this.niivue = options.niivue;
225
- this.clipPlaneDebounceMs = options.clipPlaneDebounceMs ?? 300;
235
+ this.multiscales = options.multiscales
236
+ this.maxPixels = options.maxPixels ?? DEFAULT_MAX_PIXELS
237
+ this.isLabelImage = this.multiscales.method === Methods.ITKWASM_LABEL_IMAGE
238
+ this.niivue = options.niivue
239
+ this.clipPlaneDebounceMs = options.clipPlaneDebounceMs ?? 300
226
240
 
227
241
  // Initialize chunk cache: user-provided > LRU(maxCacheEntries) > disabled
228
- const maxEntries = options.maxCacheEntries ?? DEFAULT_MAX_CACHE_ENTRIES;
242
+ const maxEntries = options.maxCacheEntries ?? DEFAULT_MAX_CACHE_ENTRIES
229
243
  if (options.cache) {
230
- this._chunkCache = options.cache;
244
+ this._chunkCache = options.cache
231
245
  } else if (maxEntries > 0) {
232
- this._chunkCache = new LRUCache({ max: maxEntries });
246
+ this._chunkCache = new LRUCache({ max: maxEntries })
233
247
  }
234
248
 
235
- this.coalescer = new RegionCoalescer(this._chunkCache);
236
- this._max3DZoom = options.max3DZoom ?? 10.0;
237
- this._min3DZoom = options.min3DZoom ?? 0.3;
238
- this._viewportAwareEnabled = options.viewportAware ?? true;
249
+ this.coalescer = new RegionCoalescer(this._chunkCache)
250
+ this._max3DZoom = options.max3DZoom ?? 10.0
251
+ this._min3DZoom = options.min3DZoom ?? 0.3
252
+ this._viewportAwareEnabled = options.viewportAware ?? true
239
253
 
240
254
  // Initialize clip planes to empty (full volume visible)
241
- this._clipPlanes = createDefaultClipPlanes(this.multiscales);
255
+ this._clipPlanes = createDefaultClipPlanes(this.multiscales)
242
256
 
243
257
  // Get data type from highest resolution image
244
- const highResImage = this.multiscales.images[0];
245
- this.dtype = parseZarritaDtype(highResImage.data.dtype);
258
+ const highResImage = this.multiscales.images[0]
259
+ this.dtype = parseZarritaDtype(highResImage.data.dtype)
246
260
 
247
261
  // Calculate volume bounds from highest resolution for most accurate bounds
248
- const highResAffine = createAffineFromNgffImage(highResImage);
249
- const highResShape = getVolumeShape(highResImage);
250
- this._volumeBounds = calculateWorldBounds(highResAffine, highResShape);
262
+ const highResAffine = createAffineFromNgffImage(highResImage)
263
+ const highResShape = getVolumeShape(highResImage)
264
+ this._volumeBounds = calculateWorldBounds(highResAffine, highResShape)
251
265
 
252
266
  // Initially, buffer bounds = full volume bounds (no clipping yet)
253
- this._currentBufferBounds = { ...this._volumeBounds };
267
+ this._currentBufferBounds = { ...this._volumeBounds }
254
268
 
255
269
  // Calculate target resolution based on pixel budget
256
270
  const selection = selectResolution(
@@ -258,16 +272,16 @@ export class OMEZarrNVImage extends NVImage {
258
272
  this.maxPixels,
259
273
  this._clipPlanes,
260
274
  this._volumeBounds,
261
- );
262
- this.targetLevelIndex = selection.levelIndex;
263
- this.currentLevelIndex = this.multiscales.images.length - 1;
275
+ )
276
+ this.targetLevelIndex = selection.levelIndex
277
+ this.currentLevelIndex = this.multiscales.images.length - 1
264
278
 
265
279
  // Create buffer manager (dynamic sizing, no pre-allocation)
266
- this.bufferManager = new BufferManager(this.maxPixels, this.dtype);
280
+ this.bufferManager = new BufferManager(this.maxPixels, this.dtype)
267
281
 
268
282
  // Initialize NVImage properties with placeholder values
269
283
  // Actual values will be set when data is first loaded
270
- this.initializeNVImageProperties();
284
+ this.initializeNVImageProperties()
271
285
  }
272
286
 
273
287
  /**
@@ -284,30 +298,30 @@ export class OMEZarrNVImage extends NVImage {
284
298
  * @returns Promise resolving to the OMEZarrNVImage instance
285
299
  */
286
300
  static async create(options: OMEZarrNVImageOptions): Promise<OMEZarrNVImage> {
287
- const image = new OMEZarrNVImage(options);
301
+ const image = new OMEZarrNVImage(options)
288
302
 
289
303
  // Store and replace the clip plane change handler
290
- image.previousOnClipPlaneChange = image.niivue.onClipPlaneChange;
304
+ image.previousOnClipPlaneChange = image.niivue.onClipPlaneChange
291
305
  image.niivue.onClipPlaneChange = (clipPlane: number[]) => {
292
306
  // Call original handler if it exists
293
307
  if (image.previousOnClipPlaneChange) {
294
- image.previousOnClipPlaneChange(clipPlane);
308
+ image.previousOnClipPlaneChange(clipPlane)
295
309
  }
296
310
  // Handle clip plane change
297
- image.onNiivueClipPlaneChange(clipPlane);
298
- };
311
+ image.onNiivueClipPlaneChange(clipPlane)
312
+ }
299
313
 
300
314
  // Auto-attach the primary NV instance for slice type / location tracking
301
- image.attachNiivue(image.niivue);
315
+ image.attachNiivue(image.niivue)
302
316
 
303
317
  // Auto-load by default (add to NiiVue + start progressive loading)
304
- const autoLoad = options.autoLoad ?? true;
318
+ const autoLoad = options.autoLoad ?? true
305
319
  if (autoLoad) {
306
- image.niivue.addVolume(image);
307
- void image.populateVolume(); // Fire-and-forget, returns immediately
320
+ image.niivue.addVolume(image)
321
+ void image.populateVolume() // Fire-and-forget, returns immediately
308
322
  }
309
323
 
310
- return image;
324
+ return image
311
325
  }
312
326
 
313
327
  /**
@@ -316,18 +330,18 @@ export class OMEZarrNVImage extends NVImage {
316
330
  */
317
331
  private initializeNVImageProperties(): void {
318
332
  // Create NIfTI header with placeholder values
319
- const hdr = new NIFTI1();
320
- this.hdr = hdr;
333
+ const hdr = new NIFTI1()
334
+ this.hdr = hdr
321
335
 
322
336
  // Placeholder dimensions (will be updated when data loads)
323
- hdr.dims = [3, 1, 1, 1, 1, 1, 1, 1];
337
+ hdr.dims = [3, 1, 1, 1, 1, 1, 1, 1]
324
338
 
325
339
  // Set data type
326
- hdr.datatypeCode = getNiftiDataType(this.dtype);
327
- hdr.numBitsPerVoxel = getBytesPerPixel(this.dtype) * 8;
340
+ hdr.datatypeCode = getNiftiDataType(this.dtype)
341
+ hdr.numBitsPerVoxel = getBytesPerPixel(this.dtype) * 8
328
342
 
329
343
  // Placeholder pixel dimensions
330
- hdr.pixDims = [1, 1, 1, 1, 0, 0, 0, 0];
344
+ hdr.pixDims = [1, 1, 1, 1, 0, 0, 0, 0]
331
345
 
332
346
  // Placeholder affine (identity)
333
347
  hdr.affine = [
@@ -335,20 +349,22 @@ export class OMEZarrNVImage extends NVImage {
335
349
  [0, 1, 0, 0],
336
350
  [0, 0, 1, 0],
337
351
  [0, 0, 0, 1],
338
- ];
352
+ ]
339
353
 
340
- hdr.sform_code = 1; // Scanner coordinates
354
+ hdr.sform_code = 1 // Scanner coordinates
341
355
 
342
356
  // Set name
343
- this.name = this.multiscales.metadata?.name ?? "OME-Zarr";
357
+ this.name = this.multiscales.metadata?.name ?? "OME-Zarr"
344
358
 
345
359
  // Initialize with empty typed array (will be replaced when data loads)
346
360
  // We need at least 1 element to avoid issues
347
- this.img = this.bufferManager.resize([1, 1, 1]) as any;
361
+ this.img = this.bufferManager.resize([1, 1, 1]) as NVImage["img"]
348
362
 
349
- // Set default colormap
350
- this._colormap = "gray";
351
- this._opacity = 1.0;
363
+ // Set default colormap (label images use setColormapLabel() instead)
364
+ if (!this.isLabelImage) {
365
+ this._colormap = "gray"
366
+ }
367
+ this._opacity = 1.0
352
368
  }
353
369
 
354
370
  /**
@@ -376,26 +392,26 @@ export class OMEZarrNVImage extends NVImage {
376
392
  this._emitEvent("loadingSkipped", {
377
393
  reason: "queued-replaced",
378
394
  trigger: this._pendingPopulateRequest.trigger,
379
- });
395
+ })
380
396
  }
381
397
  // Queue this request (no event - just queuing)
382
- this._pendingPopulateRequest = { skipPreview, trigger };
383
- return;
398
+ this._pendingPopulateRequest = { skipPreview, trigger }
399
+ return
384
400
  }
385
401
 
386
- this.isLoading = true;
387
- this._currentPopulateTrigger = trigger;
388
- this._pendingPopulateRequest = null; // Clear any stale pending request
402
+ this.isLoading = true
403
+ this._currentPopulateTrigger = trigger
404
+ this._pendingPopulateRequest = null // Clear any stale pending request
389
405
 
390
406
  try {
391
- const numLevels = this.multiscales.images.length;
392
- const lowestLevel = numLevels - 1;
407
+ const numLevels = this.multiscales.images.length
408
+ const lowestLevel = numLevels - 1
393
409
 
394
410
  // Quick preview from lowest resolution (if different from target and not skipped)
395
411
  if (!skipPreview && lowestLevel !== this.targetLevelIndex) {
396
- await this.loadResolutionLevel(lowestLevel, "preview");
397
- const prevLevel = this.currentLevelIndex;
398
- this.currentLevelIndex = lowestLevel;
412
+ await this.loadResolutionLevel(lowestLevel, "preview")
413
+ const prevLevel = this.currentLevelIndex
414
+ this.currentLevelIndex = lowestLevel
399
415
 
400
416
  // Emit resolutionChange for preview load
401
417
  if (prevLevel !== lowestLevel) {
@@ -404,14 +420,14 @@ export class OMEZarrNVImage extends NVImage {
404
420
  targetLevel: this.targetLevelIndex,
405
421
  previousLevel: prevLevel,
406
422
  trigger: this._currentPopulateTrigger,
407
- });
423
+ })
408
424
  }
409
425
  }
410
426
 
411
427
  // Final quality at target resolution
412
- await this.loadResolutionLevel(this.targetLevelIndex, "target");
413
- const prevLevelBeforeTarget = this.currentLevelIndex;
414
- this.currentLevelIndex = this.targetLevelIndex;
428
+ await this.loadResolutionLevel(this.targetLevelIndex, "target")
429
+ const prevLevelBeforeTarget = this.currentLevelIndex
430
+ this.currentLevelIndex = this.targetLevelIndex
415
431
 
416
432
  // Emit resolutionChange for target load
417
433
  if (prevLevelBeforeTarget !== this.targetLevelIndex) {
@@ -420,23 +436,23 @@ export class OMEZarrNVImage extends NVImage {
420
436
  targetLevel: this.targetLevelIndex,
421
437
  previousLevel: prevLevelBeforeTarget,
422
438
  trigger: this._currentPopulateTrigger,
423
- });
439
+ })
424
440
  }
425
441
 
426
442
  // Update previous state for direction-aware resolution selection
427
443
  // Always calculate at level 0 for consistent comparison across resolution changes
428
- this._previousClipPlanes = this.copyClipPlanes(this._clipPlanes);
429
- const referenceImage = this.multiscales.images[0];
444
+ this._previousClipPlanes = this.copyClipPlanes(this._clipPlanes)
445
+ const referenceImage = this.multiscales.images[0]
430
446
  const region = clipPlanesToPixelRegion(
431
447
  this._clipPlanes,
432
448
  this._volumeBounds,
433
449
  referenceImage,
434
- );
435
- const aligned = alignToChunks(region, referenceImage);
436
- this._previousPixelCount = this.calculateAlignedPixelCount(aligned);
450
+ )
451
+ const aligned = alignToChunks(region, referenceImage)
452
+ this._previousPixelCount = this.calculateAlignedPixelCount(aligned)
437
453
  } finally {
438
- this.isLoading = false;
439
- this.handlePendingPopulateRequest();
454
+ this.isLoading = false
455
+ this.handlePendingPopulateRequest()
440
456
  }
441
457
  }
442
458
 
@@ -445,12 +461,12 @@ export class OMEZarrNVImage extends NVImage {
445
461
  * If no pending request, emits populateComplete.
446
462
  */
447
463
  private handlePendingPopulateRequest(): void {
448
- const pending = this._pendingPopulateRequest;
464
+ const pending = this._pendingPopulateRequest
449
465
  if (pending !== null) {
450
- this._pendingPopulateRequest = null;
466
+ this._pendingPopulateRequest = null
451
467
  // Use void to indicate we're intentionally not awaiting
452
- void this.populateVolume(pending.skipPreview, pending.trigger);
453
- return;
468
+ void this.populateVolume(pending.skipPreview, pending.trigger)
469
+ return
454
470
  }
455
471
 
456
472
  // No more pending requests - emit populateComplete
@@ -458,7 +474,7 @@ export class OMEZarrNVImage extends NVImage {
458
474
  currentLevel: this.currentLevelIndex,
459
475
  targetLevel: this.targetLevelIndex,
460
476
  trigger: this._currentPopulateTrigger,
461
- });
477
+ })
462
478
  }
463
479
 
464
480
  /**
@@ -481,9 +497,9 @@ export class OMEZarrNVImage extends NVImage {
481
497
  this._emitEvent("loadingStart", {
482
498
  levelIndex,
483
499
  trigger: this._currentPopulateTrigger,
484
- });
500
+ })
485
501
 
486
- const ngffImage = this.multiscales.images[levelIndex];
502
+ const ngffImage = this.multiscales.images[levelIndex]
487
503
 
488
504
  // Get the pixel region for current clip planes (+ 3D viewport bounds if active)
489
505
  const pixelRegion = clipPlanesToPixelRegion(
@@ -491,69 +507,76 @@ export class OMEZarrNVImage extends NVImage {
491
507
  this._volumeBounds,
492
508
  ngffImage,
493
509
  this._viewportBounds3D ?? undefined,
494
- );
495
- const alignedRegion = alignToChunks(pixelRegion, ngffImage);
510
+ )
511
+ const alignedRegion = alignToChunks(pixelRegion, ngffImage)
496
512
 
497
513
  // Calculate the shape of data to fetch
498
514
  const fetchedShape: [number, number, number] = [
499
515
  alignedRegion.chunkAlignedEnd[0] - alignedRegion.chunkAlignedStart[0],
500
516
  alignedRegion.chunkAlignedEnd[1] - alignedRegion.chunkAlignedStart[1],
501
517
  alignedRegion.chunkAlignedEnd[2] - alignedRegion.chunkAlignedStart[2],
502
- ];
518
+ ]
503
519
 
504
520
  // Fetch the data
505
521
  const fetchRegion: PixelRegion = {
506
522
  start: alignedRegion.chunkAlignedStart,
507
523
  end: alignedRegion.chunkAlignedEnd,
508
- };
524
+ }
509
525
 
510
526
  const result = await this.coalescer.fetchRegion(
511
527
  ngffImage,
512
528
  levelIndex,
513
529
  fetchRegion,
514
530
  requesterId,
515
- );
531
+ )
516
532
 
517
533
  // Resize buffer to match fetched data exactly (no upsampling!)
518
- const targetData = this.bufferManager.resize(fetchedShape);
534
+ const targetData = this.bufferManager.resize(fetchedShape)
519
535
 
520
536
  // Direct copy of fetched data
521
- targetData.set(result.data);
537
+ targetData.set(result.data)
522
538
 
523
539
  // Update this.img to point to the (possibly new) buffer
524
- this.img = this.bufferManager.getTypedArray() as any;
540
+ this.img = this.bufferManager.getTypedArray() as NVImage["img"]
525
541
 
526
542
  // Update NVImage header with correct dimensions and transforms
527
- this.updateHeaderForRegion(ngffImage, alignedRegion, fetchedShape);
543
+ this.updateHeaderForRegion(ngffImage, alignedRegion, fetchedShape)
528
544
 
529
- // Compute or apply OMERO metadata for cal_min/cal_max
530
- await this.ensureOmeroMetadata(ngffImage, levelIndex);
545
+ if (this.isLabelImage) {
546
+ // Label images: apply a discrete colormap instead of OMERO windowing
547
+ this._applyLabelColormap(this, result.data)
548
+ } else {
549
+ // Compute or apply OMERO metadata for cal_min/cal_max
550
+ await this.ensureOmeroMetadata(ngffImage, levelIndex)
551
+ }
531
552
 
532
553
  // Reset global_min so NiiVue's refreshLayers() re-runs calMinMax() on real data.
533
554
  // Without this, if calMinMax() was previously called on placeholder/empty data
534
555
  // (e.g., when setting colormap before loading), global_min would already be set
535
556
  // and NiiVue would skip recalculating intensity ranges, leaving cal_min/cal_max
536
557
  // at stale values (typically 0/0), causing an all-white render.
537
- this.global_min = undefined;
558
+ this.global_min = undefined
538
559
 
539
560
  // Update NiiVue clip planes
540
- this.updateNiivueClipPlanes();
561
+ this.updateNiivueClipPlanes()
541
562
 
542
563
  // Refresh NiiVue
543
- this.niivue.updateGLVolume();
544
-
545
- // Widen the display window if actual data exceeds the OMERO range.
546
- // At higher resolutions, individual bright/dark voxels that were averaged
547
- // out at lower resolutions can exceed the OMERO-specified window, causing
548
- // clipping artifacts. This preserves the OMERO lower bound but widens the
549
- // ceiling to encompass the full data range when needed.
550
- this._widenCalRangeIfNeeded(this);
564
+ this.niivue.updateGLVolume()
565
+
566
+ if (!this.isLabelImage) {
567
+ // Widen the display window if actual data exceeds the OMERO range.
568
+ // At higher resolutions, individual bright/dark voxels that were averaged
569
+ // out at lower resolutions can exceed the OMERO-specified window, causing
570
+ // clipping artifacts. This preserves the OMERO lower bound but widens the
571
+ // ceiling to encompass the full data range when needed.
572
+ this._widenCalRangeIfNeeded(this)
573
+ }
551
574
 
552
575
  // Emit loadingComplete event
553
576
  this._emitEvent("loadingComplete", {
554
577
  levelIndex,
555
578
  trigger: this._currentPopulateTrigger,
556
- });
579
+ })
557
580
  }
558
581
 
559
582
  /**
@@ -572,16 +595,16 @@ export class OMEZarrNVImage extends NVImage {
572
595
  region: ChunkAlignedRegion,
573
596
  fetchedShape: [number, number, number],
574
597
  ): void {
575
- if (!this.hdr) return;
598
+ if (!this.hdr) return
576
599
 
577
600
  // Get voxel size from this resolution level (no upsampling adjustment needed!)
578
- const scale = ngffImage.scale;
579
- const sx = scale.x ?? scale.X ?? 1;
580
- const sy = scale.y ?? scale.Y ?? 1;
581
- const sz = scale.z ?? scale.Z ?? 1;
601
+ const scale = ngffImage.scale
602
+ const sx = scale.x ?? scale.X ?? 1
603
+ const sy = scale.y ?? scale.Y ?? 1
604
+ const sz = scale.z ?? scale.Z ?? 1
582
605
 
583
606
  // Set pixDims directly from resolution's voxel size
584
- this.hdr.pixDims = [1, sx, sy, sz, 0, 0, 0, 0];
607
+ this.hdr.pixDims = [1, sx, sy, sz, 0, 0, 0, 0]
585
608
 
586
609
  // Set dims to match fetched data (buffer now equals fetched size)
587
610
  // NIfTI dims: [ndim, x, y, z, t, ...]
@@ -594,27 +617,22 @@ export class OMEZarrNVImage extends NVImage {
594
617
  1,
595
618
  1,
596
619
  1,
597
- ];
620
+ ]
598
621
 
599
622
  // Build affine with offset for region start
600
- const affine = createAffineFromNgffImage(ngffImage);
623
+ const affine = createAffineFromNgffImage(ngffImage)
601
624
 
602
625
  // Adjust translation for region offset
603
626
  // Buffer pixel [0,0,0] corresponds to source pixel region.chunkAlignedStart
604
- const regionStart = region.chunkAlignedStart;
627
+ const regionStart = region.chunkAlignedStart
605
628
  // regionStart is [z, y, x], affine translation is [x, y, z] (indices 12, 13, 14)
606
- affine[12] += regionStart[2] * sx; // x offset
607
- affine[13] += regionStart[1] * sy; // y offset
608
- affine[14] += regionStart[0] * sz; // z offset
629
+ affine[12] += regionStart[2] * sx // x offset
630
+ affine[13] += regionStart[1] * sy // y offset
631
+ affine[14] += regionStart[0] * sz // z offset
609
632
 
610
633
  // Update affine in header
611
- const srows = affineToNiftiSrows(affine);
612
- this.hdr.affine = [
613
- srows.srow_x,
614
- srows.srow_y,
615
- srows.srow_z,
616
- [0, 0, 0, 1],
617
- ];
634
+ const srows = affineToNiftiSrows(affine)
635
+ this.hdr.affine = [srows.srow_x, srows.srow_y, srows.srow_z, [0, 0, 0, 1]]
618
636
 
619
637
  // Update current buffer bounds
620
638
  // Buffer starts at region.chunkAlignedStart and has extent fetchedShape
@@ -629,10 +647,10 @@ export class OMEZarrNVImage extends NVImage {
629
647
  affine[13] + fetchedShape[1] * sy,
630
648
  affine[14] + fetchedShape[0] * sz,
631
649
  ],
632
- };
650
+ }
633
651
 
634
652
  // Recalculate RAS orientation
635
- this.calculateRAS();
653
+ this.calculateRAS()
636
654
  }
637
655
 
638
656
  /**
@@ -648,13 +666,13 @@ export class OMEZarrNVImage extends NVImage {
648
666
  const niivueClipPlanes = clipPlanesToNiivue(
649
667
  this._clipPlanes,
650
668
  this._currentBufferBounds,
651
- );
669
+ )
652
670
 
653
671
  if (niivueClipPlanes.length > 0) {
654
- this.niivue.scene.clipPlaneDepthAziElevs = niivueClipPlanes;
672
+ this.niivue.scene.clipPlaneDepthAziElevs = niivueClipPlanes
655
673
  } else {
656
674
  // Clear clip planes - set to "disabled" state (depth > 1.8)
657
- this.niivue.scene.clipPlaneDepthAziElevs = [[2, 0, 0]];
675
+ this.niivue.scene.clipPlaneDepthAziElevs = [[2, 0, 0]]
658
676
  }
659
677
  }
660
678
 
@@ -665,26 +683,74 @@ export class OMEZarrNVImage extends NVImage {
665
683
  * This sets the display intensity range for NiiVue rendering.
666
684
  */
667
685
  private applyOmeroToHeader(): void {
668
- if (!this.hdr || !this._omero?.channels?.length) return;
686
+ if (!this.hdr || !this._omero?.channels?.length) return
669
687
 
670
688
  // Clamp active channel to valid range
671
689
  const channelIndex = Math.min(
672
690
  this._activeChannel,
673
691
  this._omero.channels.length - 1,
674
- );
675
- const channel = this._omero.channels[channelIndex];
676
- const window = channel?.window;
692
+ )
693
+ const channel = this._omero.channels[channelIndex]
694
+ const window = channel?.window
677
695
 
678
696
  if (window) {
679
697
  // Prefer start/end (display window based on quantiles) over min/max (data range)
680
- const calMin = window.start ?? window.min;
681
- const calMax = window.end ?? window.max;
698
+ const calMin = window.start ?? window.min
699
+ const calMax = window.end ?? window.max
682
700
 
683
- if (calMin !== undefined) this.hdr.cal_min = calMin;
684
- if (calMax !== undefined) this.hdr.cal_max = calMax;
701
+ if (calMin !== undefined) this.hdr.cal_min = calMin
702
+ if (calMax !== undefined) this.hdr.cal_max = calMax
685
703
  }
686
704
  }
687
705
 
706
+ /**
707
+ * Build and apply a discrete NiiVue label colormap to an NVImage.
708
+ *
709
+ * Scans the pixel data for unique integer values and assigns each a
710
+ * distinct color from the Glasbey palette (via `@fideus-labs/ngff-zarr`).
711
+ * Label 0 is treated as background (fully transparent).
712
+ *
713
+ * @param nvImage - The NVImage to apply the label colormap to
714
+ * @param data - The pixel data to scan for unique labels
715
+ */
716
+ private _applyLabelColormap(nvImage: NVImage, data: TypedArray): void {
717
+ const uniqueLabels = [...new Set(data as Iterable<number>)].sort(
718
+ (a, b) => a - b,
719
+ )
720
+
721
+ const R: number[] = []
722
+ const G: number[] = []
723
+ const B: number[] = []
724
+ const A: number[] = []
725
+ const I: number[] = []
726
+ const labels: string[] = []
727
+
728
+ for (let i = 0; i < uniqueLabels.length; i++) {
729
+ const label = uniqueLabels[i]
730
+ I.push(label)
731
+
732
+ if (label === 0) {
733
+ // Background: transparent
734
+ R.push(0)
735
+ G.push(0)
736
+ B.push(0)
737
+ A.push(0)
738
+ labels.push("background")
739
+ } else {
740
+ // Use Glasbey color palette (cycling if >256 labels)
741
+ const hex = GLASBEY_COLORS[(i - 1) % GLASBEY_COLORS.length] ?? "FFFFFF"
742
+ R.push(parseInt(hex.slice(0, 2), 16))
743
+ G.push(parseInt(hex.slice(2, 4), 16))
744
+ B.push(parseInt(hex.slice(4, 6), 16))
745
+ A.push(255)
746
+ labels.push(String(label))
747
+ }
748
+ }
749
+
750
+ // NiiVue's setColormapLabel expects a ColorMap-shaped object
751
+ nvImage.setColormapLabel({ R, G, B, A, I, labels })
752
+ }
753
+
688
754
  /**
689
755
  * Ensure OMERO metadata is available and applied.
690
756
  *
@@ -702,30 +768,30 @@ export class OMEZarrNVImage extends NVImage {
702
768
  ngffImage: NgffImage,
703
769
  levelIndex: number,
704
770
  ): Promise<void> {
705
- const existingOmero = this.multiscales.metadata?.omero;
771
+ const existingOmero = this.multiscales.metadata?.omero
706
772
 
707
773
  if (existingOmero && !this._omero) {
708
774
  // Use existing OMERO metadata from the file (first time)
709
- this._omero = existingOmero;
710
- this.applyOmeroToHeader();
711
- return;
775
+ this._omero = existingOmero
776
+ this.applyOmeroToHeader()
777
+ return
712
778
  }
713
779
 
714
780
  if (!existingOmero) {
715
781
  // No OMERO in file - compute dynamically
716
782
  // Compute at preview (lowest) and target levels, then keep for consistency
717
- const lowestLevel = this.multiscales.images.length - 1;
718
- const isPreviewLevel = levelIndex === lowestLevel;
719
- const isTargetLevel = levelIndex === this.targetLevelIndex;
720
- const needsCompute = isPreviewLevel ||
721
- (isTargetLevel &&
722
- this._omeroComputedForLevel !== this.targetLevelIndex);
783
+ const lowestLevel = this.multiscales.images.length - 1
784
+ const isPreviewLevel = levelIndex === lowestLevel
785
+ const isTargetLevel = levelIndex === this.targetLevelIndex
786
+ const needsCompute =
787
+ isPreviewLevel ||
788
+ (isTargetLevel && this._omeroComputedForLevel !== this.targetLevelIndex)
723
789
 
724
790
  if (needsCompute) {
725
- const computedOmero = await computeOmeroFromNgffImage(ngffImage);
726
- this._omero = computedOmero;
727
- this._omeroComputedForLevel = levelIndex;
728
- this.applyOmeroToHeader();
791
+ const computedOmero = await computeOmeroFromNgffImage(ngffImage)
792
+ this._omero = computedOmero
793
+ this._omeroComputedForLevel = levelIndex
794
+ this.applyOmeroToHeader()
729
795
  }
730
796
  }
731
797
  }
@@ -752,31 +818,31 @@ export class OMEZarrNVImage extends NVImage {
752
818
  */
753
819
  setClipPlanes(planes: ClipPlanes): void {
754
820
  // Validate the planes
755
- validateClipPlanes(planes);
821
+ validateClipPlanes(planes)
756
822
 
757
823
  // Check if this is a "reset" operation (clearing all planes)
758
- const isReset = planes.length === 0 && this._previousClipPlanes.length > 0;
824
+ const isReset = planes.length === 0 && this._previousClipPlanes.length > 0
759
825
 
760
826
  // Store new clip planes
761
827
  this._clipPlanes = planes.map((p) => ({
762
828
  point: [...p.point] as [number, number, number],
763
829
  normal: normalizeVector([...p.normal] as [number, number, number]),
764
- }));
830
+ }))
765
831
 
766
832
  // Always update NiiVue clip planes immediately (visual feedback)
767
- this.updateNiivueClipPlanes();
768
- this.niivue.drawScene();
833
+ this.updateNiivueClipPlanes()
834
+ this.niivue.drawScene()
769
835
 
770
836
  // Clear any pending debounced refetch
771
837
  if (this.clipPlaneRefetchTimeout) {
772
- clearTimeout(this.clipPlaneRefetchTimeout);
773
- this.clipPlaneRefetchTimeout = null;
838
+ clearTimeout(this.clipPlaneRefetchTimeout)
839
+ this.clipPlaneRefetchTimeout = null
774
840
  }
775
841
 
776
842
  // Debounce the data refetch decision
777
843
  this.clipPlaneRefetchTimeout = setTimeout(() => {
778
- this.handleDebouncedClipPlaneUpdate(isReset);
779
- }, this.clipPlaneDebounceMs);
844
+ this.handleDebouncedClipPlaneUpdate(isReset)
845
+ }, this.clipPlaneDebounceMs)
780
846
  }
781
847
 
782
848
  /**
@@ -787,23 +853,23 @@ export class OMEZarrNVImage extends NVImage {
787
853
  * Visual clipping is handled by NiiVue clip planes (updated immediately in setClipPlanes).
788
854
  */
789
855
  private handleDebouncedClipPlaneUpdate(isReset: boolean): void {
790
- this.clipPlaneRefetchTimeout = null;
856
+ this.clipPlaneRefetchTimeout = null
791
857
 
792
858
  // Always use level 0 for consistent pixel count comparison across resolution changes
793
- const referenceImage = this.multiscales.images[0];
859
+ const referenceImage = this.multiscales.images[0]
794
860
 
795
861
  // Calculate current region at reference resolution
796
862
  const currentRegion = clipPlanesToPixelRegion(
797
863
  this._clipPlanes,
798
864
  this._volumeBounds,
799
865
  referenceImage,
800
- );
801
- const currentAligned = alignToChunks(currentRegion, referenceImage);
802
- const currentPixelCount = this.calculateAlignedPixelCount(currentAligned);
866
+ )
867
+ const currentAligned = alignToChunks(currentRegion, referenceImage)
868
+ const currentPixelCount = this.calculateAlignedPixelCount(currentAligned)
803
869
 
804
870
  // Determine volume change direction (comparing at consistent reference level)
805
- const volumeReduced = currentPixelCount < this._previousPixelCount;
806
- const volumeIncreased = currentPixelCount > this._previousPixelCount;
871
+ const volumeReduced = currentPixelCount < this._previousPixelCount
872
+ const volumeIncreased = currentPixelCount > this._previousPixelCount
807
873
 
808
874
  // Get optimal resolution for new region (3D viewport bounds)
809
875
  const selection = selectResolution(
@@ -812,36 +878,37 @@ export class OMEZarrNVImage extends NVImage {
812
878
  this._clipPlanes,
813
879
  this._volumeBounds,
814
880
  this._viewportBounds3D ?? undefined,
815
- );
881
+ )
816
882
 
817
883
  // Direction-aware resolution change
818
- let newTargetLevel = this.targetLevelIndex;
884
+ let newTargetLevel = this.targetLevelIndex
819
885
 
820
886
  if (isReset) {
821
887
  // Reset/clear: always recalculate optimal resolution
822
- newTargetLevel = selection.levelIndex;
888
+ newTargetLevel = selection.levelIndex
823
889
  } else if (volumeReduced && selection.levelIndex < this.targetLevelIndex) {
824
890
  // Volume reduced → allow higher resolution (lower level index)
825
- newTargetLevel = selection.levelIndex;
891
+ newTargetLevel = selection.levelIndex
826
892
  } else if (
827
- volumeIncreased && selection.levelIndex > this.targetLevelIndex
893
+ volumeIncreased &&
894
+ selection.levelIndex > this.targetLevelIndex
828
895
  ) {
829
896
  // Volume increased → allow lower resolution (higher level index) if needed to fit
830
- newTargetLevel = selection.levelIndex;
897
+ newTargetLevel = selection.levelIndex
831
898
  }
832
899
  // Otherwise: keep current level (no unnecessary resolution changes)
833
900
 
834
901
  // Only refetch when resolution level changes
835
902
  // Visual clipping is handled by NiiVue clip planes (already updated in setClipPlanes)
836
903
  if (newTargetLevel !== this.targetLevelIndex) {
837
- this.targetLevelIndex = newTargetLevel;
838
- this.populateVolume(true, "clipPlanesChanged"); // Skip preview for clip plane updates
904
+ this.targetLevelIndex = newTargetLevel
905
+ this.populateVolume(true, "clipPlanesChanged") // Skip preview for clip plane updates
839
906
  }
840
907
 
841
908
  // Emit clipPlanesChange event (after debounce)
842
909
  this._emitEvent("clipPlanesChange", {
843
910
  clipPlanes: this.copyClipPlanes(this._clipPlanes),
844
- });
911
+ })
845
912
  }
846
913
 
847
914
  /**
@@ -852,7 +919,7 @@ export class OMEZarrNVImage extends NVImage {
852
919
  (aligned.chunkAlignedEnd[0] - aligned.chunkAlignedStart[0]) *
853
920
  (aligned.chunkAlignedEnd[1] - aligned.chunkAlignedStart[1]) *
854
921
  (aligned.chunkAlignedEnd[2] - aligned.chunkAlignedStart[2])
855
- );
922
+ )
856
923
  }
857
924
 
858
925
  /**
@@ -862,7 +929,7 @@ export class OMEZarrNVImage extends NVImage {
862
929
  return planes.map((p) => ({
863
930
  point: [...p.point] as [number, number, number],
864
931
  normal: [...p.normal] as [number, number, number],
865
- }));
932
+ }))
866
933
  }
867
934
 
868
935
  /**
@@ -874,7 +941,7 @@ export class OMEZarrNVImage extends NVImage {
874
941
  return this._clipPlanes.map((p) => ({
875
942
  point: [...p.point] as [number, number, number],
876
943
  normal: [...p.normal] as [number, number, number],
877
- }));
944
+ }))
878
945
  }
879
946
 
880
947
  /**
@@ -887,7 +954,7 @@ export class OMEZarrNVImage extends NVImage {
887
954
  if (this._clipPlanes.length >= MAX_CLIP_PLANES) {
888
955
  throw new Error(
889
956
  `Cannot add clip plane: already at maximum of ${MAX_CLIP_PLANES} planes`,
890
- );
957
+ )
891
958
  }
892
959
 
893
960
  const newPlanes = [
@@ -896,9 +963,9 @@ export class OMEZarrNVImage extends NVImage {
896
963
  point: [...plane.point] as [number, number, number],
897
964
  normal: [...plane.normal] as [number, number, number],
898
965
  },
899
- ];
966
+ ]
900
967
 
901
- this.setClipPlanes(newPlanes);
968
+ this.setClipPlanes(newPlanes)
902
969
  }
903
970
 
904
971
  /**
@@ -911,39 +978,60 @@ export class OMEZarrNVImage extends NVImage {
911
978
  if (index < 0 || index >= this._clipPlanes.length) {
912
979
  throw new Error(
913
980
  `Invalid clip plane index: ${index} (have ${this._clipPlanes.length} planes)`,
914
- );
981
+ )
915
982
  }
916
983
 
917
- const newPlanes = this._clipPlanes.filter((_, i) => i !== index);
918
- this.setClipPlanes(newPlanes);
984
+ const newPlanes = this._clipPlanes.filter((_, i) => i !== index)
985
+ this.setClipPlanes(newPlanes)
919
986
  }
920
987
 
921
988
  /**
922
989
  * Clear all clip planes (show full volume).
923
990
  */
924
991
  clearClipPlanes(): void {
925
- this.setClipPlanes([]);
992
+ this.setClipPlanes([])
926
993
  }
927
994
 
928
995
  /**
929
996
  * Get the current resolution level index.
930
997
  */
931
998
  getCurrentLevelIndex(): number {
932
- return this.currentLevelIndex;
999
+ return this.currentLevelIndex
933
1000
  }
934
1001
 
935
1002
  /**
936
1003
  * Get the target resolution level index.
937
1004
  */
938
1005
  getTargetLevelIndex(): number {
939
- return this.targetLevelIndex;
1006
+ return this.targetLevelIndex
940
1007
  }
941
1008
 
942
1009
  /**
943
1010
  * Get the number of resolution levels.
944
1011
  */
945
1012
  getNumLevels(): number {
946
- return this.multiscales.images.length;
1013
+ return this.multiscales.images.length
1014
+ }
1015
+
1016
+ /**
1017
+ * Load a specific resolution level.
1018
+ *
1019
+ * Overrides the automatic `maxPixels`-based level selection and loads the
1020
+ * requested level directly. The preview step is skipped because the caller
1021
+ * has explicitly chosen a level.
1022
+ *
1023
+ * @param levelIndex - Zero-based resolution level (0 = highest resolution)
1024
+ * @throws If `levelIndex` is out of range
1025
+ */
1026
+ async loadLevel(levelIndex: number): Promise<void> {
1027
+ const numLevels = this.multiscales.images.length
1028
+ if (levelIndex < 0 || levelIndex >= numLevels) {
1029
+ throw new Error(
1030
+ `levelIndex ${levelIndex} out of range [0, ${numLevels - 1}]`,
1031
+ )
1032
+ }
1033
+ this.targetLevelIndex = levelIndex
1034
+ await this.populateVolume(true, "initial")
947
1035
  }
948
1036
 
949
1037
  /**
@@ -953,7 +1041,7 @@ export class OMEZarrNVImage extends NVImage {
953
1041
  return {
954
1042
  min: [...this._volumeBounds.min],
955
1043
  max: [...this._volumeBounds.max],
956
- };
1044
+ }
957
1045
  }
958
1046
 
959
1047
  // ============================================================
@@ -970,27 +1058,27 @@ export class OMEZarrNVImage extends NVImage {
970
1058
  * @param enabled - Whether to enable viewport-aware resolution
971
1059
  */
972
1060
  setViewportAware(enabled: boolean): void {
973
- if (enabled === this._viewportAwareEnabled) return;
974
- this._viewportAwareEnabled = enabled;
1061
+ if (enabled === this._viewportAwareEnabled) return
1062
+ this._viewportAwareEnabled = enabled
975
1063
 
976
1064
  if (enabled) {
977
1065
  // Hook viewport events on all attached NVs
978
1066
  for (const [nv, state] of this._attachedNiivues) {
979
- this._hookViewportEvents(nv, state);
1067
+ this._hookViewportEvents(nv, state)
980
1068
  }
981
1069
  // Compute initial viewport bounds and trigger refetch
982
- this._recomputeViewportBounds();
1070
+ this._recomputeViewportBounds()
983
1071
  } else {
984
1072
  // Unhook viewport events on all attached NVs
985
1073
  for (const [nv, state] of this._attachedNiivues) {
986
- this._unhookViewportEvents(nv, state);
1074
+ this._unhookViewportEvents(nv, state)
987
1075
  }
988
1076
  // Clear viewport bounds and refetch at full volume
989
- this._viewportBounds3D = null;
990
- this._viewportBoundsPerSlab.clear();
1077
+ this._viewportBounds3D = null
1078
+ this._viewportBoundsPerSlab.clear()
991
1079
  if (this._viewportUpdateTimeout) {
992
- clearTimeout(this._viewportUpdateTimeout);
993
- this._viewportUpdateTimeout = null;
1080
+ clearTimeout(this._viewportUpdateTimeout)
1081
+ this._viewportUpdateTimeout = null
994
1082
  }
995
1083
  // Recompute resolution without viewport constraint
996
1084
  const selection = selectResolution(
@@ -998,13 +1086,13 @@ export class OMEZarrNVImage extends NVImage {
998
1086
  this.maxPixels,
999
1087
  this._clipPlanes,
1000
1088
  this._volumeBounds,
1001
- );
1089
+ )
1002
1090
  if (selection.levelIndex !== this.targetLevelIndex) {
1003
- this.targetLevelIndex = selection.levelIndex;
1004
- this.populateVolume(true, "viewportChanged");
1091
+ this.targetLevelIndex = selection.levelIndex
1092
+ this.populateVolume(true, "viewportChanged")
1005
1093
  }
1006
1094
  // Also reload slabs without viewport constraint
1007
- this._reloadAllSlabs("viewportChanged");
1095
+ this._reloadAllSlabs("viewportChanged")
1008
1096
  }
1009
1097
  }
1010
1098
 
@@ -1012,7 +1100,7 @@ export class OMEZarrNVImage extends NVImage {
1012
1100
  * Get whether viewport-aware resolution selection is enabled.
1013
1101
  */
1014
1102
  get viewportAware(): boolean {
1015
- return this._viewportAwareEnabled;
1103
+ return this._viewportAwareEnabled
1016
1104
  }
1017
1105
 
1018
1106
  /**
@@ -1020,11 +1108,11 @@ export class OMEZarrNVImage extends NVImage {
1020
1108
  * or no viewport constraint is active).
1021
1109
  */
1022
1110
  getViewportBounds(): VolumeBounds | null {
1023
- if (!this._viewportBounds3D) return null;
1111
+ if (!this._viewportBounds3D) return null
1024
1112
  return {
1025
1113
  min: [...this._viewportBounds3D.min] as [number, number, number],
1026
1114
  max: [...this._viewportBounds3D.max] as [number, number, number],
1027
- };
1115
+ }
1028
1116
  }
1029
1117
 
1030
1118
  /**
@@ -1032,34 +1120,34 @@ export class OMEZarrNVImage extends NVImage {
1032
1120
  */
1033
1121
  private _hookViewportEvents(nv: Niivue, state: AttachedNiivueState): void {
1034
1122
  // Save and chain onMouseUp (fires at end of any mouse/touch interaction)
1035
- state.previousOnMouseUp = nv.onMouseUp as (data: unknown) => void;
1123
+ state.previousOnMouseUp = nv.onMouseUp as (data: unknown) => void
1036
1124
  nv.onMouseUp = (data: unknown) => {
1037
1125
  if (state.previousOnMouseUp) {
1038
- state.previousOnMouseUp(data);
1126
+ state.previousOnMouseUp(data)
1039
1127
  }
1040
- this._handleViewportInteractionEnd(nv);
1041
- };
1128
+ this._handleViewportInteractionEnd(nv)
1129
+ }
1042
1130
 
1043
1131
  // Save and chain onZoom3DChange (fires when volScaleMultiplier changes)
1044
- state.previousOnZoom3DChange = nv.onZoom3DChange;
1132
+ state.previousOnZoom3DChange = nv.onZoom3DChange
1045
1133
  nv.onZoom3DChange = (zoom: number) => {
1046
1134
  if (state.previousOnZoom3DChange) {
1047
- state.previousOnZoom3DChange(zoom);
1135
+ state.previousOnZoom3DChange(zoom)
1048
1136
  }
1049
- this._handleViewportInteractionEnd(nv);
1050
- };
1137
+ this._handleViewportInteractionEnd(nv)
1138
+ }
1051
1139
 
1052
1140
  // Add wheel event listener on the canvas for scroll-wheel zoom detection
1053
- const controller = new AbortController();
1054
- state.viewportAbortController = controller;
1141
+ const controller = new AbortController()
1142
+ state.viewportAbortController = controller
1055
1143
  if (nv.canvas) {
1056
1144
  nv.canvas.addEventListener(
1057
1145
  "wheel",
1058
1146
  () => {
1059
- this._handleViewportInteractionEnd(nv);
1147
+ this._handleViewportInteractionEnd(nv)
1060
1148
  },
1061
1149
  { signal: controller.signal, passive: true },
1062
- );
1150
+ )
1063
1151
  }
1064
1152
  }
1065
1153
 
@@ -1069,20 +1157,20 @@ export class OMEZarrNVImage extends NVImage {
1069
1157
  private _unhookViewportEvents(nv: Niivue, state: AttachedNiivueState): void {
1070
1158
  // Restore onMouseUp
1071
1159
  if (state.previousOnMouseUp !== undefined) {
1072
- nv.onMouseUp = state.previousOnMouseUp as typeof nv.onMouseUp;
1073
- state.previousOnMouseUp = undefined;
1160
+ nv.onMouseUp = state.previousOnMouseUp as typeof nv.onMouseUp
1161
+ state.previousOnMouseUp = undefined
1074
1162
  }
1075
1163
 
1076
1164
  // Restore onZoom3DChange
1077
1165
  if (state.previousOnZoom3DChange !== undefined) {
1078
- nv.onZoom3DChange = state.previousOnZoom3DChange;
1079
- state.previousOnZoom3DChange = undefined;
1166
+ nv.onZoom3DChange = state.previousOnZoom3DChange
1167
+ state.previousOnZoom3DChange = undefined
1080
1168
  }
1081
1169
 
1082
1170
  // Remove wheel event listener
1083
1171
  if (state.viewportAbortController) {
1084
- state.viewportAbortController.abort();
1085
- state.viewportAbortController = undefined;
1172
+ state.viewportAbortController.abort()
1173
+ state.viewportAbortController = undefined
1086
1174
  }
1087
1175
  }
1088
1176
 
@@ -1102,72 +1190,71 @@ export class OMEZarrNVImage extends NVImage {
1102
1190
  * (depth < 1.8), the event passes through to NiiVue's native handler.
1103
1191
  */
1104
1192
  private _hookZoomOverride(nv: Niivue, state: AttachedNiivueState): void {
1105
- if (!nv.canvas) return;
1193
+ if (!nv.canvas) return
1106
1194
 
1107
- const controller = new AbortController();
1108
- state.zoomOverrideAbortController = controller;
1195
+ const controller = new AbortController()
1196
+ state.zoomOverrideAbortController = controller
1109
1197
 
1110
1198
  nv.canvas.addEventListener(
1111
1199
  "wheel",
1112
1200
  (e: WheelEvent) => {
1113
1201
  // Convert mouse position to DPR-scaled canvas coordinates
1114
- const rect = nv.canvas!.getBoundingClientRect();
1115
- const dpr = nv.uiData.dpr ?? 1;
1116
- const x = (e.clientX - rect.left) * dpr;
1117
- const y = (e.clientY - rect.top) * dpr;
1202
+ const canvas = nv.canvas
1203
+ if (!canvas) return
1204
+ const rect = canvas.getBoundingClientRect()
1205
+ const dpr = nv.uiData.dpr ?? 1
1206
+ const x = (e.clientX - rect.left) * dpr
1207
+ const y = (e.clientY - rect.top) * dpr
1118
1208
 
1119
1209
  // Only intercept if mouse is over a 3D render tile
1120
- if (nv.inRenderTile(x, y) < 0) return;
1210
+ if (nv.inRenderTile(x, y) < 0) return
1121
1211
 
1122
1212
  // Preserve clip-plane scrolling: when a clip plane is active
1123
1213
  // (depth < 1.8), let NiiVue handle the event normally.
1124
- const clips = nv.scene.clipPlaneDepthAziElevs;
1125
- const activeIdx = nv.uiData.activeClipPlaneIndex;
1214
+ const clips = nv.scene.clipPlaneDepthAziElevs
1215
+ const activeIdx = nv.uiData.activeClipPlaneIndex
1126
1216
  if (
1127
1217
  nv.volumes.length > 0 &&
1128
1218
  clips?.[activeIdx]?.[0] !== undefined &&
1129
1219
  clips[activeIdx][0] < 1.8
1130
1220
  ) {
1131
- return;
1221
+ return
1132
1222
  }
1133
1223
 
1134
1224
  // Prevent NiiVue's clamped handler from running.
1135
1225
  // NiiVue registers its listener in the bubbling phase, so our
1136
1226
  // capturing-phase listener fires first. stopImmediatePropagation
1137
1227
  // ensures no other same-element listeners fire either.
1138
- e.stopImmediatePropagation();
1139
- e.preventDefault();
1228
+ e.stopImmediatePropagation()
1229
+ e.preventDefault()
1140
1230
 
1141
1231
  // Compute new zoom (same ×1.1 / ×0.9 per step as NiiVue).
1142
1232
  // Round to 2 decimal places (NiiVue rounds to 1, which causes the
1143
1233
  // zoom to get stuck at small values like 0.5 where ×0.9 rounds back).
1144
- const zoomDir = e.deltaY < 0 ? 1 : -1;
1145
- const current = nv.scene.volScaleMultiplier;
1146
- let newZoom = current * (zoomDir > 0 ? 1.1 : 0.9);
1147
- newZoom = Math.round(newZoom * 100) / 100;
1148
- newZoom = Math.max(this._min3DZoom, Math.min(this._max3DZoom, newZoom));
1234
+ const zoomDir = e.deltaY < 0 ? 1 : -1
1235
+ const current = nv.scene.volScaleMultiplier
1236
+ let newZoom = current * (zoomDir > 0 ? 1.1 : 0.9)
1237
+ newZoom = Math.round(newZoom * 100) / 100
1238
+ newZoom = Math.max(this._min3DZoom, Math.min(this._max3DZoom, newZoom))
1149
1239
 
1150
- nv.setScale(newZoom);
1240
+ nv.setScale(newZoom)
1151
1241
 
1152
1242
  // Notify the viewport-aware system. Since we stopped propagation,
1153
1243
  // the passive wheel listener from _hookViewportEvents won't fire,
1154
1244
  // so we call this directly.
1155
- this._handleViewportInteractionEnd(nv);
1245
+ this._handleViewportInteractionEnd(nv)
1156
1246
  },
1157
1247
  { capture: true, signal: controller.signal },
1158
- );
1248
+ )
1159
1249
  }
1160
1250
 
1161
1251
  /**
1162
1252
  * Remove the 3D zoom override wheel listener from a NV instance.
1163
1253
  */
1164
- private _unhookZoomOverride(
1165
- _nv: Niivue,
1166
- state: AttachedNiivueState,
1167
- ): void {
1254
+ private _unhookZoomOverride(_nv: Niivue, state: AttachedNiivueState): void {
1168
1255
  if (state.zoomOverrideAbortController) {
1169
- state.zoomOverrideAbortController.abort();
1170
- state.zoomOverrideAbortController = undefined;
1256
+ state.zoomOverrideAbortController.abort()
1257
+ state.zoomOverrideAbortController = undefined
1171
1258
  }
1172
1259
  }
1173
1260
 
@@ -1176,16 +1263,16 @@ export class OMEZarrNVImage extends NVImage {
1176
1263
  * zoom change, scroll wheel). Debounces the viewport bounds recomputation.
1177
1264
  */
1178
1265
  private _handleViewportInteractionEnd(_nv: Niivue): void {
1179
- if (!this._viewportAwareEnabled) return;
1266
+ if (!this._viewportAwareEnabled) return
1180
1267
 
1181
1268
  // Debounce: clear any pending update and schedule a new one
1182
1269
  if (this._viewportUpdateTimeout) {
1183
- clearTimeout(this._viewportUpdateTimeout);
1270
+ clearTimeout(this._viewportUpdateTimeout)
1184
1271
  }
1185
1272
  this._viewportUpdateTimeout = setTimeout(() => {
1186
- this._viewportUpdateTimeout = null;
1187
- this._recomputeViewportBounds();
1188
- }, OMEZarrNVImage.VIEWPORT_DEBOUNCE_MS);
1273
+ this._viewportUpdateTimeout = null
1274
+ this._recomputeViewportBounds()
1275
+ }, OMEZarrNVImage.VIEWPORT_DEBOUNCE_MS)
1189
1276
  }
1190
1277
 
1191
1278
  /**
@@ -1193,13 +1280,13 @@ export class OMEZarrNVImage extends NVImage {
1193
1280
  * resolution reselection if bounds changed significantly.
1194
1281
  */
1195
1282
  private _recomputeViewportBounds(): void {
1196
- if (!this._viewportAwareEnabled) return;
1283
+ if (!this._viewportAwareEnabled) return
1197
1284
 
1198
1285
  // Compute separate viewport bounds for:
1199
1286
  // - 3D volume: union of all RENDER/MULTIPLANAR NV viewport bounds
1200
1287
  // - Per-slab: each slab type gets its own NV's viewport bounds
1201
- let new3DBounds: VolumeBounds | null = null;
1202
- const newSlabBounds = new Map<SlabSliceType, VolumeBounds | null>();
1288
+ let new3DBounds: VolumeBounds | null = null
1289
+ const newSlabBounds = new Map<SlabSliceType, VolumeBounds | null>()
1203
1290
 
1204
1291
  for (const [nv, state] of this._attachedNiivues) {
1205
1292
  if (
@@ -1207,9 +1294,9 @@ export class OMEZarrNVImage extends NVImage {
1207
1294
  state.currentSliceType === SLICE_TYPE.MULTIPLANAR
1208
1295
  ) {
1209
1296
  // 3D render mode: compute from orthographic frustum
1210
- const nvBounds = computeViewportBounds3D(nv, this._volumeBounds);
1297
+ const nvBounds = computeViewportBounds3D(nv, this._volumeBounds)
1211
1298
  if (!new3DBounds) {
1212
- new3DBounds = nvBounds;
1299
+ new3DBounds = nvBounds
1213
1300
  } else {
1214
1301
  // Union of multiple 3D views
1215
1302
  new3DBounds = {
@@ -1223,48 +1310,49 @@ export class OMEZarrNVImage extends NVImage {
1223
1310
  Math.max(new3DBounds.max[1], nvBounds.max[1]),
1224
1311
  Math.max(new3DBounds.max[2], nvBounds.max[2]),
1225
1312
  ],
1226
- };
1313
+ }
1227
1314
  }
1228
1315
  } else if (this._isSlabSliceType(state.currentSliceType)) {
1229
1316
  // 2D slice mode: compute from pan/zoom
1230
- const sliceType = state.currentSliceType as SlabSliceType;
1231
- const slabState = this._slabBuffers.get(sliceType);
1232
- const normScale = slabState?.normalizationScale ?? 1.0;
1317
+ const sliceType = state.currentSliceType as SlabSliceType
1318
+ const slabState = this._slabBuffers.get(sliceType)
1319
+ const normScale = slabState?.normalizationScale ?? 1.0
1233
1320
  const nvBounds = computeViewportBounds2D(
1234
1321
  nv,
1235
1322
  state.currentSliceType,
1236
1323
  this._volumeBounds,
1237
1324
  normScale,
1238
- );
1239
- newSlabBounds.set(sliceType, nvBounds);
1325
+ )
1326
+ newSlabBounds.set(sliceType, nvBounds)
1240
1327
  }
1241
1328
  }
1242
1329
 
1243
1330
  // Check if 3D bounds changed
1244
- const bounds3DChanged = !new3DBounds !== !this._viewportBounds3D ||
1331
+ const bounds3DChanged =
1332
+ !new3DBounds !== !this._viewportBounds3D ||
1245
1333
  (new3DBounds &&
1246
1334
  this._viewportBounds3D &&
1247
- !boundsApproxEqual(new3DBounds, this._viewportBounds3D));
1335
+ !boundsApproxEqual(new3DBounds, this._viewportBounds3D))
1248
1336
 
1249
1337
  // Check if any slab bounds changed
1250
- let slabBoundsChanged = false;
1338
+ let slabBoundsChanged = false
1251
1339
  for (const [sliceType, newBounds] of newSlabBounds) {
1252
- const oldBounds = this._viewportBoundsPerSlab.get(sliceType) ?? null;
1340
+ const oldBounds = this._viewportBoundsPerSlab.get(sliceType) ?? null
1253
1341
  if (
1254
1342
  !newBounds !== !oldBounds ||
1255
1343
  (newBounds && oldBounds && !boundsApproxEqual(newBounds, oldBounds))
1256
1344
  ) {
1257
- slabBoundsChanged = true;
1258
- break;
1345
+ slabBoundsChanged = true
1346
+ break
1259
1347
  }
1260
1348
  }
1261
1349
 
1262
- if (!bounds3DChanged && !slabBoundsChanged) return;
1350
+ if (!bounds3DChanged && !slabBoundsChanged) return
1263
1351
 
1264
1352
  // Update stored bounds
1265
- this._viewportBounds3D = new3DBounds;
1353
+ this._viewportBounds3D = new3DBounds
1266
1354
  for (const [sliceType, bounds] of newSlabBounds) {
1267
- this._viewportBoundsPerSlab.set(sliceType, bounds);
1355
+ this._viewportBoundsPerSlab.set(sliceType, bounds)
1268
1356
  }
1269
1357
 
1270
1358
  // Recompute 3D resolution selection with new 3D viewport bounds
@@ -1275,17 +1363,17 @@ export class OMEZarrNVImage extends NVImage {
1275
1363
  this._clipPlanes,
1276
1364
  this._volumeBounds,
1277
1365
  this._viewportBounds3D ?? undefined,
1278
- );
1366
+ )
1279
1367
 
1280
1368
  if (selection.levelIndex !== this.targetLevelIndex) {
1281
- this.targetLevelIndex = selection.levelIndex;
1282
- this.populateVolume(true, "viewportChanged");
1369
+ this.targetLevelIndex = selection.levelIndex
1370
+ this.populateVolume(true, "viewportChanged")
1283
1371
  }
1284
1372
  }
1285
1373
 
1286
1374
  // Reload slabs with new per-slab viewport bounds
1287
1375
  if (slabBoundsChanged) {
1288
- this._reloadAllSlabs("viewportChanged");
1376
+ this._reloadAllSlabs("viewportChanged")
1289
1377
  }
1290
1378
  }
1291
1379
 
@@ -1300,28 +1388,28 @@ export class OMEZarrNVImage extends NVImage {
1300
1388
  this._isSlabSliceType(attachedState.currentSliceType) &&
1301
1389
  (attachedState.currentSliceType as SlabSliceType) === sliceType
1302
1390
  ) {
1303
- const crosshairPos = nv.scene?.crosshairPos;
1304
- if (!crosshairPos || nv.volumes.length === 0) continue;
1391
+ const crosshairPos = nv.scene?.crosshairPos
1392
+ if (!crosshairPos || nv.volumes.length === 0) continue
1305
1393
  try {
1306
1394
  const mm = nv.frac2mm([
1307
1395
  crosshairPos[0],
1308
1396
  crosshairPos[1],
1309
1397
  crosshairPos[2],
1310
- ]);
1398
+ ])
1311
1399
  // frac2mm returns values in the slab NVImage's mm space, which
1312
1400
  // is normalized (world * normalizationScale). Convert back to
1313
1401
  // physical world coordinates for worldToPixel and other callers.
1314
- const ns = slabState.normalizationScale;
1402
+ const ns = slabState.normalizationScale
1315
1403
  const worldCoord: [number, number, number] = [
1316
1404
  mm[0] / ns,
1317
1405
  mm[1] / ns,
1318
1406
  mm[2] / ns,
1319
- ];
1320
- this._debouncedSlabReload(sliceType, worldCoord, trigger);
1407
+ ]
1408
+ this._debouncedSlabReload(sliceType, worldCoord, trigger)
1321
1409
  } catch {
1322
1410
  // Can't convert coordinates yet
1323
1411
  }
1324
- break;
1412
+ break
1325
1413
  }
1326
1414
  }
1327
1415
  }
@@ -1331,14 +1419,14 @@ export class OMEZarrNVImage extends NVImage {
1331
1419
  * Get whether the image is currently loading.
1332
1420
  */
1333
1421
  getIsLoading(): boolean {
1334
- return this.isLoading;
1422
+ return this.isLoading
1335
1423
  }
1336
1424
 
1337
1425
  /**
1338
1426
  * Wait for all pending fetches to complete.
1339
1427
  */
1340
1428
  async waitForIdle(): Promise<void> {
1341
- await this.coalescer.onIdle();
1429
+ await this.coalescer.onIdle()
1342
1430
  }
1343
1431
 
1344
1432
  // ============================================================
@@ -1360,7 +1448,7 @@ export class OMEZarrNVImage extends NVImage {
1360
1448
  * @returns OMERO metadata or undefined if not yet loaded/computed
1361
1449
  */
1362
1450
  getOmero(): Omero | undefined {
1363
- return this._omero;
1451
+ return this._omero
1364
1452
  }
1365
1453
 
1366
1454
  /**
@@ -1372,7 +1460,7 @@ export class OMEZarrNVImage extends NVImage {
1372
1460
  * @returns Current active channel index (0-based)
1373
1461
  */
1374
1462
  getActiveChannel(): number {
1375
- return this._activeChannel;
1463
+ return this._activeChannel
1376
1464
  }
1377
1465
 
1378
1466
  /**
@@ -1401,17 +1489,17 @@ export class OMEZarrNVImage extends NVImage {
1401
1489
  */
1402
1490
  setActiveChannel(index: number): void {
1403
1491
  if (!this._omero?.channels?.length) {
1404
- throw new Error("No OMERO metadata available");
1492
+ throw new Error("No OMERO metadata available")
1405
1493
  }
1406
1494
  if (index < 0 || index >= this._omero.channels.length) {
1407
1495
  throw new Error(
1408
1496
  `Invalid channel index: ${index} (have ${this._omero.channels.length} channels)`,
1409
- );
1497
+ )
1410
1498
  }
1411
- this._activeChannel = index;
1412
- this.applyOmeroToHeader();
1413
- this.niivue.updateGLVolume();
1414
- this._widenCalRangeIfNeeded(this);
1499
+ this._activeChannel = index
1500
+ this.applyOmeroToHeader()
1501
+ this.niivue.updateGLVolume()
1502
+ this._widenCalRangeIfNeeded(this)
1415
1503
  }
1416
1504
 
1417
1505
  // ============================================================
@@ -1432,15 +1520,15 @@ export class OMEZarrNVImage extends NVImage {
1432
1520
  * @param nv - The Niivue instance to attach
1433
1521
  */
1434
1522
  attachNiivue(nv: Niivue): void {
1435
- if (this._attachedNiivues.has(nv)) return; // Already attached
1523
+ if (this._attachedNiivues.has(nv)) return // Already attached
1436
1524
 
1437
1525
  const state: AttachedNiivueState = {
1438
1526
  nv,
1439
1527
  currentSliceType: this._detectSliceType(nv),
1440
1528
  previousOnLocationChange: nv.onLocationChange,
1441
- previousOnOptsChange: nv
1442
- .onOptsChange as AttachedNiivueState["previousOnOptsChange"],
1443
- };
1529
+ previousOnOptsChange:
1530
+ nv.onOptsChange as AttachedNiivueState["previousOnOptsChange"],
1531
+ }
1444
1532
 
1445
1533
  // Hook onOptsChange to detect slice type changes
1446
1534
  nv.onOptsChange = (
@@ -1450,36 +1538,36 @@ export class OMEZarrNVImage extends NVImage {
1450
1538
  ) => {
1451
1539
  // Chain to previous handler
1452
1540
  if (state.previousOnOptsChange) {
1453
- state.previousOnOptsChange(propertyName, newValue, oldValue);
1541
+ state.previousOnOptsChange(propertyName, newValue, oldValue)
1454
1542
  }
1455
1543
  if (propertyName === "sliceType") {
1456
- this._handleSliceTypeChange(nv, newValue as SLICE_TYPE);
1544
+ this._handleSliceTypeChange(nv, newValue as SLICE_TYPE)
1457
1545
  }
1458
- };
1546
+ }
1459
1547
 
1460
1548
  // Hook onLocationChange to detect slice position changes
1461
1549
  nv.onLocationChange = (location: unknown) => {
1462
1550
  // Chain to previous handler
1463
1551
  if (state.previousOnLocationChange) {
1464
- state.previousOnLocationChange(location);
1552
+ state.previousOnLocationChange(location)
1465
1553
  }
1466
- this._handleLocationChange(nv, location);
1467
- };
1554
+ this._handleLocationChange(nv, location)
1555
+ }
1468
1556
 
1469
- this._attachedNiivues.set(nv, state);
1557
+ this._attachedNiivues.set(nv, state)
1470
1558
 
1471
1559
  // Hook viewport events if viewport-aware mode is already enabled
1472
1560
  if (this._viewportAwareEnabled) {
1473
- this._hookViewportEvents(nv, state);
1561
+ this._hookViewportEvents(nv, state)
1474
1562
  }
1475
1563
 
1476
1564
  // Override NiiVue's hardcoded 3D zoom clamp (always-on)
1477
- this._hookZoomOverride(nv, state);
1565
+ this._hookZoomOverride(nv, state)
1478
1566
 
1479
1567
  // If the NV is already in a 2D slice mode, set up the slab buffer
1480
- const sliceType = state.currentSliceType;
1568
+ const sliceType = state.currentSliceType
1481
1569
  if (this._isSlabSliceType(sliceType)) {
1482
- this._ensureSlabForNiivue(nv, sliceType as SlabSliceType);
1570
+ this._ensureSlabForNiivue(nv, sliceType as SlabSliceType)
1483
1571
  }
1484
1572
  }
1485
1573
 
@@ -1489,21 +1577,21 @@ export class OMEZarrNVImage extends NVImage {
1489
1577
  * @param nv - The Niivue instance to detach
1490
1578
  */
1491
1579
  detachNiivue(nv: Niivue): void {
1492
- const state = this._attachedNiivues.get(nv);
1493
- if (!state) return;
1580
+ const state = this._attachedNiivues.get(nv)
1581
+ if (!state) return
1494
1582
 
1495
1583
  // Unhook viewport events if active
1496
- this._unhookViewportEvents(nv, state);
1584
+ this._unhookViewportEvents(nv, state)
1497
1585
 
1498
1586
  // Unhook 3D zoom override
1499
- this._unhookZoomOverride(nv, state);
1587
+ this._unhookZoomOverride(nv, state)
1500
1588
 
1501
1589
  // Restore original callbacks
1502
- nv.onLocationChange = state.previousOnLocationChange ?? (() => {});
1503
- nv.onOptsChange =
1504
- (state.previousOnOptsChange ?? (() => {})) as typeof nv.onOptsChange;
1590
+ nv.onLocationChange = state.previousOnLocationChange ?? (() => {})
1591
+ nv.onOptsChange = (state.previousOnOptsChange ??
1592
+ (() => {})) as typeof nv.onOptsChange
1505
1593
 
1506
- this._attachedNiivues.delete(nv);
1594
+ this._attachedNiivues.delete(nv)
1507
1595
  }
1508
1596
 
1509
1597
  /**
@@ -1514,14 +1602,14 @@ export class OMEZarrNVImage extends NVImage {
1514
1602
  * @returns The slab buffer state, or undefined if not yet created
1515
1603
  */
1516
1604
  getSlabBufferState(sliceType: SlabSliceType): SlabBufferState | undefined {
1517
- return this._slabBuffers.get(sliceType);
1605
+ return this._slabBuffers.get(sliceType)
1518
1606
  }
1519
1607
 
1520
1608
  /**
1521
1609
  * Get all attached Niivue instances.
1522
1610
  */
1523
1611
  getAttachedNiivues(): Niivue[] {
1524
- return Array.from(this._attachedNiivues.keys());
1612
+ return Array.from(this._attachedNiivues.keys())
1525
1613
  }
1526
1614
 
1527
1615
  // ---- Private slab helpers ----
@@ -1533,12 +1621,12 @@ export class OMEZarrNVImage extends NVImage {
1533
1621
  // Access the opts.sliceType via the scene data or fall back to checking
1534
1622
  // the convenience properties. Niivue stores the current sliceType in opts.
1535
1623
  // We can read it from the NV instance's internal opts.
1536
- const opts = (nv as any).opts;
1537
- if (opts && typeof opts.sliceType === "number") {
1538
- return opts.sliceType as SLICE_TYPE;
1624
+ const { sliceType } = nv.opts
1625
+ if (typeof sliceType === "number") {
1626
+ return sliceType
1539
1627
  }
1540
1628
  // Default to Render
1541
- return SLICE_TYPE.RENDER;
1629
+ return SLICE_TYPE.RENDER
1542
1630
  }
1543
1631
 
1544
1632
  /**
@@ -1549,7 +1637,7 @@ export class OMEZarrNVImage extends NVImage {
1549
1637
  st === SLICE_TYPE.AXIAL ||
1550
1638
  st === SLICE_TYPE.CORONAL ||
1551
1639
  st === SLICE_TYPE.SAGITTAL
1552
- );
1640
+ )
1553
1641
  }
1554
1642
 
1555
1643
  /**
@@ -1562,11 +1650,11 @@ export class OMEZarrNVImage extends NVImage {
1562
1650
  private _getOrthogonalAxis(sliceType: SlabSliceType): OrthogonalAxis {
1563
1651
  switch (sliceType) {
1564
1652
  case SLICE_TYPE.AXIAL:
1565
- return 0; // Z
1653
+ return 0 // Z
1566
1654
  case SLICE_TYPE.CORONAL:
1567
- return 1; // Y
1655
+ return 1 // Y
1568
1656
  case SLICE_TYPE.SAGITTAL:
1569
- return 2; // X
1657
+ return 2 // X
1570
1658
  }
1571
1659
  }
1572
1660
 
@@ -1574,20 +1662,20 @@ export class OMEZarrNVImage extends NVImage {
1574
1662
  * Handle a slice type change on an attached Niivue instance.
1575
1663
  */
1576
1664
  private _handleSliceTypeChange(nv: Niivue, newSliceType: SLICE_TYPE): void {
1577
- const state = this._attachedNiivues.get(nv);
1578
- if (!state) return;
1665
+ const state = this._attachedNiivues.get(nv)
1666
+ if (!state) return
1579
1667
 
1580
- const oldSliceType = state.currentSliceType;
1581
- state.currentSliceType = newSliceType;
1668
+ const oldSliceType = state.currentSliceType
1669
+ state.currentSliceType = newSliceType
1582
1670
 
1583
- if (oldSliceType === newSliceType) return;
1671
+ if (oldSliceType === newSliceType) return
1584
1672
 
1585
1673
  if (this._isSlabSliceType(newSliceType)) {
1586
1674
  // Switching TO a 2D slab mode: swap in the slab NVImage
1587
- this._ensureSlabForNiivue(nv, newSliceType as SlabSliceType);
1675
+ this._ensureSlabForNiivue(nv, newSliceType as SlabSliceType)
1588
1676
  } else {
1589
1677
  // Switching TO Render or Multiplanar mode: swap back to the main (3D) NVImage
1590
- this._swapVolumeInNiivue(nv, this as NVImage);
1678
+ this._swapVolumeInNiivue(nv, this as NVImage)
1591
1679
  }
1592
1680
  }
1593
1681
 
@@ -1596,44 +1684,40 @@ export class OMEZarrNVImage extends NVImage {
1596
1684
  * Checks if the current slice position has moved outside the loaded slab.
1597
1685
  */
1598
1686
  private _handleLocationChange(nv: Niivue, _location: unknown): void {
1599
- const state = this._attachedNiivues.get(nv);
1600
- if (!state || !this._isSlabSliceType(state.currentSliceType)) return;
1687
+ const state = this._attachedNiivues.get(nv)
1688
+ if (!state || !this._isSlabSliceType(state.currentSliceType)) return
1601
1689
 
1602
- const sliceType = state.currentSliceType as SlabSliceType;
1603
- const slabState = this._slabBuffers.get(sliceType);
1604
- if (!slabState || slabState.slabStart < 0) return; // Slab not yet created or loaded
1690
+ const sliceType = state.currentSliceType as SlabSliceType
1691
+ const slabState = this._slabBuffers.get(sliceType)
1692
+ if (!slabState || slabState.slabStart < 0) return // Slab not yet created or loaded
1605
1693
 
1606
1694
  // Get the current crosshair position in fractional coordinates [0..1]
1607
- const crosshairPos = nv.scene?.crosshairPos;
1608
- if (!crosshairPos || nv.volumes.length === 0) return;
1695
+ const crosshairPos = nv.scene?.crosshairPos
1696
+ if (!crosshairPos || nv.volumes.length === 0) return
1609
1697
 
1610
- let worldCoord: [number, number, number];
1698
+ let worldCoord: [number, number, number]
1611
1699
  try {
1612
- const mm = nv.frac2mm([
1613
- crosshairPos[0],
1614
- crosshairPos[1],
1615
- crosshairPos[2],
1616
- ]);
1700
+ const mm = nv.frac2mm([crosshairPos[0], crosshairPos[1], crosshairPos[2]])
1617
1701
  // frac2mm returns values in the slab NVImage's normalized mm space
1618
1702
  // (world * normalizationScale). Convert back to physical world.
1619
- const ns = slabState.normalizationScale;
1620
- worldCoord = [mm[0] / ns, mm[1] / ns, mm[2] / ns];
1703
+ const ns = slabState.normalizationScale
1704
+ worldCoord = [mm[0] / ns, mm[1] / ns, mm[2] / ns]
1621
1705
  } catch {
1622
- return; // Can't convert coordinates yet
1706
+ return // Can't convert coordinates yet
1623
1707
  }
1624
1708
 
1625
1709
  // Convert world to pixel at the slab's current resolution level
1626
- const ngffImage = this.multiscales.images[slabState.levelIndex];
1627
- const pixelCoord = worldToPixel(worldCoord, ngffImage);
1710
+ const ngffImage = this.multiscales.images[slabState.levelIndex]
1711
+ const pixelCoord = worldToPixel(worldCoord, ngffImage)
1628
1712
 
1629
1713
  // Check the orthogonal axis
1630
- const orthAxis = this._getOrthogonalAxis(sliceType);
1631
- const pixelPos = pixelCoord[orthAxis];
1714
+ const orthAxis = this._getOrthogonalAxis(sliceType)
1715
+ const pixelPos = pixelCoord[orthAxis]
1632
1716
 
1633
1717
  // Is the pixel position outside the currently loaded slab?
1634
1718
  if (pixelPos < slabState.slabStart || pixelPos >= slabState.slabEnd) {
1635
1719
  // Need to reload the slab for the new position
1636
- this._debouncedSlabReload(sliceType, worldCoord);
1720
+ this._debouncedSlabReload(sliceType, worldCoord)
1637
1721
  }
1638
1722
  }
1639
1723
 
@@ -1646,15 +1730,15 @@ export class OMEZarrNVImage extends NVImage {
1646
1730
  trigger: PopulateTrigger = "sliceChanged",
1647
1731
  ): void {
1648
1732
  // Clear any pending reload for this slice type
1649
- const existing = this._slabReloadTimeouts.get(sliceType);
1650
- if (existing) clearTimeout(existing);
1733
+ const existing = this._slabReloadTimeouts.get(sliceType)
1734
+ if (existing) clearTimeout(existing)
1651
1735
 
1652
1736
  const timeout = setTimeout(() => {
1653
- this._slabReloadTimeouts.delete(sliceType);
1654
- void this._loadSlab(sliceType, worldCoord, trigger);
1655
- }, 100); // Short debounce for slice scrolling (faster than clip plane debounce)
1737
+ this._slabReloadTimeouts.delete(sliceType)
1738
+ void this._loadSlab(sliceType, worldCoord, trigger)
1739
+ }, 100) // Short debounce for slice scrolling (faster than clip plane debounce)
1656
1740
 
1657
- this._slabReloadTimeouts.set(sliceType, timeout);
1741
+ this._slabReloadTimeouts.set(sliceType, timeout)
1658
1742
  }
1659
1743
 
1660
1744
  /**
@@ -1662,36 +1746,36 @@ export class OMEZarrNVImage extends NVImage {
1662
1746
  * If needed, creates the slab buffer and triggers an initial load.
1663
1747
  */
1664
1748
  private _ensureSlabForNiivue(nv: Niivue, sliceType: SlabSliceType): void {
1665
- let slabState = this._slabBuffers.get(sliceType);
1749
+ let slabState = this._slabBuffers.get(sliceType)
1666
1750
 
1667
1751
  if (!slabState) {
1668
1752
  // Lazily create the slab buffer
1669
- slabState = this._createSlabBuffer(sliceType);
1670
- this._slabBuffers.set(sliceType, slabState);
1753
+ slabState = this._createSlabBuffer(sliceType)
1754
+ this._slabBuffers.set(sliceType, slabState)
1671
1755
  }
1672
1756
 
1673
1757
  // Swap the slab's NVImage into this NV instance
1674
- this._swapVolumeInNiivue(nv, slabState.nvImage);
1758
+ this._swapVolumeInNiivue(nv, slabState.nvImage)
1675
1759
 
1676
1760
  // Get the current crosshair position and load the slab.
1677
1761
  // Use the volume bounds center as a fallback if crosshair isn't available yet.
1678
- let worldCoord: [number, number, number];
1762
+ let worldCoord: [number, number, number]
1679
1763
  try {
1680
- const crosshairPos = nv.scene?.crosshairPos;
1764
+ const crosshairPos = nv.scene?.crosshairPos
1681
1765
  if (crosshairPos && nv.volumes.length > 0) {
1682
1766
  const mm = nv.frac2mm([
1683
1767
  crosshairPos[0],
1684
1768
  crosshairPos[1],
1685
1769
  crosshairPos[2],
1686
- ]);
1687
- worldCoord = [mm[0], mm[1], mm[2]];
1770
+ ])
1771
+ worldCoord = [mm[0], mm[1], mm[2]]
1688
1772
  } else {
1689
1773
  // Fall back to volume center
1690
1774
  worldCoord = [
1691
1775
  (this._volumeBounds.min[0] + this._volumeBounds.max[0]) / 2,
1692
1776
  (this._volumeBounds.min[1] + this._volumeBounds.max[1]) / 2,
1693
1777
  (this._volumeBounds.min[2] + this._volumeBounds.max[2]) / 2,
1694
- ];
1778
+ ]
1695
1779
  }
1696
1780
  } catch {
1697
1781
  // Fall back to volume center if frac2mm fails
@@ -1699,52 +1783,54 @@ export class OMEZarrNVImage extends NVImage {
1699
1783
  (this._volumeBounds.min[0] + this._volumeBounds.max[0]) / 2,
1700
1784
  (this._volumeBounds.min[1] + this._volumeBounds.max[1]) / 2,
1701
1785
  (this._volumeBounds.min[2] + this._volumeBounds.max[2]) / 2,
1702
- ];
1786
+ ]
1703
1787
  }
1704
1788
 
1705
1789
  void this._loadSlab(sliceType, worldCoord, "initial").catch((err) => {
1706
1790
  console.error(
1707
1791
  `[fidnii] Error loading slab for ${SLICE_TYPE[sliceType]}:`,
1708
1792
  err,
1709
- );
1710
- });
1793
+ )
1794
+ })
1711
1795
  }
1712
1796
 
1713
1797
  /**
1714
1798
  * Create a new slab buffer state for a slice type.
1715
1799
  */
1716
1800
  private _createSlabBuffer(sliceType: SlabSliceType): SlabBufferState {
1717
- const bufferManager = new BufferManager(this.maxPixels, this.dtype);
1718
- const nvImage = new NVImage();
1801
+ const bufferManager = new BufferManager(this.maxPixels, this.dtype)
1802
+ const nvImage = new NVImage()
1719
1803
 
1720
1804
  // Initialize with placeholder NIfTI header (same as main image setup)
1721
- const hdr = new NIFTI1();
1722
- nvImage.hdr = hdr;
1723
- hdr.dims = [3, 1, 1, 1, 1, 1, 1, 1];
1724
- hdr.datatypeCode = getNiftiDataType(this.dtype);
1725
- hdr.numBitsPerVoxel = getBytesPerPixel(this.dtype) * 8;
1726
- hdr.pixDims = [1, 1, 1, 1, 0, 0, 0, 0];
1805
+ const hdr = new NIFTI1()
1806
+ nvImage.hdr = hdr
1807
+ hdr.dims = [3, 1, 1, 1, 1, 1, 1, 1]
1808
+ hdr.datatypeCode = getNiftiDataType(this.dtype)
1809
+ hdr.numBitsPerVoxel = getBytesPerPixel(this.dtype) * 8
1810
+ hdr.pixDims = [1, 1, 1, 1, 0, 0, 0, 0]
1727
1811
  hdr.affine = [
1728
1812
  [1, 0, 0, 0],
1729
1813
  [0, 1, 0, 0],
1730
1814
  [0, 0, 1, 0],
1731
1815
  [0, 0, 0, 1],
1732
- ];
1733
- hdr.sform_code = 1;
1734
- nvImage.name = `${this.name ?? "OME-Zarr"} [${SLICE_TYPE[sliceType]}]`;
1735
- nvImage.img = bufferManager.resize([1, 1, 1]) as any;
1736
- (nvImage as any)._colormap = "gray";
1737
- (nvImage as any)._opacity = 1.0;
1816
+ ]
1817
+ hdr.sform_code = 1
1818
+ nvImage.name = `${this.name ?? "OME-Zarr"} [${SLICE_TYPE[sliceType]}]`
1819
+ nvImage.img = bufferManager.resize([1, 1, 1]) as NVImage["img"]
1820
+ if (!this.isLabelImage) {
1821
+ nvImage._colormap = "gray"
1822
+ }
1823
+ nvImage._opacity = 1.0
1738
1824
 
1739
1825
  // Select initial resolution using 2D pixel budget
1740
- const orthAxis = this._getOrthogonalAxis(sliceType);
1826
+ const orthAxis = this._getOrthogonalAxis(sliceType)
1741
1827
  const selection = select2DResolution(
1742
1828
  this.multiscales,
1743
1829
  this.maxPixels,
1744
1830
  this._clipPlanes,
1745
1831
  this._volumeBounds,
1746
1832
  orthAxis,
1747
- );
1833
+ )
1748
1834
 
1749
1835
  return {
1750
1836
  nvImage,
@@ -1757,7 +1843,7 @@ export class OMEZarrNVImage extends NVImage {
1757
1843
  dtype: this.dtype,
1758
1844
  normalizationScale: 1.0, // Updated on first slab load
1759
1845
  pendingReload: null,
1760
- };
1846
+ }
1761
1847
  }
1762
1848
 
1763
1849
  /**
@@ -1766,16 +1852,16 @@ export class OMEZarrNVImage extends NVImage {
1766
1852
  */
1767
1853
  private _swapVolumeInNiivue(nv: Niivue, targetVolume: NVImage): void {
1768
1854
  // Find and remove any volumes we own (the main image or any slab NVImages)
1769
- const ourVolumes = new Set<NVImage>([this as NVImage]);
1855
+ const ourVolumes = new Set<NVImage>([this as NVImage])
1770
1856
  for (const slab of this._slabBuffers.values()) {
1771
- ourVolumes.add(slab.nvImage);
1857
+ ourVolumes.add(slab.nvImage)
1772
1858
  }
1773
1859
 
1774
1860
  // Remove our volumes from nv (in reverse to avoid index shifting issues)
1775
- const toRemove = nv.volumes.filter((v) => ourVolumes.has(v));
1861
+ const toRemove = nv.volumes.filter((v) => ourVolumes.has(v))
1776
1862
  for (const vol of toRemove) {
1777
1863
  try {
1778
- nv.removeVolume(vol);
1864
+ nv.removeVolume(vol)
1779
1865
  } catch {
1780
1866
  // Ignore errors during removal (volume may not be fully initialized)
1781
1867
  }
@@ -1784,16 +1870,16 @@ export class OMEZarrNVImage extends NVImage {
1784
1870
  // Add the target volume if not already present
1785
1871
  if (!nv.volumes.includes(targetVolume)) {
1786
1872
  try {
1787
- nv.addVolume(targetVolume);
1873
+ nv.addVolume(targetVolume)
1788
1874
  } catch (err) {
1789
- console.warn("[fidnii] Failed to add volume to NV:", err);
1790
- return;
1875
+ console.warn("[fidnii] Failed to add volume to NV:", err)
1876
+ return
1791
1877
  }
1792
1878
  }
1793
1879
 
1794
1880
  try {
1795
- nv.updateGLVolume();
1796
- this._widenCalRangeIfNeeded(targetVolume);
1881
+ nv.updateGLVolume()
1882
+ this._widenCalRangeIfNeeded(targetVolume)
1797
1883
  } catch {
1798
1884
  // May fail if GL context not ready
1799
1885
  }
@@ -1818,38 +1904,38 @@ export class OMEZarrNVImage extends NVImage {
1818
1904
  worldCoord: [number, number, number],
1819
1905
  trigger: PopulateTrigger,
1820
1906
  ): Promise<void> {
1821
- const slabState = this._slabBuffers.get(sliceType);
1822
- if (!slabState) return;
1907
+ const slabState = this._slabBuffers.get(sliceType)
1908
+ if (!slabState) return
1823
1909
 
1824
1910
  if (slabState.isLoading) {
1825
1911
  // Queue this request (latest wins) — auto-drained when current load finishes
1826
- slabState.pendingReload = { worldCoord, trigger };
1912
+ slabState.pendingReload = { worldCoord, trigger }
1827
1913
  // Abort the in-flight progressive load so it finishes faster
1828
- const controller = this._slabAbortControllers.get(sliceType);
1829
- if (controller) controller.abort();
1830
- return;
1914
+ const controller = this._slabAbortControllers.get(sliceType)
1915
+ if (controller) controller.abort()
1916
+ return
1831
1917
  }
1832
1918
 
1833
- slabState.isLoading = true;
1834
- slabState.pendingReload = null;
1919
+ slabState.isLoading = true
1920
+ slabState.pendingReload = null
1835
1921
 
1836
1922
  // Create an AbortController for this load so it can be cancelled if a
1837
1923
  // newer request arrives while we're still fetching intermediate levels.
1838
- const abortController = new AbortController();
1839
- this._slabAbortControllers.set(sliceType, abortController);
1924
+ const abortController = new AbortController()
1925
+ this._slabAbortControllers.set(sliceType, abortController)
1840
1926
 
1841
1927
  this._emitEvent("slabLoadingStart", {
1842
1928
  sliceType,
1843
1929
  levelIndex: slabState.targetLevelIndex,
1844
1930
  trigger,
1845
- });
1931
+ })
1846
1932
 
1847
1933
  try {
1848
- const orthAxis = this._getOrthogonalAxis(sliceType);
1934
+ const orthAxis = this._getOrthogonalAxis(sliceType)
1849
1935
 
1850
1936
  // Recompute target resolution using 2D pixel budget with per-slab viewport bounds
1851
- const slabViewportBounds = this._viewportBoundsPerSlab.get(sliceType) ??
1852
- undefined;
1937
+ const slabViewportBounds =
1938
+ this._viewportBoundsPerSlab.get(sliceType) ?? undefined
1853
1939
  const selection = select2DResolution(
1854
1940
  this.multiscales,
1855
1941
  this.maxPixels,
@@ -1857,21 +1943,21 @@ export class OMEZarrNVImage extends NVImage {
1857
1943
  this._volumeBounds,
1858
1944
  orthAxis,
1859
1945
  slabViewportBounds,
1860
- );
1861
- slabState.targetLevelIndex = selection.levelIndex;
1946
+ )
1947
+ slabState.targetLevelIndex = selection.levelIndex
1862
1948
 
1863
- const numLevels = this.multiscales.images.length;
1864
- const lowestLevel = numLevels - 1;
1949
+ const numLevels = this.multiscales.images.length
1950
+ const lowestLevel = numLevels - 1
1865
1951
 
1866
1952
  // For viewport-triggered reloads, skip progressive rendering — jump
1867
1953
  // straight to the target level. The user already sees the previous
1868
1954
  // resolution, so a single update is smoother than replaying the full
1869
1955
  // progressive sequence which causes visual flicker during rapid
1870
1956
  // zoom/pan interactions.
1871
- const skipProgressive = trigger === "viewportChanged";
1957
+ const skipProgressive = trigger === "viewportChanged"
1872
1958
  const startLevel = skipProgressive
1873
1959
  ? slabState.targetLevelIndex
1874
- : lowestLevel;
1960
+ : lowestLevel
1875
1961
 
1876
1962
  for (
1877
1963
  let level = startLevel;
@@ -1879,7 +1965,7 @@ export class OMEZarrNVImage extends NVImage {
1879
1965
  level--
1880
1966
  ) {
1881
1967
  // Check if this load has been superseded by a newer request
1882
- if (abortController.signal.aborted) break;
1968
+ if (abortController.signal.aborted) break
1883
1969
 
1884
1970
  await this._loadSlabAtLevel(
1885
1971
  slabState,
@@ -1888,23 +1974,23 @@ export class OMEZarrNVImage extends NVImage {
1888
1974
  level,
1889
1975
  orthAxis,
1890
1976
  trigger,
1891
- );
1977
+ )
1892
1978
 
1893
1979
  // Check again after the async fetch completes
1894
- if (abortController.signal.aborted) break;
1980
+ if (abortController.signal.aborted) break
1895
1981
 
1896
- slabState.levelIndex = level;
1982
+ slabState.levelIndex = level
1897
1983
 
1898
1984
  // Yield to the browser so the current level is actually painted before
1899
1985
  // we start fetching the next (higher-resolution) level.
1900
1986
  if (level > slabState.targetLevelIndex) {
1901
1987
  await new Promise<void>((resolve) =>
1902
- requestAnimationFrame(() => resolve())
1903
- );
1988
+ requestAnimationFrame(() => resolve()),
1989
+ )
1904
1990
  }
1905
1991
  }
1906
1992
  } finally {
1907
- slabState.isLoading = false;
1993
+ slabState.isLoading = false
1908
1994
 
1909
1995
  this._emitEvent("slabLoadingComplete", {
1910
1996
  sliceType,
@@ -1912,11 +1998,11 @@ export class OMEZarrNVImage extends NVImage {
1912
1998
  slabStart: slabState.slabStart,
1913
1999
  slabEnd: slabState.slabEnd,
1914
2000
  trigger,
1915
- });
2001
+ })
1916
2002
 
1917
2003
  // Auto-drain: if a newer request was queued while we were loading,
1918
2004
  // start it now (like populateVolume's handlePendingPopulateRequest).
1919
- this._handlePendingSlabReload(sliceType);
2005
+ this._handlePendingSlabReload(sliceType)
1920
2006
  }
1921
2007
  }
1922
2008
 
@@ -1925,13 +2011,13 @@ export class OMEZarrNVImage extends NVImage {
1925
2011
  * Mirrors populateVolume's handlePendingPopulateRequest pattern.
1926
2012
  */
1927
2013
  private _handlePendingSlabReload(sliceType: SlabSliceType): void {
1928
- const slabState = this._slabBuffers.get(sliceType);
1929
- if (!slabState) return;
2014
+ const slabState = this._slabBuffers.get(sliceType)
2015
+ if (!slabState) return
1930
2016
 
1931
- const pending = slabState.pendingReload;
2017
+ const pending = slabState.pendingReload
1932
2018
  if (pending) {
1933
- slabState.pendingReload = null;
1934
- void this._loadSlab(sliceType, pending.worldCoord, pending.trigger);
2019
+ slabState.pendingReload = null
2020
+ void this._loadSlab(sliceType, pending.worldCoord, pending.trigger)
1935
2021
  }
1936
2022
  }
1937
2023
 
@@ -1946,21 +2032,18 @@ export class OMEZarrNVImage extends NVImage {
1946
2032
  orthAxis: OrthogonalAxis,
1947
2033
  _trigger: PopulateTrigger,
1948
2034
  ): Promise<void> {
1949
- const ngffImage = this.multiscales.images[levelIndex];
1950
- const chunkShape = getChunkShape(ngffImage);
1951
- const volumeShape = getVolumeShape(ngffImage);
2035
+ const ngffImage = this.multiscales.images[levelIndex]
2036
+ const chunkShape = getChunkShape(ngffImage)
2037
+ const volumeShape = getVolumeShape(ngffImage)
1952
2038
 
1953
2039
  // Convert world position to pixel position at this level
1954
- const pixelCoord = worldToPixel(worldCoord, ngffImage);
1955
- const orthPixel = pixelCoord[orthAxis];
2040
+ const pixelCoord = worldToPixel(worldCoord, ngffImage)
2041
+ const orthPixel = pixelCoord[orthAxis]
1956
2042
 
1957
2043
  // Find the chunk-aligned slab in the orthogonal axis
1958
- const chunkSize = chunkShape[orthAxis];
1959
- const slabStart = Math.max(
1960
- 0,
1961
- Math.floor(orthPixel / chunkSize) * chunkSize,
1962
- );
1963
- const slabEnd = Math.min(slabStart + chunkSize, volumeShape[orthAxis]);
2044
+ const chunkSize = chunkShape[orthAxis]
2045
+ const slabStart = Math.max(0, Math.floor(orthPixel / chunkSize) * chunkSize)
2046
+ const slabEnd = Math.min(slabStart + chunkSize, volumeShape[orthAxis])
1964
2047
 
1965
2048
  // Get the full in-plane region (respecting clip planes only).
1966
2049
  // Viewport bounds are intentionally NOT passed here — they are used only
@@ -1971,46 +2054,47 @@ export class OMEZarrNVImage extends NVImage {
1971
2054
  this._clipPlanes,
1972
2055
  this._volumeBounds,
1973
2056
  ngffImage,
1974
- );
1975
- const alignedRegion = alignToChunks(pixelRegion, ngffImage);
2057
+ )
2058
+ const alignedRegion = alignToChunks(pixelRegion, ngffImage)
1976
2059
 
1977
2060
  // Override the orthogonal axis with our slab extent
1978
2061
  const fetchStart: [number, number, number] = [
1979
2062
  alignedRegion.chunkAlignedStart[0],
1980
2063
  alignedRegion.chunkAlignedStart[1],
1981
2064
  alignedRegion.chunkAlignedStart[2],
1982
- ];
2065
+ ]
1983
2066
  const fetchEnd: [number, number, number] = [
1984
2067
  alignedRegion.chunkAlignedEnd[0],
1985
2068
  alignedRegion.chunkAlignedEnd[1],
1986
2069
  alignedRegion.chunkAlignedEnd[2],
1987
- ];
1988
- fetchStart[orthAxis] = slabStart;
1989
- fetchEnd[orthAxis] = slabEnd;
2070
+ ]
2071
+ fetchStart[orthAxis] = slabStart
2072
+ fetchEnd[orthAxis] = slabEnd
1990
2073
 
1991
2074
  const fetchedShape: [number, number, number] = [
1992
2075
  fetchEnd[0] - fetchStart[0],
1993
2076
  fetchEnd[1] - fetchStart[1],
1994
2077
  fetchEnd[2] - fetchStart[2],
1995
- ];
2078
+ ]
1996
2079
 
1997
2080
  // Fetch the data
1998
- const fetchRegion: PixelRegion = { start: fetchStart, end: fetchEnd };
2081
+ const fetchRegion: PixelRegion = { start: fetchStart, end: fetchEnd }
1999
2082
  const result = await this.coalescer.fetchRegion(
2000
2083
  ngffImage,
2001
2084
  levelIndex,
2002
2085
  fetchRegion,
2003
2086
  `slab-${SLICE_TYPE[sliceType]}-${levelIndex}`,
2004
- );
2087
+ )
2005
2088
 
2006
2089
  // Resize buffer and copy data
2007
- const targetData = slabState.bufferManager.resize(fetchedShape);
2008
- targetData.set(result.data);
2009
- slabState.nvImage.img = slabState.bufferManager.getTypedArray() as any;
2090
+ const targetData = slabState.bufferManager.resize(fetchedShape)
2091
+ targetData.set(result.data)
2092
+ slabState.nvImage.img =
2093
+ slabState.bufferManager.getTypedArray() as NVImage["img"]
2010
2094
 
2011
2095
  // Update slab position tracking
2012
- slabState.slabStart = slabStart;
2013
- slabState.slabEnd = slabEnd;
2096
+ slabState.slabStart = slabStart
2097
+ slabState.slabEnd = slabEnd
2014
2098
 
2015
2099
  // Update the NVImage header for this slab region
2016
2100
  this._updateSlabHeader(
@@ -2019,31 +2103,34 @@ export class OMEZarrNVImage extends NVImage {
2019
2103
  fetchStart,
2020
2104
  fetchEnd,
2021
2105
  fetchedShape,
2022
- );
2023
-
2024
- // Apply OMERO metadata if available
2025
- if (this._omero) {
2026
- this._applyOmeroToSlabHeader(slabState.nvImage);
2106
+ )
2107
+
2108
+ if (this.isLabelImage) {
2109
+ // Label images: apply discrete colormap to the slab NVImage
2110
+ this._applyLabelColormap(slabState.nvImage, result.data)
2111
+ } else if (this._omero) {
2112
+ // Apply OMERO metadata if available
2113
+ this._applyOmeroToSlabHeader(slabState.nvImage)
2027
2114
  }
2028
2115
 
2029
2116
  // Reset global_min so NiiVue recalculates intensity ranges
2030
- slabState.nvImage.global_min = undefined;
2117
+ slabState.nvImage.global_min = undefined
2031
2118
 
2032
2119
  // Compute the normalization scale used by _updateSlabHeader so we can
2033
2120
  // convert the world coordinate into the slab's normalized mm space.
2034
- const scale = ngffImage.scale;
2121
+ const scale = ngffImage.scale
2035
2122
  const maxVoxelSize = Math.max(
2036
2123
  scale.x ?? scale.X ?? 1,
2037
2124
  scale.y ?? scale.Y ?? 1,
2038
2125
  scale.z ?? scale.Z ?? 1,
2039
- );
2040
- const normalizationScale = maxVoxelSize > 0 ? 1.0 / maxVoxelSize : 1.0;
2041
- slabState.normalizationScale = normalizationScale;
2126
+ )
2127
+ const normalizationScale = maxVoxelSize > 0 ? 1.0 / maxVoxelSize : 1.0
2128
+ slabState.normalizationScale = normalizationScale
2042
2129
  const normalizedMM: [number, number, number] = [
2043
2130
  worldCoord[0] * normalizationScale,
2044
2131
  worldCoord[1] * normalizationScale,
2045
2132
  worldCoord[2] * normalizationScale,
2046
- ];
2133
+ ]
2047
2134
 
2048
2135
  // Refresh all NV instances using this slice type
2049
2136
  for (const [attachedNv, attachedState] of this._attachedNiivues) {
@@ -2053,24 +2140,26 @@ export class OMEZarrNVImage extends NVImage {
2053
2140
  ) {
2054
2141
  // Ensure this NV has the slab volume
2055
2142
  if (attachedNv.volumes.includes(slabState.nvImage)) {
2056
- attachedNv.updateGLVolume();
2143
+ attachedNv.updateGLVolume()
2057
2144
 
2058
- // Widen the display window if actual data exceeds the OMERO range.
2059
- // Must run after updateGLVolume() which computes global_min/global_max.
2060
- this._widenCalRangeIfNeeded(slabState.nvImage);
2145
+ if (!this.isLabelImage) {
2146
+ // Widen the display window if actual data exceeds the OMERO range.
2147
+ // Must run after updateGLVolume() which computes global_min/global_max.
2148
+ this._widenCalRangeIfNeeded(slabState.nvImage)
2149
+ }
2061
2150
 
2062
2151
  // Position the crosshair at the correct slice within this slab.
2063
2152
  // Without this, NiiVue defaults to the center of the slab which
2064
2153
  // corresponds to different physical positions at each resolution level.
2065
- const frac = attachedNv.mm2frac(normalizedMM);
2154
+ const frac = attachedNv.mm2frac(normalizedMM)
2066
2155
  // Clamp to [0,1] — when viewport-aware mode constrains the slab to
2067
2156
  // a subregion, the crosshair world position may be outside the slab's
2068
2157
  // spatial extent, causing mm2frac to return out-of-range values.
2069
- frac[0] = Math.max(0, Math.min(1, frac[0]));
2070
- frac[1] = Math.max(0, Math.min(1, frac[1]));
2071
- frac[2] = Math.max(0, Math.min(1, frac[2]));
2072
- attachedNv.scene.crosshairPos = frac;
2073
- attachedNv.drawScene();
2158
+ frac[0] = Math.max(0, Math.min(1, frac[0]))
2159
+ frac[1] = Math.max(0, Math.min(1, frac[1]))
2160
+ frac[2] = Math.max(0, Math.min(1, frac[2]))
2161
+ attachedNv.scene.crosshairPos = frac
2162
+ attachedNv.drawScene()
2074
2163
  }
2075
2164
  }
2076
2165
  }
@@ -2086,25 +2175,25 @@ export class OMEZarrNVImage extends NVImage {
2086
2175
  _fetchEnd: [number, number, number],
2087
2176
  fetchedShape: [number, number, number],
2088
2177
  ): void {
2089
- if (!nvImage.hdr) return;
2178
+ if (!nvImage.hdr) return
2090
2179
 
2091
- const scale = ngffImage.scale;
2092
- const sx = scale.x ?? scale.X ?? 1;
2093
- const sy = scale.y ?? scale.Y ?? 1;
2094
- const sz = scale.z ?? scale.Z ?? 1;
2180
+ const scale = ngffImage.scale
2181
+ const sx = scale.x ?? scale.X ?? 1
2182
+ const sy = scale.y ?? scale.Y ?? 1
2183
+ const sz = scale.z ?? scale.Z ?? 1
2095
2184
 
2096
2185
  // NiiVue's 2D slice renderer has precision issues when voxel sizes are
2097
2186
  // very small (e.g. OME-Zarr datasets in meters where pixDims ~ 2e-5).
2098
2187
  // Since the slab NVImage is rendered independently in its own Niivue
2099
2188
  // instance, we can normalize coordinates to ~1mm voxels without affecting
2100
2189
  // the 3D render. We scale uniformly to preserve aspect ratio.
2101
- const maxVoxelSize = Math.max(sx, sy, sz);
2102
- const normalizationScale = maxVoxelSize > 0 ? 1.0 / maxVoxelSize : 1.0;
2103
- const nsx = sx * normalizationScale;
2104
- const nsy = sy * normalizationScale;
2105
- const nsz = sz * normalizationScale;
2190
+ const maxVoxelSize = Math.max(sx, sy, sz)
2191
+ const normalizationScale = maxVoxelSize > 0 ? 1.0 / maxVoxelSize : 1.0
2192
+ const nsx = sx * normalizationScale
2193
+ const nsy = sy * normalizationScale
2194
+ const nsz = sz * normalizationScale
2106
2195
 
2107
- nvImage.hdr.pixDims = [1, nsx, nsy, nsz, 0, 0, 0, 0];
2196
+ nvImage.hdr.pixDims = [1, nsx, nsy, nsz, 0, 0, 0, 0]
2108
2197
  // NIfTI dims: [ndim, x, y, z, t, ...]
2109
2198
  nvImage.hdr.dims = [
2110
2199
  3,
@@ -2115,51 +2204,51 @@ export class OMEZarrNVImage extends NVImage {
2115
2204
  1,
2116
2205
  1,
2117
2206
  1,
2118
- ];
2207
+ ]
2119
2208
 
2120
2209
  // Build affine with offset for region start, then normalize
2121
- const affine = createAffineFromNgffImage(ngffImage);
2210
+ const affine = createAffineFromNgffImage(ngffImage)
2122
2211
  // Adjust translation for region offset (fetchStart is [z, y, x])
2123
- affine[12] += fetchStart[2] * sx; // x offset
2124
- affine[13] += fetchStart[1] * sy; // y offset
2125
- affine[14] += fetchStart[0] * sz; // z offset
2212
+ affine[12] += fetchStart[2] * sx // x offset
2213
+ affine[13] += fetchStart[1] * sy // y offset
2214
+ affine[14] += fetchStart[0] * sz // z offset
2126
2215
 
2127
2216
  // Apply normalization to the entire affine (scale columns + translation)
2128
2217
  for (let i = 0; i < 15; i++) {
2129
- affine[i] *= normalizationScale;
2218
+ affine[i] *= normalizationScale
2130
2219
  }
2131
2220
  // affine[15] stays 1
2132
2221
 
2133
- const srows = affineToNiftiSrows(affine);
2222
+ const srows = affineToNiftiSrows(affine)
2134
2223
  nvImage.hdr.affine = [
2135
2224
  srows.srow_x,
2136
2225
  srows.srow_y,
2137
2226
  srows.srow_z,
2138
2227
  [0, 0, 0, 1],
2139
- ];
2228
+ ]
2140
2229
 
2141
- nvImage.hdr.sform_code = 1;
2142
- nvImage.calculateRAS();
2230
+ nvImage.hdr.sform_code = 1
2231
+ nvImage.calculateRAS()
2143
2232
  }
2144
2233
 
2145
2234
  /**
2146
2235
  * Apply OMERO metadata to a slab NVImage header.
2147
2236
  */
2148
2237
  private _applyOmeroToSlabHeader(nvImage: NVImage): void {
2149
- if (!nvImage.hdr || !this._omero?.channels?.length) return;
2238
+ if (!nvImage.hdr || !this._omero?.channels?.length) return
2150
2239
 
2151
2240
  const channelIndex = Math.min(
2152
2241
  this._activeChannel,
2153
2242
  this._omero.channels.length - 1,
2154
- );
2155
- const channel = this._omero.channels[channelIndex];
2156
- const window = channel?.window;
2243
+ )
2244
+ const channel = this._omero.channels[channelIndex]
2245
+ const window = channel?.window
2157
2246
 
2158
2247
  if (window) {
2159
- const calMin = window.start ?? window.min;
2160
- const calMax = window.end ?? window.max;
2161
- if (calMin !== undefined) nvImage.hdr.cal_min = calMin;
2162
- if (calMax !== undefined) nvImage.hdr.cal_max = calMax;
2248
+ const calMin = window.start ?? window.min
2249
+ const calMax = window.end ?? window.max
2250
+ if (calMin !== undefined) nvImage.hdr.cal_min = calMin
2251
+ if (calMax !== undefined) nvImage.hdr.cal_max = calMax
2163
2252
  }
2164
2253
  }
2165
2254
 
@@ -2184,31 +2273,25 @@ export class OMEZarrNVImage extends NVImage {
2184
2273
  */
2185
2274
  private _widenCalRangeIfNeeded(nvImage: NVImage): boolean {
2186
2275
  if (nvImage.global_min === undefined || nvImage.global_max === undefined) {
2187
- return false;
2276
+ return false
2188
2277
  }
2189
2278
 
2190
- let widened = false;
2279
+ let widened = false
2191
2280
 
2192
2281
  // Widen the runtime display range (cal_min/cal_max) to encompass the
2193
2282
  // actual data extremes (global_min/global_max) at this resolution level.
2194
2283
  // The hdr values are NOT modified so the original OMERO window is
2195
2284
  // preserved for next reload.
2196
- if (
2197
- nvImage.cal_max !== undefined &&
2198
- nvImage.global_max > nvImage.cal_max
2199
- ) {
2200
- nvImage.cal_max = nvImage.global_max;
2201
- widened = true;
2285
+ if (nvImage.cal_max !== undefined && nvImage.global_max > nvImage.cal_max) {
2286
+ nvImage.cal_max = nvImage.global_max
2287
+ widened = true
2202
2288
  }
2203
- if (
2204
- nvImage.cal_min !== undefined &&
2205
- nvImage.global_min < nvImage.cal_min
2206
- ) {
2207
- nvImage.cal_min = nvImage.global_min;
2208
- widened = true;
2289
+ if (nvImage.cal_min !== undefined && nvImage.global_min < nvImage.cal_min) {
2290
+ nvImage.cal_min = nvImage.global_min
2291
+ widened = true
2209
2292
  }
2210
2293
 
2211
- return widened;
2294
+ return widened
2212
2295
  }
2213
2296
 
2214
2297
  // ============================================================
@@ -2242,11 +2325,7 @@ export class OMEZarrNVImage extends NVImage {
2242
2325
  listener: OMEZarrNVImageEventListener<K>,
2243
2326
  options?: OMEZarrNVImageEventListenerOptions,
2244
2327
  ): void {
2245
- this._eventTarget.addEventListener(
2246
- type,
2247
- listener as EventListener,
2248
- options,
2249
- );
2328
+ this._eventTarget.addEventListener(type, listener as EventListener, options)
2250
2329
  }
2251
2330
 
2252
2331
  /**
@@ -2265,7 +2344,7 @@ export class OMEZarrNVImage extends NVImage {
2265
2344
  type,
2266
2345
  listener as EventListener,
2267
2346
  options,
2268
- );
2347
+ )
2269
2348
  }
2270
2349
 
2271
2350
  /**
@@ -2277,10 +2356,10 @@ export class OMEZarrNVImage extends NVImage {
2277
2356
  detail: OMEZarrNVImageEventMap[K],
2278
2357
  ): void {
2279
2358
  try {
2280
- const event = new OMEZarrNVImageEvent(eventName, detail);
2281
- this._eventTarget.dispatchEvent(event);
2359
+ const event = new OMEZarrNVImageEvent(eventName, detail)
2360
+ this._eventTarget.dispatchEvent(event)
2282
2361
  } catch (error) {
2283
- console.error(`Error in ${eventName} event listener:`, error);
2362
+ console.error(`Error in ${eventName} event listener:`, error)
2284
2363
  }
2285
2364
  }
2286
2365
  }