From 0abd03b44177fe858e3d24b4858b85bda5b0f554 Mon Sep 17 00:00:00 2001
From: Ross Johnson <159597299+rosco54@users.noreply.github.com>
Date: Sat, 6 Apr 2024 17:27:47 +1100
Subject: [PATCH] MonotoneCubic fixes: Line and area charts with null data
points now work. RangeArea with null data points now works. RangeArea with
forecast line combo chart now works. Refactored to implement full series
interpolation before segmenting (segments relate to each other).
Fix libs/monotone-cubic.js splice.slice(): NEEDS TO BE PUSHED UPSTREAM
was crashing on single point slices.
New sample as a regression test to verify most of the above.
---
.../range-area-line-combo-monotoneCubic.html | 308 ++++++++++++++++++
.../range-area-line-combo-monotoneCubic.xml | 217 ++++++++++++
.../range-area-line-combo-monotoneCubic.html | 275 ++++++++++++++++
.../range-area-line-combo-monotoneCubic.html | 294 +++++++++++++++++
src/charts/Line.js | 235 ++++++++-----
src/libs/monotone-cubic.js | 2 +-
.../range-area-line-combo-monotoneCubic.png | Bin 0 -> 35289 bytes
7 files changed, 1249 insertions(+), 82 deletions(-)
create mode 100644 samples/react/rangeArea/range-area-line-combo-monotoneCubic.html
create mode 100644 samples/source/rangeArea/range-area-line-combo-monotoneCubic.xml
create mode 100644 samples/vanilla-js/rangeArea/range-area-line-combo-monotoneCubic.html
create mode 100644 samples/vue/rangeArea/range-area-line-combo-monotoneCubic.html
create mode 100644 tests/e2e/snapshots/rangeArea/range-area-line-combo-monotoneCubic.png
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 0000000000000000000000000000000000000000..edf975e4d6a0b27d5a6a482abc60a651ce773a4e
GIT binary patch
literal 35289
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