Files
learnharness/components/pan-zoom.ts
T

56 lines
2.5 KiB
TypeScript
Raw Normal View History

/**
* Shared pan/zoom viewport math — the "fit floor" semantics harvested from
* the plan popup (plan-graph rounds 2–4) so the session overlays (the plan
* focus, the mermaid focus) run the SAME geometry: fully zoomed out IS the
* fit (whichever axis binds), panning locks at the floor (nothing to move),
* wheel zooms to the cursor. Pure on purpose: the overlays are render-tested
* in static markup (no live pointer events), so the math lives here where
* tests can pin it (tests/pan-zoom.test.ts).
*/
/** Zoom ceiling for every surface that uses this math (raised 2×→4× for
* reading node titles closely, owner, plan-graph round 3). */
export const ZOOM_MAX = 4;
/** Viewport breathing room subtracted by the fit. */
export const FIT_PAD = 16;
/** The fit never zooms IN past this — a tiny diagram must not blow up. */
export const FIT_ZOOM_MAX = 1.5;
/** A pan/zoom viewport state (translate + scale; origin top-left). */
export interface Viewport {
zoom: number;
x: number;
y: number;
}
/** The fit zoom for a box in a viewport: never below a true fit (the old
* 0.15 floor could sit ABOVE the fit for wide graphs — the epsilon only
* guards degenerate viewports), never above FIT_ZOOM_MAX. Pure. */
export function fitZoomFor(box: { w: number; h: number }, viewport: { w: number; h: number }): number {
if (!viewport.w || !viewport.h || box.w === 0) return 1;
return Math.max(0.02, Math.min((viewport.w - FIT_PAD) / box.w, (viewport.h - FIT_PAD) / box.h, FIT_ZOOM_MAX));
}
/** Centers a box (at the given zoom) in the viewport. Pure. */
export function fitGraphView(box: { w: number; h: number }, viewport: { w: number; h: number }, zoom: number): Viewport {
return { zoom, x: (viewport.w - box.w * zoom) / 2, y: (viewport.h - box.h * zoom) / 2 };
}
/** Zoom by `factor` keeping `focus` (viewport coordinates) visually fixed,
* clamped to [floor, ZOOM_MAX]. At the floor the view re-fits + RE-CENTERS
* (the fit IS the floor — a locked view there has nothing to move). Pure. */
export function zoomedGraphView(
view: Viewport,
factor: number,
floor: number,
focus: { x: number; y: number },
box: { w: number; h: number },
viewport: { w: number; h: number },
): Viewport {
const zoom = Math.min(Math.max(view.zoom * factor, floor), ZOOM_MAX);
if (zoom === view.zoom) return view;
if (zoom <= floor) return fitGraphView(box, viewport, floor);
const k = zoom / view.zoom;
return { zoom, x: focus.x - (focus.x - view.x) * k, y: focus.y - (focus.y - view.y) * k };
}