@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
@@ -14,6 +14,7 @@ import type {
14
14
  ClassMember,
15
15
  PositionedClassDiagram,
16
16
  PositionedClassNode,
17
+ PositionedClassNamespace,
17
18
  PositionedClassNote,
18
19
  PositionedClassRelationship,
19
20
  } from '@zombie-mermaid/mermaid-parser'
@@ -36,6 +37,7 @@ import {
36
37
  buildElkEdge,
37
38
  buildElkLeafNode,
38
39
  directionToElk,
40
+ elkPadding,
39
41
  } from '../layout-engine/elk-graph-builder.ts'
40
42
  import { measureMultilineText, resolveNodeStyle } from '@zombie-mermaid/core'
41
43
 
@@ -56,6 +58,11 @@ export const CLS = {
56
58
  /** Horizontal / vertical padding inside a note box, around its text */
57
59
  notePadX: 10,
58
60
  notePadY: 6,
61
+ /** Space reserved above a namespace's classes for its title */
62
+ namespaceTitleHeight: 28,
63
+ /** Padding between a namespace frame and its classes (other three sides) */
64
+ namespacePad: 16,
65
+ namespaceTitleFontSize: 12,
59
66
  } as const
60
67
 
61
68
  /**
@@ -83,6 +90,41 @@ type ClassSizeMap = Map<
83
90
  }
84
91
  >
85
92
 
93
+ /** Layout id for the i-th namespace; contains a space, so it can't match a class id. */
94
+ function classNamespaceId(index: number): string {
95
+ return `namespace ${index}`
96
+ }
97
+
98
+ /** A namespace that has at least one member class, ready for layout. */
99
+ interface LayoutNamespace {
100
+ id: string
101
+ name: string
102
+ classIds: string[]
103
+ }
104
+
105
+ /**
106
+ * Namespaces to draw: members that aren't classes of the diagram are dropped,
107
+ * a class claimed by two namespaces stays in the first (ELK nodes have one
108
+ * parent), and a namespace left with no members is skipped.
109
+ */
110
+ function resolveNamespaces(diagram: ClassDiagram): LayoutNamespace[] {
111
+ const known = new Set(diagram.classes.map((c) => c.id))
112
+ const claimed = new Set<string>()
113
+ const out: LayoutNamespace[] = []
114
+ for (const [i, ns] of diagram.namespaces.entries()) {
115
+ const classIds: string[] = []
116
+ for (const id of ns.classIds) {
117
+ if (!known.has(id) || claimed.has(id)) continue
118
+ claimed.add(id)
119
+ classIds.push(id)
120
+ }
121
+ if (classIds.length > 0) {
122
+ out.push({ id: classNamespaceId(i), name: ns.name, classIds })
123
+ }
124
+ }
125
+ return out
126
+ }
127
+
86
128
  /** Size of each note box, keyed by its layout id. */
87
129
  type NoteSizeMap = Map<string, { width: number; height: number }>
88
130
 
@@ -90,7 +132,12 @@ type NoteSizeMap = Map<string, { width: number; height: number }>
90
132
  function buildClassElkGraph(
91
133
  diagram: ClassDiagram,
92
134
  options: ClassRenderOptions,
93
- ): { elkGraph: ElkNode; classSizes: ClassSizeMap; noteSizes: NoteSizeMap } {
135
+ ): {
136
+ elkGraph: ElkNode
137
+ classSizes: ClassSizeMap
138
+ noteSizes: NoteSizeMap
139
+ namespaces: LayoutNamespace[]
140
+ } {
94
141
  const classSizes: ClassSizeMap = new Map()
95
142
  const noteSizes: NoteSizeMap = new Map()
96
143
  const fontSizes = resolveFontSizes(options.fontSizes)
@@ -137,9 +184,44 @@ function buildClassElkGraph(
137
184
  // Iterate classSizes directly (populated above, in diagram.classes order)
138
185
  // rather than looking each class back up by id — sidesteps needing an
139
186
  // assertion or invariant check for a lookup that can't actually miss.
187
+ const namespaces = resolveNamespaces(diagram)
188
+ const namespaceOf = new Map<string, string>()
189
+ for (const ns of namespaces) {
190
+ for (const id of ns.classIds) namespaceOf.set(id, ns.id)
191
+ }
192
+ const namespaceNodes = new Map<string, ElkNode>()
140
193
  const children: ElkNode[] = []
194
+ for (const ns of namespaces) {
195
+ // Wide enough for the title; ELK grows it to fit the classes inside.
196
+ const titleW =
197
+ estimateTextWidth(
198
+ ns.name,
199
+ CLS.namespaceTitleFontSize,
200
+ FONT_WEIGHTS.groupHeader,
201
+ ) +
202
+ CLS.namespacePad * 2
203
+ const node: ElkNode = {
204
+ id: ns.id,
205
+ children: [],
206
+ layoutOptions: {
207
+ 'elk.padding': elkPadding({
208
+ top: CLS.namespaceTitleHeight + CLS.namespacePad / 2,
209
+ left: CLS.namespacePad,
210
+ bottom: CLS.namespacePad,
211
+ right: CLS.namespacePad,
212
+ }),
213
+ 'elk.nodeSize.constraints': 'MINIMUM_SIZE',
214
+ 'elk.nodeSize.minimum': `(${Math.ceil(titleW)}, 0)`,
215
+ },
216
+ }
217
+ namespaceNodes.set(ns.id, node)
218
+ children.push(node)
219
+ }
141
220
  for (const [id, size] of classSizes) {
142
- children.push(buildElkLeafNode(id, size))
221
+ const leaf = buildElkLeafNode(id, size)
222
+ const parent = namespaceNodes.get(namespaceOf.get(id) ?? '')
223
+ if (parent?.children) parent.children.push(leaf)
224
+ else children.push(leaf)
143
225
  }
144
226
 
145
227
  // Class edge labels carry no per-label layout options — placement is set
@@ -203,12 +285,21 @@ function buildClassElkGraph(
203
285
  padding: CLS.padding,
204
286
  }),
205
287
  'elk.edgeLabels.placement': 'CENTER',
288
+ // Relationships are declared at the root and may cross namespace
289
+ // frames; INCLUDE_CHILDREN routes them in one pass through the nesting.
290
+ 'elk.hierarchyHandling': 'INCLUDE_CHILDREN',
291
+ // Mermaid lays disconnected components out left to right in
292
+ // declaration order (#1249). ELK's default packs components by size
293
+ // into rows, which reorders them; laying them out as one graph with
294
+ // model order as the tie-break keeps source order.
295
+ 'elk.separateConnectedComponents': 'false',
296
+ 'elk.layered.considerModelOrder.strategy': 'NODES_AND_EDGES',
206
297
  },
207
298
  children,
208
299
  edges,
209
300
  }
210
301
 
211
- return { elkGraph, classSizes, noteSizes }
302
+ return { elkGraph, classSizes, noteSizes, namespaces }
212
303
  }
213
304
 
214
305
  /** Extract positioned classes, relationships, and notes from ELK result. */
@@ -217,12 +308,56 @@ function extractClassLayout(
217
308
  diagram: ClassDiagram,
218
309
  classSizes: ClassSizeMap,
219
310
  noteSizes: NoteSizeMap,
311
+ namespaces: LayoutNamespace[],
220
312
  ): PositionedClassDiagram {
221
313
  const classLookup = new Map<string, ClassNode>()
222
314
  for (const cls of diagram.classes) classLookup.set(cls.id, cls)
223
315
 
224
- const positionedClasses: PositionedClassNode[] = []
316
+ // ELK reports a nested node relative to its parent. Namespaces are one
317
+ // level deep, so flatten each member into diagram coordinates.
318
+ const namespaceIds = new Set(namespaces.map((ns) => ns.id))
319
+ const namespaceOffsets = new Map<string, { x: number; y: number }>()
320
+ const flatChildren: ElkNode[] = []
225
321
  for (const child of result.children ?? []) {
322
+ if (namespaceIds.has(child.id)) {
323
+ const ox = child.x ?? 0
324
+ const oy = child.y ?? 0
325
+ namespaceOffsets.set(child.id, { x: ox, y: oy })
326
+ for (const inner of child.children ?? []) {
327
+ flatChildren.push({
328
+ ...inner,
329
+ x: (inner.x ?? 0) + ox,
330
+ y: (inner.y ?? 0) + oy,
331
+ })
332
+ }
333
+ } else {
334
+ flatChildren.push(child)
335
+ }
336
+ }
337
+ // Under INCLUDE_CHILDREN an edge's points are relative to its container
338
+ // (the lowest common ancestor of its endpoints): a namespace for an edge
339
+ // inside one, the root otherwise.
340
+ const edgeOffset = (edge: ElkExtendedEdge): { x: number; y: number } => {
341
+ const container = (edge as { container?: string }).container
342
+ return (container && namespaceOffsets.get(container)) || { x: 0, y: 0 }
343
+ }
344
+
345
+ const positionedNamespaces: PositionedClassNamespace[] = []
346
+ for (const ns of namespaces) {
347
+ const node = (result.children ?? []).find((c) => c.id === ns.id)
348
+ if (!node) continue
349
+ positionedNamespaces.push({
350
+ name: ns.name,
351
+ classIds: ns.classIds,
352
+ x: node.x ?? 0,
353
+ y: node.y ?? 0,
354
+ width: node.width ?? 0,
355
+ height: node.height ?? 0,
356
+ })
357
+ }
358
+
359
+ const positionedClasses: PositionedClassNode[] = []
360
+ for (const child of flatChildren) {
226
361
  const cls = classLookup.get(child.id)
227
362
  if (cls) {
228
363
  const size = classSizes.get(cls.id)
@@ -269,8 +404,9 @@ function extractClassLayout(
269
404
  throw new Error(`Missing ELK edge for relationship ${i}`)
270
405
  }
271
406
 
272
- const points = extractEdgePoints(elkEdge)
273
- const labelPosition = extractEdgeLabelPosition(elkEdge)
407
+ const off = edgeOffset(elkEdge)
408
+ const points = extractEdgePoints(elkEdge, off.x, off.y)
409
+ const labelPosition = extractEdgeLabelPosition(elkEdge, off.x, off.y)
274
410
 
275
411
  relationships.push({
276
412
  from: rel.from,
@@ -292,7 +428,7 @@ function extractClassLayout(
292
428
  linkEdges.set(elkEdge.id, elkEdge)
293
429
  }
294
430
  const childById = new Map<string, ElkNode>()
295
- for (const child of result.children ?? []) childById.set(child.id, child)
431
+ for (const child of flatChildren) childById.set(child.id, child)
296
432
 
297
433
  const notes: PositionedClassNote[] = []
298
434
  for (const [i, note] of diagram.notes.entries()) {
@@ -315,7 +451,15 @@ function extractClassLayout(
315
451
  y: child.y ?? 0,
316
452
  width: child.width ?? size.width,
317
453
  height: child.height ?? size.height,
318
- ...(link ? { linkPoints: extractEdgePoints(link) } : {}),
454
+ ...(link
455
+ ? {
456
+ linkPoints: extractEdgePoints(
457
+ link,
458
+ edgeOffset(link).x,
459
+ edgeOffset(link).y,
460
+ ),
461
+ }
462
+ : {}),
319
463
  })
320
464
  }
321
465
 
@@ -325,6 +469,7 @@ function extractClassLayout(
325
469
  classes: positionedClasses,
326
470
  relationships,
327
471
  notes,
472
+ namespaces: positionedNamespaces,
328
473
  }
329
474
  }
330
475
 
@@ -336,15 +481,36 @@ export function layoutClassDiagramSync(
336
481
  options: ClassRenderOptions = {},
337
482
  ): PositionedClassDiagram {
338
483
  if (diagram.classes.length === 0 && diagram.notes.length === 0) {
339
- return { width: 0, height: 0, classes: [], relationships: [], notes: [] }
484
+ return {
485
+ width: 0,
486
+ height: 0,
487
+ classes: [],
488
+ relationships: [],
489
+ notes: [],
490
+ namespaces: [],
491
+ }
340
492
  }
341
493
 
342
- const { elkGraph, classSizes, noteSizes } = buildClassElkGraph(
494
+ const { elkGraph, classSizes, noteSizes, namespaces } = buildClassElkGraph(
343
495
  diagram,
344
496
  options,
345
497
  )
346
498
  const result = elkLayoutSync(elkGraph, options.layoutCache)
347
- return extractClassLayout(result, diagram, classSizes, noteSizes)
499
+ return extractClassLayout(result, diagram, classSizes, noteSizes, namespaces)
500
+ }
501
+
502
+ /**
503
+ * Width of a member line sized for the worst-case glyph advance.
504
+ *
505
+ * The mono face advances 0.6em per glyph (6.6px at the 11px member size), but
506
+ * browsers that don't position glyphs at subpixel offsets (headless Chromium
507
+ * on Linux, notably) round each advance to a whole pixel, so a long member
508
+ * draws up to 0.4px per character wider than `estimateMonoTextWidth` says and
509
+ * ran into the box's right border (#1238). Rounding the advance up here keeps
510
+ * the full `CLS.boxPadX` of right padding whichever way the font is rasterized.
511
+ */
512
+ function memberTextWidth(text: string): number {
513
+ return text.length * Math.ceil(estimateMonoTextWidth('M', CLS.memberFontSize))
348
514
  }
349
515
 
350
516
  /** Calculate the max width of a list of class members (uses mono metrics) */
@@ -352,8 +518,7 @@ function maxMemberWidth(members: ClassMember[]): number {
352
518
  if (members.length === 0) return 0
353
519
  let maxW = 0
354
520
  for (const m of members) {
355
- const text = formatClassMember(m)
356
- const w = estimateMonoTextWidth(text, CLS.memberFontSize)
521
+ const w = memberTextWidth(formatClassMember(m))
357
522
  if (w > maxW) maxW = w
358
523
  }
359
524
  return maxW
@@ -1,5 +1,6 @@
1
1
  import type {
2
2
  PositionedClassDiagram,
3
+ PositionedClassNamespace,
3
4
  PositionedClassNode,
4
5
  PositionedClassNote,
5
6
  PositionedClassRelationship,
@@ -35,6 +36,7 @@ import { CLS } from './layout.ts'
35
36
  // All colors use CSS custom properties (var(--_xxx)) from the theme system.
36
37
  //
37
38
  // Render order:
39
+ // 0. Namespace frames (behind everything)
38
40
  // 1. Relationship lines and note links (behind boxes)
39
41
  // 2. Class boxes (header + attributes + methods compartments)
40
42
  // 3. Notes (dog-eared boxes)
@@ -113,6 +115,11 @@ export function renderClassSvg(
113
115
  parts.push(relationshipMarkerDefs())
114
116
  parts.push('</defs>')
115
117
 
118
+ // 0. Namespace frames (behind everything else)
119
+ for (const ns of diagram.namespaces) {
120
+ parts.push(renderNamespace(ns))
121
+ }
122
+
116
123
  // 1. Relationship lines and note links (rendered behind boxes)
117
124
  for (const rel of diagram.relationships) {
118
125
  parts.push(renderRelationship(rel))
@@ -140,6 +147,24 @@ export function renderClassSvg(
140
147
  return parts.join('\n')
141
148
  }
142
149
 
150
+ // ============================================================================
151
+ // Namespace frames
152
+ // ============================================================================
153
+
154
+ /** A `namespace` block: a framed box with its name in a header band. */
155
+ function renderNamespace(ns: PositionedClassNamespace): string {
156
+ const { x, y, width, height } = ns
157
+ const bandH = CLS.namespaceTitleHeight
158
+ const sw = STROKE_WIDTHS.outerBox
159
+ return [
160
+ f`<g class="class-namespace" data-id="${escapeAttr(ns.name)}" data-label="${escapeAttr(ns.name)}">`,
161
+ f` <rect x="${x}" y="${y}" width="${width}" height="${height}" rx="0" ry="0" fill="var(--_group-fill)" stroke="var(--_node-stroke)" stroke-width="${sw}" />`,
162
+ f` <rect x="${x}" y="${y}" width="${width}" height="${bandH}" rx="0" ry="0" fill="var(--_group-hdr)" stroke="var(--_node-stroke)" stroke-width="${sw}" />`,
163
+ f` <text x="${x + CLS.namespacePad}" y="${y + bandH / 2}" dy="${TEXT_BASELINE_SHIFT}" font-size="${CLS.namespaceTitleFontSize}" font-weight="${FONT_WEIGHTS.groupHeader}" fill="var(--_text-sec)">${escapeXml(ns.name)}</text>`,
164
+ '</g>',
165
+ ].join('\n')
166
+ }
167
+
143
168
  // ============================================================================
144
169
  // Marker definitions
145
170
  // ============================================================================
@@ -336,10 +361,14 @@ function renderClassBox(
336
361
  return parts.join('\n')
337
362
  }
338
363
 
364
+ /** Heavier weight for the visibility marker so `~` stays distinct from `-` (#1237) */
365
+ const VISIBILITY_MARKER_WEIGHT = 700
366
+
339
367
  /**
340
368
  * Render a single class member with syntax highlighting.
341
369
  * Uses <tspan> elements to color each part of the member differently:
342
- * - visibility symbol (+/-/#/~) → textFaint
370
+ * - visibility symbol (+/-/#/~) → textSecondary, bold (faint at weight 400
371
+ * made `~` read like `-` at 1x, #1237)
343
372
  * - member name (incl. parens for methods) → textSecondary
344
373
  * - colon separator → textFaint
345
374
  * - type annotation → textMuted
@@ -368,7 +397,7 @@ function renderMember(
368
397
 
369
398
  if (member.visibility) {
370
399
  spans.push(
371
- f`<tspan fill="${faint}">${escapeXml(member.visibility)} </tspan>`,
400
+ f`<tspan fill="${secondary}" font-weight="${VISIBILITY_MARKER_WEIGHT}">${escapeXml(member.visibility)}</tspan><tspan fill="${faint}"> </tspan>`,
372
401
  )
373
402
  }
374
403
 
package/src/er/layout.ts CHANGED
@@ -47,6 +47,19 @@ const ER = {
47
47
  type EntitySizeMap = Map<string, { width: number; height: number }>
48
48
 
49
49
  /** Build ELK graph and size map from an ER diagram. */
50
+ /**
51
+ * Width of an attribute row sized for the worst-case glyph advance.
52
+ *
53
+ * The mono face advances 0.6em per glyph (6.6px at the 11px attribute size),
54
+ * but browsers that don't position glyphs at subpixel offsets (headless
55
+ * Chromium on Linux) round each advance to a whole pixel, so a long row draws
56
+ * wider than `estimateMonoTextWidth` says and the type runs into the
57
+ * right-aligned name (#1262, same cause as class members in #1238).
58
+ */
59
+ function attrTextWidth(text: string): number {
60
+ return text.length * Math.ceil(estimateMonoTextWidth('M', ER.attrFontSize))
61
+ }
62
+
50
63
  function buildErElkGraph(
51
64
  diagram: ErDiagram,
52
65
  options: ErRenderOptions,
@@ -63,7 +76,7 @@ function buildErElkGraph(
63
76
  let maxAttrW = 0
64
77
  for (const attr of entity.attributes) {
65
78
  const attrText = `${attr.type} ${attr.name}${attr.keys.length > 0 ? ' ' + attr.keys.join(',') : ''}`
66
- const w = estimateMonoTextWidth(attrText, ER.attrFontSize)
79
+ const w = attrTextWidth(attrText)
67
80
  if (w > maxAttrW) maxAttrW = w
68
81
  }
69
82
  const width = Math.max(
@@ -105,15 +118,18 @@ function buildErElkGraph(
105
118
  id: 'root',
106
119
  layoutOptions: {
107
120
  ...baseElkLayoutOptions({
108
- // A source with no `direction` statement lays out left-to-right —
109
- // ER's own default, unlike flowchart/class's DOWN. See
110
- // ELK_DIRECTION_FALLBACK.
121
+ // A source with no `direction` statement lays out top-to-bottom,
122
+ // as official Mermaid does (#1250). See ELK_DIRECTION_FALLBACK.
111
123
  direction: directionToElk(diagram.direction, ELK_DIRECTION_FALLBACK.er),
112
124
  nodeSpacing: ER.nodeSpacing,
113
125
  layerSpacing: ER.layerSpacing,
114
126
  padding: ER.padding,
115
127
  }),
116
128
  'elk.edgeLabels.placement': 'CENTER',
129
+ // Keep declaration order as the crossing-minimization tie-break and
130
+ // center children under their parents, as official Mermaid does.
131
+ 'elk.layered.considerModelOrder.strategy': 'NODES_AND_EDGES',
132
+ 'elk.layered.nodePlacement.bk.fixedAlignment': 'BALANCED',
117
133
  },
118
134
  children,
119
135
  edges,
@@ -38,6 +38,15 @@ import type { FontSizes } from '../styles.ts'
38
38
  // 4. Relationship labels
39
39
  // ============================================================================
40
40
 
41
+ /**
42
+ * Relationship-label text: `--fg` mixed into `--bg` at this percentage.
43
+ * The shared `--_text-muted` (40%) is ~2.4:1 on the default white theme and
44
+ * `--_text-sec` (60%) is ~4.1:1, both under the WCAG AA 4.5:1 text minimum;
45
+ * 70% is ~5.6:1 while still reading as secondary to the entity names (#1244).
46
+ * Local to ER so other diagram types keep their tuned text hierarchy.
47
+ */
48
+ const ER_EDGE_LABEL_FG_PERCENT = 70
49
+
41
50
  /** Font sizes specific to ER diagrams */
42
51
  const ER_FONT = {
43
52
  attrSize: 11,
@@ -320,7 +329,7 @@ function renderRelationshipLabel(
320
329
  mid.x,
321
330
  mid.y,
322
331
  fontSizes.edgeLabel,
323
- f`text-anchor="middle" font-size="${fontSizes.edgeLabel}" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_text-muted)"`,
332
+ f`text-anchor="middle" font-size="${fontSizes.edgeLabel}" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="color-mix(in srgb, var(--fg) ${ER_EDGE_LABEL_FG_PERCENT}%, var(--bg))"`,
324
333
  )}`
325
334
  )
326
335
  }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Which edges of a flowchart are "back edges", found the way mermaid.js finds
3
+ * them.
4
+ *
5
+ * mermaid lays a flowchart out with dagre, which makes the graph acyclic by a
6
+ * depth-first walk: starting from each node in order and following out-edges
7
+ * in source order, an edge that points at a node still on the walk's stack is
8
+ * reversed. Which edge of a cycle gets reversed decides the whole layout. In
9
+ * the CI/CD sample `D -.-> A` closes the cycle `A -> B -> D -> A`, and walking
10
+ * `A -> B -> C -> E -> F` reaches `D` through `F --> D` first, so `D -.-> A` is
11
+ * the back edge and `D` ("Fix & Retry") sits after `F`.
12
+ *
13
+ * ELK's own cycle breaking picks a different edge (`F --> D`), puts `D` early,
14
+ * and draws the long way round. It also treats a subgraph's contents
15
+ * separately from the rest of the graph, so no ELK setting reproduces the
16
+ * dagre result (#1239). Handing ELK these edges already reversed means it never
17
+ * has a cycle to break.
18
+ */
19
+
20
+ import type { MermaidGraph } from '@zombie-mermaid/core'
21
+
22
+ /**
23
+ * Indices into `graph.edges` of the edges a depth-first walk reverses.
24
+ * Self-loops are never back edges here; layout handles them on their own.
25
+ *
26
+ * `walkOrder` is the order the walk starts from; it defaults to the graph's
27
+ * node order, which is mermaid's (first appearance in the source).
28
+ */
29
+ export function findBackEdgeIndexes(
30
+ graph: MermaidGraph,
31
+ walkOrder?: Iterable<string>,
32
+ ): Set<number> {
33
+ const outEdges = new Map<string, number[]>()
34
+ graph.edges.forEach((edge, index) => {
35
+ if (edge.source === edge.target) return
36
+ const list = outEdges.get(edge.source)
37
+ if (list) list.push(index)
38
+ else outEdges.set(edge.source, [index])
39
+ })
40
+
41
+ const back = new Set<number>()
42
+ const visited = new Set<string>()
43
+ const onStack = new Set<string>()
44
+
45
+ // Iterative, so a long chain can't overflow the call stack.
46
+ for (const root of walkOrder ?? graph.nodes.keys()) {
47
+ if (visited.has(root)) continue
48
+ const stack: Array<{ node: string; next: number }> = [
49
+ { node: root, next: 0 },
50
+ ]
51
+ visited.add(root)
52
+ onStack.add(root)
53
+ while (stack.length > 0) {
54
+ const frame = stack[stack.length - 1]!
55
+ const edges = outEdges.get(frame.node) ?? []
56
+ if (frame.next >= edges.length) {
57
+ onStack.delete(frame.node)
58
+ stack.pop()
59
+ continue
60
+ }
61
+ const index = edges[frame.next++]!
62
+ const target = graph.edges[index]!.target
63
+ if (onStack.has(target)) {
64
+ back.add(index)
65
+ } else if (!visited.has(target)) {
66
+ visited.add(target)
67
+ onStack.add(target)
68
+ stack.push({ node: target, next: 0 })
69
+ }
70
+ }
71
+ }
72
+ return back
73
+ }