diff --git a/frontend/src/lib/person/genealogy/StammbaumTree.svelte b/frontend/src/lib/person/genealogy/StammbaumTree.svelte index a957ccd4..53ccf02b 100644 --- a/frontend/src/lib/person/genealogy/StammbaumTree.svelte +++ b/frontend/src/lib/person/genealogy/StammbaumTree.svelte @@ -13,7 +13,7 @@ import { type PanZoomState, clampZoom, clampPan, - recentreOn, + recentreAbove, cornerView, ZOOM_STEP_KB } from '$lib/person/genealogy/panZoom'; @@ -35,6 +35,11 @@ interface Props { onPanZoom?: (state: PanZoomState) => void; /** When set to a node id, the canvas recentres on that node (US-PAN-005). */ centreOnId?: string | null; + /** + * Fraction of the viewBox height to lift a recentred node above the centre, + * so on a phone the anchor clears the bottom sheet (#703 AC8). 0 centres it. + */ + centreBiasFraction?: number; /** Fired on the first pointer interaction with the canvas (affordance dismiss). */ onActivity?: () => void; /** When true, the initial view is anchored to the tree's top-left corner. */ @@ -56,6 +61,7 @@ let { panZoom, onPanZoom = () => {}, centreOnId = null, + centreBiasFraction = 0, onActivity, anchorTopLeft = false, onSelect, @@ -219,7 +225,7 @@ $effect(() => { if (!id) return; untrack(() => { const c = nodeCenter(id); - if (c) onPanZoom(recentreOn(c, baseCentre, panZoom, true)); + if (c) onPanZoom(recentreAbove(c, baseCentre, panZoom, baseDims.h, centreBiasFraction)); }); }); diff --git a/frontend/src/routes/stammbaum/+page.svelte b/frontend/src/routes/stammbaum/+page.svelte index f26f1fce..a5b59cce 100644 --- a/frontend/src/routes/stammbaum/+page.svelte +++ b/frontend/src/routes/stammbaum/+page.svelte @@ -54,6 +54,31 @@ async function centreOnSelected() { 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(); @@ -140,10 +165,11 @@ $effect(() => { 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={(id) => (selectedId = id)} + onSelect={selectPerson} />