/** Geometry of the graph view (rubber band, fit, drag), kept out of the canvas so tests can reach it. */ import { snapToStep } from '../ui/math'; export interface Point { x: number; y: number; } /** * The offset a drag adds to one node that started at `start`. A positive `step` lands that node on * the grid – applied per node, so a whole selection lines up instead of carrying its old spacing * along. `step <= 0` is "no snapping". A node nobody drags never moves either way. */ export function dragOffset(start: Point, dx: number, dy: number, step: number): Point { if (!(step > 0)) return { x: Math.round(dx), y: Math.round(dy) }; return { x: snapToStep(start.x + dx, step) - start.x, y: snapToStep(start.y + dy, step) - start.y, }; } export interface Box { id: string; x: number; y: number; w: number; h: number; } export interface Band { x1: number; y1: number; x2: number; y2: number; } export interface Framing { zoom: number; panX: number; panY: number; } /** * Ids of every box the band overlaps. Nodes are large rectangles, so – unlike the scene view, * which tests an object's position – touching counts: a band drawn across a node that visibly * caught it would otherwise leave it out. */ export function boxesInBand(boxes: readonly Box[], band: Band): string[] { const minX = Math.min(band.x1, band.x2); const maxX = Math.max(band.x1, band.x2); const minY = Math.min(band.y1, band.y2); const maxY = Math.max(band.y1, band.y2); return boxes .filter((b) => b.x <= maxX && b.x + b.w >= minX && b.y <= maxY && b.y + b.h >= minY) .map((b) => b.id); } export interface Bounds { minX: number; minY: number; maxX: number; maxY: number; } /** * The rectangle enclosing every box, or null for none. `seed` starts the sum off – the scene view * passes the origin, because that is where a container is grabbed when placed and a template drawn * far to one side would otherwise hide it. */ export function boundsOf(boxes: readonly Box[], seed?: Bounds): Bounds | null { let { minX, minY, maxX, maxY } = seed ?? { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity, }; for (const b of boxes) { minX = Math.min(minX, b.x); minY = Math.min(minY, b.y); maxX = Math.max(maxX, b.x + b.w); maxY = Math.max(maxY, b.y + b.h); } return Number.isFinite(minX) ? { minX, minY, maxX, maxY } : null; } /** * The view that shows every box at once, centred, with `margin` screen pixels to spare. * Never zooms past 1: a two-node graph blown up to fill the screen helps nobody. The scene view * deliberately does not share that cap, which is why only the bounds sum is common. */ export function frameBoxes( boxes: readonly Box[], view: { w: number; h: number }, margin: number, limits: { min: number; max: number }, ): Framing { const bounds = boundsOf(boxes); if (!bounds) return { zoom: 1, panX: view.w / 2, panY: view.h / 2 }; const { minX, minY, maxX, maxY } = bounds; const width = Math.max(maxX - minX, 1); const height = Math.max(maxY - minY, 1); const fitted = Math.min((view.w - margin * 2) / width, (view.h - margin * 2) / height, 1); const zoom = Math.min(Math.max(fitted, limits.min), limits.max); return { zoom, panX: view.w / 2 - ((minX + maxX) / 2) * zoom, panY: view.h / 2 - ((minY + maxY) / 2) * zoom, }; }