@zombie-mermaid/svg-renderer 3.2.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/index.cjs +48 -38
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +42 -4
  4. package/dist/index.d.ts +42 -4
  5. package/dist/index.js +2567 -1766
  6. package/dist/index.js.map +1 -1
  7. package/package.json +3 -3
  8. package/src/__tests__/c4-label-clearance-1290.test.ts +112 -0
  9. package/src/__tests__/c4-line-ends-1208.test.ts +52 -0
  10. package/src/__tests__/c4-mermaid-reference.test.ts +56 -17
  11. package/src/__tests__/c4-text-width-1210.test.ts +43 -0
  12. package/src/__tests__/class-component-order-1249.test.ts +40 -0
  13. package/src/__tests__/class-member-right-padding-1238.test.ts +43 -0
  14. package/src/__tests__/class-namespace-1196.test.ts +165 -0
  15. package/src/__tests__/class-namespace-sparse-elk-1196.test.ts +96 -0
  16. package/src/__tests__/class-visibility-markers-1237.test.ts +27 -0
  17. package/src/__tests__/er-attribute-right-padding-1262.test.ts +47 -0
  18. package/src/__tests__/er-default-direction-1250.test.ts +69 -0
  19. package/src/__tests__/er-edge-label-contrast-1244.test.ts +58 -0
  20. package/src/__tests__/flowchart-parallel-edge-lanes-1308.test.ts +168 -0
  21. package/src/__tests__/sequence-label-clearance-1242.test.ts +162 -0
  22. package/src/__tests__/sequence-nested-activation-1241.test.ts +37 -0
  23. package/src/__tests__/svg-edge-to-own-cluster-1310.test.ts +32 -0
  24. package/src/__tests__/xychart-x-label-overlap-1243.test.ts +62 -0
  25. package/src/c4/arial-widths.ts +59 -0
  26. package/src/c4/layout.ts +234 -44
  27. package/src/c4/metrics.ts +56 -30
  28. package/src/c4/renderer.ts +1 -1
  29. package/src/class/layout.ts +178 -13
  30. package/src/class/renderer.ts +33 -4
  31. package/src/er/layout.ts +20 -4
  32. package/src/er/renderer.ts +12 -3
  33. package/src/generated/mono-font-subset.ts +32 -0
  34. package/src/layout-engine/back-edges.ts +73 -0
  35. package/src/layout-engine/compound-flat.ts +916 -0
  36. package/src/layout-engine/edge-bundling.ts +79 -0
  37. package/src/layout-engine/elk-graph-builder.ts +5 -4
  38. package/src/layout-engine/from-elk.ts +7 -2
  39. package/src/layout-engine/inner-edges.ts +329 -0
  40. package/src/layout-engine/layout-hints.ts +27 -0
  41. package/src/layout-engine/to-elk.ts +185 -25
  42. package/src/layout-engine.ts +42 -1
  43. package/src/registry.ts +3 -3
  44. package/src/renderer.ts +26 -2
  45. package/src/sequence/layout.ts +72 -4
  46. package/src/sequence/renderer.ts +155 -15
  47. package/src/styles.ts +28 -1
  48. package/src/title-gaps.ts +161 -0
  49. package/src/xychart/layout.ts +31 -2
  50. package/src/xychart/renderer.ts +1 -0
@@ -17,6 +17,8 @@ import type { FontSizes } from '../styles.ts'
17
17
  import { FONT_WEIGHTS, NODE_PADDING } from '../styles.ts'
18
18
  import { measureMultilineText } from '@zombie-mermaid/core'
19
19
  import { DEFAULTS } from './constants.ts'
20
+ import { findBackEdgeIndexes } from './back-edges.ts'
21
+ import type { LayoutHints } from './layout-hints.ts'
20
22
  import {
21
23
  ELK_DIRECTION_FALLBACK,
22
24
  INLINE_CENTERED_EDGE_LABEL,
@@ -29,6 +31,112 @@ import {
29
31
  type ElkPaddingSides,
30
32
  } from './elk-graph-builder.ts'
31
33
 
34
+ /**
35
+ * ELK layer constraint for a top-level `[*]` marker: the start marker is
36
+ * pinned to the first layer so a back-edge can't drag it into the middle
37
+ * (#1240). The end marker is deliberately left unconstrained: it settles in
38
+ * the layer right after the state(s) that transition into it, instead of a
39
+ * layer of its own after every other node (`LAST_SEPARATE`), which drew
40
+ * `Closed --> [*]` as a long line down the page edge when `Closed` is near
41
+ * the top. Keyed by node shape.
42
+ */
43
+ const STATE_MARKER_LAYER_CONSTRAINT: Partial<Record<string, string>> = {
44
+ 'state-start': 'FIRST',
45
+ }
46
+
47
+ /**
48
+ * Root-graph ELK options applied to state diagrams only (flowcharts keep
49
+ * their tuned defaults).
50
+ *
51
+ * Flowcharts deliberately do not get `MODEL_ORDER` cycle breaking (#1307).
52
+ * They already break cycles the way mermaid.js does, by reversing the
53
+ * depth-first back edges before ELK runs (`edgesReversedForLayout`), so ELK
54
+ * has no cycle left to break; adding `MODEL_ORDER` on top only moved nodes
55
+ * away from mermaid.js. Measured against mermaid 11.17.2 (relative placement
56
+ * of every node pair, `scripts/layout-oracle.ts` style): applied to every
57
+ * flowchart it changed 5 of the 26 gallery samples and 49 of 104 doc/test
58
+ * flowcharts, closer for 0 and 2, further for 4 and 41; applied only to
59
+ * graphs with a subgraph direction override (the one case where the
60
+ * pre-reversal is skipped) it changed 1 and 7, closer for 0 and 2, further
61
+ * for 1 and 5.
62
+ *
63
+ * - `cycleBreaking: MODEL_ORDER` reverses the edge that points *against*
64
+ * declaration order instead of whichever edge ELK's greedy heuristic picks.
65
+ * The greedy default reversed the main chain of `[*] --> Idle`,
66
+ * `Idle --> Active`, `Active --> Idle`, drawing it bottom-to-top with the
67
+ * start marker beside a state.
68
+ * - `postCompaction: NONE`: LEFT_RIGHT_CONSTRAINT_LOCKING compaction slides
69
+ * nodes sideways after edges are routed, which leaves state edges ending
70
+ * in a horizontal run along the target's top border (arrowhead sideways).
71
+ */
72
+ const STATE_LAYOUT_OPTIONS: LayoutOptions = {
73
+ 'elk.layered.cycleBreaking.strategy': 'MODEL_ORDER',
74
+ 'elk.layered.compaction.postCompaction.strategy': 'NONE',
75
+ }
76
+
77
+ /**
78
+ * Whether `graph` is a `stateDiagram` (it has `[*]` markers, the only thing
79
+ * that distinguishes a state graph from a flowchart here). Composite states
80
+ * are included: their compound nodes are placed among their siblings at
81
+ * first-mention position (see `firstMentionRank`), so `MODEL_ORDER` reads
82
+ * them in source order instead of after every leaf (#1287).
83
+ */
84
+ export function isStateGraph(graph: MermaidGraph): boolean {
85
+ for (const node of graph.nodes.values()) {
86
+ if (node.shape === 'state-start' || node.shape === 'state-end') return true
87
+ }
88
+ return false
89
+ }
90
+
91
+ /**
92
+ * Room a subgraph's box leaves around its contents, in px. The top is the
93
+ * title bar (28) plus a gap (16) to match the bottom. Shared with the compound
94
+ * layout, which draws the boxes itself.
95
+ */
96
+ export const SUBGRAPH_PADDING = { top: 44, left: 16, bottom: 16, right: 16 }
97
+
98
+ const reversedCache = new WeakMap<MermaidGraph, Set<number>>()
99
+
100
+ /**
101
+ * Indices of the edges handed to ELK reversed, so the layout breaks cycles the
102
+ * way mermaid.js (dagre) does: see `findBackEdgeIndexes`. `elkToPositioned`
103
+ * flips each one's points back so they still run source to target.
104
+ *
105
+ * Flowcharts only. State diagrams have their own cycle breaking (above), and a
106
+ * graph with a subgraph direction override goes through hop edges and ports
107
+ * that this does not rewrite.
108
+ */
109
+ export function edgesReversedForLayout(
110
+ graph: MermaidGraph,
111
+ hints?: LayoutHints,
112
+ ): Set<number> {
113
+ // Worked out once per graph object, by whichever of `mermaidToElk` (which
114
+ // has the hints) and `elkToPositioned` (which doesn't need them) asks first.
115
+ let set = reversedCache.get(graph)
116
+ if (!set) {
117
+ set =
118
+ isStateGraph(graph) || hasAnyDirectionOverride(graph.subgraphs)
119
+ ? new Set()
120
+ : findBackEdgeIndexes(graph, hints?.walkOrder)
121
+ reversedCache.set(graph, set)
122
+ }
123
+ return set
124
+ }
125
+
126
+ /**
127
+ * Source position of a root-level state or composite: the index of the first
128
+ * edge that names it. A composite no edge names itself falls back to the first
129
+ * edge naming a state inside it. Items no edge mentions rank last, keeping
130
+ * their relative order.
131
+ */
132
+ function firstMentionRank(graph: MermaidGraph, ids: Set<string>): number {
133
+ for (let i = 0; i < graph.edges.length; i++) {
134
+ const e = graph.edges[i]!
135
+ if (ids.has(e.source) || ids.has(e.target)) return i
136
+ }
137
+ return Number.POSITIVE_INFINITY
138
+ }
139
+
32
140
  /**
33
141
  * Convert a Mermaid direction to an ELK direction, flowchart/state-style
34
142
  * (no direction at all → `DOWN`; see `ELK_DIRECTION_FALLBACK`).
@@ -156,7 +264,7 @@ const ROOT_CONTAINER = ' root'
156
264
  * compound node's own `elk.direction`) for exactly the diagrams that need
157
265
  * `SEPARATE` the most.
158
266
  */
159
- function hasAnyDirectionOverride(subgraphs: MermaidSubgraph[]): boolean {
267
+ export function hasAnyDirectionOverride(subgraphs: MermaidSubgraph[]): boolean {
160
268
  for (const sg of subgraphs) {
161
269
  if (sg.direction !== undefined) return true
162
270
  if (hasAnyDirectionOverride(sg.children)) return true
@@ -231,10 +339,12 @@ function commonPrefixLength(a: string[], b: string[]): number {
231
339
  */
232
340
  export function mermaidToElk(
233
341
  graph: MermaidGraph,
234
- opts: Required<
342
+ givenOpts: Required<
235
343
  Pick<RenderOptions, 'font' | 'padding' | 'nodeSpacing' | 'layerSpacing'>
236
344
  > & { fontSizes: FontSizes },
345
+ hints?: LayoutHints,
237
346
  ): ElkGraphNode {
347
+ const opts = givenOpts
238
348
  // Collect all node IDs that belong to subgraphs
239
349
  const subgraphNodeIds = new Set<string>()
240
350
  const subgraphIds = new Set<string>()
@@ -325,6 +435,12 @@ export function mermaidToElk(
325
435
  'elk.layered.compaction.postCompaction.strategy':
326
436
  'LEFT_RIGHT_CONSTRAINT_LOCKING',
327
437
  'elk.layered.considerModelOrder.strategy': 'NODES_AND_EDGES',
438
+ // Every layer keeps the nodes in the order the graph lists them: the
439
+ // compound layout relies on it to keep a subgraph's nodes together.
440
+ ...(hints?.forceNodeOrder
441
+ ? { 'elk.layered.crossingMinimization.forceNodeModelOrder': 'true' }
442
+ : {}),
443
+ ...(isStateGraph(graph) ? STATE_LAYOUT_OPTIONS : {}),
328
444
  'elk.layered.wrapping.strategy': 'OFF',
329
445
  // Use SEPARATE when subgraphs have direction overrides (enables proper direction handling)
330
446
  // Use INCLUDE_CHILDREN otherwise (simpler cross-hierarchy edge routing)
@@ -332,7 +448,6 @@ export function mermaidToElk(
332
448
  ? 'SEPARATE'
333
449
  : 'INCLUDE_CHILDREN',
334
450
  }
335
-
336
451
  // Ports to declare on each subgraph's ELK node, keyed by subgraph ID.
337
452
  const portsBySubgraph = new Map<string, Set<string>>()
338
453
  // Hop/bridge edges to inject into each container's own `edges` array.
@@ -432,7 +547,19 @@ export function mermaidToElk(
432
547
  node.shape,
433
548
  opts.fontSizes.nodeLabel,
434
549
  )
435
- rootChildren.push(buildElkLeafNode(id, size, node.label))
550
+ const fixedWidth = hints?.fixedWidths?.get(id)
551
+ if (fixedWidth !== undefined) size.width = fixedWidth
552
+ const leaf = buildElkLeafNode(id, size, node.label)
553
+ // A top-level start `[*]` marks where reading starts: pin it to the
554
+ // first layer so a back-edge can't drag it into the middle. The end
555
+ // marker stays free (see STATE_MARKER_LAYER_CONSTRAINT).
556
+ const pin = isStateGraph(graph)
557
+ ? STATE_MARKER_LAYER_CONSTRAINT[node.shape]
558
+ : undefined
559
+ if (pin !== undefined) {
560
+ leaf.layoutOptions = { 'elk.layered.layering.layerConstraint': pin }
561
+ }
562
+ rootChildren.push(leaf)
436
563
  }
437
564
  }
438
565
 
@@ -455,12 +582,17 @@ export function mermaidToElk(
455
582
  // Deliberately scoped to *only* the sibling-subgraph reversal, not a
456
583
  // wholesale "all clusters before all leaves" reordering (which mermaid's
457
584
  // own mechanism also does, globally): moving the whole subgraphs group
458
- // ahead of top-level leaf nodes changed which edge in a cycle ELK treats
459
- // as a feedback edge for a sample mixing top-level leaves and a
460
- // subgraph in a cyclic flow (e.g. "CI/CD Pipeline"), reordering the
461
- // entire rank structure rather than just left-right sibling position —
462
- // a much bigger, unreviewed blast radius than the reported bug needs.
463
- for (const sg of [...graph.subgraphs].reverse()) {
585
+ // ahead of top-level leaf nodes changes which edge in a cycle ELK treats
586
+ // as a feedback edge. Cycles in a flowchart are broken before ELK sees
587
+ // them (`edgesReversedForLayout`), so this ordering no longer decides that.
588
+ const stateGraph = isStateGraph(graph)
589
+ const leafCount = rootChildren.length
590
+ // State diagrams read in source order, so composites keep forward
591
+ // declaration order and are interleaved with the leaves below.
592
+ const subgraphOrder = stateGraph
593
+ ? graph.subgraphs
594
+ : [...graph.subgraphs].reverse()
595
+ for (const sg of subgraphOrder) {
464
596
  rootChildren.push(
465
597
  subgraphToElk(
466
598
  sg,
@@ -473,22 +605,50 @@ export function mermaidToElk(
473
605
  ),
474
606
  )
475
607
  }
608
+ if (stateGraph && graph.subgraphs.length > 0) {
609
+ // ELK's MODEL_ORDER cycle breaking and crossing minimisation follow the
610
+ // child order, so a composite must sit at its first-mention position
611
+ // among its siblings rather than after every leaf (#1287). Array#sort is
612
+ // stable, so equal ranks keep leaves-then-composites order.
613
+ const rank = rootChildren.map((child, i) => {
614
+ if (i < leafCount) return firstMentionRank(graph, new Set([child.id]))
615
+ const sg = graph.subgraphs[i - leafCount]!
616
+ const own = firstMentionRank(graph, new Set([sg.id]))
617
+ if (own !== Number.POSITIVE_INFINITY) return own
618
+ const members = new Set<string>()
619
+ collectAllMemberNodeIds(sg, members)
620
+ return firstMentionRank(graph, members)
621
+ })
622
+ const order = rootChildren
623
+ .map((_, i) => i)
624
+ .sort((a, b) => rank[a]! - rank[b]! || a - b)
625
+ const sorted = order.map((i) => rootChildren[i]!)
626
+ rootChildren.splice(0, rootChildren.length, ...sorted)
627
+ }
476
628
 
477
629
  // Add root-level edges. Self-loops (edge.source === edge.target) are
478
630
  // excluded — ELK has no native self-loop layout and produces a degenerate
479
631
  // zero-length-span polyline for them; from-elk.ts synthesizes a proper
480
632
  // side loop for these once node positions are known instead.
633
+ const reversed = edgesReversedForLayout(graph, hints)
481
634
  for (const { index, edge } of edgesBySubgraph.get(null)!) {
482
635
  if (edge.source === edge.target) continue
483
- rootEdges.push(
484
- buildElkEdge({
485
- id: `e${index}`,
486
- source: edge.source,
487
- target: edge.target,
488
- label: edge.label,
489
- labelStyle: edgeLabelStyle(opts),
490
- }),
491
- )
636
+ if (hints?.detachedEdges?.has(index)) continue
637
+ const elkEdge = buildElkEdge({
638
+ id: `e${index}`,
639
+ source: reversed.has(index) ? edge.target : edge.source,
640
+ target: reversed.has(index) ? edge.source : edge.target,
641
+ label: edge.label,
642
+ labelStyle: edgeLabelStyle(opts),
643
+ })
644
+ if (hints?.looseEdges?.has(index)) {
645
+ // Priority 0: the straightening pass leaves this edge alone.
646
+ elkEdge.layoutOptions = {
647
+ ...elkEdge.layoutOptions,
648
+ 'elk.layered.priority.straightness': '0',
649
+ }
650
+ }
651
+ rootEdges.push(elkEdge)
492
652
  }
493
653
 
494
654
  if (hasDirectionOverride) {
@@ -506,8 +666,8 @@ export function mermaidToElk(
506
666
  rootEdges.push(
507
667
  buildElkEdge({
508
668
  id: `e${index}`,
509
- source: edge.source,
510
- target: edge.target,
669
+ source: reversed.has(index) ? edge.target : edge.source,
670
+ target: reversed.has(index) ? edge.source : edge.target,
511
671
  label: edge.label,
512
672
  labelStyle: edgeLabelStyle(opts),
513
673
  }),
@@ -567,8 +727,7 @@ function subgraphToElk(
567
727
  direction: directionToElk(effectiveDirection),
568
728
  nodeSpacing: opts.nodeSpacing,
569
729
  layerSpacing: opts.layerSpacing,
570
- // Top = headerHeight(28) + gap(16) to match bottom padding
571
- padding: { top: 44, left: 16, bottom: 16, right: 16 },
730
+ padding: SUBGRAPH_PADDING,
572
731
  })
573
732
 
574
733
  // Ports, built before children/edges since they don't depend on them.
@@ -619,13 +778,14 @@ function subgraphToElk(
619
778
  // Self-loops are excluded — see the matching comment in mermaidToElk.
620
779
  const edges: ElkExtendedEdge[] = []
621
780
  const internalEdges = edgesBySubgraph.get(sg.id) ?? []
781
+ const reversed = edgesReversedForLayout(graph)
622
782
  for (const { index, edge } of internalEdges) {
623
783
  if (edge.source === edge.target) continue
624
784
  edges.push(
625
785
  buildElkEdge({
626
786
  id: `e${index}`,
627
- source: edge.source,
628
- target: edge.target,
787
+ source: reversed.has(index) ? edge.target : edge.source,
788
+ target: reversed.has(index) ? edge.source : edge.target,
629
789
  label: edge.label,
630
790
  labelStyle: edgeLabelStyle(opts),
631
791
  }),
@@ -30,11 +30,17 @@ import type {
30
30
  PositionedGraph,
31
31
  FlowchartRenderOptions,
32
32
  } from '@zombie-mermaid/core'
33
+ import { withoutOwnClusterEdges } from '@zombie-mermaid/core'
33
34
  import { elkLayoutSync } from './elk-instance.ts'
34
35
  import { resolveFontSizes } from './styles.ts'
35
36
  import { DEFAULTS } from './layout-engine/constants.ts'
36
37
  import { mermaidToElk } from './layout-engine/to-elk.ts'
37
38
  import { elkToPositioned } from './layout-engine/from-elk.ts'
39
+ import {
40
+ canLayOutFlat,
41
+ layoutCompoundFlat,
42
+ } from './layout-engine/compound-flat.ts'
43
+ import type { LayoutHints } from './layout-engine/layout-hints.ts'
38
44
 
39
45
  // ============================================================================
40
46
  // Public API
@@ -47,17 +53,52 @@ import { elkToPositioned } from './layout-engine/from-elk.ts'
47
53
  export function layoutGraphSync(
48
54
  graph: MermaidGraph,
49
55
  options: FlowchartRenderOptions = {},
56
+ ): PositionedGraph {
57
+ return layoutWithHints(graph, options)
58
+ }
59
+
60
+ /** One layout run, with the hints the compound layout gives it. */
61
+ function layoutWithHints(
62
+ graph: MermaidGraph,
63
+ options: FlowchartRenderOptions,
64
+ hints?: LayoutHints,
50
65
  ): PositionedGraph {
51
66
  const opts = {
52
67
  ...DEFAULTS,
53
68
  ...options,
54
69
  fontSizes: resolveFontSizes(options.fontSizes),
55
70
  }
56
- const elkGraph = mermaidToElk(graph, opts)
71
+ const elkGraph = mermaidToElk(graph, opts, hints)
57
72
  const result = elkLayoutSync(elkGraph, options.layoutCache)
58
73
  return elkToPositioned(result, graph, opts.mergeEdges)
59
74
  }
60
75
 
76
+ /**
77
+ * Lay out a flowchart, with its subgraphs arranged the way mermaid.js arranges
78
+ * them (see `layout-engine/compound-flat.ts`). Anything that layout doesn't
79
+ * handle, or can't make fit, is laid out by `layoutGraphSync`, so this is a
80
+ * drop-in for it wherever the graph is a flowchart. Other diagram types that
81
+ * share the engine (state, architecture) keep using `layoutGraphSync`.
82
+ */
83
+ export function layoutFlowchartSync(
84
+ rawGraph: MermaidGraph,
85
+ options: FlowchartRenderOptions = {},
86
+ ): PositionedGraph {
87
+ // Real mermaid.js draws no line for an edge between a node and a subgraph
88
+ // containing it (#1310), so it is not laid out or rendered here either.
89
+ const graph = withoutOwnClusterEdges(rawGraph)
90
+ if (canLayOutFlat(graph)) {
91
+ const fontSizes = resolveFontSizes(options.fontSizes)
92
+ const flat = layoutCompoundFlat(
93
+ graph,
94
+ (g, hints) => layoutWithHints(g, options, hints),
95
+ fontSizes,
96
+ )
97
+ if (flat) return flat
98
+ }
99
+ return layoutGraphSync(graph, options)
100
+ }
101
+
61
102
  /**
62
103
  * Convert MermaidGraph to ELK format (for benchmarking conversion overhead).
63
104
  */
package/src/registry.ts CHANGED
@@ -81,9 +81,9 @@ import { layoutClassDiagramSync } from './class/layout.ts'
81
81
  import { renderClassSvg } from './class/renderer.ts'
82
82
  import { layoutC4DiagramSync } from './c4/layout.ts'
83
83
  import { renderC4Svg } from './c4/renderer.ts'
84
- import { layoutGraphSync } from './layout-engine.ts'
84
+ import { layoutFlowchartSync, layoutGraphSync } from './layout-engine.ts'
85
85
  import { renderSvg as renderFlowchartSvg } from './renderer.ts'
86
- import { parseMermaid } from '../../../src/parser.ts'
86
+ import { parseMermaid } from '@zombie-mermaid/mermaid-parser'
87
87
 
88
88
  /**
89
89
  * Parameters shared by every per-type SVG renderer today, factored out of
@@ -309,7 +309,7 @@ const flowchartModule: DiagramModule<MermaidGraph, PositionedFlowchart> = {
309
309
  // see the doc comment on `PositionedFlowchart` above.
310
310
  const graph = withDirectionOverride(diagram, options.direction)
311
311
  return {
312
- graph: layoutGraphSync(graph, options),
312
+ graph: layoutFlowchartSync(graph, options),
313
313
  curve: options.curve ?? diagram.initConfig?.curve ?? 'linear',
314
314
  }
315
315
  },
package/src/renderer.ts CHANGED
@@ -30,6 +30,7 @@ import {
30
30
  ARROW_HEAD,
31
31
  } from './styles.ts'
32
32
  import { pointsToPath } from './edge-curves.ts'
33
+ import { edgeCrossesTitle, titleGapMask, titleTextBoxes } from './title-gaps.ts'
33
34
 
34
35
  // ============================================================================
35
36
  // SVG renderer — converts a PositionedGraph into an SVG string.
@@ -143,6 +144,19 @@ export function renderSvg(
143
144
  for (const color of customStrokeColors) {
144
145
  parts.push(arrowMarkerDefsForColor(color))
145
146
  }
147
+ // An edge that crosses a subgraph's title text is not painted over the text
148
+ // (#1239). The mask is only emitted when some edge does, so a diagram with
149
+ // no such edge is unchanged.
150
+ const titleBoxes = titleTextBoxes(graph.groups, fontSizes)
151
+ const crossingEdges = new Set(
152
+ graph.edges.filter((e) => edgeCrossesTitle(e, titleBoxes)),
153
+ )
154
+ let titleGapMaskId: string | undefined
155
+ if (crossingEdges.size > 0) {
156
+ const mask = titleGapMask(titleBoxes, graph.width, graph.height)
157
+ titleGapMaskId = mask.id
158
+ parts.push(mask.markup)
159
+ }
146
160
  parts.push('</defs>')
147
161
 
148
162
  // 1. Subgraph backgrounds (group rectangles with header bands)
@@ -153,7 +167,14 @@ export function renderSvg(
153
167
  // 2. Edges (paths — rendered behind nodes)
154
168
  // Each edge is a <path> with semantic data-* attributes
155
169
  for (const edge of graph.edges) {
156
- parts.push(renderEdge(edge, curve, animationEnabled))
170
+ parts.push(
171
+ renderEdge(
172
+ edge,
173
+ curve,
174
+ animationEnabled,
175
+ crossingEdges.has(edge) ? titleGapMaskId : undefined,
176
+ ),
177
+ )
157
178
  }
158
179
 
159
180
  // 3. Edge labels (positioned at midpoint of edge)
@@ -315,6 +336,7 @@ function renderEdge(
315
336
  edge: PositionedEdge,
316
337
  curve: CurveStyle,
317
338
  animationEnabled: boolean = true,
339
+ titleGapMaskId?: string,
318
340
  ): string {
319
341
  if (edge.points.length < 2) return ''
320
342
 
@@ -402,7 +424,9 @@ function renderEdge(
402
424
  return (
403
425
  f`<${curved ? 'path' : 'polyline'} ${dataAttrs.join(' ')} ${geometry} ` +
404
426
  f`fill="none" stroke="${strokeColor}" ` +
405
- f`stroke-width="${strokeWidth}"${dashArray}${animatedDash}${markers} />`
427
+ f`stroke-width="${strokeWidth}"${dashArray}${animatedDash}${markers}` +
428
+ (titleGapMaskId ? f` mask="url(#${titleGapMaskId})"` : '') +
429
+ ' />'
406
430
  )
407
431
  }
408
432
 
@@ -10,6 +10,7 @@ import type {
10
10
  PositionedParticipantBox,
11
11
  } from '@zombie-mermaid/mermaid-parser'
12
12
  import type { RenderOptions, SequenceRenderOptions } from '@zombie-mermaid/core'
13
+ import { measureMultilineText } from '@zombie-mermaid/core'
13
14
  import { estimateTextWidth, FONT_WEIGHTS, resolveFontSizes } from '../styles.ts'
14
15
 
15
16
  // ============================================================================
@@ -77,6 +78,32 @@ export function boxLabelHeight(labelFontSize: number): number {
77
78
  return labelFontSize + 8
78
79
  }
79
80
 
81
+ /**
82
+ * Distance from the bottom of a stick-figure (`actor`) icon to the vertical
83
+ * centre of its label. Shared with the renderer so the label lands where the
84
+ * layout reserved room for it.
85
+ */
86
+ export const ACTOR_LABEL_OFFSET = 14
87
+
88
+ /** Clearance kept between an actor label and what sits below it, in px. */
89
+ const ACTOR_LABEL_CLEARANCE = 4
90
+
91
+ /**
92
+ * Distance from the bottom of a stick-figure icon to the bottom of its
93
+ * (possibly multi-line) label. Only `actor` participants draw a label below
94
+ * the icon; a `participant` box holds its label inside.
95
+ */
96
+ function actorLabelBottom(
97
+ label: string,
98
+ fontSize: number,
99
+ fontWeight: number,
100
+ ): number {
101
+ return (
102
+ ACTOR_LABEL_OFFSET +
103
+ measureMultilineText(label, fontSize, fontWeight).height / 2
104
+ )
105
+ }
106
+
80
107
  /** Resolved sequence-layout config — same shape as {@link SEQ} but mutable numbers. */
81
108
  type SeqConfig = { [K in keyof typeof SEQ]: number }
82
109
 
@@ -194,8 +221,32 @@ export function layoutSequenceDiagram(
194
221
  return positioned
195
222
  })
196
223
 
197
- // 3. Stack messages vertically
198
- let messageY = actorY + seq.actorHeight + seq.headerGap
224
+ // 3. Stack messages vertically. A stick-figure actor's label sits below its
225
+ // icon, in the gap before the first message; widen the gap so the label
226
+ // (and the first message's own label above its arrow) never touch.
227
+ // Participants created mid-diagram are placed at their creating message
228
+ // instead, so they don't take part in the header.
229
+ const actorLabelBottoms = diagram.actors.map((a) =>
230
+ a.type === 'actor'
231
+ ? actorLabelBottom(a.label, fontSizes.nodeLabel, FONT_WEIGHTS.nodeLabel)
232
+ : 0,
233
+ )
234
+ const headerLabelBottom = Math.max(
235
+ 0,
236
+ ...diagram.actors.map((a, i) =>
237
+ a.createdAt === undefined ? actorLabelBottoms[i]! : 0,
238
+ ),
239
+ )
240
+ const firstMessageLabelRoom = fontSizes.edgeLabel + 12
241
+ let messageY =
242
+ actorY +
243
+ seq.actorHeight +
244
+ Math.max(
245
+ seq.headerGap,
246
+ headerLabelBottom > 0
247
+ ? headerLabelBottom + ACTOR_LABEL_CLEARANCE + firstMessageLabelRoom
248
+ : 0,
249
+ )
199
250
  const messages: PositionedMessage[] = []
200
251
 
201
252
  // Pre-scan blocks to determine which message indices need extra vertical
@@ -292,7 +343,9 @@ export function layoutSequenceDiagram(
292
343
  { startY: number; depth: number }[]
293
344
  >()
294
345
  const activations: Activation[] = []
295
- const nestingOffset = 4 // Horizontal offset per nesting level
346
+ // Horizontal offset per nesting level: half a bar, as official Mermaid does,
347
+ // so a nested bar overlaps its parent by half instead of hiding behind it.
348
+ const nestingOffset = seq.activationWidth / 2
296
349
 
297
350
  /** Open an activation bar on `actorId` at row `y` (stacks for nesting). */
298
351
  function startActivation(actorId: string, y: number): void {
@@ -320,6 +373,7 @@ export function layoutSequenceDiagram(
320
373
  topY: startY,
321
374
  bottomY: y,
322
375
  width: seq.activationWidth,
376
+ depth,
323
377
  })
324
378
  }
325
379
 
@@ -488,10 +542,17 @@ export function layoutSequenceDiagram(
488
542
  topY: startY,
489
543
  bottomY: messageY - seq.messageRowHeight / 2,
490
544
  width: seq.activationWidth,
545
+ depth,
491
546
  })
492
547
  }
493
548
  }
494
549
 
550
+ // Paint outer bars before the bars nested inside them. Bars are pushed in
551
+ // close order, so an inner one otherwise precedes (and is covered by) its
552
+ // parent, which closes later. Array#sort is stable, so equal depths keep
553
+ // their order.
554
+ activations.sort((a, b) => a.depth - b.depth)
555
+
495
556
  // 4. Position blocks (loop/alt/opt)
496
557
  const blocks: PositionedBlock[] = diagram.blocks.map((block) => {
497
558
  // Block spans from the Y of startIndex to endIndex messages
@@ -664,7 +725,14 @@ export function layoutSequenceDiagram(
664
725
  const lifeline: Lifeline = {
665
726
  actorId: a.id,
666
727
  x: actorCenterX[i]!,
667
- topY: actors[i]!.y + seq.actorHeight,
728
+ // A stick-figure actor's label sits on the lifeline's axis just below
729
+ // the icon, so the line starts under it rather than through it.
730
+ topY:
731
+ actors[i]!.y +
732
+ seq.actorHeight +
733
+ (actorLabelBottoms[i]! > 0
734
+ ? actorLabelBottoms[i]! + ACTOR_LABEL_CLEARANCE
735
+ : 0),
668
736
  bottomY:
669
737
  destroyedAt === undefined
670
738
  ? diagramBottom - seq.padding