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,160 @@
<script lang="ts">
import { onMount } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import { inferredRelationshipLabel } from '$lib/relationshipLabels';
import type { components } from '$lib/generated/api';
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
type RelationshipDTO = components['schemas']['RelationshipDTO'];
type InferredRelationshipWithPersonDTO = components['schemas']['InferredRelationshipWithPersonDTO'];
interface Props {
node: PersonNodeDTO;
onClose: () => void;
}
let { node, onClose }: Props = $props();
let directRels = $state<RelationshipDTO[]>([]);
let derivedRels = $state<InferredRelationshipWithPersonDTO[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
$effect(() => {
const id = node.id;
loadFor(id);
});
async function loadFor(id: string) {
loading = true;
error = null;
try {
const [directRes, derivedRes] = await Promise.all([
fetch(`/api/persons/${id}/relationships`),
fetch(`/api/persons/${id}/inferred-relationships`)
]);
if (!directRes.ok || !derivedRes.ok) {
error = m.error_internal_error();
return;
}
directRels = await directRes.json();
derivedRels = await derivedRes.json();
} catch {
error = m.error_internal_error();
} finally {
loading = false;
}
}
function chipLabel(rel: RelationshipDTO): string {
const viewpointIsSubject = rel.personId === node.id;
switch (rel.relationType) {
case 'PARENT_OF':
return viewpointIsSubject ? m.relation_parent_of() : m.relation_child_of();
case 'SPOUSE_OF':
return m.relation_spouse_of();
case 'SIBLING_OF':
return m.relation_sibling_of();
case 'FRIEND':
return m.relation_friend();
case 'COLLEAGUE':
return m.relation_colleague();
case 'EMPLOYER':
return m.relation_employer();
case 'DOCTOR':
return m.relation_doctor();
case 'NEIGHBOR':
return m.relation_neighbor();
default:
return m.relation_other();
}
}
function otherName(rel: RelationshipDTO): string {
return rel.personId === node.id ? rel.relatedPersonDisplayName : rel.personDisplayName;
}
function handleEscape(event: KeyboardEvent) {
if (event.key === 'Escape') onClose();
}
onMount(() => {
const handler = (e: KeyboardEvent) => handleEscape(e);
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
});
const topDerived = $derived(derivedRels.slice(0, 5));
</script>
<div class="flex h-full flex-col p-5">
<div class="mb-4">
<h2 class="font-serif text-lg text-ink">{node.displayName}</h2>
{#if node.birthYear || node.deathYear}
<p class="font-sans text-xs text-ink-3">
{node.birthYear ?? '?'}{node.deathYear ?? ''}
</p>
{/if}
</div>
{#if error}
<p class="mb-3 text-sm text-red-700" role="alert">{error}</p>
{:else if loading}
<p class="font-sans text-xs text-ink-3 italic"></p>
{:else}
<section class="mb-5">
<h3 class="mb-2 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.stammbaum_panel_direct_rels()}
</h3>
{#if directRels.length === 0}
<p class="text-xs text-ink-2 italic">{m.person_relationships_empty()}</p>
{:else}
<ul class="space-y-1.5">
{#each directRels as rel (rel.id)}
<li class="flex items-center gap-2">
<span
class="inline-flex shrink-0 items-center rounded-full border border-accent/40 bg-accent/15 px-2 py-0.5 font-sans text-[10px] font-bold tracking-widest text-ink uppercase"
>
{chipLabel(rel)}
</span>
<span class="min-w-0 flex-1 truncate font-serif text-xs text-ink">
{otherName(rel)}
</span>
</li>
{/each}
</ul>
{/if}
</section>
{#if topDerived.length > 0}
<section class="mb-5">
<h3 class="mb-2 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.stammbaum_panel_derived_rels()}
</h3>
<ul class="space-y-1.5">
{#each topDerived as derived (derived.person.id)}
<li class="flex items-center gap-2">
<span
class="inline-flex shrink-0 items-center rounded-full border border-line bg-muted/50 px-2 py-0.5 font-sans text-[10px] font-bold tracking-widest text-ink-2 uppercase"
>
{inferredRelationshipLabel(derived.label)}
</span>
<span class="min-w-0 flex-1 truncate font-serif text-xs text-ink-2">
{derived.person.displayName}
</span>
</li>
{/each}
</ul>
</section>
{/if}
{/if}
<div class="mt-auto">
<a
href="/persons/{node.id}"
class="block w-full rounded-sm border border-line bg-surface px-3 py-2 text-center font-sans text-xs font-medium text-primary transition hover:bg-muted"
>
{m.stammbaum_panel_to_person()}
</a>
</div>
</div>