apexcharts
Version:
A JavaScript Chart Library
1,389 lines (1,226 loc) • 41.9 kB
JavaScript
// @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'
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)
}
/**
* @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'
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
}
/**
* @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
return ohlc
}
/**
* @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 = []
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])
}
}
}
return {
o: serO,
h: serH,
m: serM,
l: serL,
c: serC,
}
}
/**
* @param {any[]} ser
*/
parseDataAxisCharts(ser) {
const cnf = this.w.config
const gl = this.w.globals
const dt = new DateTime(this.w)
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
}
// 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.
const dr = cnf.chart.dataReducer
if (
dr?.enabled &&
this.isMultiFormat() &&
ser[i].data.length > (dr.threshold ?? 500)
) {
ser[i] = {
...ser[i],
data: Data.lttbDownsample(ser[i].data, dr.targetPoints ?? 250),
}
}
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)
}
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)
}
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
// 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),
)
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
}
/**
* 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
/**
* @param {Record<string, any>} s
*/
if (!globalParsing && !series.some((s) => s.parsing)) {
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
}
// 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)
cnf.series = ser
gl.initialSeries = Utils.clone(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) {
this.w.seriesData.stackedSeriesTotals =
this.coreUtils.getStackedSeriesTotals()
this.w.seriesData.stackedSeriesTotalsByGroups =
this.coreUtils.getStackedSeriesTotalsByGroups()
}
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)
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,
initialSeries: gl.initialSeries,
originalSeries: gl.originalSeries,
stackedSeriesTotals: this.w.seriesData.stackedSeriesTotals,
stackedSeriesTotalsByGroups:
this.w.seriesData.stackedSeriesTotalsByGroups,
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,
},
// 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,
},
}
}
/**
* 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
}
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)
}
}