UNPKG

apexcharts

Version:

A JavaScript Chart Library

536 lines (508 loc) 18.5 kB
// @ts-check /** * Axis-chrome transitions for variable-length data updates. * * When a datum enters or exits, the series marks reflow smoothly (see * LengthTransition / PathReconcile) but the chrome around them: x-axis tick * labels, y-axis tick labels, gridlines: used to re-render instantly at the * new layout, so even a perfect mark reflow played against a snapped ruler. * * This module captures the outgoing render's tick geometry right before * teardown (label text + position, gridline positions aligned to those * labels) and, after the new render mounts, matches new ticks to old ones BY * TEXT: matched labels and their gridlines tween from the old position to the * new one on the same clock and easing as the series morph; unmatched (new) * labels and gridlines fade in. Removed ticks simply are not re-rendered. * * Deliberately best-effort: rotated labels are left alone (their rotation * transform bakes in the position), and gridlines are only paired when their * count matches the tick-label count on both sides. Any bail-out leaves the * new chrome exactly where the renderer put it. * * @module modules/animations/AxisTransition */ import { Environment } from '../../utils/Environment' import Utils from '../../utils/Utils' import { lengthTransitionEnabled, morphEasing, rafTween, seriesJoin, } from './LengthTransition' /** * @param {Element} root * @param {string} sel * @param {string} posAttr * @returns {{text: string, display: string, pos: number, transform: string | null}[]} */ function grabLabels(root, sel, posAttr) { return [...root.querySelectorAll(sel)].map((el) => ({ // `text` is the matching KEY (textContent, which doubles tspan + title // but does so consistently on both sides); `display` is the visible // string, used when rendering an exit ghost. text: el.textContent || '', display: el.querySelector('tspan')?.textContent ?? el.textContent ?? '', pos: parseFloat(el.getAttribute(posAttr) || ''), transform: el.getAttribute('transform'), })) } /** * @param {Element} root * @param {string} sel * @param {string} posAttr * @returns {number[]} */ function grabLines(root, sel, posAttr) { return [...root.querySelectorAll(sel)].map((el) => parseFloat(el.getAttribute(posAttr) || ''), ) } // Exit ghosts spawned by a previous transition must never be captured or // matched as real ticks. const NO_GHOST = ':not(.apexcharts-tick-ghost)' const X_LABELS_SEL = `.apexcharts-xaxis-texts-g text:not(.apexcharts-xaxis-group-label)${NO_GHOST}` const Y_LABELS_SEL = `.apexcharts-yaxis-texts-g text${NO_GHOST}` const V_GRID_SEL = `.apexcharts-gridlines-vertical line${NO_GHOST}` const H_GRID_SEL = `.apexcharts-gridlines-horizontal line${NO_GHOST}` /** * The x-axis value scale of the current render, when the label/gridline * coordinate space is affine in the data value: numeric/datetime axes where * `pos(v) = (v - min) / (max - min) * gridWidth`. Category axes (sentinel * bounds) and reversed axes return null, which limits the transition to * text matching + fades exactly as before. * * @param {import('../../types/internal').ChartStateW} w * @returns {{min: number, max: number, width: number} | null} */ function currentXScale(w) { const gl = w.globals if (!w.axisFlags?.isXNumeric || gl.isBarHorizontal) return null if (w.config.xaxis?.reversed) return null const min = gl.minX const max = gl.maxX const width = w.layout.gridWidth if (!isFinite(min) || !isFinite(max) || !(max > min) || !(width > 0)) { return null } return { min, max, width } } /** * The y-axis value anchors of the current render: the value range plus the * pixel positions of the outermost tick labels (which absorbs any constant * label offset, and handles reversed axes for free). Single non-log y axis * only; anything else falls back to text matching. * * @param {import('../../types/internal').ChartStateW} w * @param {{pos: number}[]} labels - grabbed y labels of this render * @returns {{min: number, max: number, pLo: number, pHi: number} | null} */ function currentYAnchors(w, labels) { const gl = w.globals if (gl.isBarHorizontal) return null if (!Array.isArray(w.config.yaxis) || w.config.yaxis.length !== 1) return null if (w.config.yaxis[0]?.logarithmic) return null const min = gl.minY const max = gl.maxY if (!isFinite(min) || !isFinite(max) || !(max > min)) return null const ps = labels.map((l) => l.pos).filter((p) => isFinite(p)) if (ps.length < 2) return null // min value renders at the LARGEST y (bottom), max at the smallest. return { min, max, pLo: Math.max(...ps), pHi: Math.min(...ps) } } /** * Compose old->new / new->old pixel maps for the x axis from its bounds. * * @param {{min: number, max: number, width: number} | null | undefined} o * @param {{min: number, max: number, width: number} | null} n * @returns {{toNew: (p: number) => number, toOld: (p: number) => number} | null} */ function composeXMap(o, n) { if (!o || !n) return null const os = o.max - o.min const ns = n.max - n.min if (!(os > 0) || !(ns > 0) || !(o.width > 0) || !(n.width > 0)) return null return { toNew: (p) => ((o.min + (p / o.width) * os - n.min) / ns) * n.width, toOld: (p) => ((n.min + (p / n.width) * ns - o.min) / os) * o.width, } } /** * Compose old->new / new->old pixel maps for the y axis from tick anchors. * * @param {{min: number, max: number, pLo: number, pHi: number} | null | undefined} o * @param {{min: number, max: number, pLo: number, pHi: number} | null} n * @returns {{toNew: (p: number) => number, toOld: (p: number) => number} | null} */ function composeYMap(o, n) { if (!o || !n) return null const oSpanP = o.pHi - o.pLo const nSpanP = n.pHi - n.pLo const oSpanV = o.max - o.min const nSpanV = n.max - n.min if (!oSpanP || !nSpanP || !(oSpanV > 0) || !(nSpanV > 0)) return null /** @param {number} p */ const oldVal = (p) => o.min + ((p - o.pLo) / oSpanP) * oSpanV /** @param {number} p */ const newVal = (p) => n.min + ((p - n.pLo) / nSpanP) * nSpanV return { toNew: (p) => n.pLo + ((oldVal(p) - n.min) / nSpanV) * nSpanP, toOld: (p) => o.pLo + ((newVal(p) - o.min) / oSpanV) * oSpanP, } } /** * Snapshot the outgoing render's axis chrome. Called from * Series.getPreviousPaths() alongside the path/frame captures, i.e. before * the DOM is torn down for the incoming update. * * @param {import('../../types/internal').ChartStateW} w */ export function captureAxisChrome(w) { const gl = w.globals gl.prevChromeFrame = null if (!gl.axisCharts || !Environment.isBrowser()) return const root = w.dom.baseEl if (!Utils.elementExists(root)) return try { const yLabels = grabLabels(root, Y_LABELS_SEL, 'y') gl.prevChromeFrame = { xLabels: grabLabels(root, X_LABELS_SEL, 'x'), yLabels, vGrid: grabLines(root, V_GRID_SEL, 'x1'), hGrid: grabLines(root, H_GRID_SEL, 'y1'), // Value scales of the outgoing render, so ticks whose TEXT has no // counterpart (e.g. a zoom across datetime granularities) can still be // re-projected: new ticks slide in from where their value sat, old // ticks ghost out to where their value lands. xScale: currentXScale(w), yAnchors: currentYAnchors(w, yLabels), } } catch (_) { gl.prevChromeFrame = 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 = '' }, ) } /** * @param {import('../../types/internal').ChartStateW} w * @param {Element} node * @param {string[]} attrs - attributes to drive (e.g. ['x'] or ['x1', 'x2']) * @param {number} from * @param {number} to * @param {number} duration * @param {(t: number) => number} ease */ function tweenPos(w, node, attrs, from, to, duration, ease) { attrs.forEach((a) => node.setAttribute(a, String(from))) rafTween( w, duration, ease, (eased) => { const v = String(from + (to - from) * eased) attrs.forEach((a) => node.setAttribute(a, v)) }, () => { attrs.forEach((a) => node.setAttribute(a, String(to))) }, ) } /** * Spawn a ghost of an outgoing tick label (or gridline): a clone that slides * to the projected new position of its value while fading out, then removes * itself. Ghosts carry `.apexcharts-tick-ghost` so captures, matching, and * later transitions never see them, plus pointer-events none. * * @param {import('../../types/internal').ChartStateW} w * @param {{ * template: Element, * display?: string, * attrs: string[], * from: number, * to: number, * duration: number, * ease: (t: number) => number, * }} opts */ function spawnGhost(w, { template, display, attrs, from, to, duration, ease }) { const parent = template.parentNode if (!parent) return const ghost = /** @type {Element} */ (template.cloneNode(true)) ghost.classList.add('apexcharts-tick-ghost') ghost.setAttribute('pointer-events', 'none') ghost.removeAttribute('id') if (display !== undefined) { const tspan = ghost.querySelector('tspan') if (tspan) tspan.textContent = display else ghost.textContent = display const title = ghost.querySelector('title') if (title) title.textContent = display } attrs.forEach((a) => ghost.setAttribute(a, String(from))) const style = /** @type {any} */ (ghost).style style.opacity = '1' parent.appendChild(ghost) rafTween( w, duration, ease, (eased) => { const v = String(from + (to - from) * eased) attrs.forEach((a) => ghost.setAttribute(a, v)) style.opacity = String(1 - eased) }, () => { if (ghost.parentNode) ghost.parentNode.removeChild(ghost) }, ) } // Zooming far out can retire many ticks at once; cap the ghost fan-out. const MAX_GHOSTS = 20 /** * Match new tick labels to captured ones by text, tweening matches and * fading in the rest. When a value-space projection exists (numeric/datetime * axes), unmatched NEW ticks additionally slide in from where their value * sat in the old render, and unmatched OLD ticks ghost out to where their * value lands: the cross-granularity zoom case, where no texts match. When * the gridline count equals the label count on a side, each gridline rides * with its label. * * @param {import('../../types/internal').ChartStateW} w * @param {{ * newLabels: Element[], * oldLabels: {text: string, display: string, pos: number, transform: string | null}[], * posAttr: string, * newLines: Element[], * oldLines: number[], * lineAttrs: string[], * duration: number, * ease: (t: number) => number, * project?: {toNew: (p: number) => number, toOld: (p: number) => number} | null, * }} opts */ function transitionAxis( w, { newLabels, oldLabels, posAttr, newLines, oldLines, lineAttrs, duration, ease, project, }, ) { const oldByText = new Map() oldLabels.forEach((l, i) => { if (!oldByText.has(l.text)) oldByText.set(l.text, { ...l, i }) }) const matchedOld = new Set() const newLinesAligned = newLines.length === newLabels.length const oldLinesAligned = oldLines.length === oldLabels.length // Projected positions can land thousands of px off-axis on a deep zoom. // The axis groups are not clipped, so clamp travel to just beyond the // rendered span: visually identical (elements are near-transparent by the // time they cross the edge) and nothing ever paints far outside the chart. const spanPs = oldLabels .map((l) => l.pos) .concat( newLabels.map((l) => parseFloat(l.getAttribute(posAttr) || '')), ) .filter((p) => isFinite(p)) const spanLo = Math.min(...spanPs) const spanHi = Math.max(...spanPs) const margin = Math.max(40, (spanHi - spanLo) * 0.25) /** @param {number} p */ const clamp = (p) => Math.max(spanLo - margin, Math.min(spanHi + margin, p)) /** * Ride a paired gridline from its old to its new position (gridlines are * never rotated, so a plain attribute tween always applies). * @param {Element | null} line * @param {{i: number}} old */ const tweenPairedLine = (line, old) => { if (!line) return const lineTo = parseFloat(line.getAttribute(lineAttrs[0]) || '') const lineFrom = oldLines[old.i] if (isFinite(lineTo) && isFinite(lineFrom)) { tweenPos(w, line, lineAttrs, lineFrom, lineTo, duration, ease) } } newLabels.forEach((label, i) => { const to = parseFloat(label.getAttribute(posAttr) || '') const old = oldByText.get(label.textContent || '') const line = newLinesAligned ? newLines[i] : null if (old) matchedOld.add(old.i) const labelTransform = label.getAttribute('transform') // Entering tick (no text match): slide in from its value's projected old // position (numeric/datetime axes only) and fade up. if (!old || !isFinite(old.pos)) { if (!old) { if (project && isFinite(to) && !labelTransform) { const from = isFinite(project.toOld(to)) ? clamp(project.toOld(to)) : NaN if (isFinite(from) && Math.abs(from - to) > 0.5) { tweenPos(w, label, [posAttr], from, to, duration, ease) if (line) tweenPos(w, line, lineAttrs, from, to, duration, ease) } } fadeIn(w, label, duration, ease) if (line) fadeIn(w, line, duration, ease) } return } if (!isFinite(to) || Math.abs(old.pos - to) < 0.5) return if (labelTransform || old.transform) { // Rotated label (e.g. a vertical column chart's x-axis categories): the // position is baked behind a rotate() transform, so driving the x/y // attribute would fight it. Instead ride via a leading translate composed // in front of the existing rotate and tweened to zero — the glyph keeps // its rotation and slides along the axis (same trick the data-label ride // uses). Applied for any transformed match, so rotated ticks also move on // zoom / length-change updates, not just reorders. const delta = old.pos - to if (isFinite(delta)) { const base = labelTransform || '' rafTween( w, duration, ease, (eased) => { const v = delta * (1 - eased) const t = posAttr === 'x' ? `translate(${v} 0)` : `translate(0 ${v})` label.setAttribute('transform', `${t} ${base}`.trim()) }, () => { if (base) label.setAttribute('transform', base) else label.removeAttribute('transform') }, ) } tweenPairedLine(line, old) return } tweenPos(w, label, [posAttr], old.pos, to, duration, ease) tweenPairedLine(line, old) }) // Exit ghosts: outgoing ticks whose text found no new counterpart slide to // their value's projected position while fading out. if (!project || !newLabels.length) return let ghosts = 0 oldLabels.forEach((old, i) => { if (matchedOld.has(i)) return if (!isFinite(old.pos) || old.transform) return if (ghosts >= MAX_GHOSTS) return const rawTo = project.toNew(old.pos) if (!isFinite(rawTo) || Math.abs(rawTo - old.pos) < 0.5) return ghosts++ spawnGhost(w, { template: newLabels[0], display: old.display, attrs: [posAttr], from: old.pos, to: clamp(rawTo), duration, ease, }) if (oldLinesAligned && newLines.length && isFinite(oldLines[i])) { spawnGhost(w, { template: newLines[0], attrs: lineAttrs, from: oldLines[i], to: clamp(project.toNew(oldLines[i])), duration, ease, }) } }) } /** * Animate the axis chrome after a variable-length re-render. Consumes the * captured frame (one shot per update); a no-op unless this update is an * animated layout change for at least one series. * * @param {import('../../types/internal').ChartStateW} w */ export function applyAxisTransition(w) { const gl = w.globals const chrome = gl.prevChromeFrame gl.prevChromeFrame = null if (!chrome || !gl.axisCharts || !Environment.isBrowser()) return if (!lengthTransitionEnabled(w)) return // Any animated data-change render qualifies, including identity joins // (zoom re-projections, same-length value updates) and pure reorders (a // "bar chart race" swap): matched ticks slide, new ones fade in, and // unchanged chrome is a per-label no-op (the sub-half-pixel skip below). // allowReorder keeps the gate open on a reorder so labels ride the swap the // same way the keyed bar morph already does; the text-match tween below is // order-agnostic, so no reconciliation assumption is violated. const anyMotion = (w.seriesData.series || []).some( (_, i) => seriesJoin(w, i, true, true) !== null, ) if (!anyMotion) return const root = w.dom.baseEl if (!Utils.elementExists(root)) return const duration = Math.max(1, w.config.chart.animations.dynamicAnimation.speed || 1) const ease = morphEasing(w) try { const newYLabels = [...root.querySelectorAll(Y_LABELS_SEL)] // Value-space projections (null on category/reversed/multi-axis/log, // where the transition stays text-match + fade). const projX = composeXMap(chrome.xScale, currentXScale(w)) const projY = composeYMap( chrome.yAnchors, currentYAnchors( w, newYLabels.map((el) => ({ pos: parseFloat(el.getAttribute('y') || ''), })), ), ) transitionAxis(w, { newLabels: [...root.querySelectorAll(X_LABELS_SEL)], oldLabels: chrome.xLabels, posAttr: 'x', newLines: [...root.querySelectorAll(V_GRID_SEL)], oldLines: chrome.vGrid, lineAttrs: ['x1', 'x2'], duration, ease, project: projX, }) transitionAxis(w, { newLabels: newYLabels, oldLabels: chrome.yLabels, posAttr: 'y', newLines: [...root.querySelectorAll(H_GRID_SEL)], oldLines: chrome.hGrid, lineAttrs: ['y1', 'y2'], duration, ease, project: projY, }) } catch (_) { // Chrome polish must never break a render. } }