feat(stammbaum): /stammbaum page — SVG tree + side panel + empty state

- /stammbaum/+page.server.ts loads GET /api/network (already filtered
  to family members on the backend) and returns nodes + edges.
- +page.svelte holds the page shell, manages selectedId (with
  ?focus={id} deep-link support) and zoom state, renders the empty
  state when nodes.length === 0 (icon + heading + body + link to
  /persons), or the tree + side panel otherwise.
- StammbaumTree.svelte: BFS-based generation assignment from roots,
  spouses promoted to the deeper generation so couples sit on the same
  row, alphabetical sort within row, simple grid layout. SVG nodes are
  role="button" + aria-label="{name}, {birth}–{death}" +
  aria-expanded={selected}, with click + Enter/Space activation. Solid
  parent→child connectors; mint spouse line with midpoint circle, dashed
  if SPOUSE_OF.toYear is set (former spouse). Zoom maps to viewBox.
- StammbaumSidePanel.svelte: lazily loads
  /api/persons/{id}/relationships and /inferred-relationships when the
  selection changes; shows direct chips (mint), top-5 derived chips
  (grey), and a "Zur Personenseite →" link. Escape closes the panel.

Refs #358.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-27 14:56:41 +02:00
committed by marcel
parent aaf885cafd
commit 242e10179d
6 changed files with 865 additions and 0 deletions

View File

@@ -0,0 +1,239 @@
<script lang="ts">
/* eslint-disable svelte/prefer-svelte-reactivity -- maps are scope-local
to a single $derived.by computation; never mutated after layout. */
import type { components } from '$lib/generated/api';
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
type RelationshipDTO = components['schemas']['RelationshipDTO'];
interface Props {
nodes: PersonNodeDTO[];
edges: RelationshipDTO[];
selectedId: string | null;
zoom: number;
onSelect: (id: string) => void;
}
let { nodes, edges, selectedId, zoom, onSelect }: Props = $props();
const NODE_W = 160;
const NODE_H = 56;
const COL_GAP = 40;
const ROW_GAP = 80;
type Layout = {
positions: Map<string, { x: number; y: number }>;
generations: Map<number, string[]>;
width: number;
height: number;
};
const layout = $derived.by<Layout>(() => buildLayout(nodes, edges));
const viewBox = $derived(`0 0 ${layout.width / zoom} ${layout.height / zoom}`);
function buildLayout(allNodes: PersonNodeDTO[], allEdges: RelationshipDTO[]): Layout {
const parentToChildren = new Map<string, string[]>();
const childToParents = new Map<string, string[]>();
const spousePairs = new Map<string, string>();
for (const e of allEdges) {
switch (e.relationType) {
case 'PARENT_OF':
mapPush(parentToChildren, e.personId, e.relatedPersonId);
mapPush(childToParents, e.relatedPersonId, e.personId);
break;
case 'SPOUSE_OF':
spousePairs.set(e.personId, e.relatedPersonId);
spousePairs.set(e.relatedPersonId, e.personId);
break;
}
}
// Generation assignment via BFS from roots (nodes with no parents in graph).
const generation = new Map<string, number>();
const queue: string[] = [];
for (const n of allNodes) {
if (!childToParents.has(n.id)) {
generation.set(n.id, 0);
queue.push(n.id);
}
}
while (queue.length > 0) {
const id = queue.shift()!;
const g = generation.get(id) ?? 0;
for (const childId of parentToChildren.get(id) ?? []) {
if (!generation.has(childId)) {
generation.set(childId, g + 1);
queue.push(childId);
}
}
}
// Anything not assigned (cycles or isolated nodes after a graph slice) → gen 0.
for (const n of allNodes) {
if (!generation.has(n.id)) generation.set(n.id, 0);
}
// Spouses share the deeper generation so they sit on the same row.
for (const [a, b] of spousePairs) {
const g = Math.max(generation.get(a) ?? 0, generation.get(b) ?? 0);
generation.set(a, g);
generation.set(b, g);
}
// Group by generation, then sort within generation by display name.
const generations = new Map<number, string[]>();
for (const n of allNodes) {
const g = generation.get(n.id) ?? 0;
if (!generations.has(g)) generations.set(g, []);
generations.get(g)!.push(n.id);
}
const byId = new Map(allNodes.map((n) => [n.id, n]));
for (const ids of generations.values()) {
ids.sort((a, b) => {
const an = byId.get(a)?.displayName ?? '';
const bn = byId.get(b)?.displayName ?? '';
return an.localeCompare(bn);
});
}
const positions = new Map<string, { x: number; y: number }>();
let maxRowWidth = 0;
const sortedGens = [...generations.keys()].sort((a, b) => a - b);
for (const g of sortedGens) {
const ids = generations.get(g)!;
ids.forEach((id, idx) => {
positions.set(id, {
x: idx * (NODE_W + COL_GAP),
y: g * (NODE_H + ROW_GAP)
});
});
maxRowWidth = Math.max(maxRowWidth, ids.length * (NODE_W + COL_GAP));
}
const height = (sortedGens.length || 1) * (NODE_H + ROW_GAP);
return { positions, generations, width: maxRowWidth + 80, height: height + 80 };
}
function mapPush<K, V>(map: Map<K, V[]>, key: K, value: V) {
const arr = map.get(key);
if (arr) arr.push(value);
else map.set(key, [value]);
}
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 };
}
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'));
</script>
<svg
viewBox={viewBox}
preserveAspectRatio="xMidYMid meet"
role="img"
aria-label="Stammbaum"
class="block h-full w-full"
>
<!-- Parent → child connectors -->
{#each parentEdges as e (e.id)}
{@const parentCenter = nodeCenter(e.personId)}
{@const childCenter = nodeCenter(e.relatedPersonId)}
{#if parentCenter && childCenter}
<line
x1={parentCenter.x}
y1={parentCenter.y + NODE_H / 2}
x2={childCenter.x}
y2={childCenter.y - NODE_H / 2}
stroke="var(--c-line, #d4d4d4)"
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-accent, #00c7b1)"
stroke-width="2"
stroke-dasharray={e.toYear ? '4 4' : undefined}
/>
<circle
cx={(aCenter.x + bCenter.x) / 2}
cy={(aCenter.y + bCenter.y) / 2}
r="3"
fill="var(--c-accent, #00c7b1)"
/>
{/if}
{/each}
<!-- Nodes -->
{#each nodes as node (node.id)}
{@const pos = layout.positions.get(node.id)}
{#if pos}
{@const isSelected = selectedId === 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)}
class="cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<rect
width={NODE_W}
height={NODE_H}
rx="4"
fill={isSelected ? 'var(--c-primary, #002850)' : 'var(--c-surface, white)'}
stroke="var(--c-line, #d4d4d4)"
stroke-width="1"
/>
{#if isSelected}
<rect width="4" height={NODE_H} rx="2" fill="var(--c-accent, #00c7b1)" />
{/if}
<text
x={NODE_W / 2}
y={NODE_H / 2 - 4}
text-anchor="middle"
font-family="serif"
font-size="14"
fill={isSelected ? 'white' : 'var(--c-ink, #002850)'}
>
{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="10"
fill={isSelected
? 'rgba(255,255,255,0.7)'
: 'var(--c-ink-3, #6b7280)'}
>
{node.birthYear ?? '?'}{node.deathYear ?? ''}
</text>
{/if}
</g>
{/if}
{/each}
</svg>