From bf5e37cc8fc352025294ff522dbb504bb132f67c Mon Sep 17 00:00:00 2001 From: JamesGoslings <3248175240@qq.com> Date: Wed, 13 May 2026 21:06:29 +0800 Subject: [PATCH] fix(visualMap): emit boundary stops for half-infinite piecewise pieces, harden line gradient. close #18066 When a piecewise visualMap piece has no upper or lower bound (e.g. `{lte: null}` or `{lte: 10}` with implicit out-of-range), the piece interval is `[-Infinity, x]`, `[x, Infinity]` or `[-Infinity, Infinity]`. Previously `PiecewiseModel.getVisualMeta` only wrote `outerColors` and produced an empty `stops` array for all three. The line series renderer then either crashed at `colorStopsInRange[0].coord` or rendered with a single fallback color, losing the finite boundary. Fix the root cause in `PiecewiseModel.setStop`: - For half-infinite intervals (`[-Infinity, x]` or `[x, Infinity]`), set the corresponding `outerColors` entry as before AND emit the finite edge `x` as a stop so consumers can locate the boundary. - For fully-infinite `[-Infinity, Infinity]` intervals there is no finite edge to record, so only `outerColors` is set on both sides. In `LineView.getVisualGradient`: - Keep a defensive empty-stop fallback for the truly fully-infinite case so the chart renders a solid color instead of crashing. - Fix the reversal heuristic: when stops collapse to a single coord (e.g. two boundary stops at the same value, which is exactly what a single half-infinite piece produces) the coord comparison gives no signal, so fall back to `axis.inverse` to decide whether to flip on an inverted axis. Adds three tests: - `piecewiseWithNullBoundOnLineSeries`: original repro, asserts `setOption` does not throw. - `piecewiseHalfInfiniteEmitsBoundaryStop`: asserts the finite edge of `{lte: 10}` plus its implicit `(10, Infinity)` outOfRange piece both end up as stops at value `10`. - `piecewiseFullInfiniteStillEmitsNoStops`: asserts the fully infinite case still produces an empty `stops` array (defensive LineView fallback path). --- src/chart/line/LineView.ts | 16 +++- src/component/visualMap/PiecewiseModel.ts | 20 ++++- .../component/visualMap/setOption.test.ts | 89 +++++++++++++++++++ 3 files changed, 122 insertions(+), 3 deletions(-) diff --git a/src/chart/line/LineView.ts b/src/chart/line/LineView.ts index 847a876495..81b6d69952 100644 --- a/src/chart/line/LineView.ts +++ b/src/chart/line/LineView.ts @@ -323,7 +323,14 @@ function getVisualGradient( const stopLen = colorStops.length; const outerColors = visualMeta.outerColors.slice(); - if (stopLen && colorStops[0].coord > colorStops[stopLen - 1].coord) { + if (stopLen && ( + colorStops[0].coord > colorStops[stopLen - 1].coord + // When stops collapse to the same coord (e.g. a piecewise visualMap with + // a single half-infinite piece produces two boundary stops at the same + // value), the coord comparison gives no signal — fall back to the axis + // direction so we still flip on an inverted axis. See #18066. + || (colorStops[0].coord === colorStops[stopLen - 1].coord && axis.inverse) + )) { colorStops.reverse(); outerColors.reverse(); } @@ -337,6 +344,13 @@ function getVisualGradient( ? (outerColors[1] ? outerColors[1] : colorStops[stopLen - 1].color) : (outerColors[0] ? outerColors[0] : colorStops[0].color); } + if (!inRangeStopLen) { + // No color stops at all (e.g. a visualMap piecewise piece producing + // a fully [-Infinity, Infinity] interval, like `pieces: [{ lte: null }]`). + // Fall back to a single outer color or transparent so we don't crash + // on `colorStopsInRange[0].coord` below. See #18066. + return outerColors[0] || outerColors[1] || 'transparent'; + } const tinyExtent = 10; // Arbitrary value: 10px const minCoord = colorStopsInRange[0].coord - tinyExtent; diff --git a/src/component/visualMap/PiecewiseModel.ts b/src/component/visualMap/PiecewiseModel.ts index ef5913a62a..75e2391d34 100644 --- a/src/component/visualMap/PiecewiseModel.ts +++ b/src/component/visualMap/PiecewiseModel.ts @@ -370,12 +370,28 @@ class PiecewiseModel extends VisualMapModel { valueState = visualMapModel.getValueState(representValue); } const color = getColorVisual(representValue, valueState); - if (interval[0] === -Infinity) { + const lowInf = interval[0] === -Infinity; + const highInf = interval[1] === Infinity; + if (lowInf) { outerColors[0] = color; } - else if (interval[1] === Infinity) { + if (highInf) { outerColors[1] = color; } + // Emit the finite edge(s) as stops as well, so consumers know where + // a half-infinite interval ends (e.g. `pieces: [{lte: 10}]` should + // still report a boundary at 10). When the interval is fully infinite + // there is no finite edge to record, so only `outerColors` is set. + // See #18066. + if (lowInf && highInf) { + return; + } + if (lowInf) { + stops.push({value: interval[1], color: color}); + } + else if (highInf) { + stops.push({value: interval[0], color: color}); + } else { stops.push( {value: interval[0], color: color}, diff --git a/test/ut/spec/component/visualMap/setOption.test.ts b/test/ut/spec/component/visualMap/setOption.test.ts index 05d9c58c82..e5c9947d12 100755 --- a/test/ut/spec/component/visualMap/setOption.test.ts +++ b/test/ut/spec/component/visualMap/setOption.test.ts @@ -287,4 +287,93 @@ describe('vsiaulMap_setOption', function () { done(); }); + // See https://github.com/apache/echarts/issues/18066 + it('piecewiseWithNullBoundOnLineSeries', function (done) { + // Setting `lte: null` (or omitting both bounds) makes the piece + // interval [-Infinity, Infinity], which previously crashed + // line series rendering with + // "Cannot read properties of undefined (reading 'coord')". + expect(function () { + chart.setOption({ + xAxis: {type: 'category', data: ['A', 'B', 'C', 'D']}, + yAxis: {type: 'value'}, + visualMap: { + type: 'piecewise', + pieces: [ + // lte set to null should be treated as no upper bound. + {lte: null, color: 'red'} + ] + }, + series: [{ + type: 'line', + data: [10, 20, 30, 40] + }] + }); + }).not.toThrow(); + done(); + }); + + // See https://github.com/apache/echarts/issues/18066 review feedback. + it('piecewiseHalfInfiniteEmitsBoundaryStop', function () { + chart.setOption({ + xAxis: {type: 'category', data: ['A', 'B', 'C', 'D']}, + yAxis: {type: 'value'}, + visualMap: { + type: 'piecewise', + pieces: [ + {lte: 10, color: 'red'} + ], + outOfRange: { color: 'blue' } + } as PiecewiseVisualMapOption, + series: [{ + type: 'line', + data: [5, 8, 15, 20] + }] + }); + + const visualMapModel = (chart as any).getModel().getComponent('visualMap', 0) as VisualMapModel; + const visualMeta = (visualMapModel as any).getVisualMeta( + (val: number) => visualMapModel.getValueState(val) === 'inRange' ? 'red' : 'blue' + ); + + // The half-infinite [-Infinity, 10] piece must record the finite edge + // at 10 in `stops` (not just in `outerColors`), so consumers can locate + // the boundary. + const valuesAtTen = visualMeta.stops.filter((s: any) => s.value === 10); + expect(valuesAtTen.length).toBeGreaterThanOrEqual(1); + const colorsAtTen = valuesAtTen.map((s: any) => s.color); + expect(colorsAtTen).toContain('red'); + // The implicit out-of-range piece for (10, Infinity) should also emit + // its finite left edge. + expect(colorsAtTen).toContain('blue'); + }); + + it('piecewiseFullInfiniteStillEmitsNoStops', function () { + chart.setOption({ + xAxis: {type: 'category', data: ['A', 'B', 'C', 'D']}, + yAxis: {type: 'value'}, + visualMap: { + type: 'piecewise', + pieces: [ + {lte: null, color: 'red'} + ] + } as PiecewiseVisualMapOption, + series: [{ + type: 'line', + data: [10, 20, 30, 40] + }] + }); + + const visualMapModel = (chart as any).getModel().getComponent('visualMap', 0) as VisualMapModel; + const visualMeta = (visualMapModel as any).getVisualMeta( + () => 'red' + ); + + // Truly fully infinite intervals have no finite edge to record, so + // `stops` stays empty and the LineView defensive fallback handles it. + expect(visualMeta.stops).toEqual([]); + expect(visualMeta.outerColors[0]).toBe('red'); + expect(visualMeta.outerColors[1]).toBe('red'); + }); + }); \ No newline at end of file