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,2112 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// WebGPU mount — owns the renderer, atlas, layers, and rAF loop
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// `chart.mount(canvas, opts?)` returns a `MountedPlot<T>` that owns the
|
|
5
|
+
// per-canvas rendering loop (Renderer2D, GlyphAtlas, three layers, RAF tick,
|
|
6
|
+
// ResizeObserver, visibility pause). The intent is that 90% of users never
|
|
7
|
+
// touch any of the lower-level pieces. The remaining 10% can:
|
|
8
|
+
//
|
|
9
|
+
// - bring their own Renderer2D / GlyphAtlas / layers (`opts.renderer`, etc.)
|
|
10
|
+
// - drive sizing manually (`opts.width`/`height`, `opts.autoResize: false`)
|
|
11
|
+
// - swap the spec at runtime (`handle.update(...)`) or rebuild via a closure
|
|
12
|
+
// - reach into `handle.renderer` / `handle.atlas` / `handle.axisLayer` etc.
|
|
13
|
+
// - opt out of the rAF loop entirely (`opts.autoFrame: false`) and call
|
|
14
|
+
// `handle.update()` themselves from a host loop
|
|
15
|
+
//
|
|
16
|
+
// The mount is sync. If you need to bootstrap a device, do it once with
|
|
17
|
+
// `await initGPU()` and pass the result in.
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
type CacheHint,
|
|
21
|
+
type Color,
|
|
22
|
+
createFrame,
|
|
23
|
+
createInteractionManager,
|
|
24
|
+
createInvalidator,
|
|
25
|
+
createLayer,
|
|
26
|
+
createSVGRenderer,
|
|
27
|
+
createRenderer,
|
|
28
|
+
type Frame,
|
|
29
|
+
type FrameRect,
|
|
30
|
+
type FrameTiming,
|
|
31
|
+
type GlyphAtlas,
|
|
32
|
+
type InteractionManager,
|
|
33
|
+
type Invalidator,
|
|
34
|
+
type Layer,
|
|
35
|
+
type Layer as V1Layer,
|
|
36
|
+
loadSystemFont,
|
|
37
|
+
SdfGlyphAtlas,
|
|
38
|
+
} from "insomni";
|
|
39
|
+
import type {
|
|
40
|
+
BrushConfig,
|
|
41
|
+
Chart,
|
|
42
|
+
ContextMenuConfig,
|
|
43
|
+
CrosshairConfig,
|
|
44
|
+
InteractionsConfig,
|
|
45
|
+
LegendInteractionConfig,
|
|
46
|
+
MountedPlot,
|
|
47
|
+
MountPlotOptions,
|
|
48
|
+
SelectionConfig,
|
|
49
|
+
TooltipConfig,
|
|
50
|
+
TransitionsConfig,
|
|
51
|
+
} from "./chart.ts";
|
|
52
|
+
import { createGrammarTransitions, type GrammarTransitions } from "./interactions/transitions.ts";
|
|
53
|
+
import { resolveMotion } from "./theme.ts";
|
|
54
|
+
import {
|
|
55
|
+
DEFAULT_CHART_HEIGHT,
|
|
56
|
+
DEFAULT_CHART_WIDTH,
|
|
57
|
+
DEFAULT_PAN_BOUNDS,
|
|
58
|
+
DEFAULT_Y_FIT_PADDING,
|
|
59
|
+
} from "./constants.ts";
|
|
60
|
+
import type { HoveredHit } from "./geoms/types.ts";
|
|
61
|
+
import { createGrammarBrush, type GrammarBrush } from "./interactions/brush.ts";
|
|
62
|
+
import { createGrammarCrosshair, type GrammarCrosshair } from "./interactions/crosshair.ts";
|
|
63
|
+
import { createGrammarHitLayer, type GrammarHitLayer } from "./interactions/hit-layer.ts";
|
|
64
|
+
import { createGrammarLegend, type GrammarLegend } from "./interactions/legend.ts";
|
|
65
|
+
import { createGrammarContextMenu, type GrammarContextMenu } from "./interactions/menu.ts";
|
|
66
|
+
import { createGrammarSelection, type GrammarSelection } from "./interactions/selection.ts";
|
|
67
|
+
import {
|
|
68
|
+
createSeriesReadout,
|
|
69
|
+
type AttachedSeriesReadout,
|
|
70
|
+
type AttachSeriesReadoutOptions,
|
|
71
|
+
type SeriesReadoutInternal,
|
|
72
|
+
} from "./interactions/series-readout.ts";
|
|
73
|
+
import { createGrammarTooltip, type GrammarTooltip } from "./interactions/tooltip.ts";
|
|
74
|
+
import { currentData, runPipeline, type ChartConfig } from "./pipeline.ts";
|
|
75
|
+
import {
|
|
76
|
+
attachRangePresets as attachRangePresetsHelper,
|
|
77
|
+
type AttachedRangePresets,
|
|
78
|
+
type AttachRangePresetsOptions,
|
|
79
|
+
} from "./attach-presets.ts";
|
|
80
|
+
import {
|
|
81
|
+
createAttachedBrush,
|
|
82
|
+
type AttachBrushOptions,
|
|
83
|
+
type AttachedBrush,
|
|
84
|
+
type AttachedBrushInternal,
|
|
85
|
+
} from "./attach-brush.ts";
|
|
86
|
+
import { createDataViewport, type DataViewport } from "../viewport.ts";
|
|
87
|
+
import { bindDataViewport, type DataViewportBinding } from "../interactions.ts";
|
|
88
|
+
import type { Coord } from "./coord.ts";
|
|
89
|
+
import type { PanZoomConfig } from "./chart.ts";
|
|
90
|
+
import { resolveAes, type Aes } from "./aes.ts";
|
|
91
|
+
import { shallowObjectEqual } from "./equality.ts";
|
|
92
|
+
import type { DataPanBoundsOptions } from "../viewport.ts";
|
|
93
|
+
import type { AxisSelection } from "../interactions.ts";
|
|
94
|
+
import { readNumericDomain, readContinuousType, type PositionScaleOptions } from "./scales.ts";
|
|
95
|
+
import { isSignal, signal, type Signal } from "insomni/reactivity";
|
|
96
|
+
import { recordCpu, recordGpu } from "./profiling.ts";
|
|
97
|
+
import { GPU_DIM_GEOM_KINDS, type EmphasisResolver } from "./geoms/emphasis.ts";
|
|
98
|
+
import { createEmphasisDriver, type EmphasisDriver } from "./emphasis-driver.ts";
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Render-orchestration contract (P5 — core z-aware cache + damage migration)
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// Plot is a thin DAMAGE SOURCE for the core renderer. It no longer runs its own
|
|
104
|
+
// CPU-bake state machine; instead it emits ordered layers (`zIndex` bands) with
|
|
105
|
+
// `cache` HINTS and lets the core's per-frame cache policy decide bake-vs-live,
|
|
106
|
+
// composite order, and damage tracking. The behavior contract:
|
|
107
|
+
//
|
|
108
|
+
// • PAN / ZOOM (interaction in flight): every static layer's `cache` is
|
|
109
|
+
// flipped to "never" (see `applyInteractionCacheHints`). A pan recompiles
|
|
110
|
+
// the geoms each frame → the layer's pack version changes → an "auto" bake
|
|
111
|
+
// would re-bake (texture create + bake pass) EVERY frame, which is strictly
|
|
112
|
+
// worse than drawing live. So during a gesture all layers draw LIVE and each
|
|
113
|
+
// frame is a cheap full re-raster.
|
|
114
|
+
// • SETTLE (gesture ends): hints restore to "auto"; the core's policy bakes
|
|
115
|
+
// the now-static stack ONCE on the next full frame, after which pan-idle
|
|
116
|
+
// hovers ride the cached fast path. A (re)bake forces that frame full
|
|
117
|
+
// automatically (core contract) so there is no stale-composite window.
|
|
118
|
+
// • HOVER tooltip / crosshair / point-halo: overlay-only change → `drawOverlay`
|
|
119
|
+
// re-emits the overlay layer and issues `render(currentLayers(), { regions })`
|
|
120
|
+
// — a scissored, OIT-aware partial repaint of just the damaged rect(s). The
|
|
121
|
+
// overlay layer is `cache:"never"` and sits in the trailing z-band, so the
|
|
122
|
+
// baked marks/axis/hud composite UNDER it correctly (core T-ZBAKE z-runs).
|
|
123
|
+
// • DATA / SCALE / VIEW change: a normal `render(currentLayers(), { viewKey })`
|
|
124
|
+
// with no regions → full frame by construction. `viewKey` folds the visible
|
|
125
|
+
// domain so a pan-driven domain shift also forces full (the core's camera
|
|
126
|
+
// never moves for ui-space layers).
|
|
127
|
+
//
|
|
128
|
+
// Plot NEVER calls `cacheLayer`/`uncacheLayer` and NEVER passes `fullFrame:true`
|
|
129
|
+
// — the core's view fingerprint + force-full-after-bake logic owns that decision.
|
|
130
|
+
// When `partialRedraw` is off, every layer is `cache:"never"` and the renderer
|
|
131
|
+
// is non-persistent (pure-live fallback, byte-equivalent to the classic path).
|
|
132
|
+
|
|
133
|
+
// `GPU_DIM_GEOM_KINDS` — the set of geom kinds whose dim-others hover treatment
|
|
134
|
+
// rides the core's animated emphasis uniform — now lives in `./geoms/emphasis.ts`
|
|
135
|
+
// alongside the key-namespacing helpers (imported above) so the geom-tagging
|
|
136
|
+
// code and the mount agree on one source of truth.
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Internal entry. `chart.mount(canvas, opts)` calls this with its frozen
|
|
140
|
+
* config + the user-provided mount options.
|
|
141
|
+
*/
|
|
142
|
+
export function mountChart<T>(
|
|
143
|
+
config: ChartConfig<T>,
|
|
144
|
+
canvas: HTMLCanvasElement,
|
|
145
|
+
opts: MountPlotOptions<T> = {},
|
|
146
|
+
): MountedPlot<T> {
|
|
147
|
+
// -----------------------------------------------------------------------
|
|
148
|
+
// Resolve external pieces — ownership rules:
|
|
149
|
+
// - we only `destroy()` what we created.
|
|
150
|
+
// - any of {renderer, atlas, layers} can be brought from outside.
|
|
151
|
+
// - if you bring a renderer you also own its lifecycle / setBackground /
|
|
152
|
+
// onFrameTiming wiring; we leave them alone.
|
|
153
|
+
// -----------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
const ownedRenderer = !opts.renderer;
|
|
156
|
+
const ownedLayers = !opts.layers;
|
|
157
|
+
|
|
158
|
+
// Damage-tracked partial redraw. Only when we own *both* the renderer (so we
|
|
159
|
+
// can give it a persistent backbuffer) and the layers (so we can bake the
|
|
160
|
+
// marks layer to a texture). Otherwise it silently degrades to the classic
|
|
161
|
+
// full-frame path. `partial` gates every persistent-mode branch below;
|
|
162
|
+
// when false the code path is byte-identical to before.
|
|
163
|
+
const wantPartial = opts.partialRedraw ?? true;
|
|
164
|
+
// Warn only when partial redraw was *explicitly* requested but can't run
|
|
165
|
+
// because the caller brought their own renderer/layers. When it's just the
|
|
166
|
+
// default, silently degrade to the classic full-frame path (no warning).
|
|
167
|
+
if (opts.partialRedraw === true && (!ownedRenderer || !ownedLayers)) {
|
|
168
|
+
console.warn(
|
|
169
|
+
"insomni-plot: `partialRedraw` requires the mount to own both its renderer and layers; " +
|
|
170
|
+
"it is ignored when an external `renderer` or `layers` is supplied.",
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
const partial = wantPartial && ownedRenderer && ownedLayers;
|
|
174
|
+
|
|
175
|
+
// -----------------------------------------------------------------------
|
|
176
|
+
// Z-bands + cache hints (P5 migration)
|
|
177
|
+
// -----------------------------------------------------------------------
|
|
178
|
+
// Explicit flat-z bands make the composite order self-documenting and robust
|
|
179
|
+
// to `currentLayers()` reordering. Bands leave gaps so caller `extraLayers`
|
|
180
|
+
// (below/above marks) slot in between without colliding:
|
|
181
|
+
// axis = 0, below-geom = 10,11,…, marks = 100, above-geom = 110,111,…,
|
|
182
|
+
// hud = 200, overlay = 300.
|
|
183
|
+
const Z_AXIS = 0;
|
|
184
|
+
const Z_BELOW_BASE = 10;
|
|
185
|
+
const Z_MARKS = 100;
|
|
186
|
+
const Z_ABOVE_BASE = 110;
|
|
187
|
+
const Z_HUD = 200;
|
|
188
|
+
const Z_OVERLAY = 300;
|
|
189
|
+
|
|
190
|
+
// Cache hint for the STATIC layers (axis / below / marks / above / hud). When
|
|
191
|
+
// `partial` is off we run pure-live (every layer "never", renderer
|
|
192
|
+
// non-persistent), byte-equivalent to the classic full-frame path. The
|
|
193
|
+
// overlay layer is ALWAYS "never": its cursor shapes change every frame and it
|
|
194
|
+
// must stay live so it composites OVER the baked static stack (trailing z-band
|
|
195
|
+
// + live → core T-ZBAKE stacks bakes under it). `staticCacheHint` flips to
|
|
196
|
+
// "never" during a pan/zoom gesture (see `applyInteractionCacheHints`).
|
|
197
|
+
// `CacheHint` is the public union ("auto"|"always"|"never"), re-exported from
|
|
198
|
+
// the `insomni` barrel.
|
|
199
|
+
const staticCacheHint: CacheHint = partial ? "auto" : "never";
|
|
200
|
+
|
|
201
|
+
// device source: opts > config. Only required when we have to create a renderer ourselves.
|
|
202
|
+
const device = opts.device ?? config.device;
|
|
203
|
+
if (ownedRenderer && !device) {
|
|
204
|
+
throw new Error(
|
|
205
|
+
"chart.mount(canvas) needs a `device`. Pass `{ device }` in plot({...}) or in mount opts, " +
|
|
206
|
+
"or pass an existing `renderer`.",
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// DPR — explicit > window. Re-read on every resize tick (DPR can change
|
|
211
|
+
// when dragging a window between displays).
|
|
212
|
+
const initialDpr = opts.dpr ?? readDpr();
|
|
213
|
+
|
|
214
|
+
// -----------------------------------------------------------------------
|
|
215
|
+
// Renderer
|
|
216
|
+
// -----------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
// v3 `FrameTiming` carries v1 back-compat alias getters (`cpuMs`/`renderNs`/
|
|
219
|
+
// `computeNs`), so these reads work unchanged. `renderNs`/`computeNs` are
|
|
220
|
+
// non-null bigints on v3 (no GPU timestamp query — they are CPU analogs).
|
|
221
|
+
const onFrameTiming = (t: FrameTiming) => {
|
|
222
|
+
recordCpu("render-cpu", t.cpuMs);
|
|
223
|
+
recordGpu("render-pass", 0, Number(t.renderNs));
|
|
224
|
+
recordGpu("compute-passes", 0, Number(t.computeNs));
|
|
225
|
+
opts.onFrameTiming?.(t);
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
// v3: `createRenderer` auto-assembles shader + pipelines (+ OIT, default on).
|
|
229
|
+
// `persistent` + `onFrameTiming` live under `config`; the DPR is applied via
|
|
230
|
+
// `setDpr` below (the renderer never reads `window.devicePixelRatio` itself).
|
|
231
|
+
const renderer =
|
|
232
|
+
opts.renderer ??
|
|
233
|
+
createRenderer(device!, canvas, {
|
|
234
|
+
dpr: initialDpr,
|
|
235
|
+
config: { onFrameTiming, persistent: partial },
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// Background — explicit override > config.background > theme.background.
|
|
239
|
+
// Only set when we own the renderer; an external renderer is the caller's
|
|
240
|
+
// responsibility.
|
|
241
|
+
if (ownedRenderer) {
|
|
242
|
+
renderer.setBackground(opts.background ?? config.background ?? config.theme.background);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// -----------------------------------------------------------------------
|
|
246
|
+
// Atlas
|
|
247
|
+
// -----------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
// v3 externalizes the glyph atlas (D1=Hybrid): the renderer no longer owns a
|
|
250
|
+
// default font or `atlasFor`. We build an MSDF/SDF atlas from a system font
|
|
251
|
+
// and hand it to `createLayer({ atlas })`. A caller-supplied `externalAtlas`
|
|
252
|
+
// is used immediately and short-circuits the system-font load.
|
|
253
|
+
let atlas: GlyphAtlas | undefined = config.externalAtlas ?? undefined;
|
|
254
|
+
|
|
255
|
+
// -----------------------------------------------------------------------
|
|
256
|
+
// Layers
|
|
257
|
+
// -----------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
// When we own the layers and already have an atlas (caller-supplied), mint
|
|
260
|
+
// them with it up front. Otherwise mint atlas-less layers — shapes render
|
|
261
|
+
// immediately; text is enabled once the system-font atlas resolves and we
|
|
262
|
+
// remint below. External layers are used verbatim.
|
|
263
|
+
let axisLayer =
|
|
264
|
+
opts.layers?.axis ??
|
|
265
|
+
createLayer({ space: "ui", atlas, zIndex: Z_AXIS, cache: staticCacheHint, label: "axis" });
|
|
266
|
+
let marksLayer =
|
|
267
|
+
opts.layers?.marks ??
|
|
268
|
+
createLayer({ space: "ui", atlas, zIndex: Z_MARKS, cache: staticCacheHint, label: "marks" });
|
|
269
|
+
let hudLayer =
|
|
270
|
+
opts.layers?.hud ??
|
|
271
|
+
createLayer({ space: "ui", atlas, zIndex: Z_HUD, cache: staticCacheHint, label: "hud" });
|
|
272
|
+
// Cursor-driven overlays (tooltip / crosshair / brush / menu) live on their
|
|
273
|
+
// own layer above the hud. It is ALWAYS `cache:"never"`: its cursor shapes
|
|
274
|
+
// change every overlay frame, so it draws LIVE and — sitting in the trailing
|
|
275
|
+
// z-band (Z_OVERLAY) above the baked static stack — composites correctly OVER
|
|
276
|
+
// the baked marks/axis/hud (core T-ZBAKE: leading-run bakes composite UNDER
|
|
277
|
+
// live geometry). Repainted via `regions` in `drawOverlay` (damage-tracked).
|
|
278
|
+
// `oitExempt`: the overlay skips the bounded-K OIT A-buffer and draws
|
|
279
|
+
// post-resolve on top — over dense transparent marks its fragments (appended
|
|
280
|
+
// last) would otherwise be the first dropped on per-pixel budget overflow,
|
|
281
|
+
// rendering the tooltip ghost-faint.
|
|
282
|
+
let overlayLayer =
|
|
283
|
+
opts.layers?.overlay ??
|
|
284
|
+
createLayer({
|
|
285
|
+
space: "ui",
|
|
286
|
+
atlas,
|
|
287
|
+
zIndex: Z_OVERLAY,
|
|
288
|
+
cache: "never",
|
|
289
|
+
label: "overlay",
|
|
290
|
+
oitExempt: true,
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// Default font — `Layer.pushText` requires the layer to carry a glyph atlas.
|
|
294
|
+
// When we own the layers AND no external atlas was supplied, kick off the
|
|
295
|
+
// system-font load, build an SDF atlas, and remint the layers with it as soon
|
|
296
|
+
// as it resolves. External layers / atlas are the caller's responsibility.
|
|
297
|
+
// `fontReady` gates the first draw. We are ready immediately when we don't own
|
|
298
|
+
// the layers (caller wired text) or already hold an atlas (external).
|
|
299
|
+
let fontReady = !ownedLayers || atlas !== undefined;
|
|
300
|
+
// Declared early so the font-load `.then` can short-circuit when the mount
|
|
301
|
+
// is destroyed before the promise resolves — otherwise we'd allocate fresh
|
|
302
|
+
// layers and invalidate the loop on a torn-down chart.
|
|
303
|
+
let disposed = false;
|
|
304
|
+
if (ownedLayers && atlas === undefined) {
|
|
305
|
+
void loadSystemFont("sans-serif").then((font) => {
|
|
306
|
+
if (disposed) return;
|
|
307
|
+
atlas = new SdfGlyphAtlas(device!, font);
|
|
308
|
+
axisLayer = createLayer({
|
|
309
|
+
space: "ui",
|
|
310
|
+
atlas,
|
|
311
|
+
zIndex: Z_AXIS,
|
|
312
|
+
cache: staticCacheHint,
|
|
313
|
+
label: "axis",
|
|
314
|
+
});
|
|
315
|
+
marksLayer = createLayer({
|
|
316
|
+
space: "ui",
|
|
317
|
+
atlas,
|
|
318
|
+
zIndex: Z_MARKS,
|
|
319
|
+
cache: staticCacheHint,
|
|
320
|
+
label: "marks",
|
|
321
|
+
});
|
|
322
|
+
hudLayer = createLayer({
|
|
323
|
+
space: "ui",
|
|
324
|
+
atlas,
|
|
325
|
+
zIndex: Z_HUD,
|
|
326
|
+
cache: staticCacheHint,
|
|
327
|
+
label: "hud",
|
|
328
|
+
});
|
|
329
|
+
overlayLayer = createLayer({
|
|
330
|
+
space: "ui",
|
|
331
|
+
atlas,
|
|
332
|
+
zIndex: Z_OVERLAY,
|
|
333
|
+
cache: "never",
|
|
334
|
+
label: "overlay",
|
|
335
|
+
oitExempt: true,
|
|
336
|
+
});
|
|
337
|
+
fontReady = true;
|
|
338
|
+
inv.invalidate();
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// -----------------------------------------------------------------------
|
|
343
|
+
// Loop state
|
|
344
|
+
// -----------------------------------------------------------------------
|
|
345
|
+
|
|
346
|
+
const inv: Invalidator = createInvalidator(true);
|
|
347
|
+
|
|
348
|
+
// Damage-tracked partial-redraw state (only meaningful when `partial`).
|
|
349
|
+
//
|
|
350
|
+
// Dual-flag scheme: `inv.dirty` means "a full frame is needed" — every
|
|
351
|
+
// existing `inv.invalidate()` keeps that meaning, so the default for any
|
|
352
|
+
// unclassified change is a (correct) full frame. `overlayDirty` is the
|
|
353
|
+
// separate, weaker signal that *only* the cursor overlays moved; it drives
|
|
354
|
+
// the cheap per-rect repaint. A pending full frame always wins.
|
|
355
|
+
let overlayDirty = false;
|
|
356
|
+
// Overlay-layer content bounds (CSS px) from the previous frame, so the next
|
|
357
|
+
// overlay frame can erase the old footprint as well as paint the new one.
|
|
358
|
+
let lastOverlayRect: FrameRect | null = null;
|
|
359
|
+
// Pan-tax settle detection (partial mode only). Tracks whether the previous
|
|
360
|
+
// `tick()` saw an active pan/zoom/fling. When it flips true → false we force
|
|
361
|
+
// one final `drawFull()` so the now-settled axis/hud/marks layers re-bake
|
|
362
|
+
// before any cheaper overlay-only frame composites against a live-but-gone
|
|
363
|
+
// sprite. Drag-end alone fires no `onChange` when fling is disabled, so this
|
|
364
|
+
// is the only thing guaranteeing the settle re-bake.
|
|
365
|
+
let wasInteracting = false;
|
|
366
|
+
// Debug annotation (P5-T1, decision D7) — the caller's INTENT for the next full
|
|
367
|
+
// frame, surfaced next to the core's own decision in the frame inspector. A
|
|
368
|
+
// weak classification latch: callers that know their cause (setData/update →
|
|
369
|
+
// "data-changed", resize → "resize", settle → "settle-rebake") set this before
|
|
370
|
+
// `inv.invalidate()`; `drawFull` reads it (overridden by "pan-zoom" when a
|
|
371
|
+
// gesture is live), then resets to "invalidate" for any unclassified change.
|
|
372
|
+
// Read only on the (already-non-zero-cost) full-frame path; core ignores it
|
|
373
|
+
// when the probe is disabled, so it is byte-irrelevant to rendering.
|
|
374
|
+
let pendingFullReason = "invalidate";
|
|
375
|
+
function requestOverlay(): void {
|
|
376
|
+
// Classic path has no partial frames — fall back to a normal full redraw.
|
|
377
|
+
if (partial) overlayDirty = true;
|
|
378
|
+
else inv.invalidate();
|
|
379
|
+
}
|
|
380
|
+
// Invalidator handed to the cursor overlays (tooltip / crosshair / brush /
|
|
381
|
+
// menu). Inherits everything from `inv` but routes `invalidate()` to an
|
|
382
|
+
// overlay-only repaint; `dispose()` is a no-op because the mount owns `inv`.
|
|
383
|
+
const overlayInv: Invalidator = Object.assign(Object.create(inv) as Invalidator, {
|
|
384
|
+
invalidate: () => requestOverlay(),
|
|
385
|
+
dispose: () => {},
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
const autoFrame = opts.autoFrame !== false;
|
|
389
|
+
const pauseOnHidden = opts.pauseOnHidden !== false;
|
|
390
|
+
const autoResize = opts.autoResize ?? (opts.width === undefined && opts.height === undefined);
|
|
391
|
+
|
|
392
|
+
// Working dimensions (CSS pixels).
|
|
393
|
+
let width = opts.width ?? config.width ?? (canvas.clientWidth || DEFAULT_CHART_WIDTH);
|
|
394
|
+
let height = opts.height ?? config.height ?? (canvas.clientHeight || DEFAULT_CHART_HEIGHT);
|
|
395
|
+
let dpr = initialDpr;
|
|
396
|
+
let backgroundOverride: Color | null = opts.background ?? null;
|
|
397
|
+
|
|
398
|
+
// Builder source — user can pass either a static `Chart<T>` (the one this
|
|
399
|
+
// mount was attached to) or a closure that rebuilds on each invalidation.
|
|
400
|
+
// We always re-resolve to a `ChartConfig<T>` in the draw path.
|
|
401
|
+
let activeConfig: ChartConfig<T> = config;
|
|
402
|
+
let builder: (() => Chart<T>) | null = opts.build ?? null;
|
|
403
|
+
|
|
404
|
+
// Reactive data: subscribe if the active config's data is a signal. We
|
|
405
|
+
// re-resolve this every time activeConfig changes (in `update`).
|
|
406
|
+
let unsubscribeData: (() => void) | null = null;
|
|
407
|
+
let dataSnapshot: readonly T[] = currentData(activeConfig.data);
|
|
408
|
+
function bindDataSignal(): void {
|
|
409
|
+
unsubscribeData?.();
|
|
410
|
+
unsubscribeData = null;
|
|
411
|
+
dataSnapshot = currentData(activeConfig.data);
|
|
412
|
+
if (isSignal<readonly T[]>(activeConfig.data)) {
|
|
413
|
+
unsubscribeData = (activeConfig.data as Signal<readonly T[]>).subscribe((next) => {
|
|
414
|
+
dataSnapshot = next;
|
|
415
|
+
resetEmphasisForData(); // clear stale index-derived emphasis (Fix C)
|
|
416
|
+
pendingFullReason = "data-changed";
|
|
417
|
+
inv.invalidate();
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
bindDataSignal();
|
|
422
|
+
|
|
423
|
+
// -----------------------------------------------------------------------
|
|
424
|
+
// Pan / zoom — DataViewport + binding, when enabled
|
|
425
|
+
// -----------------------------------------------------------------------
|
|
426
|
+
|
|
427
|
+
const panZoomCfg = resolvePanZoom(opts.panZoom);
|
|
428
|
+
let panZoomViewport: DataViewport<number, number> | null = null;
|
|
429
|
+
let panZoomBinding: DataViewportBinding | null = null;
|
|
430
|
+
let panZoomUnsub: (() => void) | null = null;
|
|
431
|
+
let yFitPadding: number | null = null;
|
|
432
|
+
const attachedPresets = new Set<AttachedRangePresets>();
|
|
433
|
+
const attachedSeriesReadouts = new Set<SeriesReadoutInternal>();
|
|
434
|
+
// Snapshot of the latest pipeline scales — series-readout needs the color
|
|
435
|
+
// scale to resolve swatches for non-constant color channels.
|
|
436
|
+
let latestScales: import("./geoms/types.ts").ScaleBundle | null = null;
|
|
437
|
+
// Latest hover-focus decorators (point halo + bring-to-front). Captured each
|
|
438
|
+
// full compile; replayed into the overlay layer on hover so local focus
|
|
439
|
+
// treatment costs an overlay re-bake, not a marks recompute.
|
|
440
|
+
let latestHoverDecorators: readonly import("./geoms/types.ts").GeomHoverDecorator[] = [];
|
|
441
|
+
if (panZoomCfg) {
|
|
442
|
+
const xDom = readNumericDomain(activeConfig.scaleOverrides.x, "x");
|
|
443
|
+
const yDom = readNumericDomain(activeConfig.scaleOverrides.y, "y");
|
|
444
|
+
const xType = readContinuousType(activeConfig.scaleOverrides.x);
|
|
445
|
+
const yType = readContinuousType(activeConfig.scaleOverrides.y);
|
|
446
|
+
panZoomViewport = createDataViewport<number, number>({
|
|
447
|
+
frame: createFrame({ x: 0, y: 0, width, height }),
|
|
448
|
+
x: { type: xType, domain: xDom },
|
|
449
|
+
y: { type: yType, domain: yDom },
|
|
450
|
+
minZoom: panZoomCfg.minZoom,
|
|
451
|
+
maxZoom: panZoomCfg.maxZoom,
|
|
452
|
+
panBounds: panZoomCfg.panBounds,
|
|
453
|
+
});
|
|
454
|
+
// Route pan/zoom through the active coord. For Cartesian this is a
|
|
455
|
+
// direct passthrough (byte-identical to `viewport.panBy`/`zoomAt`); for
|
|
456
|
+
// polar / radial it decomposes drag deltas into rotation + radial
|
|
457
|
+
// translation and restricts zoom to the radius scale. The wrapper uses
|
|
458
|
+
// prototype inheritance so every other DataViewport member (state,
|
|
459
|
+
// visibleXDomain, absoluteFrame, etc.) flows through untouched.
|
|
460
|
+
const coordViewport = wrapViewportThroughCoord(
|
|
461
|
+
panZoomViewport,
|
|
462
|
+
() => activeConfig.coord,
|
|
463
|
+
() => inv.invalidate(),
|
|
464
|
+
);
|
|
465
|
+
panZoomBinding = bindDataViewport(coordViewport, canvas, {
|
|
466
|
+
pan: panZoomCfg.pan,
|
|
467
|
+
zoom: panZoomCfg.zoom,
|
|
468
|
+
});
|
|
469
|
+
panZoomUnsub = panZoomViewport.onChange(() => inv.invalidate());
|
|
470
|
+
yFitPadding = panZoomCfg.yFitPadding;
|
|
471
|
+
}
|
|
472
|
+
const clipMarks = opts.clipMarks ?? true;
|
|
473
|
+
|
|
474
|
+
// -----------------------------------------------------------------------
|
|
475
|
+
// Interactions — manager + grammar tooltip wiring
|
|
476
|
+
// -----------------------------------------------------------------------
|
|
477
|
+
|
|
478
|
+
// -----------------------------------------------------------------------
|
|
479
|
+
// Transitions — animated channel lerp on data/scale change
|
|
480
|
+
// -----------------------------------------------------------------------
|
|
481
|
+
|
|
482
|
+
const transitionsCfg = resolveTransitionsCfg(opts.transitions);
|
|
483
|
+
let grammarTransitions: GrammarTransitions | null = null;
|
|
484
|
+
const transitionKey =
|
|
485
|
+
transitionsCfg && transitionsCfg.key
|
|
486
|
+
? (datum: T, index: number) => String(transitionsCfg.key!(datum, index))
|
|
487
|
+
: undefined;
|
|
488
|
+
if (transitionsCfg !== false) {
|
|
489
|
+
const m = config.theme.motion;
|
|
490
|
+
const resolvedMotion = resolveMotion(m, m.data);
|
|
491
|
+
grammarTransitions = createGrammarTransitions({
|
|
492
|
+
duration:
|
|
493
|
+
transitionsCfg.duration !== undefined
|
|
494
|
+
? transitionsCfg.duration / 1000
|
|
495
|
+
: resolvedMotion.durationMs / 1000,
|
|
496
|
+
easing: resolvedMotion.easing,
|
|
497
|
+
invalidator: inv,
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Track the previous data reference to detect changes.
|
|
502
|
+
let prevDataRef: readonly unknown[] | null = null;
|
|
503
|
+
// Track the previous scaleOverrides object to detect explicit scale-domain
|
|
504
|
+
// changes (e.g. `chart.scale("y", { domain: [...] })`). Identity compare per
|
|
505
|
+
// channel works because `chart.scale()` produces a new sub-object only for
|
|
506
|
+
// the changed channel; unchanged channels retain identity.
|
|
507
|
+
let prevScaleOverrides: ChartConfig<T>["scaleOverrides"] | null = null;
|
|
508
|
+
|
|
509
|
+
const interactionsCfg = resolveInteractions(opts.interactions);
|
|
510
|
+
let manager: InteractionManager | null = null;
|
|
511
|
+
let hitLayer: GrammarHitLayer | null = null;
|
|
512
|
+
let grammarTooltip: GrammarTooltip | null = null;
|
|
513
|
+
let grammarCrosshair: GrammarCrosshair | null = null;
|
|
514
|
+
let grammarSelection: GrammarSelection | null = null;
|
|
515
|
+
let grammarBrush: GrammarBrush | null = null;
|
|
516
|
+
// Imperative brush attached via `MountedPlot.attachBrush`. Mutually exclusive
|
|
517
|
+
// with `grammarBrush` — `attachBrush` throws if either is already live.
|
|
518
|
+
let attachedBrush: AttachedBrushInternal | null = null;
|
|
519
|
+
let grammarLegend: GrammarLegend | null = null;
|
|
520
|
+
let grammarContextMenu: GrammarContextMenu | null = null;
|
|
521
|
+
// Hover state — published as a signal on the public handle so chart users
|
|
522
|
+
// can react (custom annotations, side-panel detail views) and so future
|
|
523
|
+
// animated transitions can drive off it. Populated synchronously from
|
|
524
|
+
// tooltip's onHover; null when nothing's hovered.
|
|
525
|
+
const hoverSignal: Signal<HoveredHit | null> = signal<HoveredHit | null>(null);
|
|
526
|
+
// Last hit the hover subscriber acted on, so a per-pointer-move re-fire on the
|
|
527
|
+
// same row is deduped (skip redundant emphasis/overlay work). The dim/halo
|
|
528
|
+
// animation itself is driven by the GPU emphasis uniform below, not a CPU
|
|
529
|
+
// from/to lerp.
|
|
530
|
+
let lastHoverHit: HoveredHit | null = null;
|
|
531
|
+
// Pending hoverSignal.set(null) handle for the swap-grace debounce. Held at
|
|
532
|
+
// module scope of the closure so the tooltip's onHover callback can cancel
|
|
533
|
+
// it on the next non-null hit.
|
|
534
|
+
let pendingHoverNullHandle: number | null = null;
|
|
535
|
+
// Cursor-tracking cleanup for tooltip pointerPos. Set when the tooltip is
|
|
536
|
+
// created, called during dispose.
|
|
537
|
+
let cleanupCursorTracking: (() => void) | null = null;
|
|
538
|
+
|
|
539
|
+
// Shared helper: apply a hover hit (or clear) to hoverSignal + crosshair.
|
|
540
|
+
// Used by both the mouse-hover onHover path (with its grace-window debounce)
|
|
541
|
+
// and the touch-tap onPress path (which calls this directly).
|
|
542
|
+
function applyHover(next: HoveredHit | null): void {
|
|
543
|
+
hoverSignal.set(next);
|
|
544
|
+
updateCrosshairBoundsFor(next ? { x: next.x, y: next.y } : null);
|
|
545
|
+
grammarCrosshair?.setPosition(next ? { x: next.x, y: next.y } : null);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// ----- Animated GPU emphasis (P5-T3) ----------------------------------
|
|
549
|
+
// The dim-others hover treatment is driven entirely by the core's emphasis
|
|
550
|
+
// uniform: on a hover hit-change over a GPU-dim geom we resolve the hit to its
|
|
551
|
+
// namespaced focused key and animate `t` 0→1 (ease-out cubic) over
|
|
552
|
+
// `theme.interactions.hover.durationMs`; on exit we animate t→0 and keep the
|
|
553
|
+
// last focused key until t reaches 0 (then clear). Zero marks recompile.
|
|
554
|
+
//
|
|
555
|
+
// The state machine lives in `./emphasis-driver.ts` (pure, unit-tested with a
|
|
556
|
+
// deterministic clock). The mount is a thin consumer: it feeds resolved hover
|
|
557
|
+
// keys into `onHover`, advances the ramp from the RAF tick via `step(now)`,
|
|
558
|
+
// and routes the returned `{ needsFrame, full }` decisions into `inv` /
|
|
559
|
+
// `requestOverlay` / the repaint-only frame path (below).
|
|
560
|
+
//
|
|
561
|
+
// The uniform holds ONE key. Swapping hover A→B mid-animation snaps the
|
|
562
|
+
// focused key to B and continues t toward 1 (no per-key crossfade — a single
|
|
563
|
+
// global uniform can't express two focuses; documented limitation).
|
|
564
|
+
//
|
|
565
|
+
// Latest per-frame emphasis resolvers, captured each full compile.
|
|
566
|
+
let latestEmphasisResolvers: readonly EmphasisResolver[] = [];
|
|
567
|
+
const emphasis: EmphasisDriver = createEmphasisDriver({
|
|
568
|
+
// Re-read on every onHover/step so a live reduced-motion / theme change
|
|
569
|
+
// takes effect immediately. `motion.enabled === false` collapses to a snap.
|
|
570
|
+
durationS: () => {
|
|
571
|
+
const m = activeConfig.theme.motion;
|
|
572
|
+
const durMs = activeConfig.theme.interactions.hover.durationMs ?? 120;
|
|
573
|
+
return m.enabled && durMs > 0 ? durMs / 1000 : 0;
|
|
574
|
+
},
|
|
575
|
+
dim: () => activeConfig.theme.interactions.hover.dim,
|
|
576
|
+
setEmphasis: (s) => renderer.setEmphasis(s),
|
|
577
|
+
});
|
|
578
|
+
// True while the dim animation is mid-flight — a changed emphasis uniform
|
|
579
|
+
// alters pixels EVERYWHERE, so overlay/regions partial frames are FORBIDDEN
|
|
580
|
+
// until t reaches a settled value (exactly 0 or 1). See `tick()` / `drawOverlay`.
|
|
581
|
+
const emphasisAnimating = (): boolean => emphasis.animating();
|
|
582
|
+
// Set by an emphasis frame request that needs a FULL render but NOT a geom
|
|
583
|
+
// recompile (the per-tick ramp): a uniform-only change. The tick services it
|
|
584
|
+
// via the repaint-only path (`renderer.render(currentLayers())` with the live
|
|
585
|
+
// packs untouched) so the auto-cached glyph axis never re-bakes mid-ramp.
|
|
586
|
+
let emphasisRepaintPending = false;
|
|
587
|
+
// Resolve a hit to its GPU focused emphasis key, or 0 when no dim geom owns it.
|
|
588
|
+
function focusedKeyFor(hit: HoveredHit | null): number {
|
|
589
|
+
if (!hit || !hoverEmphasisEnabled() || !GPU_DIM_GEOM_KINDS.has(hit.geomKind)) return 0;
|
|
590
|
+
for (const r of latestEmphasisResolvers) {
|
|
591
|
+
if (r.geomKind === hit.geomKind && r.data === hit.data) {
|
|
592
|
+
return r.resolve(hit) ?? 0;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
return 0;
|
|
596
|
+
}
|
|
597
|
+
hoverSignal.subscribe((next) => {
|
|
598
|
+
if (next === lastHoverHit) return;
|
|
599
|
+
lastHoverHit = next;
|
|
600
|
+
|
|
601
|
+
// Drive the emphasis state machine with the resolved focused key (0 = no dim
|
|
602
|
+
// geom under the cursor). The driver snaps under reduced-motion and reports
|
|
603
|
+
// whether a frame is needed and whether it must be FULL.
|
|
604
|
+
const req = emphasis.onHover(focusedKeyFor(next));
|
|
605
|
+
// A hover hit-change is routed through a FULL recompile (`inv.invalidate()` →
|
|
606
|
+
// drawFull), NOT the per-tick repaint-only path. Rationale: the deliberately-
|
|
607
|
+
// inert nearestX geoms (`area`/`rolling`) read `hovered` at COMPILE time, so
|
|
608
|
+
// a hover-change over them must recompile to refresh their halo; selection /
|
|
609
|
+
// legend changes arrive via their own invalidations. The hot path (~8x/ramp)
|
|
610
|
+
// is the per-tick `step()`, which IS optimized to repaint-only below — the
|
|
611
|
+
// single hover-change frame is not worth the soundness risk.
|
|
612
|
+
if (req.needsFrame) {
|
|
613
|
+
if (req.full) inv.invalidate();
|
|
614
|
+
else requestOverlay(); // exit that was never dimmed — overlay-only repaint
|
|
615
|
+
}
|
|
616
|
+
});
|
|
617
|
+
// Fix C — clear stale emphasis on a data change. Emphasis keys are
|
|
618
|
+
// index-derived, so a held hover keeps the OLD focused key dimming RE-INDEXED
|
|
619
|
+
// instances after a data swap. Snap the uniform off (driver `reset()`) and drop
|
|
620
|
+
// the hover-hit dedup state so the pointer's NEXT move re-establishes emphasis
|
|
621
|
+
// against the new data. Called by setData / update / the data signal BEFORE the
|
|
622
|
+
// full redraw they already trigger.
|
|
623
|
+
function resetEmphasisForData(): void {
|
|
624
|
+
emphasis.reset(); // snaps the uniform to t:0 / focusedKey:0
|
|
625
|
+
emphasisRepaintPending = false;
|
|
626
|
+
lastHoverHit = null; // force the next hoverSignal fire through the subscriber
|
|
627
|
+
}
|
|
628
|
+
// Selection state — array of currently-selected hits. Always an array (even
|
|
629
|
+
// when selection is disabled) so consumers don't branch on null/undefined.
|
|
630
|
+
const selectionSignal: Signal<readonly HoveredHit[]> = signal<readonly HoveredHit[]>([]);
|
|
631
|
+
// Brush state — separate signal from selection so consumers can react to
|
|
632
|
+
// either (or both) without conflating range queries with discrete picks.
|
|
633
|
+
const brushedSignal: Signal<readonly HoveredHit[]> = signal<readonly HoveredHit[]>([]);
|
|
634
|
+
// Hidden series — set of keys toggled via legend. pipeline respects this.
|
|
635
|
+
const hiddenSignal: Signal<ReadonlySet<string>> = signal<ReadonlySet<string>>(new Set());
|
|
636
|
+
if (
|
|
637
|
+
interactionsCfg.tooltip ||
|
|
638
|
+
interactionsCfg.crosshair ||
|
|
639
|
+
interactionsCfg.selection ||
|
|
640
|
+
interactionsCfg.brush ||
|
|
641
|
+
interactionsCfg.legend ||
|
|
642
|
+
interactionsCfg.contextMenu
|
|
643
|
+
) {
|
|
644
|
+
manager = createInteractionManager(canvas);
|
|
645
|
+
manager.onChange(() => requestOverlay());
|
|
646
|
+
// Shared hit-test fan-out — one PointCloudNode per geom regardless of
|
|
647
|
+
// how many consumers (tooltip, selection, contextMenu). Only built when
|
|
648
|
+
// at least one hit-driven consumer is enabled.
|
|
649
|
+
if (
|
|
650
|
+
interactionsCfg.tooltip ||
|
|
651
|
+
interactionsCfg.selection ||
|
|
652
|
+
(interactionsCfg.contextMenu && interactionsCfg.contextMenu.hitMode !== "background")
|
|
653
|
+
) {
|
|
654
|
+
hitLayer = createGrammarHitLayer({ manager, element: canvas });
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
// Crosshair bounds — clipped to the active plot frame so the guide line
|
|
658
|
+
// doesn't extend through axis/legend slots. Updated each draw from the
|
|
659
|
+
// pipeline output; falls back to the full canvas before the first draw.
|
|
660
|
+
// For faceted charts, this becomes the *panel* frame containing the
|
|
661
|
+
// current hover position rather than the chart-wide union frame.
|
|
662
|
+
let crosshairBounds = { x: 0, y: 0, width, height };
|
|
663
|
+
// Chart-wide plot frame (faceted: union of panels). Used by brush + as the
|
|
664
|
+
// crosshair fallback when no panel contains the hover position.
|
|
665
|
+
let chartPlotFrame: Frame = createFrame({ x: 0, y: 0, width, height });
|
|
666
|
+
// Latest per-panel frames from the pipeline. Empty for non-faceted charts.
|
|
667
|
+
let lastPanelFrames: readonly Frame[] = [];
|
|
668
|
+
|
|
669
|
+
function frameContaining(p: { x: number; y: number }): Frame | null {
|
|
670
|
+
for (const f of lastPanelFrames) {
|
|
671
|
+
if (p.x >= f.x && p.x <= f.x + f.width && p.y >= f.y && p.y <= f.y + f.height) {
|
|
672
|
+
return f;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
return null;
|
|
676
|
+
}
|
|
677
|
+
function updateCrosshairBoundsFor(p: { x: number; y: number } | null): void {
|
|
678
|
+
if (lastPanelFrames.length === 0 || !p) {
|
|
679
|
+
crosshairBounds = chartPlotFrame;
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
const f = frameContaining(p);
|
|
683
|
+
crosshairBounds = f ? { x: f.x, y: f.y, width: f.width, height: f.height } : chartPlotFrame;
|
|
684
|
+
}
|
|
685
|
+
if (interactionsCfg.crosshair) {
|
|
686
|
+
grammarCrosshair = createGrammarCrosshair(
|
|
687
|
+
{
|
|
688
|
+
// The crosshair/brush/menu/tooltip primitives are v1-only insomni
|
|
689
|
+
// helpers (no v3 equivalent yet); they draw via `pushSegment`/`pushRect`
|
|
690
|
+
// which exist identically on the v3 `Layer` at runtime, so the v3
|
|
691
|
+
// overlay layer is bridged to the v1 `Layer` type here.
|
|
692
|
+
hudLayer: () => overlayLayer as unknown as V1Layer,
|
|
693
|
+
theme: () => activeConfig.theme,
|
|
694
|
+
bounds: () => crosshairBounds,
|
|
695
|
+
invalidator: overlayInv,
|
|
696
|
+
},
|
|
697
|
+
typeof interactionsCfg.crosshair === "object" ? interactionsCfg.crosshair : {},
|
|
698
|
+
);
|
|
699
|
+
// Free pointer-following mode — register a low-zIndex InteractionNode over
|
|
700
|
+
// the chart-wide plot frame that pushes raw cursor positions into the
|
|
701
|
+
// crosshair. The hit-layer's pointcloud nodes sit at higher zIndex, so
|
|
702
|
+
// when the cursor is over a hit point the tooltip's onHover snaps the
|
|
703
|
+
// crosshair to the data position and this node never sees those events.
|
|
704
|
+
// Outside hits, this node fires and the crosshair tracks the raw cursor.
|
|
705
|
+
const crosshairCfg =
|
|
706
|
+
typeof interactionsCfg.crosshair === "object" ? interactionsCfg.crosshair : {};
|
|
707
|
+
if (crosshairCfg.followPointer && manager) {
|
|
708
|
+
manager.add({
|
|
709
|
+
zIndex: -1,
|
|
710
|
+
space: "ui",
|
|
711
|
+
bounds: () => chartPlotFrame,
|
|
712
|
+
onHoverEnter: (e) => {
|
|
713
|
+
updateCrosshairBoundsFor({ x: e.x, y: e.y });
|
|
714
|
+
grammarCrosshair?.setPosition({ x: e.x, y: e.y });
|
|
715
|
+
},
|
|
716
|
+
onHoverMove: (e) => {
|
|
717
|
+
updateCrosshairBoundsFor({ x: e.x, y: e.y });
|
|
718
|
+
grammarCrosshair?.setPosition({ x: e.x, y: e.y });
|
|
719
|
+
},
|
|
720
|
+
onHoverLeave: () => {
|
|
721
|
+
grammarCrosshair?.setPosition(null);
|
|
722
|
+
},
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
if (interactionsCfg.selection && manager && hitLayer) {
|
|
727
|
+
grammarSelection = createGrammarSelection(
|
|
728
|
+
{ manager, hitLayer },
|
|
729
|
+
{
|
|
730
|
+
onChange: (selected) => {
|
|
731
|
+
selectionSignal.set(selected);
|
|
732
|
+
inv.invalidate();
|
|
733
|
+
},
|
|
734
|
+
},
|
|
735
|
+
);
|
|
736
|
+
}
|
|
737
|
+
if (interactionsCfg.brush && manager) {
|
|
738
|
+
grammarBrush = createGrammarBrush(
|
|
739
|
+
{
|
|
740
|
+
manager,
|
|
741
|
+
// Brush operates on the chart-wide plot frame even when faceted —
|
|
742
|
+
// a range query that spans panels is still meaningful. Crosshair
|
|
743
|
+
// bounds are panel-scoped (see `crosshairBounds`).
|
|
744
|
+
bounds: () => chartPlotFrame,
|
|
745
|
+
hudLayer: () => overlayLayer as unknown as V1Layer,
|
|
746
|
+
theme: () => activeConfig.theme,
|
|
747
|
+
invalidator: overlayInv,
|
|
748
|
+
},
|
|
749
|
+
{
|
|
750
|
+
...(typeof interactionsCfg.brush === "object" ? interactionsCfg.brush : {}),
|
|
751
|
+
onChange: (hits) => {
|
|
752
|
+
brushedSignal.set(hits);
|
|
753
|
+
// Brushed set never feeds mark compile (only hovered/selected/hidden
|
|
754
|
+
// do), so a brush change is an overlay-only repaint.
|
|
755
|
+
requestOverlay();
|
|
756
|
+
},
|
|
757
|
+
},
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
if (interactionsCfg.legend && manager) {
|
|
761
|
+
grammarLegend = createGrammarLegend({
|
|
762
|
+
manager,
|
|
763
|
+
hidden: hiddenSignal,
|
|
764
|
+
invalidator: inv,
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
if (interactionsCfg.contextMenu && manager) {
|
|
768
|
+
const cfg = interactionsCfg.contextMenu;
|
|
769
|
+
// For `background` mode we never need a hit-layer; resolve through a stub.
|
|
770
|
+
// Otherwise reuse the shared hit-layer built above. A `background`-mode
|
|
771
|
+
// menu running alongside a tooltip still gets the shared hit-layer (no
|
|
772
|
+
// harm — it just ignores it).
|
|
773
|
+
const hl: GrammarHitLayer = hitLayer ?? {
|
|
774
|
+
sync: () => {},
|
|
775
|
+
subscribe: () => () => {},
|
|
776
|
+
pickAt: () => null,
|
|
777
|
+
state: () => ({ active: null }),
|
|
778
|
+
subscribeState: () => () => {},
|
|
779
|
+
dispose: () => {},
|
|
780
|
+
};
|
|
781
|
+
grammarContextMenu = createGrammarContextMenu(
|
|
782
|
+
{
|
|
783
|
+
manager,
|
|
784
|
+
hitLayer: hl,
|
|
785
|
+
hudLayer: () => overlayLayer as unknown as V1Layer,
|
|
786
|
+
atlas: () => atlas,
|
|
787
|
+
theme: () => activeConfig.theme,
|
|
788
|
+
bounds: () => ({ x: 0, y: 0, width, height }),
|
|
789
|
+
invalidator: overlayInv,
|
|
790
|
+
onViewportChange: panZoomViewport ? (cb) => panZoomViewport!.onChange(cb) : undefined,
|
|
791
|
+
},
|
|
792
|
+
{
|
|
793
|
+
hitMode: cfg.hitMode ?? "nearest-point",
|
|
794
|
+
managerOpts:
|
|
795
|
+
cfg.holdMs !== undefined || cfg.slopPx !== undefined
|
|
796
|
+
? { holdMs: cfg.holdMs, slopPx: cfg.slopPx }
|
|
797
|
+
: undefined,
|
|
798
|
+
// Suppress trigger while the data viewport is mid-pan / mid-fling.
|
|
799
|
+
// The manager already cancels touch long-press on drag promotion;
|
|
800
|
+
// this catches the right-click-during-drag case for mouse.
|
|
801
|
+
isSuppressed: panZoomBinding
|
|
802
|
+
? () => panZoomBinding!.interacting || panZoomBinding!.flinging
|
|
803
|
+
: undefined,
|
|
804
|
+
onTrigger: cfg.onTrigger,
|
|
805
|
+
items: cfg.items,
|
|
806
|
+
onAction: cfg.onAction,
|
|
807
|
+
placement: cfg.placement,
|
|
808
|
+
style: cfg.style,
|
|
809
|
+
},
|
|
810
|
+
);
|
|
811
|
+
}
|
|
812
|
+
if (interactionsCfg.tooltip && hitLayer) {
|
|
813
|
+
// Track cursor position so the tooltip anchor follows the pointer even
|
|
814
|
+
// while the cursor stays within the same data cell (where no enter/leave
|
|
815
|
+
// fires from the hit-layer). This keeps the tooltip box near the cursor
|
|
816
|
+
// in dense UIs like heatmaps instead of being left behind at the enter
|
|
817
|
+
// position.
|
|
818
|
+
let cursorPos: { x: number; y: number } | null = null;
|
|
819
|
+
const onCursorMove = (e: PointerEvent) => {
|
|
820
|
+
cursorPos = { x: e.offsetX, y: e.offsetY };
|
|
821
|
+
};
|
|
822
|
+
const onCursorLeave = () => {
|
|
823
|
+
cursorPos = null;
|
|
824
|
+
};
|
|
825
|
+
canvas.addEventListener("pointermove", onCursorMove);
|
|
826
|
+
canvas.addEventListener("pointerleave", onCursorLeave);
|
|
827
|
+
cleanupCursorTracking = () => {
|
|
828
|
+
canvas.removeEventListener("pointermove", onCursorMove);
|
|
829
|
+
canvas.removeEventListener("pointerleave", onCursorLeave);
|
|
830
|
+
};
|
|
831
|
+
const ttCfg = typeof interactionsCfg.tooltip === "object" ? interactionsCfg.tooltip : {};
|
|
832
|
+
const axisMode = ttCfg.trigger === "axis";
|
|
833
|
+
grammarTooltip = createGrammarTooltip(
|
|
834
|
+
{
|
|
835
|
+
hitLayer,
|
|
836
|
+
hudLayer: () => overlayLayer as unknown as V1Layer,
|
|
837
|
+
atlas: () => atlas,
|
|
838
|
+
theme: () => activeConfig.theme,
|
|
839
|
+
bounds: () => ({ x: 0, y: 0, width, height }),
|
|
840
|
+
invalidator: overlayInv,
|
|
841
|
+
pointerPos: () => cursorPos,
|
|
842
|
+
scales: () => latestScales,
|
|
843
|
+
onAxisPointer:
|
|
844
|
+
axisMode && manager
|
|
845
|
+
? (h) => {
|
|
846
|
+
const node = manager!.add({
|
|
847
|
+
zIndex: -2, // below hit clouds (1000+) and crosshair (-1)
|
|
848
|
+
space: "ui",
|
|
849
|
+
bounds: () => chartPlotFrame,
|
|
850
|
+
onHoverEnter: (e) => h.move({ x: e.x, y: e.y }),
|
|
851
|
+
onHoverMove: (e) => h.move({ x: e.x, y: e.y }),
|
|
852
|
+
onHoverLeave: () => h.leave(),
|
|
853
|
+
});
|
|
854
|
+
return () => node.destroy();
|
|
855
|
+
}
|
|
856
|
+
: undefined,
|
|
857
|
+
},
|
|
858
|
+
{
|
|
859
|
+
...(typeof interactionsCfg.tooltip === "object" ? interactionsCfg.tooltip : {}),
|
|
860
|
+
onHover: (hit) => {
|
|
861
|
+
// Debounce null transitions: when leave fires, defer the
|
|
862
|
+
// `hoverSignal.set(null)` through a grace window. A non-null hit
|
|
863
|
+
// arriving within the window cancels the pending null and applies the
|
|
864
|
+
// new hit directly. This keeps `emphFocusedKey` (held in the emphasis
|
|
865
|
+
// driver) pointed at the previous focus until the swap lands, so the
|
|
866
|
+
// dim stays continuous and never flashes un-dim → re-dim as the cursor
|
|
867
|
+
// crosses between adjacent geoms.
|
|
868
|
+
if (pendingHoverNullHandle !== null) {
|
|
869
|
+
clearTimeout(pendingHoverNullHandle);
|
|
870
|
+
pendingHoverNullHandle = null;
|
|
871
|
+
}
|
|
872
|
+
if (hit) {
|
|
873
|
+
applyHover(hit);
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
const grace = activeConfig.theme.interactions.hoverSwapGraceMs;
|
|
877
|
+
if (grace > 0) {
|
|
878
|
+
pendingHoverNullHandle = setTimeout(() => {
|
|
879
|
+
pendingHoverNullHandle = null;
|
|
880
|
+
applyHover(null);
|
|
881
|
+
}, grace) as unknown as number;
|
|
882
|
+
} else {
|
|
883
|
+
applyHover(null);
|
|
884
|
+
}
|
|
885
|
+
},
|
|
886
|
+
},
|
|
887
|
+
);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// -----------------------------------------------------------------------
|
|
891
|
+
// Resize plumbing
|
|
892
|
+
// -----------------------------------------------------------------------
|
|
893
|
+
|
|
894
|
+
// Apply our `width`/`height`/`dpr` to the renderer's backing canvas.
|
|
895
|
+
// Keeps device pixels = CSS px * dpr; a `space: "ui"` layer maps 1:1 with
|
|
896
|
+
// CSS pixels.
|
|
897
|
+
function applySize(): void {
|
|
898
|
+
if (ownedRenderer) {
|
|
899
|
+
renderer.setDpr(dpr);
|
|
900
|
+
renderer.resize(Math.max(1, Math.round(width * dpr)), Math.max(1, Math.round(height * dpr)));
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
applySize();
|
|
905
|
+
|
|
906
|
+
let resizeObserver: ResizeObserver | null = null;
|
|
907
|
+
if (autoResize && typeof ResizeObserver !== "undefined") {
|
|
908
|
+
resizeObserver = new ResizeObserver(() => {
|
|
909
|
+
const r = canvas.getBoundingClientRect();
|
|
910
|
+
const nextDpr = opts.dpr ?? readDpr();
|
|
911
|
+
const nextW = r.width || canvas.clientWidth || width;
|
|
912
|
+
const nextH = r.height || canvas.clientHeight || height;
|
|
913
|
+
if (nextW === width && nextH === height && nextDpr === dpr) return;
|
|
914
|
+
width = nextW;
|
|
915
|
+
height = nextH;
|
|
916
|
+
dpr = nextDpr;
|
|
917
|
+
applySize();
|
|
918
|
+
pendingFullReason = "resize";
|
|
919
|
+
inv.invalidate();
|
|
920
|
+
});
|
|
921
|
+
resizeObserver.observe(canvas);
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// -----------------------------------------------------------------------
|
|
925
|
+
// Visibility pause
|
|
926
|
+
// -----------------------------------------------------------------------
|
|
927
|
+
|
|
928
|
+
let visibleHandler: (() => void) | null = null;
|
|
929
|
+
if (pauseOnHidden && typeof document !== "undefined") {
|
|
930
|
+
visibleHandler = () => {
|
|
931
|
+
// When tab returns, force a redraw (state may have advanced).
|
|
932
|
+
if (document.visibilityState === "visible") inv.invalidate();
|
|
933
|
+
};
|
|
934
|
+
document.addEventListener("visibilitychange", visibleHandler);
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// -----------------------------------------------------------------------
|
|
938
|
+
// Draw — pure of loop concerns; called from rAF tick, manual update(), or
|
|
939
|
+
// toSVG(). Uses the active config + the latest dataSnapshot + working
|
|
940
|
+
// width/height.
|
|
941
|
+
// -----------------------------------------------------------------------
|
|
942
|
+
|
|
943
|
+
// Rebuild active config from the builder closure (if any) and rebind data.
|
|
944
|
+
function refreshActiveConfig(): void {
|
|
945
|
+
if (!builder) return;
|
|
946
|
+
const next = builder();
|
|
947
|
+
activeConfig = configOf(next);
|
|
948
|
+
// Builder may return a chart whose `data` is a different signal — rebind.
|
|
949
|
+
if (isSignal<readonly T[]>(activeConfig.data)) {
|
|
950
|
+
bindDataSignal();
|
|
951
|
+
} else {
|
|
952
|
+
unsubscribeData?.();
|
|
953
|
+
unsubscribeData = null;
|
|
954
|
+
dataSnapshot = currentData(activeConfig.data);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// Pan/zoom: override x/y scale domains with the viewport's visible window
|
|
959
|
+
// so axes / ticks / scales follow pan & zoom automatically. yFit additionally
|
|
960
|
+
// rescans the visible-X slice each frame so Y tracks on-screen values.
|
|
961
|
+
function applyPanZoomScales(snapshot: ChartConfig<T>): void {
|
|
962
|
+
if (!panZoomViewport) return;
|
|
963
|
+
const vx = panZoomViewport.visibleXDomain as readonly [number, number];
|
|
964
|
+
let vy = panZoomViewport.visibleYDomain as readonly [number, number];
|
|
965
|
+
if (yFitPadding !== null) {
|
|
966
|
+
const yExtent = computeVisibleYExtent(activeConfig.layers, dataSnapshot, vx, yFitPadding);
|
|
967
|
+
if (yExtent) vy = yExtent;
|
|
968
|
+
}
|
|
969
|
+
snapshot.scaleOverrides = {
|
|
970
|
+
...snapshot.scaleOverrides,
|
|
971
|
+
x: { ...snapshot.scaleOverrides.x, domain: vx } as PositionScaleOptions,
|
|
972
|
+
y: { ...snapshot.scaleOverrides.y, domain: vy } as PositionScaleOptions,
|
|
973
|
+
};
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// Notify transitions when data identity or scaleOverride values change.
|
|
977
|
+
// Compares scaleOverrides by value: closure-driven `build()` recreates the
|
|
978
|
+
// option objects each frame even when nothing changed, so reference compare
|
|
979
|
+
// would retrigger every frame and pin transitions at t≈0.
|
|
980
|
+
function notifyTransitionsOnChange(): void {
|
|
981
|
+
const currentDataRef: readonly unknown[] = dataSnapshot as readonly unknown[];
|
|
982
|
+
const currentScaleOverrides = activeConfig.scaleOverrides;
|
|
983
|
+
const dataChanged = prevDataRef !== null && prevDataRef !== currentDataRef;
|
|
984
|
+
const scaleChanged =
|
|
985
|
+
prevScaleOverrides !== null &&
|
|
986
|
+
SCALE_OVERRIDE_KEYS.some(
|
|
987
|
+
(k) => !shallowObjectEqual(prevScaleOverrides![k], currentScaleOverrides[k]),
|
|
988
|
+
);
|
|
989
|
+
// Gate on theme.motion.enabled — reduced-motion already collapses
|
|
990
|
+
// duration, but skipping notifyChange avoids spurious frame captures.
|
|
991
|
+
if (grammarTransitions && activeConfig.theme.motion.enabled && (dataChanged || scaleChanged)) {
|
|
992
|
+
grammarTransitions.notifyChange();
|
|
993
|
+
}
|
|
994
|
+
prevDataRef = currentDataRef;
|
|
995
|
+
prevScaleOverrides = currentScaleOverrides;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
// Apply layer clips, viewport frame sync, and crosshair gating from a
|
|
999
|
+
// freshly-run pipeline output.
|
|
1000
|
+
function applyPipelineFrames(out: import("./pipeline.ts").PipelineOutput<T>): void {
|
|
1001
|
+
chartPlotFrame = out.plotFrame;
|
|
1002
|
+
// Universal: clip marks to the inner plot panel so geom rects/lines
|
|
1003
|
+
// can't bleed into axis / legend / title slots.
|
|
1004
|
+
if (clipMarks) {
|
|
1005
|
+
marksLayer.setClipRect({
|
|
1006
|
+
x: out.plotFrame.x,
|
|
1007
|
+
y: out.plotFrame.y,
|
|
1008
|
+
width: out.plotFrame.width,
|
|
1009
|
+
height: out.plotFrame.height,
|
|
1010
|
+
});
|
|
1011
|
+
const outerClip = {
|
|
1012
|
+
x: out.outerFrame.x,
|
|
1013
|
+
y: out.outerFrame.y,
|
|
1014
|
+
width: out.outerFrame.width,
|
|
1015
|
+
height: out.outerFrame.height,
|
|
1016
|
+
};
|
|
1017
|
+
hudLayer.setClipRect(outerClip);
|
|
1018
|
+
// Overlays share the hud's outer clip. The clip survives `clear()`, so
|
|
1019
|
+
// setting it on full frames keeps it valid through overlay-only frames.
|
|
1020
|
+
overlayLayer.setClipRect(outerClip);
|
|
1021
|
+
}
|
|
1022
|
+
// Pan/zoom: keep the viewport's pointer-frame aligned with the actual
|
|
1023
|
+
// panel so drag deltas map 1:1 to data-domain shifts. Pass `reserve`
|
|
1024
|
+
// so the viewport's axis pixel range matches the position scales —
|
|
1025
|
+
// `dataToScreen` then lines up with where marks actually render.
|
|
1026
|
+
if (panZoomViewport) {
|
|
1027
|
+
panZoomViewport.setFrame(out.plotFrame, out.reserve);
|
|
1028
|
+
}
|
|
1029
|
+
lastPanelFrames = out.panelFrames ?? [];
|
|
1030
|
+
// Recompute panel-scoped bounds against the current hover (panel frames
|
|
1031
|
+
// may have shifted on resize / data change while the cursor sat still).
|
|
1032
|
+
updateCrosshairBoundsFor(hoverSignal.peek());
|
|
1033
|
+
if (grammarCrosshair) {
|
|
1034
|
+
// Auto-enable on continuous x scales only — gating here so the
|
|
1035
|
+
// tooltip's onHover observer (which fires synchronously) sees the
|
|
1036
|
+
// live scale type.
|
|
1037
|
+
grammarCrosshair.setEnabled(out.scales.x.type !== "band");
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
// v3 composite view fingerprint extension. The mount's layers are all
|
|
1042
|
+
// `ui`-space, so the renderer's camera never moves and it cannot observe a
|
|
1043
|
+
// pan/zoom data-domain shift on its own. Folding the visible domain into
|
|
1044
|
+
// `viewKey` makes the renderer treat a domain change as a view change (a full
|
|
1045
|
+
// repaint) rather than letting an overlay-only damage frame composite against
|
|
1046
|
+
// a stale bake — the v3 fix for v1's group/domain-omission footgun.
|
|
1047
|
+
function currentViewKey(): string | undefined {
|
|
1048
|
+
if (!panZoomViewport) return undefined;
|
|
1049
|
+
const vx = panZoomViewport.visibleXDomain as readonly [number, number];
|
|
1050
|
+
const vy = panZoomViewport.visibleYDomain as readonly [number, number];
|
|
1051
|
+
return `${vx[0]},${vx[1]},${vy[0]},${vy[1]}`;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// Render node list for this frame. Order: axis (bottom), caller `belowMarks`,
|
|
1055
|
+
// marks, caller `aboveMarks`, hud, overlay (top). Each layer carries an
|
|
1056
|
+
// explicit `zIndex` band so the core's flat-z composite order matches this
|
|
1057
|
+
// array order exactly (and is robust to any future reordering). Stable across
|
|
1058
|
+
// full and overlay-only frames so the renderer's damage replay sees the same
|
|
1059
|
+
// commands. Extra layers are assigned bands by index (below: 10,11,…; above:
|
|
1060
|
+
// 110,111,…) so they slot between the fixed bands without colliding.
|
|
1061
|
+
function currentLayers(): readonly Layer[] {
|
|
1062
|
+
const extra = opts.extraLayers?.();
|
|
1063
|
+
if (!extra) return [axisLayer, marksLayer, hudLayer, overlayLayer];
|
|
1064
|
+
const below = extra.belowMarks ?? [];
|
|
1065
|
+
const above = extra.aboveMarks ?? [];
|
|
1066
|
+
for (let i = 0; i < below.length; i++) {
|
|
1067
|
+
below[i]!.zIndex = Z_BELOW_BASE + i;
|
|
1068
|
+
below[i]!.label ??= `below:${i}`;
|
|
1069
|
+
}
|
|
1070
|
+
for (let i = 0; i < above.length; i++) {
|
|
1071
|
+
above[i]!.zIndex = Z_ABOVE_BASE + i;
|
|
1072
|
+
above[i]!.label ??= `above:${i}`;
|
|
1073
|
+
}
|
|
1074
|
+
return [axisLayer, ...below, marksLayer, ...above, hudLayer, overlayLayer];
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
// Interaction-aware cache-hint flip (replaces the old pan-tax bake dance).
|
|
1078
|
+
// While a pan/zoom/fling is in flight, set the static layers' `cache` to
|
|
1079
|
+
// "never" so they draw LIVE: a pan recompiles the geoms each frame → the
|
|
1080
|
+
// layer pack version changes → an "auto" bake would re-bake (texture create +
|
|
1081
|
+
// bake pass) EVERY frame, strictly worse than live. On settle, restore "auto"
|
|
1082
|
+
// so the core's policy bakes the now-static stack once and pan-idle hovers ride
|
|
1083
|
+
// the cached fast path. No-op outside `partial` mode (hints are pinned
|
|
1084
|
+
// "never"). Mutating `cache` between frames is a supported core contract
|
|
1085
|
+
// (Layer.cache is mutable; the policy re-evaluates it every render()).
|
|
1086
|
+
function applyInteractionCacheHints(): void {
|
|
1087
|
+
if (!partial) return;
|
|
1088
|
+
const interacting = !!panZoomBinding && (panZoomBinding.interacting || panZoomBinding.flinging);
|
|
1089
|
+
const hint: CacheHint = interacting ? "never" : "auto";
|
|
1090
|
+
axisLayer.cache = hint;
|
|
1091
|
+
// Marks get their own hint: pinned "never" for GPU-dim charts (a bake would
|
|
1092
|
+
// freeze the no-op bake-time emphasis, so dim needs live marks), else the
|
|
1093
|
+
// shared interaction hint (big scatters still auto-bake when settled).
|
|
1094
|
+
marksLayer.cache = marksCacheHint(interacting);
|
|
1095
|
+
hudLayer.cache = hint;
|
|
1096
|
+
// Extra layers (caller below/above) follow the same gesture treatment.
|
|
1097
|
+
const extra = opts.extraLayers?.();
|
|
1098
|
+
if (extra) {
|
|
1099
|
+
for (const l of extra.belowMarks ?? []) l.cache = hint;
|
|
1100
|
+
for (const l of extra.aboveMarks ?? []) l.cache = hint;
|
|
1101
|
+
}
|
|
1102
|
+
// overlayLayer stays "never" — never touched here.
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
// Whether hover emphasis (focus halo / dim) is enabled in the active theme.
|
|
1106
|
+
function hoverEmphasisEnabled(): boolean {
|
|
1107
|
+
return activeConfig.theme.interactions.hover.enabled;
|
|
1108
|
+
}
|
|
1109
|
+
// The decorator (if any) that owns this hit. A decorator draws a local focus
|
|
1110
|
+
// treatment (contrast halo / bring-to-front) into the live overlay layer:
|
|
1111
|
+
// point, plus bar/histogram/tile/line (whose dim-others is the GPU emphasis
|
|
1112
|
+
// uniform — the halo is the complementary focus shape, left at emphasisKey 0
|
|
1113
|
+
// so it stays full-strength while the rest dim). A hit with NO decorator just
|
|
1114
|
+
// gets the uniform dim (or nothing).
|
|
1115
|
+
function decoratorFor(
|
|
1116
|
+
hit: import("./geoms/types.ts").HoveredHit | null,
|
|
1117
|
+
): import("./geoms/types.ts").GeomHoverDecorator | null {
|
|
1118
|
+
if (!hit) return null;
|
|
1119
|
+
for (const d of latestHoverDecorators) {
|
|
1120
|
+
if (d.geomKind === hit.geomKind && d.data === hit.data) return d;
|
|
1121
|
+
}
|
|
1122
|
+
return null;
|
|
1123
|
+
}
|
|
1124
|
+
// Whether the active chart contains a GPU-dim geom (with hover enabled). Such
|
|
1125
|
+
// a chart pins its marks layer to `cache:"never"` (see `marksCacheHint`) so the
|
|
1126
|
+
// emphasis uniform can dim LIVE marks — a bake would freeze the bake-time no-op
|
|
1127
|
+
// emphasis. Recomputed each full frame off the resolved config.
|
|
1128
|
+
function chartHasDimGeom(): boolean {
|
|
1129
|
+
if (!hoverEmphasisEnabled()) return false;
|
|
1130
|
+
return activeConfig.layers.some((g) => GPU_DIM_GEOM_KINDS.has(g.kind));
|
|
1131
|
+
}
|
|
1132
|
+
// Cache hint for the MARKS layer specifically. Pinned to "never" whenever the
|
|
1133
|
+
// chart has a GPU-dim geom (live marks required for the emphasis uniform to
|
|
1134
|
+
// dim them). Otherwise it follows the shared interaction hint (auto when
|
|
1135
|
+
// settled, never while panning) so big scatters still auto-bake.
|
|
1136
|
+
function marksCacheHint(interacting: boolean): CacheHint {
|
|
1137
|
+
if (!partial) return "never";
|
|
1138
|
+
if (chartHasDimGeom()) return "never";
|
|
1139
|
+
return interacting ? "never" : "auto";
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
// Clear + re-emit the cursor-driven overlays onto their dedicated layer.
|
|
1143
|
+
// Used by both the full and overlay-only paths. The overlay clip is set on
|
|
1144
|
+
// full frames (in applyPipelineFrames) and survives `clear()`.
|
|
1145
|
+
function emitOverlays(): void {
|
|
1146
|
+
overlayLayer.clear();
|
|
1147
|
+
// Hover focus decoration first so it sits beneath crosshair lines + tooltip
|
|
1148
|
+
// shapes (push order = z order within the layer). It composites above the
|
|
1149
|
+
// baked marks, so the focused point reads on top of every other mark.
|
|
1150
|
+
if (hoverEmphasisEnabled()) {
|
|
1151
|
+
const hit = hoverSignal.peek();
|
|
1152
|
+
decoratorFor(hit)?.decorate(hit!, overlayLayer);
|
|
1153
|
+
}
|
|
1154
|
+
// Brush rect next so it sits beneath crosshair lines + tooltip shapes.
|
|
1155
|
+
if (grammarBrush) grammarBrush.draw();
|
|
1156
|
+
if (attachedBrush) attachedBrush.draw();
|
|
1157
|
+
// Crosshair BEFORE tooltip so the tooltip box occludes the crosshair
|
|
1158
|
+
// line that passes beneath/through it — prevents a visible line segment
|
|
1159
|
+
// inside the tooltip's rounded rectangle.
|
|
1160
|
+
if (grammarCrosshair) grammarCrosshair.draw();
|
|
1161
|
+
if (grammarTooltip) grammarTooltip.draw();
|
|
1162
|
+
// Menu on top so it can't be obscured.
|
|
1163
|
+
if (grammarContextMenu) grammarContextMenu.draw();
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// Current overlay-layer content rect (CSS px), or null when empty. Snapped
|
|
1167
|
+
// outward to integers and padded a few px: the text AABB is advance-width
|
|
1168
|
+
// based and can underflow actual ink (drop shadows, italics, decorations),
|
|
1169
|
+
// and the rect lands as an integer device-px scissor. Without the margin a
|
|
1170
|
+
// moving tooltip could leave a 1–2px ghost of its previous footprint. (The
|
|
1171
|
+
// renderer pads a further +1px for the AA fringe.)
|
|
1172
|
+
const OVERLAY_DAMAGE_PAD = 3;
|
|
1173
|
+
function overlayRect(): FrameRect | null {
|
|
1174
|
+
const b = overlayLayer.effectiveLocalBounds;
|
|
1175
|
+
if (!b) return null;
|
|
1176
|
+
const x = Math.floor(b.minX - OVERLAY_DAMAGE_PAD);
|
|
1177
|
+
const y = Math.floor(b.minY - OVERLAY_DAMAGE_PAD);
|
|
1178
|
+
const maxX = Math.ceil(b.maxX + OVERLAY_DAMAGE_PAD);
|
|
1179
|
+
const maxY = Math.ceil(b.maxY + OVERLAY_DAMAGE_PAD);
|
|
1180
|
+
return { x, y, width: maxX - x, height: maxY - y };
|
|
1181
|
+
}
|
|
1182
|
+
function unionRect(a: FrameRect | null, b: FrameRect | null): FrameRect | null {
|
|
1183
|
+
if (!a) return b;
|
|
1184
|
+
if (!b) return a;
|
|
1185
|
+
const x = Math.min(a.x, b.x);
|
|
1186
|
+
const y = Math.min(a.y, b.y);
|
|
1187
|
+
const maxX = Math.max(a.x + a.width, b.x + b.width);
|
|
1188
|
+
const maxY = Math.max(a.y + a.height, b.y + b.height);
|
|
1189
|
+
return { x, y, width: maxX - x, height: maxY - y };
|
|
1190
|
+
}
|
|
1191
|
+
// The core's `render({ regions })` takes CSS-px `Bounds2D` ({minX,minY,maxX,
|
|
1192
|
+
// maxY}); our overlay rects are `FrameRect` ({x,y,width,height}). Convert.
|
|
1193
|
+
function rectToBounds(r: FrameRect): import("insomni").Bounds2D {
|
|
1194
|
+
return { minX: r.x, minY: r.y, maxX: r.x + r.width, maxY: r.y + r.height };
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
// Full frame: recompile the whole chart, (partial mode) bake the marks, and
|
|
1198
|
+
// render every layer. Resets both dirty flags.
|
|
1199
|
+
function drawFull(): void {
|
|
1200
|
+
if (!fontReady) return;
|
|
1201
|
+
inv.clear();
|
|
1202
|
+
overlayDirty = false;
|
|
1203
|
+
|
|
1204
|
+
refreshActiveConfig();
|
|
1205
|
+
|
|
1206
|
+
const snapshot: ChartConfig<T> = { ...activeConfig, width, height };
|
|
1207
|
+
applyPanZoomScales(snapshot);
|
|
1208
|
+
const legendDimAlpha = interactionsCfg.legend ? interactionsCfg.legend.dimAlpha : undefined;
|
|
1209
|
+
|
|
1210
|
+
notifyTransitionsOnChange();
|
|
1211
|
+
|
|
1212
|
+
const out = runPipeline(snapshot, dataSnapshot, axisLayer, marksLayer, hudLayer, atlas, {
|
|
1213
|
+
// GPU-dim geoms (bar/histogram/tile/line/...) do NOT read `hovered` — their
|
|
1214
|
+
// dim-others treatment rides the animated emphasis uniform (P5-T3), keyed
|
|
1215
|
+
// at compile time. `hovered` is still threaded for the deliberately-inert
|
|
1216
|
+
// nearestX geoms (`area`/`rolling`) whose compile-time halo only updates on
|
|
1217
|
+
// a full frame. Point's focus halo rides the overlay decorator, not
|
|
1218
|
+
// compile-time `hovered`.
|
|
1219
|
+
hovered: hoverEmphasisEnabled() ? hoverSignal.peek() : undefined,
|
|
1220
|
+
selected: grammarSelection ? selectionSignal.peek() : undefined,
|
|
1221
|
+
hidden: hiddenSignal.peek(),
|
|
1222
|
+
legendDimAlpha,
|
|
1223
|
+
transitions: grammarTransitions ?? undefined,
|
|
1224
|
+
transitionKey,
|
|
1225
|
+
});
|
|
1226
|
+
|
|
1227
|
+
// Hand fresh hit-test data to the interactions layer; tooltip ticks are
|
|
1228
|
+
// driven by the rAF loop (see `tick()` below) and tooltip.draw() lays
|
|
1229
|
+
// shapes on the hud layer after the pipeline has finished filling it.
|
|
1230
|
+
applyPipelineFrames(out);
|
|
1231
|
+
// Single sync into the shared hit layer; tooltip + selection observe its
|
|
1232
|
+
// events via subscribe(). `selection.sync` still runs to refresh its
|
|
1233
|
+
// id→position registry (needed for sticky selections across re-renders),
|
|
1234
|
+
// but no longer manages PointCloudNodes itself.
|
|
1235
|
+
if (hitLayer) {
|
|
1236
|
+
hitLayer.sync(out.hitTests);
|
|
1237
|
+
}
|
|
1238
|
+
// Series-readouts pull from the same compiled hit-tests + the live scale
|
|
1239
|
+
// bundle (color scale → series swatch). Push on every draw so the panel
|
|
1240
|
+
// tracks data / scale changes alongside the chart.
|
|
1241
|
+
latestScales = out.scales;
|
|
1242
|
+
latestHoverDecorators = out.hoverDecorators;
|
|
1243
|
+
latestEmphasisResolvers = out.emphasisResolvers;
|
|
1244
|
+
if (attachedSeriesReadouts.size > 0) {
|
|
1245
|
+
for (const r of attachedSeriesReadouts) r.syncHits(out.hitTests);
|
|
1246
|
+
}
|
|
1247
|
+
grammarTooltip?.syncHits?.(out.hitTests);
|
|
1248
|
+
if (grammarSelection) {
|
|
1249
|
+
grammarSelection.sync(out.hitTests);
|
|
1250
|
+
}
|
|
1251
|
+
if (grammarBrush) {
|
|
1252
|
+
grammarBrush.sync(out.hitTests);
|
|
1253
|
+
}
|
|
1254
|
+
if (attachedBrush) {
|
|
1255
|
+
attachedBrush.syncHits(out.hitTests);
|
|
1256
|
+
}
|
|
1257
|
+
if (grammarLegend) {
|
|
1258
|
+
grammarLegend.sync(out);
|
|
1259
|
+
}
|
|
1260
|
+
// Emit the cursor overlays onto their own layer (above the hud).
|
|
1261
|
+
emitOverlays();
|
|
1262
|
+
lastOverlayRect = overlayRect();
|
|
1263
|
+
|
|
1264
|
+
// Background follows the resolved config unless overridden — only when we
|
|
1265
|
+
// own the renderer. Re-apply each frame because the builder may have
|
|
1266
|
+
// produced a chart with a different theme.
|
|
1267
|
+
if (ownedRenderer) {
|
|
1268
|
+
renderer.setBackground(
|
|
1269
|
+
backgroundOverride ?? activeConfig.background ?? activeConfig.theme.background,
|
|
1270
|
+
);
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
// Flip the static layers' cache hint based on the live interaction state
|
|
1274
|
+
// (never while panning/flinging, auto when settled) — the core's per-frame
|
|
1275
|
+
// cache policy reads `Layer.cache` on the next render() and bakes/unbakes
|
|
1276
|
+
// accordingly. No manual cacheLayer/uncacheLayer here: the policy owns it.
|
|
1277
|
+
applyInteractionCacheHints();
|
|
1278
|
+
|
|
1279
|
+
// A normal full frame. NEVER `fullFrame:true` — the core's view fingerprint
|
|
1280
|
+
// (camera/dpr/size/background + `viewKey`) plus its force-full-after-(un)bake
|
|
1281
|
+
// logic decides. With no `regions` this is a full frame by construction; a
|
|
1282
|
+
// (re)bake triggered by the policy this frame is automatically promoted to
|
|
1283
|
+
// full. `viewKey` folds the visible domain so a pan-driven domain shift on
|
|
1284
|
+
// these ui-space layers (whose camera never moves) still forces a full
|
|
1285
|
+
// repaint rather than compositing against a stale bake.
|
|
1286
|
+
// Static debug metadata on the marks layer (P5-T1, D7): the geom kinds in
|
|
1287
|
+
// compile order, refreshed each full frame. Read only when the probe is
|
|
1288
|
+
// enabled; assigning a small array on the full-frame path is irrelevant to
|
|
1289
|
+
// rendering. Reference-assigned (not cloned) — the core copies the reference.
|
|
1290
|
+
marksLayer.debugData = { geoms: activeConfig.layers.map((g) => g.kind) };
|
|
1291
|
+
|
|
1292
|
+
// Resolve the annotation reason: a live pan/zoom gesture overrides the
|
|
1293
|
+
// latched cause; otherwise use whatever the invalidation source set (or the
|
|
1294
|
+
// "invalidate" default). Reset the latch afterwards so a later unclassified
|
|
1295
|
+
// invalidation reports "invalidate" rather than a stale cause.
|
|
1296
|
+
const interacting = !!panZoomBinding && (panZoomBinding.interacting || panZoomBinding.flinging);
|
|
1297
|
+
const reason = interacting ? "pan-zoom" : pendingFullReason;
|
|
1298
|
+
pendingFullReason = "invalidate";
|
|
1299
|
+
|
|
1300
|
+
renderer.render(currentLayers(), {
|
|
1301
|
+
...(partial ? { viewKey: currentViewKey() } : {}),
|
|
1302
|
+
debug: { reason },
|
|
1303
|
+
});
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
// Overlay-only repaint (partial mode only). The baked static stack (axis /
|
|
1307
|
+
// marks / hud) is untouched in the persistent backbuffer; re-emit just the
|
|
1308
|
+
// overlay layer and DAMAGE-repaint the union of its previous + current
|
|
1309
|
+
// footprints via `regions`. The core scissors the partial frame (OIT-aware,
|
|
1310
|
+
// Phase 1), so the live overlay composites over the baked stack inside the
|
|
1311
|
+
// damage rect with correct transparency.
|
|
1312
|
+
//
|
|
1313
|
+
// `viewKey` MUST match the value the last `drawFull` used so the core's view
|
|
1314
|
+
// fingerprint is unchanged across this overlay-only frame — otherwise the core
|
|
1315
|
+
// demotes to a full repaint. `currentViewKey()` folds only the visible domain,
|
|
1316
|
+
// which doesn't change on an overlay-only frame, so it is stable here.
|
|
1317
|
+
//
|
|
1318
|
+
// OIT note: plot's renderer runs with OIT ON (`createRenderer` default — the
|
|
1319
|
+
// mount does not pass `oit:false`), so a glyph-bearing overlay (tooltip text)
|
|
1320
|
+
// stays a partial frame. (On a NON-OIT renderer the core self-demotes live
|
|
1321
|
+
// MSDF-glyph partial frames to full — correct, just less optimal — but that
|
|
1322
|
+
// path is unreachable here.)
|
|
1323
|
+
function drawOverlay(): void {
|
|
1324
|
+
if (!fontReady) return;
|
|
1325
|
+
overlayDirty = false;
|
|
1326
|
+
emitOverlays();
|
|
1327
|
+
const next = overlayRect();
|
|
1328
|
+
const region = unionRect(lastOverlayRect, next);
|
|
1329
|
+
lastOverlayRect = next;
|
|
1330
|
+
// Nothing visible changed on the overlay (e.g. a hover with no tooltip) —
|
|
1331
|
+
// skip rather than fall back to a full clear.
|
|
1332
|
+
if (!region) return;
|
|
1333
|
+
renderer.render(currentLayers(), {
|
|
1334
|
+
regions: [rectToBounds(region)],
|
|
1335
|
+
viewKey: currentViewKey(),
|
|
1336
|
+
// P5-T1 (D7): overlay-only frames are cursor-driven (tooltip / crosshair /
|
|
1337
|
+
// brush / point halo). A single "hover-moved" tag covers them — the cause
|
|
1338
|
+
// isn't cheaply distinguishable at this call site (all overlay sources
|
|
1339
|
+
// funnel through `requestOverlay`), so we don't build plumbing to split it.
|
|
1340
|
+
debug: { reason: "hover-moved" },
|
|
1341
|
+
});
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
// Repaint-only FULL frame (Fix D — emphasis ramp). A full render of the
|
|
1345
|
+
// EXISTING layers with NO `runPipeline` recompile: the marks/axis/hud packs
|
|
1346
|
+
// are byte-identical to the last `drawFull`, so their cache keys are stable
|
|
1347
|
+
// and the core re-uses every existing bake — zero texture allocs / bake passes
|
|
1348
|
+
// — while the changed emphasis uniform (already written by the driver) dims the
|
|
1349
|
+
// composite. It is a FULL render (no `regions`): the uniform is global, so a
|
|
1350
|
+
// partial frame would leave the un-repainted backbuffer showing the old dim
|
|
1351
|
+
// (the P5-T3 soundness rule). Used by the per-tick ramp where the ONLY thing
|
|
1352
|
+
// that changed is the uniform — marks packs are hover-independent. NEVER used
|
|
1353
|
+
// when `inv.dirty` (a real recompile is pending → `drawFull` wins).
|
|
1354
|
+
function drawRepaint(): void {
|
|
1355
|
+
if (!fontReady) return;
|
|
1356
|
+
renderer.render(currentLayers(), {
|
|
1357
|
+
...(partial ? { viewKey: currentViewKey() } : {}),
|
|
1358
|
+
// P5-T1 (D7): a uniform-only emphasis dim ramp step (no recompile).
|
|
1359
|
+
debug: { reason: "emphasis-ramp" },
|
|
1360
|
+
});
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
// -----------------------------------------------------------------------
|
|
1364
|
+
// RAF tick
|
|
1365
|
+
// -----------------------------------------------------------------------
|
|
1366
|
+
|
|
1367
|
+
let rafId = 0;
|
|
1368
|
+
let lastFrameTime = 0;
|
|
1369
|
+
function tick(time: number): void {
|
|
1370
|
+
if (disposed) return;
|
|
1371
|
+
const dtSeconds = lastFrameTime === 0 ? 0 : Math.max(0, (time - lastFrameTime) / 1000);
|
|
1372
|
+
lastFrameTime = time;
|
|
1373
|
+
// Tick interaction animations even when nothing else is dirty — the
|
|
1374
|
+
// tooltip's fade/show-delay needs frames to advance.
|
|
1375
|
+
if (grammarTooltip && dtSeconds > 0) grammarTooltip.step(dtSeconds);
|
|
1376
|
+
if (grammarContextMenu && dtSeconds > 0) grammarContextMenu.step(dtSeconds);
|
|
1377
|
+
if (grammarTransitions && dtSeconds > 0) {
|
|
1378
|
+
if (grammarTransitions.step(dtSeconds)) inv.invalidate();
|
|
1379
|
+
}
|
|
1380
|
+
// Advance the GPU emphasis dim toward its target via the driver. The driver
|
|
1381
|
+
// writes the eased uniform and reports whether a (full) frame is needed. A
|
|
1382
|
+
// ramp step changes ONLY the uniform — the marks packs are hover-independent
|
|
1383
|
+
// — so we DON'T `inv.invalidate()` (which would `drawFull` → `runPipeline`,
|
|
1384
|
+
// bumping pack versions and re-baking the auto-cached glyph axis ~8x/ramp).
|
|
1385
|
+
// Instead we flag a REPAINT-ONLY full frame (`drawRepaint`): a full render of
|
|
1386
|
+
// the unchanged packs with stable cache keys → zero re-bakes. Still a FULL
|
|
1387
|
+
// render (never regions): the uniform is global. The loop stops cleanly once
|
|
1388
|
+
// settled (the driver returns no frame when `t === target`). A real
|
|
1389
|
+
// invalidation mid-ramp (resize / data / settle below) sets `inv.dirty` and
|
|
1390
|
+
// wins over the repaint via the `needsFull` ordering at the bottom of tick().
|
|
1391
|
+
if (emphasisAnimating()) {
|
|
1392
|
+
const req = emphasis.step(time);
|
|
1393
|
+
if (req.needsFrame) emphasisRepaintPending = true; // req.full is always true here
|
|
1394
|
+
}
|
|
1395
|
+
// Settle edge: detect the interacting → settled transition and force one
|
|
1396
|
+
// final full frame so `applyInteractionCacheHints()` flips the static layers
|
|
1397
|
+
// back to "auto" and the core's policy re-bakes the now-static stack. Without
|
|
1398
|
+
// this, drag-end (no fling) fires no `onChange`, so the last full frame ran
|
|
1399
|
+
// while still interacting (hints "never", live) and the stack would never
|
|
1400
|
+
// re-bake — every later overlay frame would composite against live geometry
|
|
1401
|
+
// it can't damage-track minimally.
|
|
1402
|
+
if (partial) {
|
|
1403
|
+
const nowInteracting =
|
|
1404
|
+
!!panZoomBinding && (panZoomBinding.interacting || panZoomBinding.flinging);
|
|
1405
|
+
if (wasInteracting && !nowInteracting) {
|
|
1406
|
+
pendingFullReason = "settle-rebake";
|
|
1407
|
+
inv.invalidate();
|
|
1408
|
+
}
|
|
1409
|
+
wasInteracting = nowInteracting;
|
|
1410
|
+
}
|
|
1411
|
+
// Frame-kind dispatch, strongest-wins:
|
|
1412
|
+
// 1. `inv.dirty` → drawFull (recompile + bake + render). A real
|
|
1413
|
+
// change (resize / data / settle / hover hit-change) ALWAYS wins, even
|
|
1414
|
+
// mid-ramp, so a resize/data invalidation during the dim animation is
|
|
1415
|
+
// serviced correctly.
|
|
1416
|
+
// 2. `emphasisRepaintPending` → drawRepaint (FULL render, NO recompile): a
|
|
1417
|
+
// uniform-only emphasis ramp step. Stable packs → zero re-bakes (Fix D).
|
|
1418
|
+
// 3. `overlayDirty` → drawOverlay (regions/partial). Only reached
|
|
1419
|
+
// when the emphasis uniform is SETTLED (an active ramp sets (2), which
|
|
1420
|
+
// is full and outranks this) — preserving the P5-T3 soundness rule that
|
|
1421
|
+
// a partial frame never runs while the global uniform is mid-transition.
|
|
1422
|
+
const needsFull = inv.dirty;
|
|
1423
|
+
const needsRepaint = emphasisRepaintPending;
|
|
1424
|
+
const needsOverlay = partial && overlayDirty;
|
|
1425
|
+
if (needsFull || needsRepaint || needsOverlay) {
|
|
1426
|
+
const hidden = pauseOnHidden && typeof document !== "undefined" && document.hidden;
|
|
1427
|
+
if (!hidden) {
|
|
1428
|
+
if (needsFull) drawFull();
|
|
1429
|
+
else if (needsRepaint) drawRepaint();
|
|
1430
|
+
else drawOverlay();
|
|
1431
|
+
}
|
|
1432
|
+
// The repaint flag is consumed by ANY frame this tick (a winning drawFull
|
|
1433
|
+
// already painted the current uniform; a drawRepaint serviced it directly).
|
|
1434
|
+
emphasisRepaintPending = false;
|
|
1435
|
+
}
|
|
1436
|
+
rafId = requestAnimationFrame(tick);
|
|
1437
|
+
}
|
|
1438
|
+
if (autoFrame) {
|
|
1439
|
+
rafId = requestAnimationFrame(tick);
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
// -----------------------------------------------------------------------
|
|
1443
|
+
// Public handle
|
|
1444
|
+
// -----------------------------------------------------------------------
|
|
1445
|
+
|
|
1446
|
+
return {
|
|
1447
|
+
invalidate(): void {
|
|
1448
|
+
inv.invalidate();
|
|
1449
|
+
},
|
|
1450
|
+
update(source) {
|
|
1451
|
+
if (typeof source === "function") {
|
|
1452
|
+
builder = source as () => Chart<T>;
|
|
1453
|
+
} else if (source) {
|
|
1454
|
+
builder = null;
|
|
1455
|
+
activeConfig = configOf(source);
|
|
1456
|
+
bindDataSignal();
|
|
1457
|
+
}
|
|
1458
|
+
// Clear stale index-derived emphasis (Fix C) before the recompile — a held
|
|
1459
|
+
// hover would otherwise keep dimming re-indexed instances.
|
|
1460
|
+
resetEmphasisForData();
|
|
1461
|
+
pendingFullReason = "data-changed";
|
|
1462
|
+
// Pass-through (no source) → just rebuild via the existing builder/spec.
|
|
1463
|
+
inv.invalidate();
|
|
1464
|
+
// If autoFrame is off, draw synchronously so `update()` is the user's
|
|
1465
|
+
// single render call.
|
|
1466
|
+
if (!autoFrame && !disposed) drawFull();
|
|
1467
|
+
},
|
|
1468
|
+
setData(next): void {
|
|
1469
|
+
// Replace activeConfig.data with a frozen snapshot. Signal subscriptions
|
|
1470
|
+
// are torn down because the user just took manual control of the data.
|
|
1471
|
+
unsubscribeData?.();
|
|
1472
|
+
unsubscribeData = null;
|
|
1473
|
+
dataSnapshot = next;
|
|
1474
|
+
activeConfig = { ...activeConfig, data: next };
|
|
1475
|
+
// Clear stale index-derived emphasis (Fix C): the new data re-indexes
|
|
1476
|
+
// instances, so a held hover's old focused key must drop before the redraw.
|
|
1477
|
+
resetEmphasisForData();
|
|
1478
|
+
pendingFullReason = "data-changed";
|
|
1479
|
+
inv.invalidate();
|
|
1480
|
+
},
|
|
1481
|
+
resize(w, h): void {
|
|
1482
|
+
// Manual resize. If autoResize is on you don't usually need this, but
|
|
1483
|
+
// it's a useful escape hatch (e.g. forcing a fixed export size).
|
|
1484
|
+
if (w !== undefined) width = w;
|
|
1485
|
+
if (h !== undefined) height = h;
|
|
1486
|
+
if (w === undefined && h === undefined) {
|
|
1487
|
+
const r = canvas.getBoundingClientRect();
|
|
1488
|
+
width = r.width || canvas.clientWidth || width;
|
|
1489
|
+
height = r.height || canvas.clientHeight || height;
|
|
1490
|
+
}
|
|
1491
|
+
dpr = opts.dpr ?? readDpr();
|
|
1492
|
+
applySize();
|
|
1493
|
+
pendingFullReason = "resize";
|
|
1494
|
+
inv.invalidate();
|
|
1495
|
+
},
|
|
1496
|
+
setBackground(color): void {
|
|
1497
|
+
backgroundOverride = color;
|
|
1498
|
+
inv.invalidate();
|
|
1499
|
+
},
|
|
1500
|
+
toSVG(svgOpts) {
|
|
1501
|
+
// Re-runs the pipeline against fresh layers and an SVGRenderer. Reuses
|
|
1502
|
+
// the live atlas so axis labels render. We deliberately use scratch
|
|
1503
|
+
// layers so the on-screen layers aren't disturbed for a frame.
|
|
1504
|
+
const w = svgOpts?.width ?? width;
|
|
1505
|
+
const h = svgOpts?.height ?? height;
|
|
1506
|
+
const svg = createSVGRenderer({ width: w, height: h, dpr: 1 });
|
|
1507
|
+
// Scratch v3 layers carrying the live atlas so axis labels / glyphs export
|
|
1508
|
+
// (v3 has no `renderer.createLayer`/`font`; the atlas is externalized).
|
|
1509
|
+
const a = createLayer({ space: "ui", atlas });
|
|
1510
|
+
const m = createLayer({ space: "ui", atlas });
|
|
1511
|
+
const u = createLayer({ space: "ui", atlas });
|
|
1512
|
+
// Resolve a concrete config for export — re-run the builder so settings
|
|
1513
|
+
// closures pick up the live values.
|
|
1514
|
+
const exportConfig = builder ? configOf(builder()) : activeConfig;
|
|
1515
|
+
const exportData = builder ? currentData(exportConfig.data) : dataSnapshot;
|
|
1516
|
+
const snapshot: ChartConfig<T> = { ...exportConfig, width: w, height: h };
|
|
1517
|
+
runPipeline(snapshot, exportData, a, m, u, atlas);
|
|
1518
|
+
svg.setBackground(
|
|
1519
|
+
backgroundOverride ?? exportConfig.background ?? exportConfig.theme.background,
|
|
1520
|
+
);
|
|
1521
|
+
svg.render([a, m, u]);
|
|
1522
|
+
// v3's `element()` is a method (was a getter property on v1's SVGRenderer).
|
|
1523
|
+
return svg.element();
|
|
1524
|
+
},
|
|
1525
|
+
destroy(): void {
|
|
1526
|
+
if (disposed) return;
|
|
1527
|
+
disposed = true;
|
|
1528
|
+
if (autoFrame && rafId) cancelAnimationFrame(rafId);
|
|
1529
|
+
unsubscribeData?.();
|
|
1530
|
+
resizeObserver?.disconnect();
|
|
1531
|
+
if (visibleHandler && typeof document !== "undefined") {
|
|
1532
|
+
document.removeEventListener("visibilitychange", visibleHandler);
|
|
1533
|
+
}
|
|
1534
|
+
if (pendingHoverNullHandle !== null) {
|
|
1535
|
+
clearTimeout(pendingHoverNullHandle);
|
|
1536
|
+
pendingHoverNullHandle = null;
|
|
1537
|
+
}
|
|
1538
|
+
cleanupCursorTracking?.();
|
|
1539
|
+
grammarTooltip?.dispose();
|
|
1540
|
+
grammarCrosshair?.dispose();
|
|
1541
|
+
grammarSelection?.dispose();
|
|
1542
|
+
grammarBrush?.dispose();
|
|
1543
|
+
attachedBrush?.dispose();
|
|
1544
|
+
attachedBrush = null;
|
|
1545
|
+
grammarLegend?.dispose();
|
|
1546
|
+
grammarContextMenu?.dispose();
|
|
1547
|
+
hitLayer?.dispose();
|
|
1548
|
+
manager?.destroy();
|
|
1549
|
+
for (const a of attachedPresets) a.dispose();
|
|
1550
|
+
attachedPresets.clear();
|
|
1551
|
+
for (const r of attachedSeriesReadouts) r.dispose();
|
|
1552
|
+
attachedSeriesReadouts.clear();
|
|
1553
|
+
panZoomUnsub?.();
|
|
1554
|
+
panZoomBinding?.destroy();
|
|
1555
|
+
inv.dispose();
|
|
1556
|
+
// Drop external subscribers held against the public mount handle so
|
|
1557
|
+
// their closures stop pinning mount internals after teardown.
|
|
1558
|
+
hoverSignal.dispose();
|
|
1559
|
+
selectionSignal.dispose();
|
|
1560
|
+
brushedSignal.dispose();
|
|
1561
|
+
hiddenSignal.dispose();
|
|
1562
|
+
// Only destroy what we created. External renderer/atlas/layers belong
|
|
1563
|
+
// to the caller.
|
|
1564
|
+
if (ownedRenderer) renderer.destroy();
|
|
1565
|
+
if (ownedLayers) {
|
|
1566
|
+
axisLayer.destroy();
|
|
1567
|
+
marksLayer.destroy();
|
|
1568
|
+
hudLayer.destroy();
|
|
1569
|
+
overlayLayer.destroy();
|
|
1570
|
+
}
|
|
1571
|
+
},
|
|
1572
|
+
|
|
1573
|
+
// Escape hatches
|
|
1574
|
+
get renderer() {
|
|
1575
|
+
return renderer;
|
|
1576
|
+
},
|
|
1577
|
+
get axisLayer() {
|
|
1578
|
+
return axisLayer;
|
|
1579
|
+
},
|
|
1580
|
+
get marksLayer() {
|
|
1581
|
+
return marksLayer;
|
|
1582
|
+
},
|
|
1583
|
+
get hudLayer() {
|
|
1584
|
+
return hudLayer;
|
|
1585
|
+
},
|
|
1586
|
+
get overlayLayer() {
|
|
1587
|
+
return overlayLayer;
|
|
1588
|
+
},
|
|
1589
|
+
get invalidator() {
|
|
1590
|
+
return inv;
|
|
1591
|
+
},
|
|
1592
|
+
get hovered() {
|
|
1593
|
+
// Narrow to the read-only view so handle consumers can't mutate the
|
|
1594
|
+
// hover state directly. Mutation flows through the pointer pipeline.
|
|
1595
|
+
return {
|
|
1596
|
+
get: () => hoverSignal.get(),
|
|
1597
|
+
peek: () => hoverSignal.peek(),
|
|
1598
|
+
subscribe: (fn: (v: HoveredHit | null) => void) => hoverSignal.subscribe(fn),
|
|
1599
|
+
};
|
|
1600
|
+
},
|
|
1601
|
+
get selected() {
|
|
1602
|
+
// Read-only view + a `clear()` escape hatch (e.g., on Escape key).
|
|
1603
|
+
// Mutation otherwise flows through the click pipeline.
|
|
1604
|
+
return {
|
|
1605
|
+
get: () => selectionSignal.get(),
|
|
1606
|
+
peek: () => selectionSignal.peek(),
|
|
1607
|
+
subscribe: (fn: (v: readonly HoveredHit[]) => void) => selectionSignal.subscribe(fn),
|
|
1608
|
+
clear: () => grammarSelection?.clear(),
|
|
1609
|
+
};
|
|
1610
|
+
},
|
|
1611
|
+
get brushed() {
|
|
1612
|
+
return {
|
|
1613
|
+
get: () => brushedSignal.get(),
|
|
1614
|
+
peek: () => brushedSignal.peek(),
|
|
1615
|
+
subscribe: (fn: (v: readonly HoveredHit[]) => void) => brushedSignal.subscribe(fn),
|
|
1616
|
+
clear: () => grammarBrush?.clear(),
|
|
1617
|
+
};
|
|
1618
|
+
},
|
|
1619
|
+
get hidden() {
|
|
1620
|
+
return {
|
|
1621
|
+
get: () => hiddenSignal.get(),
|
|
1622
|
+
peek: () => hiddenSignal.peek(),
|
|
1623
|
+
subscribe: (fn: (v: ReadonlySet<string>) => void) => hiddenSignal.subscribe(fn),
|
|
1624
|
+
clear: () => {
|
|
1625
|
+
hiddenSignal.set(new Set());
|
|
1626
|
+
inv.invalidate();
|
|
1627
|
+
},
|
|
1628
|
+
};
|
|
1629
|
+
},
|
|
1630
|
+
|
|
1631
|
+
// Stats
|
|
1632
|
+
get width() {
|
|
1633
|
+
return width;
|
|
1634
|
+
},
|
|
1635
|
+
get height() {
|
|
1636
|
+
return height;
|
|
1637
|
+
},
|
|
1638
|
+
get dpr() {
|
|
1639
|
+
return dpr;
|
|
1640
|
+
},
|
|
1641
|
+
get shapeCount() {
|
|
1642
|
+
return (
|
|
1643
|
+
axisLayer.shapeCount + marksLayer.shapeCount + hudLayer.shapeCount + overlayLayer.shapeCount
|
|
1644
|
+
);
|
|
1645
|
+
},
|
|
1646
|
+
get triangleCount() {
|
|
1647
|
+
return (
|
|
1648
|
+
axisLayer.triangleCount +
|
|
1649
|
+
marksLayer.triangleCount +
|
|
1650
|
+
hudLayer.triangleCount +
|
|
1651
|
+
overlayLayer.triangleCount
|
|
1652
|
+
);
|
|
1653
|
+
},
|
|
1654
|
+
get needsFrame() {
|
|
1655
|
+
return inv.dirty || (partial && overlayDirty);
|
|
1656
|
+
},
|
|
1657
|
+
get plotFrame() {
|
|
1658
|
+
return chartPlotFrame;
|
|
1659
|
+
},
|
|
1660
|
+
get viewport() {
|
|
1661
|
+
return panZoomViewport;
|
|
1662
|
+
},
|
|
1663
|
+
attachSeriesReadout(readoutOpts: AttachSeriesReadoutOptions): AttachedSeriesReadout {
|
|
1664
|
+
// Series-readout needs an InteractionManager — auto-create one when the
|
|
1665
|
+
// mount didn't already build one for a tooltip/selection/etc. Same for
|
|
1666
|
+
// the shared hit-layer: high-z hit-cloud events keep the readout's
|
|
1667
|
+
// `cursorX` populated while the cursor is over a real mark.
|
|
1668
|
+
if (!manager) {
|
|
1669
|
+
manager = createInteractionManager(canvas);
|
|
1670
|
+
manager.onChange(() => requestOverlay());
|
|
1671
|
+
}
|
|
1672
|
+
if (!hitLayer) {
|
|
1673
|
+
hitLayer = createGrammarHitLayer({ manager, element: canvas });
|
|
1674
|
+
}
|
|
1675
|
+
const readout = createSeriesReadout(
|
|
1676
|
+
{
|
|
1677
|
+
manager,
|
|
1678
|
+
bounds: () => chartPlotFrame,
|
|
1679
|
+
scales: () => latestScales,
|
|
1680
|
+
theme: () => activeConfig.theme,
|
|
1681
|
+
invalidator: inv,
|
|
1682
|
+
hitLayer,
|
|
1683
|
+
},
|
|
1684
|
+
readoutOpts,
|
|
1685
|
+
);
|
|
1686
|
+
attachedSeriesReadouts.add(readout);
|
|
1687
|
+
// Force a draw so `out.hitTests` flows into the freshly attached readout
|
|
1688
|
+
// (and a freshly created hit-layer, if any). Without this the panel
|
|
1689
|
+
// stays empty until the next pointer move / data change.
|
|
1690
|
+
inv.invalidate();
|
|
1691
|
+
return {
|
|
1692
|
+
peek: () => readout.peek(),
|
|
1693
|
+
subscribe: (fn) => readout.subscribe(fn),
|
|
1694
|
+
dispose: () => {
|
|
1695
|
+
readout.dispose();
|
|
1696
|
+
attachedSeriesReadouts.delete(readout);
|
|
1697
|
+
},
|
|
1698
|
+
};
|
|
1699
|
+
},
|
|
1700
|
+
attachBrush(brushOpts: AttachBrushOptions): AttachedBrush {
|
|
1701
|
+
if (grammarBrush !== null || attachedBrush !== null) {
|
|
1702
|
+
throw new Error(
|
|
1703
|
+
"attachBrush: a brush is already configured on this mount " +
|
|
1704
|
+
"(via `interactions.brush` or a prior `attachBrush` call). " +
|
|
1705
|
+
"Dispose the existing brush before attaching another.",
|
|
1706
|
+
);
|
|
1707
|
+
}
|
|
1708
|
+
// Same lazy-create pattern as `attachSeriesReadout` — a chart with no
|
|
1709
|
+
// other interactions can still attach a brush after mount.
|
|
1710
|
+
if (!manager) {
|
|
1711
|
+
manager = createInteractionManager(canvas);
|
|
1712
|
+
manager.onChange(() => requestOverlay());
|
|
1713
|
+
}
|
|
1714
|
+
const attached = createAttachedBrush(
|
|
1715
|
+
{
|
|
1716
|
+
manager,
|
|
1717
|
+
// Brush operates on the chart-wide plot frame (matches the
|
|
1718
|
+
// `interactions.brush` path).
|
|
1719
|
+
bounds: () => chartPlotFrame,
|
|
1720
|
+
hudLayer: () => overlayLayer,
|
|
1721
|
+
theme: () => activeConfig.theme,
|
|
1722
|
+
invalidator: overlayInv,
|
|
1723
|
+
},
|
|
1724
|
+
{
|
|
1725
|
+
...brushOpts,
|
|
1726
|
+
onSelect: (hits) => {
|
|
1727
|
+
// Fan-out to both the mount-level signal (so `MountedPlot.brushed`
|
|
1728
|
+
// observers see the same payload as the declarative brush path)
|
|
1729
|
+
// and the user's onSelect callback.
|
|
1730
|
+
brushedSignal.set(hits);
|
|
1731
|
+
brushOpts.onSelect?.(hits);
|
|
1732
|
+
requestOverlay();
|
|
1733
|
+
},
|
|
1734
|
+
},
|
|
1735
|
+
);
|
|
1736
|
+
attachedBrush = attached;
|
|
1737
|
+
inv.invalidate();
|
|
1738
|
+
return {
|
|
1739
|
+
peek: () => brushedSignal.peek(),
|
|
1740
|
+
rect: () => attached.rect(),
|
|
1741
|
+
subscribe: (fn) => brushedSignal.subscribe(fn),
|
|
1742
|
+
clear: () => attached.clear(),
|
|
1743
|
+
dispose: () => {
|
|
1744
|
+
attached.dispose();
|
|
1745
|
+
if (attachedBrush === attached) attachedBrush = null;
|
|
1746
|
+
if (brushedSignal.peek().length > 0) brushedSignal.set([]);
|
|
1747
|
+
},
|
|
1748
|
+
};
|
|
1749
|
+
},
|
|
1750
|
+
pickAt(canvasX: number, canvasY: number) {
|
|
1751
|
+
return screenToData(canvasX, canvasY, {
|
|
1752
|
+
frame: chartPlotFrame,
|
|
1753
|
+
scales: latestScales,
|
|
1754
|
+
coord: activeConfig.coord,
|
|
1755
|
+
});
|
|
1756
|
+
},
|
|
1757
|
+
attachRangePresets(presetOpts: AttachRangePresetsOptions): AttachedRangePresets {
|
|
1758
|
+
if (!panZoomViewport) {
|
|
1759
|
+
throw new Error(
|
|
1760
|
+
"attachRangePresets: panZoom must be enabled on mount() to drive a range-preset controller.",
|
|
1761
|
+
);
|
|
1762
|
+
}
|
|
1763
|
+
const attached = attachRangePresetsHelper(panZoomViewport, activeConfig.theme, presetOpts);
|
|
1764
|
+
attachedPresets.add(attached);
|
|
1765
|
+
return {
|
|
1766
|
+
setActive: (k) => attached.setActive(k),
|
|
1767
|
+
getActive: () => attached.getActive(),
|
|
1768
|
+
subscribe: (fn) => attached.subscribe(fn),
|
|
1769
|
+
dispose: () => {
|
|
1770
|
+
attached.dispose();
|
|
1771
|
+
attachedPresets.delete(attached);
|
|
1772
|
+
},
|
|
1773
|
+
};
|
|
1774
|
+
},
|
|
1775
|
+
|
|
1776
|
+
// Transitions handle
|
|
1777
|
+
get transitions() {
|
|
1778
|
+
return {
|
|
1779
|
+
requestTransition: () => {
|
|
1780
|
+
if (!activeConfig.theme.motion.enabled) return;
|
|
1781
|
+
grammarTransitions?.requestTransition();
|
|
1782
|
+
inv.invalidate();
|
|
1783
|
+
},
|
|
1784
|
+
};
|
|
1785
|
+
},
|
|
1786
|
+
};
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
// ---------------------------------------------------------------------------
|
|
1790
|
+
// Helpers
|
|
1791
|
+
// ---------------------------------------------------------------------------
|
|
1792
|
+
|
|
1793
|
+
/**
|
|
1794
|
+
* Charts produced by `plot()` carry their frozen `ChartConfig` on a non-
|
|
1795
|
+
* enumerable internal property (`__config__`). The mount needs that config
|
|
1796
|
+
* to compile the spec, but we don't want it visible on the public `Chart<T>`
|
|
1797
|
+
* interface — the export here keeps the cast in one place.
|
|
1798
|
+
*/
|
|
1799
|
+
interface ChartWithConfig<T> extends Chart<T> {
|
|
1800
|
+
readonly __config__: ChartConfig<T>;
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
function configOf<T>(chart: Chart<T>): ChartConfig<T> {
|
|
1804
|
+
const c = (chart as ChartWithConfig<T>).__config__;
|
|
1805
|
+
if (!c) {
|
|
1806
|
+
throw new Error(
|
|
1807
|
+
"Chart is missing internal config — was it produced by plot()? " +
|
|
1808
|
+
"If you're constructing a chart manually, ensure makeChart() is used.",
|
|
1809
|
+
);
|
|
1810
|
+
}
|
|
1811
|
+
return c;
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
function readDpr(): number {
|
|
1815
|
+
if (typeof window !== "undefined" && typeof window.devicePixelRatio === "number") {
|
|
1816
|
+
return window.devicePixelRatio || 1;
|
|
1817
|
+
}
|
|
1818
|
+
return 1;
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
interface ResolvedPanZoom {
|
|
1822
|
+
minZoom: number;
|
|
1823
|
+
maxZoom: number;
|
|
1824
|
+
panBounds: DataPanBoundsOptions | undefined;
|
|
1825
|
+
pan: AxisSelection;
|
|
1826
|
+
zoom: AxisSelection;
|
|
1827
|
+
/** Half-padding fraction applied around the visible-Y extent, or null when yFit is off. */
|
|
1828
|
+
yFitPadding: number | null;
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
function resolvePanZoom(input: boolean | PanZoomConfig | undefined): ResolvedPanZoom | null {
|
|
1832
|
+
if (!input) return null;
|
|
1833
|
+
const cfg: PanZoomConfig = input === true ? {} : input;
|
|
1834
|
+
|
|
1835
|
+
// yFit forces X-only pan/zoom — Y becomes derived state.
|
|
1836
|
+
const yFit = cfg.yFit;
|
|
1837
|
+
let yFitPadding: number | null = null;
|
|
1838
|
+
if (yFit) {
|
|
1839
|
+
const raw = yFit === true ? DEFAULT_Y_FIT_PADDING : (yFit.padding ?? DEFAULT_Y_FIT_PADDING);
|
|
1840
|
+
if (!Number.isFinite(raw) || raw < 0 || raw > 0.5) {
|
|
1841
|
+
throw new Error(`panZoom.yFit.padding must be in [0, 0.5] (got ${raw}).`);
|
|
1842
|
+
}
|
|
1843
|
+
yFitPadding = raw;
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
const pan: AxisSelection = yFitPadding !== null ? "x" : (cfg.pan ?? "xy");
|
|
1847
|
+
const zoom: AxisSelection = yFitPadding !== null ? "x" : (cfg.zoom ?? "xy");
|
|
1848
|
+
|
|
1849
|
+
const panBounds: DataPanBoundsOptions | undefined =
|
|
1850
|
+
cfg.panBounds === false ? undefined : (cfg.panBounds ?? DEFAULT_PAN_BOUNDS);
|
|
1851
|
+
|
|
1852
|
+
return {
|
|
1853
|
+
minZoom: cfg.minZoom ?? 1,
|
|
1854
|
+
maxZoom: cfg.maxZoom ?? 100,
|
|
1855
|
+
panBounds,
|
|
1856
|
+
pan,
|
|
1857
|
+
zoom,
|
|
1858
|
+
yFitPadding,
|
|
1859
|
+
};
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
/**
|
|
1863
|
+
* Walk each layer's x/y accessors, find data points whose X falls inside
|
|
1864
|
+
* `visibleX`, and return `[yMin - pad, yMax + pad]`. Returns `null` when no
|
|
1865
|
+
* layer / no point falls in range — callers should keep the previous Y
|
|
1866
|
+
* window rather than collapsing.
|
|
1867
|
+
*/
|
|
1868
|
+
function computeVisibleYExtent<T>(
|
|
1869
|
+
layers: ReadonlyArray<{ channels: { x?: unknown; y?: unknown } }>,
|
|
1870
|
+
data: readonly T[],
|
|
1871
|
+
visibleX: readonly [number, number],
|
|
1872
|
+
padding: number,
|
|
1873
|
+
): readonly [number, number] | null {
|
|
1874
|
+
if (data.length === 0 || layers.length === 0) return null;
|
|
1875
|
+
const lo = Math.min(visibleX[0], visibleX[1]);
|
|
1876
|
+
const hi = Math.max(visibleX[0], visibleX[1]);
|
|
1877
|
+
let yMin = Infinity;
|
|
1878
|
+
let yMax = -Infinity;
|
|
1879
|
+
let seen = false;
|
|
1880
|
+
for (const layer of layers) {
|
|
1881
|
+
const cx = layer.channels.x;
|
|
1882
|
+
const cy = layer.channels.y;
|
|
1883
|
+
if (cx === undefined || cy === undefined) continue;
|
|
1884
|
+
// Array-shaped channels (stacked / multi-series) — skip; yFit on a stack
|
|
1885
|
+
// would need separate per-series logic and isn't supported in v1.
|
|
1886
|
+
if (Array.isArray(cx) || Array.isArray(cy)) continue;
|
|
1887
|
+
const ax = resolveAes<T, unknown>(cx as Aes<T, unknown>);
|
|
1888
|
+
const ay = resolveAes<T, unknown>(cy as Aes<T, unknown>);
|
|
1889
|
+
for (let i = 0; i < data.length; i++) {
|
|
1890
|
+
const xv = toNumeric(ax.fn(data[i]!, i));
|
|
1891
|
+
if (xv === null || xv < lo || xv > hi) continue;
|
|
1892
|
+
const yv = toNumeric(ay.fn(data[i]!, i));
|
|
1893
|
+
if (yv === null) continue;
|
|
1894
|
+
if (yv < yMin) yMin = yv;
|
|
1895
|
+
if (yv > yMax) yMax = yv;
|
|
1896
|
+
seen = true;
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
if (!seen) return null;
|
|
1900
|
+
if (yMin === yMax) {
|
|
1901
|
+
// Singleton — pad against an absolute baseline so the axis has range.
|
|
1902
|
+
const pad = Math.abs(yMin) * padding || 1;
|
|
1903
|
+
return [yMin - pad, yMax + pad];
|
|
1904
|
+
}
|
|
1905
|
+
const span = yMax - yMin;
|
|
1906
|
+
const pad = span * padding;
|
|
1907
|
+
return [yMin - pad, yMax + pad];
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
function toNumeric(v: unknown): number | null {
|
|
1911
|
+
if (typeof v === "number") return Number.isFinite(v) ? v : null;
|
|
1912
|
+
if (v instanceof Date) {
|
|
1913
|
+
const t = v.getTime();
|
|
1914
|
+
return Number.isFinite(t) ? t : null;
|
|
1915
|
+
}
|
|
1916
|
+
return null;
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
/**
|
|
1920
|
+
* Screen → data primitive backing `MountedPlot.pickAt`. Extracted so it can be
|
|
1921
|
+
* unit-tested without spinning up a full mount (WebGPU device, layers, etc.).
|
|
1922
|
+
*
|
|
1923
|
+
* Returns `null` when the point is outside the frame, when `coord.unproject`
|
|
1924
|
+
* rejects the point (polar outside the radius band), when scales are not yet
|
|
1925
|
+
* available (pre-first-draw), or when either position scale lacks `invert`
|
|
1926
|
+
* (band scales — band → continuous picking is a separate problem).
|
|
1927
|
+
*/
|
|
1928
|
+
function screenToData(
|
|
1929
|
+
canvasX: number,
|
|
1930
|
+
canvasY: number,
|
|
1931
|
+
ctx: {
|
|
1932
|
+
frame: Frame;
|
|
1933
|
+
scales: import("./geoms/types.ts").ScaleBundle | null;
|
|
1934
|
+
coord: Coord;
|
|
1935
|
+
},
|
|
1936
|
+
): {
|
|
1937
|
+
plotFrameX: number;
|
|
1938
|
+
plotFrameY: number;
|
|
1939
|
+
dataX: number;
|
|
1940
|
+
dataY: number;
|
|
1941
|
+
frame: Frame;
|
|
1942
|
+
} | null {
|
|
1943
|
+
const { frame, scales, coord } = ctx;
|
|
1944
|
+
if (frame.width <= 0 || frame.height <= 0) return null;
|
|
1945
|
+
if (!scales) return null;
|
|
1946
|
+
const xAxis = scales.x.axisScale as { invert?: (v: number) => unknown };
|
|
1947
|
+
const yAxis = scales.y.axisScale as { invert?: (v: number) => unknown };
|
|
1948
|
+
if (typeof xAxis.invert !== "function" || typeof yAxis.invert !== "function") {
|
|
1949
|
+
return null;
|
|
1950
|
+
}
|
|
1951
|
+
// Plot-frame-relative pixel position.
|
|
1952
|
+
const pfx = canvasX - frame.x;
|
|
1953
|
+
const pfy = canvasY - frame.y;
|
|
1954
|
+
// Outside the frame → no hit.
|
|
1955
|
+
if (pfx < 0 || pfx > frame.width || pfy < 0 || pfy > frame.height) return null;
|
|
1956
|
+
// Route through the active coord's unproject. For Cartesian this is the
|
|
1957
|
+
// identity; for polar/radial it inverts the projection and returns null
|
|
1958
|
+
// outside `[innerRadius, outerRadius]`. The pipeline ensures the coord is
|
|
1959
|
+
// `bindFrame`-bound to the current `plotFrame` before each draw, so its
|
|
1960
|
+
// internal centre/outerR match what's on screen.
|
|
1961
|
+
const unprojected = coord.unproject({ x: pfx, y: pfy });
|
|
1962
|
+
if (!unprojected) return null;
|
|
1963
|
+
const dataX = xAxis.invert(unprojected.x) as number;
|
|
1964
|
+
const dataY = yAxis.invert(unprojected.y) as number;
|
|
1965
|
+
if (!Number.isFinite(dataX) || !Number.isFinite(dataY)) return null;
|
|
1966
|
+
return {
|
|
1967
|
+
plotFrameX: unprojected.x,
|
|
1968
|
+
plotFrameY: unprojected.y,
|
|
1969
|
+
dataX,
|
|
1970
|
+
dataY,
|
|
1971
|
+
frame,
|
|
1972
|
+
};
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
/** @internal — exposed for unit tests only. */
|
|
1976
|
+
export const __test__ = {
|
|
1977
|
+
resolvePanZoom,
|
|
1978
|
+
computeVisibleYExtent,
|
|
1979
|
+
wrapViewportThroughCoord,
|
|
1980
|
+
screenToData,
|
|
1981
|
+
};
|
|
1982
|
+
|
|
1983
|
+
interface ResolvedInteractions {
|
|
1984
|
+
tooltip: false | TooltipConfig;
|
|
1985
|
+
crosshair: false | CrosshairConfig;
|
|
1986
|
+
selection: false | SelectionConfig;
|
|
1987
|
+
brush: false | BrushConfig;
|
|
1988
|
+
legend: false | LegendInteractionConfig;
|
|
1989
|
+
contextMenu: false | ContextMenuConfig;
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
function resolveInteractions(
|
|
1993
|
+
input: MountPlotOptions<unknown>["interactions"],
|
|
1994
|
+
): ResolvedInteractions {
|
|
1995
|
+
// Selection, brush, and context menu are opt-in: not enabled by `true` /
|
|
1996
|
+
// `undefined` shorthand, because their semantics are app-specific (the
|
|
1997
|
+
// context menu in particular has no useful default without an `onTrigger`).
|
|
1998
|
+
if (input === false) {
|
|
1999
|
+
return {
|
|
2000
|
+
tooltip: false,
|
|
2001
|
+
crosshair: false,
|
|
2002
|
+
selection: false,
|
|
2003
|
+
brush: false,
|
|
2004
|
+
legend: false,
|
|
2005
|
+
contextMenu: false,
|
|
2006
|
+
};
|
|
2007
|
+
}
|
|
2008
|
+
if (input === undefined || input === true) {
|
|
2009
|
+
return {
|
|
2010
|
+
tooltip: {},
|
|
2011
|
+
crosshair: {},
|
|
2012
|
+
selection: false,
|
|
2013
|
+
brush: false,
|
|
2014
|
+
legend: {},
|
|
2015
|
+
contextMenu: false,
|
|
2016
|
+
};
|
|
2017
|
+
}
|
|
2018
|
+
const cfg: InteractionsConfig = input;
|
|
2019
|
+
return {
|
|
2020
|
+
tooltip: resolveDefaultOn(cfg.tooltip),
|
|
2021
|
+
crosshair: resolveDefaultOn(cfg.crosshair),
|
|
2022
|
+
selection: resolveOptIn(cfg.selection),
|
|
2023
|
+
brush: resolveOptIn(cfg.brush),
|
|
2024
|
+
legend: resolveDefaultOn(cfg.legend),
|
|
2025
|
+
// Context menu requires an `onTrigger` to be useful, so we only honor
|
|
2026
|
+
// the object form. Boolean shorthands have no sensible default.
|
|
2027
|
+
contextMenu: cfg.contextMenu ?? false,
|
|
2028
|
+
};
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
// Default-on channels (tooltip/crosshair/legend): undefined or true → {}.
|
|
2032
|
+
function resolveDefaultOn<C>(input: C | boolean | undefined): false | C {
|
|
2033
|
+
if (input === false) return false;
|
|
2034
|
+
if (input === undefined || input === true) return {} as C;
|
|
2035
|
+
return input;
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
// Opt-in channels (selection/brush): undefined or false → off; true → {}.
|
|
2039
|
+
function resolveOptIn<C>(input: C | boolean | undefined): false | C {
|
|
2040
|
+
if (!input) return false;
|
|
2041
|
+
if (input === true) return {} as C;
|
|
2042
|
+
return input;
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
function resolveTransitionsCfg(
|
|
2046
|
+
input: MountPlotOptions<unknown>["transitions"],
|
|
2047
|
+
): false | TransitionsConfig {
|
|
2048
|
+
if (input === false) return false;
|
|
2049
|
+
if (input === undefined || input === true) return {};
|
|
2050
|
+
return input;
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
const SCALE_OVERRIDE_KEYS = [
|
|
2054
|
+
"x",
|
|
2055
|
+
"y",
|
|
2056
|
+
"color",
|
|
2057
|
+
"size",
|
|
2058
|
+
"alpha",
|
|
2059
|
+
"shape",
|
|
2060
|
+
"borderStyle",
|
|
2061
|
+
"overlayGlyph",
|
|
2062
|
+
] as const;
|
|
2063
|
+
|
|
2064
|
+
/**
|
|
2065
|
+
* Wrap a `DataViewport` so pan/zoom calls flow through `coord.handlePan` /
|
|
2066
|
+
* `coord.handleZoom`. The wrapper is `Object.create(underlying)` — every
|
|
2067
|
+
* untouched member (state, visibleXDomain, absoluteFrame, setFrame, …)
|
|
2068
|
+
* resolves through the prototype, so the wrapper stays in lockstep with the
|
|
2069
|
+
* underlying `DataViewport` API. Only `panBy` and `zoomAt` are overridden.
|
|
2070
|
+
*
|
|
2071
|
+
* `getCoord` is called at-the-event-time so `update()` swapping the chart
|
|
2072
|
+
* spec (and its `coord`) takes effect on the next interaction without a
|
|
2073
|
+
* remount. `invalidate` fires after each routed call because polar's
|
|
2074
|
+
* `handlePan` may mutate its own `startAngle` without touching the viewport —
|
|
2075
|
+
* which means the viewport's own `onChange` won't fire, and the rAF loop
|
|
2076
|
+
* needs an explicit kick to re-draw the rotated chart.
|
|
2077
|
+
*/
|
|
2078
|
+
function wrapViewportThroughCoord<X, Y>(
|
|
2079
|
+
underlying: DataViewport<X, Y>,
|
|
2080
|
+
getCoord: () => Coord,
|
|
2081
|
+
invalidate: () => void,
|
|
2082
|
+
): DataViewport<X, Y> {
|
|
2083
|
+
const wrapped = Object.create(underlying) as DataViewport<X, Y>;
|
|
2084
|
+
// Build a stable handle the coord uses to mutate scale state. Reading the
|
|
2085
|
+
// bound methods up front avoids `this`-binding surprises since `Object.create`
|
|
2086
|
+
// child lookups would otherwise call them with `this === wrapped` (whose
|
|
2087
|
+
// `panBy` is our override — infinite loop).
|
|
2088
|
+
const handle = {
|
|
2089
|
+
panBy: (dx: number, dy: number) => underlying.panBy(dx, dy),
|
|
2090
|
+
zoomAt: (ax: number, ay: number, f: number | { x?: number; y?: number }) =>
|
|
2091
|
+
underlying.zoomAt(ax, ay, f),
|
|
2092
|
+
};
|
|
2093
|
+
Object.defineProperty(wrapped, "panBy", {
|
|
2094
|
+
value: (dx: number, dy: number) => {
|
|
2095
|
+
getCoord().handlePan({ dx, dy, plotFrame: underlying.frame, viewport: handle });
|
|
2096
|
+
invalidate();
|
|
2097
|
+
},
|
|
2098
|
+
});
|
|
2099
|
+
Object.defineProperty(wrapped, "zoomAt", {
|
|
2100
|
+
value: (anchorSx: number, anchorSy: number, factor: number | { x?: number; y?: number }) => {
|
|
2101
|
+
getCoord().handleZoom({
|
|
2102
|
+
factor,
|
|
2103
|
+
cx: anchorSx,
|
|
2104
|
+
cy: anchorSy,
|
|
2105
|
+
plotFrame: underlying.frame,
|
|
2106
|
+
viewport: handle,
|
|
2107
|
+
});
|
|
2108
|
+
invalidate();
|
|
2109
|
+
},
|
|
2110
|
+
});
|
|
2111
|
+
return wrapped;
|
|
2112
|
+
}
|