@meltemi/diagrams-layout 1.10.0 → 1.10.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/dist/audit.d.ts +8 -6
  2. package/dist/audit.d.ts.map +1 -1
  3. package/dist/audit.js +28 -14
  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 +3 -3
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +3 -3
  20. package/dist/index.js.map +1 -1
  21. package/dist/layout.d.ts +203 -7
  22. package/dist/layout.d.ts.map +1 -1
  23. package/dist/layout.js +883 -135
  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 +24 -1
  30. package/dist/messages/en.d.ts.map +1 -1
  31. package/dist/messages/en.js +25 -2
  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 +8 -1
  35. package/dist/messages/es.js.map +1 -1
  36. package/dist/messages/index.d.ts +7 -0
  37. package/dist/messages/index.d.ts.map +1 -1
  38. package/dist/scene.d.ts +41 -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 +658 -282
  43. package/dist/sequence.js.map +1 -1
  44. package/dist/styles.d.ts +5 -1
  45. package/dist/styles.d.ts.map +1 -1
  46. package/dist/styles.js +20 -4
  47. package/dist/styles.js.map +1 -1
  48. package/dist/text.d.ts +8 -0
  49. package/dist/text.d.ts.map +1 -1
  50. package/dist/text.js +132 -0
  51. package/dist/text.js.map +1 -1
  52. package/dist/theme.d.ts +5 -0
  53. package/dist/theme.d.ts.map +1 -1
  54. package/dist/theme.js +20 -0
  55. package/dist/theme.js.map +1 -1
  56. package/package.json +11 -6
package/dist/layout.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { DEFAULT_LOCALE } from "@meltemi/diagrams-i18n";
2
2
  import { defaultImageSize, } from "@meltemi/diagrams-assets";
3
- import { ENTITY_TYPE_NAMES, ICON_SIZE_RANGE, MAIN_LAYER, takesRotation, } from "@meltemi/diagrams-model";
3
+ import { ENTITY_TYPE_NAMES, ICON_SIZE_RANGE, MAIN_LAYER, PEN_WIDTH_RANGE, takesRotation, } from "@meltemi/diagrams-model";
4
4
  import { auditLabels } from "./audit.js";
5
5
  import { drawingWords, messages } from "./messages/index.js";
6
6
  import { checkComposition } from "./checks.js";
@@ -9,17 +9,29 @@ import { ENGINE_SPACING, } from "./engine.js";
9
9
  import { LANE_SPACING, anchorPoints, avoidingRoute, crossedBoxes, bezierThrough, detourRoute, portPoint, shapeRoute, sideNormal, sideOfPoint, snapEnd, } from "./routing.js";
10
10
  import { boxCenter, boxFromCenter, cylinderPaths, ellipsePath, expandBox, pointAlong, unionBoxes, } from "./geometry.js";
11
11
  import { CORNER_RADIUS, endMarker, markerItems, markerPoints, markerReach, relationEnds, roundCorners, shiftMarker, } from "./markers.js";
12
- import { freeFrames, groupHeaderOf, isUntitledGroup, paintOrder, parentOf } from "./free.js";
12
+ import { freeFrames, groupHeaderOf, isList, isUntitledGroup, listHolders, listPlaces, paintOrder, parentOf, } from "./free.js";
13
13
  import { layoutSequence } from "./sequence.js";
14
14
  import { IconPainter } from "./icons.js";
15
- import { brandFrame } from "./frame.js";
15
+ import { brandFrame, textItem } from "./frame.js";
16
16
  import { markGradient } from "./mark.js";
17
17
  import { strokeDash, } from "./scene.js";
18
- import { MIN_TEXT_MARGIN, TextMetrics, estimateMeasurer } from "./measure.js";
18
+ import { MIN_TEXT_MARGIN, TextMetrics, estimateMeasurer, fontRole, textFont, writtenStack, } from "./measure.js";
19
19
  import { CORNERED_SHAPES, FIXED_TEXT_SHAPES, boundaryToward, SHAPE_SIZES, agentHeight, framedLines, isOutlined, onOutline, onPolygon, shapeOutline, shapeTextCenter, shapeTextBand, shapeTextSpan, shapeTextSpanner, turnPoint, turnedOutline, } from "./shapes.js";
20
20
  import { StyleResolver } from "./styles.js";
21
- import { splitLines } from "./text.js";
21
+ import { splitLines, wrapLines } from "./text.js";
22
22
  import { DEFAULT_THEME_NAME, THEMES, resolveTheme, } from "./theme.js";
23
+ /** The width of a pen stroke whose style writes none. */
24
+ export const DEFAULT_PEN_WIDTH = 3;
25
+ /** The smallest size a sticky's text shrinks to before it is clipped to its card. */
26
+ export const STICKY_MIN_FONT_SIZE = 8;
27
+ /** A sticky's default font size, as a factor of the theme's label size. */
28
+ const STICKY_FONT_SCALE = 1.2;
29
+ /** The weight of a sticky's text when its style writes no `fontWeight`. */
30
+ const STICKY_FONT_WEIGHT = 400;
31
+ /** The corner radius of a sticky's card when its style writes no `radius`. */
32
+ const STICKY_RADIUS = 2;
33
+ /** The shadow under a sticky's card: how far down it lies, its colour and its opacity. */
34
+ const STICKY_SHADOW = { offset: 3, color: "#000000", opacity: 0.15 };
23
35
  /** Widest content canvas of `preset document` before `layout.too-large`. */
24
36
  export const DOCUMENT_MAX_WIDTH = 1200;
25
37
  /** The 16:9 canvas of `preset slide`. */
@@ -38,8 +50,11 @@ export const DEFAULT_SIZES = {
38
50
  text: null,
39
51
  icon: { width: 48, height: 48 },
40
52
  image: null,
41
- // A free line is the bounding box of its points.
53
+ // A sticky is a square card that never grows for its text.
54
+ sticky: { width: 160, height: 160 },
55
+ // A free line is the bounding box of its points; a pen, of its points and its width.
42
56
  line: null,
57
+ pen: null,
43
58
  // Sequence heads are measured by the sequence layout.
44
59
  participant: null,
45
60
  actor: null,
@@ -166,6 +181,92 @@ export function typeScale(theme) {
166
181
  frameMark: Math.round(theme.frameSize * 2),
167
182
  };
168
183
  }
184
+ /**
185
+ * The type scale of a target whose style writes `fontSize`: the theme's scale with the size of
186
+ * its kind and every measure derived from it, as the theme's token would give them: a label's
187
+ * description at the theme's ratio of description to label, a pill's height and padding, a tag's
188
+ * pill, a note's line (decision 0067). Without a written size, `scale` itself.
189
+ */
190
+ export function writtenScale(theme, scale, kind, size) {
191
+ if (size === undefined)
192
+ return scale;
193
+ switch (kind) {
194
+ case "label":
195
+ return {
196
+ ...scale,
197
+ ...pick(typeScale({
198
+ ...theme,
199
+ labelSize: size,
200
+ descriptionSize: Math.round((size * theme.descriptionSize) / theme.labelSize),
201
+ }), ["label", "description", "descriptionLine"]),
202
+ };
203
+ case "groupTitle":
204
+ return { ...scale, groupTitle: size };
205
+ case "note":
206
+ return { ...scale, ...pick(typeScale({ ...theme, noteSize: size }), ["note", "noteLine"]) };
207
+ case "tag":
208
+ return {
209
+ ...scale,
210
+ ...pick(typeScale({ ...theme, tagSize: size }), ["tag", "tagHeight", "tagPadding"]),
211
+ };
212
+ case "relationLabel":
213
+ return {
214
+ ...scale,
215
+ ...pick(typeScale({ ...theme, relationLabelSize: size }), [
216
+ "relationLabel",
217
+ "relationLabelHeight",
218
+ "relationLabelPadding",
219
+ ]),
220
+ };
221
+ }
222
+ }
223
+ function pick(value, keys) {
224
+ return Object.fromEntries(keys.map((key) => [key, value[key]]));
225
+ }
226
+ /**
227
+ * The text of a relation's or a free line's label pill from its resolved style: the theme's
228
+ * relation label size at weight 500 in the body font, or what the style writes.
229
+ */
230
+ export function pillText(theme, scale, style) {
231
+ return {
232
+ scale: writtenScale(theme, scale, "relationLabel", style.fontSize),
233
+ weight: style.fontWeight ?? 500,
234
+ font: textFont(style.font),
235
+ };
236
+ }
237
+ /**
238
+ * The width of a tag's pill: its text in the mono font at `tagSize`, weight 500, with `beside`
239
+ * more for an icon, plus `tagPadding`. A pill is `tagHeight` high. Flow tags and the tags of
240
+ * sequence messages and participants are measured alike.
241
+ */
242
+ export function tagPillWidth(metrics, scale, text, beside = 0, face = DEFAULT_TAG_FACE) {
243
+ return Math.ceil(metrics.width(text, face.font, face.weight, scale.tag) + beside + scale.tagPadding);
244
+ }
245
+ const DEFAULT_TAG_FACE = { font: "mono", weight: 500 };
246
+ /**
247
+ * A tag's pill in `box`, in scene coordinates: its outline, as round as its shorter side allows
248
+ * (upright when the tag is taller than wide), and its text centred on it in the mono font at
249
+ * `tagSize`, weight 500. Flow tags and the tags of sequence messages and participants are drawn
250
+ * alike.
251
+ */
252
+ export function tagPill(metrics, scale, box, text, paint, face = DEFAULT_TAG_FACE) {
253
+ const center = boxCenter(box);
254
+ return {
255
+ outline: {
256
+ kind: "rect",
257
+ x: round(box.x),
258
+ y: round(box.y),
259
+ width: round(box.width),
260
+ height: round(box.height),
261
+ radius: cornerRadius(Infinity, box),
262
+ fill: paint.fill,
263
+ stroke: paint.stroke,
264
+ strokeWidth: paint.strokeWidth,
265
+ dashed: paint.dashed,
266
+ },
267
+ label: textItem(metrics, center.x, center.y, text, scale.tag, face.weight, paint.color, "middle", face.font),
268
+ };
269
+ }
169
270
  /** Room kept between a group's title and the connectors that pass under it. */
170
271
  const TITLE_GAP = 3;
171
272
  /**
@@ -300,17 +401,88 @@ function linePointsOf(line) {
300
401
  }
301
402
  /** The box of a free line: the bounding box of its points, at least 1 wide and 1 tall. */
302
403
  function lineBox(points) {
303
- const xs = points.map((p) => p.x);
304
- const ys = points.map((p) => p.y);
305
- const x = Math.min(...xs);
306
- const y = Math.min(...ys);
404
+ const { x, y, width, height } = penBox(points, 0);
405
+ return { x, y, width: Math.max(1, width), height: Math.max(1, height) };
406
+ }
407
+ /**
408
+ * The box of a pen stroke: the bounding box of its points grown by half its width on every side.
409
+ * The extremes are found in one loop: a stroke may hold more points than a call takes arguments.
410
+ */
411
+ function penBox(points, width) {
412
+ let x0 = Infinity;
413
+ let y0 = Infinity;
414
+ let x1 = -Infinity;
415
+ let y1 = -Infinity;
416
+ for (const p of points) {
417
+ if (p.x < x0)
418
+ x0 = p.x;
419
+ if (p.y < y0)
420
+ y0 = p.y;
421
+ if (p.x > x1)
422
+ x1 = p.x;
423
+ if (p.y > y1)
424
+ y1 = p.y;
425
+ }
307
426
  return {
308
- x,
309
- y,
310
- width: Math.max(1, Math.max(...xs) - x),
311
- height: Math.max(1, Math.max(...ys) - y),
427
+ x: x0 - width / 2,
428
+ y: y0 - width / 2,
429
+ width: x1 - x0 + width,
430
+ height: y1 - y0 + width,
312
431
  };
313
432
  }
433
+ /** Whether every point of a stroke is its first: a dot. */
434
+ function isDot(points) {
435
+ const first = points[0];
436
+ return first !== undefined && points.every((p) => p.x === first.x && p.y === first.y);
437
+ }
438
+ /**
439
+ * A name as part of an XML id: letters, digits, `_`, `-` and `.` kept, anything else `_`. Ids of
440
+ * the language are already such names; the rule keeps a scene's ids valid whatever it is given.
441
+ */
442
+ function xmlName(name) {
443
+ return name.replace(/[^A-Za-z0-9_.-]/g, "_");
444
+ }
445
+ /** A stroke's points without a point equal to the one before it, which make no segment. */
446
+ function withoutRepeats(points) {
447
+ const out = [];
448
+ for (const p of points) {
449
+ const last = out[out.length - 1];
450
+ if (last === undefined || last.x !== p.x || last.y !== p.y)
451
+ out.push(p);
452
+ }
453
+ return out;
454
+ }
455
+ /**
456
+ * The path of a pen stroke through its points, as the layout draws it: `M p0`, then for every
457
+ * inner point a quadratic curve to the midpoint between it and the next one, with the point as its
458
+ * control (`Q p_i mid(p_i, p_i+1)`), then `L p_last`; two points are one segment, and one point
459
+ * is `M p0 L p0`, which round caps draw as a dot. Numbers are rounded to hundredths, as the scene
460
+ * writes them. Hosts draw a stroke in progress with it, so it looks as it will be written.
461
+ */
462
+ export function penPath(points) {
463
+ const at = (p) => `${round(p.x)} ${round(p.y)}`;
464
+ const first = points[0];
465
+ if (first === undefined)
466
+ return "";
467
+ let d = `M ${at(first)}`;
468
+ for (let i = 1; i < points.length - 1; i++) {
469
+ const p = points[i];
470
+ const next = points[i + 1];
471
+ d += ` Q ${at(p)} ${at({ x: (p.x + next.x) / 2, y: (p.y + next.y) / 2 })}`;
472
+ }
473
+ return `${d} L ${at(points[points.length - 1])}`;
474
+ }
475
+ /** A pen's stroke width: its style's, else `DEFAULT_PEN_WIDTH`, and never below one pixel. */
476
+ function penWidth(style) {
477
+ return Math.max(PEN_WIDTH_RANGE.min, style.width ?? DEFAULT_PEN_WIDTH);
478
+ }
479
+ /**
480
+ * The length of a pen's heads: the theme's arrow size, or three times the stroke's width for a
481
+ * thicker stroke, so the head stays wider than the stroke it ends.
482
+ */
483
+ function penHeadLength(arrowSize, width) {
484
+ return Math.max(arrowSize, 3 * width);
485
+ }
314
486
  /** The side whose normal points most nearly from `from` towards `to`. */
315
487
  function sideToward(from, to) {
316
488
  const dx = to.x - from.x;
@@ -597,8 +769,8 @@ function iconDiagnostics(diagram, styles, sizing, lines, words, themeRange) {
597
769
  /**
598
770
  * `layout.vertical-align-ignored` for a `verticalAlign` that no element it styles uses: a box whose
599
771
  * shape draws its text at a fixed spot, a box whose icon is the centre piece (its label band is at
600
- * `labelPosition`), or an element that is no box. Once per statement that writes the property, or
601
- * once for a theme rule or class, on the diagram's `theme` (`themeRange`).
772
+ * `labelPosition`), or an element that is neither a box nor a sticky. Once per statement that
773
+ * writes the property, or once for a theme rule or class, on the diagram's `theme` (`themeRange`).
602
774
  */
603
775
  function verticalAlignDiagnostics(diagram, styles, sizing, lines, words, themeRange) {
604
776
  const sources = new Map();
@@ -609,7 +781,8 @@ function verticalAlignDiagnostics(diagram, styles, sizing, lines, words, themeRa
609
781
  const box = element.kind === "box";
610
782
  const fixed = box && FIXED_TEXT_SHAPES.has(sizing.shapeOf(element));
611
783
  const centred = box && !fixed && sizing.centred(element);
612
- const used = box && !fixed && !centred;
784
+ // A sticky's text block always moves within its card.
785
+ const used = (box && !fixed && !centred) || element.kind === "sticky";
613
786
  const why = used
614
787
  ? undefined
615
788
  : fixed
@@ -666,7 +839,9 @@ function layoutDrawing(compilation, diagram, options = {}) {
666
839
  const definitions = options.themes ?? compilation.document.themes;
667
840
  const resolved = resolveTheme(written ?? options.defaultTheme ?? null, definitions);
668
841
  // A default theme that does not resolve is reported where it is configured.
669
- const { 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;
670
845
  if (!resolved.known && written !== null) {
671
846
  const fileThemes = definitions.map((t) => t.id).filter((id) => !id.includes("#"));
672
847
  diagnostics.push({
@@ -720,18 +895,68 @@ function layoutDrawing(compilation, diagram, options = {}) {
720
895
  places.set(line.id, boxCenter(box));
721
896
  sizes.set(line.id, { width: box.width, height: box.height });
722
897
  }
898
+ const styles = new StyleResolver(theme, diagram);
899
+ // Pen strokes likewise, their box grown by half their width: a group holds the whole stroke.
900
+ const freePens = diagram.elements.filter((e) => e.kind === "pen" && e.pen !== null);
901
+ for (const pen of freePens) {
902
+ const box = penBox(pen.pen.points, penWidth(styles.element(pen)));
903
+ places.set(pen.id, boxCenter(box));
904
+ sizes.set(pen.id, { width: box.width, height: box.height });
905
+ }
723
906
  const mode = options.layout ?? diagram.properties.layout;
724
907
  const free = diagram.type === "free";
725
- 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));
726
935
  // Places are ignored in `auto` only when an engine arranges the diagram.
727
936
  const arranging = options.engine !== undefined &&
728
937
  (mode === "auto" ? true : mode === "assisted" && unplaced.length > 0);
938
+ // What a group that is not a list holds inside a list keeps its written place in every mode:
939
+ // no engine arranges inside a list.
940
+ const unplacedInLists = diagram.elements.filter((e) => holders.has(e.id) && !listed.has(e.id) && listPlaces(e) && !places.has(e.id));
941
+ for (const element of unplacedInLists) {
942
+ const list = [...element.groupPath]
943
+ .reverse()
944
+ .find((id) => isList(diagram.elementsById.get(id)));
945
+ diagnostics.push({
946
+ code: "layout.missing-placement",
947
+ severity: "error",
948
+ message: words.diagnostics.unplacedInList(element.id, parentOf(element), list),
949
+ range: element.range,
950
+ hints: [words.hints.addPlace(element.id)],
951
+ });
952
+ }
729
953
  // Free diagrams place children inside their group, so a group must be placed when its
730
954
  // children are, and always when nothing is arranged.
731
955
  if (free && !(arranging && mode === "auto")) {
732
956
  const placedChild = new Set(diagram.elements.flatMap((e) => (places.has(e.id) ? [parentOf(e) ?? ""] : [])));
733
957
  const missing = diagram.elements.filter((e) => e.kind === "group" &&
734
958
  !places.has(e.id) &&
959
+ !holders.has(e.id) &&
735
960
  (!arranging || placedChild.has(e.id) || unplaced.length === 0));
736
961
  for (const group of missing) {
737
962
  diagnostics.push({
@@ -748,7 +973,10 @@ function layoutDrawing(compilation, diagram, options = {}) {
748
973
  }
749
974
  if (unplaced.length > 0 && !arranging) {
750
975
  if (mode === "manual") {
976
+ // An unplaced list is a group without a place, reported above.
751
977
  for (const element of unplaced) {
978
+ if (element.kind === "group")
979
+ continue;
752
980
  diagnostics.push({
753
981
  code: "layout.missing-placement",
754
982
  severity: "error",
@@ -769,6 +997,8 @@ function layoutDrawing(compilation, diagram, options = {}) {
769
997
  }
770
998
  return { scene: null, diagnostics };
771
999
  }
1000
+ if (unplacedInLists.length > 0)
1001
+ return { scene: null, diagnostics };
772
1002
  // A placed and sized group that holds nothing is a band: it stays where it is written, out of
773
1003
  // the engine's request. In `auto` only bands at the top level, whose places are in diagram
774
1004
  // coordinates, are kept.
@@ -781,8 +1011,9 @@ function layoutDrawing(compilation, diagram, options = {}) {
781
1011
  childless(e))
782
1012
  .map((e) => e.id));
783
1013
  if (arranging && mode === "auto") {
1014
+ // What a list holds keeps its places: the engine arranges the list as one box.
784
1015
  const ignored = diagram.placement
785
- .filter((e) => (e.statement === "place" && !autoBands.has(e.target)) ||
1016
+ .filter((e) => (e.statement === "place" && !autoBands.has(e.target) && !holders.has(e.target)) ||
786
1017
  (e.statement === "route" && e.via.length > 0))
787
1018
  .sort((a, b) => a.range.start.offset - b.range.start.offset)[0];
788
1019
  if (ignored) {
@@ -795,7 +1026,6 @@ function layoutDrawing(compilation, diagram, options = {}) {
795
1026
  });
796
1027
  }
797
1028
  }
798
- const styles = new StyleResolver(theme, diagram);
799
1029
  const images = new Map();
800
1030
  for (const element of diagram.elements) {
801
1031
  if (element.kind !== "image" || element.source === null)
@@ -822,8 +1052,10 @@ function layoutDrawing(compilation, diagram, options = {}) {
822
1052
  const explicit = sizes.get(element.id);
823
1053
  const size = explicit ?? sizing.defaultSize(element);
824
1054
  const entry = sizeEntries.get(element.id);
825
- if (explicit && entry)
1055
+ // Stickies are checked at the size they are drawn, which a list may stretch.
1056
+ if (element.kind !== "sticky" && explicit && entry) {
826
1057
  sizing.checkOverflow(element, explicit, entry, diagnostics);
1058
+ }
827
1059
  elementSizes.set(element.id, size);
828
1060
  }
829
1061
  // Turned elements: their angle, clockwise; 0 and 360 draw upright.
@@ -879,30 +1111,66 @@ function layoutDrawing(compilation, diagram, options = {}) {
879
1111
  : [];
880
1112
  return outlineBounds([...turnedAt(id, box), ...corners]);
881
1113
  };
882
- // Free diagrams: turn places relative to group interiors into diagram coordinates, and give
883
- // every placed group its written or derived size.
884
- if (free && !(arranging && mode === "auto")) {
885
- 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)
886
1121
  ? extentAt(id, boxFromCenter({ x: 0, y: 0 }, size.width, size.height))
887
1122
  : undefined);
888
- places.clear();
889
- for (const [id, center] of frames.centers)
890
- places.set(id, center);
891
- for (const [id, size] of frames.groupSizes)
892
- 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);
893
1145
  }
1146
+ // The outermost lists an engine arranges, as boxes of their arranged size.
1147
+ const topLists = new Map();
1148
+ for (const element of diagram.elements) {
1149
+ const size = frames?.sizes.get(element.id);
1150
+ if (arranging && isList(element) && !holders.has(element.id) && size) {
1151
+ topLists.set(element.id, size);
1152
+ }
1153
+ }
1154
+ // The groups whose boxes follow the place an engine gave the list that holds them.
1155
+ const framedGroups = new Set();
894
1156
  // Positions: placed, or arranged by the engine; fixed ones never move afterwards.
895
1157
  const centers = new Map();
896
1158
  const fixed = new Set();
897
1159
  let engineRoutes = new Map();
898
1160
  let engineGroups = new Map();
899
- // `auto`: the written size of a group with children is the least size the engine gives it.
1161
+ // `auto`: the written size of a group with children is the least size the engine gives it. A
1162
+ // list's written size is already in its arranged one.
900
1163
  const minimums = new Map();
901
- if (arranging && mode === "auto") {
1164
+ if (autoArranged) {
902
1165
  for (const element of diagram.elements) {
903
1166
  const size = sizes.get(element.id);
904
- 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)) {
905
1172
  minimums.set(element.id, size);
1173
+ }
906
1174
  }
907
1175
  }
908
1176
  if (arranging) {
@@ -918,6 +1186,13 @@ function layoutDrawing(compilation, diagram, options = {}) {
918
1186
  // A turned element is arranged as its drawn box.
919
1187
  const extents = new Map();
920
1188
  const arranged = new Map(elementSizes);
1189
+ // A list is a box of its arranged size; a band stays out, as any band does.
1190
+ for (const [id, size] of topLists) {
1191
+ if (bands.has(id))
1192
+ topLists.delete(id);
1193
+ else
1194
+ arranged.set(id, size);
1195
+ }
921
1196
  const shifts = new Map();
922
1197
  for (const id of new Set([...outside.keys(), ...rotations.keys()])) {
923
1198
  const size = elementSizes.get(id);
@@ -940,7 +1215,7 @@ function layoutDrawing(compilation, diagram, options = {}) {
940
1215
  let arrangement;
941
1216
  const request = (mirrored) => arrangeRequest(diagram, arranged, mode === "assisted"
942
1217
  ? new Map([...places].map(([id, place]) => [id, shifted(id, place, 1)]))
943
- : new Map(), routes, theme, (group) => sizing.headerOf(group), (relation) => labelSize(relation, metrics, 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);
944
1219
  try {
945
1220
  arrangement = options.engine.arrange(request(new Set()));
946
1221
  // The ports of icon shapes face the flow; a relation the engine turns back would wrap
@@ -971,11 +1246,37 @@ function layoutDrawing(compilation, diagram, options = {}) {
971
1246
  if (mode === "auto") {
972
1247
  for (const [id, center] of arrangement.centers)
973
1248
  centers.set(id, center);
974
- 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)));
975
1255
  engineGroups = arrangement.groups ?? new Map();
976
1256
  }
977
1257
  else {
978
- 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
+ }
979
1280
  }
980
1281
  }
981
1282
  else {
@@ -1016,7 +1317,7 @@ function layoutDrawing(compilation, diagram, options = {}) {
1016
1317
  .sort((a, b) => b.groupPath.length - a.groupPath.length);
1017
1318
  const explicitGroups = new Set();
1018
1319
  for (const group of groups) {
1019
- const arranged = mode === "auto" && arranging && !autoBands.has(group.id);
1320
+ const arranged = mode === "auto" && arranging && !autoBands.has(group.id) && !framedGroups.has(group.id);
1020
1321
  const place = arranged ? undefined : places.get(group.id);
1021
1322
  const size = arranged ? undefined : sizes.get(group.id);
1022
1323
  if (place && size) {
@@ -1106,6 +1407,17 @@ function layoutDrawing(compilation, diagram, options = {}) {
1106
1407
  });
1107
1408
  }
1108
1409
  }
1410
+ // Pen strokes: their written points moved by their parent's interior origin; their box is the
1411
+ // bounding box of those points grown by half their width, which they draw within.
1412
+ const penGeometry = new Map();
1413
+ for (const pen of freePens) {
1414
+ const origin = origins.get(pen.id) ?? { x: 0, y: 0 };
1415
+ const points = pen.pen.points.map((p) => ({ x: p.x + origin.x, y: p.y + origin.y }));
1416
+ const width = penWidth(styles.element(pen));
1417
+ const bounds = penBox(points, width);
1418
+ boxes.set(pen.id, bounds);
1419
+ penGeometry.set(pen.id, { points, bounds, width });
1420
+ }
1109
1421
  // The drawn shape of every element box, and the outline of those that are not their box; a
1110
1422
  // turned element's outline is turned, and its drawn box is the bounding box of that outline.
1111
1423
  const shapes = new Map();
@@ -1113,8 +1425,9 @@ function layoutDrawing(compilation, diagram, options = {}) {
1113
1425
  const turnedBoxes = new Map();
1114
1426
  for (const element of diagram.elements) {
1115
1427
  const box = boxes.get(element.id);
1116
- if (element.kind === "group" || element.kind === "line" || !box)
1428
+ if (element.kind === "group" || element.kind === "line" || element.kind === "pen" || !box) {
1117
1429
  continue;
1430
+ }
1118
1431
  const shape = element.kind === "tag" ? "pill" : sizing.shapeOf(element);
1119
1432
  shapes.set(element.id, shape);
1120
1433
  if (rotations.has(element.id)) {
@@ -1148,7 +1461,7 @@ function layoutDrawing(compilation, diagram, options = {}) {
1148
1461
  * The boxes a connector should not cross: the other shapes of its layer, but not its ends, the
1149
1462
  * groups that hold them, nor a shape that contains an end box (a box it is stacked inside).
1150
1463
  */
1151
- const shapeIds = diagram.elements.filter((e) => e.kind !== "group" && e.kind !== "tag" && e.kind !== "line");
1464
+ const shapeIds = diagram.elements.filter((e) => e.kind !== "group" && e.kind !== "tag" && e.kind !== "line" && e.kind !== "pen");
1152
1465
  const obstaclesFor = (relation) => {
1153
1466
  const ends = [relation.from, relation.to];
1154
1467
  const holders = new Set(ends.flatMap((id) => [id, ...(diagram.elementsById.get(id)?.groupPath ?? [])]));
@@ -1171,11 +1484,14 @@ function layoutDrawing(compilation, diagram, options = {}) {
1171
1484
  });
1172
1485
  }
1173
1486
  // Relations with labels sit a label apart, so their pills do not overlap.
1174
- const laneSpacing = (relation) => relation.label !== null || relation.technology
1175
- ? scale.relationLabelHeight +
1176
- (relation.technology ? Math.round(technologySize(scale.relationLabel) * 1.25) : 0) +
1177
- 4
1178
- : 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
+ };
1179
1495
  /** Labels placed by the layout, which may slide along their connectors to stay clear. */
1180
1496
  const movable = [];
1181
1497
  // Named ports and compass sides with a position are points of the box.
@@ -1395,23 +1711,25 @@ function layoutDrawing(compilation, diagram, options = {}) {
1395
1711
  const offset = entry ? entry.offset : { x: 0, y: 0 };
1396
1712
  const center = pointAlong(curve ? line : drawn, at);
1397
1713
  const text = relation.label ?? "";
1398
- const 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);
1399
1717
  const iconWidth = icon ? icon.size + (text === "" ? 0 : icon.gap) : 0;
1400
1718
  // C4 relations show their technology on a second, smaller line.
1401
- const techSize = technologySize(scale.relationLabel);
1402
- const techWidth = technology ? metrics.width(technology, "body", 400, techSize) : 0;
1403
- const width = Math.max(textWidth + iconWidth, techWidth) + scale.relationLabelPadding;
1404
- 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);
1405
1723
  if (!entry) {
1406
1724
  movable.push({
1407
1725
  index,
1408
1726
  along: curve ? line : drawn,
1409
- width: Math.max(width, scale.relationLabelHeight),
1727
+ width: Math.max(width, pillScale.relationLabelHeight),
1410
1728
  height,
1411
1729
  });
1412
1730
  }
1413
1731
  label = {
1414
- box: boxFromCenter({ x: center.x + offset.x, y: center.y + offset.y }, Math.max(width, scale.relationLabelHeight), height),
1732
+ box: boxFromCenter({ x: center.x + offset.x, y: center.y + offset.y }, Math.max(width, pillScale.relationLabelHeight), height),
1415
1733
  text,
1416
1734
  technology,
1417
1735
  };
@@ -1475,11 +1793,12 @@ function layoutDrawing(compilation, diagram, options = {}) {
1475
1793
  const offset = entry ? entry.offset : { x: 0, y: 0 };
1476
1794
  const center = pointAlong(curve ? line : points, at);
1477
1795
  const text = element.label;
1478
- const textWidth = text === "" ? 0 : metrics.width(text, "body", 500, scale.relationLabel);
1796
+ const pill = sizing.linePill(element);
1797
+ const textWidth = text === "" ? 0 : metrics.width(text, pill.font, pill.weight, pill.scale.relationLabel);
1479
1798
  const iconWidth = icon ? icon.size + (text === "" ? 0 : icon.gap) : 0;
1480
- const width = textWidth + iconWidth + scale.relationLabelPadding;
1799
+ const width = textWidth + iconWidth + pill.scale.relationLabelPadding;
1481
1800
  label = {
1482
- box: boxFromCenter({ x: center.x + offset.x, y: center.y + offset.y }, Math.max(width, scale.relationLabelHeight), scale.relationLabelHeight),
1801
+ box: boxFromCenter({ x: center.x + offset.x, y: center.y + offset.y }, Math.max(width, pill.scale.relationLabelHeight), pill.scale.relationLabelHeight),
1483
1802
  text,
1484
1803
  };
1485
1804
  }
@@ -1493,10 +1812,27 @@ function layoutDrawing(compilation, diagram, options = {}) {
1493
1812
  connector: { points, line, fromSide, toSide, via: anchors.slice(1, -1) },
1494
1813
  });
1495
1814
  }
1815
+ // Pen strokes: their heads along their end segments, none unless written, which the stroke
1816
+ // stops at as a line does; a dot has none. A point written twice in a row makes no segment, so
1817
+ // the heads and the cut are taken from the stroke without repeats.
1818
+ const penDrawing = new Map();
1819
+ for (const pen of freePens) {
1820
+ const { points, width } = penGeometry.get(pen.id);
1821
+ const style = styles.element(pen);
1822
+ const endKind = style.arrowhead ?? "none";
1823
+ const startKind = style.arrowtail ?? "none";
1824
+ if (isDot(points) || (endKind === "none" && startKind === "none")) {
1825
+ penDrawing.set(pen.id, { line: points, start: [], end: [] });
1826
+ continue;
1827
+ }
1828
+ const length = penHeadLength(theme.arrowSize, width);
1829
+ const ends = relationEnds(withoutRepeats(points), startKind, endKind, length, (length * 7) / 8);
1830
+ penDrawing.set(pen.id, { line: ends.line, start: ends.start, end: ends.end });
1831
+ }
1496
1832
  // Every element's box with the label it draws outside it, in diagram coordinates.
1497
1833
  const extents = new Map([...boxes].map(([id, box]) => [id, extentAt(id, box)]));
1498
- // A line's box is no obstacle: labels may sit beside it as beside any connector.
1499
- const shapeExtents = new Map([...extents].filter(([id]) => !lineGeometry.has(id)));
1834
+ // A line's box is no obstacle: labels may sit beside it as beside any connector; nor is a pen's.
1835
+ const shapeExtents = new Map([...extents].filter(([id]) => !lineGeometry.has(id) && !penGeometry.has(id)));
1500
1836
  placeLabels(relationGeometry, movable, labelObstacles(diagram, shapeExtents, sizing));
1501
1837
  const elementLabels = new Map();
1502
1838
  for (const [id, box] of boxes) {
@@ -1510,15 +1846,17 @@ function layoutDrawing(compilation, diagram, options = {}) {
1510
1846
  for (const [id, box] of boxes)
1511
1847
  if (!rotations.has(id))
1512
1848
  checked.set(id, box);
1513
- // Lines cross boxes by design: they take no part in the composition checks.
1849
+ // Lines and pen strokes cross boxes by design: they take no part in the composition checks.
1514
1850
  for (const id of lineGeometry.keys())
1515
1851
  checked.delete(id);
1852
+ for (const id of penGeometry.keys())
1853
+ checked.delete(id);
1516
1854
  diagnostics.push(...checkComposition(diagram, checked, relationGeometry.map((g) => ({
1517
1855
  relation: g.relation,
1518
1856
  points: g.line,
1519
1857
  curved: g.curve !== undefined,
1520
1858
  label: g.label?.box,
1521
- })), explicitGroups, theme, (group) => sizing.untitled(group), outlines, elementLabels));
1859
+ })), explicitGroups, theme, (group) => sizing.untitled(group), outlines, elementLabels, (group) => sizing.headerOf(group)));
1522
1860
  const everything = [...extents.values()];
1523
1861
  for (const geometry of relationGeometry) {
1524
1862
  for (const p of [
@@ -1536,6 +1874,11 @@ function layoutDrawing(compilation, diagram, options = {}) {
1536
1874
  if (geometry.label)
1537
1875
  everything.push(geometry.label.box);
1538
1876
  }
1877
+ // A pen's box holds its stroke; its heads may reach beyond it.
1878
+ for (const drawing of penDrawing.values()) {
1879
+ for (const p of [...markerPoints(drawing.start), ...markerPoints(drawing.end)])
1880
+ everything.push({ x: p.x, y: p.y, width: 0, height: 0 });
1881
+ }
1539
1882
  const bounds = unionBoxes(everything) ?? { x: 0, y: 0, width: 0, height: 0 };
1540
1883
  const margin = theme.margin;
1541
1884
  const contentWidth = Math.ceil(bounds.width + 2 * margin);
@@ -1562,7 +1905,7 @@ function layoutDrawing(compilation, diagram, options = {}) {
1562
1905
  const shiftPoint = (p) => ({ x: p.x + dx, y: p.y + dy });
1563
1906
  const items = [];
1564
1907
  const gradients = [];
1565
- const emitter = new Emitter(theme, scale, styles, metrics, sizing);
1908
+ const emitter = new Emitter(theme, scale, styles, metrics, sizing, free, diagram.elementsById, diagram.id);
1566
1909
  const drawRelation = (geometry) => emitter.relation(geometry.relation, geometry.line.map(shiftPoint), shiftMarker(geometry.start, shiftPoint), shiftMarker(geometry.end, shiftPoint), geometry.label
1567
1910
  ? {
1568
1911
  box: shift(geometry.label.box),
@@ -1638,6 +1981,11 @@ function layoutDrawing(compilation, diagram, options = {}) {
1638
1981
  items.push(onLayer(emitter.line(line.element, box, line.line.map(shiftPoint), shiftMarker(line.start, shiftPoint), shiftMarker(line.end, shiftPoint), line.label ? { box: shift(line.label.box), text: line.label.text } : undefined, line.curve ? translatePath(line.curve, dx, dy) : undefined), paintable.element.layer));
1639
1982
  continue;
1640
1983
  }
1984
+ const pen = penDrawing.get(paintable.element.id);
1985
+ if (pen) {
1986
+ items.push(onLayer(emitter.pen(paintable.element, box, penGeometry.get(paintable.element.id).points.map(shiftPoint), pen.line.map(shiftPoint), shiftMarker(pen.start, shiftPoint), shiftMarker(pen.end, shiftPoint)), paintable.element.layer));
1987
+ continue;
1988
+ }
1641
1989
  items.push(onLayer(paintable.kind === "group"
1642
1990
  ? emitter.group(paintable.element, box)
1643
1991
  : elementGroup(paintable.element, box), paintable.element.layer));
@@ -1718,6 +2066,22 @@ function layoutDrawing(compilation, diagram, options = {}) {
1718
2066
  labelBoxes.set(id, geometry.label.box);
1719
2067
  connectors.set(id, geometry.connector);
1720
2068
  }
2069
+ // Stickies: the room their text is wrapped in and the size it is drawn at, for editors.
2070
+ const stickies = new Map();
2071
+ for (const element of diagram.elements) {
2072
+ const box = element.kind === "sticky" ? boxes.get(element.id) : undefined;
2073
+ if (box)
2074
+ stickies.set(element.id, sizing.stickyPlace(element, box));
2075
+ }
2076
+ // Lists: how they arrange their children and where the first one starts, for editors.
2077
+ const lists = new Map();
2078
+ for (const [id, list] of frames?.lists ?? []) {
2079
+ const box = boxes.get(id);
2080
+ if (!box)
2081
+ continue;
2082
+ const header = sizing.headerOf(diagram.elementsById.get(id));
2083
+ lists.set(id, { ...list, origin: { x: box.x + theme.groupPadding, y: box.y + header } });
2084
+ }
1721
2085
  return {
1722
2086
  scene,
1723
2087
  diagnostics,
@@ -1725,6 +2089,9 @@ function layoutDrawing(compilation, diagram, options = {}) {
1725
2089
  offset: { x: dx, y: dy },
1726
2090
  labels: labelBoxes,
1727
2091
  connectors,
2092
+ pens: penGeometry,
2093
+ stickies,
2094
+ lists,
1728
2095
  origins,
1729
2096
  theme,
1730
2097
  shapes,
@@ -1815,7 +2182,7 @@ function roundedPoints(list) {
1815
2182
  }
1816
2183
  /** A circle of radius `r` about (`cx`, `cy`), as path data. */
1817
2184
  function circlePath(cx, cy, r) {
1818
- return `M${cx - r} ${cy}a${r} ${r} 0 1 0 ${2 * r} 0a${r} ${r} 0 1 0 ${-2 * r} 0Z`;
2185
+ return `M${round(cx - r)} ${cy}a${r} ${r} 0 1 0 ${round(2 * r)} 0a${r} ${r} 0 1 0 ${round(-2 * r)} 0Z`;
1819
2186
  }
1820
2187
  function round(value) {
1821
2188
  return Math.round(value * 100) / 100;
@@ -1878,13 +2245,16 @@ function translatePath(d, dx, dy) {
1878
2245
  });
1879
2246
  }
1880
2247
  /** The size of a relation's label pill without its icon, or null when it has no label. */
1881
- function labelSize(relation, metrics, scale) {
2248
+ function labelSize(relation, metrics, pill) {
1882
2249
  const technology = relation.technology ? `[${relation.technology}]` : null;
1883
2250
  if (relation.label === null && !technology)
1884
2251
  return null;
2252
+ const scale = pill.scale;
1885
2253
  const techSize = technologySize(scale.relationLabel);
1886
- const text = relation.label ? metrics.width(relation.label, "body", 500, scale.relationLabel) : 0;
1887
- 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;
1888
2258
  return {
1889
2259
  width: Math.ceil(Math.max(text, tech) + scale.relationLabelPadding),
1890
2260
  height: scale.relationLabelHeight + (technology ? Math.round(techSize * 1.25) : 0),
@@ -1911,8 +2281,14 @@ const FLOW_SIDES = {
1911
2281
  * the routes meet the icon and not its label: on the side the flow leaves by for a source and
1912
2282
  * enters by for a target, or the other way round for the relations in `mirrored`, which run
1913
2283
  * against the flow.
2284
+ *
2285
+ * A list group is a node of its arranged size in `sizes`, and the engine never sees what it
2286
+ * holds: `holders` gives everything inside a list the outermost list around it, which the
2287
+ * relations and notes attached to what the list holds end on instead, with no port.
1914
2288
  */
1915
- function arrangeRequest(diagram, sizes, fixedPlaces, routes, theme, headerOf, labelOf = () => null, leftOut = new Set(), minimums = new Map(), iconExtents = new Map(), mirrored = new Set()) {
2289
+ function arrangeRequest(diagram, sizes, fixedPlaces, routes, theme, headerOf, labelOf = () => null, leftOut = new Set(), minimums = new Map(), iconExtents = new Map(), mirrored = new Set(), holders = new Map()) {
2290
+ /** The box the engine arranges for an element: the outermost list holding it, or its own. */
2291
+ const boxOf = (id) => holders.get(id) ?? id;
1916
2292
  /** A position along a side of an element's box, as a position along its extent's side. */
1917
2293
  const along = (id, side, position) => {
1918
2294
  const known = iconExtents.get(id);
@@ -1947,19 +2323,26 @@ function arrangeRequest(diagram, sizes, fixedPlaces, routes, theme, headerOf, la
1947
2323
  return id;
1948
2324
  };
1949
2325
  const edges = diagram.relations.flatMap((relation) => {
1950
- 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)))
1951
2332
  return [];
1952
2333
  const route = routes.get(relation.key);
1953
- const port = (end, which) => end && end.port !== "auto"
1954
- ? end.named
1955
- ? end.port
1956
- : enginePort(end)
1957
- : 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);
1958
2341
  return [
1959
2342
  {
1960
2343
  id: relation.key,
1961
- source: relation.from,
1962
- target: relation.to,
2344
+ source,
2345
+ target,
1963
2346
  sourcePort: port(route?.from, "from"),
1964
2347
  targetPort: port(route?.to, "to"),
1965
2348
  primary: relation.line === "solid",
@@ -1975,16 +2358,24 @@ function arrangeRequest(diagram, sizes, fixedPlaces, routes, theme, headerOf, la
1975
2358
  const target = note.attachedTo;
1976
2359
  if (note.kind !== "note" || target === null || target === note.id)
1977
2360
  return [];
1978
- if (fixedPlaces.has(note.id) || leftOut.has(note.id) || leftOut.has(target))
2361
+ // A note in a list is placed by it; a note on what a list holds is arranged beside the list.
2362
+ const source = boxOf(target);
2363
+ if (holders.has(note.id) || fixedPlaces.has(note.id))
2364
+ return [];
2365
+ if (leftOut.has(note.id) || leftOut.has(source))
1979
2366
  return [];
1980
2367
  const element = diagram.elementsById.get(target);
1981
- if (!element || element.kind === "line" || !sizes.has(target) || !sizes.has(note.id)) {
2368
+ if (!element ||
2369
+ element.kind === "line" ||
2370
+ element.kind === "pen" ||
2371
+ !sizes.has(source) ||
2372
+ !sizes.has(note.id)) {
1982
2373
  return [];
1983
2374
  }
1984
2375
  return [
1985
2376
  {
1986
2377
  id: `attach${note.id}`,
1987
- source: target,
2378
+ source,
1988
2379
  target: note.id,
1989
2380
  sourcePort: null,
1990
2381
  targetPort: null,
@@ -1994,9 +2385,13 @@ function arrangeRequest(diagram, sizes, fixedPlaces, routes, theme, headerOf, la
1994
2385
  ];
1995
2386
  });
1996
2387
  edges.push(...attached);
1997
- // Free lines are drawn where their points are written: the engine never moves them.
2388
+ // Free lines and pens are drawn where their points are written: the engine never moves them.
2389
+ // A list is a node; what it holds is not.
1998
2390
  const nodes = diagram.elements
1999
- .filter((e) => e.kind !== "group" && e.kind !== "line")
2391
+ .filter((e) => (e.kind === "group" ? isList(e) && !leftOut.has(e.id) : true) &&
2392
+ e.kind !== "line" &&
2393
+ e.kind !== "pen" &&
2394
+ !holders.has(e.id))
2000
2395
  .map((element) => {
2001
2396
  const ports = element.ports.map((p) => ({
2002
2397
  id: p.name,
@@ -2029,7 +2424,7 @@ function arrangeRequest(diagram, sizes, fixedPlaces, routes, theme, headerOf, la
2029
2424
  };
2030
2425
  });
2031
2426
  const groups = diagram.elements
2032
- .filter((e) => e.kind === "group" && !leftOut.has(e.id))
2427
+ .filter((e) => e.kind === "group" && !leftOut.has(e.id) && !isList(e) && !holders.has(e.id))
2033
2428
  .map((group) => {
2034
2429
  const minimum = minimums.get(group.id);
2035
2430
  return {
@@ -2097,7 +2492,8 @@ function routesLength(arrangement) {
2097
2492
  * `assisted` positions: placed elements keep their place. Each other element keeps its offset in
2098
2493
  * the arrangement from the placed element nearest to it there, and is then pushed away from that
2099
2494
  * element, along the layout direction, until it no longer overlaps a box already positioned. Boxes
2100
- * are compared with what they draw beyond them, `extentOf` (the label of an icon shape).
2495
+ * are compared with what they draw beyond them, `extentOf` (the label of an icon shape). `sizes`
2496
+ * holds the boxes to position: the leaves, and the lists, which are arranged as boxes.
2101
2497
  */
2102
2498
  function placeAround(diagram, arrangement, places, sizes, centers, fixed, extentOf = (_id, box) => box) {
2103
2499
  const direction = diagram.properties.direction;
@@ -2105,7 +2501,7 @@ function placeAround(diagram, arrangement, places, sizes, centers, fixed, extent
2105
2501
  const anchors = [...places.keys()].filter((id) => sizes.has(id) && arrangement.centers.has(id));
2106
2502
  const positioned = [];
2107
2503
  for (const element of diagram.elements) {
2108
- if (element.kind === "group" || !places.has(element.id))
2504
+ if (!sizes.has(element.id) || !places.has(element.id))
2109
2505
  continue;
2110
2506
  const size = sizes.get(element.id);
2111
2507
  centers.set(element.id, places.get(element.id));
@@ -2113,7 +2509,7 @@ function placeAround(diagram, arrangement, places, sizes, centers, fixed, extent
2113
2509
  positioned.push(extentOf(element.id, boxFromCenter(places.get(element.id), size.width, size.height)));
2114
2510
  }
2115
2511
  for (const element of diagram.elements) {
2116
- if (element.kind === "group" || places.has(element.id))
2512
+ if (!sizes.has(element.id) || places.has(element.id))
2117
2513
  continue;
2118
2514
  const size = sizes.get(element.id);
2119
2515
  const arranged = arrangement.centers.get(element.id) ?? { x: 0, y: 0 };
@@ -2281,7 +2677,8 @@ function parallelLanes(diagram, routes, spacingOf = () => LANE_SPACING) {
2281
2677
  }
2282
2678
  /**
2283
2679
  * The canvas for the diagram's `preset` or `canvas`, reporting `layout.too-large` when the content
2284
- * canvas exceeds what the preset holds. Nothing is clipped: a fixed canvas grows to fit.
2680
+ * canvas exceeds what the preset holds. Nothing is clipped: a fixed canvas grows to fit. A board
2681
+ * (`preset board`) is the content canvas whatever its size: a whiteboard grows as it is drawn on.
2285
2682
  */
2286
2683
  export function canvasSize(diagram, width, height, lines, diagnostics) {
2287
2684
  const { preset, canvas } = diagram.properties;
@@ -2291,8 +2688,15 @@ export function canvasSize(diagram, width, height, lines, diagnostics) {
2291
2688
  severity: "warning",
2292
2689
  message,
2293
2690
  range: propertyRange(diagram, keyword, lines) ?? diagram.idRange,
2294
- 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
+ ],
2295
2697
  });
2698
+ if (preset === "board")
2699
+ return { width, height };
2296
2700
  if (preset === "document") {
2297
2701
  if (width > DOCUMENT_MAX_WIDTH) {
2298
2702
  tooLarge("preset", words.diagnostics.tooWideForDocument(width, DOCUMENT_MAX_WIDTH));
@@ -2367,6 +2771,8 @@ class Sizing {
2367
2771
  centredLays = new Map();
2368
2772
  /** The centre of each aligned text block, by element and box. */
2369
2773
  textCenters = new Map();
2774
+ /** How the text of each sticky is drawn, by element and card size. */
2775
+ stickyTexts = new Map();
2370
2776
  constructor(theme, scale, metrics, styles, icons, images,
2371
2777
  /** The written `size` of each element that has one. */
2372
2778
  written = new Map()) {
@@ -2384,12 +2790,48 @@ class Sizing {
2384
2790
  * times the size apart.
2385
2791
  */
2386
2792
  labelFont(element) {
2387
- const free = element.kind === "box" || element.kind === "text";
2388
- const style = free ? this.styles.element(element) : {};
2793
+ const style = this.styles.element(element);
2389
2794
  const size = style.fontSize ??
2390
2795
  (element.kind === "text" ? Math.round(this.scale.label * 1.6) : this.scale.label);
2391
2796
  const weight = style.fontWeight ?? this.scale.labelWeight;
2392
- return { size, weight, line: Math.round(size * 1.25) };
2797
+ return { size, weight, line: Math.round(size * 1.25), font: textFont(style.font) };
2798
+ }
2799
+ /**
2800
+ * The description lines of an element: the theme's description size and line, or, when its
2801
+ * style writes `fontSize`, the description of that label size at the theme's ratio, in the
2802
+ * label's font at weight 400.
2803
+ */
2804
+ descriptionFont(element) {
2805
+ const style = this.styles.element(element);
2806
+ const scale = writtenScale(this.theme, this.scale, "label", style.fontSize);
2807
+ return { size: scale.description, line: scale.descriptionLine, font: textFont(style.font) };
2808
+ }
2809
+ /** The title of a group: the theme's group title size at the label weight, or the style's. */
2810
+ groupFont(group) {
2811
+ const style = this.styles.element(group);
2812
+ const size = style.fontSize ?? this.scale.groupTitle;
2813
+ return {
2814
+ size,
2815
+ weight: style.fontWeight ?? this.scale.labelWeight,
2816
+ line: Math.round(size * 1.25),
2817
+ font: textFont(style.font),
2818
+ };
2819
+ }
2820
+ /** A tag's scale and face: its pill at the written size, in the mono font unless styled. */
2821
+ tagText(element) {
2822
+ const style = this.styles.element(element);
2823
+ return {
2824
+ scale: writtenScale(this.theme, this.scale, "tag", style.fontSize),
2825
+ face: { font: textFont(style.font, "mono"), weight: style.fontWeight ?? 500 },
2826
+ };
2827
+ }
2828
+ /** The text of a relation's label pill. */
2829
+ relationPill(relation) {
2830
+ return pillText(this.theme, this.scale, this.styles.relation(relation));
2831
+ }
2832
+ /** The text of a free line's label pill. */
2833
+ linePill(element) {
2834
+ return pillText(this.theme, this.scale, this.styles.element(element));
2393
2835
  }
2394
2836
  /**
2395
2837
  * The drawn shape of a box-like element: its kind's (data stores are cylinders, decisions
@@ -2409,6 +2851,9 @@ class Sizing {
2409
2851
  return known(this.styles.element(element).shape) ?? "rounded";
2410
2852
  case "box":
2411
2853
  return known(this.styles.element(element).shape) ?? known(element.shape) ?? "rounded";
2854
+ case "sticky":
2855
+ // A card: a rectangle with the corners of its `radius` (see `shapeOptions`).
2856
+ return "rectangle";
2412
2857
  default:
2413
2858
  return "rounded";
2414
2859
  }
@@ -2421,6 +2866,8 @@ class Sizing {
2421
2866
  const shape = this.shapeOf(element);
2422
2867
  // A written radius (the element's style, a class or a rule) rounds every cornered shape.
2423
2868
  const written = CORNERED_SHAPES.has(shape) ? this.styles.element(element).radius : undefined;
2869
+ if (element.kind === "sticky")
2870
+ return { radius: written ?? STICKY_RADIUS };
2424
2871
  if (shape === "agent")
2425
2872
  return { radius: written ?? this.theme.radius * 1.5 };
2426
2873
  if (shape === "person") {
@@ -2496,9 +2943,10 @@ class Sizing {
2496
2943
  columnWith(element, icon) {
2497
2944
  const block = this.blockWith(element, icon);
2498
2945
  const font = this.labelFont(element);
2946
+ const description = this.descriptionFont(element);
2499
2947
  const lines = [
2500
2948
  ...block.labelOffsets.map((y) => ({ y, size: font.size })),
2501
- ...block.descriptionOffsets.map((y) => ({ y, size: this.scale.description })),
2949
+ ...block.descriptionOffsets.map((y) => ({ y, size: description.size })),
2502
2950
  ];
2503
2951
  let top = Math.min(...lines.map((l) => l.y - l.size / 2));
2504
2952
  let bottom = Math.max(...lines.map((l) => l.y + l.size / 2));
@@ -2605,6 +3053,14 @@ class Sizing {
2605
3053
  headerOf(group) {
2606
3054
  return groupHeaderOf(group, this.styles.element(group), this.theme);
2607
3055
  }
3056
+ /** The vertical centre of a group's title, from the group's top edge, inside its header. */
3057
+ titleOffset(group) {
3058
+ const header = this.headerOf(group);
3059
+ // A larger title keeps the theme's band below it and its 45 % above.
3060
+ return header === this.theme.groupHeader
3061
+ ? this.theme.groupHeader * 0.45
3062
+ : header - this.theme.groupHeader * 0.55;
3063
+ }
2608
3064
  /** Horizontal alignment of a box or text label. */
2609
3065
  /**
2610
3066
  * A note's font: the theme's note size at weight 400, or a free note's `fontSize` and
@@ -2617,16 +3073,131 @@ class Sizing {
2617
3073
  size,
2618
3074
  weight: style.fontWeight ?? 400,
2619
3075
  line: Math.round((size * this.scale.noteLine) / this.scale.note),
3076
+ font: textFont(style.font),
2620
3077
  };
2621
3078
  }
2622
3079
  alignOf(element) {
2623
3080
  if (element.kind === "text" || element.kind === "note") {
2624
3081
  return this.styles.element(element).textAlign ?? "left";
2625
3082
  }
2626
- if (element.kind === "box")
3083
+ if (element.kind === "box" || element.kind === "sticky") {
2627
3084
  return this.styles.element(element).textAlign ?? "center";
3085
+ }
2628
3086
  return "center";
2629
3087
  }
3088
+ /**
3089
+ * How a sticky's text is drawn on a card of `size`, its area relative to the card's top left
3090
+ * corner: wrapped at the width of the card inside the `padding` (right of its icon), at
3091
+ * `fontSize` or 1.2 times the label size, stepped down by 1 until the lines fit the area or the
3092
+ * size reaches `STICKY_MIN_FONT_SIZE`; lines 1.25 times the size apart, aligned by `textAlign`
3093
+ * (`center`) and `verticalAlign` (`middle`). A blank text always fits.
3094
+ */
3095
+ stickyText(element, size) {
3096
+ const key = `${element.id}\u0000${size.width}\u0000${size.height}`;
3097
+ const known = this.stickyTexts.get(key);
3098
+ if (known)
3099
+ return known;
3100
+ const style = this.styles.element(element);
3101
+ const weight = style.fontWeight ?? STICKY_FONT_WEIGHT;
3102
+ const face = textFont(style.font);
3103
+ const area = this.stickyArea(element, size);
3104
+ let font = style.fontSize ?? round(this.scale.label * STICKY_FONT_SCALE);
3105
+ let laid = this.stickyLines(element.label, area, font, weight, face);
3106
+ while (!laid.fits && font > STICKY_MIN_FONT_SIZE) {
3107
+ font = Math.max(STICKY_MIN_FONT_SIZE, round(font - 1));
3108
+ laid = this.stickyLines(element.label, area, font, weight, face);
3109
+ }
3110
+ const text = {
3111
+ area,
3112
+ size: font,
3113
+ weight,
3114
+ font: face,
3115
+ line: laid.line,
3116
+ lines: laid.lines,
3117
+ align: this.alignOf(element),
3118
+ verticalAlign: style.verticalAlign ?? "middle",
3119
+ fits: laid.fits,
3120
+ };
3121
+ this.stickyTexts.set(key, text);
3122
+ return text;
3123
+ }
3124
+ /**
3125
+ * The room of a sticky's text on a card of `size`: inside the padding, below the row of its icon,
3126
+ * so that the text keeps the card's whole width and stays centred on it.
3127
+ */
3128
+ stickyArea(element, size) {
3129
+ const padding = this.theme.padding;
3130
+ const icon = this.iconOf(element);
3131
+ const row = icon ? icon.size + icon.gap : 0;
3132
+ return {
3133
+ x: padding,
3134
+ y: padding + row,
3135
+ width: Math.max(0, size.width - 2 * padding),
3136
+ height: Math.max(0, size.height - 2 * padding - row),
3137
+ };
3138
+ }
3139
+ /** A text wrapped at the width of `area` at a font size, and whether its lines fit `area`. */
3140
+ stickyLines(text, area, size, weight, font) {
3141
+ const measure = (line) => this.metrics.width(line, font, weight, size);
3142
+ const lines = wrapLines(text, area.width, measure);
3143
+ const line = round(size * 1.25);
3144
+ const fits = text.trim() === "" ||
3145
+ (lines.length * line <= area.height + 1e-9 &&
3146
+ lines.every((l) => measure(l) <= area.width + 1e-9));
3147
+ return { lines, line, fits };
3148
+ }
3149
+ /**
3150
+ * How a sticky's text is drawn on its card at `box`, the area in the box's coordinates: what
3151
+ * editors that type over the card take.
3152
+ */
3153
+ stickyPlace(element, box) {
3154
+ const text = this.stickyText(element, { width: box.width, height: box.height });
3155
+ return {
3156
+ ...text,
3157
+ area: { ...text.area, x: box.x + text.area.x, y: box.y + text.area.y },
3158
+ };
3159
+ }
3160
+ /**
3161
+ * Reports `layout.text-overflow` on a sticky whose text does not fit a card of `size` even at
3162
+ * its smallest font size, with the smallest card of the same proportions on which it fits at
3163
+ * that size, in whole pixels.
3164
+ */
3165
+ checkSticky(element, size, range, diagnostics) {
3166
+ const text = this.stickyText(element, size);
3167
+ if (text.fits)
3168
+ return;
3169
+ const scaled = (width) => ({
3170
+ width,
3171
+ height: Math.max(1, Math.round((width * size.height) / Math.max(1, size.width))),
3172
+ });
3173
+ const fitsAt = (width) => {
3174
+ const card = scaled(width);
3175
+ return this.stickyLines(element.label, this.stickyArea(element, card), text.size, text.weight, text.font).fits;
3176
+ };
3177
+ let low = Math.max(1, Math.ceil(size.width));
3178
+ let high = low * 2;
3179
+ for (let i = 0; i < 32 && !fitsAt(high); i++) {
3180
+ low = high;
3181
+ high *= 2;
3182
+ }
3183
+ while (high - low > 1) {
3184
+ const middle = Math.floor((low + high) / 2);
3185
+ if (fitsAt(middle))
3186
+ high = middle;
3187
+ else
3188
+ low = middle;
3189
+ }
3190
+ const fit = scaled(high);
3191
+ const words = messages();
3192
+ diagnostics.push({
3193
+ code: "layout.text-overflow",
3194
+ severity: "warning",
3195
+ message: words.diagnostics.stickyOverflow(element.id, size.width, size.height, text.size, fit.width, fit.height),
3196
+ range,
3197
+ hints: [words.hints.textOverflow(element.id, fit.width, fit.height)],
3198
+ data: { ids: [element.id], size: { element: element.id, ...fit } },
3199
+ });
3200
+ }
2630
3201
  /** The inset of a free text or icon drawn with a frame (`fill`, `stroke` or `pattern`). */
2631
3202
  frameInset(element) {
2632
3203
  const style = this.styles.element(element);
@@ -2676,6 +3247,9 @@ class Sizing {
2676
3247
  return this.place(style.icon, Math.round(scale.tag * 1.3), "left");
2677
3248
  case "note":
2678
3249
  return this.place(style.icon, Math.round(this.noteFont(element).size * 1.3), "left");
3250
+ case "sticky":
3251
+ // At the top left of the card, its `iconSize` or the theme's, the text right of it.
3252
+ return this.place(style.icon, typeof style.iconSize === "number" ? style.iconSize : theme.iconSize, "left");
2679
3253
  }
2680
3254
  const icon = this.elementIcon(element);
2681
3255
  if (!icon)
@@ -3086,8 +3660,9 @@ class Sizing {
3086
3660
  return this.blockWith(element, undefined);
3087
3661
  }
3088
3662
  blockWith(element, icon) {
3089
- const scale = this.scale;
3090
3663
  const font = this.labelFont(element);
3664
+ const description = this.descriptionFont(element);
3665
+ const scale = { description: description.size, descriptionLine: description.line };
3091
3666
  const label = font.size;
3092
3667
  const lines = splitLines(element.label).length;
3093
3668
  // How far the first line of the label lies above its last.
@@ -3120,14 +3695,15 @@ class Sizing {
3120
3695
  }
3121
3696
  /** The widest of a box's label and description lines. */
3122
3697
  textWidth(element) {
3123
- const { metrics, scale } = this;
3698
+ const { metrics } = this;
3124
3699
  const font = this.labelFont(element);
3700
+ const description = this.descriptionFont(element);
3125
3701
  let width = 0;
3126
3702
  for (const line of splitLines(element.label)) {
3127
- width = Math.max(width, metrics.width(line, "body", font.weight, font.size));
3703
+ width = Math.max(width, metrics.width(line, font.font, font.weight, font.size));
3128
3704
  }
3129
3705
  for (const line of element.description ? splitLines(element.description) : []) {
3130
- width = Math.max(width, metrics.width(line, "body", 400, scale.description));
3706
+ width = Math.max(width, metrics.width(line, description.font, 400, description.size));
3131
3707
  }
3132
3708
  return width;
3133
3709
  }
@@ -3136,11 +3712,12 @@ class Sizing {
3136
3712
  * default sizes and hints), and the minimum width below which the content touches the edges.
3137
3713
  */
3138
3714
  textNeeds(element) {
3139
- const { metrics, scale, theme } = this;
3715
+ const { metrics, theme } = this;
3140
3716
  const widest = (entries) => entries.reduce((a, b) => (b.width > a.width ? b : a), { text: "", width: 0 });
3717
+ const description = this.descriptionFont(element);
3141
3718
  const descriptions = (element.description ? splitLines(element.description) : []).map((line) => ({
3142
3719
  text: line,
3143
- width: metrics.width(line, "body", 400, scale.description),
3720
+ width: metrics.width(line, description.font, 400, description.size),
3144
3721
  }));
3145
3722
  const icon = this.iconOf(element);
3146
3723
  const beside = icon && icon.position === "left" ? icon.size + icon.gap : 0;
@@ -3152,16 +3729,17 @@ class Sizing {
3152
3729
  const font = this.labelFont(element);
3153
3730
  const best = widest(splitLines(element.label).map((line) => ({
3154
3731
  text: line,
3155
- width: metrics.width(line, "body", font.weight, font.size),
3732
+ width: metrics.width(line, font.font, font.weight, font.size),
3156
3733
  })));
3157
3734
  const width = Math.ceil(Math.max(best.width, 1) + 2 * this.frameInset(element));
3158
3735
  return { text: best.text, width, minimum: width };
3159
3736
  }
3160
3737
  case "tag": {
3161
- const 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;
3162
3740
  return {
3163
3741
  text: element.label,
3164
- width: Math.ceil(width + scale.tagPadding),
3742
+ width: tagPillWidth(metrics, tag.scale, element.label, beside, tag.face),
3165
3743
  minimum: Math.ceil(width + 2 * MIN_TEXT_MARGIN),
3166
3744
  };
3167
3745
  }
@@ -3169,7 +3747,7 @@ class Sizing {
3169
3747
  const font = this.noteFont(element);
3170
3748
  const best = widest(splitLines(element.label).map((line) => ({
3171
3749
  text: line,
3172
- width: metrics.width(line, "body", font.weight, font.size),
3750
+ width: metrics.width(line, font.font, font.weight, font.size),
3173
3751
  })));
3174
3752
  const width = Math.ceil(Math.max(best.width + beside, 1));
3175
3753
  return { text: best.text, width, minimum: width };
@@ -3178,9 +3756,10 @@ class Sizing {
3178
3756
  // An untitled group has no text, so no minimum width.
3179
3757
  if (this.untitled(element))
3180
3758
  return { text: "", width: 0, minimum: 0 };
3759
+ const font = this.groupFont(element);
3181
3760
  const title = {
3182
3761
  text: element.label,
3183
- width: metrics.width(element.label, "body", scale.labelWeight, scale.groupTitle) + beside,
3762
+ width: metrics.width(element.label, font.font, font.weight, font.size) + beside,
3184
3763
  };
3185
3764
  const best = widest([title, ...descriptions]);
3186
3765
  const inset = theme.groupPadding + 4;
@@ -3202,17 +3781,18 @@ class Sizing {
3202
3781
  }
3203
3782
  /** The widest of a box's label and description lines. */
3204
3783
  widestLine(element) {
3205
- const { metrics, scale } = this;
3784
+ const { metrics } = this;
3206
3785
  const font = this.labelFont(element);
3786
+ const description = this.descriptionFont(element);
3207
3787
  const [first = "", ...rest] = splitLines(element.label);
3208
- let best = { text: first, width: metrics.width(first, "body", font.weight, font.size) };
3788
+ let best = { text: first, width: metrics.width(first, font.font, font.weight, font.size) };
3209
3789
  for (const line of rest) {
3210
- const width = metrics.width(line, "body", font.weight, font.size);
3790
+ const width = metrics.width(line, font.font, font.weight, font.size);
3211
3791
  if (width > best.width)
3212
3792
  best = { text: line, width };
3213
3793
  }
3214
3794
  for (const line of element.description ? splitLines(element.description) : []) {
3215
- const width = metrics.width(line, "body", 400, scale.description);
3795
+ const width = metrics.width(line, description.font, 400, description.size);
3216
3796
  if (width > best.width)
3217
3797
  best = { text: line, width };
3218
3798
  }
@@ -3232,10 +3812,16 @@ class Sizing {
3232
3812
  }
3233
3813
  defaultSize(element) {
3234
3814
  const { scale, theme } = this;
3815
+ if (element.kind === "sticky") {
3816
+ // A square that never grows for its text, scaled with the label size.
3817
+ const base = DEFAULT_SIZES.sticky;
3818
+ const factor = theme.labelSize / 13;
3819
+ return { width: Math.round(base.width * factor), height: Math.round(base.height * factor) };
3820
+ }
3235
3821
  const needs = this.textNeeds(element);
3236
3822
  switch (element.kind) {
3237
3823
  case "tag":
3238
- return { width: needs.width, height: scale.tagHeight };
3824
+ return { width: needs.width, height: this.tagText(element).scale.tagHeight };
3239
3825
  case "note":
3240
3826
  return {
3241
3827
  width: needs.width,
@@ -3418,20 +4004,54 @@ class Emitter {
3418
4004
  styles;
3419
4005
  metrics;
3420
4006
  sizing;
4007
+ free;
4008
+ elementsById;
4009
+ diagramId;
3421
4010
  patterns = [];
3422
4011
  painter = new IconPainter();
3423
4012
  patternIds = new Map();
3424
4013
  /** The title areas of drawn groups, which connectors pass under. */
3425
4014
  titles = [];
3426
- constructor(theme, scale, styles, metrics, sizing) {
4015
+ constructor(theme, scale, styles, metrics, sizing,
4016
+ /** Whether the diagram is free, the only kind whose styles give `opacity`. */
4017
+ free = false,
4018
+ /** The diagram's elements by id, where the groups around an element are found. */
4019
+ elementsById = new Map(),
4020
+ /**
4021
+ * The diagram's id, which the ids the scene declares (a sticky's clip) carry: a story frame
4022
+ * that cross-fades two views draws both scenes in one document.
4023
+ */
4024
+ diagramId = "") {
3427
4025
  this.theme = theme;
3428
4026
  this.scale = scale;
3429
4027
  this.styles = styles;
3430
4028
  this.metrics = metrics;
3431
4029
  this.sizing = sizing;
4030
+ this.free = free;
4031
+ this.elementsById = elementsById;
4032
+ this.diagramId = diagramId;
4033
+ }
4034
+ /**
4035
+ * The group of an element, group, line or pen of a free diagram with its style's `opacity`
4036
+ * times that of every group it is in as `styleOpacity`, which renderers multiply with a story's;
4037
+ * unchanged when it is opaque. A free scene is flat, so a group's children are its siblings and
4038
+ * a faded group fades them through their own groups.
4039
+ */
4040
+ faded(group, element) {
4041
+ if (!this.free)
4042
+ return group;
4043
+ const own = (e) => Math.max(0, Math.min(1, this.styles.element(e).opacity ?? 1));
4044
+ let opacity = own(element);
4045
+ for (const id of element.groupPath) {
4046
+ const holder = this.elementsById.get(id);
4047
+ if (holder)
4048
+ opacity *= own(holder);
4049
+ }
4050
+ return opacity >= 1 ? group : { ...group, styleOpacity: opacity };
3432
4051
  }
3433
4052
  text(x, y, text, size, weight, fill, anchor = "middle", font = "body") {
3434
4053
  const baseline = round(this.metrics.baseline(y, font, weight, size));
4054
+ const stack = writtenStack(font);
3435
4055
  return {
3436
4056
  kind: "text",
3437
4057
  x: round(x),
@@ -3440,7 +4060,8 @@ class Emitter {
3440
4060
  text,
3441
4061
  size,
3442
4062
  weight,
3443
- font,
4063
+ font: fontRole(font),
4064
+ ...(stack !== undefined ? { stack } : {}),
3444
4065
  anchor,
3445
4066
  fill,
3446
4067
  };
@@ -3486,7 +4107,7 @@ class Emitter {
3486
4107
  };
3487
4108
  }
3488
4109
  group(element, box) {
3489
- return withUrl(withBounds(this.groupItems(element, box), box), element);
4110
+ return this.faded(withUrl(withBounds(this.groupItems(element, box), box), element), element);
3490
4111
  }
3491
4112
  groupItems(element, box) {
3492
4113
  const { theme, scale } = this;
@@ -3499,7 +4120,11 @@ class Emitter {
3499
4120
  return { kind: "group", id: element.id, role: "element", items };
3500
4121
  }
3501
4122
  const inset = theme.groupPadding + 4;
3502
- const 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;
3503
4128
  const color = style.color ?? theme.ink;
3504
4129
  let titleX = box.x + inset;
3505
4130
  const icon = this.sizing.iconOf(element);
@@ -3507,38 +4132,40 @@ class Emitter {
3507
4132
  items.push(this.icon(icon, titleX + icon.size / 2, titleY, style.iconColor ?? color));
3508
4133
  titleX += icon.size + icon.gap;
3509
4134
  }
3510
- items.push(this.text(titleX, titleY, element.label, scale.groupTitle, scale.labelWeight, color, "start"));
4135
+ items.push(this.text(titleX, titleY, element.label, font.size, font.weight, color, "start", font.font));
3511
4136
  // The title, and the description under it, are kept clear of connectors.
3512
- const titleWidth = this.metrics.width(element.label, "body", scale.labelWeight, scale.groupTitle);
4137
+ const titleWidth = this.metrics.width(element.label, font.font, font.weight, font.size);
3513
4138
  const descriptionWidth = element.description
3514
- ? this.metrics.width(element.description, "body", 400, scale.description)
4139
+ ? this.metrics.width(element.description, font.font, 400, scale.description)
3515
4140
  : 0;
3516
4141
  const right = Math.max(titleX + titleWidth, box.x + inset + descriptionWidth);
3517
4142
  this.titles.push({
3518
4143
  id: element.id,
3519
4144
  box: {
3520
4145
  x: box.x + inset - TITLE_GAP,
3521
- y: titleY - scale.groupTitle * 0.75 - TITLE_GAP,
4146
+ y: titleY - font.size * 0.75 - TITLE_GAP,
3522
4147
  width: right - (box.x + inset) + 2 * TITLE_GAP,
3523
4148
  height: (element.description
3524
- ? box.y + theme.groupHeader * 0.85 + scale.description * 0.6
3525
- : titleY + scale.groupTitle * 0.6) -
3526
- (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) +
3527
4152
  2 * TITLE_GAP,
3528
4153
  },
3529
4154
  });
3530
4155
  if (element.description) {
3531
- items.push(this.text(box.x + inset, box.y + theme.groupHeader * 0.85, element.description, scale.description, 400, style.color ?? theme.muted, "start"));
4156
+ items.push(this.text(box.x + inset, box.y + below + theme.groupHeader * 0.85, element.description, scale.description, 400, style.color ?? theme.muted, "start", font.font));
3532
4157
  }
3533
4158
  return { kind: "group", id: element.id, role: "element", items };
3534
4159
  }
3535
4160
  element(element, box) {
3536
- return withUrl(withBounds(this.elementItems(element, box), box, this.sizing.innerArea(element, box), this.sizing.outsideLabel(element, box)?.box), element);
4161
+ return this.faded(withUrl(withBounds(this.elementItems(element, box), box, this.sizing.innerArea(element, box), this.sizing.outsideLabel(element, box)?.box), element), element);
3537
4162
  }
3538
4163
  elementItems(element, box) {
3539
4164
  switch (element.kind) {
3540
4165
  case "note":
3541
4166
  return this.note(element, box);
4167
+ case "sticky":
4168
+ return this.sticky(element, box);
3542
4169
  case "tag":
3543
4170
  return this.tag(element, box);
3544
4171
  case "text":
@@ -3600,7 +4227,7 @@ class Emitter {
3600
4227
  const anchor = align === "left" ? "start" : align === "right" ? "end" : "middle";
3601
4228
  const top = box.y + box.height / 2 - (lines.length * font.line) / 2;
3602
4229
  lines.forEach((line, i) => {
3603
- items.push(this.text(x, top + font.line / 2 + i * font.line, line, font.size, font.weight, style.color ?? this.theme.ink, anchor));
4230
+ items.push(this.text(x, top + font.line / 2 + i * font.line, line, font.size, font.weight, style.color ?? this.theme.ink, anchor, font.font));
3604
4231
  });
3605
4232
  return { kind: "group", id: element.id, role: "element", items };
3606
4233
  }
@@ -3763,9 +4390,10 @@ class Emitter {
3763
4390
  * anchor's side) about a text centre `y`, with the offsets of `block`.
3764
4391
  */
3765
4392
  lines(items, element, x, y, block, align) {
3766
- const { theme, scale } = this;
4393
+ const { theme } = this;
3767
4394
  const style = this.styles.element(element);
3768
4395
  const font = this.sizing.labelFont(element);
4396
+ const description = this.sizing.descriptionFont(element);
3769
4397
  const anchor = align === "left"
3770
4398
  ? "start"
3771
4399
  : align === "right"
@@ -3774,11 +4402,11 @@ class Emitter {
3774
4402
  ? "middle"
3775
4403
  : align;
3776
4404
  splitLines(element.label).forEach((line, i) => {
3777
- items.push(this.text(x, y + block.labelOffsets[i], line, font.size, font.weight, style.color ?? theme.ink, anchor));
4405
+ items.push(this.text(x, y + block.labelOffsets[i], line, font.size, font.weight, style.color ?? theme.ink, anchor, font.font));
3778
4406
  });
3779
4407
  const descriptionLines = element.description ? splitLines(element.description) : [];
3780
4408
  descriptionLines.forEach((line, i) => {
3781
- items.push(this.text(x, y + block.descriptionOffsets[i], line, 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));
3782
4410
  });
3783
4411
  }
3784
4412
  /**
@@ -3829,28 +4457,101 @@ class Emitter {
3829
4457
  : box.x + indent + (box.width - indent) / 2;
3830
4458
  const anchor = align === "left" ? "start" : align === "right" ? "end" : "middle";
3831
4459
  splitLines(element.label).forEach((line, i) => {
3832
- items.push(this.text(x, box.y + font.line / 2 + i * font.line, line, font.size, font.weight, color, anchor));
4460
+ items.push(this.text(x, box.y + font.line / 2 + i * font.line, line, font.size, font.weight, color, anchor, font.font));
3833
4461
  });
3834
4462
  return { kind: "group", id: element.id, role: "element", items };
3835
4463
  }
4464
+ /**
4465
+ * A sticky: a soft shadow 3 px under its card, the card in the theme's `sticky` colour (or its
4466
+ * `fill`) with `radius` 2 and an outline only when its style writes `stroke`, its icon at the
4467
+ * top left inside the padding, and its text (`Sizing.stickyText`) in `stickyText` (or its
4468
+ * `color`), wrapped and shrunk to the card, aligned by `textAlign` and `verticalAlign`. Text that
4469
+ * does not fit even at the smallest size starts at the top and is clipped to the card.
4470
+ */
4471
+ sticky(element, box) {
4472
+ const { theme } = this;
4473
+ const style = this.styles.element(element);
4474
+ const radius = cornerRadius(style.radius ?? STICKY_RADIUS, box);
4475
+ const shadow = {
4476
+ kind: "rect",
4477
+ x: round(box.x),
4478
+ y: round(box.y + STICKY_SHADOW.offset),
4479
+ width: round(box.width),
4480
+ height: round(box.height),
4481
+ radius,
4482
+ fill: STICKY_SHADOW.color,
4483
+ stroke: "none",
4484
+ strokeWidth: 0,
4485
+ dashed: false,
4486
+ opacity: STICKY_SHADOW.opacity,
4487
+ };
4488
+ const card = this.rect(box, radius, style.fill ?? theme.sticky, style.stroke ?? "none", style.stroke ? (style.width ?? theme.strokeWidth) : 0, strokeDash(style.line));
4489
+ const items = [shadow, card];
4490
+ const text = this.sizing.stickyPlace(element, box);
4491
+ const { area, line } = text;
4492
+ const color = style.color ?? theme.stickyText;
4493
+ const icon = this.sizing.iconOf(element);
4494
+ if (icon) {
4495
+ // In the top left corner inside the padding, on a row of its own above the text.
4496
+ const x = box.x + theme.padding + icon.size / 2;
4497
+ const y = box.y + theme.padding + icon.size / 2;
4498
+ items.push(this.icon(icon, x, y, style.iconColor ?? color));
4499
+ }
4500
+ const block = text.lines.length * line;
4501
+ const top = !text.fits
4502
+ ? area.y
4503
+ : text.verticalAlign === "top"
4504
+ ? area.y
4505
+ : text.verticalAlign === "bottom"
4506
+ ? area.y + area.height - block
4507
+ : area.y + (area.height - block) / 2;
4508
+ const x = text.align === "left"
4509
+ ? area.x
4510
+ : text.align === "right"
4511
+ ? area.x + area.width
4512
+ : area.x + area.width / 2;
4513
+ const anchor = text.align === "left" ? "start" : text.align === "right" ? "end" : "middle";
4514
+ text.lines.forEach((content, i) => {
4515
+ items.push(this.text(x, top + line / 2 + i * line, content, text.size, text.weight, color, anchor, text.font));
4516
+ });
4517
+ const group = { kind: "group", id: element.id, role: "element", items };
4518
+ if (text.fits)
4519
+ return group;
4520
+ const clip = {
4521
+ id: `clip-${xmlName(this.diagramId)}-${xmlName(element.id)}`,
4522
+ x: round(box.x),
4523
+ y: round(box.y),
4524
+ width: round(box.width),
4525
+ height: round(box.height),
4526
+ radius,
4527
+ };
4528
+ return { ...group, textClip: clip };
4529
+ }
3836
4530
  tag(element, box) {
3837
- const { theme, scale } = this;
4531
+ const { theme } = this;
3838
4532
  const style = this.styles.element(element);
3839
4533
  const fill = style.fill ?? theme.tag;
3840
4534
  const center = boxCenter(box);
3841
- // A pill, upright when the tag is taller than wide.
3842
- const outline = this.rect(box, cornerRadius(Infinity, box), fill, style.stroke ?? fill, style.width ?? theme.strokeWidth, strokeDash(style.line));
3843
4535
  const color = style.color ?? theme.tagText;
4536
+ const { scale, face } = this.sizing.tagText(element);
4537
+ // The pill sequence tags share, upright when the tag is taller than wide.
4538
+ const { outline, label } = tagPill(this.metrics, scale, box, element.label, {
4539
+ fill,
4540
+ stroke: style.stroke ?? fill,
4541
+ strokeWidth: style.width ?? theme.strokeWidth,
4542
+ dashed: strokeDash(style.line),
4543
+ color,
4544
+ }, face);
3844
4545
  const items = [outline, ...this.overlay(style, outline)];
3845
4546
  const icon = this.sizing.iconOf(element);
3846
4547
  if (icon) {
3847
- const textWidth = this.metrics.width(element.label, "mono", 500, scale.tag);
4548
+ const textWidth = this.metrics.width(element.label, face.font, face.weight, scale.tag);
3848
4549
  const left = center.x - (icon.size + icon.gap + textWidth) / 2;
3849
4550
  items.push(this.icon(icon, left + icon.size / 2, center.y, style.iconColor ?? color));
3850
- items.push(this.text(left + icon.size + icon.gap, center.y, element.label, scale.tag, 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));
3851
4552
  }
3852
4553
  else {
3853
- items.push(this.text(center.x, center.y, element.label, scale.tag, 500, color, "middle", "mono"));
4554
+ items.push(label);
3854
4555
  }
3855
4556
  return { kind: "group", id: element.id, role: "element", items };
3856
4557
  }
@@ -3889,14 +4590,61 @@ class Emitter {
3889
4590
  },
3890
4591
  ]
3891
4592
  : this.connectorItems(element.id, style, stroke, strokeDash(style.line ?? "solid"), [], line, start, end, label && { ...label, technology: null }, this.sizing.lineIcon(element), curve);
3892
- return withBounds({ kind: "group", id: element.id, role: "element", items, track: roundedPoints(line) }, box);
4593
+ return this.faded(withBounds({ kind: "group", id: element.id, role: "element", items, track: roundedPoints(line) }, box), element);
4594
+ }
4595
+ /**
4596
+ * A pen stroke: one path through its points (`penPath`) with round caps and joins and no fill,
4597
+ * in its `stroke` (the theme's `ink`), `width`, `line` and `opacity`, then its heads; a stroke
4598
+ * whose points coincide is a disc of its width. An element group marked `freehand`, whose
4599
+ * `track` is its points and whose `bounds` is its box. `points` are all its points; `line` the
4600
+ * same cut where its heads begin.
4601
+ */
4602
+ pen(element, box, points, line, start, end) {
4603
+ const style = this.styles.element(element);
4604
+ const stroke = style.stroke ?? this.theme.ink;
4605
+ const width = penWidth(style);
4606
+ const first = points[0];
4607
+ const items = isDot(points)
4608
+ ? [
4609
+ {
4610
+ kind: "path",
4611
+ d: circlePath(round(first.x), round(first.y), width / 2),
4612
+ fill: stroke,
4613
+ stroke: "none",
4614
+ strokeWidth: 0,
4615
+ dashed: false,
4616
+ },
4617
+ ]
4618
+ : [
4619
+ {
4620
+ kind: "path",
4621
+ d: penPath(line),
4622
+ fill: "none",
4623
+ stroke,
4624
+ strokeWidth: width,
4625
+ dashed: strokeDash(style.line),
4626
+ rounded: true,
4627
+ },
4628
+ ...markerItems(start, stroke, width),
4629
+ ...markerItems(end, stroke, width),
4630
+ ];
4631
+ return this.faded(withBounds({
4632
+ kind: "group",
4633
+ id: element.id,
4634
+ role: "element",
4635
+ freehand: true,
4636
+ items,
4637
+ track: roundedPoints(points),
4638
+ }, box), element);
3893
4639
  }
3894
4640
  /**
3895
4641
  * The items of a connector, in the order motion and hit tests expect: its line (pieces around
3896
4642
  * `holes`, or one curve), its heads, then its label pill with its icon and text.
3897
4643
  */
3898
4644
  connectorItems(id, style, stroke, dashed, holes, line, start, end, label, icon, curve) {
3899
- const { theme, scale } = this;
4645
+ const { theme } = this;
4646
+ const face = pillText(theme, this.scale, style);
4647
+ const scale = face.scale;
3900
4648
  const strokeWidth = style.width ?? theme.connectorWidth;
3901
4649
  const pieces = curve ? [] : splitAround(line, holes);
3902
4650
  const items = curve
@@ -3925,17 +4673,17 @@ class Emitter {
3925
4673
  pill.push(this.icon(icon, center.x, center.y, style.iconColor ?? color));
3926
4674
  }
3927
4675
  else if (icon) {
3928
- const textWidth = this.metrics.width(label.text, "body", 500, scale.relationLabel);
4676
+ const textWidth = this.metrics.width(label.text, face.font, face.weight, scale.relationLabel);
3929
4677
  const left = center.x - (icon.size + icon.gap + textWidth) / 2;
3930
4678
  pill.push(this.icon(icon, left + icon.size / 2, center.y, style.iconColor ?? color));
3931
- pill.push(this.text(left + icon.size + icon.gap, center.y, label.text, scale.relationLabel, 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));
3932
4680
  }
3933
4681
  else if (label.text !== "") {
3934
- 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));
3935
4683
  }
3936
4684
  if (label.technology) {
3937
4685
  const y = label.text === "" && !icon ? box.y + box.height / 2 : center.y + techLine;
3938
- pill.push(this.text(center.x, y, label.technology, techSize, 400, style.color ?? theme.muted));
4686
+ pill.push(this.text(center.x, y, label.technology, techSize, 400, style.color ?? theme.muted, "middle", face.font));
3939
4687
  }
3940
4688
  items.push({ kind: "group", id, role: "label", items: pill });
3941
4689
  }