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>
This commit is contained in:
Marcel
2026-05-31 16:41:00 +02:00
parent 0a7b4fa265
commit 4583ee2c4d
2 changed files with 35 additions and 3 deletions

View File

@@ -13,7 +13,7 @@ import {
type PanZoomState, type PanZoomState,
clampZoom, clampZoom,
clampPan, clampPan,
recentreOn, recentreAbove,
cornerView, cornerView,
ZOOM_STEP_KB ZOOM_STEP_KB
} from '$lib/person/genealogy/panZoom'; } from '$lib/person/genealogy/panZoom';
@@ -35,6 +35,11 @@ interface Props {
onPanZoom?: (state: PanZoomState) => void; onPanZoom?: (state: PanZoomState) => void;
/** When set to a node id, the canvas recentres on that node (US-PAN-005). */ /** When set to a node id, the canvas recentres on that node (US-PAN-005). */
centreOnId?: string | null; 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). */ /** Fired on the first pointer interaction with the canvas (affordance dismiss). */
onActivity?: () => void; onActivity?: () => void;
/** When true, the initial view is anchored to the tree's top-left corner. */ /** When true, the initial view is anchored to the tree's top-left corner. */
@@ -56,6 +61,7 @@ let {
panZoom, panZoom,
onPanZoom = () => {}, onPanZoom = () => {},
centreOnId = null, centreOnId = null,
centreBiasFraction = 0,
onActivity, onActivity,
anchorTopLeft = false, anchorTopLeft = false,
onSelect, onSelect,
@@ -219,7 +225,7 @@ $effect(() => {
if (!id) return; if (!id) return;
untrack(() => { untrack(() => {
const c = nodeCenter(id); const c = nodeCenter(id);
if (c) onPanZoom(recentreOn(c, baseCentre, panZoom, true)); if (c) onPanZoom(recentreAbove(c, baseCentre, panZoom, baseDims.h, centreBiasFraction));
}); });
}); });

View File

@@ -54,6 +54,31 @@ async function centreOnSelected() {
centreOnId = null; 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 = () => {}; let cancelAnimation = () => {};
function fitToScreen() { function fitToScreen() {
cancelAnimation(); cancelAnimation();
@@ -140,10 +165,11 @@ $effect(() => {
selectedId={selectedId} selectedId={selectedId}
panZoom={view} panZoom={view}
centreOnId={centreOnId} centreOnId={centreOnId}
centreBiasFraction={isMobile ? MOBILE_CENTRE_BIAS : 0}
anchorTopLeft={!page.url.searchParams.has('z')} anchorTopLeft={!page.url.searchParams.has('z')}
onPanZoom={(v) => (view = v)} onPanZoom={(v) => (view = v)}
onActivity={() => (canvasActivity = true)} onActivity={() => (canvasActivity = true)}
onSelect={(id) => (selectedId = id)} onSelect={selectPerson}
/> />
<StammbaumAffordance dismissed={canvasActivity} /> <StammbaumAffordance dismissed={canvasActivity} />
<StammbaumControls onZoomIn={zoomIn} onZoomOut={zoomOut} onFit={fitToScreen} /> <StammbaumControls onZoomIn={zoomIn} onZoomOut={zoomOut} onFit={fitToScreen} />