@opendata-ai/openchart-vanilla 7.8.0 → 7.9.1
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-KYRQV6KR.js → chunk-EQ6DYFO7.js} +46 -19
- package/dist/chunk-EQ6DYFO7.js.map +1 -0
- package/dist/index.d.ts +26 -19
- package/dist/index.js +1757 -24
- package/dist/index.js.map +1 -1
- package/dist/static.js +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 +21 -0
- package/src/renderers/marks.ts +21 -7
- package/src/sankey-mount.ts +2 -2
- package/src/sankey-renderer.ts +9 -8
- 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 +44 -4
- package/src/tilemap-renderer.ts +6 -6
- package/src/transition.ts +2473 -0
- package/dist/chunk-KYRQV6KR.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,
|
|
@@ -292,10 +310,13 @@ function renderAxis(
|
|
|
292
310
|
);
|
|
293
311
|
return Math.max(max, w);
|
|
294
312
|
}, 0);
|
|
313
|
+
// Inline y-ticks live inside the plot, so the title clears only the chart
|
|
314
|
+
// edge — mirror the engine's reservation (see axisTitleOffset).
|
|
295
315
|
const titleOffset = axisTitleOffset(
|
|
296
316
|
maxTickLabelWidth,
|
|
297
317
|
axis.labelStyle.fontSize,
|
|
298
318
|
layout.dimensions.width,
|
|
319
|
+
isInlineY,
|
|
299
320
|
);
|
|
300
321
|
setAttrs(axisLabel, {
|
|
301
322
|
x: area.x - titleOffset,
|
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/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
|
@@ -13,6 +13,7 @@ import type {
|
|
|
13
13
|
TileMapLayout,
|
|
14
14
|
TileMapSpec,
|
|
15
15
|
} from '@opendata-ai/openchart-core';
|
|
16
|
+
import { BREAKPOINT_COMPACT_MAX, BREAKPOINT_MEDIUM_MAX } from '@opendata-ai/openchart-core';
|
|
16
17
|
import { compileTileMap } from '@opendata-ai/openchart-engine';
|
|
17
18
|
import { cancelAnimations, setupAnimationCleanup } from './animation';
|
|
18
19
|
import {
|
|
@@ -28,6 +29,33 @@ import { observeResize } from './resize-observer';
|
|
|
28
29
|
import { renderTileMapSVG } from './tilemap-renderer';
|
|
29
30
|
import { createTooltipManager, type TooltipManager } from './tooltip';
|
|
30
31
|
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Responsive tile sizing
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
// The state grid is 8 rows tall (see engine/tilemap/layout.ts GRID_ROWS).
|
|
37
|
+
// TILEMAP_TILE_GAP must match the gap passed to computeTilePositions in
|
|
38
|
+
// compile-tilemap.ts (currently 5) so the mount's height budget matches the
|
|
39
|
+
// grid the compiler builds.
|
|
40
|
+
const TILEMAP_GRID_ROWS = 8;
|
|
41
|
+
const TILEMAP_TILE_GAP = 5;
|
|
42
|
+
// Generous vertical reserve for chrome (title/subtitle/source) + padding +
|
|
43
|
+
// legend. Intentionally over-reserved: on narrow widths we WANT tile size to be
|
|
44
|
+
// bound by width, so any excess height budget just lets the map grow taller
|
|
45
|
+
// (which is the desired mobile behavior) rather than starving the tiles.
|
|
46
|
+
const TILEMAP_CHROME_RESERVE = 180;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Target tile size (px) for a given container width. Drives the height budget
|
|
50
|
+
* on non-desktop widths so the 8-row grid isn't starved to tiny tiles after
|
|
51
|
+
* chrome is subtracted from a square drawing area. Width still caps the actual
|
|
52
|
+
* tile size, so these are ceilings the layout grows toward, not guarantees.
|
|
53
|
+
*/
|
|
54
|
+
function pickTargetTileSize(width: number): number {
|
|
55
|
+
if (width < BREAKPOINT_COMPACT_MAX) return 34; // compact / mobile
|
|
56
|
+
return 40; // medium (tablet, sidebars)
|
|
57
|
+
}
|
|
58
|
+
|
|
31
59
|
// ---------------------------------------------------------------------------
|
|
32
60
|
// Types
|
|
33
61
|
// ---------------------------------------------------------------------------
|
|
@@ -150,9 +178,21 @@ export function createTileMap(
|
|
|
150
178
|
function getContainerDimensions(): { width: number; height: number } {
|
|
151
179
|
const rect = container.getBoundingClientRect();
|
|
152
180
|
const width = Math.max(rect.width || 600, 100);
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
|
|
181
|
+
// The compiler derives its own tight content height and treats the passed
|
|
182
|
+
// height as a cap. On desktop, a square budget (height = width) keeps tiles
|
|
183
|
+
// from ballooning (the 8-row grid becomes the binding constraint at
|
|
184
|
+
// ~width/8). Below desktop, a square budget starves the grid after chrome,
|
|
185
|
+
// so give it a height budget sized to a comfortable target tile instead —
|
|
186
|
+
// the map then grows taller as needed rather than shrinking the tiles.
|
|
187
|
+
if (width >= BREAKPOINT_MEDIUM_MAX) {
|
|
188
|
+
return { width, height: width };
|
|
189
|
+
}
|
|
190
|
+
const targetTile = pickTargetTileSize(width);
|
|
191
|
+
const height =
|
|
192
|
+
targetTile * TILEMAP_GRID_ROWS +
|
|
193
|
+
TILEMAP_TILE_GAP * (TILEMAP_GRID_ROWS - 1) +
|
|
194
|
+
TILEMAP_CHROME_RESERVE;
|
|
195
|
+
return { width, height };
|
|
156
196
|
}
|
|
157
197
|
|
|
158
198
|
function compile(): TileMapLayout {
|
|
@@ -287,7 +327,7 @@ export function createTileMap(
|
|
|
287
327
|
}
|
|
288
328
|
|
|
289
329
|
// Setup animation cleanup only when actually animating
|
|
290
|
-
if (animate && currentLayout.animation?.
|
|
330
|
+
if (animate && currentLayout.animation?.enter) {
|
|
291
331
|
animationCleanup = setupAnimationCleanup(newSvg, () => {
|
|
292
332
|
// On animation complete, check if resize was pending
|
|
293
333
|
if (pendingResize && !destroyed) {
|
package/src/tilemap-renderer.ts
CHANGED
|
@@ -210,7 +210,7 @@ function renderTiles(
|
|
|
210
210
|
// Base delay spreads tiles across ~800ms window, jitter adds +-40% variation
|
|
211
211
|
// so some tiles pop in clusters while others have longer gaps.
|
|
212
212
|
const tileDelays: number[] = [];
|
|
213
|
-
if (animation?.
|
|
213
|
+
if (animation?.enter) {
|
|
214
214
|
const baseStagger = 800 / Math.max(tiles.length, 1);
|
|
215
215
|
let seed = 17;
|
|
216
216
|
for (let i = 0; i < tiles.length; i++) {
|
|
@@ -231,7 +231,7 @@ function renderTiles(
|
|
|
231
231
|
tileGroup.setAttribute('aria-label', tile.aria.label);
|
|
232
232
|
}
|
|
233
233
|
|
|
234
|
-
if (animation?.
|
|
234
|
+
if (animation?.enter) {
|
|
235
235
|
const idx = tile.animationIndex ?? i;
|
|
236
236
|
tileGroup.setAttribute('data-animation-index', String(idx));
|
|
237
237
|
(tileGroup as SVGElement & ElementCSSInlineStyle).style.setProperty(
|
|
@@ -406,7 +406,7 @@ export function renderTileMapSVG(
|
|
|
406
406
|
opts?: { animate?: boolean },
|
|
407
407
|
): SVGSVGElement {
|
|
408
408
|
const { width, height, tiles, a11y, watermark, animation } = layout;
|
|
409
|
-
const animate = opts?.animate && animation?.
|
|
409
|
+
const animate = opts?.animate && !!animation?.enter;
|
|
410
410
|
|
|
411
411
|
const svg = createSVGElement('svg') as SVGSVGElement;
|
|
412
412
|
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
|
|
@@ -418,13 +418,13 @@ export function renderTileMapSVG(
|
|
|
418
418
|
const classes = animate ? 'oc-tilemap oc-animate' : 'oc-tilemap';
|
|
419
419
|
svg.setAttribute('class', classes);
|
|
420
420
|
|
|
421
|
-
if (animate && animation) {
|
|
421
|
+
if (animate && animation?.enter) {
|
|
422
422
|
// Target ~1s total: stagger window ~800ms + per-tile pop ~200ms
|
|
423
423
|
const stagger = Math.max(5, Math.round(800 / Math.max(tiles.length, 1)));
|
|
424
|
-
svg.style.setProperty('--oc-animation-duration', `${animation.duration}ms`);
|
|
424
|
+
svg.style.setProperty('--oc-animation-duration', `${animation.enter.duration}ms`);
|
|
425
425
|
svg.style.setProperty('--oc-animation-stagger', `${stagger}ms`);
|
|
426
426
|
svg.style.setProperty('--oc-annotation-delay', `${animation.annotationDelay}ms`);
|
|
427
|
-
const easeVar = EASE_VAR_MAP[animation.ease] || EASE_VAR_MAP.smooth;
|
|
427
|
+
const easeVar = EASE_VAR_MAP[animation.enter.ease] || EASE_VAR_MAP.smooth;
|
|
428
428
|
svg.style.setProperty('--oc-animation-ease', easeVar);
|
|
429
429
|
}
|
|
430
430
|
|