apexcharts
Version:
A JavaScript Chart Library
602 lines (546 loc) • 19.2 kB
JavaScript
// @ts-check
import Utils from '../utils/Utils'
import { BrowserAPIs } from '../ssr/BrowserAPIs'
import { Environment } from '../utils/Environment'
const SVGNS = 'http://www.w3.org/2000/svg'
/**
* Cubic ease-out — fast start, decelerates toward the end. Reads as a pen
* sweeping across the chart and settling, which is the feel we want for the
* draw effect (ease-in-out's fast middle makes it whip too aggressively).
* @param {number} t
*/
function easeOutCubic(t) {
return 1 - Math.pow(1 - t, 3)
}
/**
* Cubic ease-out-back — overshoots `1` near the end then settles. Used for the
* `pop` effect (scatter/bubble markers) so each marker feels like it's
* landing rather than just appearing.
* Equivalent to CSS `cubic-bezier(0.34, 1.56, 0.64, 1)`.
* @param {number} t
*/
function easeOutBack(t) {
const c1 = 1.70158
const c3 = c1 + 1
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2)
}
/**
* Cached reduced-motion query — `matchMedia` evaluation is cheap but the
* MediaQueryList instance is reused if a chart re-renders.
* @type {MediaQueryList | null}
*/
let _reducedMotionMql = null
/**
* @returns {boolean} true when the OS has prefers-reduced-motion: reduce active.
*/
export function prefersReducedMotion() {
if (!Environment.isBrowser()) return false
try {
if (!_reducedMotionMql) {
_reducedMotionMql = window.matchMedia('(prefers-reduced-motion: reduce)')
}
return !!_reducedMotionMql.matches
} catch (_) {
return false
}
}
/**
* @typedef {Object} StaggerOpts
* @property {'sequential' | 'group' | 'diagonal' | 'centroid' | 'none'} style
* @property {number} index - 0-based item index in its group
* @property {number} [total] - total items (for centroid normalization)
* @property {number} [baseDelay=40] - delay per stagger step in ms
* @property {number} [row] - for 'diagonal' (heatmap)
* @property {number} [col] - for 'diagonal'
* @property {number} [groupIndex] - for 'group'
* @property {number} [perGroup] - for 'group'
* @property {number} [centerDistance] - for 'centroid' (0..1 normalized)
*/
/**
* Apply the "progressive marker reveal" to an SVG element at grid-x `x`.
* On initial mount of a line/area/rangeArea chart, each marker (or data label
* or x-axis/point annotation) snaps in at the moment the line's pen-stroke
* reaches its x position.
*
* Idempotent and SSR-safe — no-op when:
* - not running in a browser
* - this is a data-update / resize render
* - chart.animations.enabled is false
* - chart type isn't line/area/rangeArea
*
* @param {any} el - svg.js element (uses el.node.style)
* @param {number} x - grid-coordinate x of the element
* @param {import('../types/internal').ChartStateW} w
* @returns {boolean} - true if the reveal was applied
*/
export function applyProgressiveReveal(el, x, w) {
if (!Environment.isBrowser()) return false
if (w.globals.dataChanged || w.globals.resized) return false
const animCfg = w.config.chart.animations
if (!animCfg || animCfg.enabled === false) return false
const chartType = w.config.chart.type
if (chartType !== 'line' && chartType !== 'area' && chartType !== 'rangeArea') {
return false
}
if (!(w.layout.gridWidth > 0)) return false
// Line/area/radar draw mode internally doubles the configured speed (see
// Graphics.renderPaths). The reveal must use the same effective duration
// so markers land in sync with the pen tip.
const drawSpeed = (animCfg.speed || 800) * 2
const xRatio = Math.max(0, Math.min(1, x / w.layout.gridWidth))
// Invert the line's easeOutCubic to find *when* the tip reaches this x.
// The line covers ground fast at the start and slow at the end, so a marker
// at xRatio=0.5 is reached at t ≈ 0.21, not t = 0.5. Linear timing was
// making everything lag by exactly this eased-vs-linear difference.
// easeOutCubic(t) = 1 - (1 - t)^3
// inverse: t = 1 - cbrt(1 - y)
const easedT = 1 - Math.cbrt(1 - xRatio)
const revealDelay = easedT * drawSpeed
const node = el.node
const style = node.style
style.opacity = '0'
// Anchor on the first rAF after render — the same frame `animateDraw` uses
// as its t=0. All rAF callbacks scheduled in the current task receive the
// same timestamp in the next frame, so reveal and draw share an origin.
// Then poll rAFs (not setTimeout) so the snap happens on a frame boundary.
/** @type {number | null} */
let startAnchor = null
/** @param {number} now */
const tick = (now) => {
if (startAnchor === null) startAnchor = now
if (now - startAnchor >= revealDelay) {
style.opacity = '1'
} else {
BrowserAPIs.requestAnimationFrame(tick)
}
}
BrowserAPIs.requestAnimationFrame(tick)
return true
}
/**
* Apply OS / config-driven animation policy to the chart's effective config.
* Currently: if prefers-reduced-motion is active AND
* `chart.animations.respectReducedMotion` is true, disable both initial-mount
* and data-change animations so the chart renders instantly.
*
* Must be called once, after config finalization (Base.init) and before the
* first render. Idempotent; safe to call again after a config update.
*
* @param {import('../types/internal').ChartStateW} w
*/
export function applyAnimationPolicy(w) {
const anim = w.config.chart.animations
if (!anim) return
if (anim.respectReducedMotion !== false && prefersReducedMotion()) {
anim.enabled = false
if (anim.dynamicAnimation) anim.dynamicAnimation.enabled = false
}
}
/**
* Compute a per-element delay for staggered initial-mount animations.
*
* Recognized options on `opts`:
* style: 'sequential' | 'group' | 'diagonal' | 'centroid' | 'none'
* index: 0-based item index in its group
* baseDelay: delay per stagger step in ms (default 40)
* row, col: for 'diagonal' (heatmap)
* groupIndex,
* perGroup: for 'group'
* centerDistance: for 'centroid' (0..1 normalized)
*
* @param {any} opts
* @returns {number} delay in ms
*/
export function computeStagger(opts) {
const style = opts.style
const index = opts.index || 0
const baseDelay = typeof opts.baseDelay === 'number' ? opts.baseDelay : 40
const row = opts.row || 0
const col = opts.col || 0
const groupIndex = opts.groupIndex || 0
const perGroup = opts.perGroup || 1
const centerDistance = opts.centerDistance || 0
switch (style) {
case 'none':
return 0
case 'diagonal':
return (row + col) * baseDelay
case 'group':
return groupIndex * baseDelay + (index % perGroup) * (baseDelay / 4)
case 'centroid':
return centerDistance * baseDelay * (index + 1)
case 'sequential':
default:
return index * baseDelay
}
}
/**
* ApexCharts Animation Class.
*
* @module Animations
**/
export default class Animations {
/**
* @param {import('../types/internal').ChartStateW} w
* @param {import('../types/internal').ChartContext} [ctx]
*/
constructor(w, ctx) {
this.w = w
this.ctx = ctx // kept for animationEnd user callback: chart.events.animationEnd(ctx, …)
}
/**
* @param {any} el
* @param {Record<string, any>} from
* @param {Record<string, any>} to
* @param {object} speed
*/
animateLine(el, from, to, speed) {
el.attr(from).animate(speed).attr(to)
}
/*
** Animate radius of a circle element
* @param {any} el
* @param {number} speed
* @param {string} easing
* @param {Function} cb
*/
/** @param {any} el @param {any} speed @param {any} easing @param {any} cb */
animateMarker(el, speed, easing, cb) {
el.attr({
opacity: 0,
})
.animate(speed)
.attr({
opacity: 1,
})
.after(() => {
cb()
})
}
/**
* Scale-up "pop" effect for scatter/bubble markers. Animates
* `transform: scale(0 → 1)` (with `easeOutBack` overshoot) plus opacity,
* around the marker's own center via `transform-box: fill-box`.
*
* Falls back to instant render in SSR / when shouldAnimate is false.
*
* @param {any} el
* @param {{ speed: number, delay?: number, onComplete?: () => void }} params
*/
animatePop(el, { speed, delay = 0, onComplete }) {
const w = this.w
if (!Environment.isBrowser() || !w.globals.shouldAnimate || speed < 1) {
if (onComplete) onComplete()
return
}
const node = el.node
const style = node.style
// SVG2 transform-box puts the origin at the element's geometric center
// regardless of its (x,y) position, so each marker scales in place.
style.transformBox = 'fill-box'
style.transformOrigin = 'center'
style.transform = 'scale(0)'
style.opacity = '0'
const startAt = performance.now() + delay
/** @param {number} now */
const step = (now) => {
const t = Math.max(0, Math.min(1, (now - startAt) / speed))
style.transform = `scale(${easeOutBack(t)})`
// Fade in over the first half so the back-out overshoot is visible
// (rather than blasted in at full opacity from frame 1).
style.opacity = String(Math.min(1, t * 2))
if (t < 1) {
BrowserAPIs.requestAnimationFrame(step)
} else {
style.transform = ''
style.transformOrigin = ''
style.transformBox = ''
style.opacity = ''
if (onComplete) onComplete()
}
}
BrowserAPIs.requestAnimationFrame(step)
}
/*
** Animate rect properties
* @param {any} el
* @param {any} from
* @param {any} to
* @param {number} speed
* @param {Function} fn
*/
/** @param {any} el @param {any} from @param {any} to @param {any} speed @param {any} fn @param {number} [delay] */
animateRect(el, from, to, speed, fn, delay = 0) {
el.attr(from)
.animate(speed, delay)
.attr(to)
.after(() => fn())
}
/**
* @param {Record<string, any>} params
*/
animatePathsGradually(params) {
const { el, realIndex, j, fill, pathFrom, pathTo, speed, delay } = params
const me = this
const w = this.w
let delayFactor = 0
if (w.config.chart.animations.animateGradually.enabled) {
delayFactor = w.config.chart.animations.animateGradually.delay
}
if (
w.config.chart.animations.dynamicAnimation.enabled &&
w.globals.dataChanged &&
w.config.chart.type !== 'bar'
) {
// disabled due to this bug - https://github.com/apexcharts/vue-apexcharts/issues/75
delayFactor = 0
}
me.morphSVG(
el,
realIndex,
j,
w.config.chart.type === 'line' && !w.globals.comboCharts
? 'stroke'
: fill,
pathFrom,
pathTo,
speed,
delay * delayFactor,
)
}
showDelayedElements() {
/**
* @param {object} d
*/
this.w.globals.delayedElements.forEach((d) => {
const ele = d.el
ele.classList.remove('apexcharts-element-hidden')
ele.classList.add('apexcharts-hidden-element-shown')
})
}
/**
* @param {any} el
*/
animationCompleted(el) {
const w = this.w
if (w.globals.animationEnded) return
w.globals.animationEnded = true
this.showDelayedElements()
if (typeof w.config.chart.events.animationEnd === 'function') {
w.config.chart.events.animationEnd(this.ctx, { el, w })
}
}
/**
* Initial-mount "pen-stroke" draw effect for line / area / rangeArea paths.
*
* Stroke paths animate `stroke-dashoffset` from total length → 0.
* Fill paths animate the width of a per-series SVG `<mask>` rect from 0 → gridWidth,
* which coexists with the existing `clip-path: gridRectMask` (mask + clip-path
* are independent SVG attributes).
*
* For radar / radial shapes, pass `mask: { type: 'radial', cx, cy, r }` to
* use a circular mask that blooms from center outward instead of the default
* left-to-right rect wipe.
*
* @param {any} el - SVG.js path element
* @param {{realIndex: number, j?: number, isFill: boolean, isLast: boolean, speed: number, delay: number, mask?: {type: 'rect'|'radial', cx?: number, cy?: number, r?: number}}} params
*/
animateDraw(el, { realIndex, j, isFill, isLast, speed, delay, mask: maskShape }) {
const w = this.w
const me = this
const finalize = () => {
if (isLast && w.globals.shouldAnimate) {
me.animationCompleted(el)
}
me.showDelayedElements()
}
if (!Environment.isBrowser() || !w.globals.shouldAnimate || speed < 1) {
finalize()
return
}
const node = el.node
// Mask-based reveal — works for filled paths (area) and for stroked paths that
// already have a custom dash pattern (forecast, user-set stroke.dashArray).
const runMaskReveal = () => {
const pad = 4
const isRadial = maskShape && maskShape.type === 'radial'
const targetWidth = w.layout.gridWidth + pad * 2
const radialCx = (maskShape && maskShape.cx) || 0
const radialCy = (maskShape && maskShape.cy) || 0
const targetRadius =
((maskShape && maskShape.r) || w.layout.gridWidth / 2) + pad
const maskId = `apexDrawMask${w.globals.cuid}-${realIndex}-${j ?? 0}-${isFill ? 'f' : 's'}`
const mask = BrowserAPIs.createElementNS(SVGNS, 'mask')
mask.setAttribute('id', maskId)
mask.setAttribute('maskUnits', 'userSpaceOnUse')
/** @type {Element} */
let revealEl
if (isRadial) {
// Region must cover the full radar bbox (centered at cx, cy with radius r + pad).
const region = targetRadius
mask.setAttribute('x', String(radialCx - region))
mask.setAttribute('y', String(radialCy - region))
mask.setAttribute('width', String(region * 2))
mask.setAttribute('height', String(region * 2))
revealEl = BrowserAPIs.createElementNS(SVGNS, 'circle')
revealEl.setAttribute('cx', String(radialCx))
revealEl.setAttribute('cy', String(radialCy))
revealEl.setAttribute('r', '0')
revealEl.setAttribute('fill', '#fff')
} else {
mask.setAttribute('x', String(-pad))
mask.setAttribute('y', String(-pad))
mask.setAttribute('width', String(targetWidth))
mask.setAttribute('height', String(w.layout.gridHeight + pad * 2))
revealEl = BrowserAPIs.createElementNS(SVGNS, 'rect')
revealEl.setAttribute('x', String(-pad))
revealEl.setAttribute('y', String(-pad))
revealEl.setAttribute('width', '0')
revealEl.setAttribute('height', String(w.layout.gridHeight + pad * 2))
revealEl.setAttribute('fill', '#fff')
}
mask.appendChild(revealEl)
w.dom.elDefs.node.appendChild(mask)
node.setAttribute('mask', `url(#${maskId})`)
const startAt = performance.now() + (delay || 0)
/** @param {number} now */
const step = (now) => {
const t = Math.max(0, Math.min(1, (now - startAt) / speed))
const eased = easeOutCubic(t)
if (isRadial) {
revealEl.setAttribute('r', String(eased * targetRadius))
} else {
revealEl.setAttribute('width', String(eased * targetWidth))
}
if (t < 1) {
BrowserAPIs.requestAnimationFrame(step)
} else {
node.removeAttribute('mask')
if (mask.parentNode) mask.parentNode.removeChild(mask)
finalize()
}
}
BrowserAPIs.requestAnimationFrame(step)
}
// Stroke draw via stroke-dashoffset (only safe when no custom dash pattern exists).
/** @param {number} len */
const runStrokeDraw = (len) => {
node.setAttribute('stroke-dasharray', String(len))
node.setAttribute('stroke-dashoffset', String(len))
const startAt = performance.now() + (delay || 0)
/** @param {number} now */
const step = (now) => {
const t = Math.max(0, Math.min(1, (now - startAt) / speed))
node.setAttribute('stroke-dashoffset', String(len * (1 - easeOutCubic(t))))
if (t < 1) {
BrowserAPIs.requestAnimationFrame(step)
} else {
node.removeAttribute('stroke-dasharray')
node.removeAttribute('stroke-dashoffset')
finalize()
}
}
BrowserAPIs.requestAnimationFrame(step)
}
// Defer one frame: callers (e.g. Line.js for forecast paths) set stroke-dasharray
// *after* renderPaths returns, so a sync check would miss it.
BrowserAPIs.requestAnimationFrame(() => {
if (isFill) {
runMaskReveal()
return
}
const existingDash = node.getAttribute('stroke-dasharray')
const hasCustomDash =
!!existingDash && existingDash !== '0' && existingDash !== ''
if (hasCustomDash) {
// Preserve dash pattern; reveal via mask instead of fighting dashoffset.
runMaskReveal()
return
}
let len = 0
try {
if (typeof node.getTotalLength === 'function') {
len = node.getTotalLength()
}
} catch (_) {
len = 0
}
if (!len) {
finalize()
return
}
runStrokeDraw(len)
})
}
// SVG.js animation for morphing one path to another
/**
* @param {any} el
* @param {number} realIndex
* @param {number} j
* @param {string} fill
* @param {string} pathFrom
* @param {string} pathTo
* @param {number} speed
* @param {number} delay
*/
morphSVG(el, realIndex, j, fill, pathFrom, pathTo, speed, delay) {
const w = this.w
if (!pathFrom) {
pathFrom = el.attr('pathFrom')
}
if (!pathTo) {
pathTo = el.attr('pathTo')
}
const disableAnimationForCorrupPath = () => {
if (w.config.chart.type === 'radar') {
// radar chart drops the path to bottom and hence a corrup path looks ugly
// therefore, disable animation for such a case
speed = 1
}
return `M 0 ${w.layout.gridHeight}`
}
if (
!pathFrom ||
pathFrom.indexOf('undefined') > -1 ||
pathFrom.indexOf('NaN') > -1
) {
pathFrom = disableAnimationForCorrupPath()
}
if (
!pathTo.trim() ||
pathTo.indexOf('undefined') > -1 ||
pathTo.indexOf('NaN') > -1
) {
pathTo = disableAnimationForCorrupPath()
}
if (!w.globals.shouldAnimate) {
speed = 1
}
el.plot(pathFrom)
.animate(1, delay)
.plot(pathFrom)
.animate(speed, delay)
.plot(pathTo)
.after(() => {
// a flag to indicate that the original mount function can return true now as animation finished here
if (Utils.isNumber(j)) {
if (
j ===
w.seriesData.series[w.globals.maxValsInArrayIndex].length - 2 &&
w.globals.shouldAnimate
) {
this.animationCompleted(el)
}
} else if (fill !== 'none' && w.globals.shouldAnimate) {
if (
(!w.globals.comboCharts &&
realIndex === w.seriesData.series.length - 1) ||
w.globals.comboCharts
) {
this.animationCompleted(el)
}
}
this.showDelayedElements()
})
}
}