/** * Pure geometry for the canvas v1 (T-167). Zero deps, no I/O, deterministic. * * World coords are the canvas coordinate space (unbounded, y-down, matching * the SVG viewBox convention). Screen mapping = uniform scale + translation. */ import { ANCHORS, type Anchor, type CanvasElement, type ConnectorElement } from "./schema.js"; export interface Rect { x: number; y: number; w: number; h: number; } /** Union of two rects (degenerate rects with w/h = 0 are points). */ export function unionRect(a: Rect, b: Rect): Rect { const x = Math.min(a.x, b.x); const y = Math.min(a.y, b.y); const x2 = Math.max(a.x + a.w, b.x + b.w); const y2 = Math.max(a.y + a.h, b.y + b.h); return { x, y, w: x2 - x, h: y2 - y }; } export function unionAll(rects: Rect[]): Rect { return rects.reduce((acc, r) => unionRect(acc, r), { x: 0, y: 0, w: 0, h: 0 }); } /** Element bounds in world space. */ export function elementBounds(el: CanvasElement): Rect { return { x: el.x, y: el.y, w: el.w, h: el.h }; } /** Point-in-rect (edge-inclusive) in WORLD coords. */ export function pointInRect(px: number, py: number, r: Rect): boolean { return px >= r.x && px <= r.x + r.w && py >= r.y && py <= r.y + r.h; } const EPS = 1e-9; function distToSegment(px: number, py: number, ax: number, ay: number, bx: number, by: number): number { const dx = bx - ax; const dy = by - ay; const len2 = dx * dx + dy * dy; let t = len2 === 0 ? 0 : ((px - ax) * dx + (py - ay) * dy) / len2; t = Math.max(0, Math.min(1, t)); const cx = ax + t * dx; const cy = ay + t * dy; return Math.hypot(px - cx, py - cy); } /** * Hit-test an element at a world point. Shapes/sticky/text = bounds; * freehand = polyline proximity (≤ tolerance); group = bounds; connector = * proximity to its RATED path (needs the element map to resolve anchors). */ export function hitTest(el: CanvasElement, px: number, py: number, tolerance = 6, elements?: Record): boolean { switch (el.type) { case "sticky": case "text": case "rect": case "ellipse": case "group": return pointInRect(px, py, elementBounds(el)); case "freehand": { const pts = el.points; for (let i = 0; i + 3 < pts.length; i += 2) { if (distToSegment(px, py, pts[i]!, pts[i + 1]!, pts[i + 2]!, pts[i + 3]!) <= tolerance + el.strokeWidth / 2) return true; } return false; } case "connector": { if (!elements) return false; const a = elements[el.from.elementId]; const b = elements[el.to.elementId]; if (!a || !b) return false; const p1 = anchorPoint(a, el.from.anchor); const p2 = anchorPoint(b, el.to.anchor); if (el.kind === "straight") return distToSegment(px, py, p1.x, p1.y, p2.x, p2.y) <= tolerance; // orthogonal: test the 3 segments of the routed path const midX = p1.x + (p2.x - p1.x) / 2; const midY = p1.y + (p2.y - p1.y) / 2; const horizontal = Math.abs(p2.x - p1.x) >= Math.abs(p2.y - p1.y); const segs: [number, number, number, number][] = horizontal ? [ [p1.x, p1.y, midX, p1.y], [midX, p1.y, midX, p2.y], [midX, p2.y, p2.x, p2.y] ] : [ [p1.x, p1.y, p1.x, midY], [p1.x, midY, p2.x, midY], [p2.x, midY, p2.x, p2.y] ]; return segs.some(([ax, ay, bx, by]) => distToSegment(px, py, ax, ay, bx, by) <= tolerance); } } } /** Resolve the world point of an anchor on an element (4 anchors). */ export function anchorPoint(el: CanvasElement, anchor: Anchor): { x: number; y: number } { const cx = el.x + el.w / 2; const cy = el.y + el.h / 2; switch (anchor) { case "top": return { x: cx, y: el.y }; case "bottom": return { x: cx, y: el.y + el.h }; case "left": return { x: el.x, y: cy }; case "right": return { x: el.x + el.w, y: cy }; } } /** * The element anchor nearest to a world point (connector tool: pick where a * new connector attaches based on where the user released the drag). */ export function nearestAnchor(el: CanvasElement, p: { x: number; y: number }): Anchor { let best: Anchor = "top"; let bestD = Infinity; for (const a of ANCHORS) { const ap = anchorPoint(el, a); const d = (ap.x - p.x) ** 2 + (ap.y - p.y) ** 2; if (d < bestD) { bestD = d; best = a; } } return best; } /** * SVG path for a connector between two resolved world points. * - straight: M … L … * - orthogonal: rounded-ish right-angle routing — go along the dominant * axis first (horizontal when |dx| ≥ |dy|, else vertical), then the rest. */ export function connectorPath(from: { x: number; y: number }, to: { x: number; y: number }, kind: "straight" | "orthogonal"): string { if (kind === "straight") return `M ${from.x} ${from.y} L ${to.x} ${to.y}`; const dx = to.x - from.x; const dy = to.y - from.y; if (Math.abs(dx) >= Math.abs(dy)) { const midX = from.x + dx / 2; return `M ${from.x} ${from.y} L ${midX} ${from.y} L ${midX} ${to.y} L ${to.x} ${to.y}`; } const midY = from.y + dy / 2; return `M ${from.x} ${from.y} L ${from.x} ${midY} L ${to.x} ${midY} L ${to.x} ${to.y}`; } /** Arrowhead polygon (points, in world space) at `to`, pointing along `dir`. */ export function arrowHead(to: { x: number; y: number }, dir: { x: number; y: number }, size = 10): [number, number][] { const len = Math.hypot(dir.x, dir.y) || 1; const ux = dir.x / len; const uy = dir.y / len; const px = -uy; // perpendicular const py = ux; const bx = to.x - ux * size; const by = to.y - uy * size; return [ [to.x, to.y], [bx + px * size * 0.5, by + py * size * 0.5], [bx - px * size * 0.5, by - py * size * 0.5] ]; } /** * Ramer–Douglas–Peucker decimation of a freehand stroke (flat [x,y,…] pairs, * WORLD coords) to at most `tolerance` deviation. Keeps the endpoints. * O(n log n). Deterministic. */ export function decimatePoints(points: number[], tolerance: number): number[] { const n = points.length / 2; if (n <= 2) return points.slice(); const keep = new Array(n).fill(false); keep[0] = true; keep[n - 1] = true; const stack: [number, number][] = [[0, n - 1]]; while (stack.length > 0) { const [s, e] = stack.pop()!; let maxD = -1; let idx = -1; const ax = points[s * 2]!; const ay = points[s * 2 + 1]!; const bx = points[e * 2]!; const by = points[e * 2 + 1]!; for (let i = s + 1; i < e; i++) { const d = distToSegment(points[i * 2]!, points[i * 2 + 1]!, ax, ay, bx, by); if (d > maxD) { maxD = d; idx = i; } } if (maxD > tolerance && idx !== -1) { keep[idx] = true; stack.push([s, idx], [idx, e]); } } const out: number[] = []; for (let i = 0; i < n; i++) { if (keep[i]) out.push(points[i * 2]!, points[i * 2 + 1]!); } return out; } /** World ↔ screen (viewport) mapping. scale ≥ a small floor; no flips. */ export interface Viewport { scale: number; // ≥ 0.05 tx: number; // translation (screen px) ty: number; } export function worldToScreen(vp: Viewport, x: number, y: number): { x: number; y: number } { return { x: x * vp.scale + vp.tx, y: y * vp.scale + vp.ty }; } export function screenToWorld(vp: Viewport, x: number, y: number): { x: number; y: number } { return { x: (x - vp.tx) / vp.scale, y: (y - vp.ty) / vp.scale }; } /** Zoom toward a screen anchor point, clamped to [minScale, maxScale]. */ export function zoomAt(vp: Viewport, anchorScreenX: number, anchorScreenY: number, factor: number, minScale = 0.05, maxScale = 8): Viewport { const scale = Math.min(maxScale, Math.max(minScale, vp.scale * factor)); const world = screenToWorld(vp, anchorScreenX, anchorScreenY); return { scale, tx: anchorScreenX - world.x * scale, ty: anchorScreenY - world.y * scale }; } /** * The WORLD rect currently visible in a screen box of `width`×`height` under * viewport `vp`. Returns null for a degenerate/unknown box (0 or negative * width/height — e.g. jsdom, before layout) so the caller renders everything * (no culling) rather than culling to nothing. This is the basis of viewport * culling: only elements intersecting this rect are painted (the 2,000-object * perf gate — an infinite canvas with thousands of elements must not mount a * DOM node for every one of them). */ export function viewportToWorldRect(vp: Viewport, width: number, height: number): Rect | null { if (!(width > 0) || !(height > 0)) return null; const a = screenToWorld(vp, 0, 0); const b = screenToWorld(vp, width, height); return { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y), w: Math.abs(b.x - a.x), h: Math.abs(b.y - a.y) }; } /** Axis-aligned rect–rect intersection (edge-exclusive; a touching edge does * not count as intersecting — matches the visible-window semantics). */ export function rectsIntersect(a: Rect, b: Rect): boolean { return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y; } /** * The DISPLAY bounds used for culling. For a connector, the schema stores * x/y/w/h as a (possibly 0) path box — but its true extent is the union of * its two endpoint elements, so culling on the raw box would drop connectors * whose endpoints straddle the viewport. Every other type uses its own box * (freehand already stores the stroke box; group stores the union box). */ export function displayBounds(el: CanvasElement, elements: Record): Rect { if (el.type === "connector") { const from = elements[el.from.elementId]; const to = elements[el.to.elementId]; if (from && to) return unionRect(elementBounds(from), elementBounds(to)); return elementBounds(el); } return elementBounds(el); } /** * Apply a translation+scale transform about an origin to an element's * geometry (used by group transform; connectors move with their endpoints — * the op layer resolves that, this only transforms raw coordinates). */ export function transformElement(el: CanvasElement, dx: number, dy: number): CanvasElement { if (el.type === "freehand") { const points = el.points.slice(); for (let i = 0; i < points.length; i += 2) { points[i] = (points[i] ?? 0) + dx; points[i + 1] = (points[i + 1] ?? 0) + dy; } return { ...el, x: el.x + dx, y: el.y + dy, points }; } return { ...el, x: el.x + dx, y: el.y + dy }; } /** Convenience: is `a` a connector (type guard for the renderer). */ export function isConnector(el: CanvasElement): el is ConnectorElement { return el.type === "connector"; }