@opendata-ai/openchart-vanilla 8.0.0-rc.2 → 8.0.0-rc.21

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