/** 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; } /** Grows a snapped size by whole steps until it clears `min`, so the far edge stays on the grid. */ function growToMin(size: number, min: number, step: number): number { return size >= min ? size : size + Math.ceil((min - size) / step) * step; } /** * The size a resize drag gives a box that started as `start`. With a positive `step` the corner * being dragged – the far one, since the near one stays put – lands on the grid, the same way * `dragOffset` puts a dragged node's own corner there. `step <= 0` is "no snapping". `min` is * honoured in whole steps, so a box that hits its minimum is still grid-aligned. */ export function resizeSize( start: Omit, dx: number, dy: number, step: number, min: { w: number; h: number }, ): { w: number; h: number } { const w = start.w + dx; const h = start.h + dy; if (!(step > 0)) return { w, h }; return { w: growToMin(snapToStep(start.x + w, step) - start.x, min.w, step), h: growToMin(snapToStep(start.y + h, step) - start.y, min.h, step), }; } export interface Band { x1: number; y1: number; x2: number; y2: number; } export interface Framing { zoom: number; panX: number; panY: number; } function bandBounds(band: Band): { minX: number; maxX: number; minY: number; maxY: number } { return { minX: Math.min(band.x1, band.x2), maxX: Math.max(band.x1, band.x2), minY: Math.min(band.y1, band.y2), maxY: Math.max(band.y1, band.y2), }; } /** * 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, maxX, minY, maxY } = bandBounds(band); return boxes .filter((b) => b.x <= maxX && b.x + b.w >= minX && b.y <= maxY && b.y + b.h >= minY) .map((b) => b.id); } /** * Ids of every box the band encloses whole. This is the rule for groups and comments, not the * touching one: a group wraps the nodes it holds, so it overlaps every band drawn over any of * them – and since a group drags its nodes along, catching it by accident would be felt at once. * Enclosing it is a deliberate gesture, and it is the one the eye reads as "this box, too". */ export function boxesWithinBand(boxes: readonly Box[], band: Band): string[] { const { minX, maxX, minY, maxY } = bandBounds(band); return boxes .filter((b) => b.x >= minX && b.x + b.w <= maxX && b.y >= minY && b.y + b.h <= maxY) .map((b) => b.id); } /** * Ids of the boxes a group carries: those whose centre lies inside it. The centre, not an overlap, * because a group and the node next to it always touch a little – and because the answer must be * the one the eye gives. Computed fresh whenever the group is grabbed, so nothing is remembered * about a node that has been dragged out. */ export function boxesCentredIn(boxes: readonly Box[], rect: Box | Omit): string[] { return boxes .filter((b) => { const cx = b.x + b.w / 2; const cy = b.y + b.h / 2; return cx >= rect.x && cx <= rect.x + rect.w && cy >= rect.y && cy <= rect.y + rect.h; }) .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, }; }