mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-01 17:10:24 +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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user