@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.
- package/dist/lib/chunk-parse.mjs +9406 -0
- package/dist/lib/chunk-parse.mjs.map +1 -0
- package/dist/lib/extension.mjs +204 -0
- package/dist/lib/extension.mjs.map +1 -0
- package/dist/lib/index.mjs +497 -2846
- package/dist/lib/index.mjs.map +1 -1
- package/dist/types/src/aesthetics.d.ts +12 -0
- package/dist/types/src/aesthetics.d.ts.map +1 -0
- package/dist/types/src/aesthetics.test.d.ts +2 -0
- package/dist/types/src/aesthetics.test.d.ts.map +1 -0
- package/dist/types/src/appeal.d.ts +55 -0
- package/dist/types/src/appeal.d.ts.map +1 -0
- package/dist/types/src/appeal.test.d.ts +2 -0
- package/dist/types/src/appeal.test.d.ts.map +1 -0
- package/dist/types/src/architecture.d.ts +86 -0
- package/dist/types/src/architecture.d.ts.map +1 -0
- package/dist/types/src/architecture.test.d.ts +2 -0
- package/dist/types/src/architecture.test.d.ts.map +1 -0
- package/dist/types/src/compact.d.ts +39 -0
- package/dist/types/src/compact.d.ts.map +1 -0
- package/dist/types/src/compact.test.d.ts +2 -0
- package/dist/types/src/compact.test.d.ts.map +1 -0
- package/dist/types/src/diagnostics.d.ts +70 -3
- package/dist/types/src/diagnostics.d.ts.map +1 -1
- package/dist/types/src/dsl/cli.d.ts +2 -0
- package/dist/types/src/dsl/cli.d.ts.map +1 -0
- package/dist/types/src/dsl/compile.d.ts +12 -0
- package/dist/types/src/dsl/compile.d.ts.map +1 -0
- package/dist/types/src/dsl/convert.d.ts +45 -0
- package/dist/types/src/dsl/convert.d.ts.map +1 -0
- package/dist/types/src/dsl/convert.test.d.ts +2 -0
- package/dist/types/src/dsl/convert.test.d.ts.map +1 -0
- package/dist/types/src/dsl/dsl.test.d.ts +2 -0
- package/dist/types/src/dsl/dsl.test.d.ts.map +1 -0
- package/dist/types/src/dsl/gen/diagram.d.ts +3 -0
- package/dist/types/src/dsl/gen/diagram.d.ts.map +1 -0
- package/dist/types/src/dsl/index.d.ts +7 -0
- package/dist/types/src/dsl/index.d.ts.map +1 -0
- package/dist/types/src/dsl/parse.d.ts +49 -0
- package/dist/types/src/dsl/parse.d.ts.map +1 -0
- package/dist/types/src/dsl/print.d.ts +19 -0
- package/dist/types/src/dsl/print.d.ts.map +1 -0
- package/dist/types/src/dsl/semantic.test.d.ts +2 -0
- package/dist/types/src/dsl/semantic.test.d.ts.map +1 -0
- package/dist/types/src/dsl/vocabulary.d.ts +61 -0
- package/dist/types/src/dsl/vocabulary.d.ts.map +1 -0
- package/dist/types/src/dx-svg.d.ts +12 -0
- package/dist/types/src/dx-svg.d.ts.map +1 -0
- package/dist/types/src/dx-svg.test.d.ts +2 -0
- package/dist/types/src/dx-svg.test.d.ts.map +1 -0
- package/dist/types/src/extension/complete.d.ts +3 -0
- package/dist/types/src/extension/complete.d.ts.map +1 -0
- package/dist/types/src/extension/extension.test.d.ts +2 -0
- package/dist/types/src/extension/extension.test.d.ts.map +1 -0
- package/dist/types/src/extension/highlight.d.ts +14 -0
- package/dist/types/src/extension/highlight.d.ts.map +1 -0
- package/dist/types/src/extension/index.d.ts +6 -0
- package/dist/types/src/extension/index.d.ts.map +1 -0
- package/dist/types/src/extension/language.d.ts +13 -0
- package/dist/types/src/extension/language.d.ts.map +1 -0
- package/dist/types/src/extension/lint.d.ts +21 -0
- package/dist/types/src/extension/lint.d.ts.map +1 -0
- package/dist/types/src/extension/syntax.d.ts +3 -0
- package/dist/types/src/extension/syntax.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +14 -0
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/mermaid-engine.d.ts +40 -1
- package/dist/types/src/mermaid-engine.d.ts.map +1 -1
- package/dist/types/src/mermaid.d.ts +3 -3
- package/dist/types/src/mermaid.d.ts.map +1 -1
- package/dist/types/src/nudge.d.ts +30 -0
- package/dist/types/src/nudge.d.ts.map +1 -0
- package/dist/types/src/nudge.test.d.ts +2 -0
- package/dist/types/src/nudge.test.d.ts.map +1 -0
- package/dist/types/src/objective.d.ts +4 -0
- package/dist/types/src/objective.d.ts.map +1 -1
- package/dist/types/src/ortho-router.d.ts +13 -1
- package/dist/types/src/ortho-router.d.ts.map +1 -1
- package/dist/types/src/ports.d.ts +40 -0
- package/dist/types/src/ports.d.ts.map +1 -0
- package/dist/types/src/ports.test.d.ts +2 -0
- package/dist/types/src/ports.test.d.ts.map +1 -0
- package/dist/types/src/relations.test.d.ts +2 -0
- package/dist/types/src/relations.test.d.ts.map +1 -0
- package/dist/types/src/rules.d.ts +30 -0
- package/dist/types/src/rules.d.ts.map +1 -0
- package/dist/types/src/scene.d.ts +108 -74
- package/dist/types/src/scene.d.ts.map +1 -1
- package/dist/types/src/score.d.ts +54 -0
- package/dist/types/src/score.d.ts.map +1 -0
- package/dist/types/src/score.test.d.ts +2 -0
- package/dist/types/src/score.test.d.ts.map +1 -0
- package/dist/types/src/semantic-engine.d.ts +41 -0
- package/dist/types/src/semantic-engine.d.ts.map +1 -0
- package/dist/types/src/semantic-engine.test.d.ts +2 -0
- package/dist/types/src/semantic-engine.test.d.ts.map +1 -0
- package/dist/types/src/semantic-place.d.ts +81 -0
- package/dist/types/src/semantic-place.d.ts.map +1 -0
- package/dist/types/src/semantic-route.d.ts +133 -0
- package/dist/types/src/semantic-route.d.ts.map +1 -0
- package/dist/types/src/semantic.d.ts +164 -0
- package/dist/types/src/semantic.d.ts.map +1 -0
- package/dist/types/src/uml-grid.d.ts.map +1 -1
- package/dist/types/src/uml.d.ts +3 -5
- package/dist/types/src/uml.d.ts.map +1 -1
- package/dist/types/src/view.d.ts +53 -0
- package/dist/types/src/view.d.ts.map +1 -0
- package/dist/types/src/view.test.d.ts +2 -0
- package/dist/types/src/view.test.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +23 -5
- package/rules/DIAGRAM.mdl +388 -0
- package/src/aesthetics.test.ts +89 -0
- package/src/aesthetics.ts +148 -0
- package/src/appeal.test.ts +144 -0
- package/src/appeal.ts +586 -0
- package/src/architecture.test.ts +94 -0
- package/src/architecture.ts +215 -0
- package/src/compact.test.ts +67 -0
- package/src/compact.ts +219 -0
- package/src/diagnostics.test.ts +207 -1
- package/src/diagnostics.ts +481 -37
- package/src/dsl/cli.ts +46 -0
- package/src/dsl/compile.ts +28 -0
- package/src/dsl/convert.test.ts +51 -0
- package/src/dsl/convert.ts +98 -0
- package/src/dsl/diagram.grammar +195 -0
- package/src/dsl/dsl.test.ts +286 -0
- package/src/dsl/gen/diagram.ts +22 -0
- package/src/dsl/index.ts +44 -0
- package/src/dsl/parse.ts +1054 -0
- package/src/dsl/print.ts +198 -0
- package/src/dsl/semantic.test.ts +216 -0
- package/src/dsl/vocabulary.ts +150 -0
- package/src/dx-svg.test.ts +44 -0
- package/src/dx-svg.ts +77 -0
- package/src/extension/complete.ts +105 -0
- package/src/extension/extension.test.ts +135 -0
- package/src/extension/highlight.ts +23 -0
- package/src/extension/index.ts +9 -0
- package/src/extension/language.ts +31 -0
- package/src/extension/lint.ts +60 -0
- package/src/extension/syntax.ts +62 -0
- package/src/index.ts +14 -0
- package/src/mermaid-engine.test.ts +6 -6
- package/src/mermaid-engine.ts +569 -119
- package/src/mermaid.test.ts +17 -1
- package/src/mermaid.ts +30 -18
- package/src/nudge.test.ts +186 -0
- package/src/nudge.ts +387 -0
- package/src/objective.test.ts +2 -0
- package/src/objective.ts +17 -1
- package/src/ortho-router.ts +240 -80
- package/src/ports.test.ts +157 -0
- package/src/ports.ts +186 -0
- package/src/relations.test.ts +98 -0
- package/src/rules.ts +117 -0
- package/src/scene.ts +66 -5
- package/src/score.test.ts +64 -0
- package/src/score.ts +130 -0
- package/src/semantic-engine.test.ts +635 -0
- package/src/semantic-engine.ts +891 -0
- package/src/semantic-place.ts +1508 -0
- package/src/semantic-route.ts +1136 -0
- package/src/semantic.ts +278 -0
- package/src/typings.d.ts +8 -0
- package/src/uml-grid.ts +6 -1
- package/src/uml.test.ts +5 -1
- package/src/uml.ts +24 -11
- package/src/view.test.ts +139 -0
- package/src/view.ts +376 -0
package/src/mermaid-engine.ts
CHANGED
|
@@ -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) =>
|
|
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
|
|
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
|
|
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,
|
|
491
|
-
const ports = new Map<MermaidEdge,
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
|
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
|
|
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
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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
|
-
|
|
733
|
-
|
|
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
|
-
|
|
1011
|
+
[ports, routes, drawn, report] = [candidate, candidateRoutes, candidateDrawn, candidateReport];
|
|
1012
|
+
return true;
|
|
746
1013
|
};
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
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
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
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
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
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
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
|
829
|
-
const
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
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
|
|
858
|
-
|
|
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. */
|