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,422 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Grammar-level series-readout — dygraph-style pinned legend
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Renders a side panel that updates as the cursor moves across the plot
|
|
5
|
+
// frame, showing each series' value at the snapped x (one row per series,
|
|
6
|
+
// grouped by `color` channel for multi-series layers). Reuses the compiled
|
|
7
|
+
// hit-tests for snapping; coordinates flow through a low-zIndex pointer node
|
|
8
|
+
// that overlays the plot frame so hover events on individual marks still win.
|
|
9
|
+
//
|
|
10
|
+
// Comes in two flavors:
|
|
11
|
+
// - DOM-attached: `opts.ui = { mount, position, inset }` builds and tracks
|
|
12
|
+
// a small absolute-positioned panel.
|
|
13
|
+
// - Headless: omit `opts.ui`; subscribe to `snapshot` changes and roll
|
|
14
|
+
// your own UI (Svelte / React / Solid / whatever).
|
|
15
|
+
//
|
|
16
|
+
// Mirrors the `attach-presets` shape — controller-first, optional DOM layer.
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
type Color,
|
|
20
|
+
type Frame,
|
|
21
|
+
type InteractionManager,
|
|
22
|
+
type InteractionNode,
|
|
23
|
+
type Invalidator,
|
|
24
|
+
} from "insomni";
|
|
25
|
+
import type { CompiledHitTest, ScaleBundle } from "../geoms/types.ts";
|
|
26
|
+
import type { Theme } from "../theme.ts";
|
|
27
|
+
import { createDisposable } from "./_disposable.ts";
|
|
28
|
+
import type { GrammarHitLayer } from "./hit-layer.ts";
|
|
29
|
+
import { defaultFormat } from "./tooltip.ts";
|
|
30
|
+
import {
|
|
31
|
+
collectLayerGroups,
|
|
32
|
+
computeSnap,
|
|
33
|
+
overrideGroupPick,
|
|
34
|
+
resolveGroupColor,
|
|
35
|
+
resolveGroupKey,
|
|
36
|
+
} from "./series-snap.ts";
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Public types
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
export interface SeriesReadoutRow {
|
|
43
|
+
/** Series name (color-channel value) or layer label. */
|
|
44
|
+
label: string;
|
|
45
|
+
/** Formatted display value (raw y at the snapped x). */
|
|
46
|
+
value: string;
|
|
47
|
+
/** Series swatch color, when the layer has a color channel. */
|
|
48
|
+
color?: Color;
|
|
49
|
+
/** True for the row whose snapped position is closest to the cursor. */
|
|
50
|
+
active: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface SeriesReadoutSnapshot {
|
|
54
|
+
/** Formatted snapped x (from the closest layer). */
|
|
55
|
+
x: string;
|
|
56
|
+
/** Raw snapped x value (column-typed). Useful for custom formatting. */
|
|
57
|
+
xValue: unknown;
|
|
58
|
+
/** One row per series across all layers, in original layer + series order. */
|
|
59
|
+
rows: readonly SeriesReadoutRow[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type SeriesReadoutFormat = {
|
|
63
|
+
/** Format the snapped x for the panel title. Default: `defaultFormat`. */
|
|
64
|
+
x?: (value: unknown) => string;
|
|
65
|
+
/** Format each per-series y value. Default: `defaultFormat`. */
|
|
66
|
+
y?: (value: unknown) => string;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export type SeriesReadoutPosition = "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
|
70
|
+
|
|
71
|
+
export interface SeriesReadoutUi {
|
|
72
|
+
/** Element to append the panel into (typically the chart's stage). */
|
|
73
|
+
mount: HTMLElement;
|
|
74
|
+
/** Corner inside `mount`. Default `"top-right"`. */
|
|
75
|
+
position?: SeriesReadoutPosition;
|
|
76
|
+
/** Inset (px) from the chosen corner. Default `12`. */
|
|
77
|
+
inset?: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface AttachSeriesReadoutOptions {
|
|
81
|
+
/** DOM mount info. Omit to run headless and observe via `subscribe`. */
|
|
82
|
+
ui?: SeriesReadoutUi;
|
|
83
|
+
/**
|
|
84
|
+
* How to pick a hit per series.
|
|
85
|
+
* - `"nearest-x"` (default): the hit with smallest |x − cursorX|. Best for
|
|
86
|
+
* time-series readouts; one row per series at the cursor's x.
|
|
87
|
+
* - `"hover"`: only show rows while the cursor is over a real hit (the
|
|
88
|
+
* tooltip-style behavior, but extended to every series in the chart).
|
|
89
|
+
*/
|
|
90
|
+
snap?: "nearest-x" | "hover";
|
|
91
|
+
/** Per-channel formatters. */
|
|
92
|
+
format?: SeriesReadoutFormat;
|
|
93
|
+
/** Hide when the cursor leaves the plot frame. Default `true`. */
|
|
94
|
+
hideOnLeave?: boolean;
|
|
95
|
+
/** Notified whenever the snapshot changes (incl. with `ui` attached). */
|
|
96
|
+
onChange?(snapshot: SeriesReadoutSnapshot | null): void;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface AttachedSeriesReadout {
|
|
100
|
+
peek(): SeriesReadoutSnapshot | null;
|
|
101
|
+
subscribe(fn: (snapshot: SeriesReadoutSnapshot | null) => void): () => void;
|
|
102
|
+
dispose(): void;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// Internal: dependencies + factory
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
export interface SeriesReadoutDeps {
|
|
110
|
+
manager: InteractionManager;
|
|
111
|
+
/** Plot-frame bounds (absolute element-CSS px). */
|
|
112
|
+
bounds: () => Frame;
|
|
113
|
+
/** Latest resolved scales (for color swatches). */
|
|
114
|
+
scales: () => ScaleBundle | null;
|
|
115
|
+
theme: () => Theme;
|
|
116
|
+
invalidator: Invalidator;
|
|
117
|
+
/**
|
|
118
|
+
* Optional shared hit-layer. When provided, the readout also subscribes to
|
|
119
|
+
* its hover events so the panel stays populated while the cursor is over a
|
|
120
|
+
* real mark (where high-z hit-cloud nodes claim hover and the readout's own
|
|
121
|
+
* low-z pointer node never fires).
|
|
122
|
+
*/
|
|
123
|
+
hitLayer?: GrammarHitLayer;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Internal handle used by the mount to push fresh hit-tests in. */
|
|
127
|
+
export interface SeriesReadoutInternal extends AttachedSeriesReadout {
|
|
128
|
+
syncHits<T>(hits: readonly CompiledHitTest<T>[]): void;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function createSeriesReadout(
|
|
132
|
+
deps: SeriesReadoutDeps,
|
|
133
|
+
opts: AttachSeriesReadoutOptions,
|
|
134
|
+
): SeriesReadoutInternal {
|
|
135
|
+
const snap = opts.snap ?? "nearest-x";
|
|
136
|
+
const hideOnLeave = opts.hideOnLeave !== false;
|
|
137
|
+
const formatX = opts.format?.x ?? defaultFormat;
|
|
138
|
+
const formatY = opts.format?.y ?? defaultFormat;
|
|
139
|
+
const subscribers = new Set<(snapshot: SeriesReadoutSnapshot | null) => void>();
|
|
140
|
+
if (opts.onChange) {
|
|
141
|
+
subscribers.add((snapshot) => opts.onChange!(snapshot));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
let activeHits: readonly CompiledHitTest<unknown>[] = [];
|
|
145
|
+
let cursorX: number | null = null;
|
|
146
|
+
let snapshot: SeriesReadoutSnapshot | null = null;
|
|
147
|
+
|
|
148
|
+
function emit(next: SeriesReadoutSnapshot | null): void {
|
|
149
|
+
if (snapshotEqual(snapshot, next)) return;
|
|
150
|
+
snapshot = next;
|
|
151
|
+
panel?.render(snapshot);
|
|
152
|
+
for (const fn of subscribers) fn(snapshot);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function recompute(): void {
|
|
156
|
+
if (d.isDisposed) return;
|
|
157
|
+
if (cursorX === null || activeHits.length === 0) {
|
|
158
|
+
emit(null);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
// The hit-layer owns "which hit is active" — read its live state rather
|
|
162
|
+
// than caching dispatch events, so the active row stays anchored across a
|
|
163
|
+
// chart re-render (state() re-dereferences from the slot).
|
|
164
|
+
const active = deps.hitLayer?.state().active ?? null;
|
|
165
|
+
const scales = deps.scales();
|
|
166
|
+
const result = computeSnap({
|
|
167
|
+
hits: activeHits,
|
|
168
|
+
cursor: cursorX,
|
|
169
|
+
axis: "x",
|
|
170
|
+
snap,
|
|
171
|
+
active,
|
|
172
|
+
});
|
|
173
|
+
if (result === null) {
|
|
174
|
+
emit(null);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const rows: SeriesReadoutRow[] = result.groups.map((g) => ({
|
|
178
|
+
label: g.label,
|
|
179
|
+
value: formatY(g.yValue),
|
|
180
|
+
color: g.color ?? resolveGroupColor(g, scales),
|
|
181
|
+
active: snap === "hover" ? true : g.id === result.activeId,
|
|
182
|
+
}));
|
|
183
|
+
emit({
|
|
184
|
+
x: formatX(result.columnValue),
|
|
185
|
+
xValue: result.columnValue,
|
|
186
|
+
rows,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// -----------------------------------------------------------------------
|
|
191
|
+
// Pointer tracking — low-zIndex node over the plot frame. Hit-cloud nodes
|
|
192
|
+
// sit at Z_HIT_CLOUD_BASE (1000+) so any tooltip / selection cloud still
|
|
193
|
+
// claims hover ahead of us. When the cursor crosses an actual mark we
|
|
194
|
+
// never get a hover event, but the `recompute` we already triggered on
|
|
195
|
+
// entering the frame keeps the panel populated — `nearest-x` doesn't
|
|
196
|
+
// care whether the cursor sits ON a hit, only what x it's at.
|
|
197
|
+
// -----------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
// When the cursor sits over a hit (high-z point cloud claims the hover),
|
|
200
|
+
// our own low-z plot-frame node never fires — track `cursorX` off the
|
|
201
|
+
// shared hit-layer's enter events instead.
|
|
202
|
+
//
|
|
203
|
+
// The hit-layer is the source of truth for "which hit is active". Our
|
|
204
|
+
// `recompute` reads it via `state()`; here we just subscribe to enter
|
|
205
|
+
// events for cursor tracking and to `subscribeState` for invalidation so
|
|
206
|
+
// the panel re-renders the moment the dispatched hit changes (or its
|
|
207
|
+
// underlying compiled is swapped by a sync).
|
|
208
|
+
const unsubscribeHit = deps.hitLayer?.subscribe({
|
|
209
|
+
key: "series-readout",
|
|
210
|
+
onHoverEnter: (ctx) => {
|
|
211
|
+
cursorX = ctx.pointer.x;
|
|
212
|
+
// recompute fires from the state subscription below.
|
|
213
|
+
},
|
|
214
|
+
onPress: (ctx) => {
|
|
215
|
+
// Touch tap: update cursor position so the panel populates immediately.
|
|
216
|
+
cursorX = ctx.pointer.x;
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
const unsubscribeState = deps.hitLayer?.subscribeState(() => {
|
|
220
|
+
recompute();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
const node: InteractionNode = deps.manager.add({
|
|
224
|
+
space: "ui",
|
|
225
|
+
bounds: deps.bounds,
|
|
226
|
+
// Negative so this never wins hover claims against engine/user nodes.
|
|
227
|
+
zIndex: -2,
|
|
228
|
+
onHoverEnter: (info) => {
|
|
229
|
+
cursorX = info.x;
|
|
230
|
+
recompute();
|
|
231
|
+
},
|
|
232
|
+
onHoverMove: (info) => {
|
|
233
|
+
cursorX = info.x;
|
|
234
|
+
recompute();
|
|
235
|
+
},
|
|
236
|
+
onHoverLeave: () => {
|
|
237
|
+
if (!hideOnLeave) return;
|
|
238
|
+
cursorX = null;
|
|
239
|
+
emit(null);
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// -----------------------------------------------------------------------
|
|
244
|
+
// DOM panel — optional. Re-renders on each snapshot change.
|
|
245
|
+
// -----------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
const panel = opts.ui ? buildPanel(opts.ui, deps.theme()) : null;
|
|
248
|
+
|
|
249
|
+
const d = createDisposable(() => {
|
|
250
|
+
unsubscribeHit?.();
|
|
251
|
+
unsubscribeState?.();
|
|
252
|
+
node.destroy();
|
|
253
|
+
panel?.dispose();
|
|
254
|
+
subscribers.clear();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
peek() {
|
|
259
|
+
return snapshot;
|
|
260
|
+
},
|
|
261
|
+
subscribe(fn) {
|
|
262
|
+
if (d.isDisposed) return () => {};
|
|
263
|
+
subscribers.add(fn);
|
|
264
|
+
// Fire current state so consumers don't need a separate `peek()` call.
|
|
265
|
+
fn(snapshot);
|
|
266
|
+
return () => {
|
|
267
|
+
subscribers.delete(fn);
|
|
268
|
+
};
|
|
269
|
+
},
|
|
270
|
+
syncHits(hits) {
|
|
271
|
+
activeHits = hits as readonly CompiledHitTest<unknown>[];
|
|
272
|
+
// Recompute against the new hit set; the cursor x is unchanged so the
|
|
273
|
+
// panel tracks data changes without waiting for the next pointer move.
|
|
274
|
+
recompute();
|
|
275
|
+
},
|
|
276
|
+
dispose: () => d.dispose(),
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
// Snapshot equality — avoids redundant DOM writes / subscriber fan-outs.
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
function snapshotEqual(a: SeriesReadoutSnapshot | null, b: SeriesReadoutSnapshot | null): boolean {
|
|
285
|
+
if (a === b) return true;
|
|
286
|
+
if (!a || !b) return false;
|
|
287
|
+
if (a.x !== b.x) return false;
|
|
288
|
+
if (a.rows.length !== b.rows.length) return false;
|
|
289
|
+
for (let i = 0; i < a.rows.length; i++) {
|
|
290
|
+
const ra = a.rows[i]!;
|
|
291
|
+
const rb = b.rows[i]!;
|
|
292
|
+
if (ra.label !== rb.label) return false;
|
|
293
|
+
if (ra.value !== rb.value) return false;
|
|
294
|
+
if (ra.active !== rb.active) return false;
|
|
295
|
+
if (!colorEqual(ra.color, rb.color)) return false;
|
|
296
|
+
}
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function colorEqual(a: Color | undefined, b: Color | undefined): boolean {
|
|
301
|
+
if (a === b) return true;
|
|
302
|
+
if (!a || !b) return false;
|
|
303
|
+
return a.r === b.r && a.g === b.g && a.b === b.b && a.a === b.a;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
// DOM panel
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
interface PanelHandle {
|
|
311
|
+
render(snapshot: SeriesReadoutSnapshot | null): void;
|
|
312
|
+
dispose(): void;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const POSITION_STYLES: Record<SeriesReadoutPosition, (inset: number) => string> = {
|
|
316
|
+
"top-left": (i) => `top:${i}px;left:${i}px`,
|
|
317
|
+
"top-right": (i) => `top:${i}px;right:${i}px`,
|
|
318
|
+
"bottom-left": (i) => `bottom:${i}px;left:${i}px`,
|
|
319
|
+
"bottom-right": (i) => `bottom:${i}px;right:${i}px`,
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
function buildPanel(ui: SeriesReadoutUi, theme: Theme): PanelHandle {
|
|
323
|
+
const doc = ui.mount.ownerDocument;
|
|
324
|
+
const inset = ui.inset ?? 12;
|
|
325
|
+
const position = ui.position ?? "top-right";
|
|
326
|
+
|
|
327
|
+
const root = doc.createElement("div");
|
|
328
|
+
root.style.cssText = [
|
|
329
|
+
"position:absolute",
|
|
330
|
+
POSITION_STYLES[position](inset),
|
|
331
|
+
"min-width:120px",
|
|
332
|
+
"padding:6px 9px",
|
|
333
|
+
"border-radius:5px",
|
|
334
|
+
`background:${cssColor(withAlphaC(theme.background, 0.92))}`,
|
|
335
|
+
`border:1px solid ${cssColor(withAlphaC(theme.legend.labelColor, 0.18))}`,
|
|
336
|
+
`color:${cssColor(theme.legend.labelColor)}`,
|
|
337
|
+
`font:${theme.legend.fontSize - 1}px/1.5 ${cssFontStack(theme.text.fontFamily)}`,
|
|
338
|
+
"z-index:55",
|
|
339
|
+
"pointer-events:none",
|
|
340
|
+
"display:none",
|
|
341
|
+
].join(";");
|
|
342
|
+
|
|
343
|
+
const title = doc.createElement("div");
|
|
344
|
+
title.style.cssText = [
|
|
345
|
+
"font-weight:600",
|
|
346
|
+
"margin-bottom:4px",
|
|
347
|
+
`color:${cssColor(withAlphaC(theme.legend.labelColor, 0.85))}`,
|
|
348
|
+
].join(";");
|
|
349
|
+
root.appendChild(title);
|
|
350
|
+
|
|
351
|
+
const list = doc.createElement("div");
|
|
352
|
+
list.style.cssText = "display:flex;flex-direction:column;gap:2px";
|
|
353
|
+
root.appendChild(list);
|
|
354
|
+
ui.mount.appendChild(root);
|
|
355
|
+
|
|
356
|
+
return {
|
|
357
|
+
render(snapshot) {
|
|
358
|
+
if (!snapshot) {
|
|
359
|
+
root.style.display = "none";
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
root.style.display = "block";
|
|
363
|
+
title.textContent = snapshot.x;
|
|
364
|
+
// Rebuild the rows in place — small N (one per series) keeps this cheap.
|
|
365
|
+
while (list.firstChild) list.removeChild(list.firstChild);
|
|
366
|
+
for (const row of snapshot.rows) {
|
|
367
|
+
const rowEl = doc.createElement("div");
|
|
368
|
+
rowEl.style.cssText = [
|
|
369
|
+
"display:flex",
|
|
370
|
+
"align-items:center",
|
|
371
|
+
"gap:6px",
|
|
372
|
+
row.active ? "opacity:1" : "opacity:0.72",
|
|
373
|
+
].join(";");
|
|
374
|
+
if (row.color) {
|
|
375
|
+
const sw = doc.createElement("span");
|
|
376
|
+
sw.style.cssText = [
|
|
377
|
+
"display:inline-block",
|
|
378
|
+
"width:9px",
|
|
379
|
+
"height:9px",
|
|
380
|
+
"border-radius:2px",
|
|
381
|
+
`background:${cssColor(row.color)}`,
|
|
382
|
+
"flex-shrink:0",
|
|
383
|
+
].join(";");
|
|
384
|
+
rowEl.appendChild(sw);
|
|
385
|
+
}
|
|
386
|
+
const label = doc.createElement("span");
|
|
387
|
+
label.textContent = row.label;
|
|
388
|
+
label.style.cssText = "flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis";
|
|
389
|
+
const value = doc.createElement("span");
|
|
390
|
+
value.textContent = row.value;
|
|
391
|
+
value.style.cssText = [
|
|
392
|
+
"font-variant-numeric:tabular-nums",
|
|
393
|
+
row.active ? "font-weight:600" : "font-weight:400",
|
|
394
|
+
].join(";");
|
|
395
|
+
rowEl.appendChild(label);
|
|
396
|
+
rowEl.appendChild(value);
|
|
397
|
+
list.appendChild(rowEl);
|
|
398
|
+
}
|
|
399
|
+
},
|
|
400
|
+
dispose() {
|
|
401
|
+
root.remove();
|
|
402
|
+
},
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function cssColor(c: Color): string {
|
|
407
|
+
const r = Math.round(c.r * 255);
|
|
408
|
+
const g = Math.round(c.g * 255);
|
|
409
|
+
const b = Math.round(c.b * 255);
|
|
410
|
+
return `rgba(${r},${g},${b},${c.a})`;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function withAlphaC(c: Color, alpha: number): Color {
|
|
414
|
+
return { r: c.r, g: c.g, b: c.b, a: c.a * alpha };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function cssFontStack(family: string | undefined): string {
|
|
418
|
+
return family && family.length > 0 ? family : "ui-sans-serif,system-ui,sans-serif";
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** @internal — exposed for unit tests only. */
|
|
422
|
+
export const __test__ = { collectLayerGroups, resolveGroupKey, overrideGroupPick, snapshotEqual };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { Color } from "insomni";
|
|
2
|
+
import type { CompiledHitTest, ScaleBundle } from "../geoms/types.ts";
|
|
3
|
+
import type { HitLayerActive } from "./hit-layer.ts";
|
|
4
|
+
/** One per-series group snapped to the cursor's column. */
|
|
5
|
+
export interface SnapGroup {
|
|
6
|
+
/** Stable id `${layerIdx}::${seriesKey ?? "default"}`. */
|
|
7
|
+
id: string;
|
|
8
|
+
label: string;
|
|
9
|
+
/** Picked hit index inside the compiled hit. */
|
|
10
|
+
index: number;
|
|
11
|
+
/** |cursor − positions[index*2 (+1 for y)]|. */
|
|
12
|
+
dist: number;
|
|
13
|
+
xValue: unknown;
|
|
14
|
+
yValue: unknown;
|
|
15
|
+
/** Raw color-channel value, for scale lookup when no constant. */
|
|
16
|
+
colorRaw?: unknown;
|
|
17
|
+
/** Constant color when the layer's color channel is a constant aes. */
|
|
18
|
+
color?: Color;
|
|
19
|
+
/** seriesKey of the picked hit (stacked / dodged segments). */
|
|
20
|
+
seriesKey?: string;
|
|
21
|
+
/** Reference back to the compiled hit (used for swatch resolution). */
|
|
22
|
+
hit: CompiledHitTest<unknown>;
|
|
23
|
+
}
|
|
24
|
+
export interface SnapResult {
|
|
25
|
+
/** Snapped column value: the X for `axis:"x"`, the Y for `axis:"y"`. */
|
|
26
|
+
columnValue: unknown;
|
|
27
|
+
/** Ordered groups, one per series across all layers. */
|
|
28
|
+
groups: SnapGroup[];
|
|
29
|
+
/** Resolved active group id (closest to cursor / dispatched hit). */
|
|
30
|
+
activeId: string | null;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Resolve the group identity for a given hit index. Group key priority:
|
|
34
|
+
* 1. `seriesKey[i]` from the compiled hit (stacked / dodged geoms).
|
|
35
|
+
* 2. `colorAes(datum, i)` when color is an accessor / column.
|
|
36
|
+
* 3. Otherwise the whole layer is one group keyed by the layer label.
|
|
37
|
+
* Returns null when no x/y channels are bound. (axis-independent.)
|
|
38
|
+
*/
|
|
39
|
+
export declare function resolveGroupKey<T>(hit: CompiledHitTest<T>, hitIndex: number): {
|
|
40
|
+
key: string;
|
|
41
|
+
label: string;
|
|
42
|
+
raw: unknown;
|
|
43
|
+
} | null;
|
|
44
|
+
/**
|
|
45
|
+
* Collect per-series groups for one layer, picking the nearest hit on `axis`
|
|
46
|
+
* within each group. `axis` is the LAST param and defaults to `"x"` so the
|
|
47
|
+
* existing 5-arg callers / `__test__` signature are unaffected.
|
|
48
|
+
*/
|
|
49
|
+
export declare function collectLayerGroups<T>(hit: CompiledHitTest<T>, layerIdx: number, cursor: number, snap: "nearest-x" | "hover", out: SnapGroup[], axis?: "x" | "y"): void;
|
|
50
|
+
/**
|
|
51
|
+
* Replace a group's pick with the exact dispatched hit. No-op if the group
|
|
52
|
+
* isn't in the collected set. (moved verbatim, axis-independent.)
|
|
53
|
+
*/
|
|
54
|
+
export declare function overrideGroupPick<T>(groups: SnapGroup[], groupId: string, hit: CompiledHitTest<T>, hitIndex: number): void;
|
|
55
|
+
/** Resolve a group's swatch color, falling back to the color scale. (moved.) */
|
|
56
|
+
export declare function resolveGroupColor(group: SnapGroup, scales: ScaleBundle | null): Color | undefined;
|
|
57
|
+
/**
|
|
58
|
+
* Encapsulates the body of series-readout's `recompute()` MINUS emit/DOM: snaps
|
|
59
|
+
* the cursor to the active hit's column (when one is dispatched), collects all
|
|
60
|
+
* groups across layers, resolves the active group, and returns the title column
|
|
61
|
+
* value. Pure — the caller maps `groups` → rows and resolves swatch colors via
|
|
62
|
+
* `resolveGroupColor`. Returns null when no layer produced a group.
|
|
63
|
+
*/
|
|
64
|
+
export declare function computeSnap(args: {
|
|
65
|
+
hits: readonly CompiledHitTest<unknown>[];
|
|
66
|
+
cursor: number;
|
|
67
|
+
axis: "x" | "y";
|
|
68
|
+
snap: "nearest-x" | "hover";
|
|
69
|
+
active: HitLayerActive | null;
|
|
70
|
+
}): SnapResult | null;
|