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.
Files changed (242) hide show
  1. package/LICENSE.md +674 -0
  2. package/README.md +81 -0
  3. package/dist/core.d.mts +340 -0
  4. package/dist/core.mjs +1047 -0
  5. package/dist/index.d.mts +3426 -0
  6. package/dist/index.mjs +12762 -0
  7. package/dist/interactions-DEFL_F4E.mjs +5395 -0
  8. package/dist/range-presets-CzECsu3V.d.mts +1523 -0
  9. package/package.json +34 -0
  10. package/src/annotations.d.ts +121 -0
  11. package/src/annotations.ts +438 -0
  12. package/src/axis.d.ts +184 -0
  13. package/src/axis.test.ts +131 -0
  14. package/src/axis.ts +765 -0
  15. package/src/colorbar.d.ts +69 -0
  16. package/src/colorbar.ts +294 -0
  17. package/src/colors.d.ts +57 -0
  18. package/src/colors.test.ts +28 -0
  19. package/src/colors.ts +486 -0
  20. package/src/core.ts +299 -0
  21. package/src/format.d.ts +54 -0
  22. package/src/format.ts +138 -0
  23. package/src/grammar/accessibility.d.ts +147 -0
  24. package/src/grammar/accessibility.test.ts +199 -0
  25. package/src/grammar/accessibility.ts +443 -0
  26. package/src/grammar/aes.d.ts +35 -0
  27. package/src/grammar/aes.test.ts +75 -0
  28. package/src/grammar/aes.ts +120 -0
  29. package/src/grammar/annotations.d.ts +86 -0
  30. package/src/grammar/annotations.test.ts +68 -0
  31. package/src/grammar/annotations.ts +336 -0
  32. package/src/grammar/attach-brush.d.ts +44 -0
  33. package/src/grammar/attach-brush.test.ts +214 -0
  34. package/src/grammar/attach-brush.ts +111 -0
  35. package/src/grammar/attach-presets.d.ts +33 -0
  36. package/src/grammar/attach-presets.test.ts +106 -0
  37. package/src/grammar/attach-presets.ts +215 -0
  38. package/src/grammar/chart.d.ts +952 -0
  39. package/src/grammar/chart.test.ts +118 -0
  40. package/src/grammar/chart.ts +1172 -0
  41. package/src/grammar/color-utils.d.ts +29 -0
  42. package/src/grammar/color-utils.test.ts +53 -0
  43. package/src/grammar/color-utils.ts +66 -0
  44. package/src/grammar/constants.d.ts +45 -0
  45. package/src/grammar/constants.ts +61 -0
  46. package/src/grammar/coord.d.ts +183 -0
  47. package/src/grammar/coord.test.ts +355 -0
  48. package/src/grammar/coord.ts +619 -0
  49. package/src/grammar/data/pivot.d.ts +57 -0
  50. package/src/grammar/data/pivot.ts +107 -0
  51. package/src/grammar/emphasis-driver.d.ts +69 -0
  52. package/src/grammar/emphasis-driver.test.ts +199 -0
  53. package/src/grammar/emphasis-driver.ts +205 -0
  54. package/src/grammar/equality.d.ts +3 -0
  55. package/src/grammar/equality.ts +40 -0
  56. package/src/grammar/facet.d.ts +63 -0
  57. package/src/grammar/facet.test.ts +60 -0
  58. package/src/grammar/facet.ts +175 -0
  59. package/src/grammar/geoms/_categorical.d.ts +94 -0
  60. package/src/grammar/geoms/_categorical.ts +0 -0
  61. package/src/grammar/geoms/_distribution.d.ts +52 -0
  62. package/src/grammar/geoms/_distribution.ts +125 -0
  63. package/src/grammar/geoms/_mark.d.ts +69 -0
  64. package/src/grammar/geoms/_mark.ts +136 -0
  65. package/src/grammar/geoms/_shape.d.ts +41 -0
  66. package/src/grammar/geoms/_shape.ts +74 -0
  67. package/src/grammar/geoms/aggregate.d.ts +95 -0
  68. package/src/grammar/geoms/aggregate.test.ts +554 -0
  69. package/src/grammar/geoms/aggregate.ts +840 -0
  70. package/src/grammar/geoms/area.d.ts +32 -0
  71. package/src/grammar/geoms/area.test.ts +165 -0
  72. package/src/grammar/geoms/area.ts +578 -0
  73. package/src/grammar/geoms/band.d.ts +27 -0
  74. package/src/grammar/geoms/band.test.ts +57 -0
  75. package/src/grammar/geoms/band.ts +126 -0
  76. package/src/grammar/geoms/bar.d.ts +56 -0
  77. package/src/grammar/geoms/bar.test.ts +367 -0
  78. package/src/grammar/geoms/bar.ts +1054 -0
  79. package/src/grammar/geoms/boxplot.d.ts +129 -0
  80. package/src/grammar/geoms/boxplot.test.ts +299 -0
  81. package/src/grammar/geoms/boxplot.ts +834 -0
  82. package/src/grammar/geoms/connected-scatter.d.ts +27 -0
  83. package/src/grammar/geoms/connected-scatter.test.ts +157 -0
  84. package/src/grammar/geoms/connected-scatter.ts +63 -0
  85. package/src/grammar/geoms/emphasis.d.ts +76 -0
  86. package/src/grammar/geoms/emphasis.test.ts +135 -0
  87. package/src/grammar/geoms/emphasis.ts +162 -0
  88. package/src/grammar/geoms/histogram.d.ts +75 -0
  89. package/src/grammar/geoms/histogram.test.ts +262 -0
  90. package/src/grammar/geoms/histogram.ts +740 -0
  91. package/src/grammar/geoms/index.d.ts +20 -0
  92. package/src/grammar/geoms/index.ts +77 -0
  93. package/src/grammar/geoms/interval.d.ts +31 -0
  94. package/src/grammar/geoms/interval.test.ts +154 -0
  95. package/src/grammar/geoms/interval.ts +342 -0
  96. package/src/grammar/geoms/line.d.ts +38 -0
  97. package/src/grammar/geoms/line.test.ts +247 -0
  98. package/src/grammar/geoms/line.ts +659 -0
  99. package/src/grammar/geoms/point.d.ts +57 -0
  100. package/src/grammar/geoms/point.test.ts +163 -0
  101. package/src/grammar/geoms/point.ts +545 -0
  102. package/src/grammar/geoms/polar.test.ts +216 -0
  103. package/src/grammar/geoms/ribbon.d.ts +21 -0
  104. package/src/grammar/geoms/ribbon.test.ts +170 -0
  105. package/src/grammar/geoms/ribbon.ts +87 -0
  106. package/src/grammar/geoms/ridgeline.d.ts +89 -0
  107. package/src/grammar/geoms/ridgeline.test.ts +247 -0
  108. package/src/grammar/geoms/ridgeline.ts +1164 -0
  109. package/src/grammar/geoms/rolling.d.ts +43 -0
  110. package/src/grammar/geoms/rolling.test.ts +217 -0
  111. package/src/grammar/geoms/rolling.ts +387 -0
  112. package/src/grammar/geoms/rug.d.ts +28 -0
  113. package/src/grammar/geoms/rug.test.ts +126 -0
  114. package/src/grammar/geoms/rug.ts +214 -0
  115. package/src/grammar/geoms/rule.d.ts +23 -0
  116. package/src/grammar/geoms/rule.test.ts +69 -0
  117. package/src/grammar/geoms/rule.ts +212 -0
  118. package/src/grammar/geoms/smooth.d.ts +54 -0
  119. package/src/grammar/geoms/smooth.test.ts +78 -0
  120. package/src/grammar/geoms/smooth.ts +337 -0
  121. package/src/grammar/geoms/text.d.ts +29 -0
  122. package/src/grammar/geoms/text.test.ts +64 -0
  123. package/src/grammar/geoms/text.ts +234 -0
  124. package/src/grammar/geoms/tile.d.ts +61 -0
  125. package/src/grammar/geoms/tile.test.ts +157 -0
  126. package/src/grammar/geoms/tile.ts +621 -0
  127. package/src/grammar/geoms/types.d.ts +319 -0
  128. package/src/grammar/geoms/types.ts +362 -0
  129. package/src/grammar/geoms/violin.d.ts +85 -0
  130. package/src/grammar/geoms/violin.test.ts +187 -0
  131. package/src/grammar/geoms/violin.ts +672 -0
  132. package/src/grammar/index.d.ts +22 -0
  133. package/src/grammar/index.ts +269 -0
  134. package/src/grammar/interactions/_disposable.d.ts +5 -0
  135. package/src/grammar/interactions/_disposable.ts +23 -0
  136. package/src/grammar/interactions/_z.d.ts +4 -0
  137. package/src/grammar/interactions/_z.ts +16 -0
  138. package/src/grammar/interactions/brush-selection.test.ts +262 -0
  139. package/src/grammar/interactions/brush.d.ts +63 -0
  140. package/src/grammar/interactions/brush.test.ts +483 -0
  141. package/src/grammar/interactions/brush.ts +452 -0
  142. package/src/grammar/interactions/crosshair.d.ts +19 -0
  143. package/src/grammar/interactions/crosshair.test.ts +127 -0
  144. package/src/grammar/interactions/crosshair.ts +76 -0
  145. package/src/grammar/interactions/hit-layer.d.ts +64 -0
  146. package/src/grammar/interactions/hit-layer.ts +246 -0
  147. package/src/grammar/interactions/legend.d.ts +19 -0
  148. package/src/grammar/interactions/legend.ts +101 -0
  149. package/src/grammar/interactions/menu.d.ts +93 -0
  150. package/src/grammar/interactions/menu.test.ts +373 -0
  151. package/src/grammar/interactions/menu.ts +342 -0
  152. package/src/grammar/interactions/selection.d.ts +25 -0
  153. package/src/grammar/interactions/selection.test.ts +289 -0
  154. package/src/grammar/interactions/selection.ts +142 -0
  155. package/src/grammar/interactions/series-readout.d.ts +91 -0
  156. package/src/grammar/interactions/series-readout.test.ts +668 -0
  157. package/src/grammar/interactions/series-readout.ts +422 -0
  158. package/src/grammar/interactions/series-snap.d.ts +70 -0
  159. package/src/grammar/interactions/series-snap.test.ts +214 -0
  160. package/src/grammar/interactions/series-snap.ts +218 -0
  161. package/src/grammar/interactions/tooltip-axis.test.ts +176 -0
  162. package/src/grammar/interactions/tooltip-touch.browser.test.ts +49 -0
  163. package/src/grammar/interactions/tooltip-touch.test.ts +161 -0
  164. package/src/grammar/interactions/tooltip.d.ts +140 -0
  165. package/src/grammar/interactions/tooltip.test.ts +406 -0
  166. package/src/grammar/interactions/tooltip.ts +622 -0
  167. package/src/grammar/interactions/transitions.d.ts +34 -0
  168. package/src/grammar/interactions/transitions.test.ts +172 -0
  169. package/src/grammar/interactions/transitions.ts +160 -0
  170. package/src/grammar/layout.d.ts +68 -0
  171. package/src/grammar/layout.ts +186 -0
  172. package/src/grammar/legend-merge.test.ts +332 -0
  173. package/src/grammar/mount.d.ts +78 -0
  174. package/src/grammar/mount.test.ts +479 -0
  175. package/src/grammar/mount.ts +2112 -0
  176. package/src/grammar/palettes.d.ts +54 -0
  177. package/src/grammar/palettes.test.ts +80 -0
  178. package/src/grammar/palettes.ts +167 -0
  179. package/src/grammar/pan-zoom.test.ts +398 -0
  180. package/src/grammar/phylo.d.ts +65 -0
  181. package/src/grammar/phylo.test.ts +59 -0
  182. package/src/grammar/phylo.ts +112 -0
  183. package/src/grammar/pipeline.auto-ticks.test.ts +40 -0
  184. package/src/grammar/pipeline.d.ts +158 -0
  185. package/src/grammar/pipeline.test.ts +463 -0
  186. package/src/grammar/pipeline.ts +1233 -0
  187. package/src/grammar/profiling.d.ts +8 -0
  188. package/src/grammar/profiling.ts +24 -0
  189. package/src/grammar/scales.d.ts +188 -0
  190. package/src/grammar/scales.test.ts +181 -0
  191. package/src/grammar/scales.ts +800 -0
  192. package/src/grammar/svg.d.ts +3 -0
  193. package/src/grammar/svg.ts +39 -0
  194. package/src/grammar/theme.d.ts +261 -0
  195. package/src/grammar/theme.test.ts +105 -0
  196. package/src/grammar/theme.ts +490 -0
  197. package/src/heatmap/cpu.ts +109 -0
  198. package/src/heatmap/gpu.ts +565 -0
  199. package/src/heatmap/types.ts +177 -0
  200. package/src/heatmap.browser.test.ts +308 -0
  201. package/src/heatmap.test.ts +320 -0
  202. package/src/heatmap.ts +123 -0
  203. package/src/index.d.ts +1 -0
  204. package/src/index.ts +8 -0
  205. package/src/interactions.d.ts +48 -0
  206. package/src/interactions.test.ts +226 -0
  207. package/src/interactions.ts +394 -0
  208. package/src/layout/box.d.ts +48 -0
  209. package/src/layout/box.test.ts +107 -0
  210. package/src/layout/box.ts +143 -0
  211. package/src/legend.d.ts +115 -0
  212. package/src/legend.ts +422 -0
  213. package/src/marks/curve.d.ts +43 -0
  214. package/src/marks/curve.ts +244 -0
  215. package/src/marks/stack.d.ts +53 -0
  216. package/src/marks/stack.ts +184 -0
  217. package/src/marks.d.ts +273 -0
  218. package/src/marks.test.ts +541 -0
  219. package/src/marks.ts +1292 -0
  220. package/src/navigator.test.ts +174 -0
  221. package/src/navigator.ts +393 -0
  222. package/src/range-presets.d.ts +113 -0
  223. package/src/range-presets.test.ts +345 -0
  224. package/src/range-presets.ts +349 -0
  225. package/src/scales.d.ts +98 -0
  226. package/src/scales.test.ts +103 -0
  227. package/src/scales.ts +695 -0
  228. package/src/stats/index.d.ts +200 -0
  229. package/src/stats/index.test.ts +349 -0
  230. package/src/stats/index.ts +740 -0
  231. package/src/stats/regression.d.ts +38 -0
  232. package/src/stats/regression.test.ts +56 -0
  233. package/src/stats/regression.ts +396 -0
  234. package/src/stats/rolling-window.d.ts +55 -0
  235. package/src/stats/rolling-window.test.ts +237 -0
  236. package/src/stats/rolling-window.ts +256 -0
  237. package/src/test-setup.ts +19 -0
  238. package/src/viewport/axis-state.d.ts +72 -0
  239. package/src/viewport/axis-state.ts +476 -0
  240. package/src/viewport.d.ts +170 -0
  241. package/src/viewport.test.ts +363 -0
  242. 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
+ });