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}
/>