UNPKG

apexcharts

Version:

A JavaScript Chart Library

2,384 lines 80.3 kB
// @ts-check
import CoreUtils from './CoreUtils'
import DateTime from './../utils/DateTime'
import Series from './Series'
import Utils from '../utils/Utils'
import Defaults from './settings/Defaults'
import { isCustom, getChartClass } from './ChartFactory'
import { getSeriesTransform } from './SeriesTransformRegistry'
import {
  isNestedTreemap,
  resolveTreemapTree,
} from '../charts/common/treemap/Nested'

/**
 * Chart types whose series carries raw observations and therefore cannot draw
 * anything until `apexcharts/features/stats` supplies the statistic.
 */
const RAW_SAMPLE_TYPES = ['histogram']

export default class Data {
  /**
   * @param {import('../types/internal').ChartStateW} w
   */
  constructor(w, { resetGlobals = () => {}, isMultipleY = () => {} } = {}) {
    this.w = w
    this.resetGlobals = resetGlobals
    this.isMultipleY = isMultipleY

    /** @type {any} */
    this.twoDSeries = []
    /** @type {any} */
    this.threeDSeries = []
    /** @type {any} */
    this.twoDSeriesX = []
    /** @type {any} */
    this.seriesGoals = []
    /** Warn once per chart when a raw-sample type has no registered transform. */
    this._warnedMissingTransform = false
    this.coreUtils = new CoreUtils(this.w)
    /** @type {number} */ this.activeSeriesIndex = 0
  }

  // Helper to get the first valid data point from the active series
  getFirstDataPoint() {
    const series = this.w.config.series
    const sr = new Series(this.w)
    this.activeSeriesIndex = sr.getActiveConfigSeriesIndex()
    const activeItem = /** @type {any} */ (series[this.activeSeriesIndex])

    if (
      activeItem &&
      activeItem.data &&
      activeItem.data.length > 0 &&
      activeItem.data[0] !== null &&
      typeof activeItem.data[0] !== 'undefined'
    ) {
      return activeItem.data[0]
    }
    return null
  }

  isMultiFormat() {
    return this.isFormatXY() || this.isFormat2DArray()
  }

  // given format is [{x, y}, {x, y}]
  isFormatXY() {
    const firstDataPoint = this.getFirstDataPoint()
    if (!firstDataPoint || typeof firstDataPoint.x === 'undefined') return false
    const data = /** @type {any} */ (
      this.w.config.series[this.activeSeriesIndex]
    )?.data
    if (data) {
      /**
       * @param {Record<string, any>} pt
       */
      const isXY = (pt) => pt && typeof pt.x !== 'undefined'
      for (let k = 1; k < Math.min(3, data.length); k++) {
        if (isXY(data[k]) !== true) {
          console.warn(
            `ApexCharts: series data has mixed formats starting at index ${k}`,
          )
          break
        }
      }
    }
    return true
  }

  // given format is [[x, y], [x, y]]
  isFormat2DArray() {
    const firstDataPoint = this.getFirstDataPoint()
    return firstDataPoint && Array.isArray(firstDataPoint)
  }

  /**
   * Typed single pass for the dominant [[x, y], ...] shape: scalar numeric or
   * null y, no z, no OHLC tuples. One monomorphic loop fills preallocated
   * x/y arrays and fuses the y-extrema scan that Range.getMinYMaxY would
   * otherwise repeat over every value (the extrema entry is ref+length
   * guarded, so any later reshaping of the series array simply falls back to
   * the scan). Returns false untouched on any non-conforming point so the
   * general loop below handles mixed/exotic data with unchanged output.
   * @param {any[]} data
   * @param {number} i
   * @returns {boolean}
   */
  _fast2DArrayParse(data, i) {
    const n = data.length
    if (n === 0) return false
    const ys = new Array(n)
    const xs = new Array(n)
    let maxY = -Number.MAX_VALUE
    let lowestY = Number.MAX_VALUE
    let negMinY = Infinity
    let hasNulls = false
    let yDec = 0
    // x stats are only usable when every x is a number: string x values
    // coerce in the legacy adjacent-diff loop, so mixed data falls back
    let xNumeric = true
    let minX = Infinity
    let maxX = -Infinity
    let xSorted = true
    let minXDiff = Infinity
    let prevX = NaN
    for (let j = 0; j < n; j++) {
      const point = data[j]
      if (!Array.isArray(point) || point.length > 2) return false
      const x = point[0]
      const y = point[1]
      if (xNumeric) {
        if (typeof x === 'number') {
          if (x === x) {
            if (x < minX) minX = x
            if (x > maxX) maxX = x
          }
          // NaN diffs fall through both branches, matching the legacy
          // detector's behavior on NaN-containing arrays
          const d = x - prevX
          if (d > 0) {
            if (d < minXDiff) minXDiff = d
          } else if (d < 0) {
            xSorted = false
          }
          prevX = x
        } else {
          xNumeric = false
        }
      }
      if (typeof y === 'number') {
        // same observable semantics as Range's plain-numeric lane:
        // NaN/Infinity count as null values, decimals counted in plain
        // notation for 1e-6..1e21, legacy noExponents/isFloat for exotics
        if (y === y && y !== Infinity && y !== -Infinity) {
          if (y > maxY) maxY = y
          if (y < lowestY) lowestY = y
          if (y < 0 && y < negMinY) negMinY = y
          if (!Number.isInteger(y)) {
            const av = y < 0 ? -y : y
            if (av >= 1e-6 && av < 1e21) {
              const str = '' + y
              const dot = str.indexOf('.')
              const dec = dot === -1 ? 0 : str.length - dot - 1
              if (dec > yDec) yDec = dec
            } else {
              const nv = Utils.noExponents(y)
              if (Utils.isFloat(nv)) {
                yDec = Math.max(yDec, nv.toString().split('.')[1].length)
              }
            }
          }
        } else {
          hasNulls = true
        }
      } else if (y === null) {
        hasNulls = true
      } else {
        // string/array/undefined y: needs parseNumber / OHLC handling
        return false
      }
      ys[j] = y
      xs[j] = x
    }
    this.twoDSeries = ys
    this.twoDSeriesX = xs
    this.w.axisFlags.dataFormatXNumeric = true
    const extrema = (this.w.seriesData._parsedExtrema ??= [])
    extrema[i] = {
      ref: ys,
      len: n,
      maxY,
      lowestY,
      negMinY,
      hasNulls,
      yDec,
      xref: xs,
      xNumeric,
      minX,
      maxX,
      xSorted,
      minXDiff,
    }
    return true
  }

  /**
   * @param {any[]} ser
   * @param {number} i
   */
  handleFormat2DArray(ser, i) {
    const cnf = this.w.config
    const data = ser[i].data

    const isBoxPlot =
      cnf.chart.type === 'boxPlot' ||
      /** @type {any} */ (cnf.series[i]).type === 'boxPlot'

    if (
      !isBoxPlot &&
      cnf.xaxis.type !== 'datetime' &&
      this._fast2DArrayParse(data, i)
    ) {
      return
    }

    for (let j = 0; j < data.length; j++) {
      const point = data[j]
      const x = point[0]
      const y = point[1]
      const z = point[2]

      if (typeof y !== 'undefined') {
        if (Array.isArray(y) && y.length === 4 && !isBoxPlot) {
          // candlestick nested ohlc format
          this.twoDSeries.push(Utils.parseNumber(y[3]))
        } else if (point.length >= 5) {
          // candlestick non-nested ohlc format
          this.twoDSeries.push(Utils.parseNumber(point[4]))
        } else {
          this.twoDSeries.push(Utils.parseNumber(y))
        }
        this.w.axisFlags.dataFormatXNumeric = true
      }
      if (cnf.xaxis.type === 'datetime') {
        // if timestamps are provided and xaxis type is datetime,
        const ts = new Date(x).getTime()
        this.twoDSeriesX.push(ts)
      } else {
        this.twoDSeriesX.push(x)
      }

      if (typeof z !== 'undefined') {
        this.threeDSeries.push(z)
        this.w.axisFlags.isDataXYZ = true
      }
    }
  }

  /**
   * @param {any[]} ser
   * @param {number} i
   */
  handleFormatXY(ser, i) {
    const cnf = this.w.config
    const gl = this.w.globals
    const dt = new DateTime(this.w)
    const data = ser[i].data

    let activeI = i
    if (gl.collapsedSeriesIndices.indexOf(i) > -1) {
      // fix #368
      activeI = this.activeSeriesIndex
    }
    const activeData = ser[activeI].data

    // get series, goals, z
    for (let j = 0; j < data.length; j++) {
      const point = data[j]

      if (typeof point.y !== 'undefined') {
        const val = Array.isArray(point.y)
          ? Utils.parseNumber(point.y[point.y.length - 1])
          : Utils.parseNumber(point.y)
        this.twoDSeries.push(val)
      }

      if (typeof this.seriesGoals[i] === 'undefined') {
        this.seriesGoals[i] = []
      }
      if (typeof point.goals !== 'undefined' && Array.isArray(point.goals)) {
        this.seriesGoals[i].push(point.goals)
      } else {
        this.seriesGoals[i].push(null)
      }

      if (typeof point.z !== 'undefined') {
        this.threeDSeries.push(point.z)
        this.w.axisFlags.isDataXYZ = true
      }
    }

    // get seriesX
    for (let j = 0; j < activeData.length; j++) {
      const point = activeData[j]
      const x = point.x

      const isXString = typeof x === 'string'
      const isXArr = Array.isArray(x)
      const isXDate = !isXArr && !!dt.isValidDate(x)

      if (isXString || isXDate) {
        // user supplied a date string ('01/01/2017') or a JS Date object
        if (isXString || cnf.xaxis.convertedCatToNumeric) {
          const isRangeColumn =
            gl.isBarHorizontal && this.w.axisFlags.isRangeData

          if (cnf.xaxis.type === 'datetime' && !isRangeColumn) {
            this.twoDSeriesX.push(dt.parseDate(x))
          } else {
            // a category and not a numeric x value
            this.fallbackToCategory = true
            this.twoDSeriesX.push(x)

            if (
              !isNaN(x) &&
              this.w.config.xaxis.type !== 'category' &&
              typeof x !== 'string'
            ) {
              this.w.axisFlags.isXNumeric = true
            }
          }
        } else {
          if (cnf.xaxis.type === 'datetime') {
            // A Date already carries an exact epoch value, so read it
            // directly. Date.prototype.toString() has second resolution, and
            // parsing its output back drops the milliseconds silently.
            this.twoDSeriesX.push(
              x instanceof Date ? x.getTime() : dt.parseDate(x.toString()),
            )
          } else {
            this.w.axisFlags.dataFormatXNumeric = true
            this.w.axisFlags.isXNumeric = true
            this.twoDSeriesX.push(parseFloat(x))
          }
        }
      } else if (isXArr) {
        // a multiline label described in array format
        this.fallbackToCategory = true
        this.twoDSeriesX.push(x)
      } else {
        // a numeric value in x property
        this.w.axisFlags.isXNumeric = true
        this.w.axisFlags.dataFormatXNumeric = true
        this.twoDSeriesX.push(x)
      }
    }
  }

  /**
   * @param {any[]} ser
   * @param {number} i
   */
  handleRangeData(ser, i) {
    /** @type {any} */
    let range = { start: [], end: [], rangeUniques: [] }
    if (this.isFormat2DArray()) {
      range = this.handleRangeDataFormat('array', ser, i)
    } else if (this.isFormatXY()) {
      range = this.handleRangeDataFormat('xy', ser, i)
    }

    // Fix: RangeArea Chart: hide all series results in a crash #3984
    this.w.rangeData.seriesRangeStart[i] =
      range.start === undefined ? [] : range.start
    this.w.rangeData.seriesRangeEnd[i] =
      range.end === undefined ? [] : range.end

    this.w.rangeData.seriesRange[i] = range.rangeUniques

    // check for overlaps to avoid clashes in a timeline chart
    /**
     * @param {Array<Record<string, any>>} sr
     */
    this.w.rangeData.seriesRange.forEach((sr) => {
      if (!sr) return

      /**
       * @param {Record<string, any>} sarr
       */
      sr.forEach((sarr) => {
        const yItems = /** @type {any} */ (sarr).y
        const len = /** @type {any[]} */ (yItems).length

        // Pre-check: if only one item, no overlaps possible
        if (len <= 1) return

        for (let arri = 0; arri < len; arri++) {
          const arr = /** @type {any} */ (yItems[arri])
          const range1y1 = arr.y1
          const range1y2 = arr.y2

          // Only check subsequent items to avoid duplicate comparisons
          for (let sri = arri + 1; sri < len; sri++) {
            const range2 = /** @type {any} */ (yItems[sri])
            const range2y1 = range2.y1
            const range2y2 = range2.y2

            // Check overlap using interval intersection
            if (range1y1 <= range2y2 && range2y1 <= range1y2) {
              const sarrAny = /** @type {any} */ (sarr)
              sarrAny.overlaps.add(arr.rangeName)
              sarrAny.overlaps.add(range2.rangeName)
            }
          }
        }
      })
    })

    return range
  }

  /**
   * Marks (#11) P3: fold a custom series' per-datum y-extent into the
   * range-data slice so both bounds drive the y-axis scale. When `yExtent` is
   * given it supplies the values a datum occupies (scalar or array => min/max
   * across them); otherwise the datum's `y` is used (array => first/last,
   * scalar => itself). The datum still carries a representative scalar `y`
   * (folded by handleFormatXY into seriesData.series) that gates Range.
   * @param {any[]} ser @param {number} i @param {Function|null} yExtent
   */
  handleCustomRangeData(ser, i, yExtent) {
    const data = ser[i].data || []
    /** @type {any[]} */
    const start = []
    /** @type {any[]} */
    const end = []
    for (let j = 0; j < data.length; j++) {
      const datum = data[j]
      let lo
      let hi
      if (typeof yExtent === 'function') {
        let ext = yExtent(datum, j)
        if (!Array.isArray(ext)) ext = [ext]
        const nums = ext
          .map((/** @type {any} */ v) => Utils.parseNumber(v))
          .filter((/** @type {any} */ v) => v !== null && !isNaN(v))
        lo = nums.length ? Math.min(...nums) : null
        hi = nums.length ? Math.max(...nums) : null
      } else {
        const y = datum == null ? null : datum.y
        if (Array.isArray(y)) {
          lo = Utils.parseNumber(y[0])
          hi = Utils.parseNumber(y[y.length - 1])
        } else {
          lo = hi = Utils.parseNumber(y)
        }
      }
      start.push(lo)
      end.push(hi)
    }
    this.w.rangeData.seriesRangeStart[i] = start
    this.w.rangeData.seriesRangeEnd[i] = end
  }

  /**
   * @param {any[]} ser
   * @param {number} i
   */
  handleCandleStickBoxData(ser, i) {
    /** @type {any} */
    let ohlc = { o: [], h: [], m: [], l: [], c: [] }
    if (this.isFormat2DArray()) {
      ohlc = this.handleCandleStickBoxDataFormat('array', ser, i)
    } else if (this.isFormatXY()) {
      ohlc = this.handleCandleStickBoxDataFormat('xy', ser, i)
    }

    this.w.candleData.seriesCandleO[i] = ohlc.o
    this.w.candleData.seriesCandleH[i] = ohlc.h
    this.w.candleData.seriesCandleM[i] = ohlc.m
    this.w.candleData.seriesCandleL[i] = ohlc.l
    this.w.candleData.seriesCandleC[i] = ohlc.c
    this.w.candleData.seriesBoxPoints[i] = ohlc.points || []

    return ohlc
  }

  /**
   * Parse a violin series. Each data point carries a precomputed density
   * profile (the violin shape) and an array of raw observations (the jitter):
   *
   *   { x, y: { density: [[value, weight], ...], points: [v1, v2, ...] } }
   *
   * Array fallback form: [x, densityPairs, pointsArray].
   *
   * Density `weight` need not be normalized — Violin.js scales each violin by
   * its own maxWeight. The representative scalar pushed into the main series
   * (so generic code paths see a non-null y) is the density mode — the value
   * carrying the greatest weight.
   *
   * @param {any[]} ser
   * @param {number} i
   */
  handleViolinData(ser, i) {
    const w = this.w
    const data = ser[i].data

    /** @type {any[]} */ const densityArr = []
    /** @type {any[]} */ const pointsArr = []
    /** @type {number[]} */ const minArr = []
    /** @type {number[]} */ const maxArr = []
    /** @type {number[]} */ const placeholders = []

    for (let j = 0; j < data.length; j++) {
      const d = data[j]
      const dens = d?.y?.density ?? d?.[1] ?? []
      const pts = d?.y?.points ?? d?.[2] ?? []

      /** @type {number[]} */ const values = []
      /** @type {number[]} */ const weights = []
      let maxWeight = 0
      let modeValue = null
      let minVal = Infinity
      let maxVal = -Infinity

      for (let k = 0; k < dens.length; k++) {
        const v = Utils.parseNumber(dens[k][0])
        const wt = Utils.parseNumber(dens[k][1])
        if (v === null || wt === null) continue
        values.push(v)
        weights.push(wt)
        if (wt > maxWeight) {
          maxWeight = wt
          modeValue = v
        }
        if (v < minVal) minVal = v
        if (v > maxVal) maxVal = v
      }

      /** @type {number[]} */ const cleanPts = []
      for (let k = 0; k < pts.length; k++) {
        const p = Utils.parseNumber(pts[k])
        if (p === null) continue
        cleanPts.push(p)
        if (p < minVal) minVal = p
        if (p > maxVal) maxVal = p
      }

      densityArr.push({ values, weights, maxWeight })
      pointsArr.push(cleanPts)
      minArr.push(minVal === Infinity ? 0 : minVal)
      maxArr.push(maxVal === -Infinity ? 0 : maxVal)
      // Representative value: density mode, else median of points, else 0.
      placeholders.push(
        modeValue !== null
          ? modeValue
          : cleanPts.length
            ? cleanPts[Math.floor(cleanPts.length / 2)]
            : 0,
      )
    }

    w.violinData.seriesViolinDensity[i] = densityArr
    w.violinData.seriesViolinPoints[i] = pointsArr
    w.violinData.seriesViolinMin[i] = minArr
    w.violinData.seriesViolinMax[i] = maxArr

    // Overwrite the y-placeholders that handleFormatXY/2DArray pushed (it could
    // not interpret the object/array y) with the representative scalars.
    this.twoDSeries = placeholders
  }

  /**
   * @param {string} format
   * @param {any[]} ser
   * @param {number} i
   */
  handleRangeDataFormat(format, ser, i) {
    const rangeStart = []
    const rangeEnd = []

    const uniqueKeysMap = new Map()
    /** @type {any[]} */
    const uniqueKeys = []

    // unique keys map
    /**
     * @param {Record<string, any>} item
     */
    ser[i].data.forEach((/** @type {any} */ item) => {
      if (!uniqueKeysMap.has(item.x)) {
        const keyObj = {
          x: item.x,
          overlaps: new Set(),
          y: [],
        }
        uniqueKeysMap.set(item.x, keyObj)
        uniqueKeys.push(keyObj)
      }
    })

    if (format === 'array') {
      for (let j = 0; j < ser[i].data.length; j++) {
        if (Array.isArray(ser[i].data[j])) {
          rangeStart.push(ser[i].data[j][1][0])
          rangeEnd.push(ser[i].data[j][1][1])
        } else {
          rangeStart.push(ser[i].data[j])
          rangeEnd.push(ser[i].data[j])
        }
      }
    } else if (format === 'xy') {
      for (let j = 0; j < ser[i].data.length; j++) {
        const isDataPoint2D = Array.isArray(ser[i].data[j].y)
        const id = Utils.randomId()
        const x = ser[i].data[j].x
        const y = {
          y1: isDataPoint2D ? ser[i].data[j].y[0] : ser[i].data[j].y,
          y2: isDataPoint2D ? ser[i].data[j].y[1] : ser[i].data[j].y,
          rangeName: id,
        }

        // Stash the per-point range id off to the side (keyed by series/point
        // index) rather than mutating the user's config data point. RangeBar
        // reads it back via w.globals.seriesRangeName[i][j] to compute the
        // overlap offset. Avoids leaking a `rangeName` onto user objects (which
        // breaks framework reactivity / immutable diffing). See audit D1.
        const gl = this.w.globals
        if (!gl.seriesRangeName) gl.seriesRangeName = {}
        if (!gl.seriesRangeName[i]) gl.seriesRangeName[i] = {}
        gl.seriesRangeName[i][j] = id

        const keyObj = uniqueKeysMap.get(x)
        if (keyObj) {
          keyObj.y.push(y)
        }

        rangeStart.push(y.y1)
        rangeEnd.push(y.y2)
      }
    }

    return {
      start: rangeStart,
      end: rangeEnd,
      rangeUniques: uniqueKeys,
    }
  }

  /**
   * @param {string} format
   * @param {any[]} ser
   * @param {number} i
   */
  handleCandleStickBoxDataFormat(format, ser, i) {
    const w = this.w
    const isBoxPlot =
      w.config.chart.type === 'boxPlot' ||
      /** @type {Record<string,any>} */ (w.config.series[i]).type === 'boxPlot'

    const serO = []
    const serH = []
    const serM = []
    const serL = []
    const serC = []
    // Raw observations for optional boxPlot jitter (object form only — the
    // flat-array form has no slot for them).
    const serPoints = []

    const data = ser[i].data
    let getVals

    if (format === 'array') {
      // Guard data[0]: an empty series (e.g. one blank series in a multi-series
      // candlestick/boxPlot) still enters this branch; the loop below no-ops on
      // empty data, so we only need to avoid dereferencing data[0].length.
      const first = data[0]
      const isFlat =
        (isBoxPlot && first && first.length === 6) ||
        (!isBoxPlot && first && first.length === 5)
      if (isFlat) {
        /**
         * @param {any[]} d
         */
        getVals = (d) => d.slice(1)
      } else {
        /**
         * @param {any} d
         */
        getVals = (d) => (Array.isArray(d[1]) ? d[1] : [])
      }
    } else {
      // format === 'xy'
      /**
       * @param {Record<string, any>} d
       */
      getVals = (d) => (Array.isArray(d.y) ? d.y : [])
    }

    for (let j = 0; j < data.length; j++) {
      const vals = getVals(data[j])
      if (vals && vals.length >= 2) {
        serO.push(vals[0])
        serH.push(vals[1])
        if (isBoxPlot) {
          serM.push(vals[2])
          serL.push(vals[3])
          serC.push(vals[4])
        } else {
          serL.push(vals[2])
          serC.push(vals[3])
        }
      }
      const pts = data[j] && /** @type {any} */ (data[j]).points
      serPoints.push(Array.isArray(pts) ? pts : [])
    }

    return {
      o: serO,
      h: serH,
      m: serM,
      l: serL,
      c: serC,
      points: serPoints,
    }
  }

  /**
   * @param {any[]} ser
   */
  parseDataAxisCharts(ser) {
    const cnf = this.w.config
    const gl = this.w.globals

    const dt = new DateTime(this.w)

    // per-parse fused-extrema entries filled by _fast2DArrayParse, consumed
    // by Range.getMinYMaxY (ref+length guarded)
    this.w.seriesData._parsedExtrema = []

    const xlabels =
      cnf.labels.length > 0 ? cnf.labels.slice() : cnf.xaxis.categories.slice()

    this.w.axisFlags.isRangeBar =
      cnf.chart.type === 'rangeBar' && gl.isBarHorizontal

    this.w.labelData.hasXaxisGroups =
      cnf.xaxis.type === 'category' && cnf.xaxis.group.groups.length > 0
    if (this.w.labelData.hasXaxisGroups) {
      this.w.labelData.groups = cnf.xaxis.group.groups
    }

    /**
     * @param {Record<string, any>} s
     * @param {number} i
     */
    ser.forEach((s, i) => {
      if (s.name !== undefined) {
        this.w.seriesData.seriesNames.push(s.name)
      } else {
        this.w.seriesData.seriesNames.push(
          'series-' + parseInt(String(i + 1), 10),
        )
      }
    })

    this.coreUtils.setSeriesYAxisMappings()
    // At this point, every series that didn't have a user defined group name
    // has been given a name according to the yaxis the series is referenced by.
    // This fits the existing behaviour where all series associated with an axis
    // are defacto presented as a single group. It is now formalised.
    /** @type {any[]} */
    const buckets = []
    /**
     * @param {Record<string, any>} s
     */
    const groups = [
      ...new Set(cnf.series.map((/** @type {any} */ s) => s.group)),
    ]
    cnf.series.forEach((/** @type {any} */ s, i) => {
      const index = groups.indexOf(s.group)
      if (!buckets[index]) buckets[index] = []

      buckets[index].push(this.w.seriesData.seriesNames[i])
    })
    this.w.labelData.seriesGroups = buckets

    const handleDates = () => {
      for (let j = 0; j < xlabels.length; j++) {
        if (typeof xlabels[j] === 'string') {
          // user provided date strings
          const isDate = dt.isValidDate(xlabels[j])
          if (isDate) {
            this.twoDSeriesX.push(dt.parseDate(xlabels[j]))
          } else {
            throw new Error(
              'You have provided invalid Date format. Please provide a valid JavaScript Date',
            )
          }
        } else {
          // user provided timestamps
          this.twoDSeriesX.push(xlabels[j])
        }
      }
    }

    for (let i = 0; i < ser.length; i++) {
      this.twoDSeries = []
      this.twoDSeriesX = []
      this.threeDSeries = []

      if (typeof ser[i].data === 'undefined') {
        console.error(
          "It is a possibility that you may have not included 'data' property in series.",
        )
        // Treat a series missing `data` as an empty series and keep parsing, so
        // the parsed arrays (series / labels / seriesZ / seriesColors) stay
        // index-aligned with the remaining series (and seriesNames) instead of
        // aborting the whole parse. Replace (not mutate) ser[i], matching the
        // downsample path below, so the caller's series object is left intact.
        ser[i] = { ...ser[i], data: [] }
      }

      // Zoom-aware LTTB downsampling — runs before any parsing so all
      // downstream paths benefit. Only applies to multiFormat (XY/{x,y}) data
      // where both x and y are available for triangle-area calculation.
      // On zoom, cnf.xaxis.min/max define the visible window; we slice the
      // stashed raw series to that window and downsample the slice so users
      // see progressively higher-resolution detail as they zoom in.
      const dr = cnf.chart.dataReducer
      const rawStash = gl.dataReducerRawSeries?.[i]?.data
      if (
        dr?.enabled &&
        this.isMultiFormat() &&
        Array.isArray(rawStash) &&
        rawStash.length > (dr.threshold ?? 500)
      ) {
        const targetPoints = dr.targetPoints ?? 250
        const xmin = cnf.xaxis.min
        const xmax = cnf.xaxis.max
        const windowed =
          xmin == null && xmax == null
            ? rawStash
            : Data.sliceByXRange(rawStash, xmin, xmax)

        // Pick the reduction strategy by the data's *shape*, not the chart-type
        // config (so combo charts work too). A point whose y is a 4-tuple is
        // candlestick/OHLC: aggregate into OHLC buckets so the high/low extremes
        // survive — LTTB would silently drop them. A 2-tuple is range data
        // (rangeArea/rangeBar): aggregate into [min low, max high] buckets, same
        // extreme-preserving idea. Scalar-y series (line/area) use LTTB, which
        // preserves visual shape. Any other array-y data (e.g. a boxPlot
        // 5-tuple) can't be meaningfully merged, so it's left as the windowed
        // slice rather than corrupted.
        let reduced = windowed
        if (windowed.length > targetPoints) {
          const sampleY = !Array.isArray(windowed[0])
            ? windowed[0]?.y
            : windowed[0]?.[1]
          if (Array.isArray(sampleY)) {
            if (sampleY.length === 4) {
              reduced = Data.ohlcAggregate(windowed, targetPoints)
            } else if (sampleY.length === 2) {
              reduced = Data.rangeAggregate(windowed, targetPoints)
            }
          } else {
            reduced = Data.lttbDownsample(windowed, targetPoints)
          }
        }
        ser[i] = { ...ser[i], data: reduced }
      }

      if (
        cnf.chart.type === 'rangeBar' ||
        cnf.chart.type === 'rangeArea' ||
        ser[i].type === 'rangeBar' ||
        ser[i].type === 'rangeArea'
      ) {
        this.w.axisFlags.isRangeData = true
        this.handleRangeData(ser, i)
      }

      // Marks (#11) P3: a custom series type may declare range/extent semantics
      // (dumbbell y:[lo,hi], bullet, ...) so BOTH y-bounds fold into the axis
      // scale. Reuses the range-data slice: Range.getMinYMaxY folds
      // seriesRangeStart/End, and the tooltip renders "lo - hi".
      const customType = ser[i].type || cnf.chart.type
      if (isCustom(customType)) {
        const cls = /** @type {any} */ (getChartClass(customType))
        const yExtent = cls && cls.yExtent
        if ((cls && cls.dataType === 'rangeXY') || typeof yExtent === 'function') {
          this.w.axisFlags.isRangeData = true
          this.handleCustomRangeData(ser, i, yExtent)
        }
      }

      if (this.isMultiFormat()) {
        if (this.isFormat2DArray()) {
          this.handleFormat2DArray(ser, i)
        } else if (this.isFormatXY()) {
          this.handleFormatXY(ser, i)
        }

        if (
          cnf.chart.type === 'candlestick' ||
          ser[i].type === 'candlestick' ||
          cnf.chart.type === 'boxPlot' ||
          ser[i].type === 'boxPlot'
        ) {
          this.handleCandleStickBoxData(ser, i)
        }

        if (cnf.chart.type === 'violin' || ser[i].type === 'violin') {
          // Must run after handleFormatXY/2DArray — it rebuilds this.twoDSeries
          // with representative scalars before the push below.
          this.handleViolinData(ser, i)
        }

        this.w.seriesData.series.push(this.twoDSeries)
        this.w.labelData.labels.push(this.twoDSeriesX)
        this.w.seriesData.seriesX.push(this.twoDSeriesX)
        this.w.seriesData.seriesGoals = this.seriesGoals

        if (i === this.activeSeriesIndex && !this.fallbackToCategory) {
          this.w.axisFlags.isXNumeric = true
        }
      } else {
        if (cnf.xaxis.type === 'datetime') {
          // user didn't supplied [{x,y}] or [[x,y]], but single array in data.
          // Also labels/categories were supplied differently
          this.w.axisFlags.isXNumeric = true

          handleDates()

          this.w.seriesData.seriesX.push(this.twoDSeriesX)
        } else if (cnf.xaxis.type === 'numeric') {
          this.w.axisFlags.isXNumeric = true

          if (xlabels.length > 0) {
            this.twoDSeriesX = xlabels
            this.w.seriesData.seriesX.push(this.twoDSeriesX)
          }
        }
        this.w.labelData.labels.push(this.twoDSeriesX)
        /**
         * @param {any} d
         */
        const singleArray = ser[i].data.map((/** @type {any} */ d) =>
          Utils.parseNumber(d),
        )
        this.w.seriesData.series.push(singleArray)
      }

      this.w.seriesData.seriesZ.push(this.threeDSeries)

      // overrided default color if user inputs color with series data
      if (ser[i].color !== undefined) {
        this.w.seriesData.seriesColors.push(ser[i].color)
      } else {
        this.w.seriesData.seriesColors.push(/** @type {any} */ (undefined))
      }
    }

    return this.w
  }

  /**
   * @param {any[]} ser
   */
  parseDataNonAxisCharts(ser) {
    const cnf = this.w.config

    // Reset any per-unit data from a previous parse; only the unit chart's
    // object form (below) repopulates it.
    this.w.seriesData.unitData = []

    // Check if we have both old format (numeric series + labels) and new format
    const hasOldFormat =
      Array.isArray(ser) &&
      ser.every((s) => typeof s === 'number') &&
      cnf.labels.length > 0
    const hasNewFormat =
      Array.isArray(ser) &&
      ser.some(
        (s) =>
          (s && typeof s === 'object' && s.data) ||
          (s && typeof s === 'object' && s.parsing),
      )

    // Unit chart with the per-unit object form: each category is an array of
    // unit data (one datum per dot), not a single aggregate. Handle it before
    // the pie extraction below (which would treat every unit as its own slice).
    if (cnf.chart.type === 'unit' && hasNewFormat && !hasOldFormat) {
      return this.parseUnitSeries(ser)
    }

    if (hasOldFormat && hasNewFormat) {
      console.warn(
        'ApexCharts: Both old format (numeric series + labels) and new format (series objects with data/parsing) detected. Using old format for backward compatibility.',
      )
    }

    // If old format exists, use it (backward compatibility priority)
    if (hasOldFormat) {
      this.w.seriesData.series = /** @type {any} */ (ser.slice())
      this.w.seriesData.seriesNames = cnf.labels.slice()
      for (let i = 0; i < this.w.seriesData.series.length; i++) {
        if (this.w.seriesData.seriesNames[i] === undefined) {
          this.w.seriesData.seriesNames.push('series-' + (i + 1))
        }
      }
      return this.w
    }

    // Check if it's just a plain numeric array without labels (radialBar common case)
    if (Array.isArray(ser) && ser.every((s) => typeof s === 'number')) {
      this.w.seriesData.series = /** @type {any} */ (ser.slice())
      this.w.seriesData.seriesNames = []
      for (let i = 0; i < this.w.seriesData.series.length; i++) {
        this.w.seriesData.seriesNames.push(cnf.labels[i] || `series-${i + 1}`)
      }
      return this.w
    }

    const processedData = this.extractPieDataFromSeries(ser)

    this.w.seriesData.series = processedData.values
    this.w.seriesData.seriesNames = processedData.labels

    // Special handling for radialBar - ensure percentages are valid
    if (cnf.chart.type === 'radialBar') {
      /**
       * @param {any} val
       */
      this.w.seriesData.series = this.w.seriesData.series.map((val) => {
        const numVal = Utils.parseNumber(val)
        if (numVal > 100) {
          console.warn(
            `ApexCharts: RadialBar value ${numVal} > 100, consider using percentage values (0-100)`,
          )
        }
        return numVal
      })
    }

    // Ensure we have proper fallback names
    for (let i = 0; i < this.w.seriesData.series.length; i++) {
      if (this.w.seriesData.seriesNames[i] === undefined) {
        this.w.seriesData.seriesNames.push('series-' + (i + 1))
      }
    }

    return this.w
  }

  /**
   * Parse the unit chart's per-unit object form:
   *   series: [{ name, data: [datum, datum, ...] }, ...]
   * Each category's dot count is `data.length` (one dot per datum), and the
   * per-unit data is kept on `w.seriesData.unitData` so the renderer can colour
   * dots individually and the tooltip can show each unit's own info.
   * @param {any[]} ser
   * @returns {any} w
   */
  parseUnitSeries(ser) {
    const cnf = this.w.config
    /** @type {number[]} */
    const series = []
    /** @type {string[]} */
    const seriesNames = []
    /** @type {any[][]} */
    const unitData = []

    ser.forEach((s, i) => {
      const data = s && Array.isArray(s.data) ? s.data : []
      series.push(data.length)
      const name =
        s && s.name !== undefined && s.name !== null ? s.name : undefined
      seriesNames.push(name ?? cnf.labels[i] ?? `series-${i + 1}`)
      unitData.push(data.slice())
    })

    this.w.seriesData.series = /** @type {any} */ (series)
    this.w.seriesData.seriesNames = seriesNames
    this.w.seriesData.unitData = unitData

    return this.w
  }

  /**
   * Reset parsing flags to allow re-parsing of data during updates
   */
  resetParsingFlags() {
    const w = this.w
    w.axisFlags.dataWasParsed = false
    w.globals.originalSeries = null

    if (w.config.series) {
      /**
       * @param {Object} serie
       */
      w.config.series.forEach((serie) => {
        if (/** @type {any} */ (serie).__apexParsed) {
          delete (/** @type {any} */ (serie).__apexParsed)
        }
      })
    }
  }

  /**
   * @param {any[]} ser
   */
  extractPieDataFromSeries(ser) {
    /** @type {any[]} */
    const values = []
    /** @type {any[]} */
    const labels = []

    if (!Array.isArray(ser)) {
      console.warn('ApexCharts: Expected array for series data')
      return { values: [], labels: [] }
    }

    if (ser.length === 0) {
      console.warn('ApexCharts: Empty series array')
      return { values: [], labels: [] }
    }

    // Handle only series objects with data property
    const firstItem = ser[0]

    if (typeof firstItem === 'object' && firstItem !== null && firstItem.data) {
      // Format: [{ data: [{x: 'A', y: 10}] }] or [{ data: rawData, parsing: {...} }]
      this.extractPieDataFromSeriesObjects(ser, values, labels)
    } else {
      // Unsupported format
      console.warn(
        'ApexCharts: Unsupported series format for pie/donut/radialBar. Expected series objects with data property.',
      )
      return { values: [], labels: [] }
    }

    return { values, labels }
  }

  // Extract data from series objects: [{ data: [...], parsing: {...} }]
  /**
   * @param {any[]} seriesArray
   * @param {any[]} values
   * @param {any[]} labels
   */
  extractPieDataFromSeriesObjects(seriesArray, values, labels) {
    /**
     * @param {Object} serie
     * @param {number} serieIndex
     */
    seriesArray.forEach((serie, serieIndex) => {
      if (!serie.data || !Array.isArray(serie.data)) {
        console.warn(`ApexCharts: Series ${serieIndex} has no valid data array`)
        return
      }

      // If series was already parsed by parseRawDataIfNeeded, data should be in {x, y} format
      /**
       * @param {Record<string, any>} dataPoint
       */
      serie.data.forEach((/** @type {any} */ dataPoint) => {
        if (typeof dataPoint === 'object' && dataPoint !== null) {
          if (dataPoint.x !== undefined && dataPoint.y !== undefined) {
            labels.push(String(dataPoint.x))
            values.push(Utils.parseNumber(dataPoint.y))
          } else {
            console.warn(
              'ApexCharts: Invalid data point format for pie chart. Expected {x, y} format:',
              dataPoint,
            )
          }
        } else {
          console.warn(
            'ApexCharts: Expected object data point, got:',
            typeof dataPoint,
          )
        }
      })
    })
  }

  /** User possibly set string categories in xaxis.categories or labels prop
   * Or didn't set xaxis labels at all - in which case we manually do it.
   * If user passed series data as [[3, 2], [4, 5]] or [{ x: 3, y: 55 }],
   * this shouldn't be called
   * @param {any[]} ser - the series which user passed to the config
   */
  handleExternalLabelsData(ser) {
    const cnf = this.w.config

    if (cnf.xaxis.categories.length > 0) {
      // user provided labels in xaxis.category prop
      this.w.labelData.labels = cnf.xaxis.categories
    } else if (cnf.labels.length > 0) {
      // user provided labels in labels props
      this.w.labelData.labels = cnf.labels.slice()
    } else if (this.fallbackToCategory) {
      // user provided labels in x prop in [{ x: 3, y: 55 }] data, and those labels are already stored in this.w.labelData.labels[0], so just re-arrange the this.w.labelData.labels array
      this.w.labelData.labels = /** @type {string[]} */ (
        /** @type {unknown} */ (this.w.labelData.labels[0])
      )

      if (this.w.rangeData.seriesRange.length) {
        /**
         * @param {Array<Record<string, any>>} srt
         */
        this.w.rangeData.seriesRange.map((srt) => {
          srt.forEach((/** @type {any} */ sr) => {
            if (this.w.labelData.labels.indexOf(sr.x) < 0 && sr.x) {
              this.w.labelData.labels.push(sr.x)
            }
          })
        })
        // remove duplicate x-axis labels
        const _labels = this.w.labelData.labels
        if (
          _labels.length > 0 &&
          (typeof _labels[0] === 'number' || typeof _labels[0] === 'string')
        ) {
          this.w.labelData.labels = [...new Set(_labels)]
        } else {
          const _seen = new Map()
          for (const _label of _labels) {
            const _key = JSON.stringify(_label)
            if (!_seen.has(_key)) _seen.set(_key, _label)
          }
          this.w.labelData.labels = Array.from(_seen.values())
        }
      }

      if (cnf.xaxis.convertedCatToNumeric) {
        const defaults = new Defaults(cnf)
        defaults.convertCatToNumericXaxis(cnf, this.w.seriesData.seriesX[0])
        this._generateExternalLabels(ser)
      }
    } else {
      this._generateExternalLabels(ser)
    }
  }

  /**
   * @param {any[]} ser
   */
  _generateExternalLabels(ser) {
    const gl = this.w.globals
    const cnf = this.w.config
    // user didn't provided any labels, fallback to 1-2-3-4-5
    let labelArr = []

    if (gl.axisCharts) {
      if (this.w.seriesData.series.length > 0) {
        if (this.isFormatXY()) {
          // in case there is a combo chart (boxplot/scatter)
          // and there are duplicated x values, we need to eliminate duplicates
          /**
           * @param {Object} serie
           */
          const seriesDataFiltered = cnf.series.map(
            (/** @type {any} */ serie) => {
              const seen = new Map()
              for (const point of serie.data) {
                if (!seen.has(point.x)) seen.set(point.x, point)
              }
              return Array.from(seen.values())
            },
          )

          /**
           * @param {number} p
           * @param {any} c
           * @param {number} i
           * @param {any} a
           */
          const len = seriesDataFiltered.reduce(
            (p, c, i, a) => (a[p].length > c.length ? p : i),
            0,
          )

          for (let i = 0; i < seriesDataFiltered[len].length; i++) {
            labelArr.push(i + 1)
          }
        } else {
          for (
            let i = 0;
            i < this.w.seriesData.series[gl.maxValsInArrayIndex].length;
            i++
          ) {
            labelArr.push(i + 1)
          }
        }
      }

      this.w.seriesData.seriesX = []
      // create this.w.seriesData.seriesX as it will be used in calculations of x positions
      for (let i = 0; i < ser.length; i++) {
        this.w.seriesData.seriesX.push(labelArr)
      }

      // turn on the isXNumeric flag to allow minX and maxX to function properly
      if (!this.w.globals.isBarHorizontal) {
        this.w.axisFlags.isXNumeric = true
      }
    }

    // no series to pull labels from, put a 0-10 series
    // possibly, user collapsed all series. Hence we can't work with above calc
    if (labelArr.length === 0) {
      labelArr = gl.axisCharts
        ? []
        : /**
           * @param {Record<string, any>} gls
           * @param {number} glsi
           */
          this.w.seriesData.series.map((gls, glsi) => {
            return glsi + 1
          })
      for (let i = 0; i < ser.length; i++) {
        this.w.seriesData.seriesX.push(labelArr)
      }
    }

    // Finally, pass the labelArr in this.w.labelData.labels which will be printed on x-axis
    this.w.labelData.labels = /** @type {string[]} */ (
      /** @type {unknown} */ (labelArr)
    )

    if (cnf.xaxis.convertedCatToNumeric) {
      /**
       * @param {number} l
       */
      this.w.labelData.categoryLabels = labelArr.map((l) => {
        return cnf.xaxis.labels.formatter(l)
      })
    }

    // Turn on this global flag to indicate no labels were provided by user
    this.w.axisFlags.noLabelsProvided = true
  }

  /**
   * @param {any[]} series
   */
  parseRawDataIfNeeded(series) {
    const cnf = this.w.config
    const gl = this.w.globals
    const globalParsing = cnf.parsing

    // If data was already parsed, don't parse again
    if (this.w.axisFlags.dataWasParsed) {
      return series
    }

    // If no global parsing config and no series-level parsing, return as-is.
    // The config default is `parsing: { x: undefined, y: undefined }`, a
    // truthy object, so test the FIELDS, not the object: otherwise every
    // chart without parsing still walks the series and deep-clones
    // originalSeries on every single update.
    const hasGlobalParsing = !!(
      globalParsing &&
      (globalParsing.x || globalParsing.y || globalParsing.z)
    )
    /**
     * @param {Record<string, any>} s
     */
    const hasSeriesParsing = series.some(
      (s) => s.parsing && (s.parsing.x || s.parsing.y || s.parsing.z),
    )
    if (!hasGlobalParsing && !hasSeriesParsing) {
      return series
    }

    /**
     * @param {Object} serie
     * @param {number} index
     */
    const processedSeries = series.map((serie, index) => {
      if (
        !serie.data ||
        !Array.isArray(serie.data) ||
        serie.data.length === 0
      ) {
        return serie
      }

      // Resolve effective parsing config for this series
      const effectiveParsing = {
        x: serie.parsing?.x || globalParsing?.x,
        y: serie.parsing?.y || globalParsing?.y,
        z: serie.parsing?.z || globalParsing?.z,
      }

      // If no effective parsing config, return as-is
      if (!effectiveParsing.x && !effectiveParsing.y) {
        return serie
      }

      // Check if data is already in {x, y} format or 2D array format
      const firstDataPoint = serie.data[0]

      if (
        (typeof firstDataPoint === 'object' &&
          firstDataPoint !== null &&
          (Object.prototype.hasOwnProperty.call(firstDataPoint, 'x') ||
            Object.prototype.hasOwnProperty.call(firstDataPoint, 'y'))) ||
        Array.isArray(firstDataPoint)
      ) {
        return serie
      }

      // Validate that we have both x and y parsing config
      if (
        !effectiveParsing.x ||
        !effectiveParsing.y ||
        (Array.isArray(effectiveParsing.y) && effectiveParsing.y.length === 0)
      ) {
        const missing = []
        if (!effectiveParsing.x) missing.push('x')
        if (
          !effectiveParsing.y ||
          (Array.isArray(effectiveParsing.y) && effectiveParsing.y.length === 0)
        ) {
          missing.push('y')
        }
        const seriesName = serie.name ?? `series[${index}]`
        console.warn(
          `ApexCharts [${this.w.globals.chartID}]: "${seriesName}" has a parseData config but is missing the '${missing.join(
            "', '",
          )}' field specification.`,
          { parsing: serie.parsing ?? globalParsing },
        )
        return serie
      }

      // Transform raw data to {x, y} format
      /**
       * @param {Record<string, any>} item
       * @param {number} itemIndex
       */
      const transformedData = serie.data.map(
        (/** @type {any} */ item, /** @type {any} */ itemIndex) => {
          if (typeof item !== 'object' || item === null) {
            console.warn(
              `ApexCharts: Series ${index}, data point ${itemIndex} is not an object, skipping parsing`,
            )
            return item
          }

          const x = this.getNestedValue(item, effectiveParsing.x)

          let y
          let z = undefined
          if (Array.isArray(effectiveParsing.y)) {
            const yValues = effectiveParsing.y.map((fieldName) =>
              this.getNestedValue(item, fieldName),
            )

            if (this.w.config.chart.type === 'bubble') {
              if (yValues.length < 2) {
                console.warn(
                  `ApexCharts: series[${index}] bubble chart requires parseData.y to have at least 2 fields (y and z). Got: ${JSON.stringify(effectiveParsing.y)}`,
                )
              }
              // For bubble: [y-value, z-value] → y = yValues[0], z = yValues[1]
              y = yValues[0]
            } else {
              y = yValues
            }
          } else {
            y = this.getNestedValue(item, effectiveParsing.y)
          }

          // explicit z field for bubble charts
          if (effectiveParsing.z) {
            z = this.getNestedValue(item, effectiveParsing.z)
          }

          // Warn if fields don't exist
          if (x === undefined) {
            console.warn(
              `ApexCharts: Series ${index}, data point ${itemIndex} missing field '${effectiveParsing.x}'`,
            )
          }

          if (y === undefined) {
            console.warn(
              `ApexCharts: Series ${index}, data point ${itemIndex} missing field '${effectiveParsing.y}'`,
            )
          }

          const result = { x, y, z: undefined }

          if (
            this.w.config.chart.type === 'bubble' &&
            Array.isArray(effectiveParsing.y) &&
            effectiveParsing.y.length === 2
          ) {
            const zValue = this.getNestedValue(item, effectiveParsing.y[1])
            if (zValue !== undefined) {
              result.z = zValue
            }
          }

          if (z !== undefined) {
            result.z = z
          }

          return result
        },
      )

      return {
        ...serie,
        data: transformedData,
        __apexParsed: true,
      }
    })

    // Mark that data was parsed
    this.w.axisFlags.dataWasParsed = true

    if (!gl.originalSeries) {
      gl.originalSeries = Utils.clone(series)
    }

    return processedSeries
  }

  /**
   * Get nested object value using dot notation path
   * @param {Object} obj - The object to search in
   * @param {string} path - Dot notation path (e.g., 'user.profile.name')
   * @returns {*} The value at the path, or undefined if not found
   */
  getNestedValue(obj, path) {
    if (!obj || typeof obj !== 'object' || !path) {
      return undefined
    }

    // Handle simple property access (no dots)
    if (path.indexOf('.') === -1) {
      return /** @type {any} */ (obj)[path]
    }

    // Handle nested property access
    const keys = path.split('.')
    let current = obj

    for (let i = 0; i < keys.length; i++) {
      if (
        current === null ||
        current === undefined ||
        typeof current !== 'object'
      ) {
        return undefined
      }
      current = /** @type {any} */ (current)[keys[i]]
    }

    return current
  }

  /**
   * Optional pre-parse series transform. A chart type whose series carries RAW
   * observations rather than the values it draws (a histogram's sample, and in
   * time a boxPlot's or a violin's) registers a transform through
   * `apexcharts/features/stats`. Core keeps only this lookup, so a bundle that
   * never asks for a raw-sample type never pays for the statistics.
   *
   * @param {any[]} ser
   * @returns {any[]}
   */
  applySeriesTransform(ser) {
    const cnf = this.w.config
    const name = cnf.chart.requestedType || cnf.chart.type
    const transform = getSeriesTransform(name)
    if (transform) return transform(ser, this.w)
    if (!Array.isArray(ser) || RAW_SAMPLE_TYPES.indexOf(name) === -1) return ser
    // Without the feature there is no statistic to compute, and drawing the
    // raw sample as one mark per observation would be a silent, unusable mess
    // (a 1800-point sample would render 1800 bars). Warn once per chart:
    // parseData runs on every render, and a resize should not spam the console.
    if (!this._warnedMissingTransform) {
      this._warnedMissingTransform = true
      console.warn(
        `ApexCharts: chart.type '${name}' needs the stats feature. Add ` +
          `\`import 'apexcharts/features/stats'\`, or import from ` +
          `'apexcharts/${name}'.`,
      )
    }
    return ser.map((/** @type {any} */ s) => ({ ...s, data: [] }))
  }

  /**
   * Nested treemap: resolve a `children` hierarchy into the tree the renderer
   * lays out, and return the leaves as a flat series.
   *
   * Everything downstream of here addresses a treemap by `(seriesIndex,
   * dataPointIndex)` into a flat matrix, so the leaves are flattened in
   * depth-first order and `dataPointIndex` keeps meaning "the nth leaf of this
   * series". The tree itself goes on globals for the renderer.
   *
   * `cnf.series` is replaced with the flattened leaves further down parseData,
   * which is the only copy that survives — so the nested input is stashed on
   * the first parse and every later parse resolves from the stash, never from
   * the already-flattened view. That is the same contract the histogram's raw
   * observations and the downsampler's raw series use, and `_updateSeries`
   * clears all three when the user pushes new data.
   *
   * @param {any[]} ser
   * @returns {any[]}
   */
  flattenTreemapHierarchy(ser) {
    const w = this.w
    const gl = w.globals
    if (w.config.chart.type !== 'treemap' || !Array.isArray(ser)) return ser

    if (!gl.treemapRawSeries) {
      if (!isNestedTreemap(w, ser)) {
        // Flat treemap: the renderer builds the two-level tree itself. Clear
        // any tree left over from a previous (nested) data set.
        gl.treemapRoots = null
        return ser
      }
      gl.treemapRawSeries = ser.map((/** @type {any} */ s) => ({
        ...s,
        data: Array.isArray(s?.data) ? s.data.slice() : s?.data,
      }))
    }

    const { roots, leafSeries, maxDepth } = resolveTreemapTree(
      w,
      gl.treemapRawSeries,
    )
    gl.treemapRoots = roots
    gl.treemapMaxDepth = maxDepth
    return leafSeries
  }

  /**
   * Scatter strip-plot support. When `plotOptions.scatter.jitter.enabled` and a
   * series carries compact `{ x: 'Category', y: [v1, v2, ...] }` data, expand
   * each observation into its own `{ x: bandIndex, y }` point (so every dot is a
   * first-class, hoverable marker) and frame the x-axis as evenly-spaced,
   * labelled bands. The reference per-point form (numeric x + `xaxis.categories`)
   * is reframed too, without expansion. Returns `ser` unchanged for non-scatter
   * charts and for plain numeric/datetime data (continuous overplotting jitter
   * is offset at render time instead).
   *
   * @param {any[]} ser
   * @returns {any[]}
   */
  expandScatterJitterData(ser) {
    const cnf = this.w.config
    const isScatter =
      cnf.chart.type === 'scatter' || cnf.chart.type === 'bubble'
    const jt = cnf.plotOptions?.scatter?.jitter
    if (!isScatter || !jt || !jt.enabled || !Array.isArray(ser)) return ser

    const hasArrayY = ser.some(
      (/** @type {any} */ s) =>
        Array.isArray(s?.data) &&
        s.data.some(
          (/** @type {any} */ d) =>
            d && !Array.isArray(d) && Array.isArray(d.y),
        ),
    )

    if (!hasArrayY) {
      // Reference form: plain points at numeric band indices + xaxis.categories.
      // Just frame the axis; the data is already one point per observation.
      if (cnf.xaxis.type !== 'datetime') {
        if (
          Array.isArray(cnf.xaxis.categories) &&
          cnf.xaxis.categories.length
        ) {
          this._applyBandAxis(cnf.xaxis.categories.slice())
        } else if (
          Array.isArray(cnf.xaxis._scatterBandLabels) &&
          cnf.xaxis._scatterBandLabels.length
        ) {
          // Re-render after the compact data was expanded in place (zoom,
          // pan, any updateOptions): the config now carries plain points and
          // no categories, so re-frame from the labels persisted by the
          // first pass. Without this, zoom windows never get band-snapped.
          this._applyBandAxis(cnf.xaxis._scatterBandLabels)
        }
      }
      return ser
    }

    // Compact form: collect bands (in first-seen order) then expand.
    /** @type {any[]} */
    const bandLabels = []
    const bandIndex = new Map()
    ser.forEach((/** @type {any} */ s) => {
      if (!Array.isArray(s?.data)) return
      s.data.forEach((/** @type {any} */ d) => {
        if (d && Array.isArray(d.y)) {
          const key = String(d.x)
          if (!bandIndex.has(key)) {
            bandIndex.set(key, bandLabels.length)
            bandLabels.push(d.x)
          }
        }
      })
    })

    const maxPoints = jt.maxPoints || 5000

    const expanded = ser.map((/** @type {any} */ s) => {
      if (!Array.isArray(s?.data)) return s
      /** @type {any[]} */
      const out = []
      s.data.forEach((/** @type {any} */ d) => {
        if (d && Array.isArray(d.y)) {
          const bi = bandIndex.get(String(d.x))
          const ys = d.y
          const stride =
            ys.length > maxPoints ? Math.ceil(ys.length / maxPoints) : 1
          for (let k = 0; k < ys.length; k += stride) {
            const yv = Utils.parseNumber(ys[k])
            if (yv === null) continue
            out.push({ x: bi, y: yv })
          }
        } else if (d && typeof d === 'object' && !Array.isArray(d)) {
          // a plain { x, y } mixed into compact data — map known labels to bands
          const key = String(d.x)
          out.push({ x: bandIndex.has(key) ? bandIndex.get(key) : d.x, y: d.y })
        } else {
          out.push(d)
        }
      })
      return { ...s, data: out }
    })

    this._applyBandAxis(bandLabels)
    return expanded
  }

  /**
   * Frame the x-axis as N evenly-spaced bands (one per category) on a numeric
   * scale. Bands sit at integer positions 0..N-1; the range is padded by a full
   * band on each side (min -1, max N) so jittered dots never clip. Crucially the
   * range bounds and tick count are integers, so the ticks land exactly on the
   * band centers regardless of how the numeric scale "nices" the step (e.g. the
   * small-range reduction in Scales._adjustTicksForSmallRange triggered by a
   * y-axis formatter). Only fills in options the user hasn't set, so explicit
   * min/max/tickAmount/formatter still win. The exception is an interactive
   * zoom/pan window (w.interact.zoomed): its fractional bounds are snapped to
   * whole bands so tick labels stay on band centers and edge bands are never
   * half-cropped.
   *
   * @param {any[]} bandLabels
   */
  _applyBandAxis(bandLabels) {
    const xa = this.w.config.xaxis
    const n = bandLabels.length
    if (!n) return

    // Track which options we auto-assigned so a re-render with a different band
    // set refreshes them, while values the user set explicitly are left alone.
    const owned =
      /** @type {Record<string, boolean>} */ (
        (xa._scatterBand = xa._scatterBand || {})
      )

    // Persist the labels: once the compact data has been expanded into plain
    // points, re-renders can no longer derive the bands from the series (see
    // expandScatterJitterData's reference-form branch).
    xa._scatterBandLabels = bandLabels.slice()

    xa.type = 'numeric'
    if (
      this.w.interact?.zoomed &&
      typeof xa.min === 'number' &&
      typeof xa.max === 'number' &&
      isFinite(xa.min) &&
      isFinite(xa.max)
    ) {
      // A zoom or pan window arrives with fractional bounds (a rubber-band
      // selection, wheel zoom, pinch). On a band axis a fractional window is
      // doubly broken: ticks no longer land on band centers (every label
      // formats to ''), and a band sitting on the window edge shows half a
      // dot cloud. Snap the window to the touched band centers plus one full
      // band of padding per side, mirroring the initial frame: integer bounds
      // and an integer tick count survive the numeric scale's nicing, so
      // every tick lands exactly on a band center. Neighbouring bands peek in
      // half-cropped at the window edges (zoom context); the selected bands
      // themselves are never edge-cropped. The 0.49 bias keeps a band whose
      // center sits exactly on the window edge (a zoom-out clamped to the
      // data bounds 0..n-1 must keep the outermost bands).
      const clampBand = (/** @type {number} */ b) =>
        Math.max(0, Math.min(n - 1, b))
      let first = clampBand(Math.round(xa.min + 0.49))
      let last = clampBand(Math.round(xa.max - 0.49))
      if (last < first) {
        // window narrower than one band: show the band nearest its center
        first = last = clampBand(Math.round((xa.min + xa.max) / 2))
      }
      xa.min = first - 1
      xa.max = last + 1
      xa.tickAmount = last - first + 2
      owned.min = true
      owned.max = true
      owned.tick = true
    } else {
      if (xa.min == null || owned.min) {
        xa.min = -1
        owned.min = true
      }
      if (xa.max == null || owned.max) {
        xa.max = n
        owned.max = true
      }
      if (
        xa.tickAmount == null ||
        xa.tickAmount === 'dataPoints' ||
        owned.tick
      ) {
        xa.tickAmount = n + 1
        owned.tick = true
      }
    }

    xa.labels = xa.labels || {}
    const existing = /** @type {any} */ (xa.labels.formatter)
    if (typeof existing !== 'function' || existing._scatterBand) {
      const fmt = /** @type {any} */ (
        (/** @type {number} */ val) => {
          const r = Math.round(val)
          return Math.abs(val - r) < 1e-6 && bandLabels[r] !== undefined
            ? bandLabels[r]
            : ''
        }
      )
      fmt._scatterBand = true
      xa.labels.formatter = fmt
    }
  }

  // Segregate user provided data into appropriate vars
  /**
   * @param {any[]} ser
   */
  parseData(ser) {
    const w = this.w
    const cnf = w.config
    const gl = w.globals

    ser = this.parseRawDataIfNeeded(ser)

    // Raw-sample types (histogram today): turn the observations into the rows
    // the renderer draws, before anything else reads the data. A no-op for
    // every other chart type.
    ser = this.applySeriesTransform(ser)

    // Scatter "jitter": expand compact { x, y:[...] } strip-plot data into one
    // point per observation and frame the x-axis as evenly-spaced bands. A no-op
    // for non-scatter charts and for plain { x, y } data (overplotting jitter is
    // applied at render time instead — see Scatter.drawPoint).
    ser = this.expandScatterJitterData(ser)

    // Nested treemap: resolve `children` into the tree the renderer lays out and
    // hand the rest of the pipeline the leaves as a flat series. A no-op for
    // every other chart type and for a treemap without `children`.
    ser = this.flattenTreemapHierarchy(ser)

    // Stash raw series once per chart lifetime so zoom/pan can re-downsample
    // against full-resolution data. Cleared by _updateSeries when user pushes
    // new data. We hold references — parseDataAxisCharts replaces ser[i] but
    // never mutates the underlying data array.
    if (
      cnf.chart.dataReducer?.enabled &&
      gl.axisCharts &&
      !gl.dataReducerRawSeries
    ) {
      gl.dataReducerRawSeries = ser.map((s) => ({
        data: Array.isArray(s?.data) ? s.data.slice() : s?.data,
      }))
      // Also capture true x-bounds from the raw data so zoom-out/pan clamps
      // don't shrink to the current window's bounds (initialMinX/initialMaxX
      // are recomputed from the downsampled-and-sliced data each parse).
      let rawMinX = Infinity
      let rawMaxX = -Infinity
      for (const s of ser) {
        const d = s?.data
        if (!Array.isArray(d) || d.length === 0) continue
        const isXY = !Array.isArray(d[0])
        const firstX = isXY ? d[0]?.x : d[0]?.[0]
        const lastX = isXY ? d[d.length - 1]?.x : d[d.length - 1]?.[0]
        if (typeof firstX === 'number') rawMinX = Math.min(rawMinX, firstX)
        if (typeof lastX === 'number') rawMaxX = Math.max(rawMaxX, lastX)
      }
      if (rawMinX !== Infinity) {
        gl.dataReducerRawMinX = rawMinX
        gl.dataReducerRawMaxX = rawMaxX
      }
    }

    // When zoom-aware downsampling is active, parseDataAxisCharts replaces
    // ser[i] with the windowed/downsampled view. `ser` shares its array
    // reference with the caller's `options.series` (Utils.extend copies arrays
    // by reference), so writing the reduced view back into it would corrupt the
    // user's original full-resolution data: every later re-render created from
    // that same options object would start already reduced and could never
    // recover the raw points (nor be un-downsampled by disabling the reducer).
    // Detach with a shallow clone so the reduction only touches our copy. The
    // raw stash above was taken before any reduction, so it stays intact.
    if (gl.dataReducerRawSeries && cnf.chart.dataReducer?.enabled) {
      ser = ser.map((s) => ({ ...s }))
    }

    cnf.series = ser
    // parseDataAxisCharts mutates ser[i] to the windowed/downsampled view.
    // Re-cloning from cnf.series each parse would corrupt initialSeries and
    // break resetZoom (it would only restore one zoom step). Instead, snapshot
    // from the raw stash so initialSeries always represents the true input.
    if (gl.dataReducerRawSeries && cnf.chart.dataReducer?.enabled) {
      const stash = gl.dataReducerRawSeries
      gl.initialSeries = ser.map((s, i) => ({
        ...s,
        data: stash[i]?.data?.slice() ?? s.data,
      }))
    } else if (gl.histogramRawSeries) {
      // Same reason as the reducer branch above: `ser` is the binned view, so
      // snapshotting it would make resetSeries() restore counts as if they
      // were observations, and every reset would bin one level deeper.
      gl.initialSeries = gl.histogramRawSeries
    } else if (gl.treemapRawSeries) {
      // Same reason again: `ser` is the flattened leaves, so snapshotting it
      // would make resetSeries() restore a treemap that has lost its levels.
      gl.initialSeries = gl.treemapRawSeries
    } else {
      // lazy snapshot: the globals setter stores a cheap per-series shallow
      // copy; the deep clone materializes only if something reads it
      gl.initialSeries = ser
    }

    this.excludeCollapsedSeriesInYAxis()

    // If we detected string in X prop of series, we fallback to category x-axis
    this.fallbackToCategory = false

    this.resetGlobals()
    this.isMultipleY()

    if (gl.axisCharts) {
      // axisCharts includes line / area / column / scatter
      this.parseDataAxisCharts(ser)
      this.coreUtils.getLargestSeries()
    } else {
      // non-axis charts are pie / donut
      this.parseDataNonAxisCharts(ser)
    }

    // set Null values to 0 in all series when user hides/shows some series
    if (cnf.chart.stacked) {
      const series = new Series(this.w)
      this.w.seriesData.series = series.setNullSeriesToZeroValues(
        this.w.seriesData.series,
      )
    }

    this.coreUtils.getSeriesTotals()
    if (gl.axisCharts) {
      // Lazy: three additional O(n) passes over every series that most axis
      // charts never read (only stacked charts and percent formatters do).
      // The property getters compute on first access after each parse.
      Data._defineLazyResult(this.w.seriesData, 'stackedSeriesTotals', () =>
        this.coreUtils.getStackedSeriesTotals(),
      )
      Data._defineLazyResult(
        this.w.seriesData,
        'stackedSeriesTotalsByGroups',
        () => this.coreUtils.getStackedSeriesTotalsByGroups(),
      )
      Data._defineLazyResult(gl, 'seriesPercent', () => {
        this.coreUtils.getPercentSeries()
        // getPercentSeries assigns through this property's setter
        return gl.seriesPercent
      })
    } else {
      this.coreUtils.getPercentSeries()
    }

    if (
      !this.w.axisFlags.dataFormatXNumeric &&
      (!this.w.axisFlags.isXNumeric ||
        (cnf.xaxis.type === 'numeric' &&
          cnf.labels.length === 0 &&
          cnf.xaxis.categories.length === 0))
    ) {
      // x-axis labels couldn't be detected; hence try searching every option in config
      this.handleExternalLabelsData(ser)
    }

    // check for multiline xaxis
    const catLabels = this.coreUtils.getCategoryLabels(this.w.labelData.labels)
    for (let l = 0; l < catLabels.length; l++) {
      if (Array.isArray(catLabels[l])) {
        this.w.axisFlags.isMultiLineX = true
        break
      }
    }

    // Return a snapshot of all parsed state grouped by future w.* slice destinations.
    // Phase 1: callers use named writer stubs (no-ops — mutations above already wrote to gl).
    // Phase 2: writers will assign to typed slices instead of gl.*.
    return {
      // w.seriesData (future slice)
      // initialSeries/originalSeries and the stacked totals are deliberately
      // ABSENT: they already live as lazy accessors on gl / w.seriesData, so
      // a snapshot field would either force their materialization (a deep
      // clone plus three O(n) passes per parse that most charts never need)
      // or, as a delegating getter, recurse into itself when a writer copies
      // it back onto the object it delegates to.
      seriesData: {
        series: this.w.seriesData.series,
        seriesNames: this.w.seriesData.seriesNames,
        seriesX: this.w.seriesData.seriesX,
        seriesZ: this.w.seriesData.seriesZ,
        seriesColors: this.w.seriesData.seriesColors,
        seriesGoals: this.w.seriesData.seriesGoals,
        unitData: this.w.seriesData.unitData,
        noLabelsProvided: this.w.axisFlags.noLabelsProvided,
      },
      // w.rangeData (future slice)
      rangeData: {
        seriesRangeStart: this.w.rangeData.seriesRangeStart,
        seriesRangeEnd: this.w.rangeData.seriesRangeEnd,
        seriesRange: this.w.rangeData.seriesRange,
      },
      // w.candleData (future slice)
      candleData: {
        seriesCandleO: this.w.candleData.seriesCandleO,
        seriesCandleH: this.w.candleData.seriesCandleH,
        seriesCandleM: this.w.candleData.seriesCandleM,
        seriesCandleL: this.w.candleData.seriesCandleL,
        seriesCandleC: this.w.candleData.seriesCandleC,
        seriesBoxPoints: this.w.candleData.seriesBoxPoints,
      },
      // w.labelData (future slice)
      labelData: {
        labels: this.w.labelData.labels,
        categoryLabels: this.w.labelData.categoryLabels,
      },
      // w.axisFlags (future slice)
      axisFlags: {
        isXNumeric: this.w.axisFlags.isXNumeric,
        dataFormatXNumeric: this.w.axisFlags.dataFormatXNumeric,
        isDataXYZ: this.w.axisFlags.isDataXYZ,
        isRangeData: this.w.axisFlags.isRangeData,
        isRangeBar: this.w.axisFlags.isRangeBar,
        isMultiLineX: this.w.axisFlags.isMultiLineX,
        dataWasParsed: this.w.axisFlags.dataWasParsed,
        hasXaxisGroups: this.w.labelData.hasXaxisGroups,
        groups: this.w.labelData.groups,
        seriesGroups: this.w.labelData.seriesGroups,
      },
    }
  }

  /**
   * Slice a sorted-by-x series to a [xmin, xmax] window using binary search.
   *
   * Pads with one extra point on each side so lines extend cleanly to the
   * chart edges. Either bound may be null/undefined to disable that side.
   *
   * @param {any[]} data - Series data in [{x,y}] or [[x,y]] format, sorted by x.
   * @param {number|null|undefined} xmin
   * @param {number|null|undefined} xmax
   * @returns {any[]} Sliced array (new array, never the input reference).
   */
  /**
   * Define `key` on `obj` as a lazily computed property: `compute` runs on
   * first read after this call and its result is cached; assigning to the
   * property stores the assigned value directly (so code that writes the
   * field, like getPercentSeries, keeps working). Re-calling resets the cache
   * (used once per parse).
   * @param {any} obj
   * @param {string} key
   * @param {() => any} compute
   */
  static _defineLazyResult(obj, key, compute) {
    let has = false
    /** @type {any} */
    let value
    Object.defineProperty(obj, key, {
      configurable: true,
      enumerable: true,
      get() {
        if (!has) {
          has = true
          value = compute()
        }
        return value
      },
      set(v) {
        has = true
        value = v
      },
    })
  }

  /**
   * @param {any[]} data
   * @param {any} xmin
   * @param {any} xmax
   */
  static sliceByXRange(data, xmin, xmax) {
    const len = data.length
    if (len === 0) return data
    const isXY = !Array.isArray(data[0])
    const getX = isXY
      ? (/** @type {any} */ p) => p.x
      : (/** @type {any} */ p) => p[0]

    let lo = 0
    if (xmin != null) {
      let l = 0
      let r = len - 1
      while (l <= r) {
        const m = (l + r) >> 1
        if (getX(data[m]) < xmin) l = m + 1
        else r = m - 1
      }
      lo = Math.max(0, l - 1)
    }

    let hi = len
    if (xmax != null) {
      let l = 0
      let r = len - 1
      while (l <= r) {
        const m = (l + r) >> 1
        if (getX(data[m]) > xmax) r = m - 1
        else l = m + 1
      }
      hi = Math.min(len, l + 1)
    }

    return lo === 0 && hi === len ? data.slice() : data.slice(lo, hi)
  }

  /**
   * Largest-Triangle-Three-Bucket (LTTB) downsampling.
   *
   * Reduces `data` to `targetPoints` points while preserving the visual shape
   * of the series as perceived by the human eye.
   *
   * @param {any[]} data   - Raw series data in [{x,y}] or [[x,y]] format.
   * @param {number} targetPoints - Desired output length (>= 3).
   * @returns {any[]} Downsampled array in the same format as the input.
   */
  static lttbDownsample(data, targetPoints) {
    const len = data.length
    if (targetPoints >= len || targetPoints < 3) return data

    // Normalise each element to {x, y} for the algorithm, remembering format.
    /**
     * @param {number} p
     */
    const isXY = !Array.isArray(data[0])
    const getX = isXY
      ? (/** @type {any} */ p) => p.x
      : (/** @type {any} */ p) => p[0]
    const getY = isXY
      ? (/** @type {any} */ p) => p.y
      : (/** @type {any} */ p) => p[1]

    const sampled = []
    // Always include the first point.
    sampled.push(data[0])

    const bucketSize = (len - 2) / (targetPoints - 2)
    let a = 0 // index of the last selected point

    for (let i = 0; i < targetPoints - 2; i++) {
      // Calculate point average for next bucket (used as the "future" anchor).
      const avgRangeStart = Math.floor((i + 1) * bucketSize) + 1
      const avgRangeEnd = Math.min(Math.floor((i + 2) * bucketSize) + 1, len)

      let avgX = 0
      let avgY = 0
      const avgRangeLen = avgRangeEnd - avgRangeStart
      for (let j = avgRangeStart; j < avgRangeEnd; j++) {
        avgX += getX(data[j])
        avgY += getY(data[j])
      }
      avgX /= avgRangeLen
      avgY /= avgRangeLen

      // Pick the point in the current bucket with the largest triangle area.
      const rangeStart = Math.floor(i * bucketSize) + 1
      const rangeEnd = Math.min(Math.floor((i + 1) * bucketSize) + 1, len)

      const pointAX = getX(data[a])
      const pointAY = getY(data[a])

      let maxArea = -1
      let maxAreaIdx = rangeStart

      for (let j = rangeStart; j < rangeEnd; j++) {
        const area =
          Math.abs(
            (pointAX - avgX) * (getY(data[j]) - pointAY) -
              (pointAX - getX(data[j])) * (avgY - pointAY),
          ) * 0.5
        if (area > maxArea) {
          maxArea = area
          maxAreaIdx = j
        }
      }

      sampled.push(data[maxAreaIdx])
      a = maxAreaIdx
    }

    // Always include the last point.
    sampled.push(data[len - 1])
    return sampled
  }

  /**
   * OHLC-aware bucket aggregation for candlestick / OHLC series.
   *
   * Each point's y is a 4-tuple `[open, high, low, close]`. LTTB is unusable
   * here — it treats y as a scalar, so the triangle-area math degenerates and
   * silently discards the high/low extremes that *define* a candle. Instead we
   * split the series into `targetPoints` contiguous buckets and roll each up
   * into a single candle: open = first bucket open, close = last bucket close,
   * high = max of highs, low = min of lows. The x is the first point's x in the
   * bucket. Output keeps the input's format ([{x,y}] or [[x,y]]).
   *
   * @param {any[]} data         - Raw OHLC series in [{x,y:[o,h,l,c]}] or [[x,[o,h,l,c]]] format.
   * @param {number} targetPoints - Desired output length (>= 1).
   * @returns {any[]} Aggregated array in the same format as the input.
   */
  static ohlcAggregate(data, targetPoints) {
    const len = data.length
    if (targetPoints >= len || targetPoints < 1) return data

    const isXY = !Array.isArray(data[0])
    const getX = isXY
      ? (/** @type {any} */ p) => p.x
      : (/** @type {any} */ p) => p[0]
    const getY = isXY
      ? (/** @type {any} */ p) => p.y
      : (/** @type {any} */ p) => p[1]
    const make = isXY
      ? (/** @type {any} */ x, /** @type {any} */ y) => ({ x, y })
      : (/** @type {any} */ x, /** @type {any} */ y) => [x, y]

    const out = []
    const bucketSize = len / targetPoints

    for (let i = 0; i < targetPoints; i++) {
      const start = Math.floor(i * bucketSize)
      // Last bucket absorbs any remainder so the final close is never dropped.
      const end =
        i === targetPoints - 1 ? len : Math.floor((i + 1) * bucketSize)
      if (end <= start) continue

      const firstY = getY(data[start])
      const open = firstY[0]
      let high = firstY[1]
      let low = firstY[2]
      let close = firstY[3]

      for (let j = start + 1; j < end; j++) {
        const y = getY(data[j])
        if (y[1] > high) high = y[1]
        if (y[2] < low) low = y[2]
        close = y[3]
      }

      out.push(make(getX(data[start]), [open, high, low, close]))
    }

    return out
  }

  /**
   * Bucket-aggregate 2-tuple range data (`y: [low, high]`, rangeArea/rangeBar)
   * into `targetPoints` points, the range analog of {@link ohlcAggregate}. Each
   * bucket emits `[min low, max high]` so the band's vertical extent is never
   * understated by downsampling (LTTB, built for scalar y, would drop these
   * extremes). Order-agnostic: the min/max scan both tuple slots, so it is
   * correct whether a point is stored `[low, high]` or `[high, low]`.
   *
   * Null bounds (e.g. an indicator's warm-up period) are ignored, not treated
   * as 0 — `Math.min(null, x)` would coerce to 0 and pin the band to the
   * baseline. A bucket with no finite bounds emits `[null, null]` so it renders
   * as a gap, matching the un-downsampled series.
   * @param {any[]} data
   * @param {number} targetPoints
   * @returns {any[]}
   */
  static rangeAggregate(data, targetPoints) {
    const len = data.length
    if (targetPoints >= len || targetPoints < 1) return data

    const isXY = !Array.isArray(data[0])
    const getX = isXY
      ? (/** @type {any} */ p) => p.x
      : (/** @type {any} */ p) => p[0]
    const getY = isXY
      ? (/** @type {any} */ p) => p.y
      : (/** @type {any} */ p) => p[1]
    const make = isXY
      ? (/** @type {any} */ x, /** @type {any} */ y) => ({ x, y })
      : (/** @type {any} */ x, /** @type {any} */ y) => [x, y]

    const out = []
    const bucketSize = len / targetPoints

    for (let i = 0; i < targetPoints; i++) {
      const start = Math.floor(i * bucketSize)
      const end =
        i === targetPoints - 1 ? len : Math.floor((i + 1) * bucketSize)
      if (end <= start) continue

      let low = Infinity
      let high = -Infinity
      for (let j = start; j < end; j++) {
        const y = getY(data[j])
        if (y == null) continue
        // Scan both slots independently so a single finite bound still counts
        // and tuple order does not matter.
        for (let k = 0; k < 2; k++) {
          const v = y[k]
          if (v == null || !isFinite(v)) continue
          if (v < low) low = v
          if (v > high) high = v
        }
      }

      out.push(
        make(getX(data[start]), low === Infinity ? [null, null] : [low, high]),
      )
    }

    return out
  }

  excludeCollapsedSeriesInYAxis() {
    const w = this.w
    // Post revision 3.46.0 there is no longer a strict one-to-one
    // correspondence between series and Y axes.
    // An axis can be ignored only while all series referenced by it
    // are collapsed.
    /** @type {any[]} */
    const yAxisIndexes = []
    /**
     * @param {any[]} yAxisArr
     * @param {number} yi
     */
    w.globals.seriesYAxisMap.forEach((yAxisArr, yi) => {
      let collapsedCount = 0
      /**
       * @param {number} seriesIndex
       */
      yAxisArr.forEach((seriesIndex) => {
        if (w.globals.collapsedSeriesIndices.indexOf(seriesIndex) !== -1) {
          collapsedCount++
        }
      })
      // It's possible to have a yaxis that doesn't reference any series yet,
      // eg, because there are no series' yet, so don't list it as ignored
      // prematurely.
      if (collapsedCount > 0 && collapsedCount == yAxisArr.length) {
        yAxisIndexes.push(yi)
      }
    })
    w.globals.ignoreYAxisIndexes = yAxisIndexes.map((x) => x)
  }
}