@fideus-labs/fidnii 0.1.0

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