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