apexcharts
Version:
A JavaScript Chart Library
1,055 lines (965 loc) • 40.6 kB
JavaScript
// @ts-check
import Utils from '../../utils/Utils'
import { Environment } from '../../utils/Environment.js'
import Breadcrumb from './Breadcrumb'
import DrilldownLoading from './DrilldownLoading'
/**
* Opt-in drilldown navigation.
*
* Clicking a data point that carries a `drilldown: '<id>'` field swaps the chart
* to the matching `chart.drilldown.series[id]` level; a breadcrumb and the
* drillUp()/drillToRoot() methods navigate back. State lives on the instance
* (this.stack) and survives updates because the module is created once in
* InitCtxVariables and w.globals.events is never reset.
*
* Animation is delegated to the existing update pipeline:
* - same-type axis swap → updateSeries() (fastUpdate morph)
* - type change / non-axis / per-level overrides → updateOptions()
*
* @module Drilldown
*/
const MAX_DEPTH = 32
/**
* Marks a `markers.discrete` entry as one we supplied for a drillable point, so
* a resync replaces only our own and never an author's.
*/
const DRILL_MARKER = '__apexDrilldownMarker'
export default class Drilldown {
/**
* @param {import('../../types/internal').ChartStateW} w
* @param {import('../../types/internal').ChartContext} ctx
*/
constructor(w, ctx) {
this.w = w
this.ctx = ctx
/**
* Restore-frames, one per level below root. stack[k] describes the level at
* depth k+1: { id, name, restore } where `restore` is a snapshot of the
* view at depth k (the parent), applied to navigate back to it.
* @type {Array<{ id: string|number, name?: string, restore: object }>}
*/
this.stack = []
/** Full snapshot of the root view, captured lazily on the first drill. */
this.rootSnapshot = null
this._wired = false
/**
* Async levels already resolved, keyed by the requested id, so drilling back
* down a branch does not re-fetch it. Cleared via `clearCache()`.
* @type {Map<string|number, any>}
*/
this._asyncCache = new Map()
/**
* The in-flight `onDrillDown` promise, if any. Guards against a second
* click starting a second fetch (and a double drill) mid-resolve.
* @type {Promise<any>|null}
*/
this._pending = null
/**
* Whether the level on screen was given drill dots. A level that has none
* must still send an empty `discrete` array so the previous level's dots
* are cleared rather than left behind.
* @type {boolean}
*/
/** One warning per chart when a drillable point has nothing to click. */
this._warnedUnreachable = false
/** One warning per chart when a pie/donut asked for the slice pull-out. */
this._warnedNoSliceOffset = false
this.breadcrumb = new Breadcrumb(w, ctx, this)
this.loading = new DrilldownLoading(w)
this._onPointSelect = this._onPointSelect.bind(this)
this._afterRender = this._afterRender.bind(this)
this._onPlotDown = this._onPlotDown.bind(this)
this._onPlotClick = this._onPlotClick.bind(this)
/** Pointer-down position, to tell a click apart from a zoom drag. */
this._downAt = null
this._plotClickWired = null
// Self-wire. The instance and w.globals.events both outlive updates, so the
// listeners registered here persist for the chart's lifetime.
this.init()
}
init() {
const w = this.w
if (!w.config.drilldown || !w.config.drilldown.enabled) return
if (this._wired) return
this._wired = true
// Coexist with any user dataPointSelection handler: both the config callback
// and the addEventListener registry fire (see Graphics.pathMouseDown).
this.ctx.addEventListener('dataPointSelection', this._onPointSelect)
// Re-mark drillable points + (re)render breadcrumb after every (re)render.
// 'mounted' covers initial render; 'updated' covers fastUpdate + full update.
this.ctx.addEventListener('mounted', this._afterRender)
this.ctx.addEventListener('updated', this._afterRender)
// Supply the drill dots for the FIRST render. Every later render gets them
// from _apply (drills) or _afterRender (host-app updates).
if (w.config.markers) {
w.config.markers.discrete = this._drillMarkers(w.config.series)
}
}
// ─── Observable state ──────────────────────────────────────────────────────
/** @returns {Array<string|number>} e.g. ['root', '2024-quarters'] */
get path() {
return ['root', ...this.stack.map((f) => f.id)]
}
/** @returns {number} 0 at root */
get depth() {
return this.stack.length
}
// ─── Navigation API ────────────────────────────────────────────────────────
/**
* Drill into the child level with the given id.
* @param {string|number} id
* @param {any} [triggerPoint] - the clicked data point (for events / async ctx)
* @param {{ seriesIndex?: number, dataPointIndex?: number }} [meta]
* @returns {Promise<any>}
*/
drillDown(id, triggerPoint, meta) {
const child = this._resolveChild(id)
if (child) return this._drillInto(child, triggerPoint, meta)
if (typeof this.w.config.drilldown.onDrillDown === 'function') {
return this._drillDownAsync(id, triggerPoint, meta)
}
// Unknown id, no resolver — warn and no-op (consistent with the rest of the API).
console.warn(
`ApexCharts: drilldown id "${id}" not found in chart.drilldown.series, and no onDrillDown resolver is set.`,
)
return Promise.resolve(this.ctx)
}
/**
* Navigate back one level.
* @returns {Promise<any>}
*/
drillUp() {
return this.drillToLevel(this.stack.length - 1)
}
/**
* Navigate back to the root view.
* @returns {Promise<any>}
*/
drillToRoot() {
return this.drillToLevel(0)
}
/**
* Navigate to an arbitrary depth (0 = root). Used by breadcrumb clicks.
* @param {number} targetDepth
* @returns {Promise<any>}
*/
drillToLevel(targetDepth) {
const cur = this.stack.length
if (targetDepth < 0 || targetDepth >= cur) return Promise.resolve(this.ctx)
const from = this.path[this.path.length - 1]
// To display depth D: apply the snapshot of that level's view.
// D === 0 → rootSnapshot
// D >= 1 → stack[D].restore (snapshot taken before drilling into D+1)
const restore = targetDepth === 0 ? this.rootSnapshot : this.stack[targetDepth].restore
this.stack = this.stack.slice(0, targetDepth)
const to = this.path[this.path.length - 1]
return this._apply(this._viewFromSnapshot(restore), 'up', { from, to })
}
// ─── Internals ─────────────────────────────────────────────────────────────
/**
* @param {string|number} id
* @returns {any|null}
*/
_resolveChild(id) {
const list = this.w.config.drilldown && this.w.config.drilldown.series
if (!Array.isArray(list)) return null
return list.find((s) => s && s.id === id) || null
}
/**
* @param {any} child
* @param {any} [triggerPoint]
* @param {{ seriesIndex?: number, dataPointIndex?: number }} [meta]
* @returns {Promise<any>}
*/
_drillInto(child, triggerPoint, meta) {
if (this.stack.length >= MAX_DEPTH) {
console.warn(`ApexCharts: drilldown max depth (${MAX_DEPTH}) reached.`)
return Promise.resolve(this.ctx)
}
if (!this.rootSnapshot) this.rootSnapshot = this._snapshot()
const from = this.path[this.path.length - 1]
this.stack.push({ id: child.id, name: child.name, restore: this._snapshot() })
return this._apply(this._viewFromChild(child), 'down', {
from,
to: child.id,
point: triggerPoint,
seriesIndex: meta && meta.seriesIndex,
dataPointIndex: meta && meta.dataPointIndex,
})
}
/**
* Resolve a level through `onDrillDown` and drill into it.
*
* Failure never changes state: on a throw, a rejection, or a resolver that
* hands back something undrillable, the chart stays exactly where it was and
* `drillDownError` fires. That is what makes this usable against a real
* backend, where a fetch failing is ordinary rather than exceptional.
*
* @param {string|number|null} id
* @param {any} point
* @param {{ seriesIndex?: number, dataPointIndex?: number }} [meta]
* @returns {Promise<any>}
*/
_drillDownAsync(id, point, meta) {
const cfg = this.w.config.drilldown
const fn = cfg.onDrillDown
// Already resolved once: skip the round trip entirely. Drilling back down
// the same branch is the common navigation pattern, so without this every
// breadcrumb bounce re-fetches.
const cached = this._cacheGet(id)
if (cached) return this._drillInto(cached, point, meta)
// A second click while a fetch is in flight must not start a second fetch
// or drill twice. Returning the pending promise keeps the caller's
// await-able contract intact.
if (this._pending) return this._pending
let result
this.loading.show()
try {
result = fn({
// `id` was missing here, so a resolver could not tell WHICH level was
// asked for without re-deriving it from the point. It is the first
// thing a real implementation needs (`fetch('/levels/' + id)`).
id,
point,
seriesIndex: meta && meta.seriesIndex,
dataPointIndex: meta && meta.dataPointIndex,
})
} catch (error) {
this.loading.hide()
this._fire('drillDownError', { id, error })
return Promise.resolve(this.ctx)
}
const settle = () => {
this._pending = null
this.loading.hide()
}
const p = Promise.resolve(result).then(
(child) => {
settle()
if (this._isDead()) return this.ctx
if (!child || !child.data) {
// Previously a silent no-op, which looks identical to "nothing
// happened" and is the hardest kind of integration bug to chase.
this._fire('drillDownError', {
id,
error: new Error(
`drilldown: onDrillDown resolved without a drillable level for id "${id}" ` +
`(expected an object with a \`data\` array).`,
),
})
return this.ctx
}
// A resolver naturally returns just the level, so default its id to the
// one that was asked for; the breadcrumb and restore stack key off it.
const level = child.id != null ? child : { ...child, id }
this._cacheSet(id, level)
return this._drillInto(level, point, meta)
},
(error) => {
settle()
if (this._isDead()) return this.ctx
this._fire('drillDownError', { id, error })
return this.ctx
},
)
this._pending = p
return p
}
/**
* Whether the chart was torn down while a resolver was in flight.
*
* Clicking to drill and then navigating away is ordinary, not exceptional: a
* component unmounts, `destroy()` runs, and the fetch settles afterwards.
* Without this the resolved level would be applied to a destroyed chart,
* which throws out of `updateOptions` and surfaces in the host app as an
* unhandled rejection from a click the user has already forgotten about.
*
* @returns {boolean}
*/
_isDead() {
const w = this.w
return !w || !w.globals || w.globals.isDestroyed === true
}
/** @returns {boolean} whether resolved async levels are cached. */
_cacheEnabled() {
const cfg = this.w.config.drilldown
return !!(cfg && cfg.cache !== false)
}
/**
* @param {string|number|null} id
* @returns {any|null}
*/
_cacheGet(id) {
if (!this._cacheEnabled() || id == null) return null
return this._asyncCache.get(id) || null
}
/**
* @param {string|number|null} id
* @param {any} level
*/
_cacheSet(id, level) {
if (!this._cacheEnabled() || id == null) return
this._asyncCache.set(id, level)
}
/**
* Drop cached async levels, so the next drill re-runs `onDrillDown`. Call it
* when the underlying data changes behind a chart that has already drilled.
* @param {string|number} [id] a single level, or every level when omitted
* @returns {any} the chart, for chaining
*/
clearCache(id) {
if (id == null) this._asyncCache.clear()
else this._asyncCache.delete(id)
return this.ctx
}
/**
* Capture the overridable surface of the current view so it can be restored.
* Only fields that some drilldown.series entry can change are cloned; series
* and chart.type/stacked are always captured.
* @returns {object}
*/
_snapshot() {
const c = this.w.config
const fields = this._overrideFields()
/** @type {Record<string, any>} */
// Capture the FULL data, not the post-collapse view: navigation clears the
// legend-collapse bookkeeping (see _apply), so a snapshot that kept a
// hidden slice/series at 0/[] would strand it on return — active in the
// legend but zero-valued, with no collapse state left to restore it.
const snap = { series: this._uncollapseSeries(Utils.clone(c.series)) }
if (Array.isArray(c.labels) && c.labels.length) {
snap.labels = Utils.clone(c.labels)
}
snap.chart = { type: c.chart.type, stacked: c.chart.stacked }
if (fields.has('xaxis')) snap.xaxis = Utils.clone(c.xaxis)
if (fields.has('yaxis')) snap.yaxis = Utils.clone(c.yaxis)
if (fields.has('colors')) snap.colors = c.colors ? Utils.clone(c.colors) : undefined
if (fields.has('plotOptions')) snap.plotOptions = Utils.clone(c.plotOptions)
if (fields.has('fill')) snap.fill = Utils.clone(c.fill)
if (fields.has('legend')) snap.legend = Utils.clone(c.legend)
return snap
}
/**
* Restore any legend-collapsed slices/series to their original values in a
* cloned series array, so a drill snapshot captures the pre-collapse data.
* Mirrors legend Helpers' collapse addressing: object-form pie/donut packs
* every slice as a data point inside `series[0].data`; numeric pie stores a
* slice per top-level element; axis series carry a `data` array. No-op when
* nothing is collapsed.
* @param {any[]} series
* @returns {any[]}
*/
_uncollapseSeries(series) {
const w = this.w
const gl = w.globals
const entries = [
...(gl.collapsedSeries || []),
...(gl.ancillaryCollapsedSeries || []),
]
if (!entries.length) return series
const type = w.config.chart.type
const objectFormPie =
(type === 'pie' || type === 'donut' || type === 'polarArea') &&
series.length === 1 &&
series[0] &&
typeof series[0] === 'object' &&
Array.isArray(series[0].data)
const container = objectFormPie ? series[0].data : series
for (const entry of entries) {
const i = entry.index
if (gl.axisCharts) {
if (series[i]) {
series[i].data = Array.isArray(entry.data)
? entry.data.slice()
: entry.data
}
} else if (container[i] && typeof container[i] === 'object') {
container[i].y = entry.data
} else if (container[i] !== undefined) {
container[i] = entry.data
}
}
return series
}
/**
* Union of overridable fields across all declared drilldown levels. Ensures a
* deep drillToRoot restores everything any intermediate level may have changed.
* @returns {Set<string>}
*/
_overrideFields() {
const fields = new Set()
const list = (this.w.config.drilldown && this.w.config.drilldown.series) || []
for (const s of list) {
if (!s) continue
if (s.xaxis) fields.add('xaxis')
if (s.yaxis) fields.add('yaxis')
if (s.colors) fields.add('colors')
if (s.plotOptions) fields.add('plotOptions')
if (s.fill) fields.add('fill')
if (s.legend) fields.add('legend')
}
return fields
}
/**
* Copy the optional view fields shared by a drilldown child level and a
* restore snapshot (`xaxis`, `yaxis`, `colors`, `plotOptions`, `fill`,
* `legend`) from `src` onto `view`, only when present.
* @param {Record<string, any>} view @param {Record<string, any>} src
*/
_copyOptionalViewFields(view, src) {
if (src.xaxis) view.xaxis = src.xaxis
if (src.yaxis) view.yaxis = src.yaxis
if (src.colors) view.colors = src.colors
if (src.plotOptions) view.plotOptions = src.plotOptions
if (src.fill) view.fill = src.fill
if (src.legend) view.legend = src.legend
}
/**
* Build an updateOptions/updateSeries payload for drilling INTO a child level.
* Works for axis charts and pie/donut alike: both accept series objects with a
* `data` array of `{ x, y }` points (pie derives slice labels from `x`).
* @param {any} child
* @returns {Record<string, any>}
*/
_viewFromChild(child) {
/** @type {Record<string, any>} */
const view = {}
// A level may declare a full multi-series array (`series`) to reveal a
// grouped/stacked breakdown, or a single series' worth of points (`data`).
if (Array.isArray(child.series)) {
view.series = child.series
} else {
view.series = [{ name: child.name || '', data: child.data }]
}
const chart = {}
if (child.chart && child.chart.type) chart.type = child.chart.type
if (child.chart && child.chart.stacked != null) chart.stacked = child.chart.stacked
if (Object.keys(chart).length) view.chart = chart
this._copyOptionalViewFields(view, child)
return view
}
/**
* Build an updateOptions payload from a restore-snapshot.
* @param {Record<string, any>} snap
* @returns {Record<string, any>}
*/
_viewFromSnapshot(snap) {
/** @type {Record<string, any>} */
const view = { series: snap.series, chart: snap.chart }
if (snap.labels && snap.labels.length) view.labels = snap.labels
this._copyOptionalViewFields(view, snap)
return view
}
/**
* Apply a view by delegating to the right update path, firing drill events
* around it.
* @param {Record<string, any>} view
* @param {'down'|'up'} direction
* @param {object} meta
* @returns {Promise<any>}
*/
_apply(view, direction, meta) {
const w = this.w
// A drill is navigation, not a data-point selection. Clear any selection
// carried in from the click that triggered it: the child's data points are
// different, and a stale selected index makes pie/donut levels render a
// "pulled-out" slice for whatever now sits at that position.
w.interact.selectedDataPoints = []
// Legend-collapse state is per-level and indexed by series position, so it
// is meaningless at the destination: a series hidden at this level points at
// a different (or non-existent) series in the level we navigate to. Left in
// place, a stale collapsed index re-collapses whatever series now sits at
// that position — e.g. hiding series 0 in a multi-series child then drilling
// back to a single-series root collapses the root's only series, which gets
// the `apexcharts-series-collapsed` class (opacity 0) and the chart renders
// blank. Reset it like resetSeries() does. (Re-collapsing on every navigation
// would also be wrong: a fresh level should show all of its series.)
w.globals.collapsedSeries = []
w.globals.collapsedSeriesIndices = []
w.globals.ancillaryCollapsedSeries = []
w.globals.ancillaryCollapsedSeriesIndices = []
w.globals.allSeriesCollapsed = false
w.globals.risingSeries = []
// Drill dots belong to the level being applied, not the one we are leaving:
// the destination has different points, and different ones are drillable.
// They ride along in the update payload because the level's series only
// reach w.config inside updateOptions, which is too late to read them.
// Always sent, even when empty: a level with nothing further to open has to
// actively clear the dots, or the ones from the level we just left stay on
// screen pointing at points that no longer open anything.
view.markers = {
...(view.markers || {}),
discrete: this._drillMarkers(view.series),
}
const animate =
(!w.config.drilldown.animation || w.config.drilldown.animation.enabled !== false) &&
w.config.chart.animations.enabled !== false
if (direction === 'down') this._fire('drillDownStart', meta)
// Always go through updateOptions, never the updateSeries fast path. A drill
// navigates to a different dataset, so the child's x-axis categories almost
// always differ from the parent's (years → quarters, sectors → companies).
// updateSeries' fast path morphs the series in place but does NOT re-derive
// category labels, so a same-type drill would leave the parent's axis labels
// under the child's bars. updateOptions re-parses and rebuilds the axes, so
// labels, scale, and series all match the level we navigated to.
//
// overwriteInitial* stay false: resetSeries() must still return to the user's
// original top-level data, not whichever level we drilled to.
const runUpdate = (anim) =>
this.ctx.updateOptions(view, false, anim, false, false)
const done = () => {
this._fire(direction === 'down' ? 'drillDownEnd' : 'drillUp', meta)
return this.ctx
}
// Trigger-point zoom: when enabled, the transition is a camera move anchored
// at the clicked point — the current view scales up/out and fades, the child
// is rendered instantly underneath (no morph, so the two don't fight), then
// the child scales in from that same point. Additive polish: gated behind
// drilldown.animation.zoomFromPoint, layered on top of the SVG via the Web
// Animations API, and a no-op (falls back to the normal animated update) when
// disabled, animations are off, or we are not in a capable browser.
if (animate && this._zoomEnabled()) {
const origin = this._triggerOrigin(meta)
if (origin) {
return this._zoomDrill(origin, direction, () => runUpdate(false)).then(done)
}
}
return runUpdate(animate).then(done)
}
/** @returns {boolean} whether trigger-point zoom is configured on. */
_zoomEnabled() {
const a = this.w.config.drilldown && this.w.config.drilldown.animation
return !!(a && a.zoomFromPoint)
}
/** @returns {SVGSVGElement|null} the chart's root <svg> node, if present. */
_svgNode() {
const paper = this.w.dom && this.w.dom.Paper
return paper && paper.node ? paper.node : null
}
/**
* The group wrapping ONLY the data marks (bars/cells/tiles) — not the axes,
* grid, or titles. Animating this keeps the chart frame still while the marks
* move. Covers bar/line/area (`.apexcharts-plot-series`), heatmap, and treemap.
* @returns {SVGElement|null}
*/
_markGroup() {
const svg = this._svgNode()
if (!svg || typeof svg.querySelector !== 'function') return null
return svg.querySelector(
'.apexcharts-plot-series, .apexcharts-heatmap, .apexcharts-treemap',
)
}
/**
* Centre of the clicked point in the SVG's view-box pixel space, used as the
* transform-origin for the mark-group scale (which uses `transform-box:
* view-box`, so the origin is resolved in SVG coordinates and stays stable
* across the parent and child renders). Falls back to the mark group's centre
* when there is no trigger point (e.g. drillUp / imperative drill). Returns
* null when the marks / SVG / WAAPI are unavailable (SSR / old browsers).
* @param {object} meta
* @returns {{ x: number, y: number }|null}
*/
_triggerOrigin(meta) {
if (!Environment.isBrowser()) return null
const svg = this._svgNode()
const group = this._markGroup()
if (
!svg ||
!group ||
typeof group.animate !== 'function' ||
typeof svg.getBoundingClientRect !== 'function'
) {
return null
}
const svgRect = svg.getBoundingClientRect()
let el = null
if (meta && meta.seriesIndex != null && meta.dataPointIndex != null && this.w.dom.baseEl) {
el = this.w.dom.baseEl.querySelector(
`[index="${meta.seriesIndex}"][j="${meta.dataPointIndex}"]`,
)
}
if (el && typeof el.getBoundingClientRect === 'function') {
const r = el.getBoundingClientRect()
return {
x: r.left + r.width / 2 - svgRect.left,
y: r.top + r.height / 2 - svgRect.top,
}
}
const gRect = group.getBoundingClientRect()
return {
x: gRect.left + gRect.width / 2 - svgRect.left,
y: gRect.top + gRect.height / 2 - svgRect.top,
}
}
/**
* Run the "expand from the clicked point" choreography around an instant
* (un-animated) update. Only the data-mark group is animated — the axes, grid,
* and titles stay fixed, so the effect doesn't drag the whole chart frame. The
* current marks fade out near-in-place (a quick fade, not a balloon), the child
* renders invisibly underneath, then the child marks unfold outward from the
* clicked point: a horizontal-biased scale anchored there, so the bars read as
* emerging from the column you clicked. Drilling up has no trigger column, so
* it settles gently from the marks' centre.
*
* `transform-box: view-box` resolves the origin in SVG coordinates, so the same
* origin applies cleanly to the parent and the freshly-rendered child group.
* @param {{ x: number, y: number }} origin
* @param {'down'|'up'} direction
* @param {() => Promise<any>} runUpdate
* @returns {Promise<void>}
*/
async _zoomDrill(origin, direction, runUpdate) {
const dur = this._zoomDuration()
const down = direction === 'down'
// Out phase: a quick fade, barely any scale (no dramatic zoom-out). It runs
// shorter than the in phase so the child reveal carries the motion.
const outDur = Math.round(dur * 0.55)
const outTo = down ? 'scale(1.03)' : 'scale(0.97)'
// In phase: drill-in unfolds the child outward from the point (X compressed
// more than Y, so it spreads sideways out of the column); drill-up just
// eases the parent in from a hair oversized.
const inFrom = down ? 'scaleX(0.55) scaleY(0.85)' : 'scale(1.04)'
/** @param {SVGElement} el */
const anchor = (el) => {
el.style.transformBox = 'view-box'
el.style.transformOrigin = `${origin.x}px ${origin.y}px`
}
/** @param {SVGElement} el */
const clear = (el) => {
el.style.transform = ''
el.style.opacity = ''
el.style.transformOrigin = ''
el.style.transformBox = ''
}
const outGroup = this._markGroup()
let outAnim = null
if (outGroup) {
anchor(outGroup)
outAnim = outGroup.animate(
[
{ transform: 'scale(1)', opacity: 1 },
{ transform: outTo, opacity: 0 },
],
{ duration: outDur, easing: 'ease-in', fill: 'forwards' },
)
try {
await outAnim.finished
} catch (e) {
/* cancelled by a rapid follow-up drill — fall through */
}
}
// Swap content while the marks are invisible, so the change is unseen. The
// update rebuilds the chart, so the mark group is a fresh element afterwards.
await runUpdate()
const inGroup = this._markGroup()
if (inGroup) {
// Pin the invisible start state inline before the first paint, so the
// freshly-rendered marks never flash at full size for a frame.
anchor(inGroup)
inGroup.style.opacity = '0'
inGroup.style.transform = inFrom
if (outAnim && outGroup === inGroup) outAnim.cancel()
const inAnim = inGroup.animate(
[
{ transform: inFrom, opacity: 0 },
{ transform: 'scale(1)', opacity: 1 },
],
// Decelerating ease so the unfold settles softly into place.
{ duration: dur, easing: 'cubic-bezier(0.16, 1, 0.3, 1)', fill: 'forwards' },
)
try {
await inAnim.finished
} catch (e) {
/* cancelled */
}
// Restore the natural (untransformed) state and drop the WAAPI fill.
clear(inGroup)
inAnim.cancel()
}
}
/** @returns {number} per-phase zoom duration in ms. */
_zoomDuration() {
const a = this.w.config.drilldown && this.w.config.drilldown.animation
const speed = a && typeof a.speed === 'number' ? a.speed : 260
return Math.max(80, speed)
}
/**
* Fire a drill event through both the config callback and the listener registry.
* @param {string} name
* @param {object} payload
*/
_fire(name, payload) {
const cb = this.w.config.chart.events && this.w.config.chart.events[name]
if (typeof cb === 'function') cb(payload, this.ctx, this.w)
this.ctx.events.fireEvent(name, [payload, this.ctx, this.w])
}
// ─── Click + post-render hooks ───────────────────────────────────────────────
/**
* @param {Event} _event
* @param {any} _ctx
* @param {{ seriesIndex?: number, dataPointIndex?: number }} opts
*/
_onPointSelect(_event, _ctx, opts) {
if (!opts) return undefined
const point = this._pointAt(opts.seriesIndex, opts.dataPointIndex)
if (point && typeof point === 'object' && point.drilldown != null) {
return this.drillDown(point.drilldown, point, opts)
}
if (typeof this.w.config.drilldown.onDrillDown === 'function') {
return this._drillDownAsync(null, point, opts)
}
return undefined
}
/**
* @param {number|undefined} seriesIndex
* @param {number|undefined} dataPointIndex
* @returns {any|null}
*/
_pointAt(seriesIndex, dataPointIndex) {
const series = this.w.config.series
if (!Array.isArray(series) || seriesIndex == null || dataPointIndex == null) {
return null
}
const s = series[seriesIndex]
if (!s || !Array.isArray(s.data)) return null
return s.data[dataPointIndex] != null ? s.data[dataPointIndex] : null
}
_afterRender() {
const w = this.w
if (!w.config.drilldown || !w.config.drilldown.enabled) return
this._markDrillableTargets()
this._wirePlotClick()
this.breadcrumb.render(this.path)
// Keep the dots in step with a host-app update that changed which points
// are drillable. There is no pre-update hook to run this in, so a plain
// `chart.updateSeries()` introducing newly-drillable points shows them from
// the next render. Drills are exact, since _apply computes them per level.
if (w.config.markers) {
w.config.markers.discrete = this._drillMarkers(w.config.series)
}
}
/**
* Mark every point that carries a `drilldown` field as an openable target.
*
* Two things have to be true for a point to be drillable, and on line/area
* neither holds by default. It needs a mark to click (with `markers.size: 0`
* there is no element at all), and that mark has to accept the click: core
* gives line/area markers `no-pointer-events` so the shared tooltip can track
* the whole plot, which silently swallows it. `_drillMarkers()` supplies the
* missing dots; this re-enables pointer events on them.
*
* The cursor class only goes on marks that can actually take the click, so we
* never promise an interaction that cannot happen.
*/
_markDrillableTargets() {
if (!Environment.isBrowser()) return
const w = this.w
const baseEl = w.dom.baseEl
const series = w.config.series
if (!baseEl || !Array.isArray(series)) return
let unreachable = 0
series.forEach((s, i) => {
const data = s && Array.isArray(s.data) ? s.data : null
if (!data) return
data.forEach((point, j) => {
if (!point || typeof point !== 'object' || point.drilldown == null) return
// bar/column/line: [index="i"][j="j"]; pie/donut: series index is 0.
const nodes = baseEl.querySelectorAll(`[index="${i}"][j="${j}"]`)
if (!nodes.length) unreachable++
nodes.forEach((node) => {
if (this._isClickThroughMark(node)) {
node.classList.remove('no-pointer-events')
}
node.classList.add('apexcharts-drilldown-target')
})
})
})
// A drillable point with nothing to click is a dead interaction, and it
// looks identical to a broken one. Say so rather than no-opping: the only
// way to reach this is to have turned the drill dots off without providing
// markers of your own.
if (unreachable && !this._warnedUnreachable) {
this._warnedUnreachable = true
console.warn(
`ApexCharts: ${unreachable} drillable point(s) have no clickable mark, ` +
`so clicking them cannot do anything. Leave \`drilldown.marker\` on, ` +
`or give the series markers of its own (\`markers.size > 0\`).`,
)
}
}
/**
* Called by Pie when it declines to wire the slice pull-out because this
* chart drills. Warned once per chart (a drill re-renders, and the same
* notice on every navigation is just noise), and from here rather than from
* Pie because this module is the reason it is unavailable.
*/
warnSliceOffsetDisabled() {
if (this._warnedNoSliceOffset) return
this._warnedNoSliceOffset = true
console.warn(
'ApexCharts: `plotOptions.pie.expandOnClick` is not available in a ' +
'drilldown pie/donut, so it was ignored. A slice click navigates, and a ' +
'slice that slid out would be discarded by the drill it just triggered.',
)
}
/**
* Make the whole band a drillable point owns clickable, not just its dot.
*
* A dot is ~6px across, so hitting it takes pixel-precise aim, it is far under
* the ~44px a finger needs, and the tooltip's arrow points AT the point by
* design, which puts a triangle over the very thing you are aiming at. Rather
* than move the tooltip, widen the target: a click anywhere in the plot drills
* whichever point the tooltip is currently reading. The hit area then matches
* the feedback already on screen, so "the tooltip says 2024, I click, I get
* 2024" holds, and the dot goes back to being an affordance rather than a
* target you have to chase.
*
* Only for the point-based types, since a bar, slice or tile is already a
* comfortably large mark and drilling one by clicking the background near it
* would be surprising.
*/
_wirePlotClick() {
if (!Environment.isBrowser()) return
const baseEl = this.w.dom.baseEl
if (!baseEl || this._plotClickWired === baseEl) return
if (this._plotClickWired) {
this._plotClickWired.removeEventListener('mousedown', this._onPlotDown)
this._plotClickWired.removeEventListener('click', this._onPlotClick)
}
baseEl.addEventListener('mousedown', this._onPlotDown)
baseEl.addEventListener('click', this._onPlotClick)
this._plotClickWired = baseEl
}
/** @param {any} e */
_onPlotDown(e) {
this._downAt = { x: e.clientX, y: e.clientY }
}
/**
* @param {any} e
* @returns {any}
*/
_onPlotClick(e) {
const w = this.w
if (!w.config.drilldown || !w.config.drilldown.enabled) return undefined
// A zoom/pan gesture ends in a click too. Only a click that did not travel
// counts as one, or every zoom selection would drill on release.
const down = this._downAt
this._downAt = null
if (down && Math.hypot(e.clientX - down.x, e.clientY - down.y) > 4) {
return undefined
}
const target = /** @type {Element} */ (e.target)
if (!target || typeof target.closest !== 'function') return undefined
// The mark handles its own click through dataPointSelection; running here
// as well would drill twice.
if (target.closest('.apexcharts-drilldown-target')) return undefined
// Chrome around the plot is not the plot.
if (
target.closest(
'.apexcharts-legend, .apexcharts-toolbar, .apexcharts-breadcrumb, .apexcharts-menu, .apexcharts-tooltip',
)
) {
return undefined
}
const i = w.interact.capturedSeriesIndex
const j = w.interact.capturedDataPointIndex
if (i == null || j == null || i < 0 || j < 0) return undefined
// Any point-based series, not just one drawn without markers: a 5px marker
// the author supplied is no easier to hit than a 6px dot we supplied.
if (!this._isPointBasedSeries(w.config.series[i])) return undefined
const point = this._pointAt(i, j)
if (!point || typeof point !== 'object' || point.drilldown == null) {
return undefined
}
return this.drillDown(point.drilldown, point, {
seriesIndex: i,
dataPointIndex: j,
})
}
/**
* A series mark that is deliberately click-through. Restricted to markers
* inside the plot: the tooltip draws its own `no-pointer-events` marker, and
* that one must stay click-through or it would sit under the cursor and eat
* the hover it exists to follow.
* @param {Element} node
* @returns {boolean}
*/
_isClickThroughMark(node) {
if (!node.classList || !node.classList.contains('no-pointer-events')) {
return false
}
if (!node.classList.contains('apexcharts-marker')) return false
return !(
typeof node.closest === 'function' && node.closest('.apexcharts-tooltip')
)
}
/**
* Discrete-marker entries that give each drillable point a visible dot.
*
* Only series drawn WITHOUT markers get them, so an author who already shows
* markers keeps their styling untouched, and only drillable points get one, so
* the dots read as "these are the ones you can open" rather than turning every
* point into a dot. Core renders discrete markers even when `markers.size` is
* 0, which is what makes the affordance possible without a core change.
*
* Entries are tagged so a resync replaces ours and leaves the author's alone.
* @param {any[]} series - the series being rendered (a drill applies its
* level's series, which are not yet on `w.config` when this runs)
* @returns {any[]}
*/
_drillMarkers(series) {
const w = this.w
const cfg = w.config.drilldown
const authored = Array.isArray(w.config.markers && w.config.markers.discrete)
? w.config.markers.discrete.filter(
(/** @type {any} */ d) => !d || !d[DRILL_MARKER],
)
: []
const mk = (cfg && cfg.marker) || {}
if (mk.show === false || !Array.isArray(series)) return authored
/** @type {any[]} */
const own = []
series.forEach((/** @type {any} */ s, /** @type {number} */ i) => {
if (!this._seriesNeedsDrillMarker(i, s)) return
const data = s && Array.isArray(s.data) ? s.data : null
if (!data) return
data.forEach((/** @type {any} */ point, /** @type {number} */ j) => {
if (!point || typeof point !== 'object' || point.drilldown == null) return
/** @type {any} */
const entry = { seriesIndex: i, dataPointIndex: j, [DRILL_MARKER]: true }
// Declared fields only: an omitted one inherits the series default
// rather than blanking it (see Markers.getMarkerConfig).
if (mk.size !== undefined) entry.size = mk.size
if (mk.shape !== undefined) entry.shape = mk.shape
if (mk.fillColor !== undefined) entry.fillColor = mk.fillColor
if (mk.strokeColor !== undefined) entry.strokeColor = mk.strokeColor
own.push(entry)
})
})
return authored.concat(own)
}
/**
* Whether a series needs drill dots supplied for it: a point-based type whose
* marks are the markers, drawn with markers off. Bar, pie, treemap and heatmap
* marks are already real clickable elements, and a series that already shows
* markers already has its affordance.
* @param {number} i @param {any} s
* @returns {boolean}
*/
_seriesNeedsDrillMarker(i, s) {
if (!this._isPointBasedSeries(s)) return false
const size = this.w.config.markers && this.w.config.markers.size
const effective = Array.isArray(size) ? size[i] : size
return !(Number(effective) > 0)
}
/**
* A series whose marks are markers (a point), rather than a shape big enough
* to aim at on its own.
* @param {any} s
* @returns {boolean}
*/
_isPointBasedSeries(s) {
const type = (s && s.type) || this.w.config.chart.type
return type === 'line' || type === 'area'
}
}