UNPKG

apexcharts

Version:

A JavaScript Chart Library

1,581 lines (1,419 loc) 73.9 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' 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 = [] 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 '01/01/2017' or a date string (a JS date object is not supported) 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') { this.twoDSeriesX.push(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, } // CAUTION: mutating config object by adding a new property // TODO: As this is specifically for timeline rangebar charts, update the docs mentioning the series only supports xy format ser[i].data[j].rangeName = 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') { const isFlat = (isBoxPlot && data[0].length === 6) || (!isBoxPlot && data[0].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.", ) return } // 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) ) { console.warn( `ApexCharts: Series ${index} has parsing config but missing x or y field specification`, ) 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 } /** * 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((/** @