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,75 @@
1
+ import type { Color } from "insomni";
2
+ import { type BinClosed, type BinResult, type BinRule, type HistogramMeasure } from "../../stats/index.ts";
3
+ export type { HistogramMeasure };
4
+ import { type Aes } from "../aes.ts";
5
+ import type { Geom } from "./types.ts";
6
+ export type HistogramPosition = "identity" | "stack" | "dodge" | "fill";
7
+ export interface HistogramChannels<T> {
8
+ /** Numeric variable to bin. Provide one of `x` (vertical bars) or `y`. */
9
+ x?: Aes<T, number>;
10
+ /** Numeric variable to bin. Provide one of `x` (vertical bars) or `y`. */
11
+ y?: Aes<T, number>;
12
+ /** Optional categorical group key — splits the sample into per-group bins. */
13
+ color?: Aes<T, unknown>;
14
+ }
15
+ export interface HistogramOptions {
16
+ /** Explicit bin count. Loses to `binwidth` and `breaks`. */
17
+ bins?: number;
18
+ /** Explicit bin width in data units. Beats `bins` and `rule`. */
19
+ binwidth?: number;
20
+ /** Fully explicit edge array. Beats every other bin selector. */
21
+ breaks?: readonly number[];
22
+ /**
23
+ * Auto-bin rule when none of `bins`, `binwidth`, `breaks` is set.
24
+ *
25
+ * - `"sturges"` (default) — R / ggplot default; works for ~normal data.
26
+ * - `"rice"` — slightly more bins.
27
+ * - `"scott"` — uses σ; good for normal-ish data.
28
+ * - `"fd"` — Freedman–Diaconis; robust to outliers.
29
+ */
30
+ rule?: BinRule;
31
+ /** Clip / extend the value-axis range used when computing edges. */
32
+ domain?: readonly [number, number];
33
+ /** Round outer edges + step to nice numbers. Default `true`. */
34
+ nice?: boolean;
35
+ /** Edge convention. Default `"left"` (`[x0, x1)` plus closed last bin). */
36
+ closed?: BinClosed;
37
+ /**
38
+ * Y measure. Default `"count"`.
39
+ *
40
+ * - `"count"` / `"frequency"` — raw bin count.
41
+ * - `"density"` — `count / (n · width)`. Integrates to 1 across bins.
42
+ * - `"proportion"` — `count / n`. Sums to 1 across bins (per group).
43
+ */
44
+ y?: HistogramMeasure;
45
+ /**
46
+ * Multi-group layout. Default `"stack"` when `color` channel is present,
47
+ * else `"identity"`.
48
+ *
49
+ * - `"identity"` — bars share a baseline of 0; useful with reduced
50
+ * `fillAlpha` for an overlay effect.
51
+ * - `"stack"` — per-bin counts stacked.
52
+ * - `"dodge"` — sub-divide bin width across groups, side by side.
53
+ * - `"fill"` — stack normalized to `[0, 1]` per bin.
54
+ */
55
+ position?: HistogramPosition;
56
+ /** Render bars below the baseline (negate the count axis). Default `false`. */
57
+ mirror?: boolean;
58
+ /** Override theme `fillAlpha`. Lower this for `position: "identity"` overlays. */
59
+ fillAlpha?: number;
60
+ fill?: Color;
61
+ stroke?: Color;
62
+ strokeWidth?: number;
63
+ cornerRadius?: number;
64
+ /** Pixel gap between adjacent bars. Default `0` (continuous wall). */
65
+ gap?: number;
66
+ /** Inner-band padding for `position: "dodge"` (fraction of bin width). Default `0.05`. */
67
+ groupPadding?: number;
68
+ /** Optional per-bar label (uses bin midpoint + value). */
69
+ showCounts?: (value: number, bin: BinResult, key?: string) => string;
70
+ labelColor?: Color;
71
+ labelFontSize?: number;
72
+ /** Used by the auto-legend. */
73
+ label?: string;
74
+ }
75
+ export declare function histogram<T>(channels: HistogramChannels<T>, options?: HistogramOptions): Geom<T>;
@@ -0,0 +1,262 @@
1
+ // @vitest-environment jsdom
2
+ import { createFrame } from "insomni";
3
+ import { describe, expect, test } from "vite-plus/test";
4
+ import { plot } from "../chart.ts";
5
+ import { resolveAes } from "../aes.ts";
6
+ import { buildPositionScale, type ScaleBundle } from "../scales.ts";
7
+ import { themeDefault } from "../theme.ts";
8
+ import { histogram } from "./histogram.ts";
9
+ import { emphasisKeyFor, geomEmphasisBase } from "./emphasis.ts";
10
+ import type { CompileContext } from "./types.ts";
11
+
12
+ interface Row {
13
+ value: number;
14
+ group?: string;
15
+ }
16
+
17
+ const ROWS: Row[] = [
18
+ // Two groups with overlapping ranges so every bin has data from both.
19
+ ...Array.from({ length: 100 }, (_, i) => ({ value: i / 10, group: "A" }) as Row),
20
+ ...Array.from({ length: 100 }, (_, i) => ({ value: i / 10, group: "B" }) as Row),
21
+ ];
22
+
23
+ function rectCount(svg: SVGSVGElement): number {
24
+ // The chart's background frame contributes one <rect>; subtract it before
25
+ // asserting on bar counts.
26
+ return svg.querySelectorAll("rect").length;
27
+ }
28
+
29
+ describe("histogram()", () => {
30
+ test("renders one rect per bin for a single distribution", () => {
31
+ const svg = plot({ data: ROWS, width: 400, height: 300 })
32
+ .layer(histogram({ x: "value" }, { bins: 10, nice: false }))
33
+ .toSVG();
34
+ expect(svg).toBeInstanceOf(SVGSVGElement);
35
+ // 10 bins + 1 background; tolerate extra (axis frame). Must be ≥ 11.
36
+ expect(rectCount(svg)).toBeGreaterThanOrEqual(11);
37
+ });
38
+
39
+ test("explicit `breaks` overrides any rule and pins edges", () => {
40
+ const svg = plot({ data: ROWS, width: 320, height: 240 })
41
+ .layer(histogram({ x: "value" }, { breaks: [0, 5, 10] }))
42
+ .toSVG();
43
+ expect(svg).toBeInstanceOf(SVGSVGElement);
44
+ expect(rectCount(svg)).toBeGreaterThanOrEqual(3);
45
+ });
46
+
47
+ test("dodge position renders one rect per (bin, group)", () => {
48
+ const baseline = plot({ data: ROWS, width: 320, height: 240 })
49
+ .layer(histogram({ x: "value" }, { bins: 4, nice: false, position: "stack" }))
50
+ .toSVG();
51
+ const dodged = plot({ data: ROWS, width: 320, height: 240 })
52
+ .layer(histogram({ x: "value", color: "group" }, { bins: 4, nice: false, position: "dodge" }))
53
+ .toSVG();
54
+ expect(rectCount(dodged)).toBeGreaterThan(rectCount(baseline));
55
+ });
56
+
57
+ test("identity overlay produces one rect per (bin, group) with shared baseline", () => {
58
+ expect(() =>
59
+ plot({ data: ROWS, width: 320, height: 240 })
60
+ .layer(
61
+ histogram(
62
+ { x: "value", color: "group" },
63
+ { bins: 8, nice: false, position: "identity", fillAlpha: 0.4 },
64
+ ),
65
+ )
66
+ .toSVG(),
67
+ ).not.toThrow();
68
+ });
69
+
70
+ test("fill position normalizes per bin", () => {
71
+ expect(() =>
72
+ plot({ data: ROWS, width: 320, height: 240 })
73
+ .layer(
74
+ histogram({ x: "value", color: "group" }, { bins: 8, nice: false, position: "fill" }),
75
+ )
76
+ .toSVG(),
77
+ ).not.toThrow();
78
+ });
79
+
80
+ test("density measure does not throw and renders bars", () => {
81
+ expect(() =>
82
+ plot({ data: ROWS, width: 320, height: 240 })
83
+ .layer(histogram({ x: "value" }, { bins: 10, nice: false, y: "density" }))
84
+ .toSVG(),
85
+ ).not.toThrow();
86
+ });
87
+
88
+ test("mirror flips bars below baseline", () => {
89
+ expect(() =>
90
+ plot({ data: ROWS, width: 320, height: 240 })
91
+ .layer(histogram({ x: "value" }, { bins: 10, nice: false, mirror: true }))
92
+ .toSVG(),
93
+ ).not.toThrow();
94
+ });
95
+
96
+ test("horizontal orientation via `y` channel", () => {
97
+ expect(() =>
98
+ plot({ data: ROWS, width: 320, height: 240 })
99
+ .layer(histogram({ y: "value" }, { bins: 10, nice: false }))
100
+ .toSVG(),
101
+ ).not.toThrow();
102
+ });
103
+
104
+ test("rejects providing both `x` and `y`", () => {
105
+ expect(() =>
106
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
107
+ histogram({ x: "value", y: "value" } as any),
108
+ ).toThrow();
109
+ });
110
+
111
+ test("rejects empty channels", () => {
112
+ expect(() =>
113
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
114
+ histogram({} as any),
115
+ ).toThrow();
116
+ });
117
+
118
+ test("does not throw on empty data", () => {
119
+ expect(() =>
120
+ plot({ data: [] as Row[], width: 200, height: 200 })
121
+ .layer(histogram({ x: "value" }))
122
+ .toSVG(),
123
+ ).not.toThrow();
124
+ });
125
+
126
+ test("does not throw on all-equal values", () => {
127
+ const flat: Row[] = Array.from({ length: 5 }, () => ({ value: 7 }));
128
+ expect(() =>
129
+ plot({ data: flat, width: 200, height: 200 })
130
+ .layer(histogram({ x: "value" }))
131
+ .toSVG(),
132
+ ).not.toThrow();
133
+ });
134
+
135
+ test("two layers compose into a back-to-back mirrored histogram", () => {
136
+ const aOnly: Row[] = ROWS.filter((r) => r.group === "A");
137
+ const bOnly: Row[] = ROWS.filter((r) => r.group === "B");
138
+ expect(() =>
139
+ plot({ data: [...aOnly, ...bOnly], width: 400, height: 300 })
140
+ .layer(histogram({ x: "value" }, { bins: 12, nice: false }))
141
+ .layer(histogram({ x: "value" }, { bins: 12, nice: false, mirror: true }))
142
+ .toSVG(),
143
+ ).not.toThrow();
144
+ });
145
+
146
+ test("custom domain clips bins", () => {
147
+ expect(() =>
148
+ plot({ data: ROWS, width: 320, height: 240 })
149
+ .layer(histogram({ x: "value" }, { domain: [2, 8], bins: 6, nice: false }))
150
+ .toSVG(),
151
+ ).not.toThrow();
152
+ });
153
+
154
+ test("compileHitTest emits one hit per (group, bin)", () => {
155
+ const xAes = resolveAes<Row, unknown>("value");
156
+ // Synthesize a Y aes — histogram doesn't use it for layout but the
157
+ // pipeline expects a position scale built from data.
158
+ const xScale = buildPositionScale(xAes, ROWS, [0, 300], { domain: [0, 10] });
159
+ const yScale = buildPositionScale(xAes, ROWS, [200, 0], { domain: [0, 100] });
160
+ const scales: ScaleBundle = { x: xScale, y: yScale };
161
+ const ctx: CompileContext<Row> = {
162
+ data: ROWS,
163
+ scales,
164
+ plot: createFrame({ x: 50, y: 30, width: 300, height: 200 }),
165
+ theme: themeDefault,
166
+ atlas: undefined,
167
+ };
168
+ const geom = histogram<Row>(
169
+ { x: "value", color: "group" },
170
+ { bins: 4, nice: false, position: "dodge" },
171
+ );
172
+ const hits = geom.compileHitTest!(ctx)!;
173
+ expect(hits.geomKind).toBe("histogram");
174
+ // 4 bins × 2 groups = 8 hits (every bin has data from both groups).
175
+ expect(hits.dataIndex.length).toBe(8);
176
+ // Color channel should be present and surface the group key.
177
+ const colorAes = hits.channels.color!;
178
+ expect(colorAes.fn(ROWS[0]!, 0)).toBe("A");
179
+ });
180
+
181
+ test("tags each cell with a per-cell emphasis key (ordinal = flat cell index)", () => {
182
+ const xAes = resolveAes<Row, unknown>("value");
183
+ const xScale = buildPositionScale(xAes, ROWS, [0, 300], { domain: [0, 10] });
184
+ const yScale = buildPositionScale(xAes, ROWS, [200, 0], { domain: [0, 100] });
185
+ const scales: ScaleBundle = { x: xScale, y: yScale };
186
+ const ctx: CompileContext<Row> = {
187
+ data: ROWS,
188
+ scales,
189
+ plot: createFrame({ x: 50, y: 30, width: 300, height: 200 }),
190
+ theme: themeDefault,
191
+ atlas: undefined,
192
+ emphasisBase: geomEmphasisBase(2), // pretend this is geom index 2
193
+ };
194
+ const geom = histogram<Row>(
195
+ { x: "value", color: "group" },
196
+ { bins: 4, nice: false, position: "dodge" },
197
+ );
198
+ const rects: Array<{ emphasisKey?: number }> = [];
199
+ const layer = { pushRect: (s: { emphasisKey?: number }) => rects.push(s), pushText: () => {} };
200
+ for (const b of geom.compile(ctx)) b.addTo(layer as never);
201
+ const base = geomEmphasisBase(2);
202
+ const tagged = rects.filter((r) => r.emphasisKey !== undefined);
203
+ // 4 bins × 2 groups = 8 cells; keys are the flat cellN ordinals 0..7.
204
+ expect(tagged.length).toBe(8);
205
+ expect(tagged.map((r) => r.emphasisKey).sort((a, b) => a! - b!)).toEqual(
206
+ Array.from({ length: 8 }, (_, i) => emphasisKeyFor(base, i)),
207
+ );
208
+ // Resolver: a hit's dataIndex (= cellN) maps to the same key.
209
+ const res = geom.emphasisResolution!(ctx)!;
210
+ expect(res.resolve({ geomKind: "histogram", dataIndex: 5, data: ROWS, x: 0, y: 0 })).toBe(
211
+ emphasisKeyFor(base, 5),
212
+ );
213
+ });
214
+ });
215
+
216
+ describe("histogram() — focus halo decorator (Gap 2)", () => {
217
+ function makeCtx(): CompileContext<Row> {
218
+ const xAes = resolveAes<Row, unknown>("value");
219
+ const xScale = buildPositionScale(xAes, ROWS, [0, 300], { domain: [0, 10] });
220
+ const yScale = buildPositionScale(xAes, ROWS, [200, 0], { domain: [0, 100] });
221
+ const scales: ScaleBundle = { x: xScale, y: yScale };
222
+ return {
223
+ data: ROWS,
224
+ scales,
225
+ plot: createFrame({ x: 50, y: 30, width: 300, height: 200 }),
226
+ theme: themeDefault,
227
+ atlas: undefined,
228
+ };
229
+ }
230
+ function decorate(dataIndex: number, hitData: readonly unknown[] = ROWS) {
231
+ const geom = histogram<Row>({ x: "value", color: "group" }, { bins: 4, nice: false });
232
+ const ctx = makeCtx();
233
+ const deco = geom.hoverDecoration!(ctx)!;
234
+ const rects: Array<{
235
+ fill?: unknown;
236
+ stroke?: unknown;
237
+ strokeWidth?: number;
238
+ emphasisKey?: number;
239
+ }> = [];
240
+ const layer = { pushRect: (s: never) => rects.push(s) };
241
+ deco.decorate({ geomKind: "histogram", dataIndex, data: hitData, x: 0, y: 0 }, layer as never);
242
+ return { deco, rects };
243
+ }
244
+
245
+ test("emits a stroke-only halo rect at the hovered cell, exempt from dim", () => {
246
+ const { deco, rects } = decorate(3);
247
+ expect(deco.geomKind).toBe("histogram");
248
+ expect(rects.length).toBe(1);
249
+ expect(rects[0]!.fill).toBeUndefined();
250
+ expect(rects[0]!.stroke).toBeDefined();
251
+ expect(rects[0]!.strokeWidth).toBe(themeDefault.interactions.hover.haloStrokeWidth);
252
+ expect(rects[0]!.emphasisKey).toBeUndefined();
253
+ });
254
+
255
+ test("hover on a foreign data array emits nothing", () => {
256
+ expect(decorate(0, []).rects.length).toBe(0);
257
+ });
258
+
259
+ test("out-of-range cell index emits nothing", () => {
260
+ expect(decorate(9999).rects.length).toBe(0);
261
+ });
262
+ });