@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.
Files changed (45) hide show
  1. package/dist/index.cjs +45 -41
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +34 -4
  4. package/dist/index.d.ts +34 -4
  5. package/dist/index.js +2310 -1115
  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__/sequence-label-clearance-1242.test.ts +162 -0
  21. package/src/__tests__/sequence-nested-activation-1241.test.ts +37 -0
  22. package/src/__tests__/xychart-x-label-overlap-1243.test.ts +62 -0
  23. package/src/c4/arial-widths.ts +59 -0
  24. package/src/c4/layout.ts +234 -44
  25. package/src/c4/metrics.ts +56 -30
  26. package/src/c4/renderer.ts +1 -1
  27. package/src/class/layout.ts +178 -13
  28. package/src/class/renderer.ts +31 -2
  29. package/src/er/layout.ts +20 -4
  30. package/src/er/renderer.ts +10 -1
  31. package/src/layout-engine/back-edges.ts +73 -0
  32. package/src/layout-engine/compound-flat.ts +916 -0
  33. package/src/layout-engine/elk-graph-builder.ts +5 -4
  34. package/src/layout-engine/from-elk.ts +7 -2
  35. package/src/layout-engine/inner-edges.ts +329 -0
  36. package/src/layout-engine/layout-hints.ts +27 -0
  37. package/src/layout-engine/to-elk.ts +170 -20
  38. package/src/layout-engine.ts +38 -1
  39. package/src/registry.ts +2 -2
  40. package/src/renderer.ts +26 -2
  41. package/src/sequence/layout.ts +72 -4
  42. package/src/sequence/renderer.ts +155 -15
  43. package/src/title-gaps.ts +146 -0
  44. package/src/xychart/layout.ts +31 -2
  45. package/src/xychart/renderer.ts +1 -0
@@ -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: 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
@@ -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 + 14,
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
- * Render a lifeline (dashed vertical line from actor to bottom).
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(lifeline: Lifeline): string {
231
- const line =
232
- f`<line class="lifeline" data-actor="${escapeAttr(lifeline.actorId)}" ` +
233
- f`x1="${lifeline.x}" y1="${lifeline.topY}" x2="${lifeline.x}" y2="${lifeline.bottomY}" ` +
234
- `stroke="var(--_line)" stroke-width="0.75" stroke-dasharray="6 4" />`
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 = 30
291
- const loopH = 20
292
- const labelPadding = 8 // Space between loop and label
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 - 10,
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 = 18
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
+ }
@@ -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
- _bandWidth: number,
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),