@zombie-mermaid/svg-renderer 4.2.3 → 5.0.1

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/README.md +15 -1
  2. package/dist/index.cjs +52 -52
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +23 -4
  5. package/dist/index.d.ts +23 -4
  6. package/dist/index.js +890 -850
  7. package/dist/index.js.map +1 -1
  8. package/package.json +14 -6
  9. package/src/c4/arial-widths.ts +0 -59
  10. package/src/c4/layout.ts +0 -680
  11. package/src/c4/metrics.ts +0 -261
  12. package/src/c4/renderer.ts +0 -366
  13. package/src/class/layout.ts +0 -525
  14. package/src/class/renderer.ts +0 -669
  15. package/src/edge-curves.ts +0 -205
  16. package/src/elk-instance.ts +0 -292
  17. package/src/er/layout.ts +0 -216
  18. package/src/er/renderer.ts +0 -516
  19. package/src/generated/mono-font-subset.ts +0 -32
  20. package/src/index.ts +0 -310
  21. package/src/layout-engine/back-edges.ts +0 -73
  22. package/src/layout-engine/compound-flat.ts +0 -916
  23. package/src/layout-engine/constants.ts +0 -19
  24. package/src/layout-engine/edge-bundling.ts +0 -458
  25. package/src/layout-engine/elk-adapter-utils.ts +0 -81
  26. package/src/layout-engine/elk-graph-builder.ts +0 -245
  27. package/src/layout-engine/from-elk.ts +0 -767
  28. package/src/layout-engine/inner-edges.ts +0 -329
  29. package/src/layout-engine/jog-straightening.ts +0 -52
  30. package/src/layout-engine/layer-alignment.ts +0 -174
  31. package/src/layout-engine/layout-hints.ts +0 -27
  32. package/src/layout-engine/to-elk.ts +0 -855
  33. package/src/layout-engine.ts +0 -115
  34. package/src/pie/layout.ts +0 -234
  35. package/src/pie/renderer.ts +0 -161
  36. package/src/registry.ts +0 -441
  37. package/src/renderer.ts +0 -1510
  38. package/src/resolve-colors.ts +0 -339
  39. package/src/sequence/layout.ts +0 -766
  40. package/src/sequence/renderer.ts +0 -687
  41. package/src/shape-clipping.ts +0 -197
  42. package/src/styles.ts +0 -145
  43. package/src/title-gaps.ts +0 -161
  44. package/src/xychart/layout.ts +0 -727
  45. package/src/xychart/renderer.ts +0 -676
@@ -1,525 +0,0 @@
1
- /**
2
- * Class diagram layout engine (ELK.js).
3
- *
4
- * Each class box has 3 compartments:
5
- * 1. Header (class name + optional annotation)
6
- * 2. Attributes section
7
- * 3. Methods section
8
- */
9
-
10
- import type { ElkNode, ElkExtendedEdge } from 'elkjs'
11
- import type {
12
- ClassDiagram,
13
- ClassNode,
14
- ClassMember,
15
- PositionedClassDiagram,
16
- PositionedClassNode,
17
- PositionedClassNamespace,
18
- PositionedClassNote,
19
- PositionedClassRelationship,
20
- } from '@zombie-mermaid/mermaid-parser'
21
- import { formatClassMember } from '@zombie-mermaid/mermaid-parser'
22
- import type { ClassRenderOptions } from '@zombie-mermaid/core'
23
- import {
24
- estimateTextWidth,
25
- estimateMonoTextWidth,
26
- FONT_WEIGHTS,
27
- resolveFontSizes,
28
- } from '../styles.ts'
29
- import { elkLayoutSync } from '../elk-instance.ts'
30
- import {
31
- extractEdgePoints,
32
- extractEdgeLabelPosition,
33
- } from '../layout-engine/elk-adapter-utils.ts'
34
- import {
35
- ELK_DIRECTION_FALLBACK,
36
- baseElkLayoutOptions,
37
- buildElkEdge,
38
- buildElkLeafNode,
39
- directionToElk,
40
- elkPadding,
41
- } from '../layout-engine/elk-graph-builder.ts'
42
- import { measureMultilineText, resolveNodeStyle } from '@zombie-mermaid/core'
43
-
44
- /** Layout constants for class diagrams */
45
- export const CLS = {
46
- padding: 40,
47
- boxPadX: 8,
48
- headerBaseHeight: 32,
49
- annotationHeight: 16,
50
- memberRowHeight: 20,
51
- sectionPadY: 8,
52
- emptySectionHeight: 8,
53
- minWidth: 120,
54
- memberFontSize: 11,
55
- memberFontWeight: 400,
56
- nodeSpacing: 40,
57
- layerSpacing: 60,
58
- /** Horizontal / vertical padding inside a note box, around its text */
59
- notePadX: 10,
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,
66
- } as const
67
-
68
- /**
69
- * Layout id for the i-th note. A class id is a run of non-whitespace
70
- * (`\S+` in the parser), so an id containing a space can never collide
71
- * with one; ELK treats ids as opaque strings.
72
- */
73
- export function classNoteId(index: number): string {
74
- return `note ${index}`
75
- }
76
-
77
- /** Layout id for the dotted link joining note `noteId` to its class. */
78
- function classNoteLinkId(noteId: string): string {
79
- return `${noteId} link`
80
- }
81
-
82
- type ClassSizeMap = Map<
83
- string,
84
- {
85
- width: number
86
- height: number
87
- headerHeight: number
88
- attrHeight: number
89
- methodHeight: number
90
- }
91
- >
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
-
128
- /** Size of each note box, keyed by its layout id. */
129
- type NoteSizeMap = Map<string, { width: number; height: number }>
130
-
131
- /** Build ELK graph and size map from a class diagram. */
132
- function buildClassElkGraph(
133
- diagram: ClassDiagram,
134
- options: ClassRenderOptions,
135
- ): {
136
- elkGraph: ElkNode
137
- classSizes: ClassSizeMap
138
- noteSizes: NoteSizeMap
139
- namespaces: LayoutNamespace[]
140
- } {
141
- const classSizes: ClassSizeMap = new Map()
142
- const noteSizes: NoteSizeMap = new Map()
143
- const fontSizes = resolveFontSizes(options.fontSizes)
144
-
145
- for (const cls of diagram.classes) {
146
- const headerHeight = cls.annotation
147
- ? CLS.headerBaseHeight + CLS.annotationHeight
148
- : CLS.headerBaseHeight
149
-
150
- const attrHeight =
151
- cls.attributes.length > 0
152
- ? cls.attributes.length * CLS.memberRowHeight + CLS.sectionPadY
153
- : CLS.emptySectionHeight
154
-
155
- const methodHeight =
156
- cls.methods.length > 0
157
- ? cls.methods.length * CLS.memberRowHeight + CLS.sectionPadY
158
- : CLS.emptySectionHeight
159
-
160
- const headerTextW = estimateTextWidth(
161
- cls.label,
162
- fontSizes.nodeLabel,
163
- FONT_WEIGHTS.nodeLabel,
164
- )
165
- const maxAttrW = maxMemberWidth(cls.attributes)
166
- const maxMethodW = maxMemberWidth(cls.methods)
167
- const width = Math.max(
168
- CLS.minWidth,
169
- headerTextW + CLS.boxPadX * 2,
170
- maxAttrW + CLS.boxPadX * 2,
171
- maxMethodW + CLS.boxPadX * 2,
172
- )
173
- const height = headerHeight + attrHeight + methodHeight
174
-
175
- classSizes.set(cls.id, {
176
- width,
177
- height,
178
- headerHeight,
179
- attrHeight,
180
- methodHeight,
181
- })
182
- }
183
-
184
- // Iterate classSizes directly (populated above, in diagram.classes order)
185
- // rather than looking each class back up by id — sidesteps needing an
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>()
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
- }
220
- for (const [id, size] of classSizes) {
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)
225
- }
226
-
227
- // Class edge labels carry no per-label layout options — placement is set
228
- // once on the root graph below (`elk.edgeLabels.placement: CENTER`).
229
- const labelStyle = { fontSize: fontSizes.edgeLabel }
230
-
231
- const edges: ElkExtendedEdge[] = []
232
- for (const [i, rel] of diagram.relationships.entries()) {
233
- edges.push(
234
- buildElkEdge({
235
- id: `e${i}`,
236
- source: rel.from,
237
- target: rel.to,
238
- label: rel.label,
239
- labelStyle,
240
- }),
241
- )
242
- }
243
-
244
- // Notes. Mirrors Mermaid's classDb.getData(): every note is a node of its
245
- // own, and `note for X` adds an arrowless dotted edge note→class so the
246
- // layout keeps the two adjacent (with a DOWN layout the note lands above
247
- // its class, as it does in Mermaid's TB rendering). The link edges go
248
- // after the relationship edges so relationship indices stay positional.
249
- const classIds = new Set(classSizes.keys())
250
- for (const [i, note] of diagram.notes.entries()) {
251
- const id = classNoteId(i)
252
- const metrics = measureMultilineText(
253
- note.text,
254
- fontSizes.edgeLabel,
255
- FONT_WEIGHTS.edgeLabel,
256
- )
257
- const size = {
258
- width: metrics.width + CLS.notePadX * 2,
259
- height: metrics.height + CLS.notePadY * 2,
260
- }
261
- noteSizes.set(id, size)
262
- children.push(buildElkLeafNode(id, size))
263
- if (note.forClass !== undefined && classIds.has(note.forClass)) {
264
- edges.push(
265
- buildElkEdge({
266
- id: classNoteLinkId(id),
267
- source: id,
268
- target: note.forClass,
269
- labelStyle,
270
- }),
271
- )
272
- }
273
- }
274
-
275
- const elkGraph: ElkNode = {
276
- id: 'root',
277
- layoutOptions: {
278
- ...baseElkLayoutOptions({
279
- // Class diagrams have no `direction` concept — they always lay out
280
- // top-down. See ELK_DIRECTION_FALLBACK for why that default is
281
- // per-diagram-type rather than shared with ER's.
282
- direction: directionToElk(undefined, ELK_DIRECTION_FALLBACK.class),
283
- nodeSpacing: CLS.nodeSpacing,
284
- layerSpacing: CLS.layerSpacing,
285
- padding: CLS.padding,
286
- }),
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',
297
- },
298
- children,
299
- edges,
300
- }
301
-
302
- return { elkGraph, classSizes, noteSizes, namespaces }
303
- }
304
-
305
- /** Extract positioned classes, relationships, and notes from ELK result. */
306
- function extractClassLayout(
307
- result: ElkNode,
308
- diagram: ClassDiagram,
309
- classSizes: ClassSizeMap,
310
- noteSizes: NoteSizeMap,
311
- namespaces: LayoutNamespace[],
312
- ): PositionedClassDiagram {
313
- const classLookup = new Map<string, ClassNode>()
314
- for (const cls of diagram.classes) classLookup.set(cls.id, cls)
315
-
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[] = []
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) {
361
- const cls = classLookup.get(child.id)
362
- if (cls) {
363
- const size = classSizes.get(cls.id)
364
- if (!size) {
365
- // Unreachable — classSizes is populated for every diagram.classes
366
- // entry, and classLookup/cls.id come from that same list.
367
- /* v8 ignore next */
368
- throw new Error(`Missing computed size for class "${cls.id}"`)
369
- }
370
- positionedClasses.push({
371
- id: cls.id,
372
- label: cls.label,
373
- annotation: cls.annotation,
374
- attributes: cls.attributes,
375
- methods: cls.methods,
376
- x: child.x ?? 0,
377
- y: child.y ?? 0,
378
- width: child.width ?? size.width,
379
- height: child.height ?? size.height,
380
- headerHeight: size.headerHeight,
381
- attrHeight: size.attrHeight,
382
- methodHeight: size.methodHeight,
383
- interaction: diagram.interactions.get(cls.id),
384
- // Same cascade the flowchart layout applies (src/layout-engine/
385
- // from-elk.ts): classDef default → assigned class → `style`.
386
- inlineStyle: resolveNodeStyle(cls.id, diagram),
387
- // Kept separately from inlineStyle so the class name still reaches
388
- // the SVG `class` attribute when it has no matching classDef.
389
- className: diagram.classAssignments.get(cls.id),
390
- })
391
- }
392
- }
393
-
394
- const relationships: PositionedClassRelationship[] = []
395
- const elkEdges = result.edges ?? []
396
- // The first diagram.relationships.length edges are the relationships, in
397
- // order — buildClassElkGraph creates exactly one ELK edge per relationship
398
- // before appending any note links.
399
- for (const [i, rel] of diagram.relationships.entries()) {
400
- const elkEdge = elkEdges[i]
401
- if (!elkEdge) {
402
- // Unreachable — ELK returns every edge it was given.
403
- /* v8 ignore next */
404
- throw new Error(`Missing ELK edge for relationship ${i}`)
405
- }
406
-
407
- const off = edgeOffset(elkEdge)
408
- const points = extractEdgePoints(elkEdge, off.x, off.y)
409
- const labelPosition = extractEdgeLabelPosition(elkEdge, off.x, off.y)
410
-
411
- relationships.push({
412
- from: rel.from,
413
- to: rel.to,
414
- type: rel.type,
415
- markerAt: rel.markerAt,
416
- label: rel.label,
417
- fromCardinality: rel.fromCardinality,
418
- toCardinality: rel.toCardinality,
419
- points,
420
- labelPosition,
421
- })
422
- }
423
-
424
- // Note links are matched by id rather than position — only notes whose
425
- // class exists got an edge, so their count isn't derivable from the note list.
426
- const linkEdges = new Map<string, ElkExtendedEdge>()
427
- for (const elkEdge of elkEdges.slice(diagram.relationships.length)) {
428
- linkEdges.set(elkEdge.id, elkEdge)
429
- }
430
- const childById = new Map<string, ElkNode>()
431
- for (const child of flatChildren) childById.set(child.id, child)
432
-
433
- const notes: PositionedClassNote[] = []
434
- for (const [i, note] of diagram.notes.entries()) {
435
- const id = classNoteId(i)
436
- const child = childById.get(id)
437
- const size = noteSizes.get(id)
438
- if (!child || !size) {
439
- // Unreachable — a child and a size are recorded for every note.
440
- /* v8 ignore next */
441
- throw new Error(`Missing layout for note ${i}`)
442
- }
443
- const link = linkEdges.get(classNoteLinkId(id))
444
- notes.push({
445
- id,
446
- text: note.text,
447
- ...(link && note.forClass !== undefined
448
- ? { forClass: note.forClass }
449
- : {}),
450
- x: child.x ?? 0,
451
- y: child.y ?? 0,
452
- width: child.width ?? size.width,
453
- height: child.height ?? size.height,
454
- ...(link
455
- ? {
456
- linkPoints: extractEdgePoints(
457
- link,
458
- edgeOffset(link).x,
459
- edgeOffset(link).y,
460
- ),
461
- }
462
- : {}),
463
- })
464
- }
465
-
466
- return {
467
- width: result.width ?? 600,
468
- height: result.height ?? 400,
469
- classes: positionedClasses,
470
- relationships,
471
- notes,
472
- namespaces: positionedNamespaces,
473
- }
474
- }
475
-
476
- /**
477
- * Lay out a parsed class diagram using ELK.js (synchronous).
478
- */
479
- export function layoutClassDiagramSync(
480
- diagram: ClassDiagram,
481
- options: ClassRenderOptions = {},
482
- ): PositionedClassDiagram {
483
- if (diagram.classes.length === 0 && diagram.notes.length === 0) {
484
- return {
485
- width: 0,
486
- height: 0,
487
- classes: [],
488
- relationships: [],
489
- notes: [],
490
- namespaces: [],
491
- }
492
- }
493
-
494
- const { elkGraph, classSizes, noteSizes, namespaces } = buildClassElkGraph(
495
- diagram,
496
- options,
497
- )
498
- const result = elkLayoutSync(elkGraph, options.layoutCache)
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))
514
- }
515
-
516
- /** Calculate the max width of a list of class members (uses mono metrics) */
517
- function maxMemberWidth(members: ClassMember[]): number {
518
- if (members.length === 0) return 0
519
- let maxW = 0
520
- for (const m of members) {
521
- const w = memberTextWidth(formatClassMember(m))
522
- if (w > maxW) maxW = w
523
- }
524
- return maxW
525
- }