@jax-data-science/sci-image-visualizer 0.3.3 → 0.4.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 (91) hide show
  1. package/README.md +208 -4
  2. package/esm2022/index.mjs +20 -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.mjs +36 -1
  6. package/esm2022/lib/contracts/ports/spatial-data.port.mjs +3 -0
  7. package/esm2022/lib/contracts/spatial-dataset.contract.mjs +45 -0
  8. package/esm2022/lib/contracts/visualizer.contract.mjs +1 -1
  9. package/esm2022/lib/implementations/napari-js/napari-axes-labels.mjs +10 -19
  10. package/esm2022/lib/implementations/napari-js/napari-region-overlay.mjs +71 -7
  11. package/esm2022/lib/implementations/napari-js/napari-scale-bar.mjs +1 -1
  12. package/esm2022/lib/implementations/napari-js/napari-spatial-tooltip.mjs +96 -0
  13. package/esm2022/lib/implementations/napari-js/napari-visualizer.service.mjs +1934 -55
  14. package/esm2022/lib/implementations/napari-js/napari-zoom.mjs +18 -0
  15. package/esm2022/lib/implementations/osd/openseadragon-visualizer.service.mjs +5 -3
  16. package/esm2022/lib/implementations/osd/osd-region-overlay.mjs +3 -4
  17. package/esm2022/lib/implementations/osd/osd-zoom.mjs +20 -0
  18. package/esm2022/lib/implementations/plotly/omics-trace-builders.mjs +555 -0
  19. package/esm2022/lib/implementations/plotly/plotly.service.mjs +13 -7
  20. package/esm2022/lib/implementations/spatial-data-http/spatial-data-http.service.mjs +304 -0
  21. package/esm2022/lib/implementations/spatial-data-http/spatial-wire.mjs +179 -0
  22. package/esm2022/lib/routing-visualizer.service.mjs +223 -12
  23. package/esm2022/lib/spatial/embedding-compute.mjs +142 -0
  24. package/esm2022/lib/spatial/gene-search.mjs +77 -0
  25. package/esm2022/lib/spatial/spatial-density.mjs +224 -0
  26. package/esm2022/lib/spatial/spatial-encoding.mjs +232 -0
  27. package/esm2022/lib/spatial/spatial-expression.mjs +257 -0
  28. package/esm2022/lib/spatial/spatial-framing.mjs +60 -0
  29. package/esm2022/lib/spatial/spatial-heatmap.mjs +185 -0
  30. package/esm2022/lib/spatial/spatial-hover.mjs +79 -0
  31. package/esm2022/lib/spatial/spatial-sections.mjs +96 -0
  32. package/esm2022/lib/spatial/spatial-selection.mjs +264 -0
  33. package/esm2022/lib/spatial/spatial-volume-image.mjs +163 -0
  34. package/esm2022/lib/spatial/tsne-worker.mjs +10 -0
  35. package/esm2022/lib/spatial/world-grid.mjs +77 -0
  36. package/esm2022/lib/spatial-controls/spatial-charts/spatial-charts.component.mjs +1278 -0
  37. package/esm2022/lib/spatial-controls/spatial-controls.component.mjs +621 -0
  38. package/esm2022/lib/store/spatial-selection.service.mjs +47 -0
  39. package/esm2022/lib/store/visualizer-store.service.mjs +23 -1
  40. package/esm2022/lib/toolbar/toolbar.component.mjs +30 -7
  41. package/esm2022/lib/visualization.module.mjs +23 -5
  42. package/esm2022/lib/visualizer.component.mjs +283 -22
  43. 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
  44. 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
  45. package/fesm2022/jax-data-science-sci-image-visualizer-tsne-worker-B_nXDGf0.mjs +12 -0
  46. package/fesm2022/jax-data-science-sci-image-visualizer-tsne-worker-B_nXDGf0.mjs.map +1 -0
  47. package/fesm2022/jax-data-science-sci-image-visualizer.mjs +15769 -8316
  48. package/fesm2022/jax-data-science-sci-image-visualizer.mjs.map +1 -1
  49. package/fesm2022/tsne.worker.js +19091 -0
  50. package/index.d.ts +15 -0
  51. package/lib/contracts/display-types.d.ts +131 -0
  52. package/lib/contracts/image.contract.d.ts +7 -0
  53. package/lib/contracts/plot-type.d.ts +42 -2
  54. package/lib/contracts/ports/spatial-data.port.d.ts +90 -0
  55. package/lib/contracts/spatial-dataset.contract.d.ts +281 -0
  56. package/lib/contracts/visualizer.contract.d.ts +99 -1
  57. package/lib/implementations/napari-js/napari-axes-labels.d.ts +9 -7
  58. package/lib/implementations/napari-js/napari-region-overlay.d.ts +38 -4
  59. package/lib/implementations/napari-js/napari-scale-bar.d.ts +7 -3
  60. package/lib/implementations/napari-js/napari-spatial-tooltip.d.ts +32 -0
  61. package/lib/implementations/napari-js/napari-visualizer.service.d.ts +497 -16
  62. package/lib/implementations/napari-js/napari-zoom.d.ts +15 -0
  63. package/lib/implementations/osd/osd-zoom.d.ts +19 -0
  64. package/lib/implementations/plotly/omics-trace-builders.d.ts +193 -0
  65. package/lib/implementations/plotly/plotly.service.d.ts +9 -0
  66. package/lib/implementations/spatial-data-http/spatial-data-http.service.d.ts +123 -0
  67. package/lib/implementations/spatial-data-http/spatial-wire.d.ts +115 -0
  68. package/lib/routing-visualizer.service.d.ts +45 -6
  69. package/lib/spatial/embedding-compute.d.ts +73 -0
  70. package/lib/spatial/gene-search.d.ts +48 -0
  71. package/lib/spatial/spatial-density.d.ts +73 -0
  72. package/lib/spatial/spatial-encoding.d.ts +155 -0
  73. package/lib/spatial/spatial-expression.d.ts +144 -0
  74. package/lib/spatial/spatial-framing.d.ts +37 -0
  75. package/lib/spatial/spatial-heatmap.d.ts +111 -0
  76. package/lib/spatial/spatial-hover.d.ts +49 -0
  77. package/lib/spatial/spatial-sections.d.ts +59 -0
  78. package/lib/spatial/spatial-selection.d.ts +113 -0
  79. package/lib/spatial/spatial-volume-image.d.ts +67 -0
  80. package/lib/spatial/tsne-worker.d.ts +7 -0
  81. package/lib/spatial/world-grid.d.ts +58 -0
  82. package/lib/spatial-controls/spatial-charts/spatial-charts.component.d.ts +439 -0
  83. package/lib/spatial-controls/spatial-controls.component.d.ts +257 -0
  84. package/lib/store/spatial-selection.service.d.ts +27 -0
  85. package/lib/store/visualizer-store.service.d.ts +13 -0
  86. package/lib/toolbar/toolbar.component.d.ts +19 -3
  87. package/lib/visualization.module.d.ts +28 -24
  88. package/lib/visualizer.component.d.ts +73 -2
  89. package/package.json +6 -2
  90. package/src/lib/assets/spatial-omics-3d.svg +31 -0
  91. package/src/lib/assets/spatial-omics.svg +27 -0
@@ -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;
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Mean expression per group, for the gene × class heatmap.
3
+ *
4
+ * A scatter coloured by one gene answers "where is this gene". It cannot answer
5
+ * "which classes express which genes", which is the question a marker panel is
6
+ * actually read for — and the standard object for that is the mean-expression
7
+ * matrix per cluster (scanpy's `matrixplot`/`dotplot`), genes against classes.
8
+ *
9
+ * Deliberately NOT cells × genes at the full dataset: 374k columns is not a
10
+ * heatmap. Cells become columns only inside a selection, where a few thousand
11
+ * of them render and the question changes to "what is in this region".
12
+ *
13
+ * Two things this has to get right to be readable:
14
+ *
15
+ * - A cell with no measurement for a gene (`NaN`) is SKIPPED, not counted as
16
+ * zero. Counting it would drag a group's mean towards absence for a reason
17
+ * that has nothing to do with the biology.
18
+ * - Z-scoring is per GENE, across groups. Without it a gene expressed an order
19
+ * of magnitude above the others saturates the colour scale and the rest of
20
+ * the panel reads as uniformly blank — the matrix would show which gene is
21
+ * loudest rather than which class expresses what.
22
+ *
23
+ * Pure — no Angular, no Plotly, no renderer — like `spatial-density.ts`, and
24
+ * tested the same way.
25
+ */
26
+ /** One gene's values, with the name to label its row. */
27
+ export interface HeatmapGene {
28
+ name: string;
29
+ /** One value per observation; `NaN` where the gene was not measured. */
30
+ values: Float32Array;
31
+ }
32
+ /** The columns to aggregate into: a categorical column's categories. */
33
+ export interface HeatmapGroups {
34
+ /** Per-observation category index, or {@link NO_CATEGORY}. */
35
+ codes: Uint16Array;
36
+ categories: readonly string[];
37
+ }
38
+ export interface HeatmapMatrixOptions {
39
+ /** Observations to include; every one when absent (an ROI selection). */
40
+ indices?: Uint32Array;
41
+ /**
42
+ * Z-score each gene across the groups, so one loud gene cannot flatten the
43
+ * panel. On by default — the raw means are only readable when every gene is
44
+ * on a comparable scale.
45
+ */
46
+ zScore?: boolean;
47
+ /** Drop groups with fewer than this many measured cells. A mean over one or
48
+ * two cells is noise presented with the same weight as a real one. */
49
+ minCells?: number;
50
+ /**
51
+ * Keep at most this many columns, chosen by the STRONGEST signal any picked
52
+ * gene shows in them.
53
+ *
54
+ * `subclass` has 338 categories. Drawn in full that is a texture rather than a
55
+ * panel — every column a couple of pixels wide, every label unreadable — and
56
+ * the question being asked ("which groups express my genes") is answered by
57
+ * the handful of groups where a gene actually stands out. Ranking by
58
+ * max |value| across the rows surfaces exactly those; ranking by cell count
59
+ * would instead surface the biggest groups, which is a different question.
60
+ *
61
+ * Unset keeps every column that cleared `minCells`.
62
+ */
63
+ maxCols?: number;
64
+ }
65
+ export interface HeatmapMatrix {
66
+ /** Gene names, one per row, in the order given. */
67
+ rows: string[];
68
+ /** Columns dropped by `maxCols`, so the caller can say how many are hidden
69
+ * rather than silently showing a subset as if it were everything. */
70
+ hiddenCols: number;
71
+ /** Group labels, one per column — only the groups that survived `minCells`. */
72
+ cols: string[];
73
+ /**
74
+ * `rows.length × cols.length`, row-major: `values[r * cols.length + c]`.
75
+ * `NaN` where a group had nothing measured for that gene, which the caller
76
+ * draws as a gap rather than as a zero.
77
+ */
78
+ values: Float32Array;
79
+ /** Measured cells behind each column, index-aligned with {@link cols}. */
80
+ counts: Uint32Array;
81
+ /** Finite range of `values`, for a symmetric or absolute colour scale. */
82
+ range: [number, number];
83
+ }
84
+ /** Default floor for a group to earn a column. */
85
+ export declare const HEATMAP_MIN_CELLS = 3;
86
+ /**
87
+ * Mean expression of each gene in each group.
88
+ *
89
+ * Null when nothing survives — no genes, no groups, or every group filtered
90
+ * out — so the caller draws no chart rather than an empty grid.
91
+ */
92
+ export declare function heatmapMatrix(genes: readonly HeatmapGene[], groups: HeatmapGroups, opts?: HeatmapMatrixOptions): HeatmapMatrix | null;
93
+ /**
94
+ * Groups standing in for individual cells, for the in-selection view.
95
+ *
96
+ * Inside an ROI, "which classes express what" is often the wrong question and
97
+ * "what is in front of me" is the right one — so each cell becomes its own
98
+ * column. Capped: past a few hundred columns a heatmap is a texture, not a
99
+ * readable panel, and the cap is applied by even thinning so the selection is
100
+ * still represented across its whole extent rather than truncated to its first
101
+ * cells.
102
+ *
103
+ * NOTE: every column here holds exactly ONE cell, so the caller must pass
104
+ * `minCells: 1` to {@link heatmapMatrix}. The default floor of
105
+ * {@link HEATMAP_MIN_CELLS} exists to suppress noisy group means and would
106
+ * filter out every column of this view, returning null.
107
+ */
108
+ export declare function cellsAsGroups(indices: Uint32Array, observationCount: number, max?: number): {
109
+ groups: HeatmapGroups;
110
+ indices: Uint32Array;
111
+ };
@@ -0,0 +1,49 @@
1
+ /**
2
+ * What is under the cursor, and what to say about it.
3
+ *
4
+ * A coloured cloud answers "how is this annotation distributed"; it cannot answer
5
+ * "which class is THAT" — the legend has 34 entries, several of them similar
6
+ * colours, and matching a dot to a swatch by eye is exactly the task a tooltip
7
+ * exists to remove.
8
+ *
9
+ * Pure — no DOM, no camera, no data access. The renderer supplies screen
10
+ * positions (it owns the projection) and the active colour source; everything
11
+ * here is arithmetic and string formatting, so it is tested directly.
12
+ */
13
+ /** The colour source a tooltip describes, as the renderer already has it. */
14
+ export type HoverSource = {
15
+ kind: 'categorical';
16
+ /** Column name, for the tooltip's first line. */
17
+ name: string;
18
+ categories: readonly string[];
19
+ /** Per-observation category index, or `NO_CATEGORY`. */
20
+ codes: Uint16Array;
21
+ } | {
22
+ kind: 'continuous';
23
+ /** Column or gene name. */
24
+ name: string;
25
+ values: Float32Array;
26
+ /** Unit for the value, when the dataset declares one. */
27
+ unit?: string;
28
+ };
29
+ /**
30
+ * Index of the drawn observation nearest `(x, y)` within `maxDist` screen pixels,
31
+ * or -1.
32
+ *
33
+ * `screen` is `[x0, y0, x1, y1, …]` indexed BY OBSERVATION, with NaN for anything
34
+ * not currently drawn — off screen, behind the eye, or on a hidden section. NaN
35
+ * fails every comparison, so those are skipped without a special case.
36
+ *
37
+ * Ties go to the LATER observation, which is the one drawn on top and therefore
38
+ * the one the user believes they are pointing at.
39
+ */
40
+ export declare function nearestObservation(screen: Float32Array, x: number, y: number, maxDist: number): number;
41
+ /**
42
+ * The tooltip's lines for one observation: what it is, then which column said so.
43
+ *
44
+ * Null when the source cannot describe this observation — a code outside the
45
+ * category list, or a value that was never measured. Saying nothing is better
46
+ * than a tooltip that reads "undefined", and better than inventing a label for a
47
+ * cell whose annotation is genuinely missing.
48
+ */
49
+ export declare function hoverText(source: HoverSource | null, index: number): string[] | null;
@@ -0,0 +1,59 @@
1
+ import { SpatialObservations } from '../contracts/spatial-dataset.contract';
2
+ /**
3
+ * The planes a **serially sectioned** dataset was actually imaged at.
4
+ *
5
+ * A 3D spatial-omics dataset of this kind is not a cloud that happens to have a z:
6
+ * it is a stack of thin sections, hundreds of microns apart, each one a separate
7
+ * slide. Every observation on a given slide shares that slide's registered z
8
+ * exactly, so the distinct z values ARE the sections — no binning, no tolerance,
9
+ * and no need for a dataset-specific section-label column.
10
+ *
11
+ * That matters for the 3D view, where the cloud reads as a stack of discs and the
12
+ * discs hide the density volumes drawn between them. Being able to show one
13
+ * section's cells against the estimated field is how you check the estimate against
14
+ * the measurement; being able to hide them is how you see the field at all.
15
+ *
16
+ * Deliberately NOT a binning of a continuous z (that is
17
+ * `observationsInSlice`'s job, against a volume's voxel planes). If the z is
18
+ * continuous rather than sectioned there are no sections to offer, and
19
+ * {@link sampledSections} says so by returning null rather than inventing some.
20
+ *
21
+ * Pure — no Angular, no GPU — like `spatial-density.ts`, and tested the same way.
22
+ */
23
+ /**
24
+ * Distinct z values beyond which the data is treated as continuous rather than
25
+ * sectioned. Serial sectioning runs to tens or low hundreds of slides (53 for the
26
+ * ABC whole-brain MERFISH atlas); a z that keeps producing new values is a
27
+ * measurement axis, and a "pick one section" control over it would be a lie.
28
+ */
29
+ export declare const MAX_SAMPLED_SECTIONS = 512;
30
+ /**
31
+ * The dataset's sampled section positions, ascending — or null when it has no z,
32
+ * no observations, or a z too finely divided to be sections at all.
33
+ *
34
+ * Bails out as soon as the distinct count passes `max`, so a continuous z costs one
35
+ * partial pass rather than a set with one entry per observation.
36
+ */
37
+ export declare function sampledSections(obs: SpatialObservations, max?: number): Float32Array | null;
38
+ /**
39
+ * Indices of the observations lying on one sampled section.
40
+ *
41
+ * Exact equality on purpose: `z` comes from {@link sampledSections}, which read the
42
+ * values out of this same array, so the comparison cannot drift. Passing a z that
43
+ * is not one of the sampled positions yields nothing, which is the honest answer.
44
+ */
45
+ export declare function observationsInSection(obs: SpatialObservations, z: number): Uint32Array;
46
+ /**
47
+ * {@link sampledSections} for a dataset's observations, computed once.
48
+ *
49
+ * Both the renderer (which filters the cloud's geometry to one section) and the
50
+ * control panel (which offers the sections to pick from) need this list, and the
51
+ * scan reads the z of up to 3.7M observations — so it is memoized on the
52
+ * observations object itself rather than each caller keeping its own copy keyed by
53
+ * a dataset id it has to remember to invalidate. Still referentially transparent:
54
+ * the same observations always give the same sections.
55
+ *
56
+ * Default `max` only; a caller wanting a different cap should call
57
+ * {@link sampledSections} directly and cache it itself.
58
+ */
59
+ export declare function sectionsOf(obs: SpatialObservations): Float32Array | null;