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,43 @@
1
+ import type { Color } from "insomni";
2
+ import { type LineCurve, type LineDashStyle } from "../../marks.ts";
3
+ import type { RollingAxis, RollingStatistic, RollingWindow } from "../../stats/rolling-window.ts";
4
+ import type { Aes } from "../aes.ts";
5
+ import type { Geom } from "./types.ts";
6
+ export interface StatRollingChannels<T> {
7
+ x: Aes<T, number | Date>;
8
+ y: Aes<T, number>;
9
+ /** Categorical color channel — runs one rolling fit per group. */
10
+ color?: Aes<T, unknown>;
11
+ }
12
+ export interface StatRollingOptions<T> {
13
+ /** Sliding window. See `RollingWindow` — bare number = count, object for explicit unit. */
14
+ window: RollingWindow;
15
+ /** Default `"mean"`. */
16
+ statistic?: RollingStatistic<T>;
17
+ /** Default `"x"`. */
18
+ axis?: RollingAxis;
19
+ /** Rows failing the filter are dropped from every window and the output series. */
20
+ filter?: (datum: T, index: number) => boolean;
21
+ /** Line curve. Default `"linear"` — pre-smoothed data rarely needs another smoother. */
22
+ curve?: LineCurve;
23
+ /** Override the per-group stroke color. When `color` is set, defaults to the color scale. */
24
+ stroke?: Color;
25
+ strokeWidth?: number;
26
+ dashStyle?: LineDashStyle;
27
+ /**
28
+ * When true (default), hovering anywhere along the curve resolves to the
29
+ * nearest rolling point by x — the cursor reads off "the smoothed value
30
+ * here." Set false for Euclidean nearest within a small pickRadius.
31
+ *
32
+ * Hits always carry the synthetic {@link RollingPoint} (`{ x, y, count,
33
+ * sourceIndex }`) as `info.datum`, not the underlying source row — so a
34
+ * tooltip resolver can describe the *line's* value at the cursor (e.g.
35
+ * "14-day mean: 88.4 kg") rather than misattributing a source row's tooltip
36
+ * to a position on the smoothed curve. Branch on `info.mark === label` to
37
+ * format the rolling tooltip distinctly from sibling point/line layers.
38
+ */
39
+ nearestX?: boolean;
40
+ /** Used by the auto-legend and tooltip discrimination. Default `"rolling"`. */
41
+ label?: string;
42
+ }
43
+ export declare function statRolling<T>(channels: StatRollingChannels<T>, options: StatRollingOptions<T>): Geom<T>;
@@ -0,0 +1,217 @@
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 { statRolling } from "./rolling.ts";
8
+ import type { CompileContext } from "./types.ts";
9
+
10
+ interface Row {
11
+ t: number;
12
+ v: number;
13
+ }
14
+
15
+ const data: Row[] = [
16
+ { t: 0, v: 1 },
17
+ { t: 1, v: 4 },
18
+ { t: 2, v: 2 },
19
+ { t: 3, v: 5 },
20
+ { t: 4, v: 3 },
21
+ ];
22
+
23
+ function makeCtx<R extends Row>(rows: readonly R[]): CompileContext<R> {
24
+ const xAes = resolveAes<R, unknown>("t");
25
+ const yAes = resolveAes<R, unknown>("v");
26
+ const xScale = buildPositionScale(xAes, rows, [0, 100]);
27
+ const yScale = buildPositionScale(yAes, rows, [200, 0]);
28
+ const scales: ScaleBundle = { x: xScale, y: yScale };
29
+ return {
30
+ data: rows,
31
+ scales,
32
+ plot: createFrame({ x: 0, y: 0, width: 100, height: 200 }),
33
+ theme: themeDefault,
34
+ atlas: undefined,
35
+ };
36
+ }
37
+
38
+ describe("statRolling geom", () => {
39
+ test("kind + label default", () => {
40
+ const geom = statRolling<Row>({ x: "t", y: "v" }, { window: 1 });
41
+ expect(geom.kind).toBe("rolling");
42
+ expect(geom.label).toBe("rolling");
43
+ });
44
+
45
+ test("compile emits a single line builder for the rolling-mean series", () => {
46
+ const geom = statRolling<Row>({ x: "t", y: "v" }, { window: 3, statistic: "mean" });
47
+ const builders = geom.compile(makeCtx(data));
48
+ expect(builders.length).toBe(1);
49
+ expect(builders[0]!.length).toBe(data.length);
50
+ });
51
+
52
+ test("empty data → no builders, no hit-test", () => {
53
+ const geom = statRolling<Row>({ x: "t", y: "v" }, { window: 3 });
54
+ expect(geom.compile(makeCtx<Row>([]))).toEqual([]);
55
+ expect(geom.compileHitTest!(makeCtx<Row>([]))).toBeNull();
56
+ });
57
+
58
+ test("hit-test emits one position per rolling point at the curve's predicted y", () => {
59
+ const geom = statRolling<Row>({ x: "t", y: "v" }, { window: 1 });
60
+ const hits = geom.compileHitTest!(makeCtx(data))!;
61
+ expect(hits.geomKind).toBe("rolling");
62
+ expect(hits.dataIndex.length).toBe(data.length);
63
+ // window=1 → identity for mean. Row 0: x=0 → px=0; v=1 maps to range
64
+ // start (200) because the y-scale's domain bottoms at v=1.
65
+ expect(hits.positions[0]).toBeCloseTo(0, 3);
66
+ expect(hits.positions[1]).toBeCloseTo(200, 3);
67
+ expect(hits.pickAxis).toBe("x");
68
+ });
69
+
70
+ test("hit-test datum is the synthetic rolling point, not the source row", () => {
71
+ const geom = statRolling<Row>({ x: "t", y: "v" }, { window: 1, statistic: "mean" });
72
+ const hits = geom.compileHitTest!(makeCtx(data))!;
73
+ // hit.data is the flat RollingPoint[] array, dataIndex is identity.
74
+ expect(hits.data.length).toBe(data.length);
75
+ for (let i = 0; i < hits.dataIndex.length; i++) {
76
+ expect(hits.dataIndex[i]).toBe(i);
77
+ }
78
+ const first = hits.data[0] as unknown as {
79
+ x: number;
80
+ y: number;
81
+ sourceIndex: number;
82
+ count: number;
83
+ };
84
+ expect(first.x).toBe(0);
85
+ expect(first.y).toBe(1);
86
+ expect(first.sourceIndex).toBe(0);
87
+ // Channel aes target the rolling point, so attachSeriesReadout reads the
88
+ // smoothed value rather than the source row's raw value.
89
+ expect(hits.channels.x!.fn(first as unknown as Row, 0)).toBe(0);
90
+ expect(hits.channels.y!.fn(first as unknown as Row, 0)).toBe(1);
91
+ });
92
+
93
+ test("filter excludes rows from both the windowed series and emitted hits", () => {
94
+ const tagged = data.map((d, i) => ({ ...d, keep: i % 2 === 0 })) as Array<
95
+ Row & { keep: boolean }
96
+ >;
97
+ const geom = statRolling<(typeof tagged)[number]>(
98
+ { x: "t", y: "v" },
99
+ { window: 1, filter: (d) => d.keep },
100
+ );
101
+ const builders = geom.compile(makeCtx(tagged));
102
+ // 3 even-index rows: 0, 2, 4.
103
+ expect(builders[0]!.length).toBe(3);
104
+ const hits = geom.compileHitTest!(makeCtx(tagged))!;
105
+ expect(hits.dataIndex.length).toBe(3);
106
+ // Each rolling hit's `sourceIndex` points back into the original tagged
107
+ // array — proves the filter's source-row tracking survived even though
108
+ // the hit's `dataIndex` is now an index into the rolling array itself.
109
+ const sourceIndices = (hits.data as unknown as readonly { sourceIndex: number }[])
110
+ .map((p) => p.sourceIndex)
111
+ .sort((a, b) => a - b);
112
+ expect(sourceIndices).toEqual([0, 2, 4]);
113
+ });
114
+
115
+ test("caches the rollingWindow output across compile passes for stable inputs", () => {
116
+ const channels = { x: (d: Row) => d.t, y: (d: Row) => d.v };
117
+ const options = { window: 3, statistic: "mean" as const };
118
+ const geom = statRolling<Row>(channels, options);
119
+ const ctx = makeCtx(data);
120
+ const b1 = geom.compile(ctx);
121
+ const b2 = geom.compile(ctx);
122
+ // Same length is a weak signal — but combined with the cache impl it
123
+ // verifies the second compile path didn't crash and reused the series.
124
+ expect(b1.length).toBe(b2.length);
125
+ // Recomputing with a fresh options object that differs by window value
126
+ // forces a cache miss; the geom still returns a builder.
127
+ const geom2 = statRolling<Row>(channels, { window: 5, statistic: "mean" });
128
+ expect(geom2.compile(ctx).length).toBe(1);
129
+ });
130
+
131
+ test("nearestX: false yields a tight pickRadius for Euclidean hover", () => {
132
+ const geom = statRolling<Row>(
133
+ { x: "t", y: "v" },
134
+ { window: 1, nearestX: false, strokeWidth: 2 },
135
+ );
136
+ const hits = geom.compileHitTest!(makeCtx(data))!;
137
+ expect(hits.pickAxis).toBeUndefined();
138
+ expect(hits.pickRadius).toBeLessThan(20);
139
+ });
140
+
141
+ test("color channel splits the series into one rolling fit per group", () => {
142
+ interface GRow extends Row {
143
+ g: string;
144
+ }
145
+ const grouped: GRow[] = [
146
+ { t: 0, v: 1, g: "a" },
147
+ { t: 1, v: 9, g: "b" },
148
+ { t: 2, v: 2, g: "a" },
149
+ { t: 3, v: 8, g: "b" },
150
+ { t: 4, v: 3, g: "a" },
151
+ { t: 5, v: 7, g: "b" },
152
+ ];
153
+ const geom = statRolling<GRow>(
154
+ { x: "t", y: "v", color: "g" },
155
+ { window: 1, statistic: "mean" },
156
+ );
157
+ const ctx: CompileContext<GRow> = {
158
+ data: grouped,
159
+ scales: {
160
+ x: buildPositionScale(resolveAes<GRow, unknown>("t"), grouped, [0, 100]),
161
+ y: buildPositionScale(resolveAes<GRow, unknown>("v"), grouped, [200, 0]),
162
+ },
163
+ plot: createFrame({ x: 0, y: 0, width: 100, height: 200 }),
164
+ theme: themeDefault,
165
+ atlas: undefined,
166
+ };
167
+ const builders = geom.compile(ctx);
168
+ // Two groups → two line builders, three points each (window=1, identity).
169
+ expect(builders.length).toBe(2);
170
+ expect(builders[0]!.length).toBe(3);
171
+ expect(builders[1]!.length).toBe(3);
172
+
173
+ const hits = geom.compileHitTest!(ctx)!;
174
+ expect(hits.dataIndex.length).toBe(6);
175
+ expect(hits.seriesKey).toBeDefined();
176
+ // Each hit's RollingPoint.sourceIndex points back to the matching group
177
+ // in the original data — proves the per-group filter remap survived.
178
+ const points = hits.data as unknown as readonly { sourceIndex: number }[];
179
+ for (let i = 0; i < hits.dataIndex.length; i++) {
180
+ const sourceIdx = points[i]!.sourceIndex;
181
+ expect(hits.seriesKey![i]).toBe(grouped[sourceIdx]!.g);
182
+ }
183
+ });
184
+
185
+ test("color channel: ctx.hidden filters out toggled-off groups", () => {
186
+ interface GRow extends Row {
187
+ g: string;
188
+ }
189
+ const grouped: GRow[] = [
190
+ { t: 0, v: 1, g: "a" },
191
+ { t: 1, v: 9, g: "b" },
192
+ { t: 2, v: 2, g: "a" },
193
+ { t: 3, v: 8, g: "b" },
194
+ ];
195
+ const geom = statRolling<GRow>({ x: "t", y: "v", color: "g" }, { window: 1 });
196
+ const ctx: CompileContext<GRow> = {
197
+ data: grouped,
198
+ scales: {
199
+ x: buildPositionScale(resolveAes<GRow, unknown>("t"), grouped, [0, 100]),
200
+ y: buildPositionScale(resolveAes<GRow, unknown>("v"), grouped, [200, 0]),
201
+ },
202
+ plot: createFrame({ x: 0, y: 0, width: 100, height: 200 }),
203
+ theme: themeDefault,
204
+ atlas: undefined,
205
+ hidden: new Set(["b"]),
206
+ };
207
+ const builders = geom.compile(ctx);
208
+ expect(builders.length).toBe(1);
209
+ const hits = geom.compileHitTest!(ctx)!;
210
+ // Only group "a" survives; two points.
211
+ expect(hits.dataIndex.length).toBe(2);
212
+ const points = hits.data as unknown as readonly { sourceIndex: number }[];
213
+ for (let i = 0; i < hits.dataIndex.length; i++) {
214
+ expect(grouped[points[i]!.sourceIndex]!.g).toBe("a");
215
+ }
216
+ });
217
+ });
@@ -0,0 +1,387 @@
1
+ // ---------------------------------------------------------------------------
2
+ // statRolling geom — sliding-window statistic rendered as a line
3
+ // ---------------------------------------------------------------------------
4
+ // Wraps `rollingWindow` so consumers can drop a rolling-mean / median / quantile
5
+ // curve into a chart with a single `.layer(statRolling(...))` call, instead of
6
+ // pre-computing the series and hand-writing a custom `Geom<T>` to render it.
7
+ //
8
+ // Mirrors the ggplot2 `stat_*` model: stat is a layer, the chart owns recompute
9
+ // on data/scale change, and the consumer never sees the intermediate series.
10
+ //
11
+ // Grouping: when `channels.color` is set, the geom buckets rows by the
12
+ // categorical color value and runs `rollingWindow` once per bucket, emitting
13
+ // one line per group. Mirrors `smooth`'s grouping. Without `color`, behaves
14
+ // as a single global fit.
15
+ //
16
+ // Caching: the geom closes over `data + options` and memoizes the last
17
+ // rollingWindow output against (data ref, window, statistic, axis, filter
18
+ // ref, x ref, y ref, color ref). Stable accessors → cache hits across compile
19
+ // passes. Inline closures → cache misses (and the geom recomputes on every
20
+ // dirty draw), so callers paying the per-frame cost should hoist accessors.
21
+ //
22
+ // Hoist-for-cache-warmth pattern (callers):
23
+ //
24
+ // // module scope — re-used across renders
25
+ // const xAccessor = (d: Row) => d.timestamp;
26
+ // const yAccessor = (d: Row) => d.value;
27
+ //
28
+ // statRolling(
29
+ // { x: xAccessor, y: yAccessor },
30
+ // { window: { value: 14, unit: "domain" }, statistic: "median" },
31
+ // );
32
+ //
33
+ // Inline `x: (d) => d.timestamp` works but recomputes the rolling series
34
+ // every frame because each render creates a fresh function reference.
35
+ // Same guidance applies to `smooth` once it grows an explicit cache.
36
+
37
+ import type { Color } from "insomni";
38
+ import { lineMark, type LineCurve, type LineDashStyle } from "../../marks.ts";
39
+ import { rollingWindow, type RollingPoint } from "../../stats/rolling-window.ts";
40
+ import type { RollingAxis, RollingStatistic, RollingWindow } from "../../stats/rolling-window.ts";
41
+ import { groupBy } from "../../stats/index.ts";
42
+ import { haloRing, inlineMark, wrapMark } from "./_mark.ts";
43
+ import { dropNullCategoricalIndices, materialize, resolveAes } from "../aes.ts";
44
+ import type { Aes, ResolvedAes } from "../aes.ts";
45
+ import type { CompileContext, CompiledHitTest, Geom, ResolvedChannelMap } from "./types.ts";
46
+
47
+ export interface StatRollingChannels<T> {
48
+ x: Aes<T, number | Date>;
49
+ y: Aes<T, number>;
50
+ /** Categorical color channel — runs one rolling fit per group. */
51
+ color?: Aes<T, unknown>;
52
+ }
53
+
54
+ export interface StatRollingOptions<T> {
55
+ /** Sliding window. See `RollingWindow` — bare number = count, object for explicit unit. */
56
+ window: RollingWindow;
57
+ /** Default `"mean"`. */
58
+ statistic?: RollingStatistic<T>;
59
+ /** Default `"x"`. */
60
+ axis?: RollingAxis;
61
+ /** Rows failing the filter are dropped from every window and the output series. */
62
+ filter?: (datum: T, index: number) => boolean;
63
+ /** Line curve. Default `"linear"` — pre-smoothed data rarely needs another smoother. */
64
+ curve?: LineCurve;
65
+ /** Override the per-group stroke color. When `color` is set, defaults to the color scale. */
66
+ stroke?: Color;
67
+ strokeWidth?: number;
68
+ dashStyle?: LineDashStyle;
69
+ /**
70
+ * When true (default), hovering anywhere along the curve resolves to the
71
+ * nearest rolling point by x — the cursor reads off "the smoothed value
72
+ * here." Set false for Euclidean nearest within a small pickRadius.
73
+ *
74
+ * Hits always carry the synthetic {@link RollingPoint} (`{ x, y, count,
75
+ * sourceIndex }`) as `info.datum`, not the underlying source row — so a
76
+ * tooltip resolver can describe the *line's* value at the cursor (e.g.
77
+ * "14-day mean: 88.4 kg") rather than misattributing a source row's tooltip
78
+ * to a position on the smoothed curve. Branch on `info.mark === label` to
79
+ * format the rolling tooltip distinctly from sibling point/line layers.
80
+ */
81
+ nearestX?: boolean;
82
+ /** Used by the auto-legend and tooltip discrimination. Default `"rolling"`. */
83
+ label?: string;
84
+ }
85
+
86
+ interface GroupSeries {
87
+ key: unknown;
88
+ series: RollingPoint[];
89
+ }
90
+
91
+ // Cache slot keyed by (window, statistic, axis, filter, x, y, color). Holds
92
+ // the per-group series map and a flat RollingPoint[] array. The flat array's
93
+ // reference is held stable across all `compileHitTest` calls that resolve to
94
+ // the same cell, so the hit-layer's slot-identity check (`s.hit.data ===
95
+ // next.data` — `hit-layer.ts:296`) reuses the existing PointCloudNode
96
+ // instead of tearing it down each redraw.
97
+ interface CacheCell<T> {
98
+ data: readonly T[];
99
+ window: RollingWindow;
100
+ statistic: RollingStatistic<T> | undefined;
101
+ axis: RollingAxis | undefined;
102
+ filter: ((d: T, i: number) => boolean) | undefined;
103
+ x: Aes<T, number | Date>;
104
+ y: Aes<T, number>;
105
+ // oxlint-disable-next-line no-redundant-type-constituents -- Aes<T,unknown> expands to include unknown; | undefined is explicit intent for "no color accessor"
106
+ color: Aes<T, unknown> | undefined;
107
+ groups: GroupSeries[];
108
+ rollingData: RollingPoint[];
109
+ // `rollingData` start offset for each entry in `groups[i]`. Lets
110
+ // `compileHitTest` translate (group, i-within-group) → stable global
111
+ // index when emitting hits, even when `ctx.hidden` changes between calls.
112
+ groupOffsets: number[];
113
+ }
114
+
115
+ // Module-scope cache keyed on the data array reference. Sits OUTSIDE every
116
+ // Geom instance: consumers that drive Monitor-style settings closures via
117
+ // `mount({ build: () => buildChart() })` recreate `statRolling()` (and
118
+ // every other Geom) on every frame — see `mount.ts:600`. A per-instance
119
+ // cache resets each frame, breaks `hit-layer.ts:296`'s slot-identity fast
120
+ // path, and forces a tear-down+rebuild of every hit slot every frame
121
+ // (synthesizing a spurious `onHoverLeave` that hides the tooltip / drops
122
+ // the halo). The module-scope cache survives Geom re-instantiation: as
123
+ // long as the underlying `data` is reference-stable and the rolling
124
+ // config matches by value, the same `rollingData` array is returned and
125
+ // slot identity holds.
126
+ //
127
+ // One data array can host several rolling configs (different windows on
128
+ // the same series, etc.), so we keep a small array per data. Bounded to
129
+ // avoid pathological growth when a control sweeps continuously.
130
+ const CELLS_PER_DATA_CAP = 8;
131
+ const DATA_CACHE = new WeakMap<readonly unknown[], CacheCell<unknown>[]>();
132
+
133
+ function windowEqual(a: RollingWindow, b: RollingWindow): boolean {
134
+ if (a === b) return true;
135
+ if (typeof a === "number" && typeof b === "number") return a === b;
136
+ if (typeof a === "object" && typeof b === "object") {
137
+ return a.value === b.value && a.unit === b.unit;
138
+ }
139
+ return false;
140
+ }
141
+
142
+ function cellMatches<T>(
143
+ cell: CacheCell<T>,
144
+ data: readonly T[],
145
+ channels: StatRollingChannels<T>,
146
+ options: StatRollingOptions<T>,
147
+ ): boolean {
148
+ return (
149
+ cell.data === data &&
150
+ cell.x === channels.x &&
151
+ cell.y === channels.y &&
152
+ cell.color === channels.color &&
153
+ cell.filter === options.filter &&
154
+ cell.axis === options.axis &&
155
+ cell.statistic === options.statistic &&
156
+ windowEqual(cell.window, options.window)
157
+ );
158
+ }
159
+
160
+ function buildGroups<T>(
161
+ data: readonly T[],
162
+ colorAes: ResolvedAes<T, unknown> | undefined,
163
+ ): { key: unknown; rows: number[] }[] {
164
+ const indices = Array.from(data, (_d, i) => i);
165
+ if (!colorAes) return [{ key: undefined, rows: indices }];
166
+ // Drop null/undefined categorical rows up front so the rendered group set
167
+ // stays aligned with the color scale's domain (see aes.ts null policy).
168
+ const valid = dropNullCategoricalIndices(indices, colorAes, data);
169
+ const colorValues = materialize(colorAes, data);
170
+ const groups: { key: unknown; rows: number[] }[] = [];
171
+ for (const [key, rows] of groupBy(valid, (i) => colorValues[i])) {
172
+ groups.push({ key, rows });
173
+ }
174
+ return groups;
175
+ }
176
+
177
+ export function statRolling<T>(
178
+ channels: StatRollingChannels<T>,
179
+ options: StatRollingOptions<T>,
180
+ ): Geom<T> {
181
+ const computeCache = (data: readonly T[]): CacheCell<T> => {
182
+ // Consult the module-level WeakMap first so Geom re-instantiation
183
+ // (closure-driven `build()` callbacks recompose the chart on every
184
+ // frame) doesn't reset our cache. Match by value on the cache key
185
+ // fields — accessor identity stays the rule for `x/y/color/filter`,
186
+ // but the wrapping `options` object is fresh each frame even when
187
+ // its fields haven't changed.
188
+ let cells = DATA_CACHE.get(data as readonly unknown[]) as CacheCell<T>[] | undefined;
189
+ if (cells) {
190
+ for (let i = 0; i < cells.length; i++) {
191
+ const candidate = cells[i]!;
192
+ if (cellMatches(candidate, data, channels, options)) {
193
+ // MRU bump so eviction targets the truly-cold entries.
194
+ if (i !== cells.length - 1) {
195
+ cells.splice(i, 1);
196
+ cells.push(candidate);
197
+ }
198
+ return candidate;
199
+ }
200
+ }
201
+ }
202
+ const xAes = resolveAes<T, unknown>(channels.x as Aes<T, unknown>);
203
+ const yAes = resolveAes<T, number>(channels.y);
204
+ const colorAes = channels.color ? resolveAes<T, unknown>(channels.color) : undefined;
205
+ const rowGroups = buildGroups(data, colorAes);
206
+
207
+ const userFilter = options.filter;
208
+ const groups: GroupSeries[] = [];
209
+ for (const group of rowGroups) {
210
+ const memberSet = colorAes ? new Set(group.rows) : null;
211
+ const combinedFilter = (datum: T, i: number) => {
212
+ if (memberSet && !memberSet.has(i)) return false;
213
+ if (userFilter && !userFilter(datum, i)) return false;
214
+ return true;
215
+ };
216
+ const series = rollingWindow(data, {
217
+ x: (d, i) => {
218
+ const v = xAes.fn(d, i);
219
+ return v instanceof Date ? v : (v as number);
220
+ },
221
+ y: (d, i) => yAes.fn(d, i),
222
+ window: options.window,
223
+ statistic: options.statistic,
224
+ filter: combinedFilter,
225
+ axis: options.axis,
226
+ });
227
+ groups.push({ key: group.key, series });
228
+ }
229
+
230
+ // Build the flat rolling-point array + per-group offsets once. Both are
231
+ // held stable on the cache cell so hover state persists across redraws.
232
+ let total = 0;
233
+ const groupOffsets: number[] = Array.from({ length: groups.length });
234
+ for (let i = 0; i < groups.length; i++) {
235
+ groupOffsets[i] = total;
236
+ total += groups[i]!.series.length;
237
+ }
238
+ const rollingData: RollingPoint[] = Array.from({ length: total });
239
+ for (let gi = 0; gi < groups.length; gi++) {
240
+ const series = groups[gi]!.series;
241
+ const offset = groupOffsets[gi]!;
242
+ for (let i = 0; i < series.length; i++) {
243
+ rollingData[offset + i] = series[i]!;
244
+ }
245
+ }
246
+
247
+ const fresh: CacheCell<T> = {
248
+ data,
249
+ window: options.window,
250
+ statistic: options.statistic,
251
+ axis: options.axis,
252
+ filter: options.filter,
253
+ x: channels.x,
254
+ y: channels.y,
255
+ color: channels.color,
256
+ groups,
257
+ rollingData,
258
+ groupOffsets,
259
+ };
260
+ if (!cells) {
261
+ cells = [];
262
+ DATA_CACHE.set(data as readonly unknown[], cells as CacheCell<unknown>[]);
263
+ }
264
+ cells.push(fresh);
265
+ if (cells.length > CELLS_PER_DATA_CAP) cells.shift();
266
+ return fresh;
267
+ };
268
+
269
+ const computeGroups = (data: readonly T[]): GroupSeries[] => computeCache(data).groups;
270
+
271
+ return {
272
+ kind: "rolling",
273
+ channels,
274
+ label: options.label ?? "rolling",
275
+ compile(ctx: CompileContext<T>) {
276
+ const groups = computeGroups(ctx.data);
277
+ if (groups.length === 0) return [];
278
+ const xScale = ctx.scales.x.fn as (v: number) => number;
279
+ const yScale = ctx.scales.y.fn as (v: number) => number;
280
+ const colorScale = ctx.scales.color?.fn;
281
+ const hasColor = channels.color !== undefined;
282
+ const baseStroke = options.stroke ?? ctx.theme.palettes.categorical(0);
283
+ const strokeWidth = options.strokeWidth ?? ctx.theme.marks.strokeWidth;
284
+
285
+ const builders: ReturnType<typeof wrapMark>[] = [];
286
+ for (const group of groups) {
287
+ if (group.series.length === 0) continue;
288
+ if (hasColor && ctx.hidden && ctx.hidden.has(String(group.key))) continue;
289
+ const stroke =
290
+ options.stroke ??
291
+ (hasColor && colorScale ? (colorScale(group.key) as Color) : baseStroke);
292
+ const mark = lineMark(group.series, {
293
+ x: (p) => xScale(p.x),
294
+ y: (p) => yScale(p.y),
295
+ stroke,
296
+ strokeWidth,
297
+ curve: options.curve ?? "linear",
298
+ dashStyle: options.dashStyle,
299
+ });
300
+ builders.push(wrapMark(mark, ctx.plot.topLeft, group.series.length));
301
+ }
302
+
303
+ // Hover halo at the rolling point under the cursor. `ctx.hovered.x/y`
304
+ // are absolute element-CSS pixels, already on the smoothed line — the
305
+ // ring lands exactly on the point the tooltip is describing.
306
+ if (ctx.hovered && ctx.hovered.geomKind === "rolling") {
307
+ const cx = ctx.hovered.x;
308
+ const cy = ctx.hovered.y;
309
+ if (Number.isFinite(cx) && Number.isFinite(cy)) {
310
+ const sk = ctx.hovered.seriesKey;
311
+ const ringColor: Color =
312
+ sk !== undefined && hasColor && colorScale ? (colorScale(sk) as Color) : baseStroke;
313
+ const r = Math.max(4, strokeWidth * 1.5 + 2);
314
+ builders.push(inlineMark((layer) => haloRing(layer, cx, cy, r, ringColor, 2)));
315
+ }
316
+ }
317
+ return builders;
318
+ },
319
+ compileHitTest(ctx: CompileContext<T>): CompiledHitTest<T> | null {
320
+ const cell = computeCache(ctx.data);
321
+ const groups = cell.groups;
322
+ if (groups.length === 0) return null;
323
+ const xScale = ctx.scales.x.fn as (v: number) => number;
324
+ const yScale = ctx.scales.y.fn as (v: number) => number;
325
+ const ox = ctx.plot.topLeft.x;
326
+ const oy = ctx.plot.topLeft.y;
327
+ const hasColor = channels.color !== undefined;
328
+ const totalCached = cell.rollingData.length;
329
+ if (totalCached === 0) return null;
330
+
331
+ // Emit one hit per visible rolling point. `dataIndex[i]` is the index
332
+ // into the *cached* flat `rollingData` (stable reference across redraws
333
+ // — preserves the hit-layer's slot identity); `positions` are recomputed
334
+ // each call against the current scale.
335
+ const positions = new Float32Array(totalCached * 2);
336
+ const dataIndex = new Int32Array(totalCached);
337
+ const seriesKey: (string | undefined)[] = Array.from({ length: totalCached });
338
+ let n = 0;
339
+ for (let gi = 0; gi < groups.length; gi++) {
340
+ const group = groups[gi]!;
341
+ if (hasColor && ctx.hidden && ctx.hidden.has(String(group.key))) continue;
342
+ const offset = cell.groupOffsets[gi]!;
343
+ const key = hasColor ? String(group.key) : undefined;
344
+ for (let i = 0; i < group.series.length; i++) {
345
+ const p = group.series[i]!;
346
+ const px = xScale(p.x);
347
+ const py = yScale(p.y);
348
+ if (!Number.isFinite(px) || !Number.isFinite(py)) continue;
349
+ positions[n * 2] = ox + px;
350
+ positions[n * 2 + 1] = oy + py;
351
+ dataIndex[n] = offset + i;
352
+ seriesKey[n] = key;
353
+ n++;
354
+ }
355
+ }
356
+ if (n === 0) return null;
357
+
358
+ // Channel aes target the rolling points, not the source rows. Keeps
359
+ // `attachSeriesReadout` honest: the readout panel reads `xAes.fn(datum)`
360
+ // and `yAes.fn(datum)` over `hit.data` — they need to return the
361
+ // rolling point's smoothed value, not whatever source row happens to
362
+ // share the index.
363
+ const channelsMap: ResolvedChannelMap<T> = {
364
+ x: { kind: "accessor", fn: (p) => (p as RollingPoint).x } as ResolvedAes<T, unknown>,
365
+ y: { kind: "accessor", fn: (p) => (p as RollingPoint).y } as ResolvedAes<T, unknown>,
366
+ };
367
+
368
+ const strokeWidth = options.strokeWidth ?? ctx.theme.marks.strokeWidth;
369
+ const nearestX = options.nearestX ?? true;
370
+ const pickRadius = nearestX
371
+ ? Math.max(ctx.plot.width, ctx.plot.height)
372
+ : Math.max(8, strokeWidth * 2 + 4);
373
+
374
+ return {
375
+ geomKind: "rolling",
376
+ label: options.label ?? "rolling",
377
+ positions: positions.subarray(0, n * 2),
378
+ dataIndex: dataIndex.subarray(0, n),
379
+ seriesKey: hasColor ? seriesKey.slice(0, n) : undefined,
380
+ pickRadius,
381
+ pickAxis: nearestX ? "x" : undefined,
382
+ channels: channelsMap,
383
+ data: cell.rollingData as readonly unknown[] as readonly T[],
384
+ };
385
+ },
386
+ };
387
+ }
@@ -0,0 +1,28 @@
1
+ import type { Color } from "insomni";
2
+ import type { Aes } from "../aes.ts";
3
+ import type { Geom } from "./types.ts";
4
+ export interface RugChannels<T> {
5
+ x?: Aes<T, number | Date>;
6
+ y?: Aes<T, number | Date>;
7
+ /** Categorical color split for the ticks. */
8
+ color?: Aes<T, unknown>;
9
+ }
10
+ export type RugSide = "x" | "y" | "both";
11
+ export interface RugOptions {
12
+ /**
13
+ * Which edges receive ticks. Defaults to whichever channels are wired —
14
+ * with both `x` and `y`, both edges; otherwise just the matching edge.
15
+ * Use `"both"` to force both edges (mirrors x onto bottom, y onto left).
16
+ */
17
+ side?: RugSide;
18
+ /** Tick length in pixels. Default `6`. */
19
+ length?: number;
20
+ /** Tick stroke width. Default `1`. */
21
+ strokeWidth?: number;
22
+ /** Override stroke color (defaults to theme.axis.color). */
23
+ stroke?: Color;
24
+ /** Multiplier on the resolved stroke alpha. Default `0.6`. */
25
+ opacity?: number;
26
+ label?: string;
27
+ }
28
+ export declare function rug<T>(channels: RugChannels<T>, options?: RugOptions): Geom<T>;