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