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,345 @@
|
|
|
1
|
+
import { describe, expect, test } from "vite-plus/test";
|
|
2
|
+
|
|
3
|
+
import { viewportFrame } from "insomni";
|
|
4
|
+
import {
|
|
5
|
+
createRangePresets,
|
|
6
|
+
linearPreset,
|
|
7
|
+
logPreset,
|
|
8
|
+
timePreset,
|
|
9
|
+
type RangePreset,
|
|
10
|
+
} from "./range-presets.ts";
|
|
11
|
+
import { createDataViewport } from "./viewport.ts";
|
|
12
|
+
|
|
13
|
+
const FROZEN_NOW = new Date("2026-05-20T12:00:00Z").getTime();
|
|
14
|
+
const now = () => FROZEN_NOW;
|
|
15
|
+
|
|
16
|
+
const linearViewport = (domain: [number, number] = [0, 100]) =>
|
|
17
|
+
createDataViewport({
|
|
18
|
+
frame: viewportFrame(400, 300),
|
|
19
|
+
x: { type: "linear", domain },
|
|
20
|
+
y: { type: "linear", domain: [0, 100] },
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const timeViewport = (domain: [Date, Date]) =>
|
|
24
|
+
createDataViewport({
|
|
25
|
+
frame: viewportFrame(400, 300),
|
|
26
|
+
x: { type: "time", domain },
|
|
27
|
+
y: { type: "linear", domain: [0, 100] },
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("createRangePresets — preset resolution", () => {
|
|
31
|
+
test("time presets resolve against a frozen `now`", () => {
|
|
32
|
+
const dataDomain: [Date, Date] = [new Date("2024-01-01T00:00:00Z"), new Date(FROZEN_NOW)];
|
|
33
|
+
const vp = timeViewport(dataDomain);
|
|
34
|
+
const ctl = createRangePresets({
|
|
35
|
+
viewport: vp,
|
|
36
|
+
axis: "x",
|
|
37
|
+
presets: [timePreset("24H"), timePreset("7D"), timePreset("1M"), timePreset("MAX")],
|
|
38
|
+
dataDomain,
|
|
39
|
+
now,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
ctl.setActive("24H");
|
|
43
|
+
const [s24, e24] = vp.visibleXDomain as readonly [Date, Date];
|
|
44
|
+
expect(e24.getTime()).toBe(FROZEN_NOW);
|
|
45
|
+
expect(s24.getTime()).toBe(FROZEN_NOW - 24 * 3600_000);
|
|
46
|
+
|
|
47
|
+
ctl.setActive("7D");
|
|
48
|
+
const [s7] = vp.visibleXDomain as readonly [Date, Date];
|
|
49
|
+
expect(s7.getTime()).toBe(FROZEN_NOW - 7 * 24 * 3600_000);
|
|
50
|
+
|
|
51
|
+
ctl.setActive("1M");
|
|
52
|
+
const [s1m] = vp.visibleXDomain as readonly [Date, Date];
|
|
53
|
+
expect(s1m.getTime()).toBe(FROZEN_NOW - 30 * 24 * 3600_000);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("YTD spans January 1 to now", () => {
|
|
57
|
+
const dataDomain: [Date, Date] = [new Date("2020-01-01T00:00:00Z"), new Date(FROZEN_NOW)];
|
|
58
|
+
const vp = timeViewport(dataDomain);
|
|
59
|
+
const ctl = createRangePresets({
|
|
60
|
+
viewport: vp,
|
|
61
|
+
axis: "x",
|
|
62
|
+
presets: [timePreset("YTD")],
|
|
63
|
+
dataDomain,
|
|
64
|
+
now,
|
|
65
|
+
});
|
|
66
|
+
ctl.setActive("YTD");
|
|
67
|
+
const [start, end] = vp.visibleXDomain as readonly [Date, Date];
|
|
68
|
+
expect(end.getTime()).toBe(FROZEN_NOW);
|
|
69
|
+
expect(start.getUTCFullYear()).toBe(2026);
|
|
70
|
+
expect(start.getUTCMonth()).toBe(0);
|
|
71
|
+
expect(start.getUTCDate()).toBe(1);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("MAX resets to the full data extent", () => {
|
|
75
|
+
const dataDomain: [Date, Date] = [new Date("2024-01-01T00:00:00Z"), new Date(FROZEN_NOW)];
|
|
76
|
+
const vp = timeViewport(dataDomain);
|
|
77
|
+
vp.setVisibleDomain({
|
|
78
|
+
x: [new Date("2025-06-01T00:00:00Z"), new Date("2025-09-01T00:00:00Z")] as const,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const ctl = createRangePresets({
|
|
82
|
+
viewport: vp,
|
|
83
|
+
axis: "x",
|
|
84
|
+
presets: [timePreset("MAX")],
|
|
85
|
+
dataDomain,
|
|
86
|
+
now,
|
|
87
|
+
});
|
|
88
|
+
ctl.setActive("MAX");
|
|
89
|
+
const [start, end] = vp.visibleXDomain as readonly [Date, Date];
|
|
90
|
+
expect(start.getTime()).toBe(dataDomain[0].getTime());
|
|
91
|
+
expect(end.getTime()).toBe(dataDomain[1].getTime());
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("linearPreset shows the last `span` units", () => {
|
|
95
|
+
const vp = linearViewport([0, 100]);
|
|
96
|
+
const ctl = createRangePresets({
|
|
97
|
+
viewport: vp,
|
|
98
|
+
axis: "x",
|
|
99
|
+
presets: [linearPreset({ key: "L20", label: "Last 20", span: 20 })],
|
|
100
|
+
dataDomain: [0, 100],
|
|
101
|
+
});
|
|
102
|
+
ctl.setActive("L20");
|
|
103
|
+
expect(vp.visibleXDomain).toEqual([80, 100]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("linearPreset with Infinity clamps to data extent", () => {
|
|
107
|
+
const vp = linearViewport([0, 100]);
|
|
108
|
+
vp.setVisibleDomain({ x: [25, 50] });
|
|
109
|
+
const ctl = createRangePresets({
|
|
110
|
+
viewport: vp,
|
|
111
|
+
axis: "x",
|
|
112
|
+
presets: [linearPreset({ key: "max", label: "MAX", span: Infinity })],
|
|
113
|
+
dataDomain: [0, 100],
|
|
114
|
+
});
|
|
115
|
+
ctl.setActive("max");
|
|
116
|
+
expect(vp.visibleXDomain).toEqual([0, 100]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("logPreset shows the last N decades, clamped to data lower bound", () => {
|
|
120
|
+
const vp = linearViewport([1, 1000]);
|
|
121
|
+
const ctl = createRangePresets({
|
|
122
|
+
viewport: vp,
|
|
123
|
+
axis: "x",
|
|
124
|
+
presets: [
|
|
125
|
+
logPreset({ key: "1d", label: "1 decade", decades: 1 }),
|
|
126
|
+
logPreset({ key: "5d", label: "5 decades", decades: 5 }),
|
|
127
|
+
],
|
|
128
|
+
dataDomain: [1, 1000],
|
|
129
|
+
});
|
|
130
|
+
ctl.setActive("1d");
|
|
131
|
+
expect(vp.visibleXDomain).toEqual([100, 1000]);
|
|
132
|
+
|
|
133
|
+
ctl.setActive("5d");
|
|
134
|
+
// 1000 / 10^5 = 0.01, clamped to data lower bound = 1
|
|
135
|
+
expect(vp.visibleXDomain).toEqual([1, 1000]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("custom resolver preset receives ctx and applies returned domain", () => {
|
|
139
|
+
const vp = linearViewport([0, 100]);
|
|
140
|
+
const seen: Array<{ dataDomain: unknown; now: number }> = [];
|
|
141
|
+
const custom: RangePreset = {
|
|
142
|
+
key: "half",
|
|
143
|
+
label: "Half",
|
|
144
|
+
resolve: (ctx) => {
|
|
145
|
+
seen.push({ dataDomain: ctx.dataDomain, now: ctx.now });
|
|
146
|
+
const [lo, hi] = ctx.dataDomain as readonly [number, number];
|
|
147
|
+
return [lo + (hi - lo) / 2, hi] as const;
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
const ctl = createRangePresets({
|
|
151
|
+
viewport: vp,
|
|
152
|
+
axis: "x",
|
|
153
|
+
presets: [custom],
|
|
154
|
+
dataDomain: [0, 100],
|
|
155
|
+
now,
|
|
156
|
+
});
|
|
157
|
+
ctl.setActive("half");
|
|
158
|
+
expect(vp.visibleXDomain).toEqual([50, 100]);
|
|
159
|
+
expect(seen).toEqual([{ dataDomain: [0, 100], now: FROZEN_NOW }]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("preset returning null leaves the viewport untouched and clears active", () => {
|
|
163
|
+
const vp = linearViewport([0, 100]);
|
|
164
|
+
vp.setVisibleDomain({ x: [10, 20] });
|
|
165
|
+
const declining: RangePreset = {
|
|
166
|
+
key: "nope",
|
|
167
|
+
label: "Nope",
|
|
168
|
+
resolve: () => null,
|
|
169
|
+
};
|
|
170
|
+
const ctl = createRangePresets({
|
|
171
|
+
viewport: vp,
|
|
172
|
+
axis: "x",
|
|
173
|
+
presets: [declining],
|
|
174
|
+
});
|
|
175
|
+
ctl.setActive("nope");
|
|
176
|
+
expect(vp.visibleXDomain).toEqual([10, 20]);
|
|
177
|
+
expect(ctl.getActive()).toBeNull();
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe("createRangePresets — active state + manual-change detection", () => {
|
|
182
|
+
test("setActive(null) clears active without touching the viewport", () => {
|
|
183
|
+
const vp = linearViewport([0, 100]);
|
|
184
|
+
vp.setVisibleDomain({ x: [30, 70] });
|
|
185
|
+
const ctl = createRangePresets({
|
|
186
|
+
viewport: vp,
|
|
187
|
+
axis: "x",
|
|
188
|
+
presets: [linearPreset({ key: "L20", label: "20", span: 20 })],
|
|
189
|
+
dataDomain: [0, 100],
|
|
190
|
+
});
|
|
191
|
+
ctl.setActive("L20");
|
|
192
|
+
expect(ctl.getActive()).toBe("L20");
|
|
193
|
+
expect(vp.visibleXDomain).toEqual([80, 100]);
|
|
194
|
+
|
|
195
|
+
ctl.setActive(null);
|
|
196
|
+
expect(ctl.getActive()).toBeNull();
|
|
197
|
+
// Viewport stays where the preset left it.
|
|
198
|
+
expect(vp.visibleXDomain).toEqual([80, 100]);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("manual panBy clears active to null", () => {
|
|
202
|
+
const vp = linearViewport([0, 100]);
|
|
203
|
+
const ctl = createRangePresets({
|
|
204
|
+
viewport: vp,
|
|
205
|
+
axis: "x",
|
|
206
|
+
presets: [linearPreset({ key: "L20", label: "20", span: 20 })],
|
|
207
|
+
dataDomain: [0, 100],
|
|
208
|
+
});
|
|
209
|
+
ctl.setActive("L20");
|
|
210
|
+
expect(ctl.getActive()).toBe("L20");
|
|
211
|
+
|
|
212
|
+
vp.panBy(10, 0);
|
|
213
|
+
expect(ctl.getActive()).toBeNull();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("manual setVisibleDomain by consumer clears active", () => {
|
|
217
|
+
const vp = linearViewport([0, 100]);
|
|
218
|
+
const ctl = createRangePresets({
|
|
219
|
+
viewport: vp,
|
|
220
|
+
axis: "x",
|
|
221
|
+
presets: [linearPreset({ key: "L20", label: "20", span: 20 })],
|
|
222
|
+
dataDomain: [0, 100],
|
|
223
|
+
});
|
|
224
|
+
ctl.setActive("L20");
|
|
225
|
+
expect(ctl.getActive()).toBe("L20");
|
|
226
|
+
|
|
227
|
+
vp.setVisibleDomain({ x: [40, 60] });
|
|
228
|
+
expect(ctl.getActive()).toBeNull();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("y-axis change does not clear x-axis preset", () => {
|
|
232
|
+
const vp = linearViewport([0, 100]);
|
|
233
|
+
const ctl = createRangePresets({
|
|
234
|
+
viewport: vp,
|
|
235
|
+
axis: "x",
|
|
236
|
+
presets: [linearPreset({ key: "L20", label: "20", span: 20 })],
|
|
237
|
+
dataDomain: [0, 100],
|
|
238
|
+
});
|
|
239
|
+
ctl.setActive("L20");
|
|
240
|
+
expect(ctl.getActive()).toBe("L20");
|
|
241
|
+
|
|
242
|
+
vp.setVisibleDomain({ y: [10, 50] });
|
|
243
|
+
expect(ctl.getActive()).toBe("L20");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("re-activating the same preset is idempotent (no subscriber call)", () => {
|
|
247
|
+
const vp = linearViewport([0, 100]);
|
|
248
|
+
const ctl = createRangePresets({
|
|
249
|
+
viewport: vp,
|
|
250
|
+
axis: "x",
|
|
251
|
+
presets: [linearPreset({ key: "L20", label: "20", span: 20 })],
|
|
252
|
+
dataDomain: [0, 100],
|
|
253
|
+
});
|
|
254
|
+
const calls: Array<string | null> = [];
|
|
255
|
+
ctl.subscribe((k) => calls.push(k));
|
|
256
|
+
ctl.setActive("L20");
|
|
257
|
+
ctl.setActive("L20");
|
|
258
|
+
expect(calls).toEqual(["L20"]);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("subscribe fires on activate and on manual-change reset", () => {
|
|
262
|
+
const vp = linearViewport([0, 100]);
|
|
263
|
+
const ctl = createRangePresets({
|
|
264
|
+
viewport: vp,
|
|
265
|
+
axis: "x",
|
|
266
|
+
presets: [linearPreset({ key: "L20", label: "20", span: 20 })],
|
|
267
|
+
dataDomain: [0, 100],
|
|
268
|
+
});
|
|
269
|
+
const calls: Array<string | null> = [];
|
|
270
|
+
const unsub = ctl.subscribe((k) => calls.push(k));
|
|
271
|
+
|
|
272
|
+
ctl.setActive("L20");
|
|
273
|
+
vp.panBy(5, 0);
|
|
274
|
+
expect(calls).toEqual(["L20", null]);
|
|
275
|
+
|
|
276
|
+
unsub();
|
|
277
|
+
ctl.setActive("L20");
|
|
278
|
+
expect(calls).toEqual(["L20", null]);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("dispose unsubscribes from viewport and drops subscribers", () => {
|
|
282
|
+
const vp = linearViewport([0, 100]);
|
|
283
|
+
const ctl = createRangePresets({
|
|
284
|
+
viewport: vp,
|
|
285
|
+
axis: "x",
|
|
286
|
+
presets: [linearPreset({ key: "L20", label: "20", span: 20 })],
|
|
287
|
+
dataDomain: [0, 100],
|
|
288
|
+
});
|
|
289
|
+
const calls: Array<string | null> = [];
|
|
290
|
+
ctl.subscribe((k) => calls.push(k));
|
|
291
|
+
ctl.setActive("L20");
|
|
292
|
+
|
|
293
|
+
ctl.dispose();
|
|
294
|
+
// After dispose, a manual pan should not produce any more subscriber
|
|
295
|
+
// calls (active-state diff is also disabled because we unsubscribed).
|
|
296
|
+
vp.panBy(10, 0);
|
|
297
|
+
expect(calls).toEqual(["L20"]);
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
describe("createRangePresets — dataDomain fallback", () => {
|
|
302
|
+
test("snapshots viewport's domain when dataDomain is omitted", () => {
|
|
303
|
+
const vp = linearViewport([0, 100]);
|
|
304
|
+
const ctl = createRangePresets({
|
|
305
|
+
viewport: vp,
|
|
306
|
+
axis: "x",
|
|
307
|
+
presets: [linearPreset({ key: "max", label: "MAX", span: Infinity })],
|
|
308
|
+
// dataDomain omitted — should snapshot [0, 100] at construction.
|
|
309
|
+
});
|
|
310
|
+
vp.setVisibleDomain({ x: [25, 50] });
|
|
311
|
+
ctl.setActive("max");
|
|
312
|
+
expect(vp.visibleXDomain).toEqual([0, 100]);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("dataDomain getter is re-read on each setActive", () => {
|
|
316
|
+
const vp = linearViewport([0, 100]);
|
|
317
|
+
let domain: [number, number] = [0, 100];
|
|
318
|
+
const ctl = createRangePresets({
|
|
319
|
+
viewport: vp,
|
|
320
|
+
axis: "x",
|
|
321
|
+
presets: [linearPreset({ key: "max", label: "MAX", span: Infinity })],
|
|
322
|
+
dataDomain: () => domain,
|
|
323
|
+
});
|
|
324
|
+
ctl.setActive("max");
|
|
325
|
+
expect(vp.visibleXDomain).toEqual([0, 100]);
|
|
326
|
+
|
|
327
|
+
// Simulate data appended — extent grows; next activation should reflect it.
|
|
328
|
+
domain = [0, 200];
|
|
329
|
+
ctl.setActive("max");
|
|
330
|
+
expect(vp.visibleXDomain).toEqual([0, 200]);
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
describe("createRangePresets — error handling", () => {
|
|
335
|
+
test("unknown key throws", () => {
|
|
336
|
+
const vp = linearViewport([0, 100]);
|
|
337
|
+
const ctl = createRangePresets({
|
|
338
|
+
viewport: vp,
|
|
339
|
+
axis: "x",
|
|
340
|
+
presets: [linearPreset({ key: "L20", label: "20", span: 20 })],
|
|
341
|
+
dataDomain: [0, 100],
|
|
342
|
+
});
|
|
343
|
+
expect(() => ctl.setActive("missing")).toThrow(/unknown preset key/);
|
|
344
|
+
});
|
|
345
|
+
});
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// createRangePresets — headless axis-range preset controller
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Generic across any continuous axis: time, linear, log, score thresholds,
|
|
5
|
+
// spatial extents. Given a `DataViewport` and a list of `RangePreset`s, the
|
|
6
|
+
// controller exposes `{ presets, setActive, getActive, subscribe, dispose }`.
|
|
7
|
+
//
|
|
8
|
+
// "Headless" means the library never touches the DOM — consumers render their
|
|
9
|
+
// own chips/buttons/menu and call `setActive(key)` on click. The library owns
|
|
10
|
+
// preset resolution + active-state diffing.
|
|
11
|
+
//
|
|
12
|
+
// Active-state goes to `null` ("custom") whenever the viewport's visible
|
|
13
|
+
// domain on the controlled axis no longer matches the resolved domain the
|
|
14
|
+
// last `setActive` applied. This makes manual pan / zoom / programmatic
|
|
15
|
+
// `setVisibleDomain` calls correctly clear the active chip without the
|
|
16
|
+
// consumer wiring anything else.
|
|
17
|
+
|
|
18
|
+
import type { DateDomain, NumericDomain } from "./scales.ts";
|
|
19
|
+
import type { DataViewport } from "./viewport.ts";
|
|
20
|
+
import type { VisibleDomainInput } from "./viewport/axis-state.ts";
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Types
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
/** Axis the controller drives. */
|
|
27
|
+
export type RangePresetAxis = "x" | "y";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Resolved domain a preset applies to the viewport. Numeric pair for linear /
|
|
31
|
+
* log / score axes; Date pair for time axes.
|
|
32
|
+
*/
|
|
33
|
+
export type RangePresetDomain = NumericDomain | DateDomain;
|
|
34
|
+
|
|
35
|
+
/** Optional getter form for the data-extent fallback. */
|
|
36
|
+
export type RangePresetDataDomain = RangePresetDomain | (() => RangePresetDomain);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Context passed to a preset's `resolve` function. `dataDomain` is the full
|
|
40
|
+
* extent of the underlying data (either provided by the consumer at
|
|
41
|
+
* construction time or, if omitted, the viewport's current visible domain
|
|
42
|
+
* sampled at construction). `now` is the current time (epoch ms) — relevant
|
|
43
|
+
* for time presets; helpers like `linearPreset` ignore it.
|
|
44
|
+
*/
|
|
45
|
+
export interface RangePresetContext {
|
|
46
|
+
dataDomain: RangePresetDomain;
|
|
47
|
+
now: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A single preset entry. `resolve` returns the domain the viewport should be
|
|
52
|
+
* set to when the user activates this key. Return `null` to express "this
|
|
53
|
+
* preset has no valid domain given the current data" — the controller will
|
|
54
|
+
* leave the viewport untouched and not mark itself active.
|
|
55
|
+
*/
|
|
56
|
+
export interface RangePreset {
|
|
57
|
+
key: string;
|
|
58
|
+
label: string;
|
|
59
|
+
resolve: (ctx: RangePresetContext) => RangePresetDomain | null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface RangePresetsOptions {
|
|
63
|
+
/**
|
|
64
|
+
* Any `DataViewport` regardless of axis-value type — accepts the
|
|
65
|
+
* `DataViewport<number, number>` returned by `MountedPlot.viewport` as
|
|
66
|
+
* well as the unparameterized form. The controller only reads visible
|
|
67
|
+
* domains and applies new ones through the viewport's continuous-axis
|
|
68
|
+
* API, so X/Y value types are not constrained at this layer.
|
|
69
|
+
*/
|
|
70
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
71
|
+
viewport: DataViewport<any, any>;
|
|
72
|
+
axis: RangePresetAxis;
|
|
73
|
+
presets: readonly RangePreset[];
|
|
74
|
+
/**
|
|
75
|
+
* Full data extent. Either a fixed `[min, max]` (numbers or Dates) or a
|
|
76
|
+
* getter that re-reads on each preset evaluation. If omitted, the
|
|
77
|
+
* controller snapshots the viewport's current visible domain at
|
|
78
|
+
* construction time and reuses it as the data domain.
|
|
79
|
+
*/
|
|
80
|
+
dataDomain?: RangePresetDataDomain;
|
|
81
|
+
/** Defaults to `Date.now`. Re-evaluated on every `setActive`. */
|
|
82
|
+
now?: () => number;
|
|
83
|
+
/**
|
|
84
|
+
* Relative tolerance for the diff-against-snapshot custom check. A change
|
|
85
|
+
* smaller than `epsilon * max(|a|, |b|, 1)` on either endpoint is ignored.
|
|
86
|
+
* Defaults to `1e-6` — tight enough to detect a one-pixel pan, loose
|
|
87
|
+
* enough to absorb float round-trips through the axis scale.
|
|
88
|
+
*/
|
|
89
|
+
epsilon?: number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Subscriber receives the new active key, or `null` for "custom". */
|
|
93
|
+
export type RangePresetsSubscriber = (active: string | null) => void;
|
|
94
|
+
|
|
95
|
+
export interface RangePresetsController {
|
|
96
|
+
readonly presets: readonly RangePreset[];
|
|
97
|
+
/**
|
|
98
|
+
* Activate a preset by key. Re-resolves the preset, applies the resulting
|
|
99
|
+
* domain to the viewport, and remembers the resolved snapshot so future
|
|
100
|
+
* `onChange` events can detect manual changes. Passing `null` clears the
|
|
101
|
+
* active state without touching the viewport.
|
|
102
|
+
*/
|
|
103
|
+
setActive(key: string | null): void;
|
|
104
|
+
/** Currently active preset key, or `null` if user has panned/zoomed manually. */
|
|
105
|
+
getActive(): string | null;
|
|
106
|
+
/** Subscribe to active-state changes. Returns an unsubscribe fn. */
|
|
107
|
+
subscribe(fn: RangePresetsSubscriber): () => void;
|
|
108
|
+
dispose(): void;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// createRangePresets
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
export function createRangePresets(opts: RangePresetsOptions): RangePresetsController {
|
|
116
|
+
const viewport = opts.viewport;
|
|
117
|
+
const axis = opts.axis;
|
|
118
|
+
const presets = opts.presets;
|
|
119
|
+
const nowFn = opts.now ?? Date.now;
|
|
120
|
+
const epsilon = opts.epsilon ?? 1e-6;
|
|
121
|
+
|
|
122
|
+
const presetByKey = new Map(presets.map((p) => [p.key, p] as const));
|
|
123
|
+
const resolveDataDomain = (): RangePresetDomain => {
|
|
124
|
+
if (typeof opts.dataDomain === "function")
|
|
125
|
+
return (opts.dataDomain as () => RangePresetDomain)();
|
|
126
|
+
if (opts.dataDomain) return opts.dataDomain;
|
|
127
|
+
return readAxisDomain(viewport, axis);
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// Snapshot the viewport's domain at construction to use as a fallback
|
|
131
|
+
// dataDomain, so a caller that omits `dataDomain` still gets a stable
|
|
132
|
+
// extent (rather than re-reading the current — possibly already panned —
|
|
133
|
+
// visible domain on every preset evaluation).
|
|
134
|
+
const snapshotDataDomain = opts.dataDomain === undefined ? readAxisDomain(viewport, axis) : null;
|
|
135
|
+
const getDataDomain = (): RangePresetDomain => snapshotDataDomain ?? resolveDataDomain();
|
|
136
|
+
|
|
137
|
+
let activeKey: string | null = null;
|
|
138
|
+
// Last domain the controller applied via setActive — used to detect when a
|
|
139
|
+
// subsequent viewport change came from outside (pan/zoom/setVisibleDomain
|
|
140
|
+
// by the consumer) and flip active back to `null`.
|
|
141
|
+
let lastAppliedDomain: RangePresetDomain | null = null;
|
|
142
|
+
let applying = 0;
|
|
143
|
+
const subscribers = new Set<RangePresetsSubscriber>();
|
|
144
|
+
|
|
145
|
+
const setActiveInternal = (next: string | null): void => {
|
|
146
|
+
if (next === activeKey) return;
|
|
147
|
+
activeKey = next;
|
|
148
|
+
for (const fn of subscribers) fn(next);
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const unsubViewport = viewport.onChange(() => {
|
|
152
|
+
if (applying > 0) return;
|
|
153
|
+
if (activeKey === null || lastAppliedDomain === null) return;
|
|
154
|
+
const current = readAxisDomain(viewport, axis);
|
|
155
|
+
if (!domainsEqual(current, lastAppliedDomain, epsilon)) {
|
|
156
|
+
lastAppliedDomain = null;
|
|
157
|
+
setActiveInternal(null);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
presets,
|
|
163
|
+
setActive(key) {
|
|
164
|
+
if (key === null) {
|
|
165
|
+
lastAppliedDomain = null;
|
|
166
|
+
setActiveInternal(null);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const preset = presetByKey.get(key);
|
|
170
|
+
if (!preset) {
|
|
171
|
+
throw new Error(`createRangePresets: unknown preset key "${key}"`);
|
|
172
|
+
}
|
|
173
|
+
const domain = preset.resolve({ dataDomain: getDataDomain(), now: nowFn() });
|
|
174
|
+
if (domain === null) {
|
|
175
|
+
// Preset declined to produce a domain — clear active without touching
|
|
176
|
+
// the viewport so the consumer's chip UI reflects the no-op.
|
|
177
|
+
lastAppliedDomain = null;
|
|
178
|
+
setActiveInternal(null);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
applying++;
|
|
182
|
+
try {
|
|
183
|
+
viewport.setVisibleDomain({ [axis]: domain as VisibleDomainInput });
|
|
184
|
+
} finally {
|
|
185
|
+
applying--;
|
|
186
|
+
}
|
|
187
|
+
// Re-read the viewport's domain post-apply. If clamping (panBounds,
|
|
188
|
+
// minZoom, maxZoom) altered the request, treat the *clamped* domain as
|
|
189
|
+
// the snapshot — that's what subsequent pans will be diffed against.
|
|
190
|
+
lastAppliedDomain = readAxisDomain(viewport, axis);
|
|
191
|
+
setActiveInternal(key);
|
|
192
|
+
},
|
|
193
|
+
getActive() {
|
|
194
|
+
return activeKey;
|
|
195
|
+
},
|
|
196
|
+
subscribe(fn) {
|
|
197
|
+
subscribers.add(fn);
|
|
198
|
+
return () => {
|
|
199
|
+
subscribers.delete(fn);
|
|
200
|
+
};
|
|
201
|
+
},
|
|
202
|
+
dispose() {
|
|
203
|
+
unsubViewport();
|
|
204
|
+
subscribers.clear();
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
// Built-in preset helpers
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Recognized time-preset keys. Each maps to a window relative to `now`
|
|
215
|
+
* (except `MAX`, which uses the full data extent, and `YTD` which goes
|
|
216
|
+
* from January 1 of the current year to `now`).
|
|
217
|
+
*/
|
|
218
|
+
export type TimePresetKey = "24H" | "7D" | "1M" | "3M" | "6M" | "1Y" | "YTD" | "MAX";
|
|
219
|
+
|
|
220
|
+
const TIME_PRESET_MS: Record<Exclude<TimePresetKey, "YTD" | "MAX">, number> = {
|
|
221
|
+
"24H": 24 * 3600_000,
|
|
222
|
+
"7D": 7 * 24 * 3600_000,
|
|
223
|
+
"1M": 30 * 24 * 3600_000,
|
|
224
|
+
"3M": 90 * 24 * 3600_000,
|
|
225
|
+
"6M": 182 * 24 * 3600_000,
|
|
226
|
+
"1Y": 365 * 24 * 3600_000,
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const TIME_PRESET_LABEL: Record<TimePresetKey, string> = {
|
|
230
|
+
"24H": "24H",
|
|
231
|
+
"7D": "7D",
|
|
232
|
+
"1M": "1M",
|
|
233
|
+
"3M": "3M",
|
|
234
|
+
"6M": "6M",
|
|
235
|
+
"1Y": "1Y",
|
|
236
|
+
YTD: "YTD",
|
|
237
|
+
MAX: "MAX",
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Time-axis preset. Resolves to a `DateDomain` ending at `now` (or the data
|
|
242
|
+
* domain's end, for `MAX`). Built-in months use calendar-approximate
|
|
243
|
+
* constants (30/90/182/365 days) — close enough for chart UI.
|
|
244
|
+
*/
|
|
245
|
+
export function timePreset(key: TimePresetKey, label?: string): RangePreset {
|
|
246
|
+
const resolveLabel = label ?? TIME_PRESET_LABEL[key];
|
|
247
|
+
return {
|
|
248
|
+
key,
|
|
249
|
+
label: resolveLabel,
|
|
250
|
+
resolve: ({ dataDomain, now }) => {
|
|
251
|
+
if (key === "MAX") {
|
|
252
|
+
return toDateDomain(dataDomain);
|
|
253
|
+
}
|
|
254
|
+
if (key === "YTD") {
|
|
255
|
+
const start = new Date(Date.UTC(new Date(now).getUTCFullYear(), 0, 1));
|
|
256
|
+
return [start, new Date(now)] as DateDomain;
|
|
257
|
+
}
|
|
258
|
+
const span = TIME_PRESET_MS[key];
|
|
259
|
+
return [new Date(now - span), new Date(now)] as DateDomain;
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Linear / numeric preset showing the last `span` units of `dataDomain` —
|
|
266
|
+
* i.e. `[max - span, max]`. Useful for "last 100m", "last 50 errors", etc.
|
|
267
|
+
* `MAX`-style behavior comes from passing `Infinity` (will clamp to the
|
|
268
|
+
* full data domain).
|
|
269
|
+
*/
|
|
270
|
+
export function linearPreset(opts: { key: string; label: string; span: number }): RangePreset {
|
|
271
|
+
return {
|
|
272
|
+
key: opts.key,
|
|
273
|
+
label: opts.label,
|
|
274
|
+
resolve: ({ dataDomain }) => {
|
|
275
|
+
const [lo, hi] = toNumericDomain(dataDomain);
|
|
276
|
+
if (!Number.isFinite(opts.span)) return [lo, hi] as NumericDomain;
|
|
277
|
+
const start = Math.max(lo, hi - opts.span);
|
|
278
|
+
return [start, hi] as NumericDomain;
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Log-axis preset showing the last `decades` decades of the data — i.e.
|
|
285
|
+
* `[max / 10^decades, max]`, clamped to the data domain's lower bound.
|
|
286
|
+
* The viewport itself stays linear-or-log depending on its scale config;
|
|
287
|
+
* this helper just picks the domain endpoints.
|
|
288
|
+
*/
|
|
289
|
+
export function logPreset(opts: { key: string; label: string; decades: number }): RangePreset {
|
|
290
|
+
return {
|
|
291
|
+
key: opts.key,
|
|
292
|
+
label: opts.label,
|
|
293
|
+
resolve: ({ dataDomain }) => {
|
|
294
|
+
const [lo, hi] = toNumericDomain(dataDomain);
|
|
295
|
+
if (hi <= 0) return [lo, hi] as NumericDomain;
|
|
296
|
+
const candidate = hi / Math.pow(10, opts.decades);
|
|
297
|
+
const start = Math.max(lo, candidate);
|
|
298
|
+
return [start, hi] as NumericDomain;
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
// Helpers
|
|
305
|
+
// ---------------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
function readAxisDomain(viewport: DataViewport, axis: RangePresetAxis): RangePresetDomain {
|
|
308
|
+
const domain = axis === "x" ? viewport.visibleXDomain : viewport.visibleYDomain;
|
|
309
|
+
if (!Array.isArray(domain) || domain.length !== 2) {
|
|
310
|
+
throw new Error(`createRangePresets: ${axis}-axis must be a continuous (numeric or date) axis`);
|
|
311
|
+
}
|
|
312
|
+
// `Array.isArray` widens to `any[]`; the length check above guarantees this is
|
|
313
|
+
// a 2-tuple of numbers or Dates (band domains have length !== 2 only by luck,
|
|
314
|
+
// but a continuous axis is required by contract — hence the throw above).
|
|
315
|
+
return domain as unknown as RangePresetDomain;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function toNumericDomain(domain: RangePresetDomain): NumericDomain {
|
|
319
|
+
const [a, b] = domain;
|
|
320
|
+
return [domainEndAsNumber(a), domainEndAsNumber(b)] as NumericDomain;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function toDateDomain(domain: RangePresetDomain): DateDomain {
|
|
324
|
+
const [a, b] = domain;
|
|
325
|
+
return [domainEndAsDate(a), domainEndAsDate(b)] as DateDomain;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function domainEndAsNumber(value: number | Date): number {
|
|
329
|
+
return typeof value === "number" ? value : value.getTime();
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function domainEndAsDate(value: number | Date): Date {
|
|
333
|
+
return typeof value === "number" ? new Date(value) : value;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function domainsEqual(a: RangePresetDomain, b: RangePresetDomain, epsilon: number): boolean {
|
|
337
|
+
const a0 = domainEndAsNumber(a[0]);
|
|
338
|
+
const a1 = domainEndAsNumber(a[1]);
|
|
339
|
+
const b0 = domainEndAsNumber(b[0]);
|
|
340
|
+
const b1 = domainEndAsNumber(b[1]);
|
|
341
|
+
return numberClose(a0, b0, epsilon) && numberClose(a1, b1, epsilon);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function numberClose(a: number, b: number, epsilon: number): boolean {
|
|
345
|
+
if (a === b) return true;
|
|
346
|
+
const diff = Math.abs(a - b);
|
|
347
|
+
const scale = Math.max(Math.abs(a), Math.abs(b), 1);
|
|
348
|
+
return diff <= epsilon * scale;
|
|
349
|
+
}
|