@meltemi/diagrams-layout 1.9.0 → 1.10.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/layout.js CHANGED
@@ -16,7 +16,7 @@ import { brandFrame } from "./frame.js";
16
16
  import { markGradient } from "./mark.js";
17
17
  import { strokeDash, } from "./scene.js";
18
18
  import { MIN_TEXT_MARGIN, TextMetrics, estimateMeasurer } from "./measure.js";
19
- import { CORNERED_SHAPES, SHAPE_SIZES, agentHeight, framedLines, isOutlined, onOutline, onPolygon, shapeOutline, shapeTextCenter, shapeTextSpan, shapeTextSpanner, turnPoint, turnedOutline, } from "./shapes.js";
19
+ import { CORNERED_SHAPES, FIXED_TEXT_SHAPES, boundaryToward, SHAPE_SIZES, agentHeight, framedLines, isOutlined, onOutline, onPolygon, shapeOutline, shapeTextCenter, shapeTextBand, shapeTextSpan, shapeTextSpanner, turnPoint, turnedOutline, } from "./shapes.js";
20
20
  import { StyleResolver } from "./styles.js";
21
21
  import { splitLines } from "./text.js";
22
22
  import { DEFAULT_THEME_NAME, THEMES, resolveTheme, } from "./theme.js";
@@ -38,6 +38,8 @@ export const DEFAULT_SIZES = {
38
38
  text: null,
39
39
  icon: { width: 48, height: 48 },
40
40
  image: null,
41
+ // A free line is the bounding box of its points.
42
+ line: null,
41
43
  // Sequence heads are measured by the sequence layout.
42
44
  participant: null,
43
45
  actor: null,
@@ -292,6 +294,47 @@ function boundsOf(points) {
292
294
  // A straight horizontal or vertical line still has an area to meet.
293
295
  return { x: x0 - 1, y: y0 - 1, width: x1 - x0 + 2, height: y1 - y0 + 2 };
294
296
  }
297
+ /** A free line's points in order: `from`, its bends, `to`. */
298
+ function linePointsOf(line) {
299
+ return [line.from, ...line.via, line.to];
300
+ }
301
+ /** The box of a free line: the bounding box of its points, at least 1 wide and 1 tall. */
302
+ function lineBox(points) {
303
+ const xs = points.map((p) => p.x);
304
+ const ys = points.map((p) => p.y);
305
+ const x = Math.min(...xs);
306
+ const y = Math.min(...ys);
307
+ return {
308
+ x,
309
+ y,
310
+ width: Math.max(1, Math.max(...xs) - x),
311
+ height: Math.max(1, Math.max(...ys) - y),
312
+ };
313
+ }
314
+ /** The side whose normal points most nearly from `from` towards `to`. */
315
+ function sideToward(from, to) {
316
+ const dx = to.x - from.x;
317
+ const dy = to.y - from.y;
318
+ if (Math.abs(dx) >= Math.abs(dy))
319
+ return dx >= 0 ? "east" : "west";
320
+ return dy >= 0 ? "south" : "north";
321
+ }
322
+ /**
323
+ * A free line's points with an elbow between consecutive points that differ on both axes, each
324
+ * leg leaving along the axis of the longer displacement: every segment horizontal or vertical.
325
+ */
326
+ function orthogonalThrough(points) {
327
+ const out = [points[0]];
328
+ for (let i = 1; i < points.length; i++) {
329
+ const a = points[i - 1];
330
+ const b = points[i];
331
+ if (a.x !== b.x && a.y !== b.y) {
332
+ out.push(Math.abs(b.x - a.x) >= Math.abs(b.y - a.y) ? { x: b.x, y: a.y } : { x: a.x, y: b.y });
333
+ }
334
+ out.push(b);
335
+ }
336
+ return out;
337
+ }
295
338
  const SIDES = ["north", "east", "south", "west"];
296
339
  const overlaps = (a, b) => Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x) > 0.5 &&
297
340
  Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y) > 0.5;
@@ -528,6 +571,7 @@ function iconDiagnostics(diagram, styles, sizing, lines, words, themeRange) {
528
571
  }
529
572
  }
530
573
  }
574
+ out.push(...verticalAlignDiagnostics(diagram, styles, sizing, lines, words, themeRange));
531
575
  for (const { range, element, used, placed, inherited } of labels.values()) {
532
576
  if (used)
533
577
  continue;
@@ -550,6 +594,68 @@ function iconDiagnostics(diagram, styles, sizing, lines, words, themeRange) {
550
594
  }
551
595
  return out;
552
596
  }
597
+ /**
598
+ * `layout.vertical-align-ignored` for a `verticalAlign` that no element it styles uses: a box whose
599
+ * shape draws its text at a fixed spot, a box whose icon is the centre piece (its label band is at
600
+ * `labelPosition`), or an element that is no box. Once per statement that writes the property, or
601
+ * once for a theme rule or class, on the diagram's `theme` (`themeRange`).
602
+ */
603
+ function verticalAlignDiagnostics(diagram, styles, sizing, lines, words, themeRange) {
604
+ const sources = new Map();
605
+ for (const element of diagram.elements) {
606
+ if (styles.element(element).verticalAlign === undefined)
607
+ continue;
608
+ const source = styleSource(diagram, element, "verticalAlign", lines, styles);
609
+ const box = element.kind === "box";
610
+ const fixed = box && FIXED_TEXT_SHAPES.has(sizing.shapeOf(element));
611
+ const centred = box && !fixed && sizing.centred(element);
612
+ const used = box && !fixed && !centred;
613
+ const why = used
614
+ ? undefined
615
+ : fixed
616
+ ? "fixed"
617
+ : centred
618
+ ? "centred"
619
+ : "notBox";
620
+ const known = sources.get(source.key);
621
+ if (known) {
622
+ known.used ||= used;
623
+ if (why)
624
+ known.why.add(why);
625
+ }
626
+ else {
627
+ sources.set(source.key, {
628
+ range: source.inherited ? themeRange : source.range,
629
+ element: element.id,
630
+ used,
631
+ why: new Set(why ? [why] : []),
632
+ inherited: source.inherited,
633
+ });
634
+ }
635
+ }
636
+ const out = [];
637
+ for (const { range, element, used, why, inherited } of sources.values()) {
638
+ if (used)
639
+ continue;
640
+ out.push({
641
+ code: "layout.vertical-align-ignored",
642
+ severity: "warning",
643
+ message: inherited
644
+ ? words.diagnostics.verticalAlignIgnoredByTheme(element)
645
+ : words.diagnostics.verticalAlignIgnored(element),
646
+ range,
647
+ hints: [
648
+ why.has("fixed")
649
+ ? words.hints.verticalAlignFixedShape
650
+ : why.has("centred")
651
+ ? words.hints.verticalAlignCentredIcon
652
+ : words.hints.verticalAlignNeverUsed,
653
+ ],
654
+ data: { ids: [element] },
655
+ });
656
+ }
657
+ return out;
658
+ }
553
659
  /** Lays out one compiled diagram into a scene. */
554
660
  function layoutDrawing(compilation, diagram, options = {}) {
555
661
  const diagnostics = [];
@@ -605,6 +711,15 @@ function layoutDrawing(compilation, diagram, options = {}) {
605
711
  break;
606
712
  }
607
713
  }
714
+ // Free lines: the bounding box of the points written in their statement is their place and
715
+ // size, in their parent's interior coordinates, so a group derives its size from them as from
716
+ // its boxes; the drawn points are found once the groups are placed.
717
+ const freeLines = diagram.elements.filter((e) => e.kind === "line" && e.line !== null);
718
+ for (const line of freeLines) {
719
+ const box = lineBox(linePointsOf(line.line));
720
+ places.set(line.id, boxCenter(box));
721
+ sizes.set(line.id, { width: box.width, height: box.height });
722
+ }
608
723
  const mode = options.layout ?? diagram.properties.layout;
609
724
  const free = diagram.type === "free";
610
725
  const unplaced = diagram.elements.filter((e) => e.kind !== "group" && !places.has(e.id));
@@ -706,8 +821,9 @@ function layoutDrawing(compilation, diagram, options = {}) {
706
821
  continue;
707
822
  const explicit = sizes.get(element.id);
708
823
  const size = explicit ?? sizing.defaultSize(element);
709
- if (explicit)
710
- sizing.checkOverflow(element, explicit, sizeEntries.get(element.id), diagnostics);
824
+ const entry = sizeEntries.get(element.id);
825
+ if (explicit && entry)
826
+ sizing.checkOverflow(element, explicit, entry, diagnostics);
711
827
  elementSizes.set(element.id, size);
712
828
  }
713
829
  // Turned elements: their angle, clockwise; 0 and 360 draw upright.
@@ -972,6 +1088,24 @@ function layoutDrawing(compilation, diagram, options = {}) {
972
1088
  }
973
1089
  }
974
1090
  }
1091
+ // Free lines: their written points moved by their parent's interior origin, in diagram
1092
+ // coordinates; their box is the bounding box of those points.
1093
+ const lineAnchors = new Map();
1094
+ for (const line of freeLines) {
1095
+ const origin = origins.get(line.id) ?? { x: 0, y: 0 };
1096
+ const points = linePointsOf(line.line).map((p) => ({ x: p.x + origin.x, y: p.y + origin.y }));
1097
+ lineAnchors.set(line.id, points);
1098
+ boxes.set(line.id, lineBox(points));
1099
+ if (points.every((p) => p.x === points[0].x && p.y === points[0].y)) {
1100
+ diagnostics.push({
1101
+ code: "layout.degenerate-line",
1102
+ severity: "warning",
1103
+ message: words.diagnostics.degenerateLine(line.id),
1104
+ range: line.range,
1105
+ hints: [words.hints.lineEnds],
1106
+ });
1107
+ }
1108
+ }
975
1109
  // The drawn shape of every element box, and the outline of those that are not their box; a
976
1110
  // turned element's outline is turned, and its drawn box is the bounding box of that outline.
977
1111
  const shapes = new Map();
@@ -979,7 +1113,7 @@ function layoutDrawing(compilation, diagram, options = {}) {
979
1113
  const turnedBoxes = new Map();
980
1114
  for (const element of diagram.elements) {
981
1115
  const box = boxes.get(element.id);
982
- if (element.kind === "group" || !box)
1116
+ if (element.kind === "group" || element.kind === "line" || !box)
983
1117
  continue;
984
1118
  const shape = element.kind === "tag" ? "pill" : sizing.shapeOf(element);
985
1119
  shapes.set(element.id, shape);
@@ -1014,7 +1148,7 @@ function layoutDrawing(compilation, diagram, options = {}) {
1014
1148
  * The boxes a connector should not cross: the other shapes of its layer, but not its ends, the
1015
1149
  * groups that hold them, nor a shape that contains an end box (a box it is stacked inside).
1016
1150
  */
1017
- const shapeIds = diagram.elements.filter((e) => e.kind !== "group" && e.kind !== "tag");
1151
+ const shapeIds = diagram.elements.filter((e) => e.kind !== "group" && e.kind !== "tag" && e.kind !== "line");
1018
1152
  const obstaclesFor = (relation) => {
1019
1153
  const ends = [relation.from, relation.to];
1020
1154
  const holders = new Set(ends.flatMap((id) => [id, ...(diagram.elementsById.get(id)?.groupPath ?? [])]));
@@ -1155,10 +1289,10 @@ function layoutDrawing(compilation, diagram, options = {}) {
1155
1289
  else {
1156
1290
  points = shapeRoute(kind, anchors.points, { first: anchors.points[0], last: anchors.points[anchors.points.length - 1], via }, anchors.fromSide, anchors.toSide);
1157
1291
  }
1158
- // The target's head is the style's or the theme's; the source's is the style's, else the
1159
- // target's for a two-way arrow, else none.
1160
- const endKind = style.arrowhead ?? theme.arrowhead;
1161
- const startKind = style.arrowtail ?? (relation.twoWay ? endKind : "none");
1292
+ // The target's head is the style's, else none for a line (`--`, `---`), else the theme's; the
1293
+ // source's is the style's, else the target's for a two-way arrow, else none.
1294
+ const endKind = style.arrowhead ?? (relation.heads === "none" ? "none" : theme.arrowhead);
1295
+ const startKind = style.arrowtail ?? (relation.heads === "both" ? endKind : "none");
1162
1296
  const headWidth = (theme.arrowSize * 7) / 8;
1163
1297
  // The labels icon shapes draw beside their ends: a connector passes under its own end's label
1164
1298
  // only straight from the icon and on beyond it, far enough for its head; elsewhere the label
@@ -1291,9 +1425,79 @@ function layoutDrawing(compilation, diagram, options = {}) {
1291
1425
  };
1292
1426
  return { relation, line, start: startHead, end: endHead, label, curve, connector };
1293
1427
  });
1428
+ // Free lines: connectors without ends, routed through their own points (`polyline` unless a
1429
+ // route kind is written; `auto` is `polyline`, there being no box to anchor to), with no lanes,
1430
+ // detours or gaps, their heads `none` unless written, and a pill as a relation's.
1431
+ const lineGeometry = new Map();
1432
+ for (const element of freeLines) {
1433
+ const anchors = lineAnchors.get(element.id);
1434
+ const style = styles.element(element);
1435
+ const written = style.routing ?? "polyline";
1436
+ const kind = written === "auto" ? "polyline" : written;
1437
+ const points = kind === "orthogonal" ? orthogonalThrough(anchors) : [...anchors];
1438
+ const last = points.length - 1;
1439
+ const fromSide = sideToward(points[0], points[Math.min(1, last)]);
1440
+ const toSide = sideToward(points[last], points[Math.max(0, last - 1)]);
1441
+ const endKind = style.arrowhead ?? "none";
1442
+ const startKind = style.arrowtail ?? "none";
1443
+ const headWidth = (theme.arrowSize * 7) / 8;
1444
+ let line;
1445
+ let start;
1446
+ let end;
1447
+ let curve;
1448
+ if (kind === "bezier" && points.length > 1) {
1449
+ const approach = (tip, side) => {
1450
+ const normal = sideNormal(side);
1451
+ return [{ x: tip.x + normal.x * 10, y: tip.y + normal.y * 10 }, tip];
1452
+ };
1453
+ const atEnd = endMarker(approach(points[last], toSide), endKind, theme.arrowSize, headWidth);
1454
+ const atStart = endMarker(approach(points[0], fromSide), startKind, theme.arrowSize, headWidth);
1455
+ const bezier = bezierThrough(points, fromSide, toSide, atEnd.line[atEnd.line.length - 1], atStart.line[atStart.line.length - 1]);
1456
+ line = bezier.samples;
1457
+ start = atStart.parts;
1458
+ end = atEnd.parts;
1459
+ curve = bezier.d;
1460
+ }
1461
+ else {
1462
+ const ends = relationEnds(points, startKind, endKind, theme.arrowSize, headWidth);
1463
+ line =
1464
+ (style.corners ?? theme.corners) === "rounded"
1465
+ ? roundCorners(ends.line, CORNER_RADIUS)
1466
+ : ends.line;
1467
+ start = ends.start;
1468
+ end = ends.end;
1469
+ }
1470
+ let label;
1471
+ const icon = sizing.lineIcon(element);
1472
+ if (element.label !== "" || icon) {
1473
+ const entry = labels.get(element.id);
1474
+ const at = entry ? entry.at / 100 : 0.5;
1475
+ const offset = entry ? entry.offset : { x: 0, y: 0 };
1476
+ const center = pointAlong(curve ? line : points, at);
1477
+ const text = element.label;
1478
+ const textWidth = text === "" ? 0 : metrics.width(text, "body", 500, scale.relationLabel);
1479
+ const iconWidth = icon ? icon.size + (text === "" ? 0 : icon.gap) : 0;
1480
+ const width = textWidth + iconWidth + scale.relationLabelPadding;
1481
+ label = {
1482
+ box: boxFromCenter({ x: center.x + offset.x, y: center.y + offset.y }, Math.max(width, scale.relationLabelHeight), scale.relationLabelHeight),
1483
+ text,
1484
+ };
1485
+ }
1486
+ lineGeometry.set(element.id, {
1487
+ element,
1488
+ line,
1489
+ start,
1490
+ end,
1491
+ label,
1492
+ curve,
1493
+ connector: { points, line, fromSide, toSide, via: anchors.slice(1, -1) },
1494
+ });
1495
+ }
1294
1496
  // Every element's box with the label it draws outside it, in diagram coordinates.
1295
1497
  const extents = new Map([...boxes].map(([id, box]) => [id, extentAt(id, box)]));
1296
- placeLabels(relationGeometry, movable, labelObstacles(diagram, extents, sizing));
1498
+ // A line's box is no obstacle: labels may sit beside it as beside any connector.
1499
+ const shapeExtents = new Map([...extents].filter(([id]) => !lineGeometry.has(id)));
1500
+ placeLabels(relationGeometry, movable, labelObstacles(diagram, shapeExtents, sizing));
1297
1501
  const elementLabels = new Map();
1298
1502
  for (const [id, box] of boxes) {
1299
1503
  const label = labelAt(id, box);
@@ -1306,6 +1510,9 @@ function layoutDrawing(compilation, diagram, options = {}) {
1306
1510
  for (const [id, box] of boxes)
1307
1511
  if (!rotations.has(id))
1308
1512
  checked.set(id, box);
1513
+ // Lines cross boxes by design: they take no part in the composition checks.
1514
+ for (const id of lineGeometry.keys())
1515
+ checked.delete(id);
1309
1516
  diagnostics.push(...checkComposition(diagram, checked, relationGeometry.map((g) => ({
1310
1517
  relation: g.relation,
1311
1518
  points: g.line,
@@ -1323,6 +1530,12 @@ function layoutDrawing(compilation, diagram, options = {}) {
1323
1530
  if (geometry.label)
1324
1531
  everything.push(geometry.label.box);
1325
1532
  }
1533
+ for (const geometry of lineGeometry.values()) {
1534
+ for (const p of [...markerPoints(geometry.start), ...markerPoints(geometry.end)])
1535
+ everything.push({ x: p.x, y: p.y, width: 0, height: 0 });
1536
+ if (geometry.label)
1537
+ everything.push(geometry.label.box);
1538
+ }
1326
1539
  const bounds = unionBoxes(everything) ?? { x: 0, y: 0, width: 0, height: 0 };
1327
1540
  const margin = theme.margin;
1328
1541
  const contentWidth = Math.ceil(bounds.width + 2 * margin);
@@ -1367,6 +1580,49 @@ function layoutDrawing(compilation, diagram, options = {}) {
1367
1580
  const c = boxCenter(box);
1368
1581
  return { ...group, transform: `rotate(${round(degrees)} ${round(c.x)} ${round(c.y)})` };
1369
1582
  };
1583
+ /**
1584
+ * An attached note's leader: a dotted polyline from where the segment between the two boxes'
1585
+ * centres leaves the note to where it enters its element's outline (turned with the element),
1586
+ * the last item of the note's group. None when the target is not drawn or the boxes overlap. A
1587
+ * turned note's group turns its items, so the leader is written in the note's own frame.
1588
+ */
1589
+ const withLeader = (group, element, box) => {
1590
+ const targetId = element.attachedTo;
1591
+ const target = targetId === null ? undefined : diagram.elementsById.get(targetId);
1592
+ const placed = target && target.id !== element.id ? boxes.get(target.id) : undefined;
1593
+ if (!target || !placed)
1594
+ return group;
1595
+ const targetBox = shift(placed);
1596
+ const overlaps = box.x < targetBox.x + targetBox.width &&
1597
+ targetBox.x < box.x + box.width &&
1598
+ box.y < targetBox.y + targetBox.height &&
1599
+ targetBox.y < box.y + box.height;
1600
+ if (overlaps)
1601
+ return group;
1602
+ const own = rotations.get(element.id) ?? 0;
1603
+ const turnedTarget = rotations.get(target.id) ?? 0;
1604
+ const shapeOf = (e) => e.kind === "tag" ? "pill" : e.kind === "group" ? "rectangle" : sizing.shapeOf(e);
1605
+ const centres = { note: boxCenter(box), target: boxCenter(targetBox) };
1606
+ const from = boundaryToward("rectangle", box, own, centres.target);
1607
+ const to = boundaryToward(shapeOf(target), targetBox, turnedTarget, centres.note, sizing.shapeOptions(target));
1608
+ const local = (p) => (own === 0 ? p : turnPoint(p, centres.note, -own));
1609
+ const leader = {
1610
+ kind: "polyline",
1611
+ points: [from, to].map(local).map((p) => ({ x: round(p.x), y: round(p.y) })),
1612
+ stroke: theme.muted,
1613
+ strokeWidth: theme.strokeWidth,
1614
+ dashed: "dotted",
1615
+ className: "leader",
1616
+ };
1617
+ return { ...group, items: [...group.items, leader] };
1618
+ };
1619
+ /** An element's drawing, turned when it is, with the leader of a note attached to something. */
1620
+ const elementGroup = (element, box) => {
1621
+ const drawn = turn(emitter.element(element, box), box);
1622
+ return element.kind === "note" && element.attachedTo !== null
1623
+ ? withLeader(drawn, element, box)
1624
+ : drawn;
1625
+ };
1370
1626
  if (free) {
1371
1627
  const byKey = new Map(relationGeometry.map((g) => [g.relation.key, g]));
1372
1628
  for (const paintable of paintOrder(diagram)) {
@@ -1377,9 +1633,14 @@ function layoutDrawing(compilation, diagram, options = {}) {
1377
1633
  continue;
1378
1634
  }
1379
1635
  const box = shift(boxes.get(paintable.element.id));
1636
+ const line = lineGeometry.get(paintable.element.id);
1637
+ if (line) {
1638
+ items.push(onLayer(emitter.line(line.element, box, line.line.map(shiftPoint), shiftMarker(line.start, shiftPoint), shiftMarker(line.end, shiftPoint), line.label ? { box: shift(line.label.box), text: line.label.text } : undefined, line.curve ? translatePath(line.curve, dx, dy) : undefined), paintable.element.layer));
1639
+ continue;
1640
+ }
1380
1641
  items.push(onLayer(paintable.kind === "group"
1381
1642
  ? emitter.group(paintable.element, box)
1382
- : turn(emitter.element(paintable.element, box), box), paintable.element.layer));
1643
+ : elementGroup(paintable.element, box), paintable.element.layer));
1383
1644
  }
1384
1645
  }
1385
1646
  else {
@@ -1402,7 +1663,7 @@ function layoutDrawing(compilation, diagram, options = {}) {
1402
1663
  if (element.kind === "group" || element.kind === "tag" || indexOf(element.layer) !== layer)
1403
1664
  continue;
1404
1665
  const box = shift(boxes.get(element.id));
1405
- items.push(onLayer(turn(emitter.element(element, box), box), element.layer));
1666
+ items.push(onLayer(elementGroup(element, box), element.layer));
1406
1667
  }
1407
1668
  for (const geometry of relationGeometry) {
1408
1669
  if (indexOf(geometry.relation.layer) !== layer)
@@ -1442,6 +1703,7 @@ function layoutDrawing(compilation, diagram, options = {}) {
1442
1703
  gradients: [...gradients, ...emitter.painter.gradients],
1443
1704
  patterns: emitter.patterns,
1444
1705
  items,
1706
+ ...(theme.sketch !== "none" ? { sketch: theme.sketch } : {}),
1445
1707
  };
1446
1708
  const labelBoxes = new Map();
1447
1709
  const connectors = new Map();
@@ -1450,6 +1712,12 @@ function layoutDrawing(compilation, diagram, options = {}) {
1450
1712
  labelBoxes.set(geometry.relation.key, geometry.label.box);
1451
1713
  connectors.set(geometry.relation.key, geometry.connector);
1452
1714
  }
1715
+ // Free lines by their id, beside the relations: their pill and their route.
1716
+ for (const [id, geometry] of lineGeometry) {
1717
+ if (geometry.label)
1718
+ labelBoxes.set(id, geometry.label.box);
1719
+ connectors.set(id, geometry.connector);
1720
+ }
1453
1721
  return {
1454
1722
  scene,
1455
1723
  diagnostics,
@@ -1541,6 +1809,14 @@ function ownLabelCrossings(points, labels, rooms, straight) {
1541
1809
  const route = beyondOwnLabels(points, labels, rooms, straight);
1542
1810
  return labels.filter((label) => label && crossedBoxes(route, [label.box]).length > 0).length;
1543
1811
  }
1812
+ /** Points rounded as the scene writes them. */
1813
+ function roundedPoints(list) {
1814
+ return list.map((p) => ({ x: round(p.x), y: round(p.y) }));
1815
+ }
1816
+ /** A circle of radius `r` about (`cx`, `cy`), as path data. */
1817
+ function circlePath(cx, cy, r) {
1818
+ return `M${cx - r} ${cy}a${r} ${r} 0 1 0 ${2 * r} 0a${r} ${r} 0 1 0 ${-2 * r} 0Z`;
1819
+ }
1544
1820
  function round(value) {
1545
1821
  return Math.round(value * 100) / 100;
1546
1822
  }
@@ -1691,8 +1967,36 @@ function arrangeRequest(diagram, sizes, fixedPlaces, routes, theme, headerOf, la
1691
1967
  },
1692
1968
  ];
1693
1969
  });
1970
+ // An attached note is arranged beside its element as a dashed relation from the element would
1971
+ // be (the element first, so the note follows it in the flow's direction): in `auto`, and for a
1972
+ // note that has no place in `assisted`. The leader is no relation, so the route the engine
1973
+ // answers for this edge is never read.
1974
+ const attached = diagram.elements.flatMap((note) => {
1975
+ const target = note.attachedTo;
1976
+ if (note.kind !== "note" || target === null || target === note.id)
1977
+ return [];
1978
+ if (fixedPlaces.has(note.id) || leftOut.has(note.id) || leftOut.has(target))
1979
+ return [];
1980
+ const element = diagram.elementsById.get(target);
1981
+ if (!element || element.kind === "line" || !sizes.has(target) || !sizes.has(note.id)) {
1982
+ return [];
1983
+ }
1984
+ return [
1985
+ {
1986
+ id: `attach${note.id}`,
1987
+ source: target,
1988
+ target: note.id,
1989
+ sourcePort: null,
1990
+ targetPort: null,
1991
+ primary: false,
1992
+ label: null,
1993
+ },
1994
+ ];
1995
+ });
1996
+ edges.push(...attached);
1997
+ // Free lines are drawn where their points are written: the engine never moves them.
1694
1998
  const nodes = diagram.elements
1695
- .filter((e) => e.kind !== "group")
1999
+ .filter((e) => e.kind !== "group" && e.kind !== "line")
1696
2000
  .map((element) => {
1697
2001
  const ports = element.ports.map((p) => ({
1698
2002
  id: p.name,
@@ -2061,6 +2365,8 @@ class Sizing {
2061
2365
  centredSizes = new Map();
2062
2366
  centredLayouts = new Map();
2063
2367
  centredLays = new Map();
2368
+ /** The centre of each aligned text block, by element and box. */
2369
+ textCenters = new Map();
2064
2370
  constructor(theme, scale, metrics, styles, icons, images,
2065
2371
  /** The written `size` of each element that has one. */
2066
2372
  written = new Map()) {
@@ -2122,6 +2428,62 @@ class Sizing {
2122
2428
  }
2123
2429
  return written === undefined ? {} : { radius: written };
2124
2430
  }
2431
+ /**
2432
+ * Where `verticalAlign` moves the text block of an element: `top` or `bottom` on a free box whose
2433
+ * shape has a text band and whose icon is not centred (its band sits at `labelPosition`), else
2434
+ * undefined, and the block stays at the shape's text centre.
2435
+ */
2436
+ verticalAlignOf(element) {
2437
+ if (element.kind !== "box")
2438
+ return undefined;
2439
+ const written = this.styles.element(element).verticalAlign;
2440
+ if (written !== "top" && written !== "bottom")
2441
+ return undefined;
2442
+ if (FIXED_TEXT_SHAPES.has(this.shapeOf(element)) || this.centred(element))
2443
+ return undefined;
2444
+ return written;
2445
+ }
2446
+ /**
2447
+ * The centre of an element's text block drawn in `box`: the shape's text centre, or with
2448
+ * `verticalAlign top` the first row band of the shape's text band, `padding` below its top edge,
2449
+ * whose span about the centre holds the block with the padding on each side (or as much as the
2450
+ * middle does), searched pixel by pixel towards the middle; `bottom` the same from the bottom
2451
+ * edge. A block that no band holds before the middle stays at the middle.
2452
+ */
2453
+ textCenter(element, box) {
2454
+ const shape = this.shapeOf(element);
2455
+ const options = this.shapeOptions(element);
2456
+ const center = shapeTextCenter(shape, box, options);
2457
+ const align = this.verticalAlignOf(element);
2458
+ if (!align)
2459
+ return center;
2460
+ const key = `${element.id}\u0000${box.x}\u0000${box.y}\u0000${box.width}\u0000${box.height}`;
2461
+ const known = this.textCenters.get(key);
2462
+ if (known)
2463
+ return known;
2464
+ const column = this.textColumn(element);
2465
+ const spanOf = shapeTextSpanner(shape, box, options);
2466
+ const band = shapeTextBand(shape, box, options);
2467
+ const padding = this.theme.padding;
2468
+ const widthAt = (y) => {
2469
+ const span = spanOf(y + column.slots.top, y + column.slots.bottom, center.x);
2470
+ return span ? 2 * Math.max(0, Math.min(center.x - span[0], span[1] - center.x)) : -1;
2471
+ };
2472
+ const need = Math.min(column.width + 2 * padding, Math.max(0, widthAt(center.y)));
2473
+ const direction = align === "top" ? 1 : -1;
2474
+ const start = align === "top"
2475
+ ? band.top + padding - column.slots.top
2476
+ : band.bottom - padding - column.slots.bottom;
2477
+ let found = center;
2478
+ for (let y = start; (center.y - y) * direction > 1e-9; y += direction) {
2479
+ if (widthAt(y) >= need - 1e-9) {
2480
+ found = { x: center.x, y };
2481
+ break;
2482
+ }
2483
+ }
2484
+ this.textCenters.set(key, found);
2485
+ return found;
2486
+ }
2125
2487
  /**
2126
2488
  * The rows the text column of a box-like element covers, from its text centre: the slots of its
2127
2489
  * lines and icon, and the same rows with room for the glyphs' descenders. `width` is the widest
@@ -2171,12 +2533,13 @@ class Sizing {
2171
2533
  * aligned text may lie. The glyph rows count as clipped only where the shape reaches more than
2172
2534
  * `ROW_TOLERANCE` into them.
2173
2535
  */
2174
- textArea(element, box, rows, column = this.textColumn(element), centred = true) {
2536
+ textArea(element, box, rows, column = this.textColumn(element), centred = true, aligned = true) {
2175
2537
  const shape = this.shapeOf(element);
2176
2538
  if (FULL_WIDTH_TEXT.has(shape))
2177
2539
  return box;
2178
2540
  const options = this.shapeOptions(element);
2179
- const center = shapeTextCenter(shape, box, options);
2541
+ // Where the block is drawn; the sizes of boxes are found about the middle.
2542
+ const center = aligned ? this.textCenter(element, box) : shapeTextCenter(shape, box, options);
2180
2543
  const top = center.y + column[rows].top;
2181
2544
  const bottom = center.y + column[rows].bottom;
2182
2545
  const inset = rows === "glyphs" ? Math.min(ROW_TOLERANCE, (bottom - top) / 2) : 0;
@@ -2214,7 +2577,8 @@ class Sizing {
2214
2577
  widthAt(element, height, margin, rows) {
2215
2578
  const column = this.textColumn(element);
2216
2579
  const need = column.width + 2 * margin;
2217
- const fits = (width) => this.textArea(element, { x: 0, y: 0, width, height }, rows, column).width >= need - 1e-9;
2580
+ const fits = (width) => this.textArea(element, { x: 0, y: 0, width, height }, rows, column, true, false).width >=
2581
+ need - 1e-9;
2218
2582
  let low = Math.max(1, need);
2219
2583
  if (fits(low))
2220
2584
  return Math.ceil(low - ROUNDING);
@@ -2242,9 +2606,23 @@ class Sizing {
2242
2606
  return groupHeaderOf(group, this.styles.element(group), this.theme);
2243
2607
  }
2244
2608
  /** Horizontal alignment of a box or text label. */
2609
+ /**
2610
+ * A note's font: the theme's note size at weight 400, or a free note's `fontSize` and
2611
+ * `fontWeight`; its lines are as far apart as the note scale's ratio of line to size.
2612
+ */
2613
+ noteFont(element) {
2614
+ const style = this.styles.element(element);
2615
+ const size = style.fontSize ?? this.scale.note;
2616
+ return {
2617
+ size,
2618
+ weight: style.fontWeight ?? 400,
2619
+ line: Math.round((size * this.scale.noteLine) / this.scale.note),
2620
+ };
2621
+ }
2245
2622
  alignOf(element) {
2246
- if (element.kind === "text")
2623
+ if (element.kind === "text" || element.kind === "note") {
2247
2624
  return this.styles.element(element).textAlign ?? "left";
2625
+ }
2248
2626
  if (element.kind === "box")
2249
2627
  return this.styles.element(element).textAlign ?? "center";
2250
2628
  return "center";
@@ -2297,7 +2675,7 @@ class Sizing {
2297
2675
  case "tag":
2298
2676
  return this.place(style.icon, Math.round(scale.tag * 1.3), "left");
2299
2677
  case "note":
2300
- return this.place(style.icon, Math.round(scale.note * 1.3), "left");
2678
+ return this.place(style.icon, Math.round(this.noteFont(element).size * 1.3), "left");
2301
2679
  }
2302
2680
  const icon = this.elementIcon(element);
2303
2681
  if (!icon)
@@ -2375,7 +2753,7 @@ class Sizing {
2375
2753
  const box = { x: 0, y: 0, ...size };
2376
2754
  if (!this.rowsFit(element, box, slots))
2377
2755
  return false;
2378
- const area = this.textArea(element, box, "slots", { ...column, slots });
2756
+ const area = this.textArea(element, box, "slots", { ...column, slots }, true, false);
2379
2757
  return area.width >= column.width + 2 * margin - 1e-9;
2380
2758
  }
2381
2759
  /** Whether rows about an element's text centre lie inside the text area of its shape in `box`. */
@@ -2691,6 +3069,10 @@ class Sizing {
2691
3069
  relationIcon(relation) {
2692
3070
  return this.place(this.styles.relation(relation).icon, Math.round(this.scale.relationLabel * 1.3), "left");
2693
3071
  }
3072
+ /** The icon of a free line's pill, as a relation's pill takes it. */
3073
+ lineIcon(line) {
3074
+ return this.place(this.styles.element(line).icon, Math.round(this.scale.relationLabel * 1.3), "left");
3075
+ }
2694
3076
  /**
2695
3077
  * Label and description positions of a box, with the icon above or below them when it sits
2696
3078
  * there. The label is broken at `\n` into lines `line` apart; a one-line label sits where it
@@ -2784,9 +3166,10 @@ class Sizing {
2784
3166
  };
2785
3167
  }
2786
3168
  case "note": {
3169
+ const font = this.noteFont(element);
2787
3170
  const best = widest(splitLines(element.label).map((line) => ({
2788
3171
  text: line,
2789
- width: metrics.width(line, "body", 400, scale.note),
3172
+ width: metrics.width(line, "body", font.weight, font.size),
2790
3173
  })));
2791
3174
  const width = Math.ceil(Math.max(best.width + beside, 1));
2792
3175
  return { text: best.text, width, minimum: width };
@@ -2856,7 +3239,7 @@ class Sizing {
2856
3239
  case "note":
2857
3240
  return {
2858
3241
  width: needs.width,
2859
- height: splitLines(element.label).length * scale.noteLine + 4,
3242
+ height: splitLines(element.label).length * this.noteFont(element).line + 4,
2860
3243
  };
2861
3244
  case "text":
2862
3245
  return {
@@ -3340,7 +3723,8 @@ class Emitter {
3340
3723
  this.lines(items, element, x, band.y - column.slots.top, this.sizing.plainBlock(element), align);
3341
3724
  return { kind: "group", id: element.id, role: "element", items };
3342
3725
  }
3343
- const textCenter = shapeTextCenter(shape, box, this.sizing.shapeOptions(element));
3726
+ // At the shape's text centre, or where `verticalAlign` moves the block.
3727
+ const textCenter = this.sizing.textCenter(element, box);
3344
3728
  const block = this.sizing.textBlock(element);
3345
3729
  // Aligned text keeps the padding inside the shape's text area.
3346
3730
  const area = this.sizing.textArea(element, box, "slots");
@@ -3422,7 +3806,7 @@ class Emitter {
3422
3806
  return { kind: "group", id: element.id, role: "element", items };
3423
3807
  }
3424
3808
  note(element, box) {
3425
- const { theme, scale } = this;
3809
+ const { theme } = this;
3426
3810
  const style = this.styles.element(element);
3427
3811
  const items = [];
3428
3812
  if (style.fill || style.stroke || style.pattern) {
@@ -3430,13 +3814,22 @@ class Emitter {
3430
3814
  items.push(outline, ...this.overlay(style, outline));
3431
3815
  }
3432
3816
  const color = style.color ?? theme.muted;
3817
+ const font = this.sizing.noteFont(element);
3433
3818
  const icon = this.sizing.iconOf(element);
3434
3819
  const indent = icon ? icon.size + icon.gap : 0;
3435
3820
  if (icon) {
3436
- items.push(this.icon(icon, box.x + icon.size / 2, box.y + scale.noteLine / 2, style.iconColor ?? color));
3821
+ items.push(this.icon(icon, box.x + icon.size / 2, box.y + font.line / 2, style.iconColor ?? color));
3437
3822
  }
3823
+ // The lines are aligned in the room right of the icon: at its edge, its middle or the box's.
3824
+ const align = this.sizing.alignOf(element);
3825
+ const x = align === "left"
3826
+ ? box.x + indent
3827
+ : align === "right"
3828
+ ? box.x + box.width
3829
+ : box.x + indent + (box.width - indent) / 2;
3830
+ const anchor = align === "left" ? "start" : align === "right" ? "end" : "middle";
3438
3831
  splitLines(element.label).forEach((line, i) => {
3439
- items.push(this.text(box.x + indent, box.y + scale.noteLine / 2 + i * scale.noteLine, line, scale.note, 400, color, "start"));
3832
+ items.push(this.text(x, box.y + font.line / 2 + i * font.line, line, font.size, font.weight, color, anchor));
3440
3833
  });
3441
3834
  return { kind: "group", id: element.id, role: "element", items };
3442
3835
  }
@@ -3462,25 +3855,55 @@ class Emitter {
3462
3855
  return { kind: "group", id: element.id, role: "element", items };
3463
3856
  }
3464
3857
  relation(relation, line, start, end, label, curve) {
3465
- const { theme, scale } = this;
3466
3858
  const style = this.styles.relation(relation);
3467
3859
  // An assumption is drawn dashed in the muted colour until a style says otherwise.
3468
3860
  const assumed = relation.assumption !== null;
3469
- const stroke = style.stroke ?? (assumed ? theme.muted : theme.connector);
3470
- const strokeWidth = style.width ?? theme.connectorWidth;
3471
- const dashed = strokeDash(style.line ?? (assumed ? "dashed" : relation.line));
3472
- const points = (list) => list.map((p) => ({ x: round(p.x), y: round(p.y) }));
3473
3861
  // A straight connector passes under the titles of groups it crosses (not its own ends'). It
3474
3862
  // is drawn from beyond the labels its own ends draw outside their boxes (see `underLabel`).
3475
3863
  const holes = this.titles
3476
3864
  .filter((t) => t.id !== relation.from && t.id !== relation.to)
3477
3865
  .map((t) => t.box);
3866
+ const items = this.connectorItems(relation.key, style, style.stroke ?? (assumed ? this.theme.muted : this.theme.connector), strokeDash(style.line ?? (assumed ? "dashed" : relation.line)), holes, line, start, end, label, this.sizing.relationIcon(relation), curve);
3867
+ return { kind: "group", id: relation.key, role: "relation", items, track: roundedPoints(line) };
3868
+ }
3869
+ /**
3870
+ * A free line: a connector without ends, drawn as a relation is (its line, its heads, its pill),
3871
+ * as an element group whose `track` is its drawn line and whose `bounds` is its box. A line whose
3872
+ * points coincide is a dot of its width.
3873
+ */
3874
+ line(element, box, line, start, end, label, curve) {
3875
+ const style = this.styles.element(element);
3876
+ const stroke = style.stroke ?? this.theme.connector;
3877
+ const strokeWidth = style.width ?? this.theme.connectorWidth;
3878
+ const first = line[0];
3879
+ const dot = first !== undefined && line.every((p) => p.x === first.x && p.y === first.y);
3880
+ const items = dot
3881
+ ? [
3882
+ {
3883
+ kind: "path",
3884
+ d: circlePath(round(first.x), round(first.y), Math.max(1, strokeWidth)),
3885
+ fill: stroke,
3886
+ stroke: "none",
3887
+ strokeWidth: 0,
3888
+ dashed: false,
3889
+ },
3890
+ ]
3891
+ : this.connectorItems(element.id, style, stroke, strokeDash(style.line ?? "solid"), [], line, start, end, label && { ...label, technology: null }, this.sizing.lineIcon(element), curve);
3892
+ return withBounds({ kind: "group", id: element.id, role: "element", items, track: roundedPoints(line) }, box);
3893
+ }
3894
+ /**
3895
+ * The items of a connector, in the order motion and hit tests expect: its line (pieces around
3896
+ * `holes`, or one curve), its heads, then its label pill with its icon and text.
3897
+ */
3898
+ connectorItems(id, style, stroke, dashed, holes, line, start, end, label, icon, curve) {
3899
+ const { theme, scale } = this;
3900
+ const strokeWidth = style.width ?? theme.connectorWidth;
3478
3901
  const pieces = curve ? [] : splitAround(line, holes);
3479
3902
  const items = curve
3480
3903
  ? [{ kind: "path", d: curve, fill: "none", stroke, strokeWidth, dashed }]
3481
3904
  : pieces.map((piece) => ({
3482
3905
  kind: "polyline",
3483
- points: points(piece),
3906
+ points: roundedPoints(piece),
3484
3907
  stroke,
3485
3908
  strokeWidth,
3486
3909
  dashed,
@@ -3498,7 +3921,6 @@ class Emitter {
3498
3921
  const pill = [
3499
3922
  this.rect(label.box, Math.min(label.box.height, scale.relationLabelHeight) / 2, fill, style.stroke ?? (style.fill ? style.fill : theme.line), theme.strokeWidth, false),
3500
3923
  ];
3501
- const icon = this.sizing.relationIcon(relation);
3502
3924
  if (icon && label.text === "") {
3503
3925
  pill.push(this.icon(icon, center.x, center.y, style.iconColor ?? color));
3504
3926
  }
@@ -3515,9 +3937,9 @@ class Emitter {
3515
3937
  const y = label.text === "" && !icon ? box.y + box.height / 2 : center.y + techLine;
3516
3938
  pill.push(this.text(center.x, y, label.technology, techSize, 400, style.color ?? theme.muted));
3517
3939
  }
3518
- items.push({ kind: "group", id: relation.key, role: "label", items: pill });
3940
+ items.push({ kind: "group", id, role: "label", items: pill });
3519
3941
  }
3520
- return { kind: "group", id: relation.key, role: "relation", items, track: points(line) };
3942
+ return items;
3521
3943
  }
3522
3944
  /** The brand strip: hairline, mark, wordmark and diagram title, spanning the canvas. */
3523
3945
  frame(width, stripTop, title) {