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) + }) +})