From c8931071babb8681615cf0ec1183e2bc8d219412 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 29 May 2026 16:45:18 +0200 Subject: [PATCH] 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 --- .../lib/person/genealogy/StammbaumTree.svelte | 26 ++ .../genealogy/StammbaumTree.svelte.test.ts | 29 +++ .../src/lib/person/genealogy/panZoom.test.ts | 21 ++ frontend/src/lib/person/genealogy/panZoom.ts | 14 + .../lib/person/genealogy/panZoomGestures.ts | 244 ++++++++++++++++++ frontend/src/routes/stammbaum/+page.svelte | 2 +- 6 files changed, 335 insertions(+), 1 deletion(-) create mode 100644 frontend/src/lib/person/genealogy/panZoomGestures.ts diff --git a/frontend/src/lib/person/genealogy/StammbaumTree.svelte b/frontend/src/lib/person/genealogy/StammbaumTree.svelte index db74a366..e2aa74c2 100644 --- a/frontend/src/lib/person/genealogy/StammbaumTree.svelte +++ b/frontend/src/lib/person/genealogy/StammbaumTree.svelte @@ -9,6 +9,7 @@ import { type Layout } from '$lib/person/genealogy/layout/buildLayout'; import { type PanZoomState, clampZoom, ZOOM_STEP_KB } from '$lib/person/genealogy/panZoom'; +import { panZoomGestures } from '$lib/person/genealogy/panZoomGestures'; type PersonNodeDTO = components['schemas']['PersonNodeDTO']; type RelationshipDTO = components['schemas']['RelationshipDTO']; @@ -67,6 +68,22 @@ $effect(() => { const gutterVisible = $derived(showGutter ?? isMdOrUp); const gutterWidth = $derived(gutterVisible ? GUTTER_WIDTH_DESKTOP : 0); +// Reduced-motion preference disables pan inertia and animated transitions +// (REQ-PAN-005). Seeded synchronously like the gutter state above. +const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)'; +let reducedMotion = $state( + typeof window !== 'undefined' && typeof window.matchMedia === 'function' + ? window.matchMedia(REDUCED_MOTION_QUERY).matches + : false +); +$effect(() => { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return; + const mq = window.matchMedia(REDUCED_MOTION_QUERY); + const handler = (e: MediaQueryListEvent) => (reducedMotion = e.matches); + mq.addEventListener('change', handler); + return () => mq.removeEventListener('change', handler); +}); + type GutterRow = { rank: number; y: number; label: number | null }; const gutterRows = $derived.by(() => { if (gutterWidth === 0) return []; @@ -236,6 +253,15 @@ const parentLinks = $derived.by(() => { aria-label="Stammbaum" tabindex="0" onkeydown={handleCanvasKey} + use:panZoomGestures={{ + state: panZoom, + baseW: baseDims.w, + baseH: baseDims.h, + baseCentreX: baseCentre.x, + baseCentreY: baseCentre.y, + reducedMotion, + onPanZoom + }} class="block h-full w-full" >