@meltemi/diagrams-layout 1.9.0 → 1.10.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/audit.d.ts +8 -6
  2. package/dist/audit.d.ts.map +1 -1
  3. package/dist/audit.js +34 -17
  4. package/dist/audit.js.map +1 -1
  5. package/dist/checks.d.ts +5 -1
  6. package/dist/checks.d.ts.map +1 -1
  7. package/dist/checks.js +11 -3
  8. package/dist/checks.js.map +1 -1
  9. package/dist/frame.d.ts +2 -2
  10. package/dist/frame.d.ts.map +1 -1
  11. package/dist/frame.js +4 -1
  12. package/dist/frame.js.map +1 -1
  13. package/dist/free.d.ts +50 -3
  14. package/dist/free.d.ts.map +1 -1
  15. package/dist/free.js +187 -32
  16. package/dist/free.js.map +1 -1
  17. package/dist/index.d.ts +4 -4
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +4 -4
  20. package/dist/index.js.map +1 -1
  21. package/dist/layout.d.ts +203 -6
  22. package/dist/layout.d.ts.map +1 -1
  23. package/dist/layout.js +1308 -138
  24. package/dist/layout.js.map +1 -1
  25. package/dist/measure.d.ts +21 -2
  26. package/dist/measure.d.ts.map +1 -1
  27. package/dist/measure.js +30 -7
  28. package/dist/measure.js.map +1 -1
  29. package/dist/messages/en.d.ts +37 -0
  30. package/dist/messages/en.d.ts.map +1 -1
  31. package/dist/messages/en.js +37 -0
  32. package/dist/messages/en.js.map +1 -1
  33. package/dist/messages/es.d.ts.map +1 -1
  34. package/dist/messages/es.js +14 -0
  35. package/dist/messages/es.js.map +1 -1
  36. package/dist/messages/index.d.ts +14 -0
  37. package/dist/messages/index.d.ts.map +1 -1
  38. package/dist/scene.d.ts +46 -1
  39. package/dist/scene.d.ts.map +1 -1
  40. package/dist/sequence.d.ts +1 -1
  41. package/dist/sequence.d.ts.map +1 -1
  42. package/dist/sequence.js +719 -286
  43. package/dist/sequence.js.map +1 -1
  44. package/dist/shapes.d.ts +19 -0
  45. package/dist/shapes.d.ts.map +1 -1
  46. package/dist/shapes.js +58 -0
  47. package/dist/shapes.js.map +1 -1
  48. package/dist/styles.d.ts +7 -1
  49. package/dist/styles.d.ts.map +1 -1
  50. package/dist/styles.js +22 -2
  51. package/dist/styles.js.map +1 -1
  52. package/dist/text.d.ts +8 -0
  53. package/dist/text.d.ts.map +1 -1
  54. package/dist/text.js +132 -0
  55. package/dist/text.js.map +1 -1
  56. package/dist/theme.d.ts +13 -1
  57. package/dist/theme.d.ts.map +1 -1
  58. package/dist/theme.js +69 -30
  59. package/dist/theme.js.map +1 -1
  60. package/package.json +6 -6
package/dist/sequence.js CHANGED
@@ -2,18 +2,35 @@ 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;
15
+ /** How far the second box of a collections head stands up and to the right of the first. */
16
+ const STACK = 4;
17
+ /** The opacity of a lifeline's stroke: half of `muted`, behind the messages. */
18
+ const LIFELINE_OPACITY = 0.5;
13
19
  const BAR = 10;
14
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;
15
25
  const SELF_WIDTH = 34;
16
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;
17
34
  /** Lays out a sequence diagram (decision 0019): columns in source order, events top to bottom. */
18
35
  export function layoutSequence(input) {
19
36
  const { compilation, diagram, options, theme, scale, metrics, diagnostics } = input;
@@ -29,73 +46,188 @@ export function layoutSequence(input) {
29
46
  const groups = diagram.elements.filter((e) => e.kind === "group");
30
47
  const column = new Map(participants.map((p, i) => [p.id, i]));
31
48
  // ---------------------------------------------------------------- measures
32
- const labelLine = Math.round(scale.label * 1.5);
33
- 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));
34
58
  const figureHeight = Math.round(34 * f);
35
59
  const hasFigures = participants.some((p) => !BOX_KINDS.has(p.kind));
36
60
  const headHeight = Math.max(boxHeight, hasFigures ? figureHeight + 6 + labelLine : 0);
37
- 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
+ };
38
65
  const widths = participants.map((p) => BOX_KINDS.has(p.kind)
39
66
  ? Math.max(Math.round(96 * f), Math.ceil(labelWidth(p) + 2 * theme.padding)) +
40
67
  // A queue's right cap takes room from its body.
41
68
  (p.kind === "queue" ? 2 * QUEUE_CAP : 0)
42
69
  : Math.max(Math.round(48 * f), Math.ceil(labelWidth(p) + 8)));
43
- 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)));
44
87
  const noteLines = (note) => splitLines(note.text);
45
88
  const noteSize = (note) => {
46
89
  const lines = noteLines(note);
47
- const width = Math.max(...lines.map((line) => metrics.width(line, "body", 400, scale.note)), 24);
48
- 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 };
49
93
  };
50
94
  const numbered = diagram.properties.autonumber !== null;
51
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.
52
138
  const spaces = new Map();
53
- const gaps = new Map();
139
+ const spacesAfter = new Map();
140
+ const gapsBefore = new Map();
141
+ const gapsAfter = new Map();
54
142
  const labelEntries = new Map();
55
143
  for (const entry of diagram.placement) {
56
- if (entry.statement === "space")
57
- spaces.set(entry.target, entry.amount);
58
- else if (entry.statement === "gap")
59
- 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
+ }
60
150
  else if (entry.statement === "label")
61
151
  labelEntries.set(entry.target, entry);
62
152
  }
153
+ const gapBefore = (id) => gapsBefore.get(id)?.amount ?? 0;
154
+ const gapAfter = (id) => gapsAfter.get(id)?.amount ?? 0;
63
155
  const baseKey = (key) => key.replace(/@\d+$/, "").replace(/@\d+$/, "");
64
156
  // ---------------------------------------------------------------- columns
65
- const xs = [];
66
- participants.forEach((p, i) => {
67
- xs[i] =
68
- i === 0
69
- ? widths[0] / 2
70
- : xs[i - 1] +
71
- widths[i - 1] / 2 +
72
- widths[i] / 2 +
73
- Math.round(40 * f) +
74
- (gaps.get(p.id) ?? 0);
75
- });
76
- 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
+ };
77
187
  let leftExtra = 0;
78
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;
79
197
  const walk = (list) => {
80
- for (const event of list) {
198
+ for (const [index, event] of list.entries()) {
81
199
  if (event.kind === "message") {
82
200
  const relation = diagram.relationsByKey.get(event.relation);
83
201
  if (!relation)
84
202
  continue;
85
203
  const a = column.get(relation.from);
86
204
  const b = column.get(relation.to);
87
- const room = messageLabel(relation) + discRoom;
88
205
  if (a === b) {
89
- const length = SELF_WIDTH + room + 16;
206
+ const length = loopWidth(relation) + lineWidth(relation) + discRoom + 16;
90
207
  if (a < participants.length - 1) {
91
- constraints.push({ a, b: a + 1, length: length + widths[a + 1] / 2 });
208
+ both({ a, b: a + 1, length: length + widths[a + 1] / 2 });
92
209
  }
93
210
  else
94
211
  rightExtra = Math.max(rightExtra, length);
95
212
  }
96
213
  else {
97
- 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
+ });
98
229
  }
230
+ walk(event.events);
99
231
  }
100
232
  else if (event.kind === "note") {
101
233
  const size = noteSize(event);
@@ -104,26 +236,26 @@ export function layoutSequence(input) {
104
236
  const j = indices[indices.length - 1];
105
237
  if (event.position === "over" && i === j) {
106
238
  if (i > 0)
107
- 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 });
108
240
  else
109
241
  leftExtra = Math.max(leftExtra, size.width / 2 - widths[i] / 2);
110
242
  if (i < participants.length - 1) {
111
- 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 });
112
244
  }
113
245
  else
114
246
  rightExtra = Math.max(rightExtra, size.width / 2 - widths[i] / 2);
115
247
  }
116
248
  else if (event.position === "over") {
117
- constraints.push({ a: i, b: j, length: size.width - 48 });
249
+ both({ a: i, b: j, length: size.width - 48 });
118
250
  }
119
251
  else if (event.position === "left") {
120
252
  if (i > 0)
121
- 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 });
122
254
  else
123
255
  leftExtra = Math.max(leftExtra, size.width + 20 - widths[i] / 2);
124
256
  }
125
257
  else if (i < participants.length - 1) {
126
- 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 });
127
259
  }
128
260
  else
129
261
  rightExtra = Math.max(rightExtra, size.width + 20 - widths[i] / 2);
@@ -135,30 +267,92 @@ export function layoutSequence(input) {
135
267
  }
136
268
  };
137
269
  walk(events);
138
- constraints.sort((p, q) => p.b - q.b || p.a - q.a);
139
- for (const { a, b, length } of constraints) {
140
- const deficit = length - (xs[b] - xs[a]);
141
- if (deficit > 0)
142
- for (let k = b; k < xs.length; k++)
143
- 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
+ });
144
332
  }
145
333
  // ---------------------------------------------------------------- rows
146
- 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;
147
338
  const rowGap = Math.round(14 * f);
148
- const labelHeight = scale.relationLabelHeight;
149
- 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);
150
342
  const lifelines = new Map();
151
343
  for (const p of participants) {
152
344
  lifelines.set(p.id, {
153
345
  participant: p.id,
154
346
  x: xs[column.get(p.id)],
155
- top: groupTitle + headHeight,
347
+ top: headTop + headHeight,
156
348
  bottom: null,
157
349
  });
158
350
  }
159
351
  const created = new Map();
160
352
  const heads = new Map();
161
353
  const messages = [];
354
+ const messageTexts = new Map();
355
+ const tags = new Map();
162
356
  const notes = [];
163
357
  const frames = [];
164
358
  const dividers = [];
@@ -187,226 +381,328 @@ export function layoutSequence(input) {
187
381
  const width = widths[i];
188
382
  return { x: xs[i] - width / 2, y: top, width, height: headHeight };
189
383
  };
384
+ /**
385
+ * The box a head is drawn in, within its row's cell: a box kind's box at the bottom of the cell
386
+ * (a collections head with its second box), a figure's whole cell. Editors select, halos pulse
387
+ * and highlights frame this box.
388
+ */
389
+ const drawnHead = (p, cell) => {
390
+ if (!BOX_KINDS.has(p.kind))
391
+ return cell;
392
+ const box = {
393
+ x: cell.x,
394
+ y: cell.y + cell.height - boxHeight,
395
+ width: cell.width,
396
+ height: boxHeight,
397
+ };
398
+ return p.kind === "collections"
399
+ ? { x: box.x, y: box.y - STACK, width: box.width + STACK, height: box.height + STACK }
400
+ : box;
401
+ };
190
402
  const process = (list) => {
191
403
  for (const [index, event] of list.entries()) {
192
404
  y += spaces.get(baseKey(event.key)) ?? 0;
193
405
  const start = y;
194
- switch (event.kind) {
195
- case "message": {
196
- const relation = diagram.relationsByKey.get(event.relation);
197
- if (!relation)
198
- break;
199
- const a = relation.from;
200
- const b = relation.to;
201
- number += numbered ? 1 : 0;
202
- const text = relation.label ?? "";
203
- const textWidth = messageLabel(relation);
204
- let points;
205
- let arrowY;
206
- if (a === b) {
207
- const top = y + labelHeight;
208
- const x0 = edge(a, 1);
209
- points = [
210
- { x: x0, y: top },
211
- { x: x0 + SELF_WIDTH, y: top },
212
- { x: x0 + SELF_WIDTH, y: top + SELF_HEIGHT },
213
- { x: x0, y: top + SELF_HEIGHT },
214
- ];
215
- arrowY = top;
216
- y = top + SELF_HEIGHT + rowGap;
217
- }
218
- else {
219
- const createdHead = created.get(b);
220
- const dir = xs[column.get(b)] > xs[column.get(a)] ? 1 : -1;
221
- if (createdHead !== undefined && createdHead < 0) {
222
- // The creating message ends on the new head, drawn at this row.
223
- const top = y + 4;
224
- const head = headBoxAt(diagram.elementsById.get(b), top);
225
- heads.set(b, head);
226
- created.set(b, top);
227
- lifelines.get(b).top = top + headHeight;
228
- arrowY = top + headHeight - boxHeight / 2;
229
- const x1 = edge(a, dir);
230
- 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);
231
449
  points = [
232
- { x: x1, y: arrowY },
233
- { 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 },
234
454
  ];
235
- y = top + headHeight + rowGap;
455
+ arrowY = top;
456
+ y = top + SELF_HEIGHT + over + rowGap;
236
457
  }
237
458
  else {
238
- arrowY = y + labelHeight + 4;
239
- points = [
240
- { x: edge(a, dir), y: arrowY },
241
- { x: edge(b, dir === 1 ? -1 : 1), y: arrowY },
242
- ];
243
- 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
+ }
244
489
  }
245
- }
246
- let label;
247
- 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.
248
493
  const entry = relation.id ? labelEntries.get(relation.id) : undefined;
249
494
  const first = points[0];
250
495
  const lastPoint = points[points.length - 1];
251
496
  const at = entry ? entry.at / 100 : 0.5;
252
497
  const offset = entry?.offset ?? { x: 0, y: 0 };
253
- const width = Math.max(textWidth + 8, 8);
254
- const anchor = a === b
255
- ? { x: first.x + SELF_WIDTH + 8 + width / 2, y: first.y + SELF_HEIGHT / 2 }
256
- : { x: first.x + (lastPoint.x - first.x) * at, y: arrowY - labelHeight / 2 - 1 };
257
- label = {
258
- box: {
259
- x: anchor.x - width / 2 + offset.x,
260
- y: anchor.y - labelHeight / 2 + offset.y,
261
- width,
262
- height: labelHeight,
263
- },
264
- 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,
265
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;
266
565
  }
267
- messages.push({
268
- key: event.key,
269
- relation,
270
- points,
271
- self: a === b,
272
- label,
273
- number: numbered ? number : null,
274
- });
275
- const xsOf = points.map((p) => p.x);
276
- extend(Math.min(...xsOf) - 8, Math.max(...xsOf, label ? label.box.x + label.box.width : -Infinity) + 8);
277
- last = { participants: new Set([a, b]), y: a === b ? points[3].y : arrowY };
278
- rows.set(event.key, { y: start, height: y - start });
279
- continue;
280
- }
281
- case "activate": {
282
- const top = last && last.participants.has(event.participant) ? last.y : y;
283
- const stack = stacks.get(event.participant) ?? [];
284
- const bar = {
285
- participant: event.participant,
286
- depth: stack.length,
287
- top,
288
- bottom: top,
289
- };
290
- stack.push(bar);
291
- stacks.set(event.participant, stack);
292
- bars.push(bar);
293
- rows.set(event.key, { y: top, height: 0 });
294
- continue;
295
- }
296
- case "deactivate": {
297
- const stack = stacks.get(event.participant) ?? [];
298
- const bar = stack.pop();
299
- const bottom = last && last.participants.has(event.participant) ? last.y : y;
300
- if (bar)
301
- bar.bottom = Math.max(bottom, bar.top + 12);
302
- rows.set(event.key, { y: bottom, height: 0 });
303
- continue;
304
- }
305
- case "create": {
306
- const next = list[index + 1];
307
- const relation = next?.kind === "message" ? diagram.relationsByKey.get(next.relation) : undefined;
308
- if (relation && relation.to === event.participant && relation.from !== relation.to) {
309
- 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;
310
583
  }
311
- else {
312
- const top = y + 4;
313
- heads.set(event.participant, headBoxAt(diagram.elementsById.get(event.participant), top));
314
- created.set(event.participant, top);
315
- lifelines.get(event.participant).top = top + headHeight;
316
- 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;
317
594
  }
318
- rows.set(event.key, { y: start, height: y - start });
319
- continue;
320
- }
321
- case "destroy": {
322
- const x = xs[column.get(event.participant)];
323
- const at = y + 8;
324
- crosses.push({ key: event.key, x, y: at });
325
- lifelines.get(event.participant).bottom = at;
326
- for (const bar of stacks.get(event.participant) ?? [])
327
- bar.bottom = Math.max(at, bar.top + 12);
328
- stacks.set(event.participant, []);
329
- y = at + rowGap + 4;
330
- rows.set(event.key, { y: start, height: y - start });
331
- last = undefined;
332
- continue;
333
- }
334
- case "note": {
335
- const size = noteSize(event);
336
- const indices = event.participants.map((p) => column.get(p)).sort((p, q) => p - q);
337
- const i = indices[0];
338
- const j = indices[indices.length - 1];
339
- const top = y + 4;
340
- let x;
341
- let width = size.width;
342
- if (event.position === "over") {
343
- const left = xs[i] - (i === j ? 0 : 24);
344
- const right = xs[j] + (i === j ? 0 : 24);
345
- width = Math.max(size.width, right - left);
346
- 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;
347
612
  }
348
- else if (event.position === "left") {
349
- 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;
350
625
  }
351
- else {
352
- 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;
353
653
  }
354
- const box = { x, y: top, width, height: size.height };
355
- notes.push({ key: event.key, event, box });
356
- extend(box.x - 8, box.x + box.width + 8);
357
- y = top + size.height + rowGap;
358
- rows.set(event.key, { y: start, height: y - start });
359
- last = undefined;
360
- continue;
361
- }
362
- case "fragment": {
363
- const top = y + 4;
364
- const headerHeight = Math.round(22 * f);
365
- const frame = { minX: Infinity, maxX: -Infinity };
366
- open.push(frame);
367
- const separators = [];
368
- y = top + headerHeight + 6;
369
- for (const [b, branch] of event.branches.entries()) {
370
- if (b > 0) {
371
- const sep = y + 2;
372
- separators.push({ y: sep, condition: branch.condition });
373
- 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);
374
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 });
375
684
  last = undefined;
376
- process(branch.events);
685
+ continue;
377
686
  }
378
- open.pop();
379
- const bottom = y + 4;
380
- const headerText = fragmentHeader(event, metrics, scale);
381
- if (!Number.isFinite(frame.minX)) {
382
- frame.minX = xs[0] - 24;
383
- 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;
384
700
  }
385
- const minX = frame.minX - 16;
386
- const maxX = Math.max(frame.maxX + 16, minX + headerText + 24);
387
- const box = { x: minX, y: top, width: maxX - minX, height: bottom - top };
388
- frames.push({ key: event.key, event, box, headerHeight, separators });
389
- extend(box.x - 8, box.x + box.width + 8);
390
- y = bottom + rowGap;
391
- rows.set(event.key, { y: start, height: y - start });
392
- last = undefined;
393
- continue;
394
- }
395
- case "divider": {
396
- dividers.push({ key: event.key, y: y + 12, label: event.label });
397
- y += 24 + rowGap;
398
- rows.set(event.key, { y: start, height: y - start });
399
- last = undefined;
400
- continue;
401
- }
402
- case "delay": {
403
- delays.push({ key: event.key, y: y + 14, label: event.label });
404
- y += 28 + rowGap;
405
- rows.set(event.key, { y: start, height: y - start });
406
- last = undefined;
407
- continue;
408
701
  }
409
702
  }
703
+ finally {
704
+ y += spacesAfter.get(baseKey(event.key)) ?? 0;
705
+ }
410
706
  }
411
707
  };
412
708
  process(events);
@@ -419,15 +715,35 @@ export function layoutSequence(input) {
419
715
  bar.bottom = Math.max(bar.bottom, end);
420
716
  for (const p of participants) {
421
717
  if (!heads.has(p.id))
422
- heads.set(p.id, headBoxAt(p, groupTitle));
718
+ heads.set(p.id, headBoxAt(p, headTop));
423
719
  const line = lifelines.get(p.id);
424
720
  if (line.bottom === null)
425
721
  line.bottom = end;
426
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
+ });
427
742
  const groupBoxes = new Map();
428
743
  for (const group of groups) {
429
744
  const members = participants.filter((p) => p.groupPath.includes(group.id));
430
- 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) ?? [])]);
431
747
  const union = unionBoxes(boxes);
432
748
  if (!union)
433
749
  continue;
@@ -439,12 +755,19 @@ export function layoutSequence(input) {
439
755
  });
440
756
  }
441
757
  // ---------------------------------------------------------------- canvas
442
- const left = Math.min(xs[0] - widths[0] / 2 - leftExtra, ...frames.map((fr) => fr.box.x));
443
- const right = Math.max(xs[xs.length - 1] + widths[widths.length - 1] / 2 + rightExtra, ...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] : [])));
758
+ // A diagram with no participant yet (a new one) has an empty width, not an unknown one.
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;
763
+ const left = Math.min(leftmost, ...frames.map((fr) => fr.box.x));
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] : [])));
444
765
  const everything = [
445
766
  { x: left, y: 0, width: right - left, height: end + 8 },
446
767
  ...groupBoxes.values(),
447
768
  ...heads.values(),
769
+ ...[...badges.values()].flat(),
770
+ ...[...tags.values()].flat(),
448
771
  ];
449
772
  const bounds = unionBoxes(everything);
450
773
  const margin = theme.margin;
@@ -474,7 +797,9 @@ export function layoutSequence(input) {
474
797
  items.push(emit.frame(frame));
475
798
  const headers = frames.map((frame) => emit.frameHeader(frame));
476
799
  for (const p of participants) {
477
- 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)]));
478
803
  }
479
804
  items.push(...headers);
480
805
  for (const message of messages)
@@ -488,7 +813,8 @@ export function layoutSequence(input) {
488
813
  for (const cross of crosses)
489
814
  items.push(emit.cross(cross));
490
815
  for (const p of participants) {
491
- items.push(emit.head(p, heads.get(p.id), boxHeight, figureHeight, labelLine));
816
+ const cell = heads.get(p.id);
817
+ items.push(emit.head(p, cell, drawnHead(p, cell), boxHeight, figureHeight, labelLine, badges.get(p.id) ?? []));
492
818
  }
493
819
  const gradients = [];
494
820
  if (framed) {
@@ -498,14 +824,43 @@ export function layoutSequence(input) {
498
824
  gradients.push(markGradient(theme.accent, theme.accentEnd));
499
825
  }
500
826
  const boxes = new Map();
501
- for (const [id, box] of heads)
502
- boxes.set(id, box);
827
+ // Box kinds are drawn as rounded boxes and databases as cylinders; figures have no outline.
828
+ const shapes = new Map();
829
+ const radii = new Map();
830
+ for (const p of participants) {
831
+ boxes.set(p.id, drawnHead(p, heads.get(p.id)));
832
+ if (!BOX_KINDS.has(p.kind))
833
+ continue;
834
+ if (p.kind === "database") {
835
+ shapes.set(p.id, "cylinder");
836
+ }
837
+ else {
838
+ shapes.set(p.id, "rounded");
839
+ radii.set(p.id, theme.radius);
840
+ }
841
+ }
503
842
  for (const [id, box] of groupBoxes)
504
843
  boxes.set(id, box);
505
844
  for (const note of notes)
506
845
  boxes.set(note.key, note.box);
507
846
  for (const frame of frames)
508
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
+ }
509
864
  const connectors = new Map();
510
865
  const labels = new Map();
511
866
  for (const message of messages) {
@@ -533,22 +888,63 @@ export function layoutSequence(input) {
533
888
  gradients: [...gradients, ...emit.gradients],
534
889
  patterns: [],
535
890
  items,
891
+ ...(theme.sketch !== "none" ? { sketch: theme.sketch } : {}),
536
892
  },
537
893
  diagnostics,
538
894
  boxes,
539
895
  offset: { x: dx, y: dy },
540
896
  labels,
897
+ messageTexts,
898
+ bars: barGeometry,
899
+ // The participants' badges, then the messages' pills.
900
+ tags: new Map([...badges, ...tags]),
541
901
  connectors,
542
902
  origins: new Map(),
543
903
  theme,
544
904
  events: rows,
905
+ shapes,
906
+ radii,
545
907
  };
546
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
+ }
547
942
  /** Width of a fragment's header: the operator tab and the first condition. */
548
- function fragmentHeader(event, metrics, scale) {
549
- 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;
550
946
  const condition = event.branches[0]?.condition;
551
- 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);
552
948
  }
553
949
  class SequenceEmitter {
554
950
  theme;
@@ -581,8 +977,8 @@ class SequenceEmitter {
581
977
  height: round2(box.height),
582
978
  };
583
979
  }
584
- text(x, y, text, size, weight, fill, anchor = "middle") {
585
- 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);
586
982
  }
587
983
  rect(box, radius, fill, stroke, width, dashed = false) {
588
984
  const b = this.box(box);
@@ -605,7 +1001,7 @@ class SequenceEmitter {
605
1001
  const style = this.styles.element(group);
606
1002
  const items = [
607
1003
  this.rect(box, theme.groupRadius, style.fill ?? theme.band, style.stroke ?? theme.bandLine, style.width ?? theme.strokeWidth, strokeDash(style.line)),
608
- 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)),
609
1005
  ];
610
1006
  return {
611
1007
  kind: "group",
@@ -616,30 +1012,48 @@ class SequenceEmitter {
616
1012
  ...(group.url ? { href: group.url } : {}),
617
1013
  };
618
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
+ */
619
1019
  lifeline(participant, line, bars, x) {
620
1020
  const { theme } = this;
621
1021
  const style = this.styles.element(participant);
1022
+ // Lifelines, bars, heads and notes take `muted`: the quiet `line` of boxes is too pale for the
1023
+ // long thin strokes a sequence diagram is made of (decision 0052). A lifeline is a guide, drawn
1024
+ // at half strength so the messages that cross it stay in front.
622
1025
  const items = [
623
- this.line([
624
- { x, y: line.top },
625
- { x, y: line.bottom ?? line.top },
626
- ], style.stroke ?? theme.line, theme.strokeWidth, true),
1026
+ {
1027
+ ...this.line([
1028
+ { x, y: line.top },
1029
+ { x, y: line.bottom ?? line.top },
1030
+ ], style.stroke ?? theme.muted, theme.strokeWidth, true),
1031
+ opacity: LIFELINE_OPACITY,
1032
+ },
627
1033
  ];
628
1034
  for (const bar of [...bars].sort((a, b) => a.depth - b.depth)) {
629
- items.push(this.rect({
630
- x: x - BAR / 2 + bar.depth * BAR_STEP,
631
- y: bar.top,
632
- width: BAR,
633
- height: bar.bottom - bar.top,
634
- }, 0, style.fill ?? theme.surface, style.stroke ?? theme.line, 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
+ });
635
1044
  }
636
1045
  return { kind: "group", id: participant.id, role: "lifeline", items };
637
1046
  }
638
- head(participant, head, boxHeight, figureHeight, labelLine) {
1047
+ /**
1048
+ * A participant's head in its row's cell `head`; `drawn` is the box it is drawn in, which the
1049
+ * group carries as its bounds. Its badges, the pills of its tags, are drawn over it.
1050
+ */
1051
+ head(participant, head, drawn, boxHeight, figureHeight, labelLine, badges) {
639
1052
  const { theme, scale } = this;
640
1053
  const style = this.styles.element(participant);
1054
+ const text = sequenceText(style, scale.label, scale.labelWeight);
641
1055
  const fill = style.fill ?? theme.surface;
642
- const stroke = style.stroke ?? theme.line;
1056
+ const stroke = style.stroke ?? theme.muted;
643
1057
  const strokeWidth = style.width ?? theme.strokeWidth;
644
1058
  const dashed = strokeDash(style.line);
645
1059
  const color = style.color ?? theme.ink;
@@ -658,7 +1072,7 @@ class SequenceEmitter {
658
1072
  items.push(this.path(body, fill, stroke, strokeWidth, dashed), this.path(top, fill, stroke, strokeWidth, dashed));
659
1073
  }
660
1074
  else if (participant.kind === "collections") {
661
- items.push(this.rect({ ...box, x: box.x + 4, y: box.y - 4 }, theme.radius, fill, stroke, strokeWidth, dashed));
1075
+ items.push(this.rect({ ...box, x: box.x + STACK, y: box.y - STACK }, theme.radius, fill, stroke, strokeWidth, dashed));
662
1076
  items.push(this.rect(box, theme.radius, fill, stroke, strokeWidth, dashed));
663
1077
  }
664
1078
  else if (participant.kind === "queue") {
@@ -674,7 +1088,7 @@ class SequenceEmitter {
674
1088
  }
675
1089
  // A queue's label is centred on the body, left of its right cap.
676
1090
  const capRoom = participant.kind === "queue" ? Math.min(QUEUE_CAP, box.width * 0.1) : 0;
677
- 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));
678
1092
  }
679
1093
  else {
680
1094
  const top = head.y + head.height - figureHeight - 6 - labelLine;
@@ -709,14 +1123,15 @@ class SequenceEmitter {
709
1123
  items.push(this.path(d, fill, figure, Math.max(strokeWidth, 1.5)));
710
1124
  }
711
1125
  }
712
- 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));
713
1127
  }
1128
+ items.push(...this.tags(participant.id, participant.tags, badges));
714
1129
  return {
715
1130
  kind: "group",
716
1131
  id: participant.id,
717
1132
  role: "element",
718
1133
  items,
719
- bounds: this.bounds(head, theme.radius),
1134
+ bounds: this.bounds(drawn, theme.radius),
720
1135
  ...(participant.url ? { href: participant.url } : {}),
721
1136
  };
722
1137
  }
@@ -727,11 +1142,8 @@ class SequenceEmitter {
727
1142
  const stroke = style.stroke ?? theme.connector;
728
1143
  const strokeWidth = style.width ?? theme.connectorWidth;
729
1144
  const dashed = strokeDash(style.line ?? relation.line);
730
- // Calls take a filled head, replies and async calls an open one; two-way messages take it at
731
- // both ends; styles replace either.
732
- const endKind = style.arrowhead ?? (relation.line === "dashed" || relation.async ? "open" : "triangle");
733
- const startKind = style.arrowtail ?? (relation.twoWay ? endKind : "none");
734
- 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);
735
1147
  const items = [this.line(arrow.line, stroke, strokeWidth, dashed)];
736
1148
  const local = (parts) => shiftMarker(parts, (q) => this.p(q));
737
1149
  items.push(...markerItems(local(arrow.start), stroke, strokeWidth), ...markerItems(local(arrow.end), stroke, strokeWidth));
@@ -740,17 +1152,18 @@ class SequenceEmitter {
740
1152
  items.push(this.rect({ x: start.x - 8, y: start.y - 8, width: 16, height: 16 }, 8, style.stroke ?? theme.ink, "none", 0));
741
1153
  items.push(this.text(start.x, start.y, String(message.number), Math.round(scale.relationLabel * 0.85), 700, theme.surface));
742
1154
  }
743
- if (message.label && message.label.text !== "") {
744
- 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;
745
1158
  items.push({
746
1159
  kind: "group",
747
1160
  id: message.key,
748
1161
  role: "label",
749
- items: [
750
- this.text(box.x + box.width / 2, box.y + box.height / 2, message.label.text, scale.relationLabel, 400, style.color ?? theme.ink),
751
- ],
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)),
752
1163
  });
753
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));
754
1167
  return {
755
1168
  kind: "group",
756
1169
  id: message.key,
@@ -759,6 +1172,23 @@ class SequenceEmitter {
759
1172
  track: arrow.line.map((q) => this.p(q)),
760
1173
  };
761
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
+ }
762
1192
  note(note) {
763
1193
  const { theme, scale } = this;
764
1194
  const style = note.event.id
@@ -770,10 +1200,11 @@ class SequenceEmitter {
770
1200
  const n = (v) => String(round2(v));
771
1201
  const d = `M${n(s.x)} ${n(s.y)} L${n(s.x + s.width - fold)} ${n(s.y)} L${n(s.x + s.width)} ${n(s.y + fold)} L${n(s.x + s.width)} ${n(s.y + s.height)} L${n(s.x)} ${n(s.y + s.height)} Z`;
772
1202
  const items = [
773
- this.path(d, style.fill ?? theme.surface, style.stroke ?? theme.line, style.width ?? theme.strokeWidth, strokeDash(style.line)),
1203
+ this.path(d, style.fill ?? theme.surface, style.stroke ?? theme.muted, style.width ?? theme.strokeWidth, strokeDash(style.line)),
774
1204
  ];
1205
+ const text = noteText(this.styles, scale, { id: note.event.id, key: note.key });
775
1206
  splitLines(note.event.text).forEach((line, i) => {
776
- 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));
777
1208
  });
778
1209
  return { kind: "group", id: note.key, role: "event", items, bounds: this.bounds(b, 0) };
779
1210
  }
@@ -803,28 +1234,30 @@ class SequenceEmitter {
803
1234
  const style = this.styles.event(event.id ?? frame.key, "fragment");
804
1235
  const b = frame.box;
805
1236
  const stroke = style.stroke ?? theme.muted;
806
- const size = scale.relationLabel;
1237
+ const written = fragmentText(this.styles, scale, event, frame.key);
1238
+ const size = written.size;
1239
+ const operatorWeight = written.weight;
807
1240
  const color = style.color ?? theme.ink;
808
1241
  const muted = style.color ?? theme.muted;
809
1242
  const first = event.branches[0]?.condition ?? null;
810
1243
  const h = frame.headerHeight;
811
1244
  const items = [];
812
1245
  const label = (x, y, text, weight, fill) => {
813
- const width = metrics.width(text, "body", weight, size) + 6;
1246
+ const width = metrics.width(text, written.font, weight, size) + 6;
814
1247
  items.push(this.rect({ x: x - 3, y: y - h / 2 + 4, width, height: h - 8 }, 2, theme.background, "none", 0));
815
- 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));
816
1249
  };
817
1250
  if (event.operator === "region") {
818
1251
  if (first)
819
- label(b.x + 8, b.y + h / 2, first, 600, color);
1252
+ label(b.x + 8, b.y + h / 2, first, operatorWeight, color);
820
1253
  }
821
1254
  else {
822
- const tabWidth = metrics.width(event.operator, "body", 600, size) + 18;
1255
+ const tabWidth = metrics.width(event.operator, written.font, operatorWeight, size) + 18;
823
1256
  const s = this.box(b);
824
1257
  const n = (v) => String(round2(v));
825
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`;
826
1259
  items.push(this.path(tab, theme.surface, stroke, style.width ?? theme.strokeWidth));
827
- 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));
828
1261
  if (first)
829
1262
  label(b.x + tabWidth + 8, b.y + h / 2, `[${first}]`, 400, muted);
830
1263
  }