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,44 @@
|
|
|
1
|
+
import type { BrushRect, Frame, InteractionManager, Invalidator, Layer } from "insomni";
|
|
2
|
+
import type { CompiledHitTest, HoveredHit } from "./geoms/types.ts";
|
|
3
|
+
import type { Theme } from "./theme.ts";
|
|
4
|
+
import { type GrammarBrush, type GrammarBrushConfig } from "./interactions/brush.ts";
|
|
5
|
+
export interface AttachBrushOptions extends GrammarBrushConfig {
|
|
6
|
+
/**
|
|
7
|
+
* Fires whenever the brushed set changes — including the empty `[]` after
|
|
8
|
+
* `clear()` / background tap. Receives one `HoveredHit` per data row whose
|
|
9
|
+
* hit-test position falls within the brush rect.
|
|
10
|
+
*/
|
|
11
|
+
onSelect?(hits: readonly HoveredHit[]): void;
|
|
12
|
+
}
|
|
13
|
+
export interface AttachedBrush {
|
|
14
|
+
/** Read-only snapshot of the current brushed hits. */
|
|
15
|
+
peek(): readonly HoveredHit[];
|
|
16
|
+
/** Current brush rect in element-local CSS px, or `null` while idle. */
|
|
17
|
+
rect(): BrushRect | null;
|
|
18
|
+
/** Subscribe to changes. Fires immediately with the current set. */
|
|
19
|
+
subscribe(fn: (hits: readonly HoveredHit[]) => void): () => void;
|
|
20
|
+
/** Imperatively clear the brush. */
|
|
21
|
+
clear(): void;
|
|
22
|
+
/** Tear down the brush + interaction nodes. */
|
|
23
|
+
dispose(): void;
|
|
24
|
+
}
|
|
25
|
+
export interface AttachBrushDeps {
|
|
26
|
+
manager: InteractionManager;
|
|
27
|
+
/** Plot-frame bounds in element-local CSS px. */
|
|
28
|
+
bounds: () => Frame;
|
|
29
|
+
hudLayer: () => Layer;
|
|
30
|
+
theme: () => Theme;
|
|
31
|
+
invalidator: Invalidator;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Internal handle exposed to the mount so it can drive `sync` + `draw` from
|
|
35
|
+
* its rAF loop and dispose alongside teardown. Public consumers receive the
|
|
36
|
+
* narrower `AttachedBrush` view returned by `MountedPlot.attachBrush`.
|
|
37
|
+
*/
|
|
38
|
+
export interface AttachedBrushInternal extends AttachedBrush {
|
|
39
|
+
syncHits<T>(hits: readonly CompiledHitTest<T>[]): void;
|
|
40
|
+
draw(): void;
|
|
41
|
+
/** Underlying grammar brush — exposed for tests; not part of the public API. */
|
|
42
|
+
readonly brush: GrammarBrush;
|
|
43
|
+
}
|
|
44
|
+
export declare function createAttachedBrush(deps: AttachBrushDeps, opts: AttachBrushOptions): AttachedBrushInternal;
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createFrame,
|
|
3
|
+
type DragPointerInfo,
|
|
4
|
+
type InteractionManager,
|
|
5
|
+
type InteractionNode,
|
|
6
|
+
type InteractionNodeSpec,
|
|
7
|
+
type PointerInfo,
|
|
8
|
+
} from "insomni";
|
|
9
|
+
import { describe, expect, test } from "vite-plus/test";
|
|
10
|
+
|
|
11
|
+
import { createAttachedBrush } from "./attach-brush.ts";
|
|
12
|
+
import type { CompiledHitTest, HoveredHit } from "./geoms/types.ts";
|
|
13
|
+
import { themeDefault } from "./theme.ts";
|
|
14
|
+
|
|
15
|
+
interface CapturedNode {
|
|
16
|
+
spec: InteractionNodeSpec;
|
|
17
|
+
destroyed: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function fakeManager(): {
|
|
21
|
+
manager: InteractionManager;
|
|
22
|
+
nodes: CapturedNode[];
|
|
23
|
+
fireBackgroundTap: () => void;
|
|
24
|
+
} {
|
|
25
|
+
const nodes: CapturedNode[] = [];
|
|
26
|
+
let bgHandler: (() => void) | null = null;
|
|
27
|
+
const manager = {
|
|
28
|
+
element: {} as HTMLElement,
|
|
29
|
+
add(spec: InteractionNodeSpec): InteractionNode {
|
|
30
|
+
const captured: CapturedNode = { spec, destroyed: false };
|
|
31
|
+
nodes.push(captured);
|
|
32
|
+
return {
|
|
33
|
+
id: Symbol("node"),
|
|
34
|
+
update(patch) {
|
|
35
|
+
captured.spec = { ...captured.spec, ...patch };
|
|
36
|
+
},
|
|
37
|
+
destroy() {
|
|
38
|
+
captured.destroyed = true;
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
},
|
|
42
|
+
addPointCloud() {
|
|
43
|
+
throw new Error("not used");
|
|
44
|
+
},
|
|
45
|
+
onBackgroundTap(handler: () => void) {
|
|
46
|
+
bgHandler = handler;
|
|
47
|
+
return () => {
|
|
48
|
+
if (bgHandler === handler) bgHandler = null;
|
|
49
|
+
};
|
|
50
|
+
},
|
|
51
|
+
onChange() {
|
|
52
|
+
return () => {};
|
|
53
|
+
},
|
|
54
|
+
destroy() {},
|
|
55
|
+
} as unknown as InteractionManager;
|
|
56
|
+
return {
|
|
57
|
+
manager,
|
|
58
|
+
nodes,
|
|
59
|
+
fireBackgroundTap: () => bgHandler?.(),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const noMods = { shift: false, ctrl: false, meta: false, alt: false };
|
|
64
|
+
function pointer(x: number, y: number): PointerInfo {
|
|
65
|
+
return {
|
|
66
|
+
pointerId: 1,
|
|
67
|
+
type: "mouse",
|
|
68
|
+
x,
|
|
69
|
+
y,
|
|
70
|
+
localX: x,
|
|
71
|
+
localY: y,
|
|
72
|
+
buttons: 1,
|
|
73
|
+
mods: noMods,
|
|
74
|
+
stopPropagation: () => {},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function dragInfo(x: number, y: number, dx = 0, dy = 0): DragPointerInfo {
|
|
78
|
+
return { ...pointer(x, y), dx, dy };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface Row {
|
|
82
|
+
x: number;
|
|
83
|
+
y: number;
|
|
84
|
+
}
|
|
85
|
+
function hitFor(
|
|
86
|
+
positions: number[],
|
|
87
|
+
indices: number[],
|
|
88
|
+
data: readonly Row[],
|
|
89
|
+
): CompiledHitTest<Row> {
|
|
90
|
+
return {
|
|
91
|
+
geomKind: "point",
|
|
92
|
+
positions: Float32Array.from(positions),
|
|
93
|
+
dataIndex: Int32Array.from(indices),
|
|
94
|
+
pickRadius: 5,
|
|
95
|
+
channels: {},
|
|
96
|
+
data,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const fullBounds = () => createFrame({ x: 0, y: 0, width: 100, height: 200 });
|
|
101
|
+
|
|
102
|
+
function setup(opts: { onSelect?: (hits: readonly HoveredHit[]) => void } = {}) {
|
|
103
|
+
const { manager, nodes, fireBackgroundTap } = fakeManager();
|
|
104
|
+
const hudLayer = { pushRect: () => hudLayer };
|
|
105
|
+
const inv = { invalidate: () => {}, dirty: false } as never;
|
|
106
|
+
const attached = createAttachedBrush(
|
|
107
|
+
{
|
|
108
|
+
manager,
|
|
109
|
+
bounds: fullBounds,
|
|
110
|
+
hudLayer: () => hudLayer as never,
|
|
111
|
+
theme: () => themeDefault,
|
|
112
|
+
invalidator: inv,
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
onSelect: opts.onSelect,
|
|
116
|
+
},
|
|
117
|
+
);
|
|
118
|
+
return { attached, nodes, fireBackgroundTap };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
describe("attachBrush (createAttachedBrush)", () => {
|
|
122
|
+
test("returns an empty peek + null rect before any drag", () => {
|
|
123
|
+
const { attached } = setup();
|
|
124
|
+
expect(attached.peek()).toEqual([]);
|
|
125
|
+
expect(attached.rect()).toBeNull();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("subscribe fires immediately with current state", () => {
|
|
129
|
+
const { attached } = setup();
|
|
130
|
+
const seen: (readonly HoveredHit[])[] = [];
|
|
131
|
+
attached.subscribe((hits) => seen.push(hits));
|
|
132
|
+
expect(seen).toHaveLength(1);
|
|
133
|
+
expect(seen[0]).toEqual([]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("drag populates peek + rect and notifies subscribers and onSelect", () => {
|
|
137
|
+
const onSelect: (readonly HoveredHit[])[] = [];
|
|
138
|
+
const { attached, nodes } = setup({ onSelect: (h) => onSelect.push(h) });
|
|
139
|
+
const data: Row[] = [
|
|
140
|
+
{ x: 10, y: 20 },
|
|
141
|
+
{ x: 50, y: 60 },
|
|
142
|
+
{ x: 80, y: 90 },
|
|
143
|
+
];
|
|
144
|
+
attached.syncHits([hitFor([10, 20, 50, 60, 80, 90], [0, 1, 2], data)]);
|
|
145
|
+
|
|
146
|
+
const subseen: (readonly HoveredHit[])[] = [];
|
|
147
|
+
attached.subscribe((hits) => subseen.push(hits));
|
|
148
|
+
|
|
149
|
+
nodes[0]!.spec.onDragStart!(pointer(5, 15));
|
|
150
|
+
nodes[0]!.spec.onDragMove!(dragInfo(60, 70));
|
|
151
|
+
nodes[0]!.spec.onDragEnd!(pointer(60, 70));
|
|
152
|
+
|
|
153
|
+
expect(attached.peek().map((h) => h.dataIndex)).toEqual([0, 1]);
|
|
154
|
+
expect(attached.rect()).toEqual({ x: 5, y: 15, width: 55, height: 55 });
|
|
155
|
+
// onSelect fires at least once with the final set.
|
|
156
|
+
expect(onSelect.at(-1)!.map((h) => h.dataIndex)).toEqual([0, 1]);
|
|
157
|
+
// Subscriber fires once with [] (initial) plus updates during the drag.
|
|
158
|
+
expect(subseen[0]).toEqual([]);
|
|
159
|
+
expect(subseen.at(-1)!.map((h) => h.dataIndex)).toEqual([0, 1]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("clear empties state + fires onSelect with []", () => {
|
|
163
|
+
const fired: (readonly HoveredHit[])[] = [];
|
|
164
|
+
const { attached, nodes } = setup({ onSelect: (h) => fired.push(h) });
|
|
165
|
+
const data: Row[] = [{ x: 10, y: 20 }];
|
|
166
|
+
attached.syncHits([hitFor([10, 20], [0], data)]);
|
|
167
|
+
|
|
168
|
+
nodes[0]!.spec.onDragStart!(pointer(0, 0));
|
|
169
|
+
nodes[0]!.spec.onDragMove!(dragInfo(40, 40));
|
|
170
|
+
nodes[0]!.spec.onDragEnd!(pointer(40, 40));
|
|
171
|
+
expect(attached.peek()).toHaveLength(1);
|
|
172
|
+
|
|
173
|
+
attached.clear();
|
|
174
|
+
expect(attached.peek()).toEqual([]);
|
|
175
|
+
expect(attached.rect()).toBeNull();
|
|
176
|
+
expect(fired.at(-1)).toEqual([]);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("background tap routes through onSelect as a clear", () => {
|
|
180
|
+
const fired: (readonly HoveredHit[])[] = [];
|
|
181
|
+
const { attached, nodes, fireBackgroundTap } = setup({ onSelect: (h) => fired.push(h) });
|
|
182
|
+
const data: Row[] = [{ x: 10, y: 20 }];
|
|
183
|
+
attached.syncHits([hitFor([10, 20], [0], data)]);
|
|
184
|
+
nodes[0]!.spec.onDragStart!(pointer(0, 0));
|
|
185
|
+
nodes[0]!.spec.onDragMove!(dragInfo(40, 40));
|
|
186
|
+
nodes[0]!.spec.onDragEnd!(pointer(40, 40));
|
|
187
|
+
expect(attached.peek()).toHaveLength(1);
|
|
188
|
+
|
|
189
|
+
fireBackgroundTap();
|
|
190
|
+
expect(attached.peek()).toEqual([]);
|
|
191
|
+
expect(attached.rect()).toBeNull();
|
|
192
|
+
expect(fired.at(-1)).toEqual([]);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("dispose tears down nodes and stops subscriber fan-out", () => {
|
|
196
|
+
const { attached, nodes } = setup();
|
|
197
|
+
const seen: (readonly HoveredHit[])[] = [];
|
|
198
|
+
attached.subscribe((hits) => seen.push(hits));
|
|
199
|
+
expect(seen).toHaveLength(1);
|
|
200
|
+
|
|
201
|
+
attached.dispose();
|
|
202
|
+
for (const n of nodes) expect(n.destroyed).toBe(true);
|
|
203
|
+
// A subscribe after dispose is a no-op — nothing else should append.
|
|
204
|
+
attached.subscribe((hits) => seen.push(hits));
|
|
205
|
+
expect(seen).toHaveLength(1);
|
|
206
|
+
expect(attached.peek()).toEqual([]);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("dispose is idempotent", () => {
|
|
210
|
+
const { attached } = setup();
|
|
211
|
+
attached.dispose();
|
|
212
|
+
expect(() => attached.dispose()).not.toThrow();
|
|
213
|
+
});
|
|
214
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// attachBrush — high-level helper on top of createGrammarBrush
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Mirrors `attachRangePresets` / `attachSeriesReadout`: returns a controller
|
|
5
|
+
// with `peek` / `rect` / `subscribe` / `clear` / `dispose` so a consumer can
|
|
6
|
+
// wire a drag-to-rect brush onto a `MountedPlot` without going through the
|
|
7
|
+
// declarative `interactions: { brush: ... }` config path. Internally this is
|
|
8
|
+
// just `createGrammarBrush` wrapped so the mount can track sync/draw + cleanup.
|
|
9
|
+
|
|
10
|
+
import type { BrushRect, Frame, InteractionManager, Invalidator, Layer } from "insomni";
|
|
11
|
+
import type { CompiledHitTest, HoveredHit } from "./geoms/types.ts";
|
|
12
|
+
import type { Theme } from "./theme.ts";
|
|
13
|
+
import {
|
|
14
|
+
createGrammarBrush,
|
|
15
|
+
type GrammarBrush,
|
|
16
|
+
type GrammarBrushConfig,
|
|
17
|
+
} from "./interactions/brush.ts";
|
|
18
|
+
|
|
19
|
+
export interface AttachBrushOptions extends GrammarBrushConfig {
|
|
20
|
+
/**
|
|
21
|
+
* Fires whenever the brushed set changes — including the empty `[]` after
|
|
22
|
+
* `clear()` / background tap. Receives one `HoveredHit` per data row whose
|
|
23
|
+
* hit-test position falls within the brush rect.
|
|
24
|
+
*/
|
|
25
|
+
onSelect?(hits: readonly HoveredHit[]): void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface AttachedBrush {
|
|
29
|
+
/** Read-only snapshot of the current brushed hits. */
|
|
30
|
+
peek(): readonly HoveredHit[];
|
|
31
|
+
/** Current brush rect in element-local CSS px, or `null` while idle. */
|
|
32
|
+
rect(): BrushRect | null;
|
|
33
|
+
/** Subscribe to changes. Fires immediately with the current set. */
|
|
34
|
+
subscribe(fn: (hits: readonly HoveredHit[]) => void): () => void;
|
|
35
|
+
/** Imperatively clear the brush. */
|
|
36
|
+
clear(): void;
|
|
37
|
+
/** Tear down the brush + interaction nodes. */
|
|
38
|
+
dispose(): void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface AttachBrushDeps {
|
|
42
|
+
manager: InteractionManager;
|
|
43
|
+
/** Plot-frame bounds in element-local CSS px. */
|
|
44
|
+
bounds: () => Frame;
|
|
45
|
+
hudLayer: () => Layer;
|
|
46
|
+
theme: () => Theme;
|
|
47
|
+
invalidator: Invalidator;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Internal handle exposed to the mount so it can drive `sync` + `draw` from
|
|
52
|
+
* its rAF loop and dispose alongside teardown. Public consumers receive the
|
|
53
|
+
* narrower `AttachedBrush` view returned by `MountedPlot.attachBrush`.
|
|
54
|
+
*/
|
|
55
|
+
export interface AttachedBrushInternal extends AttachedBrush {
|
|
56
|
+
syncHits<T>(hits: readonly CompiledHitTest<T>[]): void;
|
|
57
|
+
draw(): void;
|
|
58
|
+
/** Underlying grammar brush — exposed for tests; not part of the public API. */
|
|
59
|
+
readonly brush: GrammarBrush;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function createAttachedBrush(
|
|
63
|
+
deps: AttachBrushDeps,
|
|
64
|
+
opts: AttachBrushOptions,
|
|
65
|
+
): AttachedBrushInternal {
|
|
66
|
+
const subscribers = new Set<(hits: readonly HoveredHit[]) => void>();
|
|
67
|
+
let current: readonly HoveredHit[] = [];
|
|
68
|
+
let disposed = false;
|
|
69
|
+
|
|
70
|
+
const brush = createGrammarBrush(deps, {
|
|
71
|
+
...opts,
|
|
72
|
+
onChange: (hits) => {
|
|
73
|
+
current = hits;
|
|
74
|
+
opts.onSelect?.(hits);
|
|
75
|
+
for (const fn of subscribers) fn(hits);
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
peek: () => current,
|
|
81
|
+
rect: () => brush.rect(),
|
|
82
|
+
subscribe(fn) {
|
|
83
|
+
if (disposed) return () => {};
|
|
84
|
+
subscribers.add(fn);
|
|
85
|
+
fn(current);
|
|
86
|
+
return () => {
|
|
87
|
+
subscribers.delete(fn);
|
|
88
|
+
};
|
|
89
|
+
},
|
|
90
|
+
clear: () => brush.clear(),
|
|
91
|
+
dispose: () => {
|
|
92
|
+
if (disposed) return;
|
|
93
|
+
disposed = true;
|
|
94
|
+
brush.dispose();
|
|
95
|
+
// Notify the documented empty `[]` on teardown so consumers mirroring the
|
|
96
|
+
// selection into external UI don't keep a stale non-empty value. Fire
|
|
97
|
+
// before removing subscribers.
|
|
98
|
+
if (current.length > 0) {
|
|
99
|
+
const empty: readonly HoveredHit[] = [];
|
|
100
|
+
current = empty;
|
|
101
|
+
opts.onSelect?.(empty);
|
|
102
|
+
for (const fn of subscribers) fn(empty);
|
|
103
|
+
}
|
|
104
|
+
subscribers.clear();
|
|
105
|
+
current = [];
|
|
106
|
+
},
|
|
107
|
+
syncHits: (hits) => brush.sync(hits),
|
|
108
|
+
draw: () => brush.draw(),
|
|
109
|
+
brush,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Theme } from "./theme.ts";
|
|
2
|
+
import { type RangePreset, type RangePresetDataDomain, type RangePresetsSubscriber, type TimePresetKey } from "../range-presets.ts";
|
|
3
|
+
import type { DataViewport } from "../viewport.ts";
|
|
4
|
+
/** Where the chip strip sits inside its mount element. */
|
|
5
|
+
export type AttachRangePresetsPosition = "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
|
6
|
+
export interface AttachRangePresetsUi {
|
|
7
|
+
/** Element to append the chip strip into (typically the chart's stage). */
|
|
8
|
+
mount: HTMLElement;
|
|
9
|
+
/** Corner placement inside `mount`. Defaults to `"top-left"`. */
|
|
10
|
+
position?: AttachRangePresetsPosition;
|
|
11
|
+
/** Inset (px) from the chosen corner. Defaults to `12`. */
|
|
12
|
+
inset?: number;
|
|
13
|
+
}
|
|
14
|
+
export interface AttachRangePresetsOptions {
|
|
15
|
+
axis: "x" | "y";
|
|
16
|
+
/**
|
|
17
|
+
* Presets to expose. A `TimePresetKey` string is expanded to `timePreset(key)`;
|
|
18
|
+
* pass a full `RangePreset` for custom presets (label override, linear/log
|
|
19
|
+
* presets, etc.).
|
|
20
|
+
*/
|
|
21
|
+
presets: readonly (TimePresetKey | RangePreset)[];
|
|
22
|
+
dataDomain?: RangePresetDataDomain;
|
|
23
|
+
now?: () => number;
|
|
24
|
+
/** Omit to skip DOM construction — returns the same controller, headless. */
|
|
25
|
+
ui?: AttachRangePresetsUi;
|
|
26
|
+
}
|
|
27
|
+
export interface AttachedRangePresets {
|
|
28
|
+
setActive(key: string | null): void;
|
|
29
|
+
getActive(): string | null;
|
|
30
|
+
subscribe(fn: RangePresetsSubscriber): () => void;
|
|
31
|
+
dispose(): void;
|
|
32
|
+
}
|
|
33
|
+
export declare function attachRangePresets(viewport: DataViewport<any, any>, theme: Theme, opts: AttachRangePresetsOptions): AttachedRangePresets;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, expect, test } from "vite-plus/test";
|
|
3
|
+
|
|
4
|
+
import { viewportFrame } from "insomni";
|
|
5
|
+
import { createDataViewport } from "../viewport.ts";
|
|
6
|
+
import { themeDefault } from "./theme.ts";
|
|
7
|
+
import { attachRangePresets } from "./attach-presets.ts";
|
|
8
|
+
|
|
9
|
+
const FROZEN_NOW = new Date("2026-05-20T12:00:00Z").getTime();
|
|
10
|
+
const now = () => FROZEN_NOW;
|
|
11
|
+
const DATA_DOMAIN: [Date, Date] = [new Date("2024-01-01T00:00:00Z"), new Date(FROZEN_NOW)];
|
|
12
|
+
|
|
13
|
+
const makeViewport = () =>
|
|
14
|
+
createDataViewport({
|
|
15
|
+
frame: viewportFrame(400, 300),
|
|
16
|
+
x: { type: "time", domain: DATA_DOMAIN },
|
|
17
|
+
y: { type: "linear", domain: [0, 100] },
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("attachRangePresets — headless (no ui)", () => {
|
|
21
|
+
test("expands TimePresetKey strings into timePreset() entries", () => {
|
|
22
|
+
const vp = makeViewport();
|
|
23
|
+
const a = attachRangePresets(vp, themeDefault, {
|
|
24
|
+
axis: "x",
|
|
25
|
+
presets: ["1M", "MAX"],
|
|
26
|
+
dataDomain: DATA_DOMAIN,
|
|
27
|
+
now,
|
|
28
|
+
});
|
|
29
|
+
a.setActive("1M");
|
|
30
|
+
const [s, e] = vp.visibleXDomain as readonly [Date, Date];
|
|
31
|
+
expect(e.getTime()).toBe(FROZEN_NOW);
|
|
32
|
+
expect(s.getTime()).toBe(FROZEN_NOW - 30 * 24 * 3600_000);
|
|
33
|
+
a.dispose();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("subscribe fires on setActive", () => {
|
|
37
|
+
const vp = makeViewport();
|
|
38
|
+
const a = attachRangePresets(vp, themeDefault, {
|
|
39
|
+
axis: "x",
|
|
40
|
+
presets: ["1M"],
|
|
41
|
+
dataDomain: DATA_DOMAIN,
|
|
42
|
+
now,
|
|
43
|
+
});
|
|
44
|
+
const log: (string | null)[] = [];
|
|
45
|
+
a.subscribe((k) => log.push(k));
|
|
46
|
+
a.setActive("1M");
|
|
47
|
+
a.setActive(null);
|
|
48
|
+
expect(log).toEqual(["1M", null]);
|
|
49
|
+
a.dispose();
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("attachRangePresets — with ui", () => {
|
|
54
|
+
test("renders a chip per preset and toggles active styling on click", () => {
|
|
55
|
+
const vp = makeViewport();
|
|
56
|
+
const host = document.createElement("div");
|
|
57
|
+
document.body.appendChild(host);
|
|
58
|
+
const a = attachRangePresets(vp, themeDefault, {
|
|
59
|
+
axis: "x",
|
|
60
|
+
presets: ["1M", "3M", "MAX"],
|
|
61
|
+
dataDomain: DATA_DOMAIN,
|
|
62
|
+
now,
|
|
63
|
+
ui: { mount: host, position: "top-left" },
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const buttons = Array.from(host.querySelectorAll("button"));
|
|
67
|
+
expect(buttons.map((b) => b.textContent)).toEqual(["1M", "3M", "MAX"]);
|
|
68
|
+
|
|
69
|
+
const oneM = buttons[0]!;
|
|
70
|
+
const inactiveBg = oneM.style.background;
|
|
71
|
+
oneM.click();
|
|
72
|
+
expect(oneM.style.background).not.toBe(inactiveBg);
|
|
73
|
+
expect(a.getActive()).toBe("1M");
|
|
74
|
+
|
|
75
|
+
// A controller-side setActive update must propagate back to chip styling.
|
|
76
|
+
a.setActive("MAX");
|
|
77
|
+
const max = buttons[2]!;
|
|
78
|
+
expect(max.style.background).not.toBe(inactiveBg);
|
|
79
|
+
// 1M chip should have reverted to its inactive look.
|
|
80
|
+
expect(oneM.style.background).toBe(inactiveBg);
|
|
81
|
+
|
|
82
|
+
a.dispose();
|
|
83
|
+
expect(host.querySelector("button")).toBeNull();
|
|
84
|
+
host.remove();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("position option flips the chip strip's CSS anchor", () => {
|
|
88
|
+
const vp = makeViewport();
|
|
89
|
+
const host = document.createElement("div");
|
|
90
|
+
document.body.appendChild(host);
|
|
91
|
+
const a = attachRangePresets(vp, themeDefault, {
|
|
92
|
+
axis: "x",
|
|
93
|
+
presets: ["1M"],
|
|
94
|
+
dataDomain: DATA_DOMAIN,
|
|
95
|
+
now,
|
|
96
|
+
ui: { mount: host, position: "bottom-right", inset: 8 },
|
|
97
|
+
});
|
|
98
|
+
const strip = host.querySelector("div") as HTMLDivElement;
|
|
99
|
+
expect(strip.style.bottom).toBe("8px");
|
|
100
|
+
expect(strip.style.right).toBe("8px");
|
|
101
|
+
expect(strip.style.top).toBe("");
|
|
102
|
+
expect(strip.style.left).toBe("");
|
|
103
|
+
a.dispose();
|
|
104
|
+
host.remove();
|
|
105
|
+
});
|
|
106
|
+
});
|