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:
17
frontend/src/lib/person/genealogy/animateView.test.ts
Normal file
17
frontend/src/lib/person/genealogy/animateView.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
38
frontend/src/lib/person/genealogy/animateView.ts
Normal file
38
frontend/src/lib/person/genealogy/animateView.ts
Normal 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);
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user