From b684b5437575c1e9bf0c18c12526a06548ec884f Mon Sep 17 00:00:00 2001 From: JamesGoslings <3248175240@qq.com> Date: Mon, 1 Jun 2026 21:02:39 +0800 Subject: [PATCH] fix(dataZoom): allow wheel zoom-out to expand a collapsed inside dataZoom range. close #21541 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the toolbox dataZoom feature brushes a single category on a category axis, it dispatches startValue === endValue and AxisProxy collapses the percent window to a zero-width range (e.g. [33.33, 33.33] for category index 1 of 4). InsideZoomView.zoom expands the window multiplicatively around the wheel anchor: range[i] = (range[i] - percentPoint) * scale + percentPoint For a collapsed range every term is zero, so wheel-out leaves the range unchanged and the user is stuck looking at the single category they brushed. Detect the collapsed-range case in the zoom handler. When the user is wheel-zooming out (scale > 1), seed the range with a small percent (currently 5%) centered on the wheel anchor before applying the multiplicative expansion. Wheel-in is intentionally left as a no-op on a collapsed range — it cannot collapse the range further. Adds unit coverage for the collapsed-range expand, the collapsed no-op on wheel-in, the unchanged behavior on a non-degenerate range, and the [0, 100] clamp at the edge. Adds a visual reproducer matching the steps in the issue. --- src/component/dataZoom/InsideZoomView.ts | 13 ++- test/dataZoom-wheel-zoom-out-collapsed.html | 91 +++++++++++++++ .../dataZoom/insideZoomViewWheel.test.ts | 105 ++++++++++++++++++ 3 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 test/dataZoom-wheel-zoom-out-collapsed.html create mode 100644 test/ut/spec/component/dataZoom/insideZoomViewWheel.test.ts diff --git a/src/component/dataZoom/InsideZoomView.ts b/src/component/dataZoom/InsideZoomView.ts index 99b9cfd729..a3cb4cb69a 100644 --- a/src/component/dataZoom/InsideZoomView.ts +++ b/src/component/dataZoom/InsideZoomView.ts @@ -88,7 +88,7 @@ interface DataZoomGetRangeHandler< ): [number, number] } -const getRangeHandlers: { +export const getRangeHandlers: { pan: DataZoomGetRangeHandler zoom: DataZoomGetRangeHandler scrollMove: DataZoomGetRangeHandler @@ -114,6 +114,17 @@ const getRangeHandlers: { ) / directionInfo.pixelLength * (range[1] - range[0]) + range[0]; const scale = Math.max(1 / e.scale, 0); + + // When the current range is collapsed to a single point (e.g. the toolbox + // dataZoom brushed exactly one category), multiplicative zoom cannot expand + // a zero-width window. Seed it with a small percent around the wheel anchor + // so wheel-out becomes responsive again. See #21541. + const SEED_HALF_PERCENT = 2.5; + if (range[1] - range[0] < 1e-6 && scale > 1) { + range[0] = percentPoint - SEED_HALF_PERCENT; + range[1] = percentPoint + SEED_HALF_PERCENT; + } + range[0] = (range[0] - percentPoint) * scale + percentPoint; range[1] = (range[1] - percentPoint) * scale + percentPoint; diff --git a/test/dataZoom-wheel-zoom-out-collapsed.html b/test/dataZoom-wheel-zoom-out-collapsed.html new file mode 100644 index 0000000000..3918e85ed9 --- /dev/null +++ b/test/dataZoom-wheel-zoom-out-collapsed.html @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + +
+ + + + + diff --git a/test/ut/spec/component/dataZoom/insideZoomViewWheel.test.ts b/test/ut/spec/component/dataZoom/insideZoomViewWheel.test.ts new file mode 100644 index 0000000000..b6209bdf5d --- /dev/null +++ b/test/ut/spec/component/dataZoom/insideZoomViewWheel.test.ts @@ -0,0 +1,105 @@ +/* +* 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 { getRangeHandlers } from '@/src/component/dataZoom/InsideZoomView'; + +interface ZoomCtx { + range: [number, number]; + dataZoomModel: { + findRepresentativeAxisProxy: () => { getMinMaxSpan: () => Record }; + }; +} + +function makeCtx(range: [number, number]): ZoomCtx { + return { + range, + dataZoomModel: { + findRepresentativeAxisProxy: () => ({ getMinMaxSpan: () => ({}) }) + } + }; +} + +const coordSysInfo = { + model: { + coordinateSystem: { + getRect: () => ({ x: 0, y: 0, width: 400, height: 300 }) + } + }, + axisModels: [{ axis: { dim: 'x', inverse: false } }] +}; + +function callZoom( + ctx: ZoomCtx, + eScale: number, + originX = 200 +): [number, number] | void { + return (getRangeHandlers.zoom as any).call( + ctx, + coordSysInfo, + 'grid', + null, + { scale: eScale, originX, originY: 150, isAvailableBehavior: null } + ); +} + +describe('dataZoom/InsideZoomView wheel zoom', function () { + + // https://github.com/apache/echarts/issues/21541 + it('expands a collapsed range when wheel-zooming out', function () { + const ctx = makeCtx([33.33, 33.33]); + + // Wheel-out: e.scale < 1 → handler scale = 1 / e.scale > 1. + const result = callZoom(ctx, 1 / 1.1) as [number, number]; + + expect(result).toBeDefined(); + expect(result[1] - result[0]).toBeGreaterThan(0); + // The seed expansion is anchored on the wheel position (centered here). + expect((result[0] + result[1]) / 2).toBeCloseTo(33.33, 5); + }); + + it('does not expand a collapsed range when wheel-zooming in', function () { + const ctx = makeCtx([33.33, 33.33]); + const result = callZoom(ctx, 1.1); + // Range did not change, so the handler returns undefined. + expect(result).toBeUndefined(); + expect(ctx.range[0]).toBeCloseTo(33.33, 6); + expect(ctx.range[1]).toBeCloseTo(33.33, 6); + }); + + it('keeps the existing multiplicative behavior on a non-degenerate range', function () { + const ctx = makeCtx([25, 75]); + const before = ctx.range[1] - ctx.range[0]; + const result = callZoom(ctx, 1 / 1.1) as [number, number]; + const after = result[1] - result[0]; + + expect(after).toBeGreaterThan(before); + // Roughly the wheel factor (1.1×). + expect(after / before).toBeCloseTo(1.1, 1); + }); + + it('clamps the seeded range to [0, 100] without crashing near the edge', function () { + const ctx = makeCtx([99.5, 99.5]); + const result = callZoom(ctx, 1 / 1.1, 400) as [number, number]; + + expect(result).toBeDefined(); + expect(result[1] - result[0]).toBeGreaterThan(0); + expect(result[0]).toBeGreaterThanOrEqual(0); + expect(result[1]).toBeLessThanOrEqual(100); + }); +});