From 5d8e7cd0638af5d295d0bb938dfb00ee54797f3a Mon Sep 17 00:00:00 2001 From: JamesGoslings <3248175240@qq.com> Date: Fri, 29 May 2026 21:41:45 +0800 Subject: [PATCH] fix(dataZoom): keep neighbor rows when filtering line series so partial segments and null gaps survive. close #21564, close #21565 When dataZoom uses the default filterMode 'filter', AxisProxy calls selectRange() on the series store, which physically drops every row whose value falls outside the window. For line / area series this corrupts the rendering in two ways: - A line segment that crosses the window boundary loses one of its endpoints, so the visible portion of the segment vanishes entirely (#21564). - A null data point that interrupts the line gets dropped if its own value sits outside the window, so the line view no longer sees the gap and connects across it as a ghost line (#21565). This is most visible when data is non-monotonic in the filter dimension and the null is the only signal that two adjacent rows belong to different segments. Replace selectRange with a boundary-aware filter for line subType only: keep row i when row i, row i-1 or row i+1 is in the window. A single Uint8Array pre-scan computes inWindow status; filterSelf then applies the keep predicate. Other series types and other filter modes are untouched. Adds unit coverage for the partial-segment case, the null-gap case, the bar-series no-op, the filterMode: 'none' no-op, and the all-in-window short-circuit. Adds a visual reproducer at test/line-dataZoom-boundary.html. --- src/component/dataZoom/AxisProxy.ts | 48 +++++ test/line-dataZoom-boundary.html | 137 ++++++++++++++ .../component/dataZoom/lineBoundary.test.ts | 177 ++++++++++++++++++ 3 files changed, 362 insertions(+) create mode 100644 test/line-dataZoom-boundary.html create mode 100644 test/ut/spec/component/dataZoom/lineBoundary.test.ts diff --git a/src/component/dataZoom/AxisProxy.ts b/src/component/dataZoom/AxisProxy.ts index 4f2c60d386..816fdd9cea 100644 --- a/src/component/dataZoom/AxisProxy.ts +++ b/src/component/dataZoom/AxisProxy.ts @@ -450,6 +450,13 @@ class AxisProxy { }) ); } + else if (shouldKeepLineBoundary(seriesModel)) { + // Line / area series need one neighboring data row on each side + // of the in-window range so partial line segments at the + // boundary are still drawn (#21564) and null gap markers that + // fall outside the window still interrupt the line (#21565). + keepLineBoundary(seriesData, dim, valueWindow); + } else { const range: Dictionary<[number, number]> = {}; range[dim] = valueWindow as [number, number]; @@ -499,4 +506,45 @@ class AxisProxy { } } +function shouldKeepLineBoundary(seriesModel: SeriesModel): boolean { + return seriesModel.subType === 'line'; +} + +function keepLineBoundary( + seriesData: ReturnType, + dim: string, + valueWindow: number[] +): void { + const store = seriesData.getStore(); + const dimIdx = seriesData.getDimensionIndex(dim); + const count = store.count(); + if (!count || dimIdx < 0) { + return; + } + + const min = valueWindow[0]; + const max = valueWindow[1]; + const inWindow = new Uint8Array(count); + let allIn = true; + for (let i = 0; i < count; i++) { + const v = store.get(dimIdx, i) as number; + const isIn = ((v >= min && v <= max) || isNaN(v)) ? 1 : 0; + inWindow[i] = isIn; + if (!isIn) { + allIn = false; + } + } + if (allIn) { + return; + } + + seriesData.filterSelf(function (idx: number) { + return !!( + inWindow[idx] + || (idx > 0 && inWindow[idx - 1]) + || (idx < count - 1 && inWindow[idx + 1]) + ); + }); +} + export default AxisProxy; diff --git a/test/line-dataZoom-boundary.html b/test/line-dataZoom-boundary.html new file mode 100644 index 0000000000..09590ff77e --- /dev/null +++ b/test/line-dataZoom-boundary.html @@ -0,0 +1,137 @@ + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + + diff --git a/test/ut/spec/component/dataZoom/lineBoundary.test.ts b/test/ut/spec/component/dataZoom/lineBoundary.test.ts new file mode 100644 index 0000000000..76d338f989 --- /dev/null +++ b/test/ut/spec/component/dataZoom/lineBoundary.test.ts @@ -0,0 +1,177 @@ +/* +* Licensed to the Apache Software Foundation (ASF) under one +* or more contributor license agreements. See the NOTICE file +* distributed with this work for additional information +* regarding copyright ownership. The ASF licenses this file +* to you under the Apache License, Version 2.0 (the +* "License"); you may not use this file except in compliance +* with the License. You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, +* software distributed under the License is distributed on an +* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +* KIND, either express or implied. See the License for the +* specific language governing permissions and limitations +* under the License. +*/ + +import { EChartsType } from '@/src/echarts'; +import { createChart, getECModel } from '../../../core/utHelper'; + +function getRawIndices(chart: EChartsType, seriesIndex = 0): number[] { + const data = getECModel(chart).getSeries()[seriesIndex].getData(); + const out: number[] = []; + for (let i = 0; i < data.count(); i++) { + out.push(data.getRawIndex(i)); + } + return out; +} + +describe('dataZoom/lineBoundary', function () { + + let chart: EChartsType; + beforeEach(function () { + chart = createChart(); + }); + afterEach(function () { + chart.dispose(); + }); + + // https://github.com/apache/echarts/issues/21564 + it('keeps one neighbor on each side so partial line segments still draw', function () { + chart.setOption({ + xAxis: { + type: 'category', + data: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19] + }, + yAxis: { type: 'value' }, + dataZoom: [{ + type: 'inside', + xAxisIndex: 0, + startValue: 3, + endValue: 13 + }], + series: [{ + type: 'line', + data: [ + [0, 5], + [2, 6], + [8, 5], + [12, 5], + [16, 6] + ] + }] + }); + + // Window covers categories [3, 13]. Raw rows in window: 2 (x=8), 3 (x=12). + // The fix also keeps row 1 (x=2, predecessor of first in-window row) + // and row 4 (x=16, successor of last in-window row), so the line view + // can render the partial segments crossing the window boundary. + expect(getRawIndices(chart)).toEqual([1, 2, 3, 4]); + }); + + // https://github.com/apache/echarts/issues/21565 + it('keeps a null gap row whose neighbor in raw order is in the window', function () { + chart.setOption({ + xAxis: { + type: 'category', + data: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14] + }, + yAxis: { type: 'value' }, + dataZoom: [{ + type: 'inside', + xAxisIndex: 0, + startValue: 3, + endValue: 10 + }], + series: [{ + type: 'line', + data: [ + [4, 5], + [7, 5], + [11, 5], + [11, null], + [6, 6], + [9, 6] + ] + }] + }); + + // Window covers categories [3, 10]. Raw rows whose x is in window: + // 0 (x=4), 1 (x=7), 4 (x=6), 5 (x=9). Without the fix, row 3 (x=11, + // y=null) would be dropped and the line would jump from row 1 (7,5) + // to row 4 (6,6), producing a ghost line. The fix keeps row 3 because + // its raw-order successor (row 4) is in the window, and also keeps + // row 2 (x=11) because its predecessor (row 1) is in the window. + expect(getRawIndices(chart)).toEqual([0, 1, 2, 3, 4, 5]); + }); + + it('does not affect bar series (boundary keeping is line-only)', function () { + chart.setOption({ + xAxis: { type: 'category', data: [0, 1, 2, 3, 4] }, + yAxis: { type: 'value' }, + dataZoom: [{ + type: 'inside', + xAxisIndex: 0, + startValue: 1, + endValue: 3 + }], + series: [{ + type: 'bar', + data: [ + [0, 5], + [1, 5], + [2, 5], + [3, 5], + [4, 5] + ] + }] + }); + + expect(getRawIndices(chart)).toEqual([1, 2, 3]); + }); + + it('does not change behavior when filterMode is not "filter"', function () { + chart.setOption({ + xAxis: { type: 'category', data: [0, 1, 2, 3, 4, 5, 6] }, + yAxis: { type: 'value' }, + dataZoom: [{ + type: 'inside', + xAxisIndex: 0, + startValue: 2, + endValue: 4, + filterMode: 'none' + }], + series: [{ + type: 'line', + data: [ + [0, 5], [1, 5], [2, 5], [3, 5], [4, 5], [5, 5], [6, 5] + ] + }] + }); + + // filterMode 'none' keeps every row. + expect(getRawIndices(chart)).toEqual([0, 1, 2, 3, 4, 5, 6]); + }); + + it('keeps no extra neighbors when every row is in the window', function () { + chart.setOption({ + xAxis: { type: 'category', data: [0, 1, 2, 3, 4] }, + yAxis: { type: 'value' }, + dataZoom: [{ + type: 'inside', + xAxisIndex: 0, + startValue: 0, + endValue: 4 + }], + series: [{ + type: 'line', + data: [[0, 5], [1, 5], [2, 5], [3, 5], [4, 5]] + }] + }); + + expect(getRawIndices(chart)).toEqual([0, 1, 2, 3, 4]); + }); +});