@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/class/layout.ts
CHANGED
|
@@ -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
|
-
): {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
273
|
-
const
|
|
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
|
|
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
|
|
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 {
|
|
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
|
|
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
|
package/src/class/renderer.ts
CHANGED
|
@@ -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 (+/-/#/~) →
|
|
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="${
|
|
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 =
|
|
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
|
|
109
|
-
//
|
|
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,
|
package/src/er/renderer.ts
CHANGED
|
@@ -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(--
|
|
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
|
+
}
|