UNPKG

apexcharts

Version:

A JavaScript Chart Library

1,374 lines (1,228 loc) 77.9 kB
// @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(