From 396c87f8ab78f6aaab79e78c6d7fcb4bb5e5c13d Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 29 May 2026 16:54:34 +0200 Subject: [PATCH] feat(stammbaum): animate fit-to-screen, snap under reduced motion (#692) Fit-to-screen tweens to the default view over 300ms via animateView (eased, lerpView-driven) and snaps instantly when prefers-reduced-motion is set (US-PAN-004 AC2, NFR-A11Y-003). Co-Authored-By: Claude Opus 4.8 --- .../lib/person/genealogy/animateView.test.ts | 17 +++++++++ .../src/lib/person/genealogy/animateView.ts | 38 +++++++++++++++++++ .../src/lib/person/genealogy/panZoom.test.ts | 15 ++++++++ frontend/src/lib/person/genealogy/panZoom.ts | 9 +++++ frontend/src/routes/stammbaum/+page.svelte | 7 +++- 5 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 frontend/src/lib/person/genealogy/animateView.test.ts create mode 100644 frontend/src/lib/person/genealogy/animateView.ts diff --git a/frontend/src/lib/person/genealogy/animateView.test.ts b/frontend/src/lib/person/genealogy/animateView.test.ts new file mode 100644 index 00000000..f25fda18 --- /dev/null +++ b/frontend/src/lib/person/genealogy/animateView.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect, vi } from 'vitest'; +import { animateView } from './animateView'; + +describe('animateView (reduced motion)', () => { + const from = { x: 0, y: 0, z: 1 }; + const to = { x: 80, y: -30, z: 2 }; + + it('snaps straight to the target in a single frame when reduced motion is on', () => { + const onFrame = vi.fn(); + const cancel = animateView(from, to, onFrame, { reducedMotion: true }); + + expect(onFrame).toHaveBeenCalledTimes(1); + expect(onFrame).toHaveBeenCalledWith(to); + expect(typeof cancel).toBe('function'); + cancel(); + }); +}); diff --git a/frontend/src/lib/person/genealogy/animateView.ts b/frontend/src/lib/person/genealogy/animateView.ts new file mode 100644 index 00000000..72754899 --- /dev/null +++ b/frontend/src/lib/person/genealogy/animateView.ts @@ -0,0 +1,38 @@ +import { lerpView, type PanZoomState } from '$lib/person/genealogy/panZoom'; + +/** Fit / recentre animation duration (US-PAN-004 AC2: ≤ 300 ms). */ +export const VIEW_ANIM_MS = 300; + +const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3); + +/** Snapshot of the user's reduced-motion preference (non-reactive, browser-only). */ +export function prefersReducedMotion(): boolean { + return typeof window !== 'undefined' && typeof window.matchMedia === 'function' + ? window.matchMedia('(prefers-reduced-motion: reduce)').matches + : false; +} + +/** + * Tween the view from `from` to `to`, calling `onFrame` with each interpolated + * state. Honors reduced motion by snapping straight to the target (NFR-A11Y-003, + * REQ-PAN-005). Returns a cancel function. + */ +export function animateView( + from: PanZoomState, + to: PanZoomState, + onFrame: (state: PanZoomState) => void, + opts: { reducedMotion?: boolean; durationMs?: number } = {} +): () => void { + if (opts.reducedMotion) { + onFrame(to); + return () => {}; + } + const duration = opts.durationMs ?? VIEW_ANIM_MS; + const start = performance.now(); + let raf = requestAnimationFrame(function step(now: number) { + const t = Math.min(1, (now - start) / duration); + onFrame(lerpView(from, to, easeOutCubic(t))); + if (t < 1) raf = requestAnimationFrame(step); + }); + return () => cancelAnimationFrame(raf); +} diff --git a/frontend/src/lib/person/genealogy/panZoom.test.ts b/frontend/src/lib/person/genealogy/panZoom.test.ts index 441c3b76..fcde710b 100644 --- a/frontend/src/lib/person/genealogy/panZoom.test.ts +++ b/frontend/src/lib/person/genealogy/panZoom.test.ts @@ -7,6 +7,7 @@ import { zoomAtPoint, recentreOn, clampPan, + lerpView, DEFAULT_VIEW, DEFAULT_ZOOM, LEGIBLE_ZOOM, @@ -166,3 +167,17 @@ describe('clampPan', () => { expect(clampPan({ x: 100, y: -50, z: 2 }, 1000, 800)).toEqual({ x: 100, y: -50, z: 2 }); }); }); + +describe('lerpView', () => { + const from = { x: 0, y: 0, z: 1 }; + const to = { x: 100, y: -40, z: 2 }; + + it('returns the start at t=0 and the end at t=1', () => { + expect(lerpView(from, to, 0)).toEqual(from); + expect(lerpView(from, to, 1)).toEqual(to); + }); + + it('interpolates each axis linearly at t=0.5', () => { + expect(lerpView(from, to, 0.5)).toEqual({ x: 50, y: -20, z: 1.5 }); + }); +}); diff --git a/frontend/src/lib/person/genealogy/panZoom.ts b/frontend/src/lib/person/genealogy/panZoom.ts index 3190ed3c..9b83cd27 100644 --- a/frontend/src/lib/person/genealogy/panZoom.ts +++ b/frontend/src/lib/person/genealogy/panZoom.ts @@ -113,6 +113,15 @@ export function zoomAtPoint( }; } +/** Linearly interpolate between two view states (drives fit/recentre tweening). */ +export function lerpView(from: PanZoomState, to: PanZoomState, t: number): PanZoomState { + return { + x: from.x + (to.x - from.x) * t, + y: from.y + (to.y - from.y) * t, + z: from.z + (to.z - from.z) * t + }; +} + /** * Clamp the pan offset so the canvas cannot be dragged off the edge (US-PAN-001 * AC4 — no infinite scroll). The pannable range on each axis is half the diff --git a/frontend/src/routes/stammbaum/+page.svelte b/frontend/src/routes/stammbaum/+page.svelte index ad14c2be..24f9a98d 100644 --- a/frontend/src/routes/stammbaum/+page.svelte +++ b/frontend/src/routes/stammbaum/+page.svelte @@ -10,6 +10,7 @@ import { clampZoom, ZOOM_STEP_KB } from '$lib/person/genealogy/panZoom'; +import { animateView, prefersReducedMotion } from '$lib/person/genealogy/animateView'; import type { components } from '$lib/generated/api'; type PersonNodeDTO = components['schemas']['PersonNodeDTO']; @@ -37,8 +38,12 @@ function zoomIn() { function zoomOut() { view = { ...view, z: clampZoom(view.z - ZOOM_STEP_KB) }; } +let cancelAnimation = () => {}; function fitToScreen() { - view = DEFAULT_VIEW; + cancelAnimation(); + cancelAnimation = animateView(view, DEFAULT_VIEW, (v) => (view = v), { + reducedMotion: prefersReducedMotion() + }); }