@meltemi/diagrams-layout 1.9.0 → 1.10.17
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 +8 -6
- package/dist/audit.d.ts.map +1 -1
- package/dist/audit.js +34 -17
- package/dist/audit.js.map +1 -1
- package/dist/checks.d.ts +5 -1
- package/dist/checks.d.ts.map +1 -1
- package/dist/checks.js +11 -3
- package/dist/checks.js.map +1 -1
- package/dist/frame.d.ts +2 -2
- package/dist/frame.d.ts.map +1 -1
- package/dist/frame.js +4 -1
- package/dist/frame.js.map +1 -1
- package/dist/free.d.ts +50 -3
- package/dist/free.d.ts.map +1 -1
- package/dist/free.js +187 -32
- package/dist/free.js.map +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -4
- package/dist/index.js.map +1 -1
- package/dist/layout.d.ts +203 -6
- package/dist/layout.d.ts.map +1 -1
- package/dist/layout.js +1308 -138
- package/dist/layout.js.map +1 -1
- package/dist/measure.d.ts +21 -2
- package/dist/measure.d.ts.map +1 -1
- package/dist/measure.js +30 -7
- package/dist/measure.js.map +1 -1
- package/dist/messages/en.d.ts +37 -0
- package/dist/messages/en.d.ts.map +1 -1
- package/dist/messages/en.js +37 -0
- package/dist/messages/en.js.map +1 -1
- package/dist/messages/es.d.ts.map +1 -1
- package/dist/messages/es.js +14 -0
- package/dist/messages/es.js.map +1 -1
- package/dist/messages/index.d.ts +14 -0
- package/dist/messages/index.d.ts.map +1 -1
- package/dist/scene.d.ts +46 -1
- package/dist/scene.d.ts.map +1 -1
- package/dist/sequence.d.ts +1 -1
- package/dist/sequence.d.ts.map +1 -1
- package/dist/sequence.js +719 -286
- 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 +7 -1
- package/dist/styles.d.ts.map +1 -1
- package/dist/styles.js +22 -2
- package/dist/styles.js.map +1 -1
- package/dist/text.d.ts +8 -0
- package/dist/text.d.ts.map +1 -1
- package/dist/text.js +132 -0
- package/dist/text.js.map +1 -1
- package/dist/theme.d.ts +13 -1
- package/dist/theme.d.ts.map +1 -1
- package/dist/theme.js +69 -30
- package/dist/theme.js.map +1 -1
- package/package.json +6 -6
package/dist/layout.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { DEFAULT_LOCALE } from "@meltemi/diagrams-i18n";
|
|
2
2
|
import { defaultImageSize, } from "@meltemi/diagrams-assets";
|
|
3
|
-
import { ENTITY_TYPE_NAMES, ICON_SIZE_RANGE, MAIN_LAYER, takesRotation, } from "@meltemi/diagrams-model";
|
|
3
|
+
import { ENTITY_TYPE_NAMES, ICON_SIZE_RANGE, MAIN_LAYER, PEN_WIDTH_RANGE, takesRotation, } from "@meltemi/diagrams-model";
|
|
4
4
|
import { auditLabels } from "./audit.js";
|
|
5
5
|
import { drawingWords, messages } from "./messages/index.js";
|
|
6
6
|
import { checkComposition } from "./checks.js";
|
|
@@ -9,17 +9,29 @@ import { ENGINE_SPACING, } from "./engine.js";
|
|
|
9
9
|
import { LANE_SPACING, anchorPoints, avoidingRoute, crossedBoxes, bezierThrough, detourRoute, portPoint, shapeRoute, sideNormal, sideOfPoint, snapEnd, } from "./routing.js";
|
|
10
10
|
import { boxCenter, boxFromCenter, cylinderPaths, ellipsePath, expandBox, pointAlong, unionBoxes, } from "./geometry.js";
|
|
11
11
|
import { CORNER_RADIUS, endMarker, markerItems, markerPoints, markerReach, relationEnds, roundCorners, shiftMarker, } from "./markers.js";
|
|
12
|
-
import { freeFrames, groupHeaderOf, isUntitledGroup, paintOrder, parentOf } from "./free.js";
|
|
12
|
+
import { freeFrames, groupHeaderOf, isList, isUntitledGroup, listHolders, listPlaces, paintOrder, parentOf, } from "./free.js";
|
|
13
13
|
import { layoutSequence } from "./sequence.js";
|
|
14
14
|
import { IconPainter } from "./icons.js";
|
|
15
|
-
import { brandFrame } from "./frame.js";
|
|
15
|
+
import { brandFrame, textItem } from "./frame.js";
|
|
16
16
|
import { markGradient } from "./mark.js";
|
|
17
17
|
import { strokeDash, } from "./scene.js";
|
|
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";
|
|
18
|
+
import { MIN_TEXT_MARGIN, TextMetrics, estimateMeasurer, fontRole, textFont, writtenStack, } from "./measure.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
|
-
import { splitLines } from "./text.js";
|
|
21
|
+
import { splitLines, wrapLines } from "./text.js";
|
|
22
22
|
import { DEFAULT_THEME_NAME, THEMES, resolveTheme, } from "./theme.js";
|
|
23
|
+
/** The width of a pen stroke whose style writes none. */
|
|
24
|
+
export const DEFAULT_PEN_WIDTH = 3;
|
|
25
|
+
/** The smallest size a sticky's text shrinks to before it is clipped to its card. */
|
|
26
|
+
export const STICKY_MIN_FONT_SIZE = 8;
|
|
27
|
+
/** A sticky's default font size, as a factor of the theme's label size. */
|
|
28
|
+
const STICKY_FONT_SCALE = 1.2;
|
|
29
|
+
/** The weight of a sticky's text when its style writes no `fontWeight`. */
|
|
30
|
+
const STICKY_FONT_WEIGHT = 400;
|
|
31
|
+
/** The corner radius of a sticky's card when its style writes no `radius`. */
|
|
32
|
+
const STICKY_RADIUS = 2;
|
|
33
|
+
/** The shadow under a sticky's card: how far down it lies, its colour and its opacity. */
|
|
34
|
+
const STICKY_SHADOW = { offset: 3, color: "#000000", opacity: 0.15 };
|
|
23
35
|
/** Widest content canvas of `preset document` before `layout.too-large`. */
|
|
24
36
|
export const DOCUMENT_MAX_WIDTH = 1200;
|
|
25
37
|
/** The 16:9 canvas of `preset slide`. */
|
|
@@ -38,6 +50,11 @@ export const DEFAULT_SIZES = {
|
|
|
38
50
|
text: null,
|
|
39
51
|
icon: { width: 48, height: 48 },
|
|
40
52
|
image: null,
|
|
53
|
+
// A sticky is a square card that never grows for its text.
|
|
54
|
+
sticky: { width: 160, height: 160 },
|
|
55
|
+
// A free line is the bounding box of its points; a pen, of its points and its width.
|
|
56
|
+
line: null,
|
|
57
|
+
pen: null,
|
|
41
58
|
// Sequence heads are measured by the sequence layout.
|
|
42
59
|
participant: null,
|
|
43
60
|
actor: null,
|
|
@@ -164,6 +181,92 @@ export function typeScale(theme) {
|
|
|
164
181
|
frameMark: Math.round(theme.frameSize * 2),
|
|
165
182
|
};
|
|
166
183
|
}
|
|
184
|
+
/**
|
|
185
|
+
* The type scale of a target whose style writes `fontSize`: the theme's scale with the size of
|
|
186
|
+
* its kind and every measure derived from it, as the theme's token would give them: a label's
|
|
187
|
+
* description at the theme's ratio of description to label, a pill's height and padding, a tag's
|
|
188
|
+
* pill, a note's line (decision 0067). Without a written size, `scale` itself.
|
|
189
|
+
*/
|
|
190
|
+
export function writtenScale(theme, scale, kind, size) {
|
|
191
|
+
if (size === undefined)
|
|
192
|
+
return scale;
|
|
193
|
+
switch (kind) {
|
|
194
|
+
case "label":
|
|
195
|
+
return {
|
|
196
|
+
...scale,
|
|
197
|
+
...pick(typeScale({
|
|
198
|
+
...theme,
|
|
199
|
+
labelSize: size,
|
|
200
|
+
descriptionSize: Math.round((size * theme.descriptionSize) / theme.labelSize),
|
|
201
|
+
}), ["label", "description", "descriptionLine"]),
|
|
202
|
+
};
|
|
203
|
+
case "groupTitle":
|
|
204
|
+
return { ...scale, groupTitle: size };
|
|
205
|
+
case "note":
|
|
206
|
+
return { ...scale, ...pick(typeScale({ ...theme, noteSize: size }), ["note", "noteLine"]) };
|
|
207
|
+
case "tag":
|
|
208
|
+
return {
|
|
209
|
+
...scale,
|
|
210
|
+
...pick(typeScale({ ...theme, tagSize: size }), ["tag", "tagHeight", "tagPadding"]),
|
|
211
|
+
};
|
|
212
|
+
case "relationLabel":
|
|
213
|
+
return {
|
|
214
|
+
...scale,
|
|
215
|
+
...pick(typeScale({ ...theme, relationLabelSize: size }), [
|
|
216
|
+
"relationLabel",
|
|
217
|
+
"relationLabelHeight",
|
|
218
|
+
"relationLabelPadding",
|
|
219
|
+
]),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
function pick(value, keys) {
|
|
224
|
+
return Object.fromEntries(keys.map((key) => [key, value[key]]));
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* The text of a relation's or a free line's label pill from its resolved style: the theme's
|
|
228
|
+
* relation label size at weight 500 in the body font, or what the style writes.
|
|
229
|
+
*/
|
|
230
|
+
export function pillText(theme, scale, style) {
|
|
231
|
+
return {
|
|
232
|
+
scale: writtenScale(theme, scale, "relationLabel", style.fontSize),
|
|
233
|
+
weight: style.fontWeight ?? 500,
|
|
234
|
+
font: textFont(style.font),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* The width of a tag's pill: its text in the mono font at `tagSize`, weight 500, with `beside`
|
|
239
|
+
* more for an icon, plus `tagPadding`. A pill is `tagHeight` high. Flow tags and the tags of
|
|
240
|
+
* sequence messages and participants are measured alike.
|
|
241
|
+
*/
|
|
242
|
+
export function tagPillWidth(metrics, scale, text, beside = 0, face = DEFAULT_TAG_FACE) {
|
|
243
|
+
return Math.ceil(metrics.width(text, face.font, face.weight, scale.tag) + beside + scale.tagPadding);
|
|
244
|
+
}
|
|
245
|
+
const DEFAULT_TAG_FACE = { font: "mono", weight: 500 };
|
|
246
|
+
/**
|
|
247
|
+
* A tag's pill in `box`, in scene coordinates: its outline, as round as its shorter side allows
|
|
248
|
+
* (upright when the tag is taller than wide), and its text centred on it in the mono font at
|
|
249
|
+
* `tagSize`, weight 500. Flow tags and the tags of sequence messages and participants are drawn
|
|
250
|
+
* alike.
|
|
251
|
+
*/
|
|
252
|
+
export function tagPill(metrics, scale, box, text, paint, face = DEFAULT_TAG_FACE) {
|
|
253
|
+
const center = boxCenter(box);
|
|
254
|
+
return {
|
|
255
|
+
outline: {
|
|
256
|
+
kind: "rect",
|
|
257
|
+
x: round(box.x),
|
|
258
|
+
y: round(box.y),
|
|
259
|
+
width: round(box.width),
|
|
260
|
+
height: round(box.height),
|
|
261
|
+
radius: cornerRadius(Infinity, box),
|
|
262
|
+
fill: paint.fill,
|
|
263
|
+
stroke: paint.stroke,
|
|
264
|
+
strokeWidth: paint.strokeWidth,
|
|
265
|
+
dashed: paint.dashed,
|
|
266
|
+
},
|
|
267
|
+
label: textItem(metrics, center.x, center.y, text, scale.tag, face.weight, paint.color, "middle", face.font),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
167
270
|
/** Room kept between a group's title and the connectors that pass under it. */
|
|
168
271
|
const TITLE_GAP = 3;
|
|
169
272
|
/**
|
|
@@ -292,6 +395,118 @@ function boundsOf(points) {
|
|
|
292
395
|
// A straight horizontal or vertical line still has an area to meet.
|
|
293
396
|
return { x: x0 - 1, y: y0 - 1, width: x1 - x0 + 2, height: y1 - y0 + 2 };
|
|
294
397
|
}
|
|
398
|
+
/** A free line's points in order: `from`, its bends, `to`. */
|
|
399
|
+
function linePointsOf(line) {
|
|
400
|
+
return [line.from, ...line.via, line.to];
|
|
401
|
+
}
|
|
402
|
+
/** The box of a free line: the bounding box of its points, at least 1 wide and 1 tall. */
|
|
403
|
+
function lineBox(points) {
|
|
404
|
+
const { x, y, width, height } = penBox(points, 0);
|
|
405
|
+
return { x, y, width: Math.max(1, width), height: Math.max(1, height) };
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* The box of a pen stroke: the bounding box of its points grown by half its width on every side.
|
|
409
|
+
* The extremes are found in one loop: a stroke may hold more points than a call takes arguments.
|
|
410
|
+
*/
|
|
411
|
+
function penBox(points, width) {
|
|
412
|
+
let x0 = Infinity;
|
|
413
|
+
let y0 = Infinity;
|
|
414
|
+
let x1 = -Infinity;
|
|
415
|
+
let y1 = -Infinity;
|
|
416
|
+
for (const p of points) {
|
|
417
|
+
if (p.x < x0)
|
|
418
|
+
x0 = p.x;
|
|
419
|
+
if (p.y < y0)
|
|
420
|
+
y0 = p.y;
|
|
421
|
+
if (p.x > x1)
|
|
422
|
+
x1 = p.x;
|
|
423
|
+
if (p.y > y1)
|
|
424
|
+
y1 = p.y;
|
|
425
|
+
}
|
|
426
|
+
return {
|
|
427
|
+
x: x0 - width / 2,
|
|
428
|
+
y: y0 - width / 2,
|
|
429
|
+
width: x1 - x0 + width,
|
|
430
|
+
height: y1 - y0 + width,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
/** Whether every point of a stroke is its first: a dot. */
|
|
434
|
+
function isDot(points) {
|
|
435
|
+
const first = points[0];
|
|
436
|
+
return first !== undefined && points.every((p) => p.x === first.x && p.y === first.y);
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* A name as part of an XML id: letters, digits, `_`, `-` and `.` kept, anything else `_`. Ids of
|
|
440
|
+
* the language are already such names; the rule keeps a scene's ids valid whatever it is given.
|
|
441
|
+
*/
|
|
442
|
+
function xmlName(name) {
|
|
443
|
+
return name.replace(/[^A-Za-z0-9_.-]/g, "_");
|
|
444
|
+
}
|
|
445
|
+
/** A stroke's points without a point equal to the one before it, which make no segment. */
|
|
446
|
+
function withoutRepeats(points) {
|
|
447
|
+
const out = [];
|
|
448
|
+
for (const p of points) {
|
|
449
|
+
const last = out[out.length - 1];
|
|
450
|
+
if (last === undefined || last.x !== p.x || last.y !== p.y)
|
|
451
|
+
out.push(p);
|
|
452
|
+
}
|
|
453
|
+
return out;
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* The path of a pen stroke through its points, as the layout draws it: `M p0`, then for every
|
|
457
|
+
* inner point a quadratic curve to the midpoint between it and the next one, with the point as its
|
|
458
|
+
* control (`Q p_i mid(p_i, p_i+1)`), then `L p_last`; two points are one segment, and one point
|
|
459
|
+
* is `M p0 L p0`, which round caps draw as a dot. Numbers are rounded to hundredths, as the scene
|
|
460
|
+
* writes them. Hosts draw a stroke in progress with it, so it looks as it will be written.
|
|
461
|
+
*/
|
|
462
|
+
export function penPath(points) {
|
|
463
|
+
const at = (p) => `${round(p.x)} ${round(p.y)}`;
|
|
464
|
+
const first = points[0];
|
|
465
|
+
if (first === undefined)
|
|
466
|
+
return "";
|
|
467
|
+
let d = `M ${at(first)}`;
|
|
468
|
+
for (let i = 1; i < points.length - 1; i++) {
|
|
469
|
+
const p = points[i];
|
|
470
|
+
const next = points[i + 1];
|
|
471
|
+
d += ` Q ${at(p)} ${at({ x: (p.x + next.x) / 2, y: (p.y + next.y) / 2 })}`;
|
|
472
|
+
}
|
|
473
|
+
return `${d} L ${at(points[points.length - 1])}`;
|
|
474
|
+
}
|
|
475
|
+
/** A pen's stroke width: its style's, else `DEFAULT_PEN_WIDTH`, and never below one pixel. */
|
|
476
|
+
function penWidth(style) {
|
|
477
|
+
return Math.max(PEN_WIDTH_RANGE.min, style.width ?? DEFAULT_PEN_WIDTH);
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* The length of a pen's heads: the theme's arrow size, or three times the stroke's width for a
|
|
481
|
+
* thicker stroke, so the head stays wider than the stroke it ends.
|
|
482
|
+
*/
|
|
483
|
+
function penHeadLength(arrowSize, width) {
|
|
484
|
+
return Math.max(arrowSize, 3 * width);
|
|
485
|
+
}
|
|
486
|
+
/** The side whose normal points most nearly from `from` towards `to`. */
|
|
487
|
+
function sideToward(from, to) {
|
|
488
|
+
const dx = to.x - from.x;
|
|
489
|
+
const dy = to.y - from.y;
|
|
490
|
+
if (Math.abs(dx) >= Math.abs(dy))
|
|
491
|
+
return dx >= 0 ? "east" : "west";
|
|
492
|
+
return dy >= 0 ? "south" : "north";
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* A free line's points with an elbow between consecutive points that differ on both axes, each
|
|
496
|
+
* leg leaving along the axis of the longer displacement: every segment horizontal or vertical.
|
|
497
|
+
*/
|
|
498
|
+
function orthogonalThrough(points) {
|
|
499
|
+
const out = [points[0]];
|
|
500
|
+
for (let i = 1; i < points.length; i++) {
|
|
501
|
+
const a = points[i - 1];
|
|
502
|
+
const b = points[i];
|
|
503
|
+
if (a.x !== b.x && a.y !== b.y) {
|
|
504
|
+
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 });
|
|
505
|
+
}
|
|
506
|
+
out.push(b);
|
|
507
|
+
}
|
|
508
|
+
return out;
|
|
509
|
+
}
|
|
295
510
|
const SIDES = ["north", "east", "south", "west"];
|
|
296
511
|
const overlaps = (a, b) => Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x) > 0.5 &&
|
|
297
512
|
Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y) > 0.5;
|
|
@@ -528,6 +743,7 @@ function iconDiagnostics(diagram, styles, sizing, lines, words, themeRange) {
|
|
|
528
743
|
}
|
|
529
744
|
}
|
|
530
745
|
}
|
|
746
|
+
out.push(...verticalAlignDiagnostics(diagram, styles, sizing, lines, words, themeRange));
|
|
531
747
|
for (const { range, element, used, placed, inherited } of labels.values()) {
|
|
532
748
|
if (used)
|
|
533
749
|
continue;
|
|
@@ -550,6 +766,69 @@ function iconDiagnostics(diagram, styles, sizing, lines, words, themeRange) {
|
|
|
550
766
|
}
|
|
551
767
|
return out;
|
|
552
768
|
}
|
|
769
|
+
/**
|
|
770
|
+
* `layout.vertical-align-ignored` for a `verticalAlign` that no element it styles uses: a box whose
|
|
771
|
+
* shape draws its text at a fixed spot, a box whose icon is the centre piece (its label band is at
|
|
772
|
+
* `labelPosition`), or an element that is neither a box nor a sticky. Once per statement that
|
|
773
|
+
* writes the property, or once for a theme rule or class, on the diagram's `theme` (`themeRange`).
|
|
774
|
+
*/
|
|
775
|
+
function verticalAlignDiagnostics(diagram, styles, sizing, lines, words, themeRange) {
|
|
776
|
+
const sources = new Map();
|
|
777
|
+
for (const element of diagram.elements) {
|
|
778
|
+
if (styles.element(element).verticalAlign === undefined)
|
|
779
|
+
continue;
|
|
780
|
+
const source = styleSource(diagram, element, "verticalAlign", lines, styles);
|
|
781
|
+
const box = element.kind === "box";
|
|
782
|
+
const fixed = box && FIXED_TEXT_SHAPES.has(sizing.shapeOf(element));
|
|
783
|
+
const centred = box && !fixed && sizing.centred(element);
|
|
784
|
+
// A sticky's text block always moves within its card.
|
|
785
|
+
const used = (box && !fixed && !centred) || element.kind === "sticky";
|
|
786
|
+
const why = used
|
|
787
|
+
? undefined
|
|
788
|
+
: fixed
|
|
789
|
+
? "fixed"
|
|
790
|
+
: centred
|
|
791
|
+
? "centred"
|
|
792
|
+
: "notBox";
|
|
793
|
+
const known = sources.get(source.key);
|
|
794
|
+
if (known) {
|
|
795
|
+
known.used ||= used;
|
|
796
|
+
if (why)
|
|
797
|
+
known.why.add(why);
|
|
798
|
+
}
|
|
799
|
+
else {
|
|
800
|
+
sources.set(source.key, {
|
|
801
|
+
range: source.inherited ? themeRange : source.range,
|
|
802
|
+
element: element.id,
|
|
803
|
+
used,
|
|
804
|
+
why: new Set(why ? [why] : []),
|
|
805
|
+
inherited: source.inherited,
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
const out = [];
|
|
810
|
+
for (const { range, element, used, why, inherited } of sources.values()) {
|
|
811
|
+
if (used)
|
|
812
|
+
continue;
|
|
813
|
+
out.push({
|
|
814
|
+
code: "layout.vertical-align-ignored",
|
|
815
|
+
severity: "warning",
|
|
816
|
+
message: inherited
|
|
817
|
+
? words.diagnostics.verticalAlignIgnoredByTheme(element)
|
|
818
|
+
: words.diagnostics.verticalAlignIgnored(element),
|
|
819
|
+
range,
|
|
820
|
+
hints: [
|
|
821
|
+
why.has("fixed")
|
|
822
|
+
? words.hints.verticalAlignFixedShape
|
|
823
|
+
: why.has("centred")
|
|
824
|
+
? words.hints.verticalAlignCentredIcon
|
|
825
|
+
: words.hints.verticalAlignNeverUsed,
|
|
826
|
+
],
|
|
827
|
+
data: { ids: [element] },
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
return out;
|
|
831
|
+
}
|
|
553
832
|
/** Lays out one compiled diagram into a scene. */
|
|
554
833
|
function layoutDrawing(compilation, diagram, options = {}) {
|
|
555
834
|
const diagnostics = [];
|
|
@@ -560,7 +839,9 @@ function layoutDrawing(compilation, diagram, options = {}) {
|
|
|
560
839
|
const definitions = options.themes ?? compilation.document.themes;
|
|
561
840
|
const resolved = resolveTheme(written ?? options.defaultTheme ?? null, definitions);
|
|
562
841
|
// A default theme that does not resolve is reported where it is configured.
|
|
563
|
-
const
|
|
842
|
+
const known = resolved.known || written !== null ? resolved : resolveTheme(null, definitions);
|
|
843
|
+
// An export without the branding keeps the theme and leaves its frame out.
|
|
844
|
+
const theme = options.frame === "none" ? { ...known.theme, frame: "none" } : known.theme;
|
|
564
845
|
if (!resolved.known && written !== null) {
|
|
565
846
|
const fileThemes = definitions.map((t) => t.id).filter((id) => !id.includes("#"));
|
|
566
847
|
diagnostics.push({
|
|
@@ -605,18 +886,77 @@ function layoutDrawing(compilation, diagram, options = {}) {
|
|
|
605
886
|
break;
|
|
606
887
|
}
|
|
607
888
|
}
|
|
889
|
+
// Free lines: the bounding box of the points written in their statement is their place and
|
|
890
|
+
// size, in their parent's interior coordinates, so a group derives its size from them as from
|
|
891
|
+
// its boxes; the drawn points are found once the groups are placed.
|
|
892
|
+
const freeLines = diagram.elements.filter((e) => e.kind === "line" && e.line !== null);
|
|
893
|
+
for (const line of freeLines) {
|
|
894
|
+
const box = lineBox(linePointsOf(line.line));
|
|
895
|
+
places.set(line.id, boxCenter(box));
|
|
896
|
+
sizes.set(line.id, { width: box.width, height: box.height });
|
|
897
|
+
}
|
|
898
|
+
const styles = new StyleResolver(theme, diagram);
|
|
899
|
+
// Pen strokes likewise, their box grown by half their width: a group holds the whole stroke.
|
|
900
|
+
const freePens = diagram.elements.filter((e) => e.kind === "pen" && e.pen !== null);
|
|
901
|
+
for (const pen of freePens) {
|
|
902
|
+
const box = penBox(pen.pen.points, penWidth(styles.element(pen)));
|
|
903
|
+
places.set(pen.id, boxCenter(box));
|
|
904
|
+
sizes.set(pen.id, { width: box.width, height: box.height });
|
|
905
|
+
}
|
|
608
906
|
const mode = options.layout ?? diagram.properties.layout;
|
|
609
907
|
const free = diagram.type === "free";
|
|
610
|
-
|
|
908
|
+
// Lists (`layout column|row` on a free group) place their children, elements and groups, in
|
|
909
|
+
// order: a `place` on one is ignored and said so. What a list holds, at any depth, is held by
|
|
910
|
+
// the outermost list around it, which an engine arranges as one box.
|
|
911
|
+
const holders = free ? listHolders(diagram) : new Map();
|
|
912
|
+
const listed = new Set(diagram.elements
|
|
913
|
+
.filter((e) => {
|
|
914
|
+
const parent = parentOf(e);
|
|
915
|
+
const group = parent === null ? undefined : diagram.elementsById.get(parent);
|
|
916
|
+
return group !== undefined && isList(group) && listPlaces(e);
|
|
917
|
+
})
|
|
918
|
+
.map((e) => e.id));
|
|
919
|
+
for (const entry of diagram.placement) {
|
|
920
|
+
if (entry.statement !== "place" || !listed.has(entry.target))
|
|
921
|
+
continue;
|
|
922
|
+
places.delete(entry.target);
|
|
923
|
+
const list = parentOf(diagram.elementsById.get(entry.target));
|
|
924
|
+
diagnostics.push({
|
|
925
|
+
code: "layout.place-ignored",
|
|
926
|
+
severity: "warning",
|
|
927
|
+
message: words.diagnostics.placeIgnored(entry.target, list),
|
|
928
|
+
range: entry.range,
|
|
929
|
+
hints: [words.hints.placeIgnored(entry.target, list)],
|
|
930
|
+
data: { ids: [entry.target, list] },
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
// A list is one box of the arrangement; what it holds is placed by it, or by its own `place`.
|
|
934
|
+
const unplaced = diagram.elements.filter((e) => (e.kind !== "group" || isList(e)) && !holders.has(e.id) && !places.has(e.id));
|
|
611
935
|
// Places are ignored in `auto` only when an engine arranges the diagram.
|
|
612
936
|
const arranging = options.engine !== undefined &&
|
|
613
937
|
(mode === "auto" ? true : mode === "assisted" && unplaced.length > 0);
|
|
938
|
+
// What a group that is not a list holds inside a list keeps its written place in every mode:
|
|
939
|
+
// no engine arranges inside a list.
|
|
940
|
+
const unplacedInLists = diagram.elements.filter((e) => holders.has(e.id) && !listed.has(e.id) && listPlaces(e) && !places.has(e.id));
|
|
941
|
+
for (const element of unplacedInLists) {
|
|
942
|
+
const list = [...element.groupPath]
|
|
943
|
+
.reverse()
|
|
944
|
+
.find((id) => isList(diagram.elementsById.get(id)));
|
|
945
|
+
diagnostics.push({
|
|
946
|
+
code: "layout.missing-placement",
|
|
947
|
+
severity: "error",
|
|
948
|
+
message: words.diagnostics.unplacedInList(element.id, parentOf(element), list),
|
|
949
|
+
range: element.range,
|
|
950
|
+
hints: [words.hints.addPlace(element.id)],
|
|
951
|
+
});
|
|
952
|
+
}
|
|
614
953
|
// Free diagrams place children inside their group, so a group must be placed when its
|
|
615
954
|
// children are, and always when nothing is arranged.
|
|
616
955
|
if (free && !(arranging && mode === "auto")) {
|
|
617
956
|
const placedChild = new Set(diagram.elements.flatMap((e) => (places.has(e.id) ? [parentOf(e) ?? ""] : [])));
|
|
618
957
|
const missing = diagram.elements.filter((e) => e.kind === "group" &&
|
|
619
958
|
!places.has(e.id) &&
|
|
959
|
+
!holders.has(e.id) &&
|
|
620
960
|
(!arranging || placedChild.has(e.id) || unplaced.length === 0));
|
|
621
961
|
for (const group of missing) {
|
|
622
962
|
diagnostics.push({
|
|
@@ -633,7 +973,10 @@ function layoutDrawing(compilation, diagram, options = {}) {
|
|
|
633
973
|
}
|
|
634
974
|
if (unplaced.length > 0 && !arranging) {
|
|
635
975
|
if (mode === "manual") {
|
|
976
|
+
// An unplaced list is a group without a place, reported above.
|
|
636
977
|
for (const element of unplaced) {
|
|
978
|
+
if (element.kind === "group")
|
|
979
|
+
continue;
|
|
637
980
|
diagnostics.push({
|
|
638
981
|
code: "layout.missing-placement",
|
|
639
982
|
severity: "error",
|
|
@@ -654,6 +997,8 @@ function layoutDrawing(compilation, diagram, options = {}) {
|
|
|
654
997
|
}
|
|
655
998
|
return { scene: null, diagnostics };
|
|
656
999
|
}
|
|
1000
|
+
if (unplacedInLists.length > 0)
|
|
1001
|
+
return { scene: null, diagnostics };
|
|
657
1002
|
// A placed and sized group that holds nothing is a band: it stays where it is written, out of
|
|
658
1003
|
// the engine's request. In `auto` only bands at the top level, whose places are in diagram
|
|
659
1004
|
// coordinates, are kept.
|
|
@@ -666,8 +1011,9 @@ function layoutDrawing(compilation, diagram, options = {}) {
|
|
|
666
1011
|
childless(e))
|
|
667
1012
|
.map((e) => e.id));
|
|
668
1013
|
if (arranging && mode === "auto") {
|
|
1014
|
+
// What a list holds keeps its places: the engine arranges the list as one box.
|
|
669
1015
|
const ignored = diagram.placement
|
|
670
|
-
.filter((e) => (e.statement === "place" && !autoBands.has(e.target)) ||
|
|
1016
|
+
.filter((e) => (e.statement === "place" && !autoBands.has(e.target) && !holders.has(e.target)) ||
|
|
671
1017
|
(e.statement === "route" && e.via.length > 0))
|
|
672
1018
|
.sort((a, b) => a.range.start.offset - b.range.start.offset)[0];
|
|
673
1019
|
if (ignored) {
|
|
@@ -680,7 +1026,6 @@ function layoutDrawing(compilation, diagram, options = {}) {
|
|
|
680
1026
|
});
|
|
681
1027
|
}
|
|
682
1028
|
}
|
|
683
|
-
const styles = new StyleResolver(theme, diagram);
|
|
684
1029
|
const images = new Map();
|
|
685
1030
|
for (const element of diagram.elements) {
|
|
686
1031
|
if (element.kind !== "image" || element.source === null)
|
|
@@ -706,8 +1051,11 @@ function layoutDrawing(compilation, diagram, options = {}) {
|
|
|
706
1051
|
continue;
|
|
707
1052
|
const explicit = sizes.get(element.id);
|
|
708
1053
|
const size = explicit ?? sizing.defaultSize(element);
|
|
709
|
-
|
|
710
|
-
|
|
1054
|
+
const entry = sizeEntries.get(element.id);
|
|
1055
|
+
// Stickies are checked at the size they are drawn, which a list may stretch.
|
|
1056
|
+
if (element.kind !== "sticky" && explicit && entry) {
|
|
1057
|
+
sizing.checkOverflow(element, explicit, entry, diagnostics);
|
|
1058
|
+
}
|
|
711
1059
|
elementSizes.set(element.id, size);
|
|
712
1060
|
}
|
|
713
1061
|
// Turned elements: their angle, clockwise; 0 and 360 draw upright.
|
|
@@ -763,30 +1111,66 @@ function layoutDrawing(compilation, diagram, options = {}) {
|
|
|
763
1111
|
: [];
|
|
764
1112
|
return outlineBounds([...turnedAt(id, box), ...corners]);
|
|
765
1113
|
};
|
|
766
|
-
// Free diagrams: turn places relative to group interiors into diagram coordinates,
|
|
767
|
-
//
|
|
768
|
-
|
|
769
|
-
|
|
1114
|
+
// Free diagrams: turn places relative to group interiors into diagram coordinates, give every
|
|
1115
|
+
// placed group its written or derived size, and arrange the lists. In `auto` only what the lists
|
|
1116
|
+
// hold keeps its places: the engine arranges each outermost list as one box.
|
|
1117
|
+
const autoArranged = arranging && mode === "auto";
|
|
1118
|
+
let frames;
|
|
1119
|
+
if (free) {
|
|
1120
|
+
frames = freeFrames(diagram, autoArranged ? new Map([...places].filter(([id]) => holders.has(id))) : places, sizes, elementSizes, theme, (group) => sizing.textNeeds(group).width, (group) => sizing.headerOf(group), (id, size) => outside.has(id) || rotations.has(id)
|
|
770
1121
|
? extentAt(id, boxFromCenter({ x: 0, y: 0 }, size.width, size.height))
|
|
771
1122
|
: undefined);
|
|
772
|
-
|
|
773
|
-
for (const [id,
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
1123
|
+
// A stretched element is drawn at the size its list gives it, its outside label with it.
|
|
1124
|
+
for (const [id, size] of frames.stretched) {
|
|
1125
|
+
elementSizes.set(id, size);
|
|
1126
|
+
const label = sizing.outsideLabel(diagram.elementsById.get(id), { x: 0, y: 0, ...size });
|
|
1127
|
+
if (label)
|
|
1128
|
+
outside.set(id, label.box);
|
|
1129
|
+
}
|
|
1130
|
+
if (!autoArranged) {
|
|
1131
|
+
places.clear();
|
|
1132
|
+
for (const [id, center] of frames.centers)
|
|
1133
|
+
places.set(id, center);
|
|
1134
|
+
for (const [id, size] of frames.groupSizes)
|
|
1135
|
+
sizes.set(id, size);
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
// A sticky never grows: its text shrinks to fit its card, written, default or stretched, or is
|
|
1139
|
+
// reported.
|
|
1140
|
+
for (const element of diagram.elements) {
|
|
1141
|
+
if (element.kind !== "sticky")
|
|
1142
|
+
continue;
|
|
1143
|
+
const range = sizeEntries.get(element.id)?.range ?? element.range;
|
|
1144
|
+
sizing.checkSticky(element, elementSizes.get(element.id), range, diagnostics);
|
|
1145
|
+
}
|
|
1146
|
+
// The outermost lists an engine arranges, as boxes of their arranged size.
|
|
1147
|
+
const topLists = new Map();
|
|
1148
|
+
for (const element of diagram.elements) {
|
|
1149
|
+
const size = frames?.sizes.get(element.id);
|
|
1150
|
+
if (arranging && isList(element) && !holders.has(element.id) && size) {
|
|
1151
|
+
topLists.set(element.id, size);
|
|
1152
|
+
}
|
|
777
1153
|
}
|
|
1154
|
+
// The groups whose boxes follow the place an engine gave the list that holds them.
|
|
1155
|
+
const framedGroups = new Set();
|
|
778
1156
|
// Positions: placed, or arranged by the engine; fixed ones never move afterwards.
|
|
779
1157
|
const centers = new Map();
|
|
780
1158
|
const fixed = new Set();
|
|
781
1159
|
let engineRoutes = new Map();
|
|
782
1160
|
let engineGroups = new Map();
|
|
783
|
-
// `auto`: the written size of a group with children is the least size the engine gives it.
|
|
1161
|
+
// `auto`: the written size of a group with children is the least size the engine gives it. A
|
|
1162
|
+
// list's written size is already in its arranged one.
|
|
784
1163
|
const minimums = new Map();
|
|
785
|
-
if (
|
|
1164
|
+
if (autoArranged) {
|
|
786
1165
|
for (const element of diagram.elements) {
|
|
787
1166
|
const size = sizes.get(element.id);
|
|
788
|
-
if (element.kind === "group" &&
|
|
1167
|
+
if (element.kind === "group" &&
|
|
1168
|
+
size &&
|
|
1169
|
+
!childless(element) &&
|
|
1170
|
+
!isList(element) &&
|
|
1171
|
+
!holders.has(element.id)) {
|
|
789
1172
|
minimums.set(element.id, size);
|
|
1173
|
+
}
|
|
790
1174
|
}
|
|
791
1175
|
}
|
|
792
1176
|
if (arranging) {
|
|
@@ -802,6 +1186,13 @@ function layoutDrawing(compilation, diagram, options = {}) {
|
|
|
802
1186
|
// A turned element is arranged as its drawn box.
|
|
803
1187
|
const extents = new Map();
|
|
804
1188
|
const arranged = new Map(elementSizes);
|
|
1189
|
+
// A list is a box of its arranged size; a band stays out, as any band does.
|
|
1190
|
+
for (const [id, size] of topLists) {
|
|
1191
|
+
if (bands.has(id))
|
|
1192
|
+
topLists.delete(id);
|
|
1193
|
+
else
|
|
1194
|
+
arranged.set(id, size);
|
|
1195
|
+
}
|
|
805
1196
|
const shifts = new Map();
|
|
806
1197
|
for (const id of new Set([...outside.keys(), ...rotations.keys()])) {
|
|
807
1198
|
const size = elementSizes.get(id);
|
|
@@ -824,7 +1215,7 @@ function layoutDrawing(compilation, diagram, options = {}) {
|
|
|
824
1215
|
let arrangement;
|
|
825
1216
|
const request = (mirrored) => arrangeRequest(diagram, arranged, mode === "assisted"
|
|
826
1217
|
? new Map([...places].map(([id, place]) => [id, shifted(id, place, 1)]))
|
|
827
|
-
: new Map(), routes, theme, (group) => sizing.headerOf(group), (relation) => labelSize(relation, metrics,
|
|
1218
|
+
: new Map(), routes, theme, (group) => sizing.headerOf(group), (relation) => labelSize(relation, metrics, sizing.relationPill(relation)), bands, minimums, extents, mirrored, holders);
|
|
828
1219
|
try {
|
|
829
1220
|
arrangement = options.engine.arrange(request(new Set()));
|
|
830
1221
|
// The ports of icon shapes face the flow; a relation the engine turns back would wrap
|
|
@@ -855,11 +1246,37 @@ function layoutDrawing(compilation, diagram, options = {}) {
|
|
|
855
1246
|
if (mode === "auto") {
|
|
856
1247
|
for (const [id, center] of arrangement.centers)
|
|
857
1248
|
centers.set(id, center);
|
|
858
|
-
|
|
1249
|
+
// A relation to what a list holds was arranged to the list: its own route is drawn.
|
|
1250
|
+
const held = (key) => {
|
|
1251
|
+
const relation = diagram.relationsByKey.get(key);
|
|
1252
|
+
return relation !== undefined && (holders.has(relation.from) || holders.has(relation.to));
|
|
1253
|
+
};
|
|
1254
|
+
engineRoutes = new Map([...arrangement.routes].filter(([key]) => !held(key)));
|
|
859
1255
|
engineGroups = arrangement.groups ?? new Map();
|
|
860
1256
|
}
|
|
861
1257
|
else {
|
|
862
|
-
|
|
1258
|
+
// Boxes to position: the leaves and the lists; what an unplaced list holds follows it.
|
|
1259
|
+
const positioned = new Map([...elementSizes].filter(([id]) => !holders.has(id) || places.has(id)));
|
|
1260
|
+
for (const [id, size] of topLists)
|
|
1261
|
+
positioned.set(id, size);
|
|
1262
|
+
placeAround(diagram, arrangement, places, positioned, centers, fixed, (id, box) => extentAt(id, box));
|
|
1263
|
+
}
|
|
1264
|
+
// The lists the engine arranged: what they hold is where they put it, and fixed there.
|
|
1265
|
+
for (const id of topLists.keys()) {
|
|
1266
|
+
const center = centers.get(id);
|
|
1267
|
+
if (!center || (mode === "assisted" && places.has(id)))
|
|
1268
|
+
continue;
|
|
1269
|
+
for (const [inner, at] of frames.within(id, center)) {
|
|
1270
|
+
if (diagram.elementsById.get(inner).kind === "group") {
|
|
1271
|
+
places.set(inner, at);
|
|
1272
|
+
sizes.set(inner, frames.sizes.get(inner));
|
|
1273
|
+
framedGroups.add(inner);
|
|
1274
|
+
}
|
|
1275
|
+
else {
|
|
1276
|
+
centers.set(inner, at);
|
|
1277
|
+
fixed.add(inner);
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
863
1280
|
}
|
|
864
1281
|
}
|
|
865
1282
|
else {
|
|
@@ -900,7 +1317,7 @@ function layoutDrawing(compilation, diagram, options = {}) {
|
|
|
900
1317
|
.sort((a, b) => b.groupPath.length - a.groupPath.length);
|
|
901
1318
|
const explicitGroups = new Set();
|
|
902
1319
|
for (const group of groups) {
|
|
903
|
-
const arranged = mode === "auto" && arranging && !autoBands.has(group.id);
|
|
1320
|
+
const arranged = mode === "auto" && arranging && !autoBands.has(group.id) && !framedGroups.has(group.id);
|
|
904
1321
|
const place = arranged ? undefined : places.get(group.id);
|
|
905
1322
|
const size = arranged ? undefined : sizes.get(group.id);
|
|
906
1323
|
if (place && size) {
|
|
@@ -972,6 +1389,35 @@ function layoutDrawing(compilation, diagram, options = {}) {
|
|
|
972
1389
|
}
|
|
973
1390
|
}
|
|
974
1391
|
}
|
|
1392
|
+
// Free lines: their written points moved by their parent's interior origin, in diagram
|
|
1393
|
+
// coordinates; their box is the bounding box of those points.
|
|
1394
|
+
const lineAnchors = new Map();
|
|
1395
|
+
for (const line of freeLines) {
|
|
1396
|
+
const origin = origins.get(line.id) ?? { x: 0, y: 0 };
|
|
1397
|
+
const points = linePointsOf(line.line).map((p) => ({ x: p.x + origin.x, y: p.y + origin.y }));
|
|
1398
|
+
lineAnchors.set(line.id, points);
|
|
1399
|
+
boxes.set(line.id, lineBox(points));
|
|
1400
|
+
if (points.every((p) => p.x === points[0].x && p.y === points[0].y)) {
|
|
1401
|
+
diagnostics.push({
|
|
1402
|
+
code: "layout.degenerate-line",
|
|
1403
|
+
severity: "warning",
|
|
1404
|
+
message: words.diagnostics.degenerateLine(line.id),
|
|
1405
|
+
range: line.range,
|
|
1406
|
+
hints: [words.hints.lineEnds],
|
|
1407
|
+
});
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
// Pen strokes: their written points moved by their parent's interior origin; their box is the
|
|
1411
|
+
// bounding box of those points grown by half their width, which they draw within.
|
|
1412
|
+
const penGeometry = new Map();
|
|
1413
|
+
for (const pen of freePens) {
|
|
1414
|
+
const origin = origins.get(pen.id) ?? { x: 0, y: 0 };
|
|
1415
|
+
const points = pen.pen.points.map((p) => ({ x: p.x + origin.x, y: p.y + origin.y }));
|
|
1416
|
+
const width = penWidth(styles.element(pen));
|
|
1417
|
+
const bounds = penBox(points, width);
|
|
1418
|
+
boxes.set(pen.id, bounds);
|
|
1419
|
+
penGeometry.set(pen.id, { points, bounds, width });
|
|
1420
|
+
}
|
|
975
1421
|
// The drawn shape of every element box, and the outline of those that are not their box; a
|
|
976
1422
|
// turned element's outline is turned, and its drawn box is the bounding box of that outline.
|
|
977
1423
|
const shapes = new Map();
|
|
@@ -979,8 +1425,9 @@ function layoutDrawing(compilation, diagram, options = {}) {
|
|
|
979
1425
|
const turnedBoxes = new Map();
|
|
980
1426
|
for (const element of diagram.elements) {
|
|
981
1427
|
const box = boxes.get(element.id);
|
|
982
|
-
if (element.kind === "group" || !box)
|
|
1428
|
+
if (element.kind === "group" || element.kind === "line" || element.kind === "pen" || !box) {
|
|
983
1429
|
continue;
|
|
1430
|
+
}
|
|
984
1431
|
const shape = element.kind === "tag" ? "pill" : sizing.shapeOf(element);
|
|
985
1432
|
shapes.set(element.id, shape);
|
|
986
1433
|
if (rotations.has(element.id)) {
|
|
@@ -1014,7 +1461,7 @@ function layoutDrawing(compilation, diagram, options = {}) {
|
|
|
1014
1461
|
* The boxes a connector should not cross: the other shapes of its layer, but not its ends, the
|
|
1015
1462
|
* groups that hold them, nor a shape that contains an end box (a box it is stacked inside).
|
|
1016
1463
|
*/
|
|
1017
|
-
const shapeIds = diagram.elements.filter((e) => e.kind !== "group" && e.kind !== "tag");
|
|
1464
|
+
const shapeIds = diagram.elements.filter((e) => e.kind !== "group" && e.kind !== "tag" && e.kind !== "line" && e.kind !== "pen");
|
|
1018
1465
|
const obstaclesFor = (relation) => {
|
|
1019
1466
|
const ends = [relation.from, relation.to];
|
|
1020
1467
|
const holders = new Set(ends.flatMap((id) => [id, ...(diagram.elementsById.get(id)?.groupPath ?? [])]));
|
|
@@ -1037,11 +1484,14 @@ function layoutDrawing(compilation, diagram, options = {}) {
|
|
|
1037
1484
|
});
|
|
1038
1485
|
}
|
|
1039
1486
|
// Relations with labels sit a label apart, so their pills do not overlap.
|
|
1040
|
-
const laneSpacing = (relation) =>
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1487
|
+
const laneSpacing = (relation) => {
|
|
1488
|
+
if (relation.label === null && !relation.technology)
|
|
1489
|
+
return LANE_SPACING;
|
|
1490
|
+
const pill = sizing.relationPill(relation).scale;
|
|
1491
|
+
return (pill.relationLabelHeight +
|
|
1492
|
+
(relation.technology ? Math.round(technologySize(pill.relationLabel) * 1.25) : 0) +
|
|
1493
|
+
4);
|
|
1494
|
+
};
|
|
1045
1495
|
/** Labels placed by the layout, which may slide along their connectors to stay clear. */
|
|
1046
1496
|
const movable = [];
|
|
1047
1497
|
// Named ports and compass sides with a position are points of the box.
|
|
@@ -1155,10 +1605,10 @@ function layoutDrawing(compilation, diagram, options = {}) {
|
|
|
1155
1605
|
else {
|
|
1156
1606
|
points = shapeRoute(kind, anchors.points, { first: anchors.points[0], last: anchors.points[anchors.points.length - 1], via }, anchors.fromSide, anchors.toSide);
|
|
1157
1607
|
}
|
|
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.
|
|
1608
|
+
// The target's head is the style's, else none for a line (`--`, `---`), else the theme's; the
|
|
1609
|
+
// source's is the style's, else the target's for a two-way arrow, else none.
|
|
1610
|
+
const endKind = style.arrowhead ?? (relation.heads === "none" ? "none" : theme.arrowhead);
|
|
1611
|
+
const startKind = style.arrowtail ?? (relation.heads === "both" ? endKind : "none");
|
|
1162
1612
|
const headWidth = (theme.arrowSize * 7) / 8;
|
|
1163
1613
|
// The labels icon shapes draw beside their ends: a connector passes under its own end's label
|
|
1164
1614
|
// only straight from the icon and on beyond it, far enough for its head; elsewhere the label
|
|
@@ -1261,23 +1711,25 @@ function layoutDrawing(compilation, diagram, options = {}) {
|
|
|
1261
1711
|
const offset = entry ? entry.offset : { x: 0, y: 0 };
|
|
1262
1712
|
const center = pointAlong(curve ? line : drawn, at);
|
|
1263
1713
|
const text = relation.label ?? "";
|
|
1264
|
-
const
|
|
1714
|
+
const pill = sizing.relationPill(relation);
|
|
1715
|
+
const pillScale = pill.scale;
|
|
1716
|
+
const textWidth = text === "" ? 0 : metrics.width(text, pill.font, pill.weight, pillScale.relationLabel);
|
|
1265
1717
|
const iconWidth = icon ? icon.size + (text === "" ? 0 : icon.gap) : 0;
|
|
1266
1718
|
// C4 relations show their technology on a second, smaller line.
|
|
1267
|
-
const techSize = technologySize(
|
|
1268
|
-
const techWidth = technology ? metrics.width(technology,
|
|
1269
|
-
const width = Math.max(textWidth + iconWidth, techWidth) +
|
|
1270
|
-
const height =
|
|
1719
|
+
const techSize = technologySize(pillScale.relationLabel);
|
|
1720
|
+
const techWidth = technology ? metrics.width(technology, pill.font, 400, techSize) : 0;
|
|
1721
|
+
const width = Math.max(textWidth + iconWidth, techWidth) + pillScale.relationLabelPadding;
|
|
1722
|
+
const height = pillScale.relationLabelHeight + (technology ? Math.round(techSize * 1.25) : 0);
|
|
1271
1723
|
if (!entry) {
|
|
1272
1724
|
movable.push({
|
|
1273
1725
|
index,
|
|
1274
1726
|
along: curve ? line : drawn,
|
|
1275
|
-
width: Math.max(width,
|
|
1727
|
+
width: Math.max(width, pillScale.relationLabelHeight),
|
|
1276
1728
|
height,
|
|
1277
1729
|
});
|
|
1278
1730
|
}
|
|
1279
1731
|
label = {
|
|
1280
|
-
box: boxFromCenter({ x: center.x + offset.x, y: center.y + offset.y }, Math.max(width,
|
|
1732
|
+
box: boxFromCenter({ x: center.x + offset.x, y: center.y + offset.y }, Math.max(width, pillScale.relationLabelHeight), height),
|
|
1281
1733
|
text,
|
|
1282
1734
|
technology,
|
|
1283
1735
|
};
|
|
@@ -1291,9 +1743,97 @@ function layoutDrawing(compilation, diagram, options = {}) {
|
|
|
1291
1743
|
};
|
|
1292
1744
|
return { relation, line, start: startHead, end: endHead, label, curve, connector };
|
|
1293
1745
|
});
|
|
1746
|
+
// Free lines: connectors without ends, routed through their own points (`polyline` unless a
|
|
1747
|
+
// route kind is written; `auto` is `polyline`, there being no box to anchor to), with no lanes,
|
|
1748
|
+
// detours or gaps, their heads `none` unless written, and a pill as a relation's.
|
|
1749
|
+
const lineGeometry = new Map();
|
|
1750
|
+
for (const element of freeLines) {
|
|
1751
|
+
const anchors = lineAnchors.get(element.id);
|
|
1752
|
+
const style = styles.element(element);
|
|
1753
|
+
const written = style.routing ?? "polyline";
|
|
1754
|
+
const kind = written === "auto" ? "polyline" : written;
|
|
1755
|
+
const points = kind === "orthogonal" ? orthogonalThrough(anchors) : [...anchors];
|
|
1756
|
+
const last = points.length - 1;
|
|
1757
|
+
const fromSide = sideToward(points[0], points[Math.min(1, last)]);
|
|
1758
|
+
const toSide = sideToward(points[last], points[Math.max(0, last - 1)]);
|
|
1759
|
+
const endKind = style.arrowhead ?? "none";
|
|
1760
|
+
const startKind = style.arrowtail ?? "none";
|
|
1761
|
+
const headWidth = (theme.arrowSize * 7) / 8;
|
|
1762
|
+
let line;
|
|
1763
|
+
let start;
|
|
1764
|
+
let end;
|
|
1765
|
+
let curve;
|
|
1766
|
+
if (kind === "bezier" && points.length > 1) {
|
|
1767
|
+
const approach = (tip, side) => {
|
|
1768
|
+
const normal = sideNormal(side);
|
|
1769
|
+
return [{ x: tip.x + normal.x * 10, y: tip.y + normal.y * 10 }, tip];
|
|
1770
|
+
};
|
|
1771
|
+
const atEnd = endMarker(approach(points[last], toSide), endKind, theme.arrowSize, headWidth);
|
|
1772
|
+
const atStart = endMarker(approach(points[0], fromSide), startKind, theme.arrowSize, headWidth);
|
|
1773
|
+
const bezier = bezierThrough(points, fromSide, toSide, atEnd.line[atEnd.line.length - 1], atStart.line[atStart.line.length - 1]);
|
|
1774
|
+
line = bezier.samples;
|
|
1775
|
+
start = atStart.parts;
|
|
1776
|
+
end = atEnd.parts;
|
|
1777
|
+
curve = bezier.d;
|
|
1778
|
+
}
|
|
1779
|
+
else {
|
|
1780
|
+
const ends = relationEnds(points, startKind, endKind, theme.arrowSize, headWidth);
|
|
1781
|
+
line =
|
|
1782
|
+
(style.corners ?? theme.corners) === "rounded"
|
|
1783
|
+
? roundCorners(ends.line, CORNER_RADIUS)
|
|
1784
|
+
: ends.line;
|
|
1785
|
+
start = ends.start;
|
|
1786
|
+
end = ends.end;
|
|
1787
|
+
}
|
|
1788
|
+
let label;
|
|
1789
|
+
const icon = sizing.lineIcon(element);
|
|
1790
|
+
if (element.label !== "" || icon) {
|
|
1791
|
+
const entry = labels.get(element.id);
|
|
1792
|
+
const at = entry ? entry.at / 100 : 0.5;
|
|
1793
|
+
const offset = entry ? entry.offset : { x: 0, y: 0 };
|
|
1794
|
+
const center = pointAlong(curve ? line : points, at);
|
|
1795
|
+
const text = element.label;
|
|
1796
|
+
const pill = sizing.linePill(element);
|
|
1797
|
+
const textWidth = text === "" ? 0 : metrics.width(text, pill.font, pill.weight, pill.scale.relationLabel);
|
|
1798
|
+
const iconWidth = icon ? icon.size + (text === "" ? 0 : icon.gap) : 0;
|
|
1799
|
+
const width = textWidth + iconWidth + pill.scale.relationLabelPadding;
|
|
1800
|
+
label = {
|
|
1801
|
+
box: boxFromCenter({ x: center.x + offset.x, y: center.y + offset.y }, Math.max(width, pill.scale.relationLabelHeight), pill.scale.relationLabelHeight),
|
|
1802
|
+
text,
|
|
1803
|
+
};
|
|
1804
|
+
}
|
|
1805
|
+
lineGeometry.set(element.id, {
|
|
1806
|
+
element,
|
|
1807
|
+
line,
|
|
1808
|
+
start,
|
|
1809
|
+
end,
|
|
1810
|
+
label,
|
|
1811
|
+
curve,
|
|
1812
|
+
connector: { points, line, fromSide, toSide, via: anchors.slice(1, -1) },
|
|
1813
|
+
});
|
|
1814
|
+
}
|
|
1815
|
+
// Pen strokes: their heads along their end segments, none unless written, which the stroke
|
|
1816
|
+
// stops at as a line does; a dot has none. A point written twice in a row makes no segment, so
|
|
1817
|
+
// the heads and the cut are taken from the stroke without repeats.
|
|
1818
|
+
const penDrawing = new Map();
|
|
1819
|
+
for (const pen of freePens) {
|
|
1820
|
+
const { points, width } = penGeometry.get(pen.id);
|
|
1821
|
+
const style = styles.element(pen);
|
|
1822
|
+
const endKind = style.arrowhead ?? "none";
|
|
1823
|
+
const startKind = style.arrowtail ?? "none";
|
|
1824
|
+
if (isDot(points) || (endKind === "none" && startKind === "none")) {
|
|
1825
|
+
penDrawing.set(pen.id, { line: points, start: [], end: [] });
|
|
1826
|
+
continue;
|
|
1827
|
+
}
|
|
1828
|
+
const length = penHeadLength(theme.arrowSize, width);
|
|
1829
|
+
const ends = relationEnds(withoutRepeats(points), startKind, endKind, length, (length * 7) / 8);
|
|
1830
|
+
penDrawing.set(pen.id, { line: ends.line, start: ends.start, end: ends.end });
|
|
1831
|
+
}
|
|
1294
1832
|
// Every element's box with the label it draws outside it, in diagram coordinates.
|
|
1295
1833
|
const extents = new Map([...boxes].map(([id, box]) => [id, extentAt(id, box)]));
|
|
1296
|
-
|
|
1834
|
+
// A line's box is no obstacle: labels may sit beside it as beside any connector; nor is a pen's.
|
|
1835
|
+
const shapeExtents = new Map([...extents].filter(([id]) => !lineGeometry.has(id) && !penGeometry.has(id)));
|
|
1836
|
+
placeLabels(relationGeometry, movable, labelObstacles(diagram, shapeExtents, sizing));
|
|
1297
1837
|
const elementLabels = new Map();
|
|
1298
1838
|
for (const [id, box] of boxes) {
|
|
1299
1839
|
const label = labelAt(id, box);
|
|
@@ -1306,12 +1846,17 @@ function layoutDrawing(compilation, diagram, options = {}) {
|
|
|
1306
1846
|
for (const [id, box] of boxes)
|
|
1307
1847
|
if (!rotations.has(id))
|
|
1308
1848
|
checked.set(id, box);
|
|
1849
|
+
// Lines and pen strokes cross boxes by design: they take no part in the composition checks.
|
|
1850
|
+
for (const id of lineGeometry.keys())
|
|
1851
|
+
checked.delete(id);
|
|
1852
|
+
for (const id of penGeometry.keys())
|
|
1853
|
+
checked.delete(id);
|
|
1309
1854
|
diagnostics.push(...checkComposition(diagram, checked, relationGeometry.map((g) => ({
|
|
1310
1855
|
relation: g.relation,
|
|
1311
1856
|
points: g.line,
|
|
1312
1857
|
curved: g.curve !== undefined,
|
|
1313
1858
|
label: g.label?.box,
|
|
1314
|
-
})), explicitGroups, theme, (group) => sizing.untitled(group), outlines, elementLabels));
|
|
1859
|
+
})), explicitGroups, theme, (group) => sizing.untitled(group), outlines, elementLabels, (group) => sizing.headerOf(group)));
|
|
1315
1860
|
const everything = [...extents.values()];
|
|
1316
1861
|
for (const geometry of relationGeometry) {
|
|
1317
1862
|
for (const p of [
|
|
@@ -1323,6 +1868,17 @@ function layoutDrawing(compilation, diagram, options = {}) {
|
|
|
1323
1868
|
if (geometry.label)
|
|
1324
1869
|
everything.push(geometry.label.box);
|
|
1325
1870
|
}
|
|
1871
|
+
for (const geometry of lineGeometry.values()) {
|
|
1872
|
+
for (const p of [...markerPoints(geometry.start), ...markerPoints(geometry.end)])
|
|
1873
|
+
everything.push({ x: p.x, y: p.y, width: 0, height: 0 });
|
|
1874
|
+
if (geometry.label)
|
|
1875
|
+
everything.push(geometry.label.box);
|
|
1876
|
+
}
|
|
1877
|
+
// A pen's box holds its stroke; its heads may reach beyond it.
|
|
1878
|
+
for (const drawing of penDrawing.values()) {
|
|
1879
|
+
for (const p of [...markerPoints(drawing.start), ...markerPoints(drawing.end)])
|
|
1880
|
+
everything.push({ x: p.x, y: p.y, width: 0, height: 0 });
|
|
1881
|
+
}
|
|
1326
1882
|
const bounds = unionBoxes(everything) ?? { x: 0, y: 0, width: 0, height: 0 };
|
|
1327
1883
|
const margin = theme.margin;
|
|
1328
1884
|
const contentWidth = Math.ceil(bounds.width + 2 * margin);
|
|
@@ -1349,7 +1905,7 @@ function layoutDrawing(compilation, diagram, options = {}) {
|
|
|
1349
1905
|
const shiftPoint = (p) => ({ x: p.x + dx, y: p.y + dy });
|
|
1350
1906
|
const items = [];
|
|
1351
1907
|
const gradients = [];
|
|
1352
|
-
const emitter = new Emitter(theme, scale, styles, metrics, sizing);
|
|
1908
|
+
const emitter = new Emitter(theme, scale, styles, metrics, sizing, free, diagram.elementsById, diagram.id);
|
|
1353
1909
|
const drawRelation = (geometry) => emitter.relation(geometry.relation, geometry.line.map(shiftPoint), shiftMarker(geometry.start, shiftPoint), shiftMarker(geometry.end, shiftPoint), geometry.label
|
|
1354
1910
|
? {
|
|
1355
1911
|
box: shift(geometry.label.box),
|
|
@@ -1367,6 +1923,49 @@ function layoutDrawing(compilation, diagram, options = {}) {
|
|
|
1367
1923
|
const c = boxCenter(box);
|
|
1368
1924
|
return { ...group, transform: `rotate(${round(degrees)} ${round(c.x)} ${round(c.y)})` };
|
|
1369
1925
|
};
|
|
1926
|
+
/**
|
|
1927
|
+
* An attached note's leader: a dotted polyline from where the segment between the two boxes'
|
|
1928
|
+
* centres leaves the note to where it enters its element's outline (turned with the element),
|
|
1929
|
+
* the last item of the note's group. None when the target is not drawn or the boxes overlap. A
|
|
1930
|
+
* turned note's group turns its items, so the leader is written in the note's own frame.
|
|
1931
|
+
*/
|
|
1932
|
+
const withLeader = (group, element, box) => {
|
|
1933
|
+
const targetId = element.attachedTo;
|
|
1934
|
+
const target = targetId === null ? undefined : diagram.elementsById.get(targetId);
|
|
1935
|
+
const placed = target && target.id !== element.id ? boxes.get(target.id) : undefined;
|
|
1936
|
+
if (!target || !placed)
|
|
1937
|
+
return group;
|
|
1938
|
+
const targetBox = shift(placed);
|
|
1939
|
+
const overlaps = box.x < targetBox.x + targetBox.width &&
|
|
1940
|
+
targetBox.x < box.x + box.width &&
|
|
1941
|
+
box.y < targetBox.y + targetBox.height &&
|
|
1942
|
+
targetBox.y < box.y + box.height;
|
|
1943
|
+
if (overlaps)
|
|
1944
|
+
return group;
|
|
1945
|
+
const own = rotations.get(element.id) ?? 0;
|
|
1946
|
+
const turnedTarget = rotations.get(target.id) ?? 0;
|
|
1947
|
+
const shapeOf = (e) => e.kind === "tag" ? "pill" : e.kind === "group" ? "rectangle" : sizing.shapeOf(e);
|
|
1948
|
+
const centres = { note: boxCenter(box), target: boxCenter(targetBox) };
|
|
1949
|
+
const from = boundaryToward("rectangle", box, own, centres.target);
|
|
1950
|
+
const to = boundaryToward(shapeOf(target), targetBox, turnedTarget, centres.note, sizing.shapeOptions(target));
|
|
1951
|
+
const local = (p) => (own === 0 ? p : turnPoint(p, centres.note, -own));
|
|
1952
|
+
const leader = {
|
|
1953
|
+
kind: "polyline",
|
|
1954
|
+
points: [from, to].map(local).map((p) => ({ x: round(p.x), y: round(p.y) })),
|
|
1955
|
+
stroke: theme.muted,
|
|
1956
|
+
strokeWidth: theme.strokeWidth,
|
|
1957
|
+
dashed: "dotted",
|
|
1958
|
+
className: "leader",
|
|
1959
|
+
};
|
|
1960
|
+
return { ...group, items: [...group.items, leader] };
|
|
1961
|
+
};
|
|
1962
|
+
/** An element's drawing, turned when it is, with the leader of a note attached to something. */
|
|
1963
|
+
const elementGroup = (element, box) => {
|
|
1964
|
+
const drawn = turn(emitter.element(element, box), box);
|
|
1965
|
+
return element.kind === "note" && element.attachedTo !== null
|
|
1966
|
+
? withLeader(drawn, element, box)
|
|
1967
|
+
: drawn;
|
|
1968
|
+
};
|
|
1370
1969
|
if (free) {
|
|
1371
1970
|
const byKey = new Map(relationGeometry.map((g) => [g.relation.key, g]));
|
|
1372
1971
|
for (const paintable of paintOrder(diagram)) {
|
|
@@ -1377,9 +1976,19 @@ function layoutDrawing(compilation, diagram, options = {}) {
|
|
|
1377
1976
|
continue;
|
|
1378
1977
|
}
|
|
1379
1978
|
const box = shift(boxes.get(paintable.element.id));
|
|
1979
|
+
const line = lineGeometry.get(paintable.element.id);
|
|
1980
|
+
if (line) {
|
|
1981
|
+
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));
|
|
1982
|
+
continue;
|
|
1983
|
+
}
|
|
1984
|
+
const pen = penDrawing.get(paintable.element.id);
|
|
1985
|
+
if (pen) {
|
|
1986
|
+
items.push(onLayer(emitter.pen(paintable.element, box, penGeometry.get(paintable.element.id).points.map(shiftPoint), pen.line.map(shiftPoint), shiftMarker(pen.start, shiftPoint), shiftMarker(pen.end, shiftPoint)), paintable.element.layer));
|
|
1987
|
+
continue;
|
|
1988
|
+
}
|
|
1380
1989
|
items.push(onLayer(paintable.kind === "group"
|
|
1381
1990
|
? emitter.group(paintable.element, box)
|
|
1382
|
-
:
|
|
1991
|
+
: elementGroup(paintable.element, box), paintable.element.layer));
|
|
1383
1992
|
}
|
|
1384
1993
|
}
|
|
1385
1994
|
else {
|
|
@@ -1402,7 +2011,7 @@ function layoutDrawing(compilation, diagram, options = {}) {
|
|
|
1402
2011
|
if (element.kind === "group" || element.kind === "tag" || indexOf(element.layer) !== layer)
|
|
1403
2012
|
continue;
|
|
1404
2013
|
const box = shift(boxes.get(element.id));
|
|
1405
|
-
items.push(onLayer(
|
|
2014
|
+
items.push(onLayer(elementGroup(element, box), element.layer));
|
|
1406
2015
|
}
|
|
1407
2016
|
for (const geometry of relationGeometry) {
|
|
1408
2017
|
if (indexOf(geometry.relation.layer) !== layer)
|
|
@@ -1442,6 +2051,7 @@ function layoutDrawing(compilation, diagram, options = {}) {
|
|
|
1442
2051
|
gradients: [...gradients, ...emitter.painter.gradients],
|
|
1443
2052
|
patterns: emitter.patterns,
|
|
1444
2053
|
items,
|
|
2054
|
+
...(theme.sketch !== "none" ? { sketch: theme.sketch } : {}),
|
|
1445
2055
|
};
|
|
1446
2056
|
const labelBoxes = new Map();
|
|
1447
2057
|
const connectors = new Map();
|
|
@@ -1450,6 +2060,28 @@ function layoutDrawing(compilation, diagram, options = {}) {
|
|
|
1450
2060
|
labelBoxes.set(geometry.relation.key, geometry.label.box);
|
|
1451
2061
|
connectors.set(geometry.relation.key, geometry.connector);
|
|
1452
2062
|
}
|
|
2063
|
+
// Free lines by their id, beside the relations: their pill and their route.
|
|
2064
|
+
for (const [id, geometry] of lineGeometry) {
|
|
2065
|
+
if (geometry.label)
|
|
2066
|
+
labelBoxes.set(id, geometry.label.box);
|
|
2067
|
+
connectors.set(id, geometry.connector);
|
|
2068
|
+
}
|
|
2069
|
+
// Stickies: the room their text is wrapped in and the size it is drawn at, for editors.
|
|
2070
|
+
const stickies = new Map();
|
|
2071
|
+
for (const element of diagram.elements) {
|
|
2072
|
+
const box = element.kind === "sticky" ? boxes.get(element.id) : undefined;
|
|
2073
|
+
if (box)
|
|
2074
|
+
stickies.set(element.id, sizing.stickyPlace(element, box));
|
|
2075
|
+
}
|
|
2076
|
+
// Lists: how they arrange their children and where the first one starts, for editors.
|
|
2077
|
+
const lists = new Map();
|
|
2078
|
+
for (const [id, list] of frames?.lists ?? []) {
|
|
2079
|
+
const box = boxes.get(id);
|
|
2080
|
+
if (!box)
|
|
2081
|
+
continue;
|
|
2082
|
+
const header = sizing.headerOf(diagram.elementsById.get(id));
|
|
2083
|
+
lists.set(id, { ...list, origin: { x: box.x + theme.groupPadding, y: box.y + header } });
|
|
2084
|
+
}
|
|
1453
2085
|
return {
|
|
1454
2086
|
scene,
|
|
1455
2087
|
diagnostics,
|
|
@@ -1457,6 +2089,9 @@ function layoutDrawing(compilation, diagram, options = {}) {
|
|
|
1457
2089
|
offset: { x: dx, y: dy },
|
|
1458
2090
|
labels: labelBoxes,
|
|
1459
2091
|
connectors,
|
|
2092
|
+
pens: penGeometry,
|
|
2093
|
+
stickies,
|
|
2094
|
+
lists,
|
|
1460
2095
|
origins,
|
|
1461
2096
|
theme,
|
|
1462
2097
|
shapes,
|
|
@@ -1541,6 +2176,14 @@ function ownLabelCrossings(points, labels, rooms, straight) {
|
|
|
1541
2176
|
const route = beyondOwnLabels(points, labels, rooms, straight);
|
|
1542
2177
|
return labels.filter((label) => label && crossedBoxes(route, [label.box]).length > 0).length;
|
|
1543
2178
|
}
|
|
2179
|
+
/** Points rounded as the scene writes them. */
|
|
2180
|
+
function roundedPoints(list) {
|
|
2181
|
+
return list.map((p) => ({ x: round(p.x), y: round(p.y) }));
|
|
2182
|
+
}
|
|
2183
|
+
/** A circle of radius `r` about (`cx`, `cy`), as path data. */
|
|
2184
|
+
function circlePath(cx, cy, r) {
|
|
2185
|
+
return `M${round(cx - r)} ${cy}a${r} ${r} 0 1 0 ${round(2 * r)} 0a${r} ${r} 0 1 0 ${round(-2 * r)} 0Z`;
|
|
2186
|
+
}
|
|
1544
2187
|
function round(value) {
|
|
1545
2188
|
return Math.round(value * 100) / 100;
|
|
1546
2189
|
}
|
|
@@ -1602,13 +2245,16 @@ function translatePath(d, dx, dy) {
|
|
|
1602
2245
|
});
|
|
1603
2246
|
}
|
|
1604
2247
|
/** The size of a relation's label pill without its icon, or null when it has no label. */
|
|
1605
|
-
function labelSize(relation, metrics,
|
|
2248
|
+
function labelSize(relation, metrics, pill) {
|
|
1606
2249
|
const technology = relation.technology ? `[${relation.technology}]` : null;
|
|
1607
2250
|
if (relation.label === null && !technology)
|
|
1608
2251
|
return null;
|
|
2252
|
+
const scale = pill.scale;
|
|
1609
2253
|
const techSize = technologySize(scale.relationLabel);
|
|
1610
|
-
const text = relation.label
|
|
1611
|
-
|
|
2254
|
+
const text = relation.label
|
|
2255
|
+
? metrics.width(relation.label, pill.font, pill.weight, scale.relationLabel)
|
|
2256
|
+
: 0;
|
|
2257
|
+
const tech = technology ? metrics.width(technology, pill.font, 400, techSize) : 0;
|
|
1612
2258
|
return {
|
|
1613
2259
|
width: Math.ceil(Math.max(text, tech) + scale.relationLabelPadding),
|
|
1614
2260
|
height: scale.relationLabelHeight + (technology ? Math.round(techSize * 1.25) : 0),
|
|
@@ -1635,8 +2281,14 @@ const FLOW_SIDES = {
|
|
|
1635
2281
|
* the routes meet the icon and not its label: on the side the flow leaves by for a source and
|
|
1636
2282
|
* enters by for a target, or the other way round for the relations in `mirrored`, which run
|
|
1637
2283
|
* against the flow.
|
|
2284
|
+
*
|
|
2285
|
+
* A list group is a node of its arranged size in `sizes`, and the engine never sees what it
|
|
2286
|
+
* holds: `holders` gives everything inside a list the outermost list around it, which the
|
|
2287
|
+
* relations and notes attached to what the list holds end on instead, with no port.
|
|
1638
2288
|
*/
|
|
1639
|
-
function arrangeRequest(diagram, sizes, fixedPlaces, routes, theme, headerOf, labelOf = () => null, leftOut = new Set(), minimums = new Map(), iconExtents = new Map(), mirrored = new Set()) {
|
|
2289
|
+
function arrangeRequest(diagram, sizes, fixedPlaces, routes, theme, headerOf, labelOf = () => null, leftOut = new Set(), minimums = new Map(), iconExtents = new Map(), mirrored = new Set(), holders = new Map()) {
|
|
2290
|
+
/** The box the engine arranges for an element: the outermost list holding it, or its own. */
|
|
2291
|
+
const boxOf = (id) => holders.get(id) ?? id;
|
|
1640
2292
|
/** A position along a side of an element's box, as a position along its extent's side. */
|
|
1641
2293
|
const along = (id, side, position) => {
|
|
1642
2294
|
const known = iconExtents.get(id);
|
|
@@ -1671,19 +2323,26 @@ function arrangeRequest(diagram, sizes, fixedPlaces, routes, theme, headerOf, la
|
|
|
1671
2323
|
return id;
|
|
1672
2324
|
};
|
|
1673
2325
|
const edges = diagram.relations.flatMap((relation) => {
|
|
1674
|
-
|
|
2326
|
+
const source = boxOf(relation.from);
|
|
2327
|
+
const target = boxOf(relation.to);
|
|
2328
|
+
if (leftOut.has(source) || leftOut.has(target))
|
|
2329
|
+
return [];
|
|
2330
|
+
// A relation within one list is drawn between the places the list gives.
|
|
2331
|
+
if (source === target && (holders.has(relation.from) || holders.has(relation.to)))
|
|
1675
2332
|
return [];
|
|
1676
2333
|
const route = routes.get(relation.key);
|
|
1677
|
-
const port = (end, which) =>
|
|
1678
|
-
?
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
2334
|
+
const port = (end, which) => holders.has(relation[which])
|
|
2335
|
+
? null
|
|
2336
|
+
: end && end.port !== "auto"
|
|
2337
|
+
? end.named
|
|
2338
|
+
? end.port
|
|
2339
|
+
: enginePort(end)
|
|
2340
|
+
: iconPort(relation, which);
|
|
1682
2341
|
return [
|
|
1683
2342
|
{
|
|
1684
2343
|
id: relation.key,
|
|
1685
|
-
source
|
|
1686
|
-
target
|
|
2344
|
+
source,
|
|
2345
|
+
target,
|
|
1687
2346
|
sourcePort: port(route?.from, "from"),
|
|
1688
2347
|
targetPort: port(route?.to, "to"),
|
|
1689
2348
|
primary: relation.line === "solid",
|
|
@@ -1691,8 +2350,48 @@ function arrangeRequest(diagram, sizes, fixedPlaces, routes, theme, headerOf, la
|
|
|
1691
2350
|
},
|
|
1692
2351
|
];
|
|
1693
2352
|
});
|
|
2353
|
+
// An attached note is arranged beside its element as a dashed relation from the element would
|
|
2354
|
+
// be (the element first, so the note follows it in the flow's direction): in `auto`, and for a
|
|
2355
|
+
// note that has no place in `assisted`. The leader is no relation, so the route the engine
|
|
2356
|
+
// answers for this edge is never read.
|
|
2357
|
+
const attached = diagram.elements.flatMap((note) => {
|
|
2358
|
+
const target = note.attachedTo;
|
|
2359
|
+
if (note.kind !== "note" || target === null || target === note.id)
|
|
2360
|
+
return [];
|
|
2361
|
+
// A note in a list is placed by it; a note on what a list holds is arranged beside the list.
|
|
2362
|
+
const source = boxOf(target);
|
|
2363
|
+
if (holders.has(note.id) || fixedPlaces.has(note.id))
|
|
2364
|
+
return [];
|
|
2365
|
+
if (leftOut.has(note.id) || leftOut.has(source))
|
|
2366
|
+
return [];
|
|
2367
|
+
const element = diagram.elementsById.get(target);
|
|
2368
|
+
if (!element ||
|
|
2369
|
+
element.kind === "line" ||
|
|
2370
|
+
element.kind === "pen" ||
|
|
2371
|
+
!sizes.has(source) ||
|
|
2372
|
+
!sizes.has(note.id)) {
|
|
2373
|
+
return [];
|
|
2374
|
+
}
|
|
2375
|
+
return [
|
|
2376
|
+
{
|
|
2377
|
+
id: `attach${note.id}`,
|
|
2378
|
+
source,
|
|
2379
|
+
target: note.id,
|
|
2380
|
+
sourcePort: null,
|
|
2381
|
+
targetPort: null,
|
|
2382
|
+
primary: false,
|
|
2383
|
+
label: null,
|
|
2384
|
+
},
|
|
2385
|
+
];
|
|
2386
|
+
});
|
|
2387
|
+
edges.push(...attached);
|
|
2388
|
+
// Free lines and pens are drawn where their points are written: the engine never moves them.
|
|
2389
|
+
// A list is a node; what it holds is not.
|
|
1694
2390
|
const nodes = diagram.elements
|
|
1695
|
-
.filter((e) => e.kind
|
|
2391
|
+
.filter((e) => (e.kind === "group" ? isList(e) && !leftOut.has(e.id) : true) &&
|
|
2392
|
+
e.kind !== "line" &&
|
|
2393
|
+
e.kind !== "pen" &&
|
|
2394
|
+
!holders.has(e.id))
|
|
1696
2395
|
.map((element) => {
|
|
1697
2396
|
const ports = element.ports.map((p) => ({
|
|
1698
2397
|
id: p.name,
|
|
@@ -1725,7 +2424,7 @@ function arrangeRequest(diagram, sizes, fixedPlaces, routes, theme, headerOf, la
|
|
|
1725
2424
|
};
|
|
1726
2425
|
});
|
|
1727
2426
|
const groups = diagram.elements
|
|
1728
|
-
.filter((e) => e.kind === "group" && !leftOut.has(e.id))
|
|
2427
|
+
.filter((e) => e.kind === "group" && !leftOut.has(e.id) && !isList(e) && !holders.has(e.id))
|
|
1729
2428
|
.map((group) => {
|
|
1730
2429
|
const minimum = minimums.get(group.id);
|
|
1731
2430
|
return {
|
|
@@ -1793,7 +2492,8 @@ function routesLength(arrangement) {
|
|
|
1793
2492
|
* `assisted` positions: placed elements keep their place. Each other element keeps its offset in
|
|
1794
2493
|
* the arrangement from the placed element nearest to it there, and is then pushed away from that
|
|
1795
2494
|
* element, along the layout direction, until it no longer overlaps a box already positioned. Boxes
|
|
1796
|
-
* are compared with what they draw beyond them, `extentOf` (the label of an icon shape).
|
|
2495
|
+
* are compared with what they draw beyond them, `extentOf` (the label of an icon shape). `sizes`
|
|
2496
|
+
* holds the boxes to position: the leaves, and the lists, which are arranged as boxes.
|
|
1797
2497
|
*/
|
|
1798
2498
|
function placeAround(diagram, arrangement, places, sizes, centers, fixed, extentOf = (_id, box) => box) {
|
|
1799
2499
|
const direction = diagram.properties.direction;
|
|
@@ -1801,7 +2501,7 @@ function placeAround(diagram, arrangement, places, sizes, centers, fixed, extent
|
|
|
1801
2501
|
const anchors = [...places.keys()].filter((id) => sizes.has(id) && arrangement.centers.has(id));
|
|
1802
2502
|
const positioned = [];
|
|
1803
2503
|
for (const element of diagram.elements) {
|
|
1804
|
-
if (element.
|
|
2504
|
+
if (!sizes.has(element.id) || !places.has(element.id))
|
|
1805
2505
|
continue;
|
|
1806
2506
|
const size = sizes.get(element.id);
|
|
1807
2507
|
centers.set(element.id, places.get(element.id));
|
|
@@ -1809,7 +2509,7 @@ function placeAround(diagram, arrangement, places, sizes, centers, fixed, extent
|
|
|
1809
2509
|
positioned.push(extentOf(element.id, boxFromCenter(places.get(element.id), size.width, size.height)));
|
|
1810
2510
|
}
|
|
1811
2511
|
for (const element of diagram.elements) {
|
|
1812
|
-
if (element.
|
|
2512
|
+
if (!sizes.has(element.id) || places.has(element.id))
|
|
1813
2513
|
continue;
|
|
1814
2514
|
const size = sizes.get(element.id);
|
|
1815
2515
|
const arranged = arrangement.centers.get(element.id) ?? { x: 0, y: 0 };
|
|
@@ -1977,7 +2677,8 @@ function parallelLanes(diagram, routes, spacingOf = () => LANE_SPACING) {
|
|
|
1977
2677
|
}
|
|
1978
2678
|
/**
|
|
1979
2679
|
* The canvas for the diagram's `preset` or `canvas`, reporting `layout.too-large` when the content
|
|
1980
|
-
* canvas exceeds what the preset holds. Nothing is clipped: a fixed canvas grows to fit.
|
|
2680
|
+
* canvas exceeds what the preset holds. Nothing is clipped: a fixed canvas grows to fit. A board
|
|
2681
|
+
* (`preset board`) is the content canvas whatever its size: a whiteboard grows as it is drawn on.
|
|
1981
2682
|
*/
|
|
1982
2683
|
export function canvasSize(diagram, width, height, lines, diagnostics) {
|
|
1983
2684
|
const { preset, canvas } = diagram.properties;
|
|
@@ -1987,8 +2688,15 @@ export function canvasSize(diagram, width, height, lines, diagnostics) {
|
|
|
1987
2688
|
severity: "warning",
|
|
1988
2689
|
message,
|
|
1989
2690
|
range: propertyRange(diagram, keyword, lines) ?? diagram.idRange,
|
|
1990
|
-
|
|
2691
|
+
// A whiteboard's preset is the board's, which grows with what it holds: a preset or a
|
|
2692
|
+
// written canvas can give way to it.
|
|
2693
|
+
hints: [
|
|
2694
|
+
words.hints.tooLarge,
|
|
2695
|
+
keyword === "preset" ? words.hints.boardPreset : words.hints.boardCanvas,
|
|
2696
|
+
],
|
|
1991
2697
|
});
|
|
2698
|
+
if (preset === "board")
|
|
2699
|
+
return { width, height };
|
|
1992
2700
|
if (preset === "document") {
|
|
1993
2701
|
if (width > DOCUMENT_MAX_WIDTH) {
|
|
1994
2702
|
tooLarge("preset", words.diagnostics.tooWideForDocument(width, DOCUMENT_MAX_WIDTH));
|
|
@@ -2061,6 +2769,10 @@ class Sizing {
|
|
|
2061
2769
|
centredSizes = new Map();
|
|
2062
2770
|
centredLayouts = new Map();
|
|
2063
2771
|
centredLays = new Map();
|
|
2772
|
+
/** The centre of each aligned text block, by element and box. */
|
|
2773
|
+
textCenters = new Map();
|
|
2774
|
+
/** How the text of each sticky is drawn, by element and card size. */
|
|
2775
|
+
stickyTexts = new Map();
|
|
2064
2776
|
constructor(theme, scale, metrics, styles, icons, images,
|
|
2065
2777
|
/** The written `size` of each element that has one. */
|
|
2066
2778
|
written = new Map()) {
|
|
@@ -2078,12 +2790,48 @@ class Sizing {
|
|
|
2078
2790
|
* times the size apart.
|
|
2079
2791
|
*/
|
|
2080
2792
|
labelFont(element) {
|
|
2081
|
-
const
|
|
2082
|
-
const style = free ? this.styles.element(element) : {};
|
|
2793
|
+
const style = this.styles.element(element);
|
|
2083
2794
|
const size = style.fontSize ??
|
|
2084
2795
|
(element.kind === "text" ? Math.round(this.scale.label * 1.6) : this.scale.label);
|
|
2085
2796
|
const weight = style.fontWeight ?? this.scale.labelWeight;
|
|
2086
|
-
return { size, weight, line: Math.round(size * 1.25) };
|
|
2797
|
+
return { size, weight, line: Math.round(size * 1.25), font: textFont(style.font) };
|
|
2798
|
+
}
|
|
2799
|
+
/**
|
|
2800
|
+
* The description lines of an element: the theme's description size and line, or, when its
|
|
2801
|
+
* style writes `fontSize`, the description of that label size at the theme's ratio, in the
|
|
2802
|
+
* label's font at weight 400.
|
|
2803
|
+
*/
|
|
2804
|
+
descriptionFont(element) {
|
|
2805
|
+
const style = this.styles.element(element);
|
|
2806
|
+
const scale = writtenScale(this.theme, this.scale, "label", style.fontSize);
|
|
2807
|
+
return { size: scale.description, line: scale.descriptionLine, font: textFont(style.font) };
|
|
2808
|
+
}
|
|
2809
|
+
/** The title of a group: the theme's group title size at the label weight, or the style's. */
|
|
2810
|
+
groupFont(group) {
|
|
2811
|
+
const style = this.styles.element(group);
|
|
2812
|
+
const size = style.fontSize ?? this.scale.groupTitle;
|
|
2813
|
+
return {
|
|
2814
|
+
size,
|
|
2815
|
+
weight: style.fontWeight ?? this.scale.labelWeight,
|
|
2816
|
+
line: Math.round(size * 1.25),
|
|
2817
|
+
font: textFont(style.font),
|
|
2818
|
+
};
|
|
2819
|
+
}
|
|
2820
|
+
/** A tag's scale and face: its pill at the written size, in the mono font unless styled. */
|
|
2821
|
+
tagText(element) {
|
|
2822
|
+
const style = this.styles.element(element);
|
|
2823
|
+
return {
|
|
2824
|
+
scale: writtenScale(this.theme, this.scale, "tag", style.fontSize),
|
|
2825
|
+
face: { font: textFont(style.font, "mono"), weight: style.fontWeight ?? 500 },
|
|
2826
|
+
};
|
|
2827
|
+
}
|
|
2828
|
+
/** The text of a relation's label pill. */
|
|
2829
|
+
relationPill(relation) {
|
|
2830
|
+
return pillText(this.theme, this.scale, this.styles.relation(relation));
|
|
2831
|
+
}
|
|
2832
|
+
/** The text of a free line's label pill. */
|
|
2833
|
+
linePill(element) {
|
|
2834
|
+
return pillText(this.theme, this.scale, this.styles.element(element));
|
|
2087
2835
|
}
|
|
2088
2836
|
/**
|
|
2089
2837
|
* The drawn shape of a box-like element: its kind's (data stores are cylinders, decisions
|
|
@@ -2103,6 +2851,9 @@ class Sizing {
|
|
|
2103
2851
|
return known(this.styles.element(element).shape) ?? "rounded";
|
|
2104
2852
|
case "box":
|
|
2105
2853
|
return known(this.styles.element(element).shape) ?? known(element.shape) ?? "rounded";
|
|
2854
|
+
case "sticky":
|
|
2855
|
+
// A card: a rectangle with the corners of its `radius` (see `shapeOptions`).
|
|
2856
|
+
return "rectangle";
|
|
2106
2857
|
default:
|
|
2107
2858
|
return "rounded";
|
|
2108
2859
|
}
|
|
@@ -2115,6 +2866,8 @@ class Sizing {
|
|
|
2115
2866
|
const shape = this.shapeOf(element);
|
|
2116
2867
|
// A written radius (the element's style, a class or a rule) rounds every cornered shape.
|
|
2117
2868
|
const written = CORNERED_SHAPES.has(shape) ? this.styles.element(element).radius : undefined;
|
|
2869
|
+
if (element.kind === "sticky")
|
|
2870
|
+
return { radius: written ?? STICKY_RADIUS };
|
|
2118
2871
|
if (shape === "agent")
|
|
2119
2872
|
return { radius: written ?? this.theme.radius * 1.5 };
|
|
2120
2873
|
if (shape === "person") {
|
|
@@ -2122,6 +2875,62 @@ class Sizing {
|
|
|
2122
2875
|
}
|
|
2123
2876
|
return written === undefined ? {} : { radius: written };
|
|
2124
2877
|
}
|
|
2878
|
+
/**
|
|
2879
|
+
* Where `verticalAlign` moves the text block of an element: `top` or `bottom` on a free box whose
|
|
2880
|
+
* shape has a text band and whose icon is not centred (its band sits at `labelPosition`), else
|
|
2881
|
+
* undefined, and the block stays at the shape's text centre.
|
|
2882
|
+
*/
|
|
2883
|
+
verticalAlignOf(element) {
|
|
2884
|
+
if (element.kind !== "box")
|
|
2885
|
+
return undefined;
|
|
2886
|
+
const written = this.styles.element(element).verticalAlign;
|
|
2887
|
+
if (written !== "top" && written !== "bottom")
|
|
2888
|
+
return undefined;
|
|
2889
|
+
if (FIXED_TEXT_SHAPES.has(this.shapeOf(element)) || this.centred(element))
|
|
2890
|
+
return undefined;
|
|
2891
|
+
return written;
|
|
2892
|
+
}
|
|
2893
|
+
/**
|
|
2894
|
+
* The centre of an element's text block drawn in `box`: the shape's text centre, or with
|
|
2895
|
+
* `verticalAlign top` the first row band of the shape's text band, `padding` below its top edge,
|
|
2896
|
+
* whose span about the centre holds the block with the padding on each side (or as much as the
|
|
2897
|
+
* middle does), searched pixel by pixel towards the middle; `bottom` the same from the bottom
|
|
2898
|
+
* edge. A block that no band holds before the middle stays at the middle.
|
|
2899
|
+
*/
|
|
2900
|
+
textCenter(element, box) {
|
|
2901
|
+
const shape = this.shapeOf(element);
|
|
2902
|
+
const options = this.shapeOptions(element);
|
|
2903
|
+
const center = shapeTextCenter(shape, box, options);
|
|
2904
|
+
const align = this.verticalAlignOf(element);
|
|
2905
|
+
if (!align)
|
|
2906
|
+
return center;
|
|
2907
|
+
const key = `${element.id}\u0000${box.x}\u0000${box.y}\u0000${box.width}\u0000${box.height}`;
|
|
2908
|
+
const known = this.textCenters.get(key);
|
|
2909
|
+
if (known)
|
|
2910
|
+
return known;
|
|
2911
|
+
const column = this.textColumn(element);
|
|
2912
|
+
const spanOf = shapeTextSpanner(shape, box, options);
|
|
2913
|
+
const band = shapeTextBand(shape, box, options);
|
|
2914
|
+
const padding = this.theme.padding;
|
|
2915
|
+
const widthAt = (y) => {
|
|
2916
|
+
const span = spanOf(y + column.slots.top, y + column.slots.bottom, center.x);
|
|
2917
|
+
return span ? 2 * Math.max(0, Math.min(center.x - span[0], span[1] - center.x)) : -1;
|
|
2918
|
+
};
|
|
2919
|
+
const need = Math.min(column.width + 2 * padding, Math.max(0, widthAt(center.y)));
|
|
2920
|
+
const direction = align === "top" ? 1 : -1;
|
|
2921
|
+
const start = align === "top"
|
|
2922
|
+
? band.top + padding - column.slots.top
|
|
2923
|
+
: band.bottom - padding - column.slots.bottom;
|
|
2924
|
+
let found = center;
|
|
2925
|
+
for (let y = start; (center.y - y) * direction > 1e-9; y += direction) {
|
|
2926
|
+
if (widthAt(y) >= need - 1e-9) {
|
|
2927
|
+
found = { x: center.x, y };
|
|
2928
|
+
break;
|
|
2929
|
+
}
|
|
2930
|
+
}
|
|
2931
|
+
this.textCenters.set(key, found);
|
|
2932
|
+
return found;
|
|
2933
|
+
}
|
|
2125
2934
|
/**
|
|
2126
2935
|
* The rows the text column of a box-like element covers, from its text centre: the slots of its
|
|
2127
2936
|
* lines and icon, and the same rows with room for the glyphs' descenders. `width` is the widest
|
|
@@ -2134,9 +2943,10 @@ class Sizing {
|
|
|
2134
2943
|
columnWith(element, icon) {
|
|
2135
2944
|
const block = this.blockWith(element, icon);
|
|
2136
2945
|
const font = this.labelFont(element);
|
|
2946
|
+
const description = this.descriptionFont(element);
|
|
2137
2947
|
const lines = [
|
|
2138
2948
|
...block.labelOffsets.map((y) => ({ y, size: font.size })),
|
|
2139
|
-
...block.descriptionOffsets.map((y) => ({ y, size:
|
|
2949
|
+
...block.descriptionOffsets.map((y) => ({ y, size: description.size })),
|
|
2140
2950
|
];
|
|
2141
2951
|
let top = Math.min(...lines.map((l) => l.y - l.size / 2));
|
|
2142
2952
|
let bottom = Math.max(...lines.map((l) => l.y + l.size / 2));
|
|
@@ -2171,12 +2981,13 @@ class Sizing {
|
|
|
2171
2981
|
* aligned text may lie. The glyph rows count as clipped only where the shape reaches more than
|
|
2172
2982
|
* `ROW_TOLERANCE` into them.
|
|
2173
2983
|
*/
|
|
2174
|
-
textArea(element, box, rows, column = this.textColumn(element), centred = true) {
|
|
2984
|
+
textArea(element, box, rows, column = this.textColumn(element), centred = true, aligned = true) {
|
|
2175
2985
|
const shape = this.shapeOf(element);
|
|
2176
2986
|
if (FULL_WIDTH_TEXT.has(shape))
|
|
2177
2987
|
return box;
|
|
2178
2988
|
const options = this.shapeOptions(element);
|
|
2179
|
-
|
|
2989
|
+
// Where the block is drawn; the sizes of boxes are found about the middle.
|
|
2990
|
+
const center = aligned ? this.textCenter(element, box) : shapeTextCenter(shape, box, options);
|
|
2180
2991
|
const top = center.y + column[rows].top;
|
|
2181
2992
|
const bottom = center.y + column[rows].bottom;
|
|
2182
2993
|
const inset = rows === "glyphs" ? Math.min(ROW_TOLERANCE, (bottom - top) / 2) : 0;
|
|
@@ -2214,7 +3025,8 @@ class Sizing {
|
|
|
2214
3025
|
widthAt(element, height, margin, rows) {
|
|
2215
3026
|
const column = this.textColumn(element);
|
|
2216
3027
|
const need = column.width + 2 * margin;
|
|
2217
|
-
const fits = (width) => this.textArea(element, { x: 0, y: 0, width, height }, rows, column).width >=
|
|
3028
|
+
const fits = (width) => this.textArea(element, { x: 0, y: 0, width, height }, rows, column, true, false).width >=
|
|
3029
|
+
need - 1e-9;
|
|
2218
3030
|
let low = Math.max(1, need);
|
|
2219
3031
|
if (fits(low))
|
|
2220
3032
|
return Math.ceil(low - ROUNDING);
|
|
@@ -2241,14 +3053,151 @@ class Sizing {
|
|
|
2241
3053
|
headerOf(group) {
|
|
2242
3054
|
return groupHeaderOf(group, this.styles.element(group), this.theme);
|
|
2243
3055
|
}
|
|
3056
|
+
/** The vertical centre of a group's title, from the group's top edge, inside its header. */
|
|
3057
|
+
titleOffset(group) {
|
|
3058
|
+
const header = this.headerOf(group);
|
|
3059
|
+
// A larger title keeps the theme's band below it and its 45 % above.
|
|
3060
|
+
return header === this.theme.groupHeader
|
|
3061
|
+
? this.theme.groupHeader * 0.45
|
|
3062
|
+
: header - this.theme.groupHeader * 0.55;
|
|
3063
|
+
}
|
|
2244
3064
|
/** Horizontal alignment of a box or text label. */
|
|
3065
|
+
/**
|
|
3066
|
+
* A note's font: the theme's note size at weight 400, or a free note's `fontSize` and
|
|
3067
|
+
* `fontWeight`; its lines are as far apart as the note scale's ratio of line to size.
|
|
3068
|
+
*/
|
|
3069
|
+
noteFont(element) {
|
|
3070
|
+
const style = this.styles.element(element);
|
|
3071
|
+
const size = style.fontSize ?? this.scale.note;
|
|
3072
|
+
return {
|
|
3073
|
+
size,
|
|
3074
|
+
weight: style.fontWeight ?? 400,
|
|
3075
|
+
line: Math.round((size * this.scale.noteLine) / this.scale.note),
|
|
3076
|
+
font: textFont(style.font),
|
|
3077
|
+
};
|
|
3078
|
+
}
|
|
2245
3079
|
alignOf(element) {
|
|
2246
|
-
if (element.kind === "text")
|
|
3080
|
+
if (element.kind === "text" || element.kind === "note") {
|
|
2247
3081
|
return this.styles.element(element).textAlign ?? "left";
|
|
2248
|
-
|
|
3082
|
+
}
|
|
3083
|
+
if (element.kind === "box" || element.kind === "sticky") {
|
|
2249
3084
|
return this.styles.element(element).textAlign ?? "center";
|
|
3085
|
+
}
|
|
2250
3086
|
return "center";
|
|
2251
3087
|
}
|
|
3088
|
+
/**
|
|
3089
|
+
* How a sticky's text is drawn on a card of `size`, its area relative to the card's top left
|
|
3090
|
+
* corner: wrapped at the width of the card inside the `padding` (right of its icon), at
|
|
3091
|
+
* `fontSize` or 1.2 times the label size, stepped down by 1 until the lines fit the area or the
|
|
3092
|
+
* size reaches `STICKY_MIN_FONT_SIZE`; lines 1.25 times the size apart, aligned by `textAlign`
|
|
3093
|
+
* (`center`) and `verticalAlign` (`middle`). A blank text always fits.
|
|
3094
|
+
*/
|
|
3095
|
+
stickyText(element, size) {
|
|
3096
|
+
const key = `${element.id}\u0000${size.width}\u0000${size.height}`;
|
|
3097
|
+
const known = this.stickyTexts.get(key);
|
|
3098
|
+
if (known)
|
|
3099
|
+
return known;
|
|
3100
|
+
const style = this.styles.element(element);
|
|
3101
|
+
const weight = style.fontWeight ?? STICKY_FONT_WEIGHT;
|
|
3102
|
+
const face = textFont(style.font);
|
|
3103
|
+
const area = this.stickyArea(element, size);
|
|
3104
|
+
let font = style.fontSize ?? round(this.scale.label * STICKY_FONT_SCALE);
|
|
3105
|
+
let laid = this.stickyLines(element.label, area, font, weight, face);
|
|
3106
|
+
while (!laid.fits && font > STICKY_MIN_FONT_SIZE) {
|
|
3107
|
+
font = Math.max(STICKY_MIN_FONT_SIZE, round(font - 1));
|
|
3108
|
+
laid = this.stickyLines(element.label, area, font, weight, face);
|
|
3109
|
+
}
|
|
3110
|
+
const text = {
|
|
3111
|
+
area,
|
|
3112
|
+
size: font,
|
|
3113
|
+
weight,
|
|
3114
|
+
font: face,
|
|
3115
|
+
line: laid.line,
|
|
3116
|
+
lines: laid.lines,
|
|
3117
|
+
align: this.alignOf(element),
|
|
3118
|
+
verticalAlign: style.verticalAlign ?? "middle",
|
|
3119
|
+
fits: laid.fits,
|
|
3120
|
+
};
|
|
3121
|
+
this.stickyTexts.set(key, text);
|
|
3122
|
+
return text;
|
|
3123
|
+
}
|
|
3124
|
+
/**
|
|
3125
|
+
* The room of a sticky's text on a card of `size`: inside the padding, below the row of its icon,
|
|
3126
|
+
* so that the text keeps the card's whole width and stays centred on it.
|
|
3127
|
+
*/
|
|
3128
|
+
stickyArea(element, size) {
|
|
3129
|
+
const padding = this.theme.padding;
|
|
3130
|
+
const icon = this.iconOf(element);
|
|
3131
|
+
const row = icon ? icon.size + icon.gap : 0;
|
|
3132
|
+
return {
|
|
3133
|
+
x: padding,
|
|
3134
|
+
y: padding + row,
|
|
3135
|
+
width: Math.max(0, size.width - 2 * padding),
|
|
3136
|
+
height: Math.max(0, size.height - 2 * padding - row),
|
|
3137
|
+
};
|
|
3138
|
+
}
|
|
3139
|
+
/** A text wrapped at the width of `area` at a font size, and whether its lines fit `area`. */
|
|
3140
|
+
stickyLines(text, area, size, weight, font) {
|
|
3141
|
+
const measure = (line) => this.metrics.width(line, font, weight, size);
|
|
3142
|
+
const lines = wrapLines(text, area.width, measure);
|
|
3143
|
+
const line = round(size * 1.25);
|
|
3144
|
+
const fits = text.trim() === "" ||
|
|
3145
|
+
(lines.length * line <= area.height + 1e-9 &&
|
|
3146
|
+
lines.every((l) => measure(l) <= area.width + 1e-9));
|
|
3147
|
+
return { lines, line, fits };
|
|
3148
|
+
}
|
|
3149
|
+
/**
|
|
3150
|
+
* How a sticky's text is drawn on its card at `box`, the area in the box's coordinates: what
|
|
3151
|
+
* editors that type over the card take.
|
|
3152
|
+
*/
|
|
3153
|
+
stickyPlace(element, box) {
|
|
3154
|
+
const text = this.stickyText(element, { width: box.width, height: box.height });
|
|
3155
|
+
return {
|
|
3156
|
+
...text,
|
|
3157
|
+
area: { ...text.area, x: box.x + text.area.x, y: box.y + text.area.y },
|
|
3158
|
+
};
|
|
3159
|
+
}
|
|
3160
|
+
/**
|
|
3161
|
+
* Reports `layout.text-overflow` on a sticky whose text does not fit a card of `size` even at
|
|
3162
|
+
* its smallest font size, with the smallest card of the same proportions on which it fits at
|
|
3163
|
+
* that size, in whole pixels.
|
|
3164
|
+
*/
|
|
3165
|
+
checkSticky(element, size, range, diagnostics) {
|
|
3166
|
+
const text = this.stickyText(element, size);
|
|
3167
|
+
if (text.fits)
|
|
3168
|
+
return;
|
|
3169
|
+
const scaled = (width) => ({
|
|
3170
|
+
width,
|
|
3171
|
+
height: Math.max(1, Math.round((width * size.height) / Math.max(1, size.width))),
|
|
3172
|
+
});
|
|
3173
|
+
const fitsAt = (width) => {
|
|
3174
|
+
const card = scaled(width);
|
|
3175
|
+
return this.stickyLines(element.label, this.stickyArea(element, card), text.size, text.weight, text.font).fits;
|
|
3176
|
+
};
|
|
3177
|
+
let low = Math.max(1, Math.ceil(size.width));
|
|
3178
|
+
let high = low * 2;
|
|
3179
|
+
for (let i = 0; i < 32 && !fitsAt(high); i++) {
|
|
3180
|
+
low = high;
|
|
3181
|
+
high *= 2;
|
|
3182
|
+
}
|
|
3183
|
+
while (high - low > 1) {
|
|
3184
|
+
const middle = Math.floor((low + high) / 2);
|
|
3185
|
+
if (fitsAt(middle))
|
|
3186
|
+
high = middle;
|
|
3187
|
+
else
|
|
3188
|
+
low = middle;
|
|
3189
|
+
}
|
|
3190
|
+
const fit = scaled(high);
|
|
3191
|
+
const words = messages();
|
|
3192
|
+
diagnostics.push({
|
|
3193
|
+
code: "layout.text-overflow",
|
|
3194
|
+
severity: "warning",
|
|
3195
|
+
message: words.diagnostics.stickyOverflow(element.id, size.width, size.height, text.size, fit.width, fit.height),
|
|
3196
|
+
range,
|
|
3197
|
+
hints: [words.hints.textOverflow(element.id, fit.width, fit.height)],
|
|
3198
|
+
data: { ids: [element.id], size: { element: element.id, ...fit } },
|
|
3199
|
+
});
|
|
3200
|
+
}
|
|
2252
3201
|
/** The inset of a free text or icon drawn with a frame (`fill`, `stroke` or `pattern`). */
|
|
2253
3202
|
frameInset(element) {
|
|
2254
3203
|
const style = this.styles.element(element);
|
|
@@ -2297,7 +3246,10 @@ class Sizing {
|
|
|
2297
3246
|
case "tag":
|
|
2298
3247
|
return this.place(style.icon, Math.round(scale.tag * 1.3), "left");
|
|
2299
3248
|
case "note":
|
|
2300
|
-
return this.place(style.icon, Math.round(
|
|
3249
|
+
return this.place(style.icon, Math.round(this.noteFont(element).size * 1.3), "left");
|
|
3250
|
+
case "sticky":
|
|
3251
|
+
// At the top left of the card, its `iconSize` or the theme's, the text right of it.
|
|
3252
|
+
return this.place(style.icon, typeof style.iconSize === "number" ? style.iconSize : theme.iconSize, "left");
|
|
2301
3253
|
}
|
|
2302
3254
|
const icon = this.elementIcon(element);
|
|
2303
3255
|
if (!icon)
|
|
@@ -2375,7 +3327,7 @@ class Sizing {
|
|
|
2375
3327
|
const box = { x: 0, y: 0, ...size };
|
|
2376
3328
|
if (!this.rowsFit(element, box, slots))
|
|
2377
3329
|
return false;
|
|
2378
|
-
const area = this.textArea(element, box, "slots", { ...column, slots });
|
|
3330
|
+
const area = this.textArea(element, box, "slots", { ...column, slots }, true, false);
|
|
2379
3331
|
return area.width >= column.width + 2 * margin - 1e-9;
|
|
2380
3332
|
}
|
|
2381
3333
|
/** Whether rows about an element's text centre lie inside the text area of its shape in `box`. */
|
|
@@ -2691,6 +3643,10 @@ class Sizing {
|
|
|
2691
3643
|
relationIcon(relation) {
|
|
2692
3644
|
return this.place(this.styles.relation(relation).icon, Math.round(this.scale.relationLabel * 1.3), "left");
|
|
2693
3645
|
}
|
|
3646
|
+
/** The icon of a free line's pill, as a relation's pill takes it. */
|
|
3647
|
+
lineIcon(line) {
|
|
3648
|
+
return this.place(this.styles.element(line).icon, Math.round(this.scale.relationLabel * 1.3), "left");
|
|
3649
|
+
}
|
|
2694
3650
|
/**
|
|
2695
3651
|
* Label and description positions of a box, with the icon above or below them when it sits
|
|
2696
3652
|
* there. The label is broken at `\n` into lines `line` apart; a one-line label sits where it
|
|
@@ -2704,8 +3660,9 @@ class Sizing {
|
|
|
2704
3660
|
return this.blockWith(element, undefined);
|
|
2705
3661
|
}
|
|
2706
3662
|
blockWith(element, icon) {
|
|
2707
|
-
const scale = this.scale;
|
|
2708
3663
|
const font = this.labelFont(element);
|
|
3664
|
+
const description = this.descriptionFont(element);
|
|
3665
|
+
const scale = { description: description.size, descriptionLine: description.line };
|
|
2709
3666
|
const label = font.size;
|
|
2710
3667
|
const lines = splitLines(element.label).length;
|
|
2711
3668
|
// How far the first line of the label lies above its last.
|
|
@@ -2738,14 +3695,15 @@ class Sizing {
|
|
|
2738
3695
|
}
|
|
2739
3696
|
/** The widest of a box's label and description lines. */
|
|
2740
3697
|
textWidth(element) {
|
|
2741
|
-
const { metrics
|
|
3698
|
+
const { metrics } = this;
|
|
2742
3699
|
const font = this.labelFont(element);
|
|
3700
|
+
const description = this.descriptionFont(element);
|
|
2743
3701
|
let width = 0;
|
|
2744
3702
|
for (const line of splitLines(element.label)) {
|
|
2745
|
-
width = Math.max(width, metrics.width(line,
|
|
3703
|
+
width = Math.max(width, metrics.width(line, font.font, font.weight, font.size));
|
|
2746
3704
|
}
|
|
2747
3705
|
for (const line of element.description ? splitLines(element.description) : []) {
|
|
2748
|
-
width = Math.max(width, metrics.width(line,
|
|
3706
|
+
width = Math.max(width, metrics.width(line, description.font, 400, description.size));
|
|
2749
3707
|
}
|
|
2750
3708
|
return width;
|
|
2751
3709
|
}
|
|
@@ -2754,11 +3712,12 @@ class Sizing {
|
|
|
2754
3712
|
* default sizes and hints), and the minimum width below which the content touches the edges.
|
|
2755
3713
|
*/
|
|
2756
3714
|
textNeeds(element) {
|
|
2757
|
-
const { metrics,
|
|
3715
|
+
const { metrics, theme } = this;
|
|
2758
3716
|
const widest = (entries) => entries.reduce((a, b) => (b.width > a.width ? b : a), { text: "", width: 0 });
|
|
3717
|
+
const description = this.descriptionFont(element);
|
|
2759
3718
|
const descriptions = (element.description ? splitLines(element.description) : []).map((line) => ({
|
|
2760
3719
|
text: line,
|
|
2761
|
-
width: metrics.width(line,
|
|
3720
|
+
width: metrics.width(line, description.font, 400, description.size),
|
|
2762
3721
|
}));
|
|
2763
3722
|
const icon = this.iconOf(element);
|
|
2764
3723
|
const beside = icon && icon.position === "left" ? icon.size + icon.gap : 0;
|
|
@@ -2770,23 +3729,25 @@ class Sizing {
|
|
|
2770
3729
|
const font = this.labelFont(element);
|
|
2771
3730
|
const best = widest(splitLines(element.label).map((line) => ({
|
|
2772
3731
|
text: line,
|
|
2773
|
-
width: metrics.width(line,
|
|
3732
|
+
width: metrics.width(line, font.font, font.weight, font.size),
|
|
2774
3733
|
})));
|
|
2775
3734
|
const width = Math.ceil(Math.max(best.width, 1) + 2 * this.frameInset(element));
|
|
2776
3735
|
return { text: best.text, width, minimum: width };
|
|
2777
3736
|
}
|
|
2778
3737
|
case "tag": {
|
|
2779
|
-
const
|
|
3738
|
+
const tag = this.tagText(element);
|
|
3739
|
+
const width = metrics.width(element.label, tag.face.font, tag.face.weight, tag.scale.tag) + beside;
|
|
2780
3740
|
return {
|
|
2781
3741
|
text: element.label,
|
|
2782
|
-
width:
|
|
3742
|
+
width: tagPillWidth(metrics, tag.scale, element.label, beside, tag.face),
|
|
2783
3743
|
minimum: Math.ceil(width + 2 * MIN_TEXT_MARGIN),
|
|
2784
3744
|
};
|
|
2785
3745
|
}
|
|
2786
3746
|
case "note": {
|
|
3747
|
+
const font = this.noteFont(element);
|
|
2787
3748
|
const best = widest(splitLines(element.label).map((line) => ({
|
|
2788
3749
|
text: line,
|
|
2789
|
-
width: metrics.width(line,
|
|
3750
|
+
width: metrics.width(line, font.font, font.weight, font.size),
|
|
2790
3751
|
})));
|
|
2791
3752
|
const width = Math.ceil(Math.max(best.width + beside, 1));
|
|
2792
3753
|
return { text: best.text, width, minimum: width };
|
|
@@ -2795,9 +3756,10 @@ class Sizing {
|
|
|
2795
3756
|
// An untitled group has no text, so no minimum width.
|
|
2796
3757
|
if (this.untitled(element))
|
|
2797
3758
|
return { text: "", width: 0, minimum: 0 };
|
|
3759
|
+
const font = this.groupFont(element);
|
|
2798
3760
|
const title = {
|
|
2799
3761
|
text: element.label,
|
|
2800
|
-
width: metrics.width(element.label,
|
|
3762
|
+
width: metrics.width(element.label, font.font, font.weight, font.size) + beside,
|
|
2801
3763
|
};
|
|
2802
3764
|
const best = widest([title, ...descriptions]);
|
|
2803
3765
|
const inset = theme.groupPadding + 4;
|
|
@@ -2819,17 +3781,18 @@ class Sizing {
|
|
|
2819
3781
|
}
|
|
2820
3782
|
/** The widest of a box's label and description lines. */
|
|
2821
3783
|
widestLine(element) {
|
|
2822
|
-
const { metrics
|
|
3784
|
+
const { metrics } = this;
|
|
2823
3785
|
const font = this.labelFont(element);
|
|
3786
|
+
const description = this.descriptionFont(element);
|
|
2824
3787
|
const [first = "", ...rest] = splitLines(element.label);
|
|
2825
|
-
let best = { text: first, width: metrics.width(first,
|
|
3788
|
+
let best = { text: first, width: metrics.width(first, font.font, font.weight, font.size) };
|
|
2826
3789
|
for (const line of rest) {
|
|
2827
|
-
const width = metrics.width(line,
|
|
3790
|
+
const width = metrics.width(line, font.font, font.weight, font.size);
|
|
2828
3791
|
if (width > best.width)
|
|
2829
3792
|
best = { text: line, width };
|
|
2830
3793
|
}
|
|
2831
3794
|
for (const line of element.description ? splitLines(element.description) : []) {
|
|
2832
|
-
const width = metrics.width(line,
|
|
3795
|
+
const width = metrics.width(line, description.font, 400, description.size);
|
|
2833
3796
|
if (width > best.width)
|
|
2834
3797
|
best = { text: line, width };
|
|
2835
3798
|
}
|
|
@@ -2849,14 +3812,20 @@ class Sizing {
|
|
|
2849
3812
|
}
|
|
2850
3813
|
defaultSize(element) {
|
|
2851
3814
|
const { scale, theme } = this;
|
|
3815
|
+
if (element.kind === "sticky") {
|
|
3816
|
+
// A square that never grows for its text, scaled with the label size.
|
|
3817
|
+
const base = DEFAULT_SIZES.sticky;
|
|
3818
|
+
const factor = theme.labelSize / 13;
|
|
3819
|
+
return { width: Math.round(base.width * factor), height: Math.round(base.height * factor) };
|
|
3820
|
+
}
|
|
2852
3821
|
const needs = this.textNeeds(element);
|
|
2853
3822
|
switch (element.kind) {
|
|
2854
3823
|
case "tag":
|
|
2855
|
-
return { width: needs.width, height: scale.tagHeight };
|
|
3824
|
+
return { width: needs.width, height: this.tagText(element).scale.tagHeight };
|
|
2856
3825
|
case "note":
|
|
2857
3826
|
return {
|
|
2858
3827
|
width: needs.width,
|
|
2859
|
-
height: splitLines(element.label).length *
|
|
3828
|
+
height: splitLines(element.label).length * this.noteFont(element).line + 4,
|
|
2860
3829
|
};
|
|
2861
3830
|
case "text":
|
|
2862
3831
|
return {
|
|
@@ -3035,20 +4004,54 @@ class Emitter {
|
|
|
3035
4004
|
styles;
|
|
3036
4005
|
metrics;
|
|
3037
4006
|
sizing;
|
|
4007
|
+
free;
|
|
4008
|
+
elementsById;
|
|
4009
|
+
diagramId;
|
|
3038
4010
|
patterns = [];
|
|
3039
4011
|
painter = new IconPainter();
|
|
3040
4012
|
patternIds = new Map();
|
|
3041
4013
|
/** The title areas of drawn groups, which connectors pass under. */
|
|
3042
4014
|
titles = [];
|
|
3043
|
-
constructor(theme, scale, styles, metrics, sizing
|
|
4015
|
+
constructor(theme, scale, styles, metrics, sizing,
|
|
4016
|
+
/** Whether the diagram is free, the only kind whose styles give `opacity`. */
|
|
4017
|
+
free = false,
|
|
4018
|
+
/** The diagram's elements by id, where the groups around an element are found. */
|
|
4019
|
+
elementsById = new Map(),
|
|
4020
|
+
/**
|
|
4021
|
+
* The diagram's id, which the ids the scene declares (a sticky's clip) carry: a story frame
|
|
4022
|
+
* that cross-fades two views draws both scenes in one document.
|
|
4023
|
+
*/
|
|
4024
|
+
diagramId = "") {
|
|
3044
4025
|
this.theme = theme;
|
|
3045
4026
|
this.scale = scale;
|
|
3046
4027
|
this.styles = styles;
|
|
3047
4028
|
this.metrics = metrics;
|
|
3048
4029
|
this.sizing = sizing;
|
|
4030
|
+
this.free = free;
|
|
4031
|
+
this.elementsById = elementsById;
|
|
4032
|
+
this.diagramId = diagramId;
|
|
4033
|
+
}
|
|
4034
|
+
/**
|
|
4035
|
+
* The group of an element, group, line or pen of a free diagram with its style's `opacity`
|
|
4036
|
+
* times that of every group it is in as `styleOpacity`, which renderers multiply with a story's;
|
|
4037
|
+
* unchanged when it is opaque. A free scene is flat, so a group's children are its siblings and
|
|
4038
|
+
* a faded group fades them through their own groups.
|
|
4039
|
+
*/
|
|
4040
|
+
faded(group, element) {
|
|
4041
|
+
if (!this.free)
|
|
4042
|
+
return group;
|
|
4043
|
+
const own = (e) => Math.max(0, Math.min(1, this.styles.element(e).opacity ?? 1));
|
|
4044
|
+
let opacity = own(element);
|
|
4045
|
+
for (const id of element.groupPath) {
|
|
4046
|
+
const holder = this.elementsById.get(id);
|
|
4047
|
+
if (holder)
|
|
4048
|
+
opacity *= own(holder);
|
|
4049
|
+
}
|
|
4050
|
+
return opacity >= 1 ? group : { ...group, styleOpacity: opacity };
|
|
3049
4051
|
}
|
|
3050
4052
|
text(x, y, text, size, weight, fill, anchor = "middle", font = "body") {
|
|
3051
4053
|
const baseline = round(this.metrics.baseline(y, font, weight, size));
|
|
4054
|
+
const stack = writtenStack(font);
|
|
3052
4055
|
return {
|
|
3053
4056
|
kind: "text",
|
|
3054
4057
|
x: round(x),
|
|
@@ -3057,7 +4060,8 @@ class Emitter {
|
|
|
3057
4060
|
text,
|
|
3058
4061
|
size,
|
|
3059
4062
|
weight,
|
|
3060
|
-
font,
|
|
4063
|
+
font: fontRole(font),
|
|
4064
|
+
...(stack !== undefined ? { stack } : {}),
|
|
3061
4065
|
anchor,
|
|
3062
4066
|
fill,
|
|
3063
4067
|
};
|
|
@@ -3103,7 +4107,7 @@ class Emitter {
|
|
|
3103
4107
|
};
|
|
3104
4108
|
}
|
|
3105
4109
|
group(element, box) {
|
|
3106
|
-
return withUrl(withBounds(this.groupItems(element, box), box), element);
|
|
4110
|
+
return this.faded(withUrl(withBounds(this.groupItems(element, box), box), element), element);
|
|
3107
4111
|
}
|
|
3108
4112
|
groupItems(element, box) {
|
|
3109
4113
|
const { theme, scale } = this;
|
|
@@ -3116,7 +4120,11 @@ class Emitter {
|
|
|
3116
4120
|
return { kind: "group", id: element.id, role: "element", items };
|
|
3117
4121
|
}
|
|
3118
4122
|
const inset = theme.groupPadding + 4;
|
|
3119
|
-
const
|
|
4123
|
+
const font = this.sizing.groupFont(element);
|
|
4124
|
+
const title = this.sizing.titleOffset(element);
|
|
4125
|
+
// The title band grows with a larger title; the description keeps its place under the title.
|
|
4126
|
+
const below = title - theme.groupHeader * 0.45;
|
|
4127
|
+
const titleY = box.y + title;
|
|
3120
4128
|
const color = style.color ?? theme.ink;
|
|
3121
4129
|
let titleX = box.x + inset;
|
|
3122
4130
|
const icon = this.sizing.iconOf(element);
|
|
@@ -3124,38 +4132,40 @@ class Emitter {
|
|
|
3124
4132
|
items.push(this.icon(icon, titleX + icon.size / 2, titleY, style.iconColor ?? color));
|
|
3125
4133
|
titleX += icon.size + icon.gap;
|
|
3126
4134
|
}
|
|
3127
|
-
items.push(this.text(titleX, titleY, element.label,
|
|
4135
|
+
items.push(this.text(titleX, titleY, element.label, font.size, font.weight, color, "start", font.font));
|
|
3128
4136
|
// The title, and the description under it, are kept clear of connectors.
|
|
3129
|
-
const titleWidth = this.metrics.width(element.label,
|
|
4137
|
+
const titleWidth = this.metrics.width(element.label, font.font, font.weight, font.size);
|
|
3130
4138
|
const descriptionWidth = element.description
|
|
3131
|
-
? this.metrics.width(element.description,
|
|
4139
|
+
? this.metrics.width(element.description, font.font, 400, scale.description)
|
|
3132
4140
|
: 0;
|
|
3133
4141
|
const right = Math.max(titleX + titleWidth, box.x + inset + descriptionWidth);
|
|
3134
4142
|
this.titles.push({
|
|
3135
4143
|
id: element.id,
|
|
3136
4144
|
box: {
|
|
3137
4145
|
x: box.x + inset - TITLE_GAP,
|
|
3138
|
-
y: titleY -
|
|
4146
|
+
y: titleY - font.size * 0.75 - TITLE_GAP,
|
|
3139
4147
|
width: right - (box.x + inset) + 2 * TITLE_GAP,
|
|
3140
4148
|
height: (element.description
|
|
3141
|
-
? box.y + theme.groupHeader * 0.85 + scale.description * 0.6
|
|
3142
|
-
: titleY +
|
|
3143
|
-
(titleY -
|
|
4149
|
+
? box.y + below + theme.groupHeader * 0.85 + scale.description * 0.6
|
|
4150
|
+
: titleY + font.size * 0.6) -
|
|
4151
|
+
(titleY - font.size * 0.75) +
|
|
3144
4152
|
2 * TITLE_GAP,
|
|
3145
4153
|
},
|
|
3146
4154
|
});
|
|
3147
4155
|
if (element.description) {
|
|
3148
|
-
items.push(this.text(box.x + inset, box.y + theme.groupHeader * 0.85, element.description, scale.description, 400, style.color ?? theme.muted, "start"));
|
|
4156
|
+
items.push(this.text(box.x + inset, box.y + below + theme.groupHeader * 0.85, element.description, scale.description, 400, style.color ?? theme.muted, "start", font.font));
|
|
3149
4157
|
}
|
|
3150
4158
|
return { kind: "group", id: element.id, role: "element", items };
|
|
3151
4159
|
}
|
|
3152
4160
|
element(element, box) {
|
|
3153
|
-
return withUrl(withBounds(this.elementItems(element, box), box, this.sizing.innerArea(element, box), this.sizing.outsideLabel(element, box)?.box), element);
|
|
4161
|
+
return this.faded(withUrl(withBounds(this.elementItems(element, box), box, this.sizing.innerArea(element, box), this.sizing.outsideLabel(element, box)?.box), element), element);
|
|
3154
4162
|
}
|
|
3155
4163
|
elementItems(element, box) {
|
|
3156
4164
|
switch (element.kind) {
|
|
3157
4165
|
case "note":
|
|
3158
4166
|
return this.note(element, box);
|
|
4167
|
+
case "sticky":
|
|
4168
|
+
return this.sticky(element, box);
|
|
3159
4169
|
case "tag":
|
|
3160
4170
|
return this.tag(element, box);
|
|
3161
4171
|
case "text":
|
|
@@ -3217,7 +4227,7 @@ class Emitter {
|
|
|
3217
4227
|
const anchor = align === "left" ? "start" : align === "right" ? "end" : "middle";
|
|
3218
4228
|
const top = box.y + box.height / 2 - (lines.length * font.line) / 2;
|
|
3219
4229
|
lines.forEach((line, i) => {
|
|
3220
|
-
items.push(this.text(x, top + font.line / 2 + i * font.line, line, font.size, font.weight, style.color ?? this.theme.ink, anchor));
|
|
4230
|
+
items.push(this.text(x, top + font.line / 2 + i * font.line, line, font.size, font.weight, style.color ?? this.theme.ink, anchor, font.font));
|
|
3221
4231
|
});
|
|
3222
4232
|
return { kind: "group", id: element.id, role: "element", items };
|
|
3223
4233
|
}
|
|
@@ -3340,7 +4350,8 @@ class Emitter {
|
|
|
3340
4350
|
this.lines(items, element, x, band.y - column.slots.top, this.sizing.plainBlock(element), align);
|
|
3341
4351
|
return { kind: "group", id: element.id, role: "element", items };
|
|
3342
4352
|
}
|
|
3343
|
-
|
|
4353
|
+
// At the shape's text centre, or where `verticalAlign` moves the block.
|
|
4354
|
+
const textCenter = this.sizing.textCenter(element, box);
|
|
3344
4355
|
const block = this.sizing.textBlock(element);
|
|
3345
4356
|
// Aligned text keeps the padding inside the shape's text area.
|
|
3346
4357
|
const area = this.sizing.textArea(element, box, "slots");
|
|
@@ -3379,9 +4390,10 @@ class Emitter {
|
|
|
3379
4390
|
* anchor's side) about a text centre `y`, with the offsets of `block`.
|
|
3380
4391
|
*/
|
|
3381
4392
|
lines(items, element, x, y, block, align) {
|
|
3382
|
-
const { theme
|
|
4393
|
+
const { theme } = this;
|
|
3383
4394
|
const style = this.styles.element(element);
|
|
3384
4395
|
const font = this.sizing.labelFont(element);
|
|
4396
|
+
const description = this.sizing.descriptionFont(element);
|
|
3385
4397
|
const anchor = align === "left"
|
|
3386
4398
|
? "start"
|
|
3387
4399
|
: align === "right"
|
|
@@ -3390,11 +4402,11 @@ class Emitter {
|
|
|
3390
4402
|
? "middle"
|
|
3391
4403
|
: align;
|
|
3392
4404
|
splitLines(element.label).forEach((line, i) => {
|
|
3393
|
-
items.push(this.text(x, y + block.labelOffsets[i], line, font.size, font.weight, style.color ?? theme.ink, anchor));
|
|
4405
|
+
items.push(this.text(x, y + block.labelOffsets[i], line, font.size, font.weight, style.color ?? theme.ink, anchor, font.font));
|
|
3394
4406
|
});
|
|
3395
4407
|
const descriptionLines = element.description ? splitLines(element.description) : [];
|
|
3396
4408
|
descriptionLines.forEach((line, i) => {
|
|
3397
|
-
items.push(this.text(x, y + block.descriptionOffsets[i], line,
|
|
4409
|
+
items.push(this.text(x, y + block.descriptionOffsets[i], line, description.size, 400, style.color ?? theme.muted, anchor, description.font));
|
|
3398
4410
|
});
|
|
3399
4411
|
}
|
|
3400
4412
|
/**
|
|
@@ -3422,7 +4434,7 @@ class Emitter {
|
|
|
3422
4434
|
return { kind: "group", id: element.id, role: "element", items };
|
|
3423
4435
|
}
|
|
3424
4436
|
note(element, box) {
|
|
3425
|
-
const { theme
|
|
4437
|
+
const { theme } = this;
|
|
3426
4438
|
const style = this.styles.element(element);
|
|
3427
4439
|
const items = [];
|
|
3428
4440
|
if (style.fill || style.stroke || style.pattern) {
|
|
@@ -3430,57 +4442,216 @@ class Emitter {
|
|
|
3430
4442
|
items.push(outline, ...this.overlay(style, outline));
|
|
3431
4443
|
}
|
|
3432
4444
|
const color = style.color ?? theme.muted;
|
|
4445
|
+
const font = this.sizing.noteFont(element);
|
|
3433
4446
|
const icon = this.sizing.iconOf(element);
|
|
3434
4447
|
const indent = icon ? icon.size + icon.gap : 0;
|
|
3435
4448
|
if (icon) {
|
|
3436
|
-
items.push(this.icon(icon, box.x + icon.size / 2, box.y +
|
|
4449
|
+
items.push(this.icon(icon, box.x + icon.size / 2, box.y + font.line / 2, style.iconColor ?? color));
|
|
3437
4450
|
}
|
|
4451
|
+
// The lines are aligned in the room right of the icon: at its edge, its middle or the box's.
|
|
4452
|
+
const align = this.sizing.alignOf(element);
|
|
4453
|
+
const x = align === "left"
|
|
4454
|
+
? box.x + indent
|
|
4455
|
+
: align === "right"
|
|
4456
|
+
? box.x + box.width
|
|
4457
|
+
: box.x + indent + (box.width - indent) / 2;
|
|
4458
|
+
const anchor = align === "left" ? "start" : align === "right" ? "end" : "middle";
|
|
3438
4459
|
splitLines(element.label).forEach((line, i) => {
|
|
3439
|
-
items.push(this.text(
|
|
4460
|
+
items.push(this.text(x, box.y + font.line / 2 + i * font.line, line, font.size, font.weight, color, anchor, font.font));
|
|
3440
4461
|
});
|
|
3441
4462
|
return { kind: "group", id: element.id, role: "element", items };
|
|
3442
4463
|
}
|
|
4464
|
+
/**
|
|
4465
|
+
* A sticky: a soft shadow 3 px under its card, the card in the theme's `sticky` colour (or its
|
|
4466
|
+
* `fill`) with `radius` 2 and an outline only when its style writes `stroke`, its icon at the
|
|
4467
|
+
* top left inside the padding, and its text (`Sizing.stickyText`) in `stickyText` (or its
|
|
4468
|
+
* `color`), wrapped and shrunk to the card, aligned by `textAlign` and `verticalAlign`. Text that
|
|
4469
|
+
* does not fit even at the smallest size starts at the top and is clipped to the card.
|
|
4470
|
+
*/
|
|
4471
|
+
sticky(element, box) {
|
|
4472
|
+
const { theme } = this;
|
|
4473
|
+
const style = this.styles.element(element);
|
|
4474
|
+
const radius = cornerRadius(style.radius ?? STICKY_RADIUS, box);
|
|
4475
|
+
const shadow = {
|
|
4476
|
+
kind: "rect",
|
|
4477
|
+
x: round(box.x),
|
|
4478
|
+
y: round(box.y + STICKY_SHADOW.offset),
|
|
4479
|
+
width: round(box.width),
|
|
4480
|
+
height: round(box.height),
|
|
4481
|
+
radius,
|
|
4482
|
+
fill: STICKY_SHADOW.color,
|
|
4483
|
+
stroke: "none",
|
|
4484
|
+
strokeWidth: 0,
|
|
4485
|
+
dashed: false,
|
|
4486
|
+
opacity: STICKY_SHADOW.opacity,
|
|
4487
|
+
};
|
|
4488
|
+
const card = this.rect(box, radius, style.fill ?? theme.sticky, style.stroke ?? "none", style.stroke ? (style.width ?? theme.strokeWidth) : 0, strokeDash(style.line));
|
|
4489
|
+
const items = [shadow, card];
|
|
4490
|
+
const text = this.sizing.stickyPlace(element, box);
|
|
4491
|
+
const { area, line } = text;
|
|
4492
|
+
const color = style.color ?? theme.stickyText;
|
|
4493
|
+
const icon = this.sizing.iconOf(element);
|
|
4494
|
+
if (icon) {
|
|
4495
|
+
// In the top left corner inside the padding, on a row of its own above the text.
|
|
4496
|
+
const x = box.x + theme.padding + icon.size / 2;
|
|
4497
|
+
const y = box.y + theme.padding + icon.size / 2;
|
|
4498
|
+
items.push(this.icon(icon, x, y, style.iconColor ?? color));
|
|
4499
|
+
}
|
|
4500
|
+
const block = text.lines.length * line;
|
|
4501
|
+
const top = !text.fits
|
|
4502
|
+
? area.y
|
|
4503
|
+
: text.verticalAlign === "top"
|
|
4504
|
+
? area.y
|
|
4505
|
+
: text.verticalAlign === "bottom"
|
|
4506
|
+
? area.y + area.height - block
|
|
4507
|
+
: area.y + (area.height - block) / 2;
|
|
4508
|
+
const x = text.align === "left"
|
|
4509
|
+
? area.x
|
|
4510
|
+
: text.align === "right"
|
|
4511
|
+
? area.x + area.width
|
|
4512
|
+
: area.x + area.width / 2;
|
|
4513
|
+
const anchor = text.align === "left" ? "start" : text.align === "right" ? "end" : "middle";
|
|
4514
|
+
text.lines.forEach((content, i) => {
|
|
4515
|
+
items.push(this.text(x, top + line / 2 + i * line, content, text.size, text.weight, color, anchor, text.font));
|
|
4516
|
+
});
|
|
4517
|
+
const group = { kind: "group", id: element.id, role: "element", items };
|
|
4518
|
+
if (text.fits)
|
|
4519
|
+
return group;
|
|
4520
|
+
const clip = {
|
|
4521
|
+
id: `clip-${xmlName(this.diagramId)}-${xmlName(element.id)}`,
|
|
4522
|
+
x: round(box.x),
|
|
4523
|
+
y: round(box.y),
|
|
4524
|
+
width: round(box.width),
|
|
4525
|
+
height: round(box.height),
|
|
4526
|
+
radius,
|
|
4527
|
+
};
|
|
4528
|
+
return { ...group, textClip: clip };
|
|
4529
|
+
}
|
|
3443
4530
|
tag(element, box) {
|
|
3444
|
-
const { theme
|
|
4531
|
+
const { theme } = this;
|
|
3445
4532
|
const style = this.styles.element(element);
|
|
3446
4533
|
const fill = style.fill ?? theme.tag;
|
|
3447
4534
|
const center = boxCenter(box);
|
|
3448
|
-
// A pill, upright when the tag is taller than wide.
|
|
3449
|
-
const outline = this.rect(box, cornerRadius(Infinity, box), fill, style.stroke ?? fill, style.width ?? theme.strokeWidth, strokeDash(style.line));
|
|
3450
4535
|
const color = style.color ?? theme.tagText;
|
|
4536
|
+
const { scale, face } = this.sizing.tagText(element);
|
|
4537
|
+
// The pill sequence tags share, upright when the tag is taller than wide.
|
|
4538
|
+
const { outline, label } = tagPill(this.metrics, scale, box, element.label, {
|
|
4539
|
+
fill,
|
|
4540
|
+
stroke: style.stroke ?? fill,
|
|
4541
|
+
strokeWidth: style.width ?? theme.strokeWidth,
|
|
4542
|
+
dashed: strokeDash(style.line),
|
|
4543
|
+
color,
|
|
4544
|
+
}, face);
|
|
3451
4545
|
const items = [outline, ...this.overlay(style, outline)];
|
|
3452
4546
|
const icon = this.sizing.iconOf(element);
|
|
3453
4547
|
if (icon) {
|
|
3454
|
-
const textWidth = this.metrics.width(element.label,
|
|
4548
|
+
const textWidth = this.metrics.width(element.label, face.font, face.weight, scale.tag);
|
|
3455
4549
|
const left = center.x - (icon.size + icon.gap + textWidth) / 2;
|
|
3456
4550
|
items.push(this.icon(icon, left + icon.size / 2, center.y, style.iconColor ?? color));
|
|
3457
|
-
items.push(this.text(left + icon.size + icon.gap, center.y, element.label, scale.tag,
|
|
4551
|
+
items.push(this.text(left + icon.size + icon.gap, center.y, element.label, scale.tag, face.weight, color, "start", face.font));
|
|
3458
4552
|
}
|
|
3459
4553
|
else {
|
|
3460
|
-
items.push(
|
|
4554
|
+
items.push(label);
|
|
3461
4555
|
}
|
|
3462
4556
|
return { kind: "group", id: element.id, role: "element", items };
|
|
3463
4557
|
}
|
|
3464
4558
|
relation(relation, line, start, end, label, curve) {
|
|
3465
|
-
const { theme, scale } = this;
|
|
3466
4559
|
const style = this.styles.relation(relation);
|
|
3467
4560
|
// An assumption is drawn dashed in the muted colour until a style says otherwise.
|
|
3468
4561
|
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
4562
|
// A straight connector passes under the titles of groups it crosses (not its own ends'). It
|
|
3474
4563
|
// is drawn from beyond the labels its own ends draw outside their boxes (see `underLabel`).
|
|
3475
4564
|
const holes = this.titles
|
|
3476
4565
|
.filter((t) => t.id !== relation.from && t.id !== relation.to)
|
|
3477
4566
|
.map((t) => t.box);
|
|
4567
|
+
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);
|
|
4568
|
+
return { kind: "group", id: relation.key, role: "relation", items, track: roundedPoints(line) };
|
|
4569
|
+
}
|
|
4570
|
+
/**
|
|
4571
|
+
* A free line: a connector without ends, drawn as a relation is (its line, its heads, its pill),
|
|
4572
|
+
* as an element group whose `track` is its drawn line and whose `bounds` is its box. A line whose
|
|
4573
|
+
* points coincide is a dot of its width.
|
|
4574
|
+
*/
|
|
4575
|
+
line(element, box, line, start, end, label, curve) {
|
|
4576
|
+
const style = this.styles.element(element);
|
|
4577
|
+
const stroke = style.stroke ?? this.theme.connector;
|
|
4578
|
+
const strokeWidth = style.width ?? this.theme.connectorWidth;
|
|
4579
|
+
const first = line[0];
|
|
4580
|
+
const dot = first !== undefined && line.every((p) => p.x === first.x && p.y === first.y);
|
|
4581
|
+
const items = dot
|
|
4582
|
+
? [
|
|
4583
|
+
{
|
|
4584
|
+
kind: "path",
|
|
4585
|
+
d: circlePath(round(first.x), round(first.y), Math.max(1, strokeWidth)),
|
|
4586
|
+
fill: stroke,
|
|
4587
|
+
stroke: "none",
|
|
4588
|
+
strokeWidth: 0,
|
|
4589
|
+
dashed: false,
|
|
4590
|
+
},
|
|
4591
|
+
]
|
|
4592
|
+
: this.connectorItems(element.id, style, stroke, strokeDash(style.line ?? "solid"), [], line, start, end, label && { ...label, technology: null }, this.sizing.lineIcon(element), curve);
|
|
4593
|
+
return this.faded(withBounds({ kind: "group", id: element.id, role: "element", items, track: roundedPoints(line) }, box), element);
|
|
4594
|
+
}
|
|
4595
|
+
/**
|
|
4596
|
+
* A pen stroke: one path through its points (`penPath`) with round caps and joins and no fill,
|
|
4597
|
+
* in its `stroke` (the theme's `ink`), `width`, `line` and `opacity`, then its heads; a stroke
|
|
4598
|
+
* whose points coincide is a disc of its width. An element group marked `freehand`, whose
|
|
4599
|
+
* `track` is its points and whose `bounds` is its box. `points` are all its points; `line` the
|
|
4600
|
+
* same cut where its heads begin.
|
|
4601
|
+
*/
|
|
4602
|
+
pen(element, box, points, line, start, end) {
|
|
4603
|
+
const style = this.styles.element(element);
|
|
4604
|
+
const stroke = style.stroke ?? this.theme.ink;
|
|
4605
|
+
const width = penWidth(style);
|
|
4606
|
+
const first = points[0];
|
|
4607
|
+
const items = isDot(points)
|
|
4608
|
+
? [
|
|
4609
|
+
{
|
|
4610
|
+
kind: "path",
|
|
4611
|
+
d: circlePath(round(first.x), round(first.y), width / 2),
|
|
4612
|
+
fill: stroke,
|
|
4613
|
+
stroke: "none",
|
|
4614
|
+
strokeWidth: 0,
|
|
4615
|
+
dashed: false,
|
|
4616
|
+
},
|
|
4617
|
+
]
|
|
4618
|
+
: [
|
|
4619
|
+
{
|
|
4620
|
+
kind: "path",
|
|
4621
|
+
d: penPath(line),
|
|
4622
|
+
fill: "none",
|
|
4623
|
+
stroke,
|
|
4624
|
+
strokeWidth: width,
|
|
4625
|
+
dashed: strokeDash(style.line),
|
|
4626
|
+
rounded: true,
|
|
4627
|
+
},
|
|
4628
|
+
...markerItems(start, stroke, width),
|
|
4629
|
+
...markerItems(end, stroke, width),
|
|
4630
|
+
];
|
|
4631
|
+
return this.faded(withBounds({
|
|
4632
|
+
kind: "group",
|
|
4633
|
+
id: element.id,
|
|
4634
|
+
role: "element",
|
|
4635
|
+
freehand: true,
|
|
4636
|
+
items,
|
|
4637
|
+
track: roundedPoints(points),
|
|
4638
|
+
}, box), element);
|
|
4639
|
+
}
|
|
4640
|
+
/**
|
|
4641
|
+
* The items of a connector, in the order motion and hit tests expect: its line (pieces around
|
|
4642
|
+
* `holes`, or one curve), its heads, then its label pill with its icon and text.
|
|
4643
|
+
*/
|
|
4644
|
+
connectorItems(id, style, stroke, dashed, holes, line, start, end, label, icon, curve) {
|
|
4645
|
+
const { theme } = this;
|
|
4646
|
+
const face = pillText(theme, this.scale, style);
|
|
4647
|
+
const scale = face.scale;
|
|
4648
|
+
const strokeWidth = style.width ?? theme.connectorWidth;
|
|
3478
4649
|
const pieces = curve ? [] : splitAround(line, holes);
|
|
3479
4650
|
const items = curve
|
|
3480
4651
|
? [{ kind: "path", d: curve, fill: "none", stroke, strokeWidth, dashed }]
|
|
3481
4652
|
: pieces.map((piece) => ({
|
|
3482
4653
|
kind: "polyline",
|
|
3483
|
-
points:
|
|
4654
|
+
points: roundedPoints(piece),
|
|
3484
4655
|
stroke,
|
|
3485
4656
|
strokeWidth,
|
|
3486
4657
|
dashed,
|
|
@@ -3498,26 +4669,25 @@ class Emitter {
|
|
|
3498
4669
|
const pill = [
|
|
3499
4670
|
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
4671
|
];
|
|
3501
|
-
const icon = this.sizing.relationIcon(relation);
|
|
3502
4672
|
if (icon && label.text === "") {
|
|
3503
4673
|
pill.push(this.icon(icon, center.x, center.y, style.iconColor ?? color));
|
|
3504
4674
|
}
|
|
3505
4675
|
else if (icon) {
|
|
3506
|
-
const textWidth = this.metrics.width(label.text,
|
|
4676
|
+
const textWidth = this.metrics.width(label.text, face.font, face.weight, scale.relationLabel);
|
|
3507
4677
|
const left = center.x - (icon.size + icon.gap + textWidth) / 2;
|
|
3508
4678
|
pill.push(this.icon(icon, left + icon.size / 2, center.y, style.iconColor ?? color));
|
|
3509
|
-
pill.push(this.text(left + icon.size + icon.gap, center.y, label.text, scale.relationLabel,
|
|
4679
|
+
pill.push(this.text(left + icon.size + icon.gap, center.y, label.text, scale.relationLabel, face.weight, color, "start", face.font));
|
|
3510
4680
|
}
|
|
3511
4681
|
else if (label.text !== "") {
|
|
3512
|
-
pill.push(this.text(center.x, center.y, label.text, scale.relationLabel,
|
|
4682
|
+
pill.push(this.text(center.x, center.y, label.text, scale.relationLabel, face.weight, color, "middle", face.font));
|
|
3513
4683
|
}
|
|
3514
4684
|
if (label.technology) {
|
|
3515
4685
|
const y = label.text === "" && !icon ? box.y + box.height / 2 : center.y + techLine;
|
|
3516
|
-
pill.push(this.text(center.x, y, label.technology, techSize, 400, style.color ?? theme.muted));
|
|
4686
|
+
pill.push(this.text(center.x, y, label.technology, techSize, 400, style.color ?? theme.muted, "middle", face.font));
|
|
3517
4687
|
}
|
|
3518
|
-
items.push({ kind: "group", id
|
|
4688
|
+
items.push({ kind: "group", id, role: "label", items: pill });
|
|
3519
4689
|
}
|
|
3520
|
-
return
|
|
4690
|
+
return items;
|
|
3521
4691
|
}
|
|
3522
4692
|
/** The brand strip: hairline, mark, wordmark and diagram title, spanning the canvas. */
|
|
3523
4693
|
frame(width, stripTop, title) {
|