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

@@ -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<GutterRow[]>(() => {
if (gutterWidth === 0) return [];
@@ -236,6 +253,15 @@ const parentLinks = $derived.by<ParentLinks>(() => {
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"
>
<!-- Gutter stripe underlay (#689) — decorative full-row bands alternating