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
package/src/marks.ts ADDED
@@ -0,0 +1,1292 @@
1
+ import {
2
+ BLACK,
3
+ type Color,
4
+ type Group,
5
+ type Layer,
6
+ type LineCap,
7
+ type LineJoin,
8
+ polylineEllipseRing,
9
+ polylineRectRing,
10
+ type Vec2,
11
+ } from "insomni";
12
+ import { resamplePoints, type LineCurve } from "./marks/curve.ts";
13
+ import { stack, type StackOffset, type StackOrder, type StackSegment } from "./marks/stack.ts";
14
+ import type { BandScale, ContinuousScale, GroupedBandScale } from "./scales.ts";
15
+ import { DEFAULT_OVERLAY_SCALE } from "./grammar/constants.ts";
16
+
17
+ export {
18
+ cardinalCurve,
19
+ defineCurve,
20
+ resamplePoints,
21
+ type CustomCurve,
22
+ type LineCurve,
23
+ type LineCurvePreset,
24
+ } from "./marks/curve.ts";
25
+ export {
26
+ stack,
27
+ type StackOffset,
28
+ type StackOptions,
29
+ type StackOrder,
30
+ type StackSegment,
31
+ } from "./marks/stack.ts";
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Shared types
35
+ // ---------------------------------------------------------------------------
36
+
37
+ export interface MarkOrigin {
38
+ x?: number;
39
+ y?: number;
40
+ }
41
+
42
+ export interface MarkBuilder {
43
+ addTo(layer: Layer, origin?: MarkOrigin): Layer;
44
+ readonly length: number;
45
+ }
46
+
47
+ export type Accessor<T, V> = (datum: T, index: number) => V;
48
+ export type ValueOrAccessor<T, V> = V | Accessor<T, V>;
49
+
50
+ function resolve<T, V>(value: ValueOrAccessor<T, V>, datum: T, index: number): V {
51
+ return typeof value === "function" ? (value as Accessor<T, V>)(datum, index) : value;
52
+ }
53
+ export { resolve as resolveValueOrAccessor };
54
+
55
+ // Hoist the typeof/?? branching out of inner loops: pick once per render,
56
+ // then call the returned function per datum.
57
+ function toAccessor<T, V>(value: ValueOrAccessor<T, V> | undefined, fallback: V): Accessor<T, V> {
58
+ if (value === undefined) return () => fallback;
59
+ if (typeof value === "function") return value as Accessor<T, V>;
60
+ return () => value;
61
+ }
62
+
63
+ function toAccessorOrUndefined<T, V>(
64
+ value: ValueOrAccessor<T, V> | undefined,
65
+ ): Accessor<T, V | undefined> {
66
+ if (value === undefined) return () => undefined;
67
+ if (typeof value === "function") return value as Accessor<T, V>;
68
+ return () => value;
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // pointMark
73
+ // ---------------------------------------------------------------------------
74
+
75
+ /**
76
+ * Discrete marker glyphs for `pointMark`. The first four are filled solids;
77
+ * the `-open` variants render the same outline with no fill (caller must
78
+ * supply a stroke). `cross`, `plus`, and `star` are filled glyphs whose
79
+ * "arm" thickness scales with `radius`.
80
+ */
81
+ export type PointShapeKind =
82
+ | "circle"
83
+ | "square"
84
+ | "triangle"
85
+ | "diamond"
86
+ | "circle-open"
87
+ | "square-open"
88
+ | "triangle-open"
89
+ | "diamond-open"
90
+ | "cross"
91
+ | "plus"
92
+ | "star";
93
+
94
+ /** Shape ordering used by the default categorical shape scale. */
95
+ export const POINT_SHAPE_PALETTE: readonly PointShapeKind[] = [
96
+ "circle",
97
+ "triangle",
98
+ "square",
99
+ "diamond",
100
+ "plus",
101
+ "cross",
102
+ "star",
103
+ "circle-open",
104
+ "triangle-open",
105
+ "square-open",
106
+ "diamond-open",
107
+ ];
108
+
109
+ /**
110
+ * Border treatment applied orthogonally to a point's shape kind.
111
+ *
112
+ * - `"solid"` (default): existing behavior — fill + optional solid stroke.
113
+ * - `"open"`: fill is suppressed; stroke uses `stroke ?? fill` as the color
114
+ * (equivalent to the `-open` shape variants, but composable with any kind).
115
+ * - `"dashed"` / `"dotted"`: fill is kept; the stroke is rendered as a polyline
116
+ * ring with a dash pattern. Required because SDF strokes don't support
117
+ * per-shape dashes; this routes the border through `tessellatePolyline`.
118
+ */
119
+ export type PointBorderStyle = "solid" | "dashed" | "dotted" | "open";
120
+
121
+ /** Line-stroke dash treatment. Maps to polyline `dashPattern`. */
122
+ export type LineDashStyle = "solid" | "dashed" | "dotted";
123
+
124
+ /** Default dash pattern emitted for `"dashed"` border/line styles. */
125
+ export const DASHED_PATTERN: readonly number[] = [4, 4];
126
+ /** Default dash pattern emitted for `"dotted"` border/line styles. */
127
+ export const DOTTED_PATTERN: readonly number[] = [1, 3];
128
+
129
+ function dashPatternFor(
130
+ style: PointBorderStyle | LineDashStyle | undefined,
131
+ ): readonly number[] | undefined {
132
+ if (style === "dashed") return DASHED_PATTERN;
133
+ if (style === "dotted") return DOTTED_PATTERN;
134
+ return undefined;
135
+ }
136
+
137
+ export interface PointMarkOptions<T> {
138
+ x: Accessor<T, number>;
139
+ y: Accessor<T, number>;
140
+ radius?: ValueOrAccessor<T, number>;
141
+ fill?: ValueOrAccessor<T, Color>;
142
+ stroke?: ValueOrAccessor<T, Color>;
143
+ strokeWidth?: ValueOrAccessor<T, number>;
144
+ shape?: ValueOrAccessor<T, PointShapeKind>;
145
+ /**
146
+ * Per-datum border treatment (`"solid"` | `"dashed"` | `"dotted"` | `"open"`).
147
+ * Composes with any shape kind; e.g. `shape: "circle", borderStyle: "open"`
148
+ * is equivalent to `shape: "circle-open"`. Dashed/dotted borders bypass the
149
+ * SDF shader (which has no dash support) and route through a polyline ring.
150
+ */
151
+ borderStyle?: ValueOrAccessor<T, PointBorderStyle>;
152
+ /**
153
+ * Optional secondary glyph drawn on top of the base shape at the same anchor.
154
+ * Pass `null` to skip overlay for a specific datum. The overlay's radius is
155
+ * `radius * overlayScale`; its color falls back to the base stroke (or fill
156
+ * if no stroke) so badges contrast against the base shape.
157
+ */
158
+ overlayGlyph?: ValueOrAccessor<T, PointShapeKind | null>;
159
+ /** Overlay radius as a fraction of the base radius. Default `0.6`. */
160
+ overlayScale?: ValueOrAccessor<T, number>;
161
+ /**
162
+ * Per-datum GPU emphasis key (P5-T3) applied to every primitive a point emits
163
+ * (base shape + overlay glyph). Used by boxplot/violin point overlays + mean
164
+ * markers to tag a whole entity's dots with the entity's key. `undefined` →
165
+ * untagged (no dim).
166
+ */
167
+ emphasisKey?: Accessor<T, number | undefined>;
168
+ group?: Group;
169
+ }
170
+
171
+ const SQRT3_OVER_2 = Math.sqrt(3) / 2;
172
+ /** Half-thickness of cross/plus arms as a fraction of `radius`. */
173
+ const ARM_HALF = 0.28;
174
+ /** Star inner-vertex radius as a fraction of `radius`. */
175
+ const STAR_INNER = 0.5;
176
+
177
+ // Precomputed cos/sin values scaled by the appropriate radius factors (1 or STAR_INNER).
178
+ // The angles are -Math.PI / 2 + (i * Math.PI) / 5 for i = 0 to 9.
179
+ const STAR_COS_SIN: readonly { cos: number; sin: number }[] = Array.from({ length: 10 }, (_, i) => {
180
+ const angle = -Math.PI / 2 + (i * Math.PI) / 5;
181
+ const scale = i % 2 === 0 ? 1 : STAR_INNER;
182
+ return { cos: Math.cos(angle) * scale, sin: Math.sin(angle) * scale };
183
+ });
184
+
185
+ /**
186
+ * Emit a filled polygon plus an optional solid stroke outline.
187
+ *
188
+ * v3's `PolygonShape` is fill-only (stroke is a separate polyline ring, not a
189
+ * field), so this mirrors the rect/ellipse stroked-shape pattern: push a
190
+ * fill-only polygon when a real fill is present, and push a closed-loop
191
+ * polyline ring over the same points when a stroke is present. A stroke-only
192
+ * mark (no fill) emits only the ring — v3 requires `fill`, so `pushPolygon` is
193
+ * never called without one.
194
+ */
195
+ export function pushFilledPolygon(
196
+ layer: Layer,
197
+ points: readonly Vec2[],
198
+ opts: PolygonOpts & { holes?: readonly (readonly Vec2[])[] },
199
+ ): void {
200
+ const { fill, stroke, strokeWidth, holes, group, emphasisKey } = opts;
201
+ if (fill !== undefined) {
202
+ layer.pushPolygon({ points, holes, fill, group, emphasisKey });
203
+ }
204
+ if (stroke !== undefined && strokeWidth !== undefined && strokeWidth > 0) {
205
+ layer.pushPolyline({
206
+ points,
207
+ color: stroke,
208
+ width: strokeWidth,
209
+ closed: true,
210
+ group,
211
+ emphasisKey,
212
+ });
213
+ }
214
+ }
215
+
216
+ function addTriangle(layer: Layer, cx: number, cy: number, r: number, opts: PolygonOpts): void {
217
+ const points: Vec2[] = [
218
+ { x: cx, y: cy - r },
219
+ { x: cx + r * SQRT3_OVER_2, y: cy + r * 0.5 },
220
+ { x: cx - r * SQRT3_OVER_2, y: cy + r * 0.5 },
221
+ ];
222
+ pushFilledPolygon(layer, points, opts);
223
+ }
224
+
225
+ function addDiamond(layer: Layer, cx: number, cy: number, r: number, opts: PolygonOpts): void {
226
+ const points: Vec2[] = [
227
+ { x: cx, y: cy - r },
228
+ { x: cx + r, y: cy },
229
+ { x: cx, y: cy + r },
230
+ { x: cx - r, y: cy },
231
+ ];
232
+ pushFilledPolygon(layer, points, opts);
233
+ }
234
+
235
+ function addPlusPoints(cx: number, cy: number, r: number, rotation: number): Vec2[] {
236
+ const a = ARM_HALF * r;
237
+ if (rotation === 0) {
238
+ return [
239
+ { x: cx - a, y: cy - r },
240
+ { x: cx + a, y: cy - r },
241
+ { x: cx + a, y: cy - a },
242
+ { x: cx + r, y: cy - a },
243
+ { x: cx + r, y: cy + a },
244
+ { x: cx + a, y: cy + a },
245
+ { x: cx + a, y: cy + r },
246
+ { x: cx - a, y: cy + r },
247
+ { x: cx - a, y: cy + a },
248
+ { x: cx - r, y: cy + a },
249
+ { x: cx - r, y: cy - a },
250
+ { x: cx - a, y: cy - a },
251
+ ];
252
+ }
253
+ const cos = Math.cos(rotation);
254
+ const sin = Math.sin(rotation);
255
+ const cosR = r * cos;
256
+ const sinR = r * sin;
257
+ const cosA = a * cos;
258
+ const sinA = a * sin;
259
+ return [
260
+ { x: cx - cosA + sinR, y: cy - sinA - cosR },
261
+ { x: cx + cosA + sinR, y: cy + sinA - cosR },
262
+ { x: cx + cosA + sinA, y: cy + sinA - cosA },
263
+ { x: cx + cosR + sinA, y: cy + sinR - cosA },
264
+ { x: cx + cosR - sinA, y: cy + sinR + cosA },
265
+ { x: cx + cosA - sinA, y: cy + sinA + cosA },
266
+ { x: cx + cosA - sinR, y: cy + sinA + cosR },
267
+ { x: cx - cosA - sinR, y: cy - sinA + cosR },
268
+ { x: cx - cosA - sinA, y: cy - sinA + cosA },
269
+ { x: cx - cosR - sinA, y: cy - sinR + cosA },
270
+ { x: cx - cosR + sinA, y: cy - sinR - cosA },
271
+ { x: cx - cosA + sinA, y: cy - sinA - cosA },
272
+ ];
273
+ }
274
+
275
+ function addStarPoints(cx: number, cy: number, r: number): Vec2[] {
276
+ return [
277
+ { x: cx + STAR_COS_SIN[0]!.cos * r, y: cy + STAR_COS_SIN[0]!.sin * r },
278
+ { x: cx + STAR_COS_SIN[1]!.cos * r, y: cy + STAR_COS_SIN[1]!.sin * r },
279
+ { x: cx + STAR_COS_SIN[2]!.cos * r, y: cy + STAR_COS_SIN[2]!.sin * r },
280
+ { x: cx + STAR_COS_SIN[3]!.cos * r, y: cy + STAR_COS_SIN[3]!.sin * r },
281
+ { x: cx + STAR_COS_SIN[4]!.cos * r, y: cy + STAR_COS_SIN[4]!.sin * r },
282
+ { x: cx + STAR_COS_SIN[5]!.cos * r, y: cy + STAR_COS_SIN[5]!.sin * r },
283
+ { x: cx + STAR_COS_SIN[6]!.cos * r, y: cy + STAR_COS_SIN[6]!.sin * r },
284
+ { x: cx + STAR_COS_SIN[7]!.cos * r, y: cy + STAR_COS_SIN[7]!.sin * r },
285
+ { x: cx + STAR_COS_SIN[8]!.cos * r, y: cy + STAR_COS_SIN[8]!.sin * r },
286
+ { x: cx + STAR_COS_SIN[9]!.cos * r, y: cy + STAR_COS_SIN[9]!.sin * r },
287
+ ];
288
+ }
289
+
290
+ export interface PolygonOpts {
291
+ fill?: Color;
292
+ stroke?: Color;
293
+ strokeWidth?: number;
294
+ group?: Group;
295
+ /**
296
+ * GPU emphasis key (P5-T3) threaded onto the polygon fill AND its stroke-ring
297
+ * polyline so the whole filled shape dims as one unit. `undefined` → untagged
298
+ * (no dim). Used by boxplot/violin/ridgeline to tag a logical entity's fill.
299
+ */
300
+ emphasisKey?: number;
301
+ }
302
+
303
+ /** Stroke width used when a dashed/dotted border is requested without an explicit width. */
304
+ const DEFAULT_DASH_STROKE_WIDTH = 1;
305
+
306
+ /** `*-open` shape variants treated as their solid counterpart for border-style routing. */
307
+ const OPEN_TO_SOLID: Partial<Record<PointShapeKind, PointShapeKind>> = {
308
+ "circle-open": "circle",
309
+ "square-open": "square",
310
+ "triangle-open": "triangle",
311
+ "diamond-open": "diamond",
312
+ };
313
+
314
+ function isOpenVariant(shape: PointShapeKind): boolean {
315
+ return shape.endsWith("-open");
316
+ }
317
+
318
+ // Outline vertex ring for a normalized (non-`-open`) point shape, used for
319
+ // dashed/dotted borders. Triangle/diamond mirror the vertex layout in
320
+ // `addTriangle`/`addDiamond` so the dash overlay aligns with the solid fill.
321
+ function pointShapeRingPoints(
322
+ shape: PointShapeKind,
323
+ cx: number,
324
+ cy: number,
325
+ radius: number,
326
+ ): Vec2[] | undefined {
327
+ switch (shape) {
328
+ case "circle":
329
+ return polylineEllipseRing({ cx, cy, rx: radius, ry: radius });
330
+ case "square":
331
+ return polylineRectRing({
332
+ x: cx - radius,
333
+ y: cy - radius,
334
+ width: radius * 2,
335
+ height: radius * 2,
336
+ });
337
+ case "triangle":
338
+ return [
339
+ { x: cx, y: cy - radius },
340
+ { x: cx + radius * SQRT3_OVER_2, y: cy + radius * 0.5 },
341
+ { x: cx - radius * SQRT3_OVER_2, y: cy + radius * 0.5 },
342
+ ];
343
+ case "diamond":
344
+ return [
345
+ { x: cx, y: cy - radius },
346
+ { x: cx + radius, y: cy },
347
+ { x: cx, y: cy + radius },
348
+ { x: cx - radius, y: cy },
349
+ ];
350
+ case "plus":
351
+ return addPlusPoints(cx, cy, radius, 0);
352
+ case "cross":
353
+ return addPlusPoints(cx, cy, radius, Math.PI / 4);
354
+ case "star":
355
+ return addStarPoints(cx, cy, radius);
356
+ default:
357
+ return undefined;
358
+ }
359
+ }
360
+
361
+ interface ShapeEmitContext {
362
+ layer: Layer;
363
+ cx: number;
364
+ cy: number;
365
+ radius: number;
366
+ fill: Color;
367
+ stroke: Color | undefined;
368
+ strokeWidth: number | undefined;
369
+ borderStyle: PointBorderStyle;
370
+ group: Group | undefined;
371
+ /** GPU emphasis key (P5-T3) threaded onto every primitive this glyph emits. */
372
+ emphasisKey?: number;
373
+ }
374
+
375
+ /**
376
+ * Emit a single point glyph honoring `borderStyle`. For solid/open borders we
377
+ * fall through to existing SDF/polygon paths; for dashed/dotted we suppress
378
+ * the native stroke and emit a separate polyline ring with the dash pattern.
379
+ */
380
+ function emitPointShape(shape: PointShapeKind, ctx: ShapeEmitContext): void {
381
+ const { layer, cx, cy, radius, group, emphasisKey } = ctx;
382
+
383
+ // Normalize `-open` shape variants. If the user supplied an `-open` shape
384
+ // with no explicit borderStyle, treat as `'open'`; otherwise borderStyle wins.
385
+ const normalizedShape = OPEN_TO_SOLID[shape] ?? shape;
386
+ const effectiveBorder: PointBorderStyle =
387
+ ctx.borderStyle === "solid" && isOpenVariant(shape) ? "open" : ctx.borderStyle;
388
+
389
+ const open = effectiveBorder === "open";
390
+ const dashed = effectiveBorder === "dashed" || effectiveBorder === "dotted";
391
+
392
+ // For "open", suppress fill and fall back to fill color for the stroke.
393
+ const fill: Color | undefined = open ? undefined : ctx.fill;
394
+ const baseStroke: Color | undefined = open ? (ctx.stroke ?? ctx.fill) : ctx.stroke;
395
+
396
+ // For dashed/dotted, the SDF/polygon stroke is suppressed (handled below
397
+ // by a separate polyline ring). Solid keeps the native stroke path.
398
+ const sdfStroke = dashed ? undefined : baseStroke;
399
+ const sdfStrokeWidth = dashed ? undefined : ctx.strokeWidth;
400
+
401
+ switch (normalizedShape) {
402
+ case "circle":
403
+ layer.pushCircle({
404
+ cx,
405
+ cy,
406
+ radius,
407
+ fill,
408
+ stroke: sdfStroke,
409
+ strokeWidth: sdfStrokeWidth,
410
+ group,
411
+ emphasisKey,
412
+ });
413
+ break;
414
+ case "square": {
415
+ const side = radius * 2;
416
+ layer.pushRect({
417
+ x: cx - radius,
418
+ y: cy - radius,
419
+ width: side,
420
+ height: side,
421
+ fill,
422
+ stroke: sdfStroke,
423
+ strokeWidth: sdfStrokeWidth,
424
+ group,
425
+ emphasisKey,
426
+ });
427
+ break;
428
+ }
429
+ case "triangle":
430
+ addTriangle(layer, cx, cy, radius, {
431
+ fill,
432
+ stroke: sdfStroke,
433
+ strokeWidth: sdfStrokeWidth,
434
+ group,
435
+ emphasisKey,
436
+ });
437
+ break;
438
+ case "diamond":
439
+ addDiamond(layer, cx, cy, radius, {
440
+ fill,
441
+ stroke: sdfStroke,
442
+ strokeWidth: sdfStrokeWidth,
443
+ group,
444
+ emphasisKey,
445
+ });
446
+ break;
447
+ case "plus":
448
+ pushFilledPolygon(layer, addPlusPoints(cx, cy, radius, 0), {
449
+ fill,
450
+ stroke: sdfStroke,
451
+ strokeWidth: sdfStrokeWidth,
452
+ group,
453
+ emphasisKey,
454
+ });
455
+ break;
456
+ case "cross":
457
+ pushFilledPolygon(layer, addPlusPoints(cx, cy, radius, Math.PI / 4), {
458
+ fill,
459
+ stroke: sdfStroke,
460
+ strokeWidth: sdfStrokeWidth,
461
+ group,
462
+ emphasisKey,
463
+ });
464
+ break;
465
+ case "star":
466
+ pushFilledPolygon(layer, addStarPoints(cx, cy, radius), {
467
+ fill,
468
+ stroke: sdfStroke,
469
+ strokeWidth: sdfStrokeWidth,
470
+ group,
471
+ emphasisKey,
472
+ });
473
+ break;
474
+ }
475
+
476
+ if (!dashed) return;
477
+
478
+ // Dashed/dotted border — emit a polyline ring approximating the shape's
479
+ // outline and stroke it with the requested dash pattern. Without an
480
+ // explicit stroke color, fall back to the fill so the outline is visible.
481
+ const dashStroke = baseStroke ?? ctx.fill;
482
+ if (!dashStroke || dashStroke.a <= 0) return;
483
+ const ringPoints = pointShapeRingPoints(normalizedShape, cx, cy, radius);
484
+ if (!ringPoints) return;
485
+ layer.pushPolyline({
486
+ points: ringPoints,
487
+ color: dashStroke,
488
+ width: ctx.strokeWidth ?? DEFAULT_DASH_STROKE_WIDTH,
489
+ closed: true,
490
+ dashPattern: dashPatternFor(effectiveBorder),
491
+ group,
492
+ emphasisKey,
493
+ });
494
+ }
495
+
496
+ /**
497
+ * Single-point swatch emission for legends and other one-off renders.
498
+ *
499
+ * Internal helper exported so the legend renderer can produce a swatch that
500
+ * matches the on-chart point exactly — same shape, border treatment, and
501
+ * overlay glyph — without re-implementing the dispatch. Composes the same
502
+ * primitives that `pointMark` calls per datum.
503
+ */
504
+ export interface PointSwatchEmitOptions {
505
+ cx: number;
506
+ cy: number;
507
+ radius: number;
508
+ fill: Color;
509
+ stroke?: Color;
510
+ strokeWidth?: number;
511
+ shape?: PointShapeKind;
512
+ borderStyle?: PointBorderStyle;
513
+ overlayGlyph?: PointShapeKind | null;
514
+ overlayScale?: number;
515
+ group?: Group;
516
+ }
517
+
518
+ export function emitPointSwatch(layer: Layer, opts: PointSwatchEmitOptions): void {
519
+ const shape = opts.shape ?? "circle";
520
+ const borderStyle = opts.borderStyle ?? "solid";
521
+ emitPointShape(shape, {
522
+ layer,
523
+ cx: opts.cx,
524
+ cy: opts.cy,
525
+ radius: opts.radius,
526
+ fill: opts.fill,
527
+ stroke: opts.stroke,
528
+ strokeWidth: opts.strokeWidth,
529
+ borderStyle,
530
+ group: opts.group,
531
+ });
532
+ const overlay = opts.overlayGlyph;
533
+ if (!overlay) return;
534
+ const overlayScale = opts.overlayScale ?? DEFAULT_OVERLAY_SCALE;
535
+ emitPointShape(overlay, {
536
+ layer,
537
+ cx: opts.cx,
538
+ cy: opts.cy,
539
+ radius: opts.radius * overlayScale,
540
+ fill: opts.stroke ?? opts.fill,
541
+ stroke: opts.stroke,
542
+ strokeWidth: opts.strokeWidth,
543
+ borderStyle: "solid",
544
+ group: opts.group,
545
+ });
546
+ }
547
+
548
+ export function pointMark<T>(data: readonly T[], options: PointMarkOptions<T>): MarkBuilder {
549
+ return {
550
+ length: data.length,
551
+ addTo(layer: Layer, origin: MarkOrigin = {}) {
552
+ const ox = origin.x ?? 0;
553
+ const oy = origin.y ?? 0;
554
+ const group = options.group;
555
+
556
+ // Hoist per-render: turn each option into a function we can call per
557
+ // datum without re-checking typeof / re-resolving `??` fallbacks.
558
+ const xFn = options.x;
559
+ const yFn = options.y;
560
+ const radiusFn = toAccessor(options.radius, 3);
561
+ const fillFn = toAccessor(options.fill, BLACK);
562
+ const strokeFn = toAccessorOrUndefined(options.stroke);
563
+ const strokeWidthFn = toAccessorOrUndefined(options.strokeWidth);
564
+ const shapeFn = toAccessor<T, PointShapeKind>(options.shape, "circle");
565
+ const borderStyleFn = toAccessor<T, PointBorderStyle>(options.borderStyle, "solid");
566
+ const overlayGlyphFn =
567
+ options.overlayGlyph === undefined
568
+ ? undefined
569
+ : toAccessorOrUndefined(options.overlayGlyph);
570
+ const overlayScaleFn = toAccessor(options.overlayScale, DEFAULT_OVERLAY_SCALE);
571
+ const emphasisKeyFn = options.emphasisKey;
572
+
573
+ for (let i = 0; i < data.length; i++) {
574
+ const datum = data[i]!;
575
+ const cx = ox + xFn(datum, i);
576
+ const cy = oy + yFn(datum, i);
577
+ if (!Number.isFinite(cx) || !Number.isFinite(cy)) continue;
578
+ const radius = radiusFn(datum, i);
579
+ const fill = fillFn(datum, i);
580
+ const stroke = strokeFn(datum, i);
581
+ const strokeWidth = strokeWidthFn(datum, i);
582
+ const shape = shapeFn(datum, i);
583
+ const borderStyle = borderStyleFn(datum, i);
584
+ const emphasisKey = emphasisKeyFn?.(datum, i);
585
+
586
+ emitPointShape(shape, {
587
+ layer,
588
+ cx,
589
+ cy,
590
+ radius,
591
+ fill,
592
+ stroke,
593
+ strokeWidth,
594
+ borderStyle,
595
+ group,
596
+ emphasisKey,
597
+ });
598
+
599
+ if (overlayGlyphFn !== undefined) {
600
+ const overlay = overlayGlyphFn(datum, i);
601
+ if (overlay) {
602
+ const overlayScale = overlayScaleFn(datum, i);
603
+ // Overlay color: prefer the base stroke for contrast with fill;
604
+ // fall back to fill itself if no stroke was specified.
605
+ const overlayFill = stroke ?? fill;
606
+ emitPointShape(overlay, {
607
+ layer,
608
+ cx,
609
+ cy,
610
+ radius: radius * overlayScale,
611
+ fill: overlayFill,
612
+ stroke,
613
+ strokeWidth,
614
+ borderStyle: "solid",
615
+ group,
616
+ emphasisKey,
617
+ });
618
+ }
619
+ }
620
+ }
621
+ return layer;
622
+ },
623
+ };
624
+ }
625
+
626
+ // ---------------------------------------------------------------------------
627
+ // lineMark
628
+ // ---------------------------------------------------------------------------
629
+
630
+ export interface LineMarkOptions<T> {
631
+ x: Accessor<T, number>;
632
+ y: Accessor<T, number>;
633
+ stroke?: Color;
634
+ strokeWidth?: number;
635
+ curve?: LineCurve;
636
+ /** Resample density for smooth curves (`monotone-x`, `basis`). Default `16`. */
637
+ curveSamples?: number;
638
+ cap?: LineCap;
639
+ join?: LineJoin;
640
+ dashPattern?: readonly number[];
641
+ dashOffset?: number;
642
+ /**
643
+ * Categorical dash treatment. `"solid"` (default) leaves the stroke as a
644
+ * continuous line; `"dashed"` and `"dotted"` map to default dash patterns.
645
+ * `dashPattern` takes precedence when both are supplied.
646
+ */
647
+ dashStyle?: LineDashStyle;
648
+ defined?: Accessor<T, boolean>;
649
+ /**
650
+ * GPU emphasis key (P5-T3) applied to every polyline segment of this line.
651
+ * A whole line/series shares one key so it dims/un-dims together. `undefined`
652
+ * → untagged (no dim).
653
+ */
654
+ emphasisKey?: number;
655
+ group?: Group;
656
+ }
657
+
658
+ function collectSegments<T>(
659
+ data: readonly T[],
660
+ x: Accessor<T, number>,
661
+ y: Accessor<T, number>,
662
+ ox: number,
663
+ oy: number,
664
+ defined: Accessor<T, boolean> | undefined,
665
+ ): Vec2[][] {
666
+ const segments: Vec2[][] = [];
667
+ let current: Vec2[] = [];
668
+
669
+ for (let i = 0; i < data.length; i++) {
670
+ const datum = data[i]!;
671
+ const isDefined = defined ? defined(datum, i) : true;
672
+ if (!isDefined) {
673
+ if (current.length > 0) {
674
+ segments.push(current);
675
+ current = [];
676
+ }
677
+ continue;
678
+ }
679
+ const px = ox + x(datum, i);
680
+ const py = oy + y(datum, i);
681
+ if (!Number.isFinite(px) || !Number.isFinite(py)) {
682
+ if (current.length > 0) {
683
+ segments.push(current);
684
+ current = [];
685
+ }
686
+ continue;
687
+ }
688
+ current.push({ x: px, y: py });
689
+ }
690
+
691
+ if (current.length > 0) segments.push(current);
692
+ return segments;
693
+ }
694
+
695
+ export function lineMark<T>(data: readonly T[], options: LineMarkOptions<T>): MarkBuilder {
696
+ return {
697
+ length: data.length,
698
+ addTo(layer: Layer, origin: MarkOrigin = {}) {
699
+ const ox = origin.x ?? 0;
700
+ const oy = origin.y ?? 0;
701
+ const color = options.stroke ?? BLACK;
702
+ const width = options.strokeWidth ?? 1;
703
+ const curve = options.curve ?? "linear";
704
+ const samples = Math.max(2, options.curveSamples ?? 16);
705
+ const segments = collectSegments(data, options.x, options.y, ox, oy, options.defined);
706
+ const dashPattern = options.dashPattern ?? dashPatternFor(options.dashStyle);
707
+
708
+ for (const raw of segments) {
709
+ if (raw.length < 2) continue;
710
+ const points = curve === "linear" ? raw : resamplePoints(raw, curve, samples);
711
+ layer.pushPolyline({
712
+ points,
713
+ color,
714
+ width,
715
+ cap: options.cap,
716
+ join: options.join,
717
+ dashPattern,
718
+ dashOffset: options.dashOffset,
719
+ group: options.group,
720
+ emphasisKey: options.emphasisKey,
721
+ });
722
+ }
723
+ return layer;
724
+ },
725
+ };
726
+ }
727
+
728
+ // ---------------------------------------------------------------------------
729
+ // areaMark
730
+ // ---------------------------------------------------------------------------
731
+
732
+ export interface AreaMarkOptions<T> {
733
+ x: Accessor<T, number>;
734
+ y0: Accessor<T, number>;
735
+ y1: Accessor<T, number>;
736
+ fill?: Color;
737
+ stroke?: Color;
738
+ strokeWidth?: number;
739
+ defined?: Accessor<T, boolean>;
740
+ group?: Group;
741
+ }
742
+
743
+ interface AreaSegment {
744
+ top: Vec2[];
745
+ bottom: Vec2[];
746
+ }
747
+
748
+ function collectAreaSegments<T>(
749
+ data: readonly T[],
750
+ x: Accessor<T, number>,
751
+ y0: Accessor<T, number>,
752
+ y1: Accessor<T, number>,
753
+ ox: number,
754
+ oy: number,
755
+ defined: Accessor<T, boolean> | undefined,
756
+ ): AreaSegment[] {
757
+ const segments: AreaSegment[] = [];
758
+ let current: AreaSegment = { top: [], bottom: [] };
759
+
760
+ const flush = () => {
761
+ if (current.top.length > 0) segments.push(current);
762
+ current = { top: [], bottom: [] };
763
+ };
764
+
765
+ for (let i = 0; i < data.length; i++) {
766
+ const datum = data[i]!;
767
+ const isDefined = defined ? defined(datum, i) : true;
768
+ if (!isDefined) {
769
+ flush();
770
+ continue;
771
+ }
772
+ const px = ox + x(datum, i);
773
+ const p1 = oy + y1(datum, i);
774
+ const p0 = oy + y0(datum, i);
775
+ if (!Number.isFinite(px) || !Number.isFinite(p0) || !Number.isFinite(p1)) {
776
+ flush();
777
+ continue;
778
+ }
779
+ current.top.push({ x: px, y: p1 });
780
+ current.bottom.push({ x: px, y: p0 });
781
+ }
782
+
783
+ flush();
784
+ return segments;
785
+ }
786
+
787
+ export function areaMark<T>(data: readonly T[], options: AreaMarkOptions<T>): MarkBuilder {
788
+ return {
789
+ length: data.length,
790
+ addTo(layer: Layer, origin: MarkOrigin = {}) {
791
+ const ox = origin.x ?? 0;
792
+ const oy = origin.y ?? 0;
793
+ const fill = options.fill ?? BLACK;
794
+ const segments = collectAreaSegments(
795
+ data,
796
+ options.x,
797
+ options.y0,
798
+ options.y1,
799
+ ox,
800
+ oy,
801
+ options.defined,
802
+ );
803
+
804
+ for (const { top, bottom } of segments) {
805
+ if (top.length < 2) continue;
806
+ const topLen = top.length;
807
+ const bottomLen = bottom.length;
808
+ const points: Vec2[] = [];
809
+ points.length = topLen + bottomLen;
810
+ for (let i = 0; i < topLen; i++) {
811
+ points[i] = top[i]!;
812
+ }
813
+ for (let i = 0; i < bottomLen; i++) {
814
+ points[topLen + i] = bottom[bottomLen - 1 - i]!;
815
+ }
816
+ pushFilledPolygon(layer, points, {
817
+ fill,
818
+ stroke: options.stroke,
819
+ strokeWidth: options.strokeWidth,
820
+ group: options.group,
821
+ });
822
+ }
823
+ return layer;
824
+ },
825
+ };
826
+ }
827
+
828
+ // ---------------------------------------------------------------------------
829
+ // barMark
830
+ // ---------------------------------------------------------------------------
831
+
832
+ /**
833
+ * Bar border treatment — shared between `barMark`, `categoricalBarMark`,
834
+ * `stackedBarMark`, and `groupedBarMark`. Same routing as point borders:
835
+ * `"open"` suppresses fill; `"dashed"` / `"dotted"` emit a polyline ring
836
+ * around the rect because SDF rect strokes have no native dash support.
837
+ */
838
+ export type BarBorderStyle = "solid" | "dashed" | "dotted" | "open";
839
+
840
+ interface BarRectOpts {
841
+ fill: Color;
842
+ stroke: Color | undefined;
843
+ strokeWidth: number | undefined;
844
+ cornerRadius: number | undefined;
845
+ rotation?: number | undefined;
846
+ borderStyle: BarBorderStyle;
847
+ group: Group | undefined;
848
+ /** Per-instance GPU emphasis key (P5-T3). Rides onto every primitive of the bar. */
849
+ emphasisKey?: number | undefined;
850
+ }
851
+
852
+ function emitBarRect(
853
+ layer: Layer,
854
+ x: number,
855
+ y: number,
856
+ width: number,
857
+ height: number,
858
+ opts: BarRectOpts,
859
+ ): void {
860
+ const open = opts.borderStyle === "open";
861
+ const dashed = opts.borderStyle === "dashed" || opts.borderStyle === "dotted";
862
+
863
+ const fill: Color | undefined = open ? undefined : opts.fill;
864
+ const baseStroke: Color | undefined = open ? (opts.stroke ?? opts.fill) : opts.stroke;
865
+ const sdfStroke = dashed ? undefined : baseStroke;
866
+ const sdfStrokeWidth = dashed ? undefined : opts.strokeWidth;
867
+
868
+ layer.pushRect({
869
+ x,
870
+ y,
871
+ width,
872
+ height,
873
+ fill,
874
+ stroke: sdfStroke,
875
+ strokeWidth: sdfStrokeWidth,
876
+ cornerRadius: opts.cornerRadius,
877
+ rotation: opts.rotation,
878
+ group: opts.group,
879
+ emphasisKey: opts.emphasisKey,
880
+ });
881
+
882
+ if (!dashed) return;
883
+ const dashStroke = baseStroke ?? opts.fill;
884
+ if (!dashStroke || dashStroke.a <= 0) return;
885
+ // Rotation isn't honored for dashed rings yet — SDF rect rotation rotates the
886
+ // shape locally, which doesn't map cleanly to a polyline ring built in
887
+ // world-space. Bars rarely combine rotation with dashed borders; document
888
+ // and proceed without rotation for now.
889
+ const ringPoints = polylineRectRing({
890
+ x,
891
+ y,
892
+ width,
893
+ height,
894
+ cornerRadius: opts.cornerRadius,
895
+ });
896
+ layer.pushPolyline({
897
+ points: ringPoints,
898
+ color: dashStroke,
899
+ width: opts.strokeWidth ?? DEFAULT_DASH_STROKE_WIDTH,
900
+ closed: true,
901
+ dashPattern: dashPatternFor(opts.borderStyle),
902
+ group: opts.group,
903
+ emphasisKey: opts.emphasisKey,
904
+ });
905
+ }
906
+
907
+ export interface BarMarkOptions<T> {
908
+ x: Accessor<T, number>;
909
+ y: Accessor<T, number>;
910
+ width: ValueOrAccessor<T, number>;
911
+ height: ValueOrAccessor<T, number>;
912
+ fill?: ValueOrAccessor<T, Color>;
913
+ stroke?: ValueOrAccessor<T, Color>;
914
+ strokeWidth?: ValueOrAccessor<T, number>;
915
+ cornerRadius?: ValueOrAccessor<T, number>;
916
+ rotation?: ValueOrAccessor<T, number>;
917
+ /** Per-datum border treatment. See {@link BarBorderStyle}. */
918
+ borderStyle?: ValueOrAccessor<T, BarBorderStyle>;
919
+ group?: Group;
920
+ }
921
+
922
+ export function barMark<T>(data: readonly T[], options: BarMarkOptions<T>): MarkBuilder {
923
+ return {
924
+ length: data.length,
925
+ addTo(layer: Layer, origin: MarkOrigin = {}) {
926
+ const ox = origin.x ?? 0;
927
+ const oy = origin.y ?? 0;
928
+ const group = options.group;
929
+
930
+ for (let i = 0; i < data.length; i++) {
931
+ const datum = data[i]!;
932
+ const x = ox + options.x(datum, i);
933
+ const y = oy + options.y(datum, i);
934
+ const width = resolve(options.width, datum, i);
935
+ const height = resolve(options.height, datum, i);
936
+ const fill = options.fill === undefined ? BLACK : resolve(options.fill, datum, i);
937
+ const stroke = options.stroke === undefined ? undefined : resolve(options.stroke, datum, i);
938
+ const strokeWidth =
939
+ options.strokeWidth === undefined ? undefined : resolve(options.strokeWidth, datum, i);
940
+ const cornerRadius =
941
+ options.cornerRadius === undefined ? undefined : resolve(options.cornerRadius, datum, i);
942
+ const rotation =
943
+ options.rotation === undefined ? undefined : resolve(options.rotation, datum, i);
944
+ const borderStyle: BarBorderStyle =
945
+ options.borderStyle === undefined ? "solid" : resolve(options.borderStyle, datum, i);
946
+
947
+ emitBarRect(layer, x, y, width, height, {
948
+ fill,
949
+ stroke,
950
+ strokeWidth,
951
+ cornerRadius,
952
+ rotation,
953
+ borderStyle,
954
+ group,
955
+ });
956
+ }
957
+ return layer;
958
+ },
959
+ };
960
+ }
961
+
962
+ // ---------------------------------------------------------------------------
963
+ // categoricalBarMark — single-series oriented bars driven by scales
964
+ // ---------------------------------------------------------------------------
965
+
966
+ export type BarOrientation = "y" | "x";
967
+
968
+ export interface CategoricalBarMarkOptions<T, C> {
969
+ /**
970
+ * `"y"` (default) → vertical bars, value mapped to height.
971
+ * `"x"` → horizontal bars, value mapped to width.
972
+ */
973
+ orientation?: BarOrientation;
974
+ category: Accessor<T, C>;
975
+ value: Accessor<T, number>;
976
+ /** Band scale resolving categories to one of the layer's axes. */
977
+ categoryScale: BandScale<C>;
978
+ /** Continuous scale resolving values to the perpendicular axis. */
979
+ valueScale: ContinuousScale;
980
+ /** Baseline value (in domain units). Default `0`. */
981
+ baseline?: number;
982
+ fill?: ValueOrAccessor<T, Color>;
983
+ stroke?: ValueOrAccessor<T, Color>;
984
+ strokeWidth?: ValueOrAccessor<T, number>;
985
+ cornerRadius?: ValueOrAccessor<T, number>;
986
+ /** Per-datum border treatment. See {@link BarBorderStyle}. */
987
+ borderStyle?: ValueOrAccessor<T, BarBorderStyle>;
988
+ /** Per-datum GPU emphasis key (P5-T3). `undefined` → untagged (no dim). */
989
+ emphasisKey?: Accessor<T, number | undefined>;
990
+ group?: Group;
991
+ }
992
+
993
+ export function categoricalBarMark<T, C>(
994
+ data: readonly T[],
995
+ options: CategoricalBarMarkOptions<T, C>,
996
+ ): MarkBuilder {
997
+ const orientation = options.orientation ?? "y";
998
+ const baseline = options.baseline ?? 0;
999
+ const bw = options.categoryScale.bandwidth();
1000
+ const baselinePos = options.valueScale(baseline);
1001
+
1002
+ return {
1003
+ length: data.length,
1004
+ addTo(layer: Layer, origin: MarkOrigin = {}) {
1005
+ const ox = origin.x ?? 0;
1006
+ const oy = origin.y ?? 0;
1007
+ const group = options.group;
1008
+ for (let i = 0; i < data.length; i++) {
1009
+ const datum = data[i]!;
1010
+ const cat = options.category(datum, i);
1011
+ const v = options.value(datum, i);
1012
+ const valuePos = options.valueScale(v);
1013
+ const fill = options.fill === undefined ? BLACK : resolve(options.fill, datum, i);
1014
+ const stroke = options.stroke === undefined ? undefined : resolve(options.stroke, datum, i);
1015
+ const strokeWidth =
1016
+ options.strokeWidth === undefined ? undefined : resolve(options.strokeWidth, datum, i);
1017
+ const cornerRadius =
1018
+ options.cornerRadius === undefined ? undefined : resolve(options.cornerRadius, datum, i);
1019
+ const borderStyle: BarBorderStyle =
1020
+ options.borderStyle === undefined ? "solid" : resolve(options.borderStyle, datum, i);
1021
+
1022
+ let x: number;
1023
+ let y: number;
1024
+ let width: number;
1025
+ let height: number;
1026
+ if (orientation === "y") {
1027
+ x = options.categoryScale(cat);
1028
+ y = Math.min(valuePos, baselinePos);
1029
+ width = bw;
1030
+ height = Math.abs(valuePos - baselinePos);
1031
+ } else {
1032
+ x = Math.min(valuePos, baselinePos);
1033
+ y = options.categoryScale(cat);
1034
+ width = Math.abs(valuePos - baselinePos);
1035
+ height = bw;
1036
+ }
1037
+
1038
+ emitBarRect(layer, ox + x, oy + y, width, height, {
1039
+ fill,
1040
+ stroke,
1041
+ strokeWidth,
1042
+ cornerRadius,
1043
+ borderStyle,
1044
+ group,
1045
+ emphasisKey: options.emphasisKey?.(datum, i),
1046
+ });
1047
+ }
1048
+ return layer;
1049
+ },
1050
+ };
1051
+ }
1052
+
1053
+ // ---------------------------------------------------------------------------
1054
+ // stackedBarMark — multi-series stacked bars driven by stack() + a value scale
1055
+ // ---------------------------------------------------------------------------
1056
+
1057
+ export interface StackedBarMarkOptions<T, C, K extends string> {
1058
+ orientation?: BarOrientation;
1059
+ category: Accessor<T, C>;
1060
+ categoryScale: BandScale<C>;
1061
+ valueScale: ContinuousScale;
1062
+ /** Per-segment fill — typically `colorScale(palette, keys)` (a function of `K`). */
1063
+ color: (key: K, segment: StackSegment<T, K>) => Color;
1064
+ offset?: StackOffset;
1065
+ order?: StackOrder;
1066
+ value?: (datum: T, key: K, datumIndex: number) => number;
1067
+ cornerRadius?: number;
1068
+ stroke?: Color;
1069
+ strokeWidth?: number;
1070
+ /** Border treatment shared across every segment. See {@link BarBorderStyle}. */
1071
+ borderStyle?: BarBorderStyle;
1072
+ /** Per-segment GPU emphasis key (P5-T3). `undefined` → untagged (no dim). */
1073
+ emphasisKey?: (key: K, segment: StackSegment<T, K>) => number | undefined;
1074
+ group?: Group;
1075
+ }
1076
+
1077
+ export interface StackedMarkBuilder<T, K extends string> extends MarkBuilder {
1078
+ /** Per-(datum, key) layout segments — useful for `valueLabelMark` over totals. */
1079
+ readonly segments: readonly StackSegment<T, K>[];
1080
+ }
1081
+
1082
+ export function stackedBarMark<T, C, K extends string>(
1083
+ data: readonly T[],
1084
+ keys: readonly K[],
1085
+ options: StackedBarMarkOptions<T, C, K>,
1086
+ ): StackedMarkBuilder<T, K> {
1087
+ const orientation = options.orientation ?? "y";
1088
+ const segments = stack(data, keys, {
1089
+ value: options.value,
1090
+ offset: options.offset,
1091
+ order: options.order,
1092
+ });
1093
+ const bw = options.categoryScale.bandwidth();
1094
+
1095
+ return {
1096
+ length: segments.length,
1097
+ segments,
1098
+ addTo(layer: Layer, origin: MarkOrigin = {}) {
1099
+ const ox = origin.x ?? 0;
1100
+ const oy = origin.y ?? 0;
1101
+ for (const seg of segments) {
1102
+ const cat = options.category(seg.datum, seg.datumIndex);
1103
+ const basePos = options.valueScale(seg.base);
1104
+ const topPos = options.valueScale(seg.top);
1105
+ const fill = options.color(seg.key, seg);
1106
+
1107
+ let x: number;
1108
+ let y: number;
1109
+ let width: number;
1110
+ let height: number;
1111
+ if (orientation === "y") {
1112
+ x = options.categoryScale(cat);
1113
+ y = Math.min(basePos, topPos);
1114
+ width = bw;
1115
+ height = Math.abs(topPos - basePos);
1116
+ } else {
1117
+ x = Math.min(basePos, topPos);
1118
+ y = options.categoryScale(cat);
1119
+ width = Math.abs(topPos - basePos);
1120
+ height = bw;
1121
+ }
1122
+
1123
+ emitBarRect(layer, ox + x, oy + y, width, height, {
1124
+ fill,
1125
+ stroke: options.stroke,
1126
+ strokeWidth: options.strokeWidth,
1127
+ cornerRadius: options.cornerRadius,
1128
+ borderStyle: options.borderStyle ?? "solid",
1129
+ group: options.group,
1130
+ emphasisKey: options.emphasisKey?.(seg.key, seg),
1131
+ });
1132
+ }
1133
+ return layer;
1134
+ },
1135
+ };
1136
+ }
1137
+
1138
+ // ---------------------------------------------------------------------------
1139
+ // groupedBarMark — multi-series grouped bars driven by groupedBandScale
1140
+ // ---------------------------------------------------------------------------
1141
+
1142
+ export interface GroupedBarMarkOptions<T, C, K extends string> {
1143
+ orientation?: BarOrientation;
1144
+ category: Accessor<T, C>;
1145
+ groupedScale: GroupedBandScale<C, K>;
1146
+ valueScale: ContinuousScale;
1147
+ /** Per-key fill — typically `colorScale(palette, keys)`. */
1148
+ color: (key: K) => Color;
1149
+ /** Per-(datum, key) value resolver. Default `datum[key]`. */
1150
+ value?: (datum: T, key: K, datumIndex: number) => number;
1151
+ /** Domain-space baseline. Default `0`. */
1152
+ baseline?: number;
1153
+ cornerRadius?: number;
1154
+ stroke?: Color;
1155
+ strokeWidth?: number;
1156
+ /** Border treatment shared across every bar in the grouped layout. */
1157
+ borderStyle?: BarBorderStyle;
1158
+ /** Per-(datum, key) GPU emphasis key (P5-T3). `undefined` → untagged. */
1159
+ emphasisKey?: (datum: T, key: K, datumIndex: number) => number | undefined;
1160
+ group?: Group;
1161
+ }
1162
+
1163
+ export function groupedBarMark<T, C, K extends string>(
1164
+ data: readonly T[],
1165
+ options: GroupedBarMarkOptions<T, C, K>,
1166
+ ): MarkBuilder {
1167
+ const orientation = options.orientation ?? "y";
1168
+ const baseline = options.baseline ?? 0;
1169
+ const baselinePos = options.valueScale(baseline);
1170
+ const innerKeys = options.groupedScale.innerKeys;
1171
+ const bw = options.groupedScale.bandwidth();
1172
+ const value =
1173
+ options.value ?? ((datum: T, key: K) => (datum as Record<string, number>)[key] ?? 0);
1174
+
1175
+ return {
1176
+ length: data.length * innerKeys.length,
1177
+ addTo(layer: Layer, origin: MarkOrigin = {}) {
1178
+ const ox = origin.x ?? 0;
1179
+ const oy = origin.y ?? 0;
1180
+ for (let i = 0; i < data.length; i++) {
1181
+ const datum = data[i]!;
1182
+ const cat = options.category(datum, i);
1183
+ for (const key of innerKeys) {
1184
+ const v = value(datum, key, i);
1185
+ const valuePos = options.valueScale(v);
1186
+ const innerPos = options.groupedScale(cat, key);
1187
+ if (Number.isNaN(innerPos)) continue;
1188
+ const fill = options.color(key);
1189
+
1190
+ let x: number;
1191
+ let y: number;
1192
+ let width: number;
1193
+ let height: number;
1194
+ if (orientation === "y") {
1195
+ x = innerPos;
1196
+ y = Math.min(valuePos, baselinePos);
1197
+ width = bw;
1198
+ height = Math.abs(valuePos - baselinePos);
1199
+ } else {
1200
+ x = Math.min(valuePos, baselinePos);
1201
+ y = innerPos;
1202
+ width = Math.abs(valuePos - baselinePos);
1203
+ height = bw;
1204
+ }
1205
+
1206
+ emitBarRect(layer, ox + x, oy + y, width, height, {
1207
+ fill,
1208
+ stroke: options.stroke,
1209
+ strokeWidth: options.strokeWidth,
1210
+ cornerRadius: options.cornerRadius,
1211
+ borderStyle: options.borderStyle ?? "solid",
1212
+ group: options.group,
1213
+ emphasisKey: options.emphasisKey?.(datum, key, i),
1214
+ });
1215
+ }
1216
+ }
1217
+ return layer;
1218
+ },
1219
+ };
1220
+ }
1221
+
1222
+ // ---------------------------------------------------------------------------
1223
+ // stackedAreaMark — multi-series stacked area along a continuous x axis
1224
+ // ---------------------------------------------------------------------------
1225
+
1226
+ export interface StackedAreaMarkOptions<T, K extends string> {
1227
+ /** Continuous x position resolver — already in layer-space pixels. */
1228
+ x: Accessor<T, number>;
1229
+ /** Continuous scale that maps stack base/top values to layer-space pixels. */
1230
+ valueScale: ContinuousScale;
1231
+ /** Per-series fill. */
1232
+ color: (key: K) => Color;
1233
+ offset?: StackOffset;
1234
+ order?: StackOrder;
1235
+ value?: (datum: T, key: K, datumIndex: number) => number;
1236
+ defined?: Accessor<T, boolean>;
1237
+ stroke?: Color;
1238
+ strokeWidth?: number;
1239
+ group?: Group;
1240
+ }
1241
+
1242
+ export function stackedAreaMark<T, K extends string>(
1243
+ data: readonly T[],
1244
+ keys: readonly K[],
1245
+ options: StackedAreaMarkOptions<T, K>,
1246
+ ): StackedMarkBuilder<T, K> {
1247
+ const segments = stack(data, keys, {
1248
+ value: options.value,
1249
+ offset: options.offset,
1250
+ order: options.order,
1251
+ });
1252
+
1253
+ // Group segments by key, preserve datum order (segments are already datum-major).
1254
+ const byKey = new Map<K, StackSegment<T, K>[]>();
1255
+ for (const seg of segments) {
1256
+ let arr = byKey.get(seg.key);
1257
+ if (!arr) {
1258
+ arr = [];
1259
+ byKey.set(seg.key, arr);
1260
+ }
1261
+ arr.push(seg);
1262
+ }
1263
+
1264
+ // After ordering, the keys actually used may be in different order.
1265
+ const orderedKeys: K[] = [];
1266
+ for (const seg of segments) {
1267
+ if (!orderedKeys.includes(seg.key)) orderedKeys.push(seg.key);
1268
+ if (orderedKeys.length === keys.length) break;
1269
+ }
1270
+
1271
+ return {
1272
+ length: segments.length,
1273
+ segments,
1274
+ addTo(layer: Layer, origin: MarkOrigin = {}) {
1275
+ for (const key of orderedKeys) {
1276
+ const series = byKey.get(key)!;
1277
+ // Build a flat row array in datum order so areaMark can iterate.
1278
+ areaMark(series, {
1279
+ x: (s) => options.x(s.datum, s.datumIndex),
1280
+ y0: (s) => options.valueScale(s.base),
1281
+ y1: (s) => options.valueScale(s.top),
1282
+ fill: options.color(key),
1283
+ stroke: options.stroke,
1284
+ strokeWidth: options.strokeWidth,
1285
+ defined: options.defined ? (s) => options.defined!(s.datum, s.datumIndex) : undefined,
1286
+ group: options.group,
1287
+ }).addTo(layer, origin);
1288
+ }
1289
+ return layer;
1290
+ },
1291
+ };
1292
+ }