mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-02 01:20:23 +08:00
feat(diagram-engine): add_graph — arrow-ordered layout as a container
The layout vocabulary's biggest gap, after D2/TALA's 'containers are first-class at every layout stage': hierarchical zones and arrow-ordered graphs could not mix. draw_graph did whole-page flowcharts, containers did nesting, and 'an architecture zone whose contents follow the data flow' was inexpressible. add_graph is a macro operation: nodes+edges go through the existing layered pass (graph.ts — cycle breaking, longest-path layering, barycentre crossing reduction) which emits ordinary container/box/link operations, and the resulting block participates in the outer flexbox like any node. dir col/row transposes the flow. No new layout code — the coordinate work was always generic; what was missing was the entry point below page level. Synthetic layer ids are namespaced by the graph's own id (g1__layer0), fixing the collision that previously made a second graph per page impossible. graph.ts gains parent/prefix/rootId options; draw_graph keeps its behaviour as the page-level case of the same code path. 4 new tests: embedding in a flexbox column, two graphs per page, dir transposition, unknown-endpoint errors. 565 unit tests green, 8 engine e2e green. Acceptance: three-zone architecture diagram (person/cloud zone, arrow-ordered pipeline zone with decision branches and a bold arrow, cylinder/queue storage zone, cross-zone links) verified in the real editor.
This commit is contained in:
@@ -61,6 +61,16 @@ export interface GraphEdge {
|
||||
export interface GraphOptions {
|
||||
/** "col" (default): layers stack downwards. "row": layers run left to right. */
|
||||
flow?: "col" | "row"
|
||||
/** Container to embed the graph in; absent means the page. */
|
||||
parent?: string
|
||||
/**
|
||||
* Namespace for the synthetic layer-container ids. Without one, two graphs on one
|
||||
* page would both emit `__layers`/`__layer0` and the second would be rejected as a
|
||||
* duplicate id.
|
||||
*/
|
||||
prefix?: string
|
||||
/** Id for the outer container itself; defaults to `${prefix}__layers`. */
|
||||
rootId?: string
|
||||
}
|
||||
|
||||
/** Distance between layers. */
|
||||
@@ -305,12 +315,14 @@ export function graphToOperations(
|
||||
// The flow axis is the OUTER container's direction; a layer runs across it.
|
||||
const outerDir = flow
|
||||
const layerDir = flow === "col" ? "row" : "col"
|
||||
const root = `${LAYER_ID}s`
|
||||
const ns = opts.prefix ?? ""
|
||||
const root = opts.rootId ?? `${ns}${LAYER_ID}s`
|
||||
|
||||
const operations: Operation[] = [
|
||||
{
|
||||
op: "add_container",
|
||||
id: root,
|
||||
...(opts.parent ? { parent: opts.parent } : {}),
|
||||
label: "",
|
||||
dir: outerDir,
|
||||
gap: LAYER_GAP,
|
||||
@@ -344,7 +356,7 @@ export function graphToOperations(
|
||||
operations.push(add(members[0], root))
|
||||
return
|
||||
}
|
||||
const band = `${LAYER_ID}${i}`
|
||||
const band = `${ns}${LAYER_ID}${i}`
|
||||
operations.push({
|
||||
op: "add_container",
|
||||
id: band,
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
*/
|
||||
|
||||
import { z } from "zod"
|
||||
// A runtime import while graph.ts imports only TYPES from here — no cycle at runtime.
|
||||
import { graphToOperations } from "./graph"
|
||||
import {
|
||||
type ContainerNode,
|
||||
type DiagramNode,
|
||||
@@ -173,6 +175,62 @@ export const OperationSchema = z.discriminatedUnion("op", [
|
||||
),
|
||||
after: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
op: z.literal("add_graph"),
|
||||
id: z.string(),
|
||||
parent: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Container to embed the graph in; omit for top level"),
|
||||
label: z.string().optional().describe("Frame title; omit for none"),
|
||||
dir: z
|
||||
.enum(["col", "row"])
|
||||
.optional()
|
||||
.describe(
|
||||
"Flow direction: col (default) downwards, row rightwards",
|
||||
),
|
||||
nodes: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
shape: z.string().optional(),
|
||||
icon: z.string().optional(),
|
||||
group: z.string().optional(),
|
||||
role: z
|
||||
.enum([
|
||||
"banner",
|
||||
"heading",
|
||||
"body",
|
||||
"callout",
|
||||
"good",
|
||||
"bad",
|
||||
"metric",
|
||||
"muted",
|
||||
])
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.describe("The graph's nodes"),
|
||||
edges: z
|
||||
.array(
|
||||
z.object({
|
||||
source: z.string(),
|
||||
target: z.string(),
|
||||
label: z.string().optional(),
|
||||
dashed: z.boolean().optional(),
|
||||
bold: z.boolean().optional(),
|
||||
head: z.string().optional(),
|
||||
tail: z.string().optional(),
|
||||
headFill: z.boolean().optional(),
|
||||
tailFill: z.boolean().optional(),
|
||||
}),
|
||||
)
|
||||
.describe(
|
||||
"The arrows. THEY decide the node positions — layering and ordering are computed from them",
|
||||
),
|
||||
after: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
op: z.literal("add_grid"),
|
||||
id: z.string(),
|
||||
@@ -459,7 +517,40 @@ export function applyOperations(
|
||||
|
||||
const exists = (id: string) => findNode(tree, id) !== null
|
||||
|
||||
// add_graph is a macro: the layered-graph pass (graph.ts) decides which layer each
|
||||
// node belongs to and who stands beside whom, and emits ordinary container/box/link
|
||||
// operations. Expanding it HERE — rather than treating graphs as a special page-level
|
||||
// tool — is what lets a graph sit inside a poster column or an architecture zone and
|
||||
// still participate in the outer flexbox like any other node.
|
||||
const expanded: Operation[] = []
|
||||
for (const op of ops) {
|
||||
if (op.op !== "add_graph") {
|
||||
expanded.push(op)
|
||||
continue
|
||||
}
|
||||
const g = graphToOperations(op.nodes, op.edges, {
|
||||
flow: op.dir ?? "col",
|
||||
parent: op.parent,
|
||||
// The graph's own id namespaces the synthetic layer containers, so two
|
||||
// graphs on one page cannot collide on `__layer0`.
|
||||
prefix: op.id,
|
||||
rootId: op.id,
|
||||
})
|
||||
if (g.unknownEndpoints.length)
|
||||
errors.push(
|
||||
`add_graph "${op.id}": edge endpoint(s) not in nodes: ${g.unknownEndpoints.join(", ")}`,
|
||||
)
|
||||
if (op.label || op.after) {
|
||||
const root = g.operations[0]
|
||||
if (root?.op === "add_container") {
|
||||
if (op.label) root.label = op.label
|
||||
if (op.after) root.after = op.after
|
||||
}
|
||||
}
|
||||
expanded.push(...g.operations)
|
||||
}
|
||||
|
||||
for (const op of expanded) {
|
||||
switch (op.op) {
|
||||
case "add_icon":
|
||||
case "add_box":
|
||||
|
||||
@@ -96,6 +96,12 @@ Use draw_graph when the diagram is boxes joined by arrows and the arrows define
|
||||
them — a flowchart written as XML or as nested containers comes out as one column, which forces
|
||||
every branch to jump over the step beside it.
|
||||
|
||||
Use restructure_diagram's add_graph when ONE ZONE of a nested diagram is arrow-ordered:
|
||||
an architecture diagram where a zone's contents follow the data flow, a poster column with
|
||||
a small flowchart in it. add_graph takes nodes+edges like draw_graph, lays them out inside
|
||||
its container, and the container joins the outer layout like any node (dir: col flows
|
||||
down, row flows right).
|
||||
|
||||
Use restructure_diagram when the diagram's meaning is in NESTING or in a fixed frame:
|
||||
- Cloud architecture (AWS/Azure/GCP/Kubernetes): things inside things. Call search_stencils first.
|
||||
- Swimlane and BPMN diagrams: add_pool with one lane per role, then add_box with lane and col.
|
||||
|
||||
Reference in New Issue
Block a user