@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
@@ -6,7 +6,10 @@ import { ClassPreset, PresetSet } from './models/class-preset';
6
6
  import { PlotlyService } from './implementations/plotly/plotly.service';
7
7
  import { OpenSeadragonVisualizerService } from './implementations/osd/openseadragon-visualizer.service';
8
8
  import { PlotType, PlotTypeDescriptor } from './contracts/plot-type';
9
- import { IVisualizer, PixelData, IntensityProfile, IIsosurfaceControls, IIntensityControls, ISurface3dControls } from './contracts/visualizer.contract';
9
+ import { IVisualizer, PixelData, IntensityProfile, IIsosurfaceControls, IIntensityControls, ISurface3dControls, ISpatialControls } from './contracts/visualizer.contract';
10
+ import { SpatialDataPort } from './contracts/ports/spatial-data.port';
11
+ import { RegionStore } from './store/region-store.service';
12
+ import { SpatialSelectionStore } from './store/spatial-selection.service';
10
13
  import { ViewerCapabilities } from './contracts/capabilities.contract';
11
14
  import { IRegionOverlay } from './contracts/region-overlay.contract';
12
15
  import { IRegionEditorApi } from './contracts/region-editor-api.contract';
@@ -14,6 +17,7 @@ import { IChannelHistogramApi, IChannelState, IHistogram } from './contracts/cha
14
17
  import { VisualizerStore } from './store/visualizer-store.service';
15
18
  import { NapariVisualizerService } from './implementations/napari-js/napari-visualizer.service';
16
19
  import { VizConfig } from './contracts/viz-config';
20
+ import { PlotModeViewport } from './contracts/plot-type-contribution.contract';
17
21
  import * as i0 from "@angular/core";
18
22
  export declare class RoutingVisualizerService implements IVisualizer, IRegionEditorApi, IChannelHistogramApi {
19
23
  private plotly;
@@ -21,6 +25,9 @@ export declare class RoutingVisualizerService implements IVisualizer, IRegionEdi
21
25
  private napari;
22
26
  private store;
23
27
  private config;
28
+ private regionStore;
29
+ private selectionStore;
30
+ private spatialData;
24
31
  private currentPlotType;
25
32
  private lastRendered;
26
33
  /** OSD failed for the *current* render cycle (e.g. a fresh file still caching
@@ -32,12 +39,18 @@ export declare class RoutingVisualizerService implements IVisualizer, IRegionEdi
32
39
  /** napari-js failed for the current render cycle → fall back to OSD (then Plotly).
33
40
  * Reset by reset() at the start of every cycle, like {@link osdFellBack}. */
34
41
  private napariFellBack;
35
- constructor(plotly: PlotlyService, osd: OpenSeadragonVisualizerService, napari: NapariVisualizerService, store: VisualizerStore, config: VizConfig);
42
+ constructor(plotly: PlotlyService, osd: OpenSeadragonVisualizerService, napari: NapariVisualizerService, store: VisualizerStore, config: VizConfig, regionStore: RegionStore, selectionStore: SpatialSelectionStore, spatialData?: SpatialDataPort | null);
36
43
  private isImageType;
37
- /** The 2D napari types (image + region-centroid scatter) — a 2D fallback chain (OSD, then Plotly). */
44
+ /** The 2D napari types (image, region-centroid scatter, spatial omics) — a 2D fallback chain
45
+ * (OSD, then Plotly).
46
+ *
47
+ * NOTE for SPATIAL_OMICS: only napari-js draws the observation markers. If it fails (no WebGPU,
48
+ * device loss) the fallback renders the tissue image ALONE — the spatial layer is absent, not
49
+ * degraded. That is deliberate for now: showing the slide beats showing nothing, and a Plotly
50
+ * `scattergl` spatial mode is the planned real fallback (see the plot-mode design doc). */
38
51
  private isNapariImageType;
39
- /** The 3D napari types (volume/isosurface, either resolution) — no 2D fallback exists (OSD is
40
- * image-only), so they fall straight to Plotly. */
52
+ /** The 3D napari types (volume/isosurface/surface/scatter, plus the 3D spatial cloud) — no 2D
53
+ * fallback exists (OSD is image-only), so they fall straight to Plotly. */
41
54
  private isNapari3dType;
42
55
  /**
43
56
  * Backend to attempt for the current plot type this render. The fallback chains below are
@@ -86,6 +99,9 @@ export declare class RoutingVisualizerService implements IVisualizer, IRegionEdi
86
99
  resetSurfaceCamera(): void;
87
100
  setResolutionScale(scale: number): void;
88
101
  getResolutionScale(): number;
102
+ /** The viewport of the backend on screen, for a contributed plot mode. Null
103
+ * when that backend cannot provide one (e.g. OSD fell back to Plotly). */
104
+ getPlotModeViewport(): PlotModeViewport | null;
89
105
  getPlotTypeDescriptors(): PlotTypeDescriptor[];
90
106
  setStackLoading(b: boolean): void;
91
107
  cancelLoading(): void;
@@ -207,6 +223,33 @@ export declare class RoutingVisualizerService implements IVisualizer, IRegionEdi
207
223
  /** Intensity (line-ROI) controls — always Plotly's, since the line profiles
208
224
  * render their inset on Plotly regardless of which backend draws the image. */
209
225
  getIntensityControls(): IIntensityControls | null;
226
+ /**
227
+ * Spatial-omics controls, or null when no `SPATIAL_DATA_PORT` is bound.
228
+ *
229
+ * Implemented HERE rather than on a backend because the state is
230
+ * backend-neutral: the view state lives in the shared `VisualizerStore` (like
231
+ * the colormap), so the controls keep working across a plot-type switch and a
232
+ * host can drive them before any backend has mounted.
233
+ */
234
+ getSpatialControls(): ISpatialControls | null;
235
+ /**
236
+ * Whether `dataset` can answer this colour source.
237
+ *
238
+ * A column is checkable against the declared list. A gene is checkable only when
239
+ * the dataset inlines its feature names — a dataset too wide to inline them
240
+ * (typeahead-only) keeps the source, and a name that turns out not to exist
241
+ * surfaces as the chart's "could not be charted" rather than as a silent reset.
242
+ */
243
+ private canColorBy;
244
+ /** Memoised so consumers can hold the object across calls. */
245
+ private spatialControls;
246
+ /** Latest dataset, mirrored so `selectFromRegions()` can answer synchronously
247
+ * — it runs from a button click and must not await a round-trip. */
248
+ private currentSpatialDataset;
249
+ /** Displayed slice. Only a volume-backed spatial dataset reads it, to keep a
250
+ * region drawn over one section from selecting the whole depth behind it. */
251
+ private currentZIndex;
252
+ private spatialDatasetSub;
210
253
  /** Load pixel frames for intensity sampling when OpenSeadragon owns the image
211
254
  * (it doesn't feed Plotly's frame cache). No-op needed when Plotly renders. */
212
255
  ensureIntensitySampling(imageInfo: IImageInfo, zIndex: number): Promise<void>;
@@ -223,6 +266,6 @@ export declare class RoutingVisualizerService implements IVisualizer, IRegionEdi
223
266
  * ROI at display resolution (sampling always lives in Plotly). */
224
267
  refreshIntensitySamplingForRoi(x: number, y: number, width: number, height: number, zIndex: number): void;
225
268
  unsubscribe(): void;
226
- static ɵfac: i0.ɵɵFactoryDeclaration<RoutingVisualizerService, never>;
269
+ static ɵfac: i0.ɵɵFactoryDeclaration<RoutingVisualizerService, [null, null, null, null, null, null, null, { optional: true; }]>;
227
270
  static ɵprov: i0.ɵɵInjectableDeclaration<RoutingVisualizerService>;
228
271
  }
@@ -0,0 +1,73 @@
1
+ import { SpatialEmbedding, SpatialEmbeddingMeta } from '../contracts/spatial-dataset.contract';
2
+ /**
3
+ * Run a t-SNE in a Web Worker and report progress.
4
+ *
5
+ * The panel offers this for a dataset that publishes no t-SNE — a dropped-in `.h5ad`
6
+ * typically carries one UMAP and nothing else. The server reduces to PCA (it needs the
7
+ * whole expression matrix, which the browser should not download); this takes those
8
+ * scores and embeds them here, where the GPU is.
9
+ *
10
+ * A thin wrapper on purpose: no Angular, no store. It owns one worker and one run, so a
11
+ * component can hold it, show a progress bar, and cancel — and so the whole thing can be
12
+ * tested by substituting a worker.
13
+ */
14
+ export interface ComputeProgress {
15
+ /** 0..1, or null before the first report — a run starts with kNN and P, not iterations. */
16
+ fraction: number | null;
17
+ /** 'webgpu', 'wasm' or 'cpu' once known: it changes what a long wait means. */
18
+ backend: string | null;
19
+ message: string | null;
20
+ }
21
+ export interface ComputeRequest {
22
+ /** PCA scores, row-major `nObs x nDims`. */
23
+ scores: Float32Array;
24
+ nObs: number;
25
+ nDims: number;
26
+ dims: 2 | 3;
27
+ perplexity?: number;
28
+ iterations?: number;
29
+ seed?: number;
30
+ }
31
+ /** Factory, so a test can supply a worker without a bundler. */
32
+ export type WorkerFactory = () => Worker;
33
+ export declare class EmbeddingComputeRun {
34
+ private readonly factory?;
35
+ private worker;
36
+ private settled;
37
+ /**
38
+ * Settles a run that is still waiting, set for as long as one is.
39
+ *
40
+ * `terminate()` is called from outside — a dataset switch, a component being destroyed —
41
+ * and killing the worker on its own leaves the promise `run()` returned pending FOR
42
+ * EVER, because the settlement it was waiting for arrives as a worker message. The
43
+ * awaiting closure, and everything it captured, is retained with it. So termination
44
+ * settles the run itself.
45
+ */
46
+ private abandon;
47
+ /**
48
+ * `factory` is optional: without one the real worker module is imported on demand.
49
+ *
50
+ * Dynamically, and that is not laziness for its own sake — the module holds an
51
+ * `import.meta.url`, which ts-jest's CommonJS compile rejects outright, so a static
52
+ * import here would break every spec that touches this file.
53
+ */
54
+ constructor(factory?: WorkerFactory | undefined);
55
+ /**
56
+ * Start a run. Resolves with the coordinates, or null if cancelled.
57
+ *
58
+ * Rejects only on a real failure. Cancellation is a NULL rather than a rejection: a user
59
+ * pressing Cancel is not an error, and making callers tell the two apart in a catch
60
+ * block is how a cancelled run ends up reported as a crash.
61
+ */
62
+ run(request: ComputeRequest, meta: SpatialEmbeddingMeta, onProgress: (progress: ComputeProgress) => void): Promise<SpatialEmbedding | null>;
63
+ /** Ask the run to stop. It resolves with null shortly after. */
64
+ cancel(): void;
65
+ /**
66
+ * Stop immediately, abandoning any result. Safe to call twice.
67
+ *
68
+ * Unlike {@link cancel}, this does not wait for the worker to acknowledge — the run is
69
+ * settled here, with null, because nothing is left to deliver the answer.
70
+ */
71
+ terminate(): void;
72
+ get running(): boolean;
73
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Narrowing a gene list to the handful a picker should actually render.
3
+ *
4
+ * A targeted panel has a few hundred genes and can simply be listed. A
5
+ * whole-transcriptome assay has ~18k-31k, and handing that many options to a dropdown
6
+ * costs a visible freeze on every keystroke — the control rebuilds and re-filters the
7
+ * whole array, and the browser lays out what the virtual scroller has not yet clipped.
8
+ * Measured on the Visium H&E bundle: 18,078 names.
9
+ *
10
+ * The names themselves are cheap to hold — strings already parsed from the manifest — so
11
+ * this does not fetch anything. What it caps is how many OPTION OBJECTS are ever
12
+ * materialised for the view. The rest of the corpus stays a plain array, searched per
13
+ * keystroke, which is the work a dropdown was doing anyway; the saving is in not asking
14
+ * a component to render, diff and re-filter thousands of rows it will never show.
15
+ *
16
+ * Ranking is PREFIX FIRST, then substring. Typing "Mb" should surface `Mbp` above
17
+ * `Adam1b`, which a plain `includes` would not do. It deliberately mirrors the example
18
+ * server's `/spatial/:id/features?q=` (see `lib/spatial.mjs`) so a dataset that inlines
19
+ * its names and one that does not order their suggestions the same way — the picker must
20
+ * not appear to rank differently depending on how the data happens to be served.
21
+ *
22
+ * Pure — no Angular, no port, no DOM — like `spatial-heatmap.ts`, and tested the same way.
23
+ */
24
+ /**
25
+ * How many options a picker will materialise at once.
26
+ *
27
+ * Above the virtual-scroll threshold, so a capped list is still scrolled rather than laid
28
+ * out in full, and far below the point where rebuilding the array is perceptible.
29
+ */
30
+ export declare const GENE_OPTIONS_MAX = 500;
31
+ /**
32
+ * The best `limit` matches for `query`, prefix matches first.
33
+ *
34
+ * An empty query returns the head of the list rather than nothing: the dropdown opens
35
+ * before anything is typed, and an empty menu reads as "no genes" rather than "start
36
+ * typing". Matching is case-insensitive, because gene symbols are capitalised by
37
+ * convention and nobody types `Slc17a7` exactly.
38
+ */
39
+ export declare function searchGeneNames(names: readonly string[], query: string, limit?: number): string[];
40
+ /**
41
+ * The options a picker should show: the best matches, plus anything already chosen.
42
+ *
43
+ * A multi-select whose option list is capped must still carry its SELECTED values, or the
44
+ * control cannot resolve them — the chips lose their labels and, worse, a value absent
45
+ * from the options can be dropped from the model on the next change. Selections come
46
+ * first so they stay visible while a search narrows everything else.
47
+ */
48
+ export declare function geneOptionsFor(names: readonly string[], query: string, selected?: readonly string[], limit?: number): string[];
@@ -0,0 +1,73 @@
1
+ import { SpatialDataset, SpatialObservations } from '../contracts/spatial-dataset.contract';
2
+ /**
3
+ * Turning a cell cloud into a density field — the honest way to render a cluster
4
+ * in 3D when the sections it was measured on are hundreds of microns apart.
5
+ *
6
+ * Individual cells CANNOT be interpolated between sections: consecutive sections
7
+ * sample entirely different cells, so there is no correspondence to interpolate
8
+ * along, and inventing positions would fabricate observations indistinguishable
9
+ * from measured ones. A density field is a different object: a continuous
10
+ * estimate, legitimately defined between the planes that were imaged, and it
11
+ * renders as a translucent cloud rather than as dots, so it reads as an estimate.
12
+ *
13
+ * Two things make the estimate honest rather than decorative, and both are
14
+ * implemented here:
15
+ * - the kernel is **anisotropic**, with a z bandwidth at least the section
16
+ * spacing. An isotropic kernel leaves one disc per section — a sampling
17
+ * artefact that looks like biology;
18
+ * - the field is **coverage-normalised** along z (Nadaraya–Watson): unsampled
19
+ * planes would otherwise read as genuinely empty tissue rather than as tissue
20
+ * nobody imaged.
21
+ */
22
+ /** The voxel lattice a density field is estimated on. */
23
+ export interface DensityGrid {
24
+ width: number;
25
+ height: number;
26
+ depth: number;
27
+ /** World size of one voxel per axis, in the observations' units. */
28
+ voxelSize: [number, number, number];
29
+ }
30
+ export interface DensityOptions {
31
+ /** Kernel σ per axis, in the observations' units. */
32
+ sigma: [number, number, number];
33
+ /** Observations to include. Every observation when absent. */
34
+ indices?: Uint32Array;
35
+ }
36
+ /**
37
+ * The lattice to estimate on.
38
+ *
39
+ * A dataset with a registered volume estimates on that volume's own box, so the
40
+ * field lands exactly on the anatomy it will be drawn inside — `stride` coarsens
41
+ * it (density is smooth by construction, and an eighth of the voxels is an eighth
42
+ * of the work) while keeping the physical extent identical, so the two still
43
+ * align. Without a volume the box comes from the observations' own bounds.
44
+ */
45
+ export declare function densityGrid(dataset: SpatialDataset, stride?: number, targetLongAxis?: number, zStride?: number): DensityGrid | null;
46
+ /**
47
+ * In-place separable Gaussian along one axis of a w x h x d field.
48
+ *
49
+ * Exported because the gene-map volume estimates a different quantity on the same
50
+ * lattice with the same kernel (see `spatial-expression.ts`): two copies of a
51
+ * separable blur would be two chances to smooth the estimate and its normaliser
52
+ * differently, which is the one thing a Nadaraya-Watson field cannot survive.
53
+ */
54
+ export declare function blurVolumeAxis(field: Float32Array, w: number, h: number, d: number, axis: 0 | 1 | 2, sigmaVox: number): void;
55
+ /**
56
+ * Rasterise observations into a uint8 density field on `grid`.
57
+ *
58
+ * Returned as uint8 scaled to its own maximum: a `VolumeLayer` renders 0..255, and
59
+ * the absolute cell count per voxel is not the readable quantity anyway — where
60
+ * the cluster is dense RELATIVE to itself is. Null when nothing lands on the grid,
61
+ * so the caller draws no layer rather than an empty box.
62
+ */
63
+ export declare function rasterizeDensity(obs: SpatialObservations, grid: DensityGrid, opts: DensityOptions): Uint8Array | null;
64
+ /**
65
+ * Default kernel for a grid: in-plane tight, along z at least the section
66
+ * spacing.
67
+ *
68
+ * The z term is the load-bearing one — 1.5 voxels of a grid whose depth voxel IS
69
+ * the section spacing puts σ just above one section gap, which is the smallest
70
+ * bandwidth that can bridge one without leaving a disc per plane. `smoothing`
71
+ * scales all three for the user control.
72
+ */
73
+ export declare function defaultSigma(grid: DensityGrid, smoothing?: number): [number, number, number];
@@ -0,0 +1,155 @@
1
+ import { Rgb } from '../contracts/colormap-lut';
2
+ import { CategoricalColumnMeta, SpatialObservations } from '../contracts/spatial-dataset.contract';
3
+ import { ColormapValue } from '../contracts/display-types';
4
+ /**
5
+ * Turning spatial-omics columns into per-point visual attributes.
6
+ *
7
+ * Pure — no Angular, no RxJS, no renderer — like `plotly-trace-builders.ts`, so
8
+ * the encodings are testable on their own and shared by any backend that grows
9
+ * a spatial mode.
10
+ *
11
+ * The rules here are the ones the CosMx analysis guidance prescribes, made
12
+ * explicit rather than left to each call site:
13
+ * - count-like columns read on a **log** scale (linear collapses them against a
14
+ * few bright outliers);
15
+ * - contrast windows come from **percentiles**, not min/max, so a single
16
+ * saturated spot doesn't flatten everything else;
17
+ * - context is kept by **muting** rather than hiding — background points stay
18
+ * visible at low alpha so the tissue shape survives.
19
+ *
20
+ * COLOUR REPRESENTATION
21
+ * ---------------------
22
+ * Internally everything is a flat `Float32Array` of `[r,g,b,a, …]` in 0..1 —
23
+ * one allocation for N points, GPU-upload shaped. napari-js 0.12.0's
24
+ * `PointsLayer.faceColor` takes `RGBA[]` (an array of 4-tuples) instead, so
25
+ * {@link toRgbaTuples} adapts at the boundary; when the layer accepts a typed
26
+ * array the adapter goes away and the flat buffer is passed straight through.
27
+ */
28
+ /** napari-js per-point colour tuple: r, g, b, a in 0..1. */
29
+ export type RGBA = [number, number, number, number];
30
+ /** Neutral grey for observations with no value (NaN, or {@link NO_CATEGORY}). */
31
+ export declare const MISSING_COLOR: Rgb;
32
+ /** Default alpha for muted (background / unselected) points — CosMx-style context. */
33
+ export declare const DEFAULT_MUTED_OPACITY = 0.15;
34
+ /**
35
+ * Largest number of CATEGORIES whose colours survive the 3D points layer's LUT
36
+ * intact.
37
+ *
38
+ * That layer has no per-point RGBA — it maps a per-point SCALAR through a
39
+ * 256-entry colormap LUT — so a categorical palette is encoded as one contiguous
40
+ * block of LUT entries per value. Measured against napari-js, every K in 2..96
41
+ * blocks round-trips exactly and K=97 is the first that does not: 256 texels
42
+ * cannot keep more values apart than that.
43
+ *
44
+ * One of those 96 blocks is spent on {@link NO_CATEGORY}, which every column can
45
+ * contain, so what fits is **95 categories** — the number published here. It was
46
+ * 96 while the encoder rejected at 96, which meant a 96-category column drew flat
47
+ * with the panel showing no warning: the one failure this ceiling exists to
48
+ * prevent, arrived at from the other direction.
49
+ *
50
+ * Above it the cloud draws FLAT rather than draw a lie, because subtly wrong
51
+ * category colours under a confident legend is the failure nobody catches.
52
+ *
53
+ * A limit of that one layer, and of nothing else: the 2D markers carry per-point
54
+ * RGBA, and a density volume is a scalar field per cluster. Shared here so the
55
+ * renderer that enforces it and the panel that explains it cannot drift apart.
56
+ */
57
+ export declare const SPATIAL_3D_MAX_CATEGORIES = 95;
58
+ /** LUT blocks the 3D layer can keep apart — {@link SPATIAL_3D_MAX_CATEGORIES}
59
+ * plus the one reserved for a missing value. */
60
+ export declare const SPATIAL_3D_LUT_BLOCKS: number;
61
+ /**
62
+ * Fallback categorical palette. Colour-blind-safe and stable, so a category
63
+ * without an authored colour still lands somewhere sensible and lands there
64
+ * every run (`fallbackColorFor` hashes the name — same name, same colour).
65
+ * Okabe–Ito, plus two greys for long tails.
66
+ */
67
+ export declare const DEFAULT_CATEGORICAL_PALETTE: string[];
68
+ interface MuteOptions {
69
+ /** Alpha for points that are not muted. Default 1. */
70
+ opacity?: number;
71
+ /** Alpha for muted points. Default {@link DEFAULT_MUTED_OPACITY}. */
72
+ mutedOpacity?: number;
73
+ /** `1` at index i mutes observation i. Absent ⇒ nothing is muted. */
74
+ muted?: Uint8Array | null;
75
+ }
76
+ export interface CategoricalEncodingOptions extends MuteOptions {
77
+ /** Per-category `#rrggbb`, index-aligned with the column's categories. */
78
+ colors: string[];
79
+ /** Colour for {@link NO_CATEGORY}. Default {@link MISSING_COLOR}. */
80
+ missingColor?: Rgb;
81
+ }
82
+ export interface ContinuousEncodingOptions extends MuteOptions {
83
+ /** 256-entry RGB table from {@link buildColormapLut}. */
84
+ lut: Rgb[];
85
+ /** Contrast window in VALUE units (pre-log). */
86
+ min: number;
87
+ max: number;
88
+ /** Apply `log1p` to the value and the window before normalising. */
89
+ log?: boolean;
90
+ /** Colour for `NaN`. Default {@link MISSING_COLOR}. */
91
+ missingColor?: Rgb;
92
+ }
93
+ /** Parse `#rgb` / `#rrggbb` to 0–255 RGB; unparseable input falls back to grey.
94
+ * Exported so the 3D LUT path parses palettes exactly as the 2D path does. */
95
+ export declare function parseHex(hex: string): Rgb;
96
+ /**
97
+ * Per-category colours: the column's authored `colors` where present (so the
98
+ * viewer matches the figures the same analysis produced elsewhere), otherwise
99
+ * the palette by position, wrapping through a name-hash past its end so a
100
+ * 40-cluster column still gets stable, distinct-ish colours.
101
+ */
102
+ export declare function resolveCategoryColors(meta: CategoricalColumnMeta, palette?: string[]): string[];
103
+ /** Categorical column → flat per-point RGBA (length `4 * codes.length`). */
104
+ export declare function encodeCategorical(codes: Uint16Array, opts: CategoricalEncodingOptions): Float32Array;
105
+ /** Continuous column / gene vector → flat per-point RGBA. */
106
+ export declare function encodeContinuous(values: Float32Array, opts: ContinuousEncodingOptions): Float32Array;
107
+ /**
108
+ * Contrast window from percentiles of the finite values — the CosMx
109
+ * "cap outliers at percentiles to improve contrast" rule. `lo`/`hi` are
110
+ * fractions (0.01 / 0.99 clips the extreme 1% at each end).
111
+ *
112
+ * Returns `[0, 1]` for an empty or all-missing vector so callers always get a
113
+ * usable window.
114
+ */
115
+ export declare function contrastWindow(values: Float32Array, lo?: number, hi?: number): [number, number];
116
+ /**
117
+ * Marker sizes for the points layer. napari-js `PointsLayer.size` is a
118
+ * **diameter** in data units while `SpatialObservations.radius` is a radius —
119
+ * the doubling lives here so no call site has to remember it.
120
+ *
121
+ * `fallbackRadius` covers a dataset that declares no radius (segmented cells
122
+ * often don't); it is in image pixels, like everything else in this space.
123
+ */
124
+ export declare function markerDiameters(observations: SpatialObservations, fallbackRadius?: number): number | Float32Array;
125
+ /**
126
+ * Adapt a flat RGBA buffer to the `RGBA[]` shape napari-js 0.12.0's
127
+ * `PointsLayer.faceColor` accepts. One 4-element array per point — the reason
128
+ * this is a named boundary rather than inlined: it is the one place the model
129
+ * stops being typed-array shaped, and it is what a future napari-js taking a
130
+ * `Float32Array` would delete.
131
+ */
132
+ export declare function toRgbaTuples(flat: Float32Array): RGBA[];
133
+ /** Convenience: a 256-entry LUT for a colormap value, falling back to Viridis. */
134
+ export declare function lutFor(colormapValue: unknown, reverse?: boolean): Rgb[];
135
+ /** True when every entry of a LUT is a neutral grey. Tested on the VALUES rather
136
+ * than the colormap's name, so a reversed or renamed grey ramp still counts. */
137
+ export declare function isGrayscaleLut(lut: readonly Rgb[]): boolean;
138
+ /**
139
+ * LUT for a spatial CONTINUOUS encoding — a gene, a numeric column.
140
+ *
141
+ * `override` is the caller's explicit choice and wins outright, grey included: a
142
+ * grey ramp picked FOR the data is a decision, not the image's colormap leaking
143
+ * in.
144
+ *
145
+ * Without one, the display colormap — unless that colormap is grayscale. The
146
+ * tissue underneath is grayscale, so a grey overlay of a measurement is
147
+ * indistinguishable from the anatomy it is drawn over, and the reader cannot tell
148
+ * which channel a bright pixel belongs to. Viridis is then the fallback, which is
149
+ * what the CosMx guidance prescribes for continuous values anyway.
150
+ *
151
+ * Shared by the markers, both gene maps and the panel's colour bar, so none of
152
+ * them can disagree about what a colour means, whichever way the choice falls.
153
+ */
154
+ export declare function spatialContinuousLut(colormapValue: unknown, reverse?: boolean, override?: ColormapValue | null): Rgb[];
155
+ export {};
@@ -0,0 +1,144 @@
1
+ import { SpatialImageRef, SpatialObservations } from '../contracts/spatial-dataset.contract';
2
+ import { DensityGrid } from './spatial-density';
3
+ /**
4
+ * Turning per-cell expression into a **gene map** — a continuous field over the
5
+ * tissue, drawn under the cells rather than instead of them.
6
+ *
7
+ * A scatter coloured by a gene answers "which cells express it"; it does not answer
8
+ * "where is it expressed", because the eye cannot integrate thousands of small dots
9
+ * into a territory. A field can be estimated between the cells and read as a
10
+ * region, which is what the CosMx guidance and Spatial-Live's heat layer both do.
11
+ *
12
+ * The quantity is the kernel-weighted **MEAN per cell**, not a sum:
13
+ *
14
+ * ```
15
+ * mean(p) = Σᵢ w(p, xᵢ)·eᵢ / Σᵢ w(p, xᵢ)
16
+ * ```
17
+ *
18
+ * A sum would conflate "many cells here" with "high expression here" — a dense
19
+ * region would glow whatever its cells were doing, which is the single easiest way
20
+ * to read a gene map wrongly. The denominator is also what makes emptiness
21
+ * expressible: where no cell was measured the mean is *undefined*, not zero, so the
22
+ * caller gets a `support` channel and can draw nothing there instead of drawing the
23
+ * colormap's low end over unmeasured tissue.
24
+ *
25
+ * Pure — no Angular, no GPU, no colour — like `spatial-density.ts`, and tested the
26
+ * same way.
27
+ */
28
+ export interface ExpressionFieldOptions {
29
+ /** Data→image affine, so the field lands in the displayed image's pixels. */
30
+ ref?: SpatialImageRef;
31
+ /** Raster size in field pixels. */
32
+ width: number;
33
+ height: number;
34
+ /** Image pixels per field pixel — the field is coarser than the slide it covers. */
35
+ step: number;
36
+ /** Kernel σ in FIELD pixels. */
37
+ sigma: number;
38
+ /** One expression value per observation. */
39
+ values: Float32Array;
40
+ /** Observations to include; every one when absent (a plane, a selection). */
41
+ indices?: Uint32Array;
42
+ }
43
+ export interface ExpressionField {
44
+ width: number;
45
+ height: number;
46
+ /** Kernel-weighted mean expression, valid only where `support > 0`. */
47
+ mean: Float32Array;
48
+ /** Kernel-weighted cell count per field pixel — 0 where nothing was measured. */
49
+ support: Float32Array;
50
+ /** Median positive support: the scale at which a pixel is "properly sampled",
51
+ * and a data-driven saturation point for an opacity ramp. */
52
+ supportScale: number;
53
+ /** Finite range of `mean` over the supported pixels, for a contrast window. */
54
+ range: [number, number];
55
+ }
56
+ /**
57
+ * Estimate a gene's expression field over the image the observations sit in.
58
+ *
59
+ * Null when nothing lands on the raster — the caller then draws no layer, rather
60
+ * than an empty one the reader has to interpret.
61
+ */
62
+ export declare function expressionField(obs: SpatialObservations, opts: ExpressionFieldOptions): ExpressionField | null;
63
+ /**
64
+ * Colour a field into RGBA8 for a `channels: 4` image source.
65
+ *
66
+ * Alpha comes from the SUPPORT, ramped to the field's own median support rather
67
+ * than a constant: a pixel backed by one distant cell is faint, a properly sampled
68
+ * one is solid, and unmeasured tissue is fully transparent — so the map never
69
+ * asserts a measurement where there was none. `opacity` scales the whole layer on
70
+ * top of that.
71
+ */
72
+ export declare function colorExpressionField(field: ExpressionField, lut: readonly (readonly [number, number, number])[], window: [number, number], opts?: {
73
+ log?: boolean;
74
+ opacity?: number;
75
+ }): Uint8Array;
76
+ /**
77
+ * The same estimator in 3D: a gene's expression over a serially sectioned
78
+ * specimen, on a {@link DensityGrid} lattice.
79
+ *
80
+ * Two modes, and the difference between them is the difference between a
81
+ * measurement and an estimate:
82
+ *
83
+ * - **sheets** (`interpolate` unset) blurs in-plane only, so exactly the z planes
84
+ * that hold cells are filled and the gaps between sections stay empty. This is
85
+ * the measured sections, stacked — the 2D gene map of each slide, at its own z.
86
+ * - **volume** (`interpolate` set) also smooths along z, so the planes between
87
+ * the sections carry an interpolated value. Smoothing the numerator and the
88
+ * denominator together is what makes that honest: it is Nadaraya-Watson, so an
89
+ * unimaged plane inherits its neighbours' MEAN rather than their sum thinned
90
+ * over a gap nobody sampled. Outside the sampled range the field is zeroed —
91
+ * "smoothed between the imaged sections" must not put expression in front of
92
+ * the first section or behind the last.
93
+ *
94
+ * Individual cells are still never interpolated: consecutive sections sample
95
+ * different cells, so there is nothing to interpolate along. What is interpolated
96
+ * is a field, which is legitimately defined between the planes.
97
+ */
98
+ export interface ExpressionVolumeOptions {
99
+ /** Kernel σ per axis, in the observations' own units. The z term is used only
100
+ * when {@link interpolate} is set. */
101
+ sigma: [number, number, number];
102
+ /** One expression value per observation. */
103
+ values: Float32Array;
104
+ /** Observations to include; every one when absent — one section, or a selection. */
105
+ indices?: Uint32Array;
106
+ /** Smooth along z into a continuous estimate rather than leaving the measured
107
+ * sheets isolated at their own planes. */
108
+ interpolate?: boolean;
109
+ }
110
+ export interface ExpressionVolumeField {
111
+ width: number;
112
+ height: number;
113
+ depth: number;
114
+ /** Kernel-weighted mean expression, valid only where `support > 0`. */
115
+ mean: Float32Array;
116
+ /** Kernel-weighted cell count per voxel — 0 where nothing was measured. */
117
+ support: Float32Array;
118
+ /** Finite range of `mean` over the supported voxels, for a contrast window. */
119
+ range: [number, number];
120
+ }
121
+ export declare function expressionVolume(obs: SpatialObservations, grid: DensityGrid, opts: ExpressionVolumeOptions): ExpressionVolumeField | null;
122
+ /**
123
+ * Encode a field into the uint8 scalar a `VolumeLayer` renders, windowed the same
124
+ * way the 2D map and the markers are so the three cannot disagree.
125
+ *
126
+ * `floor` lifts every MEASURED voxel to at least this fraction of full scale, so
127
+ * that "the gene is not here" can be told from "nobody sectioned here" — the
128
+ * distinction the 2D map keeps with its separate support channel, which a scalar
129
+ * volume cannot express because its raymarch takes alpha from the same number it
130
+ * takes colour from.
131
+ *
132
+ * It defaults to OFF, because in a volume that trade does not pay. A ray crosses
133
+ * tens of measured planes and the DVR composites every one of them, so a floor
134
+ * large enough to see on a single plane saturates to an opaque shell over the
135
+ * whole specimen and buries the signal it was meant to contextualise, while one
136
+ * small enough to stay faint is already invisible per plane. The measured envelope
137
+ * is better read from the anatomy layer and the observations, which are drawn in
138
+ * the same scene — and the surrounding density volumes take the same no-floor
139
+ * approach, so the scene stays internally consistent.
140
+ */
141
+ export declare function encodeExpressionVolume(field: ExpressionVolumeField, window: [number, number], opts?: {
142
+ log?: boolean;
143
+ floor?: number;
144
+ }): Uint8Array;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Framing a 2D scatter on its own coordinates.
3
+ *
4
+ * The 2D spatial camera is normally fitted to the IMAGE, because the observations are recorded in
5
+ * that image's pixel space — frame the image and they come with it. A dataset with no reference
6
+ * image has coordinates in whatever units its source used: seqFISH's span about 5 x 7. Left with
7
+ * the previous image's framing, such a dataset lands as a handful of pixels far off-centre, which
8
+ * looks exactly like nothing having loaded rather than like a framing problem.
9
+ *
10
+ * Pure, like `spatial-density.ts` and the rest of this directory — the renderer applies the result.
11
+ */
12
+ /** Fraction of the viewport the coordinates are fitted into, so the outermost markers are not
13
+ * flush against the edge. Matches napari-js's own fit margin. */
14
+ export declare const SPATIAL_FIT_MARGIN = 0.95;
15
+ export interface SpatialFraming {
16
+ /** World point to put at the centre of the viewport. */
17
+ center: [number, number];
18
+ /** World units per pixel, inverted — the camera's `zoom`. */
19
+ zoom: number;
20
+ }
21
+ /**
22
+ * Camera centre and zoom that fit `positions` into a `viewportW` x `viewportH` box.
23
+ *
24
+ * `positions` is interleaved `[x0, y0, x1, y1, …]`, the layout the points layer takes.
25
+ *
26
+ * Null when there is nothing to fit — no points, a viewport with no area, or coordinates that are
27
+ * entirely non-finite. Null means LEAVE THE CAMERA ALONE: a caller that substituted a default
28
+ * would move the view for a dataset it cannot frame, which is worse than not moving it.
29
+ *
30
+ * A degenerate extent — one point, or every point on a line — yields a centre but no zoom, since
31
+ * there is no span to divide by. The caller keeps its current zoom and just recentres, rather than
32
+ * dividing by zero into an infinite one.
33
+ */
34
+ export declare function framePositions(positions: Float32Array, viewportW: number, viewportH: number, margin?: number): SpatialFraming | {
35
+ center: [number, number];
36
+ zoom: null;
37
+ } | null;