Files
feuerwerk/src/editor/graph/transitions.ts
T

222 lines
8.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 }];
}