Skip to content

Commit 2b09e02

Browse files
committed
feat(animations): tailored initial-mount animations per chart type, progressive marker reveal, reduced-motion
Each chart type gets a default animation tuned for its data shape: Line/area/rangeArea: pen-stroke draw via stroke-dashoffset + per-series mask wipe for fills; forecast paths preserve dasharray through mask reveal Bar/stacked/range-bar/funnel: left-to-right grow with stagger auto-scaled to bar count; stacked cascade bottom-to-top; non-trapezoid funnel/pyramid wipe outward from center Scatter/bubble: scale-up pop with easeOutBack overshoot + x-stagger HeatMap: diagonal-wave cell reveal ordered by (row + col) Treemap: largest-tile-first cascade — bigger tiles land before smaller ones Radar: stroke-draw around the perimeter + radial mask blooming from centroid Pie/donut/polarArea: center label fades in after slices land; pre-selected slice pull-out deferred until after sweep Gauge: needle settles from startAngle with easeOutBack overshoot; ticks/labels fade in after sweep Progressive marker reveal: markers, data labels, datalabel pills, and xaxis/point annotations snap in at the moment the line tip reaches their x — rAF-synced and inverse-eased to match easeOutCubic Stagger across all chart types driven by the existing animateGradually config
1 parent 005c4f2 commit 2b09e02

23 files changed

Lines changed: 805 additions & 66 deletions

src/apexcharts.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { getThemePalettes } from './utils/ThemePalettes.js'
1212
import XAxis from './modules/axes/XAxis'
1313
import YAxis from './modules/axes/YAxis'
1414
import InitCtxVariables from './modules/helpers/InitCtxVariables'
15+
import { applyAnimationPolicy } from './modules/Animations'
1516
import Destroy from './modules/helpers/Destroy'
1617
import { register } from './modules/ChartFactory'
1718
import { addResizeListener, removeResizeListener } from './utils/Resize'
@@ -83,6 +84,8 @@ export default class ApexCharts {
8384
? Utils.escapeString(this.w.config.chart.id)
8485
: this.w.globals.cuid
8586

87+
applyAnimationPolicy(this.w)
88+
8689
const initCtx = new InitCtxVariables(this)
8790
initCtx.initModules()
8891

src/charts/Bar.js

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import CoreUtils from '../modules/CoreUtils'
55
import Utils from '../utils/Utils'
66
import Filters from '../modules/Filters'
77
import Graphics from '../modules/Graphics'
8+
import { computeStagger } from '../modules/Animations'
89
import Series from '../modules/Series'
910

1011
/**
@@ -456,10 +457,40 @@ class Bar {
456457
pathFill = 'none'
457458
}
458459

459-
const delay =
460-
((j / w.config.chart.animations.animateGradually.delay) *
461-
(w.config.chart.animations.speed / w.globals.dataPoints)) /
462-
2.4
460+
// Per-bar stagger delay. The base step is auto-scaled so total stagger
461+
// (across all bars in a row/column) caps at ~half the animation speed —
462+
// a 5-bar chart and a 50-bar chart finish in similar wall-clock time.
463+
// For stacked bars: bottom layers (lower `i`) animate first, top layers
464+
// cascade on a smaller offset so each stack visibly "builds up".
465+
//
466+
// Note: animatePathsGradually() multiplies the passed `animationDelay` by
467+
// `animateGradually.delay` (the "delayFactor"). To express the stagger in
468+
// real milliseconds we divide by that factor here so the multiplication
469+
// cancels — otherwise a 40ms intended delay would become 40×150=6000ms.
470+
const animCfg = w.config.chart.animations
471+
const gradCfg = animCfg.animateGradually
472+
const staggerEnabled = gradCfg && gradCfg.enabled !== false
473+
let delay = 0
474+
if (staggerEnabled) {
475+
const totalBars = w.globals.dataPoints || 1
476+
const configStep = gradCfg.delay || 0
477+
const baseDelayMs = Math.min(
478+
configStep,
479+
(animCfg.speed * 0.5) / Math.max(1, totalBars),
480+
)
481+
let delayMs = computeStagger({
482+
style: 'sequential',
483+
index: j,
484+
baseDelay: baseDelayMs,
485+
})
486+
if (w.config.chart.stacked) {
487+
delayMs += i * baseDelayMs * 0.5
488+
}
489+
// Convert ms → delayFactor units so animatePathsGradually's
490+
// `delay * delayFactor` reproduces our intended ms delay.
491+
const delayFactor = configStep || 1
492+
delay = delayMs / delayFactor
493+
}
463494

464495
if (!skipDrawing) {
465496
const renderedPath = /** @type {any} */ (

src/charts/HeatMap.js

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// @ts-check
2-
import Animations from '../modules/Animations'
2+
import Animations, { computeStagger } from '../modules/Animations'
33
import Graphics from '../modules/Graphics'
44
import Fill from '../modules/Fill'
55
import Series from '../modules/Series'
@@ -164,7 +164,7 @@ export default class HeatMap {
164164
if (!w.globals.resized) {
165165
speed = w.config.chart.animations.speed
166166
}
167-
this.animateHeatMap(rect, x1, y1, xDivision, yDivision, speed)
167+
this.animateHeatMap(rect, x1, y1, xDivision, yDivision, speed, i, j)
168168
}
169169

170170
if (w.globals.dataChanged) {
@@ -241,9 +241,38 @@ export default class HeatMap {
241241
* @param {number} width
242242
* @param {number} height
243243
* @param {number} speed
244+
* @param {number} [row] - series index (heatmap row)
245+
* @param {number} [col] - data point index (heatmap column)
244246
*/
245-
animateHeatMap(el, x, y, width, height, speed) {
247+
animateHeatMap(el, x, y, width, height, speed, row = 0, col = 0) {
248+
const w = this.w
246249
const animations = new Animations(this.w)
250+
251+
// Diagonal-wave stagger: cells animate in order of (row + col), so the
252+
// reveal travels from top-left to bottom-right. Total stagger is capped
253+
// at ~half the animation speed regardless of grid size.
254+
const animCfg = w.config.chart.animations
255+
const gradCfg = animCfg.animateGradually
256+
const staggerEnabled = gradCfg && gradCfg.enabled !== false
257+
258+
let delay = 0
259+
if (staggerEnabled) {
260+
const seriesCount = (w.seriesData.series || []).length || 1
261+
const pointsCount = w.globals.dataPoints || 1
262+
const maxDiag = seriesCount + pointsCount - 2
263+
const baseDelay = Math.min(
264+
gradCfg.delay || 0,
265+
(speed * 0.5) / Math.max(1, maxDiag),
266+
)
267+
delay = computeStagger({
268+
style: 'diagonal',
269+
index: col,
270+
row,
271+
col,
272+
baseDelay,
273+
})
274+
}
275+
247276
animations.animateRect(
248277
el,
249278
{
@@ -262,6 +291,7 @@ export default class HeatMap {
262291
() => {
263292
animations.animationCompleted(el)
264293
},
294+
delay,
265295
)
266296
}
267297

src/charts/Line.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -818,7 +818,11 @@ class Line {
818818
const dataLabels = new DataLabels(this.w, this.ctx)
819819

820820
if (!this.pointsChart) {
821-
if (w.seriesData.series[i].length > 1) {
821+
// Progressive marker reveal handles per-marker opacity timing (synced
822+
// to the line draw), so the legacy group-level hide is bypassed on
823+
// initial mount. Data updates and resizes still use the old code path.
824+
const useProgressive = !w.globals.dataChanged && !w.globals.resized
825+
if (!useProgressive && w.seriesData.series[i].length > 1) {
822826
this.elPointsMain.node.classList.add('apexcharts-element-hidden')
823827
}
824828

src/charts/Pie.js

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -197,17 +197,33 @@ class Pie {
197197
elPie.add(elSeries)
198198

199199
if (this.donutDataLabels.show) {
200+
// On initial mount with animations enabled, the center label starts
201+
// hidden and fades in after the last slice finishes its sweep — so the
202+
// total/center value lands *with* the chart instead of before it.
203+
const shouldFadeInLabels =
204+
this.initialAnim &&
205+
!w.globals.resized &&
206+
!w.globals.dataChanged &&
207+
this.animDur > 0
200208
const dataLabels = this.renderInnerDataLabels(
201209
this.dataLabelsGroup,
202210
this.donutDataLabels,
203211
{
204212
hollowSize: this.donutSize,
205213
centerX: this.centerX,
206214
centerY: this.centerY,
207-
opacity: this.donutDataLabels.show,
215+
opacity: shouldFadeInLabels ? 0 : this.donutDataLabels.show,
208216
},
209217
)
210218

219+
if (shouldFadeInLabels) {
220+
const labelsNode = this.dataLabelsGroup.node
221+
labelsNode.style.transition = 'opacity 280ms ease-out'
222+
setTimeout(() => {
223+
labelsNode.style.opacity = '1'
224+
}, this.animDur)
225+
}
226+
211227
elPie.add(dataLabels)
212228
}
213229

@@ -392,7 +408,21 @@ class Pie {
392408
typeof w.interact.selectedDataPoints[0] !== 'undefined' &&
393409
w.interact.selectedDataPoints[0].indexOf(i) > -1
394410
) {
395-
this.pieClicked(i)
411+
// Defer the "pulled out" offset for pre-selected slices until after
412+
// the sweep finishes. Otherwise the slice translates while it's still
413+
// growing, which makes both motions hard to read.
414+
if (
415+
this.initialAnim &&
416+
!w.globals.resized &&
417+
!w.globals.dataChanged &&
418+
this.animDur > 0
419+
) {
420+
const _this = this
421+
const _i = i
422+
setTimeout(() => _this.pieClicked(_i), this.animDur)
423+
} else {
424+
this.pieClicked(i)
425+
}
396426
}
397427

398428
if (w.config.dataLabels.enabled) {

src/charts/Radar.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,9 @@ class Radar {
224224
strokeWidth: 0,
225225
fill: pathFill,
226226
drawShadow: false,
227+
// Radial mask: the area fill blooms outward from the radar's center
228+
// (in this group's local coords) instead of the default L→R rect wipe.
229+
drawMask: { type: 'radial', cx: 0, cy: 0, r: this.size },
227230
})
228231

229232
if (w.config.chart.dropShadow.enabled) {

src/charts/Radial.js

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import Fill from '../modules/Fill'
55
import Graphics from '../modules/Graphics'
66
import Filters from '../modules/Filters'
77
import Series from '../modules/Series'
8+
import { BrowserAPIs } from '../ssr/BrowserAPIs'
9+
import { Environment } from '../utils/Environment'
810

911
/**
1012
* ApexCharts Radial Class for drawing Circle / Semi Circle Charts.
@@ -116,6 +118,20 @@ class Radial extends Pie {
116118
centerY,
117119
series,
118120
})
121+
// On initial mount, ticks/labels fade in after the value arc + needle
122+
// finish their sweep — so they read as a "settled" annotation rather
123+
// than competing for attention during the gauge animation.
124+
const isInitialMount =
125+
this.initialAnim && !w.globals.dataChanged && !w.globals.resized
126+
if (isInitialMount && Environment.isBrowser() && w.globals.shouldAnimate) {
127+
const ticksNode = elTicks.node
128+
ticksNode.style.opacity = '0'
129+
ticksNode.style.transition = 'opacity 280ms ease-out'
130+
const sweepDur = w.config.chart.animations.speed || 800
131+
setTimeout(() => {
132+
ticksNode.style.opacity = '1'
133+
}, sweepDur)
134+
}
119135
elSeries.add(elTicks)
120136
}
121137

@@ -786,10 +802,42 @@ class Radial extends Pie {
786802
// chart center, transform-origin set to (cx, cy).
787803
const value = Number(opts.series[0])
788804
const targetAngle = this._angleAtValue(value)
789-
g.attr({
790-
'transform-origin': `${cx} ${cy}`,
791-
transform: `rotate(${targetAngle})`,
792-
})
805+
806+
const isInitialMount =
807+
this.initialAnim && !w.globals.dataChanged && !w.globals.resized
808+
if (isInitialMount && Environment.isBrowser() && w.globals.shouldAnimate) {
809+
// Animate the needle from the gauge's start angle to its target with an
810+
// ease-out-back curve so it visibly settles past the target and bounces
811+
// back, like a real spring-loaded gauge needle.
812+
const fromAngle = this.startAngle
813+
const node = g.node
814+
node.setAttribute('transform-origin', `${cx} ${cy}`)
815+
node.setAttribute('transform', `rotate(${fromAngle})`)
816+
817+
const speed =
818+
(cfg.animationSpeed && Number(cfg.animationSpeed)) ||
819+
w.config.chart.animations.speed ||
820+
800
821+
const c1 = 1.70158
822+
const c3 = c1 + 1
823+
/** @param {number} t */
824+
const ease = (t) => 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2)
825+
826+
const startAt = performance.now()
827+
/** @param {number} now */
828+
const step = (now) => {
829+
const t = Math.max(0, Math.min(1, (now - startAt) / speed))
830+
const angle = fromAngle + (targetAngle - fromAngle) * ease(t)
831+
node.setAttribute('transform', `rotate(${angle})`)
832+
if (t < 1) BrowserAPIs.requestAnimationFrame(step)
833+
}
834+
BrowserAPIs.requestAnimationFrame(step)
835+
} else {
836+
g.attr({
837+
'transform-origin': `${cx} ${cy}`,
838+
transform: `rotate(${targetAngle})`,
839+
})
840+
}
793841

794842
return g
795843
}

src/charts/Scatter.js

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// @ts-check
2-
import Animations from '../modules/Animations'
2+
import Animations, { computeStagger } from '../modules/Animations'
33
import Fill from '../modules/Fill'
44
import Filters from '../modules/Filters'
55
import Graphics from '../modules/Graphics'
@@ -168,18 +168,26 @@ export default class Scatter {
168168
}
169169

170170
if (this.initialAnim && !w.globals.dataChanged && !w.globals.resized) {
171-
const speed = w.config.chart.animations.speed
172-
173-
anim.animateMarker(
174-
el,
175-
speed,
176-
/** @type {any} */ (w.globals).easing,
177-
() => {
178-
window.setTimeout(() => {
179-
anim.animationCompleted(el)
180-
}, 100)
181-
},
182-
)
171+
const animCfg = w.config.chart.animations
172+
// Pop effect: scale + opacity per marker. Per-point left-to-right
173+
// stagger is driven by `animateGradually`.
174+
const popSpeed = animCfg.speed
175+
const totalPoints = w.globals.dataPoints || 1
176+
const gradCfg = animCfg.animateGradually
177+
const gradEnabled = gradCfg && gradCfg.enabled !== false
178+
const baseDelay = gradEnabled
179+
? Math.min(20, (popSpeed * 0.5) / Math.max(1, totalPoints))
180+
: 0
181+
const delay = computeStagger({
182+
style: baseDelay > 0 ? 'sequential' : 'none',
183+
index: dataPointIndex,
184+
baseDelay,
185+
})
186+
anim.animatePop(el, {
187+
speed: popSpeed,
188+
delay,
189+
onComplete: () => anim.animationCompleted(el),
190+
})
183191
} else {
184192
w.globals.animationEnded = true
185193
}

0 commit comments

Comments
 (0)