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 <noreply@anthropic.com>
This commit is contained in:
@@ -28,9 +28,17 @@ export const ZOOM_STEP_KB = 0.1;
|
|||||||
*/
|
*/
|
||||||
export type PanZoomState = { x: number; y: number; z: number };
|
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 };
|
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. */
|
/** Clamp a zoom factor into the supported range. */
|
||||||
export function clampZoom(z: number): number {
|
export function clampZoom(z: number): number {
|
||||||
return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z));
|
return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z));
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { error, redirect } from '@sveltejs/kit';
|
import { error, redirect } from '@sveltejs/kit';
|
||||||
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
|
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
|
||||||
import { getErrorMessage } from '$lib/shared/errors';
|
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 }) {
|
export async function load({ fetch, url }) {
|
||||||
const api = createApiClient(fetch);
|
const api = createApiClient(fetch);
|
||||||
@@ -13,14 +13,17 @@ export async function load({ fetch, url }) {
|
|||||||
throw error(result.response.status, getErrorMessage(extractErrorCode(result.error)));
|
throw error(result.response.status, getErrorMessage(extractErrorCode(result.error)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sanitise the shareable pan/zoom params server-side so a crafted link
|
// A fresh visit (no shared pan/zoom state) lands at the readable INITIAL_VIEW
|
||||||
// (?z=Infinity, ?cx=NaN) degrades to a safe view before reaching layout
|
// (z=3). When a link carries a zoom param we honour it, sanitising server-side
|
||||||
// geometry (Nora #692).
|
// so a crafted link (?z=Infinity, ?cx=NaN) degrades to a safe view before
|
||||||
const initialView = parsePanZoomParams({
|
// reaching layout geometry (Nora #692).
|
||||||
cx: url.searchParams.get('cx'),
|
const initialView = url.searchParams.has('z')
|
||||||
cy: url.searchParams.get('cy'),
|
? parsePanZoomParams({
|
||||||
z: url.searchParams.get('z')
|
cx: url.searchParams.get('cx'),
|
||||||
});
|
cy: url.searchParams.get('cy'),
|
||||||
|
z: url.searchParams.get('z')
|
||||||
|
})
|
||||||
|
: INITIAL_VIEW;
|
||||||
|
|
||||||
const network = result.data!;
|
const network = result.data!;
|
||||||
return { nodes: network.nodes ?? [], edges: network.edges ?? [], initialView };
|
return { nodes: network.nodes ?? [], edges: network.edges ?? [], initialView };
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ vi.mock('$lib/shared/api.server', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { createApiClient } from '$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());
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
@@ -26,11 +26,11 @@ function loadEvent(query = '') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('/stammbaum +page.server load — initialView', () => {
|
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();
|
mockNetwork();
|
||||||
const { load } = await import('./+page.server');
|
const { load } = await import('./+page.server');
|
||||||
const result = await load(loadEvent() as never);
|
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 () => {
|
it('parses and returns valid ?cx&cy&z params', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user