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,622 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Grammar-level tooltip wiring
3
+ // ---------------------------------------------------------------------------
4
+ // Subscribes to the shared `GrammarHitLayer` for hover enter/leave events;
5
+ // builds a TooltipContent from the geom's resolved aesthetics and drives
6
+ // insomni's generic `Tooltip` primitive. The hit-layer owns the spatial
7
+ // grids (one per geom regardless of how many consumers attach).
8
+
9
+ import {
10
+ type Color,
11
+ createTooltip,
12
+ type GlyphAtlas,
13
+ type Invalidator,
14
+ type Layer,
15
+ type Tooltip,
16
+ type TooltipBounds,
17
+ type TooltipContent,
18
+ type TooltipMeasure,
19
+ type TooltipRow,
20
+ } from "insomni";
21
+ import type {
22
+ AxisTooltipContentResolver,
23
+ AxisTooltipInfo,
24
+ AxisTooltipRow,
25
+ TooltipContentInfo,
26
+ TooltipContentResolver,
27
+ TooltipContentResult,
28
+ TooltipContentShorthand,
29
+ TooltipShorthandRow,
30
+ } from "../chart.ts";
31
+ import type { CompiledHitTest, HoveredHit, ResolvedChannelMap, ScaleBundle } from "../geoms/types.ts";
32
+ import { computeSnap, resolveGroupColor, type SnapGroup, type SnapResult } from "./series-snap.ts";
33
+ import type { Theme } from "../theme.ts";
34
+ import type { GrammarHitLayer, HitEventContext } from "./hit-layer.ts";
35
+ import { createDisposable } from "./_disposable.ts";
36
+ import { TEXT_WIDTH_FALLBACK_RATIO } from "../../format.ts";
37
+
38
+ export interface GrammarTooltipOptions {
39
+ /** Hover-intent delay before showing (ms). Default from theme.motion.tooltip.showDelay. */
40
+ showDelay?: number;
41
+ /** Linger before hiding (ms). Default from theme.motion.tooltip.hideDelay. */
42
+ hideDelay?: number;
43
+ /** Fade duration (ms). 0 disables. Default from theme.motion.tooltip.fadeMs. */
44
+ fadeMs?: number;
45
+ /**
46
+ * Hover-intent settle (ms). Coalesces rapid enter/leave/switch events so the
47
+ * tooltip only commits a show/hide once the cursor holds a target this long —
48
+ * sweeping across a dense mark grid or through gaps no longer fires an overlay
49
+ * re-render per cell. 0 disables (commit immediately).
50
+ * Default from theme.motion.tooltip.settleDelay.
51
+ */
52
+ settleDelay?: number;
53
+ /** Override placement. Default "top". */
54
+ placement?: "top" | "bottom" | "left" | "right" | "auto";
55
+ /**
56
+ * Consumer-supplied content resolver. Returning a string / shorthand
57
+ * overrides the auto-built channel rows; returning null / undefined falls
58
+ * back to the default builder.
59
+ */
60
+ content?: TooltipContentResolver;
61
+ /**
62
+ * Optional secondary observer fired alongside the tooltip's own show/hide.
63
+ * Receives the active `HoveredHit` (geom kind, data array reference, data
64
+ * index, snapped position) on enter, and `null` on leave. Used by the
65
+ * grammar crosshair to track hit positions, and by the mount to publish
66
+ * `hovered` as a public signal.
67
+ *
68
+ * Also fires on touch-tap (`onPress`) since that path calls `commit` which
69
+ * calls this callback, bypassing the settle debounce.
70
+ */
71
+ onHover?(info: HoveredHit | null): void;
72
+ /**
73
+ * Trigger model. Default `"item"` (one box per hovered mark — unchanged).
74
+ * `"axis"` = floating unified readout snapping to the nearest data column.
75
+ */
76
+ trigger?: "item" | "axis";
77
+ /** Snap axis for `trigger:"axis"`. Default `"x"`. */
78
+ axis?: "x" | "y";
79
+ /** Axis-mode content hook (replaces the auto rows when it returns a shorthand). */
80
+ axisContent?: AxisTooltipContentResolver;
81
+ /** Format the snapped column value (title) in axis mode. */
82
+ axisTitleFormat?: (v: unknown) => string;
83
+ /** Format each auto-built row's value in axis mode. */
84
+ axisValueFormat?: (v: unknown) => string;
85
+ }
86
+
87
+ export interface GrammarTooltipDeps {
88
+ hitLayer: GrammarHitLayer;
89
+ hudLayer: () => Layer;
90
+ atlas: () => GlyphAtlas | undefined;
91
+ theme: () => Theme;
92
+ bounds: () => TooltipBounds;
93
+ invalidator: Invalidator;
94
+ /**
95
+ * Optional: current cursor position in element-local CSS px. When provided,
96
+ * the tooltip anchor follows the cursor every frame while visible — no need
97
+ * to wait for a cell-transition enter event.
98
+ */
99
+ pointerPos?: () => { x: number; y: number } | null;
100
+ /** Latest resolved scales (axis-mode swatch resolution). Optional. */
101
+ scales?: () => ScaleBundle | null;
102
+ /**
103
+ * Axis-mode pointer source: a full-frame interaction node registered by the
104
+ * mount. The tooltip subscribes for cursor tracking when `trigger:"axis"`.
105
+ * Returns an unregister fn.
106
+ */
107
+ onAxisPointer?: (handlers: {
108
+ move(p: { x: number; y: number }): void;
109
+ leave(): void;
110
+ }) => () => void;
111
+ }
112
+
113
+ export interface GrammarTooltip {
114
+ /** Tick opacity + delay timers. */
115
+ step(dt: number): void;
116
+ /** Draw into the hud layer. */
117
+ draw(): void;
118
+ dispose(): void;
119
+ /** Push fresh compiled hit-tests (axis mode only; item mode ignores). */
120
+ syncHits?<T>(hits: readonly CompiledHitTest<T>[]): void;
121
+ }
122
+
123
+ const CHANNEL_ORDER = ["x", "y", "color", "size", "shape", "alpha"] as const;
124
+ type ChannelKey = (typeof CHANNEL_ORDER)[number];
125
+
126
+ /**
127
+ * Best-effort default formatter. Numbers go through `Intl.NumberFormat` with
128
+ * adaptive precision; Dates through ISO; booleans/strings stringify.
129
+ */
130
+ export function defaultFormat(value: unknown): string {
131
+ if (value === null || value === undefined) return "—";
132
+ if (typeof value === "number") {
133
+ if (!Number.isFinite(value)) return String(value);
134
+ const abs = Math.abs(value);
135
+ // Use exponential only for values that would lose precision or produce
136
+ // absurdly long decimal strings. 1e-6 matches scientific conventions.
137
+ if (abs !== 0 && (abs < 1e-6 || abs >= 1e7)) {
138
+ return value.toExponential(3);
139
+ }
140
+ const digits = abs >= 100 ? 0 : abs >= 10 ? 1 : abs >= 1 ? 2 : 3;
141
+ return value.toLocaleString(undefined, { maximumFractionDigits: digits });
142
+ }
143
+ if (value instanceof Date) {
144
+ if (!Number.isFinite(value.getTime())) return "—";
145
+ // Include time component when the date has a non-midnight time.
146
+ const s = value.toISOString();
147
+ const hms = s.slice(11, 19);
148
+ return hms === "00:00:00" ? s.slice(0, 10) : s.slice(0, 10) + " " + hms;
149
+ }
150
+ if (typeof value === "boolean") return value ? "true" : "false";
151
+ if (typeof value === "symbol") return value.description ?? "Symbol()";
152
+ // oxlint-disable-next-line no-base-to-string
153
+ return String(value);
154
+ }
155
+
156
+ function buildDefaultContent<T>(hit: CompiledHitTest<T>, hitIndex: number): TooltipContent {
157
+ const dataIdx = hit.dataIndex[hitIndex]!;
158
+ const seriesKey = hit.seriesKey?.[hitIndex];
159
+ const datum = hit.data[dataIdx]!;
160
+ const rows: TooltipRow[] = [];
161
+ if (seriesKey !== undefined) {
162
+ rows.push({ label: "series", value: seriesKey });
163
+ const value = (datum as Record<string, unknown>)[seriesKey];
164
+ if (value !== undefined) rows.push({ label: "value", value: defaultFormat(value) });
165
+ }
166
+ const channels = hit.channels as ResolvedChannelMap<T>;
167
+ for (const key of CHANNEL_ORDER) {
168
+ const aes = channels[key as ChannelKey];
169
+ if (!aes) continue;
170
+ if (aes.kind === "constant") continue;
171
+ const label = aes.column ?? key;
172
+ const raw = aes.fn(datum, dataIdx);
173
+ rows.push({
174
+ label,
175
+ value: defaultFormat(raw),
176
+ });
177
+ }
178
+ return {
179
+ title: hit.label,
180
+ rows,
181
+ };
182
+ }
183
+
184
+ function isColor(v: unknown): v is Color {
185
+ return (
186
+ typeof v === "object" &&
187
+ v !== null &&
188
+ typeof (v as { r?: unknown }).r === "number" &&
189
+ typeof (v as { g?: unknown }).g === "number" &&
190
+ typeof (v as { b?: unknown }).b === "number"
191
+ );
192
+ }
193
+
194
+ function resolveAccent(
195
+ accent: TooltipShorthandRow["accent"],
196
+ accents: Theme["interactions"]["tooltipAccents"],
197
+ ): Color | undefined {
198
+ if (accent === undefined) return undefined;
199
+ if (accent === "neutral") return undefined;
200
+ if (accent === "positive") return accents.positive;
201
+ if (accent === "negative") return accents.negative;
202
+ if (isColor(accent)) return accent;
203
+ return undefined;
204
+ }
205
+
206
+ function normalizeShorthandValue(value: TooltipShorthandRow["value"]): string {
207
+ if (typeof value === "string") return value;
208
+ return defaultFormat(value);
209
+ }
210
+
211
+ /**
212
+ * Normalize a consumer-supplied resolver result into the canvas tooltip's
213
+ * `TooltipContent` shape. Returns null if the result is null/undefined so the
214
+ * caller can fall through to the default builder.
215
+ *
216
+ * - `string` → single value-only row.
217
+ * - `{ title, rows }` shorthand → rows mapped through the default formatter
218
+ * (strings pass through) with accent → row color via theme.
219
+ *
220
+ * Exported for tests and for reuse by alternative tooltip hosts.
221
+ */
222
+ export function normalizeTooltipContent(
223
+ result: TooltipContentResult | null | undefined,
224
+ theme: Theme,
225
+ ): TooltipContent | null {
226
+ if (result === null || result === undefined) return null;
227
+ if (typeof result === "string") {
228
+ // Empty string → fall back to the default builder.
229
+ if (result === "") return null;
230
+ return { rows: [{ value: result }] };
231
+ }
232
+ const shorthand: TooltipContentShorthand = result;
233
+ if (!Array.isArray(shorthand.rows) || shorthand.rows.length === 0) {
234
+ // Missing or empty rows → fall back.
235
+ return null;
236
+ }
237
+ const accents = theme.interactions.tooltipAccents;
238
+ const rows: TooltipRow[] = shorthand.rows.map((r) => {
239
+ const row: TooltipRow = { value: normalizeShorthandValue(r.value) };
240
+ if (r.label !== undefined) row.label = r.label;
241
+ if (r.swatch !== undefined) row.swatch = r.swatch;
242
+ const tint = resolveAccent(r.accent, accents);
243
+ if (tint !== undefined) row.color = tint;
244
+ return row;
245
+ });
246
+ return shorthand.title !== undefined ? { title: shorthand.title, rows } : { rows };
247
+ }
248
+
249
+ function buildInfo<T>(hit: CompiledHitTest<T>, hitIndex: number): TooltipContentInfo<T> {
250
+ const dataIdx = hit.dataIndex[hitIndex]!;
251
+ return {
252
+ datum: hit.data[dataIdx]!,
253
+ dataIndex: dataIdx,
254
+ mark: hit.label ?? hit.geomKind,
255
+ seriesKey: hit.seriesKey?.[hitIndex],
256
+ channels: hit.channels as Readonly<Record<string, unknown>>,
257
+ };
258
+ }
259
+
260
+ /**
261
+ * Resolve final tooltip content for a hit: run the consumer resolver if any,
262
+ * normalize the result, and fall back to the default channel-driven content
263
+ * when the resolver returns null/undefined.
264
+ */
265
+ export function resolveTooltipContent<T>(
266
+ hit: CompiledHitTest<T>,
267
+ hitIndex: number,
268
+ theme: Theme,
269
+ resolver?: TooltipContentResolver,
270
+ ): TooltipContent {
271
+ if (resolver) {
272
+ const raw = resolver(buildInfo(hit, hitIndex) as TooltipContentInfo<unknown>);
273
+ const normalized = normalizeTooltipContent(raw, theme);
274
+ if (normalized !== null) return normalized;
275
+ }
276
+ return buildDefaultContent(hit, hitIndex);
277
+ }
278
+
279
+ /**
280
+ * Build the axis-mode tooltip content from a snap result. Pure (no GPU / no
281
+ * closure state) so it is unit-testable. For `axis:"x"` each auto row's value
282
+ * is the series Y; for `axis:"y"` it is the series X. When `axisContent`
283
+ * returns a shorthand it REPLACES the auto rows (normalized through the theme).
284
+ */
285
+ export function buildAxisTooltipContent(args: {
286
+ result: SnapResult;
287
+ axis: "x" | "y";
288
+ scales: ScaleBundle | null;
289
+ theme: Theme;
290
+ titleFormat: (v: unknown) => string;
291
+ valueFormat: (v: unknown) => string;
292
+ axisContent?: AxisTooltipContentResolver;
293
+ }): TooltipContent {
294
+ const { result, axis, scales, theme, titleFormat, valueFormat, axisContent } = args;
295
+ const autoRows: AxisTooltipRow[] = result.groups.map((g) => {
296
+ const raw = axis === "y" ? g.xValue : g.yValue;
297
+ const row: AxisTooltipRow = {
298
+ label: g.label,
299
+ value: valueFormat(raw),
300
+ rawValue: raw,
301
+ active: g.id === result.activeId,
302
+ };
303
+ const color = g.color ?? resolveGroupColor(g, scales);
304
+ if (color) row.color = color;
305
+ if (g.seriesKey !== undefined) row.seriesKey = g.seriesKey;
306
+ return row;
307
+ });
308
+ const columnLabel = titleFormat(result.columnValue);
309
+ if (axisContent) {
310
+ const info: AxisTooltipInfo = {
311
+ columnValue: result.columnValue,
312
+ columnLabel,
313
+ axis,
314
+ rows: autoRows,
315
+ data: result.groups.map((g) => {
316
+ const dataIdx = g.hit.dataIndex[g.index]!;
317
+ return {
318
+ datum: g.hit.data[dataIdx],
319
+ dataIndex: dataIdx,
320
+ mark: g.hit.label ?? g.hit.geomKind,
321
+ };
322
+ }),
323
+ };
324
+ const normalized = normalizeTooltipContent(axisContent(info) ?? null, theme);
325
+ if (normalized !== null) return normalized;
326
+ }
327
+ return {
328
+ title: columnLabel,
329
+ rows: autoRows.map((r) => {
330
+ const row: TooltipRow = { label: r.label, value: r.value };
331
+ if (r.color) row.swatch = r.color;
332
+ return row;
333
+ }),
334
+ };
335
+ }
336
+
337
+ /** Project a snap group's picked hit to a `HoveredHit` (for crosshair snap). */
338
+ export function hoveredHitFromGroup(g: SnapGroup): HoveredHit {
339
+ const dataIdx = g.hit.dataIndex[g.index]!;
340
+ const h: HoveredHit = {
341
+ geomKind: g.hit.geomKind,
342
+ dataIndex: dataIdx,
343
+ data: g.hit.data,
344
+ x: g.hit.positions[g.index * 2]!,
345
+ y: g.hit.positions[g.index * 2 + 1]!,
346
+ };
347
+ if (g.seriesKey !== undefined) h.seriesKey = g.seriesKey;
348
+ return h;
349
+ }
350
+
351
+ export function createGrammarTooltip(
352
+ deps: GrammarTooltipDeps,
353
+ opts: GrammarTooltipOptions = {},
354
+ ): GrammarTooltip {
355
+ // The atlas is resolved lazily — on the first frame the font may not be
356
+ // ready yet, so `measure` falls back to a rough estimate based on font size.
357
+ const measure: TooltipMeasure = (text, fontSize) => {
358
+ const a = deps.atlas();
359
+ if (a) return a.measureText(text, { fontSize, simple: true });
360
+ return { width: text.length * fontSize * TEXT_WIDTH_FALLBACK_RATIO, height: fontSize };
361
+ };
362
+
363
+ const motion = deps.theme().motion.tooltip;
364
+ const tooltip: Tooltip = createTooltip({
365
+ placement: opts.placement ?? "top",
366
+ bounds: deps.bounds,
367
+ measure,
368
+ showDelay: opts.showDelay ?? motion.showDelay,
369
+ hideDelay: opts.hideDelay ?? motion.hideDelay,
370
+ fadeMs: opts.fadeMs ?? motion.fadeMs,
371
+ invalidator: deps.invalidator,
372
+ });
373
+
374
+ const onHover = opts.onHover ? (info: HoveredHit | null) => opts.onHover!(info) : undefined;
375
+ const resolver = opts.content;
376
+
377
+ // ---- hover-intent settle (debounce) ----
378
+ // The hit fan-out fires enter/leave on every cell *transition*. We coalesce
379
+ // events into a single `pending` target and only commit it once the cursor
380
+ // has held that target for `settle` seconds — but only for cold-start
381
+ // appears (tooltip is hidden and was not recently visible) and for leaves.
382
+ // When the tooltip is already on screen (opacity > 0) or was recently hidden
383
+ // (cursor just crossed a narrow gap), enters commit immediately so switching
384
+ // between adjacent data points feels snappy and the tooltip never blinks out
385
+ // on brief gap crossings. The countdown is driven by `step(dt)`.
386
+ const settle = Math.max(0, opts.settleDelay ?? motion.settleDelay) / 1000;
387
+ type EnterPayload = {
388
+ kind: "enter";
389
+ /** Lazy so item mode builds content in commit() exactly as before. */
390
+ buildContent: () => TooltipContent;
391
+ anchor: { x: number; y: number };
392
+ hit: HoveredHit | null;
393
+ };
394
+ type Pending = EnterPayload | { kind: "leave" };
395
+ let pending: Pending | null = null;
396
+ let settleRemaining = 0;
397
+ // Temporal window (seconds) for the just-hid grace — after a hide the
398
+ // tooltip remains responsive for this long before cold-start settle kicks
399
+ // in again. Prevents flicker on gap crossings while still applying settle
400
+ // for truly fresh entries (e.g., re-entering from outside the chart).
401
+ const JUST_HID_GRACE_S = 0.3;
402
+ let justHid = false;
403
+ let justHidTimer = 0;
404
+ let lastTrackedAnchor: { x: number; y: number } | null = null;
405
+
406
+ function commit(p: Pending, fireHover: boolean = true): void {
407
+ pending = null;
408
+ settleRemaining = 0;
409
+ if (p.kind === "enter") {
410
+ justHid = false;
411
+ justHidTimer = 0;
412
+ const content = p.buildContent();
413
+ const anchor = p.anchor;
414
+ lastTrackedAnchor = anchor;
415
+ tooltip.show(anchor, content);
416
+ if (fireHover) onHover?.(p.hit);
417
+ } else {
418
+ justHid = true;
419
+ justHidTimer = 0;
420
+ lastTrackedAnchor = null;
421
+ tooltip.hide();
422
+ onHover?.(null);
423
+ }
424
+ }
425
+
426
+ function schedule(p: Pending): void {
427
+ if (settle <= 0) {
428
+ commit(p);
429
+ return;
430
+ }
431
+ // Skip settle when the tooltip is on screen (switching between points) or
432
+ // was recently hidden (re-entering after a brief gap crossing). This
433
+ // prevents the tooltip from blinking out when the cursor passes through a
434
+ // narrow gap between adjacent data-point regions.
435
+ if (p.kind === "enter" && (tooltip.opacity > 0 || justHid)) {
436
+ // Clear any stale pending (e.g. a leave that was mid-settle) so the
437
+ // settle timer doesn't fire a stale commit later.
438
+ pending = null;
439
+ commit(p);
440
+ return;
441
+ }
442
+ // Cold-start enter: fire onHover immediately so crosshair and hover
443
+ // emphasis update right away, while still deferring the tooltip box
444
+ // through the settle window.
445
+ if (p.kind === "enter") {
446
+ onHover?.(p.hit);
447
+ }
448
+ pending = p;
449
+ settleRemaining = settle;
450
+ }
451
+
452
+ const axisMode = opts.trigger === "axis";
453
+ const axisAxis: "x" | "y" = opts.axis ?? "x";
454
+ const axisTitleFormat = opts.axisTitleFormat ?? defaultFormat;
455
+ const axisValueFormat = opts.axisValueFormat ?? defaultFormat;
456
+ let axisHits: readonly CompiledHitTest<unknown>[] = [];
457
+ let axisCursor: { x: number; y: number } | null = null;
458
+
459
+ function itemEnter(ctx: HitEventContext): EnterPayload {
460
+ return {
461
+ kind: "enter",
462
+ buildContent: () => resolveTooltipContent(ctx.compiled, ctx.hitIndex, deps.theme(), resolver),
463
+ anchor: { x: ctx.pointer.x, y: ctx.pointer.y },
464
+ hit: ctx.hit,
465
+ };
466
+ }
467
+
468
+ function recomputeAxis(immediate: boolean = false): void {
469
+ if (d.isDisposed) return;
470
+ if (axisCursor === null || axisHits.length === 0) {
471
+ schedule({ kind: "leave" });
472
+ return;
473
+ }
474
+ const cursor = axisAxis === "y" ? axisCursor.y : axisCursor.x;
475
+ const result = computeSnap({
476
+ hits: axisHits,
477
+ cursor,
478
+ axis: axisAxis,
479
+ snap: "nearest-x",
480
+ active: deps.hitLayer.state().active,
481
+ });
482
+ if (result === null) {
483
+ schedule({ kind: "leave" });
484
+ return;
485
+ }
486
+ const content = buildAxisTooltipContent({
487
+ result,
488
+ axis: axisAxis,
489
+ scales: deps.scales?.() ?? null,
490
+ theme: deps.theme(),
491
+ titleFormat: axisTitleFormat,
492
+ valueFormat: axisValueFormat,
493
+ axisContent: opts.axisContent,
494
+ });
495
+ const activeGroup = result.groups.find((g) => g.id === result.activeId) ?? null;
496
+ const payload: EnterPayload = {
497
+ kind: "enter",
498
+ buildContent: () => content,
499
+ anchor: { x: axisCursor!.x, y: axisCursor!.y },
500
+ hit: activeGroup ? hoveredHitFromGroup(activeGroup) : null,
501
+ };
502
+ if (immediate) {
503
+ pending = null;
504
+ settleRemaining = 0;
505
+ commit(payload);
506
+ } else {
507
+ schedule(payload);
508
+ }
509
+ }
510
+
511
+ let unsubscribe: () => void;
512
+ let unsubscribeAxisState: (() => void) | undefined;
513
+ let unregisterAxisPointer: (() => void) | undefined;
514
+ if (!axisMode) {
515
+ unsubscribe = deps.hitLayer.subscribe({
516
+ key: "tooltip",
517
+ onHoverEnter(ctx: HitEventContext) {
518
+ schedule(itemEnter(ctx));
519
+ },
520
+ onHoverLeave() {
521
+ schedule({ kind: "leave" });
522
+ },
523
+ onPress(ctx: HitEventContext) {
524
+ // Touch tap: commit immediately, bypassing settle.
525
+ pending = null;
526
+ settleRemaining = 0;
527
+ commit(itemEnter(ctx));
528
+ },
529
+ });
530
+ } else {
531
+ // Axis mode: dual cursor source (hit-layer enter for over-a-mark, full-frame
532
+ // pointer node for bare-frame), mirroring series-readout. state() drives
533
+ // recompute when the active hit changes.
534
+ unsubscribe = deps.hitLayer.subscribe({
535
+ key: "tooltip-axis",
536
+ onHoverEnter(ctx: HitEventContext) {
537
+ axisCursor = { x: ctx.pointer.x, y: ctx.pointer.y };
538
+ recomputeAxis();
539
+ },
540
+ onPress(ctx: HitEventContext) {
541
+ axisCursor = { x: ctx.pointer.x, y: ctx.pointer.y };
542
+ recomputeAxis(/*immediate=*/ true);
543
+ },
544
+ });
545
+ unsubscribeAxisState = deps.hitLayer.subscribeState(() => recomputeAxis());
546
+ unregisterAxisPointer = deps.onAxisPointer?.({
547
+ move: (p) => {
548
+ axisCursor = { x: p.x, y: p.y };
549
+ recomputeAxis();
550
+ },
551
+ leave: () => {
552
+ axisCursor = null;
553
+ schedule({ kind: "leave" });
554
+ },
555
+ });
556
+ }
557
+
558
+ const d = createDisposable(() => {
559
+ unsubscribe();
560
+ unsubscribeAxisState?.();
561
+ unregisterAxisPointer?.();
562
+ pending = null;
563
+ tooltip.dispose();
564
+ });
565
+
566
+ return {
567
+ step(dt) {
568
+ if (d.isDisposed) return;
569
+ if (pending !== null && dt > 0) {
570
+ settleRemaining -= dt;
571
+ if (settleRemaining <= 0) {
572
+ const p = pending;
573
+ pending = null;
574
+ // Cold-start enters already fired onHover in schedule(); don't
575
+ // re-fire it here so crosshair/hoverSignal aren't doubled.
576
+ commit(p, /*fireHover=*/ p.kind !== "enter");
577
+ }
578
+ }
579
+ // Decay the just-hid grace window. After JUST_HID_GRACE_S seconds
580
+ // without an enter, cold-start settle re-activates.
581
+ if (justHid && dt > 0) {
582
+ justHidTimer += dt;
583
+ if (justHidTimer >= JUST_HID_GRACE_S) {
584
+ justHid = false;
585
+ justHidTimer = 0;
586
+ }
587
+ }
588
+ // While visible OR logically requested (show-delay fade-in), track
589
+ // cursor movement so the tooltip follows the pointer even within the
590
+ // same data cell (where no enter/leave fires). Using `requested`
591
+ // instead of `opacity > 0` ensures tracking works during the show-delay
592
+ // fade-in, preventing a 1-frame jump when the tooltip first appears.
593
+ // Use `visible` (logical requested state, not just opacity > 0) so
594
+ // tracking works during the show-delay fade-in, preventing a 1-frame
595
+ // jump when the tooltip first appears.
596
+ if (tooltip.visible && deps.pointerPos) {
597
+ const pos = deps.pointerPos();
598
+ if (
599
+ pos &&
600
+ (!lastTrackedAnchor || pos.x !== lastTrackedAnchor.x || pos.y !== lastTrackedAnchor.y)
601
+ ) {
602
+ lastTrackedAnchor = { x: pos.x, y: pos.y };
603
+ tooltip.move(pos);
604
+ }
605
+ }
606
+ tooltip.step(dt);
607
+ },
608
+ draw() {
609
+ if (d.isDisposed) return;
610
+ tooltip.draw(deps.hudLayer());
611
+ },
612
+ dispose: () => d.dispose(),
613
+ syncHits(hits) {
614
+ if (!axisMode) return;
615
+ axisHits = hits as readonly CompiledHitTest<unknown>[];
616
+ recomputeAxis();
617
+ },
618
+ };
619
+ }
620
+
621
+ /** @internal — exposed for unit tests only. */
622
+ export const __test__ = { buildAxisTooltipContent, hoveredHitFromGroup };
@@ -0,0 +1,34 @@
1
+ import { type Easing } from "insomni";
2
+ import type { Invalidator } from "insomni";
3
+ import type { ActiveTransition, GeomFrame } from "../geoms/types.ts";
4
+ export interface TransitionsOptions {
5
+ /** Duration in seconds. */
6
+ duration: number;
7
+ easing: Easing;
8
+ invalidator: Invalidator;
9
+ }
10
+ export interface GrammarTransitions {
11
+ /** Advance the progress tween. Returns true while a transition is active. */
12
+ step(dt: number): boolean;
13
+ /**
14
+ * Signal that data/config has changed. If stored frames exist, snapshots
15
+ * them as "from" frames before the new pipeline compiles.
16
+ */
17
+ notifyChange(): void;
18
+ /**
19
+ * Returns the active transition context for geom at layer index `i`,
20
+ * or undefined when no transition is running or no stored frame exists.
21
+ */
22
+ contextFor(layerIndex: number, scope?: unknown): ActiveTransition | undefined;
23
+ /**
24
+ * Store the current compiled frame for future "from" use.
25
+ * Always updates the stable store. If a transition was just signalled,
26
+ * kicks off the progress tween.
27
+ */
28
+ storeFrame(layerIndex: number, frame: GeomFrame, scope?: unknown): void;
29
+ /** True while a transition is running. */
30
+ readonly active: boolean;
31
+ /** Imperatively request a transition on next draw, even without a data change. */
32
+ requestTransition(): void;
33
+ }
34
+ export declare function createGrammarTransitions(opts: TransitionsOptions): GrammarTransitions;