diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md
index 6a6f840d..56fc47db 100644
--- a/docs/GLOSSARY.md
+++ b/docs/GLOSSARY.md
@@ -58,6 +58,7 @@ _See also [Annotation](#annotation-documentannotation)._
**DocumentStatus lifecycle** — the ordered states a `Document` moves through:
`PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED`
+
- `PLACEHOLDER`: created during mass import; no file attached yet.
- `UPLOADED`: a file has been stored in MinIO/S3.
- `TRANSCRIBED`: all transcription blocks have been marked done.
@@ -119,12 +120,16 @@ _Not to be confused with [parented](#parented-layout)_ — loose is the absence
**anchor index** — within a sibling block, the average position of `parented` member indices. The block is shifted horizontally so this index, multiplied by `NODE_W + COL_GAP`, lines up under the midpoint of the block's parents — keeping every parent-child connector orthogonal (90°).
-**intra-family marriage** — a `SPOUSE_OF` edge where both endpoints are parented members of *different* sibling blocks at the same rank (i.e. both have parents in the graph, but the parent sets differ). Layout merges the two blocks so the spouses sit adjacent at the join boundary; latent in current data (0 cases in the May-2026 canonical snapshot) but covered by a synthetic regression test in `buildLayout.test.ts`.
+**intra-family marriage** — a `SPOUSE_OF` edge where both endpoints are parented members of _different_ sibling blocks at the same rank (i.e. both have parents in the graph, but the parent sets differ). Layout merges the two blocks so the spouses sit adjacent at the join boundary; latent in current data (0 cases in the May-2026 canonical snapshot) but covered by a synthetic regression test in `buildLayout.test.ts`.
**marriage dot** — the SVG circle drawn at the midpoint of a `SPOUSE_OF` connector in the Stammbaum tree (`StammbaumTree.svelte`). Radius is `r=6` (12 px diameter) so the marker meets WCAG 1.4.11 (3:1 non-text contrast) when it stacks to disambiguate multiple marriages on the same focal person.
**canonical fixture** (Stammbaum) — `frontend/src/lib/person/genealogy/__fixtures__/stammbaum.json`, a pinned `/api/network` snapshot used by `buildLayout.test.ts` for structural-property assertions against real data. Captured locally via `frontend/scripts/capture-network-fixture.mjs` with explicit credentials and a localhost backend; never invoked from CI. Sanity-gated by `validateFixture.ts` (≥ 50 nodes / ≥ 5 generations / ≥ 1 SPOUSE_OF edge / ≥ 1 multi-spouse person).
+**pan/zoom view state** `[#692]` — the `{ x, y, z }` triple (`PanZoomState` in `frontend/src/lib/person/genealogy/panZoom.ts`) describing the Stammbaum canvas position: `x`/`y` are pan offsets applied to the SVG viewBox centre, `z` is the zoom factor (clamped 0.25–10). Mirrored into the shareable URL as `?cx&cy&z` and seeded server-side from those params. See [ADR-027](adr/027-stammbaum-custom-viewbox-pan-zoom.md).
+
+**fit-to-screen** `[user-facing, #692]` — the Stammbaum control (`⤢`) and initial state that frames the whole tree in the viewport. Because the base viewBox already encloses the layout at `z=1`, fit-to-screen is simply the default view `{x:0, y:0, z:1}`.
+
---
## Other Domain Terms
diff --git a/docs/adr/027-stammbaum-custom-viewbox-pan-zoom.md b/docs/adr/027-stammbaum-custom-viewbox-pan-zoom.md
new file mode 100644
index 00000000..1d8613e5
--- /dev/null
+++ b/docs/adr/027-stammbaum-custom-viewbox-pan-zoom.md
@@ -0,0 +1,57 @@
+# ADR-027 — Stammbaum pan/zoom is a custom viewBox layer, not a third-party library
+
+**Date:** 2026-05-29
+**Status:** Accepted
+**Issue:** #692 (mobile read path — pan, zoom, fit-to-view); supersedes OQ-007
+**Milestone:** Stammbaum mobile read path
+
+---
+
+## Context
+
+#692 makes `/stammbaum` usable on phones: drag-to-pan, pinch/keyboard/wheel zoom,
+fit-to-screen, recentre-on-person, a shareable URL view state, and an edge-fade
+affordance. During issue grooming, **OQ-007 was resolved to adopt the `panzoom`
+library** (timmywil v4.x) on the team's recommendation, pinned per NFR-MAINT-001.
+
+That recommendation predated a load-bearing implementation detail: `StammbaumTree.svelte`
+already renders zoom by **deriving the SVG `viewBox`** (`w = baseW / z`, centred on the
+layout bounding box, `preserveAspectRatio="xMinYMin meet"` so a fresh visit anchors to the
+tree's top-left corner) — not by applying a CSS
+`transform`. The `panzoom` library operates by writing `transform` to a DOM node. Adopting
+it would mean:
+
+- abandoning the proven viewBox derivation and the in-SVG generation gutter (#689), which
+ lives in SVG user-space coordinates and would have to be reconciled with a CSS-transformed
+ parent;
+- re-deriving fit-to-screen, recentre, and the `?cx&cy&z` URL state against the library's
+ transform coordinate system;
+- a client-only lazy import to keep the SSR-rendered tree from touching `window` at module
+ load; and
+- ~8 KB of bundle for behaviour we can express in a few pure functions.
+
+## Decision
+
+**Build pan/zoom as a thin custom layer over the existing viewBox**, with no third-party
+dependency. This reverses OQ-007.
+
+- All geometry is pure and unit-tested in `frontend/src/lib/person/genealogy/panZoom.ts`:
+ `clampZoom`, `parsePanZoomParams`/`serializePanZoomParams`, `screenDeltaToSvg`,
+ `zoomAtPoint` (centroid-anchored), `clampPan` (edge-clamp), `recentreOn`, `lerpView`.
+- Pan offsets shift the viewBox centre; zoom scales its width/height. The default
+ `{x:0, y:0, z:1}` already frames the whole tree, so **fit-to-screen is a reset to the
+ default** — no bounding-box recomputation.
+- DOM event wiring lives in the `panZoomGestures` action (pointer/wheel/pinch + inertia,
+ reduced-motion aware) and a keyboard handler on the SVG; both delegate to the pure module.
+
+## Consequences
+
+- **NFR-MAINT-001 (library pinning + feature-flag fallback) is moot** — no library is
+ adopted. The "swap-out point" is `panZoom.ts` + `panZoomGestures.ts`.
+- Text stays vector-crisp at any zoom (SVG-native scaling), satisfying US-PAN-002 AC5.
+- The #689 gutter and the #361 seeded-rank invariant are untouched by the pan/zoom layer.
+- Geometry is testable in the fast node project; only the DOM glue needs the browser project.
+- Trade-off: we own the inertia/pinch code (~a few hundred lines across the action) rather
+ than delegating it. This is acceptable given the testability and zero-dependency wins.
+
+The issue body's OQ-007 row is updated to point at this ADR.
diff --git a/frontend/e2e/stammbaum-mobile.visual.spec.ts b/frontend/e2e/stammbaum-mobile.visual.spec.ts
new file mode 100644
index 00000000..4cb7b196
--- /dev/null
+++ b/frontend/e2e/stammbaum-mobile.visual.spec.ts
@@ -0,0 +1,72 @@
+import { test, expect } from '@playwright/test';
+
+// Visual + structural coverage for the #692 mobile read path (pan/zoom/fit,
+// first-load affordance, bottom-sheet person panel).
+//
+// Snapshot assertions are gated on VISUAL=1 because they need pre-captured
+// baselines — regenerate in CI with `playwright test --update-snapshots` after
+// intentional UI changes. Structural assertions run unconditionally. The whole
+// suite is also subject to the project-wide Chromium-in-CI gate (#363); it
+// captures new snapshots rather than replacing the #361 desktop baselines.
+const VISUAL = process.env.VISUAL === '1';
+
+const WIDTHS = [320, 414, 768] as const;
+
+test.describe('Stammbaum — mobile read path (#692)', () => {
+ // Touch emulation so the canvas reports pointer:coarse and the first-load
+ // affordance appears; reduced-motion is already forced project-wide.
+ test.use({ hasTouch: true, isMobile: true });
+
+ // Clear the affordance-dismissed flag before every test so the first-load
+ // hint state is deterministic regardless of test order.
+ test.beforeEach(async ({ page }) => {
+ await page.addInitScript(() => localStorage.removeItem('stammbaumAffordanceDismissedAt'));
+ });
+
+ for (const width of WIDTHS) {
+ test(`affordance + controls render at ${width}px`, async ({ page }) => {
+ await page.setViewportSize({ width, height: 720 });
+ await page.goto('/stammbaum');
+ await expect(page.getByRole('heading', { name: 'Stammbaum' })).toBeVisible();
+
+ const empty = page.getByRole('heading', { name: 'Noch keine Familienmitglieder' });
+ if (await empty.isVisible().catch(() => false)) {
+ test.skip(true, 'no seeded family tree in this environment');
+ }
+
+ // Bottom-right control cluster with the fit-to-screen affordance.
+ await expect(page.getByTestId('fit-to-screen')).toBeVisible();
+ // First-load interactive hint (touch only).
+ await expect(page.getByRole('status')).toBeVisible();
+
+ if (VISUAL) {
+ await expect(page).toHaveScreenshot(`stammbaum-affordance-${width}.png`, {
+ animations: 'disabled'
+ });
+ }
+ });
+ }
+
+ test('bottom sheet opens on node tap at 414px and preserves the canvas', async ({ page }) => {
+ await page.setViewportSize({ width: 414, height: 720 });
+ await page.goto('/stammbaum');
+ await expect(page.getByRole('heading', { name: 'Stammbaum' })).toBeVisible();
+
+ const node = page.locator('svg[aria-label="Stammbaum"] g[role="button"]').first();
+ if ((await node.count()) === 0) test.skip(true, 'no seeded nodes to tap');
+
+ await node.tap();
+ const sheet = page.getByRole('dialog');
+ await expect(sheet).toBeVisible();
+
+ if (VISUAL) {
+ await expect(page).toHaveScreenshot('stammbaum-bottom-sheet-414.png', {
+ animations: 'disabled'
+ });
+ }
+
+ // Dismiss via the backdrop and confirm the sheet closes (state survives).
+ await page.getByRole('button', { name: 'Schließen' }).first().click();
+ await expect(sheet).toBeHidden();
+ });
+});
diff --git a/frontend/messages/de.json b/frontend/messages/de.json
index 00cc1488..37bd55cd 100644
--- a/frontend/messages/de.json
+++ b/frontend/messages/de.json
@@ -1106,6 +1106,11 @@
"stammbaum_relationships_heading": "Stammbaum & Beziehungen",
"stammbaum_zoom_in": "Vergrößern",
"stammbaum_zoom_out": "Verkleinern",
+ "stammbaum_fit_to_screen": "An Bildschirm anpassen",
+ "stammbaum_affordance_hint": "Ziehen zum Erkunden · Zusammendrücken zum Zoomen",
+ "stammbaum_affordance_dismiss": "Hinweis schließen",
+ "stammbaum_close_panel": "Schließen",
+ "stammbaum_centre_on_person": "Auf diese Person zentrieren",
"relation_error_duplicate": "Diese Beziehung gibt es bereits.",
"relation_error_circular": "Diese Beziehung würde einen Kreis erzeugen.",
"relation_error_self": "Eine Person kann nicht mit sich selbst verbunden werden.",
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index baa0d1b4..0f5f862f 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -1106,6 +1106,11 @@
"stammbaum_relationships_heading": "Family tree & relationships",
"stammbaum_zoom_in": "Zoom in",
"stammbaum_zoom_out": "Zoom out",
+ "stammbaum_fit_to_screen": "Fit to screen",
+ "stammbaum_affordance_hint": "Drag to explore · pinch to zoom",
+ "stammbaum_affordance_dismiss": "Dismiss hint",
+ "stammbaum_close_panel": "Close",
+ "stammbaum_centre_on_person": "Centre on this person",
"relation_error_duplicate": "This relationship already exists.",
"relation_error_circular": "This relationship would form a cycle.",
"relation_error_self": "A person cannot be related to themselves.",
diff --git a/frontend/messages/es.json b/frontend/messages/es.json
index a2128407..3d9e92ab 100644
--- a/frontend/messages/es.json
+++ b/frontend/messages/es.json
@@ -1106,6 +1106,11 @@
"stammbaum_relationships_heading": "Árbol genealógico & relaciones",
"stammbaum_zoom_in": "Acercar",
"stammbaum_zoom_out": "Alejar",
+ "stammbaum_fit_to_screen": "Ajustar a la pantalla",
+ "stammbaum_affordance_hint": "Arrastra para explorar · pellizca para ampliar",
+ "stammbaum_affordance_dismiss": "Cerrar aviso",
+ "stammbaum_close_panel": "Cerrar",
+ "stammbaum_centre_on_person": "Centrar en esta persona",
"relation_error_duplicate": "Esta relación ya existe.",
"relation_error_circular": "Esta relación crearía un ciclo.",
"relation_error_self": "Una persona no puede estar relacionada consigo misma.",
diff --git a/frontend/src/lib/person/genealogy/StammbaumAffordance.svelte b/frontend/src/lib/person/genealogy/StammbaumAffordance.svelte
new file mode 100644
index 00000000..1c38ea1a
--- /dev/null
+++ b/frontend/src/lib/person/genealogy/StammbaumAffordance.svelte
@@ -0,0 +1,90 @@
+
+
+{#if visible}
+
+
+
{m.stammbaum_affordance_hint()}
+
+
+
+
+
+
+
+{/if}
diff --git a/frontend/src/lib/person/genealogy/StammbaumAffordance.svelte.test.ts b/frontend/src/lib/person/genealogy/StammbaumAffordance.svelte.test.ts
new file mode 100644
index 00000000..d8cfff1c
--- /dev/null
+++ b/frontend/src/lib/person/genealogy/StammbaumAffordance.svelte.test.ts
@@ -0,0 +1,34 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { render } from 'vitest-browser-svelte';
+import StammbaumAffordance from './StammbaumAffordance.svelte';
+
+const STORAGE_KEY = 'stammbaumAffordanceDismissedAt';
+
+describe('StammbaumAffordance (#692)', () => {
+ beforeEach(() => localStorage.clear());
+
+ it('shows the hint on a touch device that has not dismissed it', async () => {
+ render(StammbaumAffordance, { touch: true });
+ await vi.waitFor(() => expect(document.querySelector('[role="status"]')).not.toBeNull());
+ expect(document.body.textContent).toContain('Ziehen');
+ });
+
+ it('does not show on non-touch devices (OQ-008)', async () => {
+ render(StammbaumAffordance, { touch: false });
+ expect(document.querySelector('[role="status"]')).toBeNull();
+ });
+
+ it('hides and records dismissal when the close button is clicked', async () => {
+ render(StammbaumAffordance, { touch: true });
+ const dismiss = [...document.querySelectorAll('button')][0];
+ dismiss.click();
+ await vi.waitFor(() => expect(document.querySelector('[role="status"]')).toBeNull());
+ expect(localStorage.getItem(STORAGE_KEY)).toBeTruthy();
+ });
+
+ it('does not reappear within the 30-day window (NFR-USE-001)', async () => {
+ localStorage.setItem(STORAGE_KEY, String(Date.now()));
+ render(StammbaumAffordance, { touch: true });
+ expect(document.querySelector('[role="status"]')).toBeNull();
+ });
+});
diff --git a/frontend/src/lib/person/genealogy/StammbaumBottomSheet.svelte b/frontend/src/lib/person/genealogy/StammbaumBottomSheet.svelte
new file mode 100644
index 00000000..ac9ab3c2
--- /dev/null
+++ b/frontend/src/lib/person/genealogy/StammbaumBottomSheet.svelte
@@ -0,0 +1,75 @@
+
+
+
+
+
+
diff --git a/frontend/src/lib/person/genealogy/StammbaumBottomSheet.svelte.test.ts b/frontend/src/lib/person/genealogy/StammbaumBottomSheet.svelte.test.ts
new file mode 100644
index 00000000..a0c1f72c
--- /dev/null
+++ b/frontend/src/lib/person/genealogy/StammbaumBottomSheet.svelte.test.ts
@@ -0,0 +1,46 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render } from 'vitest-browser-svelte';
+import StammbaumBottomSheet from './StammbaumBottomSheet.svelte';
+
+const node = { id: 'p-1', displayName: 'Anna Schmidt', familyMember: true };
+
+describe('StammbaumBottomSheet (#692)', () => {
+ it('renders as a dialog with the person name as its accessible name', async () => {
+ render(StammbaumBottomSheet, { node, canWrite: false, onClose: () => {} });
+ const dialog = document.querySelector('[role="dialog"]')!;
+ expect(dialog).toBeTruthy();
+ expect(dialog.getAttribute('aria-label')).toBe('Anna Schmidt');
+ });
+
+ it('dismisses on Escape', async () => {
+ const onClose = vi.fn();
+ render(StammbaumBottomSheet, { node, canWrite: false, onClose });
+ const dialog = document.querySelector('[role="dialog"]') as HTMLElement;
+ dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ it('dismisses when the backdrop is tapped', async () => {
+ const onClose = vi.fn();
+ render(StammbaumBottomSheet, { node, canWrite: false, onClose });
+ const backdrop = document.querySelector('button[aria-label]') as HTMLButtonElement;
+ backdrop.click();
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ it('dismisses on a downward swipe past the threshold', async () => {
+ const onClose = vi.fn();
+ render(StammbaumBottomSheet, { node, canWrite: false, onClose });
+ const handle = document.querySelector('[role="dialog"] > div') as HTMLElement;
+ handle.dispatchEvent(
+ new PointerEvent('pointerdown', { pointerId: 1, clientY: 100, bubbles: true })
+ );
+ handle.dispatchEvent(
+ new PointerEvent('pointermove', { pointerId: 1, clientY: 220, bubbles: true })
+ );
+ handle.dispatchEvent(
+ new PointerEvent('pointerup', { pointerId: 1, clientY: 220, bubbles: true })
+ );
+ expect(onClose).toHaveBeenCalled();
+ });
+});
diff --git a/frontend/src/lib/person/genealogy/StammbaumConnectors.svelte b/frontend/src/lib/person/genealogy/StammbaumConnectors.svelte
new file mode 100644
index 00000000..0a647b70
--- /dev/null
+++ b/frontend/src/lib/person/genealogy/StammbaumConnectors.svelte
@@ -0,0 +1,189 @@
+
+
+
+{#each parentLinks.shared as group (group.key)}
+ {@const aCenter = nodeCenter(group.parentA)}
+ {@const bCenter = nodeCenter(group.parentB)}
+ {@const childCenters = group.childIds
+ .map((id) => nodeCenter(id))
+ .filter((c): c is { x: number; y: number } => c !== null)}
+ {#if aCenter && bCenter && childCenters.length > 0}
+ {@const midX = (aCenter.x + bCenter.x) / 2}
+ {@const parentBottomY = aCenter.y + NODE_H / 2}
+ {@const childTopY = childCenters[0].y - NODE_H / 2}
+ {@const barY = (parentBottomY + childTopY) / 2}
+ {@const xs = childCenters.map((c) => c.x)}
+ {@const minX = Math.min(midX, ...xs)}
+ {@const maxX = Math.max(midX, ...xs)}
+
+ {#if minX !== maxX}
+
+ {/if}
+ {#each childCenters as cc, i (group.childIds[i])}
+
+ {/each}
+ {/if}
+{/each}
+
+
+{#each parentLinks.single as link (link.key)}
+ {@const parentCenter = nodeCenter(link.parentId)}
+ {@const childCenter = nodeCenter(link.childId)}
+ {#if parentCenter && childCenter}
+ {@const parentBottomY = parentCenter.y + NODE_H / 2}
+ {@const childTopY = childCenter.y - NODE_H / 2}
+ {@const barY = (parentBottomY + childTopY) / 2}
+
+ {#if parentCenter.x !== childCenter.x}
+
+ {/if}
+
+ {/if}
+{/each}
+
+
+{#each spouseEdges as e (e.id)}
+ {@const aCenter = nodeCenter(e.personId)}
+ {@const bCenter = nodeCenter(e.relatedPersonId)}
+ {#if aCenter && bCenter}
+
+
+ {/if}
+{/each}
diff --git a/frontend/src/lib/person/genealogy/StammbaumControls.svelte b/frontend/src/lib/person/genealogy/StammbaumControls.svelte
new file mode 100644
index 00000000..ee4fb55c
--- /dev/null
+++ b/frontend/src/lib/person/genealogy/StammbaumControls.svelte
@@ -0,0 +1,38 @@
+
+
+
+
+ +
+
+
+ −
+
+
+ ⤢
+
+
diff --git a/frontend/src/lib/person/genealogy/StammbaumGenerationRail.svelte b/frontend/src/lib/person/genealogy/StammbaumGenerationRail.svelte
new file mode 100644
index 00000000..9169f1bb
--- /dev/null
+++ b/frontend/src/lib/person/genealogy/StammbaumGenerationRail.svelte
@@ -0,0 +1,72 @@
+
+
+
+
+ {#each chips as chip (chip.rank)}
+ {#if chip.visible}
+
+ G{chip.label}
+
+ {/if}
+ {/each}
+
diff --git a/frontend/src/lib/person/genealogy/StammbaumGenerationRail.svelte.test.ts b/frontend/src/lib/person/genealogy/StammbaumGenerationRail.svelte.test.ts
new file mode 100644
index 00000000..6a3b03a3
--- /dev/null
+++ b/frontend/src/lib/person/genealogy/StammbaumGenerationRail.svelte.test.ts
@@ -0,0 +1,32 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render } from 'vitest-browser-svelte';
+import StammbaumGenerationRail from './StammbaumGenerationRail.svelte';
+
+const rows = [
+ { rank: 0, label: 0, centerY: 100 },
+ { rank: 1, label: 1, centerY: 300 },
+ { rank: 2, label: 3, centerY: 500 }
+];
+
+describe('StammbaumGenerationRail (#692)', () => {
+ it('renders one labelled chip per generation row', async () => {
+ render(StammbaumGenerationRail, { svg: null, rows, panZoom: { x: 0, y: 0, z: 1 } });
+
+ await vi.waitFor(() => {
+ const labels = Array.from(document.querySelectorAll('[role="text"]')).map((el) => ({
+ aria: el.getAttribute('aria-label'),
+ text: el.textContent?.trim()
+ }));
+ expect(labels).toEqual([
+ { aria: 'Generation 0', text: 'G0' },
+ { aria: 'Generation 1', text: 'G1' },
+ { aria: 'Generation 3', text: 'G3' }
+ ]);
+ });
+ });
+
+ it('renders nothing when there are no labelled rows', async () => {
+ render(StammbaumGenerationRail, { svg: null, rows: [], panZoom: { x: 0, y: 0, z: 1 } });
+ await vi.waitFor(() => expect(document.querySelectorAll('[role="text"]')).toHaveLength(0));
+ });
+});
diff --git a/frontend/src/lib/person/genealogy/StammbaumNode.svelte b/frontend/src/lib/person/genealogy/StammbaumNode.svelte
new file mode 100644
index 00000000..f2877994
--- /dev/null
+++ b/frontend/src/lib/person/genealogy/StammbaumNode.svelte
@@ -0,0 +1,90 @@
+
+
+ onSelect(node.id)}
+ onkeydown={handleKey}
+ onfocus={() => (focused = true)}
+ onblur={() => (focused = false)}
+ class="cursor-pointer focus:outline-none"
+>
+ {#if focused}
+
+ {/if}
+
+ {#if selected}
+
+ {/if}
+
+ {node.displayName}
+
+ {#if node.birthYear || node.deathYear}
+
+ {node.birthYear ?? '?'}–{node.deathYear ?? ''}
+
+ {/if}
+
diff --git a/frontend/src/lib/person/genealogy/StammbaumSidePanel.svelte b/frontend/src/lib/person/genealogy/StammbaumSidePanel.svelte
index c243de71..c3a34a50 100644
--- a/frontend/src/lib/person/genealogy/StammbaumSidePanel.svelte
+++ b/frontend/src/lib/person/genealogy/StammbaumSidePanel.svelte
@@ -14,10 +14,12 @@ type InferredRelationshipWithPersonDTO = components['schemas']['InferredRelation
interface Props {
node: PersonNodeDTO;
onClose: () => void;
+ /** When provided, a "centre on this person" control appears in the title row (US-PAN-005). */
+ onCentre?: () => void;
canWrite?: boolean;
}
-let { node, onClose, canWrite = false }: Props = $props();
+let { node, onClose, onCentre, canWrite = false }: Props = $props();
let directRels = $state([]);
let derivedRels = $state([]);
@@ -95,23 +97,45 @@ const topDerived = $derived(
{/if}
-
-
+ {#if onCentre}
+
+
+
+
+
+
+ {/if}
+
-
-
-
+
+
+
+
+
{#if error}
diff --git a/frontend/src/lib/person/genealogy/StammbaumSidePanel.svelte.spec.ts b/frontend/src/lib/person/genealogy/StammbaumSidePanel.svelte.spec.ts
index 4dda616b..142ccce2 100644
--- a/frontend/src/lib/person/genealogy/StammbaumSidePanel.svelte.spec.ts
+++ b/frontend/src/lib/person/genealogy/StammbaumSidePanel.svelte.spec.ts
@@ -11,7 +11,7 @@ const makeNode = () => ({
id: 'person-1',
displayName: 'Alice Müller',
birthYear: 1900,
- deathYear: null,
+ deathYear: undefined,
familyMember: true
});
@@ -50,6 +50,23 @@ describe('StammbaumSidePanel', () => {
await expect.element(page.getByText('Alice Müller')).toBeInTheDocument();
});
+ it('hides the centre control when onCentre is not provided', async () => {
+ render(StammbaumSidePanel, { node: makeNode(), onClose: vi.fn(), canWrite: false });
+ await expect
+ .element(page.getByRole('button', { name: 'Auf diese Person zentrieren' }))
+ .not.toBeInTheDocument();
+ });
+
+ it('calls onCentre when the centre control is clicked (US-PAN-005)', async () => {
+ const onCentre = vi.fn();
+ render(StammbaumSidePanel, { node: makeNode(), onClose: vi.fn(), onCentre, canWrite: false });
+ const btn = [...document.querySelectorAll('button')].find(
+ (b) => b.getAttribute('aria-label') === 'Auf diese Person zentrieren'
+ );
+ btn!.click();
+ expect(onCentre).toHaveBeenCalledOnce();
+ });
+
it('shows empty-relationships message when no direct relationships are loaded', async () => {
render(StammbaumSidePanel, { node: makeNode(), onClose: vi.fn(), canWrite: false });
await expect.element(page.getByText('Noch keine Beziehungen bekannt.')).toBeInTheDocument();
diff --git a/frontend/src/lib/person/genealogy/StammbaumTree.svelte b/frontend/src/lib/person/genealogy/StammbaumTree.svelte
index 8ab4af76..7d6caab8 100644
--- a/frontend/src/lib/person/genealogy/StammbaumTree.svelte
+++ b/frontend/src/lib/person/genealogy/StammbaumTree.svelte
@@ -1,5 +1,6 @@
-
-
- {#each gutterRows as row, i (`stripe-${row.rank}`)}
-
- {/each}
-
-
- {#each gutterRows as row (`label-${row.rank}`)}
- {#if row.label != null}
-
-
- G{row.label}
-
-
- {/if}
- {/each}
-
-
- {#each parentLinks.shared as group (group.key)}
- {@const aCenter = nodeCenter(group.parentA)}
- {@const bCenter = nodeCenter(group.parentB)}
- {@const childCenters = group.childIds
- .map((id) => nodeCenter(id))
- .filter((c): c is { x: number; y: number } => c !== null)}
- {#if aCenter && bCenter && childCenters.length > 0}
- {@const midX = (aCenter.x + bCenter.x) / 2}
- {@const parentBottomY = aCenter.y + NODE_H / 2}
- {@const childTopY = childCenters[0].y - NODE_H / 2}
- {@const barY = (parentBottomY + childTopY) / 2}
- {@const xs = childCenters.map((c) => c.x)}
- {@const minX = Math.min(midX, ...xs)}
- {@const maxX = Math.max(midX, ...xs)}
-
- {#if minX !== maxX}
-
- {/if}
- {#each childCenters as cc, i (group.childIds[i])}
-
+
+
+
+
+
+
+ {#if gutterVisible}
+ {#each gutterRows as row, i (`stripe-${row.rank}`)}
+
{/each}
{/if}
- {/each}
-
- {#each parentLinks.single as link (link.key)}
- {@const parentCenter = nodeCenter(link.parentId)}
- {@const childCenter = nodeCenter(link.childId)}
- {#if parentCenter && childCenter}
- {@const parentBottomY = parentCenter.y + NODE_H / 2}
- {@const childTopY = childCenter.y - NODE_H / 2}
- {@const barY = (parentBottomY + childTopY) / 2}
-
- {#if parentCenter.x !== childCenter.x}
-
+
+
+ {#each nodes as node (node.id)}
+ {@const pos = layout.positions.get(node.id)}
+ {#if pos}
+
{/if}
-
- {/if}
- {/each}
+ {/each}
+
-
- {#each spouseEdges as e (e.id)}
- {@const aCenter = nodeCenter(e.personId)}
- {@const bCenter = nodeCenter(e.relatedPersonId)}
- {#if aCenter && bCenter}
-
-
- {/if}
- {/each}
-
-
- {#each nodes as node (node.id)}
- {@const pos = layout.positions.get(node.id)}
- {#if pos}
- {@const isSelected = selectedId === node.id}
- {@const isFocused = focusedId === node.id}
- onSelect(node.id)}
- onkeydown={(e) => handleNodeKey(e, node.id)}
- onfocus={() => (focusedId = node.id)}
- onblur={() => (focusedId = null)}
- class="cursor-pointer focus:outline-none"
- >
- {#if isFocused}
-
- {/if}
-
- {#if isSelected}
-
- {/if}
-
- {node.displayName}
-
- {#if node.birthYear || node.deathYear}
-
- {node.birthYear ?? '?'}–{node.deathYear ?? ''}
-
- {/if}
-
- {/if}
- {/each}
-
+
+
diff --git a/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts b/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts
index 57c2bd48..7b1bc107 100644
--- a/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts
+++ b/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts
@@ -1,6 +1,7 @@
import { describe, it, expect, vi } from 'vitest';
import { render } from 'vitest-browser-svelte';
import StammbaumTree from './StammbaumTree.svelte';
+import type { PanZoomState } from './panZoom';
const ID_A = '00000000-0000-0000-0000-000000000001';
const ID_B = '00000000-0000-0000-0000-000000000002';
@@ -36,12 +37,37 @@ function rectsCentroid(svg: SVGElement): { x: number; y: number } {
}
describe('StammbaumTree viewBox', () => {
+ it('offsets the viewBox origin by the pan state (#692)', async () => {
+ render(StammbaumTree, {
+ nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
+ edges: [],
+ selectedId: null,
+ panZoom: { x: 100, y: 40, z: 1 },
+ showGutter: false,
+ onSelect: () => {}
+ });
+
+ const svg = document.querySelector('svg')!;
+ const [x, y, w, h] = parseViewBox(svg);
+
+ // Same dimensions as the unpanned default (z=1)…
+ expect(w).toBe(1200);
+ expect(h).toBe(800);
+
+ // …but the viewBox centre is the content centroid shifted by the pan
+ // offset (at pan {0,0} the centre sits on the centroid — see the test
+ // below). This avoids hard-coding the layout's absolute coordinates.
+ const c = rectsCentroid(svg);
+ expect(x + w / 2 - c.x).toBeCloseTo(100, 6);
+ expect(y + h / 2 - c.y).toBeCloseTo(40, 6);
+ });
+
it('uses the minimum size and centers a single node', async () => {
render(StammbaumTree, {
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
edges: [],
selectedId: null,
- zoom: 1,
+ panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {}
});
@@ -114,7 +140,7 @@ describe('StammbaumTree viewBox', () => {
}
],
selectedId: null,
- zoom: 1,
+ panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {}
});
@@ -174,7 +200,7 @@ describe('StammbaumTree viewBox', () => {
}
],
selectedId: null,
- zoom: 1,
+ panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {}
});
@@ -277,7 +303,7 @@ describe('StammbaumTree viewBox', () => {
}
],
selectedId: null,
- zoom: 1,
+ panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {}
});
@@ -335,7 +361,7 @@ describe('StammbaumTree viewBox', () => {
}
],
selectedId: null,
- zoom: 1,
+ panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {}
});
@@ -368,7 +394,7 @@ describe('StammbaumTree viewBox', () => {
}
],
selectedId: null,
- zoom: 1,
+ panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {}
});
@@ -393,7 +419,7 @@ describe('StammbaumTree node rendering branches', () => {
],
edges: [],
selectedId: ID_A,
- zoom: 1,
+ panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {}
});
@@ -409,7 +435,7 @@ describe('StammbaumTree node rendering branches', () => {
],
edges: [],
selectedId: null,
- zoom: 1,
+ panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {}
});
@@ -422,7 +448,7 @@ describe('StammbaumTree node rendering branches', () => {
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true, deathYear: 1950 }],
edges: [],
selectedId: null,
- zoom: 1,
+ panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {}
});
@@ -434,7 +460,7 @@ describe('StammbaumTree node rendering branches', () => {
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
edges: [],
selectedId: null,
- zoom: 1,
+ panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {}
});
@@ -447,7 +473,7 @@ describe('StammbaumTree node rendering branches', () => {
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
edges: [],
selectedId: null,
- zoom: 1,
+ panZoom: { x: 0, y: 0, z: 1 },
onSelect
});
@@ -462,7 +488,7 @@ describe('StammbaumTree node rendering branches', () => {
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
edges: [],
selectedId: null,
- zoom: 1,
+ panZoom: { x: 0, y: 0, z: 1 },
onSelect
});
@@ -478,7 +504,7 @@ describe('StammbaumTree node rendering branches', () => {
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
edges: [],
selectedId: null,
- zoom: 1,
+ panZoom: { x: 0, y: 0, z: 1 },
onSelect
});
@@ -486,6 +512,131 @@ describe('StammbaumTree node rendering branches', () => {
node.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true }));
expect(onSelect).toHaveBeenCalledWith(ID_A);
});
+});
+
+describe('StammbaumTree keyboard pan/zoom (#692)', () => {
+ const renderTree = (onPanZoom: (state: PanZoomState) => void) =>
+ render(StammbaumTree, {
+ nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
+ edges: [],
+ selectedId: null,
+ panZoom: { x: 0, y: 0, z: 1 },
+ onPanZoom,
+ onSelect: () => {}
+ });
+
+ it('zooms in on "+" and out on "-" by the keyboard step (OQ-002)', async () => {
+ const onPanZoom = vi.fn();
+ renderTree(onPanZoom);
+ const svg = document.querySelector('svg')!;
+
+ svg.dispatchEvent(new KeyboardEvent('keydown', { key: '+', bubbles: true }));
+ expect(onPanZoom).toHaveBeenCalledTimes(1);
+ expect(onPanZoom.mock.calls[0][0].z).toBeCloseTo(1.1, 6);
+
+ onPanZoom.mockClear();
+ svg.dispatchEvent(new KeyboardEvent('keydown', { key: '-', bubbles: true }));
+ expect(onPanZoom.mock.calls[0][0].z).toBeCloseTo(0.9, 6);
+ });
+
+ it('pans right/down on arrow keys (REQ-PAN-004)', async () => {
+ const onPanZoom = vi.fn();
+ renderTree(onPanZoom);
+ const svg = document.querySelector('svg')!;
+
+ svg.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
+ expect(onPanZoom.mock.calls[0][0].x).toBeGreaterThan(0);
+
+ onPanZoom.mockClear();
+ svg.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
+ expect(onPanZoom.mock.calls[0][0].y).toBeGreaterThan(0);
+ });
+
+ it('pans on a pointer drag and suppresses the trailing node click (US-PAN-001)', async () => {
+ const onPanZoom = vi.fn();
+ const onSelect = vi.fn();
+ render(StammbaumTree, {
+ nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
+ edges: [],
+ selectedId: null,
+ // Zoomed in so panning is permitted (clampPan allows movement at z>1).
+ panZoom: { x: 0, y: 0, z: 2 },
+ onPanZoom,
+ onSelect
+ });
+ const svg = document.querySelector('svg')! as SVGSVGElement;
+ const node = document.querySelector('g[role="button"]') as SVGGElement;
+
+ const opts = (x: number) => ({ pointerId: 1, clientX: x, clientY: 100, bubbles: true });
+ svg.dispatchEvent(new PointerEvent('pointerdown', opts(100)));
+ svg.dispatchEvent(new PointerEvent('pointermove', opts(160)));
+
+ // Assert on the move's emission *before* releasing: inertia starts on
+ // pointerup and could otherwise perturb the last recorded call.
+ expect(onPanZoom).toHaveBeenCalled();
+ // Dragging right reveals content to the left → pan x decreases.
+ expect(onPanZoom.mock.calls.at(-1)![0].x).toBeLessThan(0);
+
+ svg.dispatchEvent(new PointerEvent('pointerup', opts(160)));
+
+ // The synthetic click after a real drag must not select the node.
+ node.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ expect(onSelect).not.toHaveBeenCalled();
+ });
+
+ it('recentres on a node when centreOnId is set, auto-zooming to legible (US-PAN-005)', async () => {
+ const onPanZoom = vi.fn();
+ render(StammbaumTree, {
+ nodes: [
+ { id: ID_A, displayName: 'Anna', familyMember: true },
+ { id: ID_B, displayName: 'Bertha', familyMember: true }
+ ],
+ edges: [
+ {
+ id: 'sp',
+ personId: ID_A,
+ relatedPersonId: ID_B,
+ personDisplayName: 'Anna',
+ relatedPersonDisplayName: 'Bertha',
+ relationType: 'SPOUSE_OF'
+ }
+ ],
+ selectedId: null,
+ panZoom: { x: 0, y: 0, z: 0.5 },
+ centreOnId: ID_A,
+ onPanZoom,
+ onSelect: () => {}
+ });
+
+ await vi.waitFor(() => expect(onPanZoom).toHaveBeenCalled());
+ const view = onPanZoom.mock.calls.at(-1)![0];
+ // Anna sits left of the two-node midpoint → pan x is negative.
+ expect(view.x).toBeLessThan(0);
+ // Zoomed out below legible → snapped up to 1.
+ expect(view.z).toBe(1);
+ });
+
+ it('omits the edge-fade mask at fit (z = 1) (#692)', async () => {
+ render(StammbaumTree, {
+ nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
+ edges: [],
+ selectedId: null,
+ panZoom: { x: 0, y: 0, z: 1 },
+ onSelect: () => {}
+ });
+ expect(document.querySelector('svg')!.getAttribute('style') ?? '').not.toContain('mask-image');
+ });
+
+ it('applies the edge-fade mask when zoomed past fit (#692)', async () => {
+ render(StammbaumTree, {
+ nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
+ edges: [],
+ selectedId: null,
+ panZoom: { x: 0, y: 0, z: 2 },
+ onSelect: () => {}
+ });
+ expect(document.querySelector('svg')!.getAttribute('style') ?? '').toContain('mask-image');
+ });
it('does not call onSelect for other keys', async () => {
const onSelect = vi.fn();
@@ -493,7 +644,7 @@ describe('StammbaumTree node rendering branches', () => {
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
edges: [],
selectedId: null,
- zoom: 1,
+ panZoom: { x: 0, y: 0, z: 1 },
onSelect
});
@@ -520,7 +671,7 @@ describe('StammbaumTree node rendering branches', () => {
}
],
selectedId: null,
- zoom: 1,
+ panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {}
});
@@ -547,7 +698,7 @@ describe('StammbaumTree node rendering branches', () => {
}
],
selectedId: null,
- zoom: 1,
+ panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {}
});
@@ -575,7 +726,7 @@ describe('StammbaumTree node rendering branches', () => {
}
],
selectedId: null,
- zoom: 1,
+ panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {}
});
@@ -588,7 +739,7 @@ describe('StammbaumTree node rendering branches', () => {
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
edges: [],
selectedId: null,
- zoom: 1,
+ panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {}
});
@@ -607,7 +758,7 @@ describe('StammbaumTree node rendering branches', () => {
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
edges: [],
selectedId: null,
- zoom: 1,
+ panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {}
});
@@ -636,7 +787,7 @@ describe('StammbaumTree node rendering branches', () => {
],
edges: [],
selectedId: null,
- zoom: 1,
+ panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {}
});
@@ -653,7 +804,7 @@ describe('StammbaumTree node rendering branches', () => {
],
edges: [],
selectedId: ID_A,
- zoom: 1,
+ panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {}
});
@@ -674,7 +825,7 @@ describe('StammbaumTree node rendering branches', () => {
],
edges: [],
selectedId: ID_A,
- zoom: 1,
+ panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {}
});
@@ -685,10 +836,13 @@ describe('StammbaumTree node rendering branches', () => {
});
});
-describe('StammbaumTree generation gutter (#689)', () => {
- it('renders a G{n} label per occupied generation row when at least one node carries generation', async () => {
- // showGutter overrides the matchMedia detection so the test never
- // depends on the vitest-browser iframe viewport width.
+describe('StammbaumTree generation rail (#689, #692)', () => {
+ const railLabels = () =>
+ Array.from(document.querySelectorAll('[role="text"]')).map((el) =>
+ el.getAttribute('aria-label')
+ );
+
+ it('renders a G{n} label per occupied generation row on the pinned rail', async () => {
render(StammbaumTree, {
nodes: [
{ id: ID_A, displayName: 'Walter', familyMember: true, generation: 2 },
@@ -696,46 +850,47 @@ describe('StammbaumTree generation gutter (#689)', () => {
],
edges: [],
selectedId: null,
- zoom: 1,
- onSelect: () => {},
- showGutter: true
+ panZoom: { x: 0, y: 0, z: 1 },
+ onSelect: () => {}
});
- const labels = Array.from(document.querySelectorAll('g[role="text"]')).map((g) =>
- g.getAttribute('aria-label')
- );
- expect(labels).toContain('Generation 2');
- expect(labels).toContain('Generation 3');
+ await vi.waitFor(() => {
+ const labels = railLabels();
+ expect(labels).toContain('Generation 2');
+ expect(labels).toContain('Generation 3');
+ });
});
- it('wraps the visible G3 text inside an aria-labelled group so screen readers announce "Generation"', async () => {
+ it('labels the chip so screen readers announce "Generation" and shows the G{n} glyph', async () => {
render(StammbaumTree, {
nodes: [{ id: ID_A, displayName: 'Herbert', familyMember: true, generation: 3 }],
edges: [],
selectedId: null,
- zoom: 1,
- onSelect: () => {},
- showGutter: true
+ panZoom: { x: 0, y: 0, z: 1 },
+ onSelect: () => {}
});
- const g3 = Array.from(document.querySelectorAll('g[role="text"]')).find(
- (g) => g.getAttribute('aria-label') === 'Generation 3'
- );
- expect(g3).toBeDefined();
- expect(g3!.querySelector('text')!.textContent).toMatch(/G\s*3/);
+ await vi.waitFor(() => {
+ const g3 = Array.from(document.querySelectorAll('[role="text"]')).find(
+ (el) => el.getAttribute('aria-label') === 'Generation 3'
+ );
+ expect(g3).toBeDefined();
+ expect(g3!.textContent).toMatch(/G\s*3/);
+ });
});
- it('omits the gutter when showGutter is false (mobile breakpoint case)', async () => {
+ it('keeps showing generation labels on the pinned rail even on mobile (showGutter false)', async () => {
+ // The rail is viewport-independent (the #692 point); only the desktop
+ // stripe underlay is gated on the gutter breakpoint.
render(StammbaumTree, {
nodes: [{ id: ID_A, displayName: 'Herbert', familyMember: true, generation: 3 }],
edges: [],
selectedId: null,
- zoom: 1,
+ panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {},
showGutter: false
});
- const labelGroups = Array.from(document.querySelectorAll('g[role="text"]'));
- expect(labelGroups).toHaveLength(0);
+ await vi.waitFor(() => expect(railLabels()).toContain('Generation 3'));
});
});
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..8c5e44ad
--- /dev/null
+++ b/frontend/src/lib/person/genealogy/animateView.test.ts
@@ -0,0 +1,42 @@
+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();
+ });
+});
+
+describe('animateView (animated path)', () => {
+ const from = { x: 0, y: 0, z: 1 };
+ const to = { x: 100, y: 0, z: 2 };
+
+ it('tweens across frames and lands exactly on the target', () => {
+ const frames: { x: number }[] = [];
+ const callbacks: FrameRequestCallback[] = [];
+ vi.stubGlobal('performance', { now: () => 0 });
+ vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => callbacks.push(cb));
+ vi.stubGlobal('cancelAnimationFrame', () => {});
+
+ animateView(from, to, (v) => frames.push(v), { durationMs: 100 });
+
+ callbacks[0](50); // t = 0.5 → an interpolated frame
+ callbacks[callbacks.length - 1](100); // t = 1 → exact target
+
+ expect(frames.length).toBeGreaterThanOrEqual(2);
+ expect(frames[0].x).toBeGreaterThan(0);
+ expect(frames[0].x).toBeLessThan(100);
+ expect(frames.at(-1)).toEqual(to);
+
+ vi.unstubAllGlobals();
+ });
+});
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
new file mode 100644
index 00000000..67af1fa3
--- /dev/null
+++ b/frontend/src/lib/person/genealogy/panZoom.test.ts
@@ -0,0 +1,237 @@
+import { describe, it, expect } from 'vitest';
+import {
+ clampZoom,
+ parsePanZoomParams,
+ serializePanZoomParams,
+ screenDeltaToSvg,
+ zoomAtPoint,
+ pinchZoom,
+ stepInertia,
+ recentreOn,
+ clampPan,
+ cornerView,
+ lerpView,
+ DEFAULT_VIEW,
+ DEFAULT_ZOOM,
+ LEGIBLE_ZOOM,
+ MIN_ZOOM,
+ MAX_ZOOM
+} from './panZoom';
+
+describe('clampZoom', () => {
+ it('returns the value unchanged when within range', () => {
+ expect(clampZoom(1)).toBe(1);
+ expect(clampZoom(0.5)).toBe(0.5);
+ expect(clampZoom(2.75)).toBe(2.75);
+ });
+
+ it('clamps below MIN_ZOOM up to MIN_ZOOM', () => {
+ expect(clampZoom(0.1)).toBe(MIN_ZOOM);
+ expect(clampZoom(0)).toBe(MIN_ZOOM);
+ expect(clampZoom(-5)).toBe(MIN_ZOOM);
+ });
+
+ it('clamps above MAX_ZOOM down to MAX_ZOOM', () => {
+ expect(clampZoom(99)).toBe(MAX_ZOOM);
+ expect(clampZoom(MAX_ZOOM + 0.0001)).toBe(MAX_ZOOM);
+ });
+
+ it('exposes the resolved zoom bounds', () => {
+ expect(MIN_ZOOM).toBe(0.25);
+ expect(MAX_ZOOM).toBe(10);
+ });
+});
+
+describe('parsePanZoomParams', () => {
+ it('parses well-formed cx/cy/z params', () => {
+ expect(parsePanZoomParams({ cx: '120', cy: '-40', z: '1.5' })).toEqual({
+ x: 120,
+ y: -40,
+ z: 1.5
+ });
+ });
+
+ it('falls back to DEFAULT_VIEW when params are absent', () => {
+ expect(parsePanZoomParams({})).toEqual(DEFAULT_VIEW);
+ expect(DEFAULT_VIEW).toEqual({ x: 0, y: 0, z: DEFAULT_ZOOM });
+ });
+
+ it('rejects Infinity and NaN, degrading each axis to its default (Nora #692)', () => {
+ expect(parsePanZoomParams({ z: 'Infinity' }).z).toBe(DEFAULT_ZOOM);
+ expect(parsePanZoomParams({ z: 'NaN' }).z).toBe(DEFAULT_ZOOM);
+ expect(parsePanZoomParams({ cx: 'NaN', cy: 'Infinity' })).toEqual(DEFAULT_VIEW);
+ expect(parsePanZoomParams({ cx: '1e500' }).x).toBe(0);
+ });
+
+ it('clamps an out-of-range zoom into the supported bounds', () => {
+ expect(parsePanZoomParams({ z: '99' }).z).toBe(MAX_ZOOM);
+ expect(parsePanZoomParams({ z: '0.01' }).z).toBe(MIN_ZOOM);
+ expect(parsePanZoomParams({ z: '-3' }).z).toBe(MIN_ZOOM);
+ });
+});
+
+describe('serializePanZoomParams', () => {
+ it('produces string cx/cy/z keys', () => {
+ expect(serializePanZoomParams({ x: 120, y: -40, z: 1.5 })).toEqual({
+ cx: '120',
+ cy: '-40',
+ z: '1.5'
+ });
+ });
+
+ it('round-trips through parsePanZoomParams', () => {
+ const state = { x: 87.5, y: -12.25, z: 2.4 };
+ expect(parsePanZoomParams(serializePanZoomParams(state))).toEqual(state);
+ });
+
+ it('rounds noisy floats so shared URLs stay readable', () => {
+ expect(serializePanZoomParams({ x: 457.8300882631206, y: 0, z: 1.2000000000000002 })).toEqual({
+ cx: '457.83',
+ cy: '0',
+ z: '1.2'
+ });
+ });
+});
+
+describe('screenDeltaToSvg', () => {
+ it('scales a pixel delta by the viewBox-to-element ratio per axis', () => {
+ // viewBox is 2x the element in width, 2x in height → 1px == 2 SVG units.
+ expect(screenDeltaToSvg(10, 5, 1000, 800, 500, 400)).toEqual({ dx: 20, dy: 10 });
+ });
+
+ it('is identity when the viewBox matches the element pixel size', () => {
+ expect(screenDeltaToSvg(7, -3, 600, 600, 600, 600)).toEqual({ dx: 7, dy: -3 });
+ });
+
+ it('returns zero when the element has no measured size', () => {
+ expect(screenDeltaToSvg(10, 10, 1000, 800, 0, 0)).toEqual({ dx: 0, dy: 0 });
+ });
+});
+
+describe('zoomAtPoint', () => {
+ // The anchor is expressed as an offset (in SVG units) from the base viewBox
+ // centre. The fraction of the anchor across the viewBox must not change.
+ const anchorScreenFraction = (state: { x: number; z: number }, anchorOffsetX: number) => {
+ const baseW = 1000;
+ const w = baseW / state.z;
+ const centreOffset = anchorOffsetX - state.x; // anchor relative to viewBox centre
+ return centreOffset / w + 0.5;
+ };
+
+ it('keeps the canvas centre fixed when the anchor is the centre', () => {
+ const next = zoomAtPoint({ x: 0, y: 0, z: 1 }, 2, 0, 0);
+ expect(next).toEqual({ x: 0, y: 0, z: 2 });
+ });
+
+ it('keeps an off-centre anchor at the same screen position across a zoom-in', () => {
+ const before = { x: 0, y: 0, z: 1 };
+ const after = zoomAtPoint(before, 2, 100, 50);
+ expect(after.z).toBe(2);
+ expect(anchorScreenFraction(after, 100)).toBeCloseTo(anchorScreenFraction(before, 100), 10);
+ });
+
+ it('clamps the target zoom and makes no move when already at the bound', () => {
+ const next = zoomAtPoint({ x: 30, y: 10, z: MAX_ZOOM }, 99, 200, 200);
+ expect(next).toEqual({ x: 30, y: 10, z: MAX_ZOOM });
+ });
+});
+
+describe('pinchZoom', () => {
+ it('scales zoom by the finger-distance ratio around the centroid', () => {
+ // Fingers spread 100→200 → 2× zoom; centroid at canvas centre → no pan.
+ expect(pinchZoom({ x: 0, y: 0, z: 1 }, 1, 100, 200, 0, 0)).toEqual({ x: 0, y: 0, z: 2 });
+ });
+
+ it('zooms out when fingers pinch together', () => {
+ expect(pinchZoom({ x: 0, y: 0, z: 2 }, 2, 200, 100, 0, 0).z).toBe(1);
+ });
+
+ it('clamps the scaled zoom into bounds', () => {
+ expect(pinchZoom({ x: 0, y: 0, z: 2 }, 2, 100, 1000, 0, 0).z).toBe(MAX_ZOOM);
+ });
+
+ it('treats a zero start distance as no zoom change', () => {
+ expect(pinchZoom({ x: 5, y: 5, z: 1.5 }, 1.5, 0, 200, 0, 0).z).toBe(1.5);
+ });
+});
+
+describe('stepInertia', () => {
+ it('advances the pan by velocity × frame duration in the drag direction', () => {
+ expect(stepInertia({ x: 100, y: 50, z: 1 }, 0.5, 0.25, 16)).toEqual({ x: 92, y: 46, z: 1 });
+ });
+
+ it('leaves zoom untouched', () => {
+ expect(stepInertia({ x: 0, y: 0, z: 2.5 }, 1, 1, 16).z).toBe(2.5);
+ });
+});
+
+describe('recentreOn', () => {
+ const node = { x: 300, y: 200 };
+ const base = { x: 100, y: 100 };
+
+ it('pans so the node sits at the viewBox centre, keeping the current zoom', () => {
+ expect(recentreOn(node, base, { x: 0, y: 0, z: 1 }, false)).toEqual({ x: 200, y: 100, z: 1 });
+ });
+
+ it('auto-zooms up to LEGIBLE_ZOOM when zoomed out (OQ-005)', () => {
+ const next = recentreOn(node, base, { x: 0, y: 0, z: 0.4 }, true);
+ expect(next.z).toBe(LEGIBLE_ZOOM);
+ expect({ x: next.x, y: next.y }).toEqual({ x: 200, y: 100 });
+ });
+
+ it('does not reduce an already-legible zoom when auto-zooming', () => {
+ expect(recentreOn(node, base, { x: 0, y: 0, z: 2 }, true).z).toBe(2);
+ });
+
+ it('leaves zoom untouched when auto-zoom is off', () => {
+ expect(recentreOn(node, base, { x: 0, y: 0, z: 0.4 }, false).z).toBe(0.4);
+ });
+});
+
+describe('clampPan', () => {
+ // Base frame is 1000 x 800.
+ it('forbids panning when the whole tree fits (z <= 1)', () => {
+ expect(clampPan({ x: 200, y: -100, z: 1 }, 1000, 800)).toEqual({ x: 0, y: 0, z: 1 });
+ expect(clampPan({ x: 50, y: 50, z: 0.5 }, 1000, 800)).toEqual({ x: 0, y: 0, z: 0.5 });
+ });
+
+ it('allows panning up to the edge when zoomed in (no infinite scroll)', () => {
+ // At z=2 the viewBox is 500 wide → limit = (1000 - 500) / 2 = 250.
+ expect(clampPan({ x: 1000, y: 0, z: 2 }, 1000, 800).x).toBe(250);
+ expect(clampPan({ x: -1000, y: 0, z: 2 }, 1000, 800).x).toBe(-250);
+ // Vertical limit at z=2: (800 - 400) / 2 = 200.
+ expect(clampPan({ x: 0, y: 999, z: 2 }, 1000, 800).y).toBe(200);
+ });
+
+ it('leaves an in-range pan untouched', () => {
+ expect(clampPan({ x: 100, y: -50, z: 2 }, 1000, 800)).toEqual({ x: 100, y: -50, z: 2 });
+ });
+});
+
+describe('cornerView', () => {
+ // Frame 0..1000 × 0..800, centre (500, 400).
+ it('puts the viewBox top-left on the target SVG point', () => {
+ // Target = content top-left at (100, 80), z=2 → viewBox is 500×400.
+ expect(cornerView(100, 80, 500, 400, 1000, 800, 2)).toEqual({ x: -150, y: -120, z: 2 });
+ });
+
+ it('reduces to the frame corner when the target is the frame top-left', () => {
+ // Target = frame top-left (0, 0) → most-negative (corner) pan.
+ const v = cornerView(0, 0, 500, 400, 1000, 800, 3);
+ expect(clampPan(v, 1000, 800)).toEqual(v); // on the clamp boundary
+ });
+});
+
+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
new file mode 100644
index 00000000..a19dddef
--- /dev/null
+++ b/frontend/src/lib/person/genealogy/panZoom.ts
@@ -0,0 +1,238 @@
+/**
+ * Pan/zoom geometry for the Stammbaum canvas (#692).
+ *
+ * The Stammbaum renders zoom by deriving the SVG `viewBox` rather than applying
+ * a CSS transform (see `StammbaumTree.svelte`). This module is the single source
+ * of truth for the zoom bounds, the view-state shape, and every pure geometry
+ * helper used by the gesture action, the URL serialiser, and the page. Keeping
+ * the math here (and free of DOM access) makes it unit-testable in the node
+ * project. See ADR-027 for why this is custom rather than a third-party library.
+ */
+
+/**
+ * Zoom bounds. OQ-001 originally resolved the ceiling to 3.0, but because zoom
+ * is normalised to the whole tree, z=3 still shows too much of a wide tree to be
+ * legible on a phone — so the ceiling was raised to 10 (product-owner revision,
+ * #692). SVG stays vector-crisp at any zoom, so a generous max is harmless.
+ */
+export const MIN_ZOOM = 0.25;
+export const MAX_ZOOM = 10;
+export const DEFAULT_ZOOM = 1;
+
+/** Minimum zoom a recentre will snap up to so the focal node's text is legible (OQ-005). */
+export const LEGIBLE_ZOOM = 1;
+
+/** Fixed zoom increment per keyboard `+`/`-` press and per control-button click (OQ-002). */
+export const ZOOM_STEP_KB = 0.1;
+
+/**
+ * The canvas view state. `x`/`y` are pan offsets applied to the viewBox centre
+ * (SVG user units); `z` is the zoom factor. The default `{0, 0, 1}` frames the
+ * whole tree (fit-to-screen) because the base viewBox already encloses the
+ * layout bounding box at z=1.
+ */
+export type PanZoomState = { x: number; y: number; z: number };
+
+/** Fit-to-screen target — frames the whole tree at z=1 (US-PAN-004). */
+export const DEFAULT_VIEW: PanZoomState = { x: 0, y: 0, z: DEFAULT_ZOOM };
+
+/**
+ * Landing zoom for a fresh visit (no URL state). Higher than fit so node tiles
+ * and generation labels are legible on arrival; the fit-to-screen control
+ * (DEFAULT_VIEW, z=1) zooms back out to the whole tree.
+ */
+export const INITIAL_ZOOM = 3;
+export const INITIAL_VIEW: PanZoomState = { x: 0, y: 0, z: INITIAL_ZOOM };
+
+/** Clamp a zoom factor into the supported range. */
+export function clampZoom(z: number): number {
+ return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z));
+}
+
+/** Parse a raw value to a finite number, or return `fallback` for NaN/Infinity/absent. */
+function finiteOr(raw: string | null | undefined, fallback: number): number {
+ if (raw == null) return fallback;
+ const n = Number(raw);
+ return Number.isFinite(n) ? n : fallback;
+}
+
+/**
+ * Parse URL-supplied pan/zoom params into a safe {@link PanZoomState} (OQ-003).
+ *
+ * Every axis is sanitised independently: `Infinity`, `NaN`, overflow (`1e500`),
+ * and absent values degrade to the default for that axis, and the zoom is
+ * clamped into [MIN_ZOOM, MAX_ZOOM]. This guards against a crafted shared link
+ * (`?z=Infinity`, `?cx=NaN`) rendering the SVG blank — see Nora's review (#692).
+ */
+export function parsePanZoomParams(raw: {
+ cx?: string | null;
+ cy?: string | null;
+ z?: string | null;
+}): PanZoomState {
+ return {
+ x: finiteOr(raw.cx, DEFAULT_VIEW.x),
+ y: finiteOr(raw.cy, DEFAULT_VIEW.y),
+ z: clampZoom(finiteOr(raw.z, DEFAULT_ZOOM))
+ };
+}
+
+/** Format a number with at most `dp` decimals, dropping trailing zeros. */
+function round(n: number, dp: number): string {
+ return String(Number(n.toFixed(dp)));
+}
+
+/**
+ * Serialise a view state into URL query params (the inverse of
+ * {@link parsePanZoomParams}). Pan is rounded to 2 decimals and zoom to 3 so
+ * shared links stay readable (no `cx=457.8300882631206` float noise).
+ */
+export function serializePanZoomParams(state: PanZoomState): { cx: string; cy: string; z: string } {
+ return { cx: round(state.x, 2), cy: round(state.y, 2), z: round(state.z, 3) };
+}
+
+/**
+ * Convert a pointer delta in CSS pixels into SVG user units, using the current
+ * viewBox-to-element ratio per axis. This is the distance the pointer traversed
+ * expressed in the tree's coordinate space; the gesture handler subtracts it
+ * from the pan offset so the canvas follows the finger (US-PAN-001).
+ */
+export function screenDeltaToSvg(
+ dxPx: number,
+ dyPx: number,
+ viewBoxW: number,
+ viewBoxH: number,
+ elPxW: number,
+ elPxH: number
+): { dx: number; dy: number } {
+ return {
+ dx: elPxW > 0 ? dxPx * (viewBoxW / elPxW) : 0,
+ dy: elPxH > 0 ? dyPx * (viewBoxH / elPxH) : 0
+ };
+}
+
+/**
+ * Zoom to `newZoom` while keeping a given anchor point fixed on screen
+ * (pinch-centroid zoom — US-PAN-002 AC1 / US-PAN-003 AC1).
+ *
+ * `anchorX`/`anchorY` are the anchor point expressed as an offset, in SVG units,
+ * from the base viewBox centre. Because the viewBox width scales as `1/z`, the
+ * ratio of old-to-new width is exactly `z / newZoom` independent of the base
+ * size, so the new pan offset that preserves the anchor's screen fraction is
+ * `anchor - (anchor - pan) * (z / newZoom)`.
+ */
+export function zoomAtPoint(
+ state: PanZoomState,
+ newZoom: number,
+ anchorX: number,
+ anchorY: number
+): PanZoomState {
+ const z = clampZoom(newZoom);
+ const ratio = state.z / z;
+ return {
+ x: anchorX - (anchorX - state.x) * ratio,
+ y: anchorY - (anchorY - state.y) * ratio,
+ z
+ };
+}
+
+/** Assumed milliseconds per animation frame, used to scale inertia velocity. */
+export const FRAME_MS = 16;
+/** Per-frame velocity decay for pan inertia (OQ-004). */
+export const INERTIA_DECAY = 0.92;
+/** Inertia stops once the velocity (svg units per ms) drops below this. */
+export const INERTIA_MIN_SPEED = 0.02;
+
+/**
+ * Pinch zoom around the gesture centroid (US-PAN-002/003). The new zoom is the
+ * start zoom scaled by the finger-distance ratio (clamped); the anchor offset
+ * keeps the centroid fixed via {@link zoomAtPoint}.
+ */
+export function pinchZoom(
+ state: PanZoomState,
+ startZoom: number,
+ startDist: number,
+ currentDist: number,
+ anchorX: number,
+ anchorY: number
+): PanZoomState {
+ const ratio = startDist > 0 ? currentDist / startDist : 1;
+ return zoomAtPoint(state, clampZoom(startZoom * ratio), anchorX, anchorY);
+}
+
+/**
+ * Advance the pan by one inertia frame: continue the release velocity (svg units
+ * per ms) in the drag direction, scaled by the frame duration. Zoom is untouched.
+ */
+export function stepInertia(
+ state: PanZoomState,
+ velX: number,
+ velY: number,
+ frameMs: number = FRAME_MS
+): PanZoomState {
+ return { x: state.x - velX * frameMs, y: state.y - velY * frameMs, z: state.z };
+}
+
+/** 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
+ * difference between the base frame and the (smaller) zoomed viewBox; when the
+ * whole tree fits (z ≤ 1) the range collapses to zero, so the view stays centred.
+ */
+export function clampPan(state: PanZoomState, baseW: number, baseH: number): PanZoomState {
+ const clampAxis = (pan: number, base: number) => {
+ const limit = Math.max(0, (base - base / state.z) / 2);
+ return Math.min(limit, Math.max(-limit, pan)) || 0; // normalise -0 → 0
+ };
+ return { x: clampAxis(state.x, baseW), y: clampAxis(state.y, baseH), z: state.z };
+}
+
+/**
+ * The view whose viewBox top-left lands on the SVG point (`targetX`, `targetY`)
+ * at zoom `z` — used to anchor a fresh visit to the tree's content corner.
+ * Pass the content bounding-box top-left (not the padded frame corner) so the
+ * first row sits near the top with no empty slack above it.
+ */
+export function cornerView(
+ targetX: number,
+ targetY: number,
+ baseCentreX: number,
+ baseCentreY: number,
+ baseW: number,
+ baseH: number,
+ z: number
+): PanZoomState {
+ return {
+ x: targetX - baseCentreX + baseW / z / 2,
+ y: targetY - baseCentreY + baseH / z / 2,
+ z
+ };
+}
+
+/**
+ * Pan so a node sits at the viewBox centre (US-PAN-005). Because the viewBox
+ * centre is `baseCentre + pan` independent of zoom, centring is a pure pan:
+ * `pan = nodeCentre - baseCentre`. When `autoZoom` is set, a zoomed-out view is
+ * snapped up to {@link LEGIBLE_ZOOM} so the focal node's text is readable
+ * (OQ-005); an already-legible zoom is preserved.
+ */
+export function recentreOn(
+ nodeCentre: { x: number; y: number },
+ baseCentre: { x: number; y: number },
+ state: PanZoomState,
+ autoZoom: boolean
+): PanZoomState {
+ return {
+ x: nodeCentre.x - baseCentre.x,
+ y: nodeCentre.y - baseCentre.y,
+ z: autoZoom ? clampZoom(Math.max(state.z, LEGIBLE_ZOOM)) : state.z
+ };
+}
diff --git a/frontend/src/lib/person/genealogy/panZoomGestures.ts b/frontend/src/lib/person/genealogy/panZoomGestures.ts
new file mode 100644
index 00000000..45f6bf52
--- /dev/null
+++ b/frontend/src/lib/person/genealogy/panZoomGestures.ts
@@ -0,0 +1,256 @@
+import type { Action } from 'svelte/action';
+import {
+ clampPan,
+ clampZoom,
+ screenDeltaToSvg,
+ zoomAtPoint,
+ pinchZoom,
+ stepInertia,
+ FRAME_MS,
+ INERTIA_DECAY,
+ INERTIA_MIN_SPEED,
+ ZOOM_STEP_KB,
+ type PanZoomState
+} from '$lib/person/genealogy/panZoom';
+
+export interface PanZoomGesturesParams {
+ /** The authoritative view state (re-synced at the start of each gesture). */
+ state: PanZoomState;
+ /** Base viewBox geometry at z=1 (includes the gutter) — see StammbaumTree. */
+ baseW: number;
+ baseH: number;
+ baseCentreX: number;
+ baseCentreY: number;
+ /** When true, inertia is skipped and motion stops on release (REQ-PAN-005). */
+ reducedMotion: boolean;
+ onPanZoom: (state: PanZoomState) => void;
+ /** Fired on the first pointer of a gesture (used to dismiss the affordance). */
+ onGestureStart?: () => void;
+}
+
+/** Pointer movement (px) below which a drag is treated as a tap, not a pan. */
+const DRAG_THRESHOLD_PX = 4;
+
+/**
+ * Touch/mouse/wheel pan & zoom for the Stammbaum canvas (#692). Thin DOM glue:
+ * all geometry is delegated to the pure helpers in `panZoom.ts`. One-finger
+ * drag and left-button drag pan; two-finger pinch and Ctrl+wheel zoom around the
+ * gesture centroid; plain wheel pans. Pan is edge-clamped and a real drag
+ * suppresses the trailing node click. Inertia decays after release unless the
+ * user prefers reduced motion.
+ */
+export const panZoomGestures: Action = (node, params) => {
+ let p = params;
+ let current = p.state;
+
+ const pointers = new Map();
+ let dragging = false;
+ let moved = false;
+ let lastX = 0;
+ let lastY = 0;
+ let lastTime = 0;
+ let velX = 0;
+ let velY = 0;
+ let pinchStartDist = 0;
+ let pinchStartZoom = 1;
+ let inertiaFrame = 0;
+ let suppressClick = false;
+
+ const emit = (next: PanZoomState) => {
+ current = clampPan(next, p.baseW, p.baseH);
+ p.onPanZoom(current);
+ };
+
+ const viewBoxW = () => p.baseW / current.z;
+ const viewBoxH = () => p.baseH / current.z;
+
+ // Convert a client point to its anchor offset from the base viewBox centre.
+ const anchorOffset = (clientX: number, clientY: number) => {
+ const rect = node.getBoundingClientRect();
+ const w = viewBoxW();
+ const h = viewBoxH();
+ const fracX = rect.width > 0 ? (clientX - rect.left) / rect.width : 0.5;
+ const fracY = rect.height > 0 ? (clientY - rect.top) / rect.height : 0.5;
+ const svgX = p.baseCentreX + current.x - w / 2 + fracX * w;
+ const svgY = p.baseCentreY + current.y - h / 2 + fracY * h;
+ return { x: svgX - p.baseCentreX, y: svgY - p.baseCentreY };
+ };
+
+ const distance = (a: { x: number; y: number }, b: { x: number; y: number }) =>
+ Math.hypot(a.x - b.x, a.y - b.y);
+
+ const cancelInertia = () => {
+ if (inertiaFrame) cancelAnimationFrame(inertiaFrame);
+ inertiaFrame = 0;
+ };
+
+ const runInertia = () => {
+ if (p.reducedMotion) return;
+ if (Math.hypot(velX, velY) < INERTIA_MIN_SPEED) return;
+ const step = () => {
+ const before = current;
+ emit(stepInertia(current, velX, velY, FRAME_MS));
+ velX *= INERTIA_DECAY;
+ velY *= INERTIA_DECAY;
+ const stalled = current.x === before.x && current.y === before.y;
+ if (!stalled && Math.hypot(velX, velY) >= INERTIA_MIN_SPEED) {
+ inertiaFrame = requestAnimationFrame(step);
+ } else {
+ inertiaFrame = 0;
+ }
+ };
+ inertiaFrame = requestAnimationFrame(step);
+ };
+
+ const onPointerDown = (e: PointerEvent) => {
+ cancelInertia();
+ // NB: do NOT capture the pointer here — capturing on pointerdown makes the
+ // browser dispatch the trailing `click` at this element instead of the
+ // node under the pointer, which silently breaks node selection (a tap must
+ // still reach the node's onclick). Capture is deferred to the first move
+ // that crosses the drag threshold (see onPointerMove).
+ pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
+ p.onGestureStart?.();
+
+ if (pointers.size === 1) {
+ current = p.state; // re-sync from the authoritative state
+ dragging = true;
+ moved = false;
+ lastX = e.clientX;
+ lastY = e.clientY;
+ lastTime = performance.now();
+ velX = 0;
+ velY = 0;
+ } else if (pointers.size === 2) {
+ const [a, b] = [...pointers.values()];
+ pinchStartDist = distance(a, b) || 1;
+ pinchStartZoom = current.z;
+ dragging = false;
+ }
+ };
+
+ const onPointerMove = (e: PointerEvent) => {
+ if (!pointers.has(e.pointerId)) return;
+ pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
+
+ if (pointers.size >= 2) {
+ const [a, b] = [...pointers.values()];
+ const dist = distance(a, b) || 1;
+ const centroid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
+ const anchor = anchorOffset(centroid.x, centroid.y);
+ emit(pinchZoom(current, pinchStartZoom, pinchStartDist, dist, anchor.x, anchor.y));
+ moved = true;
+ return;
+ }
+
+ if (!dragging) return;
+ const dxPx = e.clientX - lastX;
+ const dyPx = e.clientY - lastY;
+ if (!moved && Math.hypot(dxPx, dyPx) >= DRAG_THRESHOLD_PX) {
+ // A real drag has started — now capture so we keep receiving move/up
+ // even if the pointer leaves the canvas. (Deferred from pointerdown so
+ // taps still select nodes.)
+ moved = true;
+ try {
+ node.setPointerCapture(e.pointerId);
+ } catch {
+ /* pointer not capturable (e.g. synthetic event) — drag still works */
+ }
+ }
+
+ const { dx, dy } = screenDeltaToSvg(
+ dxPx,
+ dyPx,
+ viewBoxW(),
+ viewBoxH(),
+ node.clientWidth,
+ node.clientHeight
+ );
+ const now = performance.now();
+ const dt = Math.max(1, now - lastTime);
+ velX = dx / dt;
+ velY = dy / dt;
+ lastTime = now;
+ lastX = e.clientX;
+ lastY = e.clientY;
+ emit({ ...current, x: current.x - dx, y: current.y - dy });
+ };
+
+ const onPointerUp = (e: PointerEvent) => {
+ pointers.delete(e.pointerId);
+ try {
+ if (node.hasPointerCapture?.(e.pointerId)) node.releasePointerCapture(e.pointerId);
+ } catch {
+ /* nothing to release */
+ }
+
+ if (pointers.size === 0) {
+ if (dragging && moved) {
+ suppressClick = true;
+ runInertia();
+ }
+ dragging = false;
+ } else if (pointers.size === 1) {
+ // Dropped from pinch to a single pointer — resume a clean drag.
+ const [only] = [...pointers.entries()];
+ dragging = true;
+ moved = true;
+ lastX = only[1].x;
+ lastY = only[1].y;
+ lastTime = performance.now();
+ }
+ };
+
+ // A drag ends with a synthetic click on the node underneath; swallow it so a
+ // pan does not also select a person (US-PAN-001).
+ const onClickCapture = (e: MouseEvent) => {
+ if (suppressClick) {
+ e.stopPropagation();
+ e.preventDefault();
+ suppressClick = false;
+ }
+ };
+
+ const onWheel = (e: WheelEvent) => {
+ e.preventDefault();
+ if (e.ctrlKey) {
+ const factor = e.deltaY < 0 ? 1 + ZOOM_STEP_KB : 1 / (1 + ZOOM_STEP_KB);
+ const anchor = anchorOffset(e.clientX, e.clientY);
+ emit(zoomAtPoint(current, clampZoom(current.z * factor), anchor.x, anchor.y));
+ return;
+ }
+ const { dx, dy } = screenDeltaToSvg(
+ e.deltaX,
+ e.deltaY,
+ viewBoxW(),
+ viewBoxH(),
+ node.clientWidth,
+ node.clientHeight
+ );
+ emit({ ...current, x: current.x + dx, y: current.y + dy });
+ };
+
+ node.style.touchAction = 'none';
+ node.addEventListener('pointerdown', onPointerDown);
+ node.addEventListener('pointermove', onPointerMove);
+ node.addEventListener('pointerup', onPointerUp);
+ node.addEventListener('pointercancel', onPointerUp);
+ node.addEventListener('click', onClickCapture, true);
+ node.addEventListener('wheel', onWheel, { passive: false });
+
+ return {
+ update(next: PanZoomGesturesParams) {
+ p = next;
+ if (!dragging && pointers.size === 0 && !inertiaFrame) current = next.state;
+ },
+ destroy() {
+ cancelInertia();
+ node.removeEventListener('pointerdown', onPointerDown);
+ node.removeEventListener('pointermove', onPointerMove);
+ node.removeEventListener('pointerup', onPointerUp);
+ node.removeEventListener('pointercancel', onPointerUp);
+ node.removeEventListener('click', onClickCapture, true);
+ node.removeEventListener('wheel', onWheel);
+ }
+ };
+};
diff --git a/frontend/src/lib/shared/actions/trapFocus.svelte.spec.ts b/frontend/src/lib/shared/actions/trapFocus.svelte.spec.ts
new file mode 100644
index 00000000..fcca10a6
--- /dev/null
+++ b/frontend/src/lib/shared/actions/trapFocus.svelte.spec.ts
@@ -0,0 +1,75 @@
+import { describe, it, expect, afterEach } from 'vitest';
+
+const { trapFocus } = await import('./trapFocus');
+
+describe('trapFocus action', () => {
+ const nodes: HTMLElement[] = [];
+
+ function makeContainer(buttonLabels: string[]): {
+ node: HTMLElement;
+ buttons: HTMLButtonElement[];
+ } {
+ const node = document.createElement('div');
+ const buttons = buttonLabels.map((label) => {
+ const b = document.createElement('button');
+ b.textContent = label;
+ node.appendChild(b);
+ return b;
+ });
+ document.body.appendChild(node);
+ nodes.push(node);
+ return { node, buttons };
+ }
+
+ afterEach(() => {
+ nodes.forEach((n) => n.remove());
+ nodes.length = 0;
+ });
+
+ it('focuses the first focusable element on mount', () => {
+ const { node, buttons } = makeContainer(['one', 'two']);
+ trapFocus(node);
+ expect(document.activeElement).toBe(buttons[0]);
+ });
+
+ it('wraps Tab from the last focusable back to the first', () => {
+ const { node, buttons } = makeContainer(['one', 'two']);
+ trapFocus(node);
+ buttons[1].focus();
+ node.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }));
+ expect(document.activeElement).toBe(buttons[0]);
+ });
+
+ it('wraps Shift+Tab from the first focusable to the last', () => {
+ const { node, buttons } = makeContainer(['one', 'two']);
+ trapFocus(node);
+ buttons[0].focus();
+ node.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true }));
+ expect(document.activeElement).toBe(buttons[1]);
+ });
+
+ it('removes its listener on destroy', () => {
+ const { node, buttons } = makeContainer(['one', 'two']);
+ const handle = trapFocus(node);
+ handle.destroy();
+ buttons[1].focus();
+ node.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }));
+ // No trap after destroy → focus stays on the last button.
+ expect(document.activeElement).toBe(buttons[1]);
+ });
+
+ it('restores focus to the previously-focused element on destroy (WCAG 2.4.3)', () => {
+ const opener = document.createElement('button');
+ document.body.appendChild(opener);
+ nodes.push(opener);
+ opener.focus();
+ expect(document.activeElement).toBe(opener);
+
+ const { node } = makeContainer(['one', 'two']);
+ const handle = trapFocus(node);
+ expect(document.activeElement).not.toBe(opener);
+
+ handle.destroy();
+ expect(document.activeElement).toBe(opener);
+ });
+});
diff --git a/frontend/src/lib/shared/actions/trapFocus.ts b/frontend/src/lib/shared/actions/trapFocus.ts
new file mode 100644
index 00000000..fd393843
--- /dev/null
+++ b/frontend/src/lib/shared/actions/trapFocus.ts
@@ -0,0 +1,47 @@
+/**
+ * Trap keyboard focus within a node and move focus to its first focusable
+ * element on mount. Used by modal-style overlays such as the Stammbaum mobile
+ * bottom sheet (#692, NFR-A11Y-004). Tab from the last focusable wraps to the
+ * first and Shift+Tab from the first wraps to the last.
+ */
+const FOCUSABLE_SELECTOR = [
+ 'a[href]',
+ 'button:not([disabled])',
+ 'input:not([disabled])',
+ 'select:not([disabled])',
+ 'textarea:not([disabled])',
+ '[tabindex]:not([tabindex="-1"])'
+].join(',');
+
+export function trapFocus(node: HTMLElement) {
+ // Remember what had focus so it can be restored when the overlay closes
+ // (WCAG 2.4.3 — don't strand keyboard/AT users at the top of the page).
+ const previouslyFocused = document.activeElement as HTMLElement | null;
+ const focusable = () => Array.from(node.querySelectorAll(FOCUSABLE_SELECTOR));
+
+ function onKeydown(event: KeyboardEvent) {
+ if (event.key !== 'Tab') return;
+ const items = focusable();
+ if (items.length === 0) return;
+ const first = items[0];
+ const last = items[items.length - 1];
+ const active = document.activeElement;
+ if (event.shiftKey && active === first) {
+ event.preventDefault();
+ last.focus();
+ } else if (!event.shiftKey && active === last) {
+ event.preventDefault();
+ first.focus();
+ }
+ }
+
+ node.addEventListener('keydown', onKeydown);
+ focusable()[0]?.focus();
+
+ return {
+ destroy() {
+ node.removeEventListener('keydown', onKeydown);
+ previouslyFocused?.focus?.();
+ }
+ };
+}
diff --git a/frontend/src/routes/stammbaum/+page.server.ts b/frontend/src/routes/stammbaum/+page.server.ts
index f060df29..8c4fbff6 100644
--- a/frontend/src/routes/stammbaum/+page.server.ts
+++ b/frontend/src/routes/stammbaum/+page.server.ts
@@ -1,8 +1,9 @@
import { error, redirect } from '@sveltejs/kit';
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors';
+import { parsePanZoomParams, INITIAL_VIEW } from '$lib/person/genealogy/panZoom';
-export async function load({ fetch }) {
+export async function load({ fetch, url }) {
const api = createApiClient(fetch);
const result = await api.GET('/api/network');
@@ -12,6 +13,18 @@ export async function load({ fetch }) {
throw error(result.response.status, getErrorMessage(extractErrorCode(result.error)));
}
+ // A fresh visit (no shared pan/zoom state) lands at the readable INITIAL_VIEW
+ // (z=3). When a link carries a zoom param we honour it, sanitising server-side
+ // so a crafted link (?z=Infinity, ?cx=NaN) degrades to a safe view before
+ // reaching layout geometry (Nora #692).
+ const initialView = url.searchParams.has('z')
+ ? parsePanZoomParams({
+ cx: url.searchParams.get('cx'),
+ cy: url.searchParams.get('cy'),
+ z: url.searchParams.get('z')
+ })
+ : INITIAL_VIEW;
+
const network = result.data!;
- return { nodes: network.nodes ?? [], edges: network.edges ?? [] };
+ return { nodes: network.nodes ?? [], edges: network.edges ?? [], initialView };
}
diff --git a/frontend/src/routes/stammbaum/+page.svelte b/frontend/src/routes/stammbaum/+page.svelte
index f2c774dc..f26f1fce 100644
--- a/frontend/src/routes/stammbaum/+page.svelte
+++ b/frontend/src/routes/stammbaum/+page.svelte
@@ -1,15 +1,28 @@
@@ -110,18 +157,16 @@ function zoomOut() {
node={selectedNode}
canWrite={canWrite}
onClose={() => (selectedId = null)}
+ onCentre={centreOnSelected}
/>
-
-
- (selectedId = null)}
- />
-
+
+ (selectedId = null)}
+ onCentre={centreOnSelected}
+ />
{/if}
{/if}
diff --git a/frontend/src/routes/stammbaum/page.server.test.ts b/frontend/src/routes/stammbaum/page.server.test.ts
new file mode 100644
index 00000000..7efa0783
--- /dev/null
+++ b/frontend/src/routes/stammbaum/page.server.test.ts
@@ -0,0 +1,77 @@
+import { describe, expect, it, vi, beforeEach } from 'vitest';
+
+vi.mock('$lib/shared/api.server', () => ({
+ createApiClient: vi.fn(),
+ extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
+}));
+
+import { createApiClient } from '$lib/shared/api.server';
+import { DEFAULT_VIEW, INITIAL_VIEW, MAX_ZOOM } from '$lib/person/genealogy/panZoom';
+
+beforeEach(() => vi.clearAllMocks());
+
+function mockNetwork() {
+ vi.mocked(createApiClient).mockReturnValue({
+ GET: vi.fn().mockResolvedValue({ response: { ok: true }, data: { nodes: [], edges: [] } })
+ } as unknown as ReturnType);
+}
+
+function mockNetworkResponse(status: number) {
+ vi.mocked(createApiClient).mockReturnValue({
+ GET: vi.fn().mockResolvedValue({ response: { ok: false, status }, error: { code: 'X' } })
+ } as unknown as ReturnType);
+}
+
+function loadEvent(query = '') {
+ const url = new URL(`http://localhost/stammbaum${query}`);
+ return {
+ fetch: vi.fn() as unknown as typeof fetch,
+ request: new Request(url),
+ url
+ };
+}
+
+describe('/stammbaum +page.server load — initialView', () => {
+ it('returns the readable INITIAL_VIEW (z=3) for a fresh visit with no params', async () => {
+ mockNetwork();
+ const { load } = await import('./+page.server');
+ const result = await load(loadEvent() as never);
+ expect(result.initialView).toEqual(INITIAL_VIEW);
+ });
+
+ it('parses and returns valid ?cx&cy&z params', async () => {
+ mockNetwork();
+ const { load } = await import('./+page.server');
+ const result = await load(loadEvent('?cx=120&cy=-40&z=1.5') as never);
+ expect(result.initialView).toEqual({ x: 120, y: -40, z: 1.5 });
+ });
+
+ it('degrades a crafted ?z=Infinity to a safe view (Nora #692)', async () => {
+ mockNetwork();
+ const { load } = await import('./+page.server');
+ const result = await load(loadEvent('?z=Infinity&cx=NaN') as never);
+ expect(result.initialView).toEqual(DEFAULT_VIEW);
+ });
+
+ it('clamps an out-of-range zoom server-side', async () => {
+ mockNetwork();
+ const { load } = await import('./+page.server');
+ const result = await load(loadEvent('?z=99') as never);
+ expect(result.initialView.z).toBe(MAX_ZOOM);
+ });
+
+ it('redirects to /login when the network API returns 401', async () => {
+ mockNetworkResponse(401);
+ const { load } = await import('./+page.server');
+ await expect(load(loadEvent() as never)).rejects.toMatchObject({
+ status: 302,
+ location: '/login'
+ });
+ });
+
+ it('throws an HTTP error when the network API fails', async () => {
+ mockNetworkResponse(500);
+ const { load } = await import('./+page.server');
+ await expect(load(loadEvent() as never)).rejects.toMatchObject({ status: 500 });
+ });
+});
diff --git a/frontend/src/routes/stammbaum/page.svelte.test.ts b/frontend/src/routes/stammbaum/page.svelte.test.ts
index a788c102..b9f58e20 100644
--- a/frontend/src/routes/stammbaum/page.svelte.test.ts
+++ b/frontend/src/routes/stammbaum/page.svelte.test.ts
@@ -1,9 +1,11 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
+import { DEFAULT_VIEW } from '$lib/person/genealogy/panZoom';
const mockPage = {
url: new URL('http://localhost/stammbaum'),
+ state: {},
data: { canWrite: false } as { canWrite: boolean }
};
@@ -13,6 +15,15 @@ vi.mock('$app/state', () => ({
}
}));
+const replaceState = vi.fn();
+vi.mock('$app/navigation', () => ({
+ replaceState: (...args: unknown[]) => replaceState(...args),
+ // StammbaumSidePanel (rendered transitively) imports invalidateAll/goto, so
+ // the mock must provide every export the module graph uses.
+ invalidateAll: vi.fn(),
+ goto: vi.fn()
+}));
+
afterEach(cleanup);
async function loadComponent() {
@@ -28,7 +39,7 @@ describe('stammbaum page', () => {
it('shows the empty state when there are no family nodes', async () => {
mockPage.url = new URL('http://localhost/stammbaum');
const Stammbaum = await loadComponent();
- render(Stammbaum, { props: { data: { nodes: [], edges: [] } } });
+ render(Stammbaum, { props: { data: { nodes: [], edges: [], initialView: DEFAULT_VIEW } } });
await expect
.element(page.getByRole('heading', { name: /noch keine familienmitglieder/i }))
@@ -41,7 +52,7 @@ describe('stammbaum page', () => {
it('hides zoom controls when there are no nodes', async () => {
mockPage.url = new URL('http://localhost/stammbaum');
const Stammbaum = await loadComponent();
- render(Stammbaum, { props: { data: { nodes: [], edges: [] } } });
+ render(Stammbaum, { props: { data: { nodes: [], edges: [], initialView: DEFAULT_VIEW } } });
await expect.element(page.getByRole('button', { name: /vergrößern/i })).not.toBeInTheDocument();
await expect
@@ -52,7 +63,9 @@ describe('stammbaum page', () => {
it('renders the page heading and zoom controls when nodes are present', async () => {
mockPage.url = new URL('http://localhost/stammbaum');
const Stammbaum = await loadComponent();
- render(Stammbaum, { props: { data: { nodes: sampleNodes, edges: [] } } });
+ render(Stammbaum, {
+ props: { data: { nodes: sampleNodes, edges: [], initialView: DEFAULT_VIEW } }
+ });
await expect.element(page.getByRole('heading', { name: /stammbaum/i })).toBeVisible();
await expect.element(page.getByRole('button', { name: /vergrößern/i })).toBeVisible();
@@ -62,7 +75,9 @@ describe('stammbaum page', () => {
it('preselects a node when the URL has a focus query param matching an existing node', async () => {
mockPage.url = new URL('http://localhost/stammbaum?focus=p-1');
const Stammbaum = await loadComponent();
- render(Stammbaum, { props: { data: { nodes: sampleNodes, edges: [] } } });
+ render(Stammbaum, {
+ props: { data: { nodes: sampleNodes, edges: [], initialView: DEFAULT_VIEW } }
+ });
await expect.element(page.getByRole('complementary')).toBeVisible();
});
@@ -70,19 +85,44 @@ describe('stammbaum page', () => {
it('does not preselect when the focus param does not match any node', async () => {
mockPage.url = new URL('http://localhost/stammbaum?focus=missing');
const Stammbaum = await loadComponent();
- render(Stammbaum, { props: { data: { nodes: sampleNodes, edges: [] } } });
+ render(Stammbaum, {
+ props: { data: { nodes: sampleNodes, edges: [], initialView: DEFAULT_VIEW } }
+ });
await expect.element(page.getByRole('complementary')).not.toBeInTheDocument();
});
- it('clamps the zoom level when the zoom-out button is clicked many times', async () => {
+ it('clamps zoom-out at MIN_ZOOM (0.25), reflected in the mirrored ?z param', async () => {
mockPage.url = new URL('http://localhost/stammbaum');
+ replaceState.mockClear();
const Stammbaum = await loadComponent();
- render(Stammbaum, { props: { data: { nodes: sampleNodes, edges: [] } } });
+ render(Stammbaum, {
+ props: { data: { nodes: sampleNodes, edges: [], initialView: DEFAULT_VIEW } }
+ });
const zoomOut = page.getByRole('button', { name: /verkleinern/i });
- for (let i = 0; i < 10; i++) await zoomOut.click();
- // Just verify that repeated clicks don't throw — branch coverage
- await expect.element(zoomOut).toBeVisible();
+ // Default z=1; well over (1 - 0.25) / 0.1 = 8 steps to reach the floor.
+ for (let i = 0; i < 15; i++) await zoomOut.click();
+
+ await vi.waitFor(() => {
+ const url = replaceState.mock.calls.at(-1)![0] as URL;
+ expect(url.searchParams.get('z')).toBe('0.25');
+ });
+ });
+
+ it('mirrors the view into ?cx&cy&z when zoomed (US-PANEL-002 AC2)', async () => {
+ mockPage.url = new URL('http://localhost/stammbaum');
+ replaceState.mockClear();
+ const Stammbaum = await loadComponent();
+ render(Stammbaum, {
+ props: { data: { nodes: sampleNodes, edges: [], initialView: DEFAULT_VIEW } }
+ });
+
+ await page.getByRole('button', { name: /vergrößern/i }).click();
+ await vi.waitFor(() => expect(replaceState).toHaveBeenCalled());
+ const url = replaceState.mock.calls.at(-1)![0] as URL;
+ expect(url.searchParams.get('z')).toBeTruthy();
+ expect(url.searchParams.has('cx')).toBe(true);
+ expect(url.searchParams.has('cy')).toBe(true);
});
});