feat(diagram-engine): flowcharts, swimlanes, sequence diagrams and mind maps
Extends the declarative engine past cloud architecture. The tool routing was
divided by icon library — AWS through the engine, everything else hand-written
XML — which is the wrong axis. What matters is the LAYOUT SHAPE.
Measured first: a six-step approval flow declared in its natural order comes out
as one column, because the layout only arranges what nesting tells it to and
never looked at the arrows. That forces the arrow from the decision to its second
branch to jump over the first branch.
graph.ts computes what the layout should have looked at: layer assignment by
longest path, cycle breaking so a loop is drawn without setting the order, and
barycentre sweeping to cut edge crossings. It emits ordinary container
operations, so layout, routing and round-tripping are unchanged — reaching zero
arrows-through-boxes on a 14-node pipeline and zero crossings on a bipartite
graph whose declared order forces three.
Three new container kinds, each because one layout rule cannot serve them all:
pool — swimlanes. Lanes are real cells and each step is parented to its
band, so dragging a step to another role records the change.
sequence — participants across the top, one lifeline cell per participant so
head and line stay together on a drag. Messages bypass the router:
a message's height IS its order.
radial — mind maps and org charts. Children are a flat list and the
hierarchy comes from the links, because a branch is a box and a box
cannot hold children.
Flowchart box shapes (diamond, stadium, parallelogram, document) so a reader can
tell a branch from a step.
Two bugs the new tests caught: the duplicate-link guard blocked a sequence
diagram from having two messages between the same pair, and the fallback message
numbering was shared across containers, pushing a second diagram's messages off
its own lifelines.
523 unit tests and 17 diagram e2e tests pass. Every kind verified round-trip
stable to a fixed point, and rendered in a real browser — draw.io keeps the
lifeline shape and the lane markers.
2026-08-09 13:47:23 +09:00
|
|
|
|
import { describe, expect, it } from "vitest"
|
|
|
|
|
|
import {
|
|
|
|
|
|
type GraphEdge,
|
|
|
|
|
|
type GraphNode,
|
|
|
|
|
|
graphToOperations,
|
feat(diagram-engine): corners, borderless fills, shadows and strikethrough
Four more Tailwind classes, all four verified against draw.io's own source in
public/drawio rather than against a prose reference — which is how three earlier
exclusions turned out to be wrong:
rounded-* mxShape.js:1172-1189 — absoluteArcSize=1 switches arcSize to
absolute pixels and halves it, so the same class is the same
corner on every box. Previously excluded as 'percentage only'
shadow-sm..xl mxShape.js:505-535 — getShadowStyle reads five independent
params, not one flag, so Tailwind's offset+blur rungs map one
to one. Previously excluded as 'six sizes collapse to one'
line-through mxConstants.js:2054 FONT_STRIKETHROUGH: 8, read at both
mxText.js:723 and :1040. The bitmask has four bits, not three
border-none the only one of the four that adds something previously
inexpressible: a fill with no outline
Also fixes an edge style growing 76 characters per re-layout, without bound. The
router recomputes ports on every pass, and appending them to a style recovered
from the canvas — which already carried the previous pass's eight port keys —
grew the string forever. draw.io resolves duplicates last-wins so the arrow
always looked right; a byte-identity check is what caught it.
Two traps found while wiring the readback, both the same shape: a value the
THEME emits being recorded as one the model asked for. strokeColor=none from a
filled or ghost role, and rounded=0 from the fallback style. Either one would
outlive a set_role, since that clears style but keeps text.
Deliberately not included, with reasons in tw.ts: per-side borders and per-corner
radius (both would take the shape slot, and what a node IS matters more than
which of its edges show), per-side padding (draw.io's keys pad the label, not the
room left for children), text-shadow (a bare flag with no offset or blur),
opacity (Tailwind's is any integer, not a scale), tracking/uppercase/leading
(absent from draw.io — zero grep hits, not merely coarse).
615 tests, 90 new. Browser-verified: four shadow rungs visibly differ, radius is
real pixels, terminator + rounded-lg becomes a small-cornered rounded rect while
an untouched terminator stays a stadium.
2026-08-11 09:09:45 +09:00
|
|
|
|
type Operation,
|
feat(diagram-engine): label avoidance, paired opposite edges, semantic group colours
A git-workflow flowchart rendered with no overlaps but read poorly. Three
distinct causes, each fixed and measured:
1. Edge labels sat on boxes and on each other (4 collisions on the
reported diagram; 280 across 250 generated flowcharts). The router
keeps LINES off the boxes but a label renders at its edge's midpoint,
which on a long edge is beside exactly the things the line was routed
around. placeLabels slides each label along its own edge to a clear
spot — longest edges first, midpoint-outward tries — written as the
geometry's relative x, which draw.io natively supports. Corpus: 280
label collisions -> 8.
2. A->B and B->A were routed independently, so "git add" ran straight
while "git reset" wandered through a different corridor with a kink.
Opposite edges that agree on axis now get two absolute parallel tracks
in the strip where the two boxes overlap, a constant 24px apart,
converted back to port fractions. Zero crossing regressions.
3. All boxes rendered the same white, because the render layer's
fill/stroke support was never reachable: neither add_box's schema nor
draw_graph's nodes exposed it. Rather than exposing raw hex (the model
picks mismatched saturations, differently every time), nodes take a
semantic group name and the engine maps groups to a fixed palette of
six paired fill/strokes in order of first appearance. The model names
the zones - remote vs local vs temp - and never touches a colour.
532 unit tests pass; the 5 diagram e2e tests pass in a real browser.
2026-08-09 18:59:34 +09:00
|
|
|
|
restructureDiagram,
|
feat(diagram-engine): flowcharts, swimlanes, sequence diagrams and mind maps
Extends the declarative engine past cloud architecture. The tool routing was
divided by icon library — AWS through the engine, everything else hand-written
XML — which is the wrong axis. What matters is the LAYOUT SHAPE.
Measured first: a six-step approval flow declared in its natural order comes out
as one column, because the layout only arranges what nesting tells it to and
never looked at the arrows. That forces the arrow from the decision to its second
branch to jump over the first branch.
graph.ts computes what the layout should have looked at: layer assignment by
longest path, cycle breaking so a loop is drawn without setting the order, and
barycentre sweeping to cut edge crossings. It emits ordinary container
operations, so layout, routing and round-tripping are unchanged — reaching zero
arrows-through-boxes on a 14-node pipeline and zero crossings on a bipartite
graph whose declared order forces three.
Three new container kinds, each because one layout rule cannot serve them all:
pool — swimlanes. Lanes are real cells and each step is parented to its
band, so dragging a step to another role records the change.
sequence — participants across the top, one lifeline cell per participant so
head and line stay together on a drag. Messages bypass the router:
a message's height IS its order.
radial — mind maps and org charts. Children are a flat list and the
hierarchy comes from the links, because a branch is a box and a box
cannot hold children.
Flowchart box shapes (diamond, stadium, parallelogram, document) so a reader can
tell a branch from a step.
Two bugs the new tests caught: the duplicate-link guard blocked a sequence
diagram from having two messages between the same pair, and the fallback message
numbering was shared across containers, pushing a second diagram's messages off
its own lifelines.
523 unit tests and 17 diagram e2e tests pass. Every kind verified round-trip
stable to a fixed point, and rendered in a real browser — draw.io keeps the
lifeline shape and the lane markers.
2026-08-09 13:47:23 +09:00
|
|
|
|
} from "@/lib/diagram-engine"
|
|
|
|
|
|
import {
|
|
|
|
|
|
absoluteRects,
|
|
|
|
|
|
edgePaths,
|
|
|
|
|
|
nodeCollisions,
|
|
|
|
|
|
rectOf,
|
|
|
|
|
|
} from "./fixtures/geometry"
|
|
|
|
|
|
|
|
|
|
|
|
const n = (id: string, label = id): GraphNode => ({ id, label })
|
|
|
|
|
|
const e = (source: string, target: string, label?: string): GraphEdge => ({
|
|
|
|
|
|
source,
|
|
|
|
|
|
target,
|
|
|
|
|
|
...(label ? { label } : {}),
|
|
|
|
|
|
})
|
|
|
|
|
|
|
feat(diagram-engine): corners, borderless fills, shadows and strikethrough
Four more Tailwind classes, all four verified against draw.io's own source in
public/drawio rather than against a prose reference — which is how three earlier
exclusions turned out to be wrong:
rounded-* mxShape.js:1172-1189 — absoluteArcSize=1 switches arcSize to
absolute pixels and halves it, so the same class is the same
corner on every box. Previously excluded as 'percentage only'
shadow-sm..xl mxShape.js:505-535 — getShadowStyle reads five independent
params, not one flag, so Tailwind's offset+blur rungs map one
to one. Previously excluded as 'six sizes collapse to one'
line-through mxConstants.js:2054 FONT_STRIKETHROUGH: 8, read at both
mxText.js:723 and :1040. The bitmask has four bits, not three
border-none the only one of the four that adds something previously
inexpressible: a fill with no outline
Also fixes an edge style growing 76 characters per re-layout, without bound. The
router recomputes ports on every pass, and appending them to a style recovered
from the canvas — which already carried the previous pass's eight port keys —
grew the string forever. draw.io resolves duplicates last-wins so the arrow
always looked right; a byte-identity check is what caught it.
Two traps found while wiring the readback, both the same shape: a value the
THEME emits being recorded as one the model asked for. strokeColor=none from a
filled or ghost role, and rounded=0 from the fallback style. Either one would
outlive a set_role, since that clears style but keeps text.
Deliberately not included, with reasons in tw.ts: per-side borders and per-corner
radius (both would take the shape slot, and what a node IS matters more than
which of its edges show), per-side padding (draw.io's keys pad the label, not the
room left for children), text-shadow (a bare flag with no offset or blur),
opacity (Tailwind's is any integer, not a scale), tracking/uppercase/leading
(absent from draw.io — zero grep hits, not merely coarse).
615 tests, 90 new. Browser-verified: four shadow rungs visibly differ, radius is
real pixels, terminator + rounded-lg becomes a small-cornered rounded rect while
an untouched terminator stays a stadium.
2026-08-11 09:09:45 +09:00
|
|
|
|
/**
|
|
|
|
|
|
* Draw a whole-page graph the way the model does: one `add_graph` with no parent.
|
|
|
|
|
|
*
|
|
|
|
|
|
* There used to be a `drawGraph` entry point that did only this. It was removed once
|
|
|
|
|
|
* `add_graph` covered the same ground with a `parent` argument — one code path instead of
|
|
|
|
|
|
* two overlapping ones — and these tests kept their assertions by going through this.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function drawGraph(
|
|
|
|
|
|
nodes: GraphNode[],
|
|
|
|
|
|
edges: GraphEdge[],
|
|
|
|
|
|
opts: { title?: string; flow?: "col" | "row" } = {},
|
|
|
|
|
|
) {
|
|
|
|
|
|
const ops: Operation[] = [
|
|
|
|
|
|
{
|
|
|
|
|
|
op: "add_graph",
|
|
|
|
|
|
id: "g",
|
|
|
|
|
|
nodes,
|
|
|
|
|
|
edges,
|
|
|
|
|
|
...(opts.flow ? { dir: opts.flow } : {}),
|
|
|
|
|
|
} as Operation,
|
|
|
|
|
|
]
|
|
|
|
|
|
if (opts.title) ops.unshift({ op: "set_title", title: opts.title })
|
|
|
|
|
|
return restructureDiagram("", ops)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(diagram-engine): flowcharts, swimlanes, sequence diagrams and mind maps
Extends the declarative engine past cloud architecture. The tool routing was
divided by icon library — AWS through the engine, everything else hand-written
XML — which is the wrong axis. What matters is the LAYOUT SHAPE.
Measured first: a six-step approval flow declared in its natural order comes out
as one column, because the layout only arranges what nesting tells it to and
never looked at the arrows. That forces the arrow from the decision to its second
branch to jump over the first branch.
graph.ts computes what the layout should have looked at: layer assignment by
longest path, cycle breaking so a loop is drawn without setting the order, and
barycentre sweeping to cut edge crossings. It emits ordinary container
operations, so layout, routing and round-tripping are unchanged — reaching zero
arrows-through-boxes on a 14-node pipeline and zero crossings on a bipartite
graph whose declared order forces three.
Three new container kinds, each because one layout rule cannot serve them all:
pool — swimlanes. Lanes are real cells and each step is parented to its
band, so dragging a step to another role records the change.
sequence — participants across the top, one lifeline cell per participant so
head and line stay together on a drag. Messages bypass the router:
a message's height IS its order.
radial — mind maps and org charts. Children are a flat list and the
hierarchy comes from the links, because a branch is a box and a box
cannot hold children.
Flowchart box shapes (diamond, stadium, parallelogram, document) so a reader can
tell a branch from a step.
Two bugs the new tests caught: the duplicate-link guard blocked a sequence
diagram from having two messages between the same pair, and the fallback message
numbering was shared across containers, pushing a second diagram's messages off
its own lifelines.
523 unit tests and 17 diagram e2e tests pass. Every kind verified round-trip
stable to a fixed point, and rendered in a real browser — draw.io keeps the
lifeline shape and the lane markers.
2026-08-09 13:47:23 +09:00
|
|
|
|
describe("graphToOperations: layering", () => {
|
|
|
|
|
|
it("puts a chain in one node per layer", () => {
|
|
|
|
|
|
const { layers } = graphToOperations(
|
|
|
|
|
|
[n("a"), n("b"), n("c")],
|
|
|
|
|
|
[e("a", "b"), e("b", "c")],
|
|
|
|
|
|
)
|
|
|
|
|
|
expect(layers).toEqual([["a"], ["b"], ["c"]])
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it("puts the branches of a decision in the same layer", () => {
|
|
|
|
|
|
const { layers } = graphToOperations(
|
|
|
|
|
|
[n("q"), n("yes"), n("no")],
|
|
|
|
|
|
[e("q", "yes"), e("q", "no")],
|
|
|
|
|
|
)
|
|
|
|
|
|
expect(layers[0]).toEqual(["q"])
|
|
|
|
|
|
expect(layers[1].sort()).toEqual(["no", "yes"])
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it("uses the LONGEST path, so no arrow points sideways", () => {
|
|
|
|
|
|
// a→b, a→c, c→b. The shortest path would put b in layer 1 beside c, leaving c→b
|
|
|
|
|
|
// pointing sideways. b has to come after c.
|
|
|
|
|
|
const { layers } = graphToOperations(
|
|
|
|
|
|
[n("a"), n("b"), n("c")],
|
|
|
|
|
|
[e("a", "b"), e("a", "c"), e("c", "b")],
|
|
|
|
|
|
)
|
|
|
|
|
|
expect(layers).toEqual([["a"], ["c"], ["b"]])
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it("keeps a node with no arrows at all", () => {
|
|
|
|
|
|
const { layers } = graphToOperations(
|
|
|
|
|
|
[n("a"), n("b"), n("island")],
|
|
|
|
|
|
[e("a", "b")],
|
|
|
|
|
|
)
|
|
|
|
|
|
expect(layers.flat().sort()).toEqual(["a", "b", "island"])
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it("draws a loop but does not let it set the layering", () => {
|
|
|
|
|
|
const r = graphToOperations(
|
|
|
|
|
|
[n("a"), n("b"), n("c")],
|
|
|
|
|
|
[e("a", "b"), e("b", "c"), e("c", "a")],
|
|
|
|
|
|
)
|
|
|
|
|
|
expect(r.layers).toEqual([["a"], ["b"], ["c"]])
|
|
|
|
|
|
expect(r.backEdges).toEqual([{ source: "c", target: "a" }])
|
|
|
|
|
|
// The loop is still drawn.
|
|
|
|
|
|
expect(r.operations.filter((o) => o.op === "link").length).toBe(3)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it("draws a self-loop and keeps it out of the layering", () => {
|
|
|
|
|
|
const r = graphToOperations(
|
|
|
|
|
|
[n("a"), n("b")],
|
|
|
|
|
|
[e("a", "b"), e("a", "a")],
|
|
|
|
|
|
)
|
|
|
|
|
|
expect(r.layers).toEqual([["a"], ["b"]])
|
|
|
|
|
|
expect(r.operations.filter((o) => o.op === "link").length).toBe(2)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it("reports an edge naming a node that does not exist", () => {
|
|
|
|
|
|
const r = graphToOperations([n("a")], [e("a", "ghost")])
|
|
|
|
|
|
expect(r.unknownEndpoints).toEqual(["ghost"])
|
|
|
|
|
|
expect(r.operations.filter((o) => o.op === "link")).toEqual([])
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it("survives a graph that is nothing but a cycle", () => {
|
|
|
|
|
|
const r = graphToOperations(
|
|
|
|
|
|
[n("a"), n("b")],
|
|
|
|
|
|
[e("a", "b"), e("b", "a")],
|
|
|
|
|
|
)
|
|
|
|
|
|
expect(r.layers.flat().sort()).toEqual(["a", "b"])
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
describe("graphToOperations: within-layer ordering", () => {
|
|
|
|
|
|
it("reverses a layer when that removes the crossings", () => {
|
|
|
|
|
|
// a→z, b→y, c→x. Declared order would make all three cross.
|
|
|
|
|
|
const { layers } = graphToOperations(
|
|
|
|
|
|
[n("a"), n("b"), n("c"), n("x"), n("y"), n("z")],
|
|
|
|
|
|
[e("a", "z"), e("b", "y"), e("c", "x")],
|
|
|
|
|
|
)
|
|
|
|
|
|
expect(layers[0]).toEqual(["a", "b", "c"])
|
|
|
|
|
|
expect(layers[1]).toEqual(["z", "y", "x"])
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it("leaves an already-good order alone", () => {
|
|
|
|
|
|
const { layers } = graphToOperations(
|
|
|
|
|
|
[n("a"), n("b"), n("x"), n("y")],
|
|
|
|
|
|
[e("a", "x"), e("b", "y")],
|
|
|
|
|
|
)
|
|
|
|
|
|
expect(layers[1]).toEqual(["x", "y"])
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
describe("graphToOperations: emitted operations", () => {
|
|
|
|
|
|
it("does not wrap a layer holding one node", () => {
|
|
|
|
|
|
const { operations } = graphToOperations(
|
|
|
|
|
|
[n("a"), n("b")],
|
|
|
|
|
|
[e("a", "b")],
|
|
|
|
|
|
)
|
|
|
|
|
|
const containers = operations.filter((o) => o.op === "add_container")
|
|
|
|
|
|
// Only the outer flow container: neither single-node layer needs a wrapper.
|
|
|
|
|
|
expect(containers.length).toBe(1)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it("wraps a layer holding several nodes", () => {
|
|
|
|
|
|
const { operations } = graphToOperations(
|
|
|
|
|
|
[n("q"), n("yes"), n("no")],
|
|
|
|
|
|
[e("q", "yes"), e("q", "no")],
|
|
|
|
|
|
)
|
|
|
|
|
|
const containers = operations.filter((o) => o.op === "add_container")
|
|
|
|
|
|
expect(containers.length).toBe(2)
|
|
|
|
|
|
// The layer band runs ACROSS the flow.
|
|
|
|
|
|
const band = containers.find((c) => c.id !== "__layers")
|
|
|
|
|
|
expect(band?.dir).toBe("row")
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it("flips both axes when the flow runs left to right", () => {
|
|
|
|
|
|
const { operations } = graphToOperations(
|
|
|
|
|
|
[n("q"), n("yes"), n("no")],
|
|
|
|
|
|
[e("q", "yes"), e("q", "no")],
|
|
|
|
|
|
{ flow: "row" },
|
|
|
|
|
|
)
|
|
|
|
|
|
const containers = operations.filter((o) => o.op === "add_container")
|
|
|
|
|
|
expect(containers.find((c) => c.id === "__layers")?.dir).toBe("row")
|
|
|
|
|
|
expect(containers.find((c) => c.id !== "__layers")?.dir).toBe("col")
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it("carries shapes and labels through", () => {
|
|
|
|
|
|
const { operations } = graphToOperations(
|
|
|
|
|
|
[
|
|
|
|
|
|
{ id: "s", label: "Start", shape: "terminator" },
|
|
|
|
|
|
{ id: "q", label: "OK?", shape: "decision" },
|
|
|
|
|
|
],
|
|
|
|
|
|
[e("s", "q", "go")],
|
|
|
|
|
|
)
|
|
|
|
|
|
const boxes = operations.filter((o) => o.op === "add_box")
|
|
|
|
|
|
expect(boxes.map((b) => b.shape)).toEqual(["terminator", "decision"])
|
|
|
|
|
|
expect(operations.find((o) => o.op === "link")?.label).toBe("go")
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it("emits an icon node as an icon", () => {
|
|
|
|
|
|
const { operations } = graphToOperations(
|
|
|
|
|
|
[{ id: "s3", label: "Bucket", icon: "s3" }],
|
|
|
|
|
|
[],
|
|
|
|
|
|
)
|
|
|
|
|
|
const icon = operations.find((o) => o.op === "add_icon")
|
|
|
|
|
|
expect(icon).toMatchObject({ id: "s3", name: "s3", label: "Bucket" })
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it("does not emit a plain box shape as an explicit shape", () => {
|
|
|
|
|
|
const { operations } = graphToOperations(
|
|
|
|
|
|
[{ id: "a", label: "A", shape: "box" }],
|
|
|
|
|
|
[],
|
|
|
|
|
|
)
|
|
|
|
|
|
expect(operations.find((o) => o.op === "add_box")).not.toHaveProperty(
|
|
|
|
|
|
"shape",
|
|
|
|
|
|
)
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
describe("drawGraph: the whole pipeline", () => {
|
|
|
|
|
|
it("draws a decision flow with no arrow hitting an unrelated box", () => {
|
|
|
|
|
|
const ids = ["start", "check", "mgr", "auto", "ship", "reject"]
|
|
|
|
|
|
const r = drawGraph(
|
|
|
|
|
|
[
|
|
|
|
|
|
{ id: "start", label: "Order received", shape: "terminator" },
|
|
|
|
|
|
{ id: "check", label: "Amount > $1000?", shape: "decision" },
|
|
|
|
|
|
n("mgr", "Manager approval"),
|
|
|
|
|
|
n("auto", "Auto-approve"),
|
|
|
|
|
|
n("ship", "Ship order"),
|
|
|
|
|
|
{ id: "reject", label: "Reject", shape: "terminator" },
|
|
|
|
|
|
],
|
|
|
|
|
|
[
|
|
|
|
|
|
e("start", "check"),
|
|
|
|
|
|
e("check", "mgr", "yes"),
|
|
|
|
|
|
e("check", "auto", "no"),
|
|
|
|
|
|
e("mgr", "ship", "approved"),
|
|
|
|
|
|
e("mgr", "reject", "denied"),
|
|
|
|
|
|
e("auto", "ship"),
|
|
|
|
|
|
],
|
|
|
|
|
|
{ title: "Order Approval" },
|
|
|
|
|
|
)
|
|
|
|
|
|
expect(r.errors).toEqual([])
|
|
|
|
|
|
const xml = r.xml as string
|
|
|
|
|
|
const rects = absoluteRects(xml)
|
|
|
|
|
|
expect(nodeCollisions(edgePaths(xml, rects), rects, ids)).toEqual([])
|
|
|
|
|
|
|
|
|
|
|
|
// Layers descend in flow order.
|
|
|
|
|
|
expect(rectOf(rects, "start").y).toBeLessThan(rectOf(rects, "check").y)
|
|
|
|
|
|
expect(rectOf(rects, "check").y).toBeLessThan(rectOf(rects, "mgr").y)
|
|
|
|
|
|
expect(rectOf(rects, "mgr").y).toBe(rectOf(rects, "auto").y)
|
|
|
|
|
|
|
|
|
|
|
|
expect(xml).toContain("Order Approval")
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it("renders a decision as a diamond and a terminator as a stadium", () => {
|
|
|
|
|
|
const r = drawGraph(
|
|
|
|
|
|
[
|
|
|
|
|
|
{ id: "s", label: "Start", shape: "terminator" },
|
|
|
|
|
|
{ id: "q", label: "OK?", shape: "decision" },
|
|
|
|
|
|
{ id: "d", label: "Report", shape: "document" },
|
|
|
|
|
|
{ id: "i", label: "Input", shape: "data" },
|
|
|
|
|
|
],
|
|
|
|
|
|
[e("s", "q"), e("q", "d"), e("q", "i")],
|
|
|
|
|
|
)
|
|
|
|
|
|
expect(r.errors).toEqual([])
|
|
|
|
|
|
const xml = r.xml as string
|
|
|
|
|
|
expect(xml).toMatch(/id="q"[^>]*rhombus/)
|
|
|
|
|
|
expect(xml).toMatch(/id="s"[^>]*arcSize=50/)
|
|
|
|
|
|
expect(xml).toMatch(/id="d"[^>]*shape=document/)
|
|
|
|
|
|
expect(xml).toMatch(/id="i"[^>]*shape=parallelogram/)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it("keeps a 14-node pipeline free of arrows through boxes", () => {
|
|
|
|
|
|
const ids = [
|
|
|
|
|
|
"commit",
|
|
|
|
|
|
"lint",
|
|
|
|
|
|
"unit",
|
|
|
|
|
|
"build",
|
|
|
|
|
|
"itest",
|
|
|
|
|
|
"sec",
|
|
|
|
|
|
"stage",
|
|
|
|
|
|
"smoke",
|
|
|
|
|
|
"approve",
|
|
|
|
|
|
"prod",
|
|
|
|
|
|
"canary",
|
|
|
|
|
|
"monitor",
|
|
|
|
|
|
"alert",
|
|
|
|
|
|
"rollback",
|
|
|
|
|
|
]
|
|
|
|
|
|
const r = drawGraph(
|
|
|
|
|
|
ids.map((id) => n(id)),
|
|
|
|
|
|
[
|
|
|
|
|
|
e("commit", "lint"),
|
|
|
|
|
|
e("commit", "unit"),
|
|
|
|
|
|
e("lint", "build"),
|
|
|
|
|
|
e("unit", "build"),
|
|
|
|
|
|
e("build", "itest"),
|
|
|
|
|
|
e("build", "sec"),
|
|
|
|
|
|
e("itest", "stage"),
|
|
|
|
|
|
e("sec", "stage"),
|
|
|
|
|
|
e("stage", "smoke"),
|
|
|
|
|
|
e("smoke", "approve"),
|
|
|
|
|
|
e("approve", "prod"),
|
|
|
|
|
|
e("prod", "canary"),
|
|
|
|
|
|
e("canary", "monitor"),
|
|
|
|
|
|
e("monitor", "alert"),
|
|
|
|
|
|
e("alert", "rollback"),
|
|
|
|
|
|
e("rollback", "stage"),
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
expect(r.errors).toEqual([])
|
|
|
|
|
|
const rects = absoluteRects(r.xml as string)
|
|
|
|
|
|
expect(
|
|
|
|
|
|
nodeCollisions(edgePaths(r.xml as string, rects), rects, ids),
|
|
|
|
|
|
).toEqual([])
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it("rejects an empty node list rather than drawing nothing", () => {
|
|
|
|
|
|
const r = drawGraph([], [])
|
|
|
|
|
|
expect(r.xml).toBeNull()
|
|
|
|
|
|
expect(r.errors[0]).toContain("no nodes")
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it("rejects a duplicate id instead of silently dropping one", () => {
|
|
|
|
|
|
const r = drawGraph([n("a"), n("a")], [])
|
|
|
|
|
|
expect(r.xml).toBeNull()
|
|
|
|
|
|
expect(r.errors[0]).toContain("duplicate")
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it("warns about an edge naming a node that is not there", () => {
|
|
|
|
|
|
const r = drawGraph([n("a")], [e("a", "ghost")])
|
|
|
|
|
|
expect(r.errors).toEqual([])
|
|
|
|
|
|
expect(r.warnings.join(" ")).toContain("ghost")
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it("warns which arrows were treated as loops", () => {
|
|
|
|
|
|
const r = drawGraph(
|
|
|
|
|
|
[n("a"), n("b")],
|
|
|
|
|
|
[e("a", "b"), e("b", "a", "retry")],
|
|
|
|
|
|
)
|
|
|
|
|
|
expect(r.errors).toEqual([])
|
|
|
|
|
|
expect(r.warnings.join(" ")).toContain("b→a")
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
fix(diagram-engine): eliminate arrows drawn through boxes, complete the route search
A user's approval-workflow flowchart came out with the return arrow drawn
straight through two unrelated steps, and a second report showed arrows
leaving a box and bending straight back across it. Measured over 250
generated flowcharts (2722 edges): 347 arrows crossed an unrelated box and
215 waypoints landed inside a shape. Four defects, each measured in
isolation:
1. The invisible layer containers draw_graph emits were handed to the
router as frames, so every clean return path was rejected for
"trespassing" on a border that is not drawn, and the fallback cut
through two boxes. Excluding invisible containers: 347 -> 218 crossing
arrows, no diagram made worse.
2. The router chose the horizontal-vs-vertical axis BEFORE searching, so
when the only clean corridor ran along the other axis it was never
looked at. A complete two-bend candidate generator that tries both
trunk axes, all four sides at each end, and the port fractions:
218 -> 27. (An independent ablation measured the axis pre-choice alone
at a 40% per-edge failure rate.)
3. Nothing stopped a route's first leg from turning back across its own
source shape - the obstacle test exempts an edge's own endpoints, and
must, since the line has to touch them. A terminal-leg rule refuses
such routes outright: 215 -> 4 hooks.
4. A two-bend search cannot express the staircase needed when a box sits
directly between two vertically aligned nodes (21 of the last 27
crossings). Added the orthogonal visibility graph + A* from Wybrow,
Marriott & Stuckey, "Orthogonal Connector Routing" (GD 2009) - the
libavoid algorithm - as the backstop when the candidate search finds
nothing. The interesting-points grid is provably sufficient: any valid
route shrinks onto it without getting longer or gaining bends. The A*
state is (point, incoming direction) with libavoid's bend cost of 10,
and the admissible bends-remaining heuristic, so it returns a cheapest
route, not merely a route. Implemented from the paper, not ported.
After all four: 0 crossing arrows and 0 hooks over the same 250 diagrams,
page area unchanged (494k px^2 mean), 260ms for the whole corpus, mean
0.82 bends per edge. The shape ladder still runs first, so routes that
were already clean are byte-identical.
Also post-nudge validation now checks the whole path (the nudge pass only
reverts the single segment it moved, judged in isolation) and restores the
search's route if nudging made it dirty.
2026-08-09 18:18:54 +09:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Two defects that both showed up as an arrow drawn over a box, on a flowchart with a
|
|
|
|
|
|
* "send it back" loop in it. Neither was visible on the smaller flowcharts above: one
|
|
|
|
|
|
* needs a back edge whose return path is lined with other steps, the other needs an edge
|
|
|
|
|
|
* that skips over a layer holding more than one node.
|
|
|
|
|
|
*/
|
|
|
|
|
|
describe("graphToOperations: arrows do not run through boxes", () => {
|
|
|
|
|
|
/** Ids of the real steps, excluding the engine's own layout cells. */
|
|
|
|
|
|
const steps = (xml: string): string[] =>
|
|
|
|
|
|
[...xml.matchAll(/<mxCell id="([^"]+)"[^>]*dai_kind=(?:box|icon);/g)]
|
|
|
|
|
|
.map((m) => m[1])
|
|
|
|
|
|
.filter((id) => !id.startsWith("__"))
|
|
|
|
|
|
|
|
|
|
|
|
it("routes a back edge around the steps it returns past", () => {
|
|
|
|
|
|
// The reported case: "return for correction" goes back to "submit", and the two
|
|
|
|
|
|
// steps between them are directly in the way. The engine wraps each layer in an
|
|
|
|
|
|
// INVISIBLE container, and those used to be handed to the router as frames — so
|
|
|
|
|
|
// every clean return path was rejected for "trespassing" on a frame that is not
|
|
|
|
|
|
// drawn, and the fallback cut straight through both steps.
|
|
|
|
|
|
const r = drawGraph(
|
|
|
|
|
|
[
|
|
|
|
|
|
{ id: "start", label: "Start", shape: "terminator" },
|
|
|
|
|
|
{ id: "submit", label: "Submit Request", shape: "data" },
|
|
|
|
|
|
n("validate", "Validate Input"),
|
|
|
|
|
|
{ id: "valid", label: "Valid?", shape: "decision" },
|
|
|
|
|
|
n("fix", "Return for Correction"),
|
|
|
|
|
|
n("review", "Manager Review"),
|
|
|
|
|
|
{ id: "end", label: "End", shape: "terminator" },
|
|
|
|
|
|
],
|
|
|
|
|
|
[
|
|
|
|
|
|
e("start", "submit"),
|
|
|
|
|
|
e("submit", "validate"),
|
|
|
|
|
|
e("validate", "valid"),
|
|
|
|
|
|
e("valid", "fix", "No"),
|
|
|
|
|
|
e("fix", "submit"),
|
|
|
|
|
|
e("valid", "review", "Yes"),
|
|
|
|
|
|
e("review", "end"),
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
expect(r.errors).toEqual([])
|
|
|
|
|
|
const xml = r.xml as string
|
|
|
|
|
|
const rects = absoluteRects(xml)
|
|
|
|
|
|
expect(
|
|
|
|
|
|
nodeCollisions(edgePaths(xml, rects), rects, steps(xml)),
|
|
|
|
|
|
).toEqual([])
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it("keeps the placeholders out of the reported layers", () => {
|
|
|
|
|
|
// They are an internal device for reserving space; a caller asked about its own nodes.
|
|
|
|
|
|
const { layers } = graphToOperations(
|
|
|
|
|
|
[n("a"), n("b1"), n("b2"), n("z")],
|
|
|
|
|
|
[
|
|
|
|
|
|
e("a", "b1"),
|
|
|
|
|
|
e("a", "b2"),
|
|
|
|
|
|
e("b1", "z"),
|
|
|
|
|
|
e("b2", "z"),
|
|
|
|
|
|
e("a", "z"),
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
expect(layers.flat().filter((id) => id.startsWith("__"))).toEqual([])
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* An arrow that leaves its own shape and immediately turns back across it.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Reported on a request-handling flowchart: a short hook came out of the right side of
|
|
|
|
|
|
* "Manager approval" and bent straight back over the box's own edge. The ordinary obstacle
|
|
|
|
|
|
* test cannot see this — an edge is exempt from its own two endpoints, and it has to be,
|
|
|
|
|
|
* since the line must touch them — so it is checked separately here.
|
|
|
|
|
|
*/
|
|
|
|
|
|
describe("graphToOperations: arrows leave their own shape cleanly", () => {
|
|
|
|
|
|
/** Waypoints that land inside any box, which is what draws the hook. */
|
|
|
|
|
|
const waypointsInsideBoxes = (xml: string): string[] => {
|
|
|
|
|
|
const rects = absoluteRects(xml)
|
|
|
|
|
|
const boxes = [
|
|
|
|
|
|
...xml.matchAll(/<mxCell id="([^"]+)"[^>]*dai_kind=(?:box|icon);/g),
|
|
|
|
|
|
]
|
|
|
|
|
|
.map((m) => m[1])
|
|
|
|
|
|
.filter((id) => !id.startsWith("__"))
|
|
|
|
|
|
const bad: string[] = []
|
|
|
|
|
|
for (const p of edgePaths(xml, rects))
|
|
|
|
|
|
for (const w of p.points.slice(1, -1))
|
|
|
|
|
|
for (const id of boxes) {
|
|
|
|
|
|
const r = rects.get(id)
|
|
|
|
|
|
if (!r) continue
|
|
|
|
|
|
if (
|
|
|
|
|
|
w.x > r.x + 1 &&
|
|
|
|
|
|
w.x < r.x + r.w - 1 &&
|
|
|
|
|
|
w.y > r.y + 1 &&
|
|
|
|
|
|
w.y < r.y + r.h - 1
|
|
|
|
|
|
)
|
|
|
|
|
|
bad.push(`${p.source}→${p.target} bends inside ${id}`)
|
|
|
|
|
|
}
|
|
|
|
|
|
return [...new Set(bad)]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
it("does not bend an arrow back over the box it just left", () => {
|
|
|
|
|
|
const r = drawGraph(
|
|
|
|
|
|
[
|
|
|
|
|
|
{
|
|
|
|
|
|
id: "start",
|
|
|
|
|
|
label: "Start: Request received",
|
|
|
|
|
|
shape: "terminator",
|
|
|
|
|
|
},
|
|
|
|
|
|
n("intake", "Log request in system"),
|
|
|
|
|
|
n("review", "Review details"),
|
|
|
|
|
|
{ id: "complete", label: "Info complete?", shape: "decision" },
|
|
|
|
|
|
{ id: "askinfo", label: "Request missing info", shape: "data" },
|
|
|
|
|
|
{ id: "approve", label: "Approval needed?", shape: "decision" },
|
|
|
|
|
|
n("manager", "Manager approval"),
|
|
|
|
|
|
n("process", "Process request"),
|
|
|
|
|
|
{ id: "report", label: "Generate report", shape: "document" },
|
|
|
|
|
|
n("notify", "Notify requester"),
|
|
|
|
|
|
{ id: "end", label: "End", shape: "terminator" },
|
|
|
|
|
|
{ id: "reject", label: "Reject & close", shape: "round" },
|
|
|
|
|
|
],
|
|
|
|
|
|
[
|
|
|
|
|
|
e("start", "intake"),
|
|
|
|
|
|
e("intake", "review"),
|
|
|
|
|
|
e("review", "complete"),
|
|
|
|
|
|
e("complete", "askinfo", "no"),
|
|
|
|
|
|
e("askinfo", "review", "resubmit"),
|
|
|
|
|
|
e("complete", "approve", "yes"),
|
|
|
|
|
|
e("approve", "manager", "yes"),
|
|
|
|
|
|
e("approve", "process", "no"),
|
|
|
|
|
|
e("manager", "process", "approved"),
|
|
|
|
|
|
e("manager", "reject", "declined"),
|
|
|
|
|
|
e("process", "report"),
|
|
|
|
|
|
e("report", "notify"),
|
|
|
|
|
|
e("reject", "notify"),
|
|
|
|
|
|
e("notify", "end"),
|
|
|
|
|
|
],
|
|
|
|
|
|
{ title: "Sample Request Handling Workflow" },
|
|
|
|
|
|
)
|
|
|
|
|
|
expect(r.errors).toEqual([])
|
|
|
|
|
|
expect(waypointsInsideBoxes(r.xml as string)).toEqual([])
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
feat(diagram-engine): label avoidance, paired opposite edges, semantic group colours
A git-workflow flowchart rendered with no overlaps but read poorly. Three
distinct causes, each fixed and measured:
1. Edge labels sat on boxes and on each other (4 collisions on the
reported diagram; 280 across 250 generated flowcharts). The router
keeps LINES off the boxes but a label renders at its edge's midpoint,
which on a long edge is beside exactly the things the line was routed
around. placeLabels slides each label along its own edge to a clear
spot — longest edges first, midpoint-outward tries — written as the
geometry's relative x, which draw.io natively supports. Corpus: 280
label collisions -> 8.
2. A->B and B->A were routed independently, so "git add" ran straight
while "git reset" wandered through a different corridor with a kink.
Opposite edges that agree on axis now get two absolute parallel tracks
in the strip where the two boxes overlap, a constant 24px apart,
converted back to port fractions. Zero crossing regressions.
3. All boxes rendered the same white, because the render layer's
fill/stroke support was never reachable: neither add_box's schema nor
draw_graph's nodes exposed it. Rather than exposing raw hex (the model
picks mismatched saturations, differently every time), nodes take a
semantic group name and the engine maps groups to a fixed palette of
six paired fill/strokes in order of first appearance. The model names
the zones - remote vs local vs temp - and never touches a colour.
532 unit tests pass; the 5 diagram e2e tests pass in a real browser.
2026-08-09 18:59:34 +09:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Edge labels must not sit on boxes or on each other.
|
|
|
|
|
|
*
|
|
|
|
|
|
* The router keeps LINES off the boxes, but a label renders at its edge's midpoint — and on
|
|
|
|
|
|
* a long edge that midpoint is beside exactly the things the line was routed around. On the
|
|
|
|
|
|
* reported git-workflow diagram, four labels sat on unrelated boxes or on other labels.
|
|
|
|
|
|
* `placeLabels` slides each label along its own edge to a clear spot, written as the
|
|
|
|
|
|
* geometry's relative x.
|
|
|
|
|
|
*/
|
|
|
|
|
|
describe("edge labels avoid boxes and each other", () => {
|
|
|
|
|
|
it("places every git-workflow label on empty space", () => {
|
|
|
|
|
|
const r = drawGraph(
|
|
|
|
|
|
[
|
|
|
|
|
|
{
|
|
|
|
|
|
id: "remote",
|
|
|
|
|
|
label: "Remote Repository (origin / GitHub)",
|
|
|
|
|
|
shape: "data",
|
|
|
|
|
|
},
|
|
|
|
|
|
n("work", "Working Directory (edited files)"),
|
|
|
|
|
|
n("stage", "Staging Area (index)"),
|
|
|
|
|
|
{
|
|
|
|
|
|
id: "stash",
|
|
|
|
|
|
label: "Stash (temporary shelf)",
|
|
|
|
|
|
shape: "round",
|
|
|
|
|
|
},
|
|
|
|
|
|
n("local", "Local Repository (.git commits)"),
|
|
|
|
|
|
{
|
|
|
|
|
|
id: "branch",
|
|
|
|
|
|
label: "Branch / Merge (feature -> main)",
|
|
|
|
|
|
shape: "decision",
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
[
|
|
|
|
|
|
e("remote", "work", "git clone"),
|
|
|
|
|
|
e("work", "stage", "git add"),
|
|
|
|
|
|
{
|
|
|
|
|
|
source: "stage",
|
|
|
|
|
|
target: "work",
|
|
|
|
|
|
label: "git reset",
|
|
|
|
|
|
dashed: true,
|
|
|
|
|
|
},
|
|
|
|
|
|
e("work", "stash", "git stash"),
|
|
|
|
|
|
e("stash", "work", "git stash pop"),
|
|
|
|
|
|
e("stage", "local", "git commit"),
|
|
|
|
|
|
{
|
|
|
|
|
|
source: "local",
|
|
|
|
|
|
target: "stage",
|
|
|
|
|
|
label: "git checkout",
|
|
|
|
|
|
dashed: true,
|
|
|
|
|
|
},
|
|
|
|
|
|
e("local", "work", "git pull / merge"),
|
|
|
|
|
|
{
|
|
|
|
|
|
source: "remote",
|
|
|
|
|
|
target: "local",
|
|
|
|
|
|
label: "git fetch",
|
|
|
|
|
|
dashed: true,
|
|
|
|
|
|
},
|
|
|
|
|
|
e("local", "branch", "git branch / checkout -b"),
|
|
|
|
|
|
e("branch", "remote", "git push"),
|
|
|
|
|
|
],
|
|
|
|
|
|
{ title: "Git Operations Overview" },
|
|
|
|
|
|
)
|
|
|
|
|
|
expect(r.errors).toEqual([])
|
|
|
|
|
|
const xml = r.xml as string
|
|
|
|
|
|
const rects = absoluteRects(xml)
|
|
|
|
|
|
const ids = ["remote", "work", "stage", "stash", "local", "branch"]
|
|
|
|
|
|
|
|
|
|
|
|
// Recompute each label's rectangle the way draw.io places it: at the geometry's
|
|
|
|
|
|
// relative x along the path (−1 source, 0 middle, +1 target).
|
|
|
|
|
|
const labels: {
|
|
|
|
|
|
id: string
|
|
|
|
|
|
x: number
|
|
|
|
|
|
y: number
|
|
|
|
|
|
w: number
|
|
|
|
|
|
h: number
|
|
|
|
|
|
}[] = []
|
|
|
|
|
|
for (const p of edgePaths(xml, rects)) {
|
|
|
|
|
|
if (!p.label) continue
|
|
|
|
|
|
const m = xml.match(
|
|
|
|
|
|
new RegExp(
|
|
|
|
|
|
`<mxCell id="${p.id}"[^>]*>\\s*<mxGeometry x="(-?[\\d.]+)" relative="1"`,
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
const t = ((m ? Number(m[1]) : 0) + 1) / 2
|
|
|
|
|
|
let total = 0
|
|
|
|
|
|
const segs = p.points.slice(0, -1).map((pt, i) => {
|
|
|
|
|
|
const len =
|
|
|
|
|
|
Math.abs(p.points[i + 1].x - pt.x) +
|
|
|
|
|
|
Math.abs(p.points[i + 1].y - pt.y)
|
|
|
|
|
|
total += len
|
|
|
|
|
|
return { a: pt, b: p.points[i + 1], len }
|
|
|
|
|
|
})
|
|
|
|
|
|
let at = total * t
|
|
|
|
|
|
let pos = p.points[0]
|
|
|
|
|
|
for (const s of segs) {
|
|
|
|
|
|
if (at <= s.len || s === segs[segs.length - 1]) {
|
|
|
|
|
|
const f = s.len ? Math.min(1, at / s.len) : 0
|
|
|
|
|
|
pos = {
|
|
|
|
|
|
x: s.a.x + (s.b.x - s.a.x) * f,
|
|
|
|
|
|
y: s.a.y + (s.b.y - s.a.y) * f,
|
|
|
|
|
|
}
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
at -= s.len
|
|
|
|
|
|
}
|
|
|
|
|
|
const w = Math.min(160, p.label.length * 7 + 8)
|
|
|
|
|
|
labels.push({ id: p.id, x: pos.x - w / 2, y: pos.y - 8, w, h: 16 })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const bad: string[] = []
|
|
|
|
|
|
for (const l of labels) {
|
|
|
|
|
|
for (const id of ids) {
|
|
|
|
|
|
const b = rectOf(rects, id)
|
|
|
|
|
|
if (
|
|
|
|
|
|
l.x < b.x + b.w &&
|
|
|
|
|
|
l.x + l.w > b.x &&
|
|
|
|
|
|
l.y < b.y + b.h &&
|
|
|
|
|
|
l.y + l.h > b.y
|
|
|
|
|
|
)
|
|
|
|
|
|
bad.push(`${l.id} label sits on ${id}`)
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const m of labels)
|
|
|
|
|
|
if (
|
|
|
|
|
|
m !== l &&
|
|
|
|
|
|
l.id < m.id &&
|
|
|
|
|
|
l.x < m.x + m.w &&
|
|
|
|
|
|
l.x + l.w > m.x &&
|
|
|
|
|
|
l.y < m.y + m.h &&
|
|
|
|
|
|
l.y + l.h > m.y
|
|
|
|
|
|
)
|
|
|
|
|
|
bad.push(`${l.id} label sits on ${m.id} label`)
|
|
|
|
|
|
}
|
|
|
|
|
|
expect(bad).toEqual([])
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* A→B and B→A are one relationship drawn as two arrows, and a reader expects a matched
|
|
|
|
|
|
* pair: two parallel lines a constant gap apart. Routed independently they land on port
|
|
|
|
|
|
* positions chosen for unrelated reasons, so one line runs straight while its partner
|
|
|
|
|
|
* wanders through a different corridor with a kink in it.
|
|
|
|
|
|
*/
|
|
|
|
|
|
describe("opposite edges are drawn as a parallel pair", () => {
|
|
|
|
|
|
it("gives git add / git reset two straight tracks a constant gap apart", () => {
|
|
|
|
|
|
const r = drawGraph(
|
|
|
|
|
|
[n("work", "Working Directory"), n("stage", "Staging Area")],
|
|
|
|
|
|
[
|
|
|
|
|
|
e("work", "stage", "git add"),
|
|
|
|
|
|
{
|
|
|
|
|
|
source: "stage",
|
|
|
|
|
|
target: "work",
|
|
|
|
|
|
label: "git reset",
|
|
|
|
|
|
dashed: true,
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
expect(r.errors).toEqual([])
|
|
|
|
|
|
const xml = r.xml as string
|
|
|
|
|
|
const rects = absoluteRects(xml)
|
|
|
|
|
|
const paths = edgePaths(xml, rects)
|
|
|
|
|
|
const fwd = paths.find((p) => p.source === "work")
|
|
|
|
|
|
const rev = paths.find((p) => p.source === "stage")
|
|
|
|
|
|
if (!fwd || !rev) throw new Error("both edges must render")
|
|
|
|
|
|
|
|
|
|
|
|
// Both straight: two points, no waypoints.
|
|
|
|
|
|
expect(fwd.points).toHaveLength(2)
|
|
|
|
|
|
expect(rev.points).toHaveLength(2)
|
|
|
|
|
|
// Parallel vertical tracks a constant gap apart.
|
|
|
|
|
|
expect(Math.abs(fwd.points[0].x - fwd.points[1].x)).toBeLessThan(1)
|
|
|
|
|
|
expect(Math.abs(rev.points[0].x - rev.points[1].x)).toBeLessThan(1)
|
|
|
|
|
|
const gap = Math.abs(fwd.points[0].x - rev.points[0].x)
|
|
|
|
|
|
expect(gap).toBeGreaterThan(12)
|
|
|
|
|
|
expect(gap).toBeLessThan(40)
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Semantic groups: the caller names zones, the engine colours them.
|
|
|
|
|
|
*
|
|
|
|
|
|
* The model is good at judging which nodes belong together and bad at picking hex colours
|
|
|
|
|
|
* that match; letting it choose produced mismatched saturations and a different look per
|
|
|
|
|
|
* diagram. So `group` maps to a fixed engine palette in order of first appearance, and the
|
|
|
|
|
|
* same grouping always produces the same colours.
|
|
|
|
|
|
*/
|
|
|
|
|
|
describe("draw_graph: semantic groups", () => {
|
|
|
|
|
|
it("colours nodes by group, consistently, without the caller naming a colour", () => {
|
|
|
|
|
|
const r = drawGraph(
|
|
|
|
|
|
[
|
|
|
|
|
|
{ id: "a", label: "A", group: "remote" },
|
|
|
|
|
|
{ id: "b", label: "B", group: "local" },
|
|
|
|
|
|
{ id: "c", label: "C", group: "local" },
|
|
|
|
|
|
n("d", "D"),
|
|
|
|
|
|
],
|
|
|
|
|
|
[e("a", "b"), e("b", "c"), e("c", "d")],
|
|
|
|
|
|
)
|
|
|
|
|
|
expect(r.errors).toEqual([])
|
|
|
|
|
|
const xml = r.xml as string
|
|
|
|
|
|
const fillOf = (id: string) => {
|
|
|
|
|
|
const m = xml.match(new RegExp(`id="${id}"[^>]*style="([^"]*)"`))
|
|
|
|
|
|
return [...(m?.[1] ?? "").matchAll(/fillColor=([^;]*)/g)].pop()?.[1]
|
|
|
|
|
|
}
|
|
|
|
|
|
// First group named gets the first palette slot; same group, same colour.
|
|
|
|
|
|
expect(fillOf("a")).toBe("#DAE8FC")
|
|
|
|
|
|
expect(fillOf("b")).toBe("#D5E8D4")
|
|
|
|
|
|
expect(fillOf("c")).toBe("#D5E8D4")
|
|
|
|
|
|
// No group: the plain white fallback.
|
|
|
|
|
|
expect(fillOf("d")).toBe("#FFFFFF")
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it("group colours survive a round trip through the canvas", () => {
|
|
|
|
|
|
const r = drawGraph(
|
|
|
|
|
|
[
|
|
|
|
|
|
{ id: "a", label: "A", group: "g1" },
|
|
|
|
|
|
{ id: "b", label: "B", group: "g2" },
|
|
|
|
|
|
],
|
|
|
|
|
|
[e("a", "b")],
|
|
|
|
|
|
)
|
|
|
|
|
|
const again = restructureDiagram(r.xml as string, [])
|
|
|
|
|
|
const fillOf = (xml: string, id: string) => {
|
|
|
|
|
|
const m = xml.match(new RegExp(`id="${id}"[^>]*style="([^"]*)"`))
|
|
|
|
|
|
return [...(m?.[1] ?? "").matchAll(/fillColor=([^;]*)/g)].pop()?.[1]
|
|
|
|
|
|
}
|
|
|
|
|
|
expect(fillOf(again.xml as string, "a")).toBe(
|
|
|
|
|
|
fillOf(r.xml as string, "a"),
|
|
|
|
|
|
)
|
|
|
|
|
|
expect(fillOf(again.xml as string, "b")).toBe(
|
|
|
|
|
|
fillOf(r.xml as string, "b"),
|
|
|
|
|
|
)
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|