@zombie-mermaid/svg-renderer 3.2.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +45 -41
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +34 -4
- package/dist/index.d.ts +34 -4
- package/dist/index.js +2310 -1115
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/c4-label-clearance-1290.test.ts +112 -0
- package/src/__tests__/c4-line-ends-1208.test.ts +52 -0
- package/src/__tests__/c4-mermaid-reference.test.ts +56 -17
- package/src/__tests__/c4-text-width-1210.test.ts +43 -0
- package/src/__tests__/class-component-order-1249.test.ts +40 -0
- package/src/__tests__/class-member-right-padding-1238.test.ts +43 -0
- package/src/__tests__/class-namespace-1196.test.ts +165 -0
- package/src/__tests__/class-namespace-sparse-elk-1196.test.ts +96 -0
- package/src/__tests__/class-visibility-markers-1237.test.ts +27 -0
- package/src/__tests__/er-attribute-right-padding-1262.test.ts +47 -0
- package/src/__tests__/er-default-direction-1250.test.ts +69 -0
- package/src/__tests__/er-edge-label-contrast-1244.test.ts +58 -0
- package/src/__tests__/sequence-label-clearance-1242.test.ts +162 -0
- package/src/__tests__/sequence-nested-activation-1241.test.ts +37 -0
- package/src/__tests__/xychart-x-label-overlap-1243.test.ts +62 -0
- package/src/c4/arial-widths.ts +59 -0
- package/src/c4/layout.ts +234 -44
- package/src/c4/metrics.ts +56 -30
- package/src/c4/renderer.ts +1 -1
- package/src/class/layout.ts +178 -13
- package/src/class/renderer.ts +31 -2
- package/src/er/layout.ts +20 -4
- package/src/er/renderer.ts +10 -1
- package/src/layout-engine/back-edges.ts +73 -0
- package/src/layout-engine/compound-flat.ts +916 -0
- package/src/layout-engine/elk-graph-builder.ts +5 -4
- package/src/layout-engine/from-elk.ts +7 -2
- package/src/layout-engine/inner-edges.ts +329 -0
- package/src/layout-engine/layout-hints.ts +27 -0
- package/src/layout-engine/to-elk.ts +170 -20
- package/src/layout-engine.ts +38 -1
- package/src/registry.ts +2 -2
- package/src/renderer.ts +26 -2
- package/src/sequence/layout.ts +72 -4
- package/src/sequence/renderer.ts +155 -15
- package/src/title-gaps.ts +146 -0
- package/src/xychart/layout.ts +31 -2
- package/src/xychart/renderer.ts +1 -0
package/src/c4/layout.ts
CHANGED
|
@@ -17,7 +17,9 @@ import type { Point, RenderOptions } from '@zombie-mermaid/core'
|
|
|
17
17
|
import {
|
|
18
18
|
C4,
|
|
19
19
|
C4_TEXT_WIDTH,
|
|
20
|
+
c4CylinderCap,
|
|
20
21
|
c4PersonGeometry,
|
|
22
|
+
c4QueueCap,
|
|
21
23
|
c4ShapeSize,
|
|
22
24
|
c4TextWidth,
|
|
23
25
|
wrapToWidth,
|
|
@@ -129,9 +131,9 @@ interface MeasuredElement extends Placed {
|
|
|
129
131
|
}
|
|
130
132
|
|
|
131
133
|
function measureElement(el: C4Element): MeasuredElement {
|
|
132
|
-
const nameLines = wrapToWidth(el.label, C4_TEXT_WIDTH, C4.nameSize
|
|
134
|
+
const nameLines = wrapToWidth(el.label, C4_TEXT_WIDTH, C4.nameSize)
|
|
133
135
|
const descriptionLines = el.description
|
|
134
|
-
? wrapToWidth(el.description, C4_TEXT_WIDTH, C4.descrSize
|
|
136
|
+
? wrapToWidth(el.description, C4_TEXT_WIDTH, C4.descrSize)
|
|
135
137
|
: []
|
|
136
138
|
const textWidth = Math.max(
|
|
137
139
|
...nameLines.map((l) => c4TextWidth(l, C4.nameSize, 700)),
|
|
@@ -313,55 +315,150 @@ function rectIntersect(box: Placed, toward: Point): Point {
|
|
|
313
315
|
}
|
|
314
316
|
|
|
315
317
|
/**
|
|
316
|
-
* Where
|
|
317
|
-
*
|
|
318
|
-
* a
|
|
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`.
|
|
319
322
|
*/
|
|
320
323
|
function personIntersect(box: Placed, toward: Point): Point {
|
|
321
|
-
const g = c4PersonGeometry(box.width)
|
|
324
|
+
const g = c4PersonGeometry(box.width, box.height)
|
|
322
325
|
const cx = box.x + box.width / 2
|
|
323
326
|
const cy = box.y + box.height / 2
|
|
324
|
-
const
|
|
325
|
-
const
|
|
326
|
-
const
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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 }
|
|
359
454
|
}
|
|
360
455
|
|
|
361
456
|
function leave(box: Placed, toward: Point): Point {
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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)
|
|
365
462
|
}
|
|
366
463
|
|
|
367
464
|
const centre = (b: Placed): Point => ({
|
|
@@ -416,6 +513,90 @@ function routeRelationship(
|
|
|
416
513
|
return out
|
|
417
514
|
}
|
|
418
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
|
+
|
|
419
600
|
/** Lay out a parsed C4 diagram the way Mermaid's C4 renderer does. */
|
|
420
601
|
export function layoutC4DiagramSync(
|
|
421
602
|
diagram: C4Diagram,
|
|
@@ -466,6 +647,15 @@ export function layoutC4DiagramSync(
|
|
|
466
647
|
const relationships = diagram.relationships.map((r, i) =>
|
|
467
648
|
routeRelationship(r, i, boxes, shift),
|
|
468
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)
|
|
469
659
|
|
|
470
660
|
const width = layout.maxX + C4.diagramMarginX
|
|
471
661
|
const height = layout.maxY + C4.diagramMarginY + titleExtra
|
package/src/c4/metrics.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { C4Element } from '@zombie-mermaid/mermaid-parser'
|
|
2
2
|
import { estimateTextWidth } from '../styles.ts'
|
|
3
|
+
import { arialAdvance } from './arial-widths.ts'
|
|
3
4
|
|
|
4
5
|
// ============================================================================
|
|
5
6
|
// C4 SVG geometry, shared by the layout (which sizes and places shapes) and
|
|
@@ -9,8 +10,8 @@ import { estimateTextWidth } from '../styles.ts'
|
|
|
9
10
|
// The numbers follow Mermaid 11.17's C4 renderer: its default `c4` config for
|
|
10
11
|
// spacing, and sizes fitted to what it draws for each shape (a fixed 216px
|
|
11
12
|
// width, 20px padding, a 14px name, a 0.75em type line and a 0.82em
|
|
12
|
-
// description). Text is measured
|
|
13
|
-
//
|
|
13
|
+
// description). Text is measured by summing Arial advances instead of in the
|
|
14
|
+
// browser, which matches the recorded Mermaid diagrams to a fraction of a pixel.
|
|
14
15
|
// ============================================================================
|
|
15
16
|
|
|
16
17
|
export const C4 = {
|
|
@@ -42,9 +43,8 @@ export const C4 = {
|
|
|
42
43
|
/** Each wrapped description line after the first (one `1.1em` row). */
|
|
43
44
|
descrExtraLine: 12.48,
|
|
44
45
|
/**
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
* name.
|
|
46
|
+
* For text outside printable ASCII, which has no Arial advance table:
|
|
47
|
+
* Arial is about 8% narrower than this package's width estimate (Inter).
|
|
48
48
|
*/
|
|
49
49
|
textScale: 0.922,
|
|
50
50
|
// Heights Mermaid measures for a boundary's heading text (fitted): the
|
|
@@ -60,14 +60,17 @@ export const C4 = {
|
|
|
60
60
|
cornerRadius: 12,
|
|
61
61
|
strokeWidth: 2,
|
|
62
62
|
/**
|
|
63
|
-
* Person proportions
|
|
64
|
-
*
|
|
63
|
+
* Person proportions, from Mermaid's person shape: the pill's corner is
|
|
64
|
+
* 17.7% of the width (at most 45% of the pill's height), the head's radius
|
|
65
|
+
* is 23% of the width clamped to 16..56, and the head overlaps the pill by
|
|
66
|
+
* 27% of its radius.
|
|
65
67
|
*/
|
|
66
|
-
personRx:
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
68
|
+
personRx: 0.177,
|
|
69
|
+
personRxMaxOfHeight: 0.45,
|
|
70
|
+
personHeadRatio: 0.23,
|
|
71
|
+
personHeadMin: 16,
|
|
72
|
+
personHeadMax: 56,
|
|
73
|
+
personHeadOverlap: 0.27,
|
|
71
74
|
/** Cylinder height lost where its text block meets the lower ellipse. */
|
|
72
75
|
dbTrim: 3.91,
|
|
73
76
|
/** Queue height over its text block. */
|
|
@@ -75,27 +78,27 @@ export const C4 = {
|
|
|
75
78
|
} as const
|
|
76
79
|
|
|
77
80
|
/**
|
|
78
|
-
* Width the text of an element wraps to
|
|
79
|
-
*
|
|
81
|
+
* Width the text of an element wraps to: the shape's width less its padding
|
|
82
|
+
* on both sides (Mermaid's `c4LabelHelper`).
|
|
80
83
|
*/
|
|
81
|
-
export const C4_TEXT_WIDTH = C4.width - C4.shapePadding
|
|
84
|
+
export const C4_TEXT_WIDTH = C4.width - 2 * C4.shapePadding
|
|
82
85
|
|
|
83
|
-
/**
|
|
86
|
+
/**
|
|
87
|
+
* Greedy word wrap by estimated pixel width. Mermaid decides where to break
|
|
88
|
+
* with the text at regular weight and draws a name in bold afterwards, so a
|
|
89
|
+
* bold name can come out wider than the limit.
|
|
90
|
+
*/
|
|
84
91
|
export function wrapToWidth(
|
|
85
92
|
text: string,
|
|
86
93
|
maxWidth: number,
|
|
87
94
|
fontSize: number,
|
|
88
|
-
weight: number,
|
|
89
95
|
): string[] {
|
|
90
96
|
const lines: string[] = []
|
|
91
97
|
for (const paragraph of text.split('\n')) {
|
|
92
98
|
let cur = ''
|
|
93
99
|
for (const word of paragraph.split(/\s+/).filter(Boolean)) {
|
|
94
100
|
const next = cur ? `${cur} ${word}` : word
|
|
95
|
-
if (
|
|
96
|
-
cur &&
|
|
97
|
-
estimateTextWidth(next, fontSize, weight) * C4.textScale > maxWidth
|
|
98
|
-
) {
|
|
101
|
+
if (cur && c4TextWidth(next, fontSize, 400) > maxWidth) {
|
|
99
102
|
lines.push(cur)
|
|
100
103
|
cur = word
|
|
101
104
|
} else {
|
|
@@ -107,18 +110,29 @@ export function wrapToWidth(
|
|
|
107
110
|
return lines
|
|
108
111
|
}
|
|
109
112
|
|
|
110
|
-
/**
|
|
111
|
-
|
|
113
|
+
/**
|
|
114
|
+
* A person's pill corner, head radius and pill top for a box of this width
|
|
115
|
+
* (and, when known, this height: a short pill gets smaller corners).
|
|
116
|
+
*/
|
|
117
|
+
export function c4PersonGeometry(
|
|
118
|
+
width: number,
|
|
119
|
+
height?: number,
|
|
120
|
+
): {
|
|
112
121
|
rx: number
|
|
113
122
|
headRadius: number
|
|
114
123
|
pillTop: number
|
|
115
124
|
} {
|
|
116
|
-
const
|
|
125
|
+
const headRadius = Math.min(
|
|
126
|
+
Math.max(width * C4.personHeadRatio, C4.personHeadMin),
|
|
127
|
+
C4.personHeadMax,
|
|
128
|
+
)
|
|
129
|
+
// The head's diameter less the part that overlaps the pill.
|
|
130
|
+
const pillTop = headRadius * (2 - C4.personHeadOverlap)
|
|
131
|
+
const pillHeight = height === undefined ? Infinity : height - pillTop
|
|
117
132
|
return {
|
|
118
|
-
rx: C4.personRx *
|
|
119
|
-
headRadius
|
|
120
|
-
pillTop
|
|
121
|
-
C4.personPillTop + C4.personPillTopPerExtraWidth * (width - C4.width),
|
|
133
|
+
rx: Math.min(width * C4.personRx, pillHeight * C4.personRxMaxOfHeight),
|
|
134
|
+
headRadius,
|
|
135
|
+
pillTop,
|
|
122
136
|
}
|
|
123
137
|
}
|
|
124
138
|
|
|
@@ -158,13 +172,25 @@ export function c4QueueCap(height: number): { rx: number; ry: number } {
|
|
|
158
172
|
return { rx: ry / (2.5 + height / 50), ry }
|
|
159
173
|
}
|
|
160
174
|
|
|
161
|
-
/**
|
|
175
|
+
/**
|
|
176
|
+
* Estimated width of a text line as Mermaid would measure it: sans-serif
|
|
177
|
+
* advances for printable ASCII, this package's own estimate (scaled to match)
|
|
178
|
+
* for anything else.
|
|
179
|
+
*/
|
|
162
180
|
export function c4TextWidth(
|
|
163
181
|
text: string,
|
|
164
182
|
fontSize: number,
|
|
165
183
|
weight: number,
|
|
166
184
|
): number {
|
|
167
|
-
|
|
185
|
+
let width = 0
|
|
186
|
+
for (const ch of text) {
|
|
187
|
+
const em = arialAdvance(ch, weight >= 600)
|
|
188
|
+
width +=
|
|
189
|
+
em === undefined
|
|
190
|
+
? estimateTextWidth(ch, fontSize, weight) * C4.textScale
|
|
191
|
+
: em * fontSize
|
|
192
|
+
}
|
|
193
|
+
return width
|
|
168
194
|
}
|
|
169
195
|
|
|
170
196
|
/**
|
package/src/c4/renderer.ts
CHANGED
|
@@ -252,7 +252,7 @@ function renderElement(el: PositionedC4Element): string {
|
|
|
252
252
|
/** A rounded pill with a round head rising out of its top, as Mermaid draws a person. */
|
|
253
253
|
function personShape(el: PositionedC4Element, pal: C4Palette): string[] {
|
|
254
254
|
const { x, width: w } = el
|
|
255
|
-
const { rx, headRadius, pillTop: rise } = c4PersonGeometry(w)
|
|
255
|
+
const { rx, headRadius, pillTop: rise } = c4PersonGeometry(w, el.height)
|
|
256
256
|
const y = el.y
|
|
257
257
|
const cx = x + w / 2
|
|
258
258
|
const pillTop = y + rise
|