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,126 @@
1
+ // ---------------------------------------------------------------------------
2
+ // band geom — highlighted x or y range
3
+ // ---------------------------------------------------------------------------
4
+ // `band({ x: [a, b] })` → vertical band between x=a and x=b
5
+ // `band({ y: [a, b] })` → horizontal band
6
+
7
+ import type { Color } from "insomni";
8
+ import { bandMark } from "../../annotations.ts";
9
+ import { isAccentKey, resolveAccent, withAlpha, type ColorOrAccent } from "../color-utils.ts";
10
+ import type { CompileContext, CompiledHitTest, Geom } from "./types.ts";
11
+ import { wrapMark } from "./_mark.ts";
12
+
13
+ export interface BandChannels {
14
+ x?: readonly [number | Date, number | Date];
15
+ y?: readonly [number | Date, number | Date];
16
+ }
17
+
18
+ export interface BandOptions {
19
+ /**
20
+ * Fill color. Accepts a literal {@link Color} or a theme accent key
21
+ * (`"positive" | "negative" | "warn" | "info"`); accent keys resolve through
22
+ * `theme.accents` and have `theme.marks.bandFillAlpha` applied unless
23
+ * {@link BandOptions.alpha} is set.
24
+ */
25
+ fill?: ColorOrAccent;
26
+ stroke?: ColorOrAccent;
27
+ strokeWidth?: number;
28
+ /**
29
+ * Override the fill alpha. Defaults to `theme.marks.bandFillAlpha` when
30
+ * `fill` is omitted or is an accent key; defaults to no override (use the
31
+ * color's own alpha) when `fill` is a literal {@link Color}.
32
+ */
33
+ alpha?: number;
34
+ label?: string;
35
+ labelColor?: Color;
36
+ }
37
+
38
+ export function band<T>(channels: BandChannels, options: BandOptions = {}): Geom<T> {
39
+ if (!channels.x && !channels.y) {
40
+ throw new Error("band(): one of `x` or `y` must be provided");
41
+ }
42
+ const vertical = channels.x !== undefined;
43
+ return {
44
+ kind: "band",
45
+ channels: {},
46
+ label: options.label,
47
+ compile(ctx: CompileContext<T>) {
48
+ const { scales, plot, theme, atlas } = ctx;
49
+ const rawFill = resolveAccent(options.fill, theme) ?? theme.text.color;
50
+ // Apply alpha when the user supplied one, or when the fill is an accent
51
+ // key / falls through to the default text color — accents and text are
52
+ // saturated and need a tint multiplier to read as a band.
53
+ const needsDefaultAlpha = options.fill === undefined || isAccentKey(options.fill);
54
+ const fillAlpha =
55
+ options.alpha ?? (needsDefaultAlpha ? theme.marks.bandFillAlpha : undefined);
56
+ const fill = fillAlpha !== undefined ? withAlpha(rawFill, fillAlpha) : rawFill;
57
+ const stroke = resolveAccent(options.stroke, theme);
58
+ const strokeWidth = options.strokeWidth;
59
+ const labelColor = options.labelColor ?? theme.subtitle.color;
60
+ const fontSize = theme.marks.annotationFontSize;
61
+
62
+ const sharedStyle = {
63
+ fill,
64
+ stroke,
65
+ strokeWidth,
66
+ label: options.label,
67
+ labelColor,
68
+ atlas,
69
+ fontSize,
70
+ };
71
+ const mark = vertical
72
+ ? bandMark({
73
+ x: [scales.x.fn(channels.x![0]), scales.x.fn(channels.x![1])],
74
+ extent: [0, plot.height],
75
+ ...sharedStyle,
76
+ })
77
+ : bandMark({
78
+ y: [scales.y.fn(channels.y![0]), scales.y.fn(channels.y![1])],
79
+ extent: [0, plot.width],
80
+ ...sharedStyle,
81
+ });
82
+ return [wrapMark(mark, plot.topLeft, 1)];
83
+ },
84
+ compileHitTest(ctx: CompileContext<T>): CompiledHitTest<T> | null {
85
+ const { data, scales, plot } = ctx;
86
+ const ox = plot.topLeft.x;
87
+ const oy = plot.topLeft.y;
88
+ // Single synthetic hit at the band's center. Channels stay empty —
89
+ // tooltip relies on the band's `label` for title text.
90
+ let cx: number;
91
+ let cy: number;
92
+ let halfX: number;
93
+ let halfY: number;
94
+ if (vertical) {
95
+ const a = scales.x.fn(channels.x![0]);
96
+ const b = scales.x.fn(channels.x![1]);
97
+ if (!Number.isFinite(a) || !Number.isFinite(b)) return null;
98
+ cx = ox + (a + b) / 2;
99
+ cy = oy + plot.height / 2;
100
+ halfX = Math.abs(b - a) / 2;
101
+ halfY = plot.height / 2;
102
+ } else {
103
+ const a = scales.y.fn(channels.y![0]);
104
+ const b = scales.y.fn(channels.y![1]);
105
+ if (!Number.isFinite(a) || !Number.isFinite(b)) return null;
106
+ cx = ox + plot.width / 2;
107
+ cy = oy + (a + b) / 2;
108
+ halfX = plot.width / 2;
109
+ halfY = Math.abs(b - a) / 2;
110
+ }
111
+ const positions = new Float32Array([cx, cy]);
112
+ const dataIndex = new Int32Array([0]);
113
+ const rects = new Float32Array([cx - halfX, cy - halfY, halfX * 2, halfY * 2]);
114
+ return {
115
+ geomKind: "band",
116
+ label: options.label,
117
+ positions,
118
+ dataIndex,
119
+ rects,
120
+ pickRadius: Math.max(halfX, halfY),
121
+ channels: {},
122
+ data,
123
+ };
124
+ },
125
+ };
126
+ }
@@ -0,0 +1,56 @@
1
+ import { type Color } from "insomni";
2
+ import { type BarBorderStyle, type BarOrientation, type StackOrder } from "../../marks.ts";
3
+ import type { Aes } from "../aes.ts";
4
+ import type { Geom } from "./types.ts";
5
+ export type BarPosition = "identity" | "stack" | "dodge" | "fill";
6
+ export interface BarChannels<T> {
7
+ x: Aes<T, string | number | Date>;
8
+ /**
9
+ * Single column → simple bars (one per category). Array of column keys →
10
+ * multi-series bars (stacked / dodged / 100% stacked).
11
+ *
12
+ * For horizontal layouts (`orientation: "x"`) the single-column form carries
13
+ * the band category, so a string is accepted; the value axis then lives on
14
+ * `x`.
15
+ */
16
+ y: Aes<T, string | number | Date> | readonly (keyof T & string)[];
17
+ color?: Aes<T, unknown>;
18
+ }
19
+ export interface BarOptions<T = unknown> {
20
+ orientation?: BarOrientation;
21
+ fill?: Color;
22
+ stroke?: Color;
23
+ strokeWidth?: number;
24
+ cornerRadius?: number;
25
+ /**
26
+ * Border treatment shared across every bar (or segment, for stacked/dodged
27
+ * layouts). See {@link BarBorderStyle}.
28
+ */
29
+ borderStyle?: BarBorderStyle;
30
+ /**
31
+ * Multi-series layout. Only meaningful when `y` (or `x`) is an array of keys.
32
+ * - `"stack"` (default for arrays) — segments stacked from 0
33
+ * - `"dodge"` — bars side-by-side within each category
34
+ * - `"fill"` — stacked, normalized to [0, 1]
35
+ * - `"identity"` — each datum drawn at its raw value
36
+ */
37
+ position?: BarPosition;
38
+ order?: StackOrder;
39
+ label?: string;
40
+ /** Inner-band padding for `position: 'dodge'`. Default 0.05. */
41
+ groupPadding?: number;
42
+ /** If set, render a label above each bar with the per-category total. */
43
+ showTotals?: (total: number, datum: T, datumIndex: number) => string;
44
+ /**
45
+ * If set, render a label per-bar (or per-segment for stacked / fill) with
46
+ * the segment's value. For `fill`, the supplied `value` is normalized
47
+ * `[0, 1]`; for other positions it's the raw segment value. `key` is the
48
+ * column name for multi-series bars, `undefined` for simple bars.
49
+ */
50
+ showValues?: (value: number, datum: T, datumIndex: number, key?: string) => string;
51
+ /** Override label color. */
52
+ labelColor?: Color;
53
+ /** Override label font size. */
54
+ labelFontSize?: number;
55
+ }
56
+ export declare function bar<T>(channels: BarChannels<T>, options?: BarOptions<T>): Geom<T>;
@@ -0,0 +1,367 @@
1
+ import { createFrame } from "insomni";
2
+ import { describe, expect, test } from "vite-plus/test";
3
+
4
+ import { resolveAes } from "../aes.ts";
5
+ import { buildPositionScale, type ScaleBundle } from "../scales.ts";
6
+ import { themeDefault } from "../theme.ts";
7
+ import { bar } from "./bar.ts";
8
+ import { emphasisKeyFor, geomEmphasisBase } from "./emphasis.ts";
9
+ import type { CompileContext } from "./types.ts";
10
+
11
+ interface Row {
12
+ cat: string;
13
+ val: number;
14
+ group: string;
15
+ east?: number;
16
+ west?: number;
17
+ }
18
+
19
+ const data: Row[] = [
20
+ { cat: "a", val: 10, group: "x" },
21
+ { cat: "b", val: 20, group: "y" },
22
+ { cat: "c", val: 5, group: "x" },
23
+ ];
24
+
25
+ function makeCtx(rows: readonly Row[]): CompileContext<Row> {
26
+ const xAes = resolveAes<Row, unknown>("cat");
27
+ const yAes = resolveAes<Row, unknown>("val");
28
+ // Domain for value axis must include 0 (bar baseline) for tests to be sane.
29
+ const xScale = buildPositionScale(xAes, rows, [0, 100]);
30
+ const yScale = buildPositionScale(yAes, rows, [200, 0], { domain: [0, 20] });
31
+ const scales: ScaleBundle = { x: xScale, y: yScale };
32
+ return {
33
+ data: rows,
34
+ scales,
35
+ plot: createFrame({ x: 50, y: 30, width: 100, height: 200 }),
36
+ theme: themeDefault,
37
+ atlas: undefined,
38
+ // Geom index 0 → emphasis band base (P5-T3). Enables emphasis-key tagging.
39
+ emphasisBase: geomEmphasisBase(0),
40
+ };
41
+ }
42
+
43
+ function fakeRectLayer(): {
44
+ rects: Array<{ x: number; y: number; width: number; height: number }>;
45
+ layer: { pushRect: (shape: { x: number; y: number; width: number; height: number }) => void };
46
+ } {
47
+ const rects: Array<{ x: number; y: number; width: number; height: number }> = [];
48
+ return {
49
+ rects,
50
+ layer: {
51
+ pushRect(shape) {
52
+ rects.push(shape);
53
+ },
54
+ },
55
+ };
56
+ }
57
+
58
+ describe("bar geom — compileHitTest", () => {
59
+ test("emits one hit per simple bar at bar bbox center (absolute px)", () => {
60
+ const geom = bar<Row>({ x: "cat", y: "val" });
61
+ const hits = geom.compileHitTest!(makeCtx(data))!;
62
+ expect(hits).not.toBeNull();
63
+ expect(hits.geomKind).toBe("bar");
64
+ expect(hits.dataIndex.length).toBe(3);
65
+ // y center for cat "a" (val=10): baseline=200 (val=0), top=100 (val=10)
66
+ // → mid = 150, absolute = plot.y + 150 = 180
67
+ expect(hits.positions[1]).toBeCloseTo(180, 3);
68
+ });
69
+
70
+ test("dataIndex preserves order with all-finite data", () => {
71
+ const geom = bar<Row>({ x: "cat", y: "val" });
72
+ const hits = geom.compileHitTest!(makeCtx(data))!;
73
+ expect(Array.from(hits.dataIndex)).toEqual([0, 1, 2]);
74
+ });
75
+
76
+ test("filters non-finite values", () => {
77
+ const dirty: Row[] = [
78
+ { cat: "a", val: 10, group: "x" },
79
+ { cat: "b", val: NaN, group: "y" },
80
+ { cat: "c", val: 5, group: "x" },
81
+ ];
82
+ const geom = bar<Row>({ x: "cat", y: "val" });
83
+ const hits = geom.compileHitTest!(makeCtx(dirty))!;
84
+ expect(Array.from(hits.dataIndex)).toEqual([0, 2]);
85
+ });
86
+
87
+ test("captures channel metadata for tooltip labels", () => {
88
+ const geom = bar<Row>({ x: "cat", y: "val", color: "group" });
89
+ const hits = geom.compileHitTest!(makeCtx(data))!;
90
+ expect(hits.channels.x?.column).toBe("cat");
91
+ expect(hits.channels.y?.column).toBe("val");
92
+ expect(hits.channels.color?.column).toBe("group");
93
+ });
94
+
95
+ test("returns null for empty data", () => {
96
+ const geom = bar<Row>({ x: "cat", y: "val" });
97
+ expect(geom.compileHitTest!(makeCtx([]))).toBeNull();
98
+ });
99
+
100
+ test("emits one hit per visible segment for stacked multi-series bars", () => {
101
+ const rows: Row[] = [
102
+ { cat: "a", val: 0, group: "g", east: 4, west: 6 },
103
+ { cat: "b", val: 0, group: "g", east: 3, west: 5 },
104
+ ];
105
+ const geom = bar<Row>({ x: "cat", y: ["east", "west"] });
106
+ const hits = geom.compileHitTest!(makeCtx(rows))!;
107
+ expect(Array.from(hits.dataIndex)).toEqual([0, 0, 1, 1]);
108
+ expect(hits.seriesKey).toEqual(["east", "west", "east", "west"]);
109
+ });
110
+
111
+ test("omits hidden segments from multi-series hit-test output", () => {
112
+ const rows: Row[] = [{ cat: "a", val: 0, group: "g", east: 4, west: 6 }];
113
+ const geom = bar<Row>({ x: "cat", y: ["east", "west"] });
114
+ const hits = geom.compileHitTest!({
115
+ ...makeCtx(rows),
116
+ hidden: new Set(["west"]),
117
+ })!;
118
+ expect(Array.from(hits.dataIndex)).toEqual([0]);
119
+ expect(hits.seriesKey).toEqual(["east"]);
120
+ });
121
+
122
+ test("captures one frame entry per visible segment for multi-series bars", () => {
123
+ const rows: Row[] = [{ cat: "a", val: 0, group: "g", east: 4, west: 6 }];
124
+ const geom = bar<Row>({ x: "cat", y: ["east", "west"] });
125
+ const frame = geom.captureFrame!(makeCtx(rows))!;
126
+ expect(frame.count).toBe(2);
127
+ expect(Array.from(frame.r!)).toEqual([4, 6]);
128
+ });
129
+
130
+ test("stacked multi-series compile lerps segment heights during activeTransition", () => {
131
+ const fromRows: Row[] = [{ cat: "a", val: 0, group: "g", east: 4, west: 4 }];
132
+ const toRows: Row[] = [{ cat: "a", val: 0, group: "g", east: 8, west: 8 }];
133
+ const geom = bar<Row>({ x: "cat", y: ["east", "west"] });
134
+ const from = geom.captureFrame!(makeCtx(fromRows))!;
135
+ const builders = geom.compile({
136
+ ...makeCtx(toRows),
137
+ activeTransition: {
138
+ t: 0.5,
139
+ from,
140
+ matchIndex: (_key, fallbackIndex) => fallbackIndex,
141
+ },
142
+ });
143
+ const { rects, layer } = fakeRectLayer();
144
+ for (const builder of builders) builder.addTo(layer as never);
145
+ expect(rects).toHaveLength(2);
146
+ expect(rects[0]!.height).toBeCloseTo(60, 3);
147
+ expect(rects[1]!.height).toBeCloseTo(60, 3);
148
+ });
149
+
150
+ test("pickRadius covers half-bandwidth or half-bar-height", () => {
151
+ const geom = bar<Row>({ x: "cat", y: "val" });
152
+ const hits = geom.compileHitTest!(makeCtx(data))!;
153
+ // The tallest bar (val=20) spans 0..200 so half-height is 100.
154
+ expect(hits.pickRadius).toBeGreaterThanOrEqual(100);
155
+ });
156
+ });
157
+
158
+ describe("bar geom — multi-series halos & selection rings", () => {
159
+ const rows: Row[] = [
160
+ { cat: "a", val: 0, group: "g", east: 4, west: 6 },
161
+ { cat: "b", val: 0, group: "g", east: 3, west: 5 },
162
+ ];
163
+
164
+ function countStrokedRects(builders: ReturnType<ReturnType<typeof bar<Row>>["compile"]>) {
165
+ const { rects, layer } = fakeRectLayer();
166
+ const stroked: Array<{ fill?: unknown; stroke?: unknown }> = [];
167
+ const allRects: Array<{ fill?: unknown; stroke?: unknown; emphasisKey?: number }> = [];
168
+ const wrapped = {
169
+ pushRect: (shape: {
170
+ x: number;
171
+ y: number;
172
+ width: number;
173
+ height: number;
174
+ fill?: unknown;
175
+ stroke?: unknown;
176
+ emphasisKey?: number;
177
+ }) => {
178
+ layer.pushRect(shape);
179
+ allRects.push(shape);
180
+ if (shape.stroke !== undefined) stroked.push(shape);
181
+ },
182
+ };
183
+ for (const builder of builders) builder.addTo(wrapped as never);
184
+ return { rects, stroked, allRects };
185
+ }
186
+
187
+ test("stacked: tags each segment with a per-segment emphasis key (no hover halo)", () => {
188
+ const ctx = makeCtx(rows);
189
+ const geom = bar<Row>({ x: "cat", y: ["east", "west"] });
190
+ const builders = geom.compile({
191
+ ...ctx,
192
+ hovered: { geomKind: "bar", dataIndex: 0, seriesKey: "west", data: rows, x: 0, y: 0 },
193
+ });
194
+ const { stroked, allRects } = countStrokedRects(builders);
195
+ // The marks-baked hover halo is gone (GPU dim replaces it) — no stroked ring.
196
+ expect(stroked.length).toBe(0);
197
+ // 2 rows × 2 series = 4 tagged segment rects, each with a distinct key.
198
+ const tagged = allRects.filter((r) => r.emphasisKey !== undefined);
199
+ expect(tagged.length).toBe(4);
200
+ // Stable namespacing: ordinal = dataIndex * keys.length + seriesIndex (full
201
+ // keys ["east","west"]); base = geom 0's band. (row0,east)=ord0, (row0,west)=ord1.
202
+ const base = geomEmphasisBase(0);
203
+ const keysSet = new Set(tagged.map((r) => r.emphasisKey));
204
+ expect(keysSet.has(emphasisKeyFor(base, 0))).toBe(true); // row0,east
205
+ expect(keysSet.has(emphasisKeyFor(base, 1))).toBe(true); // row0,west
206
+ expect(keysSet.size).toBe(4); // all distinct
207
+ });
208
+
209
+ test("resolver maps a hit's (dataIndex, seriesKey) to the tagged key", () => {
210
+ const ctx = makeCtx(rows);
211
+ const geom = bar<Row>({ x: "cat", y: ["east", "west"] });
212
+ const res = geom.emphasisResolution!(ctx)!;
213
+ expect(res).not.toBeNull();
214
+ const base = geomEmphasisBase(0);
215
+ // row1,west → ordinal 1*2 + 1 = 3.
216
+ expect(
217
+ res.resolve({ geomKind: "bar", dataIndex: 1, seriesKey: "west", data: rows, x: 0, y: 0 }),
218
+ ).toBe(emphasisKeyFor(base, 3));
219
+ // A hit with no seriesKey focuses nothing.
220
+ expect(res.resolve({ geomKind: "bar", dataIndex: 0, data: rows, x: 0, y: 0 })).toBeNull();
221
+ });
222
+
223
+ test("single bars tag per-row keys (ordinal = dataIndex)", () => {
224
+ const ctx = makeCtx(data);
225
+ const geom = bar<Row>({ x: "cat", y: "val" });
226
+ const builders = geom.compile(ctx);
227
+ const { allRects } = countStrokedRects(builders);
228
+ const tagged = allRects.filter((r) => r.emphasisKey !== undefined);
229
+ expect(tagged.length).toBe(3);
230
+ const base = geomEmphasisBase(0);
231
+ expect(tagged.map((r) => r.emphasisKey).sort((a, b) => a! - b!)).toEqual([
232
+ emphasisKeyFor(base, 0),
233
+ emphasisKeyFor(base, 1),
234
+ emphasisKeyFor(base, 2),
235
+ ]);
236
+ // Resolver mirrors the tag (ordinal = dataIndex).
237
+ const res = geom.emphasisResolution!(ctx)!;
238
+ expect(res.resolve({ geomKind: "bar", dataIndex: 2, data, x: 0, y: 0 })).toBe(
239
+ emphasisKeyFor(base, 2),
240
+ );
241
+ });
242
+
243
+ test("compile no longer reads ctx.hovered for color (fills stable across hover)", () => {
244
+ const geom = bar<Row>({ x: "cat", y: "val" });
245
+ const plain = geom.compile(makeCtx(data));
246
+ const hovered = geom.compile({
247
+ ...makeCtx(data),
248
+ hovered: { geomKind: "bar", dataIndex: 0, data, x: 0, y: 0 },
249
+ });
250
+ const a = countStrokedRects(plain).allRects.map((r) => r.fill);
251
+ const b = countStrokedRects(hovered).allRects.map((r) => r.fill);
252
+ expect(a).toEqual(b);
253
+ });
254
+
255
+ test("stacked: selection rings draw one stroked rect per selected segment", () => {
256
+ const ctx = makeCtx(rows);
257
+ const geom = bar<Row>({ x: "cat", y: ["east", "west"] });
258
+ const builders = geom.compile({
259
+ ...ctx,
260
+ selected: [
261
+ { geomKind: "bar", dataIndex: 0, seriesKey: "east", data: rows, x: 0, y: 0 },
262
+ { geomKind: "bar", dataIndex: 1, seriesKey: "west", data: rows, x: 0, y: 0 },
263
+ ],
264
+ });
265
+ const { stroked } = countStrokedRects(builders);
266
+ expect(stroked.length).toBe(2);
267
+ });
268
+
269
+ test("dodge: tags each segment with a per-segment emphasis key", () => {
270
+ const ctx = makeCtx(rows);
271
+ const geom = bar<Row>({ x: "cat", y: ["east", "west"] }, { position: "dodge" });
272
+ const builders = geom.compile(ctx);
273
+ const { stroked, allRects } = countStrokedRects(builders);
274
+ expect(stroked.length).toBe(0); // no hover halo
275
+ const tagged = allRects.filter((r) => r.emphasisKey !== undefined);
276
+ expect(tagged.length).toBe(4);
277
+ });
278
+
279
+ test("stacked: selected segments overlay a filled rect when selection dims base", () => {
280
+ const ctx = makeCtx(rows);
281
+ const geom = bar<Row>({ x: "cat", y: ["east", "west"] });
282
+ const builders = geom.compile({
283
+ ...ctx,
284
+ selected: [{ geomKind: "bar", dataIndex: 0, seriesKey: "east", data: rows, x: 0, y: 0 }],
285
+ });
286
+ const { stroked } = countStrokedRects(builders);
287
+ expect(stroked.length).toBe(1);
288
+ expect(stroked[0]!.fill).toBeDefined();
289
+ });
290
+ });
291
+
292
+ // ---------------------------------------------------------------------------
293
+ // Gap 2 — focus-halo overlay decorators. The marks-baked halo is gone; the
294
+ // halo now rides hoverDecoration into the overlay layer. Mirrors point.test.ts.
295
+ // ---------------------------------------------------------------------------
296
+
297
+ interface CapturedRect {
298
+ x: number;
299
+ y: number;
300
+ width: number;
301
+ height: number;
302
+ fill?: unknown;
303
+ stroke?: { r: number; g: number; b: number; a: number };
304
+ strokeWidth?: number;
305
+ emphasisKey?: number;
306
+ }
307
+ function decorateInto(
308
+ geom: ReturnType<typeof bar<Row>>,
309
+ ctx: CompileContext<Row>,
310
+ hit: { geomKind: string; dataIndex: number; seriesKey?: string; data: readonly unknown[] },
311
+ ): CapturedRect[] {
312
+ const out: CapturedRect[] = [];
313
+ const layer = { pushRect: (s: CapturedRect) => out.push(s) };
314
+ const deco = geom.hoverDecoration!(ctx)!;
315
+ deco.decorate(hit as never, layer as never);
316
+ return out;
317
+ }
318
+
319
+ describe("bar geom — focus halo decorator (Gap 2)", () => {
320
+ test("single: emits a stroke-only halo rect at the hovered bar, exempt from dim", () => {
321
+ const geom = bar<Row>({ x: "cat", y: "val" });
322
+ const ctx = makeCtx(data);
323
+ const deco = geom.hoverDecoration!(ctx)!;
324
+ expect(deco.geomKind).toBe("bar");
325
+ const rects = decorateInto(geom, ctx, {
326
+ geomKind: "bar",
327
+ dataIndex: 1,
328
+ data,
329
+ });
330
+ expect(rects.length).toBe(1);
331
+ const halo = rects[0]!;
332
+ // Stroke-only (no fill), width = theme halo width, key left default (exempt).
333
+ expect(halo.fill).toBeUndefined();
334
+ expect(halo.stroke).toBeDefined();
335
+ expect(halo.strokeWidth).toBe(themeDefault.interactions.hover.haloStrokeWidth);
336
+ expect(halo.emphasisKey).toBeUndefined();
337
+ });
338
+
339
+ test("single: hover on a foreign data array emits nothing", () => {
340
+ // The decorator guards on data identity; the mount's `decoratorFor` does the
341
+ // geomKind match before calling decorate (mirrors point.ts).
342
+ const geom = bar<Row>({ x: "cat", y: "val" });
343
+ const ctx = makeCtx(data);
344
+ expect(decorateInto(geom, ctx, { geomKind: "bar", dataIndex: 0, data: [] }).length).toBe(0);
345
+ });
346
+
347
+ test("stacked: halo rect for the hovered (dataIndex, seriesKey) segment", () => {
348
+ const rows: Row[] = [
349
+ { cat: "a", val: 0, group: "g", east: 4, west: 6 },
350
+ { cat: "b", val: 0, group: "g", east: 3, west: 5 },
351
+ ];
352
+ const geom = bar<Row>({ x: "cat", y: ["east", "west"] });
353
+ const ctx = makeCtx(rows);
354
+ const rects = decorateInto(geom, ctx, {
355
+ geomKind: "bar",
356
+ dataIndex: 0,
357
+ seriesKey: "west",
358
+ data: rows,
359
+ });
360
+ expect(rects.length).toBe(1);
361
+ expect(rects[0]!.fill).toBeUndefined();
362
+ expect(rects[0]!.strokeWidth).toBe(themeDefault.interactions.hover.haloStrokeWidth);
363
+ expect(rects[0]!.emphasisKey).toBeUndefined();
364
+ // A segment hit with no seriesKey draws nothing.
365
+ expect(decorateInto(geom, ctx, { geomKind: "bar", dataIndex: 0, data: rows }).length).toBe(0);
366
+ });
367
+ });