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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 21:42:53 +02:00

309 lines
11 KiB
Svelte

<script lang="ts">
import { untrack, onMount } from 'svelte';
import { SvelteMap } from 'svelte/reactivity';
import type { components } from '$lib/generated/api';
import {
buildLayout,
NODE_W,
NODE_H,
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'];
interface Props {
nodes: PersonNodeDTO[];
edges: RelationshipDTO[];
selectedId: string | null;
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
* back to a `window.matchMedia('(min-width: 768px)')` detection so the
* gutter only appears on md+ viewports. Tests pass an explicit boolean
* to avoid depending on the vitest-browser iframe viewport.
*/
showGutter?: boolean;
}
let {
nodes,
edges,
selectedId,
panZoom,
onPanZoom = () => {},
centreOnId = null,
onActivity,
anchorTopLeft = false,
onSelect,
showGutter
}: Props = $props();
const layout = $derived.by<Layout>(() => buildLayout(nodes, edges));
// Stammbaum gutter (#689). 100 px column on the left of the canvas on md+
// viewports, carrying the G{n} label per generation row. Hidden entirely on
// phones (canvas is already overflow-scroll; 100 px of permanent chrome is
// too costly on a 320 px screen).
const GUTTER_WIDTH_DESKTOP = 100;
const GUTTER_MEDIA_QUERY = '(min-width: 768px)';
// Seed synchronously so the first paint already has the right gutter state —
// otherwise the test (and a brief flash on real CSR mount) would see the
// pre-effect false. SSR has no window; the gutter stays hidden until hydrate.
let isMdOrUp = $state(
typeof window !== 'undefined' && typeof window.matchMedia === 'function'
? window.matchMedia(GUTTER_MEDIA_QUERY).matches
: false
);
$effect(() => {
if (showGutter !== undefined) return;
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
const mq = window.matchMedia(GUTTER_MEDIA_QUERY);
const handler = (e: MediaQueryListEvent) => (isMdOrUp = e.matches);
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
});
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[]>(() => {
const byId = new SvelteMap(nodes.map((n) => [n.id, n]));
const rows: GutterRow[] = [];
const sortedRanks = [...layout.generations.keys()].sort((a, b) => a - b);
for (const rank of sortedRanks) {
const ids = layout.generations.get(rank)!;
const firstPos = layout.positions.get(ids[0]);
if (!firstPos) continue;
let label: number | null = null;
for (const id of ids) {
const g = byId.get(id)?.generation;
if (g != null) {
label = g;
break;
}
}
rows.push({ rank, y: firstPos.y, label });
}
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 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 };
}
// Recentre when the parent sets centreOnId (US-PAN-005). Only centreOnId is a
// tracked dependency — the current view is read untracked so a normal pan does
// not retrigger a recentre.
$effect(() => {
const id = centreOnId;
if (!id) return;
untrack(() => {
const c = nodeCenter(id);
if (c) onPanZoom(recentreOn(c, baseCentre, panZoom, true));
});
});
// Canvas-level keyboard: `+`/`-` zoom by the fixed step (OQ-002), arrows pan by
// a tenth of the visible extent. Nodes keep their own Enter/Space selection.
function handleCanvasKey(event: KeyboardEvent) {
const stepX = (baseDims.w / panZoom.z) * 0.1;
const stepY = (baseDims.h / panZoom.z) * 0.1;
switch (event.key) {
case '+':
case '=':
onPanZoom({ ...panZoom, z: clampZoom(panZoom.z + ZOOM_STEP_KB) });
break;
case '-':
case '_':
onPanZoom({ ...panZoom, z: clampZoom(panZoom.z - ZOOM_STEP_KB) });
break;
case 'ArrowLeft':
onPanZoom({ ...panZoom, x: panZoom.x - stepX });
break;
case 'ArrowRight':
onPanZoom({ ...panZoom, x: panZoom.x + stepX });
break;
case 'ArrowUp':
onPanZoom({ ...panZoom, y: panZoom.y - stepY });
break;
case 'ArrowDown':
onPanZoom({ ...panZoom, y: panZoom.y + stepY });
break;
default:
return;
}
event.preventDefault();
}
</script>
<!-- Relative wrapper so the pinned generation rail can overlay the canvas. -->
<div class="relative h-full w-full">
<!-- The canvas is a custom interactive pan/zoom region: `tabindex` lets keyboard
users focus it and the keydown handler is the keyboard-only alternative to
touch/mouse gestures (NFR-A11Y-002). The visible focus outline is kept. -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<svg
bind:this={svgEl}
viewBox={viewBox}
preserveAspectRatio="xMinYMin meet"
role="img"
aria-label="Stammbaum"
tabindex="0"
style={maskStyle}
onkeydown={handleCanvasKey}
use:panZoomGestures={{
state: panZoom,
baseW: baseDims.w,
baseH: baseDims.h,
baseCentreX: baseCentre.x,
baseCentreY: baseCentre.y,
reducedMotion,
onPanZoom,
onGestureStart: onActivity
}}
class="block h-full w-full"
>
<!-- Gutter stripe underlay (#689) — decorative full-row bands alternating
transparent / var(--c-gutter-stripe), desktop only. Generation labels
are no longer drawn in-SVG; the pinned rail below carries them. -->
{#if gutterVisible}
{#each gutterRows as row, i (`stripe-${row.rank}`)}
<rect
aria-hidden="true"
x={layout.viewX - gutterWidth}
y={row.y - ROW_GAP / 2}
width={layout.viewW + gutterWidth}
height={NODE_H + ROW_GAP}
fill={i % 2 === 0 ? 'transparent' : 'var(--c-gutter-stripe)'}
/>
{/each}
{/if}
<StammbaumConnectors edges={edges} positions={layout.positions} />
<!-- Nodes -->
{#each nodes as node (node.id)}
{@const pos = layout.positions.get(node.id)}
{#if pos}
<StammbaumNode
node={node}
pos={pos}
selected={selectedId === node.id}
onSelect={onSelect}
/>
{/if}
{/each}
</svg>
<StammbaumGenerationRail svg={svgEl} rows={railRows} panZoom={panZoom} />
</div>