Compare commits

..

39 Commits

Author SHA1 Message Date
Marcel
8cc6031ef0 refactor(stammbaum): split StammbaumTree into Connectors + Node components (#692)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m21s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m37s
CI / fail2ban Regex (pull_request) Successful in 47s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
Extract the three SVG connector layers (+ the parent-link graph computation)
into StammbaumConnectors.svelte and the node <g> into StammbaumNode.svelte (which
now owns its own focus-ring state). StammbaumTree drops 546→308 lines and is now
an orchestrator: layout, gutter/reduced-motion state, viewBox, gestures, rail,
anchor. Rendered SVG is byte-identical, so the existing browser tests are
unchanged. Verified live: 62 nodes + 58 connector lines render, node-tap selects.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 21:42:53 +02:00
Marcel
ecae789be2 test(stammbaum): fix two CI-only browser-test failures (#692)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m18s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m36s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m4s
- page.svelte.test.ts mocked $app/navigation with only replaceState, dropping
  invalidateAll (imported by StammbaumSidePanel) → the module errored and failed
  all 7 tests in the file. Mock now exports invalidateAll + goto too.
- StammbaumTree viewBox 'offsets origin' test hard-coded a wrong unpanned-x; assert
  the robust relationship instead (viewBox centre − content centroid == pan).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 20:42:50 +02:00
Marcel
95d35c20b2 fix(stammbaum): address re-review nits — opaque rail, stale docs, rail clarity (#692)
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m35s
CI / OCR Service Tests (pull_request) Successful in 21s
CI / Backend Unit Tests (pull_request) Successful in 3m38s
CI / fail2ban Regex (pull_request) Successful in 41s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
- Rail chip background opaque (was /85) so G{n} labels stay AA-legible over
  tree content (Leonie).
- Rail effect: replace the reactKey hack with an inputsFinite guard that both
  tracks deps and guards NaN; name the fallback-stack magics; correct the stale
  'xMidYMid' comment (the CTM mapping is preserveAspectRatio-agnostic) (Felix/Markus).
- GLOSSARY zoom range 0.25–3.0 → 0.25–10; ADR-027 preserveAspectRatio note
  xMidYMid → xMinYMin (Elicit traceability).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 20:21:13 +02:00
Marcel
11dc25ef31 fix(stammbaum): anchor fresh visit to content top-left, drop space above row 1 (#692)
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m35s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m41s
CI / fail2ban Regex (pull_request) Successful in 43s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
The frame-corner anchor + xMidYMid letterboxing left ~290px of empty space
above the first row on desktop. Anchor to the content corner (first row /
leftmost node, small margin) via cornerView, and switch the canvas to
xMinYMin meet so a wide/short tree pins to the top-left instead of centring
vertically. Verified live: gap above row 1 is now ~20px.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 19:40:04 +02:00
Marcel
b1309db8db feat(stammbaum): land a fresh visit on the tree's top-left corner (#692)
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m35s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m33s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
At z=3 a pan of {0,0} centres on the tree midpoint; a fresh visit (no shared
?z) now anchors the viewBox to the tree's top-left corner via topLeftView
(the negative clamp limit), emitted on mount. Shared links still win.
Verified live: lands at cx<0, cy<0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 19:25:03 +02:00
Marcel
01b902e885 test(stammbaum): assert zoom-out floor via mirrored ?z; e2e affordance beforeEach (#692)
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m35s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m40s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m7s
Strengthen the zoom-clamp test to assert z floors at 0.25 in the URL (was a
'does not throw' smoke test) and move the affordance localStorage reset to a
beforeEach so the e2e tests are order-independent (QA review).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 19:06:06 +02:00
Marcel
20db3d0d8f test(stammbaum): cover animateView rAF tween + server 401/500 paths (#692)
Add a deterministic stubbed-rAF test for animateView's animated path (was only
covering the reduced-motion branch) and assert the server load redirects on 401
and throws on a network 500 (QA review).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 19:04:22 +02:00
Marcel
0306023610 fix(stammbaum): 44x44 touch targets for panel + affordance icon buttons (#692)
Enlarge the centre-on-person, panel-close, and affordance-dismiss icon buttons
to 44x44 hit areas (WCAG 2.5.8, UX review) while keeping the small glyphs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 19:00:58 +02:00
Marcel
8f836dfefb feat(stammbaum): raise MAX_ZOOM 3→10 so phones can zoom in to read (#692)
Zoom is normalised to the whole tree, so z=3 still renders a wide tree too
small on a phone. Raise the ceiling to 10 (revises OQ-001); SVG stays crisp at
any zoom so a generous max is harmless.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 18:58:38 +02:00
Marcel
b170085311 fix(stammbaum): node tap stopped selecting — defer pointer capture to drag start (#692)
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m34s
CI / OCR Service Tests (pull_request) Successful in 21s
CI / Backend Unit Tests (pull_request) Successful in 3m41s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m7s
Capturing the pointer on pointerdown made the browser dispatch the trailing
click at the SVG instead of the node under the finger, so node taps silently
stopped opening the person panel. Capture only once a drag crosses the
threshold; a tap now reaches the node's onclick. Verified live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 18:54:48 +02:00
Marcel
d5a7974f3a fix(shared): trapFocus restores focus to the opener on destroy (#692)
When the bottom sheet closes, focus returns to the element that was focused
before it opened instead of being dropped to document.body (WCAG 2.4.3,
Architect + UX review).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 18:50:54 +02:00
Marcel
53660eadc9 test(stammbaum): assert drag-pan before release to avoid inertia flake (#692)
Read the pan emission from the pointermove (deterministic) instead of the
post-pointerup last call, which inertia could perturb when reduced-motion is
not forced in vitest-browser (QA blocker).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 18:49:03 +02:00
Marcel
f4b631e1bc refactor(stammbaum): extract + unit-test pinch and inertia math (#692)
Move the pinch-zoom (pinchZoom) and inertia-step (stepInertia) geometry out of
the panZoomGestures DOM glue into pure, unit-tested helpers in panZoom.ts, with
named FRAME_MS/INERTIA_* constants. Addresses the QA blocker that the gesture
module's core math was untested. No behaviour change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 18:47:29 +02:00
Marcel
c1dd6d299f feat(stammbaum): round pan/zoom URL params for readable shared links (#692)
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m36s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m30s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
Pan rounded to 2 decimals, zoom to 3, so ?cx/?cy/?z no longer carry float
noise like cx=457.8300882631206.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 18:42:11 +02:00
Marcel
a458d3508b feat(stammbaum): pinned generation-label rail on all viewports (#692)
Generation labels are no longer drawn in-SVG (where they panned/zoomed off
screen and were desktop-only). A new StammbaumGenerationRail overlays the canvas
left edge, mapping each generation row's centre through the SVG's live
getScreenCTM so chips stay pinned horizontally and track their row vertically at
any pan/zoom — on phones too. The desktop stripe underlay stays (gated on the
gutter breakpoint); the #689 label tests are rewritten against the rail.
Verified live: labels stay at left=4px while the canvas pans.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 18:39:22 +02:00
Marcel
bb2a89da58 feat(stammbaum): land a fresh visit at readable z=3, keep fit-to-screen at z=1 (#692)
A fresh visit (no URL state) now opens at INITIAL_VIEW (z=3) so node tiles and
generation labels are legible on arrival; the fit-to-screen control still zooms
out to the whole tree (DEFAULT_VIEW, z=1). Shared links with ?z still win.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 18:00:17 +02:00
Marcel
578bebbd8b fix(stammbaum): URL pan/zoom sync never fired — gate replaceState on router-ready (#692)
replaceState throws 'before the router is initialized' during hydration, which
killed the sync $effect on its first tick so the URL never updated on pan/zoom.
Gate the write behind a flag flipped after the first post-mount tick() (router
started) plus a defensive try/catch. Verified live: zoom now updates ?z=.
The prior component test mocked replaceState and masked this.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 17:56:22 +02:00
Marcel
7e859252a3 docs(stammbaum): renumber pan/zoom ADR 026→027 (collision with #361) (#692)
The #361 layout ADR already owns 026; renumber the custom-viewBox pan/zoom ADR
to 027 and update the glossary + panZoom.ts references (Elicit review).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 17:48:42 +02:00
Marcel
ba053b3c23 docs(stammbaum): ADR-026 custom viewBox pan/zoom + glossary terms (#692)
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m35s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m41s
CI / fail2ban Regex (pull_request) Successful in 47s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
Record the reversal of OQ-007 (build custom over the existing viewBox rather
than adopt the panzoom library) and add pan/zoom view-state + fit-to-screen
glossary entries.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 17:17:10 +02:00
Marcel
80f5e0b147 test(stammbaum): mobile visual + structural e2e at 320/414/768 (#692)
VISUAL-gated screenshots of the first-load affordance + control cluster at
each width and the bottom-sheet-open state at 414px, plus always-on structural
assertions. New snapshots; the #361 desktop baselines are untouched. Baselines
regenerate in CI via --update-snapshots.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 17:15:36 +02:00
Marcel
11b70d814f feat(stammbaum): first-load touch affordance hint (#692)
Add StammbaumAffordance: a touch-only "drag to explore · pinch to zoom" hint
that auto-dismisses on the first canvas pointer interaction (wired via the
gesture action's onGestureStart) or the explicit close, and stays dismissed for
30 days via a localStorage timestamp (boolean gate only, never rendered).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 17:13:36 +02:00
Marcel
1dffb430ac feat(stammbaum): centre-on-person control in the panel title row (#692)
Add an onCentre control to StammbaumSidePanel (title row, both desktop aside
and mobile sheet). The page drives a one-shot centreOnId so StammbaumTree
recentres the canvas on the focal node (US-PAN-005). Also tighten the panel
spec's deathYear fixture to a valid type.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 17:10:49 +02:00
Marcel
1e5a45a027 feat(stammbaum): dismissible accessible mobile bottom sheet (#692)
Wrap the mobile person panel in StammbaumBottomSheet: drag-handle grip with
swipe-down-to-dismiss (≥80px), full-screen backdrop button for tap-outside
dismiss, role=dialog + aria-label, focus trap, and Escape (NFR-A11Y-004).
Pan/zoom state is untouched by open/close (US-PANEL-001/002).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 17:06:55 +02:00
Marcel
ccc37fe1bb feat(shared): add trapFocus action for modal overlays (#692)
Focuses the first focusable on mount and wraps Tab/Shift+Tab within the node.
Used by the Stammbaum mobile bottom sheet (NFR-A11Y-004).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 17:04:12 +02:00
Marcel
289c3bbfb5 feat(stammbaum): sync view to shareable ?cx&cy&z URL (#692)
A view-keyed effect mirrors pan/zoom into the URL via replaceState (URL read
untracked to avoid a feedback loop). State survives panel open/close
(US-PANEL-002 AC1) and a shared link reproduces the view (AC2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 17:02:47 +02:00
Marcel
8d29bb10e2 feat(stammbaum): server-clamped initial view from ?cx&cy&z (#692)
The server load parses and sanitises the shareable pan/zoom params (degrading
Infinity/NaN, clamping zoom) into initialView, which seeds the page view. A
crafted link can no longer blank the SVG (Nora). US-PANEL-002 AC2 groundwork.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 16:58:36 +02:00
Marcel
396c87f8ab 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>
2026-05-29 16:54:34 +02:00
Marcel
7a6c2e877f feat(stammbaum): bottom-right zoom + fit-to-screen control cluster (#692)
Move zoom controls out of the page header into a docked bottom-right cluster
inside the canvas (one-handed phone reach, Leonie) and add a fit-to-screen
button (data-testid=fit-to-screen). Add the 5 new i18n keys to de/en/es.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 16:52:32 +02:00
Marcel
ffc14dd2ff feat(stammbaum): edge-fade mask when zoomed past fit (#692)
Permanent 4-edge mask-image gradient cues off-screen content when the tree is
zoomed in; nothing fades at fit. Replaces the dropped US-PAN-006 AC3 idle cue.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 16:48:56 +02:00
Marcel
3827a9d059 feat(stammbaum): recentre on a node via centreOnId prop (#692)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 16:47:10 +02:00
Marcel
c8931071ba feat(stammbaum): touch/mouse/wheel pan & pinch zoom gestures (#692)
Add a panZoomGestures action: one-finger/left-button drag pans, two-finger
pinch and Ctrl+wheel zoom around the centroid, plain wheel pans. Pan is
edge-clamped via clampPan (no infinite scroll), a real drag suppresses the
trailing node click, and inertia decays after release unless prefers-reduced-
motion. Canvas container switches from native scroll to overflow-hidden.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 16:45:18 +02:00
Marcel
da1984b916 feat(stammbaum): keyboard pan/zoom on the canvas (#692)
+/- zoom by the fixed step and arrow keys pan by a tenth of the visible
extent, emitted via onPanZoom. Provides the keyboard-only alternative path
required by NFR-A11Y-002. Nodes keep their own Enter/Space selection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 16:39:55 +02:00
Marcel
0422af8980 feat(stammbaum): drive viewBox from PanZoomState (pan + zoom) (#692)
Replace the scalar zoom prop with a {x,y,z} PanZoomState. The viewBox centre
is offset by the pan and width/height scaled by zoom; the default {0,0,1}
frames the whole tree (fit-to-screen). Page header buttons now step view.z
through clampZoom over the resolved 0.25–3.0 range.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 16:35:49 +02:00
Marcel
197b668f20 feat(stammbaum): recentre-on-node with legible auto-zoom (#692)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 16:29:55 +02:00
Marcel
5d752fcc0f feat(stammbaum): centroid-anchored zoom (zoomAtPoint) (#692)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 16:28:41 +02:00
Marcel
0170f79690 feat(stammbaum): convert pointer pixel delta to SVG units (#692)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 16:27:14 +02:00
Marcel
369a0213e5 feat(stammbaum): serialise pan/zoom state to URL params (#692)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 16:26:07 +02:00
Marcel
a7d0e96613 feat(stammbaum): parse + sanitise URL pan/zoom params (#692)
Degrade Infinity/NaN/overflow per axis and clamp zoom into bounds so a crafted
?cx/?cy/?z shared link cannot blank the SVG (Nora's review).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 16:25:11 +02:00
Marcel
5458ca9bae feat(stammbaum): add clampZoom with resolved 0.25–3.0 zoom bounds (#692)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 16:23:47 +02:00
30 changed files with 2444 additions and 414 deletions

View File

@@ -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.2510). 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

View File

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

View File

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

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -0,0 +1,90 @@
<script lang="ts">
import { onMount } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
interface Props {
/** Set true once the canvas receives its first pointer interaction. */
dismissed?: boolean;
/**
* Force touch mode on/off. When undefined, falls back to a
* `matchMedia('(pointer: coarse)')` check so the hint only appears on touch
* devices (OQ-008). Tests pass an explicit boolean.
*/
touch?: boolean;
}
let { dismissed = false, touch }: Props = $props();
// Boolean gate only — the stored timestamp is compared, never rendered to the
// DOM (Nora #692). 30-day re-show window (NFR-USE-001).
const STORAGE_KEY = 'stammbaumAffordanceDismissedAt';
const RESHOW_AFTER_MS = 30 * 24 * 60 * 60 * 1000;
function recentlyDismissed(): boolean {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return false;
return Date.now() - Number(raw) < RESHOW_AFTER_MS;
} catch {
return false;
}
}
function isTouch(): boolean {
if (touch !== undefined) return touch;
return (
typeof window !== 'undefined' &&
typeof window.matchMedia === 'function' &&
window.matchMedia('(pointer: coarse)').matches
);
}
let visible = $state(false);
onMount(() => {
visible = isTouch() && !recentlyDismissed();
});
function hide() {
try {
localStorage.setItem(STORAGE_KEY, String(Date.now()));
} catch {
/* storage unavailable — hide anyway for this session */
}
visible = false;
}
// First canvas interaction auto-dismisses the hint (Leonie).
$effect(() => {
if (dismissed && visible) hide();
});
</script>
{#if visible}
<div
class="pointer-events-none absolute inset-x-0 bottom-4 z-20 flex justify-center px-4"
role="status"
>
<div
class="pointer-events-auto flex items-center gap-2 rounded-full border border-line bg-surface/95 px-4 py-2 text-sm text-ink-2 shadow-sm"
>
<span>{m.stammbaum_affordance_hint()}</span>
<button
type="button"
onclick={hide}
aria-label={m.stammbaum_affordance_dismiss()}
class="-my-2 inline-flex h-11 w-11 items-center justify-center rounded-sm text-ink-3 transition hover:text-ink focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none"
>
<svg
class="h-3.5 w-3.5"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path stroke-linecap="round" d="M3 3l10 10M13 3L3 13" />
</svg>
</button>
</div>
</div>
{/if}

View File

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

View File

@@ -0,0 +1,75 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { trapFocus } from '$lib/shared/actions/trapFocus';
import StammbaumSidePanel from '$lib/person/genealogy/StammbaumSidePanel.svelte';
import type { components } from '$lib/generated/api';
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
interface Props {
node: PersonNodeDTO;
canWrite: boolean;
onClose: () => void;
onCentre?: () => void;
}
let { node, canWrite, onClose, onCentre }: Props = $props();
// Swipe the sheet down past this threshold to dismiss it (Leonie).
const SWIPE_DISMISS_PX = 80;
let dragY = $state(0);
let dragging = false;
let startY = 0;
function onHandleDown(event: PointerEvent) {
dragging = true;
startY = event.clientY;
(event.currentTarget as HTMLElement).setPointerCapture?.(event.pointerId);
}
function onHandleMove(event: PointerEvent) {
if (dragging) dragY = Math.max(0, event.clientY - startY);
}
function onHandleUp() {
if (!dragging) return;
dragging = false;
if (dragY >= SWIPE_DISMISS_PX) onClose();
dragY = 0;
}
function onKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose();
}
</script>
<!-- Backdrop: a full-screen button so tap-outside dismiss is keyboard- and
screen-reader-accessible without a static-element click handler. -->
<button
type="button"
class="fixed inset-0 z-30 bg-black/30 md:hidden"
aria-label={m.stammbaum_close_panel()}
onclick={onClose}
></button>
<div
role="dialog"
aria-modal="true"
aria-label={node.displayName}
class="fixed inset-x-0 bottom-0 z-40 max-h-[60dvh] overflow-y-auto rounded-t-xl border-t border-line bg-surface shadow-lg md:hidden"
style="transform: translateY({dragY}px);"
use:trapFocus
onkeydown={onKeydown}
>
<!-- Drag handle grip — swipe down to dismiss. -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="flex cursor-grab justify-center py-2 active:cursor-grabbing"
onpointerdown={onHandleDown}
onpointermove={onHandleMove}
onpointerup={onHandleUp}
onpointercancel={onHandleUp}
>
<div class="h-1 w-10 rounded-full bg-line" aria-hidden="true"></div>
</div>
<StammbaumSidePanel node={node} canWrite={canWrite} onClose={onClose} onCentre={onCentre} />
</div>

View File

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

View File

@@ -0,0 +1,189 @@
<script lang="ts">
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import { type Layout, NODE_W, NODE_H } from '$lib/person/genealogy/layout/buildLayout';
import type { components } from '$lib/generated/api';
type RelationshipDTO = components['schemas']['RelationshipDTO'];
interface Props {
edges: RelationshipDTO[];
positions: Layout['positions'];
}
let { edges, positions }: Props = $props();
function nodeCenter(id: string): { x: number; y: number } | null {
const p = positions.get(id);
if (!p) return null;
return { x: p.x + NODE_W / 2, y: p.y + NODE_H / 2 };
}
const parentEdges = $derived(edges.filter((e) => e.relationType === 'PARENT_OF'));
const spouseEdges = $derived(edges.filter((e) => e.relationType === 'SPOUSE_OF'));
function pairKey(a: string, b: string): string {
return a < b ? `${a}|${b}` : `${b}|${a}`;
}
type ParentLinks = {
// One entry per spouse-pair-with-children: drives the drop + sibling-bar
// + per-child vertical pattern in the SVG.
shared: { key: string; parentA: string; parentB: string; childIds: string[] }[];
// One entry per remaining parent → child edge (single parents, or the
// "second" parent edge when only one parent is in the spouse pair).
single: { key: string; parentId: string; childId: string }[];
};
const parentLinks = $derived.by<ParentLinks>(() => {
const spousePairs = new SvelteSet<string>();
for (const e of spouseEdges) {
spousePairs.add(pairKey(e.personId, e.relatedPersonId));
}
const childToParents = new SvelteMap<string, string[]>();
for (const e of parentEdges) {
const list = childToParents.get(e.relatedPersonId) ?? [];
list.push(e.personId);
childToParents.set(e.relatedPersonId, list);
}
const sharedMap = new SvelteMap<
string,
{ parentA: string; parentB: string; childIds: string[] }
>();
const single: ParentLinks['single'] = [];
for (const [childId, parents] of childToParents) {
const consumed = new SvelteSet<string>();
for (let i = 0; i < parents.length; i++) {
if (consumed.has(parents[i])) continue;
for (let j = i + 1; j < parents.length; j++) {
if (consumed.has(parents[j])) continue;
if (spousePairs.has(pairKey(parents[i], parents[j]))) {
const groupKey = pairKey(parents[i], parents[j]);
const existing = sharedMap.get(groupKey);
if (existing) {
existing.childIds.push(childId);
} else {
sharedMap.set(groupKey, {
parentA: parents[i],
parentB: parents[j],
childIds: [childId]
});
}
consumed.add(parents[i]);
consumed.add(parents[j]);
break;
}
}
}
for (const parentId of parents) {
if (consumed.has(parentId)) continue;
single.push({ key: `${parentId}->${childId}`, parentId, childId });
}
}
const shared: ParentLinks['shared'] = [];
for (const [key, group] of sharedMap) shared.push({ key, ...group });
return { shared, single };
});
</script>
<!-- Shared parent-pair → children: drop from spouse midpoint to a sibling
bar, then short verticals from the bar to each child top. -->
{#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)}
<line
x1={midX}
y1={parentBottomY}
x2={midX}
y2={barY}
stroke="var(--c-primary)"
stroke-width="1.5"
/>
{#if minX !== maxX}
<line x1={minX} y1={barY} x2={maxX} y2={barY} stroke="var(--c-primary)" stroke-width="1.5" />
{/if}
{#each childCenters as cc, i (group.childIds[i])}
<line
x1={cc.x}
y1={barY}
x2={cc.x}
y2={childTopY}
stroke="var(--c-primary)"
stroke-width="1.5"
/>
{/each}
{/if}
{/each}
<!-- Single-parent → child connectors: parent bottom → bar → child top. -->
{#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}
<line
x1={parentCenter.x}
y1={parentBottomY}
x2={parentCenter.x}
y2={barY}
stroke="var(--c-primary)"
stroke-width="1.5"
/>
{#if parentCenter.x !== childCenter.x}
<line
x1={parentCenter.x}
y1={barY}
x2={childCenter.x}
y2={barY}
stroke="var(--c-primary)"
stroke-width="1.5"
/>
{/if}
<line
x1={childCenter.x}
y1={barY}
x2={childCenter.x}
y2={childTopY}
stroke="var(--c-primary)"
stroke-width="1.5"
/>
{/if}
{/each}
<!-- Spouse connectors -->
{#each spouseEdges as e (e.id)}
{@const aCenter = nodeCenter(e.personId)}
{@const bCenter = nodeCenter(e.relatedPersonId)}
{#if aCenter && bCenter}
<line
x1={aCenter.x}
y1={aCenter.y}
x2={bCenter.x}
y2={bCenter.y}
stroke="var(--c-primary)"
stroke-width="1.5"
stroke-dasharray={e.toYear ? '4 4' : undefined}
/>
<circle
cx={(aCenter.x + bCenter.x) / 2}
cy={(aCenter.y + bCenter.y) / 2}
r="6"
fill="var(--c-primary)"
/>
{/if}
{/each}

View File

@@ -0,0 +1,38 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
interface Props {
onZoomIn: () => void;
onZoomOut: () => void;
onFit: () => void;
}
let { onZoomIn, onZoomOut, onFit }: Props = $props();
// Docked bottom-right inside the canvas — the primary one-handed reach zone on a
// phone (Leonie). The container ignores pointer events so canvas gestures pass
// through the gaps; only the buttons capture taps.
const buttonClass =
'pointer-events-auto inline-flex h-11 w-11 items-center justify-center rounded-sm border border-line bg-surface text-lg text-ink-2 shadow-sm transition hover:bg-muted focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none';
</script>
<div
class="pointer-events-none absolute right-4 z-30 flex flex-col gap-1"
style="bottom: max(calc(env(safe-area-inset-bottom, 0px) + 1rem), 1rem);"
>
<button type="button" onclick={onZoomIn} aria-label={m.stammbaum_zoom_in()} class={buttonClass}>
+
</button>
<button type="button" onclick={onZoomOut} aria-label={m.stammbaum_zoom_out()} class={buttonClass}>
</button>
<button
type="button"
data-testid="fit-to-screen"
onclick={onFit}
aria-label={m.stammbaum_fit_to_screen()}
class={buttonClass}
>
</button>
</div>

View File

@@ -0,0 +1,72 @@
<script lang="ts">
import type { PanZoomState } from '$lib/person/genealogy/panZoom';
interface RailRow {
rank: number;
label: number;
/** Row centre in SVG user coordinates. */
centerY: number;
}
interface Props {
/** The canvas SVG, read for its live screen transform. */
svg: SVGSVGElement | null;
rows: RailRow[];
/** Tracked so chip positions recompute on every pan/zoom. */
panZoom: PanZoomState;
}
let { svg, rows, panZoom }: Props = $props();
type Chip = { rank: number; label: number; top: number; visible: boolean };
let chips = $state<Chip[]>([]);
let height = $state(0);
// Fallback stacked positions when no screen transform is available (e.g. an
// unsized test container): list the chips top-down rather than hiding them.
const FALLBACK_TOP = 24;
const FALLBACK_GAP = 28;
// Off-screen cull margin (px) so a chip just past an edge isn't popped abruptly.
const CULL_MARGIN = 16;
// Map each generation-row centre from SVG user space to a screen-y via the live
// CTM. This reads whatever transform the browser actually computed, so it is
// independent of `preserveAspectRatio` (no manual letterbox math). Runs in an
// effect — not a $derived — so the CTM is read AFTER the viewBox DOM update is
// flushed. NB: this reads layout (getScreenCTM + getBoundingClientRect) once per
// pan/zoom/inertia frame; fine for a handful of chips, revisit if rows grow large.
$effect(() => {
// Reading pan/zoom + height registers them as effect dependencies (so it
// re-runs on pan, zoom and resize — the CTM reflects the parent's viewBox,
// which Svelte cannot track on its own) and doubles as a NaN guard.
const inputsFinite = Number.isFinite(panZoom.x + panZoom.y + panZoom.z + height);
const ctm = svg && inputsFinite ? svg.getScreenCTM() : null;
const top0 = svg ? svg.getBoundingClientRect().top : 0;
// Always emit one chip per labelled row so the labels exist regardless of
// transform availability; the CTM only positions them (fallback: stacked).
chips = rows.map((row, i) => {
const top = ctm
? new DOMPoint(0, row.centerY).matrixTransform(ctm).y - top0
: FALLBACK_TOP + i * FALLBACK_GAP;
const visible = !ctm || height <= 0 || (top >= -CULL_MARGIN && top <= height + CULL_MARGIN);
return { rank: row.rank, label: row.label, top, visible };
});
});
</script>
<!-- Pinned to the canvas's left edge: chips stay put horizontally while the
tree pans, and track their generation row vertically at any zoom. -->
<div class="pointer-events-none absolute inset-y-0 left-0 z-10" bind:clientHeight={height}>
{#each chips as chip (chip.rank)}
{#if chip.visible}
<div
role="text"
aria-label={`Generation ${chip.label}`}
class="absolute left-1 -translate-y-1/2 rounded-sm border border-line bg-surface px-1.5 py-0.5 font-serif text-xs text-ink-3 shadow-sm"
style="top: {chip.top}px"
>
G{chip.label}
</div>
{/if}
{/each}
</div>

View File

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

View File

@@ -0,0 +1,90 @@
<script lang="ts">
import { NODE_W, NODE_H } from '$lib/person/genealogy/layout/buildLayout';
import type { components } from '$lib/generated/api';
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
interface Props {
node: PersonNodeDTO;
pos: { x: number; y: number };
selected: boolean;
onSelect: (id: string) => void;
}
let { node, pos, selected, onSelect }: Props = $props();
// Each node owns its own focus-ring state (the focus ring is decorative; the
// `<g role="button">` is the real focus target).
let focused = $state(false);
function handleKey(event: KeyboardEvent) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onSelect(node.id);
}
}
const datesLabel = $derived(
node.birthYear || node.deathYear ? `, ${node.birthYear ?? '?'}${node.deathYear ?? ''}` : ''
);
</script>
<g
role="button"
tabindex="0"
aria-label="{node.displayName}{datesLabel}"
aria-expanded={selected}
transform="translate({pos.x}, {pos.y})"
onclick={() => onSelect(node.id)}
onkeydown={handleKey}
onfocus={() => (focused = true)}
onblur={() => (focused = false)}
class="cursor-pointer focus:outline-none"
>
{#if focused}
<rect
x="-3"
y="-3"
width={NODE_W + 6}
height={NODE_H + 6}
rx="6"
fill="none"
stroke="var(--c-focus-ring)"
stroke-width="2"
/>
{/if}
<rect
width={NODE_W}
height={NODE_H}
rx="4"
fill={selected ? 'var(--c-primary)' : 'var(--c-surface)'}
stroke="var(--c-primary)"
stroke-width="1.5"
/>
{#if selected}
<rect width="4" height={NODE_H} rx="2" fill="var(--c-accent)" />
{/if}
<text
x={NODE_W / 2}
y={NODE_H / 2 - 6}
text-anchor="middle"
font-family="serif"
font-size="16"
fill={selected ? 'var(--c-primary-fg)' : 'var(--c-ink)'}
>
{node.displayName}
</text>
{#if node.birthYear || node.deathYear}
<text
x={NODE_W / 2}
y={NODE_H / 2 + 12}
text-anchor="middle"
font-family="sans-serif"
font-size="12"
fill={selected ? 'var(--c-primary-fg)' : 'var(--c-ink-3)'}
opacity={selected ? 0.75 : 1}
>
{node.birthYear ?? '?'}{node.deathYear ?? ''}
</text>
{/if}
</g>

View File

@@ -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<RelationshipDTO[]>([]);
let derivedRels = $state<InferredRelationshipWithPersonDTO[]>([]);
@@ -95,23 +97,45 @@ const topDerived = $derived(
</p>
{/if}
</div>
<button
type="button"
onclick={onClose}
aria-label={m.comp_dismiss()}
class="shrink-0 rounded-sm p-1 text-ink-3 transition hover:bg-muted hover:text-ink focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none"
>
<svg
class="h-4 w-4"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
<div class="flex shrink-0 items-center gap-1">
{#if onCentre}
<button
type="button"
onclick={onCentre}
aria-label={m.stammbaum_centre_on_person()}
class="inline-flex h-11 w-11 items-center justify-center rounded-sm text-ink-3 transition hover:bg-muted hover:text-ink focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none"
>
<svg
class="h-4 w-4"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="1.5"
aria-hidden="true"
>
<circle cx="8" cy="8" r="2" />
<path stroke-linecap="round" d="M8 1v2M8 13v2M1 8h2M13 8h2" />
</svg>
</button>
{/if}
<button
type="button"
onclick={onClose}
aria-label={m.comp_dismiss()}
class="inline-flex h-11 w-11 items-center justify-center rounded-sm text-ink-3 transition hover:bg-muted hover:text-ink focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none"
>
<path stroke-linecap="round" d="M3 3l10 10M13 3L3 13" />
</svg>
</button>
<svg
class="h-4 w-4"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path stroke-linecap="round" d="M3 3l10 10M13 3L3 13" />
</svg>
</button>
</div>
</div>
{#if error}

View File

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

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import { untrack, onMount } from 'svelte';
import { SvelteMap } from 'svelte/reactivity';
import type { components } from '$lib/generated/api';
import {
buildLayout,
@@ -8,6 +9,18 @@ import {
ROW_GAP,
type Layout
} from '$lib/person/genealogy/layout/buildLayout';
import {
type PanZoomState,
clampZoom,
clampPan,
recentreOn,
cornerView,
ZOOM_STEP_KB
} from '$lib/person/genealogy/panZoom';
import { panZoomGestures } from '$lib/person/genealogy/panZoomGestures';
import StammbaumGenerationRail from '$lib/person/genealogy/StammbaumGenerationRail.svelte';
import StammbaumConnectors from '$lib/person/genealogy/StammbaumConnectors.svelte';
import StammbaumNode from '$lib/person/genealogy/StammbaumNode.svelte';
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
type RelationshipDTO = components['schemas']['RelationshipDTO'];
@@ -16,7 +29,15 @@ interface Props {
nodes: PersonNodeDTO[];
edges: RelationshipDTO[];
selectedId: string | null;
zoom: number;
panZoom: PanZoomState;
/** Emitted when the keyboard, a gesture, or a recentre changes the view. */
onPanZoom?: (state: PanZoomState) => void;
/** When set to a node id, the canvas recentres on that node (US-PAN-005). */
centreOnId?: string | null;
/** Fired on the first pointer interaction with the canvas (affordance dismiss). */
onActivity?: () => void;
/** When true, the initial view is anchored to the tree's top-left corner. */
anchorTopLeft?: boolean;
onSelect: (id: string) => void;
/**
* Force-show or force-hide the generation gutter. When undefined, falls
@@ -27,7 +48,18 @@ interface Props {
showGutter?: boolean;
}
let { nodes, edges, selectedId, zoom, onSelect, showGutter }: Props = $props();
let {
nodes,
edges,
selectedId,
panZoom,
onPanZoom = () => {},
centreOnId = null,
onActivity,
anchorTopLeft = false,
onSelect,
showGutter
}: Props = $props();
const layout = $derived.by<Layout>(() => buildLayout(nodes, edges));
@@ -56,9 +88,26 @@ $effect(() => {
const gutterVisible = $derived(showGutter ?? isMdOrUp);
const gutterWidth = $derived(gutterVisible ? GUTTER_WIDTH_DESKTOP : 0);
// Reduced-motion preference disables pan inertia and animated transitions
// (REQ-PAN-005). Seeded synchronously like the gutter state above.
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
let reducedMotion = $state(
typeof window !== 'undefined' && typeof window.matchMedia === 'function'
? window.matchMedia(REDUCED_MOTION_QUERY).matches
: false
);
$effect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
const mq = window.matchMedia(REDUCED_MOTION_QUERY);
const handler = (e: MediaQueryListEvent) => (reducedMotion = e.matches);
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
});
type GutterRow = { rank: number; y: number; label: number | null };
// Computed on all viewports (not gated on the desktop gutter) so the pinned
// generation rail can show labels on phones too (#692).
const gutterRows = $derived.by<GutterRow[]>(() => {
if (gutterWidth === 0) return [];
const byId = new SvelteMap(nodes.map((n) => [n.id, n]));
const rows: GutterRow[] = [];
const sortedRanks = [...layout.generations.keys()].sort((a, b) => a - b);
@@ -79,319 +128,181 @@ const gutterRows = $derived.by<GutterRow[]>(() => {
return rows;
});
// Base viewBox geometry at z=1, no pan — the whole tree framed (#692). Pan
// offsets shift the centre; zoom scales width/height inversely. The default
// {x:0,y:0,z:1} therefore fits the tree to the element (fit-to-screen).
const baseDims = $derived({ w: layout.viewW + gutterWidth, h: layout.viewH });
const baseCentre = $derived({
x: layout.viewX - gutterWidth + baseDims.w / 2,
y: layout.viewY + layout.viewH / 2
});
// Labelled generation rows for the pinned rail, with each row's centre in SVG
// coordinates (the rail maps these through the live screen transform).
let svgEl = $state<SVGSVGElement | null>(null);
const railRows = $derived(
gutterRows
.filter((r): r is GutterRow & { label: number } => r.label != null)
.map((r) => ({ rank: r.rank, label: r.label, centerY: r.y + NODE_H / 2 }))
);
// A fresh visit (no shared URL state) lands on the tree's content top-left
// rather than its centre (#692). Anchors to the first row / leftmost node (not
// the padded frame corner, which would leave empty space above row 1), with a
// small margin. Runs once after layout is available.
const ANCHOR_MARGIN = 24;
onMount(() => {
if (!anchorTopLeft) return;
let minX = Infinity;
let minY = Infinity;
for (const pos of layout.positions.values()) {
minX = Math.min(minX, pos.x);
minY = Math.min(minY, pos.y);
}
if (!Number.isFinite(minX)) return; // no nodes
const target = cornerView(
minX - ANCHOR_MARGIN,
minY - ANCHOR_MARGIN,
baseCentre.x,
baseCentre.y,
baseDims.w,
baseDims.h,
panZoom.z
);
onPanZoom(clampPan(target, baseDims.w, baseDims.h));
});
const viewBox = $derived.by(() => {
const totalW = layout.viewW + gutterWidth;
const w = totalW / zoom;
const h = layout.viewH / zoom;
const cx = layout.viewX - gutterWidth + totalW / 2;
const cy = layout.viewY + layout.viewH / 2;
const w = baseDims.w / panZoom.z;
const h = baseDims.h / panZoom.z;
const cx = baseCentre.x + panZoom.x;
const cy = baseCentre.y + panZoom.y;
return `${cx - w / 2} ${cy - h / 2} ${w} ${h}`;
});
// Permanent edge-fade affordance (#692, replaces US-PAN-006 AC3). When the tree
// is zoomed past fit, content is clipped at the viewport edges, so a 24px fade
// on all four edges cues that more tree exists off-screen. Zero JS beyond this
// reactive style; nothing fades at fit (z <= 1, whole tree visible).
const EDGE_FADE = 24;
const maskStyle = $derived(
panZoom.z > 1
? `-webkit-mask-image:linear-gradient(to right,transparent,#000 ${EDGE_FADE}px,#000 calc(100% - ${EDGE_FADE}px),transparent),linear-gradient(to bottom,transparent,#000 ${EDGE_FADE}px,#000 calc(100% - ${EDGE_FADE}px),transparent);` +
`mask-image:linear-gradient(to right,transparent,#000 ${EDGE_FADE}px,#000 calc(100% - ${EDGE_FADE}px),transparent),linear-gradient(to bottom,transparent,#000 ${EDGE_FADE}px,#000 calc(100% - ${EDGE_FADE}px),transparent);` +
`-webkit-mask-composite:source-in;mask-composite:intersect;`
: ''
);
function nodeCenter(id: string): { x: number; y: number } | null {
const p = layout.positions.get(id);
if (!p) return null;
return { x: p.x + NODE_W / 2, y: p.y + NODE_H / 2 };
}
let focusedId = $state<string | null>(null);
function handleNodeKey(event: KeyboardEvent, id: string) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onSelect(id);
}
}
const parentEdges = $derived(edges.filter((e) => e.relationType === 'PARENT_OF'));
const spouseEdges = $derived(edges.filter((e) => e.relationType === 'SPOUSE_OF'));
function pairKey(a: string, b: string): string {
return a < b ? `${a}|${b}` : `${b}|${a}`;
}
type ParentLinks = {
// One entry per spouse-pair-with-children: drives the drop + sibling-bar
// + per-child vertical pattern in the SVG.
shared: { key: string; parentA: string; parentB: string; childIds: string[] }[];
// One entry per remaining parent → child edge (single parents, or the
// "second" parent edge when only one parent is in the spouse pair).
single: { key: string; parentId: string; childId: string }[];
};
const parentLinks = $derived.by<ParentLinks>(() => {
const spousePairs = new SvelteSet<string>();
for (const e of spouseEdges) {
spousePairs.add(pairKey(e.personId, e.relatedPersonId));
}
const childToParents = new SvelteMap<string, string[]>();
for (const e of parentEdges) {
const list = childToParents.get(e.relatedPersonId) ?? [];
list.push(e.personId);
childToParents.set(e.relatedPersonId, list);
}
const sharedMap = new SvelteMap<
string,
{ parentA: string; parentB: string; childIds: string[] }
>();
const single: ParentLinks['single'] = [];
for (const [childId, parents] of childToParents) {
const consumed = new SvelteSet<string>();
for (let i = 0; i < parents.length; i++) {
if (consumed.has(parents[i])) continue;
for (let j = i + 1; j < parents.length; j++) {
if (consumed.has(parents[j])) continue;
if (spousePairs.has(pairKey(parents[i], parents[j]))) {
const groupKey = pairKey(parents[i], parents[j]);
const existing = sharedMap.get(groupKey);
if (existing) {
existing.childIds.push(childId);
} else {
sharedMap.set(groupKey, {
parentA: parents[i],
parentB: parents[j],
childIds: [childId]
});
}
consumed.add(parents[i]);
consumed.add(parents[j]);
break;
}
}
}
for (const parentId of parents) {
if (consumed.has(parentId)) continue;
single.push({ key: `${parentId}->${childId}`, parentId, childId });
}
}
const shared: ParentLinks['shared'] = [];
for (const [key, group] of sharedMap) shared.push({ key, ...group });
return { shared, single };
// Recentre when the parent sets centreOnId (US-PAN-005). Only centreOnId is a
// tracked dependency — the current view is read untracked so a normal pan does
// not retrigger a recentre.
$effect(() => {
const id = centreOnId;
if (!id) return;
untrack(() => {
const c = nodeCenter(id);
if (c) onPanZoom(recentreOn(c, baseCentre, panZoom, true));
});
});
// Canvas-level keyboard: `+`/`-` zoom by the fixed step (OQ-002), arrows pan by
// a tenth of the visible extent. Nodes keep their own Enter/Space selection.
function handleCanvasKey(event: KeyboardEvent) {
const stepX = (baseDims.w / panZoom.z) * 0.1;
const stepY = (baseDims.h / panZoom.z) * 0.1;
switch (event.key) {
case '+':
case '=':
onPanZoom({ ...panZoom, z: clampZoom(panZoom.z + ZOOM_STEP_KB) });
break;
case '-':
case '_':
onPanZoom({ ...panZoom, z: clampZoom(panZoom.z - ZOOM_STEP_KB) });
break;
case 'ArrowLeft':
onPanZoom({ ...panZoom, x: panZoom.x - stepX });
break;
case 'ArrowRight':
onPanZoom({ ...panZoom, x: panZoom.x + stepX });
break;
case 'ArrowUp':
onPanZoom({ ...panZoom, y: panZoom.y - stepY });
break;
case 'ArrowDown':
onPanZoom({ ...panZoom, y: panZoom.y + stepY });
break;
default:
return;
}
event.preventDefault();
}
</script>
<svg
viewBox={viewBox}
preserveAspectRatio="xMidYMid meet"
role="img"
aria-label="Stammbaum"
class="block h-full w-full"
>
<!-- Gutter stripe underlay (#689) — decorative full-row bands alternating
transparent / var(--c-gutter-stripe). aria-hidden because they carry
no meaning; the row's generation is announced by the label group below. -->
{#each gutterRows as row, i (`stripe-${row.rank}`)}
<rect
aria-hidden="true"
x={layout.viewX - gutterWidth}
y={row.y - ROW_GAP / 2}
width={layout.viewW + gutterWidth}
height={NODE_H + ROW_GAP}
fill={i % 2 === 0 ? 'transparent' : 'var(--c-gutter-stripe)'}
/>
{/each}
<!-- Gutter labels (#689) — `G{node.generation}` per occupied row at the
un-shifted source-truth value. Wrapped in <g role="text"> so screen
readers announce "Generation three" instead of "G three". -->
{#each gutterRows as row (`label-${row.rank}`)}
{#if row.label != null}
<g role="text" aria-label={`Generation ${row.label}`}>
<text
x={layout.viewX - gutterWidth + 12}
y={row.y + NODE_H / 2}
text-anchor="start"
dominant-baseline="middle"
font-family="var(--font-sans)"
font-size="12"
font-weight="700"
letter-spacing="0.08em"
fill="var(--c-ink-2)"
style:text-transform="uppercase"
>
G{row.label}
</text>
</g>
{/if}
{/each}
<!-- Shared parent-pair → children: drop from spouse midpoint to a sibling
bar, then short verticals from the bar to each child top. -->
{#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)}
<line
x1={midX}
y1={parentBottomY}
x2={midX}
y2={barY}
stroke="var(--c-primary)"
stroke-width="1.5"
/>
{#if minX !== maxX}
<line
x1={minX}
y1={barY}
x2={maxX}
y2={barY}
stroke="var(--c-primary)"
stroke-width="1.5"
/>
{/if}
{#each childCenters as cc, i (group.childIds[i])}
<line
x1={cc.x}
y1={barY}
x2={cc.x}
y2={childTopY}
stroke="var(--c-primary)"
stroke-width="1.5"
<!-- Relative wrapper so the pinned generation rail can overlay the canvas. -->
<div class="relative h-full w-full">
<!-- The canvas is a custom interactive pan/zoom region: `tabindex` lets keyboard
users focus it and the keydown handler is the keyboard-only alternative to
touch/mouse gestures (NFR-A11Y-002). The visible focus outline is kept. -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<svg
bind:this={svgEl}
viewBox={viewBox}
preserveAspectRatio="xMinYMin meet"
role="img"
aria-label="Stammbaum"
tabindex="0"
style={maskStyle}
onkeydown={handleCanvasKey}
use:panZoomGestures={{
state: panZoom,
baseW: baseDims.w,
baseH: baseDims.h,
baseCentreX: baseCentre.x,
baseCentreY: baseCentre.y,
reducedMotion,
onPanZoom,
onGestureStart: onActivity
}}
class="block h-full w-full"
>
<!-- Gutter stripe underlay (#689) — decorative full-row bands alternating
transparent / var(--c-gutter-stripe), desktop only. Generation labels
are no longer drawn in-SVG; the pinned rail below carries them. -->
{#if gutterVisible}
{#each gutterRows as row, i (`stripe-${row.rank}`)}
<rect
aria-hidden="true"
x={layout.viewX - gutterWidth}
y={row.y - ROW_GAP / 2}
width={layout.viewW + gutterWidth}
height={NODE_H + ROW_GAP}
fill={i % 2 === 0 ? 'transparent' : 'var(--c-gutter-stripe)'}
/>
{/each}
{/if}
{/each}
<!-- Single-parent → child connectors: parent bottom → bar → child top. -->
{#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}
<line
x1={parentCenter.x}
y1={parentBottomY}
x2={parentCenter.x}
y2={barY}
stroke="var(--c-primary)"
stroke-width="1.5"
/>
{#if parentCenter.x !== childCenter.x}
<line
x1={parentCenter.x}
y1={barY}
x2={childCenter.x}
y2={barY}
stroke="var(--c-primary)"
stroke-width="1.5"
<StammbaumConnectors edges={edges} positions={layout.positions} />
<!-- Nodes -->
{#each nodes as node (node.id)}
{@const pos = layout.positions.get(node.id)}
{#if pos}
<StammbaumNode
node={node}
pos={pos}
selected={selectedId === node.id}
onSelect={onSelect}
/>
{/if}
<line
x1={childCenter.x}
y1={barY}
x2={childCenter.x}
y2={childTopY}
stroke="var(--c-primary)"
stroke-width="1.5"
/>
{/if}
{/each}
{/each}
</svg>
<!-- Spouse connectors -->
{#each spouseEdges as e (e.id)}
{@const aCenter = nodeCenter(e.personId)}
{@const bCenter = nodeCenter(e.relatedPersonId)}
{#if aCenter && bCenter}
<line
x1={aCenter.x}
y1={aCenter.y}
x2={bCenter.x}
y2={bCenter.y}
stroke="var(--c-primary)"
stroke-width="1.5"
stroke-dasharray={e.toYear ? '4 4' : undefined}
/>
<circle
cx={(aCenter.x + bCenter.x) / 2}
cy={(aCenter.y + bCenter.y) / 2}
r="6"
fill="var(--c-primary)"
/>
{/if}
{/each}
<!-- Nodes -->
{#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}
<g
role="button"
tabindex="0"
aria-label="{node.displayName}{node.birthYear || node.deathYear
? `, ${node.birthYear ?? '?'}${node.deathYear ?? ''}`
: ''}"
aria-expanded={isSelected}
transform="translate({pos.x}, {pos.y})"
onclick={() => onSelect(node.id)}
onkeydown={(e) => handleNodeKey(e, node.id)}
onfocus={() => (focusedId = node.id)}
onblur={() => (focusedId = null)}
class="cursor-pointer focus:outline-none"
>
{#if isFocused}
<rect
x="-3"
y="-3"
width={NODE_W + 6}
height={NODE_H + 6}
rx="6"
fill="none"
stroke="var(--c-focus-ring)"
stroke-width="2"
/>
{/if}
<rect
width={NODE_W}
height={NODE_H}
rx="4"
fill={isSelected ? 'var(--c-primary)' : 'var(--c-surface)'}
stroke="var(--c-primary)"
stroke-width="1.5"
/>
{#if isSelected}
<rect width="4" height={NODE_H} rx="2" fill="var(--c-accent)" />
{/if}
<text
x={NODE_W / 2}
y={NODE_H / 2 - 6}
text-anchor="middle"
font-family="serif"
font-size="16"
fill={isSelected ? 'var(--c-primary-fg)' : 'var(--c-ink)'}
>
{node.displayName}
</text>
{#if node.birthYear || node.deathYear}
<text
x={NODE_W / 2}
y={NODE_H / 2 + 12}
text-anchor="middle"
font-family="sans-serif"
font-size="12"
fill={isSelected ? 'var(--c-primary-fg)' : 'var(--c-ink-3)'}
opacity={isSelected ? 0.75 : 1}
>
{node.birthYear ?? '?'}{node.deathYear ?? ''}
</text>
{/if}
</g>
{/if}
{/each}
</svg>
<StammbaumGenerationRail svg={svgEl} rows={railRows} panZoom={panZoom} />
</div>

View File

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

View File

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

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

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

View File

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

View File

@@ -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<SVGSVGElement, PanZoomGesturesParams> = (node, params) => {
let p = params;
let current = p.state;
const pointers = new Map<number, { x: number; y: number }>();
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);
}
};
};

View File

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

View File

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

View File

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

View File

@@ -1,15 +1,28 @@
<script lang="ts">
import { untrack, tick, onMount } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import { page } from '$app/state';
import { replaceState } from '$app/navigation';
import StammbaumTree from '$lib/person/genealogy/StammbaumTree.svelte';
import StammbaumSidePanel from '$lib/person/genealogy/StammbaumSidePanel.svelte';
import StammbaumBottomSheet from '$lib/person/genealogy/StammbaumBottomSheet.svelte';
import StammbaumControls from '$lib/person/genealogy/StammbaumControls.svelte';
import StammbaumAffordance from '$lib/person/genealogy/StammbaumAffordance.svelte';
import {
type PanZoomState,
DEFAULT_VIEW,
clampZoom,
serializePanZoomParams,
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'];
type RelationshipDTO = components['schemas']['RelationshipDTO'];
interface Props {
data: { nodes: PersonNodeDTO[]; edges: RelationshipDTO[] };
data: { nodes: PersonNodeDTO[]; edges: RelationshipDTO[]; initialView: PanZoomState };
}
let { data }: Props = $props();
@@ -23,13 +36,61 @@ let selectedId = $state<string | null>(
const selectedNode = $derived(data.nodes.find((n) => n.id === selectedId) ?? null);
let zoom = $state(1);
let view = $state<PanZoomState>(data.initialView);
let canvasActivity = $state(false);
function zoomIn() {
zoom = Math.min(2, zoom + 0.1);
view = { ...view, z: clampZoom(view.z + ZOOM_STEP_KB) };
}
function zoomOut() {
zoom = Math.max(0.4, zoom - 0.1);
view = { ...view, z: clampZoom(view.z - ZOOM_STEP_KB) };
}
// One-shot recentre trigger: set the focal id, let StammbaumTree's effect read
// it and emit the recentred view, then clear so the same person can be
// re-centred on a later click (US-PAN-005).
let centreOnId = $state<string | null>(null);
async function centreOnSelected() {
centreOnId = selectedId;
await tick();
centreOnId = null;
}
let cancelAnimation = () => {};
function fitToScreen() {
cancelAnimation();
cancelAnimation = animateView(view, DEFAULT_VIEW, (v) => (view = v), {
reducedMotion: prefersReducedMotion()
});
}
// SvelteKit's replaceState throws "before the router is initialized" if called
// during hydration (the router sets `started = true` only after onMount + the
// first effect tick). Gate the URL sync on a flag flipped after the first
// post-mount tick() — which resolves once hydration is complete — so the write
// only ever runs against a ready router.
let routerReady = $state(false);
onMount(() => {
tick().then(() => (routerReady = true));
});
// Mirror the view into shareable ?cx&cy&z params (OQ-003). Only `view` and
// `routerReady` are tracked; the current URL is read untracked so the
// replaceState write does not retrigger the effect. The state thus survives
// panel open/close (US-PANEL-002 AC1) and a shared link reproduces it (AC2).
$effect(() => {
const { cx, cy, z } = serializePanZoomParams(view);
if (!routerReady) return;
untrack(() => {
const url = new URL(window.location.href);
url.searchParams.set('cx', cx);
url.searchParams.set('cy', cy);
url.searchParams.set('z', z);
try {
replaceState(url, page.state);
} catch {
// Router not ready yet — the next view change retries.
}
});
});
</script>
<!-- 4.25rem = 4rem navbar (h-16) + 0.25rem accent strip (h-1).
@@ -40,26 +101,6 @@ function zoomOut() {
class="flex shrink-0 items-center justify-between border-b border-line bg-surface px-6 py-4"
>
<h1 class="font-serif text-2xl text-ink">{m.nav_stammbaum()}</h1>
{#if data.nodes.length > 0}
<div class="flex items-center gap-2">
<button
type="button"
onclick={zoomOut}
aria-label={m.stammbaum_zoom_out()}
class="inline-flex h-11 w-11 items-center justify-center rounded-sm border border-line bg-surface text-ink-2 transition hover:bg-muted focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none"
>
</button>
<button
type="button"
onclick={zoomIn}
aria-label={m.stammbaum_zoom_in()}
class="inline-flex h-11 w-11 items-center justify-center rounded-sm border border-line bg-surface text-ink-2 transition hover:bg-muted focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none"
>
+
</button>
</div>
{/if}
</header>
{#if data.nodes.length === 0}
@@ -92,14 +133,20 @@ function zoomOut() {
</div>
{:else}
<div class="flex flex-1 overflow-hidden">
<div class="flex-1 overflow-auto bg-muted/20">
<div class="relative flex-1 overflow-hidden bg-muted/20">
<StammbaumTree
nodes={data.nodes}
edges={data.edges}
selectedId={selectedId}
zoom={zoom}
panZoom={view}
centreOnId={centreOnId}
anchorTopLeft={!page.url.searchParams.has('z')}
onPanZoom={(v) => (view = v)}
onActivity={() => (canvasActivity = true)}
onSelect={(id) => (selectedId = id)}
/>
<StammbaumAffordance dismissed={canvasActivity} />
<StammbaumControls onZoomIn={zoomIn} onZoomOut={zoomOut} onFit={fitToScreen} />
</div>
{#if selectedNode}
<!-- Desktop: side panel on the right -->
@@ -110,18 +157,16 @@ function zoomOut() {
node={selectedNode}
canWrite={canWrite}
onClose={() => (selectedId = null)}
onCentre={centreOnSelected}
/>
</aside>
<!-- Mobile: fixed bottom sheet -->
<div
class="fixed inset-x-0 bottom-0 z-40 max-h-[60dvh] overflow-y-auto border-t border-line bg-surface shadow-lg md:hidden"
>
<StammbaumSidePanel
node={selectedNode}
canWrite={canWrite}
onClose={() => (selectedId = null)}
/>
</div>
<!-- Mobile: dismissible bottom sheet (overlay, preserves pan/zoom) -->
<StammbaumBottomSheet
node={selectedNode}
canWrite={canWrite}
onClose={() => (selectedId = null)}
onCentre={centreOnSelected}
/>
{/if}
</div>
{/if}

View File

@@ -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<typeof createApiClient>);
}
function mockNetworkResponse(status: number) {
vi.mocked(createApiClient).mockReturnValue({
GET: vi.fn().mockResolvedValue({ response: { ok: false, status }, error: { code: 'X' } })
} as unknown as ReturnType<typeof createApiClient>);
}
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 });
});
});

View File

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