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,668 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createFrame,
|
|
3
|
+
createInvalidator,
|
|
4
|
+
rgba,
|
|
5
|
+
type Color,
|
|
6
|
+
type Frame,
|
|
7
|
+
type InteractionManager,
|
|
8
|
+
type InteractionNode,
|
|
9
|
+
type InteractionNodeSpec,
|
|
10
|
+
type PointerInfo,
|
|
11
|
+
} from "insomni";
|
|
12
|
+
import { describe, expect, test } from "vite-plus/test";
|
|
13
|
+
|
|
14
|
+
import type { CompiledHitTest, ScaleBundle } from "../geoms/types.ts";
|
|
15
|
+
import { themeDefault } from "../theme.ts";
|
|
16
|
+
import type { GrammarHitLayer, HitEventContext, HitLayerSubscriber } from "./hit-layer.ts";
|
|
17
|
+
import { createSeriesReadout, __test__ } from "./series-readout.ts";
|
|
18
|
+
|
|
19
|
+
const { collectLayerGroups, resolveGroupKey, overrideGroupPick, snapshotEqual } = __test__;
|
|
20
|
+
|
|
21
|
+
interface Row {
|
|
22
|
+
t: number;
|
|
23
|
+
v: number;
|
|
24
|
+
series?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Fake manager — records added nodes, returns handles whose destroy() flips a
|
|
29
|
+
// flag so tests can assert teardown. Only the surface used by the readout
|
|
30
|
+
// (add / onChange / destroy) is implemented.
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
interface CapturedNode {
|
|
34
|
+
spec: InteractionNodeSpec;
|
|
35
|
+
destroyed: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function fakeManager(): { manager: InteractionManager; nodes: CapturedNode[] } {
|
|
39
|
+
const nodes: CapturedNode[] = [];
|
|
40
|
+
const manager = {
|
|
41
|
+
element: {} as HTMLElement,
|
|
42
|
+
add(spec: InteractionNodeSpec): InteractionNode {
|
|
43
|
+
const captured: CapturedNode = { spec, destroyed: false };
|
|
44
|
+
nodes.push(captured);
|
|
45
|
+
return {
|
|
46
|
+
id: Symbol("node"),
|
|
47
|
+
update(patch) {
|
|
48
|
+
captured.spec = { ...captured.spec, ...patch };
|
|
49
|
+
},
|
|
50
|
+
destroy() {
|
|
51
|
+
captured.destroyed = true;
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
},
|
|
55
|
+
addPointCloud() {
|
|
56
|
+
throw new Error("not used");
|
|
57
|
+
},
|
|
58
|
+
onBackgroundTap() {
|
|
59
|
+
return () => {};
|
|
60
|
+
},
|
|
61
|
+
onContextMenuRequest() {
|
|
62
|
+
return () => {};
|
|
63
|
+
},
|
|
64
|
+
onDoubleTap() {
|
|
65
|
+
return () => {};
|
|
66
|
+
},
|
|
67
|
+
onChange() {
|
|
68
|
+
return () => {};
|
|
69
|
+
},
|
|
70
|
+
destroy() {},
|
|
71
|
+
} as unknown as InteractionManager;
|
|
72
|
+
return { manager, nodes };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const noMods = { shift: false, ctrl: false, meta: false, alt: false };
|
|
76
|
+
function pointer(x: number, y: number): PointerInfo {
|
|
77
|
+
return {
|
|
78
|
+
pointerId: 1,
|
|
79
|
+
type: "mouse",
|
|
80
|
+
x,
|
|
81
|
+
y,
|
|
82
|
+
localX: x,
|
|
83
|
+
localY: y,
|
|
84
|
+
buttons: 0,
|
|
85
|
+
mods: noMods,
|
|
86
|
+
stopPropagation: () => {},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// Hit-test fixture builders
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
function singleSeriesHit(data: readonly Row[], xs: number[], label?: string): CompiledHitTest<Row> {
|
|
95
|
+
const positions = new Float32Array(xs.length * 2);
|
|
96
|
+
for (let i = 0; i < xs.length; i++) {
|
|
97
|
+
positions[i * 2] = xs[i]!;
|
|
98
|
+
positions[i * 2 + 1] = 50;
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
geomKind: "line",
|
|
102
|
+
label,
|
|
103
|
+
positions,
|
|
104
|
+
dataIndex: Int32Array.from(xs.map((_, i) => i)),
|
|
105
|
+
pickRadius: 5,
|
|
106
|
+
channels: {
|
|
107
|
+
x: { kind: "column", column: "t", fn: (d) => d.t },
|
|
108
|
+
y: { kind: "column", column: "v", fn: (d) => d.v },
|
|
109
|
+
},
|
|
110
|
+
data,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function multiSeriesHit(data: readonly Row[]): CompiledHitTest<Row> {
|
|
115
|
+
const positions = new Float32Array(data.length * 2);
|
|
116
|
+
for (let i = 0; i < data.length; i++) {
|
|
117
|
+
positions[i * 2] = data[i]!.t;
|
|
118
|
+
positions[i * 2 + 1] = 50;
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
geomKind: "line",
|
|
122
|
+
positions,
|
|
123
|
+
dataIndex: Int32Array.from(data.map((_, i) => i)),
|
|
124
|
+
pickRadius: 5,
|
|
125
|
+
channels: {
|
|
126
|
+
x: { kind: "column", column: "t", fn: (d) => d.t },
|
|
127
|
+
y: { kind: "column", column: "v", fn: (d) => d.v },
|
|
128
|
+
color: { kind: "accessor", fn: (d) => d.series ?? "default" },
|
|
129
|
+
},
|
|
130
|
+
data,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const plotFrame: Frame = createFrame({ x: 0, y: 0, width: 200, height: 100 });
|
|
135
|
+
|
|
136
|
+
function bareDeps(manager: InteractionManager, scales: ScaleBundle | null = null) {
|
|
137
|
+
return {
|
|
138
|
+
manager,
|
|
139
|
+
bounds: () => plotFrame,
|
|
140
|
+
scales: () => scales,
|
|
141
|
+
theme: () => themeDefault,
|
|
142
|
+
invalidator: createInvalidator(),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// collectLayerGroups — pure snap logic, no manager / DOM
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
describe("collectLayerGroups (nearest-x)", () => {
|
|
151
|
+
test("single-series layer collapses to one group", () => {
|
|
152
|
+
const data: Row[] = [
|
|
153
|
+
{ t: 10, v: 1 },
|
|
154
|
+
{ t: 20, v: 2 },
|
|
155
|
+
{ t: 30, v: 3 },
|
|
156
|
+
];
|
|
157
|
+
const hit = singleSeriesHit(data, [10, 20, 30], "weight");
|
|
158
|
+
const out: Parameters<typeof __test__.collectLayerGroups>[4] = [];
|
|
159
|
+
collectLayerGroups(hit, 0, 22, "nearest-x", out);
|
|
160
|
+
expect(out).toHaveLength(1);
|
|
161
|
+
expect(out[0]!.label).toBe("weight");
|
|
162
|
+
expect(out[0]!.index).toBe(1); // closest to x=22 is positions[2]=20
|
|
163
|
+
expect(out[0]!.yValue).toBe(2);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("multi-series layer groups by color accessor and snaps within each", () => {
|
|
167
|
+
const data: Row[] = [
|
|
168
|
+
{ t: 10, v: 1, series: "a" },
|
|
169
|
+
{ t: 10, v: 5, series: "b" },
|
|
170
|
+
{ t: 20, v: 2, series: "a" },
|
|
171
|
+
{ t: 20, v: 6, series: "b" },
|
|
172
|
+
{ t: 30, v: 3, series: "a" },
|
|
173
|
+
{ t: 30, v: 7, series: "b" },
|
|
174
|
+
];
|
|
175
|
+
const hit = multiSeriesHit(data);
|
|
176
|
+
const out: Parameters<typeof __test__.collectLayerGroups>[4] = [];
|
|
177
|
+
collectLayerGroups(hit, 0, 21, "nearest-x", out);
|
|
178
|
+
expect(out).toHaveLength(2);
|
|
179
|
+
const byLabel = new Map(out.map((g) => [g.label, g]));
|
|
180
|
+
expect(byLabel.get("a")!.yValue).toBe(2);
|
|
181
|
+
expect(byLabel.get("b")!.yValue).toBe(6);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("hover mode requires cursor inside pickRadius", () => {
|
|
185
|
+
const data: Row[] = [
|
|
186
|
+
{ t: 10, v: 1 },
|
|
187
|
+
{ t: 50, v: 2 },
|
|
188
|
+
];
|
|
189
|
+
const hit = singleSeriesHit(data, [10, 50]);
|
|
190
|
+
// cursor at 30, pickRadius 5 → both hits are too far (distances 20 / 20).
|
|
191
|
+
const out1: Parameters<typeof __test__.collectLayerGroups>[4] = [];
|
|
192
|
+
collectLayerGroups(hit, 0, 30, "hover", out1);
|
|
193
|
+
expect(out1).toHaveLength(0);
|
|
194
|
+
// cursor at 12 → first hit within radius (distance 2).
|
|
195
|
+
const out2: Parameters<typeof __test__.collectLayerGroups>[4] = [];
|
|
196
|
+
collectLayerGroups(hit, 0, 12, "hover", out2);
|
|
197
|
+
expect(out2).toHaveLength(1);
|
|
198
|
+
expect(out2[0]!.yValue).toBe(1);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("layers without x/y channel are skipped", () => {
|
|
202
|
+
const hit: CompiledHitTest<Row> = {
|
|
203
|
+
geomKind: "rule",
|
|
204
|
+
positions: Float32Array.from([10, 50]),
|
|
205
|
+
dataIndex: Int32Array.from([0]),
|
|
206
|
+
pickRadius: 5,
|
|
207
|
+
channels: {},
|
|
208
|
+
data: [{ t: 10, v: 1 }],
|
|
209
|
+
};
|
|
210
|
+
const out: Parameters<typeof __test__.collectLayerGroups>[4] = [];
|
|
211
|
+
collectLayerGroups(hit, 0, 10, "nearest-x", out);
|
|
212
|
+
expect(out).toHaveLength(0);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
// snapshotEqual — value compare so renderer doesn't churn
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
describe("snapshotEqual", () => {
|
|
221
|
+
test("identical row sequences compare equal", () => {
|
|
222
|
+
const a = {
|
|
223
|
+
x: "2026-01-01",
|
|
224
|
+
xValue: 1,
|
|
225
|
+
rows: [{ label: "weight", value: "80", active: true, color: rgba(1, 0, 0, 1) }],
|
|
226
|
+
};
|
|
227
|
+
const b = {
|
|
228
|
+
x: "2026-01-01",
|
|
229
|
+
xValue: 999, // intentionally different — xValue isn't part of equality
|
|
230
|
+
rows: [{ label: "weight", value: "80", active: true, color: rgba(1, 0, 0, 1) }],
|
|
231
|
+
};
|
|
232
|
+
expect(snapshotEqual(a, b)).toBe(true);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("differing row count compares unequal", () => {
|
|
236
|
+
const a = { x: "1", xValue: 1, rows: [{ label: "a", value: "1", active: true }] };
|
|
237
|
+
const b = {
|
|
238
|
+
x: "1",
|
|
239
|
+
xValue: 1,
|
|
240
|
+
rows: [
|
|
241
|
+
{ label: "a", value: "1", active: true },
|
|
242
|
+
{ label: "b", value: "2", active: false },
|
|
243
|
+
],
|
|
244
|
+
};
|
|
245
|
+
expect(snapshotEqual(a, b)).toBe(false);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("null vs snapshot compares unequal", () => {
|
|
249
|
+
const a = { x: "1", xValue: 1, rows: [{ label: "a", value: "1", active: true }] };
|
|
250
|
+
expect(snapshotEqual(null, a)).toBe(false);
|
|
251
|
+
expect(snapshotEqual(a, null)).toBe(false);
|
|
252
|
+
expect(snapshotEqual(null, null)).toBe(true);
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
// End-to-end (headless) — pointer node + syncHits drive snapshots
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
describe("createSeriesReadout (headless)", () => {
|
|
261
|
+
test("pointer-move emits snapshot with one row per series", () => {
|
|
262
|
+
const { manager, nodes } = fakeManager();
|
|
263
|
+
const readout = createSeriesReadout(bareDeps(manager), {});
|
|
264
|
+
const data: Row[] = [
|
|
265
|
+
{ t: 10, v: 1, series: "a" },
|
|
266
|
+
{ t: 10, v: 5, series: "b" },
|
|
267
|
+
{ t: 30, v: 3, series: "a" },
|
|
268
|
+
{ t: 30, v: 7, series: "b" },
|
|
269
|
+
];
|
|
270
|
+
readout.syncHits([multiSeriesHit(data)]);
|
|
271
|
+
|
|
272
|
+
// The pointer node is the only `add` call.
|
|
273
|
+
expect(nodes).toHaveLength(1);
|
|
274
|
+
const spec = nodes[0]!.spec;
|
|
275
|
+
spec.onHoverEnter?.(pointer(12, 50));
|
|
276
|
+
|
|
277
|
+
const snap = readout.peek()!;
|
|
278
|
+
expect(snap.rows).toHaveLength(2);
|
|
279
|
+
const byLabel = new Map(snap.rows.map((r) => [r.label, r]));
|
|
280
|
+
expect(byLabel.get("a")!.value).toBe("1"); // nearest x is 10 (dist 2)
|
|
281
|
+
expect(byLabel.get("b")!.value).toBe("5");
|
|
282
|
+
// The "active" flag goes to the closest row — but in this case both series
|
|
283
|
+
// have a hit at x=10 (dist 2), so the first one wins by iteration order.
|
|
284
|
+
const active = snap.rows.filter((r) => r.active);
|
|
285
|
+
expect(active).toHaveLength(1);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("subscribe fires current snapshot + subsequent updates", () => {
|
|
289
|
+
const { manager, nodes } = fakeManager();
|
|
290
|
+
const readout = createSeriesReadout(bareDeps(manager), {});
|
|
291
|
+
const data: Row[] = [{ t: 10, v: 1 }];
|
|
292
|
+
readout.syncHits([singleSeriesHit(data, [10], "weight")]);
|
|
293
|
+
|
|
294
|
+
const events: (string | null)[] = [];
|
|
295
|
+
readout.subscribe((snap) => events.push(snap === null ? null : snap.x));
|
|
296
|
+
// Initial null fires.
|
|
297
|
+
expect(events).toEqual([null]);
|
|
298
|
+
|
|
299
|
+
const spec = nodes[0]!.spec;
|
|
300
|
+
spec.onHoverEnter?.(pointer(10, 50));
|
|
301
|
+
expect(events.length).toBeGreaterThanOrEqual(2);
|
|
302
|
+
expect(events.at(-1)).not.toBeNull();
|
|
303
|
+
|
|
304
|
+
spec.onHoverLeave?.(pointer(-1, -1));
|
|
305
|
+
expect(events.at(-1)).toBeNull();
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("custom format hooks apply to title + values", () => {
|
|
309
|
+
const { manager, nodes } = fakeManager();
|
|
310
|
+
const readout = createSeriesReadout(bareDeps(manager), {
|
|
311
|
+
format: {
|
|
312
|
+
x: (v) => `t=${String(v)}`,
|
|
313
|
+
y: (v) => `${String(v)} kg`,
|
|
314
|
+
},
|
|
315
|
+
});
|
|
316
|
+
readout.syncHits([singleSeriesHit([{ t: 5, v: 99 }], [5], "weight")]);
|
|
317
|
+
nodes[0]!.spec.onHoverEnter?.(pointer(5, 50));
|
|
318
|
+
const snap = readout.peek()!;
|
|
319
|
+
expect(snap.x).toBe("t=5");
|
|
320
|
+
expect(snap.rows[0]!.value).toBe("99 kg");
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("color resolves through the color scale for multi-series layers", () => {
|
|
324
|
+
const colorMap = new Map<unknown, Color>([
|
|
325
|
+
["a", rgba(1, 0, 0, 1)],
|
|
326
|
+
["b", rgba(0, 1, 0, 1)],
|
|
327
|
+
]);
|
|
328
|
+
// Only `color` is read by the readout, so we leave x/y stubbed via cast.
|
|
329
|
+
const scales = {
|
|
330
|
+
color: {
|
|
331
|
+
kind: "color",
|
|
332
|
+
type: "categorical",
|
|
333
|
+
dataType: "string",
|
|
334
|
+
fn: (v: unknown) => colorMap.get(v) ?? rgba(0, 0, 0, 1),
|
|
335
|
+
domain: ["a", "b"],
|
|
336
|
+
},
|
|
337
|
+
} as unknown as ScaleBundle;
|
|
338
|
+
const { manager, nodes } = fakeManager();
|
|
339
|
+
const readout = createSeriesReadout(bareDeps(manager, scales), {});
|
|
340
|
+
const data: Row[] = [
|
|
341
|
+
{ t: 10, v: 1, series: "a" },
|
|
342
|
+
{ t: 10, v: 5, series: "b" },
|
|
343
|
+
];
|
|
344
|
+
readout.syncHits([multiSeriesHit(data)]);
|
|
345
|
+
nodes[0]!.spec.onHoverEnter?.(pointer(10, 50));
|
|
346
|
+
const snap = readout.peek()!;
|
|
347
|
+
const byLabel = new Map(snap.rows.map((r) => [r.label, r]));
|
|
348
|
+
expect(byLabel.get("a")!.color).toEqual(rgba(1, 0, 0, 1));
|
|
349
|
+
expect(byLabel.get("b")!.color).toEqual(rgba(0, 1, 0, 1));
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("dispose tears down the pointer node and silences subscribers", () => {
|
|
353
|
+
const { manager, nodes } = fakeManager();
|
|
354
|
+
const readout = createSeriesReadout(bareDeps(manager), {});
|
|
355
|
+
let fired = 0;
|
|
356
|
+
readout.subscribe(() => fired++);
|
|
357
|
+
fired = 0; // reset after the initial-state emit
|
|
358
|
+
readout.dispose();
|
|
359
|
+
expect(nodes[0]!.destroyed).toBe(true);
|
|
360
|
+
// Post-dispose, syncHits + pointer events shouldn't fan out.
|
|
361
|
+
readout.syncHits([singleSeriesHit([{ t: 1, v: 1 }], [1])]);
|
|
362
|
+
nodes[0]!.spec.onHoverEnter?.(pointer(1, 50));
|
|
363
|
+
expect(fired).toBe(0);
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// ---------------------------------------------------------------------------
|
|
368
|
+
// resolveGroupKey / overrideGroupPick — helpers used by hit-layer sync
|
|
369
|
+
// ---------------------------------------------------------------------------
|
|
370
|
+
|
|
371
|
+
describe("resolveGroupKey", () => {
|
|
372
|
+
test("returns seriesKey when the compiled hit publishes one", () => {
|
|
373
|
+
const data: Row[] = [{ t: 10, v: 1 }];
|
|
374
|
+
const hit: CompiledHitTest<Row> = {
|
|
375
|
+
geomKind: "bar",
|
|
376
|
+
positions: Float32Array.from([10, 50]),
|
|
377
|
+
dataIndex: Int32Array.from([0]),
|
|
378
|
+
seriesKey: ["alpha"],
|
|
379
|
+
pickRadius: 5,
|
|
380
|
+
channels: {
|
|
381
|
+
x: { kind: "column", column: "t", fn: (d) => d.t },
|
|
382
|
+
y: { kind: "column", column: "v", fn: (d) => d.v },
|
|
383
|
+
},
|
|
384
|
+
data,
|
|
385
|
+
};
|
|
386
|
+
expect(resolveGroupKey(hit, 0)).toEqual({ key: "alpha", label: "alpha", raw: "alpha" });
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test("falls back to color accessor when no seriesKey is published", () => {
|
|
390
|
+
const data: Row[] = [{ t: 10, v: 1, series: "b" }];
|
|
391
|
+
const hit = multiSeriesHit(data);
|
|
392
|
+
expect(resolveGroupKey(hit, 0)).toEqual({ key: "b", label: "b", raw: "b" });
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test("uses the layer label as the default key when there is no series channel", () => {
|
|
396
|
+
const hit = singleSeriesHit([{ t: 10, v: 1 }], [10], "weight");
|
|
397
|
+
expect(resolveGroupKey(hit, 0)).toEqual({
|
|
398
|
+
key: "__default__",
|
|
399
|
+
label: "weight",
|
|
400
|
+
raw: undefined,
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test("returns null when x or y channels are missing", () => {
|
|
405
|
+
const hit: CompiledHitTest<Row> = {
|
|
406
|
+
geomKind: "rule",
|
|
407
|
+
positions: Float32Array.from([10, 50]),
|
|
408
|
+
dataIndex: Int32Array.from([0]),
|
|
409
|
+
pickRadius: 5,
|
|
410
|
+
channels: {},
|
|
411
|
+
data: [{ t: 10, v: 1 }],
|
|
412
|
+
};
|
|
413
|
+
expect(resolveGroupKey(hit, 0)).toBeNull();
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
describe("overrideGroupPick", () => {
|
|
418
|
+
test("rewrites the targeted group's index/xValue/yValue and zeroes its distance", () => {
|
|
419
|
+
const data: Row[] = [
|
|
420
|
+
{ t: 10, v: 1 },
|
|
421
|
+
{ t: 20, v: 2 },
|
|
422
|
+
{ t: 30, v: 3 },
|
|
423
|
+
];
|
|
424
|
+
const hit = singleSeriesHit(data, [10, 20, 30], "weight");
|
|
425
|
+
const out: Parameters<typeof __test__.collectLayerGroups>[4] = [];
|
|
426
|
+
// Pretend the user is at x=18 — nearest-x picks index 1 (t=20).
|
|
427
|
+
collectLayerGroups(hit, 0, 18, "nearest-x", out);
|
|
428
|
+
expect(out[0]!.index).toBe(1);
|
|
429
|
+
// Dispatch override to the third hit (t=30, v=3).
|
|
430
|
+
overrideGroupPick(out, "0::__default__", hit, 2);
|
|
431
|
+
expect(out[0]!.index).toBe(2);
|
|
432
|
+
expect(out[0]!.xValue).toBe(30);
|
|
433
|
+
expect(out[0]!.yValue).toBe(3);
|
|
434
|
+
expect(out[0]!.dist).toBe(0);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test("no-op when the group id is not in the collected set", () => {
|
|
438
|
+
const data: Row[] = [{ t: 10, v: 1 }];
|
|
439
|
+
const hit = singleSeriesHit(data, [10], "weight");
|
|
440
|
+
const out: Parameters<typeof __test__.collectLayerGroups>[4] = [];
|
|
441
|
+
collectLayerGroups(hit, 0, 10, "nearest-x", out);
|
|
442
|
+
const before = { ...out[0]! };
|
|
443
|
+
overrideGroupPick(out, "9::nope", hit, 0);
|
|
444
|
+
expect(out[0]!.index).toBe(before.index);
|
|
445
|
+
expect(out[0]!.xValue).toBe(before.xValue);
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
// ---------------------------------------------------------------------------
|
|
450
|
+
// Hit-layer sync — active dispatch drives active row + title xValue
|
|
451
|
+
// ---------------------------------------------------------------------------
|
|
452
|
+
|
|
453
|
+
function fakeHitLayer(): {
|
|
454
|
+
hitLayer: GrammarHitLayer;
|
|
455
|
+
dispatch: {
|
|
456
|
+
enter(layerIdx: number, ctx: HitEventContext): void;
|
|
457
|
+
leave(ctx: HitEventContext): void;
|
|
458
|
+
};
|
|
459
|
+
} {
|
|
460
|
+
const subs = new Set<HitLayerSubscriber>();
|
|
461
|
+
const stateSubs = new Set<(s: import("./hit-layer.ts").HitLayerState) => void>();
|
|
462
|
+
let active: import("./hit-layer.ts").HitLayerActive | null = null;
|
|
463
|
+
const hitLayer: GrammarHitLayer = {
|
|
464
|
+
sync() {},
|
|
465
|
+
subscribe(sub) {
|
|
466
|
+
subs.add(sub);
|
|
467
|
+
return () => subs.delete(sub);
|
|
468
|
+
},
|
|
469
|
+
pickAt() {
|
|
470
|
+
return null;
|
|
471
|
+
},
|
|
472
|
+
state() {
|
|
473
|
+
return { active };
|
|
474
|
+
},
|
|
475
|
+
subscribeState(fn) {
|
|
476
|
+
stateSubs.add(fn);
|
|
477
|
+
return () => stateSubs.delete(fn);
|
|
478
|
+
},
|
|
479
|
+
dispose() {
|
|
480
|
+
subs.clear();
|
|
481
|
+
stateSubs.clear();
|
|
482
|
+
},
|
|
483
|
+
};
|
|
484
|
+
const fireState = () => {
|
|
485
|
+
const s = { active };
|
|
486
|
+
for (const fn of stateSubs) fn(s);
|
|
487
|
+
};
|
|
488
|
+
return {
|
|
489
|
+
hitLayer,
|
|
490
|
+
dispatch: {
|
|
491
|
+
enter(layerIdx, ctx) {
|
|
492
|
+
active = {
|
|
493
|
+
layerIdx,
|
|
494
|
+
hitIndex: ctx.hitIndex,
|
|
495
|
+
compiled: ctx.compiled,
|
|
496
|
+
hit: ctx.hit,
|
|
497
|
+
};
|
|
498
|
+
for (const s of subs) s.onHoverEnter?.(ctx);
|
|
499
|
+
fireState();
|
|
500
|
+
},
|
|
501
|
+
leave(ctx) {
|
|
502
|
+
active = null;
|
|
503
|
+
for (const s of subs) s.onHoverLeave?.(ctx);
|
|
504
|
+
fireState();
|
|
505
|
+
},
|
|
506
|
+
},
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function hitContext<T>(
|
|
511
|
+
compiled: CompiledHitTest<T>,
|
|
512
|
+
hitIndex: number,
|
|
513
|
+
pointerX: number,
|
|
514
|
+
): HitEventContext {
|
|
515
|
+
const px = compiled.positions[hitIndex * 2]!;
|
|
516
|
+
const py = compiled.positions[hitIndex * 2 + 1]!;
|
|
517
|
+
return {
|
|
518
|
+
hit: {
|
|
519
|
+
geomKind: compiled.geomKind,
|
|
520
|
+
dataIndex: compiled.dataIndex[hitIndex] ?? hitIndex,
|
|
521
|
+
seriesKey: compiled.seriesKey?.[hitIndex],
|
|
522
|
+
data: compiled.data,
|
|
523
|
+
x: px,
|
|
524
|
+
y: py,
|
|
525
|
+
},
|
|
526
|
+
compiled: compiled as CompiledHitTest<unknown>,
|
|
527
|
+
hitIndex,
|
|
528
|
+
pointer: pointer(pointerX, py),
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
describe("createSeriesReadout (hit-layer sync)", () => {
|
|
533
|
+
test("dispatched hit overrides the matching group and becomes the active row", () => {
|
|
534
|
+
const { manager } = fakeManager();
|
|
535
|
+
const { hitLayer, dispatch } = fakeHitLayer();
|
|
536
|
+
const readout = createSeriesReadout({ ...bareDeps(manager), hitLayer }, {});
|
|
537
|
+
|
|
538
|
+
// Two layers: a rolling-style line at x=10/20/30 and a points layer with
|
|
539
|
+
// two series ("a" / "b") at x=10/20/30. The points layer's z-order would
|
|
540
|
+
// win in the real hit-layer when the cursor is on a point.
|
|
541
|
+
const linePoints: Row[] = [
|
|
542
|
+
{ t: 10, v: 4 },
|
|
543
|
+
{ t: 20, v: 5 },
|
|
544
|
+
{ t: 30, v: 6 },
|
|
545
|
+
];
|
|
546
|
+
const lineHit = singleSeriesHit(linePoints, [10, 20, 30], "rolling mean");
|
|
547
|
+
const pointData: Row[] = [
|
|
548
|
+
{ t: 10, v: 1, series: "a" },
|
|
549
|
+
{ t: 10, v: 5, series: "b" },
|
|
550
|
+
{ t: 20, v: 2, series: "a" },
|
|
551
|
+
{ t: 20, v: 6, series: "b" },
|
|
552
|
+
{ t: 30, v: 3, series: "a" },
|
|
553
|
+
{ t: 30, v: 7, series: "b" },
|
|
554
|
+
];
|
|
555
|
+
const pointHit = multiSeriesHit(pointData);
|
|
556
|
+
readout.syncHits([lineHit, pointHit]);
|
|
557
|
+
|
|
558
|
+
// Dispatch as if the hit-layer just landed on the "b"-series point at
|
|
559
|
+
// x=20 (pointData index 3, v=6). Cursor x is 19 — slightly off, but
|
|
560
|
+
// the override snaps the readout to the dispatched datum.
|
|
561
|
+
dispatch.enter(1, hitContext(pointHit, 3, 19));
|
|
562
|
+
|
|
563
|
+
const snap = readout.peek()!;
|
|
564
|
+
expect(snap.xValue).toBe(20);
|
|
565
|
+
const byLabel = new Map(snap.rows.map((r) => [r.label, r]));
|
|
566
|
+
// Active row = the dispatched series.
|
|
567
|
+
expect(byLabel.get("b")!.active).toBe(true);
|
|
568
|
+
expect(byLabel.get("b")!.value).toBe("6");
|
|
569
|
+
// Non-active rows still resolve per-series nearest-x at the dispatched x.
|
|
570
|
+
expect(byLabel.get("a")!.active).toBe(false);
|
|
571
|
+
expect(byLabel.get("a")!.value).toBe("2");
|
|
572
|
+
expect(byLabel.get("rolling mean")!.active).toBe(false);
|
|
573
|
+
expect(byLabel.get("rolling mean")!.value).toBe("5");
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
test("hit-layer leave restores nearest-x ranking for the active row", () => {
|
|
577
|
+
const { manager } = fakeManager();
|
|
578
|
+
const { hitLayer, dispatch } = fakeHitLayer();
|
|
579
|
+
const readout = createSeriesReadout({ ...bareDeps(manager), hitLayer }, {});
|
|
580
|
+
|
|
581
|
+
const lineHit = singleSeriesHit(
|
|
582
|
+
[
|
|
583
|
+
{ t: 10, v: 4 },
|
|
584
|
+
{ t: 20, v: 5 },
|
|
585
|
+
],
|
|
586
|
+
[10, 20],
|
|
587
|
+
"rolling mean",
|
|
588
|
+
);
|
|
589
|
+
const pointData: Row[] = [
|
|
590
|
+
{ t: 10, v: 1, series: "a" },
|
|
591
|
+
{ t: 20, v: 2, series: "a" },
|
|
592
|
+
];
|
|
593
|
+
const pointHit = multiSeriesHit(pointData);
|
|
594
|
+
readout.syncHits([lineHit, pointHit]);
|
|
595
|
+
|
|
596
|
+
// While hovered, "a" is active.
|
|
597
|
+
dispatch.enter(1, hitContext(pointHit, 1, 19));
|
|
598
|
+
expect(readout.peek()!.rows.find((r) => r.active)!.label).toBe("a");
|
|
599
|
+
|
|
600
|
+
// After leave, cursorX is the same; with no active hit, nearest-x ranking
|
|
601
|
+
// takes over. Both rows have the same |dist| (cursor is exactly on x=20
|
|
602
|
+
// for line, and ~1px from x=20 for points), so the line group — added
|
|
603
|
+
// first — wins the tie via iteration order.
|
|
604
|
+
dispatch.leave(hitContext(pointHit, 1, 19));
|
|
605
|
+
const snap = readout.peek()!;
|
|
606
|
+
expect(snap.rows.find((r) => r.active)!.label).toBe("rolling mean");
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
test("active row survives a chart re-sync (no stale-compiled fallback)", () => {
|
|
610
|
+
// This guards the regression where the readout cached the dispatched
|
|
611
|
+
// `compiled` ref. After a sync that swaps compiled refs (typical of any
|
|
612
|
+
// pipeline re-run during hover), `indexOf` would return -1 and the
|
|
613
|
+
// active group silently fell back to nearest-x — which the dense rolling
|
|
614
|
+
// curve always wins, so the user saw "rolling mean" highlighted even
|
|
615
|
+
// when the tooltip was on a real point.
|
|
616
|
+
const { manager } = fakeManager();
|
|
617
|
+
const { hitLayer, dispatch } = fakeHitLayer();
|
|
618
|
+
const readout = createSeriesReadout({ ...bareDeps(manager), hitLayer }, {});
|
|
619
|
+
|
|
620
|
+
// Same data array, but two different compiled hit objects — mimics the
|
|
621
|
+
// hit-layer's fast-path `setPoints` updating `slot.hit` in place. The
|
|
622
|
+
// hit-layer keeps `primary.slot` stable across this swap, so `state()`
|
|
623
|
+
// returns the NEW compiled even though the readout's `activeHits` is
|
|
624
|
+
// still on the OLD one for a beat.
|
|
625
|
+
const pointData: Row[] = [
|
|
626
|
+
{ t: 10, v: 1, series: "a" },
|
|
627
|
+
{ t: 20, v: 2, series: "a" },
|
|
628
|
+
{ t: 10, v: 5, series: "b" },
|
|
629
|
+
{ t: 20, v: 6, series: "b" },
|
|
630
|
+
];
|
|
631
|
+
const oldPointHit = multiSeriesHit(pointData);
|
|
632
|
+
const oldLineHit = singleSeriesHit(
|
|
633
|
+
[
|
|
634
|
+
{ t: 10, v: 4 },
|
|
635
|
+
{ t: 20, v: 5 },
|
|
636
|
+
],
|
|
637
|
+
[10, 20],
|
|
638
|
+
"rolling mean",
|
|
639
|
+
);
|
|
640
|
+
readout.syncHits([oldLineHit, oldPointHit]);
|
|
641
|
+
// Dispatch hits the "b"-series point at x=20.
|
|
642
|
+
dispatch.enter(1, hitContext(oldPointHit, 3, 20));
|
|
643
|
+
expect(readout.peek()!.rows.find((r) => r.active)!.label).toBe("b");
|
|
644
|
+
|
|
645
|
+
// Simulate a pipeline re-run: new compiled, same data identity, same
|
|
646
|
+
// layout. The fake hit-layer's `state().active.compiled` now reports
|
|
647
|
+
// the new compiled because the test updates it through dispatch. To
|
|
648
|
+
// mimic real behavior (active survives sync), we rewrite `state()` by
|
|
649
|
+
// re-dispatching enter against the new compiled BEFORE the readout sees
|
|
650
|
+
// the new hits — the order the real pipeline uses (hitLayer.sync first,
|
|
651
|
+
// then attached consumers).
|
|
652
|
+
const newPointHit = multiSeriesHit(pointData);
|
|
653
|
+
const newLineHit = singleSeriesHit(
|
|
654
|
+
[
|
|
655
|
+
{ t: 10, v: 4 },
|
|
656
|
+
{ t: 20, v: 5 },
|
|
657
|
+
],
|
|
658
|
+
[10, 20],
|
|
659
|
+
"rolling mean",
|
|
660
|
+
);
|
|
661
|
+
dispatch.enter(1, hitContext(newPointHit, 3, 20));
|
|
662
|
+
readout.syncHits([newLineHit, newPointHit]);
|
|
663
|
+
|
|
664
|
+
// Still locked to "b" after the sync — no stale-compiled fallback to
|
|
665
|
+
// rolling mean.
|
|
666
|
+
expect(readout.peek()!.rows.find((r) => r.active)!.label).toBe("b");
|
|
667
|
+
});
|
|
668
|
+
});
|