From dfec4f4b0a4289d995092be2a5277717fc17114e Mon Sep 17 00:00:00 2001 From: Takahiro Ebato Date: Sun, 19 Apr 2026 16:52:38 +0900 Subject: [PATCH] feat(dataZoom): support per-axis wheel for inside dataZoom --- src/component/dataZoom/InsideZoomModel.ts | 24 ++ src/component/dataZoom/InsideZoomView.ts | 58 ++- src/component/helper/RoamController.ts | 182 ++++++++-- test/dataZoom-inside-wheel-axis.html | 423 ++++++++++++++++++++++ 4 files changed, 654 insertions(+), 33 deletions(-) create mode 100644 test/dataZoom-inside-wheel-axis.html diff --git a/src/component/dataZoom/InsideZoomModel.ts b/src/component/dataZoom/InsideZoomModel.ts index ad417afc87..b9a90a46fa 100644 --- a/src/component/dataZoom/InsideZoomModel.ts +++ b/src/component/dataZoom/InsideZoomModel.ts @@ -19,6 +19,7 @@ import DataZoomModel, {DataZoomOption} from './DataZoomModel'; import { inheritDefaultOption } from '../../util/component'; +import { WheelAxisType } from '../helper/RoamController'; export interface InsideDataZoomOption extends DataZoomOption { @@ -40,6 +41,29 @@ export interface InsideDataZoomOption extends DataZoomOption { preventDefaultMouseMove?: boolean + /** + * Restricts the pan (triggered by `moveOnMouseWheel`) to a single + * wheel axis. Has no effect on zoom — see `zoomOnMouseWheelAxis`. + * + * - Omitted (default): either wheel axis can drive the pan, preserving + * the pre-existing single-`scrollDelta` behavior. + * - `'horizontal'`: only `deltaX` drives the pan. + * - `'vertical'`: only `deltaY` drives the pan. + */ + moveOnMouseWheelAxis?: WheelAxisType + + /** + * Restricts the zoom (triggered by `zoomOnMouseWheel`) to a single + * wheel axis. Has no effect on pan — see `moveOnMouseWheelAxis`. + * + * - Omitted (default): any wheel direction triggers zoom, matching the + * pre-existing behavior where zrender's collapsed `wheelDelta` drives + * the scale factor. + * - `'horizontal'`: only `deltaX` drives the zoom. + * - `'vertical'`: only `deltaY` drives the zoom. + */ + zoomOnMouseWheelAxis?: WheelAxisType + /** * Inside dataZoom don't support textStyle */ diff --git a/src/component/dataZoom/InsideZoomView.ts b/src/component/dataZoom/InsideZoomView.ts index 99b9cfd729..7c7015ee4e 100644 --- a/src/component/dataZoom/InsideZoomView.ts +++ b/src/component/dataZoom/InsideZoomView.ts @@ -24,7 +24,7 @@ import InsideZoomModel from './InsideZoomModel'; import GlobalModel from '../../model/Global'; import ExtensionAPI from '../../core/ExtensionAPI'; import { bind } from 'zrender/src/core/util'; -import RoamController, {RoamEventParams} from '../helper/RoamController'; +import RoamController, {RoamEventParams, WheelAxisType} from '../helper/RoamController'; import { AxisBaseModel } from '../../coord/AxisBaseModel'; import Polar from '../../coord/polar/Polar'; import SingleAxis from '../../coord/single/SingleAxis'; @@ -104,6 +104,14 @@ const getRangeHandlers: { return; } + // `zoomOnMouseWheelAxis` restricts the zoom to one wheel axis; + // unset falls back to the combined `scale` for backward + // compatibility. + const effectiveScale = pickWheelAxisValue( + (this.dataZoomModel as InsideZoomModel).get('zoomOnMouseWheelAxis', true), + e.scaleX, e.scaleY, e.scale + ); + const directionInfo = getDirectionInfo[coordSysMainType]( null, [e.originX, e.originY], axisModel, controller, coordSysInfo ); @@ -113,7 +121,7 @@ const getRangeHandlers: { : (directionInfo.pixel - directionInfo.pixelStart) ) / directionInfo.pixelLength * (range[1] - range[0]) + range[0]; - const scale = Math.max(1 / e.scale, 0); + const scale = Math.max(1 / effectiveScale, 0); range[0] = (range[0] - percentPoint) * scale + percentPoint; range[1] = (range[1] - percentPoint) * scale + percentPoint; @@ -140,19 +148,55 @@ const getRangeHandlers: { }), scrollMove: makeMover( - function (range, axisModel, coordSysInfo, coordSysMainType, controller, e: RoamEventParams['scrollMove'] + function ( + this: InsideZoomView, + range, axisModel, coordSysInfo, coordSysMainType, controller, + e: RoamEventParams['scrollMove'] ) { + // `moveOnMouseWheelAxis` restricts the pan to one wheel axis; + // unset falls back to the combined `scrollDelta` for backward + // compatibility. + const effectiveDelta = pickWheelAxisValue( + (this.dataZoomModel as InsideZoomModel).get('moveOnMouseWheelAxis', true), + e.scrollDeltaX, e.scrollDeltaY, e.scrollDelta + ); const directionInfo = getDirectionInfo[coordSysMainType]( - [0, 0], [e.scrollDelta, e.scrollDelta], axisModel, controller, coordSysInfo + [0, 0], [effectiveDelta, effectiveDelta], + axisModel, controller, coordSysInfo ); - return directionInfo.signal * (range[1] - range[0]) * e.scrollDelta; + // Only `directionInfo.signal` is used here — the scalar already + // encodes the magnitude. `directionInfo.pixel` would go through + // `pointToCoord` for polar and no longer match the scalar. + return directionInfo.signal * (range[1] - range[0]) * effectiveDelta; }) }; +/** + * Picks the wheel-derived value for this dataZoom given a + * `moveOnMouseWheelAxis` / `zoomOnMouseWheelAxis` setting. Unset falls + * back to the caller-supplied scalar so existing configurations keep + * their pre-existing behavior. + */ +function pickWheelAxisValue( + wheelAxis: WheelAxisType | undefined, + axisHorizontal: number, + axisVertical: number, + fallback: number +): number { + if (wheelAxis === 'horizontal') { + return axisHorizontal; + } + if (wheelAxis === 'vertical') { + return axisVertical; + } + return fallback; +} + export type DataZoomGetRangeHandlers = typeof getRangeHandlers; function makeMover( getPercentDelta: ( + this: InsideZoomView, range: [number, number], axisModel: AxisBaseModel, coordSysInfo: DataZoomReferCoordSysInfo, @@ -177,8 +221,8 @@ function makeMover( return; } - const percentDelta = getPercentDelta( - range, axisModel, coordSysInfo, coordSysMainType, controller, e + const percentDelta = getPercentDelta.call( + this, range, axisModel, coordSysInfo, coordSysMainType, controller, e ); sliderMove(percentDelta, range, [0, 100], 'all'); diff --git a/src/component/helper/RoamController.ts b/src/component/helper/RoamController.ts index 4c057412e5..b0525a4cc7 100644 --- a/src/component/helper/RoamController.ts +++ b/src/component/helper/RoamController.ts @@ -69,14 +69,44 @@ type RoamBehavior = 'zoomOnMouseWheel' | 'moveOnMouseMove' | 'moveOnMouseWheel'; export interface RoamEventParams { 'zoom': { + /** + * Zoom factor derived from zrender's scalar `wheelDelta` + * (Y-priority via its polyfill). Axis-aware consumers can read + * `scaleX` / `scaleY` instead. + */ scale: number + /** + * Zoom factor derived from the horizontal wheel component. `1` + * (identity) when the wheel event carries no horizontal delta. + */ + scaleX: number + /** + * Zoom factor derived from the vertical wheel component. `1` + * (identity) when the wheel event carries no vertical delta. + */ + scaleY: number originX: number originY: number isAvailableBehavior: Bind3 } 'scrollMove': { + /** + * Pan magnitude (fraction of range) derived from zrender's scalar + * `wheelDelta` (Y-priority via its polyfill). Axis-aware consumers + * can read `scrollDeltaX` / `scrollDeltaY` instead. + */ scrollDelta: number + /** + * Pan magnitude derived from the horizontal wheel component. `0` + * when the wheel event carries no horizontal delta. + */ + scrollDeltaX: number + /** + * Pan magnitude derived from the vertical wheel component. `0` + * when the wheel event carries no vertical delta. + */ + scrollDeltaY: number originX: number originY: number @@ -362,43 +392,43 @@ class RoamController extends Eventful { const shouldZoom = isAvailableBehavior('zoomOnMouseWheel', e, this._opt); const shouldMove = isAvailableBehavior('moveOnMouseWheel', e, this._opt); const wheelDelta = e.wheelDelta; - const absWheelDeltaDelta = Math.abs(wheelDelta); - const originX = e.offsetX; - const originY = e.offsetY; - // wheelDelta maybe -0 in chrome mac. if (wheelDelta === 0 || (!shouldZoom && !shouldMove)) { return; } - // If both `shouldZoom` and `shouldMove` is true, trigger - // their event both, and the final behavior is determined - // by event listener themselves. + // Reach through to the native WheelEvent for per-axis deltas; + // zrender's `wheelDelta` collapses the two axes into a single scalar. + // Pre-2013 IE has no `deltaY`; the axis helpers fall back to the + // passed default in that case. + const nativeEvent = (e.event as unknown as WheelEvent) || null; + const originX = e.offsetX; + const originY = e.offsetY; if (shouldZoom) { - // Convenience: - // Mac and VM Windows on Mac: scroll up: zoom out. - // Windows: scroll up: zoom in. - - // FIXME: Should do more test in different environment. - // wheelDelta is too complicated in difference nvironment - // (https://developer.mozilla.org/en-US/docs/Web/Events/mousewheel), - // although it has been normallized by zrender. - // wheelDelta of mouse wheel is bigger than touch pad. - const factor = absWheelDeltaDelta > 3 ? 1.4 : absWheelDeltaDelta > 1 ? 1.2 : 1.1; - const scale = wheelDelta > 0 ? factor : 1 / factor; + // `scaleY` defaults to `scale` on the IE fallback so the single + // scalar still drives zoom; `scaleX` defaults to identity so + // `zoomOnMouseWheelAxis: 'horizontal'` becomes a safe no-op. + const scale = ladderZoomScale(wheelDelta); this._checkTriggerMoveZoom(this, 'zoom', 'zoomOnMouseWheel', e, { - scale: scale, originX: originX, originY: originY, isAvailableBehavior: null + scale: scale, + scaleX: axisZoomScale(nativeEvent, 'horizontal', 1), + scaleY: axisZoomScale(nativeEvent, 'vertical', scale), + originX: originX, originY: originY, isAvailableBehavior: null }); } if (shouldMove) { - // FIXME: Should do more test in different environment. - const absDelta = Math.abs(wheelDelta); - // wheelDelta of mouse wheel is bigger than touch pad. - const scrollDelta = (wheelDelta > 0 ? 1 : -1) * (absDelta > 3 ? 0.4 : absDelta > 1 ? 0.15 : 0.05); + // `scrollDelta` stays derived from the scalar `wheelDelta` so + // pre-existing listeners see bit-identical values. Per-axis + // fields let a horizontal wheel (or a diagonal trackpad swipe) + // drive the matching axis independently. + const scrollDelta = ladderScrollDelta(wheelDelta); this._checkTriggerMoveZoom(this, 'scrollMove', 'moveOnMouseWheel', e, { - scrollDelta: scrollDelta, originX: originX, originY: originY, isAvailableBehavior: null + scrollDelta: scrollDelta, + scrollDeltaX: axisScrollDelta(nativeEvent, 'horizontal', 0), + scrollDeltaY: axisScrollDelta(nativeEvent, 'vertical', scrollDelta), + originX: originX, originY: originY, isAvailableBehavior: null }); } } @@ -410,8 +440,13 @@ class RoamController extends Eventful { return; } const scale = e.pinchScale > 1 ? 1.1 : 1 / 1.1; + // Touch pinch is a 2D gesture without a distinct horizontal/vertical + // component, so mirror the combined `scale` into both per-axis fields. this._checkTriggerMoveZoom(this, 'zoom', null, e, { - scale: scale, originX: e.pinchX, originY: e.pinchY, isAvailableBehavior: null + scale: scale, + scaleX: scale, + scaleY: scale, + originX: e.pinchX, originY: e.pinchY, isAvailableBehavior: null }); } @@ -442,6 +477,101 @@ function eventConsumed(e: ZRElementEvent): boolean { return (e as RoamControllerZREventExtend).__ecRoamConsumed; } +/** + * Wheel input axis name. Shared by the user-facing + * `moveOnMouseWheelAxis` / `zoomOnMouseWheelAxis` options and by the + * internal wheel-delta helpers. + */ +export type WheelAxisType = 'horizontal' | 'vertical'; + +// Legacy WebKit exposes these per-axis siblings alongside `wheelDelta`; prefer +// them when present because they preserve the exact per-notch scaling the +// IE-style wheel APIs used. +interface WheelEventWithLegacyPerAxis extends WheelEvent { + wheelDeltaX?: number + wheelDeltaY?: number +} + +/** + * Per-axis equivalent of zrender's `getWheelDeltaMayPolyfill` + `/120` + * normalization. Returns a value on the same scale as `wheelDelta` + * (≈ ±1 per mouse-wheel notch, much smaller on trackpads), or 0 when the + * axis carries no input. + */ +function axisWheelDelta( + nativeEvent: WheelEvent, axis: WheelAxisType +): number { + const legacy = axis === 'horizontal' + ? (nativeEvent as WheelEventWithLegacyPerAxis).wheelDeltaX + : (nativeEvent as WheelEventWithLegacyPerAxis).wheelDeltaY; + if (typeof legacy === 'number' && legacy !== 0) { + return legacy / 120; + } + const raw = axis === 'horizontal' ? nativeEvent.deltaX : nativeEvent.deltaY; + if (raw == null || raw === 0) { + return 0; + } + // Flip sign so positive == scroll up / left, matching the + // `wheelDelta` convention the downstream code already expects. + return -raw * 3 / 120; +} + +// FIXME: Should do more test in different environments. +// `wheelDelta` is too complicated across environments +// (https://developer.mozilla.org/en-US/docs/Web/Events/mousewheel), +// although it has been normalized by zrender. `wheelDelta` of mouse +// wheel is bigger than touch pad. + +/** Maps a `wheelDelta`-scaled value to a pan magnitude (fraction of range). */ +function ladderScrollDelta(wheelDelta: number): number { + if (wheelDelta === 0) { + return 0; + } + const absDelta = Math.abs(wheelDelta); + return (wheelDelta > 0 ? 1 : -1) + * (absDelta > 3 ? 0.4 : absDelta > 1 ? 0.15 : 0.05); +} + +/** Maps a `wheelDelta`-scaled value to a zoom scale (1 == identity). */ +function ladderZoomScale(wheelDelta: number): number { + if (wheelDelta === 0) { + return 1; + } + const absDelta = Math.abs(wheelDelta); + const factor = absDelta > 3 ? 1.4 : absDelta > 1 ? 1.2 : 1.1; + return wheelDelta > 0 ? factor : 1 / factor; +} + +/** + * Per-axis pan magnitude; returns `fallback` when the event lacks + * per-axis info (effectively only pre-2013 IE). + */ +function axisScrollDelta( + nativeEvent: WheelEvent | null, + axis: WheelAxisType, + fallback: number +): number { + if (!nativeEvent || nativeEvent.deltaY == null) { + return fallback; + } + return ladderScrollDelta(axisWheelDelta(nativeEvent, axis)); +} + +/** + * Per-axis zoom scale; returns `fallback` when the event lacks per-axis + * info (effectively only pre-2013 IE). + */ +function axisZoomScale( + nativeEvent: WheelEvent | null, + axis: WheelAxisType, + fallback: number +): number { + if (!nativeEvent || nativeEvent.deltaY == null) { + return fallback; + } + return ladderZoomScale(axisWheelDelta(nativeEvent, axis)); +} + type RoamControllerZREventListener = (e: ZRElementEvent) => void; type RoamControllerZREventType = 'mousedown' | 'mousemove' | 'mouseup' | 'mousewheel' | 'pinch'; @@ -577,4 +707,4 @@ function isAvailableBehavior( ); } -export default RoamController; \ No newline at end of file +export default RoamController; diff --git a/test/dataZoom-inside-wheel-axis.html b/test/dataZoom-inside-wheel-axis.html new file mode 100644 index 0000000000..2d82209440 --- /dev/null +++ b/test/dataZoom-inside-wheel-axis.html @@ -0,0 +1,423 @@ + + + + + + + + + + + + + + + +
+ Manual check for independent x/y wheel handling on + dataZoom.inside via the + moveOnMouseWheelAxis and + zoomOnMouseWheelAxis options. Pan and zoom behavior + differs per panel — see each section heading and the config shown + to the right of each chart. A physical tilt wheel (or + shift + vertical wheel, which browsers convert to a + horizontal scroll) is useful for exercising the + 'horizontal' cases. +
+
+ + + +