apexcharts
Version:
A JavaScript Chart Library
793 lines (726 loc) • 24.2 kB
JavaScript
// @ts-check
import Utils from '../utils/Utils'
import DateTime from '../utils/DateTime'
import Scales from './Scales'
/**
* Range is used to generates values between min and max.
*
* @module Range
**/
class Range {
/**
* @param {import('../types/internal').ChartStateW} w
*/
constructor(w) {
this.w = w
this.scales = new Scales(this.w)
}
init() {
this.setYRange()
this.setXRange()
this.setZRange()
}
/**
* @param {number} startingSeriesIndex
* @param {number | null} [endingSeriesIndex]
*/
getMinYMaxY(
startingSeriesIndex,
lowestY = Number.MAX_VALUE,
highestY = -Number.MAX_VALUE,
endingSeriesIndex = null,
) {
const cnf = this.w.config
const gl = this.w.globals
let maxY = -Number.MAX_VALUE
let minY = Number.MIN_VALUE
if (endingSeriesIndex === null) {
endingSeriesIndex = startingSeriesIndex + 1
}
const series = this.w.seriesData.series
let seriesMin = series
let seriesMax = series
if (cnf.chart.type === 'candlestick') {
seriesMin = this.w.candleData.seriesCandleL
seriesMax = this.w.candleData.seriesCandleH
} else if (cnf.chart.type === 'boxPlot') {
seriesMin = this.w.candleData.seriesCandleO
seriesMax = this.w.candleData.seriesCandleC
} else if (this.w.axisFlags.isRangeData) {
seriesMin = this.w.rangeData.seriesRangeStart
seriesMax = this.w.rangeData.seriesRangeEnd
}
let autoScaleYaxis = false
if (this.w.seriesData.seriesX.length >= endingSeriesIndex) {
// Eventually brushSource will be set if the current chart is a target.
// That is, after the appropriate event causes us to update.
const brush = /** @type {any} */ (gl).brushSource?.w.config.chart.brush
if (
(cnf.chart.zoom.enabled && cnf.chart.zoom.autoScaleYaxis) ||
(brush?.enabled && brush?.autoScaleYaxis)
) {
autoScaleYaxis = true
}
}
for (let i = startingSeriesIndex; i < endingSeriesIndex; i++) {
gl.dataPoints = Math.max(gl.dataPoints, series[i].length)
const seriesType = /** @type {Record<string,any>} */ (cnf.series[i]).type
if (this.w.labelData.categoryLabels.length) {
/**
* @param {string} label
*/
gl.dataPoints = this.w.labelData.categoryLabels.filter(
(label) => typeof label !== 'undefined',
).length
}
if (
this.w.labelData.labels.length &&
cnf.xaxis.type !== 'datetime' &&
/**
* @param {number} a
* @param {number[]} c
*/
this.w.seriesData.series.reduce((a, c) => a + c.length, 0) !== 0
) {
// the condition cnf.xaxis.type !== 'datetime' fixes #3897 and #3905
gl.dataPoints = Math.max(gl.dataPoints, this.w.labelData.labels.length)
}
let firstXIndex = 0
let lastXIndex = series[i].length - 1
if (autoScaleYaxis) {
// Scale the Y axis to the min..max within the possibly zoomed X axis domain.
if (cnf.xaxis.min) {
for (
;
firstXIndex < lastXIndex &&
this.w.seriesData.seriesX[i][firstXIndex] < cnf.xaxis.min;
firstXIndex++
) {
// Intentionally empty - just incrementing firstXIndex
}
}
if (cnf.xaxis.max) {
for (
;
lastXIndex > firstXIndex &&
this.w.seriesData.seriesX[i][lastXIndex] > cnf.xaxis.max;
lastXIndex--
) {
// Intentionally empty - just decrementing lastXIndex
}
}
}
for (
let j = firstXIndex;
j <= lastXIndex && j < this.w.seriesData.series[i].length;
j++
) {
let val = series[i][j]
if (val !== null && Utils.isNumber(val)) {
if (typeof seriesMax[i]?.[j] !== 'undefined') {
maxY = Math.max(maxY, seriesMax[i][j])
lowestY = Math.min(lowestY, seriesMax[i][j])
}
if (typeof seriesMin[i]?.[j] !== 'undefined') {
lowestY = Math.min(lowestY, seriesMin[i][j])
highestY = Math.max(highestY, seriesMin[i][j])
}
// These series arrays are dual purpose:
// Array : CandleO, CandleH, CandleM, CandleL, CandleC
// Candlestick: O H L C
// Boxplot : Min Q1 Median Q3 Max
switch (seriesType) {
case 'candlestick':
{
if (
typeof this.w.candleData.seriesCandleC[i][j] !== 'undefined'
) {
maxY = Math.max(maxY, this.w.candleData.seriesCandleH[i][j])
lowestY = Math.min(
lowestY,
this.w.candleData.seriesCandleL[i][j],
)
}
}
break
case 'boxPlot':
{
if (
typeof this.w.candleData.seriesCandleC[i][j] !== 'undefined'
) {
maxY = Math.max(maxY, this.w.candleData.seriesCandleC[i][j])
lowestY = Math.min(
lowestY,
this.w.candleData.seriesCandleO[i][j],
)
}
}
break
}
// there is a combo chart and the specified series in not either
// candlestick, boxplot, or rangeArea/rangeBar; find the max there.
if (
seriesType &&
seriesType !== 'candlestick' &&
seriesType !== 'boxPlot' &&
seriesType !== 'rangeArea' &&
seriesType !== 'rangeBar'
) {
maxY = Math.max(maxY, this.w.seriesData.series[i][j])
lowestY = Math.min(lowestY, this.w.seriesData.series[i][j])
}
if (
this.w.seriesData.seriesGoals[i] &&
this.w.seriesData.seriesGoals[i][j] &&
Array.isArray(this.w.seriesData.seriesGoals[i][j])
) {
this.w.seriesData.seriesGoals[i][j].forEach(
(/** @type {any} */ g) => {
maxY = Math.max(maxY, g.value)
lowestY = Math.min(lowestY, g.value)
},
)
}
highestY = maxY
val = Utils.noExponents(val)
if (Utils.isFloat(val)) {
gl.yValueDecimal = Math.max(
gl.yValueDecimal,
val.toString().split('.')[1].length,
)
}
if (minY > seriesMin[i]?.[j] && seriesMin[i]?.[j] < 0) {
minY = seriesMin[i][j]
}
} else {
gl.hasNullValues = true
}
}
if (seriesType === 'bar' || seriesType === 'column') {
if (minY < 0 && maxY < 0) {
// all negative values in a bar series, hence make the max to 0
maxY = 0
highestY = Math.max(highestY, 0)
}
if (minY === Number.MIN_VALUE) {
minY = 0
lowestY = Math.min(lowestY, 0)
}
}
}
if (
cnf.chart.type === 'rangeBar' &&
this.w.rangeData.seriesRangeStart.length &&
gl.isBarHorizontal
) {
minY = lowestY
}
if (cnf.chart.type === 'bar') {
if (minY < 0 && maxY < 0) {
// all negative values in a bar chart, hence make the max to 0
maxY = 0
}
if (minY === Number.MIN_VALUE) {
minY = 0
}
}
return {
minY,
maxY,
lowestY,
highestY,
}
}
setYRange() {
const gl = this.w.globals
const cnf = this.w.config
gl.maxY = -Number.MAX_VALUE
gl.minY = Number.MIN_VALUE
let lowestYInAllSeries = Number.MAX_VALUE
let minYMaxY
if (gl.isMultipleYAxis) {
// we need to get minY and maxY for multiple y axis
lowestYInAllSeries = Number.MAX_VALUE
for (let i = 0; i < this.w.seriesData.series.length; i++) {
minYMaxY = this.getMinYMaxY(i)
gl.minYArr[i] = minYMaxY.lowestY
gl.maxYArr[i] = minYMaxY.highestY
lowestYInAllSeries = Math.min(lowestYInAllSeries, minYMaxY.lowestY)
}
}
// and then, get the minY and maxY from all series
minYMaxY = this.getMinYMaxY(
0,
lowestYInAllSeries,
undefined,
this.w.seriesData.series.length,
)
if (cnf.chart.type === 'bar') {
gl.minY = minYMaxY.minY
gl.maxY = minYMaxY.maxY
} else {
gl.minY = minYMaxY.lowestY
gl.maxY = minYMaxY.highestY
}
lowestYInAllSeries = minYMaxY.lowestY
if (cnf.chart.stacked) {
this._setStackedMinMax()
}
// if the numbers are too big, reduce the range
// for eg, if number is between 100000-110000, putting 0 as the lowest
// value is not so good idea. So change the gl.minY for
// line/area/scatter/candlesticks/boxPlot/vertical rangebar
if (
cnf.chart.type === 'line' ||
cnf.chart.type === 'area' ||
cnf.chart.type === 'scatter' ||
cnf.chart.type === 'candlestick' ||
cnf.chart.type === 'boxPlot' ||
(cnf.chart.type === 'rangeBar' && !gl.isBarHorizontal)
) {
if (
gl.minY === Number.MIN_VALUE &&
lowestYInAllSeries !== -Number.MAX_VALUE &&
lowestYInAllSeries !== gl.maxY // single value possibility
) {
gl.minY = lowestYInAllSeries
}
} else {
gl.minY =
gl.minY !== Number.MIN_VALUE
? Math.min(minYMaxY.minY, gl.minY)
: minYMaxY.minY
}
/**
* @param {ApexYAxis} yaxe
* @param {number} index
*/
cnf.yaxis.forEach((yaxe, index) => {
// override all min/max values by user defined values (y axis)
if (yaxe.max !== undefined) {
if (typeof yaxe.max === 'number') {
gl.maxYArr[index] = yaxe.max
} else if (typeof yaxe.max === 'function') {
// fixes apexcharts.js/issues/2098
gl.maxYArr[index] = yaxe.max(
gl.isMultipleYAxis ? gl.maxYArr[index] : gl.maxY,
)
}
// gl.maxY is for single y-axis chart, it will be ignored in multi-yaxis
gl.maxY = gl.maxYArr[index]
}
if (yaxe.min !== undefined) {
if (typeof yaxe.min === 'number') {
gl.minYArr[index] = yaxe.min
} else if (typeof yaxe.min === 'function') {
// fixes apexcharts.js/issues/2098
gl.minYArr[index] = yaxe.min(
gl.isMultipleYAxis
? gl.minYArr[index] === Number.MIN_VALUE
? 0
: gl.minYArr[index]
: gl.minY,
)
}
// gl.minY is for single y-axis chart, it will be ignored in multi-yaxis
gl.minY = gl.minYArr[index]
}
})
// for horizontal bar charts, we need to check xaxis min/max as user may have specified there
if (gl.isBarHorizontal) {
const minmax = ['min', 'max']
minmax.forEach((m) => {
if (cnf.xaxis[m] !== undefined && typeof cnf.xaxis[m] === 'number') {
m === 'min' ? (gl.minY = cnf.xaxis[m]) : (gl.maxY = cnf.xaxis[m])
}
})
}
if (gl.isMultipleYAxis) {
this.scales.scaleMultipleYAxes()
gl.minY = lowestYInAllSeries
} else {
this.scales.setYScaleForIndex(0, gl.minY, gl.maxY)
gl.minY = gl.yAxisScale[0].niceMin
gl.maxY = gl.yAxisScale[0].niceMax
gl.minYArr[0] = gl.minY
gl.maxYArr[0] = gl.maxY
}
gl.barGroups = []
gl.lineGroups = []
gl.areaGroups = []
/**
* @param {Record<string, any>} s
*/
cnf.series.forEach((s) => {
const _s = /** @type {any} */ (s)
const type = _s.type || cnf.chart.type
switch (type) {
case 'bar':
case 'column':
gl.barGroups.push(_s.group)
break
case 'line':
gl.lineGroups.push(_s.group)
break
case 'area':
gl.areaGroups.push(_s.group)
break
}
})
// Uniquify the group names in each stackable chart type.
/**
* @param {string} v
* @param {number} i
* @param {string[]} a
*/
gl.barGroups = gl.barGroups.filter((v, i, a) => a.indexOf(v) === i)
/**
* @param {string} v
* @param {number} i
* @param {string[]} a
*/
gl.lineGroups = gl.lineGroups.filter((v, i, a) => a.indexOf(v) === i)
/**
* @param {string} v
* @param {number} i
* @param {string[]} a
*/
gl.areaGroups = gl.areaGroups.filter((v, i, a) => a.indexOf(v) === i)
return {
minY: gl.minY,
maxY: gl.maxY,
minYArr: gl.minYArr,
maxYArr: gl.maxYArr,
yAxisScale: gl.yAxisScale,
}
}
setXRange() {
const gl = this.w.globals
const cnf = this.w.config
const isXNumeric =
cnf.xaxis.type === 'numeric' ||
cnf.xaxis.type === 'datetime' ||
(cnf.xaxis.type === 'category' && !this.w.axisFlags.noLabelsProvided) ||
this.w.axisFlags.noLabelsProvided ||
this.w.axisFlags.isXNumeric
const getInitialMinXMaxX = () => {
for (let i = 0; i < this.w.seriesData.series.length; i++) {
if (this.w.labelData.labels[i]) {
for (let j = 0; j < this.w.labelData.labels[i].length; j++) {
if (
this.w.labelData.labels[i][j] !== null &&
Utils.isNumber(this.w.labelData.labels[i][j])
) {
gl.maxX = Math.max(
gl.maxX,
/** @type {number} */ (
/** @type {any} */ (this.w.labelData.labels[i][j])
),
)
gl.initialMaxX = Math.max(
gl.maxX,
/** @type {number} */ (
/** @type {any} */ (this.w.labelData.labels[i][j])
),
)
gl.minX = Math.min(
gl.minX,
/** @type {number} */ (
/** @type {any} */ (this.w.labelData.labels[i][j])
),
)
gl.initialMinX = Math.min(
gl.minX,
/** @type {number} */ (
/** @type {any} */ (this.w.labelData.labels[i][j])
),
)
}
}
}
}
}
// minX maxX starts here
if (this.w.axisFlags.isXNumeric) {
getInitialMinXMaxX()
}
if (this.w.axisFlags.noLabelsProvided) {
if (cnf.xaxis.categories.length === 0) {
gl.maxX = /** @type {any} */ (
this.w.labelData.labels[this.w.labelData.labels.length - 1]
)
gl.initialMaxX = /** @type {any} */ (
this.w.labelData.labels[this.w.labelData.labels.length - 1]
)
gl.minX = 1
gl.initialMinX = 1
}
}
if (
this.w.axisFlags.isXNumeric ||
this.w.axisFlags.noLabelsProvided ||
this.w.axisFlags.dataFormatXNumeric
) {
let ticks = 10
if (cnf.xaxis.tickAmount === undefined) {
ticks = Math.round(gl.svgWidth / 150)
// no labels provided and total number of dataPoints is less than 30
if (cnf.xaxis.type === 'numeric' && gl.dataPoints < 30) {
ticks = gl.dataPoints - 1
}
// this check is for when ticks exceeds total datapoints and that would result in duplicate labels
if (ticks > gl.dataPoints && gl.dataPoints !== 0) {
ticks = gl.dataPoints - 1
}
} else if (cnf.xaxis.tickAmount === 'dataPoints') {
if (this.w.seriesData.series.length > 1) {
ticks = this.w.seriesData.series[gl.maxValsInArrayIndex].length - 1
}
if (this.w.axisFlags.isXNumeric) {
const diff = Math.round(gl.maxX - gl.minX)
if (diff < 30) {
// When numeric range is small, show a tick for every integer
ticks = diff
}
}
} else {
ticks = cnf.xaxis.tickAmount
}
gl.xTickAmount = ticks
// override all min/max values by user defined values (x axis)
if (cnf.xaxis.max !== undefined && typeof cnf.xaxis.max === 'number') {
gl.maxX = cnf.xaxis.max
}
if (cnf.xaxis.min !== undefined && typeof cnf.xaxis.min === 'number') {
gl.minX = cnf.xaxis.min
}
// if range is provided, adjust the new minX
if (cnf.xaxis.range !== undefined) {
gl.minX = gl.maxX - cnf.xaxis.range
}
if (gl.minX !== Number.MAX_VALUE && gl.maxX !== -Number.MAX_VALUE) {
if (
cnf.xaxis.convertedCatToNumeric &&
!this.w.axisFlags.dataFormatXNumeric
) {
const catScale = []
for (let i = gl.minX - 1; i < gl.maxX; i++) {
catScale.push(i + 1)
}
gl.xAxisScale = {
result: catScale,
niceMin: catScale[0],
niceMax: catScale[catScale.length - 1],
}
} else {
gl.xAxisScale = this.scales.setXScale(gl.minX, gl.maxX)
}
} else {
gl.xAxisScale = this.scales.linearScale(
0,
ticks,
ticks,
0,
cnf.xaxis.stepSize,
)
if (
this.w.axisFlags.noLabelsProvided &&
this.w.labelData.labels.length > 0
) {
gl.xAxisScale = this.scales.linearScale(
1,
this.w.labelData.labels.length,
ticks - 1,
0,
cnf.xaxis.stepSize,
)
// this is the only place seriesX is again mutated
this.w.seriesData.seriesX = /** @type {any} */ (
this.w.labelData.labels.slice()
)
}
}
// we will still store these labels as the count for this will be different (to draw grid and labels placement)
if (isXNumeric) {
this.w.labelData.labels = /** @type {any} */ (
gl.xAxisScale.result.slice()
)
}
}
if (gl.isBarHorizontal && this.w.labelData.labels.length) {
gl.xTickAmount = this.w.labelData.labels.length
}
// single dataPoint
this._handleSingleDataPoint()
// minimum x difference to calculate bar width in numeric bars
this._getMinXDiff()
return {
minX: gl.minX,
maxX: gl.maxX,
}
}
setZRange() {
// minZ, maxZ starts here
const gl = this.w.globals
if (!this.w.axisFlags.isDataXYZ) return
for (let i = 0; i < this.w.seriesData.series.length; i++) {
if (typeof this.w.seriesData.seriesZ[i] !== 'undefined') {
for (let j = 0; j < this.w.seriesData.seriesZ[i].length; j++) {
if (
this.w.seriesData.seriesZ[i][j] !== null &&
Utils.isNumber(this.w.seriesData.seriesZ[i][j])
) {
gl.maxZ = Math.max(gl.maxZ, this.w.seriesData.seriesZ[i][j])
gl.minZ = Math.min(gl.minZ, this.w.seriesData.seriesZ[i][j])
}
}
}
}
}
_handleSingleDataPoint() {
const gl = this.w.globals
const cnf = this.w.config
if (gl.minX === gl.maxX) {
const datetimeObj = new DateTime(this.w)
if (cnf.xaxis.type === 'datetime') {
const newMinX = datetimeObj.getDate(gl.minX)
if (cnf.xaxis.labels.datetimeUTC) {
newMinX.setUTCDate(newMinX.getUTCDate() - 2)
} else {
newMinX.setDate(newMinX.getDate() - 2)
}
gl.minX = new Date(newMinX).getTime()
const newMaxX = datetimeObj.getDate(gl.maxX)
if (cnf.xaxis.labels.datetimeUTC) {
newMaxX.setUTCDate(newMaxX.getUTCDate() + 2)
} else {
newMaxX.setDate(newMaxX.getDate() + 2)
}
gl.maxX = new Date(newMaxX).getTime()
} else if (
cnf.xaxis.type === 'numeric' ||
(cnf.xaxis.type === 'category' && !this.w.axisFlags.noLabelsProvided)
) {
gl.minX = gl.minX - 2
gl.initialMinX = gl.minX
gl.maxX = gl.maxX + 2
gl.initialMaxX = gl.maxX
}
}
}
_getMinXDiff() {
const gl = this.w.globals
if (this.w.axisFlags.isXNumeric) {
// get the least x diff if numeric x axis is present
/**
* @param {number} sX
*/
this.w.seriesData.seriesX.forEach((sX) => {
if (sX.length) {
if (sX.length === 1) {
// a small hack to prevent overlapping multiple bars when there is just 1 datapoint in bar series.
// fix #811
sX.push(
this.w.seriesData.seriesX[gl.maxValsInArrayIndex][
this.w.seriesData.seriesX[gl.maxValsInArrayIndex].length - 1
],
)
}
// fix #983 (clone the array to avoid side effects)
const seriesX = sX.slice()
/**
* @param {number} a
* @param {number} b
*/
seriesX.sort((a, b) => a - b)
/**
* @param {Record<string, any>} s
* @param {number} j
*/
seriesX.forEach((s, j) => {
if (j > 0) {
const xDiff = s - seriesX[j - 1]
if (xDiff > 0) {
gl.minXDiff = Math.min(xDiff, gl.minXDiff)
}
}
})
if (gl.dataPoints === 1 || gl.minXDiff === Number.MAX_VALUE) {
// fixes apexcharts.js #1221
gl.minXDiff = 0.5
}
}
})
}
}
_setStackedMinMax() {
const gl = this.w.globals
// for stacked charts, we calculate each series's parallel values.
// i.e, series[0][j] + series[1][j] .... [series[i.length][j]]
// and get the max out of it
if (!this.w.seriesData.series.length) return
let seriesGroups = this.w.labelData.seriesGroups
if (!seriesGroups.length) {
/**
* @param {string} name
*/
seriesGroups = [this.w.seriesData.seriesNames.map((name) => name)]
}
/** @type {Record<string, number[]>} */
const stackedPoss = {}
/** @type {Record<string, number[]>} */
const stackedNegs = {}
seriesGroups.forEach((/** @type {any} */ group) => {
stackedPoss[group] = []
stackedNegs[group] = []
const indicesOfSeriesInGroup = this.w.config.series
.map((/** @type {any} */ serie, /** @type {any} */ si) =>
group.indexOf(this.w.seriesData.seriesNames[si]) > -1 ? si : null,
)
.filter((/** @type {any} */ f) => f !== null)
indicesOfSeriesInGroup.forEach((/** @type {number} */ i) => {
for (
let j = 0;
j < this.w.seriesData.series[gl.maxValsInArrayIndex].length;
j++
) {
if (typeof stackedPoss[group][j] === 'undefined') {
stackedPoss[group][j] = 0
stackedNegs[group][j] = 0
}
const stackSeries =
(this.w.config.chart.stacked && !gl.comboCharts) ||
(this.w.config.chart.stacked &&
gl.comboCharts &&
(!this.w.config.chart.stackOnlyBar ||
/** @type {Record<string,any>} */ (this.w.config.series?.[i])
?.type === 'bar' ||
/** @type {Record<string,any>} */ (this.w.config.series?.[i])
?.type === 'column'))
if (stackSeries) {
if (
this.w.seriesData.series[i][j] !== null &&
Utils.isNumber(this.w.seriesData.series[i][j])
) {
this.w.seriesData.series[i][j] > 0
? (stackedPoss[group][j] +=
parseFloat(String(this.w.seriesData.series[i][j])) + 0.0001)
: (stackedNegs[group][j] += parseFloat(
String(this.w.seriesData.series[i][j]),
))
}
}
}
})
})
Object.entries(stackedPoss).forEach(([key]) => {
stackedPoss[key].forEach(
(/** @type {any} */ _, /** @type {number} */ stgi) => {
gl.maxY = Math.max(gl.maxY, stackedPoss[key][stgi])
gl.minY = Math.min(gl.minY, stackedNegs[key][stgi])
},
)
})
}
}
export default Range