apexcharts
Version:
A JavaScript Chart Library
383 lines (362 loc) • 13.9 kB
JavaScript
// @ts-check
/**
* Data-label transitions for data-change updates.
*
* Two behaviors, both bar/column only:
* - dataLabels.animate : a surviving label RIDES to its new position on a
* reorder/update instead of snapping, on the same clock and easing as the
* bar morph. ON by default: the bars, the markers and the axis ticks all
* already reflow together, so a label that jumps to its final slot on the
* first frame arrives several hundred ms before the bar it labels.
* - dataLabels.countUp : a surviving label's numeric value tweens from its
* previous value to the new one, like countUp.js. OFF by default: it
* changes the number on screen mid-flight, which is a deliberate choice
* (the bar chart race) rather than a default. The user's formatter runs
* each frame, so number formatting is preserved.
*
* Labels carry no persistent DOM identity across the full teardown/rebuild an
* update performs, so the bar renderer stamps each label group with
* `data:dlKey` (= `${realIndex}::${datumKey}`), `data:dlJ`, and `data:dlVal`.
* Stacked totals are NOT inside those groups (they hang off the series-wide
* label wrap) and track the top of the whole stack rather than any one segment,
* so they carry their own `data:dlTotalKey` / `data:dlTotalVal` and are tweened
* separately. captureDataLabels() snapshots the outgoing frame's positions and
* values by key right before teardown; applyDataLabelTransition() matches the
* incoming labels back and tweens position and/or value from old to new.
*
* Both halves run on the full render path AND on fastUpdate. A same-shape
* updateSeries, which is the most common update there is, only ever reaches the
* latter.
*
* Deliberately best-effort: any missing counterpart, unparseable coordinate, or
* thrown formatter leaves that label exactly where the renderer put it.
*
* @module modules/animations/DataLabelTransition
*/
import { Environment } from '../../utils/Environment'
import Utils from '../../utils/Utils'
import { lengthTransitionEnabled, morphEasing, rafTween } from './LengthTransition'
const DL_GROUP_SEL = '.apexcharts-data-labels[data\\:dlKey]'
const DL_TEXT_SEL = '.apexcharts-datalabel'
const DL_TOTAL_SEL = '.apexcharts-datalabel-total[data\\:dlTotalKey]'
/**
* @param {import('../../types/internal').ChartStateW} w
* @returns {boolean}
*/
function dataLabelMotionEnabled(w) {
const dl = w.config.dataLabels
return !!(dl?.animate?.enabled || dl?.countUp?.enabled)
}
/**
* Decimal places of a number (capped), so a count-up on integer data renders
* clean integers with the default formatter rather than long floats.
*
* @param {number} n
* @returns {number}
*/
export function decimalsOf(n) {
if (!isFinite(n)) return 0
const s = String(Math.abs(n))
// Exponential notation (e.g. "1e-7", "1.5e-7") carries no '.', so the plain
// indexOf below would report 0 decimals and a count-up on a tiny value would
// render "0". Derive the decimal count from the mantissa + exponent instead.
const e = s.indexOf('e')
if (e !== -1) {
const mantissa = s.slice(0, e)
const exp = parseInt(s.slice(e + 1), 10)
const dot = mantissa.indexOf('.')
const mantissaDec = dot === -1 ? 0 : mantissa.length - dot - 1
return Math.min(6, Math.max(0, mantissaDec - exp))
}
const dot = s.indexOf('.')
return dot === -1 ? 0 : Math.min(6, s.length - dot - 1)
}
/**
* Write a string into a data-label text node, honoring a tspan child when the
* renderer produced one (multi-line), else the plain text content.
*
* @param {Element} textEl
* @param {string} s
*/
function writeLabel(textEl, s) {
const tspan = textEl.querySelector('tspan')
if (tspan) tspan.textContent = s
else textEl.textContent = s
}
/**
* Snapshot the outgoing render's bar/column data labels by datum key. Called
* from Series.getPreviousPaths(), i.e. before the DOM is torn down for the
* incoming update. No-op unless a label-motion feature is on.
*
* @param {import('../../types/internal').ChartStateW} w
*/
export function captureDataLabels(w) {
const gl = w.globals
gl.prevDataLabels = null
if (!gl.axisCharts || !Environment.isBrowser()) return
if (!dataLabelMotionEnabled(w)) return
const root = w.dom.baseEl
if (!Utils.elementExists(root)) return
try {
/** @type {Map<string, {cx: number, cy: number, val: number}>} */
const map = new Map()
root.querySelectorAll(DL_GROUP_SEL).forEach((group) => {
const key = group.getAttribute('data:dlKey')
if (!key) return
const textEl = group.querySelector(DL_TEXT_SEL)
if (!textEl) return
map.set(key, {
cx: parseFloat(textEl.getAttribute('cx') || ''),
cy: parseFloat(textEl.getAttribute('cy') || ''),
val: parseFloat(group.getAttribute('data:dlVal') || ''),
})
})
// Stacked totals hang off the series-wide label wrap rather than a keyed
// per-segment group, so they are tracked in their own map.
root.querySelectorAll(DL_TOTAL_SEL).forEach((el) => {
const key = el.getAttribute('data:dlTotalKey')
if (!key) return
map.set(`total::${key}`, {
cx: parseFloat(el.getAttribute('cx') || ''),
cy: parseFloat(el.getAttribute('cy') || ''),
val: parseFloat(el.getAttribute('data:dlTotalVal') || ''),
})
})
gl.prevDataLabels = map.size ? map : null
} catch (_) {
gl.prevDataLabels = null
}
}
/**
* @param {import('../../types/internal').ChartStateW} w
* @param {Element} node
* @param {number} duration
* @param {(t: number) => number} ease
*/
function fadeIn(w, node, duration, ease) {
const style = /** @type {any} */ (node).style
style.opacity = '0'
rafTween(
w,
duration,
ease,
(eased) => {
style.opacity = String(eased)
},
() => {
style.opacity = ''
},
)
}
/**
* Ride an element from where its counterpart sat last frame to where the
* renderer has just put it: offset it by the old-minus-new delta and tween that
* offset to zero, composed over whatever transform it already carries (the bar
* label groups carry a rotate). No-op for a move under half a pixel.
*
* The element's own `cx`/`cy` attributes are the anchor on both sides, so this
* works for a group (whose label text carries them) and for a bare text node.
*
* @param {import('../../types/internal').ChartStateW} w
* @param {{el: Element, oldCx: number, oldCy: number, duration: number, ease: (t: number) => number, delay?: number}} opts
*/
function rideTo(w, { el, oldCx, oldCy, duration, ease, delay = 0 }) {
const anchor = el.hasAttribute('cx') ? el : el.querySelector(DL_TEXT_SEL)
if (!anchor) return
const dx = oldCx - parseFloat(anchor.getAttribute('cx') || '')
const dy = oldCy - parseFloat(anchor.getAttribute('cy') || '')
if (!isFinite(dx) || !isFinite(dy)) return
if (Math.abs(dx) + Math.abs(dy) <= 0.5) return
const base = el.getAttribute('transform') || ''
const start = () =>
rafTween(
w,
duration,
ease,
(eased) => {
const t = 1 - eased
el.setAttribute('transform', `translate(${dx * t} ${dy * t}) ${base}`.trim())
},
() => {
if (base) el.setAttribute('transform', base)
else el.removeAttribute('transform')
},
)
if (delay > 0) {
// Its bar starts this much later (the per-bar stagger), so hold the label
// at its previous spot until then; the pair then move on one clock.
el.setAttribute('transform', `translate(${dx} ${dy}) ${base}`.trim())
setTimeout(() => {
if (w.globals.isDestroyed) return
start()
}, delay)
} else {
start()
}
}
/**
* Tween one label's number from `from` to `to`, re-running the user's formatter
* every frame so decimals, separators and prefixes survive the count. No-op
* when either end is not a finite number or the value did not change.
*
* @param {import('../../types/internal').ChartStateW} w
* @param {{el: Element, from: number, to: number, formatter: any, fmtOpts: any, duration: number, ease: (t: number) => number, delay?: number}} opts
*/
function countUpText(w, { el, from, to, formatter, fmtOpts, duration, ease, delay = 0 }) {
if (!isFinite(from) || !isFinite(to)) return
if (Math.abs(to - from) <= 1e-9) return
const dec = Math.max(decimalsOf(from), decimalsOf(to))
/** @param {number} v */
const format = (v) => {
const rounded = Number(v.toFixed(dec))
let out = rounded
if (typeof formatter === 'function') {
try {
out = formatter(rounded, fmtOpts)
} catch (_) {
out = rounded
}
}
return String(out)
}
const start = () =>
rafTween(
w,
duration,
ease,
(eased) => {
writeLabel(el, format(from + (to - from) * eased))
},
() => {
// Land exactly on the rendered final string.
writeLabel(el, format(to))
},
)
if (delay > 0) {
// Same hold as the position ride: show the old value until the bar moves.
writeLabel(el, format(from))
setTimeout(() => {
if (w.globals.isDestroyed) return
start()
}, delay)
} else {
start()
}
}
/**
* Animate bar/column data labels after a data-change re-render: surviving
* labels ride from their old position to the new one and/or count their value
* up, entering labels fade in. Consumes the captured frame (one shot per
* update); a no-op unless this is an animated update with a captured frame.
*
* @param {import('../../types/internal').ChartStateW} w
*/
export function applyDataLabelTransition(w) {
const gl = w.globals
const prev = gl.prevDataLabels
gl.prevDataLabels = null
if (!prev || !gl.axisCharts || !Environment.isBrowser()) return
if (!dataLabelMotionEnabled(w)) return
if (!lengthTransitionEnabled(w)) return
const root = w.dom.baseEl
if (!Utils.elementExists(root)) return
const dl = w.config.dataLabels
const ride = !!dl.animate?.enabled
const countUp = !!dl.countUp?.enabled
const formatter = dl.formatter
const duration = Math.max(1, w.config.chart.animations.dynamicAnimation.speed || 1)
const ease = morphEasing(w)
try {
root.querySelectorAll(DL_GROUP_SEL).forEach((group) => {
const key = group.getAttribute('data:dlKey')
if (!key) return
const textEl = group.querySelector(DL_TEXT_SEL)
if (!textEl) return
const old = prev.get(key)
// The bar renderer stamps its own stagger delay so the label waits for
// ITS bar; without it, a staggered update had every label land on one
// clock while the later bars had not even started (last category's label
// arrived a full stagger-spread early).
const delay = parseInt(group.getAttribute('data:dlDelay') || '0', 10) || 0
// Position ride: shift the whole label group (text + background) via an
// added translate composed over its existing rotate, tweened to zero.
if (ride) {
if (old && isFinite(old.cx) && isFinite(old.cy)) {
rideTo(w, {
el: group,
oldCx: old.cx,
oldCy: old.cy,
duration,
ease,
delay,
})
} else if (!old) {
// Entering label: no previous position to ride from, so fade it in.
fadeIn(w, group, duration, ease)
}
}
// Count-up: tween the numeric value, re-running the formatter per frame.
if (countUp && old) {
const realIndex = parseInt(key, 10)
const j = parseInt(group.getAttribute('data:dlJ') || '', 10)
countUpText(w, {
el: textEl,
from: old.val,
to: parseFloat(group.getAttribute('data:dlVal') || ''),
formatter,
// The formatter opts don't change between tween frames (only the
// value does), so build them once per label instead of spreading all
// of `w` on every frame. Same shape the bar formatter gets.
fmtOpts: {
...w,
seriesIndex: realIndex,
dataPointIndex: isFinite(j) ? j : 0,
w,
},
duration,
ease,
delay,
})
}
})
// Stacked totals. Not inside a keyed per-segment group (they hang off the
// series-wide label wrap), and they track the top of the whole stack rather
// than any one segment, so they ride their own delta and count up on their
// own numbers, with the total formatter when one is configured.
const totalFormatter =
w.config.plotOptions.bar.dataLabels.total.formatter || formatter
root.querySelectorAll(DL_TOTAL_SEL).forEach((el) => {
const key = el.getAttribute('data:dlTotalKey')
if (!key) return
const old = prev.get(`total::${key}`)
if (!old) return
// Totals track the top of the whole stack, and every layer of column j
// shares one stagger delay on updates, so the stamped delay is the
// stack's delay too.
const delay = parseInt(el.getAttribute('data:dlDelay') || '0', 10) || 0
if (ride && isFinite(old.cx) && isFinite(old.cy)) {
rideTo(w, { el, oldCx: old.cx, oldCy: old.cy, duration, ease, delay })
}
if (countUp) {
// The key's first segment is the GROUP index (stable across legend
// toggles); the series that drew the total travels in its own attr.
const realIndex = parseInt(
el.getAttribute('data:dlTotalSeries') || key,
10,
)
countUpText(w, {
el,
from: old.val,
to: parseFloat(el.getAttribute('data:dlTotalVal') || ''),
formatter: totalFormatter,
fmtOpts: { ...w, seriesIndex: realIndex, dataPointIndex: 0, w },
duration,
ease,
delay,
})
}
})
} catch (_) {
// Label polish must never break a render.
}
}