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,840 @@
1
+ // ---------------------------------------------------------------------------
2
+ // aggregate geom — data-density binning with semantic-zoom dissolve
3
+ // ---------------------------------------------------------------------------
4
+ // Bins source data along x or y, summarises each bin, and emits the result
5
+ // through one of `point` / `line` / `bar`. When the average datapoints-per-bin
6
+ // drops below `dissolveAt` the geom renders the raw input instead — so at
7
+ // extreme zoom-out you get a clean summary curve, and as you zoom in the geom
8
+ // transparently dissolves to the underlying scatter / line / bars.
9
+ //
10
+ // Generic across any high-cardinality numeric or time-axis stream (telemetry,
11
+ // log events, geo points, financial ticks, sensor readings). The medical
12
+ // weight chart is one consumer.
13
+ //
14
+ // Implementation notes:
15
+ // - Pure, scale-aware compile. `binSize: 'auto'` reads the active scale's
16
+ // pixel range + domain to target ~7px per bin on the binned axis.
17
+ // - Dissolve threshold is checked AFTER binning: if avg/bin < dissolveAt,
18
+ // skip the synthetic series and forward the original (post-filter) rows
19
+ // to the chosen inner mark.
20
+ // - Bin axis must be continuous (linear / log / sqrt / time). Band scales
21
+ // are not supported as a bin axis — they're already categorical.
22
+
23
+ import { type Color } from "insomni";
24
+ import {
25
+ barMark,
26
+ lineMark,
27
+ pointMark,
28
+ type LineCurve,
29
+ type MarkBuilder,
30
+ type PointShapeKind,
31
+ } from "../../marks.ts";
32
+ import type { Aes } from "../aes.ts";
33
+ import { resolveAes } from "../aes.ts";
34
+ import { alphaize } from "../color-utils.ts";
35
+ import { interval } from "./interval.ts";
36
+ import { ribbon } from "./ribbon.ts";
37
+ import type { CompileContext, Geom } from "./types.ts";
38
+ import { defaultMarkFill, wrapMark } from "./_mark.ts";
39
+ import { DEFAULT_CI_LEVEL } from "../constants.ts";
40
+
41
+ export type AggregateBinBy = "x" | "y";
42
+
43
+ export type AggregateBinSize = number | "auto" | ((info: AutoBinSizeInfo) => number);
44
+
45
+ export interface AutoBinSizeInfo {
46
+ /** Pixel span of the binned axis (positive). */
47
+ pixelSpan: number;
48
+ /** Domain span of the binned axis in axis units (positive). For time scales, ms. */
49
+ domainSpan: number;
50
+ /** `pixelSpan / domainSpan` — pixels per domain unit on the binned axis. */
51
+ pixelsPerUnit: number;
52
+ }
53
+
54
+ export type AggregateSummaryKind = "mean" | "median" | "count" | "sum" | "first" | "last";
55
+
56
+ export interface AggregateBinView<T> {
57
+ /** Values from the *non-binned* axis, in input order within this bin. */
58
+ values: readonly number[];
59
+ /** Source items inside this bin, in input order. */
60
+ items: readonly T[];
61
+ /** Count after filter. */
62
+ count: number;
63
+ /** Bin's lower edge on the binned axis (inclusive). */
64
+ lo: number;
65
+ /** Bin's upper edge on the binned axis (exclusive, except the final bin). */
66
+ hi: number;
67
+ }
68
+
69
+ export type AggregateSummary<T> = AggregateSummaryKind | ((bin: AggregateBinView<T>) => number);
70
+
71
+ /** Result of a bundle summary — one center + a [lo, hi] range per bin. */
72
+ export interface AggregateBundleResult {
73
+ center: number;
74
+ lo: number;
75
+ hi: number;
76
+ }
77
+
78
+ export type AggregateBundleSummaryKind = "mean+ci" | "median+iqr";
79
+
80
+ /**
81
+ * Bundle summaries emit `{ center, lo, hi }` per bin and pair with the
82
+ * `interval` or `ribbon` inner geom. Built-in kinds plus a quantile triple
83
+ * `{ quantiles: [loQ, centerQ, hiQ] }` (e.g. `[0.1, 0.5, 0.9]` for an 80%
84
+ * range) or a custom function returning the bundle directly.
85
+ */
86
+ export type AggregateBundleSummary<T> =
87
+ | AggregateBundleSummaryKind
88
+ | { quantiles: readonly [number, number, number] }
89
+ | ((bin: AggregateBinView<T>) => AggregateBundleResult);
90
+
91
+ export interface AggregateChannels<T> {
92
+ x: Aes<T, number | Date>;
93
+ y: Aes<T, number | Date>;
94
+ }
95
+
96
+ export type AggregateInnerGeom = "point" | "line" | "bar" | "interval" | "ribbon";
97
+
98
+ export interface AggregateOptions<T> {
99
+ /** Axis to slide along. Default `"x"`. */
100
+ binBy?: AggregateBinBy;
101
+ /** Bin width in domain units; `"auto"` targets `~autoTargetPx` px. Default `"auto"`. */
102
+ binSize?: AggregateBinSize;
103
+ /**
104
+ * Reduction applied per bin. Scalar kinds (`"mean"`, `"median"`, …) pair
105
+ * with `geom: "point" | "line" | "bar"`. Bundle kinds (`"mean+ci"`,
106
+ * `"median+iqr"`, `{ quantiles }`, function-returning-`{center, lo, hi}`)
107
+ * pair with `geom: "interval" | "ribbon"`. Default `"mean"`.
108
+ */
109
+ summary?: AggregateSummary<T> | AggregateBundleSummary<T>;
110
+ /** Row predicate. Rejected rows are excluded from both binning and the
111
+ * raw-fallback path. */
112
+ filter?: (datum: T, index: number) => boolean;
113
+ /** When `avg-points-per-bin < dissolveAt`, render raw geom instead.
114
+ * Default `1.2`. */
115
+ dissolveAt?: number;
116
+ /** Inner mark kind. Default `"point"`. */
117
+ geom?: AggregateInnerGeom;
118
+ /** Fill / stroke applied to every emitted mark. */
119
+ fill?: Color;
120
+ stroke?: Color;
121
+ strokeWidth?: number;
122
+ /** Point-specific. */
123
+ radius?: number;
124
+ shape?: PointShapeKind;
125
+ /** Line-specific. */
126
+ curve?: LineCurve;
127
+ /** Target pixels per bin for `"auto"` sizing. Default `7`. */
128
+ autoTargetPx?: number;
129
+ /** Display label for legend / hit-test (forwarded to inner mark). */
130
+ label?: string;
131
+ /**
132
+ * Confidence level for the `"mean+ci"` bundle summary. Default `0.95`.
133
+ * Ignored for other summaries.
134
+ */
135
+ ciLevel?: number;
136
+ /** Interval-specific: render perpendicular caps. Default `true`. */
137
+ caps?: boolean;
138
+ /** Interval-specific: cap length in pixels. Default `6`. */
139
+ capWidth?: number;
140
+ /** Ribbon-specific: fill alpha override (otherwise theme.marks.ribbonFillAlpha). */
141
+ fillAlpha?: number;
142
+ }
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // Implementation
146
+ // ---------------------------------------------------------------------------
147
+
148
+ interface PreparedRow<T> {
149
+ binVal: number;
150
+ otherVal: number;
151
+ item: T;
152
+ sourceIndex: number;
153
+ }
154
+
155
+ interface ResolvedBin<T> {
156
+ lo: number;
157
+ hi: number;
158
+ center: number;
159
+ count: number;
160
+ values: number[];
161
+ items: T[];
162
+ /** Scalar aggregate (`NaN` when bin is empty or summary is bundle-typed). */
163
+ value: number;
164
+ /** Bundle aggregate (`undefined` when bin is empty or summary is scalar-typed). */
165
+ bundle?: AggregateBundleResult;
166
+ }
167
+
168
+ const BUNDLE_INNER: ReadonlySet<AggregateInnerGeom> = new Set(["interval", "ribbon"]);
169
+
170
+ function isBundleSummary<T>(
171
+ summary: AggregateSummary<T> | AggregateBundleSummary<T> | undefined,
172
+ ): summary is AggregateBundleSummary<T> {
173
+ if (summary === undefined) return false;
174
+ if (typeof summary === "string") return summary === "mean+ci" || summary === "median+iqr";
175
+ if (typeof summary === "function") {
176
+ // Function form is intentionally ambiguous at the type level — callers
177
+ // must align `summary` with `geom`. The inner-geom kind is the runtime
178
+ // discriminator (see compile).
179
+ return false;
180
+ }
181
+ return Array.isArray((summary as { quantiles?: unknown }).quantiles);
182
+ }
183
+
184
+ function quantileSorted(sorted: readonly number[], q: number): number {
185
+ const n = sorted.length;
186
+ if (n === 0) return Number.NaN;
187
+ if (n === 1) return sorted[0]!;
188
+ const clamped = q < 0 ? 0 : q > 1 ? 1 : q;
189
+ const pos = clamped * (n - 1);
190
+ const lo = Math.floor(pos);
191
+ const hi = Math.ceil(pos);
192
+ if (lo === hi) return sorted[lo]!;
193
+ return sorted[lo]! * (1 - (pos - lo)) + sorted[hi]! * (pos - lo);
194
+ }
195
+
196
+ /**
197
+ * Inverse standard-normal CDF via Acklam's rational approximation. Used to
198
+ * map a CI level (e.g. 0.95) → z (≈1.96) without dragging in a stats library.
199
+ * Accurate to ~1.15e-9 over the full open interval (0, 1).
200
+ */
201
+ function inverseNormalCdf(p: number): number {
202
+ if (!(p > 0 && p < 1)) return Number.NaN;
203
+ const a = [
204
+ -3.969683028665376e1, 2.209460984245205e2, -2.759285104469687e2, 1.38357751867269e2,
205
+ -3.066479806614716e1, 2.506628277459239,
206
+ ];
207
+ const b = [
208
+ -5.447609879822406e1, 1.615858368580409e2, -1.556989798598866e2, 6.680131188771972e1,
209
+ -1.328068155288572e1,
210
+ ];
211
+ const c = [
212
+ -7.784894002430293e-3, -3.223964580411365e-1, -2.400758277161838, -2.549732539343734,
213
+ 4.374664141464968, 2.938163982698783,
214
+ ];
215
+ const d = [7.784695709041462e-3, 3.224671290700398e-1, 2.445134137142996, 3.754408661907416];
216
+ const pLow = 0.02425;
217
+ const pHigh = 1 - pLow;
218
+ if (p < pLow) {
219
+ const q = Math.sqrt(-2 * Math.log(p));
220
+ return (
221
+ (((((c[0]! * q + c[1]!) * q + c[2]!) * q + c[3]!) * q + c[4]!) * q + c[5]!) /
222
+ ((((d[0]! * q + d[1]!) * q + d[2]!) * q + d[3]!) * q + 1)
223
+ );
224
+ }
225
+ if (p <= pHigh) {
226
+ const q = p - 0.5;
227
+ const r = q * q;
228
+ return (
229
+ ((((((a[0]! * r + a[1]!) * r + a[2]!) * r + a[3]!) * r + a[4]!) * r + a[5]!) * q) /
230
+ (((((b[0]! * r + b[1]!) * r + b[2]!) * r + b[3]!) * r + b[4]!) * r + 1)
231
+ );
232
+ }
233
+ const q = Math.sqrt(-2 * Math.log(1 - p));
234
+ return -(
235
+ (((((c[0]! * q + c[1]!) * q + c[2]!) * q + c[3]!) * q + c[4]!) * q + c[5]!) /
236
+ ((((d[0]! * q + d[1]!) * q + d[2]!) * q + d[3]!) * q + 1)
237
+ );
238
+ }
239
+
240
+ function zForCiLevel(level: number): number {
241
+ return inverseNormalCdf((1 + level) / 2);
242
+ }
243
+
244
+ function reduceBundle<T>(
245
+ summary: AggregateBundleSummary<T>,
246
+ view: AggregateBinView<T>,
247
+ ciLevel: number,
248
+ ): AggregateBundleResult {
249
+ if (typeof summary === "function") return summary(view);
250
+ const values = view.values;
251
+ const n = values.length;
252
+ if (n === 0) return { center: Number.NaN, lo: Number.NaN, hi: Number.NaN };
253
+
254
+ if (summary === "mean+ci") {
255
+ let sum = 0;
256
+ for (const v of values) sum += v;
257
+ const mean = sum / n;
258
+ if (n < 2) return { center: mean, lo: mean, hi: mean };
259
+ let sse = 0;
260
+ for (const v of values) {
261
+ const d = v - mean;
262
+ sse += d * d;
263
+ }
264
+ const stderr = Math.sqrt(sse / (n - 1)) / Math.sqrt(n);
265
+ const z = zForCiLevel(ciLevel);
266
+ const half = z * stderr;
267
+ return { center: mean, lo: mean - half, hi: mean + half };
268
+ }
269
+
270
+ // Sorted copy reused by quantile-based bundles.
271
+ const sorted = values.slice().sort((a, b) => a - b);
272
+ if (summary === "median+iqr") {
273
+ return {
274
+ center: quantileSorted(sorted, 0.5),
275
+ lo: quantileSorted(sorted, 0.25),
276
+ hi: quantileSorted(sorted, 0.75),
277
+ };
278
+ }
279
+ const qs = summary.quantiles;
280
+ return {
281
+ lo: quantileSorted(sorted, qs[0]),
282
+ center: quantileSorted(sorted, qs[1]),
283
+ hi: quantileSorted(sorted, qs[2]),
284
+ };
285
+ }
286
+
287
+ function coerceNumeric(value: unknown): number {
288
+ if (value instanceof Date) return value.getTime();
289
+ return value as number;
290
+ }
291
+
292
+ function reduce<T>(summary: AggregateSummary<T>, view: AggregateBinView<T>): number {
293
+ if (typeof summary === "function") return summary(view);
294
+ const values = view.values;
295
+ const n = values.length;
296
+ if (n === 0) return Number.NaN;
297
+ switch (summary) {
298
+ case "count":
299
+ return n;
300
+ case "mean": {
301
+ let s = 0;
302
+ for (const v of values) s += v;
303
+ return s / n;
304
+ }
305
+ case "sum": {
306
+ let s = 0;
307
+ for (const v of values) s += v;
308
+ return s;
309
+ }
310
+ case "first":
311
+ return values[0]!;
312
+ case "last":
313
+ return values[n - 1]!;
314
+ case "median": {
315
+ const sorted = values.slice().sort((a, b) => a - b);
316
+ const mid = n >>> 1;
317
+ return n % 2 === 0 ? (sorted[mid - 1]! + sorted[mid]!) / 2 : sorted[mid]!;
318
+ }
319
+ }
320
+ }
321
+
322
+ function resolveAutoBinSize(
323
+ scale: import("../scales.ts").PositionScale | undefined,
324
+ targetPx: number,
325
+ ): { binSize: number; info: AutoBinSizeInfo } | null {
326
+ if (!scale) return null;
327
+ if (scale.type === "band") return null;
328
+ const axis = scale.axisScale;
329
+ // band scales lack `.domain` as a numeric pair, but we've already returned.
330
+ const domain = (axis as { domain: readonly [number | Date, number | Date] }).domain;
331
+ const range = (axis as { range: readonly [number, number] }).range;
332
+ if (!domain || !range) return null;
333
+ const d0 = coerceNumeric(domain[0]);
334
+ const d1 = coerceNumeric(domain[1]);
335
+ const domainSpan = Math.abs(d1 - d0);
336
+ const pixelSpan = Math.abs(range[1] - range[0]);
337
+ if (!(domainSpan > 0) || !(pixelSpan > 0)) return null;
338
+ const pixelsPerUnit = pixelSpan / domainSpan;
339
+ const binSize = targetPx / pixelsPerUnit;
340
+ if (!(binSize > 0) || !Number.isFinite(binSize)) return null;
341
+ return { binSize, info: { pixelSpan, domainSpan, pixelsPerUnit } };
342
+ }
343
+
344
+ function buildBins<T>(
345
+ prepared: readonly PreparedRow<T>[],
346
+ binSize: number,
347
+ summary: AggregateSummary<T> | AggregateBundleSummary<T>,
348
+ extent: readonly [number, number] | null,
349
+ bundleMode: boolean,
350
+ ciLevel: number,
351
+ ): ResolvedBin<T>[] {
352
+ if (prepared.length === 0 || !(binSize > 0)) return [];
353
+
354
+ let lo: number;
355
+ let hi: number;
356
+ if (extent) {
357
+ [lo, hi] = extent[0] <= extent[1] ? [extent[0], extent[1]] : [extent[1], extent[0]];
358
+ } else {
359
+ lo = Number.POSITIVE_INFINITY;
360
+ hi = Number.NEGATIVE_INFINITY;
361
+ for (const row of prepared) {
362
+ if (row.binVal < lo) lo = row.binVal;
363
+ if (row.binVal > hi) hi = row.binVal;
364
+ }
365
+ }
366
+ if (!Number.isFinite(lo) || !Number.isFinite(hi) || hi < lo) return [];
367
+
368
+ // Anchor bins to multiples of binSize so neighbouring frames line up when
369
+ // the user pans — moves are integer-bin shifts rather than sub-bin drifts.
370
+ const start = Math.floor(lo / binSize) * binSize;
371
+ const span = hi - start;
372
+ const count = Math.max(1, Math.ceil((span + Number.EPSILON) / binSize));
373
+ if (count > 1_000_000) return [];
374
+
375
+ const bins: ResolvedBin<T>[] = Array.from({ length: count });
376
+ for (let i = 0; i < count; i++) {
377
+ const binLo = start + i * binSize;
378
+ bins[i] = {
379
+ lo: binLo,
380
+ hi: binLo + binSize,
381
+ center: binLo + binSize / 2,
382
+ count: 0,
383
+ values: [],
384
+ items: [],
385
+ value: Number.NaN,
386
+ };
387
+ }
388
+
389
+ for (const row of prepared) {
390
+ let idx = Math.floor((row.binVal - start) / binSize);
391
+ if (idx === count) idx = count - 1;
392
+ if (idx < 0 || idx >= count) continue;
393
+ const bin = bins[idx]!;
394
+ bin.count++;
395
+ bin.values.push(row.otherVal);
396
+ bin.items.push(row.item);
397
+ }
398
+
399
+ for (const bin of bins) {
400
+ if (bin.count === 0) continue;
401
+ const view: AggregateBinView<T> = {
402
+ values: bin.values,
403
+ items: bin.items,
404
+ count: bin.count,
405
+ lo: bin.lo,
406
+ hi: bin.hi,
407
+ };
408
+ if (bundleMode) {
409
+ bin.bundle = reduceBundle(summary as AggregateBundleSummary<T>, view, ciLevel);
410
+ } else {
411
+ bin.value = reduce(summary as AggregateSummary<T>, view);
412
+ }
413
+ }
414
+ return bins;
415
+ }
416
+
417
+ // Synthetic shape used for the inner mark when the geom emits aggregated bins.
418
+ interface BinPoint {
419
+ x: number;
420
+ y: number;
421
+ count: number;
422
+ }
423
+
424
+ interface BundleBin {
425
+ x: number;
426
+ y: number;
427
+ lo: number;
428
+ hi: number;
429
+ count: number;
430
+ }
431
+
432
+ const GEOM_KIND_BY_INNER: Record<AggregateInnerGeom, import("./types.ts").GeomKind> = {
433
+ point: "point",
434
+ line: "line",
435
+ bar: "bar",
436
+ interval: "interval",
437
+ ribbon: "area",
438
+ };
439
+
440
+ export function aggregate<T>(
441
+ channels: AggregateChannels<T>,
442
+ options: AggregateOptions<T> = {},
443
+ ): Geom<T> {
444
+ const binBy: AggregateBinBy = options.binBy ?? "x";
445
+ const innerKind: AggregateInnerGeom = options.geom ?? "point";
446
+ const summaryOption = options.summary;
447
+ const bundleInner = BUNDLE_INNER.has(innerKind);
448
+ const summaryIsBundle = isBundleSummary(summaryOption);
449
+ // Function-form summaries are ambiguous at the type level — disambiguate by
450
+ // the inner geom: bundle inners always run the bundle path; scalar inners
451
+ // always run the scalar path. Built-in kinds are validated up front.
452
+ const bundleMode = bundleInner || summaryIsBundle;
453
+
454
+ if (summaryIsBundle && !bundleInner) {
455
+ throw new Error(
456
+ `aggregate(): bundle summary requires geom: "interval" | "ribbon" (got "${innerKind}").`,
457
+ );
458
+ }
459
+ if (
460
+ bundleInner &&
461
+ summaryOption !== undefined &&
462
+ !summaryIsBundle &&
463
+ typeof summaryOption !== "function"
464
+ ) {
465
+ throw new Error(
466
+ `aggregate(): geom: "${innerKind}" requires a bundle summary ("mean+ci", "median+iqr", { quantiles }, or a function returning {center, lo, hi}).`,
467
+ );
468
+ }
469
+ if (innerKind === "ribbon" && binBy !== "x") {
470
+ throw new Error(
471
+ 'aggregate(): geom: "ribbon" only supports binBy: "x" (horizontal ribbons aren\'t supported yet).',
472
+ );
473
+ }
474
+
475
+ const summary: AggregateSummary<T> | AggregateBundleSummary<T> =
476
+ summaryOption ?? (bundleInner ? "mean+ci" : "mean");
477
+ const dissolveAt = options.dissolveAt ?? 1.2;
478
+ const autoTargetPx = options.autoTargetPx ?? 7;
479
+ const ciLevel = options.ciLevel ?? DEFAULT_CI_LEVEL;
480
+
481
+ return {
482
+ kind: GEOM_KIND_BY_INNER[innerKind],
483
+ channels: { x: channels.x as unknown, y: channels.y as unknown },
484
+ label: options.label,
485
+ compile(ctx: CompileContext<T>): readonly MarkBuilder[] {
486
+ const { data, scales, plot, theme } = ctx;
487
+ if (data.length === 0) return [];
488
+
489
+ const xAes = resolveAes<T, unknown>(channels.x as Aes<T, unknown>);
490
+ const yAes = resolveAes<T, unknown>(channels.y as Aes<T, unknown>);
491
+ const xScale = scales.x.fn;
492
+ const yScale = scales.y.fn;
493
+
494
+ // Filter once. Used for both binning and the raw-dissolve path so the
495
+ // two views stay consistent.
496
+ const accepted: { item: T; index: number }[] = [];
497
+ const filter = options.filter;
498
+ for (let i = 0; i < data.length; i++) {
499
+ const d = data[i]!;
500
+ if (filter && !filter(d, i)) continue;
501
+ accepted.push({ item: d, index: i });
502
+ }
503
+ if (accepted.length === 0) return [];
504
+
505
+ // Resolve bin size against the binned-axis scale.
506
+ const binnedScale = binBy === "x" ? scales.x : scales.y;
507
+ let binSize: number;
508
+ if (typeof options.binSize === "number") {
509
+ binSize = options.binSize;
510
+ } else if (typeof options.binSize === "function") {
511
+ const auto = resolveAutoBinSize(binnedScale, autoTargetPx);
512
+ if (!auto) return [];
513
+ binSize = options.binSize(auto.info);
514
+ } else {
515
+ const auto = resolveAutoBinSize(binnedScale, autoTargetPx);
516
+ if (!auto) return [];
517
+ binSize = auto.binSize;
518
+ }
519
+ if (!(binSize > 0) || !Number.isFinite(binSize)) return [];
520
+
521
+ // Numeric-coerce the source rows and split into (bin, other) per axis.
522
+ const prepared: PreparedRow<T>[] = [];
523
+ for (const { item, index } of accepted) {
524
+ const xv = coerceNumeric(xAes.fn(item, index));
525
+ const yv = coerceNumeric(yAes.fn(item, index));
526
+ if (!Number.isFinite(xv) || !Number.isFinite(yv)) continue;
527
+ const binVal = binBy === "x" ? xv : yv;
528
+ const otherVal = binBy === "x" ? yv : xv;
529
+ prepared.push({ binVal, otherVal, item, sourceIndex: index });
530
+ }
531
+ if (prepared.length === 0) return [];
532
+
533
+ // Bin extent follows the active visible domain so zoom-in shrinks both
534
+ // the bin span and the points considered — that's what makes the
535
+ // dissolve threshold flip naturally as the user zooms.
536
+ const extent = visibleDomain(binnedScale);
537
+ const bins = buildBins(prepared, binSize, summary, extent, bundleMode, ciLevel);
538
+ const nonEmpty = bins.filter((b) => b.count > 0);
539
+ // Count points in the bin extent so dissolve / raw-fallback both see
540
+ // the same view of the data.
541
+ let pointsInExtent = 0;
542
+ if (bins.length > 0) {
543
+ const extLo = bins[0]!.lo;
544
+ const extHi = bins[bins.length - 1]!.hi;
545
+ for (const row of prepared) {
546
+ if (row.binVal >= extLo && row.binVal <= extHi) pointsInExtent++;
547
+ }
548
+ }
549
+ // Dissolve check: divide total points in the extent by the *total* bin
550
+ // count. At deep zoom-in the bin extent shrinks proportionally with the
551
+ // pixel-anchored bin size, but the in-extent point count shrinks faster
552
+ // — so density drops, and we fall back to the raw geom.
553
+ const avgPerBin = bins.length > 0 ? pointsInExtent / bins.length : 0;
554
+ // Bundle mode has no sensible "raw" fallback (interval/ribbon need a
555
+ // [lo, hi] per row, and raw rows don't carry one) — skip dissolve and
556
+ // always aggregate.
557
+ const dissolve = !bundleMode && avgPerBin < dissolveAt;
558
+
559
+ if (dissolve) {
560
+ // Forward the filtered rows to the inner mark with the original
561
+ // channels. We re-derive the items array (preserving input order) so
562
+ // line/bar accessors see contiguous indices.
563
+ const rawItems = accepted.map((a) => a.item);
564
+ return [wrapMark(buildRawMark(innerKind, rawItems, channels, options, ctx), plot.topLeft)];
565
+ }
566
+
567
+ // Bundle path: delegate to interval / ribbon with synthetic bin rows
568
+ // anchored at bin centers. Per-row state (hidden/selected/hovered) is
569
+ // cleared since bin-level selection isn't a concept at the aggregate
570
+ // layer.
571
+ if (bundleInner) {
572
+ const bundleBins: BundleBin[] = Array.from({ length: nonEmpty.length });
573
+ for (let i = 0; i < nonEmpty.length; i++) {
574
+ const bin = nonEmpty[i]!;
575
+ const b = bin.bundle!;
576
+ if (binBy === "x") {
577
+ bundleBins[i] = { x: bin.center, y: b.center, lo: b.lo, hi: b.hi, count: bin.count };
578
+ } else {
579
+ bundleBins[i] = { x: b.center, y: bin.center, lo: b.lo, hi: b.hi, count: bin.count };
580
+ }
581
+ }
582
+ const subCtx: CompileContext<BundleBin> = {
583
+ ...(ctx as unknown as CompileContext<BundleBin>),
584
+ data: bundleBins,
585
+ hidden: undefined,
586
+ selected: undefined,
587
+ hovered: null,
588
+ activeTransition: undefined,
589
+ };
590
+ if (innerKind === "interval") {
591
+ const innerGeom =
592
+ binBy === "x"
593
+ ? interval<BundleBin>(
594
+ {
595
+ x: (b) => b.x,
596
+ yMin: (b) => b.lo,
597
+ yMax: (b) => b.hi,
598
+ },
599
+ {
600
+ stroke: options.stroke,
601
+ strokeWidth: options.strokeWidth,
602
+ caps: options.caps,
603
+ capWidth: options.capWidth,
604
+ label: options.label,
605
+ },
606
+ )
607
+ : interval<BundleBin>(
608
+ {
609
+ y: (b) => b.y,
610
+ xMin: (b) => b.lo,
611
+ xMax: (b) => b.hi,
612
+ },
613
+ {
614
+ stroke: options.stroke,
615
+ strokeWidth: options.strokeWidth,
616
+ caps: options.caps,
617
+ capWidth: options.capWidth,
618
+ label: options.label,
619
+ },
620
+ );
621
+ return innerGeom.compile(subCtx);
622
+ }
623
+ // ribbon (binBy === "x" enforced at factory time)
624
+ const ribbonGeom = ribbon<BundleBin>(
625
+ {
626
+ x: (b) => b.x,
627
+ y0: (b) => b.lo,
628
+ y1: (b) => b.hi,
629
+ },
630
+ {
631
+ fill: options.fill,
632
+ stroke: options.stroke,
633
+ strokeWidth: options.strokeWidth,
634
+ fillAlpha: options.fillAlpha,
635
+ label: options.label,
636
+ },
637
+ );
638
+ return ribbonGeom.compile(subCtx);
639
+ }
640
+
641
+ // Build synthetic bin-points; only emit bins with data.
642
+ const fill = options.fill ?? defaultMarkFill(theme);
643
+ const stroke = options.stroke;
644
+ const strokeWidth = options.strokeWidth;
645
+
646
+ const points: BinPoint[] = Array.from({ length: nonEmpty.length });
647
+ for (let i = 0; i < nonEmpty.length; i++) {
648
+ const bin = nonEmpty[i]!;
649
+ if (binBy === "x") {
650
+ points[i] = { x: bin.center, y: bin.value, count: bin.count };
651
+ } else {
652
+ points[i] = { x: bin.value, y: bin.center, count: bin.count };
653
+ }
654
+ }
655
+
656
+ const builders: MarkBuilder[] = [];
657
+ if (innerKind === "point") {
658
+ const baseRadius = options.radius ?? theme.marks.pointRadius;
659
+ const shape = options.shape ?? "circle";
660
+ const pStroke = stroke === undefined ? (theme.marks.pointStroke ?? undefined) : stroke;
661
+ const pStrokeWidth = strokeWidth ?? theme.marks.pointStrokeWidth;
662
+ const mark = pointMark(points, {
663
+ x: (p) => xScale(p.x),
664
+ y: (p) => yScale(p.y),
665
+ radius: baseRadius,
666
+ fill: alphaize(fill, theme.marks.fillAlpha),
667
+ stroke: pStroke,
668
+ strokeWidth: pStrokeWidth,
669
+ shape,
670
+ });
671
+ builders.push(wrapMark(mark, plot.topLeft, points.length));
672
+ } else if (innerKind === "line") {
673
+ const lineStroke = strokeWidth ?? theme.marks.strokeWidth;
674
+ const lineColor = stroke ?? options.fill ?? theme.palettes.categorical(0);
675
+ // Sort by the binned axis so the polyline draws monotonically. Bins
676
+ // are already in axis order from `buildBins` (sequential index over
677
+ // `start + i * binSize`), so no extra sort is needed.
678
+ const mark = lineMark(points, {
679
+ x: (p) => xScale(p.x),
680
+ y: (p) => yScale(p.y),
681
+ stroke: lineColor,
682
+ strokeWidth: lineStroke,
683
+ curve: options.curve ?? "linear",
684
+ });
685
+ builders.push(wrapMark(mark, plot.topLeft, points.length));
686
+ } else {
687
+ // bar — bar extends one bin in pixel space along the binned axis,
688
+ // and from the value-axis baseline to the aggregated value on the
689
+ // other axis. The baseline is `scale(0)` when 0 is in the domain,
690
+ // else the scale's range floor (so log / off-zero axes don't draw
691
+ // from a clamped `scale(0)` pixel). See `baselinePixel`.
692
+ const pixelBinWidth = pixelsForBin(binnedScale, binSize);
693
+ const barThickness = Math.max(1, pixelBinWidth - 1);
694
+ const xZero = baselinePixel(scales.x, xScale as (v: number) => number);
695
+ const yZero = baselinePixel(scales.y, yScale as (v: number) => number);
696
+ const mark = barMark(points, {
697
+ x: (p) =>
698
+ binBy === "x" ? xScale(p.x) - pixelBinWidth / 2 : Math.min(xScale(p.x), xZero),
699
+ y: (p) =>
700
+ binBy === "x" ? Math.min(yScale(p.y), yZero) : yScale(p.y) - pixelBinWidth / 2,
701
+ width: (p) => (binBy === "x" ? barThickness : Math.abs(xScale(p.x) - xZero)),
702
+ height: (p) => (binBy === "x" ? Math.abs(yScale(p.y) - yZero) : barThickness),
703
+ fill: alphaize(fill, theme.marks.fillAlpha),
704
+ stroke,
705
+ strokeWidth,
706
+ });
707
+ builders.push(wrapMark(mark, plot.topLeft, points.length));
708
+ }
709
+ return builders;
710
+ },
711
+ };
712
+ }
713
+
714
+ function visibleDomain(
715
+ scale: import("../scales.ts").PositionScale | undefined,
716
+ ): readonly [number, number] | null {
717
+ if (!scale || scale.type === "band") return null;
718
+ const axis = scale.axisScale as { domain: readonly [number | Date, number | Date] };
719
+ const d = axis.domain;
720
+ if (!d) return null;
721
+ const lo = coerceNumeric(d[0]);
722
+ const hi = coerceNumeric(d[1]);
723
+ if (!Number.isFinite(lo) || !Number.isFinite(hi)) return null;
724
+ return [lo, hi];
725
+ }
726
+
727
+ /**
728
+ * Pixel coordinate of the bar baseline on a value axis.
729
+ *
730
+ * When `0` lies inside the scale's domain we project it directly — that's the
731
+ * correct, conventional baseline for a linear axis spanning zero (bars hang
732
+ * up *and* down from the zero line). When `0` is outside the domain (e.g. a log
733
+ * axis, whose domain never includes 0, or any axis panned off zero),
734
+ * `scale(0)` extrapolates to a clamped/garbage pixel, so bars would draw from
735
+ * the wrong place. In that case we fall back to the *range endpoint paired with
736
+ * the domain extreme nearest zero* — i.e. the visual floor of the bars.
737
+ *
738
+ * Reasoning about which range endpoint is the floor: a scale maps
739
+ * `domain[0] → range[0]` and `domain[1] → range[1]`. The bars' floor is the
740
+ * smallest-magnitude data value's pixel, which on a same-sign axis is whichever
741
+ * domain endpoint is nearest zero. We pick the range endpoint that pairs with
742
+ * it — this is orientation-agnostic and works for inverted ranges (the typical
743
+ * `y` range is `[height, 0]`) and for both `x` and `y` axes.
744
+ *
745
+ * Falls back to `scale(0)` if the axis can't be introspected (band scales, or
746
+ * scales missing domain/range).
747
+ */
748
+ function baselinePixel(
749
+ scale: import("../scales.ts").PositionScale | undefined,
750
+ scaleFn: (value: number) => number,
751
+ ): number {
752
+ if (!scale || scale.type === "band") return scaleFn(0);
753
+ const axis = scale.axisScale as {
754
+ domain?: readonly [number | Date, number | Date];
755
+ range?: readonly [number, number];
756
+ };
757
+ const domain = axis.domain;
758
+ const range = axis.range;
759
+ if (!domain || !range) return scaleFn(0);
760
+ const d0 = coerceNumeric(domain[0]);
761
+ const d1 = coerceNumeric(domain[1]);
762
+ if (!Number.isFinite(d0) || !Number.isFinite(d1)) return scaleFn(0);
763
+ const lo = Math.min(d0, d1);
764
+ const hi = Math.max(d0, d1);
765
+ // 0 inside [lo, hi] → existing correct behavior.
766
+ if (lo <= 0 && 0 <= hi) return scaleFn(0);
767
+ // 0 outside the domain → use the range endpoint paired with the domain
768
+ // extreme nearest zero (the smallest-magnitude value = the bars' floor).
769
+ const nearestZeroIsD0 = Math.abs(d0) <= Math.abs(d1);
770
+ const px = nearestZeroIsD0 ? range[0] : range[1];
771
+ return Number.isFinite(px) ? px : scaleFn(0);
772
+ }
773
+
774
+ function pixelsForBin(
775
+ scale: import("../scales.ts").PositionScale | undefined,
776
+ binSize: number,
777
+ ): number {
778
+ if (!scale || scale.type === "band") return 0;
779
+ const axis = scale.axisScale as {
780
+ domain: readonly [number | Date, number | Date];
781
+ range: readonly [number, number];
782
+ };
783
+ const d0 = coerceNumeric(axis.domain[0]);
784
+ const d1 = coerceNumeric(axis.domain[1]);
785
+ const domainSpan = Math.abs(d1 - d0);
786
+ const pixelSpan = Math.abs(axis.range[1] - axis.range[0]);
787
+ if (!(domainSpan > 0) || !(pixelSpan > 0)) return 0;
788
+ return (binSize / domainSpan) * pixelSpan;
789
+ }
790
+
791
+ function buildRawMark<T>(
792
+ kind: AggregateInnerGeom,
793
+ rows: readonly T[],
794
+ channels: AggregateChannels<T>,
795
+ options: AggregateOptions<T>,
796
+ ctx: CompileContext<T>,
797
+ ): MarkBuilder {
798
+ const { scales, theme } = ctx;
799
+ const xAes = resolveAes<T, unknown>(channels.x as Aes<T, unknown>);
800
+ const yAes = resolveAes<T, unknown>(channels.y as Aes<T, unknown>);
801
+ const xScale = scales.x.fn;
802
+ const yScale = scales.y.fn;
803
+ const fill = options.fill ?? defaultMarkFill(theme);
804
+
805
+ if (kind === "point") {
806
+ return pointMark(rows, {
807
+ x: (d, i) => xScale(xAes.fn(d, i)),
808
+ y: (d, i) => yScale(yAes.fn(d, i)),
809
+ radius: options.radius ?? theme.marks.pointRadius,
810
+ fill: alphaize(fill, theme.marks.fillAlpha),
811
+ stroke: options.stroke ?? theme.marks.pointStroke ?? undefined,
812
+ strokeWidth: options.strokeWidth ?? theme.marks.pointStrokeWidth,
813
+ shape: options.shape ?? "circle",
814
+ });
815
+ }
816
+ if (kind === "line") {
817
+ return lineMark(rows, {
818
+ x: (d, i) => xScale(xAes.fn(d, i)),
819
+ y: (d, i) => yScale(yAes.fn(d, i)),
820
+ stroke: options.stroke ?? options.fill ?? theme.palettes.categorical(0),
821
+ strokeWidth: options.strokeWidth ?? theme.marks.strokeWidth,
822
+ curve: options.curve ?? "linear",
823
+ });
824
+ }
825
+ // bar fallback: 1px tick at each row's position. Bar-as-raw isn't a common
826
+ // request (the dissolve case for bars usually means showing the source
827
+ // points), but we keep the path consistent. Baseline matches the aggregate
828
+ // path: `yScale(0)` when 0 is in the domain, else the y range floor (so log
829
+ // / off-zero axes don't draw from a clamped `yScale(0)` pixel).
830
+ const yBase = baselinePixel(scales.y, yScale as (v: number) => number);
831
+ return barMark(rows, {
832
+ x: (d, i) => xScale(xAes.fn(d, i)) - 0.5,
833
+ y: (d, i) => Math.min(yScale(yAes.fn(d, i)), yBase),
834
+ width: 1,
835
+ height: (d, i) => Math.abs(yScale(yAes.fn(d, i)) - yBase),
836
+ fill: alphaize(fill, theme.marks.fillAlpha),
837
+ stroke: options.stroke,
838
+ strokeWidth: options.strokeWidth,
839
+ });
840
+ }