diff --git a/__tests__/graph/mousewheel.spec.ts b/__tests__/graph/mousewheel.spec.ts index 12e490a9439..03af176cab0 100644 --- a/__tests__/graph/mousewheel.spec.ts +++ b/__tests__/graph/mousewheel.spec.ts @@ -104,4 +104,59 @@ describe('MouseWheel', () => { expect(mockGraph.options.mousewheel.enabled).toBe(false) expect(mockMouseWheelHandle.disable).toHaveBeenCalled() }) + + describe('factorByDelta', () => { + const targetScaleOf = (deltaY: number, accumulated?: number) => { + mockGraph.zoom.mockClear() + const e = new WheelEvent('wheel', { deltaY, clientX: 0, clientY: 0 }) + mouseWheel['onMouseWheel'](e, 0, accumulated) + return mockGraph.zoom.mock.calls[0]?.[0] as number + } + + beforeEach(() => { + mouseWheel.widgetOptions.factorByDelta = true + mouseWheel.widgetOptions.factor = 1.2 + }) + + it('scales the zoom step proportionally to the wheel delta', () => { + // currentScale = 1, factor = 1.2 → targetScale = 1.2 ** (-delta / 100) + expect(targetScaleOf(-100)).toBeCloseTo(1.2, 5) // one full notch = one factor + expect(targetScaleOf(-10)).toBeCloseTo(1.2 ** 0.1, 5) // a small delta = a small step + }) + + it('makes a large delta zoom more than a small delta', () => { + const big = targetScaleOf(-100) + const small = targetScaleOf(-10) + expect(big).toBeGreaterThan(small) + expect(small).toBeGreaterThan(1) // still zooms in + }) + + it('zooms out on positive delta', () => { + expect(targetScaleOf(100)).toBeCloseTo(1.2 ** -1, 5) + }) + + it('prefers the accumulated deltaY batched by MouseWheelHandle', () => { + // event carries -10 but the frame accumulated -100 → the accumulated wins + expect(targetScaleOf(-10, -100)).toBeCloseTo(1.2, 5) + }) + + it('leaves the quantized path untouched when disabled', () => { + mouseWheel.widgetOptions.factorByDelta = false + // classic path: a single event zooms by the fixed >= 5% step, not delta-scaled + expect(targetScaleOf(-10)).toBeCloseTo(1.2, 5) + }) + + it('does not zoom on a non-finite delta (never calls zoom(NaN))', () => { + // a malformed event yields cumulatedFactor 1 → targetScale === currentScale → no zoom + mockGraph.zoom.mockClear() + const e = new WheelEvent('wheel', { clientX: 0, clientY: 0 }) + mouseWheel['onMouseWheel'](e, 0, Number.NaN) + expect(mockGraph.zoom).not.toHaveBeenCalled() + }) + + it('falls back to the default factor when misconfigured negative', () => { + mouseWheel.widgetOptions.factor = -2 + expect(targetScaleOf(-100)).toBeCloseTo(1.2, 5) // uses 1.2, not NaN + }) + }) }) diff --git a/site/docs/api/graph/mousewheel.en.md b/site/docs/api/graph/mousewheel.en.md index 0a241bf864e..453a4a3ec1e 100644 --- a/site/docs/api/graph/mousewheel.en.md +++ b/site/docs/api/graph/mousewheel.en.md @@ -33,6 +33,7 @@ interface MouseWheelOptions { enabled?: boolean global?: boolean factor?: number + factorByDelta?: boolean zoomAtMousePosition?: boolean modifiers?: string | ('alt' | 'ctrl' | 'meta' | 'shift')[] | null guard?: (this: Graph, e: WheelEvent) => boolean @@ -47,6 +48,12 @@ Whether to enable mouse wheel zooming interaction. The zoom factor. Defaults to `1.2`. +### factorByDelta + +Whether to scale the zoom step by the wheel-delta magnitude instead of applying a fixed quantized step per event. Defaults to `false`. + +When `false` (default), every wheel event applies a fixed step (at least 5%), which makes a trackpad pinch — emitting many small high-frequency wheel events — zoom far too fast. When `true`, the zoom step is proportional to the wheel delta: a standard ~100px notch keeps the classic `factor` feel, while small trackpad deltas produce smooth, proportionally small steps. + ### zoomAtMousePosition Whether to zoom in/out at the mouse position. Defaults to `true`. diff --git a/site/docs/api/graph/mousewheel.zh.md b/site/docs/api/graph/mousewheel.zh.md index 3a74e647e08..1e84435d78b 100644 --- a/site/docs/api/graph/mousewheel.zh.md +++ b/site/docs/api/graph/mousewheel.zh.md @@ -33,6 +33,7 @@ interface MouseWheelOptions { enabled?: boolean global?: boolean factor?: number + factorByDelta?: boolean zoomAtMousePosition?: boolean modifiers?: string | ('alt' | 'ctrl' | 'meta' | 'shift')[] | null guard?: (this: Graph, e: WheelEvent) => boolean @@ -47,6 +48,12 @@ interface MouseWheelOptions { 滚动缩放因子。默认为 `1.2`。 +### factorByDelta + +是否按滚轮 delta 的大小来缩放,而不是每个事件都套用固定的缩放步长。默认为 `false`。 + +为 `false`(默认)时,每个 wheel 事件都套用固定步长(至少 5%);触控板捏合会连发大量高频的小 wheel 事件,导致缩放过快。为 `true` 时,缩放步长与滚轮 delta 成正比:约 100px 的标准滚动一格保持原有 `factor` 手感,而触控板的小 delta 则产生平滑、按比例的小步缩放。 + ### zoomAtMousePosition 是否将鼠标位置作为中心缩放,默认为 `true`。 diff --git a/src/graph/mousewheel.ts b/src/graph/mousewheel.ts index 7f565b73ef7..07df1193a42 100644 --- a/src/graph/mousewheel.ts +++ b/src/graph/mousewheel.ts @@ -11,6 +11,17 @@ export interface MouseWheelOptions { modifiers?: string | ModifierKey[] | null guard?: (e: WheelEvent) => boolean zoomAtMousePosition?: boolean + /** + * Scale the zoom step by the wheel-delta magnitude instead of applying a + * fixed quantized step per event. A standard ~100px wheel notch keeps the + * classic `factor` feel, while the many small high-frequency events emitted + * by a trackpad pinch produce proportionally small, smooth zoom steps + * (the default quantized path applies a >= 5% step to every event, which + * makes trackpad pinch zoom far too fast). + * + * Defaults to `false` to preserve the existing behavior. + */ + factorByDelta?: boolean } export class MouseWheel extends Base { @@ -67,7 +78,7 @@ export class MouseWheel extends Base { ) } - protected onMouseWheel(e: WheelEvent) { + protected onMouseWheel(e: WheelEvent, _deltaX?: number, deltaY?: number) { const guard = this.widgetOptions.guard if ( @@ -82,7 +93,21 @@ export class MouseWheel extends Base { } const delta = e.deltaY - if (delta < 0) { + + if (this.widgetOptions.factorByDelta) { + // Continuous, delta-proportional zoom (trackpad-friendly). + // Prefer the delta accumulated per rAF frame by `MouseWheelHandle` + // over a single event's delta. A ~100px notch equals one `factor` + // multiplication, preserving the classic mouse-wheel feel, while + // small trackpad deltas produce small steps. Negative delta zooms + // in, matching the quantized path below. + // Guard against a malformed event (non-finite delta) or a + // misconfigured negative factor so a bad input cannot produce + // `zoom(NaN)` — the quantized path below is NaN-safe by construction. + const d = deltaY != null ? deltaY : delta + const base = factor > 0 ? factor : 1.2 + this.cumulatedFactor = Number.isFinite(d) ? base ** (-d / 100) : 1 + } else if (delta < 0) { // zoomin // ------ // Switches to 1% zoom steps below 15%