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,320 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, expect, test, vi } from "vite-plus/test";
|
|
3
|
+
|
|
4
|
+
import { viridis } from "./colors.ts";
|
|
5
|
+
import { HeatmapCpuRenderer } from "./heatmap/cpu.ts";
|
|
6
|
+
import { HeatmapGpuRenderer } from "./heatmap/gpu.ts";
|
|
7
|
+
import { resolveSpec, type HeatmapSpec } from "./heatmap/types.ts";
|
|
8
|
+
import { Layer } from "insomni";
|
|
9
|
+
|
|
10
|
+
interface Pt {
|
|
11
|
+
x: number;
|
|
12
|
+
y: number;
|
|
13
|
+
w?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function baseSpec(overrides: Partial<HeatmapSpec<Pt>> = {}): HeatmapSpec<Pt> {
|
|
17
|
+
return {
|
|
18
|
+
x: (d) => d.x,
|
|
19
|
+
y: (d) => d.y,
|
|
20
|
+
bins: [4, 4],
|
|
21
|
+
xDomain: [0, 4],
|
|
22
|
+
yDomain: [0, 4],
|
|
23
|
+
frame: { x: 0, y: 0, width: 40, height: 40 },
|
|
24
|
+
colorMap: viridis,
|
|
25
|
+
...overrides,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function cpu(
|
|
30
|
+
data: readonly Pt[],
|
|
31
|
+
overrides: Partial<HeatmapSpec<Pt>> = {},
|
|
32
|
+
): HeatmapCpuRenderer<Pt> {
|
|
33
|
+
return new HeatmapCpuRenderer(resolveSpec(baseSpec(overrides)), data);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Spec validation (lives in resolveSpec, shared by both renderers)
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
describe("heatmap spec validation", () => {
|
|
41
|
+
test("rejects non-integer bin counts", () => {
|
|
42
|
+
expect(() => resolveSpec(baseSpec({ bins: [0, 4] }))).toThrow(/positive integers/);
|
|
43
|
+
expect(() => resolveSpec(baseSpec({ bins: [4, 2.5] }))).toThrow(/positive integers/);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("rejects non-positive weightScale", () => {
|
|
47
|
+
expect(() => resolveSpec(baseSpec({ weightScale: 0 }))).toThrow(/weightScale/);
|
|
48
|
+
expect(() => resolveSpec(baseSpec({ weightScale: -1 }))).toThrow(/weightScale/);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// CPU binning — HeatmapCpuRenderer.build() returns v3 Layer drawables
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
describe("HeatmapCpuRenderer.build — CPU binning", () => {
|
|
57
|
+
test("empty data produces a v3 Layer with no rects", () => {
|
|
58
|
+
const [layer] = cpu([]).build();
|
|
59
|
+
expect(layer).toBeInstanceOf(Layer);
|
|
60
|
+
expect((layer as Layer).shapeCount).toBe(0);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("emits one rect per non-empty cell", () => {
|
|
64
|
+
// 4×4 grid over [0,4]×[0,4] → cells at integer coords 0..3.
|
|
65
|
+
// Points at (0.5, 0.5), (0.5, 0.5), (2.5, 1.5) → 2 distinct cells.
|
|
66
|
+
const [layer] = cpu([
|
|
67
|
+
{ x: 0.5, y: 0.5 },
|
|
68
|
+
{ x: 0.5, y: 0.5 },
|
|
69
|
+
{ x: 2.5, y: 1.5 },
|
|
70
|
+
]).build();
|
|
71
|
+
expect((layer as Layer).shapeCount).toBe(2);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("drops data outside the domain", () => {
|
|
75
|
+
const [layer] = cpu([
|
|
76
|
+
{ x: -1, y: 0.5 },
|
|
77
|
+
{ x: 4.5, y: 0.5 }, // exactly at upper bound is excluded (tx >= 1)
|
|
78
|
+
{ x: 4, y: 2 }, // tx == 1 excluded
|
|
79
|
+
{ x: 1, y: 1 },
|
|
80
|
+
]).build();
|
|
81
|
+
expect((layer as Layer).shapeCount).toBe(1);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("rect positions align with the frame", () => {
|
|
85
|
+
const frame = { x: 100, y: 50, width: 40, height: 40 };
|
|
86
|
+
// bins 4×4 over [0,4]×[0,4] → 10px cells
|
|
87
|
+
const [layer] = cpu([{ x: 2.5, y: 1.5 }], { frame }).build();
|
|
88
|
+
const l = layer as Layer;
|
|
89
|
+
const cmd = l.pack.commands[0]!;
|
|
90
|
+
// v3 packs a typeFlag (number) per command rather than a "shapes" string;
|
|
91
|
+
// a rect lives in the rect-kind span, so there is exactly one command.
|
|
92
|
+
expect(l.pack.commands.length).toBe(1);
|
|
93
|
+
// ix = floor(2.5 * 4 / 4) = 2 → cell x = 100 + 2*10 = 120 (left edge)
|
|
94
|
+
// iy is flipped so higher data-y is higher on screen:
|
|
95
|
+
// raw = floor(1.5 * 4 / 4) = 1 → iy = ny-1-raw = 2 → cell y = 50 + 2*10 = 70
|
|
96
|
+
// The 10×10 cell rect therefore centers at (125, 75). Assert via the AABB
|
|
97
|
+
// center (the v3 SDF rect pads its AABB by a feather margin for AA, so the
|
|
98
|
+
// raw min/max carry an implementation-defined inset — the center does not).
|
|
99
|
+
const aabb = l.pack.aabbs;
|
|
100
|
+
const cx = (aabb[0]! + aabb[2]!) / 2;
|
|
101
|
+
const cy = (aabb[1]! + aabb[3]!) / 2;
|
|
102
|
+
expect(cx).toBeCloseTo(125);
|
|
103
|
+
expect(cy).toBeCloseTo(75);
|
|
104
|
+
void cmd;
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("uses weight accessor when provided", () => {
|
|
108
|
+
const [layer] = cpu(
|
|
109
|
+
[
|
|
110
|
+
{ x: 0.5, y: 0.5, w: 5 },
|
|
111
|
+
{ x: 0.5, y: 0.5, w: 7 },
|
|
112
|
+
],
|
|
113
|
+
{ weight: (d) => d.w ?? 1 },
|
|
114
|
+
).build();
|
|
115
|
+
// One cell, sum weight = 12 → fill should be at t=1 of the palette.
|
|
116
|
+
expect((layer as Layer).shapeCount).toBe(1);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("setData re-bins on next build call", () => {
|
|
120
|
+
const renderer = cpu([{ x: 0.5, y: 0.5 }]);
|
|
121
|
+
const [first] = renderer.build();
|
|
122
|
+
expect((first as Layer).shapeCount).toBe(1);
|
|
123
|
+
|
|
124
|
+
renderer.setData([
|
|
125
|
+
{ x: 0.5, y: 0.5 },
|
|
126
|
+
{ x: 2.5, y: 2.5 },
|
|
127
|
+
{ x: 3.5, y: 0.5 },
|
|
128
|
+
]);
|
|
129
|
+
const [second] = renderer.build();
|
|
130
|
+
expect((second as Layer).shapeCount).toBe(3);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("degenerate domain produces an empty layer", () => {
|
|
134
|
+
const [layer] = cpu([{ x: 1, y: 1 }], { xDomain: [2, 2] }).build();
|
|
135
|
+
expect((layer as Layer).shapeCount).toBe(0);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("getMax reflects the largest bin weight", () => {
|
|
139
|
+
const renderer = cpu(
|
|
140
|
+
[
|
|
141
|
+
{ x: 0.5, y: 0.5, w: 5 },
|
|
142
|
+
{ x: 0.5, y: 0.5, w: 7 },
|
|
143
|
+
{ x: 2.5, y: 2.5, w: 3 },
|
|
144
|
+
],
|
|
145
|
+
{ weight: (d) => d.w ?? 1 },
|
|
146
|
+
);
|
|
147
|
+
expect(renderer.getMax()).toBe(12);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
// GPU build — caller-driven v3 compute seam + sprite Layer
|
|
153
|
+
//
|
|
154
|
+
// Replaces the dropped v1 SceneProducer / GPUProducerContext / enqueueCompute
|
|
155
|
+
// protocol. The new contract:
|
|
156
|
+
// build(root, renderer) → Layer (space:"ui", one sprite quad)
|
|
157
|
+
// - queues exactly one renderer.compute(cb) when binning is dirty
|
|
158
|
+
// - cb opens its own compute pass and dispatches the binning kernels
|
|
159
|
+
// - returns a sprite Layer (spritePack with count 1) reading the GPU output
|
|
160
|
+
// - the sprite Layer is cleared + re-pushed every frame (no bumpVersion needed)
|
|
161
|
+
//
|
|
162
|
+
// jsdom has no real WebGPU, so we drive the renderer against a recording fake
|
|
163
|
+
// device/root that hands back opaque tokens for every resource. This proves the
|
|
164
|
+
// seam wiring (compute callback registered + runs, sprite Layer produced) without
|
|
165
|
+
// asserting GPU pixel behaviour (covered by the renderer's browser suite).
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
// jsdom defines no WebGPU globals. `ensureState` reads the usage/visibility
|
|
169
|
+
// flag enums to build descriptors; the values are irrelevant to the recording
|
|
170
|
+
// fake (it ignores descriptors), so we stub them with the real bit values.
|
|
171
|
+
const g = globalThis as Record<string, unknown>;
|
|
172
|
+
g.GPUBufferUsage ??= {
|
|
173
|
+
STORAGE: 0x80,
|
|
174
|
+
COPY_DST: 0x08,
|
|
175
|
+
COPY_SRC: 0x04,
|
|
176
|
+
UNIFORM: 0x40,
|
|
177
|
+
MAP_READ: 0x01,
|
|
178
|
+
};
|
|
179
|
+
g.GPUTextureUsage ??= {
|
|
180
|
+
TEXTURE_BINDING: 0x04,
|
|
181
|
+
STORAGE_BINDING: 0x08,
|
|
182
|
+
COPY_DST: 0x10,
|
|
183
|
+
};
|
|
184
|
+
g.GPUShaderStage ??= { COMPUTE: 0x04, VERTEX: 0x01, FRAGMENT: 0x02 };
|
|
185
|
+
|
|
186
|
+
function fakeComputePass() {
|
|
187
|
+
return {
|
|
188
|
+
setPipeline: vi.fn(),
|
|
189
|
+
setBindGroup: vi.fn(),
|
|
190
|
+
dispatchWorkgroups: vi.fn(),
|
|
191
|
+
end: vi.fn(),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function fakeEncoder(pass: ReturnType<typeof fakeComputePass>) {
|
|
196
|
+
return {
|
|
197
|
+
beginComputePass: vi.fn(() => pass),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
interface FakeGpu {
|
|
202
|
+
// The TgpuRoot stand-in passed to build().
|
|
203
|
+
root: { device: GPUDevice; createBuffer: ReturnType<typeof vi.fn> };
|
|
204
|
+
// The v3 Renderer2D stand-in.
|
|
205
|
+
renderer: { compute: ReturnType<typeof vi.fn> };
|
|
206
|
+
pass: ReturnType<typeof fakeComputePass>;
|
|
207
|
+
// Resolves the most recently queued compute callback against the fake encoder.
|
|
208
|
+
runQueuedCompute(): void;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function makeFakeGpu(): FakeGpu {
|
|
212
|
+
const token = (label: string) => ({ __token: label });
|
|
213
|
+
const device = {
|
|
214
|
+
createBuffer: vi.fn(() => ({ destroy: vi.fn() })),
|
|
215
|
+
createTexture: vi.fn(() => ({ createView: vi.fn(() => token("view")), destroy: vi.fn() })),
|
|
216
|
+
createShaderModule: vi.fn(() => token("module")),
|
|
217
|
+
createBindGroupLayout: vi.fn(() => token("bgl")),
|
|
218
|
+
createPipelineLayout: vi.fn(() => token("pll")),
|
|
219
|
+
createComputePipeline: vi.fn(() => token("pipeline")),
|
|
220
|
+
createBindGroup: vi.fn(() => token("bg")),
|
|
221
|
+
queue: { writeBuffer: vi.fn(), writeTexture: vi.fn() },
|
|
222
|
+
} as unknown as GPUDevice;
|
|
223
|
+
|
|
224
|
+
// root.createBuffer(...).$usage("storage") → a thing with `.buffer`.
|
|
225
|
+
const root = {
|
|
226
|
+
device,
|
|
227
|
+
createBuffer: vi.fn(() => ({
|
|
228
|
+
$usage: vi.fn(() => ({ buffer: token("sprite-buffer"), destroy: vi.fn() })),
|
|
229
|
+
})),
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const pass = fakeComputePass();
|
|
233
|
+
const queued: Array<(encoder: GPUCommandEncoder) => void> = [];
|
|
234
|
+
const renderer = {
|
|
235
|
+
compute: vi.fn((cb: (encoder: GPUCommandEncoder) => void) => queued.push(cb)),
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
root: root as unknown as FakeGpu["root"],
|
|
240
|
+
renderer,
|
|
241
|
+
pass,
|
|
242
|
+
runQueuedCompute() {
|
|
243
|
+
const cb = queued.at(-1);
|
|
244
|
+
if (cb) cb(fakeEncoder(pass) as unknown as GPUCommandEncoder);
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
describe("HeatmapGpuRenderer.build — v3 compute seam", () => {
|
|
250
|
+
test("returns a sprite Layer and queues a compute callback", () => {
|
|
251
|
+
const gpu = new HeatmapGpuRenderer(resolveSpec(baseSpec()), [
|
|
252
|
+
{ x: 0.5, y: 0.5 },
|
|
253
|
+
{ x: 2.5, y: 1.5 },
|
|
254
|
+
]);
|
|
255
|
+
const fake = makeFakeGpu();
|
|
256
|
+
|
|
257
|
+
const drawable = gpu.build(fake.root as never, fake.renderer as never);
|
|
258
|
+
|
|
259
|
+
expect(drawable).toBeInstanceOf(Layer);
|
|
260
|
+
// spritePack holds one quad pointing at the output texture.
|
|
261
|
+
expect(drawable.spritePack?.count).toBe(1);
|
|
262
|
+
// The dirty first build must register exactly one compute callback.
|
|
263
|
+
expect(fake.renderer.compute).toHaveBeenCalledTimes(1);
|
|
264
|
+
gpu.destroy();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("queued callback opens its own compute pass and dispatches", () => {
|
|
268
|
+
const gpu = new HeatmapGpuRenderer(resolveSpec(baseSpec()), [{ x: 0.5, y: 0.5 }]);
|
|
269
|
+
const fake = makeFakeGpu();
|
|
270
|
+
|
|
271
|
+
gpu.build(fake.root as never, fake.renderer as never);
|
|
272
|
+
fake.runQueuedCompute();
|
|
273
|
+
|
|
274
|
+
// The callback owns its pass (caller-driven), and runs the four binning
|
|
275
|
+
// kernels (clear, splat, reduce, colormap) → at least 4 dispatches + end.
|
|
276
|
+
expect(fake.pass.end).toHaveBeenCalledTimes(1);
|
|
277
|
+
expect(fake.pass.dispatchWorkgroups.mock.calls.length).toBeGreaterThanOrEqual(4);
|
|
278
|
+
gpu.destroy();
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("clean build skips compute; dirty build re-queues it (partial-redraw contract)", () => {
|
|
282
|
+
const gpu = new HeatmapGpuRenderer(resolveSpec(baseSpec()), [{ x: 0.5, y: 0.5 }]);
|
|
283
|
+
const fake = makeFakeGpu();
|
|
284
|
+
|
|
285
|
+
const drawable = gpu.build(fake.root as never, fake.renderer as never);
|
|
286
|
+
|
|
287
|
+
// Clean build: no new data, no compute queued; same Layer identity returned.
|
|
288
|
+
const again = gpu.build(fake.root as never, fake.renderer as never);
|
|
289
|
+
expect(again).toBe(drawable);
|
|
290
|
+
expect(fake.renderer.compute).toHaveBeenCalledTimes(1);
|
|
291
|
+
// Sprite is still present after second build (re-pushed each frame).
|
|
292
|
+
expect(again.spritePack?.count).toBe(1);
|
|
293
|
+
|
|
294
|
+
// Dirty build: data changed → compute re-queued.
|
|
295
|
+
gpu.setData([
|
|
296
|
+
{ x: 0.5, y: 0.5 },
|
|
297
|
+
{ x: 2.5, y: 2.5 },
|
|
298
|
+
]);
|
|
299
|
+
gpu.build(fake.root as never, fake.renderer as never);
|
|
300
|
+
expect(fake.renderer.compute).toHaveBeenCalledTimes(2);
|
|
301
|
+
gpu.destroy();
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// ---------------------------------------------------------------------------
|
|
306
|
+
// Lifecycle
|
|
307
|
+
// ---------------------------------------------------------------------------
|
|
308
|
+
|
|
309
|
+
describe("heatmap renderers — lifecycle", () => {
|
|
310
|
+
test("GPU destroy() is safe before any build (no GPU state)", () => {
|
|
311
|
+
const gpu = new HeatmapGpuRenderer(resolveSpec(baseSpec()), [{ x: 1, y: 1 }]);
|
|
312
|
+
expect(() => gpu.destroy()).not.toThrow();
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("GPU destroy() is idempotent", () => {
|
|
316
|
+
const gpu = new HeatmapGpuRenderer(resolveSpec(baseSpec()), []);
|
|
317
|
+
gpu.destroy();
|
|
318
|
+
expect(() => gpu.destroy()).not.toThrow();
|
|
319
|
+
});
|
|
320
|
+
});
|
package/src/heatmap.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// heatmapLayer — heatmap controller over a CPU and a GPU renderer.
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// v3 dropped the v1 SceneProducer / GPUProducerContext / ProducedDrawable draw
|
|
5
|
+
// protocol (decisions.md T4 = caller-driven `renderer.compute(cb)` + drawable
|
|
6
|
+
// Layer). This controller no longer implements `SceneProducer`; instead
|
|
7
|
+
// the chart layer decides CPU-vs-GPU and calls the matching build method:
|
|
8
|
+
//
|
|
9
|
+
// - GPU (interactive WebGPU): `buildGPU({ renderer })` threads
|
|
10
|
+
// `resolveRoot(renderer.device)` + the v3 renderer into the GPU renderer.
|
|
11
|
+
// The binning compute is queued onto the renderer's `compute(cb)` seam and
|
|
12
|
+
// a single sprite `Layer` (space:"ui") is returned (drawn post-main).
|
|
13
|
+
// - CPU (SVG export / no device): `buildCPU()` returns v3 `Layer`s of rects
|
|
14
|
+
// (or a rasterized image layer for dense grids).
|
|
15
|
+
//
|
|
16
|
+
// The capability switch lives HERE (the chart layer), never inside the
|
|
17
|
+
// renderer's `render()`.
|
|
18
|
+
|
|
19
|
+
import { resolveRoot } from "insomni/internal";
|
|
20
|
+
import type { Layer, Renderer2D } from "insomni";
|
|
21
|
+
|
|
22
|
+
import { HeatmapCpuRenderer, type HeatmapCpuDrawable } from "./heatmap/cpu.ts";
|
|
23
|
+
import { HeatmapGpuRenderer } from "./heatmap/gpu.ts";
|
|
24
|
+
import { resolveSpec, type HeatmapSpec, type ResolvedSpec } from "./heatmap/types.ts";
|
|
25
|
+
|
|
26
|
+
export type { HeatmapSpec } from "./heatmap/types.ts";
|
|
27
|
+
|
|
28
|
+
/** Options threaded into the GPU build path. */
|
|
29
|
+
export interface HeatmapBuildOptions {
|
|
30
|
+
/** The v3 renderer whose `compute(cb)` seam the binning passes are recorded onto. */
|
|
31
|
+
renderer: Renderer2D;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface HeatmapProducer<T> {
|
|
35
|
+
/** Swap the underlying data. Invalidates both CPU and GPU bin state. */
|
|
36
|
+
setData(data: readonly T[]): void;
|
|
37
|
+
/**
|
|
38
|
+
* Largest bin weight currently visible. Triggers a (cheap) CPU rebin if
|
|
39
|
+
* needed. Useful when wiring a continuous color-bar legend whose domain
|
|
40
|
+
* tracks the actual data range. Returns `0` when no data is in view.
|
|
41
|
+
*/
|
|
42
|
+
getMax(): number;
|
|
43
|
+
/**
|
|
44
|
+
* GPU path — queues the binning compute onto `renderer.compute(cb)` and returns
|
|
45
|
+
* a `space:"ui"` sprite {@link Layer} (a single quad covering the frame,
|
|
46
|
+
* sampling the compute-written output texture) to draw post-main. The renderer's
|
|
47
|
+
* device owns the GPU buffers/textures via `resolveRoot(renderer.device)`.
|
|
48
|
+
*/
|
|
49
|
+
buildGPU(opts: HeatmapBuildOptions): Layer;
|
|
50
|
+
/**
|
|
51
|
+
* CPU path — returns v3 `Layer`s (vector rects) or a rasterized image layer.
|
|
52
|
+
* Used by the SVG export path and any device-less consumer.
|
|
53
|
+
*/
|
|
54
|
+
buildCPU(): readonly HeatmapCpuDrawable[];
|
|
55
|
+
destroy(): void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function heatmapLayer<T>(data: readonly T[], spec: HeatmapSpec<T>): HeatmapProducer<T> {
|
|
59
|
+
return new HeatmapProducerImpl(data, spec);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
class HeatmapProducerImpl<T> implements HeatmapProducer<T> {
|
|
63
|
+
private readonly resolved: ResolvedSpec<T>;
|
|
64
|
+
private readonly cpu: HeatmapCpuRenderer<T>;
|
|
65
|
+
private readonly gpu: HeatmapGpuRenderer<T>;
|
|
66
|
+
private unsubscribeViewport: (() => void) | null = null;
|
|
67
|
+
|
|
68
|
+
constructor(data: readonly T[], spec: HeatmapSpec<T>) {
|
|
69
|
+
this.resolved = resolveSpec(spec);
|
|
70
|
+
this.cpu = new HeatmapCpuRenderer(this.resolved, data);
|
|
71
|
+
this.gpu = new HeatmapGpuRenderer(this.resolved, data);
|
|
72
|
+
if (this.resolved.viewport) {
|
|
73
|
+
this.unsubscribeViewport = this.resolved.viewport.onChange(() => {
|
|
74
|
+
this.cpu.markDirty();
|
|
75
|
+
this.gpu.markDirty();
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
setData(data: readonly T[]): void {
|
|
81
|
+
this.cpu.setData(data);
|
|
82
|
+
this.gpu.setData(data);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
getMax(): number {
|
|
86
|
+
this.syncFromViewport();
|
|
87
|
+
return this.cpu.getMax();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
buildCPU(): readonly HeatmapCpuDrawable[] {
|
|
91
|
+
this.syncFromViewport();
|
|
92
|
+
return this.cpu.build();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
buildGPU(opts: HeatmapBuildOptions): Layer {
|
|
96
|
+
this.syncFromViewport();
|
|
97
|
+
// v3's Renderer2D exposes `device` but not its TgpuRoot — resolve one from
|
|
98
|
+
// the device (cached per device by `resolveRoot`).
|
|
99
|
+
const root = resolveRoot(opts.renderer.device);
|
|
100
|
+
return this.gpu.build(root, opts.renderer);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
destroy(): void {
|
|
104
|
+
if (this.unsubscribeViewport) {
|
|
105
|
+
this.unsubscribeViewport();
|
|
106
|
+
this.unsubscribeViewport = null;
|
|
107
|
+
}
|
|
108
|
+
this.gpu.destroy();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Pull current visible domain + frame off the viewport, if one is attached. */
|
|
112
|
+
private syncFromViewport(): void {
|
|
113
|
+
const vp = this.resolved.viewport;
|
|
114
|
+
if (!vp) return;
|
|
115
|
+
const xd = vp.visibleXDomain as readonly [number, number];
|
|
116
|
+
const yd = vp.visibleYDomain as readonly [number, number];
|
|
117
|
+
this.resolved.x0 = xd[0];
|
|
118
|
+
this.resolved.x1 = xd[1];
|
|
119
|
+
this.resolved.y0 = yd[0];
|
|
120
|
+
this.resolved.y1 = yd[1];
|
|
121
|
+
this.resolved.frame = vp.frame;
|
|
122
|
+
}
|
|
123
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./grammar/index.ts";
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// insomni-plot — grammar-of-graphics API
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// The high-level grammar lives at the package root. Low-level imperative
|
|
5
|
+
// primitives (scales, axes, *Mark builders, viewport, navigator, etc.) are
|
|
6
|
+
// available at "insomni-plot/core".
|
|
7
|
+
|
|
8
|
+
export * from "./grammar/index.ts";
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type FlingOptions } from "insomni";
|
|
2
|
+
import type { DataViewport } from "./viewport.ts";
|
|
3
|
+
export type AxisSelection = "x" | "y" | "xy" | "none";
|
|
4
|
+
export interface BindDataViewportOptions {
|
|
5
|
+
/** Enable pointer drag pan. Default: true. */
|
|
6
|
+
drag?: boolean;
|
|
7
|
+
/** Enable mouse-wheel zoom. Default: true. */
|
|
8
|
+
wheel?: boolean;
|
|
9
|
+
/** Enable two-finger pinch (touch). Default: true. */
|
|
10
|
+
pinch?: boolean;
|
|
11
|
+
/** SmoothDamp time constant (seconds). `0` = instant (no smoothing). Default: `0`. */
|
|
12
|
+
smoothTime?: number;
|
|
13
|
+
/**
|
|
14
|
+
* Drag-release fling. `true` enables with defaults, `false` disables, or
|
|
15
|
+
* pass an object to tune. Default: off (fling on a chart feels disorienting).
|
|
16
|
+
*/
|
|
17
|
+
fling?: boolean | FlingOptions;
|
|
18
|
+
/**
|
|
19
|
+
* Axes that respond to drag / pinch pan. Default: derived from axis types
|
|
20
|
+
* (continuous/time axes pan; band axes don't).
|
|
21
|
+
*/
|
|
22
|
+
pan?: AxisSelection;
|
|
23
|
+
/** Axes that respond to wheel / pinch zoom. Default: derived from axis types. */
|
|
24
|
+
zoom?: AxisSelection;
|
|
25
|
+
/** Multiplier applied to wheel deltaY. Default: 0.001. */
|
|
26
|
+
wheelSensitivity?: number;
|
|
27
|
+
/**
|
|
28
|
+
* When true, Shift while wheeling zooms Y only; Meta / Ctrl zoom X only.
|
|
29
|
+
* Default: true.
|
|
30
|
+
*/
|
|
31
|
+
axisModifiers?: boolean;
|
|
32
|
+
}
|
|
33
|
+
export interface DataViewportBinding {
|
|
34
|
+
readonly mode: "data";
|
|
35
|
+
/** True while the user is actively dragging or pinching. */
|
|
36
|
+
readonly interacting: boolean;
|
|
37
|
+
/** True while a drag-release fling is still decaying. */
|
|
38
|
+
readonly flinging: boolean;
|
|
39
|
+
/** Hard disable. */
|
|
40
|
+
enabled: boolean;
|
|
41
|
+
/** Advance smoothing / fling by `dt` seconds and apply pending pan. */
|
|
42
|
+
update(dt: number): void;
|
|
43
|
+
/** Immediately cancel any pending smoothing and fling. */
|
|
44
|
+
stopAnimation(): void;
|
|
45
|
+
/** Remove all listeners and release any pending state. */
|
|
46
|
+
destroy(): void;
|
|
47
|
+
}
|
|
48
|
+
export declare function bindDataViewport<X, Y>(viewport: DataViewport<X, Y>, element: HTMLElement, options?: BindDataViewportOptions): DataViewportBinding;
|