@dxos/diagram 0.11.1
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/LICENSE +105 -0
- package/README.md +10 -0
- package/dist/lib/index.mjs +3975 -0
- package/dist/lib/index.mjs.map +1 -0
- package/dist/lib/testing.mjs +68 -0
- package/dist/lib/testing.mjs.map +1 -0
- package/dist/types/src/content.d.ts +60 -0
- package/dist/types/src/content.d.ts.map +1 -0
- package/dist/types/src/diagnostics.d.ts +56 -0
- package/dist/types/src/diagnostics.d.ts.map +1 -0
- package/dist/types/src/diagnostics.test.d.ts +2 -0
- package/dist/types/src/diagnostics.test.d.ts.map +1 -0
- package/dist/types/src/dialect.d.ts +19 -0
- package/dist/types/src/dialect.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +16 -0
- package/dist/types/src/index.d.ts.map +1 -0
- package/dist/types/src/layout.d.ts +26 -0
- package/dist/types/src/layout.d.ts.map +1 -0
- package/dist/types/src/mermaid-engine.d.ts +83 -0
- package/dist/types/src/mermaid-engine.d.ts.map +1 -0
- package/dist/types/src/mermaid-engine.test.d.ts +2 -0
- package/dist/types/src/mermaid-engine.test.d.ts.map +1 -0
- package/dist/types/src/mermaid.d.ts +56 -0
- package/dist/types/src/mermaid.d.ts.map +1 -0
- package/dist/types/src/mermaid.test.d.ts +3 -0
- package/dist/types/src/mermaid.test.d.ts.map +1 -0
- package/dist/types/src/objective.d.ts +75 -0
- package/dist/types/src/objective.d.ts.map +1 -0
- package/dist/types/src/objective.test.d.ts +2 -0
- package/dist/types/src/objective.test.d.ts.map +1 -0
- package/dist/types/src/ortho-router.d.ts +13 -0
- package/dist/types/src/ortho-router.d.ts.map +1 -0
- package/dist/types/src/scene.d.ts +1006 -0
- package/dist/types/src/scene.d.ts.map +1 -0
- package/dist/types/src/scene.test.d.ts +2 -0
- package/dist/types/src/scene.test.d.ts.map +1 -0
- package/dist/types/src/svg-handler.d.ts +5 -0
- package/dist/types/src/svg-handler.d.ts.map +1 -0
- package/dist/types/src/svg-handler.test.d.ts +2 -0
- package/dist/types/src/svg-handler.test.d.ts.map +1 -0
- package/dist/types/src/testing.d.ts +11 -0
- package/dist/types/src/testing.d.ts.map +1 -0
- package/dist/types/src/ui.d.ts +67 -0
- package/dist/types/src/ui.d.ts.map +1 -0
- package/dist/types/src/ui.test.d.ts +2 -0
- package/dist/types/src/ui.test.d.ts.map +1 -0
- package/dist/types/src/uml-engine.d.ts +14 -0
- package/dist/types/src/uml-engine.d.ts.map +1 -0
- package/dist/types/src/uml-engine.test.d.ts +2 -0
- package/dist/types/src/uml-engine.test.d.ts.map +1 -0
- package/dist/types/src/uml-grid.d.ts +99 -0
- package/dist/types/src/uml-grid.d.ts.map +1 -0
- package/dist/types/src/uml-grid.test.d.ts +2 -0
- package/dist/types/src/uml-grid.test.d.ts.map +1 -0
- package/dist/types/src/uml-rules.d.ts +56 -0
- package/dist/types/src/uml-rules.d.ts.map +1 -0
- package/dist/types/src/uml-rules.test.d.ts +2 -0
- package/dist/types/src/uml-rules.test.d.ts.map +1 -0
- package/dist/types/src/uml-search.d.ts +26 -0
- package/dist/types/src/uml-search.d.ts.map +1 -0
- package/dist/types/src/uml-search.test.d.ts +2 -0
- package/dist/types/src/uml-search.test.d.ts.map +1 -0
- package/dist/types/src/uml.d.ts +60 -0
- package/dist/types/src/uml.d.ts.map +1 -0
- package/dist/types/src/uml.test.d.ts +2 -0
- package/dist/types/src/uml.test.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -0
- package/package.json +48 -0
- package/src/__snapshots__/diagnostics.test.ts.snap +36 -0
- package/src/__snapshots__/mermaid-engine.test.ts.snap +22 -0
- package/src/content.ts +180 -0
- package/src/diagnostics.test.ts +173 -0
- package/src/diagnostics.ts +393 -0
- package/src/dialect.ts +53 -0
- package/src/index.ts +19 -0
- package/src/layout.ts +99 -0
- package/src/mermaid-engine.test.ts +244 -0
- package/src/mermaid-engine.ts +863 -0
- package/src/mermaid.test.ts +138 -0
- package/src/mermaid.ts +326 -0
- package/src/objective.test.ts +82 -0
- package/src/objective.ts +157 -0
- package/src/ortho-router.ts +372 -0
- package/src/scene.test.ts +36 -0
- package/src/scene.ts +281 -0
- package/src/svg-handler.test.ts +75 -0
- package/src/svg-handler.ts +119 -0
- package/src/testing.ts +68 -0
- package/src/ui.test.ts +183 -0
- package/src/ui.ts +398 -0
- package/src/uml-engine.test.ts +63 -0
- package/src/uml-engine.ts +146 -0
- package/src/uml-grid.test.ts +175 -0
- package/src/uml-grid.ts +559 -0
- package/src/uml-rules.test.ts +109 -0
- package/src/uml-rules.ts +319 -0
- package/src/uml-search.test.ts +108 -0
- package/src/uml-search.ts +266 -0
- package/src/uml.test.ts +172 -0
- package/src/uml.ts +390 -0
|
@@ -0,0 +1,863 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
//
|
|
6
|
+
// Engine-backed mermaid flowchart dialect. ELK lays out the graph as a compound graph (subgraphs
|
|
7
|
+
// are hierarchical nodes), node origins are quantized to a lattice of the cell size so neighbours
|
|
8
|
+
// align exactly, and connectors take the shared obstacle-avoiding router with straightened ports.
|
|
9
|
+
// Placement and emission are candidate generators — lattice × sibling order × inheritance bus —
|
|
10
|
+
// and the `Objective` picks the layout. Parsing stays in `mermaid.ts`; see `docs/DESIGN.md`.
|
|
11
|
+
//
|
|
12
|
+
|
|
13
|
+
import ELK, { type ElkNode } from 'elkjs/lib/elk.bundled.js';
|
|
14
|
+
|
|
15
|
+
import { invariant } from '@dxos/invariant';
|
|
16
|
+
|
|
17
|
+
import * as Diagnostics from './diagnostics.ts';
|
|
18
|
+
import * as Layout from './layout.ts';
|
|
19
|
+
import { type Direction, type MermaidEdge, type MermaidGraph, markers, parse } from './mermaid.ts';
|
|
20
|
+
import * as Objective from './objective.ts';
|
|
21
|
+
import { makeAvoidingRouter } from './ortho-router.ts';
|
|
22
|
+
import type * as Scene from './scene.ts';
|
|
23
|
+
import { GRID, type Rect, type Router, zRouter } from './uml-grid.ts';
|
|
24
|
+
|
|
25
|
+
const MIN_W = GRID * 3;
|
|
26
|
+
const MAX_W = GRID * 6;
|
|
27
|
+
const MIN_H = GRID * 2;
|
|
28
|
+
const PAD_X = GRID;
|
|
29
|
+
const PAD_Y = GRID / 2;
|
|
30
|
+
/** Frame inset around a subgraph's members; the top also holds the frame label. */
|
|
31
|
+
const FRAME_PAD = GRID;
|
|
32
|
+
const FRAME_LABEL_H = GRID;
|
|
33
|
+
/** Clear space between two frames, so packages read as separate even when their nodes are adjacent. */
|
|
34
|
+
const FRAME_GAP = GRID;
|
|
35
|
+
const GRID_FINE = GRID / 2;
|
|
36
|
+
|
|
37
|
+
const FONT = Layout.FONT_METRICS.m;
|
|
38
|
+
const LABEL_FONT = Layout.FONT_METRICS.s;
|
|
39
|
+
|
|
40
|
+
const ELK_DIRECTION: Record<Direction, string> = { TB: 'DOWN', BT: 'UP', LR: 'RIGHT', RL: 'LEFT' };
|
|
41
|
+
|
|
42
|
+
const snapUp = (value: number) => Math.ceil(value / GRID) * GRID;
|
|
43
|
+
const fineSnap = (value: number) => Math.round(value / GRID_FINE) * GRID_FINE;
|
|
44
|
+
|
|
45
|
+
/** How ELK orders siblings within a layer. */
|
|
46
|
+
export type Order = 'model' | 'free';
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* How groups relate: `layered` lays the whole hierarchy out in one flow (groups stack along it);
|
|
50
|
+
* `columns` lays each group out in the flow direction and arranges the groups side by side
|
|
51
|
+
* across it, so cross-package edges run sideways instead of stacking packages.
|
|
52
|
+
*/
|
|
53
|
+
export type Arrangement = 'layered' | 'columns';
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* How a plain reference ranks its ends. Inheritance always ranks the base above, has-many and
|
|
57
|
+
* containment the owner above; a reference has no such convention, so it is a candidate axis:
|
|
58
|
+
* `down` — the referenced sits below, ELK's reading of an arrow; `up` — the referenced sits above,
|
|
59
|
+
* as the abstraction does, so a node many others point at heads its package; `free` — a reference
|
|
60
|
+
* ranks nothing and its ends may share a row. Between packages a reference always orders the packages.
|
|
61
|
+
*/
|
|
62
|
+
export type Layering = 'down' | 'up' | 'free';
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* How packages sit along the flow in `columns`: `none` keeps ELK's placement; `edges` shifts each
|
|
66
|
+
* package by whole rows to where its cross-package edges meet their partners' rows, so those edges
|
|
67
|
+
* run straight instead of jogging between a short package and a tall one.
|
|
68
|
+
*/
|
|
69
|
+
export type Alignment = 'none' | 'edges';
|
|
70
|
+
|
|
71
|
+
const LATTICES: readonly number[] = [1.5, 2];
|
|
72
|
+
const ORDERS: readonly Order[] = ['model', 'free'];
|
|
73
|
+
const ARRANGEMENTS: readonly Arrangement[] = ['layered', 'columns'];
|
|
74
|
+
const LAYERINGS: readonly Layering[] = ['down', 'up', 'free'];
|
|
75
|
+
const ALIGNMENTS: readonly Alignment[] = ['none', 'edges'];
|
|
76
|
+
|
|
77
|
+
/** The direction perpendicular to the flow, for arranging groups across it. */
|
|
78
|
+
const ACROSS: Record<Direction, string> = { TB: 'RIGHT', BT: 'RIGHT', LR: 'DOWN', RL: 'DOWN' };
|
|
79
|
+
|
|
80
|
+
export type CompileOptions = {
|
|
81
|
+
/** Canvas position of the diagram's top-left, in canvas px. */
|
|
82
|
+
origin?: Scene.Point;
|
|
83
|
+
/** Canvas px per scene unit. */
|
|
84
|
+
scale?: number;
|
|
85
|
+
/**
|
|
86
|
+
* Pitch of the placement lattice as a multiple of the cell size. Node origins snap to lattice
|
|
87
|
+
* points, so nodes that ELK placed roughly in line share an axis exactly and their connectors
|
|
88
|
+
* run straight; the gutter between neighbours is `(lattice − 1) × cell`. A list names the
|
|
89
|
+
* candidates the objective chooses among (default `[1.5, 2]`); a number fixes it.
|
|
90
|
+
*/
|
|
91
|
+
lattice?: number | readonly number[];
|
|
92
|
+
/** ELK sibling ordering candidates (default both): declaration order, or free to minimize crossings. */
|
|
93
|
+
order?: readonly Order[];
|
|
94
|
+
/** Group arrangement candidates (default both when the graph has two or more groups). */
|
|
95
|
+
arrangement?: readonly Arrangement[];
|
|
96
|
+
/** How references rank their ends (default all three candidates). */
|
|
97
|
+
layering?: readonly Layering[];
|
|
98
|
+
/** Package alignment candidates along the flow, in `columns` (default both). */
|
|
99
|
+
alignment?: readonly Alignment[];
|
|
100
|
+
/**
|
|
101
|
+
* Inheritance bus candidates (default both): with `true`, subtypes on one row that share a base
|
|
102
|
+
* connect through one horizontal bus and a single triangle-headed trunk instead of parallel arrows.
|
|
103
|
+
*/
|
|
104
|
+
bus?: readonly boolean[];
|
|
105
|
+
/** Objective that picks among candidates (default `Objective.DEFAULT`). */
|
|
106
|
+
objective?: Objective.Objective;
|
|
107
|
+
/** Maximum cell width, in scene units (default GRID × 6); longer labels wrap. */
|
|
108
|
+
maxWidth?: number;
|
|
109
|
+
/** Connector router (default: obstacle-avoiding A* with the Z-router as fallback). */
|
|
110
|
+
route?: Router;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/** One generated layout with the objective's verdict on it. */
|
|
114
|
+
export type Candidate = {
|
|
115
|
+
lattice: number;
|
|
116
|
+
order: Order;
|
|
117
|
+
arrangement: Arrangement;
|
|
118
|
+
layering: Layering;
|
|
119
|
+
alignment: Alignment;
|
|
120
|
+
bus: boolean;
|
|
121
|
+
commands: Scene.Command[];
|
|
122
|
+
layout: Objective.Layout;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
export type Result = {
|
|
126
|
+
commands: Scene.Command[];
|
|
127
|
+
chosen: Objective.Ranked<Candidate>;
|
|
128
|
+
/** Every candidate, best first. */
|
|
129
|
+
ranked: readonly Objective.Ranked<Candidate>[];
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
type Cell = { w: number; h: number };
|
|
133
|
+
type Pitch = { x: number; y: number };
|
|
134
|
+
|
|
135
|
+
/** One cell size for every node, sized to the longest label wrapped at `maxWidth`. */
|
|
136
|
+
const measureCell = (graph: MermaidGraph, maxWidth: number): Cell => {
|
|
137
|
+
const longest = Math.max(0, ...graph.nodes.map((node) => node.label.length));
|
|
138
|
+
const w = snapUp(Math.min(maxWidth, Math.max(MIN_W, longest * FONT.charW + PAD_X * 2)));
|
|
139
|
+
const perLine = Math.max(1, Math.floor((w - PAD_X) / FONT.charW));
|
|
140
|
+
const lines = Math.max(1, ...graph.nodes.map((node) => Math.ceil(node.label.length / perLine)));
|
|
141
|
+
const h = snapUp(Math.max(MIN_H, (lines - 1) * FONT.lineH + FONT.lineH / 1.35 + PAD_Y * 2));
|
|
142
|
+
return { w, h };
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Lattice pitch for a cell. With groups, the gutter between lattice rows must also hold two frame
|
|
147
|
+
* borders, a label band, and clear space between the frames, or adjacent frames touch or overlap
|
|
148
|
+
* after quantization — so that clearance is a floor on the pitch.
|
|
149
|
+
*/
|
|
150
|
+
const pitchFor = (graph: MermaidGraph, cell: Cell, lattice: number): Pitch => {
|
|
151
|
+
const framed = graph.groups.some((group) => group.children.length > 0);
|
|
152
|
+
return {
|
|
153
|
+
x: snapUp(Math.max(cell.w * lattice, framed ? cell.w + FRAME_PAD * 2 + FRAME_GAP : 0)),
|
|
154
|
+
y: snapUp(Math.max(cell.h * lattice, framed ? cell.h + FRAME_PAD * 2 + FRAME_LABEL_H + FRAME_GAP : 0)),
|
|
155
|
+
};
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
type Placement = {
|
|
159
|
+
nodes: Map<string, Rect>;
|
|
160
|
+
frames: Map<string, Rect>;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Quantize ELK's positions to the lattice. Each node takes the nearest lattice point; when two
|
|
165
|
+
* nodes claim the same point, the later one slides along the cross axis to the nearest free slot,
|
|
166
|
+
* so quantization can never stack nodes.
|
|
167
|
+
*/
|
|
168
|
+
const quantize = (positions: Map<string, Scene.Point>, pitch: Pitch, horizontal: boolean): Map<string, Scene.Point> => {
|
|
169
|
+
const taken = new Set<string>();
|
|
170
|
+
const result = new Map<string, Scene.Point>();
|
|
171
|
+
// Place in reading order so a slide pushes the later node, not an earlier one.
|
|
172
|
+
const ordered = [...positions.entries()].sort(([, a], [, b]) =>
|
|
173
|
+
horizontal ? a.x - b.x || a.y - b.y : a.y - b.y || a.x - b.x,
|
|
174
|
+
);
|
|
175
|
+
for (const [id, point] of ordered) {
|
|
176
|
+
const col = Math.round(point.x / pitch.x);
|
|
177
|
+
const row = Math.round(point.y / pitch.y);
|
|
178
|
+
for (let step = 0; ; step++) {
|
|
179
|
+
// Alternate sides so the node stays as close as possible to where ELK wanted it.
|
|
180
|
+
const offset = step === 0 ? 0 : (step % 2 ? 1 : -1) * Math.ceil(step / 2);
|
|
181
|
+
const cell = horizontal ? { col, row: row + offset } : { col: col + offset, row };
|
|
182
|
+
const key = `${cell.col}:${cell.row}`;
|
|
183
|
+
if (!taken.has(key)) {
|
|
184
|
+
taken.add(key);
|
|
185
|
+
result.set(id, { x: cell.col * pitch.x, y: cell.row * pitch.y });
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return result;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Close surplus gutters between groups along the cross axis. ELK spaces compound nodes by their
|
|
195
|
+
* own padded extents, so after quantization two groups can sit a whole lattice pitch further apart
|
|
196
|
+
* than the frame clearance needs while another pair sits at the minimum — the uneven gaps a reader
|
|
197
|
+
* notices first. Groups are walked in cross-axis order and each, with everything beyond it, is
|
|
198
|
+
* pulled back by whole pitches while its gap to the previous group stays above the clearance, so
|
|
199
|
+
* every node remains on the lattice and cross-group connectors stay straight.
|
|
200
|
+
*/
|
|
201
|
+
const compactGroups = (
|
|
202
|
+
graph: MermaidGraph,
|
|
203
|
+
positions: Map<string, Scene.Point>,
|
|
204
|
+
cell: Cell,
|
|
205
|
+
pitch: Pitch,
|
|
206
|
+
horizontal: boolean,
|
|
207
|
+
): Map<string, Scene.Point> => {
|
|
208
|
+
const axis = horizontal ? 'y' : 'x';
|
|
209
|
+
const step = horizontal ? pitch.y : pitch.x;
|
|
210
|
+
const size = horizontal ? cell.h : cell.w;
|
|
211
|
+
const groups = graph.groups
|
|
212
|
+
.filter((group) => group.children.some((id) => positions.has(id)))
|
|
213
|
+
.map((group) => {
|
|
214
|
+
const coords = group.children.flatMap((id) => (positions.has(id) ? [positions.get(id)![axis]] : []));
|
|
215
|
+
return { ids: group.children, start: Math.min(...coords), end: Math.max(...coords) };
|
|
216
|
+
})
|
|
217
|
+
.sort((left, right) => left.start - right.start);
|
|
218
|
+
|
|
219
|
+
const result = new Map(positions);
|
|
220
|
+
for (let index = 1; index < groups.length; index++) {
|
|
221
|
+
const previous = groups[index - 1];
|
|
222
|
+
const current = groups[index];
|
|
223
|
+
// Gap between the two frames: last member's far edge plus pad, to first member's near edge less pad.
|
|
224
|
+
const gap = current.start - FRAME_PAD - (previous.end + size + FRAME_PAD);
|
|
225
|
+
let surplus = Math.floor((gap - FRAME_GAP) / step) * step;
|
|
226
|
+
if (surplus <= 0) {
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
// Everything at or beyond this group's start shifts together, so relative order is preserved —
|
|
230
|
+
// but only onto free lattice points: an ungrouped node moving with the block must not land on
|
|
231
|
+
// a node that stays put.
|
|
232
|
+
const moving = [...result].filter(([, point]) => point[axis] >= current.start);
|
|
233
|
+
const staying = new Set(
|
|
234
|
+
[...result].filter(([, point]) => point[axis] < current.start).map(([, point]) => `${point.x}:${point.y}`),
|
|
235
|
+
);
|
|
236
|
+
const collides = (shift: number) =>
|
|
237
|
+
moving.some(([, point]) =>
|
|
238
|
+
staying.has(`${axis === 'x' ? point.x - shift : point.x}:${axis === 'y' ? point.y - shift : point.y}`),
|
|
239
|
+
);
|
|
240
|
+
while (surplus > 0 && collides(surplus)) {
|
|
241
|
+
surplus -= step;
|
|
242
|
+
}
|
|
243
|
+
if (surplus <= 0) {
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
for (const [id, point] of moving) {
|
|
247
|
+
result.set(id, { ...point, [axis]: point[axis] - surplus });
|
|
248
|
+
}
|
|
249
|
+
for (const later of groups.slice(index)) {
|
|
250
|
+
later.start -= surplus;
|
|
251
|
+
later.end -= surplus;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return result;
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
type ElkEdge = { id: string; sources: string[]; targets: string[] };
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Edges for ELK's layering. Inheritance points at the abstraction, which ranks ABOVE its
|
|
261
|
+
* subtypes — so those edges are reversed, as `relationRanks` does for class diagrams; has-many
|
|
262
|
+
* and containment already flow owner-above-owned. In `columns`, the root lays out groups without
|
|
263
|
+
* seeing inside them (`SEPARATE_CHILDREN`), so every edge is lifted to its endpoints' root-level
|
|
264
|
+
* representatives — the group, or the node itself when ungrouped — and the groups fall into
|
|
265
|
+
* dependency order; edges within one group stay where they are for that group's own layout.
|
|
266
|
+
* A reference between packages always orders the packages; within one it ranks per `layering`.
|
|
267
|
+
*/
|
|
268
|
+
const layeringEdges = (graph: MermaidGraph, arrangement: Arrangement, layering: Layering): ElkEdge[] => {
|
|
269
|
+
const groupOf = new Map(graph.groups.flatMap((group) => group.children.map((id) => [id, group.id] as const)));
|
|
270
|
+
const betweenGroups = (edge: MermaidEdge) =>
|
|
271
|
+
groupOf.has(edge.from) && groupOf.has(edge.to) && groupOf.get(edge.from) !== groupOf.get(edge.to);
|
|
272
|
+
const oriented = graph.edges.flatMap((edge) => {
|
|
273
|
+
if (edge.kind === 'inheritance') {
|
|
274
|
+
return [{ from: edge.to, to: edge.from }];
|
|
275
|
+
}
|
|
276
|
+
if (edge.kind !== 'reference' || layering === 'down' || betweenGroups(edge)) {
|
|
277
|
+
return [{ from: edge.from, to: edge.to }];
|
|
278
|
+
}
|
|
279
|
+
return layering === 'up' ? [{ from: edge.to, to: edge.from }] : [];
|
|
280
|
+
});
|
|
281
|
+
if (arrangement === 'layered') {
|
|
282
|
+
return oriented.map((edge, index) => ({ id: `edge-${index}`, sources: [edge.from], targets: [edge.to] }));
|
|
283
|
+
}
|
|
284
|
+
const edges: ElkEdge[] = [];
|
|
285
|
+
const seen = new Set<string>();
|
|
286
|
+
oriented.forEach((edge, index) => {
|
|
287
|
+
const from = groupOf.get(edge.from);
|
|
288
|
+
const to = groupOf.get(edge.to);
|
|
289
|
+
if (from !== undefined && from === to) {
|
|
290
|
+
edges.push({ id: `edge-${index}`, sources: [edge.from], targets: [edge.to] });
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
const lifted = { from: from ?? edge.from, to: to ?? edge.to };
|
|
294
|
+
const key = `${lifted.from}->${lifted.to}`;
|
|
295
|
+
if (lifted.from !== lifted.to && !seen.has(key)) {
|
|
296
|
+
seen.add(key);
|
|
297
|
+
edges.push({ id: `group-edge-${index}`, sources: [lifted.from], targets: [lifted.to] });
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
return edges;
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* ELK compound layout. Groups become hierarchical nodes whose children ELK lays out inside
|
|
305
|
+
* them; positions come back parent-relative and are flattened here. Node origins are then
|
|
306
|
+
* quantized to the lattice and frames recomputed from their quantized members, so quantization
|
|
307
|
+
* can never break containment.
|
|
308
|
+
*/
|
|
309
|
+
const place = async (
|
|
310
|
+
graph: MermaidGraph,
|
|
311
|
+
cell: Cell,
|
|
312
|
+
pitch: Pitch,
|
|
313
|
+
order: Order,
|
|
314
|
+
arrangement: Arrangement,
|
|
315
|
+
layering: Layering,
|
|
316
|
+
): Promise<Map<string, Scene.Point>> => {
|
|
317
|
+
const horizontal = graph.direction === 'LR' || graph.direction === 'RL';
|
|
318
|
+
const grouped = new Set(graph.groups.flatMap((group) => group.children));
|
|
319
|
+
const leaf = (id: string): ElkNode => ({ id, width: cell.w, height: cell.h });
|
|
320
|
+
// Spacing is per compound node, not inherited: left unset, ELK packs a group's members at its
|
|
321
|
+
// 20px default — inside the router's clearance — and every route through that group is fenced.
|
|
322
|
+
// Matching the lattice gutters keeps ELK's output close to lattice points before quantization.
|
|
323
|
+
const spacing = {
|
|
324
|
+
'elk.layered.spacing.nodeNodeBetweenLayers': String(horizontal ? pitch.x - cell.w : pitch.y - cell.h),
|
|
325
|
+
'elk.spacing.nodeNode': String(horizontal ? pitch.y - cell.h : pitch.x - cell.w),
|
|
326
|
+
};
|
|
327
|
+
const flow = {
|
|
328
|
+
'elk.algorithm': 'layered',
|
|
329
|
+
'elk.direction': ELK_DIRECTION[graph.direction],
|
|
330
|
+
'elk.layered.nodePlacement.strategy': 'BRANDES_KOEPF',
|
|
331
|
+
'elk.layered.considerModelOrder.strategy': order === 'model' ? 'PREFER_NODES' : 'NONE',
|
|
332
|
+
};
|
|
333
|
+
const children: ElkNode[] = [
|
|
334
|
+
...graph.nodes.filter((node) => !grouped.has(node.id)).map((node) => leaf(node.id)),
|
|
335
|
+
...graph.groups
|
|
336
|
+
.filter((group) => group.children.length > 0)
|
|
337
|
+
.map((group) => ({
|
|
338
|
+
id: group.id,
|
|
339
|
+
layoutOptions: {
|
|
340
|
+
...spacing,
|
|
341
|
+
// Columns: each group is its own flow; the root only arranges the groups.
|
|
342
|
+
...(arrangement === 'columns' ? flow : {}),
|
|
343
|
+
'elk.padding': `[top=${FRAME_PAD + FRAME_LABEL_H},left=${FRAME_PAD},bottom=${FRAME_PAD},right=${FRAME_PAD}]`,
|
|
344
|
+
},
|
|
345
|
+
children: group.children.map(leaf),
|
|
346
|
+
})),
|
|
347
|
+
];
|
|
348
|
+
|
|
349
|
+
const elk = new ELK();
|
|
350
|
+
const result = await elk.layout({
|
|
351
|
+
id: 'root',
|
|
352
|
+
layoutOptions:
|
|
353
|
+
arrangement === 'columns'
|
|
354
|
+
? {
|
|
355
|
+
// Groups side by side across the flow, each laid out internally first.
|
|
356
|
+
...flow,
|
|
357
|
+
'elk.direction': ACROSS[graph.direction],
|
|
358
|
+
'elk.hierarchyHandling': 'SEPARATE_CHILDREN',
|
|
359
|
+
...spacing,
|
|
360
|
+
}
|
|
361
|
+
: {
|
|
362
|
+
...flow,
|
|
363
|
+
// Lay out the whole hierarchy in one pass so edges crossing group borders still order layers.
|
|
364
|
+
'elk.hierarchyHandling': 'INCLUDE_CHILDREN',
|
|
365
|
+
...spacing,
|
|
366
|
+
},
|
|
367
|
+
children,
|
|
368
|
+
edges: layeringEdges(graph, arrangement, layering),
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
const raw = new Map<string, Scene.Point>();
|
|
372
|
+
const visit = (node: ElkNode, offset: Scene.Point) => {
|
|
373
|
+
for (const child of node.children ?? []) {
|
|
374
|
+
const x = offset.x + (child.x ?? 0);
|
|
375
|
+
const y = offset.y + (child.y ?? 0);
|
|
376
|
+
if (child.children?.length) {
|
|
377
|
+
visit(child, { x, y });
|
|
378
|
+
} else {
|
|
379
|
+
raw.set(child.id, { x, y });
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
visit(result, { x: 0, y: 0 });
|
|
384
|
+
|
|
385
|
+
return compactGroups(graph, quantize(raw, pitch, horizontal), cell, pitch, horizontal);
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Align each package to its cross-package edges: among the whole-row shifts that put one of its
|
|
390
|
+
* members on the row of that member's partner, take the one leaving its edges the least total row
|
|
391
|
+
* offset (nearest to no shift on a tie), onto free lattice points only. Two sweeps, since a partner
|
|
392
|
+
* may itself move in the first.
|
|
393
|
+
*/
|
|
394
|
+
const alignGroups = (
|
|
395
|
+
graph: MermaidGraph,
|
|
396
|
+
positions: Map<string, Scene.Point>,
|
|
397
|
+
direction: Direction,
|
|
398
|
+
alignment: Alignment,
|
|
399
|
+
): Map<string, Scene.Point> => {
|
|
400
|
+
if (alignment === 'none') {
|
|
401
|
+
return positions;
|
|
402
|
+
}
|
|
403
|
+
const axis = direction === 'LR' || direction === 'RL' ? 'x' : 'y';
|
|
404
|
+
const groupOf = new Map(graph.groups.flatMap((group) => group.children.map((id) => [id, group.id] as const)));
|
|
405
|
+
const result = new Map(positions);
|
|
406
|
+
for (let sweep = 0; sweep < 2; sweep++) {
|
|
407
|
+
for (const group of graph.groups) {
|
|
408
|
+
const members = new Set(group.children.filter((id) => result.has(id)));
|
|
409
|
+
// Each cross-package edge, as the member's coordinate and the partner's along the flow.
|
|
410
|
+
const pairs = graph.edges.flatMap((edge) => {
|
|
411
|
+
const [member, partner] = members.has(edge.from) ? [edge.from, edge.to] : [edge.to, edge.from];
|
|
412
|
+
const here = result.get(member);
|
|
413
|
+
const there = result.get(partner);
|
|
414
|
+
return members.has(member) && !members.has(partner) && groupOf.has(partner) && here && there
|
|
415
|
+
? [{ here: here[axis], there: there[axis] }]
|
|
416
|
+
: [];
|
|
417
|
+
});
|
|
418
|
+
if (pairs.length === 0) {
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
const offset = (shift: number) => pairs.reduce((sum, pair) => sum + Math.abs(pair.here + shift - pair.there), 0);
|
|
422
|
+
const shifts = [...new Set([0, ...pairs.map((pair) => pair.there - pair.here)])].sort(
|
|
423
|
+
(left, right) => offset(left) - offset(right) || Math.abs(left) - Math.abs(right),
|
|
424
|
+
);
|
|
425
|
+
const others = new Set(
|
|
426
|
+
[...result].filter(([id]) => !members.has(id)).map(([, point]) => `${point.x}:${point.y}`),
|
|
427
|
+
);
|
|
428
|
+
const shift = shifts.find(
|
|
429
|
+
(candidate) =>
|
|
430
|
+
![...members].some((id) => {
|
|
431
|
+
const point = result.get(id);
|
|
432
|
+
return (
|
|
433
|
+
point &&
|
|
434
|
+
others.has(
|
|
435
|
+
`${axis === 'x' ? point.x + candidate : point.x}:${axis === 'y' ? point.y + candidate : point.y}`,
|
|
436
|
+
)
|
|
437
|
+
);
|
|
438
|
+
}),
|
|
439
|
+
);
|
|
440
|
+
if (!shift) {
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
for (const id of members) {
|
|
444
|
+
const point = result.get(id);
|
|
445
|
+
if (point) {
|
|
446
|
+
result.set(id, { ...point, [axis]: point[axis] + shift });
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return result;
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
/** Node rects at the origin plus the frame around each group's members. */
|
|
455
|
+
const frame = (graph: MermaidGraph, positions: Map<string, Scene.Point>, cell: Cell): Placement => {
|
|
456
|
+
const minX = Math.min(0, ...[...positions.values()].map((point) => point.x));
|
|
457
|
+
const minY = Math.min(0, ...[...positions.values()].map((point) => point.y));
|
|
458
|
+
const nodes = new Map<string, Rect>(
|
|
459
|
+
[...positions].map(([id, point]) => [id, { x: point.x - minX, y: point.y - minY, w: cell.w, h: cell.h }]),
|
|
460
|
+
);
|
|
461
|
+
|
|
462
|
+
const frames = new Map<string, Rect>();
|
|
463
|
+
for (const group of graph.groups) {
|
|
464
|
+
const members = group.children.flatMap((id) => nodes.get(id) ?? []);
|
|
465
|
+
if (members.length === 0) {
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
const x = Math.min(...members.map((rect) => rect.x)) - FRAME_PAD;
|
|
469
|
+
const y = Math.min(...members.map((rect) => rect.y)) - FRAME_PAD - FRAME_LABEL_H;
|
|
470
|
+
const right = Math.max(...members.map((rect) => rect.x + rect.w)) + FRAME_PAD;
|
|
471
|
+
const bottom = Math.max(...members.map((rect) => rect.y + rect.h)) + FRAME_PAD;
|
|
472
|
+
frames.set(group.id, { x, y, w: right - x, h: bottom - y });
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return { nodes, frames };
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
type Ports = { start?: number; end?: number };
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Straightened ports: when an edge's nodes overlap on the cross axis, both terminals take one
|
|
482
|
+
* shared coordinate inside the overlap, so the connector is a single straight run instead of a
|
|
483
|
+
* jog between nearly aligned nodes. Distinct edges leaving the same node side take distinct
|
|
484
|
+
* coordinates. Mirrors the straightening pass in the UML emitter.
|
|
485
|
+
*/
|
|
486
|
+
const straighten = (
|
|
487
|
+
edges: readonly MermaidEdge[],
|
|
488
|
+
nodes: Map<string, Rect>,
|
|
489
|
+
isHorizontal: (edge: MermaidEdge) => boolean,
|
|
490
|
+
): Map<MermaidEdge, Ports> => {
|
|
491
|
+
const ports = new Map<MermaidEdge, Ports>();
|
|
492
|
+
const taken = new Map<string, number[]>();
|
|
493
|
+
for (const edge of edges) {
|
|
494
|
+
const from = nodes.get(edge.from);
|
|
495
|
+
const to = nodes.get(edge.to);
|
|
496
|
+
if (!from || !to) {
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
const horizontal = isHorizontal(edge);
|
|
500
|
+
const sameLane = horizontal ? from.x === to.x : from.y === to.y;
|
|
501
|
+
const alongY = horizontal ? sameLane : !sameLane;
|
|
502
|
+
const lo = (alongY ? Math.max(from.x, to.x) : Math.max(from.y, to.y)) + GRID_FINE;
|
|
503
|
+
const hi = (alongY ? Math.min(from.x + from.w, to.x + to.w) : Math.min(from.y + from.h, to.y + to.h)) - GRID_FINE;
|
|
504
|
+
if (lo > hi) {
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
const sides = alongY
|
|
508
|
+
? [`${edge.from}:${to.y >= from.y ? 'bottom' : 'top'}`, `${edge.to}:${to.y >= from.y ? 'top' : 'bottom'}`]
|
|
509
|
+
: [`${edge.from}:${to.x >= from.x ? 'right' : 'left'}`, `${edge.to}:${to.x >= from.x ? 'left' : 'right'}`];
|
|
510
|
+
const center = alongY ? (from.x + from.w / 2 + to.x + to.w / 2) / 2 : (from.y + from.h / 2 + to.y + to.h / 2) / 2;
|
|
511
|
+
const base = Math.min(hi, Math.max(lo, fineSnap(center)));
|
|
512
|
+
const isFree = (coord: number) =>
|
|
513
|
+
sides.every((side) => (taken.get(side) ?? []).every((used) => Math.abs(used - coord) >= GRID_FINE));
|
|
514
|
+
let chosen: number | undefined;
|
|
515
|
+
for (let step = 0; step <= Math.ceil((hi - lo) / GRID_FINE) && chosen === undefined; step++) {
|
|
516
|
+
for (const candidate of step === 0 ? [base] : [base - step * GRID_FINE, base + step * GRID_FINE]) {
|
|
517
|
+
if (candidate >= lo && candidate <= hi && isFree(candidate)) {
|
|
518
|
+
chosen = candidate;
|
|
519
|
+
break;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
if (chosen === undefined) {
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
ports.set(edge, { start: chosen, end: chosen });
|
|
527
|
+
for (const side of sides) {
|
|
528
|
+
taken.set(side, [...(taken.get(side) ?? []), chosen]);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
return ports;
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Inheritance bus (TB only): subtypes of one base that sit on one row above which the base sits
|
|
536
|
+
* connect through a shared horizontal bus and a single triangle-headed trunk, the UML tree
|
|
537
|
+
* connector. Returns the elements and the edges it consumed; edges that do not qualify are left
|
|
538
|
+
* to the per-edge router.
|
|
539
|
+
*/
|
|
540
|
+
const inheritanceBuses = (
|
|
541
|
+
edges: readonly MermaidEdge[],
|
|
542
|
+
nodes: Map<string, Rect>,
|
|
543
|
+
): { elements: Scene.Element[]; consumed: Set<MermaidEdge> } => {
|
|
544
|
+
const elements: Scene.Element[] = [];
|
|
545
|
+
const consumed = new Set<MermaidEdge>();
|
|
546
|
+
const byBase = new Map<string, MermaidEdge[]>();
|
|
547
|
+
for (const edge of edges) {
|
|
548
|
+
// A labelled edge keeps its own connector: the bus has no place to put its text.
|
|
549
|
+
if (edge.kind === 'inheritance' && !edge.label) {
|
|
550
|
+
byBase.set(edge.to, [...(byBase.get(edge.to) ?? []), edge]);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
for (const [baseId, group] of byBase) {
|
|
554
|
+
const base = nodes.get(baseId);
|
|
555
|
+
const subs = group.map((edge) => nodes.get(edge.from));
|
|
556
|
+
if (!base || group.length < 2 || subs.some((sub) => !sub)) {
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
const rects = subs as Rect[];
|
|
560
|
+
const row = rects[0].y;
|
|
561
|
+
if (rects.some((sub) => sub.y !== row) || base.y + base.h >= row) {
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
const busY = fineSnap((base.y + base.h + row) / 2);
|
|
565
|
+
const trunkX = fineSnap(base.x + base.w / 2);
|
|
566
|
+
const centers = rects.map((sub) => fineSnap(sub.x + sub.w / 2));
|
|
567
|
+
const span = [Math.min(trunkX, ...centers), Math.max(trunkX, ...centers)];
|
|
568
|
+
elements.push({
|
|
569
|
+
kind: 'line',
|
|
570
|
+
id: `${baseId}-bus`,
|
|
571
|
+
points: [
|
|
572
|
+
{ x: span[0], y: busY },
|
|
573
|
+
{ x: span[1], y: busY },
|
|
574
|
+
],
|
|
575
|
+
});
|
|
576
|
+
group.forEach((edge, index) => {
|
|
577
|
+
elements.push({
|
|
578
|
+
kind: 'line',
|
|
579
|
+
id: `${edge.from}-${baseId}-stub-${index}`,
|
|
580
|
+
points: [
|
|
581
|
+
{ x: centers[index], y: row },
|
|
582
|
+
{ x: centers[index], y: busY },
|
|
583
|
+
],
|
|
584
|
+
});
|
|
585
|
+
consumed.add(edge);
|
|
586
|
+
});
|
|
587
|
+
elements.push({
|
|
588
|
+
kind: 'arrow',
|
|
589
|
+
id: `${baseId}-inherit`,
|
|
590
|
+
start: { x: trunkX, y: busY },
|
|
591
|
+
end: { x: trunkX, y: base.y + base.h },
|
|
592
|
+
head: 'triangle',
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
return { elements, consumed };
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
type EmitOptions = {
|
|
599
|
+
origin: Scene.Point;
|
|
600
|
+
scale: number;
|
|
601
|
+
bus: boolean;
|
|
602
|
+
arrangement: Arrangement;
|
|
603
|
+
route?: Router;
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Scene commands for a placement: one world object per subgraph frame (painted first) and per
|
|
608
|
+
* node, plus an `edges` object of connectors.
|
|
609
|
+
*/
|
|
610
|
+
const emit = (
|
|
611
|
+
graph: MermaidGraph,
|
|
612
|
+
cell: Cell,
|
|
613
|
+
{ nodes, frames }: Placement,
|
|
614
|
+
{ origin, scale, bus, arrangement, route }: EmitOptions,
|
|
615
|
+
): Scene.Command[] => {
|
|
616
|
+
const horizontal = graph.direction === 'LR' || graph.direction === 'RL';
|
|
617
|
+
const at = (rect: Rect): Scene.Point => ({ x: origin.x + rect.x * scale, y: origin.y + rect.y * scale });
|
|
618
|
+
const commands: Scene.Command[] = [];
|
|
619
|
+
|
|
620
|
+
for (const group of graph.groups) {
|
|
621
|
+
const frame = frames.get(group.id);
|
|
622
|
+
if (!frame) {
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
commands.push({
|
|
626
|
+
op: 'upsert-object',
|
|
627
|
+
object: {
|
|
628
|
+
id: group.id,
|
|
629
|
+
origin: at(frame),
|
|
630
|
+
scale,
|
|
631
|
+
elements: [
|
|
632
|
+
{ kind: 'rect', id: 'frame', x: 0, y: 0, w: frame.w, h: frame.h, stroke: 'dashed', color: 'grey' },
|
|
633
|
+
// The label sits in the frame's top band rather than centered, where members would cover it.
|
|
634
|
+
...(group.label.trim()
|
|
635
|
+
? [
|
|
636
|
+
{
|
|
637
|
+
kind: 'text',
|
|
638
|
+
id: 'label',
|
|
639
|
+
x: FRAME_PAD / 2,
|
|
640
|
+
y: (FRAME_LABEL_H - LABEL_FONT.lineH) / 2,
|
|
641
|
+
text: group.label,
|
|
642
|
+
weight: 's',
|
|
643
|
+
color: 'grey',
|
|
644
|
+
} satisfies Scene.Text,
|
|
645
|
+
]
|
|
646
|
+
: []),
|
|
647
|
+
],
|
|
648
|
+
},
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
for (const node of graph.nodes) {
|
|
653
|
+
const rect = nodes.get(node.id);
|
|
654
|
+
if (!rect) {
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
commands.push({
|
|
658
|
+
op: 'upsert-object',
|
|
659
|
+
object: {
|
|
660
|
+
id: node.id,
|
|
661
|
+
origin: at(rect),
|
|
662
|
+
scale,
|
|
663
|
+
...(node.ref ? { ref: node.ref } : {}),
|
|
664
|
+
elements: [{ kind: 'rect', id: 'box', x: 0, y: 0, w: cell.w, h: cell.h, text: node.label }],
|
|
665
|
+
},
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
if (graph.edges.length > 0) {
|
|
670
|
+
// Frames are containers, not obstacles: only node rects block routes.
|
|
671
|
+
const router = route ?? makeAvoidingRouter([...nodes.values()], zRouter, { step: GRID / 4 });
|
|
672
|
+
const buses = bus && !horizontal ? inheritanceBuses(graph.edges, nodes) : { elements: [], consumed: new Set() };
|
|
673
|
+
const routed = graph.edges.filter((edge) => !buses.consumed.has(edge));
|
|
674
|
+
// In columns the root level runs across the flow, so an edge between two of its members — groups
|
|
675
|
+
// or ungrouped nodes — runs across it too.
|
|
676
|
+
const groupOf = new Map(graph.groups.flatMap((group) => group.children.map((id) => [id, group.id] as const)));
|
|
677
|
+
const rootOf = (id: string) => groupOf.get(id) ?? id;
|
|
678
|
+
const isHorizontal = (edge: MermaidEdge) =>
|
|
679
|
+
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
|
+
);
|
|
697
|
+
// Paired nudges first: both ends move together, so a straight edge stays straight; single-ended
|
|
698
|
+
// ones for when only one end is crowded.
|
|
699
|
+
const NUDGES: [number, number][] = [
|
|
700
|
+
[0, 0],
|
|
701
|
+
[1, 1],
|
|
702
|
+
[-1, -1],
|
|
703
|
+
[2, 2],
|
|
704
|
+
[-2, -2],
|
|
705
|
+
[1, 0],
|
|
706
|
+
[-1, 0],
|
|
707
|
+
[0, 1],
|
|
708
|
+
[0, -1],
|
|
709
|
+
];
|
|
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
|
+
},
|
|
731
|
+
});
|
|
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;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
return best ?? direct;
|
|
746
|
+
};
|
|
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;
|
|
752
|
+
}
|
|
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) });
|
|
759
|
+
}
|
|
760
|
+
elements.push({
|
|
761
|
+
kind: 'arrow',
|
|
762
|
+
id,
|
|
763
|
+
start: points[points.length - 2],
|
|
764
|
+
end: points[points.length - 1],
|
|
765
|
+
...markers(edge.kind),
|
|
766
|
+
});
|
|
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
|
+
});
|
|
778
|
+
}
|
|
779
|
+
});
|
|
780
|
+
commands.push({ op: 'upsert-object', object: { id: 'edges', origin, scale, elements } });
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
return commands;
|
|
784
|
+
};
|
|
785
|
+
|
|
786
|
+
const objectsOf = (commands: readonly Scene.Command[]): Scene.WorldObject[] =>
|
|
787
|
+
commands.flatMap((command) => (command.op === 'upsert-object' ? [command.object] : []));
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* Lay out a mermaid flowchart: place once per lattice × ordering, emit with and without the
|
|
791
|
+
* inheritance bus, analyze every candidate, and let the objective choose. All candidates come back
|
|
792
|
+
* ranked so a bench can show what lost and why. Async because ELK is.
|
|
793
|
+
*/
|
|
794
|
+
export const layout = async (source: string, options: CompileOptions = {}): Promise<Result> => {
|
|
795
|
+
const graph = parse(source);
|
|
796
|
+
const { origin = { x: 0, y: 0 }, scale = 1, maxWidth = MAX_W, objective = Objective.DEFAULT, route } = options;
|
|
797
|
+
const cell = measureCell(graph, maxWidth);
|
|
798
|
+
const lattices = typeof options.lattice === 'number' ? [options.lattice] : (options.lattice ?? LATTICES);
|
|
799
|
+
const orders = options.order ?? ORDERS;
|
|
800
|
+
const buses = options.bus ?? [true, false];
|
|
801
|
+
// Columns only mean something with two or more groups to arrange.
|
|
802
|
+
const grouped = graph.groups.filter((group) => group.children.length > 0).length;
|
|
803
|
+
const arrangements = options.arrangement ?? (grouped >= 2 ? ARRANGEMENTS : ['layered']);
|
|
804
|
+
const layerings = options.layering ?? LAYERINGS;
|
|
805
|
+
const alignments = options.alignment ?? ALIGNMENTS;
|
|
806
|
+
invariant(
|
|
807
|
+
lattices.length > 0 &&
|
|
808
|
+
orders.length > 0 &&
|
|
809
|
+
buses.length > 0 &&
|
|
810
|
+
arrangements.length > 0 &&
|
|
811
|
+
layerings.length > 0 &&
|
|
812
|
+
alignments.length > 0,
|
|
813
|
+
'every candidate axis needs a value',
|
|
814
|
+
);
|
|
815
|
+
|
|
816
|
+
const candidates: Candidate[] = [];
|
|
817
|
+
// Knobs often reach the same placement (a graph with no in-package references layers the same
|
|
818
|
+
// either way; equal-height packages align the same every way); each is routed and graded once.
|
|
819
|
+
const seen = new Set<string>();
|
|
820
|
+
for (const lattice of lattices) {
|
|
821
|
+
for (const order of orders) {
|
|
822
|
+
for (const arrangement of arrangements) {
|
|
823
|
+
for (const layering of layerings) {
|
|
824
|
+
const pitch = pitchFor(graph, cell, lattice);
|
|
825
|
+
const positions = await place(graph, cell, pitch, order, arrangement, layering);
|
|
826
|
+
// Alignment moves packages relative to each other, which only columns set side by side.
|
|
827
|
+
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;
|
|
836
|
+
}
|
|
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
|
+
}
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
const { chosen, ranked } = Objective.select(objective, candidates);
|
|
858
|
+
return { commands: chosen.candidate.commands, chosen, ranked };
|
|
859
|
+
};
|
|
860
|
+
|
|
861
|
+
/** The chosen layout's scene commands; see {@link layout} for the candidates and verdicts. */
|
|
862
|
+
export const compile = async (source: string, options: CompileOptions = {}): Promise<Scene.Command[]> =>
|
|
863
|
+
(await layout(source, options)).commands;
|