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,740 @@
1
+ // ---------------------------------------------------------------------------
2
+ // histogram geom
3
+ // ---------------------------------------------------------------------------
4
+ // Counts samples into bins and draws one rect per bin. Composes with the
5
+ // existing position machinery (`identity` / `stack` / `dodge` / `fill`) so
6
+ // multi-group overlay, stacked, dodged, and 100% stacked histograms all fall
7
+ // out of the same code path. `mirror: true` renders the bars below the
8
+ // baseline so two layers compose into a back-to-back distribution.
9
+
10
+ import type { Color, Layer } from "insomni";
11
+ import { valueLabelMark } from "../../annotations.ts";
12
+ import {
13
+ binBreaks,
14
+ binWithBreaks,
15
+ groupBy,
16
+ histogramMeasureValue,
17
+ type BinClosed,
18
+ type BinResult,
19
+ type BinRule,
20
+ type HistogramMeasure,
21
+ } from "../../stats/index.ts";
22
+
23
+ export type { HistogramMeasure };
24
+ import type { ContinuousScale } from "../../scales.ts";
25
+ import { resolveAes, type Aes, type ResolvedAes } from "../aes.ts";
26
+ import { alphaize, seriesColor } from "../color-utils.ts";
27
+ import { barSwatch } from "../../legend.ts";
28
+ import type {
29
+ CompileContext,
30
+ CompiledHitTest,
31
+ Geom,
32
+ GeomHoverDecorator,
33
+ HoveredHit,
34
+ ResolvedChannelMap,
35
+ ScaleHints,
36
+ } from "./types.ts";
37
+ import { defaultMarkFill, wrapMark } from "./_mark.ts";
38
+ import { emphasisContext } from "./emphasis.ts";
39
+ import { DEFAULT_GROUP_PADDING, synthAes } from "./_categorical.ts";
40
+
41
+ export type HistogramPosition = "identity" | "stack" | "dodge" | "fill";
42
+
43
+ export interface HistogramChannels<T> {
44
+ /** Numeric variable to bin. Provide one of `x` (vertical bars) or `y`. */
45
+ x?: Aes<T, number>;
46
+ /** Numeric variable to bin. Provide one of `x` (vertical bars) or `y`. */
47
+ y?: Aes<T, number>;
48
+ /** Optional categorical group key — splits the sample into per-group bins. */
49
+ color?: Aes<T, unknown>;
50
+ }
51
+
52
+ export interface HistogramOptions {
53
+ /** Explicit bin count. Loses to `binwidth` and `breaks`. */
54
+ bins?: number;
55
+ /** Explicit bin width in data units. Beats `bins` and `rule`. */
56
+ binwidth?: number;
57
+ /** Fully explicit edge array. Beats every other bin selector. */
58
+ breaks?: readonly number[];
59
+ /**
60
+ * Auto-bin rule when none of `bins`, `binwidth`, `breaks` is set.
61
+ *
62
+ * - `"sturges"` (default) — R / ggplot default; works for ~normal data.
63
+ * - `"rice"` — slightly more bins.
64
+ * - `"scott"` — uses σ; good for normal-ish data.
65
+ * - `"fd"` — Freedman–Diaconis; robust to outliers.
66
+ */
67
+ rule?: BinRule;
68
+ /** Clip / extend the value-axis range used when computing edges. */
69
+ domain?: readonly [number, number];
70
+ /** Round outer edges + step to nice numbers. Default `true`. */
71
+ nice?: boolean;
72
+ /** Edge convention. Default `"left"` (`[x0, x1)` plus closed last bin). */
73
+ closed?: BinClosed;
74
+ /**
75
+ * Y measure. Default `"count"`.
76
+ *
77
+ * - `"count"` / `"frequency"` — raw bin count.
78
+ * - `"density"` — `count / (n · width)`. Integrates to 1 across bins.
79
+ * - `"proportion"` — `count / n`. Sums to 1 across bins (per group).
80
+ */
81
+ y?: HistogramMeasure;
82
+ /**
83
+ * Multi-group layout. Default `"stack"` when `color` channel is present,
84
+ * else `"identity"`.
85
+ *
86
+ * - `"identity"` — bars share a baseline of 0; useful with reduced
87
+ * `fillAlpha` for an overlay effect.
88
+ * - `"stack"` — per-bin counts stacked.
89
+ * - `"dodge"` — sub-divide bin width across groups, side by side.
90
+ * - `"fill"` — stack normalized to `[0, 1]` per bin.
91
+ */
92
+ position?: HistogramPosition;
93
+ /** Render bars below the baseline (negate the count axis). Default `false`. */
94
+ mirror?: boolean;
95
+ /** Override theme `fillAlpha`. Lower this for `position: "identity"` overlays. */
96
+ fillAlpha?: number;
97
+ fill?: Color;
98
+ stroke?: Color;
99
+ strokeWidth?: number;
100
+ cornerRadius?: number;
101
+ /** Pixel gap between adjacent bars. Default `0` (continuous wall). */
102
+ gap?: number;
103
+ /** Inner-band padding for `position: "dodge"` (fraction of bin width). Default `0.05`. */
104
+ groupPadding?: number;
105
+ /** Optional per-bar label (uses bin midpoint + value). */
106
+ showCounts?: (value: number, bin: BinResult, key?: string) => string;
107
+ labelColor?: Color;
108
+ labelFontSize?: number;
109
+ /** Used by the auto-legend. */
110
+ label?: string;
111
+ }
112
+
113
+ interface PerGroup {
114
+ key: string;
115
+ bins: BinResult[];
116
+ /** Sample size (after filtering non-finite + domain). */
117
+ n: number;
118
+ fill: Color;
119
+ }
120
+
121
+ interface ResolvedLayout {
122
+ orientation: "x" | "y";
123
+ breaks: number[];
124
+ groups: PerGroup[];
125
+ position: HistogramPosition;
126
+ measure: HistogramMeasure;
127
+ }
128
+
129
+ export function histogram<T>(
130
+ channels: HistogramChannels<T>,
131
+ options: HistogramOptions = {},
132
+ ): Geom<T> {
133
+ if (channels.x === undefined && channels.y === undefined) {
134
+ throw new Error("histogram(): one of `x` or `y` must be provided");
135
+ }
136
+ if (channels.x !== undefined && channels.y !== undefined) {
137
+ throw new Error("histogram(): provide either `x` or `y`, not both");
138
+ }
139
+
140
+ const orientation: "x" | "y" = channels.x !== undefined ? "x" : "y";
141
+ const valueAesRaw = (orientation === "x" ? channels.x : channels.y) as Aes<T, number>;
142
+
143
+ // Synthesise the count-axis channel as a constant-0 accessor. The pipeline
144
+ // builds a numeric scale on the back of this; `prepareDomain` then overrides
145
+ // the domain with the actual count extent.
146
+ const synthesisedCount: Aes<T, number> = 0 as unknown as Aes<T, number>;
147
+ const declaredChannels = {
148
+ x: orientation === "x" ? channels.x : synthesisedCount,
149
+ y: orientation === "y" ? channels.y : synthesisedCount,
150
+ color: channels.color,
151
+ };
152
+
153
+ const measure: HistogramMeasure = options.y ?? "count";
154
+ const positionDefault: HistogramPosition = channels.color !== undefined ? "stack" : "identity";
155
+ const position: HistogramPosition = options.position ?? positionDefault;
156
+ const mirror = options.mirror ?? false;
157
+
158
+ function buildLayout(data: readonly T[]): ResolvedLayout | null {
159
+ const valueAes = resolveAes<T, number>(valueAesRaw);
160
+ const colorAes: ResolvedAes<T, unknown> | undefined = channels.color
161
+ ? resolveAes<T, unknown>(channels.color)
162
+ : undefined;
163
+
164
+ // Null policy (see aes.ts): rows whose categorical color channel is
165
+ // null/undefined are dropped — they do not form a `"null"` bin group.
166
+ // Numeric values are kept; downstream `binBreaks` already tolerates NaN.
167
+ const allValues: number[] = [];
168
+ const allKeys: string[] | undefined = colorAes ? [] : undefined;
169
+ for (let i = 0; i < data.length; i++) {
170
+ const datum = data[i]!;
171
+ if (colorAes) {
172
+ const k = colorAes.fn(datum, i);
173
+ if (k === null || k === undefined) continue;
174
+ // oxlint-disable-next-line no-base-to-string -- color key is a scalar (string/number); String() is safe here
175
+ allKeys!.push(String(k));
176
+ }
177
+ allValues.push(valueAes.fn(datum, i));
178
+ }
179
+
180
+ const breaks = binBreaks(allValues, {
181
+ bins: options.bins,
182
+ binwidth: options.binwidth,
183
+ breaks: options.breaks,
184
+ rule: options.rule,
185
+ domain: options.domain,
186
+ nice: options.nice,
187
+ });
188
+ if (breaks.length < 2) return null;
189
+
190
+ // Group order: first-occurrence in input. Single-group fast-path uses one
191
+ // bucket keyed by the empty string.
192
+ const groupValues = allKeys
193
+ ? groupBy(allValues, (_v, i) => allKeys[i]!)
194
+ : new Map<string, number[]>([["", allValues]]);
195
+ const groupOrder = [...groupValues.keys()];
196
+
197
+ const closed = options.closed ?? "left";
198
+ const groups: PerGroup[] = [];
199
+ for (const key of groupOrder) {
200
+ const values = groupValues.get(key)!;
201
+ const bins = binWithBreaks(values, breaks, closed);
202
+ let n = 0;
203
+ for (const b of bins) n += b.count;
204
+ groups.push({ key, bins, n, fill: { r: 0, g: 0, b: 0, a: 1 } });
205
+ }
206
+
207
+ return {
208
+ orientation,
209
+ breaks,
210
+ groups,
211
+ position,
212
+ measure,
213
+ };
214
+ }
215
+
216
+ const measureValue = (b: BinResult, n: number): number => histogramMeasureValue(b, n, measure);
217
+
218
+ /**
219
+ * Per-bin position offsets keyed by group index. Returns parallel arrays
220
+ * `[base, top]` per group per bin so the renderer doesn't recompute them.
221
+ */
222
+ function computeStacks(layout: ResolvedLayout): { base: number; top: number }[][] {
223
+ const k = layout.breaks.length - 1;
224
+ const groupCount = layout.groups.length;
225
+ const out: { base: number; top: number }[][] = layout.groups.map(() =>
226
+ Array.from({ length: k }),
227
+ );
228
+
229
+ if (layout.position === "identity") {
230
+ for (let g = 0; g < groupCount; g++) {
231
+ const group = layout.groups[g]!;
232
+ for (let i = 0; i < k; i++) {
233
+ const v = measureValue(group.bins[i]!, group.n);
234
+ out[g]![i] = { base: 0, top: v };
235
+ }
236
+ }
237
+ return out;
238
+ }
239
+
240
+ if (layout.position === "stack" || layout.position === "fill") {
241
+ for (let i = 0; i < k; i++) {
242
+ let acc = 0;
243
+ let total = 0;
244
+ if (layout.position === "fill") {
245
+ for (let g = 0; g < groupCount; g++) {
246
+ total += measureValue(layout.groups[g]!.bins[i]!, layout.groups[g]!.n);
247
+ }
248
+ }
249
+ for (let g = 0; g < groupCount; g++) {
250
+ const v = measureValue(layout.groups[g]!.bins[i]!, layout.groups[g]!.n);
251
+ let base = acc;
252
+ let top = acc + v;
253
+ if (layout.position === "fill") {
254
+ if (total > 0) {
255
+ base = acc / total;
256
+ top = (acc + v) / total;
257
+ } else {
258
+ base = 0;
259
+ top = 0;
260
+ }
261
+ }
262
+ out[g]![i] = { base, top };
263
+ acc += v;
264
+ }
265
+ }
266
+ return out;
267
+ }
268
+
269
+ // dodge: each group draws its own bar within the bin slot — base 0, top v.
270
+ for (let g = 0; g < groupCount; g++) {
271
+ const group = layout.groups[g]!;
272
+ for (let i = 0; i < k; i++) {
273
+ const v = measureValue(group.bins[i]!, group.n);
274
+ out[g]![i] = { base: 0, top: v };
275
+ }
276
+ }
277
+ return out;
278
+ }
279
+
280
+ function maxStackTop(stacks: { base: number; top: number }[][]): number {
281
+ let max = 0;
282
+ for (const group of stacks) {
283
+ for (const seg of group) {
284
+ if (seg.top > max) max = seg.top;
285
+ }
286
+ }
287
+ return max;
288
+ }
289
+
290
+ return {
291
+ kind: "histogram",
292
+ channels: declaredChannels,
293
+ label: options.label,
294
+ scaleHints: {
295
+ [orientation === "x" ? "y" : "x"]: { includeZero: true },
296
+ } as ScaleHints,
297
+ legendSwatch: (color) => barSwatch({ fill: color, size: 12 }),
298
+ prepareDomain(data) {
299
+ const layout = buildLayout(data);
300
+ if (!layout) return undefined;
301
+ const stacks = computeStacks(layout);
302
+ const max = maxStackTop(stacks);
303
+ const valueExtend: [number, number] = [
304
+ layout.breaks[0]!,
305
+ layout.breaks[layout.breaks.length - 1]!,
306
+ ];
307
+ // The count axis is synthesised (constant 0 per row) so numericExtent
308
+ // yields a useless `[-1, 1]`. Pin it explicitly via `domain` (which
309
+ // takes precedence over `extend`/`includeZero` in `applyHint`). The
310
+ // value axis only needs to extend to bin edges, so we use `extend` —
311
+ // unions cleanly with sibling layers via `mergeHint`.
312
+ const countLo = mirror ? -max : 0;
313
+ const countHi = mirror ? 0 : max;
314
+ const countDomain: [number, number] = countLo === countHi ? [0, 1] : [countLo, countHi];
315
+ const valueHint = { extend: valueExtend };
316
+ const countHint = { domain: countDomain };
317
+ if (layout.orientation === "x") {
318
+ return { x: valueHint, y: countHint };
319
+ }
320
+ return { x: countHint, y: valueHint };
321
+ },
322
+ compile(ctx: CompileContext<T>) {
323
+ const { data, scales, plot, theme, atlas } = ctx;
324
+ const layout = buildLayout(data);
325
+ if (!layout) return [];
326
+
327
+ const stacks = computeStacks(layout);
328
+
329
+ // Resolve fills.
330
+ const fillAlpha = options.fillAlpha ?? theme.marks.fillAlpha;
331
+ const baseFill: Color = options.fill ?? defaultMarkFill(theme);
332
+ if (channels.color) {
333
+ const keys = layout.groups.map((g) => g.key);
334
+ const fillFor = seriesColor(scales.color, theme.palettes.categorical, keys);
335
+ for (const g of layout.groups) g.fill = alphaize(fillFor(g.key), fillAlpha);
336
+ } else {
337
+ const fill = alphaize(baseFill, fillAlpha);
338
+ for (const g of layout.groups) g.fill = fill;
339
+ }
340
+
341
+ const stroke = options.stroke;
342
+ const strokeWidth = options.strokeWidth;
343
+ const cornerRadius = options.cornerRadius ?? theme.marks.barCornerRadius;
344
+ const gap = options.gap ?? 0;
345
+ const groupPadding = options.groupPadding ?? DEFAULT_GROUP_PADDING;
346
+
347
+ const valueAxis = (
348
+ layout.orientation === "x" ? scales.x.axisScale : scales.y.axisScale
349
+ ) as ContinuousScale;
350
+ const countAxis = (
351
+ layout.orientation === "x" ? scales.y.axisScale : scales.x.axisScale
352
+ ) as ContinuousScale;
353
+
354
+ const ox = plot.topLeft.x;
355
+ const oy = plot.topLeft.y;
356
+ const groupCount = layout.groups.length;
357
+
358
+ // Hover emphasis — the dim-others treatment rides the core's GPU emphasis
359
+ // uniform (P5-T3): tag each cell with a stable per-cell key (the flat cell
360
+ // counter that compileHitTest reports as `dataIndex`); the mount fades
361
+ // non-focused cells with no marks recompile. No compile-time color dim /
362
+ // halo. `dataIndex` in compileHitTest is the flat counter `n` over the same
363
+ // i × g iteration order (skipping empty cells), so we replicate it here.
364
+ const emph = emphasisContext(ctx, "histogram");
365
+
366
+ const builders: { length: number; addTo: (l: Layer) => Layer }[] = [];
367
+ // Note: first builder uses a custom addTo that paints rects directly,
368
+ // so it can't reuse `wrapMark`. Label builder below does.
369
+
370
+ builders.push({
371
+ length: layout.breaks.length - 1,
372
+ addTo(layer: Layer) {
373
+ let cellN = 0; // mirrors compileHitTest's `n` counter
374
+
375
+ for (let i = 0; i < layout.breaks.length - 1; i++) {
376
+ const x0Px = valueAxis(layout.breaks[i]!);
377
+ const x1Px = valueAxis(layout.breaks[i + 1]!);
378
+ const binLo = Math.min(x0Px, x1Px);
379
+ const binHi = Math.max(x0Px, x1Px);
380
+ const binSpan = binHi - binLo;
381
+ for (let g = 0; g < groupCount; g++) {
382
+ const group = layout.groups[g]!;
383
+ const seg = stacks[g]![i]!;
384
+ if (seg.top === seg.base) continue;
385
+ const baseV = mirror ? -seg.base : seg.base;
386
+ const topV = mirror ? -seg.top : seg.top;
387
+ const basePx = countAxis(baseV);
388
+ const topPx = countAxis(topV);
389
+ const lo = Math.min(basePx, topPx);
390
+ const hi = Math.max(basePx, topPx);
391
+
392
+ let cellLo = binLo;
393
+ let cellSpan = binSpan;
394
+ if (layout.position === "dodge" && groupCount > 1) {
395
+ const padPx = binSpan * groupPadding;
396
+ const inner = (binSpan - padPx) / groupCount;
397
+ cellLo = binLo + padPx / 2 + g * inner;
398
+ cellSpan = inner;
399
+ }
400
+ const halfGap = Math.min(gap / 2, Math.max(0, cellSpan / 2 - 0.5));
401
+ const x = cellLo + halfGap;
402
+ const w = Math.max(0, cellSpan - halfGap * 2);
403
+
404
+ // Per-cell emphasis key — keyed by the flat cell counter `cellN`,
405
+ // which is exactly the `dataIndex` compileHitTest reports.
406
+ const emphasisKey = emph?.keyFor(cellN);
407
+
408
+ if (layout.orientation === "x") {
409
+ layer.pushRect({
410
+ x: ox + x,
411
+ y: oy + lo,
412
+ width: w,
413
+ height: Math.max(0, hi - lo),
414
+ fill: group.fill,
415
+ stroke,
416
+ strokeWidth,
417
+ cornerRadius,
418
+ emphasisKey,
419
+ });
420
+ } else {
421
+ layer.pushRect({
422
+ x: ox + lo,
423
+ y: oy + x,
424
+ width: Math.max(0, hi - lo),
425
+ height: w,
426
+ fill: group.fill,
427
+ stroke,
428
+ strokeWidth,
429
+ cornerRadius,
430
+ emphasisKey,
431
+ });
432
+ }
433
+ cellN++;
434
+ }
435
+ }
436
+
437
+ return layer;
438
+ },
439
+ });
440
+
441
+ // Optional value labels above each bar.
442
+ if (atlas && options.showCounts) {
443
+ const fmt = options.showCounts;
444
+ const labelColor: Color = options.labelColor ?? theme.text.color;
445
+ const labelFontSize = options.labelFontSize ?? theme.marks.labelFontSize;
446
+
447
+ type LabelEntry = {
448
+ binIndex: number;
449
+ groupIndex: number;
450
+ bin: BinResult;
451
+ group: PerGroup;
452
+ seg: { base: number; top: number };
453
+ };
454
+ const entries: LabelEntry[] = [];
455
+ for (let i = 0; i < layout.breaks.length - 1; i++) {
456
+ for (let g = 0; g < groupCount; g++) {
457
+ const group = layout.groups[g]!;
458
+ const seg = stacks[g]![i]!;
459
+ if (seg.top === seg.base) continue;
460
+ entries.push({
461
+ binIndex: i,
462
+ groupIndex: g,
463
+ bin: group.bins[i]!,
464
+ group,
465
+ seg,
466
+ });
467
+ }
468
+ }
469
+
470
+ const orientationX = layout.orientation === "x";
471
+ const labelMark = valueLabelMark(entries, {
472
+ x: (e) => {
473
+ const x0Px = valueAxis(layout.breaks[e.binIndex]!);
474
+ const x1Px = valueAxis(layout.breaks[e.binIndex + 1]!);
475
+ const lo = Math.min(x0Px, x1Px);
476
+ const hi = Math.max(x0Px, x1Px);
477
+ const span = hi - lo;
478
+ if (orientationX) {
479
+ if (layout.position === "dodge" && groupCount > 1) {
480
+ const inner = (span * (1 - groupPadding)) / groupCount;
481
+ return lo + span * (groupPadding / 2) + e.groupIndex * inner + inner / 2;
482
+ }
483
+ return (lo + hi) / 2;
484
+ }
485
+ // horizontal: x is the count axis
486
+ const topV = mirror ? -e.seg.top : e.seg.top;
487
+ return countAxis(topV);
488
+ },
489
+ y: (e) => {
490
+ if (orientationX) {
491
+ const topV = mirror ? -e.seg.top : e.seg.top;
492
+ return countAxis(topV);
493
+ }
494
+ const x0Px = valueAxis(layout.breaks[e.binIndex]!);
495
+ const x1Px = valueAxis(layout.breaks[e.binIndex + 1]!);
496
+ const lo = Math.min(x0Px, x1Px);
497
+ const hi = Math.max(x0Px, x1Px);
498
+ const span = hi - lo;
499
+ if (layout.position === "dodge" && groupCount > 1) {
500
+ const inner = (span * (1 - groupPadding)) / groupCount;
501
+ return lo + span * (groupPadding / 2) + e.groupIndex * inner + inner / 2;
502
+ }
503
+ return (lo + hi) / 2;
504
+ },
505
+ text: (e) =>
506
+ fmt(
507
+ measureValue(e.bin, e.group.n),
508
+ e.bin,
509
+ e.group.key === "" ? undefined : e.group.key,
510
+ ),
511
+ color: labelColor,
512
+ fontSize: labelFontSize,
513
+ align: orientationX ? "center" : "left",
514
+ offset: orientationX
515
+ ? { x: 0, y: mirror ? labelFontSize + 2 : -6 }
516
+ : { x: mirror ? -6 : 6, y: 0 },
517
+ });
518
+ builders.push(wrapMark(labelMark, plot.topLeft, entries.length));
519
+ }
520
+
521
+ return builders;
522
+ },
523
+ emphasisResolution(ctx) {
524
+ return emphasisContext(ctx, "histogram")?.resolver() ?? null;
525
+ },
526
+ hoverDecoration(ctx: CompileContext<T>): GeomHoverDecorator | null {
527
+ // Focus halo on the hovered bin cell — recovered from the old snap path's
528
+ // compile-time outline (deleted in e6d5643). Now an OVERLAY decorator
529
+ // replayed into the live overlay layer (NO marks recompile). Overlay shapes
530
+ // leave emphasisKey 0 ⇒ EXEMPT ⇒ the halo stays full-strength while the
531
+ // other cells dim via the GPU uniform. The hit's `dataIndex` is the flat
532
+ // cell counter `cellN`, so we replicate the same i×g iteration order
533
+ // (skipping empty cells) to find the hovered cell's rect + group fill.
534
+ const { data, plot } = ctx;
535
+ const hoverCfg = ctx.theme.interactions.hover;
536
+ const layout = buildLayout(data);
537
+ if (!layout) return null;
538
+
539
+ const fillAlpha = options.fillAlpha ?? ctx.theme.marks.fillAlpha;
540
+ const baseFill: Color = options.fill ?? defaultMarkFill(ctx.theme);
541
+ if (ctx.scales.color && layout.groups.length > 0 && options.fill === undefined) {
542
+ const keys = layout.groups.map((g) => g.key);
543
+ const fillFor = seriesColor(ctx.scales.color, ctx.theme.palettes.categorical, keys);
544
+ for (const g of layout.groups) g.fill = alphaize(fillFor(g.key), fillAlpha);
545
+ } else {
546
+ const fill = alphaize(baseFill, fillAlpha);
547
+ for (const g of layout.groups) g.fill = fill;
548
+ }
549
+
550
+ const stacks = computeStacks(layout);
551
+ const cornerRadius = options.cornerRadius ?? ctx.theme.marks.barCornerRadius;
552
+ const gap = options.gap ?? 0;
553
+ const groupPadding = options.groupPadding ?? DEFAULT_GROUP_PADDING;
554
+ const valueAxis = (
555
+ layout.orientation === "x" ? ctx.scales.x.axisScale : ctx.scales.y.axisScale
556
+ ) as ContinuousScale;
557
+ const countAxis = (
558
+ layout.orientation === "x" ? ctx.scales.y.axisScale : ctx.scales.x.axisScale
559
+ ) as ContinuousScale;
560
+ const ox = plot.topLeft.x;
561
+ const oy = plot.topLeft.y;
562
+ const groupCount = layout.groups.length;
563
+
564
+ return {
565
+ geomKind: "histogram",
566
+ data,
567
+ decorate(hit: HoveredHit, layer: Layer): void {
568
+ if (!hoverCfg.enabled || hit.data !== data) return;
569
+ let cellN = 0;
570
+ for (let i = 0; i < layout.breaks.length - 1; i++) {
571
+ const x0Px = valueAxis(layout.breaks[i]!);
572
+ const x1Px = valueAxis(layout.breaks[i + 1]!);
573
+ const binLo = Math.min(x0Px, x1Px);
574
+ const binHi = Math.max(x0Px, x1Px);
575
+ const binSpan = binHi - binLo;
576
+ for (let g = 0; g < groupCount; g++) {
577
+ const group = layout.groups[g]!;
578
+ const seg = stacks[g]![i]!;
579
+ if (seg.top === seg.base) continue;
580
+ if (cellN === hit.dataIndex) {
581
+ const baseV = mirror ? -seg.base : seg.base;
582
+ const topV = mirror ? -seg.top : seg.top;
583
+ const basePx = countAxis(baseV);
584
+ const topPx = countAxis(topV);
585
+ const lo = Math.min(basePx, topPx);
586
+ const hi = Math.max(basePx, topPx);
587
+ let cellLo = binLo;
588
+ let cellSpan = binSpan;
589
+ if (layout.position === "dodge" && groupCount > 1) {
590
+ const padPx = binSpan * groupPadding;
591
+ const inner = (binSpan - padPx) / groupCount;
592
+ cellLo = binLo + padPx / 2 + g * inner;
593
+ cellSpan = inner;
594
+ }
595
+ const halfGap = Math.min(gap / 2, Math.max(0, cellSpan / 2 - 0.5));
596
+ const x = cellLo + halfGap;
597
+ const w = Math.max(0, cellSpan - halfGap * 2);
598
+ const ringColor: Color = hoverCfg.haloColor ?? { ...group.fill, a: 1 };
599
+ if (layout.orientation === "x") {
600
+ layer.pushRect({
601
+ x: ox + x,
602
+ y: oy + lo,
603
+ width: w,
604
+ height: Math.max(0, hi - lo),
605
+ cornerRadius,
606
+ stroke: ringColor,
607
+ strokeWidth: hoverCfg.haloStrokeWidth,
608
+ });
609
+ } else {
610
+ layer.pushRect({
611
+ x: ox + lo,
612
+ y: oy + x,
613
+ width: Math.max(0, hi - lo),
614
+ height: w,
615
+ cornerRadius,
616
+ stroke: ringColor,
617
+ strokeWidth: hoverCfg.haloStrokeWidth,
618
+ });
619
+ }
620
+ return;
621
+ }
622
+ cellN++;
623
+ }
624
+ }
625
+ },
626
+ };
627
+ },
628
+ compileHitTest(ctx: CompileContext<T>): CompiledHitTest<T> | null {
629
+ const { data, scales, plot } = ctx;
630
+ const layout = buildLayout(data);
631
+ if (!layout) return null;
632
+
633
+ const stacks = computeStacks(layout);
634
+ const valueAxis = (
635
+ layout.orientation === "x" ? scales.x.axisScale : scales.y.axisScale
636
+ ) as ContinuousScale;
637
+ const countAxis = (
638
+ layout.orientation === "x" ? scales.y.axisScale : scales.x.axisScale
639
+ ) as ContinuousScale;
640
+ const groupPadding = options.groupPadding ?? DEFAULT_GROUP_PADDING;
641
+ const groupCount = layout.groups.length;
642
+ const k = layout.breaks.length - 1;
643
+
644
+ const ox = plot.topLeft.x;
645
+ const oy = plot.topLeft.y;
646
+ const cap = groupCount * k;
647
+ const positions = new Float32Array(cap * 2);
648
+ const rects = new Float32Array(cap * 4);
649
+ const dataIndex = new Int32Array(cap);
650
+ const seriesKey: (string | undefined)[] = Array.from({ length: cap });
651
+ const binMidpoints: number[] = Array.from({ length: cap });
652
+ const binValues: number[] = Array.from({ length: cap });
653
+ const groupKeys: (string | undefined)[] = Array.from({ length: cap });
654
+ let n = 0;
655
+ let minBinSpan = Infinity;
656
+ for (let i = 0; i < k; i++) {
657
+ const x0Px = valueAxis(layout.breaks[i]!);
658
+ const x1Px = valueAxis(layout.breaks[i + 1]!);
659
+ const binLo = Math.min(x0Px, x1Px);
660
+ const binHi = Math.max(x0Px, x1Px);
661
+ const binSpan = binHi - binLo;
662
+ for (let g = 0; g < groupCount; g++) {
663
+ const seg = stacks[g]![i]!;
664
+ if (seg.top === seg.base) continue;
665
+ const baseV = mirror ? -seg.base : seg.base;
666
+ const topV = mirror ? -seg.top : seg.top;
667
+ const basePx = countAxis(baseV);
668
+ const topPx = countAxis(topV);
669
+ const segLo = Math.min(basePx, topPx);
670
+ const segHi = Math.max(basePx, topPx);
671
+
672
+ let cellLo = binLo;
673
+ let cellSpan = binSpan;
674
+ if (layout.position === "dodge" && groupCount > 1) {
675
+ const padPx = binSpan * groupPadding;
676
+ const inner = (binSpan - padPx) / groupCount;
677
+ cellLo = binLo + padPx / 2 + g * inner;
678
+ cellSpan = inner;
679
+ }
680
+ if (cellSpan < minBinSpan) minBinSpan = cellSpan;
681
+ const cx = cellLo + cellSpan / 2;
682
+ const cy = (segLo + segHi) / 2;
683
+
684
+ if (layout.orientation === "x") {
685
+ positions[n * 2] = ox + cx;
686
+ positions[n * 2 + 1] = oy + cy;
687
+ // Whole-bar footprint for region hit testing.
688
+ rects[n * 4] = ox + cellLo;
689
+ rects[n * 4 + 1] = oy + segLo;
690
+ rects[n * 4 + 2] = cellSpan;
691
+ rects[n * 4 + 3] = segHi - segLo;
692
+ } else {
693
+ positions[n * 2] = ox + cy;
694
+ positions[n * 2 + 1] = oy + cx;
695
+ rects[n * 4] = ox + segLo;
696
+ rects[n * 4 + 1] = oy + cellLo;
697
+ rects[n * 4 + 2] = segHi - segLo;
698
+ rects[n * 4 + 3] = cellSpan;
699
+ }
700
+ const group = layout.groups[g]!;
701
+ const groupKey = group.key === "" ? undefined : group.key;
702
+ seriesKey[n] = groupKey ?? `bin${i}`;
703
+ groupKeys[n] = groupKey;
704
+ binMidpoints[n] = (layout.breaks[i]! + layout.breaks[i + 1]!) / 2;
705
+ binValues[n] = measureValue(group.bins[i]!, group.n);
706
+ dataIndex[n] = n;
707
+ n++;
708
+ }
709
+ }
710
+ if (n === 0) return null;
711
+
712
+ const hasGroups = channels.color !== undefined;
713
+ const channelsMap: ResolvedChannelMap<T> = {
714
+ x: synthAes(layout.orientation === "x" ? "bin" : measure, (_d, idx) =>
715
+ layout.orientation === "x" ? binMidpoints[idx] : binValues[idx],
716
+ ),
717
+ y: synthAes(layout.orientation === "x" ? measure : "bin", (_d, idx) =>
718
+ layout.orientation === "x" ? binValues[idx] : binMidpoints[idx],
719
+ ),
720
+ color: hasGroups ? synthAes("group", (_d, idx) => groupKeys[idx] ?? "") : undefined,
721
+ };
722
+
723
+ // Sized to half the smallest dodged-cell span so the cursor inside a
724
+ // bar's footprint picks it; off-axis still uses Euclidean default.
725
+ const pickRadius = Math.max(Number.isFinite(minBinSpan) ? minBinSpan / 2 : 0, 6);
726
+
727
+ return {
728
+ geomKind: "histogram",
729
+ label: options.label,
730
+ positions: positions.subarray(0, n * 2),
731
+ rects: rects.subarray(0, n * 4),
732
+ dataIndex: dataIndex.subarray(0, n),
733
+ seriesKey: seriesKey.slice(0, n),
734
+ pickRadius,
735
+ channels: channelsMap,
736
+ data,
737
+ };
738
+ },
739
+ };
740
+ }