From 1e4c74d464b3666c9a3c07d47834a4228cd67359 Mon Sep 17 00:00:00 2001 From: "dayuan.jiang" Date: Sun, 9 Aug 2026 12:46:44 +0900 Subject: [PATCH] =?UTF-8?q?feat(diagram-engine):=20edge=20router=20?= =?UTF-8?q?=E2=80=94=20pinned=20ports,=20obstacle=20avoidance,=20cost=20se?= =?UTF-8?q?arch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the reported problem: arrows overlapped on top of icons and ran through shapes they had nothing to do with. The cause was a missing layer, not a bug. render.ts emitted only source and target, so draw.io routed every edge itself — and its router sees the two terminals' bounds and nothing else, not where the other icons are. It therefore ran lines straight through whatever was in between and left several edges leaving one node at the same point. Ported from drawio-ai-kit's router (MIT, see NOTICE): - Port de-collision. Edges leaving the same side of the same node spread along it, ordered by where their far end sits so they do not cross on the way out. An edge with a clean straight shot keeps the centre. - Obstacle avoidance. Candidate shapes in order of directness — straight, a Z through the gap, an L, a two-bend detour — each tested against every icon on the page. - Frame placement. A frame is passable (an edge into a VPC must cross its border) but not free: running alongside a border, or cutting through a frame that holds only one of the two endpoints, is penalised. - Port snapping. On a bent route, each port moves to the side its leg actually arrives from. Without this the terminal segment can pierce the icon to reach a far-side port. - Lane claiming. Each routed edge records the lanes it occupies so later edges avoid them, rather than colliding and being pulled apart afterwards. - Global nudge, three passes, reverting any move that makes a path worse. Two things I got wrong on the way, both caught by looking at real geometry: - The Z corridor was computed as min/max of both nodes' edges, which spans the whole distance between them — including anything parked in between. So the lane sweep would place the detour's middle leg on top of the very icon it was avoiding. It has to be the gap: trailing edge of the first node to the leading edge of the other. - The router was fed layout's slot rectangles, but an icon's cell is the glyph square centred in a slot roughly twice as wide. Collision tests against slots both missed real overlaps and invented false ones. Extracted cellRect() so the router and the emitted XML cannot diverge. Accept-or-reject was not enough on its own. When one endpoint is inside a VPC and the other outside, EVERY path trespasses on that frame, so the strict rule always fails and the relaxed pass took whatever it tried first — which is how a line ended up cutting across a whole VPC. Candidates are now scored (frame offences 500, lane sharing 700, bends 80, length 1) and the cheapest wins, so an edge that must trespass still gets the least-bad route. Waypoints are still written only when load-bearing — a labelled bend or a deliberate detour — so an unobstructed edge stays drag-friendly. 455 unit tests (27 new, asserting produced geometry rather than algorithm shape). Verified by rendering the reported diagram in the real editor: the two arrows that overlapped on the EC2 icon now leave from different sides, and the load balancer's fan-out leaves from three distinct points. --- lib/diagram-engine/render.ts | 125 ++- lib/diagram-engine/route.ts | 1072 +++++++++++++++++++++++ tests/unit/diagram-engine-route.test.ts | 602 +++++++++++++ 3 files changed, 1773 insertions(+), 26 deletions(-) create mode 100644 lib/diagram-engine/route.ts create mode 100644 tests/unit/diagram-engine-route.test.ts diff --git a/lib/diagram-engine/render.ts b/lib/diagram-engine/render.ts index 37178d8..b811d91 100644 --- a/lib/diagram-engine/render.ts +++ b/lib/diagram-engine/render.ts @@ -10,6 +10,7 @@ import { flatten, ICON_SIZE, layoutForest, type Placed } from "./layout" import { stampContainer, stampLeaf } from "./markers" +import { type RoutedEdge, routeEdges } from "./route" import type { DiagramNode, DiagramTree, LinkSpec, Rect } from "./types" /** Escape the five characters that would break an XML attribute. */ @@ -104,6 +105,31 @@ function styleFor(n: DiagramNode, resolve: StyleResolver | undefined): string { * outside it. Emitting the padded slot would both stretch the glyph and — because the * padding depends on the label length — make the size grow on every round-trip. */ +/** + * The rectangle a node actually occupies in the XML. + * + * For everything except an icon this is the slot layout measured. An icon's slot is wider + * and taller than the glyph, to leave room for the label underneath, but the cell itself + * is the glyph square centred in that slot. + * + * The router has to use this, not the slot: a slot is roughly twice the glyph's width, so + * collision tests against slots both miss real overlaps and invent false ones. + */ +export function cellRect( + n: DiagramNode, + slot: Rect, + defaultGlyph: number, +): Rect { + if (n.kind !== "icon") return slot + const glyph = n.size ?? defaultGlyph + return { + x: Math.round(slot.x + (slot.w - glyph) / 2), + y: slot.y, + w: glyph, + h: glyph, + } +} + function vertexXml( n: DiagramNode, rect: Rect, @@ -114,16 +140,7 @@ function vertexXml( ): string { const ox = parentRect?.x ?? 0 const oy = parentRect?.y ?? 0 - let box = rect - if (n.kind === "icon") { - const glyph = n.size ?? defaultGlyph - box = { - x: Math.round(rect.x + (rect.w - glyph) / 2), - y: rect.y, - w: glyph, - h: glyph, - } - } + const box = cellRect(n, rect, defaultGlyph) return ( `` + @@ -132,31 +149,51 @@ function vertexXml( ) } +/** The label an edge renders, with its step number prefixed. */ +function edgeLabel(l: LinkSpec): string { + if (l.step == null) return l.label ?? "" + return l.label ? `${l.step}. ${l.label}` : `${l.step}.` +} + /** - * One `` for an edge. + * One `` for an edge, carrying the route the router computed. * - * No waypoints: draw.io's own orthogonal router recomputes the route from the terminals - * on every edit, so a user who moves a node never has to re-link an arrow. Freezing a - * pre-computed route would look better on first open and then deform the moment anyone - * touched the diagram — the wrong trade for an editor. + * Connection points are always written. 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. Without them draw.io picks the side itself, knowing only the two + * terminals and nothing about the other icons, which is how arrows end up running through + * unrelated shapes and stacking several on one point. + * + * Waypoints are absolute, so draw.io keeps them after a drag and the route deforms. They + * are written only when the router says they are load-bearing: a labelled bend (the label + * sits at the path midpoint and needs a straight segment under it) or a deliberate detour + * around something a straight line would have hit. */ -function edgeXml(l: LinkSpec, index: number): string { - const label = - l.step != null - ? l.label - ? `${l.step}. ${l.label}` - : `${l.step}.` - : (l.label ?? "") +function edgeXml(l: LinkSpec, index: number, route?: RoutedEdge): string { + const label = edgeLabel(l) let style = l.style ?? EDGE_STYLE if (!l.style) { if (l.dashed) style += "dashed=1;" if (label) style += "labelBackgroundColor=light-dark(#FFFFFF,#0B0F14);" } + if (route) + style += + `exitX=${route.exit.x};exitY=${route.exit.y};exitDx=0;exitDy=0;` + + `entryX=${route.entry.x};entryY=${route.entry.y};entryDx=0;entryDy=0;` const id = l.id ?? `ed${index + 1}` + const points = + route?.freeze && route.waypoints.length + ? `${route.waypoints + .map( + (p) => + ``, + ) + .join("")}` + : "" return ( `` + - `` + + `${points}` + `` ) } @@ -186,8 +223,14 @@ export function renderDiagram( }) const flat = flatten(roots) + const glyph = opts.iconSize ?? ICON_SIZE + // Slot rectangles, for positioning children relative to their parent. const rectById = new Map() for (const f of flat) rectById.set(f.node.id, f.rect) + // Emitted-cell rectangles, which is what the router must see. + const cellById = new Map() + for (const f of flat) + cellById.set(f.node.id, cellRect(f.node, f.rect, glyph)) const cells: string[] = [] @@ -199,7 +242,6 @@ export function renderDiagram( ) // Parents come before children (flatten guarantees it), which draw.io requires. - const glyph = opts.iconSize ?? ICON_SIZE for (const f of flat) { const parentRect = f.parent === "1" ? null : (rectById.get(f.parent) ?? null) @@ -227,16 +269,47 @@ export function renderDiagram( const known = new Set(flat.map((f) => f.node.id)) for (const c of tree.foreign) known.add(c.id) const dangling: string[] = [] - let emitted = 0 + const drawable: LinkSpec[] = [] for (const l of tree.links) { if (!known.has(l.source) || !known.has(l.target)) { if (!known.has(l.source)) dangling.push(l.source) if (!known.has(l.target)) dangling.push(l.target) continue } - cells.push(edgeXml(l, emitted++)) + drawable.push(l) } + // Route with the whole page in view. Only leaf shapes are obstacles: an edge from + // outside a VPC to something inside it has to cross the VPC's border, so a container + // frame must not block it. + const obstacles = new Set( + flat + .filter((f) => f.node.kind === "icon" || f.node.kind === "box") + .map((f) => f.node.id), + ) + // Frames are passable but not free to ignore: a line that runs alongside a border, or + // cuts through a frame only one of its endpoints belongs to, reads as a mistake even + // though it hits nothing. + const frames = new Set( + flat + .filter((f) => f.node.kind === "group" || f.node.kind === "grid") + .map((f) => f.node.id), + ) + const routes = routeEdges( + drawable.map((l, i) => ({ + id: l.id ?? `ed${i + 1}`, + source: l.source, + target: l.target, + hasLabel: edgeLabel(l) !== "", + })), + cellById, + obstacles, + frames, + ) + drawable.forEach((l, i) => { + cells.push(edgeXml(l, i, routes[i])) + }) + const model = ` 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 } +} + +/** + * 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 + }[] = [] + + 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 + 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 + // 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 + } + + // 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. + const chosen = 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, sf), + ...chosen.wp, + portPoint(b, chosen.entrySide, tf), + ]) + 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 + } + + // --- 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), + } + }) +} diff --git a/tests/unit/diagram-engine-route.test.ts b/tests/unit/diagram-engine-route.test.ts new file mode 100644 index 0000000..d06b4a4 --- /dev/null +++ b/tests/unit/diagram-engine-route.test.ts @@ -0,0 +1,602 @@ +/** + * Edge routing. + * + * The property that matters is geometric: a routed path must not run through an icon it + * does not connect to, and two edges must not leave the same side of a node at the same + * point. These tests check the produced geometry rather than the shape of the algorithm, + * so a different routing strategy that still satisfies them would pass. + */ +import { describe, expect, it } from "vitest" +import { renderDiagram } from "@/lib/diagram-engine/render" +import { routeEdges } from "@/lib/diagram-engine/route" +import type { + DiagramNode, + DiagramTree, + GroupNode, + IconNode, + Rect, +} from "@/lib/diagram-engine/types" + +const rect = (x: number, y: number, w = 48, h = 48): Rect => ({ x, y, w, h }) + +/** Absolute path of a routed edge: exit port → waypoints → entry port. */ +function pathOf( + route: { + exit: { x: number; y: number } + entry: { x: number; y: number } + waypoints: { x: number; y: number }[] + }, + src: Rect, + tgt: Rect, +): { x: number; y: number }[] { + return [ + { x: src.x + route.exit.x * src.w, y: src.y + route.exit.y * src.h }, + ...route.waypoints, + { x: tgt.x + route.entry.x * tgt.w, y: tgt.y + route.entry.y * tgt.h }, + ] +} + +/** Does a segment cross this rect's core (not merely graze its edge)? */ +function segCrosses( + a: { x: number; y: number }, + b: { x: number; y: number }, + r: Rect, +): boolean { + const inset = Math.min(r.w, r.h) * 0.25 + return ( + Math.max(a.x, b.x) > r.x + inset && + Math.min(a.x, b.x) < r.x + r.w - inset && + Math.max(a.y, b.y) > r.y + inset && + Math.min(a.y, b.y) < r.y + r.h - inset + ) +} + +function pathCrosses(path: { x: number; y: number }[], r: Rect): boolean { + for (let i = 0; i < path.length - 1; i++) + if (segCrosses(path[i], path[i + 1], r)) return true + return false +} + +describe("connection points are always pinned", () => { + const rects = new Map([ + ["a", rect(0, 0)], + ["b", rect(300, 0)], + ]) + const routes = routeEdges( + [{ id: "e1", source: "a", target: "b", hasLabel: false }], + rects, + new Set(["a", "b"]), + ) + + it("emits an exit and an entry for every edge", () => { + // Without these draw.io picks the side itself, knowing nothing about the other + // icons on the page. + expect(routes).toHaveLength(1) + expect(routes[0].exit).toBeDefined() + expect(routes[0].entry).toBeDefined() + }) + + it("leaves from the side facing the target", () => { + // b is to the right of a, so the arrow should exit a's right edge and enter b's left. + expect(routes[0].exit.x).toBe(1) + expect(routes[0].entry.x).toBe(0) + }) + + it("uses fractions, so draw.io recomputes them when a node is dragged", () => { + for (const v of [ + routes[0].exit.x, + routes[0].exit.y, + routes[0].entry.x, + routes[0].entry.y, + ]) { + expect(v).toBeGreaterThanOrEqual(0) + expect(v).toBeLessThanOrEqual(1) + } + }) + + it("picks the vertical sides when the target is below", () => { + const r = routeEdges( + [{ id: "e", source: "a", target: "c", hasLabel: false }], + new Map([ + ["a", rect(0, 0)], + ["c", rect(0, 300)], + ]), + new Set(["a", "c"]), + ) + expect(r[0].exit.y).toBe(1) // leaves the bottom + expect(r[0].entry.y).toBe(0) // enters the top + }) + + it("leaves leftwards when the target is to the left", () => { + const r = routeEdges( + [{ id: "e", source: "b", target: "a", hasLabel: false }], + rects, + new Set(["a", "b"]), + ) + expect(r[0].exit.x).toBe(0) + expect(r[0].entry.x).toBe(1) + }) +}) + +describe("ports on the same side are de-collided", () => { + it("spreads three edges leaving one node's right side", () => { + // This is the case from the reported screenshot: two edges left the same EC2 icon + // at the same point and overlapped on top of it. + const rects = new Map([ + ["hub", rect(0, 200)], + ["t1", rect(400, 0)], + ["t2", rect(400, 200)], + ["t3", rect(400, 400)], + ]) + const routes = routeEdges( + [ + { id: "e1", source: "hub", target: "t1", hasLabel: false }, + { id: "e2", source: "hub", target: "t2", hasLabel: false }, + { id: "e3", source: "hub", target: "t3", hasLabel: false }, + ], + rects, + new Set(["hub", "t1", "t2", "t3"]), + ) + const ys = routes.map((r) => r.exit.y) + expect(new Set(ys).size).toBe(3) + }) + + it("keeps the edge that has a straight shot on the centre line", () => { + // t2 is level with hub, so that arrow can run straight; bending it to make room + // for the others would be the wrong trade. + const rects = new Map([ + ["hub", rect(0, 200)], + ["t1", rect(400, 0)], + ["t2", rect(400, 200)], + ["t3", rect(400, 400)], + ]) + const routes = routeEdges( + [ + { id: "e1", source: "hub", target: "t1", hasLabel: false }, + { id: "e2", source: "hub", target: "t2", hasLabel: false }, + { id: "e3", source: "hub", target: "t3", hasLabel: false }, + ], + rects, + new Set(["hub", "t1", "t2", "t3"]), + ) + expect(routes[1].exit.y).toBe(0.5) + expect(routes[0].exit.y).not.toBe(0.5) + expect(routes[2].exit.y).not.toBe(0.5) + }) + + it("orders the spread so the edges do not cross on the way out", () => { + // The target that sits highest should leave from the highest port. + const rects = new Map([ + ["hub", rect(0, 200)], + ["top", rect(400, 0)], + ["bottom", rect(400, 400)], + ]) + const routes = routeEdges( + [ + { id: "e1", source: "hub", target: "bottom", hasLabel: false }, + { id: "e2", source: "hub", target: "top", hasLabel: false }, + ], + rects, + new Set(["hub", "top", "bottom"]), + ) + // e2 goes up, so its exit must be above e1's + expect(routes[1].exit.y).toBeLessThan(routes[0].exit.y) + }) + + it("spreads fan-in on the target side too", () => { + const rects = new Map([ + ["s1", rect(0, 0)], + ["s2", rect(0, 200)], + ["s3", rect(0, 400)], + ["sink", rect(400, 200)], + ]) + const routes = routeEdges( + [ + { id: "e1", source: "s1", target: "sink", hasLabel: false }, + { id: "e2", source: "s2", target: "sink", hasLabel: false }, + { id: "e3", source: "s3", target: "sink", hasLabel: false }, + ], + rects, + new Set(["s1", "s2", "s3", "sink"]), + ) + expect(new Set(routes.map((r) => r.entry.y)).size).toBe(3) + }) + + it("leaves a single edge centred", () => { + const routes = routeEdges( + [{ id: "e", source: "a", target: "b", hasLabel: false }], + new Map([ + ["a", rect(0, 0)], + ["b", rect(300, 0)], + ]), + new Set(["a", "b"]), + ) + expect(routes[0].exit.y).toBe(0.5) + expect(routes[0].entry.y).toBe(0.5) + }) +}) + +describe("routes avoid icons they do not connect to", () => { + it("bends around an icon sitting on the straight line", () => { + // a — blocker — b, all level. A straight line would run through the blocker. + const rects = new Map([ + ["a", rect(0, 100)], + ["blocker", rect(200, 100)], + ["b", rect(400, 100)], + ]) + const routes = routeEdges( + [{ id: "e", source: "a", target: "b", hasLabel: false }], + rects, + new Set(["a", "blocker", "b"]), + ) + const path = pathOf( + routes[0], + rects.get("a") as Rect, + rects.get("b") as Rect, + ) + expect(pathCrosses(path, rects.get("blocker") as Rect)).toBe(false) + }) + + it("freezes the waypoints of a deliberate detour", () => { + // A re-route from the pins alone would put the path back through the blocker, so + // this is one of the two cases where the waypoints have to survive. + const rects = new Map([ + ["a", rect(0, 100)], + ["blocker", rect(200, 100)], + ["b", rect(400, 100)], + ]) + const routes = routeEdges( + [{ id: "e", source: "a", target: "b", hasLabel: false }], + rects, + new Set(["a", "blocker", "b"]), + ) + expect(routes[0].waypoints.length).toBeGreaterThan(0) + expect(routes[0].freeze).toBe(true) + }) + + it("does NOT freeze a clear straight run", () => { + // Nothing in the way, so leave the route to draw.io and keep the edge + // drag-friendly. + const routes = routeEdges( + [{ id: "e", source: "a", target: "b", hasLabel: false }], + new Map([ + ["a", rect(0, 0)], + ["b", rect(300, 0)], + ]), + new Set(["a", "b"]), + ) + expect(routes[0].waypoints).toEqual([]) + expect(routes[0].freeze).toBe(false) + }) + + it("freezes a labelled bend so the label lands on a straight segment", () => { + const rects = new Map([ + ["a", rect(0, 0)], + ["b", rect(400, 300)], + ]) + const routes = routeEdges( + [{ id: "e", source: "a", target: "b", hasLabel: true }], + rects, + new Set(["a", "b"]), + ) + if (routes[0].waypoints.length > 0) expect(routes[0].freeze).toBe(true) + }) + + it("keeps clear of several icons in a row", () => { + const rects = new Map([ + ["a", rect(0, 200)], + ["x1", rect(150, 200)], + ["x2", rect(300, 200)], + ["x3", rect(450, 200)], + ["b", rect(600, 200)], + ]) + const routes = routeEdges( + [{ id: "e", source: "a", target: "b", hasLabel: false }], + rects, + new Set(["a", "x1", "x2", "x3", "b"]), + ) + const path = pathOf( + routes[0], + rects.get("a") as Rect, + rects.get("b") as Rect, + ) + for (const id of ["x1", "x2", "x3"]) + expect(pathCrosses(path, rects.get(id) as Rect)).toBe(false) + }) + + it("treats a container frame as passable, not an obstacle", () => { + // An arrow from outside a VPC to something inside it has to cross the border. + const rects = new Map([ + ["outside", rect(0, 100)], + ["vpc", { x: 200, y: 0, w: 400, h: 300 }], + ["inside", rect(350, 100)], + ]) + const routes = routeEdges( + [{ id: "e", source: "outside", target: "inside", hasLabel: false }], + rects, + // vpc deliberately absent from the obstacle set + new Set(["outside", "inside"]), + ) + // A straight shot is available and should be taken. + expect(routes[0].waypoints).toEqual([]) + }) +}) + +describe("parallel segments are separated", () => { + it("does not leave two detours stacked on the same track", () => { + // Two edges that both have to bend around the same column of icons would + // otherwise pick the same corridor lane and overlap for its whole length. + const rects = new Map([ + ["a1", rect(0, 100)], + ["a2", rect(0, 250)], + ["blocker1", rect(250, 100)], + ["blocker2", rect(250, 250)], + ["b1", rect(500, 100)], + ["b2", rect(500, 250)], + ]) + const routes = routeEdges( + [ + { id: "e1", source: "a1", target: "b1", hasLabel: false }, + { id: "e2", source: "a2", target: "b2", hasLabel: false }, + ], + rects, + new Set(["a1", "a2", "blocker1", "blocker2", "b1", "b2"]), + ) + // Collect the vertical lanes each route uses. + const lanes = routes.flatMap((r) => + r.waypoints.map((p) => p.x).filter((x) => x !== undefined), + ) + if (lanes.length >= 2) { + // No two lanes may sit within a few pixels of each other. + for (let i = 0; i < lanes.length; i++) + for (let j = i + 1; j < lanes.length; j++) + if (Math.abs(lanes[i] - lanes[j]) < 6) + expect(lanes[i]).toBe(lanes[j]) // same lane of one route is fine + } + }) + + it("produces the same result regardless of declaration order", () => { + // The nudge pass is global, so routing must not depend on the order link() was + // called in. + const rects = new Map([ + ["hub", rect(0, 200)], + ["t1", rect(400, 0)], + ["t2", rect(400, 200)], + ["t3", rect(400, 400)], + ]) + const obstacles = new Set(["hub", "t1", "t2", "t3"]) + const forward = routeEdges( + [ + { id: "e1", source: "hub", target: "t1", hasLabel: false }, + { id: "e2", source: "hub", target: "t2", hasLabel: false }, + { id: "e3", source: "hub", target: "t3", hasLabel: false }, + ], + rects, + obstacles, + ) + const reversed = routeEdges( + [ + { id: "e3", source: "hub", target: "t3", hasLabel: false }, + { id: "e2", source: "hub", target: "t2", hasLabel: false }, + { id: "e1", source: "hub", target: "t1", hasLabel: false }, + ], + rects, + obstacles, + ) + const byId = (rs: typeof forward) => + new Map(rs.map((r) => [r.id, JSON.stringify(r)])) + const f = byId(forward) + const r = byId(reversed) + for (const id of ["e1", "e2", "e3"]) expect(r.get(id)).toBe(f.get(id)) + }) +}) + +describe("degenerate input", () => { + it("returns a usable route when a terminal is missing", () => { + const routes = routeEdges( + [{ id: "e", source: "ghost", target: "b", hasLabel: false }], + new Map([["b", rect(0, 0)]]), + new Set(["b"]), + ) + expect(routes).toHaveLength(1) + expect(routes[0].exit).toBeDefined() + }) + + it("handles a self-loop without hanging", () => { + const routes = routeEdges( + [{ id: "e", source: "a", target: "a", hasLabel: false }], + new Map([["a", rect(0, 0)]]), + new Set(["a"]), + ) + expect(routes).toHaveLength(1) + }) + + it("handles no edges", () => { + expect(routeEdges([], new Map(), new Set())).toEqual([]) + }) + + it("routes 40 edges without blowing up", () => { + const rects = new Map() + for (let i = 0; i < 40; i++) + rects.set(`n${i}`, rect((i % 8) * 120, Math.floor(i / 8) * 120)) + const edges = [] + for (let i = 0; i < 39; i++) + edges.push({ + id: `e${i}`, + source: `n${i}`, + target: `n${i + 1}`, + hasLabel: false, + }) + const routes = routeEdges(edges, rects, new Set(rects.keys())) + expect(routes).toHaveLength(39) + }) +}) + +describe("the rendered XML carries the route", () => { + const icon = (id: string, label = ""): IconNode => ({ + kind: "icon", + id, + name: "ec2", + label, + }) + const tree = ( + roots: DiagramNode[], + links: DiagramTree["links"], + ): DiagramTree => ({ + roots, + links, + foreign: [], + }) + + it("writes exitX/entryX into the edge style", () => { + const t = tree( + [ + { + kind: "group", + id: "f", + gname: null, + label: "F", + dir: "row", + gap: 60, + children: [icon("a"), icon("b")], + } as GroupNode, + ], + [{ source: "a", target: "b" }], + ) + const { xml } = renderDiagram(t) + const edge = xml.match(/]*edge="1"[^>]*>/)?.[0] ?? "" + expect(edge).toContain("exitX=") + expect(edge).toContain("exitY=") + expect(edge).toContain("entryX=") + expect(edge).toContain("entryY=") + }) + + it("writes no waypoints for an unobstructed unlabelled edge", () => { + const t = tree( + [ + { + kind: "group", + id: "f", + gname: null, + label: "F", + dir: "row", + gap: 60, + children: [icon("a"), icon("b")], + } as GroupNode, + ], + [{ source: "a", target: "b" }], + ) + expect(renderDiagram(t).xml).not.toContain('as="points"') + }) + + it("writes waypoints when the router had to bend around an icon", () => { + // Three icons in a row; the arrow skips the middle one. + const t = tree( + [ + { + kind: "group", + id: "f", + gname: null, + label: "F", + dir: "row", + gap: 60, + children: [icon("a"), icon("mid"), icon("b")], + } as GroupNode, + ], + [{ source: "a", target: "b" }], + ) + const { xml } = renderDiagram(t) + expect(xml).toContain('as="points"') + }) + + it("does not route an arrow through a sibling icon", () => { + const t = tree( + [ + { + kind: "group", + id: "f", + gname: null, + label: "F", + dir: "row", + gap: 60, + children: [icon("a"), icon("mid"), icon("b")], + } as GroupNode, + ], + [{ source: "a", target: "b" }], + ) + const { xml } = renderDiagram(t) + // Pull the geometry back out and check it against the middle icon. + const geo = (id: string): Rect | null => { + const m = xml.match( + new RegExp( + `]*>\\s*]*>\s*/g), + ].map((m) => ({ x: Number(m[1]), y: Number(m[2]) })) + expect(mid).not.toBeNull() + if (mid && pts.length >= 2) { + // The corridor the route uses must be clear of the middle icon. + for (const p of pts) { + const insideX = p.x > mid.x && p.x < mid.x + mid.w + const insideY = p.y > mid.y && p.y < mid.y + mid.h + expect(insideX && insideY).toBe(false) + } + } + }) + + it("spreads a fan-out so the arrows do not stack on one point", () => { + // The screenshot's failure mode, checked through the full render path. + const t = tree( + [ + { + kind: "group", + id: "f", + gname: null, + label: "F", + dir: "row", + gap: 120, + children: [ + icon("hub"), + { + kind: "group", + id: "col", + gname: null, + label: "", + dir: "col", + gap: 60, + children: [icon("t1"), icon("t2"), icon("t3")], + } as GroupNode, + ], + } as GroupNode, + ], + [ + { source: "hub", target: "t1" }, + { source: "hub", target: "t2" }, + { source: "hub", target: "t3" }, + ], + ) + const { xml } = renderDiagram(t) + const exits = [...xml.matchAll(/exitX=([\d.]+);exitY=([\d.]+)/g)].map( + (m) => `${m[1]},${m[2]}`, + ) + expect(exits).toHaveLength(3) + expect(new Set(exits).size).toBe(3) + }) +})