@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/sequence.js CHANGED
@@ -2,11 +2,13 @@ import { resolveScenario, } from "@meltemi/diagrams-model";
2
2
  import { brandFrame, round2, textItem } from "./frame.js";
3
3
  import { cylinderPaths, unionBoxes } from "./geometry.js";
4
4
  import { markerItems, relationEnds, shiftMarker } from "./markers.js";
5
- import { canvasSize, frameMinWidth, } from "./layout.js";
5
+ import { canvasSize, frameMinWidth, tagPill, tagPillWidth, } from "./layout.js";
6
+ import { textFont, writtenStack } from "./measure.js";
6
7
  import { markGradient } from "./mark.js";
8
+ import { messages as words } from "./messages/index.js";
7
9
  import { strokeDash, } from "./scene.js";
8
10
  import { StyleResolver } from "./styles.js";
9
- import { splitLines } from "./text.js";
11
+ import { splitLines, wrapLines } from "./text.js";
10
12
  const BOX_KINDS = new Set(["participant", "database", "collections", "queue"]);
11
13
  /** The horizontal radius of a queue's ends. */
12
14
  const QUEUE_CAP = 8;
@@ -16,8 +18,19 @@ const STACK = 4;
16
18
  const LIFELINE_OPACITY = 0.5;
17
19
  const BAR = 10;
18
20
  const BAR_STEP = 5;
21
+ /** The least room between two heads, whatever a negative `gap` asks. */
22
+ const HEAD_CLEARANCE = 8;
23
+ /** How far two distances of the columns may differ by rounding alone. */
24
+ const ROUNDING = 1e-6;
19
25
  const SELF_WIDTH = 34;
20
26
  const SELF_HEIGHT = 22;
27
+ /** The room between two pills of a row of tags. */
28
+ const TAG_GAP = 4;
29
+ /** How far a message's row of tags stands from the arrow's start, or from its number's disc. */
30
+ const TAG_START = 6;
31
+ const TAG_START_NUMBERED = 12;
32
+ /** How far a figure's badges stand above it. */
33
+ const BADGE_LIFT = 4;
21
34
  /** Lays out a sequence diagram (decision 0019): columns in source order, events top to bottom. */
22
35
  export function layoutSequence(input) {
23
36
  const { compilation, diagram, options, theme, scale, metrics, diagnostics } = input;
@@ -33,73 +46,188 @@ export function layoutSequence(input) {
33
46
  const groups = diagram.elements.filter((e) => e.kind === "group");
34
47
  const column = new Map(participants.map((p, i) => [p.id, i]));
35
48
  // ---------------------------------------------------------------- measures
36
- const labelLine = Math.round(scale.label * 1.5);
37
- const boxHeight = Math.round(40 * f);
49
+ // A head's label is in the theme's label size and weight unless its style writes them; the row
50
+ // of heads grows to the largest label written (decision 0067).
51
+ const headText = (p) => sequenceText(styles.element(p), scale.label, scale.labelWeight);
52
+ const headSizes = participants
53
+ .map((p) => styles.element(p).fontSize)
54
+ .filter((size) => size !== undefined && size > scale.label);
55
+ const largest = Math.max(scale.label, ...headSizes);
56
+ const labelLine = Math.round(largest * 1.5);
57
+ const boxHeight = Math.max(Math.round(40 * f), Math.round((40 * largest) / 13));
38
58
  const figureHeight = Math.round(34 * f);
39
59
  const hasFigures = participants.some((p) => !BOX_KINDS.has(p.kind));
40
60
  const headHeight = Math.max(boxHeight, hasFigures ? figureHeight + 6 + labelLine : 0);
41
- const labelWidth = (p) => metrics.width(p.label, "body", scale.labelWeight, scale.label);
61
+ const labelWidth = (p) => {
62
+ const text = headText(p);
63
+ return metrics.width(p.label, text.font, text.weight, text.size);
64
+ };
42
65
  const widths = participants.map((p) => BOX_KINDS.has(p.kind)
43
66
  ? Math.max(Math.round(96 * f), Math.ceil(labelWidth(p) + 2 * theme.padding)) +
44
67
  // A queue's right cap takes room from its body.
45
68
  (p.kind === "queue" ? 2 * QUEUE_CAP : 0)
46
69
  : Math.max(Math.round(48 * f), Math.ceil(labelWidth(p) + 8)));
47
- const messageLabel = (relation) => relation.label === null ? 0 : metrics.width(relation.label, "body", 400, scale.relationLabel);
70
+ // A message label is in the body font at weight 400 and the relation label size, broken at `\n`.
71
+ // Between two lifelines it holds its columns apart on one line, and wraps at the room they leave
72
+ // it when a pull brings them closer; a self-call's stays beside its loop. Its lines are 1.25
73
+ // times the size apart, as a sticky's are.
74
+ const messageText = (relation) => labelText(sequenceText(styles.relation(relation), scale.relationLabel, 400));
75
+ const measureLabel = (relation) => {
76
+ const text = messageText(relation);
77
+ return (line) => metrics.width(line, text.font, text.weight, text.size);
78
+ };
79
+ /** The widest word of a label, words split at spaces and line breaks: the least room it needs. */
80
+ const longestWord = (relation) => relation.label === null
81
+ ? 0
82
+ : Math.max(0, ...relation.label.split(/[ \n]+/).map(measureLabel(relation)));
83
+ /** The widest line of a label broken at `\n` only: the room it takes unwrapped. */
84
+ const lineWidth = (relation) => relation.label === null
85
+ ? 0
86
+ : Math.max(0, ...splitLines(relation.label).map(measureLabel(relation)));
48
87
  const noteLines = (note) => splitLines(note.text);
49
88
  const noteSize = (note) => {
50
89
  const lines = noteLines(note);
51
- const width = Math.max(...lines.map((line) => metrics.width(line, "body", 400, scale.note)), 24);
52
- return { width: Math.ceil(width + 16), height: lines.length * scale.noteLine + 12 };
90
+ const text = noteText(styles, scale, note);
91
+ const width = Math.max(...lines.map((line) => metrics.width(line, text.font, text.weight, text.size)), 24);
92
+ return { width: Math.ceil(width + 16), height: lines.length * text.line + 12 };
53
93
  };
54
94
  const numbered = diagram.properties.autonumber !== null;
55
95
  const discRoom = numbered ? 22 : 0;
96
+ // Tags are the flow tag's pills, `tagHeight` high, in rows 4 apart: a message's on its arrow
97
+ // next to its source end, a participant's over its head as badges.
98
+ const tagHeight = scale.tagHeight;
99
+ const pillWidths = (tags) => tags.map((tag) => tagPillWidth(metrics, scale, tag.text));
100
+ /** The width of a row of pills, nothing when there are no tags. */
101
+ const rowWidth = (tags) => {
102
+ const pills = pillWidths(tags);
103
+ return pills.length === 0
104
+ ? 0
105
+ : pills.reduce((sum, w) => sum + w, 0) + (pills.length - 1) * TAG_GAP;
106
+ };
107
+ /** Boxes of a row of pills from `x`, centred on `y`. */
108
+ const pillRow = (tags, x, y) => {
109
+ let left = x;
110
+ return pillWidths(tags).map((width) => {
111
+ const box = { x: left, y: y - tagHeight / 2, width, height: tagHeight };
112
+ left += width + TAG_GAP;
113
+ return box;
114
+ });
115
+ };
116
+ /** The length of the head a message draws at its source end: two-way messages draw one. */
117
+ const startHeadRoom = (relation) => messageHeads(relation, styles.relation(relation)).start !== "none" ? theme.arrowSize : 0;
118
+ // A message's row starts 6 from its arrow's start, 12 beside its number's disc, and past the
119
+ // head a two-way message draws there.
120
+ const tagStart = (relation) => (numbered ? TAG_START_NUMBERED : TAG_START) + startHeadRoom(relation);
121
+ /** The loop of a self-call: wide enough for its row of tags on its top segment. */
122
+ const loopWidth = (relation) => relation.tags.length === 0
123
+ ? SELF_WIDTH
124
+ : Math.max(SELF_WIDTH, tagStart(relation) + rowWidth(relation.tags) + TAG_START);
125
+ // A head counts as the wider of itself and its badges where heads may not touch.
126
+ const extents = participants.map((p, i) => Math.max(widths[i], rowWidth(p.tags)));
127
+ // The badges stand over the heads: the row of heads starts lower by the room they need above
128
+ // it, a box kind's with their middle on its box's top edge, a figure's 4 above the figure.
129
+ const boxTop = headHeight - boxHeight;
130
+ const figureTop = headHeight - figureHeight - 6 - labelLine;
131
+ /** The room a head's badges need above its cell: none without tags. */
132
+ const badgeNeed = (p) => p.tags.length === 0
133
+ ? 0
134
+ : Math.max(0, BOX_KINDS.has(p.kind) ? tagHeight / 2 - boxTop : tagHeight + BADGE_LIFT - figureTop);
135
+ const badgeRoom = Math.max(0, ...participants.map(badgeNeed));
136
+ // `space` above (before) or below (after) an event; `gap` left (before) or right (after) of a
137
+ // participant, both sides of a pair adding up.
56
138
  const spaces = new Map();
57
- const gaps = new Map();
139
+ const spacesAfter = new Map();
140
+ const gapsBefore = new Map();
141
+ const gapsAfter = new Map();
58
142
  const labelEntries = new Map();
59
143
  for (const entry of diagram.placement) {
60
- if (entry.statement === "space")
61
- spaces.set(entry.target, entry.amount);
62
- else if (entry.statement === "gap")
63
- gaps.set(entry.target, entry.amount);
144
+ if (entry.statement === "space") {
145
+ (entry.side === "after" ? spacesAfter : spaces).set(entry.target, entry.amount);
146
+ }
147
+ else if (entry.statement === "gap") {
148
+ (entry.side === "after" ? gapsAfter : gapsBefore).set(entry.target, entry);
149
+ }
64
150
  else if (entry.statement === "label")
65
151
  labelEntries.set(entry.target, entry);
66
152
  }
153
+ const gapBefore = (id) => gapsBefore.get(id)?.amount ?? 0;
154
+ const gapAfter = (id) => gapsAfter.get(id)?.amount ?? 0;
67
155
  const baseKey = (key) => key.replace(/@\d+$/, "").replace(/@\d+$/, "");
68
156
  // ---------------------------------------------------------------- columns
69
- const xs = [];
70
- participants.forEach((p, i) => {
71
- xs[i] =
72
- i === 0
73
- ? widths[0] / 2
74
- : xs[i - 1] +
75
- widths[i - 1] / 2 +
76
- widths[i] / 2 +
77
- Math.round(40 * f) +
78
- (gaps.get(p.id) ?? 0);
79
- });
80
- const constraints = [];
157
+ // Two passes (decision 0065, D1-D2). The natural columns come first: half of each head, the
158
+ // `spacing` (40 scaled with the theme's labels when none is written, and never less there) and
159
+ // the two `gap`s of a pair when their sum is positive, pushed apart by every message label on
160
+ // one line, the rows of tags, self-calls and side notes. A pull then brings a pair closer than
161
+ // its natural distance by its amount: a negative sum of its two `gap`s, and a `spacing` below
162
+ // the default by the difference. It stops at the pair's floor: the two half heads (or their rows
163
+ // of badges, when wider) and HEAD_CLEARANCE, so heads never touch, and the longest word of every
164
+ // label between them, which wraps at the room it is left.
165
+ const defaultSpacing = Math.round(40 * f);
166
+ const spacing = diagram.properties.spacing ?? defaultSpacing;
167
+ const base = Math.max(spacing, defaultSpacing);
168
+ const spacingPull = Math.min(0, spacing - defaultSpacing);
169
+ const halves = (i) => widths[i - 1] / 2 + widths[i] / 2;
170
+ const floor = (i) => extents[i - 1] / 2 + extents[i] / 2 + HEAD_CLEARANCE;
171
+ const gapSum = (i) => gapAfter(participants[i - 1].id) + gapBefore(participants[i].id);
172
+ /** A pair's written distance before the labels push it: positive gaps only. */
173
+ const written = (i) => halves(i) + base + Math.max(0, gapSum(i));
174
+ /** How much closer than its natural distance a pair is asked to be: 0 or less. */
175
+ const pull = (i) => Math.min(0, gapSum(i)) + spacingPull;
176
+ // Room left of the first participant, and right of the last, never less than nothing.
177
+ const outerBefore = participants.length > 0 ? Math.max(0, gapBefore(participants[0].id)) : 0;
178
+ const outerAfter = participants.length > 0 ? Math.max(0, gapAfter(participants[participants.length - 1].id)) : 0;
179
+ // What holds the natural columns apart (each label on one line), and what holds the pulled ones
180
+ // (each label's longest word); self-calls, notes and rows of tags hold both.
181
+ const natural = [];
182
+ const floors = [];
183
+ const both = (constraint) => {
184
+ natural.push(constraint);
185
+ floors.push(constraint);
186
+ };
81
187
  let leftExtra = 0;
82
188
  let rightExtra = 0;
189
+ /**
190
+ * What a creating message (the message right after the `create` of its receiver) leaves out of
191
+ * the distance between the columns: its arrow ends on the near edge of the head it draws, half
192
+ * that head short of the receiver's column. Nothing for any other message.
193
+ */
194
+ const madeHead = (relation, before) => before?.kind === "create" && before.participant === relation.to && relation.from !== relation.to
195
+ ? widths[column.get(relation.to)] / 2
196
+ : 0;
83
197
  const walk = (list) => {
84
- for (const event of list) {
198
+ for (const [index, event] of list.entries()) {
85
199
  if (event.kind === "message") {
86
200
  const relation = diagram.relationsByKey.get(event.relation);
87
201
  if (!relation)
88
202
  continue;
89
203
  const a = column.get(relation.from);
90
204
  const b = column.get(relation.to);
91
- const room = messageLabel(relation) + discRoom;
92
205
  if (a === b) {
93
- const length = SELF_WIDTH + room + 16;
206
+ const length = loopWidth(relation) + lineWidth(relation) + discRoom + 16;
94
207
  if (a < participants.length - 1) {
95
- constraints.push({ a, b: a + 1, length: length + widths[a + 1] / 2 });
208
+ both({ a, b: a + 1, length: length + widths[a + 1] / 2 });
96
209
  }
97
210
  else
98
211
  rightExtra = Math.max(rightExtra, length);
99
212
  }
100
213
  else {
101
- constraints.push({ a: Math.min(a, b), b: Math.max(a, b), length: room + 24 });
214
+ // The label on one line holds the natural pair apart, its longest word a pulled one;
215
+ // the row of its tags holds both, with the disc and the arrowhead beside it. Each counts
216
+ // along the arrow, which a creating message ends on the head it draws.
217
+ const made = madeHead(relation, list[index - 1]);
218
+ const row = rowWidth(relation.tags);
219
+ const tagged = row > 0 ? row + 24 + discRoom + theme.arrowSize + startHeadRoom(relation) + made : 0;
220
+ const pair = { a: Math.min(a, b), b: Math.max(a, b) };
221
+ natural.push({
222
+ ...pair,
223
+ length: Math.max(lineWidth(relation) + discRoom + 24 + made, tagged),
224
+ });
225
+ floors.push({
226
+ ...pair,
227
+ length: Math.max(longestWord(relation) + 24 + discRoom + made, tagged),
228
+ });
102
229
  }
230
+ walk(event.events);
103
231
  }
104
232
  else if (event.kind === "note") {
105
233
  const size = noteSize(event);
@@ -108,26 +236,26 @@ export function layoutSequence(input) {
108
236
  const j = indices[indices.length - 1];
109
237
  if (event.position === "over" && i === j) {
110
238
  if (i > 0)
111
- constraints.push({ a: i - 1, b: i, length: size.width / 2 + widths[i - 1] / 2 + 10 });
239
+ both({ a: i - 1, b: i, length: size.width / 2 + widths[i - 1] / 2 + 10 });
112
240
  else
113
241
  leftExtra = Math.max(leftExtra, size.width / 2 - widths[i] / 2);
114
242
  if (i < participants.length - 1) {
115
- constraints.push({ a: i, b: i + 1, length: size.width / 2 + widths[i + 1] / 2 + 10 });
243
+ both({ a: i, b: i + 1, length: size.width / 2 + widths[i + 1] / 2 + 10 });
116
244
  }
117
245
  else
118
246
  rightExtra = Math.max(rightExtra, size.width / 2 - widths[i] / 2);
119
247
  }
120
248
  else if (event.position === "over") {
121
- constraints.push({ a: i, b: j, length: size.width - 48 });
249
+ both({ a: i, b: j, length: size.width - 48 });
122
250
  }
123
251
  else if (event.position === "left") {
124
252
  if (i > 0)
125
- constraints.push({ a: i - 1, b: i, length: size.width + 20 + widths[i - 1] / 2 });
253
+ both({ a: i - 1, b: i, length: size.width + 20 + widths[i - 1] / 2 });
126
254
  else
127
255
  leftExtra = Math.max(leftExtra, size.width + 20 - widths[i] / 2);
128
256
  }
129
257
  else if (i < participants.length - 1) {
130
- constraints.push({ a: i, b: i + 1, length: size.width + 20 + widths[i + 1] / 2 });
258
+ both({ a: i, b: i + 1, length: size.width + 20 + widths[i + 1] / 2 });
131
259
  }
132
260
  else
133
261
  rightExtra = Math.max(rightExtra, size.width + 20 - widths[i] / 2);
@@ -139,30 +267,92 @@ export function layoutSequence(input) {
139
267
  }
140
268
  };
141
269
  walk(events);
142
- constraints.sort((p, q) => p.b - q.b || p.a - q.a);
143
- for (const { a, b, length } of constraints) {
144
- const deficit = length - (xs[b] - xs[a]);
145
- if (deficit > 0)
146
- for (let k = b; k < xs.length; k++)
147
- xs[k] = xs[k] + deficit;
270
+ /**
271
+ * Pushes the right column of every constraint, and everything after it, as far as it needs
272
+ * beyond `slack`.
273
+ */
274
+ const push = (xs, constraints, slack) => {
275
+ constraints.sort((p, q) => p.b - q.b || p.a - q.a);
276
+ for (const { a, b, length } of constraints) {
277
+ const deficit = length - (xs[b] - xs[a]);
278
+ if (deficit > slack)
279
+ for (let k = b; k < xs.length; k++)
280
+ xs[k] = xs[k] + deficit;
281
+ }
282
+ };
283
+ const naturalXs = [];
284
+ participants.forEach((_p, i) => {
285
+ naturalXs[i] =
286
+ i === 0 ? widths[0] / 2 + outerBefore : naturalXs[i - 1] + Math.max(written(i), floor(i));
287
+ });
288
+ push(naturalXs, natural, 0);
289
+ /** The natural distance of the pair left of column `i`. */
290
+ const naturalDistance = (i) => naturalXs[i] - naturalXs[i - 1];
291
+ // The pulled columns: each pair closer by its pull, down to its heads' floor, then pushed by
292
+ // the longest words, the rows of tags, self-calls and notes. A pair without a pull keeps its
293
+ // natural distance exactly.
294
+ const xs = [];
295
+ let shift = 0;
296
+ participants.forEach((_p, i) => {
297
+ if (i > 0 && pull(i) < 0)
298
+ shift += Math.max(pull(i), floor(i) - naturalDistance(i));
299
+ xs[i] = naturalXs[i] + shift;
300
+ });
301
+ // The natural columns already hold every floor: rounding is no deficit.
302
+ push(xs, floors, ROUNDING);
303
+ /** Whether a pull brings any pair between columns `a` and `b` closer: their labels wrap. */
304
+ const pulled = (a, b) => {
305
+ for (let i = Math.min(a, b) + 1; i <= Math.max(a, b); i++)
306
+ if (pull(i) < 0)
307
+ return true;
308
+ return false;
309
+ };
310
+ // A negative gap that the floor kept from being drawn, measured from the natural distance, is
311
+ // said on the statement (the `before` one when both sides are written).
312
+ for (let i = 1; i < participants.length; i++) {
313
+ const after = gapsAfter.get(participants[i - 1].id);
314
+ const before = gapsBefore.get(participants[i].id);
315
+ const sum = (after?.amount ?? 0) + (before?.amount ?? 0);
316
+ const distance = xs[i] - xs[i - 1];
317
+ if (sum >= 0 || distance <= naturalDistance(i) + pull(i) + ROUNDING)
318
+ continue;
319
+ const entry = before ?? after;
320
+ const other = entry === before ? (after?.amount ?? 0) : (before?.amount ?? 0);
321
+ // `+ 0` writes a drawn 0 as 0, never -0.
322
+ const applied = Math.round(distance - naturalDistance(i) - other - spacingPull) + 0;
323
+ diagnostics.push({
324
+ code: "layout.gap-floor",
325
+ severity: "warning",
326
+ message: words().diagnostics.gapFloor(entry.target, entry.side, entry.amount, applied, participants[i - 1].id, participants[i].id),
327
+ range: entry.range,
328
+ data: {
329
+ gap: { participant: entry.target, side: entry.side, written: entry.amount, applied },
330
+ },
331
+ });
148
332
  }
149
333
  // ---------------------------------------------------------------- rows
150
- const groupTitle = groups.length > 0 ? labelLine + 12 : 0;
334
+ // The groups' titles share a band as tall as the largest title written, the head's line at least.
335
+ const groupTitle = groups.length > 0
336
+ ? Math.max(labelLine, ...groups.map((g) => Math.round((styles.element(g).fontSize ?? 0) * 1.5))) + 12
337
+ : 0;
151
338
  const rowGap = Math.round(14 * f);
152
- const labelHeight = scale.relationLabelHeight;
153
- let y = groupTitle + headHeight + Math.round(24 * f);
339
+ // The heads' row, below the groups' titles and the room of the badges over the heads.
340
+ const headTop = groupTitle + badgeRoom;
341
+ let y = headTop + headHeight + Math.round(24 * f);
154
342
  const lifelines = new Map();
155
343
  for (const p of participants) {
156
344
  lifelines.set(p.id, {
157
345
  participant: p.id,
158
346
  x: xs[column.get(p.id)],
159
- top: groupTitle + headHeight,
347
+ top: headTop + headHeight,
160
348
  bottom: null,
161
349
  });
162
350
  }
163
351
  const created = new Map();
164
352
  const heads = new Map();
165
353
  const messages = [];
354
+ const messageTexts = new Map();
355
+ const tags = new Map();
166
356
  const notes = [];
167
357
  const frames = [];
168
358
  const dividers = [];
@@ -213,222 +403,306 @@ export function layoutSequence(input) {
213
403
  for (const [index, event] of list.entries()) {
214
404
  y += spaces.get(baseKey(event.key)) ?? 0;
215
405
  const start = y;
216
- switch (event.kind) {
217
- case "message": {
218
- const relation = diagram.relationsByKey.get(event.relation);
219
- if (!relation)
220
- break;
221
- const a = relation.from;
222
- const b = relation.to;
223
- number += numbered ? 1 : 0;
224
- const text = relation.label ?? "";
225
- const textWidth = messageLabel(relation);
226
- let points;
227
- let arrowY;
228
- if (a === b) {
229
- const top = y + labelHeight;
230
- const x0 = edge(a, 1);
231
- points = [
232
- { x: x0, y: top },
233
- { x: x0 + SELF_WIDTH, y: top },
234
- { x: x0 + SELF_WIDTH, y: top + SELF_HEIGHT },
235
- { x: x0, y: top + SELF_HEIGHT },
236
- ];
237
- arrowY = top;
238
- y = top + SELF_HEIGHT + rowGap;
239
- }
240
- else {
241
- const createdHead = created.get(b);
242
- const dir = xs[column.get(b)] > xs[column.get(a)] ? 1 : -1;
243
- if (createdHead !== undefined && createdHead < 0) {
244
- // The creating message ends on the new head, drawn at this row.
245
- const top = y + 4;
246
- const head = headBoxAt(diagram.elementsById.get(b), top);
247
- heads.set(b, head);
248
- created.set(b, top);
249
- lifelines.get(b).top = top + headHeight;
250
- arrowY = top + headHeight - boxHeight / 2;
251
- const x1 = edge(a, dir);
252
- const x2 = dir > 0 ? head.x : head.x + head.width;
406
+ // `space … after` is room below the event's row, which the row itself does not hold.
407
+ try {
408
+ switch (event.kind) {
409
+ case "message": {
410
+ const relation = diagram.relationsByKey.get(event.relation);
411
+ if (!relation)
412
+ break;
413
+ const a = relation.from;
414
+ const b = relation.to;
415
+ number += numbered ? 1 : 0;
416
+ const text = relation.label ?? "";
417
+ const self = a === b;
418
+ // The room of a label between two lifelines: the length of its arrow (the distance
419
+ // between them, less half the head a creating message ends on), less 12 on each side
420
+ // and the disc. At the natural distance it holds the label on one line; a pull may
421
+ // leave it less, down to the longest word, and the label wraps there.
422
+ const made = self ? 0 : madeHead(relation, list[index - 1]);
423
+ const room = self
424
+ ? 0
425
+ : Math.max(1, Math.abs(xs[column.get(b)] - xs[column.get(a)]) - made - 24 - discRoom);
426
+ const wraps = !self && pulled(column.get(a), column.get(b));
427
+ const written = messageText(relation);
428
+ const measure = measureLabel(relation);
429
+ const labelHeight = written.height;
430
+ const labelStep = written.step;
431
+ const lines = text === ""
432
+ ? []
433
+ : self || lineWidth(relation) <= room + ROUNDING
434
+ ? splitLines(text)
435
+ : wrapLines(text, room, measure);
436
+ // The block of lines: one label line high, and a step more for each line after it.
437
+ const block = labelHeight + Math.max(0, lines.length - 1) * labelStep;
438
+ // A message's tags sit on its arrow: its label stands higher by half a pill and 2, and
439
+ // its row is that much taller, so that a wrapped label never meets them.
440
+ const lift = relation.tags.length > 0 && !self ? tagHeight / 2 + 2 : 0;
441
+ const loop = self ? loopWidth(relation) : SELF_WIDTH;
442
+ let points;
443
+ let arrowY;
444
+ if (self) {
445
+ // The lines are centred on the loop's middle: the row grows when they are taller.
446
+ const over = Math.max(0, (block - SELF_HEIGHT) / 2);
447
+ const top = y + Math.max(labelHeight, over + 3);
448
+ const x0 = edge(a, 1);
253
449
  points = [
254
- { x: x1, y: arrowY },
255
- { x: x2, y: arrowY },
450
+ { x: x0, y: top },
451
+ { x: x0 + loop, y: top },
452
+ { x: x0 + loop, y: top + SELF_HEIGHT },
453
+ { x: x0, y: top + SELF_HEIGHT },
256
454
  ];
257
- y = top + headHeight + rowGap;
455
+ arrowY = top;
456
+ y = top + SELF_HEIGHT + over + rowGap;
258
457
  }
259
458
  else {
260
- arrowY = y + labelHeight + 4;
261
- points = [
262
- { x: edge(a, dir), y: arrowY },
263
- { x: edge(b, dir === 1 ? -1 : 1), y: arrowY },
264
- ];
265
- y = arrowY + rowGap;
459
+ const createdHead = created.get(b);
460
+ const dir = xs[column.get(b)] > xs[column.get(a)] ? 1 : -1;
461
+ if (createdHead !== undefined && createdHead < 0) {
462
+ // The creating message ends on the new head, drawn at this row, lower by the
463
+ // label's lines after the first, the lift of its tags and the room of the head's
464
+ // own badges.
465
+ const element = diagram.elementsById.get(b);
466
+ const top = y + 4 + (block - labelHeight) + lift + badgeNeed(element);
467
+ const head = headBoxAt(element, top);
468
+ heads.set(b, head);
469
+ created.set(b, top);
470
+ lifelines.get(b).top = top + headHeight;
471
+ arrowY = top + headHeight - boxHeight / 2;
472
+ const x1 = edge(a, dir);
473
+ const x2 = dir > 0 ? head.x : head.x + head.width;
474
+ points = [
475
+ { x: x1, y: arrowY },
476
+ { x: x2, y: arrowY },
477
+ ];
478
+ y = top + headHeight + rowGap;
479
+ }
480
+ else {
481
+ // The arrow under the label's last line: the label keeps its top.
482
+ arrowY = y + block + 4 + lift;
483
+ points = [
484
+ { x: edge(a, dir), y: arrowY },
485
+ { x: edge(b, dir === 1 ? -1 : 1), y: arrowY },
486
+ ];
487
+ y = arrowY + rowGap;
488
+ }
266
489
  }
267
- }
268
- let label;
269
- if (text !== "" || numbered) {
490
+ // The block sits as a one-line label does: its bottom just above the arrow, centred at
491
+ // `at` along it, or beside a self-call's loop. `label … offset` moves the block, not
492
+ // the room it wraps in.
270
493
  const entry = relation.id ? labelEntries.get(relation.id) : undefined;
271
494
  const first = points[0];
272
495
  const lastPoint = points[points.length - 1];
273
496
  const at = entry ? entry.at / 100 : 0.5;
274
497
  const offset = entry?.offset ?? { x: 0, y: 0 };
275
- const width = Math.max(textWidth + 8, 8);
276
- const anchor = a === b
277
- ? { x: first.x + SELF_WIDTH + 8 + width / 2, y: first.y + SELF_HEIGHT / 2 }
278
- : { x: first.x + (lastPoint.x - first.x) * at, y: arrowY - labelHeight / 2 - 1 };
279
- label = {
280
- box: {
281
- x: anchor.x - width / 2 + offset.x,
282
- y: anchor.y - labelHeight / 2 + offset.y,
283
- width,
284
- height: labelHeight,
285
- },
286
- text,
498
+ const width = Math.max(Math.max(0, ...lines.map(measure)) + 8, 8);
499
+ const anchor = self
500
+ ? { x: first.x + loop + 8 + width / 2, y: first.y + SELF_HEIGHT / 2 }
501
+ : { x: first.x + (lastPoint.x - first.x) * at, y: arrowY - block / 2 - 1 - lift };
502
+ const box = {
503
+ x: anchor.x - width / 2 + offset.x,
504
+ y: anchor.y - block / 2 + offset.y,
505
+ width,
506
+ height: block,
287
507
  };
508
+ const label = text !== "" || numbered ? { box, lines, line: labelStep, text: written } : undefined;
509
+ messageTexts.set(event.key, {
510
+ area: self
511
+ ? box
512
+ : { x: box.x + box.width / 2 - room / 2, y: box.y, width: room, height: block },
513
+ lines,
514
+ size: written.size,
515
+ line: labelStep,
516
+ wraps,
517
+ weight: written.weight,
518
+ ...(writtenStack(written.font) !== undefined
519
+ ? { font: writtenStack(written.font) }
520
+ : {}),
521
+ });
522
+ // The row of tags on the arrow, its end nearest the source `tagStart` from the
523
+ // arrow's start: rightwards from it, or leftwards on a message from right to left.
524
+ const row = rowWidth(relation.tags);
525
+ const toRight = self || lastPoint.x >= first.x;
526
+ const pills = pillRow(relation.tags, toRight ? first.x + tagStart(relation) : first.x - tagStart(relation) - row, first.y);
527
+ if (pills.length > 0)
528
+ tags.set(event.key, pills);
529
+ messages.push({
530
+ key: event.key,
531
+ relation,
532
+ points,
533
+ self,
534
+ label,
535
+ number: numbered ? number : null,
536
+ tags: pills,
537
+ });
538
+ const xsOf = [...points.map((p) => p.x), ...pills.flatMap((b) => [b.x, b.x + b.width])];
539
+ extend(Math.min(...xsOf) - 8, Math.max(...xsOf, label ? label.box.x + label.box.width : -Infinity) + 8);
540
+ last = { participants: new Set([a, b]), y: a === b ? points[3].y : arrowY };
541
+ rows.set(event.key, { y: start, height: y - start });
542
+ if (event.events.length > 0) {
543
+ // The receiver is active for the call's block: a bar from the arrow to the block's
544
+ // last event, which moves the ends of the nested messages to its edge.
545
+ const stack = stacks.get(b) ?? [];
546
+ const top = last.y;
547
+ const bar = {
548
+ key: event.key,
549
+ participant: b,
550
+ depth: stack.length,
551
+ top,
552
+ bottom: top,
553
+ to: null,
554
+ block: true,
555
+ };
556
+ stack.push(bar);
557
+ stacks.set(b, stack);
558
+ bars.push(bar);
559
+ process(event.events);
560
+ (stacks.get(b) ?? []).pop();
561
+ const bottom = last && last.participants.has(b) ? last.y : y;
562
+ bar.bottom = Math.max(bottom, bar.top + 12);
563
+ }
564
+ continue;
288
565
  }
289
- messages.push({
290
- key: event.key,
291
- relation,
292
- points,
293
- self: a === b,
294
- label,
295
- number: numbered ? number : null,
296
- });
297
- const xsOf = points.map((p) => p.x);
298
- extend(Math.min(...xsOf) - 8, Math.max(...xsOf, label ? label.box.x + label.box.width : -Infinity) + 8);
299
- last = { participants: new Set([a, b]), y: a === b ? points[3].y : arrowY };
300
- rows.set(event.key, { y: start, height: y - start });
301
- continue;
302
- }
303
- case "activate": {
304
- const top = last && last.participants.has(event.participant) ? last.y : y;
305
- const stack = stacks.get(event.participant) ?? [];
306
- const bar = {
307
- participant: event.participant,
308
- depth: stack.length,
309
- top,
310
- bottom: top,
311
- };
312
- stack.push(bar);
313
- stacks.set(event.participant, stack);
314
- bars.push(bar);
315
- rows.set(event.key, { y: top, height: 0 });
316
- continue;
317
- }
318
- case "deactivate": {
319
- const stack = stacks.get(event.participant) ?? [];
320
- const bar = stack.pop();
321
- const bottom = last && last.participants.has(event.participant) ? last.y : y;
322
- if (bar)
323
- bar.bottom = Math.max(bottom, bar.top + 12);
324
- rows.set(event.key, { y: bottom, height: 0 });
325
- continue;
326
- }
327
- case "create": {
328
- const next = list[index + 1];
329
- const relation = next?.kind === "message" ? diagram.relationsByKey.get(next.relation) : undefined;
330
- if (relation && relation.to === event.participant && relation.from !== relation.to) {
331
- created.set(event.participant, -1); // drawn by the next message
566
+ case "activate": {
567
+ const top = last && last.participants.has(event.participant) ? last.y : y;
568
+ const stack = stacks.get(event.participant) ?? [];
569
+ const bar = {
570
+ key: event.key,
571
+ participant: event.participant,
572
+ depth: stack.length,
573
+ top,
574
+ bottom: top,
575
+ to: null,
576
+ block: false,
577
+ };
578
+ stack.push(bar);
579
+ stacks.set(event.participant, stack);
580
+ bars.push(bar);
581
+ rows.set(event.key, { y: top, height: 0 });
582
+ continue;
332
583
  }
333
- else {
334
- const top = y + 4;
335
- heads.set(event.participant, headBoxAt(diagram.elementsById.get(event.participant), top));
336
- created.set(event.participant, top);
337
- lifelines.get(event.participant).top = top + headHeight;
338
- y = top + headHeight + rowGap;
584
+ case "deactivate": {
585
+ const stack = stacks.get(event.participant) ?? [];
586
+ const bar = stack.pop();
587
+ const bottom = last && last.participants.has(event.participant) ? last.y : y;
588
+ if (bar) {
589
+ bar.bottom = Math.max(bottom, bar.top + 12);
590
+ bar.to = event.key;
591
+ }
592
+ rows.set(event.key, { y: bottom, height: 0 });
593
+ continue;
339
594
  }
340
- rows.set(event.key, { y: start, height: y - start });
341
- continue;
342
- }
343
- case "destroy": {
344
- const x = xs[column.get(event.participant)];
345
- const at = y + 8;
346
- crosses.push({ key: event.key, x, y: at });
347
- lifelines.get(event.participant).bottom = at;
348
- for (const bar of stacks.get(event.participant) ?? [])
349
- bar.bottom = Math.max(at, bar.top + 12);
350
- stacks.set(event.participant, []);
351
- y = at + rowGap + 4;
352
- rows.set(event.key, { y: start, height: y - start });
353
- last = undefined;
354
- continue;
355
- }
356
- case "note": {
357
- const size = noteSize(event);
358
- const indices = event.participants.map((p) => column.get(p)).sort((p, q) => p - q);
359
- const i = indices[0];
360
- const j = indices[indices.length - 1];
361
- const top = y + 4;
362
- let x;
363
- let width = size.width;
364
- if (event.position === "over") {
365
- const left = xs[i] - (i === j ? 0 : 24);
366
- const right = xs[j] + (i === j ? 0 : 24);
367
- width = Math.max(size.width, right - left);
368
- x = (left + right) / 2 - width / 2;
595
+ case "create": {
596
+ const next = list[index + 1];
597
+ const relation = next?.kind === "message" ? diagram.relationsByKey.get(next.relation) : undefined;
598
+ if (relation && relation.to === event.participant && relation.from !== relation.to) {
599
+ created.set(event.participant, -1); // drawn by the next message
600
+ }
601
+ else {
602
+ // A created head takes the room of its badges above it, as the heads' row does.
603
+ const element = diagram.elementsById.get(event.participant);
604
+ const top = y + 4 + badgeNeed(element);
605
+ heads.set(event.participant, headBoxAt(element, top));
606
+ created.set(event.participant, top);
607
+ lifelines.get(event.participant).top = top + headHeight;
608
+ y = top + headHeight + rowGap;
609
+ }
610
+ rows.set(event.key, { y: start, height: y - start });
611
+ continue;
369
612
  }
370
- else if (event.position === "left") {
371
- x = xs[i] - 10 - width;
613
+ case "destroy": {
614
+ const x = xs[column.get(event.participant)];
615
+ const at = y + 8;
616
+ crosses.push({ key: event.key, x, y: at });
617
+ lifelines.get(event.participant).bottom = at;
618
+ for (const bar of stacks.get(event.participant) ?? [])
619
+ bar.bottom = Math.max(at, bar.top + 12);
620
+ stacks.set(event.participant, []);
621
+ y = at + rowGap + 4;
622
+ rows.set(event.key, { y: start, height: y - start });
623
+ last = undefined;
624
+ continue;
372
625
  }
373
- else {
374
- x = xs[i] + 10;
626
+ case "note": {
627
+ const size = noteSize(event);
628
+ const indices = event.participants.map((p) => column.get(p)).sort((p, q) => p - q);
629
+ const i = indices[0];
630
+ const j = indices[indices.length - 1];
631
+ const top = y + 4;
632
+ let x;
633
+ let width = size.width;
634
+ if (event.position === "over") {
635
+ const left = xs[i] - (i === j ? 0 : 24);
636
+ const right = xs[j] + (i === j ? 0 : 24);
637
+ width = Math.max(size.width, right - left);
638
+ x = (left + right) / 2 - width / 2;
639
+ }
640
+ else if (event.position === "left") {
641
+ x = xs[i] - 10 - width;
642
+ }
643
+ else {
644
+ x = xs[i] + 10;
645
+ }
646
+ const box = { x, y: top, width, height: size.height };
647
+ notes.push({ key: event.key, event, box });
648
+ extend(box.x - 8, box.x + box.width + 8);
649
+ y = top + size.height + rowGap;
650
+ rows.set(event.key, { y: start, height: y - start });
651
+ last = undefined;
652
+ continue;
375
653
  }
376
- const box = { x, y: top, width, height: size.height };
377
- notes.push({ key: event.key, event, box });
378
- extend(box.x - 8, box.x + box.width + 8);
379
- y = top + size.height + rowGap;
380
- rows.set(event.key, { y: start, height: y - start });
381
- last = undefined;
382
- continue;
383
- }
384
- case "fragment": {
385
- const top = y + 4;
386
- const headerHeight = Math.round(22 * f);
387
- const frame = { minX: Infinity, maxX: -Infinity };
388
- open.push(frame);
389
- const separators = [];
390
- y = top + headerHeight + 6;
391
- for (const [b, branch] of event.branches.entries()) {
392
- if (b > 0) {
393
- const sep = y + 2;
394
- separators.push({ y: sep, condition: branch.condition });
395
- y = sep + Math.round(20 * f);
654
+ case "fragment": {
655
+ const top = y + 4;
656
+ const headerHeight = Math.round(22 * f);
657
+ const frame = { minX: Infinity, maxX: -Infinity };
658
+ open.push(frame);
659
+ const separators = [];
660
+ y = top + headerHeight + 6;
661
+ for (const [b, branch] of event.branches.entries()) {
662
+ if (b > 0) {
663
+ const sep = y + 2;
664
+ separators.push({ y: sep, condition: branch.condition });
665
+ y = sep + Math.round(20 * f);
666
+ }
667
+ last = undefined;
668
+ process(branch.events);
396
669
  }
670
+ open.pop();
671
+ const bottom = y + 4;
672
+ const headerText = fragmentHeader(event, metrics, scale, styles);
673
+ if (!Number.isFinite(frame.minX)) {
674
+ frame.minX = xs[0] - 24;
675
+ frame.maxX = xs[xs.length - 1] + 24;
676
+ }
677
+ const minX = frame.minX - 16;
678
+ const maxX = Math.max(frame.maxX + 16, minX + headerText + 24);
679
+ const box = { x: minX, y: top, width: maxX - minX, height: bottom - top };
680
+ frames.push({ key: event.key, event, box, headerHeight, separators });
681
+ extend(box.x - 8, box.x + box.width + 8);
682
+ y = bottom + rowGap;
683
+ rows.set(event.key, { y: start, height: y - start });
397
684
  last = undefined;
398
- process(branch.events);
685
+ continue;
399
686
  }
400
- open.pop();
401
- const bottom = y + 4;
402
- const headerText = fragmentHeader(event, metrics, scale);
403
- if (!Number.isFinite(frame.minX)) {
404
- frame.minX = xs[0] - 24;
405
- frame.maxX = xs[xs.length - 1] + 24;
687
+ case "divider": {
688
+ dividers.push({ key: event.key, y: y + 12, label: event.label });
689
+ y += 24 + rowGap;
690
+ rows.set(event.key, { y: start, height: y - start });
691
+ last = undefined;
692
+ continue;
693
+ }
694
+ case "delay": {
695
+ delays.push({ key: event.key, y: y + 14, label: event.label });
696
+ y += 28 + rowGap;
697
+ rows.set(event.key, { y: start, height: y - start });
698
+ last = undefined;
699
+ continue;
406
700
  }
407
- const minX = frame.minX - 16;
408
- const maxX = Math.max(frame.maxX + 16, minX + headerText + 24);
409
- const box = { x: minX, y: top, width: maxX - minX, height: bottom - top };
410
- frames.push({ key: event.key, event, box, headerHeight, separators });
411
- extend(box.x - 8, box.x + box.width + 8);
412
- y = bottom + rowGap;
413
- rows.set(event.key, { y: start, height: y - start });
414
- last = undefined;
415
- continue;
416
- }
417
- case "divider": {
418
- dividers.push({ key: event.key, y: y + 12, label: event.label });
419
- y += 24 + rowGap;
420
- rows.set(event.key, { y: start, height: y - start });
421
- last = undefined;
422
- continue;
423
- }
424
- case "delay": {
425
- delays.push({ key: event.key, y: y + 14, label: event.label });
426
- y += 28 + rowGap;
427
- rows.set(event.key, { y: start, height: y - start });
428
- last = undefined;
429
- continue;
430
701
  }
431
702
  }
703
+ finally {
704
+ y += spacesAfter.get(baseKey(event.key)) ?? 0;
705
+ }
432
706
  }
433
707
  };
434
708
  process(events);
@@ -441,15 +715,35 @@ export function layoutSequence(input) {
441
715
  bar.bottom = Math.max(bar.bottom, end);
442
716
  for (const p of participants) {
443
717
  if (!heads.has(p.id))
444
- heads.set(p.id, headBoxAt(p, groupTitle));
718
+ heads.set(p.id, headBoxAt(p, headTop));
445
719
  const line = lifelines.get(p.id);
446
720
  if (line.bottom === null)
447
721
  line.bottom = end;
448
722
  }
723
+ // A participant's badges: a row centred on its head, a box kind's with their middle on the top
724
+ // edge of its (front) box, a figure's 4 above the figure.
725
+ const badges = new Map();
726
+ for (const p of participants) {
727
+ if (p.tags.length === 0)
728
+ continue;
729
+ const cell = heads.get(p.id);
730
+ const middle = BOX_KINDS.has(p.kind)
731
+ ? cell.y + boxTop
732
+ : cell.y + figureTop - BADGE_LIFT - tagHeight / 2;
733
+ badges.set(p.id, pillRow(p.tags, cell.x + cell.width / 2 - rowWidth(p.tags) / 2, middle));
734
+ }
735
+ /** A bar's rectangle on its lifeline, nested bars stepping right. */
736
+ const barBox = (bar) => ({
737
+ x: xs[column.get(bar.participant)] - BAR / 2 + bar.depth * BAR_STEP,
738
+ y: bar.top,
739
+ width: BAR,
740
+ height: bar.bottom - bar.top,
741
+ });
449
742
  const groupBoxes = new Map();
450
743
  for (const group of groups) {
451
744
  const members = participants.filter((p) => p.groupPath.includes(group.id));
452
- const boxes = members.map((m) => heads.get(m.id));
745
+ // A group holds its members' heads and their badges.
746
+ const boxes = members.flatMap((m) => [heads.get(m.id), ...(badges.get(m.id) ?? [])]);
453
747
  const union = unionBoxes(boxes);
454
748
  if (!union)
455
749
  continue;
@@ -462,14 +756,18 @@ export function layoutSequence(input) {
462
756
  }
463
757
  // ---------------------------------------------------------------- canvas
464
758
  // A diagram with no participant yet (a new one) has an empty width, not an unknown one.
465
- const leftmost = xs.length > 0 ? xs[0] - widths[0] / 2 - leftExtra : 0;
466
- const rightmost = xs.length > 0 ? xs[xs.length - 1] + widths[widths.length - 1] / 2 + rightExtra : 0;
759
+ const leftmost = xs.length > 0 ? xs[0] - widths[0] / 2 - outerBefore - leftExtra : 0;
760
+ const rightmost = xs.length > 0
761
+ ? xs[xs.length - 1] + widths[widths.length - 1] / 2 + rightExtra + outerAfter
762
+ : 0;
467
763
  const left = Math.min(leftmost, ...frames.map((fr) => fr.box.x));
468
764
  const right = Math.max(rightmost, ...frames.map((fr) => fr.box.x + fr.box.width), ...notes.map((n) => n.box.x + n.box.width), ...messages.flatMap((m) => (m.label ? [m.label.box.x + m.label.box.width] : [])));
469
765
  const everything = [
470
766
  { x: left, y: 0, width: right - left, height: end + 8 },
471
767
  ...groupBoxes.values(),
472
768
  ...heads.values(),
769
+ ...[...badges.values()].flat(),
770
+ ...[...tags.values()].flat(),
473
771
  ];
474
772
  const bounds = unionBoxes(everything);
475
773
  const margin = theme.margin;
@@ -499,7 +797,9 @@ export function layoutSequence(input) {
499
797
  items.push(emit.frame(frame));
500
798
  const headers = frames.map((frame) => emit.frameHeader(frame));
501
799
  for (const p of participants) {
502
- items.push(emit.lifeline(p, lifelines.get(p.id), bars.filter((b) => b.participant === p.id), xs[column.get(p.id)]));
800
+ items.push(emit.lifeline(p, lifelines.get(p.id), bars
801
+ .filter((b) => b.participant === p.id)
802
+ .map((bar) => ({ key: bar.key, depth: bar.depth, box: barBox(bar) })), xs[column.get(p.id)]));
503
803
  }
504
804
  items.push(...headers);
505
805
  for (const message of messages)
@@ -514,7 +814,7 @@ export function layoutSequence(input) {
514
814
  items.push(emit.cross(cross));
515
815
  for (const p of participants) {
516
816
  const cell = heads.get(p.id);
517
- items.push(emit.head(p, cell, drawnHead(p, cell), boxHeight, figureHeight, labelLine));
817
+ items.push(emit.head(p, cell, drawnHead(p, cell), boxHeight, figureHeight, labelLine, badges.get(p.id) ?? []));
518
818
  }
519
819
  const gradients = [];
520
820
  if (framed) {
@@ -545,6 +845,22 @@ export function layoutSequence(input) {
545
845
  boxes.set(note.key, note.box);
546
846
  for (const frame of frames)
547
847
  boxes.set(frame.key, frame.box);
848
+ // Every bar by the key of its `activate`, or of the call whose block draws it; a written bar is
849
+ // also a box, which editors select and outline.
850
+ const barGeometry = new Map();
851
+ for (const bar of bars) {
852
+ const box = barBox(bar);
853
+ barGeometry.set(bar.key, {
854
+ participant: bar.participant,
855
+ box,
856
+ depth: bar.depth,
857
+ from: bar.key,
858
+ to: bar.to,
859
+ block: bar.block,
860
+ });
861
+ if (!bar.block)
862
+ boxes.set(bar.key, box);
863
+ }
548
864
  const connectors = new Map();
549
865
  const labels = new Map();
550
866
  for (const message of messages) {
@@ -578,6 +894,10 @@ export function layoutSequence(input) {
578
894
  boxes,
579
895
  offset: { x: dx, y: dy },
580
896
  labels,
897
+ messageTexts,
898
+ bars: barGeometry,
899
+ // The participants' badges, then the messages' pills.
900
+ tags: new Map([...badges, ...tags]),
581
901
  connectors,
582
902
  origins: new Map(),
583
903
  theme,
@@ -586,11 +906,45 @@ export function layoutSequence(input) {
586
906
  radii,
587
907
  };
588
908
  }
909
+ /**
910
+ * The heads a message draws: calls a filled one, replies and async calls an open one, two-way
911
+ * messages one at both ends; styles replace either.
912
+ */
913
+ function messageHeads(relation, style) {
914
+ const end = style.arrowhead ??
915
+ (relation.heads === "none"
916
+ ? "none"
917
+ : relation.line === "dashed" || relation.async
918
+ ? "open"
919
+ : "triangle");
920
+ return { start: style.arrowtail ?? (relation.heads === "both" ? end : "none"), end };
921
+ }
922
+ function sequenceText(style, size, weight) {
923
+ return {
924
+ size: style.fontSize ?? size,
925
+ weight: style.fontWeight ?? weight,
926
+ font: textFont(style.font),
927
+ };
928
+ }
929
+ /** A label's lines 1.25 times its size apart, its first line as tall as a relation label's. */
930
+ function labelText(text) {
931
+ return { ...text, step: text.size * 1.25, height: Math.round((text.size * 18) / 11) };
932
+ }
933
+ /** A note's text: the theme's note size at 400 or its style's, its lines the note scale's ratio. */
934
+ function noteText(styles, scale, note) {
935
+ const text = sequenceText(styles.event(note.id ?? note.key, "note"), scale.note, 400);
936
+ return { ...text, line: Math.round((text.size * scale.noteLine) / scale.note) };
937
+ }
938
+ /** A fragment's text: its operator's size (the relation label size) and font. */
939
+ function fragmentText(styles, scale, event, key) {
940
+ return sequenceText(styles.event(event.id ?? key, "fragment"), scale.relationLabel, 600);
941
+ }
589
942
  /** Width of a fragment's header: the operator tab and the first condition. */
590
- function fragmentHeader(event, metrics, scale) {
591
- const tab = metrics.width(event.operator, "body", 600, scale.relationLabel) + 18;
943
+ function fragmentHeader(event, metrics, scale, styles) {
944
+ const text = fragmentText(styles, scale, event, event.key);
945
+ const tab = metrics.width(event.operator, text.font, text.weight, text.size) + 18;
592
946
  const condition = event.branches[0]?.condition;
593
- return (tab + (condition ? metrics.width(`[${condition}]`, "body", 400, scale.relationLabel) + 12 : 0));
947
+ return tab + (condition ? metrics.width(`[${condition}]`, text.font, 400, text.size) + 12 : 0);
594
948
  }
595
949
  class SequenceEmitter {
596
950
  theme;
@@ -623,8 +977,8 @@ class SequenceEmitter {
623
977
  height: round2(box.height),
624
978
  };
625
979
  }
626
- text(x, y, text, size, weight, fill, anchor = "middle") {
627
- return textItem(this.metrics, x + this.dx, y + this.dy, text, size, weight, fill, anchor);
980
+ text(x, y, text, size, weight, fill, anchor = "middle", font = "body") {
981
+ return textItem(this.metrics, x + this.dx, y + this.dy, text, size, weight, fill, anchor, font);
628
982
  }
629
983
  rect(box, radius, fill, stroke, width, dashed = false) {
630
984
  const b = this.box(box);
@@ -647,7 +1001,7 @@ class SequenceEmitter {
647
1001
  const style = this.styles.element(group);
648
1002
  const items = [
649
1003
  this.rect(box, theme.groupRadius, style.fill ?? theme.band, style.stroke ?? theme.bandLine, style.width ?? theme.strokeWidth, strokeDash(style.line)),
650
- this.text(box.x + 12, box.y + 14, group.label, scale.groupTitle, scale.labelWeight, style.color ?? theme.ink, "start"),
1004
+ this.text(box.x + 12, box.y + 14 + Math.max(0, (style.fontSize ?? scale.groupTitle) - scale.groupTitle) / 2, group.label, style.fontSize ?? scale.groupTitle, style.fontWeight ?? scale.labelWeight, style.color ?? theme.ink, "start", textFont(style.font)),
651
1005
  ];
652
1006
  return {
653
1007
  kind: "group",
@@ -658,6 +1012,10 @@ class SequenceEmitter {
658
1012
  ...(group.url ? { href: group.url } : {}),
659
1013
  };
660
1014
  }
1015
+ /**
1016
+ * A participant's lifeline and its activation bars, each bar its own group of role `event` with
1017
+ * the key of its `activate` (or of the call whose block draws it) and its box as bounds.
1018
+ */
661
1019
  lifeline(participant, line, bars, x) {
662
1020
  const { theme } = this;
663
1021
  const style = this.styles.element(participant);
@@ -674,22 +1032,26 @@ class SequenceEmitter {
674
1032
  },
675
1033
  ];
676
1034
  for (const bar of [...bars].sort((a, b) => a.depth - b.depth)) {
677
- items.push(this.rect({
678
- x: x - BAR / 2 + bar.depth * BAR_STEP,
679
- y: bar.top,
680
- width: BAR,
681
- height: bar.bottom - bar.top,
682
- }, 0, style.fill ?? theme.surface, style.stroke ?? theme.muted, theme.strokeWidth));
1035
+ items.push({
1036
+ kind: "group",
1037
+ id: bar.key,
1038
+ role: "event",
1039
+ items: [
1040
+ this.rect(bar.box, 0, style.fill ?? theme.surface, style.stroke ?? theme.muted, theme.strokeWidth),
1041
+ ],
1042
+ bounds: this.bounds(bar.box, 0),
1043
+ });
683
1044
  }
684
1045
  return { kind: "group", id: participant.id, role: "lifeline", items };
685
1046
  }
686
1047
  /**
687
1048
  * A participant's head in its row's cell `head`; `drawn` is the box it is drawn in, which the
688
- * group carries as its bounds.
1049
+ * group carries as its bounds. Its badges, the pills of its tags, are drawn over it.
689
1050
  */
690
- head(participant, head, drawn, boxHeight, figureHeight, labelLine) {
1051
+ head(participant, head, drawn, boxHeight, figureHeight, labelLine, badges) {
691
1052
  const { theme, scale } = this;
692
1053
  const style = this.styles.element(participant);
1054
+ const text = sequenceText(style, scale.label, scale.labelWeight);
693
1055
  const fill = style.fill ?? theme.surface;
694
1056
  const stroke = style.stroke ?? theme.muted;
695
1057
  const strokeWidth = style.width ?? theme.strokeWidth;
@@ -726,7 +1088,7 @@ class SequenceEmitter {
726
1088
  }
727
1089
  // A queue's label is centred on the body, left of its right cap.
728
1090
  const capRoom = participant.kind === "queue" ? Math.min(QUEUE_CAP, box.width * 0.1) : 0;
729
- items.push(this.text(cx - capRoom, box.y + box.height / 2, participant.label, scale.label, scale.labelWeight, color));
1091
+ items.push(this.text(cx - capRoom, box.y + box.height / 2, participant.label, text.size, text.weight, color, "middle", text.font));
730
1092
  }
731
1093
  else {
732
1094
  const top = head.y + head.height - figureHeight - 6 - labelLine;
@@ -761,8 +1123,9 @@ class SequenceEmitter {
761
1123
  items.push(this.path(d, fill, figure, Math.max(strokeWidth, 1.5)));
762
1124
  }
763
1125
  }
764
- items.push(this.text(cx, head.y + head.height - labelLine / 2, participant.label, scale.label, scale.labelWeight, color));
1126
+ items.push(this.text(cx, head.y + head.height - labelLine / 2, participant.label, text.size, text.weight, color, "middle", text.font));
765
1127
  }
1128
+ items.push(...this.tags(participant.id, participant.tags, badges));
766
1129
  return {
767
1130
  kind: "group",
768
1131
  id: participant.id,
@@ -779,16 +1142,8 @@ class SequenceEmitter {
779
1142
  const stroke = style.stroke ?? theme.connector;
780
1143
  const strokeWidth = style.width ?? theme.connectorWidth;
781
1144
  const dashed = strokeDash(style.line ?? relation.line);
782
- // Calls take a filled head, replies and async calls an open one; two-way messages take it at
783
- // both ends; styles replace either.
784
- const endKind = style.arrowhead ??
785
- (relation.heads === "none"
786
- ? "none"
787
- : relation.line === "dashed" || relation.async
788
- ? "open"
789
- : "triangle");
790
- const startKind = style.arrowtail ?? (relation.heads === "both" ? endKind : "none");
791
- const arrow = relationEnds(message.points, startKind, endKind, theme.arrowSize, (theme.arrowSize * 7) / 8);
1145
+ const heads = messageHeads(relation, style);
1146
+ const arrow = relationEnds(message.points, heads.start, heads.end, theme.arrowSize, (theme.arrowSize * 7) / 8);
792
1147
  const items = [this.line(arrow.line, stroke, strokeWidth, dashed)];
793
1148
  const local = (parts) => shiftMarker(parts, (q) => this.p(q));
794
1149
  items.push(...markerItems(local(arrow.start), stroke, strokeWidth), ...markerItems(local(arrow.end), stroke, strokeWidth));
@@ -797,17 +1152,18 @@ class SequenceEmitter {
797
1152
  items.push(this.rect({ x: start.x - 8, y: start.y - 8, width: 16, height: 16 }, 8, style.stroke ?? theme.ink, "none", 0));
798
1153
  items.push(this.text(start.x, start.y, String(message.number), Math.round(scale.relationLabel * 0.85), 700, theme.surface));
799
1154
  }
800
- if (message.label && message.label.text !== "") {
801
- const box = message.label.box;
1155
+ if (message.label && message.label.lines.length > 0) {
1156
+ // One text a line, centred, the first where a one-line label is drawn.
1157
+ const { box, lines, line, text: written } = message.label;
802
1158
  items.push({
803
1159
  kind: "group",
804
1160
  id: message.key,
805
1161
  role: "label",
806
- items: [
807
- this.text(box.x + box.width / 2, box.y + box.height / 2, message.label.text, scale.relationLabel, 400, style.color ?? theme.ink),
808
- ],
1162
+ items: lines.map((text, i) => this.text(box.x + box.width / 2, box.y + written.height / 2 + i * line, text, written.size, written.weight, style.color ?? theme.ink, "middle", written.font)),
809
1163
  });
810
1164
  }
1165
+ // The pills of its tags, over the arrow and after the label, so that particles run under both.
1166
+ items.push(...this.tags(message.key, relation.tags, message.tags));
811
1167
  return {
812
1168
  kind: "group",
813
1169
  id: message.key,
@@ -816,6 +1172,23 @@ class SequenceEmitter {
816
1172
  track: arrow.line.map((q) => this.p(q)),
817
1173
  };
818
1174
  }
1175
+ /**
1176
+ * The pills of an owner's tags in their boxes, each a group of role `tag` with the owner's id or
1177
+ * key and its index: the flow tag's pill in the theme's `tag` and `tagText`.
1178
+ */
1179
+ tags(owner, tags, boxes) {
1180
+ const { theme } = this;
1181
+ return boxes.map((box, index) => {
1182
+ const { outline, label } = tagPill(this.metrics, this.scale, this.box(box), tags[index].text, {
1183
+ fill: theme.tag,
1184
+ stroke: theme.tag,
1185
+ strokeWidth: theme.strokeWidth,
1186
+ dashed: false,
1187
+ color: theme.tagText,
1188
+ });
1189
+ return { kind: "group", id: owner, role: "tag", tag: index, items: [outline, label] };
1190
+ });
1191
+ }
819
1192
  note(note) {
820
1193
  const { theme, scale } = this;
821
1194
  const style = note.event.id
@@ -829,8 +1202,9 @@ class SequenceEmitter {
829
1202
  const items = [
830
1203
  this.path(d, style.fill ?? theme.surface, style.stroke ?? theme.muted, style.width ?? theme.strokeWidth, strokeDash(style.line)),
831
1204
  ];
1205
+ const text = noteText(this.styles, scale, { id: note.event.id, key: note.key });
832
1206
  splitLines(note.event.text).forEach((line, i) => {
833
- items.push(this.text(b.x + 8, b.y + 6 + scale.noteLine / 2 + i * scale.noteLine, line, scale.note, 400, style.color ?? theme.ink, "start"));
1207
+ items.push(this.text(b.x + 8, b.y + 6 + text.line / 2 + i * text.line, line, text.size, text.weight, style.color ?? theme.ink, "start", text.font));
834
1208
  });
835
1209
  return { kind: "group", id: note.key, role: "event", items, bounds: this.bounds(b, 0) };
836
1210
  }
@@ -860,28 +1234,30 @@ class SequenceEmitter {
860
1234
  const style = this.styles.event(event.id ?? frame.key, "fragment");
861
1235
  const b = frame.box;
862
1236
  const stroke = style.stroke ?? theme.muted;
863
- const size = scale.relationLabel;
1237
+ const written = fragmentText(this.styles, scale, event, frame.key);
1238
+ const size = written.size;
1239
+ const operatorWeight = written.weight;
864
1240
  const color = style.color ?? theme.ink;
865
1241
  const muted = style.color ?? theme.muted;
866
1242
  const first = event.branches[0]?.condition ?? null;
867
1243
  const h = frame.headerHeight;
868
1244
  const items = [];
869
1245
  const label = (x, y, text, weight, fill) => {
870
- const width = metrics.width(text, "body", weight, size) + 6;
1246
+ const width = metrics.width(text, written.font, weight, size) + 6;
871
1247
  items.push(this.rect({ x: x - 3, y: y - h / 2 + 4, width, height: h - 8 }, 2, theme.background, "none", 0));
872
- items.push(this.text(x, y, text, size, weight, fill, "start"));
1248
+ items.push(this.text(x, y, text, size, weight, fill, "start", written.font));
873
1249
  };
874
1250
  if (event.operator === "region") {
875
1251
  if (first)
876
- label(b.x + 8, b.y + h / 2, first, 600, color);
1252
+ label(b.x + 8, b.y + h / 2, first, operatorWeight, color);
877
1253
  }
878
1254
  else {
879
- const tabWidth = metrics.width(event.operator, "body", 600, size) + 18;
1255
+ const tabWidth = metrics.width(event.operator, written.font, operatorWeight, size) + 18;
880
1256
  const s = this.box(b);
881
1257
  const n = (v) => String(round2(v));
882
1258
  const tab = `M${n(s.x)} ${n(s.y)} L${n(s.x + tabWidth)} ${n(s.y)} L${n(s.x + tabWidth)} ${n(s.y + h - 6)} L${n(s.x + tabWidth - 6)} ${n(s.y + h)} L${n(s.x)} ${n(s.y + h)} Z`;
883
1259
  items.push(this.path(tab, theme.surface, stroke, style.width ?? theme.strokeWidth));
884
- items.push(this.text(b.x + 8, b.y + h / 2, event.operator, size, 600, color, "start"));
1260
+ items.push(this.text(b.x + 8, b.y + h / 2, event.operator, size, operatorWeight, color, "start", written.font));
885
1261
  if (first)
886
1262
  label(b.x + tabWidth + 8, b.y + h / 2, `[${first}]`, 400, muted);
887
1263
  }