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.
This commit is contained in:
dayuan.jiang
2026-08-09 13:47:23 +09:00
parent 1e4c74d464
commit a3814f702d
27 changed files with 4497 additions and 88 deletions

View File

@@ -11,6 +11,12 @@
*/
import { checkNames, resolveStyle } from "./catalog"
import {
type GraphEdge,
type GraphNode,
type GraphOptions,
graphToOperations,
} from "./graph"
import {
applyOperations,
collectNames,
@@ -99,6 +105,69 @@ export function restructureDiagram(
}
}
/**
* Draw a flowchart, dependency graph, ER diagram or site map from nodes and arrows alone.
*
* The model gives no positions and no nesting — just what the boxes are and what points at
* what. The engine works out how many rows there are, who shares a row, and who goes left
* of whom, then hands the result to the same layout and edge router the architecture
* diagrams use.
*
* This exists because declaring a flowchart as nesting does not work: six steps declared in
* their natural order become one column, and every branch then has to jump over the step
* beside it. The layering has to come from the arrows, and only the engine can see all of
* them at once.
*
* Replaces the whole diagram rather than adding to it: the layer assignment depends on every
* arrow, so one new edge can move half the nodes. Editing afterwards goes through
* `restructureDiagram` as usual.
*/
export function drawGraph(
nodes: GraphNode[],
edges: GraphEdge[],
opts: RestructureOptions & GraphOptions & { title?: string } = {},
): RestructureResult {
if (nodes.length === 0)
return {
xml: null,
outline: "",
errors: ["draw_graph: no nodes — nothing to draw."],
warnings: [],
}
const dupes = nodes
.map((n) => n.id)
.filter((id, i, all) => all.indexOf(id) !== i)
if (dupes.length > 0)
return {
xml: null,
outline: "",
errors: [
`draw_graph: duplicate node id(s): ${[...new Set(dupes)].join(", ")}.`,
],
warnings: [],
}
const graph = graphToOperations(nodes, edges, opts)
const warnings: string[] = []
if (graph.unknownEndpoints.length)
warnings.push(
`Dropped edge(s) naming nodes that were not in the node list: ${graph.unknownEndpoints.join(", ")}.`,
)
if (graph.backEdges.length)
warnings.push(
`Loop(s) drawn but not used for ordering: ${graph.backEdges
.map((e) => `${e.source}${e.target}`)
.join(", ")}.`,
)
const ops: Operation[] = opts.title
? [{ op: "set_title", title: opts.title }, ...graph.operations]
: graph.operations
const result = restructureDiagram("", ops, opts)
return { ...result, warnings: [...warnings, ...result.warnings] }
}
/** Read the current canvas structure without changing it. */
export function describeDiagram(
currentXml: string,
@@ -114,6 +183,12 @@ export function describeDiagram(
}
export { CATALOG_SIZE, lookupStencil, searchStencils } from "./catalog"
export {
type GraphEdge,
type GraphNode,
type GraphOptions,
graphToOperations,
} from "./graph"
export { type Operation, OperationSchema } from "./operations"
export { parseDiagram } from "./parse"
export { renderDiagram } from "./render"