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,479 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * Unit tests for the mount.ts render-orchestration invariants (P5 migration).
4
+ *
5
+ * mount.ts cannot be directly imported in node-mode tests because it
6
+ * transitively imports "insomni/reactivity" (a browser-only export). So these
7
+ * tests exercise the REAL building blocks the mount composes:
8
+ * - `createLayer` (real insomni) with the explicit zIndex bands + cache hints,
9
+ * proving the band layout + cache-pin contract mount.ts mints layers under;
10
+ * - the REAL `createEmphasisDriver` state machine (`./emphasis-driver.ts`),
11
+ * which mount.ts wires to renderer.setEmphasis / inv / requestOverlay / RAF;
12
+ * - the per-tick frame-kind dispatch RULE (recompile-only-when-dirty), proving
13
+ * Fix D: an emphasis ramp produces FULL renders with ZERO pipeline recompiles.
14
+ *
15
+ * The emphasis STATE MACHINE itself is tested directly in emphasis-driver.test.ts
16
+ * (deterministic clock); the key-band + GPU_DIM_GEOM_KINDS contracts in
17
+ * geoms/emphasis.test.ts. No hand-copied driver mirror lives here anymore.
18
+ *
19
+ * Plot NEVER calls cacheLayer/uncacheLayer and NEVER passes fullFrame:true — the
20
+ * core's per-frame cache policy + view fingerprint own the bake/full decision.
21
+ */
22
+
23
+ import { createLayer } from "insomni";
24
+ import { describe, expect, test } from "vite-plus/test";
25
+
26
+ import { createEmphasisDriver, type EmphasisState } from "./emphasis-driver.ts";
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Layer band layout + cache hints (real `createLayer`)
30
+ //
31
+ // Mount mints the four owned layers as:
32
+ // const staticCacheHint = partial ? "auto" : "never";
33
+ // axisLayer = createLayer({ space:"ui", zIndex:0, cache: staticCacheHint });
34
+ // marksLayer = createLayer({ space:"ui", zIndex:100, cache: staticCacheHint });
35
+ // hudLayer = createLayer({ space:"ui", zIndex:200, cache: staticCacheHint });
36
+ // overlayLayer = createLayer({ space:"ui", zIndex:300, cache: "never" });
37
+ // These tests run that exact minting against the real layer impl.
38
+ // ---------------------------------------------------------------------------
39
+
40
+ const Z_AXIS = 0;
41
+ const Z_BELOW_BASE = 10;
42
+ const Z_MARKS = 100;
43
+ const Z_ABOVE_BASE = 110;
44
+ const Z_HUD = 200;
45
+ const Z_OVERLAY = 300;
46
+
47
+ function staticCacheHint(partial: boolean): "auto" | "never" {
48
+ return partial ? "auto" : "never";
49
+ }
50
+
51
+ function makeLayers(partial: boolean) {
52
+ const hint = staticCacheHint(partial);
53
+ const axisLayer = createLayer({ space: "ui", zIndex: Z_AXIS, cache: hint });
54
+ const marksLayer = createLayer({ space: "ui", zIndex: Z_MARKS, cache: hint });
55
+ const hudLayer = createLayer({ space: "ui", zIndex: Z_HUD, cache: hint });
56
+ const overlayLayer = createLayer({ space: "ui", zIndex: Z_OVERLAY, cache: "never" });
57
+ const cleanup = () => {
58
+ axisLayer.destroy();
59
+ marksLayer.destroy();
60
+ hudLayer.destroy();
61
+ overlayLayer.destroy();
62
+ };
63
+ return { axisLayer, marksLayer, hudLayer, overlayLayer, cleanup };
64
+ }
65
+
66
+ describe("layer zIndex bands + cache hints", () => {
67
+ test("partial=true: static layers cache:'auto', overlay 'never'; bands ascend axis<marks<hud<overlay", () => {
68
+ const { axisLayer, marksLayer, hudLayer, overlayLayer, cleanup } = makeLayers(true);
69
+
70
+ expect(axisLayer.cache).toBe("auto");
71
+ expect(marksLayer.cache).toBe("auto");
72
+ expect(hudLayer.cache).toBe("auto");
73
+ expect(overlayLayer.cache).toBe("never");
74
+
75
+ expect(axisLayer.zIndex).toBe(0);
76
+ expect(marksLayer.zIndex).toBe(100);
77
+ expect(hudLayer.zIndex).toBe(200);
78
+ expect(overlayLayer.zIndex).toBe(300);
79
+ expect(axisLayer.zIndex! < marksLayer.zIndex!).toBe(true);
80
+ expect(marksLayer.zIndex! < hudLayer.zIndex!).toBe(true);
81
+ expect(hudLayer.zIndex! < overlayLayer.zIndex!).toBe(true);
82
+
83
+ cleanup();
84
+ });
85
+
86
+ test("partial=false: EVERY layer is cache:'never' (pure-live fallback)", () => {
87
+ const { axisLayer, marksLayer, hudLayer, overlayLayer, cleanup } = makeLayers(false);
88
+
89
+ expect(axisLayer.cache).toBe("never");
90
+ expect(marksLayer.cache).toBe("never");
91
+ expect(hudLayer.cache).toBe("never");
92
+ expect(overlayLayer.cache).toBe("never");
93
+
94
+ cleanup();
95
+ });
96
+
97
+ test("overlay is ALWAYS cache:'never' regardless of partial", () => {
98
+ const a = makeLayers(true);
99
+ expect(a.overlayLayer.cache).toBe("never");
100
+ a.cleanup();
101
+ const b = makeLayers(false);
102
+ expect(b.overlayLayer.cache).toBe("never");
103
+ b.cleanup();
104
+ });
105
+
106
+ test("extra below/above layers slot into 10.. / 110.. bands (between fixed bands)", () => {
107
+ const below = [createLayer({ space: "ui" }), createLayer({ space: "ui" })];
108
+ const above = [createLayer({ space: "ui" })];
109
+ for (let i = 0; i < below.length; i++) below[i]!.zIndex = Z_BELOW_BASE + i;
110
+ for (let i = 0; i < above.length; i++) above[i]!.zIndex = Z_ABOVE_BASE + i;
111
+
112
+ expect(below.map((l) => l.zIndex)).toEqual([10, 11]);
113
+ expect(above.map((l) => l.zIndex)).toEqual([110]);
114
+ expect(below.every((l) => l.zIndex! > Z_AXIS && l.zIndex! < Z_MARKS)).toBe(true);
115
+ expect(above.every((l) => l.zIndex! > Z_MARKS && l.zIndex! < Z_HUD)).toBe(true);
116
+
117
+ for (const l of [...below, ...above]) l.destroy();
118
+ });
119
+ });
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Interaction-aware cache-hint flip (real `Layer.cache` mutation)
123
+ //
124
+ // mount.ts's `applyInteractionCacheHints()`:
125
+ // if (!partial) return;
126
+ // const interacting = !!binding && (binding.interacting || binding.flinging);
127
+ // const hint = interacting ? "never" : "auto";
128
+ // axisLayer.cache = hint; marksLayer.cache = hint; hudLayer.cache = hint;
129
+ // marksLayer.cache = marksCacheHint(interacting); // dim charts pin "never"
130
+ // // overlay stays "never".
131
+ // We run the same mutation against real layers (mutating Layer.cache between
132
+ // frames is a supported core contract).
133
+ // ---------------------------------------------------------------------------
134
+
135
+ interface StubBinding {
136
+ interacting: boolean;
137
+ flinging: boolean;
138
+ }
139
+
140
+ function applyInteractionCacheHints(
141
+ partial: boolean,
142
+ binding: StubBinding | null,
143
+ layers: ReturnType<typeof makeLayers>,
144
+ ): void {
145
+ if (!partial) return;
146
+ const interacting = !!binding && (binding.interacting || binding.flinging);
147
+ const hint = interacting ? "never" : "auto";
148
+ layers.axisLayer.cache = hint;
149
+ layers.marksLayer.cache = hint;
150
+ layers.hudLayer.cache = hint;
151
+ // overlay untouched.
152
+ }
153
+
154
+ describe("interaction-aware cache-hint flip", () => {
155
+ test("dragging (interacting): static layers flip to 'never', overlay stays 'never'", () => {
156
+ const layers = makeLayers(true);
157
+ applyInteractionCacheHints(true, { interacting: true, flinging: false }, layers);
158
+
159
+ expect(layers.axisLayer.cache).toBe("never");
160
+ expect(layers.marksLayer.cache).toBe("never");
161
+ expect(layers.hudLayer.cache).toBe("never");
162
+ expect(layers.overlayLayer.cache).toBe("never");
163
+
164
+ layers.cleanup();
165
+ });
166
+
167
+ test("flinging also flips static layers to 'never'", () => {
168
+ const layers = makeLayers(true);
169
+ applyInteractionCacheHints(true, { interacting: false, flinging: true }, layers);
170
+
171
+ expect(layers.axisLayer.cache).toBe("never");
172
+ expect(layers.marksLayer.cache).toBe("never");
173
+ expect(layers.hudLayer.cache).toBe("never");
174
+
175
+ layers.cleanup();
176
+ });
177
+
178
+ test("settled (not interacting): static layers restore to 'auto'", () => {
179
+ const layers = makeLayers(true);
180
+ applyInteractionCacheHints(true, { interacting: true, flinging: false }, layers);
181
+ applyInteractionCacheHints(true, { interacting: false, flinging: false }, layers);
182
+
183
+ expect(layers.axisLayer.cache).toBe("auto");
184
+ expect(layers.marksLayer.cache).toBe("auto");
185
+ expect(layers.hudLayer.cache).toBe("auto");
186
+ expect(layers.overlayLayer.cache).toBe("never");
187
+
188
+ layers.cleanup();
189
+ });
190
+
191
+ test("no binding (pan/zoom not configured): hints stay 'auto'", () => {
192
+ const layers = makeLayers(true);
193
+ applyInteractionCacheHints(true, null, layers);
194
+
195
+ expect(layers.axisLayer.cache).toBe("auto");
196
+ expect(layers.marksLayer.cache).toBe("auto");
197
+ expect(layers.hudLayer.cache).toBe("auto");
198
+
199
+ layers.cleanup();
200
+ });
201
+
202
+ test("partial=false: hint flip is a no-op (layers stay 'never')", () => {
203
+ const layers = makeLayers(false);
204
+ applyInteractionCacheHints(false, { interacting: true, flinging: false }, layers);
205
+
206
+ expect(layers.axisLayer.cache).toBe("never");
207
+ expect(layers.marksLayer.cache).toBe("never");
208
+ expect(layers.hudLayer.cache).toBe("never");
209
+
210
+ layers.cleanup();
211
+ });
212
+
213
+ test("drag → settle: 'never' during gesture, 'auto' after release", () => {
214
+ const layers = makeLayers(true);
215
+
216
+ applyInteractionCacheHints(true, { interacting: true, flinging: false }, layers);
217
+ expect(layers.marksLayer.cache).toBe("never");
218
+
219
+ applyInteractionCacheHints(true, { interacting: false, flinging: false }, layers);
220
+ expect(layers.marksLayer.cache).toBe("auto");
221
+
222
+ layers.cleanup();
223
+ });
224
+ });
225
+
226
+ // ---------------------------------------------------------------------------
227
+ // Fix D — per-tick emphasis ramp is REPAINT-ONLY: N full renders, ZERO recompiles.
228
+ //
229
+ // This composes the REAL emphasis driver with the mount's per-tick dispatch RULE
230
+ // (the thin-consumer glue that stays in mount.ts):
231
+ //
232
+ // if (emphasisAnimating()) { const req = driver.step(now);
233
+ // if (req.needsFrame) repaintPending = true; }
234
+ // const needsFull = inv.dirty; // real recompile pending
235
+ // const needsRepaint = repaintPending; // uniform-only full render
236
+ // if (needsFull) drawFull(); // runPipeline + bake + render (FULL)
237
+ // else if (needsRepaint) drawRepaint(); // render(currentLayers()) — NO pipeline
238
+ // else drawOverlay(); // regions/partial
239
+ //
240
+ // The pipeline + renderer boundaries are faked so we can count recompiles and
241
+ // classify the render kind of every emitted frame.
242
+ // ---------------------------------------------------------------------------
243
+
244
+ interface FrameLog {
245
+ kind: "full" | "repaint" | "overlay";
246
+ regions: boolean; // whether this render() carried `regions` (partial)
247
+ }
248
+
249
+ /** A tick loop that drives the REAL driver through the production dispatch rule. */
250
+ function makeTickHarness(opts: { durationS: number }) {
251
+ let durationS = opts.durationS;
252
+ const emphasisWrites: EmphasisState[] = [];
253
+ let runPipelineCalls = 0;
254
+ let renderCalls = 0;
255
+ const frames: FrameLog[] = [];
256
+
257
+ const driver = createEmphasisDriver({
258
+ durationS: () => durationS,
259
+ dim: () => 0.45,
260
+ setEmphasis: (s) => emphasisWrites.push({ ...s }),
261
+ });
262
+
263
+ // --- faked mount boundaries ---
264
+ let invDirty = false;
265
+ let overlayDirty = false;
266
+ let repaintPending = false;
267
+
268
+ function runPipeline(): void {
269
+ runPipelineCalls++; // the expensive recompile + axis bake bump
270
+ }
271
+ function render(withRegions: boolean): void {
272
+ renderCalls++;
273
+ void withRegions;
274
+ }
275
+ function drawFull(): void {
276
+ invDirty = false;
277
+ overlayDirty = false;
278
+ runPipeline(); // a full frame recompiles
279
+ render(false);
280
+ frames.push({ kind: "full", regions: false });
281
+ }
282
+ function drawRepaint(): void {
283
+ render(false); // FULL render, NO runPipeline → packs stable → zero re-bakes
284
+ frames.push({ kind: "repaint", regions: false });
285
+ }
286
+ function drawOverlay(): void {
287
+ overlayDirty = false;
288
+ render(true); // regions/partial
289
+ frames.push({ kind: "overlay", regions: true });
290
+ }
291
+
292
+ function tick(now: number): void {
293
+ if (driver.animating()) {
294
+ const req = driver.step(now);
295
+ if (req.needsFrame) repaintPending = true;
296
+ }
297
+ const needsFull = invDirty;
298
+ const needsRepaint = repaintPending;
299
+ const needsOverlay = overlayDirty;
300
+ if (needsFull || needsRepaint || needsOverlay) {
301
+ if (needsFull) drawFull();
302
+ else if (needsRepaint) drawRepaint();
303
+ else drawOverlay();
304
+ repaintPending = false;
305
+ }
306
+ }
307
+
308
+ return {
309
+ driver,
310
+ tick,
311
+ onHover(key: number | null): void {
312
+ const req = driver.onHover(key);
313
+ if (req.needsFrame) {
314
+ if (req.full)
315
+ invDirty = true; // hover hit-change → full recompile
316
+ else overlayDirty = true;
317
+ }
318
+ },
319
+ markOverlayDirty: () => {
320
+ overlayDirty = true;
321
+ },
322
+ invalidate: () => {
323
+ invDirty = true;
324
+ },
325
+ setDuration: (s: number) => (durationS = s),
326
+ get runPipelineCalls() {
327
+ return runPipelineCalls;
328
+ },
329
+ get renderCalls() {
330
+ return renderCalls;
331
+ },
332
+ frames,
333
+ emphasisWrites,
334
+ };
335
+ }
336
+
337
+ describe("Fix D — emphasis ramp is repaint-only (no per-tick recompile)", () => {
338
+ test("N ramp steps produce N FULL renders and ZERO pipeline recompiles", () => {
339
+ const h = makeTickHarness({ durationS: 0.1 }); // 100ms ramp
340
+ h.onHover(1234); // enter → one full recompile frame
341
+ // Service the enter frame (drawFull).
342
+ h.tick(0);
343
+ expect(h.runPipelineCalls).toBe(1);
344
+ expect(h.frames.at(-1)!.kind).toBe("full");
345
+
346
+ // Now ramp: 5 steps of 25ms. The driver settles at t=1 partway, but each
347
+ // animating step requests a frame; once settled, step() requests nothing.
348
+ const recompilesBefore = h.runPipelineCalls;
349
+ let now = 0;
350
+ let repaintFrames = 0;
351
+ for (let i = 0; i < 5; i++) {
352
+ now += 25;
353
+ const framesBefore = h.frames.length;
354
+ h.tick(now);
355
+ const emitted = h.frames.length > framesBefore;
356
+ if (emitted) {
357
+ // Every emitted ramp frame is a repaint-only FULL render (no regions).
358
+ expect(h.frames.at(-1)!.kind).toBe("repaint");
359
+ expect(h.frames.at(-1)!.regions).toBe(false);
360
+ repaintFrames++;
361
+ }
362
+ }
363
+ // The ramp emitted at least one repaint frame...
364
+ expect(repaintFrames).toBeGreaterThan(0);
365
+ // ...and NOT ONE of them recompiled the pipeline (Fix D core assertion).
366
+ expect(h.runPipelineCalls).toBe(recompilesBefore);
367
+ });
368
+
369
+ test("a real invalidation mid-ramp (resize/data) WINS → drawFull, not repaint", () => {
370
+ const h = makeTickHarness({ durationS: 0.2 });
371
+ h.onHover(1234);
372
+ h.tick(0); // enter full
373
+ h.tick(50); // ramp step (repaint)
374
+ expect(h.frames.at(-1)!.kind).toBe("repaint");
375
+ const recompiles = h.runPipelineCalls;
376
+ // A resize fires inv.invalidate() mid-ramp.
377
+ h.invalidate();
378
+ h.tick(75);
379
+ // inv.dirty outranks the repaint → a full recompile frame.
380
+ expect(h.frames.at(-1)!.kind).toBe("full");
381
+ expect(h.runPipelineCalls).toBe(recompiles + 1);
382
+ });
383
+
384
+ test("an overlay-only request is SUPPRESSED while the dim is animating (soundness)", () => {
385
+ const h = makeTickHarness({ durationS: 0.2 });
386
+ h.onHover(1234);
387
+ h.tick(0); // enter full
388
+ // Cursor moves (tooltip) mid-ramp → overlay requested.
389
+ h.markOverlayDirty();
390
+ h.tick(50);
391
+ // The animating ramp's repaint outranks the overlay → FULL repaint, not a
392
+ // partial regions frame (the uniform is global; a partial would ghost).
393
+ expect(h.frames.at(-1)!.kind).toBe("repaint");
394
+ expect(h.frames.at(-1)!.regions).toBe(false);
395
+ });
396
+
397
+ test("once settled, a pure overlay change rides a regions (overlay) frame again", () => {
398
+ const h = makeTickHarness({ durationS: 0.1 });
399
+ h.onHover(1234);
400
+ h.tick(0);
401
+ h.tick(50);
402
+ h.tick(100); // settle at t=1
403
+ expect(h.driver.animating()).toBe(false);
404
+ h.markOverlayDirty();
405
+ h.tick(116);
406
+ expect(h.frames.at(-1)!.kind).toBe("overlay");
407
+ expect(h.frames.at(-1)!.regions).toBe(true);
408
+ });
409
+ });
410
+
411
+ // ---------------------------------------------------------------------------
412
+ // Fix A — reduced-motion hover-exit forces a FULL frame (no stuck dim).
413
+ //
414
+ // Drives the REAL driver through the same dispatch glue. The bug: a reduced-
415
+ // motion exit snapped the uniform off (global pixel change) but routed to an
416
+ // overlay-only repaint, leaving the rest of the backbuffer dimmed.
417
+ // ---------------------------------------------------------------------------
418
+
419
+ describe("Fix A — reduced-motion exit forces a full frame", () => {
420
+ test("hover then exit under reduced-motion both produce FULL frames", () => {
421
+ const h = makeTickHarness({ durationS: 0 }); // reduced-motion
422
+ h.onHover(1234); // snap on
423
+ h.tick(0);
424
+ expect(h.frames.at(-1)!.kind).toBe("full");
425
+ expect(h.driver.state().t).toBe(1);
426
+
427
+ const fullsBefore = h.frames.filter((f) => f.kind === "full").length;
428
+ h.onHover(null); // snap off — MUST be a full frame, not overlay
429
+ h.tick(16);
430
+ const lastFrame = h.frames.at(-1)!;
431
+ expect(lastFrame.kind).toBe("full"); // <-- the fix (was "overlay" → stuck dim)
432
+ expect(lastFrame.regions).toBe(false);
433
+ expect(h.frames.filter((f) => f.kind === "full").length).toBe(fullsBefore + 1);
434
+ // Uniform snapped off → no residual dim.
435
+ expect(h.driver.state()).toEqual({ focusedKey: 0, t: 0, target: 0 });
436
+ expect(h.emphasisWrites.at(-1)!.t).toBe(0);
437
+ });
438
+
439
+ test("hover-enter under reduced-motion is a full frame (symmetric to exit)", () => {
440
+ const h = makeTickHarness({ durationS: 0 });
441
+ h.onHover(1234);
442
+ h.tick(0);
443
+ expect(h.frames.at(-1)!.kind).toBe("full");
444
+ expect(h.frames.at(-1)!.regions).toBe(false);
445
+ });
446
+ });
447
+
448
+ // ---------------------------------------------------------------------------
449
+ // Marks cache pin for dim charts (P5-T3) — mirror of mount.ts `marksCacheHint`.
450
+ // (A pure decision table; the dim-set membership it keys off is GPU_DIM_GEOM_KINDS,
451
+ // tested in geoms/emphasis.test.ts.)
452
+ // ---------------------------------------------------------------------------
453
+
454
+ function marksCacheHint(opts: {
455
+ partial: boolean;
456
+ hasDimGeom: boolean;
457
+ interacting: boolean;
458
+ }): "auto" | "never" {
459
+ if (!opts.partial) return "never";
460
+ if (opts.hasDimGeom) return "never";
461
+ return opts.interacting ? "never" : "auto";
462
+ }
463
+
464
+ describe("marks cache pin for dim charts (P5-T3)", () => {
465
+ test("a chart with a dim geom pins marks to 'never' even when settled", () => {
466
+ expect(marksCacheHint({ partial: true, hasDimGeom: true, interacting: false })).toBe("never");
467
+ expect(marksCacheHint({ partial: true, hasDimGeom: true, interacting: true })).toBe("never");
468
+ });
469
+
470
+ test("a pure-scatter chart (no dim geom) keeps 'auto' when settled, 'never' while interacting", () => {
471
+ expect(marksCacheHint({ partial: true, hasDimGeom: false, interacting: false })).toBe("auto");
472
+ expect(marksCacheHint({ partial: true, hasDimGeom: false, interacting: true })).toBe("never");
473
+ });
474
+
475
+ test("partial=false → always 'never' regardless of dim geom", () => {
476
+ expect(marksCacheHint({ partial: false, hasDimGeom: true, interacting: false })).toBe("never");
477
+ expect(marksCacheHint({ partial: false, hasDimGeom: false, interacting: false })).toBe("never");
478
+ });
479
+ });