insomni-plot 0.1.0-alpha.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.
- package/LICENSE.md +674 -0
- package/README.md +81 -0
- package/dist/core.d.mts +340 -0
- package/dist/core.mjs +1047 -0
- package/dist/index.d.mts +3426 -0
- package/dist/index.mjs +12762 -0
- package/dist/interactions-DEFL_F4E.mjs +5395 -0
- package/dist/range-presets-CzECsu3V.d.mts +1523 -0
- package/package.json +34 -0
- package/src/annotations.d.ts +121 -0
- package/src/annotations.ts +438 -0
- package/src/axis.d.ts +184 -0
- package/src/axis.test.ts +131 -0
- package/src/axis.ts +765 -0
- package/src/colorbar.d.ts +69 -0
- package/src/colorbar.ts +294 -0
- package/src/colors.d.ts +57 -0
- package/src/colors.test.ts +28 -0
- package/src/colors.ts +486 -0
- package/src/core.ts +299 -0
- package/src/format.d.ts +54 -0
- package/src/format.ts +138 -0
- package/src/grammar/accessibility.d.ts +147 -0
- package/src/grammar/accessibility.test.ts +199 -0
- package/src/grammar/accessibility.ts +443 -0
- package/src/grammar/aes.d.ts +35 -0
- package/src/grammar/aes.test.ts +75 -0
- package/src/grammar/aes.ts +120 -0
- package/src/grammar/annotations.d.ts +86 -0
- package/src/grammar/annotations.test.ts +68 -0
- package/src/grammar/annotations.ts +336 -0
- package/src/grammar/attach-brush.d.ts +44 -0
- package/src/grammar/attach-brush.test.ts +214 -0
- package/src/grammar/attach-brush.ts +111 -0
- package/src/grammar/attach-presets.d.ts +33 -0
- package/src/grammar/attach-presets.test.ts +106 -0
- package/src/grammar/attach-presets.ts +215 -0
- package/src/grammar/chart.d.ts +952 -0
- package/src/grammar/chart.test.ts +118 -0
- package/src/grammar/chart.ts +1172 -0
- package/src/grammar/color-utils.d.ts +29 -0
- package/src/grammar/color-utils.test.ts +53 -0
- package/src/grammar/color-utils.ts +66 -0
- package/src/grammar/constants.d.ts +45 -0
- package/src/grammar/constants.ts +61 -0
- package/src/grammar/coord.d.ts +183 -0
- package/src/grammar/coord.test.ts +355 -0
- package/src/grammar/coord.ts +619 -0
- package/src/grammar/data/pivot.d.ts +57 -0
- package/src/grammar/data/pivot.ts +107 -0
- package/src/grammar/emphasis-driver.d.ts +69 -0
- package/src/grammar/emphasis-driver.test.ts +199 -0
- package/src/grammar/emphasis-driver.ts +205 -0
- package/src/grammar/equality.d.ts +3 -0
- package/src/grammar/equality.ts +40 -0
- package/src/grammar/facet.d.ts +63 -0
- package/src/grammar/facet.test.ts +60 -0
- package/src/grammar/facet.ts +175 -0
- package/src/grammar/geoms/_categorical.d.ts +94 -0
- package/src/grammar/geoms/_categorical.ts +0 -0
- package/src/grammar/geoms/_distribution.d.ts +52 -0
- package/src/grammar/geoms/_distribution.ts +125 -0
- package/src/grammar/geoms/_mark.d.ts +69 -0
- package/src/grammar/geoms/_mark.ts +136 -0
- package/src/grammar/geoms/_shape.d.ts +41 -0
- package/src/grammar/geoms/_shape.ts +74 -0
- package/src/grammar/geoms/aggregate.d.ts +95 -0
- package/src/grammar/geoms/aggregate.test.ts +554 -0
- package/src/grammar/geoms/aggregate.ts +840 -0
- package/src/grammar/geoms/area.d.ts +32 -0
- package/src/grammar/geoms/area.test.ts +165 -0
- package/src/grammar/geoms/area.ts +578 -0
- package/src/grammar/geoms/band.d.ts +27 -0
- package/src/grammar/geoms/band.test.ts +57 -0
- package/src/grammar/geoms/band.ts +126 -0
- package/src/grammar/geoms/bar.d.ts +56 -0
- package/src/grammar/geoms/bar.test.ts +367 -0
- package/src/grammar/geoms/bar.ts +1054 -0
- package/src/grammar/geoms/boxplot.d.ts +129 -0
- package/src/grammar/geoms/boxplot.test.ts +299 -0
- package/src/grammar/geoms/boxplot.ts +834 -0
- package/src/grammar/geoms/connected-scatter.d.ts +27 -0
- package/src/grammar/geoms/connected-scatter.test.ts +157 -0
- package/src/grammar/geoms/connected-scatter.ts +63 -0
- package/src/grammar/geoms/emphasis.d.ts +76 -0
- package/src/grammar/geoms/emphasis.test.ts +135 -0
- package/src/grammar/geoms/emphasis.ts +162 -0
- package/src/grammar/geoms/histogram.d.ts +75 -0
- package/src/grammar/geoms/histogram.test.ts +262 -0
- package/src/grammar/geoms/histogram.ts +740 -0
- package/src/grammar/geoms/index.d.ts +20 -0
- package/src/grammar/geoms/index.ts +77 -0
- package/src/grammar/geoms/interval.d.ts +31 -0
- package/src/grammar/geoms/interval.test.ts +154 -0
- package/src/grammar/geoms/interval.ts +342 -0
- package/src/grammar/geoms/line.d.ts +38 -0
- package/src/grammar/geoms/line.test.ts +247 -0
- package/src/grammar/geoms/line.ts +659 -0
- package/src/grammar/geoms/point.d.ts +57 -0
- package/src/grammar/geoms/point.test.ts +163 -0
- package/src/grammar/geoms/point.ts +545 -0
- package/src/grammar/geoms/polar.test.ts +216 -0
- package/src/grammar/geoms/ribbon.d.ts +21 -0
- package/src/grammar/geoms/ribbon.test.ts +170 -0
- package/src/grammar/geoms/ribbon.ts +87 -0
- package/src/grammar/geoms/ridgeline.d.ts +89 -0
- package/src/grammar/geoms/ridgeline.test.ts +247 -0
- package/src/grammar/geoms/ridgeline.ts +1164 -0
- package/src/grammar/geoms/rolling.d.ts +43 -0
- package/src/grammar/geoms/rolling.test.ts +217 -0
- package/src/grammar/geoms/rolling.ts +387 -0
- package/src/grammar/geoms/rug.d.ts +28 -0
- package/src/grammar/geoms/rug.test.ts +126 -0
- package/src/grammar/geoms/rug.ts +214 -0
- package/src/grammar/geoms/rule.d.ts +23 -0
- package/src/grammar/geoms/rule.test.ts +69 -0
- package/src/grammar/geoms/rule.ts +212 -0
- package/src/grammar/geoms/smooth.d.ts +54 -0
- package/src/grammar/geoms/smooth.test.ts +78 -0
- package/src/grammar/geoms/smooth.ts +337 -0
- package/src/grammar/geoms/text.d.ts +29 -0
- package/src/grammar/geoms/text.test.ts +64 -0
- package/src/grammar/geoms/text.ts +234 -0
- package/src/grammar/geoms/tile.d.ts +61 -0
- package/src/grammar/geoms/tile.test.ts +157 -0
- package/src/grammar/geoms/tile.ts +621 -0
- package/src/grammar/geoms/types.d.ts +319 -0
- package/src/grammar/geoms/types.ts +362 -0
- package/src/grammar/geoms/violin.d.ts +85 -0
- package/src/grammar/geoms/violin.test.ts +187 -0
- package/src/grammar/geoms/violin.ts +672 -0
- package/src/grammar/index.d.ts +22 -0
- package/src/grammar/index.ts +269 -0
- package/src/grammar/interactions/_disposable.d.ts +5 -0
- package/src/grammar/interactions/_disposable.ts +23 -0
- package/src/grammar/interactions/_z.d.ts +4 -0
- package/src/grammar/interactions/_z.ts +16 -0
- package/src/grammar/interactions/brush-selection.test.ts +262 -0
- package/src/grammar/interactions/brush.d.ts +63 -0
- package/src/grammar/interactions/brush.test.ts +483 -0
- package/src/grammar/interactions/brush.ts +452 -0
- package/src/grammar/interactions/crosshair.d.ts +19 -0
- package/src/grammar/interactions/crosshair.test.ts +127 -0
- package/src/grammar/interactions/crosshair.ts +76 -0
- package/src/grammar/interactions/hit-layer.d.ts +64 -0
- package/src/grammar/interactions/hit-layer.ts +246 -0
- package/src/grammar/interactions/legend.d.ts +19 -0
- package/src/grammar/interactions/legend.ts +101 -0
- package/src/grammar/interactions/menu.d.ts +93 -0
- package/src/grammar/interactions/menu.test.ts +373 -0
- package/src/grammar/interactions/menu.ts +342 -0
- package/src/grammar/interactions/selection.d.ts +25 -0
- package/src/grammar/interactions/selection.test.ts +289 -0
- package/src/grammar/interactions/selection.ts +142 -0
- package/src/grammar/interactions/series-readout.d.ts +91 -0
- package/src/grammar/interactions/series-readout.test.ts +668 -0
- package/src/grammar/interactions/series-readout.ts +422 -0
- package/src/grammar/interactions/series-snap.d.ts +70 -0
- package/src/grammar/interactions/series-snap.test.ts +214 -0
- package/src/grammar/interactions/series-snap.ts +218 -0
- package/src/grammar/interactions/tooltip-axis.test.ts +176 -0
- package/src/grammar/interactions/tooltip-touch.browser.test.ts +49 -0
- package/src/grammar/interactions/tooltip-touch.test.ts +161 -0
- package/src/grammar/interactions/tooltip.d.ts +140 -0
- package/src/grammar/interactions/tooltip.test.ts +406 -0
- package/src/grammar/interactions/tooltip.ts +622 -0
- package/src/grammar/interactions/transitions.d.ts +34 -0
- package/src/grammar/interactions/transitions.test.ts +172 -0
- package/src/grammar/interactions/transitions.ts +160 -0
- package/src/grammar/layout.d.ts +68 -0
- package/src/grammar/layout.ts +186 -0
- package/src/grammar/legend-merge.test.ts +332 -0
- package/src/grammar/mount.d.ts +78 -0
- package/src/grammar/mount.test.ts +479 -0
- package/src/grammar/mount.ts +2112 -0
- package/src/grammar/palettes.d.ts +54 -0
- package/src/grammar/palettes.test.ts +80 -0
- package/src/grammar/palettes.ts +167 -0
- package/src/grammar/pan-zoom.test.ts +398 -0
- package/src/grammar/phylo.d.ts +65 -0
- package/src/grammar/phylo.test.ts +59 -0
- package/src/grammar/phylo.ts +112 -0
- package/src/grammar/pipeline.auto-ticks.test.ts +40 -0
- package/src/grammar/pipeline.d.ts +158 -0
- package/src/grammar/pipeline.test.ts +463 -0
- package/src/grammar/pipeline.ts +1233 -0
- package/src/grammar/profiling.d.ts +8 -0
- package/src/grammar/profiling.ts +24 -0
- package/src/grammar/scales.d.ts +188 -0
- package/src/grammar/scales.test.ts +181 -0
- package/src/grammar/scales.ts +800 -0
- package/src/grammar/svg.d.ts +3 -0
- package/src/grammar/svg.ts +39 -0
- package/src/grammar/theme.d.ts +261 -0
- package/src/grammar/theme.test.ts +105 -0
- package/src/grammar/theme.ts +490 -0
- package/src/heatmap/cpu.ts +109 -0
- package/src/heatmap/gpu.ts +565 -0
- package/src/heatmap/types.ts +177 -0
- package/src/heatmap.browser.test.ts +308 -0
- package/src/heatmap.test.ts +320 -0
- package/src/heatmap.ts +123 -0
- package/src/index.d.ts +1 -0
- package/src/index.ts +8 -0
- package/src/interactions.d.ts +48 -0
- package/src/interactions.test.ts +226 -0
- package/src/interactions.ts +394 -0
- package/src/layout/box.d.ts +48 -0
- package/src/layout/box.test.ts +107 -0
- package/src/layout/box.ts +143 -0
- package/src/legend.d.ts +115 -0
- package/src/legend.ts +422 -0
- package/src/marks/curve.d.ts +43 -0
- package/src/marks/curve.ts +244 -0
- package/src/marks/stack.d.ts +53 -0
- package/src/marks/stack.ts +184 -0
- package/src/marks.d.ts +273 -0
- package/src/marks.test.ts +541 -0
- package/src/marks.ts +1292 -0
- package/src/navigator.test.ts +174 -0
- package/src/navigator.ts +393 -0
- package/src/range-presets.d.ts +113 -0
- package/src/range-presets.test.ts +345 -0
- package/src/range-presets.ts +349 -0
- package/src/scales.d.ts +98 -0
- package/src/scales.test.ts +103 -0
- package/src/scales.ts +695 -0
- package/src/stats/index.d.ts +200 -0
- package/src/stats/index.test.ts +349 -0
- package/src/stats/index.ts +740 -0
- package/src/stats/regression.d.ts +38 -0
- package/src/stats/regression.test.ts +56 -0
- package/src/stats/regression.ts +396 -0
- package/src/stats/rolling-window.d.ts +55 -0
- package/src/stats/rolling-window.test.ts +237 -0
- package/src/stats/rolling-window.ts +256 -0
- package/src/test-setup.ts +19 -0
- package/src/viewport/axis-state.d.ts +72 -0
- package/src/viewport/axis-state.ts +476 -0
- package/src/viewport.d.ts +170 -0
- package/src/viewport.test.ts +363 -0
- package/src/viewport.ts +510 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Shared types and helpers used by both the CPU and GPU heatmap renderers.
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
import type { FrameRect } from "insomni";
|
|
6
|
+
import type { ContinuousPalette } from "../colors.ts";
|
|
7
|
+
import type { DataViewport } from "../viewport.ts";
|
|
8
|
+
|
|
9
|
+
export interface HeatmapSpec<T> {
|
|
10
|
+
/** Datum → x value (in `xDomain` units). */
|
|
11
|
+
x: (d: T) => number;
|
|
12
|
+
/** Datum → y value (in `yDomain` units). */
|
|
13
|
+
y: (d: T) => number;
|
|
14
|
+
/** Datum → weight added to its bin. Default: `() => 1`. */
|
|
15
|
+
weight?: (d: T) => number;
|
|
16
|
+
/** Grid resolution `[nx, ny]`. */
|
|
17
|
+
bins: readonly [number, number];
|
|
18
|
+
/**
|
|
19
|
+
* Data domain along x. Data outside this range is dropped.
|
|
20
|
+
* Required unless `viewport` is supplied (in which case the viewport's
|
|
21
|
+
* visible x domain is used and this field is ignored).
|
|
22
|
+
*/
|
|
23
|
+
xDomain?: readonly [number, number];
|
|
24
|
+
/** Data domain along y. See `xDomain`. */
|
|
25
|
+
yDomain?: readonly [number, number];
|
|
26
|
+
/**
|
|
27
|
+
* CSS-pixel rectangle the heatmap paints into.
|
|
28
|
+
* Required unless `viewport` is supplied (in which case `viewport.frame`
|
|
29
|
+
* is used and this field is ignored).
|
|
30
|
+
*/
|
|
31
|
+
frame?: FrameRect;
|
|
32
|
+
/**
|
|
33
|
+
* Optional viewport for interactive pan/zoom. When supplied, the heatmap
|
|
34
|
+
* re-bins on every viewport change against `viewport.visibleXDomain` /
|
|
35
|
+
* `visibleYDomain`, and paints into `viewport.frame`. Pair with
|
|
36
|
+
* `bindViewport` to wire mouse / wheel / touch input.
|
|
37
|
+
*/
|
|
38
|
+
viewport?: DataViewport<number, number>;
|
|
39
|
+
/** Palette sampled per-cell. */
|
|
40
|
+
colorMap: ContinuousPalette;
|
|
41
|
+
/**
|
|
42
|
+
* Fixed-point divisor used to encode float weights as `atomic<i32>` on the
|
|
43
|
+
* GPU path. `round(weight * weightScale)` is added per splat. Keep
|
|
44
|
+
* `|weight| * N * weightScale` inside `i32` range per cell.
|
|
45
|
+
* Default: `1_000_000`.
|
|
46
|
+
*/
|
|
47
|
+
weightScale?: number;
|
|
48
|
+
/**
|
|
49
|
+
* Output strategy used by CPU-only renderers (e.g. `SVGRenderer`):
|
|
50
|
+
* - `"rects"`: emit one vector rect per non-empty bin. Best when the grid is
|
|
51
|
+
* small and you want an editable/inspectable SVG.
|
|
52
|
+
* - `"image"`: rasterize bins to a PNG and emit a single `<image>` element.
|
|
53
|
+
* Keeps exports small for dense grids.
|
|
54
|
+
* - `"auto"` (default): pick `"image"` when `nx * ny > 128 * 128`,
|
|
55
|
+
* otherwise `"rects"`.
|
|
56
|
+
*/
|
|
57
|
+
svgExport?: "auto" | "rects" | "image";
|
|
58
|
+
/**
|
|
59
|
+
* How cells are sampled when the bin grid is upscaled to `frame`.
|
|
60
|
+
* - `"nearest"` (default): each cell renders as a crisp rectangle.
|
|
61
|
+
* - `"linear"`: bilinearly interpolate between cells for a smoothed look.
|
|
62
|
+
*
|
|
63
|
+
* Applies to both the GPU path (sprite sampler) and the CPU/SVG `"image"`
|
|
64
|
+
* path (CSS `image-rendering`). Has no effect on the `"rects"` SVG export.
|
|
65
|
+
*/
|
|
66
|
+
interpolation?: "nearest" | "linear";
|
|
67
|
+
/**
|
|
68
|
+
* Per-cell value transform applied before color sampling. Default `"max"`
|
|
69
|
+
* — divide each bin by the grid's max so the palette spans `[0, 1]`.
|
|
70
|
+
* `"log"` and `"sqrt"` compress dynamic range so isolated hotspots don't
|
|
71
|
+
* wash out the rest of the field.
|
|
72
|
+
*/
|
|
73
|
+
normalize?: "max" | "log" | "sqrt";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface ResolvedSpec<T> {
|
|
77
|
+
x: (d: T) => number;
|
|
78
|
+
y: (d: T) => number;
|
|
79
|
+
weight: (d: T) => number;
|
|
80
|
+
nx: number;
|
|
81
|
+
ny: number;
|
|
82
|
+
/** Current binning domain. Overwritten from `viewport` each build if present. */
|
|
83
|
+
x0: number;
|
|
84
|
+
x1: number;
|
|
85
|
+
y0: number;
|
|
86
|
+
y1: number;
|
|
87
|
+
/** Current output rect. Overwritten from `viewport.frame` each build if present. */
|
|
88
|
+
frame: FrameRect;
|
|
89
|
+
viewport: DataViewport<number, number> | null;
|
|
90
|
+
colorMap: ContinuousPalette;
|
|
91
|
+
weightScale: number;
|
|
92
|
+
svgExport: "auto" | "rects" | "image";
|
|
93
|
+
interpolation: "nearest" | "linear";
|
|
94
|
+
normalize: "max" | "log" | "sqrt";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const LUT_SIZE = 256;
|
|
98
|
+
/** Workgroup size for 1D compute passes (clear, splat, reduce). Must match
|
|
99
|
+
* the `@workgroup_size(...)` literals in the WGSL strings. */
|
|
100
|
+
export const WG_1D = 256;
|
|
101
|
+
/** Workgroup size per dimension for the 2D colormap pass. */
|
|
102
|
+
export const WG_2D = 8;
|
|
103
|
+
/**
|
|
104
|
+
* Cell budget above which the auto SVG export path falls back to a single
|
|
105
|
+
* rasterized image instead of N rect commands. ~16K rects works for vector
|
|
106
|
+
* SVG; past that, image is faster to render and produces smaller files.
|
|
107
|
+
*/
|
|
108
|
+
export const HEATMAP_RECT_PIXEL_BUDGET = 128 * 128;
|
|
109
|
+
|
|
110
|
+
export function resolveSpec<T>(spec: HeatmapSpec<T>): ResolvedSpec<T> {
|
|
111
|
+
const [nx, ny] = spec.bins;
|
|
112
|
+
if (!Number.isInteger(nx) || !Number.isInteger(ny) || nx < 1 || ny < 1) {
|
|
113
|
+
throw new Error("heatmapLayer: `bins` entries must be positive integers.");
|
|
114
|
+
}
|
|
115
|
+
const weightScale = spec.weightScale ?? 1_000_000;
|
|
116
|
+
if (!Number.isFinite(weightScale) || weightScale <= 0) {
|
|
117
|
+
throw new Error("heatmapLayer: `weightScale` must be a positive finite number.");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const viewport = spec.viewport ?? null;
|
|
121
|
+
const xDomain =
|
|
122
|
+
spec.xDomain ?? (viewport ? (viewport.visibleXDomain as readonly [number, number]) : null);
|
|
123
|
+
const yDomain =
|
|
124
|
+
spec.yDomain ?? (viewport ? (viewport.visibleYDomain as readonly [number, number]) : null);
|
|
125
|
+
const frame = spec.frame ?? viewport?.frame ?? null;
|
|
126
|
+
if (!xDomain || !yDomain) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
"heatmapLayer: `xDomain` and `yDomain` are required when no `viewport` is provided.",
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
if (!frame) {
|
|
132
|
+
throw new Error("heatmapLayer: `frame` is required when no `viewport` is provided.");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
x: spec.x,
|
|
137
|
+
y: spec.y,
|
|
138
|
+
weight: spec.weight ?? (() => 1),
|
|
139
|
+
nx,
|
|
140
|
+
ny,
|
|
141
|
+
x0: xDomain[0],
|
|
142
|
+
x1: xDomain[1],
|
|
143
|
+
y0: yDomain[0],
|
|
144
|
+
y1: yDomain[1],
|
|
145
|
+
frame,
|
|
146
|
+
viewport,
|
|
147
|
+
svgExport: spec.svgExport ?? "auto",
|
|
148
|
+
colorMap: spec.colorMap,
|
|
149
|
+
weightScale,
|
|
150
|
+
interpolation: spec.interpolation ?? "nearest",
|
|
151
|
+
normalize: spec.normalize ?? "max",
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Apply the configured normalization to a raw bin value, given the grid's
|
|
157
|
+
* `max`. Returns a `[0, 1]` parameter for palette sampling.
|
|
158
|
+
*/
|
|
159
|
+
export function normalizeValue(value: number, max: number, mode: "max" | "log" | "sqrt"): number {
|
|
160
|
+
if (max <= 0 || value <= 0) return 0;
|
|
161
|
+
if (mode === "max") return value / max;
|
|
162
|
+
if (mode === "sqrt") return Math.sqrt(value / max);
|
|
163
|
+
// log: log(1+v) / log(1+max) — well-defined for v=0, monotonic, in [0,1].
|
|
164
|
+
return Math.log1p(value) / Math.log1p(max);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function clamp01(v: number): number {
|
|
168
|
+
if (v < 0) return 0;
|
|
169
|
+
if (v > 1) return 1;
|
|
170
|
+
return v;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function nextPow2(n: number): number {
|
|
174
|
+
let p = 1;
|
|
175
|
+
while (p < n) p <<= 1;
|
|
176
|
+
return p;
|
|
177
|
+
}
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
// Phase 1 GPU verification: heatmap interop → v3 sprite port.
|
|
2
|
+
//
|
|
3
|
+
// This test confirms that the heatmap GPU path (binning compute passes +
|
|
4
|
+
// sprite layer drawn via the v3 renderer) actually rasterizes colored pixels
|
|
5
|
+
// onto the framebuffer. It would CATCH a regression where:
|
|
6
|
+
// - The sprite layer is returned but never drawn (blank output).
|
|
7
|
+
// - The compute passes run but write no colormap output.
|
|
8
|
+
// - The output texture is never bound to the sprite (all-transparent quad).
|
|
9
|
+
//
|
|
10
|
+
// Run ONLY via the browser GPU suite on a machine with real GPU or SwiftShader:
|
|
11
|
+
// vp run --filter insomni-plot test:browser
|
|
12
|
+
|
|
13
|
+
import { beforeAll, describe, expect, test } from "vite-plus/test";
|
|
14
|
+
|
|
15
|
+
import { initGPU, createRenderer, type GPUHandle, type Renderer2D } from "insomni";
|
|
16
|
+
|
|
17
|
+
import { heatmapLayer } from "./heatmap.ts";
|
|
18
|
+
import { inferno } from "./colors.ts";
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Skip guard — mirrors packages/insomni/src/renderer.browser.test.ts exactly.
|
|
22
|
+
// Two-level guard:
|
|
23
|
+
// 1. `hasWebGPU` — synchronous module-load check. The whole suite is
|
|
24
|
+
// `describe.skip` when false (Node / no-GPU environment).
|
|
25
|
+
// 2. `gpuReady` — set in `beforeAll` after `initGPU` resolves. Individual
|
|
26
|
+
// tests return early when false (handles headless where navigator.gpu
|
|
27
|
+
// exists but no adapter is available, which would otherwise hang).
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
const hasWebGPU = typeof navigator !== "undefined" && !!(navigator as Navigator).gpu;
|
|
30
|
+
const describeWithGPU = hasWebGPU ? describe : describe.skip;
|
|
31
|
+
|
|
32
|
+
// Canvas / readback dimensions — small for fast pixel readback.
|
|
33
|
+
const W = 128;
|
|
34
|
+
const H = 128;
|
|
35
|
+
|
|
36
|
+
// The heatmap fills the full canvas.
|
|
37
|
+
const FRAME = { x: 0, y: 0, width: W, height: H };
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Helpers
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
function makeCanvas(w = W, h = H): HTMLCanvasElement {
|
|
44
|
+
const el = document.createElement("canvas");
|
|
45
|
+
el.width = w;
|
|
46
|
+
el.height = h;
|
|
47
|
+
return el;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getDevice(renderer: Renderer2D): GPUDevice {
|
|
51
|
+
const r = renderer as unknown as { root: { device: GPUDevice } };
|
|
52
|
+
return r.root.device;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function getBackbuffer(renderer: Renderer2D): GPUTexture {
|
|
56
|
+
const r = renderer as unknown as { ctx: { backbuffer: GPUTexture | null } };
|
|
57
|
+
const bb = r.ctx.backbuffer;
|
|
58
|
+
if (!bb) throw new Error("getBackbuffer: not in persistent mode (backbuffer is null)");
|
|
59
|
+
return bb;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Read back the full pixel contents of a GPU texture as a tight RGBA
|
|
64
|
+
* Uint8Array (row-major, width*height*4 bytes). Inlined here because
|
|
65
|
+
* `readTextureToPixels` is insomni-internal and not part of the public API.
|
|
66
|
+
*/
|
|
67
|
+
async function readPixels(
|
|
68
|
+
device: GPUDevice,
|
|
69
|
+
texture: GPUTexture,
|
|
70
|
+
w: number,
|
|
71
|
+
h: number,
|
|
72
|
+
): Promise<Uint8Array> {
|
|
73
|
+
const bytesPerRow = Math.ceil((w * 4) / 256) * 256;
|
|
74
|
+
const buf = device.createBuffer({
|
|
75
|
+
size: bytesPerRow * h,
|
|
76
|
+
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
|
|
77
|
+
});
|
|
78
|
+
const encoder = device.createCommandEncoder();
|
|
79
|
+
encoder.copyTextureToBuffer({ texture }, { buffer: buf, bytesPerRow, rowsPerImage: h }, [
|
|
80
|
+
w,
|
|
81
|
+
h,
|
|
82
|
+
1,
|
|
83
|
+
]);
|
|
84
|
+
device.queue.submit([encoder.finish()]);
|
|
85
|
+
await buf.mapAsync(GPUMapMode.READ);
|
|
86
|
+
const src = new Uint8Array(buf.getMappedRange());
|
|
87
|
+
// De-stride: extract only the active bytes (w*4) from each padded row.
|
|
88
|
+
const out = new Uint8Array(w * h * 4);
|
|
89
|
+
const rowBytes = w * 4;
|
|
90
|
+
for (let row = 0; row < h; row++) {
|
|
91
|
+
out.set(src.subarray(row * bytesPerRow, row * bytesPerRow + rowBytes), row * rowBytes);
|
|
92
|
+
}
|
|
93
|
+
buf.unmap();
|
|
94
|
+
buf.destroy();
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// GPU handle shared across tests
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
let gpu: GPUHandle | null = null;
|
|
103
|
+
let gpuReady = false;
|
|
104
|
+
|
|
105
|
+
beforeAll(async () => {
|
|
106
|
+
if (!hasWebGPU) return;
|
|
107
|
+
try {
|
|
108
|
+
// 10 s timeout — fast on machines with a real GPU, fails fast in headless.
|
|
109
|
+
gpu = await new Promise<GPUHandle>((resolve, reject) => {
|
|
110
|
+
const timer = setTimeout(
|
|
111
|
+
() => reject(new Error("initGPU timed out — no WebGPU adapter")),
|
|
112
|
+
10_000,
|
|
113
|
+
);
|
|
114
|
+
initGPU()
|
|
115
|
+
.then((h) => {
|
|
116
|
+
clearTimeout(timer);
|
|
117
|
+
resolve(h);
|
|
118
|
+
})
|
|
119
|
+
.catch((e: unknown) => {
|
|
120
|
+
clearTimeout(timer);
|
|
121
|
+
reject(e);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
gpuReady = true;
|
|
125
|
+
} catch {
|
|
126
|
+
gpu = null;
|
|
127
|
+
gpuReady = false;
|
|
128
|
+
}
|
|
129
|
+
}, 15_000);
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Tests
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
describeWithGPU("heatmap GPU pixel tests — Phase 1 v3 sprite port", () => {
|
|
136
|
+
// -------------------------------------------------------------------------
|
|
137
|
+
// HEATMAP-SPRITE-DRAWS: the v3 sprite path writes colored pixels
|
|
138
|
+
//
|
|
139
|
+
// Build a small heatmap over a dense cluster of 400 points in the domain
|
|
140
|
+
// center. Run a frame (compute + render). Read back the framebuffer and
|
|
141
|
+
// assert:
|
|
142
|
+
// - The canvas is NOT all-background (the sprite drew something).
|
|
143
|
+
// - The cluster region contains non-transparent pixels (colormap ran).
|
|
144
|
+
// - The cluster region has higher total color signal than empty corners.
|
|
145
|
+
// - At least one pixel in the cluster interior has a red channel > 128,
|
|
146
|
+
// consistent with the inferno colormap (which goes from black through
|
|
147
|
+
// purple/red to yellow at t=1). At high density t ≈ 1, inferno yields
|
|
148
|
+
// near-yellow (R≈252, G≈255, B≈164) or orange/red (R>200, G~100, B~0);
|
|
149
|
+
// any channel above threshold confirms colormap was applied.
|
|
150
|
+
//
|
|
151
|
+
// This test would FAIL if:
|
|
152
|
+
// - The sprite layer is returned but `renderer.render()` skips it.
|
|
153
|
+
// - The compute callback is never invoked (compute seam broken).
|
|
154
|
+
// - The output texture remains at its cleared/zero state.
|
|
155
|
+
// - The sprite quad draws transparent (texture binding mismatch).
|
|
156
|
+
// -------------------------------------------------------------------------
|
|
157
|
+
test("HEATMAP-SPRITE-DRAWS: dense cluster produces non-background colored region", async () => {
|
|
158
|
+
if (!gpuReady) return;
|
|
159
|
+
|
|
160
|
+
const canvas = makeCanvas();
|
|
161
|
+
const renderer = createRenderer(gpu!.root, canvas, {
|
|
162
|
+
config: { persistent: true, oit: false },
|
|
163
|
+
dpr: 1,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// Dense cluster: 400 points tightly packed at (0.5, 0.5) in [0,1]×[0,1].
|
|
167
|
+
// At 16×16 bins the cluster saturates the center cells → t ≈ 1 → inferno
|
|
168
|
+
// hot end (bright yellow / orange) — easily distinguishable from background.
|
|
169
|
+
interface Point {
|
|
170
|
+
x: number;
|
|
171
|
+
y: number;
|
|
172
|
+
}
|
|
173
|
+
const data: Point[] = [];
|
|
174
|
+
for (let i = 0; i < 400; i++) {
|
|
175
|
+
// Tight cluster — all within ±0.05 of center.
|
|
176
|
+
const theta = (i / 400) * Math.PI * 2;
|
|
177
|
+
const r = 0.03 + (i % 7) * 0.003;
|
|
178
|
+
data.push({ x: 0.5 + Math.cos(theta) * r, y: 0.5 + Math.sin(theta) * r });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const producer = heatmapLayer(data, {
|
|
182
|
+
x: (d) => d.x,
|
|
183
|
+
y: (d) => d.y,
|
|
184
|
+
bins: [16, 16],
|
|
185
|
+
xDomain: [0, 1],
|
|
186
|
+
yDomain: [0, 1],
|
|
187
|
+
frame: FRAME,
|
|
188
|
+
colorMap: inferno,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// buildGPU queues the compute passes onto renderer.compute() and returns
|
|
192
|
+
// the sprite Layer. The sprite layer covers the full canvas (FRAME).
|
|
193
|
+
const spriteLayer = producer.buildGPU({ renderer });
|
|
194
|
+
|
|
195
|
+
// Run one frame: compute passes fire, then the render pass draws the sprite.
|
|
196
|
+
renderer.render([spriteLayer]);
|
|
197
|
+
const device = getDevice(renderer);
|
|
198
|
+
await device.queue.onSubmittedWorkDone();
|
|
199
|
+
const pixels = await readPixels(device, getBackbuffer(renderer), W, H);
|
|
200
|
+
|
|
201
|
+
// -----------------------------------------------------------------------
|
|
202
|
+
// Assertion 1: canvas is NOT all-background (0,0,0,0).
|
|
203
|
+
// Any non-zero pixel proves the sprite pipeline ran.
|
|
204
|
+
// -----------------------------------------------------------------------
|
|
205
|
+
let anyNonZero = false;
|
|
206
|
+
for (let i = 0; i < pixels.length; i++) {
|
|
207
|
+
if (pixels[i]! > 0) {
|
|
208
|
+
anyNonZero = true;
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
expect(anyNonZero).toBe(true);
|
|
213
|
+
|
|
214
|
+
// -----------------------------------------------------------------------
|
|
215
|
+
// Assertion 2: the cluster region (center quarter) contains non-transparent
|
|
216
|
+
// pixels. Inferno at high density (t≈1) produces near-yellow (a≈1), so
|
|
217
|
+
// the alpha channel must be substantially above zero in the cluster cells.
|
|
218
|
+
// -----------------------------------------------------------------------
|
|
219
|
+
// Center quarter: x=[32,96), y=[32,96) — spans the 8 center cells of the
|
|
220
|
+
// 16×16 grid when projected onto the 128×128 canvas (each cell = 8px).
|
|
221
|
+
let clusterNonTransparent = 0;
|
|
222
|
+
for (let py = 32; py < 96; py++) {
|
|
223
|
+
for (let px = 32; px < 96; px++) {
|
|
224
|
+
const idx = (py * W + px) * 4;
|
|
225
|
+
if (pixels[idx + 3]! > 32) clusterNonTransparent++;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
// At least 10% of the 64×64 center square must be non-transparent.
|
|
229
|
+
// A working heatmap fills nearly all cluster cells (≥ 4096 * 0.1 ≈ 410 px).
|
|
230
|
+
expect(clusterNonTransparent).toBeGreaterThan(400);
|
|
231
|
+
|
|
232
|
+
// -----------------------------------------------------------------------
|
|
233
|
+
// Assertion 3: the cluster interior has a meaningful RED signal.
|
|
234
|
+
// Inferno at t>0.5 has R>100 (purple/red range); at t≈1 R>250 (yellow).
|
|
235
|
+
// Empty bins map to t=0 → inferno black (R≈0). So R>100 distinguishes
|
|
236
|
+
// cluster cells from background/empty cells without pinning exact values.
|
|
237
|
+
// -----------------------------------------------------------------------
|
|
238
|
+
let hotPixels = 0;
|
|
239
|
+
for (let py = 40; py < 88; py++) {
|
|
240
|
+
for (let px = 40; px < 88; px++) {
|
|
241
|
+
const idx = (py * W + px) * 4;
|
|
242
|
+
if (pixels[idx]! > 100) hotPixels++; // red channel above inferno mid-range
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
// The dense cluster (all 400 points in ~3 cell radii) should saturate
|
|
246
|
+
// many center cells to t≈1 (bright yellow: R≈252). Require ≥ 20 hot pixels
|
|
247
|
+
// — robust to minor SwiftShader differences in bin distribution.
|
|
248
|
+
expect(hotPixels).toBeGreaterThan(20);
|
|
249
|
+
|
|
250
|
+
producer.destroy();
|
|
251
|
+
renderer.destroy();
|
|
252
|
+
}, 30_000);
|
|
253
|
+
|
|
254
|
+
// -------------------------------------------------------------------------
|
|
255
|
+
// HEATMAP-EMPTY-STAYS-DARK: zero-data heatmap produces background pixels
|
|
256
|
+
//
|
|
257
|
+
// A complementary canary: with NO data points the compute passes write all
|
|
258
|
+
// bins to 0 (t=0 everywhere) → inferno maps t=0 to near-black (R≈0,G≈0,B≈4,
|
|
259
|
+
// A≈255 — inferno has a non-zero alpha even at t=0 because it is opaque).
|
|
260
|
+
// The sprite still draws (alpha>0) but the color is near-black, NOT hot.
|
|
261
|
+
//
|
|
262
|
+
// We assert the center region has NO hot red (R < 50) — proving the colormap
|
|
263
|
+
// fired with t=0 rather than missing entirely and leaving a random texture.
|
|
264
|
+
// -------------------------------------------------------------------------
|
|
265
|
+
test("HEATMAP-EMPTY-STAYS-DARK: empty dataset yields no hot pixels", async () => {
|
|
266
|
+
if (!gpuReady) return;
|
|
267
|
+
|
|
268
|
+
interface Point {
|
|
269
|
+
x: number;
|
|
270
|
+
y: number;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const canvas = makeCanvas();
|
|
274
|
+
const renderer = createRenderer(gpu!.root, canvas, {
|
|
275
|
+
config: { persistent: true, oit: false },
|
|
276
|
+
dpr: 1,
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
const producer = heatmapLayer<Point>([], {
|
|
280
|
+
x: (d) => d.x,
|
|
281
|
+
y: (d) => d.y,
|
|
282
|
+
bins: [16, 16],
|
|
283
|
+
xDomain: [0, 1],
|
|
284
|
+
yDomain: [0, 1],
|
|
285
|
+
frame: FRAME,
|
|
286
|
+
colorMap: inferno,
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
const spriteLayer = producer.buildGPU({ renderer });
|
|
290
|
+
renderer.render([spriteLayer]);
|
|
291
|
+
const device = getDevice(renderer);
|
|
292
|
+
await device.queue.onSubmittedWorkDone();
|
|
293
|
+
const pixels = await readPixels(device, getBackbuffer(renderer), W, H);
|
|
294
|
+
|
|
295
|
+
// No hot red in the center region — inferno at t=0 has R≈0 (near-black).
|
|
296
|
+
let hotPixels = 0;
|
|
297
|
+
for (let py = 32; py < 96; py++) {
|
|
298
|
+
for (let px = 32; px < 96; px++) {
|
|
299
|
+
const idx = (py * W + px) * 4;
|
|
300
|
+
if (pixels[idx]! > 50) hotPixels++;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
expect(hotPixels).toBe(0);
|
|
304
|
+
|
|
305
|
+
producer.destroy();
|
|
306
|
+
renderer.destroy();
|
|
307
|
+
}, 30_000);
|
|
308
|
+
});
|