UNPKG

apexcharts

Version:

A JavaScript Chart Library

1,597 lines (1,456 loc) 50.6 kB
// @ts-check import CoreUtils from '../modules/CoreUtils' import Graphics from '../modules/Graphics' import Fill from '../modules/Fill' import DataLabels from '../modules/DataLabels' import Markers from '../modules/Markers' import Scatter from './Scatter' import Series from '../modules/Series' import Utils from '../utils/Utils' import Helpers from './common/line/Helpers' import { hash01 } from './common/Jitter' import { svgPath, spline } from '../libs/monotone-cubic' import { seriesEmitter } from '../renderers/Renderer' import { detectStreamScroll, projectPathToPrevFrame, } from '../modules/animations/StreamScroll' import { reconcileSeriesPaths, seriesJoin, tweenSeriesMarkers, } from '../modules/animations/LengthTransition' /** * ApexCharts Line Class responsible for drawing Line / Area / RangeArea Charts. * This class is also responsible for generating values for Bubble/Scatter charts, so need to rename it to Axis Charts to avoid confusions * @module Line **/ class Line { /** * @param {import('../types/internal').ChartStateW} w * @param {import('../types/internal').ChartContext} ctx * @param {import('../types/internal').XYRatios} xyRatios * @param {boolean} isPointsChart */ constructor(w, ctx, xyRatios, isPointsChart) { this.ctx = ctx this.w = w this.xyRatios = xyRatios /** @type {number} */ this.xRatio = 0 /** @type {number[]} */ this.yRatio = [] /** @type {number} */ this.zRatio = 0 /** @type {number[]} */ this.baseLineY = [] this.pointsChart = !( this.w.config.chart.type !== 'bubble' && this.w.config.chart.type !== 'scatter' ) || isPointsChart this.scatter = new Scatter(this.w, this.ctx) this.noNegatives = this.w.globals.minX === Number.MAX_VALUE this.lineHelpers = new Helpers(this) this.markers = new Markers(this.w, this.ctx) /** @type {any} */ this.prevSeriesY = [] this.categoryAxisCorrection = 0 this.yaxisIndex = 0 /** @type {number} */ this.xDivision = 0 /** @type {number} */ this.zeroY = 0 /** @type {number} */ this.areaBottomY = 0 /** @type {number} */ this.strokeWidth = 0 /** @type {boolean} */ this.isReversed = false /** @type {boolean} */ this.appendPathFrom = false /** @type {any} */ this.elSeries = null /** @type {any} */ this.elPointsMain = null /** @type {any} */ this.elDataLabelsWrap = null /** @type {any} last marker wrap appended to elPointsMain (identity guard) */ this._elLastPointsWrap = null } /** * @param {any[]} series * @param {string} ctype * @param {number} seriesIndex * @param {any} seriesRangeEnd */ draw(series, ctype, seriesIndex, seriesRangeEnd) { const w = this.w const graphics = new Graphics(this.w) const type = w.globals.comboCharts ? ctype : w.config.chart.type const ret = graphics.group({ class: `apexcharts-${type}-series apexcharts-plot-series`, }) const coreUtils = new CoreUtils(this.w) this.yRatio = this.xyRatios.yRatio this.zRatio = this.xyRatios.zRatio this.xRatio = this.xyRatios.xRatio this.baseLineY = this.xyRatios.baseLineY series = coreUtils.getLogSeries(series) this.yRatio = coreUtils.getLogYRatios(this.yRatio) // We call draw() for each series group this.prevSeriesY = [] // push all series in an array, so we can draw in reverse order // (for stacked charts) const allSeries = [] for (let i = 0; i < series.length; i++) { series = this.lineHelpers.sameValueSeriesFix(i, series) const realIndex = w.globals.comboCharts ? /** @type {any} */ (seriesIndex)[i] : i const translationsIndex = this.yRatio.length > 1 ? realIndex : 0 this._initSerieVariables(series, i, realIndex) const yArrj = [] // hold y values of current iterating series const y2Arrj = [] // holds y2 values in range-area charts const xArrj = [] // hold x values of current iterating series let x = w.globals.padHorizontal + this.categoryAxisCorrection const y = 1 /** @type {any[]} */ const linePaths = [] /** @type {any[]} */ const areaPaths = [] Series.addCollapsedClassToSeries(this.w, this.elSeries, realIndex) if (w.axisFlags.isXNumeric && w.seriesData.seriesX.length > 0) { x = (w.seriesData.seriesX[realIndex][0] - w.globals.minX) / this.xRatio } xArrj.push(x) const pX = x let pY2 const prevX = pX let prevY = this.zeroY let prevY2 = this.zeroY const lineYPosition = 0 // the first value in the current series is not null or undefined const firstPrevY = this.lineHelpers.determineFirstPrevY({ i, realIndex, series, prevY, lineYPosition, translationsIndex, }) prevY = firstPrevY.prevY if (w.config.stroke.curve === 'monotoneCubic' && series[i][0] === null) { // we have to discard the y position if 1st dataPoint is null as it // causes issues with monotoneCubic path creation yArrj.push(null) } else { yArrj.push(prevY) } const pY = prevY // y2 are needed for range-area charts let firstPrevY2 if (type === 'rangeArea') { firstPrevY2 = this.lineHelpers.determineFirstPrevY({ i, realIndex, series: seriesRangeEnd, prevY: prevY2, lineYPosition, translationsIndex, }) prevY2 = firstPrevY2.prevY pY2 = prevY2 y2Arrj.push(yArrj[0] !== null ? prevY2 : null) } const pathsFrom = this._calculatePathsFrom({ type, series, i, realIndex, translationsIndex, prevX, prevY, prevY2, }) // RangeArea will resume with these for the upper path creation const rYArrj = [yArrj[0]] const rY2Arrj = [y2Arrj[0]] const iteratingOpts = { type, series, realIndex, translationsIndex, i, x, y, pX, pY, pathsFrom, linePaths, areaPaths, seriesIndex, lineYPosition, xArrj, yArrj, y2Arrj, seriesRangeEnd, } const paths = this._iterateOverDataPoints({ ...iteratingOpts, iterations: type === 'rangeArea' ? series[i].length - 1 : undefined, isRangeStart: true, }) if (type === 'rangeArea') { const pathsFrom2 = this._calculatePathsFrom({ series: seriesRangeEnd, i, realIndex, prevX, prevY: prevY2, }) const rangePaths = this._iterateOverDataPoints({ ...iteratingOpts, series: seriesRangeEnd, xArrj: [x], yArrj: rYArrj, y2Arrj: rY2Arrj, pY: pY2, areaPaths: paths.areaPaths, pathsFrom: pathsFrom2, iterations: seriesRangeEnd[i].length - 1, isRangeStart: false, }) // Path may be segmented by nulls in data. // paths.linePaths should hold (segments * 2) paths (upper and lower) // the first n segments belong to the lower and the last n segments // belong to the upper. // paths.linePaths and rangePaths.linepaths are actually equivalent // but we retain the distinction below for consistency with the // unsegmented paths conditional branch. const segments = paths.linePaths.length / 2 for (let s = 0; s < segments; s++) { paths.linePaths[s] = rangePaths.linePaths[s + segments] + paths.linePaths[s] } paths.linePaths.splice(segments) paths.pathFromLine = rangePaths.pathFromLine + paths.pathFromLine } else if (!/z\s*$/i.test(paths.pathFromArea)) { // Close the initial-mount baseline pathFrom. A pathFrom taken from a // captured previous render already ends with `z`; appending another // used to produce a double-z path that broke reconciliation and fed // the morph a malformed command list. paths.pathFromArea += 'z' } this._handlePaths({ type, realIndex, i, paths }) this.elSeries.add(this.elPointsMain) this.elSeries.add(this.elDataLabelsWrap) allSeries.push(this.elSeries) } if ( typeof ( /** @type {Record<string,any>} */ (w.config.series[0])?.zIndex ) !== 'undefined' ) { allSeries.sort( (a, b) => Number(a.node.getAttribute('zIndex')) - Number(b.node.getAttribute('zIndex')), ) } if (w.config.chart.stacked) { for (let s = allSeries.length - 1; s >= 0; s--) { ret.add(allSeries[s]) } } else { for (let s = 0; s < allSeries.length; s++) { ret.add(allSeries[s]) } } return ret } /** * @param {any[]} series * @param {number} i * @param {number} realIndex */ _initSerieVariables(series, i, realIndex) { const w = this.w const graphics = new Graphics(this.w) // width divided into equal parts this.xDivision = w.layout.gridWidth / (w.globals.dataPoints - (w.config.xaxis.tickPlacement === 'on' ? 1 : 0)) this.strokeWidth = Array.isArray(w.config.stroke.width) ? w.config.stroke.width[realIndex] : w.config.stroke.width let translationsIndex = 0 if (this.yRatio.length > 1) { this.yaxisIndex = w.globals.seriesYAxisReverseMap[realIndex] translationsIndex = realIndex } this.isReversed = w.config.yaxis[this.yaxisIndex] && w.config.yaxis[this.yaxisIndex].reversed // zeroY is the 0 value in y series which can be used in negative charts this.zeroY = w.layout.gridHeight - this.baseLineY[translationsIndex] - (this.isReversed ? w.layout.gridHeight : 0) + (this.isReversed ? this.baseLineY[translationsIndex] * 2 : 0) this.areaBottomY = this.zeroY if ( this.zeroY > w.layout.gridHeight || w.config.plotOptions.area.fillTo === 'end' ) { this.areaBottomY = w.layout.gridHeight } this.categoryAxisCorrection = this.xDivision / 2 // el to which series will be drawn const seriesItem = /** @type {Record<string,any>} */ ( w.config.series[realIndex] ) this.elSeries = graphics.group({ class: `apexcharts-series`, zIndex: typeof seriesItem.zIndex !== 'undefined' ? seriesItem.zIndex : realIndex, seriesName: Utils.escapeString(w.seriesData.seriesNames[realIndex]), }) // points this.elPointsMain = graphics.group({ class: 'apexcharts-series-markers-wrap', 'data:realIndex': realIndex, }) // fresh series element tree -> the per-series marker wrap cache must not // leak a group belonging to a previous series/render this.markers.resetSeriesWrapCache() this._elLastPointsWrap = null if (w.globals.hasNullValues) { // fixes https://github.com/apexcharts/apexcharts.js/issues/3641 const firstPoint = this.markers.plotChartMarkers({ pointsPos: { x: [0], y: [w.layout.gridHeight + w.globals.markers.largestSize], }, seriesIndex: i, j: 0, pSize: 0.1, alwaysDrawMarker: true, isVirtualPoint: true, }) if (firstPoint !== null) { // firstPoint is rendered for cases where there are null values and when dynamic markers are required this.elPointsMain.add(firstPoint) } } // eldatalabels this.elDataLabelsWrap = graphics.group({ class: 'apexcharts-datalabels', 'data:realIndex': realIndex, }) const longestSeries = series[i].length === w.globals.dataPoints this.elSeries.attr({ 'data:longestSeries': longestSeries, rel: i + 1, 'data:realIndex': realIndex, }) this.appendPathFrom = true } /** @param {{ type?: any, series?: any, i?: any, realIndex?: any, translationsIndex?: any, prevX?: any, prevY?: any, prevY2?: any }} opts */ _calculatePathsFrom({ type, series, i, realIndex, translationsIndex, prevX, prevY, prevY2, }) { const w = this.w const graphics = new Graphics(this.w) let linePath, areaPath, pathFromLine, pathFromArea if (series[i][0] === null) { // when the first value itself is null, we need to move the pointer to a location where a null value is not found for (let s = 0; s < series[i].length; s++) { if (series[i][s] !== null) { prevX = this.xDivision * s prevY = this.zeroY - series[i][s] / this.yRatio[translationsIndex] linePath = graphics.move(prevX, prevY) areaPath = graphics.move(prevX, this.areaBottomY) break } } } else { linePath = graphics.move(prevX, prevY) if (type === 'rangeArea') { linePath = graphics.move(prevX, prevY2) + graphics.line(prevX, prevY) } areaPath = graphics.move(prevX, this.areaBottomY) + graphics.line(prevX, prevY) } pathFromLine = graphics.move(0, this.areaBottomY) + graphics.line(0, this.areaBottomY) pathFromArea = graphics.move(0, this.areaBottomY) + graphics.line(0, this.areaBottomY) if (w.globals.previousPaths.length > 0) { const pathFrom = this.lineHelpers.checkPreviousPaths({ pathFromLine, pathFromArea, realIndex, }) pathFromLine = pathFrom.pathFromLine pathFromArea = pathFrom.pathFromArea } return { prevX, prevY, linePath, areaPath, pathFromLine, pathFromArea, } } /** @param {{type: any, realIndex: any, i: any, paths: any}} opts */ _handlePaths({ type, realIndex, i, paths }) { const w = this.w const graphics = new Graphics(this.w) // Strata (#2): series body paths emit through the active renderer (canvas // records them; SVG returns `graphics` unchanged). Chrome (forecast masks) // stays on `graphics`. const emit = seriesEmitter(this.ctx, graphics) const fill = new Fill(this.w) // push all current y values array to main PrevY Array this.prevSeriesY.push(paths.yArrj) // Streaming scroll: when this update is a windowed continuation of the // previous render (rolling window / append under xaxis.range), morph from // the new path re-projected into the previous frame's pixel space instead // of the captured previous path, so the morph translates the window // (points slide left) rather than crossfading y at fixed x. let streamScroll = null if ((type === 'line' || type === 'area') && w.globals.dataChanged) { streamScroll = detectStreamScroll(w, realIndex, paths.xArrj, paths.yArrj) } // Variable-length update (points entered/exited): rebuild the morph pair // over the union of old+new datums so entering points grow out of the old // curve, exiting points melt into the new one, and area fills never tear. // The runner tweens toward the padded target and snaps to the clean path. let reconcile = null if (!streamScroll && (type === 'line' || type === 'area')) { reconcile = reconcileSeriesPaths(w, { type, realIndex, pathFromLine: paths.pathFromLine, pathFromArea: paths.pathFromArea, linePaths: paths.linePaths, areaPaths: paths.areaPaths, }) } // push all x val arrays into main xArr w.globals.seriesXvalues[realIndex] = paths.xArrj w.globals.seriesYvalues[realIndex] = paths.yArrj const forecast = w.config.forecastDataPoints if (forecast.count > 0 && type !== 'rangeArea') { const forecastCutoff = w.globals.seriesXvalues[realIndex][ w.globals.seriesXvalues[realIndex].length - forecast.count - 1 ] const elForecastMask = graphics.drawRect( forecastCutoff, 0, w.layout.gridWidth, w.layout.gridHeight, 0, ) w.dom.elForecastMask.appendChild(elForecastMask.node) const elNonForecastMask = graphics.drawRect( 0, 0, forecastCutoff, w.layout.gridHeight, 0, ) w.dom.elNonForecastMask.appendChild(elNonForecastMask.node) } // these elements will be shown after area path animation completes if (!this.pointsChart) { w.globals.delayedElements.push({ el: this.elPointsMain.node, index: realIndex, }) // Animated update: ride the markers along the morph instead of hiding // them (survivors translate, enters fade). Applies to zooms and value // updates too (identity joins), not just length changes. tweenSeriesMarkers(w, { elPointsMain: this.elPointsMain, realIndex, speed: w.config.chart.animations.dynamicAnimation.speed, }) // On a LAYOUT change additionally hide the data labels until the morph // settles so they never float off the line. if (seriesJoin(w, realIndex) && this.elDataLabelsWrap?.node) { this.elDataLabelsWrap.node.classList.add('apexcharts-element-hidden') w.globals.delayedElements.push({ el: this.elDataLabelsWrap.node, holdUntilComplete: true, }) } } else { // Scatter/bubble: the markers ARE the series. Ride them across animated // data updates and zoom re-projections the same way (survivors translate, // bubbles retween radius, enters fade); without this the points snap to // their new spots on frame 0. tweenSeriesMarkers(w, { elPointsMain: this.elPointsMain, realIndex, speed: w.config.chart.animations.dynamicAnimation.speed, }) } const defaultRenderedPathOptions = { i, realIndex, animationDelay: i, initialSpeed: w.config.chart.animations.speed, dataChangeSpeed: w.config.chart.animations.dynamicAnimation.speed, className: `apexcharts-${type}`, } // Numeric fast-path coords (canvas mode): the renderer paints these via a // moveTo/lineTo loop instead of parsing the d string into a Path2D. const numericXY = paths.numericXY if (type === 'area') { const pathFill = fill.fillPath({ seriesNumber: realIndex, }) for (let p = 0; p < paths.areaPaths.length; p++) { const renderedPath = emit.renderPaths({ ...defaultRenderedPathOptions, pathFrom: streamScroll ? projectPathToPrevFrame(paths.areaPaths[p], streamScroll) : (reconcile?.area?.from ?? paths.pathFromArea), pathTo: paths.areaPaths[p], pathToNumeric: numericXY ? { xs: numericXY.xs, ys: numericXY.ys, closeY: numericXY.areaCloseY, } : undefined, pathToInterp: reconcile?.area?.toInterp, scrollMorph: !!streamScroll, stroke: 'none', strokeWidth: 0, strokeLineCap: null, fill: pathFill, }) this.elSeries.add(renderedPath) } } if (w.config.stroke.show && !this.pointsChart) { let lineFill = null if (type === 'line') { lineFill = fill.fillPath({ seriesNumber: realIndex, i, }) } else { if (w.config.stroke.fill.type === 'solid') { lineFill = w.globals.stroke.colors[realIndex] } else { const prevFill = w.config.fill w.config.fill = w.config.stroke.fill lineFill = fill.fillPath({ seriesNumber: realIndex, i, }) w.config.fill = prevFill } } // range-area paths are drawn using linePaths for (let p = 0; p < paths.linePaths.length; p++) { let pathFill = lineFill if (type === 'rangeArea') { pathFill = fill.fillPath({ seriesNumber: realIndex, }) } const linePathCommonOpts = { ...defaultRenderedPathOptions, pathFrom: streamScroll ? projectPathToPrevFrame(paths.linePaths[p], streamScroll) : (reconcile?.line?.from ?? paths.pathFromLine), pathTo: paths.linePaths[p], pathToNumeric: numericXY ? { xs: numericXY.xs, ys: numericXY.ys } : undefined, pathToInterp: reconcile?.line?.toInterp, scrollMorph: !!streamScroll, stroke: lineFill, strokeWidth: this.strokeWidth, strokeLineCap: w.config.stroke.lineCap, fill: type === 'rangeArea' ? pathFill : 'none', } const renderedPath = emit.renderPaths(linePathCommonOpts) this.elSeries.add(renderedPath) renderedPath.attr('fill-rule', `evenodd`) if (forecast.count > 0 && type !== 'rangeArea') { const renderedForecastPath = emit.renderPaths(linePathCommonOpts) renderedForecastPath.node.setAttribute( 'stroke-dasharray', forecast.dashArray, ) if (forecast.strokeWidth) { renderedForecastPath.node.setAttribute( 'stroke-width', forecast.strokeWidth, ) } this.elSeries.add(renderedForecastPath) renderedForecastPath.attr( 'clip-path', `url(#forecastMask${w.globals.cuid})`, ) renderedPath.attr( 'clip-path', `url(#nonForecastMask${w.globals.cuid})`, ) } } } } _iterateOverDataPoints( /** @type {any} */ { type, series, iterations, realIndex, translationsIndex, i, x, y, pX, pY, pathsFrom, linePaths, areaPaths, seriesIndex, lineYPosition, xArrj, yArrj, y2Arrj, isRangeStart, seriesRangeEnd, }, ) { const w = this.w const graphics = new Graphics(this.w) const yRatio = this.yRatio let { prevY, linePath, areaPath, pathFromLine, pathFromArea } = pathsFrom const minY = Utils.isNumber(w.globals.minYArr[realIndex]) ? w.globals.minYArr[realIndex] : w.globals.minY if (!iterations) { iterations = w.globals.dataPoints > 1 ? w.globals.dataPoints - 1 : w.globals.dataPoints } /** * @param {number} _y * @param {number} lineYPos */ const getY = (_y, lineYPos) => { return ( lineYPos - _y / yRatio[translationsIndex] + (this.isReversed ? _y / yRatio[translationsIndex] : 0) * 2 ) } let y2 = y const stackSeries = (w.config.chart.stacked && !w.globals.comboCharts) || (w.config.chart.stacked && w.globals.comboCharts && (!this.w.config.chart.stackOnlyBar || /** @type {Record<string,any>} */ (this.w.config.series[realIndex]) ?.type === 'bar' || /** @type {Record<string,any>} */ (this.w.config.series[realIndex]) ?.type === 'column')) let curve = w.config.stroke.curve if (Array.isArray(curve)) { if (Array.isArray(seriesIndex)) { curve = curve[seriesIndex[i]] } else { curve = curve[i] } } let pathState = 0 let segmentStartX // Scatter jitter: max ± offsets (px) for this series, or null. Applied to // the stored point positions below so markers, seriesXvalues/seriesYvalues // and the tooltip's nearest-point picker all share the same jittered coords // (keeps the sticky tooltip anchored to the dot the cursor is actually over). const jitterPx = this.pointsChart ? this._scatterJitterPx(realIndex) : null // Numeric fast path: a plain straight line/area (no nulls, markers, // labels, stacking) computes its geometry in one tight loop with a // join-built d string that is byte-identical to what the state machine // below produces, skipping the per-point _createPaths / // _handleMarkersAndLabels machinery that dominates large-series renders. if (curve === 'straight' && !this.pointsChart) { const fast = this._fastStraightPath({ type, series, i, realIndex, translationsIndex, iterations, x, y, pX, pY, pathsFrom, linePaths, areaPaths, xArrj, yArrj, y2Arrj, stackSeries, }) if (fast) return fast } for (let j = 0; j < iterations; j++) { if (series[i].length === 0) break const isNull = typeof series[i][j + 1] === 'undefined' || series[i][j + 1] === null if (w.axisFlags.isXNumeric) { let sX = w.seriesData.seriesX[realIndex][j + 1] if (typeof w.seriesData.seriesX[realIndex][j + 1] === 'undefined') { /* fix #374 */ sX = w.seriesData.seriesX[realIndex][iterations - 1] } x = (sX - w.globals.minX) / this.xRatio } else { x = x + this.xDivision } if (stackSeries) { if ( i > 0 && w.globals.collapsedSeries.length < w.config.series.length - 1 ) { // a collapsed series in a stacked chart may provide wrong result // for the next series, hence find the prevIndex of prev series // which is not collapsed - fixes apexcharts.js#1372 /** * @param {number} pi */ const prevIndex = (pi) => { for (let pii = pi; pii > 0; pii--) { if ( w.globals.collapsedSeriesIndices.indexOf( seriesIndex?.[pii] || pii, ) > -1 ) { pii-- } else { return pii } } return 0 } lineYPosition = this.prevSeriesY[prevIndex(i - 1)][j + 1] } else { // the first series will not have prevY values lineYPosition = this.zeroY } } else { lineYPosition = this.zeroY } if (isNull) { y = getY(minY, lineYPosition) } else { y = getY(series[i][j + 1], lineYPosition) if (type === 'rangeArea') { y2 = getY(seriesRangeEnd[i][j + 1], lineYPosition) } } // Jittered copies for the marker position (path drawing keeps the // un-jittered x/y). Deterministic per (series, dataPoint) so the layout is // stable across re-renders and SSR-safe. let xj = x let yj = y if (jitterPx) { const seed = realIndex * 100003 + (j + 1) if (jitterPx.x) xj = x + (hash01(seed * 7919 + 13) - 0.5) * 2 * jitterPx.x if (jitterPx.y) yj = y + (hash01(seed * 6271 + 97) - 0.5) * 2 * jitterPx.y } // push current X xArrj.push(series[i][j + 1] === null ? null : xj) // push current Y that will be used as next series's bottom position if ( isNull && (w.config.stroke.curve === 'smooth' || w.config.stroke.curve === 'monotoneCubic') ) { yArrj.push(null) y2Arrj.push(null) } else { yArrj.push(yj) y2Arrj.push(y2) } const pointsPos = this.lineHelpers.calculatePoints({ series, x: xj, y: yj, realIndex, i, j, prevY, }) const calculatedPaths = this._createPaths({ type, series, i, j, x, y, y2, xArrj, yArrj, y2Arrj, pX, pY, pathState, segmentStartX, linePath, areaPath, linePaths, areaPaths, curve, isRangeStart, }) areaPaths = calculatedPaths.areaPaths linePaths = calculatedPaths.linePaths pX = calculatedPaths.pX pY = calculatedPaths.pY pathState = calculatedPaths.pathState segmentStartX = calculatedPaths.segmentStartX areaPath = calculatedPaths.areaPath linePath = calculatedPaths.linePath if ( this.appendPathFrom && !w.globals.hasNullValues && !(curve === 'monotoneCubic' && type === 'rangeArea') ) { pathFromLine += graphics.line(x, this.areaBottomY) pathFromArea += graphics.line(x, this.areaBottomY) } this.handleNullDataPoints(series, pointsPos, i, j, realIndex) this._handleMarkersAndLabels({ type, pointsPos, i, j, realIndex, isRangeStart, }) } return { yArrj, xArrj, pathFromArea, areaPaths, pathFromLine, linePaths, linePath, areaPath, } } /** @param {{type: any, pointsPos: any, isRangeStart: any, i: any, j: any, realIndex: any}} opts */ _handleMarkersAndLabels({ type, pointsPos, isRangeStart, i, j, realIndex }) { const w = this.w const dataLabels = new DataLabels(this.w, this.ctx) if (!this.pointsChart) { // Progressive marker reveal handles per-marker opacity timing (synced // to the line draw), so the legacy group-level hide is bypassed on // initial mount. Data updates and resizes still use the old code path. const useProgressive = !w.globals.dataChanged && !w.globals.resized if (!useProgressive && w.seriesData.series[i].length > 1) { this.elPointsMain.node.classList.add('apexcharts-element-hidden') } const elPointsWrap = this.markers.plotChartMarkers({ pointsPos, seriesIndex: realIndex, j: j + 1, }) // plotChartMarkers returns the same per-series wrap group for every // point; append it only when it first appears if (elPointsWrap !== null && elPointsWrap !== this._elLastPointsWrap) { this.elPointsMain.add(elPointsWrap) this._elLastPointsWrap = elPointsWrap } } else { // scatter / bubble chart points creation (jitter is already baked into // pointsPos above, so the markers, seriesXvalues and tooltip stay in sync) this.scatter.draw(this.elSeries, j, { realIndex, pointsPos, zRatio: this.zRatio, elParent: this.elPointsMain, }) } const drawnLabels = dataLabels.drawDataLabel({ type, isRangeStart, pos: pointsPos, i: realIndex, j: j + 1, }) if (drawnLabels !== null) { this.elDataLabelsWrap.add(drawnLabels) } } /** * Max scatter-jitter offsets in pixels for this series, or null when jitter is * off. The config offsets are in axis units (x: 1 = one category step / x-data * unit, y: 1 = one y-data unit); convert each to pixels using the chart's * ratios. The actual per-point offset is a deterministic fraction of these * (see Scatter.drawPoint). * @param {number} realIndex * @returns {{ x: number, y: number } | null} */ _scatterJitterPx(realIndex) { const w = this.w const jt = w.config.plotOptions.scatter?.jitter if (!jt || !jt.enabled || (!jt.x && !jt.y)) return null // px per x-unit: numeric axis → gridWidth/xRange (= 1/xRatio); category axis // → one category slot (xDivision). const xUnitPx = w.axisFlags.isXNumeric && this.xRatio ? 1 / this.xRatio : this.xDivision const ti = this.yRatio.length > 1 ? realIndex : 0 const yUnitPx = this.yRatio[ti] ? 1 / this.yRatio[ti] : 0 return { x: (jt.x || 0) * xUnitPx, y: (jt.y || 0) * yUnitPx, } } /** * Numeric geometry fast path for plain straight line/area series (the * render-2026 perf work). Eligibility is strict: everything the per-point * slow loop can do beyond plain geometry (null gaps, markers, data labels, * discrete markers, stacking, combos, range areas) bails to the state * machine. When eligible it produces the SAME outputs as the slow loop * (byte-identical d strings via join, the same xArrj/yArrj/y2Arrj * pushes, and the same pointsArray tooltip cache) in one tight loop. * In canvas mode it additionally emits typed-array coordinates so the * renderer can paint via moveTo/lineTo without a Path2D d-string parse. * * @param {{type: any, series: any, i: number, realIndex: number, * translationsIndex: number, iterations: number, x: number, y: number, * pX: number, pY: number, pathsFrom: any, linePaths: any[], * areaPaths: any[], xArrj: any[], yArrj: any[], y2Arrj: any[], * stackSeries: boolean}} opts * @returns {any} the _iterateOverDataPoints result, or null when ineligible */ _fastStraightPath({ type, series, i, realIndex, translationsIndex, iterations, x, y, pX, pY, pathsFrom, linePaths, areaPaths, xArrj, yArrj, y2Arrj, stackSeries, }) { const w = this.w if (type !== 'line' && type !== 'area') return null if (w.globals.comboCharts || stackSeries) return null if (w.config.dataLabels.enabled) return null if (w.config.markers.discrete.length) return null // markers on -> per-point marker emission, slow loop if (w.globals.markers.size[realIndex] > 0) return null const s = series[i] const n = s.length // ragged/short series rely on the slow loop's padding fixes (#374) if (!iterations || n < 2 || n - 1 !== iterations) return null // any null/undefined value re-enters the segmenting state machine for (let k = 0; k <= iterations; k++) { const v = s[k] if (v === null || typeof v === 'undefined') return null } const isXNumeric = w.axisFlags.isXNumeric const sx = isXNumeric ? w.seriesData.seriesX[realIndex] : null if (isXNumeric && (!sx || sx.length < n)) return null const yR = this.yRatio[translationsIndex] const isReversed = this.isReversed const zeroY = this.zeroY const bottomY = this.areaBottomY const xRatio = this.xRatio const minX = w.globals.minX const xDivision = this.xDivision const offX = w.config.markers.offsetX const offY = w.config.markers.offsetY // pathFrom baselines grow one ` L x bottom` per point (unless a previous // render's path was adopted, which turns appendPathFrom off) const appendFrom = this.appendPathFrom && !w.globals.hasNullValues let { pathFromLine, pathFromArea } = pathsFrom // canvas mode: numeric coords let the renderer skip the d-string parse. // The d strings themselves are only consumed by the update-morph pipeline // (streamScroll / reconcile), which requires a captured previous frame; // without one (initial render, or updates with animations disabled) skip // building them entirely. const r = this.ctx && this.ctx.renderer const canvasMode = !!(r && r.kind && r.kind !== 'svg') const buildStrings = !canvasMode || (w.globals.dataChanged && !!w.globals.prevStreamFrame) const nxs = canvasMode ? new Float64Array(n) : null const nys = canvasMode ? new Float64Array(n) : null if (nxs && nys) { nxs[0] = pX nys[0] = pY } // tooltip position cache: replicate the markers-off plotChartMarkers // pushes (two entries for the doubled first point, then one per point) if (typeof w.globals.pointsArray[realIndex] === 'undefined') { w.globals.pointsArray[realIndex] = [] } const pts = w.globals.pointsArray[realIndex] const xPT1st = sx ? (sx[0] - minX) / xRatio + offX : this.categoryAxisCorrection + offX pts.push([xPT1st, pathsFrom.prevY + offY]) /** @type {any[]} */ const parts = buildStrings ? new Array(iterations + 1) : [] if (buildStrings) parts[0] = 'M ' + pX + ' ' + pY /** @type {any[]|null} */ const fromParts = buildStrings && appendFrom ? new Array(iterations) : null let xv = x let xj = pX let yj = pY for (let j = 0; j < iterations; j++) { if (sx) { xj = (sx[j + 1] - minX) / xRatio } else { xv = xv + xDivision xj = xv } const v = s[j + 1] yj = zeroY - v / yR + (isReversed ? v / yR : 0) * 2 xArrj.push(xj) yArrj.push(yj) y2Arrj.push(y) pts.push([xj + offX, yj + offY]) if (buildStrings) { parts[j + 1] = ' L ' + xj + ' ' + yj if (fromParts) fromParts[j] = ' L ' + xj + ' ' + bottomY } if (nxs && nys) { nxs[j + 1] = xj nys[j + 1] = yj } } let linePath = '' let areaPath = '' if (buildStrings) { linePath = parts.join('') areaPath = linePath + ' L ' + xj + ' ' + bottomY + ' L ' + pX + ' ' + bottomY + 'z' } if (fromParts) { const fromAppend = fromParts.join('') pathFromLine += fromAppend pathFromArea += fromAppend } else if (!buildStrings && appendFrom) { // canvas initial render: baselines aren't consumed, keep them cheap // (renderPaths only checks truthiness before the inert canvas morph) pathFromLine += ' L ' + xj + ' ' + bottomY pathFromArea += ' L ' + xj + ' ' + bottomY } linePaths.push(linePath) areaPaths.push(areaPath) return { yArrj, xArrj, pathFromArea, areaPaths, pathFromLine, linePaths, linePath, areaPath, numericXY: nxs ? { xs: nxs, ys: nys, areaCloseY: bottomY } : undefined, } } /** @param {{type: any, series: any, i: any, j: any, x: any, y: any, xArrj: any, yArrj: any, y2: any, y2Arrj: any, pX: any, pY: any, pathState: any, segmentStartX: any, linePath: any, areaPath: any, linePaths: any, areaPaths: any, curve: any, isRangeStart: any}} opts */ _createPaths({ type, series, i, j, x, y, xArrj, yArrj, y2, y2Arrj, pX, pY, pathState, segmentStartX, linePath, areaPath, linePaths, areaPaths, curve, isRangeStart, }) { const graphics = new Graphics(this.w) const areaBottomY = this.areaBottomY const rangeArea = type === 'rangeArea' const isLowerRangeAreaPath = type === 'rangeArea' && isRangeStart switch (curve) { case 'monotoneCubic': { const yAj = isRangeStart ? yArrj : y2Arrj /** * @param {any[]} xArr * @param {any[]} yArr */ const getSmoothInputs = (xArr, yArr) => { return ( xArr /** * @param {any} _ * @param {number} i */ .map((_, i) => { return [_, yArr[i]] }) /** * @param {any} _ */ .filter((_) => _[1] !== null) ) } /** * @param {any[]} yArr */ const getSegmentLengths = (yArr) => { // Get the segment lengths so the segments can be extracted from // the null-filtered smoothInputs array const segLens = [] let count = 0 /** * @param {any} _ */ yArr.forEach((_) => { if (_ !== null) { count++ } else if (count > 0) { segLens.push(count) count = 0 } }) if (count > 0) { segLens.push(count) } return segLens } /** * @param {any[]} yArr * @param {any} points */ const getSegments = (yArr, points) => { const segLens = getSegmentLengths(yArr) const segments = [] for (let i = 0, len = 0; i < segLens.length; len += segLens[i++]) { segments[i] = spline.slice(points, len, len + segLens[i]) } return segments } switch (pathState) { case 0: // Find start of segment if (yAj[j + 1] === null) { break } pathState = 1 // falls through case 1: if ( !(rangeArea ? xArrj.length === series[i].length : j === series[i].length - 2) ) { break } // falls through case 2: { // Interpolate the full series with nulls excluded then extract the // null delimited segments with interpolated points included. const _xAj = isRangeStart ? xArrj : xArrj.slice().reverse() const _yAj = isRangeStart ? yAj : yAj.slice().reverse() const smoothInputs = getSmoothInputs(_xAj, _yAj) const points = smoothInputs.length > 1 ? spline.points(smoothInputs) : smoothInputs /** @type {any[]} */ let smoothInputsLower = [] if (rangeArea) { if (isLowerRangeAreaPath) { // As we won't be needing it, borrow areaPaths to retain our // rangeArea lower points. areaPaths = smoothInputs } else { // Retrieve the corresponding lower raw interpolated points so we // can join onto its end points. Note: the upper Y2 segments will // be in the reverse order relative to the lower segments. smoothInputsLower = areaPaths.reverse() } } let segmentCount = 0 let smoothInputsIndex = 0 getSegments(_yAj, points).forEach((_) => { segmentCount++ const svgPoints = svgPath(_) const _start = smoothInputsIndex smoothInputsIndex += _.length const _end = smoothInputsIndex - 1 if (isLowerRangeAreaPath) { linePath = graphics.move( smoothInputs[_start][0], smoothInputs[_start][1], ) + svgPoints } else if (rangeArea) { linePath = graphics.move( smoothInputsLower[_start][0], smoothInputsLower[_start][1], ) + graphics.line( smoothInputs[_start][0], smoothInputs[_start][1], ) + svgPoints + graphics.line( smoothInputsLower[_end][0], smoothInputsLower[_end][1], ) } else { linePath = graphics.move( smoothInputs[_start][0], smoothInputs[_start][1], ) + svgPoints areaPath = linePath + graphics.line(smoothInputs[_end][0], areaBottomY) + graphics.line(smoothInputs[_start][0], areaBottomY) + 'z' areaPaths.push(areaPath) } linePaths.push(linePath) }) if (rangeArea && segmentCount > 1 && !isLowerRangeAreaPath) { // Reverse the order of the upper path segments const upperLinePaths = linePaths.slice(segmentCount).reverse() linePaths.splice(segmentCount) /** * @param {string} u */ upperLinePaths.forEach((/** @type {any} */ u) => linePaths.push(u), ) } pathState = 0 break } } break } case 'smooth': { const length = (x - pX) * 0.35 if (series[i][j] === null) { pathState = 0 } else { switch (pathState) { case 0: // Beginning of segment segmentStartX = pX if (isLowerRangeAreaPath) { // Need to add path portion that will join to the upper path linePath = graphics.move(pX, y2Arrj[j]) + graphics.line(pX, pY) } else { linePath = graphics.move(pX, pY) } areaPath = graphics.move(pX, pY) // Check for single isolated point if ( series[i][j + 1] === null || typeof series[i][j + 1] === 'undefined' ) { linePaths.push(linePath) areaPaths.push(areaPath) // Stay in pathState = 0; break } pathState = 1 if (j < series[i].length - 2) { const p = graphics.curve(pX + length, pY, x - length, y, x, y) linePath += p areaPath += p break } // falls through case 1: // Continuing with segment if (series[i][j + 1] === null) { // Segment ends here if (isLowerRangeAreaPath) { linePath += graphics.line(pX, y2) } else { linePath += graphics.move(pX, pY) } areaPath += graphics.line(pX, areaBottomY) + graphics.line(segmentStartX, areaBottomY) + 'z' linePaths.push(linePath) areaPaths.push(areaPath) pathState = -1 } else { const p = graphics.curve(pX + length, pY, x - length, y, x, y) linePath += p areaPath += p if (j >= series[i].length - 2) { if (isLowerRangeAreaPath) { // Need to add path portion that will join to the upper path linePath += graphics.curve(x, y, x, y, x, y2) + graphics.move(x, y2) } areaPath += graphics.curve(x, y, x, y, x, areaBottomY) + graphics.line(segmentStartX, areaBottomY) + 'z' linePaths.push(linePath) areaPaths.push(areaPath) pathState = -1 } } break } } pX = x pY = y break } default: { /** * @param {string} curve * @param {number} x * @param {number} y */ const pathToPoint = (curve, x, y) => { /** @type {string} */ let path = '' switch (curve) { case 'stepline': path = graphics.line(x, null, 'H') + graphics.line(null, y, 'V') break case 'linestep': path = graphics.line(null, y, 'V') + graphics.line(x, null, 'H') break case 'straight': path = graphics.line(x, y) break } return path } if (series[i][j] === null) { pathState = 0 } else { switch (pathState) { case 0: // Beginning of segment segmentStartX = pX if (isLowerRangeAreaPath) { // Need to add path portion that will join to the upper path linePath = graphics.move(pX, y2Arrj[j]) + graphics.line(pX, pY) } else { linePath = graphics.move(pX, pY) } areaPath = graphics.move(pX, pY) // Check for single isolated point if ( series[i][j + 1] === null || typeof series[i][j + 1] === 'undefined' ) { linePaths.push(linePath) areaPaths.push(areaPath) // Stay in pathState = 0 break } pathState = 1 if (j < series[i].length - 2) { const p = pathToPoint(curve, x, y) linePath += p areaPath += p break } // falls through case 1: // Continuing with segment if (series[i][j + 1] === null) { // Segment ends here if (isLowerRangeAreaPath) { linePath += graphics.line(pX, y2) } else { linePath += graphics.move(pX, pY) } areaPath += graphics.line(pX, areaBottomY) + graphics.line(segmentStartX, areaBottomY) + 'z' linePaths.push(linePath) areaPaths.push(areaPath) pathState = -1 } else { const p = pathToPoint(curve, x, y) linePath += p areaPath += p if (j >= series[i].length - 2) { if (isLowerRangeAreaPath) { // Need to add path portion that will join to the upper path linePath += graphics.line(x, y2) } areaPath += graphics.line(x, areaBottomY) + graphics.line(segmentStartX, areaBottomY) + 'z' linePaths.push(linePath) areaPaths.push(areaPath) pathState = -1 } } break } } pX = x pY = y break } } return { linePaths, areaPaths, pX, pY, pathState, segmentStartX, linePath, areaPath, } } /** * @param {any[]} series * @param {any} pointsPos * @param {number} i * @param {number} j * @param {number} realIndex */ handleNullDataPoints(series, pointsPos, i, j