@zombie-mermaid/svg-renderer 3.1.0 → 3.2.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.
@@ -0,0 +1,490 @@
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
+ c4PersonGeometry,
21
+ c4ShapeSize,
22
+ c4TextWidth,
23
+ wrapToWidth,
24
+ } from './metrics.ts'
25
+
26
+ // ============================================================================
27
+ // C4 layout, following Mermaid's own C4 renderer.
28
+ //
29
+ // Mermaid does not run a graph layout for C4. It places shapes in rows, in
30
+ // the order they are declared: up to `c4ShapeInRow` per row, boundaries
31
+ // `c4BoundaryInRow` to a row, each boundary wide enough for what it holds.
32
+ // Relationships are drawn afterwards between the shapes and never move
33
+ // anything, so `Rel_U/D/L/R` (and a render direction) have no effect on
34
+ // placement, as in Mermaid. This is a port of that algorithm
35
+ // (packages/mermaid/src/diagrams/c4/c4Renderer.ts), with text measured by
36
+ // estimate instead of in a browser.
37
+ // ============================================================================
38
+
39
+ /** Mermaid's `Bounds`: the extent of what has been placed in one boundary. */
40
+ interface Bounds {
41
+ startx?: number
42
+ stopx?: number
43
+ starty?: number
44
+ stopy?: number
45
+ widthLimit?: number
46
+ /** Where the next shape would go (Mermaid's `nextData`). */
47
+ next: {
48
+ startx?: number
49
+ stopx?: number
50
+ starty?: number
51
+ stopy?: number
52
+ cnt: number
53
+ }
54
+ }
55
+
56
+ function newBounds(): Bounds {
57
+ return { next: { cnt: 0 } }
58
+ }
59
+
60
+ function setData(
61
+ b: Bounds,
62
+ startx: number,
63
+ stopx: number,
64
+ starty: number,
65
+ stopy: number,
66
+ ): void {
67
+ b.next.startx = b.startx = startx
68
+ b.next.stopx = b.stopx = stopx
69
+ b.next.starty = b.starty = starty
70
+ b.next.stopy = b.stopy = stopy
71
+ }
72
+
73
+ function grow(
74
+ obj: Bounds | Bounds['next'],
75
+ key: 'startx' | 'stopx' | 'starty' | 'stopy',
76
+ val: number,
77
+ pick: (a: number, b: number) => number,
78
+ ): void {
79
+ const cur = obj[key]
80
+ obj[key] = cur === undefined ? val : pick(val, cur)
81
+ }
82
+
83
+ /** Mermaid's `Bounds.insert`: give a shape the next free slot. */
84
+ function insert(b: Bounds, shape: Placed): void {
85
+ b.next.cnt += 1
86
+ const margin = C4.shapeMargin
87
+ let startx =
88
+ b.next.startx === b.next.stopx
89
+ ? b.next.stopx! + margin
90
+ : b.next.stopx! + margin * 2
91
+ let stopx = startx + shape.width
92
+ let starty = b.next.starty! + margin * 2
93
+ let stopy = starty + shape.height
94
+ if (
95
+ startx >= b.widthLimit! ||
96
+ stopx >= b.widthLimit! ||
97
+ b.next.cnt > C4.shapeInRow
98
+ ) {
99
+ startx = b.next.startx! + margin + C4.nextLinePaddingX
100
+ starty = b.next.stopy! + margin * 2
101
+ b.next.stopx = stopx = startx + shape.width
102
+ b.next.starty = b.next.stopy
103
+ b.next.stopy = stopy = starty + shape.height
104
+ b.next.cnt = 1
105
+ }
106
+ shape.x = startx
107
+ shape.y = starty
108
+ grow(b, 'startx', startx, Math.min)
109
+ grow(b, 'starty', starty, Math.min)
110
+ grow(b, 'stopx', stopx, Math.max)
111
+ grow(b, 'stopy', stopy, Math.max)
112
+ grow(b.next, 'startx', startx, Math.min)
113
+ grow(b.next, 'starty', starty, Math.min)
114
+ grow(b.next, 'stopx', stopx, Math.max)
115
+ grow(b.next, 'stopy', stopy, Math.max)
116
+ }
117
+
118
+ interface Placed {
119
+ x: number
120
+ y: number
121
+ width: number
122
+ height: number
123
+ }
124
+
125
+ interface MeasuredElement extends Placed {
126
+ el: C4Element
127
+ nameLines: string[]
128
+ descriptionLines: string[]
129
+ }
130
+
131
+ function measureElement(el: C4Element): MeasuredElement {
132
+ const nameLines = wrapToWidth(el.label, C4_TEXT_WIDTH, C4.nameSize, 700)
133
+ const descriptionLines = el.description
134
+ ? wrapToWidth(el.description, C4_TEXT_WIDTH, C4.descrSize, 400)
135
+ : []
136
+ const textWidth = Math.max(
137
+ ...nameLines.map((l) => c4TextWidth(l, C4.nameSize, 700)),
138
+ c4TextWidth(c4TypeLine(el), C4.typeSize, 400),
139
+ ...descriptionLines.map((l) => c4TextWidth(l, C4.descrSize, 400)),
140
+ )
141
+ const size = c4ShapeSize(
142
+ el,
143
+ nameLines.length,
144
+ descriptionLines.length,
145
+ textWidth,
146
+ )
147
+ return { el, nameLines, descriptionLines, x: 0, y: 0, ...size }
148
+ }
149
+
150
+ // A boundary as the layout walks it: its own shapes, then its children.
151
+ interface Frame {
152
+ alias: string
153
+ boundary?: C4Boundary
154
+ elements: C4Element[]
155
+ children: Frame[]
156
+ }
157
+
158
+ function buildFrames(diagram: C4Diagram): Frame {
159
+ const inBoundary = new Set<string>()
160
+ const byAlias = new Map(diagram.elements.map((e) => [e.alias, e]))
161
+ const frame = (b: C4Boundary): Frame => {
162
+ const elements = b.elementAliases.flatMap((a) => {
163
+ inBoundary.add(a)
164
+ const el = byAlias.get(a)
165
+ return el ? [el] : []
166
+ })
167
+ return {
168
+ alias: b.alias,
169
+ boundary: b,
170
+ elements,
171
+ children: b.children.map(frame),
172
+ }
173
+ }
174
+ const children = diagram.boundaries.map(frame)
175
+ return {
176
+ alias: 'global',
177
+ elements: diagram.elements.filter((e) => !inBoundary.has(e.alias)),
178
+ children,
179
+ }
180
+ }
181
+
182
+ interface LaidOutBoundary extends PositionedC4Boundary {
183
+ labelY: number
184
+ }
185
+
186
+ interface Layout {
187
+ elements: Map<string, MeasuredElement>
188
+ boundaries: LaidOutBoundary[]
189
+ maxX: number
190
+ maxY: number
191
+ }
192
+
193
+ function layoutFrames(root: Frame): Layout {
194
+ const elements = new Map<string, MeasuredElement>()
195
+ const boundaries: LaidOutBoundary[] = []
196
+ let maxX: number = C4.diagramMarginX
197
+ let maxY: number = C4.diagramMarginY
198
+
199
+ // Mermaid's `drawC4ShapeArray`: measure, insert each, then bump the margin.
200
+ const placeShapes = (bounds: Bounds, els: C4Element[]): void => {
201
+ for (const el of els) {
202
+ const m = measureElement(el)
203
+ elements.set(el.alias, m)
204
+ insert(bounds, m)
205
+ }
206
+ bounds.stopx! += C4.shapeMargin
207
+ bounds.stopy! += C4.shapeMargin
208
+ }
209
+
210
+ // Mermaid's `drawInsideBoundary`.
211
+ const inside = (parent: Bounds, frames: Frame[], depth: number): void => {
212
+ const bounds = newBounds()
213
+ bounds.widthLimit =
214
+ parent.widthLimit! / Math.min(C4.boundaryInRow, frames.length)
215
+ for (const [i, fr] of frames.entries()) {
216
+ const b = fr.boundary
217
+ // Height of the boundary's heading, which the shapes start below.
218
+ let Y = 0
219
+ const labelY = Y + 8
220
+ Y = labelY + C4.boundaryLabelHeight
221
+ let typeY: number | undefined
222
+ const typeLine = b ? c4BoundaryTypeLine(b) : undefined
223
+ if (typeLine) {
224
+ typeY = Y + 5
225
+ Y = typeY + C4.boundaryTypeHeight
226
+ }
227
+ let descrY: number | undefined
228
+ if (b?.description) {
229
+ descrY = Y + 20
230
+ Y = descrY + C4.boundaryDescrHeight
231
+ }
232
+ // The unnamed root keeps a fixed heading height.
233
+ if (!b) Y = C4.rootHeadingHeight
234
+
235
+ if (i === 0 || i % C4.boundaryInRow === 0) {
236
+ const x = parent.startx! + C4.diagramMarginX
237
+ const y = parent.stopy! + C4.diagramMarginY + Y
238
+ setData(bounds, x, x, y, y)
239
+ } else {
240
+ const x =
241
+ bounds.stopx !== bounds.startx
242
+ ? bounds.stopx! + C4.diagramMarginX
243
+ : bounds.startx!
244
+ setData(bounds, x, x, bounds.starty!, bounds.starty!)
245
+ }
246
+
247
+ if (fr.elements.length > 0) placeShapes(bounds, fr.elements)
248
+ if (fr.children.length > 0) inside(bounds, fr.children, depth + 1)
249
+
250
+ if (b) {
251
+ let width = bounds.stopx! - bounds.startx!
252
+ let height = bounds.stopy! - bounds.starty!
253
+ // A boundary with nothing in it still gets a visible frame.
254
+ if (width === 0) width = C4.width
255
+ if (height === 0) height = Y + C4.shapeMargin
256
+ boundaries.push({
257
+ alias: b.alias,
258
+ label: b.label,
259
+ ...(b.type ? { type: b.type } : {}),
260
+ ...(b.description ? { description: b.description } : {}),
261
+ x: bounds.startx!,
262
+ y: bounds.starty!,
263
+ width,
264
+ height,
265
+ depth,
266
+ labelY,
267
+ ...(typeY === undefined ? {} : { typeY }),
268
+ ...(descrY === undefined ? {} : { descrY }),
269
+ })
270
+ }
271
+ parent.stopy = Math.max(bounds.stopy! + C4.shapeMargin, parent.stopy!)
272
+ parent.stopx = Math.max(bounds.stopx! + C4.shapeMargin, parent.stopx!)
273
+ maxX = Math.max(maxX, parent.stopx)
274
+ maxY = Math.max(maxY, parent.stopy)
275
+ }
276
+ }
277
+
278
+ const screen = newBounds()
279
+ setData(
280
+ screen,
281
+ C4.diagramMarginX,
282
+ C4.diagramMarginX,
283
+ C4.diagramMarginY,
284
+ C4.diagramMarginY,
285
+ )
286
+ screen.widthLimit = C4.screenWidth
287
+ // The unnamed root is not drawn, so its children are the depth-0 boundaries.
288
+ inside(screen, [root], -1)
289
+ return { elements, boundaries, maxX, maxY }
290
+ }
291
+
292
+ /** Where the line from a box's centre toward `toward` leaves the box. */
293
+ function rectIntersect(box: Placed, toward: Point): Point {
294
+ const cx = box.x + box.width / 2
295
+ const cy = box.y + box.height / 2
296
+ const dx = toward.x - cx
297
+ const dy = toward.y - cy
298
+ const w = box.width / 2
299
+ const h = box.height / 2
300
+ if (dx === 0 && dy === 0) return { x: cx, y: cy }
301
+ let sx: number
302
+ let sy: number
303
+ if (Math.abs(dy) * w > Math.abs(dx) * h) {
304
+ const half = dy < 0 ? -h : h
305
+ sx = (half * dx) / dy
306
+ sy = half
307
+ } else {
308
+ const half = dx < 0 ? -w : w
309
+ sx = half
310
+ sy = (half * dy) / dx
311
+ }
312
+ return { x: cx + sx, y: cy + sy }
313
+ }
314
+
315
+ /**
316
+ * Where the line from a person's centre toward `toward` leaves the figure,
317
+ * which is a round head over a pill, not the box around both (Mermaid clips
318
+ * a relationship against what is drawn).
319
+ */
320
+ function personIntersect(box: Placed, toward: Point): Point {
321
+ const g = c4PersonGeometry(box.width)
322
+ const cx = box.x + box.width / 2
323
+ const cy = box.y + box.height / 2
324
+ const headY = box.y + g.headRadius
325
+ const pillTop = box.y + g.pillTop
326
+ const pillBottom = box.y + box.height
327
+ const inside = (x: number, y: number): boolean => {
328
+ if (Math.hypot(x - cx, y - headY) <= g.headRadius) return true
329
+ if (y < pillTop || y > pillBottom) return false
330
+ // Round the pill's ends: each is a circle of radius `rx` (clamped).
331
+ const r = Math.min(g.rx, (pillBottom - pillTop) / 2)
332
+ const left = box.x + r
333
+ const right = box.x + box.width - r
334
+ const nearY = Math.min(Math.max(y, pillTop + r), pillBottom - r)
335
+ const nearX = Math.min(Math.max(x, left), right)
336
+ return (
337
+ x >= box.x &&
338
+ x <= box.x + box.width &&
339
+ Math.hypot(x - nearX, y - nearY) <= r
340
+ )
341
+ }
342
+ const len = Math.hypot(toward.x - cx, toward.y - cy)
343
+ if (len === 0 || !inside(cx, cy)) return rectIntersect(box, toward)
344
+ const ux = (toward.x - cx) / len
345
+ const uy = (toward.y - cy) / len
346
+ const at = (t: number): boolean => inside(cx + ux * t, cy + uy * t)
347
+ let lo = 0
348
+ let hi = 0.5
349
+ while (at(hi) && hi < box.width + box.height) {
350
+ lo = hi
351
+ hi += 0.5
352
+ }
353
+ for (let i = 0; i < 30; i++) {
354
+ const mid = (lo + hi) / 2
355
+ if (at(mid)) lo = mid
356
+ else hi = mid
357
+ }
358
+ return { x: cx + ux * lo, y: cy + uy * lo }
359
+ }
360
+
361
+ function leave(box: Placed, toward: Point): Point {
362
+ return (box as Partial<MeasuredElement>).el?.kind === 'person'
363
+ ? personIntersect(box, toward)
364
+ : rectIntersect(box, toward)
365
+ }
366
+
367
+ const centre = (b: Placed): Point => ({
368
+ x: b.x + b.width / 2,
369
+ y: b.y + b.height / 2,
370
+ })
371
+
372
+ function routeRelationship(
373
+ rel: C4Relationship,
374
+ index: number,
375
+ boxes: Map<string, Placed>,
376
+ shift: number,
377
+ ): PositionedC4Relationship {
378
+ const a = boxes.get(rel.from)!
379
+ const b = boxes.get(rel.to)!
380
+ const start = leave(a, centre(b))
381
+ const end = leave(b, centre(a))
382
+ const dx = end.x - start.x
383
+ const dy = end.y - start.y
384
+ const shifted = (p: Point): Point => ({ x: p.x, y: p.y + shift })
385
+ const out: PositionedC4Relationship = {
386
+ ...rel,
387
+ points: [shifted(start), shifted(end)],
388
+ }
389
+ // Mermaid draws the first relationship straight and every other one as a
390
+ // quadratic curve bent toward the start of the chord.
391
+ if (index > 0) {
392
+ out.curve = shifted({
393
+ x: start.x + dx / 2 - dx / 4,
394
+ y: start.y + dy / 2,
395
+ })
396
+ }
397
+ if (c4RelLabelLines(rel).length > 0) {
398
+ const mid = {
399
+ x: Math.min(start.x, end.x) + Math.abs(dx) / 2,
400
+ y: Math.min(start.y, end.y) + Math.abs(dy) / 2,
401
+ }
402
+ // Mermaid hands its text routine the chord midpoint as the block's left
403
+ // edge and centres the text in the block's width, so a label starts at
404
+ // the midpoint and runs along the line rather than sitting on it.
405
+ const head = c4RelLabelLines(rel)[0]
406
+ const hasHead = rel.label !== '' || rel.index !== undefined
407
+ const labelWidth = hasHead
408
+ ? c4TextWidth(head ?? '', C4.messageSize, 400)
409
+ : 0
410
+ out.labelPosition = shifted({ x: mid.x + labelWidth / 2, y: mid.y })
411
+ if (rel.technology) {
412
+ const techWidth = c4TextWidth(`[${rel.technology}]`, C4.messageSize, 400)
413
+ out.technologyX = mid.x + Math.max(labelWidth, techWidth) / 2
414
+ }
415
+ }
416
+ return out
417
+ }
418
+
419
+ /** Lay out a parsed C4 diagram the way Mermaid's C4 renderer does. */
420
+ export function layoutC4DiagramSync(
421
+ diagram: C4Diagram,
422
+ _options: RenderOptions = {},
423
+ ): PositionedC4Diagram {
424
+ if (diagram.elements.length === 0 && diagram.boundaries.length === 0) {
425
+ const empty: PositionedC4Diagram = {
426
+ variant: diagram.variant,
427
+ width: 0,
428
+ height: 0,
429
+ elements: [],
430
+ boundaries: [],
431
+ relationships: [],
432
+ }
433
+ if (diagram.title) empty.title = diagram.title
434
+ return empty
435
+ }
436
+
437
+ const layout = layoutFrames(buildFrames(diagram))
438
+ const titleExtra = diagram.title ? C4.titleExtra : 0
439
+ // Mermaid's viewBox starts `diagramMarginY + titleExtra` above the content.
440
+ const shift = C4.diagramMarginY + titleExtra
441
+
442
+ const elements: PositionedC4Element[] = diagram.elements.flatMap((el) => {
443
+ const m = layout.elements.get(el.alias)
444
+ if (!m) return []
445
+ return [
446
+ {
447
+ ...el,
448
+ x: m.x,
449
+ y: m.y + shift,
450
+ width: m.width,
451
+ height: m.height,
452
+ nameLines: m.nameLines,
453
+ descriptionLines: m.descriptionLines,
454
+ },
455
+ ]
456
+ })
457
+
458
+ // Outer boundaries first, so inner ones paint over them.
459
+ const boundaries = [...layout.boundaries]
460
+ .sort((p, q) => p.depth - q.depth)
461
+ .map((b) => ({ ...b, y: b.y + shift }))
462
+
463
+ const boxes = new Map<string, Placed>()
464
+ for (const m of layout.elements.values()) boxes.set(m.el.alias, m)
465
+ for (const b of layout.boundaries) boxes.set(b.alias, b)
466
+ const relationships = diagram.relationships.map((r, i) =>
467
+ routeRelationship(r, i, boxes, shift),
468
+ )
469
+
470
+ const width = layout.maxX + C4.diagramMarginX
471
+ const height = layout.maxY + C4.diagramMarginY + titleExtra
472
+ const positioned: PositionedC4Diagram = {
473
+ variant: diagram.variant,
474
+ width,
475
+ height,
476
+ elements,
477
+ boundaries,
478
+ relationships,
479
+ }
480
+ if (diagram.title) {
481
+ positioned.title = diagram.title
482
+ // Mermaid starts the title at `boxWidth / 2 - 4 * marginX`.
483
+ const boxWidth = layout.maxX - C4.diagramMarginX
484
+ positioned.titlePosition = {
485
+ x: boxWidth / 2 - 4 * C4.diagramMarginX,
486
+ y: C4.diagramMarginY + C4.diagramMarginY + shift,
487
+ }
488
+ }
489
+ return positioned
490
+ }