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,834 @@
1
+ // ---------------------------------------------------------------------------
2
+ // boxplot geom
3
+ // ---------------------------------------------------------------------------
4
+ // Tukey-style box-and-whisker plots — with optional notches, mean overlay,
5
+ // variable width, sample-size labels, and jittered raw-point overlay. Uses
6
+ // only existing primitives + the categorical-layout helper shared with violin.
7
+
8
+ import type { Color, Layer, Vec2 } from "insomni";
9
+ import { type ContinuousScale } from "../../scales.ts";
10
+ import {
11
+ boxStats,
12
+ jitter,
13
+ type BoxStats,
14
+ type QuantileMethod,
15
+ type WhiskerRule,
16
+ } from "../../stats/index.ts";
17
+ import { pointMark, pushFilledPolygon, type PointShapeKind } from "../../marks.ts";
18
+ import { alphaize } from "../color-utils.ts";
19
+ import { DEFAULT_POINTS_THRESHOLD } from "../constants.ts";
20
+ import type { Aes } from "../aes.ts";
21
+ import type { CompileContext, CompiledHitTest, Geom, ResolvedChannelMap } from "./types.ts";
22
+ import {
23
+ bucketBandCenter,
24
+ bucketSeed,
25
+ countsLabelMark,
26
+ prepareCategoricalLayout,
27
+ synthAes,
28
+ type CategoricalBucket,
29
+ type CategoricalChannels,
30
+ type CategoricalLayout,
31
+ } from "./_categorical.ts";
32
+ import { emphasisContext } from "./emphasis.ts";
33
+
34
+ export type PointsMode = "auto" | "always" | "none";
35
+
36
+ export interface BoxplotChannels<T> {
37
+ /** Categorical band axis. */
38
+ x: Aes<T, string | number | Date>;
39
+ /**
40
+ * Numeric value axis (the distribution to summarize) when vertical. For
41
+ * horizontal layouts (`orientation: "x"`) this carries the band category, so
42
+ * a string is accepted; the value axis then lives on `x`.
43
+ */
44
+ y: Aes<T, string | number | Date>;
45
+ /**
46
+ * Optional grouping key. When supplied (and distinct from `x`) the layer
47
+ * dodges side-by-side by this channel within each band.
48
+ */
49
+ color?: Aes<T, unknown>;
50
+ }
51
+
52
+ /**
53
+ * Mean-overlay configuration. `true` → default red dot; `false` (default) →
54
+ * no overlay; object → fully customised.
55
+ */
56
+ export interface MeanMarkerOptions {
57
+ shape?: PointShapeKind;
58
+ /** Radius in pixels. Default `3.5`. */
59
+ radius?: number;
60
+ fill?: Color;
61
+ stroke?: Color;
62
+ strokeWidth?: number;
63
+ }
64
+
65
+ export interface BoxplotOptions {
66
+ /** Auto-detected from scale types when omitted (band axis = category axis). */
67
+ orientation?: "x" | "y";
68
+ /**
69
+ * Box width as a fraction of the bandwidth (or grouped inner bandwidth, when
70
+ * dodging). Default `0.6`.
71
+ */
72
+ width?: number;
73
+ /**
74
+ * Scale box widths by `√(n / nMax)` so groups with more samples render
75
+ * wider — matches ggplot's `varwidth = TRUE`. Default `false`.
76
+ */
77
+ varwidth?: boolean;
78
+ /** Whisker rule. Default `1.5` (Tukey). Pass `"minmax"` for no outliers. */
79
+ whisker?: WhiskerRule;
80
+ /** Quantile interpolation method. Default `"type-7"` (R default). */
81
+ quantile?: QuantileMethod;
82
+ /**
83
+ * Render notches around the median at `± 1.58 · IQR / √n` (McGill 1978's
84
+ * 95% CI). Useful as an informal significance test: non-overlapping
85
+ * notches between groups suggest medians differ. Default `false`.
86
+ */
87
+ notched?: boolean;
88
+ /**
89
+ * Notch indent as a fraction of the box width (how far the sides pull in
90
+ * at the median). Default `0.5`.
91
+ */
92
+ notchWidth?: number;
93
+ /**
94
+ * When to overlay raw jittered points.
95
+ *
96
+ * - `"auto"` (default) — only when `n < pointsThreshold` for the group.
97
+ * Rationale: small samples are easy to mislead with a box; show the dots.
98
+ * - `"always"` — always overlay.
99
+ * - `"none"` — never overlay (still draws outliers unless `outliers: false`).
100
+ */
101
+ points?: PointsMode;
102
+ /** Threshold for `points: "auto"`. Default `10`. */
103
+ pointsThreshold?: number;
104
+ /** Jitter spread, fraction of the per-box width. Default `0.5`. */
105
+ pointJitter?: number;
106
+ /** Radius of overlay & outlier points. Default `2.5`. */
107
+ pointRadius?: number;
108
+ /** Override fill for jittered overlay points. Default `alphaize(stroke, 0.5)`. */
109
+ pointFill?: Color;
110
+ /** Override fill for outlier points. Default = stroke. */
111
+ outlierFill?: Color;
112
+ /** Outlier point radius. Default = `pointRadius`. */
113
+ outlierRadius?: number;
114
+ /** Seed for deterministic jitter. Default `1`. */
115
+ jitterSeed?: number;
116
+ /** Render outlier dots beyond the whiskers. Default `true`. */
117
+ outliers?: boolean;
118
+ /** Constant fill when no `color` channel. Default theme palette[0]. */
119
+ fill?: Color;
120
+ /**
121
+ * Override the theme's fill alpha (use `0` for outline-only boxes, e.g.
122
+ * to match ggplot's bottom-middle "no-fill" look). When unset the layer
123
+ * uses `theme.marks.fillAlpha`.
124
+ */
125
+ fillAlpha?: number;
126
+ /** Box stroke color. Default theme text color (light line on dark fill). */
127
+ stroke?: Color;
128
+ /** Box stroke width. Default `1`. */
129
+ strokeWidth?: number;
130
+ /** Median line color. Defaults to `stroke`. */
131
+ medianStroke?: Color;
132
+ /** Median line stroke width. Default `2`. */
133
+ medianStrokeWidth?: number;
134
+ /** Whisker line stroke width. Default = `strokeWidth`. */
135
+ whiskerStrokeWidth?: number;
136
+ /** Whisker cap width as a fraction of box width. Default `0.5`. */
137
+ capWidth?: number;
138
+ /**
139
+ * Mean overlay. `true` for the default red dot; `false` (default) to omit;
140
+ * pass an object to customize shape, radius, fill, stroke. The mean is
141
+ * **not** part of the box geometry — it's an annotation on top.
142
+ */
143
+ mean?: boolean | MeanMarkerOptions;
144
+ /** Inner-band padding for grouped (color-split) layout. Default `0.05`. */
145
+ groupPadding?: number;
146
+ /**
147
+ * Render an `n=<count>` label per band-axis category, just outside the
148
+ * plot frame. Counts are summed across dodge groups. Default `false`.
149
+ */
150
+ showCounts?: boolean;
151
+ /** Pixel offset of count labels from plot frame. Default `28` (vertical) / `32` (horizontal). */
152
+ countsOffset?: number;
153
+ countsFontSize?: number;
154
+ countsColor?: Color;
155
+ /** Display label for legend. */
156
+ label?: string;
157
+ }
158
+
159
+ interface BoxLayout {
160
+ bucket: CategoricalBucket;
161
+ /** Index of this bucket in `layout.buckets` — used to match against hover dataIndex. */
162
+ bucketIndex: number;
163
+ /** Top-left of box on the band axis. */
164
+ bandPos: number;
165
+ /** Box thickness on the band axis (after `varwidth` scaling). */
166
+ bandSize: number;
167
+ /** Cap thickness on the band axis. */
168
+ capSize: number;
169
+ /** Notch indent in pixels (0 when `notched: false` or n=1). */
170
+ notchInset: number;
171
+ /** Half-height of the notch around the median, in value-axis pixels. */
172
+ notchHalfPx: number;
173
+ stats: BoxStats;
174
+ /** Pre-resolved fill (already alphaized). */
175
+ fill: Color;
176
+ /** Stroke width for this box. */
177
+ strokeWidth: number;
178
+ /** Whisker stroke width for this box. */
179
+ whiskerStrokeWidth: number;
180
+ /**
181
+ * GPU emphasis key for this box (P5-T3). The SAME key is written onto EVERY
182
+ * primitive of the box (fill rect/notched polygon, median, whiskers, outlier
183
+ * + raw + mean dots) so the whole entity dims uniformly — a half-dimmed box
184
+ * is worse than none. Ordinal mirrors the hit identity (`bucketIndex`).
185
+ * `undefined` when emphasis is off (SSR/SVG, hover disabled).
186
+ */
187
+ emphasisKey?: number;
188
+ }
189
+
190
+ const DEFAULT_MEAN_FILL: Color = { r: 0.85, g: 0.18, b: 0.18, a: 1 };
191
+
192
+ export function boxplot<T>(channels: BoxplotChannels<T>, options: BoxplotOptions = {}): Geom<T> {
193
+ // The categorical layout helper stringifies the band axis and reads the
194
+ // value axis as a number; `y` may be the band category (horizontal layouts),
195
+ // so widen-then-narrow at the helper boundary. Same pattern as ridgeline.
196
+ const catChannels = channels as unknown as CategoricalChannels<T>;
197
+ const whisker = options.whisker ?? 1.5;
198
+ const quantileMethod = options.quantile ?? "type-7";
199
+
200
+ // Shared between compile() and compileHitTest() within a single render —
201
+ // both receive the same `ctx` instance from pipeline.ts. WeakMap keyed by
202
+ // ctx so the cache invalidates naturally each frame.
203
+ const statsCache = new WeakMap<CompileContext<T>, Map<CategoricalBucket, BoxStats | null>>();
204
+ const getStats = (ctx: CompileContext<T>, bucket: CategoricalBucket): BoxStats | null => {
205
+ let perCtx = statsCache.get(ctx);
206
+ if (!perCtx) {
207
+ perCtx = new Map();
208
+ statsCache.set(ctx, perCtx);
209
+ }
210
+ if (perCtx.has(bucket)) return perCtx.get(bucket) ?? null;
211
+ const s = boxStats(bucket.values, { whisker, quantile: quantileMethod });
212
+ perCtx.set(bucket, s ?? null);
213
+ return s ?? null;
214
+ };
215
+
216
+ return {
217
+ kind: "boxplot",
218
+ channels: { x: channels.x, y: channels.y, color: channels.color },
219
+ label: options.label,
220
+ compile(ctx: CompileContext<T>) {
221
+ const { plot, theme, atlas } = ctx;
222
+ const layout = prepareCategoricalLayout(ctx, catChannels, {
223
+ orientation: options.orientation,
224
+ groupPadding: options.groupPadding,
225
+ fill: options.fill,
226
+ });
227
+ const { orientation, valueAxis, cellSize, ox, oy, buckets } = layout;
228
+
229
+ const widthFraction = options.width ?? 0.6;
230
+ const capFraction = options.capWidth ?? 0.5;
231
+ const baseBoxSize = cellSize * widthFraction;
232
+ const notched = options.notched ?? false;
233
+ const notchWidth = options.notchWidth ?? 0.5;
234
+
235
+ // First pass: compute stats so we know the sample-size max for varwidth.
236
+ interface PreLayout {
237
+ bucket: CategoricalBucket;
238
+ stats: BoxStats;
239
+ }
240
+ const pre: PreLayout[] = [];
241
+ let nMax = 0;
242
+ for (const bucket of buckets) {
243
+ const stats = getStats(ctx, bucket);
244
+ if (!stats) continue;
245
+ if (stats.n > nMax) nMax = stats.n;
246
+ pre.push({ bucket, stats });
247
+ }
248
+ const useVarwidth = options.varwidth === true && nMax > 0;
249
+
250
+ // Apply optional fillAlpha override per-bucket without disturbing the
251
+ // categorical layout's resolveFill (which already applied theme alpha).
252
+ const fillAlphaOverride = options.fillAlpha;
253
+ const reFill = (bucket: CategoricalBucket): Color => {
254
+ const fill = layout.resolveFill(bucket);
255
+ return fillAlphaOverride === undefined ? fill : { ...fill, a: fillAlphaOverride };
256
+ };
257
+
258
+ // Hover dim rides the core's GPU emphasis uniform (P5-T3). Polygon-fill
259
+ // emphasis keys (added in this change) now reach `pushPolygon` (notched
260
+ // boxes) and glyph markers, so a box can be tagged as ONE entity: the SAME
261
+ // key is written onto every primitive (fill, median, whiskers, dots, mean)
262
+ // so the whole box dims uniformly. The ordinal is `bucketIndex`, which is
263
+ // exactly the `dataIndex` compileHitTest reports — so the mount's
264
+ // emphasisResolution maps a hover hit to this same key with no recompile.
265
+ const emph = emphasisContext(ctx, "boxplot");
266
+ const stroke: Color = options.stroke ?? theme.text.color;
267
+ const baseStrokeWidth = options.strokeWidth ?? 1;
268
+ const medianStroke: Color = options.medianStroke ?? stroke;
269
+ const medianStrokeWidth = options.medianStrokeWidth ?? 2;
270
+ const baseWhiskerStrokeWidth = options.whiskerStrokeWidth ?? baseStrokeWidth;
271
+
272
+ const layouts: BoxLayout[] = [];
273
+ for (const p of pre) {
274
+ const bandCenter = bucketBandCenter(layout, p.bucket);
275
+ if (Number.isNaN(bandCenter)) continue;
276
+ const bucketIndex = buckets.indexOf(p.bucket);
277
+
278
+ const sizeFactor = useVarwidth ? Math.sqrt(p.stats.n / nMax) : 1;
279
+ const boxSize = baseBoxSize * sizeFactor;
280
+ const capSize = boxSize * capFraction;
281
+ const bandPos = bandCenter - boxSize / 2;
282
+
283
+ let notchInset = 0;
284
+ let notchHalfPx = 0;
285
+ if (notched && p.stats.n > 1) {
286
+ // McGill notch: ± 1.58 * IQR / sqrt(n).
287
+ const halfData = (1.58 * p.stats.iqr) / Math.sqrt(p.stats.n);
288
+ // Clamp the notch into the IQR — avoids the "flying triangles"
289
+ // artefact when the CI is wider than the box.
290
+ const q1Px = valueAxis(p.stats.q1);
291
+ const q3Px = valueAxis(p.stats.q3);
292
+ const medPx = valueAxis(p.stats.median);
293
+ const loPx = valueAxis(p.stats.median - halfData);
294
+ const hiPx = valueAxis(p.stats.median + halfData);
295
+ const halfPxRaw = Math.max(Math.abs(medPx - loPx), Math.abs(hiPx - medPx));
296
+ const iqrHalfPx = Math.abs(q3Px - q1Px) / 2;
297
+ notchHalfPx = Math.min(halfPxRaw, iqrHalfPx);
298
+ notchInset = (boxSize / 2) * notchWidth;
299
+ }
300
+
301
+ layouts.push({
302
+ bucket: p.bucket,
303
+ bucketIndex,
304
+ bandPos,
305
+ bandSize: boxSize,
306
+ capSize,
307
+ notchInset,
308
+ notchHalfPx,
309
+ stats: p.stats,
310
+ fill: reFill(p.bucket),
311
+ strokeWidth: baseStrokeWidth,
312
+ whiskerStrokeWidth: baseWhiskerStrokeWidth,
313
+ emphasisKey: emph?.keyFor(bucketIndex),
314
+ });
315
+ }
316
+
317
+ const showOutliers = options.outliers ?? true;
318
+ const pointsMode: PointsMode = options.points ?? "auto";
319
+ const pointsThreshold = options.pointsThreshold ?? DEFAULT_POINTS_THRESHOLD;
320
+ const pointJitter = options.pointJitter ?? 0.5;
321
+ const pointRadius = options.pointRadius ?? 2.5;
322
+ const outlierRadius = options.outlierRadius ?? pointRadius;
323
+ const outlierFill: Color = options.outlierFill ?? stroke;
324
+ const overlayFill: Color = options.pointFill ?? alphaize(stroke, 0.5);
325
+ const jitterSeed = options.jitterSeed ?? 1;
326
+
327
+ const meanCfg: MeanMarkerOptions | null =
328
+ options.mean === true
329
+ ? {}
330
+ : options.mean === false || options.mean === undefined
331
+ ? null
332
+ : options.mean;
333
+
334
+ const showCounts = options.showCounts ?? false;
335
+ const countsFontSize = options.countsFontSize ?? theme.marks.labelFontSize;
336
+ const countsColor: Color = options.countsColor ?? theme.text.color;
337
+ const countsOffset = options.countsOffset ?? (orientation === "y" ? 28 : 32);
338
+
339
+ const builder = {
340
+ length: layouts.length,
341
+ addTo(layer: Layer): Layer {
342
+ for (const lay of layouts) {
343
+ drawBox(layer, lay, {
344
+ orientation,
345
+ valueAxis,
346
+ ox,
347
+ oy,
348
+ fill: lay.fill,
349
+ stroke,
350
+ strokeWidth: lay.strokeWidth,
351
+ medianStroke,
352
+ medianStrokeWidth,
353
+ whiskerStrokeWidth: lay.whiskerStrokeWidth,
354
+ emphasisKey: lay.emphasisKey,
355
+ });
356
+
357
+ if (showOutliers && lay.stats.outliers.length > 0) {
358
+ drawPoints(layer, lay, lay.stats.outliers, {
359
+ orientation,
360
+ valueAxis,
361
+ ox,
362
+ oy,
363
+ radius: outlierRadius,
364
+ fill: outlierFill,
365
+ jitterValues: undefined,
366
+ emphasisKey: lay.emphasisKey,
367
+ });
368
+ }
369
+
370
+ const showRaw =
371
+ pointsMode === "always" || (pointsMode === "auto" && lay.stats.n < pointsThreshold);
372
+ if (showRaw) {
373
+ const jitterValues = jitter(
374
+ bucketSeed(lay.bucket, jitterSeed),
375
+ lay.bucket.values.length,
376
+ lay.bandSize * pointJitter,
377
+ );
378
+ drawPoints(layer, lay, lay.bucket.values, {
379
+ orientation,
380
+ valueAxis,
381
+ ox,
382
+ oy,
383
+ radius: pointRadius,
384
+ fill: overlayFill,
385
+ jitterValues,
386
+ emphasisKey: lay.emphasisKey,
387
+ });
388
+ }
389
+
390
+ if (meanCfg) {
391
+ drawMean(layer, lay, meanCfg, {
392
+ orientation,
393
+ valueAxis,
394
+ ox,
395
+ oy,
396
+ stroke,
397
+ emphasisKey: lay.emphasisKey,
398
+ });
399
+ }
400
+ }
401
+
402
+ if (showCounts && atlas) {
403
+ const labelMark = countsLabelMark(
404
+ layout,
405
+ {
406
+ offset: countsOffset,
407
+ fontSize: countsFontSize,
408
+ color: countsColor,
409
+ },
410
+ plot.width,
411
+ plot.height,
412
+ );
413
+ labelMark.addTo(layer, plot.topLeft);
414
+ }
415
+
416
+ return layer;
417
+ },
418
+ };
419
+
420
+ return [builder];
421
+ },
422
+ emphasisResolution(ctx) {
423
+ // Ordinal = the hit's `dataIndex`, which compileHitTest sets to the
424
+ // pre-filter `bucketIndex` — the same value `compile` tags each box with.
425
+ return emphasisContext(ctx, "boxplot")?.resolver() ?? null;
426
+ },
427
+ compileHitTest(ctx: CompileContext<T>): CompiledHitTest<T> | null {
428
+ const { data } = ctx;
429
+ const layout = prepareCategoricalLayout(ctx, catChannels, {
430
+ orientation: options.orientation,
431
+ groupPadding: options.groupPadding,
432
+ fill: options.fill,
433
+ });
434
+ const { orientation, valueAxis, ox, oy, buckets, dodging } = layout;
435
+
436
+ // One hit per non-empty bucket at (bandCenter, median).
437
+ // Rect covers whisker-low → whisker-high on the value axis × full band slot.
438
+ const positions = new Float32Array(buckets.length * 2);
439
+ const rects = new Float32Array(buckets.length * 4);
440
+ const seriesKey: (string | undefined)[] = Array.from({ length: buckets.length });
441
+ // Stash per-hit stats so the synthetic channel accessors can return them
442
+ // on tooltip lookup. Index aligns 1:1 with `dataIndex`.
443
+ const stats: BoxStats[] = Array.from({ length: buckets.length });
444
+ const categories: string[] = Array.from({ length: buckets.length });
445
+ const groupKeys: (string | undefined)[] = Array.from({ length: buckets.length });
446
+ // Pre-filter bucket index per emitted hit, so `compile()` can match
447
+ // its hovered bucket via `buckets.indexOf(bucket)` and the synth
448
+ // channel accessors can index into the per-bucket side arrays
449
+ // (`stats`/`categories`/`groupKeys`) with the same key.
450
+ const hitBucketIndex: number[] = Array.from({ length: buckets.length });
451
+ let n = 0;
452
+ for (let bi = 0; bi < buckets.length; bi++) {
453
+ const bucket = buckets[bi]!;
454
+ const s = getStats(ctx, bucket);
455
+ if (!s) continue;
456
+ const bandCenter = bucketBandCenter(layout, bucket);
457
+ if (!Number.isFinite(bandCenter)) continue;
458
+ const medianPx = valueAxis(s.median);
459
+ if (!Number.isFinite(medianPx)) continue;
460
+ const whiskerLoPx = valueAxis(s.lowerWhisker);
461
+ const whiskerHiPx = valueAxis(s.upperWhisker);
462
+ const valueLo = Math.min(whiskerLoPx, whiskerHiPx);
463
+ const valueHi = Math.max(whiskerLoPx, whiskerHiPx);
464
+ const valueSpan = Math.max(valueHi - valueLo, 1);
465
+ const bandStart = bandCenter - layout.cellSize / 2;
466
+ if (orientation === "y") {
467
+ positions[n * 2] = ox + bandCenter;
468
+ positions[n * 2 + 1] = oy + medianPx;
469
+ rects[n * 4] = ox + bandStart;
470
+ rects[n * 4 + 1] = oy + valueLo;
471
+ rects[n * 4 + 2] = layout.cellSize;
472
+ rects[n * 4 + 3] = valueSpan;
473
+ } else {
474
+ positions[n * 2] = ox + medianPx;
475
+ positions[n * 2 + 1] = oy + bandCenter;
476
+ rects[n * 4] = ox + valueLo;
477
+ rects[n * 4 + 1] = oy + bandStart;
478
+ rects[n * 4 + 2] = valueSpan;
479
+ rects[n * 4 + 3] = layout.cellSize;
480
+ }
481
+ seriesKey[n] = dodging ? bucket.groupKey : bucket.category;
482
+ // Side arrays keyed by pre-filter bucket index; the synth accessors
483
+ // and `compile`'s hover lookup use the same key.
484
+ stats[bi] = s;
485
+ categories[bi] = bucket.category;
486
+ groupKeys[bi] = bucket.groupKey;
487
+ hitBucketIndex[n] = bi;
488
+ n++;
489
+ }
490
+ if (n === 0) return null;
491
+
492
+ // Half the cell on the band axis is generous enough that the cursor
493
+ // anywhere over a box picks it. Off-axis distance still matters via
494
+ // Euclidean default, so users still get a per-bucket selection.
495
+ const pickRadius = Math.max(layout.cellSize / 2, 8);
496
+
497
+ const channelsMap: ResolvedChannelMap<T> = {
498
+ x: synthAes(orientation === "y" ? "category" : "median", (_d, idx) =>
499
+ orientation === "y" ? categories[idx] : stats[idx]!.median,
500
+ ),
501
+ y: synthAes(orientation === "y" ? "median" : "category", (_d, idx) =>
502
+ orientation === "y" ? stats[idx]!.median : categories[idx],
503
+ ),
504
+ color: dodging ? synthAes("group", (_d, idx) => groupKeys[idx] ?? "") : undefined,
505
+ };
506
+
507
+ // dataIndex points at the pre-filter bucket position so it agrees
508
+ // with `compile`'s `buckets.indexOf(bucket)` and with the synth
509
+ // accessors above. `bi ≤ buckets.length ≤ data.length`, so the
510
+ // value is also a valid (if irrelevant) source-row index.
511
+ const dataIndexFinal = new Int32Array(n);
512
+ for (let i = 0; i < n; i++) dataIndexFinal[i] = hitBucketIndex[i]!;
513
+
514
+ return {
515
+ geomKind: "boxplot",
516
+ label: options.label,
517
+ positions: positions.subarray(0, n * 2),
518
+ rects: rects.subarray(0, n * 4),
519
+ dataIndex: dataIndexFinal,
520
+ seriesKey: seriesKey.slice(0, n),
521
+ pickRadius,
522
+ channels: channelsMap,
523
+ data,
524
+ };
525
+ },
526
+ };
527
+ }
528
+
529
+ interface DrawBoxCtx {
530
+ orientation: "x" | "y";
531
+ valueAxis: ContinuousScale;
532
+ ox: number;
533
+ oy: number;
534
+ fill: Color;
535
+ stroke: Color;
536
+ strokeWidth: number;
537
+ medianStroke: Color;
538
+ medianStrokeWidth: number;
539
+ whiskerStrokeWidth: number;
540
+ /** GPU emphasis key threaded onto every primitive of the box (P5-T3). */
541
+ emphasisKey?: number;
542
+ }
543
+
544
+ function drawBox(layer: Layer, lay: BoxLayout, c: DrawBoxCtx) {
545
+ const { stats } = lay;
546
+ const valuePos = (v: number) => c.valueAxis(v);
547
+ const bp = lay.bandPos;
548
+ const bs = lay.bandSize;
549
+ const center = bp + bs / 2;
550
+ const capHalf = lay.capSize / 2;
551
+
552
+ // Draws a whisker line from `boxEdge` to `whiskerEnd` along the value axis,
553
+ // plus a perpendicular cap at the whisker end. `axis` selects which screen
554
+ // axis the value lives on.
555
+ const drawWhisker = (axis: "x" | "y", boxEdge: number, whiskerEnd: number) => {
556
+ const stemA =
557
+ axis === "y"
558
+ ? { x: c.ox + center, y: c.oy + boxEdge }
559
+ : { x: c.ox + boxEdge, y: c.oy + center };
560
+ const stemB =
561
+ axis === "y"
562
+ ? { x: c.ox + center, y: c.oy + whiskerEnd }
563
+ : { x: c.ox + whiskerEnd, y: c.oy + center };
564
+ const capA =
565
+ axis === "y"
566
+ ? { x: c.ox + center - capHalf, y: c.oy + whiskerEnd }
567
+ : { x: c.ox + whiskerEnd, y: c.oy + center - capHalf };
568
+ const capB =
569
+ axis === "y"
570
+ ? { x: c.ox + center + capHalf, y: c.oy + whiskerEnd }
571
+ : { x: c.ox + whiskerEnd, y: c.oy + center + capHalf };
572
+ layer.pushPolyline({
573
+ points: [stemA, stemB],
574
+ color: c.stroke,
575
+ width: c.whiskerStrokeWidth,
576
+ emphasisKey: c.emphasisKey,
577
+ });
578
+ layer.pushPolyline({
579
+ points: [capA, capB],
580
+ color: c.stroke,
581
+ width: c.whiskerStrokeWidth,
582
+ emphasisKey: c.emphasisKey,
583
+ });
584
+ };
585
+
586
+ if (c.orientation === "y") {
587
+ const yQ1 = valuePos(stats.q1);
588
+ const yQ3 = valuePos(stats.q3);
589
+ const yMed = valuePos(stats.median);
590
+ const yLo = valuePos(stats.lowerWhisker);
591
+ const yHi = valuePos(stats.upperWhisker);
592
+ const yTop = Math.min(yQ1, yQ3);
593
+ const yBot = Math.max(yQ1, yQ3);
594
+
595
+ if (lay.notchInset > 0) {
596
+ drawNotchedBox(layer, {
597
+ orientation: "y",
598
+ ox: c.ox,
599
+ oy: c.oy,
600
+ bp,
601
+ bs,
602
+ yTop,
603
+ yBot,
604
+ yMed,
605
+ notchInset: lay.notchInset,
606
+ notchHalfPx: lay.notchHalfPx,
607
+ fill: c.fill,
608
+ stroke: c.stroke,
609
+ strokeWidth: c.strokeWidth,
610
+ emphasisKey: c.emphasisKey,
611
+ });
612
+ layer.pushPolyline({
613
+ points: [
614
+ { x: c.ox + bp + lay.notchInset, y: c.oy + yMed },
615
+ { x: c.ox + bp + bs - lay.notchInset, y: c.oy + yMed },
616
+ ],
617
+ color: c.medianStroke,
618
+ width: c.medianStrokeWidth,
619
+ emphasisKey: c.emphasisKey,
620
+ });
621
+ } else {
622
+ layer.pushRect({
623
+ x: c.ox + bp,
624
+ y: c.oy + yTop,
625
+ width: bs,
626
+ height: Math.max(yBot - yTop, 0.5),
627
+ fill: c.fill,
628
+ stroke: c.stroke,
629
+ strokeWidth: c.strokeWidth,
630
+ emphasisKey: c.emphasisKey,
631
+ });
632
+ layer.pushPolyline({
633
+ points: [
634
+ { x: c.ox + bp, y: c.oy + yMed },
635
+ { x: c.ox + bp + bs, y: c.oy + yMed },
636
+ ],
637
+ color: c.medianStroke,
638
+ width: c.medianStrokeWidth,
639
+ emphasisKey: c.emphasisKey,
640
+ });
641
+ }
642
+
643
+ drawWhisker("y", yTop, Math.min(yLo, yHi));
644
+ drawWhisker("y", yBot, Math.max(yLo, yHi));
645
+ } else {
646
+ const xQ1 = valuePos(stats.q1);
647
+ const xQ3 = valuePos(stats.q3);
648
+ const xMed = valuePos(stats.median);
649
+ const xLo = valuePos(stats.lowerWhisker);
650
+ const xHi = valuePos(stats.upperWhisker);
651
+ const xLeft = Math.min(xQ1, xQ3);
652
+ const xRight = Math.max(xQ1, xQ3);
653
+
654
+ if (lay.notchInset > 0) {
655
+ drawNotchedBox(layer, {
656
+ orientation: "x",
657
+ ox: c.ox,
658
+ oy: c.oy,
659
+ bp,
660
+ bs,
661
+ yTop: xLeft,
662
+ yBot: xRight,
663
+ yMed: xMed,
664
+ notchInset: lay.notchInset,
665
+ notchHalfPx: lay.notchHalfPx,
666
+ fill: c.fill,
667
+ stroke: c.stroke,
668
+ strokeWidth: c.strokeWidth,
669
+ emphasisKey: c.emphasisKey,
670
+ });
671
+ layer.pushPolyline({
672
+ points: [
673
+ { x: c.ox + xMed, y: c.oy + bp + lay.notchInset },
674
+ { x: c.ox + xMed, y: c.oy + bp + bs - lay.notchInset },
675
+ ],
676
+ color: c.medianStroke,
677
+ width: c.medianStrokeWidth,
678
+ emphasisKey: c.emphasisKey,
679
+ });
680
+ } else {
681
+ layer.pushRect({
682
+ x: c.ox + xLeft,
683
+ y: c.oy + bp,
684
+ width: Math.max(xRight - xLeft, 0.5),
685
+ height: bs,
686
+ fill: c.fill,
687
+ stroke: c.stroke,
688
+ strokeWidth: c.strokeWidth,
689
+ emphasisKey: c.emphasisKey,
690
+ });
691
+ layer.pushPolyline({
692
+ points: [
693
+ { x: c.ox + xMed, y: c.oy + bp },
694
+ { x: c.ox + xMed, y: c.oy + bp + bs },
695
+ ],
696
+ color: c.medianStroke,
697
+ width: c.medianStrokeWidth,
698
+ emphasisKey: c.emphasisKey,
699
+ });
700
+ }
701
+
702
+ drawWhisker("x", xLeft, Math.min(xLo, xHi));
703
+ drawWhisker("x", xRight, Math.max(xLo, xHi));
704
+ }
705
+ }
706
+
707
+ interface DrawNotchedCtx {
708
+ orientation: "x" | "y";
709
+ ox: number;
710
+ oy: number;
711
+ bp: number;
712
+ bs: number;
713
+ /** Box edge along the value axis, first endpoint (y-top or x-left). */
714
+ yTop: number;
715
+ /** Box edge along the value axis, second endpoint (y-bot or x-right). */
716
+ yBot: number;
717
+ /** Median position along the value axis. */
718
+ yMed: number;
719
+ notchInset: number;
720
+ notchHalfPx: number;
721
+ fill: Color;
722
+ stroke: Color;
723
+ strokeWidth: number;
724
+ emphasisKey?: number;
725
+ }
726
+
727
+ /**
728
+ * 10-point polygon for a notched box. Walks: corner → notch top → notch waist
729
+ * → notch bottom → corner → corner → notch bottom → notch waist → notch top
730
+ * → corner. Same routine for both orientations — just swaps which axis the
731
+ * "value" coordinate maps to.
732
+ */
733
+ function drawNotchedBox(layer: Layer, c: DrawNotchedCtx) {
734
+ const { bp, bs, yTop, yBot, yMed, notchInset, notchHalfPx } = c;
735
+ const valTopNotch = yMed - notchHalfPx;
736
+ const valBotNotch = yMed + notchHalfPx;
737
+ const bandLeft = bp;
738
+ const bandRight = bp + bs;
739
+ const innerLeft = bp + notchInset;
740
+ const innerRight = bp + bs - notchInset;
741
+
742
+ const points: Vec2[] =
743
+ c.orientation === "y"
744
+ ? [
745
+ { x: c.ox + bandLeft, y: c.oy + yTop },
746
+ { x: c.ox + bandRight, y: c.oy + yTop },
747
+ { x: c.ox + bandRight, y: c.oy + valTopNotch },
748
+ { x: c.ox + innerRight, y: c.oy + yMed },
749
+ { x: c.ox + bandRight, y: c.oy + valBotNotch },
750
+ { x: c.ox + bandRight, y: c.oy + yBot },
751
+ { x: c.ox + bandLeft, y: c.oy + yBot },
752
+ { x: c.ox + bandLeft, y: c.oy + valBotNotch },
753
+ { x: c.ox + innerLeft, y: c.oy + yMed },
754
+ { x: c.ox + bandLeft, y: c.oy + valTopNotch },
755
+ ]
756
+ : [
757
+ { x: c.ox + yTop, y: c.oy + bandLeft },
758
+ { x: c.ox + yTop, y: c.oy + bandRight },
759
+ { x: c.ox + valTopNotch, y: c.oy + bandRight },
760
+ { x: c.ox + yMed, y: c.oy + innerRight },
761
+ { x: c.ox + valBotNotch, y: c.oy + bandRight },
762
+ { x: c.ox + yBot, y: c.oy + bandRight },
763
+ { x: c.ox + yBot, y: c.oy + bandLeft },
764
+ { x: c.ox + valBotNotch, y: c.oy + bandLeft },
765
+ { x: c.ox + yMed, y: c.oy + innerLeft },
766
+ { x: c.ox + valTopNotch, y: c.oy + bandLeft },
767
+ ];
768
+
769
+ pushFilledPolygon(layer, points, {
770
+ fill: c.fill,
771
+ stroke: c.stroke,
772
+ strokeWidth: c.strokeWidth,
773
+ emphasisKey: c.emphasisKey,
774
+ });
775
+ }
776
+
777
+ interface DrawPointsCtx {
778
+ orientation: "x" | "y";
779
+ valueAxis: ContinuousScale;
780
+ ox: number;
781
+ oy: number;
782
+ radius: number;
783
+ fill: Color;
784
+ /** Pre-computed jitter offsets (one per value). Undefined → no jitter. */
785
+ jitterValues: number[] | undefined;
786
+ emphasisKey?: number;
787
+ }
788
+
789
+ function drawPoints(layer: Layer, lay: BoxLayout, values: readonly number[], c: DrawPointsCtx) {
790
+ const center = lay.bandPos + lay.bandSize / 2;
791
+ const valuePos = (v: number) => c.valueAxis(v);
792
+ const mark = pointMark(values as number[], {
793
+ x: (v, i) =>
794
+ c.orientation === "y"
795
+ ? c.ox + center + (c.jitterValues ? (c.jitterValues[i] ?? 0) : 0)
796
+ : c.ox + valuePos(v),
797
+ y: (v, i) =>
798
+ c.orientation === "y"
799
+ ? c.oy + valuePos(v)
800
+ : c.oy + center + (c.jitterValues ? (c.jitterValues[i] ?? 0) : 0),
801
+ radius: c.radius,
802
+ fill: c.fill,
803
+ emphasisKey: c.emphasisKey === undefined ? undefined : () => c.emphasisKey,
804
+ });
805
+ mark.addTo(layer);
806
+ }
807
+
808
+ interface DrawMeanCtx {
809
+ orientation: "x" | "y";
810
+ valueAxis: ContinuousScale;
811
+ ox: number;
812
+ oy: number;
813
+ stroke: Color;
814
+ emphasisKey?: number;
815
+ }
816
+
817
+ function drawMean(layer: Layer, lay: BoxLayout, cfg: MeanMarkerOptions, c: DrawMeanCtx) {
818
+ const center = lay.bandPos + lay.bandSize / 2;
819
+ const valuePos = c.valueAxis(lay.stats.mean);
820
+ const mark = pointMark([lay.stats.mean], {
821
+ x: () => (c.orientation === "y" ? c.ox + center : c.ox + valuePos),
822
+ y: () => (c.orientation === "y" ? c.oy + valuePos : c.oy + center),
823
+ radius: cfg.radius ?? 3.5,
824
+ fill: cfg.fill ?? DEFAULT_MEAN_FILL,
825
+ stroke: cfg.stroke,
826
+ strokeWidth: cfg.strokeWidth,
827
+ shape: cfg.shape ?? "circle",
828
+ emphasisKey: c.emphasisKey === undefined ? undefined : () => c.emphasisKey,
829
+ });
830
+ mark.addTo(layer);
831
+ }
832
+
833
+ // Re-export for tests / advanced users that want the shared layout shape.
834
+ export type { CategoricalLayout };