@zombie-mermaid/svg-renderer 4.2.2 → 5.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 (72) 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 +897 -849
  7. package/dist/index.js.map +1 -1
  8. package/package.json +14 -5
  9. package/src/__tests__/c4-label-clearance-1290.test.ts +0 -112
  10. package/src/__tests__/c4-line-ends-1208.test.ts +0 -52
  11. package/src/__tests__/c4-mermaid-reference.test.ts +0 -124
  12. package/src/__tests__/c4-svg.test.ts +0 -337
  13. package/src/__tests__/c4-text-width-1210.test.ts +0 -43
  14. package/src/__tests__/class-component-order-1249.test.ts +0 -40
  15. package/src/__tests__/class-diamond-markers-1232.test.ts +0 -59
  16. package/src/__tests__/class-member-right-padding-1238.test.ts +0 -43
  17. package/src/__tests__/class-namespace-1196.test.ts +0 -165
  18. package/src/__tests__/class-namespace-sparse-elk-1196.test.ts +0 -96
  19. package/src/__tests__/class-visibility-markers-1237.test.ts +0 -27
  20. package/src/__tests__/edge-jog-straightening-1388.test.ts +0 -57
  21. package/src/__tests__/elk-adapter-utils.test.ts +0 -166
  22. package/src/__tests__/er-attribute-right-padding-1262.test.ts +0 -47
  23. package/src/__tests__/er-cardinality-markers-1233.test.ts +0 -35
  24. package/src/__tests__/er-default-direction-1250.test.ts +0 -69
  25. package/src/__tests__/er-edge-label-contrast-1244.test.ts +0 -58
  26. package/src/__tests__/fixtures/c4-mermaid-reference.json +0 -626
  27. package/src/__tests__/flowchart-parallel-edge-lanes-1308.test.ts +0 -168
  28. package/src/__tests__/pie-layout.test.ts +0 -215
  29. package/src/__tests__/pie-svg.test.ts +0 -194
  30. package/src/__tests__/render-mermaid-svg.test.ts +0 -64
  31. package/src/__tests__/sequence-label-clearance-1242.test.ts +0 -162
  32. package/src/__tests__/sequence-nested-activation-1241.test.ts +0 -37
  33. package/src/__tests__/svg-edge-to-own-cluster-1310.test.ts +0 -32
  34. package/src/__tests__/xychart-smooth-curve.test.ts +0 -42
  35. package/src/__tests__/xychart-x-label-overlap-1243.test.ts +0 -62
  36. package/src/c4/arial-widths.ts +0 -59
  37. package/src/c4/layout.ts +0 -680
  38. package/src/c4/metrics.ts +0 -261
  39. package/src/c4/renderer.ts +0 -366
  40. package/src/class/layout.ts +0 -525
  41. package/src/class/renderer.ts +0 -669
  42. package/src/edge-curves.ts +0 -205
  43. package/src/elk-instance.ts +0 -292
  44. package/src/er/layout.ts +0 -216
  45. package/src/er/renderer.ts +0 -516
  46. package/src/generated/mono-font-subset.ts +0 -32
  47. package/src/index.ts +0 -277
  48. package/src/layout-engine/back-edges.ts +0 -73
  49. package/src/layout-engine/compound-flat.ts +0 -916
  50. package/src/layout-engine/constants.ts +0 -19
  51. package/src/layout-engine/edge-bundling.ts +0 -458
  52. package/src/layout-engine/elk-adapter-utils.ts +0 -81
  53. package/src/layout-engine/elk-graph-builder.ts +0 -245
  54. package/src/layout-engine/from-elk.ts +0 -767
  55. package/src/layout-engine/inner-edges.ts +0 -329
  56. package/src/layout-engine/jog-straightening.ts +0 -52
  57. package/src/layout-engine/layer-alignment.ts +0 -174
  58. package/src/layout-engine/layout-hints.ts +0 -27
  59. package/src/layout-engine/to-elk.ts +0 -855
  60. package/src/layout-engine.ts +0 -115
  61. package/src/pie/layout.ts +0 -234
  62. package/src/pie/renderer.ts +0 -161
  63. package/src/registry.ts +0 -441
  64. package/src/renderer.ts +0 -1510
  65. package/src/resolve-colors.ts +0 -339
  66. package/src/sequence/layout.ts +0 -766
  67. package/src/sequence/renderer.ts +0 -687
  68. package/src/shape-clipping.ts +0 -197
  69. package/src/styles.ts +0 -145
  70. package/src/title-gaps.ts +0 -161
  71. package/src/xychart/layout.ts +0 -727
  72. package/src/xychart/renderer.ts +0 -676
package/src/c4/layout.ts DELETED
@@ -1,680 +0,0 @@
1
- import type {
2
- C4Boundary,
3
- C4Diagram,
4
- C4Element,
5
- C4Relationship,
6
- PositionedC4Boundary,
7
- PositionedC4Diagram,
8
- PositionedC4Element,
9
- PositionedC4Relationship,
10
- } from '@zombie-mermaid/mermaid-parser'
11
- import {
12
- c4BoundaryTypeLine,
13
- c4RelLabelLines,
14
- c4TypeLine,
15
- } from '@zombie-mermaid/mermaid-parser'
16
- import type { Point, RenderOptions } from '@zombie-mermaid/core'
17
- import {
18
- C4,
19
- C4_TEXT_WIDTH,
20
- c4CylinderCap,
21
- c4PersonGeometry,
22
- c4QueueCap,
23
- c4ShapeSize,
24
- c4TextWidth,
25
- wrapToWidth,
26
- } from './metrics.ts'
27
-
28
- // ============================================================================
29
- // C4 layout, following Mermaid's own C4 renderer.
30
- //
31
- // Mermaid does not run a graph layout for C4. It places shapes in rows, in
32
- // the order they are declared: up to `c4ShapeInRow` per row, boundaries
33
- // `c4BoundaryInRow` to a row, each boundary wide enough for what it holds.
34
- // Relationships are drawn afterwards between the shapes and never move
35
- // anything, so `Rel_U/D/L/R` (and a render direction) have no effect on
36
- // placement, as in Mermaid. This is a port of that algorithm
37
- // (packages/mermaid/src/diagrams/c4/c4Renderer.ts), with text measured by
38
- // estimate instead of in a browser.
39
- // ============================================================================
40
-
41
- /** Mermaid's `Bounds`: the extent of what has been placed in one boundary. */
42
- interface Bounds {
43
- startx?: number
44
- stopx?: number
45
- starty?: number
46
- stopy?: number
47
- widthLimit?: number
48
- /** Where the next shape would go (Mermaid's `nextData`). */
49
- next: {
50
- startx?: number
51
- stopx?: number
52
- starty?: number
53
- stopy?: number
54
- cnt: number
55
- }
56
- }
57
-
58
- function newBounds(): Bounds {
59
- return { next: { cnt: 0 } }
60
- }
61
-
62
- function setData(
63
- b: Bounds,
64
- startx: number,
65
- stopx: number,
66
- starty: number,
67
- stopy: number,
68
- ): void {
69
- b.next.startx = b.startx = startx
70
- b.next.stopx = b.stopx = stopx
71
- b.next.starty = b.starty = starty
72
- b.next.stopy = b.stopy = stopy
73
- }
74
-
75
- function grow(
76
- obj: Bounds | Bounds['next'],
77
- key: 'startx' | 'stopx' | 'starty' | 'stopy',
78
- val: number,
79
- pick: (a: number, b: number) => number,
80
- ): void {
81
- const cur = obj[key]
82
- obj[key] = cur === undefined ? val : pick(val, cur)
83
- }
84
-
85
- /** Mermaid's `Bounds.insert`: give a shape the next free slot. */
86
- function insert(b: Bounds, shape: Placed): void {
87
- b.next.cnt += 1
88
- const margin = C4.shapeMargin
89
- let startx =
90
- b.next.startx === b.next.stopx
91
- ? b.next.stopx! + margin
92
- : b.next.stopx! + margin * 2
93
- let stopx = startx + shape.width
94
- let starty = b.next.starty! + margin * 2
95
- let stopy = starty + shape.height
96
- if (
97
- startx >= b.widthLimit! ||
98
- stopx >= b.widthLimit! ||
99
- b.next.cnt > C4.shapeInRow
100
- ) {
101
- startx = b.next.startx! + margin + C4.nextLinePaddingX
102
- starty = b.next.stopy! + margin * 2
103
- b.next.stopx = stopx = startx + shape.width
104
- b.next.starty = b.next.stopy
105
- b.next.stopy = stopy = starty + shape.height
106
- b.next.cnt = 1
107
- }
108
- shape.x = startx
109
- shape.y = starty
110
- grow(b, 'startx', startx, Math.min)
111
- grow(b, 'starty', starty, Math.min)
112
- grow(b, 'stopx', stopx, Math.max)
113
- grow(b, 'stopy', stopy, Math.max)
114
- grow(b.next, 'startx', startx, Math.min)
115
- grow(b.next, 'starty', starty, Math.min)
116
- grow(b.next, 'stopx', stopx, Math.max)
117
- grow(b.next, 'stopy', stopy, Math.max)
118
- }
119
-
120
- interface Placed {
121
- x: number
122
- y: number
123
- width: number
124
- height: number
125
- }
126
-
127
- interface MeasuredElement extends Placed {
128
- el: C4Element
129
- nameLines: string[]
130
- descriptionLines: string[]
131
- }
132
-
133
- function measureElement(el: C4Element): MeasuredElement {
134
- const nameLines = wrapToWidth(el.label, C4_TEXT_WIDTH, C4.nameSize)
135
- const descriptionLines = el.description
136
- ? wrapToWidth(el.description, C4_TEXT_WIDTH, C4.descrSize)
137
- : []
138
- const textWidth = Math.max(
139
- ...nameLines.map((l) => c4TextWidth(l, C4.nameSize, 700)),
140
- c4TextWidth(c4TypeLine(el), C4.typeSize, 400),
141
- ...descriptionLines.map((l) => c4TextWidth(l, C4.descrSize, 400)),
142
- )
143
- const size = c4ShapeSize(
144
- el,
145
- nameLines.length,
146
- descriptionLines.length,
147
- textWidth,
148
- )
149
- return { el, nameLines, descriptionLines, x: 0, y: 0, ...size }
150
- }
151
-
152
- // A boundary as the layout walks it: its own shapes, then its children.
153
- interface Frame {
154
- alias: string
155
- boundary?: C4Boundary
156
- elements: C4Element[]
157
- children: Frame[]
158
- }
159
-
160
- function buildFrames(diagram: C4Diagram): Frame {
161
- const inBoundary = new Set<string>()
162
- const byAlias = new Map(diagram.elements.map((e) => [e.alias, e]))
163
- const frame = (b: C4Boundary): Frame => {
164
- const elements = b.elementAliases.flatMap((a) => {
165
- inBoundary.add(a)
166
- const el = byAlias.get(a)
167
- return el ? [el] : []
168
- })
169
- return {
170
- alias: b.alias,
171
- boundary: b,
172
- elements,
173
- children: b.children.map(frame),
174
- }
175
- }
176
- const children = diagram.boundaries.map(frame)
177
- return {
178
- alias: 'global',
179
- elements: diagram.elements.filter((e) => !inBoundary.has(e.alias)),
180
- children,
181
- }
182
- }
183
-
184
- interface LaidOutBoundary extends PositionedC4Boundary {
185
- labelY: number
186
- }
187
-
188
- interface Layout {
189
- elements: Map<string, MeasuredElement>
190
- boundaries: LaidOutBoundary[]
191
- maxX: number
192
- maxY: number
193
- }
194
-
195
- function layoutFrames(root: Frame): Layout {
196
- const elements = new Map<string, MeasuredElement>()
197
- const boundaries: LaidOutBoundary[] = []
198
- let maxX: number = C4.diagramMarginX
199
- let maxY: number = C4.diagramMarginY
200
-
201
- // Mermaid's `drawC4ShapeArray`: measure, insert each, then bump the margin.
202
- const placeShapes = (bounds: Bounds, els: C4Element[]): void => {
203
- for (const el of els) {
204
- const m = measureElement(el)
205
- elements.set(el.alias, m)
206
- insert(bounds, m)
207
- }
208
- bounds.stopx! += C4.shapeMargin
209
- bounds.stopy! += C4.shapeMargin
210
- }
211
-
212
- // Mermaid's `drawInsideBoundary`.
213
- const inside = (parent: Bounds, frames: Frame[], depth: number): void => {
214
- const bounds = newBounds()
215
- bounds.widthLimit =
216
- parent.widthLimit! / Math.min(C4.boundaryInRow, frames.length)
217
- for (const [i, fr] of frames.entries()) {
218
- const b = fr.boundary
219
- // Height of the boundary's heading, which the shapes start below.
220
- let Y = 0
221
- const labelY = Y + 8
222
- Y = labelY + C4.boundaryLabelHeight
223
- let typeY: number | undefined
224
- const typeLine = b ? c4BoundaryTypeLine(b) : undefined
225
- if (typeLine) {
226
- typeY = Y + 5
227
- Y = typeY + C4.boundaryTypeHeight
228
- }
229
- let descrY: number | undefined
230
- if (b?.description) {
231
- descrY = Y + 20
232
- Y = descrY + C4.boundaryDescrHeight
233
- }
234
- // The unnamed root keeps a fixed heading height.
235
- if (!b) Y = C4.rootHeadingHeight
236
-
237
- if (i === 0 || i % C4.boundaryInRow === 0) {
238
- const x = parent.startx! + C4.diagramMarginX
239
- const y = parent.stopy! + C4.diagramMarginY + Y
240
- setData(bounds, x, x, y, y)
241
- } else {
242
- const x =
243
- bounds.stopx !== bounds.startx
244
- ? bounds.stopx! + C4.diagramMarginX
245
- : bounds.startx!
246
- setData(bounds, x, x, bounds.starty!, bounds.starty!)
247
- }
248
-
249
- if (fr.elements.length > 0) placeShapes(bounds, fr.elements)
250
- if (fr.children.length > 0) inside(bounds, fr.children, depth + 1)
251
-
252
- if (b) {
253
- let width = bounds.stopx! - bounds.startx!
254
- let height = bounds.stopy! - bounds.starty!
255
- // A boundary with nothing in it still gets a visible frame.
256
- if (width === 0) width = C4.width
257
- if (height === 0) height = Y + C4.shapeMargin
258
- boundaries.push({
259
- alias: b.alias,
260
- label: b.label,
261
- ...(b.type ? { type: b.type } : {}),
262
- ...(b.description ? { description: b.description } : {}),
263
- x: bounds.startx!,
264
- y: bounds.starty!,
265
- width,
266
- height,
267
- depth,
268
- labelY,
269
- ...(typeY === undefined ? {} : { typeY }),
270
- ...(descrY === undefined ? {} : { descrY }),
271
- })
272
- }
273
- parent.stopy = Math.max(bounds.stopy! + C4.shapeMargin, parent.stopy!)
274
- parent.stopx = Math.max(bounds.stopx! + C4.shapeMargin, parent.stopx!)
275
- maxX = Math.max(maxX, parent.stopx)
276
- maxY = Math.max(maxY, parent.stopy)
277
- }
278
- }
279
-
280
- const screen = newBounds()
281
- setData(
282
- screen,
283
- C4.diagramMarginX,
284
- C4.diagramMarginX,
285
- C4.diagramMarginY,
286
- C4.diagramMarginY,
287
- )
288
- screen.widthLimit = C4.screenWidth
289
- // The unnamed root is not drawn, so its children are the depth-0 boundaries.
290
- inside(screen, [root], -1)
291
- return { elements, boundaries, maxX, maxY }
292
- }
293
-
294
- /** Where the line from a box's centre toward `toward` leaves the box. */
295
- function rectIntersect(box: Placed, toward: Point): Point {
296
- const cx = box.x + box.width / 2
297
- const cy = box.y + box.height / 2
298
- const dx = toward.x - cx
299
- const dy = toward.y - cy
300
- const w = box.width / 2
301
- const h = box.height / 2
302
- if (dx === 0 && dy === 0) return { x: cx, y: cy }
303
- let sx: number
304
- let sy: number
305
- if (Math.abs(dy) * w > Math.abs(dx) * h) {
306
- const half = dy < 0 ? -h : h
307
- sx = (half * dx) / dy
308
- sy = half
309
- } else {
310
- const half = dx < 0 ? -w : w
311
- sx = half
312
- sy = (half * dy) / dx
313
- }
314
- return { x: cx + sx, y: cy + sy }
315
- }
316
-
317
- /**
318
- * Where a line from the centre of a person toward `toward` leaves the figure.
319
- * Mermaid clips against the polygon it builds for the person (a round head
320
- * over a rounded body, 24 points on the head and 12 on each body corner), so
321
- * this builds the same polygon and returns the crossing nearest `toward`.
322
- */
323
- function personIntersect(box: Placed, toward: Point): Point {
324
- const g = c4PersonGeometry(box.width, box.height)
325
- const cx = box.x + box.width / 2
326
- const cy = box.y + box.height / 2
327
- const w = box.width
328
- const h = box.height
329
- const headCentre = -h / 2 + g.headRadius
330
- const bodyTop = -h / 2 + g.pillTop
331
- const br = g.rx
332
- // `n` points on the circle at (ox, oy), sweeping from angle `from` to `to`
333
- // in degrees, with Mermaid's sign convention (`generateCirclePoints`).
334
- const arc = (
335
- ox: number,
336
- oy: number,
337
- r: number,
338
- n: number,
339
- from: number,
340
- to: number,
341
- ): Point[] =>
342
- Array.from({ length: n }, (_, i) => {
343
- const a = ((from + (i * (to - from)) / (n - 1)) * Math.PI) / 180
344
- return { x: ox - r * Math.cos(a), y: oy - r * Math.sin(a) }
345
- })
346
- // Where the head meets the body's top edge, below the head's centre.
347
- const phi =
348
- (Math.asin(Math.min(1, (bodyTop - headCentre) / g.headRadius)) * 180) /
349
- Math.PI
350
- const outline: Point[] = [
351
- ...arc(0, headCentre, g.headRadius, 24, 180 + phi, -phi),
352
- ...arc(-w / 2 + br, bodyTop + br, br, 12, 90, 0),
353
- ...arc(-w / 2 + br, h / 2 - br, br, 12, 360, 270),
354
- ...arc(w / 2 - br, h / 2 - br, br, 12, 270, 180),
355
- ...arc(w / 2 - br, bodyTop + br, br, 12, 180, 90),
356
- ]
357
- return nearestCrossing(
358
- { x: cx, y: cy },
359
- toward,
360
- outline.map((p) => ({ x: cx + p.x, y: cy + p.y })),
361
- )
362
- }
363
-
364
- /**
365
- * The crossing of segment `from`-`toward` with an edge `q1`-`q2`, as Mermaid's
366
- * (dagre's) `intersectLine` computes it: it adds half the determinant before
367
- * dividing, which rounds the result by up to half a pixel.
368
- */
369
- function lineCrossing(
370
- from: Point,
371
- toward: Point,
372
- q1: Point,
373
- q2: Point,
374
- ): Point | undefined {
375
- const a1 = toward.y - from.y
376
- const b1 = from.x - toward.x
377
- const c1 = toward.x * from.y - from.x * toward.y
378
- const r3 = a1 * q1.x + b1 * q1.y + c1
379
- const r4 = a1 * q2.x + b1 * q2.y + c1
380
- if (r3 !== 0 && r4 !== 0 && r3 * r4 > 0) return undefined
381
- const a2 = q2.y - q1.y
382
- const b2 = q1.x - q2.x
383
- const c2 = q2.x * q1.y - q1.x * q2.y
384
- const r1 = a2 * from.x + b2 * from.y + c2
385
- const r2 = a2 * toward.x + b2 * toward.y + c2
386
- if (r1 !== 0 && r2 !== 0 && r1 * r2 > 0) return undefined
387
- const denom = a1 * b2 - a2 * b1
388
- if (denom === 0) return undefined
389
- const offset = Math.abs(denom / 2)
390
- let num = b1 * c2 - b2 * c1
391
- const x = num < 0 ? (num - offset) / denom : (num + offset) / denom
392
- num = a2 * c1 - a1 * c2
393
- const y = num < 0 ? (num - offset) / denom : (num + offset) / denom
394
- return { x, y }
395
- }
396
-
397
- /** The crossing of segment `from`-`toward` with the polygon nearest `toward`. */
398
- function nearestCrossing(from: Point, toward: Point, poly: Point[]): Point {
399
- let best: Point | undefined
400
- let bestDist = Infinity
401
- poly.forEach((p, i) => {
402
- const hit = lineCrossing(from, toward, p, poly[(i + 1) % poly.length]!)
403
- if (!hit) return
404
- const d = Math.hypot(hit.x - toward.x, hit.y - toward.y)
405
- if (d < bestDist) {
406
- best = hit
407
- bestDist = d
408
- }
409
- })
410
- return best ?? from
411
- }
412
-
413
- /** Slack for deciding that a point sits exactly on a box edge. */
414
- const EDGE = 1e-6
415
-
416
- /**
417
- * A cylinder (`SystemDb`, `ContainerDb`) clips like Mermaid's: the point on
418
- * the bounding rectangle, then moved along the curve of the top or bottom cap
419
- * where the line meets a cap rather than the straight side.
420
- */
421
- function cylinderIntersect(box: Placed, toward: Point): Point {
422
- const pos = rectIntersect(box, toward)
423
- const cx = box.x + box.width / 2
424
- const cy = box.y + box.height / 2
425
- const { rx, ry } = c4CylinderCap(box.width)
426
- const x = pos.x - cx
427
- const onCap =
428
- Math.abs(x) < box.width / 2 - EDGE ||
429
- (Math.abs(Math.abs(x) - box.width / 2) <= EDGE &&
430
- Math.abs(pos.y - cy) > box.height / 2 - ry)
431
- if (rx === 0 || !onCap) return pos
432
- const under = ry * ry * (1 - (x * x) / (rx * rx))
433
- let drop = ry - (under > 0 ? Math.sqrt(under) : under)
434
- if (toward.y - cy > 0) drop = -drop
435
- return { x: pos.x, y: pos.y + drop }
436
- }
437
-
438
- /** The same for a queue (`SystemQueue`, `ContainerQueue`), a cylinder on its side. */
439
- function queueIntersect(box: Placed, toward: Point): Point {
440
- const pos = rectIntersect(box, toward)
441
- const cx = box.x + box.width / 2
442
- const cy = box.y + box.height / 2
443
- const { rx, ry } = c4QueueCap(box.height)
444
- const y = pos.y - cy
445
- const onCap =
446
- Math.abs(y) < box.height / 2 - EDGE ||
447
- (Math.abs(Math.abs(y) - box.height / 2) <= EDGE &&
448
- Math.abs(pos.x - cx) > box.width / 2 - rx)
449
- if (ry === 0 || !onCap) return pos
450
- const under = rx * rx * (1 - (y * y) / (ry * ry))
451
- let drop = rx - Math.sqrt(Math.abs(under))
452
- if (toward.x - cx > 0) drop = -drop
453
- return { x: pos.x + drop, y: pos.y }
454
- }
455
-
456
- function leave(box: Placed, toward: Point): Point {
457
- const el = (box as Partial<MeasuredElement>).el
458
- if (el?.kind === 'person') return personIntersect(box, toward)
459
- if (el?.shape === 'db') return cylinderIntersect(box, toward)
460
- if (el?.shape === 'queue') return queueIntersect(box, toward)
461
- return rectIntersect(box, toward)
462
- }
463
-
464
- const centre = (b: Placed): Point => ({
465
- x: b.x + b.width / 2,
466
- y: b.y + b.height / 2,
467
- })
468
-
469
- function routeRelationship(
470
- rel: C4Relationship,
471
- index: number,
472
- boxes: Map<string, Placed>,
473
- shift: number,
474
- ): PositionedC4Relationship {
475
- const a = boxes.get(rel.from)!
476
- const b = boxes.get(rel.to)!
477
- const start = leave(a, centre(b))
478
- const end = leave(b, centre(a))
479
- const dx = end.x - start.x
480
- const dy = end.y - start.y
481
- const shifted = (p: Point): Point => ({ x: p.x, y: p.y + shift })
482
- const out: PositionedC4Relationship = {
483
- ...rel,
484
- points: [shifted(start), shifted(end)],
485
- }
486
- // Mermaid draws the first relationship straight and every other one as a
487
- // quadratic curve bent toward the start of the chord.
488
- if (index > 0) {
489
- out.curve = shifted({
490
- x: start.x + dx / 2 - dx / 4,
491
- y: start.y + dy / 2,
492
- })
493
- }
494
- if (c4RelLabelLines(rel).length > 0) {
495
- const mid = {
496
- x: Math.min(start.x, end.x) + Math.abs(dx) / 2,
497
- y: Math.min(start.y, end.y) + Math.abs(dy) / 2,
498
- }
499
- // Mermaid hands its text routine the chord midpoint as the block's left
500
- // edge and centres the text in the block's width, so a label starts at
501
- // the midpoint and runs along the line rather than sitting on it.
502
- const head = c4RelLabelLines(rel)[0]
503
- const hasHead = rel.label !== '' || rel.index !== undefined
504
- const labelWidth = hasHead
505
- ? c4TextWidth(head ?? '', C4.messageSize, 400)
506
- : 0
507
- out.labelPosition = shifted({ x: mid.x + labelWidth / 2, y: mid.y })
508
- if (rel.technology) {
509
- const techWidth = c4TextWidth(`[${rel.technology}]`, C4.messageSize, 400)
510
- out.technologyX = mid.x + Math.max(labelWidth, techWidth) / 2
511
- }
512
- }
513
- return out
514
- }
515
-
516
- /** A text line's box: centre x, centre y, width and height. */
517
- interface TextBox {
518
- cx: number
519
- cy: number
520
- w: number
521
- h: number
522
- }
523
-
524
- /** Clear space kept between a label and a shape or another label. */
525
- const LABEL_CLEARANCE = 2
526
- /** Extra space kept between a label and a shape, for the arrowhead. */
527
- const SHAPE_CLEARANCE = 6
528
- /** How far along the chord each trial position is from the last. */
529
- const LABEL_STEP = 2
530
-
531
- /** The boxes a relationship's label lines occupy, as the renderer draws them. */
532
- function labelBoxes(rel: PositionedC4Relationship, dx: number): TextBox[] {
533
- const pos = rel.labelPosition
534
- if (!pos) return []
535
- return c4RelLabelLines(rel).map((line, i, all) => {
536
- const isTech = rel.technology !== undefined && i === all.length - 1
537
- return {
538
- cx:
539
- (isTech && rel.technologyX !== undefined ? rel.technologyX : pos.x) +
540
- dx,
541
- cy: pos.y + (i === 0 ? 0 : C4.messageSize + 5),
542
- w: c4TextWidth(line, C4.messageSize, 400),
543
- h: C4.messageSize,
544
- }
545
- })
546
- }
547
-
548
- const boxesOverlap = (a: TextBox, b: TextBox): boolean =>
549
- Math.abs(a.cx - b.cx) < (a.w + b.w) / 2 + LABEL_CLEARANCE &&
550
- Math.abs(a.cy - b.cy) < (a.h + b.h) / 2 + LABEL_CLEARANCE
551
-
552
- /**
553
- * Mermaid starts a label at the chord's midpoint, so a label can land on a
554
- * shape the line passes beside or through (#1290). Slide it along the chord,
555
- * nearest the midpoint first, to the first spot clear of every shape and of
556
- * the labels already placed; if there is none, leave it where Mermaid does.
557
- */
558
- function clearLabel(
559
- rel: PositionedC4Relationship,
560
- shapes: TextBox[],
561
- placed: TextBox[],
562
- ): void {
563
- const start = rel.points[0]
564
- const end = rel.points[rel.points.length - 1]
565
- if (!rel.labelPosition || !start || !end) return
566
- const dx = end.x - start.x
567
- const dy = end.y - start.y
568
- const len = Math.hypot(dx, dy)
569
- if (len === 0) return
570
- const ux = dx / len
571
- const uy = dy / len
572
- const clear = (t: number): TextBox[] | undefined => {
573
- const boxes = labelBoxes(rel, t * ux).map((b) => ({
574
- ...b,
575
- cy: b.cy + t * uy,
576
- }))
577
- const hit = boxes.some((b) =>
578
- [...shapes, ...placed].some((o) => boxesOverlap(b, o)),
579
- )
580
- return hit ? undefined : boxes
581
- }
582
- for (let t = 0; t <= len / 2; t += LABEL_STEP) {
583
- for (const s of t === 0 ? [1] : [1, -1]) {
584
- const boxes = clear(s * t)
585
- if (!boxes) continue
586
- if (t !== 0) {
587
- rel.labelPosition = {
588
- x: rel.labelPosition.x + s * t * ux,
589
- y: rel.labelPosition.y + s * t * uy,
590
- }
591
- if (rel.technologyX !== undefined) rel.technologyX += s * t * ux
592
- }
593
- placed.push(...boxes)
594
- return
595
- }
596
- }
597
- placed.push(...labelBoxes(rel, 0))
598
- }
599
-
600
- /** Lay out a parsed C4 diagram the way Mermaid's C4 renderer does. */
601
- export function layoutC4DiagramSync(
602
- diagram: C4Diagram,
603
- _options: RenderOptions = {},
604
- ): PositionedC4Diagram {
605
- if (diagram.elements.length === 0 && diagram.boundaries.length === 0) {
606
- const empty: PositionedC4Diagram = {
607
- variant: diagram.variant,
608
- width: 0,
609
- height: 0,
610
- elements: [],
611
- boundaries: [],
612
- relationships: [],
613
- }
614
- if (diagram.title) empty.title = diagram.title
615
- return empty
616
- }
617
-
618
- const layout = layoutFrames(buildFrames(diagram))
619
- const titleExtra = diagram.title ? C4.titleExtra : 0
620
- // Mermaid's viewBox starts `diagramMarginY + titleExtra` above the content.
621
- const shift = C4.diagramMarginY + titleExtra
622
-
623
- const elements: PositionedC4Element[] = diagram.elements.flatMap((el) => {
624
- const m = layout.elements.get(el.alias)
625
- if (!m) return []
626
- return [
627
- {
628
- ...el,
629
- x: m.x,
630
- y: m.y + shift,
631
- width: m.width,
632
- height: m.height,
633
- nameLines: m.nameLines,
634
- descriptionLines: m.descriptionLines,
635
- },
636
- ]
637
- })
638
-
639
- // Outer boundaries first, so inner ones paint over them.
640
- const boundaries = [...layout.boundaries]
641
- .sort((p, q) => p.depth - q.depth)
642
- .map((b) => ({ ...b, y: b.y + shift }))
643
-
644
- const boxes = new Map<string, Placed>()
645
- for (const m of layout.elements.values()) boxes.set(m.el.alias, m)
646
- for (const b of layout.boundaries) boxes.set(b.alias, b)
647
- const relationships = diagram.relationships.map((r, i) =>
648
- routeRelationship(r, i, boxes, shift),
649
- )
650
- const shapeBoxes: TextBox[] = elements.map((e) => ({
651
- cx: e.x + e.width / 2,
652
- cy: e.y + e.height / 2,
653
- // Padded so a label also keeps off the arrowhead that ends a line on it.
654
- w: e.width + 2 * SHAPE_CLEARANCE,
655
- h: e.height + 2 * SHAPE_CLEARANCE,
656
- }))
657
- const placedLabels: TextBox[] = []
658
- for (const r of relationships) clearLabel(r, shapeBoxes, placedLabels)
659
-
660
- const width = layout.maxX + C4.diagramMarginX
661
- const height = layout.maxY + C4.diagramMarginY + titleExtra
662
- const positioned: PositionedC4Diagram = {
663
- variant: diagram.variant,
664
- width,
665
- height,
666
- elements,
667
- boundaries,
668
- relationships,
669
- }
670
- if (diagram.title) {
671
- positioned.title = diagram.title
672
- // Mermaid starts the title at `boxWidth / 2 - 4 * marginX`.
673
- const boxWidth = layout.maxX - C4.diagramMarginX
674
- positioned.titlePosition = {
675
- x: boxWidth / 2 - 4 * C4.diagramMarginX,
676
- y: C4.diagramMarginY + C4.diagramMarginY + shift,
677
- }
678
- }
679
- return positioned
680
- }