@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zombie-mermaid/svg-renderer",
3
- "version": "3.2.0",
3
+ "version": "4.0.0",
4
4
  "license": "MIT",
5
5
  "description": "SVG rendering primitives and the ELK-backed layout engine behind zombie-mermaid's SVG output.",
6
6
  "repository": {
@@ -37,7 +37,7 @@
37
37
  "dependencies": {
38
38
  "elkjs": "^0.11.0",
39
39
  "entities": "^7.0.1",
40
- "@zombie-mermaid/core": "3.2.0",
41
- "@zombie-mermaid/mermaid-parser": "3.2.0"
40
+ "@zombie-mermaid/core": "4.0.0",
41
+ "@zombie-mermaid/mermaid-parser": "4.0.0"
42
42
  }
43
43
  }
@@ -0,0 +1,112 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { splitStatements } from '@zombie-mermaid/core'
3
+ import { c4RelLabelLines, parseC4Diagram } from '@zombie-mermaid/mermaid-parser'
4
+ import { layoutC4DiagramSync } from '../c4/layout.ts'
5
+ import { C4, c4TextWidth } from '../c4/metrics.ts'
6
+
7
+ // Mermaid starts a relationship label at the chord's midpoint, which can land
8
+ // it on a shape. The label slides along its line to clear every shape (#1290).
9
+ const layout = (src: string) =>
10
+ layoutC4DiagramSync(parseC4Diagram(splitStatements(src)))
11
+
12
+ // The C4 Container sample: `Reads/writes [SQL]` and `Publishes [AMQP]` both
13
+ // used to sit on the Database cylinder.
14
+ const container = `C4Container
15
+ Person(user, "User")
16
+ System_Boundary(sys, "Shop") {
17
+ Container(web, "Web App", "React", "Storefront")
18
+ Container(api, "API", "Node.js", "Business logic")
19
+ ContainerDb(db, "Database", "PostgreSQL", "Orders")
20
+ ContainerQueue(q, "Events", "Kafka")
21
+ }
22
+ System_Ext(pay, "Payments")
23
+ Rel(user, web, "Browses")
24
+ Rel(web, api, "Calls", "JSON/HTTPS")
25
+ Rel(api, db, "Reads/writes", "SQL")
26
+ BiRel(api, q, "Publishes", "AMQP")
27
+ Rel(api, pay, "Charges cards")`
28
+
29
+ function labelRects(d: ReturnType<typeof layout>) {
30
+ return d.relationships.flatMap((rel) => {
31
+ if (!rel.labelPosition) return []
32
+ const lines = c4RelLabelLines(rel)
33
+ return lines.map((line, i) => {
34
+ const isTech = rel.technology !== undefined && i === lines.length - 1
35
+ const w = c4TextWidth(line, C4.messageSize, 400)
36
+ const cx =
37
+ isTech && rel.technologyX !== undefined
38
+ ? rel.technologyX
39
+ : rel.labelPosition!.x
40
+ const cy = rel.labelPosition!.y + (i === 0 ? 0 : C4.messageSize + 5)
41
+ return {
42
+ rel: `${rel.from}->${rel.to}`,
43
+ x0: cx - w / 2,
44
+ x1: cx + w / 2,
45
+ y0: cy - C4.messageSize / 2,
46
+ y1: cy + C4.messageSize / 2,
47
+ }
48
+ })
49
+ })
50
+ }
51
+
52
+ function overlapping(d: ReturnType<typeof layout>): string[] {
53
+ const hits: string[] = []
54
+ for (const r of labelRects(d)) {
55
+ for (const e of d.elements) {
56
+ if (
57
+ r.x0 < e.x + e.width &&
58
+ r.x1 > e.x &&
59
+ r.y0 < e.y + e.height &&
60
+ r.y1 > e.y
61
+ ) {
62
+ hits.push(`${r.rel} label over ${e.alias}`)
63
+ }
64
+ }
65
+ }
66
+ return hits
67
+ }
68
+
69
+ describe('C4 relationship labels clear the shapes (#1290)', () => {
70
+ it('keeps every label in the Container sample off every shape', () => {
71
+ expect(overlapping(layout(container))).toEqual([])
72
+ })
73
+
74
+ it('keeps a label on its own line, only sliding along it', () => {
75
+ const d = layout(container)
76
+ const rel = d.relationships.find((r) => r.to === 'db')!
77
+ const [s, e] = rel.points
78
+ const dx = e!.x - s!.x
79
+ const dy = e!.y - s!.y
80
+ const t = rel.labelPosition!
81
+ // Same distance from the chord as an unmoved label: Mermaid's label is
82
+ // half its own width to the right of the midpoint, never further off.
83
+ const across = Math.abs(
84
+ ((t.x - s!.x) * dy - (t.y - s!.y) * dx) / Math.hypot(dx, dy),
85
+ )
86
+ expect(across).toBeLessThan(40)
87
+ })
88
+
89
+ it('leaves a label that already clears every shape where Mermaid puts it', () => {
90
+ const d = layout(`C4Context
91
+ System(a, "A")
92
+ System(b, "B")
93
+ Rel(a, b, "Uses")`)
94
+ const rel = d.relationships[0]!
95
+ const [s, e] = rel.points
96
+ const mid = (s!.x + e!.x) / 2
97
+ const width = c4TextWidth('Uses', C4.messageSize, 400)
98
+ expect(rel.labelPosition!.x).toBeCloseTo(mid + width / 2, 5)
99
+ })
100
+
101
+ it('does not let two labels in one gap sit on each other', () => {
102
+ const rects = labelRects(layout(container))
103
+ for (const [i, a] of rects.entries()) {
104
+ for (const b of rects.slice(i + 1)) {
105
+ if (a.rel === b.rel) continue
106
+ const apart =
107
+ a.x1 <= b.x0 || b.x1 <= a.x0 || a.y1 <= b.y0 || b.y1 <= a.y0
108
+ expect(apart, `${a.rel} vs ${b.rel}`).toBe(true)
109
+ }
110
+ }
111
+ })
112
+ })
@@ -0,0 +1,52 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { splitStatements } from '@zombie-mermaid/core'
3
+ import { parseC4Diagram } from '@zombie-mermaid/mermaid-parser'
4
+ import { layoutC4DiagramSync } from '../c4/layout.ts'
5
+ import { c4PersonGeometry } from '../c4/metrics.ts'
6
+
7
+ // A relationship meets a cylinder, pipe or person where Mermaid's own shape
8
+ // outline puts it, not at the box around the shape (#1208). The pipe and the
9
+ // oblique person cases are checked against recorded Mermaid output in
10
+ // c4-mermaid-reference.test.ts.
11
+ const layout = (src: string) =>
12
+ layoutC4DiagramSync(parseC4Diagram(splitStatements(src)))
13
+
14
+ describe('C4 line ends on shaped elements (#1208)', () => {
15
+ // Row 1 holds four shapes and row 2 starts under the first, so the line from
16
+ // `b` down to the database arrives from the upper right.
17
+ const src = `C4Context
18
+ System(a, "A")
19
+ System(b, "B")
20
+ System(c, "C")
21
+ System(e, "E")
22
+ SystemDb(db, "Store")
23
+ Rel(b, db, "Reads")`
24
+
25
+ it('ends a slanted line on the curve of the cylinder cap, below the box top', () => {
26
+ const d = layout(src)
27
+ const store = d.elements.find((e) => e.alias === 'db')!
28
+ const [, end] = d.relationships[0]!.points
29
+ // 84.4px right of the centre the cap (rx 108, ry 15.8) has dropped
30
+ // 5.9px, so the line ends 5.9px under the top of the box, not on it.
31
+ expect(end!.y - store.y).toBeCloseTo(5.96, 1)
32
+ })
33
+
34
+ it('ends a line into the bottom middle of a cylinder at the box bottom', () => {
35
+ const d = layout(`C4Context
36
+ SystemDb(db, "Store")
37
+ System(b, "B")
38
+ System(c, "C")
39
+ System(e, "E")
40
+ System(f, "F")
41
+ Rel(f, db, "Reads")`)
42
+ const store = d.elements.find((e) => e.alias === 'db')!
43
+ const [, end] = d.relationships[0]!.points
44
+ // Straight below the centre the ellipse is at its lowest: no lift.
45
+ expect(end!.y - (store.y + store.height)).toBeCloseTo(0, 5)
46
+ })
47
+
48
+ it("keeps a wide person head at Mermaid's 56px radius cap", () => {
49
+ expect(c4PersonGeometry(216).headRadius).toBeCloseTo(49.68, 2)
50
+ expect(c4PersonGeometry(400).headRadius).toBe(56)
51
+ })
52
+ })
@@ -12,9 +12,9 @@ import { layoutC4DiagramSync } from '../c4/layout.ts'
12
12
  // direction), so a constant tuned to one sample cannot satisfy all of them.
13
13
  //
14
14
  // Vertical numbers depend only on heights and gaps, so they must agree to a
15
- // few pixels. Horizontal ones depend on text widths, which this package
16
- // estimates (Mermaid measures them in a browser), and relationship ends depend
17
- // on each shape's own outline, so those get a wider allowance.
15
+ // few pixels. Horizontal ones depend on text widths, which this package sums
16
+ // from Arial advances (Mermaid measures them in a browser), and relationship
17
+ // ends follow each shape's own outline, as Mermaid's do.
18
18
  interface Reference {
19
19
  name: string
20
20
  source: string
@@ -32,9 +32,22 @@ const cases: Reference[] = JSON.parse(
32
32
  )
33
33
 
34
34
  const Y = 3
35
- const X = 11
36
- const LINE = 16
35
+ const X = 2
36
+ const LINE = 3
37
+ // Relationships whose end this package cannot place within LINE, with the
38
+ // reason. Each is a limit of the shape outline, not of the text widths.
39
+ const WIDER_LINE = 9
40
+ const WIDER: Record<string, number[]> = {
41
+ // A line that skims along the top of a person's body, where the pill meets
42
+ // the head: 0.25px of height moves where it crosses by about 8px.
43
+ 'v02-person-targets': [3],
44
+ }
37
45
  const LABEL = 10
46
+ // Deliberate difference (#1209): Mermaid merges two relationships with the
47
+ // same from/to (the later one wins, one line drawn). This package draws both,
48
+ // so a relationship the author wrote is never dropped. Each entry is a case
49
+ // where Mermaid drew `n` lines for a source with `n + 1` relationships.
50
+ const DUPLICATE_PAIRS = new Set(['v07-dynamic'])
38
51
 
39
52
  describe('C4 SVG layout against Mermaid on varied diagrams', () => {
40
53
  it('covers a spread of diagrams', () => {
@@ -56,28 +69,54 @@ describe('C4 SVG layout against Mermaid on varied diagrams', () => {
56
69
  expect(Math.abs(cy - r[1]), `${e.alias} centre y`).toBeLessThan(Y)
57
70
  }
58
71
 
59
- // Mermaid merges two relationships between the same pair; this draws
60
- // both, so the lines only line up when the counts agree.
61
- if (ref.relationships.length !== d.relationships.length) return
72
+ // Mermaid merges duplicate pairs and this package draws both (see
73
+ // DUPLICATE_PAIRS), so the lines only line up when the counts agree.
74
+ // A count mismatch is only allowed for a listed case, and there it must
75
+ // be exactly the duplicate that is drawn extra.
76
+ if (DUPLICATE_PAIRS.has(ref.name)) {
77
+ expect(d.relationships.length, 'both duplicate lines drawn').toBe(
78
+ ref.relationships.length + 1,
79
+ )
80
+ const pairs = d.relationships.map((r) => `${r.from}>${r.to}`)
81
+ expect(
82
+ new Set(pairs).size,
83
+ 'a pair is drawn twice, as written',
84
+ ).toBeLessThan(pairs.length)
85
+ return
86
+ }
87
+ expect(d.relationships.length, 'relationship count').toBe(
88
+ ref.relationships.length,
89
+ )
62
90
  d.relationships.forEach((rel, i) => {
63
91
  const r = ref.relationships[i]!
64
92
  const [s, e] = rel.points
65
93
  const got = [s!.x, s!.y, e!.x, e!.y]
94
+ const limit = WIDER[ref.name]?.includes(i + 1) ? WIDER_LINE : LINE
66
95
  got.forEach((v, k) => {
67
96
  expect(
68
97
  Math.abs(v - r.line[k]!),
69
98
  `relationship ${i + 1} line[${k}]`,
70
- ).toBeLessThan(LINE)
99
+ ).toBeLessThan(limit)
71
100
  })
72
101
  if (r.label && rel.labelPosition) {
73
- expect(
74
- Math.abs(rel.labelPosition.x - r.label[0]),
75
- `relationship ${i + 1} label x`,
76
- ).toBeLessThan(LABEL)
77
- expect(
78
- Math.abs(rel.labelPosition.y - r.label[1]),
79
- `relationship ${i + 1} label y`,
80
- ).toBeLessThan(LABEL)
102
+ // Mermaid's label can sit on a shape; this package slides it along
103
+ // the chord to clear it (#1290). That is the one allowed difference:
104
+ // the label may move along the line but not off it.
105
+ const dx = e!.x - s!.x
106
+ const dy = e!.y - s!.y
107
+ const len = Math.hypot(dx, dy)
108
+ const across = (p: { x: number; y: number }): number =>
109
+ ((p.x - s!.x) * dy - (p.y - s!.y) * dx) / len
110
+ const ref0 = { x: r.label[0], y: r.label[1] }
111
+ const moved =
112
+ Math.abs(rel.labelPosition.x - ref0.x) >= LABEL ||
113
+ Math.abs(rel.labelPosition.y - ref0.y) >= LABEL
114
+ if (moved) {
115
+ expect(
116
+ Math.abs(across(rel.labelPosition) - across(ref0)),
117
+ `relationship ${i + 1} label is off the chord`,
118
+ ).toBeLessThan(LABEL)
119
+ }
81
120
  }
82
121
  })
83
122
  })
@@ -0,0 +1,43 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { splitStatements } from '@zombie-mermaid/core'
3
+ import { parseC4Diagram } from '@zombie-mermaid/mermaid-parser'
4
+ import { layoutC4DiagramSync } from '../c4/layout.ts'
5
+ import { C4, C4_TEXT_WIDTH, c4TextWidth, wrapToWidth } from '../c4/metrics.ts'
6
+
7
+ // Mermaid 11.17.2 measures C4 text in the browser. The widths below are read
8
+ // back from its SVG (see c4-mermaid-reference.test.ts for the full set), and
9
+ // text near the wrap limit is where an estimate that is a few percent off
10
+ // changes a shape's width (#1210).
11
+ const shapes = (src: string) =>
12
+ layoutC4DiagramSync(parseC4Diagram(splitStatements(src))).elements
13
+
14
+ describe('C4 text widths (#1210)', () => {
15
+ it('measures a bold name as Mermaid does', () => {
16
+ // Mermaid made the shape for this name 227.6px wide: text plus 2 x 20.
17
+ expect(
18
+ c4TextWidth('Personal Banking Customer', C4.nameSize, 700),
19
+ ).toBeCloseTo(227.6 - 2 * C4.shapePadding, 0)
20
+ })
21
+
22
+ it('wraps at the shape width less its padding, measured at regular weight', () => {
23
+ expect(C4_TEXT_WIDTH).toBe(176)
24
+ // 187.6px in bold, 175.9px in regular: it stays on one line, as in
25
+ // Mermaid, even though the drawn name is wider than the limit.
26
+ const name = 'Personal Banking Customer'
27
+ expect(wrapToWidth(name, C4_TEXT_WIDTH, C4.nameSize)).toEqual([name])
28
+ expect(c4TextWidth(name, C4.nameSize, 700)).toBeGreaterThan(C4_TEXT_WIDTH)
29
+ })
30
+
31
+ it('keeps a person with a three-line description at the standard width', () => {
32
+ const [p] = shapes(
33
+ 'C4Context\n Person(p, "Customer", "A description that is long enough to wrap into three lines at the standard width of a shape")',
34
+ )
35
+ expect(p!.descriptionLines).toHaveLength(3)
36
+ // Mermaid: 216px. A wider estimate widened it to 222px.
37
+ expect(p!.width).toBeCloseTo(216, 1)
38
+ })
39
+
40
+ it('falls back to a scaled estimate outside printable ASCII', () => {
41
+ expect(c4TextWidth('中文', C4.descrSize, 400)).toBeGreaterThan(0)
42
+ })
43
+ })
@@ -0,0 +1,40 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { splitStatements } from '@zombie-mermaid/core'
3
+ import { parseClassDiagram } from '@zombie-mermaid/mermaid-parser'
4
+ import { layoutClassDiagramSync } from '../class/layout.ts'
5
+
6
+ const layout = (src: string) =>
7
+ layoutClassDiagramSync(parseClassDiagram(splitStatements(src)))
8
+
9
+ // Mermaid places disconnected components left to right in declaration order
10
+ // (#1249). ELK's layered algorithm otherwise orders them by its own graph
11
+ // traversal, so the layout has to keep the declared order.
12
+ describe('class diagram disconnected components (#1249)', () => {
13
+ it('places each component left to right in source order, in one row', () => {
14
+ const p = layout(`classDiagram
15
+ A <|-- B : inheritance
16
+ C *-- D : composition
17
+ E o-- F : aggregation
18
+ G --> H : association
19
+ I ..> J : dependency
20
+ K ..|> L : realization`)
21
+ const at = (id: string) => p.classes.find((c) => c.id === id)!
22
+ const roots = ['A', 'C', 'E', 'G', 'I', 'K'].map((id) => at(id))
23
+ const lefts = roots.map((c, i) => Math.min(c.x, at('BDFHJL'[i]!).x))
24
+ for (let i = 1; i < lefts.length; i++) {
25
+ expect(lefts[i]!).toBeGreaterThan(lefts[i - 1]!)
26
+ }
27
+ // Single row: every component starts at the same top.
28
+ expect(new Set(roots.map((c) => c.y)).size).toBe(1)
29
+ })
30
+
31
+ it('orders components by first appearance, not by relationship order', () => {
32
+ const p = layout(`classDiagram
33
+ class Z
34
+ class Y
35
+ Z <|-- Q
36
+ Y <|-- R`)
37
+ const x = (id: string) => p.classes.find((c) => c.id === id)!.x
38
+ expect(x('Z')).toBeLessThan(x('Y'))
39
+ })
40
+ })
@@ -0,0 +1,43 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { renderMermaidSVG } from '../../../../src/index.ts'
3
+
4
+ // Mono glyphs advance 0.6em (6.6px at the 11px member size). Chromium without
5
+ // subpixel glyph positioning (headless Linux) rounds each advance to a whole
6
+ // pixel, so the worst-case drawn width is chars * ceil(6.6) = chars * 7. A box
7
+ // sized from the unrounded estimate let a long member run into the right
8
+ // border in the Linux baselines (#1238).
9
+ const WORST_CASE_ADVANCE = 7
10
+ const MIN_RIGHT_PADDING = 8
11
+
12
+ const SRC = `classDiagram
13
+ class Controller {
14
+ -model Model
15
+ +handleInput(event) void
16
+ +updateModel(data) void
17
+ }`
18
+
19
+ const attr = (tag: string, name: string) => {
20
+ const start = tag.indexOf(`${name}="`) + name.length + 2
21
+ return Number(tag.slice(start, tag.indexOf('"', start)))
22
+ }
23
+
24
+ describe('class member right padding (#1238)', () => {
25
+ const svg = renderMermaidSVG(SRC)
26
+ const box =
27
+ (svg.split('<g class="class-node"')[1] ?? '').split('<rect')[1] ?? ''
28
+ const text = svg
29
+ .split('<text')
30
+ .find((t) => t.includes('handleInput(event)')) as string
31
+ const drawnChars = '+ handleInput(event): void'.length
32
+
33
+ it('finds the box and the member text', () => {
34
+ expect(box).not.toBe('')
35
+ expect(text).toBeDefined()
36
+ })
37
+
38
+ it('keeps the minimum right padding even with whole-pixel glyph advances', () => {
39
+ const boxRight = attr(box, 'x') + attr(box, 'width')
40
+ const textRight = attr(text, 'x') + drawnChars * WORST_CASE_ADVANCE
41
+ expect(boxRight - textRight).toBeGreaterThanOrEqual(MIN_RIGHT_PADDING)
42
+ })
43
+ })
@@ -0,0 +1,165 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { splitStatements } from '@zombie-mermaid/core'
3
+ import { parseClassDiagram } from '@zombie-mermaid/mermaid-parser'
4
+ import { layoutClassDiagramSync } from '../class/layout.ts'
5
+ import { renderMermaidSVG } from '../index.ts'
6
+
7
+ const SRC = `classDiagram
8
+ namespace Core {
9
+ class A
10
+ class B
11
+ }
12
+ namespace Extras {
13
+ class C
14
+ }
15
+ class D
16
+ A --> C
17
+ A --> B
18
+ D --> B`
19
+
20
+ const layout = (src: string) =>
21
+ layoutClassDiagramSync(parseClassDiagram(splitStatements(src)))
22
+
23
+ describe('class diagram namespaces (#1196)', () => {
24
+ const p = layout(SRC)
25
+ const cls = (id: string) => p.classes.find((c) => c.id === id)!
26
+ const ns = (name: string) => p.namespaces.find((n) => n.name === name)!
27
+
28
+ it('lays out one frame per namespace', () => {
29
+ expect(p.namespaces.map((n) => n.name)).toEqual(['Core', 'Extras'])
30
+ })
31
+
32
+ it('encloses each member class and no outsider', () => {
33
+ const inside = (n: string, id: string) => {
34
+ const f = ns(n)
35
+ const c = cls(id)
36
+ return (
37
+ c.x >= f.x &&
38
+ c.y >= f.y &&
39
+ c.x + c.width <= f.x + f.width &&
40
+ c.y + c.height <= f.y + f.height
41
+ )
42
+ }
43
+ expect(inside('Core', 'A')).toBe(true)
44
+ expect(inside('Core', 'B')).toBe(true)
45
+ expect(inside('Extras', 'C')).toBe(true)
46
+ expect(inside('Core', 'C')).toBe(false)
47
+ expect(inside('Core', 'D')).toBe(false)
48
+ expect(inside('Extras', 'D')).toBe(false)
49
+ })
50
+
51
+ it('keeps frames inside the canvas and apart from each other', () => {
52
+ for (const f of p.namespaces) {
53
+ expect(f.x).toBeGreaterThanOrEqual(0)
54
+ expect(f.y).toBeGreaterThanOrEqual(0)
55
+ expect(f.x + f.width).toBeLessThanOrEqual(p.width)
56
+ expect(f.y + f.height).toBeLessThanOrEqual(p.height)
57
+ }
58
+ const a = ns('Core')
59
+ const b = ns('Extras')
60
+ const disjoint =
61
+ a.x + a.width <= b.x ||
62
+ b.x + b.width <= a.x ||
63
+ a.y + a.height <= b.y ||
64
+ b.y + b.height <= a.y
65
+ expect(disjoint).toBe(true)
66
+ })
67
+
68
+ it('leaves room for the title above the classes', () => {
69
+ expect(cls('A').y - ns('Core').y).toBeGreaterThanOrEqual(28)
70
+ })
71
+
72
+ it('routes edges to the class boxes, including edges inside a namespace', () => {
73
+ const touches = (pt: { x: number; y: number }, id: string) => {
74
+ const c = cls(id)
75
+ const tol = 1.5
76
+ return (
77
+ pt.x >= c.x - tol &&
78
+ pt.x <= c.x + c.width + tol &&
79
+ pt.y >= c.y - tol &&
80
+ pt.y <= c.y + c.height + tol
81
+ )
82
+ }
83
+ for (const rel of p.relationships) {
84
+ const first = rel.points[0]!
85
+ const last = rel.points[rel.points.length - 1]!
86
+ expect(touches(first, rel.from)).toBe(true)
87
+ expect(touches(last, rel.to)).toBe(true)
88
+ }
89
+ })
90
+
91
+ it('draws the namespace names and frames in SVG', () => {
92
+ const svg = renderMermaidSVG(SRC)
93
+ expect(svg).toContain('>Core</text>')
94
+ expect(svg).toContain('>Extras</text>')
95
+ expect(svg.match(/class="class-namespace"/g)).toHaveLength(2)
96
+ // Frames sit behind the class boxes.
97
+ expect(svg.indexOf('class-namespace')).toBeLessThan(
98
+ svg.indexOf('class-node'),
99
+ )
100
+ })
101
+
102
+ it('escapes the namespace name', () => {
103
+ const svg = renderMermaidSVG(`classDiagram
104
+ namespace a&b {
105
+ class A
106
+ }`)
107
+ expect(svg).toContain('>a&amp;b</text>')
108
+ })
109
+
110
+ it('ignores a namespace with no classes and a class claimed twice', () => {
111
+ const q = layout(`classDiagram
112
+ namespace Empty {
113
+ }
114
+ namespace One {
115
+ class A
116
+ }
117
+ namespace Two {
118
+ class A
119
+ class B
120
+ }`)
121
+ expect(q.namespaces.map((n) => [n.name, n.classIds])).toEqual([
122
+ ['One', ['A']],
123
+ ['Two', ['B']],
124
+ ])
125
+ })
126
+
127
+ it('leaves diagrams without namespaces unframed', () => {
128
+ expect(layout('classDiagram\nA --> B').namespaces).toEqual([])
129
+ })
130
+
131
+ it('returns no frames for an empty diagram', () => {
132
+ expect(layout('classDiagram').namespaces).toEqual([])
133
+ })
134
+
135
+ it('keeps a note link attached to a class inside a namespace', () => {
136
+ const q = layout(`classDiagram
137
+ namespace Core {
138
+ class A
139
+ class B
140
+ }
141
+ A --> B
142
+ note for A "hello"`)
143
+ const a = q.classes.find((c) => c.id === 'A')!
144
+ const note = q.notes[0]!
145
+ expect(note.linkPoints).toBeDefined()
146
+ const pts = note.linkPoints!
147
+ const end = pts[pts.length - 1]!
148
+ const tol = 1.5
149
+ const touchesA =
150
+ end.x >= a.x - tol &&
151
+ end.x <= a.x + a.width + tol &&
152
+ end.y >= a.y - tol &&
153
+ end.y <= a.y + a.height + tol
154
+ expect(touchesA).toBe(true)
155
+ })
156
+
157
+ it('lays out a note that has no link', () => {
158
+ const q = layout(`classDiagram
159
+ namespace Core {
160
+ class A
161
+ }
162
+ note "free floating"`)
163
+ expect(q.notes[0]!.linkPoints).toBeUndefined()
164
+ })
165
+ })
@@ -0,0 +1,96 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import type { ElkNode } from 'elkjs/lib/elk-api'
3
+ import { splitStatements } from '@zombie-mermaid/core'
4
+ import { parseClassDiagram } from '@zombie-mermaid/mermaid-parser'
5
+
6
+ // ELK's result type marks every coordinate optional. Strip them (and
7
+ // optionally drop a node) so the layout extractor's fallbacks run.
8
+ const mode = vi.hoisted(() => ({
9
+ strip: false,
10
+ dropNamespaces: false,
11
+ emptyNamespaces: false,
12
+ noChildren: false,
13
+ }))
14
+
15
+ vi.mock('../elk-instance.ts', async (importOriginal) => {
16
+ const real = await importOriginal<typeof import('../elk-instance.ts')>()
17
+ const bare = (n: ElkNode): ElkNode => {
18
+ return { id: n.id, edges: n.edges, children: n.children?.map(bare) }
19
+ }
20
+ return {
21
+ ...real,
22
+ elkLayoutSync: (...args: Parameters<typeof real.elkLayoutSync>) => {
23
+ const result = real.elkLayoutSync(...args)
24
+ if (mode.noChildren) return { ...result, children: undefined }
25
+ if (mode.emptyNamespaces) {
26
+ return {
27
+ ...result,
28
+ children: result.children?.map((c) =>
29
+ c.id.startsWith('namespace') ? { ...c, children: undefined } : c,
30
+ ),
31
+ }
32
+ }
33
+ if (mode.dropNamespaces) {
34
+ return {
35
+ ...result,
36
+ children: result.children?.filter(
37
+ (c) => !c.id.startsWith('namespace'),
38
+ ),
39
+ }
40
+ }
41
+ return mode.strip ? bare(result) : result
42
+ },
43
+ }
44
+ })
45
+
46
+ const { layoutClassDiagramSync } = await import('../class/layout.ts')
47
+
48
+ const SRC = `classDiagram
49
+ namespace Core {
50
+ class A
51
+ class B
52
+ }
53
+ A --> B`
54
+
55
+ const layout = () =>
56
+ layoutClassDiagramSync(parseClassDiagram(splitStatements(SRC)))
57
+
58
+ describe('class namespace layout against sparse ELK output (#1196)', () => {
59
+ it('falls back to the origin and measured sizes when ELK omits geometry', () => {
60
+ mode.strip = true
61
+ mode.dropNamespaces = false
62
+ const p = layout()
63
+ expect(p.namespaces).toEqual([
64
+ { name: 'Core', classIds: ['A', 'B'], x: 0, y: 0, width: 0, height: 0 },
65
+ ])
66
+ for (const c of p.classes) {
67
+ expect(c.x).toBe(0)
68
+ expect(c.width).toBeGreaterThan(0)
69
+ }
70
+ })
71
+
72
+ it('tolerates a namespace node without children', () => {
73
+ mode.strip = false
74
+ mode.dropNamespaces = false
75
+ mode.emptyNamespaces = true
76
+ expect(layout().namespaces).toHaveLength(1)
77
+ })
78
+
79
+ it('tolerates a result without any children', () => {
80
+ mode.strip = false
81
+ mode.dropNamespaces = false
82
+ mode.emptyNamespaces = false
83
+ mode.noChildren = true
84
+ const p = layout()
85
+ expect(p.namespaces).toEqual([])
86
+ expect(p.classes).toEqual([])
87
+ })
88
+
89
+ it('skips a namespace ELK did not return', () => {
90
+ mode.strip = false
91
+ mode.noChildren = false
92
+ mode.emptyNamespaces = false
93
+ mode.dropNamespaces = true
94
+ expect(layout().namespaces).toEqual([])
95
+ })
96
+ })