UNPKG

apexcharts

Version:

A JavaScript Chart Library

2,657 lines 97.2 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 {
  registerUnitLayout,
  unregisterUnitLayout,
} from './modules/UnitLayoutRegistry'
import { registerUnitMark, unregisterUnitMark } from './modules/UnitMarkRegistry'
import {
  registerRowSource,
  unregisterRowSource,
  rowSourceFor,
} from './modules/RowSourceRegistry'
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 'apex-commons'
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} */ trellis
  /** @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])

        // Trellis (#22): a host carrying `trellis.by` (or `row`/`column`,
        // P4) delegates rendering to the orchestrator (real panel charts in
        // a coordinated grid) instead of the single-chart pipeline. Resolved
        // here, before the resize listeners: the trellis owns relayout
        // through its own container ResizeObserver, so the host must not
        // self-rerender on resize.
        const trellisCfg = this.w.config.trellis
        const wantsTrellis = !!(
          trellisCfg &&
          (trellisCfg.by || trellisCfg.row || trellisCfg.column)
        )
        const isTrellisHost = !!(
          wantsTrellis &&
          this.trellis &&
          this.trellis.isActive()
        )
        if (wantsTrellis && !this.trellis) {
          console.warn(
            "ApexCharts: `trellis` requires the trellis feature, which is not in the default bundle. Bundler: import 'apexcharts/features/trellis'. Script tag: add <script src='.../dist/features/trellis.js'> after apexcharts.js. Rendering as a single chart.",
          )
        }

        // Same guard for the measure ruler: asking for it without the feature
        // present used to fail in total silence, which is the worst possible
        // outcome for someone whose chart stopped having a ruler after an
        // upgrade. Config-driven, so it fires whether or not they ever call
        // startMeasure().
        if (this.w.config.chart?.measure?.enabled && !this.measure) {
          console.warn(
            "ApexCharts: `chart.measure` requires the measure feature, which is not in the default bundle. Bundler: import 'apexcharts/features/measure'. Script tag: add <script src='.../dist/features/measure.js'> after apexcharts.js.",
          )
        }

        // And for linked views. `chart.link` carries the crossfilter dimension
        // config too, so an absent feature means the chart quietly stops
        // participating in its dashboard rather than erroring anywhere.
        if (this.w.config.chart?.link?.enabled && !this.linkedViews) {
          console.warn(
            "ApexCharts: `chart.link` requires the link feature, which is not in the default bundle. Bundler: import 'apexcharts/features/link'. Script tag: add <script src='.../dist/features/link.js'> after apexcharts.js.",
          )
        }

        // And for the ink layer. Two ways in, so check both: the global switch
        // and a single annotation asking to be draggable. Without the feature
        // the annotation still DRAWS, it just cannot be moved, which is exactly
        // the kind of half-working state nobody thinks to file a bug about.
        if (!this.ink) {
          const inkOn = this.w.config.chart?.ink?.enabled
          const anyDraggable = (
            this.w.config.annotations?.points ?? []
          ).some((/** @type {any} */ p) => p && p.draggable)
          if (inkOn || anyDraggable) {
            console.warn(
              "ApexCharts: `chart.ink` / `annotations.points[].draggable` requires the ink feature, which is not in the default bundle. Bundler: import 'apexcharts/features/ink'. Script tag: add <script src='.../dist/features/ink.js'> after apexcharts.js.",
            )
          }
        }

        // And for the context menu. Absent, the browser's own menu opens on
        // right-click, which looks enough like "nothing happened" that it
        // reads as a broken build rather than a missing import.
        if (this.w.config.chart?.contextMenu?.enabled && !this.contextMenu) {
          console.warn(
            "ApexCharts: `chart.contextMenu` requires the context-menu feature, which is not in the default bundle. Bundler: import 'apexcharts/features/context-menu'. Script tag: add <script src='.../dist/features/context-menu.js'> after apexcharts.js.",
          )
        }

        // And for Rewind. Absent, edits still apply and simply cannot be undone
        // — Ctrl-Z does nothing, which users read as a lost keystroke rather
        // than a missing feature.
        if (this.w.config.chart?.history?.enabled && !this.history) {
          console.warn(
            "ApexCharts: `chart.history` requires the history feature, which is not in the default bundle. Bundler: import 'apexcharts/features/history'. Script tag: add <script src='.../dist/features/history.js'> after apexcharts.js.",
          )
        }

        // add event listeners in browser environment
        if (Environment.isBrowser()) {
          if (!isTrellisHost) {
            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)
            }
          }
        }

        if (isTrellisHost) {
          this.trellis
            .render()
            .then(() => {
              // License: the trellis is a gated premium feature; the enforcer
              // addresses the host through w.dom.elWrap, which the trellis
              // orchestrator has just populated.
              enforceLicense(this.w, this)
              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])
              resolve(this)
            })
            .catch((/** @type {any} */ e) => {
              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)
            })
          return
        }

        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 (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() {
    // Trellis (#22): destroy every panel (each unregisters itself from
    // Apex._chartInstances), disconnect the container observer and drop the
    // grid DOM before the host's own teardown runs.
    if (this.trellis) {
      this.trellis.teardown()
    }
    // 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

    // A non-array `series` (most commonly a null, e.g. rowSeries() when the
    // current type has no row source) must not reach the merge: it would
    // replace config.series and the initialSeries snapshot, and every later
    // series update would then crash in resetSeries against the null. Drop
    // the key and keep the rest of the update alive.
    if (options && 'series' in options && !Array.isArray(options.series)) {
      console.warn(
        'ApexCharts: updateOptions() ignored `series` because it is not an array.',
      )
      options = { ...options }
      delete options.series
    }

    // Trellis (#22): an option change on a live trellis host is structural
    // (it can move the split, the scales, the layout or any panel option), so
    // it merges into the host's config and re-renders the whole grid.
    if (this.trellis && this.trellis._mounted) {
      this.opts = Utils.extend(this.opts || {}, options || {})
      this.w.config = Utils.extend(w.config, options || {})
      this.trellis.teardown()
      return this.render()
    }

    // 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 (
          Utils.stringifyForCompare(this.lastUpdateOptions.series) ===
          Utils.stringifyForCompare(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) {
    // Same contract as updateOptions: a null/non-array series would poison
    // config.series and the initialSeries snapshot. Refuse it, keep the chart.
    if (!Array.isArray(newSeries)) {
      console.warn(
        'ApexCharts: updateSeries() ignored the call because the series is not an array.',
      )
      return Promise.resolve(this)
    }
    // Trellis (#22): the host re-splits and fans the new slices out to its
    // panels (same key set: in-place panel updates; changed key set: a full
    // trellis re-render).
    if (this.trellis && this.trellis._mounted) {
      return this.trellis.updateSeries(newSeries, animate)
    }
    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()
    }

    // Histogram: config.series holds the binned rows, and the only copy of the
    // sample is the raw stash, so new observations are appended there and the
    // bars are recomputed from the enlarged sample. Appending to the binned
    // rows instead would add bars whose x is an observation value.
    const histRaw = me.w.globals.histogramRawSeries
    if (histRaw) {
      for (let i = 0; i < histRaw.length; i++) {
        const src = /** @type {any} */ (newData[i])
        if (src && Array.isArray(src.data) && Array.isArray(histRaw[i].data)) {
          for (let j = 0; j < src.data.length; j++) {
            histRaw[i].data.push(src.data[j])
          }
        }
      }
      return this.update()
    }

    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) &&
        Utils.stringifyForCompare(this.lastUpdateOptions) ===
          Utils.stringifyForCompare(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)

          // Ride data labels to their new slot (and, opt-in, count their value
          // up) on the same clock as the morph. No-op when nothing moved or no
          // 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()
        }

        // Same reflow tweens the full render runs, and for the same reason: a
        // same-shape updateSeries is the MOST common update there is, and it
        // lands here, not in update(). Without these the ruler and the labels
        // snap to their final places on the first frame while the marks morph
        // for another few hundred ms. Both consume the frame captured by
        // Series.getPreviousPaths() before this render, and both no-op when
        // nothing moved, including when the scale did not change and the axis
        // chrome was preserved in place rather than redrawn.
        //
        // Ordering: after bringForward()/dataLabelsBackground(), so a label
        // group is in its final parent with its background rect inside it and
        // one translate carries the pair.
        //
        // A streaming scroll is the one case that opts out of the ruler tween:
        // StreamScroll is already producing continuous motion many times a
        // second, so sliding every tick label on top of it buys nothing visible
        // and the extra per-frame DOM writes show up as velocity jitter in the
        // scroll itself.
        if (!gl.streamScrolled) applyAxisTransition(w)
        applyDataLabelTransition(w)

        // 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
  }

  /**
   * Trellis (#22): the panels of a trellis host, in grid order. Empty for a
   * chart that is not a trellis.
   *
   * @returns {Array<{ key: string, index: number, chart: ApexCharts|null, el: HTMLElement|null }>}
   */
  getPanels() {
    return this.trellis ? this.trellis.getPanels() : []
  }

  /**
   * Trellis (#22): one panel's own ApexCharts instance by facet key — the
   * escape hatch to every per-chart API the trellis does not re-expose.
   *
   * @param {string} key
   * @returns {ApexCharts|null}
   */
  getPanel(key) {
    return this.trellis ? this.trellis.getPanel(key) : null
  }

  /**
   * 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
  }

  /**
   * Trellis (#22): imperative entry point. Creates a trellis host and starts
   * rendering it; `render()` is idempotent, so `await chart.render()` on the
   * returned instance settles with the same in-flight mount.
   *
   * Requires the trellis feature, which is NOT in the default bundle
   * (`import 'apexcharts/features/trellis'`, or add `dist/features/trellis.js`
   * after apexcharts.js on a script-tag page); warns and returns null otherwise.
   *
   * @param {HTMLElement} el
   * @param {ApexOptions} options must carry `trellis.by` (or `trellis.row`
   *   / `trellis.column` for a 2-D grid)
   * @returns {ApexCharts|null}
   */
  static trellis(el, options) {
    if (!InitCtxVariables._featureRegistry.get('trellis')) {
      console.warn(
        "ApexCharts.trellis requires the trellis feature, which is not in the default bundle. Bundler: import 'apexcharts/features/trellis'. Script tag: add <script src='.../dist/features/trellis.js'> after apexcharts.js.",
      )
      return null
    }
    const chart = new ApexCharts(el, options)
    chart.render()
    return 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(els[i].getAttribute('data-options') ?? '')
      const apexChart = new ApexCharts(el, options)
      apexChart.render()
    }
  }

  /**
   * This static method allows users to call chart methods without necessarily from the
   * instance of the chart in case user has assigned chartID to the targeted chart.
   * The chartID is used for mapping the instance stored in Apex._chartInstances global variable
   *
   * This is helpful in cases when you don't have reference of the chart instance
   * easily and need to call the method from anywhere.
   * For eg, in React/Vue applications when you have many parent/child components,
   * and need easy reference to other charts for performing dynamic operations
   *
   * @param {string} chartID - The unique identifier which will be used to call methods
   * on that chart instance
   * @param {string} fn - The method name to call
   * @param {...any} opts - The parameters which are accepted in the original method will be passed here in the same order.
   */
  static exec(chartID, fn, ...opts) {
    const chart = this.getChartByID(chartID)
    if (!chart) return

    // turn on the global exec flag to indicate this method was called
    chart.w.globals.isExecCalled = true

    let ret = null
    if (chart.publicMethods.indexOf(fn) !== -1) {
      ret = /** @type {any} */ (chart)[fn](...opts)
    }
    return ret
  }

  /**
   * Deep-merges `source` into `target` and returns the result.
   * Thin wrapper around the internal `Utils.extend` utility.
   *
   * @param {object} target
   * @param {object} source
   * @returns {object}
   */
  static merge(target, source) {
    return Utils.extend(target, source)
  }

  static getThemePalettes() {
    return getThemePalettes()
  }

  /**
   * Register additional chart types. Used by sub-entry points so that only
   * the types they include are bundled.
   *
   * @param {Record<string, new (...args: any[]) => any>} typeMap  e.g. { line: Line, area: Line }
   */
  static use(typeMap) {
    register(typeMap)
  }

  /**
   * Register optional feature modules (Exports, Legend, Toolbar,
   * ZoomPanSelection, KeyboardNavigation, Annotations).
   *
   * Call this before rendering any chart. Feature entry files (e.g.
   * `apexcharts/features/legend`) call this automatically when imported.
   * Note: Tooltip is part of core and does not need to be registered.
   *
   * @param {Record<string, new (...args: any[]) => any>} featureMap  e.g. { legend: Legend, exports: Exports }
   */
  static registerFeatures(featureMap) {
    InitCtxVariables.registerFeatures(featureMap)
  }

  /**
   * Set the license key that unlocks the premium features (storyboard, link /
   * crossfilter, ink, measure, contextMenu, perspectives, history). Without a
   * valid key those features still work but the chart shows an "APEXCHARTS"
   * trial watermark; a valid key removes it. Keys are shared across the whole
   * ApexCharts family (apexgantt, apextree, apexsankey, apex-grid-enterprise,
   * apexstock), so one customer key works everywhere.
   *
   * Call before render(). The watermark is re-evaluated on every render/update,
   * so a late setLicense(validKey) followed by chart.update() clears it.
   *
   * Precedence per chart: `chart.license` (most specific) -> this key ->
   * `window.Apex.license` -> unlicensed (trial).
   *
   * @param {string} key  the `APEX-<base64(JSON)>` license key
   * @returns {typeof ApexCharts}
   */
  static setLicense(key) {
    LicenseManager.setLicense(key)
    // A late (post-render) valid key should clear watermarks already on screen.
    reevaluateLicenseAcrossCharts()
    return ApexCharts
  }

  /**
   * Register a Weave plugin definition (a plain { name, setup } object).
   * Lives in core so plugins can always be registered; they only activate when
   * the Weave host is bundled (`import 'apexcharts/features/weave'`, included in
   * the full bundle) and listed in a chart's `plugins` config.
   *
   * @param {{ name: string, apiVersion?: number, setup: Function, destroy?: Function }} def
   * @returns {typeof ApexCharts}
   */
  static registerPlugin(def) {
    registerPluginImpl(def)
    return ApexCharts
  }

  /**
   * Remove a registered Weave plugin definition. Charts already holding an
   * active instance keep it until their plugins config changes or they are
   * destroyed; the name simply stops resolving for new activations. Intended
   * for tests and hot-reload flows.
   * @param {string} name
   * @returns {typeof ApexCharts}
   */
  static unregisterPlugin(name) {
    unregisterPluginImpl(name)
    return ApexCharts
  }

  /**
   * Register a non-SVG series renderer (Strata #2). SVG is built in; the canvas
   * backend registers itself via `import 'apexcharts/features/renderer-canvas'`.
   * When a `kind` is not registered, selection falls back to SVG.
   *
   * @param {string} kind  e.g. 'canvas'
   * @param {(w: any, ctx: any) => any} factory  returns a Renderer instance
   */
  static registerRenderer(kind, factory) {
    RendererController.registerRenderer(kind, factory)
  }

  /**
   * Register a custom series type (Marks #11): a `{ renderItem }` definition
   * that draws primitives (path/line/rect/circle/text) per datum. Requires the
   * Marks feature to be bundled (`import 'apexcharts/features/marks'`, included
   * in the full bundle); without it this warns and no-ops. Once registered, use
   * it via `series[].type` or `chart.type`.
   *
   * @param {string} name  the type name, e.g. 'dumbbell'
   * @param {{ renderItem: Function, dataType?: string, yExtent?: Function, tooltip?: Function }} def
   * @returns {typeof ApexCharts}
   */
  static registerSeriesType(name, def) {
    const factory = /** @type {any} */ (ApexCharts)._customSeriesFactory
    if (!factory) {
      console.warn(
        `[apexcharts] registerSeriesType("${name}") requires the Marks feature: import 'apexcharts/features/marks'.`,
      )
      return ApexCharts
    }
    if (!def || typeof def.renderItem !== 'function') {
      console.warn(
        `[apexcharts] registerSeriesType("${name}") needs a def with a renderItem() function.`,
      )
      return ApexCharts
    }
    // The type registry is global (all charts, all bundle copies), so letting a
    // custom type shadow a built-in would silently break every chart on the
    // page. Re-registering a CUSTOM name replaces it (idempotent, like
    // registerPlugin); a built-in name is rejected.
    if (hasChartClass(name) && !isCustom(name)) {
      console.warn(
        `[apexcharts] registerSeriesType("${name}") would override the built-in "${name}" chart type; pick another name.`,
      )
      return ApexCharts
    }
    register({ [name]: factory(name, def) })
    markCustom(name)
    return ApexCharts
  }

  /**
   * Remove a custom series type registered via registerSeriesType. Built-in
   * chart types cannot be unregistered. Intended for tests and hot-reload.
   * @param {string} name
   * @returns {typeof ApexCharts}
   */
  static unregisterSeriesType(name) {
    if (isCustom(name)) unregister(name)
    return ApexCharts
  }

  /**
   * Facet (#13): register a named theme (palette + design tokens + mode)
   * referenceable via `theme: { name }`. The theme sits below explicit config
   * and CSS `--apx-*` tokens, above the built-in palette/mode defaults.
   *
   * @param {string} name  the theme name, e.g. 'brand'
   * @param {any} def  { mode?, palette?, tokens?, monochrome?, accessibility? }
   * @returns {typeof ApexCharts}
   */
  static registerTheme(name, def) {
    registerTheme(name, def)
    return ApexCharts
  }

  /**
   * Remove a theme registered via registerTheme. Charts referencing it by
   * `theme.name` fall back to the built-in defaults on their next render.
   * Intended for tests and hot-reload flows.
   * @param {string} name
   * @returns {typeof ApexCharts}
   */
  static unregisterTheme(name) {
    unregisterTheme(name)
    return ApexCharts
  }

  /**
   * Cadence (#6): register a named easing function referenceable via
   * `chart.animations.easing: '<name>'`. `fn` maps linear progress t in [0,1]
   * to eased progress (back/elastic curves may overshoot 1).
   *
   * @param {string} name  the easing name, e.g. 'bounce'
   * @param {(t:number)=>number} fn
   * @returns {typeof ApexCharts}
   */
  static registerEasing(name, fn) {
    registerEasing(name, fn)
    return ApexCharts
  }

  /**
   * Register a named unit-chart layout, referenceable via
   * `plotOptions.unit.positions: '<name>'` with `plotOptions.unit.layout:
   * 'custom'`.
   *
   * A layout is objects in, positions out: `(objects, rect) => [{id, x, y,
   * r?}]`, in plot pixels. It knows nothing about animation, because the engine
   * already tweens position, radius and colour and already keeps each mark's
   * identity across a relayout. That is what lets an arrangement the engine
   * cannot know about - a country silhouette, a hex grid, a timeline, a
   * projection supplied by ApexMaps - be a plugin rather than a core change.
   *
   * Marks whose id the layout omits animate out; ids matching no mark are
   * ignored.
   *
   * @param {string} name  the layout name, e.g. 'silhouette'
   * @param {(objects: any[], rect: {x:number,y:number,width:number,height:number}) => any[]} fn
   * @returns {typeof ApexCharts}
   */
  static registerUnitLayout(name, fn) {
    registerUnitLayout(name, fn)
    return ApexCharts
  }

  /**
   * Remove a layout registered via registerUnitLayout. Charts referencing it by
   * name fall back to the grouped layout on their next render.
   * @param {string} name
   * @returns {typeof ApexCharts}
   */
  static unregisterUnitLayout(name) {
    unregisterUnitLayout(name)
    return ApexCharts
  }

  /**
   * Register a named unit-chart MARK (pictogram), referenceable via
   * `plotOptions.unit.pictogram.mark: '<name>'` with
   * `plotOptions.unit.shape: 'pictogram'`.
   *
   * This is the twin of registerUnitLayout, and the split between them is the
   * one the unit chart is built on: a LAYOUT is where the marks go, a MARK is
   * what one of them looks like. They compose freely - a person glyph arranged
   * into a heart, a house glyph on a waffle grid - so neither has to know about
   * the other.
   *
   * A mark is fill-only path data. The chart positions it with a uniform
   * `scale()` fitted to the radius the layout chose, so the glyph occupies the
   * box the dot would have and any stroke width would scale with it.
   *
   * @param {string} name  the mark name, e.g. 'person'
   * @param {string|any} def path data in a 0..100 box, or
   *   `{path, viewBox?, fillRule?}`
   * @returns {typeof ApexCharts}
   */
  static registerUnitMark(name, def) {
    registerUnitMark(name, def)
    return ApexCharts
  }

  /**
   * Remove a mark registered via registerUnitMark. Charts referencing it by
   * name fall back to `plotOptions.unit.pictogram.fallback` on their next
   * render.
   * @param {string} name
   * @returns {typeof ApexCharts}
   */
  static unregisterUnitMark(name) {
    unregisterUnitMark(name)
    return ApexCharts
  }

  /**
   * Register a row source: given a chart's state, what rows is each of its
   * marks standing for?
   *
   * Most marks cannot answer. An ordinary bar aggregates rows the library never
   * saw. The types that can are the ones whose series carries raw observations
   * (histogram, boxPlot, violin), and their sources ship with the statistics in
   * `apexcharts/features/stats`; core keeps only the lookup.
   *
   * The function returns a unit-chart series (one cluster per mark, one datum
   * per row) in the marks' own draw order, or null. See RowSourceRegistry for
   * why that order is a contract rather than a convention.
   *
   * @param {string} name  chart type name, matched against `chart.requestedType` then `chart.type`
   * @param {(w: any, opts?: any) => any[] | null} fn
   * @returns {typeof ApexCharts}
   */
  static registerRowSource(name, fn) {
    registerRowSource(name, fn)
    return ApexCharts
  }

  /**
   * Remove a row source registered via registerRowSource.
   * @param {string} name
   * @returns {typeof ApexCharts}
   */
  static unregisterRowSource(name) {
    unregisterRowSource(name)
    return ApexCharts
  }

  /**
   * The rows behind this chart's marks, as a unit-chart series.
   *
   * A histogram bar stands for the observations it counted, a box for the
   * sample it summarises. This hands them back as one cluster per mark, so a
   * mark can come apart into its own rows:
   *
   *     chart.updateOptions({ chart: { type: 'unit' }, series: chart.rowSeries() })
   *
   * With the morph feature loaded, each dot then leaves from the part of the
   * mark that was standing for it, and collapsing back is the inverse.
   *
   * Returns null when the chart's type cannot name its rows, or when
   * `apexcharts/features/stats` (which carries the sources for the types that
   * can) is not loaded.
   *
   * @param {{ maxRows?: number }} [opts] `maxRows` caps the dots produced
   *   (default 3000, matching the jitter overlay); past it every cluster is
   *   thinned by one shared stride so their relative sizes survive.
   * @returns {any[]|null}
   */
  rowSeries(opts) {
    const source = rowSourceFor(this.w)
    if (!source) return null
    return source(this.w, opts) || null
  }

  /**
   * Linked Views (#4) Phase 2: get-or-create a crossfilter coordinator by id.
   * Register one shared record set, then let each chart declare a dimension +
   * reduction under `chart.link`. Selecting in one chart re-aggregates the
   * others over the filtered subset.
   *
   * Lives in core (always callable) but the engine ships in the `link` feature,
   * which is NOT in the default bundle (`import 'apexcharts/features/link'`, or
   * add `dist/features/link.js` after apexcharts.js on a script-tag page);
   * without it this warns and returns null so the engine shakes out when
   * unused.
   *
   * @param {{ id: string, records?: any[] }} opts
   * @returns {any} the coordinator handle, or null if the feature is absent
   */
  static crossfilter(opts) {
    // Validated here rather than downstream so the message names the call the
    // user actually made; the shared engine cannot know it was reached through
    // ApexCharts.
    if (!opts || typeof opts.id !== 'string') {
      throw new Error('ApexCharts.crossfilter requires an { id } string.')
    }
    const factory = /** @type {any} */ (ApexCharts)._crossfilterFactory
    if (!factory) {
      console.warn(
        `[apexcharts] ApexCharts.crossfilter(...) requires the link feature, which is not in the default bundle. Bundler: import 'apexcharts/features/link'. Script tag: add <script src='.../dist/features/link.js'> after apexcharts.js.`,
      )
      return null
    }
    const coordinator = factory(opts)
    // Using the crossfilter engine is premium "link" usage; re-evaluate live
    // charts so any that consume this coordinator pick up the trial watermark.
    reevaluateLicenseAcrossCharts()
    return coordinator
  }

  /**
   * Look up an existing crossfilter coordinator by id (null if none / feature
   * absent).
   * @param {string} id
   * @returns {any}
   */
  static getCrossfilter(id) {
    const get = /** @type {any} */ (ApexCharts)._crossfilterGet
    return get ? get(id) : null
  }

  /**
   * Linked Views (#4): clear crossfilter dimming across this chart and every
   * chart in its `chart.group`. No-op unless the `link` feature is bundled.
   */
  clearCrossfilter() {
    this.linkedViews?.clearGroup()
  }

  /**
   * Measure ruler (#18): arm a sticky measure-ruler mode (drag A->B on the
   * plot to read dx/dy/%change/slope). Alternatively hold the measure key
   * (chart.measure.key, default 'm') and drag. No-op unless the `measure`
   * feature is bundled and chart.measure.enabled.
   */
  startMeasure() {
    this.measure?.startMeasure()
  }

  /** Measure ruler (#18): leave measure mode. */
  stopMeasure() {
    this.measure?.stopMeasure()
  }

  /** Measure ruler (#18): remove all pinned measure rulers. */
  clearMeasures() {
    this.measure?.clearMeasures()
  }

  /**
   * Toggles (show/hide) the series identified by name.
   * Mirrors a click on the corresponding legend item.
   *
   * @param {string} seriesName
   * @returns {object | undefined} The collapsed series object, if now hidden.
   */
  toggleSeries(seriesName) {
    return this.series.toggleSeries(seriesName)
  }

  /**
   * Highlights or un-highlights a series when the user hovers a legend item.
   * Called internally by the legend; not typically called by consumers.
   *
   * @param {MouseEvent} e
   * @param {HTMLElement} targetElement - The legend marker element being hovered.
   */
  highlightSeriesOnLegendHover(e, targetElement) {
    return this.series.toggleSeriesOnHover(e, targetElement)
  }

  /**
   * Makes a previously hidden series visible and re-renders.
   *
   * @param {string} seriesName
   */
  showSeries(seriesName) {
    this.series.showSeries(seriesName)
  }

  /**
   * Hides a visible series and re-renders.
   *
   * @param {string} seriesName
   */
  hideSeries(seriesName) {
    this.series.hideSeries(seriesName)
  }

  /**
   * Highlights (dims all other series) the series identified by name.
   *
   * @param {string} seriesName
   */
  highlightSeries(seriesName) {
    this.series.highlightSeries(seriesName)
  }

  /**
   * Returns whether the series identified by name is currently hidden.
   *
   * @param {string} seriesName
   * @returns {boolean}
   */
  isSeriesHidden(seriesName) {
    return this.series.isSeriesHidden(seriesName)
  }

  /**
   * Resets the chart to the initial series and optionally the initial zoom level.
   *
   * @param {boolean} [shouldUpdateChart=true] - When true, triggers a re-render.
   * @param {boolean} [shouldResetZoom=true] - When true, restores the initial zoom level.
   */
  resetSeries(shouldUpdateChart = true, shouldResetZoom = true) {
    this.series.resetSeries(shouldUpdateChart, shouldResetZoom)
  }

  /**
   * Subscribes to a chart event by name.
   * Supported event names mirror the `chart.events` option keys
   * (e.g. `'mounted'`, `'updated'`, `'dataPointMouseEnter'`).
   *
   * @param {string} name - Event name.
   * @param {Function} handler - Callback invoked when the event fires.
   */
  addEventListener(name, handler) {
    this.events.addEventListener(name, handler)
  }

  /**
   * Removes a previously registered event listener.
   *
   * @param {string} name - Event name.
   * @param {Function} handler - The exact function reference passed to addEventListener.
   */
  removeEventListener(name, handler) {
    this.events.removeEventListener(name, handler)
  }

  /**
   * Adds an x-axis annotation dynamically after render.
   *
   * @param {XAxisAnnotations} opts - Annotation configuration.
   * @param {boolean} [pushToMemory=true] - When true, the annotation persists across re-renders.
   * @param {ApexCharts} [context] - Override the target chart instance (used by exec()).
   */
  addXaxisAnnotation(opts, pushToMemory = true, context = undefined) {
    let me = /** @type {ApexCharts} */ (/** @type {unknown} */ (this))
    if (context) {
      me = context
    }
    me.annotations?.addXaxisAnnotationExternal(opts, pushToMemory, me)
  }

  /**
   * Adds a y-axis annotation dynamically after render.
   *
   * @param {YAxisAnnotations} opts - Annotation configuration.
   * @param {boolean} [pushToMemory=true] - When true, the annotation persists across re-renders.
   * @param {ApexCharts} [context] - Override the target chart instance (used by exec()).
   */
  addYaxisAnnotation(opts, pushToMemory = true, context = undefined) {
    let me = /** @type {ApexCharts} */ (/** @type {unknown} */ (this))
    if (context) {
      me = context
    }
    me.annotations?.addYaxisAnnotationExternal(opts, pushToMemory, me)
  }

  /**
   * Adds a point annotation dynamically after render.
   *
   * @param {PointAnnotations} opts - Annotation configuration.
   * @param {boolean} [pushToMemory=true] - When true, the annotation persists across re-renders.
   * @param {ApexCharts} [context] - Override the target chart instance (used by exec()).
   */
  addPointAnnotation(opts, pushToMemory = true, context = undefined) {
    let me = /** @type {ApexCharts} */ (/** @type {unknown} */ (this))
    if (context) {
      me = context
    }
    me.annotations?.addPointAnnotationExternal(opts, pushToMemory, me)
  }

  /**
   * Removes all annotations from the chart.
   *
   * @param {ApexCharts} [context] - Override the target chart instance (used by exec()).
   */
  clearAnnotations(context = undefined) {
    let me = /** @type {ApexCharts} */ (/** @type {unknown} */ (this))
    if (context) {
      me = context
    }
    // This mutates the rendered DOM out of band, so the memoized "last rendered
    // options" no longer matches what is on screen. Invalidate it, or a
    // subsequent updateOptions() with identical options short-circuits and
    // leaves the annotations cleared-but-not-redrawn (hit by Rewind /
    // Perspectives restore, which clear then re-apply the same config).
    me.lastUpdateOptions = null
    me.annotations?.clearAnnotations(me)
  }

  /**
   * Removes a specific annotation by its `id`.
   *
   * @param {string} id - The annotation id as set in the annotation config.
   * @param {ApexCharts} [context] - Override the target chart instance (used by exec()).
   */
  removeAnnotation(id, context = undefined) {
    let me = /** @type {ApexCharts} */ (/** @type {unknown} */ (this))
    if (context) {
      me = context
    }
    // See clearAnnotations: removing an annotation changes the rendered DOM, so
    // invalidate the update memo to keep a following identical updateOptions().
    me.lastUpdateOptions = null
    me.annotations?.removeAnnotation(me, id)
  }

  /**
   * Returns the inner SVG group element that contains all chart graphics.
   *
   * @returns {Element | null}
   */
  getChartArea() {
    const el = this.w.dom.baseEl.querySelector('.apexcharts-inner')

    return el
  }

  /**
   * Returns the sum of all data points whose x value falls within [minX, maxX].
   *
   * @param {number} minX
   * @param {number} maxX
   * @returns {number[]} One total per series.
   */
  getSeriesTotalXRange(minX, maxX) {
    return this.coreUtils.getSeriesTotalsXRange(minX, maxX)
  }

  /**
   * Returns the highest y value in the specified series.
   *
   * @param {number} [seriesIndex=0]
   * @returns {number}
   */
  getHighestValueInSeries(seriesIndex = 0) {
    const range = new Range(this.w)
    return range.getMinYMaxY(seriesIndex).highestY
  }

  /**
   * Returns the lowest y value in the specified series.
   *
   * @param {number} [seriesIndex=0]
   * @returns {number}
   */
  getLowestValueInSeries(seriesIndex = 0) {
    const range = new Range(this.w)
    return range.getMinYMaxY(seriesIndex).lowestY
  }

  /**
   * Returns the sum of each series (the totals used for percentage calculations).
   *
   * @returns {number[]}
   */
  getSeriesTotal() {
    return this.w.globals.seriesTotals
  }

  /**
   * Returns a curated snapshot of chart state for use in formatters, events,
   * and external integrations. Prefer this over accessing `chart.w` directly.
   *
   * The shape of this object is stable and versioned. `chart.w` is internal
   * and will be restricted in a future major version.
   */
  getState() {
    const w = this.w
    const gl = w.globals

    return {
      // Series data — computed/parsed form used for rendering
      series: w.seriesData.series,
      seriesNames: w.seriesData.seriesNames,
      colors: gl.colors,
      labels: w.labelData.labels,
      seriesTotals: gl.seriesTotals,
      seriesPercent: gl.seriesPercent,
      seriesXvalues: gl.seriesXvalues,
      seriesYvalues: gl.seriesYvalues,

      // Axis bounds — updated after each render
      minX: gl.minX,
      maxX: gl.maxX,
      minY: gl.minY,
      maxY: gl.maxY,
      minYArr: gl.minYArr,
      maxYArr: gl.maxYArr,
      minXDiff: gl.minXDiff,
      dataPoints: gl.dataPoints,

      // Axis scale objects — computed tick/scale results
      xAxisScale: gl.xAxisScale,
      yAxisScale: gl.yAxisScale,
      xTickAmount: gl.xTickAmount,

      // Axis type flags
      isXNumeric: w.axisFlags.isXNumeric,

      // Multi-axis series mapping
      seriesYAxisMap: gl.seriesYAxisMap,
      seriesYAxisReverseMap: gl.seriesYAxisReverseMap,

      // Chart dimensions — updated after each render/resize
      svgWidth: gl.svgWidth,
      svgHeight: gl.svgHeight,
      gridWidth: w.layout.gridWidth,
      gridHeight: w.layout.gridHeight,

      // Interactive state
      selectedDataPoints: w.interact.selectedDataPoints,
      collapsedSeriesIndices: gl.collapsedSeriesIndices,
      zoomed: w.interact.zoomed,

      // Chart-type-specific series data (null when not applicable)
      seriesX: w.seriesData.seriesX,
      seriesZ: w.seriesData.seriesZ,
      seriesCandleO: w.candleData.seriesCandleO,
      seriesCandleH: w.candleData.seriesCandleH,
      seriesCandleM: w.candleData.seriesCandleM,
      seriesCandleL: w.candleData.seriesCandleL,
      seriesCandleC: w.candleData.seriesCandleC,
      seriesRangeStart: w.rangeData.seriesRangeStart,
      seriesRangeEnd: w.rangeData.seriesRangeEnd,
      seriesGoals: w.seriesData.seriesGoals,
    }
  }

  /**
   * Programmatically selects or deselects a data point.
   * Equivalent to a user click on the data point.
   *
   * @param {number} seriesIndex - Zero-based series index.
   * @param {number} [dataPointIndex] - Zero-based data point index within the series.
   * @returns {number[][] | null} Updated selectedDataPoints array, or null.
   */
  toggleDataPointSelection(seriesIndex, dataPointIndex) {
    return this.updateHelpers.toggleDataPointSelection(
      seriesIndex,
      dataPointIndex,
    )
  }

  /**
   * Programmatically zooms the x-axis to the given range.
   * Requires zoom to be enabled (`chart.zoom.enabled: true`).
   *
   * @param {number} min - The minimum x value (timestamp or numeric).
   * @param {number} max - The maximum x value (timestamp or numeric).
   */
  zoomX(min, max) {
    this.ctx.toolbar?.zoomUpdateOptions(min, max)
  }

  /**
   * Switches the active locale, updating all locale-dependent labels (toolbar tooltips, month names, etc.).
   *
   * @param {string} localeName - Must match a locale name defined in `chart.locales`.
   */
  setLocale(localeName) {
    this.localization.setCurrentLocaleValues(localeName)
  }

  /**
   * Exports the chart to a PNG or SVG data URI.
   * Requires the Exports feature: `import 'apexcharts/features/exports'`.
   *
   * @param {{ scale?: number, width?: number }} [options]
   * @returns {Promise<{ imgURI: string } | { blob: Blob }>}
   */
  dataURI(options) {
    if (!this.ctx.exports)
      throw new Error(
        'apexcharts: Exports feature is not registered. Import apexcharts/features/exports.',
      )
    // Trellis (#22, P3): a trellis host exports ONE composed image of the
    // whole grid (its panels' own export paths do the per-panel work).
    if (this.trellis && this.trellis._mounted) {
      return this.trellis.exports.dataURI(options)
    }
    return this.ctx.exports.dataURI(options)
  }

  /**
   * Returns the chart's SVG markup as a string, optionally scaled.
   * Requires the Exports feature: `import 'apexcharts/features/exports'`.
   *
   * @param {number} [scale=1]
   * @returns {Promise<string>}
   */
  getSvgString(scale) {
    if (!this.ctx.exports)
      throw new Error(
        'apexcharts: Exports feature is not registered. Import apexcharts/features/exports.',
      )
    if (this.trellis && this.trellis._mounted) {
      return this.trellis.exports.svgString()
    }
    return this.ctx.exports.getSvgString(scale)
  }

  /**
   * Triggers a CSV download of the chart's data.
   * Requires the Exports feature: `import 'apexcharts/features/exports'`.
   *
   * @param {{ series?: any, fileName?: string, columnDelimiter?: string, lineDelimiter?: string }} [options]
   */
  exportToCSV(options = {}) {
    if (!this.ctx.exports)
      throw new Error(
        'apexcharts: Exports feature is not registered. Import apexcharts/features/exports.',
      )
    if (this.trellis && this.trellis._mounted) {
      return this.trellis.exports.download('csv')
    }
    return this.ctx.exports.exportToCSV(options)
  }

  /**
   * Trellis (#22, P3): expand one panel to the grid's full width (what
   * clicking its header does). No-op on a chart that is not a trellis host.
   * @param {string} key the panel's facet key
   * @returns {Promise<void>}
   */
  promotePanel(key) {
    return this.trellis && this.trellis._mounted
      ? this.trellis.promote(key)
      : Promise.resolve()
  }

  /**
   * Trellis (#22, P3): restore the grid from a panel promotion.
   * @returns {Promise<void>}
   */
  restorePanels() {
    return this.trellis && this.trellis._mounted
      ? this.trellis.restorePromotion()
      : Promise.resolve()
  }

  paper() {
    return this.w.dom.Paper
  }

  /**
   * Returns the active series renderer for the last render: `'svg'` (default)
   * or `'canvas'` (Strata #2). `'auto'`/`'canvas'` resolve to `'svg'` unless the
   * canvas renderer feature is bundled and no canvas-unsupported feature is in
   * use. See `chart.renderer` / `chart.rendererThreshold`.
   *
   * @returns {'svg' | 'canvas' | 'gpu'}
   */
  getActiveRenderer() {
    return this.rendererController
      ? this.rendererController.getActiveKind()
      : 'svg'
  }

  /**
   * Facet (#13): re-resolve the `--apx-*` design tokens and re-render.
   *
   * Tokens are read from the CSS cascade once per render, so a runtime change
   * that is NOT an OS color-scheme flip (e.g. the host app swaps its own
   * design-system theme by toggling a class or setting style properties) is
   * invisible until the next render, and `updateOptions({})` is memoized away.
   * This busts the memo and re-renders, picking up the current token values.
   * @returns {Promise<any>}
   */
  refreshTokens() {
    this.lastUpdateOptions = null
    return this.update()
  }

  /**
   * Drills into the child level referenced by `id` (a `chart.drilldown.series` entry).
   * Requires the Drilldown feature: `import 'apexcharts/features/drilldown'`.
   *
   * @param {string|number} id - The drilldown series id to navigate into.
   * @returns {Promise<ApexCharts>}
   */
  drillDown(id) {
    if (!this.ctx.drilldown)
      throw new Error(
        'apexcharts: Drilldown feature is not registered. Import apexcharts/features/drilldown.',
      )
    return this.ctx.drilldown.drillDown(id)
  }

  /**
   * Navigates back one drilldown level.
   * Requires the Drilldown feature: `import 'apexcharts/features/drilldown'`.
   *
   * @returns {Promise<ApexCharts>}
   */
  drillUp() {
    if (!this.ctx.drilldown)
      throw new Error(
        'apexcharts: Drilldown feature is not registered. Import apexcharts/features/drilldown.',
      )
    return this.ctx.drilldown.drillUp()
  }

  /**
   * Navigates back to the root drilldown level.
   * Requires the Drilldown feature: `import 'apexcharts/features/drilldown'`.
   *
   * @returns {Promise<ApexCharts>}
   */
  drillToRoot() {
    if (!this.ctx.drilldown)
      throw new Error(
        'apexcharts: Drilldown feature is not registered. Import apexcharts/features/drilldown.',
      )
    return this.ctx.drilldown.drillToRoot()
  }

  /**
   * Drops levels cached from `drilldown.onDrillDown`, so the next drill re-runs
   * the resolver. Call it when the data behind an already-drilled chart changes.
   * Requires the Drilldown feature: `import 'apexcharts/features/drilldown'`.
   *
   * @param {string|number} [id] - A single level id, or every level when omitted.
   * @returns {ApexCharts}
   */
  clearDrilldownCache(id) {
    if (!this.ctx.drilldown)
      throw new Error(
        'apexcharts: Drilldown feature is not registered. Import apexcharts/features/drilldown.',
      )
    return this.ctx.drilldown.clearCache(id)
  }

  // ─── Slice write-back stubs ─────────────────────────────────────────────────
  /**
   * Copy own DATA properties of a parse-state slice onto a live w.* slice.
   * Never Object.assign here: several w.* fields (and, historically, snapshot
   * fields) are lazy accessors, and [[Get]]-ing an accessor while copying
   * forces its deferred computation (a deep initialSeries clone plus O(n)
   * stacked-totals passes on every render/update) and can replace the live
   * accessor with a materialized value for the life of the instance.
   * @param {any} target
   * @param {any} slice
   */
  static _writeDataProps(target, slice) {
    for (const key of Object.keys(slice)) {
      const d = Object.getOwnPropertyDescriptor(slice, key)
      if (d && 'value' in d) target[key] = d.value
    }
  }
  /**
   * @param {Partial<import('./types/internal').SeriesData>} slice
   */
  _writeParsedSeriesData(slice) {
    ApexCharts._writeDataProps(this.w.seriesData, slice)
  }
  /**
   * @param {Partial<import('./types/internal').RangeData>} slice
   */
  _writeParsedRangeData(slice) {
    ApexCharts._writeDataProps(this.w.rangeData, slice)
  }
  /**
   * @param {Partial<import('./types/internal').CandleData>} slice
   */
  _writeParsedCandleData(slice) {
    ApexCharts._writeDataProps(this.w.candleData, slice)
  }
  /**
   * @param {Partial<import('./types/internal').LabelData>} slice
   */
  _writeParsedLabelData(slice) {
    ApexCharts._writeDataProps(this.w.labelData, slice)
  }
  /**
   * @param {Partial<import('./types/internal').AxisFlags>} slice
   */
  _writeParsedAxisFlags(slice) {
    ApexCharts._writeDataProps(this.w.axisFlags, slice)
  }
  /**
   * @param {Partial<import('./types/internal').LayoutCoords>} slice
   */
  _writeLayoutCoords(slice) {
    ApexCharts._writeDataProps(this.w.layout, slice)
  }

  _parentResizeCallback() {
    if (
      this.w.globals.animationEnded &&
      this.w.config.chart.redrawOnParentResize
    ) {
      this._windowResize()
    }
  }

  /**
   * Handle window resize and re-draw the whole chart.
   */
  _windowResize() {
    this.w.globals.resizeTimer = window.setTimeout(() => {
      const gl = this.w.globals

      // Only redraw if the resize actually changes the chart's drawing box.
      // A resize that leaves the box unchanged (a height-only change to an
      // ancestor, or an auto-sizing iframe growing to fit a fixed-size chart)
      // must not tear the chart down and rebuild it: the rebuild renders
      // instantly and cancels any running entrance animation. The signature
      // captures only the container inputs that feed the size, so a pixel-sized
      // chart never redraws on resize while a percentage-sized one still does.
      if (this.core && gl.lastResizeSignature) {
        const sig = this.core.getResizeSignature()
        if (
          sig.w === gl.lastResizeSignature.w &&
          sig.h === gl.lastResizeSignature.h &&
          sig.iw === gl.lastResizeSignature.iw
        ) {
          return
        }
      }

      gl.resized = true
      gl.dataChanged = false

      // we need to redraw the whole chart on window resize (with a small delay).
      this.ctx.update()
    }, 150)
  }

  _windowResizeHandler() {
    // Always clear any pending timer so a false→true→false toggle never fires a stale render
    clearTimeout(this.w.globals.resizeTimer ?? undefined)

    let { redrawOnWindowResize: redraw } = this.w.config.chart

    if (typeof redraw === 'function') {
      redraw = /** @type {any} */ (redraw)()
    }

    redraw && this._windowResize()
  }
}