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,54 @@
1
+ import type { Color } from "insomni";
2
+ import { type LabelBoxStyle } from "../../annotations.ts";
3
+ import type { Aes } from "../aes.ts";
4
+ import type { Geom } from "./types.ts";
5
+ export type SmoothMethod = "lm" | "poly" | "loess";
6
+ export interface SmoothChannels<T> {
7
+ x: Aes<T, number | Date>;
8
+ y: Aes<T, number>;
9
+ /** Categorical color channel — fits one curve per group. */
10
+ color?: Aes<T, unknown>;
11
+ }
12
+ export interface SmoothLabelOptions {
13
+ /** Pixel offset from the right end of the fit. */
14
+ offsetX?: number;
15
+ offsetY?: number;
16
+ /** Custom label color. Defaults to the curve color. */
17
+ color?: Color;
18
+ fontSize?: number;
19
+ /** Optional rounded-rect background. */
20
+ box?: LabelBoxStyle;
21
+ }
22
+ export interface SmoothOptions {
23
+ /** Fit method. Default `"lm"`. */
24
+ method?: SmoothMethod;
25
+ /** Degree for `"poly"` (default `2`) or `"loess"` local polynomial (default `1`). */
26
+ degree?: number;
27
+ /** Span for `"loess"` (fraction of data per neighborhood). Default `0.5`. */
28
+ span?: number;
29
+ /**
30
+ * Confidence ribbon. `true` (default) → 95%, `false` → no ribbon, `number`
31
+ * → that confidence level (e.g. `0.99`).
32
+ */
33
+ ci?: boolean | number;
34
+ /** Number of x samples for the curve & ribbon. Default `64`. */
35
+ samples?: number;
36
+ stroke?: Color;
37
+ strokeWidth?: number;
38
+ /** Override the ribbon fill. Defaults to the stroke color at low alpha. */
39
+ ribbonFill?: Color;
40
+ ribbonOpacity?: number;
41
+ /**
42
+ * Per-group inline label at the right end of each fitted curve. Only emitted
43
+ * when the `color` channel is present. `true` uses the group key as the
44
+ * label string.
45
+ */
46
+ label?: boolean | SmoothLabelOptions;
47
+ /**
48
+ * When true, hit-tests resolve to the nearest sample by x. Hover anywhere
49
+ * along the curve and the closest source row in the matching group is
50
+ * picked. Default `false` (Euclidean within `pickRadius`).
51
+ */
52
+ nearestX?: boolean;
53
+ }
54
+ export declare function smooth<T>(channels: SmoothChannels<T>, options?: SmoothOptions): Geom<T>;
@@ -0,0 +1,78 @@
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 { smooth } from "./smooth.ts";
8
+ import type { CompileContext } from "./types.ts";
9
+
10
+ interface Row {
11
+ x: number;
12
+ y: number;
13
+ series: string;
14
+ }
15
+
16
+ // Linear data — easy to reason about with method "lm".
17
+ const data: Row[] = [
18
+ { x: 0, y: 0, series: "a" },
19
+ { x: 1, y: 2, series: "a" },
20
+ { x: 2, y: 4, series: "a" },
21
+ { x: 3, y: 6, series: "a" },
22
+ ];
23
+
24
+ function makeCtx(rows: readonly Row[]): CompileContext<Row> {
25
+ const xAes = resolveAes<Row, unknown>("x");
26
+ const yAes = resolveAes<Row, unknown>("y");
27
+ const xScale = buildPositionScale(xAes, rows, [0, 100]);
28
+ const yScale = buildPositionScale(yAes, rows, [200, 0]);
29
+ const scales: ScaleBundle = { x: xScale, y: yScale };
30
+ return {
31
+ data: rows,
32
+ scales,
33
+ plot: createFrame({ x: 0, y: 0, width: 100, height: 200 }),
34
+ theme: themeDefault,
35
+ atlas: undefined,
36
+ };
37
+ }
38
+
39
+ describe("smooth geom — compileHitTest", () => {
40
+ test("emits one hit per source row at the predicted curve y", () => {
41
+ const geom = smooth<Row>({ x: "x", y: "y" }, { method: "lm", ci: false });
42
+ const hits = geom.compileHitTest!(makeCtx(data))!;
43
+ expect(hits.geomKind).toBe("smooth");
44
+ expect(hits.dataIndex.length).toBe(4);
45
+ // Linear fit on (0,0)..(3,6) → y = 2x. Row 0: x=0 → predicted 0.
46
+ // x=0 maps to range start 0; y=0 maps to range start 200.
47
+ expect(hits.positions[0]).toBeCloseTo(0, 3);
48
+ expect(hits.positions[1]).toBeCloseTo(200, 3);
49
+ });
50
+
51
+ test("color channel: emits per-row hits with seriesKey set", () => {
52
+ const mixed: Row[] = [
53
+ { x: 0, y: 0, series: "a" },
54
+ { x: 1, y: 2, series: "a" },
55
+ { x: 0, y: 1, series: "b" },
56
+ { x: 1, y: 3, series: "b" },
57
+ ];
58
+ const geom = smooth<Row>({ x: "x", y: "y", color: "series" }, { method: "lm", ci: false });
59
+ const hits = geom.compileHitTest!(makeCtx(mixed))!;
60
+ expect(hits.dataIndex.length).toBe(4);
61
+ expect(hits.seriesKey).toBeDefined();
62
+ const keys = new Set(hits.seriesKey);
63
+ expect(keys.has("a")).toBe(true);
64
+ expect(keys.has("b")).toBe(true);
65
+ });
66
+
67
+ test("nearestX option sets pickAxis 'x'", () => {
68
+ const geom = smooth<Row>({ x: "x", y: "y" }, { method: "lm", ci: false, nearestX: true });
69
+ const hits = geom.compileHitTest!(makeCtx(data))!;
70
+ expect(hits.pickAxis).toBe("x");
71
+ expect(hits.pickRadius).toBeGreaterThanOrEqual(100);
72
+ });
73
+
74
+ test("returns null for empty data", () => {
75
+ const geom = smooth<Row>({ x: "x", y: "y" });
76
+ expect(geom.compileHitTest!(makeCtx([]))).toBeNull();
77
+ });
78
+ });
@@ -0,0 +1,337 @@
1
+ // ---------------------------------------------------------------------------
2
+ // smooth geom — fitted regression curve with optional confidence ribbon
3
+ // ---------------------------------------------------------------------------
4
+ // Wraps `linearFit` / `polyFit` / `loessFit` so the chart can render a
5
+ // smoothed reference curve straight from a continuous x/y dataset. Optional
6
+ // 95% (or arbitrary level) confidence ribbon and per-group inline labels
7
+ // when the `color` channel splits the data into series.
8
+
9
+ import type { Color } from "insomni";
10
+ import { areaMark, lineMark } from "../../marks.ts";
11
+ import { wrapMark } from "./_mark.ts";
12
+ import { valueLabelMark, type LabelBoxStyle } from "../../annotations.ts";
13
+ import {
14
+ confidenceBand,
15
+ linearFit,
16
+ loessFit,
17
+ polyFit,
18
+ type RegressionFit,
19
+ } from "../../stats/regression.ts";
20
+ import { groupBy } from "../../stats/index.ts";
21
+ import { withAlpha } from "../color-utils.ts";
22
+ import type { Aes } from "../aes.ts";
23
+ import { dropNullCategoricalIndices, materialize, resolveAes } from "../aes.ts";
24
+ import type { CompileContext, CompiledHitTest, Geom, ResolvedChannelMap } from "./types.ts";
25
+ import { DEFAULT_CI_LEVEL } from "../constants.ts";
26
+
27
+ export type SmoothMethod = "lm" | "poly" | "loess";
28
+
29
+ export interface SmoothChannels<T> {
30
+ x: Aes<T, number | Date>;
31
+ y: Aes<T, number>;
32
+ /** Categorical color channel — fits one curve per group. */
33
+ color?: Aes<T, unknown>;
34
+ }
35
+
36
+ export interface SmoothLabelOptions {
37
+ /** Pixel offset from the right end of the fit. */
38
+ offsetX?: number;
39
+ offsetY?: number;
40
+ /** Custom label color. Defaults to the curve color. */
41
+ color?: Color;
42
+ fontSize?: number;
43
+ /** Optional rounded-rect background. */
44
+ box?: LabelBoxStyle;
45
+ }
46
+
47
+ export interface SmoothOptions {
48
+ /** Fit method. Default `"lm"`. */
49
+ method?: SmoothMethod;
50
+ /** Degree for `"poly"` (default `2`) or `"loess"` local polynomial (default `1`). */
51
+ degree?: number;
52
+ /** Span for `"loess"` (fraction of data per neighborhood). Default `0.5`. */
53
+ span?: number;
54
+ /**
55
+ * Confidence ribbon. `true` (default) → 95%, `false` → no ribbon, `number`
56
+ * → that confidence level (e.g. `0.99`).
57
+ */
58
+ ci?: boolean | number;
59
+ /** Number of x samples for the curve & ribbon. Default `64`. */
60
+ samples?: number;
61
+ stroke?: Color;
62
+ strokeWidth?: number;
63
+ /** Override the ribbon fill. Defaults to the stroke color at low alpha. */
64
+ ribbonFill?: Color;
65
+ ribbonOpacity?: number;
66
+ /**
67
+ * Per-group inline label at the right end of each fitted curve. Only emitted
68
+ * when the `color` channel is present. `true` uses the group key as the
69
+ * label string.
70
+ */
71
+ label?: boolean | SmoothLabelOptions;
72
+ /**
73
+ * When true, hit-tests resolve to the nearest sample by x. Hover anywhere
74
+ * along the curve and the closest source row in the matching group is
75
+ * picked. Default `false` (Euclidean within `pickRadius`).
76
+ */
77
+ nearestX?: boolean;
78
+ }
79
+
80
+ interface NumericPair {
81
+ xs: number[];
82
+ ys: number[];
83
+ }
84
+
85
+ function toNumeric<T>(
86
+ data: readonly T[],
87
+ xAes: { fn: (d: T, i: number) => unknown },
88
+ yAes: { fn: (d: T, i: number) => number },
89
+ rows: readonly number[],
90
+ ): NumericPair {
91
+ const xs: number[] = [];
92
+ const ys: number[] = [];
93
+ for (const idx of rows) {
94
+ const datum = data[idx]!;
95
+ const xv = xAes.fn(datum, idx);
96
+ const yv = yAes.fn(datum, idx);
97
+ const x = xv instanceof Date ? xv.getTime() : (xv as number);
98
+ if (Number.isFinite(x) && Number.isFinite(yv)) {
99
+ xs.push(x);
100
+ ys.push(yv);
101
+ }
102
+ }
103
+ return { xs, ys };
104
+ }
105
+
106
+ function buildFit(
107
+ method: SmoothMethod,
108
+ sample: NumericPair,
109
+ options: SmoothOptions,
110
+ ): RegressionFit | null {
111
+ if (method === "lm") return linearFit(sample.xs, sample.ys);
112
+ if (method === "poly") return polyFit(sample.xs, sample.ys, options.degree ?? 2);
113
+ return loessFit(sample.xs, sample.ys, {
114
+ span: options.span,
115
+ degree: (options.degree === 2 ? 2 : 1) as 1 | 2,
116
+ });
117
+ }
118
+
119
+ export function smooth<T>(channels: SmoothChannels<T>, options: SmoothOptions = {}): Geom<T> {
120
+ return {
121
+ kind: "smooth",
122
+ channels,
123
+ compile(ctx: CompileContext<T>) {
124
+ return compileSmooth(channels, options, ctx);
125
+ },
126
+ compileHitTest(ctx: CompileContext<T>): CompiledHitTest<T> | null {
127
+ const { data, scales, plot } = ctx;
128
+ if (data.length === 0) return null;
129
+ const xAes = resolveAes<T, unknown>(channels.x as Aes<T, unknown>);
130
+ const yAes = resolveAes<T, number>(channels.y);
131
+ const colorAes = channels.color ? resolveAes<T, unknown>(channels.color) : undefined;
132
+
133
+ const xScale = scales.x.fn;
134
+ const yScale = scales.y.fn;
135
+
136
+ const groups = buildSmoothGroups(data, colorAes);
137
+
138
+ // Upper bound: one hit per source row.
139
+ const positions = new Float32Array(data.length * 2);
140
+ const dataIndex = new Int32Array(data.length);
141
+ const seriesKey: (string | undefined)[] = Array.from({ length: data.length });
142
+ const ox = plot.topLeft.x;
143
+ const oy = plot.topLeft.y;
144
+ let n = 0;
145
+
146
+ for (const group of groups) {
147
+ const sample = toNumeric(data, xAes, yAes, group.rows);
148
+ const fit = buildFit(options.method ?? "lm", sample, options);
149
+ if (!fit) continue;
150
+ const groupSeriesKey = colorAes ? String(group.key) : undefined;
151
+ for (const idx of group.rows) {
152
+ const datum = data[idx]!;
153
+ const xv = xAes.fn(datum, idx);
154
+ const xNum = xv instanceof Date ? xv.getTime() : (xv as number);
155
+ if (!Number.isFinite(xNum)) continue;
156
+ const yhat = fit.predict(xNum);
157
+ const px = xScale(xv as never);
158
+ const py = yScale(yhat);
159
+ if (!Number.isFinite(px) || !Number.isFinite(py)) continue;
160
+ positions[n * 2] = ox + px;
161
+ positions[n * 2 + 1] = oy + py;
162
+ dataIndex[n] = idx;
163
+ seriesKey[n] = groupSeriesKey;
164
+ n++;
165
+ }
166
+ }
167
+ if (n === 0) return null;
168
+
169
+ const channelsMap: ResolvedChannelMap<T> = {
170
+ x: xAes,
171
+ y: yAes as ResolvedChannelMap<T>["y"],
172
+ color: colorAes,
173
+ };
174
+ const strokeWidth = options.strokeWidth ?? ctx.theme.marks.strokeWidth;
175
+ const pickRadius = options.nearestX
176
+ ? Math.max(plot.width, plot.height)
177
+ : Math.max(8, strokeWidth * 2 + 4);
178
+ return {
179
+ geomKind: "smooth",
180
+ positions: positions.subarray(0, n * 2),
181
+ dataIndex: dataIndex.subarray(0, n),
182
+ seriesKey: seriesKey.slice(0, n),
183
+ pickRadius,
184
+ pickAxis: options.nearestX ? "x" : undefined,
185
+ channels: channelsMap,
186
+ data,
187
+ };
188
+ },
189
+ };
190
+ }
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // Helpers shared by compile + compileHitTest
194
+ // ---------------------------------------------------------------------------
195
+
196
+ interface SmoothGroup {
197
+ key: unknown;
198
+ rows: number[];
199
+ }
200
+
201
+ function buildSmoothGroups<T>(
202
+ data: readonly T[],
203
+ colorAes: ReturnType<typeof resolveAes<T, unknown>> | undefined,
204
+ ): SmoothGroup[] {
205
+ const indices = Array.from(data, (_d, i) => i);
206
+ const groups: SmoothGroup[] = [];
207
+ if (colorAes) {
208
+ // Null categorical → drop row (see "Null policy" in aes.ts). Keeps the
209
+ // rendered group set aligned with the categorical color scale's domain.
210
+ const valid = dropNullCategoricalIndices(indices, colorAes, data);
211
+ const colorValues = materialize(colorAes, data);
212
+ for (const [key, rows] of groupBy(valid, (i) => colorValues[i])) {
213
+ groups.push({ key, rows });
214
+ }
215
+ } else {
216
+ groups.push({ key: undefined, rows: indices });
217
+ }
218
+ return groups;
219
+ }
220
+
221
+ function compileSmooth<T>(
222
+ channels: SmoothChannels<T>,
223
+ options: SmoothOptions,
224
+ ctx: CompileContext<T>,
225
+ ) {
226
+ const { data, scales, plot, theme, atlas } = ctx;
227
+ const xAes = resolveAes<T, unknown>(channels.x as Aes<T, unknown>);
228
+ const yAes = resolveAes<T, number>(channels.y);
229
+ const colorAes = channels.color ? resolveAes<T, unknown>(channels.color) : undefined;
230
+
231
+ const xScale = scales.x.fn;
232
+ const yScale = scales.y.fn;
233
+ const colorScale = scales.color?.fn;
234
+
235
+ const method = options.method ?? "lm";
236
+ const samples = Math.max(2, options.samples ?? 64);
237
+ const ciOpt = options.ci ?? true;
238
+ const drawCI = ciOpt !== false;
239
+ const ciLevel = typeof ciOpt === "number" ? ciOpt : DEFAULT_CI_LEVEL;
240
+ const baseStroke: Color = options.stroke ?? theme.palettes.categorical(0);
241
+ const strokeWidth = options.strokeWidth ?? theme.marks.strokeWidth;
242
+ const ribbonOpacity = options.ribbonOpacity ?? theme.marks.ribbonFillAlpha;
243
+
244
+ // Bucket row indices by color group; single bucket when no color channel.
245
+ const indices = Array.from(data, (_d, i) => i);
246
+ const groups: { key: unknown; rows: number[] }[] = [];
247
+ if (colorAes) {
248
+ const colorValues = materialize(colorAes, data);
249
+ for (const [key, rows] of groupBy(indices, (i) => colorValues[i])) {
250
+ groups.push({ key, rows });
251
+ }
252
+ } else {
253
+ groups.push({ key: undefined, rows: indices });
254
+ }
255
+
256
+ const labelEnabled =
257
+ options.label !== undefined && options.label !== false && colorAes !== undefined;
258
+ const labelOpts: SmoothLabelOptions = typeof options.label === "object" ? options.label : {};
259
+ const labelOffsetX = labelOpts.offsetX ?? -4;
260
+ const labelOffsetY = labelOpts.offsetY ?? -10;
261
+ const labelFontSize = labelOpts.fontSize ?? theme.marks.annotationFontSize;
262
+
263
+ const builders: ReturnType<typeof wrapMark>[] = [];
264
+
265
+ for (const group of groups) {
266
+ if (ctx.hidden && colorAes && ctx.hidden.has(String(group.key))) continue;
267
+ const sample = toNumeric(data, xAes, yAes, group.rows);
268
+ const fit = buildFit(method, sample, options);
269
+ if (!fit) continue;
270
+
271
+ const groupColor = colorScale && colorAes ? colorScale(group.key) : baseStroke;
272
+ const ribbonFill =
273
+ options.ribbonFill ?? withAlpha(groupColor, (groupColor.a ?? 1) * ribbonOpacity);
274
+
275
+ const xMin = sample.xs.length > 0 ? Math.min(...sample.xs) : 0;
276
+ const xMax = sample.xs.length > 0 ? Math.max(...sample.xs) : 1;
277
+ const band = drawCI
278
+ ? confidenceBand(fit, sample.xs, {
279
+ samples,
280
+ level: ciLevel,
281
+ domain: [xMin, xMax],
282
+ })
283
+ : null;
284
+
285
+ // Build the curve point series first — used for the line and (when no
286
+ // CI) standalone. Re-derive from `band` if available so points line up.
287
+ const curve = band
288
+ ? band.map((p) => ({ x: p.x, yhat: p.yhat }))
289
+ : (() => {
290
+ const out: { x: number; yhat: number }[] = Array.from({ length: samples });
291
+ const step = (xMax - xMin) / (samples - 1);
292
+ for (let i = 0; i < samples; i++) {
293
+ const x = xMin + step * i;
294
+ out[i] = { x, yhat: fit.predict(x) };
295
+ }
296
+ return out;
297
+ })();
298
+
299
+ if (band) {
300
+ const ribbonPoints = band.map((p) => p);
301
+ const ribbon = areaMark(ribbonPoints, {
302
+ x: (p) => xScale(p.x),
303
+ y0: (p) => yScale(p.lo),
304
+ y1: (p) => yScale(p.hi),
305
+ fill: ribbonFill,
306
+ });
307
+ builders.push(wrapMark(ribbon, plot.topLeft, ribbonPoints.length));
308
+ }
309
+
310
+ const line = lineMark(curve, {
311
+ x: (p) => xScale(p.x),
312
+ y: (p) => yScale(p.yhat),
313
+ stroke: groupColor,
314
+ strokeWidth,
315
+ });
316
+ builders.push(wrapMark(line, plot.topLeft, curve.length));
317
+
318
+ if (labelEnabled && atlas) {
319
+ const last = curve[curve.length - 1]!;
320
+ const labelText = String(group.key);
321
+ const labelMark = valueLabelMark([last], {
322
+ x: (p) => xScale(p.x),
323
+ y: (p) => yScale(p.yhat),
324
+ text: () => labelText,
325
+ fontSize: labelFontSize,
326
+ color: labelOpts.color ?? groupColor,
327
+ align: "right",
328
+ offset: { x: labelOffsetX, y: labelOffsetY },
329
+ box: labelOpts.box,
330
+ atlas,
331
+ });
332
+ builders.push(wrapMark(labelMark, plot.topLeft, 1));
333
+ }
334
+ }
335
+
336
+ return builders;
337
+ }
@@ -0,0 +1,29 @@
1
+ import type { Color } from "insomni";
2
+ import { type LabelBoxStyle, type ValueLabelAlign } from "../../annotations.ts";
3
+ import type { Aes } from "../aes.ts";
4
+ import type { Geom } from "./types.ts";
5
+ export type TextCollisionMode = "none" | "hide" | "stagger";
6
+ export interface TextChannels<T> {
7
+ x: Aes<T, number | Date | string>;
8
+ y: Aes<T, number | Date | string>;
9
+ text: Aes<T, string>;
10
+ }
11
+ export interface TextOptions {
12
+ fontSize?: number;
13
+ color?: Color;
14
+ align?: ValueLabelAlign;
15
+ offsetX?: number;
16
+ offsetY?: number;
17
+ /** Optional rounded-rect background. Requires the chart's glyph atlas. */
18
+ box?: LabelBoxStyle;
19
+ label?: string;
20
+ /**
21
+ * Post-layout collision handling. Default `"none"`. `"hide"` drops
22
+ * overlapping labels via a greedy first-fit pass; `"stagger"` adds an
23
+ * alternating y offset so adjacent labels sit on two rows.
24
+ */
25
+ collisionMode?: TextCollisionMode;
26
+ /** Pixel padding around each label's measured rect when checking overlap. Default `2`. */
27
+ collisionPadding?: number;
28
+ }
29
+ export declare function text<T>(channels: TextChannels<T>, options?: TextOptions): Geom<T>;
@@ -0,0 +1,64 @@
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 { text } from "./text.ts";
8
+ import type { CompileContext } from "./types.ts";
9
+
10
+ interface Row {
11
+ x: number;
12
+ y: number;
13
+ label: string;
14
+ }
15
+
16
+ const data: Row[] = [
17
+ { x: 0, y: 10, label: "alpha" },
18
+ { x: 50, y: 20, label: "beta" },
19
+ { x: 100, y: 30, label: "gamma" },
20
+ ];
21
+
22
+ function makeCtx(rows: readonly Row[]): CompileContext<Row> {
23
+ const xAes = resolveAes<Row, unknown>("x");
24
+ const yAes = resolveAes<Row, unknown>("y");
25
+ const xScale = buildPositionScale(xAes, rows, [0, 100]);
26
+ const yScale = buildPositionScale(yAes, rows, [200, 0]);
27
+ const scales: ScaleBundle = { x: xScale, y: yScale };
28
+ return {
29
+ data: rows,
30
+ scales,
31
+ plot: createFrame({ x: 50, y: 30, width: 100, height: 200 }),
32
+ theme: themeDefault,
33
+ atlas: undefined,
34
+ };
35
+ }
36
+
37
+ describe("text geom — compileHitTest", () => {
38
+ test("emits one hit per row at scaled (x, y) absolute coords", () => {
39
+ const geom = text<Row>({ x: "x", y: "y", text: "label" });
40
+ const hits = geom.compileHitTest!(makeCtx(data))!;
41
+ expect(hits.geomKind).toBe("text");
42
+ expect(hits.dataIndex.length).toBe(3);
43
+ // First row: x=0 → range start 0, +plot.x(50). y=10 → domain [10,30], range [200,0],
44
+ // 10 → 200. +plot.y(30) = 230.
45
+ expect(hits.positions[0]).toBeCloseTo(50, 3);
46
+ expect(hits.positions[1]).toBeCloseTo(230, 3);
47
+ expect(hits.dataIndex[0]).toBe(0);
48
+ expect(hits.dataIndex[2]).toBe(2);
49
+ });
50
+
51
+ test("text content is exposed via channels for tooltip rows", () => {
52
+ const geom = text<Row>({ x: "x", y: "y", text: "label" });
53
+ const hits = geom.compileHitTest!(makeCtx(data))!;
54
+ const shapeAes = hits.channels.shape;
55
+ expect(shapeAes).toBeDefined();
56
+ expect(shapeAes!.column).toBe("text");
57
+ expect(shapeAes!.fn(data[1]!, 1)).toBe("beta");
58
+ });
59
+
60
+ test("returns null on empty data", () => {
61
+ const geom = text<Row>({ x: "x", y: "y", text: "label" });
62
+ expect(geom.compileHitTest!(makeCtx([]))).toBeNull();
63
+ });
64
+ });