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.
- package/LICENSE.md +674 -0
- package/README.md +81 -0
- package/dist/core.d.mts +340 -0
- package/dist/core.mjs +1047 -0
- package/dist/index.d.mts +3426 -0
- package/dist/index.mjs +12762 -0
- package/dist/interactions-DEFL_F4E.mjs +5395 -0
- package/dist/range-presets-CzECsu3V.d.mts +1523 -0
- package/package.json +34 -0
- package/src/annotations.d.ts +121 -0
- package/src/annotations.ts +438 -0
- package/src/axis.d.ts +184 -0
- package/src/axis.test.ts +131 -0
- package/src/axis.ts +765 -0
- package/src/colorbar.d.ts +69 -0
- package/src/colorbar.ts +294 -0
- package/src/colors.d.ts +57 -0
- package/src/colors.test.ts +28 -0
- package/src/colors.ts +486 -0
- package/src/core.ts +299 -0
- package/src/format.d.ts +54 -0
- package/src/format.ts +138 -0
- package/src/grammar/accessibility.d.ts +147 -0
- package/src/grammar/accessibility.test.ts +199 -0
- package/src/grammar/accessibility.ts +443 -0
- package/src/grammar/aes.d.ts +35 -0
- package/src/grammar/aes.test.ts +75 -0
- package/src/grammar/aes.ts +120 -0
- package/src/grammar/annotations.d.ts +86 -0
- package/src/grammar/annotations.test.ts +68 -0
- package/src/grammar/annotations.ts +336 -0
- package/src/grammar/attach-brush.d.ts +44 -0
- package/src/grammar/attach-brush.test.ts +214 -0
- package/src/grammar/attach-brush.ts +111 -0
- package/src/grammar/attach-presets.d.ts +33 -0
- package/src/grammar/attach-presets.test.ts +106 -0
- package/src/grammar/attach-presets.ts +215 -0
- package/src/grammar/chart.d.ts +952 -0
- package/src/grammar/chart.test.ts +118 -0
- package/src/grammar/chart.ts +1172 -0
- package/src/grammar/color-utils.d.ts +29 -0
- package/src/grammar/color-utils.test.ts +53 -0
- package/src/grammar/color-utils.ts +66 -0
- package/src/grammar/constants.d.ts +45 -0
- package/src/grammar/constants.ts +61 -0
- package/src/grammar/coord.d.ts +183 -0
- package/src/grammar/coord.test.ts +355 -0
- package/src/grammar/coord.ts +619 -0
- package/src/grammar/data/pivot.d.ts +57 -0
- package/src/grammar/data/pivot.ts +107 -0
- package/src/grammar/emphasis-driver.d.ts +69 -0
- package/src/grammar/emphasis-driver.test.ts +199 -0
- package/src/grammar/emphasis-driver.ts +205 -0
- package/src/grammar/equality.d.ts +3 -0
- package/src/grammar/equality.ts +40 -0
- package/src/grammar/facet.d.ts +63 -0
- package/src/grammar/facet.test.ts +60 -0
- package/src/grammar/facet.ts +175 -0
- package/src/grammar/geoms/_categorical.d.ts +94 -0
- package/src/grammar/geoms/_categorical.ts +0 -0
- package/src/grammar/geoms/_distribution.d.ts +52 -0
- package/src/grammar/geoms/_distribution.ts +125 -0
- package/src/grammar/geoms/_mark.d.ts +69 -0
- package/src/grammar/geoms/_mark.ts +136 -0
- package/src/grammar/geoms/_shape.d.ts +41 -0
- package/src/grammar/geoms/_shape.ts +74 -0
- package/src/grammar/geoms/aggregate.d.ts +95 -0
- package/src/grammar/geoms/aggregate.test.ts +554 -0
- package/src/grammar/geoms/aggregate.ts +840 -0
- package/src/grammar/geoms/area.d.ts +32 -0
- package/src/grammar/geoms/area.test.ts +165 -0
- package/src/grammar/geoms/area.ts +578 -0
- package/src/grammar/geoms/band.d.ts +27 -0
- package/src/grammar/geoms/band.test.ts +57 -0
- package/src/grammar/geoms/band.ts +126 -0
- package/src/grammar/geoms/bar.d.ts +56 -0
- package/src/grammar/geoms/bar.test.ts +367 -0
- package/src/grammar/geoms/bar.ts +1054 -0
- package/src/grammar/geoms/boxplot.d.ts +129 -0
- package/src/grammar/geoms/boxplot.test.ts +299 -0
- package/src/grammar/geoms/boxplot.ts +834 -0
- package/src/grammar/geoms/connected-scatter.d.ts +27 -0
- package/src/grammar/geoms/connected-scatter.test.ts +157 -0
- package/src/grammar/geoms/connected-scatter.ts +63 -0
- package/src/grammar/geoms/emphasis.d.ts +76 -0
- package/src/grammar/geoms/emphasis.test.ts +135 -0
- package/src/grammar/geoms/emphasis.ts +162 -0
- package/src/grammar/geoms/histogram.d.ts +75 -0
- package/src/grammar/geoms/histogram.test.ts +262 -0
- package/src/grammar/geoms/histogram.ts +740 -0
- package/src/grammar/geoms/index.d.ts +20 -0
- package/src/grammar/geoms/index.ts +77 -0
- package/src/grammar/geoms/interval.d.ts +31 -0
- package/src/grammar/geoms/interval.test.ts +154 -0
- package/src/grammar/geoms/interval.ts +342 -0
- package/src/grammar/geoms/line.d.ts +38 -0
- package/src/grammar/geoms/line.test.ts +247 -0
- package/src/grammar/geoms/line.ts +659 -0
- package/src/grammar/geoms/point.d.ts +57 -0
- package/src/grammar/geoms/point.test.ts +163 -0
- package/src/grammar/geoms/point.ts +545 -0
- package/src/grammar/geoms/polar.test.ts +216 -0
- package/src/grammar/geoms/ribbon.d.ts +21 -0
- package/src/grammar/geoms/ribbon.test.ts +170 -0
- package/src/grammar/geoms/ribbon.ts +87 -0
- package/src/grammar/geoms/ridgeline.d.ts +89 -0
- package/src/grammar/geoms/ridgeline.test.ts +247 -0
- package/src/grammar/geoms/ridgeline.ts +1164 -0
- package/src/grammar/geoms/rolling.d.ts +43 -0
- package/src/grammar/geoms/rolling.test.ts +217 -0
- package/src/grammar/geoms/rolling.ts +387 -0
- package/src/grammar/geoms/rug.d.ts +28 -0
- package/src/grammar/geoms/rug.test.ts +126 -0
- package/src/grammar/geoms/rug.ts +214 -0
- package/src/grammar/geoms/rule.d.ts +23 -0
- package/src/grammar/geoms/rule.test.ts +69 -0
- package/src/grammar/geoms/rule.ts +212 -0
- package/src/grammar/geoms/smooth.d.ts +54 -0
- package/src/grammar/geoms/smooth.test.ts +78 -0
- package/src/grammar/geoms/smooth.ts +337 -0
- package/src/grammar/geoms/text.d.ts +29 -0
- package/src/grammar/geoms/text.test.ts +64 -0
- package/src/grammar/geoms/text.ts +234 -0
- package/src/grammar/geoms/tile.d.ts +61 -0
- package/src/grammar/geoms/tile.test.ts +157 -0
- package/src/grammar/geoms/tile.ts +621 -0
- package/src/grammar/geoms/types.d.ts +319 -0
- package/src/grammar/geoms/types.ts +362 -0
- package/src/grammar/geoms/violin.d.ts +85 -0
- package/src/grammar/geoms/violin.test.ts +187 -0
- package/src/grammar/geoms/violin.ts +672 -0
- package/src/grammar/index.d.ts +22 -0
- package/src/grammar/index.ts +269 -0
- package/src/grammar/interactions/_disposable.d.ts +5 -0
- package/src/grammar/interactions/_disposable.ts +23 -0
- package/src/grammar/interactions/_z.d.ts +4 -0
- package/src/grammar/interactions/_z.ts +16 -0
- package/src/grammar/interactions/brush-selection.test.ts +262 -0
- package/src/grammar/interactions/brush.d.ts +63 -0
- package/src/grammar/interactions/brush.test.ts +483 -0
- package/src/grammar/interactions/brush.ts +452 -0
- package/src/grammar/interactions/crosshair.d.ts +19 -0
- package/src/grammar/interactions/crosshair.test.ts +127 -0
- package/src/grammar/interactions/crosshair.ts +76 -0
- package/src/grammar/interactions/hit-layer.d.ts +64 -0
- package/src/grammar/interactions/hit-layer.ts +246 -0
- package/src/grammar/interactions/legend.d.ts +19 -0
- package/src/grammar/interactions/legend.ts +101 -0
- package/src/grammar/interactions/menu.d.ts +93 -0
- package/src/grammar/interactions/menu.test.ts +373 -0
- package/src/grammar/interactions/menu.ts +342 -0
- package/src/grammar/interactions/selection.d.ts +25 -0
- package/src/grammar/interactions/selection.test.ts +289 -0
- package/src/grammar/interactions/selection.ts +142 -0
- package/src/grammar/interactions/series-readout.d.ts +91 -0
- package/src/grammar/interactions/series-readout.test.ts +668 -0
- package/src/grammar/interactions/series-readout.ts +422 -0
- package/src/grammar/interactions/series-snap.d.ts +70 -0
- package/src/grammar/interactions/series-snap.test.ts +214 -0
- package/src/grammar/interactions/series-snap.ts +218 -0
- package/src/grammar/interactions/tooltip-axis.test.ts +176 -0
- package/src/grammar/interactions/tooltip-touch.browser.test.ts +49 -0
- package/src/grammar/interactions/tooltip-touch.test.ts +161 -0
- package/src/grammar/interactions/tooltip.d.ts +140 -0
- package/src/grammar/interactions/tooltip.test.ts +406 -0
- package/src/grammar/interactions/tooltip.ts +622 -0
- package/src/grammar/interactions/transitions.d.ts +34 -0
- package/src/grammar/interactions/transitions.test.ts +172 -0
- package/src/grammar/interactions/transitions.ts +160 -0
- package/src/grammar/layout.d.ts +68 -0
- package/src/grammar/layout.ts +186 -0
- package/src/grammar/legend-merge.test.ts +332 -0
- package/src/grammar/mount.d.ts +78 -0
- package/src/grammar/mount.test.ts +479 -0
- package/src/grammar/mount.ts +2112 -0
- package/src/grammar/palettes.d.ts +54 -0
- package/src/grammar/palettes.test.ts +80 -0
- package/src/grammar/palettes.ts +167 -0
- package/src/grammar/pan-zoom.test.ts +398 -0
- package/src/grammar/phylo.d.ts +65 -0
- package/src/grammar/phylo.test.ts +59 -0
- package/src/grammar/phylo.ts +112 -0
- package/src/grammar/pipeline.auto-ticks.test.ts +40 -0
- package/src/grammar/pipeline.d.ts +158 -0
- package/src/grammar/pipeline.test.ts +463 -0
- package/src/grammar/pipeline.ts +1233 -0
- package/src/grammar/profiling.d.ts +8 -0
- package/src/grammar/profiling.ts +24 -0
- package/src/grammar/scales.d.ts +188 -0
- package/src/grammar/scales.test.ts +181 -0
- package/src/grammar/scales.ts +800 -0
- package/src/grammar/svg.d.ts +3 -0
- package/src/grammar/svg.ts +39 -0
- package/src/grammar/theme.d.ts +261 -0
- package/src/grammar/theme.test.ts +105 -0
- package/src/grammar/theme.ts +490 -0
- package/src/heatmap/cpu.ts +109 -0
- package/src/heatmap/gpu.ts +565 -0
- package/src/heatmap/types.ts +177 -0
- package/src/heatmap.browser.test.ts +308 -0
- package/src/heatmap.test.ts +320 -0
- package/src/heatmap.ts +123 -0
- package/src/index.d.ts +1 -0
- package/src/index.ts +8 -0
- package/src/interactions.d.ts +48 -0
- package/src/interactions.test.ts +226 -0
- package/src/interactions.ts +394 -0
- package/src/layout/box.d.ts +48 -0
- package/src/layout/box.test.ts +107 -0
- package/src/layout/box.ts +143 -0
- package/src/legend.d.ts +115 -0
- package/src/legend.ts +422 -0
- package/src/marks/curve.d.ts +43 -0
- package/src/marks/curve.ts +244 -0
- package/src/marks/stack.d.ts +53 -0
- package/src/marks/stack.ts +184 -0
- package/src/marks.d.ts +273 -0
- package/src/marks.test.ts +541 -0
- package/src/marks.ts +1292 -0
- package/src/navigator.test.ts +174 -0
- package/src/navigator.ts +393 -0
- package/src/range-presets.d.ts +113 -0
- package/src/range-presets.test.ts +345 -0
- package/src/range-presets.ts +349 -0
- package/src/scales.d.ts +98 -0
- package/src/scales.test.ts +103 -0
- package/src/scales.ts +695 -0
- package/src/stats/index.d.ts +200 -0
- package/src/stats/index.test.ts +349 -0
- package/src/stats/index.ts +740 -0
- package/src/stats/regression.d.ts +38 -0
- package/src/stats/regression.test.ts +56 -0
- package/src/stats/regression.ts +396 -0
- package/src/stats/rolling-window.d.ts +55 -0
- package/src/stats/rolling-window.test.ts +237 -0
- package/src/stats/rolling-window.ts +256 -0
- package/src/test-setup.ts +19 -0
- package/src/viewport/axis-state.d.ts +72 -0
- package/src/viewport/axis-state.ts +476 -0
- package/src/viewport.d.ts +170 -0
- package/src/viewport.test.ts +363 -0
- package/src/viewport.ts +510 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { describe, expect, test } from "vite-plus/test";
|
|
2
|
+
|
|
3
|
+
import { rollingWindow } from "./rolling-window.ts";
|
|
4
|
+
|
|
5
|
+
interface Row {
|
|
6
|
+
t: number;
|
|
7
|
+
v: number;
|
|
8
|
+
ok?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function rows(values: readonly number[], xs?: readonly number[]): Row[] {
|
|
12
|
+
return values.map((v, i) => ({ t: xs ? xs[i]! : i, v }));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("rollingWindow", () => {
|
|
16
|
+
test("window of 1 (count) is identity for mean", () => {
|
|
17
|
+
const data = rows([1, 4, 2, 5, 3]);
|
|
18
|
+
const out = rollingWindow(data, {
|
|
19
|
+
x: (d) => d.t,
|
|
20
|
+
y: (d) => d.v,
|
|
21
|
+
window: 1,
|
|
22
|
+
});
|
|
23
|
+
expect(out).toHaveLength(5);
|
|
24
|
+
for (const p of out) {
|
|
25
|
+
expect(p.y).toBeCloseTo(data[p.sourceIndex]!.v, 9);
|
|
26
|
+
expect(p.count).toBe(1);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("window of 0 (count) coerces to 1 → identity for mean", () => {
|
|
31
|
+
const data = rows([10, 20, 30, 40]);
|
|
32
|
+
const out = rollingWindow(data, {
|
|
33
|
+
x: (d) => d.t,
|
|
34
|
+
y: (d) => d.v,
|
|
35
|
+
window: 0,
|
|
36
|
+
});
|
|
37
|
+
expect(out.map((p) => p.y)).toEqual([10, 20, 30, 40]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("window ≥ data extent (domain) collapses to a single flat value", () => {
|
|
41
|
+
const data = rows([1, 2, 3, 4, 5]);
|
|
42
|
+
const out = rollingWindow(data, {
|
|
43
|
+
x: (d) => d.t,
|
|
44
|
+
y: (d) => d.v,
|
|
45
|
+
window: { value: 1_000_000, unit: "domain" },
|
|
46
|
+
statistic: "mean",
|
|
47
|
+
});
|
|
48
|
+
const mean = (1 + 2 + 3 + 4 + 5) / 5;
|
|
49
|
+
for (const p of out) expect(p.y).toBeCloseTo(mean, 9);
|
|
50
|
+
// Every window saw all 5 points.
|
|
51
|
+
expect(out.every((p) => p.count === 5)).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("centered domain window includes neighbors within ±value/2", () => {
|
|
55
|
+
// xs = 0..9 step 1. 3-day window → ±1.5 → 3-wide.
|
|
56
|
+
const data = rows([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
|
|
57
|
+
const out = rollingWindow(data, {
|
|
58
|
+
x: (d) => d.t,
|
|
59
|
+
y: (d) => d.v,
|
|
60
|
+
window: { value: 3, unit: "domain" },
|
|
61
|
+
statistic: "mean",
|
|
62
|
+
});
|
|
63
|
+
// Interior point at t=4: window contains v=3,4,5 → mean 4.
|
|
64
|
+
expect(out[4]!.y).toBeCloseTo(4, 9);
|
|
65
|
+
expect(out[4]!.count).toBe(3);
|
|
66
|
+
// Edge point at t=0: window contains v=0,1 only → mean 0.5.
|
|
67
|
+
expect(out[0]!.y).toBeCloseTo(0.5, 9);
|
|
68
|
+
expect(out[0]!.count).toBe(2);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("filter excludes points from both window contents and output series", () => {
|
|
72
|
+
const data: Row[] = [
|
|
73
|
+
{ t: 0, v: 10, ok: true },
|
|
74
|
+
{ t: 1, v: 100, ok: false }, // excluded
|
|
75
|
+
{ t: 2, v: 20, ok: true },
|
|
76
|
+
{ t: 3, v: 999, ok: false }, // excluded
|
|
77
|
+
{ t: 4, v: 30, ok: true },
|
|
78
|
+
];
|
|
79
|
+
const out = rollingWindow(data, {
|
|
80
|
+
x: (d) => d.t,
|
|
81
|
+
y: (d) => d.v,
|
|
82
|
+
window: { value: 10_000, unit: "domain" },
|
|
83
|
+
statistic: "mean",
|
|
84
|
+
filter: (d) => d.ok === true,
|
|
85
|
+
});
|
|
86
|
+
// Only 3 accepted rows emitted.
|
|
87
|
+
expect(out).toHaveLength(3);
|
|
88
|
+
// Mean is over {10, 20, 30} = 20 — filtered values not in window either.
|
|
89
|
+
for (const p of out) expect(p.y).toBeCloseTo(20, 9);
|
|
90
|
+
// sourceIndex points back to accepted rows only.
|
|
91
|
+
expect(out.map((p) => p.sourceIndex).sort((a, b) => a - b)).toEqual([0, 2, 4]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("Date-typed x axis is coerced to millis and produces a numeric output x", () => {
|
|
95
|
+
const start = new Date("2025-01-01T00:00:00Z").getTime();
|
|
96
|
+
const day = 24 * 3600_000;
|
|
97
|
+
const data = [
|
|
98
|
+
{ t: new Date(start), v: 1 },
|
|
99
|
+
{ t: new Date(start + day), v: 2 },
|
|
100
|
+
{ t: new Date(start + 2 * day), v: 3 },
|
|
101
|
+
];
|
|
102
|
+
const out = rollingWindow(data, {
|
|
103
|
+
x: (d) => d.t,
|
|
104
|
+
y: (d) => d.v,
|
|
105
|
+
window: { value: day, unit: "domain" },
|
|
106
|
+
statistic: "mean",
|
|
107
|
+
});
|
|
108
|
+
expect(out).toHaveLength(3);
|
|
109
|
+
expect(typeof out[0]!.x).toBe("number");
|
|
110
|
+
expect(out[0]!.x).toBe(start);
|
|
111
|
+
expect(out[2]!.x).toBe(start + 2 * day);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("count-window picks k nearest items by axis", () => {
|
|
115
|
+
const data = rows([10, 20, 30, 40, 50]);
|
|
116
|
+
// k = 3 centered at i=2 → indices 1..3 → {20, 30, 40} → mean 30.
|
|
117
|
+
const out = rollingWindow(data, {
|
|
118
|
+
x: (d) => d.t,
|
|
119
|
+
y: (d) => d.v,
|
|
120
|
+
window: { value: 3, unit: "count" },
|
|
121
|
+
statistic: "mean",
|
|
122
|
+
});
|
|
123
|
+
expect(out[2]!.y).toBeCloseTo(30, 9);
|
|
124
|
+
expect(out[2]!.count).toBe(3);
|
|
125
|
+
// Edge i=0: clipped window is {10, 20, 30} → mean 20.
|
|
126
|
+
expect(out[0]!.y).toBeCloseTo(20, 9);
|
|
127
|
+
expect(out[0]!.count).toBe(3);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("quantile statistic respects the requested p", () => {
|
|
131
|
+
const data = rows([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
|
|
132
|
+
const out = rollingWindow(data, {
|
|
133
|
+
x: (d) => d.t,
|
|
134
|
+
y: (d) => d.v,
|
|
135
|
+
window: { value: 1000, unit: "domain" }, // covers all
|
|
136
|
+
statistic: { kind: "quantile", p: 0.9 },
|
|
137
|
+
});
|
|
138
|
+
// 90th percentile of 1..10 (type-7) = 9.1.
|
|
139
|
+
expect(out[0]!.y).toBeCloseTo(9.1, 6);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("stddev statistic matches sample stddev", () => {
|
|
143
|
+
const data = rows([2, 4, 4, 4, 5, 5, 7, 9]);
|
|
144
|
+
const out = rollingWindow(data, {
|
|
145
|
+
x: (d) => d.t,
|
|
146
|
+
y: (d) => d.v,
|
|
147
|
+
window: { value: 1000, unit: "domain" },
|
|
148
|
+
statistic: "stddev",
|
|
149
|
+
});
|
|
150
|
+
// Sample stddev of [2,4,4,4,5,5,7,9] ≈ 2.138.
|
|
151
|
+
expect(out[0]!.y).toBeCloseTo(2.138, 2);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("custom statistic function receives window values and items", () => {
|
|
155
|
+
const data = rows([1, 2, 3, 4, 5]);
|
|
156
|
+
let lastN = -1;
|
|
157
|
+
const out = rollingWindow(data, {
|
|
158
|
+
x: (d) => d.t,
|
|
159
|
+
y: (d) => d.v,
|
|
160
|
+
window: 3,
|
|
161
|
+
statistic: (values, items) => {
|
|
162
|
+
lastN = items.length;
|
|
163
|
+
// Custom: range = max - min.
|
|
164
|
+
let lo = Infinity;
|
|
165
|
+
let hi = -Infinity;
|
|
166
|
+
for (const v of values) {
|
|
167
|
+
if (v < lo) lo = v;
|
|
168
|
+
if (v > hi) hi = v;
|
|
169
|
+
}
|
|
170
|
+
return hi - lo;
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
expect(lastN).toBeGreaterThan(0);
|
|
174
|
+
// Interior 3-window over consecutive integers → range 2.
|
|
175
|
+
expect(out[2]!.y).toBe(2);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("axis='y' slides on y and aggregates x", () => {
|
|
179
|
+
// Same y values, distinct x — rolling over y should pull adjacent x's.
|
|
180
|
+
const data = [
|
|
181
|
+
{ t: 100, v: 1 },
|
|
182
|
+
{ t: 200, v: 2 },
|
|
183
|
+
{ t: 300, v: 3 },
|
|
184
|
+
{ t: 400, v: 4 },
|
|
185
|
+
];
|
|
186
|
+
const out = rollingWindow(data, {
|
|
187
|
+
x: (d) => d.t,
|
|
188
|
+
y: (d) => d.v,
|
|
189
|
+
window: 3,
|
|
190
|
+
statistic: "mean",
|
|
191
|
+
axis: "y",
|
|
192
|
+
});
|
|
193
|
+
// Sorted by y already. Interior point v=2: window over y={1,2,3} → mean(x)=200.
|
|
194
|
+
const interior = out.find((p) => p.y === 2)!;
|
|
195
|
+
expect(interior).toBeDefined();
|
|
196
|
+
expect(interior.x).toBeCloseTo(200, 6);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("output is sorted by slide axis (x by default)", () => {
|
|
200
|
+
// Unsorted input.
|
|
201
|
+
const data = rows([5, 1, 3, 2, 4], [50, 10, 30, 20, 40]);
|
|
202
|
+
const out = rollingWindow(data, {
|
|
203
|
+
x: (d) => d.t,
|
|
204
|
+
y: (d) => d.v,
|
|
205
|
+
window: 1,
|
|
206
|
+
});
|
|
207
|
+
const xs = out.map((p) => p.x);
|
|
208
|
+
for (let i = 1; i < xs.length; i++) {
|
|
209
|
+
expect(xs[i]!).toBeGreaterThanOrEqual(xs[i - 1]!);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("non-finite x or y rows are dropped", () => {
|
|
214
|
+
const data: Row[] = [
|
|
215
|
+
{ t: 0, v: 1 },
|
|
216
|
+
{ t: Number.NaN, v: 2 },
|
|
217
|
+
{ t: 2, v: Number.POSITIVE_INFINITY },
|
|
218
|
+
{ t: 3, v: 4 },
|
|
219
|
+
];
|
|
220
|
+
const out = rollingWindow(data, {
|
|
221
|
+
x: (d) => d.t,
|
|
222
|
+
y: (d) => d.v,
|
|
223
|
+
window: 1,
|
|
224
|
+
});
|
|
225
|
+
expect(out).toHaveLength(2);
|
|
226
|
+
expect(out.map((p) => p.sourceIndex).sort((a, b) => a - b)).toEqual([0, 3]);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("empty input → empty output", () => {
|
|
230
|
+
const out = rollingWindow([], {
|
|
231
|
+
x: (d: Row) => d.t,
|
|
232
|
+
y: (d: Row) => d.v,
|
|
233
|
+
window: 5,
|
|
234
|
+
});
|
|
235
|
+
expect(out).toEqual([]);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// rollingWindow — sliding-window statistic over an ordered numeric series
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Generic time-series / ordered-domain transform. The output is a new series
|
|
5
|
+
// of `{ x, y, sourceIndex, count }` points that can be fed straight into a
|
|
6
|
+
// `lineMark` / `pointMark` / `areaMark` to render a moving statistic
|
|
7
|
+
// (rolling mean, median, stddev, quantile, …) on top of the raw geom.
|
|
8
|
+
//
|
|
9
|
+
// Composition example:
|
|
10
|
+
// const smoothed = rollingWindow(rows, {
|
|
11
|
+
// x: (d) => d.ts,
|
|
12
|
+
// y: (d) => d.weightKg,
|
|
13
|
+
// window: { value: 7 * 24 * 3600_000, unit: 'domain' },
|
|
14
|
+
// statistic: 'mean',
|
|
15
|
+
// filter: (d) => d.accepted,
|
|
16
|
+
// });
|
|
17
|
+
// lineMark({ data: smoothed, x: (d) => d.x, y: (d) => d.y, curve: 'basis' });
|
|
18
|
+
//
|
|
19
|
+
// The transform is pure: callers re-run it when the `window` option changes.
|
|
20
|
+
// The chart doesn't rebuild scales/axes — only the dependent mark's data
|
|
21
|
+
// rebinds — so changing window is cheap from the consumer's perspective.
|
|
22
|
+
|
|
23
|
+
import { quantile } from "./index.ts";
|
|
24
|
+
|
|
25
|
+
export type RollingStatisticKind = "mean" | "median" | "min" | "max" | "sum" | "stddev";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Statistic to compute over each window. Strings cover the common cases;
|
|
29
|
+
* `{ kind: 'quantile', p }` picks an arbitrary quantile (0..1); a function
|
|
30
|
+
* receives the windowed `(values, items)` and returns any scalar — use it for
|
|
31
|
+
* custom stats (mode, trimmed mean, sign of slope, …).
|
|
32
|
+
*/
|
|
33
|
+
export type RollingStatistic<T> =
|
|
34
|
+
| RollingStatisticKind
|
|
35
|
+
| { kind: "quantile"; p: number }
|
|
36
|
+
| ((values: readonly number[], items: readonly T[]) => number);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Window size.
|
|
40
|
+
*
|
|
41
|
+
* - A bare number is interpreted as `unit: 'count'` (k nearest items by axis
|
|
42
|
+
* value, including self). Most predictable across axis types.
|
|
43
|
+
* - `{ value, unit: 'domain' }` — sliding window of `value` data-axis units,
|
|
44
|
+
* centered on the current item. For a time axis pass milliseconds (e.g.
|
|
45
|
+
* `7 * 24 * 3_600_000` for a 7-day window). For a numeric axis the value is
|
|
46
|
+
* in the axis's own units.
|
|
47
|
+
* - `{ value, unit: 'count' }` — explicit count-window form.
|
|
48
|
+
*/
|
|
49
|
+
export type RollingWindow = number | { value: number; unit: "domain" | "count" };
|
|
50
|
+
|
|
51
|
+
export type RollingAxis = "x" | "y";
|
|
52
|
+
|
|
53
|
+
export interface RollingWindowOptions<T> {
|
|
54
|
+
x: (datum: T, index: number) => number | Date;
|
|
55
|
+
y: (datum: T, index: number) => number;
|
|
56
|
+
window: RollingWindow;
|
|
57
|
+
/** Default `"mean"`. */
|
|
58
|
+
statistic?: RollingStatistic<T>;
|
|
59
|
+
/**
|
|
60
|
+
* Points failing the filter are excluded from every window *and* not
|
|
61
|
+
* emitted on the output series — they vanish from both the aggregation
|
|
62
|
+
* and the line that connects the results.
|
|
63
|
+
*/
|
|
64
|
+
filter?: (datum: T, index: number) => boolean;
|
|
65
|
+
/**
|
|
66
|
+
* Axis the window slides along. Default `"x"` (slide on x, aggregate y);
|
|
67
|
+
* `"y"` flips (slide on y, aggregate x). Output is sorted by the slide
|
|
68
|
+
* axis so a `lineMark` connecting the points draws monotonically.
|
|
69
|
+
*/
|
|
70
|
+
axis?: RollingAxis;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface RollingPoint {
|
|
74
|
+
x: number;
|
|
75
|
+
y: number;
|
|
76
|
+
/** Index into the original input `data` array. */
|
|
77
|
+
sourceIndex: number;
|
|
78
|
+
/** Number of points (after filter) that fell in this window. */
|
|
79
|
+
count: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface PreparedItem<T> {
|
|
83
|
+
axisVal: number;
|
|
84
|
+
otherVal: number;
|
|
85
|
+
item: T;
|
|
86
|
+
sourceIndex: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function coerceNumeric(value: number | Date): number {
|
|
90
|
+
return value instanceof Date ? value.getTime() : value;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function resolveWindow(window: RollingWindow): { value: number; unit: "domain" | "count" } {
|
|
94
|
+
if (typeof window === "number") return { value: window, unit: "count" };
|
|
95
|
+
return { value: window.value, unit: window.unit };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function computeStat<T>(
|
|
99
|
+
statistic: RollingStatistic<T>,
|
|
100
|
+
values: readonly number[],
|
|
101
|
+
items: readonly T[],
|
|
102
|
+
): number {
|
|
103
|
+
if (typeof statistic === "function") return statistic(values, items);
|
|
104
|
+
const n = values.length;
|
|
105
|
+
if (n === 0) return Number.NaN;
|
|
106
|
+
|
|
107
|
+
if (typeof statistic === "object") {
|
|
108
|
+
// Quantile — only object variant today.
|
|
109
|
+
const sorted = values.slice().sort((a, b) => a - b);
|
|
110
|
+
return quantile(sorted, statistic.p);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
switch (statistic) {
|
|
114
|
+
case "mean": {
|
|
115
|
+
let s = 0;
|
|
116
|
+
for (const v of values) s += v;
|
|
117
|
+
return s / n;
|
|
118
|
+
}
|
|
119
|
+
case "sum": {
|
|
120
|
+
let s = 0;
|
|
121
|
+
for (const v of values) s += v;
|
|
122
|
+
return s;
|
|
123
|
+
}
|
|
124
|
+
case "min": {
|
|
125
|
+
let m = values[0]!;
|
|
126
|
+
for (let i = 1; i < n; i++) {
|
|
127
|
+
const v = values[i]!;
|
|
128
|
+
if (v < m) m = v;
|
|
129
|
+
}
|
|
130
|
+
return m;
|
|
131
|
+
}
|
|
132
|
+
case "max": {
|
|
133
|
+
let m = values[0]!;
|
|
134
|
+
for (let i = 1; i < n; i++) {
|
|
135
|
+
const v = values[i]!;
|
|
136
|
+
if (v > m) m = v;
|
|
137
|
+
}
|
|
138
|
+
return m;
|
|
139
|
+
}
|
|
140
|
+
case "median": {
|
|
141
|
+
const sorted = values.slice().sort((a, b) => a - b);
|
|
142
|
+
return quantile(sorted, 0.5);
|
|
143
|
+
}
|
|
144
|
+
case "stddev": {
|
|
145
|
+
if (n < 2) return 0;
|
|
146
|
+
let s = 0;
|
|
147
|
+
for (const v of values) s += v;
|
|
148
|
+
const mean = s / n;
|
|
149
|
+
let sq = 0;
|
|
150
|
+
for (const v of values) {
|
|
151
|
+
const d = v - mean;
|
|
152
|
+
sq += d * d;
|
|
153
|
+
}
|
|
154
|
+
// Sample stddev (Bessel-corrected). Matches the convention used by
|
|
155
|
+
// `stats/index.ts`'s internal helpers.
|
|
156
|
+
return Math.sqrt(sq / (n - 1));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function rollingWindow<T>(
|
|
162
|
+
data: readonly T[],
|
|
163
|
+
options: RollingWindowOptions<T>,
|
|
164
|
+
): RollingPoint[] {
|
|
165
|
+
const axis: RollingAxis = options.axis ?? "x";
|
|
166
|
+
const statistic: RollingStatistic<T> = options.statistic ?? "mean";
|
|
167
|
+
const window = resolveWindow(options.window);
|
|
168
|
+
const filter = options.filter;
|
|
169
|
+
const xAcc = options.x;
|
|
170
|
+
const yAcc = options.y;
|
|
171
|
+
|
|
172
|
+
const prepared: PreparedItem<T>[] = [];
|
|
173
|
+
for (let i = 0; i < data.length; i++) {
|
|
174
|
+
const datum = data[i]!;
|
|
175
|
+
if (filter && !filter(datum, i)) continue;
|
|
176
|
+
const xv = coerceNumeric(xAcc(datum, i));
|
|
177
|
+
const yv = yAcc(datum, i);
|
|
178
|
+
if (!Number.isFinite(xv) || !Number.isFinite(yv)) continue;
|
|
179
|
+
const axisVal = axis === "x" ? xv : yv;
|
|
180
|
+
const otherVal = axis === "x" ? yv : xv;
|
|
181
|
+
prepared.push({ axisVal, otherVal, item: datum, sourceIndex: i });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const n = prepared.length;
|
|
185
|
+
if (n === 0) return [];
|
|
186
|
+
|
|
187
|
+
prepared.sort((a, b) => a.axisVal - b.axisVal);
|
|
188
|
+
|
|
189
|
+
const out: RollingPoint[] = Array.from({ length: n });
|
|
190
|
+
const tmpValues: number[] = [];
|
|
191
|
+
const tmpItems: T[] = [];
|
|
192
|
+
|
|
193
|
+
if (window.unit === "domain") {
|
|
194
|
+
// Centered domain window: include j where |axisVal[j] - axisVal[i]| ≤ half.
|
|
195
|
+
const half = Math.max(0, window.value / 2);
|
|
196
|
+
let lo = 0;
|
|
197
|
+
let hi = 0;
|
|
198
|
+
for (let i = 0; i < n; i++) {
|
|
199
|
+
const center = prepared[i]!.axisVal;
|
|
200
|
+
while (lo < n && prepared[lo]!.axisVal < center - half) lo++;
|
|
201
|
+
if (hi < lo) hi = lo;
|
|
202
|
+
while (hi < n && prepared[hi]!.axisVal <= center + half) hi++;
|
|
203
|
+
tmpValues.length = 0;
|
|
204
|
+
tmpItems.length = 0;
|
|
205
|
+
for (let j = lo; j < hi; j++) {
|
|
206
|
+
tmpValues.push(prepared[j]!.otherVal);
|
|
207
|
+
tmpItems.push(prepared[j]!.item);
|
|
208
|
+
}
|
|
209
|
+
const statVal = computeStat(statistic, tmpValues, tmpItems);
|
|
210
|
+
const cur = prepared[i]!;
|
|
211
|
+
const outX = axis === "x" ? cur.axisVal : statVal;
|
|
212
|
+
const outY = axis === "x" ? statVal : cur.axisVal;
|
|
213
|
+
out[i] = {
|
|
214
|
+
x: outX,
|
|
215
|
+
y: outY,
|
|
216
|
+
sourceIndex: cur.sourceIndex,
|
|
217
|
+
count: tmpValues.length,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
} else {
|
|
221
|
+
// Count window: k nearest items by axis distance, including self.
|
|
222
|
+
const k = Math.max(1, Math.floor(window.value));
|
|
223
|
+
for (let i = 0; i < n; i++) {
|
|
224
|
+
// Center k-window on i with two-sided clipping at the array edges.
|
|
225
|
+
let lo = i - Math.floor((k - 1) / 2);
|
|
226
|
+
let hi = lo + k;
|
|
227
|
+
if (lo < 0) {
|
|
228
|
+
hi -= lo;
|
|
229
|
+
lo = 0;
|
|
230
|
+
}
|
|
231
|
+
if (hi > n) {
|
|
232
|
+
const overflow = hi - n;
|
|
233
|
+
lo = Math.max(0, lo - overflow);
|
|
234
|
+
hi = n;
|
|
235
|
+
}
|
|
236
|
+
tmpValues.length = 0;
|
|
237
|
+
tmpItems.length = 0;
|
|
238
|
+
for (let j = lo; j < hi; j++) {
|
|
239
|
+
tmpValues.push(prepared[j]!.otherVal);
|
|
240
|
+
tmpItems.push(prepared[j]!.item);
|
|
241
|
+
}
|
|
242
|
+
const statVal = computeStat(statistic, tmpValues, tmpItems);
|
|
243
|
+
const cur = prepared[i]!;
|
|
244
|
+
const outX = axis === "x" ? cur.axisVal : statVal;
|
|
245
|
+
const outY = axis === "x" ? statVal : cur.axisVal;
|
|
246
|
+
out[i] = {
|
|
247
|
+
x: outX,
|
|
248
|
+
y: outY,
|
|
249
|
+
sourceIndex: cur.sourceIndex,
|
|
250
|
+
count: tmpValues.length,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return out;
|
|
256
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Test setup: stub window.matchMedia before any module (including insomni)
|
|
2
|
+
// evaluates it. jsdom does not implement matchMedia, but insomni/src/config.ts
|
|
3
|
+
// calls it at module-evaluation time to detect coarse-pointer devices.
|
|
4
|
+
if (typeof window !== "undefined" && !window.matchMedia) {
|
|
5
|
+
Object.defineProperty(window, "matchMedia", {
|
|
6
|
+
writable: true,
|
|
7
|
+
configurable: true,
|
|
8
|
+
value: (_query: string) => ({
|
|
9
|
+
matches: false,
|
|
10
|
+
media: _query,
|
|
11
|
+
onchange: null,
|
|
12
|
+
addListener: () => {},
|
|
13
|
+
removeListener: () => {},
|
|
14
|
+
addEventListener: () => {},
|
|
15
|
+
removeEventListener: () => {},
|
|
16
|
+
dispatchEvent: () => false,
|
|
17
|
+
}),
|
|
18
|
+
});
|
|
19
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { type AxisScale, type BandScale, type BandScaleOptions, type ContinuousScale, type DateDomain, type NumericDomain, type TimeScale } from "../scales.ts";
|
|
2
|
+
export interface ContinuousAxisConfig {
|
|
3
|
+
type: "linear" | "log" | "sqrt";
|
|
4
|
+
domain: NumericDomain;
|
|
5
|
+
}
|
|
6
|
+
export interface TimeAxisConfig {
|
|
7
|
+
type: "time";
|
|
8
|
+
domain: DateDomain;
|
|
9
|
+
}
|
|
10
|
+
export interface BandAxisConfig<T = unknown> extends BandScaleOptions {
|
|
11
|
+
type: "band";
|
|
12
|
+
domain: readonly T[];
|
|
13
|
+
}
|
|
14
|
+
export type ViewportAxisConfig<T = unknown> = ContinuousAxisConfig | TimeAxisConfig | BandAxisConfig<T>;
|
|
15
|
+
export type VisibleDomainInput = readonly [number, number] | readonly [Date, Date];
|
|
16
|
+
interface Transform {
|
|
17
|
+
to: (v: number) => number;
|
|
18
|
+
from: (v: number) => number;
|
|
19
|
+
}
|
|
20
|
+
export interface ContinuousAxisState {
|
|
21
|
+
kind: "continuous";
|
|
22
|
+
type: "linear" | "log" | "sqrt" | "time";
|
|
23
|
+
transform: Transform;
|
|
24
|
+
t0: number;
|
|
25
|
+
t1: number;
|
|
26
|
+
baseT0: number;
|
|
27
|
+
baseT1: number;
|
|
28
|
+
r0: number;
|
|
29
|
+
r1: number;
|
|
30
|
+
/**
|
|
31
|
+
* Memoized scale instance for the current (t0, t1, r0, r1, type, transform)
|
|
32
|
+
* tuple. Null after construction and after any mutation; populated lazily by
|
|
33
|
+
* `buildScale` / `applyAxis`. Pan/zoom/setVisibleDomain/clampAxisToBase/
|
|
34
|
+
* resetAxis/setAxisRange/copyContinuous all clear it via `invalidateScale`.
|
|
35
|
+
*/
|
|
36
|
+
scaleCache: ContinuousScale | TimeScale | null;
|
|
37
|
+
}
|
|
38
|
+
export interface BandAxisState<T> {
|
|
39
|
+
kind: "band";
|
|
40
|
+
domain: readonly T[];
|
|
41
|
+
options: BandScaleOptions;
|
|
42
|
+
r0: number;
|
|
43
|
+
r1: number;
|
|
44
|
+
/** See `ContinuousAxisState.scaleCache`. */
|
|
45
|
+
scaleCache: BandScale<T> | null;
|
|
46
|
+
}
|
|
47
|
+
export type AxisState<T> = ContinuousAxisState | BandAxisState<T>;
|
|
48
|
+
export declare function createAxisState<T>(config: ViewportAxisConfig<T>, r0: number, r1: number): AxisState<T>;
|
|
49
|
+
export declare function buildScale<T>(state: AxisState<T>): AxisScale<T>;
|
|
50
|
+
export declare function panAxis(axis: AxisState<unknown>, dPx: number): boolean;
|
|
51
|
+
export declare function setAxisVisibleDomain(axis: AxisState<unknown>, domain: VisibleDomainInput, minZoom: number, maxZoom: number): boolean;
|
|
52
|
+
export declare function zoomAxis(axis: AxisState<unknown>, anchorPx: number, factor: number, minZoom: number, maxZoom: number): boolean;
|
|
53
|
+
export declare function clampAxisToBase(axis: AxisState<unknown>, overshoot: number, drift: number): boolean;
|
|
54
|
+
export declare function setAxisRange(axis: AxisState<unknown>, r0: number, r1: number): void;
|
|
55
|
+
export declare function resetAxis(axis: AxisState<unknown>): boolean;
|
|
56
|
+
export declare function readVisibleDomain(axis: AxisState<unknown>): unknown;
|
|
57
|
+
export declare function invertAxis(axis: AxisState<unknown>, localPx: number): unknown;
|
|
58
|
+
export declare function applyAxis(axis: AxisState<unknown>, value: unknown): number;
|
|
59
|
+
export interface LinearAxisFn {
|
|
60
|
+
readonly t0: number;
|
|
61
|
+
readonly t1: number;
|
|
62
|
+
readonly r0: number;
|
|
63
|
+
readonly r1: number;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* If the axis is a linear, continuous, non-time axis, return its current
|
|
67
|
+
* (t0, t1, r0, r1) tuple. Otherwise return null. Used by callers that want
|
|
68
|
+
* to hoist the linear projection out of a hot loop.
|
|
69
|
+
*/
|
|
70
|
+
export declare function asLinearAxisFn(axis: AxisState<unknown>): LinearAxisFn | null;
|
|
71
|
+
export declare function copyContinuous(source: AxisState<unknown>, target: AxisState<unknown>): void;
|
|
72
|
+
export {};
|