@meltemi/diagrams-layout 1.9.0 → 1.10.17

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