diff --git a/samples/react/rangeArea/range-area-line-combo-monotoneCubic.html b/samples/react/rangeArea/range-area-line-combo-monotoneCubic.html new file mode 100644 index 000000000..c0fa257f2 --- /dev/null +++ b/samples/react/rangeArea/range-area-line-combo-monotoneCubic.html @@ -0,0 +1,308 @@ + + + + + + + Range Area - Line - monotone cubic (Combo) + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ <div id="chart"> + <ReactApexChart options={this.state.options} series={this.state.series} type="rangeArea" height={350} /> +</div> +
+ + + + + + diff --git a/samples/source/rangeArea/range-area-line-combo-monotoneCubic.xml b/samples/source/rangeArea/range-area-line-combo-monotoneCubic.xml new file mode 100644 index 000000000..73cbc8231 --- /dev/null +++ b/samples/source/rangeArea/range-area-line-combo-monotoneCubic.xml @@ -0,0 +1,217 @@ +Range Area - Line - monotone cubic (Combo) + + + + +chart: { + height: 350, + type: 'rangeArea', + animations: { + speed: 500 + } +}, +colors: ['#d4526e', '#33b2df', '#d4526e', '#33b2df'], +dataLabels: { + enabled: false +}, +fill: { + opacity: [0.24, 0.24, 1, 1] +}, +forecastDataPoints: { + count: 2 +}, +stroke: { + curve: 'monotoneCubic', + width: [0, 0, 2, 2] +}, +legend: { + show: true, + customLegendItems: ['Team B', 'Team A'], + inverseOrder: true +}, +title: { + text: 'MonotoneCubic Range Area, Forecast Line, Missing Data' +}, +markers: { + hover: { + sizeOffset: 5 + } +} + + + +[ + { + type: 'rangeArea', + name: 'Team B Range', + + data: [ + { + x: 'Jan', + y: [null, null] + }, + { + x: 'Feb', + y: [1200, 1800] + }, + { + x: 'Mar', + y: [900, 2900] + }, + { + x: 'Apr', + y: [1400, 2700] + }, + { + x: 'May', + y: [2600, 3900] + }, + { + x: 'Jun', + y: [500, 1700] + }, + { + x: 'Jul', + y: [1900, 2300] + }, + { + x: 'Aug', + y: [1000, 1500] + } + ] + }, + + { + type: 'rangeArea', + name: 'Team A Range', + data: [ + { + x: 'Jan', + y: [3100, 3400] + }, + { + x: 'Feb', + y: [4200, 5200] + }, + { + x: 'Mar', + y: [null, null] + }, + { + x: 'Apr', + y: [3400, 3900] + }, + { + x: 'May', + y: [5100, 5900] + }, + { + x: 'Jun', + y: [5400, 6700] + }, + { + x: 'Jul', + y: [4300, 4600] + }, + { + x: 'Aug', + y: [null, null] + } + ] + }, + + { + type: 'line', + name: 'Team B Median', + data: [ + { + x: 'Jan', + y: 1500 + }, + { + x: 'Feb', + y: 1700 + }, + { + x: 'Mar', + y: 1900 + }, + { + x: 'Apr', + y: 2200 + }, + { + x: 'May', + y: 3000 + }, + { + x: 'Jun', + y: 1000 + }, + { + x: 'Jul', + y: 2100 + }, + { + x: 'Aug', + y: 1200 + }, + { + x: 'Sep', + y: 1800 + }, + { + x: 'Oct', + y: 2000 + } + ] + }, + { + type: 'line', + name: 'Team A Median', + data: [ + { + x: 'Jan', + y: 3300 + }, + { + x: 'Feb', + y: 4900 + }, + { + x: 'Mar', + y: 4300 + }, + { + x: 'Apr', + y: 3700 + }, + { + x: 'May', + y: 5500 + }, + { + x: 'Jun', + y: 5900 + }, + { + x: 'Jul', + y: 4500 + }, + { + x: 'Aug', + y: 2400 + }, + { + x: 'Sep', + y: 2100 + }, + { + x: 'Oct', + y: 1500 + } + ] + } +] + + \ No newline at end of file diff --git a/samples/vanilla-js/rangeArea/range-area-line-combo-monotoneCubic.html b/samples/vanilla-js/rangeArea/range-area-line-combo-monotoneCubic.html new file mode 100644 index 000000000..c7cb36ce5 --- /dev/null +++ b/samples/vanilla-js/rangeArea/range-area-line-combo-monotoneCubic.html @@ -0,0 +1,275 @@ + + + + + + + Range Area - Line - monotone cubic (Combo) + + + + + + + + + + + + + + + + + +
+ + + + + + diff --git a/samples/vue/rangeArea/range-area-line-combo-monotoneCubic.html b/samples/vue/rangeArea/range-area-line-combo-monotoneCubic.html new file mode 100644 index 000000000..2563f449d --- /dev/null +++ b/samples/vue/rangeArea/range-area-line-combo-monotoneCubic.html @@ -0,0 +1,294 @@ + + + + + + + Range Area - Line - monotone cubic (Combo) + + + + + + + + + + + + + + + + + + + + +
+
+ +
+
+ + +
+ <div id="chart"> + <apexchart type="rangeArea" height="350" :options="chartOptions" :series="series"></apexchart> + </div> +
+ + + + + diff --git a/src/charts/Line.js b/src/charts/Line.js index 16736b2c8..44ebae3a5 100644 --- a/src/charts/Line.js +++ b/src/charts/Line.js @@ -103,7 +103,8 @@ class Line { }) 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 + // 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) @@ -123,7 +124,7 @@ class Line { }) prevY2 = firstPrevY2.prevY pY2 = prevY2 - y2Arrj.push(prevY2) + y2Arrj.push(yArrj[0] !== null ? prevY2 : null) } let pathsFrom = this._calculatePathsFrom({ @@ -137,6 +138,10 @@ class Line { prevY2, }) + // RangeArea will resume with these for the upper path creation + let rYArrj = [yArrj[0]] + let rY2Arrj = [y2Arrj[0]] + const iteratingOpts = { type, series, @@ -175,31 +180,29 @@ class Line { let 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, }) -// if (w.globals.hasNullValues || w.config.forecastDataPoints.count) { - if (w.config.stroke.curve !== 'monotoneCubic') { - // 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. - let 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 { - paths.linePaths[0] = rangePaths.linePath + paths.linePath - paths.pathFromLine = rangePaths.pathFromLine + paths.pathFromLine + // 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. + let 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 } this._handlePaths({ type, realIndex, i, paths }) @@ -623,12 +626,16 @@ class Line { xArrj.push(x) // push current Y that will be used as next series's bottom position - if (isNull && w.config.stroke.curve === 'smooth') { + if (isNull + && (w.config.stroke.curve === 'smooth' + || w.config.stroke.curve === 'monotoneCubic') + ) { yArrj.push(null) + y2Arrj.push(null) } else { yArrj.push(y) + y2Arrj.push(y2) } - y2Arrj.push(y2) let pointsPos = this.lineHelpers.calculatePoints({ series, @@ -769,76 +776,142 @@ class Line { }) { let w = this.w let graphics = new Graphics(this.ctx) - const areaBottomY = this.areaBottomY - - if ( - type === 'rangeArea' && - (w.globals.hasNullValues || w.config.forecastDataPoints.count > 0) && - curve === 'monotoneCubic' - ) { - curve = 'straight' - } - + let rangeArea = type === 'rangeArea' let isLowerRangeAreaPath = (type === 'rangeArea' && isRangeStart) switch (curve) { case 'monotoneCubic': - const shouldRenderMonotone = - type === 'rangeArea' - ? xArrj.length === w.globals.dataPoints - : j === series[i].length - 2 - - const smoothInputs = xArrj - .map((_, i) => { - return [xArrj[i], yArrj[i]] + let yAj = isRangeStart ? yArrj : y2Arrj + let getSmoothInputs = (xArr, yArr) => { + return xArr.map((_, i) => { + return [_, yArr[i]] + }) + .filter((_) => _[1] !== null) + } + let getSegmentLengths = (yArr) => { + // Get the segment lengths so the segments can be extracted from + // the null-filtered smoothInputs array + let segLens = [] + let count = 0 + yArr.forEach((_) => { + if (_ !== null) { + count++ + } else if (count > 0) { + segLens.push(count) + count = 0 + } }) - .filter((_) => _[1] !== null) - - if (shouldRenderMonotone && smoothInputs.length > 1) { - const points = spline.points(smoothInputs) - - linePath += svgPath(points) - if (series[i][0] === null) { - // if the first dataPoint is null, we use the linePath directly - areaPath = linePath - } else { - // else, we append the areaPath - areaPath += svgPath(points) + if (count > 0) { + segLens.push(count) } + return segLens + } + let getSegments = (yArr, points) => { + let segLens = getSegmentLengths(yArr) + let segments = [] + for (let i = 0, len = 0; i < segLens.length; len += segLens[i++]) { + segments[i] = spline.slice(points, len, len + segLens[i]) + } + return segments + } - if (isLowerRangeAreaPath) { - // draw the line to connect y with y2; then draw the other end of range - linePath += graphics.line( - xArrj[xArrj.length - 1], - y2Arrj[y2Arrj.length - 1] - ) - - const xArrjInversed = xArrj.slice().reverse() - const y2ArrjInversed = y2Arrj.slice().reverse() - const smoothInputsY2 = xArrjInversed.map((_, i) => { - return [xArrjInversed[i], y2ArrjInversed[i]] - }) - - const pointsY2 = spline.points(smoothInputsY2) + switch (pathState) { + case 0: + // Find start of segment + if (yAj[j + 1] === null) { + break + } + pathState = 1 + // continue through to pathState 1 + case 1: + if (!(rangeArea + ? xArrj.length === series[i].length + : (j === series[i].length - 2)) + ) { + break + } + // continue through to pathState 2 + 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 + + 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() + } + } - linePath += svgPath(pointsY2) + let segmentCount = 0 + let smoothInputsIndex = 0 + getSegments(_yAj, points).forEach((_) => { + segmentCount++ + let svgPoints = svgPath(_) + let _start = smoothInputsIndex + smoothInputsIndex += _.length + let _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) + }) - // in range area, we don't have separate line and area path - areaPath = linePath - } else { - areaPath += - graphics.line( - smoothInputs[smoothInputs.length - 1][0], - areaBottomY - ) + - graphics.line(smoothInputs[0][0], areaBottomY) + - graphics.move(smoothInputs[0][0], smoothInputs[0][1]) + - 'z' + if (rangeArea && segmentCount > 1 && !isLowerRangeAreaPath) { + // Reverse the order of the upper path segments + let upperLinePaths = linePaths.slice(segmentCount).reverse() + linePaths.splice(segmentCount) + upperLinePaths.forEach((u) => linePaths.push(u)) } - - linePaths.push(linePath) - areaPaths.push(areaPath) + pathState = 0 + break } break case 'smooth': diff --git a/src/libs/monotone-cubic.js b/src/libs/monotone-cubic.js index 7a28ba661..9298565ba 100644 --- a/src/libs/monotone-cubic.js +++ b/src/libs/monotone-cubic.js @@ -137,7 +137,7 @@ export const spline = { if (start) { // Add additional 'C' points - if (pts[1].length < 6) { + if (end - start > 1 && pts[1].length < 6) { const n = pts[0].length pts[1] = [ diff --git a/tests/e2e/snapshots/rangeArea/range-area-line-combo-monotoneCubic.png b/tests/e2e/snapshots/rangeArea/range-area-line-combo-monotoneCubic.png new file mode 100644 index 000000000..edf975e4d Binary files /dev/null and b/tests/e2e/snapshots/rangeArea/range-area-line-combo-monotoneCubic.png differ