From bb2a89da58581e8a114ff871482697ab594850dd Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 29 May 2026 18:00:17 +0200 Subject: [PATCH] feat(stammbaum): land a fresh visit at readable z=3, keep fit-to-screen at z=1 (#692) A fresh visit (no URL state) now opens at INITIAL_VIEW (z=3) so node tiles and generation labels are legible on arrival; the fit-to-screen control still zooms out to the whole tree (DEFAULT_VIEW, z=1). Shared links with ?z still win. Co-Authored-By: Claude Opus 4.8 --- frontend/src/lib/person/genealogy/panZoom.ts | 10 ++++++++- frontend/src/routes/stammbaum/+page.server.ts | 21 +++++++++++-------- .../src/routes/stammbaum/page.server.test.ts | 6 +++--- 3 files changed, 24 insertions(+), 13 deletions(-) diff --git a/frontend/src/lib/person/genealogy/panZoom.ts b/frontend/src/lib/person/genealogy/panZoom.ts index 21296590..0d0d5500 100644 --- a/frontend/src/lib/person/genealogy/panZoom.ts +++ b/frontend/src/lib/person/genealogy/panZoom.ts @@ -28,9 +28,17 @@ export const ZOOM_STEP_KB = 0.1; */ export type PanZoomState = { x: number; y: number; z: number }; -/** Fit-to-screen / initial view (US-PAN-004). */ +/** Fit-to-screen target — frames the whole tree at z=1 (US-PAN-004). */ export const DEFAULT_VIEW: PanZoomState = { x: 0, y: 0, z: DEFAULT_ZOOM }; +/** + * Landing zoom for a fresh visit (no URL state). Higher than fit so node tiles + * and generation labels are legible on arrival; the fit-to-screen control + * (DEFAULT_VIEW, z=1) zooms back out to the whole tree. + */ +export const INITIAL_ZOOM = 3; +export const INITIAL_VIEW: PanZoomState = { x: 0, y: 0, z: INITIAL_ZOOM }; + /** Clamp a zoom factor into the supported range. */ export function clampZoom(z: number): number { return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z)); diff --git a/frontend/src/routes/stammbaum/+page.server.ts b/frontend/src/routes/stammbaum/+page.server.ts index f9d5a2a5..8c4fbff6 100644 --- a/frontend/src/routes/stammbaum/+page.server.ts +++ b/frontend/src/routes/stammbaum/+page.server.ts @@ -1,7 +1,7 @@ import { error, redirect } from '@sveltejs/kit'; import { createApiClient, extractErrorCode } from '$lib/shared/api.server'; import { getErrorMessage } from '$lib/shared/errors'; -import { parsePanZoomParams } from '$lib/person/genealogy/panZoom'; +import { parsePanZoomParams, INITIAL_VIEW } from '$lib/person/genealogy/panZoom'; export async function load({ fetch, url }) { const api = createApiClient(fetch); @@ -13,14 +13,17 @@ export async function load({ fetch, url }) { throw error(result.response.status, getErrorMessage(extractErrorCode(result.error))); } - // Sanitise the shareable pan/zoom params server-side so a crafted link - // (?z=Infinity, ?cx=NaN) degrades to a safe view before reaching layout - // geometry (Nora #692). - const initialView = parsePanZoomParams({ - cx: url.searchParams.get('cx'), - cy: url.searchParams.get('cy'), - z: url.searchParams.get('z') - }); + // A fresh visit (no shared pan/zoom state) lands at the readable INITIAL_VIEW + // (z=3). When a link carries a zoom param we honour it, sanitising server-side + // so a crafted link (?z=Infinity, ?cx=NaN) degrades to a safe view before + // reaching layout geometry (Nora #692). + const initialView = url.searchParams.has('z') + ? parsePanZoomParams({ + cx: url.searchParams.get('cx'), + cy: url.searchParams.get('cy'), + z: url.searchParams.get('z') + }) + : INITIAL_VIEW; const network = result.data!; return { nodes: network.nodes ?? [], edges: network.edges ?? [], initialView }; diff --git a/frontend/src/routes/stammbaum/page.server.test.ts b/frontend/src/routes/stammbaum/page.server.test.ts index f755ad14..786ede67 100644 --- a/frontend/src/routes/stammbaum/page.server.test.ts +++ b/frontend/src/routes/stammbaum/page.server.test.ts @@ -6,7 +6,7 @@ vi.mock('$lib/shared/api.server', () => ({ })); import { createApiClient } from '$lib/shared/api.server'; -import { DEFAULT_VIEW, MAX_ZOOM } from '$lib/person/genealogy/panZoom'; +import { DEFAULT_VIEW, INITIAL_VIEW, MAX_ZOOM } from '$lib/person/genealogy/panZoom'; beforeEach(() => vi.clearAllMocks()); @@ -26,11 +26,11 @@ function loadEvent(query = '') { } describe('/stammbaum +page.server load — initialView', () => { - it('returns DEFAULT_VIEW when no pan/zoom params are present', async () => { + it('returns the readable INITIAL_VIEW (z=3) for a fresh visit with no params', async () => { mockNetwork(); const { load } = await import('./+page.server'); const result = await load(loadEvent() as never); - expect(result.initialView).toEqual(DEFAULT_VIEW); + expect(result.initialView).toEqual(INITIAL_VIEW); }); it('parses and returns valid ?cx&cy&z params', async () => {