/** * Pan/zoom geometry for the Stammbaum canvas (#692). * * The Stammbaum renders zoom by deriving the SVG `viewBox` rather than applying * a CSS transform (see `StammbaumTree.svelte`). This module is the single source * of truth for the zoom bounds, the view-state shape, and every pure geometry * helper used by the gesture action, the URL serialiser, and the page. Keeping * the math here (and free of DOM access) makes it unit-testable in the node * project. See ADR-026 for why this is custom rather than a third-party library. */ /** Resolved zoom bounds (OQ-001). */ export const MIN_ZOOM = 0.25; export const MAX_ZOOM = 3.0; export const DEFAULT_ZOOM = 1; /** Minimum zoom a recentre will snap up to so the focal node's text is legible (OQ-005). */ export const LEGIBLE_ZOOM = 1; /** * The canvas view state. `x`/`y` are pan offsets applied to the viewBox centre * (SVG user units); `z` is the zoom factor. The default `{0, 0, 1}` frames the * whole tree (fit-to-screen) because the base viewBox already encloses the * layout bounding box at z=1. */ export type PanZoomState = { x: number; y: number; z: number }; /** Fit-to-screen / initial view (US-PAN-004). */ export const DEFAULT_VIEW: PanZoomState = { x: 0, y: 0, z: DEFAULT_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)); } /** Parse a raw value to a finite number, or return `fallback` for NaN/Infinity/absent. */ function finiteOr(raw: string | null | undefined, fallback: number): number { if (raw == null) return fallback; const n = Number(raw); return Number.isFinite(n) ? n : fallback; } /** * Parse URL-supplied pan/zoom params into a safe {@link PanZoomState} (OQ-003). * * Every axis is sanitised independently: `Infinity`, `NaN`, overflow (`1e500`), * and absent values degrade to the default for that axis, and the zoom is * clamped into [MIN_ZOOM, MAX_ZOOM]. This guards against a crafted shared link * (`?z=Infinity`, `?cx=NaN`) rendering the SVG blank — see Nora's review (#692). */ export function parsePanZoomParams(raw: { cx?: string | null; cy?: string | null; z?: string | null; }): PanZoomState { return { x: finiteOr(raw.cx, DEFAULT_VIEW.x), y: finiteOr(raw.cy, DEFAULT_VIEW.y), z: clampZoom(finiteOr(raw.z, DEFAULT_ZOOM)) }; } /** Serialise a view state into URL query params (the inverse of {@link parsePanZoomParams}). */ export function serializePanZoomParams(state: PanZoomState): { cx: string; cy: string; z: string } { return { cx: String(state.x), cy: String(state.y), z: String(state.z) }; } /** * Convert a pointer delta in CSS pixels into SVG user units, using the current * viewBox-to-element ratio per axis. This is the distance the pointer traversed * expressed in the tree's coordinate space; the gesture handler subtracts it * from the pan offset so the canvas follows the finger (US-PAN-001). */ export function screenDeltaToSvg( dxPx: number, dyPx: number, viewBoxW: number, viewBoxH: number, elPxW: number, elPxH: number ): { dx: number; dy: number } { return { dx: elPxW > 0 ? dxPx * (viewBoxW / elPxW) : 0, dy: elPxH > 0 ? dyPx * (viewBoxH / elPxH) : 0 }; } /** * Zoom to `newZoom` while keeping a given anchor point fixed on screen * (pinch-centroid zoom — US-PAN-002 AC1 / US-PAN-003 AC1). * * `anchorX`/`anchorY` are the anchor point expressed as an offset, in SVG units, * from the base viewBox centre. Because the viewBox width scales as `1/z`, the * ratio of old-to-new width is exactly `z / newZoom` independent of the base * size, so the new pan offset that preserves the anchor's screen fraction is * `anchor - (anchor - pan) * (z / newZoom)`. */ export function zoomAtPoint( state: PanZoomState, newZoom: number, anchorX: number, anchorY: number ): PanZoomState { const z = clampZoom(newZoom); const ratio = state.z / z; return { x: anchorX - (anchorX - state.x) * ratio, y: anchorY - (anchorY - state.y) * ratio, z }; } /** * 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: * `pan = nodeCentre - baseCentre`. When `autoZoom` is set, a zoomed-out view is * snapped up to {@link LEGIBLE_ZOOM} so the focal node's text is readable * (OQ-005); an already-legible zoom is preserved. */ export function recentreOn( nodeCentre: { x: number; y: number }, baseCentre: { x: number; y: number }, state: PanZoomState, autoZoom: boolean ): PanZoomState { return { x: nodeCentre.x - baseCentre.x, y: nodeCentre.y - baseCentre.y, z: autoZoom ? clampZoom(Math.max(state.z, LEGIBLE_ZOOM)) : state.z }; }