@opendata-ai/openchart-vanilla 8.0.0-rc.2 → 8.0.0-rc.21
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/dist/{chunk-W34KN7FD.js → chunk-2NAQRP66.js} +2 -2
- package/dist/chunk-DCODCLWI.js +234 -0
- package/dist/chunk-DCODCLWI.js.map +1 -0
- package/dist/{chunk-5EOV5JQZ.js → chunk-EKAUQAAX.js} +50 -46
- package/dist/chunk-EKAUQAAX.js.map +1 -0
- package/dist/{chunk-ENXSUXC5.js → chunk-KNT5AZ2O.js} +7309 -5490
- package/dist/chunk-KNT5AZ2O.js.map +1 -0
- package/dist/chunk-RM5XS6NR.js +113 -0
- package/dist/chunk-RM5XS6NR.js.map +1 -0
- package/dist/chunk-T32UHMZA.js +282 -0
- package/dist/chunk-T32UHMZA.js.map +1 -0
- package/dist/export-gif.d.ts +113 -0
- package/dist/export-gif.js +21 -0
- package/dist/export-gif.js.map +1 -0
- package/dist/index.d.ts +283 -24
- package/dist/index.js +2012 -763
- package/dist/index.js.map +1 -1
- package/dist/{mount-CUKQLeqD.d.ts → mount-Dvv8NYIv.d.ts} +117 -3
- package/dist/simulation-worker.js +83 -2
- package/dist/static.js +7 -69
- package/dist/static.js.map +1 -1
- package/dist/story/index.d.ts +3 -2
- package/dist/story/index.js +5 -3
- package/dist/story/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/dist/styles.css.map +1 -1
- package/package.json +14 -6
- package/dist/chunk-5EOV5JQZ.js.map +0 -1
- package/dist/chunk-ENXSUXC5.js.map +0 -1
- package/src/__test-fixtures__/dom.ts +0 -70
- package/src/__test-fixtures__/specs.ts +0 -214
- package/src/__tests__/animation.test.ts +0 -358
- package/src/__tests__/annotations-render.test.ts +0 -201
- package/src/__tests__/compound-labels.test.ts +0 -122
- package/src/__tests__/continuous-legend.test.ts +0 -91
- package/src/__tests__/crosshair.test.ts +0 -130
- package/src/__tests__/edit-events.test.ts +0 -902
- package/src/__tests__/events.test.ts +0 -387
- package/src/__tests__/export.test.ts +0 -241
- package/src/__tests__/font-race.test.ts +0 -199
- package/src/__tests__/gradient-ids.test.ts +0 -159
- package/src/__tests__/gradient-utils.test.ts +0 -125
- package/src/__tests__/known-bugs-render.test.ts +0 -183
- package/src/__tests__/map.test.ts +0 -293
- package/src/__tests__/mount.test.ts +0 -357
- package/src/__tests__/pattern-fills.test.ts +0 -105
- package/src/__tests__/resize-timing.test.ts +0 -218
- package/src/__tests__/sankey.test.ts +0 -133
- package/src/__tests__/selection-events.test.ts +0 -869
- package/src/__tests__/series-search.test.ts +0 -311
- package/src/__tests__/static.test.ts +0 -141
- package/src/__tests__/svg-renderer.test.ts +0 -895
- package/src/__tests__/table-keyboard.test.ts +0 -361
- package/src/__tests__/table-mount.test.ts +0 -639
- package/src/__tests__/tilemap.test.ts +0 -158
- package/src/__tests__/tooltip.test.ts +0 -337
- package/src/__tests__/transition.test.ts +0 -1777
- package/src/__tests__/you-draw-it.test.ts +0 -273
- package/src/animation-vars.ts +0 -42
- package/src/animation.ts +0 -76
- package/src/barlist-mount.ts +0 -383
- package/src/barlist-renderer.ts +0 -267
- package/src/dom-helpers.ts +0 -17
- package/src/export.ts +0 -482
- package/src/gradient-utils.ts +0 -138
- package/src/graph/__tests__/canvas-renderer.test.ts +0 -713
- package/src/graph/__tests__/graph-mount.test.ts +0 -213
- package/src/graph/__tests__/interaction.test.ts +0 -205
- package/src/graph/__tests__/keyboard.test.ts +0 -653
- package/src/graph/__tests__/search.test.ts +0 -88
- package/src/graph/__tests__/simulation.test.ts +0 -233
- package/src/graph/__tests__/spatial-index.test.ts +0 -142
- package/src/graph/__tests__/zoom.test.ts +0 -203
- package/src/graph/canvas-renderer.ts +0 -747
- package/src/graph/interaction.ts +0 -367
- package/src/graph/keyboard.ts +0 -208
- package/src/graph/search.ts +0 -50
- package/src/graph/simulation-worker-url.ts +0 -12
- package/src/graph/simulation-worker.ts +0 -278
- package/src/graph/simulation.ts +0 -422
- package/src/graph/spatial-index.ts +0 -121
- package/src/graph/types.ts +0 -47
- package/src/graph/worker-protocol.ts +0 -70
- package/src/graph/zoom.ts +0 -134
- package/src/graph-mount.ts +0 -888
- package/src/index.ts +0 -76
- package/src/interactions/chart-events.ts +0 -139
- package/src/interactions/crosshair.ts +0 -233
- package/src/interactions/drag-handler.ts +0 -175
- package/src/interactions/editing-drags.ts +0 -515
- package/src/interactions/index.ts +0 -25
- package/src/interactions/keyboard-nav.ts +0 -111
- package/src/interactions/legend-interaction.ts +0 -46
- package/src/interactions/selection.ts +0 -280
- package/src/interactions/tooltip-events.ts +0 -72
- package/src/map-camera.ts +0 -88
- package/src/map-mount.ts +0 -701
- package/src/map-renderer.ts +0 -338
- package/src/map-transition.ts +0 -83
- package/src/measure-text.ts +0 -134
- package/src/mount.ts +0 -1382
- package/src/pattern-utils.ts +0 -110
- package/src/renderers/annotations.ts +0 -388
- package/src/renderers/axes.ts +0 -351
- package/src/renderers/brand.ts +0 -87
- package/src/renderers/chrome.ts +0 -196
- package/src/renderers/endpoint-labels.ts +0 -150
- package/src/renderers/legend.ts +0 -311
- package/src/renderers/marks.ts +0 -561
- package/src/renderers/metrics.ts +0 -63
- package/src/renderers/svg-dom.ts +0 -40
- package/src/renderers/table-cells.ts +0 -446
- package/src/resize-observer.ts +0 -46
- package/src/sankey-mount.ts +0 -622
- package/src/sankey-renderer.ts +0 -614
- package/src/series-search.ts +0 -392
- package/src/static.ts +0 -251
- package/src/story/__tests__/camera-math.test.ts +0 -145
- package/src/story/__tests__/create-chart-story.test.ts +0 -317
- package/src/story/__tests__/progress-math.test.ts +0 -95
- package/src/story/__tests__/scroll-driver.test.ts +0 -130
- package/src/story/__tests__/tween.test.ts +0 -159
- package/src/story/camera-math.ts +0 -125
- package/src/story/create-chart-story.ts +0 -353
- package/src/story/crossfade.ts +0 -70
- package/src/story/index.ts +0 -39
- package/src/story/progress-math.ts +0 -85
- package/src/story/resolve-camera-target.ts +0 -119
- package/src/story/scroll-driver.ts +0 -206
- package/src/story/story-camera.ts +0 -49
- package/src/story/tween.ts +0 -112
- package/src/story/types.ts +0 -76
- package/src/svg-ids.ts +0 -23
- package/src/svg-renderer.ts +0 -383
- package/src/table-keyboard.ts +0 -266
- package/src/table-mount.ts +0 -567
- package/src/table-renderer.ts +0 -414
- package/src/text-edit-overlay.ts +0 -255
- package/src/theme-tokens.ts +0 -51
- package/src/tilemap-mount.ts +0 -511
- package/src/tilemap-renderer.ts +0 -396
- package/src/tooltip.ts +0 -177
- package/src/transition.ts +0 -2816
- package/src/you-draw-it.ts +0 -480
- /package/dist/{chunk-W34KN7FD.js.map → chunk-2NAQRP66.js.map} +0 -0
package/src/transition.ts
DELETED
|
@@ -1,2816 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Data-update transition driver for bar/column, line, area, scatter/dot,
|
|
3
|
-
* rule, tick, and text charts. Also transitions axis ticks, tick labels,
|
|
4
|
-
* and gridlines so the whole chart adjusts as one system.
|
|
5
|
-
*
|
|
6
|
-
* Matches marks across layout snapshots by `key`, then animates geometry
|
|
7
|
-
* changes (position, size, path) using a single rAF loop. Enter/exit/update
|
|
8
|
-
* choreography runs in one timeline with cubic-out easing for a smooth
|
|
9
|
-
* deceleration feel.
|
|
10
|
-
*
|
|
11
|
-
* For rect marks: tweens position/size directly.
|
|
12
|
-
* For line/area marks: normalizes point arrays to equal length, interpolates
|
|
13
|
-
* point positions per frame, and rebuilds SVG paths via d3 curve generators.
|
|
14
|
-
* For point marks (scatter/dot/line overlay): tweens cx/cy/r.
|
|
15
|
-
* For rule/tick marks: tweens line endpoints (x1/y1/x2/y2).
|
|
16
|
-
* For text marks: tweens x/y position.
|
|
17
|
-
* For axis ticks/gridlines: matches by value key and tweens positions.
|
|
18
|
-
*
|
|
19
|
-
* Ghost elements handle exits: cloned into the marks container, they
|
|
20
|
-
* collapse + fade out then get removed. No CSS animations are used here
|
|
21
|
-
* because transitions tween computed geometry, not class-toggled states.
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
import type {
|
|
25
|
-
AreaMark,
|
|
26
|
-
AxisLayout,
|
|
27
|
-
ChartLayout,
|
|
28
|
-
GradientDef,
|
|
29
|
-
LineMark,
|
|
30
|
-
Point,
|
|
31
|
-
PointMark,
|
|
32
|
-
RectMark,
|
|
33
|
-
ResolvedAnimation,
|
|
34
|
-
ResolvedAnnotation,
|
|
35
|
-
RuleMarkLayout,
|
|
36
|
-
TextMarkLayout,
|
|
37
|
-
TickMarkLayout,
|
|
38
|
-
} from '@opendata-ai/openchart-core';
|
|
39
|
-
import { isGradientDef } from '@opendata-ai/openchart-core';
|
|
40
|
-
import {
|
|
41
|
-
buildAreaPath,
|
|
42
|
-
buildLinePath,
|
|
43
|
-
EXIT_DEFAULTS,
|
|
44
|
-
serializeKeyValue,
|
|
45
|
-
} from '@opendata-ai/openchart-engine';
|
|
46
|
-
import { interpolateRgb } from 'd3-interpolate';
|
|
47
|
-
import { buildGradientDefs, resolveMarkFill } from './gradient-utils';
|
|
48
|
-
import { rectPathWithCorners, renderSingleMark } from './renderers/marks';
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Stable identity for an annotation across layout snapshots.
|
|
52
|
-
*
|
|
53
|
-
* Annotations have no engine-assigned `mark.key`. When the author supplies
|
|
54
|
-
* `annotation.id` that is the identity; otherwise fall back to type + text,
|
|
55
|
-
* which is stable as long as the annotation keeps saying the same thing.
|
|
56
|
-
*
|
|
57
|
-
* Deliberately NOT keyed on position: an annotation that moves is the same
|
|
58
|
-
* annotation and should tween to its new spot, not cross-fade with itself.
|
|
59
|
-
* And deliberately NOT `data-annotation-index`: that is positional, so
|
|
60
|
-
* inserting an annotation ahead of another would renumber it and make every
|
|
61
|
-
* later annotation look brand new and re-fade.
|
|
62
|
-
*
|
|
63
|
-
* Duplicate keys (same type and text twice) are disambiguated by occurrence
|
|
64
|
-
* order in `keyAnnotations` below, not here.
|
|
65
|
-
*/
|
|
66
|
-
function annotationKey(a: ResolvedAnnotation): string {
|
|
67
|
-
if (a.id) return `id:${a.id}`;
|
|
68
|
-
return `${a.type}:${a.label?.text ?? ''}`;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Key a layout's annotations, suffixing duplicates so every key is unique
|
|
73
|
-
* within the snapshot. Two annotations sharing a key would otherwise both
|
|
74
|
-
* match the same element and fight over it.
|
|
75
|
-
*/
|
|
76
|
-
function keyAnnotations(annotations: ResolvedAnnotation[]): string[] {
|
|
77
|
-
const seen = new Map<string, number>();
|
|
78
|
-
return annotations.map((a) => {
|
|
79
|
-
const base = annotationKey(a);
|
|
80
|
-
const n = seen.get(base) ?? 0;
|
|
81
|
-
seen.set(base, n + 1);
|
|
82
|
-
return n === 0 ? base : `${base}#${n}`;
|
|
83
|
-
});
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// ---------------------------------------------------------------------------
|
|
87
|
-
// Public types
|
|
88
|
-
// ---------------------------------------------------------------------------
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Snapshot of in-flight tween geometry, keyed by mark key.
|
|
92
|
-
* Used for interruption retargeting: the next transition starts from
|
|
93
|
-
* whatever position the previous transition had reached.
|
|
94
|
-
*/
|
|
95
|
-
export type GeometrySnapshot = Map<string, SnapshotGeometry>;
|
|
96
|
-
|
|
97
|
-
export type SnapshotGeometry =
|
|
98
|
-
| { type: 'rect'; x: number; y: number; width: number; height: number }
|
|
99
|
-
| { type: 'point'; cx: number; cy: number; r?: number }
|
|
100
|
-
| { type: 'rule'; x1: number; y1: number; x2: number; y2: number }
|
|
101
|
-
| { type: 'tick'; x1: number; y1: number; x2: number; y2: number }
|
|
102
|
-
| { type: 'textMark'; x: number; y: number }
|
|
103
|
-
| { type: 'line'; d: string }
|
|
104
|
-
| { type: 'area'; d: string; topD?: string };
|
|
105
|
-
|
|
106
|
-
export interface TransitionHandle {
|
|
107
|
-
cancel(): void;
|
|
108
|
-
readonly running: boolean;
|
|
109
|
-
/** Capture current interpolated geometry for all in-flight tweens. */
|
|
110
|
-
snapshot(): GeometrySnapshot;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// ---------------------------------------------------------------------------
|
|
114
|
-
// Easing
|
|
115
|
-
// ---------------------------------------------------------------------------
|
|
116
|
-
|
|
117
|
-
/** Cubic-out easing: objects decelerate naturally into place. */
|
|
118
|
-
function cubicOut(t: number): number {
|
|
119
|
-
const f = 1 - t;
|
|
120
|
-
return 1 - f * f * f;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// ---------------------------------------------------------------------------
|
|
124
|
-
// canTransition gate
|
|
125
|
-
// ---------------------------------------------------------------------------
|
|
126
|
-
|
|
127
|
-
/** Mark types that support data-update transitions. */
|
|
128
|
-
const TRANSITIONABLE_MARKS = new Set(['bar', 'line', 'area', 'point']);
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* Spec-shape half of the transition gate (mark type + encoding identity),
|
|
132
|
-
* usable before either spec has been compiled. Exported so callers that
|
|
133
|
-
* need to predict transition eligibility ahead of a compile (e.g. the
|
|
134
|
-
* scrollytelling story layer deciding whether to arm its crossfade
|
|
135
|
-
* fallback) can reuse the exact same rule the post-compile gate enforces,
|
|
136
|
-
* rather than re-deriving it and risking drift.
|
|
137
|
-
*
|
|
138
|
-
* This is gate checks 3-4 of `canTransition` in isolation; it does NOT
|
|
139
|
-
* check layout-derived conditions (dimensions, mark count, geometry delta,
|
|
140
|
-
* sparkline display) since those require a compiled `ChartLayout`.
|
|
141
|
-
*/
|
|
142
|
-
export function canTransitionSpecShape(prevSpec: unknown, nextSpec: unknown): boolean {
|
|
143
|
-
const prev = prevSpec as Record<string, unknown>;
|
|
144
|
-
const next = nextSpec as Record<string, unknown>;
|
|
145
|
-
if (!prev || !next || !('mark' in prev) || !('mark' in next)) return false;
|
|
146
|
-
const prevMark =
|
|
147
|
-
typeof prev.mark === 'string' ? prev.mark : (prev.mark as Record<string, unknown>)?.type;
|
|
148
|
-
const nextMark =
|
|
149
|
-
typeof next.mark === 'string' ? next.mark : (next.mark as Record<string, unknown>)?.type;
|
|
150
|
-
if (prevMark !== nextMark) return false;
|
|
151
|
-
if (!TRANSITIONABLE_MARKS.has(prevMark as string)) return false;
|
|
152
|
-
|
|
153
|
-
const prevEnc = (prev as { encoding?: Record<string, Record<string, unknown>> }).encoding;
|
|
154
|
-
const nextEnc = (next as { encoding?: Record<string, Record<string, unknown>> }).encoding;
|
|
155
|
-
if (!prevEnc || !nextEnc) return false;
|
|
156
|
-
return !(
|
|
157
|
-
prevEnc.x?.field !== nextEnc.x?.field ||
|
|
158
|
-
prevEnc.x?.type !== nextEnc.x?.type ||
|
|
159
|
-
prevEnc.y?.field !== nextEnc.y?.field ||
|
|
160
|
-
prevEnc.y?.type !== nextEnc.y?.type ||
|
|
161
|
-
prevEnc.color?.field !== nextEnc.color?.field
|
|
162
|
-
);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/**
|
|
166
|
-
* Determine whether a data-update transition should run instead of
|
|
167
|
-
* a full tear-down + re-render.
|
|
168
|
-
*
|
|
169
|
-
* Ten gate checks must all pass. If any fails, the caller should fall
|
|
170
|
-
* through to the standard render path (instant swap).
|
|
171
|
-
*/
|
|
172
|
-
export function canTransition(args: {
|
|
173
|
-
prevLayout: ChartLayout | null;
|
|
174
|
-
nextLayout: ChartLayout;
|
|
175
|
-
prevSpec: unknown;
|
|
176
|
-
nextSpec: unknown;
|
|
177
|
-
isFirstRender: boolean;
|
|
178
|
-
entranceInFlight: boolean;
|
|
179
|
-
}): boolean {
|
|
180
|
-
const { prevLayout, nextLayout, prevSpec, nextSpec, isFirstRender, entranceInFlight } = args;
|
|
181
|
-
|
|
182
|
-
// 1. Previous layout + spec exist and not first render
|
|
183
|
-
if (!prevLayout || !prevSpec || isFirstRender) return false;
|
|
184
|
-
|
|
185
|
-
// 2. Resolved animation.update present on next layout
|
|
186
|
-
if (!nextLayout.animation?.update) return false;
|
|
187
|
-
|
|
188
|
-
// 3-4. Mark type + encoding identity unchanged (spec-shape half of the gate)
|
|
189
|
-
if (!canTransitionSpecShape(prevSpec, nextSpec)) return false;
|
|
190
|
-
|
|
191
|
-
// 5. Not sparkline
|
|
192
|
-
if (nextLayout.display === 'sparkline') return false;
|
|
193
|
-
|
|
194
|
-
// 6. Entrance animation not in flight
|
|
195
|
-
if (entranceInFlight) return false;
|
|
196
|
-
|
|
197
|
-
// 7. Layout dimensions unchanged
|
|
198
|
-
if (
|
|
199
|
-
prevLayout.dimensions.width !== nextLayout.dimensions.width ||
|
|
200
|
-
prevLayout.dimensions.height !== nextLayout.dimensions.height
|
|
201
|
-
) {
|
|
202
|
-
return false;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
// 8. Mark count <= 500
|
|
206
|
-
if (nextLayout.marks.length > 500) return false;
|
|
207
|
-
|
|
208
|
-
// 9. Something visible actually changed (zero-delta check)
|
|
209
|
-
if (!hasVisibleChange(prevLayout, nextLayout)) return false;
|
|
210
|
-
|
|
211
|
-
// 10. prefers-reduced-motion not active
|
|
212
|
-
if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
|
|
213
|
-
try {
|
|
214
|
-
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return false;
|
|
215
|
-
} catch {
|
|
216
|
-
// happy-dom may not support matchMedia fully; treat as no preference
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
return true;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/**
|
|
224
|
-
* Check whether anything the transition can animate differs between layouts.
|
|
225
|
-
*
|
|
226
|
-
* Gate check 9. This is broader than geometry on purpose: a step that only
|
|
227
|
-
* recolors (an `encoding.color.highlight` mute) or only adds an annotation
|
|
228
|
-
* moves no mark by a single pixel. Testing geometry alone reported "nothing
|
|
229
|
-
* changed" for those, vetoing the transition and leaving `render()`'s instant
|
|
230
|
-
* swap as the only thing that happened -- so the highlight snapped and the
|
|
231
|
-
* annotation popped in, even though the machinery to fade both already existed
|
|
232
|
-
* further down this file.
|
|
233
|
-
*/
|
|
234
|
-
function hasVisibleChange(prevLayout: ChartLayout, nextLayout: ChartLayout): boolean {
|
|
235
|
-
return (
|
|
236
|
-
hasGeometryChanged(prevLayout, nextLayout) ||
|
|
237
|
-
hasColorChanged(prevLayout, nextLayout) ||
|
|
238
|
-
hasAnnotationChanged(prevLayout, nextLayout)
|
|
239
|
-
);
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
/** Check if any mark's fill or stroke differs (e.g. a highlight mute). */
|
|
243
|
-
function hasColorChanged(prevLayout: ChartLayout, nextLayout: ChartLayout): boolean {
|
|
244
|
-
const prevMarks = prevLayout.marks;
|
|
245
|
-
const nextMarks = nextLayout.marks;
|
|
246
|
-
if (prevMarks.length !== nextMarks.length) return true;
|
|
247
|
-
|
|
248
|
-
for (let i = 0; i < prevMarks.length; i++) {
|
|
249
|
-
const p = prevMarks[i] as { fill?: string; stroke?: string };
|
|
250
|
-
const n = nextMarks[i] as { fill?: string; stroke?: string };
|
|
251
|
-
if (p.fill !== n.fill || p.stroke !== n.stroke) return true;
|
|
252
|
-
}
|
|
253
|
-
return false;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
/** Check if annotations were added, removed, reordered, or moved. */
|
|
257
|
-
function hasAnnotationChanged(prevLayout: ChartLayout, nextLayout: ChartLayout): boolean {
|
|
258
|
-
const prev = prevLayout.annotations ?? [];
|
|
259
|
-
const next = nextLayout.annotations ?? [];
|
|
260
|
-
if (prev.length !== next.length) return true;
|
|
261
|
-
|
|
262
|
-
const prevKeys = keyAnnotations(prev);
|
|
263
|
-
const nextKeys = keyAnnotations(next);
|
|
264
|
-
|
|
265
|
-
for (let i = 0; i < prev.length; i++) {
|
|
266
|
-
if (prevKeys[i] !== nextKeys[i]) return true;
|
|
267
|
-
const p = prev[i];
|
|
268
|
-
const n = next[i];
|
|
269
|
-
if (p.label?.x !== n.label?.x || p.label?.y !== n.label?.y) return true;
|
|
270
|
-
if (p.opacity !== n.opacity) return true;
|
|
271
|
-
}
|
|
272
|
-
return false;
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
/** Check if any mark geometry differs between prev and next layouts. */
|
|
276
|
-
function hasGeometryChanged(prevLayout: ChartLayout, nextLayout: ChartLayout): boolean {
|
|
277
|
-
const prevMarks = prevLayout.marks;
|
|
278
|
-
const nextMarks = nextLayout.marks;
|
|
279
|
-
|
|
280
|
-
if (prevMarks.length !== nextMarks.length) return true;
|
|
281
|
-
|
|
282
|
-
for (let i = 0; i < prevMarks.length; i++) {
|
|
283
|
-
const p = prevMarks[i];
|
|
284
|
-
const n = nextMarks[i];
|
|
285
|
-
if (p.type !== n.type || p.key !== n.key) return true;
|
|
286
|
-
|
|
287
|
-
if (p.type === 'rect' && n.type === 'rect') {
|
|
288
|
-
if (p.x !== n.x || p.y !== n.y || p.width !== n.width || p.height !== n.height) return true;
|
|
289
|
-
} else if (p.type === 'line' && n.type === 'line') {
|
|
290
|
-
if (p.path !== n.path || p.points.length !== n.points.length) return true;
|
|
291
|
-
for (let j = 0; j < p.points.length; j++) {
|
|
292
|
-
if (p.points[j].x !== n.points[j].x || p.points[j].y !== n.points[j].y) return true;
|
|
293
|
-
}
|
|
294
|
-
} else if (p.type === 'area' && n.type === 'area') {
|
|
295
|
-
if (p.path !== n.path) return true;
|
|
296
|
-
} else if (p.type === 'point' && n.type === 'point') {
|
|
297
|
-
if (p.cx !== n.cx || p.cy !== n.cy || p.r !== n.r) return true;
|
|
298
|
-
} else if (p.type === 'rule' && n.type === 'rule') {
|
|
299
|
-
if (p.x1 !== n.x1 || p.y1 !== n.y1 || p.x2 !== n.x2 || p.y2 !== n.y2) return true;
|
|
300
|
-
} else if (p.type === 'tick' && n.type === 'tick') {
|
|
301
|
-
if (p.x !== n.x || p.y !== n.y) return true;
|
|
302
|
-
} else if (p.type === 'textMark' && n.type === 'textMark') {
|
|
303
|
-
if (p.x !== n.x || p.y !== n.y) return true;
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
return false;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
// ---------------------------------------------------------------------------
|
|
310
|
-
// Internal types for tweens
|
|
311
|
-
// ---------------------------------------------------------------------------
|
|
312
|
-
|
|
313
|
-
interface RectGeom {
|
|
314
|
-
x: number;
|
|
315
|
-
y: number;
|
|
316
|
-
width: number;
|
|
317
|
-
height: number;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
interface RectTween {
|
|
321
|
-
tweenType: 'rect';
|
|
322
|
-
kind: 'update' | 'enter' | 'exit';
|
|
323
|
-
el: SVGElement;
|
|
324
|
-
from: RectGeom;
|
|
325
|
-
to: RectGeom;
|
|
326
|
-
mark: RectMark;
|
|
327
|
-
ghost?: SVGElement;
|
|
328
|
-
fromOpacity?: number;
|
|
329
|
-
toOpacity?: number;
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
interface LineTween {
|
|
333
|
-
tweenType: 'line';
|
|
334
|
-
kind: 'update' | 'enter' | 'exit';
|
|
335
|
-
el: SVGElement;
|
|
336
|
-
fromPts: Point[];
|
|
337
|
-
toPts: Point[];
|
|
338
|
-
/** The final mark's points (may differ from toPts which is the normalized array). */
|
|
339
|
-
finalPoints: Point[];
|
|
340
|
-
interpolate?: string;
|
|
341
|
-
ghost?: SVGElement;
|
|
342
|
-
fromOpacity?: number;
|
|
343
|
-
toOpacity?: number;
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
interface AreaTween {
|
|
347
|
-
tweenType: 'area';
|
|
348
|
-
kind: 'update' | 'enter' | 'exit';
|
|
349
|
-
el: SVGElement;
|
|
350
|
-
fromTop: Point[];
|
|
351
|
-
toTop: Point[];
|
|
352
|
-
fromBottom: Point[];
|
|
353
|
-
toBottom: Point[];
|
|
354
|
-
/** Final mark's points for the exact path at t=1. */
|
|
355
|
-
finalTopPoints: Point[];
|
|
356
|
-
finalBottomPoints: Point[];
|
|
357
|
-
interpolate?: string;
|
|
358
|
-
/** Whether to also write the topPath stroke element. */
|
|
359
|
-
hasStroke: boolean;
|
|
360
|
-
ghost?: SVGElement;
|
|
361
|
-
fromOpacity?: number;
|
|
362
|
-
toOpacity?: number;
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
interface PointTween {
|
|
366
|
-
tweenType: 'point';
|
|
367
|
-
kind: 'update' | 'enter' | 'exit';
|
|
368
|
-
el: SVGElement;
|
|
369
|
-
fromCx: number;
|
|
370
|
-
fromCy: number;
|
|
371
|
-
toCx: number;
|
|
372
|
-
toCy: number;
|
|
373
|
-
fromR?: number;
|
|
374
|
-
toR?: number;
|
|
375
|
-
ghost?: SVGElement;
|
|
376
|
-
fromOpacity?: number;
|
|
377
|
-
toOpacity?: number;
|
|
378
|
-
/** Whether this point has a renderer-set opacity (e.g. suppressed under endpoint marker). */
|
|
379
|
-
suppressedOpacity?: string;
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
interface RuleTween {
|
|
383
|
-
tweenType: 'rule';
|
|
384
|
-
kind: 'update' | 'enter' | 'exit';
|
|
385
|
-
el: SVGElement;
|
|
386
|
-
fromX1: number;
|
|
387
|
-
fromY1: number;
|
|
388
|
-
fromX2: number;
|
|
389
|
-
fromY2: number;
|
|
390
|
-
toX1: number;
|
|
391
|
-
toY1: number;
|
|
392
|
-
toX2: number;
|
|
393
|
-
toY2: number;
|
|
394
|
-
ghost?: SVGElement;
|
|
395
|
-
fromOpacity?: number;
|
|
396
|
-
toOpacity?: number;
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
interface TickTween {
|
|
400
|
-
tweenType: 'tick';
|
|
401
|
-
kind: 'update' | 'enter' | 'exit';
|
|
402
|
-
el: SVGElement;
|
|
403
|
-
fromX1: number;
|
|
404
|
-
fromY1: number;
|
|
405
|
-
fromX2: number;
|
|
406
|
-
fromY2: number;
|
|
407
|
-
toX1: number;
|
|
408
|
-
toY1: number;
|
|
409
|
-
toX2: number;
|
|
410
|
-
toY2: number;
|
|
411
|
-
ghost?: SVGElement;
|
|
412
|
-
fromOpacity?: number;
|
|
413
|
-
toOpacity?: number;
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
interface TextMarkTween {
|
|
417
|
-
tweenType: 'textMark';
|
|
418
|
-
kind: 'update' | 'enter' | 'exit';
|
|
419
|
-
el: SVGElement;
|
|
420
|
-
fromX: number;
|
|
421
|
-
fromY: number;
|
|
422
|
-
toX: number;
|
|
423
|
-
toY: number;
|
|
424
|
-
ghost?: SVGElement;
|
|
425
|
-
fromOpacity?: number;
|
|
426
|
-
toOpacity?: number;
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
interface AxisTickTween {
|
|
430
|
-
tweenType: 'axisTick';
|
|
431
|
-
kind: 'update' | 'enter' | 'exit';
|
|
432
|
-
el: SVGElement;
|
|
433
|
-
/** Position attribute name: 'x' for x-axis ticks, 'y' for y-axis ticks. */
|
|
434
|
-
posAttr: string;
|
|
435
|
-
fromPos: number;
|
|
436
|
-
toPos: number;
|
|
437
|
-
/** Whether to crossfade instead of slide (for rotated labels). */
|
|
438
|
-
crossfade: boolean;
|
|
439
|
-
ghost?: SVGElement;
|
|
440
|
-
fromOpacity?: number;
|
|
441
|
-
toOpacity?: number;
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
interface GridlineTween {
|
|
445
|
-
tweenType: 'gridline';
|
|
446
|
-
kind: 'update' | 'enter' | 'exit';
|
|
447
|
-
el: SVGElement;
|
|
448
|
-
/** Which attrs to tween: 'y' for horizontal gridlines, 'x' for vertical. */
|
|
449
|
-
orientation: 'x' | 'y';
|
|
450
|
-
fromPos: number;
|
|
451
|
-
toPos: number;
|
|
452
|
-
ghost?: SVGElement;
|
|
453
|
-
fromOpacity?: number;
|
|
454
|
-
toOpacity?: number;
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
/**
|
|
458
|
-
* Fill/stroke interpolation, kept separate from the geometry tweens rather
|
|
459
|
-
* than bolted onto each of them.
|
|
460
|
-
*
|
|
461
|
-
* Color is orthogonal to geometry: a highlight mute recolors marks that do not
|
|
462
|
-
* move, and a normal data update moves marks that do not recolor. Threading
|
|
463
|
-
* from/to colors through all nine geometry variants would touch every builder
|
|
464
|
-
* to express something none of them need. A parallel tween on the same rAF
|
|
465
|
-
* loop composes instead -- a mark that both moves and recolors simply gets two
|
|
466
|
-
* tweens, each doing one job.
|
|
467
|
-
*
|
|
468
|
-
* Targets the shape element (rect/path/circle/line) rather than the mark <g>,
|
|
469
|
-
* since that is where the renderer writes fill and stroke.
|
|
470
|
-
*/
|
|
471
|
-
interface ColorTween {
|
|
472
|
-
tweenType: 'color';
|
|
473
|
-
kind: 'update';
|
|
474
|
-
el: SVGElement;
|
|
475
|
-
attr: 'fill' | 'stroke';
|
|
476
|
-
from: string;
|
|
477
|
-
to: string;
|
|
478
|
-
/** Never set; present so the shared opacity step can read the union. */
|
|
479
|
-
fromOpacity?: number;
|
|
480
|
-
toOpacity?: number;
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
/**
|
|
484
|
-
* Annotation enter/update/exit. Entering annotations fade in, surviving ones
|
|
485
|
-
* tween to their new position, exiting ones fade out.
|
|
486
|
-
*/
|
|
487
|
-
interface AnnotationTween {
|
|
488
|
-
tweenType: 'annotation';
|
|
489
|
-
kind: 'update' | 'enter' | 'exit';
|
|
490
|
-
el: SVGElement;
|
|
491
|
-
/** Pixel delta applied as a transform; annotations position via many child attrs. */
|
|
492
|
-
fromDx: number;
|
|
493
|
-
fromDy: number;
|
|
494
|
-
ghost?: SVGElement;
|
|
495
|
-
fromOpacity?: number;
|
|
496
|
-
toOpacity?: number;
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
type Tween =
|
|
500
|
-
| RectTween
|
|
501
|
-
| LineTween
|
|
502
|
-
| AreaTween
|
|
503
|
-
| PointTween
|
|
504
|
-
| RuleTween
|
|
505
|
-
| TickTween
|
|
506
|
-
| TextMarkTween
|
|
507
|
-
| AxisTickTween
|
|
508
|
-
| GridlineTween
|
|
509
|
-
| ColorTween
|
|
510
|
-
| AnnotationTween;
|
|
511
|
-
|
|
512
|
-
// ---------------------------------------------------------------------------
|
|
513
|
-
// Geometry helpers (rect)
|
|
514
|
-
// ---------------------------------------------------------------------------
|
|
515
|
-
|
|
516
|
-
function geomFromMark(m: RectMark): RectGeom {
|
|
517
|
-
return { x: m.x, y: m.y, width: m.width, height: m.height };
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
function applyGeomToElement(el: SVGElement, geom: RectGeom, mark: RectMark): void {
|
|
521
|
-
const shapeEl = el.querySelector('rect, path') as SVGElement | null;
|
|
522
|
-
if (!shapeEl) return;
|
|
523
|
-
|
|
524
|
-
const sides = mark.cornerRadiusSides;
|
|
525
|
-
const partialCorners =
|
|
526
|
-
!!sides && (!sides.tl || !sides.tr || !sides.br || !sides.bl) && !!mark.cornerRadius;
|
|
527
|
-
|
|
528
|
-
if (partialCorners && shapeEl.tagName === 'path') {
|
|
529
|
-
const tempMark = { ...mark, ...geom };
|
|
530
|
-
shapeEl.setAttribute('d', rectPathWithCorners(tempMark, sides));
|
|
531
|
-
} else {
|
|
532
|
-
shapeEl.setAttribute('x', String(geom.x));
|
|
533
|
-
shapeEl.setAttribute('y', String(geom.y));
|
|
534
|
-
shapeEl.setAttribute('width', String(geom.width));
|
|
535
|
-
shapeEl.setAttribute('height', String(geom.height));
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
function lerpGeom(from: RectGeom, to: RectGeom, t: number): RectGeom {
|
|
540
|
-
return {
|
|
541
|
-
x: from.x + (to.x - from.x) * t,
|
|
542
|
-
y: from.y + (to.y - from.y) * t,
|
|
543
|
-
width: from.width + (to.width - from.width) * t,
|
|
544
|
-
height: from.height + (to.height - from.height) * t,
|
|
545
|
-
};
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
// ---------------------------------------------------------------------------
|
|
549
|
-
// Point interpolation helpers (line/area morphing)
|
|
550
|
-
// ---------------------------------------------------------------------------
|
|
551
|
-
|
|
552
|
-
function lerpPoint(a: Point, b: Point, t: number): Point {
|
|
553
|
-
return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
/**
|
|
557
|
-
* Normalize two point arrays (prev and next) to equal length by matching
|
|
558
|
-
* on pointKeys. Inserted/removed points get synthetic positions interpolated
|
|
559
|
-
* from surviving neighbors.
|
|
560
|
-
*
|
|
561
|
-
* Returns [fromPts, toPts] of equal length.
|
|
562
|
-
*/
|
|
563
|
-
export function normalizePointArrays(
|
|
564
|
-
prevPoints: Point[],
|
|
565
|
-
nextPoints: Point[],
|
|
566
|
-
prevKeys: string[],
|
|
567
|
-
nextKeys: string[],
|
|
568
|
-
): [Point[], Point[]] {
|
|
569
|
-
// Build merged key sequence preserving order from both sides
|
|
570
|
-
const nextKeySet = new Set(nextKeys);
|
|
571
|
-
|
|
572
|
-
// Check for zero survivors (no keys in common)
|
|
573
|
-
const survivors = prevKeys.filter((k) => nextKeySet.has(k));
|
|
574
|
-
if (survivors.length === 0) {
|
|
575
|
-
// Crossfade path: return arrays as-is (caller handles opacity crossfade)
|
|
576
|
-
return [prevPoints, nextPoints];
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
// Build key-to-point maps
|
|
580
|
-
const prevByKey = new Map<string, Point>();
|
|
581
|
-
for (let i = 0; i < prevKeys.length; i++) {
|
|
582
|
-
prevByKey.set(prevKeys[i], prevPoints[i]);
|
|
583
|
-
}
|
|
584
|
-
const nextByKey = new Map<string, Point>();
|
|
585
|
-
for (let i = 0; i < nextKeys.length; i++) {
|
|
586
|
-
nextByKey.set(nextKeys[i], nextPoints[i]);
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
// Merge keys in order: walk both arrays maintaining relative order
|
|
590
|
-
const merged = mergeKeyOrder(prevKeys, nextKeys);
|
|
591
|
-
|
|
592
|
-
// Precompute merged index map for O(1) lookups in insertion/removal helpers
|
|
593
|
-
const mergedIndex = new Map<string, number>();
|
|
594
|
-
for (let i = 0; i < merged.length; i++) {
|
|
595
|
-
mergedIndex.set(merged[i], i);
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
const fromPts: Point[] = [];
|
|
599
|
-
const toPts: Point[] = [];
|
|
600
|
-
|
|
601
|
-
for (const key of merged) {
|
|
602
|
-
const inPrev = prevByKey.has(key);
|
|
603
|
-
const inNext = nextByKey.has(key);
|
|
604
|
-
|
|
605
|
-
if (inPrev && inNext) {
|
|
606
|
-
// Matched point: straightforward interpolation
|
|
607
|
-
fromPts.push(prevByKey.get(key)!);
|
|
608
|
-
toPts.push(nextByKey.get(key)!);
|
|
609
|
-
} else if (inNext && !inPrev) {
|
|
610
|
-
// Inserted point: "to" is nextPoint; "from" is interpolated on prev line
|
|
611
|
-
const nextPt = nextByKey.get(key)!;
|
|
612
|
-
toPts.push(nextPt);
|
|
613
|
-
fromPts.push(findInsertionPosition(key, merged, mergedIndex, prevByKey, nextByKey, nextPt));
|
|
614
|
-
} else if (inPrev && !inNext) {
|
|
615
|
-
// Removed point: "from" is prevPoint; "to" is collapse position
|
|
616
|
-
const prevPt = prevByKey.get(key)!;
|
|
617
|
-
fromPts.push(prevPt);
|
|
618
|
-
toPts.push(findRemovalPosition(key, merged, mergedIndex, prevByKey, nextByKey, prevPt));
|
|
619
|
-
}
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
return [fromPts, toPts];
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
/**
|
|
626
|
-
* Merge two key arrays preserving the relative order from both.
|
|
627
|
-
* Keys present in both appear once at their first occurrence position.
|
|
628
|
-
*/
|
|
629
|
-
function mergeKeyOrder(prevKeys: string[], nextKeys: string[]): string[] {
|
|
630
|
-
// Precompute index maps for O(1) lookups instead of indexOf
|
|
631
|
-
const nextIndex = new Map<string, number>();
|
|
632
|
-
for (let i = 0; i < nextKeys.length; i++) {
|
|
633
|
-
if (!nextIndex.has(nextKeys[i])) nextIndex.set(nextKeys[i], i);
|
|
634
|
-
}
|
|
635
|
-
const prevIndex = new Map<string, number>();
|
|
636
|
-
for (let i = 0; i < prevKeys.length; i++) {
|
|
637
|
-
if (!prevIndex.has(prevKeys[i])) prevIndex.set(prevKeys[i], i);
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
const result: string[] = [];
|
|
641
|
-
const added = new Set<string>();
|
|
642
|
-
let pi = 0;
|
|
643
|
-
let ni = 0;
|
|
644
|
-
|
|
645
|
-
while (pi < prevKeys.length && ni < nextKeys.length) {
|
|
646
|
-
const pk = prevKeys[pi];
|
|
647
|
-
const nk = nextKeys[ni];
|
|
648
|
-
|
|
649
|
-
if (added.has(pk)) {
|
|
650
|
-
pi++;
|
|
651
|
-
continue;
|
|
652
|
-
}
|
|
653
|
-
if (added.has(nk)) {
|
|
654
|
-
ni++;
|
|
655
|
-
continue;
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
if (pk === nk) {
|
|
659
|
-
result.push(pk);
|
|
660
|
-
added.add(pk);
|
|
661
|
-
pi++;
|
|
662
|
-
ni++;
|
|
663
|
-
} else {
|
|
664
|
-
const pkInNext = nextIndex.has(pk) && nextIndex.get(pk)! >= ni ? nextIndex.get(pk)! : -1;
|
|
665
|
-
const nkInPrev = prevIndex.has(nk) && prevIndex.get(nk)! >= pi ? prevIndex.get(nk)! : -1;
|
|
666
|
-
|
|
667
|
-
if (pkInNext === -1 && nkInPrev === -1) {
|
|
668
|
-
result.push(pk);
|
|
669
|
-
added.add(pk);
|
|
670
|
-
pi++;
|
|
671
|
-
} else if (pkInNext === -1) {
|
|
672
|
-
result.push(pk);
|
|
673
|
-
added.add(pk);
|
|
674
|
-
pi++;
|
|
675
|
-
} else if (nkInPrev === -1) {
|
|
676
|
-
result.push(nk);
|
|
677
|
-
added.add(nk);
|
|
678
|
-
ni++;
|
|
679
|
-
} else {
|
|
680
|
-
if (pkInNext <= nkInPrev) {
|
|
681
|
-
result.push(pk);
|
|
682
|
-
added.add(pk);
|
|
683
|
-
pi++;
|
|
684
|
-
} else {
|
|
685
|
-
result.push(nk);
|
|
686
|
-
added.add(nk);
|
|
687
|
-
ni++;
|
|
688
|
-
}
|
|
689
|
-
}
|
|
690
|
-
}
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
// Drain remaining
|
|
694
|
-
while (pi < prevKeys.length) {
|
|
695
|
-
if (!added.has(prevKeys[pi])) {
|
|
696
|
-
result.push(prevKeys[pi]);
|
|
697
|
-
added.add(prevKeys[pi]);
|
|
698
|
-
}
|
|
699
|
-
pi++;
|
|
700
|
-
}
|
|
701
|
-
while (ni < nextKeys.length) {
|
|
702
|
-
if (!added.has(nextKeys[ni])) {
|
|
703
|
-
result.push(nextKeys[ni]);
|
|
704
|
-
added.add(nextKeys[ni]);
|
|
705
|
-
}
|
|
706
|
-
ni++;
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
return result;
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
/**
|
|
713
|
-
* Find where an inserted point should start from on the previous line.
|
|
714
|
-
* Interpolates between the nearest surviving neighbors' prev positions,
|
|
715
|
-
* proportional to the new point's x between neighbors' next x positions.
|
|
716
|
-
* If at head/tail, uses the nearest surviving endpoint's prev position.
|
|
717
|
-
*/
|
|
718
|
-
function findInsertionPosition(
|
|
719
|
-
key: string,
|
|
720
|
-
merged: string[],
|
|
721
|
-
mergedIndex: Map<string, number>,
|
|
722
|
-
prevByKey: Map<string, Point>,
|
|
723
|
-
nextByKey: Map<string, Point>,
|
|
724
|
-
nextPt: Point,
|
|
725
|
-
): Point {
|
|
726
|
-
const idx = mergedIndex.get(key) ?? -1;
|
|
727
|
-
|
|
728
|
-
// Find nearest surviving neighbor before
|
|
729
|
-
let beforeKey: string | null = null;
|
|
730
|
-
for (let i = idx - 1; i >= 0; i--) {
|
|
731
|
-
if (prevByKey.has(merged[i]) && nextByKey.has(merged[i])) {
|
|
732
|
-
beforeKey = merged[i];
|
|
733
|
-
break;
|
|
734
|
-
}
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
// Find nearest surviving neighbor after
|
|
738
|
-
let afterKey: string | null = null;
|
|
739
|
-
for (let i = idx + 1; i < merged.length; i++) {
|
|
740
|
-
if (prevByKey.has(merged[i]) && nextByKey.has(merged[i])) {
|
|
741
|
-
afterKey = merged[i];
|
|
742
|
-
break;
|
|
743
|
-
}
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
if (beforeKey && afterKey) {
|
|
747
|
-
// Interpolate between neighbors
|
|
748
|
-
const beforeNext = nextByKey.get(beforeKey)!;
|
|
749
|
-
const afterNext = nextByKey.get(afterKey)!;
|
|
750
|
-
const beforePrev = prevByKey.get(beforeKey)!;
|
|
751
|
-
const afterPrev = prevByKey.get(afterKey)!;
|
|
752
|
-
|
|
753
|
-
const range = afterNext.x - beforeNext.x;
|
|
754
|
-
const t = range === 0 ? 0.5 : (nextPt.x - beforeNext.x) / range;
|
|
755
|
-
return lerpPoint(beforePrev, afterPrev, Math.max(0, Math.min(1, t)));
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
if (beforeKey) {
|
|
759
|
-
// At tail: unfold from nearest surviving endpoint
|
|
760
|
-
return { ...prevByKey.get(beforeKey)! };
|
|
761
|
-
}
|
|
762
|
-
|
|
763
|
-
if (afterKey) {
|
|
764
|
-
// At head: unfold from nearest surviving endpoint
|
|
765
|
-
return { ...prevByKey.get(afterKey)! };
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
// No survivors (shouldn't happen since we check above, but safe fallback)
|
|
769
|
-
return nextPt;
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
/**
|
|
773
|
-
* Find where a removed point should collapse to on the next line.
|
|
774
|
-
* Mirror of findInsertionPosition but for the destination side.
|
|
775
|
-
*/
|
|
776
|
-
function findRemovalPosition(
|
|
777
|
-
key: string,
|
|
778
|
-
merged: string[],
|
|
779
|
-
mergedIndex: Map<string, number>,
|
|
780
|
-
prevByKey: Map<string, Point>,
|
|
781
|
-
nextByKey: Map<string, Point>,
|
|
782
|
-
prevPt: Point,
|
|
783
|
-
): Point {
|
|
784
|
-
const idx = mergedIndex.get(key) ?? -1;
|
|
785
|
-
|
|
786
|
-
// Find nearest surviving neighbor before
|
|
787
|
-
let beforeKey: string | null = null;
|
|
788
|
-
for (let i = idx - 1; i >= 0; i--) {
|
|
789
|
-
if (prevByKey.has(merged[i]) && nextByKey.has(merged[i])) {
|
|
790
|
-
beforeKey = merged[i];
|
|
791
|
-
break;
|
|
792
|
-
}
|
|
793
|
-
}
|
|
794
|
-
|
|
795
|
-
// Find nearest surviving neighbor after
|
|
796
|
-
let afterKey: string | null = null;
|
|
797
|
-
for (let i = idx + 1; i < merged.length; i++) {
|
|
798
|
-
if (prevByKey.has(merged[i]) && nextByKey.has(merged[i])) {
|
|
799
|
-
afterKey = merged[i];
|
|
800
|
-
break;
|
|
801
|
-
}
|
|
802
|
-
}
|
|
803
|
-
|
|
804
|
-
if (beforeKey && afterKey) {
|
|
805
|
-
const beforePrev = prevByKey.get(beforeKey)!;
|
|
806
|
-
const afterPrev = prevByKey.get(afterKey)!;
|
|
807
|
-
const beforeNext = nextByKey.get(beforeKey)!;
|
|
808
|
-
const afterNext = nextByKey.get(afterKey)!;
|
|
809
|
-
|
|
810
|
-
const range = afterPrev.x - beforePrev.x;
|
|
811
|
-
const t = range === 0 ? 0.5 : (prevPt.x - beforePrev.x) / range;
|
|
812
|
-
return lerpPoint(beforeNext, afterNext, Math.max(0, Math.min(1, t)));
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
if (beforeKey) {
|
|
816
|
-
return { ...nextByKey.get(beforeKey)! };
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
if (afterKey) {
|
|
820
|
-
return { ...nextByKey.get(afterKey)! };
|
|
821
|
-
}
|
|
822
|
-
|
|
823
|
-
return prevPt;
|
|
824
|
-
}
|
|
825
|
-
|
|
826
|
-
// ---------------------------------------------------------------------------
|
|
827
|
-
// Line/area path application helpers
|
|
828
|
-
// ---------------------------------------------------------------------------
|
|
829
|
-
|
|
830
|
-
/** Interpolate a point array at parameter t. */
|
|
831
|
-
function interpolatePoints(fromPts: Point[], toPts: Point[], t: number): Point[] {
|
|
832
|
-
const len = Math.min(fromPts.length, toPts.length);
|
|
833
|
-
const result: Point[] = new Array(len);
|
|
834
|
-
for (let i = 0; i < len; i++) {
|
|
835
|
-
result[i] = lerpPoint(fromPts[i], toPts[i], t);
|
|
836
|
-
}
|
|
837
|
-
return result;
|
|
838
|
-
}
|
|
839
|
-
|
|
840
|
-
/** Apply an interpolated path to a line mark's path element. */
|
|
841
|
-
function applyLinePath(el: SVGElement, points: Point[], interpolate?: string): void {
|
|
842
|
-
const pathEl = el.querySelector('path') as SVGElement | null;
|
|
843
|
-
if (!pathEl) return;
|
|
844
|
-
pathEl.setAttribute('d', buildLinePath(points, interpolate));
|
|
845
|
-
}
|
|
846
|
-
|
|
847
|
-
/** Apply interpolated paths to an area mark's fill and stroke path elements. */
|
|
848
|
-
function applyAreaPaths(
|
|
849
|
-
el: SVGElement,
|
|
850
|
-
topPoints: Point[],
|
|
851
|
-
bottomPoints: Point[],
|
|
852
|
-
interpolate?: string,
|
|
853
|
-
hasStroke?: boolean,
|
|
854
|
-
): void {
|
|
855
|
-
const paths = el.querySelectorAll('path');
|
|
856
|
-
// First path is the area fill
|
|
857
|
-
if (paths[0]) {
|
|
858
|
-
paths[0].setAttribute('d', buildAreaPath(topPoints, bottomPoints, interpolate));
|
|
859
|
-
}
|
|
860
|
-
// Second path (if present) is the top-line stroke
|
|
861
|
-
if (hasStroke && paths[1]) {
|
|
862
|
-
paths[1].setAttribute('d', buildLinePath(topPoints, interpolate));
|
|
863
|
-
}
|
|
864
|
-
}
|
|
865
|
-
|
|
866
|
-
/** Apply the exact final path from the mark data (not from interpolated points). */
|
|
867
|
-
function applyFinalLinePath(el: SVGElement, mark: LineMark): void {
|
|
868
|
-
const pathEl = el.querySelector('path') as SVGElement | null;
|
|
869
|
-
if (!pathEl || !mark.path) return;
|
|
870
|
-
pathEl.setAttribute('d', mark.path);
|
|
871
|
-
}
|
|
872
|
-
|
|
873
|
-
/** Apply the exact final paths from the area mark data. */
|
|
874
|
-
function applyFinalAreaPaths(el: SVGElement, mark: AreaMark): void {
|
|
875
|
-
const paths = el.querySelectorAll('path');
|
|
876
|
-
if (paths[0]) {
|
|
877
|
-
paths[0].setAttribute('d', mark.path);
|
|
878
|
-
}
|
|
879
|
-
if (paths[1] && mark.topPath) {
|
|
880
|
-
paths[1].setAttribute('d', mark.topPath);
|
|
881
|
-
}
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
// ---------------------------------------------------------------------------
|
|
885
|
-
// runTransition
|
|
886
|
-
// ---------------------------------------------------------------------------
|
|
887
|
-
|
|
888
|
-
export function runTransition(args: {
|
|
889
|
-
svg: SVGSVGElement;
|
|
890
|
-
prevLayout: ChartLayout;
|
|
891
|
-
nextLayout: ChartLayout;
|
|
892
|
-
animation: ResolvedAnimation;
|
|
893
|
-
onComplete: () => void;
|
|
894
|
-
/** Snapshot from a cancelled in-flight transition for retargeting. */
|
|
895
|
-
fromSnapshot?: GeometrySnapshot;
|
|
896
|
-
}): TransitionHandle {
|
|
897
|
-
const { svg, prevLayout, nextLayout, animation, onComplete, fromSnapshot } = args;
|
|
898
|
-
const update = animation.update!;
|
|
899
|
-
const exit = animation.exit ?? { ...EXIT_DEFAULTS };
|
|
900
|
-
|
|
901
|
-
// Total timeline
|
|
902
|
-
const totalMs = Math.max(update.duration, exit.duration);
|
|
903
|
-
const enterDelay = 0.4 * update.duration; // enters start at 40% of update duration
|
|
904
|
-
const enterDuration = update.duration - enterDelay; // remaining 60%
|
|
905
|
-
|
|
906
|
-
// Find the marks container in the SVG
|
|
907
|
-
const marksContainer = svg.querySelector('.oc-marks') as SVGElement | null;
|
|
908
|
-
if (!marksContainer) {
|
|
909
|
-
onComplete();
|
|
910
|
-
return {
|
|
911
|
-
cancel() {},
|
|
912
|
-
get running() {
|
|
913
|
-
return false;
|
|
914
|
-
},
|
|
915
|
-
snapshot() {
|
|
916
|
-
return new Map();
|
|
917
|
-
},
|
|
918
|
-
};
|
|
919
|
-
}
|
|
920
|
-
|
|
921
|
-
// Build all tweens
|
|
922
|
-
const tweens: Tween[] = [];
|
|
923
|
-
const ghosts: SVGElement[] = [];
|
|
924
|
-
|
|
925
|
-
// Fix gradient ghosts: ensure exiting marks with gradient fills have valid
|
|
926
|
-
// gradient defs in the new SVG. Build any missing gradients into <defs>.
|
|
927
|
-
const defs = svg.querySelector('defs') as SVGElement | null;
|
|
928
|
-
let ghostGradientMap = new Map<string, string>();
|
|
929
|
-
if (defs) {
|
|
930
|
-
// Collect gradient fills from exiting marks (marks in prev but not next)
|
|
931
|
-
const nextKeySet = new Set(nextLayout.marks.filter((m) => m.key).map((m) => m.key));
|
|
932
|
-
const exitingMarks = prevLayout.marks.filter(
|
|
933
|
-
(m) => m.key && !nextKeySet.has(m.key) && 'fill' in m,
|
|
934
|
-
);
|
|
935
|
-
const gradientMarks = exitingMarks.filter(
|
|
936
|
-
(m) => 'fill' in m && isGradientDef((m as { fill: unknown }).fill),
|
|
937
|
-
);
|
|
938
|
-
if (gradientMarks.length > 0) {
|
|
939
|
-
ghostGradientMap = buildGradientDefs(gradientMarks as Array<{ fill?: unknown }>, defs);
|
|
940
|
-
}
|
|
941
|
-
}
|
|
942
|
-
|
|
943
|
-
// Determine what mark types we're dealing with
|
|
944
|
-
const hasRects =
|
|
945
|
-
prevLayout.marks.some((m) => m.type === 'rect') ||
|
|
946
|
-
nextLayout.marks.some((m) => m.type === 'rect');
|
|
947
|
-
const hasLines =
|
|
948
|
-
prevLayout.marks.some((m) => m.type === 'line') ||
|
|
949
|
-
nextLayout.marks.some((m) => m.type === 'line');
|
|
950
|
-
const hasAreas =
|
|
951
|
-
prevLayout.marks.some((m) => m.type === 'area') ||
|
|
952
|
-
nextLayout.marks.some((m) => m.type === 'area');
|
|
953
|
-
const hasPoints =
|
|
954
|
-
prevLayout.marks.some((m) => m.type === 'point') ||
|
|
955
|
-
nextLayout.marks.some((m) => m.type === 'point');
|
|
956
|
-
const hasRules =
|
|
957
|
-
prevLayout.marks.some((m) => m.type === 'rule') ||
|
|
958
|
-
nextLayout.marks.some((m) => m.type === 'rule');
|
|
959
|
-
const hasTicks =
|
|
960
|
-
prevLayout.marks.some((m) => m.type === 'tick') ||
|
|
961
|
-
nextLayout.marks.some((m) => m.type === 'tick');
|
|
962
|
-
const hasTextMarks =
|
|
963
|
-
prevLayout.marks.some((m) => m.type === 'textMark') ||
|
|
964
|
-
nextLayout.marks.some((m) => m.type === 'textMark');
|
|
965
|
-
|
|
966
|
-
if (hasRects) {
|
|
967
|
-
buildRectTweens(
|
|
968
|
-
prevLayout,
|
|
969
|
-
nextLayout,
|
|
970
|
-
marksContainer,
|
|
971
|
-
tweens,
|
|
972
|
-
ghosts,
|
|
973
|
-
ghostGradientMap,
|
|
974
|
-
fromSnapshot,
|
|
975
|
-
);
|
|
976
|
-
}
|
|
977
|
-
if (hasLines) {
|
|
978
|
-
buildLineTweens(prevLayout, nextLayout, marksContainer, tweens, ghosts, fromSnapshot);
|
|
979
|
-
}
|
|
980
|
-
if (hasAreas) {
|
|
981
|
-
buildAreaTweens(
|
|
982
|
-
prevLayout,
|
|
983
|
-
nextLayout,
|
|
984
|
-
marksContainer,
|
|
985
|
-
tweens,
|
|
986
|
-
ghosts,
|
|
987
|
-
ghostGradientMap,
|
|
988
|
-
fromSnapshot,
|
|
989
|
-
);
|
|
990
|
-
}
|
|
991
|
-
if (hasPoints) {
|
|
992
|
-
buildPointTweens(
|
|
993
|
-
prevLayout,
|
|
994
|
-
nextLayout,
|
|
995
|
-
marksContainer,
|
|
996
|
-
tweens,
|
|
997
|
-
ghosts,
|
|
998
|
-
ghostGradientMap,
|
|
999
|
-
fromSnapshot,
|
|
1000
|
-
);
|
|
1001
|
-
}
|
|
1002
|
-
if (hasRules) {
|
|
1003
|
-
buildRuleTweens(prevLayout, nextLayout, marksContainer, tweens, ghosts, fromSnapshot);
|
|
1004
|
-
}
|
|
1005
|
-
if (hasTicks) {
|
|
1006
|
-
buildTickTweens(prevLayout, nextLayout, marksContainer, tweens, ghosts, fromSnapshot);
|
|
1007
|
-
}
|
|
1008
|
-
if (hasTextMarks) {
|
|
1009
|
-
buildTextMarkTweens(prevLayout, nextLayout, marksContainer, tweens, ghosts, fromSnapshot);
|
|
1010
|
-
}
|
|
1011
|
-
|
|
1012
|
-
// Axis tick/gridline transitions
|
|
1013
|
-
buildAxisTweens(svg, prevLayout, nextLayout, tweens, ghosts);
|
|
1014
|
-
|
|
1015
|
-
// Mark fill/stroke (a highlight mute recolors marks that never move)
|
|
1016
|
-
buildColorTweens(prevLayout, nextLayout, marksContainer, tweens);
|
|
1017
|
-
|
|
1018
|
-
// Annotations: keyed diff, so only NEW annotations fade in and surviving
|
|
1019
|
-
// ones hold steady instead of blinking.
|
|
1020
|
-
buildAnnotationTweens(svg, prevLayout, nextLayout, tweens);
|
|
1021
|
-
|
|
1022
|
-
// Secondary element crossfade: endpoint labels and mark labels still use the
|
|
1023
|
-
// blanket fade (annotations were promoted to the keyed path above).
|
|
1024
|
-
const secondaryEls = collectSecondaryElements(svg);
|
|
1025
|
-
// Store original opacities so we can restore them on completion
|
|
1026
|
-
const secondaryOriginalOpacity = secondaryEls.map((el) => el.style.opacity ?? '');
|
|
1027
|
-
|
|
1028
|
-
// Apply all from-states SYNCHRONOUSLY before scheduling the first rAF
|
|
1029
|
-
for (const tw of tweens) {
|
|
1030
|
-
applyTweenState(tw, 0, update, exit, enterDelay, enterDuration);
|
|
1031
|
-
}
|
|
1032
|
-
// Set secondary elements to invisible at start
|
|
1033
|
-
for (const el of secondaryEls) {
|
|
1034
|
-
el.style.opacity = '0';
|
|
1035
|
-
}
|
|
1036
|
-
|
|
1037
|
-
// Animation loop state
|
|
1038
|
-
let rafId: number | null = null;
|
|
1039
|
-
let running = true;
|
|
1040
|
-
let startTime: number | null = null;
|
|
1041
|
-
let lastElapsed = 0;
|
|
1042
|
-
|
|
1043
|
-
// Track line/area marks that need final path snapping
|
|
1044
|
-
const nextLineMarks = nextLayout.marks.filter((m): m is LineMark => m.type === 'line');
|
|
1045
|
-
const nextAreaMarks = nextLayout.marks.filter((m): m is AreaMark => m.type === 'area');
|
|
1046
|
-
|
|
1047
|
-
function tick(now: number): void {
|
|
1048
|
-
if (!running) return;
|
|
1049
|
-
if (startTime === null) startTime = now;
|
|
1050
|
-
|
|
1051
|
-
const elapsed = now - startTime;
|
|
1052
|
-
lastElapsed = elapsed;
|
|
1053
|
-
const tGlobal = Math.min(elapsed / totalMs, 1);
|
|
1054
|
-
|
|
1055
|
-
for (const tw of tweens) {
|
|
1056
|
-
applyTweenState(tw, elapsed, update, exit, enterDelay, enterDuration);
|
|
1057
|
-
}
|
|
1058
|
-
|
|
1059
|
-
// Crossfade secondary elements: delayed 40%, over 60% of update duration
|
|
1060
|
-
applySecondaryOpacity(secondaryEls, elapsed, enterDelay, enterDuration);
|
|
1061
|
-
|
|
1062
|
-
if (tGlobal >= 1) {
|
|
1063
|
-
finish();
|
|
1064
|
-
} else {
|
|
1065
|
-
rafId = requestAnimationFrame(tick);
|
|
1066
|
-
}
|
|
1067
|
-
}
|
|
1068
|
-
|
|
1069
|
-
function finish(): void {
|
|
1070
|
-
if (!running) return;
|
|
1071
|
-
running = false;
|
|
1072
|
-
rafId = null;
|
|
1073
|
-
|
|
1074
|
-
snapToFinal();
|
|
1075
|
-
removeGhosts();
|
|
1076
|
-
// Restore secondary element opacities to their rendered values
|
|
1077
|
-
for (let i = 0; i < secondaryEls.length; i++) {
|
|
1078
|
-
secondaryEls[i].style.opacity = secondaryOriginalOpacity[i];
|
|
1079
|
-
}
|
|
1080
|
-
onComplete();
|
|
1081
|
-
}
|
|
1082
|
-
|
|
1083
|
-
function snapToFinal(): void {
|
|
1084
|
-
for (const tw of tweens) {
|
|
1085
|
-
snapTweenToFinal(tw);
|
|
1086
|
-
}
|
|
1087
|
-
// Snap line/area paths to exact final mark paths (not interpolated)
|
|
1088
|
-
// to ensure round-trip invariant
|
|
1089
|
-
for (const mark of nextLineMarks) {
|
|
1090
|
-
if (!mark.key) continue;
|
|
1091
|
-
const el = marksContainer!.querySelector(`[data-key="${mark.key}"]`) as SVGElement | null;
|
|
1092
|
-
if (el) applyFinalLinePath(el, mark);
|
|
1093
|
-
}
|
|
1094
|
-
for (const mark of nextAreaMarks) {
|
|
1095
|
-
if (!mark.key) continue;
|
|
1096
|
-
const el = marksContainer!.querySelector(`[data-key="${mark.key}"]`) as SVGElement | null;
|
|
1097
|
-
if (el) applyFinalAreaPaths(el, mark);
|
|
1098
|
-
}
|
|
1099
|
-
}
|
|
1100
|
-
|
|
1101
|
-
function removeGhosts(): void {
|
|
1102
|
-
for (const ghost of ghosts) {
|
|
1103
|
-
ghost.parentNode?.removeChild(ghost);
|
|
1104
|
-
}
|
|
1105
|
-
}
|
|
1106
|
-
|
|
1107
|
-
/** Capture the current interpolated geometry of all in-flight tweens. */
|
|
1108
|
-
function captureSnapshot(): GeometrySnapshot {
|
|
1109
|
-
const snap: GeometrySnapshot = new Map();
|
|
1110
|
-
const tUpdate = Math.min(lastElapsed / update.duration, 1);
|
|
1111
|
-
const easedUpdate = cubicOut(tUpdate);
|
|
1112
|
-
|
|
1113
|
-
for (const tw of tweens) {
|
|
1114
|
-
if (tw.kind !== 'update') continue;
|
|
1115
|
-
const key = getKeyForTween(tw);
|
|
1116
|
-
if (!key) continue;
|
|
1117
|
-
|
|
1118
|
-
switch (tw.tweenType) {
|
|
1119
|
-
case 'rect': {
|
|
1120
|
-
const g = lerpGeom(tw.from, tw.to, easedUpdate);
|
|
1121
|
-
snap.set(key, { type: 'rect', ...g });
|
|
1122
|
-
break;
|
|
1123
|
-
}
|
|
1124
|
-
case 'point': {
|
|
1125
|
-
const cx = tw.fromCx + (tw.toCx - tw.fromCx) * easedUpdate;
|
|
1126
|
-
const cy = tw.fromCy + (tw.toCy - tw.fromCy) * easedUpdate;
|
|
1127
|
-
const entry: SnapshotGeometry = { type: 'point', cx, cy };
|
|
1128
|
-
if (tw.fromR !== undefined && tw.toR !== undefined) {
|
|
1129
|
-
entry.r = tw.fromR + (tw.toR - tw.fromR) * easedUpdate;
|
|
1130
|
-
}
|
|
1131
|
-
snap.set(key, entry);
|
|
1132
|
-
break;
|
|
1133
|
-
}
|
|
1134
|
-
case 'rule':
|
|
1135
|
-
case 'tick': {
|
|
1136
|
-
snap.set(key, {
|
|
1137
|
-
type: tw.tweenType,
|
|
1138
|
-
x1: tw.fromX1 + (tw.toX1 - tw.fromX1) * easedUpdate,
|
|
1139
|
-
y1: tw.fromY1 + (tw.toY1 - tw.fromY1) * easedUpdate,
|
|
1140
|
-
x2: tw.fromX2 + (tw.toX2 - tw.fromX2) * easedUpdate,
|
|
1141
|
-
y2: tw.fromY2 + (tw.toY2 - tw.fromY2) * easedUpdate,
|
|
1142
|
-
});
|
|
1143
|
-
break;
|
|
1144
|
-
}
|
|
1145
|
-
case 'textMark': {
|
|
1146
|
-
snap.set(key, {
|
|
1147
|
-
type: 'textMark',
|
|
1148
|
-
x: tw.fromX + (tw.toX - tw.fromX) * easedUpdate,
|
|
1149
|
-
y: tw.fromY + (tw.toY - tw.fromY) * easedUpdate,
|
|
1150
|
-
});
|
|
1151
|
-
break;
|
|
1152
|
-
}
|
|
1153
|
-
case 'line': {
|
|
1154
|
-
// Freeze the current interpolated path string
|
|
1155
|
-
const pts = interpolatePoints(tw.fromPts, tw.toPts, easedUpdate);
|
|
1156
|
-
const d = buildLinePath(pts, tw.interpolate);
|
|
1157
|
-
snap.set(key, { type: 'line', d });
|
|
1158
|
-
break;
|
|
1159
|
-
}
|
|
1160
|
-
case 'area': {
|
|
1161
|
-
const top = interpolatePoints(tw.fromTop, tw.toTop, easedUpdate);
|
|
1162
|
-
const bottom = interpolatePoints(tw.fromBottom, tw.toBottom, easedUpdate);
|
|
1163
|
-
const d = buildAreaPath(top, bottom, tw.interpolate);
|
|
1164
|
-
let topD: string | undefined;
|
|
1165
|
-
if (tw.hasStroke) {
|
|
1166
|
-
topD = buildLinePath(top, tw.interpolate);
|
|
1167
|
-
}
|
|
1168
|
-
snap.set(key, { type: 'area', d, topD });
|
|
1169
|
-
break;
|
|
1170
|
-
}
|
|
1171
|
-
}
|
|
1172
|
-
}
|
|
1173
|
-
return snap;
|
|
1174
|
-
}
|
|
1175
|
-
|
|
1176
|
-
rafId = requestAnimationFrame(tick);
|
|
1177
|
-
|
|
1178
|
-
return {
|
|
1179
|
-
cancel(): void {
|
|
1180
|
-
if (!running) return;
|
|
1181
|
-
running = false;
|
|
1182
|
-
if (rafId !== null) {
|
|
1183
|
-
cancelAnimationFrame(rafId);
|
|
1184
|
-
rafId = null;
|
|
1185
|
-
}
|
|
1186
|
-
snapToFinal();
|
|
1187
|
-
removeGhosts();
|
|
1188
|
-
// Restore secondary element opacities on cancel
|
|
1189
|
-
for (let i = 0; i < secondaryEls.length; i++) {
|
|
1190
|
-
secondaryEls[i].style.opacity = secondaryOriginalOpacity[i];
|
|
1191
|
-
}
|
|
1192
|
-
},
|
|
1193
|
-
get running() {
|
|
1194
|
-
return running;
|
|
1195
|
-
},
|
|
1196
|
-
snapshot(): GeometrySnapshot {
|
|
1197
|
-
return captureSnapshot();
|
|
1198
|
-
},
|
|
1199
|
-
};
|
|
1200
|
-
}
|
|
1201
|
-
|
|
1202
|
-
// ---------------------------------------------------------------------------
|
|
1203
|
-
// Secondary element helpers
|
|
1204
|
-
// ---------------------------------------------------------------------------
|
|
1205
|
-
|
|
1206
|
-
/**
|
|
1207
|
-
* Collect endpoint-label and mark-label groups for the blanket crossfade.
|
|
1208
|
-
*
|
|
1209
|
-
* Annotations used to be in here, which is why an annotation that was already
|
|
1210
|
-
* on screen blinked out and back in on every update: the blanket fade drives
|
|
1211
|
-
* every element it collects from opacity 0, with no notion of which ones are
|
|
1212
|
-
* actually new. They now go through `buildAnnotationTweens` instead, which
|
|
1213
|
-
* diffs them by key. Do not re-add them here -- the blanket fade runs after the
|
|
1214
|
-
* tweens and would clobber the keyed opacity.
|
|
1215
|
-
*/
|
|
1216
|
-
function collectSecondaryElements(svg: SVGSVGElement): SVGElement[] {
|
|
1217
|
-
const els: SVGElement[] = [];
|
|
1218
|
-
const epLabels = svg.querySelector('.oc-endpoint-labels') as SVGElement | null;
|
|
1219
|
-
if (epLabels) els.push(epLabels);
|
|
1220
|
-
const markLabels = svg.querySelector('.oc-mark-labels') as SVGElement | null;
|
|
1221
|
-
if (markLabels) els.push(markLabels);
|
|
1222
|
-
return els;
|
|
1223
|
-
}
|
|
1224
|
-
|
|
1225
|
-
/** Apply delayed fade-in to secondary elements during transition. */
|
|
1226
|
-
function applySecondaryOpacity(
|
|
1227
|
-
els: SVGElement[],
|
|
1228
|
-
elapsed: number,
|
|
1229
|
-
enterDelay: number,
|
|
1230
|
-
enterDuration: number,
|
|
1231
|
-
): void {
|
|
1232
|
-
if (els.length === 0) return;
|
|
1233
|
-
let t: number;
|
|
1234
|
-
if (elapsed < enterDelay) {
|
|
1235
|
-
t = 0;
|
|
1236
|
-
} else {
|
|
1237
|
-
t = Math.min((elapsed - enterDelay) / enterDuration, 1);
|
|
1238
|
-
}
|
|
1239
|
-
const opacity = String(cubicOut(t));
|
|
1240
|
-
for (const el of els) {
|
|
1241
|
-
el.style.opacity = opacity;
|
|
1242
|
-
}
|
|
1243
|
-
}
|
|
1244
|
-
|
|
1245
|
-
/** Get the data-key for a tween's element (used for snapshot keying). */
|
|
1246
|
-
function getKeyForTween(tw: Tween): string | null {
|
|
1247
|
-
if ('ghost' in tw && tw.ghost) return null; // ghosts don't carry keys
|
|
1248
|
-
return tw.el.getAttribute('data-key');
|
|
1249
|
-
}
|
|
1250
|
-
|
|
1251
|
-
// ---------------------------------------------------------------------------
|
|
1252
|
-
// Tween state application (shared across all tween types)
|
|
1253
|
-
// ---------------------------------------------------------------------------
|
|
1254
|
-
|
|
1255
|
-
function resolveLineElement(el: SVGElement): SVGElement {
|
|
1256
|
-
return el.tagName === 'line' ? el : ((el.querySelector('line') as SVGElement) ?? el);
|
|
1257
|
-
}
|
|
1258
|
-
|
|
1259
|
-
function applyTweenState(
|
|
1260
|
-
tw: Tween,
|
|
1261
|
-
elapsed: number,
|
|
1262
|
-
update: { duration: number },
|
|
1263
|
-
exit: { duration: number },
|
|
1264
|
-
enterDelay: number,
|
|
1265
|
-
enterDuration: number,
|
|
1266
|
-
): void {
|
|
1267
|
-
let tLocal: number;
|
|
1268
|
-
|
|
1269
|
-
if (tw.kind === 'exit') {
|
|
1270
|
-
tLocal = Math.min(elapsed / exit.duration, 1);
|
|
1271
|
-
} else if (tw.kind === 'enter') {
|
|
1272
|
-
if (elapsed < enterDelay) {
|
|
1273
|
-
tLocal = 0;
|
|
1274
|
-
} else {
|
|
1275
|
-
tLocal = Math.min((elapsed - enterDelay) / enterDuration, 1);
|
|
1276
|
-
}
|
|
1277
|
-
} else {
|
|
1278
|
-
tLocal = Math.min(elapsed / update.duration, 1);
|
|
1279
|
-
}
|
|
1280
|
-
|
|
1281
|
-
const eased = cubicOut(tLocal);
|
|
1282
|
-
|
|
1283
|
-
switch (tw.tweenType) {
|
|
1284
|
-
case 'rect': {
|
|
1285
|
-
const geom = lerpGeom(tw.from, tw.to, eased);
|
|
1286
|
-
applyGeomToElement(tw.el, geom, tw.mark);
|
|
1287
|
-
break;
|
|
1288
|
-
}
|
|
1289
|
-
case 'line': {
|
|
1290
|
-
const pts = interpolatePoints(tw.fromPts, tw.toPts, eased);
|
|
1291
|
-
applyLinePath(tw.el, pts, tw.interpolate);
|
|
1292
|
-
break;
|
|
1293
|
-
}
|
|
1294
|
-
case 'area': {
|
|
1295
|
-
const top = interpolatePoints(tw.fromTop, tw.toTop, eased);
|
|
1296
|
-
const bottom = interpolatePoints(tw.fromBottom, tw.toBottom, eased);
|
|
1297
|
-
applyAreaPaths(tw.el, top, bottom, tw.interpolate, tw.hasStroke);
|
|
1298
|
-
break;
|
|
1299
|
-
}
|
|
1300
|
-
case 'point': {
|
|
1301
|
-
const cx = tw.fromCx + (tw.toCx - tw.fromCx) * eased;
|
|
1302
|
-
const cy = tw.fromCy + (tw.toCy - tw.fromCy) * eased;
|
|
1303
|
-
tw.el.setAttribute('cx', String(cx));
|
|
1304
|
-
tw.el.setAttribute('cy', String(cy));
|
|
1305
|
-
if (tw.fromR !== undefined && tw.toR !== undefined) {
|
|
1306
|
-
const r = tw.fromR + (tw.toR - tw.fromR) * eased;
|
|
1307
|
-
tw.el.setAttribute('r', String(r));
|
|
1308
|
-
}
|
|
1309
|
-
break;
|
|
1310
|
-
}
|
|
1311
|
-
case 'rule':
|
|
1312
|
-
case 'tick': {
|
|
1313
|
-
const lineEl = resolveLineElement(tw.el);
|
|
1314
|
-
lineEl.setAttribute('x1', String(tw.fromX1 + (tw.toX1 - tw.fromX1) * eased));
|
|
1315
|
-
lineEl.setAttribute('y1', String(tw.fromY1 + (tw.toY1 - tw.fromY1) * eased));
|
|
1316
|
-
lineEl.setAttribute('x2', String(tw.fromX2 + (tw.toX2 - tw.fromX2) * eased));
|
|
1317
|
-
lineEl.setAttribute('y2', String(tw.fromY2 + (tw.toY2 - tw.fromY2) * eased));
|
|
1318
|
-
break;
|
|
1319
|
-
}
|
|
1320
|
-
case 'textMark': {
|
|
1321
|
-
const x = tw.fromX + (tw.toX - tw.fromX) * eased;
|
|
1322
|
-
const y = tw.fromY + (tw.toY - tw.fromY) * eased;
|
|
1323
|
-
tw.el.setAttribute('x', String(x));
|
|
1324
|
-
tw.el.setAttribute('y', String(y));
|
|
1325
|
-
break;
|
|
1326
|
-
}
|
|
1327
|
-
case 'axisTick': {
|
|
1328
|
-
if (tw.crossfade) {
|
|
1329
|
-
// Crossfade: only tween opacity, don't move
|
|
1330
|
-
break;
|
|
1331
|
-
}
|
|
1332
|
-
const pos = tw.fromPos + (tw.toPos - tw.fromPos) * eased;
|
|
1333
|
-
if (tw.posAttr === 'x') {
|
|
1334
|
-
tw.el.setAttribute('x', String(pos));
|
|
1335
|
-
} else {
|
|
1336
|
-
tw.el.setAttribute('y', String(pos));
|
|
1337
|
-
}
|
|
1338
|
-
break;
|
|
1339
|
-
}
|
|
1340
|
-
case 'gridline': {
|
|
1341
|
-
const pos = tw.fromPos + (tw.toPos - tw.fromPos) * eased;
|
|
1342
|
-
if (tw.orientation === 'y') {
|
|
1343
|
-
// Horizontal gridline: y1 and y2 share the same position
|
|
1344
|
-
tw.el.setAttribute('y1', String(pos));
|
|
1345
|
-
tw.el.setAttribute('y2', String(pos));
|
|
1346
|
-
} else {
|
|
1347
|
-
// Vertical gridline: x1 and x2 share the same position
|
|
1348
|
-
tw.el.setAttribute('x1', String(pos));
|
|
1349
|
-
tw.el.setAttribute('x2', String(pos));
|
|
1350
|
-
}
|
|
1351
|
-
break;
|
|
1352
|
-
}
|
|
1353
|
-
case 'color': {
|
|
1354
|
-
tw.el.setAttribute(tw.attr, interpolateRgb(tw.from, tw.to)(eased));
|
|
1355
|
-
break;
|
|
1356
|
-
}
|
|
1357
|
-
case 'annotation': {
|
|
1358
|
-
// Ease the offset back to zero: the group is already rendered at its
|
|
1359
|
-
// final position, so (0,0) is the destination. SVG transform attribute,
|
|
1360
|
-
// not CSS -- CSS transform would replace any SVG transform attribute
|
|
1361
|
-
// (see .claude/rules/svg-animation.md), and this composes cleanly since
|
|
1362
|
-
// the annotations renderer sets none.
|
|
1363
|
-
const dx = tw.fromDx * (1 - eased);
|
|
1364
|
-
const dy = tw.fromDy * (1 - eased);
|
|
1365
|
-
if (dx !== 0 || dy !== 0) {
|
|
1366
|
-
tw.el.setAttribute('transform', `translate(${dx} ${dy})`);
|
|
1367
|
-
}
|
|
1368
|
-
break;
|
|
1369
|
-
}
|
|
1370
|
-
}
|
|
1371
|
-
|
|
1372
|
-
// Apply opacity interpolation if defined
|
|
1373
|
-
if (tw.fromOpacity !== undefined && tw.toOpacity !== undefined) {
|
|
1374
|
-
const opacity = tw.fromOpacity + (tw.toOpacity - tw.fromOpacity) * eased;
|
|
1375
|
-
tw.el.style.opacity = String(opacity);
|
|
1376
|
-
}
|
|
1377
|
-
}
|
|
1378
|
-
|
|
1379
|
-
function snapTweenToFinal(tw: Tween): void {
|
|
1380
|
-
switch (tw.tweenType) {
|
|
1381
|
-
case 'rect':
|
|
1382
|
-
applyGeomToElement(tw.el, tw.to, tw.mark);
|
|
1383
|
-
break;
|
|
1384
|
-
case 'line':
|
|
1385
|
-
applyLinePath(tw.el, tw.toPts, tw.interpolate);
|
|
1386
|
-
break;
|
|
1387
|
-
case 'area':
|
|
1388
|
-
applyAreaPaths(tw.el, tw.toTop, tw.toBottom, tw.interpolate, tw.hasStroke);
|
|
1389
|
-
break;
|
|
1390
|
-
case 'point':
|
|
1391
|
-
tw.el.setAttribute('cx', String(tw.toCx));
|
|
1392
|
-
tw.el.setAttribute('cy', String(tw.toCy));
|
|
1393
|
-
if (tw.toR !== undefined) {
|
|
1394
|
-
tw.el.setAttribute('r', String(tw.toR));
|
|
1395
|
-
}
|
|
1396
|
-
break;
|
|
1397
|
-
case 'rule':
|
|
1398
|
-
case 'tick': {
|
|
1399
|
-
const lineEl = resolveLineElement(tw.el);
|
|
1400
|
-
lineEl.setAttribute('x1', String(tw.toX1));
|
|
1401
|
-
lineEl.setAttribute('y1', String(tw.toY1));
|
|
1402
|
-
lineEl.setAttribute('x2', String(tw.toX2));
|
|
1403
|
-
lineEl.setAttribute('y2', String(tw.toY2));
|
|
1404
|
-
break;
|
|
1405
|
-
}
|
|
1406
|
-
case 'textMark':
|
|
1407
|
-
tw.el.setAttribute('x', String(tw.toX));
|
|
1408
|
-
tw.el.setAttribute('y', String(tw.toY));
|
|
1409
|
-
break;
|
|
1410
|
-
case 'axisTick':
|
|
1411
|
-
if (!tw.crossfade) {
|
|
1412
|
-
if (tw.posAttr === 'x') {
|
|
1413
|
-
tw.el.setAttribute('x', String(tw.toPos));
|
|
1414
|
-
} else {
|
|
1415
|
-
tw.el.setAttribute('y', String(tw.toPos));
|
|
1416
|
-
}
|
|
1417
|
-
}
|
|
1418
|
-
break;
|
|
1419
|
-
case 'gridline':
|
|
1420
|
-
if (tw.orientation === 'y') {
|
|
1421
|
-
tw.el.setAttribute('y1', String(tw.toPos));
|
|
1422
|
-
tw.el.setAttribute('y2', String(tw.toPos));
|
|
1423
|
-
} else {
|
|
1424
|
-
tw.el.setAttribute('x1', String(tw.toPos));
|
|
1425
|
-
tw.el.setAttribute('x2', String(tw.toPos));
|
|
1426
|
-
}
|
|
1427
|
-
break;
|
|
1428
|
-
case 'color':
|
|
1429
|
-
tw.el.setAttribute(tw.attr, tw.to);
|
|
1430
|
-
break;
|
|
1431
|
-
case 'annotation':
|
|
1432
|
-
// The group is rendered at its final position; drop the offset entirely
|
|
1433
|
-
// rather than writing translate(0 0), so the DOM returns to the exact
|
|
1434
|
-
// shape render() produced.
|
|
1435
|
-
tw.el.removeAttribute('transform');
|
|
1436
|
-
break;
|
|
1437
|
-
}
|
|
1438
|
-
|
|
1439
|
-
if (tw.toOpacity !== undefined) {
|
|
1440
|
-
tw.el.style.opacity = String(tw.toOpacity);
|
|
1441
|
-
}
|
|
1442
|
-
|
|
1443
|
-
// Restore suppressed-point opacity
|
|
1444
|
-
if (tw.tweenType === 'point' && tw.suppressedOpacity !== undefined) {
|
|
1445
|
-
tw.el.setAttribute('opacity', tw.suppressedOpacity);
|
|
1446
|
-
}
|
|
1447
|
-
}
|
|
1448
|
-
|
|
1449
|
-
// ---------------------------------------------------------------------------
|
|
1450
|
-
// Gradient ghost helper
|
|
1451
|
-
// ---------------------------------------------------------------------------
|
|
1452
|
-
|
|
1453
|
-
/**
|
|
1454
|
-
* For marks with gradient fills, resolve the gradient to a url(#id) reference
|
|
1455
|
-
* using the ghost gradient map. This ensures ghosts reference valid gradient
|
|
1456
|
-
* IDs in the new SVG's <defs>.
|
|
1457
|
-
*/
|
|
1458
|
-
function resolveGhostGradientFill<T extends { fill?: string | GradientDef }>(
|
|
1459
|
-
mark: T,
|
|
1460
|
-
ghostGradientMap: Map<string, string>,
|
|
1461
|
-
): T {
|
|
1462
|
-
if (!mark.fill || typeof mark.fill === 'string' || ghostGradientMap.size === 0) return mark;
|
|
1463
|
-
const resolved = resolveMarkFill(mark.fill, ghostGradientMap);
|
|
1464
|
-
return { ...mark, fill: resolved };
|
|
1465
|
-
}
|
|
1466
|
-
|
|
1467
|
-
// ---------------------------------------------------------------------------
|
|
1468
|
-
// Rect tween building (extracted from original runTransition)
|
|
1469
|
-
// ---------------------------------------------------------------------------
|
|
1470
|
-
|
|
1471
|
-
function buildRectTweens(
|
|
1472
|
-
prevLayout: ChartLayout,
|
|
1473
|
-
nextLayout: ChartLayout,
|
|
1474
|
-
marksContainer: SVGElement,
|
|
1475
|
-
tweens: Tween[],
|
|
1476
|
-
ghosts: SVGElement[],
|
|
1477
|
-
ghostGradientMap: Map<string, string>,
|
|
1478
|
-
fromSnapshot?: GeometrySnapshot,
|
|
1479
|
-
): void {
|
|
1480
|
-
const prevRects = prevLayout.marks.filter((m): m is RectMark => m.type === 'rect');
|
|
1481
|
-
const nextRects = nextLayout.marks.filter((m): m is RectMark => m.type === 'rect');
|
|
1482
|
-
|
|
1483
|
-
const prevByKey = new Map<string, RectMark>();
|
|
1484
|
-
for (const m of prevRects) {
|
|
1485
|
-
if (m.key) prevByKey.set(m.key, m);
|
|
1486
|
-
}
|
|
1487
|
-
const nextByKey = new Map<string, RectMark>();
|
|
1488
|
-
for (const m of nextRects) {
|
|
1489
|
-
if (m.key) nextByKey.set(m.key, m);
|
|
1490
|
-
}
|
|
1491
|
-
|
|
1492
|
-
// Updated
|
|
1493
|
-
for (const [key, next] of nextByKey) {
|
|
1494
|
-
const prev = prevByKey.get(key);
|
|
1495
|
-
if (!prev) continue;
|
|
1496
|
-
const el = marksContainer.querySelector(`[data-key="${key}"]`) as SVGElement | null;
|
|
1497
|
-
if (!el) continue;
|
|
1498
|
-
|
|
1499
|
-
// Retarget: use snapshot geometry if available (interrupted transition)
|
|
1500
|
-
let fromGeom = geomFromMark(prev);
|
|
1501
|
-
const snap = fromSnapshot?.get(key);
|
|
1502
|
-
if (snap && snap.type === 'rect') {
|
|
1503
|
-
fromGeom = { x: snap.x, y: snap.y, width: snap.width, height: snap.height };
|
|
1504
|
-
}
|
|
1505
|
-
|
|
1506
|
-
tweens.push({
|
|
1507
|
-
tweenType: 'rect',
|
|
1508
|
-
kind: 'update',
|
|
1509
|
-
el,
|
|
1510
|
-
from: fromGeom,
|
|
1511
|
-
to: geomFromMark(next),
|
|
1512
|
-
mark: next,
|
|
1513
|
-
});
|
|
1514
|
-
}
|
|
1515
|
-
|
|
1516
|
-
// Entered
|
|
1517
|
-
for (const [key, next] of nextByKey) {
|
|
1518
|
-
if (prevByKey.has(key)) continue;
|
|
1519
|
-
const el = marksContainer.querySelector(`[data-key="${key}"]`) as SVGElement | null;
|
|
1520
|
-
if (!el) continue;
|
|
1521
|
-
|
|
1522
|
-
let fromGeom: RectGeom;
|
|
1523
|
-
if (next.orient === 'horizontal') {
|
|
1524
|
-
fromGeom = { x: next.x, y: next.y, width: 0, height: next.height };
|
|
1525
|
-
} else {
|
|
1526
|
-
fromGeom = { x: next.x, y: next.y + next.height, width: next.width, height: 0 };
|
|
1527
|
-
}
|
|
1528
|
-
|
|
1529
|
-
tweens.push({
|
|
1530
|
-
tweenType: 'rect',
|
|
1531
|
-
kind: 'enter',
|
|
1532
|
-
el,
|
|
1533
|
-
from: fromGeom,
|
|
1534
|
-
to: geomFromMark(next),
|
|
1535
|
-
mark: next,
|
|
1536
|
-
});
|
|
1537
|
-
}
|
|
1538
|
-
|
|
1539
|
-
// Exited
|
|
1540
|
-
for (const [key, prev] of prevByKey) {
|
|
1541
|
-
if (nextByKey.has(key)) continue;
|
|
1542
|
-
// Resolve gradient fill for ghost if needed
|
|
1543
|
-
const ghostMark = resolveGhostGradientFill(prev, ghostGradientMap);
|
|
1544
|
-
const ghost = renderSingleMark(ghostMark, 0);
|
|
1545
|
-
if (!ghost) continue;
|
|
1546
|
-
ghost.classList.add('oc-ghost');
|
|
1547
|
-
ghost.setAttribute('aria-hidden', 'true');
|
|
1548
|
-
ghost.setAttribute('pointer-events', 'none');
|
|
1549
|
-
ghost.removeAttribute('data-key');
|
|
1550
|
-
marksContainer.appendChild(ghost);
|
|
1551
|
-
ghosts.push(ghost);
|
|
1552
|
-
|
|
1553
|
-
let toGeom: RectGeom;
|
|
1554
|
-
if (prev.orient === 'horizontal') {
|
|
1555
|
-
toGeom = { x: prev.x, y: prev.y, width: 0, height: prev.height };
|
|
1556
|
-
} else {
|
|
1557
|
-
toGeom = { x: prev.x, y: prev.y + prev.height, width: prev.width, height: 0 };
|
|
1558
|
-
}
|
|
1559
|
-
|
|
1560
|
-
tweens.push({
|
|
1561
|
-
tweenType: 'rect',
|
|
1562
|
-
kind: 'exit',
|
|
1563
|
-
el: ghost,
|
|
1564
|
-
from: geomFromMark(prev),
|
|
1565
|
-
to: toGeom,
|
|
1566
|
-
mark: prev,
|
|
1567
|
-
ghost,
|
|
1568
|
-
fromOpacity: 1,
|
|
1569
|
-
toOpacity: 0,
|
|
1570
|
-
});
|
|
1571
|
-
}
|
|
1572
|
-
}
|
|
1573
|
-
|
|
1574
|
-
// ---------------------------------------------------------------------------
|
|
1575
|
-
// Line tween building
|
|
1576
|
-
// ---------------------------------------------------------------------------
|
|
1577
|
-
|
|
1578
|
-
function buildLineTweens(
|
|
1579
|
-
prevLayout: ChartLayout,
|
|
1580
|
-
nextLayout: ChartLayout,
|
|
1581
|
-
marksContainer: SVGElement,
|
|
1582
|
-
tweens: Tween[],
|
|
1583
|
-
ghosts: SVGElement[],
|
|
1584
|
-
fromSnapshot?: GeometrySnapshot,
|
|
1585
|
-
): void {
|
|
1586
|
-
const prevLines = prevLayout.marks.filter((m): m is LineMark => m.type === 'line');
|
|
1587
|
-
const nextLines = nextLayout.marks.filter((m): m is LineMark => m.type === 'line');
|
|
1588
|
-
|
|
1589
|
-
const prevByKey = new Map<string, LineMark>();
|
|
1590
|
-
for (const m of prevLines) {
|
|
1591
|
-
if (m.key) prevByKey.set(m.key, m);
|
|
1592
|
-
}
|
|
1593
|
-
const nextByKey = new Map<string, LineMark>();
|
|
1594
|
-
for (const m of nextLines) {
|
|
1595
|
-
if (m.key) nextByKey.set(m.key, m);
|
|
1596
|
-
}
|
|
1597
|
-
|
|
1598
|
-
// Updated series
|
|
1599
|
-
for (const [key, next] of nextByKey) {
|
|
1600
|
-
const prev = prevByKey.get(key);
|
|
1601
|
-
if (!prev) continue;
|
|
1602
|
-
const el = marksContainer.querySelector(`[data-key="${key}"]`) as SVGElement | null;
|
|
1603
|
-
if (!el) continue;
|
|
1604
|
-
|
|
1605
|
-
// If interrupted mid-morph, freeze the current path and crossfade
|
|
1606
|
-
const snap = fromSnapshot?.get(key);
|
|
1607
|
-
if (snap && snap.type === 'line') {
|
|
1608
|
-
// Create ghost carrying the frozen mid-morph path
|
|
1609
|
-
const ghost = el.cloneNode(true) as SVGElement;
|
|
1610
|
-
ghost.classList.add('oc-ghost');
|
|
1611
|
-
ghost.setAttribute('aria-hidden', 'true');
|
|
1612
|
-
ghost.setAttribute('pointer-events', 'none');
|
|
1613
|
-
ghost.removeAttribute('data-key');
|
|
1614
|
-
const ghostPath = ghost.querySelector('path');
|
|
1615
|
-
if (ghostPath) ghostPath.setAttribute('d', snap.d);
|
|
1616
|
-
marksContainer.appendChild(ghost);
|
|
1617
|
-
ghosts.push(ghost);
|
|
1618
|
-
|
|
1619
|
-
tweens.push({
|
|
1620
|
-
tweenType: 'line',
|
|
1621
|
-
kind: 'exit',
|
|
1622
|
-
el: ghost,
|
|
1623
|
-
fromPts: next.points, // placeholder, not used for path
|
|
1624
|
-
toPts: next.points,
|
|
1625
|
-
finalPoints: next.points,
|
|
1626
|
-
interpolate: next.interpolate,
|
|
1627
|
-
ghost,
|
|
1628
|
-
fromOpacity: 1,
|
|
1629
|
-
toOpacity: 0,
|
|
1630
|
-
});
|
|
1631
|
-
|
|
1632
|
-
// Fade in the new final path
|
|
1633
|
-
tweens.push({
|
|
1634
|
-
tweenType: 'line',
|
|
1635
|
-
kind: 'enter',
|
|
1636
|
-
el,
|
|
1637
|
-
fromPts: next.points,
|
|
1638
|
-
toPts: next.points,
|
|
1639
|
-
finalPoints: next.points,
|
|
1640
|
-
interpolate: next.interpolate,
|
|
1641
|
-
fromOpacity: 0,
|
|
1642
|
-
toOpacity: 1,
|
|
1643
|
-
});
|
|
1644
|
-
continue;
|
|
1645
|
-
}
|
|
1646
|
-
|
|
1647
|
-
const prevPKs = prev.pointKeys ?? prev.points.map((_, i) => `${i}`);
|
|
1648
|
-
const nextPKs = next.pointKeys ?? next.points.map((_, i) => `${i}`);
|
|
1649
|
-
|
|
1650
|
-
// Check if zero survivors (fully replaced dataset)
|
|
1651
|
-
const prevKeySet = new Set(prevPKs);
|
|
1652
|
-
const hasSurvivors = nextPKs.some((k) => prevKeySet.has(k));
|
|
1653
|
-
|
|
1654
|
-
if (!hasSurvivors) {
|
|
1655
|
-
// Crossfade: fade out old (ghost), fade in new
|
|
1656
|
-
const ghost = renderSingleMark(prev, 0);
|
|
1657
|
-
if (ghost) {
|
|
1658
|
-
ghost.classList.add('oc-ghost');
|
|
1659
|
-
ghost.setAttribute('aria-hidden', 'true');
|
|
1660
|
-
ghost.setAttribute('pointer-events', 'none');
|
|
1661
|
-
ghost.removeAttribute('data-key');
|
|
1662
|
-
marksContainer.appendChild(ghost);
|
|
1663
|
-
ghosts.push(ghost);
|
|
1664
|
-
|
|
1665
|
-
tweens.push({
|
|
1666
|
-
tweenType: 'line',
|
|
1667
|
-
kind: 'exit',
|
|
1668
|
-
el: ghost,
|
|
1669
|
-
fromPts: prev.points,
|
|
1670
|
-
toPts: prev.points,
|
|
1671
|
-
finalPoints: prev.points,
|
|
1672
|
-
interpolate: prev.interpolate,
|
|
1673
|
-
ghost,
|
|
1674
|
-
fromOpacity: 1,
|
|
1675
|
-
toOpacity: 0,
|
|
1676
|
-
});
|
|
1677
|
-
}
|
|
1678
|
-
|
|
1679
|
-
// Fade in the new one
|
|
1680
|
-
tweens.push({
|
|
1681
|
-
tweenType: 'line',
|
|
1682
|
-
kind: 'enter',
|
|
1683
|
-
el,
|
|
1684
|
-
fromPts: next.points,
|
|
1685
|
-
toPts: next.points,
|
|
1686
|
-
finalPoints: next.points,
|
|
1687
|
-
interpolate: next.interpolate,
|
|
1688
|
-
fromOpacity: 0,
|
|
1689
|
-
toOpacity: 1,
|
|
1690
|
-
});
|
|
1691
|
-
} else {
|
|
1692
|
-
// Normal morph with point matching
|
|
1693
|
-
const [fromPts, toPts] = normalizePointArrays(prev.points, next.points, prevPKs, nextPKs);
|
|
1694
|
-
|
|
1695
|
-
tweens.push({
|
|
1696
|
-
tweenType: 'line',
|
|
1697
|
-
kind: 'update',
|
|
1698
|
-
el,
|
|
1699
|
-
fromPts,
|
|
1700
|
-
toPts,
|
|
1701
|
-
finalPoints: next.points,
|
|
1702
|
-
interpolate: next.interpolate,
|
|
1703
|
-
});
|
|
1704
|
-
}
|
|
1705
|
-
}
|
|
1706
|
-
|
|
1707
|
-
// Entering series (only in next)
|
|
1708
|
-
for (const [key, next] of nextByKey) {
|
|
1709
|
-
if (prevByKey.has(key)) continue;
|
|
1710
|
-
const el = marksContainer.querySelector(`[data-key="${key}"]`) as SVGElement | null;
|
|
1711
|
-
if (!el) continue;
|
|
1712
|
-
|
|
1713
|
-
tweens.push({
|
|
1714
|
-
tweenType: 'line',
|
|
1715
|
-
kind: 'enter',
|
|
1716
|
-
el,
|
|
1717
|
-
fromPts: next.points,
|
|
1718
|
-
toPts: next.points,
|
|
1719
|
-
finalPoints: next.points,
|
|
1720
|
-
interpolate: next.interpolate,
|
|
1721
|
-
fromOpacity: 0,
|
|
1722
|
-
toOpacity: 1,
|
|
1723
|
-
});
|
|
1724
|
-
}
|
|
1725
|
-
|
|
1726
|
-
// Exiting series (only in prev)
|
|
1727
|
-
for (const [key, prev] of prevByKey) {
|
|
1728
|
-
if (nextByKey.has(key)) continue;
|
|
1729
|
-
|
|
1730
|
-
const ghost = renderSingleMark(prev, 0);
|
|
1731
|
-
if (!ghost) continue;
|
|
1732
|
-
ghost.classList.add('oc-ghost');
|
|
1733
|
-
ghost.setAttribute('aria-hidden', 'true');
|
|
1734
|
-
ghost.setAttribute('pointer-events', 'none');
|
|
1735
|
-
ghost.removeAttribute('data-key');
|
|
1736
|
-
marksContainer.appendChild(ghost);
|
|
1737
|
-
ghosts.push(ghost);
|
|
1738
|
-
|
|
1739
|
-
tweens.push({
|
|
1740
|
-
tweenType: 'line',
|
|
1741
|
-
kind: 'exit',
|
|
1742
|
-
el: ghost,
|
|
1743
|
-
fromPts: prev.points,
|
|
1744
|
-
toPts: prev.points,
|
|
1745
|
-
finalPoints: prev.points,
|
|
1746
|
-
interpolate: prev.interpolate,
|
|
1747
|
-
ghost,
|
|
1748
|
-
fromOpacity: 1,
|
|
1749
|
-
toOpacity: 0,
|
|
1750
|
-
});
|
|
1751
|
-
}
|
|
1752
|
-
}
|
|
1753
|
-
|
|
1754
|
-
// ---------------------------------------------------------------------------
|
|
1755
|
-
// Area tween building
|
|
1756
|
-
// ---------------------------------------------------------------------------
|
|
1757
|
-
|
|
1758
|
-
function buildAreaTweens(
|
|
1759
|
-
prevLayout: ChartLayout,
|
|
1760
|
-
nextLayout: ChartLayout,
|
|
1761
|
-
marksContainer: SVGElement,
|
|
1762
|
-
tweens: Tween[],
|
|
1763
|
-
ghosts: SVGElement[],
|
|
1764
|
-
ghostGradientMap: Map<string, string>,
|
|
1765
|
-
fromSnapshot?: GeometrySnapshot,
|
|
1766
|
-
): void {
|
|
1767
|
-
const prevAreas = prevLayout.marks.filter((m): m is AreaMark => m.type === 'area');
|
|
1768
|
-
const nextAreas = nextLayout.marks.filter((m): m is AreaMark => m.type === 'area');
|
|
1769
|
-
|
|
1770
|
-
const prevByKey = new Map<string, AreaMark>();
|
|
1771
|
-
for (const m of prevAreas) {
|
|
1772
|
-
if (m.key) prevByKey.set(m.key, m);
|
|
1773
|
-
}
|
|
1774
|
-
const nextByKey = new Map<string, AreaMark>();
|
|
1775
|
-
for (const m of nextAreas) {
|
|
1776
|
-
if (m.key) nextByKey.set(m.key, m);
|
|
1777
|
-
}
|
|
1778
|
-
|
|
1779
|
-
// Updated series
|
|
1780
|
-
for (const [key, next] of nextByKey) {
|
|
1781
|
-
const prev = prevByKey.get(key);
|
|
1782
|
-
if (!prev) continue;
|
|
1783
|
-
const el = marksContainer.querySelector(`[data-key="${key}"]`) as SVGElement | null;
|
|
1784
|
-
if (!el) continue;
|
|
1785
|
-
|
|
1786
|
-
// If interrupted mid-morph, freeze and crossfade
|
|
1787
|
-
const snap = fromSnapshot?.get(key);
|
|
1788
|
-
if (snap && snap.type === 'area') {
|
|
1789
|
-
const ghost = el.cloneNode(true) as SVGElement;
|
|
1790
|
-
ghost.classList.add('oc-ghost');
|
|
1791
|
-
ghost.setAttribute('aria-hidden', 'true');
|
|
1792
|
-
ghost.setAttribute('pointer-events', 'none');
|
|
1793
|
-
ghost.removeAttribute('data-key');
|
|
1794
|
-
const ghostPaths = ghost.querySelectorAll('path');
|
|
1795
|
-
if (ghostPaths[0]) ghostPaths[0].setAttribute('d', snap.d);
|
|
1796
|
-
if (ghostPaths[1] && snap.topD) ghostPaths[1].setAttribute('d', snap.topD);
|
|
1797
|
-
marksContainer.appendChild(ghost);
|
|
1798
|
-
ghosts.push(ghost);
|
|
1799
|
-
|
|
1800
|
-
tweens.push({
|
|
1801
|
-
tweenType: 'area',
|
|
1802
|
-
kind: 'exit',
|
|
1803
|
-
el: ghost,
|
|
1804
|
-
fromTop: next.topPoints,
|
|
1805
|
-
toTop: next.topPoints,
|
|
1806
|
-
fromBottom: next.bottomPoints,
|
|
1807
|
-
toBottom: next.bottomPoints,
|
|
1808
|
-
finalTopPoints: next.topPoints,
|
|
1809
|
-
finalBottomPoints: next.bottomPoints,
|
|
1810
|
-
interpolate: next.interpolate,
|
|
1811
|
-
hasStroke: !!next.stroke && !!next.topPath,
|
|
1812
|
-
ghost,
|
|
1813
|
-
fromOpacity: 1,
|
|
1814
|
-
toOpacity: 0,
|
|
1815
|
-
});
|
|
1816
|
-
|
|
1817
|
-
tweens.push({
|
|
1818
|
-
tweenType: 'area',
|
|
1819
|
-
kind: 'enter',
|
|
1820
|
-
el,
|
|
1821
|
-
fromTop: next.topPoints,
|
|
1822
|
-
toTop: next.topPoints,
|
|
1823
|
-
fromBottom: next.bottomPoints,
|
|
1824
|
-
toBottom: next.bottomPoints,
|
|
1825
|
-
finalTopPoints: next.topPoints,
|
|
1826
|
-
finalBottomPoints: next.bottomPoints,
|
|
1827
|
-
interpolate: next.interpolate,
|
|
1828
|
-
hasStroke: !!next.stroke && !!next.topPath,
|
|
1829
|
-
fromOpacity: 0,
|
|
1830
|
-
toOpacity: 1,
|
|
1831
|
-
});
|
|
1832
|
-
continue;
|
|
1833
|
-
}
|
|
1834
|
-
|
|
1835
|
-
const prevPKs = prev.pointKeys ?? prev.topPoints.map((_, i) => `${i}`);
|
|
1836
|
-
const nextPKs = next.pointKeys ?? next.topPoints.map((_, i) => `${i}`);
|
|
1837
|
-
|
|
1838
|
-
const prevKeySet = new Set(prevPKs);
|
|
1839
|
-
const hasSurvivors = nextPKs.some((k) => prevKeySet.has(k));
|
|
1840
|
-
|
|
1841
|
-
if (!hasSurvivors) {
|
|
1842
|
-
// Crossfade
|
|
1843
|
-
const ghost = renderSingleMark(prev, 0);
|
|
1844
|
-
if (ghost) {
|
|
1845
|
-
ghost.classList.add('oc-ghost');
|
|
1846
|
-
ghost.setAttribute('aria-hidden', 'true');
|
|
1847
|
-
ghost.setAttribute('pointer-events', 'none');
|
|
1848
|
-
ghost.removeAttribute('data-key');
|
|
1849
|
-
marksContainer.appendChild(ghost);
|
|
1850
|
-
ghosts.push(ghost);
|
|
1851
|
-
|
|
1852
|
-
tweens.push({
|
|
1853
|
-
tweenType: 'area',
|
|
1854
|
-
kind: 'exit',
|
|
1855
|
-
el: ghost,
|
|
1856
|
-
fromTop: prev.topPoints,
|
|
1857
|
-
toTop: prev.topPoints,
|
|
1858
|
-
fromBottom: prev.bottomPoints,
|
|
1859
|
-
toBottom: prev.bottomPoints,
|
|
1860
|
-
finalTopPoints: prev.topPoints,
|
|
1861
|
-
finalBottomPoints: prev.bottomPoints,
|
|
1862
|
-
interpolate: prev.interpolate,
|
|
1863
|
-
hasStroke: !!prev.stroke && !!prev.topPath,
|
|
1864
|
-
ghost,
|
|
1865
|
-
fromOpacity: 1,
|
|
1866
|
-
toOpacity: 0,
|
|
1867
|
-
});
|
|
1868
|
-
}
|
|
1869
|
-
|
|
1870
|
-
tweens.push({
|
|
1871
|
-
tweenType: 'area',
|
|
1872
|
-
kind: 'enter',
|
|
1873
|
-
el,
|
|
1874
|
-
fromTop: next.topPoints,
|
|
1875
|
-
toTop: next.topPoints,
|
|
1876
|
-
fromBottom: next.bottomPoints,
|
|
1877
|
-
toBottom: next.bottomPoints,
|
|
1878
|
-
finalTopPoints: next.topPoints,
|
|
1879
|
-
finalBottomPoints: next.bottomPoints,
|
|
1880
|
-
interpolate: next.interpolate,
|
|
1881
|
-
hasStroke: !!next.stroke && !!next.topPath,
|
|
1882
|
-
fromOpacity: 0,
|
|
1883
|
-
toOpacity: 1,
|
|
1884
|
-
});
|
|
1885
|
-
} else {
|
|
1886
|
-
// Morph
|
|
1887
|
-
const [fromTop, toTop] = normalizePointArrays(
|
|
1888
|
-
prev.topPoints,
|
|
1889
|
-
next.topPoints,
|
|
1890
|
-
prevPKs,
|
|
1891
|
-
nextPKs,
|
|
1892
|
-
);
|
|
1893
|
-
const [fromBottom, toBottom] = normalizePointArrays(
|
|
1894
|
-
prev.bottomPoints,
|
|
1895
|
-
next.bottomPoints,
|
|
1896
|
-
prevPKs,
|
|
1897
|
-
nextPKs,
|
|
1898
|
-
);
|
|
1899
|
-
|
|
1900
|
-
tweens.push({
|
|
1901
|
-
tweenType: 'area',
|
|
1902
|
-
kind: 'update',
|
|
1903
|
-
el,
|
|
1904
|
-
fromTop,
|
|
1905
|
-
toTop,
|
|
1906
|
-
fromBottom,
|
|
1907
|
-
toBottom,
|
|
1908
|
-
finalTopPoints: next.topPoints,
|
|
1909
|
-
finalBottomPoints: next.bottomPoints,
|
|
1910
|
-
interpolate: next.interpolate,
|
|
1911
|
-
hasStroke: !!next.stroke && !!next.topPath,
|
|
1912
|
-
});
|
|
1913
|
-
}
|
|
1914
|
-
}
|
|
1915
|
-
|
|
1916
|
-
// Entering series
|
|
1917
|
-
for (const [key, next] of nextByKey) {
|
|
1918
|
-
if (prevByKey.has(key)) continue;
|
|
1919
|
-
const el = marksContainer.querySelector(`[data-key="${key}"]`) as SVGElement | null;
|
|
1920
|
-
if (!el) continue;
|
|
1921
|
-
|
|
1922
|
-
tweens.push({
|
|
1923
|
-
tweenType: 'area',
|
|
1924
|
-
kind: 'enter',
|
|
1925
|
-
el,
|
|
1926
|
-
fromTop: next.topPoints,
|
|
1927
|
-
toTop: next.topPoints,
|
|
1928
|
-
fromBottom: next.bottomPoints,
|
|
1929
|
-
toBottom: next.bottomPoints,
|
|
1930
|
-
finalTopPoints: next.topPoints,
|
|
1931
|
-
finalBottomPoints: next.bottomPoints,
|
|
1932
|
-
interpolate: next.interpolate,
|
|
1933
|
-
hasStroke: !!next.stroke && !!next.topPath,
|
|
1934
|
-
fromOpacity: 0,
|
|
1935
|
-
toOpacity: 1,
|
|
1936
|
-
});
|
|
1937
|
-
}
|
|
1938
|
-
|
|
1939
|
-
// Exiting series
|
|
1940
|
-
for (const [key, prev] of prevByKey) {
|
|
1941
|
-
if (nextByKey.has(key)) continue;
|
|
1942
|
-
|
|
1943
|
-
const ghostMark = resolveGhostGradientFill(prev, ghostGradientMap);
|
|
1944
|
-
const ghost = renderSingleMark(ghostMark, 0);
|
|
1945
|
-
if (!ghost) continue;
|
|
1946
|
-
ghost.classList.add('oc-ghost');
|
|
1947
|
-
ghost.setAttribute('aria-hidden', 'true');
|
|
1948
|
-
ghost.setAttribute('pointer-events', 'none');
|
|
1949
|
-
ghost.removeAttribute('data-key');
|
|
1950
|
-
marksContainer.appendChild(ghost);
|
|
1951
|
-
ghosts.push(ghost);
|
|
1952
|
-
|
|
1953
|
-
tweens.push({
|
|
1954
|
-
tweenType: 'area',
|
|
1955
|
-
kind: 'exit',
|
|
1956
|
-
el: ghost,
|
|
1957
|
-
fromTop: prev.topPoints,
|
|
1958
|
-
toTop: prev.topPoints,
|
|
1959
|
-
fromBottom: prev.bottomPoints,
|
|
1960
|
-
toBottom: prev.bottomPoints,
|
|
1961
|
-
finalTopPoints: prev.topPoints,
|
|
1962
|
-
finalBottomPoints: prev.bottomPoints,
|
|
1963
|
-
interpolate: prev.interpolate,
|
|
1964
|
-
hasStroke: !!prev.stroke && !!prev.topPath,
|
|
1965
|
-
ghost,
|
|
1966
|
-
fromOpacity: 1,
|
|
1967
|
-
toOpacity: 0,
|
|
1968
|
-
});
|
|
1969
|
-
}
|
|
1970
|
-
}
|
|
1971
|
-
|
|
1972
|
-
// ---------------------------------------------------------------------------
|
|
1973
|
-
// Point tween building (scatter/dot/line overlay circle marks)
|
|
1974
|
-
// ---------------------------------------------------------------------------
|
|
1975
|
-
|
|
1976
|
-
function buildPointTweens(
|
|
1977
|
-
prevLayout: ChartLayout,
|
|
1978
|
-
nextLayout: ChartLayout,
|
|
1979
|
-
marksContainer: SVGElement,
|
|
1980
|
-
tweens: Tween[],
|
|
1981
|
-
ghosts: SVGElement[],
|
|
1982
|
-
ghostGradientMap: Map<string, string>,
|
|
1983
|
-
fromSnapshot?: GeometrySnapshot,
|
|
1984
|
-
): void {
|
|
1985
|
-
const prevPoints = prevLayout.marks.filter((m): m is PointMark => m.type === 'point');
|
|
1986
|
-
const nextPoints = nextLayout.marks.filter((m): m is PointMark => m.type === 'point');
|
|
1987
|
-
|
|
1988
|
-
const prevByKey = new Map<string, PointMark>();
|
|
1989
|
-
for (const m of prevPoints) {
|
|
1990
|
-
if (m.key) prevByKey.set(m.key, m);
|
|
1991
|
-
}
|
|
1992
|
-
const nextByKey = new Map<string, PointMark>();
|
|
1993
|
-
for (const m of nextPoints) {
|
|
1994
|
-
if (m.key) nextByKey.set(m.key, m);
|
|
1995
|
-
}
|
|
1996
|
-
|
|
1997
|
-
// Updated points: tween cx/cy/r
|
|
1998
|
-
for (const [key, next] of nextByKey) {
|
|
1999
|
-
const prev = prevByKey.get(key);
|
|
2000
|
-
if (!prev) continue;
|
|
2001
|
-
const el = marksContainer.querySelector(
|
|
2002
|
-
`circle.oc-mark-point[data-key="${key}"]`,
|
|
2003
|
-
) as SVGElement | null;
|
|
2004
|
-
if (!el) continue;
|
|
2005
|
-
|
|
2006
|
-
// Read the element's current opacity attribute (NOT style.opacity)
|
|
2007
|
-
// to detect suppressed points (opacity="0" set by renderer for endpoint markers)
|
|
2008
|
-
const renderedOpacity = el.getAttribute('opacity');
|
|
2009
|
-
const isSuppressed = renderedOpacity === '0';
|
|
2010
|
-
|
|
2011
|
-
// Retarget from snapshot if interrupted
|
|
2012
|
-
let fromCx = prev.cx;
|
|
2013
|
-
let fromCy = prev.cy;
|
|
2014
|
-
let fromR = prev.r !== next.r ? prev.r : undefined;
|
|
2015
|
-
const snap = fromSnapshot?.get(key);
|
|
2016
|
-
if (snap && snap.type === 'point') {
|
|
2017
|
-
fromCx = snap.cx;
|
|
2018
|
-
fromCy = snap.cy;
|
|
2019
|
-
if (snap.r !== undefined && snap.r !== next.r) {
|
|
2020
|
-
fromR = snap.r;
|
|
2021
|
-
}
|
|
2022
|
-
}
|
|
2023
|
-
|
|
2024
|
-
tweens.push({
|
|
2025
|
-
tweenType: 'point',
|
|
2026
|
-
kind: 'update',
|
|
2027
|
-
el,
|
|
2028
|
-
fromCx,
|
|
2029
|
-
fromCy,
|
|
2030
|
-
toCx: next.cx,
|
|
2031
|
-
toCy: next.cy,
|
|
2032
|
-
fromR: fromR,
|
|
2033
|
-
toR: fromR !== undefined ? next.r : undefined,
|
|
2034
|
-
// Do NOT tween opacity for updated points - preserve renderer state
|
|
2035
|
-
suppressedOpacity: isSuppressed ? '0' : undefined,
|
|
2036
|
-
});
|
|
2037
|
-
}
|
|
2038
|
-
|
|
2039
|
-
// Entering points: fade in
|
|
2040
|
-
for (const [key, next] of nextByKey) {
|
|
2041
|
-
if (prevByKey.has(key)) continue;
|
|
2042
|
-
const el = marksContainer.querySelector(
|
|
2043
|
-
`circle.oc-mark-point[data-key="${key}"]`,
|
|
2044
|
-
) as SVGElement | null;
|
|
2045
|
-
if (!el) continue;
|
|
2046
|
-
|
|
2047
|
-
// Read rendered opacity for the final value
|
|
2048
|
-
const renderedOpacity = el.getAttribute('opacity');
|
|
2049
|
-
const targetOpacity = renderedOpacity != null ? Number(renderedOpacity) : 1;
|
|
2050
|
-
|
|
2051
|
-
tweens.push({
|
|
2052
|
-
tweenType: 'point',
|
|
2053
|
-
kind: 'enter',
|
|
2054
|
-
el,
|
|
2055
|
-
fromCx: next.cx,
|
|
2056
|
-
fromCy: next.cy,
|
|
2057
|
-
toCx: next.cx,
|
|
2058
|
-
toCy: next.cy,
|
|
2059
|
-
fromOpacity: 0,
|
|
2060
|
-
toOpacity: targetOpacity,
|
|
2061
|
-
suppressedOpacity: renderedOpacity === '0' ? '0' : undefined,
|
|
2062
|
-
});
|
|
2063
|
-
}
|
|
2064
|
-
|
|
2065
|
-
// Exiting points: ghost fade out
|
|
2066
|
-
for (const [key, prev] of prevByKey) {
|
|
2067
|
-
if (nextByKey.has(key)) continue;
|
|
2068
|
-
|
|
2069
|
-
const ghostMark = resolveGhostGradientFill(prev, ghostGradientMap);
|
|
2070
|
-
const ghost = renderSingleMark(ghostMark, 0);
|
|
2071
|
-
if (!ghost) continue;
|
|
2072
|
-
ghost.classList.add('oc-ghost');
|
|
2073
|
-
ghost.setAttribute('aria-hidden', 'true');
|
|
2074
|
-
ghost.setAttribute('pointer-events', 'none');
|
|
2075
|
-
ghost.removeAttribute('data-key');
|
|
2076
|
-
marksContainer.appendChild(ghost);
|
|
2077
|
-
ghosts.push(ghost);
|
|
2078
|
-
|
|
2079
|
-
tweens.push({
|
|
2080
|
-
tweenType: 'point',
|
|
2081
|
-
kind: 'exit',
|
|
2082
|
-
el: ghost,
|
|
2083
|
-
fromCx: prev.cx,
|
|
2084
|
-
fromCy: prev.cy,
|
|
2085
|
-
toCx: prev.cx,
|
|
2086
|
-
toCy: prev.cy,
|
|
2087
|
-
ghost,
|
|
2088
|
-
fromOpacity: 1,
|
|
2089
|
-
toOpacity: 0,
|
|
2090
|
-
});
|
|
2091
|
-
}
|
|
2092
|
-
}
|
|
2093
|
-
|
|
2094
|
-
// ---------------------------------------------------------------------------
|
|
2095
|
-
// Rule tween building
|
|
2096
|
-
// ---------------------------------------------------------------------------
|
|
2097
|
-
|
|
2098
|
-
function buildRuleTweens(
|
|
2099
|
-
prevLayout: ChartLayout,
|
|
2100
|
-
nextLayout: ChartLayout,
|
|
2101
|
-
marksContainer: SVGElement,
|
|
2102
|
-
tweens: Tween[],
|
|
2103
|
-
ghosts: SVGElement[],
|
|
2104
|
-
fromSnapshot?: GeometrySnapshot,
|
|
2105
|
-
): void {
|
|
2106
|
-
const prevRules = prevLayout.marks.filter((m): m is RuleMarkLayout => m.type === 'rule');
|
|
2107
|
-
const nextRules = nextLayout.marks.filter((m): m is RuleMarkLayout => m.type === 'rule');
|
|
2108
|
-
|
|
2109
|
-
const prevByKey = new Map<string, RuleMarkLayout>();
|
|
2110
|
-
for (const m of prevRules) {
|
|
2111
|
-
if (m.key) prevByKey.set(m.key, m);
|
|
2112
|
-
}
|
|
2113
|
-
const nextByKey = new Map<string, RuleMarkLayout>();
|
|
2114
|
-
for (const m of nextRules) {
|
|
2115
|
-
if (m.key) nextByKey.set(m.key, m);
|
|
2116
|
-
}
|
|
2117
|
-
|
|
2118
|
-
// Updated
|
|
2119
|
-
for (const [key, next] of nextByKey) {
|
|
2120
|
-
const prev = prevByKey.get(key);
|
|
2121
|
-
if (!prev) continue;
|
|
2122
|
-
const el = marksContainer.querySelector(
|
|
2123
|
-
`.oc-mark-rule[data-key="${key}"]`,
|
|
2124
|
-
) as SVGElement | null;
|
|
2125
|
-
if (!el) continue;
|
|
2126
|
-
|
|
2127
|
-
let fromX1 = prev.x1;
|
|
2128
|
-
let fromY1 = prev.y1;
|
|
2129
|
-
let fromX2 = prev.x2;
|
|
2130
|
-
let fromY2 = prev.y2;
|
|
2131
|
-
const snap = fromSnapshot?.get(key);
|
|
2132
|
-
if (snap && snap.type === 'rule') {
|
|
2133
|
-
fromX1 = snap.x1;
|
|
2134
|
-
fromY1 = snap.y1;
|
|
2135
|
-
fromX2 = snap.x2;
|
|
2136
|
-
fromY2 = snap.y2;
|
|
2137
|
-
}
|
|
2138
|
-
|
|
2139
|
-
tweens.push({
|
|
2140
|
-
tweenType: 'rule',
|
|
2141
|
-
kind: 'update',
|
|
2142
|
-
el,
|
|
2143
|
-
fromX1,
|
|
2144
|
-
fromY1,
|
|
2145
|
-
fromX2,
|
|
2146
|
-
fromY2,
|
|
2147
|
-
toX1: next.x1,
|
|
2148
|
-
toY1: next.y1,
|
|
2149
|
-
toX2: next.x2,
|
|
2150
|
-
toY2: next.y2,
|
|
2151
|
-
});
|
|
2152
|
-
}
|
|
2153
|
-
|
|
2154
|
-
// Entering
|
|
2155
|
-
for (const [key, next] of nextByKey) {
|
|
2156
|
-
if (prevByKey.has(key)) continue;
|
|
2157
|
-
const el = marksContainer.querySelector(
|
|
2158
|
-
`.oc-mark-rule[data-key="${key}"]`,
|
|
2159
|
-
) as SVGElement | null;
|
|
2160
|
-
if (!el) continue;
|
|
2161
|
-
tweens.push({
|
|
2162
|
-
tweenType: 'rule',
|
|
2163
|
-
kind: 'enter',
|
|
2164
|
-
el,
|
|
2165
|
-
fromX1: next.x1,
|
|
2166
|
-
fromY1: next.y1,
|
|
2167
|
-
fromX2: next.x2,
|
|
2168
|
-
fromY2: next.y2,
|
|
2169
|
-
toX1: next.x1,
|
|
2170
|
-
toY1: next.y1,
|
|
2171
|
-
toX2: next.x2,
|
|
2172
|
-
toY2: next.y2,
|
|
2173
|
-
fromOpacity: 0,
|
|
2174
|
-
toOpacity: 1,
|
|
2175
|
-
});
|
|
2176
|
-
}
|
|
2177
|
-
|
|
2178
|
-
// Exiting
|
|
2179
|
-
for (const [key, prev] of prevByKey) {
|
|
2180
|
-
if (nextByKey.has(key)) continue;
|
|
2181
|
-
const ghost = renderSingleMark(prev, 0);
|
|
2182
|
-
if (!ghost) continue;
|
|
2183
|
-
ghost.classList.add('oc-ghost');
|
|
2184
|
-
ghost.setAttribute('aria-hidden', 'true');
|
|
2185
|
-
ghost.setAttribute('pointer-events', 'none');
|
|
2186
|
-
ghost.removeAttribute('data-key');
|
|
2187
|
-
marksContainer.appendChild(ghost);
|
|
2188
|
-
ghosts.push(ghost);
|
|
2189
|
-
tweens.push({
|
|
2190
|
-
tweenType: 'rule',
|
|
2191
|
-
kind: 'exit',
|
|
2192
|
-
el: ghost,
|
|
2193
|
-
fromX1: prev.x1,
|
|
2194
|
-
fromY1: prev.y1,
|
|
2195
|
-
fromX2: prev.x2,
|
|
2196
|
-
fromY2: prev.y2,
|
|
2197
|
-
toX1: prev.x1,
|
|
2198
|
-
toY1: prev.y1,
|
|
2199
|
-
toX2: prev.x2,
|
|
2200
|
-
toY2: prev.y2,
|
|
2201
|
-
ghost,
|
|
2202
|
-
fromOpacity: 1,
|
|
2203
|
-
toOpacity: 0,
|
|
2204
|
-
});
|
|
2205
|
-
}
|
|
2206
|
-
}
|
|
2207
|
-
|
|
2208
|
-
// ---------------------------------------------------------------------------
|
|
2209
|
-
// Tick tween building
|
|
2210
|
-
// ---------------------------------------------------------------------------
|
|
2211
|
-
|
|
2212
|
-
function buildTickTweens(
|
|
2213
|
-
prevLayout: ChartLayout,
|
|
2214
|
-
nextLayout: ChartLayout,
|
|
2215
|
-
marksContainer: SVGElement,
|
|
2216
|
-
tweens: Tween[],
|
|
2217
|
-
ghosts: SVGElement[],
|
|
2218
|
-
fromSnapshot?: GeometrySnapshot,
|
|
2219
|
-
): void {
|
|
2220
|
-
const prevTicks = prevLayout.marks.filter((m): m is TickMarkLayout => m.type === 'tick');
|
|
2221
|
-
const nextTicks = nextLayout.marks.filter((m): m is TickMarkLayout => m.type === 'tick');
|
|
2222
|
-
|
|
2223
|
-
const prevByKey = new Map<string, TickMarkLayout>();
|
|
2224
|
-
for (const m of prevTicks) {
|
|
2225
|
-
if (m.key) prevByKey.set(m.key, m);
|
|
2226
|
-
}
|
|
2227
|
-
const nextByKey = new Map<string, TickMarkLayout>();
|
|
2228
|
-
for (const m of nextTicks) {
|
|
2229
|
-
if (m.key) nextByKey.set(m.key, m);
|
|
2230
|
-
}
|
|
2231
|
-
|
|
2232
|
-
function tickEndpoints(t: TickMarkLayout) {
|
|
2233
|
-
const half = t.length / 2;
|
|
2234
|
-
if (t.orient === 'vertical') {
|
|
2235
|
-
return { x1: t.x, y1: t.y - half, x2: t.x, y2: t.y + half };
|
|
2236
|
-
}
|
|
2237
|
-
return { x1: t.x - half, y1: t.y, x2: t.x + half, y2: t.y };
|
|
2238
|
-
}
|
|
2239
|
-
|
|
2240
|
-
// Updated
|
|
2241
|
-
for (const [key, next] of nextByKey) {
|
|
2242
|
-
const prev = prevByKey.get(key);
|
|
2243
|
-
if (!prev) continue;
|
|
2244
|
-
const el = marksContainer.querySelector(
|
|
2245
|
-
`.oc-mark-tick[data-key="${key}"]`,
|
|
2246
|
-
) as SVGElement | null;
|
|
2247
|
-
if (!el) continue;
|
|
2248
|
-
let pEnd = tickEndpoints(prev);
|
|
2249
|
-
const nEnd = tickEndpoints(next);
|
|
2250
|
-
const snap = fromSnapshot?.get(key);
|
|
2251
|
-
if (snap && snap.type === 'tick') {
|
|
2252
|
-
pEnd = { x1: snap.x1, y1: snap.y1, x2: snap.x2, y2: snap.y2 };
|
|
2253
|
-
}
|
|
2254
|
-
tweens.push({
|
|
2255
|
-
tweenType: 'tick',
|
|
2256
|
-
kind: 'update',
|
|
2257
|
-
el,
|
|
2258
|
-
fromX1: pEnd.x1,
|
|
2259
|
-
fromY1: pEnd.y1,
|
|
2260
|
-
fromX2: pEnd.x2,
|
|
2261
|
-
fromY2: pEnd.y2,
|
|
2262
|
-
toX1: nEnd.x1,
|
|
2263
|
-
toY1: nEnd.y1,
|
|
2264
|
-
toX2: nEnd.x2,
|
|
2265
|
-
toY2: nEnd.y2,
|
|
2266
|
-
});
|
|
2267
|
-
}
|
|
2268
|
-
|
|
2269
|
-
// Entering
|
|
2270
|
-
for (const [key, next] of nextByKey) {
|
|
2271
|
-
if (prevByKey.has(key)) continue;
|
|
2272
|
-
const el = marksContainer.querySelector(
|
|
2273
|
-
`.oc-mark-tick[data-key="${key}"]`,
|
|
2274
|
-
) as SVGElement | null;
|
|
2275
|
-
if (!el) continue;
|
|
2276
|
-
const nEnd = tickEndpoints(next);
|
|
2277
|
-
tweens.push({
|
|
2278
|
-
tweenType: 'tick',
|
|
2279
|
-
kind: 'enter',
|
|
2280
|
-
el,
|
|
2281
|
-
fromX1: nEnd.x1,
|
|
2282
|
-
fromY1: nEnd.y1,
|
|
2283
|
-
fromX2: nEnd.x2,
|
|
2284
|
-
fromY2: nEnd.y2,
|
|
2285
|
-
toX1: nEnd.x1,
|
|
2286
|
-
toY1: nEnd.y1,
|
|
2287
|
-
toX2: nEnd.x2,
|
|
2288
|
-
toY2: nEnd.y2,
|
|
2289
|
-
fromOpacity: 0,
|
|
2290
|
-
toOpacity: 1,
|
|
2291
|
-
});
|
|
2292
|
-
}
|
|
2293
|
-
|
|
2294
|
-
// Exiting
|
|
2295
|
-
for (const [key, prev] of prevByKey) {
|
|
2296
|
-
if (nextByKey.has(key)) continue;
|
|
2297
|
-
const ghost = renderSingleMark(prev, 0);
|
|
2298
|
-
if (!ghost) continue;
|
|
2299
|
-
ghost.classList.add('oc-ghost');
|
|
2300
|
-
ghost.setAttribute('aria-hidden', 'true');
|
|
2301
|
-
ghost.setAttribute('pointer-events', 'none');
|
|
2302
|
-
ghost.removeAttribute('data-key');
|
|
2303
|
-
marksContainer.appendChild(ghost);
|
|
2304
|
-
ghosts.push(ghost);
|
|
2305
|
-
const pEnd = tickEndpoints(prev);
|
|
2306
|
-
tweens.push({
|
|
2307
|
-
tweenType: 'tick',
|
|
2308
|
-
kind: 'exit',
|
|
2309
|
-
el: ghost,
|
|
2310
|
-
fromX1: pEnd.x1,
|
|
2311
|
-
fromY1: pEnd.y1,
|
|
2312
|
-
fromX2: pEnd.x2,
|
|
2313
|
-
fromY2: pEnd.y2,
|
|
2314
|
-
toX1: pEnd.x1,
|
|
2315
|
-
toY1: pEnd.y1,
|
|
2316
|
-
toX2: pEnd.x2,
|
|
2317
|
-
toY2: pEnd.y2,
|
|
2318
|
-
ghost,
|
|
2319
|
-
fromOpacity: 1,
|
|
2320
|
-
toOpacity: 0,
|
|
2321
|
-
});
|
|
2322
|
-
}
|
|
2323
|
-
}
|
|
2324
|
-
|
|
2325
|
-
// ---------------------------------------------------------------------------
|
|
2326
|
-
// Text mark tween building
|
|
2327
|
-
// ---------------------------------------------------------------------------
|
|
2328
|
-
|
|
2329
|
-
function buildTextMarkTweens(
|
|
2330
|
-
prevLayout: ChartLayout,
|
|
2331
|
-
nextLayout: ChartLayout,
|
|
2332
|
-
marksContainer: SVGElement,
|
|
2333
|
-
tweens: Tween[],
|
|
2334
|
-
ghosts: SVGElement[],
|
|
2335
|
-
fromSnapshot?: GeometrySnapshot,
|
|
2336
|
-
): void {
|
|
2337
|
-
const prevTexts = prevLayout.marks.filter((m): m is TextMarkLayout => m.type === 'textMark');
|
|
2338
|
-
const nextTexts = nextLayout.marks.filter((m): m is TextMarkLayout => m.type === 'textMark');
|
|
2339
|
-
|
|
2340
|
-
const prevByKey = new Map<string, TextMarkLayout>();
|
|
2341
|
-
for (const m of prevTexts) {
|
|
2342
|
-
if (m.key) prevByKey.set(m.key, m);
|
|
2343
|
-
}
|
|
2344
|
-
const nextByKey = new Map<string, TextMarkLayout>();
|
|
2345
|
-
for (const m of nextTexts) {
|
|
2346
|
-
if (m.key) nextByKey.set(m.key, m);
|
|
2347
|
-
}
|
|
2348
|
-
|
|
2349
|
-
// Updated
|
|
2350
|
-
for (const [key, next] of nextByKey) {
|
|
2351
|
-
const prev = prevByKey.get(key);
|
|
2352
|
-
if (!prev) continue;
|
|
2353
|
-
const el = marksContainer.querySelector(
|
|
2354
|
-
`.oc-mark-text[data-key="${key}"]`,
|
|
2355
|
-
) as SVGElement | null;
|
|
2356
|
-
if (!el) continue;
|
|
2357
|
-
|
|
2358
|
-
let fromX = prev.x;
|
|
2359
|
-
let fromY = prev.y;
|
|
2360
|
-
const snap = fromSnapshot?.get(key);
|
|
2361
|
-
if (snap && snap.type === 'textMark') {
|
|
2362
|
-
fromX = snap.x;
|
|
2363
|
-
fromY = snap.y;
|
|
2364
|
-
}
|
|
2365
|
-
|
|
2366
|
-
tweens.push({
|
|
2367
|
-
tweenType: 'textMark',
|
|
2368
|
-
kind: 'update',
|
|
2369
|
-
el,
|
|
2370
|
-
fromX,
|
|
2371
|
-
fromY,
|
|
2372
|
-
toX: next.x,
|
|
2373
|
-
toY: next.y,
|
|
2374
|
-
});
|
|
2375
|
-
}
|
|
2376
|
-
|
|
2377
|
-
// Entering
|
|
2378
|
-
for (const [key, next] of nextByKey) {
|
|
2379
|
-
if (prevByKey.has(key)) continue;
|
|
2380
|
-
const el = marksContainer.querySelector(
|
|
2381
|
-
`.oc-mark-text[data-key="${key}"]`,
|
|
2382
|
-
) as SVGElement | null;
|
|
2383
|
-
if (!el) continue;
|
|
2384
|
-
tweens.push({
|
|
2385
|
-
tweenType: 'textMark',
|
|
2386
|
-
kind: 'enter',
|
|
2387
|
-
el,
|
|
2388
|
-
fromX: next.x,
|
|
2389
|
-
fromY: next.y,
|
|
2390
|
-
toX: next.x,
|
|
2391
|
-
toY: next.y,
|
|
2392
|
-
fromOpacity: 0,
|
|
2393
|
-
toOpacity: 1,
|
|
2394
|
-
});
|
|
2395
|
-
}
|
|
2396
|
-
|
|
2397
|
-
// Exiting
|
|
2398
|
-
for (const [key, prev] of prevByKey) {
|
|
2399
|
-
if (nextByKey.has(key)) continue;
|
|
2400
|
-
const ghost = renderSingleMark(prev, 0);
|
|
2401
|
-
if (!ghost) continue;
|
|
2402
|
-
ghost.classList.add('oc-ghost');
|
|
2403
|
-
ghost.setAttribute('aria-hidden', 'true');
|
|
2404
|
-
ghost.setAttribute('pointer-events', 'none');
|
|
2405
|
-
ghost.removeAttribute('data-key');
|
|
2406
|
-
marksContainer.appendChild(ghost);
|
|
2407
|
-
ghosts.push(ghost);
|
|
2408
|
-
tweens.push({
|
|
2409
|
-
tweenType: 'textMark',
|
|
2410
|
-
kind: 'exit',
|
|
2411
|
-
el: ghost,
|
|
2412
|
-
fromX: prev.x,
|
|
2413
|
-
fromY: prev.y,
|
|
2414
|
-
toX: prev.x,
|
|
2415
|
-
toY: prev.y,
|
|
2416
|
-
ghost,
|
|
2417
|
-
fromOpacity: 1,
|
|
2418
|
-
toOpacity: 0,
|
|
2419
|
-
});
|
|
2420
|
-
}
|
|
2421
|
-
}
|
|
2422
|
-
|
|
2423
|
-
// ---------------------------------------------------------------------------
|
|
2424
|
-
// Color tween building
|
|
2425
|
-
// ---------------------------------------------------------------------------
|
|
2426
|
-
|
|
2427
|
-
/**
|
|
2428
|
-
* The child element a mark's `fill` or `stroke` is actually written to.
|
|
2429
|
-
*
|
|
2430
|
-
* Resolved per attribute, not once per mark: an area is two stacked paths, and
|
|
2431
|
-
* they do not carry the same channels. `renderAreaMark` appends the closed fill
|
|
2432
|
-
* shape first (`stroke: 'none'`) and the `.oc-area-top` line second -- that
|
|
2433
|
-
* second path is the only thing carrying the stroke, and it traces the data
|
|
2434
|
-
* points alone rather than the baseline. A blanket `querySelector` returns the
|
|
2435
|
-
* *first* match, so writing a stroke through it would paint an outline around
|
|
2436
|
-
* the whole closed area (baseline included) and `snapTweenToFinal` would leave
|
|
2437
|
-
* it there for good.
|
|
2438
|
-
*/
|
|
2439
|
-
function markShapeElement(el: SVGElement, attr: 'fill' | 'stroke'): SVGElement {
|
|
2440
|
-
if (el.tagName === 'circle' || el.tagName === 'rect' || el.tagName === 'path') return el;
|
|
2441
|
-
if (attr === 'stroke') {
|
|
2442
|
-
const areaTop = el.querySelector('.oc-area-top') as SVGElement | null;
|
|
2443
|
-
if (areaTop) return areaTop;
|
|
2444
|
-
}
|
|
2445
|
-
return (el.querySelector('rect, path, circle, line') as SVGElement | null) ?? el;
|
|
2446
|
-
}
|
|
2447
|
-
|
|
2448
|
-
/** Gradient fills are `url(#id)` refs and cannot be interpolated numerically. */
|
|
2449
|
-
function isInterpolableColor(v: unknown): v is string {
|
|
2450
|
-
return typeof v === 'string' && v !== '' && v !== 'none' && !v.startsWith('url(');
|
|
2451
|
-
}
|
|
2452
|
-
|
|
2453
|
-
/**
|
|
2454
|
-
* Build fill/stroke tweens for marks present in both layouts whose color moved.
|
|
2455
|
-
*
|
|
2456
|
-
* Mark-type agnostic on purpose: every mark carries `fill`/`stroke` resolved by
|
|
2457
|
-
* the compiler, so matching on `key` and diffing the two strings covers rects,
|
|
2458
|
-
* lines, areas and points in one pass. Marks whose color did not change emit no
|
|
2459
|
-
* tween, so the common data-update case costs nothing.
|
|
2460
|
-
*/
|
|
2461
|
-
function buildColorTweens(
|
|
2462
|
-
prevLayout: ChartLayout,
|
|
2463
|
-
nextLayout: ChartLayout,
|
|
2464
|
-
marksContainer: SVGElement,
|
|
2465
|
-
tweens: Tween[],
|
|
2466
|
-
): void {
|
|
2467
|
-
type Colored = { key?: string; fill?: unknown; stroke?: unknown };
|
|
2468
|
-
const prevByKey = new Map<string, Colored>();
|
|
2469
|
-
for (const m of prevLayout.marks as Colored[]) {
|
|
2470
|
-
if (m.key) prevByKey.set(m.key, m);
|
|
2471
|
-
}
|
|
2472
|
-
|
|
2473
|
-
for (const next of nextLayout.marks as Colored[]) {
|
|
2474
|
-
if (!next.key) continue;
|
|
2475
|
-
const prev = prevByKey.get(next.key);
|
|
2476
|
-
if (!prev) continue;
|
|
2477
|
-
|
|
2478
|
-
const group = marksContainer.querySelector(`[data-key="${next.key}"]`) as SVGElement | null;
|
|
2479
|
-
if (!group) continue;
|
|
2480
|
-
|
|
2481
|
-
for (const attr of ['fill', 'stroke'] as const) {
|
|
2482
|
-
const from = prev[attr];
|
|
2483
|
-
const to = next[attr];
|
|
2484
|
-
if (from === to) continue;
|
|
2485
|
-
if (!isInterpolableColor(from) || !isInterpolableColor(to)) continue;
|
|
2486
|
-
tweens.push({
|
|
2487
|
-
tweenType: 'color',
|
|
2488
|
-
kind: 'update',
|
|
2489
|
-
el: markShapeElement(group, attr),
|
|
2490
|
-
attr,
|
|
2491
|
-
from,
|
|
2492
|
-
to,
|
|
2493
|
-
});
|
|
2494
|
-
}
|
|
2495
|
-
}
|
|
2496
|
-
}
|
|
2497
|
-
|
|
2498
|
-
// ---------------------------------------------------------------------------
|
|
2499
|
-
// Annotation tween building
|
|
2500
|
-
// ---------------------------------------------------------------------------
|
|
2501
|
-
|
|
2502
|
-
/**
|
|
2503
|
-
* Build keyed enter/update/exit tweens for annotations.
|
|
2504
|
-
*
|
|
2505
|
-
* Follows the `buildAxisTweens` pattern rather than the mark builders': the
|
|
2506
|
-
* annotation group is a SIBLING of the clipped marks group, so it must be
|
|
2507
|
-
* queried from the `svg` root. A `marksContainer.querySelector` would never
|
|
2508
|
-
* find it.
|
|
2509
|
-
*
|
|
2510
|
-
* Surviving annotations tween from their old position to their new one via a
|
|
2511
|
-
* transform offset (the alternative -- re-resolving label x/y, connector path,
|
|
2512
|
-
* dot, subtitle and background rect individually -- is a lot of surface for no
|
|
2513
|
-
* extra fidelity). Entering ones fade in. See the note at the bottom for why
|
|
2514
|
-
* exiting ones do not fade out.
|
|
2515
|
-
*/
|
|
2516
|
-
function buildAnnotationTweens(
|
|
2517
|
-
svg: SVGSVGElement,
|
|
2518
|
-
prevLayout: ChartLayout,
|
|
2519
|
-
nextLayout: ChartLayout,
|
|
2520
|
-
tweens: Tween[],
|
|
2521
|
-
): void {
|
|
2522
|
-
const prev = prevLayout.annotations ?? [];
|
|
2523
|
-
const next = nextLayout.annotations ?? [];
|
|
2524
|
-
if (prev.length === 0 && next.length === 0) return;
|
|
2525
|
-
|
|
2526
|
-
const container = svg.querySelector('.oc-annotations');
|
|
2527
|
-
if (!container) return;
|
|
2528
|
-
|
|
2529
|
-
const prevByKey = new Map(keyAnnotations(prev).map((k, i) => [k, prev[i]]));
|
|
2530
|
-
const nextKeys = keyAnnotations(next);
|
|
2531
|
-
|
|
2532
|
-
// The rendered DOM matches nextLayout, so index i is nextKeys[i].
|
|
2533
|
-
const els = container.querySelectorAll('.oc-annotation');
|
|
2534
|
-
|
|
2535
|
-
for (let i = 0; i < next.length; i++) {
|
|
2536
|
-
const el = els[i] as SVGElement | undefined;
|
|
2537
|
-
if (!el) continue;
|
|
2538
|
-
const before = prevByKey.get(nextKeys[i]);
|
|
2539
|
-
|
|
2540
|
-
if (!before) {
|
|
2541
|
-
// Entering: fade in on the annotation cadence.
|
|
2542
|
-
tweens.push({
|
|
2543
|
-
tweenType: 'annotation',
|
|
2544
|
-
kind: 'enter',
|
|
2545
|
-
el,
|
|
2546
|
-
fromDx: 0,
|
|
2547
|
-
fromDy: 0,
|
|
2548
|
-
fromOpacity: 0,
|
|
2549
|
-
toOpacity: 1,
|
|
2550
|
-
});
|
|
2551
|
-
continue;
|
|
2552
|
-
}
|
|
2553
|
-
|
|
2554
|
-
// Surviving: hold at full opacity, slide from the old position if it moved.
|
|
2555
|
-
const dx = (before.label?.x ?? 0) - (next[i].label?.x ?? 0);
|
|
2556
|
-
const dy = (before.label?.y ?? 0) - (next[i].label?.y ?? 0);
|
|
2557
|
-
if (dx === 0 && dy === 0) continue;
|
|
2558
|
-
tweens.push({
|
|
2559
|
-
tweenType: 'annotation',
|
|
2560
|
-
kind: 'update',
|
|
2561
|
-
el,
|
|
2562
|
-
fromDx: dx,
|
|
2563
|
-
fromDy: dy,
|
|
2564
|
-
});
|
|
2565
|
-
}
|
|
2566
|
-
|
|
2567
|
-
// Exiting annotations are NOT ghosted. render() has already torn down the old
|
|
2568
|
-
// SVG, so the removed node is gone and there is nothing left to clone -- the
|
|
2569
|
-
// elements still in the DOM belong to nextLayout, and cloning one by the old
|
|
2570
|
-
// index would ghost an annotation that actually survived. Building a faithful
|
|
2571
|
-
// ghost would mean re-rendering the annotation from prevLayout (the marks
|
|
2572
|
-
// path can do this via `renderSingleMark`; annotations have no such entry
|
|
2573
|
-
// point that takes a single ResolvedAnnotation).
|
|
2574
|
-
//
|
|
2575
|
-
// So a removed annotation disappears immediately. That matches the behavior
|
|
2576
|
-
// before this change and is not what the blink bug was about; adding a real
|
|
2577
|
-
// exit fade means exposing a single-annotation renderer first.
|
|
2578
|
-
}
|
|
2579
|
-
|
|
2580
|
-
// ---------------------------------------------------------------------------
|
|
2581
|
-
// Axis tick and gridline tween building
|
|
2582
|
-
// ---------------------------------------------------------------------------
|
|
2583
|
-
|
|
2584
|
-
/**
|
|
2585
|
-
* Build tweens for axis tick labels and gridlines across both x and y axes.
|
|
2586
|
-
* Matches ticks by `data-tick-key` (stamped at render time).
|
|
2587
|
-
*/
|
|
2588
|
-
function buildAxisTweens(
|
|
2589
|
-
svg: SVGSVGElement,
|
|
2590
|
-
prevLayout: ChartLayout,
|
|
2591
|
-
nextLayout: ChartLayout,
|
|
2592
|
-
tweens: Tween[],
|
|
2593
|
-
ghosts: SVGElement[],
|
|
2594
|
-
): void {
|
|
2595
|
-
// Process each axis orientation
|
|
2596
|
-
buildSingleAxisTweens(svg, prevLayout.axes.x, nextLayout.axes.x, 'x', nextLayout, tweens, ghosts);
|
|
2597
|
-
buildSingleAxisTweens(svg, prevLayout.axes.y, nextLayout.axes.y, 'y', nextLayout, tweens, ghosts);
|
|
2598
|
-
}
|
|
2599
|
-
|
|
2600
|
-
function buildSingleAxisTweens(
|
|
2601
|
-
svg: SVGSVGElement,
|
|
2602
|
-
prevAxis: AxisLayout | undefined,
|
|
2603
|
-
nextAxis: AxisLayout | undefined,
|
|
2604
|
-
orientation: 'x' | 'y',
|
|
2605
|
-
layout: ChartLayout,
|
|
2606
|
-
tweens: Tween[],
|
|
2607
|
-
ghosts: SVGElement[],
|
|
2608
|
-
): void {
|
|
2609
|
-
if (!prevAxis || !nextAxis) return;
|
|
2610
|
-
|
|
2611
|
-
const axisClass = `oc-axis-${orientation}`;
|
|
2612
|
-
const axisGroup = svg.querySelector(`.${axisClass}`) as SVGElement | null;
|
|
2613
|
-
if (!axisGroup) return;
|
|
2614
|
-
|
|
2615
|
-
const isRotated = !!(nextAxis.tickAngle && Math.abs(nextAxis.tickAngle) > 10);
|
|
2616
|
-
|
|
2617
|
-
// Build tick value -> position maps from layouts (NOT from DOM)
|
|
2618
|
-
const prevTickMap = new Map<string, number>();
|
|
2619
|
-
for (const tick of prevAxis.ticks) {
|
|
2620
|
-
prevTickMap.set(serializeKeyValue(tick.value), tick.position);
|
|
2621
|
-
}
|
|
2622
|
-
const nextTickMap = new Map<string, number>();
|
|
2623
|
-
for (const tick of nextAxis.ticks) {
|
|
2624
|
-
nextTickMap.set(serializeKeyValue(tick.value), tick.position);
|
|
2625
|
-
}
|
|
2626
|
-
|
|
2627
|
-
// Position attribute: 'x' for x-axis tick labels, 'y' for y-axis tick labels
|
|
2628
|
-
const posAttr = orientation === 'x' ? 'x' : 'y';
|
|
2629
|
-
|
|
2630
|
-
// Tick labels
|
|
2631
|
-
const tickLabels = axisGroup.querySelectorAll('.oc-axis-tick[data-tick-key]');
|
|
2632
|
-
const matchedNextKeys = new Set<string>();
|
|
2633
|
-
|
|
2634
|
-
for (const labelEl of tickLabels) {
|
|
2635
|
-
const key = labelEl.getAttribute('data-tick-key');
|
|
2636
|
-
if (!key) continue;
|
|
2637
|
-
|
|
2638
|
-
const nextPos = nextTickMap.get(key);
|
|
2639
|
-
const prevPos = prevTickMap.get(key);
|
|
2640
|
-
|
|
2641
|
-
if (prevPos !== undefined && nextPos !== undefined) {
|
|
2642
|
-
// Updated tick: slide to new position
|
|
2643
|
-
matchedNextKeys.add(key);
|
|
2644
|
-
tweens.push({
|
|
2645
|
-
tweenType: 'axisTick',
|
|
2646
|
-
kind: 'update',
|
|
2647
|
-
el: labelEl as SVGElement,
|
|
2648
|
-
posAttr,
|
|
2649
|
-
fromPos: prevPos,
|
|
2650
|
-
toPos: nextPos,
|
|
2651
|
-
crossfade: isRotated,
|
|
2652
|
-
});
|
|
2653
|
-
} else if (prevPos === undefined && nextPos !== undefined) {
|
|
2654
|
-
// Entering tick: fade in
|
|
2655
|
-
matchedNextKeys.add(key);
|
|
2656
|
-
tweens.push({
|
|
2657
|
-
tweenType: 'axisTick',
|
|
2658
|
-
kind: 'enter',
|
|
2659
|
-
el: labelEl as SVGElement,
|
|
2660
|
-
posAttr,
|
|
2661
|
-
fromPos: nextPos,
|
|
2662
|
-
toPos: nextPos,
|
|
2663
|
-
crossfade: false,
|
|
2664
|
-
fromOpacity: 0,
|
|
2665
|
-
toOpacity: 1,
|
|
2666
|
-
});
|
|
2667
|
-
}
|
|
2668
|
-
// Exiting ticks are handled below (they won't be in the new SVG's DOM,
|
|
2669
|
-
// so we clone from the prev state)
|
|
2670
|
-
}
|
|
2671
|
-
|
|
2672
|
-
// Exiting tick labels: create ghost elements
|
|
2673
|
-
for (const [key, prevPos] of prevTickMap) {
|
|
2674
|
-
if (nextTickMap.has(key)) continue;
|
|
2675
|
-
// Find the tick label in the rendered SVG by data-tick-key
|
|
2676
|
-
// It won't be there since the SVG was already re-rendered from nextLayout,
|
|
2677
|
-
// so we need to create a ghost text element
|
|
2678
|
-
const prevTick = prevAxis.ticks.find((t) => serializeKeyValue(t.value) === key);
|
|
2679
|
-
if (!prevTick) continue;
|
|
2680
|
-
|
|
2681
|
-
const ghost = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
2682
|
-
ghost.setAttribute('class', 'oc-axis-tick oc-ghost');
|
|
2683
|
-
ghost.setAttribute('aria-hidden', 'true');
|
|
2684
|
-
ghost.setAttribute('pointer-events', 'none');
|
|
2685
|
-
ghost.textContent = prevTick.label;
|
|
2686
|
-
// Copy positioning from prevAxis ticks
|
|
2687
|
-
const area = layout.area;
|
|
2688
|
-
if (orientation === 'x') {
|
|
2689
|
-
ghost.setAttribute('x', String(prevPos));
|
|
2690
|
-
const xLabelPad = nextAxis.labelPadding ?? layout.theme.spacing.xAxisLabelPadding;
|
|
2691
|
-
const fontSize = nextAxis.tickLabelStyle.fontSize;
|
|
2692
|
-
ghost.setAttribute('y', String(area.y + area.height + xLabelPad + fontSize * 0.8));
|
|
2693
|
-
ghost.setAttribute('text-anchor', 'middle');
|
|
2694
|
-
} else {
|
|
2695
|
-
const TICK_LABEL_OFFSET = 8;
|
|
2696
|
-
ghost.setAttribute('x', String(area.x - TICK_LABEL_OFFSET));
|
|
2697
|
-
ghost.setAttribute('y', String(prevPos));
|
|
2698
|
-
ghost.setAttribute('text-anchor', 'end');
|
|
2699
|
-
ghost.setAttribute('dominant-baseline', 'central');
|
|
2700
|
-
}
|
|
2701
|
-
axisGroup.appendChild(ghost);
|
|
2702
|
-
ghosts.push(ghost);
|
|
2703
|
-
|
|
2704
|
-
tweens.push({
|
|
2705
|
-
tweenType: 'axisTick',
|
|
2706
|
-
kind: 'exit',
|
|
2707
|
-
el: ghost,
|
|
2708
|
-
posAttr,
|
|
2709
|
-
fromPos: prevPos,
|
|
2710
|
-
toPos: prevPos,
|
|
2711
|
-
crossfade: false,
|
|
2712
|
-
ghost,
|
|
2713
|
-
fromOpacity: 1,
|
|
2714
|
-
toOpacity: 0,
|
|
2715
|
-
});
|
|
2716
|
-
}
|
|
2717
|
-
|
|
2718
|
-
// Gridlines
|
|
2719
|
-
const prevGridMap = new Map<string, number>();
|
|
2720
|
-
for (const gridline of prevAxis.gridlines) {
|
|
2721
|
-
// Gridlines share positions with their axis ticks
|
|
2722
|
-
prevGridMap.set(String(gridline.position), gridline.position);
|
|
2723
|
-
}
|
|
2724
|
-
// Build a value->position map for gridlines by matching tick values
|
|
2725
|
-
const prevGridByValue = new Map<string, number>();
|
|
2726
|
-
for (const tick of prevAxis.ticks) {
|
|
2727
|
-
const key = serializeKeyValue(tick.value);
|
|
2728
|
-
// Find matching gridline at same position
|
|
2729
|
-
const matchingGridline = prevAxis.gridlines.find((g) => g.position === tick.position);
|
|
2730
|
-
if (matchingGridline) {
|
|
2731
|
-
prevGridByValue.set(key, matchingGridline.position);
|
|
2732
|
-
}
|
|
2733
|
-
}
|
|
2734
|
-
const nextGridByValue = new Map<string, number>();
|
|
2735
|
-
for (const tick of nextAxis.ticks) {
|
|
2736
|
-
const key = serializeKeyValue(tick.value);
|
|
2737
|
-
const matchingGridline = nextAxis.gridlines.find((g) => g.position === tick.position);
|
|
2738
|
-
if (matchingGridline) {
|
|
2739
|
-
nextGridByValue.set(key, matchingGridline.position);
|
|
2740
|
-
}
|
|
2741
|
-
}
|
|
2742
|
-
|
|
2743
|
-
const gridlines = axisGroup.querySelectorAll('.oc-gridline[data-tick-key]');
|
|
2744
|
-
const matchedGridKeys = new Set<string>();
|
|
2745
|
-
|
|
2746
|
-
for (const glEl of gridlines) {
|
|
2747
|
-
const key = glEl.getAttribute('data-tick-key');
|
|
2748
|
-
if (!key) continue;
|
|
2749
|
-
|
|
2750
|
-
const nextPos = nextGridByValue.get(key);
|
|
2751
|
-
const prevPos = prevGridByValue.get(key);
|
|
2752
|
-
|
|
2753
|
-
if (prevPos !== undefined && nextPos !== undefined) {
|
|
2754
|
-
matchedGridKeys.add(key);
|
|
2755
|
-
tweens.push({
|
|
2756
|
-
tweenType: 'gridline',
|
|
2757
|
-
kind: 'update',
|
|
2758
|
-
el: glEl as SVGElement,
|
|
2759
|
-
orientation,
|
|
2760
|
-
fromPos: prevPos,
|
|
2761
|
-
toPos: nextPos,
|
|
2762
|
-
});
|
|
2763
|
-
} else if (prevPos === undefined && nextPos !== undefined) {
|
|
2764
|
-
matchedGridKeys.add(key);
|
|
2765
|
-
tweens.push({
|
|
2766
|
-
tweenType: 'gridline',
|
|
2767
|
-
kind: 'enter',
|
|
2768
|
-
el: glEl as SVGElement,
|
|
2769
|
-
orientation,
|
|
2770
|
-
fromPos: nextPos,
|
|
2771
|
-
toPos: nextPos,
|
|
2772
|
-
fromOpacity: 0,
|
|
2773
|
-
toOpacity: 1,
|
|
2774
|
-
});
|
|
2775
|
-
}
|
|
2776
|
-
}
|
|
2777
|
-
|
|
2778
|
-
// Exiting gridlines
|
|
2779
|
-
for (const [key, prevPos] of prevGridByValue) {
|
|
2780
|
-
if (nextGridByValue.has(key)) continue;
|
|
2781
|
-
|
|
2782
|
-
const ghost = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
|
2783
|
-
ghost.setAttribute('class', 'oc-gridline oc-ghost');
|
|
2784
|
-
ghost.setAttribute('aria-hidden', 'true');
|
|
2785
|
-
ghost.setAttribute('pointer-events', 'none');
|
|
2786
|
-
if (orientation === 'y') {
|
|
2787
|
-
// Horizontal gridline at y=prevPos
|
|
2788
|
-
const area = layout.area;
|
|
2789
|
-
ghost.setAttribute('x1', String(area.x));
|
|
2790
|
-
ghost.setAttribute('y1', String(prevPos));
|
|
2791
|
-
ghost.setAttribute('x2', String(area.x + area.width));
|
|
2792
|
-
ghost.setAttribute('y2', String(prevPos));
|
|
2793
|
-
} else {
|
|
2794
|
-
// Vertical gridline at x=prevPos
|
|
2795
|
-
const area = layout.area;
|
|
2796
|
-
ghost.setAttribute('x1', String(prevPos));
|
|
2797
|
-
ghost.setAttribute('y1', String(area.y));
|
|
2798
|
-
ghost.setAttribute('x2', String(prevPos));
|
|
2799
|
-
ghost.setAttribute('y2', String(area.y + area.height));
|
|
2800
|
-
}
|
|
2801
|
-
axisGroup.appendChild(ghost);
|
|
2802
|
-
ghosts.push(ghost);
|
|
2803
|
-
|
|
2804
|
-
tweens.push({
|
|
2805
|
-
tweenType: 'gridline',
|
|
2806
|
-
kind: 'exit',
|
|
2807
|
-
el: ghost,
|
|
2808
|
-
orientation,
|
|
2809
|
-
fromPos: prevPos,
|
|
2810
|
-
toPos: prevPos,
|
|
2811
|
-
ghost,
|
|
2812
|
-
fromOpacity: 1,
|
|
2813
|
-
toOpacity: 0,
|
|
2814
|
-
});
|
|
2815
|
-
}
|
|
2816
|
-
}
|