@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.
- package/dist/index.cjs +48 -38
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +42 -4
- package/dist/index.d.ts +42 -4
- package/dist/index.js +2567 -1766
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/c4-label-clearance-1290.test.ts +112 -0
- package/src/__tests__/c4-line-ends-1208.test.ts +52 -0
- package/src/__tests__/c4-mermaid-reference.test.ts +56 -17
- package/src/__tests__/c4-text-width-1210.test.ts +43 -0
- package/src/__tests__/class-component-order-1249.test.ts +40 -0
- package/src/__tests__/class-member-right-padding-1238.test.ts +43 -0
- package/src/__tests__/class-namespace-1196.test.ts +165 -0
- package/src/__tests__/class-namespace-sparse-elk-1196.test.ts +96 -0
- package/src/__tests__/class-visibility-markers-1237.test.ts +27 -0
- package/src/__tests__/er-attribute-right-padding-1262.test.ts +47 -0
- package/src/__tests__/er-default-direction-1250.test.ts +69 -0
- package/src/__tests__/er-edge-label-contrast-1244.test.ts +58 -0
- package/src/__tests__/flowchart-parallel-edge-lanes-1308.test.ts +168 -0
- package/src/__tests__/sequence-label-clearance-1242.test.ts +162 -0
- package/src/__tests__/sequence-nested-activation-1241.test.ts +37 -0
- package/src/__tests__/svg-edge-to-own-cluster-1310.test.ts +32 -0
- package/src/__tests__/xychart-x-label-overlap-1243.test.ts +62 -0
- package/src/c4/arial-widths.ts +59 -0
- package/src/c4/layout.ts +234 -44
- package/src/c4/metrics.ts +56 -30
- package/src/c4/renderer.ts +1 -1
- package/src/class/layout.ts +178 -13
- package/src/class/renderer.ts +33 -4
- package/src/er/layout.ts +20 -4
- package/src/er/renderer.ts +12 -3
- package/src/generated/mono-font-subset.ts +32 -0
- package/src/layout-engine/back-edges.ts +73 -0
- package/src/layout-engine/compound-flat.ts +916 -0
- package/src/layout-engine/edge-bundling.ts +79 -0
- package/src/layout-engine/elk-graph-builder.ts +5 -4
- package/src/layout-engine/from-elk.ts +7 -2
- package/src/layout-engine/inner-edges.ts +329 -0
- package/src/layout-engine/layout-hints.ts +27 -0
- package/src/layout-engine/to-elk.ts +185 -25
- package/src/layout-engine.ts +42 -1
- package/src/registry.ts +3 -3
- package/src/renderer.ts +26 -2
- package/src/sequence/layout.ts +72 -4
- package/src/sequence/renderer.ts +155 -15
- package/src/styles.ts +28 -1
- package/src/title-gaps.ts +161 -0
- package/src/xychart/layout.ts +31 -2
- 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
|
-
|
|
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
|
-
|
|
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
|
|
459
|
-
// as a feedback edge
|
|
460
|
-
//
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
|
|
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
|
}),
|
package/src/layout-engine.ts
CHANGED
|
@@ -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 '
|
|
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:
|
|
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(
|
|
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
|
|
package/src/sequence/layout.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|