Files
next-ai-draw-io/lib/diagram-engine/render.ts
dayuan.jiang 0b03e15336 feat(diagram-engine): corners, borderless fills, shadows and strikethrough
Four more Tailwind classes, all four verified against draw.io's own source in
public/drawio rather than against a prose reference — which is how three earlier
exclusions turned out to be wrong:

  rounded-*      mxShape.js:1172-1189 — absoluteArcSize=1 switches arcSize to
                 absolute pixels and halves it, so the same class is the same
                 corner on every box. Previously excluded as 'percentage only'
  shadow-sm..xl  mxShape.js:505-535 — getShadowStyle reads five independent
                 params, not one flag, so Tailwind's offset+blur rungs map one
                 to one. Previously excluded as 'six sizes collapse to one'
  line-through   mxConstants.js:2054 FONT_STRIKETHROUGH: 8, read at both
                 mxText.js:723 and :1040. The bitmask has four bits, not three
  border-none    the only one of the four that adds something previously
                 inexpressible: a fill with no outline

Also fixes an edge style growing 76 characters per re-layout, without bound. The
router recomputes ports on every pass, and appending them to a style recovered
from the canvas — which already carried the previous pass's eight port keys —
grew the string forever. draw.io resolves duplicates last-wins so the arrow
always looked right; a byte-identity check is what caught it.

Two traps found while wiring the readback, both the same shape: a value the
THEME emits being recorded as one the model asked for. strokeColor=none from a
filled or ghost role, and rounded=0 from the fallback style. Either one would
outlive a set_role, since that clears style but keeps text.

Deliberately not included, with reasons in tw.ts: per-side borders and per-corner
radius (both would take the shape slot, and what a node IS matters more than
which of its edges show), per-side padding (draw.io's keys pad the label, not the
room left for children), text-shadow (a bare flag with no offset or blur),
opacity (Tailwind's is any integer, not a scale), tracking/uppercase/leading
(absent from draw.io — zero grep hits, not merely coarse).

615 tests, 90 new. Browser-verified: four shadow rungs visibly differ, radius is
real pixels, terminator + rounded-lg becomes a small-cornered rounded rect while
an untouched terminator stays a stadium.
2026-08-11 09:09:45 +09:00

1102 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* tree → XML. Takes a laid-out forest and writes the mxCell elements draw.io reads.
*
* Every cell it emits carries `container=1` on containers and `dai_*` markers recording
* the layout parameters, so parse.ts can read the structure back. That round-trip is
* what lets the canvas stay the single source of truth.
*
* Ported from drawio-ai-kit (MIT) — see NOTICE.
*/
import {
flatten,
ICON_SIZE,
LANE_LABEL,
layoutForest,
messageCount,
type Placed,
POOL_PAD,
poolCellOf,
poolMetrics,
type SequenceMetrics,
sequenceMetrics,
} from "./layout"
import {
appendOnce,
isInvisible,
MARKER,
stampAuto,
stampCell,
stampContainer,
stampFlex,
stampGroup,
stampLane,
stampLeaf,
stampPool,
stampPoolDecoration,
stampRadial,
stampRole,
stampSequence,
stampShape,
} from "./markers"
import { type RoutedEdge, routeEdges } from "./route"
import { mergeStyle, resolveShape } from "./shapes"
import { hueOf, NEUTRAL, themedStyle } from "./theme"
import {
type DiagramNode,
type DiagramTree,
isContainer,
type LinkSpec,
type PoolNode,
type Rect,
type SequenceNode,
type TextStyle,
} from "./types"
import type { Point } from "./visgraph"
/** Escape the five characters that would break an XML attribute. */
export function esc(s: string): string {
return String(s ?? "")
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
}
// NOTE ON RICH TEXT: a label may carry inline HTML — <b>, <i>, <font color>, <br>,
// <span> — and needs no special handling here. `esc()` writes it into the value
// attribute as entities, the XML parser decodes them back, and because every style
// carries `html=1` draw.io renders the tags. That is exactly how hand-written rich
// labels have always worked; the editor sanitises HTML labels itself. The one place
// tags DO need handling is the measure pass (layout.ts autoBoxSize), which must not
// count markup as text.
/**
* Is this label a paragraph rather than a short caption?
*
* Typography's basic rule: labels centre, paragraphs set flush left. The split is by
* content — explicit line breaks, or enough text that draw.io will wrap it — because
* the model declares WHAT the text is, never how to align it.
*/
function isParagraph(label: string): boolean {
const plain = label.replace(/<[^<>]+>/g, "")
return /\n/.test(label) || /<br/i.test(label) || plain.length > 60
}
/**
* The five draw.io shadow parameters per Tailwind rung, keyed by TextStyle.shadow.
*
* Values are the primary layer of Tailwind's own CSS: `shadow-md` is
* `0 4px 6px rgb(0 0 0/0.1)`, so 4px down, 6px of blur, 10% opaque. Tailwind stacks a second
* tighter layer on each step; draw.io renders one `drop-shadow()`, so the main layer is what
* survives. `shadowColor` is left off deliberately — draw.io's default grey is correct, and
* colour belongs to `role` and `group`.
*/
const SHADOW_KEYS: Record<number, string> = {
1: "shadow=1;shadowOffsetX=0;shadowOffsetY=1;shadowBlur=3;shadowOpacity=10;",
2: "shadow=1;shadowOffsetX=0;shadowOffsetY=4;shadowBlur=6;shadowOpacity=10;",
3: "shadow=1;shadowOffsetX=0;shadowOffsetY=10;shadowBlur=15;shadowOpacity=10;",
4: "shadow=1;shadowOffsetX=0;shadowOffsetY=20;shadowBlur=25;shadowOpacity=10;",
}
/**
* A node's presentation overrides, as draw.io style keys.
*
* Bold, italic, underline and strikethrough are ONE key: draw.io packs them into `fontStyle`
* as a bitmask (1 bold, 2 italic, 4 underline, 8 strikethrough) which you add together.
* Writing four separate keys, or writing `fontStyle=1` twice, would leave only the last one
* in effect — so the bits are combined here and emitted once.
*
* `dashPattern` accompanies dotted: `dashed=1` alone gives draw.io's default dash, and the
* short-on-long-off pattern is what makes it read as a dotted line rather than a dashed one.
*
* A radius needs three keys together. `rounded=1` turns corners on at all, `absoluteArcSize=1`
* makes the number pixels instead of a percentage of the box, and `arcSize` is DOUBLE the
* radius because draw.io halves it on the way in (mxShape.js:1172-1189). All three or none:
* `arcSize` alone would be read as a percentage and give a different radius on every box.
*/
function textStyleKeys(t: TextStyle | undefined): string | undefined {
if (!t) return undefined
const parts: string[] = []
const bits =
(t.bold ? 1 : 0) +
(t.italic ? 2 : 0) +
(t.underline ? 4 : 0) +
(t.strike ? 8 : 0)
// Only when something asked. `fontStyle=0` would override a role's own bold.
if (
t.bold != null ||
t.italic != null ||
t.underline != null ||
t.strike != null
)
parts.push(`fontStyle=${bits};`)
if (t.size != null && t.size > 0) parts.push(`fontSize=${t.size};`)
if (t.align) parts.push(`align=${t.align};`)
if (t.valign) parts.push(`verticalAlign=${t.valign};`)
if (t.nowrap != null)
parts.push(`whiteSpace=${t.nowrap ? "nowrap" : "wrap"};`)
if (t.borderWidth != null && t.borderWidth > 0)
parts.push(`strokeWidth=${t.borderWidth};`)
if (t.borderStyle === "dashed") parts.push("dashed=1;")
else if (t.borderStyle === "dotted") parts.push("dashed=1;dashPattern=1 3;")
else if (t.borderStyle === "solid") parts.push("dashed=0;")
// `strokeColor=none` is how draw.io says "no border" (mxShape.js:1398 accepts it), and
// it is the only way to draw a plain colour field with no outline at all.
if (t.borderless) parts.push("strokeColor=none;")
if (t.radius != null) {
if (t.radius > 0)
parts.push(`rounded=1;absoluteArcSize=1;arcSize=${t.radius * 2};`)
else parts.push("rounded=0;")
}
if (t.shadow != null)
parts.push(t.shadow > 0 ? (SHADOW_KEYS[t.shadow] ?? "") : "shadow=0;")
return parts.length ? parts.join("") : undefined
}
/** Resolve a catalog name to a style. Injected so the engine does not own the catalog. */
export type StyleResolver = (
name: string,
kind: "icon" | "group",
) => string | null
const FALLBACK_BOX =
"rounded=0;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#5A6B7B;fontColor=#1A1A1A;fontSize=11;verticalAlign=middle;"
/** fill/stroke for the n-th distinct group — the theme's hue ramp, tint and base steps. */
export function groupColour(index: number): { fill: string; stroke: string } {
const h = hueOf(index)
return { fill: h.tint, stroke: h.base }
}
const FALLBACK_FRAME =
"rounded=0;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#999999;fontColor=#1A1A1A;fontSize=12;fontStyle=1;verticalAlign=top;align=left;spacingLeft=8;spacingTop=4;"
const TITLE_STYLE =
"text;html=1;align=center;fontStyle=1;fontSize=14;fontColor=light-dark(#232F3E,#E8E8E8);"
const EDGE_STYLE =
"edgeStyle=orthogonalEdgeStyle;html=1;rounded=0;jettySize=auto;orthogonalLoop=1;fontSize=10;fontColor=light-dark(#1B2733,#CFE0F0);strokeColor=light-dark(#1A1A1A,#E0E0E0);strokeWidth=1;"
// ---- swimlane pool chrome ----
/** Hairline between lane bands: present, but quieter than the shapes sitting on it. */
const POOL_HAIR = "#D8E0E8"
/** Alternating band tint, so a reader can follow one lane across a wide diagram. */
const POOL_BAND_ALT = "#F5F8FB"
/** Lane-name column, slightly darker than the bands so it reads as a header. */
const POOL_LABEL_FILL = "#EEF2F7"
const POOL_FILL = "#FFFFFF"
const POOL_STROKE = "#5A6B7B"
/** A pool's outer frame: a plain titled rectangle, since the bands supply the structure. */
const POOL_FRAME_STYLE =
`rounded=0;whiteSpace=wrap;html=1;fillColor=${POOL_FILL};strokeColor=${POOL_STROKE};` +
`fontColor=#1A1A1A;fontSize=13;fontStyle=1;verticalAlign=top;align=left;spacingLeft=8;spacingTop=4;`
/**
* A participant head in a sequence diagram: the box at the top of a lifeline.
*
* `umlLifeline` is a core mxGraph shape whose cell covers the head AND the line below it,
* with `size` giving the head's height. Emitting head and line as one cell is what makes
* draw.io keep them together when the user drags the participant sideways.
*/
const LIFELINE_STYLE =
"shape=umlLifeline;perimeter=lifelinePerimeter;whiteSpace=wrap;html=1;container=0;collapsible=0;recursiveResize=0;outlineConnect=0;fillColor=#FFFFFF;strokeColor=#5A6B7B;fontColor=#1A1A1A;fontSize=11;fontStyle=1;"
export interface RenderOptions {
/** Resolves a catalog icon/group name to its verbatim draw.io style. */
resolveStyle?: StyleResolver
/** Diagram-wide glyph size. */
iconSize?: number
/** Gap between top-level roots. */
rootGap?: number
}
/**
* Build the style for one node.
*
* A style recovered from XML is preferred over re-resolving the catalog name: it is
* what is already on the canvas, including any colour the user changed by hand. We only
* re-stamp the markers on top, so layout parameters stay current.
*/
/** The hue ramp for a node's group, or the neutral ramp. Assigned in document order. */
export type HueResolver = (
group: string | undefined,
) => ReturnType<typeof hueOf>
function styleFor(
n: DiagramNode,
resolve: StyleResolver | undefined,
hue: HueResolver = () => NEUTRAL,
): string {
if (n.kind === "title") return TITLE_STYLE
if (n.kind === "icon") {
const base =
n.style ??
(n.name ? resolve?.(n.name, "icon") : null) ??
FALLBACK_BOX
return stampLeaf(base, "icon", { name: n.name })
}
if (n.kind === "box") {
let base: string
if (n.style) {
base = n.style
} else {
// Structured merge, ownership by fragment order: the fallback's neutral
// look, the shape's geometry keys, the theme's colour/type keys, explicit
// colours last. Each key ends up in the style exactly once — a theme that
// says rounded=1 cannot leave a contradictory duplicate on a rhombus.
const shape = n.shape ? resolveShape(n.shape) : null
base = mergeStyle(
FALLBACK_BOX,
shape?.spec.style,
n.role || n.group
? themedStyle(n.role ?? "body", hue(n.group), "leaf")
: undefined,
// Typography, decided by the content: a PARAGRAPH sets left and top —
// a three-line body floating dead-centre in its box was the poster's
// second-ugliest defect — while a short label stays centred. Only for
// rectangles: inside a rhombus or a cloud the safe text area is the
// middle, which is exactly where centring puts it.
isParagraph(n.label) && !n.shape
? "align=left;verticalAlign=top;spacingLeft=10;spacingRight=10;spacingTop=8;"
: undefined,
n.fill ? `fillColor=${n.fill};` : undefined,
n.stroke ? `strokeColor=${n.stroke};` : undefined,
n.bold ? "fontStyle=1;" : undefined,
// Last, so an explicit override beats both the role's type and the
// paragraph heuristic above — asking for centred text has to win over
// "this looks like a paragraph, so set it flush left".
textStyleKeys(n.text),
)
}
let stamped = stampLeaf(base, "box")
if (n.shape && n.shape !== "box") stamped = stampShape(stamped, n.shape)
if (n.role && n.role !== "body") stamped = stampRole(stamped, n.role)
if (n.group) stamped = stampGroup(stamped, n.group)
stamped = stampFlex(stamped, {
grow: n.grow,
align: n.align,
maxW: n.maxW,
minW0: n.minW0,
})
// Engine-measured (no explicit w/h): mark it, so the parser re-measures next
// time instead of freezing this layout's numbers as a fixed size.
if (n.w == null && n.h == null) stamped = stampAuto(stamped)
return n.cell ? stampCell(stamped, n.cell) : stamped
}
if (n.kind === "pool") {
return stampPool(n.style ?? POOL_FRAME_STYLE, {
lanes: n.lanes,
phases: n.phases,
orientation: n.orientation,
gap: n.gap,
})
}
if (n.kind === "sequence" || n.kind === "radial") {
// Both draw their own contents — lifelines, branch arrows — so the container itself
// is a frame only when the model labelled it, and invisible otherwise.
const base =
n.style ?? (n.label ? FALLBACK_FRAME : INVISIBLE_FRAME_STYLE)
return n.kind === "sequence"
? stampSequence(base, { gap: n.gap, step: n.step })
: stampRadial(base, { spread: n.spread, gap: n.gap })
}
// group or grid
const fromCatalog = n.gname ? resolve?.(n.gname, "group") : null
// An unlabelled frame with no stencil is a layout-only wrapper: emit a real cell so
// the structure survives a round-trip, but draw nothing. This replaces the
// reference project's "phantom", which emitted no cell and therefore lost the
// wrapper's direction and grouping on the way back.
const groupRole = n.kind === "group" ? n.role : undefined
const zone = n.kind === "group" ? n.group : undefined
const invisible =
!n.gname && !n.label && !n.fill && !n.stroke && !groupRole && !zone
let base = n.style ?? fromCatalog ?? FALLBACK_FRAME
if (!n.style && !fromCatalog) {
if (groupRole || zone)
base += themedStyle(groupRole ?? "heading", hue(zone), "container")
if (n.fill) base += `fillColor=${n.fill};`
if (n.stroke) base += `strokeColor=${n.stroke};`
}
if (n.kind === "group") {
const overrides = textStyleKeys(n.text)
if (overrides) base = mergeStyle(base, overrides)
}
if (groupRole && groupRole !== "body") base = stampRole(base, groupRole)
if (zone) base = stampGroup(base, zone)
if (n.kind === "group")
base = stampFlex(base, {
grow: n.grow,
align: n.align,
justify: n.justify,
alignItems: n.alignItems,
maxW: n.maxW,
minW0: n.minW0,
pad: n.pad,
})
return stampContainer(base, {
kind: n.kind,
dir: n.kind === "grid" ? "grid" : n.dir,
gap: n.gap,
cols: n.kind === "grid" ? n.cols : undefined,
invisible,
})
}
// `connectable=0` because an invisible wrapper is scaffolding, not a thing to draw arrows
// from. Without it draw.io treats the empty frame as a normal shape: hovering anywhere over
// the group pops up its connection crosses and direction arrows, which land on top of the
// content inside it and read as stray marks in the middle of the diagram.
const INVISIBLE_FRAME_STYLE =
"rounded=0;whiteSpace=wrap;html=1;fillColor=none;strokeColor=none;connectable=0;"
/**
* One `<mxCell>` for a vertex, with geometry relative to its parent.
*
* An icon's cell is the glyph square, not the measured slot. Layout reserves a wider,
* taller slot so the label underneath has room, but the cell itself must stay square:
* the stencil scales to the cell, and `verticalLabelPosition=bottom` renders the label
* outside it. Emitting the padded slot would both stretch the glyph and — because the
* padding depends on the label length — make the size grow on every round-trip.
*/
/**
* The rectangle a node actually occupies in the XML.
*
* For everything except an icon this is the slot layout measured. An icon's slot is wider
* and taller than the glyph, to leave room for the label underneath, but the cell itself
* is the glyph square centred in that slot.
*
* The router has to use this, not the slot: a slot is roughly twice the glyph's width, so
* collision tests against slots both miss real overlaps and invent false ones.
*/
export function cellRect(
n: DiagramNode,
slot: Rect,
defaultGlyph: number,
): Rect {
if (n.kind !== "icon") return slot
const glyph = n.size ?? defaultGlyph
return {
x: Math.round(slot.x + (slot.w - glyph) / 2),
y: slot.y,
w: glyph,
h: glyph,
}
}
function vertexXml(
n: DiagramNode,
rect: Rect,
parent: string,
parentRect: Rect | null,
resolve: StyleResolver | undefined,
defaultGlyph: number,
hue: HueResolver = () => NEUTRAL,
): string {
const ox = parentRect?.x ?? 0
const oy = parentRect?.y ?? 0
const box = cellRect(n, rect, defaultGlyph)
return (
`<mxCell id="${esc(n.id)}" value="${esc("label" in n ? n.label : "")}"` +
` style="${styleFor(n, resolve, hue)}" vertex="1" parent="${esc(parent)}">` +
`<mxGeometry x="${box.x - ox}" y="${box.y - oy}" width="${box.w}" height="${box.h}" as="geometry"/>` +
`</mxCell>`
)
}
/** One chrome cell: a lane band, a label column, a milestone strip, a lifeline. */
function chromeXml(
id: string,
parent: string,
rect: Rect,
parentRect: Rect | null,
style: string,
label: string,
): string {
const ox = parentRect?.x ?? 0
const oy = parentRect?.y ?? 0
return (
`<mxCell id="${esc(id)}" value="${esc(label)}" style="${style}" vertex="1" parent="${esc(parent)}">` +
`<mxGeometry x="${Math.round(rect.x - ox)}" y="${Math.round(rect.y - oy)}"` +
` width="${Math.round(rect.w)}" height="${Math.round(rect.h)}" as="geometry"/></mxCell>`
)
}
/**
* The lane bands, role-name column and milestone strip of a swimlane pool.
*
* Emitted BEFORE the pool's children so the nodes render on top of the bands, and derived
* from the same `poolMetrics` layout used, so a band cannot end up offset from the nodes
* sitting on it.
*
* The bands are draw.io containers and the nodes are their children. That is what makes a
* user dragging a step onto another role's band record the change: draw.io rewrites the
* node's `parent` to that band, and the band's `dai_lane` marker says which lane it is.
*/
function poolChrome(
n: PoolNode,
rect: Rect,
kids: { rect: Rect }[],
): { xml: string[]; bands: { id: string; rect: Rect }[] } {
const m = poolMetrics(n, rect, kids)
const xml: string[] = []
const bands: { id: string; rect: Rect }[] = []
for (let i = 0; i < m.lanes; i++) {
const band: Rect = m.horizontal
? {
x: m.contentX,
y: m.contentY + i * m.cellH,
w: m.contentW,
h: m.cellH,
}
: {
x: rect.x + POOL_PAD + i * m.cellW,
y: m.contentY,
w: m.cellW,
h: m.contentH,
}
const tint = i % 2 ? POOL_BAND_ALT : POOL_FILL
xml.push(
chromeXml(
`${n.id}__band${i}`,
n.id,
band,
rect,
stampLane(
`rounded=0;whiteSpace=wrap;html=1;fillColor=${tint};strokeColor=${POOL_HAIR};`,
i,
),
"",
),
)
bands.push({ id: `${n.id}__band${i}`, rect: band })
// The role name, in its own column beside the band.
const label: Rect = m.horizontal
? {
x: rect.x + POOL_PAD,
y: m.contentY + i * m.cellH,
w: LANE_LABEL,
h: m.cellH,
}
: {
x: rect.x + POOL_PAD + i * m.cellW,
y: rect.y + m.header + POOL_PAD,
w: m.cellW,
h: LANE_LABEL,
}
xml.push(
chromeXml(
`${n.id}__lane${i}`,
n.id,
label,
rect,
stampPoolDecoration(
`rounded=0;whiteSpace=wrap;html=1;fillColor=${POOL_LABEL_FILL};strokeColor=${POOL_HAIR};` +
`verticalAlign=middle;align=center;fontStyle=1;fontSize=11;${m.horizontal ? "" : "horizontal=1;"}`,
),
n.lanes[i] ?? "",
),
)
}
// Milestone labels, each spanning its even share of the columns.
for (let j = 0; j < n.phases.length; j++) {
const count = n.phases.length
const from = Math.floor((j * m.cols) / count)
const to = Math.floor(((j + 1) * m.cols) / count)
const last = j === count - 1
const span = (to - from) * (m.cellW + n.gap) - (last ? n.gap : 0)
const strip: Rect = m.horizontal
? {
x: m.contentX + from * (m.cellW + n.gap),
y: rect.y + m.header,
w: Math.max(0, span),
h: m.phaseLabel,
}
: {
// Flush against the content, because that is what the measure pass
// reserved: the pool's width is padding + content + this strip, with no
// gap between the two. Adding one here pushed the strip outside the frame.
x: rect.x + POOL_PAD + m.contentW,
y: m.contentY + from * (m.cellH + n.gap),
w: m.phaseLabel,
h: Math.max(
0,
(to - from) * (m.cellH + n.gap) - (last ? n.gap : 0),
),
}
xml.push(
chromeXml(
`${n.id}__phase${j}`,
n.id,
strip,
rect,
stampPoolDecoration(
`rounded=0;whiteSpace=wrap;html=1;fillColor=${POOL_FILL};strokeColor=${POOL_HAIR};` +
`verticalAlign=middle;align=center;fontStyle=1;fontSize=11;`,
),
n.phases[j] ?? "",
),
)
}
return { xml, bands }
}
/**
* The lifelines of a sequence diagram: one per participant, hanging from its head.
*
* Head and line are ONE cell, using mxGraph's `umlLifeline` shape with `size` set to the
* head's height. That is what keeps them together when the user drags a participant
* sideways — two separate cells would come apart, and the line would be left behind.
*
* The participant node itself is therefore not emitted as its own cell: this replaces it.
*/
function sequenceChrome(
n: SequenceNode,
rect: Rect,
kids: { node: DiagramNode; rect: Rect }[],
metrics: SequenceMetrics,
): string[] {
return kids.map((k) => {
const head = k.rect
return chromeXml(
k.node.id,
n.id,
{
x: head.x,
y: head.y,
w: head.w,
h: Math.max(head.h, metrics.bottom - head.y),
},
rect,
`${LIFELINE_STYLE}size=${Math.round(head.h)};`,
"label" in k.node ? k.node.label : "",
)
})
}
/** The label an edge renders, with its step number prefixed. */
function edgeLabel(l: LinkSpec): string {
if (l.step == null) return l.label ?? ""
return l.label ? `${l.step}. ${l.label}` : `${l.step}.`
}
/**
* Slide each edge label along its own edge to a spot where it covers nothing.
*
* A label renders centred on the path midpoint, and on a long edge that midpoint is
* frequently on top of something — the edge was routed AROUND the boxes, so its middle
* passes exactly the things it avoided, and the router has never known labels exist.
* Measured on a git-workflow diagram: four labels sat on unrelated boxes or on each other.
*
* For each labelled edge, in order of path length (longest first, since they have the
* fewest clear spots), positions along the path are tried from the middle outwards; the
* first where the label's rectangle overlaps no box and no already-placed label wins.
* draw.io expresses the position as the geometry's relative x: 1 at the source, 0 at the
* midpoint, +1 at the target.
*
* The label's size is an estimate (7px per character, one line). That is fine here: the
* goal is to stop labels sitting ON things, and a near miss by a few pixels still reads
* clearly, where the current midpoint placement puts them dead centre on a box.
*/
function placeLabels(
edges: { id: string; label: string; path: Point[] }[],
boxes: Rect[],
): Map<string, number> {
const placed: Rect[] = []
const out = new Map<string, number>()
const measure = (label: string): { w: number; h: number } => ({
w: Math.min(160, label.length * 7 + 8),
h: 16,
})
const pointAt = (path: Point[], t: number): Point => {
let total = 0
const segs = path.slice(0, -1).map((p, i) => {
const len =
Math.abs(path[i + 1].x - p.x) + Math.abs(path[i + 1].y - p.y)
total += len
return { a: p, b: path[i + 1], len }
})
let at = total * t
for (const s of segs) {
if (at <= s.len || s === segs[segs.length - 1]) {
const f = s.len ? Math.min(1, at / s.len) : 0
return {
x: s.a.x + (s.b.x - s.a.x) * f,
y: s.a.y + (s.b.y - s.a.y) * f,
}
}
at -= s.len
}
return path[0]
}
const overlaps = (r: Rect, list: Rect[]) =>
list.some(
(o) =>
r.x < o.x + o.w &&
o.x < r.x + r.w &&
r.y < o.y + o.h &&
o.y < r.y + r.h,
)
const byLength = [...edges].sort((p, q) => {
const len = (e: { path: Point[] }) =>
e.path.reduce(
(s, pt, i) =>
i === 0
? 0
: s +
Math.abs(pt.x - e.path[i - 1].x) +
Math.abs(pt.y - e.path[i - 1].y),
0,
)
return len(q) - len(p)
})
// The midpoint first — it is where a reader expects the label — then nearby spots,
// preferring the source half slightly: a label near the arrow's origin still reads as
// naming the action.
const TRIES = [0.5, 0.42, 0.58, 0.34, 0.66, 0.26, 0.74, 0.18, 0.82]
for (const e of byLength) {
const { w, h } = measure(e.label)
let chosen = 0.5
for (const t of TRIES) {
const c = pointAt(e.path, t)
const rect = { x: c.x - w / 2, y: c.y - h / 2, w, h }
if (!overlaps(rect, boxes) && !overlaps(rect, placed)) {
chosen = t
break
}
}
const c = pointAt(e.path, chosen)
placed.push({ x: c.x - w / 2, y: c.y - h / 2, w, h })
// Even a spot that still overlaps is recorded, so the NEXT label avoids stacking
// on top of it — two labels on one point is strictly worse than one on a box.
if (chosen !== 0.5) out.set(e.id, chosen * 2 - 1)
}
return out
}
/**
* One `<mxCell>` for an edge, carrying the route the router computed.
*
* Connection points are always written. They are fractions of the terminal's bounds, so
* draw.io recomputes them from live geometry on every edit — they follow a node when the
* user drags it. Without them draw.io picks the side itself, knowing only the two
* terminals and nothing about the other icons, which is how arrows end up running through
* unrelated shapes and stacking several on one point.
*
* Waypoints are absolute, so draw.io keeps them after a drag and the route deforms. They
* are written only when the router says they are load-bearing: a labelled bend (the label
* sits at the path midpoint and needs a straight segment under it) or a deliberate detour
* around something a straight line would have hit.
*/
function edgeXml(
l: LinkSpec,
index: number,
route?: RoutedEdge,
labelAt?: number,
): string {
const label = edgeLabel(l)
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);"
}
// SET rather than append. Unlike the branch above, this runs for a recovered style too —
// the router recomputes the ports on every layout, so they cannot be left at whatever the
// canvas held. But that recovered style ALREADY carries the previous pass's eight port
// keys, and appending grew the style by 76 characters per round-trip without bound.
// draw.io resolves duplicates last-wins so the arrow always looked right, which is why
// this survived until a byte-identity check caught it.
if (route)
style = appendOnce(
style,
`exitX=${route.exit.x};exitY=${route.exit.y};exitDx=0;exitDy=0;` +
`entryX=${route.entry.x};entryY=${route.entry.y};entryDx=0;entryDy=0;`,
)
const id = l.id ?? `ed${index + 1}`
const points =
route?.freeze && route.waypoints.length
? `<Array as="points">${route.waypoints
.map(
(p) =>
`<mxPoint x="${Math.round(p.x)}" y="${Math.round(p.y)}"/>`,
)
.join("")}</Array>`
: ""
// The geometry's x is the label's position along the path: 1 source, 0 middle, +1
// target. Written only when the label had to move off the midpoint to cover nothing.
const geo =
labelAt !== undefined
? `<mxGeometry x="${labelAt.toFixed(2)}" relative="1" as="geometry">${points}</mxGeometry>`
: `<mxGeometry relative="1" as="geometry">${points}</mxGeometry>`
return (
`<mxCell id="${esc(id)}" value="${esc(label)}" style="${style}" edge="1" parent="1"` +
` source="${esc(l.source)}" target="${esc(l.target)}">` +
geo +
`</mxCell>`
)
}
/**
* One message of a sequence diagram: a horizontal arrow between two lifelines.
*
* Written with absolute endpoints rather than terminal references, because that is the only
* way to control the HEIGHT. A message's vertical position is its position in the
* conversation; if draw.io picked it, the reading order would be whatever the geometry
* happened to give. The source and target are still recorded, so the arrow follows a
* participant the user drags sideways and the parser can read the message back.
*
* A self-message — an object calling itself — cannot be a straight line, so it steps out to
* the right and comes back one row lower.
*/
function messageXml(
l: LinkSpec,
index: number,
y: number,
rects: Map<string, Rect>,
): string {
const a = rects.get(l.source)
const b = rects.get(l.target)
const centre = (r: Rect | undefined) => (r ? r.x + r.w / 2 : 0)
const from = centre(a)
const to = centre(b)
const self = l.source === l.target
let style = l.style ?? EDGE_STYLE
if (!l.style) {
// 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;"
}
const id = l.id ?? `ed${index + 1}`
// A self-message loops out 40px and drops half a row, so it reads as one call and return.
const points = self
? `<Array as="points"><mxPoint x="${Math.round(from + 40)}" y="${Math.round(y)}"/>` +
`<mxPoint x="${Math.round(from + 40)}" y="${Math.round(y + 22)}"/></Array>`
: ""
const endY = self ? y + 22 : y
return (
`<mxCell id="${esc(id)}" value="${esc(edgeLabel(l))}" style="${style}" edge="1" parent="1"` +
` source="${esc(l.source)}" target="${esc(l.target)}">` +
`<mxGeometry relative="1" as="geometry">${points}` +
`<mxPoint x="${Math.round(from)}" y="${Math.round(y)}" as="sourcePoint"/>` +
`<mxPoint x="${Math.round(self ? from : to)}" y="${Math.round(endY)}" as="targetPoint"/>` +
`</mxGeometry></mxCell>`
)
}
export interface RenderResult {
/** A complete `<mxfile>` document, ready for the editor. */
xml: string
page: { w: number; h: number }
/** Ids the links referenced that no node provides — these edges were dropped. */
danglingLinks: string[]
}
/**
* Render a tree to a complete draw.io document.
*
* Links whose endpoints do not exist are dropped rather than emitted: draw.io renders a
* dangling edge as an arrow floating in space, which looks like a bug in the diagram.
* The dropped ids are reported so the caller can tell the model what happened.
*/
export function renderDiagram(
tree: DiagramTree,
opts: RenderOptions = {},
): RenderResult {
const { roots, page } = layoutForest(tree.roots, {
iconSize: opts.iconSize,
gap: opts.rootGap,
links: tree.links,
aspect: tree.aspect,
})
const flat = flatten(roots)
const glyph = opts.iconSize ?? ICON_SIZE
// Slot rectangles, for positioning children relative to their parent.
const rectById = new Map<string, Rect>()
for (const f of flat) rectById.set(f.node.id, f.rect)
// Emitted-cell rectangles, which is what the router must see.
const cellById = new Map<string, Rect>()
for (const f of flat)
cellById.set(f.node.id, cellRect(f.node, f.rect, glyph))
const cells: string[] = []
// Title spans the page width, above the content.
if (tree.title)
cells.push(
`<mxCell id="__title" value="${esc(tree.title)}" style="${TITLE_STYLE}" vertex="1" parent="1">` +
`<mxGeometry x="0" y="24" width="${page.w}" height="30" as="geometry"/></mxCell>`,
)
// A pool's children are parented to its lane BANDS, not to the pool: that is what
// records the role assignment when the user drags a step to another lane.
const bandOf = new Map<string, Rect & { id: string }>()
// Participants a sequence container emits as lifelines instead of ordinary cells.
const asLifeline = new Set<string>()
// Message y-positions per sequence container, so its arrows can be pinned to a height.
const messageYOf = new Map<string, (step: number) => number>()
// Chrome cells, keyed by the container they belong to so they can be emitted just after
// it — a band has to exist before the node that names it as parent.
const chrome = new Map<string, string[]>()
for (const f of flat) {
const n = f.node
if (n.kind === "pool") {
const kids = n.children
.map((c) => rectById.get(c.id))
.filter((r): r is Rect => r !== undefined)
.map((rect) => ({ rect }))
const { xml, bands } = poolChrome(n, f.rect, kids)
chrome.set(n.id, xml)
for (const c of n.children) {
const band =
bands[Math.min(poolCellOf(c).lane, bands.length - 1)]
if (band) bandOf.set(c.id, { ...band.rect, id: band.id })
}
} else if (n.kind === "sequence") {
const kids = n.children
.map((c) => ({ node: c, rect: rectById.get(c.id) }))
.filter(
(k): k is { node: DiagramNode; rect: Rect } =>
k.rect !== undefined,
)
// One metrics call for both the lifeline heights and the message positions:
// computing it twice is how the two would drift apart.
const metrics = sequenceMetrics(
n,
f.rect,
messageCount(n, tree.links),
)
chrome.set(n.id, sequenceChrome(n, f.rect, kids, metrics))
for (const k of kids) asLifeline.add(k.node.id)
messageYOf.set(n.id, metrics.messageY)
}
}
// Groups become hues here, in document order, so "the second zone named is green"
// holds for every diagram the engine draws. The caller only ever names zones.
const groupIndex = new Map<string, number>()
for (const f of flat) {
const g =
f.node.kind === "box" || f.node.kind === "group"
? f.node.group
: undefined
if (g && !groupIndex.has(g)) groupIndex.set(g, groupIndex.size)
}
const hue: HueResolver = (g) =>
g !== undefined && groupIndex.has(g)
? hueOf(groupIndex.get(g) as number)
: NEUTRAL
// Parents come before children (flatten guarantees it), which draw.io requires.
for (const f of flat) {
// A lifeline cell already carries its participant's label and geometry.
if (asLifeline.has(f.node.id)) continue
const band = bandOf.get(f.node.id)
const parent = band?.id ?? f.parent
const parentRect =
band ?? (parent === "1" ? null : (rectById.get(parent) ?? null))
cells.push(
vertexXml(
f.node,
f.rect,
parent,
parentRect,
opts.resolveStyle,
glyph,
hue,
),
)
const own = chrome.get(f.node.id)
if (own) cells.push(...own)
}
// Cells the parser could not interpret — user annotations, imported shapes — go back
// verbatim. A re-layout must not delete work the engine does not understand.
const foreignLayer = tree.foreign.some((c) => c.parent === "boundaries")
if (foreignLayer)
cells.push(
`<mxCell id="boundaries" value="Boundaries (locked)" parent="0" style="locked=1;"/>`,
)
for (const c of tree.foreign) cells.push(c.xml)
const known = new Set(flat.map((f) => f.node.id))
for (const c of tree.foreign) known.add(c.id)
const dangling: string[] = []
const drawable: LinkSpec[] = []
for (const l of tree.links) {
if (!known.has(l.source) || !known.has(l.target)) {
if (!known.has(l.source)) dangling.push(l.source)
if (!known.has(l.target)) dangling.push(l.target)
continue
}
drawable.push(l)
}
// A message between two participants of the same sequence container is a horizontal
// arrow at a fixed height, so it bypasses the router entirely: there is nothing to route
// around, and the height is the message's ORDER, which a router is not allowed to move.
const seqOwner = new Map<string, string>()
for (const f of flat)
if (f.node.kind === "sequence")
for (const c of f.node.children) seqOwner.set(c.id, f.node.id)
const messages: { link: LinkSpec; index: number; y: number }[] = []
const routable: { link: LinkSpec; index: number }[] = []
// Fallback numbering is per container: a page with two sequence diagrams on it must not
// have the second one's messages continue the first one's count, which would push them
// below the bottom of their own lifelines.
const autoStep = new Map<string, number>()
for (const [i, l] of drawable.entries()) {
const owner = seqOwner.get(l.source)
const yOf =
owner && owner === seqOwner.get(l.target)
? messageYOf.get(owner)
: undefined
if (yOf && owner) {
const next = (autoStep.get(owner) ?? 0) + 1
autoStep.set(owner, next)
messages.push({ link: l, index: i, y: yOf(l.step ?? next) })
} else {
routable.push({ link: l, index: i })
}
}
// Route with the whole page in view. Only leaf shapes are obstacles: an edge from
// outside a VPC to something inside it has to cross the VPC's border, so a container
// frame must not block it. Lifelines are excluded too: a message's whole job is to run
// from one lifeline to another, and every message crosses whatever lifelines lie between.
const obstacles = new Set(
flat
.filter(
(f) =>
(f.node.kind === "icon" || f.node.kind === "box") &&
!asLifeline.has(f.node.id),
)
.map((f) => f.node.id),
)
// Frames are passable but not free to ignore: a line that runs alongside a border, or
// cuts through a frame only one of its endpoints belongs to, reads as a mistake even
// though it hits nothing.
//
// An INVISIBLE container is excluded, because both of those judgements are about what a
// reader sees, and there is no border on screen to run alongside or to trespass across.
// A layer band in a flowchart is exactly that: `add_graph` wraps each row of the graph
// in an unlabelled, unstroked container purely to stack them. Counting those as frames
// measurably ruined the arrows — a back edge such as "return for correction" → "submit"
// leaves its own band, so every clean route was rejected for trespassing on a frame that
// is not drawn, and the router fell back to one that cut straight through two boxes.
// Measured over 161 generated flowcharts: 319 crossing edges before, 151 after, and not
// one diagram made worse.
const frames = new Set(
flat
.filter(
(f) =>
isContainer(f.node) &&
!isInvisible(styleFor(f.node, opts.resolveStyle)),
)
.map((f) => f.node.id),
)
const routes = routeEdges(
routable.map(({ link: l, index }) => ({
id: l.id ?? `ed${index + 1}`,
source: l.source,
target: l.target,
hasLabel: edgeLabel(l) !== "",
})),
cellById,
obstacles,
frames,
)
// Where each label goes along its edge. The router only kept LINES off the boxes; a
// label sits at the path midpoint, which on a long edge is exactly beside the things
// the line was routed around.
const labelled = routable
.map(({ link: l, index }, i) => {
const label = edgeLabel(l)
if (!label) return null
const a = cellById.get(l.source)
const b = cellById.get(l.target)
const r = routes[i]
if (!a || !b || !r) return null
const sp = {
x: a.x + r.exit.x * a.w,
y: a.y + r.exit.y * a.h,
}
const ep = {
x: b.x + r.entry.x * b.w,
y: b.y + r.entry.y * b.h,
}
return {
id: l.id ?? `ed${index + 1}`,
label,
path: [sp, ...r.waypoints, ep],
}
})
.filter((e): e is { id: string; label: string; path: Point[] } =>
Boolean(e),
)
const labelBoxes = [...obstacles]
.map((id) => cellById.get(id))
.filter((r): r is Rect => Boolean(r))
const labelAt = placeLabels(labelled, labelBoxes)
routable.forEach(({ link, index }, i) => {
const id = link.id ?? `ed${index + 1}`
cells.push(edgeXml(link, index, routes[i], labelAt.get(id)))
})
for (const m of messages)
cells.push(messageXml(m.link, m.index, m.y, cellById))
// The declared page ratio rides on the default layer's cell — the one cell every
// diagram has, and page-level state has no node to live on.
const layer = tree.aspect
? `<mxCell id="1" parent="0" style="${MARKER.aspect}=${tree.aspect};"/>`
: `<mxCell id="1" parent="0"/>`
const model =
`<mxGraphModel dx="1400" dy="900" grid="0" gridSize="10" guides="1" tooltips="1"` +
` connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="${page.w}"` +
` pageHeight="${page.h}" math="0" shadow="0"><root><mxCell id="0"/>` +
`${layer}${cells.join("")}</root></mxGraphModel>`
return {
xml: `<mxfile host="app.diagrams.net"><diagram name="Page-1" id="page-1">${model}</diagram></mxfile>`,
page,
danglingLinks: [...new Set(dangling)],
}
}
/** Re-export so callers can lay out without rendering. */
export type { Placed }