dsh-model-everything 0.5.0 → 0.5.2

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 (65) hide show
  1. package/README.en-US.md +12 -0
  2. package/README.md +4 -0
  3. package/lib/client.js +2829 -224
  4. package/lib/render/channels.js +243 -0
  5. package/lib/render/collapse.js +12 -20
  6. package/lib/render/components.js +99 -0
  7. package/lib/render/coords.js +288 -0
  8. package/lib/render/cycle-break.js +147 -0
  9. package/lib/render/dummy.js +43 -0
  10. package/lib/render/from-mermaid.js +34 -10
  11. package/lib/render/from-plantuml.js +318 -46
  12. package/lib/render/layer.js +91 -0
  13. package/lib/render/layout.js +27 -11
  14. package/lib/render/network-simplex.js +272 -0
  15. package/lib/render/order.js +122 -0
  16. package/lib/render/paint.js +1227 -69
  17. package/lib/render/place.js +393 -121
  18. package/lib/render/route.js +225 -0
  19. package/lib/render/svg.js +146 -21
  20. package/lib/render/symbol.js +108 -0
  21. package/lib/render/text.js +74 -0
  22. package/lib/render/trunk.js +213 -0
  23. package/lib/render/wrap.js +87 -0
  24. package/lib/types/render/channels.d.ts +63 -0
  25. package/lib/types/render/components.d.ts +57 -0
  26. package/lib/types/render/coords.d.ts +64 -0
  27. package/lib/types/render/cycle-break.d.ts +45 -0
  28. package/lib/types/render/dummy.d.ts +32 -0
  29. package/lib/types/render/from-plantuml.d.ts +12 -6
  30. package/lib/types/render/layer.d.ts +28 -0
  31. package/lib/types/render/layout.d.ts +43 -11
  32. package/lib/types/render/model.d.ts +17 -0
  33. package/lib/types/render/network-simplex.d.ts +54 -0
  34. package/lib/types/render/order.d.ts +27 -0
  35. package/lib/types/render/paint.d.ts +157 -10
  36. package/lib/types/render/place.d.ts +40 -19
  37. package/lib/types/render/route.d.ts +77 -0
  38. package/lib/types/render/svg.d.ts +1 -1
  39. package/lib/types/render/symbol.d.ts +73 -0
  40. package/lib/types/render/text.d.ts +38 -0
  41. package/lib/types/render/trunk.d.ts +104 -0
  42. package/lib/types/render/wrap.d.ts +27 -0
  43. package/package.json +148 -148
  44. package/skills/model-everything-diagram/SKILL.md +85 -82
  45. package/src/render/channels.ts +309 -0
  46. package/src/render/collapse.ts +150 -155
  47. package/src/render/components.ts +126 -0
  48. package/src/render/coords.ts +328 -0
  49. package/src/render/cycle-break.ts +164 -0
  50. package/src/render/dummy.ts +65 -0
  51. package/src/render/from-mermaid.ts +261 -236
  52. package/src/render/from-plantuml.ts +469 -178
  53. package/src/render/layer.ts +98 -0
  54. package/src/render/layout.ts +101 -62
  55. package/src/render/model.ts +86 -67
  56. package/src/render/network-simplex.ts +290 -0
  57. package/src/render/order.ts +139 -0
  58. package/src/render/paint.ts +1570 -290
  59. package/src/render/place.ts +532 -234
  60. package/src/render/route.ts +269 -0
  61. package/src/render/svg.ts +274 -127
  62. package/src/render/symbol.ts +137 -0
  63. package/src/render/text.ts +86 -0
  64. package/src/render/trunk.ts +275 -0
  65. package/src/render/wrap.ts +103 -0
@@ -1,68 +1,55 @@
1
1
  /**
2
2
  * Where the boxes go.
3
3
  *
4
- * Two placements, chosen by the source:
4
+ * The drawing is placed by the layered pipeline, in the order the mature implementations run it: cycle breaking turns
5
+ * the graph into a DAG, layering gives every box a row, dummy nodes give every long edge a lane, ordering reduces the
6
+ * crossings, and coordinate assignment decides the x of each lane and box. This module composes those stages; it does
7
+ * not decide any of them itself. Each stage is a pure module next door, so it can be asserted without a canvas.
5
8
  *
6
- * - **layered** (no groups): breadth-first rows, centred, no containment - the shape a state machine wants.
7
- * - **tree** (groups present): a group is a block, its members stack inside it, a nested group is a block inside
8
- * its parent, and the top level stacks - the shape an architecture diagram wants, because a package whose
9
- * members float somewhere else on the page is not showing containment at all.
9
+ * Two decompositions sit around the pipeline. A level - the top of the drawing, or the inside of a boundary - is
10
+ * split into weakly connected components, each laid out on its own and then packed onto the page, so a wide
11
+ * subgraph cannot squeeze an unrelated one. A boundary is a node in its parent's layer whose size is whatever its
12
+ * contents need, and its inside is laid out by the same pipeline; that is how nesting is drawn.
10
13
  *
11
- * Split out of the painter so the two questions stay separate: this module decides positions, `paint.ts` decides
12
- * what to draw between them. A change to edge routing cannot move a box, and a change to nesting cannot move a
13
- * line.
14
+ * What comes out is geometry only: a box per node, a rectangle per boundary, and - for a long edge - the lane its
15
+ * dummies reserved, so the painter can draw one polyline through it instead of searching around the boxes.
14
16
  */
15
- import { rankStates } from './layout.js';
16
- /** Geometry constants shared by both placements. */
17
+ import { aspectOf, packRectangles, weakComponents } from './components.js';
18
+ import { assignChannels } from './channels.js';
19
+ import { assignCoordinates } from './coords.js';
20
+ import { breakCycles } from './cycle-break.js';
21
+ import { addDummies } from './dummy.js';
22
+ import { layer } from './layer.js';
23
+ import { seedOrder } from './layout.js';
24
+ import { order } from './order.js';
25
+ import { measure, MIN_NODE_WIDTH } from './text.js';
26
+ import { planBands } from './wrap.js';
27
+ /** Geometry constants in one place: a container with different proportions changes these, not the rules. */
17
28
  export const MARGIN = 20;
18
- const NODE_HEIGHT = 34;
19
- const ROW_GAP = 58;
20
- const COLUMN_GAP = 30;
21
- const MIN_NODE_WIDTH = 88;
22
- const MAX_NODE_WIDTH = 220;
23
- const CHAR_WIDTH = 7.2;
29
+ /** The strip between two layers: room for a route and, when the level has labels, for one to sit in. */
30
+ const LAYER_GAP = 44;
31
+ const LABELLED_LAYER_GAP = 54;
32
+ /** The space between two boxes in one layer. */
33
+ const NODE_GAP = 30;
34
+ /** How far a lane keeps from a box in a row it crosses, and the least two lanes in one gap may share. */
35
+ const LANE_CLEARANCE = 12;
36
+ const LANE_SPACING = 14;
37
+ /** Two components are two drawings on one page, so they get a wider gap than two boxes. */
38
+ const COMPONENT_GAP = 30;
24
39
  /** A boundary's inner padding and the strip its name sits in. */
25
40
  const GROUP_PADDING = 10;
26
41
  const GROUP_HEADER = 16;
27
- /** Between two blocks stacked inside a boundary, and between two top-level blocks. */
28
- const BLOCK_GAP = 14;
29
- /** A box wide enough for its label, clamped so a long id cannot dominate the page. */
30
- export const nodeWidth = (label) => Math.min(MAX_NODE_WIDTH, Math.max(MIN_NODE_WIDTH, label.length * CHAR_WIDTH + 26));
31
- /** The layered placement: breadth-first rows, centred, no containment. */
32
- function placeByRanks(diagram, nodes, top) {
33
- const ranked = rankStates(diagram.nodes, diagram.edges, diagram.init);
34
- const rows = [...ranked.rows];
35
- if (ranked.unreachable.length > 0)
36
- rows.push(ranked.unreachable);
37
- const boxes = new Map();
38
- let widest = 0;
39
- rows.forEach((row, index) => {
40
- const widths = row.map((id) => nodeWidth(id));
41
- widest = Math.max(widest, widths.reduce((total, width) => total + width, 0) + Math.max(0, row.length - 1) * COLUMN_GAP);
42
- let x = 0;
43
- row.forEach((id, position) => {
44
- boxes.set(id, { x, y: top + index * (NODE_HEIGHT + ROW_GAP), width: widths[position], height: NODE_HEIGHT });
45
- x += widths[position] + COLUMN_GAP;
46
- });
47
- });
48
- rows.forEach((row) => {
49
- const rowWidth = row.reduce((total, id) => total + boxes.get(id).width, 0) + Math.max(0, row.length - 1) * COLUMN_GAP;
50
- const offset = (widest - rowWidth) / 2;
51
- for (const id of row)
52
- boxes.get(id).x += offset + MARGIN;
53
- });
54
- return { boxes, groupRects: new Map(), width: widest + MARGIN * 2 };
42
+ /** The text a node will draw, measured from its label - the id is what the source called it, not what it says. */
43
+ export function textOf(node) {
44
+ return measure(node.label !== undefined && node.label !== '' ? node.label : node.id);
55
45
  }
56
46
  /**
57
- * Which group each id belongs to, and which group contains which.
58
- *
59
- * Membership is read from `members`; containment is read from `parent`, because only the parser knows where a
60
- * boundary was written. A model assembled by hand may omit `parent`, and then containment is inferred from
61
- * member sets - the tightest superset wins - which is the best such a model can offer.
47
+ * Read membership and containment off the model, which is data and can be wrong.
62
48
  *
63
- * Guards, because a model is data and data can be wrong: a parent that does not exist is ignored, a group that
64
- * contains itself or closes a cycle is treated as a root, and a member claimed by two groups belongs to the
65
- * tightest one, so a nested boundary is not drawn around nothing.
49
+ * Membership is read from `members`; containment from `parent`, because only the parser knows where a boundary was
50
+ * written - a hand-written model may omit it, and then the tightest member-set that contains this one is the best
51
+ * guess available. Guards, because a model is data: a parent that does not exist is ignored, a group that contains
52
+ * itself or closes a cycle is treated as a root, and a member claimed by two groups belongs to the tightest one.
66
53
  */
67
54
  function groupTree(groups) {
68
55
  const byId = new Map(groups.map((group) => [group.id, group]));
@@ -129,82 +116,367 @@ function groupTree(groups) {
129
116
  else
130
117
  children.set(parent, [...(children.get(parent) ?? []), group.id]);
131
118
  }
132
- return { owner, children, roots };
119
+ return { owner, parentOf, children, roots };
133
120
  }
134
- /** The tree placement: a boundary is a block, its contents stack inside it, the top level stacks. */
135
- function placeByGroups(diagram, nodes, top) {
136
- const groups = diagram.groups;
137
- const byId = new Map(groups.map((group) => [group.id, group]));
138
- const { owner, children, roots } = groupTree(groups);
139
- // Nodes that belong to no group, and the first-seen order that keeps a source's own ordering visible.
140
- const order = new Map(nodes.map((node, index) => [node.id, index]));
141
- const loose = nodes.filter((node) => !owner.has(node.id)).map((node) => node.id);
142
- const nodeIds = new Set(nodes.map((node) => node.id));
143
- const rawContents = (groupId) => {
144
- const group = byId.get(groupId);
145
- const own = [...new Set(group.members)].filter((member) => owner.get(member) === groupId && nodeIds.has(member));
146
- const nested = children.get(groupId) ?? [];
147
- return [...own, ...nested];
121
+ export function place(diagram, top) {
122
+ const groups = diagram.groups ?? [];
123
+ const nodes = [];
124
+ const seenNode = new Set();
125
+ for (const node of diagram.nodes) {
126
+ if (seenNode.has(node.id))
127
+ continue;
128
+ seenNode.add(node.id);
129
+ nodes.push(node);
130
+ }
131
+ const nodeById = new Map(nodes.map((node) => [node.id, node]));
132
+ const groupById = new Map(groups.map((group) => [group.id, group]));
133
+ const tree = groupTree(groups);
134
+ // The order the source wrote things in: the fallback order wherever connectivity says nothing.
135
+ const appearance = new Map();
136
+ for (const node of nodes)
137
+ if (!appearance.has(node.id))
138
+ appearance.set(node.id, appearance.size);
139
+ for (const group of groups)
140
+ if (!appearance.has(group.id))
141
+ appearance.set(group.id, appearance.size);
142
+ const visible = new Set();
143
+ const asked = new Map();
144
+ const isVisible = (id) => {
145
+ const known = asked.get(id);
146
+ if (known !== undefined)
147
+ return known;
148
+ const group = groupById.get(id);
149
+ let found = false;
150
+ for (const member of group.members) {
151
+ if (tree.owner.get(member) === id && nodeById.has(member)) {
152
+ found = true;
153
+ break;
154
+ }
155
+ }
156
+ if (!found) {
157
+ for (const child of tree.children.get(id) ?? []) {
158
+ if (isVisible(child)) {
159
+ found = true;
160
+ break;
161
+ }
162
+ }
163
+ }
164
+ asked.set(id, found);
165
+ if (found)
166
+ visible.add(id);
167
+ return found;
148
168
  };
149
- // A boundary with nothing inside it is not drawn: an empty box claims a containment the source never stated.
150
- const contents = (groupId) => rawContents(groupId).filter((child) => !byId.has(child) || rawContents(child).length > 0);
151
- const measure = (id) => {
152
- if (!byId.has(id))
153
- return { width: nodeWidth(id), height: NODE_HEIGHT };
154
- const items = contents(id);
155
- const sizes = items.map(measure);
156
- return {
157
- width: Math.max(MIN_NODE_WIDTH, ...sizes.map((size) => size.width)) + GROUP_PADDING * 2,
158
- height: GROUP_HEADER + GROUP_PADDING * 2 + sizes.reduce((total, size) => total + size.height, 0) + Math.max(0, items.length - 1) * BLOCK_GAP,
159
- };
169
+ // Every group is asked, not only the roots: a nested group's visibility is what tells its parent it has contents.
170
+ for (const group of groups)
171
+ isVisible(group.id);
172
+ const directNodes = (scope) => nodes.filter((node) => tree.owner.get(node.id) === scope).map((node) => node.id);
173
+ const directGroups = (scope) => (tree.children.get(scope) ?? []).filter((child) => visible.has(child));
174
+ const firstIndexCache = new Map();
175
+ const firstIndexOf = (id) => {
176
+ const cached = firstIndexCache.get(id);
177
+ if (cached !== undefined)
178
+ return cached;
179
+ if (nodeById.has(id))
180
+ return appearance.get(id);
181
+ const members = groupById.get(id).members
182
+ .filter((member) => tree.owner.get(member) === id && nodeById.has(member))
183
+ .map((member) => appearance.get(member));
184
+ const children = directGroups(id).map(firstIndexOf);
185
+ const all = [...members, ...children];
186
+ const found = Math.min(...all);
187
+ firstIndexCache.set(id, found);
188
+ return found;
160
189
  };
161
- const boxes = new Map();
162
- const groupRects = new Map();
163
- const place = (id, x, y) => {
164
- const size = measure(id);
165
- if (!byId.has(id)) {
166
- boxes.set(id, { x, y, width: size.width, height: NODE_HEIGHT });
167
- return size;
168
- }
169
- groupRects.set(id, { x, y, width: size.width, height: size.height });
170
- let cursor = y + GROUP_HEADER + GROUP_PADDING;
171
- const inner = size.width - GROUP_PADDING * 2;
172
- const ordered = [...contents(id)].sort((left, right) => firstIndex(left) - firstIndex(right));
173
- for (const child of ordered) {
174
- const childSize = measure(child);
175
- place(child, x + GROUP_PADDING + (inner - childSize.width) / 2, cursor);
176
- cursor += childSize.height + BLOCK_GAP;
190
+ /** Which group or node a diagram node is drawn as, at one level; `undefined` when it is outside that level. */
191
+ const entityAt = (scope, id) => {
192
+ // An edge may name a boundary instead of a box: it attaches to the boundary's own rectangle. A group is
193
+ // an entity at the level it is drawn in, and a nested group belongs to its outermost drawn ancestor there.
194
+ const boundary = groupById.get(id);
195
+ if (boundary !== undefined) {
196
+ if (!visible.has(id))
197
+ return undefined;
198
+ if (scope === undefined) {
199
+ let cursor = id;
200
+ while (tree.parentOf.has(cursor))
201
+ cursor = tree.parentOf.get(cursor);
202
+ return cursor;
203
+ }
204
+ return tree.parentOf.get(id) === scope ? id : undefined;
205
+ }
206
+ if (!nodeById.has(id))
207
+ return undefined;
208
+ const group = tree.owner.get(id);
209
+ if (scope === undefined) {
210
+ if (group === undefined || !visible.has(group))
211
+ return id;
212
+ let cursor = group;
213
+ while (tree.parentOf.has(cursor))
214
+ cursor = tree.parentOf.get(cursor);
215
+ return cursor;
177
216
  }
217
+ if (group === scope)
218
+ return id;
219
+ if (group === undefined)
220
+ return undefined;
221
+ return tree.parentOf.get(group) === scope && visible.has(group) ? group : undefined;
222
+ };
223
+ const groupLayouts = new Map();
224
+ const measureGroup = (id) => {
225
+ const inside = buildLevel(id);
226
+ const size = {
227
+ width: Math.max(MIN_NODE_WIDTH, inside.width) + GROUP_PADDING * 2,
228
+ height: inside.height + GROUP_HEADER + GROUP_PADDING * 2,
229
+ };
230
+ groupLayouts.set(id, inside);
178
231
  return size;
179
232
  };
180
- // Top level: loose nodes and root groups, in the order the source named them.
181
- const visibleRoots = roots.filter((id) => contents(id).length > 0);
182
- // A block takes the earliest position of anything inside it, so a group is drawn where its first member was
183
- // named - not after every loose node, which put a downstream box above the packages it depends on.
184
- const firstIndex = (id) => {
185
- // `order` holds every node, and a block is only ever asked about when it has contents.
186
- if (!byId.has(id))
187
- return order.get(id);
188
- return Math.min(...contents(id).map(firstIndex));
233
+ const entityOf = (id) => {
234
+ const node = nodeById.get(id);
235
+ if (node !== undefined) {
236
+ const text = textOf(node);
237
+ return { id, group: false, width: text.width, height: text.height };
238
+ }
239
+ const size = measureGroup(id);
240
+ return { id, group: true, width: size.width, height: size.height };
241
+ };
242
+ const entitiesIn = (scope) => {
243
+ const found = [];
244
+ if (scope === undefined) {
245
+ for (const node of nodes) {
246
+ const group = tree.owner.get(node.id);
247
+ if (group === undefined || !visible.has(group))
248
+ found.push(entityOf(node.id));
249
+ }
250
+ for (const root of tree.roots)
251
+ if (visible.has(root))
252
+ found.push(entityOf(root));
253
+ }
254
+ else {
255
+ for (const id of directNodes(scope))
256
+ found.push(entityOf(id));
257
+ for (const id of directGroups(scope))
258
+ found.push(entityOf(id));
259
+ }
260
+ return found.sort((left, right) => firstIndexOf(left.id) - firstIndexOf(right.id));
261
+ };
262
+ const edgesIn = (scope) => {
263
+ const found = [];
264
+ diagram.edges.forEach((edge, index) => {
265
+ if (edge.from === edge.to)
266
+ return;
267
+ const from = entityAt(scope, edge.from);
268
+ const to = entityAt(scope, edge.to);
269
+ if (from === undefined || to === undefined || from === to)
270
+ return;
271
+ found.push({ index, from, to });
272
+ });
273
+ return found;
274
+ };
275
+ /** The entity that holds the initial state at this level, which is where layer zero is pinned. */
276
+ const pinnedAt = (scope) => {
277
+ if (diagram.init === undefined)
278
+ return undefined;
279
+ const entity = entityAt(scope, diagram.init);
280
+ return entity === diagram.init || (entity !== undefined && groupById.has(entity)) ? entity : undefined;
189
281
  };
190
- const topItems = [...loose, ...visibleRoots].sort((left, right) => firstIndex(left) - firstIndex(right));
191
- const sizes = topItems.map(measure);
192
- const width = Math.max(MIN_NODE_WIDTH, ...sizes.map((size) => size.width));
193
- let cursor = top;
194
- for (const item of topItems) {
195
- const size = measure(item);
196
- place(item, MARGIN + (width - size.width) / 2, cursor);
197
- cursor += size.height + BLOCK_GAP;
282
+ /**
283
+ * Lay out one weakly connected component: the pipeline, unshortened.
284
+ *
285
+ * A component has its own layers, its own ordering and its own coordinates. Nothing outside it can move it.
286
+ */
287
+ const layoutPiece = (entities, edges, pinned, seed) => {
288
+ const byId = new Map(entities.map((entity) => [entity.id, entity]));
289
+ const graph = {
290
+ nodes: entities.map((entity) => ({ id: entity.id, width: entity.width, height: entity.height })),
291
+ edges,
292
+ ...(pinned === undefined ? {} : { init: pinned }),
293
+ };
294
+ const broken = breakCycles(graph);
295
+ const layered = layer(graph, broken.edges);
296
+ const augmented = addDummies(layered, broken.edges);
297
+ const ordered = order(augmented.layers, augmented.chains, seed);
298
+ const gap = edges.some((edge) => diagram.edges[edge.index].label !== '') ? LABELLED_LAYER_GAP : LAYER_GAP;
299
+ const coordinates = assignCoordinates({
300
+ layers: ordered.layers,
301
+ chains: augmented.chains,
302
+ sizeOf: (id) => {
303
+ const entity = byId.get(id);
304
+ return { width: entity.width, height: entity.height };
305
+ },
306
+ chainOf: augmented.chainOf,
307
+ }, { gap: NODE_GAP, layerGap: gap });
308
+ const bands = planBands(coordinates.layerWidth, coordinates.layerHeight, gap);
309
+ const bandOfLayer = new Array(coordinates.layerHeight.length).fill(0);
310
+ bands.bands.forEach((band, index) => {
311
+ for (let depth = band.first; depth <= band.last; depth += 1)
312
+ bandOfLayer[depth] = index;
313
+ });
314
+ const layerTop = [];
315
+ for (const band of bands.bands) {
316
+ let y = 0;
317
+ for (let depth = band.first; depth <= band.last; depth += 1) {
318
+ layerTop[depth] = y;
319
+ y += coordinates.layerHeight[depth] + gap;
320
+ }
321
+ }
322
+ const items = new Map();
323
+ ordered.layers.forEach((row, depth) => {
324
+ const shift = bands.bands[bandOfLayer[depth]].offsetX;
325
+ const top = layerTop[depth];
326
+ const rowHeight = coordinates.layerHeight[depth];
327
+ for (const id of row) {
328
+ const entity = byId.get(id);
329
+ if (entity === undefined)
330
+ continue;
331
+ const centre = coordinates.x.get(id);
332
+ items.set(id, {
333
+ box: {
334
+ x: centre + shift - entity.width / 2,
335
+ y: top + (rowHeight - entity.height) / 2,
336
+ width: entity.width,
337
+ height: entity.height,
338
+ },
339
+ group: entity.group,
340
+ });
341
+ }
342
+ });
343
+ // An edge whose ends sit in two different columns is read across the page, not down it: the painter draws it
344
+ // out of the side of one box and into the side of the other, which is where a wrapped reading order turns.
345
+ const across = new Set();
346
+ for (const edge of edges) {
347
+ const from = layered.layerOf.get(edge.from);
348
+ const to = layered.layerOf.get(edge.to);
349
+ if (bandOfLayer[from] !== bandOfLayer[to])
350
+ across.add(edge.index);
351
+ }
352
+ // The channels: where each long edge's lane runs and where the runs between two rows sit. A lane is only worth
353
+ // reserving when both ends are boxes - a lane that ends on a boundary has no box to arrive at - and one that
354
+ // crosses a band boundary is two lanes with different column offsets. Those edges get the router instead.
355
+ const laneProposal = new Map();
356
+ for (const chain of augmented.chains) {
357
+ if (chain.path.length <= 2)
358
+ continue;
359
+ const lane = augmented.chainOf.get(chain.path[1]);
360
+ const fromLayer = layered.layerOf.get(chain.path[0]);
361
+ const proposed = coordinates.x.get(lane);
362
+ laneProposal.set(lane, proposed + bands.bands[bandOfLayer[fromLayer]].offsetX);
363
+ }
364
+ const itemBoxes = new Map();
365
+ for (const [id, item] of items)
366
+ itemBoxes.set(id, item.box);
367
+ const channels = assignChannels({
368
+ layers: ordered.layers,
369
+ chains: augmented.chains,
370
+ chainOf: augmented.chainOf,
371
+ boxes: itemBoxes,
372
+ layerY: layerTop,
373
+ layerHeight: coordinates.layerHeight,
374
+ laneX: laneProposal,
375
+ }, { clearance: LANE_CLEARANCE, spacing: LANE_SPACING });
376
+ const lanes = new Map();
377
+ for (const chain of augmented.chains) {
378
+ if (chain.path.length <= 2)
379
+ continue;
380
+ const start = byId.get(chain.path[0]);
381
+ const end = byId.get(chain.path[chain.path.length - 1]);
382
+ if (start.group || end.group)
383
+ continue;
384
+ const fromLayer = layered.layerOf.get(chain.path[0]);
385
+ const toLayer = layered.layerOf.get(chain.path[chain.path.length - 1]);
386
+ if (bandOfLayer[fromLayer] !== bandOfLayer[toLayer])
387
+ continue;
388
+ const channel = channels.lanes.get(chain.index);
389
+ lanes.set(chain.index, {
390
+ points: channel.y.map((y) => ({ x: channel.x, y })),
391
+ reversed: broken.reversed.has(chain.index),
392
+ });
393
+ }
394
+ const runs = new Map();
395
+ for (const [edgeIndex, y] of channels.runs)
396
+ if (!across.has(edgeIndex))
397
+ runs.set(edgeIndex, y);
398
+ let right = 0;
399
+ let bottom = 0;
400
+ for (const entry of items.values()) {
401
+ right = Math.max(right, entry.box.x + entry.box.width);
402
+ bottom = Math.max(bottom, entry.box.y + entry.box.height);
403
+ }
404
+ return { width: right, height: bottom, items, lanes, runs, across };
405
+ };
406
+ function buildLevel(scope) {
407
+ const entities = entitiesIn(scope);
408
+ if (entities.length === 0) {
409
+ return { width: 0, height: 0, items: new Map(), lanes: new Map(), runs: new Map(), across: new Set() };
410
+ }
411
+ const edges = edgesIn(scope);
412
+ const pinned = pinnedAt(scope);
413
+ const seed = seedOrder({ nodes: entities.map((entity) => ({ id: entity.id, width: entity.width, height: entity.height })), edges, ...(pinned === undefined ? {} : { init: pinned }) });
414
+ const components = weakComponents(entities, edges);
415
+ const pieces = [];
416
+ for (const members of components) {
417
+ const inside = new Set(members);
418
+ const pieceEntities = entities.filter((entity) => inside.has(entity.id));
419
+ const pieceEdges = edges.filter((edge) => inside.has(edge.from) && inside.has(edge.to));
420
+ const pin = pinned !== undefined && inside.has(pinned) ? pinned : undefined;
421
+ pieces.push(layoutPiece(pieceEntities, pieceEdges, pin, seed));
422
+ }
423
+ const packed = packRectangles(pieces.map((piece) => ({ width: piece.width, height: piece.height })), { gap: COMPONENT_GAP });
424
+ const layout = { width: packed.width, height: packed.height, items: new Map(), lanes: new Map(), runs: new Map(), across: new Set() };
425
+ pieces.forEach((piece, index) => {
426
+ for (const edgeIndex of piece.across)
427
+ layout.across.add(edgeIndex);
428
+ const at = packed.placements[index];
429
+ for (const [id, item] of piece.items) {
430
+ layout.items.set(id, { box: { x: item.box.x + at.x, y: item.box.y + at.y, width: item.box.width, height: item.box.height }, group: item.group });
431
+ }
432
+ for (const [edgeIndex, lane] of piece.lanes) {
433
+ layout.lanes.set(edgeIndex, { points: lane.points.map((point) => ({ x: point.x + at.x, y: point.y + at.y })), reversed: lane.reversed });
434
+ }
435
+ for (const [edgeIndex, y] of piece.runs)
436
+ layout.runs.set(edgeIndex, y + at.y);
437
+ });
438
+ return layout;
198
439
  }
199
- return { boxes, groupRects, width: width + MARGIN * 2 };
440
+ const root = buildLevel(undefined);
441
+ const boxes = new Map();
442
+ const groupRects = new Map();
443
+ const lanes = new Map();
444
+ const runs = new Map();
445
+ const across = new Set();
446
+ const placeLevel = (layout, originX, originY) => {
447
+ for (const edgeIndex of layout.across)
448
+ across.add(edgeIndex);
449
+ for (const [id, item] of layout.items) {
450
+ const box = { x: originX + item.box.x, y: originY + item.box.y, width: item.box.width, height: item.box.height };
451
+ if (item.group) {
452
+ groupRects.set(id, box);
453
+ const inside = groupLayouts.get(id);
454
+ placeLevel(inside, box.x + (box.width - inside.width) / 2, box.y + GROUP_HEADER + GROUP_PADDING);
455
+ continue;
456
+ }
457
+ boxes.set(id, box);
458
+ }
459
+ for (const [edgeIndex, lane] of layout.lanes) {
460
+ lanes.set(edgeIndex, { points: lane.points.map((point) => ({ x: point.x + originX, y: point.y + originY })), reversed: lane.reversed });
461
+ }
462
+ for (const [edgeIndex, y] of layout.runs)
463
+ runs.set(edgeIndex, y + originY);
464
+ };
465
+ placeLevel(root, MARGIN, top);
466
+ let right = MARGIN;
467
+ for (const box of boxes.values())
468
+ right = Math.max(right, box.x + box.width);
469
+ for (const box of groupRects.values())
470
+ right = Math.max(right, box.x + box.width);
471
+ return { boxes, groupRects, width: right + MARGIN, lanes, runs, across };
200
472
  }
201
- /**
202
- * Place every box of a diagram.
203
- *
204
- * The choice of placement is a property of the source, not of the caller: a drawing that declares boundaries is
205
- * read as containers and gets the tree; one that does not is read as a machine and gets the ranks.
206
- */
207
- export function place(diagram, top) {
208
- const groups = diagram.groups ?? [];
209
- return groups.length > 0 ? placeByGroups(diagram, diagram.nodes, top) : placeByRanks(diagram, diagram.nodes, top);
473
+ /** The aspect ratio of a placement, for the tests that check a drawing is a page and not a strip. */
474
+ export function placementAspect(placement) {
475
+ let right = 0;
476
+ let bottom = 0;
477
+ for (const box of [...placement.boxes.values(), ...placement.groupRects.values()]) {
478
+ right = Math.max(right, box.x + box.width);
479
+ bottom = Math.max(bottom, box.y + box.height);
480
+ }
481
+ return aspectOf(right, bottom);
210
482
  }