feat(stammbaum): sync view to shareable ?cx&cy&z URL (#692)

A view-keyed effect mirrors pan/zoom into the URL via replaceState (URL read
untracked to avoid a feedback loop). State survives panel open/close
(US-PANEL-002 AC1) and a shared link reproduces the view (AC2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-29 17:02:47 +02:00
parent 8d29bb10e2
commit 289c3bbfb5
3 changed files with 42 additions and 1 deletions

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, vi } from 'vitest';
import { render } from 'vitest-browser-svelte';
import StammbaumTree from './StammbaumTree.svelte';
import type { PanZoomState } from './panZoom';
const ID_A = '00000000-0000-0000-0000-000000000001';
const ID_B = '00000000-0000-0000-0000-000000000002';
@@ -512,7 +513,7 @@ describe('StammbaumTree node rendering branches', () => {
});
describe('StammbaumTree keyboard pan/zoom (#692)', () => {
const renderTree = (onPanZoom: ReturnType<typeof vi.fn>) =>
const renderTree = (onPanZoom: (state: PanZoomState) => void) =>
render(StammbaumTree, {
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
edges: [],

View File

@@ -1,6 +1,8 @@
<script lang="ts">
import { untrack } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import { page } from '$app/state';
import { replaceState } from '$app/navigation';
import StammbaumTree from '$lib/person/genealogy/StammbaumTree.svelte';
import StammbaumSidePanel from '$lib/person/genealogy/StammbaumSidePanel.svelte';
import StammbaumControls from '$lib/person/genealogy/StammbaumControls.svelte';
@@ -8,6 +10,7 @@ import {
type PanZoomState,
DEFAULT_VIEW,
clampZoom,
serializePanZoomParams,
ZOOM_STEP_KB
} from '$lib/person/genealogy/panZoom';
import { animateView, prefersReducedMotion } from '$lib/person/genealogy/animateView';
@@ -45,6 +48,21 @@ function fitToScreen() {
reducedMotion: prefersReducedMotion()
});
}
// Mirror the view into shareable ?cx&cy&z params (OQ-003). Only `view` is a
// tracked dependency; the current URL is read untracked so the replaceState
// write does not retrigger the effect. The state thus survives panel open/close
// (US-PANEL-002 AC1) and a shared link reproduces it (AC2).
$effect(() => {
const { cx, cy, z } = serializePanZoomParams(view);
untrack(() => {
const url = new URL(window.location.href);
url.searchParams.set('cx', cx);
url.searchParams.set('cy', cy);
url.searchParams.set('z', z);
replaceState(url, page.state);
});
});
</script>
<!-- 4.25rem = 4rem navbar (h-16) + 0.25rem accent strip (h-1).

View File

@@ -5,6 +5,7 @@ import { DEFAULT_VIEW } from '$lib/person/genealogy/panZoom';
const mockPage = {
url: new URL('http://localhost/stammbaum'),
state: {},
data: { canWrite: false } as { canWrite: boolean }
};
@@ -14,6 +15,11 @@ vi.mock('$app/state', () => ({
}
}));
const replaceState = vi.fn();
vi.mock('$app/navigation', () => ({
replaceState: (...args: unknown[]) => replaceState(...args)
}));
afterEach(cleanup);
async function loadComponent() {
@@ -94,4 +100,20 @@ describe('stammbaum page', () => {
// Just verify that repeated clicks don't throw — branch coverage
await expect.element(zoomOut).toBeVisible();
});
it('mirrors the view into ?cx&cy&z when zoomed (US-PANEL-002 AC2)', async () => {
mockPage.url = new URL('http://localhost/stammbaum');
replaceState.mockClear();
const Stammbaum = await loadComponent();
render(Stammbaum, {
props: { data: { nodes: sampleNodes, edges: [], initialView: DEFAULT_VIEW } }
});
await page.getByRole('button', { name: /vergrößern/i }).click();
await vi.waitFor(() => expect(replaceState).toHaveBeenCalled());
const url = replaceState.mock.calls.at(-1)![0] as URL;
expect(url.searchParams.get('z')).toBeTruthy();
expect(url.searchParams.has('cx')).toBe(true);
expect(url.searchParams.has('cy')).toBe(true);
});
});