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,740 @@
1
+ export {
2
+ confidenceBand,
3
+ linearFit,
4
+ loessFit,
5
+ polyFit,
6
+ type ConfidenceBandOptions,
7
+ type ConfidenceBandPoint,
8
+ type LoessOptions,
9
+ type RegressionFit,
10
+ } from "./regression.ts";
11
+
12
+ export {
13
+ rollingWindow,
14
+ type RollingAxis,
15
+ type RollingPoint,
16
+ type RollingStatistic,
17
+ type RollingStatisticKind,
18
+ type RollingWindow,
19
+ type RollingWindowOptions,
20
+ } from "./rolling-window.ts";
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Distribution statistics
24
+ // ---------------------------------------------------------------------------
25
+ // Pure helpers for box-plot / violin geoms. No DOM or GPU deps. Exported as a
26
+ // public surface (`insomni-plot/core`) so users can compute stats once and
27
+ // pass results into a custom mark, or just sanity-check geom output.
28
+
29
+ export type QuantileMethod = "type-7" | "type-1";
30
+
31
+ /**
32
+ * Quantile of an already-sorted ascending numeric array.
33
+ *
34
+ * - `"type-7"` (default): R's default — linear interpolation between order
35
+ * stats with `h = (n - 1) * p`. The quantile method most users expect.
36
+ * - `"type-1"`: inverse-of-empirical-CDF (step function). No interpolation.
37
+ *
38
+ * Returns `NaN` for empty input. Clamps `p` to `[0, 1]`.
39
+ */
40
+ export function quantile(
41
+ sorted: readonly number[],
42
+ p: number,
43
+ method: QuantileMethod = "type-7",
44
+ ): number {
45
+ const n = sorted.length;
46
+ if (n === 0) return Number.NaN;
47
+ if (n === 1) return sorted[0]!;
48
+ const q = p <= 0 ? 0 : p >= 1 ? 1 : p;
49
+
50
+ if (method === "type-1") {
51
+ const h = q * n;
52
+ const idx = Math.min(n - 1, Math.max(0, Math.ceil(h) - 1));
53
+ return sorted[idx]!;
54
+ }
55
+
56
+ // Type-7: linear interpolation
57
+ const h = (n - 1) * q;
58
+ const lo = Math.floor(h);
59
+ const hi = Math.ceil(h);
60
+ if (lo === hi) return sorted[lo]!;
61
+ const frac = h - lo;
62
+ return sorted[lo]! * (1 - frac) + sorted[hi]! * frac;
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Box stats
67
+ // ---------------------------------------------------------------------------
68
+
69
+ export type WhiskerRule = number | "minmax";
70
+
71
+ export interface BoxStats {
72
+ /** Sample size (after filtering non-finite). */
73
+ n: number;
74
+ /** Smallest value in the sample. */
75
+ min: number;
76
+ /** Lower whisker endpoint (clamped to data range). */
77
+ lowerWhisker: number;
78
+ q1: number;
79
+ median: number;
80
+ q3: number;
81
+ /** Upper whisker endpoint (clamped to data range). */
82
+ upperWhisker: number;
83
+ /** Largest value in the sample. */
84
+ max: number;
85
+ /** Q3 − Q1. */
86
+ iqr: number;
87
+ /** Values strictly outside the whiskers. Order preserves input order. */
88
+ outliers: number[];
89
+ /** Mean of the sample. Useful as an annotation; not used for box geometry. */
90
+ mean: number;
91
+ }
92
+
93
+ export interface BoxStatsOptions {
94
+ /**
95
+ * Whisker rule.
96
+ *
97
+ * - A number `k` (default `1.5`) — Tukey-style: whiskers extend to the most
98
+ * extreme value within `k * IQR` of Q1/Q3. Anything beyond is an outlier.
99
+ * - `"minmax"` — whiskers extend to the data min/max; no outliers.
100
+ */
101
+ whisker?: WhiskerRule;
102
+ quantile?: QuantileMethod;
103
+ }
104
+
105
+ /**
106
+ * Compute Tukey-style box statistics. Filters non-finite inputs.
107
+ *
108
+ * Returns `null` for empty input — caller decides how to render `n=0`.
109
+ * For `n=1`, every quartile equals the single value and `outliers` is empty.
110
+ */
111
+ export function boxStats(
112
+ values: readonly number[],
113
+ options: BoxStatsOptions = {},
114
+ ): BoxStats | null {
115
+ const finite: number[] = [];
116
+ for (const v of values) {
117
+ if (Number.isFinite(v)) finite.push(v);
118
+ }
119
+ if (finite.length === 0) return null;
120
+
121
+ const sorted = finite.slice().sort((a, b) => a - b);
122
+ const n = sorted.length;
123
+ const method = options.quantile ?? "type-7";
124
+ const q1 = quantile(sorted, 0.25, method);
125
+ const median = quantile(sorted, 0.5, method);
126
+ const q3 = quantile(sorted, 0.75, method);
127
+ const iqr = q3 - q1;
128
+ const min = sorted[0]!;
129
+ const max = sorted[n - 1]!;
130
+
131
+ const rule = options.whisker ?? 1.5;
132
+ let lowerWhisker: number;
133
+ let upperWhisker: number;
134
+ let outliers: number[] = [];
135
+
136
+ if (rule === "minmax") {
137
+ lowerWhisker = min;
138
+ upperWhisker = max;
139
+ } else {
140
+ const k = rule;
141
+ const lowerFence = q1 - k * iqr;
142
+ const upperFence = q3 + k * iqr;
143
+ lowerWhisker = min;
144
+ upperWhisker = max;
145
+ for (const v of sorted) {
146
+ if (v >= lowerFence) {
147
+ lowerWhisker = v;
148
+ break;
149
+ }
150
+ }
151
+ for (let i = sorted.length - 1; i >= 0; i--) {
152
+ const v = sorted[i]!;
153
+ if (v <= upperFence) {
154
+ upperWhisker = v;
155
+ break;
156
+ }
157
+ }
158
+ // Preserve original (input) order so e.g. labels can recover the index.
159
+ for (const v of finite) {
160
+ if (v < lowerFence || v > upperFence) outliers.push(v);
161
+ }
162
+ }
163
+
164
+ let sum = 0;
165
+ for (const v of finite) sum += v;
166
+
167
+ return {
168
+ n,
169
+ min,
170
+ lowerWhisker,
171
+ q1,
172
+ median,
173
+ q3,
174
+ upperWhisker,
175
+ max,
176
+ iqr,
177
+ outliers,
178
+ mean: sum / n,
179
+ };
180
+ }
181
+
182
+ // ---------------------------------------------------------------------------
183
+ // Kernel density estimation
184
+ // ---------------------------------------------------------------------------
185
+
186
+ export type KdeKernel = "gaussian" | "epanechnikov";
187
+ export type KdeBandwidth = number | "silverman" | "scott";
188
+
189
+ export interface KdeOptions {
190
+ /** Number of evaluation points. Default `64`, capped to `512`. */
191
+ gridSize?: number;
192
+ /**
193
+ * Either a positive number (fixed bandwidth in data units) or a rule:
194
+ * - `"silverman"` (default) — `1.06 · scale · n^(-1/5)` with the robust
195
+ * scale `min(σ, IQR/1.34)`. Less sensitive to heavy tails / skew.
196
+ * - `"scott"` — `1.06 · σ · n^(-1/5)` using the plain standard deviation
197
+ * (no IQR robustification). Wider on skewed/heavy-tailed samples.
198
+ */
199
+ bandwidth?: KdeBandwidth;
200
+ kernel?: KdeKernel;
201
+ /**
202
+ * Evaluate over `[min, max]` of the sample (`true`, default) or pad outward
203
+ * by ~3 bandwidths so density tails reach near-zero (`false`).
204
+ */
205
+ trim?: boolean;
206
+ /** Override the evaluation domain. Bypasses `trim`. */
207
+ domain?: readonly [number, number];
208
+ }
209
+
210
+ export interface KdeResult {
211
+ /** Evaluation grid (length `gridSize`). */
212
+ x: number[];
213
+ /** Density at each grid point (length `gridSize`). */
214
+ y: number[];
215
+ /** Resolved bandwidth in data units. */
216
+ bandwidth: number;
217
+ }
218
+
219
+ const SQRT_2PI = Math.sqrt(2 * Math.PI);
220
+
221
+ function stddev(values: readonly number[]): number {
222
+ const n = values.length;
223
+ if (n < 2) return 0;
224
+ let sum = 0;
225
+ for (const v of values) sum += v;
226
+ const mean = sum / n;
227
+ let sq = 0;
228
+ for (const v of values) {
229
+ const d = v - mean;
230
+ sq += d * d;
231
+ }
232
+ return Math.sqrt(sq / (n - 1));
233
+ }
234
+
235
+ function silverman(values: readonly number[]): number {
236
+ const n = values.length;
237
+ if (n < 2) return 0;
238
+ const sorted = values.slice().sort((a, b) => a - b);
239
+ const sd = stddev(sorted);
240
+ const q1 = quantile(sorted, 0.25);
241
+ const q3 = quantile(sorted, 0.75);
242
+ const iqr = q3 - q1;
243
+ // Robust scale per Silverman: min(σ, IQR/1.34).
244
+ const scale = iqr > 0 ? Math.min(sd, iqr / 1.34) : sd;
245
+ if (scale === 0) return 0;
246
+ return 1.06 * scale * Math.pow(n, -1 / 5);
247
+ }
248
+
249
+ function scott(values: readonly number[]): number {
250
+ const n = values.length;
251
+ if (n < 2) return 0;
252
+ // Scott's rule: plain standard deviation, no IQR robustification.
253
+ const sd = stddev(values);
254
+ if (sd === 0) return 0;
255
+ return 1.06 * sd * Math.pow(n, -1 / 5);
256
+ }
257
+
258
+ /**
259
+ * Kernel density estimate.
260
+ *
261
+ * Returns `null` for empty input. For all-equal samples falls back to a tiny
262
+ * bandwidth so callers don't divide by zero — the result is a narrow spike.
263
+ */
264
+ export function kde(values: readonly number[], options: KdeOptions = {}): KdeResult | null {
265
+ const finite: number[] = [];
266
+ for (const v of values) {
267
+ if (Number.isFinite(v)) finite.push(v);
268
+ }
269
+ if (finite.length === 0) return null;
270
+
271
+ const gridSize = Math.max(2, Math.min(512, options.gridSize ?? 64));
272
+ const kernel: KdeKernel = options.kernel ?? "gaussian";
273
+ const trim = options.trim ?? true;
274
+
275
+ let bw: number;
276
+ const bwOpt = options.bandwidth ?? "silverman";
277
+ if (typeof bwOpt === "number") {
278
+ bw = bwOpt > 0 ? bwOpt : 1e-9;
279
+ } else {
280
+ bw = bwOpt === "scott" ? scott(finite) : silverman(finite);
281
+ if (bw <= 0) bw = 1e-9;
282
+ }
283
+
284
+ let lo: number;
285
+ let hi: number;
286
+ if (options.domain) {
287
+ [lo, hi] = options.domain;
288
+ } else {
289
+ lo = Number.POSITIVE_INFINITY;
290
+ hi = Number.NEGATIVE_INFINITY;
291
+ for (const v of finite) {
292
+ if (v < lo) lo = v;
293
+ if (v > hi) hi = v;
294
+ }
295
+ if (lo === hi) {
296
+ lo -= bw;
297
+ hi += bw;
298
+ }
299
+ if (!trim) {
300
+ lo -= 3 * bw;
301
+ hi += 3 * bw;
302
+ }
303
+ }
304
+
305
+ const x = Array.from<number>({ length: gridSize });
306
+ const y = Array.from<number>({ length: gridSize });
307
+ const step = (hi - lo) / (gridSize - 1);
308
+ const n = finite.length;
309
+ const invNbw = 1 / (n * bw);
310
+
311
+ if (kernel === "gaussian") {
312
+ const norm = invNbw / SQRT_2PI;
313
+ for (let i = 0; i < gridSize; i++) {
314
+ const xi = lo + step * i;
315
+ x[i] = xi;
316
+ let acc = 0;
317
+ for (let j = 0; j < n; j++) {
318
+ const u = (xi - finite[j]!) / bw;
319
+ acc += Math.exp(-0.5 * u * u);
320
+ }
321
+ y[i] = acc * norm;
322
+ }
323
+ } else {
324
+ // Epanechnikov: K(u) = 3/4 (1 - u²) on |u| ≤ 1.
325
+ const norm = invNbw * 0.75;
326
+ for (let i = 0; i < gridSize; i++) {
327
+ const xi = lo + step * i;
328
+ x[i] = xi;
329
+ let acc = 0;
330
+ for (let j = 0; j < n; j++) {
331
+ const u = (xi - finite[j]!) / bw;
332
+ if (u >= -1 && u <= 1) acc += 1 - u * u;
333
+ }
334
+ y[i] = acc * norm;
335
+ }
336
+ }
337
+
338
+ return { x, y, bandwidth: bw };
339
+ }
340
+
341
+ // ---------------------------------------------------------------------------
342
+ // Helpers
343
+ // ---------------------------------------------------------------------------
344
+
345
+ /**
346
+ * Group an iterable into a `Map` keyed by `key(item)`. Insertion order
347
+ * preserved per key; the map's key order is the order of first occurrence.
348
+ */
349
+ export function groupBy<T, K>(items: Iterable<T>, key: (item: T, index: number) => K): Map<K, T[]> {
350
+ const out = new Map<K, T[]>();
351
+ let i = 0;
352
+ for (const item of items) {
353
+ const k = key(item, i++);
354
+ let bucket = out.get(k);
355
+ if (!bucket) {
356
+ bucket = [];
357
+ out.set(k, bucket);
358
+ }
359
+ bucket.push(item);
360
+ }
361
+ return out;
362
+ }
363
+
364
+ // ---------------------------------------------------------------------------
365
+ // Binning (histogram support)
366
+ // ---------------------------------------------------------------------------
367
+
368
+ export type BinRule = "sturges" | "scott" | "fd" | "rice";
369
+ export type BinClosed = "left" | "right";
370
+
371
+ export interface BinBreaksOptions {
372
+ /** Explicit bin count. Ignored if `binwidth` or `breaks` is set. */
373
+ bins?: number;
374
+ /** Explicit bin width in data units. Beats `bins` and `rule`. */
375
+ binwidth?: number;
376
+ /** Fully explicit edge array — bypasses rules entirely. */
377
+ breaks?: readonly number[];
378
+ /**
379
+ * Rule used to pick a bin count when neither `bins`, `binwidth`, nor
380
+ * `breaks` is provided.
381
+ *
382
+ * - `"sturges"` (default) — `⌈log₂ n⌉ + 1`. R / ggplot default.
383
+ * - `"rice"` — `⌈2 · n^(1/3)⌉`. Slightly higher resolution.
384
+ * - `"scott"` — `3.49 σ n^(-1/3)` width. Good for roughly normal data.
385
+ * - `"fd"` — Freedman-Diaconis `2 · IQR · n^(-1/3)` width. Robust on skew.
386
+ */
387
+ rule?: BinRule;
388
+ /** Clip edges to this. Default = `[min, max]` of data. */
389
+ domain?: readonly [number, number];
390
+ /** Round outer edges + step to nice numbers. Default `true`. */
391
+ nice?: boolean;
392
+ }
393
+
394
+ export interface BinResult {
395
+ /** Inclusive lower edge for `closed: "left"` (the default). */
396
+ x0: number;
397
+ /** Exclusive upper edge for `closed: "left"`, except the final bin. */
398
+ x1: number;
399
+ /** Number of input values that fell in this bin. */
400
+ count: number;
401
+ /** `count / (n · width)`. Sums to 1 across all bins (∑ density · width). */
402
+ density: number;
403
+ }
404
+
405
+ /**
406
+ * How a histogram bin's `count` is converted into a y-axis value:
407
+ * - `"count"` / `"frequency"`: raw `count`
408
+ * - `"density"`: `count / (n · width)` — area sums to 1
409
+ * - `"proportion"`: `count / n` — values sum to 1
410
+ */
411
+ export type HistogramMeasure = "count" | "density" | "proportion" | "frequency";
412
+
413
+ /**
414
+ * Convert a `BinResult` to its measured value under `measure`. `n` is the
415
+ * total sample count for the histogram (or per-group total for stacked /
416
+ * faceted cases) — used to normalise `density` and `proportion`.
417
+ */
418
+ export function histogramMeasureValue(
419
+ bin: BinResult,
420
+ n: number,
421
+ measure: HistogramMeasure,
422
+ ): number {
423
+ if (measure === "count" || measure === "frequency") return bin.count;
424
+ if (measure === "density") {
425
+ const w = bin.x1 - bin.x0;
426
+ return w > 0 && n > 0 ? bin.count / (n * w) : 0;
427
+ }
428
+ return n > 0 ? bin.count / n : 0;
429
+ }
430
+
431
+ export interface BinOptions extends BinBreaksOptions {
432
+ /**
433
+ * Edge-closure convention. With `"left"` (default) bins are `[x0, x1)` and
434
+ * the final bin includes the max as `[x_{k-1}, x_k]`. With `"right"` the
435
+ * first bin is `[x_0, x_1]` and the rest are `(x_i, x_{i+1}]`.
436
+ */
437
+ closed?: BinClosed;
438
+ }
439
+
440
+ const MIN_BINS = 1;
441
+ const MAX_BINS = 1000;
442
+
443
+ function clampBinCount(count: number): number {
444
+ if (!Number.isFinite(count) || count < MIN_BINS) return MIN_BINS;
445
+ if (count > MAX_BINS) return MAX_BINS;
446
+ return Math.floor(count);
447
+ }
448
+
449
+ function ruleBinCount(values: readonly number[], rule: BinRule, domain: [number, number]): number {
450
+ const n = values.length;
451
+ if (n < 2) return 1;
452
+ if (rule === "sturges") return clampBinCount(Math.ceil(Math.log2(n)) + 1);
453
+ if (rule === "rice") return clampBinCount(Math.ceil(2 * Math.pow(n, 1 / 3)));
454
+
455
+ const span = domain[1] - domain[0];
456
+ if (span <= 0) return 1;
457
+
458
+ if (rule === "scott") {
459
+ const sd = stddev(values);
460
+ if (sd <= 0) return 1;
461
+ const w = (3.49 * sd) / Math.cbrt(n);
462
+ return clampBinCount(Math.ceil(span / w));
463
+ }
464
+
465
+ // fd
466
+ const sorted = values.slice().sort((a, b) => a - b);
467
+ const q1 = quantile(sorted, 0.25);
468
+ const q3 = quantile(sorted, 0.75);
469
+ const iqr = q3 - q1;
470
+ const w = iqr > 0 ? (2 * iqr) / Math.cbrt(n) : (3.49 * stddev(values)) / Math.cbrt(n);
471
+ if (!(w > 0)) return 1;
472
+ return clampBinCount(Math.ceil(span / w));
473
+ }
474
+
475
+ function dataExtent(values: readonly number[]): [number, number] | null {
476
+ let lo = Number.POSITIVE_INFINITY;
477
+ let hi = Number.NEGATIVE_INFINITY;
478
+ for (const v of values) {
479
+ if (!Number.isFinite(v)) continue;
480
+ if (v < lo) lo = v;
481
+ if (v > hi) hi = v;
482
+ }
483
+ if (!Number.isFinite(lo) || !Number.isFinite(hi)) return null;
484
+ return [lo, hi];
485
+ }
486
+
487
+ function niceStep(span: number, count: number): number {
488
+ if (span <= 0 || count <= 0) return 1;
489
+ const step0 = span / count;
490
+ const power = Math.floor(Math.log10(step0));
491
+ const power10 = 10 ** power;
492
+ const error = step0 / power10;
493
+ let factor = 1;
494
+ if (error >= Math.sqrt(50)) factor = 10;
495
+ else if (error >= Math.sqrt(10)) factor = 5;
496
+ else if (error >= Math.sqrt(2)) factor = 2;
497
+ return factor * power10;
498
+ }
499
+
500
+ function buildEvenBreaks(lo: number, hi: number, count: number): number[] {
501
+ const out = Array.from<number>({ length: count + 1 });
502
+ const step = (hi - lo) / count;
503
+ for (let i = 0; i <= count; i++) out[i] = lo + step * i;
504
+ // Pin endpoints exactly to avoid float drift at the upper edge.
505
+ out[0] = lo;
506
+ out[count] = hi;
507
+ return out;
508
+ }
509
+
510
+ function buildNiceBreaks(lo: number, hi: number, count: number): number[] {
511
+ if (lo === hi) return [lo - 0.5, hi + 0.5];
512
+ const step = niceStep(hi - lo, count);
513
+ if (step <= 0) return buildEvenBreaks(lo, hi, count);
514
+ const start = Math.floor(lo / step) * step;
515
+ const end = Math.ceil(hi / step) * step;
516
+ const n = Math.max(1, Math.round((end - start) / step));
517
+ const out = Array.from<number>({ length: n + 1 });
518
+ for (let i = 0; i <= n; i++) out[i] = start + step * i;
519
+ return out;
520
+ }
521
+
522
+ /**
523
+ * Compute bin edges for the given data. Returns an array of length
524
+ * `binCount + 1` describing the boundaries of each bin from left to right.
525
+ *
526
+ * Resolution order (highest precedence first): explicit `breaks` → `binwidth`
527
+ * → `bins` → `rule` (default `"sturges"`).
528
+ *
529
+ * Returns `[]` for empty / all-non-finite input.
530
+ */
531
+ export function binBreaks(values: readonly number[], options: BinBreaksOptions = {}): number[] {
532
+ if (options.breaks && options.breaks.length >= 2) {
533
+ return [...options.breaks];
534
+ }
535
+
536
+ const finite: number[] = [];
537
+ for (const v of values) {
538
+ if (Number.isFinite(v)) finite.push(v);
539
+ }
540
+ if (finite.length === 0) return [];
541
+
542
+ const dataDomain = dataExtent(finite)!;
543
+ const domain: [number, number] = options.domain
544
+ ? [options.domain[0], options.domain[1]]
545
+ : dataDomain;
546
+ let [lo, hi] = domain;
547
+ if (lo === hi) {
548
+ // Degenerate domain — produce a single bin around the value.
549
+ return [lo - 0.5, hi + 0.5];
550
+ }
551
+ if (lo > hi) [lo, hi] = [hi, lo];
552
+
553
+ const nice = options.nice ?? true;
554
+
555
+ if (typeof options.binwidth === "number" && options.binwidth > 0) {
556
+ const w = options.binwidth;
557
+ const start = nice ? Math.floor(lo / w) * w : lo;
558
+ const end = nice ? Math.ceil(hi / w) * w : hi;
559
+ const span = end - start;
560
+ const n = clampBinCount(Math.max(1, Math.round(span / w)));
561
+ const out = Array.from<number>({ length: n + 1 });
562
+ for (let i = 0; i <= n; i++) out[i] = start + w * i;
563
+ return out;
564
+ }
565
+
566
+ let count: number;
567
+ if (typeof options.bins === "number" && options.bins > 0) {
568
+ count = clampBinCount(options.bins);
569
+ } else {
570
+ const rule = options.rule ?? "sturges";
571
+ count = ruleBinCount(finite, rule, [lo, hi]);
572
+ }
573
+
574
+ return nice ? buildNiceBreaks(lo, hi, count) : buildEvenBreaks(lo, hi, count);
575
+ }
576
+
577
+ /**
578
+ * Bin a numeric sample into a sorted array of `BinResult`. Filters non-finite.
579
+ *
580
+ * Bins are half-open `[x0, x1)` by default (`closed: "left"`); the last bin
581
+ * is closed on both ends so the maximum value is counted. With
582
+ * `closed: "right"` the first bin is closed on both ends and the rest are
583
+ * `(x0, x1]`.
584
+ *
585
+ * Values strictly outside the resolved domain are dropped.
586
+ */
587
+ export function bin(values: readonly number[], options: BinOptions = {}): BinResult[] {
588
+ const finite: number[] = [];
589
+ for (const v of values) {
590
+ if (Number.isFinite(v)) finite.push(v);
591
+ }
592
+ const breaks = binBreaks(finite, options);
593
+ return assignBins(finite, breaks, options.closed ?? "left");
594
+ }
595
+
596
+ /**
597
+ * Assign already-resolved values into known bin edges. Useful when the caller
598
+ * has computed `breaks` once across multiple groups (so per-group histograms
599
+ * align horizontally).
600
+ */
601
+ export function binWithBreaks(
602
+ values: readonly number[],
603
+ breaks: readonly number[],
604
+ closed: BinClosed = "left",
605
+ ): BinResult[] {
606
+ const finite: number[] = [];
607
+ for (const v of values) {
608
+ if (Number.isFinite(v)) finite.push(v);
609
+ }
610
+ return assignBins(finite, breaks, closed);
611
+ }
612
+
613
+ function assignBins(
614
+ finite: readonly number[],
615
+ breaks: readonly number[],
616
+ closed: BinClosed,
617
+ ): BinResult[] {
618
+ if (breaks.length < 2) return [];
619
+ const k = breaks.length - 1;
620
+ const counts = Array.from({ length: k }, () => 0);
621
+ const lo = breaks[0]!;
622
+ const hi = breaks[k]!;
623
+ const n = finite.length;
624
+
625
+ for (let i = 0; i < n; i++) {
626
+ const v = finite[i]!;
627
+ if (v < lo || v > hi) continue;
628
+ const idx = locateBin(v, breaks, closed);
629
+ if (idx >= 0) counts[idx]!++;
630
+ }
631
+
632
+ let totalIn = 0;
633
+ for (const c of counts) totalIn += c;
634
+
635
+ const out: BinResult[] = Array.from({ length: k });
636
+ for (let i = 0; i < k; i++) {
637
+ const x0 = breaks[i]!;
638
+ const x1 = breaks[i + 1]!;
639
+ const w = x1 - x0;
640
+ const c = counts[i]!;
641
+ const density = totalIn > 0 && w > 0 ? c / (totalIn * w) : 0;
642
+ out[i] = { x0, x1, count: c, density };
643
+ }
644
+ return out;
645
+ }
646
+
647
+ /**
648
+ * Find the bin index for `v`. Binary search; returns -1 if out of range.
649
+ * Encodes the half-open / closed-end convention.
650
+ */
651
+ function locateBin(v: number, breaks: readonly number[], closed: BinClosed): number {
652
+ const k = breaks.length - 1;
653
+ // Boundary handling for the closed-on-both-ends bin (last for "left",
654
+ // first for "right").
655
+ if (closed === "left") {
656
+ if (v === breaks[k]!) return k - 1;
657
+ } else {
658
+ if (v === breaks[0]!) return 0;
659
+ }
660
+ // Binary search for the rightmost edge <= v (left) or < v (right).
661
+ let lo = 0;
662
+ let hi = k;
663
+ while (lo < hi) {
664
+ const mid = (lo + hi) >>> 1;
665
+ const edge = breaks[mid + 1]!;
666
+ const goRight = closed === "left" ? v >= edge : v > edge;
667
+ if (goRight) lo = mid + 1;
668
+ else hi = mid;
669
+ }
670
+ return lo < k ? lo : -1;
671
+ }
672
+
673
+ export interface BinByOptions extends BinOptions {
674
+ /**
675
+ * Use one shared edge array for every group (default `true`) so per-group
676
+ * histograms align horizontally for stack/dodge/overlay. With `false`, each
677
+ * group computes its own breaks against its own data.
678
+ */
679
+ sharedBreaks?: boolean;
680
+ }
681
+
682
+ export interface BinByResult<K> {
683
+ breaks: number[];
684
+ groups: Map<K, BinResult[]>;
685
+ }
686
+
687
+ /**
688
+ * Bin a flat numeric array partitioned by key. Group iteration order matches
689
+ * first-occurrence order in the input.
690
+ */
691
+ export function binBy<K>(
692
+ values: readonly number[],
693
+ keys: readonly K[],
694
+ options: BinByOptions = {},
695
+ ): BinByResult<K> {
696
+ const groups = new Map<K, number[]>();
697
+ for (let i = 0; i < values.length; i++) {
698
+ const v = values[i]!;
699
+ if (!Number.isFinite(v)) continue;
700
+ const k = keys[i] as K;
701
+ let bucket = groups.get(k);
702
+ if (!bucket) {
703
+ bucket = [];
704
+ groups.set(k, bucket);
705
+ }
706
+ bucket.push(v);
707
+ }
708
+
709
+ const shared = options.sharedBreaks ?? true;
710
+ const closed = options.closed ?? "left";
711
+ const breaks = shared ? binBreaks(values, options) : [];
712
+
713
+ const out = new Map<K, BinResult[]>();
714
+ for (const [k, bucket] of groups) {
715
+ const groupBreaks = shared ? breaks : binBreaks(bucket, options);
716
+ out.set(k, assignBins(bucket, groupBreaks, closed));
717
+ }
718
+
719
+ return { breaks, groups: out };
720
+ }
721
+
722
+ /**
723
+ * Deterministic uniform jitter offsets in `[-width/2, +width/2]`. Same
724
+ * `(seed, count)` pair always produces the same sequence — important so that
725
+ * jittered overlay points don't dance between renders.
726
+ */
727
+ export function jitter(seed: number, count: number, width: number): number[] {
728
+ // Mulberry32 — small, fast, good enough for visual jitter.
729
+ let state = seed | 0 || 1;
730
+ const out = Array.from<number>({ length: count });
731
+ for (let i = 0; i < count; i++) {
732
+ state = (state + 0x6d2b79f5) | 0;
733
+ let t = state;
734
+ t = Math.imul(t ^ (t >>> 15), t | 1);
735
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
736
+ const r = ((t ^ (t >>> 14)) >>> 0) / 4294967296;
737
+ out[i] = (r - 0.5) * width;
738
+ }
739
+ return out;
740
+ }