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