Files
next-ai-draw-io/lib/diagram-engine/route.ts
dayuan.jiang 6b5fd613f2 feat(diagram-engine): label avoidance, paired opposite edges, semantic group colours
A git-workflow flowchart rendered with no overlaps but read poorly. Three
distinct causes, each fixed and measured:

1. Edge labels sat on boxes and on each other (4 collisions on the
   reported diagram; 280 across 250 generated flowcharts). The router
   keeps LINES off the boxes but a label renders at its edge's midpoint,
   which on a long edge is beside exactly the things the line was routed
   around. placeLabels slides each label along its own edge to a clear
   spot — longest edges first, midpoint-outward tries — written as the
   geometry's relative x, which draw.io natively supports. Corpus: 280
   label collisions -> 8.

2. A->B and B->A were routed independently, so "git add" ran straight
   while "git reset" wandered through a different corridor with a kink.
   Opposite edges that agree on axis now get two absolute parallel tracks
   in the strip where the two boxes overlap, a constant 24px apart,
   converted back to port fractions. Zero crossing regressions.

3. All boxes rendered the same white, because the render layer's
   fill/stroke support was never reachable: neither add_box's schema nor
   draw_graph's nodes exposed it. Rather than exposing raw hex (the model
   picks mismatched saturations, differently every time), nodes take a
   semantic group name and the engine maps groups to a fixed palette of
   six paired fill/strokes in order of first appearance. The model names
   the zones - remote vs local vs temp - and never touches a colour.

532 unit tests pass; the 5 diagram e2e tests pass in a real browser.
2026-08-09 18:59:34 +09:00

1412 lines
56 KiB
TypeScript

/**
* Edge routing: where an arrow leaves a node, and how it gets to the other end.
*
* Without this, `source` and `target` are all draw.io has to work with. Its own router
* knows the two terminals' bounds and nothing else — not where the other icons are — so
* it runs arrows straight through unrelated shapes and stacks several on one point.
*
* Three stages, the same shape as drawio-ai-kit's router (MIT — see NOTICE):
*
* 1. pick a side per edge, then DE-COLLIDE: several edges leaving the same side of the
* same node get spread along that side. An edge that has a clean straight shot keeps
* the centre; the others move off it.
* 2. try progressively less direct paths — straight, a Z through the gap between the
* two nodes, an L — testing each against every icon on the page, and keep the first
* that is clear.
* 3. NUDGE: globally separate parallel segments that ended up on top of each other, so
* the result does not depend on the order edges were declared in.
*
* What gets written to the XML is deliberately asymmetric:
*
* - Connection points ALWAYS. They are fractions of the terminal's bounds, so draw.io
* recomputes them from live geometry on every edit — they follow a node when the user
* drags it, and cost nothing in exchange.
* - Waypoints only when they are load-bearing: the edge carries a label (which sits at
* the path midpoint and needs a straight segment under it), or the router deliberately
* bent around something a straight line would have hit. Waypoints are absolute, so
* draw.io keeps them after a drag and the route deforms; spending that only where it
* buys something keeps the diagram editable.
*/
import type { Rect } from "./types"
import { routeOrthogonal, SIDE_DIR } from "./visgraph"
/** Which side of a node an edge attaches to. */
export type Side = "L" | "R" | "T" | "B"
export interface RouteInput {
id: string
source: string
target: string
/** Edges with a label need a straight segment under the midpoint. */
hasLabel: boolean
}
export interface RoutedEdge {
id: string
/** Exit point as a fraction of the source's bounds. */
exit: { x: number; y: number }
/** Entry point as a fraction of the target's bounds. */
entry: { x: number; y: number }
/** Absolute waypoints — emitted only when `freeze` is set. */
waypoints: { x: number; y: number }[]
/** Whether the waypoints must be written to the XML. */
freeze: boolean
}
interface Point {
x: number
y: number
}
/** Clearance kept around an icon when testing whether a segment hits it. */
const MARGIN = 7
/** Track separation used by the nudge pass. */
const SEP = 16
/** How close to a frame's border counts as running alongside it. */
const BORDER_MARGIN = 24
/** Segments shorter than this are connector stubs, not runs worth judging. */
const MIN_RUN = 28
/** The point on `r`'s side `side`, at fraction `f` along it. */
function portPoint(r: Rect, side: Side, f: number): Point {
if (side === "L") return { x: r.x, y: Math.round(r.y + f * r.h) }
if (side === "R") return { x: r.x + r.w, y: Math.round(r.y + f * r.h) }
if (side === "T") return { x: Math.round(r.x + f * r.w), y: r.y }
return { x: Math.round(r.x + f * r.w), y: r.y + r.h }
}
/** Does an axis-aligned segment cross this rect (plus its margin)? */
function segHitsRect(p: Point, q: Point, r: Rect): boolean {
const x0 = r.x - MARGIN
const x1 = r.x + r.w + MARGIN
const y0 = r.y - MARGIN
const y1 = r.y + r.h + MARGIN
if (Math.abs(p.y - q.y) < 1)
return (
p.y > y0 &&
p.y < y1 &&
Math.min(p.x, q.x) < x1 &&
Math.max(p.x, q.x) > x0
)
if (Math.abs(p.x - q.x) < 1)
return (
p.x > x0 &&
p.x < x1 &&
Math.min(p.y, q.y) < y1 &&
Math.max(p.y, q.y) > y0
)
// A diagonal should not occur, but treat its bounding box as a hit rather than
// silently letting it through.
return (
Math.min(p.x, q.x) < x1 &&
Math.max(p.x, q.x) > x0 &&
Math.min(p.y, q.y) < y1 &&
Math.max(p.y, q.y) > y0
)
}
/** Candidate route shapes, in the order they are tried. */
type Shape =
| { kind: "straight" }
| { kind: "Zx"; lane: number }
| { kind: "Zy"; lane: number }
| { kind: "Lhv" }
| { kind: "Lvh" }
/** Turn a shape into the concrete point list for one edge. */
function shapePoints(
a: Rect,
b: Rect,
exitSide: Side,
entrySide: Side,
sf: number,
tf: number,
shape: Shape,
): { sp: Point; ep: Point; wp: Point[] } {
const sp = portPoint(a, exitSide, sf)
const ep = portPoint(b, entrySide, tf)
let wp: Point[] = []
if (shape.kind === "Zx")
wp = [
{ x: shape.lane, y: sp.y },
{ x: shape.lane, y: ep.y },
]
else if (shape.kind === "Zy")
wp = [
{ x: sp.x, y: shape.lane },
{ x: ep.x, y: shape.lane },
]
else if (shape.kind === "Lhv") wp = [{ x: ep.x, y: sp.y }]
else if (shape.kind === "Lvh") wp = [{ x: sp.x, y: ep.y }]
return { sp, ep, wp }
}
/**
* Does this route's first or last leg turn back over the shape it belongs to?
*
* An arrow has to leave its own shape going AWAY from it. When the first bend lands back
* within the source's own span, draw.io draws the arrow out of one side and immediately back
* across the shape's own edge — the small hook seen coming out of a box's right side and
* turning straight back over it. Nothing is technically crossed, which is why the ordinary
* obstacle test misses it: an edge is exempt from its own two endpoints, and that exemption
* has to exist, since the line must touch them.
*
* This checks the two terminal legs only. A middle leg running past its own endpoint is
* normal — that is what a return path does.
*/
function doublesBack(pts: Point[], a: Rect, b: Rect): boolean {
const backOver = (from: Point, to: Point, r: Rect): boolean => {
// Leaving through a vertical side: the leg must not head back inside the box's width.
if (Math.abs(from.x - r.x) < 1 || Math.abs(from.x - (r.x + r.w)) < 1) {
if (Math.abs(from.y - to.y) < 1)
return to.x > r.x + 1 && to.x < r.x + r.w - 1
}
// Leaving through a horizontal side: likewise for the box's height.
if (Math.abs(from.y - r.y) < 1 || Math.abs(from.y - (r.y + r.h)) < 1) {
if (Math.abs(from.x - to.x) < 1)
return to.y > r.y + 1 && to.y < r.y + r.h - 1
}
return false
}
if (pts.length < 3) return false
return (
backOver(pts[0], pts[1], a) ||
backOver(pts[pts.length - 1], pts[pts.length - 2], b)
)
}
/**
* Lane positions to try inside a gap, from the middle outwards.
*
* The middle of the corridor is where a route looks intentional; stepping outwards from
* there finds the nearest clear lane when the middle is taken.
*/
function laneSweep(lo: number, hi: number): number[] {
const mid = (lo + hi) / 2
const out = [Math.round(mid)]
for (let k = 1; k <= 24; k++) {
const down = mid - k * 10
const up = mid + k * 10
if (down > lo + 2) out.push(Math.round(down))
if (up < hi - 2) out.push(Math.round(up))
}
return out
}
/**
* Route every edge.
*
* `rects` must hold every node on the page, `obstacles` the ids of the leaf shapes an arrow
* must not cross, and `containers` the ids of the frames.
*
* Containers are not obstacles — an edge from outside a VPC to something inside it has to
* cross the VPC's border. But they are not free to ignore either: a line that runs
* alongside a border, or straight through a frame neither of its endpoints belongs to,
* reads as a mistake even though it hits nothing. Those two cases are penalised instead.
*/
export function routeEdges(
edges: RouteInput[],
rects: Map<string, Rect>,
obstacles: Set<string>,
containers: Set<string> = new Set(),
): RoutedEdge[] {
const cards: { id: string; r: Rect }[] = []
for (const id of obstacles) {
const r = rects.get(id)
if (r) cards.push({ id, r })
}
const frames: Rect[] = []
for (const id of containers) {
const r = rects.get(id)
if (r) frames.push(r)
}
/** Does this path cross any icon other than its own two endpoints? */
const pathHits = (pts: Point[], exempt: Set<string>): boolean => {
for (let i = 0; i < pts.length - 1; i++)
for (const c of cards) {
if (exempt.has(c.id)) continue
if (segHitsRect(pts[i], pts[i + 1], c.r)) return true
}
return false
}
const encloses = (frame: Rect, n: Rect) =>
frame.x <= n.x + 1 &&
frame.y <= n.y + 1 &&
frame.x + frame.w >= n.x + n.w - 1 &&
frame.y + frame.h >= n.y + n.h - 1
/** Is this point inside any frame? Routing inside a frame is normal. */
const insideAnyFrame = (px: number, py: number) =>
frames.some(
(c) =>
px > c.x + 1 &&
px < c.x + c.w - 1 &&
py > c.y + 1 &&
py < c.y + c.h - 1,
)
/**
* Is this segment badly placed relative to the frames, even though it hits nothing?
*
* Two ways it can be:
*
* - It runs ALONGSIDE a border, within BORDER_MARGIN of it. That looks like a line
* trying and failing to be the frame's edge. Only counted when the segment is
* outside every frame: inside one, running near the wall is unavoidable and fine.
* - It passes THROUGH a frame that contains exactly one of the two endpoints. The
* line then appears to belong to that frame's contents when it does not — this is
* the case where an edge from outside a VPC cuts across the whole VPC interior on
* its way somewhere else.
*/
const segAlongFrame = (
p: Point,
q: Point,
a: Rect | null,
b: Rect | null,
): boolean => {
const vertical = Math.abs(p.x - q.x) < 1
const lo = vertical ? Math.min(p.y, q.y) : Math.min(p.x, q.x)
const hi = vertical ? Math.max(p.y, q.y) : Math.max(p.x, q.x)
// A short segment is a connector stub, not a run along a wall.
if (hi - lo < MIN_RUN) return false
const mid = (lo + hi) / 2
if (!insideAnyFrame(vertical ? p.x : mid, vertical ? mid : p.y)) {
for (const c of frames) {
const borders = vertical ? [c.x, c.x + c.w] : [c.y, c.y + c.h]
const cLo = vertical ? c.y : c.x
const cHi = vertical ? c.y + c.h : c.x + c.w
const shared = Math.min(hi, cHi) - Math.max(lo, cLo)
if (shared <= MIN_RUN) continue
for (const border of borders)
if (
Math.abs((vertical ? p.x : p.y) - border) <
BORDER_MARGIN
)
return true
}
}
if (a && b)
for (const c of frames) {
const across = vertical
? p.x > c.x + 8 && p.x < c.x + c.w - 8
: p.y > c.y + 8 && p.y < c.y + c.h - 8
if (!across) continue
const cLo = vertical ? c.y : c.x
const cHi = vertical ? c.y + c.h : c.x + c.w
if (Math.min(hi, cHi) - Math.max(lo, cLo) <= MIN_RUN) continue
// Exactly one endpoint inside → the segment is trespassing.
if (encloses(c, a) !== encloses(c, b)) return true
}
return false
}
const pathAlongFrame = (
pts: Point[],
a: Rect | null,
b: Rect | null,
): boolean => {
for (let i = 0; i < pts.length - 1; i++)
if (segAlongFrame(pts[i], pts[i + 1], a, b)) return true
return false
}
/** How many segments of this path are badly placed relative to the frames. */
const frameOffences = (
pts: Point[],
a: Rect | null,
b: Rect | null,
): number => {
let n = 0
for (let i = 0; i < pts.length - 1; i++)
if (segAlongFrame(pts[i], pts[i + 1], a, b)) n++
return n
}
/** Total length of a path, for preferring the shorter of two equally tidy routes. */
const pathLength = (pts: Point[]): number => {
let d = 0
for (let i = 0; i < pts.length - 1; i++)
d +=
Math.abs(pts[i + 1].x - pts[i].x) +
Math.abs(pts[i + 1].y - pts[i].y)
return d
}
// --- stage 1a: which side does each edge leave from?
interface Face {
exit: Side
entry: Side
horiz: boolean
}
const faces: (Face | null)[] = edges.map((e) => {
const a = rects.get(e.source)
const b = rects.get(e.target)
if (!a || !b) return null
const fwdX = b.x + b.w / 2 >= a.x + a.w / 2
const fwdY = b.y + b.h / 2 >= a.y + a.h / 2
const xOverlap = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x)
const yOverlap = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y)
// Prefer the axis the two nodes are separated along: if their vertical extents
// overlap they sit side by side, so the arrow should run horizontally.
const horiz =
yOverlap > 8
? true
: xOverlap > 8
? false
: Math.abs(b.x - a.x) >= Math.abs(b.y - a.y)
return horiz
? { exit: fwdX ? "R" : "L", entry: fwdX ? "L" : "R", horiz: true }
: { exit: fwdY ? "B" : "T", entry: fwdY ? "T" : "B", horiz: false }
})
// --- stage 1b: de-collide ports sharing one (node, side)
const frac = edges.map(() => ({ s: 0.5, t: 0.5 }))
const groups = new Map<string, { i: number; end: "s" | "t" }[]>()
edges.forEach((e, i) => {
const f = faces[i]
if (!f) return
for (const end of ["s", "t"] as const) {
const node = end === "s" ? e.source : e.target
const side = end === "s" ? f.exit : f.entry
const key = `${node}|${side}`
const list = groups.get(key)
if (list) list.push({ i, end })
else groups.set(key, [{ i, end }])
}
})
for (const [key, members] of groups) {
if (members.length < 2) continue
const sepIdx = key.lastIndexOf("|")
const nodeId = key.slice(0, sepIdx)
const side = key.slice(sepIdx + 1) as Side
const node = rects.get(nodeId)
if (!node) continue
const vertical = side === "L" || side === "R"
const nodeCentre = vertical ? node.y + node.h / 2 : node.x + node.w / 2
// Where the far end of each edge sits along this side's axis — the order edges
// should be stacked in, so they do not cross each other on the way out.
const info = members.map((m) => {
const farId = m.end === "s" ? edges[m.i].target : edges[m.i].source
const far = rects.get(farId)
const farCentre = far
? vertical
? far.y + far.h / 2
: far.x + far.w / 2
: nodeCentre
return { m, farCentre }
})
const setFrac = (m: { i: number; end: "s" | "t" }, f: number) => {
if (m.end === "s") frac[m.i].s = f
else frac[m.i].t = f
}
// An edge whose far end is on this side's centre line has a clean straight shot.
// Keep it centred and push the others off, rather than bending all of them.
const aligned = info.filter(
(x) => Math.abs(x.farCentre - nodeCentre) < 8,
)
if (aligned.length === 1 && members.length <= 3) {
setFrac(aligned[0].m, 0.5)
const rest = info.filter((x) => x !== aligned[0])
const below = rest
.filter((x) => x.farCentre <= nodeCentre)
.sort((a, b) => b.farCentre - a.farCentre)
const above = rest
.filter((x) => x.farCentre > nodeCentre)
.sort((a, b) => a.farCentre - b.farCentre)
below.forEach((x, j) => {
setFrac(x.m, 0.3 - j * 0.14)
})
above.forEach((x, j) => {
setFrac(x.m, 0.7 + j * 0.14)
})
} else {
info.sort((a, b) => a.farCentre - b.farCentre)
info.forEach((x, j) => {
setFrac(x.m, (j + 1) / (members.length + 1))
})
}
}
// --- stage 1c: pair opposite edges
//
// A→B and B→A are one relationship drawn as two arrows — "git add" out, "git reset"
// back. Left to the general de-collide they land on port positions chosen for entirely
// separate reasons at each end, so one line runs straight while its partner wanders off
// through a different corridor. A reader expects a matched pair: two parallel lines a
// constant gap apart, one clearly out and one clearly back.
//
// The two tracks are ABSOLUTE positions in the strip where the boxes overlap — the
// corridor's centre ± half a track gap — converted back to a fraction of each box.
// Assigning the same fraction to both boxes instead only works when they happen to be
// the same size and aligned; on real diagrams it put the two lines 79px apart with a
// kink in one of them.
const seen = new Map<string, number>()
edges.forEach((e, i) => {
seen.set(`${e.source}|${e.target}`, i)
})
const PAIR_GAP = 24
const pairedDone = new Set<number>()
edges.forEach((e, i) => {
if (pairedDone.has(i)) return
const j = seen.get(`${e.target}|${e.source}`)
if (j === undefined || j === i || pairedDone.has(j)) return
const fi = faces[i]
const fj = faces[j]
const a = rects.get(e.source)
const b = rects.get(e.target)
if (!fi || !fj || !a || !b) return
// Only pair edges that agree on the axis; when they disagree the geometry wants
// them apart, and forcing them together would fight the search.
if (fi.horiz !== fj.horiz) return
// The strip both boxes span, on the axis ACROSS the arrows. Two straight parallel
// tracks need the corridor to hold them both.
const lo = fi.horiz ? Math.max(a.y, b.y) : Math.max(a.x, b.x)
const hi = fi.horiz
? Math.min(a.y + a.h, b.y + b.h)
: Math.min(a.x + a.w, b.x + b.w)
if (hi - lo < PAIR_GAP + 12) return
const mid = (lo + hi) / 2
const t1 = mid - PAIR_GAP / 2
const t2 = mid + PAIR_GAP / 2
const fracOf = (r: Rect, track: number) =>
fi.horiz ? (track - r.y) / r.h : (track - r.x) / r.w
pairedDone.add(i)
pairedDone.add(j)
frac[i].s = fracOf(a, t1)
frac[i].t = fracOf(b, t1)
frac[j].s = fracOf(b, t2)
frac[j].t = fracOf(a, t2)
})
// --- stage 2: try shapes in order of directness, keep the first that is clear
//
// Segments already claimed by a routed edge, so later edges can avoid sharing a lane
// rather than relying on the nudge pass to separate them afterwards.
const usedSegs: { x1: number; y1: number; x2: number; y2: number }[] = []
const overlap1 = (a0: number, a1: number, b0: number, b1: number) =>
Math.min(a1, b1) - Math.max(a0, b0)
/** Does this path run along a lane an earlier edge already occupies? */
const overlapsUsed = (pts: Point[]): boolean => {
for (let i = 0; i < pts.length - 1; i++) {
const p = pts[i]
const q = pts[i + 1]
const vertical = Math.abs(p.x - q.x) < 1
for (const s of usedSegs) {
const sVertical = Math.abs(s.x1 - s.x2) < 1
if (vertical !== sVertical) continue
if (vertical) {
if (Math.abs(p.x - s.x1) >= 6) continue
if (
overlap1(
Math.min(p.y, q.y),
Math.max(p.y, q.y),
Math.min(s.y1, s.y2),
Math.max(s.y1, s.y2),
) > 14
)
return true
} else {
if (Math.abs(p.y - s.y1) >= 6) continue
if (
overlap1(
Math.min(p.x, q.x),
Math.max(p.x, q.x),
Math.min(s.x1, s.x2),
Math.max(s.x1, s.x2),
) > 14
)
return true
}
}
}
return false
}
const claimLanes = (pts: Point[]) => {
for (let i = 0; i < pts.length - 1; i++)
usedSegs.push({
x1: pts[i].x,
y1: pts[i].y,
x2: pts[i + 1].x,
y2: pts[i + 1].y,
})
}
const routes: {
exitSide: Side
entrySide: Side
wp: Point[]
/** The router had to bend around something — a straight line would have hit it. */
avoided: boolean
}[] = []
/**
* Every two-bend route that is geometrically distinct, ranked; the cheapest clear one.
*
* The ladder above tries a fixed list of shapes and, crucially, decides the axis BEFORE it
* searches — so when a vertical corridor is the only clean option and the axis came out
* horizontal, the answer is in the half that was never looked at. This does not choose an
* axis: it tries both trunk directions and all four sides at each end.
*
* The lanes come from the obstacles themselves rather than a fixed step. Whether a lane
* collides can only change where it crosses an obstacle's boundary, so one lane taken from
* each gap between boundaries covers every distinct outcome — sampling every 10px would
* test the same corridor repeatedly and still miss a narrow one.
*/
const complete = (
a: Rect,
b: Rect,
exempt: Set<string>,
sf: number,
tf: number,
): {
exitSide: Side
entrySide: Side
wp: Point[]
avoided: boolean
sf: number
tf: number
} | null => {
const blockers = cards.filter((c) => !exempt.has(c.id))
// Candidate trunk positions: just outside each obstacle edge, and the middle of each
// gap between consecutive edges.
const linesFrom = (
values: number[],
lo: number,
hi: number,
): number[] => {
const sorted = [...new Set(values)].sort((p, q) => p - q)
const out = [lo, hi]
for (const v of sorted) {
out.push(v - MARGIN - 1, v + MARGIN + 1)
}
for (let k = 0; k + 1 < sorted.length; k++)
out.push(Math.round((sorted[k] + sorted[k + 1]) / 2))
return [...new Set(out)]
}
const xLanes = linesFrom(
blockers.flatMap((c) => [c.r.x, c.r.x + c.r.w]),
Math.min(a.x, b.x) - 40,
Math.max(a.x + a.w, b.x + b.w) + 40,
)
const yLanes = linesFrom(
blockers.flatMap((c) => [c.r.y, c.r.y + c.r.h]),
Math.min(a.y, b.y) - 40,
Math.max(a.y + a.h, b.y + b.h) + 40,
)
const spread = [0.5, 0.3, 0.7, 0.16, 0.84]
const portTries: [number, number][] = [[sf, tf]]
for (const p of spread) for (const q of spread) portTries.push([p, q])
let best: {
exitSide: Side
entrySide: Side
wp: Point[]
avoided: boolean
/** Fractions this route needs; they may differ from the assigned pair. */
sf: number
tf: number
} | null = null
let bestCost = Number.POSITIVE_INFINITY
const consider = (
es: Side,
en: Side,
shape: Shape,
pf: number,
qf: number,
) => {
const g = shapePoints(a, b, es, en, pf, qf, shape)
const pts = [g.sp, ...g.wp, g.ep]
if (pathHits(pts, exempt)) return
if (doublesBack(pts, a, b)) return
const cost =
frameOffences(pts, a, b) * 5000 +
(overlapsUsed(pts) ? 700 : 0) +
g.wp.length * 80 +
pathLength(pts)
if (cost < bestCost) {
bestCost = cost
best = {
exitSide: es,
entrySide: en,
wp: g.wp,
avoided: g.wp.length > 0,
sf: pf,
tf: qf,
}
}
}
// A vertical trunk leaves and enters through a left or right side; a horizontal one
// through a top or bottom. Both ends are tried on both sides, which is what lets an
// arrow leave the way it has to rather than the way the axis guess expected.
//
// The port FRACTIONS are searched too, not just the sides. The de-collide pass assigns
// one fraction per (node, side) before any path is known, purely to stop several
// arrows stacking on one point; when that guess leaves every route dirty, a Z's short
// stubs are what clip the box, and no choice of trunk lane can help. Spreading ports
// is a tidiness preference, and not drawing a line through a shape outranks it — so
// the assigned fraction is tried first and alternatives only if it fails.
for (const [pf, qf] of portTries) {
for (const lane of xLanes)
for (const es of ["L", "R"] as const)
for (const en of ["L", "R"] as const)
consider(es, en, { kind: "Zx", lane }, pf, qf)
for (const lane of yLanes)
for (const es of ["T", "B"] as const)
for (const en of ["T", "B"] as const)
consider(es, en, { kind: "Zy", lane }, pf, qf)
// One-bend routes, which are tidier when they happen to be clear.
for (const es of ["L", "R", "T", "B"] as const)
for (const en of ["L", "R", "T", "B"] as const) {
consider(es, en, { kind: "Lhv" }, pf, qf)
consider(es, en, { kind: "Lvh" }, pf, qf)
}
// Stop at the first fraction pair that yields a clean route: the assigned one is
// first, so a tidy answer is preferred whenever it exists.
if (best) break
}
return best
}
edges.forEach((e, i) => {
const f = faces[i]
const a = rects.get(e.source)
const b = rects.get(e.target)
if (!f || !a || !b) {
routes.push({
exitSide: "R",
entrySide: "L",
wp: [],
avoided: false,
})
return
}
const exempt = new Set([e.source, e.target])
const sf = frac[i].s
const tf = frac[i].t
/**
* Try one candidate. `strict` also rejects a path that is merely badly placed
* relative to the frames — running alongside a border, or cutting through a frame
* only one endpoint belongs to.
*
* Every shape is tried strictly first and the whole ladder re-run relaxed, so a
* tidier route always wins over a nearer one, and an edge that has no tidy option
* still gets a sensible path rather than the fallback.
*/
const attempt = (
exitSide: Side,
entrySide: Side,
shape: Shape,
strict: boolean,
): Point[] | null => {
const g = shapePoints(a, b, exitSide, entrySide, sf, tf, shape)
const pts = [g.sp, ...g.wp, g.ep]
if (pathHits(pts, exempt)) return null
// An arrow that leaves its own shape and immediately turns back across it reads as
// a mistake, and the obstacle test cannot see it: an edge is exempt from its own
// endpoints. Refused outright rather than scored, since there is never a reason to
// prefer it — the generator below will find a route that leaves cleanly.
if (doublesBack(pts, a, b)) return null
if (strict && pathAlongFrame(pts, a, b)) return null
// Strict mode also declines a lane an earlier edge already runs along. Waiting
// for the nudge pass to pull them apart afterwards is worse: it can only move
// a segment so far before it hits something, so two edges that both picked the
// corridor's centre may stay overlapping.
if (strict && overlapsUsed(pts)) return null
return g.wp
}
/**
* The ladder of candidate shapes, most direct first.
*
* Run once refusing anything that hugs or trespasses on a frame, then again with
* that relaxed. So a tidy longer route beats an untidy shorter one, and an edge
* with no tidy option still gets a real path instead of the fallback.
*/
const ladder = (
strict: boolean,
): {
exitSide: Side
entrySide: Side
wp: Point[]
avoided: boolean
} | null => {
// straight, when the two ports already line up
const aligned = f.horiz
? Math.abs(a.y + sf * a.h - (b.y + tf * b.h)) < 2
: Math.abs(a.x + sf * a.w - (b.x + tf * b.w)) < 2
if (aligned) {
const wp = attempt(
f.exit,
f.entry,
{ kind: "straight" },
strict,
)
if (wp)
return {
exitSide: f.exit,
entrySide: f.entry,
wp,
avoided: false,
}
}
// A Z whose middle leg sits in the gap between the two nodes.
//
// The gap runs from the trailing edge of whichever node comes first to the
// leading edge of the other. Taking min/max of both edges instead would span
// the whole distance between them, including anything parked in between — so
// the sweep would happily place the leg on top of an icon it is meant to
// route around.
if (f.horiz) {
const aFirst = a.x <= b.x
const lo = aFirst ? a.x + a.w : b.x + b.w
const hi = aFirst ? b.x : a.x
for (const lane of laneSweep(lo, hi)) {
const wp = attempt(
f.exit,
f.entry,
{ kind: "Zx", lane },
strict,
)
if (wp)
return {
exitSide: f.exit,
entrySide: f.entry,
wp,
avoided: true,
}
}
} else {
const aFirst = a.y <= b.y
const lo = aFirst ? a.y + a.h : b.y + b.h
const hi = aFirst ? b.y : a.y
for (const lane of laneSweep(lo, hi)) {
const wp = attempt(
f.exit,
f.entry,
{ kind: "Zy", lane },
strict,
)
if (wp)
return {
exitSide: f.exit,
entrySide: f.entry,
wp,
avoided: true,
}
}
}
// an L, turning once — this needs a different side at one end
const downward = b.y + b.h / 2 >= a.y + a.h / 2
const rightward = b.x + b.w / 2 >= a.x + a.w / 2
const lCandidates: [Side, Side, Shape][] = f.horiz
? [
[f.exit, downward ? "T" : "B", { kind: "Lhv" }],
[downward ? "B" : "T", f.entry, { kind: "Lvh" }],
]
: [
[f.exit, rightward ? "L" : "R", { kind: "Lvh" }],
[rightward ? "R" : "L", f.entry, { kind: "Lhv" }],
]
for (const [es, en, shape] of lCandidates) {
const wp = attempt(es, en, shape, strict)
if (wp)
return { exitSide: es, entrySide: en, wp, avoided: true }
}
// A detour: out of the way, across, and back. Two bends, which is what it
// takes to get past something sitting directly between the two nodes — a Z's
// middle leg runs along the blocked axis and an L only turns once, so neither
// can clear it.
const blockers = cards.filter((c) => !exempt.has(c.id))
const detour = f.horiz
? (() => {
const spanLo = Math.min(a.x, b.x)
const spanHi = Math.max(a.x + a.w, b.x + b.w)
const between = blockers.filter(
(c) => c.r.x + c.r.w > spanLo && c.r.x < spanHi,
)
if (between.length === 0) return null
const top = Math.min(...between.map((c) => c.r.y))
const bottom = Math.max(
...between.map((c) => c.r.y + c.r.h),
)
const aMid = a.y + a.h / 2
const goUp =
Math.abs(aMid - top) <= Math.abs(bottom - aMid)
const lane = goUp
? top - MARGIN - 14
: bottom + MARGIN + 14
const side: Side = goUp ? "T" : "B"
return {
exitSide: side,
entrySide: side,
wp: [
{ x: portPoint(a, side, sf).x, y: lane },
{ x: portPoint(b, side, tf).x, y: lane },
],
}
})()
: (() => {
const spanLo = Math.min(a.y, b.y)
const spanHi = Math.max(a.y + a.h, b.y + b.h)
const between = blockers.filter(
(c) => c.r.y + c.r.h > spanLo && c.r.y < spanHi,
)
if (between.length === 0) return null
const left = Math.min(...between.map((c) => c.r.x))
const right = Math.max(
...between.map((c) => c.r.x + c.r.w),
)
const aMid = a.x + a.w / 2
const goLeft =
Math.abs(aMid - left) <= Math.abs(right - aMid)
const lane = goLeft
? left - MARGIN - 14
: right + MARGIN + 14
const side: Side = goLeft ? "L" : "R"
return {
exitSide: side,
entrySide: side,
wp: [
{ x: lane, y: portPoint(a, side, sf).y },
{ x: lane, y: portPoint(b, side, tf).y },
],
}
})()
if (detour) {
const sp = portPoint(a, detour.exitSide, sf)
const ep = portPoint(b, detour.entrySide, tf)
const pts = [sp, ...detour.wp, ep]
const ok =
!pathHits(pts, exempt) &&
(!strict || !pathAlongFrame(pts, a, b))
if (ok)
return {
exitSide: detour.exitSide,
entrySide: detour.entrySide,
wp: detour.wp,
avoided: true,
}
}
return null
}
/**
* Score every candidate shape and return the cheapest.
*
* Weights, in the reference router's proportions: an icon hit is disqualifying, a
* frame offence costs far more than a bend, a bend costs more than distance. So a
* route that trespasses on one frame beats one that trespasses on two, and among
* equals the shorter and straighter wins.
*/
const cheapest = (): {
exitSide: Side
entrySide: Side
wp: Point[]
avoided: boolean
} | null => {
const candidates: [Side, Side, Shape][] = []
candidates.push([f.exit, f.entry, { kind: "straight" }])
if (f.horiz) {
const aFirst = a.x <= b.x
const gapLo = aFirst ? a.x + a.w : b.x + b.w
const gapHi = aFirst ? b.x : a.x
for (const lane of laneSweep(gapLo, gapHi))
candidates.push([f.exit, f.entry, { kind: "Zx", lane }])
// Also consider lanes outside the gap: when the gap is narrow or blocked,
// going around the outside can be much tidier.
for (const lane of laneSweep(
Math.min(a.x, b.x) - 140,
Math.max(a.x + a.w, b.x + b.w) + 140,
))
candidates.push([f.exit, f.entry, { kind: "Zx", lane }])
} else {
const aFirst = a.y <= b.y
const gapLo = aFirst ? a.y + a.h : b.y + b.h
const gapHi = aFirst ? b.y : a.y
for (const lane of laneSweep(gapLo, gapHi))
candidates.push([f.exit, f.entry, { kind: "Zy", lane }])
for (const lane of laneSweep(
Math.min(a.y, b.y) - 140,
Math.max(a.y + a.h, b.y + b.h) + 140,
))
candidates.push([f.exit, f.entry, { kind: "Zy", lane }])
}
const downward = b.y + b.h / 2 >= a.y + a.h / 2
const rightward = b.x + b.w / 2 >= a.x + a.w / 2
candidates.push([f.exit, downward ? "T" : "B", { kind: "Lhv" }])
candidates.push([downward ? "B" : "T", f.entry, { kind: "Lvh" }])
candidates.push([f.exit, rightward ? "L" : "R", { kind: "Lvh" }])
candidates.push([rightward ? "R" : "L", f.entry, { kind: "Lhv" }])
let best: {
exitSide: Side
entrySide: Side
wp: Point[]
avoided: boolean
} | null = null
let bestCost = Number.POSITIVE_INFINITY
for (const [es, en, shape] of candidates) {
const g = shapePoints(a, b, es, en, sf, tf, shape)
const pts = [g.sp, ...g.wp, g.ep]
if (pathHits(pts, exempt)) continue
if (doublesBack(pts, a, b)) continue
// Sharing a lane with an existing edge is weighed as heavily as trespassing
// on a frame. Two lines drawn on top of each other are indistinguishable —
// strictly worse to read than one line crossing a border it has to cross
// anyway. Cheaper weights here made the search accept an overlap in order
// to save one frame crossing.
const cost =
frameOffences(pts, a, b) * 500 +
(overlapsUsed(pts) ? 700 : 0) +
g.wp.length * 80 +
pathLength(pts)
if (cost < bestCost) {
bestCost = cost
best = {
exitSide: es,
entrySide: en,
wp: g.wp,
avoided: g.wp.length > 0,
}
}
}
return best
}
/**
* The graph search, for the edges a bounded candidate list cannot express.
*
* Same shape as `complete` — try the side pairs, keep the cheapest clear result — but
* each attempt is a full obstacle-avoiding search rather than one fixed shape, so it
* finds staircases of any number of bends. A* is optimal for a GIVEN pair of sides,
* which is why the sides are still enumerated out here.
*/
const viaGraph = (
a: Rect,
b: Rect,
exempt: Set<string>,
sf: number,
tf: number,
): {
exitSide: Side
entrySide: Side
wp: Point[]
avoided: boolean
sf: number
tf: number
} | null => {
const obstacles = cards
.filter((c) => !exempt.has(c.id))
.map((c) => c.r)
let best: {
exitSide: Side
entrySide: Side
wp: Point[]
avoided: boolean
sf: number
tf: number
} | null = null
let bestCost = Number.POSITIVE_INFINITY
for (const es of ["L", "R", "T", "B"] as const)
for (const en of ["L", "R", "T", "B"] as const) {
const sp = portPoint(a, es, sf)
const ep = portPoint(b, en, tf)
const path = routeOrthogonal(
sp,
ep,
SIDE_DIR[es],
// The path must ARRIVE heading into the target's side, which is the
// reverse of the direction that side faces.
(SIDE_DIR[en] + 2) % 4,
obstacles,
{ xs: [sp.x, ep.x], ys: [sp.y, ep.y] },
)
if (!path || path.length < 2) continue
const wp = path.slice(1, -1)
const pts = [sp, ...wp, ep]
if (pathHits(pts, exempt)) continue
if (doublesBack(pts, a, b)) continue
const cost =
frameOffences(pts, a, b) * 5000 +
(overlapsUsed(pts) ? 700 : 0) +
wp.length * 80 +
pathLength(pts)
if (cost < bestCost) {
bestCost = cost
best = {
exitSide: es,
entrySide: en,
wp,
avoided: wp.length > 0,
sf,
tf,
}
}
}
return best
}
// Strict first: a route that offends no frame wins outright. Failing that, score
// every candidate and take the least-bad one.
//
// Scoring is not optional here. Some edges CANNOT satisfy the strict rule: when one
// endpoint sits inside a VPC and the other outside it, every possible path
// trespasses on that frame. Accept-or-reject leaves those edges unoptimised — the
// relaxed pass takes whatever it happens to try first, which is how a line ends up
// cutting diagonally across a whole VPC. Weighing the offences instead picks the
// path that trespasses least and is shortest.
// A bounded candidate search covers all but a fraction of edges and produces tidier
// routes, so it goes first; the graph search is the backstop for what it cannot do.
// Measured over 250 generated flowcharts: the candidate search leaves 27 arrows
// crossing a box out of 2722 edges, and 21 of those need three bends — which is
// exactly the case a two-bend enumeration cannot express.
const viaComplete =
complete(a, b, exempt, sf, tf) ?? viaGraph(a, b, exempt, sf, tf)
// A route the generator found may need different port positions from the ones the
// de-collide pass assigned; record them, so the emitted connection points match the
// path that was actually verified clear.
if (viaComplete) {
frac[i].s = viaComplete.sf
frac[i].t = viaComplete.tf
}
const chosen = viaComplete ?? ladder(true) ?? cheapest()
if (chosen) {
routes.push(chosen)
// Claim this route's lanes so the edges after it look elsewhere.
claimLanes([
portPoint(a, chosen.exitSide, frac[i].s),
...chosen.wp,
portPoint(b, chosen.entrySide, frac[i].t),
])
return
}
// Nothing was clear even relaxed. Sweep a wider band for a lane that at least
// clears every icon before settling for one that does not — an unconditional
// mid-point corridor was the reference project's own reported failure: it could cut
// straight through nodes.
const wide = f.horiz
? {
lo: Math.min(a.x, b.x) - 160,
hi: Math.max(a.x + a.w, b.x + b.w) + 160,
}
: {
lo: Math.min(a.y, b.y) - 160,
hi: Math.max(a.y + a.h, b.y + b.h) + 160,
}
let fallbackWp: Point[] | null = null
for (const lane of laneSweep(wide.lo, wide.hi)) {
const wp = attempt(
f.exit,
f.entry,
{ kind: f.horiz ? "Zx" : "Zy", lane },
false,
)
if (wp) {
fallbackWp = wp
break
}
}
if (!fallbackWp) {
const lane = f.horiz
? Math.round((a.x + a.w + b.x) / 2)
: Math.round((a.y + a.h + b.y) / 2)
fallbackWp = shapePoints(a, b, f.exit, f.entry, sf, tf, {
kind: f.horiz ? "Zx" : "Zy",
lane,
}).wp
}
routes.push({
exitSide: f.exit,
entrySide: f.entry,
wp: fallbackWp,
avoided: true,
})
claimLanes([
portPoint(a, f.exit, sf),
...fallbackWp,
portPoint(b, f.entry, tf),
])
})
// --- stage 3: nudge parallel segments apart
// Absolute point paths, which the nudge pass mutates in place.
const paths: (Point[] | null)[] = edges.map((e, i) => {
const a = rects.get(e.source)
const b = rects.get(e.target)
if (!a || !b) return null
const r = routes[i]
const sp = portPoint(a, r.exitSide, frac[i].s)
const ep = portPoint(b, r.entrySide, frac[i].t)
return [sp, ...r.wp.map((p) => ({ x: p.x, y: p.y })), ep]
})
interface Seg {
i: number
axis: "v" | "h"
a: Point
b: Point
pos: number
lo: number
hi: number
tie: number
}
const conflict = (s: Seg, t: Seg) =>
s.axis === t.axis &&
Math.abs(s.pos - t.pos) < SEP &&
Math.min(s.hi, t.hi) - Math.max(s.lo, t.lo) > 8
// Repeat: moving one segment can bring it within SEP of a bundle it was not grouped
// with, and a single pass would leave that new conflict unresolved.
for (let pass = 0; pass < 3; pass++) {
const segs: Seg[] = []
paths.forEach((P, i) => {
if (!P) return
// Skip the terminal segments: they touch a port, which is fixed.
for (let k = 1; k < P.length - 2; k++) {
const p = P[k]
const q = P[k + 1]
if (Math.abs(p.x - q.x) < 1 && Math.abs(p.y - q.y) >= 1)
segs.push({
i,
axis: "v",
a: P[k],
b: P[k + 1],
pos: p.x,
lo: Math.min(p.y, q.y),
hi: Math.max(p.y, q.y),
tie: P[k - 1].x + P[k + 2].x,
})
else if (Math.abs(p.y - q.y) < 1 && Math.abs(p.x - q.x) >= 1)
segs.push({
i,
axis: "h",
a: P[k],
b: P[k + 1],
pos: p.y,
lo: Math.min(p.x, q.x),
hi: Math.max(p.x, q.x),
tie: P[k - 1].y + P[k + 2].y,
})
}
})
// Group overlapping parallel segments into bundles (connected components).
const comp = segs.map(() => -1)
let next = 0
for (let x = 0; x < segs.length; x++) {
if (comp[x] === -1) comp[x] = next++
for (let y = x + 1; y < segs.length; y++) {
if (!conflict(segs[x], segs[y])) continue
if (comp[y] === -1) comp[y] = comp[x]
else if (comp[y] !== comp[x]) {
const from = comp[y]
const to = comp[x]
for (let z = 0; z < segs.length; z++)
if (comp[z] === from) comp[z] = to
}
}
}
const bundles = new Map<number, Seg[]>()
segs.forEach((s, idx) => {
const list = bundles.get(comp[idx])
if (list) list.push(s)
else bundles.set(comp[idx], [s])
})
let moved = 0
for (const bundle of bundles.values()) {
if (bundle.length < 2) continue
// Order by current track, then by where the segment's neighbours are, so the
// spread does not introduce new crossings.
bundle.sort((a, b) => a.pos - b.pos || a.tie - b.tie)
const centre = bundle.reduce((s, x) => s + x.pos, 0) / bundle.length
bundle.forEach((s, j) => {
const target = Math.round(
centre + (j - (bundle.length - 1) / 2) * SEP,
)
if (target === s.pos) return
const P = paths[s.i]
if (!P) return
const e = edges[s.i]
const exempt = new Set([e.source, e.target])
const sa = rects.get(e.source) ?? null
const sb = rects.get(e.target) ?? null
// Whether this path already had a frame problem: if so, one more is not
// the nudge's fault and should not block a tidier spread.
const alongBefore = pathAlongFrame(P, sa, sb)
const before = s.pos
if (s.axis === "v") {
s.a.x = target
s.b.x = target
} else {
s.a.y = target
s.b.y = target
}
// Revert a move that makes the path WORSE — through an icon, or newly
// hugging a frame border. Tidier is not worth less correct.
const worse =
pathHits(P, exempt) ||
(!alongBefore && pathAlongFrame(P, sa, sb))
if (worse) {
if (s.axis === "v") {
s.a.x = before
s.b.x = before
} else {
s.a.y = before
s.b.y = before
}
} else {
s.pos = target
moved++
}
})
}
if (!moved) break
}
// --- stage 3b: a final check on the whole path
//
// The nudge pass judges each segment it moves on its own and reverts that segment if the
// path got worse. That is not the same as the path being clean: two segments can each be
// acceptable in isolation while their combination clips a box, and a revert restores only
// the segment last touched. Re-checking the finished path and restoring the route the
// search chose is what makes the guarantee hold end to end.
paths.forEach((P, i) => {
if (!P) return
const e = edges[i]
const exempt = new Set([e.source, e.target])
const a = rects.get(e.source)
const b = rects.get(e.target)
if (!a || !b) return
if (!pathHits(P, exempt) && !doublesBack(P, a, b)) return
const r = routes[i]
const restored = [
portPoint(a, r.exitSide, frac[i].s),
...r.wp.map((p) => ({ x: p.x, y: p.y })),
portPoint(b, r.entrySide, frac[i].t),
]
if (!pathHits(restored, exempt) && !doublesBack(restored, a, b))
paths[i] = restored
})
// --- emit
const sideFraction = (side: Side, f: number) =>
side === "L"
? { x: 0, y: f }
: side === "R"
? { x: 1, y: f }
: side === "T"
? { x: f, y: 0 }
: { x: f, y: 1 }
const round3 = (v: number) => Math.round(v * 1000) / 1000
const clamp01 = (v: number) => Math.max(0.04, Math.min(0.96, v))
/**
* Move a port to the side the adjacent waypoint actually arrives from.
*
* The side is chosen before the path is known, so on a bent route the two can end up
* disagreeing: the search settles on, say, a bottom entry while the last leg comes in
* from above. draw.io then draws the terminal segment straight THROUGH the icon to
* reach the far-side port — an arrow that appears to pierce the shape it points at.
*
* Snapping is only meaningful for a bent route: a straight one connects two aligned
* ports and cannot pierce anything. When the waypoint sits diagonally off a corner
* there is no single side it arrives from, so the router's original choice stands.
*/
const snapPort = (
n: Rect,
adjacent: Point,
fallback: { x: number; y: number },
): { x: number; y: number } => {
const withinX = adjacent.x > n.x + 1 && adjacent.x < n.x + n.w - 1
const withinY = adjacent.y > n.y + 1 && adjacent.y < n.y + n.h - 1
if (withinX === withinY) return fallback
const cx = n.x + n.w / 2
const cy = n.y + n.h / 2
return withinX
? {
x: clamp01((adjacent.x - n.x) / n.w),
y: adjacent.y <= cy ? 0 : 1,
}
: {
x: adjacent.x <= cx ? 0 : 1,
y: clamp01((adjacent.y - n.y) / n.h),
}
}
return edges.map((e, i) => {
const r = routes[i]
const P = paths[i]
// Drop points the nudge made collinear or duplicate — draw.io renders a redundant
// waypoint as a visible kink.
let wp: Point[] = []
if (P && P.length > 2) {
const kept: Point[] = [P[0]]
for (let k = 1; k < P.length - 1; k++) {
const prev = kept[kept.length - 1]
const cur = P[k]
const nxt = P[k + 1]
const collinear =
(Math.abs(prev.x - cur.x) < 1 &&
Math.abs(cur.x - nxt.x) < 1) ||
(Math.abs(prev.y - cur.y) < 1 &&
Math.abs(cur.y - nxt.y) < 1)
if (collinear) continue
if (
Math.abs(prev.x - cur.x) < 1 &&
Math.abs(prev.y - cur.y) < 1
)
continue
kept.push(cur)
}
wp = kept.slice(1)
}
let exit = sideFraction(r.exitSide, frac[i].s)
let entry = sideFraction(r.entrySide, frac[i].t)
// On a bent route, make each port face where its leg actually comes from.
const src = rects.get(e.source)
const tgt = rects.get(e.target)
if (wp.length > 0) {
if (src) exit = snapPort(src, wp[0], exit)
if (tgt) entry = snapPort(tgt, wp[wp.length - 1], entry)
}
return {
id: e.id,
// Round both axes: the fraction lands in y for a left/right side and in x for
// a top/bottom one.
exit: { x: round3(exit.x), y: round3(exit.y) },
entry: { x: round3(entry.x), y: round3(entry.y) },
waypoints: wp,
// Freeze only what a re-route would get wrong: a labelled bend (the label
// needs a straight segment under it) or a deliberate detour.
freeze: wp.length > 0 && (e.hasLabel || r.avoided),
}
})
}