Compare commits
39 Commits
main
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cc6031ef0 | ||
|
|
ecae789be2 | ||
|
|
95d35c20b2 | ||
|
|
11dc25ef31 | ||
|
|
b1309db8db | ||
|
|
01b902e885 | ||
|
|
20db3d0d8f | ||
|
|
0306023610 | ||
|
|
8f836dfefb | ||
|
|
b170085311 | ||
|
|
d5a7974f3a | ||
|
|
53660eadc9 | ||
|
|
f4b631e1bc | ||
|
|
c1dd6d299f | ||
|
|
a458d3508b | ||
|
|
bb2a89da58 | ||
|
|
578bebbd8b | ||
|
|
7e859252a3 | ||
|
|
ba053b3c23 | ||
|
|
80f5e0b147 | ||
|
|
11b70d814f | ||
|
|
1dffb430ac | ||
|
|
1e5a45a027 | ||
|
|
ccc37fe1bb | ||
|
|
289c3bbfb5 | ||
|
|
8d29bb10e2 | ||
|
|
396c87f8ab | ||
|
|
7a6c2e877f | ||
|
|
ffc14dd2ff | ||
|
|
3827a9d059 | ||
|
|
c8931071ba | ||
|
|
da1984b916 | ||
|
|
0422af8980 | ||
|
|
197b668f20 | ||
|
|
5d752fcc0f | ||
|
|
0170f79690 | ||
|
|
369a0213e5 | ||
|
|
a7d0e96613 | ||
|
|
5458ca9bae |
@@ -58,6 +58,7 @@ _See also [Annotation](#annotation-documentannotation)._
|
||||
|
||||
**DocumentStatus lifecycle** — the ordered states a `Document` moves through:
|
||||
`PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED`
|
||||
|
||||
- `PLACEHOLDER`: created during mass import; no file attached yet.
|
||||
- `UPLOADED`: a file has been stored in MinIO/S3.
|
||||
- `TRANSCRIBED`: all transcription blocks have been marked done.
|
||||
@@ -119,12 +120,16 @@ _Not to be confused with [parented](#parented-layout)_ — loose is the absence
|
||||
|
||||
**anchor index** — within a sibling block, the average position of `parented` member indices. The block is shifted horizontally so this index, multiplied by `NODE_W + COL_GAP`, lines up under the midpoint of the block's parents — keeping every parent-child connector orthogonal (90°).
|
||||
|
||||
**intra-family marriage** — a `SPOUSE_OF` edge where both endpoints are parented members of *different* sibling blocks at the same rank (i.e. both have parents in the graph, but the parent sets differ). Layout merges the two blocks so the spouses sit adjacent at the join boundary; latent in current data (0 cases in the May-2026 canonical snapshot) but covered by a synthetic regression test in `buildLayout.test.ts`.
|
||||
**intra-family marriage** — a `SPOUSE_OF` edge where both endpoints are parented members of _different_ sibling blocks at the same rank (i.e. both have parents in the graph, but the parent sets differ). Layout merges the two blocks so the spouses sit adjacent at the join boundary; latent in current data (0 cases in the May-2026 canonical snapshot) but covered by a synthetic regression test in `buildLayout.test.ts`.
|
||||
|
||||
**marriage dot** — the SVG circle drawn at the midpoint of a `SPOUSE_OF` connector in the Stammbaum tree (`StammbaumTree.svelte`). Radius is `r=6` (12 px diameter) so the marker meets WCAG 1.4.11 (3:1 non-text contrast) when it stacks to disambiguate multiple marriages on the same focal person.
|
||||
|
||||
**canonical fixture** (Stammbaum) — `frontend/src/lib/person/genealogy/__fixtures__/stammbaum.json`, a pinned `/api/network` snapshot used by `buildLayout.test.ts` for structural-property assertions against real data. Captured locally via `frontend/scripts/capture-network-fixture.mjs` with explicit credentials and a localhost backend; never invoked from CI. Sanity-gated by `validateFixture.ts` (≥ 50 nodes / ≥ 5 generations / ≥ 1 SPOUSE_OF edge / ≥ 1 multi-spouse person).
|
||||
|
||||
**pan/zoom view state** `[#692]` — the `{ x, y, z }` triple (`PanZoomState` in `frontend/src/lib/person/genealogy/panZoom.ts`) describing the Stammbaum canvas position: `x`/`y` are pan offsets applied to the SVG viewBox centre, `z` is the zoom factor (clamped 0.25–10). Mirrored into the shareable URL as `?cx&cy&z` and seeded server-side from those params. See [ADR-027](adr/027-stammbaum-custom-viewbox-pan-zoom.md).
|
||||
|
||||
**fit-to-screen** `[user-facing, #692]` — the Stammbaum control (`⤢`) and initial state that frames the whole tree in the viewport. Because the base viewBox already encloses the layout at `z=1`, fit-to-screen is simply the default view `{x:0, y:0, z:1}`.
|
||||
|
||||
---
|
||||
|
||||
## Other Domain Terms
|
||||
|
||||
57
docs/adr/027-stammbaum-custom-viewbox-pan-zoom.md
Normal file
57
docs/adr/027-stammbaum-custom-viewbox-pan-zoom.md
Normal 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.
|
||||
72
frontend/e2e/stammbaum-mobile.visual.spec.ts
Normal file
72
frontend/e2e/stammbaum-mobile.visual.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
90
frontend/src/lib/person/genealogy/StammbaumAffordance.svelte
Normal file
90
frontend/src/lib/person/genealogy/StammbaumAffordance.svelte
Normal 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}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
189
frontend/src/lib/person/genealogy/StammbaumConnectors.svelte
Normal file
189
frontend/src/lib/person/genealogy/StammbaumConnectors.svelte
Normal 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}
|
||||
38
frontend/src/lib/person/genealogy/StammbaumControls.svelte
Normal file
38
frontend/src/lib/person/genealogy/StammbaumControls.svelte
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
90
frontend/src/lib/person/genealogy/StammbaumNode.svelte
Normal file
90
frontend/src/lib/person/genealogy/StammbaumNode.svelte
Normal 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>
|
||||
@@ -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,11 +97,32 @@ const topDerived = $derived(
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<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="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"
|
||||
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"
|
||||
@@ -113,6 +136,7 @@ const topDerived = $derived(
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="mb-3 text-sm text-red-700" role="alert">{error}</p>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,110 +128,154 @@ 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]
|
||||
// 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));
|
||||
});
|
||||
}
|
||||
consumed.add(parents[i]);
|
||||
consumed.add(parents[j]);
|
||||
});
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
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>
|
||||
|
||||
<!-- 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="xMidYMid meet"
|
||||
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). aria-hidden because they carry
|
||||
no meaning; the row's generation is announced by the label group below. -->
|
||||
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"
|
||||
@@ -193,205 +286,23 @@ const parentLinks = $derived.by<ParentLinks>(() => {
|
||||
fill={i % 2 === 0 ? 'transparent' : 'var(--c-gutter-stripe)'}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<!-- 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"
|
||||
/>
|
||||
{/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}
|
||||
<StammbaumConnectors edges={edges} positions={layout.positions} />
|
||||
|
||||
<!-- 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"
|
||||
<StammbaumNode
|
||||
node={node}
|
||||
pos={pos}
|
||||
selected={selectedId === node.id}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
{/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>
|
||||
|
||||
@@ -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')
|
||||
);
|
||||
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'
|
||||
await vi.waitFor(() => {
|
||||
const g3 = Array.from(document.querySelectorAll('[role="text"]')).find(
|
||||
(el) => el.getAttribute('aria-label') === 'Generation 3'
|
||||
);
|
||||
expect(g3).toBeDefined();
|
||||
expect(g3!.querySelector('text')!.textContent).toMatch(/G\s*3/);
|
||||
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'));
|
||||
});
|
||||
});
|
||||
|
||||
42
frontend/src/lib/person/genealogy/animateView.test.ts
Normal file
42
frontend/src/lib/person/genealogy/animateView.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
38
frontend/src/lib/person/genealogy/animateView.ts
Normal file
38
frontend/src/lib/person/genealogy/animateView.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { lerpView, type PanZoomState } from '$lib/person/genealogy/panZoom';
|
||||
|
||||
/** Fit / recentre animation duration (US-PAN-004 AC2: ≤ 300 ms). */
|
||||
export const VIEW_ANIM_MS = 300;
|
||||
|
||||
const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3);
|
||||
|
||||
/** Snapshot of the user's reduced-motion preference (non-reactive, browser-only). */
|
||||
export function prefersReducedMotion(): boolean {
|
||||
return typeof window !== 'undefined' && typeof window.matchMedia === 'function'
|
||||
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
: false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tween the view from `from` to `to`, calling `onFrame` with each interpolated
|
||||
* state. Honors reduced motion by snapping straight to the target (NFR-A11Y-003,
|
||||
* REQ-PAN-005). Returns a cancel function.
|
||||
*/
|
||||
export function animateView(
|
||||
from: PanZoomState,
|
||||
to: PanZoomState,
|
||||
onFrame: (state: PanZoomState) => void,
|
||||
opts: { reducedMotion?: boolean; durationMs?: number } = {}
|
||||
): () => void {
|
||||
if (opts.reducedMotion) {
|
||||
onFrame(to);
|
||||
return () => {};
|
||||
}
|
||||
const duration = opts.durationMs ?? VIEW_ANIM_MS;
|
||||
const start = performance.now();
|
||||
let raf = requestAnimationFrame(function step(now: number) {
|
||||
const t = Math.min(1, (now - start) / duration);
|
||||
onFrame(lerpView(from, to, easeOutCubic(t)));
|
||||
if (t < 1) raf = requestAnimationFrame(step);
|
||||
});
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}
|
||||
237
frontend/src/lib/person/genealogy/panZoom.test.ts
Normal file
237
frontend/src/lib/person/genealogy/panZoom.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
238
frontend/src/lib/person/genealogy/panZoom.ts
Normal file
238
frontend/src/lib/person/genealogy/panZoom.ts
Normal 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
|
||||
};
|
||||
}
|
||||
256
frontend/src/lib/person/genealogy/panZoomGestures.ts
Normal file
256
frontend/src/lib/person/genealogy/panZoomGestures.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
};
|
||||
75
frontend/src/lib/shared/actions/trapFocus.svelte.spec.ts
Normal file
75
frontend/src/lib/shared/actions/trapFocus.svelte.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
47
frontend/src/lib/shared/actions/trapFocus.ts
Normal file
47
frontend/src/lib/shared/actions/trapFocus.ts
Normal 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?.();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
|
||||
<!-- Mobile: dismissible bottom sheet (overlay, preserves pan/zoom) -->
|
||||
<StammbaumBottomSheet
|
||||
node={selectedNode}
|
||||
canWrite={canWrite}
|
||||
onClose={() => (selectedId = null)}
|
||||
onCentre={centreOnSelected}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
77
frontend/src/routes/stammbaum/page.server.test.ts
Normal file
77
frontend/src/routes/stammbaum/page.server.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user