@meltemi/diagrams-layout 1.10.0 → 1.10.18
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 +28 -14
- 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 +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/layout.d.ts +203 -7
- package/dist/layout.d.ts.map +1 -1
- package/dist/layout.js +883 -135
- 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 +24 -1
- package/dist/messages/en.d.ts.map +1 -1
- package/dist/messages/en.js +25 -2
- package/dist/messages/en.js.map +1 -1
- package/dist/messages/es.d.ts.map +1 -1
- package/dist/messages/es.js +8 -1
- package/dist/messages/es.js.map +1 -1
- package/dist/messages/index.d.ts +7 -0
- package/dist/messages/index.d.ts.map +1 -1
- package/dist/scene.d.ts +41 -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 +658 -282
- package/dist/sequence.js.map +1 -1
- package/dist/styles.d.ts +5 -1
- package/dist/styles.d.ts.map +1 -1
- package/dist/styles.js +20 -4
- 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 +5 -0
- package/dist/theme.d.ts.map +1 -1
- package/dist/theme.js +20 -0
- package/dist/theme.js.map +1 -1
- package/package.json +11 -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";
|
|
18
|
+
import { MIN_TEXT_MARGIN, TextMetrics, estimateMeasurer, fontRole, textFont, writtenStack, } from "./measure.js";
|
|
19
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,8 +50,11 @@ export const DEFAULT_SIZES = {
|
|
|
38
50
|
text: null,
|
|
39
51
|
icon: { width: 48, height: 48 },
|
|
40
52
|
image: null,
|
|
41
|
-
// A
|
|
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.
|
|
42
56
|
line: null,
|
|
57
|
+
pen: null,
|
|
43
58
|
// Sequence heads are measured by the sequence layout.
|
|
44
59
|
participant: null,
|
|
45
60
|
actor: null,
|
|
@@ -166,6 +181,92 @@ export function typeScale(theme) {
|
|
|
166
181
|
frameMark: Math.round(theme.frameSize * 2),
|
|
167
182
|
};
|
|
168
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
|
+
}
|
|
169
270
|
/** Room kept between a group's title and the connectors that pass under it. */
|
|
170
271
|
const TITLE_GAP = 3;
|
|
171
272
|
/**
|
|
@@ -300,17 +401,88 @@ function linePointsOf(line) {
|
|
|
300
401
|
}
|
|
301
402
|
/** The box of a free line: the bounding box of its points, at least 1 wide and 1 tall. */
|
|
302
403
|
function lineBox(points) {
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
+
}
|
|
307
426
|
return {
|
|
308
|
-
x,
|
|
309
|
-
y,
|
|
310
|
-
width:
|
|
311
|
-
height:
|
|
427
|
+
x: x0 - width / 2,
|
|
428
|
+
y: y0 - width / 2,
|
|
429
|
+
width: x1 - x0 + width,
|
|
430
|
+
height: y1 - y0 + width,
|
|
312
431
|
};
|
|
313
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
|
+
}
|
|
314
486
|
/** The side whose normal points most nearly from `from` towards `to`. */
|
|
315
487
|
function sideToward(from, to) {
|
|
316
488
|
const dx = to.x - from.x;
|
|
@@ -597,8 +769,8 @@ function iconDiagnostics(diagram, styles, sizing, lines, words, themeRange) {
|
|
|
597
769
|
/**
|
|
598
770
|
* `layout.vertical-align-ignored` for a `verticalAlign` that no element it styles uses: a box whose
|
|
599
771
|
* shape draws its text at a fixed spot, a box whose icon is the centre piece (its label band is at
|
|
600
|
-
* `labelPosition`), or an element that is
|
|
601
|
-
* once for a theme rule or class, on the diagram's `theme` (`themeRange`).
|
|
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`).
|
|
602
774
|
*/
|
|
603
775
|
function verticalAlignDiagnostics(diagram, styles, sizing, lines, words, themeRange) {
|
|
604
776
|
const sources = new Map();
|
|
@@ -609,7 +781,8 @@ function verticalAlignDiagnostics(diagram, styles, sizing, lines, words, themeRa
|
|
|
609
781
|
const box = element.kind === "box";
|
|
610
782
|
const fixed = box && FIXED_TEXT_SHAPES.has(sizing.shapeOf(element));
|
|
611
783
|
const centred = box && !fixed && sizing.centred(element);
|
|
612
|
-
|
|
784
|
+
// A sticky's text block always moves within its card.
|
|
785
|
+
const used = (box && !fixed && !centred) || element.kind === "sticky";
|
|
613
786
|
const why = used
|
|
614
787
|
? undefined
|
|
615
788
|
: fixed
|
|
@@ -666,7 +839,9 @@ function layoutDrawing(compilation, diagram, options = {}) {
|
|
|
666
839
|
const definitions = options.themes ?? compilation.document.themes;
|
|
667
840
|
const resolved = resolveTheme(written ?? options.defaultTheme ?? null, definitions);
|
|
668
841
|
// A default theme that does not resolve is reported where it is configured.
|
|
669
|
-
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;
|
|
670
845
|
if (!resolved.known && written !== null) {
|
|
671
846
|
const fileThemes = definitions.map((t) => t.id).filter((id) => !id.includes("#"));
|
|
672
847
|
diagnostics.push({
|
|
@@ -720,18 +895,68 @@ function layoutDrawing(compilation, diagram, options = {}) {
|
|
|
720
895
|
places.set(line.id, boxCenter(box));
|
|
721
896
|
sizes.set(line.id, { width: box.width, height: box.height });
|
|
722
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
|
+
}
|
|
723
906
|
const mode = options.layout ?? diagram.properties.layout;
|
|
724
907
|
const free = diagram.type === "free";
|
|
725
|
-
|
|
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));
|
|
726
935
|
// Places are ignored in `auto` only when an engine arranges the diagram.
|
|
727
936
|
const arranging = options.engine !== undefined &&
|
|
728
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
|
+
}
|
|
729
953
|
// Free diagrams place children inside their group, so a group must be placed when its
|
|
730
954
|
// children are, and always when nothing is arranged.
|
|
731
955
|
if (free && !(arranging && mode === "auto")) {
|
|
732
956
|
const placedChild = new Set(diagram.elements.flatMap((e) => (places.has(e.id) ? [parentOf(e) ?? ""] : [])));
|
|
733
957
|
const missing = diagram.elements.filter((e) => e.kind === "group" &&
|
|
734
958
|
!places.has(e.id) &&
|
|
959
|
+
!holders.has(e.id) &&
|
|
735
960
|
(!arranging || placedChild.has(e.id) || unplaced.length === 0));
|
|
736
961
|
for (const group of missing) {
|
|
737
962
|
diagnostics.push({
|
|
@@ -748,7 +973,10 @@ function layoutDrawing(compilation, diagram, options = {}) {
|
|
|
748
973
|
}
|
|
749
974
|
if (unplaced.length > 0 && !arranging) {
|
|
750
975
|
if (mode === "manual") {
|
|
976
|
+
// An unplaced list is a group without a place, reported above.
|
|
751
977
|
for (const element of unplaced) {
|
|
978
|
+
if (element.kind === "group")
|
|
979
|
+
continue;
|
|
752
980
|
diagnostics.push({
|
|
753
981
|
code: "layout.missing-placement",
|
|
754
982
|
severity: "error",
|
|
@@ -769,6 +997,8 @@ function layoutDrawing(compilation, diagram, options = {}) {
|
|
|
769
997
|
}
|
|
770
998
|
return { scene: null, diagnostics };
|
|
771
999
|
}
|
|
1000
|
+
if (unplacedInLists.length > 0)
|
|
1001
|
+
return { scene: null, diagnostics };
|
|
772
1002
|
// A placed and sized group that holds nothing is a band: it stays where it is written, out of
|
|
773
1003
|
// the engine's request. In `auto` only bands at the top level, whose places are in diagram
|
|
774
1004
|
// coordinates, are kept.
|
|
@@ -781,8 +1011,9 @@ function layoutDrawing(compilation, diagram, options = {}) {
|
|
|
781
1011
|
childless(e))
|
|
782
1012
|
.map((e) => e.id));
|
|
783
1013
|
if (arranging && mode === "auto") {
|
|
1014
|
+
// What a list holds keeps its places: the engine arranges the list as one box.
|
|
784
1015
|
const ignored = diagram.placement
|
|
785
|
-
.filter((e) => (e.statement === "place" && !autoBands.has(e.target)) ||
|
|
1016
|
+
.filter((e) => (e.statement === "place" && !autoBands.has(e.target) && !holders.has(e.target)) ||
|
|
786
1017
|
(e.statement === "route" && e.via.length > 0))
|
|
787
1018
|
.sort((a, b) => a.range.start.offset - b.range.start.offset)[0];
|
|
788
1019
|
if (ignored) {
|
|
@@ -795,7 +1026,6 @@ function layoutDrawing(compilation, diagram, options = {}) {
|
|
|
795
1026
|
});
|
|
796
1027
|
}
|
|
797
1028
|
}
|
|
798
|
-
const styles = new StyleResolver(theme, diagram);
|
|
799
1029
|
const images = new Map();
|
|
800
1030
|
for (const element of diagram.elements) {
|
|
801
1031
|
if (element.kind !== "image" || element.source === null)
|
|
@@ -822,8 +1052,10 @@ function layoutDrawing(compilation, diagram, options = {}) {
|
|
|
822
1052
|
const explicit = sizes.get(element.id);
|
|
823
1053
|
const size = explicit ?? sizing.defaultSize(element);
|
|
824
1054
|
const entry = sizeEntries.get(element.id);
|
|
825
|
-
|
|
1055
|
+
// Stickies are checked at the size they are drawn, which a list may stretch.
|
|
1056
|
+
if (element.kind !== "sticky" && explicit && entry) {
|
|
826
1057
|
sizing.checkOverflow(element, explicit, entry, diagnostics);
|
|
1058
|
+
}
|
|
827
1059
|
elementSizes.set(element.id, size);
|
|
828
1060
|
}
|
|
829
1061
|
// Turned elements: their angle, clockwise; 0 and 360 draw upright.
|
|
@@ -879,30 +1111,66 @@ function layoutDrawing(compilation, diagram, options = {}) {
|
|
|
879
1111
|
: [];
|
|
880
1112
|
return outlineBounds([...turnedAt(id, box), ...corners]);
|
|
881
1113
|
};
|
|
882
|
-
// Free diagrams: turn places relative to group interiors into diagram coordinates,
|
|
883
|
-
//
|
|
884
|
-
|
|
885
|
-
|
|
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)
|
|
886
1121
|
? extentAt(id, boxFromCenter({ x: 0, y: 0 }, size.width, size.height))
|
|
887
1122
|
: undefined);
|
|
888
|
-
|
|
889
|
-
for (const [id,
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
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);
|
|
893
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
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
// The groups whose boxes follow the place an engine gave the list that holds them.
|
|
1155
|
+
const framedGroups = new Set();
|
|
894
1156
|
// Positions: placed, or arranged by the engine; fixed ones never move afterwards.
|
|
895
1157
|
const centers = new Map();
|
|
896
1158
|
const fixed = new Set();
|
|
897
1159
|
let engineRoutes = new Map();
|
|
898
1160
|
let engineGroups = new Map();
|
|
899
|
-
// `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.
|
|
900
1163
|
const minimums = new Map();
|
|
901
|
-
if (
|
|
1164
|
+
if (autoArranged) {
|
|
902
1165
|
for (const element of diagram.elements) {
|
|
903
1166
|
const size = sizes.get(element.id);
|
|
904
|
-
if (element.kind === "group" &&
|
|
1167
|
+
if (element.kind === "group" &&
|
|
1168
|
+
size &&
|
|
1169
|
+
!childless(element) &&
|
|
1170
|
+
!isList(element) &&
|
|
1171
|
+
!holders.has(element.id)) {
|
|
905
1172
|
minimums.set(element.id, size);
|
|
1173
|
+
}
|
|
906
1174
|
}
|
|
907
1175
|
}
|
|
908
1176
|
if (arranging) {
|
|
@@ -918,6 +1186,13 @@ function layoutDrawing(compilation, diagram, options = {}) {
|
|
|
918
1186
|
// A turned element is arranged as its drawn box.
|
|
919
1187
|
const extents = new Map();
|
|
920
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
|
+
}
|
|
921
1196
|
const shifts = new Map();
|
|
922
1197
|
for (const id of new Set([...outside.keys(), ...rotations.keys()])) {
|
|
923
1198
|
const size = elementSizes.get(id);
|
|
@@ -940,7 +1215,7 @@ function layoutDrawing(compilation, diagram, options = {}) {
|
|
|
940
1215
|
let arrangement;
|
|
941
1216
|
const request = (mirrored) => arrangeRequest(diagram, arranged, mode === "assisted"
|
|
942
1217
|
? new Map([...places].map(([id, place]) => [id, shifted(id, place, 1)]))
|
|
943
|
-
: 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);
|
|
944
1219
|
try {
|
|
945
1220
|
arrangement = options.engine.arrange(request(new Set()));
|
|
946
1221
|
// The ports of icon shapes face the flow; a relation the engine turns back would wrap
|
|
@@ -971,11 +1246,37 @@ function layoutDrawing(compilation, diagram, options = {}) {
|
|
|
971
1246
|
if (mode === "auto") {
|
|
972
1247
|
for (const [id, center] of arrangement.centers)
|
|
973
1248
|
centers.set(id, center);
|
|
974
|
-
|
|
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)));
|
|
975
1255
|
engineGroups = arrangement.groups ?? new Map();
|
|
976
1256
|
}
|
|
977
1257
|
else {
|
|
978
|
-
|
|
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
|
+
}
|
|
979
1280
|
}
|
|
980
1281
|
}
|
|
981
1282
|
else {
|
|
@@ -1016,7 +1317,7 @@ function layoutDrawing(compilation, diagram, options = {}) {
|
|
|
1016
1317
|
.sort((a, b) => b.groupPath.length - a.groupPath.length);
|
|
1017
1318
|
const explicitGroups = new Set();
|
|
1018
1319
|
for (const group of groups) {
|
|
1019
|
-
const arranged = mode === "auto" && arranging && !autoBands.has(group.id);
|
|
1320
|
+
const arranged = mode === "auto" && arranging && !autoBands.has(group.id) && !framedGroups.has(group.id);
|
|
1020
1321
|
const place = arranged ? undefined : places.get(group.id);
|
|
1021
1322
|
const size = arranged ? undefined : sizes.get(group.id);
|
|
1022
1323
|
if (place && size) {
|
|
@@ -1106,6 +1407,17 @@ function layoutDrawing(compilation, diagram, options = {}) {
|
|
|
1106
1407
|
});
|
|
1107
1408
|
}
|
|
1108
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
|
+
}
|
|
1109
1421
|
// The drawn shape of every element box, and the outline of those that are not their box; a
|
|
1110
1422
|
// turned element's outline is turned, and its drawn box is the bounding box of that outline.
|
|
1111
1423
|
const shapes = new Map();
|
|
@@ -1113,8 +1425,9 @@ function layoutDrawing(compilation, diagram, options = {}) {
|
|
|
1113
1425
|
const turnedBoxes = new Map();
|
|
1114
1426
|
for (const element of diagram.elements) {
|
|
1115
1427
|
const box = boxes.get(element.id);
|
|
1116
|
-
if (element.kind === "group" || element.kind === "line" || !box)
|
|
1428
|
+
if (element.kind === "group" || element.kind === "line" || element.kind === "pen" || !box) {
|
|
1117
1429
|
continue;
|
|
1430
|
+
}
|
|
1118
1431
|
const shape = element.kind === "tag" ? "pill" : sizing.shapeOf(element);
|
|
1119
1432
|
shapes.set(element.id, shape);
|
|
1120
1433
|
if (rotations.has(element.id)) {
|
|
@@ -1148,7 +1461,7 @@ function layoutDrawing(compilation, diagram, options = {}) {
|
|
|
1148
1461
|
* The boxes a connector should not cross: the other shapes of its layer, but not its ends, the
|
|
1149
1462
|
* groups that hold them, nor a shape that contains an end box (a box it is stacked inside).
|
|
1150
1463
|
*/
|
|
1151
|
-
const shapeIds = diagram.elements.filter((e) => e.kind !== "group" && e.kind !== "tag" && e.kind !== "line");
|
|
1464
|
+
const shapeIds = diagram.elements.filter((e) => e.kind !== "group" && e.kind !== "tag" && e.kind !== "line" && e.kind !== "pen");
|
|
1152
1465
|
const obstaclesFor = (relation) => {
|
|
1153
1466
|
const ends = [relation.from, relation.to];
|
|
1154
1467
|
const holders = new Set(ends.flatMap((id) => [id, ...(diagram.elementsById.get(id)?.groupPath ?? [])]));
|
|
@@ -1171,11 +1484,14 @@ function layoutDrawing(compilation, diagram, options = {}) {
|
|
|
1171
1484
|
});
|
|
1172
1485
|
}
|
|
1173
1486
|
// Relations with labels sit a label apart, so their pills do not overlap.
|
|
1174
|
-
const laneSpacing = (relation) =>
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
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
|
+
};
|
|
1179
1495
|
/** Labels placed by the layout, which may slide along their connectors to stay clear. */
|
|
1180
1496
|
const movable = [];
|
|
1181
1497
|
// Named ports and compass sides with a position are points of the box.
|
|
@@ -1395,23 +1711,25 @@ function layoutDrawing(compilation, diagram, options = {}) {
|
|
|
1395
1711
|
const offset = entry ? entry.offset : { x: 0, y: 0 };
|
|
1396
1712
|
const center = pointAlong(curve ? line : drawn, at);
|
|
1397
1713
|
const text = relation.label ?? "";
|
|
1398
|
-
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);
|
|
1399
1717
|
const iconWidth = icon ? icon.size + (text === "" ? 0 : icon.gap) : 0;
|
|
1400
1718
|
// C4 relations show their technology on a second, smaller line.
|
|
1401
|
-
const techSize = technologySize(
|
|
1402
|
-
const techWidth = technology ? metrics.width(technology,
|
|
1403
|
-
const width = Math.max(textWidth + iconWidth, techWidth) +
|
|
1404
|
-
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);
|
|
1405
1723
|
if (!entry) {
|
|
1406
1724
|
movable.push({
|
|
1407
1725
|
index,
|
|
1408
1726
|
along: curve ? line : drawn,
|
|
1409
|
-
width: Math.max(width,
|
|
1727
|
+
width: Math.max(width, pillScale.relationLabelHeight),
|
|
1410
1728
|
height,
|
|
1411
1729
|
});
|
|
1412
1730
|
}
|
|
1413
1731
|
label = {
|
|
1414
|
-
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),
|
|
1415
1733
|
text,
|
|
1416
1734
|
technology,
|
|
1417
1735
|
};
|
|
@@ -1475,11 +1793,12 @@ function layoutDrawing(compilation, diagram, options = {}) {
|
|
|
1475
1793
|
const offset = entry ? entry.offset : { x: 0, y: 0 };
|
|
1476
1794
|
const center = pointAlong(curve ? line : points, at);
|
|
1477
1795
|
const text = element.label;
|
|
1478
|
-
const
|
|
1796
|
+
const pill = sizing.linePill(element);
|
|
1797
|
+
const textWidth = text === "" ? 0 : metrics.width(text, pill.font, pill.weight, pill.scale.relationLabel);
|
|
1479
1798
|
const iconWidth = icon ? icon.size + (text === "" ? 0 : icon.gap) : 0;
|
|
1480
|
-
const width = textWidth + iconWidth + scale.relationLabelPadding;
|
|
1799
|
+
const width = textWidth + iconWidth + pill.scale.relationLabelPadding;
|
|
1481
1800
|
label = {
|
|
1482
|
-
box: boxFromCenter({ x: center.x + offset.x, y: center.y + offset.y }, Math.max(width, scale.relationLabelHeight), scale.relationLabelHeight),
|
|
1801
|
+
box: boxFromCenter({ x: center.x + offset.x, y: center.y + offset.y }, Math.max(width, pill.scale.relationLabelHeight), pill.scale.relationLabelHeight),
|
|
1483
1802
|
text,
|
|
1484
1803
|
};
|
|
1485
1804
|
}
|
|
@@ -1493,10 +1812,27 @@ function layoutDrawing(compilation, diagram, options = {}) {
|
|
|
1493
1812
|
connector: { points, line, fromSide, toSide, via: anchors.slice(1, -1) },
|
|
1494
1813
|
});
|
|
1495
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
|
+
}
|
|
1496
1832
|
// Every element's box with the label it draws outside it, in diagram coordinates.
|
|
1497
1833
|
const extents = new Map([...boxes].map(([id, box]) => [id, extentAt(id, box)]));
|
|
1498
|
-
// A line's box is no obstacle: labels may sit beside it as beside any connector.
|
|
1499
|
-
const shapeExtents = new Map([...extents].filter(([id]) => !lineGeometry.has(id)));
|
|
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)));
|
|
1500
1836
|
placeLabels(relationGeometry, movable, labelObstacles(diagram, shapeExtents, sizing));
|
|
1501
1837
|
const elementLabels = new Map();
|
|
1502
1838
|
for (const [id, box] of boxes) {
|
|
@@ -1510,15 +1846,17 @@ function layoutDrawing(compilation, diagram, options = {}) {
|
|
|
1510
1846
|
for (const [id, box] of boxes)
|
|
1511
1847
|
if (!rotations.has(id))
|
|
1512
1848
|
checked.set(id, box);
|
|
1513
|
-
// Lines cross boxes by design: they take no part in the composition checks.
|
|
1849
|
+
// Lines and pen strokes cross boxes by design: they take no part in the composition checks.
|
|
1514
1850
|
for (const id of lineGeometry.keys())
|
|
1515
1851
|
checked.delete(id);
|
|
1852
|
+
for (const id of penGeometry.keys())
|
|
1853
|
+
checked.delete(id);
|
|
1516
1854
|
diagnostics.push(...checkComposition(diagram, checked, relationGeometry.map((g) => ({
|
|
1517
1855
|
relation: g.relation,
|
|
1518
1856
|
points: g.line,
|
|
1519
1857
|
curved: g.curve !== undefined,
|
|
1520
1858
|
label: g.label?.box,
|
|
1521
|
-
})), explicitGroups, theme, (group) => sizing.untitled(group), outlines, elementLabels));
|
|
1859
|
+
})), explicitGroups, theme, (group) => sizing.untitled(group), outlines, elementLabels, (group) => sizing.headerOf(group)));
|
|
1522
1860
|
const everything = [...extents.values()];
|
|
1523
1861
|
for (const geometry of relationGeometry) {
|
|
1524
1862
|
for (const p of [
|
|
@@ -1536,6 +1874,11 @@ function layoutDrawing(compilation, diagram, options = {}) {
|
|
|
1536
1874
|
if (geometry.label)
|
|
1537
1875
|
everything.push(geometry.label.box);
|
|
1538
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
|
+
}
|
|
1539
1882
|
const bounds = unionBoxes(everything) ?? { x: 0, y: 0, width: 0, height: 0 };
|
|
1540
1883
|
const margin = theme.margin;
|
|
1541
1884
|
const contentWidth = Math.ceil(bounds.width + 2 * margin);
|
|
@@ -1562,7 +1905,7 @@ function layoutDrawing(compilation, diagram, options = {}) {
|
|
|
1562
1905
|
const shiftPoint = (p) => ({ x: p.x + dx, y: p.y + dy });
|
|
1563
1906
|
const items = [];
|
|
1564
1907
|
const gradients = [];
|
|
1565
|
-
const emitter = new Emitter(theme, scale, styles, metrics, sizing);
|
|
1908
|
+
const emitter = new Emitter(theme, scale, styles, metrics, sizing, free, diagram.elementsById, diagram.id);
|
|
1566
1909
|
const drawRelation = (geometry) => emitter.relation(geometry.relation, geometry.line.map(shiftPoint), shiftMarker(geometry.start, shiftPoint), shiftMarker(geometry.end, shiftPoint), geometry.label
|
|
1567
1910
|
? {
|
|
1568
1911
|
box: shift(geometry.label.box),
|
|
@@ -1638,6 +1981,11 @@ function layoutDrawing(compilation, diagram, options = {}) {
|
|
|
1638
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));
|
|
1639
1982
|
continue;
|
|
1640
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
|
+
}
|
|
1641
1989
|
items.push(onLayer(paintable.kind === "group"
|
|
1642
1990
|
? emitter.group(paintable.element, box)
|
|
1643
1991
|
: elementGroup(paintable.element, box), paintable.element.layer));
|
|
@@ -1718,6 +2066,22 @@ function layoutDrawing(compilation, diagram, options = {}) {
|
|
|
1718
2066
|
labelBoxes.set(id, geometry.label.box);
|
|
1719
2067
|
connectors.set(id, geometry.connector);
|
|
1720
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
|
+
}
|
|
1721
2085
|
return {
|
|
1722
2086
|
scene,
|
|
1723
2087
|
diagnostics,
|
|
@@ -1725,6 +2089,9 @@ function layoutDrawing(compilation, diagram, options = {}) {
|
|
|
1725
2089
|
offset: { x: dx, y: dy },
|
|
1726
2090
|
labels: labelBoxes,
|
|
1727
2091
|
connectors,
|
|
2092
|
+
pens: penGeometry,
|
|
2093
|
+
stickies,
|
|
2094
|
+
lists,
|
|
1728
2095
|
origins,
|
|
1729
2096
|
theme,
|
|
1730
2097
|
shapes,
|
|
@@ -1815,7 +2182,7 @@ function roundedPoints(list) {
|
|
|
1815
2182
|
}
|
|
1816
2183
|
/** A circle of radius `r` about (`cx`, `cy`), as path data. */
|
|
1817
2184
|
function circlePath(cx, cy, r) {
|
|
1818
|
-
return `M${cx - r} ${cy}a${r} ${r} 0 1 0 ${2 * r} 0a${r} ${r} 0 1 0 ${-2 * r} 0Z`;
|
|
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`;
|
|
1819
2186
|
}
|
|
1820
2187
|
function round(value) {
|
|
1821
2188
|
return Math.round(value * 100) / 100;
|
|
@@ -1878,13 +2245,16 @@ function translatePath(d, dx, dy) {
|
|
|
1878
2245
|
});
|
|
1879
2246
|
}
|
|
1880
2247
|
/** The size of a relation's label pill without its icon, or null when it has no label. */
|
|
1881
|
-
function labelSize(relation, metrics,
|
|
2248
|
+
function labelSize(relation, metrics, pill) {
|
|
1882
2249
|
const technology = relation.technology ? `[${relation.technology}]` : null;
|
|
1883
2250
|
if (relation.label === null && !technology)
|
|
1884
2251
|
return null;
|
|
2252
|
+
const scale = pill.scale;
|
|
1885
2253
|
const techSize = technologySize(scale.relationLabel);
|
|
1886
|
-
const text = relation.label
|
|
1887
|
-
|
|
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;
|
|
1888
2258
|
return {
|
|
1889
2259
|
width: Math.ceil(Math.max(text, tech) + scale.relationLabelPadding),
|
|
1890
2260
|
height: scale.relationLabelHeight + (technology ? Math.round(techSize * 1.25) : 0),
|
|
@@ -1911,8 +2281,14 @@ const FLOW_SIDES = {
|
|
|
1911
2281
|
* the routes meet the icon and not its label: on the side the flow leaves by for a source and
|
|
1912
2282
|
* enters by for a target, or the other way round for the relations in `mirrored`, which run
|
|
1913
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.
|
|
1914
2288
|
*/
|
|
1915
|
-
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;
|
|
1916
2292
|
/** A position along a side of an element's box, as a position along its extent's side. */
|
|
1917
2293
|
const along = (id, side, position) => {
|
|
1918
2294
|
const known = iconExtents.get(id);
|
|
@@ -1947,19 +2323,26 @@ function arrangeRequest(diagram, sizes, fixedPlaces, routes, theme, headerOf, la
|
|
|
1947
2323
|
return id;
|
|
1948
2324
|
};
|
|
1949
2325
|
const edges = diagram.relations.flatMap((relation) => {
|
|
1950
|
-
|
|
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)))
|
|
1951
2332
|
return [];
|
|
1952
2333
|
const route = routes.get(relation.key);
|
|
1953
|
-
const port = (end, which) =>
|
|
1954
|
-
?
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
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);
|
|
1958
2341
|
return [
|
|
1959
2342
|
{
|
|
1960
2343
|
id: relation.key,
|
|
1961
|
-
source
|
|
1962
|
-
target
|
|
2344
|
+
source,
|
|
2345
|
+
target,
|
|
1963
2346
|
sourcePort: port(route?.from, "from"),
|
|
1964
2347
|
targetPort: port(route?.to, "to"),
|
|
1965
2348
|
primary: relation.line === "solid",
|
|
@@ -1975,16 +2358,24 @@ function arrangeRequest(diagram, sizes, fixedPlaces, routes, theme, headerOf, la
|
|
|
1975
2358
|
const target = note.attachedTo;
|
|
1976
2359
|
if (note.kind !== "note" || target === null || target === note.id)
|
|
1977
2360
|
return [];
|
|
1978
|
-
|
|
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))
|
|
1979
2366
|
return [];
|
|
1980
2367
|
const element = diagram.elementsById.get(target);
|
|
1981
|
-
if (!element ||
|
|
2368
|
+
if (!element ||
|
|
2369
|
+
element.kind === "line" ||
|
|
2370
|
+
element.kind === "pen" ||
|
|
2371
|
+
!sizes.has(source) ||
|
|
2372
|
+
!sizes.has(note.id)) {
|
|
1982
2373
|
return [];
|
|
1983
2374
|
}
|
|
1984
2375
|
return [
|
|
1985
2376
|
{
|
|
1986
2377
|
id: `attach${note.id}`,
|
|
1987
|
-
source
|
|
2378
|
+
source,
|
|
1988
2379
|
target: note.id,
|
|
1989
2380
|
sourcePort: null,
|
|
1990
2381
|
targetPort: null,
|
|
@@ -1994,9 +2385,13 @@ function arrangeRequest(diagram, sizes, fixedPlaces, routes, theme, headerOf, la
|
|
|
1994
2385
|
];
|
|
1995
2386
|
});
|
|
1996
2387
|
edges.push(...attached);
|
|
1997
|
-
// Free lines are drawn where their points are written: the engine never moves them.
|
|
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.
|
|
1998
2390
|
const nodes = diagram.elements
|
|
1999
|
-
.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))
|
|
2000
2395
|
.map((element) => {
|
|
2001
2396
|
const ports = element.ports.map((p) => ({
|
|
2002
2397
|
id: p.name,
|
|
@@ -2029,7 +2424,7 @@ function arrangeRequest(diagram, sizes, fixedPlaces, routes, theme, headerOf, la
|
|
|
2029
2424
|
};
|
|
2030
2425
|
});
|
|
2031
2426
|
const groups = diagram.elements
|
|
2032
|
-
.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))
|
|
2033
2428
|
.map((group) => {
|
|
2034
2429
|
const minimum = minimums.get(group.id);
|
|
2035
2430
|
return {
|
|
@@ -2097,7 +2492,8 @@ function routesLength(arrangement) {
|
|
|
2097
2492
|
* `assisted` positions: placed elements keep their place. Each other element keeps its offset in
|
|
2098
2493
|
* the arrangement from the placed element nearest to it there, and is then pushed away from that
|
|
2099
2494
|
* element, along the layout direction, until it no longer overlaps a box already positioned. Boxes
|
|
2100
|
-
* 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.
|
|
2101
2497
|
*/
|
|
2102
2498
|
function placeAround(diagram, arrangement, places, sizes, centers, fixed, extentOf = (_id, box) => box) {
|
|
2103
2499
|
const direction = diagram.properties.direction;
|
|
@@ -2105,7 +2501,7 @@ function placeAround(diagram, arrangement, places, sizes, centers, fixed, extent
|
|
|
2105
2501
|
const anchors = [...places.keys()].filter((id) => sizes.has(id) && arrangement.centers.has(id));
|
|
2106
2502
|
const positioned = [];
|
|
2107
2503
|
for (const element of diagram.elements) {
|
|
2108
|
-
if (element.
|
|
2504
|
+
if (!sizes.has(element.id) || !places.has(element.id))
|
|
2109
2505
|
continue;
|
|
2110
2506
|
const size = sizes.get(element.id);
|
|
2111
2507
|
centers.set(element.id, places.get(element.id));
|
|
@@ -2113,7 +2509,7 @@ function placeAround(diagram, arrangement, places, sizes, centers, fixed, extent
|
|
|
2113
2509
|
positioned.push(extentOf(element.id, boxFromCenter(places.get(element.id), size.width, size.height)));
|
|
2114
2510
|
}
|
|
2115
2511
|
for (const element of diagram.elements) {
|
|
2116
|
-
if (element.
|
|
2512
|
+
if (!sizes.has(element.id) || places.has(element.id))
|
|
2117
2513
|
continue;
|
|
2118
2514
|
const size = sizes.get(element.id);
|
|
2119
2515
|
const arranged = arrangement.centers.get(element.id) ?? { x: 0, y: 0 };
|
|
@@ -2281,7 +2677,8 @@ function parallelLanes(diagram, routes, spacingOf = () => LANE_SPACING) {
|
|
|
2281
2677
|
}
|
|
2282
2678
|
/**
|
|
2283
2679
|
* The canvas for the diagram's `preset` or `canvas`, reporting `layout.too-large` when the content
|
|
2284
|
-
* 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.
|
|
2285
2682
|
*/
|
|
2286
2683
|
export function canvasSize(diagram, width, height, lines, diagnostics) {
|
|
2287
2684
|
const { preset, canvas } = diagram.properties;
|
|
@@ -2291,8 +2688,15 @@ export function canvasSize(diagram, width, height, lines, diagnostics) {
|
|
|
2291
2688
|
severity: "warning",
|
|
2292
2689
|
message,
|
|
2293
2690
|
range: propertyRange(diagram, keyword, lines) ?? diagram.idRange,
|
|
2294
|
-
|
|
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
|
+
],
|
|
2295
2697
|
});
|
|
2698
|
+
if (preset === "board")
|
|
2699
|
+
return { width, height };
|
|
2296
2700
|
if (preset === "document") {
|
|
2297
2701
|
if (width > DOCUMENT_MAX_WIDTH) {
|
|
2298
2702
|
tooLarge("preset", words.diagnostics.tooWideForDocument(width, DOCUMENT_MAX_WIDTH));
|
|
@@ -2367,6 +2771,8 @@ class Sizing {
|
|
|
2367
2771
|
centredLays = new Map();
|
|
2368
2772
|
/** The centre of each aligned text block, by element and box. */
|
|
2369
2773
|
textCenters = new Map();
|
|
2774
|
+
/** How the text of each sticky is drawn, by element and card size. */
|
|
2775
|
+
stickyTexts = new Map();
|
|
2370
2776
|
constructor(theme, scale, metrics, styles, icons, images,
|
|
2371
2777
|
/** The written `size` of each element that has one. */
|
|
2372
2778
|
written = new Map()) {
|
|
@@ -2384,12 +2790,48 @@ class Sizing {
|
|
|
2384
2790
|
* times the size apart.
|
|
2385
2791
|
*/
|
|
2386
2792
|
labelFont(element) {
|
|
2387
|
-
const
|
|
2388
|
-
const style = free ? this.styles.element(element) : {};
|
|
2793
|
+
const style = this.styles.element(element);
|
|
2389
2794
|
const size = style.fontSize ??
|
|
2390
2795
|
(element.kind === "text" ? Math.round(this.scale.label * 1.6) : this.scale.label);
|
|
2391
2796
|
const weight = style.fontWeight ?? this.scale.labelWeight;
|
|
2392
|
-
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));
|
|
2393
2835
|
}
|
|
2394
2836
|
/**
|
|
2395
2837
|
* The drawn shape of a box-like element: its kind's (data stores are cylinders, decisions
|
|
@@ -2409,6 +2851,9 @@ class Sizing {
|
|
|
2409
2851
|
return known(this.styles.element(element).shape) ?? "rounded";
|
|
2410
2852
|
case "box":
|
|
2411
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";
|
|
2412
2857
|
default:
|
|
2413
2858
|
return "rounded";
|
|
2414
2859
|
}
|
|
@@ -2421,6 +2866,8 @@ class Sizing {
|
|
|
2421
2866
|
const shape = this.shapeOf(element);
|
|
2422
2867
|
// A written radius (the element's style, a class or a rule) rounds every cornered shape.
|
|
2423
2868
|
const written = CORNERED_SHAPES.has(shape) ? this.styles.element(element).radius : undefined;
|
|
2869
|
+
if (element.kind === "sticky")
|
|
2870
|
+
return { radius: written ?? STICKY_RADIUS };
|
|
2424
2871
|
if (shape === "agent")
|
|
2425
2872
|
return { radius: written ?? this.theme.radius * 1.5 };
|
|
2426
2873
|
if (shape === "person") {
|
|
@@ -2496,9 +2943,10 @@ class Sizing {
|
|
|
2496
2943
|
columnWith(element, icon) {
|
|
2497
2944
|
const block = this.blockWith(element, icon);
|
|
2498
2945
|
const font = this.labelFont(element);
|
|
2946
|
+
const description = this.descriptionFont(element);
|
|
2499
2947
|
const lines = [
|
|
2500
2948
|
...block.labelOffsets.map((y) => ({ y, size: font.size })),
|
|
2501
|
-
...block.descriptionOffsets.map((y) => ({ y, size:
|
|
2949
|
+
...block.descriptionOffsets.map((y) => ({ y, size: description.size })),
|
|
2502
2950
|
];
|
|
2503
2951
|
let top = Math.min(...lines.map((l) => l.y - l.size / 2));
|
|
2504
2952
|
let bottom = Math.max(...lines.map((l) => l.y + l.size / 2));
|
|
@@ -2605,6 +3053,14 @@ class Sizing {
|
|
|
2605
3053
|
headerOf(group) {
|
|
2606
3054
|
return groupHeaderOf(group, this.styles.element(group), this.theme);
|
|
2607
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
|
+
}
|
|
2608
3064
|
/** Horizontal alignment of a box or text label. */
|
|
2609
3065
|
/**
|
|
2610
3066
|
* A note's font: the theme's note size at weight 400, or a free note's `fontSize` and
|
|
@@ -2617,16 +3073,131 @@ class Sizing {
|
|
|
2617
3073
|
size,
|
|
2618
3074
|
weight: style.fontWeight ?? 400,
|
|
2619
3075
|
line: Math.round((size * this.scale.noteLine) / this.scale.note),
|
|
3076
|
+
font: textFont(style.font),
|
|
2620
3077
|
};
|
|
2621
3078
|
}
|
|
2622
3079
|
alignOf(element) {
|
|
2623
3080
|
if (element.kind === "text" || element.kind === "note") {
|
|
2624
3081
|
return this.styles.element(element).textAlign ?? "left";
|
|
2625
3082
|
}
|
|
2626
|
-
if (element.kind === "box")
|
|
3083
|
+
if (element.kind === "box" || element.kind === "sticky") {
|
|
2627
3084
|
return this.styles.element(element).textAlign ?? "center";
|
|
3085
|
+
}
|
|
2628
3086
|
return "center";
|
|
2629
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
|
+
}
|
|
2630
3201
|
/** The inset of a free text or icon drawn with a frame (`fill`, `stroke` or `pattern`). */
|
|
2631
3202
|
frameInset(element) {
|
|
2632
3203
|
const style = this.styles.element(element);
|
|
@@ -2676,6 +3247,9 @@ class Sizing {
|
|
|
2676
3247
|
return this.place(style.icon, Math.round(scale.tag * 1.3), "left");
|
|
2677
3248
|
case "note":
|
|
2678
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");
|
|
2679
3253
|
}
|
|
2680
3254
|
const icon = this.elementIcon(element);
|
|
2681
3255
|
if (!icon)
|
|
@@ -3086,8 +3660,9 @@ class Sizing {
|
|
|
3086
3660
|
return this.blockWith(element, undefined);
|
|
3087
3661
|
}
|
|
3088
3662
|
blockWith(element, icon) {
|
|
3089
|
-
const scale = this.scale;
|
|
3090
3663
|
const font = this.labelFont(element);
|
|
3664
|
+
const description = this.descriptionFont(element);
|
|
3665
|
+
const scale = { description: description.size, descriptionLine: description.line };
|
|
3091
3666
|
const label = font.size;
|
|
3092
3667
|
const lines = splitLines(element.label).length;
|
|
3093
3668
|
// How far the first line of the label lies above its last.
|
|
@@ -3120,14 +3695,15 @@ class Sizing {
|
|
|
3120
3695
|
}
|
|
3121
3696
|
/** The widest of a box's label and description lines. */
|
|
3122
3697
|
textWidth(element) {
|
|
3123
|
-
const { metrics
|
|
3698
|
+
const { metrics } = this;
|
|
3124
3699
|
const font = this.labelFont(element);
|
|
3700
|
+
const description = this.descriptionFont(element);
|
|
3125
3701
|
let width = 0;
|
|
3126
3702
|
for (const line of splitLines(element.label)) {
|
|
3127
|
-
width = Math.max(width, metrics.width(line,
|
|
3703
|
+
width = Math.max(width, metrics.width(line, font.font, font.weight, font.size));
|
|
3128
3704
|
}
|
|
3129
3705
|
for (const line of element.description ? splitLines(element.description) : []) {
|
|
3130
|
-
width = Math.max(width, metrics.width(line,
|
|
3706
|
+
width = Math.max(width, metrics.width(line, description.font, 400, description.size));
|
|
3131
3707
|
}
|
|
3132
3708
|
return width;
|
|
3133
3709
|
}
|
|
@@ -3136,11 +3712,12 @@ class Sizing {
|
|
|
3136
3712
|
* default sizes and hints), and the minimum width below which the content touches the edges.
|
|
3137
3713
|
*/
|
|
3138
3714
|
textNeeds(element) {
|
|
3139
|
-
const { metrics,
|
|
3715
|
+
const { metrics, theme } = this;
|
|
3140
3716
|
const widest = (entries) => entries.reduce((a, b) => (b.width > a.width ? b : a), { text: "", width: 0 });
|
|
3717
|
+
const description = this.descriptionFont(element);
|
|
3141
3718
|
const descriptions = (element.description ? splitLines(element.description) : []).map((line) => ({
|
|
3142
3719
|
text: line,
|
|
3143
|
-
width: metrics.width(line,
|
|
3720
|
+
width: metrics.width(line, description.font, 400, description.size),
|
|
3144
3721
|
}));
|
|
3145
3722
|
const icon = this.iconOf(element);
|
|
3146
3723
|
const beside = icon && icon.position === "left" ? icon.size + icon.gap : 0;
|
|
@@ -3152,16 +3729,17 @@ class Sizing {
|
|
|
3152
3729
|
const font = this.labelFont(element);
|
|
3153
3730
|
const best = widest(splitLines(element.label).map((line) => ({
|
|
3154
3731
|
text: line,
|
|
3155
|
-
width: metrics.width(line,
|
|
3732
|
+
width: metrics.width(line, font.font, font.weight, font.size),
|
|
3156
3733
|
})));
|
|
3157
3734
|
const width = Math.ceil(Math.max(best.width, 1) + 2 * this.frameInset(element));
|
|
3158
3735
|
return { text: best.text, width, minimum: width };
|
|
3159
3736
|
}
|
|
3160
3737
|
case "tag": {
|
|
3161
|
-
const
|
|
3738
|
+
const tag = this.tagText(element);
|
|
3739
|
+
const width = metrics.width(element.label, tag.face.font, tag.face.weight, tag.scale.tag) + beside;
|
|
3162
3740
|
return {
|
|
3163
3741
|
text: element.label,
|
|
3164
|
-
width:
|
|
3742
|
+
width: tagPillWidth(metrics, tag.scale, element.label, beside, tag.face),
|
|
3165
3743
|
minimum: Math.ceil(width + 2 * MIN_TEXT_MARGIN),
|
|
3166
3744
|
};
|
|
3167
3745
|
}
|
|
@@ -3169,7 +3747,7 @@ class Sizing {
|
|
|
3169
3747
|
const font = this.noteFont(element);
|
|
3170
3748
|
const best = widest(splitLines(element.label).map((line) => ({
|
|
3171
3749
|
text: line,
|
|
3172
|
-
width: metrics.width(line,
|
|
3750
|
+
width: metrics.width(line, font.font, font.weight, font.size),
|
|
3173
3751
|
})));
|
|
3174
3752
|
const width = Math.ceil(Math.max(best.width + beside, 1));
|
|
3175
3753
|
return { text: best.text, width, minimum: width };
|
|
@@ -3178,9 +3756,10 @@ class Sizing {
|
|
|
3178
3756
|
// An untitled group has no text, so no minimum width.
|
|
3179
3757
|
if (this.untitled(element))
|
|
3180
3758
|
return { text: "", width: 0, minimum: 0 };
|
|
3759
|
+
const font = this.groupFont(element);
|
|
3181
3760
|
const title = {
|
|
3182
3761
|
text: element.label,
|
|
3183
|
-
width: metrics.width(element.label,
|
|
3762
|
+
width: metrics.width(element.label, font.font, font.weight, font.size) + beside,
|
|
3184
3763
|
};
|
|
3185
3764
|
const best = widest([title, ...descriptions]);
|
|
3186
3765
|
const inset = theme.groupPadding + 4;
|
|
@@ -3202,17 +3781,18 @@ class Sizing {
|
|
|
3202
3781
|
}
|
|
3203
3782
|
/** The widest of a box's label and description lines. */
|
|
3204
3783
|
widestLine(element) {
|
|
3205
|
-
const { metrics
|
|
3784
|
+
const { metrics } = this;
|
|
3206
3785
|
const font = this.labelFont(element);
|
|
3786
|
+
const description = this.descriptionFont(element);
|
|
3207
3787
|
const [first = "", ...rest] = splitLines(element.label);
|
|
3208
|
-
let best = { text: first, width: metrics.width(first,
|
|
3788
|
+
let best = { text: first, width: metrics.width(first, font.font, font.weight, font.size) };
|
|
3209
3789
|
for (const line of rest) {
|
|
3210
|
-
const width = metrics.width(line,
|
|
3790
|
+
const width = metrics.width(line, font.font, font.weight, font.size);
|
|
3211
3791
|
if (width > best.width)
|
|
3212
3792
|
best = { text: line, width };
|
|
3213
3793
|
}
|
|
3214
3794
|
for (const line of element.description ? splitLines(element.description) : []) {
|
|
3215
|
-
const width = metrics.width(line,
|
|
3795
|
+
const width = metrics.width(line, description.font, 400, description.size);
|
|
3216
3796
|
if (width > best.width)
|
|
3217
3797
|
best = { text: line, width };
|
|
3218
3798
|
}
|
|
@@ -3232,10 +3812,16 @@ class Sizing {
|
|
|
3232
3812
|
}
|
|
3233
3813
|
defaultSize(element) {
|
|
3234
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
|
+
}
|
|
3235
3821
|
const needs = this.textNeeds(element);
|
|
3236
3822
|
switch (element.kind) {
|
|
3237
3823
|
case "tag":
|
|
3238
|
-
return { width: needs.width, height: scale.tagHeight };
|
|
3824
|
+
return { width: needs.width, height: this.tagText(element).scale.tagHeight };
|
|
3239
3825
|
case "note":
|
|
3240
3826
|
return {
|
|
3241
3827
|
width: needs.width,
|
|
@@ -3418,20 +4004,54 @@ class Emitter {
|
|
|
3418
4004
|
styles;
|
|
3419
4005
|
metrics;
|
|
3420
4006
|
sizing;
|
|
4007
|
+
free;
|
|
4008
|
+
elementsById;
|
|
4009
|
+
diagramId;
|
|
3421
4010
|
patterns = [];
|
|
3422
4011
|
painter = new IconPainter();
|
|
3423
4012
|
patternIds = new Map();
|
|
3424
4013
|
/** The title areas of drawn groups, which connectors pass under. */
|
|
3425
4014
|
titles = [];
|
|
3426
|
-
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 = "") {
|
|
3427
4025
|
this.theme = theme;
|
|
3428
4026
|
this.scale = scale;
|
|
3429
4027
|
this.styles = styles;
|
|
3430
4028
|
this.metrics = metrics;
|
|
3431
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 };
|
|
3432
4051
|
}
|
|
3433
4052
|
text(x, y, text, size, weight, fill, anchor = "middle", font = "body") {
|
|
3434
4053
|
const baseline = round(this.metrics.baseline(y, font, weight, size));
|
|
4054
|
+
const stack = writtenStack(font);
|
|
3435
4055
|
return {
|
|
3436
4056
|
kind: "text",
|
|
3437
4057
|
x: round(x),
|
|
@@ -3440,7 +4060,8 @@ class Emitter {
|
|
|
3440
4060
|
text,
|
|
3441
4061
|
size,
|
|
3442
4062
|
weight,
|
|
3443
|
-
font,
|
|
4063
|
+
font: fontRole(font),
|
|
4064
|
+
...(stack !== undefined ? { stack } : {}),
|
|
3444
4065
|
anchor,
|
|
3445
4066
|
fill,
|
|
3446
4067
|
};
|
|
@@ -3486,7 +4107,7 @@ class Emitter {
|
|
|
3486
4107
|
};
|
|
3487
4108
|
}
|
|
3488
4109
|
group(element, box) {
|
|
3489
|
-
return withUrl(withBounds(this.groupItems(element, box), box), element);
|
|
4110
|
+
return this.faded(withUrl(withBounds(this.groupItems(element, box), box), element), element);
|
|
3490
4111
|
}
|
|
3491
4112
|
groupItems(element, box) {
|
|
3492
4113
|
const { theme, scale } = this;
|
|
@@ -3499,7 +4120,11 @@ class Emitter {
|
|
|
3499
4120
|
return { kind: "group", id: element.id, role: "element", items };
|
|
3500
4121
|
}
|
|
3501
4122
|
const inset = theme.groupPadding + 4;
|
|
3502
|
-
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;
|
|
3503
4128
|
const color = style.color ?? theme.ink;
|
|
3504
4129
|
let titleX = box.x + inset;
|
|
3505
4130
|
const icon = this.sizing.iconOf(element);
|
|
@@ -3507,38 +4132,40 @@ class Emitter {
|
|
|
3507
4132
|
items.push(this.icon(icon, titleX + icon.size / 2, titleY, style.iconColor ?? color));
|
|
3508
4133
|
titleX += icon.size + icon.gap;
|
|
3509
4134
|
}
|
|
3510
|
-
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));
|
|
3511
4136
|
// The title, and the description under it, are kept clear of connectors.
|
|
3512
|
-
const titleWidth = this.metrics.width(element.label,
|
|
4137
|
+
const titleWidth = this.metrics.width(element.label, font.font, font.weight, font.size);
|
|
3513
4138
|
const descriptionWidth = element.description
|
|
3514
|
-
? this.metrics.width(element.description,
|
|
4139
|
+
? this.metrics.width(element.description, font.font, 400, scale.description)
|
|
3515
4140
|
: 0;
|
|
3516
4141
|
const right = Math.max(titleX + titleWidth, box.x + inset + descriptionWidth);
|
|
3517
4142
|
this.titles.push({
|
|
3518
4143
|
id: element.id,
|
|
3519
4144
|
box: {
|
|
3520
4145
|
x: box.x + inset - TITLE_GAP,
|
|
3521
|
-
y: titleY -
|
|
4146
|
+
y: titleY - font.size * 0.75 - TITLE_GAP,
|
|
3522
4147
|
width: right - (box.x + inset) + 2 * TITLE_GAP,
|
|
3523
4148
|
height: (element.description
|
|
3524
|
-
? box.y + theme.groupHeader * 0.85 + scale.description * 0.6
|
|
3525
|
-
: titleY +
|
|
3526
|
-
(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) +
|
|
3527
4152
|
2 * TITLE_GAP,
|
|
3528
4153
|
},
|
|
3529
4154
|
});
|
|
3530
4155
|
if (element.description) {
|
|
3531
|
-
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));
|
|
3532
4157
|
}
|
|
3533
4158
|
return { kind: "group", id: element.id, role: "element", items };
|
|
3534
4159
|
}
|
|
3535
4160
|
element(element, box) {
|
|
3536
|
-
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);
|
|
3537
4162
|
}
|
|
3538
4163
|
elementItems(element, box) {
|
|
3539
4164
|
switch (element.kind) {
|
|
3540
4165
|
case "note":
|
|
3541
4166
|
return this.note(element, box);
|
|
4167
|
+
case "sticky":
|
|
4168
|
+
return this.sticky(element, box);
|
|
3542
4169
|
case "tag":
|
|
3543
4170
|
return this.tag(element, box);
|
|
3544
4171
|
case "text":
|
|
@@ -3600,7 +4227,7 @@ class Emitter {
|
|
|
3600
4227
|
const anchor = align === "left" ? "start" : align === "right" ? "end" : "middle";
|
|
3601
4228
|
const top = box.y + box.height / 2 - (lines.length * font.line) / 2;
|
|
3602
4229
|
lines.forEach((line, i) => {
|
|
3603
|
-
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));
|
|
3604
4231
|
});
|
|
3605
4232
|
return { kind: "group", id: element.id, role: "element", items };
|
|
3606
4233
|
}
|
|
@@ -3763,9 +4390,10 @@ class Emitter {
|
|
|
3763
4390
|
* anchor's side) about a text centre `y`, with the offsets of `block`.
|
|
3764
4391
|
*/
|
|
3765
4392
|
lines(items, element, x, y, block, align) {
|
|
3766
|
-
const { theme
|
|
4393
|
+
const { theme } = this;
|
|
3767
4394
|
const style = this.styles.element(element);
|
|
3768
4395
|
const font = this.sizing.labelFont(element);
|
|
4396
|
+
const description = this.sizing.descriptionFont(element);
|
|
3769
4397
|
const anchor = align === "left"
|
|
3770
4398
|
? "start"
|
|
3771
4399
|
: align === "right"
|
|
@@ -3774,11 +4402,11 @@ class Emitter {
|
|
|
3774
4402
|
? "middle"
|
|
3775
4403
|
: align;
|
|
3776
4404
|
splitLines(element.label).forEach((line, i) => {
|
|
3777
|
-
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));
|
|
3778
4406
|
});
|
|
3779
4407
|
const descriptionLines = element.description ? splitLines(element.description) : [];
|
|
3780
4408
|
descriptionLines.forEach((line, i) => {
|
|
3781
|
-
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));
|
|
3782
4410
|
});
|
|
3783
4411
|
}
|
|
3784
4412
|
/**
|
|
@@ -3829,28 +4457,101 @@ class Emitter {
|
|
|
3829
4457
|
: box.x + indent + (box.width - indent) / 2;
|
|
3830
4458
|
const anchor = align === "left" ? "start" : align === "right" ? "end" : "middle";
|
|
3831
4459
|
splitLines(element.label).forEach((line, i) => {
|
|
3832
|
-
items.push(this.text(x, box.y + font.line / 2 + i * font.line, line, font.size, font.weight, color, anchor));
|
|
4460
|
+
items.push(this.text(x, box.y + font.line / 2 + i * font.line, line, font.size, font.weight, color, anchor, font.font));
|
|
3833
4461
|
});
|
|
3834
4462
|
return { kind: "group", id: element.id, role: "element", items };
|
|
3835
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
|
+
}
|
|
3836
4530
|
tag(element, box) {
|
|
3837
|
-
const { theme
|
|
4531
|
+
const { theme } = this;
|
|
3838
4532
|
const style = this.styles.element(element);
|
|
3839
4533
|
const fill = style.fill ?? theme.tag;
|
|
3840
4534
|
const center = boxCenter(box);
|
|
3841
|
-
// A pill, upright when the tag is taller than wide.
|
|
3842
|
-
const outline = this.rect(box, cornerRadius(Infinity, box), fill, style.stroke ?? fill, style.width ?? theme.strokeWidth, strokeDash(style.line));
|
|
3843
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);
|
|
3844
4545
|
const items = [outline, ...this.overlay(style, outline)];
|
|
3845
4546
|
const icon = this.sizing.iconOf(element);
|
|
3846
4547
|
if (icon) {
|
|
3847
|
-
const textWidth = this.metrics.width(element.label,
|
|
4548
|
+
const textWidth = this.metrics.width(element.label, face.font, face.weight, scale.tag);
|
|
3848
4549
|
const left = center.x - (icon.size + icon.gap + textWidth) / 2;
|
|
3849
4550
|
items.push(this.icon(icon, left + icon.size / 2, center.y, style.iconColor ?? color));
|
|
3850
|
-
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));
|
|
3851
4552
|
}
|
|
3852
4553
|
else {
|
|
3853
|
-
items.push(
|
|
4554
|
+
items.push(label);
|
|
3854
4555
|
}
|
|
3855
4556
|
return { kind: "group", id: element.id, role: "element", items };
|
|
3856
4557
|
}
|
|
@@ -3889,14 +4590,61 @@ class Emitter {
|
|
|
3889
4590
|
},
|
|
3890
4591
|
]
|
|
3891
4592
|
: this.connectorItems(element.id, style, stroke, strokeDash(style.line ?? "solid"), [], line, start, end, label && { ...label, technology: null }, this.sizing.lineIcon(element), curve);
|
|
3892
|
-
return withBounds({ kind: "group", id: element.id, role: "element", items, track: roundedPoints(line) }, box);
|
|
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);
|
|
3893
4639
|
}
|
|
3894
4640
|
/**
|
|
3895
4641
|
* The items of a connector, in the order motion and hit tests expect: its line (pieces around
|
|
3896
4642
|
* `holes`, or one curve), its heads, then its label pill with its icon and text.
|
|
3897
4643
|
*/
|
|
3898
4644
|
connectorItems(id, style, stroke, dashed, holes, line, start, end, label, icon, curve) {
|
|
3899
|
-
const { theme
|
|
4645
|
+
const { theme } = this;
|
|
4646
|
+
const face = pillText(theme, this.scale, style);
|
|
4647
|
+
const scale = face.scale;
|
|
3900
4648
|
const strokeWidth = style.width ?? theme.connectorWidth;
|
|
3901
4649
|
const pieces = curve ? [] : splitAround(line, holes);
|
|
3902
4650
|
const items = curve
|
|
@@ -3925,17 +4673,17 @@ class Emitter {
|
|
|
3925
4673
|
pill.push(this.icon(icon, center.x, center.y, style.iconColor ?? color));
|
|
3926
4674
|
}
|
|
3927
4675
|
else if (icon) {
|
|
3928
|
-
const textWidth = this.metrics.width(label.text,
|
|
4676
|
+
const textWidth = this.metrics.width(label.text, face.font, face.weight, scale.relationLabel);
|
|
3929
4677
|
const left = center.x - (icon.size + icon.gap + textWidth) / 2;
|
|
3930
4678
|
pill.push(this.icon(icon, left + icon.size / 2, center.y, style.iconColor ?? color));
|
|
3931
|
-
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));
|
|
3932
4680
|
}
|
|
3933
4681
|
else if (label.text !== "") {
|
|
3934
|
-
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));
|
|
3935
4683
|
}
|
|
3936
4684
|
if (label.technology) {
|
|
3937
4685
|
const y = label.text === "" && !icon ? box.y + box.height / 2 : center.y + techLine;
|
|
3938
|
-
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));
|
|
3939
4687
|
}
|
|
3940
4688
|
items.push({ kind: "group", id, role: "label", items: pill });
|
|
3941
4689
|
}
|