/** * 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, obstacles: Set, containers: Set = 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): 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() 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, 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, 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() 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), } }) }