/** Which link a click or a rubber band means, kept out of the canvas so tests can reach it (#289). */ import type { Band } from '../framing'; import { curvePoint } from './beads'; import { type Loop, loopPoint, type Point, type Rect } from './transitions'; /** One link as the last draw laid it out, in graph units. */ export type EdgePath = { edgeId: string } & ( | { kind: 'curve'; start: Point; end: Point } | { kind: 'line'; start: Point; end: Point } | { kind: 'loop'; loop: Loop } ); /** A transition's name plate: clicking the name means its arrow. */ export type EdgePlate = Rect & { edgeId: string }; /** Straight pieces a curve is tested as; at 24 the error stays far below a pixel of tolerance. */ const CURVE_STEPS = 24; export function pathPoints(path: EdgePath): Point[] { if (path.kind === 'line') return [path.start, path.end]; const at = (t: number) => path.kind === 'loop' ? loopPoint(path.loop, t) : curvePoint(path.start.x, path.start.y, path.end.x, path.end.y, t); return Array.from({ length: CURVE_STEPS + 1 }, (_, i) => at(i / CURVE_STEPS)); } /** The point halfway along a link – where a check clicks it. */ export function pathMiddle(path: EdgePath): Point { if (path.kind === 'line') return { x: (path.start.x + path.end.x) / 2, y: (path.start.y + path.end.y) / 2 }; return path.kind === 'loop' ? loopPoint(path.loop, 0.5) : curvePoint(path.start.x, path.start.y, path.end.x, path.end.y, 0.5); } function distanceToSegment(p: Point, a: Point, b: Point): number { const [dx, dy] = [b.x - a.x, b.y - a.y]; const length = dx * dx + dy * dy; const t = length === 0 ? 0 : Math.min(1, Math.max(0, ((p.x - a.x) * dx + (p.y - a.y) * dy) / length)); return Math.hypot(p.x - (a.x + dx * t), p.y - (a.y + dy * t)); } export function distanceToPath(path: EdgePath, point: Point): number { const points = pathPoints(path); let nearest = Infinity; for (let i = 1; i < points.length; i++) nearest = Math.min(nearest, distanceToSegment(point, points[i - 1]!, points[i]!)); return nearest; } /** * The link under a point, or null. A name plate wins over any line, and of several lines within * `tolerance` the one drawn last does – it is the one on top. */ export function edgeAt( paths: readonly EdgePath[], point: Point, tolerance: number, plates: readonly EdgePlate[] = [], ): string | null { for (let i = plates.length - 1; i >= 0; i--) { const p = plates[i]!; if (point.x >= p.x && point.x <= p.x + p.w && point.y >= p.y && point.y <= p.y + p.h) return p.edgeId; } for (let i = paths.length - 1; i >= 0; i--) if (distanceToPath(paths[i]!, point) <= tolerance) return paths[i]!.edgeId; return null; } /** Whether the segment a-b has any point inside the rectangle (Liang-Barsky). */ function segmentInRect(a: Point, b: Point, minX: number, minY: number, maxX: number, maxY: number): boolean { let [t0, t1] = [0, 1]; const [dx, dy] = [b.x - a.x, b.y - a.y]; for (const [p, q] of [ [-dx, a.x - minX], [dx, maxX - a.x], [-dy, a.y - minY], [dy, maxY - a.y], ] as const) { if (p === 0) { if (q < 0) return false; continue; } const r = q / p; if (p < 0) t0 = Math.max(t0, r); else t1 = Math.min(t1, r); if (t0 > t1) return false; } return true; } /** Ids of every link the band touches, in drawing order: a line is thin, so crossing it counts. */ export function edgesInBand(paths: readonly EdgePath[], band: Band): string[] { const [minX, maxX] = [Math.min(band.x1, band.x2), Math.max(band.x1, band.x2)]; const [minY, maxY] = [Math.min(band.y1, band.y2), Math.max(band.y1, band.y2)]; return paths .filter((path) => { const points = pathPoints(path); return points.some((p, i) => i > 0 && segmentInRect(points[i - 1]!, p, minX, minY, maxX, maxY)); }) .map((path) => path.edgeId); }