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,107 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Wide → long reshape helpers
3
+ // ---------------------------------------------------------------------------
4
+ // Most of the heatmap references in the gallery start from a 2D matrix or a
5
+ // wide table — `tile()` consumes long rows of `{x, y, value}`. These two
6
+ // helpers cover both shapes without pulling in a dataframe dependency.
7
+
8
+ export interface FromMatrixOptions {
9
+ /** Y labels (rows). Top to bottom. Length must equal `values.length`. */
10
+ rows: readonly string[];
11
+ /** X labels (cols). Left to right. Length must equal `values[0].length`. */
12
+ cols: readonly string[];
13
+ /** Output key for the row label. Default `"row"`. */
14
+ rowKey?: string;
15
+ /** Output key for the col label. Default `"col"`. */
16
+ colKey?: string;
17
+ /** Output key for the cell value. Default `"value"`. */
18
+ valueKey?: string;
19
+ }
20
+
21
+ export type LongRow<R extends string, C extends string, V extends string> = {
22
+ [K in R | C | V]: K extends V ? number : string;
23
+ };
24
+
25
+ /**
26
+ * Convert a 2D `values[row][col]` matrix into long-format rows that the
27
+ * `tile()` geom consumes directly. Cells with `NaN` / `null` / `undefined`
28
+ * are kept (so consumers can opt into NA cell rendering); cells whose
29
+ * coordinates are out of range are skipped.
30
+ *
31
+ * Example:
32
+ * ```ts
33
+ * const long = fromMatrix(temperatures, {
34
+ * rows: ["00:00", "06:00", "12:00", "18:00"],
35
+ * cols: ["Mon", "Tue", "Wed", "Thu", "Fri"],
36
+ * });
37
+ * tile({ x: "col", y: "row", fill: "value" })
38
+ * ```
39
+ */
40
+ export function fromMatrix(
41
+ values: readonly (readonly number[])[],
42
+ opts: FromMatrixOptions,
43
+ ): { row: string; col: string; value: number }[] {
44
+ const rowKey = opts.rowKey ?? "row";
45
+ const colKey = opts.colKey ?? "col";
46
+ const valueKey = opts.valueKey ?? "value";
47
+ const out: { row: string; col: string; value: number }[] = [];
48
+ for (let r = 0; r < opts.rows.length; r++) {
49
+ const row = values[r];
50
+ if (!row) continue;
51
+ const rowLabel = opts.rows[r]!;
52
+ for (let c = 0; c < opts.cols.length; c++) {
53
+ const v = row[c];
54
+ if (v === undefined) continue;
55
+ out.push({
56
+ [rowKey]: rowLabel,
57
+ [colKey]: opts.cols[c]!,
58
+ [valueKey]: v,
59
+ } as { row: string; col: string; value: number });
60
+ }
61
+ }
62
+ return out;
63
+ }
64
+
65
+ export interface PivotLongerOptions<T> {
66
+ /** Output column name for the original key. Default `"name"`. */
67
+ nameKey?: string;
68
+ /** Output column name for the value. Default `"value"`. */
69
+ valueKey?: string;
70
+ /** Optional id columns kept verbatim alongside the long pair. */
71
+ idColumns?: readonly (keyof T & string)[];
72
+ }
73
+
74
+ /**
75
+ * Pivot a wide row to a sequence of long rows — one per `keys` entry.
76
+ * `idColumns` are copied through to every output row so a per-row identifier
77
+ * (e.g. car name in the mtcars example) is preserved.
78
+ *
79
+ * Example:
80
+ * ```ts
81
+ * const long = pivotLonger(mtcars, ["mpg", "cyl", "disp", "hp"], {
82
+ * idColumns: ["model"],
83
+ * });
84
+ * // → [{ model, name: "mpg", value }, { model, name: "cyl", value }, ...]
85
+ * ```
86
+ */
87
+ export function pivotLonger<T extends Record<string, unknown>>(
88
+ rows: readonly T[],
89
+ keys: readonly (keyof T & string)[],
90
+ options: PivotLongerOptions<T> = {},
91
+ ): Record<string, unknown>[] {
92
+ const nameKey = options.nameKey ?? "name";
93
+ const valueKey = options.valueKey ?? "value";
94
+ const idColumns = options.idColumns ?? [];
95
+ const out: Record<string, unknown>[] = [];
96
+ for (const row of rows) {
97
+ for (const key of keys) {
98
+ const entry: Record<string, unknown> = {
99
+ [nameKey]: key,
100
+ [valueKey]: row[key],
101
+ };
102
+ for (const id of idColumns) entry[id] = row[id];
103
+ out.push(entry);
104
+ }
105
+ }
106
+ return out;
107
+ }
@@ -0,0 +1,69 @@
1
+ /** The emphasis uniform write the driver hands back to the renderer. */
2
+ export interface EmphasisState {
3
+ focusedKey: number;
4
+ dimAlpha: number;
5
+ t: number;
6
+ }
7
+ /**
8
+ * What a driver method asks of the host this turn.
9
+ * • `needsFrame` — a frame must be drawn at all.
10
+ * • `full` — that frame MUST be a FULL render (never a regions/overlay-only
11
+ * partial), because the emphasis uniform is in a visible/transitioning state
12
+ * (see SOUNDNESS RULE in the module header). When `needsFrame` is true and
13
+ * `full` is false, an overlay-only repaint is sound.
14
+ */
15
+ export interface FrameRequest {
16
+ needsFrame: boolean;
17
+ full: boolean;
18
+ }
19
+ export interface EmphasisDriverOptions {
20
+ /**
21
+ * Animation duration in seconds. `<= 0` means reduced-motion: the dim snaps
22
+ * instantly (no ramp). Re-read on every `onHover` via `durationS` being a
23
+ * thunk so a live theme/reduced-motion change takes effect immediately.
24
+ */
25
+ durationS: () => number;
26
+ /** Dim alpha (theme.interactions.hover.dim) for the non-focused instances. */
27
+ dim: () => number;
28
+ /** Push an emphasis uniform write to the renderer. */
29
+ setEmphasis: (state: EmphasisState) => void;
30
+ }
31
+ /**
32
+ * The animated-emphasis state machine. Pure aside from the injected
33
+ * `setEmphasis` sink; time is supplied by the caller (deterministic in tests).
34
+ */
35
+ export interface EmphasisDriver {
36
+ /**
37
+ * A hover hit resolved to its focused emphasis key (or `null`/`0` for no dim
38
+ * geom / exit). Updates the target and, under reduced-motion, snaps `t`.
39
+ * Returns the frame the host must draw. The full-frame decision encodes Fix A:
40
+ * ANY snap (enter OR exit) from/to a visibly-dimmed state is a global pixel
41
+ * change → FULL; an exit that was never showing is an ordinary overlay move.
42
+ */
43
+ onHover(focusedKey: number | null): FrameRequest;
44
+ /**
45
+ * Advance the ramp by the time elapsed since the last `step`/`onHover` call.
46
+ * Writes the eased uniform and returns the frame to draw. While transitioning
47
+ * this is always a FULL frame. Returns {@link NO_FRAME} when already settled
48
+ * (so the RAF loop can stop re-invalidating — no perpetual frames).
49
+ */
50
+ step(now: number): FrameRequest;
51
+ /** True while `t` has not reached its target (mid-transition). */
52
+ animating(): boolean;
53
+ /**
54
+ * Snap the uniform to the settled-off state (t = 0, focusedKey = 0) and clear
55
+ * all internal animation state. Used on the DATA path (setData/update): a held
56
+ * hover's stale focused key — index-derived, so it points at re-indexed
57
+ * instances after a data swap — is dropped before the full redraw; the
58
+ * pointer's next move re-establishes emphasis. Idempotent. Does NOT itself
59
+ * request a frame (the data path already forces a full redraw).
60
+ */
61
+ reset(): void;
62
+ /** Internal state, exposed for tests/diagnostics. */
63
+ state(): {
64
+ focusedKey: number;
65
+ t: number;
66
+ target: number;
67
+ };
68
+ }
69
+ export declare function createEmphasisDriver(opts: EmphasisDriverOptions): EmphasisDriver;
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Unit tests for the REAL animated-emphasis driver (`emphasis-driver.ts`).
3
+ *
4
+ * These drive the production state machine directly with a deterministic clock —
5
+ * no hand-copied mirror. The driver is the single source of truth for: the t
6
+ * ramp, the ease-out-cubic write, reduced-motion snap, hover swap, the exit-snap
7
+ * full-frame rule (Fix A), and reset() (Fix C). The mount is a thin consumer that
8
+ * wires `setEmphasis` → renderer and routes `{ needsFrame, full }` into
9
+ * inv/requestOverlay/drawRepaint.
10
+ */
11
+
12
+ import { describe, expect, test } from "vite-plus/test";
13
+
14
+ import {
15
+ createEmphasisDriver,
16
+ type EmphasisDriver,
17
+ type EmphasisState,
18
+ } from "./emphasis-driver.ts";
19
+
20
+ interface Harness {
21
+ driver: EmphasisDriver;
22
+ writes: EmphasisState[];
23
+ setDuration: (s: number) => void;
24
+ }
25
+
26
+ function makeDriver(opts: { durationS?: number; dim?: number } = {}): Harness {
27
+ let durationS = opts.durationS ?? 0.1; // 100ms default
28
+ const dim = opts.dim ?? 0.45;
29
+ const writes: EmphasisState[] = [];
30
+ const driver = createEmphasisDriver({
31
+ durationS: () => durationS,
32
+ dim: () => dim,
33
+ setEmphasis: (s) => writes.push({ ...s }),
34
+ });
35
+ return { driver, writes, setDuration: (s) => (durationS = s) };
36
+ }
37
+
38
+ const FOCUS_A = 1234;
39
+ const FOCUS_B = 5678;
40
+
41
+ describe("emphasis driver — enter / ramp / settle", () => {
42
+ test("enter resolves the key, requests a FULL frame, and starts t at 0", () => {
43
+ const { driver, writes } = makeDriver({ durationS: 0.1, dim: 0.45 });
44
+ const req = driver.onHover(FOCUS_A);
45
+ expect(req).toEqual({ needsFrame: true, full: true });
46
+ expect(driver.state()).toEqual({ focusedKey: FOCUS_A, t: 0, target: 1 });
47
+ expect(writes.at(-1)).toEqual({ focusedKey: FOCUS_A, dimAlpha: 0.45, t: 0 });
48
+ });
49
+
50
+ test("t ramps over durationMs; each step is a FULL frame; settles at 1", () => {
51
+ const { driver, writes } = makeDriver({ durationS: 0.1 });
52
+ driver.onHover(FOCUS_A);
53
+ // First step seeds the clock (advances nothing).
54
+ expect(driver.step(0).needsFrame).toBe(true);
55
+ expect(driver.state().t).toBe(0);
56
+ // 50ms over a 100ms ramp → t 0.5.
57
+ let r = driver.step(50);
58
+ expect(r).toEqual({ needsFrame: true, full: true });
59
+ expect(driver.state().t).toBeCloseTo(0.5, 5);
60
+ expect(driver.animating()).toBe(true);
61
+ // Another 50ms → settled at 1.
62
+ r = driver.step(100);
63
+ expect(driver.state().t).toBeCloseTo(1, 5);
64
+ expect(driver.animating()).toBe(false);
65
+ // The eased write reflects ease-out-cubic of t.
66
+ const last = writes.at(-1)!;
67
+ expect(last.t).toBeCloseTo(1, 5);
68
+ // A step once settled requests no frame (loop stops — no perpetual RAF).
69
+ expect(driver.step(116)).toEqual({ needsFrame: false, full: false });
70
+ });
71
+
72
+ test("the written t is ease-out-cubic of the raw ramp t", () => {
73
+ const { driver, writes } = makeDriver({ durationS: 0.1 });
74
+ driver.onHover(FOCUS_A);
75
+ driver.step(0); // seed
76
+ driver.step(50); // raw t = 0.5
77
+ const eased = 1 - Math.pow(1 - 0.5, 3); // 0.875
78
+ expect(writes.at(-1)!.t).toBeCloseTo(eased, 5);
79
+ });
80
+ });
81
+
82
+ describe("emphasis driver — exit", () => {
83
+ test("exit from a shown dim ramps t→0 (FULL frames) then clears the key", () => {
84
+ const { driver } = makeDriver({ durationS: 0.1 });
85
+ driver.onHover(FOCUS_A);
86
+ driver.step(0);
87
+ driver.step(100); // settle on
88
+ const req = driver.onHover(null);
89
+ expect(req).toEqual({ needsFrame: true, full: true });
90
+ expect(driver.state().target).toBe(0);
91
+ driver.step(100); // seed clock at exit's first step
92
+ driver.step(150); // 50ms → t 0.5
93
+ expect(driver.state().t).toBeCloseTo(0.5, 5);
94
+ expect(driver.state().focusedKey).toBe(FOCUS_A); // held until settled
95
+ driver.step(200); // → t 0
96
+ expect(driver.state().t).toBeCloseTo(0, 5);
97
+ expect(driver.state().focusedKey).toBe(0); // cleared once t reaches 0
98
+ });
99
+
100
+ // Fix A core: an exit that was never showing a dim is an ordinary overlay move
101
+ // (point halo / tooltip) — NOT a full frame.
102
+ test("exit with no dim shown → no full frame (overlay-only territory)", () => {
103
+ const { driver } = makeDriver({ durationS: 0.1 });
104
+ const req = driver.onHover(null);
105
+ expect(req).toEqual({ needsFrame: false, full: false });
106
+ });
107
+ });
108
+
109
+ describe("emphasis driver — hover swap (single uniform)", () => {
110
+ test("A→B mid-ramp snaps focusedKey to B and continues toward 1 (no reset)", () => {
111
+ const { driver } = makeDriver({ durationS: 0.1 });
112
+ driver.onHover(FOCUS_A);
113
+ driver.step(0);
114
+ driver.step(50); // t 0.5 toward A
115
+ const req = driver.onHover(FOCUS_B);
116
+ expect(req).toEqual({ needsFrame: true, full: true });
117
+ expect(driver.state().focusedKey).toBe(FOCUS_B);
118
+ expect(driver.state().target).toBe(1);
119
+ expect(driver.state().t).toBeCloseTo(0.5, 5); // continues, not reset
120
+ // Resumes ramping (clock re-seeds on the swap).
121
+ driver.step(50);
122
+ driver.step(100); // 50ms more → t 1
123
+ expect(driver.state().t).toBeCloseTo(1, 5);
124
+ });
125
+ });
126
+
127
+ describe("emphasis driver — Fix A: reduced-motion snaps are FULL frames", () => {
128
+ test("reduced-motion ENTER snaps t→1 in one full frame (no ramp)", () => {
129
+ const { driver, writes } = makeDriver({ durationS: 0 });
130
+ const req = driver.onHover(FOCUS_A);
131
+ expect(req).toEqual({ needsFrame: true, full: true });
132
+ expect(driver.state().t).toBe(1);
133
+ expect(driver.animating()).toBe(false);
134
+ expect(writes.at(-1)!.t).toBe(1);
135
+ // No ramp frames follow.
136
+ expect(driver.step(16)).toEqual({ needsFrame: false, full: false });
137
+ });
138
+
139
+ // The bug Fix A repairs: reduced-motion EXIT snapped the uniform off (a global
140
+ // pixel change) but the OLD code routed it to an overlay-only repaint, leaving
141
+ // the rest of the backbuffer stuck dimmed. The driver now reports `full` for
142
+ // any snap-off from a shown dim.
143
+ test("reduced-motion EXIT from a shown dim snaps off as a FULL frame (not overlay)", () => {
144
+ const { driver, writes } = makeDriver({ durationS: 0 });
145
+ driver.onHover(FOCUS_A); // snap on
146
+ expect(driver.state().t).toBe(1);
147
+ const req = driver.onHover(null); // snap off
148
+ expect(req).toEqual({ needsFrame: true, full: true }); // <-- the fix
149
+ expect(driver.state()).toEqual({ focusedKey: 0, t: 0, target: 0 });
150
+ expect(writes.at(-1)!.t).toBe(0);
151
+ });
152
+
153
+ test("live duration change to 0 mid-hover takes effect on the next onHover", () => {
154
+ const h = makeDriver({ durationS: 0.1 });
155
+ h.driver.onHover(FOCUS_A);
156
+ h.driver.step(0);
157
+ h.driver.step(100); // settle on (animated)
158
+ h.setDuration(0); // user flips on reduced-motion
159
+ const req = h.driver.onHover(null);
160
+ expect(req.full).toBe(true);
161
+ expect(h.driver.state().t).toBe(0); // snapped, not ramped
162
+ });
163
+ });
164
+
165
+ describe("emphasis driver — Fix C: reset() on the data path", () => {
166
+ test("reset snaps the uniform off and clears all state", () => {
167
+ const { driver, writes } = makeDriver({ durationS: 0.1 });
168
+ driver.onHover(FOCUS_A);
169
+ driver.step(0);
170
+ driver.step(50); // mid-ramp, dim shown
171
+ expect(driver.state().focusedKey).toBe(FOCUS_A);
172
+ driver.reset();
173
+ expect(driver.state()).toEqual({ focusedKey: 0, t: 0, target: 0 });
174
+ expect(driver.animating()).toBe(false);
175
+ // reset writes a settled-off uniform so the next full redraw shows no dim.
176
+ expect(writes.at(-1)).toEqual({ focusedKey: 0, dimAlpha: 0.45, t: 0 });
177
+ });
178
+
179
+ test("after reset, the next enter re-establishes emphasis from scratch", () => {
180
+ const { driver } = makeDriver({ durationS: 0.1 });
181
+ driver.onHover(FOCUS_A);
182
+ driver.step(0);
183
+ driver.step(100);
184
+ driver.reset();
185
+ const req = driver.onHover(FOCUS_B);
186
+ expect(req).toEqual({ needsFrame: true, full: true });
187
+ expect(driver.state()).toEqual({ focusedKey: FOCUS_B, t: 0, target: 1 });
188
+ });
189
+ });
190
+
191
+ describe("emphasis driver — no-dim hits never engage", () => {
192
+ test("onHover(null) / onHover(0) with nothing shown is inert", () => {
193
+ const { driver, writes } = makeDriver({ durationS: 0.1 });
194
+ expect(driver.onHover(null)).toEqual({ needsFrame: false, full: false });
195
+ expect(driver.onHover(0)).toEqual({ needsFrame: false, full: false });
196
+ expect(driver.state().focusedKey).toBe(0);
197
+ expect(writes).toHaveLength(0);
198
+ });
199
+ });
@@ -0,0 +1,205 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Animated GPU-emphasis driver (P5-T3) — extracted from mount.ts
3
+ // ---------------------------------------------------------------------------
4
+ // The dim-others hover treatment is driven entirely by the core renderer's
5
+ // emphasis uniform: on a hover hit-change over a GPU-dim geom the mount resolves
6
+ // the hit to a namespaced focused key and animates `t` 0→1 (ease-out cubic) over
7
+ // `theme.interactions.hover.durationMs`; on exit `t` ramps back to 0 and the
8
+ // focused key is held until `t` settles, then cleared. Zero marks recompile per
9
+ // hover frame.
10
+ //
11
+ // This module is the PURE state machine for that animation, factored out of the
12
+ // mount so it can be unit-tested directly (deterministic `now()`) instead of
13
+ // against a hand-copied mirror. The mount is a thin consumer: it wires the
14
+ // driver's `setEmphasis(state)` callback to `renderer.setEmphasis(...)`, feeds
15
+ // hover hits in through `onHover(key)`, advances the ramp from the RAF tick via
16
+ // `step(now)`, and routes the returned `{ needsFrame, full }` decisions into
17
+ // `inv.invalidate()` / `requestOverlay()`.
18
+ //
19
+ // SOUNDNESS RULE (load-bearing): a changed emphasis uniform alters pixels
20
+ // EVERYWHERE (every tagged instance re-mixes its alpha), so a regions/overlay
21
+ // partial frame would leave the un-repainted backbuffer showing the previous
22
+ // dim. Therefore every frame the driver requests while the uniform is in a
23
+ // VISIBLE / TRANSITIONING state is a FULL frame (`full: true`). Only a settled,
24
+ // fully-undimmed state (t === 0, no focused key) lets the mount fall back to the
25
+ // cheap overlay path for a pure cursor-overlay change.
26
+ //
27
+ // The uniform holds ONE key. Swapping hover A→B mid-animation snaps the focused
28
+ // key to B and continues `t` toward 1 (no per-key crossfade — a single global
29
+ // uniform can't express two focuses; documented limitation).
30
+
31
+ /** The emphasis uniform write the driver hands back to the renderer. */
32
+ export interface EmphasisState {
33
+ focusedKey: number;
34
+ dimAlpha: number;
35
+ t: number;
36
+ }
37
+
38
+ /**
39
+ * What a driver method asks of the host this turn.
40
+ * • `needsFrame` — a frame must be drawn at all.
41
+ * • `full` — that frame MUST be a FULL render (never a regions/overlay-only
42
+ * partial), because the emphasis uniform is in a visible/transitioning state
43
+ * (see SOUNDNESS RULE in the module header). When `needsFrame` is true and
44
+ * `full` is false, an overlay-only repaint is sound.
45
+ */
46
+ export interface FrameRequest {
47
+ needsFrame: boolean;
48
+ full: boolean;
49
+ }
50
+
51
+ const NO_FRAME: FrameRequest = { needsFrame: false, full: false };
52
+
53
+ export interface EmphasisDriverOptions {
54
+ /**
55
+ * Animation duration in seconds. `<= 0` means reduced-motion: the dim snaps
56
+ * instantly (no ramp). Re-read on every `onHover` via `durationS` being a
57
+ * thunk so a live theme/reduced-motion change takes effect immediately.
58
+ */
59
+ durationS: () => number;
60
+ /** Dim alpha (theme.interactions.hover.dim) for the non-focused instances. */
61
+ dim: () => number;
62
+ /** Push an emphasis uniform write to the renderer. */
63
+ setEmphasis: (state: EmphasisState) => void;
64
+ }
65
+
66
+ /**
67
+ * The animated-emphasis state machine. Pure aside from the injected
68
+ * `setEmphasis` sink; time is supplied by the caller (deterministic in tests).
69
+ */
70
+ export interface EmphasisDriver {
71
+ /**
72
+ * A hover hit resolved to its focused emphasis key (or `null`/`0` for no dim
73
+ * geom / exit). Updates the target and, under reduced-motion, snaps `t`.
74
+ * Returns the frame the host must draw. The full-frame decision encodes Fix A:
75
+ * ANY snap (enter OR exit) from/to a visibly-dimmed state is a global pixel
76
+ * change → FULL; an exit that was never showing is an ordinary overlay move.
77
+ */
78
+ onHover(focusedKey: number | null): FrameRequest;
79
+
80
+ /**
81
+ * Advance the ramp by the time elapsed since the last `step`/`onHover` call.
82
+ * Writes the eased uniform and returns the frame to draw. While transitioning
83
+ * this is always a FULL frame. Returns {@link NO_FRAME} when already settled
84
+ * (so the RAF loop can stop re-invalidating — no perpetual frames).
85
+ */
86
+ step(now: number): FrameRequest;
87
+
88
+ /** True while `t` has not reached its target (mid-transition). */
89
+ animating(): boolean;
90
+
91
+ /**
92
+ * Snap the uniform to the settled-off state (t = 0, focusedKey = 0) and clear
93
+ * all internal animation state. Used on the DATA path (setData/update): a held
94
+ * hover's stale focused key — index-derived, so it points at re-indexed
95
+ * instances after a data swap — is dropped before the full redraw; the
96
+ * pointer's next move re-establishes emphasis. Idempotent. Does NOT itself
97
+ * request a frame (the data path already forces a full redraw).
98
+ */
99
+ reset(): void;
100
+
101
+ /** Internal state, exposed for tests/diagnostics. */
102
+ state(): { focusedKey: number; t: number; target: number };
103
+ }
104
+
105
+ const easeOutCubic = (t: number): number => 1 - Math.pow(1 - t, 3);
106
+ const clamp01 = (t: number): number => Math.max(0, Math.min(1, t));
107
+
108
+ export function createEmphasisDriver(opts: EmphasisDriverOptions): EmphasisDriver {
109
+ let focusedKey = 0; // 0 = nothing focused (settled / inert)
110
+ let t = 0; // 0 (no dim) … 1 (full dim) — raw, linear; eased at write time
111
+ let target = 0; // ramp t toward this (0 on exit, 1 on enter)
112
+ let durationS = 0; // resolved from the theme thunk on each hover change
113
+ let lastNow = 0; // last `step` timestamp; seeded on first step after a change
114
+ let lastNowValid = false;
115
+
116
+ const animating = (): boolean => t !== target;
117
+
118
+ // True when the uniform currently shows (or is ramping toward) a dim — i.e. a
119
+ // partial frame would be unsound. Either we have a live focused key with a
120
+ // non-zero/ramping-up t, or we are mid-ramp-out (t still > 0).
121
+ const isShowing = (): boolean => focusedKey !== 0 && (target === 1 || t > 0);
122
+
123
+ function write(): void {
124
+ opts.setEmphasis({ focusedKey, dimAlpha: opts.dim(), t: easeOutCubic(clamp01(t)) });
125
+ }
126
+
127
+ function onHover(nextFocused: number | null): FrameRequest {
128
+ const key = nextFocused ?? 0;
129
+ durationS = opts.durationS();
130
+ const snap = durationS <= 0;
131
+ const wasShowing = isShowing();
132
+
133
+ if (key !== 0) {
134
+ // Enter / swap. Snap the focused key (uniform holds one), ramp toward 1.
135
+ focusedKey = key;
136
+ target = 1;
137
+ lastNowValid = false; // re-seed the ramp clock on the next step
138
+ if (snap) t = 1; // reduced-motion: snap on
139
+ write();
140
+ // Enter is ALWAYS a global pixel change (a new dim appears, or the dim
141
+ // re-targets a different instance), so it always forces a FULL frame —
142
+ // both for the animated ramp AND the reduced-motion snap (Fix A symmetry).
143
+ return { needsFrame: true, full: true };
144
+ }
145
+
146
+ // No dim geom focused → ramp out (or, under reduced-motion, snap off).
147
+ target = 0;
148
+ lastNowValid = false;
149
+ if (snap) {
150
+ focusedKey = 0;
151
+ t = 0;
152
+ write();
153
+ }
154
+ // Fix A: any change from/to a visibly-dimmed state is a global pixel change
155
+ // and MUST be a full frame — INCLUDING the reduced-motion snap-off (which
156
+ // clears the dim everywhere in one frame). Only an exit that was never
157
+ // showing is an ordinary cursor-overlay move (point halo / tooltip).
158
+ if (wasShowing) return { needsFrame: true, full: true };
159
+ return { needsFrame: false, full: false };
160
+ }
161
+
162
+ function step(now: number): FrameRequest {
163
+ if (!animating()) {
164
+ lastNowValid = false;
165
+ return NO_FRAME;
166
+ }
167
+ if (!lastNowValid) {
168
+ // First step since the last change — seed the clock, advance nothing yet.
169
+ lastNow = now;
170
+ lastNowValid = true;
171
+ }
172
+ const dt = Math.max(0, (now - lastNow) / 1000);
173
+ lastNow = now;
174
+ if (dt > 0) {
175
+ if (durationS > 0) {
176
+ const stepT = dt / durationS;
177
+ t = t < target ? Math.min(target, t + stepT) : Math.max(target, t - stepT);
178
+ } else {
179
+ t = target;
180
+ }
181
+ }
182
+ // Clear the held focused key once a ramp-out settles at 0.
183
+ if (target === 0 && t <= 0) focusedKey = 0;
184
+ write();
185
+ // Mid-transition (or just-settled this frame) the uniform changed → FULL.
186
+ return { needsFrame: true, full: true };
187
+ }
188
+
189
+ function reset(): void {
190
+ focusedKey = 0;
191
+ t = 0;
192
+ target = 0;
193
+ durationS = 0;
194
+ lastNowValid = false;
195
+ write();
196
+ }
197
+
198
+ return {
199
+ onHover,
200
+ step,
201
+ animating,
202
+ reset,
203
+ state: () => ({ focusedKey, t, target }),
204
+ };
205
+ }
@@ -0,0 +1,3 @@
1
+ export declare function valuesEqual(a: unknown, b: unknown): boolean;
2
+ export declare function arraysEqual(a: readonly unknown[], b: readonly unknown[]): boolean;
3
+ export declare function shallowObjectEqual(a: unknown, b: unknown): boolean;
@@ -0,0 +1,40 @@
1
+ // Date-aware leaf comparison. Two Dates with the same instant count as equal
2
+ // even when they're separate instances — the realistic case when builder
3
+ // closures recreate option objects each frame, or when two channels resolve
4
+ // the same field via separate accessors.
5
+ export function valuesEqual(a: unknown, b: unknown): boolean {
6
+ if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
7
+ return Object.is(a, b);
8
+ }
9
+
10
+ export function arraysEqual(a: readonly unknown[], b: readonly unknown[]): boolean {
11
+ if (a === b) return true;
12
+ if (a.length !== b.length) return false;
13
+ for (let i = 0; i < a.length; i++) {
14
+ if (!valuesEqual(a[i], b[i])) return false;
15
+ }
16
+ return true;
17
+ }
18
+
19
+ // Shallow object equality with Date-aware leaves and array-typed values
20
+ // compared element-wise. Non-array, non-primitive values (palette objects,
21
+ // blendSpace strings) fall back to `Object.is` — those rarely change at
22
+ // runtime, and a false-negative there merely re-triggers a transition the
23
+ // user did intend.
24
+ export function shallowObjectEqual(a: unknown, b: unknown): boolean {
25
+ if (Object.is(a, b)) return true;
26
+ if (a == null || b == null) return false;
27
+ if (typeof a !== "object" || typeof b !== "object") return false;
28
+ const aKeys = Object.keys(a as object);
29
+ const bKeys = Object.keys(b as object);
30
+ if (aKeys.length !== bKeys.length) return false;
31
+ for (const k of aKeys) {
32
+ if (!Object.hasOwn(b as object, k)) return false;
33
+ const av = (a as Record<string, unknown>)[k];
34
+ const bv = (b as Record<string, unknown>)[k];
35
+ if (Array.isArray(av) && Array.isArray(bv)) {
36
+ if (!arraysEqual(av, bv)) return false;
37
+ } else if (!valuesEqual(av, bv)) return false;
38
+ }
39
+ return true;
40
+ }