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:
239
frontend/src/lib/components/StammbaumTree.svelte
Normal file
239
frontend/src/lib/components/StammbaumTree.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user