Skip to content

fix(line): avoid crash when visualMap piecewise produces no in-range stops#21613

Open
JamesGoslings wants to merge 1 commit into
apache:masterfrom
JamesGoslings:fix/18066-visualmap-pieces-null-lte
Open

fix(line): avoid crash when visualMap piecewise produces no in-range stops#21613
JamesGoslings wants to merge 1 commit into
apache:masterfrom
JamesGoslings:fix/18066-visualmap-pieces-null-lte

Conversation

@JamesGoslings
Copy link
Copy Markdown

Brief Information

This pull request is in the type of:

  • bug fixing
  • new feature
  • others

What does this PR do?

Fix a crash in line series when a visualMap piecewise piece has no upper / lower bound (e.g. { lte: null }), which previously made colorStopsInRange empty and caused Cannot read properties of undefined (reading 'coord').

Fixed issues

Details

Before: What was the problem?

When a visualMap of type piecewise has a piece without an upper bound (or both bounds missing), e.g.:

visualMap: {
  type: 'piecewise',
  pieces: [{ lte: null, color: 'red' }]
}

PiecewiseModel falls back the missing bound to Infinity / -Infinity, so the piece interval becomes [-Infinity, Infinity]. In PiecewiseModel.getVisualMeta, the setStop helper only fills outerColors (never stops) when an interval edge is ±Infinity. As a result the line series renderer receives an empty colorStopsInRange array and crashes at LineView.ts:350:

Uncaught TypeError: Cannot read properties of undefined (reading 'coord')
    at colorStopsInRange[0].coord - tinyExtent

Reproduction from the issue:
https://echarts.apache.org/examples/en/editor.html?c=line-simple&code=PYBwLglsB2AEC8sDeAoWsCeBBAHhAzgFywDaa6y5FsYGIApsQOQBuAhgDYCu9TANFVgBfcgF0B6HLgLEy1VNXS0GzAMZsw9AObAAThn6CR6ceQ7b60ACbEFFfAAtgAd2IAzTvnrkhE2Ft0IG2RfcjBgYA5IEFtQ9C9A-iJSQTtqZUZYJg4IaF4_amg2AFtMphBdYDcIMENqY1hTdCsNNi8wWVTBZohS6HwoftkmDHo2XX4siqqapibFfGAuXVVMuUV0NI3MMd1mACYABgBGQ7rt9HLK6triU8OCxTjtrY3R8YOT4_OLq5nb2BHB7dCjPDavRTvPZZI7HfY_bZ_G5MO4AVmBFzBigh1Chn2OAGYERskbNiATDhjtg1qKIjGI_CwCFxOABZNgxSjUEAQeirZLreQg9BaTTEKkbKKZaBcDgcR7UVSRPTMABG3F4IJpjUe4U5p0eel50A6WScgQAXjAwJwEeY3KamKsTfQJn4REIANxAA

After: How does it behave after the fixing?

In getVisualGradient of src/chart/line/LineView.ts, add a guard for the case where colorStopsInRange is empty (and stopLen is also 0, which the existing !inRangeStopLen && stopLen branch does not cover). Fall back to outerColors[0] || outerColors[1] || 'transparent' so the chart renders with a single fallback color instead of crashing.

The new branch is only hit when there are no color stops at all, so existing behaviors are unchanged.

Added a regression test piecewiseWithNullBoundOnLineSeries in test/ut/spec/component/visualMap/setOption.test.ts that reproduces the original report and asserts setOption does not throw.

Verification:

  • Without the fix, the new test fails with the exact stack from the issue (LineView.ts:350).
  • With the fix, the new test passes.
  • All existing unit tests still pass: Test Suites: 24 passed, 24 total; Tests: 186 passed, 186 total.

Document Info

One of the following should be checked.

  • This PR doesn't relate to document changes
  • The document should be updated later
  • The document changes have been made in apache/echarts-doc#xxx

Misc

Security Checking

  • This PR uses security-sensitive Web APIs.

ZRender Changes

  • This PR depends on ZRender changes (ecomfe/zrender#xxx).

Related test cases or examples to use the new APIs

N.A.

Merging options

  • Please squash the commits into a single one when merging.

Other information

@echarts-bot
Copy link
Copy Markdown

echarts-bot Bot commented May 13, 2026

Thanks for your contribution!
The community will review it ASAP. In the meanwhile, please checkout the coding standard and Wiki about How to make a pull request.

Please DO NOT commit the files in dist, i18n, and ssr/client/dist folders in a non-release pull request. These folders are for release use only.

@Justin-ZS
Copy link
Copy Markdown
Contributor

Thanks for the fix. This does avoid the crash for pieces: [{ lte: null }], but I think it is fixing the symptom in LineView rather than the root cause in PiecewiseModel.getVisualMeta.

A remaining problematic case is:

visualMap: {
  type: 'piecewise',
  pieces: [{ lte: 10, color: 'red' }],
  outOfRange: { color: 'blue' }
}

With the current PR, visualMeta.stops is still empty and only outerColors is set. So line rendering falls back to a single color, losing the finite boundary at 10; the line should be red for <= 10 and blue after that.

Suggested direction:

  • Fix PiecewiseModel.getVisualMeta so half-infinite intervals also emit their finite edge into stops, e.g. [{ value: 10, color: 'red' }, { value: 10, color: outOfRangeColor }].
  • Keep a defensive empty-stop fallback in LineView for the true full-range case like [-Infinity, Infinity].
  • If duplicate same-value stops are emitted, LineView should decide whether to reverse colors from the axis direction, not from colorStops[0].coord > colorStops[last].coord, because duplicate boundary stops have the same coord.

This keeps the responsibility aligned: visualMap produces correct visualMeta, and line only consumes it defensively.

…s, harden line gradient. close apache#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).
@JamesGoslings JamesGoslings force-pushed the fix/18066-visualmap-pieces-null-lte branch from ac6ab3a to bf5e37c Compare June 2, 2026 08:54
@pull-request-size pull-request-size Bot added size/M and removed size/XS labels Jun 2, 2026
@JamesGoslings
Copy link
Copy Markdown
Author

Thanks for the careful review @Justin-ZS — agreed, fixing it in LineView was treating the symptom. I've pushed a follow-up that addresses all three points:

1. Root cause in PiecewiseModel.getVisualMeta. setStop now emits the finite edge of half-infinite intervals into stops, in addition to outerColors:

  • [-Infinity, x] color C → outerColors[0] = C and stops.push({value: x, color: C})
  • [x, Infinity] color C → outerColors[1] = C and stops.push({value: x, color: C})
  • [-Infinity, Infinity] color C → only outerColors (no finite edge to record)

For your example pieces: [{ lte: 10, color: 'red' }], outOfRange: { color: 'blue' }, after Suplement the iteration walks [-Infinity, 10] (red) → [10, Infinity] (blue), so stops now ends up as [{10, red}, {10, blue}]. The line correctly renders red ≤ 10 and blue after.

2. Defensive empty-stop fallback in LineView for the truly fully-infinite case. Kept it. Updated the comment to say it's specifically for [-Infinity, Infinity] intervals like pieces: [{ lte: null }], since the half-infinite case is now handled at the source.

3. Reversal heuristic in LineView. When all stops collapse to one coord (which is exactly what a single half-infinite piece produces — both stops are at the same value), colorStops[0].coord > colorStops[stopLen - 1].coord gives no signal. Fall back to axis.inverse as the tiebreaker:

if (stopLen && (
    colorStops[0].coord > colorStops[stopLen - 1].coord
    || (colorStops[0].coord === colorStops[stopLen - 1].coord && axis.inverse)
)) {
    colorStops.reverse();
    outerColors.reverse();
}

Stops are emitted in value-ascending order by both PiecewiseModel and ContinuousModel, so on a non-inverted axis no reversal is correct in the tied-coord case. On an inverted axis the gradient direction needs to flip, which axis.inverse now picks up.

Tests. Three cases now:

  • piecewiseWithNullBoundOnLineSeries: original crash repro (defensive path).
  • 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, exercising the defensive fallback.

Full unit suite still green (25 suites, 195 tests). Force-pushed the branch as a single squashable commit. PTAL when you have a moment.

@Justin-ZS
Copy link
Copy Markdown
Contributor

Thanks for updating this. The PiecewiseModel.getVisualMeta direction looks much better now.

I think the new tied-coord reversal fallback in LineView still has an issue though:

colorStops[0].coord === colorStops[stopLen - 1].coord && axis.inverse

axis.inverse is not the same as "data value increases in the same direction as global pixel coord". For a normal y axis, axis.inverse is false, but global y pixel coordinates are still reversed: lower values are lower on screen (larger y), higher values are higher on screen (smaller y). So for duplicate boundary stops like {lte: 10}, the normal y-axis case still needs to reverse; otherwise the line renders the high-value side with the low-value color.

A more robust check is to derive the direction from the axis coord extent after toGlobalCoord, for example:

const coordExtent = axis.getExtent();
const isCoordReversed = axis.toGlobalCoord(coordExtent[0]) > axis.toGlobalCoord(coordExtent[1]);

if (stopLen && isCoordReversed) {
    colorStops.reverse();
    outerColors.reverse();
}

This gives the expected direction for all four basic cases:

  • xAxis normal: no reverse
  • xAxis inverse: reverse
  • yAxis normal: reverse
  • yAxis inverse: no reverse

The current axis.inverse tiebreaker gets the y-axis cases backwards. The added tests validate visualMeta, but they don't assert the actual LineView gradient direction, so this can slip through while the unit suite is still green.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants