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>
200 lines
6.9 KiB
Svelte
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>
|