Files
familienarchiv/frontend/src/lib/person/genealogy/StammbaumTree.svelte
Marcel a458d3508b feat(stammbaum): pinned generation-label rail on all viewports (#692)
Generation labels are no longer drawn in-SVG (where they panned/zoomed off
screen and were desktop-only). A new StammbaumGenerationRail overlays the canvas
left edge, mapping each generation row's centre through the SVG's live
getScreenCTM so chips stay pinned horizontally and track their row vertically at
any pan/zoom — on phones too. The desktop stripe underlay stays (gated on the
gutter breakpoint); the #689 label tests are rewritten against the rail.
Verified live: labels stay at left=4px while the canvas pans.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 18:39:22 +02:00

516 lines
16 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { untrack } from 'svelte';
import { SvelteMap, SvelteSet } 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,
recentreOn,
ZOOM_STEP_KB
} from '$lib/person/genealogy/panZoom';
import { panZoomGestures } from '$lib/person/genealogy/panZoomGestures';
import StammbaumGenerationRail from '$lib/person/genealogy/StammbaumGenerationRail.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;
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,
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 }))
);
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));
});
});
let focusedId = $state<string | null>(null);
function handleNodeKey(event: KeyboardEvent, id: string) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onSelect(id);
}
}
// 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();
}
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>
<!-- 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"
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}
<!-- 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}
<!-- Nodes -->
{#each nodes as node (node.id)}
{@const pos = layout.positions.get(node.id)}
{#if pos}
{@const isSelected = selectedId === node.id}
{@const isFocused = focusedId === node.id}
<g
role="button"
tabindex="0"
aria-label="{node.displayName}{node.birthYear || node.deathYear
? `, ${node.birthYear ?? '?'}${node.deathYear ?? ''}`
: ''}"
aria-expanded={isSelected}
transform="translate({pos.x}, {pos.y})"
onclick={() => onSelect(node.id)}
onkeydown={(e) => handleNodeKey(e, node.id)}
onfocus={() => (focusedId = node.id)}
onblur={() => (focusedId = null)}
class="cursor-pointer focus:outline-none"
>
{#if isFocused}
<rect
x="-3"
y="-3"
width={NODE_W + 6}
height={NODE_H + 6}
rx="6"
fill="none"
stroke="var(--c-focus-ring)"
stroke-width="2"
/>
{/if}
<rect
width={NODE_W}
height={NODE_H}
rx="4"
fill={isSelected ? 'var(--c-primary)' : 'var(--c-surface)'}
stroke="var(--c-primary)"
stroke-width="1.5"
/>
{#if isSelected}
<rect width="4" height={NODE_H} rx="2" fill="var(--c-accent)" />
{/if}
<text
x={NODE_W / 2}
y={NODE_H / 2 - 6}
text-anchor="middle"
font-family="serif"
font-size="16"
fill={isSelected ? 'var(--c-primary-fg)' : 'var(--c-ink)'}
>
{node.displayName}
</text>
{#if node.birthYear || node.deathYear}
<text
x={NODE_W / 2}
y={NODE_H / 2 + 12}
text-anchor="middle"
font-family="sans-serif"
font-size="12"
fill={isSelected ? 'var(--c-primary-fg)' : 'var(--c-ink-3)'}
opacity={isSelected ? 0.75 : 1}
>
{node.birthYear ?? '?'}{node.deathYear ?? ''}
</text>
{/if}
</g>
{/if}
{/each}
</svg>
<StammbaumGenerationRail svg={svgEl} rows={railRows} panZoom={panZoom} />
</div>