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,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>;
|