@fideus-labs/fidnii 0.5.0 → 0.7.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.
@@ -13,8 +13,8 @@ import { RegionCoalescer } from "./RegionCoalescer.js";
13
13
  import { getChunkShape, getVolumeShape, select2DResolution, selectResolution, } from "./ResolutionSelector.js";
14
14
  import { getBytesPerPixel, getChannelInfo, getNiftiDataType, getRGBNiftiDataType, isRGBImage, NiftiDataType, needsRGBNormalization, parseZarritaDtype, } from "./types.js";
15
15
  import { affineToNiftiSrows, calculateWorldBounds, createAffineFromNgffImage, createAffineFromOMEZarr, } from "./utils/affine.js";
16
- import { worldToPixel } from "./utils/coordinates.js";
17
- import { applyOrientationToAffine, getOrientationMapping, getOrientationSigns, } from "./utils/orientation.js";
16
+ import { worldToPixelAffine } from "./utils/coordinates.js";
17
+ import { getOrientationMapping } from "./utils/orientation.js";
18
18
  import { boundsApproxEqual, computeViewportBounds2D, computeViewportBounds3D, } from "./ViewportBounds.js";
19
19
  const DEFAULT_MAX_PIXELS = 50_000_000;
20
20
  const DEFAULT_MAX_CACHE_ENTRIES = 200;
@@ -41,6 +41,29 @@ export class OMEZarrNVImage extends NVImage {
41
41
  * OMERO intensity windowing is skipped.
42
42
  */
43
43
  isLabelImage;
44
+ // ============================================================
45
+ // Colormap Override
46
+ // ============================================================
47
+ /**
48
+ * The continuous colormap name used for rendering.
49
+ *
50
+ * Overrides the NVImage setter so that changing the colormap on this
51
+ * image automatically propagates to all slab (2D slice) NVImages.
52
+ * Label images are unaffected — they use `setColormapLabel()` instead.
53
+ */
54
+ get colormap() {
55
+ return this._colormap;
56
+ }
57
+ set colormap(cm) {
58
+ // Use NVImage's setter (calls calMinMax + onColormapChange)
59
+ super.colormap = cm;
60
+ // Propagate to all existing slab NVImages
61
+ if (!this.isLabelImage) {
62
+ for (const slab of this._slabBuffers.values()) {
63
+ slab.nvImage.colormap = cm;
64
+ }
65
+ }
66
+ }
44
67
  /** Reference to NiiVue instance */
45
68
  niivue;
46
69
  /** Buffer manager for dynamically-sized pixel data */
@@ -78,8 +101,8 @@ export class OMEZarrNVImage extends NVImage {
78
101
  _volumeBounds;
79
102
  /** Current buffer bounds in world space (may differ from full volume when clipped) */
80
103
  _currentBufferBounds;
81
- /** Previous clip plane change handler (to restore later) */
82
- previousOnClipPlaneChange;
104
+ /** AbortController for the clip plane event listener on the primary NV */
105
+ _clipPlaneAbortController;
83
106
  /** Debounce delay for clip plane updates (ms) */
84
107
  clipPlaneDebounceMs;
85
108
  /** Timeout handle for debounced clip plane refetch */
@@ -98,6 +121,12 @@ export class OMEZarrNVImage extends NVImage {
98
121
  _eventTarget = new EventTarget();
99
122
  /** Pending populate request (latest wins - replaces any previous pending) */
100
123
  _pendingPopulateRequest = null;
124
+ /**
125
+ * AbortController for the in-flight `populateVolume` fetch.
126
+ * Aborted when a new `populateVolume` call supersedes the current one,
127
+ * allowing in-flight HTTP requests to be cancelled promptly.
128
+ */
129
+ _populateAbortController = null;
101
130
  /** Current populate trigger (set at start of populateVolume, used by events) */
102
131
  _currentPopulateTrigger = "initial";
103
132
  // ============================================================
@@ -129,6 +158,34 @@ export class OMEZarrNVImage extends NVImage {
129
158
  /** Per-slab AbortController to cancel in-flight progressive loads */
130
159
  _slabAbortControllers = new Map();
131
160
  // ============================================================
161
+ // Time Dimension State
162
+ // ============================================================
163
+ /**
164
+ * Time axis metadata, or `null` if the dataset has no `"t"` dimension.
165
+ * Populated during construction by inspecting `NgffImage.dims`.
166
+ */
167
+ _timeAxisInfo = null;
168
+ /** Current time index (0-based). Always 0 for non-time datasets. */
169
+ _timeIndex = 0;
170
+ /** Number of adjacent time frames to pre-fetch in each direction. */
171
+ _timePrefetchCount;
172
+ /**
173
+ * LRU cache of pre-fetched 3D time frames, keyed by time index.
174
+ * Only used when `_timeAxisInfo` is non-null.
175
+ */
176
+ _timeFrameCache;
177
+ /** Set of time indices currently being pre-fetched (for dedup). */
178
+ _prefetchingTimeIndices = new Set();
179
+ /** AbortController for the most recent pre-fetch batch. */
180
+ _prefetchAbortController = null;
181
+ /**
182
+ * Snapshot of the chunk-aligned region and resolution level used for
183
+ * the last successful 3D volume load. Used to serve cached time frames
184
+ * at the same spatial region. Cleared on clip plane / viewport / resolution
185
+ * changes.
186
+ */
187
+ _lastLoadedRegion = null;
188
+ // ============================================================
132
189
  // 3D Zoom Override
133
190
  // ============================================================
134
191
  /** Maximum 3D render zoom level for scroll-wheel zoom */
@@ -141,6 +198,8 @@ export class OMEZarrNVImage extends NVImage {
141
198
  * continuous zoom/pan interactions.
142
199
  */
143
200
  static VIEWPORT_DEBOUNCE_MS = 500;
201
+ /** Default number of adjacent time frames to pre-fetch. */
202
+ static DEFAULT_TIME_PREFETCH_COUNT = 2;
144
203
  /**
145
204
  * Private constructor. Use OMEZarrNVImage.create() for instantiation.
146
205
  */
@@ -181,6 +240,34 @@ export class OMEZarrNVImage extends NVImage {
181
240
  // Detect 2D images (no z axis) and store y-flip preference
182
241
  this._is2D = highResImage.dims.indexOf("z") === -1;
183
242
  this._flipY2D = options.flipY2D ?? true;
243
+ // Detect time dimension from the zarr axes
244
+ const tDimIndex = highResImage.dims.indexOf("t");
245
+ if (tDimIndex !== -1) {
246
+ const timeCount = highResImage.data.shape[tDimIndex];
247
+ if (timeCount > 0) {
248
+ // Look up time unit from axes metadata
249
+ const timeAxis = this.multiscales.metadata?.axes?.find((a) => a.name === "t");
250
+ this._timeAxisInfo = {
251
+ count: timeCount,
252
+ dimIndex: tDimIndex,
253
+ step: highResImage.scale.t ?? 1,
254
+ origin: highResImage.translation.t ?? 0,
255
+ unit: timeAxis?.unit,
256
+ };
257
+ }
258
+ }
259
+ // Initialize time state
260
+ this._timePrefetchCount =
261
+ options.timePrefetchCount ?? OMEZarrNVImage.DEFAULT_TIME_PREFETCH_COUNT;
262
+ const defaultTimeIndex = options.timeIndex ?? 0;
263
+ this._timeIndex = this._timeAxisInfo
264
+ ? Math.max(0, Math.min(defaultTimeIndex, this._timeAxisInfo.count - 1))
265
+ : 0;
266
+ // Time frame cache: capacity = current frame + both directions + small buffer
267
+ const cacheCapacity = 2 * this._timePrefetchCount + 3;
268
+ this._timeFrameCache = new LRUCache({
269
+ max: cacheCapacity,
270
+ });
184
271
  // Calculate volume bounds from highest resolution for most accurate bounds.
185
272
  // Use the unadjusted affine (no orientation signs) because volume bounds
186
273
  // live in OME-Zarr world space and drive internal clip-plane / viewport math.
@@ -218,16 +305,12 @@ export class OMEZarrNVImage extends NVImage {
218
305
  */
219
306
  static async create(options) {
220
307
  const image = new OMEZarrNVImage(options);
221
- // Store and replace the clip plane change handler
222
- image.previousOnClipPlaneChange = image.niivue.onClipPlaneChange;
223
- image.niivue.onClipPlaneChange = (clipPlane) => {
224
- // Call original handler if it exists
225
- if (image.previousOnClipPlaneChange) {
226
- image.previousOnClipPlaneChange(clipPlane);
227
- }
228
- // Handle clip plane change
229
- image.onNiivueClipPlaneChange(clipPlane);
230
- };
308
+ // Listen for clip plane changes via the browser-native event API
309
+ const clipPlaneController = new AbortController();
310
+ image._clipPlaneAbortController = clipPlaneController;
311
+ image.niivue.addEventListener("clipPlaneChange", (e) => {
312
+ image.onNiivueClipPlaneChange(e.detail.clipPlane);
313
+ }, { signal: clipPlaneController.signal });
231
314
  // Auto-attach the primary NV instance for slice type / location tracking
232
315
  image.attachNiivue(image.niivue);
233
316
  // Auto-load by default (add to NiiVue + start progressive loading)
@@ -318,8 +401,14 @@ export class OMEZarrNVImage extends NVImage {
318
401
  }
319
402
  // Queue this request (no event - just queuing)
320
403
  this._pendingPopulateRequest = { skipPreview, trigger };
404
+ // Abort the in-flight fetch so the queued request runs sooner
405
+ this._populateAbortController?.abort();
321
406
  return;
322
407
  }
408
+ // Abort any lingering controller from a previous run (defensive)
409
+ this._populateAbortController?.abort();
410
+ const abortController = new AbortController();
411
+ this._populateAbortController = abortController;
323
412
  this.isLoading = true;
324
413
  this._currentPopulateTrigger = trigger;
325
414
  this._pendingPopulateRequest = null; // Clear any stale pending request
@@ -328,7 +417,9 @@ export class OMEZarrNVImage extends NVImage {
328
417
  const lowestLevel = numLevels - 1;
329
418
  // Quick preview from lowest resolution (if different from target and not skipped)
330
419
  if (!skipPreview && lowestLevel !== this.targetLevelIndex) {
331
- await this.loadResolutionLevel(lowestLevel, "preview");
420
+ await this.loadResolutionLevel(lowestLevel, "preview", undefined, abortController.signal);
421
+ if (abortController.signal.aborted)
422
+ return;
332
423
  const prevLevel = this.currentLevelIndex;
333
424
  this.currentLevelIndex = lowestLevel;
334
425
  // Emit resolutionChange for preview load
@@ -342,7 +433,9 @@ export class OMEZarrNVImage extends NVImage {
342
433
  }
343
434
  }
344
435
  // Final quality at target resolution
345
- await this.loadResolutionLevel(this.targetLevelIndex, "target");
436
+ await this.loadResolutionLevel(this.targetLevelIndex, "target", undefined, abortController.signal);
437
+ if (abortController.signal.aborted)
438
+ return;
346
439
  const prevLevelBeforeTarget = this.currentLevelIndex;
347
440
  this.currentLevelIndex = this.targetLevelIndex;
348
441
  // Emit resolutionChange for target load
@@ -364,6 +457,7 @@ export class OMEZarrNVImage extends NVImage {
364
457
  }
365
458
  finally {
366
459
  this.isLoading = false;
460
+ this._populateAbortController = null;
367
461
  this.handlePendingPopulateRequest();
368
462
  }
369
463
  }
@@ -385,6 +479,11 @@ export class OMEZarrNVImage extends NVImage {
385
479
  targetLevel: this.targetLevelIndex,
386
480
  trigger: this._currentPopulateTrigger,
387
481
  });
482
+ // Kick off pre-fetching of adjacent time frames now that we have a
483
+ // stable spatial region + resolution level.
484
+ if (this._timeAxisInfo && this._timeAxisInfo.count > 1) {
485
+ this._prefetchAdjacentFrames(this._timeIndex);
486
+ }
388
487
  }
389
488
  /**
390
489
  * Load data at a specific resolution level.
@@ -397,8 +496,11 @@ export class OMEZarrNVImage extends NVImage {
397
496
  *
398
497
  * @param levelIndex - Resolution level index
399
498
  * @param requesterId - ID for request coalescing
499
+ * @param timeIndex - Time point index to fetch (defaults to `this._timeIndex`)
500
+ * @param signal - Optional AbortSignal to cancel the fetch
400
501
  */
401
- async loadResolutionLevel(levelIndex, requesterId) {
502
+ async loadResolutionLevel(levelIndex, requesterId, timeIndex, signal) {
503
+ const effectiveTimeIndex = timeIndex ?? this._timeIndex;
402
504
  // Emit loadingStart event
403
505
  this._emitEvent("loadingStart", {
404
506
  levelIndex,
@@ -419,7 +521,7 @@ export class OMEZarrNVImage extends NVImage {
419
521
  start: alignedRegion.chunkAlignedStart,
420
522
  end: alignedRegion.chunkAlignedEnd,
421
523
  };
422
- const result = await this.coalescer.fetchRegion(ngffImage, levelIndex, fetchRegion, requesterId);
524
+ const result = await this.coalescer.fetchRegion(ngffImage, levelIndex, fetchRegion, requesterId, effectiveTimeIndex, signal);
423
525
  // Resize buffer to match fetched data exactly (no upsampling!)
424
526
  const targetData = this.bufferManager.resize(fetchedShape);
425
527
  // For non-uint8 RGB/RGBA, we need OMERO metadata *before* copying
@@ -442,6 +544,9 @@ export class OMEZarrNVImage extends NVImage {
442
544
  this.img = this.bufferManager.getTypedArray();
443
545
  // Update NVImage header with correct dimensions and transforms
444
546
  this.updateHeaderForRegion(ngffImage, alignedRegion, fetchedShape);
547
+ // Snapshot the loaded region so time frame pre-fetch uses the same
548
+ // spatial region / resolution level.
549
+ this._lastLoadedRegion = { region: alignedRegion, levelIndex };
445
550
  if (this.isLabelImage) {
446
551
  // Label images: apply a discrete colormap instead of OMERO windowing
447
552
  this._applyLabelColormap(this, result.data);
@@ -508,27 +613,36 @@ export class OMEZarrNVImage extends NVImage {
508
613
  1,
509
614
  1,
510
615
  ];
511
- // Build the unadjusted affine (no orientation signs) and offset it
512
- // to the loaded region. Buffer bounds use this OME-Zarr-space affine
513
- // for internal clip-plane / viewport math.
616
+ // Compute buffer bounds in un-oriented OME-Zarr world space.
617
+ // These drive clip-plane / viewport math and must stay un-oriented.
514
618
  const regionStart = region.chunkAlignedStart;
515
- const affine = createAffineFromOMEZarr(ngffImage.scale, ngffImage.translation);
516
- // regionStart is [z, y, x], affine translation is [x, y, z] (indices 12, 13, 14)
517
- affine[12] += regionStart[2] * sx; // x offset
518
- affine[13] += regionStart[1] * sy; // y offset
519
- affine[14] += regionStart[0] * sz; // z offset
520
- // Update current buffer bounds in OME-Zarr world space
619
+ const translation = ngffImage.translation;
620
+ const tx = (translation.x ?? translation.X ?? 0) + regionStart[2] * sx;
621
+ const ty = (translation.y ?? translation.Y ?? 0) + regionStart[1] * sy;
622
+ const tz = (translation.z ?? translation.Z ?? 0) + regionStart[0] * sz;
521
623
  this._currentBufferBounds = {
522
- min: [affine[12], affine[13], affine[14]],
624
+ min: [tx, ty, tz],
523
625
  max: [
524
- affine[12] + fetchedShape[2] * sx,
525
- affine[13] + fetchedShape[1] * sy,
526
- affine[14] + fetchedShape[0] * sz,
626
+ tx + fetchedShape[2] * sx,
627
+ ty + fetchedShape[1] * sy,
628
+ tz + fetchedShape[0] * sz,
527
629
  ],
528
630
  };
529
- // Apply orientation signs so the NIfTI affine encodes anatomical
530
- // direction for NiiVue's calculateRAS()
531
- applyOrientationToAffine(affine, ngffImage.axesOrientations);
631
+ // Build the fully oriented affine (including orientation permutation
632
+ // and sign flips), then apply the region offset in world space.
633
+ // The offset goes through the oriented 3x3 rotation matrix so it
634
+ // lands on the correct world axis even when NGFF axes are permuted.
635
+ const affine = createAffineFromNgffImage(ngffImage);
636
+ // regionStart is [z, y, x]; affine columns map NIfTI [i=x, j=y, k=z]
637
+ const offsetX = regionStart[2]; // NIfTI i = NGFF x
638
+ const offsetY = regionStart[1]; // NIfTI j = NGFF y
639
+ const offsetZ = regionStart[0]; // NIfTI k = NGFF z
640
+ affine[12] +=
641
+ affine[0] * offsetX + affine[4] * offsetY + affine[8] * offsetZ;
642
+ affine[13] +=
643
+ affine[1] * offsetX + affine[5] * offsetY + affine[9] * offsetZ;
644
+ affine[14] +=
645
+ affine[2] * offsetX + affine[6] * offsetY + affine[10] * offsetZ;
532
646
  // For 2D images, flip y so NiiVue's calculateRAS() accounts for
533
647
  // top-to-bottom pixel storage order. We shift the translation so
534
648
  // the last row maps to where the first row was, then negate the
@@ -786,6 +900,8 @@ export class OMEZarrNVImage extends NVImage {
786
900
  // Visual clipping is handled by NiiVue clip planes (already updated in setClipPlanes)
787
901
  if (newTargetLevel !== this.targetLevelIndex) {
788
902
  this.targetLevelIndex = newTargetLevel;
903
+ // Spatial region changed — cached time frames are stale
904
+ this._invalidateTimeFrameCache();
789
905
  this.populateVolume(true, "clipPlanesChanged"); // Skip preview for clip plane updates
790
906
  }
791
907
  // Emit clipPlanesChange event (after debounce)
@@ -905,6 +1021,202 @@ export class OMEZarrNVImage extends NVImage {
905
1021
  };
906
1022
  }
907
1023
  // ============================================================
1024
+ // Time Navigation
1025
+ // ============================================================
1026
+ /**
1027
+ * Time axis metadata, or `null` if the dataset has no `"t"` dimension.
1028
+ *
1029
+ * @example
1030
+ * ```ts
1031
+ * const info = image.timeAxisInfo
1032
+ * if (info) {
1033
+ * console.log(`${info.count} time points, step=${info.step} ${info.unit}`)
1034
+ * }
1035
+ * ```
1036
+ */
1037
+ get timeAxisInfo() {
1038
+ return this._timeAxisInfo;
1039
+ }
1040
+ /**
1041
+ * Total number of time points.
1042
+ * Returns 1 for datasets without a `"t"` dimension.
1043
+ */
1044
+ get timeCount() {
1045
+ return this._timeAxisInfo?.count ?? 1;
1046
+ }
1047
+ /**
1048
+ * Current time index (0-based).
1049
+ * Always 0 for datasets without a `"t"` dimension.
1050
+ */
1051
+ get timeIndex() {
1052
+ return this._timeIndex;
1053
+ }
1054
+ /**
1055
+ * Compute the physical time value at a given index.
1056
+ *
1057
+ * @param index - Time index (0-based)
1058
+ * @returns Physical time value (`origin + index * step`)
1059
+ */
1060
+ getTimeValue(index) {
1061
+ if (!this._timeAxisInfo)
1062
+ return 0;
1063
+ return this._timeAxisInfo.origin + index * this._timeAxisInfo.step;
1064
+ }
1065
+ /**
1066
+ * Set the active time index and reload the volume.
1067
+ *
1068
+ * If the requested frame is in the pre-fetch cache, the buffer is
1069
+ * swapped instantly without a network fetch. Otherwise, the frame is
1070
+ * loaded from the zarr store at the current resolution level and
1071
+ * spatial region.
1072
+ *
1073
+ * After the frame is loaded, adjacent frames are pre-fetched in the
1074
+ * background so subsequent scrubbing can serve frames from cache.
1075
+ *
1076
+ * @param index - Time index (0-based)
1077
+ * @throws If `index` is out of range `[0, timeCount)`
1078
+ *
1079
+ * @example
1080
+ * ```ts
1081
+ * await image.setTimeIndex(5)
1082
+ * image.addEventListener('timeChange', (e) => {
1083
+ * console.log(`Frame ${e.detail.index}, cached=${e.detail.cached}`)
1084
+ * })
1085
+ * ```
1086
+ */
1087
+ async setTimeIndex(index) {
1088
+ if (!this._timeAxisInfo) {
1089
+ if (index !== 0) {
1090
+ throw new Error(`Cannot set time index ${index}: dataset has no time dimension`);
1091
+ }
1092
+ return;
1093
+ }
1094
+ if (index < 0 || index >= this._timeAxisInfo.count) {
1095
+ throw new Error(`Time index ${index} out of range [0, ${this._timeAxisInfo.count})`);
1096
+ }
1097
+ const previousIndex = this._timeIndex;
1098
+ if (index === previousIndex)
1099
+ return;
1100
+ this._timeIndex = index;
1101
+ // Try the pre-fetch cache first
1102
+ const cached = this._timeFrameCache.get(index);
1103
+ if (cached && cached.levelIndex === this.currentLevelIndex) {
1104
+ // Cache hit: instant buffer swap
1105
+ const targetData = this.bufferManager.resize(cached.shape);
1106
+ targetData.set(cached.data);
1107
+ this.img = this.bufferManager.getTypedArray();
1108
+ this.updateHeaderForRegion(this.multiscales.images[cached.levelIndex], cached.region, cached.shape);
1109
+ this.global_min = undefined;
1110
+ this.niivue.updateGLVolume();
1111
+ this._emitEvent("timeChange", {
1112
+ index,
1113
+ timeValue: this.getTimeValue(index),
1114
+ previousIndex,
1115
+ cached: true,
1116
+ });
1117
+ }
1118
+ else {
1119
+ // Cache miss: full load at current resolution + region
1120
+ await this.populateVolume(true, "initial");
1121
+ this._emitEvent("timeChange", {
1122
+ index,
1123
+ timeValue: this.getTimeValue(index),
1124
+ previousIndex,
1125
+ cached: false,
1126
+ });
1127
+ }
1128
+ // Pre-fetch adjacent frames in the background
1129
+ this._prefetchAdjacentFrames(index);
1130
+ }
1131
+ /**
1132
+ * Clear the pre-fetched time frame cache.
1133
+ *
1134
+ * Called internally when the spatial region or resolution changes
1135
+ * (clip planes, viewport, resolution level), since cached frames
1136
+ * were fetched for the previous region and are no longer valid.
1137
+ */
1138
+ _invalidateTimeFrameCache() {
1139
+ this._timeFrameCache.clear();
1140
+ this._lastLoadedRegion = null;
1141
+ // Cancel any in-flight pre-fetches
1142
+ if (this._prefetchAbortController) {
1143
+ this._prefetchAbortController.abort();
1144
+ this._prefetchAbortController = null;
1145
+ }
1146
+ this._prefetchingTimeIndices.clear();
1147
+ }
1148
+ /**
1149
+ * Pre-fetch adjacent time frames in the background.
1150
+ *
1151
+ * Fetches frames `[index - N, index + N]` (clamped to valid range)
1152
+ * at the current resolution level and spatial region. Already-cached
1153
+ * and currently-in-flight indices are skipped.
1154
+ *
1155
+ * @param centerIndex - The time index to pre-fetch around
1156
+ */
1157
+ _prefetchAdjacentFrames(centerIndex) {
1158
+ if (!this._timeAxisInfo || this._timePrefetchCount <= 0)
1159
+ return;
1160
+ if (!this._lastLoadedRegion)
1161
+ return;
1162
+ // Cancel any previous pre-fetch batch
1163
+ if (this._prefetchAbortController) {
1164
+ this._prefetchAbortController.abort();
1165
+ }
1166
+ const abortController = new AbortController();
1167
+ this._prefetchAbortController = abortController;
1168
+ const { region, levelIndex } = this._lastLoadedRegion;
1169
+ const ngffImage = this.multiscales.images[levelIndex];
1170
+ // Collect indices to pre-fetch
1171
+ const indices = [];
1172
+ for (let delta = 1; delta <= this._timePrefetchCount; delta++) {
1173
+ const before = centerIndex - delta;
1174
+ const after = centerIndex + delta;
1175
+ if (before >= 0)
1176
+ indices.push(before);
1177
+ if (after < this._timeAxisInfo.count)
1178
+ indices.push(after);
1179
+ }
1180
+ // Filter out already cached and in-flight indices
1181
+ const toFetch = indices.filter((i) => !this._timeFrameCache.has(i) && !this._prefetchingTimeIndices.has(i));
1182
+ if (toFetch.length === 0)
1183
+ return;
1184
+ const fetchRegion = {
1185
+ start: region.chunkAlignedStart,
1186
+ end: region.chunkAlignedEnd,
1187
+ };
1188
+ // Fire-and-forget pre-fetches
1189
+ for (const timeIdx of toFetch) {
1190
+ if (abortController.signal.aborted)
1191
+ break;
1192
+ this._prefetchingTimeIndices.add(timeIdx);
1193
+ void this.coalescer
1194
+ .fetchRegion(ngffImage, levelIndex, fetchRegion, `prefetch-t${timeIdx}`, timeIdx)
1195
+ .then((result) => {
1196
+ if (abortController.signal.aborted)
1197
+ return;
1198
+ const shape = [
1199
+ region.chunkAlignedEnd[0] - region.chunkAlignedStart[0],
1200
+ region.chunkAlignedEnd[1] - region.chunkAlignedStart[1],
1201
+ region.chunkAlignedEnd[2] - region.chunkAlignedStart[2],
1202
+ ];
1203
+ // Store a copy so the original fetch result can be GC'd
1204
+ this._timeFrameCache.set(timeIdx, {
1205
+ data: result.data.slice(),
1206
+ shape,
1207
+ levelIndex,
1208
+ region,
1209
+ });
1210
+ })
1211
+ .catch(() => {
1212
+ // Silently ignore pre-fetch failures (non-critical)
1213
+ })
1214
+ .finally(() => {
1215
+ this._prefetchingTimeIndices.delete(timeIdx);
1216
+ });
1217
+ }
1218
+ }
1219
+ // ============================================================
908
1220
  // Viewport-Aware Resolution
909
1221
  // ============================================================
910
1222
  /**
@@ -944,6 +1256,8 @@ export class OMEZarrNVImage extends NVImage {
944
1256
  const selection = selectResolution(this.multiscales, this.maxPixels, this._clipPlanes, this._volumeBounds);
945
1257
  if (selection.levelIndex !== this.targetLevelIndex) {
946
1258
  this.targetLevelIndex = selection.levelIndex;
1259
+ // Spatial region changed — cached time frames are stale
1260
+ this._invalidateTimeFrameCache();
947
1261
  this.populateVolume(true, "viewportChanged");
948
1262
  }
949
1263
  // Also reload slabs without viewport constraint
@@ -969,49 +1283,33 @@ export class OMEZarrNVImage extends NVImage {
969
1283
  };
970
1284
  }
971
1285
  /**
972
- * Hook viewport events (onMouseUp, onZoom3DChange, wheel) on a NV instance.
1286
+ * Hook viewport events (mouseUp, zoom3DChange, wheel) on a NV instance.
1287
+ * All listeners share a single AbortController so they can be torn down
1288
+ * together via {@link _unhookViewportEvents}.
973
1289
  */
974
1290
  _hookViewportEvents(nv, state) {
975
- // Save and chain onMouseUp (fires at end of any mouse/touch interaction)
976
- state.previousOnMouseUp = nv.onMouseUp;
977
- nv.onMouseUp = (data) => {
978
- if (state.previousOnMouseUp) {
979
- state.previousOnMouseUp(data);
980
- }
981
- this._handleViewportInteractionEnd(nv);
982
- };
983
- // Save and chain onZoom3DChange (fires when volScaleMultiplier changes)
984
- state.previousOnZoom3DChange = nv.onZoom3DChange;
985
- nv.onZoom3DChange = (zoom) => {
986
- if (state.previousOnZoom3DChange) {
987
- state.previousOnZoom3DChange(zoom);
988
- }
989
- this._handleViewportInteractionEnd(nv);
990
- };
991
- // Add wheel event listener on the canvas for scroll-wheel zoom detection
992
1291
  const controller = new AbortController();
993
1292
  state.viewportAbortController = controller;
1293
+ const signal = controller.signal;
1294
+ // Detect end of mouse/touch interaction
1295
+ nv.addEventListener("mouseUp", () => {
1296
+ this._handleViewportInteractionEnd(nv);
1297
+ }, { signal });
1298
+ // Detect 3D zoom level changes
1299
+ nv.addEventListener("zoom3DChange", () => {
1300
+ this._handleViewportInteractionEnd(nv);
1301
+ }, { signal });
1302
+ // Detect scroll-wheel zoom on the canvas
994
1303
  if (nv.canvas) {
995
1304
  nv.canvas.addEventListener("wheel", () => {
996
1305
  this._handleViewportInteractionEnd(nv);
997
- }, { signal: controller.signal, passive: true });
1306
+ }, { signal, passive: true });
998
1307
  }
999
1308
  }
1000
1309
  /**
1001
1310
  * Unhook viewport events from a NV instance.
1002
1311
  */
1003
- _unhookViewportEvents(nv, state) {
1004
- // Restore onMouseUp
1005
- if (state.previousOnMouseUp !== undefined) {
1006
- nv.onMouseUp = state.previousOnMouseUp;
1007
- state.previousOnMouseUp = undefined;
1008
- }
1009
- // Restore onZoom3DChange
1010
- if (state.previousOnZoom3DChange !== undefined) {
1011
- nv.onZoom3DChange = state.previousOnZoom3DChange;
1012
- state.previousOnZoom3DChange = undefined;
1013
- }
1014
- // Remove wheel event listener
1312
+ _unhookViewportEvents(_nv, state) {
1015
1313
  if (state.viewportAbortController) {
1016
1314
  state.viewportAbortController.abort();
1017
1315
  state.viewportAbortController = undefined;
@@ -1175,6 +1473,8 @@ export class OMEZarrNVImage extends NVImage {
1175
1473
  const selection = selectResolution(this.multiscales, this.maxPixels, this._clipPlanes, this._volumeBounds, this._viewportBounds3D ?? undefined);
1176
1474
  if (selection.levelIndex !== this.targetLevelIndex) {
1177
1475
  this.targetLevelIndex = selection.levelIndex;
1476
+ // Spatial region changed — cached time frames are stale
1477
+ this._invalidateTimeFrameCache();
1178
1478
  this.populateVolume(true, "viewportChanged");
1179
1479
  }
1180
1480
  }
@@ -1227,10 +1527,65 @@ export class OMEZarrNVImage extends NVImage {
1227
1527
  return this.isLoading;
1228
1528
  }
1229
1529
  /**
1230
- * Wait for all pending fetches to complete.
1530
+ * Wait for all async work to settle: debounced timers (clip plane
1531
+ * refetch, viewport update, slab reload), the main `populateVolume`
1532
+ * pipeline, all slab loads, and in-flight coalescer fetches.
1533
+ *
1534
+ * The method polls in a loop because debounced timers may fire while
1535
+ * we are waiting, triggering new loads. It only resolves once every
1536
+ * source of async work is idle simultaneously.
1231
1537
  */
1232
1538
  async waitForIdle() {
1233
- await this.coalescer.onIdle();
1539
+ // Polling interval for active-load checks (ms).
1540
+ const POLL_MS = 50;
1541
+ while (true) {
1542
+ // ---- Debounced timers ----
1543
+ // Wait for pending debounce timers to fire (and potentially
1544
+ // trigger new loads) before checking load state.
1545
+ if (this.clipPlaneRefetchTimeout !== null) {
1546
+ await new Promise((r) => setTimeout(r, this.clipPlaneDebounceMs + POLL_MS));
1547
+ continue;
1548
+ }
1549
+ if (this._viewportUpdateTimeout !== null) {
1550
+ await new Promise((r) => setTimeout(r, OMEZarrNVImage.VIEWPORT_DEBOUNCE_MS + POLL_MS));
1551
+ continue;
1552
+ }
1553
+ if (this._slabReloadTimeouts.size > 0) {
1554
+ // Slab reload debounce is 100 ms (hardcoded in
1555
+ // _debouncedSlabReload).
1556
+ await new Promise((r) => setTimeout(r, 100 + POLL_MS));
1557
+ continue;
1558
+ }
1559
+ // ---- Active loads ----
1560
+ if (this.isLoading || this._pendingPopulateRequest !== null) {
1561
+ await new Promise((r) => setTimeout(r, POLL_MS));
1562
+ continue;
1563
+ }
1564
+ let slabBusy = false;
1565
+ for (const slabState of this._slabBuffers.values()) {
1566
+ if (slabState.isLoading || slabState.pendingReload !== null) {
1567
+ slabBusy = true;
1568
+ break;
1569
+ }
1570
+ }
1571
+ if (slabBusy) {
1572
+ await new Promise((r) => setTimeout(r, POLL_MS));
1573
+ continue;
1574
+ }
1575
+ // ---- In-flight fetches ----
1576
+ await this.coalescer.onIdle();
1577
+ // ---- Convergence check ----
1578
+ // A debounce timer or pending request may have appeared while we
1579
+ // were awaiting the coalescer. If so, loop again.
1580
+ const stillBusy = this.isLoading ||
1581
+ this._pendingPopulateRequest !== null ||
1582
+ this.clipPlaneRefetchTimeout !== null ||
1583
+ this._viewportUpdateTimeout !== null ||
1584
+ this._slabReloadTimeouts.size > 0 ||
1585
+ Array.from(this._slabBuffers.values()).some((s) => s.isLoading || s.pendingReload !== null);
1586
+ if (!stillBusy)
1587
+ break;
1588
+ }
1234
1589
  }
1235
1590
  // ============================================================
1236
1591
  // OMERO Metadata (Visualization Parameters)
@@ -1305,9 +1660,10 @@ export class OMEZarrNVImage extends NVImage {
1305
1660
  /**
1306
1661
  * Attach a Niivue instance for slice-type-aware rendering.
1307
1662
  *
1308
- * The image auto-detects the NV's current slice type and hooks into
1309
- * `onOptsChange` to track mode changes and `onLocationChange` to track
1310
- * crosshair/slice position changes.
1663
+ * The image auto-detects the NV's current slice type and uses
1664
+ * `addEventListener('sliceTypeChange', ...)` to track mode changes and
1665
+ * `addEventListener('locationChange', ...)` to track crosshair/slice
1666
+ * position changes via Niivue's browser-native EventTarget API.
1311
1667
  *
1312
1668
  * When the NV is in a 2D slice mode (Axial, Coronal, Sagittal), the image
1313
1669
  * loads a slab (one chunk thick in the orthogonal direction) at the current
@@ -1318,30 +1674,21 @@ export class OMEZarrNVImage extends NVImage {
1318
1674
  attachNiivue(nv) {
1319
1675
  if (this._attachedNiivues.has(nv))
1320
1676
  return; // Already attached
1677
+ const eventAbortController = new AbortController();
1321
1678
  const state = {
1322
1679
  nv,
1323
1680
  currentSliceType: this._detectSliceType(nv),
1324
- previousOnLocationChange: nv.onLocationChange,
1325
- previousOnOptsChange: nv.onOptsChange,
1326
- };
1327
- // Hook onOptsChange to detect slice type changes
1328
- nv.onOptsChange = (propertyName, newValue, oldValue) => {
1329
- // Chain to previous handler
1330
- if (state.previousOnOptsChange) {
1331
- state.previousOnOptsChange(propertyName, newValue, oldValue);
1332
- }
1333
- if (propertyName === "sliceType") {
1334
- this._handleSliceTypeChange(nv, newValue);
1335
- }
1336
- };
1337
- // Hook onLocationChange to detect slice position changes
1338
- nv.onLocationChange = (location) => {
1339
- // Chain to previous handler
1340
- if (state.previousOnLocationChange) {
1341
- state.previousOnLocationChange(location);
1342
- }
1343
- this._handleLocationChange(nv, location);
1681
+ eventAbortController,
1344
1682
  };
1683
+ const signal = eventAbortController.signal;
1684
+ // Listen for slice type changes via the browser-native event API
1685
+ nv.addEventListener("sliceTypeChange", (e) => {
1686
+ this._handleSliceTypeChange(nv, e.detail.sliceType);
1687
+ }, { signal });
1688
+ // Listen for crosshair/slice position changes
1689
+ nv.addEventListener("locationChange", (e) => {
1690
+ this._handleLocationChange(nv, e.detail);
1691
+ }, { signal });
1345
1692
  this._attachedNiivues.set(nv, state);
1346
1693
  // Hook viewport events if viewport-aware mode is already enabled
1347
1694
  if (this._viewportAwareEnabled) {
@@ -1356,7 +1703,9 @@ export class OMEZarrNVImage extends NVImage {
1356
1703
  }
1357
1704
  }
1358
1705
  /**
1359
- * Detach a Niivue instance, restoring its original callbacks.
1706
+ * Detach a Niivue instance, removing all event listeners registered by this
1707
+ * image (viewport, zoom-override, slice-type, location, and clip-plane
1708
+ * listeners are all torn down via their respective `AbortController`s).
1360
1709
  *
1361
1710
  * @param nv - The Niivue instance to detach
1362
1711
  */
@@ -1368,10 +1717,13 @@ export class OMEZarrNVImage extends NVImage {
1368
1717
  this._unhookViewportEvents(nv, state);
1369
1718
  // Unhook 3D zoom override
1370
1719
  this._unhookZoomOverride(nv, state);
1371
- // Restore original callbacks
1372
- nv.onLocationChange = state.previousOnLocationChange ?? (() => { });
1373
- nv.onOptsChange = (state.previousOnOptsChange ??
1374
- (() => { }));
1720
+ // Remove niivue event listeners (sliceTypeChange, locationChange)
1721
+ state.eventAbortController.abort();
1722
+ // If detaching the primary NV, also remove the clip plane listener
1723
+ if (nv === this.niivue && this._clipPlaneAbortController) {
1724
+ this._clipPlaneAbortController.abort();
1725
+ this._clipPlaneAbortController = undefined;
1726
+ }
1375
1727
  this._attachedNiivues.delete(nv);
1376
1728
  }
1377
1729
  /**
@@ -1414,21 +1766,43 @@ export class OMEZarrNVImage extends NVImage {
1414
1766
  st === SLICE_TYPE.SAGITTAL);
1415
1767
  }
1416
1768
  /**
1417
- * Get the orthogonal axis index for a slab slice type.
1418
- * Returns index in [z, y, x] order:
1419
- * - Axial: slicing through Z orthogonal axis = 0 (Z)
1420
- * - Coronal: slicing through Y orthogonal axis = 1 (Y)
1421
- * - Sagittal: slicing through X orthogonal axis = 2 (X)
1769
+ * Get the NGFF array axis index that is orthogonal to a slice plane.
1770
+ *
1771
+ * NiiVue slice types refer to anatomical planes:
1772
+ * - Axial: perpendicular to S/I (physicalRow 2)
1773
+ * - Coronal: perpendicular to A/P (physicalRow 1)
1774
+ * - Sagittal: perpendicular to R/L (physicalRow 0)
1775
+ *
1776
+ * When the dataset has permuted axes (e.g. NGFF y encodes S/I instead
1777
+ * of A/P), the NGFF array axis that is orthogonal to a given anatomical
1778
+ * plane differs from the default z/y/x mapping. This method uses the
1779
+ * orientation metadata to find the correct NGFF axis.
1780
+ *
1781
+ * Returns index in [z, y, x] order (0=z, 1=y, 2=x).
1422
1782
  */
1423
1783
  _getOrthogonalAxis(sliceType) {
1784
+ // Which physical (RAS) row is perpendicular to this slice plane?
1785
+ let targetRow;
1424
1786
  switch (sliceType) {
1425
1787
  case SLICE_TYPE.AXIAL:
1426
- return 0; // Z
1788
+ targetRow = 2; // S/I
1789
+ break;
1427
1790
  case SLICE_TYPE.CORONAL:
1428
- return 1; // Y
1791
+ targetRow = 1; // A/P
1792
+ break;
1429
1793
  case SLICE_TYPE.SAGITTAL:
1430
- return 2; // X
1794
+ targetRow = 0; // R/L
1795
+ break;
1431
1796
  }
1797
+ const orientations = this.multiscales.images[0]?.axesOrientations;
1798
+ const mapping = getOrientationMapping(orientations);
1799
+ // Find which NGFF axis maps to targetRow.
1800
+ // mapping.x/y/z have physicalRow; NGFF indices: x=2, y=1, z=0
1801
+ if (mapping.z.physicalRow === targetRow)
1802
+ return 0;
1803
+ if (mapping.y.physicalRow === targetRow)
1804
+ return 1;
1805
+ return 2;
1432
1806
  }
1433
1807
  /**
1434
1808
  * Handle a slice type change on an attached Niivue instance.
@@ -1477,9 +1851,12 @@ export class OMEZarrNVImage extends NVImage {
1477
1851
  catch {
1478
1852
  return; // Can't convert coordinates yet
1479
1853
  }
1480
- // Convert world to pixel at the slab's current resolution level
1854
+ // Convert world to pixel at the slab's current resolution level.
1855
+ // Must use the full oriented affine (not the naive scale+translation)
1856
+ // because worldCoord is in oriented (NIfTI RAS) space.
1481
1857
  const ngffImage = this.multiscales.images[slabState.levelIndex];
1482
- const pixelCoord = worldToPixel(worldCoord, ngffImage);
1858
+ const orientedAffine = createAffineFromNgffImage(ngffImage);
1859
+ const pixelCoord = worldToPixelAffine(worldCoord, orientedAffine);
1483
1860
  // Check the orthogonal axis
1484
1861
  const orthAxis = this._getOrthogonalAxis(sliceType);
1485
1862
  const pixelPos = pixelCoord[orthAxis];
@@ -1514,10 +1891,11 @@ export class OMEZarrNVImage extends NVImage {
1514
1891
  slabState = this._createSlabBuffer(sliceType);
1515
1892
  this._slabBuffers.set(sliceType, slabState);
1516
1893
  }
1517
- // Swap the slab's NVImage into this NV instance
1518
- this._swapVolumeInNiivue(nv, slabState.nvImage);
1519
- // Get the current crosshair position and load the slab.
1520
- // Use the volume bounds center as a fallback if crosshair isn't available yet.
1894
+ // Capture the crosshair world position BEFORE swapping volumes.
1895
+ // frac2mm() uses the current volume's affine, so it must run while
1896
+ // the 3D (or previous slab) NVImage is still attached. After the swap,
1897
+ // the 1×1×1 placeholder's identity affine would produce incorrect
1898
+ // coordinates.
1521
1899
  let worldCoord;
1522
1900
  try {
1523
1901
  const crosshairPos = nv.scene?.crosshairPos;
@@ -1546,6 +1924,8 @@ export class OMEZarrNVImage extends NVImage {
1546
1924
  (this._volumeBounds.min[2] + this._volumeBounds.max[2]) / 2,
1547
1925
  ];
1548
1926
  }
1927
+ // Swap the slab's NVImage into this NV instance (after capturing coords)
1928
+ this._swapVolumeInNiivue(nv, slabState.nvImage);
1549
1929
  void this._loadSlab(sliceType, worldCoord, "initial").catch((err) => {
1550
1930
  console.error(`[fidnii] Error loading slab for ${SLICE_TYPE[sliceType]}:`, err);
1551
1931
  });
@@ -1581,7 +1961,7 @@ export class OMEZarrNVImage extends NVImage {
1581
1961
  nvImage.name = `${this.name ?? "OME-Zarr"} [${SLICE_TYPE[sliceType]}]`;
1582
1962
  nvImage.img = bufferManager.resize([1, 1, 1]);
1583
1963
  if (!this.isLabelImage) {
1584
- nvImage._colormap = "gray";
1964
+ nvImage._colormap = this._colormap || "gray";
1585
1965
  }
1586
1966
  nvImage._opacity = 1.0;
1587
1967
  // Select initial resolution using 2D pixel budget
@@ -1744,8 +2124,11 @@ export class OMEZarrNVImage extends NVImage {
1744
2124
  const ngffImage = this.multiscales.images[levelIndex];
1745
2125
  const chunkShape = getChunkShape(ngffImage);
1746
2126
  const volumeShape = getVolumeShape(ngffImage);
1747
- // Convert world position to pixel position at this level
1748
- const pixelCoord = worldToPixel(worldCoord, ngffImage);
2127
+ // Convert world position to pixel position at this level.
2128
+ // Must use the full oriented affine because worldCoord is in oriented
2129
+ // (NIfTI RAS) space, not raw NGFF scale+translation space.
2130
+ const orientedAffine = createAffineFromNgffImage(ngffImage);
2131
+ const pixelCoord = worldToPixelAffine(worldCoord, orientedAffine);
1749
2132
  const orthPixel = pixelCoord[orthAxis];
1750
2133
  // Find the chunk-aligned slab in the orthogonal axis
1751
2134
  const chunkSize = chunkShape[orthAxis];
@@ -1778,7 +2161,7 @@ export class OMEZarrNVImage extends NVImage {
1778
2161
  ];
1779
2162
  // Fetch the data
1780
2163
  const fetchRegion = { start: fetchStart, end: fetchEnd };
1781
- const result = await this.coalescer.fetchRegion(ngffImage, levelIndex, fetchRegion, `slab-${SLICE_TYPE[sliceType]}-${levelIndex}`);
2164
+ const result = await this.coalescer.fetchRegion(ngffImage, levelIndex, fetchRegion, `slab-${SLICE_TYPE[sliceType]}-${levelIndex}`, this._timeIndex);
1782
2165
  // Resize buffer and copy data
1783
2166
  const targetData = slabState.bufferManager.resize(fetchedShape);
1784
2167
  const normalize = needsRGBNormalization(ngffImage, this.dtype);
@@ -1881,17 +2264,28 @@ export class OMEZarrNVImage extends NVImage {
1881
2264
  1,
1882
2265
  1,
1883
2266
  ];
1884
- // Build affine with orientation signs, then offset for region start.
1885
- // Use the original unoriented scale values with orientation signs for
1886
- // the translation offset calculation. This ensures correctness even when
1887
- // axes are permuted (where affine diagonal may be zero).
2267
+ // Build the fully oriented affine (including orientation permutation
2268
+ // and sign flips), then apply the region offset in world space.
2269
+ //
2270
+ // The offset must be applied AFTER orientation because the fetch region
2271
+ // start is in NGFF voxel space [z, y, x], and the oriented 3x3 rotation
2272
+ // matrix is needed to transform that voxel offset into world coordinates.
2273
+ // Previously, the offset was applied before orientation which broke when
2274
+ // NGFF axes were permuted (e.g. NGFF z → physical A/P axis).
1888
2275
  const affine = createAffineFromNgffImage(ngffImage);
1889
- // Get orientation signs to apply to the offset calculation
1890
- const signs = getOrientationSigns(ngffImage.axesOrientations);
1891
- // Adjust translation for region offset (fetchStart is [z, y, x])
1892
- affine[12] += fetchStart[2] * signs.x * sx; // x offset (orientation-aware)
1893
- affine[13] += fetchStart[1] * signs.y * sy; // y offset (orientation-aware)
1894
- affine[14] += fetchStart[0] * signs.z * sz; // z offset (orientation-aware)
2276
+ // Transform the NGFF voxel offset through the oriented 3x3 rotation.
2277
+ // fetchStart is [z, y, x]; affine columns map NIfTI [i=x, j=y, k=z]
2278
+ // to world, so we need offset in NIfTI [x, y, z] order.
2279
+ const offsetX = fetchStart[2]; // NIfTI i = NGFF x
2280
+ const offsetY = fetchStart[1]; // NIfTI j = NGFF y
2281
+ const offsetZ = fetchStart[0]; // NIfTI k = NGFF z
2282
+ // Multiply the 3x3 rotation by the offset vector and add to translation
2283
+ affine[12] +=
2284
+ affine[0] * offsetX + affine[4] * offsetY + affine[8] * offsetZ;
2285
+ affine[13] +=
2286
+ affine[1] * offsetX + affine[5] * offsetY + affine[9] * offsetZ;
2287
+ affine[14] +=
2288
+ affine[2] * offsetX + affine[6] * offsetY + affine[10] * offsetZ;
1895
2289
  // For 2D images, flip y before normalization (composes with orientation)
1896
2290
  if (this._flipY2D && this._is2D) {
1897
2291
  // Get the y axis orientation mapping to find where the y scale is stored