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:
dayuan.jiang
2026-08-09 22:01:39 +09:00
parent 50826c0ac8
commit d9cdfba3e1
7 changed files with 257 additions and 6 deletions

View File

@@ -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 {

View File

@@ -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

View File

@@ -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,
}

View File

@@ -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;"

View File

@@ -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. */