From d9cdfba3e11c71ca6401dc6e834d83f467705759 Mon Sep 17 00:00:00 2001 From: "dayuan.jiang" Date: Sun, 9 Aug 2026 22:01:39 +0900 Subject: [PATCH] =?UTF-8?q?feat(diagram-engine):=20connection=20vocabulary?= =?UTF-8?q?=20=E2=80=94=20arrowheads,=20parallel=20edges,=20edge=20ids?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arrowheads carry meaning: a crow's foot IS one-to-many, a hollow diamond IS aggregation. The engine allowed exactly one arrowhead; this opens the vocabulary the same way shapes were opened: - LinkSpec gains head/tail (pass-through to endArrow/startArrow, charset- gated against style injection) and headFill/tailFill — fill is written explicitly whenever a head is declared, because UML composition and aggregation differ ONLY by fill and draw.io's per-head default would flip the meaning. bold (4px amber, for THE key relationship) included. - Parallel edges: a second link between the same pair is allowed when it carries an id (ER's 'places' and 'cancels' between the same two entities); without one it stays an error, since two identical overlapping lines is a mistake. Edge ids are also what later operations address. - Sequence messages respect a declared head (an async message's open arrow is UML notation) while defaulting to the solid block as before. - draw_graph's edge schema extended to match; GraphEdge passes the new fields through to the link operations it generates. 5 new tests: crow's foot style emission, hollow-vs-filled round trip, injection rejection, parallel-edge gating, bold round trip. 561 unit tests green. ER+UML acceptance diagram (crow's foot, zero-to-one, hollow inheritance triangle, filled composition diamond) verified in the real editor. --- app/api/chat/route.ts | 20 ++++ lib/diagram-engine/graph.ts | 14 +++ lib/diagram-engine/operations.ts | 68 +++++++++++++- lib/diagram-engine/parse.ts | 8 ++ lib/diagram-engine/render.ts | 21 ++++- lib/diagram-engine/types.ts | 16 ++++ tests/unit/diagram-engine-links.test.ts | 116 ++++++++++++++++++++++++ 7 files changed, 257 insertions(+), 6 deletions(-) create mode 100644 tests/unit/diagram-engine-links.test.ts diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 73235ee..0c41ded 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -842,6 +842,26 @@ Grouping: when the nodes fall into natural zones (remote vs local, frontend vs b target: z.string(), label: z.string().optional(), dashed: z.boolean().optional(), + bold: z + .boolean() + .optional() + .describe( + "Thick coloured arrow for THE key relationship; use sparingly", + ), + head: z + .string() + .optional() + .describe( + "Arrowhead at the target: block/open/diamond/diamondThin/oval/none, ER: ERone/ERmany/ERoneToMany/ERzeroToMany. UML inheritance: head=block headFill=false", + ), + tail: z + .string() + .optional() + .describe( + "Arrowhead at the source, same values. ER 1:N: tail=ERone head=ERoneToMany", + ), + headFill: z.boolean().optional(), + tailFill: z.boolean().optional(), }), ) .describe( diff --git a/lib/diagram-engine/graph.ts b/lib/diagram-engine/graph.ts index fbab4b8..12cb5a1 100644 --- a/lib/diagram-engine/graph.ts +++ b/lib/diagram-engine/graph.ts @@ -49,6 +49,13 @@ export interface GraphEdge { target: string label?: string dashed?: boolean + /** Thick coloured arrow for THE key relationship. */ + bold?: boolean + /** Arrowhead tokens, passed through — see LinkSpec. */ + head?: string + tail?: string + headFill?: boolean + tailFill?: boolean } export interface GraphOptions { @@ -356,6 +363,13 @@ export function graphToOperations( target: e.target, ...(e.label ? { label: e.label } : {}), ...(e.dashed ? { dashed: true } : {}), + ...(e.bold ? { bold: true } : {}), + ...(e.head !== undefined + ? { head: e.head, headFill: e.headFill ?? false } + : {}), + ...(e.tail !== undefined + ? { tail: e.tail, tailFill: e.tailFill ?? false } + : {}), }) return { diff --git a/lib/diagram-engine/operations.ts b/lib/diagram-engine/operations.ts index f6cdf04..1ca1237 100644 --- a/lib/diagram-engine/operations.ts +++ b/lib/diagram-engine/operations.ts @@ -296,6 +296,12 @@ export const OperationSchema = z.discriminatedUnion("op", [ }), z.object({ op: z.literal("link"), + id: z + .string() + .optional() + .describe( + "Edge id. Required for a second edge between the same two nodes (parallel relationships), so each can be addressed later", + ), source: z.string(), target: z.string(), label: z.string().optional(), @@ -303,6 +309,31 @@ export const OperationSchema = z.discriminatedUnion("op", [ .boolean() .optional() .describe("Dashed line — replication, sync, policy"), + bold: z + .boolean() + .optional() + .describe( + "A thick coloured arrow for THE key relationship — a transformation, the main flow. Use sparingly: one or two per diagram", + ), + head: z + .string() + .optional() + .describe( + "Arrowhead at the target. block/open/diamond/diamondThin/oval/cross/none, ER: ERone/ERmany/ERoneToMany/ERzeroToMany/ERzeroToOne. UML inheritance: head=block headFill=false. Omit for a plain arrow", + ), + tail: z + .string() + .optional() + .describe( + "Arrowhead at the source, same values as head. UML composition: tail=diamondThin tailFill=true. ER 1:N: tail=ERone head=ERoneToMany", + ), + headFill: z + .boolean() + .optional() + .describe( + "Fill the head. Meaning-bearing in UML: filled diamond=composition, hollow=aggregation", + ), + tailFill: z.boolean().optional(), step: z .number() .optional() @@ -685,25 +716,52 @@ export function applyOperations( errors.push(`link: no node with id "${op.target}"`) break } - // A second arrow between the same pair is normally a mistake — two identical - // lines drawn on top of each other — EXCEPT between two participants of a - // sequence diagram, where a back-and-forth conversation is the whole point. - // There the messages are distinguished by their step, not by their endpoints. + // A second arrow between the same pair WITHOUT an id is a mistake — two + // identical lines on top of each other. With an id it is a parallel + // relationship (an ER diagram's "places" and "cancels" between the same + // two entities), addressable separately. Sequence messages are exempt + // as before: their identity is the step, not the endpoints. const conversation = sameSequence(tree, op.source, op.target) const dup = !conversation && + !op.id && tree.links.some( (l) => l.source === op.source && l.target === op.target, ) if (dup) { errors.push( - `link: "${op.source}" → "${op.target}" already exists`, + `link: "${op.source}" → "${op.target}" already exists — give this one an id to draw a second, parallel relationship`, + ) + break + } + if (op.id && tree.links.some((l) => l.id === op.id)) { + errors.push(`link: edge id "${op.id}" is already taken`) + break + } + // Arrowhead tokens reach the style string; the same charset gate as + // shapes keeps `block;dashed=1` from smuggling style keys in. + const badHead = [op.head, op.tail].find( + (v) => v !== undefined && !/^[a-zA-Z0-9]+$/.test(v), + ) + if (badHead !== undefined) { + errors.push( + `link: arrowhead "${badHead}" contains characters that are not allowed`, ) break } const link: LinkSpec = { source: op.source, target: op.target } + if (op.id) link.id = op.id if (op.label) link.label = op.label if (op.dashed) link.dashed = true + if (op.bold) link.bold = true + if (op.head !== undefined) { + link.head = op.head + link.headFill = op.headFill ?? false + } + if (op.tail !== undefined) { + link.tail = op.tail + link.tailFill = op.tailFill ?? false + } if (op.step != null) link.step = op.step tree.links.push(link) break diff --git a/lib/diagram-engine/parse.ts b/lib/diagram-engine/parse.ts index d2946c5..83b2a50 100644 --- a/lib/diagram-engine/parse.ts +++ b/lib/diagram-engine/parse.ts @@ -853,12 +853,20 @@ function toLink(c: RawCell, labelOverride?: string): LinkSpec | null { if (!c.source || !c.target) return null const raw = (labelOverride ?? c.value).trim() const { label, step } = splitStep(raw) + const head = styleValue(c.style, "endArrow") + const tail = styleValue(c.style, "startArrow") return { id: c.id, source: c.source, target: c.target, label: label || undefined, dashed: styleValue(c.style, "dashed") === "1" || undefined, + bold: + Number(styleValue(c.style, "strokeWidth") ?? "1") >= 3 || undefined, + head, + tail, + headFill: head ? styleValue(c.style, "endFill") === "1" : undefined, + tailFill: tail ? styleValue(c.style, "startFill") === "1" : undefined, step, style: c.style, } diff --git a/lib/diagram-engine/render.ts b/lib/diagram-engine/render.ts index b8ba6f4..871601a 100644 --- a/lib/diagram-engine/render.ts +++ b/lib/diagram-engine/render.ts @@ -590,6 +590,18 @@ function edgeXml( let style = l.style ?? EDGE_STYLE if (!l.style) { if (l.dashed) style += "dashed=1;" + // A bold link is a visual element, not a connector: thick amber with a filled + // block head — the "this becomes that" arrow of a comparison. + if (l.bold) + style += + "strokeWidth=4;strokeColor=#D79B00;endArrow=block;endFill=1;endSize=6;" + // Arrowhead vocabulary, passed through to draw.io. Fill is written whenever + // the head is: UML composition vs aggregation differ ONLY by fill, so leaving + // it to draw.io's per-head default would flip the meaning. + if (l.head !== undefined) + style += `endArrow=${l.head};endFill=${l.headFill ? 1 : 0};` + if (l.tail !== undefined) + style += `startArrow=${l.tail};startFill=${l.tailFill ? 1 : 0};` if (label) style += "labelBackgroundColor=light-dark(#FFFFFF,#0B0F14);" } if (route) @@ -646,7 +658,14 @@ function messageXml( const self = l.source === l.target let style = l.style ?? EDGE_STYLE if (!l.style) { - style += "endArrow=block;endFill=1;html=1;" + // The declared head wins over the sequence default: an async message drawn + // with an open arrow is UML notation, not decoration. + style += + l.head !== undefined + ? `endArrow=${l.head};endFill=${l.headFill ? 1 : 0};html=1;` + : "endArrow=block;endFill=1;html=1;" + if (l.tail !== undefined) + style += `startArrow=${l.tail};startFill=${l.tailFill ? 1 : 0};` if (l.dashed) style += "dashed=1;" style += "labelBackgroundColor=light-dark(#FFFFFF,#0B0F14);" style += self ? "edgeStyle=orthogonalEdgeStyle;" : "edgeStyle=none;" diff --git a/lib/diagram-engine/types.ts b/lib/diagram-engine/types.ts index 4933306..60a686b 100644 --- a/lib/diagram-engine/types.ts +++ b/lib/diagram-engine/types.ts @@ -244,6 +244,22 @@ export interface LinkSpec { label?: string /** Dashed line — replication, sync, policy, lineage. */ dashed?: boolean + /** + * A bold arrow: the relationship IS the point — a transformation, the main flow. + * Thick and coloured, a visual element rather than a hairline connector. + */ + bold?: boolean + /** + * Arrowhead at the target / at the source. draw.io endArrow/startArrow tokens: + * block, open, diamond, diamondThin, oval, cross, ERone, ERmany, ERoneToMany, + * ERzeroToMany, ERzeroToOne, none… Unset means the default (classic at the target, + * nothing at the source). `headFill`/`tailFill` distinguish UML composition + * (filled diamond) from aggregation (hollow) — conventions where fill IS meaning. + */ + head?: string + tail?: string + headFill?: boolean + tailFill?: boolean /** Step number, rendered as an "N. " prefix on the label. */ step?: number /** Verbatim style, when recovered from XML. */ diff --git a/tests/unit/diagram-engine-links.test.ts b/tests/unit/diagram-engine-links.test.ts new file mode 100644 index 0000000..26d2c18 --- /dev/null +++ b/tests/unit/diagram-engine-links.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest" +import { restructureDiagram } from "@/lib/diagram-engine" +import { parseDiagram } from "@/lib/diagram-engine/parse" + +/** + * The connection vocabulary: arrowheads carry meaning. A crow's foot IS "many", a + * hollow diamond IS aggregation — notation, not decoration. The engine passes the + * head/tail tokens through to draw.io and writes fill explicitly, because UML + * composition and aggregation differ ONLY by fill. + */ + +const styleOfEdge = (xml: string, source: string, target: string): string => { + const m = xml.match( + new RegExp( + `]*style="([^"]*)"[^>]*source="${source}" target="${target}"`, + ), + ) + return m?.[1] ?? "" +} + +const two = [ + { op: "add_box" as const, id: "a", label: "customer" }, + { op: "add_box" as const, id: "b", label: "order" }, +] + +describe("arrowheads", () => { + it("passes ER crow's foot through with explicit fill", () => { + const r = restructureDiagram("", [ + ...two, + { + op: "link", + source: "a", + target: "b", + tail: "ERone", + head: "ERoneToMany", + }, + ]) + expect(r.errors).toEqual([]) + const s = styleOfEdge(r.xml as string, "a", "b") + expect(s).toContain("endArrow=ERoneToMany") + expect(s).toContain("endFill=0") + expect(s).toContain("startArrow=ERone") + }) + + it("UML: hollow vs filled diamond survive the round trip distinctly", () => { + const r = restructureDiagram("", [ + ...two, + { + op: "link", + source: "a", + target: "b", + head: "diamondThin", + headFill: true, + }, + ]) + const back = parseDiagram(r.xml as string) + const l = back.tree.links.find( + (x) => x.source === "a" && x.target === "b", + ) + expect(l?.head).toBe("diamondThin") + expect(l?.headFill).toBe(true) + }) + + it("rejects an injection-capable arrowhead token", () => { + const r = restructureDiagram("", [ + ...two, + { op: "link", source: "a", target: "b", head: "block;dashed=1" }, + ]) + expect(r.errors.join(" ")).toContain("not allowed") + }) +}) + +describe("parallel edges", () => { + it("a second edge between the same pair needs an id, then both render", () => { + const rejected = restructureDiagram("", [ + ...two, + { op: "link", source: "a", target: "b", label: "places" }, + { op: "link", source: "a", target: "b", label: "cancels" }, + ]) + expect(rejected.errors.join(" ")).toContain("give this one an id") + + const r = restructureDiagram("", [ + ...two, + { op: "link", source: "a", target: "b", label: "places" }, + { + op: "link", + id: "e2", + source: "a", + target: "b", + label: "cancels", + dashed: true, + }, + ]) + expect(r.errors).toEqual([]) + const xml = r.xml as string + expect(xml).toContain('value="places"') + expect(xml).toContain('value="cancels"') + // and both come back as separate links + const back = parseDiagram(xml) + expect( + back.tree.links.filter((l) => l.source === "a" && l.target === "b"), + ).toHaveLength(2) + }) + + it("bold renders thick and survives the round trip", () => { + const r = restructureDiagram("", [ + ...two, + { op: "link", source: "a", target: "b", bold: true }, + ]) + expect(styleOfEdge(r.xml as string, "a", "b")).toContain( + "strokeWidth=4", + ) + const back = parseDiagram(r.xml as string) + expect(back.tree.links[0]?.bold).toBe(true) + }) +})