Algorithms
Edge routes
The path of a line between two boxes, round them and not through: level and upright, or a curve; and what a pointer picks.
Round, not through
Drag a step about and watch its lines: out of the port squarely, round both boxes, a turn half-way between them. Drag one behind the step it feeds. Switch to curves.
Layout
Lines
Read orders
orders.csv
Sends to Filter rows in.Sends to Filter.Read customers
customers.csv
Sends to Join right rows in.Sends to Join.Filter
date ≥ 1 July
Receives from Read orders rows out.Sends to Join left rows in.Sends to Join. Receives from Read orders.Join
on customer
Receives from Filter rows out.Receives from Read customers rows out.Sends to Summarise rows in.Sends to Summarise. Receives from Filter, Read customers.Summarise
total by region
Receives from Join rows out.Sends to Write table rows in.Sends to Notify words in.Sends to Write table, Notify. Receives from Join.Write table
regions.parquet
Receives from Summarise rows out.Receives from Summarise.Notify
#sales
Receives from Summarise summary out.Receives from Summarise.Read orders rows out to Filter rows in
Filter rows out to Join left rows in
Read customers rows out to Join right rows in
Join rows out to Summarise rows in
Summarise rows out to Write table rows in
Summarise summary out to Notify words in, daily
Tab and Shift-Tab move between nodes, and through each node's ports; Alt with an arrow key moves to the nearest node that way. Enter on a port begins a connection: the arrow keys choose where it ends, Enter joins, Escape gives it up. After the nodes, the connections are a list: Space selects one, Enter moves its end, Delete removes it. Space selects a node, the arrow keys move what is selected, and Delete removes it.
selectedlocked7 steps, 6 lines
Code
"use client";
import { Radio, RadioGroup } from "@publictheta/ui/components/radio-group/radio-group.tsx";
import { rectUnion } from "@publictheta/ui/algorithms/geometry.ts";
import { Diagram, DiagramNode, DiagramPort } from "@publictheta/ui/components/diagram/diagram.tsx";
import type { DiagramRefusal } from "@publictheta/ui/components/diagram/diagram.tsx";
import {
ContextMenu,
ContextMenuTrigger,
MenuContent,
MenuItem,
} from "@publictheta/ui/components/menu/menu.tsx";
import type { Point } from "@publictheta/ui/algorithms/geometry.ts";
import { Button } from "@publictheta/ui/components/button/button.tsx";
import { isCommandKey } from "@publictheta/ui/lib/platform.ts";
import {
applyGraphChanges,
autoLayout,
connection,
findPort,
insertion,
invertGraphChanges,
keepLaidOut,
pinning,
} from "@publictheta/ui/primitives/graph.ts";
import type {
GraphChange,
GraphData,
GraphEdge,
GraphNode,
GraphPort,
LayoutOptions,
PortRef,
} from "@publictheta/ui/primitives/graph.ts";
import { createHistory } from "@publictheta/ui/primitives/history.ts";
import { IDENTITY, fit } from "@publictheta/ui/primitives/viewport.ts";
import type { Viewport } from "@publictheta/ui/primitives/viewport.ts";
import { useEffect, useRef, useState } from "react";
import { flushSync } from "react-dom";
import type { KeyboardEvent } from "react";
import { Controls } from "../../ui/controls.tsx";
interface Step extends GraphNode {
readonly title: string;
readonly detail: string;
}
type Change = GraphChange<Step>;
const WIDTH = 128;
const HEIGHT = 52;
// The least zoom at which a step's words can still be read.
const READABLE = 0.7;
const LAYOUT: LayoutOptions = { direction: "right", layerGap: 56, nodeGap: 20 };
// Rows flow from step to step; a summary is words, and only a step that takes words takes it.
// Each side of a join takes one line; any other input takes as many as are given it.
const rowsIn = (id = "in", limit?: number): GraphPort => ({
id,
side: "left",
direction: "in",
type: "rows",
limit,
});
const rowsOut: GraphPort = { id: "out", side: "right", direction: "out", type: "rows" };
const NAMES: Readonly<Record<string, string>> = {
in: "rows in",
left: "left rows in",
right: "right rows in",
out: "rows out",
summary: "summary out",
words: "words in",
};
const step = (
id: string,
title: string,
detail: string,
x: number,
y: number,
ports: readonly GraphPort[],
): Step => ({ id, title, detail, rect: { x, y, width: WIDTH, height: HEIGHT }, ports });
const STEPS: readonly Step[] = [
step("orders", "Read orders", "orders.csv", 0, 0, [rowsOut]),
step("customers", "Read customers", "customers.csv", 0, 104, [rowsOut]),
step("recent", "Filter", "date ≥ 1 July", 184, 0, [rowsIn(), rowsOut]),
step("join", "Join", "on customer", 368, 52, [rowsIn("left", 1), rowsIn("right", 1), rowsOut]),
step("total", "Summarise", "total by region", 552, 52, [
rowsIn(),
rowsOut,
{ id: "summary", side: "bottom", direction: "out", type: "words" },
]),
step("write", "Write table", "regions.parquet", 736, 0, [rowsIn()]),
step("notify", "Notify", "#sales", 736, 156, [
{ id: "words", side: "left", direction: "in", type: "words" },
]),
];
const join = (id: string, from: string, out: string, to: string, into: string): GraphEdge => ({
id,
source: { node: from, port: out },
target: { node: to, port: into },
});
const EDGES: readonly GraphEdge[] = [
join("e1", "orders", "out", "recent", "in"),
join("e2", "recent", "out", "join", "left"),
join("e3", "customers", "out", "join", "right"),
join("e4", "join", "out", "total", "in"),
join("e5", "total", "out", "write", "in"),
{ ...join("e6", "total", "summary", "notify", "words"), label: "daily" },
];
/** What can be added where a line is let go over nothing: each made with its port at that place. */
const OFFERS: ReadonlyArray<{
readonly name: string;
readonly make: (id: string, x: number, y: number) => Step;
}> = [
{ name: "Filter", make: (id, x, y) => step(id, "Filter", "rows → rows", x, y, [rowsIn(), rowsOut]) },
{ name: "Summarise", make: (id, x, y) => step(id, "Summarise", "rows → rows", x, y, [rowsIn(), rowsOut]) },
{ name: "Write table", make: (id, x, y) => step(id, "Write table", "table.parquet", x, y, [rowsIn()]) },
{ name: "Read file", make: (id, x, y) => step(id, "Read file", "file.csv", x, y, [rowsOut]) },
];
const REASONS: Readonly<Record<DiagramRefusal, string>> = {
nowhere: "Nothing here can take that.",
missing: "That is no longer there.",
"same-node": "A step does not feed itself.",
direction: "An output goes to an input, not to another output.",
type: "A summary is words: it does not go where rows are expected.",
duplicate: "Those two are already joined.",
full: "That side of the join already has its line. Move the line, or remove it first.",
cycle: "That would feed a step with its own result.",
};
const RULES = { acyclic: true } as const;
function renderStep(node: Step) {
return (
<DiagramNode
aria-label={node.title}
className="group/step cursor-default rounded-lg border border-border bg-surface px-3 py-2 shadow-sm data-pinned:border-dashed"
data-pinned={node.pinned === true ? "" : undefined}
>
<div className="truncate text-sm leading-tight font-medium text-fg-strong">{node.title}</div>
<div className="truncate text-xs text-fg-muted">{node.detail}</div>
{node.pinned === true && (
<svg
aria-hidden
viewBox="0 0 16 16"
className="absolute end-1.5 top-1.5 size-3 text-fg-muted"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M6 2h4l-.5 4.5L12 9H4l2.5-2.5L6 2ZM8 9v5" />
</svg>
)}
{node.ports.map((port) => (
<DiagramPort
key={port.id}
port={port.id}
// What carries words is square, what carries rows round: told apart without colour.
className={port.type === "words" ? "rounded-xs" : undefined}
/>
))}
</DiagramNode>
);
}
const portName = (_: Step, port: GraphPort) => NAMES[port.id] ?? port.id;
const nodeName = (node: Step) => node.title;
export default function DiagramPipeline() {
const [graph, setGraph] = useState<GraphData<Step>>({ nodes: STEPS, edges: EDGES });
const [selected, setSelected] = useState<readonly string[]>([]);
const [view, setView] = useState<Viewport>(IDENTITY);
const [layout, setLayout] = useState<"hand" | "kept">("hand");
const [line, setLine] = useState<"orthogonal" | "curve">("orthogonal");
const [refused, setRefused] = useState<string | null>(null);
const [history] = useState(() => createHistory<readonly Change[]>());
const [, drawAgain] = useState(0);
const canvas = useRef<HTMLDivElement>(null);
const made = useRef({ steps: 0, edges: 0 });
// A line let go over nothing: from where, and the place on the plane, while the menu of what to add there is open.
const [offer, setOffer] = useState<{
readonly from: PortRef;
readonly point: Point;
readonly id: string;
} | null>(null);
const offerAt = useRef<HTMLDivElement>(null);
const size = () => ({ width: canvas.current?.clientWidth ?? 0, height: canvas.current?.clientHeight ?? 0 });
const fitAll = (nodes: readonly Step[] = graph.nodes) => {
const bounds = rectUnion(nodes.map((node) => node.rect));
if (bounds === null) return;
setView(fit(bounds, size(), { padding: 28, limits: { minimum: 0.1, maximum: 1 } }));
};
// Begin with the whole pipeline in view, once the canvas has a size — unless that would make its
// words too small to read, as on a phone: then from its start, readable, with the rest a pan away.
const fitFirst = () => {
const bounds = rectUnion(STEPS.map((node) => node.rect))!;
const whole = fit(bounds, size(), { padding: 28, limits: { minimum: 0.1, maximum: 1 } });
if (whole.zoom >= READABLE) {
// oxlint-disable-next-line react/set-state-in-effect
setView(whole);
return;
}
const { height } = size();
setView({
x: bounds.x - 16 / READABLE,
y: bounds.y + bounds.height / 2 - height / READABLE / 2,
zoom: READABLE,
});
};
// oxlint-disable-next-line react/exhaustive-effect-dependencies -- once
useEffect(fitFirst, []);
/** Make changes: with the layout kept, everything not pinned takes its place again, in the same step of history. */
function change(changes: readonly Change[], kept = layout === "kept"): GraphData<Step> {
const all = kept ? keepLaidOut(graph, changes, LAYOUT) : changes;
if (all.length === 0) return graph;
const next = applyGraphChanges(graph, all);
history.push(all);
setGraph(next);
setRefused(null);
return next;
}
function travel(back: boolean): void {
const entry = back ? history.undo() : history.redo();
if (entry === undefined) return;
setGraph(applyGraphChanges(graph, back ? invertGraphChanges(entry) : entry));
setRefused(null);
drawAgain((count) => count + 1);
}
const makeEdge = (source: PortRef, target: PortRef): GraphEdge => {
made.current.edges += 1;
return { id: `line-${made.current.edges}`, source, target };
};
function addStep(): void {
made.current.steps += 1;
const after = graph.nodes.find((node) => node.id === selected[0] && selected.length === 1);
// Beside the step that is selected, or beneath them all: never on top of one.
const all = rectUnion(graph.nodes.map((node) => node.rect)) ?? { x: 0, y: 0, width: 0, height: -20 };
const added = newStep(
`step-${made.current.steps}`,
after === undefined ? all.x : after.rect.x + WIDTH + 56,
after === undefined ? all.y + all.height + 20 : after.rect.y + HEIGHT + 20,
);
const changes: Change[] = [insertion(graph, [added])];
// And joined to it, where it has an output free to give rows.
const from = after?.ports.find((port) => port.direction === "out" && port.type === "rows");
if (after !== undefined && from !== undefined) {
const joined = connection(
applyGraphChanges(graph, changes),
{ node: after.id, port: from.id },
{ node: added.id, port: "in" },
makeEdge,
RULES,
);
if (typeof joined !== "string") changes.push(joined);
}
const next = change(changes);
setSelected([added.id]);
fitAll(next.nodes);
}
/** The steps that could take a line from `from`, made at `point`, each with the change that adds and joins it. */
function offered(from: PortRef, point: Point, id: string): Array<{ name: string; changes: Change[] }> {
const port = findPort(graph, from)?.port;
if (port === undefined) return [];
return OFFERS.flatMap(({ name, make }) => {
// Its port on the side that faces the line: an input's on the left, at the place; an output's on the right.
const probe = make(id, 0, 0);
const into = probe.ports.find((each) => each.direction !== port.direction);
if (into === undefined) return [];
const x = into.side === "left" ? point.x : point.x - WIDTH;
const added = make(id, Math.round(x / 4) * 4, Math.round((point.y - HEIGHT / 2) / 4) * 4);
const inserted = insertion(graph, [added]);
const joined = connection(
applyGraphChanges(graph, [inserted]),
from,
{ node: id, port: into.id },
(source, target) => ({ id: "", source, target }),
RULES,
);
return typeof joined === "string" ? [] : [{ name, changes: [inserted] }];
});
}
function add(from: PortRef, point: Point, id: string, name: string): void {
const choice = offered(from, point, id).find((each) => each.name === name);
if (choice === undefined) return;
made.current.steps += 1;
const [inserted] = choice.changes;
const added = inserted!.kind === "insert" ? inserted!.nodes[0]!.item : undefined;
if (added === undefined) return;
const port = added.ports.find((each) => each.direction !== findPort(graph, from)?.port.direction)!;
const joined = connection(
applyGraphChanges(graph, [inserted!]),
from,
{ node: added.id, port: port.id },
makeEdge,
RULES,
);
if (typeof joined === "string") return;
change([inserted!, joined]);
setSelected([added.id]);
}
function onKeyDown(event: KeyboardEvent): void {
if (!isCommandKey(event) || event.key.toLowerCase() !== "z") return;
event.preventDefault();
travel(!event.shiftKey);
}
const chosen = graph.nodes.filter((node) => selected.includes(node.id));
const pinned = chosen.length > 0 && chosen.every((node) => node.pinned === true);
const lines = selected.length - chosen.length;
return (
<div className="flex w-full flex-col">
<Controls>
<RadioGroup
label="Layout"
size="small"
value={layout}
onValueChange={(next) => {
setLayout(next);
// Turned on, it begins at once.
if (next === "kept") fitAll(change([], true).nodes);
}}
>
<Radio value="hand">By hand</Radio>
<Radio value="kept">Keep laid out</Radio>
</RadioGroup>
<RadioGroup label="Lines" size="small" value={line} onValueChange={setLine}>
<Radio value="orthogonal">Square</Radio>
<Radio value="curve">Curved</Radio>
</RadioGroup>
</Controls>
<div className="mb-2 flex flex-wrap items-center gap-2">
<Button onClick={addStep}>Add step</Button>
<Button
onClick={() => {
const moves = autoLayout(graph, LAYOUT);
fitAll(moves.length === 0 ? graph.nodes : change([{ kind: "move", moves }], false).nodes);
}}
>
Lay out
</Button>
<Button
pressed={pinned}
disabled={chosen.length === 0}
onClick={() => {
const pin = pinning(
graph,
chosen.map((node) => node.id),
!pinned,
);
if (pin !== null) change([pin]);
}}
>
{pinned ? "Unpin" : "Pin"}
</Button>
<div className="ms-auto flex items-center gap-2">
<Button focusableWhenDisabled disabled={!history.canUndo()} onClick={() => travel(true)}>
Undo
</Button>
<Button focusableWhenDisabled disabled={!history.canRedo()} onClick={() => travel(false)}>
Redo
</Button>
<Button onClick={() => fitAll()}>Fit</Button>
</div>
</div>
<div className="relative">
<Diagram<Step>
ref={canvas}
aria-label="Sales pipeline"
graph={graph}
onGraphChange={(changes) => change(changes)}
renderNode={renderStep}
nodeName={nodeName}
portName={portName}
createEdge={makeEdge}
rules={RULES}
line={line}
onRefuse={(reason) => setRefused(REASONS[reason])}
messages={{ refused: (reason) => REASONS[reason] }}
value={selected}
onValueChange={setSelected}
viewport={view}
onViewportChange={setView}
grid={4}
onKeyDown={onKeyDown}
onDropOnEmpty={(from, point, client) => {
const id = `step-${made.current.steps + 1}`;
if (offered(from, point, id).length === 0) return;
flushSync(() => setOffer({ from, point, id }));
// Opened where the line was let go, as a menu asked for there.
offerAt.current?.dispatchEvent(
new MouseEvent("contextmenu", {
bubbles: true,
cancelable: true,
clientX: client.x,
clientY: client.y,
}),
);
}}
className="h-[420px] w-full rounded-lg border border-border-muted bg-surface-subtle max-sm:h-[360px]"
/>
{/* The place the menu of what to add opens from. It takes no press of its own: it is only ever opened for a line let go. */}
<ContextMenu
onOpenChange={(open) => {
if (!open) setOffer(null);
}}
>
<ContextMenuTrigger ref={offerAt} aria-hidden className="pointer-events-none absolute inset-0" />
<MenuContent aria-label="Add a step here">
{offer !== null &&
offered(offer.from, offer.point, offer.id).map(({ name }) => (
<MenuItem key={name} onSelect={() => add(offer.from, offer.point, offer.id, name)}>
{`Add ${name}`}
</MenuItem>
))}
</MenuContent>
</ContextMenu>
</div>
{/* The diagram says each of these itself, to those who cannot see it: this is for those who can. */}
<p
data-refused={refused === null ? undefined : ""}
className="mt-6 text-center font-mono text-xs text-fg-muted data-refused:text-danger"
>
{refused !== null
? `Not joined. ${refused}`
: selected.length === 0
? `${graph.nodes.length} steps, ${graph.edges.length} lines`
: [
chosen.length === 1 ? chosen[0]!.title : chosen.length > 1 ? `${chosen.length} steps` : "",
lines === 1 ? "1 line" : lines > 1 ? `${lines} lines` : "",
]
.filter(Boolean)
.join(" and ") + " selected"}
</p>
</div>
);
}
/** A step that takes rows and gives rows, to be made something of. */
function newStep(id: string, x: number, y: number): Step {
return step(id, "New step", "rows → rows", Math.round(x / 4) * 4, Math.round(y / 4) * 4, [
rowsIn(),
rowsOut,
]);
}Behaviour
Orthogonal
A route begins at the first anchor, ends at the second, and has only level and upright runs, each turning into the next
fc.assert(
fc.property(apart(0), ({ boxes, from, to }) => {
const points = route(from, to, boxes);
if (points[0]![0] !== from.x || points[0]![1] !== from.y) return false;
if (points.at(-1)![0] !== to.x || points.at(-1)![1] !== to.y) return false;
for (let at = 1; at < points.length; at += 1) {
const way = heading(points[at - 1]!, points[at]!);
if (way === -1) return false;
// Every point between the ends is a corner: no run carries straight on into the next.
if (at > 1 && way % 2 === heading(points[at - 2]!, points[at - 1]!) % 2) return false;
}
return true;
}),
);It leaves the first anchor the way its side faces, and reaches the second against the way its side faces
fc.assert(
fc.property(apart(STUB * 2), ({ boxes, from, to }) => {
const points = route(from, to, boxes);
return (
heading(points[0]!, points[1]!) === from.side &&
heading(points.at(-1)!, points.at(-2)!) === to.side
);
}),
);Between boxes two stubs or more apart, no run enters either box
fc.assert(
fc.property(apart(STUB * 2), ({ boxes, from, to }) => {
const points = route(from, to, boxes);
for (let at = 1; at < points.length; at += 1) {
if (enters(points[at - 1]!, points[at]!, boxes.slice(0, 4))) return false;
if (enters(points[at - 1]!, points[at]!, boxes.slice(4))) return false;
}
return true;
}),
{ numRuns: 400 },
);Between its stubs it keeps half a stub clear of both boxes
fc.assert(
fc.property(apart(STUB * 2), ({ boxes, from, to }) => {
const points = route(from, to, boxes);
const grown = (at: number) => [
boxes[at]! - STUB / 2,
boxes[at + 1]! - STUB / 2,
boxes[at + 2]! + STUB,
boxes[at + 3]! + STUB,
];
// The first run and the last are the stubs, or begin with them.
for (let at = 2; at < points.length - 1; at += 1) {
if (enters(points[at - 1]!, points[at]!, grown(0))) return false;
if (enters(points[at - 1]!, points[at]!, grown(4))) return false;
}
return true;
}),
);Two ends that face each other in line are joined by one straight run
const boxes = [0, 0, 100, 40, 300, 0, 100, 40];
expect(route({ x: 100, y: 20, side: SIDE.right }, { x: 300, y: 20, side: SIDE.left }, boxes)).toEqual([
[100, 20],
[300, 20],
]);Two ends that face each other out of line turn in the middle of the gap between their boxes
const boxes = [0, 0, 100, 40, 300, 100, 100, 40];
expect(route({ x: 100, y: 20, side: SIDE.right }, { x: 300, y: 120, side: SIDE.left }, boxes)).toEqual([
[100, 20],
[200, 20],
[200, 120],
[300, 120],
]);An end that faces away from the other goes round its own box, a stub clear of it
// From the left side of the first box to the left side of a box to its right.
const boxes = [0, 0, 100, 40, 300, 0, 100, 40];
const points = route({ x: 0, y: 20, side: SIDE.left }, { x: 300, y: 20, side: SIDE.left }, boxes);
expect(points.slice(0, 3)).toEqual([
[0, 20],
[-16, 20],
[-16, -16],
]);
expect(points.at(-1)).toEqual([300, 20]);An edge from a box to itself goes round its corner
const boxes = [0, 0, 100, 40, 0, 0, 100, 40];
const points = route({ x: 100, y: 20, side: SIDE.right }, { x: 50, y: 0, side: SIDE.top }, boxes);
expect(points).toEqual([
[100, 20],
[116, 20],
[116, -16],
[50, -16],
[50, 0],
]);Ends that belong to no box are still left and reached squarely
expect(route({ x: 0, y: 0, side: SIDE.right }, { x: 100, y: 60, side: SIDE.top })).toEqual([
[0, 0],
[100, 0],
[100, 60],
]);Boxes that touch or overlap are still joined, by level and upright runs
fc.assert(
fc.property(
fc.tuple(coordinate, coordinate, extent, extent),
fc.tuple(coordinate, coordinate, extent, extent),
side,
side,
(a, b, first, second) => {
const from = anchor(a, first, 0.5);
const to = anchor(b, second, 0.5);
const points = route(from, to, [...a, ...b]);
if (points.length < 2 || points.length > ROUTE_POINTS) return false;
for (let at = 1; at < points.length; at += 1) {
if (heading(points[at - 1]!, points[at]!) === -1) return false;
}
return points[0]![0] === from.x && points.at(-1)![1] === to.y;
},
),
);The same ends give the same route, whatever was routed in between
fc.assert(
fc.property(apart(0), apart(0), (one, other) => {
const first = route(one.from, one.to, one.boxes);
route(other.from, other.to, other.boxes);
return JSON.stringify(route(one.from, one.to, one.boxes)) === JSON.stringify(first);
}),
);However far apart the ends, a route reads its boxes once and writes a bounded number of points
for (const far of [1000, 100_000, 100_000_000]) {
const boxes = counted([0, 0, 100, 40, far, far, 100, 40]);
const out = counted(new Float64Array(ROUTE_POINTS * 2));
const count = orthogonalRoute(100, 20, SIDE.right, far, far + 20, SIDE.left, boxes.array, out.array);
expect(count).toBe(4);
expect(boxes.accesses()).toBeLessThanOrEqual(12);
expect(out.accesses()).toBeLessThanOrEqual(64);
}Room for fewer than sixteen points, boxes that are not two, or more obstacles than are gone round, are refused
expect(() => orthogonalRoute(0, 0, SIDE.right, 9, 9, SIDE.left, null, new Float64Array(24))).toThrow(
RangeError,
);
expect(() =>
orthogonalRoute(0, 0, SIDE.right, 9, 9, SIDE.left, [0, 0, 1, 1], new Float64Array(32)),
).toThrow(RangeError);
const many = Array.from({ length: (MAX_OBSTACLES + 1) * 4 }, (_, at) => at);
expect(() =>
orthogonalRoute(0, 0, SIDE.right, 9, 9, SIDE.left, null, new Float64Array(32), STUB, many),
).toThrow(RangeError);
expect(() =>
orthogonalRoute(0, 0, SIDE.right, 9, 9, SIDE.left, null, new Float64Array(32), STUB, [1, 2, 3]),
).toThrow(RangeError);Round other boxes
A route enters no other box, where a way clear of them all is to be had among them
fc.assert(
fc.property(scene, ({ boxes, from, to, obstacles }) => {
const points = route(from, to, boxes, STUB, obstacles.flat());
for (let at = 1; at < points.length; at += 1) {
for (const each of [boxes.slice(0, 4), boxes.slice(4), ...obstacles]) {
if (enters(points[at - 1]!, points[at]!, each)) return false;
}
}
return (
heading(points[0]!, points[1]!) === from.side &&
heading(points.at(-1)!, points.at(-2)!) === to.side
);
}),
{ numRuns: 600 },
);A box in the way of a straight line is gone round, a stub clear of it
const boxes = [0, 0, 100, 40, 400, 0, 100, 40];
const points = route(
{ x: 100, y: 20, side: SIDE.right },
{ x: 400, y: 20, side: SIDE.left },
boxes,
STUB,
[200, -20, 100, 80],
);
expect(points.length).toBeGreaterThan(2);
for (let at = 1; at < points.length; at += 1) {
expect(enters(points[at - 1]!, points[at]!, [192, -28, 116, 96])).toBe(false);
}Where no way is clear of them, the route goes as it would without them: squarely, out of its own two boxes, and no further round
fc.assert(
fc.property(apart(STUB * 2), ({ boxes, from, to }) => {
// A box over the far end's stub, which the route cannot keep out of.
const blocking = [to.x - STUB, to.y - STUB, STUB * 2, STUB * 2];
const points = route(from, to, boxes, STUB, blocking);
// Length and a stub for each corner: what a route costs. Lines added for the box only offer more ways.
const cost = (line: number[][]) =>
polylineLength(line.flat(), line.length) + (line.length - 2) * STUB;
if (cost(points) > cost(route(from, to, boxes)) + 0.5) return false;
for (let at = 1; at < points.length; at += 1) {
if (heading(points[at - 1]!, points[at]!) === -1) return false;
if (enters(points[at - 1]!, points[at]!, boxes.slice(0, 4))) return false;
if (enters(points[at - 1]!, points[at]!, boxes.slice(4))) return false;
}
return (
heading(points[0]!, points[1]!) === from.side &&
heading(points.at(-1)!, points.at(-2)!) === to.side
);
}),
{ numRuns: 1000 },
);With the most obstacles, a route reads each of their numbers once
const obstacles = counted(
Array.from({ length: MAX_OBSTACLES }, (_, at) => [
150 + (at % 4) * 60,
-200 + Math.floor(at / 4) * 160,
30,
30,
]).flat(),
);
const out = new Float64Array(ROUTE_POINTS * 2);
const count = orthogonalRoute(
100,
20,
SIDE.right,
500,
20,
SIDE.left,
[0, 0, 100, 40, 500, 0, 100, 40],
out,
STUB,
obstacles.array,
);
expect(count).toBeGreaterThanOrEqual(2);
expect(obstacles.accesses()).toBeLessThanOrEqual(MAX_OBSTACLES * 4 + 1);Curve
A curve begins and ends at the anchors, and its handles reach out the way the sides face
const out = new Float64Array(8);
curveRoute(0, 0, SIDE.right, 300, 100, SIDE.left, out);
const reach = Math.hypot(300, 100) * 0.4;
expect([...out]).toEqual([0, 0, reach, 0, 300 - reach, 100, 300, 100]);
curveRoute(0, 0, SIDE.bottom, 300, 100, SIDE.top, out);
expect([...out]).toEqual([0, 0, 0, reach, 300, 100 - reach, 300, 100]);Ends close together still leave squarely, and ends far apart do not swing wide: the handles keep between a least and a most
const out = new Float64Array(8);
curveRoute(0, 0, SIDE.right, 10, 0, SIDE.left, out);
expect(out[2]).toBe(24);
curveRoute(0, 0, SIDE.right, 10_000, 0, SIDE.left, out);
expect(out[2]).toBe(240);
curveRoute(0, 0, SIDE.right, 10_000, 0, SIDE.left, out, 10, 50);
expect(out[2]).toBe(50);A curve lies within the box of its four points
fc.assert(
fc.property(coordinate, coordinate, side, coordinate, coordinate, side, (ax, ay, a, bx, by, b) => {
const curve = new Float64Array(8);
const bounds = new Float64Array(4);
const point = new Float64Array(2);
curveRoute(ax, ay, a, bx, by, b, curve);
pointsBounds(curve, 4, bounds);
for (let step = 0; step <= 50; step += 1) {
curvePoint(curve, step / 50, point);
if (point[0]! < bounds[0]! - 1e-9 || point[0]! > bounds[2]! + 1e-9) return false;
if (point[1]! < bounds[1]! - 1e-9 || point[1]! > bounds[3]! + 1e-9) return false;
}
return true;
}),
);Picking
The distance from a point to a line is the distance to the nearest place on any of its runs
fc.assert(
fc.property(
fc.array(fc.tuple(coordinate, coordinate), { minLength: 2, maxLength: 6 }),
coordinate,
coordinate,
(corners, x, y) => {
const points = corners.flat();
const distance = polylineDistance(points, corners.length, x, y);
const slow = walked(points, corners.length, x, y);
// The walk steps over the nearest place by at most half a step.
return distance <= slow + 1e-9 && distance >= slow - 1;
},
),
);A point on a line is no distance from it; a line of one point is that point; of none, out of reach
expect(polylineDistance([0, 0, 10, 0, 10, 10], 3, 10, 4)).toBe(0);
expect(polylineDistance([0, 0, 10, 0, 10, 10], 3, 13, 14)).toBe(5);
expect(polylineDistance([3, 4], 1, 0, 0)).toBe(5);
expect(polylineDistance([], 0, 0, 0)).toBe(Infinity);The distance to a curve is the distance to the curve itself, to within a unit and a half
fc.assert(
fc.property(
fc.tuple(coordinate, coordinate, side),
fc.tuple(coordinate, coordinate, side),
coordinate,
coordinate,
([ax, ay, a], [bx, by, b], x, y) => {
const curve = new Float64Array(8);
const point = new Float64Array(2);
curveRoute(ax, ay, a, bx, by, b, curve);
let slow = Infinity;
for (let step = 0; step <= 2000; step += 1) {
curvePoint(curve, step / 2000, point);
slow = Math.min(slow, Math.hypot(x - point[0]!, y - point[1]!));
}
return Math.abs(curveDistance(curve, x, y) - slow) < 1.5;
},
),
);Along a path
The point a distance along a line is on the line, that far from its start; before the start it is the start, past the end the end
const line = [0, 0, 10, 0, 10, 10];
const out = new Float64Array(2);
expect(polylineLength(line, 3)).toBe(20);
polylinePoint(line, 3, 5, out);
expect([...out]).toEqual([5, 0]);
polylinePoint(line, 3, 14, out);
expect([...out]).toEqual([10, 4]);
polylinePoint(line, 3, -3, out);
expect([...out]).toEqual([0, 0]);
polylinePoint(line, 3, 99, out);
expect([...out]).toEqual([10, 10]);
expect(() => polylinePoint([], 0, 0, out)).toThrow(RangeError);Every point along a route is on the route
fc.assert(
fc.property(
apart(STUB * 2),
fc.double({ min: 0, max: 1, noNaN: true }),
({ boxes, from, to }, part) => {
const out = new Float64Array(ROUTE_POINTS * 2);
const point = new Float64Array(2);
const count = orthogonalRoute(from.x, from.y, from.side, to.x, to.y, to.side, boxes, out);
polylinePoint(out, count, polylineLength(out, count) * part, point);
return polylineDistance(out, count, point[0]!, point[1]!) < 1e-6;
},
),
);A curve's point at 0 is its start and at 1 its end
const curve = new Float64Array(8);
const point = new Float64Array(2);
curveRoute(5, 6, SIDE.right, 70, 80, SIDE.top, curve);
curvePoint(curve, 0, point);
expect([...point]).toEqual([5, 6]);
curvePoint(curve, 1, point);
expect([...point]).toEqual([70, 80]);
curvePoint(curve, 7, point);
expect([...point]).toEqual([70, 80]);The box of some points holds every one of them, and is the smallest that does
const out = new Float64Array(4);
pointsBounds([3, 9, -4, 2, 8, 5], 3, out);
expect([...out]).toEqual([-4, 2, 8, 9]);As a path to draw
A line is drawn from point to point
expect(polylinePath([0, 0, 10, 0, 10, 10], 3)).toBe("M0 0L10 0L10 10");
expect(polylinePath([], 0)).toBe("");With a radius, each corner is rounded: the line stops short of it and curves on to the next run
expect(polylinePath([0, 0, 20, 0, 20, 20], 3, 4)).toBe("M0 0L16 0Q20 0 20 4L20 20");A corner between runs too short for the radius is rounded by half the shorter
expect(polylinePath([0, 0, 20, 0, 20, 4, 40, 4], 4, 8)).toBe("M0 0L18 0Q20 0 20 2L20 2Q20 4 22 4L40 4");A path far from zero is written from an origin near it, in small numbers
const far = 100_000_000;
expect(polylinePath([far, far, far + 10, far], 2, 0, far, far)).toBe("M0 0L10 0");
const curve = new Float64Array(8);
curveRoute(far, far, SIDE.right, far + 100, far, SIDE.left, curve);
expect(curvePath(curve, far, far)).toBe("M0 0C40 0 60 0 100 0");React
Install
npx shadcn@latest add https://ui.publictheta.com/r/edge-route.jsonSource
src/algorithms/edge-route.ts
/**
* The path of an edge between two anchors: the line of a diagram, the wire of a
* node editor. Each end leaves the box it belongs to by one of its sides, at
* right angles to it, and the path goes round both boxes rather than through
* them — and round the other boxes it is given, where it can.
*
* Two kinds of path, both a handful of numbers written into what is given:
*
* - **Orthogonal.** Level and upright runs only. Each end goes straight out by
* a stub's length; between the stubs the cheapest way is found over a grid of
* lines — the stubs' own, a line a stub clear of each side of each end's box and half a stub clear
* of each side of every other box,
* and the middle of the gap between the two ends' boxes — where length costs
* what it measures and every corner costs a stub more. A corner in the middle
* of the gap costs a hair less, so that of equal ways the one that turns
* half-way is chosen. The other boxes are at most `MAX_OBSTACLES`, so the
* grid, and the search over it, are bounded however many there are around.
* - **Curve.** One cubic curve, leaving each end the way its side faces, with
* handles that lengthen as the ends part.
*
* And the questions asked of a path once it is drawn: how far a point is from
* it, to pick it with a pointer; where the point part-way along it is, to hang
* a label or a handle there; what box holds it; what to hand an SVG `path`.
*
* Coordinates are numbers in typed arrays, `[x0, y0, x1, y1, …]`. The search
* keeps its scratch space between calls: a route allocates nothing, and the
* same ends always give the same path.
*/
/** The side of a box an end is on, as the way it faces. */
export const SIDE = { right: 0, bottom: 1, left: 2, top: 3 } as const;
export type Side = 0 | 1 | 2 | 3;
/** The most points an orthogonal route has; `out` holds twice as many numbers. */
export const ROUTE_POINTS = 16;
/** The most boxes besides its own two that a route is kept out of. */
export const MAX_OBSTACLES = 12;
const DX = [1, 0, -1, 0] as const;
const DY = [0, 1, 0, -1] as const;
// Per axis: two stub ends, two sides of each of the two boxes, the middle of their gap, two sides of each other box.
const LINES = 7 + MAX_OBSTACLES * 2;
const STATES = LINES * LINES * 4;
/** Arrived at the far anchor itself. */
const END = STATES;
const xs = new Float64Array(LINES);
const ys = new Float64Array(LINES);
const cost = new Float64Array(STATES + 1);
const guess = new Float64Array(STATES + 1);
const via = new Int16Array(STATES + 1);
// 0 unseen, 1 reached, 2 settled.
const mark = new Uint8Array(STATES + 1);
const trail = new Float64Array((LINES * LINES + 4) * 2);
// The two boxes, each as left, top, right, bottom; and the others, as many as `others` says.
const box = new Float64Array(8);
const other = new Float64Array(MAX_OBSTACLES * 4);
let others = 0;
const NO_BOX = new Float64Array(0);
// A heap of states by their guess: every state reached is pushed again when its cost falls, and passed over once settled.
const HEAP = STATES * 4 + 8;
const heap = new Int32Array(HEAP);
const heapKey = new Float64Array(HEAP);
let heapSize = 0;
function push(state: number, key: number): void {
let at = heapSize;
heapSize += 1;
while (at > 0) {
const parent = (at - 1) >> 1;
// Of equal guesses the state pushed with the lower number first: the same ends, the same route.
if (heapKey[parent]! < key || (heapKey[parent] === key && heap[parent]! <= state)) break;
heap[at] = heap[parent]!;
heapKey[at] = heapKey[parent]!;
at = parent;
}
heap[at] = state;
heapKey[at] = key;
}
function pop(): number {
const top = heap[0]!;
heapSize -= 1;
const state = heap[heapSize]!;
const key = heapKey[heapSize]!;
let at = 0;
for (;;) {
let child = at * 2 + 1;
if (child >= heapSize) break;
const right = child + 1;
if (
right < heapSize &&
(heapKey[right]! < heapKey[child]! ||
(heapKey[right] === heapKey[child] && heap[right]! < heap[child]!))
) {
child = right;
}
if (key < heapKey[child]! || (key === heapKey[child] && state <= heap[child]!)) break;
heap[at] = heap[child]!;
heapKey[at] = heapKey[child]!;
at = child;
}
heap[at] = state;
heapKey[at] = key;
return top;
}
/** The lines of one axis, in order and each once: how many. `axis` 0 reads boxes' left and right, 1 their top and bottom. */
function lines(
into: Float64Array,
start: number,
end: number,
axis: 0 | 1,
clear: number,
apart: number,
middle: number,
): number {
into[0] = start;
into[1] = end;
let count = 2;
for (let at = 0; at < 8; at += 4) {
into[count] = box[at + axis]! - clear;
into[count + 1] = box[at + axis + 2]! + clear;
count += 2;
}
if (!Number.isNaN(middle)) {
into[count] = middle;
count += 1;
}
for (let at = 0; at < others * 4; at += 4) {
into[count] = other[at + axis]! - apart;
into[count + 1] = other[at + axis + 2]! + apart;
count += 2;
}
for (let at = 1; at < count; at += 1) {
const value = into[at]!;
let slot = at;
while (slot > 0 && into[slot - 1]! > value) {
into[slot] = into[slot - 1]!;
slot -= 1;
}
into[slot] = value;
}
let kept = 1;
for (let at = 1; at < count; at += 1) {
if (into[at] !== into[kept - 1]) {
into[kept] = into[at]!;
kept += 1;
}
}
return kept;
}
/** The middle of the gap between two spans, or `NaN` where they overlap. */
function gapMiddle(aLow: number, aHigh: number, bLow: number, bHigh: number): number {
if (bLow > aHigh) return (aHigh + bLow) / 2;
if (aLow > bHigh) return (bHigh + aLow) / 2;
return Number.NaN;
}
function indexOf(values: Float64Array, count: number, value: number): number {
for (let at = 0; at < count; at += 1) if (values[at] === value) return at;
return 0;
}
/** Whether a level or upright run passes within `clear` of the inside of any of `count` boxes from `from`. */
function hits(
boxes: Float64Array,
count: number,
x1: number,
y1: number,
x2: number,
y2: number,
clear: number,
): boolean {
if (clear < 0) return false;
const left = Math.min(x1, x2);
const right = Math.max(x1, x2);
const top = Math.min(y1, y2);
const bottom = Math.max(y1, y2);
for (let at = 0; at < count * 4; at += 4) {
if (
left < boxes[at + 2]! + clear &&
right > boxes[at]! - clear &&
top < boxes[at + 3]! + clear &&
bottom > boxes[at + 1]! - clear
) {
return true;
}
}
return false;
}
/**
* The cheapest way from the first stub's end to the far anchor, as states of
* where one is and which way one is facing. `clear` is how far from the two
* boxes a run must keep, and `apart` how far from the others; negative, they
* are not there.
*/
function search(
nx: number,
ny: number,
from: number,
to: number,
toSide: Side,
stub: number,
clear: number,
apart: number,
midX: number,
midY: number,
): boolean {
mark.fill(0);
heapSize = 0;
const goalX = xs[(to >> 2) % LINES]!;
const goalY = ys[((to >> 2) / LINES) | 0]!;
const into = (toSide + 2) & 3;
cost[from] = 0;
guess[from] = Math.abs(xs[(from >> 2) % LINES]! - goalX) + Math.abs(ys[((from >> 2) / LINES) | 0]! - goalY);
via[from] = -1;
mark[from] = 1;
push(from, guess[from]!);
while (heapSize > 0) {
const best = pop();
if (mark[best] === 2) continue;
if (best === END) return true;
mark[best] = 2;
const facing = best & 3;
const ix = (best >> 2) % LINES;
const iy = ((best >> 2) / LINES) | 0;
const x = xs[ix]!;
const y = ys[iy]!;
const corner = stub * (x === midX || y === midY ? 0.999 : 1);
// From the far stub's end the anchor is reached by going in: never by backing into it.
if ((best & ~3) === to && facing !== toSide) {
const total = cost[best]! + stub + (facing === into ? 0 : corner);
if (mark[END] === 0 || total < cost[END]!) {
cost[END] = total;
guess[END] = total;
via[END] = best;
mark[END] = 1;
push(END, total);
}
}
for (let turn = 0; turn < 4; turn += 1) {
// No turning back on oneself.
if (turn === ((facing + 2) & 3)) continue;
const jx = ix + DX[turn]!;
const jy = iy + DY[turn]!;
if (jx < 0 || jy < 0 || jx >= nx || jy >= ny) continue;
const nextX = xs[jx]!;
const nextY = ys[jy]!;
if (hits(box, 2, x, y, nextX, nextY, clear) || hits(other, others, x, y, nextX, nextY, apart)) continue;
const next = ((jy * LINES + jx) << 2) | turn;
if (mark[next] === 2) continue;
const total = cost[best]! + Math.abs(nextX - x) + Math.abs(nextY - y) + (turn === facing ? 0 : corner);
if (mark[next] === 0 || total < cost[next]!) {
cost[next] = total;
guess[next] = total + Math.abs(nextX - goalX) + Math.abs(nextY - goalY);
via[next] = best;
mark[next] = 1;
push(next, guess[next]!);
}
}
}
return false;
}
// How clear of the two boxes, and of the others, each attempt keeps, in stubs; below zero, not kept clear at all.
// Clear of everything; failing that, out of everything; failing that, the others are given up before the two boxes are.
const CLEAR_OWN = [0.5, 0, 0.5, 0, -1] as const;
const CLEAR_OTHERS = [0.5, 0, -1, -1, -1] as const;
/**
* An orthogonal route from one anchor to another, written into `out` as points;
* returns how many. Each anchor is on the given side of its box — `boxes` holds
* the two as `[x, y, width, height, x, y, width, height]`, and may be left out
* for ends that belong to nothing. `obstacles` are other boxes to go round, in
* the same form, at most `MAX_OBSTACLES` of them.
*
* The route leaves the first anchor and reaches the second at right angles to
* their sides. Where the boxes are two stubs or more apart it enters neither,
* and keeps half a stub clear of both between its stubs; where a way clear of
* the obstacles is to be had on its grid, it takes it. Where none is, it goes
* as it would without them; and nearer than two stubs it keeps as clear as it
* can: out of the boxes if not clear of them, and through them only when there
* is no way round.
*/
export function orthogonalRoute(
ax: number,
ay: number,
aSide: Side,
bx: number,
by: number,
bSide: Side,
boxes: ArrayLike<number> | null,
out: Float64Array,
stub = 16,
obstacles: ArrayLike<number> | null = null,
): number {
if (out.length < ROUTE_POINTS * 2) {
throw new RangeError(`A route is written into room for ${ROUTE_POINTS} points, not ${out.length / 2}.`);
}
const given = boxes ?? NO_BOX;
if (given.length !== 0 && given.length !== 8) {
throw new RangeError(`Boxes are two, of four numbers each, not ${given.length} numbers.`);
}
const around = obstacles ?? NO_BOX;
if (around.length % 4 !== 0 || around.length > MAX_OBSTACLES * 4) {
throw new RangeError(
`Obstacles are boxes of four numbers each, at most ${MAX_OBSTACLES} of them, not ${around.length} numbers.`,
);
}
if (given.length === 0) {
box[0] = box[2] = ax;
box[1] = box[3] = ay;
box[4] = box[6] = bx;
box[5] = box[7] = by;
} else {
for (let at = 0; at < 8; at += 4) {
box[at] = given[at]!;
box[at + 1] = given[at + 1]!;
box[at + 2] = given[at]! + given[at + 2]!;
box[at + 3] = given[at + 1]! + given[at + 3]!;
}
}
others = around.length / 4;
for (let at = 0; at < around.length; at += 4) {
const x = around[at]!;
const y = around[at + 1]!;
other[at] = x;
other[at + 1] = y;
other[at + 2] = x + around[at + 2]!;
other[at + 3] = y + around[at + 3]!;
}
// A stub ends its length beyond its box's side, wherever on the side — or within it — the anchor is.
const outward = (at: number, side: Side, x: number, y: number, axis: 0 | 1): number => {
if (axis === 0) {
if (side === SIDE.right) return Math.max(x, box[at + 2]!) + stub;
if (side === SIDE.left) return Math.min(x, box[at]!) - stub;
return x;
}
if (side === SIDE.bottom) return Math.max(y, box[at + 3]!) + stub;
if (side === SIDE.top) return Math.min(y, box[at + 1]!) - stub;
return y;
};
const sx = outward(0, aSide, ax, ay, 0);
const sy = outward(0, aSide, ax, ay, 1);
const tx = outward(4, bSide, bx, by, 0);
const ty = outward(4, bSide, bx, by, 1);
const midX = gapMiddle(box[0]!, box[2]!, box[4]!, box[6]!);
const midY = gapMiddle(box[1]!, box[3]!, box[5]!, box[7]!);
// A stub clear of the two boxes, and half a stub of the others: room to pass between two of them.
const nx = lines(xs, sx, tx, 0, stub, stub / 2, midX);
const ny = lines(ys, sy, ty, 1, stub, stub / 2, midY);
const from = ((indexOf(ys, ny, sy) * LINES + indexOf(xs, nx, sx)) << 2) | aSide;
const to = (indexOf(ys, ny, ty) * LINES + indexOf(xs, nx, tx)) << 2;
for (let attempt = others > 0 ? 0 : 2; attempt < CLEAR_OWN.length; attempt += 1) {
const clear = CLEAR_OWN[attempt]! * stub;
const apart = CLEAR_OTHERS[attempt]! * stub;
// The stubs are where they must be: one that runs into another box leaves no way clear of it.
if (hits(other, others, ax, ay, sx, sy, apart) || hits(other, others, tx, ty, bx, by, apart)) continue;
if (!search(nx, ny, from, to, bSide, stub, clear, apart, midX, midY)) continue;
// Read back from the far end, then written out forwards with every point that is not a corner left out.
trail[0] = bx;
trail[1] = by;
let length = 1;
for (let state = via[END]!; state !== -1; state = via[state]!) {
trail[length * 2] = xs[(state >> 2) % LINES]!;
trail[length * 2 + 1] = ys[((state >> 2) / LINES) | 0]!;
length += 1;
}
trail[length * 2] = ax;
trail[length * 2 + 1] = ay;
length += 1;
let count = 0;
for (let at = length - 1; at >= 0; at -= 1) {
const x = trail[at * 2]!;
const y = trail[at * 2 + 1]!;
if (count >= 1 && out[count * 2 - 2] === x && out[count * 2 - 1] === y) continue;
if (count >= 2) {
const px = out[count * 2 - 2]!;
const py = out[count * 2 - 1]!;
const qx = out[count * 2 - 4]!;
const qy = out[count * 2 - 3]!;
// The point before is on the way from the one before it to this: not a corner.
if ((qx === px && px === x) || (qy === py && py === y)) count -= 1;
}
if (count === ROUTE_POINTS) {
count = -1;
break;
}
out[count * 2] = x;
out[count * 2 + 1] = y;
count += 1;
}
if (count > 0) return count;
}
// Never reached: with the boxes not there, the grid always has a way.
out[0] = ax;
out[1] = ay;
out[2] = bx;
out[3] = by;
return 2;
}
/**
* A curve from one anchor to another, written into `out` as the four points of
* a cubic: the start, its handle, the end's handle, the end. Each handle
* reaches out the way its side faces, by two-fifths of the distance between the
* ends — but never less than `least`, so that ends close together still leave
* squarely, nor more than `most`.
*/
export function curveRoute(
ax: number,
ay: number,
aSide: Side,
bx: number,
by: number,
bSide: Side,
out: Float64Array,
least = 24,
most = 240,
): void {
if (out.length < 8) throw new RangeError(`A curve is four points, eight numbers, not ${out.length}.`);
const reach = Math.min(Math.max(Math.hypot(bx - ax, by - ay) * 0.4, least), most);
out[0] = ax;
out[1] = ay;
out[2] = ax + DX[aSide]! * reach;
out[3] = ay + DY[aSide]! * reach;
out[4] = bx + DX[bSide]! * reach;
out[5] = by + DY[bSide]! * reach;
out[6] = bx;
out[7] = by;
}
/** How far a point is from the run between two others. */
function segmentDistance(x: number, y: number, x1: number, y1: number, x2: number, y2: number): number {
const dx = x2 - x1;
const dy = y2 - y1;
const squared = dx * dx + dy * dy;
const along = squared === 0 ? 0 : Math.min(1, Math.max(0, ((x - x1) * dx + (y - y1) * dy) / squared));
return Math.hypot(x - (x1 + dx * along), y - (y1 + dy * along));
}
/** How far a point is from a line through `count` points: from the nearest place on any of its runs. */
export function polylineDistance(points: ArrayLike<number>, count: number, x: number, y: number): number {
if (count === 0) return Infinity;
let nearest = Math.hypot(x - points[0]!, y - points[1]!);
for (let at = 1; at < count; at += 1) {
const distance = segmentDistance(
x,
y,
points[at * 2 - 2]!,
points[at * 2 - 1]!,
points[at * 2]!,
points[at * 2 + 1]!,
);
if (distance < nearest) nearest = distance;
}
return nearest;
}
/** One coordinate of a cubic at `t`, from 0 at its start to 1 at its end. */
function cubic(p0: number, p1: number, p2: number, p3: number, t: number): number {
const u = 1 - t;
return u * u * u * p0 + 3 * u * u * t * p1 + 3 * u * t * t * p2 + t * t * t * p3;
}
/**
* How far a point is from a curve, taken as `steps` straight runs: exact to
* within the little by which a run of the curve bows from its chord.
*/
export function curveDistance(curve: ArrayLike<number>, x: number, y: number, steps = 24): number {
let nearest = Infinity;
let fromX = curve[0]!;
let fromY = curve[1]!;
for (let step = 1; step <= steps; step += 1) {
const t = step / steps;
const toX = cubic(curve[0]!, curve[2]!, curve[4]!, curve[6]!, t);
const toY = cubic(curve[1]!, curve[3]!, curve[5]!, curve[7]!, t);
const distance = segmentDistance(x, y, fromX, fromY, toX, toY);
if (distance < nearest) nearest = distance;
fromX = toX;
fromY = toY;
}
return nearest;
}
/** How long a line through `count` points is. */
export function polylineLength(points: ArrayLike<number>, count: number): number {
let length = 0;
for (let at = 1; at < count; at += 1) {
length += Math.hypot(points[at * 2]! - points[at * 2 - 2]!, points[at * 2 + 1]! - points[at * 2 - 1]!);
}
return length;
}
/**
* The point a distance along a line from its start, written into `out`. A
* distance below zero is the start; one past the end, the end.
*/
export function polylinePoint(
points: ArrayLike<number>,
count: number,
distance: number,
out: Float64Array,
): void {
if (count === 0) throw new RangeError("A line with no points has no point along it.");
let left = Math.max(distance, 0);
for (let at = 1; at < count; at += 1) {
const x1 = points[at * 2 - 2]!;
const y1 = points[at * 2 - 1]!;
const x2 = points[at * 2]!;
const y2 = points[at * 2 + 1]!;
const run = Math.hypot(x2 - x1, y2 - y1);
if (left <= run && run > 0) {
out[0] = x1 + ((x2 - x1) * left) / run;
out[1] = y1 + ((y2 - y1) * left) / run;
return;
}
left -= run;
}
out[0] = points[count * 2 - 2]!;
out[1] = points[count * 2 - 1]!;
}
/** The point of a curve at `t`, from 0 at its start to 1 at its end, written into `out`. */
export function curvePoint(curve: ArrayLike<number>, t: number, out: Float64Array): void {
const at = Math.min(Math.max(t, 0), 1);
out[0] = cubic(curve[0]!, curve[2]!, curve[4]!, curve[6]!, at);
out[1] = cubic(curve[1]!, curve[3]!, curve[5]!, curve[7]!, at);
}
/**
* The box that holds `count` points, written into `out` as left, top, right,
* bottom. A curve lies within the box of its four points.
*/
export function pointsBounds(points: ArrayLike<number>, count: number, out: Float64Array): void {
let left = Infinity;
let top = Infinity;
let right = -Infinity;
let bottom = -Infinity;
for (let at = 0; at < count; at += 1) {
const x = points[at * 2]!;
const y = points[at * 2 + 1]!;
if (x < left) left = x;
if (x > right) right = x;
if (y < top) top = y;
if (y > bottom) bottom = y;
}
out[0] = left;
out[1] = top;
out[2] = right;
out[3] = bottom;
}
// To the hundredth: a path's text stays short, and the same from one drawing to the next.
const tidy = (value: number): number => Math.round(value * 100) / 100;
/**
* A line through `count` points as the data of an SVG `path`, each corner
* rounded by `radius` — or by half the shorter of its two runs, where they are
* too short for it. Every coordinate is taken from `(originX, originY)`, so a
* path far from zero is written in small numbers.
*/
export function polylinePath(
points: ArrayLike<number>,
count: number,
radius = 0,
originX = 0,
originY = 0,
): string {
if (count === 0) return "";
let data = `M${tidy(points[0]! - originX)} ${tidy(points[1]! - originY)}`;
for (let at = 1; at < count; at += 1) {
const x = points[at * 2]!;
const y = points[at * 2 + 1]!;
if (at === count - 1 || radius <= 0) {
data += `L${tidy(x - originX)} ${tidy(y - originY)}`;
continue;
}
const before = Math.hypot(x - points[at * 2 - 2]!, y - points[at * 2 - 1]!);
const after = Math.hypot(points[at * 2 + 2]! - x, points[at * 2 + 3]! - y);
const round = Math.min(radius, before / 2, after / 2);
if (!(round > 0)) {
data += `L${tidy(x - originX)} ${tidy(y - originY)}`;
continue;
}
const inX = x - ((x - points[at * 2 - 2]!) / before) * round;
const inY = y - ((y - points[at * 2 - 1]!) / before) * round;
const outX = x + ((points[at * 2 + 2]! - x) / after) * round;
const outY = y + ((points[at * 2 + 3]! - y) / after) * round;
data += `L${tidy(inX - originX)} ${tidy(inY - originY)}Q${tidy(x - originX)} ${tidy(y - originY)} ${tidy(outX - originX)} ${tidy(outY - originY)}`;
}
return data;
}
/** A curve as the data of an SVG `path`, every coordinate taken from `(originX, originY)`. */
export function curvePath(curve: ArrayLike<number>, originX = 0, originY = 0): string {
const at = (index: number) => tidy(curve[index]! - (index % 2 === 0 ? originX : originY));
return `M${at(0)} ${at(1)}C${at(2)} ${at(3)} ${at(4)} ${at(5)} ${at(6)} ${at(7)}`;
}