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,406 @@
1
+ import { createInvalidator, rgba, type GlyphAtlas, type TooltipBounds } from "insomni";
2
+ import { describe, expect, test, vi } from "vite-plus/test";
3
+
4
+ import type { CompiledHitTest, HoveredHit } from "../geoms/types.ts";
5
+ import { themeDefault, type Theme } from "../theme.ts";
6
+ import { createGrammarTooltip } from "./tooltip.ts";
7
+ import type { GrammarHitLayer, HitEventContext, HitLayerSubscriber } from "./hit-layer.ts";
8
+ import { defaultFormat, normalizeTooltipContent, resolveTooltipContent } from "./tooltip.ts";
9
+
10
+ interface Row {
11
+ name: string;
12
+ weight: number;
13
+ }
14
+
15
+ function makeHit(data: readonly Row[], indices: number[], label?: string): CompiledHitTest<Row> {
16
+ return {
17
+ geomKind: "point",
18
+ label,
19
+ positions: Float32Array.from(indices.flatMap((_, i) => [i * 10, 50])),
20
+ dataIndex: Int32Array.from(indices),
21
+ pickRadius: 5,
22
+ channels: {},
23
+ data,
24
+ };
25
+ }
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // normalizeTooltipContent — shorthand → canvas TooltipContent
29
+ // ---------------------------------------------------------------------------
30
+
31
+ describe("normalizeTooltipContent", () => {
32
+ test("null / undefined → null (caller falls back)", () => {
33
+ expect(normalizeTooltipContent(null, themeDefault)).toBeNull();
34
+ expect(normalizeTooltipContent(undefined, themeDefault)).toBeNull();
35
+ });
36
+
37
+ test("string → single value-only row", () => {
38
+ const c = normalizeTooltipContent("hello", themeDefault);
39
+ expect(c).toEqual({ rows: [{ value: "hello" }] });
40
+ });
41
+
42
+ test("shorthand passes label + string value through verbatim", () => {
43
+ const c = normalizeTooltipContent(
44
+ { title: "Patient", rows: [{ label: "name", value: "Alice" }] },
45
+ themeDefault,
46
+ );
47
+ expect(c).toEqual({ title: "Patient", rows: [{ label: "name", value: "Alice" }] });
48
+ });
49
+
50
+ test("numeric / Date / null values go through defaultFormat", () => {
51
+ const date = new Date("2026-05-20T00:00:00Z");
52
+ const c = normalizeTooltipContent(
53
+ {
54
+ rows: [
55
+ { label: "n", value: 1234.56 },
56
+ { label: "d", value: date },
57
+ { label: "missing", value: null },
58
+ { label: "u", value: undefined },
59
+ ],
60
+ },
61
+ themeDefault,
62
+ );
63
+ expect(c!.rows[0]!.value).toBe(defaultFormat(1234.56));
64
+ expect(c!.rows[1]!.value).toBe("2026-05-20");
65
+ expect(c!.rows[2]!.value).toBe("—");
66
+ expect(c!.rows[3]!.value).toBe("—");
67
+ });
68
+
69
+ test("accent 'positive' / 'negative' resolve to theme tokens", () => {
70
+ const a = themeDefault.interactions.tooltipAccents;
71
+ const pos = normalizeTooltipContent(
72
+ { rows: [{ value: "+1.2", accent: "positive" }] },
73
+ themeDefault,
74
+ );
75
+ const neg = normalizeTooltipContent(
76
+ { rows: [{ value: "-0.4", accent: "negative" }] },
77
+ themeDefault,
78
+ );
79
+ expect(pos!.rows[0]!.color).toBe(a.positive);
80
+ expect(neg!.rows[0]!.color).toBe(a.negative);
81
+ });
82
+
83
+ test("accent 'neutral' is a no-op (no color override)", () => {
84
+ const c = normalizeTooltipContent({ rows: [{ value: "x", accent: "neutral" }] }, themeDefault);
85
+ expect(c!.rows[0]!.color).toBeUndefined();
86
+ });
87
+
88
+ test("accent as raw Color is used directly (bypasses theme)", () => {
89
+ const tint = rgba(0.1, 0.2, 0.3, 1);
90
+ const c = normalizeTooltipContent({ rows: [{ value: "x", accent: tint }] }, themeDefault);
91
+ expect(c!.rows[0]!.color).toBe(tint);
92
+ });
93
+
94
+ test("swatch passes through to the row", () => {
95
+ const sw = rgba(1, 0, 0, 1);
96
+ const c = normalizeTooltipContent(
97
+ { rows: [{ label: "k", value: "v", swatch: sw }] },
98
+ themeDefault,
99
+ );
100
+ expect(c!.rows[0]!.swatch).toBe(sw);
101
+ });
102
+
103
+ test("multi-line value emitted verbatim — caller controls newlines", () => {
104
+ const c = normalizeTooltipContent(
105
+ { rows: [{ label: "notes", value: "first\nsecond" }] },
106
+ themeDefault,
107
+ );
108
+ expect(c!.rows[0]!.value).toBe("first\nsecond");
109
+ });
110
+
111
+ test("omits title when shorthand has none", () => {
112
+ const c = normalizeTooltipContent({ rows: [{ value: "x" }] }, themeDefault);
113
+ expect(c).not.toBeNull();
114
+ expect("title" in c!).toBe(false);
115
+ });
116
+ });
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // resolveTooltipContent — resolver + default fallback
120
+ // ---------------------------------------------------------------------------
121
+
122
+ describe("resolveTooltipContent", () => {
123
+ test("no resolver → default channel-driven content (title from hit.label)", () => {
124
+ const data: Row[] = [{ name: "Alice", weight: 70 }];
125
+ const hit = makeHit(data, [0], "weight");
126
+ const c = resolveTooltipContent(hit, 0, themeDefault);
127
+ expect(c.title).toBe("weight");
128
+ });
129
+
130
+ test("resolver returning null falls back to default content", () => {
131
+ const data: Row[] = [{ name: "Alice", weight: 70 }];
132
+ const hit = makeHit(data, [0], "weight");
133
+ const c = resolveTooltipContent(hit, 0, themeDefault, () => null);
134
+ expect(c.title).toBe("weight");
135
+ });
136
+
137
+ test("resolver returning shorthand is normalized + accent-tinted", () => {
138
+ const data: Row[] = [{ name: "Alice", weight: 70 }];
139
+ const hit = makeHit(data, [0]);
140
+ const c = resolveTooltipContent(hit, 0, themeDefault, (info) => {
141
+ const d = info.datum as Row;
142
+ return {
143
+ title: d.name,
144
+ rows: [{ label: "weight", value: d.weight, accent: "positive" }],
145
+ };
146
+ });
147
+ expect(c.title).toBe("Alice");
148
+ expect(c.rows[0]!.label).toBe("weight");
149
+ expect(c.rows[0]!.value).toBe(defaultFormat(70));
150
+ expect(c.rows[0]!.color).toBe(themeDefault.interactions.tooltipAccents.positive);
151
+ });
152
+
153
+ test("resolver info exposes datum, dataIndex, mark, channels, seriesKey", () => {
154
+ const data: Row[] = [
155
+ { name: "A", weight: 1 },
156
+ { name: "B", weight: 2 },
157
+ ];
158
+ const hit: CompiledHitTest<Row> = {
159
+ ...makeHit(data, [1], "scatter"),
160
+ seriesKey: ["B"],
161
+ };
162
+ const resolver = vi.fn(() => "ok");
163
+ resolveTooltipContent(hit, 0, themeDefault, resolver);
164
+ const info = (resolver.mock.calls[0] as unknown[])[0] as {
165
+ datum: Row;
166
+ dataIndex: number;
167
+ mark: string;
168
+ seriesKey?: string;
169
+ channels: unknown;
170
+ };
171
+ expect(info.datum).toEqual({ name: "B", weight: 2 });
172
+ expect(info.dataIndex).toBe(1);
173
+ expect(info.mark).toBe("scatter");
174
+ expect(info.seriesKey).toBe("B");
175
+ expect(info.channels).toBe(hit.channels);
176
+ });
177
+ });
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // createGrammarTooltip — hover-intent settle (debounce)
181
+ // ---------------------------------------------------------------------------
182
+
183
+ /**
184
+ * Minimal harness: a fake hit-layer that captures the tooltip's subscriber so
185
+ * the test can fire enter/leave events synchronously, plus the bits of
186
+ * GrammarTooltipDeps the show/step path actually touches (no GPU / atlas).
187
+ */
188
+ function harness(opts?: { settleDelay?: number; theme?: Theme }) {
189
+ const data: Row[] = [
190
+ { name: "A", weight: 1 },
191
+ { name: "B", weight: 2 },
192
+ ];
193
+ let sub: HitLayerSubscriber | null = null;
194
+ const hitLayer = {
195
+ subscribe(s: HitLayerSubscriber) {
196
+ sub = s;
197
+ return () => {
198
+ sub = null;
199
+ };
200
+ },
201
+ } as unknown as GrammarHitLayer;
202
+
203
+ const ctxFor = (idx: number): HitEventContext => ({
204
+ hit: { geomKind: "point", dataIndex: idx, data, x: idx * 10, y: 50 } as HoveredHit,
205
+ compiled: makeHit(data, [idx], "weight") as CompiledHitTest<unknown>,
206
+ hitIndex: 0,
207
+ pointer: { x: idx * 10 + 2, y: 52 } as HitEventContext["pointer"],
208
+ });
209
+
210
+ const onHover = vi.fn();
211
+ const tip = createGrammarTooltip(
212
+ {
213
+ hitLayer,
214
+ hudLayer: () => ({}) as never,
215
+ atlas: () => undefined as GlyphAtlas | undefined,
216
+ theme: () => opts?.theme ?? themeDefault,
217
+ bounds: (): TooltipBounds => ({ x: 0, y: 0, width: 500, height: 500 }),
218
+ invalidator: createInvalidator(),
219
+ },
220
+ { settleDelay: opts?.settleDelay, onHover },
221
+ );
222
+
223
+ return {
224
+ tip,
225
+ onHover,
226
+ enter: (idx: number) => sub!.onHoverEnter!(ctxFor(idx)),
227
+ leave: () => sub!.onHoverLeave!(ctxFor(0)),
228
+ };
229
+ }
230
+
231
+ describe("createGrammarTooltip settle", () => {
232
+ test("cold-start enter fires onHover immediately (crosshair) but defers tooltip show until settle", () => {
233
+ const h = harness({ settleDelay: 100 });
234
+ h.enter(0);
235
+ // onHover fires immediately in schedule() for crosshair/hoverSignal updates.
236
+ expect(h.onHover).toHaveBeenCalledTimes(1);
237
+ expect(h.onHover.mock.calls[0]![0]).toMatchObject({ dataIndex: 0 });
238
+ // Within the window: tooltip show is deferred; no extra onHover.
239
+ h.tip.step(0.05);
240
+ expect(h.onHover).toHaveBeenCalledTimes(1);
241
+ // Past the window: settle commits the tooltip show without re-firing onHover.
242
+ h.tip.step(0.06);
243
+ expect(h.onHover).toHaveBeenCalledTimes(1);
244
+ });
245
+
246
+ test("rapid cold-start enter→enter switch fires onHover immediately for each target", () => {
247
+ const h = harness({ settleDelay: 100 });
248
+ h.enter(0);
249
+ expect(h.onHover).toHaveBeenCalledTimes(1);
250
+ expect(h.onHover.mock.calls[0]![0]).toMatchObject({ dataIndex: 0 });
251
+ h.tip.step(0.05); // mid-window
252
+ h.enter(1); // switch before commit → fires onHover immediately (dataIndex 1)
253
+ expect(h.onHover).toHaveBeenCalledTimes(2);
254
+ expect(h.onHover.mock.calls[1]![0]).toMatchObject({ dataIndex: 1 });
255
+ h.tip.step(0.05); // 0.05s into the NEW window — still pending settle
256
+ expect(h.onHover).toHaveBeenCalledTimes(2); // no extra fires
257
+ h.tip.step(0.06); // past settle
258
+ expect(h.onHover).toHaveBeenCalledTimes(2); // no extra fires (fireHover=false)
259
+ });
260
+
261
+ test("enter then leave inside the window — enter fires onHover immediately, leave commits null after settle", () => {
262
+ const h = harness({ settleDelay: 100 });
263
+ h.enter(0);
264
+ // Immediate onHover for the enter (crosshair).
265
+ expect(h.onHover).toHaveBeenCalledTimes(1);
266
+ expect(h.onHover.mock.calls[0]![0]).toMatchObject({ dataIndex: 0 });
267
+ h.tip.step(0.05);
268
+ h.leave();
269
+ // Leave schedules but doesn't fire onHover yet (settle pending for hide).
270
+ expect(h.onHover).toHaveBeenCalledTimes(1);
271
+ h.tip.step(0.11);
272
+ // Leave committed — onHover(null) fires (crosshair clears, hoverSignal nulls).
273
+ expect(h.onHover).toHaveBeenCalledTimes(2);
274
+ expect(h.onHover.mock.calls[1]![0]).toBeNull();
275
+ });
276
+
277
+ test("settleDelay 0 commits immediately (opt-out)", () => {
278
+ const h = harness({ settleDelay: 0 });
279
+ h.enter(0);
280
+ expect(h.onHover).toHaveBeenCalledTimes(1);
281
+ expect(h.onHover.mock.calls[0]![0]).toMatchObject({ dataIndex: 0 });
282
+ });
283
+
284
+ test("enter skips settle when tooltip is already visible (warm switch)", () => {
285
+ const h = harness({ settleDelay: 100 });
286
+ // Cold start: first enter must settle across two steps.
287
+ h.enter(0);
288
+ h.tip.step(0.05);
289
+ h.tip.step(0.06); // settle 0.1s expired → commit enter(0), opacity advances > 0
290
+ expect(h.onHover).toHaveBeenCalledTimes(1);
291
+ // Warm switch: tooltip is visible; new enter should commit immediately.
292
+ h.enter(1);
293
+ expect(h.onHover).toHaveBeenCalledTimes(2);
294
+ expect(h.onHover.mock.calls[1]![0]).toMatchObject({ dataIndex: 1 });
295
+ // Step past where the old leave's settle would have expired — warm
296
+ // switch cleared pending, so nothing extra fires.
297
+ h.tip.step(1.0);
298
+ expect(h.onHover).toHaveBeenCalledTimes(2);
299
+ });
300
+
301
+ test("rapid warm switching coalesces via immediate commits (no flicker)", () => {
302
+ const h = harness({ settleDelay: 100 });
303
+ // Prime: make the tooltip visible.
304
+ h.enter(0);
305
+ h.tip.step(0.05);
306
+ h.tip.step(0.06); // commit enter(0)
307
+ expect(h.onHover).toHaveBeenCalledTimes(1);
308
+ // Sweep across three points — each commits instantly.
309
+ h.enter(1);
310
+ h.enter(2);
311
+ h.enter(3);
312
+ // Every enter committed (no settle on warm switches).
313
+ expect(h.onHover).toHaveBeenCalledTimes(4);
314
+ expect(h.onHover.mock.calls[1]![0]).toMatchObject({ dataIndex: 1 });
315
+ expect(h.onHover.mock.calls[2]![0]).toMatchObject({ dataIndex: 2 });
316
+ expect(h.onHover.mock.calls[3]![0]).toMatchObject({ dataIndex: 3 });
317
+ });
318
+
319
+ test("leave still settles even when tooltip is visible", () => {
320
+ const h = harness({ settleDelay: 100 });
321
+ // Make tooltip visible.
322
+ h.enter(0);
323
+ h.tip.step(0.05);
324
+ h.tip.step(0.06); // commit enter(0)
325
+ expect(h.onHover).toHaveBeenCalledTimes(1);
326
+ // Leave should settle — preventing flicker on brief gap crossings.
327
+ h.leave();
328
+ h.tip.step(0.05);
329
+ expect(h.onHover).toHaveBeenCalledTimes(1); // not committed yet
330
+ h.tip.step(0.06);
331
+ expect(h.onHover).toHaveBeenCalledTimes(2); // committed null
332
+ expect(h.onHover.mock.calls[1]![0]).toBeNull();
333
+ });
334
+
335
+ test("enter during leave settle cancels null and commits immediately", () => {
336
+ const h = harness({ settleDelay: 100 });
337
+ // Make tooltip visible.
338
+ h.enter(0);
339
+ h.tip.step(0.05);
340
+ h.tip.step(0.06); // commit enter(0)
341
+ expect(h.onHover).toHaveBeenCalledTimes(1);
342
+ // Start a leave settle.
343
+ h.leave();
344
+ h.tip.step(0.02); // mid-settle
345
+ // New enter arrives — should cancel leave and commit immediately (warm switch).
346
+ h.enter(1);
347
+ expect(h.onHover).toHaveBeenCalledTimes(2);
348
+ expect(h.onHover.mock.calls[1]![0]).toMatchObject({ dataIndex: 1 });
349
+ // Verify null was never committed — even after the full settle window
350
+ // would have expired, since pending was cleared on warm switch.
351
+ h.tip.step(1.0);
352
+ expect(h.onHover).toHaveBeenCalledTimes(2);
353
+ expect(h.onHover.mock.calls.flat()).not.toContain(null);
354
+ });
355
+
356
+ test("enter after leave commits skips cold-start settle (re-enter grace)", () => {
357
+ const h = harness({ settleDelay: 100 });
358
+ // Make tooltip visible, then let leave commit fully.
359
+ h.enter(0);
360
+ h.tip.step(0.05);
361
+ h.tip.step(0.06); // commit enter(0)
362
+ expect(h.onHover).toHaveBeenCalledTimes(1);
363
+ h.leave();
364
+ h.tip.step(0.05);
365
+ h.tip.step(0.06); // commit leave — tooltip now hidden, justHid = true
366
+ expect(h.onHover).toHaveBeenCalledTimes(2); // null committed
367
+ // Re-enter after hide: should skip cold-start settle (justHid grace).
368
+ h.enter(2);
369
+ expect(h.onHover).toHaveBeenCalledTimes(3); // immediate commit
370
+ expect(h.onHover.mock.calls[2]![0]).toMatchObject({ dataIndex: 2 });
371
+ // Step well past JUST_HID_GRACE_S to verify no stale fires.
372
+ h.tip.step(1.0);
373
+ expect(h.onHover).toHaveBeenCalledTimes(3);
374
+ });
375
+
376
+ test("justHid grace decays after timeout — cold-start settle re-activates", () => {
377
+ const h = harness({ settleDelay: 100 });
378
+ // Prime: visible → leave commits → justHid = true
379
+ h.enter(0);
380
+ h.tip.step(0.05);
381
+ h.tip.step(0.06);
382
+ h.leave();
383
+ h.tip.step(0.05);
384
+ h.tip.step(0.06); // commit leave, justHid = true
385
+ expect(h.onHover).toHaveBeenCalledTimes(2);
386
+ // Advance past JUST_HID_GRACE_S (0.3s) without an enter — grace expires.
387
+ h.tip.step(0.35);
388
+ // Now a cold-start enter should go through settle again.
389
+ h.enter(1);
390
+ // onHover fires immediately (crosshair update) — call 3.
391
+ expect(h.onHover).toHaveBeenCalledTimes(3);
392
+ expect(h.onHover.mock.calls[2]![0]).toMatchObject({ dataIndex: 1 });
393
+ // Not committed yet (settle pending).
394
+ h.tip.step(0.05);
395
+ expect(h.onHover).toHaveBeenCalledTimes(3);
396
+ // Past the settle window: committed without re-firing onHover.
397
+ h.tip.step(0.06);
398
+ expect(h.onHover).toHaveBeenCalledTimes(3);
399
+ });
400
+
401
+ test("defaultFormat handles invalid Date and Symbol without crashing", () => {
402
+ expect(defaultFormat(new Date("invalid"))).toBe("—");
403
+ expect(defaultFormat(Symbol("test"))).toBe("test");
404
+ expect(defaultFormat(Symbol())).toBe("Symbol()");
405
+ });
406
+ });