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:
18
frontend/src/routes/stammbaum/+page.server.ts
Normal file
18
frontend/src/routes/stammbaum/+page.server.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
import { createApiClient } from '$lib/api.server';
|
||||
import { getErrorMessage } from '$lib/errors';
|
||||
|
||||
export async function load({ fetch }) {
|
||||
const api = createApiClient(fetch);
|
||||
const result = await api.GET('/api/network');
|
||||
|
||||
if (result.response.status === 401) throw redirect(302, '/login');
|
||||
|
||||
if (!result.response.ok) {
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
throw error(result.response.status, getErrorMessage(code));
|
||||
}
|
||||
|
||||
const network = result.data!;
|
||||
return { nodes: network.nodes ?? [], edges: network.edges ?? [] };
|
||||
}
|
||||
110
frontend/src/routes/stammbaum/+page.svelte
Normal file
110
frontend/src/routes/stammbaum/+page.svelte
Normal file
@@ -0,0 +1,110 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { page } from '$app/state';
|
||||
import StammbaumTree from '$lib/components/StammbaumTree.svelte';
|
||||
import StammbaumSidePanel from '$lib/components/StammbaumSidePanel.svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
|
||||
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||
|
||||
interface Props {
|
||||
data: { nodes: PersonNodeDTO[]; edges: RelationshipDTO[] };
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
const focusId = $derived(page.url.searchParams.get('focus'));
|
||||
|
||||
let selectedId = $state<string | null>(null);
|
||||
$effect(() => {
|
||||
if (focusId && data.nodes.some((n) => n.id === focusId)) {
|
||||
selectedId = focusId;
|
||||
}
|
||||
});
|
||||
|
||||
const selectedNode = $derived(data.nodes.find((n) => n.id === selectedId) ?? null);
|
||||
|
||||
let zoom = $state(1);
|
||||
function zoomIn() {
|
||||
zoom = Math.min(2, zoom + 0.1);
|
||||
}
|
||||
function zoomOut() {
|
||||
zoom = Math.max(0.4, zoom - 0.1);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col">
|
||||
<header
|
||||
class="flex shrink-0 items-center justify-between border-b border-line bg-surface px-6 py-4"
|
||||
>
|
||||
<h1 class="font-serif text-2xl text-ink">{m.nav_stammbaum()}</h1>
|
||||
{#if data.nodes.length > 0}
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={zoomOut}
|
||||
aria-label={m.stammbaum_zoom_out()}
|
||||
class="inline-flex h-8 w-8 items-center justify-center rounded-sm border border-line bg-surface text-ink-2 transition hover:bg-muted"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={zoomIn}
|
||||
aria-label={m.stammbaum_zoom_in()}
|
||||
class="inline-flex h-8 w-8 items-center justify-center rounded-sm border border-line bg-surface text-ink-2 transition hover:bg-muted"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if data.nodes.length === 0}
|
||||
<div class="flex flex-1 items-center justify-center p-8">
|
||||
<div
|
||||
class="mx-auto max-w-md rounded-sm border border-line bg-surface p-10 text-center shadow-sm"
|
||||
>
|
||||
<svg
|
||||
class="mx-auto mb-4 h-12 w-12 text-ink-3"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="5" r="2.5" />
|
||||
<circle cx="6" cy="14" r="2.5" />
|
||||
<circle cx="18" cy="14" r="2.5" />
|
||||
<path stroke-linecap="round" d="M12 7.5v3M9.5 12.5L9 14M14.5 12.5l.5 1.5" />
|
||||
</svg>
|
||||
<h2 class="mb-2 font-serif text-xl text-ink">{m.stammbaum_empty_heading()}</h2>
|
||||
<p class="mb-4 font-serif text-sm text-ink-2">{m.stammbaum_empty_body()}</p>
|
||||
<a
|
||||
href="/persons"
|
||||
class="inline-block font-sans text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
{m.stammbaum_empty_link()}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<div class="flex-1 overflow-auto bg-muted/20">
|
||||
<StammbaumTree
|
||||
nodes={data.nodes}
|
||||
edges={data.edges}
|
||||
selectedId={selectedId}
|
||||
zoom={zoom}
|
||||
onSelect={(id) => (selectedId = id)}
|
||||
/>
|
||||
</div>
|
||||
{#if selectedNode}
|
||||
<aside class="w-[268px] shrink-0 overflow-y-auto border-l border-line bg-surface">
|
||||
<StammbaumSidePanel node={selectedNode} onClose={() => (selectedId = null)} />
|
||||
</aside>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user