222 lines
8.0 KiB
TypeScript
222 lines
8.0 KiB
TypeScript
/**
|
||
* 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<string, number> {
|
||
const count = new Map<string, number>();
|
||
const rings = new Map<string, number>();
|
||
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<string, number> {
|
||
const groups = new Map<string, TransitionLink[]>();
|
||
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<string, number>();
|
||
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 }];
|
||
}
|