mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-02 01:20:23 +08:00
feat(diagram-engine): edge router — pinned ports, obstacle avoidance, cost search
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.
This commit is contained in:
602
tests/unit/diagram-engine-route.test.ts
Normal file
602
tests/unit/diagram-engine-route.test.ts
Normal file
@@ -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<string, Rect>()
|
||||
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(/<mxCell[^>]*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(
|
||||
`<mxCell id="${id}"[^>]*>\\s*<mxGeometry x="(-?\\d+)" y="(-?\\d+)" width="(\\d+)" height="(\\d+)"`,
|
||||
),
|
||||
)
|
||||
if (!m) return null
|
||||
// These are parent-relative; the frame's own origin has to be added back.
|
||||
const f = xml.match(
|
||||
/<mxCell id="f"[^>]*>\s*<mxGeometry x="(-?\d+)" y="(-?\d+)"/,
|
||||
)
|
||||
const ox = f ? Number(f[1]) : 0
|
||||
const oy = f ? Number(f[2]) : 0
|
||||
return {
|
||||
x: Number(m[1]) + ox,
|
||||
y: Number(m[2]) + oy,
|
||||
w: Number(m[3]),
|
||||
h: Number(m[4]),
|
||||
}
|
||||
}
|
||||
const mid = geo("mid")
|
||||
const pts = [
|
||||
...xml.matchAll(/<mxPoint x="(-?\d+)" y="(-?\d+)"\/>/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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user