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() + }); }