mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-02 01:20:23 +08:00
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.
1102 lines
44 KiB
TypeScript
1102 lines
44 KiB
TypeScript
/**
|
||
* 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, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'")
|
||
}
|
||
|
||
// 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 }
|