feat(stammbaum): touch/mouse/wheel pan & pinch zoom gestures (#692)

Add a panZoomGestures action: one-finger/left-button drag pans, two-finger
pinch and Ctrl+wheel zoom around the centroid, plain wheel pans. Pan is
edge-clamped via clampPan (no infinite scroll), a real drag suppresses the
trailing node click, and inertia decays after release unless prefers-reduced-
motion. Canvas container switches from native scroll to overflow-hidden.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-29 16:45:18 +02:00
parent da1984b916
commit c8931071ba
6 changed files with 335 additions and 1 deletions

View File

@@ -6,6 +6,7 @@ import {
screenDeltaToSvg,
zoomAtPoint,
recentreOn,
clampPan,
DEFAULT_VIEW,
DEFAULT_ZOOM,
LEGIBLE_ZOOM,
@@ -145,3 +146,23 @@ describe('recentreOn', () => {
expect(recentreOn(node, base, { x: 0, y: 0, z: 0.4 }, false).z).toBe(0.4);
});
});
describe('clampPan', () => {
// Base frame is 1000 x 800.
it('forbids panning when the whole tree fits (z <= 1)', () => {
expect(clampPan({ x: 200, y: -100, z: 1 }, 1000, 800)).toEqual({ x: 0, y: 0, z: 1 });
expect(clampPan({ x: 50, y: 50, z: 0.5 }, 1000, 800)).toEqual({ x: 0, y: 0, z: 0.5 });
});
it('allows panning up to the edge when zoomed in (no infinite scroll)', () => {
// At z=2 the viewBox is 500 wide → limit = (1000 - 500) / 2 = 250.
expect(clampPan({ x: 1000, y: 0, z: 2 }, 1000, 800).x).toBe(250);
expect(clampPan({ x: -1000, y: 0, z: 2 }, 1000, 800).x).toBe(-250);
// Vertical limit at z=2: (800 - 400) / 2 = 200.
expect(clampPan({ x: 0, y: 999, z: 2 }, 1000, 800).y).toBe(200);
});
it('leaves an in-range pan untouched', () => {
expect(clampPan({ x: 100, y: -50, z: 2 }, 1000, 800)).toEqual({ x: 100, y: -50, z: 2 });
});
});