@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.
@@ -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
+ }