Files
familienarchiv/frontend/src/routes/stammbaum/+page.svelte
Marcel 4583ee2c4d feat(stammbaum): centre the tapped person above the bottom sheet (#703)
On a touch viewport (below the md breakpoint, where the bottom sheet
overlays the lower part of the canvas), tapping a person now auto-centres
them via recentreAbove with a 0.3 height bias, so the highlighted anchor
lands in the band above the sheet instead of behind it (AC8). On desktop
the side panel is a flex sibling that never covers the tree, so the bias
is 0 and selection does not pan. StammbaumTree's recentre effect takes a
centreBiasFraction prop and the page drives it from a matchMedia flag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 16:41:00 +02:00

200 lines
6.9 KiB
Svelte

<script lang="ts">
import { untrack, tick, onMount } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import { page } from '$app/state';
import { replaceState } from '$app/navigation';
import StammbaumTree from '$lib/person/genealogy/StammbaumTree.svelte';
import StammbaumSidePanel from '$lib/person/genealogy/StammbaumSidePanel.svelte';
import StammbaumBottomSheet from '$lib/person/genealogy/StammbaumBottomSheet.svelte';
import StammbaumControls from '$lib/person/genealogy/StammbaumControls.svelte';
import StammbaumAffordance from '$lib/person/genealogy/StammbaumAffordance.svelte';
import {
type PanZoomState,
DEFAULT_VIEW,
clampZoom,
serializePanZoomParams,
ZOOM_STEP_KB
} from '$lib/person/genealogy/panZoom';
import { animateView, prefersReducedMotion } from '$lib/person/genealogy/animateView';
import type { components } from '$lib/generated/api';
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
type RelationshipDTO = components['schemas']['RelationshipDTO'];
interface Props {
data: { nodes: PersonNodeDTO[]; edges: RelationshipDTO[]; initialView: PanZoomState };
}
let { data }: Props = $props();
const canWrite = $derived<boolean>(page.data.canWrite ?? false);
const focusId = page.url.searchParams.get('focus');
let selectedId = $state<string | null>(
focusId && data.nodes.some((n) => n.id === focusId) ? focusId : null
);
const selectedNode = $derived(data.nodes.find((n) => n.id === selectedId) ?? null);
let view = $state<PanZoomState>(data.initialView);
let canvasActivity = $state(false);
function zoomIn() {
view = { ...view, z: clampZoom(view.z + ZOOM_STEP_KB) };
}
function zoomOut() {
view = { ...view, z: clampZoom(view.z - ZOOM_STEP_KB) };
}
// One-shot recentre trigger: set the focal id, let StammbaumTree's effect read
// it and emit the recentred view, then clear so the same person can be
// re-centred on a later click (US-PAN-005).
let centreOnId = $state<string | null>(null);
async function centreOnSelected() {
centreOnId = selectedId;
await tick();
centreOnId = null;
}
// Below the md breakpoint the side panel is replaced by a bottom sheet that
// overlays the lower ~60dvh of the canvas. On a phone we therefore auto-centre
// the tapped person into the band above the sheet (#703 AC8); on desktop the
// panel is a flex sibling that never covers the tree, so no centring is needed.
const MOBILE_QUERY = '(max-width: 767px)';
/** How far above the viewBox centre to lift the tapped anchor on mobile. */
const MOBILE_CENTRE_BIAS = 0.3;
let isMobile = $state(
typeof window !== 'undefined' && typeof window.matchMedia === 'function'
? window.matchMedia(MOBILE_QUERY).matches
: false
);
$effect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
const mq = window.matchMedia(MOBILE_QUERY);
const handler = (e: MediaQueryListEvent) => (isMobile = e.matches);
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
});
async function selectPerson(id: string) {
selectedId = id;
if (isMobile) await centreOnSelected();
}
let cancelAnimation = () => {};
function fitToScreen() {
cancelAnimation();
cancelAnimation = animateView(view, DEFAULT_VIEW, (v) => (view = v), {
reducedMotion: prefersReducedMotion()
});
}
// SvelteKit's replaceState throws "before the router is initialized" if called
// during hydration (the router sets `started = true` only after onMount + the
// first effect tick). Gate the URL sync on a flag flipped after the first
// post-mount tick() — which resolves once hydration is complete — so the write
// only ever runs against a ready router.
let routerReady = $state(false);
onMount(() => {
tick().then(() => (routerReady = true));
});
// Mirror the view into shareable ?cx&cy&z params (OQ-003). Only `view` and
// `routerReady` are tracked; the current URL is read untracked so the
// replaceState write does not retrigger the effect. The state thus survives
// panel open/close (US-PANEL-002 AC1) and a shared link reproduces it (AC2).
$effect(() => {
const { cx, cy, z } = serializePanZoomParams(view);
if (!routerReady) return;
untrack(() => {
const url = new URL(window.location.href);
url.searchParams.set('cx', cx);
url.searchParams.set('cy', cy);
url.searchParams.set('z', z);
try {
replaceState(url, page.state);
} catch {
// Router not ready yet — the next view change retries.
}
});
});
</script>
<!-- 4.25rem = 4rem navbar (h-16) + 0.25rem accent strip (h-1).
-my-6 cancels <main>'s py-6 so the canvas sits flush against the navbar
on top and the viewport edge on the bottom. -->
<div class="-my-6 flex h-[calc(100dvh-4.25rem)] 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>
</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="relative flex-1 overflow-hidden bg-muted/20">
<StammbaumTree
nodes={data.nodes}
edges={data.edges}
selectedId={selectedId}
panZoom={view}
centreOnId={centreOnId}
centreBiasFraction={isMobile ? MOBILE_CENTRE_BIAS : 0}
anchorTopLeft={!page.url.searchParams.has('z')}
onPanZoom={(v) => (view = v)}
onActivity={() => (canvasActivity = true)}
onSelect={selectPerson}
/>
<StammbaumAffordance dismissed={canvasActivity} />
<StammbaumControls onZoomIn={zoomIn} onZoomOut={zoomOut} onFit={fitToScreen} />
</div>
{#if selectedNode}
<!-- Desktop: side panel on the right -->
<aside
class="hidden w-[320px] shrink-0 overflow-y-auto border-l border-line bg-surface md:block"
>
<StammbaumSidePanel
node={selectedNode}
canWrite={canWrite}
onClose={() => (selectedId = null)}
onCentre={centreOnSelected}
/>
</aside>
<!-- Mobile: dismissible bottom sheet (overlay, preserves pan/zoom) -->
<StammbaumBottomSheet
node={selectedNode}
canWrite={canWrite}
onClose={() => (selectedId = null)}
onCentre={centreOnSelected}
/>
{/if}
</div>
{/if}
</div>