feat(diagram-engine): wire up restructure_diagram + stencil catalog
Closes the loop: the model can now build and edit AWS architecture diagrams by
declaring structure, and never writes an mxCell again.
catalog.ts — 983 AWS icon and 19 group stencils as a name→style map, generated from
drawio-ai-kit's catalog (itself generated from jgraph's draw.io shape index). Styles
are verbatim, so the official category colours, connection points and aspect=fixed
come along for free and nothing is hand-assembled. An invented name is rejected with
suggestions instead of rendering as a blank square, which is what draw.io does with
an unknown resIcon today.
operations.ts — what the model actually sends: add_icon / add_container / move /
link / set_dir and so on, applied in order against the tree. Guards the things that
break a diagram quietly: duplicate ids (draw.io drops one of the two cells), edges
left pointing at a removed node, and moving a container inside itself.
index.ts — the entry point. current XML → parse → apply ops → check names → layout →
render → new XML. The tree is not stored between calls; it is re-derived from the
canvas every time, so a user's manual edits are input to the next layout rather than
state to reconcile.
Token cost, measured with Claude's tokenizer rather than estimated:
- build a VPC diagram: 515 tok as operations vs 3180 as XML (6.2x)
- add one icon: 27 tok as an operation vs 3823 re-emitting (142x)
- read current state: 216 tok as an outline vs 3180 as XML (14.7x)
The 142x is the one that matters day to day: "add a Redis" is one operation, not a
rewrite of the whole diagram.
Routing in the system prompt sends AWS architecture through this path and leaves
flowcharts, BPMN, sequence diagrams, mind maps and Azure/GCP on display_diagram —
the layout engine's primitives (nested rows, columns, grids) do not model a sequence
diagram's lifelines or a mind map's radial spread, and pretending otherwise would
make those worse rather than better.
Also: added a NOTICE recording the MIT port and the AWS Architecture Icons terms,
and a narrow .gitignore exception so the generated catalog is tracked while the
root data/ directory (admin settings, contains secrets) stays ignored.
403 unit tests + 3 new e2e. Verified in the real app: a structural tool call renders
with real stencils and container markers; a second call adds one node and keeps
everything from the first; an invented name is refused and nothing is drawn. The 13
existing diagram e2e tests still pass.
2026-08-09 12:13:54 +09:00
|
|
|
/**
|
|
|
|
|
* The engine's entry point: one call takes the current canvas XML plus a list of
|
|
|
|
|
* structural operations and returns new canvas XML.
|
|
|
|
|
*
|
|
|
|
|
* current XML → parse → apply operations → check names → layout → render → new XML
|
|
|
|
|
*
|
|
|
|
|
* The tree is not stored anywhere between calls. It is re-derived from the canvas every
|
|
|
|
|
* time, so a user's manual edits — moving a shape into a different frame, recolouring a
|
|
|
|
|
* box, adding an annotation — are simply part of the input to the next layout. There is
|
|
|
|
|
* no second copy of the state, and therefore nothing to reconcile.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { checkNames, resolveStyle } from "./catalog"
|
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 {
|
|
|
|
|
type GraphEdge,
|
|
|
|
|
type GraphNode,
|
|
|
|
|
type GraphOptions,
|
|
|
|
|
graphToOperations,
|
|
|
|
|
} from "./graph"
|
feat(diagram-engine): wire up restructure_diagram + stencil catalog
Closes the loop: the model can now build and edit AWS architecture diagrams by
declaring structure, and never writes an mxCell again.
catalog.ts — 983 AWS icon and 19 group stencils as a name→style map, generated from
drawio-ai-kit's catalog (itself generated from jgraph's draw.io shape index). Styles
are verbatim, so the official category colours, connection points and aspect=fixed
come along for free and nothing is hand-assembled. An invented name is rejected with
suggestions instead of rendering as a blank square, which is what draw.io does with
an unknown resIcon today.
operations.ts — what the model actually sends: add_icon / add_container / move /
link / set_dir and so on, applied in order against the tree. Guards the things that
break a diagram quietly: duplicate ids (draw.io drops one of the two cells), edges
left pointing at a removed node, and moving a container inside itself.
index.ts — the entry point. current XML → parse → apply ops → check names → layout →
render → new XML. The tree is not stored between calls; it is re-derived from the
canvas every time, so a user's manual edits are input to the next layout rather than
state to reconcile.
Token cost, measured with Claude's tokenizer rather than estimated:
- build a VPC diagram: 515 tok as operations vs 3180 as XML (6.2x)
- add one icon: 27 tok as an operation vs 3823 re-emitting (142x)
- read current state: 216 tok as an outline vs 3180 as XML (14.7x)
The 142x is the one that matters day to day: "add a Redis" is one operation, not a
rewrite of the whole diagram.
Routing in the system prompt sends AWS architecture through this path and leaves
flowcharts, BPMN, sequence diagrams, mind maps and Azure/GCP on display_diagram —
the layout engine's primitives (nested rows, columns, grids) do not model a sequence
diagram's lifelines or a mind map's radial spread, and pretending otherwise would
make those worse rather than better.
Also: added a NOTICE recording the MIT port and the AWS Architecture Icons terms,
and a narrow .gitignore exception so the generated catalog is tracked while the
root data/ directory (admin settings, contains secrets) stays ignored.
403 unit tests + 3 new e2e. Verified in the real app: a structural tool call renders
with real stencils and container markers; a second call adds one node and keeps
everything from the first; an invented name is refused and nothing is drawn. The 13
existing diagram e2e tests still pass.
2026-08-09 12:13:54 +09:00
|
|
|
import {
|
|
|
|
|
applyOperations,
|
|
|
|
|
collectNames,
|
|
|
|
|
type Operation,
|
|
|
|
|
outline,
|
|
|
|
|
} from "./operations"
|
|
|
|
|
import { parseDiagram } from "./parse"
|
|
|
|
|
import { renderDiagram } from "./render"
|
|
|
|
|
import type { DiagramTree } from "./types"
|
|
|
|
|
|
|
|
|
|
export interface RestructureResult {
|
|
|
|
|
/** New canvas XML, or null when the request could not be carried out. */
|
|
|
|
|
xml: string | null
|
|
|
|
|
/** Compact outline of the resulting structure, for the model to read back. */
|
|
|
|
|
outline: string
|
|
|
|
|
/** Operations that could not be applied, and invented stencil names. */
|
|
|
|
|
errors: string[]
|
|
|
|
|
/** Non-fatal notes: pages skipped, structure that could not be read cleanly. */
|
|
|
|
|
warnings: string[]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface RestructureOptions {
|
|
|
|
|
/** Which page of a multi-page document to work on. */
|
|
|
|
|
pageIndex?: number
|
|
|
|
|
/** Diagram-wide icon glyph size. */
|
|
|
|
|
iconSize?: number
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Apply structural operations to whatever is on the canvas.
|
|
|
|
|
*
|
|
|
|
|
* `currentXml` may be empty — that is how a diagram gets built from scratch.
|
|
|
|
|
*
|
|
|
|
|
* An invented stencil name is a hard error, not a silent fallback: draw.io renders an
|
|
|
|
|
* unknown `resIcon` as a blank square, so a diagram that "worked" would be quietly
|
|
|
|
|
* missing icons. The error carries suggestions from the catalog so the model can fix it
|
|
|
|
|
* in one more turn.
|
|
|
|
|
*/
|
|
|
|
|
export function restructureDiagram(
|
|
|
|
|
currentXml: string,
|
|
|
|
|
ops: Operation[],
|
|
|
|
|
opts: RestructureOptions = {},
|
|
|
|
|
): RestructureResult {
|
|
|
|
|
const warnings: string[] = []
|
|
|
|
|
|
|
|
|
|
let tree: DiagramTree
|
|
|
|
|
if (currentXml.trim()) {
|
|
|
|
|
const parsed = parseDiagram(currentXml, opts.pageIndex ?? 0)
|
|
|
|
|
tree = parsed.tree
|
|
|
|
|
warnings.push(...parsed.warnings)
|
|
|
|
|
} else {
|
|
|
|
|
tree = { roots: [], links: [], foreign: [] }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const applied = applyOperations(tree, ops)
|
|
|
|
|
const errors = [...applied.errors]
|
|
|
|
|
|
|
|
|
|
// Catch invented names before rendering, so the model gets a correctable error
|
|
|
|
|
// instead of a diagram with blank squares in it.
|
|
|
|
|
for (const bad of checkNames(collectNames(applied.tree))) {
|
|
|
|
|
const hint = bad.suggestions.length
|
|
|
|
|
? ` Did you mean: ${bad.suggestions.join(", ")}?`
|
|
|
|
|
: ""
|
|
|
|
|
errors.push(
|
|
|
|
|
`"${bad.name}" (node ${bad.id}) is not in the stencil catalog.${hint}`,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (errors.length > 0)
|
|
|
|
|
return { xml: null, outline: outline(applied.tree), errors, warnings }
|
|
|
|
|
|
|
|
|
|
const rendered = renderDiagram(applied.tree, {
|
|
|
|
|
resolveStyle,
|
|
|
|
|
iconSize: opts.iconSize,
|
|
|
|
|
})
|
|
|
|
|
if (rendered.danglingLinks.length)
|
|
|
|
|
warnings.push(
|
|
|
|
|
`Dropped edge(s) pointing at missing nodes: ${rendered.danglingLinks.join(", ")}.`,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
xml: rendered.xml,
|
|
|
|
|
outline: outline(applied.tree),
|
|
|
|
|
errors: [],
|
|
|
|
|
warnings,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
/**
|
|
|
|
|
* 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] }
|
|
|
|
|
}
|
|
|
|
|
|
feat(diagram-engine): wire up restructure_diagram + stencil catalog
Closes the loop: the model can now build and edit AWS architecture diagrams by
declaring structure, and never writes an mxCell again.
catalog.ts — 983 AWS icon and 19 group stencils as a name→style map, generated from
drawio-ai-kit's catalog (itself generated from jgraph's draw.io shape index). Styles
are verbatim, so the official category colours, connection points and aspect=fixed
come along for free and nothing is hand-assembled. An invented name is rejected with
suggestions instead of rendering as a blank square, which is what draw.io does with
an unknown resIcon today.
operations.ts — what the model actually sends: add_icon / add_container / move /
link / set_dir and so on, applied in order against the tree. Guards the things that
break a diagram quietly: duplicate ids (draw.io drops one of the two cells), edges
left pointing at a removed node, and moving a container inside itself.
index.ts — the entry point. current XML → parse → apply ops → check names → layout →
render → new XML. The tree is not stored between calls; it is re-derived from the
canvas every time, so a user's manual edits are input to the next layout rather than
state to reconcile.
Token cost, measured with Claude's tokenizer rather than estimated:
- build a VPC diagram: 515 tok as operations vs 3180 as XML (6.2x)
- add one icon: 27 tok as an operation vs 3823 re-emitting (142x)
- read current state: 216 tok as an outline vs 3180 as XML (14.7x)
The 142x is the one that matters day to day: "add a Redis" is one operation, not a
rewrite of the whole diagram.
Routing in the system prompt sends AWS architecture through this path and leaves
flowcharts, BPMN, sequence diagrams, mind maps and Azure/GCP on display_diagram —
the layout engine's primitives (nested rows, columns, grids) do not model a sequence
diagram's lifelines or a mind map's radial spread, and pretending otherwise would
make those worse rather than better.
Also: added a NOTICE recording the MIT port and the AWS Architecture Icons terms,
and a narrow .gitignore exception so the generated catalog is tracked while the
root data/ directory (admin settings, contains secrets) stays ignored.
403 unit tests + 3 new e2e. Verified in the real app: a structural tool call renders
with real stencils and container markers; a second call adds one node and keeps
everything from the first; an invented name is refused and nothing is drawn. The 13
existing diagram e2e tests still pass.
2026-08-09 12:13:54 +09:00
|
|
|
/** Read the current canvas structure without changing it. */
|
|
|
|
|
export function describeDiagram(
|
|
|
|
|
currentXml: string,
|
|
|
|
|
pageIndex = 0,
|
|
|
|
|
): { outline: string; warnings: string[]; needsAdoption: boolean } {
|
|
|
|
|
if (!currentXml.trim())
|
|
|
|
|
return { outline: "(empty canvas)", warnings: [], needsAdoption: false }
|
|
|
|
|
const { tree, warnings, needsAdoption } = parseDiagram(
|
|
|
|
|
currentXml,
|
|
|
|
|
pageIndex,
|
|
|
|
|
)
|
|
|
|
|
return { outline: outline(tree), warnings, needsAdoption }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export { CATALOG_SIZE, lookupStencil, searchStencils } from "./catalog"
|
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
|
|
|
export {
|
|
|
|
|
type GraphEdge,
|
|
|
|
|
type GraphNode,
|
|
|
|
|
type GraphOptions,
|
|
|
|
|
graphToOperations,
|
|
|
|
|
} from "./graph"
|
feat(diagram-engine): wire up restructure_diagram + stencil catalog
Closes the loop: the model can now build and edit AWS architecture diagrams by
declaring structure, and never writes an mxCell again.
catalog.ts — 983 AWS icon and 19 group stencils as a name→style map, generated from
drawio-ai-kit's catalog (itself generated from jgraph's draw.io shape index). Styles
are verbatim, so the official category colours, connection points and aspect=fixed
come along for free and nothing is hand-assembled. An invented name is rejected with
suggestions instead of rendering as a blank square, which is what draw.io does with
an unknown resIcon today.
operations.ts — what the model actually sends: add_icon / add_container / move /
link / set_dir and so on, applied in order against the tree. Guards the things that
break a diagram quietly: duplicate ids (draw.io drops one of the two cells), edges
left pointing at a removed node, and moving a container inside itself.
index.ts — the entry point. current XML → parse → apply ops → check names → layout →
render → new XML. The tree is not stored between calls; it is re-derived from the
canvas every time, so a user's manual edits are input to the next layout rather than
state to reconcile.
Token cost, measured with Claude's tokenizer rather than estimated:
- build a VPC diagram: 515 tok as operations vs 3180 as XML (6.2x)
- add one icon: 27 tok as an operation vs 3823 re-emitting (142x)
- read current state: 216 tok as an outline vs 3180 as XML (14.7x)
The 142x is the one that matters day to day: "add a Redis" is one operation, not a
rewrite of the whole diagram.
Routing in the system prompt sends AWS architecture through this path and leaves
flowcharts, BPMN, sequence diagrams, mind maps and Azure/GCP on display_diagram —
the layout engine's primitives (nested rows, columns, grids) do not model a sequence
diagram's lifelines or a mind map's radial spread, and pretending otherwise would
make those worse rather than better.
Also: added a NOTICE recording the MIT port and the AWS Architecture Icons terms,
and a narrow .gitignore exception so the generated catalog is tracked while the
root data/ directory (admin settings, contains secrets) stays ignored.
403 unit tests + 3 new e2e. Verified in the real app: a structural tool call renders
with real stencils and container markers; a second call adds one node and keeps
everything from the first; an invented name is refused and nothing is drawn. The 13
existing diagram e2e tests still pass.
2026-08-09 12:13:54 +09:00
|
|
|
export { type Operation, OperationSchema } from "./operations"
|
|
|
|
|
export { parseDiagram } from "./parse"
|
|
|
|
|
export { renderDiagram } from "./render"
|
|
|
|
|
export type { DiagramNode, DiagramTree } from "./types"
|