@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/audit.d.ts.map +1 -1
- package/dist/audit.js +6 -3
- package/dist/audit.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/layout.d.ts +2 -1
- package/dist/layout.d.ts.map +1 -1
- package/dist/layout.js +456 -34
- package/dist/layout.js.map +1 -1
- package/dist/messages/en.d.ts +14 -0
- package/dist/messages/en.d.ts.map +1 -1
- package/dist/messages/en.js +14 -0
- package/dist/messages/en.js.map +1 -1
- package/dist/messages/es.d.ts.map +1 -1
- package/dist/messages/es.js +7 -0
- package/dist/messages/es.js.map +1 -1
- package/dist/messages/index.d.ts +7 -0
- package/dist/messages/index.d.ts.map +1 -1
- package/dist/scene.d.ts +5 -0
- package/dist/scene.d.ts.map +1 -1
- package/dist/sequence.d.ts.map +1 -1
- package/dist/sequence.js +74 -17
- package/dist/sequence.js.map +1 -1
- package/dist/shapes.d.ts +19 -0
- package/dist/shapes.d.ts.map +1 -1
- package/dist/shapes.js +58 -0
- package/dist/shapes.js.map +1 -1
- package/dist/styles.d.ts +2 -0
- package/dist/styles.d.ts.map +1 -1
- package/dist/styles.js +4 -0
- package/dist/styles.js.map +1 -1
- package/dist/theme.d.ts +8 -1
- package/dist/theme.d.ts.map +1 -1
- package/dist/theme.js +49 -30
- package/dist/theme.js.map +1 -1
- package/package.json +6 -6
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
|
-
|
|
710
|
-
|
|
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
|
|
1159
|
-
// target's for a two-way arrow, else none.
|
|
1160
|
-
const endKind = style.arrowhead ?? theme.arrowhead;
|
|
1161
|
-
const startKind = style.arrowtail ?? (relation.
|
|
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
|
-
|
|
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
|
-
:
|
|
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(
|
|
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
|
-
|
|
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 >=
|
|
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(
|
|
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",
|
|
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 *
|
|
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
|
-
|
|
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
|
|
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 +
|
|
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(
|
|
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:
|
|
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
|
|
3940
|
+
items.push({ kind: "group", id, role: "label", items: pill });
|
|
3519
3941
|
}
|
|
3520
|
-
return
|
|
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) {
|