@opendata-ai/openchart-vanilla 7.7.0 → 7.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-PSMDJEXK.js → chunk-QRE4FQQM.js} +380 -14
- package/dist/chunk-QRE4FQQM.js.map +1 -0
- package/dist/index.d.ts +26 -19
- package/dist/index.js +1755 -378
- package/dist/index.js.map +1 -1
- package/dist/static.d.ts +2 -2
- package/dist/static.js +37 -27
- package/dist/static.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/animation.test.ts +1 -1
- package/src/__tests__/transition.test.ts +1592 -0
- package/src/barlist-mount.ts +1 -1
- package/src/barlist-renderer.ts +4 -4
- package/src/index.ts +2 -0
- package/src/mount.ts +64 -1
- package/src/renderers/axes.ts +18 -0
- package/src/renderers/marks.ts +21 -7
- package/src/sankey-mount.ts +2 -2
- package/src/sankey-renderer.ts +9 -8
- package/src/static.ts +42 -32
- package/src/svg-renderer.ts +8 -7
- package/src/table-mount.ts +1 -1
- package/src/table-renderer.ts +5 -5
- package/src/tilemap-mount.ts +1 -1
- package/src/tilemap-renderer.ts +8 -9
- package/src/transition.ts +2473 -0
- package/dist/chunk-PSMDJEXK.js.map +0 -1
package/src/barlist-mount.ts
CHANGED
|
@@ -248,7 +248,7 @@ export function createBarList(
|
|
|
248
248
|
}
|
|
249
249
|
}
|
|
250
250
|
|
|
251
|
-
if (currentLayout.animation?.
|
|
251
|
+
if (currentLayout.animation?.enter) {
|
|
252
252
|
animationCleanup = setupAnimationCleanup(newSvg, () => {
|
|
253
253
|
if (pendingResize && !destroyed) {
|
|
254
254
|
pendingResize = false;
|
package/src/barlist-renderer.ts
CHANGED
|
@@ -134,7 +134,7 @@ function renderRows(
|
|
|
134
134
|
rowGroup.setAttribute('aria-label', row.aria.label);
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
-
if (animation?.
|
|
137
|
+
if (animation?.enter) {
|
|
138
138
|
rowGroup.setAttribute('data-animation-index', String(row.animationIndex));
|
|
139
139
|
const style = (rowGroup as SVGElement & ElementCSSInlineStyle).style;
|
|
140
140
|
style.setProperty('--oc-mark-index', String(row.animationIndex));
|
|
@@ -236,7 +236,7 @@ export function renderBarListSVG(
|
|
|
236
236
|
opts?: { animate?: boolean },
|
|
237
237
|
): SVGSVGElement {
|
|
238
238
|
const { width, height, rows, a11y, watermark, animation } = layout;
|
|
239
|
-
const animate = opts?.animate && animation?.
|
|
239
|
+
const animate = opts?.animate && !!animation?.enter;
|
|
240
240
|
|
|
241
241
|
const svg = createSVGElement('svg') as SVGSVGElement;
|
|
242
242
|
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
|
|
@@ -251,8 +251,8 @@ export function renderBarListSVG(
|
|
|
251
251
|
const classes = animate ? 'oc-barlist oc-animate' : 'oc-barlist';
|
|
252
252
|
svg.setAttribute('class', classes);
|
|
253
253
|
|
|
254
|
-
if (animate && animation) {
|
|
255
|
-
svg.style.setProperty('--oc-animation-duration', `${animation.duration}ms`);
|
|
254
|
+
if (animate && animation?.enter) {
|
|
255
|
+
svg.style.setProperty('--oc-animation-duration', `${animation.enter.duration}ms`);
|
|
256
256
|
svg.style.setProperty('--oc-animation-stagger', '40ms');
|
|
257
257
|
}
|
|
258
258
|
|
package/src/index.ts
CHANGED
|
@@ -31,6 +31,8 @@ export { createGraph } from './graph-mount';
|
|
|
31
31
|
export type { ChartInstance, ExportOptions, MountOptions, UpdateOptions } from './mount';
|
|
32
32
|
// Main mount API
|
|
33
33
|
export { createChart } from './mount';
|
|
34
|
+
// Geometry helpers for mark path reconstruction
|
|
35
|
+
export { rectPathWithCorners } from './renderers/marks';
|
|
34
36
|
// Cell renderers
|
|
35
37
|
export {
|
|
36
38
|
renderBarCell,
|
package/src/mount.ts
CHANGED
|
@@ -59,6 +59,7 @@ import { renderChartSVG } from './svg-renderer';
|
|
|
59
59
|
import { createTextEditOverlay } from './text-edit-overlay';
|
|
60
60
|
import { stampThemeProperties } from './theme-tokens';
|
|
61
61
|
import { createTooltipManager, type TooltipManager } from './tooltip';
|
|
62
|
+
import { canTransition, type GeometrySnapshot, runTransition } from './transition';
|
|
62
63
|
|
|
63
64
|
// ---------------------------------------------------------------------------
|
|
64
65
|
// Types
|
|
@@ -193,6 +194,10 @@ export function createChart<TData extends DataRow = DataRow>(
|
|
|
193
194
|
let cleanupAnimations: (() => void) | null = null;
|
|
194
195
|
let pendingResize = false;
|
|
195
196
|
|
|
197
|
+
// Data-update transition state
|
|
198
|
+
let transitionHandle: import('./transition').TransitionHandle | null = null;
|
|
199
|
+
let transitionSnapshot: GeometrySnapshot | null = null;
|
|
200
|
+
|
|
196
201
|
// Set when webfonts have loaded and a recompile is owed to reflect final font
|
|
197
202
|
// metrics. The next render() that actually recompiles flips
|
|
198
203
|
// data-oc-fonts-state to 'ready' and clears this. Deferring the flip (rather
|
|
@@ -621,6 +626,12 @@ export function createChart<TData extends DataRow = DataRow>(
|
|
|
621
626
|
return;
|
|
622
627
|
}
|
|
623
628
|
|
|
629
|
+
// Cancel any in-progress data-update transition
|
|
630
|
+
if (transitionHandle) {
|
|
631
|
+
transitionHandle.cancel();
|
|
632
|
+
transitionHandle = null;
|
|
633
|
+
}
|
|
634
|
+
|
|
624
635
|
// Cancel any in-progress entrance animations before tearing down
|
|
625
636
|
if (cleanupAnimations) {
|
|
626
637
|
cleanupAnimations();
|
|
@@ -665,7 +676,7 @@ export function createChart<TData extends DataRow = DataRow>(
|
|
|
665
676
|
}
|
|
666
677
|
|
|
667
678
|
currentLayout = compile();
|
|
668
|
-
const shouldAnimate = isFirstRender && !!currentLayout.animation?.
|
|
679
|
+
const shouldAnimate = isFirstRender && !!currentLayout.animation?.enter;
|
|
669
680
|
const crosshair = !!currentLayout.crosshair;
|
|
670
681
|
svgElement = renderChartSVG(currentLayout, container, {
|
|
671
682
|
animate: shouldAnimate,
|
|
@@ -831,6 +842,19 @@ export function createChart<TData extends DataRow = DataRow>(
|
|
|
831
842
|
|
|
832
843
|
function update(newSpec: ChartSpec | GraphSpec, updateOpts?: UpdateOptions): void {
|
|
833
844
|
if (destroyed) return;
|
|
845
|
+
|
|
846
|
+
// Capture pre-update state for transition gating
|
|
847
|
+
const prevSpec = currentSpec;
|
|
848
|
+
const prevLayout = currentLayout;
|
|
849
|
+
const entranceWasRunning = cleanupAnimations != null;
|
|
850
|
+
|
|
851
|
+
// Snapshot in-flight transition geometry before render() cancels it.
|
|
852
|
+
// This enables retargeting: the next transition starts from the
|
|
853
|
+
// interrupted position instead of snapping to the previous final state.
|
|
854
|
+
if (transitionHandle?.running) {
|
|
855
|
+
transitionSnapshot = transitionHandle.snapshot();
|
|
856
|
+
}
|
|
857
|
+
|
|
834
858
|
currentSpec = newSpec;
|
|
835
859
|
// A new spec can change theme.fonts.family; rebuild the measurer so layout
|
|
836
860
|
// measures the font compile will actually render with.
|
|
@@ -845,6 +869,38 @@ export function createChart<TData extends DataRow = DataRow>(
|
|
|
845
869
|
selectedElement = updateOpts.selectedElement ?? null;
|
|
846
870
|
}
|
|
847
871
|
render();
|
|
872
|
+
|
|
873
|
+
// After render, check if we can run a smooth transition instead of the
|
|
874
|
+
// instant swap that render() already performed.
|
|
875
|
+
if (
|
|
876
|
+
svgElement &&
|
|
877
|
+
canTransition({
|
|
878
|
+
prevLayout,
|
|
879
|
+
nextLayout: currentLayout,
|
|
880
|
+
prevSpec,
|
|
881
|
+
nextSpec: newSpec,
|
|
882
|
+
isFirstRender: false,
|
|
883
|
+
entranceInFlight: entranceWasRunning,
|
|
884
|
+
})
|
|
885
|
+
) {
|
|
886
|
+
// Consume snapshot (if any) for retargeting, then clear
|
|
887
|
+
const snapshot = transitionSnapshot;
|
|
888
|
+
transitionSnapshot = null;
|
|
889
|
+
|
|
890
|
+
transitionHandle = runTransition({
|
|
891
|
+
svg: svgElement as SVGSVGElement,
|
|
892
|
+
prevLayout,
|
|
893
|
+
nextLayout: currentLayout,
|
|
894
|
+
animation: currentLayout.animation!,
|
|
895
|
+
fromSnapshot: snapshot ?? undefined,
|
|
896
|
+
onComplete: () => {
|
|
897
|
+
transitionHandle = null;
|
|
898
|
+
},
|
|
899
|
+
});
|
|
900
|
+
} else {
|
|
901
|
+
// No transition started; clear any stale snapshot
|
|
902
|
+
transitionSnapshot = null;
|
|
903
|
+
}
|
|
848
904
|
}
|
|
849
905
|
|
|
850
906
|
function resize(): void {
|
|
@@ -913,6 +969,13 @@ export function createChart<TData extends DataRow = DataRow>(
|
|
|
913
969
|
if (destroyed) return;
|
|
914
970
|
destroyed = true;
|
|
915
971
|
|
|
972
|
+
// Cancel any in-progress data-update transition
|
|
973
|
+
if (transitionHandle) {
|
|
974
|
+
transitionHandle.cancel();
|
|
975
|
+
transitionHandle = null;
|
|
976
|
+
}
|
|
977
|
+
transitionSnapshot = null;
|
|
978
|
+
|
|
916
979
|
if (cleanupAnimations) {
|
|
917
980
|
cleanupAnimations();
|
|
918
981
|
cleanupAnimations = null;
|
package/src/renderers/axes.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
TICK_LABEL_OFFSET,
|
|
11
11
|
textAscent,
|
|
12
12
|
} from '@opendata-ai/openchart-core';
|
|
13
|
+
import { serializeKeyValue } from '@opendata-ai/openchart-engine';
|
|
13
14
|
import { applyTextStyle, createSVGElement, setAttrs } from './svg-dom';
|
|
14
15
|
|
|
15
16
|
function appendCompoundLabel(
|
|
@@ -68,10 +69,14 @@ function renderAxis(
|
|
|
68
69
|
// was set to [chartArea.x, chartArea.x + chartArea.width] (and similarly for y).
|
|
69
70
|
// Don't add area.x/area.y again or you'll double-offset everything.
|
|
70
71
|
for (const tick of axis.ticks) {
|
|
72
|
+
// Stable key for data-update transitions
|
|
73
|
+
const tickKey = serializeKeyValue(tick.value);
|
|
74
|
+
|
|
71
75
|
if (orientation === 'x') {
|
|
72
76
|
// Label (no tick marks -- gridlines provide sufficient reference)
|
|
73
77
|
const label = createSVGElement('text');
|
|
74
78
|
label.setAttribute('class', 'oc-axis-tick');
|
|
79
|
+
label.setAttribute('data-tick-key', tickKey);
|
|
75
80
|
|
|
76
81
|
if (axis.tickAngle && Math.abs(axis.tickAngle) > 10) {
|
|
77
82
|
// Rotated labels: anchor at the rotation pivot point
|
|
@@ -107,6 +112,7 @@ function renderAxis(
|
|
|
107
112
|
// edge, no gutter reserved. The gridline itself is the visual axis.
|
|
108
113
|
const label = createSVGElement('text');
|
|
109
114
|
label.setAttribute('class', 'oc-axis-tick oc-axis-tick-inline');
|
|
115
|
+
label.setAttribute('data-tick-key', tickKey);
|
|
110
116
|
setAttrs(label, {
|
|
111
117
|
x: area.x,
|
|
112
118
|
y: tick.position - 6,
|
|
@@ -118,6 +124,7 @@ function renderAxis(
|
|
|
118
124
|
} else {
|
|
119
125
|
const label = createSVGElement('text');
|
|
120
126
|
label.setAttribute('class', 'oc-axis-tick');
|
|
127
|
+
label.setAttribute('data-tick-key', tickKey);
|
|
121
128
|
setAttrs(label, {
|
|
122
129
|
x: isRight ? area.x + area.width + TICK_LABEL_OFFSET : area.x - TICK_LABEL_OFFSET,
|
|
123
130
|
y: tick.position,
|
|
@@ -207,9 +214,20 @@ function renderAxis(
|
|
|
207
214
|
// Gridlines (positions are also absolute from the scales)
|
|
208
215
|
// Skip gridlines for right-side y-axis (left y-axis gridlines are sufficient)
|
|
209
216
|
if (!isRight) {
|
|
217
|
+
// Build position -> tick value map for keying gridlines
|
|
218
|
+
const posToTickKey = new Map<number, string>();
|
|
219
|
+
for (const tick of axis.ticks) {
|
|
220
|
+
posToTickKey.set(tick.position, serializeKeyValue(tick.value));
|
|
221
|
+
}
|
|
222
|
+
|
|
210
223
|
for (const gridline of axis.gridlines) {
|
|
211
224
|
const gl = createSVGElement('line');
|
|
212
225
|
gl.setAttribute('class', 'oc-gridline');
|
|
226
|
+
// Stamp data-tick-key for data-update transitions
|
|
227
|
+
const glKey = posToTickKey.get(gridline.position);
|
|
228
|
+
if (glKey) {
|
|
229
|
+
gl.setAttribute('data-tick-key', glKey);
|
|
230
|
+
}
|
|
213
231
|
if (orientation === 'y') {
|
|
214
232
|
setAttrs(gl, {
|
|
215
233
|
x1: area.x,
|
package/src/renderers/marks.ts
CHANGED
|
@@ -58,10 +58,14 @@ export function resetMarkRenderState(): void {
|
|
|
58
58
|
*/
|
|
59
59
|
function stampAnimationAttrs(
|
|
60
60
|
el: SVGElement,
|
|
61
|
-
mark: { animationIndex?: number },
|
|
61
|
+
mark: { animationIndex?: number; key?: string },
|
|
62
62
|
fallbackIndex: number,
|
|
63
63
|
): void {
|
|
64
|
-
|
|
64
|
+
// Stamp stable identity key for data-update transitions (always, not just enter)
|
|
65
|
+
if (mark.key) {
|
|
66
|
+
el.setAttribute('data-key', mark.key);
|
|
67
|
+
}
|
|
68
|
+
if (!currentAnimation?.enter) return;
|
|
65
69
|
const idx = mark.animationIndex ?? fallbackIndex;
|
|
66
70
|
el.setAttribute('data-animation-index', String(idx));
|
|
67
71
|
(el as SVGElement & ElementCSSInlineStyle).style.setProperty('--oc-mark-index', String(idx));
|
|
@@ -185,7 +189,7 @@ function renderAreaMark(mark: AreaMark, index: number): SVGElement {
|
|
|
185
189
|
* stack, right of a horizontal stack) should round so the seams between
|
|
186
190
|
* adjacent segments stay flush.
|
|
187
191
|
*/
|
|
188
|
-
function
|
|
192
|
+
export function rectPathWithCorners(
|
|
189
193
|
mark: RectMark,
|
|
190
194
|
sides: NonNullable<RectMark['cornerRadiusSides']>,
|
|
191
195
|
): string {
|
|
@@ -219,7 +223,7 @@ function renderRectMark(mark: RectMark, index: number): SVGElement {
|
|
|
219
223
|
g.setAttribute('class', 'oc-mark oc-mark-rect');
|
|
220
224
|
stampAnimationAttrs(g, mark, index);
|
|
221
225
|
// Use engine-provided orientation for animation direction
|
|
222
|
-
if (currentAnimation?.
|
|
226
|
+
if (currentAnimation?.enter && mark.orient === 'horizontal') {
|
|
223
227
|
g.setAttribute('data-orient', 'horizontal');
|
|
224
228
|
}
|
|
225
229
|
|
|
@@ -232,7 +236,7 @@ function renderRectMark(mark: RectMark, index: number): SVGElement {
|
|
|
232
236
|
!!sides && (!sides.tl || !sides.tr || !sides.br || !sides.bl) && !!mark.cornerRadius;
|
|
233
237
|
const shapeEl = partialCorners ? createSVGElement('path') : createSVGElement('rect');
|
|
234
238
|
if (partialCorners) {
|
|
235
|
-
shapeEl.setAttribute('d',
|
|
239
|
+
shapeEl.setAttribute('d', rectPathWithCorners(mark, sides));
|
|
236
240
|
} else {
|
|
237
241
|
setAttrs(shapeEl, {
|
|
238
242
|
x: mark.x,
|
|
@@ -407,6 +411,16 @@ registerMarkRenderer('textMark', renderTextMark as MarkRenderer<Mark>);
|
|
|
407
411
|
registerMarkRenderer('rule', renderRuleMark as MarkRenderer<Mark>);
|
|
408
412
|
registerMarkRenderer('tick', renderTickMark as MarkRenderer<Mark>);
|
|
409
413
|
|
|
414
|
+
/**
|
|
415
|
+
* Render a single mark to an SVG element without appending it anywhere.
|
|
416
|
+
* Used by the transition module to create ghost elements for exiting marks.
|
|
417
|
+
*/
|
|
418
|
+
export function renderSingleMark(mark: Mark, index: number): SVGElement | undefined {
|
|
419
|
+
const renderer = markRenderers[mark.type];
|
|
420
|
+
if (!renderer) return undefined;
|
|
421
|
+
return renderer(mark, index);
|
|
422
|
+
}
|
|
423
|
+
|
|
410
424
|
/** Extract series name from a mark for legend toggle matching. */
|
|
411
425
|
function getMarkSeries(mark: Mark): string | undefined {
|
|
412
426
|
// Line and area marks have an explicit seriesKey
|
|
@@ -459,7 +473,7 @@ export function renderMarks(parent: SVGElement, layout: ChartLayout): SVGElement
|
|
|
459
473
|
|
|
460
474
|
// For stacked segments, set stack position for sequential animation chaining.
|
|
461
475
|
// stackPos is computed by the engine on RectMark during compilation.
|
|
462
|
-
if (currentAnimation?.
|
|
476
|
+
if (currentAnimation?.enter && mark.type === 'rect') {
|
|
463
477
|
const rect = mark as RectMark;
|
|
464
478
|
if (rect.stackGroup && rect.stackPos !== undefined) {
|
|
465
479
|
el.setAttribute('data-stack-pos', String(rect.stackPos));
|
|
@@ -503,7 +517,7 @@ export function renderMarks(parent: SVGElement, layout: ChartLayout): SVGElement
|
|
|
503
517
|
|
|
504
518
|
// Stamp animation index so the CSS stagger delay works for overlay labels
|
|
505
519
|
// (they're not children of a .oc-mark-rect group that carries --oc-mark-index).
|
|
506
|
-
if (currentAnimation?.
|
|
520
|
+
if (currentAnimation?.enter) {
|
|
507
521
|
const idx = rect.animationIndex ?? i;
|
|
508
522
|
label.setAttribute('data-animation-index', String(idx));
|
|
509
523
|
(label as SVGElement & ElementCSSInlineStyle).style.setProperty(
|
package/src/sankey-mount.ts
CHANGED
|
@@ -399,7 +399,7 @@ export function createSankey(
|
|
|
399
399
|
currentLayout = compile();
|
|
400
400
|
|
|
401
401
|
// Determine if we should animate
|
|
402
|
-
const shouldAnimate = isFirstRender && currentLayout.animation?.
|
|
402
|
+
const shouldAnimate = isFirstRender && !!currentLayout.animation?.enter;
|
|
403
403
|
isFirstRender = false;
|
|
404
404
|
|
|
405
405
|
// Render
|
|
@@ -547,7 +547,7 @@ export function createSankey(
|
|
|
547
547
|
currentLayout = compile();
|
|
548
548
|
|
|
549
549
|
// Determine if we should animate
|
|
550
|
-
const shouldAnimate = currentLayout.animation?.
|
|
550
|
+
const shouldAnimate = !!currentLayout.animation?.enter;
|
|
551
551
|
isFirstRender = false;
|
|
552
552
|
|
|
553
553
|
// Render
|
package/src/sankey-renderer.ts
CHANGED
|
@@ -77,7 +77,7 @@ function stampAnimationAttrs(
|
|
|
77
77
|
fallbackIndex: number,
|
|
78
78
|
animation?: ResolvedAnimation,
|
|
79
79
|
): void {
|
|
80
|
-
if (!animation?.
|
|
80
|
+
if (!animation?.enter) return;
|
|
81
81
|
const idx = mark.animationIndex ?? fallbackIndex;
|
|
82
82
|
el.setAttribute('data-animation-index', String(idx));
|
|
83
83
|
(el as SVGElement & ElementCSSInlineStyle).style.setProperty('--oc-mark-index', String(idx));
|
|
@@ -560,18 +560,19 @@ export function renderSankeySVG(
|
|
|
560
560
|
svg.setAttribute('aria-label', layout.a11y.altText);
|
|
561
561
|
|
|
562
562
|
// Classes: oc-chart oc-sankey, plus oc-animate if animated
|
|
563
|
-
const
|
|
563
|
+
const enterPhase = animation?.enter;
|
|
564
|
+
const animate = !!enterPhase;
|
|
564
565
|
const classes = animate ? 'oc-chart oc-sankey oc-animate' : 'oc-chart oc-sankey';
|
|
565
566
|
svg.setAttribute('class', classes);
|
|
566
567
|
|
|
567
|
-
// Set animation CSS custom properties when enabled
|
|
568
|
-
if (
|
|
568
|
+
// Set animation CSS custom properties when the enter phase is enabled
|
|
569
|
+
if (enterPhase) {
|
|
569
570
|
const totalMarks = layout.nodes.length + layout.links.length;
|
|
570
|
-
const stagger = clampStaggerDelay(
|
|
571
|
-
svg.style.setProperty('--oc-animation-duration', `${
|
|
571
|
+
const stagger = clampStaggerDelay(enterPhase.staggerDelay, totalMarks);
|
|
572
|
+
svg.style.setProperty('--oc-animation-duration', `${enterPhase.duration}ms`);
|
|
572
573
|
svg.style.setProperty('--oc-animation-stagger', `${stagger}ms`);
|
|
573
|
-
svg.style.setProperty('--oc-annotation-delay', `${animation
|
|
574
|
-
const easeVar = EASE_VAR_MAP[
|
|
574
|
+
svg.style.setProperty('--oc-annotation-delay', `${animation!.annotationDelay}ms`);
|
|
575
|
+
const easeVar = EASE_VAR_MAP[enterPhase.ease] || EASE_VAR_MAP.smooth;
|
|
575
576
|
svg.style.setProperty('--oc-animation-ease', easeVar);
|
|
576
577
|
}
|
|
577
578
|
|
package/src/static.ts
CHANGED
|
@@ -8,12 +8,14 @@ import type {
|
|
|
8
8
|
LayerSpec,
|
|
9
9
|
ResolvedTheme,
|
|
10
10
|
ThemeConfig,
|
|
11
|
+
TileMapSpec,
|
|
11
12
|
} from '@opendata-ai/openchart-core';
|
|
12
|
-
import { adaptForLightLineStroke, isLayerSpec } from '@opendata-ai/openchart-core';
|
|
13
|
-
import { compileChart, compileLayer } from '@opendata-ai/openchart-engine';
|
|
13
|
+
import { adaptForLightLineStroke, isLayerSpec, isTileMapSpec } from '@opendata-ai/openchart-core';
|
|
14
|
+
import { compileChart, compileLayer, compileTileMap } from '@opendata-ai/openchart-engine';
|
|
14
15
|
import { SVG_NS } from './renderers/svg-dom';
|
|
15
16
|
import { resetSvgIdCounter } from './svg-ids';
|
|
16
17
|
import { renderChartSVG } from './svg-renderer';
|
|
18
|
+
import { renderTileMapSVG } from './tilemap-renderer';
|
|
17
19
|
|
|
18
20
|
const esmRequire = createRequire(import.meta.url);
|
|
19
21
|
|
|
@@ -142,7 +144,7 @@ function stripInteractiveElements(svg: Element): void {
|
|
|
142
144
|
* as no code schedules microtasks that outlive the call.
|
|
143
145
|
*/
|
|
144
146
|
export function renderStaticSVG(
|
|
145
|
-
spec: ChartSpec | LayerSpec,
|
|
147
|
+
spec: ChartSpec | LayerSpec | TileMapSpec,
|
|
146
148
|
options?: StaticRenderOptions,
|
|
147
149
|
): string {
|
|
148
150
|
if (rendering) {
|
|
@@ -174,37 +176,45 @@ export function renderStaticSVG(
|
|
|
174
176
|
watermark: options?.watermark,
|
|
175
177
|
};
|
|
176
178
|
|
|
177
|
-
let
|
|
178
|
-
|
|
179
|
-
|
|
179
|
+
let svg: SVGElement;
|
|
180
|
+
let themeForStyle: ResolvedTheme;
|
|
181
|
+
|
|
182
|
+
if (isTileMapSpec(spec)) {
|
|
183
|
+
const tileMapLayout = compileTileMap(spec, compileOpts);
|
|
184
|
+
svg = renderTileMapSVG(tileMapLayout, { animate: false });
|
|
185
|
+
themeForStyle = tileMapLayout.theme;
|
|
180
186
|
} else {
|
|
181
|
-
layout
|
|
187
|
+
let layout: ChartLayout;
|
|
188
|
+
if (isLayerSpec(spec)) {
|
|
189
|
+
layout = compileLayer(spec, compileOpts);
|
|
190
|
+
} else {
|
|
191
|
+
layout = compileChart(spec, compileOpts);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const container = win.document.createElement('div');
|
|
195
|
+
Object.defineProperty(container, 'getBoundingClientRect', {
|
|
196
|
+
value: () => ({
|
|
197
|
+
width,
|
|
198
|
+
height,
|
|
199
|
+
top: 0,
|
|
200
|
+
left: 0,
|
|
201
|
+
right: width,
|
|
202
|
+
bottom: height,
|
|
203
|
+
x: 0,
|
|
204
|
+
y: 0,
|
|
205
|
+
toJSON: () => ({}),
|
|
206
|
+
}),
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
svg = renderChartSVG(layout, container as unknown as HTMLElement, {
|
|
210
|
+
animate: false,
|
|
211
|
+
crosshair: false,
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
stripInteractiveElements(svg);
|
|
215
|
+
themeForStyle = layout.theme;
|
|
182
216
|
}
|
|
183
217
|
|
|
184
|
-
const container = win.document.createElement('div');
|
|
185
|
-
Object.defineProperty(container, 'getBoundingClientRect', {
|
|
186
|
-
value: () => ({
|
|
187
|
-
width,
|
|
188
|
-
height,
|
|
189
|
-
top: 0,
|
|
190
|
-
left: 0,
|
|
191
|
-
right: width,
|
|
192
|
-
bottom: height,
|
|
193
|
-
x: 0,
|
|
194
|
-
y: 0,
|
|
195
|
-
toJSON: () => ({}),
|
|
196
|
-
}),
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
// renderChartSVG appends to container; the container and SVG are owned by
|
|
200
|
-
// the happy-dom Window which is closed in the finally block.
|
|
201
|
-
const svg = renderChartSVG(layout, container as unknown as HTMLElement, {
|
|
202
|
-
animate: false,
|
|
203
|
-
crosshair: false,
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
stripInteractiveElements(svg);
|
|
207
|
-
|
|
208
218
|
const doc = win.document as unknown as Document;
|
|
209
219
|
let defs = svg.querySelector('defs');
|
|
210
220
|
if (!defs) {
|
|
@@ -212,7 +222,7 @@ export function renderStaticSVG(
|
|
|
212
222
|
svg.insertBefore(defs as unknown as Node, svg.firstChild);
|
|
213
223
|
}
|
|
214
224
|
const styleEl = doc.createElementNS(SVG_NS, 'style');
|
|
215
|
-
styleEl.textContent = buildThemeStyleBlock(
|
|
225
|
+
styleEl.textContent = buildThemeStyleBlock(themeForStyle);
|
|
216
226
|
defs.insertBefore(styleEl as unknown as Node, defs.firstChild);
|
|
217
227
|
|
|
218
228
|
const serializer = new (
|
package/src/svg-renderer.ts
CHANGED
|
@@ -82,14 +82,15 @@ export function renderChartSVG(
|
|
|
82
82
|
const classes = opts?.animate ? 'oc-chart oc-animate' : 'oc-chart';
|
|
83
83
|
svg.setAttribute('class', classes);
|
|
84
84
|
|
|
85
|
-
// Set animation CSS custom properties when enabled
|
|
86
|
-
|
|
85
|
+
// Set animation CSS custom properties when the enter phase is enabled
|
|
86
|
+
const enterPhase = animation?.enter;
|
|
87
|
+
if (enterPhase) {
|
|
87
88
|
const markCount = layout.marks.length;
|
|
88
|
-
const stagger = clampStaggerDelay(
|
|
89
|
-
svg.style.setProperty('--oc-animation-duration', `${
|
|
89
|
+
const stagger = clampStaggerDelay(enterPhase.staggerDelay, markCount);
|
|
90
|
+
svg.style.setProperty('--oc-animation-duration', `${enterPhase.duration}ms`);
|
|
90
91
|
svg.style.setProperty('--oc-animation-stagger', `${stagger}ms`);
|
|
91
|
-
svg.style.setProperty('--oc-annotation-delay', `${animation
|
|
92
|
-
const easeVar = EASE_VAR_MAP[
|
|
92
|
+
svg.style.setProperty('--oc-annotation-delay', `${animation!.annotationDelay}ms`);
|
|
93
|
+
const easeVar = EASE_VAR_MAP[enterPhase.ease] || EASE_VAR_MAP.smooth;
|
|
93
94
|
svg.style.setProperty('--oc-animation-ease', easeVar);
|
|
94
95
|
|
|
95
96
|
// Compute per-segment duration for stacked bars so the total bar animation
|
|
@@ -105,7 +106,7 @@ export function renderChartSVG(
|
|
|
105
106
|
}
|
|
106
107
|
}
|
|
107
108
|
if (maxSegments > 0) {
|
|
108
|
-
const segDuration = Math.round(
|
|
109
|
+
const segDuration = Math.round(enterPhase.duration / maxSegments);
|
|
109
110
|
svg.style.setProperty('--oc-stack-segment-duration', `${segDuration}ms`);
|
|
110
111
|
}
|
|
111
112
|
}
|
package/src/table-mount.ts
CHANGED
|
@@ -246,7 +246,7 @@ export function createTable(
|
|
|
246
246
|
}
|
|
247
247
|
|
|
248
248
|
currentLayout = compile();
|
|
249
|
-
const shouldAnimate = isFirstRender && !!currentLayout.animation?.
|
|
249
|
+
const shouldAnimate = isFirstRender && !!currentLayout.animation?.enter;
|
|
250
250
|
wrapperElement = renderTable(currentLayout, container, { animate: shouldAnimate });
|
|
251
251
|
|
|
252
252
|
// Set up animation cleanup on first animated render
|
package/src/table-renderer.ts
CHANGED
|
@@ -417,14 +417,14 @@ export function renderTable(
|
|
|
417
417
|
|
|
418
418
|
// Animation: stamp CSS custom properties and add oc-animate class BEFORE
|
|
419
419
|
// DOM insertion to avoid a flash of final state.
|
|
420
|
-
if (opts?.animate && layout.animation?.
|
|
421
|
-
const
|
|
420
|
+
if (opts?.animate && layout.animation?.enter) {
|
|
421
|
+
const enterPhase = layout.animation.enter;
|
|
422
422
|
const rowCount = layout.rows.length;
|
|
423
|
-
const stagger = clampStaggerDelay(
|
|
423
|
+
const stagger = clampStaggerDelay(enterPhase.staggerDelay, rowCount);
|
|
424
424
|
const s = wrapper.style;
|
|
425
|
-
s.setProperty('--oc-animation-duration', `${
|
|
425
|
+
s.setProperty('--oc-animation-duration', `${enterPhase.duration}ms`);
|
|
426
426
|
s.setProperty('--oc-animation-stagger', `${stagger}ms`);
|
|
427
|
-
s.setProperty('--oc-animation-ease', EASE_VAR_MAP[
|
|
427
|
+
s.setProperty('--oc-animation-ease', EASE_VAR_MAP[enterPhase.ease] || EASE_VAR_MAP.smooth);
|
|
428
428
|
wrapper.classList.add('oc-animate');
|
|
429
429
|
}
|
|
430
430
|
|
package/src/tilemap-mount.ts
CHANGED
|
@@ -287,7 +287,7 @@ export function createTileMap(
|
|
|
287
287
|
}
|
|
288
288
|
|
|
289
289
|
// Setup animation cleanup only when actually animating
|
|
290
|
-
if (animate && currentLayout.animation?.
|
|
290
|
+
if (animate && currentLayout.animation?.enter) {
|
|
291
291
|
animationCleanup = setupAnimationCleanup(newSvg, () => {
|
|
292
292
|
// On animation complete, check if resize was pending
|
|
293
293
|
if (pendingResize && !destroyed) {
|
package/src/tilemap-renderer.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type {
|
|
|
13
13
|
} from '@opendata-ai/openchart-core';
|
|
14
14
|
import { textAscent } from '@opendata-ai/openchart-core';
|
|
15
15
|
import { renderLegend } from './renderers/legend';
|
|
16
|
+
import { nextSvgId } from './svg-ids';
|
|
16
17
|
|
|
17
18
|
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
18
19
|
const XLINK_NS = 'http://www.w3.org/1999/xlink';
|
|
@@ -23,8 +24,6 @@ const EASE_VAR_MAP: Record<string, string> = {
|
|
|
23
24
|
snappy: 'var(--oc-ease-snappy)',
|
|
24
25
|
};
|
|
25
26
|
|
|
26
|
-
let gradientIdCounter = 0;
|
|
27
|
-
|
|
28
27
|
// ---------------------------------------------------------------------------
|
|
29
28
|
// Helpers
|
|
30
29
|
// ---------------------------------------------------------------------------
|
|
@@ -211,7 +210,7 @@ function renderTiles(
|
|
|
211
210
|
// Base delay spreads tiles across ~800ms window, jitter adds +-40% variation
|
|
212
211
|
// so some tiles pop in clusters while others have longer gaps.
|
|
213
212
|
const tileDelays: number[] = [];
|
|
214
|
-
if (animation?.
|
|
213
|
+
if (animation?.enter) {
|
|
215
214
|
const baseStagger = 800 / Math.max(tiles.length, 1);
|
|
216
215
|
let seed = 17;
|
|
217
216
|
for (let i = 0; i < tiles.length; i++) {
|
|
@@ -232,7 +231,7 @@ function renderTiles(
|
|
|
232
231
|
tileGroup.setAttribute('aria-label', tile.aria.label);
|
|
233
232
|
}
|
|
234
233
|
|
|
235
|
-
if (animation?.
|
|
234
|
+
if (animation?.enter) {
|
|
236
235
|
const idx = tile.animationIndex ?? i;
|
|
237
236
|
tileGroup.setAttribute('data-animation-index', String(idx));
|
|
238
237
|
(tileGroup as SVGElement & ElementCSSInlineStyle).style.setProperty(
|
|
@@ -322,7 +321,7 @@ function renderGradientLegend(parent: SVGElement, layout: TileMapLayout): void {
|
|
|
322
321
|
parent.insertBefore(defs, parent.firstChild);
|
|
323
322
|
}
|
|
324
323
|
|
|
325
|
-
const gradientId =
|
|
324
|
+
const gradientId = nextSvgId('oc-tilemap-legend-gradient');
|
|
326
325
|
const grad = createSVGElement('linearGradient');
|
|
327
326
|
grad.id = gradientId;
|
|
328
327
|
grad.setAttribute('x1', '0%');
|
|
@@ -407,7 +406,7 @@ export function renderTileMapSVG(
|
|
|
407
406
|
opts?: { animate?: boolean },
|
|
408
407
|
): SVGSVGElement {
|
|
409
408
|
const { width, height, tiles, a11y, watermark, animation } = layout;
|
|
410
|
-
const animate = opts?.animate && animation?.
|
|
409
|
+
const animate = opts?.animate && !!animation?.enter;
|
|
411
410
|
|
|
412
411
|
const svg = createSVGElement('svg') as SVGSVGElement;
|
|
413
412
|
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
|
|
@@ -419,13 +418,13 @@ export function renderTileMapSVG(
|
|
|
419
418
|
const classes = animate ? 'oc-tilemap oc-animate' : 'oc-tilemap';
|
|
420
419
|
svg.setAttribute('class', classes);
|
|
421
420
|
|
|
422
|
-
if (animate && animation) {
|
|
421
|
+
if (animate && animation?.enter) {
|
|
423
422
|
// Target ~1s total: stagger window ~800ms + per-tile pop ~200ms
|
|
424
423
|
const stagger = Math.max(5, Math.round(800 / Math.max(tiles.length, 1)));
|
|
425
|
-
svg.style.setProperty('--oc-animation-duration', `${animation.duration}ms`);
|
|
424
|
+
svg.style.setProperty('--oc-animation-duration', `${animation.enter.duration}ms`);
|
|
426
425
|
svg.style.setProperty('--oc-animation-stagger', `${stagger}ms`);
|
|
427
426
|
svg.style.setProperty('--oc-annotation-delay', `${animation.annotationDelay}ms`);
|
|
428
|
-
const easeVar = EASE_VAR_MAP[animation.ease] || EASE_VAR_MAP.smooth;
|
|
427
|
+
const easeVar = EASE_VAR_MAP[animation.enter.ease] || EASE_VAR_MAP.smooth;
|
|
429
428
|
svg.style.setProperty('--oc-animation-ease', easeVar);
|
|
430
429
|
}
|
|
431
430
|
|