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 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-29 16:54:34 +02:00
parent 7a6c2e877f
commit 396c87f8ab
5 changed files with 85 additions and 1 deletions

View File

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

View File

@@ -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);
}

View File

@@ -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 });
});
});

View File

@@ -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

View File

@@ -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()
});
}
</script>