Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions __tests__/graph/mousewheel.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
})
})
7 changes: 7 additions & 0 deletions site/docs/api/graph/mousewheel.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`.
Expand Down
7 changes: 7 additions & 0 deletions site/docs/api/graph/mousewheel.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -47,6 +48,12 @@ interface MouseWheelOptions {

滚动缩放因子。默认为 `1.2`。

### factorByDelta

是否按滚轮 delta 的大小来缩放,而不是每个事件都套用固定的缩放步长。默认为 `false`。

为 `false`(默认)时,每个 wheel 事件都套用固定步长(至少 5%);触控板捏合会连发大量高频的小 wheel 事件,导致缩放过快。为 `true` 时,缩放步长与滚轮 delta 成正比:约 100px 的标准滚动一格保持原有 `factor` 手感,而触控板的小 delta 则产生平滑、按比例的小步缩放。

### zoomAtMousePosition

是否将鼠标位置作为中心缩放,默认为 `true`。
Expand Down
29 changes: 27 additions & 2 deletions src/graph/mousewheel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 (
Expand All @@ -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%
Expand Down