Files
next-ai-draw-io/lib/diagram-engine/route.ts
dayuan.jiang 00ebf91b90 fix(diagram-engine): eliminate arrows drawn through boxes, complete the route search
A user's approval-workflow flowchart came out with the return arrow drawn
straight through two unrelated steps, and a second report showed arrows
leaving a box and bending straight back across it. Measured over 250
generated flowcharts (2722 edges): 347 arrows crossed an unrelated box and
215 waypoints landed inside a shape. Four defects, each measured in
isolation:

1. The invisible layer containers draw_graph emits were handed to the
   router as frames, so every clean return path was rejected for
   "trespassing" on a border that is not drawn, and the fallback cut
   through two boxes. Excluding invisible containers: 347 -> 218 crossing
   arrows, no diagram made worse.

2. The router chose the horizontal-vs-vertical axis BEFORE searching, so
   when the only clean corridor ran along the other axis it was never
   looked at. A complete two-bend candidate generator that tries both
   trunk axes, all four sides at each end, and the port fractions:
   218 -> 27. (An independent ablation measured the axis pre-choice alone
   at a 40% per-edge failure rate.)

3. Nothing stopped a route's first leg from turning back across its own
   source shape - the obstacle test exempts an edge's own endpoints, and
   must, since the line has to touch them. A terminal-leg rule refuses
   such routes outright: 215 -> 4 hooks.

4. A two-bend search cannot express the staircase needed when a box sits
   directly between two vertically aligned nodes (21 of the last 27
   crossings). Added the orthogonal visibility graph + A* from Wybrow,
   Marriott & Stuckey, "Orthogonal Connector Routing" (GD 2009) - the
   libavoid algorithm - as the backstop when the candidate search finds
   nothing. The interesting-points grid is provably sufficient: any valid
   route shrinks onto it without getting longer or gaining bends. The A*
   state is (point, incoming direction) with libavoid's bend cost of 10,
   and the admissible bends-remaining heuristic, so it returns a cheapest
   route, not merely a route. Implemented from the paper, not ported.

After all four: 0 crossing arrows and 0 hooks over the same 250 diagrams,
page area unchanged (494k px^2 mean), 260ms for the whole corpus, mean
0.82 bends per edge. The shape ladder still runs first, so routes that
were already clean are byte-identical.

Also post-nudge validation now checks the whole path (the nudge pass only
reverts the single segment it moved, judged in isolation) and restores the
search's route if nudging made it dirty.
2026-08-09 18:18:54 +09:00

1358 lines
54 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 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),
}
})
}