feat(stammbaum): recentre on a node via centreOnId prop (#692)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { untrack } 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 {
|
||||||
@@ -8,7 +9,12 @@ import {
|
|||||||
ROW_GAP,
|
ROW_GAP,
|
||||||
type Layout
|
type Layout
|
||||||
} from '$lib/person/genealogy/layout/buildLayout';
|
} from '$lib/person/genealogy/layout/buildLayout';
|
||||||
import { type PanZoomState, clampZoom, ZOOM_STEP_KB } from '$lib/person/genealogy/panZoom';
|
import {
|
||||||
|
type PanZoomState,
|
||||||
|
clampZoom,
|
||||||
|
recentreOn,
|
||||||
|
ZOOM_STEP_KB
|
||||||
|
} from '$lib/person/genealogy/panZoom';
|
||||||
import { panZoomGestures } from '$lib/person/genealogy/panZoomGestures';
|
import { panZoomGestures } from '$lib/person/genealogy/panZoomGestures';
|
||||||
|
|
||||||
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
|
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
|
||||||
@@ -21,6 +27,8 @@ interface Props {
|
|||||||
panZoom: PanZoomState;
|
panZoom: PanZoomState;
|
||||||
/** Emitted when the keyboard, a gesture, or a recentre changes the view. */
|
/** Emitted when the keyboard, a gesture, or a recentre changes the view. */
|
||||||
onPanZoom?: (state: PanZoomState) => void;
|
onPanZoom?: (state: PanZoomState) => void;
|
||||||
|
/** When set to a node id, the canvas recentres on that node (US-PAN-005). */
|
||||||
|
centreOnId?: string | null;
|
||||||
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
|
||||||
@@ -37,6 +45,7 @@ let {
|
|||||||
selectedId,
|
selectedId,
|
||||||
panZoom,
|
panZoom,
|
||||||
onPanZoom = () => {},
|
onPanZoom = () => {},
|
||||||
|
centreOnId = null,
|
||||||
onSelect,
|
onSelect,
|
||||||
showGutter
|
showGutter
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
@@ -130,6 +139,18 @@ function nodeCenter(id: string): { x: number; y: number } | null {
|
|||||||
return { x: p.x + NODE_W / 2, y: p.y + NODE_H / 2 };
|
return { x: p.x + NODE_W / 2, y: p.y + NODE_H / 2 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recentre when the parent sets centreOnId (US-PAN-005). Only centreOnId is a
|
||||||
|
// tracked dependency — the current view is read untracked so a normal pan does
|
||||||
|
// not retrigger a recentre.
|
||||||
|
$effect(() => {
|
||||||
|
const id = centreOnId;
|
||||||
|
if (!id) return;
|
||||||
|
untrack(() => {
|
||||||
|
const c = nodeCenter(id);
|
||||||
|
if (c) onPanZoom(recentreOn(c, baseCentre, panZoom, true));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
let focusedId = $state<string | null>(null);
|
let focusedId = $state<string | null>(null);
|
||||||
|
|
||||||
function handleNodeKey(event: KeyboardEvent, id: string) {
|
function handleNodeKey(event: KeyboardEvent, id: string) {
|
||||||
|
|||||||
@@ -578,6 +578,38 @@ describe('StammbaumTree keyboard pan/zoom (#692)', () => {
|
|||||||
expect(onSelect).not.toHaveBeenCalled();
|
expect(onSelect).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('recentres on a node when centreOnId is set, auto-zooming to legible (US-PAN-005)', async () => {
|
||||||
|
const onPanZoom = vi.fn();
|
||||||
|
render(StammbaumTree, {
|
||||||
|
nodes: [
|
||||||
|
{ id: ID_A, displayName: 'Anna', familyMember: true },
|
||||||
|
{ id: ID_B, displayName: 'Bertha', familyMember: true }
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{
|
||||||
|
id: 'sp',
|
||||||
|
personId: ID_A,
|
||||||
|
relatedPersonId: ID_B,
|
||||||
|
personDisplayName: 'Anna',
|
||||||
|
relatedPersonDisplayName: 'Bertha',
|
||||||
|
relationType: 'SPOUSE_OF'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
selectedId: null,
|
||||||
|
panZoom: { x: 0, y: 0, z: 0.5 },
|
||||||
|
centreOnId: ID_A,
|
||||||
|
onPanZoom,
|
||||||
|
onSelect: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(onPanZoom).toHaveBeenCalled());
|
||||||
|
const view = onPanZoom.mock.calls.at(-1)![0];
|
||||||
|
// Anna sits left of the two-node midpoint → pan x is negative.
|
||||||
|
expect(view.x).toBeLessThan(0);
|
||||||
|
// Zoomed out below legible → snapped up to 1.
|
||||||
|
expect(view.z).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('does not call onSelect for other keys', async () => {
|
it('does not call onSelect for other keys', async () => {
|
||||||
const onSelect = vi.fn();
|
const onSelect = vi.fn();
|
||||||
render(StammbaumTree, {
|
render(StammbaumTree, {
|
||||||
|
|||||||
Reference in New Issue
Block a user