132 lines
4.6 KiB
TypeScript
132 lines
4.6 KiB
TypeScript
/**
|
|
* 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
|
|
};
|
|
}
|