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,1054 @@
1
+ // ---------------------------------------------------------------------------
2
+ // bar geom
3
+ // ---------------------------------------------------------------------------
4
+
5
+ import { lerpColor, type Color, type Layer } from "insomni";
6
+ import {
7
+ categoricalBarMark,
8
+ groupedBarMark,
9
+ stackedBarMark,
10
+ type BarBorderStyle,
11
+ type BarOrientation,
12
+ type StackOffset,
13
+ type StackOrder,
14
+ } from "../../marks.ts";
15
+ import { stack } from "../../marks/stack.ts";
16
+ import { valueLabelMark } from "../../annotations.ts";
17
+ import { groupedBandScale, type BandScale, type ContinuousScale } from "../../scales.ts";
18
+ import type { Aes } from "../aes.ts";
19
+ import { resolveAes } from "../aes.ts";
20
+ import { alphaize, seriesColor } from "../color-utils.ts";
21
+ import { barSwatch } from "../../legend.ts";
22
+ import type {
23
+ CompileContext,
24
+ CompiledHitTest,
25
+ Geom,
26
+ GeomFrame,
27
+ GeomHoverDecorator,
28
+ HoveredHit,
29
+ ResolvedChannelMap,
30
+ ScaleHints,
31
+ } from "./types.ts";
32
+ import {
33
+ inlineMark,
34
+ resolveCoord,
35
+ resolveFillAlpha,
36
+ defaultMarkFill,
37
+ selectChannels,
38
+ selectedIndicesFor,
39
+ selectedSegmentsFor,
40
+ selectionActive,
41
+ SELECTION_DIM_ALPHA,
42
+ wrapMark,
43
+ } from "./_mark.ts";
44
+ import { emphasisContext } from "./emphasis.ts";
45
+ import { barRect } from "./_shape.ts";
46
+ import { DEFAULT_GROUP_PADDING } from "./_categorical.ts";
47
+ import type { MarkBuilder } from "../../marks.ts";
48
+
49
+ export type BarPosition = "identity" | "stack" | "dodge" | "fill";
50
+
51
+ export interface BarChannels<T> {
52
+ x: Aes<T, string | number | Date>;
53
+ /**
54
+ * Single column → simple bars (one per category). Array of column keys →
55
+ * multi-series bars (stacked / dodged / 100% stacked).
56
+ *
57
+ * For horizontal layouts (`orientation: "x"`) the single-column form carries
58
+ * the band category, so a string is accepted; the value axis then lives on
59
+ * `x`.
60
+ */
61
+ y: Aes<T, string | number | Date> | readonly (keyof T & string)[];
62
+ color?: Aes<T, unknown>;
63
+ }
64
+
65
+ export interface BarOptions<T = unknown> {
66
+ orientation?: BarOrientation;
67
+ fill?: Color;
68
+ stroke?: Color;
69
+ strokeWidth?: number;
70
+ cornerRadius?: number;
71
+ /**
72
+ * Border treatment shared across every bar (or segment, for stacked/dodged
73
+ * layouts). See {@link BarBorderStyle}.
74
+ */
75
+ borderStyle?: BarBorderStyle;
76
+ /**
77
+ * Multi-series layout. Only meaningful when `y` (or `x`) is an array of keys.
78
+ * - `"stack"` (default for arrays) — segments stacked from 0
79
+ * - `"dodge"` — bars side-by-side within each category
80
+ * - `"fill"` — stacked, normalized to [0, 1]
81
+ * - `"identity"` — each datum drawn at its raw value
82
+ */
83
+ position?: BarPosition;
84
+ order?: StackOrder;
85
+ label?: string;
86
+ /** Inner-band padding for `position: 'dodge'`. Default 0.05. */
87
+ groupPadding?: number;
88
+ /** If set, render a label above each bar with the per-category total. */
89
+ showTotals?: (total: number, datum: T, datumIndex: number) => string;
90
+ /**
91
+ * If set, render a label per-bar (or per-segment for stacked / fill) with
92
+ * the segment's value. For `fill`, the supplied `value` is normalized
93
+ * `[0, 1]`; for other positions it's the raw segment value. `key` is the
94
+ * column name for multi-series bars, `undefined` for simple bars.
95
+ */
96
+ showValues?: (value: number, datum: T, datumIndex: number, key?: string) => string;
97
+ /** Override label color. */
98
+ labelColor?: Color;
99
+ /** Override label font size. */
100
+ labelFontSize?: number;
101
+ }
102
+
103
+ export function bar<T>(channels: BarChannels<T>, options: BarOptions<T> = {}): Geom<T> {
104
+ const isMultiSeries = Array.isArray(channels.y);
105
+ const position: BarPosition = options.position ?? (isMultiSeries ? "stack" : "identity");
106
+ // Bars baseline at 0 on their value axis. Orientation isn't known until
107
+ // compile-time (depends on scale types); for non-multi-series we hint
108
+ // both axes — band scales ignore numeric hints, so only the value axis
109
+ // is actually anchored.
110
+ const scaleHints: ScaleHints = isMultiSeries
111
+ ? position === "fill"
112
+ ? { y: { domain: [0, 1] } }
113
+ : { y: { includeZero: true } }
114
+ : { x: { includeZero: true }, y: { includeZero: true } };
115
+ return {
116
+ kind: "bar",
117
+ channels: { x: channels.x, y: channels.y, color: channels.color },
118
+ label: options.label,
119
+ scaleHints,
120
+ legendSwatch: (color) => barSwatch({ fill: color, size: 12 }),
121
+ compile(ctx: CompileContext<T>) {
122
+ const { data, scales, plot, theme, atlas } = ctx;
123
+ const coord = resolveCoord(ctx);
124
+ // Route a (bandStart, valuePx) pair through the active coord, returning
125
+ // the projected pair in plot-frame pixel space. Under `coordCartesian()`
126
+ // this is the identity. Polar (Phase 3) replaces this geom's mark
127
+ // factories outright (bars → annular sectors), so the projection here
128
+ // affects only the geom-owned overlays (labels, halos, rings, hit-test).
129
+ const projectBand = (
130
+ orient: "x" | "y",
131
+ bandStart: number,
132
+ valuePx: number,
133
+ ): { bandStart: number; valuePx: number } => {
134
+ const projected =
135
+ orient === "y"
136
+ ? coord.project({ x: bandStart, y: valuePx })
137
+ : coord.project({ x: valuePx, y: bandStart });
138
+ return orient === "y"
139
+ ? { bandStart: projected.x, valuePx: projected.y }
140
+ : { bandStart: projected.y, valuePx: projected.x };
141
+ };
142
+ const cornerRadius = options.cornerRadius ?? theme.marks.barCornerRadius;
143
+ const stroke = options.stroke;
144
+ const strokeWidth = options.strokeWidth;
145
+
146
+ // Detect orientation from scale types unless overridden:
147
+ // x is band, y is continuous → vertical (orientation "y")
148
+ // x is continuous, y is band → horizontal (orientation "x")
149
+ const orientation: "x" | "y" =
150
+ options.orientation ??
151
+ (scales.x.type === "band" ? "y" : scales.y.type === "band" ? "x" : "y");
152
+
153
+ const { categoryChannel, valueChannel } = selectChannels(orientation, channels);
154
+ const categoryAes = resolveAes<T, unknown>(categoryChannel as Aes<T, unknown>);
155
+ const bandAxis = (
156
+ orientation === "y" ? scales.x.axisScale : scales.y.axisScale
157
+ ) as BandScale<string>;
158
+ const valueAxis = (
159
+ orientation === "y" ? scales.y.axisScale : scales.x.axisScale
160
+ ) as ContinuousScale;
161
+ const labelColor: Color = options.labelColor ?? theme.text.color;
162
+ const labelFontSize = options.labelFontSize ?? theme.marks.labelFontSize;
163
+ const anim = ctx.activeTransition;
164
+ const rowKey = (d: T, i: number) => (ctx.transitionKey ? ctx.transitionKey(d, i) : String(i));
165
+ const fromRowIndex = (d: T, i: number) => anim?.matchIndex(rowKey(d, i), i);
166
+ const fromSegmentIndex = (d: T, i: number, key: string, fallbackIndex?: number) =>
167
+ anim?.matchIndex(`${rowKey(d, i)}:${key}`, fallbackIndex);
168
+
169
+ if (!isMultiSeries) {
170
+ const valueAes = resolveAes<T, number>(valueChannel as Aes<T, number>);
171
+ const colorAes = channels.color ? resolveAes<T, unknown>(channels.color) : undefined;
172
+ const colorScaleFn = scales.color?.fn;
173
+ const baseFill: Color = options.fill ?? defaultMarkFill(theme);
174
+ const baseFillFor =
175
+ colorAes && colorScaleFn
176
+ ? (d: T, i: number) => alphaize(colorScaleFn(colorAes.fn(d, i)), theme.marks.fillAlpha)
177
+ : alphaize(baseFill, theme.marks.fillAlpha);
178
+ const selectedSet = selectedIndicesFor(ctx, "bar");
179
+ const dim = selectionActive(ctx);
180
+ // Hover emphasis — the dim-others treatment rides the core's GPU emphasis
181
+ // uniform (P5-T3): tag each bar with a stable per-row key; the mount fades
182
+ // non-focused bars with no marks recompile. Selection dim stays compile-
183
+ // time (it forces a full recompile already). No compile-time hover dim.
184
+ const emph = emphasisContext(ctx, "bar");
185
+ const fill = dim
186
+ ? (d: T, i: number) => {
187
+ const c = typeof baseFillFor === "function" ? baseFillFor(d, i) : baseFillFor;
188
+ return selectedSet?.has(i) ? c : alphaize(c, SELECTION_DIM_ALPHA);
189
+ }
190
+ : baseFillFor;
191
+ const isHidden = (d: T, i: number) => {
192
+ if (!ctx.hidden || !colorAes) return false;
193
+ return ctx.hidden.has(String(colorAes.fn(d, i)));
194
+ };
195
+
196
+ const mark = categoricalBarMark<T, string>(data, {
197
+ orientation,
198
+ category: (d, i) => String(categoryAes.fn(d, i)),
199
+ value: (d, i) => {
200
+ if (isHidden(d, i)) return NaN;
201
+ const toV = valueAes.fn(d, i);
202
+ if (!anim) return toV;
203
+ const fromIndex = fromRowIndex(d, i);
204
+ if (fromIndex === undefined) return toV * anim.t;
205
+ const fromV = anim.from.r?.[fromIndex];
206
+ if (fromV === undefined || !Number.isFinite(fromV)) return toV;
207
+ return fromV + (toV - fromV) * anim.t;
208
+ },
209
+ categoryScale: bandAxis,
210
+ valueScale: valueAxis,
211
+ fill: anim
212
+ ? (d: T, i: number): Color => {
213
+ const toFill =
214
+ typeof fill === "function"
215
+ ? (fill as (d: T, i: number) => Color)(d, i)
216
+ : (fill as Color);
217
+ const fromIndex = fromRowIndex(d, i);
218
+ if (fromIndex === undefined) return { ...toFill, a: toFill.a * anim.t };
219
+ const fromFill: Color = {
220
+ r: anim.from.rgba[fromIndex * 4]!,
221
+ g: anim.from.rgba[fromIndex * 4 + 1]!,
222
+ b: anim.from.rgba[fromIndex * 4 + 2]!,
223
+ a: anim.from.rgba[fromIndex * 4 + 3]!,
224
+ };
225
+ return lerpColor(fromFill, toFill, anim.t);
226
+ }
227
+ : fill,
228
+ stroke,
229
+ strokeWidth,
230
+ cornerRadius,
231
+ borderStyle: options.borderStyle,
232
+ emphasisKey: emph ? (_d, i) => emph.keyFor(i) : undefined,
233
+ });
234
+ const builders: MarkBuilder[] = [wrapMark(mark, plot.topLeft, data.length)];
235
+
236
+ // Selection rings — outline each selected bar. Reuses the bbox math
237
+ // from the hover halo above.
238
+ if (selectedSet) {
239
+ for (const i of selectedSet) {
240
+ const d = data[i];
241
+ if (d === undefined) continue;
242
+ const cv = categoryAes.fn(d, i);
243
+ const vv = valueAes.fn(d, i);
244
+ if (cv == null || vv == null || !Number.isFinite(vv)) continue;
245
+ const projVal = projectBand(orientation, bandAxis(String(cv)), valueAxis(vv));
246
+ const projBase = projectBand(orientation, bandAxis(String(cv)), valueAxis(0));
247
+ const rect = barRect(
248
+ orientation,
249
+ projVal.bandStart,
250
+ bandAxis.bandwidth(),
251
+ projVal.valuePx,
252
+ projBase.valuePx,
253
+ plot.topLeft,
254
+ );
255
+ if (!rect) continue;
256
+ const ringColor: Color =
257
+ colorAes && colorScaleFn
258
+ ? colorScaleFn(colorAes.fn(d, i))
259
+ : (options.fill ?? defaultMarkFill(theme));
260
+ builders.push(
261
+ inlineMark((layer) =>
262
+ layer.pushRect({
263
+ ...rect,
264
+ cornerRadius,
265
+ stroke: ringColor,
266
+ strokeWidth: 2,
267
+ }),
268
+ ),
269
+ );
270
+ }
271
+ }
272
+
273
+ const simpleLabel = options.showValues ?? options.showTotals;
274
+ if (atlas && simpleLabel) {
275
+ const bw = bandAxis.bandwidth();
276
+ const projectSimpleLabel = (d: T, i: number): { x: number; y: number } => {
277
+ const bandStart = bandAxis(String(categoryAes.fn(d, i))) + bw / 2;
278
+ const valuePx = valueAxis(valueAes.fn(d, i));
279
+ const proj = projectBand(orientation, bandStart, valuePx);
280
+ return orientation === "y"
281
+ ? { x: proj.bandStart, y: proj.valuePx }
282
+ : { x: proj.valuePx, y: proj.bandStart };
283
+ };
284
+ const labelMark = valueLabelMark(data, {
285
+ x: (d, i) => projectSimpleLabel(d, i).x,
286
+ y: (d, i) => projectSimpleLabel(d, i).y,
287
+ text: (d, i) => simpleLabel(valueAes.fn(d, i), d, i),
288
+ color: labelColor,
289
+ fontSize: labelFontSize,
290
+ align: orientation === "y" ? "center" : "left",
291
+ offset: orientation === "y" ? { y: -6 } : { x: 6 },
292
+ });
293
+ builders.push(wrapMark(labelMark, plot.topLeft, data.length));
294
+ }
295
+ return builders;
296
+ }
297
+
298
+ // ----- multi-series -----
299
+ const keys = valueChannel as readonly (keyof T & string)[];
300
+ // activeKeys excludes hidden series; color domain stays full-keys so
301
+ // visible series keep stable colors when siblings are toggled.
302
+ const activeKeys = ctx.hidden ? keys.filter((k) => !ctx.hidden!.has(k)) : keys;
303
+ const fillFor = seriesColor(scales.color, theme.palettes.categorical, [...keys] as string[]);
304
+ // Pre-compute fills once: `fillFor` runs through a Map lookup + palette
305
+ // call on every invocation, and the inner mark builders call it per
306
+ // datum × key. Caching collapses that to one lookup per series.
307
+ const fillByKey = new Map<string, Color>();
308
+ for (const k of activeKeys) fillByKey.set(k, fillFor(k));
309
+ const builders: ReturnType<typeof wrapMark>[] = [];
310
+ // When a selection is active, the base mark renders uniformly dimmed and
311
+ // we re-overlay the selected segments at full color on top (the underlying
312
+ // mark APIs only accept per-key fills, not per-(datum, key) — overlays
313
+ // are simpler than threading a richer color callback through).
314
+ const dimMulti = selectionActive(ctx);
315
+ const baseFillAlpha = resolveFillAlpha(dimMulti, theme);
316
+ // GPU hover emphasis (P5-T3) — one key per (datum, series) segment. The
317
+ // ordinal packs the segment index into the geom's key band; the resolver
318
+ // (`emphasisResolution` below) recomputes the SAME ordinal from a hit's
319
+ // (dataIndex, seriesKey) using this keys list. Stable across the full keys
320
+ // (not activeKeys) so hiding a series doesn't renumber the rest.
321
+ const emphMulti = emphasisContext(ctx, "bar", (hit) =>
322
+ multiEmphasisOrdinal(hit.dataIndex, hit.seriesKey, keys),
323
+ );
324
+ const segmentEmphasisKey = (dataIndex: number, key: string): number | undefined => {
325
+ if (!emphMulti) return undefined;
326
+ const ord = multiEmphasisOrdinal(dataIndex, key, keys);
327
+ return ord === null ? undefined : emphMulti.keyFor(ord);
328
+ };
329
+
330
+ if (position === "dodge") {
331
+ const inner = groupedBandScale<string, string>(bandAxis, [...activeKeys] as string[], {
332
+ padding: options.groupPadding ?? DEFAULT_GROUP_PADDING,
333
+ });
334
+ const mark = groupedBarMark<T, string, string>(data, {
335
+ orientation,
336
+ category: (d, i) => String(categoryAes.fn(d, i)),
337
+ groupedScale: inner,
338
+ valueScale: valueAxis,
339
+ value: (d, key, datumIndex) => {
340
+ const toV = (d as Record<string, number>)[key] ?? 0;
341
+ if (!anim) return toV;
342
+ const subIndex = activeKeys.indexOf(key as keyof T & string);
343
+ const fallbackIndex =
344
+ subIndex >= 0 ? datumIndex * activeKeys.length + subIndex : undefined;
345
+ const fromIndex = fromSegmentIndex(d, datumIndex, key, fallbackIndex);
346
+ if (fromIndex === undefined) return toV * anim.t;
347
+ const fromV = anim.from.r?.[fromIndex];
348
+ if (fromV === undefined || !Number.isFinite(fromV)) return toV;
349
+ return fromV + (toV - fromV) * anim.t;
350
+ },
351
+ color: (key) => {
352
+ const toFill = alphaize(fillByKey.get(key) ?? fillFor(key), baseFillAlpha);
353
+ if (!anim) return toFill;
354
+ const probeDatum = data[0];
355
+ if (probeDatum === undefined) return toFill;
356
+ const subIndex = activeKeys.indexOf(key as keyof T & string);
357
+ const fromIndex = fromSegmentIndex(
358
+ probeDatum,
359
+ 0,
360
+ key,
361
+ subIndex >= 0 ? subIndex : undefined,
362
+ );
363
+ if (fromIndex === undefined) return { ...toFill, a: toFill.a * anim.t };
364
+ const fromFill: Color = {
365
+ r: anim.from.rgba[fromIndex * 4]!,
366
+ g: anim.from.rgba[fromIndex * 4 + 1]!,
367
+ b: anim.from.rgba[fromIndex * 4 + 2]!,
368
+ a: anim.from.rgba[fromIndex * 4 + 3]!,
369
+ };
370
+ return lerpColor(fromFill, toFill, anim.t);
371
+ },
372
+ cornerRadius,
373
+ stroke,
374
+ strokeWidth,
375
+ borderStyle: options.borderStyle,
376
+ emphasisKey: (d, key, datumIndex) => segmentEmphasisKey(datumIndex, key),
377
+ });
378
+ builders.push(wrapMark(mark, plot.topLeft));
379
+
380
+ const ibw = inner.bandwidth();
381
+ const baseline = valueAxis(0);
382
+ const drawSegmentRing = (i: number, key: string, overlayFill: boolean) => {
383
+ const d = data[i];
384
+ if (d === undefined) return;
385
+ const cv = categoryAes.fn(d, i);
386
+ const vv = (d as Record<string, number>)[key];
387
+ if (cv == null || vv == null || !Number.isFinite(vv)) return;
388
+ const projVal = projectBand(orientation, inner(String(cv), key), valueAxis(vv));
389
+ const projBase = projectBand(orientation, inner(String(cv), key), baseline);
390
+ const rect = barRect(
391
+ orientation,
392
+ projVal.bandStart,
393
+ ibw,
394
+ projVal.valuePx,
395
+ projBase.valuePx,
396
+ plot.topLeft,
397
+ );
398
+ if (!rect) return;
399
+ const ringColor = fillByKey.get(key) ?? fillFor(key);
400
+ const overlay = overlayFill ? alphaize(ringColor, theme.marks.fillAlpha) : undefined;
401
+ builders.push(
402
+ inlineMark((layer) =>
403
+ layer.pushRect({
404
+ ...rect,
405
+ cornerRadius,
406
+ fill: overlay,
407
+ stroke: ringColor,
408
+ strokeWidth: 2,
409
+ }),
410
+ ),
411
+ );
412
+ };
413
+ const dodgeSelected = selectedSegmentsFor(ctx, "bar");
414
+ if (dodgeSelected) {
415
+ for (const { dataIndex: i, seriesKey: key } of dodgeSelected) {
416
+ if (key) drawSegmentRing(i, key, dimMulti);
417
+ }
418
+ }
419
+
420
+ if (atlas && options.showValues) {
421
+ const fmt = options.showValues;
422
+ const bw = inner.bandwidth();
423
+ for (const key of activeKeys) {
424
+ const projectDodgeLabel = (d: T, i: number): { x: number; y: number } => {
425
+ const bandStart = inner(String(categoryAes.fn(d, i)), key) + bw / 2;
426
+ const valuePx = valueAxis((d as Record<string, number>)[key] ?? 0);
427
+ const proj = projectBand(orientation, bandStart, valuePx);
428
+ return orientation === "y"
429
+ ? { x: proj.bandStart, y: proj.valuePx }
430
+ : { x: proj.valuePx, y: proj.bandStart };
431
+ };
432
+ const labelMark = valueLabelMark(data, {
433
+ x: (d, i) => projectDodgeLabel(d, i).x,
434
+ y: (d, i) => projectDodgeLabel(d, i).y,
435
+ text: (d, i) => fmt((d as Record<string, number>)[key] ?? 0, d, i, key),
436
+ color: labelColor,
437
+ fontSize: labelFontSize,
438
+ align: orientation === "y" ? "center" : "left",
439
+ offset: orientation === "y" ? { y: -4 } : { x: 4 },
440
+ });
441
+ builders.push(wrapMark(labelMark, plot.topLeft, data.length));
442
+ }
443
+ }
444
+ } else {
445
+ const offset: StackOffset = position === "fill" ? "expand" : "zero";
446
+ const stackedSegmentsCurrent = stack(data, [...activeKeys] as string[], {
447
+ offset,
448
+ order: options.order,
449
+ });
450
+ const stackedMark = stackedBarMark<T, string, string>(data, [...activeKeys], {
451
+ orientation,
452
+ category: (d, i) => String(categoryAes.fn(d, i)),
453
+ categoryScale: bandAxis,
454
+ valueScale: valueAxis,
455
+ value: (d, key, datumIndex) => {
456
+ const toV = (d as Record<string, number>)[key] ?? 0;
457
+ if (!anim) return toV;
458
+ const fallbackIndex = stackedSegmentsCurrent.findIndex(
459
+ (seg) => seg.datumIndex === datumIndex && seg.key === key,
460
+ );
461
+ const fromIndex = fromSegmentIndex(
462
+ d,
463
+ datumIndex,
464
+ key,
465
+ fallbackIndex >= 0 ? fallbackIndex : undefined,
466
+ );
467
+ if (fromIndex === undefined) return toV * anim.t;
468
+ const fromV = anim.from.r?.[fromIndex];
469
+ if (fromV === undefined || !Number.isFinite(fromV)) return toV;
470
+ return fromV + (toV - fromV) * anim.t;
471
+ },
472
+ color: (key, seg) => {
473
+ const toFill = alphaize(fillByKey.get(key) ?? fillFor(key), baseFillAlpha);
474
+ if (!anim) return toFill;
475
+ const fallbackIndex = stackedSegmentsCurrent.findIndex(
476
+ (s) => s.datumIndex === seg.datumIndex && s.key === key,
477
+ );
478
+ const fromIndex = fromSegmentIndex(
479
+ seg.datum,
480
+ seg.datumIndex,
481
+ key,
482
+ fallbackIndex >= 0 ? fallbackIndex : undefined,
483
+ );
484
+ if (fromIndex === undefined) return { ...toFill, a: toFill.a * anim.t };
485
+ const fromFill: Color = {
486
+ r: anim.from.rgba[fromIndex * 4]!,
487
+ g: anim.from.rgba[fromIndex * 4 + 1]!,
488
+ b: anim.from.rgba[fromIndex * 4 + 2]!,
489
+ a: anim.from.rgba[fromIndex * 4 + 3]!,
490
+ };
491
+ return lerpColor(fromFill, toFill, anim.t);
492
+ },
493
+ offset,
494
+ order: options.order,
495
+ cornerRadius,
496
+ stroke,
497
+ strokeWidth,
498
+ borderStyle: options.borderStyle,
499
+ emphasisKey: (key, seg) => segmentEmphasisKey(seg.datumIndex, key),
500
+ });
501
+ builders.push(wrapMark(stackedMark, plot.topLeft));
502
+
503
+ const stackBw = bandAxis.bandwidth();
504
+ const drawStackedRing = (i: number, key: string, overlayFill: boolean) => {
505
+ const seg = stackedSegmentsCurrent.find((s) => s.datumIndex === i && s.key === key);
506
+ if (!seg) return;
507
+ const cat = categoryAes.fn(seg.datum, seg.datumIndex);
508
+ if (cat == null) return;
509
+ const bandStart = bandAxis(String(cat));
510
+ const basePx = valueAxis(seg.base);
511
+ const topPx = valueAxis(seg.top);
512
+ if (!Number.isFinite(bandStart) || !Number.isFinite(basePx) || !Number.isFinite(topPx))
513
+ return;
514
+ const projBase = projectBand(orientation, bandStart, basePx);
515
+ const projTop = projectBand(orientation, bandStart, topPx);
516
+ const ox = plot.topLeft.x;
517
+ const oy = plot.topLeft.y;
518
+ const lo = Math.min(projBase.valuePx, projTop.valuePx);
519
+ const span = Math.abs(projTop.valuePx - projBase.valuePx);
520
+ const projectedBand = projBase.bandStart;
521
+ const rx = ox + (orientation === "y" ? projectedBand : lo);
522
+ const ry = oy + (orientation === "y" ? lo : projectedBand);
523
+ const rw = orientation === "y" ? stackBw : span;
524
+ const rh = orientation === "y" ? span : stackBw;
525
+ const ringColor = fillByKey.get(key) ?? fillFor(key);
526
+ const overlay = overlayFill ? alphaize(ringColor, theme.marks.fillAlpha) : undefined;
527
+ builders.push(
528
+ inlineMark((layer) =>
529
+ layer.pushRect({
530
+ x: rx,
531
+ y: ry,
532
+ width: rw,
533
+ height: rh,
534
+ cornerRadius,
535
+ fill: overlay,
536
+ stroke: ringColor,
537
+ strokeWidth: 2,
538
+ }),
539
+ ),
540
+ );
541
+ };
542
+ const stackedSelected = selectedSegmentsFor(ctx, "bar");
543
+ if (stackedSelected) {
544
+ for (const { dataIndex: i, seriesKey: key } of stackedSelected) {
545
+ if (key) drawStackedRing(i, key, dimMulti);
546
+ }
547
+ }
548
+
549
+ if (atlas && options.showValues) {
550
+ const fmt = options.showValues;
551
+ const segs = stackedMark.segments;
552
+ const bw = bandAxis.bandwidth();
553
+ const projectSegLabel = (seg: (typeof segs)[number]): { x: number; y: number } => {
554
+ const cat = String(categoryAes.fn(seg.datum, seg.datumIndex));
555
+ const bandStart = bandAxis(cat) + bw / 2;
556
+ const valuePx = valueAxis((seg.base + seg.top) / 2);
557
+ const proj = projectBand(orientation, bandStart, valuePx);
558
+ return orientation === "y"
559
+ ? { x: proj.bandStart, y: proj.valuePx }
560
+ : { x: proj.valuePx, y: proj.bandStart };
561
+ };
562
+ const segLabel = valueLabelMark(segs, {
563
+ x: (seg) => projectSegLabel(seg).x,
564
+ y: (seg) => projectSegLabel(seg).y,
565
+ text: (seg) => fmt(seg.value, seg.datum, seg.datumIndex, seg.key),
566
+ color: labelColor,
567
+ fontSize: labelFontSize,
568
+ align: "center",
569
+ offset: { x: 0, y: 0 },
570
+ });
571
+ builders.push(wrapMark(segLabel, plot.topLeft, segs.length));
572
+ }
573
+
574
+ if (atlas && options.showTotals && position !== "fill") {
575
+ const fmt = options.showTotals;
576
+ const totals = data.map((d) =>
577
+ activeKeys.reduce((s, k) => s + ((d as Record<string, number>)[k] ?? 0), 0),
578
+ );
579
+ const bw = bandAxis.bandwidth();
580
+ const projectTotalLabel = (d: T, i: number): { x: number; y: number } => {
581
+ const bandStart = bandAxis(String(categoryAes.fn(d, i))) + bw / 2;
582
+ const valuePx = valueAxis(totals[i] ?? 0);
583
+ const proj = projectBand(orientation, bandStart, valuePx);
584
+ return orientation === "y"
585
+ ? { x: proj.bandStart, y: proj.valuePx }
586
+ : { x: proj.valuePx, y: proj.bandStart };
587
+ };
588
+ const totalLabel = valueLabelMark(data, {
589
+ x: (d, i) => projectTotalLabel(d, i).x,
590
+ y: (d, i) => projectTotalLabel(d, i).y,
591
+ text: (d, i) => fmt(totals[i] ?? 0, d, i),
592
+ color: labelColor,
593
+ fontSize: labelFontSize,
594
+ align: orientation === "y" ? "center" : "left",
595
+ offset: orientation === "y" ? { y: -6 } : { x: 6 },
596
+ });
597
+ builders.push(wrapMark(totalLabel, plot.topLeft, data.length));
598
+ }
599
+ }
600
+
601
+ return builders;
602
+ },
603
+ emphasisResolution(ctx) {
604
+ if (!isMultiSeries) {
605
+ // Single bars: ordinal = dataIndex (the default).
606
+ return emphasisContext(ctx, "bar")?.resolver() ?? null;
607
+ }
608
+ // Multi-series: pack (dataIndex, seriesKey) through the stable keys list.
609
+ const keys = channels.y as readonly (keyof T & string)[];
610
+ return (
611
+ emphasisContext(ctx, "bar", (hit) =>
612
+ multiEmphasisOrdinal(hit.dataIndex, hit.seriesKey, keys as readonly string[]),
613
+ )?.resolver() ?? null
614
+ );
615
+ },
616
+ hoverDecoration(ctx: CompileContext<T>): GeomHoverDecorator | null {
617
+ // Focus halo on the hovered bar — recovered from the old snap path's
618
+ // compile-time outline (deleted in e6d5643). Now an OVERLAY decorator: the
619
+ // mount replays it into the live overlay layer (NO marks recompile), so the
620
+ // halo reads on top of the (GPU-dimmed) marks. Overlay shapes leave the
621
+ // default emphasisKey 0 ⇒ EXEMPT ⇒ the halo stays full-strength while the
622
+ // other bars dim. Mirrors point.ts's decorator (re-derive geometry, stroke
623
+ // width/color from theme.interactions.hover with the same fallback shape).
624
+ const { data, plot } = ctx;
625
+ if (data.length === 0) return null;
626
+ const coord = resolveCoord(ctx);
627
+ const hoverCfg = ctx.theme.interactions.hover;
628
+
629
+ const orientation: "x" | "y" =
630
+ options.orientation ??
631
+ (ctx.scales.x.type === "band" ? "y" : ctx.scales.y.type === "band" ? "x" : "y");
632
+ const { categoryChannel, valueChannel } = selectChannels(orientation, channels);
633
+ const categoryAes = resolveAes<T, unknown>(categoryChannel as Aes<T, unknown>);
634
+ const colorAes = channels.color ? resolveAes<T, unknown>(channels.color) : undefined;
635
+ const colorScaleFn = ctx.scales.color?.fn;
636
+ const bandAxis = (
637
+ orientation === "y" ? ctx.scales.x.axisScale : ctx.scales.y.axisScale
638
+ ) as BandScale<string>;
639
+ const valueAxis = (
640
+ orientation === "y" ? ctx.scales.y.axisScale : ctx.scales.x.axisScale
641
+ ) as ContinuousScale;
642
+ const cornerRadius = options.cornerRadius ?? ctx.theme.marks.barCornerRadius;
643
+ const fillFor = isMultiSeries
644
+ ? seriesColor(ctx.scales.color, ctx.theme.palettes.categorical, [
645
+ ...(valueChannel as readonly (keyof T & string)[]),
646
+ ] as string[])
647
+ : undefined;
648
+ // Same band-projection helper compile uses (coord.project; Cartesian = id).
649
+ const projectBand = (bandStart: number, valuePx: number) => {
650
+ const p =
651
+ orientation === "y"
652
+ ? coord.project({ x: bandStart, y: valuePx })
653
+ : coord.project({ x: valuePx, y: bandStart });
654
+ return orientation === "y"
655
+ ? { bandStart: p.x, valuePx: p.y }
656
+ : { bandStart: p.y, valuePx: p.x };
657
+ };
658
+
659
+ const haloFor = (
660
+ bandStart: number,
661
+ bandwidth: number,
662
+ valuePx: number,
663
+ ringColor: Color,
664
+ layer: Layer,
665
+ ): void => {
666
+ const projVal = projectBand(bandStart, valuePx);
667
+ const projBase = projectBand(bandStart, valueAxis(0));
668
+ const rect = barRect(
669
+ orientation,
670
+ projVal.bandStart,
671
+ bandwidth,
672
+ projVal.valuePx,
673
+ projBase.valuePx,
674
+ plot.topLeft,
675
+ );
676
+ if (!rect) return;
677
+ layer.pushRect({
678
+ ...rect,
679
+ cornerRadius,
680
+ stroke: hoverCfg.haloColor ?? ringColor,
681
+ strokeWidth: hoverCfg.haloStrokeWidth,
682
+ });
683
+ };
684
+
685
+ return {
686
+ geomKind: "bar",
687
+ data,
688
+ decorate(hit: HoveredHit, layer: Layer): void {
689
+ if (!hoverCfg.enabled || hit.data !== data) return;
690
+ const i = hit.dataIndex;
691
+ const d = data[i];
692
+ if (d === undefined) return;
693
+ const cv = categoryAes.fn(d, i);
694
+ if (cv == null) return;
695
+
696
+ if (!isMultiSeries) {
697
+ const valueAes = resolveAes<T, number>(valueChannel as Aes<T, number>);
698
+ const vv = valueAes.fn(d, i);
699
+ if (vv == null || !Number.isFinite(vv)) return;
700
+ const ringColor: Color =
701
+ colorAes && colorScaleFn
702
+ ? colorScaleFn(colorAes.fn(d, i))
703
+ : (options.fill ?? defaultMarkFill(ctx.theme));
704
+ haloFor(bandAxis(String(cv)), bandAxis.bandwidth(), valueAxis(vv), ringColor, layer);
705
+ return;
706
+ }
707
+
708
+ // Multi-series: the hit's seriesKey identifies the segment.
709
+ const key = hit.seriesKey;
710
+ if (key === undefined) return;
711
+ const ringColor = fillFor!(key);
712
+ if (position === "dodge") {
713
+ const keys = valueChannel as readonly (keyof T & string)[];
714
+ const activeKeys = ctx.hidden ? keys.filter((k) => !ctx.hidden!.has(k)) : keys;
715
+ const inner = groupedBandScale<string, string>(bandAxis, [...activeKeys] as string[], {
716
+ padding: options.groupPadding ?? DEFAULT_GROUP_PADDING,
717
+ });
718
+ const vv = (d as Record<string, number>)[key];
719
+ if (vv == null || !Number.isFinite(vv)) return;
720
+ haloFor(inner(String(cv), key), inner.bandwidth(), valueAxis(vv), ringColor, layer);
721
+ } else {
722
+ // stack / fill: re-stack to find this segment's [base, top].
723
+ const keys = valueChannel as readonly (keyof T & string)[];
724
+ const activeKeys = ctx.hidden ? keys.filter((k) => !ctx.hidden!.has(k)) : keys;
725
+ const segs = stack(data, [...activeKeys] as string[], {
726
+ offset: position === "fill" ? "expand" : "zero",
727
+ order: options.order,
728
+ });
729
+ const seg = segs.find((s) => s.datumIndex === i && s.key === key);
730
+ if (!seg) return;
731
+ const bandStart = bandAxis(String(cv));
732
+ const basePx = valueAxis(seg.base);
733
+ const topPx = valueAxis(seg.top);
734
+ if (
735
+ !Number.isFinite(bandStart) ||
736
+ !Number.isFinite(basePx) ||
737
+ !Number.isFinite(topPx)
738
+ ) {
739
+ return;
740
+ }
741
+ const projBase = projectBand(bandStart, basePx);
742
+ const projTop = projectBand(bandStart, topPx);
743
+ const ox = plot.topLeft.x;
744
+ const oy = plot.topLeft.y;
745
+ const lo = Math.min(projBase.valuePx, projTop.valuePx);
746
+ const span = Math.abs(projTop.valuePx - projBase.valuePx);
747
+ const bw = bandAxis.bandwidth();
748
+ layer.pushRect({
749
+ x: ox + (orientation === "y" ? projBase.bandStart : lo),
750
+ y: oy + (orientation === "y" ? lo : projBase.bandStart),
751
+ width: orientation === "y" ? bw : span,
752
+ height: orientation === "y" ? span : bw,
753
+ cornerRadius,
754
+ stroke: hoverCfg.haloColor ?? ringColor,
755
+ strokeWidth: hoverCfg.haloStrokeWidth,
756
+ });
757
+ }
758
+ },
759
+ };
760
+ },
761
+ compileHitTest(ctx: CompileContext<T>): CompiledHitTest<T> | null {
762
+ const { data, scales, plot, hidden } = ctx;
763
+ if (data.length === 0) return null;
764
+ const coord = resolveCoord(ctx);
765
+
766
+ const orientation: "x" | "y" =
767
+ options.orientation ??
768
+ (scales.x.type === "band" ? "y" : scales.y.type === "band" ? "x" : "y");
769
+
770
+ const { categoryChannel, valueChannel } = selectChannels(orientation, channels);
771
+ const categoryAes = resolveAes<T, unknown>(categoryChannel as Aes<T, unknown>);
772
+ const simpleValueAes = !isMultiSeries
773
+ ? resolveAes<T, number>(valueChannel as Aes<T, number>)
774
+ : undefined;
775
+ const colorAes = channels.color ? resolveAes<T, unknown>(channels.color) : undefined;
776
+
777
+ const bandAxis = (
778
+ orientation === "y" ? scales.x.axisScale : scales.y.axisScale
779
+ ) as BandScale<string>;
780
+ const valueAxis = (
781
+ orientation === "y" ? scales.y.axisScale : scales.x.axisScale
782
+ ) as ContinuousScale;
783
+ const bw = bandAxis.bandwidth();
784
+ const baseline = valueAxis(0);
785
+
786
+ const ox = plot.topLeft.x;
787
+ const oy = plot.topLeft.y;
788
+ const positionsList: number[] = [];
789
+ const rectsList: number[] = [];
790
+ const dataIndexList: number[] = [];
791
+ const seriesKeyList: (string | undefined)[] = [];
792
+ let maxHalfHeight = 0;
793
+
794
+ // Helper: push one hit entry given the bar's band-start, bar-bw, and
795
+ // the value-axis pixel extent [lo, hi]. Computes both position and rect
796
+ // for whole-bar footprint hit testing.
797
+ const pushEntry = (
798
+ bandStart: number,
799
+ barBw: number,
800
+ valueLo: number,
801
+ valueHi: number,
802
+ datumIndex: number,
803
+ seriesK: string | undefined,
804
+ ) => {
805
+ const bandCenter = bandStart + barBw / 2;
806
+ const valueCenter = (valueLo + valueHi) / 2;
807
+ const halfHeight = (valueHi - valueLo) / 2;
808
+ if (halfHeight > maxHalfHeight) maxHalfHeight = halfHeight;
809
+ // Hit-test positions go through `coord.project` so tooltips land on
810
+ // the visible mark under any coord. Cartesian: identity.
811
+ if (orientation === "y") {
812
+ const centerProj = coord.project({ x: bandCenter, y: valueCenter });
813
+ const cornerProj = coord.project({ x: bandStart, y: valueLo });
814
+ positionsList.push(ox + centerProj.x, oy + centerProj.y);
815
+ rectsList.push(ox + cornerProj.x, oy + cornerProj.y, barBw, valueHi - valueLo);
816
+ } else {
817
+ const centerProj = coord.project({ x: valueCenter, y: bandCenter });
818
+ const cornerProj = coord.project({ x: valueLo, y: bandStart });
819
+ positionsList.push(ox + centerProj.x, oy + centerProj.y);
820
+ rectsList.push(ox + cornerProj.x, oy + cornerProj.y, valueHi - valueLo, barBw);
821
+ }
822
+ dataIndexList.push(datumIndex);
823
+ seriesKeyList.push(seriesK);
824
+ };
825
+
826
+ if (!isMultiSeries) {
827
+ for (let i = 0; i < data.length; i++) {
828
+ const d = data[i]!;
829
+ if (hidden && colorAes && hidden.has(String(colorAes.fn(d, i)))) continue;
830
+ const cv = categoryAes.fn(d, i);
831
+ const vv = simpleValueAes!.fn(d, i);
832
+ if (cv == null || vv == null || !Number.isFinite(vv)) continue;
833
+ const bandStart = bandAxis(String(cv));
834
+ if (!Number.isFinite(bandStart)) continue;
835
+ const valuePx = valueAxis(vv);
836
+ if (!Number.isFinite(valuePx)) continue;
837
+ const lo = Math.min(baseline, valuePx);
838
+ const hi = Math.max(baseline, valuePx);
839
+ pushEntry(bandStart, bw, lo, hi, i, undefined);
840
+ }
841
+ } else {
842
+ const keys = valueChannel as readonly (keyof T & string)[];
843
+ const activeKeys = hidden ? keys.filter((k) => !hidden.has(k)) : keys;
844
+ if (position === "dodge") {
845
+ const inner = groupedBandScale<string, string>(bandAxis, [...activeKeys] as string[], {
846
+ padding: options.groupPadding ?? DEFAULT_GROUP_PADDING,
847
+ });
848
+ const innerBw = inner.bandwidth();
849
+ for (let i = 0; i < data.length; i++) {
850
+ const d = data[i]!;
851
+ const cat = categoryAes.fn(d, i);
852
+ if (cat == null) continue;
853
+ for (const key of activeKeys) {
854
+ const vv = (d as Record<string, number>)[key] ?? 0;
855
+ if (!Number.isFinite(vv)) continue;
856
+ const bandStart = inner(String(cat), key);
857
+ if (!Number.isFinite(bandStart)) continue;
858
+ const valuePx = valueAxis(vv);
859
+ if (!Number.isFinite(valuePx)) continue;
860
+ const lo = Math.min(baseline, valuePx);
861
+ const hi = Math.max(baseline, valuePx);
862
+ pushEntry(bandStart, innerBw, lo, hi, i, key);
863
+ }
864
+ }
865
+ } else {
866
+ const segments = stack(data, [...activeKeys] as string[], {
867
+ offset: position === "fill" ? "expand" : "zero",
868
+ order: options.order,
869
+ });
870
+ for (const seg of segments) {
871
+ const cat = categoryAes.fn(seg.datum, seg.datumIndex);
872
+ if (cat == null) continue;
873
+ const bandStart = bandAxis(String(cat));
874
+ if (!Number.isFinite(bandStart)) continue;
875
+ const basePx = valueAxis(seg.base);
876
+ const topPx = valueAxis(seg.top);
877
+ if (!Number.isFinite(basePx) || !Number.isFinite(topPx)) continue;
878
+ const lo = Math.min(basePx, topPx);
879
+ const hi = Math.max(basePx, topPx);
880
+ pushEntry(bandStart, bw, lo, hi, seg.datumIndex, seg.key);
881
+ }
882
+ }
883
+ }
884
+ const n = dataIndexList.length;
885
+ if (n === 0) return null;
886
+ const positions = Float32Array.from(positionsList);
887
+ const rects = Float32Array.from(rectsList);
888
+ const dataIndex = Int32Array.from(dataIndexList);
889
+
890
+ const channelsMap: ResolvedChannelMap<T> = {
891
+ x:
892
+ orientation === "y"
893
+ ? categoryAes
894
+ : isMultiSeries
895
+ ? undefined
896
+ : (simpleValueAes as ResolvedAesUnknown<T>),
897
+ y:
898
+ orientation === "y"
899
+ ? isMultiSeries
900
+ ? undefined
901
+ : (simpleValueAes as ResolvedAesUnknown<T>)
902
+ : categoryAes,
903
+ color: colorAes,
904
+ };
905
+
906
+ return {
907
+ geomKind: "bar",
908
+ label: options.label,
909
+ positions: positions.subarray(0, n * 2),
910
+ rects: rects.subarray(0, n * 4),
911
+ dataIndex: dataIndex.subarray(0, n),
912
+ seriesKey: seriesKeyList,
913
+ pickRadius: Math.max(bw / 2, maxHalfHeight),
914
+ channels: channelsMap,
915
+ data,
916
+ };
917
+ },
918
+ captureFrame(ctx: CompileContext<T>): GeomFrame | null {
919
+ const { data, scales, theme } = ctx;
920
+ if (data.length === 0) return null;
921
+
922
+ const orientation: "x" | "y" =
923
+ options.orientation ??
924
+ (scales.x.type === "band" ? "y" : scales.y.type === "band" ? "x" : "y");
925
+
926
+ const { categoryChannel, valueChannel } = selectChannels(orientation, channels);
927
+ const categoryAes = resolveAes<T, unknown>(categoryChannel as Aes<T, unknown>);
928
+ const colorAes = channels.color ? resolveAes<T, unknown>(channels.color) : undefined;
929
+ const colorScaleFn = scales.color?.fn;
930
+ const baseFill: Color = options.fill ?? defaultMarkFill(theme);
931
+ const fillFor = seriesColor(scales.color, theme.palettes.categorical, [
932
+ ...(isMultiSeries ? (valueChannel as readonly (keyof T & string)[]) : []),
933
+ ] as string[]);
934
+
935
+ const bandAxis = (
936
+ orientation === "y" ? scales.x.axisScale : scales.y.axisScale
937
+ ) as BandScale<string>;
938
+ const bw = bandAxis.bandwidth();
939
+ const hiddenKeys = ctx.hidden;
940
+ const count = isMultiSeries
941
+ ? (valueChannel as readonly (keyof T & string)[]).reduce(
942
+ (sum, key) => sum + (hiddenKeys?.has(key) ? 0 : data.length),
943
+ 0,
944
+ )
945
+ : data.length;
946
+ const x = new Float32Array(count);
947
+ const y = new Float32Array(count);
948
+ const rgba = new Float32Array(count * 4);
949
+ const a = new Float32Array(count);
950
+ // r stores the raw domain value for bar height (used for lerp).
951
+ const r = new Float32Array(count);
952
+ const ids = ctx.transitionKey ? Array.from<string>({ length: count }) : undefined;
953
+
954
+ if (!isMultiSeries) {
955
+ const valueAes = resolveAes<T, number>(valueChannel as Aes<T, number>);
956
+ for (let i = 0; i < count; i++) {
957
+ const d = data[i]!;
958
+ const cv = categoryAes.fn(d, i);
959
+ const bandStart = bandAxis(String(cv));
960
+ const bandCenter = Number.isFinite(bandStart) ? bandStart + bw / 2 : NaN;
961
+ x[i] = orientation === "y" ? bandCenter : NaN;
962
+ y[i] = orientation === "y" ? NaN : bandCenter;
963
+ r[i] = valueAes.fn(d, i);
964
+ const c: Color = colorAes && colorScaleFn ? colorScaleFn(colorAes.fn(d, i)) : baseFill;
965
+ const alpha = theme.marks.fillAlpha;
966
+ rgba[i * 4] = c.r;
967
+ rgba[i * 4 + 1] = c.g;
968
+ rgba[i * 4 + 2] = c.b;
969
+ rgba[i * 4 + 3] = c.a * alpha;
970
+ a[i] = alpha;
971
+ if (ids) ids[i] = ctx.transitionKey!(d, i);
972
+ }
973
+ } else {
974
+ const keys = valueChannel as readonly (keyof T & string)[];
975
+ const activeKeys = hiddenKeys ? keys.filter((k) => !hiddenKeys.has(k)) : keys;
976
+ const alpha = theme.marks.fillAlpha;
977
+ // Cache fills per series to avoid per-datum scale lookup.
978
+ const fillByKey = new Map<string, Color>();
979
+ for (const k of activeKeys) fillByKey.set(k, fillFor(k));
980
+ let n = 0;
981
+ if (position === "dodge") {
982
+ const inner = groupedBandScale<string, string>(bandAxis, [...activeKeys] as string[], {
983
+ padding: options.groupPadding ?? DEFAULT_GROUP_PADDING,
984
+ });
985
+ for (let i = 0; i < data.length; i++) {
986
+ const d = data[i]!;
987
+ const cat = categoryAes.fn(d, i);
988
+ for (const key of activeKeys) {
989
+ const bandStart = cat == null ? NaN : inner(String(cat), key);
990
+ const bandCenter = Number.isFinite(bandStart)
991
+ ? bandStart + inner.bandwidth() / 2
992
+ : NaN;
993
+ x[n] = orientation === "y" ? bandCenter : NaN;
994
+ y[n] = orientation === "y" ? NaN : bandCenter;
995
+ r[n] = (d as Record<string, number>)[key] ?? 0;
996
+ const c = fillByKey.get(key) ?? fillFor(key);
997
+ rgba[n * 4] = c.r;
998
+ rgba[n * 4 + 1] = c.g;
999
+ rgba[n * 4 + 2] = c.b;
1000
+ rgba[n * 4 + 3] = c.a * alpha;
1001
+ a[n] = alpha;
1002
+ if (ids) ids[n] = `${ctx.transitionKey!(d, i)}:${key}`;
1003
+ n++;
1004
+ }
1005
+ }
1006
+ } else {
1007
+ const segments = stack(data, [...activeKeys] as string[], {
1008
+ offset: position === "fill" ? "expand" : "zero",
1009
+ order: options.order,
1010
+ });
1011
+ for (const seg of segments) {
1012
+ const cat = categoryAes.fn(seg.datum, seg.datumIndex);
1013
+ const bandStart = cat == null ? NaN : bandAxis(String(cat));
1014
+ const bandCenter = Number.isFinite(bandStart) ? bandStart + bw / 2 : NaN;
1015
+ x[n] = orientation === "y" ? bandCenter : NaN;
1016
+ y[n] = orientation === "y" ? NaN : bandCenter;
1017
+ r[n] = (seg.datum as Record<string, number>)[seg.key] ?? 0;
1018
+ const c = fillByKey.get(seg.key) ?? fillFor(seg.key);
1019
+ rgba[n * 4] = c.r;
1020
+ rgba[n * 4 + 1] = c.g;
1021
+ rgba[n * 4 + 2] = c.b;
1022
+ rgba[n * 4 + 3] = c.a * alpha;
1023
+ a[n] = alpha;
1024
+ if (ids) ids[n] = `${ctx.transitionKey!(seg.datum, seg.datumIndex)}:${seg.key}`;
1025
+ n++;
1026
+ }
1027
+ }
1028
+ }
1029
+
1030
+ return { count, x, y, rgba, a, r, ids };
1031
+ },
1032
+ };
1033
+ }
1034
+
1035
+ type ResolvedAesUnknown<T> = import("../aes.ts").ResolvedAes<T, unknown>;
1036
+
1037
+ /**
1038
+ * Pack a multi-series bar segment's (dataIndex, seriesKey) into a single
1039
+ * emphasis ordinal: `dataIndex * keys.length + seriesIndex`. `seriesIndex` is
1040
+ * the key's position in the geom's STABLE full keys list (not `activeKeys`), so
1041
+ * toggling a series in/out of view never renumbers another segment's key. A hit
1042
+ * whose `seriesKey` isn't in `keys` (or is absent) returns `null` (focuses
1043
+ * nothing). Tagging and hit-resolution call this identically.
1044
+ */
1045
+ function multiEmphasisOrdinal(
1046
+ dataIndex: number,
1047
+ seriesKey: string | undefined,
1048
+ keys: readonly string[],
1049
+ ): number | null {
1050
+ if (seriesKey === undefined) return null;
1051
+ const seriesIndex = keys.indexOf(seriesKey);
1052
+ if (seriesIndex < 0) return null;
1053
+ return dataIndex * keys.length + seriesIndex;
1054
+ }