mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-02 01:20:23 +08:00
feat(diagram-engine): connection vocabulary — arrowheads, parallel edges, edge ids
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.
This commit is contained in:
@@ -842,6 +842,26 @@ Grouping: when the nodes fall into natural zones (remote vs local, frontend vs b
|
|||||||
target: z.string(),
|
target: z.string(),
|
||||||
label: z.string().optional(),
|
label: z.string().optional(),
|
||||||
dashed: z.boolean().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(
|
.describe(
|
||||||
|
|||||||
@@ -49,6 +49,13 @@ export interface GraphEdge {
|
|||||||
target: string
|
target: string
|
||||||
label?: string
|
label?: string
|
||||||
dashed?: boolean
|
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 {
|
export interface GraphOptions {
|
||||||
@@ -356,6 +363,13 @@ export function graphToOperations(
|
|||||||
target: e.target,
|
target: e.target,
|
||||||
...(e.label ? { label: e.label } : {}),
|
...(e.label ? { label: e.label } : {}),
|
||||||
...(e.dashed ? { dashed: true } : {}),
|
...(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 {
|
return {
|
||||||
|
|||||||
@@ -296,6 +296,12 @@ export const OperationSchema = z.discriminatedUnion("op", [
|
|||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
op: z.literal("link"),
|
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(),
|
source: z.string(),
|
||||||
target: z.string(),
|
target: z.string(),
|
||||||
label: z.string().optional(),
|
label: z.string().optional(),
|
||||||
@@ -303,6 +309,31 @@ export const OperationSchema = z.discriminatedUnion("op", [
|
|||||||
.boolean()
|
.boolean()
|
||||||
.optional()
|
.optional()
|
||||||
.describe("Dashed line — replication, sync, policy"),
|
.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
|
step: z
|
||||||
.number()
|
.number()
|
||||||
.optional()
|
.optional()
|
||||||
@@ -685,25 +716,52 @@ export function applyOperations(
|
|||||||
errors.push(`link: no node with id "${op.target}"`)
|
errors.push(`link: no node with id "${op.target}"`)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
// A second arrow between the same pair is normally a mistake — two identical
|
// A second arrow between the same pair WITHOUT an id is a mistake — two
|
||||||
// lines drawn on top of each other — EXCEPT between two participants of a
|
// identical lines on top of each other. With an id it is a parallel
|
||||||
// sequence diagram, where a back-and-forth conversation is the whole point.
|
// relationship (an ER diagram's "places" and "cancels" between the same
|
||||||
// There the messages are distinguished by their step, not by their endpoints.
|
// 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 conversation = sameSequence(tree, op.source, op.target)
|
||||||
const dup =
|
const dup =
|
||||||
!conversation &&
|
!conversation &&
|
||||||
|
!op.id &&
|
||||||
tree.links.some(
|
tree.links.some(
|
||||||
(l) => l.source === op.source && l.target === op.target,
|
(l) => l.source === op.source && l.target === op.target,
|
||||||
)
|
)
|
||||||
if (dup) {
|
if (dup) {
|
||||||
errors.push(
|
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
|
break
|
||||||
}
|
}
|
||||||
const link: LinkSpec = { source: op.source, target: op.target }
|
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.label) link.label = op.label
|
||||||
if (op.dashed) link.dashed = true
|
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
|
if (op.step != null) link.step = op.step
|
||||||
tree.links.push(link)
|
tree.links.push(link)
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -853,12 +853,20 @@ function toLink(c: RawCell, labelOverride?: string): LinkSpec | null {
|
|||||||
if (!c.source || !c.target) return null
|
if (!c.source || !c.target) return null
|
||||||
const raw = (labelOverride ?? c.value).trim()
|
const raw = (labelOverride ?? c.value).trim()
|
||||||
const { label, step } = splitStep(raw)
|
const { label, step } = splitStep(raw)
|
||||||
|
const head = styleValue(c.style, "endArrow")
|
||||||
|
const tail = styleValue(c.style, "startArrow")
|
||||||
return {
|
return {
|
||||||
id: c.id,
|
id: c.id,
|
||||||
source: c.source,
|
source: c.source,
|
||||||
target: c.target,
|
target: c.target,
|
||||||
label: label || undefined,
|
label: label || undefined,
|
||||||
dashed: styleValue(c.style, "dashed") === "1" || 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,
|
step,
|
||||||
style: c.style,
|
style: c.style,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -590,6 +590,18 @@ function edgeXml(
|
|||||||
let style = l.style ?? EDGE_STYLE
|
let style = l.style ?? EDGE_STYLE
|
||||||
if (!l.style) {
|
if (!l.style) {
|
||||||
if (l.dashed) style += "dashed=1;"
|
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 (label) style += "labelBackgroundColor=light-dark(#FFFFFF,#0B0F14);"
|
||||||
}
|
}
|
||||||
if (route)
|
if (route)
|
||||||
@@ -646,7 +658,14 @@ function messageXml(
|
|||||||
const self = l.source === l.target
|
const self = l.source === l.target
|
||||||
let style = l.style ?? EDGE_STYLE
|
let style = l.style ?? EDGE_STYLE
|
||||||
if (!l.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;"
|
if (l.dashed) style += "dashed=1;"
|
||||||
style += "labelBackgroundColor=light-dark(#FFFFFF,#0B0F14);"
|
style += "labelBackgroundColor=light-dark(#FFFFFF,#0B0F14);"
|
||||||
style += self ? "edgeStyle=orthogonalEdgeStyle;" : "edgeStyle=none;"
|
style += self ? "edgeStyle=orthogonalEdgeStyle;" : "edgeStyle=none;"
|
||||||
|
|||||||
@@ -244,6 +244,22 @@ export interface LinkSpec {
|
|||||||
label?: string
|
label?: string
|
||||||
/** Dashed line — replication, sync, policy, lineage. */
|
/** Dashed line — replication, sync, policy, lineage. */
|
||||||
dashed?: boolean
|
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, rendered as an "N. " prefix on the label. */
|
||||||
step?: number
|
step?: number
|
||||||
/** Verbatim style, when recovered from XML. */
|
/** Verbatim style, when recovered from XML. */
|
||||||
|
|||||||
116
tests/unit/diagram-engine-links.test.ts
Normal file
116
tests/unit/diagram-engine-links.test.ts
Normal file
@@ -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(
|
||||||
|
`<mxCell [^>]*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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user