apexcharts
Version:
A JavaScript Chart Library
2,009 lines • 79.3 kB
JavaScript
// @ts-check
import { Environment } from '../utils/Environment.js'
import { BrowserAPIs } from '../ssr/BrowserAPIs.js'
import { prefersReducedMotion } from './Animations'
import { parsePath } from '../svg/PathMorphing'
import {
gridDivideRect,
gridDivideShape,
makeColorLerp,
runPieceTween,
sortByHilbert,
} from './MorphPieces'
/**
* Cross-chart-type morphing.
*
* Bridges the destroy+recreate flicker that normally happens when
* `updateOptions({ chart: { type: '<other>' } })` is called. Captures the
* outgoing chart's series-element `d` strings before destroy and feeds them
* back to the new chart-type's renderer as the initial path — the existing
* `morphPaths` engine in svg/SVGAnimation interpolates between the two.
*
* Supported pairs:
* - bar ↔ pie / donut / polarArea / radialBar
* - pie ↔ donut ↔ polarArea (trivial, same renderer)
*
* Strict data-shape contract: the user must pass a series shape that matches
* the destination type. When the shape is incompatible the morph is skipped
* and the chart falls back to the normal destroy+recreate flow.
*
* This is an OPTIONAL feature module — register it via
* `import 'apexcharts/features/morph'` to opt in. When unregistered, all
* `ctx.morphTypeChange?.X` call sites in the renderers no-op via optional
* chaining and the chart behaves exactly as before.
*/
// funnel + pyramid are rendered by Bar.js internally (Config.js aliases them
// to `chart.type: 'bar'` with `plotOptions.bar.isFunnel: true, horizontal:
// true`). gauge is aliased to radialBar. Treating them as members of the
// bar / radial families lets the morph engine accept them as source or
// target without any renderer-side changes.
//
// histogram is the same kind of alias: Config maps it to `bar` and keeps the
// requested name on `chart.requestedType`, which is what the capture reads
// (see UpdateHelpers). Every mark it draws is an ordinary bar path, so it
// needs no capture branch of its own — only membership. A histogram bar
// stands for the observations it counted, which makes it the one bar the unit
// pair is literally true of.
const BAR_FAMILY = new Set(['bar', 'funnel', 'pyramid', 'histogram'])
const RADIAL_FAMILY = new Set(['pie', 'donut', 'polarArea', 'radialBar', 'gauge'])
// unit (dot-cluster / pictogram) morphs BOTH ways: a bar/radial shape comes
// apart into the objects it stood for (each leaving from the part of the shape
// that represented it, see getInitialSlotFor), and a dot cloud collapses back
// into the mark that aggregates it (the incoming mark grows out of the cloud's
// own footprint, see the `unit` branch of _captureFromDOM). As a target the
// renderer reads per-object slots rather than a path `d`, so its dots come out
// of the outgoing bar/wedge instead of gathering from the plot centre.
//
// waffle is an alias for the unit chart's square-grid layout (Config maps it to
// `unit`), so it draws the same `.apexcharts-unit-area` dots the capture reads
// and belongs to the same family. Without the alias here a waffle could morph
// with nothing at all, including with the `unit` chart it already is.
const UNIT_FAMILY = new Set(['unit', 'waffle'])
// Space-filling part-to-whole charts. A treemap tile and a sunburst arc are
// both exactly one mark per row, so this pair is an ordinary shape-to-shape
// morph: no explode/collapse, just rectangles unrolling into a radial partition
// and back. They map to each other by draw order (see the `partition` branch of
// _buildMapping) because neither renderer iterates the (realIndex, j) grid the
// bar family does.
const PARTITION_FAMILY = new Set(['treemap', 'sunburst'])
// Distribution summaries: one mark per category, each standing for a whole
// sample (a five-number box, a density silhouette). They are drawn through
// Bar's renderSeries but under their own classes, so unlike `histogram` they
// cannot join BAR_FAMILY and inherit its capture; they get their own branch in
// _captureFromDOM. What makes them worth pairing is the same thing that makes
// a histogram worth pairing: the sample behind each mark still exists, so the
// mark can genuinely come apart into it (see RowSourceRegistry).
const SUMMARY_FAMILY = new Set(['boxPlot', 'violin'])
// How much of the morph the outgoing marks get to fade over. Short of the full
// duration on purpose (see _mountGhost). Since the piece layer landed the
// ghost is the FALLBACK exit, used when pieces are infeasible (too many
// objects, a source family without piece support, a bailed mount).
const GHOST_FADE_FRACTION = 0.55
// Above this many objects the piece layer stands down and the ghost fade runs
// instead. Every piece is a live SVG node driven per frame; a unit chart's own
// gather animation moves this many dots comfortably, and past it the overlay
// would cost more than the effect is worth.
const PIECE_BUDGET = 1500
// The per-piece stagger never eats more than this many ms (or 35% of the
// morph, whichever is smaller): pieces sweep rather than march, but the last
// one still lands within the configured speed.
const PIECE_STAGGER_MAX = 300
/** @param {string} type */
function familyOf(type) {
if (BAR_FAMILY.has(type)) return 'bar'
if (RADIAL_FAMILY.has(type)) return 'radial'
if (UNIT_FAMILY.has(type)) return 'unit'
if (PARTITION_FAMILY.has(type)) return 'partition'
if (SUMMARY_FAMILY.has(type)) return 'summary'
return null
}
export default class MorphTypeChange {
/**
* @param {import('../types/internal').ChartStateW} w
* @param {import('../types/internal').ChartContext} ctx
*/
constructor(w, ctx) {
this.w = w
this.ctx = ctx
/** @type {null | { fromType: string, toType: string, mapping: Map<string, {d: string, fill: string|null}>, oldLayout: { translateX: number, translateY: number }, pieceOut?: boolean, pieceIn?: boolean, sourceDots?: Map<number, Array<{x:number,y:number,r:number,fill:string|null}>>, keyOrder?: string[] }} */
this._snapshot = null
// A detached copy of the outgoing marks, kept alive across the teardown so
// marks with no successor can still exit. Null whenever none is in flight.
/** @type {any} */
this._ghost = null
// The live piece overlay: its <g> element and the cancel handle of the
// rAF loop driving it. Null whenever no pieces are in flight.
/** @type {any} */
this._pieceLayer = null
/** @type {null | (() => void)} */
this._pieceCancel = null
}
/**
* @param {string} fromType
* @param {string} toType
* @returns {boolean}
*/
canMorphTypes(fromType, toType) {
if (fromType === toType) return false
const ff = familyOf(fromType)
const tf = familyOf(toType)
if (!ff || !tf) return false
// Every family but one draws exactly one mark per row, so any two of them
// pair up as an ordinary shape morph: the mapping is positional and each
// mark simply becomes the next shape of itself. bar ↔ radial, treemap ↔
// pie, a box plot ↔ a column, and every within-family pair besides.
//
// The unit family is the exception, in both directions: its marks are the
// OBJECTS a mark stood for, so the pair is an explode or a collapse rather
// than a shape change, and it needs the piece layer to conserve the ink.
// That layer can cut a bar, a summary silhouette and a wedge; it has no
// capture for a treemap tile or a sunburst arc, so unit ↔ partition would
// fall back to the whole-chart fade. A morph that only crossfades is worse
// than not offering the pair, so it stays closed until the divider learns
// those two shapes.
if ((ff === 'partition') !== (tf === 'partition')) {
return ff !== 'unit' && tf !== 'unit'
}
return true
}
/**
* @param {string} fromType
* @param {string} toType
* @param {any} newSeries
* @returns {boolean}
*/
isCompatibleSeriesShape(fromType, toType, newSeries) {
if (!Array.isArray(newSeries) || newSeries.length === 0) return false
const ff = familyOf(fromType)
const tf = familyOf(toType)
if (tf === 'unit') {
// A unit chart is one SERIES per cluster, each carrying that cluster's
// objects: [{ name, data: [datum, ...] }, ...]. Requiring a single series
// (as the radial branch below does) rejected every multi-cluster unit
// chart, which is the ordinary shape and the whole point of the pair.
if (newSeries.every((v) => typeof v === 'number')) return true
return newSeries.every(
(/** @type {any} */ s) =>
s && typeof s === 'object' && Array.isArray(s.data),
)
}
if (tf === 'partition') {
// A treemap takes [{ data: [...] }, ...]; a sunburst takes either that
// (with a `children` tree or a drilldown config alongside) or the flat
// form. The mapping is by draw order, so any non-empty series is workable
// and the renderer decides how many marks it draws.
return true
}
if (tf === 'radial') {
// pie/donut/polarArea/radialBar accept either a flat number[] or the
// single-series object form [{ data: [...] }] that the pie/donut data
// parser also accepts. The mapping is positional, so the exact value
// shape is irrelevant.
if (newSeries.every((v) => typeof v === 'number')) return true
return (
newSeries.length === 1 &&
newSeries[0] &&
typeof newSeries[0] === 'object' &&
Array.isArray(newSeries[0].data)
)
}
if (tf === 'bar' || tf === 'summary') {
// bar expects [{ name?, data: number[] }, ...]; boxPlot and violin take
// the same envelope with richer datums, and the mapping is positional
// either way, so the datum shape is the renderer's business.
return newSeries.every(
(s) => s && typeof s === 'object' && Array.isArray(s.data),
)
}
return ff !== null && tf !== null
}
/**
* Capture the live DOM of the *current* (outgoing) chart and stash it on
* this module. Called from `apexcharts._updateOptions` before the config
* merge that flips `chart.type`.
*
* Returns true if a morph is queued — caller doesn't need the value, but
* tests use it.
*
* @param {{ fromType: string, toType: string, newSeries: any }} args
* @returns {boolean}
*/
captureBeforeDestroy({ fromType, toType, newSeries }) {
this._snapshot = null
// A second type change while the previous exit is still in flight: drop it
// now rather than leave two dead charts stacked over the live one.
this._removeGhost()
this._cancelPieces()
if (!Environment.isBrowser()) return false
const animCfg = this.w.config.chart.animations
if (!animCfg || animCfg.enabled === false) return false
if (animCfg.chartTypeMorph && animCfg.chartTypeMorph.enabled === false)
return false
if (animCfg.respectReducedMotion && prefersReducedMotion()) return false
if (!this.canMorphTypes(fromType, toType)) return false
if (!this.isCompatibleSeriesShape(fromType, toType, newSeries)) return false
const { marks, branches, unitDots } = this._captureFromDOM(fromType)
if (!marks.length) return false
const mapping = this._buildMapping(
marks,
fromType,
toType,
newSeries,
branches,
)
if (mapping.size === 0) return false
// Capture the OLD chart's elGraphical translate so getInitialPathFor can
// shift the morphFrom `d` into the OLD chart's screen coordinates. Without
// this, the path appears in the NEW chart's translate group and gets a
// visible position jump at t=0 (e.g. bar reserves yaxis space → its
// translateX differs from radialBar's). At capture time, this.w still
// reflects the outgoing chart's layout.
this._snapshot = {
fromType,
toType,
mapping,
oldLayout: {
translateX: this.w.layout.translateX || 0,
translateY: this.w.layout.translateY || 0,
},
}
// Piece eligibility, decided now so the incoming renderer can coordinate
// (a unit chart holds its dots for the pieces to land on; a bar renders
// hidden until its mosaic assembles). Both directions conserve the ink:
// instead of a photocopy of the old chart fading over the new one, the
// outgoing marks are cut into one cell per object (or the outgoing dots
// fly to one cell each) and the pieces travel. The ghost below survives as
// the fallback for what pieces cannot serve.
const ff = familyOf(fromType)
const tf = familyOf(toType)
// A wedge can only be cut where the ink is, which needs path hit-testing;
// without it the cells would be a rectangle stamped over a circle, so the
// radial pairs keep the fade rather than lie about their geometry.
const canShape = this._canProbePaths()
const pieceFamilies =
ff === 'bar' || ff === 'summary' || (ff === 'radial' && canShape)
if (tf === 'unit' && pieceFamilies) {
// mark -> objects. The object count comes from the incoming series (one
// datum per dot in the object form); the numeric form scales values by
// unitValue and cannot be counted here, so it keeps the burst + ghost.
const total = this._countUnitSeries(newSeries)
this._snapshot.pieceOut = total > 0 && total <= PIECE_BUDGET
} else if (
ff === 'unit' &&
(tf === 'bar' || tf === 'summary' || (tf === 'radial' && canShape))
) {
// objects -> mark. The dots were just captured, so the count is exact.
let total = 0
unitDots.forEach((list) => {
total += list.length
})
if (total > 0 && total <= PIECE_BUDGET) {
this._snapshot.pieceIn = true
this._snapshot.sourceDots = unitDots
// Flat mark order of the incoming chart, the same derivation
// _buildMapping uses, so cluster k pairs with keyOrder[k].
/** @type {string[]} */
const keyOrder = []
if (tf === 'radial') {
// A radial series is one flat value per slice, and its marks are
// keyed the way the radial capture keys them: slice index, j = 0.
;(Array.isArray(newSeries) ? newSeries : []).forEach(
(/** @type {any} */ _v, /** @type {number} */ i) => {
keyOrder.push(`${i}:0`)
},
)
} else {
;(Array.isArray(newSeries) ? newSeries : []).forEach(
(/** @type {any} */ s, /** @type {number} */ seriesIdx) => {
const data = s && Array.isArray(s.data) ? s.data : []
for (let j = 0; j < data.length; j++) {
keyOrder.push(`${seriesIdx}:${j}`)
}
},
)
}
this._snapshot.keyOrder = keyOrder
}
}
// Marks with no successor need an exit, and the outgoing chart is torn
// down before the incoming one draws its first frame, so the only way to
// give them one is to take a copy now while it is still mounted. When the
// piece layer will run, the copy is never mounted, so skip taking it.
if (
this._needsGhost(fromType, toType) &&
!this._snapshot.pieceOut &&
!this._snapshot.pieceIn
) {
this._captureGhost()
}
// Clear w.globals.previousPaths so the destination chart's renderer
// doesn't try to read entries from the outgoing chart (which would be
// shaped wrong and produce NaN).
this.w.globals.previousPaths = []
return true
}
/**
* Whether the outgoing marks need an exit animation of their own.
*
* Most pairs do not. bar → pie hands every wedge the exact `d` of the bar it
* replaces, and treemap → sunburst does the same for its tiles: the outgoing
* mark IS the incoming mark's first frame, so it never needs to leave, and
* drawing a copy of it would only double the image at t=0.
*
* The unit pairs are the exception, in both directions, because the
* correspondence is not 1:1. Going in, one bar becomes N dots, so the bar has
* no successor to become. Coming out, N dots become one bar: the bar does
* grow from the cloud's footprint, but no individual dot has anywhere to go.
* Either way something on screen simply stops existing, which is exactly the
* hard cut that made these pairs read as "the old chart vanished and the new
* one animated" rather than as a morph.
*
* @param {string} fromType
* @param {string} toType
* @returns {boolean}
*/
_needsGhost(fromType, toType) {
return familyOf(fromType) === 'unit' || familyOf(toType) === 'unit'
}
/**
* Take a detached copy of the outgoing chart's marks, to be mounted over the
* incoming chart once it exists (see `_mountGhost`).
*
* The whole `<svg>` is cloned and the chrome then removed from the copy,
* rather than lifting the series groups out on their own: every mark's
* position depends on the transforms of the groups above it, and cloning
* from the root is what keeps those intact without re-deriving any geometry.
*
* The chrome is dropped because `applyChromeFade` already fades the incoming
* axes, grid and legend in from zero. Keeping the outgoing set as well would
* put two sets of axis labels on screen at half opacity each.
*/
_captureGhost() {
/** @type {any} */
const paper = this.w.dom?.Paper
const node = paper && paper.node
if (!node || typeof node.cloneNode !== 'function') return
/** @type {any} */
const clone = node.cloneNode(true)
// Everything that is not a series mark. The tooltip and toolbar live
// outside the svg, so they are not in the clone to begin with.
const drop = [
'.apexcharts-xaxis',
'.apexcharts-yaxis',
'.apexcharts-grid',
'.apexcharts-gridlines-horizontal',
'.apexcharts-gridlines-vertical',
'.apexcharts-legend',
'.apexcharts-title-text',
'.apexcharts-subtitle-text',
'.apexcharts-annotations',
'.apexcharts-zoom-rect',
'.apexcharts-selection-rect',
'.apexcharts-xcrosshairs',
'.apexcharts-ycrosshairs',
]
if (typeof clone.querySelectorAll === 'function') {
drop.forEach((sel) => {
clone.querySelectorAll(sel).forEach((/** @type {any} */ el) => {
if (el.parentNode) el.parentNode.removeChild(el)
})
})
}
// An id collision would let the live chart resolve a gradient or clip-path
// against the dead one's defs, so the copy keeps no ids at all.
if (typeof clone.querySelectorAll === 'function') {
clone.querySelectorAll('[id]').forEach((/** @type {any} */ el) => {
el.removeAttribute('id')
})
}
clone.removeAttribute?.('id')
this._ghost = clone
}
/**
* Mount the captured copy over the newly-rendered chart and fade it out.
*
* It goes ON TOP of the live svg, which is what makes both directions read
* as one motion rather than as a swap. Going into a unit chart the bars
* dissolve and the dots are uncovered already in flight, having left from
* inside the bar that held them. Coming out of one, the dots are still there
* to fade while the bar grows underneath them; behind the incoming mark they
* would be hidden on the first frame, because that mark starts out exactly
* the size of the cloud it is replacing.
*
* The fade runs over a fraction of the morph so the outgoing marks are gone
* before the incoming ones settle. Holding them for the full duration leaves
* two charts overlapping right at the moment the eye is reading the final
* shape, which looks like a rendering fault rather than a transition.
*/
_mountGhost() {
const ghost = this._ghost
if (!ghost || !Environment.isBrowser()) return
/** @type {any} */
const wrap = this.w.dom?.elWrap
if (!wrap || typeof wrap.appendChild !== 'function') {
this._ghost = null
return
}
// .apexcharts-canvas is position:relative, so this lands exactly over the
// live svg. It must never take a pointer event: the chart underneath owns
// every hover and click for the whole transition, and it must contribute
// nothing but its marks, so the cloned background goes too.
const style = ghost.style
if (style) {
style.position = 'absolute'
style.left = '0'
style.top = '0'
style.background = 'transparent'
style.pointerEvents = 'none'
style.opacity = '1'
}
ghost.setAttribute?.('aria-hidden', 'true')
ghost.setAttribute?.('class', 'apexcharts-morph-ghost')
wrap.appendChild(ghost)
const speed = this.getSpeed()
const fade = Math.max(120, Math.round(speed * GHOST_FADE_FRACTION))
BrowserAPIs.requestAnimationFrame(() => {
if (!style) return
// ease-in, so the outgoing marks hold briefly at full strength before
// going. An ease-out drops most of the opacity in the first few frames,
// which reads as a flicker rather than as coming apart.
style.transition = `opacity ${fade}ms ease-in`
style.opacity = '0'
})
setTimeout(() => this._removeGhost(), fade + 60)
}
/** Detach the ghost if one is mounted. Safe to call at any time. */
_removeGhost() {
const ghost = this._ghost
this._ghost = null
if (ghost && ghost.parentNode) ghost.parentNode.removeChild(ghost)
}
/* ------------------------------------------------------------------ *
* The piece layer (see MorphPieces for the geometry).
* ------------------------------------------------------------------ */
/**
* Object count of an incoming unit series: one datum per dot in the object
* form. The numeric form ([3, 5]) scales values by `plotOptions.unit
* .unitValue`, which is not resolvable pre-merge, so it counts as zero and
* keeps the burst-and-ghost behaviour.
*
* @param {any} newSeries
* @returns {number}
*/
_countUnitSeries(newSeries) {
if (!Array.isArray(newSeries)) return 0
let total = 0
for (const s of newSeries) {
if (!s || typeof s !== 'object' || !Array.isArray(s.data)) return 0
total += s.data.length
}
return total
}
/**
* Whether the incoming unit chart should hold its dots for the piece layer:
* render them at their final slots, hidden, and let the pieces do the
* flying. The reveal happens per dot as its piece lands.
*
* Consulted by the unit renderer during its draw, which runs after
* `captureBeforeDestroy` and before `applyChromeFade`, so the decision was
* already made from the same series the renderer is now drawing.
*
* @returns {boolean}
*/
usesPieceTakeover() {
return !!(this._snapshot && this._snapshot.pieceOut)
}
/**
* Whether the piece layer claims the incoming mark at (realIndex, j): a
* source cluster's dots will fly to it and tile it, so it must render
* hidden and reveal only when its mosaic is complete. Consulted by the bar
* renderer (boxPlot and violin render through it).
*
* @param {number|string} realIndex
* @param {number|string} j
* @returns {boolean}
*/
claimsTargetMark(realIndex, j) {
return !!(
this._snapshot &&
this._snapshot.pieceIn &&
this._snapshot.mapping.has(`${realIndex}:${j}`)
)
}
/**
* Create the overlay group the pieces are driven in. It lives INSIDE the
* new chart's elGraphical so every coordinate matches the marks' own local
* space, and it never takes a pointer event.
* @returns {any} the <g> node, or null
*/
_makePieceLayer() {
/** @type {any} */
const graph = this.w.dom?.elGraphical
const host = graph && graph.node
if (!host || typeof host.appendChild !== 'function') return null
const g = BrowserAPIs.createElementNS('http://www.w3.org/2000/svg', 'g')
if (!g) return null
g.setAttribute('class', 'apexcharts-morph-pieces')
g.setAttribute('pointer-events', 'none')
// The marks the pieces stand in for are grid-clipped, so the pieces must
// be too: a violin's ink stops at the plot edge, and the mosaic tiling it
// may not stick out below the axis. The bar mask is the expanded one the
// bar family itself clips to.
const cuid = this.w.globals?.cuid
if (cuid) g.setAttribute('clip-path', `url(#gridRectBarMask${cuid})`)
host.appendChild(g)
this._pieceLayer = g
return g
}
/**
* Reveal everything a piece takeover hid, whether or not the pieces ran.
* The attribute is plain (no namespace colon) so it stays selectable
* everywhere.
*/
_revealPieceHidden() {
/** @type {any} */
const baseEl = this.w.globals.dom?.baseEl
if (!baseEl || typeof baseEl.querySelectorAll !== 'function') return
baseEl.querySelectorAll('[data-piece-hidden]').forEach(
(/** @type {any} */ el) => {
el.removeAttribute('opacity')
el.removeAttribute('data-piece-hidden')
},
)
}
/** Stop the piece run, drop the overlay, and reveal anything still hidden. */
_cancelPieces() {
if (this._pieceCancel) {
this._pieceCancel()
this._pieceCancel = null
}
const layer = this._pieceLayer
this._pieceLayer = null
if (layer && layer.parentNode) layer.parentNode.removeChild(layer)
this._revealPieceHidden()
}
/**
* Whether this environment can hit-test path geometry at all. Decided
* before the ghost clone is taken, because a family whose cells are only
* honest when probed (radial) must keep the fade rather than fall back to a
* rectangular grid it cannot justify. jsdom answers no.
* @returns {boolean}
*/
_canProbePaths() {
if (!Environment.isBrowser()) return false
const probe = BrowserAPIs.createElementNS(
'http://www.w3.org/2000/svg',
'path',
)
return !!probe && typeof (/** @type {any} */ (probe).isPointInFill) === 'function'
}
/**
* A per-band ink prober over a mark's path, for gridDivideShape: given a
* major-axis band it measures where the mark actually has ink across the
* minor axis, so a violin's cells follow its density outline, a boxPlot's
* whisker rows collapse to slivers, and a wedge's rows stop at the wedge
* instead of spanning the bounding box (which stamped a rectangle over the
* mark at frame one).
*
* The probe path is mounted (hidden) inside the piece layer so its user
* space is exactly the space the cells are laid out in. The stroke test
* catches zero-area subpaths (a boxPlot's whisker line has no fill to
* hit), and its width is what a whisker's slivers will measure.
*
* Returns null when the environment cannot hit-test path geometry (jsdom);
* the caller then keeps the plain grid.
*
* @param {string} d - path data, in piece-layer coordinates
* @param {{x:number,y:number,width:number,height:number}} bbox
* @param {any} layer - the mounted piece layer
* @returns {{ intervalsAt: (bandLo: number, bandHi: number, horizontal: boolean) => Array<[number, number]> | null, dispose: () => void } | null}
*/
_makeExtentProber(d, bbox, layer) {
const doc = layer.ownerDocument
const probe = doc.createElementNS('http://www.w3.org/2000/svg', 'path')
probe.setAttribute('d', d)
probe.setAttribute('fill', '#000')
probe.setAttribute('stroke', '#000')
probe.setAttribute('stroke-width', '3')
probe.setAttribute('visibility', 'hidden')
layer.appendChild(probe)
const svg = probe.ownerSVGElement
if (
typeof (/** @type {any} */ (probe).isPointInFill) !== 'function' ||
!svg ||
typeof svg.createSVGPoint !== 'function'
) {
layer.removeChild(probe)
return null
}
const pt = svg.createSVGPoint()
/** @param {number} x @param {number} y */
const hit = (x, y) => {
pt.x = x
pt.y = y
const p = /** @type {any} */ (probe)
return (
p.isPointInFill(pt) ||
(typeof p.isPointInStroke === 'function' && p.isPointInStroke(pt))
)
}
// Samples across the minor axis per probe line. Fine enough to catch a
// thin ring (a donut's arm at its narrowest) and the exact centre, which
// is where a centred whisker line lives.
const SCAN = 48
/**
* Every interval of ink a band crosses, not merely its outermost extent:
* a wedge can cross twice and a donut ring crosses twice over most of its
* height, and treating those as one interval would fill the hole.
*
* @param {number} bandLo
* @param {number} bandHi
* @param {boolean} horizontal
* @returns {Array<[number, number]> | null}
*/
const intervalsAt = (bandLo, bandHi, horizontal) => {
const lo = horizontal ? bbox.y : bbox.x
const hi = lo + (horizontal ? bbox.height : bbox.width)
if (!(hi > lo)) return null
/** @param {number} v @param {number} major */
const at = (v, major) => (horizontal ? hit(major, v) : hit(v, major))
// Three probe lines across the band, unioned: the silhouette can widen
// inside a band, and sampling only its centre line would undercut the
// widest row.
const majors = [
bandLo + (bandHi - bandLo) * 0.1,
(bandLo + bandHi) / 2,
bandHi - (bandHi - bandLo) * 0.1,
]
/**
* Bisect the ink edge between a known hit and a known miss, on whichever
* probe line found the hit.
* @param {number} inside @param {number} outside @param {number} major
*/
const edge = (inside, outside, major) => {
let a = outside
let b = inside
for (let it = 0; it < 6; it++) {
const m = (a + b) / 2
if (at(m, major)) b = m
else a = m
}
return (a + b) / 2
}
// Union the three lines sample by sample, keeping which line proved each
// hit so the edge refinement asks the line that can answer.
const step = (hi - lo) / SCAN
/** @type {Array<number|null>} */
const proof = new Array(SCAN + 1).fill(null)
let any = false
for (const major of majors) {
for (let s = 0; s <= SCAN; s++) {
if (proof[s] !== null) continue
if (at(lo + s * step, major)) {
proof[s] = major
any = true
}
}
}
if (!any) return null
/** @type {Array<[number, number]>} */
const out = []
let runStart = -1
for (let s = 0; s <= SCAN + 1; s++) {
const inside = s <= SCAN && proof[s] !== null
if (inside && runStart < 0) runStart = s
if (!inside && runStart >= 0) {
const last = s - 1
const major = /** @type {number} */ (proof[runStart])
const left =
runStart === 0
? lo
: edge(lo + runStart * step, lo + (runStart - 1) * step, major)
const right =
last === SCAN
? hi
: edge(
lo + last * step,
lo + (last + 1) * step,
/** @type {number} */ (proof[last]),
)
if (right > left) out.push([left, right])
runStart = -1
}
}
return out.length ? out : null
}
return {
intervalsAt,
dispose: () => {
if (probe.parentNode) probe.parentNode.removeChild(probe)
},
}
}
/**
* mark -> objects. Cut each captured mark into one cell per dot and fly
* every cell to its dot, corners rounding off and fill blending on the way.
* The real dots (rendered hidden by the unit chart, see usesPieceTakeover)
* are revealed one by one as their piece lands, so the handoff is
* geometrically exact and nothing ever fades.
*/
_separatePieces() {
const snap = this._snapshot
/** @type {any} */
const baseEl = this.w.globals.dom?.baseEl
if (!snap || !baseEl) return this._revealPieceHidden()
// The live dots, grouped by cluster. They exist and are hidden: the unit
// renderer drew them at their final slots before this ran.
/** @type {Map<number, Array<{el:any,x:number,y:number,r:number,fill:string|null}>>} */
const byCluster = new Map()
let total = 0
baseEl
.querySelectorAll('.apexcharts-unit-area')
.forEach((/** @type {any} */ dot) => {
const i = parseInt(dot.getAttribute('i') ?? '', 10)
if (isNaN(i)) return
const cxAttr = dot.getAttribute('cx')
let x
let y
let r = 3
if (cxAttr != null) {
x = parseFloat(cxAttr)
y = parseFloat(dot.getAttribute('cy') ?? '')
r = parseFloat(dot.getAttribute('r') ?? '3') || 3
} else {
const wAttr = parseFloat(dot.getAttribute('width') ?? '0') || 0
const hAttr = parseFloat(dot.getAttribute('height') ?? '0') || 0
x = parseFloat(dot.getAttribute('x') ?? '') + wAttr / 2
y = parseFloat(dot.getAttribute('y') ?? '') + hAttr / 2
r = Math.max(wAttr, hAttr) / 2 || 3
}
if (!isFinite(x) || !isFinite(y)) return
let list = byCluster.get(i)
if (!list) {
list = []
byCluster.set(i, list)
}
list.push({ el: dot, x, y, r, fill: dot.getAttribute('fill') })
total++
})
if (total === 0 || total > PIECE_BUDGET) return this._revealPieceHidden()
const layer = this._makePieceLayer()
if (!layer) return this._revealPieceHidden()
/** @type {import('./MorphPieces').Piece[]} */
const pieces = []
const doc = layer.ownerDocument
// A bar IS its bounding box, so the plain grid tiles it exactly. A
// summary mark (violin, boxPlot) is a silhouette inside its box, and
// cutting the box would stamp a rectangle over the curve at frame one:
// its cells follow the measured ink instead.
const sourceFam = familyOf(snap.fromType)
const shapedSource = sourceFam === 'summary' || sourceFam === 'radial'
Array.from(byCluster.keys())
.sort((a, b) => a - b)
.forEach((i) => {
const dots = /** @type {any[]} */ (byCluster.get(i))
const box = this.getInitialBBoxFor(i)
const entry = snap.mapping.get(`${i}:0`)
if (!box || !entry) {
// No outgoing mark stood for this cluster (an entering series): the
// dots have nothing to come out of, so just show them.
dots.forEach((d) => {
d.el.removeAttribute('opacity')
d.el.removeAttribute('data-piece-hidden')
})
return
}
// A gradient fill arrives as url(#...); the series colour is the
// honest solid stand-in.
const markFill =
entry.fill && entry.fill.indexOf('url(') !== 0
? entry.fill
: this.w.globals.colors?.[i] || dots[0].fill
let prober = null
if (shapedSource) {
const shifted = this.getInitialPathFor(i, 0)
if (shifted) prober = this._makeExtentProber(shifted, box, layer)
}
const divided = prober
? gridDivideShape(box, dots.length, prober.intervalsAt)
: gridDivideRect(box, dots.length)
if (prober) prober.dispose()
const cells = sortByHilbert(divided, (c) => [
c.x + c.width / 2,
c.y + c.height / 2,
])
const ordered = sortByHilbert(dots, (d) => [d.x, d.y])
for (let k = 0; k < ordered.length; k++) {
const cell = cells[k]
const dot = ordered[k]
const el = doc.createElementNS('http://www.w3.org/2000/svg', 'rect')
// Which cluster this piece serves; diagnostics and tests key on it.
el.setAttribute('data-i', String(i))
el.setAttribute('x', String(cell.x))
el.setAttribute('y', String(cell.y))
el.setAttribute('width', String(cell.width))
el.setAttribute('height', String(cell.height))
el.setAttribute('rx', '0')
el.setAttribute('fill', String(markFill))
layer.appendChild(el)
pieces.push({
el,
from: { x: cell.x, y: cell.y, width: cell.width, height: cell.height, rx: 0 },
to: {
x: dot.x - dot.r,
y: dot.y - dot.r,
width: dot.r * 2,
height: dot.r * 2,
rx: dot.r,
},
fill: makeColorLerp(markFill, dot.fill),
fillEnd: dot.fill,
delay: 0,
meta: { dotEl: dot.el },
})
}
})
if (!pieces.length) return this._cancelPieces()
this._runPieces(pieces, (piece) => {
// The dot appears exactly where its piece just was, then the piece goes:
// a swap, not a fade.
const dotEl = piece.meta.dotEl
dotEl.removeAttribute('opacity')
dotEl.removeAttribute('data-piece-hidden')
if (piece.el.parentNode) piece.el.parentNode.removeChild(piece.el)
})
}
/**
* objects -> mark. Each captured outgoing dot flies to one cell of the
* incoming mark, squaring off and blending towards the mark's fill; the
* mark itself (rendered hidden, see claimsTargetMark) is revealed the
* moment its last piece lands and the mosaic is complete, which is also the
* moment the seams disappear.
*/
_combinePieces() {
const snap = this._snapshot
/** @type {any} */
const baseEl = this.w.globals.dom?.baseEl
if (!snap || !snap.sourceDots || !snap.keyOrder || !baseEl) {
return this._revealPieceHidden()
}
// The incoming marks, keyed `${realIndex}:${j}` with every path element
// that makes the mark up (a boxPlot draws two per category).
const targets = this._collectTargetMarks(snap.toType)
if (!targets.size) return this._revealPieceHidden()
// Old-chart dot coordinates shift into the new chart's local space the
// same way every captured path does (see getInitialPathFor).
const dx = snap.oldLayout.translateX - (this.w.layout.translateX || 0)
const dy = snap.oldLayout.translateY - (this.w.layout.translateY || 0)
const clusterIdx = Array.from(snap.sourceDots.keys()).sort((a, b) => a - b)
const layer = this._makePieceLayer()
if (!layer) return this._revealPieceHidden()
const doc = layer.ownerDocument
/** @type {import('./MorphPieces').Piece[]} */
const pieces = []
// Same silhouette rule as the separate direction: a mosaic assembling a
// violin should build the violin's outline, not its bounding rectangle.
const targetFam = familyOf(snap.toType)
const shapedTarget = targetFam === 'summary' || targetFam === 'radial'
for (let k = 0; k < clusterIdx.length; k++) {
const dots = /** @type {any[]} */ (snap.sourceDots.get(clusterIdx[k]))
const key = snap.keyOrder[k]
const target = key ? targets.get(key) : null
if (!target || !dots || !dots.length) {
// A mark with no incoming dots (or dots with no mark) has no pieces;
// whatever was hidden for it must still show.
if (target) {
target.els.forEach((/** @type {any} */ el) => {
el.removeAttribute('opacity')
el.removeAttribute('data-piece-hidden')
})
}
continue
}
const markFill =
target.fill && target.fill.indexOf('url(') !== 0
? target.fill
: this.w.globals.colors?.[target.realIndex] || dots[0].fill
let prober = null
if (shapedTarget) {
// The mark's final geometry, straight off its (hidden) elements. A
// boxPlot is more than one path per mark, so probe them as one; a
// radial mark carries the final path the renderer stamped, since its
// live `d` is still wherever its own animation left it.
const d =
target.d ||
target.els
.map(
(/** @type {any} */ p) =>
p.getAttribute('pathTo') || p.getAttribute('d'),
)
.filter(Boolean)
.join(' ')
if (d) prober = this._makeExtentProber(d, target.bbox, layer)
}
const divided = prober
? gridDivideShape(target.bbox, dots.length, prober.intervalsAt)
: gridDivideRect(target.bbox, dots.length)
if (prober) prober.dispose()
const cells = sortByHilbert(divided, (c) => [
c.x + c.width / 2,
c.y + c.height / 2,
])
const ordered = sortByHilbert(dots, (d) => [d.x, d.y])
// Landed-piece bookkeeping: the mark reveals when ALL of its pieces are
// down, and its tiles leave together, so the mosaic holds until the
// moment it becomes the solid mark.
const markState = { remaining: ordered.length, els: target.els, tiles: /** @type {any[]} */ ([]) }
for (let m = 0; m < ordered.length; m++) {
const dot = ordered[m]
const cell = cells[m]
const el = doc.createElementNS('http://www.w3.org/2000/svg', 'rect')
const fx = dot.x + dx - dot.r
const fy = dot.y + dy - dot.r
// Which incoming mark this piece tiles; diagnostics and tests key on it.
el.setAttribute('data-key', key)
el.setAttribute('x', String(fx))
el.setAttribute('y', String(fy))
el.setAttribute('width', String(dot.r * 2))
el.setAttribute('height', String(dot.r * 2))
el.setAttribute('rx', String(dot.r))
el.setAttribute('fill', String(dot.fill || markFill))
layer.appendChild(el)
markState.tiles.push(el)
pieces.push({
el,
from: { x: fx, y: fy, width: dot.r * 2, height: dot.r * 2, rx: dot.r },
to: { x: cell.x, y: cell.y, width: cell.width, height: cell.height, rx: 0 },
fill: makeColorLerp(dot.fill, markFill),
fillEnd: String(markFill),
delay: 0,
meta: { markState },
})
}
}
if (!pieces.length) return this._cancelPieces()
this._runPieces(pieces, (piece) => {
const state = piece.meta.markState
state.remaining--
if (state.remaining === 0) {
state.els.forEach((/** @type {any} */ el) => {
el.removeAttribute('opacity')
el.removeAttribute('data-piece-hidden')
})
state.tiles.forEach((/** @type {any} */ t) => {
if (t.parentNode) t.parentNode.removeChild(t)
})
}
})
}
/**
* Stagger and start a piece run. Delays sweep the (already spatially
* sorted) list front to back, and the last piece still lands within the
* configured morph speed.
*
* @param {import('./MorphPieces').Piece[]} pieces
* @param {(piece: import('./MorphPieces').Piece) => void} onPieceDone
*/
_runPieces(pieces, onPieceDone) {
const speed = this.getSpeed()
const stagger = Math.min(PIECE_STAGGER_MAX, speed * 0.35)
const flight = Math.max(180, speed - stagger)
for (let k = 0; k < pieces.length; k++) {
pieces[k].delay = pieces.length > 1 ? (k / (pieces.length - 1)) * stagger : 0
}
this._pieceCancel = runPieceTween({
pieces,
duration: flight,
onPieceDone,
onAllDone: () => {
this._pieceCancel = null
this._cancelPieces()
},
})
}
/**
* The incoming chart's marks, read live: every `path[pathTo]` grouped into
* one mark per (realIndex, j), with the union bbox of its final geometry.
* Bar marks are one path each; summary marks (boxPlot, violin) may be
* several, walked exactly like the capture branch walks the outgoing ones.
*
* @param {string} toType
* @returns {Map<string, { realIndex: number, j: number, bbox: {x:number,y:number,width:number,height:number}, fill: string|null, els: any[], d?: string }>}
*/
_collectTargetMarks(toType) {
/** @type {any} */
const baseEl = this.w.globals.dom?.baseEl
/** @type {Map<string, any>} */
const out = new Map()
if (!baseEl) return out
const fam = familyOf(toType)
if (fam === 'radial') {
// A slice's live `d` is wherever its own animation currently has it, so
// the geometry comes from the final path the renderer stamps for us
// (data:pathFinal). Keys match the radial capture: slice index, j = 0.
baseEl
.querySelectorAll('.apexcharts-pie-series .apexcharts-pie-area')
.forEach((/** @type {any} */ p, /** @type {number} */ i) => {
const d = p.getAttribute('data:pathFinal') || p.getAttribute('d')
if (!d || !d.trim()) return
const box = this._pathBBox(d)
if (!box) return
out.set(`${i}:0`, {
realIndex: i,
j: 0,
d,
bbox: {
x: box.minX,
y: box.minY,
width: box.maxX - box.minX,
height: box.maxY - box.minY,
},
fill: p.getAttribute('fill'),
els: [p],
})
})
return out
}
const wrapClass =
fam === 'summary'
? `.apexcharts-${toType}-series`
: '.apexcharts-bar-series'
baseEl
.querySelectorAll(`${wrapClass} .apexcharts-series`)
.forEach((/** @type {any} */ group) => {
const realIndex =
parseInt(group.getAttribute('data:realIndex') ?? '0', 10) || 0
let order = 0
group
.querySelectorAll('path[pathTo]')
.forEach((/** @type {any} */ p) => {
const d = p.getAttribute('pathTo') || p.getAttribute('d')
if (!d || !d.trim()) return
// Summary paths carry their category on `j`; bar paths are read in
// draw order, which is the same order the mapping was keyed in.
const jAttr = parseInt(p.getAttribute('j') ?? '', 10)
const j = isNaN(jAttr) ? order++ : jAttr
const box = this._pathBBox(d)
if (!box) return
const key = `${realIndex}:${j}`
const prev = out.get(key)
if (prev) {
prev.els.push(p)
prev.bbox = {
x: Math.min(prev.bbox.x, box.minX),
y: Math.min(prev.bbox.y, box.minY),
width:
Math.max(prev.bbox.x + prev.bbox.width, box.maxX) -
Math.min(prev.bbox.x, box.minX),
height:
Math.max(prev.bbox.y + prev.bbox.height, box.maxY) -
Math.min(prev.bbox.y, box.minY),
}
} else {
out.set(key, {
realIndex,
j,
bbox: {
x: box.minX,
y: box.minY,
width: box.maxX - box.minX,
height: box.maxY - box.minY,
},
fill: p.getAttribute('fill'),
els: [p],
})
}
})
})
return out
}
/**
* Walk the outgoing chart's DOM and collect path `d` strings keyed by
* (realIndex, j). The selectors are scoped to the chart family — bar
* elements have `pathTo` set; pie/radial elements use their final `d`.
*
* @param {string} fromType
* @returns {{ marks: Array<{ realIndex: number, j: number, d: string, fill: string|null, key?: string|null }>, branches: Array<{ key: string, d: string, fill: string|null }>, unitDots: Map<number, Array<{x:number,y:number,r:number,fill:string|null}>> }}
*/
_captureFromDOM(fromType) {
/** @type {any} */
const baseEl = this.w.globals.dom?.baseEl
if (!baseEl) return { marks: [], branches: [], unitDots: new Map() }
/** @type {Array<{ realIndex: number, j: number, d: string, fill: string|null, key?: string|null }>} */
const captured = []
// Non-leaf marks (treemap containers / sunburst inner rings). Only usable
// when both charts carry branch keys, so they travel separately.
/** @type {Array<{ key: string, d: string, fill: string|null }>} */
const branches = []
// Per-dot records when the outgoing chart is a unit chart, keyed by
// cluster index. Only the unit branch fills this.
/** @type {Map<number, Array<{x:number,y:number,r:number,fill:string|null}>>} */
const unitDots = new Map()
const fam = familyOf(fromType)
if (fam === 'bar') {
const seriesNodes = baseEl.querySelectorAll(
'.apexcharts-bar-series .apexcharts-series',
)
seriesNodes.forEach((/** @type {Element} */ seriesNode) => {
const realIndex = parseInt(
seriesNode.getAttribute('data:realIndex') ?? '0',
10,
)
const paths = seriesNode.querySelectorAll('path[pathTo]')
paths.forEach((/** @type {Element} */ p, /** @type {number} */ j) => {
const d = p.getAttribute('pathTo') || p.getAttribute('d')
if (!d) return
captured.push({
realIndex,
j,
d,
fill: p.getAttribute('fill'),
})
})
})
} else if (fam === 'summary') {
// A summary mark is not reliably one path. Measured: a boxPlot draws TWO
// per category (the box and the whisker line) and a violin draws one, and
// neither stamps the `i` attribute the bar branch reads, so the series
// index comes off the enclosing group instead.
//
// The paths belonging to one mark are concatenated into a single
// multi-subpath `d`, which makes _pathBBox return the union of their
// extents for free. Taking only the first would give an explode covering
// the box but not its whiskers.
//
// The unit pair never asks the combined path to be a shape: it reads
// slots off the bounding box. The summary pair does interpolate it, and
// the concatenation is still what we want there, because a whole box
// INCLUDING its whiskers is what has to become the density curve. The
// resample walks both subpaths, so the whiskers are absorbed into the
// silhouette rather than vanishing at the first frame; the seam between
// them shows briefly as a thin sliver, which is a fair price for
// conserving the ink.
/** @type {Map<string, {realIndex: number, j: number, d: string, fill: string|null}>} */
const byMark = new Map()
baseEl
.querySelectorAll(`.apexcharts-${fromType}-area`)
.forEach((/** @type {any} */ p) => {
const j = parseInt(p.getAttribute('j') ?? '', 10)
if (isNaN(j)) return
const d = p.getAttribute('pathTo') || p.getAttribute('d')
if (!d || !d.trim()) return
const group =
typeof p.closest === 'function' ? p.closest('.apexcharts-series') : null
const realIndex =
parseInt(group?.getAttribute('data:realIndex') ?? '0', 10) || 0
const key = `${realIndex}:${j}`
const prev = byMark.get(key)
if (prev) prev.d += ` ${d}`
else byMark.set(key, { realIndex, j, d, fill: p.getAttribute('fill') })
})
Array.from(byMark.values())
.sort((a, b) => a.realIndex - b.realIndex || a.j - b.j)
.forEach((m) => captured.push(m))
} else if (fam === 'partition') {
if (fromType === 'treemap') {
// Tiles are <rect>s, and the morph engine interpolates path data, so
// each one is emitted as the equivalent closed rectangle path.
//
// Containers are captured too, so a nested treemap can hand a sunburst
// its inner rings rather than leaving them to appear from nothing. They
// only pair up when both sides carry a branch key (see _buildMapping);
// a positional fallback would misalign every leaf, so containers are
// kept in a separate list and dropped unless keys are usable.
/** @type {(el: Element) => string|null} */
const rectPath = (el) => {
const x = parseFloat(el.getAttribute('x') ?? '')
const y = parseFloat(el.getAttribute('y') ?? '')
const width = parseFloat(el.getAttribute('width') ?? '')
const height = parseFloat(el.getAttribute('height') ?? '')
if (![x, y, width, height].every((v) => isFinite(v))) return null
return `M ${x} ${y} L ${x + width} ${y} L ${x + width} ${y + height} L ${x} ${y + height} Z`
}
const tiles = baseEl.querySelectorAll('.apexcharts-treemap-rect')
tiles.forEach((/** @type {Element} */ t) => {
const d = rectPath(t)
if (!d) return
captured.push({
realIndex: parseInt(t.getAttribute('i') ?? '0', 10) || 0,
j: parseInt(t.getAttribute('j') ?? '0', 10) || 0,
d,
fill: t.getAttribute('fill'),
key: t.getAttribute('data:key') || null,
})
})
const containers = baseEl.querySelectorAll(
'.apexcharts-treemap-parent-rect',
)
containers.forEach((/** @type {Element} */ c) => {
const d = rectPath(c)
const key = c.getAttribute('data:key')
if (!d || !key) return
branches.push({ key, d, fill: c.getAttribute('fill') })
})
} else {
// Sunburst arcs are already paths. Only LEAVES are captured: they are
// the level a flat partition can correspond to, and taking every ring
// would hand a treemap's tiles their own ancestors.
const arcs = baseEl.querySelectorAll('.apexcharts-sunburst-arc')
/** @type {Element[]} */
const leaves = []
arcs.forEach((/** @type {Element} */ a) => {
if (a.getAttribute('data:leaf') === 'true') leaves.push(a)
})
const source = leaves.length ? leaves : Array.from(arcs)
source.forEach((/** @type {Element} */ a, /** @type {number} */ i) => {
const d = a.getAttribute('d')
// A zoomed-out or collapsed arc carries an empty `d`.
if (!d || !d.trim()) return
captured.push({
realIndex: i,
j: 0,
d,
fill: a.getAttribute('fill'),
key: a.getAttribute('data:key') || null,
})
})
// The inner rings, for the level-aware pairing. Leaves are already in
// `captured`, so this is every arc that is not one.
arcs.forEach((/** @type {Element} */ a) => {
if (a.getAttribute('data:leaf') === 'true') return
const d = a.getAttribute('d')
const key = a.getAttribute('data:key')
if (!d || !d.trim() || !key) return
branches.push({ key, d, fill: a.getAttribute('fill') })
})
}
} else if (fam === 'unit') {
// A unit chart is N objects per cluster, not one mark per cluster, so
// there is no path to hand the incoming bar or wedge. Capture where each
// cluster's dots actually sat and synthesise the rect they filled: the
// incoming mark then grows out of its own dot cloud, which is the mirror
// of the burst that brought the dots out of it.
const dots = baseEl.querySelectorAll('.apexcharts-unit-area')
/** @type {Map<number, {minX:number,minY:number,maxX:number,maxY:number,fill:string|null}>} */
const boxes = new Map()
dots.forEach((/** @type {Element} */ dot) => {
const i = parseInt(dot.getAttribute('i') ?? '', 10)
if (isNaN(i)) return
// Circles carry cx/cy; square / image dots carry their top-left x/y.
const cxAttr = dot.getAttribute('cx')
let x
let y
let r = 3
if (cxAttr != null) {
x = parseFloat(cxAttr)
y = parseFloat(dot.getAttribute('cy') ?? '')
r = parseFloat(dot.getAttribute('r') ?? '3') || 3
} else {
const wAttr = parseFloat(dot.getAttribute('width') ?? '0') || 0
const hAttr = parseFloat(dot.getAttribute('height') ?? '0') || 0
x = parseFloat(dot.getAttribute('x') ?? '') + wAttr / 2
y = parseFloat(dot.getAttribute('y') ?? '') + hAttr / 2
r = Math.max(wAttr, hAttr) / 2 || 3
}
if (!isFinite(x) || !isFinite(y)) return
// The per-dot record, for the piece layer: when the target can be
// tiled, each of these dots flies to its own cell of the incoming mark
// instead of vanishing with the teardown.
let list = unitDots.get(i)
if (!list) {
list = []
unitDots.set(i, list)
}
list.push({ x, y, r, fill: dot.getAttribute('fill') })
const box = boxes.get(i)
if (!box) {
boxes.set(i, {
minX: x,
minY: y,
maxX: x,
maxY: y,
fill: dot.getAttribute('fill'),
})
return
}
if (x < box.minX) box.minX = x
if (x > box.maxX) box.maxX = x
if (y < box.minY) box.minY = y
if (y > box.maxY) box.maxY = y
})
Array.from(boxes.keys())
.sort((a, b) => a - b)
.forEach((i) => {
const b = /** @type {any} */ (boxes.get(i))
// A single-dot cluster has a zero-area box; give it the width of one
// dot so the incoming mark has something to grow from.
const pad = 2
const x1 = b.minX - pad
const y1 = b.minY - pad
const x2 = b.maxX + pad
const y2 = b.maxY + pad
captured.push({
realIndex: i,
j: 0,
d: `M ${x1} ${y1} L ${x2} ${y1} L ${x2} ${y2} L ${x1} ${y2} Z`,
fill: b.fill,
})
})
} else if (fam === 'radial') {
// `gauge` is an alias for radialBar (see Config.normalizeAliasedChartType),
// so it captures from the same selector / arc-shape.
if (fromType === 'radialBar' || fromType === 'gauge') {
// radialBar paths are STROKED open arcs (fill=none, stroke=color,
// stroke-width ≈ ring thickness). If we hand the raw `d` to a pie/
// polarArea/donut element (which fills, not strokes), the implicit
// chord fill renders as a thin pie-slice — not the visible thick ring.
// So we transform each captured arc into an equivalent closed
// donut-segment whose FILLED rendering visually matches the
// outgoing radialBar's stroked appearance.
const centerX = this.w.layout.gridWidth / 2
const centerY =
Math.min(this.w.layout.gridWidth, this.w.layout.gridHeight) / 2
const rings = baseEl.querySelectorAll(
'.apexcharts-radial-series .apexcharts-radialbar-area',
)
rings.forEach((/** @type {Element} */ p) => {
const parent = /** @type {Element|null} */ (p.parentElement)
const realIndex = parseInt(
parent?.getAttribute('data:realIndex') ?? '0',
10,
)
const rawD = p.getAttribute('d')
if (!rawD) return
const strokeWidth = parseFloat(p.getAttribute('stroke-width') || '0')
const d =
strokeWidth > 1
? this._radialArcToFilledSegment(
rawD,
strokeWidth,
centerX,
centerY,
) || rawD
: rawD
captured.push({
realIndex,
j: 0,
d,
fill: p.getAttribute('stroke'),
})
})
} else {
// pie / donut / polarArea
const slices = baseEl.querySelectorAll(
'.apexcharts-pie-series .apexcharts-pie-area',
)
slices.forEach(
(/** @type {Element} */ p, /** @type {number} */ i) => {
const d = p.getAttribute('d')
if (!d) return
captured.push({
realIndex: i,
j: 0,
d,
fill: p.getAttribute('fill'),
})
},
)
}
}
return { marks: captured, branches, unitDots }
}
/**
* Convert a radialBar's stroked open-arc `d` ("M x1 y1 A r r 0 large sweep
* x2 y2") into a closed donut-segment polygon whose FILLED rendering
* visually matches the original stroked arc — needed because the morph
* target (pie/donut/polarArea) renders by fill, not stroke. Returns null
* if the input doesn't match the expected M-then-A shape.
*
* @param {string} rawD
* @param {number} strokeWidth
* @param {number} centerX
* @param {number} centerY
* @returns {string | null}
*/
_radialArcToFilledSegment(rawD, strokeWidth, centerX, centerY) {
const m = rawD.match(
/M\s*(-?[\d.]+)\s+(-?[\d.]+)\s+A\s*(-?[\d.]+)\s+(?:-?[\d.]+)\s+(?:-?[\d.]+)\s+(\d)\s+(\d)\s+(-?[\d.]+)\s+(-?[\d.]+)/,
)
if (!m) return null
const x1 = parseFloat(m[1])
const y1 = parseFloat(m[2])
const r = parseFloat(m[3])
const large = parseInt(m[4], 10)
const sweep = parseInt(m[5], 10)
const x2 = parseFloat(m[6])
const y2 = parseFloat(m[7])
if (!isFinite(r) || r <= 0) return null
const half = strokeWidth / 2
const rOuter = r + half
const rInner = Math.max(0, r - half)
// Project a point on the ring radius `r` onto a new radius around (cx, cy).
const proj = (
/** @type {number} */ px,
/** @type {number} */ py,
/** @type {number} */ newR,
) => {
const dx = px - centerX
const dy = py - centerY
const dist = Math.sqrt(dx * dx + dy * dy)
if (dist === 0) return { x: centerX, y: centerY }
const k = newR / dist
return { x: centerX + dx * k, y: centerY + dy * k }
}
const o1 = proj(x1, y1, rOuter)
const o2 = proj(x2, y2, rOuter)
const i1 = proj(x1, y1, rInner)
const i2 = proj(x2, y2, rInner)
// Inner arc traverses in the opposite sweep direction so the segment closes.
const sweepBack = sweep ? 0 : 1
return (
`M ${o1.x} ${o1.y} ` +
`A ${rOuter} ${rOuter} 0 ${large} ${sweep} ${o2.x} ${o2.y} ` +
`L ${i2.x} ${i2.y} ` +
`A ${rInner} ${rInner} 0 ${large} ${sweepBack} ${i1.x} ${i1.y} Z`
)
}
/**
* Build a closed donut-segment path for the given polar arc geometry. Used
* by Radial.drawArcs when morphing FROM a filled wedge (pie/donut/polarArea)
* TO a radialBar arc: the final radialBar is rendered as a stroked open arc,
* but during the morph we tween d toward this closed-segment form (which
* looks identical to the stroked arc when filled with the same color) so
* the in-between frames remain visually consistent filled shapes rather
* than a thick-outlined wedge.
*
* @param {number} centerX
* @param {number} centerY
* @param {number} ringRadius - centerline radius of the radialBar ring
* @param {number} strokeWidth - the ring's stroke thickness
* @param {number} startAngleDeg - in degrees, 0° = top (12 o'clock)
* @param {number} endAngleDeg
* @returns {string}
*/
buildRingSegmentPath(
centerX,
centerY,
ringRadius,
strokeWidth,
startAngleDeg,
endAngleDeg,
) {
const halfStroke = strokeWidth / 2
const rOuter = ringRadius + halfStroke
const rInner = Math.max(0, ringRadius - halfStroke)
const sRad = ((startAngleDeg - 90) * Math.PI) / 180
const eRad = ((endAngleDeg - 90) * Math.PI) / 180
const oStart = {
x: centerX + rOuter * Math.cos(sRad),
y: centerY + rOuter * Math.sin(sRad),
}
const oEnd = {
x: centerX + rOuter * Math.cos(eRad),
y: centerY + rOuter * Math.sin(eRad),
}
const iStart = {
x: centerX + rInner * Math.cos(sRad),
y: centerY + rInner * Math.sin(sRad),
}
const iEnd = {
x: centerX + rInner * Math.cos(eRad),
y: centerY + rInner * Math.sin(eRad),
}
const sweep = endAngleDeg > startAngleDeg ? 1 : 0
const large = Math.abs(endAngleDeg - startAngleDeg) > 180 ? 1 : 0
return (
`M ${oStart.x} ${oStart.y} ` +
`A ${rOuter} ${rOuter} 0 ${large} ${sweep} ${oEnd.x} ${oEnd.y} ` +
`L ${iEnd.x} ${iEnd.y} ` +
`A ${rInner} ${rInner} 0 ${large} ${1 - sweep} ${iStart.x} ${iStart.y} Z`
)
}
/**
* @returns {string | null} the chart-type the active snapshot was captured
* from, or null when no morph is in flight.
*/
getFromType() {
return this._snapshot ? this._snapshot.fromType : null
}
/**
* Build a (targetKey → captured) map. The targetKey matches the lookup
* pattern each chart-type renderer uses when it asks
* `getInitialPathFor(realIndex, j)`.
*
* Strategy: flatten the captured items into a linear sequence (matching the
* source chart's natural DOM iteration order: series-then-point for bar,
* ring-by-ring for radial), then walk the target's iteration positions in
* the same order and pair them up 1:1. This handles every supported shape
* without per-pair branching:
*
* - bar (1 series, N pts) ↔ radial-family (N items) → linear[k] ↔ k
* - bar (M series, 1 pt) ↔ radial-family (M items) → linear[k] ↔ k
* - radial-family (N items) ↔ bar (any matching shape) → linear[k] ↔ flat target
* - radial-family ↔ radial-family → linear[k] ↔ k
*
* @param {Array<{ realIndex: number, j: number, d: string, fill: string|null, key?: string|null }>} captured
* @param {string} _fromType
* @param {string} toType
* @param {any} newSeries - the series array being passed to the new chart;
* used only to derive the bar target's (realIndex, j) iteration positions.
* @param {Array<{ key: string, d: string, fill: string|null }>} [branches]
* non-leaf marks, for the key-based partition pairing.
*/
_buildMapping(captured, _fromType, toType, newSeries, branches) {
/** @type {Map<string, { d: string, fill: string|null }>} */
const map = new Map()
const tf = familyOf(toType)
// Sort to a stable linear order. DOM order already gives us this for the
// capture selectors we use, but a defensive sort makes the algorithm
// robust to future selector changes.
const flat = captured
.slice()
.sort((a, b) => a.realIndex - b.realIndex || a.j - b.j)
// Partition -> partition, with a branch key on both sides: pair by the
// branch each mark stands for rather than by draw order, so a sector keeps
// its identity and every level tweens instead of only the leaves. Keyed
// entries live in their own namespace so a positional lookup can still fall
// through when a chart has no keys (a flat treemap, an older config).
if (tf === 'partition' && branches && branches.length) {
const keyedMarks = flat.filter((c) => c.key)
if (keyedMarks.length === flat.length) {
keyedMarks.forEach((c) => {
map.set(`key:${c.key}`, { d: c.d, fill: c.fill })
})
branches.forEach((br) => {
map.set(`key:${br.key}`, { d: br.d, fill: br.fill })
})
}
}
if (tf === 'radial' || tf === 'unit' || tf === 'partition') {
// pie / donut / polarArea / radialBar iterate i = 0..N-1 with j=0. unit
// clusters iterate the same way (one cluster per category), reading the
// captured shape's centre rather than its `d`. treemap tiles and sunburst
// leaves have no (realIndex, j) grid at all and pair up by draw order,
// which this same index keying gives them (see getInitialPathAt).
flat.forEach((c, i) => {
map.set(`${i}:0`, { d: c.d, fill: c.fill })
})
return map
}
if (tf === 'bar' || tf === 'summary') {
// Derive the target's iteration positions from newSeries: each
// `{ data: number[] }` entry produces (realIndex=seriesIdx, j=k) tuples.
// boxPlot and violin iterate the same grid through Bar's renderSeries,
// so one branch serves both.
/** @type {Array<{ realIndex: number, j: number }>} */
const positions = []
const series = Array.isArray(newSeries) ? newSeries : []
series.forEach((/** @type {any} */ s, /** @type {number} */ seriesIdx) => {
const data = s && Array.isArray(s.data) ? s.data : []
for (let j = 0; j < data.length; j++) {
positions.push({ realIndex: seriesIdx, j })
}
})
flat.forEach((c, i) => {
const pos = positions[i]
if (pos) {
map.set(`${pos.realIndex}:${pos.j}`, { d: c.d, fill: c.fill })
}
})
return map
}
return map
}
isActive() {
return this._snapshot !== null
}
/**
* @param {number|string} realIndex
* @param {number|string} j
* @returns {string | null}
*/
getInitialPathFor(realIndex, j) {
if (!this._snapshot) return null
const entry = this._snapshot.mapping.get(`${realIndex}:${j}`)
if (!entry) return null
// Shift the captured d into the OLD chart's screen position. The
// captured coords are in the OLD elGraphical's translate space, but the
// new chart's elGraphical has its own translateX/Y (different e.g. when
// bar reserves yaxis space and radialBar doesn't). Without this offset
// the morphFrom would render at the new chart's translate — producing a
// visible position jump at t=0. The morph engine then interpolates the
// shifted morphFrom toward the new-space target, so both the shape and
// the position transition as one continuous tween.
const dx =
this._snapshot.oldLayout.translateX - (this.w.layout.translateX || 0)
const dy =
this._snapshot.oldLayout.translateY - (this.w.layout.translateY || 0)
return dx === 0 && dy === 0 ? entry.d : this._translatePathD(entry.d, dx, dy)
}
/**
* Offset every absolute coordinate in an SVG path `d` by (dx, dy).
*
* Assumes the path uses only uppercase (absolute) commands — every path
* ApexCharts generates does. Relative-command paths would pass through
* unchanged at the lowercase, which is also semantically correct (deltas
* don't shift under a parent translate).
*
* @param {string} d
* @param {number} dx
* @param {number} dy
* @returns {string}
*/
_translatePathD(d, dx, dy) {
if (dx === 0 && dy === 0) return d
const commands = parsePath(d)
return commands
.map(/** @param {any[]} c */ (c) => {
const cmd = c[0]
if (cmd === 'Z') return 'Z'
if (cmd === 'M' || cmd === 'L' || cmd === 'T') {
return `${cmd} ${c[1] + dx} ${c[2] + dy}`
}
if (cmd === 'H') return `${cmd} ${c[1] + dx}`
if (cmd === 'V') return `${cmd} ${c[1] + dy}`
if (cmd === 'C') {
return `${cmd} ${c[1] + dx} ${c[2] + dy} ${c[3] + dx} ${c[4] + dy} ${c[5] + dx} ${c[6] + dy}`
}
if (cmd === 'S' || cmd === 'Q') {
return `${cmd} ${c[1] + dx} ${c[2] + dy} ${c[3] + dx} ${c[4] + dy}`
}
if (cmd === 'A') {
// rx, ry, rotation, large-arc, sweep stay; only the final (x, y) shifts
return `${cmd} ${c[1]} ${c[2]} ${c[3]} ${c[4]} ${c[5]} ${c[6] + dx} ${c[7] + dy}`
}
return c.join(' ')
})
.join(' ')
}
/**
* The centre point (in the NEW chart's screen space) of the captured shape
* for cluster `i`. Kept for callers that only need a point; the unit renderer
* uses getInitialBBoxFor so its dots fill the shape rather than stack on a
* single point.
* @param {number} i
* @returns {{ x: number, y: number } | null}
*/
getInitialCenterFor(i) {
const box = this.getInitialBBoxFor(i)
if (!box) return null
return { x: box.x + box.width / 2, y: box.y + box.height / 2 }
}
/**
* The `k`-th captured path in draw order, already shifted into the NEW
* chart's coordinate space.
*
* For marks that pair up by position rather than by a (series, point) grid:
* a treemap's tiles and a sunburst's leaves are each one mark per row, laid
* out in the same reading order, so the k-th of one becomes the k-th of the
* other.
*
* @param {number} k
* @returns {string | null}
*/
getInitialPathAt(k) {
return this.getInitialPathFor(k, 0)
}
/**
* The captured shape for a branch identity (charts/common/Hierarchy.morphKey),
* or null when the outgoing chart had no mark for that branch.
*
* This is what lets a partition morph pair at every level: a sector, an
* industry and a company each find the arc or tile that stood for the same
* branch, instead of leaves pairing by draw order while the containers pop.
*
* @param {string} key
* @returns {string | null}
*/
getInitialPathForKey(key) {
if (!this._snapshot || !key) return null
return this.getInitialPathFor('key', key)
}
/** True when the active snapshot can pair by branch key. */
hasKeyedMarks() {
if (!this._snapshot) return false
for (const k of this._snapshot.mapping.keys()) {
if (typeof k === 'string' && k.startsWith('key:')) return true
}
return false
}
/**
* Where the `j`-th of `n` objects in cluster `i` starts, INSIDE the shape it
* came out of.
*
* An aggregate mark stands for a quantity, and its extent is that quantity: a
* bar of height h representing n units gives its k-th unit the height
* fraction (k + 0.5)/n. So a bar does not spray its dots from a single point,
* it comes apart along its own length, bottom-up, and each dot leaves from
* the part of the bar that was standing for it. The reverse direction reads
* the same geometry, so explode and collapse are inverses.
*
* The distribution follows the captured shape's LONGER axis, which is what
* makes one function serve both marks: a bar's box is tall and thin, so the
* dots leave in a column; a wedge's box is squat, so they leave in a row
* across it.
*
* @param {number} i - cluster index
* @param {number} j - the object's rank within its cluster
* @param {number} n - objects in the cluster
* @returns {{ x: number, y: number } | null}
*/
getInitialSlotFor(i, j, n) {
const box = this.getInitialBBoxFor(i)
if (!box) return null
const cx = box.x + box.width / 2
const cy = box.y + box.height / 2
if (!(n > 1) || !(j >= 0)) return { x: cx, y: cy }
const t = (Math.min(j, n - 1) + 0.5) / n
if (box.height >= box.width) {
// Tall: bottom-up, so the first unit leaves from the bar's base.
return { x: cx, y: box.y + box.height * (1 - t) }
}
return { x: box.x + box.width * t, y: cy }
}
/**
* The bounding box (in the NEW chart's screen space) of the captured shape
* for cluster `i`. `getInitialSlotFor` distributes a cluster's objects across
* this box as their start positions, so a tall bar visibly breaks apart into
* a tall column of dots that then swarm into the cluster.
* @param {number} i
* @returns {{ x: number, y: number, width: number, height: number } | null}
*/
getInitialBBoxFor(i) {
if (!this._snapshot) return null
const entry = this._snapshot.mapping.get(`${i}:0`)
if (!entry) return null
const box = this._pathBBox(entry.d)
if (!box) return null
// Same OLD -> NEW translate shift getInitialPathFor applies (see there).
const dx =
this._snapshot.oldLayout.translateX - (this.w.layout.translateX || 0)
const dy =
this._snapshot.oldLayout.translateY - (this.w.layout.translateY || 0)
return {
x: box.minX + dx,
y: box.minY + dy,
width: box.maxX - box.minX,
height: box.maxY - box.minY,
}
}
/**
* Bounding box of an absolute-command SVG path `d`. Good enough as the burst
* footprint (we only need where the shape sat, not exact geometry).
* @param {string} d
* @returns {{ minX:number, minY:number, maxX:number, maxY:number } | null}
*/
_pathBBox(d) {
const commands = parsePath(d)
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
let seen = false
commands.forEach(/** @param {any[]} c */ (c) => {
const cmd = c[0]
if (cmd === 'Z') return
// Walk the numeric args in (x, y) pairs; for A the final pair is (x, y),
// the leading radii/flags are not coordinates, so read from the end.
/** @type {Array<[number, number]>} */
let pairs = []
if (cmd === 'H') pairs = [[c[1], (minY + maxY) / 2 || c[1]]]
else if (cmd === 'V') pairs = [[(minX + maxX) / 2 || c[1], c[1]]]
else if (cmd === 'A') pairs = [[c[6], c[7]]]
else {
for (let k = 1; k + 1 < c.length; k += 2) pairs.push([c[k], c[k + 1]])
}
pairs.forEach(([x, y]) => {
if (!isFinite(x) || !isFinite(y)) return
seen = true
if (x < minX) minX = x
if (x > maxX) maxX = x
if (y < minY) minY = y
if (y > maxY) maxY = y
})
})
if (!seen) return null
return { minX, minY, maxX, maxY }
}
/**
* @param {number} realIndex
* @param {number} j
* @returns {string | null}
*/
getInitialFillFor(realIndex, j) {
if (!this._snapshot) return null
const entry = this._snapshot.mapping.get(`${realIndex}:${j}`)
return entry ? entry.fill : null
}
/** @returns {number} */
getSpeed() {
const animCfg = this.w.config.chart.animations
return (
(animCfg.chartTypeMorph && animCfg.chartTypeMorph.speed) ||
animCfg.speed ||
600
)
}
/**
* Fade newly-mounted axes / grid / legend / titles from opacity 0 → 1 in
* parallel with the morph. Without this the chart's chrome would pop in
* abruptly while the series elements are still mid-tween, which reads as a
* jarring layout shift.
*/
applyChromeFade() {
if (!this._snapshot || !Environment.isBrowser()) return
/** @type {any} */
const baseEl = this.w.globals.dom?.baseEl
if (!baseEl) return
// The incoming chart exists now, so the outgoing marks captured before
// the teardown finally have something to leave over. Pieces when the pair
// can conserve its ink; the ghost fade otherwise; nothing at all for
// pairs that inherit their shapes (see _needsGhost).
if (this._snapshot.pieceOut) this._separatePieces()
else if (this._snapshot.pieceIn) this._combinePieces()
else this._mountGhost()
const speed = this.getSpeed()
const chromeSelectors = [
'.apexcharts-xaxis',
'.apexcharts-yaxis',
'.apexcharts-grid',
'.apexcharts-gridlines-horizontal',
'.apexcharts-gridlines-vertical',
'.apexcharts-legend',
'.apexcharts-title-text',
'.apexcharts-subtitle-text',
]
chromeSelectors.forEach((sel) => {
baseEl
.querySelectorAll(sel)
.forEach((/** @type {any} */ el) => {
if (!el.style) return
el.style.opacity = '0'
el.style.transition = `opacity ${speed}ms ease-out`
BrowserAPIs.requestAnimationFrame(() => {
el.style.opacity = '1'
})
setTimeout(() => {
el.style.transition = ''
el.style.opacity = ''
}, speed + 80)
})
})
setTimeout(() => this.cleanup(), speed + 100)
}
cleanup() {
this._snapshot = null
this._removeGhost()
// Belt and braces for the piece layer: normally it has already finished
// and detached itself, but a throttled tab can leave it mid-flight, and
// nothing hidden may survive the transition.
this._cancelPieces()
}
}