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,2112 @@
1
+ // ---------------------------------------------------------------------------
2
+ // WebGPU mount — owns the renderer, atlas, layers, and rAF loop
3
+ // ---------------------------------------------------------------------------
4
+ // `chart.mount(canvas, opts?)` returns a `MountedPlot<T>` that owns the
5
+ // per-canvas rendering loop (Renderer2D, GlyphAtlas, three layers, RAF tick,
6
+ // ResizeObserver, visibility pause). The intent is that 90% of users never
7
+ // touch any of the lower-level pieces. The remaining 10% can:
8
+ //
9
+ // - bring their own Renderer2D / GlyphAtlas / layers (`opts.renderer`, etc.)
10
+ // - drive sizing manually (`opts.width`/`height`, `opts.autoResize: false`)
11
+ // - swap the spec at runtime (`handle.update(...)`) or rebuild via a closure
12
+ // - reach into `handle.renderer` / `handle.atlas` / `handle.axisLayer` etc.
13
+ // - opt out of the rAF loop entirely (`opts.autoFrame: false`) and call
14
+ // `handle.update()` themselves from a host loop
15
+ //
16
+ // The mount is sync. If you need to bootstrap a device, do it once with
17
+ // `await initGPU()` and pass the result in.
18
+
19
+ import {
20
+ type CacheHint,
21
+ type Color,
22
+ createFrame,
23
+ createInteractionManager,
24
+ createInvalidator,
25
+ createLayer,
26
+ createSVGRenderer,
27
+ createRenderer,
28
+ type Frame,
29
+ type FrameRect,
30
+ type FrameTiming,
31
+ type GlyphAtlas,
32
+ type InteractionManager,
33
+ type Invalidator,
34
+ type Layer,
35
+ type Layer as V1Layer,
36
+ loadSystemFont,
37
+ SdfGlyphAtlas,
38
+ } from "insomni";
39
+ import type {
40
+ BrushConfig,
41
+ Chart,
42
+ ContextMenuConfig,
43
+ CrosshairConfig,
44
+ InteractionsConfig,
45
+ LegendInteractionConfig,
46
+ MountedPlot,
47
+ MountPlotOptions,
48
+ SelectionConfig,
49
+ TooltipConfig,
50
+ TransitionsConfig,
51
+ } from "./chart.ts";
52
+ import { createGrammarTransitions, type GrammarTransitions } from "./interactions/transitions.ts";
53
+ import { resolveMotion } from "./theme.ts";
54
+ import {
55
+ DEFAULT_CHART_HEIGHT,
56
+ DEFAULT_CHART_WIDTH,
57
+ DEFAULT_PAN_BOUNDS,
58
+ DEFAULT_Y_FIT_PADDING,
59
+ } from "./constants.ts";
60
+ import type { HoveredHit } from "./geoms/types.ts";
61
+ import { createGrammarBrush, type GrammarBrush } from "./interactions/brush.ts";
62
+ import { createGrammarCrosshair, type GrammarCrosshair } from "./interactions/crosshair.ts";
63
+ import { createGrammarHitLayer, type GrammarHitLayer } from "./interactions/hit-layer.ts";
64
+ import { createGrammarLegend, type GrammarLegend } from "./interactions/legend.ts";
65
+ import { createGrammarContextMenu, type GrammarContextMenu } from "./interactions/menu.ts";
66
+ import { createGrammarSelection, type GrammarSelection } from "./interactions/selection.ts";
67
+ import {
68
+ createSeriesReadout,
69
+ type AttachedSeriesReadout,
70
+ type AttachSeriesReadoutOptions,
71
+ type SeriesReadoutInternal,
72
+ } from "./interactions/series-readout.ts";
73
+ import { createGrammarTooltip, type GrammarTooltip } from "./interactions/tooltip.ts";
74
+ import { currentData, runPipeline, type ChartConfig } from "./pipeline.ts";
75
+ import {
76
+ attachRangePresets as attachRangePresetsHelper,
77
+ type AttachedRangePresets,
78
+ type AttachRangePresetsOptions,
79
+ } from "./attach-presets.ts";
80
+ import {
81
+ createAttachedBrush,
82
+ type AttachBrushOptions,
83
+ type AttachedBrush,
84
+ type AttachedBrushInternal,
85
+ } from "./attach-brush.ts";
86
+ import { createDataViewport, type DataViewport } from "../viewport.ts";
87
+ import { bindDataViewport, type DataViewportBinding } from "../interactions.ts";
88
+ import type { Coord } from "./coord.ts";
89
+ import type { PanZoomConfig } from "./chart.ts";
90
+ import { resolveAes, type Aes } from "./aes.ts";
91
+ import { shallowObjectEqual } from "./equality.ts";
92
+ import type { DataPanBoundsOptions } from "../viewport.ts";
93
+ import type { AxisSelection } from "../interactions.ts";
94
+ import { readNumericDomain, readContinuousType, type PositionScaleOptions } from "./scales.ts";
95
+ import { isSignal, signal, type Signal } from "insomni/reactivity";
96
+ import { recordCpu, recordGpu } from "./profiling.ts";
97
+ import { GPU_DIM_GEOM_KINDS, type EmphasisResolver } from "./geoms/emphasis.ts";
98
+ import { createEmphasisDriver, type EmphasisDriver } from "./emphasis-driver.ts";
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Render-orchestration contract (P5 — core z-aware cache + damage migration)
102
+ // ---------------------------------------------------------------------------
103
+ // Plot is a thin DAMAGE SOURCE for the core renderer. It no longer runs its own
104
+ // CPU-bake state machine; instead it emits ordered layers (`zIndex` bands) with
105
+ // `cache` HINTS and lets the core's per-frame cache policy decide bake-vs-live,
106
+ // composite order, and damage tracking. The behavior contract:
107
+ //
108
+ // • PAN / ZOOM (interaction in flight): every static layer's `cache` is
109
+ // flipped to "never" (see `applyInteractionCacheHints`). A pan recompiles
110
+ // the geoms each frame → the layer's pack version changes → an "auto" bake
111
+ // would re-bake (texture create + bake pass) EVERY frame, which is strictly
112
+ // worse than drawing live. So during a gesture all layers draw LIVE and each
113
+ // frame is a cheap full re-raster.
114
+ // • SETTLE (gesture ends): hints restore to "auto"; the core's policy bakes
115
+ // the now-static stack ONCE on the next full frame, after which pan-idle
116
+ // hovers ride the cached fast path. A (re)bake forces that frame full
117
+ // automatically (core contract) so there is no stale-composite window.
118
+ // • HOVER tooltip / crosshair / point-halo: overlay-only change → `drawOverlay`
119
+ // re-emits the overlay layer and issues `render(currentLayers(), { regions })`
120
+ // — a scissored, OIT-aware partial repaint of just the damaged rect(s). The
121
+ // overlay layer is `cache:"never"` and sits in the trailing z-band, so the
122
+ // baked marks/axis/hud composite UNDER it correctly (core T-ZBAKE z-runs).
123
+ // • DATA / SCALE / VIEW change: a normal `render(currentLayers(), { viewKey })`
124
+ // with no regions → full frame by construction. `viewKey` folds the visible
125
+ // domain so a pan-driven domain shift also forces full (the core's camera
126
+ // never moves for ui-space layers).
127
+ //
128
+ // Plot NEVER calls `cacheLayer`/`uncacheLayer` and NEVER passes `fullFrame:true`
129
+ // — the core's view fingerprint + force-full-after-bake logic owns that decision.
130
+ // When `partialRedraw` is off, every layer is `cache:"never"` and the renderer
131
+ // is non-persistent (pure-live fallback, byte-equivalent to the classic path).
132
+
133
+ // `GPU_DIM_GEOM_KINDS` — the set of geom kinds whose dim-others hover treatment
134
+ // rides the core's animated emphasis uniform — now lives in `./geoms/emphasis.ts`
135
+ // alongside the key-namespacing helpers (imported above) so the geom-tagging
136
+ // code and the mount agree on one source of truth.
137
+
138
+ /**
139
+ * Internal entry. `chart.mount(canvas, opts)` calls this with its frozen
140
+ * config + the user-provided mount options.
141
+ */
142
+ export function mountChart<T>(
143
+ config: ChartConfig<T>,
144
+ canvas: HTMLCanvasElement,
145
+ opts: MountPlotOptions<T> = {},
146
+ ): MountedPlot<T> {
147
+ // -----------------------------------------------------------------------
148
+ // Resolve external pieces — ownership rules:
149
+ // - we only `destroy()` what we created.
150
+ // - any of {renderer, atlas, layers} can be brought from outside.
151
+ // - if you bring a renderer you also own its lifecycle / setBackground /
152
+ // onFrameTiming wiring; we leave them alone.
153
+ // -----------------------------------------------------------------------
154
+
155
+ const ownedRenderer = !opts.renderer;
156
+ const ownedLayers = !opts.layers;
157
+
158
+ // Damage-tracked partial redraw. Only when we own *both* the renderer (so we
159
+ // can give it a persistent backbuffer) and the layers (so we can bake the
160
+ // marks layer to a texture). Otherwise it silently degrades to the classic
161
+ // full-frame path. `partial` gates every persistent-mode branch below;
162
+ // when false the code path is byte-identical to before.
163
+ const wantPartial = opts.partialRedraw ?? true;
164
+ // Warn only when partial redraw was *explicitly* requested but can't run
165
+ // because the caller brought their own renderer/layers. When it's just the
166
+ // default, silently degrade to the classic full-frame path (no warning).
167
+ if (opts.partialRedraw === true && (!ownedRenderer || !ownedLayers)) {
168
+ console.warn(
169
+ "insomni-plot: `partialRedraw` requires the mount to own both its renderer and layers; " +
170
+ "it is ignored when an external `renderer` or `layers` is supplied.",
171
+ );
172
+ }
173
+ const partial = wantPartial && ownedRenderer && ownedLayers;
174
+
175
+ // -----------------------------------------------------------------------
176
+ // Z-bands + cache hints (P5 migration)
177
+ // -----------------------------------------------------------------------
178
+ // Explicit flat-z bands make the composite order self-documenting and robust
179
+ // to `currentLayers()` reordering. Bands leave gaps so caller `extraLayers`
180
+ // (below/above marks) slot in between without colliding:
181
+ // axis = 0, below-geom = 10,11,…, marks = 100, above-geom = 110,111,…,
182
+ // hud = 200, overlay = 300.
183
+ const Z_AXIS = 0;
184
+ const Z_BELOW_BASE = 10;
185
+ const Z_MARKS = 100;
186
+ const Z_ABOVE_BASE = 110;
187
+ const Z_HUD = 200;
188
+ const Z_OVERLAY = 300;
189
+
190
+ // Cache hint for the STATIC layers (axis / below / marks / above / hud). When
191
+ // `partial` is off we run pure-live (every layer "never", renderer
192
+ // non-persistent), byte-equivalent to the classic full-frame path. The
193
+ // overlay layer is ALWAYS "never": its cursor shapes change every frame and it
194
+ // must stay live so it composites OVER the baked static stack (trailing z-band
195
+ // + live → core T-ZBAKE stacks bakes under it). `staticCacheHint` flips to
196
+ // "never" during a pan/zoom gesture (see `applyInteractionCacheHints`).
197
+ // `CacheHint` is the public union ("auto"|"always"|"never"), re-exported from
198
+ // the `insomni` barrel.
199
+ const staticCacheHint: CacheHint = partial ? "auto" : "never";
200
+
201
+ // device source: opts > config. Only required when we have to create a renderer ourselves.
202
+ const device = opts.device ?? config.device;
203
+ if (ownedRenderer && !device) {
204
+ throw new Error(
205
+ "chart.mount(canvas) needs a `device`. Pass `{ device }` in plot({...}) or in mount opts, " +
206
+ "or pass an existing `renderer`.",
207
+ );
208
+ }
209
+
210
+ // DPR — explicit > window. Re-read on every resize tick (DPR can change
211
+ // when dragging a window between displays).
212
+ const initialDpr = opts.dpr ?? readDpr();
213
+
214
+ // -----------------------------------------------------------------------
215
+ // Renderer
216
+ // -----------------------------------------------------------------------
217
+
218
+ // v3 `FrameTiming` carries v1 back-compat alias getters (`cpuMs`/`renderNs`/
219
+ // `computeNs`), so these reads work unchanged. `renderNs`/`computeNs` are
220
+ // non-null bigints on v3 (no GPU timestamp query — they are CPU analogs).
221
+ const onFrameTiming = (t: FrameTiming) => {
222
+ recordCpu("render-cpu", t.cpuMs);
223
+ recordGpu("render-pass", 0, Number(t.renderNs));
224
+ recordGpu("compute-passes", 0, Number(t.computeNs));
225
+ opts.onFrameTiming?.(t);
226
+ };
227
+
228
+ // v3: `createRenderer` auto-assembles shader + pipelines (+ OIT, default on).
229
+ // `persistent` + `onFrameTiming` live under `config`; the DPR is applied via
230
+ // `setDpr` below (the renderer never reads `window.devicePixelRatio` itself).
231
+ const renderer =
232
+ opts.renderer ??
233
+ createRenderer(device!, canvas, {
234
+ dpr: initialDpr,
235
+ config: { onFrameTiming, persistent: partial },
236
+ });
237
+
238
+ // Background — explicit override > config.background > theme.background.
239
+ // Only set when we own the renderer; an external renderer is the caller's
240
+ // responsibility.
241
+ if (ownedRenderer) {
242
+ renderer.setBackground(opts.background ?? config.background ?? config.theme.background);
243
+ }
244
+
245
+ // -----------------------------------------------------------------------
246
+ // Atlas
247
+ // -----------------------------------------------------------------------
248
+
249
+ // v3 externalizes the glyph atlas (D1=Hybrid): the renderer no longer owns a
250
+ // default font or `atlasFor`. We build an MSDF/SDF atlas from a system font
251
+ // and hand it to `createLayer({ atlas })`. A caller-supplied `externalAtlas`
252
+ // is used immediately and short-circuits the system-font load.
253
+ let atlas: GlyphAtlas | undefined = config.externalAtlas ?? undefined;
254
+
255
+ // -----------------------------------------------------------------------
256
+ // Layers
257
+ // -----------------------------------------------------------------------
258
+
259
+ // When we own the layers and already have an atlas (caller-supplied), mint
260
+ // them with it up front. Otherwise mint atlas-less layers — shapes render
261
+ // immediately; text is enabled once the system-font atlas resolves and we
262
+ // remint below. External layers are used verbatim.
263
+ let axisLayer =
264
+ opts.layers?.axis ??
265
+ createLayer({ space: "ui", atlas, zIndex: Z_AXIS, cache: staticCacheHint, label: "axis" });
266
+ let marksLayer =
267
+ opts.layers?.marks ??
268
+ createLayer({ space: "ui", atlas, zIndex: Z_MARKS, cache: staticCacheHint, label: "marks" });
269
+ let hudLayer =
270
+ opts.layers?.hud ??
271
+ createLayer({ space: "ui", atlas, zIndex: Z_HUD, cache: staticCacheHint, label: "hud" });
272
+ // Cursor-driven overlays (tooltip / crosshair / brush / menu) live on their
273
+ // own layer above the hud. It is ALWAYS `cache:"never"`: its cursor shapes
274
+ // change every overlay frame, so it draws LIVE and — sitting in the trailing
275
+ // z-band (Z_OVERLAY) above the baked static stack — composites correctly OVER
276
+ // the baked marks/axis/hud (core T-ZBAKE: leading-run bakes composite UNDER
277
+ // live geometry). Repainted via `regions` in `drawOverlay` (damage-tracked).
278
+ // `oitExempt`: the overlay skips the bounded-K OIT A-buffer and draws
279
+ // post-resolve on top — over dense transparent marks its fragments (appended
280
+ // last) would otherwise be the first dropped on per-pixel budget overflow,
281
+ // rendering the tooltip ghost-faint.
282
+ let overlayLayer =
283
+ opts.layers?.overlay ??
284
+ createLayer({
285
+ space: "ui",
286
+ atlas,
287
+ zIndex: Z_OVERLAY,
288
+ cache: "never",
289
+ label: "overlay",
290
+ oitExempt: true,
291
+ });
292
+
293
+ // Default font — `Layer.pushText` requires the layer to carry a glyph atlas.
294
+ // When we own the layers AND no external atlas was supplied, kick off the
295
+ // system-font load, build an SDF atlas, and remint the layers with it as soon
296
+ // as it resolves. External layers / atlas are the caller's responsibility.
297
+ // `fontReady` gates the first draw. We are ready immediately when we don't own
298
+ // the layers (caller wired text) or already hold an atlas (external).
299
+ let fontReady = !ownedLayers || atlas !== undefined;
300
+ // Declared early so the font-load `.then` can short-circuit when the mount
301
+ // is destroyed before the promise resolves — otherwise we'd allocate fresh
302
+ // layers and invalidate the loop on a torn-down chart.
303
+ let disposed = false;
304
+ if (ownedLayers && atlas === undefined) {
305
+ void loadSystemFont("sans-serif").then((font) => {
306
+ if (disposed) return;
307
+ atlas = new SdfGlyphAtlas(device!, font);
308
+ axisLayer = createLayer({
309
+ space: "ui",
310
+ atlas,
311
+ zIndex: Z_AXIS,
312
+ cache: staticCacheHint,
313
+ label: "axis",
314
+ });
315
+ marksLayer = createLayer({
316
+ space: "ui",
317
+ atlas,
318
+ zIndex: Z_MARKS,
319
+ cache: staticCacheHint,
320
+ label: "marks",
321
+ });
322
+ hudLayer = createLayer({
323
+ space: "ui",
324
+ atlas,
325
+ zIndex: Z_HUD,
326
+ cache: staticCacheHint,
327
+ label: "hud",
328
+ });
329
+ overlayLayer = createLayer({
330
+ space: "ui",
331
+ atlas,
332
+ zIndex: Z_OVERLAY,
333
+ cache: "never",
334
+ label: "overlay",
335
+ oitExempt: true,
336
+ });
337
+ fontReady = true;
338
+ inv.invalidate();
339
+ });
340
+ }
341
+
342
+ // -----------------------------------------------------------------------
343
+ // Loop state
344
+ // -----------------------------------------------------------------------
345
+
346
+ const inv: Invalidator = createInvalidator(true);
347
+
348
+ // Damage-tracked partial-redraw state (only meaningful when `partial`).
349
+ //
350
+ // Dual-flag scheme: `inv.dirty` means "a full frame is needed" — every
351
+ // existing `inv.invalidate()` keeps that meaning, so the default for any
352
+ // unclassified change is a (correct) full frame. `overlayDirty` is the
353
+ // separate, weaker signal that *only* the cursor overlays moved; it drives
354
+ // the cheap per-rect repaint. A pending full frame always wins.
355
+ let overlayDirty = false;
356
+ // Overlay-layer content bounds (CSS px) from the previous frame, so the next
357
+ // overlay frame can erase the old footprint as well as paint the new one.
358
+ let lastOverlayRect: FrameRect | null = null;
359
+ // Pan-tax settle detection (partial mode only). Tracks whether the previous
360
+ // `tick()` saw an active pan/zoom/fling. When it flips true → false we force
361
+ // one final `drawFull()` so the now-settled axis/hud/marks layers re-bake
362
+ // before any cheaper overlay-only frame composites against a live-but-gone
363
+ // sprite. Drag-end alone fires no `onChange` when fling is disabled, so this
364
+ // is the only thing guaranteeing the settle re-bake.
365
+ let wasInteracting = false;
366
+ // Debug annotation (P5-T1, decision D7) — the caller's INTENT for the next full
367
+ // frame, surfaced next to the core's own decision in the frame inspector. A
368
+ // weak classification latch: callers that know their cause (setData/update →
369
+ // "data-changed", resize → "resize", settle → "settle-rebake") set this before
370
+ // `inv.invalidate()`; `drawFull` reads it (overridden by "pan-zoom" when a
371
+ // gesture is live), then resets to "invalidate" for any unclassified change.
372
+ // Read only on the (already-non-zero-cost) full-frame path; core ignores it
373
+ // when the probe is disabled, so it is byte-irrelevant to rendering.
374
+ let pendingFullReason = "invalidate";
375
+ function requestOverlay(): void {
376
+ // Classic path has no partial frames — fall back to a normal full redraw.
377
+ if (partial) overlayDirty = true;
378
+ else inv.invalidate();
379
+ }
380
+ // Invalidator handed to the cursor overlays (tooltip / crosshair / brush /
381
+ // menu). Inherits everything from `inv` but routes `invalidate()` to an
382
+ // overlay-only repaint; `dispose()` is a no-op because the mount owns `inv`.
383
+ const overlayInv: Invalidator = Object.assign(Object.create(inv) as Invalidator, {
384
+ invalidate: () => requestOverlay(),
385
+ dispose: () => {},
386
+ });
387
+
388
+ const autoFrame = opts.autoFrame !== false;
389
+ const pauseOnHidden = opts.pauseOnHidden !== false;
390
+ const autoResize = opts.autoResize ?? (opts.width === undefined && opts.height === undefined);
391
+
392
+ // Working dimensions (CSS pixels).
393
+ let width = opts.width ?? config.width ?? (canvas.clientWidth || DEFAULT_CHART_WIDTH);
394
+ let height = opts.height ?? config.height ?? (canvas.clientHeight || DEFAULT_CHART_HEIGHT);
395
+ let dpr = initialDpr;
396
+ let backgroundOverride: Color | null = opts.background ?? null;
397
+
398
+ // Builder source — user can pass either a static `Chart<T>` (the one this
399
+ // mount was attached to) or a closure that rebuilds on each invalidation.
400
+ // We always re-resolve to a `ChartConfig<T>` in the draw path.
401
+ let activeConfig: ChartConfig<T> = config;
402
+ let builder: (() => Chart<T>) | null = opts.build ?? null;
403
+
404
+ // Reactive data: subscribe if the active config's data is a signal. We
405
+ // re-resolve this every time activeConfig changes (in `update`).
406
+ let unsubscribeData: (() => void) | null = null;
407
+ let dataSnapshot: readonly T[] = currentData(activeConfig.data);
408
+ function bindDataSignal(): void {
409
+ unsubscribeData?.();
410
+ unsubscribeData = null;
411
+ dataSnapshot = currentData(activeConfig.data);
412
+ if (isSignal<readonly T[]>(activeConfig.data)) {
413
+ unsubscribeData = (activeConfig.data as Signal<readonly T[]>).subscribe((next) => {
414
+ dataSnapshot = next;
415
+ resetEmphasisForData(); // clear stale index-derived emphasis (Fix C)
416
+ pendingFullReason = "data-changed";
417
+ inv.invalidate();
418
+ });
419
+ }
420
+ }
421
+ bindDataSignal();
422
+
423
+ // -----------------------------------------------------------------------
424
+ // Pan / zoom — DataViewport + binding, when enabled
425
+ // -----------------------------------------------------------------------
426
+
427
+ const panZoomCfg = resolvePanZoom(opts.panZoom);
428
+ let panZoomViewport: DataViewport<number, number> | null = null;
429
+ let panZoomBinding: DataViewportBinding | null = null;
430
+ let panZoomUnsub: (() => void) | null = null;
431
+ let yFitPadding: number | null = null;
432
+ const attachedPresets = new Set<AttachedRangePresets>();
433
+ const attachedSeriesReadouts = new Set<SeriesReadoutInternal>();
434
+ // Snapshot of the latest pipeline scales — series-readout needs the color
435
+ // scale to resolve swatches for non-constant color channels.
436
+ let latestScales: import("./geoms/types.ts").ScaleBundle | null = null;
437
+ // Latest hover-focus decorators (point halo + bring-to-front). Captured each
438
+ // full compile; replayed into the overlay layer on hover so local focus
439
+ // treatment costs an overlay re-bake, not a marks recompute.
440
+ let latestHoverDecorators: readonly import("./geoms/types.ts").GeomHoverDecorator[] = [];
441
+ if (panZoomCfg) {
442
+ const xDom = readNumericDomain(activeConfig.scaleOverrides.x, "x");
443
+ const yDom = readNumericDomain(activeConfig.scaleOverrides.y, "y");
444
+ const xType = readContinuousType(activeConfig.scaleOverrides.x);
445
+ const yType = readContinuousType(activeConfig.scaleOverrides.y);
446
+ panZoomViewport = createDataViewport<number, number>({
447
+ frame: createFrame({ x: 0, y: 0, width, height }),
448
+ x: { type: xType, domain: xDom },
449
+ y: { type: yType, domain: yDom },
450
+ minZoom: panZoomCfg.minZoom,
451
+ maxZoom: panZoomCfg.maxZoom,
452
+ panBounds: panZoomCfg.panBounds,
453
+ });
454
+ // Route pan/zoom through the active coord. For Cartesian this is a
455
+ // direct passthrough (byte-identical to `viewport.panBy`/`zoomAt`); for
456
+ // polar / radial it decomposes drag deltas into rotation + radial
457
+ // translation and restricts zoom to the radius scale. The wrapper uses
458
+ // prototype inheritance so every other DataViewport member (state,
459
+ // visibleXDomain, absoluteFrame, etc.) flows through untouched.
460
+ const coordViewport = wrapViewportThroughCoord(
461
+ panZoomViewport,
462
+ () => activeConfig.coord,
463
+ () => inv.invalidate(),
464
+ );
465
+ panZoomBinding = bindDataViewport(coordViewport, canvas, {
466
+ pan: panZoomCfg.pan,
467
+ zoom: panZoomCfg.zoom,
468
+ });
469
+ panZoomUnsub = panZoomViewport.onChange(() => inv.invalidate());
470
+ yFitPadding = panZoomCfg.yFitPadding;
471
+ }
472
+ const clipMarks = opts.clipMarks ?? true;
473
+
474
+ // -----------------------------------------------------------------------
475
+ // Interactions — manager + grammar tooltip wiring
476
+ // -----------------------------------------------------------------------
477
+
478
+ // -----------------------------------------------------------------------
479
+ // Transitions — animated channel lerp on data/scale change
480
+ // -----------------------------------------------------------------------
481
+
482
+ const transitionsCfg = resolveTransitionsCfg(opts.transitions);
483
+ let grammarTransitions: GrammarTransitions | null = null;
484
+ const transitionKey =
485
+ transitionsCfg && transitionsCfg.key
486
+ ? (datum: T, index: number) => String(transitionsCfg.key!(datum, index))
487
+ : undefined;
488
+ if (transitionsCfg !== false) {
489
+ const m = config.theme.motion;
490
+ const resolvedMotion = resolveMotion(m, m.data);
491
+ grammarTransitions = createGrammarTransitions({
492
+ duration:
493
+ transitionsCfg.duration !== undefined
494
+ ? transitionsCfg.duration / 1000
495
+ : resolvedMotion.durationMs / 1000,
496
+ easing: resolvedMotion.easing,
497
+ invalidator: inv,
498
+ });
499
+ }
500
+
501
+ // Track the previous data reference to detect changes.
502
+ let prevDataRef: readonly unknown[] | null = null;
503
+ // Track the previous scaleOverrides object to detect explicit scale-domain
504
+ // changes (e.g. `chart.scale("y", { domain: [...] })`). Identity compare per
505
+ // channel works because `chart.scale()` produces a new sub-object only for
506
+ // the changed channel; unchanged channels retain identity.
507
+ let prevScaleOverrides: ChartConfig<T>["scaleOverrides"] | null = null;
508
+
509
+ const interactionsCfg = resolveInteractions(opts.interactions);
510
+ let manager: InteractionManager | null = null;
511
+ let hitLayer: GrammarHitLayer | null = null;
512
+ let grammarTooltip: GrammarTooltip | null = null;
513
+ let grammarCrosshair: GrammarCrosshair | null = null;
514
+ let grammarSelection: GrammarSelection | null = null;
515
+ let grammarBrush: GrammarBrush | null = null;
516
+ // Imperative brush attached via `MountedPlot.attachBrush`. Mutually exclusive
517
+ // with `grammarBrush` — `attachBrush` throws if either is already live.
518
+ let attachedBrush: AttachedBrushInternal | null = null;
519
+ let grammarLegend: GrammarLegend | null = null;
520
+ let grammarContextMenu: GrammarContextMenu | null = null;
521
+ // Hover state — published as a signal on the public handle so chart users
522
+ // can react (custom annotations, side-panel detail views) and so future
523
+ // animated transitions can drive off it. Populated synchronously from
524
+ // tooltip's onHover; null when nothing's hovered.
525
+ const hoverSignal: Signal<HoveredHit | null> = signal<HoveredHit | null>(null);
526
+ // Last hit the hover subscriber acted on, so a per-pointer-move re-fire on the
527
+ // same row is deduped (skip redundant emphasis/overlay work). The dim/halo
528
+ // animation itself is driven by the GPU emphasis uniform below, not a CPU
529
+ // from/to lerp.
530
+ let lastHoverHit: HoveredHit | null = null;
531
+ // Pending hoverSignal.set(null) handle for the swap-grace debounce. Held at
532
+ // module scope of the closure so the tooltip's onHover callback can cancel
533
+ // it on the next non-null hit.
534
+ let pendingHoverNullHandle: number | null = null;
535
+ // Cursor-tracking cleanup for tooltip pointerPos. Set when the tooltip is
536
+ // created, called during dispose.
537
+ let cleanupCursorTracking: (() => void) | null = null;
538
+
539
+ // Shared helper: apply a hover hit (or clear) to hoverSignal + crosshair.
540
+ // Used by both the mouse-hover onHover path (with its grace-window debounce)
541
+ // and the touch-tap onPress path (which calls this directly).
542
+ function applyHover(next: HoveredHit | null): void {
543
+ hoverSignal.set(next);
544
+ updateCrosshairBoundsFor(next ? { x: next.x, y: next.y } : null);
545
+ grammarCrosshair?.setPosition(next ? { x: next.x, y: next.y } : null);
546
+ }
547
+
548
+ // ----- Animated GPU emphasis (P5-T3) ----------------------------------
549
+ // The dim-others hover treatment is driven entirely by the core's emphasis
550
+ // uniform: on a hover hit-change over a GPU-dim geom we resolve the hit to its
551
+ // namespaced focused key and animate `t` 0→1 (ease-out cubic) over
552
+ // `theme.interactions.hover.durationMs`; on exit we animate t→0 and keep the
553
+ // last focused key until t reaches 0 (then clear). Zero marks recompile.
554
+ //
555
+ // The state machine lives in `./emphasis-driver.ts` (pure, unit-tested with a
556
+ // deterministic clock). The mount is a thin consumer: it feeds resolved hover
557
+ // keys into `onHover`, advances the ramp from the RAF tick via `step(now)`,
558
+ // and routes the returned `{ needsFrame, full }` decisions into `inv` /
559
+ // `requestOverlay` / the repaint-only frame path (below).
560
+ //
561
+ // The uniform holds ONE key. Swapping hover A→B mid-animation snaps the
562
+ // focused key to B and continues t toward 1 (no per-key crossfade — a single
563
+ // global uniform can't express two focuses; documented limitation).
564
+ //
565
+ // Latest per-frame emphasis resolvers, captured each full compile.
566
+ let latestEmphasisResolvers: readonly EmphasisResolver[] = [];
567
+ const emphasis: EmphasisDriver = createEmphasisDriver({
568
+ // Re-read on every onHover/step so a live reduced-motion / theme change
569
+ // takes effect immediately. `motion.enabled === false` collapses to a snap.
570
+ durationS: () => {
571
+ const m = activeConfig.theme.motion;
572
+ const durMs = activeConfig.theme.interactions.hover.durationMs ?? 120;
573
+ return m.enabled && durMs > 0 ? durMs / 1000 : 0;
574
+ },
575
+ dim: () => activeConfig.theme.interactions.hover.dim,
576
+ setEmphasis: (s) => renderer.setEmphasis(s),
577
+ });
578
+ // True while the dim animation is mid-flight — a changed emphasis uniform
579
+ // alters pixels EVERYWHERE, so overlay/regions partial frames are FORBIDDEN
580
+ // until t reaches a settled value (exactly 0 or 1). See `tick()` / `drawOverlay`.
581
+ const emphasisAnimating = (): boolean => emphasis.animating();
582
+ // Set by an emphasis frame request that needs a FULL render but NOT a geom
583
+ // recompile (the per-tick ramp): a uniform-only change. The tick services it
584
+ // via the repaint-only path (`renderer.render(currentLayers())` with the live
585
+ // packs untouched) so the auto-cached glyph axis never re-bakes mid-ramp.
586
+ let emphasisRepaintPending = false;
587
+ // Resolve a hit to its GPU focused emphasis key, or 0 when no dim geom owns it.
588
+ function focusedKeyFor(hit: HoveredHit | null): number {
589
+ if (!hit || !hoverEmphasisEnabled() || !GPU_DIM_GEOM_KINDS.has(hit.geomKind)) return 0;
590
+ for (const r of latestEmphasisResolvers) {
591
+ if (r.geomKind === hit.geomKind && r.data === hit.data) {
592
+ return r.resolve(hit) ?? 0;
593
+ }
594
+ }
595
+ return 0;
596
+ }
597
+ hoverSignal.subscribe((next) => {
598
+ if (next === lastHoverHit) return;
599
+ lastHoverHit = next;
600
+
601
+ // Drive the emphasis state machine with the resolved focused key (0 = no dim
602
+ // geom under the cursor). The driver snaps under reduced-motion and reports
603
+ // whether a frame is needed and whether it must be FULL.
604
+ const req = emphasis.onHover(focusedKeyFor(next));
605
+ // A hover hit-change is routed through a FULL recompile (`inv.invalidate()` →
606
+ // drawFull), NOT the per-tick repaint-only path. Rationale: the deliberately-
607
+ // inert nearestX geoms (`area`/`rolling`) read `hovered` at COMPILE time, so
608
+ // a hover-change over them must recompile to refresh their halo; selection /
609
+ // legend changes arrive via their own invalidations. The hot path (~8x/ramp)
610
+ // is the per-tick `step()`, which IS optimized to repaint-only below — the
611
+ // single hover-change frame is not worth the soundness risk.
612
+ if (req.needsFrame) {
613
+ if (req.full) inv.invalidate();
614
+ else requestOverlay(); // exit that was never dimmed — overlay-only repaint
615
+ }
616
+ });
617
+ // Fix C — clear stale emphasis on a data change. Emphasis keys are
618
+ // index-derived, so a held hover keeps the OLD focused key dimming RE-INDEXED
619
+ // instances after a data swap. Snap the uniform off (driver `reset()`) and drop
620
+ // the hover-hit dedup state so the pointer's NEXT move re-establishes emphasis
621
+ // against the new data. Called by setData / update / the data signal BEFORE the
622
+ // full redraw they already trigger.
623
+ function resetEmphasisForData(): void {
624
+ emphasis.reset(); // snaps the uniform to t:0 / focusedKey:0
625
+ emphasisRepaintPending = false;
626
+ lastHoverHit = null; // force the next hoverSignal fire through the subscriber
627
+ }
628
+ // Selection state — array of currently-selected hits. Always an array (even
629
+ // when selection is disabled) so consumers don't branch on null/undefined.
630
+ const selectionSignal: Signal<readonly HoveredHit[]> = signal<readonly HoveredHit[]>([]);
631
+ // Brush state — separate signal from selection so consumers can react to
632
+ // either (or both) without conflating range queries with discrete picks.
633
+ const brushedSignal: Signal<readonly HoveredHit[]> = signal<readonly HoveredHit[]>([]);
634
+ // Hidden series — set of keys toggled via legend. pipeline respects this.
635
+ const hiddenSignal: Signal<ReadonlySet<string>> = signal<ReadonlySet<string>>(new Set());
636
+ if (
637
+ interactionsCfg.tooltip ||
638
+ interactionsCfg.crosshair ||
639
+ interactionsCfg.selection ||
640
+ interactionsCfg.brush ||
641
+ interactionsCfg.legend ||
642
+ interactionsCfg.contextMenu
643
+ ) {
644
+ manager = createInteractionManager(canvas);
645
+ manager.onChange(() => requestOverlay());
646
+ // Shared hit-test fan-out — one PointCloudNode per geom regardless of
647
+ // how many consumers (tooltip, selection, contextMenu). Only built when
648
+ // at least one hit-driven consumer is enabled.
649
+ if (
650
+ interactionsCfg.tooltip ||
651
+ interactionsCfg.selection ||
652
+ (interactionsCfg.contextMenu && interactionsCfg.contextMenu.hitMode !== "background")
653
+ ) {
654
+ hitLayer = createGrammarHitLayer({ manager, element: canvas });
655
+ }
656
+ }
657
+ // Crosshair bounds — clipped to the active plot frame so the guide line
658
+ // doesn't extend through axis/legend slots. Updated each draw from the
659
+ // pipeline output; falls back to the full canvas before the first draw.
660
+ // For faceted charts, this becomes the *panel* frame containing the
661
+ // current hover position rather than the chart-wide union frame.
662
+ let crosshairBounds = { x: 0, y: 0, width, height };
663
+ // Chart-wide plot frame (faceted: union of panels). Used by brush + as the
664
+ // crosshair fallback when no panel contains the hover position.
665
+ let chartPlotFrame: Frame = createFrame({ x: 0, y: 0, width, height });
666
+ // Latest per-panel frames from the pipeline. Empty for non-faceted charts.
667
+ let lastPanelFrames: readonly Frame[] = [];
668
+
669
+ function frameContaining(p: { x: number; y: number }): Frame | null {
670
+ for (const f of lastPanelFrames) {
671
+ if (p.x >= f.x && p.x <= f.x + f.width && p.y >= f.y && p.y <= f.y + f.height) {
672
+ return f;
673
+ }
674
+ }
675
+ return null;
676
+ }
677
+ function updateCrosshairBoundsFor(p: { x: number; y: number } | null): void {
678
+ if (lastPanelFrames.length === 0 || !p) {
679
+ crosshairBounds = chartPlotFrame;
680
+ return;
681
+ }
682
+ const f = frameContaining(p);
683
+ crosshairBounds = f ? { x: f.x, y: f.y, width: f.width, height: f.height } : chartPlotFrame;
684
+ }
685
+ if (interactionsCfg.crosshair) {
686
+ grammarCrosshair = createGrammarCrosshair(
687
+ {
688
+ // The crosshair/brush/menu/tooltip primitives are v1-only insomni
689
+ // helpers (no v3 equivalent yet); they draw via `pushSegment`/`pushRect`
690
+ // which exist identically on the v3 `Layer` at runtime, so the v3
691
+ // overlay layer is bridged to the v1 `Layer` type here.
692
+ hudLayer: () => overlayLayer as unknown as V1Layer,
693
+ theme: () => activeConfig.theme,
694
+ bounds: () => crosshairBounds,
695
+ invalidator: overlayInv,
696
+ },
697
+ typeof interactionsCfg.crosshair === "object" ? interactionsCfg.crosshair : {},
698
+ );
699
+ // Free pointer-following mode — register a low-zIndex InteractionNode over
700
+ // the chart-wide plot frame that pushes raw cursor positions into the
701
+ // crosshair. The hit-layer's pointcloud nodes sit at higher zIndex, so
702
+ // when the cursor is over a hit point the tooltip's onHover snaps the
703
+ // crosshair to the data position and this node never sees those events.
704
+ // Outside hits, this node fires and the crosshair tracks the raw cursor.
705
+ const crosshairCfg =
706
+ typeof interactionsCfg.crosshair === "object" ? interactionsCfg.crosshair : {};
707
+ if (crosshairCfg.followPointer && manager) {
708
+ manager.add({
709
+ zIndex: -1,
710
+ space: "ui",
711
+ bounds: () => chartPlotFrame,
712
+ onHoverEnter: (e) => {
713
+ updateCrosshairBoundsFor({ x: e.x, y: e.y });
714
+ grammarCrosshair?.setPosition({ x: e.x, y: e.y });
715
+ },
716
+ onHoverMove: (e) => {
717
+ updateCrosshairBoundsFor({ x: e.x, y: e.y });
718
+ grammarCrosshair?.setPosition({ x: e.x, y: e.y });
719
+ },
720
+ onHoverLeave: () => {
721
+ grammarCrosshair?.setPosition(null);
722
+ },
723
+ });
724
+ }
725
+ }
726
+ if (interactionsCfg.selection && manager && hitLayer) {
727
+ grammarSelection = createGrammarSelection(
728
+ { manager, hitLayer },
729
+ {
730
+ onChange: (selected) => {
731
+ selectionSignal.set(selected);
732
+ inv.invalidate();
733
+ },
734
+ },
735
+ );
736
+ }
737
+ if (interactionsCfg.brush && manager) {
738
+ grammarBrush = createGrammarBrush(
739
+ {
740
+ manager,
741
+ // Brush operates on the chart-wide plot frame even when faceted —
742
+ // a range query that spans panels is still meaningful. Crosshair
743
+ // bounds are panel-scoped (see `crosshairBounds`).
744
+ bounds: () => chartPlotFrame,
745
+ hudLayer: () => overlayLayer as unknown as V1Layer,
746
+ theme: () => activeConfig.theme,
747
+ invalidator: overlayInv,
748
+ },
749
+ {
750
+ ...(typeof interactionsCfg.brush === "object" ? interactionsCfg.brush : {}),
751
+ onChange: (hits) => {
752
+ brushedSignal.set(hits);
753
+ // Brushed set never feeds mark compile (only hovered/selected/hidden
754
+ // do), so a brush change is an overlay-only repaint.
755
+ requestOverlay();
756
+ },
757
+ },
758
+ );
759
+ }
760
+ if (interactionsCfg.legend && manager) {
761
+ grammarLegend = createGrammarLegend({
762
+ manager,
763
+ hidden: hiddenSignal,
764
+ invalidator: inv,
765
+ });
766
+ }
767
+ if (interactionsCfg.contextMenu && manager) {
768
+ const cfg = interactionsCfg.contextMenu;
769
+ // For `background` mode we never need a hit-layer; resolve through a stub.
770
+ // Otherwise reuse the shared hit-layer built above. A `background`-mode
771
+ // menu running alongside a tooltip still gets the shared hit-layer (no
772
+ // harm — it just ignores it).
773
+ const hl: GrammarHitLayer = hitLayer ?? {
774
+ sync: () => {},
775
+ subscribe: () => () => {},
776
+ pickAt: () => null,
777
+ state: () => ({ active: null }),
778
+ subscribeState: () => () => {},
779
+ dispose: () => {},
780
+ };
781
+ grammarContextMenu = createGrammarContextMenu(
782
+ {
783
+ manager,
784
+ hitLayer: hl,
785
+ hudLayer: () => overlayLayer as unknown as V1Layer,
786
+ atlas: () => atlas,
787
+ theme: () => activeConfig.theme,
788
+ bounds: () => ({ x: 0, y: 0, width, height }),
789
+ invalidator: overlayInv,
790
+ onViewportChange: panZoomViewport ? (cb) => panZoomViewport!.onChange(cb) : undefined,
791
+ },
792
+ {
793
+ hitMode: cfg.hitMode ?? "nearest-point",
794
+ managerOpts:
795
+ cfg.holdMs !== undefined || cfg.slopPx !== undefined
796
+ ? { holdMs: cfg.holdMs, slopPx: cfg.slopPx }
797
+ : undefined,
798
+ // Suppress trigger while the data viewport is mid-pan / mid-fling.
799
+ // The manager already cancels touch long-press on drag promotion;
800
+ // this catches the right-click-during-drag case for mouse.
801
+ isSuppressed: panZoomBinding
802
+ ? () => panZoomBinding!.interacting || panZoomBinding!.flinging
803
+ : undefined,
804
+ onTrigger: cfg.onTrigger,
805
+ items: cfg.items,
806
+ onAction: cfg.onAction,
807
+ placement: cfg.placement,
808
+ style: cfg.style,
809
+ },
810
+ );
811
+ }
812
+ if (interactionsCfg.tooltip && hitLayer) {
813
+ // Track cursor position so the tooltip anchor follows the pointer even
814
+ // while the cursor stays within the same data cell (where no enter/leave
815
+ // fires from the hit-layer). This keeps the tooltip box near the cursor
816
+ // in dense UIs like heatmaps instead of being left behind at the enter
817
+ // position.
818
+ let cursorPos: { x: number; y: number } | null = null;
819
+ const onCursorMove = (e: PointerEvent) => {
820
+ cursorPos = { x: e.offsetX, y: e.offsetY };
821
+ };
822
+ const onCursorLeave = () => {
823
+ cursorPos = null;
824
+ };
825
+ canvas.addEventListener("pointermove", onCursorMove);
826
+ canvas.addEventListener("pointerleave", onCursorLeave);
827
+ cleanupCursorTracking = () => {
828
+ canvas.removeEventListener("pointermove", onCursorMove);
829
+ canvas.removeEventListener("pointerleave", onCursorLeave);
830
+ };
831
+ const ttCfg = typeof interactionsCfg.tooltip === "object" ? interactionsCfg.tooltip : {};
832
+ const axisMode = ttCfg.trigger === "axis";
833
+ grammarTooltip = createGrammarTooltip(
834
+ {
835
+ hitLayer,
836
+ hudLayer: () => overlayLayer as unknown as V1Layer,
837
+ atlas: () => atlas,
838
+ theme: () => activeConfig.theme,
839
+ bounds: () => ({ x: 0, y: 0, width, height }),
840
+ invalidator: overlayInv,
841
+ pointerPos: () => cursorPos,
842
+ scales: () => latestScales,
843
+ onAxisPointer:
844
+ axisMode && manager
845
+ ? (h) => {
846
+ const node = manager!.add({
847
+ zIndex: -2, // below hit clouds (1000+) and crosshair (-1)
848
+ space: "ui",
849
+ bounds: () => chartPlotFrame,
850
+ onHoverEnter: (e) => h.move({ x: e.x, y: e.y }),
851
+ onHoverMove: (e) => h.move({ x: e.x, y: e.y }),
852
+ onHoverLeave: () => h.leave(),
853
+ });
854
+ return () => node.destroy();
855
+ }
856
+ : undefined,
857
+ },
858
+ {
859
+ ...(typeof interactionsCfg.tooltip === "object" ? interactionsCfg.tooltip : {}),
860
+ onHover: (hit) => {
861
+ // Debounce null transitions: when leave fires, defer the
862
+ // `hoverSignal.set(null)` through a grace window. A non-null hit
863
+ // arriving within the window cancels the pending null and applies the
864
+ // new hit directly. This keeps `emphFocusedKey` (held in the emphasis
865
+ // driver) pointed at the previous focus until the swap lands, so the
866
+ // dim stays continuous and never flashes un-dim → re-dim as the cursor
867
+ // crosses between adjacent geoms.
868
+ if (pendingHoverNullHandle !== null) {
869
+ clearTimeout(pendingHoverNullHandle);
870
+ pendingHoverNullHandle = null;
871
+ }
872
+ if (hit) {
873
+ applyHover(hit);
874
+ return;
875
+ }
876
+ const grace = activeConfig.theme.interactions.hoverSwapGraceMs;
877
+ if (grace > 0) {
878
+ pendingHoverNullHandle = setTimeout(() => {
879
+ pendingHoverNullHandle = null;
880
+ applyHover(null);
881
+ }, grace) as unknown as number;
882
+ } else {
883
+ applyHover(null);
884
+ }
885
+ },
886
+ },
887
+ );
888
+ }
889
+
890
+ // -----------------------------------------------------------------------
891
+ // Resize plumbing
892
+ // -----------------------------------------------------------------------
893
+
894
+ // Apply our `width`/`height`/`dpr` to the renderer's backing canvas.
895
+ // Keeps device pixels = CSS px * dpr; a `space: "ui"` layer maps 1:1 with
896
+ // CSS pixels.
897
+ function applySize(): void {
898
+ if (ownedRenderer) {
899
+ renderer.setDpr(dpr);
900
+ renderer.resize(Math.max(1, Math.round(width * dpr)), Math.max(1, Math.round(height * dpr)));
901
+ }
902
+ }
903
+
904
+ applySize();
905
+
906
+ let resizeObserver: ResizeObserver | null = null;
907
+ if (autoResize && typeof ResizeObserver !== "undefined") {
908
+ resizeObserver = new ResizeObserver(() => {
909
+ const r = canvas.getBoundingClientRect();
910
+ const nextDpr = opts.dpr ?? readDpr();
911
+ const nextW = r.width || canvas.clientWidth || width;
912
+ const nextH = r.height || canvas.clientHeight || height;
913
+ if (nextW === width && nextH === height && nextDpr === dpr) return;
914
+ width = nextW;
915
+ height = nextH;
916
+ dpr = nextDpr;
917
+ applySize();
918
+ pendingFullReason = "resize";
919
+ inv.invalidate();
920
+ });
921
+ resizeObserver.observe(canvas);
922
+ }
923
+
924
+ // -----------------------------------------------------------------------
925
+ // Visibility pause
926
+ // -----------------------------------------------------------------------
927
+
928
+ let visibleHandler: (() => void) | null = null;
929
+ if (pauseOnHidden && typeof document !== "undefined") {
930
+ visibleHandler = () => {
931
+ // When tab returns, force a redraw (state may have advanced).
932
+ if (document.visibilityState === "visible") inv.invalidate();
933
+ };
934
+ document.addEventListener("visibilitychange", visibleHandler);
935
+ }
936
+
937
+ // -----------------------------------------------------------------------
938
+ // Draw — pure of loop concerns; called from rAF tick, manual update(), or
939
+ // toSVG(). Uses the active config + the latest dataSnapshot + working
940
+ // width/height.
941
+ // -----------------------------------------------------------------------
942
+
943
+ // Rebuild active config from the builder closure (if any) and rebind data.
944
+ function refreshActiveConfig(): void {
945
+ if (!builder) return;
946
+ const next = builder();
947
+ activeConfig = configOf(next);
948
+ // Builder may return a chart whose `data` is a different signal — rebind.
949
+ if (isSignal<readonly T[]>(activeConfig.data)) {
950
+ bindDataSignal();
951
+ } else {
952
+ unsubscribeData?.();
953
+ unsubscribeData = null;
954
+ dataSnapshot = currentData(activeConfig.data);
955
+ }
956
+ }
957
+
958
+ // Pan/zoom: override x/y scale domains with the viewport's visible window
959
+ // so axes / ticks / scales follow pan & zoom automatically. yFit additionally
960
+ // rescans the visible-X slice each frame so Y tracks on-screen values.
961
+ function applyPanZoomScales(snapshot: ChartConfig<T>): void {
962
+ if (!panZoomViewport) return;
963
+ const vx = panZoomViewport.visibleXDomain as readonly [number, number];
964
+ let vy = panZoomViewport.visibleYDomain as readonly [number, number];
965
+ if (yFitPadding !== null) {
966
+ const yExtent = computeVisibleYExtent(activeConfig.layers, dataSnapshot, vx, yFitPadding);
967
+ if (yExtent) vy = yExtent;
968
+ }
969
+ snapshot.scaleOverrides = {
970
+ ...snapshot.scaleOverrides,
971
+ x: { ...snapshot.scaleOverrides.x, domain: vx } as PositionScaleOptions,
972
+ y: { ...snapshot.scaleOverrides.y, domain: vy } as PositionScaleOptions,
973
+ };
974
+ }
975
+
976
+ // Notify transitions when data identity or scaleOverride values change.
977
+ // Compares scaleOverrides by value: closure-driven `build()` recreates the
978
+ // option objects each frame even when nothing changed, so reference compare
979
+ // would retrigger every frame and pin transitions at t≈0.
980
+ function notifyTransitionsOnChange(): void {
981
+ const currentDataRef: readonly unknown[] = dataSnapshot as readonly unknown[];
982
+ const currentScaleOverrides = activeConfig.scaleOverrides;
983
+ const dataChanged = prevDataRef !== null && prevDataRef !== currentDataRef;
984
+ const scaleChanged =
985
+ prevScaleOverrides !== null &&
986
+ SCALE_OVERRIDE_KEYS.some(
987
+ (k) => !shallowObjectEqual(prevScaleOverrides![k], currentScaleOverrides[k]),
988
+ );
989
+ // Gate on theme.motion.enabled — reduced-motion already collapses
990
+ // duration, but skipping notifyChange avoids spurious frame captures.
991
+ if (grammarTransitions && activeConfig.theme.motion.enabled && (dataChanged || scaleChanged)) {
992
+ grammarTransitions.notifyChange();
993
+ }
994
+ prevDataRef = currentDataRef;
995
+ prevScaleOverrides = currentScaleOverrides;
996
+ }
997
+
998
+ // Apply layer clips, viewport frame sync, and crosshair gating from a
999
+ // freshly-run pipeline output.
1000
+ function applyPipelineFrames(out: import("./pipeline.ts").PipelineOutput<T>): void {
1001
+ chartPlotFrame = out.plotFrame;
1002
+ // Universal: clip marks to the inner plot panel so geom rects/lines
1003
+ // can't bleed into axis / legend / title slots.
1004
+ if (clipMarks) {
1005
+ marksLayer.setClipRect({
1006
+ x: out.plotFrame.x,
1007
+ y: out.plotFrame.y,
1008
+ width: out.plotFrame.width,
1009
+ height: out.plotFrame.height,
1010
+ });
1011
+ const outerClip = {
1012
+ x: out.outerFrame.x,
1013
+ y: out.outerFrame.y,
1014
+ width: out.outerFrame.width,
1015
+ height: out.outerFrame.height,
1016
+ };
1017
+ hudLayer.setClipRect(outerClip);
1018
+ // Overlays share the hud's outer clip. The clip survives `clear()`, so
1019
+ // setting it on full frames keeps it valid through overlay-only frames.
1020
+ overlayLayer.setClipRect(outerClip);
1021
+ }
1022
+ // Pan/zoom: keep the viewport's pointer-frame aligned with the actual
1023
+ // panel so drag deltas map 1:1 to data-domain shifts. Pass `reserve`
1024
+ // so the viewport's axis pixel range matches the position scales —
1025
+ // `dataToScreen` then lines up with where marks actually render.
1026
+ if (panZoomViewport) {
1027
+ panZoomViewport.setFrame(out.plotFrame, out.reserve);
1028
+ }
1029
+ lastPanelFrames = out.panelFrames ?? [];
1030
+ // Recompute panel-scoped bounds against the current hover (panel frames
1031
+ // may have shifted on resize / data change while the cursor sat still).
1032
+ updateCrosshairBoundsFor(hoverSignal.peek());
1033
+ if (grammarCrosshair) {
1034
+ // Auto-enable on continuous x scales only — gating here so the
1035
+ // tooltip's onHover observer (which fires synchronously) sees the
1036
+ // live scale type.
1037
+ grammarCrosshair.setEnabled(out.scales.x.type !== "band");
1038
+ }
1039
+ }
1040
+
1041
+ // v3 composite view fingerprint extension. The mount's layers are all
1042
+ // `ui`-space, so the renderer's camera never moves and it cannot observe a
1043
+ // pan/zoom data-domain shift on its own. Folding the visible domain into
1044
+ // `viewKey` makes the renderer treat a domain change as a view change (a full
1045
+ // repaint) rather than letting an overlay-only damage frame composite against
1046
+ // a stale bake — the v3 fix for v1's group/domain-omission footgun.
1047
+ function currentViewKey(): string | undefined {
1048
+ if (!panZoomViewport) return undefined;
1049
+ const vx = panZoomViewport.visibleXDomain as readonly [number, number];
1050
+ const vy = panZoomViewport.visibleYDomain as readonly [number, number];
1051
+ return `${vx[0]},${vx[1]},${vy[0]},${vy[1]}`;
1052
+ }
1053
+
1054
+ // Render node list for this frame. Order: axis (bottom), caller `belowMarks`,
1055
+ // marks, caller `aboveMarks`, hud, overlay (top). Each layer carries an
1056
+ // explicit `zIndex` band so the core's flat-z composite order matches this
1057
+ // array order exactly (and is robust to any future reordering). Stable across
1058
+ // full and overlay-only frames so the renderer's damage replay sees the same
1059
+ // commands. Extra layers are assigned bands by index (below: 10,11,…; above:
1060
+ // 110,111,…) so they slot between the fixed bands without colliding.
1061
+ function currentLayers(): readonly Layer[] {
1062
+ const extra = opts.extraLayers?.();
1063
+ if (!extra) return [axisLayer, marksLayer, hudLayer, overlayLayer];
1064
+ const below = extra.belowMarks ?? [];
1065
+ const above = extra.aboveMarks ?? [];
1066
+ for (let i = 0; i < below.length; i++) {
1067
+ below[i]!.zIndex = Z_BELOW_BASE + i;
1068
+ below[i]!.label ??= `below:${i}`;
1069
+ }
1070
+ for (let i = 0; i < above.length; i++) {
1071
+ above[i]!.zIndex = Z_ABOVE_BASE + i;
1072
+ above[i]!.label ??= `above:${i}`;
1073
+ }
1074
+ return [axisLayer, ...below, marksLayer, ...above, hudLayer, overlayLayer];
1075
+ }
1076
+
1077
+ // Interaction-aware cache-hint flip (replaces the old pan-tax bake dance).
1078
+ // While a pan/zoom/fling is in flight, set the static layers' `cache` to
1079
+ // "never" so they draw LIVE: a pan recompiles the geoms each frame → the
1080
+ // layer pack version changes → an "auto" bake would re-bake (texture create +
1081
+ // bake pass) EVERY frame, strictly worse than live. On settle, restore "auto"
1082
+ // so the core's policy bakes the now-static stack once and pan-idle hovers ride
1083
+ // the cached fast path. No-op outside `partial` mode (hints are pinned
1084
+ // "never"). Mutating `cache` between frames is a supported core contract
1085
+ // (Layer.cache is mutable; the policy re-evaluates it every render()).
1086
+ function applyInteractionCacheHints(): void {
1087
+ if (!partial) return;
1088
+ const interacting = !!panZoomBinding && (panZoomBinding.interacting || panZoomBinding.flinging);
1089
+ const hint: CacheHint = interacting ? "never" : "auto";
1090
+ axisLayer.cache = hint;
1091
+ // Marks get their own hint: pinned "never" for GPU-dim charts (a bake would
1092
+ // freeze the no-op bake-time emphasis, so dim needs live marks), else the
1093
+ // shared interaction hint (big scatters still auto-bake when settled).
1094
+ marksLayer.cache = marksCacheHint(interacting);
1095
+ hudLayer.cache = hint;
1096
+ // Extra layers (caller below/above) follow the same gesture treatment.
1097
+ const extra = opts.extraLayers?.();
1098
+ if (extra) {
1099
+ for (const l of extra.belowMarks ?? []) l.cache = hint;
1100
+ for (const l of extra.aboveMarks ?? []) l.cache = hint;
1101
+ }
1102
+ // overlayLayer stays "never" — never touched here.
1103
+ }
1104
+
1105
+ // Whether hover emphasis (focus halo / dim) is enabled in the active theme.
1106
+ function hoverEmphasisEnabled(): boolean {
1107
+ return activeConfig.theme.interactions.hover.enabled;
1108
+ }
1109
+ // The decorator (if any) that owns this hit. A decorator draws a local focus
1110
+ // treatment (contrast halo / bring-to-front) into the live overlay layer:
1111
+ // point, plus bar/histogram/tile/line (whose dim-others is the GPU emphasis
1112
+ // uniform — the halo is the complementary focus shape, left at emphasisKey 0
1113
+ // so it stays full-strength while the rest dim). A hit with NO decorator just
1114
+ // gets the uniform dim (or nothing).
1115
+ function decoratorFor(
1116
+ hit: import("./geoms/types.ts").HoveredHit | null,
1117
+ ): import("./geoms/types.ts").GeomHoverDecorator | null {
1118
+ if (!hit) return null;
1119
+ for (const d of latestHoverDecorators) {
1120
+ if (d.geomKind === hit.geomKind && d.data === hit.data) return d;
1121
+ }
1122
+ return null;
1123
+ }
1124
+ // Whether the active chart contains a GPU-dim geom (with hover enabled). Such
1125
+ // a chart pins its marks layer to `cache:"never"` (see `marksCacheHint`) so the
1126
+ // emphasis uniform can dim LIVE marks — a bake would freeze the bake-time no-op
1127
+ // emphasis. Recomputed each full frame off the resolved config.
1128
+ function chartHasDimGeom(): boolean {
1129
+ if (!hoverEmphasisEnabled()) return false;
1130
+ return activeConfig.layers.some((g) => GPU_DIM_GEOM_KINDS.has(g.kind));
1131
+ }
1132
+ // Cache hint for the MARKS layer specifically. Pinned to "never" whenever the
1133
+ // chart has a GPU-dim geom (live marks required for the emphasis uniform to
1134
+ // dim them). Otherwise it follows the shared interaction hint (auto when
1135
+ // settled, never while panning) so big scatters still auto-bake.
1136
+ function marksCacheHint(interacting: boolean): CacheHint {
1137
+ if (!partial) return "never";
1138
+ if (chartHasDimGeom()) return "never";
1139
+ return interacting ? "never" : "auto";
1140
+ }
1141
+
1142
+ // Clear + re-emit the cursor-driven overlays onto their dedicated layer.
1143
+ // Used by both the full and overlay-only paths. The overlay clip is set on
1144
+ // full frames (in applyPipelineFrames) and survives `clear()`.
1145
+ function emitOverlays(): void {
1146
+ overlayLayer.clear();
1147
+ // Hover focus decoration first so it sits beneath crosshair lines + tooltip
1148
+ // shapes (push order = z order within the layer). It composites above the
1149
+ // baked marks, so the focused point reads on top of every other mark.
1150
+ if (hoverEmphasisEnabled()) {
1151
+ const hit = hoverSignal.peek();
1152
+ decoratorFor(hit)?.decorate(hit!, overlayLayer);
1153
+ }
1154
+ // Brush rect next so it sits beneath crosshair lines + tooltip shapes.
1155
+ if (grammarBrush) grammarBrush.draw();
1156
+ if (attachedBrush) attachedBrush.draw();
1157
+ // Crosshair BEFORE tooltip so the tooltip box occludes the crosshair
1158
+ // line that passes beneath/through it — prevents a visible line segment
1159
+ // inside the tooltip's rounded rectangle.
1160
+ if (grammarCrosshair) grammarCrosshair.draw();
1161
+ if (grammarTooltip) grammarTooltip.draw();
1162
+ // Menu on top so it can't be obscured.
1163
+ if (grammarContextMenu) grammarContextMenu.draw();
1164
+ }
1165
+
1166
+ // Current overlay-layer content rect (CSS px), or null when empty. Snapped
1167
+ // outward to integers and padded a few px: the text AABB is advance-width
1168
+ // based and can underflow actual ink (drop shadows, italics, decorations),
1169
+ // and the rect lands as an integer device-px scissor. Without the margin a
1170
+ // moving tooltip could leave a 1–2px ghost of its previous footprint. (The
1171
+ // renderer pads a further +1px for the AA fringe.)
1172
+ const OVERLAY_DAMAGE_PAD = 3;
1173
+ function overlayRect(): FrameRect | null {
1174
+ const b = overlayLayer.effectiveLocalBounds;
1175
+ if (!b) return null;
1176
+ const x = Math.floor(b.minX - OVERLAY_DAMAGE_PAD);
1177
+ const y = Math.floor(b.minY - OVERLAY_DAMAGE_PAD);
1178
+ const maxX = Math.ceil(b.maxX + OVERLAY_DAMAGE_PAD);
1179
+ const maxY = Math.ceil(b.maxY + OVERLAY_DAMAGE_PAD);
1180
+ return { x, y, width: maxX - x, height: maxY - y };
1181
+ }
1182
+ function unionRect(a: FrameRect | null, b: FrameRect | null): FrameRect | null {
1183
+ if (!a) return b;
1184
+ if (!b) return a;
1185
+ const x = Math.min(a.x, b.x);
1186
+ const y = Math.min(a.y, b.y);
1187
+ const maxX = Math.max(a.x + a.width, b.x + b.width);
1188
+ const maxY = Math.max(a.y + a.height, b.y + b.height);
1189
+ return { x, y, width: maxX - x, height: maxY - y };
1190
+ }
1191
+ // The core's `render({ regions })` takes CSS-px `Bounds2D` ({minX,minY,maxX,
1192
+ // maxY}); our overlay rects are `FrameRect` ({x,y,width,height}). Convert.
1193
+ function rectToBounds(r: FrameRect): import("insomni").Bounds2D {
1194
+ return { minX: r.x, minY: r.y, maxX: r.x + r.width, maxY: r.y + r.height };
1195
+ }
1196
+
1197
+ // Full frame: recompile the whole chart, (partial mode) bake the marks, and
1198
+ // render every layer. Resets both dirty flags.
1199
+ function drawFull(): void {
1200
+ if (!fontReady) return;
1201
+ inv.clear();
1202
+ overlayDirty = false;
1203
+
1204
+ refreshActiveConfig();
1205
+
1206
+ const snapshot: ChartConfig<T> = { ...activeConfig, width, height };
1207
+ applyPanZoomScales(snapshot);
1208
+ const legendDimAlpha = interactionsCfg.legend ? interactionsCfg.legend.dimAlpha : undefined;
1209
+
1210
+ notifyTransitionsOnChange();
1211
+
1212
+ const out = runPipeline(snapshot, dataSnapshot, axisLayer, marksLayer, hudLayer, atlas, {
1213
+ // GPU-dim geoms (bar/histogram/tile/line/...) do NOT read `hovered` — their
1214
+ // dim-others treatment rides the animated emphasis uniform (P5-T3), keyed
1215
+ // at compile time. `hovered` is still threaded for the deliberately-inert
1216
+ // nearestX geoms (`area`/`rolling`) whose compile-time halo only updates on
1217
+ // a full frame. Point's focus halo rides the overlay decorator, not
1218
+ // compile-time `hovered`.
1219
+ hovered: hoverEmphasisEnabled() ? hoverSignal.peek() : undefined,
1220
+ selected: grammarSelection ? selectionSignal.peek() : undefined,
1221
+ hidden: hiddenSignal.peek(),
1222
+ legendDimAlpha,
1223
+ transitions: grammarTransitions ?? undefined,
1224
+ transitionKey,
1225
+ });
1226
+
1227
+ // Hand fresh hit-test data to the interactions layer; tooltip ticks are
1228
+ // driven by the rAF loop (see `tick()` below) and tooltip.draw() lays
1229
+ // shapes on the hud layer after the pipeline has finished filling it.
1230
+ applyPipelineFrames(out);
1231
+ // Single sync into the shared hit layer; tooltip + selection observe its
1232
+ // events via subscribe(). `selection.sync` still runs to refresh its
1233
+ // id→position registry (needed for sticky selections across re-renders),
1234
+ // but no longer manages PointCloudNodes itself.
1235
+ if (hitLayer) {
1236
+ hitLayer.sync(out.hitTests);
1237
+ }
1238
+ // Series-readouts pull from the same compiled hit-tests + the live scale
1239
+ // bundle (color scale → series swatch). Push on every draw so the panel
1240
+ // tracks data / scale changes alongside the chart.
1241
+ latestScales = out.scales;
1242
+ latestHoverDecorators = out.hoverDecorators;
1243
+ latestEmphasisResolvers = out.emphasisResolvers;
1244
+ if (attachedSeriesReadouts.size > 0) {
1245
+ for (const r of attachedSeriesReadouts) r.syncHits(out.hitTests);
1246
+ }
1247
+ grammarTooltip?.syncHits?.(out.hitTests);
1248
+ if (grammarSelection) {
1249
+ grammarSelection.sync(out.hitTests);
1250
+ }
1251
+ if (grammarBrush) {
1252
+ grammarBrush.sync(out.hitTests);
1253
+ }
1254
+ if (attachedBrush) {
1255
+ attachedBrush.syncHits(out.hitTests);
1256
+ }
1257
+ if (grammarLegend) {
1258
+ grammarLegend.sync(out);
1259
+ }
1260
+ // Emit the cursor overlays onto their own layer (above the hud).
1261
+ emitOverlays();
1262
+ lastOverlayRect = overlayRect();
1263
+
1264
+ // Background follows the resolved config unless overridden — only when we
1265
+ // own the renderer. Re-apply each frame because the builder may have
1266
+ // produced a chart with a different theme.
1267
+ if (ownedRenderer) {
1268
+ renderer.setBackground(
1269
+ backgroundOverride ?? activeConfig.background ?? activeConfig.theme.background,
1270
+ );
1271
+ }
1272
+
1273
+ // Flip the static layers' cache hint based on the live interaction state
1274
+ // (never while panning/flinging, auto when settled) — the core's per-frame
1275
+ // cache policy reads `Layer.cache` on the next render() and bakes/unbakes
1276
+ // accordingly. No manual cacheLayer/uncacheLayer here: the policy owns it.
1277
+ applyInteractionCacheHints();
1278
+
1279
+ // A normal full frame. NEVER `fullFrame:true` — the core's view fingerprint
1280
+ // (camera/dpr/size/background + `viewKey`) plus its force-full-after-(un)bake
1281
+ // logic decides. With no `regions` this is a full frame by construction; a
1282
+ // (re)bake triggered by the policy this frame is automatically promoted to
1283
+ // full. `viewKey` folds the visible domain so a pan-driven domain shift on
1284
+ // these ui-space layers (whose camera never moves) still forces a full
1285
+ // repaint rather than compositing against a stale bake.
1286
+ // Static debug metadata on the marks layer (P5-T1, D7): the geom kinds in
1287
+ // compile order, refreshed each full frame. Read only when the probe is
1288
+ // enabled; assigning a small array on the full-frame path is irrelevant to
1289
+ // rendering. Reference-assigned (not cloned) — the core copies the reference.
1290
+ marksLayer.debugData = { geoms: activeConfig.layers.map((g) => g.kind) };
1291
+
1292
+ // Resolve the annotation reason: a live pan/zoom gesture overrides the
1293
+ // latched cause; otherwise use whatever the invalidation source set (or the
1294
+ // "invalidate" default). Reset the latch afterwards so a later unclassified
1295
+ // invalidation reports "invalidate" rather than a stale cause.
1296
+ const interacting = !!panZoomBinding && (panZoomBinding.interacting || panZoomBinding.flinging);
1297
+ const reason = interacting ? "pan-zoom" : pendingFullReason;
1298
+ pendingFullReason = "invalidate";
1299
+
1300
+ renderer.render(currentLayers(), {
1301
+ ...(partial ? { viewKey: currentViewKey() } : {}),
1302
+ debug: { reason },
1303
+ });
1304
+ }
1305
+
1306
+ // Overlay-only repaint (partial mode only). The baked static stack (axis /
1307
+ // marks / hud) is untouched in the persistent backbuffer; re-emit just the
1308
+ // overlay layer and DAMAGE-repaint the union of its previous + current
1309
+ // footprints via `regions`. The core scissors the partial frame (OIT-aware,
1310
+ // Phase 1), so the live overlay composites over the baked stack inside the
1311
+ // damage rect with correct transparency.
1312
+ //
1313
+ // `viewKey` MUST match the value the last `drawFull` used so the core's view
1314
+ // fingerprint is unchanged across this overlay-only frame — otherwise the core
1315
+ // demotes to a full repaint. `currentViewKey()` folds only the visible domain,
1316
+ // which doesn't change on an overlay-only frame, so it is stable here.
1317
+ //
1318
+ // OIT note: plot's renderer runs with OIT ON (`createRenderer` default — the
1319
+ // mount does not pass `oit:false`), so a glyph-bearing overlay (tooltip text)
1320
+ // stays a partial frame. (On a NON-OIT renderer the core self-demotes live
1321
+ // MSDF-glyph partial frames to full — correct, just less optimal — but that
1322
+ // path is unreachable here.)
1323
+ function drawOverlay(): void {
1324
+ if (!fontReady) return;
1325
+ overlayDirty = false;
1326
+ emitOverlays();
1327
+ const next = overlayRect();
1328
+ const region = unionRect(lastOverlayRect, next);
1329
+ lastOverlayRect = next;
1330
+ // Nothing visible changed on the overlay (e.g. a hover with no tooltip) —
1331
+ // skip rather than fall back to a full clear.
1332
+ if (!region) return;
1333
+ renderer.render(currentLayers(), {
1334
+ regions: [rectToBounds(region)],
1335
+ viewKey: currentViewKey(),
1336
+ // P5-T1 (D7): overlay-only frames are cursor-driven (tooltip / crosshair /
1337
+ // brush / point halo). A single "hover-moved" tag covers them — the cause
1338
+ // isn't cheaply distinguishable at this call site (all overlay sources
1339
+ // funnel through `requestOverlay`), so we don't build plumbing to split it.
1340
+ debug: { reason: "hover-moved" },
1341
+ });
1342
+ }
1343
+
1344
+ // Repaint-only FULL frame (Fix D — emphasis ramp). A full render of the
1345
+ // EXISTING layers with NO `runPipeline` recompile: the marks/axis/hud packs
1346
+ // are byte-identical to the last `drawFull`, so their cache keys are stable
1347
+ // and the core re-uses every existing bake — zero texture allocs / bake passes
1348
+ // — while the changed emphasis uniform (already written by the driver) dims the
1349
+ // composite. It is a FULL render (no `regions`): the uniform is global, so a
1350
+ // partial frame would leave the un-repainted backbuffer showing the old dim
1351
+ // (the P5-T3 soundness rule). Used by the per-tick ramp where the ONLY thing
1352
+ // that changed is the uniform — marks packs are hover-independent. NEVER used
1353
+ // when `inv.dirty` (a real recompile is pending → `drawFull` wins).
1354
+ function drawRepaint(): void {
1355
+ if (!fontReady) return;
1356
+ renderer.render(currentLayers(), {
1357
+ ...(partial ? { viewKey: currentViewKey() } : {}),
1358
+ // P5-T1 (D7): a uniform-only emphasis dim ramp step (no recompile).
1359
+ debug: { reason: "emphasis-ramp" },
1360
+ });
1361
+ }
1362
+
1363
+ // -----------------------------------------------------------------------
1364
+ // RAF tick
1365
+ // -----------------------------------------------------------------------
1366
+
1367
+ let rafId = 0;
1368
+ let lastFrameTime = 0;
1369
+ function tick(time: number): void {
1370
+ if (disposed) return;
1371
+ const dtSeconds = lastFrameTime === 0 ? 0 : Math.max(0, (time - lastFrameTime) / 1000);
1372
+ lastFrameTime = time;
1373
+ // Tick interaction animations even when nothing else is dirty — the
1374
+ // tooltip's fade/show-delay needs frames to advance.
1375
+ if (grammarTooltip && dtSeconds > 0) grammarTooltip.step(dtSeconds);
1376
+ if (grammarContextMenu && dtSeconds > 0) grammarContextMenu.step(dtSeconds);
1377
+ if (grammarTransitions && dtSeconds > 0) {
1378
+ if (grammarTransitions.step(dtSeconds)) inv.invalidate();
1379
+ }
1380
+ // Advance the GPU emphasis dim toward its target via the driver. The driver
1381
+ // writes the eased uniform and reports whether a (full) frame is needed. A
1382
+ // ramp step changes ONLY the uniform — the marks packs are hover-independent
1383
+ // — so we DON'T `inv.invalidate()` (which would `drawFull` → `runPipeline`,
1384
+ // bumping pack versions and re-baking the auto-cached glyph axis ~8x/ramp).
1385
+ // Instead we flag a REPAINT-ONLY full frame (`drawRepaint`): a full render of
1386
+ // the unchanged packs with stable cache keys → zero re-bakes. Still a FULL
1387
+ // render (never regions): the uniform is global. The loop stops cleanly once
1388
+ // settled (the driver returns no frame when `t === target`). A real
1389
+ // invalidation mid-ramp (resize / data / settle below) sets `inv.dirty` and
1390
+ // wins over the repaint via the `needsFull` ordering at the bottom of tick().
1391
+ if (emphasisAnimating()) {
1392
+ const req = emphasis.step(time);
1393
+ if (req.needsFrame) emphasisRepaintPending = true; // req.full is always true here
1394
+ }
1395
+ // Settle edge: detect the interacting → settled transition and force one
1396
+ // final full frame so `applyInteractionCacheHints()` flips the static layers
1397
+ // back to "auto" and the core's policy re-bakes the now-static stack. Without
1398
+ // this, drag-end (no fling) fires no `onChange`, so the last full frame ran
1399
+ // while still interacting (hints "never", live) and the stack would never
1400
+ // re-bake — every later overlay frame would composite against live geometry
1401
+ // it can't damage-track minimally.
1402
+ if (partial) {
1403
+ const nowInteracting =
1404
+ !!panZoomBinding && (panZoomBinding.interacting || panZoomBinding.flinging);
1405
+ if (wasInteracting && !nowInteracting) {
1406
+ pendingFullReason = "settle-rebake";
1407
+ inv.invalidate();
1408
+ }
1409
+ wasInteracting = nowInteracting;
1410
+ }
1411
+ // Frame-kind dispatch, strongest-wins:
1412
+ // 1. `inv.dirty` → drawFull (recompile + bake + render). A real
1413
+ // change (resize / data / settle / hover hit-change) ALWAYS wins, even
1414
+ // mid-ramp, so a resize/data invalidation during the dim animation is
1415
+ // serviced correctly.
1416
+ // 2. `emphasisRepaintPending` → drawRepaint (FULL render, NO recompile): a
1417
+ // uniform-only emphasis ramp step. Stable packs → zero re-bakes (Fix D).
1418
+ // 3. `overlayDirty` → drawOverlay (regions/partial). Only reached
1419
+ // when the emphasis uniform is SETTLED (an active ramp sets (2), which
1420
+ // is full and outranks this) — preserving the P5-T3 soundness rule that
1421
+ // a partial frame never runs while the global uniform is mid-transition.
1422
+ const needsFull = inv.dirty;
1423
+ const needsRepaint = emphasisRepaintPending;
1424
+ const needsOverlay = partial && overlayDirty;
1425
+ if (needsFull || needsRepaint || needsOverlay) {
1426
+ const hidden = pauseOnHidden && typeof document !== "undefined" && document.hidden;
1427
+ if (!hidden) {
1428
+ if (needsFull) drawFull();
1429
+ else if (needsRepaint) drawRepaint();
1430
+ else drawOverlay();
1431
+ }
1432
+ // The repaint flag is consumed by ANY frame this tick (a winning drawFull
1433
+ // already painted the current uniform; a drawRepaint serviced it directly).
1434
+ emphasisRepaintPending = false;
1435
+ }
1436
+ rafId = requestAnimationFrame(tick);
1437
+ }
1438
+ if (autoFrame) {
1439
+ rafId = requestAnimationFrame(tick);
1440
+ }
1441
+
1442
+ // -----------------------------------------------------------------------
1443
+ // Public handle
1444
+ // -----------------------------------------------------------------------
1445
+
1446
+ return {
1447
+ invalidate(): void {
1448
+ inv.invalidate();
1449
+ },
1450
+ update(source) {
1451
+ if (typeof source === "function") {
1452
+ builder = source as () => Chart<T>;
1453
+ } else if (source) {
1454
+ builder = null;
1455
+ activeConfig = configOf(source);
1456
+ bindDataSignal();
1457
+ }
1458
+ // Clear stale index-derived emphasis (Fix C) before the recompile — a held
1459
+ // hover would otherwise keep dimming re-indexed instances.
1460
+ resetEmphasisForData();
1461
+ pendingFullReason = "data-changed";
1462
+ // Pass-through (no source) → just rebuild via the existing builder/spec.
1463
+ inv.invalidate();
1464
+ // If autoFrame is off, draw synchronously so `update()` is the user's
1465
+ // single render call.
1466
+ if (!autoFrame && !disposed) drawFull();
1467
+ },
1468
+ setData(next): void {
1469
+ // Replace activeConfig.data with a frozen snapshot. Signal subscriptions
1470
+ // are torn down because the user just took manual control of the data.
1471
+ unsubscribeData?.();
1472
+ unsubscribeData = null;
1473
+ dataSnapshot = next;
1474
+ activeConfig = { ...activeConfig, data: next };
1475
+ // Clear stale index-derived emphasis (Fix C): the new data re-indexes
1476
+ // instances, so a held hover's old focused key must drop before the redraw.
1477
+ resetEmphasisForData();
1478
+ pendingFullReason = "data-changed";
1479
+ inv.invalidate();
1480
+ },
1481
+ resize(w, h): void {
1482
+ // Manual resize. If autoResize is on you don't usually need this, but
1483
+ // it's a useful escape hatch (e.g. forcing a fixed export size).
1484
+ if (w !== undefined) width = w;
1485
+ if (h !== undefined) height = h;
1486
+ if (w === undefined && h === undefined) {
1487
+ const r = canvas.getBoundingClientRect();
1488
+ width = r.width || canvas.clientWidth || width;
1489
+ height = r.height || canvas.clientHeight || height;
1490
+ }
1491
+ dpr = opts.dpr ?? readDpr();
1492
+ applySize();
1493
+ pendingFullReason = "resize";
1494
+ inv.invalidate();
1495
+ },
1496
+ setBackground(color): void {
1497
+ backgroundOverride = color;
1498
+ inv.invalidate();
1499
+ },
1500
+ toSVG(svgOpts) {
1501
+ // Re-runs the pipeline against fresh layers and an SVGRenderer. Reuses
1502
+ // the live atlas so axis labels render. We deliberately use scratch
1503
+ // layers so the on-screen layers aren't disturbed for a frame.
1504
+ const w = svgOpts?.width ?? width;
1505
+ const h = svgOpts?.height ?? height;
1506
+ const svg = createSVGRenderer({ width: w, height: h, dpr: 1 });
1507
+ // Scratch v3 layers carrying the live atlas so axis labels / glyphs export
1508
+ // (v3 has no `renderer.createLayer`/`font`; the atlas is externalized).
1509
+ const a = createLayer({ space: "ui", atlas });
1510
+ const m = createLayer({ space: "ui", atlas });
1511
+ const u = createLayer({ space: "ui", atlas });
1512
+ // Resolve a concrete config for export — re-run the builder so settings
1513
+ // closures pick up the live values.
1514
+ const exportConfig = builder ? configOf(builder()) : activeConfig;
1515
+ const exportData = builder ? currentData(exportConfig.data) : dataSnapshot;
1516
+ const snapshot: ChartConfig<T> = { ...exportConfig, width: w, height: h };
1517
+ runPipeline(snapshot, exportData, a, m, u, atlas);
1518
+ svg.setBackground(
1519
+ backgroundOverride ?? exportConfig.background ?? exportConfig.theme.background,
1520
+ );
1521
+ svg.render([a, m, u]);
1522
+ // v3's `element()` is a method (was a getter property on v1's SVGRenderer).
1523
+ return svg.element();
1524
+ },
1525
+ destroy(): void {
1526
+ if (disposed) return;
1527
+ disposed = true;
1528
+ if (autoFrame && rafId) cancelAnimationFrame(rafId);
1529
+ unsubscribeData?.();
1530
+ resizeObserver?.disconnect();
1531
+ if (visibleHandler && typeof document !== "undefined") {
1532
+ document.removeEventListener("visibilitychange", visibleHandler);
1533
+ }
1534
+ if (pendingHoverNullHandle !== null) {
1535
+ clearTimeout(pendingHoverNullHandle);
1536
+ pendingHoverNullHandle = null;
1537
+ }
1538
+ cleanupCursorTracking?.();
1539
+ grammarTooltip?.dispose();
1540
+ grammarCrosshair?.dispose();
1541
+ grammarSelection?.dispose();
1542
+ grammarBrush?.dispose();
1543
+ attachedBrush?.dispose();
1544
+ attachedBrush = null;
1545
+ grammarLegend?.dispose();
1546
+ grammarContextMenu?.dispose();
1547
+ hitLayer?.dispose();
1548
+ manager?.destroy();
1549
+ for (const a of attachedPresets) a.dispose();
1550
+ attachedPresets.clear();
1551
+ for (const r of attachedSeriesReadouts) r.dispose();
1552
+ attachedSeriesReadouts.clear();
1553
+ panZoomUnsub?.();
1554
+ panZoomBinding?.destroy();
1555
+ inv.dispose();
1556
+ // Drop external subscribers held against the public mount handle so
1557
+ // their closures stop pinning mount internals after teardown.
1558
+ hoverSignal.dispose();
1559
+ selectionSignal.dispose();
1560
+ brushedSignal.dispose();
1561
+ hiddenSignal.dispose();
1562
+ // Only destroy what we created. External renderer/atlas/layers belong
1563
+ // to the caller.
1564
+ if (ownedRenderer) renderer.destroy();
1565
+ if (ownedLayers) {
1566
+ axisLayer.destroy();
1567
+ marksLayer.destroy();
1568
+ hudLayer.destroy();
1569
+ overlayLayer.destroy();
1570
+ }
1571
+ },
1572
+
1573
+ // Escape hatches
1574
+ get renderer() {
1575
+ return renderer;
1576
+ },
1577
+ get axisLayer() {
1578
+ return axisLayer;
1579
+ },
1580
+ get marksLayer() {
1581
+ return marksLayer;
1582
+ },
1583
+ get hudLayer() {
1584
+ return hudLayer;
1585
+ },
1586
+ get overlayLayer() {
1587
+ return overlayLayer;
1588
+ },
1589
+ get invalidator() {
1590
+ return inv;
1591
+ },
1592
+ get hovered() {
1593
+ // Narrow to the read-only view so handle consumers can't mutate the
1594
+ // hover state directly. Mutation flows through the pointer pipeline.
1595
+ return {
1596
+ get: () => hoverSignal.get(),
1597
+ peek: () => hoverSignal.peek(),
1598
+ subscribe: (fn: (v: HoveredHit | null) => void) => hoverSignal.subscribe(fn),
1599
+ };
1600
+ },
1601
+ get selected() {
1602
+ // Read-only view + a `clear()` escape hatch (e.g., on Escape key).
1603
+ // Mutation otherwise flows through the click pipeline.
1604
+ return {
1605
+ get: () => selectionSignal.get(),
1606
+ peek: () => selectionSignal.peek(),
1607
+ subscribe: (fn: (v: readonly HoveredHit[]) => void) => selectionSignal.subscribe(fn),
1608
+ clear: () => grammarSelection?.clear(),
1609
+ };
1610
+ },
1611
+ get brushed() {
1612
+ return {
1613
+ get: () => brushedSignal.get(),
1614
+ peek: () => brushedSignal.peek(),
1615
+ subscribe: (fn: (v: readonly HoveredHit[]) => void) => brushedSignal.subscribe(fn),
1616
+ clear: () => grammarBrush?.clear(),
1617
+ };
1618
+ },
1619
+ get hidden() {
1620
+ return {
1621
+ get: () => hiddenSignal.get(),
1622
+ peek: () => hiddenSignal.peek(),
1623
+ subscribe: (fn: (v: ReadonlySet<string>) => void) => hiddenSignal.subscribe(fn),
1624
+ clear: () => {
1625
+ hiddenSignal.set(new Set());
1626
+ inv.invalidate();
1627
+ },
1628
+ };
1629
+ },
1630
+
1631
+ // Stats
1632
+ get width() {
1633
+ return width;
1634
+ },
1635
+ get height() {
1636
+ return height;
1637
+ },
1638
+ get dpr() {
1639
+ return dpr;
1640
+ },
1641
+ get shapeCount() {
1642
+ return (
1643
+ axisLayer.shapeCount + marksLayer.shapeCount + hudLayer.shapeCount + overlayLayer.shapeCount
1644
+ );
1645
+ },
1646
+ get triangleCount() {
1647
+ return (
1648
+ axisLayer.triangleCount +
1649
+ marksLayer.triangleCount +
1650
+ hudLayer.triangleCount +
1651
+ overlayLayer.triangleCount
1652
+ );
1653
+ },
1654
+ get needsFrame() {
1655
+ return inv.dirty || (partial && overlayDirty);
1656
+ },
1657
+ get plotFrame() {
1658
+ return chartPlotFrame;
1659
+ },
1660
+ get viewport() {
1661
+ return panZoomViewport;
1662
+ },
1663
+ attachSeriesReadout(readoutOpts: AttachSeriesReadoutOptions): AttachedSeriesReadout {
1664
+ // Series-readout needs an InteractionManager — auto-create one when the
1665
+ // mount didn't already build one for a tooltip/selection/etc. Same for
1666
+ // the shared hit-layer: high-z hit-cloud events keep the readout's
1667
+ // `cursorX` populated while the cursor is over a real mark.
1668
+ if (!manager) {
1669
+ manager = createInteractionManager(canvas);
1670
+ manager.onChange(() => requestOverlay());
1671
+ }
1672
+ if (!hitLayer) {
1673
+ hitLayer = createGrammarHitLayer({ manager, element: canvas });
1674
+ }
1675
+ const readout = createSeriesReadout(
1676
+ {
1677
+ manager,
1678
+ bounds: () => chartPlotFrame,
1679
+ scales: () => latestScales,
1680
+ theme: () => activeConfig.theme,
1681
+ invalidator: inv,
1682
+ hitLayer,
1683
+ },
1684
+ readoutOpts,
1685
+ );
1686
+ attachedSeriesReadouts.add(readout);
1687
+ // Force a draw so `out.hitTests` flows into the freshly attached readout
1688
+ // (and a freshly created hit-layer, if any). Without this the panel
1689
+ // stays empty until the next pointer move / data change.
1690
+ inv.invalidate();
1691
+ return {
1692
+ peek: () => readout.peek(),
1693
+ subscribe: (fn) => readout.subscribe(fn),
1694
+ dispose: () => {
1695
+ readout.dispose();
1696
+ attachedSeriesReadouts.delete(readout);
1697
+ },
1698
+ };
1699
+ },
1700
+ attachBrush(brushOpts: AttachBrushOptions): AttachedBrush {
1701
+ if (grammarBrush !== null || attachedBrush !== null) {
1702
+ throw new Error(
1703
+ "attachBrush: a brush is already configured on this mount " +
1704
+ "(via `interactions.brush` or a prior `attachBrush` call). " +
1705
+ "Dispose the existing brush before attaching another.",
1706
+ );
1707
+ }
1708
+ // Same lazy-create pattern as `attachSeriesReadout` — a chart with no
1709
+ // other interactions can still attach a brush after mount.
1710
+ if (!manager) {
1711
+ manager = createInteractionManager(canvas);
1712
+ manager.onChange(() => requestOverlay());
1713
+ }
1714
+ const attached = createAttachedBrush(
1715
+ {
1716
+ manager,
1717
+ // Brush operates on the chart-wide plot frame (matches the
1718
+ // `interactions.brush` path).
1719
+ bounds: () => chartPlotFrame,
1720
+ hudLayer: () => overlayLayer,
1721
+ theme: () => activeConfig.theme,
1722
+ invalidator: overlayInv,
1723
+ },
1724
+ {
1725
+ ...brushOpts,
1726
+ onSelect: (hits) => {
1727
+ // Fan-out to both the mount-level signal (so `MountedPlot.brushed`
1728
+ // observers see the same payload as the declarative brush path)
1729
+ // and the user's onSelect callback.
1730
+ brushedSignal.set(hits);
1731
+ brushOpts.onSelect?.(hits);
1732
+ requestOverlay();
1733
+ },
1734
+ },
1735
+ );
1736
+ attachedBrush = attached;
1737
+ inv.invalidate();
1738
+ return {
1739
+ peek: () => brushedSignal.peek(),
1740
+ rect: () => attached.rect(),
1741
+ subscribe: (fn) => brushedSignal.subscribe(fn),
1742
+ clear: () => attached.clear(),
1743
+ dispose: () => {
1744
+ attached.dispose();
1745
+ if (attachedBrush === attached) attachedBrush = null;
1746
+ if (brushedSignal.peek().length > 0) brushedSignal.set([]);
1747
+ },
1748
+ };
1749
+ },
1750
+ pickAt(canvasX: number, canvasY: number) {
1751
+ return screenToData(canvasX, canvasY, {
1752
+ frame: chartPlotFrame,
1753
+ scales: latestScales,
1754
+ coord: activeConfig.coord,
1755
+ });
1756
+ },
1757
+ attachRangePresets(presetOpts: AttachRangePresetsOptions): AttachedRangePresets {
1758
+ if (!panZoomViewport) {
1759
+ throw new Error(
1760
+ "attachRangePresets: panZoom must be enabled on mount() to drive a range-preset controller.",
1761
+ );
1762
+ }
1763
+ const attached = attachRangePresetsHelper(panZoomViewport, activeConfig.theme, presetOpts);
1764
+ attachedPresets.add(attached);
1765
+ return {
1766
+ setActive: (k) => attached.setActive(k),
1767
+ getActive: () => attached.getActive(),
1768
+ subscribe: (fn) => attached.subscribe(fn),
1769
+ dispose: () => {
1770
+ attached.dispose();
1771
+ attachedPresets.delete(attached);
1772
+ },
1773
+ };
1774
+ },
1775
+
1776
+ // Transitions handle
1777
+ get transitions() {
1778
+ return {
1779
+ requestTransition: () => {
1780
+ if (!activeConfig.theme.motion.enabled) return;
1781
+ grammarTransitions?.requestTransition();
1782
+ inv.invalidate();
1783
+ },
1784
+ };
1785
+ },
1786
+ };
1787
+ }
1788
+
1789
+ // ---------------------------------------------------------------------------
1790
+ // Helpers
1791
+ // ---------------------------------------------------------------------------
1792
+
1793
+ /**
1794
+ * Charts produced by `plot()` carry their frozen `ChartConfig` on a non-
1795
+ * enumerable internal property (`__config__`). The mount needs that config
1796
+ * to compile the spec, but we don't want it visible on the public `Chart<T>`
1797
+ * interface — the export here keeps the cast in one place.
1798
+ */
1799
+ interface ChartWithConfig<T> extends Chart<T> {
1800
+ readonly __config__: ChartConfig<T>;
1801
+ }
1802
+
1803
+ function configOf<T>(chart: Chart<T>): ChartConfig<T> {
1804
+ const c = (chart as ChartWithConfig<T>).__config__;
1805
+ if (!c) {
1806
+ throw new Error(
1807
+ "Chart is missing internal config — was it produced by plot()? " +
1808
+ "If you're constructing a chart manually, ensure makeChart() is used.",
1809
+ );
1810
+ }
1811
+ return c;
1812
+ }
1813
+
1814
+ function readDpr(): number {
1815
+ if (typeof window !== "undefined" && typeof window.devicePixelRatio === "number") {
1816
+ return window.devicePixelRatio || 1;
1817
+ }
1818
+ return 1;
1819
+ }
1820
+
1821
+ interface ResolvedPanZoom {
1822
+ minZoom: number;
1823
+ maxZoom: number;
1824
+ panBounds: DataPanBoundsOptions | undefined;
1825
+ pan: AxisSelection;
1826
+ zoom: AxisSelection;
1827
+ /** Half-padding fraction applied around the visible-Y extent, or null when yFit is off. */
1828
+ yFitPadding: number | null;
1829
+ }
1830
+
1831
+ function resolvePanZoom(input: boolean | PanZoomConfig | undefined): ResolvedPanZoom | null {
1832
+ if (!input) return null;
1833
+ const cfg: PanZoomConfig = input === true ? {} : input;
1834
+
1835
+ // yFit forces X-only pan/zoom — Y becomes derived state.
1836
+ const yFit = cfg.yFit;
1837
+ let yFitPadding: number | null = null;
1838
+ if (yFit) {
1839
+ const raw = yFit === true ? DEFAULT_Y_FIT_PADDING : (yFit.padding ?? DEFAULT_Y_FIT_PADDING);
1840
+ if (!Number.isFinite(raw) || raw < 0 || raw > 0.5) {
1841
+ throw new Error(`panZoom.yFit.padding must be in [0, 0.5] (got ${raw}).`);
1842
+ }
1843
+ yFitPadding = raw;
1844
+ }
1845
+
1846
+ const pan: AxisSelection = yFitPadding !== null ? "x" : (cfg.pan ?? "xy");
1847
+ const zoom: AxisSelection = yFitPadding !== null ? "x" : (cfg.zoom ?? "xy");
1848
+
1849
+ const panBounds: DataPanBoundsOptions | undefined =
1850
+ cfg.panBounds === false ? undefined : (cfg.panBounds ?? DEFAULT_PAN_BOUNDS);
1851
+
1852
+ return {
1853
+ minZoom: cfg.minZoom ?? 1,
1854
+ maxZoom: cfg.maxZoom ?? 100,
1855
+ panBounds,
1856
+ pan,
1857
+ zoom,
1858
+ yFitPadding,
1859
+ };
1860
+ }
1861
+
1862
+ /**
1863
+ * Walk each layer's x/y accessors, find data points whose X falls inside
1864
+ * `visibleX`, and return `[yMin - pad, yMax + pad]`. Returns `null` when no
1865
+ * layer / no point falls in range — callers should keep the previous Y
1866
+ * window rather than collapsing.
1867
+ */
1868
+ function computeVisibleYExtent<T>(
1869
+ layers: ReadonlyArray<{ channels: { x?: unknown; y?: unknown } }>,
1870
+ data: readonly T[],
1871
+ visibleX: readonly [number, number],
1872
+ padding: number,
1873
+ ): readonly [number, number] | null {
1874
+ if (data.length === 0 || layers.length === 0) return null;
1875
+ const lo = Math.min(visibleX[0], visibleX[1]);
1876
+ const hi = Math.max(visibleX[0], visibleX[1]);
1877
+ let yMin = Infinity;
1878
+ let yMax = -Infinity;
1879
+ let seen = false;
1880
+ for (const layer of layers) {
1881
+ const cx = layer.channels.x;
1882
+ const cy = layer.channels.y;
1883
+ if (cx === undefined || cy === undefined) continue;
1884
+ // Array-shaped channels (stacked / multi-series) — skip; yFit on a stack
1885
+ // would need separate per-series logic and isn't supported in v1.
1886
+ if (Array.isArray(cx) || Array.isArray(cy)) continue;
1887
+ const ax = resolveAes<T, unknown>(cx as Aes<T, unknown>);
1888
+ const ay = resolveAes<T, unknown>(cy as Aes<T, unknown>);
1889
+ for (let i = 0; i < data.length; i++) {
1890
+ const xv = toNumeric(ax.fn(data[i]!, i));
1891
+ if (xv === null || xv < lo || xv > hi) continue;
1892
+ const yv = toNumeric(ay.fn(data[i]!, i));
1893
+ if (yv === null) continue;
1894
+ if (yv < yMin) yMin = yv;
1895
+ if (yv > yMax) yMax = yv;
1896
+ seen = true;
1897
+ }
1898
+ }
1899
+ if (!seen) return null;
1900
+ if (yMin === yMax) {
1901
+ // Singleton — pad against an absolute baseline so the axis has range.
1902
+ const pad = Math.abs(yMin) * padding || 1;
1903
+ return [yMin - pad, yMax + pad];
1904
+ }
1905
+ const span = yMax - yMin;
1906
+ const pad = span * padding;
1907
+ return [yMin - pad, yMax + pad];
1908
+ }
1909
+
1910
+ function toNumeric(v: unknown): number | null {
1911
+ if (typeof v === "number") return Number.isFinite(v) ? v : null;
1912
+ if (v instanceof Date) {
1913
+ const t = v.getTime();
1914
+ return Number.isFinite(t) ? t : null;
1915
+ }
1916
+ return null;
1917
+ }
1918
+
1919
+ /**
1920
+ * Screen → data primitive backing `MountedPlot.pickAt`. Extracted so it can be
1921
+ * unit-tested without spinning up a full mount (WebGPU device, layers, etc.).
1922
+ *
1923
+ * Returns `null` when the point is outside the frame, when `coord.unproject`
1924
+ * rejects the point (polar outside the radius band), when scales are not yet
1925
+ * available (pre-first-draw), or when either position scale lacks `invert`
1926
+ * (band scales — band → continuous picking is a separate problem).
1927
+ */
1928
+ function screenToData(
1929
+ canvasX: number,
1930
+ canvasY: number,
1931
+ ctx: {
1932
+ frame: Frame;
1933
+ scales: import("./geoms/types.ts").ScaleBundle | null;
1934
+ coord: Coord;
1935
+ },
1936
+ ): {
1937
+ plotFrameX: number;
1938
+ plotFrameY: number;
1939
+ dataX: number;
1940
+ dataY: number;
1941
+ frame: Frame;
1942
+ } | null {
1943
+ const { frame, scales, coord } = ctx;
1944
+ if (frame.width <= 0 || frame.height <= 0) return null;
1945
+ if (!scales) return null;
1946
+ const xAxis = scales.x.axisScale as { invert?: (v: number) => unknown };
1947
+ const yAxis = scales.y.axisScale as { invert?: (v: number) => unknown };
1948
+ if (typeof xAxis.invert !== "function" || typeof yAxis.invert !== "function") {
1949
+ return null;
1950
+ }
1951
+ // Plot-frame-relative pixel position.
1952
+ const pfx = canvasX - frame.x;
1953
+ const pfy = canvasY - frame.y;
1954
+ // Outside the frame → no hit.
1955
+ if (pfx < 0 || pfx > frame.width || pfy < 0 || pfy > frame.height) return null;
1956
+ // Route through the active coord's unproject. For Cartesian this is the
1957
+ // identity; for polar/radial it inverts the projection and returns null
1958
+ // outside `[innerRadius, outerRadius]`. The pipeline ensures the coord is
1959
+ // `bindFrame`-bound to the current `plotFrame` before each draw, so its
1960
+ // internal centre/outerR match what's on screen.
1961
+ const unprojected = coord.unproject({ x: pfx, y: pfy });
1962
+ if (!unprojected) return null;
1963
+ const dataX = xAxis.invert(unprojected.x) as number;
1964
+ const dataY = yAxis.invert(unprojected.y) as number;
1965
+ if (!Number.isFinite(dataX) || !Number.isFinite(dataY)) return null;
1966
+ return {
1967
+ plotFrameX: unprojected.x,
1968
+ plotFrameY: unprojected.y,
1969
+ dataX,
1970
+ dataY,
1971
+ frame,
1972
+ };
1973
+ }
1974
+
1975
+ /** @internal — exposed for unit tests only. */
1976
+ export const __test__ = {
1977
+ resolvePanZoom,
1978
+ computeVisibleYExtent,
1979
+ wrapViewportThroughCoord,
1980
+ screenToData,
1981
+ };
1982
+
1983
+ interface ResolvedInteractions {
1984
+ tooltip: false | TooltipConfig;
1985
+ crosshair: false | CrosshairConfig;
1986
+ selection: false | SelectionConfig;
1987
+ brush: false | BrushConfig;
1988
+ legend: false | LegendInteractionConfig;
1989
+ contextMenu: false | ContextMenuConfig;
1990
+ }
1991
+
1992
+ function resolveInteractions(
1993
+ input: MountPlotOptions<unknown>["interactions"],
1994
+ ): ResolvedInteractions {
1995
+ // Selection, brush, and context menu are opt-in: not enabled by `true` /
1996
+ // `undefined` shorthand, because their semantics are app-specific (the
1997
+ // context menu in particular has no useful default without an `onTrigger`).
1998
+ if (input === false) {
1999
+ return {
2000
+ tooltip: false,
2001
+ crosshair: false,
2002
+ selection: false,
2003
+ brush: false,
2004
+ legend: false,
2005
+ contextMenu: false,
2006
+ };
2007
+ }
2008
+ if (input === undefined || input === true) {
2009
+ return {
2010
+ tooltip: {},
2011
+ crosshair: {},
2012
+ selection: false,
2013
+ brush: false,
2014
+ legend: {},
2015
+ contextMenu: false,
2016
+ };
2017
+ }
2018
+ const cfg: InteractionsConfig = input;
2019
+ return {
2020
+ tooltip: resolveDefaultOn(cfg.tooltip),
2021
+ crosshair: resolveDefaultOn(cfg.crosshair),
2022
+ selection: resolveOptIn(cfg.selection),
2023
+ brush: resolveOptIn(cfg.brush),
2024
+ legend: resolveDefaultOn(cfg.legend),
2025
+ // Context menu requires an `onTrigger` to be useful, so we only honor
2026
+ // the object form. Boolean shorthands have no sensible default.
2027
+ contextMenu: cfg.contextMenu ?? false,
2028
+ };
2029
+ }
2030
+
2031
+ // Default-on channels (tooltip/crosshair/legend): undefined or true → {}.
2032
+ function resolveDefaultOn<C>(input: C | boolean | undefined): false | C {
2033
+ if (input === false) return false;
2034
+ if (input === undefined || input === true) return {} as C;
2035
+ return input;
2036
+ }
2037
+
2038
+ // Opt-in channels (selection/brush): undefined or false → off; true → {}.
2039
+ function resolveOptIn<C>(input: C | boolean | undefined): false | C {
2040
+ if (!input) return false;
2041
+ if (input === true) return {} as C;
2042
+ return input;
2043
+ }
2044
+
2045
+ function resolveTransitionsCfg(
2046
+ input: MountPlotOptions<unknown>["transitions"],
2047
+ ): false | TransitionsConfig {
2048
+ if (input === false) return false;
2049
+ if (input === undefined || input === true) return {};
2050
+ return input;
2051
+ }
2052
+
2053
+ const SCALE_OVERRIDE_KEYS = [
2054
+ "x",
2055
+ "y",
2056
+ "color",
2057
+ "size",
2058
+ "alpha",
2059
+ "shape",
2060
+ "borderStyle",
2061
+ "overlayGlyph",
2062
+ ] as const;
2063
+
2064
+ /**
2065
+ * Wrap a `DataViewport` so pan/zoom calls flow through `coord.handlePan` /
2066
+ * `coord.handleZoom`. The wrapper is `Object.create(underlying)` — every
2067
+ * untouched member (state, visibleXDomain, absoluteFrame, setFrame, …)
2068
+ * resolves through the prototype, so the wrapper stays in lockstep with the
2069
+ * underlying `DataViewport` API. Only `panBy` and `zoomAt` are overridden.
2070
+ *
2071
+ * `getCoord` is called at-the-event-time so `update()` swapping the chart
2072
+ * spec (and its `coord`) takes effect on the next interaction without a
2073
+ * remount. `invalidate` fires after each routed call because polar's
2074
+ * `handlePan` may mutate its own `startAngle` without touching the viewport —
2075
+ * which means the viewport's own `onChange` won't fire, and the rAF loop
2076
+ * needs an explicit kick to re-draw the rotated chart.
2077
+ */
2078
+ function wrapViewportThroughCoord<X, Y>(
2079
+ underlying: DataViewport<X, Y>,
2080
+ getCoord: () => Coord,
2081
+ invalidate: () => void,
2082
+ ): DataViewport<X, Y> {
2083
+ const wrapped = Object.create(underlying) as DataViewport<X, Y>;
2084
+ // Build a stable handle the coord uses to mutate scale state. Reading the
2085
+ // bound methods up front avoids `this`-binding surprises since `Object.create`
2086
+ // child lookups would otherwise call them with `this === wrapped` (whose
2087
+ // `panBy` is our override — infinite loop).
2088
+ const handle = {
2089
+ panBy: (dx: number, dy: number) => underlying.panBy(dx, dy),
2090
+ zoomAt: (ax: number, ay: number, f: number | { x?: number; y?: number }) =>
2091
+ underlying.zoomAt(ax, ay, f),
2092
+ };
2093
+ Object.defineProperty(wrapped, "panBy", {
2094
+ value: (dx: number, dy: number) => {
2095
+ getCoord().handlePan({ dx, dy, plotFrame: underlying.frame, viewport: handle });
2096
+ invalidate();
2097
+ },
2098
+ });
2099
+ Object.defineProperty(wrapped, "zoomAt", {
2100
+ value: (anchorSx: number, anchorSy: number, factor: number | { x?: number; y?: number }) => {
2101
+ getCoord().handleZoom({
2102
+ factor,
2103
+ cx: anchorSx,
2104
+ cy: anchorSy,
2105
+ plotFrame: underlying.frame,
2106
+ viewport: handle,
2107
+ });
2108
+ invalidate();
2109
+ },
2110
+ });
2111
+ return wrapped;
2112
+ }