diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index a3e1b359..56fc47db 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -58,6 +58,7 @@ _See also [Annotation](#annotation-documentannotation)._ **DocumentStatus lifecycle** — the ordered states a `Document` moves through: `PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED` + - `PLACEHOLDER`: created during mass import; no file attached yet. - `UPLOADED`: a file has been stored in MinIO/S3. - `TRANSCRIBED`: all transcription blocks have been marked done. @@ -119,13 +120,13 @@ _Not to be confused with [parented](#parented-layout)_ — loose is the absence **anchor index** — within a sibling block, the average position of `parented` member indices. The block is shifted horizontally so this index, multiplied by `NODE_W + COL_GAP`, lines up under the midpoint of the block's parents — keeping every parent-child connector orthogonal (90°). -**intra-family marriage** — a `SPOUSE_OF` edge where both endpoints are parented members of *different* sibling blocks at the same rank (i.e. both have parents in the graph, but the parent sets differ). Layout merges the two blocks so the spouses sit adjacent at the join boundary; latent in current data (0 cases in the May-2026 canonical snapshot) but covered by a synthetic regression test in `buildLayout.test.ts`. +**intra-family marriage** — a `SPOUSE_OF` edge where both endpoints are parented members of _different_ sibling blocks at the same rank (i.e. both have parents in the graph, but the parent sets differ). Layout merges the two blocks so the spouses sit adjacent at the join boundary; latent in current data (0 cases in the May-2026 canonical snapshot) but covered by a synthetic regression test in `buildLayout.test.ts`. **marriage dot** — the SVG circle drawn at the midpoint of a `SPOUSE_OF` connector in the Stammbaum tree (`StammbaumTree.svelte`). Radius is `r=6` (12 px diameter) so the marker meets WCAG 1.4.11 (3:1 non-text contrast) when it stacks to disambiguate multiple marriages on the same focal person. **canonical fixture** (Stammbaum) — `frontend/src/lib/person/genealogy/__fixtures__/stammbaum.json`, a pinned `/api/network` snapshot used by `buildLayout.test.ts` for structural-property assertions against real data. Captured locally via `frontend/scripts/capture-network-fixture.mjs` with explicit credentials and a localhost backend; never invoked from CI. Sanity-gated by `validateFixture.ts` (≥ 50 nodes / ≥ 5 generations / ≥ 1 SPOUSE_OF edge / ≥ 1 multi-spouse person). -**pan/zoom view state** `[#692]` — the `{ x, y, z }` triple (`PanZoomState` in `frontend/src/lib/person/genealogy/panZoom.ts`) describing the Stammbaum canvas position: `x`/`y` are pan offsets applied to the SVG viewBox centre, `z` is the zoom factor (clamped 0.25–3.0). 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). +**pan/zoom view state** `[#692]` — the `{ x, y, z }` triple (`PanZoomState` in `frontend/src/lib/person/genealogy/panZoom.ts`) describing the Stammbaum canvas position: `x`/`y` are pan offsets applied to the SVG viewBox centre, `z` is the zoom factor (clamped 0.25–10). Mirrored into the shareable URL as `?cx&cy&z` and seeded server-side from those params. See [ADR-027](adr/027-stammbaum-custom-viewbox-pan-zoom.md). **fit-to-screen** `[user-facing, #692]` — the Stammbaum control (`⤢`) and initial state that frames the whole tree in the viewport. Because the base viewBox already encloses the layout at `z=1`, fit-to-screen is simply the default view `{x:0, y:0, z:1}`. diff --git a/docs/adr/027-stammbaum-custom-viewbox-pan-zoom.md b/docs/adr/027-stammbaum-custom-viewbox-pan-zoom.md index 9baf44f3..1d8613e5 100644 --- a/docs/adr/027-stammbaum-custom-viewbox-pan-zoom.md +++ b/docs/adr/027-stammbaum-custom-viewbox-pan-zoom.md @@ -16,7 +16,8 @@ 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="xMidYMid meet"`) — not by applying a CSS +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: diff --git a/frontend/src/lib/person/genealogy/StammbaumGenerationRail.svelte b/frontend/src/lib/person/genealogy/StammbaumGenerationRail.svelte index a5b294c2..9169f1bb 100644 --- a/frontend/src/lib/person/genealogy/StammbaumGenerationRail.svelte +++ b/frontend/src/lib/person/genealogy/StammbaumGenerationRail.svelte @@ -22,21 +22,33 @@ type Chip = { rank: number; label: number; top: number; visible: boolean }; let chips = $state([]); let height = $state(0); -// Map each generation-row centre from SVG user space to a screen-y via the -// live CTM (exact under preserveAspectRatio="xMidYMid meet" — no manual -// letterbox math). Runs in an effect so it reads the CTM after the viewBox DOM -// update is flushed. `panZoom` and `height` are the recompute triggers. +// 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(() => { - // reactKey makes the effect re-run on pan, zoom and resize; the CTM reflects - // the parent's viewBox, which Svelte cannot track on its own. - const reactKey = panZoom.x + panZoom.y + panZoom.z + height; - const ctm = svg && Number.isFinite(reactKey) ? svg.getScreenCTM() : null; + // 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 : 24 + i * 28; - const visible = !ctm || height <= 0 || (top >= -16 && top <= height + 16); + 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 }; }); }); @@ -50,7 +62,7 @@ $effect(() => {
G{chip.label}