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,158 @@
1
+ import type { Color, Frame, GlyphAtlas, Layer, Padding } from "insomni";
2
+ import { type AxisOptions } from "../axis.ts";
3
+ import { type AnnotationSpec } from "./annotations.ts";
4
+ import { type FacetSpec } from "./facet.ts";
5
+ import { type PointSwatchSpec } from "../legend.ts";
6
+ import type { AxesSpec, AxisSpec, LegendMergeChannel, LegendSpec, TitleSpec } from "./chart.ts";
7
+ import { type Coord } from "./coord.ts";
8
+ import type { GrammarTransitions } from "./interactions/transitions.ts";
9
+ import type { Geom, ScaleBundle } from "./geoms/index.ts";
10
+ import { type Signal } from "insomni/reactivity";
11
+ import { type AlphaScaleOptions, type BorderStyleScaleOptions, type ColorScaleOptions, type OverlayGlyphScaleOptions, type PositionScaleOptions, type ShapeScaleOptions, type SizeScaleOptions } from "./scales.ts";
12
+ import { type Theme } from "./theme.ts";
13
+ export interface ChartConfig<T> {
14
+ data: readonly T[] | Signal<readonly T[]>;
15
+ width: number | undefined;
16
+ height: number | undefined;
17
+ background: Color | undefined;
18
+ padding: Padding;
19
+ framePadding: {
20
+ top: number;
21
+ right: number;
22
+ bottom: number;
23
+ left: number;
24
+ };
25
+ device: GPUDevice | undefined;
26
+ externalAtlas: GlyphAtlas | undefined;
27
+ theme: Theme;
28
+ layers: readonly Geom<T>[];
29
+ axes: AxesSpec;
30
+ titles: TitleSpec;
31
+ legend: LegendSpec;
32
+ scaleOverrides: {
33
+ x?: PositionScaleOptions;
34
+ y?: PositionScaleOptions;
35
+ color?: ColorScaleOptions<unknown>;
36
+ size?: SizeScaleOptions;
37
+ alpha?: AlphaScaleOptions;
38
+ shape?: ShapeScaleOptions;
39
+ borderStyle?: BorderStyleScaleOptions;
40
+ overlayGlyph?: OverlayGlyphScaleOptions;
41
+ };
42
+ annotations: readonly AnnotationSpec[];
43
+ facet?: FacetSpec<T>;
44
+ /**
45
+ * Coordinate system. Defaults to `coordCartesian()`. Threaded into
46
+ * `CompileContext.coord` and used to dispatch axis rendering.
47
+ */
48
+ coord: Coord;
49
+ }
50
+ export interface PipelineOutput<T = unknown> {
51
+ scales: ScaleBundle;
52
+ /** Hit-test contributions from any geom that implements `compileHitTest`. */
53
+ hitTests: import("./geoms/types.ts").CompiledHitTest<T>[];
54
+ /**
55
+ * Hover-focus decorators from geoms that implement `hoverDecoration` (point).
56
+ * The mount draws these into the overlay layer on hover — a cheap path that
57
+ * avoids recompiling the baked marks. Empty for geoms whose hover treatment
58
+ * is global (dim-others), which the mount handles via a marks re-bake.
59
+ * Faceted charts collect one decorator per (panel, geom), each closing over
60
+ * its panel's rows (P6-T3).
61
+ */
62
+ hoverDecorators: import("./geoms/types.ts").GeomHoverDecorator[];
63
+ /**
64
+ * Emphasis-key resolvers from dim-participating geoms (P5-T3). The mount maps
65
+ * an active hover hit to the geom's namespaced focused key and drives the
66
+ * core's animated GPU dim. Empty for charts with no dim geom. Faceted charts
67
+ * collect one resolver per (panel, geom) with a disjoint per-panel key band
68
+ * (P6-T3), so hovering one panel never dims another.
69
+ */
70
+ emphasisResolvers: import("./geoms/emphasis.ts").EmphasisResolver[];
71
+ /**
72
+ * Inset chart frame after outer padding — the area available to axes, slots,
73
+ * and the plot panel. Used to clip the hud layer so overlay marks (e.g. tip
74
+ * labels) don't bleed into the outer padding region.
75
+ */
76
+ outerFrame: Frame;
77
+ /**
78
+ * Plot drawing region in absolute element-CSS pixels (top-left of the plot
79
+ * frame, matching `Frame.topLeft`). For faceted charts this is the union
80
+ * of all panel frames — wide enough to enclose every panel — used by the
81
+ * crosshair to clip its guide line to the marks region.
82
+ */
83
+ plotFrame: Frame;
84
+ /**
85
+ * Position-scale range insets in CSS px (`framePadding` + per-geom
86
+ * `prepareRange` reservations). The position scales' pixel range is
87
+ * `[reserve.left, plotFrame.width - reserve.right]` (X) and
88
+ * `[plotFrame.height - reserve.bottom, reserve.top]` (Y). The mount feeds
89
+ * this into the data viewport via `setFrame(plotFrame, reserve)` so
90
+ * `dataToScreen` matches where marks render.
91
+ */
92
+ reserve: {
93
+ readonly left: number;
94
+ readonly right: number;
95
+ readonly top: number;
96
+ readonly bottom: number;
97
+ };
98
+ /**
99
+ * Per-panel plot frames for faceted charts (one entry per panel, in render
100
+ * order). Empty / undefined for non-faceted charts. Used by the crosshair
101
+ * to clip its guide line to the active panel rather than the chart-wide
102
+ * union frame. Coordinates match `plotFrame` (absolute element-CSS px).
103
+ */
104
+ panelFrames?: readonly Frame[];
105
+ /** Categorical legend builder and its placed origin (absolute CSS px). */
106
+ legend?: {
107
+ builder: import("../legend.ts").LegendBuilder;
108
+ origin: {
109
+ x: number;
110
+ y: number;
111
+ };
112
+ };
113
+ }
114
+ export declare function currentData<T>(data: readonly T[] | Signal<readonly T[]>): readonly T[];
115
+ /**
116
+ * Optional tail bundle for {@link runPipeline}. Required core args
117
+ * (`config`, `data`, layers, `atlas`) stay positional; everything that varies
118
+ * per-frame lives here so call sites don't drift when fields are added.
119
+ */
120
+ export interface RunPipelineOptions<T> {
121
+ hovered?: import("./geoms/types.ts").HoveredHit | null;
122
+ selected?: readonly import("./geoms/types.ts").HoveredHit[];
123
+ hidden?: ReadonlySet<string>;
124
+ legendDimAlpha?: number;
125
+ transitions?: GrammarTransitions;
126
+ transitionKey?: (datum: T, index: number) => string;
127
+ }
128
+ export declare function runPipeline<T>(config: ChartConfig<T>, data: readonly T[], axisLayer: Layer, marksLayer: Layer, hudLayer: Layer, atlas: GlyphAtlas | undefined, options?: RunPipelineOptions<T>): PipelineOutput<T>;
129
+ /**
130
+ * Extract the existing scale's domain into a `PositionScaleOptions` that pins
131
+ * a new scale to the same input range — used to keep faceted panels aligned
132
+ * under `scales: "fixed"`.
133
+ *
134
+ * Exported for unit testing — not part of the public grammar surface.
135
+ */
136
+ export declare function shareDomain(scale: ScaleBundle["x"]): PositionScaleOptions;
137
+ /** @internal Exported for unit tests only. */
138
+ export declare function makeAxisOptions(spec: AxisSpec | undefined, theme: Theme, atlas: GlyphAtlas | undefined): AxisOptions<unknown>;
139
+ type LegendChannelScales = Omit<ScaleBundle, "x" | "y">;
140
+ /**
141
+ * Build one point-style swatch for a merged legend entry. Resolves each
142
+ * listed channel's scale at `value` and composes the result into a single
143
+ * `PointSwatchSpec`. Channels listed in `mergeSet` but lacking an active
144
+ * scale are silently skipped — the merge declaration is the consumer's
145
+ * assertion that the channels exist *if* they're configured.
146
+ */
147
+ export declare function composeMergedPointSwatch(value: unknown, color: Color, scales: LegendChannelScales, mergeSet: ReadonlySet<LegendMergeChannel>, theme: Theme): PointSwatchSpec;
148
+ /**
149
+ * Detect which non-color channels share the color scale's categorical domain,
150
+ * so the legend can fold them into one row per entry without the consumer
151
+ * spelling out `merge: [...]`. A channel auto-merges when its scale exists AND
152
+ * its `.domain` is structurally equal to the color domain. `size` is numeric
153
+ * and never auto-merges; opt in via `legendSpec.merge` if you want it.
154
+ *
155
+ * Exported for testing.
156
+ */
157
+ export declare function autoMergeChannels(colorDomain: readonly unknown[], scales: LegendChannelScales): LegendMergeChannel[];
158
+ export {};
@@ -0,0 +1,463 @@
1
+ // @vitest-environment jsdom
2
+ import { createLayer } from "insomni";
3
+ import { describe, expect, it } from "vite-plus/test";
4
+
5
+ import { resolveAes } from "./aes.ts";
6
+ import { plot } from "./chart.ts";
7
+ import { coordPolar } from "./coord.ts";
8
+ import { EMPHASIS_GEOM_STRIDE } from "./geoms/emphasis.ts";
9
+ import type { ChannelSpec, CompileContext, Geom, HoveredHit } from "./geoms/types.ts";
10
+ import { runPipeline, shareDomain, type ChartConfig } from "./pipeline.ts";
11
+ import { buildPositionScale } from "./scales.ts";
12
+
13
+ interface Row {
14
+ x: number;
15
+ y: number;
16
+ }
17
+
18
+ const data: Row[] = [
19
+ { x: 1, y: 2 },
20
+ { x: 2, y: 4 },
21
+ ];
22
+
23
+ function recordingGeom(received: { ctx: CompileContext<Row> | null }): Geom<Row> {
24
+ const channels: ChannelSpec<Row> = { x: "x" as unknown, y: "y" as unknown };
25
+ return {
26
+ kind: "point",
27
+ channels,
28
+ compile(ctx) {
29
+ received.ctx = ctx;
30
+ return [];
31
+ },
32
+ };
33
+ }
34
+
35
+ interface ChartWithConfig {
36
+ __config__: ChartConfig<Row>;
37
+ }
38
+
39
+ function configFromChart<T extends ChartWithConfig>(chart: T): ChartConfig<Row> {
40
+ return chart.__config__;
41
+ }
42
+
43
+ describe("runPipeline — hovered context", () => {
44
+ it("threads `hovered: null` into compile context by default", () => {
45
+ const received: { ctx: CompileContext<Row> | null } = { ctx: null };
46
+ const chart = plot({ data, width: 400, height: 300 }).layer(recordingGeom(received));
47
+ const config = configFromChart(chart as unknown as ChartWithConfig);
48
+ const a = createLayer({ space: "ui" });
49
+ const m = createLayer({ space: "ui" });
50
+ const h = createLayer({ space: "ui" });
51
+ runPipeline(config, data, a, m, h, undefined);
52
+ expect(received.ctx).not.toBeNull();
53
+ expect(received.ctx!.hovered).toBeNull();
54
+ });
55
+
56
+ it("forwards a HoveredHit value into compile context", () => {
57
+ const received: { ctx: CompileContext<Row> | null } = { ctx: null };
58
+ const chart = plot({ data, width: 400, height: 300 }).layer(recordingGeom(received));
59
+ const config = configFromChart(chart as unknown as ChartWithConfig);
60
+ const a = createLayer({ space: "ui" });
61
+ const m = createLayer({ space: "ui" });
62
+ const h = createLayer({ space: "ui" });
63
+ const hovered: HoveredHit = {
64
+ geomKind: "point",
65
+ dataIndex: 1,
66
+ data,
67
+ x: 100,
68
+ y: 200,
69
+ };
70
+ runPipeline(config, data, a, m, h, undefined, { hovered });
71
+ expect(received.ctx?.hovered).toBe(hovered);
72
+ expect(received.ctx?.hovered?.dataIndex).toBe(1);
73
+ });
74
+ });
75
+
76
+ describe("runPipeline — emphasis (P5-T3)", () => {
77
+ it("threads a disjoint emphasisBase per geom (geomEmphasisBase(gi))", () => {
78
+ const r0: { ctx: CompileContext<Row> | null } = { ctx: null };
79
+ const r1: { ctx: CompileContext<Row> | null } = { ctx: null };
80
+ const chart = plot({ data, width: 400, height: 300 })
81
+ .layer(recordingGeom(r0))
82
+ .layer(recordingGeom(r1));
83
+ const config = configFromChart(chart as unknown as ChartWithConfig);
84
+ runPipeline(
85
+ config,
86
+ data,
87
+ createLayer({ space: "ui" }),
88
+ createLayer({ space: "ui" }),
89
+ createLayer({ space: "ui" }),
90
+ undefined,
91
+ );
92
+ // Bands are disjoint and ascend with geom index.
93
+ expect(r0.ctx!.emphasisBase).toBeGreaterThan(0);
94
+ expect(r1.ctx!.emphasisBase).toBeGreaterThan(r0.ctx!.emphasisBase!);
95
+ });
96
+
97
+ it("collects emphasisResolvers from geoms that implement emphasisResolution", () => {
98
+ const resolverGeom: Geom<Row> = {
99
+ kind: "bar",
100
+ channels: { x: "x" as unknown, y: "y" as unknown },
101
+ compile: () => [],
102
+ emphasisResolution: (ctx) => ({
103
+ geomKind: "bar",
104
+ data: ctx.data,
105
+ resolve: () => (ctx.emphasisBase ?? 0) + 1,
106
+ }),
107
+ };
108
+ const chart = plot({ data, width: 400, height: 300 }).layer(resolverGeom);
109
+ const config = configFromChart(chart as unknown as ChartWithConfig);
110
+ const out = runPipeline(
111
+ config,
112
+ data,
113
+ createLayer({ space: "ui" }),
114
+ createLayer({ space: "ui" }),
115
+ createLayer({ space: "ui" }),
116
+ undefined,
117
+ );
118
+ expect(out.emphasisResolvers).toHaveLength(1);
119
+ expect(out.emphasisResolvers[0]!.geomKind).toBe("bar");
120
+ });
121
+
122
+ it("emphasisResolvers is empty for a chart with no dim geom", () => {
123
+ const r: { ctx: CompileContext<Row> | null } = { ctx: null };
124
+ const chart = plot({ data, width: 400, height: 300 }).layer(recordingGeom(r));
125
+ const config = configFromChart(chart as unknown as ChartWithConfig);
126
+ const out = runPipeline(
127
+ config,
128
+ data,
129
+ createLayer({ space: "ui" }),
130
+ createLayer({ space: "ui" }),
131
+ createLayer({ space: "ui" }),
132
+ undefined,
133
+ );
134
+ expect(out.emphasisResolvers).toEqual([]);
135
+ });
136
+ });
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // Faceted emphasis parity (P6-T3)
140
+ // ---------------------------------------------------------------------------
141
+
142
+ interface FacetRow {
143
+ panel: string;
144
+ x: number;
145
+ y: number;
146
+ }
147
+
148
+ // Two panels (a / b), two rows each, so the facet path runs >1 panel.
149
+ const facetData: FacetRow[] = [
150
+ { panel: "a", x: 1, y: 2 },
151
+ { panel: "a", x: 2, y: 4 },
152
+ { panel: "b", x: 3, y: 6 },
153
+ { panel: "b", x: 4, y: 8 },
154
+ ];
155
+
156
+ interface FacetConfigChart {
157
+ __config__: ChartConfig<FacetRow>;
158
+ }
159
+
160
+ function facetConfig<T extends FacetConfigChart>(chart: T): ChartConfig<FacetRow> {
161
+ return chart.__config__;
162
+ }
163
+
164
+ /**
165
+ * A dim-participating ("bar") geom that mirrors the real emphasis contract: it
166
+ * tags via `emphasisBase` (recording each compile ctx so tests can read the
167
+ * per-panel band) and exposes a resolver + decorator that close over the ctx's
168
+ * (panel-sliced) data. The resolver returns `base + dataIndex + 1`, matching the
169
+ * `emphasisKeyFor` convention, so panel bands are observable through it.
170
+ */
171
+ function facetBarGeom(seen: { ctxs: CompileContext<FacetRow>[] }): Geom<FacetRow> {
172
+ return {
173
+ kind: "bar",
174
+ channels: { x: "x" as unknown, y: "y" as unknown },
175
+ compile(ctx) {
176
+ seen.ctxs.push(ctx);
177
+ return [];
178
+ },
179
+ emphasisResolution: (ctx) => ({
180
+ geomKind: "bar",
181
+ data: ctx.data,
182
+ resolve: (hit) =>
183
+ hit.geomKind === "bar" && hit.data === ctx.data
184
+ ? (ctx.emphasisBase ?? 0) + hit.dataIndex + 1
185
+ : null,
186
+ }),
187
+ hoverDecoration: (ctx) => ({
188
+ geomKind: "bar",
189
+ data: ctx.data,
190
+ decorate: () => {},
191
+ }),
192
+ };
193
+ }
194
+
195
+ // Stub atlas (measureText only) so the faceted strip's `pushText` succeeds in
196
+ // jsdom. Mirrors `makeFakeTextAtlas` in axis.test.ts.
197
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
198
+ function fakeAtlas(): any {
199
+ return {
200
+ measureText(text: string, opts: { fontSize?: number }) {
201
+ const fs = opts.fontSize ?? 12;
202
+ return { width: text.length * fs * 0.6, height: fs };
203
+ },
204
+ };
205
+ }
206
+
207
+ function runFacetPipeline(
208
+ config: ChartConfig<FacetRow>,
209
+ data: readonly FacetRow[],
210
+ options?: Parameters<typeof runPipeline<FacetRow>>[6],
211
+ ) {
212
+ const atlas = fakeAtlas();
213
+ const axisLayer = createLayer({ space: "ui", atlas });
214
+ const marksLayer = createLayer({ space: "ui", atlas });
215
+ const hudLayer = createLayer({ space: "ui", atlas });
216
+ // The facet strip + axes call `pushText` (glyph shaping needs real atlas
217
+ // metrics we don't stub); we exercise emphasis collection, not text layout, so
218
+ // no-op the text path. Mirrors axis.test.ts's recording-layer trick.
219
+ for (const l of [axisLayer, hudLayer]) {
220
+ (l as unknown as { pushText: () => void }).pushText = () => {};
221
+ }
222
+ return runPipeline(config, data, axisLayer, marksLayer, hudLayer, atlas, options);
223
+ }
224
+
225
+ describe("runPipeline — faceted emphasis (P6-T3)", () => {
226
+ it("threads a disjoint emphasisBase per panel (no cross-panel collision)", () => {
227
+ const seen: { ctxs: CompileContext<FacetRow>[] } = { ctxs: [] };
228
+ const chart = plot({ data: facetData, width: 400, height: 300 })
229
+ .layer(facetBarGeom(seen))
230
+ .facet({ by: "panel", ncol: 2 });
231
+ runFacetPipeline(facetConfig(chart as unknown as FacetConfigChart), facetData);
232
+
233
+ // One compile per panel (single geom × 2 panels).
234
+ expect(seen.ctxs).toHaveLength(2);
235
+ const baseA = seen.ctxs[0]!.emphasisBase;
236
+ const baseB = seen.ctxs[1]!.emphasisBase;
237
+ expect(baseA).toBeGreaterThan(0);
238
+ expect(baseB).toBeGreaterThan(0);
239
+ // effectiveGi = panelIndex * geomCount(1) + gi(0) → panelIndex.
240
+ expect(baseA).toBe(1 * EMPHASIS_GEOM_STRIDE); // geomEmphasisBase(0)
241
+ expect(baseB).toBe(2 * EMPHASIS_GEOM_STRIDE); // geomEmphasisBase(1)
242
+ // The two panels never share a band → a key from panel A can't land in B.
243
+ expect(baseA).not.toBe(baseB);
244
+ });
245
+
246
+ it("collects one emphasisResolver per panel; a panel-B hit resolves into B's band", () => {
247
+ const seen: { ctxs: CompileContext<FacetRow>[] } = { ctxs: [] };
248
+ const chart = plot({ data: facetData, width: 400, height: 300 })
249
+ .layer(facetBarGeom(seen))
250
+ .facet({ by: "panel", ncol: 2 });
251
+ const out = runFacetPipeline(facetConfig(chart as unknown as FacetConfigChart), facetData);
252
+
253
+ expect(out.emphasisResolvers).toHaveLength(2);
254
+
255
+ // Mirror the mount's `focusedKeyFor`: match a hit to its resolver via
256
+ // (geomKind, data identity). Faceted hits carry the panel's sliced rows.
257
+ const panelARows = seen.ctxs[0]!.data;
258
+ const panelBRows = seen.ctxs[1]!.data;
259
+ expect(panelARows).not.toBe(panelBRows);
260
+
261
+ function resolve(hit: HoveredHit): number {
262
+ for (const r of out.emphasisResolvers) {
263
+ if (r.geomKind === hit.geomKind && r.data === hit.data) return r.resolve(hit) ?? 0;
264
+ }
265
+ return 0;
266
+ }
267
+
268
+ const hitA: HoveredHit = { geomKind: "bar", dataIndex: 0, data: panelARows, x: 0, y: 0 };
269
+ const hitB: HoveredHit = { geomKind: "bar", dataIndex: 0, data: panelBRows, x: 0, y: 0 };
270
+ const keyA = resolve(hitA);
271
+ const keyB = resolve(hitB);
272
+
273
+ // Same ordinal, different panels → keys differ and land in their own bands.
274
+ expect(keyA).toBe(1 * EMPHASIS_GEOM_STRIDE + 1);
275
+ expect(keyB).toBe(2 * EMPHASIS_GEOM_STRIDE + 1);
276
+ expect(keyA).not.toBe(keyB);
277
+ });
278
+
279
+ it("collects one hoverDecorator per panel (focus-halo path)", () => {
280
+ const seen: { ctxs: CompileContext<FacetRow>[] } = { ctxs: [] };
281
+ const chart = plot({ data: facetData, width: 400, height: 300 })
282
+ .layer(facetBarGeom(seen))
283
+ .facet({ by: "panel", ncol: 2 });
284
+ const out = runFacetPipeline(facetConfig(chart as unknown as FacetConfigChart), facetData);
285
+
286
+ expect(out.hoverDecorators).toHaveLength(2);
287
+ // Each decorator closes over a distinct panel's rows.
288
+ const datas = out.hoverDecorators.map((d) => d.data);
289
+ expect(datas[0]).not.toBe(datas[1]);
290
+ });
291
+
292
+ it("hands each panel a distinct, frame-pinned coord (deferred-closure safe)", () => {
293
+ // Under polar facets, a geom's hoverDecoration closure captures `ctx.coord`
294
+ // and runs at hover time — after the facet loop has bound the SHARED coord
295
+ // to the last panel's frame. If every panel's ctx held that one shared
296
+ // instance, panel A's halo would project against panel B's centre. The
297
+ // pipeline now wraps the shared coord per panel (`frameBoundCoord`) so each
298
+ // ctx carries an independent, frame-pinned coord.
299
+ const seen: { ctxs: CompileContext<FacetRow>[] } = { ctxs: [] };
300
+ const chart = plot({ data: facetData, width: 400, height: 300 })
301
+ .coord(coordPolar({ angleChannel: "x" }))
302
+ .layer(facetBarGeom(seen))
303
+ .facet({ by: "panel", ncol: 2 });
304
+ runFacetPipeline(facetConfig(chart as unknown as FacetConfigChart), facetData);
305
+
306
+ expect(seen.ctxs).toHaveLength(2);
307
+ const coordA = seen.ctxs[0]!.coord!;
308
+ const coordB = seen.ctxs[1]!.coord!;
309
+ // Distinct instances — neither is the other, so a later panel's bind cannot
310
+ // poison an earlier panel's captured projection.
311
+ expect(coordA).not.toBe(coordB);
312
+ expect(coordA.kind).toBe("polar");
313
+
314
+ // Panel-stability under interleaving: projecting through A, then B, then A
315
+ // again yields A's SAME result both times (each call re-pins to its own
316
+ // frame). With a single shared coord, the B call would leave the coord bound
317
+ // to B's frame and corrupt A's second projection whenever the frames differ.
318
+ const p = { x: 30, y: 40 };
319
+ const a1 = coordA.project(p);
320
+ coordB.project(p);
321
+ const a2 = coordA.project(p);
322
+ expect(a2).toEqual(a1);
323
+ });
324
+
325
+ it("trips the existing key-band ceiling guard when panels × geoms overflows u32", () => {
326
+ // effectiveGi = panelIndex * geomCount + gi. With 4 geom layers per panel
327
+ // the guard (effectiveGi >= EMPHASIS_MAX_GEOM_INDEX + 1 = 4094) trips at
328
+ // panelIndex 1023, gi 2 (1023*4 + 2 = 4094) — so only ~1024 panels need to
329
+ // compile before the throw, not ~4094 (avoids flaking near vitest's 5s
330
+ // default under full-file scheduling). 1024 panels × 4 geoms reaches a max
331
+ // effectiveGi of 1023*4 + 3 = 4095 ≥ 4094, guaranteeing the overflow.
332
+ const geomCount = 4;
333
+ const panelCount = 1024; // max effectiveGi 1023*4 + 3 = 4095 ≥ 4094 → throws
334
+ const manyPanels: FacetRow[] = Array.from({ length: panelCount }, (_, i) => ({
335
+ panel: `p${i}`,
336
+ x: i,
337
+ y: i,
338
+ }));
339
+ const seen: { ctxs: CompileContext<FacetRow>[] } = { ctxs: [] };
340
+ let chart = plot({ data: manyPanels, width: 4000, height: 3000 });
341
+ for (let g = 0; g < geomCount; g++) chart = chart.layer(facetBarGeom(seen));
342
+ chart = chart.facet({ by: "panel", ncol: panelCount });
343
+ const config = facetConfig(chart as unknown as FacetConfigChart);
344
+ // geomEmphasisBase throws (RangeError) at effectiveGi 4094 — same guard the
345
+ // non-faceted path relies on, now reached by the faceted flattening.
346
+ expect(() => runFacetPipeline(config, manyPanels)).toThrow(/ceiling/);
347
+ }, 30_000); // vitest's 5s default so it never flakes on a busy CI runner. // Belt-and-braces: this builds ~1024 panels; give it generous headroom over
348
+ });
349
+
350
+ // ---------------------------------------------------------------------------
351
+ // Faceted per-panel scales (ECS back-out regression — D1)
352
+ // ---------------------------------------------------------------------------
353
+ //
354
+ // The ECS integration populated entities against the chart-wide scales while
355
+ // geoms re-resolved against per-panel scales — so every panel rendered at the
356
+ // chart-wide domain (the faceting mis-render P0 bug). With the back-out, geoms
357
+ // read `ctx.scales` directly, and the facet path hands each panel a scale bundle
358
+ // built from its OWN rows under `scales: "free"`. These assert the geom actually
359
+ // receives that per-panel bundle.
360
+
361
+ function domainOf(scale: { axisScale: unknown }): readonly unknown[] {
362
+ return (scale.axisScale as { domain: readonly unknown[] }).domain;
363
+ }
364
+
365
+ describe("runPipeline — faceted per-panel scales (D1 regression)", () => {
366
+ it('scales:"free" gives each panel a scale bundle built from its own rows', () => {
367
+ const seen: { ctxs: CompileContext<FacetRow>[] } = { ctxs: [] };
368
+ const chart = plot({ data: facetData, width: 400, height: 300 })
369
+ .layer(facetBarGeom(seen))
370
+ .facet({ by: "panel", ncol: 2, scales: "free" });
371
+ runFacetPipeline(facetConfig(chart as unknown as FacetConfigChart), facetData);
372
+
373
+ expect(seen.ctxs).toHaveLength(2);
374
+ const xA = domainOf(seen.ctxs[0]!.scales.x);
375
+ const xB = domainOf(seen.ctxs[1]!.scales.x);
376
+ const yA = domainOf(seen.ctxs[0]!.scales.y);
377
+ const yB = domainOf(seen.ctxs[1]!.scales.y);
378
+
379
+ // Panel a rows have x ∈ {1,2}, y ∈ {2,4}; panel b rows x ∈ {3,4}, y ∈ {6,8}.
380
+ // Under free scales the per-panel domains must differ — if every panel saw
381
+ // the chart-wide domain (the ECS bug) these would be equal.
382
+ expect(xA).not.toEqual(xB);
383
+ expect(yA).not.toEqual(yB);
384
+ // And each panel's max must track its own data, not the chart-wide max.
385
+ expect(Number(xB[xB.length - 1])).toBeGreaterThan(Number(xA[xA.length - 1]));
386
+ expect(Number(yB[yB.length - 1])).toBeGreaterThan(Number(yA[yA.length - 1]));
387
+ });
388
+
389
+ it('scales:"fixed" pins every panel to the shared chart-wide domain', () => {
390
+ const seen: { ctxs: CompileContext<FacetRow>[] } = { ctxs: [] };
391
+ const chart = plot({ data: facetData, width: 400, height: 300 })
392
+ .layer(facetBarGeom(seen))
393
+ .facet({ by: "panel", ncol: 2, scales: "fixed" });
394
+ runFacetPipeline(facetConfig(chart as unknown as FacetConfigChart), facetData);
395
+
396
+ expect(seen.ctxs).toHaveLength(2);
397
+ // Fixed: panels share the chart-wide domain so axes line up across panels.
398
+ expect(domainOf(seen.ctxs[0]!.scales.x)).toEqual(domainOf(seen.ctxs[1]!.scales.x));
399
+ expect(domainOf(seen.ctxs[0]!.scales.y)).toEqual(domainOf(seen.ctxs[1]!.scales.y));
400
+ });
401
+
402
+ it("each panel's geom sees its own sliced rows as ctx.data", () => {
403
+ const seen: { ctxs: CompileContext<FacetRow>[] } = { ctxs: [] };
404
+ const chart = plot({ data: facetData, width: 400, height: 300 })
405
+ .layer(facetBarGeom(seen))
406
+ .facet({ by: "panel", ncol: 2 });
407
+ runFacetPipeline(facetConfig(chart as unknown as FacetConfigChart), facetData);
408
+
409
+ expect(seen.ctxs).toHaveLength(2);
410
+ // ctx.data is the panel slice, not the full chart data.
411
+ expect(seen.ctxs[0]!.data).not.toBe(facetData);
412
+ expect(seen.ctxs[0]!.data.every((r) => r.panel === "a")).toBe(true);
413
+ expect(seen.ctxs[1]!.data.every((r) => r.panel === "b")).toBe(true);
414
+ });
415
+ });
416
+
417
+ // ---------------------------------------------------------------------------
418
+ // shareDomain — faceted `scales: "fixed"` domain pinning
419
+ // ---------------------------------------------------------------------------
420
+
421
+ describe("shareDomain — scale-type preservation", () => {
422
+ const aes = resolveAes<Row, unknown>("x");
423
+
424
+ it("preserves a log scale type and passes its domain through", () => {
425
+ const data: Row[] = [
426
+ { x: 1, y: 0 },
427
+ { x: 1000, y: 0 },
428
+ ];
429
+ const scale = buildPositionScale(aes, data, [0, 300], { type: "log", domain: [1, 1000] });
430
+ expect(scale.type).toBe("log");
431
+
432
+ const shared = shareDomain(scale);
433
+ // A faceted log axis must stay log under scales:"fixed" — not silently
434
+ // collapse to linear.
435
+ expect(shared.type).toBe("log");
436
+ expect(shared.domain).toEqual([1, 1000]);
437
+ });
438
+
439
+ it("preserves band / time / sqrt and defaults the rest to linear", () => {
440
+ const catAes = resolveAes<{ c: string }, unknown>("c");
441
+ const bandScale = buildPositionScale(catAes, [{ c: "a" }, { c: "b" }], [0, 100], {
442
+ type: "band",
443
+ domain: ["a", "b"],
444
+ });
445
+ expect(shareDomain(bandScale).type).toBe("band");
446
+
447
+ const numData: Row[] = [
448
+ { x: 0, y: 0 },
449
+ { x: 100, y: 0 },
450
+ ];
451
+ const sqrtScale = buildPositionScale(aes, numData, [0, 100], {
452
+ type: "sqrt",
453
+ domain: [0, 100],
454
+ });
455
+ expect(shareDomain(sqrtScale).type).toBe("sqrt");
456
+
457
+ const linScale = buildPositionScale(aes, numData, [0, 100], {
458
+ type: "linear",
459
+ domain: [0, 100],
460
+ });
461
+ expect(shareDomain(linScale).type).toBe("linear");
462
+ });
463
+ });