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,422 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Grammar-level series-readout — dygraph-style pinned legend
3
+ // ---------------------------------------------------------------------------
4
+ // Renders a side panel that updates as the cursor moves across the plot
5
+ // frame, showing each series' value at the snapped x (one row per series,
6
+ // grouped by `color` channel for multi-series layers). Reuses the compiled
7
+ // hit-tests for snapping; coordinates flow through a low-zIndex pointer node
8
+ // that overlays the plot frame so hover events on individual marks still win.
9
+ //
10
+ // Comes in two flavors:
11
+ // - DOM-attached: `opts.ui = { mount, position, inset }` builds and tracks
12
+ // a small absolute-positioned panel.
13
+ // - Headless: omit `opts.ui`; subscribe to `snapshot` changes and roll
14
+ // your own UI (Svelte / React / Solid / whatever).
15
+ //
16
+ // Mirrors the `attach-presets` shape — controller-first, optional DOM layer.
17
+
18
+ import {
19
+ type Color,
20
+ type Frame,
21
+ type InteractionManager,
22
+ type InteractionNode,
23
+ type Invalidator,
24
+ } from "insomni";
25
+ import type { CompiledHitTest, ScaleBundle } from "../geoms/types.ts";
26
+ import type { Theme } from "../theme.ts";
27
+ import { createDisposable } from "./_disposable.ts";
28
+ import type { GrammarHitLayer } from "./hit-layer.ts";
29
+ import { defaultFormat } from "./tooltip.ts";
30
+ import {
31
+ collectLayerGroups,
32
+ computeSnap,
33
+ overrideGroupPick,
34
+ resolveGroupColor,
35
+ resolveGroupKey,
36
+ } from "./series-snap.ts";
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Public types
40
+ // ---------------------------------------------------------------------------
41
+
42
+ export interface SeriesReadoutRow {
43
+ /** Series name (color-channel value) or layer label. */
44
+ label: string;
45
+ /** Formatted display value (raw y at the snapped x). */
46
+ value: string;
47
+ /** Series swatch color, when the layer has a color channel. */
48
+ color?: Color;
49
+ /** True for the row whose snapped position is closest to the cursor. */
50
+ active: boolean;
51
+ }
52
+
53
+ export interface SeriesReadoutSnapshot {
54
+ /** Formatted snapped x (from the closest layer). */
55
+ x: string;
56
+ /** Raw snapped x value (column-typed). Useful for custom formatting. */
57
+ xValue: unknown;
58
+ /** One row per series across all layers, in original layer + series order. */
59
+ rows: readonly SeriesReadoutRow[];
60
+ }
61
+
62
+ export type SeriesReadoutFormat = {
63
+ /** Format the snapped x for the panel title. Default: `defaultFormat`. */
64
+ x?: (value: unknown) => string;
65
+ /** Format each per-series y value. Default: `defaultFormat`. */
66
+ y?: (value: unknown) => string;
67
+ };
68
+
69
+ export type SeriesReadoutPosition = "top-left" | "top-right" | "bottom-left" | "bottom-right";
70
+
71
+ export interface SeriesReadoutUi {
72
+ /** Element to append the panel into (typically the chart's stage). */
73
+ mount: HTMLElement;
74
+ /** Corner inside `mount`. Default `"top-right"`. */
75
+ position?: SeriesReadoutPosition;
76
+ /** Inset (px) from the chosen corner. Default `12`. */
77
+ inset?: number;
78
+ }
79
+
80
+ export interface AttachSeriesReadoutOptions {
81
+ /** DOM mount info. Omit to run headless and observe via `subscribe`. */
82
+ ui?: SeriesReadoutUi;
83
+ /**
84
+ * How to pick a hit per series.
85
+ * - `"nearest-x"` (default): the hit with smallest |x − cursorX|. Best for
86
+ * time-series readouts; one row per series at the cursor's x.
87
+ * - `"hover"`: only show rows while the cursor is over a real hit (the
88
+ * tooltip-style behavior, but extended to every series in the chart).
89
+ */
90
+ snap?: "nearest-x" | "hover";
91
+ /** Per-channel formatters. */
92
+ format?: SeriesReadoutFormat;
93
+ /** Hide when the cursor leaves the plot frame. Default `true`. */
94
+ hideOnLeave?: boolean;
95
+ /** Notified whenever the snapshot changes (incl. with `ui` attached). */
96
+ onChange?(snapshot: SeriesReadoutSnapshot | null): void;
97
+ }
98
+
99
+ export interface AttachedSeriesReadout {
100
+ peek(): SeriesReadoutSnapshot | null;
101
+ subscribe(fn: (snapshot: SeriesReadoutSnapshot | null) => void): () => void;
102
+ dispose(): void;
103
+ }
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // Internal: dependencies + factory
107
+ // ---------------------------------------------------------------------------
108
+
109
+ export interface SeriesReadoutDeps {
110
+ manager: InteractionManager;
111
+ /** Plot-frame bounds (absolute element-CSS px). */
112
+ bounds: () => Frame;
113
+ /** Latest resolved scales (for color swatches). */
114
+ scales: () => ScaleBundle | null;
115
+ theme: () => Theme;
116
+ invalidator: Invalidator;
117
+ /**
118
+ * Optional shared hit-layer. When provided, the readout also subscribes to
119
+ * its hover events so the panel stays populated while the cursor is over a
120
+ * real mark (where high-z hit-cloud nodes claim hover and the readout's own
121
+ * low-z pointer node never fires).
122
+ */
123
+ hitLayer?: GrammarHitLayer;
124
+ }
125
+
126
+ /** Internal handle used by the mount to push fresh hit-tests in. */
127
+ export interface SeriesReadoutInternal extends AttachedSeriesReadout {
128
+ syncHits<T>(hits: readonly CompiledHitTest<T>[]): void;
129
+ }
130
+
131
+ export function createSeriesReadout(
132
+ deps: SeriesReadoutDeps,
133
+ opts: AttachSeriesReadoutOptions,
134
+ ): SeriesReadoutInternal {
135
+ const snap = opts.snap ?? "nearest-x";
136
+ const hideOnLeave = opts.hideOnLeave !== false;
137
+ const formatX = opts.format?.x ?? defaultFormat;
138
+ const formatY = opts.format?.y ?? defaultFormat;
139
+ const subscribers = new Set<(snapshot: SeriesReadoutSnapshot | null) => void>();
140
+ if (opts.onChange) {
141
+ subscribers.add((snapshot) => opts.onChange!(snapshot));
142
+ }
143
+
144
+ let activeHits: readonly CompiledHitTest<unknown>[] = [];
145
+ let cursorX: number | null = null;
146
+ let snapshot: SeriesReadoutSnapshot | null = null;
147
+
148
+ function emit(next: SeriesReadoutSnapshot | null): void {
149
+ if (snapshotEqual(snapshot, next)) return;
150
+ snapshot = next;
151
+ panel?.render(snapshot);
152
+ for (const fn of subscribers) fn(snapshot);
153
+ }
154
+
155
+ function recompute(): void {
156
+ if (d.isDisposed) return;
157
+ if (cursorX === null || activeHits.length === 0) {
158
+ emit(null);
159
+ return;
160
+ }
161
+ // The hit-layer owns "which hit is active" — read its live state rather
162
+ // than caching dispatch events, so the active row stays anchored across a
163
+ // chart re-render (state() re-dereferences from the slot).
164
+ const active = deps.hitLayer?.state().active ?? null;
165
+ const scales = deps.scales();
166
+ const result = computeSnap({
167
+ hits: activeHits,
168
+ cursor: cursorX,
169
+ axis: "x",
170
+ snap,
171
+ active,
172
+ });
173
+ if (result === null) {
174
+ emit(null);
175
+ return;
176
+ }
177
+ const rows: SeriesReadoutRow[] = result.groups.map((g) => ({
178
+ label: g.label,
179
+ value: formatY(g.yValue),
180
+ color: g.color ?? resolveGroupColor(g, scales),
181
+ active: snap === "hover" ? true : g.id === result.activeId,
182
+ }));
183
+ emit({
184
+ x: formatX(result.columnValue),
185
+ xValue: result.columnValue,
186
+ rows,
187
+ });
188
+ }
189
+
190
+ // -----------------------------------------------------------------------
191
+ // Pointer tracking — low-zIndex node over the plot frame. Hit-cloud nodes
192
+ // sit at Z_HIT_CLOUD_BASE (1000+) so any tooltip / selection cloud still
193
+ // claims hover ahead of us. When the cursor crosses an actual mark we
194
+ // never get a hover event, but the `recompute` we already triggered on
195
+ // entering the frame keeps the panel populated — `nearest-x` doesn't
196
+ // care whether the cursor sits ON a hit, only what x it's at.
197
+ // -----------------------------------------------------------------------
198
+
199
+ // When the cursor sits over a hit (high-z point cloud claims the hover),
200
+ // our own low-z plot-frame node never fires — track `cursorX` off the
201
+ // shared hit-layer's enter events instead.
202
+ //
203
+ // The hit-layer is the source of truth for "which hit is active". Our
204
+ // `recompute` reads it via `state()`; here we just subscribe to enter
205
+ // events for cursor tracking and to `subscribeState` for invalidation so
206
+ // the panel re-renders the moment the dispatched hit changes (or its
207
+ // underlying compiled is swapped by a sync).
208
+ const unsubscribeHit = deps.hitLayer?.subscribe({
209
+ key: "series-readout",
210
+ onHoverEnter: (ctx) => {
211
+ cursorX = ctx.pointer.x;
212
+ // recompute fires from the state subscription below.
213
+ },
214
+ onPress: (ctx) => {
215
+ // Touch tap: update cursor position so the panel populates immediately.
216
+ cursorX = ctx.pointer.x;
217
+ },
218
+ });
219
+ const unsubscribeState = deps.hitLayer?.subscribeState(() => {
220
+ recompute();
221
+ });
222
+
223
+ const node: InteractionNode = deps.manager.add({
224
+ space: "ui",
225
+ bounds: deps.bounds,
226
+ // Negative so this never wins hover claims against engine/user nodes.
227
+ zIndex: -2,
228
+ onHoverEnter: (info) => {
229
+ cursorX = info.x;
230
+ recompute();
231
+ },
232
+ onHoverMove: (info) => {
233
+ cursorX = info.x;
234
+ recompute();
235
+ },
236
+ onHoverLeave: () => {
237
+ if (!hideOnLeave) return;
238
+ cursorX = null;
239
+ emit(null);
240
+ },
241
+ });
242
+
243
+ // -----------------------------------------------------------------------
244
+ // DOM panel — optional. Re-renders on each snapshot change.
245
+ // -----------------------------------------------------------------------
246
+
247
+ const panel = opts.ui ? buildPanel(opts.ui, deps.theme()) : null;
248
+
249
+ const d = createDisposable(() => {
250
+ unsubscribeHit?.();
251
+ unsubscribeState?.();
252
+ node.destroy();
253
+ panel?.dispose();
254
+ subscribers.clear();
255
+ });
256
+
257
+ return {
258
+ peek() {
259
+ return snapshot;
260
+ },
261
+ subscribe(fn) {
262
+ if (d.isDisposed) return () => {};
263
+ subscribers.add(fn);
264
+ // Fire current state so consumers don't need a separate `peek()` call.
265
+ fn(snapshot);
266
+ return () => {
267
+ subscribers.delete(fn);
268
+ };
269
+ },
270
+ syncHits(hits) {
271
+ activeHits = hits as readonly CompiledHitTest<unknown>[];
272
+ // Recompute against the new hit set; the cursor x is unchanged so the
273
+ // panel tracks data changes without waiting for the next pointer move.
274
+ recompute();
275
+ },
276
+ dispose: () => d.dispose(),
277
+ };
278
+ }
279
+
280
+ // ---------------------------------------------------------------------------
281
+ // Snapshot equality — avoids redundant DOM writes / subscriber fan-outs.
282
+ // ---------------------------------------------------------------------------
283
+
284
+ function snapshotEqual(a: SeriesReadoutSnapshot | null, b: SeriesReadoutSnapshot | null): boolean {
285
+ if (a === b) return true;
286
+ if (!a || !b) return false;
287
+ if (a.x !== b.x) return false;
288
+ if (a.rows.length !== b.rows.length) return false;
289
+ for (let i = 0; i < a.rows.length; i++) {
290
+ const ra = a.rows[i]!;
291
+ const rb = b.rows[i]!;
292
+ if (ra.label !== rb.label) return false;
293
+ if (ra.value !== rb.value) return false;
294
+ if (ra.active !== rb.active) return false;
295
+ if (!colorEqual(ra.color, rb.color)) return false;
296
+ }
297
+ return true;
298
+ }
299
+
300
+ function colorEqual(a: Color | undefined, b: Color | undefined): boolean {
301
+ if (a === b) return true;
302
+ if (!a || !b) return false;
303
+ return a.r === b.r && a.g === b.g && a.b === b.b && a.a === b.a;
304
+ }
305
+
306
+ // ---------------------------------------------------------------------------
307
+ // DOM panel
308
+ // ---------------------------------------------------------------------------
309
+
310
+ interface PanelHandle {
311
+ render(snapshot: SeriesReadoutSnapshot | null): void;
312
+ dispose(): void;
313
+ }
314
+
315
+ const POSITION_STYLES: Record<SeriesReadoutPosition, (inset: number) => string> = {
316
+ "top-left": (i) => `top:${i}px;left:${i}px`,
317
+ "top-right": (i) => `top:${i}px;right:${i}px`,
318
+ "bottom-left": (i) => `bottom:${i}px;left:${i}px`,
319
+ "bottom-right": (i) => `bottom:${i}px;right:${i}px`,
320
+ };
321
+
322
+ function buildPanel(ui: SeriesReadoutUi, theme: Theme): PanelHandle {
323
+ const doc = ui.mount.ownerDocument;
324
+ const inset = ui.inset ?? 12;
325
+ const position = ui.position ?? "top-right";
326
+
327
+ const root = doc.createElement("div");
328
+ root.style.cssText = [
329
+ "position:absolute",
330
+ POSITION_STYLES[position](inset),
331
+ "min-width:120px",
332
+ "padding:6px 9px",
333
+ "border-radius:5px",
334
+ `background:${cssColor(withAlphaC(theme.background, 0.92))}`,
335
+ `border:1px solid ${cssColor(withAlphaC(theme.legend.labelColor, 0.18))}`,
336
+ `color:${cssColor(theme.legend.labelColor)}`,
337
+ `font:${theme.legend.fontSize - 1}px/1.5 ${cssFontStack(theme.text.fontFamily)}`,
338
+ "z-index:55",
339
+ "pointer-events:none",
340
+ "display:none",
341
+ ].join(";");
342
+
343
+ const title = doc.createElement("div");
344
+ title.style.cssText = [
345
+ "font-weight:600",
346
+ "margin-bottom:4px",
347
+ `color:${cssColor(withAlphaC(theme.legend.labelColor, 0.85))}`,
348
+ ].join(";");
349
+ root.appendChild(title);
350
+
351
+ const list = doc.createElement("div");
352
+ list.style.cssText = "display:flex;flex-direction:column;gap:2px";
353
+ root.appendChild(list);
354
+ ui.mount.appendChild(root);
355
+
356
+ return {
357
+ render(snapshot) {
358
+ if (!snapshot) {
359
+ root.style.display = "none";
360
+ return;
361
+ }
362
+ root.style.display = "block";
363
+ title.textContent = snapshot.x;
364
+ // Rebuild the rows in place — small N (one per series) keeps this cheap.
365
+ while (list.firstChild) list.removeChild(list.firstChild);
366
+ for (const row of snapshot.rows) {
367
+ const rowEl = doc.createElement("div");
368
+ rowEl.style.cssText = [
369
+ "display:flex",
370
+ "align-items:center",
371
+ "gap:6px",
372
+ row.active ? "opacity:1" : "opacity:0.72",
373
+ ].join(";");
374
+ if (row.color) {
375
+ const sw = doc.createElement("span");
376
+ sw.style.cssText = [
377
+ "display:inline-block",
378
+ "width:9px",
379
+ "height:9px",
380
+ "border-radius:2px",
381
+ `background:${cssColor(row.color)}`,
382
+ "flex-shrink:0",
383
+ ].join(";");
384
+ rowEl.appendChild(sw);
385
+ }
386
+ const label = doc.createElement("span");
387
+ label.textContent = row.label;
388
+ label.style.cssText = "flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis";
389
+ const value = doc.createElement("span");
390
+ value.textContent = row.value;
391
+ value.style.cssText = [
392
+ "font-variant-numeric:tabular-nums",
393
+ row.active ? "font-weight:600" : "font-weight:400",
394
+ ].join(";");
395
+ rowEl.appendChild(label);
396
+ rowEl.appendChild(value);
397
+ list.appendChild(rowEl);
398
+ }
399
+ },
400
+ dispose() {
401
+ root.remove();
402
+ },
403
+ };
404
+ }
405
+
406
+ function cssColor(c: Color): string {
407
+ const r = Math.round(c.r * 255);
408
+ const g = Math.round(c.g * 255);
409
+ const b = Math.round(c.b * 255);
410
+ return `rgba(${r},${g},${b},${c.a})`;
411
+ }
412
+
413
+ function withAlphaC(c: Color, alpha: number): Color {
414
+ return { r: c.r, g: c.g, b: c.b, a: c.a * alpha };
415
+ }
416
+
417
+ function cssFontStack(family: string | undefined): string {
418
+ return family && family.length > 0 ? family : "ui-sans-serif,system-ui,sans-serif";
419
+ }
420
+
421
+ /** @internal — exposed for unit tests only. */
422
+ export const __test__ = { collectLayerGroups, resolveGroupKey, overrideGroupPick, snapshotEqual };
@@ -0,0 +1,70 @@
1
+ import type { Color } from "insomni";
2
+ import type { CompiledHitTest, ScaleBundle } from "../geoms/types.ts";
3
+ import type { HitLayerActive } from "./hit-layer.ts";
4
+ /** One per-series group snapped to the cursor's column. */
5
+ export interface SnapGroup {
6
+ /** Stable id `${layerIdx}::${seriesKey ?? "default"}`. */
7
+ id: string;
8
+ label: string;
9
+ /** Picked hit index inside the compiled hit. */
10
+ index: number;
11
+ /** |cursor − positions[index*2 (+1 for y)]|. */
12
+ dist: number;
13
+ xValue: unknown;
14
+ yValue: unknown;
15
+ /** Raw color-channel value, for scale lookup when no constant. */
16
+ colorRaw?: unknown;
17
+ /** Constant color when the layer's color channel is a constant aes. */
18
+ color?: Color;
19
+ /** seriesKey of the picked hit (stacked / dodged segments). */
20
+ seriesKey?: string;
21
+ /** Reference back to the compiled hit (used for swatch resolution). */
22
+ hit: CompiledHitTest<unknown>;
23
+ }
24
+ export interface SnapResult {
25
+ /** Snapped column value: the X for `axis:"x"`, the Y for `axis:"y"`. */
26
+ columnValue: unknown;
27
+ /** Ordered groups, one per series across all layers. */
28
+ groups: SnapGroup[];
29
+ /** Resolved active group id (closest to cursor / dispatched hit). */
30
+ activeId: string | null;
31
+ }
32
+ /**
33
+ * Resolve the group identity for a given hit index. Group key priority:
34
+ * 1. `seriesKey[i]` from the compiled hit (stacked / dodged geoms).
35
+ * 2. `colorAes(datum, i)` when color is an accessor / column.
36
+ * 3. Otherwise the whole layer is one group keyed by the layer label.
37
+ * Returns null when no x/y channels are bound. (axis-independent.)
38
+ */
39
+ export declare function resolveGroupKey<T>(hit: CompiledHitTest<T>, hitIndex: number): {
40
+ key: string;
41
+ label: string;
42
+ raw: unknown;
43
+ } | null;
44
+ /**
45
+ * Collect per-series groups for one layer, picking the nearest hit on `axis`
46
+ * within each group. `axis` is the LAST param and defaults to `"x"` so the
47
+ * existing 5-arg callers / `__test__` signature are unaffected.
48
+ */
49
+ export declare function collectLayerGroups<T>(hit: CompiledHitTest<T>, layerIdx: number, cursor: number, snap: "nearest-x" | "hover", out: SnapGroup[], axis?: "x" | "y"): void;
50
+ /**
51
+ * Replace a group's pick with the exact dispatched hit. No-op if the group
52
+ * isn't in the collected set. (moved verbatim, axis-independent.)
53
+ */
54
+ export declare function overrideGroupPick<T>(groups: SnapGroup[], groupId: string, hit: CompiledHitTest<T>, hitIndex: number): void;
55
+ /** Resolve a group's swatch color, falling back to the color scale. (moved.) */
56
+ export declare function resolveGroupColor(group: SnapGroup, scales: ScaleBundle | null): Color | undefined;
57
+ /**
58
+ * Encapsulates the body of series-readout's `recompute()` MINUS emit/DOM: snaps
59
+ * the cursor to the active hit's column (when one is dispatched), collects all
60
+ * groups across layers, resolves the active group, and returns the title column
61
+ * value. Pure — the caller maps `groups` → rows and resolves swatch colors via
62
+ * `resolveGroupColor`. Returns null when no layer produced a group.
63
+ */
64
+ export declare function computeSnap(args: {
65
+ hits: readonly CompiledHitTest<unknown>[];
66
+ cursor: number;
67
+ axis: "x" | "y";
68
+ snap: "nearest-x" | "hover";
69
+ active: HitLayerActive | null;
70
+ }): SnapResult | null;