apexcharts
Version:
A JavaScript Chart Library
1,374 lines (1,228 loc) • 77.9 kB
JavaScript
// @ts-check
import Base from './modules/Base'
import CoreUtils from './modules/CoreUtils'
import DataLabels from './modules/DataLabels'
import PerformanceCache from './utils/PerformanceCache'
import Defaults from './modules/settings/Defaults'
import Grid from './modules/axes/Grid'
import Markers from './modules/Markers'
import Range from './modules/Range'
import Utils from './utils/Utils'
import { getThemePalettes } from './utils/ThemePalettes.js'
import XAxis from './modules/axes/XAxis'
import YAxis from './modules/axes/YAxis'
import InitCtxVariables from './modules/helpers/InitCtxVariables'
import { applyAnimationPolicy } from './modules/Animations'
import Destroy from './modules/helpers/Destroy'
import {
register,
markCustom,
isCustom,
hasChartClass,
unregister,
} from './modules/ChartFactory'
import { registerTheme, unregisterTheme } from './modules/ThemeRegistry'
import { registerEasing } from './modules/animations/Easing'
import { trimStreamingSeries } from './modules/animations/StreamScroll'
import { applyAxisTransition } from './modules/animations/AxisTransition'
import { applyDataLabelTransition } from './modules/animations/DataLabelTransition'
import {
registerPlugin as registerPluginImpl,
unregisterPlugin as unregisterPluginImpl,
} from './modules/weave/PluginRegistry'
import RendererController from './modules/RendererController'
import { addResizeListener, removeResizeListener } from './utils/Resize'
import apexCSS from './assets/apexcharts.css'
import { Environment } from './utils/Environment.js'
import { BrowserAPIs } from './ssr/BrowserAPIs.js'
import { LicenseManager } from './modules/license/LicenseManager'
import {
enforceLicense,
teardownWatermark,
untrackChart,
reevaluateLicenseAcrossCharts,
} from './modules/license/LicenseEnforcer'
/**
*
* @module ApexCharts
**/
export default class ApexCharts {
// Module properties set dynamically by InitCtxVariables.initModules().
// Declared as typed class fields so @ts-check resolves them throughout the
// class body without errors. Each field typed as `any` since the modules are
// plain objects whose specific shapes are not yet typed.
/** @type {any} */ core
/** @type {any} */ responsive
/** @type {any} */ axes
/** @type {any} */ grid
/** @type {any} */ graphics
/** @type {any} */ coreUtils
/** @type {any} */ crosshairs
/** @type {any} */ events
/** @type {any} */ fill
/** @type {any} */ localization
/** @type {any} */ options
/** @type {any} */ series
/** @type {any} */ theme
/** @type {any} */ formatters
/** @type {any} */ titleSubtitle
/** @type {any} */ dimensions
/** @type {any} */ updateHelpers
/** @type {any} */ tooltip
/** @type {any} */ data
/** @type {any} */ animations
/** @type {any} */ exports
/** @type {any} */ legend
/** @type {any} */ toolbar
/** @type {any} */ zoomPanSelection
/** @type {any} */ keyboardNavigation
/** @type {any} */ annotations
/** @type {any} */ morphTypeChange
/** @type {any} */ timeScale
/** @type {any} */ _keyboardNavigation
/** @type {any} */ _zoomPanSelection
/** @type {any} */ windowResizeHandler
/** @type {any} */ parentResizeHandler
/** @type {string[]} */ publicMethods = []
/** @type {string[]} */ eventList = []
/** @type {Promise<any> | null} */ _renderPromise = null
/** @type {any} */ config
/** @type {any} */ perspectives
/** @type {any} */ storyboard
/** @type {any} */ history
/** @type {any} */ linkedViews
/** @type {any} */ ink
/** @type {any} */ measure
/** @type {any} */ contextMenu
/** @type {any} */ weave
/** @type {any} */ renderer
/** @type {any} */ rendererController
/**
* Static Perspectives helpers (decode/fromURL), populated by the perspectives
* feature when imported (`import 'apexcharts/features/perspectives'`); null
* otherwise. Declared here as a placeholder so core stays free of the
* Perspectives module while the assignment in the feature file type-checks.
* @type {any}
*/
static perspectives = null
/**
* Creates a new ApexCharts instance.
*
* @param {HTMLElement} el - The DOM element to render the chart into.
* @param {ApexOptions} opts - Chart configuration options.
*/
constructor(el, opts) {
this.opts = opts
this.ctx = this
// Pass the user supplied options to the Base Class where these options will be extended with defaults. The returned object from Base Class will become the config object in the entire codebase.
this.w = new Base(opts).init()
this.el = el
this.w.globals.cuid = Utils.randomId()
this.w.globals.chartID = this.w.config.chart.id
? Utils.escapeString(this.w.config.chart.id)
: this.w.globals.cuid
applyAnimationPolicy(this.w)
const initCtx = new InitCtxVariables(this)
initCtx.initModules()
this.lastUpdateOptions = null
// Dev/test hook (render-2026 update-throughput work): counts which path
// each data update took. `fast` = series-only repaint, `fastWithAxes` =
// series repaint + in-place axis/grid chrome refresh (scale changed),
// `full` = fallback to a complete re-render.
this._updateStats = { fast: 0, fastWithAxes: 0, full: 0 }
this.create = this.create.bind(this)
// bind event handlers in browser environment
if (Environment.isBrowser()) {
this.windowResizeHandler = this._windowResizeHandler.bind(this)
this.parentResizeHandler = this._parentResizeCallback.bind(this)
}
}
/**
* Renders the chart. Must be called once after construction.
*
* @returns {Promise<ApexCharts>} Resolves with the chart instance after mount.
*/
render() {
if (!this.w?.config?.chart) {
return Promise.reject(
new Error(
'ApexCharts: chart configuration is missing or invalid. Ensure the options object includes a `chart` property.',
),
)
}
// Idempotent: a second render() call (deliberate or a framework double
// effect) must not build a duplicate chart tree in the same element.
// Return the in-flight/settled promise instead; destroy() clears it so a
// destroyed instance can be rendered fresh, and a rejected render clears
// itself so callers can retry (e.g. after attaching the element).
if (this._renderPromise) return this._renderPromise
const renderPromise = new Promise((resolve, reject) => {
// only draw chart, if element found
if (Utils.elementExists(this.el)) {
if (typeof Apex._chartInstances === 'undefined') {
Apex._chartInstances = []
}
if (this.w.config.chart.id) {
Apex._chartInstances.push({
id: this.w.globals.chartID,
group: this.w.config.chart.group,
chart: this,
})
}
// set the locale here
this.setLocale(this.w.config.chart.defaultLocale)
const beforeMount = this.w.config.chart.events.beforeMount
if (typeof beforeMount === 'function') {
beforeMount(this, this.w)
}
this.events.fireEvent('beforeMount', [this, this.w])
// add event listeners in browser environment
if (Environment.isBrowser()) {
window.addEventListener('resize', this.windowResizeHandler)
addResizeListener(
/** @type {HTMLElement} */ (this.el.parentNode),
this.parentResizeHandler,
)
const rootNode = /** @type {any} */ (
this.el.getRootNode && this.el.getRootNode()
)
const inShadowRoot = Utils.is('ShadowRoot', rootNode)
const doc = this.el.ownerDocument
let css = inShadowRoot
? rootNode.getElementById('apexcharts-css')
: doc.getElementById('apexcharts-css')
if (!css) {
css = BrowserAPIs.createElementNS(
'http://www.w3.org/1999/xhtml',
'style',
)
css.id = 'apexcharts-css'
css.textContent = apexCSS
const nonce = this.opts.chart?.nonce || this.w.config.chart.nonce
if (nonce) {
css.setAttribute('nonce', nonce)
}
if (inShadowRoot) {
// We are in Shadow DOM, add to shadow root
rootNode.prepend(css)
} else if (this.w.config.chart.injectStyleSheet !== false) {
// Add to <head> of element's document
doc.head.appendChild(css)
}
}
}
const graphData = this.create(this.w.config.series, {})
if (!graphData) return resolve(this)
this.mount(graphData)
.then(() => {
if (typeof this.w.config.chart.events.mounted === 'function') {
this.w.config.chart.events.mounted(this, this.w)
}
this.events.fireEvent('mounted', [this, this.w])
// @ts-ignore — graphData is the internal render result, resolve type is widened
resolve(graphData)
})
.catch((e) => {
// handle error in case no data or element not found
const enriched = e instanceof Error ? e : new Error(String(e))
const err = /** @type {any} */ (enriched)
err.chartId = this.w?.globals?.chartID
err.el = this.el
reject(enriched)
})
} else {
reject(new Error('Element not found'))
}
})
this._renderPromise = renderPromise
renderPromise.catch(() => {
if (this._renderPromise === renderPromise) this._renderPromise = null
})
return renderPromise
}
/**
* @param {any[]} ser
* @param {object} opts
*/
create(ser, opts) {
const w = this.w
// Core modules are preserved across updates (Destroy.clear skips them when
// isUpdating=true). Only re-init when a full destroy() was called first.
if (!this.core) {
const initCtx = new InitCtxVariables(this)
initCtx.initModules()
}
const gl = this.w.globals
gl.noData = false
gl.animationEnded = false
if (!Utils.elementExists(this.el)) {
gl.animationEnded = true
return null
}
this.responsive.checkResponsiveConfig(opts)
// Cadence (#6) P1: re-resolve chart.animations.easing on every render so an
// updateOptions that changes the easing (name / cubic-bezier / fn) actually
// takes effect on the next tween. Runs after responsive merge so it sees the
// final config; idempotent, so the constructor's earlier call is harmless.
applyAnimationPolicy(w)
// @ts-ignore — convertedCatToNumeric is an internal property set by Defaults
if (w.config.xaxis.convertedCatToNumeric) {
const defaults = new Defaults(w.config)
defaults.convertCatToNumericXaxis(w.config, this.ctx)
}
this.core.setupElements()
if (w.config.chart.type === 'treemap') {
w.config.grid.show = false
w.config.yaxis[0].show = false
}
if (gl.svgWidth === 0) {
// if the element is hidden, skip drawing
gl.animationEnded = true
return null
}
let series = ser
/**
* @param {Record<string, any>} s
* @param {number} realIndex
*/
ser.forEach((s, realIndex) => {
if (s.hidden) {
series = this.legend.legendHelpers.getSeriesAfterCollapsing({
realIndex,
})
}
})
const combo = CoreUtils.checkComboSeries(series, w.config.chart.type)
gl.comboCharts = combo.comboCharts
gl.comboBarCount = combo.comboBarCount
/**
* @param {Record<string, any>} s
*/
const allSeriesAreEmpty = series.every((s) => s.data && s.data.length === 0)
if (
series.length === 0 ||
(allSeriesAreEmpty && gl.collapsedSeries.length < 1)
) {
this.series.handleNoData()
}
if (Environment.isBrowser()) {
this.events.setupEventHandlers()
}
// Handle the data inputted by user and set some of the global variables (for eg, if data is datetime / numeric / category). Don't calculate the range / min / max at this time
// Phase 1: return value is captured; named writers are stubs (mutations already wrote to gl).
// Phase 2: writers will route each slice to its dedicated w.* namespace.
const parsedState = this.data.parseData(series)
this._writeParsedSeriesData(parsedState.seriesData)
this._writeParsedRangeData(parsedState.rangeData)
this._writeParsedCandleData(parsedState.candleData)
this._writeParsedLabelData(parsedState.labelData)
this._writeParsedAxisFlags(parsedState.axisFlags)
// Strata: choose the active series renderer now that mark count is known.
this.rendererController?.resolve()
// Weave: plugins react to freshly parsed data (geometry not computed yet).
this.weave?.dispatch('afterParse')
// this is a good time to set theme colors first
this.theme.init()
// as markers accepts array, we need to setup global markers for easier access
const markers = new Markers(this.w, this)
markers.setGlobalMarkerSize()
// labelFormatters should be called before dimensions as in dimensions we need text labels width
this.formatters.setLabelFormatters()
this.titleSubtitle.draw()
// legend is calculated here before coreCalculations because it affects the plottable area
// if there is some data to show or user collapsed all series, then proceed drawing legend
if (
!gl.noData ||
gl.collapsedSeries.length === w.seriesData.series.length ||
w.config.legend.showForSingleSeries
) {
this.legend?.init()
}
// check whether in multiple series, all series share the same X
this.series.hasAllSeriesEqualX()
// coreCalculations will give the min/max range and yaxis/axis values. It should be called here to set series variable from config to globals
if (gl.axisCharts) {
this.core.coreCalculations()
if (w.config.xaxis.type !== 'category') {
// as we have minX and maxX values, determine the default DateTimeFormat for time series
this.formatters.setLabelFormatters()
}
if (this.ctx.toolbar) {
this.ctx.toolbar.minX = w.globals.minX
this.ctx.toolbar.maxX = w.globals.maxX
}
}
// we need to generate yaxis for heatmap separately as we are not showing numerics there, but seriesNames. There are some tweaks which are required for heatmap to align labels correctly which are done in below function
// Also we need to do this before calculating Dimensions plotCoords() method of Dimensions
this.formatters.heatmapLabelFormatters()
// get the largest marker size which will be needed in dimensions calc
const coreUtils = new CoreUtils(this.w)
coreUtils.getLargestMarkerSize()
// We got plottable area here, next task would be to calculate axis areas
// Phase 1: return value captured; named writer is a stub (no-op).
// Phase 2: writer will route layout slice to w.layout namespace.
const layoutState = this.dimensions.plotCoords()
this._writeLayoutCoords(layoutState.layout)
const xyRatios = this.core.xySettings()
// Weave: plugins compute against final geometry (scales are ready).
this.weave?.dispatch('afterScales', { xyRatios })
this.grid.createGridMask()
const elGraph = this.core.plotChartType(series, xyRatios)
const dataLabels = new DataLabels(this.w, this)
dataLabels.bringForward()
if (w.config.dataLabels.background.enabled) {
dataLabels.dataLabelsBackground()
}
// after all the drawing calculations, shift the graphical area (actual charts/bars) excluding legends
this.core.shiftGraphPosition()
// The heatmap gradient legend is drawn before plotCoords (so it can be
// measured), so it can only pin to the chart's outer edge at that point.
// Now that the plot geometry is final, re-pin it to hug the plot.
this.legend?.heatmapGradientLegend?.repositionToPlot()
if (w.globals.dataPoints > 50) {
w.dom.elWrap.classList.add('apexcharts-disable-transitions')
}
const dim = {
plot: {
left: w.layout.translateX,
top: w.layout.translateY,
width: w.layout.gridWidth,
height: w.layout.gridHeight,
},
}
return {
elGraph,
xyRatios,
dimensions: dim,
}
}
/**
* @param {any} graphData
*/
mount(graphData = null) {
const me = this
const w = me.w
return new Promise((resolve, reject) => {
// no data to display
if (me.el === null) {
return reject(
new Error('Not enough data to display or target element not found'),
)
} else if (graphData === null || w.globals.allSeriesCollapsed) {
me.series.handleNoData()
}
me.grid = new Grid(me.w, me)
const elgrid = me.grid.drawGrid()
const AnnotationsCtor =
InitCtxVariables._featureRegistry.get('annotations')
me.annotations = AnnotationsCtor
? new AnnotationsCtor(me.w, {
theme: me.theme,
timeScale: me.timeScale,
})
: null
me.annotations?.drawImageAnnos()
me.annotations?.drawTextAnnos()
if (w.config.grid.position === 'back') {
if (elgrid) {
w.dom.elGraphical.add(elgrid.el)
}
if (elgrid?.elGridBorders?.node) {
w.dom.elGraphical.add(elgrid.elGridBorders)
}
}
if (Array.isArray(graphData.elGraph)) {
for (let g = 0; g < graphData.elGraph.length; g++) {
w.dom.elGraphical.add(graphData.elGraph[g])
}
} else {
w.dom.elGraphical.add(graphData.elGraph)
}
if (w.config.grid.position === 'front') {
if (elgrid) {
w.dom.elGraphical.add(elgrid.el)
}
if (elgrid?.elGridBorders?.node) {
w.dom.elGraphical.add(elgrid.elGridBorders)
}
}
if (w.config.xaxis.crosshairs.position === 'front') {
me.crosshairs.drawXCrosshairs()
}
if (w.config.yaxis[0].crosshairs.position === 'front') {
me.crosshairs.drawYCrosshairs()
}
if (w.config.chart.type !== 'treemap') {
me.axes.drawAxis(w.config.chart.type, elgrid)
}
const xAxis = new XAxis(this.w, this.ctx, elgrid)
const yaxis = new YAxis(
this.w,
{ theme: this.theme, timeScale: this.timeScale },
elgrid,
)
if (elgrid !== null) {
xAxis.xAxisLabelCorrections()
yaxis.setYAxisTextAlignments()
// @ts-ignore — yaxis is always normalised to ApexYAxis[] by Config.init()
w.config.yaxis.map((yaxe, index) => {
if (w.globals.ignoreYAxisIndexes.indexOf(index) === -1) {
yaxis.yAxisTitleRotate(index, yaxe.opposite)
}
})
}
me.annotations?.drawAxesAnnotations()
if (!w.globals.noData) {
// draw tooltips at the end (browser only — tooltip is DOM-heavy)
if (
Environment.isBrowser() &&
w.config.tooltip.enabled &&
!w.globals.noData
) {
me.w.globals.tooltip?.drawTooltip(graphData.xyRatios)
}
if (
w.config.chart.accessibility.enabled &&
w.config.chart.accessibility.keyboard.enabled &&
w.config.chart.accessibility.keyboard.navigation.enabled
) {
me.keyboardNavigation?.init()
}
if (
Environment.isBrowser() &&
w.globals.axisCharts &&
(w.axisFlags.isXNumeric ||
/** @type {Record<string,any>} */ (w.config.xaxis)
.convertedCatToNumeric ||
w.axisFlags.isRangeBar)
) {
if (
w.config.chart.zoom.enabled ||
(w.config.chart.selection && w.config.chart.selection.enabled) ||
// @ts-ignore — chart.pan is an internal toolbar config property
(w.config.chart.pan && w.config.chart.pan.enabled)
) {
me.zoomPanSelection?.init({
xyRatios: graphData.xyRatios,
})
}
} else {
const tools = w.config.chart.toolbar.tools
const toolsArr = [
'zoom',
'zoomin',
'zoomout',
'selection',
'pan',
'reset',
]
toolsArr.forEach((t) => {
tools[t] = false
})
}
if (w.config.chart.toolbar.show && !w.globals.allSeriesCollapsed) {
me.toolbar?.createToolbar()
}
}
// Weave: main render hook: series/grid/axes are live in elGraphical now.
me.weave?.dispatch('draw', {
pass: 'full',
xyRatios: graphData?.xyRatios,
})
if (w.globals.memory.methodsToExec.length > 0) {
w.globals.memory.methodsToExec.forEach((fn) => {
fn.method(fn.params, false, fn.context)
})
}
if (!w.globals.axisCharts && !w.globals.noData) {
me.core.resizeNonAxisCharts()
}
// License: show/remove the trial watermark for gated premium features.
// Runs last, after the DOM cache (w.dom.elWrap) is populated.
enforceLicense(w, me)
resolve(me)
})
}
/**
* Destroys the chart instance, removes all DOM elements and event listeners.
* After calling this, the instance should not be used again.
*/
destroy() {
// allow a fresh render() on this instance after teardown
this._renderPromise = null
// remove event listeners in browser environment
if (Environment.isBrowser()) {
window.removeEventListener('resize', this.windowResizeHandler)
removeResizeListener(
/** @type {Element} */ (this.el.parentNode),
this.parentResizeHandler,
)
// cancel any pending resize redraw so a queued update() can't run against
// a torn-down chart after destroy(). See react-apexcharts#602.
clearTimeout(this.w.globals.resizeTimer ?? undefined)
}
// remove the chart's instance from the global Apex._chartInstances
const chartID = this.w.config.chart.id
if (chartID && Array.isArray(Apex._chartInstances)) {
/**
* @param {Record<string, any>} c
* @param {number} i
*/
Apex._chartInstances.forEach(
(/** @type {any} */ c, /** @type {any} */ i) => {
if (c.id === Utils.escapeString(chartID)) {
Apex._chartInstances.splice(i, 1)
}
},
)
}
if (this._keyboardNavigation) {
this._keyboardNavigation.destroy()
}
// License: disconnect the watermark MutationObserver(s) so a torn-down
// chart leaves nothing observing the DOM, and stop reconciling it so the
// enforcer does not hold on to its context.
teardownWatermark(this)
untrackChart(this)
new Destroy(this.ctx).clear({ isUpdating: false })
}
/**
* Merges new options into the existing config and re-renders the chart.
*
* @param {ApexOptions} options - Partial config object merged with the existing config.
* @param {boolean} [redraw=false] - When true, redraws the chart from scratch instead of animating from previous paths.
* @param {boolean} [animate=true] - Whether to animate the update.
* @param {boolean} [updateSyncedCharts=true] - Whether to propagate the update to charts in the same group.
* @param {boolean} [overwriteInitialConfig=true] - When true, replaces the stored initial config used by resetSeries().
* @returns {Promise<ApexCharts>} Resolves with the chart instance after re-render.
*/
updateOptions(
options,
redraw = false,
animate = true,
updateSyncedCharts = true,
overwriteInitialConfig = true,
) {
const w = this.w
// when called externally, clear some global variables
// fixes apexcharts.js#1488
w.interact.selection = undefined
// try shallow comparison first before expensive JSON.stringify
if (this.lastUpdateOptions) {
// quick shallow check on top-level keys
if (Utils.shallowEqual(this.lastUpdateOptions, options)) {
return Promise.resolve(this)
}
// If shallow check fails, do deep comparison only for critical paths
// check series separately (skipped for data-heavy series, where the
// stringify costs more than the render it might save)
if (
options.series &&
this.lastUpdateOptions.series &&
!ApexCharts._optionsTooBigToCompare(options)
) {
if (
JSON.stringify(this.lastUpdateOptions.series) ===
JSON.stringify(options.series)
) {
// series unchanged, check other options
const optionsWithoutSeries = { ...options }
const lastWithoutSeries = { ...this.lastUpdateOptions }
delete optionsWithoutSeries.series
delete lastWithoutSeries.series
if (Utils.shallowEqual(optionsWithoutSeries, lastWithoutSeries)) {
return Promise.resolve(this)
}
}
}
}
if (options.series) {
this.data.resetParsingFlags()
this.series.resetSeries(false, true, false)
if (options.series.length && options.series[0].data) {
/**
* @param {Record<string, any>} s
* @param {number} i
*/
options.series = options.series.map(
(/** @type {any} */ s, /** @type {any} */ i) => {
return this.updateHelpers._extendSeries(s, i)
},
)
}
// user updated the series via updateOptions() function.
// Hence, we need to reset axis min/max to avoid zooming issues
this.updateHelpers.revertDefaultAxisMinMax()
}
// user has set x-axis min/max externally - hence we need to forcefully set the xaxis min/max
if (options.xaxis) {
options = this.updateHelpers.forceXAxisUpdate(options)
}
if (options.yaxis) {
options = this.updateHelpers.forceYAxisUpdate(options)
}
if (w.globals.collapsedSeriesIndices.length > 0) {
this.series.clearPreviousPaths()
}
/* update theme mode#459 */
if (options.theme) {
options = this.theme.updateThemeOptions(options)
}
return this.updateHelpers._updateOptions(
options,
redraw,
animate,
updateSyncedCharts,
overwriteInitialConfig,
)
}
/**
* Replaces the chart's series data and re-renders.
*
* @param {ApexAxisChartSeries | ApexNonAxisChartSeries} [newSeries=[]] - The replacement series array.
* @param {boolean} [animate=true] - Whether to animate the update.
* @param {boolean} [overwriteInitialSeries=true] - When true, replaces the stored initial series used by resetSeries().
* @returns {Promise<ApexCharts>} Resolves with the chart instance after re-render.
*/
updateSeries(newSeries = [], animate = true, overwriteInitialSeries = true) {
this.data.resetParsingFlags()
// clears collapse/path bookkeeping without restoring (and deep-cloning)
// the initialSeries snapshot that parseData is about to overwrite anyway
this.series.prepareDataUpdate()
this.updateHelpers.revertDefaultAxisMinMax()
return this.updateHelpers._updateSeries(
newSeries,
animate,
overwriteInitialSeries,
)
}
/**
* Appends a new series to the existing series array and re-renders.
*
* @param {ApexAxisChartSeries[0] | ApexNonAxisChartSeries} newSerie - The series object to append.
* @param {boolean} [animate=true] - Whether to animate the update.
* @param {boolean} [overwriteInitialSeries=true] - When true, replaces the stored initial series used by resetSeries().
* @returns {Promise<ApexCharts>} Resolves with the chart instance after re-render.
*/
appendSeries(newSerie, animate = true, overwriteInitialSeries = true) {
this.data.resetParsingFlags()
const newSeries = this.w.config.series.slice()
newSeries.push(/** @type {any} */ (newSerie))
this.series.prepareDataUpdate()
this.updateHelpers.revertDefaultAxisMinMax()
return this.updateHelpers._updateSeries(
newSeries,
animate,
overwriteInitialSeries,
)
}
/**
* Appends data points to existing series without replacing them.
* Each element of `newData` corresponds to the series at the same index.
*
* @param {Array<{ data: any[] }>} newData - Data to append, in the same shape as series[].data.
* @param {boolean} [overwriteInitialSeries=true] - When true, updates the stored initial series used by resetSeries().
* @returns {Promise<ApexCharts>} Resolves with the chart instance after re-render.
*/
appendData(newData, overwriteInitialSeries = true) {
const me = this
me.data.resetParsingFlags()
me.w.globals.dataChanged = true
// previous paths feed the update morph; with animations off nothing
// consumes them, and the capture is O(n) (stream-frame + DOM walk)
if (me.w.config.chart.animations.enabled) {
me.series.getPreviousPaths()
}
const newSeries = me.w.config.series.slice()
for (let i = 0; i < newSeries.length; i++) {
if (newData[i] !== null && typeof newData[i] !== 'undefined') {
// series entries are always ApexAxisChartSeries objects here
const srcSerie = /** @type {any} */ (newData[i])
const dstSerie = /** @type {any} */ (newSeries[i])
for (let j = 0; j < srcSerie.data.length; j++) {
dstSerie.data.push(srcSerie.data[j])
}
}
}
// chart.streaming: bound memory: drop points that scrolled out of the
// window (xaxis.range + runway) or beyond maxPoints. Without it a
// long-running stream grows the series array without limit.
trimStreamingSeries(newSeries, me.w)
me.w.config.series = newSeries
if (overwriteInitialSeries) {
// lazy snapshot: deep clone deferred to first read
me.w.globals.initialSeries = me.w.config.series
}
return this.update()
}
/**
* True when an options object carries enough series data that a
* JSON.stringify equality check (and the Utils.clone needed to store it for
* later comparison) would cost more than the re-render it tries to avoid.
* @param {any} options
* @returns {boolean}
*/
static _optionsTooBigToCompare(options) {
const series = options && options.series
if (!Array.isArray(series)) return false
let points = 0
for (let i = 0; i < series.length; i++) {
const d = series[i] && series[i].data
points += Array.isArray(d) ? d.length : 1
if (points > 1000) return true
}
return false
}
/**
* @param {object} [options]
*/
update(options) {
return new Promise((resolve, reject) => {
// The identical-options skip only pays off for small configs: comparing
// and cloning megabytes of series data costs more per update than the
// render it might save. Data-heavy paths (updateSeries/appendData) call
// update() with no options at all and skip straight through.
if (
options &&
this.lastUpdateOptions &&
!ApexCharts._optionsTooBigToCompare(options) &&
JSON.stringify(this.lastUpdateOptions) === JSON.stringify(options)
) {
// Options are identical, skip the update
return resolve(this)
}
// Series data changed (or is too big to compare cheaply): any previously
// stored options no longer describe the chart, so clear rather than
// deep-clone a potentially huge object.
this.lastUpdateOptions =
options && !ApexCharts._optionsTooBigToCompare(options)
? Utils.clone(options)
: null
new Destroy(this.ctx).clear({ isUpdating: true })
const graphData = this.create(this.w.config.series, options ?? {})
if (!graphData) return resolve(this)
this.mount(graphData)
.then(() => {
// Chrome cross-fade for cross-type morph (no-op when the morph
// feature isn't registered or no morph was captured this update).
this.morphTypeChange?.applyChromeFade()
// Variable-length update: slide surviving tick labels/gridlines to
// their new positions and fade in the new ones, on the same clock
// as the series morph (no-op otherwise; consumes prevChromeFrame).
applyAxisTransition(this.w)
// Opt-in bar-chart-race polish: ride data labels to their new slot
// and count their value up, on the same clock as the morph (no-op
// unless dataLabels.animate/countUp is enabled and a frame was
// captured this update).
applyDataLabelTransition(this.w)
if (typeof this.w.config.chart.events.updated === 'function') {
this.w.config.chart.events.updated(this, this.w)
}
this.events.fireEvent('updated', [this, this.w])
this.w.globals.isDirty = true
resolve(this)
})
.catch((e) => {
reject(e)
})
})
}
/**
* Redraws the scale-dependent chrome (grid lines, x-axis, y-axes) IN PLACE
* within the frozen layout after a data-only update changed the axis
* domain. The rebuilt groups replace the old nodes positionally, so z-order
* (grid back/front) is preserved without re-running mount. Legend,
* annotations containers, toolbar, defs/masks, and the plot geometry are
* untouched.
*
* Returns false when the refresh cannot faithfully reproduce the chart and
* the caller must fall back to a full render:
* - horizontal bar charts (inversed axes draw through a different path)
* - charts with annotations or ink notes (their positions are scale-bound
* and are laid out by the full render)
* - the new y labels no longer fit the width reserved at layout time
*
* @param {any} _xyRatios
* @returns {boolean} true when the chrome was refreshed in place
*/
_fastAxisChromeRefresh(_xyRatios) {
const w = this.w
const gl = w.globals
/** @type {string} dev hook: why the last in-place refresh bailed */
this._fastAxisBailReason = ''
try {
if (gl.isBarHorizontal) {
this._fastAxisBailReason = 'barHorizontal'
return false
}
if (w.config.chart.sparkline.enabled) return true // no axis chrome at all
const a = w.config.annotations
if (
a &&
((a.yaxis && a.yaxis.length) ||
(a.xaxis && a.xaxis.length) ||
(a.points && a.points.length) ||
(a.texts && a.texts.length) ||
(a.images && a.images.length))
) {
this._fastAxisBailReason = 'annotations'
return false
}
if (w.config.chart.ink && w.config.chart.ink.enabled) {
this._fastAxisBailReason = 'ink'
return false
}
// Frozen-layout guard: measure the new y labels with the same code the
// layout pass uses; when they need more width than was reserved, the
// plot rect would have to shrink, which only a full render can do.
// (Narrower labels are fine: they just leave a little extra padding.)
const dim = this.dimensions
if (!dim || !dim.dimYAxis) {
this._fastAxisBailReason = 'noDimensions'
return false
}
const prevYLabelsCoords = w.layout.yLabelsCoords
const prevYTitleCoords = w.layout.yTitleCoords
const yaxisLabelCoords = dim.dimYAxis.getyAxisLabelsCoords()
const yTitleCoords = dim.dimYAxis.getyAxisTitleCoords()
w.layout.yLabelsCoords = []
w.layout.yTitleCoords = []
w.config.yaxis.map((_yaxe, index) => {
w.layout.yLabelsCoords.push({
width: yaxisLabelCoords[index].width,
index,
})
w.layout.yTitleCoords.push(
/** @type {any} */ ({ width: yTitleCoords[index].width, index }),
)
})
const newYAxisWidth = dim.dimYAxis.getTotalYAxisWidth()
// 2px slack: proportional digit-width variance ("115.00" vs "240.00")
// must not force a full render; a genuinely longer label (an extra
// digit is ~6px+) still does.
if (newYAxisWidth > dim.yAxisWidth + 2) {
// restore the layout-pass coords; a full render recomputes them
w.layout.yLabelsCoords = prevYLabelsCoords
w.layout.yTitleCoords = prevYTitleCoords
this._fastAxisBailReason = `labelWidth ${newYAxisWidth} > ${dim.yAxisWidth}`
return false
}
const innerEl = w.dom.elGraphical.node
// NOTE: old nodes are detached BEFORE their replacements are drawn:
// the axis renderers consult the live DOM while drawing (drawYaxis
// blanks labels it believes are duplicates of on-screen ones), so
// drawing while the old chrome is still attached produces empty labels.
// ── grid ──
const oldGrid = innerEl.querySelector('.apexcharts-grid')
const oldGridBorders = innerEl.querySelector('.apexcharts-grid-borders')
if (!oldGrid) {
this._fastAxisBailReason = 'missingGridNode'
return false
}
const gridParent = oldGrid.parentNode
const gridNext = oldGridBorders
? oldGridBorders.nextSibling
: oldGrid.nextSibling
oldGrid.remove()
if (oldGridBorders) oldGridBorders.remove()
// x-axis tick lines are appended by Grid directly to elGraphical (not
// inside the grid group), so sweep the old ones before redrawing
innerEl
.querySelectorAll('.apexcharts-xaxis-tick')
.forEach((/** @type {any} */ t) => t.remove())
this.grid = new Grid(w, this)
const elgrid = this.grid.drawGrid()
if (elgrid && elgrid.el) {
gridParent.insertBefore(elgrid.el.node, gridNext)
if (elgrid.elGridBorders && elgrid.elGridBorders.node) {
gridParent.insertBefore(elgrid.elGridBorders.node, gridNext)
}
}
// ── x-axis ──
const xAxis = new XAxis(this.w, this.ctx, elgrid)
const oldXaxis = innerEl.querySelector('.apexcharts-xaxis')
if (oldXaxis) {
const xParent = oldXaxis.parentNode
const xNext = oldXaxis.nextSibling
oldXaxis.remove()
const elXaxis = xAxis.drawXaxis()
xParent.insertBefore(elXaxis.node, xNext)
}
// ── y-axes (live on the Paper root, one group per axis) ──
const yAxis = new YAxis(
this.w,
{ theme: this.theme, timeScale: this.timeScale },
elgrid,
)
for (let index = 0; index < w.config.yaxis.length; index++) {
if (gl.ignoreYAxisIndexes.indexOf(index) !== -1) continue
const oldY = w.dom.baseEl.querySelector(
`.apexcharts-yaxis[rel='${index}']`,
)
if (!oldY) {
this._fastAxisBailReason = 'missingYAxisNode'
return false // structure changed under us: full render
}
const yParent = oldY.parentNode
if (!yParent) {
this._fastAxisBailReason = 'missingYAxisParent'
return false
}
const yNext = oldY.nextSibling
oldY.remove()
const elYaxis = yAxis.drawYaxis(index)
yParent.insertBefore(elYaxis.node, yNext)
}
// same post-draw corrections mount applies
if (elgrid !== null) {
xAxis.xAxisLabelCorrections()
yAxis.setYAxisTextAlignments()
w.config.yaxis.map((yaxe, index) => {
if (gl.ignoreYAxisIndexes.indexOf(index) === -1) {
yAxis.yAxisTitleRotate(index, yaxe.opposite)
}
})
}
return true
} catch (e) {
// any surprise falls back to the always-correct full render
this._fastAxisBailReason =
'error: ' + (e && /** @type {any} */ (e).message)
return false
}
}
/**
* Fast update path for data-only series changes.
*
* Skips rebuilding grid, axes, dimensions, legend, annotations, tooltip DOM,
* and toolbar. Only recalculates scales and replots the series paths.
* Called automatically by _updateSeries() when the fast path is eligible.
*
* @param {boolean} animate - Whether to animate the update.
* @param {string} [prevAxisScaleSig] - Signature of the on-screen axis scale
* captured by _updateSeries() before parseData recomputed bounds. When the
* recomputed scale differs, the fast path can't repaint the ruler in place,
* so it delegates to a full render. Omitted -> the check is skipped.
* @returns {Promise<ApexCharts>} Resolves with the chart instance.
*/
fastUpdate(animate, prevAxisScaleSig) {
return new Promise((resolve, reject) => {
try {
const w = this.w
const gl = w.globals
gl.shouldAnimate = animate
gl.dataChanged = true
gl.animationEnded = false
// Invalidate per-render selector cache (PerformanceCache uses TTL + render invalidation)
PerformanceCache.invalidateSelectors(w)
// Reset only axis bounds and caches — preserve already-parsed series data.
// (core.resetGlobals() would wipe w.seriesData.series which was parsed in _updateSeries)
const gl2 = w.globals
gl2.maxY = -Number.MAX_VALUE
gl2.minY = Number.MIN_VALUE
gl2.minYArr = []
gl2.maxYArr = []
gl2.maxX = -Number.MAX_VALUE
gl2.minX = Number.MAX_VALUE
gl2.initialMaxX = -Number.MAX_VALUE
gl2.initialMinX = Number.MAX_VALUE
gl2.yAxisScale = []
gl2.xAxisScale = null
gl2.xAxisTicksPositions = []
gl2.xRange = 0
gl2.yRange = []
gl2.zRange = 0
gl2.xTickAmount = 0
gl2.multiAxisTickAmount = 0
gl2.pointsArray = []
gl2.barCanvasCoords = null
gl2.dataLabelsRects = []
gl2.lastDrawnDataLabelsIndexes = []
gl2.textRectsCache = new Map()
gl2.domCache = new Map()
gl2.cachedSelectors = {}
gl2.disableZoomIn = false
gl2.disableZoomOut = false
// Recompute axis min/max and scale ranges from new data.
if (gl.axisCharts) {
this.core.coreCalculations()
if (w.config.xaxis.type !== 'category') {
this.formatters.setLabelFormatters()
}
}
// Heatmaps label the y-axis with series NAMES, not the numeric scale
// (create() does this via the same call). coreCalculations() above
// rebuilt yAxisScale numerically; without this fix-up the axis-scale
// signature below always differs (names vs numbers) and the chrome
// refresh repaints the y-axis with numeric ticks after the first
// data-only update.
this.formatters.heatmapLabelFormatters()
// Compute per-pixel ratios from the existing layout.
const xyRatios = this.core.xySettings()
// Long-lived interaction modules survive the fast path (only a full
// render recreates them); hand them the fresh pixel-to-data ratios or
// a later zoom/pan/brush would convert against the pre-update domain.
if (this._zoomPanSelection) this._zoomPanSelection.xyRatios = xyRatios
// The fast path repaints only the series layer; axes and grid are
// preserved in place (below). When the recomputed axis scale differs
// from what is on screen (`prevAxisScaleSig`, captured by
// _updateSeries before parseData wiped it), the ruler would go stale,
// so the axis/grid chrome is REDRAWN IN PLACE within the frozen
// layout (_fastAxisChromeRefresh). Only when that refresh cannot
// reproduce the chart (horizontal bars, annotations, labels no longer
// fitting the reserved axis width) does the update fall back to a
// full render. Compares the y-axis nice-scale ticks (the y-label
// source) and the numeric x-domain; xAxisScale is excluded (reset to
// null here, not rebuilt for category axes before this point, so it
// would false-positive on every update).
const newAxisScaleSig = JSON.stringify({
y: (gl.yAxisScale || []).map((s) => (s ? s.result : null)),
xMin: gl.minX,
xMax: gl.maxX,
})
const scaleChanged =
gl.axisCharts &&
prevAxisScaleSig != null &&
newAxisScaleSig !== prevAxisScaleSig
if (scaleChanged && !this._fastAxisChromeRefresh(xyRatios)) {
this._updateStats.full++
return this.update()
.then(() => resolve(this))
.catch(reject)
}
if (scaleChanged) {
this._updateStats.fastWithAxes++
} else {
this._updateStats.fast++
}
// Weave: geometry refreshed on the fast path.
this.weave?.dispatch('afterScales', { pass: 'fast', xyRatios })
// Remove only the series and data-label elements from elGraphical.
// Grid, axes, crosshairs, and masks are preserved in place. In canvas
// mode the <canvas> host wrap is REUSED (repainted in place) whenever
// it is still mounted: only its chrome contents are swept.
const rr = /** @type {any} */ (this.ctx.renderer)
const reuseCanvasHost = !!(
rr &&
rr.kind === 'canvas' &&
rr.canRepaintInPlace &&
rr.canRepaintInPlace()
)
if (reuseCanvasHost) rr._repaintHostInPlace = true
const innerEl = w.dom.elGraphical.node
const toRemove = innerEl.querySelectorAll(
(reuseCanvasHost ? '' : '.apexcharts-canvas-series-wrap, ') +
'.apexcharts-plot-series, .apexcharts-series, .apexcharts-datalabels, .apexcharts-datalabels-background',
)
/**
* @param {Element} el
*/
toRemove.forEach((/** @type {any} */ el) =>
el.parentNode?.removeChild(el),
)
// Redraw series paths into the existing graphical container.
const elGraph = this.core.plotChartType(w.config.series, xyRatios)
// Insert series elements at mount's z-position: before the grid when
// the grid is 'front', otherwise before the x-axis group (mount draws
// series first, then the axis chrome; a plain append would paint the
// series ON TOP of the axis line and ticks).
const gridEl = innerEl.querySelector('.apexcharts-grid')
const xaxisEl = innerEl.querySelector('.apexcharts-xaxis')
const graphs = Array.isArray(elGraph) ? elGraph : [elGraph]
const anchor =
gridEl && w.config.grid.position === 'front' ? gridEl : xaxisEl
if (anchor) {
graphs.forEach((g) => {
const node = g && g.node ? g.node : g
if (node) innerEl.insertBefore(node, anchor)
})
} else {
graphs.forEach((g) => {
w.dom.elGraphical.add(g)
})
}
// Bring data labels forward and apply backgrounds if configured.
const dataLabels = new DataLabels(w, this)
dataLabels.bringForward()
if (w.config.dataLabels.background.enabled) {
dataLabels.dataLabelsBackground()
}
// Reattach tooltip event listeners to new series elements.
if (Environment.isBrowser() && w.config.tooltip.enabled && !gl.noData) {
w.globals.tooltip?.drawTooltip(xyRatios)
}
// Weave: main render hook (fast path): rebuild plugin layers.
this.weave?.dispatch('draw', { pass: 'fast', xyRatios })
if (typeof w.config.chart.events.updated === 'function') {
w.config.chart.events.updated(this, w)
}
this.events.fireEvent('updated', [this, w])
// License: re-evaluate on the fast path too, so a late setLicense +
// updateSeries() clears the watermark without a full render.
enforceLicense(w, this)
gl.isDirty = true
resolve(this)
} catch (e) {
reject(e)
}
})
}
/**
* Returns all charts in the same `chart.group` (including this instance),
* used to synchronise zoom/pan across grouped charts.
*
* @returns {ApexCharts[]}
*/
getSyncedCharts() {
const group = /** @type {ApexCharts[]} */ (this.getGroupedCharts())
group.splice(0, 0, this)
return group
}
/**
* Returns all charts in the same `chart.group`, excluding this instance.
* Used internally to apply hover/zoom effects to sibling charts.
*
* @returns {ApexCharts[]}
*/
getGroupedCharts() {
// Require a truthy group: charts are registered in Apex._chartInstances
// whenever chart.id is set (regardless of group), so without this guard two
// ungrouped charts both store group === undefined and `undefined ===
// undefined` would sync their zoom/pan/hover with each other. Only charts
// that opt into the SAME explicit group should coordinate.
return Apex._chartInstances
.filter(
(/** @type {any} */ ch) =>
this !== ch.chart &&
!!this.w.config.chart.group &&
this.w.config.chart.group === ch.group,
)
.map((/** @type {any} */ ch) => ch.chart)
}
/**
* Retrieves a rendered chart instance by its `chart.id` config value.
*
* @param {string} id - The chart ID set via `chart.id` in options.
* @returns {ApexCharts | undefined}
*/
static getChartByID(id) {
const chartId = Utils.escapeString(id)
if (!Apex._chartInstances) return undefined
/**
* @param {Record<string, any>} ch
*/
const c = Apex._chartInstances.filter(
(/** @type {any} */ ch) => ch.id === chartId,
)[0]
return c && c.chart
}
/**
* Scans the document for elements with a `data-apexcharts` attribute and
* `data-options` JSON, then renders a chart in each one automatically.
* Useful for non-framework HTML pages.
*/
static initOnLoad() {
const els = document.querySelectorAll('[data-apexcharts]')
for (let i = 0; i < els.length; i++) {
const el = /** @type {HTMLElement} */ (els[i])
const options = JSON.parse(