@zombie-mermaid/svg-renderer 3.2.0 → 4.0.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 +45 -41
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +34 -4
- package/dist/index.d.ts +34 -4
- package/dist/index.js +2310 -1115
- 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__/sequence-label-clearance-1242.test.ts +162 -0
- package/src/__tests__/sequence-nested-activation-1241.test.ts +37 -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 +31 -2
- package/src/er/layout.ts +20 -4
- package/src/er/renderer.ts +10 -1
- package/src/layout-engine/back-edges.ts +73 -0
- package/src/layout-engine/compound-flat.ts +916 -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 +170 -20
- package/src/layout-engine.ts +38 -1
- package/src/registry.ts +2 -2
- package/src/renderer.ts +26 -2
- package/src/sequence/layout.ts +72 -4
- package/src/sequence/renderer.ts +155 -15
- package/src/title-gaps.ts +146 -0
- package/src/xychart/layout.ts +31 -2
- package/src/xychart/renderer.ts +1 -0
package/src/layout-engine.ts
CHANGED
|
@@ -35,6 +35,11 @@ import { resolveFontSizes } from './styles.ts'
|
|
|
35
35
|
import { DEFAULTS } from './layout-engine/constants.ts'
|
|
36
36
|
import { mermaidToElk } from './layout-engine/to-elk.ts'
|
|
37
37
|
import { elkToPositioned } from './layout-engine/from-elk.ts'
|
|
38
|
+
import {
|
|
39
|
+
canLayOutFlat,
|
|
40
|
+
layoutCompoundFlat,
|
|
41
|
+
} from './layout-engine/compound-flat.ts'
|
|
42
|
+
import type { LayoutHints } from './layout-engine/layout-hints.ts'
|
|
38
43
|
|
|
39
44
|
// ============================================================================
|
|
40
45
|
// Public API
|
|
@@ -47,17 +52,49 @@ import { elkToPositioned } from './layout-engine/from-elk.ts'
|
|
|
47
52
|
export function layoutGraphSync(
|
|
48
53
|
graph: MermaidGraph,
|
|
49
54
|
options: FlowchartRenderOptions = {},
|
|
55
|
+
): PositionedGraph {
|
|
56
|
+
return layoutWithHints(graph, options)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** One layout run, with the hints the compound layout gives it. */
|
|
60
|
+
function layoutWithHints(
|
|
61
|
+
graph: MermaidGraph,
|
|
62
|
+
options: FlowchartRenderOptions,
|
|
63
|
+
hints?: LayoutHints,
|
|
50
64
|
): PositionedGraph {
|
|
51
65
|
const opts = {
|
|
52
66
|
...DEFAULTS,
|
|
53
67
|
...options,
|
|
54
68
|
fontSizes: resolveFontSizes(options.fontSizes),
|
|
55
69
|
}
|
|
56
|
-
const elkGraph = mermaidToElk(graph, opts)
|
|
70
|
+
const elkGraph = mermaidToElk(graph, opts, hints)
|
|
57
71
|
const result = elkLayoutSync(elkGraph, options.layoutCache)
|
|
58
72
|
return elkToPositioned(result, graph, opts.mergeEdges)
|
|
59
73
|
}
|
|
60
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Lay out a flowchart, with its subgraphs arranged the way mermaid.js arranges
|
|
77
|
+
* them (see `layout-engine/compound-flat.ts`). Anything that layout doesn't
|
|
78
|
+
* handle, or can't make fit, is laid out by `layoutGraphSync`, so this is a
|
|
79
|
+
* drop-in for it wherever the graph is a flowchart. Other diagram types that
|
|
80
|
+
* share the engine (state, architecture) keep using `layoutGraphSync`.
|
|
81
|
+
*/
|
|
82
|
+
export function layoutFlowchartSync(
|
|
83
|
+
graph: MermaidGraph,
|
|
84
|
+
options: FlowchartRenderOptions = {},
|
|
85
|
+
): PositionedGraph {
|
|
86
|
+
if (canLayOutFlat(graph)) {
|
|
87
|
+
const fontSizes = resolveFontSizes(options.fontSizes)
|
|
88
|
+
const flat = layoutCompoundFlat(
|
|
89
|
+
graph,
|
|
90
|
+
(g, hints) => layoutWithHints(g, options, hints),
|
|
91
|
+
fontSizes,
|
|
92
|
+
)
|
|
93
|
+
if (flat) return flat
|
|
94
|
+
}
|
|
95
|
+
return layoutGraphSync(graph, options)
|
|
96
|
+
}
|
|
97
|
+
|
|
61
98
|
/**
|
|
62
99
|
* Convert MermaidGraph to ELK format (for benchmarking conversion overhead).
|
|
63
100
|
*/
|
package/src/registry.ts
CHANGED
|
@@ -81,7 +81,7 @@ 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
86
|
import { parseMermaid } from '../../../src/parser.ts'
|
|
87
87
|
|
|
@@ -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
|
package/src/sequence/renderer.ts
CHANGED
|
@@ -8,12 +8,13 @@ import type {
|
|
|
8
8
|
PositionedNote,
|
|
9
9
|
PositionedParticipantBox,
|
|
10
10
|
} from '@zombie-mermaid/mermaid-parser'
|
|
11
|
-
import { boxLabelHeight } from './layout.ts'
|
|
11
|
+
import { ACTOR_LABEL_OFFSET, boxLabelHeight } from './layout.ts'
|
|
12
12
|
import type { DiagramColors, SvgEmitOptions } from '@zombie-mermaid/core'
|
|
13
13
|
import {
|
|
14
14
|
svgOpenTag,
|
|
15
15
|
buildStyleBlock,
|
|
16
16
|
renderMultilineText,
|
|
17
|
+
measureMultilineText,
|
|
17
18
|
escapeAttr,
|
|
18
19
|
f,
|
|
19
20
|
} from '@zombie-mermaid/core'
|
|
@@ -104,9 +105,11 @@ export function renderSequenceSvg(
|
|
|
104
105
|
parts.push(renderBlock(block, fontSizes))
|
|
105
106
|
}
|
|
106
107
|
|
|
107
|
-
// 2. Lifelines (dashed vertical lines from actor to bottom)
|
|
108
|
+
// 2. Lifelines (dashed vertical lines from actor to bottom), interrupted
|
|
109
|
+
// where a message / block label sits on them so no line runs through text
|
|
110
|
+
const labelBoxes = collectLabelBoxes(diagram, fontSizes)
|
|
108
111
|
for (const lifeline of diagram.lifelines) {
|
|
109
|
-
parts.push(renderLifeline(lifeline))
|
|
112
|
+
parts.push(renderLifeline(lifeline, labelBoxes))
|
|
110
113
|
}
|
|
111
114
|
|
|
112
115
|
// 3. Activation boxes
|
|
@@ -195,7 +198,7 @@ function renderActor(actor: PositionedActor, fontSizes: FontSizes): string {
|
|
|
195
198
|
renderMultilineText(
|
|
196
199
|
label,
|
|
197
200
|
x,
|
|
198
|
-
y + height +
|
|
201
|
+
y + height + ACTOR_LABEL_OFFSET,
|
|
199
202
|
fontSizes.nodeLabel,
|
|
200
203
|
f`font-size="${fontSizes.nodeLabel}" text-anchor="middle" font-weight="${FONT_WEIGHTS.nodeLabel}" fill="var(--_text)"`,
|
|
201
204
|
),
|
|
@@ -223,15 +226,152 @@ function renderActor(actor: PositionedActor, fontSizes: FontSizes): string {
|
|
|
223
226
|
return parts.join('\n')
|
|
224
227
|
}
|
|
225
228
|
|
|
229
|
+
/** Self-message loop width, height, and the gap before its label, in px. */
|
|
230
|
+
const SELF_LOOP_WIDTH = 30
|
|
231
|
+
const SELF_LOOP_HEIGHT = 20
|
|
232
|
+
const SELF_LABEL_PADDING = 8
|
|
233
|
+
|
|
234
|
+
/** How far above its arrow a message label's centre sits, in px. */
|
|
235
|
+
const MESSAGE_LABEL_RISE = 10
|
|
236
|
+
|
|
237
|
+
/** Height of a block's type-label tab, in px. */
|
|
238
|
+
const BLOCK_TAB_HEIGHT = 18
|
|
239
|
+
|
|
240
|
+
/** A rectangle some text occupies; lifelines are cut around it. */
|
|
241
|
+
interface LabelBox {
|
|
242
|
+
x0: number
|
|
243
|
+
x1: number
|
|
244
|
+
y0: number
|
|
245
|
+
y1: number
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Breathing room kept between a label and the lifeline segments around it. */
|
|
249
|
+
const LIFELINE_LABEL_PAD_X = 3
|
|
250
|
+
const LIFELINE_LABEL_PAD_Y = 2
|
|
251
|
+
|
|
252
|
+
/** Lifeline fragments shorter than this are dropped rather than drawn as a stub. */
|
|
253
|
+
const LIFELINE_MIN_SEGMENT = 3
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Footprints of every text label a lifeline can run through: message labels
|
|
257
|
+
* (above the arrow, or beside a self-message loop), the block type tab, and
|
|
258
|
+
* block divider (`else` / `and`) labels. Mirrors the positions
|
|
259
|
+
* {@link renderMessage} and {@link renderBlock} draw them at (#1242).
|
|
260
|
+
*/
|
|
261
|
+
function collectLabelBoxes(
|
|
262
|
+
diagram: PositionedSequenceDiagram,
|
|
263
|
+
fontSizes: FontSizes,
|
|
264
|
+
): LabelBox[] {
|
|
265
|
+
const boxes: LabelBox[] = []
|
|
266
|
+
const measure = (text: string) =>
|
|
267
|
+
measureMultilineText(text, fontSizes.edgeLabel, FONT_WEIGHTS.edgeLabel)
|
|
268
|
+
const centred = (text: string, cx: number, cy: number) => {
|
|
269
|
+
const m = measure(text)
|
|
270
|
+
boxes.push({
|
|
271
|
+
x0: cx - m.width / 2,
|
|
272
|
+
x1: cx + m.width / 2,
|
|
273
|
+
y0: cy - m.height / 2,
|
|
274
|
+
y1: cy + m.height / 2,
|
|
275
|
+
})
|
|
276
|
+
}
|
|
277
|
+
const startAnchored = (text: string, x: number, cy: number) => {
|
|
278
|
+
const m = measure(text)
|
|
279
|
+
boxes.push({
|
|
280
|
+
x0: x,
|
|
281
|
+
x1: x + m.width,
|
|
282
|
+
y0: cy - m.height / 2,
|
|
283
|
+
y1: cy + m.height / 2,
|
|
284
|
+
})
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
for (const msg of diagram.messages) {
|
|
288
|
+
if (!msg.label) continue
|
|
289
|
+
if (msg.isSelf) {
|
|
290
|
+
startAnchored(
|
|
291
|
+
msg.label,
|
|
292
|
+
msg.x1 + SELF_LOOP_WIDTH + SELF_LABEL_PADDING,
|
|
293
|
+
msg.y + SELF_LOOP_HEIGHT / 2,
|
|
294
|
+
)
|
|
295
|
+
} else {
|
|
296
|
+
centred(msg.label, (msg.x1 + msg.x2) / 2, msg.y - MESSAGE_LABEL_RISE)
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
for (const block of diagram.blocks) {
|
|
301
|
+
const tab = `${block.type}${block.label ? ` [${block.label}]` : ''}`
|
|
302
|
+
const tabWidth =
|
|
303
|
+
estimateTextWidth(
|
|
304
|
+
tab.split('\n')[0] ?? '',
|
|
305
|
+
fontSizes.edgeLabel,
|
|
306
|
+
FONT_WEIGHTS.groupHeader,
|
|
307
|
+
) + 16
|
|
308
|
+
boxes.push({
|
|
309
|
+
x0: block.x,
|
|
310
|
+
x1: block.x + tabWidth,
|
|
311
|
+
y0: block.y,
|
|
312
|
+
y1: block.y + BLOCK_TAB_HEIGHT,
|
|
313
|
+
})
|
|
314
|
+
for (const divider of block.dividers) {
|
|
315
|
+
if (!divider.label) continue
|
|
316
|
+
startAnchored(`[${divider.label}]`, block.x + 8, divider.y + 14)
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return boxes
|
|
320
|
+
}
|
|
321
|
+
|
|
226
322
|
/**
|
|
227
|
-
*
|
|
323
|
+
* The y-ranges of `lifeline` left after cutting out every label box it
|
|
324
|
+
* crosses, as `[y0, y1]` pairs top to bottom.
|
|
325
|
+
*/
|
|
326
|
+
function lifelineSegments(
|
|
327
|
+
lifeline: Lifeline,
|
|
328
|
+
labelBoxes: readonly LabelBox[],
|
|
329
|
+
): Array<[number, number]> {
|
|
330
|
+
const cuts = labelBoxes
|
|
331
|
+
.filter(
|
|
332
|
+
(b) =>
|
|
333
|
+
lifeline.x > b.x0 - LIFELINE_LABEL_PAD_X &&
|
|
334
|
+
lifeline.x < b.x1 + LIFELINE_LABEL_PAD_X &&
|
|
335
|
+
b.y1 + LIFELINE_LABEL_PAD_Y > lifeline.topY &&
|
|
336
|
+
b.y0 - LIFELINE_LABEL_PAD_Y < lifeline.bottomY,
|
|
337
|
+
)
|
|
338
|
+
.map((b): [number, number] => [
|
|
339
|
+
b.y0 - LIFELINE_LABEL_PAD_Y,
|
|
340
|
+
b.y1 + LIFELINE_LABEL_PAD_Y,
|
|
341
|
+
])
|
|
342
|
+
.sort((a, b) => a[0] - b[0])
|
|
343
|
+
|
|
344
|
+
const segments: Array<[number, number]> = []
|
|
345
|
+
let cursor = lifeline.topY
|
|
346
|
+
for (const [c0, c1] of cuts) {
|
|
347
|
+
if (c0 - cursor >= LIFELINE_MIN_SEGMENT) segments.push([cursor, c0])
|
|
348
|
+
cursor = Math.max(cursor, c1)
|
|
349
|
+
}
|
|
350
|
+
if (lifeline.bottomY - cursor >= LIFELINE_MIN_SEGMENT) {
|
|
351
|
+
segments.push([cursor, lifeline.bottomY])
|
|
352
|
+
}
|
|
353
|
+
// A lifeline entirely under a label (or otherwise shorter than a stub)
|
|
354
|
+
// would vanish; keep it whole rather than drop the participant's line.
|
|
355
|
+
return segments.length > 0 ? segments : [[lifeline.topY, lifeline.bottomY]]
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Render a lifeline (dashed vertical line from actor to bottom), as one
|
|
360
|
+
* `<line>` per stretch between label boxes it would otherwise pass through.
|
|
228
361
|
* Includes data-actor to link to its actor.
|
|
229
362
|
*/
|
|
230
|
-
function renderLifeline(
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
363
|
+
function renderLifeline(
|
|
364
|
+
lifeline: Lifeline,
|
|
365
|
+
labelBoxes: readonly LabelBox[],
|
|
366
|
+
): string {
|
|
367
|
+
const line = lifelineSegments(lifeline, labelBoxes)
|
|
368
|
+
.map(
|
|
369
|
+
([y0, y1]) =>
|
|
370
|
+
f`<line class="lifeline" data-actor="${escapeAttr(lifeline.actorId)}" ` +
|
|
371
|
+
f`x1="${lifeline.x}" y1="${y0}" x2="${lifeline.x}" y2="${y1}" ` +
|
|
372
|
+
`stroke="var(--_line)" stroke-width="0.75" stroke-dasharray="6 4" />`,
|
|
373
|
+
)
|
|
374
|
+
.join('\n')
|
|
235
375
|
if (!lifeline.destroyed) return line
|
|
236
376
|
// `destroy X`: the lifeline ends at the destroying message's row, marked
|
|
237
377
|
// with a cross centred on it — the same glyph Mermaid uses. Drawn in the
|
|
@@ -287,9 +427,9 @@ function renderMessage(msg: PositionedMessage, fontSizes: FontSizes): string {
|
|
|
287
427
|
if (msg.isSelf) {
|
|
288
428
|
// Self-message: curved loop going right and back
|
|
289
429
|
// Loop dimensions - loopH is fixed, loopW provides minimum clearance
|
|
290
|
-
const loopW =
|
|
291
|
-
const loopH =
|
|
292
|
-
const labelPadding =
|
|
430
|
+
const loopW = SELF_LOOP_WIDTH
|
|
431
|
+
const loopH = SELF_LOOP_HEIGHT
|
|
432
|
+
const labelPadding = SELF_LABEL_PADDING // Space between loop and label
|
|
293
433
|
parts.push(
|
|
294
434
|
f` <polyline points="${msg.x1},${msg.y} ${msg.x1 + loopW},${msg.y} ${msg.x1 + loopW},${msg.y + loopH} ${msg.x2},${msg.y + loopH}" ` +
|
|
295
435
|
f`fill="none" stroke="var(--_line)" stroke-width="${STROKE_WIDTHS.connector}"${dashArray} marker-end="url(#${markerId})"${markerStart} />`,
|
|
@@ -318,7 +458,7 @@ function renderMessage(msg: PositionedMessage, fontSizes: FontSizes): string {
|
|
|
318
458
|
renderMultilineText(
|
|
319
459
|
msg.label,
|
|
320
460
|
midX,
|
|
321
|
-
msg.y -
|
|
461
|
+
msg.y - MESSAGE_LABEL_RISE,
|
|
322
462
|
fontSizes.edgeLabel,
|
|
323
463
|
f`font-size="${fontSizes.edgeLabel}" text-anchor="middle" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_text-muted)"`,
|
|
324
464
|
),
|
|
@@ -455,7 +595,7 @@ function renderBlock(block: PositionedBlock, fontSizes: FontSizes): string {
|
|
|
455
595
|
fontSizes.edgeLabel,
|
|
456
596
|
FONT_WEIGHTS.groupHeader,
|
|
457
597
|
) + 16
|
|
458
|
-
const tabHeight =
|
|
598
|
+
const tabHeight = BLOCK_TAB_HEIGHT
|
|
459
599
|
|
|
460
600
|
parts.push(
|
|
461
601
|
f` <rect x="${block.x}" y="${block.y}" width="${tabWidth}" height="${tabHeight}" ` +
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Break an edge where it crosses a subgraph's title text.
|
|
3
|
+
*
|
|
4
|
+
* Layout treats a subgraph's title bar as plain padding, so an edge that
|
|
5
|
+
* enters a box from above can run straight over the title. mermaid.js draws
|
|
6
|
+
* such an edge through the text as well; here the edge is simply not painted
|
|
7
|
+
* over the text itself, and picks up again on the other side. The edge stays
|
|
8
|
+
* one element with its geometry untouched: a `<mask>` hides the stroke inside
|
|
9
|
+
* the text box (#1239).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { measureMultilineText } from '@zombie-mermaid/core'
|
|
13
|
+
import type {
|
|
14
|
+
PositionedEdge,
|
|
15
|
+
PositionedGroup,
|
|
16
|
+
Point,
|
|
17
|
+
} from '@zombie-mermaid/core'
|
|
18
|
+
import type { FontSizes } from './styles.ts'
|
|
19
|
+
import { FONT_WEIGHTS } from './styles.ts'
|
|
20
|
+
|
|
21
|
+
/** Left inset of the title text inside the box, matching `renderGroup`. */
|
|
22
|
+
const TITLE_TEXT_INSET = 12
|
|
23
|
+
|
|
24
|
+
/** Space left clear around the title text, in px. */
|
|
25
|
+
const GAP_PADDING = 2
|
|
26
|
+
|
|
27
|
+
export interface TitleTextBox {
|
|
28
|
+
x: number
|
|
29
|
+
y: number
|
|
30
|
+
width: number
|
|
31
|
+
height: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function flattenGroups(groups: PositionedGroup[]): PositionedGroup[] {
|
|
35
|
+
return groups.flatMap((g) => [g, ...flattenGroups(g.children)])
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The rectangle each subgraph's title text occupies, padded by `GAP_PADDING`. */
|
|
39
|
+
export function titleTextBoxes(
|
|
40
|
+
groups: PositionedGroup[],
|
|
41
|
+
fontSizes: FontSizes,
|
|
42
|
+
): TitleTextBox[] {
|
|
43
|
+
const headerHeight = fontSizes.groupHeader + 16
|
|
44
|
+
return flattenGroups(groups)
|
|
45
|
+
.filter((g) => g.label !== '')
|
|
46
|
+
.map((g) => {
|
|
47
|
+
const text = measureMultilineText(
|
|
48
|
+
g.label,
|
|
49
|
+
fontSizes.groupHeader,
|
|
50
|
+
FONT_WEIGHTS.groupHeader,
|
|
51
|
+
)
|
|
52
|
+
return {
|
|
53
|
+
x: g.x + TITLE_TEXT_INSET - GAP_PADDING,
|
|
54
|
+
y: g.y + headerHeight / 2 - text.height / 2 - GAP_PADDING,
|
|
55
|
+
width: text.width + 2 * GAP_PADDING,
|
|
56
|
+
height: text.height + 2 * GAP_PADDING,
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Whether the segment a-b touches the rectangle (Liang-Barsky clipping). */
|
|
62
|
+
function segmentHitsBox(a: Point, b: Point, box: TitleTextBox): boolean {
|
|
63
|
+
const dx = b.x - a.x
|
|
64
|
+
const dy = b.y - a.y
|
|
65
|
+
const p = [-dx, dx, -dy, dy]
|
|
66
|
+
const q = [
|
|
67
|
+
a.x - box.x,
|
|
68
|
+
box.x + box.width - a.x,
|
|
69
|
+
a.y - box.y,
|
|
70
|
+
box.y + box.height - a.y,
|
|
71
|
+
]
|
|
72
|
+
let t0 = 0
|
|
73
|
+
let t1 = 1
|
|
74
|
+
for (let i = 0; i < 4; i++) {
|
|
75
|
+
const pi = p[i]!
|
|
76
|
+
const qi = q[i]!
|
|
77
|
+
if (pi === 0) {
|
|
78
|
+
if (qi < 0) return false
|
|
79
|
+
} else {
|
|
80
|
+
const t = qi / pi
|
|
81
|
+
if (pi < 0) t0 = Math.max(t0, t)
|
|
82
|
+
else t1 = Math.min(t1, t)
|
|
83
|
+
if (t0 > t1) return false
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return true
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Whether any segment of `edge` passes through any of the `boxes`. */
|
|
90
|
+
export function edgeCrossesTitle(
|
|
91
|
+
edge: PositionedEdge,
|
|
92
|
+
boxes: TitleTextBox[],
|
|
93
|
+
): boolean {
|
|
94
|
+
const pts = edge.points
|
|
95
|
+
for (let i = 0; i + 1 < pts.length; i++) {
|
|
96
|
+
if (boxes.some((box) => segmentHitsBox(pts[i]!, pts[i + 1]!, box))) {
|
|
97
|
+
return true
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return false
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Small stable hash (FNV-1a) for a mask id that is the same for the same boxes. */
|
|
104
|
+
function hashBoxes(boxes: TitleTextBox[]): string {
|
|
105
|
+
let h = 0x811c9dc5
|
|
106
|
+
for (const ch of boxes
|
|
107
|
+
.map((b) => `${b.x},${b.y},${b.width},${b.height}`)
|
|
108
|
+
.join(';')) {
|
|
109
|
+
h = Math.imul(h ^ ch.charCodeAt(0), 0x01000193)
|
|
110
|
+
}
|
|
111
|
+
return (h >>> 0).toString(36)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface TitleGapMask {
|
|
115
|
+
id: string
|
|
116
|
+
/** The `<mask>` element, for `<defs>`. */
|
|
117
|
+
markup: string
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The mask that hides edge strokes inside `boxes`. The id comes from the box
|
|
122
|
+
* positions, so two inline diagrams never share an id with different content.
|
|
123
|
+
*
|
|
124
|
+
* `maskUnits="userSpaceOnUse"` is required: the default bounding-box units
|
|
125
|
+
* give a straight vertical or horizontal edge a zero-size box, which masks the
|
|
126
|
+
* whole edge away.
|
|
127
|
+
*/
|
|
128
|
+
export function titleGapMask(
|
|
129
|
+
boxes: TitleTextBox[],
|
|
130
|
+
width: number,
|
|
131
|
+
height: number,
|
|
132
|
+
): TitleGapMask {
|
|
133
|
+
const id = `zm-title-gap-${hashBoxes(boxes)}`
|
|
134
|
+
const holes = boxes
|
|
135
|
+
.map(
|
|
136
|
+
(b) =>
|
|
137
|
+
` <rect x="${b.x}" y="${b.y}" width="${b.width}" height="${b.height}" fill="#000" />`,
|
|
138
|
+
)
|
|
139
|
+
.join('\n')
|
|
140
|
+
const markup =
|
|
141
|
+
` <mask id="${id}" maskUnits="userSpaceOnUse" x="0" y="0" width="${width}" height="${height}">\n` +
|
|
142
|
+
` <rect x="0" y="0" width="${width}" height="${height}" fill="#fff" />\n` +
|
|
143
|
+
`${holes}\n` +
|
|
144
|
+
` </mask>`
|
|
145
|
+
return { id, markup }
|
|
146
|
+
}
|
package/src/xychart/layout.ts
CHANGED
|
@@ -489,16 +489,45 @@ function getCategoryLabels(chart: XYChart, count: number): string[] {
|
|
|
489
489
|
return Array.from({ length: count }, (_, i) => String(i + 1))
|
|
490
490
|
}
|
|
491
491
|
|
|
492
|
+
/** Minimum clear space between two neighbouring x-axis labels. */
|
|
493
|
+
const X_LABEL_MIN_GAP = 8
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Step between shown x-axis labels: 1 when every pair of neighbouring labels
|
|
497
|
+
* keeps `X_LABEL_MIN_GAP` of clear space, otherwise the smallest n for which
|
|
498
|
+
* every pair of shown (every n-th) labels does. Checked pairwise on the real
|
|
499
|
+
* widths so one long label doesn't thin out an otherwise roomy axis.
|
|
500
|
+
*/
|
|
501
|
+
export function xLabelStep(labels: string[], bandWidth: number): number {
|
|
502
|
+
const widths = labels.map((l) =>
|
|
503
|
+
estimateTextWidth(l, XY.axisLabelFontSize, XY.axisLabelFontWeight),
|
|
504
|
+
)
|
|
505
|
+
for (let step = 1; step < labels.length; step++) {
|
|
506
|
+
let fits = true
|
|
507
|
+
for (let i = 0; i + step < labels.length; i += step) {
|
|
508
|
+
const gap = step * bandWidth - (widths[i]! + widths[i + step]!) / 2
|
|
509
|
+
if (gap < X_LABEL_MIN_GAP) {
|
|
510
|
+
fits = false
|
|
511
|
+
break
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
if (fits) return step
|
|
515
|
+
}
|
|
516
|
+
return Math.max(1, labels.length)
|
|
517
|
+
}
|
|
518
|
+
|
|
492
519
|
function buildXTicks(
|
|
493
520
|
chart: XYChart,
|
|
494
521
|
xScale: (i: number) => number,
|
|
495
522
|
axisY: number,
|
|
496
|
-
|
|
523
|
+
bandWidth: number,
|
|
497
524
|
): AxisTick[] {
|
|
498
525
|
const count = getDataCount(chart)
|
|
499
526
|
const labels = getCategoryLabels(chart, count)
|
|
527
|
+
const step = xLabelStep(labels, bandWidth)
|
|
528
|
+
// Dropped labels keep their tick mark but render no text.
|
|
500
529
|
return labels.map((label, i) => ({
|
|
501
|
-
label,
|
|
530
|
+
label: i % step === 0 ? label : '',
|
|
502
531
|
x: xScale(i),
|
|
503
532
|
y: axisY,
|
|
504
533
|
tx: xScale(i),
|