feat(stammbaum): land a fresh visit on the tree's top-left corner (#692)
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m35s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m33s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m35s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m33s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
At z=3 a pan of {0,0} centres on the tree midpoint; a fresh visit (no shared
?z) now anchors the viewBox to the tree's top-left corner via topLeftView
(the negative clamp limit), emitted on mount. Shared links still win.
Verified live: lands at cx<0, cy<0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { untrack, onMount } from 'svelte';
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import {
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type PanZoomState,
|
||||
clampZoom,
|
||||
recentreOn,
|
||||
topLeftView,
|
||||
ZOOM_STEP_KB
|
||||
} from '$lib/person/genealogy/panZoom';
|
||||
import { panZoomGestures } from '$lib/person/genealogy/panZoomGestures';
|
||||
@@ -32,6 +33,8 @@ interface Props {
|
||||
centreOnId?: string | null;
|
||||
/** 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. */
|
||||
anchorTopLeft?: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
/**
|
||||
* Force-show or force-hide the generation gutter. When undefined, falls
|
||||
@@ -50,6 +53,7 @@ let {
|
||||
onPanZoom = () => {},
|
||||
centreOnId = null,
|
||||
onActivity,
|
||||
anchorTopLeft = false,
|
||||
onSelect,
|
||||
showGutter
|
||||
}: Props = $props();
|
||||
@@ -139,6 +143,12 @@ const railRows = $derived(
|
||||
.map((r) => ({ rank: r.rank, label: r.label, centerY: r.y + NODE_H / 2 }))
|
||||
);
|
||||
|
||||
// A fresh visit (no shared URL state) lands on the tree's top-left corner rather
|
||||
// than its centre (#692). Runs once after layout is available.
|
||||
onMount(() => {
|
||||
if (anchorTopLeft) onPanZoom(topLeftView(baseDims.w, baseDims.h, panZoom.z));
|
||||
});
|
||||
|
||||
const viewBox = $derived.by(() => {
|
||||
const w = baseDims.w / panZoom.z;
|
||||
const h = baseDims.h / panZoom.z;
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
stepInertia,
|
||||
recentreOn,
|
||||
clampPan,
|
||||
topLeftView,
|
||||
lerpView,
|
||||
DEFAULT_VIEW,
|
||||
DEFAULT_ZOOM,
|
||||
@@ -207,6 +208,18 @@ describe('clampPan', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('topLeftView', () => {
|
||||
it('aligns the viewBox top-left with the tree corner at the given zoom', () => {
|
||||
expect(topLeftView(1000, 800, 2)).toEqual({ x: -250, y: -200, z: 2 });
|
||||
});
|
||||
|
||||
it('sits exactly at the negative clamp limit (the corner is reachable)', () => {
|
||||
const v = topLeftView(1000, 800, 3);
|
||||
// clampPan must leave the corner view untouched (it is on the boundary).
|
||||
expect(clampPan(v, 1000, 800)).toEqual(v);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lerpView', () => {
|
||||
const from = { x: 0, y: 0, z: 1 };
|
||||
const to = { x: 100, y: -40, z: 2 };
|
||||
|
||||
@@ -195,6 +195,15 @@ export function clampPan(state: PanZoomState, baseW: number, baseH: number): Pan
|
||||
return { x: clampAxis(state.x, baseW), y: clampAxis(state.y, baseH), z: state.z };
|
||||
}
|
||||
|
||||
/**
|
||||
* The view whose viewBox top-left aligns with the tree's top-left corner at zoom
|
||||
* `z` (the landing view for a fresh visit). This is the pan at the most-negative
|
||||
* edge of the pannable range, i.e. `-clampPan` limit on each axis.
|
||||
*/
|
||||
export function topLeftView(baseW: number, baseH: number, z: number): PanZoomState {
|
||||
return { x: (baseW / z - baseW) / 2, y: (baseH / z - baseH) / 2, z };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pan so a node sits at the viewBox centre (US-PAN-005). Because the viewBox
|
||||
* centre is `baseCentre + pan` independent of zoom, centring is a pure pan:
|
||||
|
||||
@@ -140,6 +140,7 @@ $effect(() => {
|
||||
selectedId={selectedId}
|
||||
panZoom={view}
|
||||
centreOnId={centreOnId}
|
||||
anchorTopLeft={!page.url.searchParams.has('z')}
|
||||
onPanZoom={(v) => (view = v)}
|
||||
onActivity={() => (canvasActivity = true)}
|
||||
onSelect={(id) => (selectedId = id)}
|
||||
|
||||
Reference in New Issue
Block a user