/** * Geometry of a transition: the straight arrow drawn between two states. It runs border to border * rather than pin to pin, because a diagonal line into a port would pass under the target node and * take its arrowhead with it – the usual curve never does, since it swings in horizontally. */ export interface Point { x: number; y: number; } export interface Rect { x: number; y: number; w: number; h: number; } /** Length of an arrowhead along the line, and half its width across. */ export const ARROW = 10; export const ARROW_HALF_WIDTH = 4.5; /** Air between a node's border and the end of the arrow touching it. */ export const BORDER_GAP = 7; /** How far apart two transitions between the same pair of states are pushed. */ export const PARALLEL_SEP = 14; export function centreOf(box: Rect): Point { return { x: box.x + box.w / 2, y: box.y + box.h / 2 }; } /** Null when the two points coincide: there is no direction to draw along. */ export function direction(a: Point, b: Point): Point | null { const length = Math.hypot(b.x - a.x, b.y - a.y); return length === 0 ? null : { x: (b.x - a.x) / length, y: (b.y - a.y) / length }; } /** Where a ray leaving the box's centre crosses its border. */ export function boxExit(box: Rect, dir: Point): Point { const centre = centreOf(box); const tx = dir.x === 0 ? Infinity : box.w / 2 / Math.abs(dir.x); const ty = dir.y === 0 ? Infinity : box.h / 2 / Math.abs(dir.y); const t = Math.min(tx, ty); return { x: centre.x + dir.x * t, y: centre.y + dir.y * t }; } /** The normal of a direction, pointing to its right. */ function normal(dir: Point): Point { return { x: -dir.y, y: dir.x }; } function shift(point: Point, out: Point, along: number, n: Point, across: number): Point { return { x: point.x + out.x * along + n.x * across, y: point.y + out.y * along + n.y * across }; } /** * The two points a transition between these nodes runs between: each a border crossing, pushed * `BORDER_GAP` clear of the node and `offset` sideways. Null when the boxes share a centre. */ export function transitionEnds(from: Rect, to: Rect, offset = 0): { start: Point; end: Point } | null { const dir = direction(centreOf(from), centreOf(to)); if (!dir) return null; const back = { x: -dir.x, y: -dir.y }; const n = normal(dir); return { start: shift(boxExit(from, dir), dir, BORDER_GAP, n, offset), end: shift(boxExit(to, back), back, BORDER_GAP, n, offset), }; } /** How far apart a self-loop's two ends sit on the node's top edge, and how high it rises. */ const LOOP_SPREAD = 14; const LOOP_RISE = 44; /** What each further loop on the same state adds, so they nest with room for a name between them. */ const LOOP_SPREAD_STEP = 6; const LOOP_RISE_STEP = 24; /** A transition from a state back into itself: one cubic curve over the node's top edge. */ export interface Loop { start: Point; c1: Point; c2: Point; end: Point; } /** * The loop over the middle of the box, left end out and right end in, both `BORDER_GAP` clear of * the border like every other transition. The control points lean outwards, so the curve bulges * wider than its feet and reads as a loop rather than a hump. `ring` counts the loops already on the * state: each further one sits outside the last. */ export function selfLoop(box: Rect, ring = 0): Loop { const x = box.x + box.w / 2; const y = box.y - BORDER_GAP; const spread = LOOP_SPREAD + ring * LOOP_SPREAD_STEP; const rise = LOOP_RISE + ring * LOOP_RISE_STEP; return { start: { x: x - spread, y }, c1: { x: x - spread * 2.5, y: y - rise }, c2: { x: x + spread * 2.5, y: y - rise }, end: { x: x + spread, y }, }; } /** Which ring each loop takes: the loops on one state count up from 0 in edge order. */ export function loopRings(links: readonly TransitionLink[]): Map { const count = new Map(); const rings = new Map(); for (const link of links) { if (link.from !== link.to) continue; const ring = count.get(link.from) ?? 0; rings.set(link.id, ring); count.set(link.from, ring + 1); } return rings; } /** The point at `t` along the loop, 0 at its start and 1 at the arrow's tip. */ export function loopPoint(loop: Loop, t: number): Point { const u = 1 - t; const [a, b, c, d] = [u * u * u, 3 * u * u * t, 3 * u * t * t, t * t * t]; return { x: a * loop.start.x + b * loop.c1.x + c * loop.c2.x + d * loop.end.x, y: a * loop.start.y + b * loop.c1.y + c * loop.c2.y + d * loop.end.y, }; } /** How far a name may slide along its arrow looking for room, and in which steps. */ const LABEL_REACH = 120; const LABEL_STEP = 2; function overlaps(a: Rect, b: Rect): boolean { return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h; } /** * Where a transition's name goes, as the centre of a `w` by `h` plate: at `centre` where that plate * overlaps none already placed, else at the nearest free spot along `dir`, its own arrow – arrows * side by side are closer than a name is wide, and a name slid sideways would read as the * neighbour's. Stays at `centre` when nothing within reach is free. */ export function freeLabelSpot( centre: Point, w: number, h: number, dir: Point, placed: readonly Rect[], ): Point { for (let s = 0; s <= LABEL_REACH; s += LABEL_STEP) { for (const sign of s === 0 ? [1] : [1, -1]) { const spot = { x: centre.x + dir.x * s * sign, y: centre.y + dir.y * s * sign }; if (!placed.some((p) => overlaps(p, { x: spot.x - w / 2, y: spot.y - h / 2, w, h }))) return spot; } } return centre; } /** One transition as the offsets care about it: which states it joins, under which edge id. */ export interface TransitionLink { id: string; from: string; to: string; } /** * How far each transition is pushed sideways, so several between the same two states lie next to * each other instead of on top. The fan is computed in the canonical direction of the pair, and a * transition running the other way takes the opposite sign – which is what puts a there-and-back * pair on either side of the line rather than both on the same one. */ export function transitionOffsets(links: readonly TransitionLink[]): Map { const groups = new Map(); for (const link of links) { const [a, b] = link.from < link.to ? [link.from, link.to] : [link.to, link.from]; const key = `${a}\u0000${b}`; const group = groups.get(key); if (group) group.push(link); else groups.set(key, [link]); } const offsets = new Map(); for (const group of groups.values()) { for (const [i, link] of group.entries()) { const canonical = (i - (group.length - 1) / 2) * PARALLEL_SEP; offsets.set(link.id, link.from <= link.to ? canonical : -canonical); } } return offsets; } /** * The line drawn while a transition is being dragged: out of the source's border towards the * pointer, or border to border once the drag has found another state. */ export function transitionPreview( source: Rect, target: Rect | null, pointer: Point, ): { start: Point; end: Point; } { if (target) return transitionEnds(source, target) ?? { start: centreOf(source), end: pointer }; const dir = direction(centreOf(source), pointer); if (!dir) return { start: centreOf(source), end: pointer }; return { start: shift(boxExit(source, dir), dir, BORDER_GAP, normal(dir), 0), end: pointer }; } /** Where the line stops: at the head's base, so the stroke does not blunt its tip. */ export function arrowShaft(start: Point, end: Point): Point { const u = direction(start, end); return u ? { x: end.x - u.x * ARROW, y: end.y - u.y * ARROW } : end; } /** The three corners of the arrowhead at the target end, tip first. */ export function arrowCorners(start: Point, end: Point): [Point, Point, Point] | null { const u = direction(start, end); if (!u) return null; const base = arrowShaft(start, end); const nx = -u.y * ARROW_HALF_WIDTH; const ny = u.x * ARROW_HALF_WIDTH; return [end, { x: base.x + nx, y: base.y + ny }, { x: base.x - nx, y: base.y - ny }]; }