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,578 @@
1
+ // ---------------------------------------------------------------------------
2
+ // area geom
3
+ // ---------------------------------------------------------------------------
4
+
5
+ import { lerpColor, type Color, type Layer, type Vec2 } from "insomni";
6
+ import {
7
+ areaMark,
8
+ pushFilledPolygon,
9
+ stackedAreaMark,
10
+ type MarkBuilder,
11
+ type StackOffset,
12
+ type StackOrder,
13
+ } from "../../marks.ts";
14
+ import { stack } from "../../marks/stack.ts";
15
+ import type { ContinuousScale } from "../../scales.ts";
16
+ import type { Aes } from "../aes.ts";
17
+ import { resolveAes } from "../aes.ts";
18
+ import { alphaize, seriesColor } from "../color-utils.ts";
19
+ import { areaSwatch } from "../../legend.ts";
20
+ import {
21
+ haloRing,
22
+ inlineMark,
23
+ defaultMarkFill,
24
+ resolveCoord,
25
+ resolveFillAlpha,
26
+ SELECTION_DIM_ALPHA,
27
+ selectedIndicesFor,
28
+ selectedSegmentsFor,
29
+ selectionActive,
30
+ wrapMark,
31
+ } from "./_mark.ts";
32
+ import type {
33
+ CompileContext,
34
+ CompiledHitTest,
35
+ Geom,
36
+ GeomFrame,
37
+ ResolvedChannelMap,
38
+ ScaleHints,
39
+ } from "./types.ts";
40
+
41
+ export type AreaPosition = "identity" | "stack" | "fill";
42
+
43
+ export interface AreaChannels<T> {
44
+ x: Aes<T, number | Date>;
45
+ /**
46
+ * Single column → simple area from y=0 to y. Array of column keys → stacked
47
+ * area, with the implicit `__series` color channel keyed by column name.
48
+ */
49
+ y: Aes<T, number | Date> | readonly (keyof T & string)[];
50
+ color?: Aes<T, unknown>;
51
+ }
52
+
53
+ export interface AreaOptions {
54
+ fill?: Color;
55
+ stroke?: Color;
56
+ strokeWidth?: number;
57
+ /** "identity" (default), "stack" (offset zero), or "fill" (offset expand). */
58
+ position?: AreaPosition;
59
+ /** Stack ordering — only meaningful with stacked positions. */
60
+ order?: StackOrder;
61
+ label?: string;
62
+ /**
63
+ * When true, hover hit-tests resolve to the nearest vertex *by x* — the
64
+ * cursor's vertical position doesn't influence which datum (or stacked
65
+ * segment) is picked, so the user can hover anywhere along the area.
66
+ * Default `false` (per-vertex Euclidean pick within `pickRadius`).
67
+ */
68
+ nearestX?: boolean;
69
+ }
70
+
71
+ export function area<T>(channels: AreaChannels<T>, options: AreaOptions = {}): Geom<T> {
72
+ const isStacked = Array.isArray(channels.y);
73
+ // Area baselines at 0 on y. For `fill` (expand offset) the rendered range
74
+ // is [0, 1] regardless of input totals, so set an explicit domain.
75
+ const scaleHints: ScaleHints = isStacked
76
+ ? options.position === "fill"
77
+ ? { y: { domain: [0, 1] } }
78
+ : { y: { includeZero: true } }
79
+ : { y: { includeZero: true } };
80
+ return {
81
+ kind: "area",
82
+ channels: { x: channels.x, y: channels.y, color: channels.color },
83
+ label: options.label,
84
+ scaleHints,
85
+ legendSwatch: (color) => areaSwatch({ fill: color, width: 14, height: 10 }),
86
+ compile(ctx: CompileContext<T>) {
87
+ const { data, scales, plot, theme } = ctx;
88
+ const coord = resolveCoord(ctx);
89
+ const xAes = resolveAes<T, unknown>(channels.x as Aes<T, unknown>);
90
+ const xFn = scales.x.fn;
91
+
92
+ const fill = options.fill ?? defaultMarkFill(theme);
93
+ const stroke = options.stroke;
94
+ const strokeWidth = options.strokeWidth;
95
+ const rowKey = (d: T, i: number) => (ctx.transitionKey ? ctx.transitionKey(d, i) : String(i));
96
+
97
+ if (!isStacked) {
98
+ const yAes = resolveAes<T, unknown>(channels.y as Aes<T, unknown>);
99
+ const colorAes = channels.color ? resolveAes<T, unknown>(channels.color) : undefined;
100
+ const isHidden = (d: T, i: number) => {
101
+ if (!ctx.hidden || !colorAes) return false;
102
+ return ctx.hidden.has(String(colorAes.fn(d, i)));
103
+ };
104
+
105
+ const yScale = scales.y.fn;
106
+ const yBaseline = yScale(0);
107
+ const anim = ctx.activeTransition;
108
+ const fromIndexFor = (d: T, i: number) => anim?.matchIndex(rowKey(d, i), i);
109
+ // Dim simple-area fill when a selection is active anywhere in the
110
+ // chart and no row of this area is selected. Stacked areas do their
111
+ // own per-segment dimming via `dimMulti` below.
112
+ const selectedSetSimple = selectedIndicesFor(ctx, "area");
113
+ const dimSimple = selectionActive(ctx) && selectedSetSimple === null;
114
+ const fillAlphaResolved = resolveFillAlpha(dimSimple, theme);
115
+ const resolvedFill = alphaize(fill, fillAlphaResolved);
116
+
117
+ // Lerp fill color if animating.
118
+ const animFill: Color = anim
119
+ ? (() => {
120
+ // Use datum 0's stored color as representative for the whole area fill.
121
+ if (anim.from.count === 0) return resolvedFill;
122
+ const fromFill: Color = {
123
+ r: anim.from.rgba[0]!,
124
+ g: anim.from.rgba[1]!,
125
+ b: anim.from.rgba[2]!,
126
+ a: anim.from.rgba[3]!,
127
+ };
128
+ return lerpColor(fromFill, resolvedFill, anim.t);
129
+ })()
130
+ : resolvedFill;
131
+
132
+ // Compute the per-datum plot-frame x, y0, y1 (with animation lerp),
133
+ // then project each edge vertex through the active coord. Under
134
+ // `coordCartesian()` projection is the identity; polar (Phase 3)
135
+ // will project the top edge and baseline as separate vertex streams.
136
+ const computeFrame = (
137
+ d: T,
138
+ i: number,
139
+ ): { x: number; y0: number; y1: number; hidden: boolean } => {
140
+ if (isHidden(d, i)) return { x: NaN, y0: NaN, y1: NaN, hidden: true };
141
+ const toX = xFn(xAes.fn(d, i));
142
+ const toY1 = yScale(yAes.fn(d, i));
143
+ const fromIndex = fromIndexFor(d, i);
144
+ let px = toX;
145
+ let py1 = toY1;
146
+ let py0 = yBaseline;
147
+ if (anim && fromIndex !== undefined) {
148
+ if (Number.isFinite(anim.from.x[fromIndex]!)) {
149
+ px = anim.from.x[fromIndex]! + (toX - anim.from.x[fromIndex]!) * anim.t;
150
+ }
151
+ if (Number.isFinite(anim.from.y[fromIndex]!)) {
152
+ py1 = anim.from.y[fromIndex]! + (toY1 - anim.from.y[fromIndex]!) * anim.t;
153
+ }
154
+ const fromR = anim.from.r?.[fromIndex];
155
+ if (fromR !== undefined && Number.isFinite(fromR)) {
156
+ py0 = fromR + (yBaseline - fromR) * anim.t;
157
+ }
158
+ }
159
+ return { x: px, y0: py0, y1: py1, hidden: false };
160
+ };
161
+ let builders: MarkBuilder[];
162
+ const isPolar = coord.kind === "polar";
163
+ if (isPolar) {
164
+ // Polar area: the area "baseline" (y0) should map to r=0 (the polar
165
+ // centre) at the data point's angle, rather than the Cartesian y=0
166
+ // baseline (which under angleChannel="y" gives a constant starting
167
+ // angle). We also tessellate the top edge through coord.segment so
168
+ // consecutive sector-tip vertices follow arcs instead of straight
169
+ // chords.
170
+ const xDomain = scales.x.axisScale.domain as [number, number];
171
+ const xBaselinePx = xFn(xDomain[0] ?? 0);
172
+ const tessTop: Vec2[] = [];
173
+ const tessBottom: Vec2[] = [];
174
+ const ox = plot.topLeft.x;
175
+ const oy = plot.topLeft.y;
176
+ for (let i = 0; i < data.length; i++) {
177
+ const d = data[i]!;
178
+ const f = computeFrame(d, i);
179
+ if (f.hidden) continue;
180
+ // Top vertex: full projection at (hours, sector).
181
+ const topP = coord.project({ x: f.x, y: f.y1 });
182
+ // Bottom vertex: at the origin angle (same sector) but r=0.
183
+ const botP = coord.project({ x: xBaselinePx, y: f.y1 });
184
+ // Tessellate the top edge segment from previous → current.
185
+ if (tessTop.length > 0) {
186
+ if (i > 0) {
187
+ const prev = computeFrame(data[i - 1]!, i - 1);
188
+ if (!prev.hidden) {
189
+ const seg = coord.segment({ x: prev.x, y: prev.y1 }, { x: f.x, y: f.y1 });
190
+ // Skip first (already pushed) and last (pushed as topP below).
191
+ for (let k = 1; k < seg.length - 1; k++) {
192
+ tessTop.push({ x: ox + seg[k]!.x, y: oy + seg[k]!.y });
193
+ }
194
+ // Bottom edge: tessellate from previous → current baseline.
195
+ const botSeg = coord.segment(
196
+ { x: xBaselinePx, y: prev.y1 },
197
+ { x: xBaselinePx, y: f.y1 },
198
+ );
199
+ for (let k = 1; k < botSeg.length - 1; k++) {
200
+ tessBottom.push({ x: ox + botSeg[k]!.x, y: oy + botSeg[k]!.y });
201
+ }
202
+ }
203
+ }
204
+ }
205
+ tessTop.push({ x: ox + topP.x, y: oy + topP.y });
206
+ tessBottom.push({ x: ox + botP.x, y: oy + botP.y });
207
+ }
208
+ // Build the closed polygon: top edge forward → bottom edge reversed.
209
+ const points: Vec2[] = [...tessTop];
210
+ for (let i = tessBottom.length - 1; i >= 0; i--) {
211
+ points.push(tessBottom[i]!);
212
+ }
213
+ builders = [
214
+ {
215
+ length: data.length,
216
+ addTo(layer: Layer) {
217
+ pushFilledPolygon(layer, points, {
218
+ fill: animFill,
219
+ stroke: stroke && dimSimple ? alphaize(stroke, SELECTION_DIM_ALPHA) : stroke,
220
+ strokeWidth,
221
+ });
222
+ return layer;
223
+ },
224
+ },
225
+ ];
226
+ } else {
227
+ const mark = areaMark(data, {
228
+ x: (d, i) => {
229
+ const f = computeFrame(d, i);
230
+ if (f.hidden) return NaN;
231
+ return coord.project({ x: f.x, y: f.y1 }).x;
232
+ },
233
+ y0: (d, i) => {
234
+ const f = computeFrame(d, i);
235
+ return coord.project({ x: f.x, y: f.y0 }).y;
236
+ },
237
+ y1: (d, i) => {
238
+ const f = computeFrame(d, i);
239
+ if (f.hidden) return NaN;
240
+ return coord.project({ x: f.x, y: f.y1 }).y;
241
+ },
242
+ fill: animFill,
243
+ stroke: stroke && dimSimple ? alphaize(stroke, SELECTION_DIM_ALPHA) : stroke,
244
+ strokeWidth,
245
+ });
246
+ builders = [wrapMark(mark, plot.topLeft, data.length)];
247
+ }
248
+
249
+ // Hover halo on the active top-edge vertex.
250
+ if (ctx.hovered && ctx.hovered.geomKind === "area" && ctx.hovered.data === data) {
251
+ const i = ctx.hovered.dataIndex;
252
+ const d = data[i];
253
+ if (d !== undefined) {
254
+ const xv = xAes.fn(d, i);
255
+ const yv = yAes.fn(d, i);
256
+ if (xv != null && yv != null) {
257
+ const rawX = xFn(xv);
258
+ const rawY = yScale(yv);
259
+ if (Number.isFinite(rawX) && Number.isFinite(rawY)) {
260
+ const projected = coord.project({ x: rawX, y: rawY });
261
+ const cx = plot.topLeft.x + projected.x;
262
+ const cy = plot.topLeft.y + projected.y;
263
+ builders.push(inlineMark((layer) => haloRing(layer, cx, cy, 6, fill, 2)));
264
+ }
265
+ }
266
+ }
267
+ }
268
+
269
+ // Selection rings — one per selected top-edge vertex.
270
+ const selectedSet = selectedIndicesFor(ctx, "area");
271
+ if (selectedSet) {
272
+ for (const i of selectedSet) {
273
+ const d = data[i];
274
+ if (d === undefined) continue;
275
+ const xv = xAes.fn(d, i);
276
+ const yv = yAes.fn(d, i);
277
+ if (xv == null || yv == null) continue;
278
+ const rawX = xFn(xv);
279
+ const rawY = yScale(yv);
280
+ if (!Number.isFinite(rawX) || !Number.isFinite(rawY)) continue;
281
+ const projected = coord.project({ x: rawX, y: rawY });
282
+ const cx = plot.topLeft.x + projected.x;
283
+ const cy = plot.topLeft.y + projected.y;
284
+ builders.push(inlineMark((layer) => haloRing(layer, cx, cy, 6, fill, 2)));
285
+ }
286
+ }
287
+
288
+ return builders;
289
+ }
290
+
291
+ // Stacked: y is keys[]; build a stackedAreaMark.
292
+ const keys = channels.y as readonly (keyof T & string)[];
293
+ // activeKeys excludes hidden series; color domain stays full-keys so
294
+ // visible series keep stable colors when siblings are toggled.
295
+ const activeKeys = ctx.hidden ? keys.filter((k) => !ctx.hidden!.has(k)) : keys;
296
+ const yContinuous = scales.y.axisScale as ContinuousScale;
297
+ const offset: StackOffset = options.position === "fill" ? "expand" : "zero";
298
+ const colorFn = seriesColor(scales.color, theme.palettes.categorical, [...keys] as string[]);
299
+ const anim = ctx.activeTransition;
300
+ const stackedSegments = stack(data, [...activeKeys] as string[], {
301
+ offset,
302
+ order: options.order,
303
+ });
304
+ // When a selection is active, dim the base stacked area uniformly and
305
+ // re-overlay the selected segments' top vertex at full color so they
306
+ // pop. Mirrors the multi-series bar treatment.
307
+ const dimMulti = !!ctx.selected && ctx.selected.length > 0;
308
+ const baseFillAlpha = resolveFillAlpha(dimMulti, theme);
309
+ const builders: MarkBuilder[] = [];
310
+ if (anim) {
311
+ const segments = stackedSegments;
312
+ type AnimatedSeries = {
313
+ key: string;
314
+ fill: Color;
315
+ top: { x: number; y: number }[];
316
+ bottom: { x: number; y: number }[];
317
+ };
318
+ const series = new Map<string, AnimatedSeries>();
319
+ const orderKeys: string[] = [];
320
+ for (let i = 0; i < segments.length; i++) {
321
+ const seg = segments[i]!;
322
+ const toX = xFn(xAes.fn(seg.datum, seg.datumIndex));
323
+ const toY0 = yContinuous(seg.base);
324
+ const toY1 = yContinuous(seg.top);
325
+ const fromIndex = anim.matchIndex(`${rowKey(seg.datum, seg.datumIndex)}:${seg.key}`, i);
326
+ const fromX = fromIndex !== undefined ? anim.from.x[fromIndex] : NaN;
327
+ const fromY0 = fromIndex !== undefined ? (anim.from.r?.[fromIndex] ?? NaN) : NaN;
328
+ const fromY1 = fromIndex !== undefined ? anim.from.y[fromIndex] : NaN;
329
+ const px =
330
+ Number.isFinite(fromX) && Number.isFinite(toX) ? fromX + (toX - fromX) * anim.t : toX;
331
+ const py0 =
332
+ Number.isFinite(fromY0) && Number.isFinite(toY0)
333
+ ? fromY0 + (toY0 - fromY0) * anim.t
334
+ : toY0;
335
+ const py1 =
336
+ Number.isFinite(fromY1) && Number.isFinite(toY1)
337
+ ? fromY1 + (toY1 - fromY1) * anim.t
338
+ : toY1;
339
+ if (!Number.isFinite(px) || !Number.isFinite(py0) || !Number.isFinite(py1)) continue;
340
+ let bucket = series.get(seg.key);
341
+ if (!bucket) {
342
+ const toFill = alphaize(colorFn(seg.key), baseFillAlpha);
343
+ const fill =
344
+ fromIndex !== undefined
345
+ ? lerpColor(
346
+ {
347
+ r: anim.from.rgba[fromIndex * 4]!,
348
+ g: anim.from.rgba[fromIndex * 4 + 1]!,
349
+ b: anim.from.rgba[fromIndex * 4 + 2]!,
350
+ a: anim.from.rgba[fromIndex * 4 + 3]!,
351
+ },
352
+ toFill,
353
+ anim.t,
354
+ )
355
+ : { ...toFill, a: toFill.a * anim.t };
356
+ bucket = { key: seg.key, fill, top: [], bottom: [] };
357
+ series.set(seg.key, bucket);
358
+ orderKeys.push(seg.key);
359
+ }
360
+ // Project the top and bottom edge vertices independently through
361
+ // the active coord. Cartesian: identity.
362
+ const projTop = coord.project({ x: px, y: py1 });
363
+ const projBottom = coord.project({ x: px, y: py0 });
364
+ bucket.top.push({ x: plot.topLeft.x + projTop.x, y: plot.topLeft.y + projTop.y });
365
+ bucket.bottom.push({
366
+ x: plot.topLeft.x + projBottom.x,
367
+ y: plot.topLeft.y + projBottom.y,
368
+ });
369
+ }
370
+ builders.push(
371
+ inlineMark((layer) => {
372
+ for (const key of orderKeys) {
373
+ const s = series.get(key)!;
374
+ if (s.top.length < 2) continue;
375
+ const points = [...s.top];
376
+ for (let i = s.bottom.length - 1; i >= 0; i--) points.push(s.bottom[i]!);
377
+ pushFilledPolygon(layer, points, { fill: s.fill, stroke, strokeWidth });
378
+ }
379
+ }, segments.length),
380
+ );
381
+ } else {
382
+ // Static (non-animated) stacked path. `stackedAreaMark` owns the
383
+ // top/bottom polygon construction via `x` callback + `valueScale`.
384
+ // Under `coordCartesian()` projection is the identity — projecting
385
+ // only one axis here would produce a half-projected result under
386
+ // polar, so we leave the mark unprojected for Phase 1 and let the
387
+ // Phase 3 polar coord swap in a different mark factory entirely.
388
+ const mark = stackedAreaMark<T, string>(data, [...activeKeys], {
389
+ x: (d, i) => xFn(xAes.fn(d, i)),
390
+ valueScale: yContinuous,
391
+ color: (key) => alphaize(colorFn(key), baseFillAlpha),
392
+ offset,
393
+ order: options.order,
394
+ stroke,
395
+ strokeWidth,
396
+ });
397
+ builders.push(wrapMark(mark, plot.topLeft));
398
+ }
399
+
400
+ // Hover halo + selection rings on stacked-area segment top edge.
401
+ const drawSegmentHalo = (i: number, key: string) => {
402
+ const seg = stackedSegments.find((s) => s.datumIndex === i && s.key === key);
403
+ if (!seg) return;
404
+ const xv = xAes.fn(seg.datum, seg.datumIndex);
405
+ if (xv == null) return;
406
+ const rawX = xFn(xv);
407
+ const rawY = yContinuous(seg.top);
408
+ if (!Number.isFinite(rawX) || !Number.isFinite(rawY)) return;
409
+ const projected = coord.project({ x: rawX, y: rawY });
410
+ const cx = plot.topLeft.x + projected.x;
411
+ const cy = plot.topLeft.y + projected.y;
412
+ const ringColor = colorFn(key);
413
+ builders.push(inlineMark((layer) => haloRing(layer, cx, cy, 6, ringColor, 2)));
414
+ };
415
+ if (
416
+ ctx.hovered &&
417
+ ctx.hovered.geomKind === "area" &&
418
+ ctx.hovered.data === data &&
419
+ ctx.hovered.seriesKey
420
+ ) {
421
+ drawSegmentHalo(ctx.hovered.dataIndex, ctx.hovered.seriesKey);
422
+ }
423
+ const stackedSelected = selectedSegmentsFor(ctx, "area");
424
+ if (stackedSelected) {
425
+ for (const { dataIndex: i, seriesKey: key } of stackedSelected) {
426
+ if (key) drawSegmentHalo(i, key);
427
+ }
428
+ }
429
+ return builders;
430
+ },
431
+ compileHitTest(ctx: CompileContext<T>): CompiledHitTest<T> | null {
432
+ const { data, scales, plot, hidden } = ctx;
433
+ if (data.length === 0) return null;
434
+ const coord = resolveCoord(ctx);
435
+
436
+ const xAes = resolveAes<T, unknown>(channels.x as Aes<T, unknown>);
437
+ const xScale = scales.x.fn;
438
+ const yScale = scales.y.fn;
439
+ const ox = plot.topLeft.x;
440
+ const oy = plot.topLeft.y;
441
+ const positionsList: number[] = [];
442
+ const dataIndexList: number[] = [];
443
+ const seriesKeyList: (string | undefined)[] = [];
444
+
445
+ if (!isStacked) {
446
+ const yAes = resolveAes<T, unknown>(channels.y as Aes<T, unknown>);
447
+ const colorAes = channels.color ? resolveAes<T, unknown>(channels.color) : undefined;
448
+ for (let i = 0; i < data.length; i++) {
449
+ const d = data[i]!;
450
+ if (hidden && colorAes && hidden.has(String(colorAes.fn(d, i)))) continue;
451
+ const xv = xAes.fn(d, i);
452
+ const yv = yAes.fn(d, i);
453
+ if (xv == null || yv == null) continue;
454
+ const rawX = xScale(xv);
455
+ const rawY = yScale(yv);
456
+ if (!Number.isFinite(rawX) || !Number.isFinite(rawY)) continue;
457
+ const projected = coord.project({ x: rawX, y: rawY });
458
+ positionsList.push(ox + projected.x, oy + projected.y);
459
+ dataIndexList.push(i);
460
+ seriesKeyList.push(undefined);
461
+ }
462
+ } else {
463
+ const keys = channels.y as readonly (keyof T & string)[];
464
+ const activeKeys = hidden ? keys.filter((k) => !hidden.has(k)) : keys;
465
+ const segments = stack(data, [...activeKeys] as string[], {
466
+ offset: options.position === "fill" ? "expand" : "zero",
467
+ order: options.order,
468
+ });
469
+ for (const seg of segments) {
470
+ const xv = xAes.fn(seg.datum, seg.datumIndex);
471
+ if (xv == null) continue;
472
+ const rawX = xScale(xv);
473
+ const rawY = yScale(seg.top);
474
+ if (!Number.isFinite(rawX) || !Number.isFinite(rawY)) continue;
475
+ const projected = coord.project({ x: rawX, y: rawY });
476
+ positionsList.push(ox + projected.x, oy + projected.y);
477
+ dataIndexList.push(seg.datumIndex);
478
+ seriesKeyList.push(seg.key);
479
+ }
480
+ }
481
+ const n = dataIndexList.length;
482
+ if (n === 0) return null;
483
+ const positions = Float32Array.from(positionsList);
484
+ const dataIndex = Int32Array.from(dataIndexList);
485
+
486
+ const channelsMap: ResolvedChannelMap<T> = {
487
+ x: xAes,
488
+ y: !isStacked ? resolveAes<T, unknown>(channels.y as Aes<T, unknown>) : undefined,
489
+ color: !isStacked && channels.color ? resolveAes<T, unknown>(channels.color) : undefined,
490
+ };
491
+
492
+ const pickRadius = options.nearestX ? Math.max(plot.width, plot.height) : 12;
493
+ return {
494
+ geomKind: "area",
495
+ label: options.label,
496
+ positions: positions.subarray(0, n * 2),
497
+ dataIndex: dataIndex.subarray(0, n),
498
+ seriesKey: seriesKeyList,
499
+ pickRadius,
500
+ pickAxis: options.nearestX ? "x" : undefined,
501
+ channels: channelsMap,
502
+ data,
503
+ };
504
+ },
505
+ captureFrame(ctx: CompileContext<T>): GeomFrame | null {
506
+ const { data, scales, theme } = ctx;
507
+ if (data.length === 0) return null;
508
+
509
+ const xAes = resolveAes<T, unknown>(channels.x as Aes<T, unknown>);
510
+ const xFn = scales.x.fn;
511
+ const yScale = scales.y.fn;
512
+ const count = isStacked
513
+ ? (channels.y as readonly (keyof T & string)[]).reduce(
514
+ (sum, key) => sum + (ctx.hidden?.has(key) ? 0 : data.length),
515
+ 0,
516
+ )
517
+ : data.length;
518
+ const x = new Float32Array(count);
519
+ const y = new Float32Array(count);
520
+ const rgba = new Float32Array(count * 4);
521
+ const a = new Float32Array(count);
522
+ // r stores the pixel baseline (y0) for lerp.
523
+ const r = new Float32Array(count);
524
+ const ids = ctx.transitionKey ? Array.from<string>({ length: count }) : undefined;
525
+ if (!isStacked) {
526
+ const yAes = resolveAes<T, unknown>(channels.y as Aes<T, unknown>);
527
+ const yBaseline = yScale(0);
528
+ const areaFill = options.fill ?? defaultMarkFill(theme);
529
+ const resolvedFill = alphaize(areaFill, theme.marks.fillAlpha);
530
+ for (let i = 0; i < count; i++) {
531
+ const d = data[i]!;
532
+ const xv = xAes.fn(d, i);
533
+ const yv = yAes.fn(d, i);
534
+ const px = xFn(xv);
535
+ const py = yScale(yv);
536
+ x[i] = Number.isFinite(px) ? px : NaN;
537
+ y[i] = Number.isFinite(py) ? py : NaN;
538
+ r[i] = yBaseline;
539
+ rgba[i * 4] = resolvedFill.r;
540
+ rgba[i * 4 + 1] = resolvedFill.g;
541
+ rgba[i * 4 + 2] = resolvedFill.b;
542
+ rgba[i * 4 + 3] = resolvedFill.a;
543
+ a[i] = resolvedFill.a;
544
+ if (ids) ids[i] = ctx.transitionKey!(d, i);
545
+ }
546
+ } else {
547
+ const keys = channels.y as readonly (keyof T & string)[];
548
+ const activeKeys = ctx.hidden ? keys.filter((k) => !ctx.hidden!.has(k)) : keys;
549
+ const colorFn = seriesColor(scales.color, theme.palettes.categorical, [
550
+ ...keys,
551
+ ] as string[]);
552
+ const segments = stack(data, [...activeKeys] as string[], {
553
+ offset: options.position === "fill" ? "expand" : "zero",
554
+ order: options.order,
555
+ });
556
+ let n = 0;
557
+ for (const seg of segments) {
558
+ const px = xFn(xAes.fn(seg.datum, seg.datumIndex));
559
+ const py = yScale(seg.top);
560
+ const py0 = yScale(seg.base);
561
+ const fill = alphaize(colorFn(seg.key), theme.marks.fillAlpha);
562
+ x[n] = Number.isFinite(px) ? px : NaN;
563
+ y[n] = Number.isFinite(py) ? py : NaN;
564
+ r[n] = Number.isFinite(py0) ? py0 : NaN;
565
+ rgba[n * 4] = fill.r;
566
+ rgba[n * 4 + 1] = fill.g;
567
+ rgba[n * 4 + 2] = fill.b;
568
+ rgba[n * 4 + 3] = fill.a;
569
+ a[n] = fill.a;
570
+ if (ids) ids[n] = `${ctx.transitionKey!(seg.datum, seg.datumIndex)}:${seg.key}`;
571
+ n++;
572
+ }
573
+ }
574
+
575
+ return { count, x, y, rgba, a, r, ids };
576
+ },
577
+ };
578
+ }
@@ -0,0 +1,27 @@
1
+ import type { Color } from "insomni";
2
+ import { type ColorOrAccent } from "../color-utils.ts";
3
+ import type { Geom } from "./types.ts";
4
+ export interface BandChannels {
5
+ x?: readonly [number | Date, number | Date];
6
+ y?: readonly [number | Date, number | Date];
7
+ }
8
+ export interface BandOptions {
9
+ /**
10
+ * Fill color. Accepts a literal {@link Color} or a theme accent key
11
+ * (`"positive" | "negative" | "warn" | "info"`); accent keys resolve through
12
+ * `theme.accents` and have `theme.marks.bandFillAlpha` applied unless
13
+ * {@link BandOptions.alpha} is set.
14
+ */
15
+ fill?: ColorOrAccent;
16
+ stroke?: ColorOrAccent;
17
+ strokeWidth?: number;
18
+ /**
19
+ * Override the fill alpha. Defaults to `theme.marks.bandFillAlpha` when
20
+ * `fill` is omitted or is an accent key; defaults to no override (use the
21
+ * color's own alpha) when `fill` is a literal {@link Color}.
22
+ */
23
+ alpha?: number;
24
+ label?: string;
25
+ labelColor?: Color;
26
+ }
27
+ export declare function band<T>(channels: BandChannels, options?: BandOptions): Geom<T>;
@@ -0,0 +1,57 @@
1
+ import { createFrame } from "insomni";
2
+ import { describe, expect, test } from "vite-plus/test";
3
+
4
+ import { resolveAes } from "../aes.ts";
5
+ import { buildPositionScale, type ScaleBundle } from "../scales.ts";
6
+ import { themeDefault } from "../theme.ts";
7
+ import { band } from "./band.ts";
8
+ import type { CompileContext } from "./types.ts";
9
+
10
+ interface Row {
11
+ v: number;
12
+ }
13
+
14
+ const data: Row[] = [{ v: 25 }, { v: 75 }];
15
+
16
+ function makeCtx(rows: readonly Row[]): CompileContext<Row> {
17
+ const xAes = resolveAes<Row, unknown>("v");
18
+ const yAes = resolveAes<Row, unknown>("v");
19
+ const xScale = buildPositionScale(xAes, rows, [0, 100]);
20
+ const yScale = buildPositionScale(yAes, rows, [200, 0]);
21
+ const scales: ScaleBundle = { x: xScale, y: yScale };
22
+ return {
23
+ data: rows,
24
+ scales,
25
+ plot: createFrame({ x: 50, y: 30, width: 100, height: 200 }),
26
+ theme: themeDefault,
27
+ atlas: undefined,
28
+ };
29
+ }
30
+
31
+ describe("band geom — compileHitTest", () => {
32
+ test("vertical band emits one hit at the band's center", () => {
33
+ const geom = band<Row>({ x: [25, 75] }, { label: "warn zone" });
34
+ const hits = geom.compileHitTest!(makeCtx(data))!;
35
+ expect(hits.geomKind).toBe("band");
36
+ expect(hits.dataIndex.length).toBe(1);
37
+ // Band spans scaled x [0, 100]; center x = 50; +plot.x(50) = 100.
38
+ expect(hits.positions[0]).toBeCloseTo(100, 3);
39
+ // y centered in plot frame: 30 + 100 = 130.
40
+ expect(hits.positions[1]).toBeCloseTo(130, 3);
41
+ expect(hits.label).toBe("warn zone");
42
+ });
43
+
44
+ test("horizontal band centers x in the plot frame", () => {
45
+ const geom = band<Row>({ y: [25, 75] });
46
+ const hits = geom.compileHitTest!(makeCtx(data))!;
47
+ // x centered: 50 + 50 = 100.
48
+ expect(hits.positions[0]).toBeCloseTo(100, 3);
49
+ });
50
+
51
+ test("pickRadius covers half the band's larger extent", () => {
52
+ const geom = band<Row>({ x: [25, 75] });
53
+ const hits = geom.compileHitTest!(makeCtx(data))!;
54
+ // halfY = plot.height/2 = 100 (off-axis = full height) > halfX = 50.
55
+ expect(hits.pickRadius).toBeCloseTo(100, 3);
56
+ });
57
+ });