Compare commits
10 Commits
ffc14dd2ff
...
ba053b3c23
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba053b3c23 | ||
|
|
80f5e0b147 | ||
|
|
11b70d814f | ||
|
|
1dffb430ac | ||
|
|
1e5a45a027 | ||
|
|
ccc37fe1bb | ||
|
|
289c3bbfb5 | ||
|
|
8d29bb10e2 | ||
|
|
396c87f8ab | ||
|
|
7a6c2e877f |
@@ -125,6 +125,10 @@ _Not to be confused with [parented](#parented-layout)_ — loose is the absence
|
||||
|
||||
**canonical fixture** (Stammbaum) — `frontend/src/lib/person/genealogy/__fixtures__/stammbaum.json`, a pinned `/api/network` snapshot used by `buildLayout.test.ts` for structural-property assertions against real data. Captured locally via `frontend/scripts/capture-network-fixture.mjs` with explicit credentials and a localhost backend; never invoked from CI. Sanity-gated by `validateFixture.ts` (≥ 50 nodes / ≥ 5 generations / ≥ 1 SPOUSE_OF edge / ≥ 1 multi-spouse person).
|
||||
|
||||
**pan/zoom view state** `[#692]` — the `{ x, y, z }` triple (`PanZoomState` in `frontend/src/lib/person/genealogy/panZoom.ts`) describing the Stammbaum canvas position: `x`/`y` are pan offsets applied to the SVG viewBox centre, `z` is the zoom factor (clamped 0.25–3.0). Mirrored into the shareable URL as `?cx&cy&z` and seeded server-side from those params. See [ADR-026](adr/026-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
|
||||
|
||||
56
docs/adr/026-stammbaum-custom-viewbox-pan-zoom.md
Normal file
56
docs/adr/026-stammbaum-custom-viewbox-pan-zoom.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# ADR-026 — 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="xMidYMid meet"`) — 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.
|
||||
67
frontend/e2e/stammbaum-mobile.visual.spec.ts
Normal file
67
frontend/e2e/stammbaum-mobile.visual.spec.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
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 });
|
||||
|
||||
for (const width of WIDTHS) {
|
||||
test(`affordance + controls render at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 720 });
|
||||
await page.addInitScript(() => localStorage.removeItem('stammbaumAffordanceDismissedAt'));
|
||||
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="rounded-sm p-0.5 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();
|
||||
});
|
||||
});
|
||||
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>
|
||||
@@ -14,10 +14,12 @@ type InferredRelationshipWithPersonDTO = components['schemas']['InferredRelation
|
||||
interface Props {
|
||||
node: PersonNodeDTO;
|
||||
onClose: () => void;
|
||||
/** When provided, a "centre on this person" control appears in the title row (US-PAN-005). */
|
||||
onCentre?: () => void;
|
||||
canWrite?: boolean;
|
||||
}
|
||||
|
||||
let { node, onClose, canWrite = false }: Props = $props();
|
||||
let { node, onClose, onCentre, canWrite = false }: Props = $props();
|
||||
|
||||
let directRels = $state<RelationshipDTO[]>([]);
|
||||
let derivedRels = $state<InferredRelationshipWithPersonDTO[]>([]);
|
||||
@@ -95,23 +97,45 @@ const topDerived = $derived(
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onClose}
|
||||
aria-label={m.comp_dismiss()}
|
||||
class="shrink-0 rounded-sm p-1 text-ink-3 transition hover:bg-muted hover:text-ink focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
aria-hidden="true"
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
{#if onCentre}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onCentre}
|
||||
aria-label={m.stammbaum_centre_on_person()}
|
||||
class="rounded-sm p-1 text-ink-3 transition hover:bg-muted hover:text-ink focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="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="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"
|
||||
>
|
||||
<path stroke-linecap="round" d="M3 3l10 10M13 3L3 13" />
|
||||
</svg>
|
||||
</button>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path stroke-linecap="round" d="M3 3l10 10M13 3L3 13" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -29,6 +29,8 @@ interface Props {
|
||||
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;
|
||||
onSelect: (id: string) => void;
|
||||
/**
|
||||
* Force-show or force-hide the generation gutter. When undefined, falls
|
||||
@@ -46,6 +48,7 @@ let {
|
||||
panZoom,
|
||||
onPanZoom = () => {},
|
||||
centreOnId = null,
|
||||
onActivity,
|
||||
onSelect,
|
||||
showGutter
|
||||
}: Props = $props();
|
||||
@@ -295,7 +298,8 @@ const parentLinks = $derived.by<ParentLinks>(() => {
|
||||
baseCentreX: baseCentre.x,
|
||||
baseCentreY: baseCentre.y,
|
||||
reducedMotion,
|
||||
onPanZoom
|
||||
onPanZoom,
|
||||
onGestureStart: onActivity
|
||||
}}
|
||||
class="block h-full w-full"
|
||||
>
|
||||
|
||||
@@ -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';
|
||||
@@ -512,7 +513,7 @@ describe('StammbaumTree node rendering branches', () => {
|
||||
});
|
||||
|
||||
describe('StammbaumTree keyboard pan/zoom (#692)', () => {
|
||||
const renderTree = (onPanZoom: ReturnType<typeof vi.fn>) =>
|
||||
const renderTree = (onPanZoom: (state: PanZoomState) => void) =>
|
||||
render(StammbaumTree, {
|
||||
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
|
||||
edges: [],
|
||||
|
||||
17
frontend/src/lib/person/genealogy/animateView.test.ts
Normal file
17
frontend/src/lib/person/genealogy/animateView.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { animateView } from './animateView';
|
||||
|
||||
describe('animateView (reduced motion)', () => {
|
||||
const from = { x: 0, y: 0, z: 1 };
|
||||
const to = { x: 80, y: -30, z: 2 };
|
||||
|
||||
it('snaps straight to the target in a single frame when reduced motion is on', () => {
|
||||
const onFrame = vi.fn();
|
||||
const cancel = animateView(from, to, onFrame, { reducedMotion: true });
|
||||
|
||||
expect(onFrame).toHaveBeenCalledTimes(1);
|
||||
expect(onFrame).toHaveBeenCalledWith(to);
|
||||
expect(typeof cancel).toBe('function');
|
||||
cancel();
|
||||
});
|
||||
});
|
||||
38
frontend/src/lib/person/genealogy/animateView.ts
Normal file
38
frontend/src/lib/person/genealogy/animateView.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { lerpView, type PanZoomState } from '$lib/person/genealogy/panZoom';
|
||||
|
||||
/** Fit / recentre animation duration (US-PAN-004 AC2: ≤ 300 ms). */
|
||||
export const VIEW_ANIM_MS = 300;
|
||||
|
||||
const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3);
|
||||
|
||||
/** Snapshot of the user's reduced-motion preference (non-reactive, browser-only). */
|
||||
export function prefersReducedMotion(): boolean {
|
||||
return typeof window !== 'undefined' && typeof window.matchMedia === 'function'
|
||||
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
: false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tween the view from `from` to `to`, calling `onFrame` with each interpolated
|
||||
* state. Honors reduced motion by snapping straight to the target (NFR-A11Y-003,
|
||||
* REQ-PAN-005). Returns a cancel function.
|
||||
*/
|
||||
export function animateView(
|
||||
from: PanZoomState,
|
||||
to: PanZoomState,
|
||||
onFrame: (state: PanZoomState) => void,
|
||||
opts: { reducedMotion?: boolean; durationMs?: number } = {}
|
||||
): () => void {
|
||||
if (opts.reducedMotion) {
|
||||
onFrame(to);
|
||||
return () => {};
|
||||
}
|
||||
const duration = opts.durationMs ?? VIEW_ANIM_MS;
|
||||
const start = performance.now();
|
||||
let raf = requestAnimationFrame(function step(now: number) {
|
||||
const t = Math.min(1, (now - start) / duration);
|
||||
onFrame(lerpView(from, to, easeOutCubic(t)));
|
||||
if (t < 1) raf = requestAnimationFrame(step);
|
||||
});
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
zoomAtPoint,
|
||||
recentreOn,
|
||||
clampPan,
|
||||
lerpView,
|
||||
DEFAULT_VIEW,
|
||||
DEFAULT_ZOOM,
|
||||
LEGIBLE_ZOOM,
|
||||
@@ -166,3 +167,17 @@ describe('clampPan', () => {
|
||||
expect(clampPan({ x: 100, y: -50, z: 2 }, 1000, 800)).toEqual({ x: 100, y: -50, z: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('lerpView', () => {
|
||||
const from = { x: 0, y: 0, z: 1 };
|
||||
const to = { x: 100, y: -40, z: 2 };
|
||||
|
||||
it('returns the start at t=0 and the end at t=1', () => {
|
||||
expect(lerpView(from, to, 0)).toEqual(from);
|
||||
expect(lerpView(from, to, 1)).toEqual(to);
|
||||
});
|
||||
|
||||
it('interpolates each axis linearly at t=0.5', () => {
|
||||
expect(lerpView(from, to, 0.5)).toEqual({ x: 50, y: -20, z: 1.5 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -113,6 +113,15 @@ export function zoomAtPoint(
|
||||
};
|
||||
}
|
||||
|
||||
/** Linearly interpolate between two view states (drives fit/recentre tweening). */
|
||||
export function lerpView(from: PanZoomState, to: PanZoomState, t: number): PanZoomState {
|
||||
return {
|
||||
x: from.x + (to.x - from.x) * t,
|
||||
y: from.y + (to.y - from.y) * t,
|
||||
z: from.z + (to.z - from.z) * t
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamp the pan offset so the canvas cannot be dragged off the edge (US-PAN-001
|
||||
* AC4 — no infinite scroll). The pannable range on each axis is half the
|
||||
|
||||
60
frontend/src/lib/shared/actions/trapFocus.svelte.spec.ts
Normal file
60
frontend/src/lib/shared/actions/trapFocus.svelte.spec.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
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]);
|
||||
});
|
||||
});
|
||||
43
frontend/src/lib/shared/actions/trapFocus.ts
Normal file
43
frontend/src/lib/shared/actions/trapFocus.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 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) {
|
||||
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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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 } 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,15 @@ export async function load({ fetch }) {
|
||||
throw error(result.response.status, getErrorMessage(extractErrorCode(result.error)));
|
||||
}
|
||||
|
||||
// Sanitise the shareable pan/zoom params server-side so a crafted link
|
||||
// (?z=Infinity, ?cx=NaN) degrades to a safe view before reaching layout
|
||||
// geometry (Nora #692).
|
||||
const initialView = parsePanZoomParams({
|
||||
cx: url.searchParams.get('cx'),
|
||||
cy: url.searchParams.get('cy'),
|
||||
z: url.searchParams.get('z')
|
||||
});
|
||||
|
||||
const network = result.data!;
|
||||
return { nodes: network.nodes ?? [], edges: network.edges ?? [] };
|
||||
return { nodes: network.nodes ?? [], edges: network.edges ?? [], initialView };
|
||||
}
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { untrack, tick } 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();
|
||||
@@ -29,13 +36,46 @@ let selectedId = $state<string | null>(
|
||||
|
||||
const selectedNode = $derived(data.nodes.find((n) => n.id === selectedId) ?? null);
|
||||
|
||||
let view = $state<PanZoomState>(DEFAULT_VIEW);
|
||||
let view = $state<PanZoomState>(data.initialView);
|
||||
let canvasActivity = $state(false);
|
||||
function zoomIn() {
|
||||
view = { ...view, z: clampZoom(view.z + ZOOM_STEP_KB) };
|
||||
}
|
||||
function zoomOut() {
|
||||
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()
|
||||
});
|
||||
}
|
||||
|
||||
// Mirror the view into shareable ?cx&cy&z params (OQ-003). Only `view` is a
|
||||
// tracked dependency; 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);
|
||||
untrack(() => {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('cx', cx);
|
||||
url.searchParams.set('cy', cy);
|
||||
url.searchParams.set('z', z);
|
||||
replaceState(url, page.state);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- 4.25rem = 4rem navbar (h-16) + 0.25rem accent strip (h-1).
|
||||
@@ -46,26 +86,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}
|
||||
@@ -98,15 +118,19 @@ function zoomOut() {
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<div class="flex-1 overflow-hidden bg-muted/20">
|
||||
<div class="relative flex-1 overflow-hidden bg-muted/20">
|
||||
<StammbaumTree
|
||||
nodes={data.nodes}
|
||||
edges={data.edges}
|
||||
selectedId={selectedId}
|
||||
panZoom={view}
|
||||
centreOnId={centreOnId}
|
||||
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 -->
|
||||
@@ -117,18 +141,16 @@ function zoomOut() {
|
||||
node={selectedNode}
|
||||
canWrite={canWrite}
|
||||
onClose={() => (selectedId = null)}
|
||||
onCentre={centreOnSelected}
|
||||
/>
|
||||
</aside>
|
||||
<!-- Mobile: fixed bottom sheet -->
|
||||
<div
|
||||
class="fixed inset-x-0 bottom-0 z-40 max-h-[60dvh] overflow-y-auto border-t border-line bg-surface shadow-lg md:hidden"
|
||||
>
|
||||
<StammbaumSidePanel
|
||||
node={selectedNode}
|
||||
canWrite={canWrite}
|
||||
onClose={() => (selectedId = null)}
|
||||
/>
|
||||
</div>
|
||||
<!-- Mobile: dismissible bottom sheet (overlay, preserves pan/zoom) -->
|
||||
<StammbaumBottomSheet
|
||||
node={selectedNode}
|
||||
canWrite={canWrite}
|
||||
onClose={() => (selectedId = null)}
|
||||
onCentre={centreOnSelected}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
56
frontend/src/routes/stammbaum/page.server.test.ts
Normal file
56
frontend/src/routes/stammbaum/page.server.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
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, 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 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 DEFAULT_VIEW when no pan/zoom params are present', async () => {
|
||||
mockNetwork();
|
||||
const { load } = await import('./+page.server');
|
||||
const result = await load(loadEvent() as never);
|
||||
expect(result.initialView).toEqual(DEFAULT_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);
|
||||
});
|
||||
});
|
||||
@@ -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,11 @@ vi.mock('$app/state', () => ({
|
||||
}
|
||||
}));
|
||||
|
||||
const replaceState = vi.fn();
|
||||
vi.mock('$app/navigation', () => ({
|
||||
replaceState: (...args: unknown[]) => replaceState(...args)
|
||||
}));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
async function loadComponent() {
|
||||
@@ -28,7 +35,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 +48,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 +59,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 +71,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,7 +81,9 @@ 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();
|
||||
});
|
||||
@@ -78,11 +91,29 @@ describe('stammbaum page', () => {
|
||||
it('clamps the zoom level when the zoom-out button is clicked many times', 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 } }
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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