feat(stammbaum): mobile read path — pan, zoom, fit-to-view (#692) #694
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { untrack } from 'svelte';
|
import { untrack, onMount } from 'svelte';
|
||||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
import {
|
import {
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
type PanZoomState,
|
type PanZoomState,
|
||||||
clampZoom,
|
clampZoom,
|
||||||
recentreOn,
|
recentreOn,
|
||||||
|
topLeftView,
|
||||||
ZOOM_STEP_KB
|
ZOOM_STEP_KB
|
||||||
} from '$lib/person/genealogy/panZoom';
|
} from '$lib/person/genealogy/panZoom';
|
||||||
import { panZoomGestures } from '$lib/person/genealogy/panZoomGestures';
|
import { panZoomGestures } from '$lib/person/genealogy/panZoomGestures';
|
||||||
@@ -32,6 +33,8 @@ interface Props {
|
|||||||
centreOnId?: string | null;
|
centreOnId?: string | null;
|
||||||
/** 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. */
|
||||||
|
anchorTopLeft?: boolean;
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
/**
|
/**
|
||||||
* Force-show or force-hide the generation gutter. When undefined, falls
|
* Force-show or force-hide the generation gutter. When undefined, falls
|
||||||
@@ -50,6 +53,7 @@ let {
|
|||||||
onPanZoom = () => {},
|
onPanZoom = () => {},
|
||||||
centreOnId = null,
|
centreOnId = null,
|
||||||
onActivity,
|
onActivity,
|
||||||
|
anchorTopLeft = false,
|
||||||
onSelect,
|
onSelect,
|
||||||
showGutter
|
showGutter
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
@@ -139,6 +143,12 @@ const railRows = $derived(
|
|||||||
.map((r) => ({ rank: r.rank, label: r.label, centerY: r.y + NODE_H / 2 }))
|
.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 viewBox = $derived.by(() => {
|
||||||
const w = baseDims.w / panZoom.z;
|
const w = baseDims.w / panZoom.z;
|
||||||
const h = baseDims.h / panZoom.z;
|
const h = baseDims.h / panZoom.z;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
stepInertia,
|
stepInertia,
|
||||||
recentreOn,
|
recentreOn,
|
||||||
clampPan,
|
clampPan,
|
||||||
|
topLeftView,
|
||||||
lerpView,
|
lerpView,
|
||||||
DEFAULT_VIEW,
|
DEFAULT_VIEW,
|
||||||
DEFAULT_ZOOM,
|
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', () => {
|
describe('lerpView', () => {
|
||||||
const from = { x: 0, y: 0, z: 1 };
|
const from = { x: 0, y: 0, z: 1 };
|
||||||
const to = { x: 100, y: -40, z: 2 };
|
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 };
|
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
|
* 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:
|
* centre is `baseCentre + pan` independent of zoom, centring is a pure pan:
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ $effect(() => {
|
|||||||
selectedId={selectedId}
|
selectedId={selectedId}
|
||||||
panZoom={view}
|
panZoom={view}
|
||||||
centreOnId={centreOnId}
|
centreOnId={centreOnId}
|
||||||
|
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={(id) => (selectedId = id)}
|
||||||
|
|||||||
Reference in New Issue
Block a user