@dxos/diagram 0.11.1 → 0.14.0

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 (171) hide show
  1. package/dist/lib/chunk-parse.mjs +9406 -0
  2. package/dist/lib/chunk-parse.mjs.map +1 -0
  3. package/dist/lib/extension.mjs +204 -0
  4. package/dist/lib/extension.mjs.map +1 -0
  5. package/dist/lib/index.mjs +497 -2846
  6. package/dist/lib/index.mjs.map +1 -1
  7. package/dist/types/src/aesthetics.d.ts +12 -0
  8. package/dist/types/src/aesthetics.d.ts.map +1 -0
  9. package/dist/types/src/aesthetics.test.d.ts +2 -0
  10. package/dist/types/src/aesthetics.test.d.ts.map +1 -0
  11. package/dist/types/src/appeal.d.ts +55 -0
  12. package/dist/types/src/appeal.d.ts.map +1 -0
  13. package/dist/types/src/appeal.test.d.ts +2 -0
  14. package/dist/types/src/appeal.test.d.ts.map +1 -0
  15. package/dist/types/src/architecture.d.ts +86 -0
  16. package/dist/types/src/architecture.d.ts.map +1 -0
  17. package/dist/types/src/architecture.test.d.ts +2 -0
  18. package/dist/types/src/architecture.test.d.ts.map +1 -0
  19. package/dist/types/src/compact.d.ts +39 -0
  20. package/dist/types/src/compact.d.ts.map +1 -0
  21. package/dist/types/src/compact.test.d.ts +2 -0
  22. package/dist/types/src/compact.test.d.ts.map +1 -0
  23. package/dist/types/src/diagnostics.d.ts +70 -3
  24. package/dist/types/src/diagnostics.d.ts.map +1 -1
  25. package/dist/types/src/dsl/cli.d.ts +2 -0
  26. package/dist/types/src/dsl/cli.d.ts.map +1 -0
  27. package/dist/types/src/dsl/compile.d.ts +12 -0
  28. package/dist/types/src/dsl/compile.d.ts.map +1 -0
  29. package/dist/types/src/dsl/convert.d.ts +45 -0
  30. package/dist/types/src/dsl/convert.d.ts.map +1 -0
  31. package/dist/types/src/dsl/convert.test.d.ts +2 -0
  32. package/dist/types/src/dsl/convert.test.d.ts.map +1 -0
  33. package/dist/types/src/dsl/dsl.test.d.ts +2 -0
  34. package/dist/types/src/dsl/dsl.test.d.ts.map +1 -0
  35. package/dist/types/src/dsl/gen/diagram.d.ts +3 -0
  36. package/dist/types/src/dsl/gen/diagram.d.ts.map +1 -0
  37. package/dist/types/src/dsl/index.d.ts +7 -0
  38. package/dist/types/src/dsl/index.d.ts.map +1 -0
  39. package/dist/types/src/dsl/parse.d.ts +49 -0
  40. package/dist/types/src/dsl/parse.d.ts.map +1 -0
  41. package/dist/types/src/dsl/print.d.ts +19 -0
  42. package/dist/types/src/dsl/print.d.ts.map +1 -0
  43. package/dist/types/src/dsl/semantic.test.d.ts +2 -0
  44. package/dist/types/src/dsl/semantic.test.d.ts.map +1 -0
  45. package/dist/types/src/dsl/vocabulary.d.ts +61 -0
  46. package/dist/types/src/dsl/vocabulary.d.ts.map +1 -0
  47. package/dist/types/src/dx-svg.d.ts +12 -0
  48. package/dist/types/src/dx-svg.d.ts.map +1 -0
  49. package/dist/types/src/dx-svg.test.d.ts +2 -0
  50. package/dist/types/src/dx-svg.test.d.ts.map +1 -0
  51. package/dist/types/src/extension/complete.d.ts +3 -0
  52. package/dist/types/src/extension/complete.d.ts.map +1 -0
  53. package/dist/types/src/extension/extension.test.d.ts +2 -0
  54. package/dist/types/src/extension/extension.test.d.ts.map +1 -0
  55. package/dist/types/src/extension/highlight.d.ts +14 -0
  56. package/dist/types/src/extension/highlight.d.ts.map +1 -0
  57. package/dist/types/src/extension/index.d.ts +6 -0
  58. package/dist/types/src/extension/index.d.ts.map +1 -0
  59. package/dist/types/src/extension/language.d.ts +13 -0
  60. package/dist/types/src/extension/language.d.ts.map +1 -0
  61. package/dist/types/src/extension/lint.d.ts +21 -0
  62. package/dist/types/src/extension/lint.d.ts.map +1 -0
  63. package/dist/types/src/extension/syntax.d.ts +3 -0
  64. package/dist/types/src/extension/syntax.d.ts.map +1 -0
  65. package/dist/types/src/index.d.ts +14 -0
  66. package/dist/types/src/index.d.ts.map +1 -1
  67. package/dist/types/src/mermaid-engine.d.ts +40 -1
  68. package/dist/types/src/mermaid-engine.d.ts.map +1 -1
  69. package/dist/types/src/mermaid.d.ts +3 -3
  70. package/dist/types/src/mermaid.d.ts.map +1 -1
  71. package/dist/types/src/nudge.d.ts +30 -0
  72. package/dist/types/src/nudge.d.ts.map +1 -0
  73. package/dist/types/src/nudge.test.d.ts +2 -0
  74. package/dist/types/src/nudge.test.d.ts.map +1 -0
  75. package/dist/types/src/objective.d.ts +4 -0
  76. package/dist/types/src/objective.d.ts.map +1 -1
  77. package/dist/types/src/ortho-router.d.ts +13 -1
  78. package/dist/types/src/ortho-router.d.ts.map +1 -1
  79. package/dist/types/src/ports.d.ts +40 -0
  80. package/dist/types/src/ports.d.ts.map +1 -0
  81. package/dist/types/src/ports.test.d.ts +2 -0
  82. package/dist/types/src/ports.test.d.ts.map +1 -0
  83. package/dist/types/src/relations.test.d.ts +2 -0
  84. package/dist/types/src/relations.test.d.ts.map +1 -0
  85. package/dist/types/src/rules.d.ts +30 -0
  86. package/dist/types/src/rules.d.ts.map +1 -0
  87. package/dist/types/src/scene.d.ts +108 -74
  88. package/dist/types/src/scene.d.ts.map +1 -1
  89. package/dist/types/src/score.d.ts +54 -0
  90. package/dist/types/src/score.d.ts.map +1 -0
  91. package/dist/types/src/score.test.d.ts +2 -0
  92. package/dist/types/src/score.test.d.ts.map +1 -0
  93. package/dist/types/src/semantic-engine.d.ts +41 -0
  94. package/dist/types/src/semantic-engine.d.ts.map +1 -0
  95. package/dist/types/src/semantic-engine.test.d.ts +2 -0
  96. package/dist/types/src/semantic-engine.test.d.ts.map +1 -0
  97. package/dist/types/src/semantic-place.d.ts +81 -0
  98. package/dist/types/src/semantic-place.d.ts.map +1 -0
  99. package/dist/types/src/semantic-route.d.ts +133 -0
  100. package/dist/types/src/semantic-route.d.ts.map +1 -0
  101. package/dist/types/src/semantic.d.ts +164 -0
  102. package/dist/types/src/semantic.d.ts.map +1 -0
  103. package/dist/types/src/uml-grid.d.ts.map +1 -1
  104. package/dist/types/src/uml.d.ts +3 -5
  105. package/dist/types/src/uml.d.ts.map +1 -1
  106. package/dist/types/src/view.d.ts +53 -0
  107. package/dist/types/src/view.d.ts.map +1 -0
  108. package/dist/types/src/view.test.d.ts +2 -0
  109. package/dist/types/src/view.test.d.ts.map +1 -0
  110. package/dist/types/tsconfig.tsbuildinfo +1 -1
  111. package/package.json +23 -5
  112. package/rules/DIAGRAM.mdl +388 -0
  113. package/src/aesthetics.test.ts +89 -0
  114. package/src/aesthetics.ts +148 -0
  115. package/src/appeal.test.ts +144 -0
  116. package/src/appeal.ts +586 -0
  117. package/src/architecture.test.ts +94 -0
  118. package/src/architecture.ts +215 -0
  119. package/src/compact.test.ts +67 -0
  120. package/src/compact.ts +219 -0
  121. package/src/diagnostics.test.ts +207 -1
  122. package/src/diagnostics.ts +481 -37
  123. package/src/dsl/cli.ts +46 -0
  124. package/src/dsl/compile.ts +28 -0
  125. package/src/dsl/convert.test.ts +51 -0
  126. package/src/dsl/convert.ts +98 -0
  127. package/src/dsl/diagram.grammar +195 -0
  128. package/src/dsl/dsl.test.ts +286 -0
  129. package/src/dsl/gen/diagram.ts +22 -0
  130. package/src/dsl/index.ts +44 -0
  131. package/src/dsl/parse.ts +1054 -0
  132. package/src/dsl/print.ts +198 -0
  133. package/src/dsl/semantic.test.ts +216 -0
  134. package/src/dsl/vocabulary.ts +150 -0
  135. package/src/dx-svg.test.ts +44 -0
  136. package/src/dx-svg.ts +77 -0
  137. package/src/extension/complete.ts +105 -0
  138. package/src/extension/extension.test.ts +135 -0
  139. package/src/extension/highlight.ts +23 -0
  140. package/src/extension/index.ts +9 -0
  141. package/src/extension/language.ts +31 -0
  142. package/src/extension/lint.ts +60 -0
  143. package/src/extension/syntax.ts +62 -0
  144. package/src/index.ts +14 -0
  145. package/src/mermaid-engine.test.ts +6 -6
  146. package/src/mermaid-engine.ts +569 -119
  147. package/src/mermaid.test.ts +17 -1
  148. package/src/mermaid.ts +30 -18
  149. package/src/nudge.test.ts +186 -0
  150. package/src/nudge.ts +387 -0
  151. package/src/objective.test.ts +2 -0
  152. package/src/objective.ts +17 -1
  153. package/src/ortho-router.ts +240 -80
  154. package/src/ports.test.ts +157 -0
  155. package/src/ports.ts +186 -0
  156. package/src/relations.test.ts +98 -0
  157. package/src/rules.ts +117 -0
  158. package/src/scene.ts +66 -5
  159. package/src/score.test.ts +64 -0
  160. package/src/score.ts +130 -0
  161. package/src/semantic-engine.test.ts +635 -0
  162. package/src/semantic-engine.ts +891 -0
  163. package/src/semantic-place.ts +1508 -0
  164. package/src/semantic-route.ts +1136 -0
  165. package/src/semantic.ts +278 -0
  166. package/src/typings.d.ts +8 -0
  167. package/src/uml-grid.ts +6 -1
  168. package/src/uml.test.ts +5 -1
  169. package/src/uml.ts +24 -11
  170. package/src/view.test.ts +139 -0
  171. package/src/view.ts +376 -0
@@ -14,11 +14,14 @@ import ELK, { type ElkNode } from 'elkjs/lib/elk.bundled.js';
14
14
 
15
15
  import { invariant } from '@dxos/invariant';
16
16
 
17
+ import * as Compact from './compact.ts';
17
18
  import * as Diagnostics from './diagnostics.ts';
18
19
  import * as Layout from './layout.ts';
19
20
  import { type Direction, type MermaidEdge, type MermaidGraph, markers, parse } from './mermaid.ts';
21
+ import * as Nudge from './nudge.ts';
20
22
  import * as Objective from './objective.ts';
21
23
  import { makeAvoidingRouter } from './ortho-router.ts';
24
+ import * as Ports from './ports.ts';
22
25
  import type * as Scene from './scene.ts';
23
26
  import { GRID, type Rect, type Router, zRouter } from './uml-grid.ts';
24
27
 
@@ -102,12 +105,24 @@ export type CompileOptions = {
102
105
  * connect through one horizontal bus and a single triangle-headed trunk instead of parallel arrows.
103
106
  */
104
107
  bus?: readonly boolean[];
108
+ /**
109
+ * Compaction candidates (default both): with `true`, packages and boxes slide toward their partners
110
+ * after placement, closing empty lanes and long detours; see `compact.ts`.
111
+ */
112
+ compact?: readonly boolean[];
105
113
  /** Objective that picks among candidates (default `Objective.DEFAULT`). */
106
114
  objective?: Objective.Objective;
107
115
  /** Maximum cell width, in scene units (default GRID × 6); longer labels wrap. */
108
116
  maxWidth?: number;
109
117
  /** Connector router (default: obstacle-avoiding A* with the Z-router as fallback). */
110
118
  route?: Router;
119
+ /**
120
+ * Routes one candidate, nearly all of the layout's time (default: on this thread); a caller with
121
+ * worker threads can run {@link emitJob} there. Each candidate is handed over as soon as it is
122
+ * placed, so placing the rest overlaps the routing; the candidates are still collected in
123
+ * generation order. Not used with a custom `route`, which cannot leave the thread.
124
+ */
125
+ emitCandidate?: (job: EmitJob) => Promise<Scene.Command[]>;
111
126
  };
112
127
 
113
128
  /** One generated layout with the objective's verdict on it. */
@@ -117,6 +132,7 @@ export type Candidate = {
117
132
  arrangement: Arrangement;
118
133
  layering: Layering;
119
134
  alignment: Alignment;
135
+ compact: boolean;
120
136
  bus: boolean;
121
137
  commands: Scene.Command[];
122
138
  layout: Objective.Layout;
@@ -129,7 +145,7 @@ export type Result = {
129
145
  ranked: readonly Objective.Ranked<Candidate>[];
130
146
  };
131
147
 
132
- type Cell = { w: number; h: number };
148
+ export type Cell = { w: number; h: number };
133
149
  type Pitch = { x: number; y: number };
134
150
 
135
151
  /** One cell size for every node, sized to the longest label wrapped at `maxWidth`. */
@@ -155,7 +171,7 @@ const pitchFor = (graph: MermaidGraph, cell: Cell, lattice: number): Pitch => {
155
171
  };
156
172
  };
157
173
 
158
- type Placement = {
174
+ export type Placement = {
159
175
  nodes: Map<string, Rect>;
160
176
  frames: Map<string, Rect>;
161
177
  };
@@ -211,7 +227,10 @@ const compactGroups = (
211
227
  const groups = graph.groups
212
228
  .filter((group) => group.children.some((id) => positions.has(id)))
213
229
  .map((group) => {
214
- const coords = group.children.flatMap((id) => (positions.has(id) ? [positions.get(id)![axis]] : []));
230
+ const coords = group.children.flatMap((id) => {
231
+ const point = positions.get(id);
232
+ return point ? [point[axis]] : [];
233
+ });
215
234
  return { ids: group.children, start: Math.min(...coords), end: Math.max(...coords) };
216
235
  })
217
236
  .sort((left, right) => left.start - right.start);
@@ -257,7 +276,7 @@ const compactGroups = (
257
276
  type ElkEdge = { id: string; sources: string[]; targets: string[] };
258
277
 
259
278
  /**
260
- * Edges for ELK's layering. Inheritance points at the abstraction, which ranks ABOVE its
279
+ * Edges for ELK's layering. Inheritance and implementation point at the abstraction, which ranks ABOVE its
261
280
  * subtypes — so those edges are reversed, as `relationRanks` does for class diagrams; has-many
262
281
  * and containment already flow owner-above-owned. In `columns`, the root lays out groups without
263
282
  * seeing inside them (`SEPARATE_CHILDREN`), so every edge is lifted to its endpoints' root-level
@@ -270,7 +289,7 @@ const layeringEdges = (graph: MermaidGraph, arrangement: Arrangement, layering:
270
289
  const betweenGroups = (edge: MermaidEdge) =>
271
290
  groupOf.has(edge.from) && groupOf.has(edge.to) && groupOf.get(edge.from) !== groupOf.get(edge.to);
272
291
  const oriented = graph.edges.flatMap((edge) => {
273
- if (edge.kind === 'inheritance') {
292
+ if (edge.kind === 'inheritance' || edge.kind === 'implements') {
274
293
  return [{ from: edge.to, to: edge.from }];
275
294
  }
276
295
  if (edge.kind !== 'reference' || layering === 'down' || betweenGroups(edge)) {
@@ -451,6 +470,21 @@ const alignGroups = (
451
470
  return result;
452
471
  };
453
472
 
473
+ /** Slides packages and boxes toward their partners on the lattice; see `compact.ts`. */
474
+ const compactPlacement = (
475
+ graph: MermaidGraph,
476
+ positions: Map<string, Scene.Point>,
477
+ cell: Cell,
478
+ pitch: Pitch,
479
+ ): Map<string, Scene.Point> =>
480
+ Compact.compact({ nodes: graph.nodes.map((node) => node.id), groups: graph.groups, edges: graph.edges }, positions, {
481
+ cell,
482
+ pitch,
483
+ framePad: FRAME_PAD,
484
+ frameLabel: FRAME_LABEL_H,
485
+ frameGap: FRAME_GAP,
486
+ });
487
+
454
488
  /** Node rects at the origin plus the frame around each group's members. */
455
489
  const frame = (graph: MermaidGraph, positions: Map<string, Scene.Point>, cell: Cell): Placement => {
456
490
  const minX = Math.min(0, ...[...positions.values()].map((point) => point.x));
@@ -475,7 +509,20 @@ const frame = (graph: MermaidGraph, positions: Map<string, Scene.Point>, cell: C
475
509
  return { nodes, frames };
476
510
  };
477
511
 
478
- type Ports = { start?: number; end?: number };
512
+ type PortPair = { start?: number; end?: number };
513
+
514
+ /** Rounds of port reordering per emit; each re-routes every edge, so it is capped. */
515
+ const PORT_ROUNDS = 3;
516
+
517
+ /** Local-search passes on the chosen layout, and the most re-routes they may spend. */
518
+ const SEARCH_PASSES = 4;
519
+ const SEARCH_TRIES = 48;
520
+
521
+ /** Whether a re-routed scene has fewer crossings without any new error or connector overlap. */
522
+ const improves = (next: Diagnostics.Report, current: Diagnostics.Report): boolean =>
523
+ next.metrics.crossings < current.metrics.crossings &&
524
+ Diagnostics.errors(next).length <= Diagnostics.errors(current).length &&
525
+ next.metrics.edgeOverlaps <= current.metrics.edgeOverlaps;
479
526
 
480
527
  /**
481
528
  * Straightened ports: when an edge's nodes overlap on the cross axis, both terminals take one
@@ -487,8 +534,8 @@ const straighten = (
487
534
  edges: readonly MermaidEdge[],
488
535
  nodes: Map<string, Rect>,
489
536
  isHorizontal: (edge: MermaidEdge) => boolean,
490
- ): Map<MermaidEdge, Ports> => {
491
- const ports = new Map<MermaidEdge, Ports>();
537
+ ): Map<MermaidEdge, PortPair> => {
538
+ const ports = new Map<MermaidEdge, PortPair>();
492
539
  const taken = new Map<string, number[]>();
493
540
  for (const edge of edges) {
494
541
  const from = nodes.get(edge.from);
@@ -589,7 +636,7 @@ const inheritanceBuses = (
589
636
  id: `${baseId}-inherit`,
590
637
  start: { x: trunkX, y: busY },
591
638
  end: { x: trunkX, y: base.y + base.h },
592
- head: 'triangle',
639
+ relation: 'inheritance',
593
640
  });
594
641
  }
595
642
  return { elements, consumed };
@@ -601,8 +648,83 @@ type EmitOptions = {
601
648
  bus: boolean;
602
649
  arrangement: Arrangement;
603
650
  route?: Router;
651
+ /** Run the port local search; costly, so only for the layout already chosen. */
652
+ search?: boolean;
653
+ };
654
+
655
+ /** One candidate's routing, as structured-cloneable data so it can cross to a worker thread. */
656
+ export type EmitJob = Omit<EmitOptions, 'route'> & {
657
+ source: string;
658
+ cell: Cell;
659
+ placement: Placement;
604
660
  };
605
661
 
662
+ const rectsOverlap = (a: Rect, b: Rect) => a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
663
+
664
+ /** Whether an axis-aligned (or short diagonal) segment passes through a rect, by its bounding box. */
665
+ const segmentHits = ([a, b]: [Scene.Point, Scene.Point], rect: Rect) =>
666
+ rectsOverlap(
667
+ { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y), w: Math.abs(b.x - a.x) || 1, h: Math.abs(b.y - a.y) || 1 },
668
+ rect,
669
+ );
670
+
671
+ /**
672
+ * Edge labels, each at the first spot along its own path that is clear of every node, every label
673
+ * already placed and every other edge's line: beside a vertical run or above/below a horizontal one,
674
+ * trying the middle segment first and working outward. Falls back to the middle when nothing is clear.
675
+ */
676
+ const placeLabels = (
677
+ labelled: readonly { id: string; text: string; points: Scene.Point[] }[],
678
+ paths: readonly Scene.Point[][],
679
+ nodes: readonly Rect[],
680
+ ): Scene.Text[] => {
681
+ const segments = paths.map((points) =>
682
+ points.slice(0, -1).map((point, index): [Scene.Point, Scene.Point] => [point, points[index + 1]]),
683
+ );
684
+ const placed: Rect[] = [];
685
+ return labelled.map(({ id, text, points }) => {
686
+ const size = { w: text.length * LABEL_FONT.charW, h: LABEL_FONT.lineH };
687
+ const own = points.slice(0, -1).map((point, index): [Scene.Point, Scene.Point] => [point, points[index + 1]]);
688
+ const middle = Math.floor((own.length - 1) / 2);
689
+ const order = own.map((_, index) => index).sort((a, b) => Math.abs(a - middle) - Math.abs(b - middle));
690
+ const candidates = order.flatMap((index) => {
691
+ const [a, b] = own[index];
692
+ const vertical = Math.abs(a.x - b.x) < Math.abs(a.y - b.y);
693
+ return [0.5, 0.3, 0.7].flatMap((t) => {
694
+ const at = { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
695
+ return vertical
696
+ ? [
697
+ { x: at.x + GRID / 4, y: at.y - size.h / 2 },
698
+ { x: at.x - GRID / 4 - size.w, y: at.y - size.h / 2 },
699
+ ]
700
+ : [
701
+ { x: at.x - size.w / 2, y: at.y - size.h - GRID / 8 },
702
+ { x: at.x - size.w / 2, y: at.y + GRID / 8 },
703
+ ];
704
+ });
705
+ });
706
+ const clear = (rect: Rect) =>
707
+ !nodes.some((node) => rectsOverlap(rect, node)) &&
708
+ !placed.some((other) => rectsOverlap(rect, other)) &&
709
+ !segments.some((path) => path.some((segment) => segmentHits(segment, rect)));
710
+ const [head, tail] = own[middle] ?? [points[0], points[points.length - 1]];
711
+ const fallback = { x: (head.x + tail.x) / 2 + GRID / 4, y: (head.y + tail.y) / 2 - LABEL_FONT.lineH };
712
+ const origin = candidates.find((candidate) => clear({ ...candidate, ...size })) ?? fallback;
713
+ placed.push({ ...origin, ...size });
714
+ return { kind: 'text', id: `${id}-label`, x: origin.x, y: origin.y, text, weight: 's' };
715
+ });
716
+ };
717
+
718
+ /** Group tints in declaration order; a renderer fills a frame with a light wash of its color. */
719
+ const GROUP_COLORS: readonly Scene.Color[] = [
720
+ 'light-blue',
721
+ 'light-green',
722
+ 'yellow',
723
+ 'light-violet',
724
+ 'orange',
725
+ 'light-red',
726
+ ];
727
+
606
728
  /**
607
729
  * Scene commands for a placement: one world object per subgraph frame (painted first) and per
608
730
  * node, plus an `edges` object of connectors.
@@ -611,13 +733,13 @@ const emit = (
611
733
  graph: MermaidGraph,
612
734
  cell: Cell,
613
735
  { nodes, frames }: Placement,
614
- { origin, scale, bus, arrangement, route }: EmitOptions,
736
+ { origin, scale, bus, arrangement, route, search = false }: EmitOptions,
615
737
  ): Scene.Command[] => {
616
738
  const horizontal = graph.direction === 'LR' || graph.direction === 'RL';
617
739
  const at = (rect: Rect): Scene.Point => ({ x: origin.x + rect.x * scale, y: origin.y + rect.y * scale });
618
740
  const commands: Scene.Command[] = [];
619
741
 
620
- for (const group of graph.groups) {
742
+ for (const [index, group] of graph.groups.entries()) {
621
743
  const frame = frames.get(group.id);
622
744
  if (!frame) {
623
745
  continue;
@@ -629,7 +751,17 @@ const emit = (
629
751
  origin: at(frame),
630
752
  scale,
631
753
  elements: [
632
- { kind: 'rect', id: 'frame', x: 0, y: 0, w: frame.w, h: frame.h, stroke: 'dashed', color: 'grey' },
754
+ {
755
+ kind: 'rect',
756
+ id: 'frame',
757
+ x: 0,
758
+ y: 0,
759
+ w: frame.w,
760
+ h: frame.h,
761
+ stroke: 'dashed',
762
+ fill: 'tint',
763
+ color: GROUP_COLORS[index % GROUP_COLORS.length],
764
+ },
633
765
  // The label sits in the frame's top band rather than centered, where members would cover it.
634
766
  ...(group.label.trim()
635
767
  ? [
@@ -668,7 +800,13 @@ const emit = (
668
800
 
669
801
  if (graph.edges.length > 0) {
670
802
  // Frames are containers, not obstacles: only node rects block routes.
671
- const router = route ?? makeAvoidingRouter([...nodes.values()], zRouter, { step: GRID / 4 });
803
+ const makeRouter = (): { router: Router; reserve?: (points: readonly Scene.Point[]) => void } => {
804
+ if (route) {
805
+ return { router: route };
806
+ }
807
+ const avoiding = makeAvoidingRouter([...nodes.values()], zRouter, { step: GRID / 4 });
808
+ return { router: avoiding, reserve: avoiding.reserve };
809
+ };
672
810
  const buses = bus && !horizontal ? inheritanceBuses(graph.edges, nodes) : { elements: [], consumed: new Set() };
673
811
  const routed = graph.edges.filter((edge) => !buses.consumed.has(edge));
674
812
  // In columns the root level runs across the flow, so an edge between two of its members — groups
@@ -677,23 +815,7 @@ const emit = (
677
815
  const rootOf = (id: string) => groupOf.get(id) ?? id;
678
816
  const isHorizontal = (edge: MermaidEdge) =>
679
817
  arrangement === 'columns' && rootOf(edge.from) !== rootOf(edge.to) ? !horizontal : horizontal;
680
- const ports = straighten(routed, nodes, isHorizontal);
681
- const elements: Scene.Element[] = [...buses.elements];
682
- // Terminals already placed, per node, with their role. Edges are routed independently (and an
683
- // internal edge and a cross-group edge may even route in different modes), so a later edge can
684
- // land its terminal on an earlier one. Only an exit on an entry is a defect — it reads as a
685
- // crossing — so a slot is taken by the opposite role alone: two exits (a fork) or two entries (a
686
- // merge) share a trunk, as a bus does. Such an edge is re-routed with the port nudged to a free
687
- // slot on that side, preferring the nudge that keeps the route straightest.
688
- type Role = 'exit' | 'entry';
689
- const terminals = new Map<string, { point: Scene.Point; role: Role }[]>();
690
- const taken = (nodeId: string, point: Scene.Point, role: Role) =>
691
- (terminals.get(nodeId) ?? []).some(
692
- (other) =>
693
- other.role !== role &&
694
- Math.abs(other.point.x - point.x) < GRID_FINE &&
695
- Math.abs(other.point.y - point.y) < GRID_FINE,
696
- );
818
+ const straightened = straighten(routed, nodes, isHorizontal);
697
819
  // Paired nudges first: both ends move together, so a straight edge stays straight; single-ended
698
820
  // ones for when only one end is crowded.
699
821
  const NUDGES: [number, number][] = [
@@ -707,82 +829,320 @@ const emit = (
707
829
  [0, 1],
708
830
  [0, -1],
709
831
  ];
710
- const routeEdge = (edge: MermaidEdge, from: Rect, to: Rect): Scene.Point[] => {
711
- const horizontal = isHorizontal(edge);
712
- const sameLane = horizontal ? from.x === to.x : from.y === to.y;
713
- const alongY = horizontal ? sameLane : !sameLane;
714
- const base = ports.get(edge) ?? {};
715
- const center = (rect: Rect) => (alongY ? rect.x + rect.w / 2 : rect.y + rect.h / 2);
716
- const within = (rect: Rect, value: number) =>
717
- alongY
718
- ? Math.min(rect.x + rect.w - GRID_FINE, Math.max(rect.x + GRID_FINE, value))
719
- : Math.min(rect.y + rect.h - GRID_FINE, Math.max(rect.y + GRID_FINE, value));
720
- const route = ([startNudge, endNudge]: [number, number]) =>
721
- router({
722
- relation: edge,
723
- from,
724
- to,
725
- horizontal,
726
- offset: 0,
727
- ports: {
728
- start: within(from, (base.start ?? center(from)) + startNudge * GRID_FINE),
729
- end: within(to, (base.end ?? center(to)) + endNudge * GRID_FINE),
730
- },
832
+ type Routed = {
833
+ edge: MermaidEdge;
834
+ index: number;
835
+ points: Scene.Point[];
836
+ source: Rect;
837
+ target: Rect;
838
+ /** Every path the router returned for this edge, each of which steered the edges after it. */
839
+ attempts: Scene.Point[][];
840
+ };
841
+ /**
842
+ * Routes every edge with a fresh router, since the avoiding router steers each route off earlier
843
+ * ones. Edges before the first whose ports changed would route exactly as in `previous`, so their
844
+ * routes are replayed into the router instead of searched again.
845
+ */
846
+ const routeAll = (
847
+ ports: Map<MermaidEdge, PortPair>,
848
+ previous?: { ports: Map<MermaidEdge, PortPair>; routes: readonly Routed[] },
849
+ ): Routed[] => {
850
+ const { router: search, reserve } = makeRouter();
851
+ let attempts: Scene.Point[][] = [];
852
+ const router: Router = (relation) => {
853
+ const points = search(relation);
854
+ attempts.push(points);
855
+ return points;
856
+ };
857
+ // Terminals already placed, per node, with their role. Edges are routed independently (and an
858
+ // internal edge and a cross-group edge may even route in different modes), so a later edge can
859
+ // land its terminal on an earlier one. Only an exit on an entry is a defect — it reads as a
860
+ // crossing — so a slot is taken by the opposite role alone: two exits (a fork) or two entries (a
861
+ // merge) share a trunk, as a bus does. Such an edge is re-routed with the port nudged to a free
862
+ // slot on that side, preferring the nudge that keeps the route straightest.
863
+ type Role = 'exit' | 'entry';
864
+ const terminals = new Map<string, { point: Scene.Point; role: Role }[]>();
865
+ const taken = (nodeId: string, point: Scene.Point, role: Role) =>
866
+ (terminals.get(nodeId) ?? []).some(
867
+ (other) =>
868
+ other.role !== role &&
869
+ Math.abs(other.point.x - point.x) < GRID_FINE &&
870
+ Math.abs(other.point.y - point.y) < GRID_FINE,
871
+ );
872
+ const routeEdge = (edge: MermaidEdge, from: Rect, to: Rect): Scene.Point[] => {
873
+ const horizontal = isHorizontal(edge);
874
+ const sameLane = horizontal ? from.x === to.x : from.y === to.y;
875
+ const alongY = horizontal ? sameLane : !sameLane;
876
+ const base = ports.get(edge) ?? {};
877
+ const center = (rect: Rect) => (alongY ? rect.x + rect.w / 2 : rect.y + rect.h / 2);
878
+ const within = (rect: Rect, value: number) =>
879
+ alongY
880
+ ? Math.min(rect.x + rect.w - GRID_FINE, Math.max(rect.x + GRID_FINE, value))
881
+ : Math.min(rect.y + rect.h - GRID_FINE, Math.max(rect.y + GRID_FINE, value));
882
+ const route = ([startNudge, endNudge]: [number, number]) =>
883
+ router({
884
+ relation: edge,
885
+ from,
886
+ to,
887
+ horizontal,
888
+ offset: 0,
889
+ ports: {
890
+ start: within(from, (base.start ?? center(from)) + startNudge * GRID_FINE),
891
+ end: within(to, (base.end ?? center(to)) + endNudge * GRID_FINE),
892
+ },
893
+ });
894
+ const free = (points: Scene.Point[]) =>
895
+ !taken(edge.from, points[0], 'exit') && !taken(edge.to, points[points.length - 1], 'entry');
896
+ const direct = route(NUDGES[0]);
897
+ if (free(direct)) {
898
+ return direct;
899
+ }
900
+ let best: Scene.Point[] | undefined;
901
+ for (const nudge of NUDGES.slice(1)) {
902
+ const attempt = route(nudge);
903
+ if (free(attempt) && (!best || attempt.length < best.length)) {
904
+ best = attempt;
905
+ }
906
+ }
907
+ return best ?? direct;
908
+ };
909
+ const replayed = new Map(previous?.routes.map((entry) => [entry.edge, entry]));
910
+ const samePorts = (edge: MermaidEdge) =>
911
+ previous?.ports.get(edge)?.start === ports.get(edge)?.start &&
912
+ previous?.ports.get(edge)?.end === ports.get(edge)?.end;
913
+ let replaying = reserve !== undefined && previous !== undefined;
914
+ return routed.flatMap((edge, index): Routed[] => {
915
+ const from = nodes.get(edge.from);
916
+ const to = nodes.get(edge.to);
917
+ if (!from || !to) {
918
+ return [];
919
+ }
920
+ const earlier = replayed.get(edge);
921
+ replaying = replaying && earlier !== undefined && samePorts(edge);
922
+ let points: Scene.Point[];
923
+ if (replaying && earlier && reserve) {
924
+ earlier.attempts.forEach(reserve);
925
+ attempts = earlier.attempts;
926
+ points = earlier.points;
927
+ } else {
928
+ attempts = [];
929
+ points = routeEdge(edge, from, to);
930
+ }
931
+ terminals.set(edge.from, [...(terminals.get(edge.from) ?? []), { point: points[0], role: 'exit' }]);
932
+ terminals.set(edge.to, [
933
+ ...(terminals.get(edge.to) ?? []),
934
+ { point: points[points.length - 1], role: 'entry' },
935
+ ]);
936
+ return [{ edge, index, points, source: from, target: to, attempts }];
937
+ });
938
+ };
939
+ // Edges are routed one at a time, so parallel runs land on the same line; the bus is a deliberate merge and stays put.
940
+ const draw = (routes: readonly Routed[]) => {
941
+ const nudged = Nudge.nudge(routes, {
942
+ spacing: GRID / 4,
943
+ obstacles: [...nodes.values()],
944
+ fixed: buses.elements.flatMap((element) =>
945
+ element.kind === 'line'
946
+ ? [element.points]
947
+ : element.kind === 'arrow' && element.start && element.end
948
+ ? [[element.start, element.end]]
949
+ : [],
950
+ ),
951
+ });
952
+ const elements: Scene.Element[] = [...buses.elements];
953
+ const labelled: { id: string; text: string; points: Scene.Point[] }[] = [];
954
+ const paths: Scene.Point[][] = [];
955
+ routes.forEach(({ edge, index }, position) => {
956
+ const points = nudged[position];
957
+ const id = `${edge.from}-${edge.to}-${index}`;
958
+ const style = markers(edge.kind);
959
+ if (points.length > 2) {
960
+ elements.push({
961
+ kind: 'line',
962
+ id: `${id}-path`,
963
+ points: points.slice(0, -1),
964
+ ...(style.stroke ? { stroke: style.stroke } : {}),
965
+ });
966
+ }
967
+ elements.push({
968
+ kind: 'arrow',
969
+ id,
970
+ start: points[points.length - 2],
971
+ end: points[points.length - 1],
972
+ ...style,
731
973
  });
732
- const free = (points: Scene.Point[]) =>
733
- !taken(edge.from, points[0], 'exit') && !taken(edge.to, points[points.length - 1], 'entry');
734
- const direct = route(NUDGES[0]);
735
- if (free(direct)) {
736
- return direct;
737
- }
738
- let best: Scene.Point[] | undefined;
739
- for (const nudge of NUDGES.slice(1)) {
740
- const attempt = route(nudge);
741
- if (free(attempt) && (!best || attempt.length < best.length)) {
742
- best = attempt;
974
+ if (edge.label) {
975
+ labelled.push({ id, text: edge.label, points });
743
976
  }
977
+ paths.push(points);
978
+ });
979
+ elements.push(...placeLabels(labelled, paths, [...nodes.values()]));
980
+ const object: Scene.WorldObject = { id: 'edges', origin, scale, elements };
981
+ return { object, nudged };
982
+ };
983
+ // The faces whose port the router honours: a terminal it moved to a cross face is not ours to order.
984
+ const flowSides = (edge: MermaidEdge, from: Rect, to: Rect): [Ports.Side, Ports.Side] => {
985
+ const horizontal = isHorizontal(edge);
986
+ const alongY = horizontal ? from.x === to.x : from.y !== to.y;
987
+ return alongY
988
+ ? to.y >= from.y
989
+ ? ['bottom', 'top']
990
+ : ['top', 'bottom']
991
+ : to.x >= from.x
992
+ ? ['right', 'left']
993
+ : ['left', 'right'];
994
+ };
995
+ const others = objectsOf(commands);
996
+ let ports = straightened;
997
+ let routes = routeAll(ports);
998
+ let drawn = draw(routes);
999
+ let report = Diagnostics.analyze([...others, drawn.object]);
1000
+ const refOf = ({ edge, index }: Routed) => `edges/${edge.from}-${edge.to}-${index}`;
1001
+ const crossingRefs = () =>
1002
+ new Set(report.diagnostics.flatMap(({ code, refs }) => (code === 'edge-crossing' ? refs : [])));
1003
+ /** Re-routes with `candidate` ports and keeps the result only if it {@link improves} the scene. */
1004
+ const attempt = (candidate: Map<MermaidEdge, PortPair>): boolean => {
1005
+ const candidateRoutes = routeAll(candidate, { ports, routes });
1006
+ const candidateDrawn = draw(candidateRoutes);
1007
+ const candidateReport = Diagnostics.analyze([...others, candidateDrawn.object]);
1008
+ if (!improves(candidateReport, report)) {
1009
+ return false;
744
1010
  }
745
- return best ?? direct;
1011
+ [ports, routes, drawn, report] = [candidate, candidateRoutes, candidateDrawn, candidateReport];
1012
+ return true;
746
1013
  };
747
- routed.forEach((edge, index) => {
748
- const from = nodes.get(edge.from);
749
- const to = nodes.get(edge.to);
750
- if (!from || !to) {
751
- return;
1014
+ const withPort = (
1015
+ base: Map<MermaidEdge, PortPair>,
1016
+ edge: MermaidEdge,
1017
+ end: 'start' | 'end',
1018
+ coord: number,
1019
+ ): Map<MermaidEdge, PortPair> => new Map(base).set(edge, { ...base.get(edge), [end]: coord });
1020
+
1021
+ for (let round = 0; round < PORT_ROUNDS && report.metrics.crossings > 0; round++) {
1022
+ const assigned = Ports.assign(
1023
+ routes.map(({ edge, source, target }, position) => {
1024
+ const points = drawn.nudged[position];
1025
+ const [startSide, endSide] = flowSides(edge, source, target);
1026
+ // A straightened edge pins its ports only while its route really is one straight run.
1027
+ const straight = straightened.has(edge) && points.length === 2;
1028
+ return {
1029
+ points,
1030
+ source,
1031
+ target,
1032
+ fixed: {
1033
+ start: straight || Ports.sideOf(points[0], source) !== startSide,
1034
+ end: straight || Ports.sideOf(points[points.length - 1], target) !== endSide,
1035
+ },
1036
+ };
1037
+ }),
1038
+ { step: GRID_FINE },
1039
+ );
1040
+ // Re-routing is the expensive part, and moving ports of edges that cross nothing cannot uncross anything.
1041
+ const crossing = crossingRefs();
1042
+ const moved = routes.filter(
1043
+ (_, position) => assigned[position].start !== undefined || assigned[position].end !== undefined,
1044
+ );
1045
+ if (!moved.some((entry) => crossing.has(refOf(entry)))) {
1046
+ break;
1047
+ }
1048
+ let candidate = ports;
1049
+ routes.forEach(({ edge }, position) => {
1050
+ for (const end of ['start', 'end'] as const) {
1051
+ const coord = assigned[position][end];
1052
+ if (coord !== undefined) {
1053
+ candidate = withPort(candidate, edge, end, coord);
1054
+ }
1055
+ }
1056
+ });
1057
+ if (!attempt(candidate)) {
1058
+ break;
752
1059
  }
753
- const points = routeEdge(edge, from, to);
754
- terminals.set(edge.from, [...(terminals.get(edge.from) ?? []), { point: points[0], role: 'exit' }]);
755
- terminals.set(edge.to, [...(terminals.get(edge.to) ?? []), { point: points[points.length - 1], role: 'entry' }]);
756
- const id = `${edge.from}-${edge.to}-${index}`;
757
- if (points.length > 2) {
758
- elements.push({ kind: 'line', id: `${id}-path`, points: points.slice(0, -1) });
1060
+ }
1061
+
1062
+ // Local search, for the chosen layout only: swap two ports of one side, or slide one to a free
1063
+ // slot beside it, where an edge on that side still crosses another.
1064
+ let tries = 0;
1065
+ for (let pass = 0; search && pass < SEARCH_PASSES && report.metrics.crossings > 0; pass++) {
1066
+ type Slot = { edge: MermaidEdge; end: 'start' | 'end'; coord: number; crossing: boolean; rect: Rect };
1067
+ const crossing = crossingRefs();
1068
+ const sides = new Map<string, Slot[]>();
1069
+ for (const entry of routes) {
1070
+ const { edge, source, target, points } = entry;
1071
+ const flow = flowSides(edge, source, target);
1072
+ const ends = [
1073
+ { end: 'start' as const, rect: source, point: points[0], side: flow[0], node: edge.from },
1074
+ { end: 'end' as const, rect: target, point: points[points.length - 1], side: flow[1], node: edge.to },
1075
+ ];
1076
+ for (const { end, rect, point, side, node } of ends) {
1077
+ if (Ports.sideOf(point, rect) === side) {
1078
+ const coord = side === 'top' || side === 'bottom' ? point.x : point.y;
1079
+ const key = `${node}:${side}`;
1080
+ sides.set(key, [
1081
+ ...(sides.get(key) ?? []),
1082
+ { edge, end, coord, crossing: crossing.has(refOf(entry)), rect },
1083
+ ]);
1084
+ }
1085
+ }
759
1086
  }
760
- elements.push({
761
- kind: 'arrow',
762
- id,
763
- start: points[points.length - 2],
764
- end: points[points.length - 1],
765
- ...markers(edge.kind),
1087
+ const moves = [...sides.entries()].flatMap(([key, slots]): Map<MermaidEdge, PortPair>[] => {
1088
+ if (slots.length < 2 || !slots.some((slot) => slot.crossing)) {
1089
+ return [];
1090
+ }
1091
+ const horizontalSide = key.endsWith(':top') || key.endsWith(':bottom');
1092
+ const swaps = slots.flatMap((first, index) =>
1093
+ slots
1094
+ .slice(index + 1)
1095
+ .filter(
1096
+ (second) => (first.crossing || second.crossing) && Math.abs(first.coord - second.coord) >= GRID_FINE,
1097
+ )
1098
+ .map((second) =>
1099
+ withPort(withPort(ports, first.edge, first.end, second.coord), second.edge, second.end, first.coord),
1100
+ ),
1101
+ );
1102
+ const slides = slots
1103
+ .filter((slot) => slot.crossing)
1104
+ .flatMap((slot) =>
1105
+ [-GRID_FINE, GRID_FINE]
1106
+ .map((delta) => slot.coord + delta)
1107
+ .filter((coord) => {
1108
+ const [low, high] = horizontalSide
1109
+ ? [slot.rect.x, slot.rect.x + slot.rect.w]
1110
+ : [slot.rect.y, slot.rect.y + slot.rect.h];
1111
+ return (
1112
+ coord >= low + GRID_FINE &&
1113
+ coord <= high - GRID_FINE &&
1114
+ slots.every((other) => other === slot || Math.abs(other.coord - coord) >= GRID_FINE)
1115
+ );
1116
+ })
1117
+ .map((coord) => withPort(ports, slot.edge, slot.end, coord)),
1118
+ );
1119
+ return [...swaps, ...slides];
766
1120
  });
767
- if (edge.label) {
768
- const head = points[Math.floor(points.length / 2) - 1];
769
- const tail = points[Math.floor(points.length / 2)];
770
- elements.push({
771
- kind: 'text',
772
- id: `${id}-label`,
773
- x: (head.x + tail.x) / 2 + GRID / 4,
774
- y: (head.y + tail.y) / 2 - LABEL_FONT.lineH,
775
- text: edge.label,
776
- weight: 's',
777
- });
1121
+ let improved = false;
1122
+ for (const move of moves) {
1123
+ if (tries++ >= SEARCH_TRIES) {
1124
+ break;
1125
+ }
1126
+ if (attempt(move)) {
1127
+ improved = true;
1128
+ // Routes changed, so the remaining moves were built on stale coordinates.
1129
+ break;
1130
+ }
778
1131
  }
779
- });
780
- commands.push({ op: 'upsert-object', object: { id: 'edges', origin, scale, elements } });
1132
+ if (!improved) {
1133
+ break;
1134
+ }
1135
+ }
1136
+ commands.push({ op: 'upsert-object', object: drawn.object });
781
1137
  }
782
1138
 
783
1139
  return commands;
784
1140
  };
785
1141
 
1142
+ /** Routes one candidate with the default router; what a worker runs for `CompileOptions.emitCandidate`. */
1143
+ export const emitJob = ({ source, cell, placement, ...options }: EmitJob): Scene.Command[] =>
1144
+ emit(parse(source), cell, placement, options);
1145
+
786
1146
  const objectsOf = (commands: readonly Scene.Command[]): Scene.WorldObject[] =>
787
1147
  commands.flatMap((command) => (command.op === 'upsert-object' ? [command.object] : []));
788
1148
 
@@ -793,8 +1153,16 @@ const objectsOf = (commands: readonly Scene.Command[]): Scene.WorldObject[] =>
793
1153
  */
794
1154
  export const layout = async (source: string, options: CompileOptions = {}): Promise<Result> => {
795
1155
  const graph = parse(source);
796
- const { origin = { x: 0, y: 0 }, scale = 1, maxWidth = MAX_W, objective = Objective.DEFAULT, route } = options;
1156
+ const {
1157
+ origin = { x: 0, y: 0 },
1158
+ scale = 1,
1159
+ maxWidth = MAX_W,
1160
+ objective = Objective.DEFAULT,
1161
+ route,
1162
+ compact: compactions = [true, false],
1163
+ } = options;
797
1164
  const cell = measureCell(graph, maxWidth);
1165
+ const horizontal = graph.direction === 'LR' || graph.direction === 'RL';
798
1166
  const lattices = typeof options.lattice === 'number' ? [options.lattice] : (options.lattice ?? LATTICES);
799
1167
  const orders = options.order ?? ORDERS;
800
1168
  const buses = options.bus ?? [true, false];
@@ -809,14 +1177,33 @@ export const layout = async (source: string, options: CompileOptions = {}): Prom
809
1177
  buses.length > 0 &&
810
1178
  arrangements.length > 0 &&
811
1179
  layerings.length > 0 &&
812
- alignments.length > 0,
1180
+ alignments.length > 0 &&
1181
+ compactions.length > 0,
813
1182
  'every candidate axis needs a value',
814
1183
  );
815
1184
 
816
- const candidates: Candidate[] = [];
1185
+ const emitCandidate = route ? undefined : options.emitCandidate;
1186
+ // A candidate can fail while later ones are still being placed, before `Promise.all` below
1187
+ // observes it; marking it handled here keeps that from crashing the process as an unhandled
1188
+ // rejection, and `Promise.all` still rethrows the failure.
1189
+ const settleLater = (commands: Promise<Scene.Command[]>) => {
1190
+ commands.catch(() => {});
1191
+ return commands;
1192
+ };
1193
+
1194
+ const pending: {
1195
+ candidate: Omit<Candidate, 'commands' | 'layout'>;
1196
+ placement: Placement;
1197
+ commands: Promise<Scene.Command[]>;
1198
+ }[] = [];
817
1199
  // Knobs often reach the same placement (a graph with no in-package references layers the same
818
1200
  // either way; equal-height packages align the same every way); each is routed and graded once.
819
1201
  const seen = new Set<string>();
1202
+ const keyOf = (placement: Placement) =>
1203
+ [...placement.nodes]
1204
+ .map(([id, rect]) => `${id}:${rect.x}:${rect.y}`)
1205
+ .sort()
1206
+ .join(' ');
820
1207
  for (const lattice of lattices) {
821
1208
  for (const order of orders) {
822
1209
  for (const arrangement of arrangements) {
@@ -825,28 +1212,29 @@ export const layout = async (source: string, options: CompileOptions = {}): Prom
825
1212
  const positions = await place(graph, cell, pitch, order, arrangement, layering);
826
1213
  // Alignment moves packages relative to each other, which only columns set side by side.
827
1214
  for (const alignment of arrangement === 'columns' ? alignments : (['none'] as const)) {
828
- const placement = frame(graph, alignGroups(graph, positions, graph.direction, alignment), cell);
829
- const key = [...placement.nodes]
830
- .map(([id, rect]) => `${id}:${rect.x}:${rect.y}`)
831
- .sort()
832
- .join(' ');
833
- for (const bus of buses) {
834
- if (seen.has(`${arrangement}|${bus}|${key}`)) {
835
- continue;
1215
+ const aligned = alignGroups(graph, positions, graph.direction, alignment);
1216
+ for (const compact of [false, true].filter((value) => compactions.includes(value))) {
1217
+ const placement = frame(graph, compact ? compactPlacement(graph, aligned, cell, pitch) : aligned, cell);
1218
+ const key = keyOf(placement);
1219
+ // Without a row of subtypes to gather, the bus draws exactly what its absence does; the
1220
+ // `false` twin is routed anyway, so routing both would be the same work twice.
1221
+ const formsBus = !horizontal && inheritanceBuses(graph.edges, placement.nodes).consumed.size > 0;
1222
+ for (const bus of buses) {
1223
+ if (seen.has(`${arrangement}|${bus}|${key}`) || (bus && !formsBus && buses.includes(false))) {
1224
+ continue;
1225
+ }
1226
+ seen.add(`${arrangement}|${bus}|${key}`);
1227
+ const job: EmitJob = { source, cell, placement, origin, scale, bus, arrangement };
1228
+ pending.push({
1229
+ candidate: { lattice, order, arrangement, layering, alignment, compact, bus },
1230
+ placement,
1231
+ commands: settleLater(
1232
+ emitCandidate
1233
+ ? emitCandidate(job)
1234
+ : Promise.resolve(emit(graph, cell, placement, { origin, scale, bus, arrangement, route })),
1235
+ ),
1236
+ });
836
1237
  }
837
- seen.add(`${arrangement}|${bus}|${key}`);
838
- const commands = emit(graph, cell, placement, { origin, scale, bus, arrangement, route });
839
- const objects = objectsOf(commands);
840
- candidates.push({
841
- lattice,
842
- order,
843
- arrangement,
844
- layering,
845
- alignment,
846
- bus,
847
- commands,
848
- layout: { objects, report: Diagnostics.analyze(objects) },
849
- });
850
1238
  }
851
1239
  }
852
1240
  }
@@ -854,8 +1242,70 @@ export const layout = async (source: string, options: CompileOptions = {}): Prom
854
1242
  }
855
1243
  }
856
1244
 
857
- const { chosen, ranked } = Objective.select(objective, candidates);
858
- return { commands: chosen.candidate.commands, chosen, ranked };
1245
+ const emitted = await Promise.all(pending.map(({ commands }) => commands));
1246
+ const placements = new Map<Candidate, Placement>();
1247
+ const candidates = pending.map(({ candidate, placement }, index): Candidate => {
1248
+ const commands = emitted[index];
1249
+ const objects = objectsOf(commands);
1250
+ const result: Candidate = { ...candidate, commands, layout: { objects, report: Diagnostics.analyze(objects) } };
1251
+ placements.set(result, placement);
1252
+ return result;
1253
+ });
1254
+
1255
+ // Compaction trades connector length for proximity, but never for a crossing the uncompacted pick avoids.
1256
+ const plain = candidates.filter((candidate) => !candidate.compact);
1257
+ const ceiling = plain.length
1258
+ ? Objective.select(objective, plain).chosen.candidate.layout.report.metrics.crossings
1259
+ : Infinity;
1260
+ const { ranked } = Objective.select(
1261
+ objective,
1262
+ candidates.filter((candidate) => !candidate.compact || candidate.layout.report.metrics.crossings <= ceiling),
1263
+ );
1264
+ return refine(graph, cell, placements, objective, { origin, scale, route }, ranked);
1265
+ };
1266
+
1267
+ /** How many of the best-ranked layouts get the port local search; refining more found nothing better on the corpus. */
1268
+ const REFINE_TOP = 1;
1269
+
1270
+ /**
1271
+ * Re-emits the best-ranked layouts with the port local search, which is too costly to run on every
1272
+ * candidate, keeps each result the objective rates no worse, and picks again among them.
1273
+ */
1274
+ const refine = (
1275
+ graph: MermaidGraph,
1276
+ cell: Cell,
1277
+ placements: Map<Candidate, Placement>,
1278
+ objective: Objective.Objective,
1279
+ { origin, scale, route }: Pick<EmitOptions, 'origin' | 'scale' | 'route'>,
1280
+ ranked: readonly Objective.Ranked<Candidate>[],
1281
+ ): Result => {
1282
+ const refineOne = (entry: Objective.Ranked<Candidate>): Objective.Ranked<Candidate> => {
1283
+ const placement = placements.get(entry.candidate);
1284
+ if (!placement || entry.candidate.layout.report.metrics.crossings === 0) {
1285
+ return entry;
1286
+ }
1287
+ const { bus, arrangement } = entry.candidate;
1288
+ const commands = emit(graph, cell, placement, { origin, scale, bus, arrangement, route, search: true });
1289
+ const objects = objectsOf(commands);
1290
+ const candidate: Candidate = {
1291
+ ...entry.candidate,
1292
+ commands,
1293
+ layout: { objects, report: Diagnostics.analyze(objects) },
1294
+ };
1295
+ const evaluation = Objective.evaluate(objective, candidate.layout);
1296
+ return evaluation.violations.length > entry.evaluation.violations.length || evaluation.cost > entry.evaluation.cost
1297
+ ? entry
1298
+ : { candidate, evaluation };
1299
+ };
1300
+ // Same order as `Objective.select`, and stable, so untouched ties keep the generator's order.
1301
+ const reranked = ranked
1302
+ .map((entry, index) => (index < REFINE_TOP ? refineOne(entry) : entry))
1303
+ .sort(
1304
+ (left, right) =>
1305
+ left.evaluation.violations.length - right.evaluation.violations.length ||
1306
+ left.evaluation.cost - right.evaluation.cost,
1307
+ );
1308
+ return { commands: reranked[0].candidate.commands, chosen: reranked[0], ranked: reranked };
859
1309
  };
860
1310
 
861
1311
  /** The chosen layout's scene commands; see {@link layout} for the candidates and verdicts. */