@jax-data-science/sci-image-visualizer 0.3.3 → 0.5.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 (98) hide show
  1. package/README.md +268 -4
  2. package/esm2022/index.mjs +21 -1
  3. package/esm2022/lib/contracts/display-types.mjs +20 -2
  4. package/esm2022/lib/contracts/image.contract.mjs +1 -1
  5. package/esm2022/lib/contracts/plot-type-contribution.contract.mjs +66 -0
  6. package/esm2022/lib/contracts/plot-type.mjs +41 -1
  7. package/esm2022/lib/contracts/ports/spatial-data.port.mjs +3 -0
  8. package/esm2022/lib/contracts/spatial-dataset.contract.mjs +45 -0
  9. package/esm2022/lib/contracts/visualizer.contract.mjs +1 -1
  10. package/esm2022/lib/implementations/napari-js/napari-axes-labels.mjs +10 -19
  11. package/esm2022/lib/implementations/napari-js/napari-region-overlay.mjs +71 -7
  12. package/esm2022/lib/implementations/napari-js/napari-scale-bar.mjs +1 -1
  13. package/esm2022/lib/implementations/napari-js/napari-spatial-tooltip.mjs +96 -0
  14. package/esm2022/lib/implementations/napari-js/napari-visualizer.service.mjs +1934 -55
  15. package/esm2022/lib/implementations/napari-js/napari-zoom.mjs +18 -0
  16. package/esm2022/lib/implementations/osd/openseadragon-visualizer.service.mjs +73 -8
  17. package/esm2022/lib/implementations/osd/osd-coordinate-transform.mjs +8 -1
  18. package/esm2022/lib/implementations/osd/osd-region-overlay.mjs +3 -4
  19. package/esm2022/lib/implementations/osd/osd-zoom.mjs +20 -0
  20. package/esm2022/lib/implementations/plotly/omics-trace-builders.mjs +555 -0
  21. package/esm2022/lib/implementations/plotly/plotly.service.mjs +13 -7
  22. package/esm2022/lib/implementations/spatial-data-http/spatial-data-http.service.mjs +304 -0
  23. package/esm2022/lib/implementations/spatial-data-http/spatial-wire.mjs +179 -0
  24. package/esm2022/lib/plot-mode/plot-mode-controller.mjs +329 -0
  25. package/esm2022/lib/routing-visualizer.service.mjs +228 -12
  26. package/esm2022/lib/spatial/embedding-compute.mjs +142 -0
  27. package/esm2022/lib/spatial/gene-search.mjs +77 -0
  28. package/esm2022/lib/spatial/spatial-density.mjs +224 -0
  29. package/esm2022/lib/spatial/spatial-encoding.mjs +232 -0
  30. package/esm2022/lib/spatial/spatial-expression.mjs +257 -0
  31. package/esm2022/lib/spatial/spatial-framing.mjs +60 -0
  32. package/esm2022/lib/spatial/spatial-heatmap.mjs +185 -0
  33. package/esm2022/lib/spatial/spatial-hover.mjs +79 -0
  34. package/esm2022/lib/spatial/spatial-sections.mjs +96 -0
  35. package/esm2022/lib/spatial/spatial-selection.mjs +264 -0
  36. package/esm2022/lib/spatial/spatial-volume-image.mjs +163 -0
  37. package/esm2022/lib/spatial/tsne-worker.mjs +10 -0
  38. package/esm2022/lib/spatial/world-grid.mjs +77 -0
  39. package/esm2022/lib/spatial-controls/spatial-charts/spatial-charts.component.mjs +1278 -0
  40. package/esm2022/lib/spatial-controls/spatial-controls.component.mjs +621 -0
  41. package/esm2022/lib/store/spatial-selection.service.mjs +47 -0
  42. package/esm2022/lib/store/visualizer-store.service.mjs +23 -1
  43. package/esm2022/lib/toolbar/toolbar.component.mjs +61 -21
  44. package/esm2022/lib/visualization.module.mjs +23 -5
  45. package/esm2022/lib/visualizer.component.mjs +527 -44
  46. package/fesm2022/{jax-data-science-sci-image-visualizer-onnx-sam-session-ci1q0xnJ.mjs → jax-data-science-sci-image-visualizer-onnx-sam-session-BwP-A11m.mjs} +6 -4
  47. package/fesm2022/{jax-data-science-sci-image-visualizer-onnx-sam-session-ci1q0xnJ.mjs.map → jax-data-science-sci-image-visualizer-onnx-sam-session-BwP-A11m.mjs.map} +1 -1
  48. package/fesm2022/jax-data-science-sci-image-visualizer-tsne-worker-B_nXDGf0.mjs +12 -0
  49. package/fesm2022/jax-data-science-sci-image-visualizer-tsne-worker-B_nXDGf0.mjs.map +1 -0
  50. package/fesm2022/jax-data-science-sci-image-visualizer.mjs +15971 -7808
  51. package/fesm2022/jax-data-science-sci-image-visualizer.mjs.map +1 -1
  52. package/fesm2022/tsne.worker.js +19091 -0
  53. package/index.d.ts +16 -0
  54. package/lib/contracts/display-types.d.ts +131 -0
  55. package/lib/contracts/image.contract.d.ts +7 -0
  56. package/lib/contracts/plot-type-contribution.contract.d.ts +172 -0
  57. package/lib/contracts/plot-type.d.ts +52 -2
  58. package/lib/contracts/ports/spatial-data.port.d.ts +90 -0
  59. package/lib/contracts/spatial-dataset.contract.d.ts +281 -0
  60. package/lib/contracts/visualizer.contract.d.ts +104 -1
  61. package/lib/implementations/napari-js/napari-axes-labels.d.ts +9 -7
  62. package/lib/implementations/napari-js/napari-region-overlay.d.ts +38 -4
  63. package/lib/implementations/napari-js/napari-scale-bar.d.ts +7 -3
  64. package/lib/implementations/napari-js/napari-spatial-tooltip.d.ts +32 -0
  65. package/lib/implementations/napari-js/napari-visualizer.service.d.ts +497 -16
  66. package/lib/implementations/napari-js/napari-zoom.d.ts +15 -0
  67. package/lib/implementations/osd/openseadragon-visualizer.service.d.ts +21 -0
  68. package/lib/implementations/osd/osd-coordinate-transform.d.ts +6 -0
  69. package/lib/implementations/osd/osd-zoom.d.ts +19 -0
  70. package/lib/implementations/plotly/omics-trace-builders.d.ts +193 -0
  71. package/lib/implementations/plotly/plotly.service.d.ts +9 -0
  72. package/lib/implementations/spatial-data-http/spatial-data-http.service.d.ts +123 -0
  73. package/lib/implementations/spatial-data-http/spatial-wire.d.ts +115 -0
  74. package/lib/plot-mode/plot-mode-controller.d.ts +130 -0
  75. package/lib/routing-visualizer.service.d.ts +49 -6
  76. package/lib/spatial/embedding-compute.d.ts +73 -0
  77. package/lib/spatial/gene-search.d.ts +48 -0
  78. package/lib/spatial/spatial-density.d.ts +73 -0
  79. package/lib/spatial/spatial-encoding.d.ts +155 -0
  80. package/lib/spatial/spatial-expression.d.ts +144 -0
  81. package/lib/spatial/spatial-framing.d.ts +37 -0
  82. package/lib/spatial/spatial-heatmap.d.ts +111 -0
  83. package/lib/spatial/spatial-hover.d.ts +49 -0
  84. package/lib/spatial/spatial-sections.d.ts +59 -0
  85. package/lib/spatial/spatial-selection.d.ts +113 -0
  86. package/lib/spatial/spatial-volume-image.d.ts +67 -0
  87. package/lib/spatial/tsne-worker.d.ts +7 -0
  88. package/lib/spatial/world-grid.d.ts +58 -0
  89. package/lib/spatial-controls/spatial-charts/spatial-charts.component.d.ts +439 -0
  90. package/lib/spatial-controls/spatial-controls.component.d.ts +257 -0
  91. package/lib/store/spatial-selection.service.d.ts +27 -0
  92. package/lib/store/visualizer-store.service.d.ts +13 -0
  93. package/lib/toolbar/toolbar.component.d.ts +36 -8
  94. package/lib/visualization.module.d.ts +28 -24
  95. package/lib/visualizer.component.d.ts +139 -7
  96. package/package.json +6 -2
  97. package/src/lib/assets/spatial-omics-3d.svg +31 -0
  98. package/src/lib/assets/spatial-omics.svg +27 -0
@@ -1,6 +1,9 @@
1
1
  import { Observable } from 'rxjs';
2
2
  import { Image } from 'image-js';
3
3
  import { IImageInfo } from '../../contracts/image.contract';
4
+ import { SpatialDataPort } from '../../contracts/ports/spatial-data.port';
5
+ import { SpatialObservations } from '../../contracts/spatial-dataset.contract';
6
+ import { SpatialSelectionStore } from '../../store/spatial-selection.service';
4
7
  import { PlotType, PlotTypeDescriptor } from '../../contracts/plot-type';
5
8
  import { IVisualizer, PixelData, IntensityProfile, IIsosurfaceControls, IIntensityControls, ISurface3dControls } from '../../contracts/visualizer.contract';
6
9
  import { ViewerCapabilities } from '../../contracts/capabilities.contract';
@@ -30,20 +33,6 @@ interface NapariLoaded {
30
33
  * handle's `filename` doesn't match the requested image (guards against stale clicks). */
31
34
  filename: string;
32
35
  }
33
- /**
34
- * A WebGPU image backend built on the published `napari-js` library — the POC engine for
35
- * jit-ui#102 (a browser-based napari shipped as a JS library, swapping image plotting with
36
- * OpenSeadragon and 3D slicing/isosurfaces with Plotly).
37
- *
38
- * Render strategy: rather than re-implement the server's tile/pyramid protocol (which OSD
39
- * already handles), this backend renders the **complete per-slice image URLs the app already
40
- * produces** (`IImageInfo.urls`) — `urls[z]` for the 2D image, and the full `urls` stack
41
- * assembled into a downsampled volume for the 3D types. Region state and display options
42
- * delegate to the shared {@link RegionStore} / {@link VisualizerStore}, exactly as OSD does.
43
- *
44
- * Follow-ups (jit-ui#102): native-resolution pyramidal tiling, on-canvas tools, region
45
- * overlay rendering, per-channel histograms, TIFF export.
46
- */
47
36
  export declare class NapariVisualizerService extends BaseStoreVisualizer implements IVisualizer {
48
37
  private readonly tiles;
49
38
  private readonly wandTool;
@@ -55,6 +44,8 @@ export declare class NapariVisualizerService extends BaseStoreVisualizer impleme
55
44
  private readonly cellSegmentTool;
56
45
  private readonly cellSegmenter;
57
46
  private readonly simpleStack;
47
+ private readonly spatialData;
48
+ private readonly selectionStore;
58
49
  readonly capabilities: ViewerCapabilities;
59
50
  private readonly api;
60
51
  private viewer;
@@ -90,6 +81,97 @@ export declare class NapariVisualizerService extends BaseStoreVisualizer impleme
90
81
  private scatterRegionSub;
91
82
  /** napari-js 3D scatter (voxel point cloud). */
92
83
  private scatter3dLayer;
84
+ /** Spatial-omics observation markers + the dataset/view subscription driving them. */
85
+ private spatialPoints;
86
+ /** The 3D point cloud. One layer: selection is a per-point alpha on it, not a second. */
87
+ private spatialPoints3d;
88
+ private spatialLayerKey3d;
89
+ /** Interleaved x,y,z, cached so a colour change does not re-walk 3.7M observations. */
90
+ private spatialPositions3d;
91
+ /** Identity of the scalars currently uploaded — see {@link rebuildSpatialPoints3d}. */
92
+ private spatialScalarKey3d;
93
+ /** The anatomical volume the cloud sits inside, when the dataset has one. */
94
+ private spatialVolume;
95
+ private spatialVolumeKey;
96
+ /** Per-cluster density volumes drawn alongside the cloud, and what they were
97
+ * built from — rasterising is seconds of work, so it must not repeat for a
98
+ * change that cannot affect the field. */
99
+ private densityLayers;
100
+ private densityKey;
101
+ /** Selection identity, as a number the density key can carry. */
102
+ private lastSelectionSeen;
103
+ private selectionRevision;
104
+ /** The gene map: its layer, the field it was estimated from, and the inputs each
105
+ * was built for — the field is the expensive half and survives a recolour. */
106
+ private geneMapLayer;
107
+ private geneMapKey;
108
+ private geneMapField;
109
+ private geneMapFieldKey;
110
+ /** The 3D gene map: its volume layer, the field behind it, and the inputs each
111
+ * was built for — same two clocks as the 2D map. */
112
+ private geneMapVolumeLayer;
113
+ private geneMapVolumeKey;
114
+ private geneMapVolumeField;
115
+ private geneMapVolumeFieldKey;
116
+ /** Offset applied to observation coordinates to sit them in the volume's box. */
117
+ private spatialOrigin3d;
118
+ /** Cursor tooltip for the spatial views, and everything it needs: what the
119
+ * cloud is coloured by, and where each drawn observation is. */
120
+ private spatialTooltip;
121
+ private hoverSource;
122
+ private hoverSourceKey;
123
+ /** Sequences the async resolutions. The KEY is only committed once one lands,
124
+ * so a superseded fetch cannot leave the cache claiming to hold a source it
125
+ * never stored. */
126
+ private hoverSourceToken;
127
+ /** Drawn observations' positions for hit-testing, indexed BY OBSERVATION with
128
+ * NaN for anything not drawn. Screen pixels in 3D (the camera moves them, so
129
+ * they are rebuilt when it does) and WORLD units in 2D (where the camera only
130
+ * scales, so the pointer is converted instead of 374k points). */
131
+ private hoverPositions;
132
+ private hoverPositionsRev;
133
+ /**
134
+ * Screen-space bucket index over the 3D projection, built on the first hover after the
135
+ * scene moved rather than when it moves.
136
+ *
137
+ * Lazily, and that is the whole design: an orbit drag changes the camera every frame, so
138
+ * building eagerly would spend tens of milliseconds a frame indexing for picks nobody is
139
+ * making. Deferred, it is built once when the drag stops and the pointer next moves —
140
+ * measured upstream at 3.7M points, that turns a 12.6 ms scan per pointermove into
141
+ * 0.066 ms.
142
+ */
143
+ private hoverIndex;
144
+ /** Bumped whenever the cached positions go stale: a marker rebuild, or — in 3D
145
+ * only, where the projection depends on it — a camera move. */
146
+ private spatialSceneRev;
147
+ private hoverPointer;
148
+ private hoverFrame;
149
+ private hoverOff;
150
+ /** Observation indices the cached 3D positions belong to, in the same order;
151
+ * null when every observation is drawn. Without it a projection built from the
152
+ * positions is indexed by DRAWN order and silently attributes each point to
153
+ * the wrong observation as soon as a section is isolated. */
154
+ private spatialDrawn3d;
155
+ /** Reused projection buffers — see {@link getSpatialScreenProjection}. */
156
+ private spatialProjection3d;
157
+ /** Per-observation depth from the last projection, for the depth-aware hover pick. */
158
+ private spatialDepths3d;
159
+ /** The (viewer, dataset) whose 3D scene has already had its opening framing. */
160
+ /** Whose 2D observations the camera has been framed on — see {@link frameSpatialPointsOnce}. */
161
+ private spatial2dFramed;
162
+ /** Dataset the 3D scale bar was built for. */
163
+ private spatialScaleBarKey;
164
+ private spatialSub;
165
+ /** Latest (dataset, view, selection) the spatial subscription saw, so a slice
166
+ * change can rebuild the markers for the new plane. */
167
+ private spatialLatest;
168
+ /** Which dataset the current marker layer was built for, so a display-only
169
+ * change (size, colour, opacity, selection) can update it in place. */
170
+ private spatialLayerKey;
171
+ /** Monotonic guard for the async colour rebuild: fetching a gene vector is a
172
+ * round-trip, so a fast sequence of colour-by changes can resolve out of
173
+ * order. Only the newest rebuild is allowed to touch the layer. */
174
+ private spatialRebuildToken;
93
175
  /** Monotonic load generation. Bumped by {@link reset} and {@link cancelLoading}; the frame-loading
94
176
  * loops (volume assembly, surface preload) capture it and bail when it changes, so a Cancel (or a
95
177
  * new plot) actually stops fetching frames instead of running to completion in the background. */
@@ -168,7 +250,7 @@ export declare class NapariVisualizerService extends BaseStoreVisualizer impleme
168
250
  private readonly autoscaleEvent$;
169
251
  private readonly intensityProfile$;
170
252
  private readonly viewportChange$;
171
- constructor(tiles: TileAccessPort, store: VisualizerStore, regionStore: RegionStore, wandTool: WandToolService, brushTool: BrushToolService, eraserTool: VertexEraserToolService, zoomToBoxTool: ZoomToBoxToolService, samTool: SamToolService, samPointTool: SamPointToolService, cellSegmentTool: CellSegmentToolService, cellSegmenter: ICellSegmenter | null, simpleStack: SimpleSliceAccessService, config: VizConfig);
253
+ constructor(tiles: TileAccessPort, store: VisualizerStore, regionStore: RegionStore, wandTool: WandToolService, brushTool: BrushToolService, eraserTool: VertexEraserToolService, zoomToBoxTool: ZoomToBoxToolService, samTool: SamToolService, samPointTool: SamPointToolService, cellSegmentTool: CellSegmentToolService, cellSegmenter: ICellSegmenter | null, simpleStack: SimpleSliceAccessService, config: VizConfig, spatialData?: SpatialDataPort | null, selectionStore?: SpatialSelectionStore | null);
172
254
  /**
173
255
  * Fetch + cache the server pyramid descriptor (`GET /tiles/info`): the REAL per-level tile grid,
174
256
  * tile size, channel metadata and physical pixel size. The backend returns 202 while the source
@@ -269,6 +351,15 @@ export declare class NapariVisualizerService extends BaseStoreVisualizer impleme
269
351
  * world box (matching the AxesLayer geometry); the scale text reflects the FULL image extent —
270
352
  * physical µm when µm/pixel is known, else pixel (X/Y) / slice (Z) counts. */
271
353
  private buildAxesLabels;
354
+ /**
355
+ * The image's declared extent in pixels/slices, and its physical spacing per
356
+ * axis in µm (0 = undeclared, and then that axis reads in pixels).
357
+ *
358
+ * One place, because the volume world box and the axis labels have to agree
359
+ * about what the image's real dimensions are — the sampled grid is decimated and
360
+ * says nothing about either.
361
+ */
362
+ private imageExtent;
272
363
  /**
273
364
  * Assemble a downsampled uint8 volume (luminance) from the per-slice tile endpoint. Slices are
274
365
  * fetched with bounded concurrency (keeps the connection pool full without flooding it on a deep
@@ -286,6 +377,396 @@ export declare class NapariVisualizerService extends BaseStoreVisualizer impleme
286
377
  private rebuildScatterPoints;
287
378
  /** Region centroids as flat `[x, y, …]` data coords (rectangle / polygon / multipolygon). */
288
379
  private regionCentroids;
380
+ /**
381
+ * The interaction stack every 2D mode needs: the region overlay (draw/select/edit), the pixel
382
+ * tools that read back displayed pixels (wand, brush, vertex eraser, zoom-to-box, SAM/cellpose),
383
+ * and a camera hook that keeps that readback current as the view pans/zooms and tiled levels
384
+ * load.
385
+ *
386
+ * Extracted because forgetting it is invisible: the toolbar gates its region buttons on 2D-vs-3D
387
+ * rather than on plot type, so a 2D mode that skips this shows every tool and silently does
388
+ * nothing. That is exactly what happened to the spatial and region-centroid scatter modes.
389
+ */
390
+ private install2dInteraction;
391
+ /** Marker radius (image px) for a dataset that declares none — segmented cells often don't. */
392
+ private static readonly SPATIAL_FALLBACK_RADIUS;
393
+ /** Smallest marker DIAMETER, in slice pixels, over a volume-backed dataset. A cell's real
394
+ * size is honoured wherever it survives the grid: the ABC atlas serves 5 µm radii on a
395
+ * 40 µm/px template, so drawing them to scale would put every cell a fifth of a pixel wide
396
+ * and the section would come up empty. The point-size control scales up from this floor. */
397
+ private static readonly SPATIAL_SLICE_MIN_DIAMETER_PX;
398
+ /** Longest side of the gene-map raster, in field pixels. A gene map is a smooth
399
+ * field read as territory, so it gains nothing from matching a 2 Gpx slide's
400
+ * resolution — and the estimate costs one pass over this many pixels. */
401
+ private static readonly GENE_MAP_MAX_SIDE;
402
+ /** Kernel σ in field pixels at smoothing 1: wide enough to read between cells,
403
+ * tight enough to keep a nucleus-scale structure distinct. */
404
+ private static readonly GENE_MAP_SIGMA;
405
+ /** Clusters drawn as density volumes at once. Past a handful, additive translucent
406
+ * clouds stop being separable by eye — and each one is a full rasterisation. */
407
+ private static readonly DENSITY_MAX_CLUSTERS;
408
+ /** Colour for observations when nothing is selected to colour by: visible, neutral, and
409
+ * obviously not encoding anything. */
410
+ /** How much larger a selected marker is drawn, so a small selection is findable inside a
411
+ * 3.7M-point cloud rather than merely brighter. */
412
+ private static readonly SPATIAL_SELECTED_SIZE_SCALE;
413
+ private static readonly SPATIAL_NEUTRAL_COLOR;
414
+ /**
415
+ * Mount the SPATIAL_OMICS view: the tissue image with one marker per observation, coloured by
416
+ * an annotation column or a gene. The markers rebuild whenever the dataset or the view state
417
+ * changes, so switching the colour-by column does not remount the scene.
418
+ *
419
+ * Sets up the full 2D interaction stack — region overlay, pixel-tool hosts, readback currency —
420
+ * exactly as the plain image view does. That is NOT optional here: this mode's selection is
421
+ * driven by drawn ROIs, so without the overlay there is no way to make a selection at all.
422
+ */
423
+ private mountSpatialOmics;
424
+ /**
425
+ * Region drawing for the 3D cloud.
426
+ *
427
+ * Reuses {@link NapariRegionOverlay} verbatim by handing it a SCREEN-SPACE viewer: the drawn
428
+ * shape is a lasso in canvas pixels, so "world" is the canvas and both transforms are identity
429
+ * (bar the client→canvas offset). Every existing tool — rectangle, polygon, freehand — therefore
430
+ * works in 3D with no new drawing code.
431
+ *
432
+ * A screen-space shape stops meaning anything the moment the camera moves, so an orbit clears
433
+ * the drawn regions. The SELECTION it produced is kept: that is the durable artefact, and the
434
+ * highlighted points stay highlighted from every angle.
435
+ */
436
+ private install3dInteraction;
437
+ /** Movement, in screen pixels, under which a press-release is a CLICK and not a
438
+ * drag. An orbit or a pan starts the same way, so the two have to be told
439
+ * apart by how far the pointer travelled. */
440
+ private static readonly CLICK_SLOP_PX;
441
+ /** Longest press-release still treated as a click. A long press with the mouse
442
+ * held still is more likely an interrupted drag than a selection. */
443
+ private static readonly CLICK_MAX_MS;
444
+ /** Pointer distance, in screen pixels, that still counts as "on" a marker.
445
+ * Generous relative to a 1.5px disc: the cursor is a blunt instrument, and a
446
+ * tooltip you have to hunt for is worse than none. */
447
+ private static readonly HOVER_RADIUS_PX;
448
+ /**
449
+ * Cursor tooltip for the spatial views: hover a marker, read its class.
450
+ *
451
+ * A 34-entry legend cannot be read back from a dot — several classes get
452
+ * similar colours, and matching one to a swatch by eye is exactly the task this
453
+ * removes. It reports whatever the cloud is CURRENTLY coloured by, so it and
454
+ * the legend can never say different things.
455
+ *
456
+ * Listens on the HOST rather than the canvas so it keeps working over the region
457
+ * overlay (an SVG covering the canvas, which would otherwise swallow every
458
+ * move), and throttles to one hit-test per animation frame: a pointermove can
459
+ * fire far more often than that, and each test is a pass over the cloud.
460
+ */
461
+ private installSpatialHover;
462
+ private removeSpatialHover;
463
+ /** Hit-test the last pointer position and show or hide the tooltip. */
464
+ private updateHover;
465
+ /**
466
+ * Select the class of the marker at a client position — the canvas equivalent of
467
+ * clicking that class in the panel's legend, and the same selection object, so
468
+ * the two controls cannot produce different results.
469
+ *
470
+ * Clicking a class that is already the whole selection CLEARS it, which is what
471
+ * the legend does. Compared against the selection itself rather than against a
472
+ * remembered click, so selecting from the legend and then clicking the same
473
+ * class on the canvas still toggles.
474
+ */
475
+ private selectClassAt;
476
+ /**
477
+ * Which observation is under the cursor.
478
+ *
479
+ * In 3D this defers to napari-js's {@link nearestProjectedIndex} WITH the depths the
480
+ * projection produced, so the front-most candidate wins. That matters more than it
481
+ * sounds: the renderer depth-tests the billboards, and in a 3.7M-point cloud the cursor
482
+ * covers many of them — picking the one nearest the cursor's centre regularly names a
483
+ * cell that something else is drawn over, which reads as a wrong tooltip rather than as
484
+ * a subtlety of picking.
485
+ *
486
+ * In 2D the positions are WORLD coordinates on one plane, so there is no depth to break
487
+ * ties with and the existing nearest-marker rule is the right one.
488
+ */
489
+ private pickObservation;
490
+ /**
491
+ * Positions to hit-test against, rebuilt only when the scene or camera moved.
492
+ *
493
+ * A pass over 3.7M observations is not something to do per pointermove, and the
494
+ * cloud does not move between frames unless something says it did.
495
+ */
496
+ private hoverPositionsFor;
497
+ /**
498
+ * The 2D markers' WORLD positions, indexed by observation, NaN for any not on
499
+ * the displayed plane — the same affine and the same subset the marker layer was
500
+ * built from, so the tooltip cannot point at a cell that is not drawn.
501
+ */
502
+ private hoverWorldPositions;
503
+ /**
504
+ * What the tooltip says, resolved once per colour source rather than per hover.
505
+ *
506
+ * Fetched separately from the colouring on purpose: the reference port caches
507
+ * columns, but a host's need not, and re-fetching a 3.7M-element vector because
508
+ * the pointer moved would be indefensible either way.
509
+ */
510
+ private resolveHoverSource;
511
+ /**
512
+ * Project every observation to canvas pixels under the current 3D camera.
513
+ *
514
+ * The projection itself is the renderer's: `viewer.projectPoints` owns the camera, the
515
+ * viewport in CSS pixels, the perspective divide and the y-flip. This used to be fifteen
516
+ * lines of column-major matrix arithmetic here, duplicating a second copy in
517
+ * `napari-axes-labels` — and the two had drifted on what to do with a NaN w.
518
+ *
519
+ * What is left is the part that is genuinely this adapter's: SCATTERING the drawn subset
520
+ * back into observation order. The cloud holds only the points of the displayed section,
521
+ * in its own packing; every consumer indexes by observation. NaN stands for "not on
522
+ * screen", which is also the right answer for an observation whose section is hidden.
523
+ *
524
+ * Null when the 3D cloud is not mounted, so the caller falls back to the 2D affine path.
525
+ */
526
+ getSpatialScreenProjection(obs: SpatialObservations): Float32Array | null;
527
+ /** Rebuild the markers on any dataset or view-state change. */
528
+ private subscribeSpatial;
529
+ /** (Re)build the observation marker layer for the current dataset + view state. */
530
+ private rebuildSpatialPoints;
531
+ /**
532
+ * Tell the region overlay how finely a drawn vertex may be placed.
533
+ *
534
+ * Region geometry is stored in whole world units, which is right when the world IS
535
+ * pixels — a region should align to them. It is wrong for a dataset that registers onto
536
+ * no image: seqFISH's observations span about 5 x 7 units in total, so whole-unit
537
+ * vertices leave roughly six by eight placeable positions across the entire sample and
538
+ * an ROI cannot be drawn at any zoom. Nothing errors; the tool just cannot express the
539
+ * shape.
540
+ *
541
+ * Keyed on whether the dataset brings PIXELS rather than on the extent, because the
542
+ * extent cannot tell the two apart — 2,000 units is a small slide or a large section
543
+ * depending only on what the units are, and only the dataset knows.
544
+ */
545
+ private setRegionGridFor;
546
+ /**
547
+ * Frame the 2D camera on the observations, for a dataset that brings no image.
548
+ *
549
+ * The 2D camera is normally fitted to the IMAGE, because the observations are in that
550
+ * image's pixel space and framing the image frames them too. A dataset with no
551
+ * reference image has coordinates in its own units instead — seqFISH's span about
552
+ * 5x7 — so the image framing left over from whatever was on screen before puts the
553
+ * whole cloud offscreen: measured at 9.7 x 13.4 PIXELS, centred 256px from where the
554
+ * camera was looking. The points are all there and drawn; they are a speck. Which
555
+ * looks exactly like the dataset having failed to load.
556
+ *
557
+ * Once per dataset, as napari-js's `fit3d: 'once'` does for the cloud: re-colouring,
558
+ * slicing or picking a gene re-adds this layer, and re-framing on those would move
559
+ * the camera under the user — the camera tools and the canvas drag are meant to be
560
+ * the only things that do.
561
+ */
562
+ /**
563
+ * Hide the image layer for a dataset that brings no image of its own.
564
+ *
565
+ * The host's viewer keeps whatever image was last loaded, and for a dataset that
566
+ * registers onto none that picture belongs to something else entirely — the example's
567
+ * default slide, say. It is not merely irrelevant: the two live in different coordinate
568
+ * spaces (image pixels against the embedding's own units), so the leftover is magnified
569
+ * roughly a hundredfold. At the fitted zoom the camera sits inside its corner, where it
570
+ * reads as blank background, and it appears only once you zoom out far enough to find
571
+ * it — which is exactly how it gets noticed.
572
+ *
573
+ * Hidden rather than removed, and hidden HERE rather than by clearing the host's image
574
+ * state: that state drives the whole render pipeline, including a Plotly backend whose
575
+ * fields are declared with definite-assignment assertions, so emptying it throws from
576
+ * whichever field the next path happens to read. This touches one layer's visibility
577
+ * and nothing else.
578
+ */
579
+ private hideForeignImage;
580
+ private frameSpatialPointsOnce;
581
+ /**
582
+ * The **gene map**: the active gene's expression as a continuous field drawn under
583
+ * the cells.
584
+ *
585
+ * A scatter coloured by a gene says which cells express it; it cannot say where,
586
+ * because the eye will not integrate thousands of dots into a territory. The field
587
+ * is a kernel-weighted MEAN per cell (see `spatial-expression.ts`), so a dense
588
+ * region does not glow merely for being dense, and it is transparent wherever no
589
+ * cell was measured — an unsampled gap must not read as "not expressed".
590
+ *
591
+ * It shares the points' LUT, percentile window and log flag, so the layer under
592
+ * the cells and the cells themselves cannot disagree about what a colour means.
593
+ *
594
+ * Estimated on the DISPLAYED image's pixel grid, coarsened so the long side is at
595
+ * most {@link GENE_MAP_MAX_SIDE}: a smooth field gains nothing from a slide's full
596
+ * resolution. For a volume-backed dataset that grid is the current slice, and only
597
+ * that plane's observations are included — the same rule the markers follow.
598
+ */
599
+ private ensureGeneMap;
600
+ /**
601
+ * The plane a volume-backed dataset is currently showing: its pixel affine, the
602
+ * observations that fall in it, and the marker floor that grid needs.
603
+ *
604
+ * Null for a dataset with a real `imageRef` (its coordinates are already the
605
+ * image's pixels and every observation belongs to the one section) and for one
606
+ * with no volume at all — both of which draw exactly as before.
607
+ */
608
+ private spatialSlab;
609
+ /** Per-point colours for the drawn subset. A broadcast tuple stays broadcast —
610
+ * it is one colour for every point either way. */
611
+ private gatherColors;
612
+ /**
613
+ * Redraw the observation markers over a freshly rendered image.
614
+ *
615
+ * Two reasons, both invisible from the spatial store — which is why a scrub or a
616
+ * re-render never reached the markers on its own:
617
+ * - the image render CLEARS the layer list, taking the markers with it;
618
+ * - over a volume-backed dataset the displayed plane decides which
619
+ * observations belong on screen at all, so the cells have to move with the
620
+ * section rather than hang over a different one.
621
+ *
622
+ * Uses the latest values the marker subscription saw; a no-op until it has seen
623
+ * any, in the 3D cloud (no image, no plane), and with no dataset to draw.
624
+ */
625
+ private redrawSpatialMarkers;
626
+ /**
627
+ * Mount the SPATIAL_OMICS_3D view: observations as a 3D point cloud under the orbit camera.
628
+ *
629
+ * Deliberately much thinner than the 2D mount. There is no tissue image to render (a registered
630
+ * volume like the Allen CCF has no single reference plane), so no scale bar, no readback, and no
631
+ * 2D interaction stack — the region tools draw in screen space and have no meaning against an
632
+ * orbiting camera, which is why `Select from ROIs` is hidden in this mode. `addPoints3D` puts
633
+ * the viewer in 3D and frames the camera on the cloud itself.
634
+ */
635
+ private mountSpatialOmics3d;
636
+ /**
637
+ * Scale bar for the 3D cloud, measured at the ORBIT PIVOT.
638
+ *
639
+ * A perspective camera has no single scale — things farther away are smaller — so a bar can
640
+ * only be true at one depth. The pivot is the honest choice: it is what the camera is framing,
641
+ * what a zoom moves towards, and where the eye is anyway. napari's own 3D scale bar works the
642
+ * same way.
643
+ *
644
+ * `NapariScaleBar` needs CSS px per world unit, which an orbit camera does not expose, but at
645
+ * the pivot it is exactly `viewportHeight / (2 * distance * tan(fov / 2))` — the same
646
+ * relationship `Camera3D.pan` uses to track the cursor. The bar then converts through
647
+ * `micronsPerUnit`, and draws nothing at all when the dataset does not declare one, because a
648
+ * bar labelled in microns over unknown units would read as a measurement.
649
+ */
650
+ private installSpatial3dScaleBar;
651
+ /** Base marker diameter for the 3D cloud, in SCREEN pixels (the layer's unit). */
652
+ private static readonly SPATIAL_3D_BASE_SIZE;
653
+ /** (Re)build the 3D point cloud for the current dataset + view state. */
654
+ private rebuildSpatialPoints3d;
655
+ /**
656
+ * The **3D gene map**: the active gene's expression over the whole sectioned
657
+ * specimen, as one raymarched volume.
658
+ *
659
+ * Two things it can be, and the panel's `Volume rendering` toggle picks which:
660
+ *
661
+ * - **sheets** — exactly the planes that were imaged, each carrying that
662
+ * slide's own 2D gene map, with the gaps between sections empty. A stack of
663
+ * measured fields, at their true z.
664
+ * - **volume** — the same fields smoothed along z, so the planes between the
665
+ * sections carry an interpolated value. An estimate, and drawn as a
666
+ * translucent cloud for the same reason the density volumes are.
667
+ *
668
+ * One `VolumeLayer` rather than a textured quad per section: an `ImageLayer`
669
+ * renders only at `ndisplay === 2`, so 53 sheets in the orbit view would need a
670
+ * new layer type upstream — while a scalar volume whose z sampling already IS
671
+ * the section spacing expresses the sheets exactly, and the same lattice then
672
+ * gives the interpolated version for free.
673
+ *
674
+ * Estimated on the reference volume's own lattice (`densityGrid` at stride 1),
675
+ * so the field lands voxel-for-voxel on the anatomy and needs no offset — a
676
+ * `VolumeLayer` has no translate, and napari centres both boxes on the world
677
+ * origin.
678
+ */
679
+ private ensureGeneMapVolume;
680
+ /**
681
+ * Cluster density volumes: each cluster rasterised into a smooth scalar field and
682
+ * raymarched alongside the cloud, tinted with the cluster's own legend colour.
683
+ *
684
+ * This is what makes a serially sectioned dataset readable as an anatomical
685
+ * distribution. The cloud shows measured cells and nothing else — but at 200 µm
686
+ * section spacing the eye cannot integrate a stack of discs into a shape, and
687
+ * every gap reads as absence. A density field is a different object from a cell:
688
+ * an estimate, defined between the imaged planes, drawn as a translucent cloud so
689
+ * it cannot be mistaken for measurement. Individual cells are never interpolated —
690
+ * consecutive sections sample different cells, so there is nothing to interpolate
691
+ * along.
692
+ *
693
+ * One volume per cluster rather than one for everything: additive blending is what
694
+ * makes two clusters' territories comparable, and a single blended field would
695
+ * answer no question anyone asks of a taxonomy. Capped at
696
+ * {@link DENSITY_MAX_CLUSTERS} by cell count.
697
+ *
698
+ * Keyed so it rebuilds only when the field would actually differ — the dataset,
699
+ * the colour column, the bandwidth, or the selection.
700
+ */
701
+ private ensureDensityVolumes;
702
+ /**
703
+ * Identity of the colour scale a continuous spatial layer will draw with, for a
704
+ * cache key: the explicit choice, or else the display colormap and its reverse
705
+ * flag, since that is what {@link spatialContinuousLut} falls back to.
706
+ *
707
+ * The gene maps cache their coloured output, so without this in the key a change
708
+ * of colour scale left the field on screen in the previous colours — the markers
709
+ * recoloured and the map under them did not.
710
+ */
711
+ private continuousColormapKey;
712
+ /**
713
+ * A revision number for a selection object.
714
+ *
715
+ * The store hands out a NEW mask object per change, so object identity is the
716
+ * cheap and exact way to tell two selections apart — a fingerprint over 3.7M
717
+ * mask bytes would be neither. Counting revisions keeps the cache key a short
718
+ * string.
719
+ */
720
+ private selectionRev;
721
+ /**
722
+ * The clusters to rasterise: the categories of the active categorical colouring,
723
+ * biggest first and capped, each with its legend colour.
724
+ *
725
+ * Restricted to the current selection when there is one, so "select a region,
726
+ * check the box" answers which clusters live there. With no categorical colouring
727
+ * there is one group — total cell density, which is a real question on its own
728
+ * ("where is the tissue dense?") and the honest thing to show when the view is
729
+ * not encoding a taxonomy.
730
+ */
731
+ private densityGroups;
732
+ /**
733
+ * Add (or keep) the dataset's reference volume, and derive the offset that sits the
734
+ * observations inside it.
735
+ *
736
+ * `VolumeLayer` has no translate: napari-js maps the volume's unit cube to a world box
737
+ * **centred on the origin**, sized `dims x voxelSize`. The observations, by contract, are in
738
+ * the volume's own frame with its near corner at the coordinate origin. So the two only line
739
+ * up if the POINTS move — by half the box — which is what {@link spatialOrigin3d} is.
740
+ *
741
+ * A failed or absent volume is not fatal: the cloud renders on its own, at its own
742
+ * coordinates, and the camera frames the points instead.
743
+ */
744
+ private ensureSpatialVolume;
745
+ private removeSpatial3dLayers;
746
+ /** A one-colour colormap, for the "nothing to colour by" state. */
747
+ private spatialFlatColormap;
748
+ /**
749
+ * The per-point scalar + colormap + window that colour the 3D cloud.
750
+ *
751
+ * Continuous data is the natural fit: values go straight through the active colormap with the
752
+ * same percentile window the 2D path uses. Categorical data has to be smuggled through the same
753
+ * scalar channel — see {@link SPATIAL_3D_MAX_CATEGORIES}. Codes map to LUT blocks, and
754
+ * `contrastLimits` of `[-0.5, K - 0.5]` puts code `i` at the centre of block `i`, which is what
755
+ * makes the round-trip exact instead of approximately right.
756
+ */
757
+ private spatialScalar3d;
758
+ /** Category codes → a stepped LUT, exact for up to {@link SPATIAL_3D_MAX_CATEGORIES}. */
759
+ private encodeSpatial3dCategorical;
760
+ /** Continuous values → the active colormap over a percentile-clipped window. */
761
+ private encodeSpatial3dContinuous;
762
+ /**
763
+ * Per-observation colours for the current view state, or a single flat colour when nothing is
764
+ * selected to colour by. Categorical columns use the column's own palette; continuous columns
765
+ * and gene vectors go through the active colormap with a percentile-clipped window.
766
+ */
767
+ private spatialFaceColors;
768
+ /** Continuous values → RGBA through the active colormap and a clipped window. */
769
+ private encodeSpatialContinuous;
289
770
  /**
290
771
  * Mount the NAPARI_SCATTER3D 3D scatter: the downsampled voxel grid as a 3D point cloud colored
291
772
  * by intensity (napari-js analog of Plotly's voxel scatter3d). Assembles a coarse volume, then
@@ -451,7 +932,7 @@ export declare class NapariVisualizerService extends BaseStoreVisualizer impleme
451
932
  * only (omitted when all are visible → server default). Mirrors the OSD backend. */
452
933
  exportData(): Promise<void>;
453
934
  unsubscribe(): void;
454
- static ɵfac: i0.ɵɵFactoryDeclaration<NapariVisualizerService, [null, null, null, null, null, null, null, null, null, null, { optional: true; }, null, null]>;
935
+ static ɵfac: i0.ɵɵFactoryDeclaration<NapariVisualizerService, [null, null, null, null, null, null, null, null, null, null, { optional: true; }, null, null, { optional: true; }, null]>;
455
936
  static ɵprov: i0.ɵɵInjectableDeclaration<NapariVisualizerService>;
456
937
  }
457
938
  export {};
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Wheel-zoom sensitivity for the napari-js backend, as `exp(-delta * speed)` per normalized,
3
+ * clamped wheel-delta unit.
4
+ *
5
+ * Derived from {@link OSD_ZOOM_PER_SCROLL} rather than written as its own number: the two backends
6
+ * draw the same images, and a wheel that zooms further under one renderer than the other is a
7
+ * difference the user has no way to explain.
8
+ *
9
+ * napari-js clamps a single event's delta before applying the speed — mouse notches and trackpad
10
+ * momentum ticks both exceed the clamp, so the clamped step IS the per-notch step — which makes
11
+ * matching the two a matter of solving `exp(WHEEL_DELTA_CLAMP * speed) = OSD_ZOOM_PER_SCROLL`.
12
+ * The clamp is imported rather than copied: it belongs to napari-js, and a local literal would go
13
+ * silently stale the day that library retuned it.
14
+ */
15
+ export declare const NAPARI_WHEEL_ZOOM_SPEED: number;
@@ -10,6 +10,7 @@ import { PlotType, PlotTypeDescriptor } from '../../contracts/plot-type';
10
10
  import { IVisualizer, PixelData, IntensityProfile, IIsosurfaceControls, IIntensityControls, ISurface3dControls } from '../../contracts/visualizer.contract';
11
11
  import { ViewerCapabilities } from '../../contracts/capabilities.contract';
12
12
  import { IRegionOverlay } from '../../contracts/region-overlay.contract';
13
+ import { PlotModeViewport } from '../../contracts/plot-type-contribution.contract';
13
14
  import { BaseStoreVisualizer } from '../base-store-visualizer';
14
15
  import { SimpleSliceAccessService } from '../simple-slice-access.service';
15
16
  import { WandToolService } from '../../toolbar/wand/wand-tool.service';
@@ -221,6 +222,11 @@ export declare class OpenSeadragonVisualizerService extends BaseStoreVisualizer
221
222
  /** Visible image region (full-image pixel coords) emitted when the view
222
223
  * settles, so the intensity inset can re-sample at the current zoom level. */
223
224
  private readonly viewportChange$;
225
+ /** The visible rect on every redraw, coalesced to one emission per animation
226
+ * frame. Feeds {@link PlotModeViewport.frame$}. */
227
+ private readonly frame$;
228
+ private frameRaf;
229
+ private plotModeViewport;
224
230
  private readonly intensityProfile$;
225
231
  /**
226
232
  * Subscribe to the shared VisualizerStore colormap/reverse so OSD recolors in
@@ -410,6 +416,21 @@ export declare class OpenSeadragonVisualizerService extends BaseStoreVisualizer
410
416
  /** Compute the current viewport's image-pixel rectangle (clamped to the image)
411
417
  * and broadcast it. */
412
418
  private emitViewportChange;
419
+ /** The current viewport's image-pixel rectangle, clamped to the image, or
420
+ * null when there is no laid-out viewport. */
421
+ private visibleImageRect;
422
+ /** Coalesce redraws into one {@link frame$} emission per animation frame.
423
+ * Skipped entirely while nothing listens. */
424
+ private scheduleFrame;
425
+ /**
426
+ * The viewport a contributed plot mode draws over. One stable object per
427
+ * service: every method reads the CURRENT viewer, so it stays valid across a
428
+ * viewer rebuild and reports `isReady() === false` while none is mounted
429
+ * (conversions then return NaN rather than throwing).
430
+ */
431
+ getPlotModeViewport(): PlotModeViewport;
432
+ /** `source`, preceded by the current visible rect for each subscriber (when ready). */
433
+ private withCurrentRect;
413
434
  /** OSD targets the image display; the scalar/3D plot types belong to Plotly. */
414
435
  getPlotTypeDescriptors(): PlotTypeDescriptor[];
415
436
  getIntensityProfile$(): Observable<IntensityProfile[]>;
@@ -13,5 +13,11 @@ export declare class OsdCoordinateTransform implements ICoordinateTransform {
13
13
  x: number;
14
14
  y: number;
15
15
  };
16
+ /** Data (image-pixel) coordinates -> client (mouse event) pixel. The inverse
17
+ * of {@link clientToData}. */
18
+ dataToClient(x: number, y: number): {
19
+ x: number;
20
+ y: number;
21
+ };
16
22
  dataLengthToScreen(dataLength: number): number;
17
23
  }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Wheel-zoom step for the OpenSeadragon backend.
3
+ *
4
+ * Applied per scroll EVENT, not per unit of delta — which is what OpenSeadragon's own
5
+ * `zoomPerScroll` does, and what the region overlay does when an active tool has taken the wheel
6
+ * over. That makes the step compound with the number of events a device sends: a mouse notch is
7
+ * one event, but a trackpad swipe is a burst of them, so a step that feels right for a mouse runs
8
+ * away under a trackpad.
9
+ *
10
+ * At the previous 1.1 (OpenSeadragon's default is 1.2) an image doubled in 7 notches, against the
11
+ * ~24 the napari-js backend took for the same picture — the same gesture zoomed several times
12
+ * further depending only on which renderer was active. 1.03 puts the two within a whisker of each
13
+ * other, so the wheel feels the same whichever backend is drawing.
14
+ *
15
+ * Shared because the viewer option and the overlay's handler MUST agree: whether the wheel is
16
+ * handled by OpenSeadragon or by the overlay depends on whether a region tool is active, and the
17
+ * zoom visibly changing pace when a tool is picked up is the bug this constant prevents.
18
+ */
19
+ export declare const OSD_ZOOM_PER_SCROLL = 1.03;