// Mark positions on a drawing page. Every position is a fraction of the page (0..1), so a mark stays on its // joint whatever the zoom or the bitmap size. A port of the old build's clamp01, bpos, arrowRot and toNorm. /** The viewer transform: the stage is translated by (x, y) and scaled by z. w and h are the page size at z = 1. */ export interface View { x: number; y: number; z: number; w: number; h: number; } /** A point on the page, as fractions. */ export interface Frac { fx: number; fy: number; } /** Marks keep a small margin from the page edge. */ export function clamp01(v: number): number { return Math.max(0.01, Math.min(0.99, v)); } /** Plain 0..1 clamp, for drops and table corners. */ export function clamp(v: number, lo = 0, hi = 1): number { return Math.max(lo, Math.min(hi, v)); } export interface Placed { fx: number | null; fy: number | null; bfx?: number | null | undefined; bfy?: number | null | undefined; } /** * Where the label (bubble) sits. A stored bfx, bfy wins. Otherwise the old default: a little right of the arrow * point (left near the right edge) and a little above it (below near the top edge). */ export function bpos(m: Placed): { bfx: number; bfy: number } { const fx = m.fx ?? 0.5; const fy = m.fy ?? 0.5; return { bfx: m.bfx ?? clamp01(fx + (fx > 0.85 ? -0.05 : 0.05)), bfy: m.bfy ?? clamp01(fy + (fy < 0.12 ? 0.07 : -0.07)), }; } /** A client point to page fractions. `frame` is the viewer's bounding box. Not clamped. */ export function toFraction(clientX: number, clientY: number, frame: { left: number; top: number }, view: View): Frac { if (!view.w || !view.h || !view.z) return { fx: NaN, fy: NaN }; return { fx: (clientX - frame.left - view.x) / view.z / view.w, fy: (clientY - frame.top - view.y) / view.z / view.h, }; } /** Page fractions to pixels inside the viewer frame. The inverse of toFraction. */ export function toPixels(fx: number, fy: number, view: View): { px: number; py: number } { return { px: view.x + fx * view.w * view.z, py: view.y + fy * view.h * view.z }; } /** True when the fraction lies on the page. */ export function onPage(f: Frac): boolean { return f.fx >= 0 && f.fx <= 1 && f.fy >= 0 && f.fy <= 1; } /** * The arrowhead's rotation in degrees: it points from the bubble (bx, by) to the anchor (ax, ay). * The fractions are scaled by the page aspect (width / height) so the angle is right on a non-square page. */ export function arrowRotation(ax: number, ay: number, bx: number, by: number, aspect: number): number { return (Math.atan2(ay - by, (ax - bx) * (aspect || 1.5)) * 180) / Math.PI; } /** The weld circle radius in page pixels for the printed raster. */ export function markRadius(width: number): number { return Math.max(18, width / 150); } /** The fit transform: the whole page in the frame, centred. */ export function fitView(frameW: number, frameH: number, w: number, h: number): View { const z = Math.min(frameW / w, frameH / h) || 1; return { x: (frameW - w * z) / 2, y: (frameH - h * z) / 2, z, w, h }; } /** Zoom by factor f around the frame point (cx, cy), clamped between minZ and maxZ. */ export function zoomAt(view: View, f: number, cx: number, cy: number, minZ: number, maxZ = 6): View { const nz = Math.max(minZ, Math.min(maxZ, view.z * f)); const k = nz / view.z; return { ...view, x: cx - (cx - view.x) * k, y: cy - (cy - view.y) * k, z: nz }; }