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); + }); +});