@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
|
@@ -0,0 +1,891 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
//
|
|
6
|
+
// Lays out a semantic diagram: the DSL's `node`/`edge`/`group` statements with whatever hints the
|
|
7
|
+
// author gave, from exact coordinates down to nothing. Placement is a scored grid search
|
|
8
|
+
// (`semantic-place.ts`), routing a channel router (`semantic-route.ts`); the few best placements
|
|
9
|
+
// are routed for real and the layout objective, with the appeal library, picks one. When nothing
|
|
10
|
+
// was hinted, `compile` also runs the mermaid engine on the same graph and offers its placements,
|
|
11
|
+
// and its own layout, as candidates — so an unhinted diagram is never worse than the engine's.
|
|
12
|
+
//
|
|
13
|
+
|
|
14
|
+
import * as Appeal from './appeal.ts';
|
|
15
|
+
import * as Diagnostics from './diagnostics.ts';
|
|
16
|
+
import * as Layout from './layout.ts';
|
|
17
|
+
import * as MermaidEngine from './mermaid-engine.ts';
|
|
18
|
+
import * as Objective from './objective.ts';
|
|
19
|
+
import * as Scene from './scene.ts';
|
|
20
|
+
import * as Place from './semantic-place.ts';
|
|
21
|
+
import * as Route from './semantic-route.ts';
|
|
22
|
+
import * as Semantic from './semantic.ts';
|
|
23
|
+
import { GRID, type Rect } from './uml-grid.ts';
|
|
24
|
+
|
|
25
|
+
const FONT = Layout.FONT_METRICS.m;
|
|
26
|
+
const LABEL_FONT = Layout.FONT_METRICS.s;
|
|
27
|
+
const MIN_W = GRID * 3;
|
|
28
|
+
const MAX_W = GRID * 6;
|
|
29
|
+
const MIN_H = GRID * 3;
|
|
30
|
+
const PAD_X = GRID;
|
|
31
|
+
const PAD_Y = GRID / 2;
|
|
32
|
+
/** Frame inset around a group's members, the same on every side; the title sits in the top inset. */
|
|
33
|
+
const FRAME_PAD = GRID;
|
|
34
|
+
/** Room a frame's title takes above its members, when sizing the default pitch. */
|
|
35
|
+
const FRAME_LABEL_H = GRID;
|
|
36
|
+
/** Extra gutter where two frames face each other across a row gutter, so packages read as separate. */
|
|
37
|
+
const FRAME_STACK_GAP = GRID * 2;
|
|
38
|
+
|
|
39
|
+
const GROUP_COLORS: readonly Scene.Color[] = [
|
|
40
|
+
'light-blue',
|
|
41
|
+
'light-green',
|
|
42
|
+
'yellow',
|
|
43
|
+
'light-violet',
|
|
44
|
+
'orange',
|
|
45
|
+
'light-red',
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
const snapUp = (value: number) => Math.ceil(value / GRID) * GRID;
|
|
49
|
+
|
|
50
|
+
/** One box size for every node, from the longest label wrapped at the widest box. */
|
|
51
|
+
export const measureBox = (labels: readonly string[]): Semantic.Size => {
|
|
52
|
+
const longest = Math.max(0, ...labels.map((label) => label.length));
|
|
53
|
+
const w = snapUp(Math.min(MAX_W, Math.max(MIN_W, longest * FONT.charW + PAD_X * 2)));
|
|
54
|
+
const perLine = Math.max(1, Math.floor((w - PAD_X) / FONT.charW));
|
|
55
|
+
const lines = Math.max(1, ...labels.map((label) => Math.ceil(label.length / perLine)));
|
|
56
|
+
const h = snapUp(Math.max(MIN_H, (lines - 1) * FONT.lineH + FONT.lineH / 1.35 + PAD_Y * 2));
|
|
57
|
+
return { w, h };
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** Cell pitch for a box: a box-wide gutter across, and room for two frame borders, a title and a gap down. */
|
|
61
|
+
export const pitchFor = (box: Semantic.Size): Semantic.Size => ({
|
|
62
|
+
w: snapUp(Math.max(box.w * 2, box.w + FRAME_PAD * 2 + GRID)),
|
|
63
|
+
h: snapUp(Math.max(box.h * 2, box.h + FRAME_PAD * 2 + FRAME_LABEL_H + GRID)),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
export type SolveOptions = {
|
|
67
|
+
/** Objective that picks among routed candidates (default: the layout objective plus appeal). */
|
|
68
|
+
objective?: Objective.Objective;
|
|
69
|
+
/** Placements to refine besides the search's own. */
|
|
70
|
+
seeds?: readonly Map<string, Place.Cell>[];
|
|
71
|
+
/** Complete layouts to compare against, such as the mermaid engine's. */
|
|
72
|
+
alternatives?: readonly Scene.Command[][];
|
|
73
|
+
/** Independent placement searches (default 6). */
|
|
74
|
+
restarts?: number;
|
|
75
|
+
/** Routes the mermaid engine's candidates off the thread; see `MermaidEngine.CompileOptions`. */
|
|
76
|
+
emitCandidate?: MermaidEngine.CompileOptions['emitCandidate'];
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export type Solution = {
|
|
80
|
+
commands: Scene.Command[];
|
|
81
|
+
issues: Semantic.Issue[];
|
|
82
|
+
report: Diagnostics.Report;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
type Geometry = {
|
|
86
|
+
box: Semantic.Size;
|
|
87
|
+
pitch: Semantic.Size;
|
|
88
|
+
rects: Map<string, Rect>;
|
|
89
|
+
frames: Map<string, Rect>;
|
|
90
|
+
context: Route.Context;
|
|
91
|
+
/** Scene x (y) of a fractional cell coordinate: `n` is a cell centre, `n.5` the channel after it. */
|
|
92
|
+
cellX: (col: number) => number;
|
|
93
|
+
cellY: (row: number) => number;
|
|
94
|
+
bounds: Rect;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const relationsOf = (diagram: Semantic.Diagram) => ({
|
|
98
|
+
relations: diagram.nodes.flatMap((node) =>
|
|
99
|
+
node.relations.map((relation) => ({
|
|
100
|
+
from: node.id,
|
|
101
|
+
kind: relation.kind,
|
|
102
|
+
target: relation.target,
|
|
103
|
+
soft: relation.soft,
|
|
104
|
+
})),
|
|
105
|
+
),
|
|
106
|
+
groupRelations: diagram.groups.flatMap((group) =>
|
|
107
|
+
group.relations.map((relation) => ({
|
|
108
|
+
from: group.id,
|
|
109
|
+
kind: relation.kind,
|
|
110
|
+
target: relation.target,
|
|
111
|
+
soft: relation.soft,
|
|
112
|
+
})),
|
|
113
|
+
),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
/** Outweighs the bends a trunk costs each subtype in the placer, which routes edges one by one. */
|
|
117
|
+
const SIBLING_WEIGHT = 40;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Soft relations that line up the spokes of one trunk across the flow — the subtypes of one
|
|
121
|
+
* abstraction, or the far ends of a `bus` — so the trunk can gather them from one side.
|
|
122
|
+
*/
|
|
123
|
+
const siblingRelations = (diagram: Semantic.Diagram): Place.PlaceRelation[] => {
|
|
124
|
+
const kind = diagram.flow === 'down' || diagram.flow === 'up' ? 'same-row' : 'same-col';
|
|
125
|
+
const families = new Map<string, string[]>();
|
|
126
|
+
for (const edge of diagram.edges) {
|
|
127
|
+
const key = edge.bus ?? Semantic.inheritanceKey(edge);
|
|
128
|
+
if (key === undefined) {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const spoke = key.endsWith('|in') ? edge.from.node : edge.to.node;
|
|
132
|
+
const family = families.get(key) ?? [];
|
|
133
|
+
if (!family.includes(spoke)) {
|
|
134
|
+
families.set(key, [...family, spoke]);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return [...families.values()].flatMap(([first, ...rest]) =>
|
|
138
|
+
rest.map((from) => ({ from, kind, target: first, soft: true, weight: SIBLING_WEIGHT })),
|
|
139
|
+
);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/** Grid coordinates to scene rects, frames and routing channels. */
|
|
143
|
+
const geometry = (
|
|
144
|
+
diagram: Semantic.Diagram,
|
|
145
|
+
cells: Map<string, Place.Cell>,
|
|
146
|
+
box: Semantic.Size,
|
|
147
|
+
pitch: Semantic.Size,
|
|
148
|
+
): Geometry => {
|
|
149
|
+
const all = [...cells.values()];
|
|
150
|
+
const minCol = Math.min(0, ...all.map((cell) => cell.col));
|
|
151
|
+
const maxCol = Math.max(0, ...all.map((cell) => cell.col));
|
|
152
|
+
const minRow = Math.min(0, ...all.map((cell) => cell.row));
|
|
153
|
+
const maxRow = Math.max(0, ...all.map((cell) => cell.row));
|
|
154
|
+
const span = (group: string) => {
|
|
155
|
+
const members = diagram.nodes.filter((node) => node.group === group).flatMap((node) => cells.get(node.id) ?? []);
|
|
156
|
+
return members.length === 0
|
|
157
|
+
? undefined
|
|
158
|
+
: {
|
|
159
|
+
minCol: Math.min(...members.map((cell) => cell.col)),
|
|
160
|
+
maxCol: Math.max(...members.map((cell) => cell.col)),
|
|
161
|
+
minRow: Math.min(...members.map((cell) => cell.row)),
|
|
162
|
+
maxRow: Math.max(...members.map((cell) => cell.row)),
|
|
163
|
+
};
|
|
164
|
+
};
|
|
165
|
+
const spans = new Map(
|
|
166
|
+
diagram.groups.flatMap((group) => {
|
|
167
|
+
const value = span(group.id);
|
|
168
|
+
return value ? [[group.id, value] as const] : [];
|
|
169
|
+
}),
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
// Extra space after column `col` (row `row`): a group's own gap, or the clearance of stacked frames.
|
|
173
|
+
const extraAfterCol = (col: number) =>
|
|
174
|
+
Math.max(
|
|
175
|
+
0,
|
|
176
|
+
...diagram.groups.flatMap((group) => {
|
|
177
|
+
const own = spans.get(group.id);
|
|
178
|
+
return own && group.gap !== undefined
|
|
179
|
+
? group.relations.flatMap((relation) => {
|
|
180
|
+
const target = spans.get(relation.target);
|
|
181
|
+
return target &&
|
|
182
|
+
((relation.kind === 'right-of' && own.minCol === col + 1) ||
|
|
183
|
+
(relation.kind === 'left-of' && target.minCol === col + 1))
|
|
184
|
+
? [group.gap ?? 0]
|
|
185
|
+
: [];
|
|
186
|
+
})
|
|
187
|
+
: [];
|
|
188
|
+
}),
|
|
189
|
+
);
|
|
190
|
+
const extraAfterRow = (row: number) => {
|
|
191
|
+
const stacked = [...spans].some(([id, upper]) =>
|
|
192
|
+
[...spans].some(
|
|
193
|
+
([other, lower]) =>
|
|
194
|
+
other !== id &&
|
|
195
|
+
upper.maxRow === row &&
|
|
196
|
+
lower.minRow === row + 1 &&
|
|
197
|
+
upper.minCol <= lower.maxCol &&
|
|
198
|
+
lower.minCol <= upper.maxCol,
|
|
199
|
+
),
|
|
200
|
+
);
|
|
201
|
+
const gaps = diagram.groups.flatMap((group) => {
|
|
202
|
+
const own = spans.get(group.id);
|
|
203
|
+
return own && group.gap !== undefined
|
|
204
|
+
? group.relations.flatMap((relation) => {
|
|
205
|
+
const target = spans.get(relation.target);
|
|
206
|
+
return target &&
|
|
207
|
+
((relation.kind === 'below' && own.minRow === row + 1) ||
|
|
208
|
+
(relation.kind === 'above' && target.minRow === row + 1))
|
|
209
|
+
? [group.gap ?? 0]
|
|
210
|
+
: [];
|
|
211
|
+
})
|
|
212
|
+
: [];
|
|
213
|
+
});
|
|
214
|
+
return Math.max(stacked ? FRAME_STACK_GAP : 0, ...gaps);
|
|
215
|
+
};
|
|
216
|
+
// An explicit grid is a lattice the boxes must stay on (a canvas lattice, say): extra space is whole cells.
|
|
217
|
+
const whole = (extra: number, step: number) => (diagram.grid && extra > 0 ? Math.ceil(extra / step) * step : extra);
|
|
218
|
+
const colX = new Map<number, number>();
|
|
219
|
+
for (let col = minCol, x = minCol * pitch.w; col <= maxCol; col++) {
|
|
220
|
+
colX.set(col, x);
|
|
221
|
+
x += pitch.w + whole(extraAfterCol(col), pitch.w);
|
|
222
|
+
}
|
|
223
|
+
const rowY = new Map<number, number>();
|
|
224
|
+
for (let row = minRow, y = minRow * pitch.h; row <= maxRow; row++) {
|
|
225
|
+
rowY.set(row, y);
|
|
226
|
+
y += pitch.h + whole(extraAfterRow(row), pitch.h);
|
|
227
|
+
}
|
|
228
|
+
const at = (lookup: Map<number, number>, min: number, max: number, step: number, value: number) =>
|
|
229
|
+
value < min
|
|
230
|
+
? (lookup.get(min) ?? 0) - (min - value) * step
|
|
231
|
+
: value > max
|
|
232
|
+
? (lookup.get(max) ?? 0) + (value - max) * step
|
|
233
|
+
: (lookup.get(value) ?? 0);
|
|
234
|
+
const fractional =
|
|
235
|
+
(lookup: Map<number, number>, min: number, max: number, step: number, size: number) => (value: number) => {
|
|
236
|
+
const low = Math.floor(value);
|
|
237
|
+
const ratio = value - low;
|
|
238
|
+
const lowCentre = at(lookup, min, max, step, low) + size / 2;
|
|
239
|
+
const highCentre = at(lookup, min, max, step, low + 1) + size / 2;
|
|
240
|
+
return lowCentre + (highCentre - lowCentre) * ratio;
|
|
241
|
+
};
|
|
242
|
+
const cellX = fractional(colX, minCol, maxCol, pitch.w, box.w);
|
|
243
|
+
const cellY = fractional(rowY, minRow, maxRow, pitch.h, box.h);
|
|
244
|
+
|
|
245
|
+
const rects = new Map<string, Rect>();
|
|
246
|
+
for (const node of diagram.nodes) {
|
|
247
|
+
const cell = cells.get(node.id);
|
|
248
|
+
if (!cell) {
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
rects.set(
|
|
252
|
+
node.id,
|
|
253
|
+
node.pin?.kind === 'point'
|
|
254
|
+
? { x: node.pin.x, y: node.pin.y, w: box.w, h: box.h }
|
|
255
|
+
: { x: cellX(cell.col) - box.w / 2, y: cellY(cell.row) - box.h / 2, w: box.w, h: box.h },
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
const frames = new Map<string, Rect>();
|
|
259
|
+
for (const group of diagram.groups) {
|
|
260
|
+
const members = diagram.nodes.filter((node) => node.group === group.id).flatMap((node) => rects.get(node.id) ?? []);
|
|
261
|
+
if (members.length === 0) {
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
const x = Math.min(...members.map((rect) => rect.x)) - FRAME_PAD;
|
|
265
|
+
const y = Math.min(...members.map((rect) => rect.y)) - FRAME_PAD;
|
|
266
|
+
const right = Math.max(...members.map((rect) => rect.x + rect.w)) + FRAME_PAD;
|
|
267
|
+
const bottom = Math.max(...members.map((rect) => rect.y + rect.h)) + FRAME_PAD;
|
|
268
|
+
frames.set(group.id, { x, y, w: right - x, h: bottom - y });
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const channels = (low: number, high: number, centre: (value: number) => number) =>
|
|
272
|
+
Array.from({ length: high - low + 2 }, (_, index) => Math.round(centre(low - 0.5 + index)));
|
|
273
|
+
const titles = diagram.groups.flatMap((group) => {
|
|
274
|
+
const frame = frames.get(group.id);
|
|
275
|
+
return frame && group.label.trim()
|
|
276
|
+
? [
|
|
277
|
+
{
|
|
278
|
+
x: frame.x + FRAME_PAD / 2,
|
|
279
|
+
y: frame.y + (FRAME_PAD - LABEL_FONT.lineH) / 2,
|
|
280
|
+
w: group.label.length * LABEL_FONT.charW,
|
|
281
|
+
h: LABEL_FONT.lineH,
|
|
282
|
+
},
|
|
283
|
+
]
|
|
284
|
+
: [];
|
|
285
|
+
});
|
|
286
|
+
const extent = [...rects.values(), ...frames.values()];
|
|
287
|
+
const bounds = {
|
|
288
|
+
x: Math.min(...extent.map((rect) => rect.x)),
|
|
289
|
+
y: Math.min(...extent.map((rect) => rect.y)),
|
|
290
|
+
w: Math.max(...extent.map((rect) => rect.x + rect.w)) - Math.min(...extent.map((rect) => rect.x)),
|
|
291
|
+
h: Math.max(...extent.map((rect) => rect.y + rect.h)) - Math.min(...extent.map((rect) => rect.y)),
|
|
292
|
+
};
|
|
293
|
+
return {
|
|
294
|
+
box,
|
|
295
|
+
pitch,
|
|
296
|
+
rects,
|
|
297
|
+
frames,
|
|
298
|
+
cellX,
|
|
299
|
+
cellY,
|
|
300
|
+
bounds,
|
|
301
|
+
context: {
|
|
302
|
+
obstacles: [...rects.values()],
|
|
303
|
+
avoid: titles,
|
|
304
|
+
channels: { xs: channels(minCol, maxCol, cellX), ys: channels(minRow, maxRow, cellY) },
|
|
305
|
+
},
|
|
306
|
+
};
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
const OPPOSITE: Record<Semantic.Side, Semantic.Side> = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' };
|
|
310
|
+
|
|
311
|
+
/** The side of the hub every spoke lies wholly beyond, if there is one. */
|
|
312
|
+
const facingSide = (hub: Rect | undefined, spokes: readonly (Rect | undefined)[]): Semantic.Side | undefined => {
|
|
313
|
+
if (!hub) {
|
|
314
|
+
return undefined;
|
|
315
|
+
}
|
|
316
|
+
const placed = spokes.flatMap((rect) => (rect ? [rect] : []));
|
|
317
|
+
const beyond: Record<Semantic.Side, (rect: Rect) => boolean> = {
|
|
318
|
+
bottom: (rect) => rect.y >= hub.y + hub.h,
|
|
319
|
+
top: (rect) => rect.y + rect.h <= hub.y,
|
|
320
|
+
right: (rect) => rect.x >= hub.x + hub.w,
|
|
321
|
+
left: (rect) => rect.x + rect.w <= hub.x,
|
|
322
|
+
};
|
|
323
|
+
return placed.length < 2 ? undefined : Semantic.SIDES.find((side) => placed.every(beyond[side]));
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
/** Routes every edge and bus over a placement and emits the scene. */
|
|
327
|
+
const draw = (
|
|
328
|
+
diagram: Semantic.Diagram,
|
|
329
|
+
geometry: Geometry,
|
|
330
|
+
): { commands: Scene.Command[]; forced: string[]; unbused: Semantic.Bus[] } => {
|
|
331
|
+
const { rects, frames, context } = geometry;
|
|
332
|
+
const grouped = Semantic.buses(diagram);
|
|
333
|
+
const facing = new Map(
|
|
334
|
+
grouped.buses.flatMap((bus) => {
|
|
335
|
+
const side = bus.implicit
|
|
336
|
+
? facingSide(
|
|
337
|
+
rects.get(bus.hub.node),
|
|
338
|
+
bus.edges.map((edge) => rects.get(edge.from.node)),
|
|
339
|
+
)
|
|
340
|
+
: undefined;
|
|
341
|
+
return side ? [[bus, side] as const] : [];
|
|
342
|
+
}),
|
|
343
|
+
);
|
|
344
|
+
// An implicit trunk only reads as one when every subtype sits on the same side of the abstraction.
|
|
345
|
+
const buses = grouped.buses.filter((bus) => !bus.implicit || facing.has(bus));
|
|
346
|
+
const single = [
|
|
347
|
+
...grouped.single,
|
|
348
|
+
...grouped.buses.filter((bus) => !buses.includes(bus)).flatMap((bus) => bus.edges),
|
|
349
|
+
];
|
|
350
|
+
const endOf = (end: Semantic.End, sides?: readonly Semantic.Side[]): Route.End | undefined => {
|
|
351
|
+
const rect = rects.get(end.node);
|
|
352
|
+
if (!rect) {
|
|
353
|
+
return undefined;
|
|
354
|
+
}
|
|
355
|
+
if (sides) {
|
|
356
|
+
return { rect, node: end.node, sides };
|
|
357
|
+
}
|
|
358
|
+
return end.soft && end.sides
|
|
359
|
+
? { rect, node: end.node, sides: Semantic.SIDES, preferred: end.sides }
|
|
360
|
+
: { rect, node: end.node, sides: end.sides ?? Semantic.SIDES };
|
|
361
|
+
};
|
|
362
|
+
const waypoint = (point: Semantic.Waypoint): Route.Waypoint => ({
|
|
363
|
+
...(point.x === undefined ? {} : { x: point.unit === 'cell' ? geometry.cellX(point.x) : point.x }),
|
|
364
|
+
...(point.y === undefined ? {} : { y: point.unit === 'cell' ? geometry.cellY(point.y) : point.y }),
|
|
365
|
+
});
|
|
366
|
+
const pieces = single.flatMap((edge): Route.Piece[] => {
|
|
367
|
+
const start = endOf(edge.from);
|
|
368
|
+
const end = endOf(edge.to);
|
|
369
|
+
return start && end
|
|
370
|
+
? [
|
|
371
|
+
{
|
|
372
|
+
id: edge.id,
|
|
373
|
+
start,
|
|
374
|
+
end,
|
|
375
|
+
via: edge.via.map(waypoint),
|
|
376
|
+
points: [],
|
|
377
|
+
significance: Semantic.significance(edge.relation, edge.stroke),
|
|
378
|
+
},
|
|
379
|
+
]
|
|
380
|
+
: [];
|
|
381
|
+
});
|
|
382
|
+
const requests = buses.flatMap((bus): Route.BusRequest[] => {
|
|
383
|
+
const side = facing.get(bus);
|
|
384
|
+
const hub = endOf(bus.hub, side && [side]);
|
|
385
|
+
const spokes = bus.edges.flatMap((edge) => {
|
|
386
|
+
const end = endOf(bus.direction === 'out' ? edge.to : edge.from, side && [OPPOSITE[side]]);
|
|
387
|
+
return end ? [{ id: edge.id, end }] : [];
|
|
388
|
+
});
|
|
389
|
+
const significance = Math.max(...bus.edges.map((edge) => Semantic.significance(edge.relation, edge.stroke)));
|
|
390
|
+
return hub && spokes.length > 0 ? [{ id: bus.id, hub, spokes, direction: bus.direction, significance }] : [];
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
const first = Route.routeAll(pieces, requests, context);
|
|
394
|
+
const spread = Route.reroute(Route.spreadPorts(first), first, context);
|
|
395
|
+
const routed = Route.separate(spread, context);
|
|
396
|
+
|
|
397
|
+
// Scene: frames first so they paint under everything, then boxes, then one object of connectors.
|
|
398
|
+
const commands: Scene.Command[] = [];
|
|
399
|
+
const origin = diagram.origin;
|
|
400
|
+
const place = (rect: Rect): Scene.Point => ({ x: origin.x + rect.x, y: origin.y + rect.y });
|
|
401
|
+
diagram.groups.forEach((group, index) => {
|
|
402
|
+
const frame = frames.get(group.id);
|
|
403
|
+
if (!frame) {
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
commands.push({
|
|
407
|
+
op: 'upsert-object',
|
|
408
|
+
object: {
|
|
409
|
+
id: group.id,
|
|
410
|
+
origin: place(frame),
|
|
411
|
+
scale: 1,
|
|
412
|
+
elements: [
|
|
413
|
+
{
|
|
414
|
+
kind: 'rect',
|
|
415
|
+
id: 'frame',
|
|
416
|
+
x: 0,
|
|
417
|
+
y: 0,
|
|
418
|
+
w: frame.w,
|
|
419
|
+
h: frame.h,
|
|
420
|
+
stroke: 'dashed',
|
|
421
|
+
fill: 'tint',
|
|
422
|
+
color: group.color ?? GROUP_COLORS[index % GROUP_COLORS.length],
|
|
423
|
+
},
|
|
424
|
+
...(group.label.trim()
|
|
425
|
+
? [
|
|
426
|
+
{
|
|
427
|
+
kind: 'text',
|
|
428
|
+
id: 'label',
|
|
429
|
+
x: FRAME_PAD / 2,
|
|
430
|
+
y: (FRAME_PAD - LABEL_FONT.lineH) / 2,
|
|
431
|
+
text: group.label,
|
|
432
|
+
weight: 's',
|
|
433
|
+
color: 'grey',
|
|
434
|
+
} satisfies Scene.Text,
|
|
435
|
+
]
|
|
436
|
+
: []),
|
|
437
|
+
],
|
|
438
|
+
},
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
for (const node of diagram.nodes) {
|
|
442
|
+
const rect = rects.get(node.id);
|
|
443
|
+
if (!rect) {
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
commands.push({
|
|
447
|
+
op: 'upsert-object',
|
|
448
|
+
object: {
|
|
449
|
+
id: node.id,
|
|
450
|
+
origin: place(rect),
|
|
451
|
+
scale: 1,
|
|
452
|
+
...(node.ref ? { ref: node.ref } : {}),
|
|
453
|
+
elements: [
|
|
454
|
+
{
|
|
455
|
+
kind: node.shape ?? 'rect',
|
|
456
|
+
id: 'box',
|
|
457
|
+
x: 0,
|
|
458
|
+
y: 0,
|
|
459
|
+
w: rect.w,
|
|
460
|
+
h: rect.h,
|
|
461
|
+
text: node.label,
|
|
462
|
+
...(node.color ? { color: node.color } : {}),
|
|
463
|
+
...(node.fill ? { fill: node.fill } : {}),
|
|
464
|
+
...(node.stroke ? { stroke: node.stroke } : {}),
|
|
465
|
+
},
|
|
466
|
+
],
|
|
467
|
+
},
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
const unbused = buses.filter((bus) => !bus.implicit && !routed.some((piece) => piece.id === `${bus.id}#trunk`));
|
|
471
|
+
if (routed.length === 0) {
|
|
472
|
+
return { commands, forced: [], unbused };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const edges = new Map(diagram.edges.map((edge) => [edge.id, edge]));
|
|
476
|
+
// A trunk label whose bus was drawn as separate edges goes on the first of them.
|
|
477
|
+
const orphaned = new Map(
|
|
478
|
+
unbused.flatMap((bus) => (bus.label === undefined ? [] : [[bus.edges[0].id, bus.label] as const])),
|
|
479
|
+
);
|
|
480
|
+
const elements: Scene.Element[] = [];
|
|
481
|
+
const labels: Route.LabelRequest[] = [];
|
|
482
|
+
const relative = (point: Scene.Point): Scene.Point => ({ x: point.x, y: point.y });
|
|
483
|
+
for (const piece of routed) {
|
|
484
|
+
const [busId, part] = piece.bus !== undefined ? piece.id.split('#') : [undefined, piece.id];
|
|
485
|
+
const edge = edges.get(part);
|
|
486
|
+
const bus = buses.find((entry) => entry.id === busId);
|
|
487
|
+
const points = piece.points.map(relative);
|
|
488
|
+
if (points.length < 2) {
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
const style = edge ?? bus?.edges[0];
|
|
492
|
+
const markers = Scene.markersOf(style ?? {});
|
|
493
|
+
const stroke = style?.stroke ? { stroke: style.stroke } : markers.dashed ? { stroke: 'dashed' as const } : {};
|
|
494
|
+
const color = style?.color ? { color: style.color } : {};
|
|
495
|
+
// A bus draws its arrowheads where the bus ends: on each spoke going out, on the trunk coming in.
|
|
496
|
+
const headed = bus ? (bus.direction === 'out' ? part !== 'trunk' : part === 'trunk') : true;
|
|
497
|
+
const id = bus && part === 'trunk' ? `${bus.id}-trunk` : part;
|
|
498
|
+
if (!headed) {
|
|
499
|
+
elements.push({ kind: 'line', id, points, ...stroke, ...color });
|
|
500
|
+
} else {
|
|
501
|
+
if (points.length > 2) {
|
|
502
|
+
elements.push({ kind: 'line', id: `${id}-path`, points: points.slice(0, -1), ...stroke, ...color });
|
|
503
|
+
}
|
|
504
|
+
const tail = style?.tail;
|
|
505
|
+
elements.push({
|
|
506
|
+
kind: 'arrow',
|
|
507
|
+
id,
|
|
508
|
+
start: points[points.length - 2],
|
|
509
|
+
end: points[points.length - 1],
|
|
510
|
+
...(style?.relation ? { relation: style.relation } : {}),
|
|
511
|
+
...(style?.head ? { head: style.head } : {}),
|
|
512
|
+
...(tail ? { tail } : {}),
|
|
513
|
+
...stroke,
|
|
514
|
+
...color,
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
const text = bus ? (part === 'trunk' ? bus.label : edge?.label) : (edge?.label ?? orphaned.get(part));
|
|
518
|
+
if (text) {
|
|
519
|
+
// A spoke's label falls back to the trunk it shares only when its own runs have no room.
|
|
520
|
+
const trunk =
|
|
521
|
+
bus && part !== 'trunk' ? routed.find((other) => other.id === `${bus.id}#trunk`)?.points : undefined;
|
|
522
|
+
labels.push({ id, text, points, ...(trunk ? { shared: trunk } : {}) });
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
const paths = routed.map((piece) => piece.points);
|
|
526
|
+
elements.push(
|
|
527
|
+
...Route.placeLabels(labels, {
|
|
528
|
+
paths,
|
|
529
|
+
boxes: [...rects.values()],
|
|
530
|
+
avoid: context.avoid,
|
|
531
|
+
frames: [...frames.values()],
|
|
532
|
+
bounds: geometry.bounds,
|
|
533
|
+
}),
|
|
534
|
+
);
|
|
535
|
+
commands.push({
|
|
536
|
+
op: 'upsert-object',
|
|
537
|
+
object: { id: Semantic.CONNECTORS, origin: { ...origin }, scale: 1, elements },
|
|
538
|
+
});
|
|
539
|
+
const forced = routed
|
|
540
|
+
.filter((piece) => piece.forced)
|
|
541
|
+
.map((piece) => (piece.bus !== undefined ? piece.id.split('#')[1] : piece.id));
|
|
542
|
+
return { commands, forced, unbused };
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
const objectsOf = (commands: readonly Scene.Command[]): Scene.WorldObject[] =>
|
|
546
|
+
commands.flatMap((command) => (command.op === 'upsert-object' ? [command.object] : []));
|
|
547
|
+
|
|
548
|
+
/** Pins that conflict with one another or with a hard relation between pinned nodes; reported and dropped. */
|
|
549
|
+
const checkPins = (
|
|
550
|
+
diagram: Semantic.Diagram,
|
|
551
|
+
pitch: Semantic.Size,
|
|
552
|
+
issues: Semantic.Issue[],
|
|
553
|
+
): Map<string, Place.Cell> => {
|
|
554
|
+
const pins = new Map<string, Place.Cell>();
|
|
555
|
+
const taken = new Map<string, string>();
|
|
556
|
+
for (const node of diagram.nodes) {
|
|
557
|
+
if (!node.pin) {
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
const cell =
|
|
561
|
+
node.pin.kind === 'cell'
|
|
562
|
+
? { col: node.pin.col, row: node.pin.row }
|
|
563
|
+
: { col: Math.round(node.pin.x / pitch.w), row: Math.round(node.pin.y / pitch.h) };
|
|
564
|
+
const key = `${cell.col}:${cell.row}`;
|
|
565
|
+
const holder = taken.get(key);
|
|
566
|
+
if (holder !== undefined) {
|
|
567
|
+
issues.push({
|
|
568
|
+
severity: 'warning',
|
|
569
|
+
message: `"${node.id}" is pinned to the cell "${holder}" already holds; its pin is ignored.`,
|
|
570
|
+
...node.pin.range,
|
|
571
|
+
});
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
taken.set(key, node.id);
|
|
575
|
+
pins.set(node.id, cell);
|
|
576
|
+
}
|
|
577
|
+
return pins;
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
const DESCRIBE: Record<Semantic.RelationKind, string> = {
|
|
581
|
+
'right-of': 'right of',
|
|
582
|
+
'left-of': 'left of',
|
|
583
|
+
'above': 'above',
|
|
584
|
+
'below': 'below',
|
|
585
|
+
'same-row': 'in the same row as',
|
|
586
|
+
'same-col': 'in the same column as',
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
/** A hard relation between two pinned nodes is decided by the pins; when they disagree the relation goes. */
|
|
590
|
+
const checkRelations = (
|
|
591
|
+
diagram: Semantic.Diagram,
|
|
592
|
+
pins: Map<string, Place.Cell>,
|
|
593
|
+
issues: Semantic.Issue[],
|
|
594
|
+
): Semantic.Diagram => ({
|
|
595
|
+
...diagram,
|
|
596
|
+
nodes: diagram.nodes.map((node) => ({
|
|
597
|
+
...node,
|
|
598
|
+
relations: node.relations.filter((relation) => {
|
|
599
|
+
const from = pins.get(node.id);
|
|
600
|
+
const target = pins.get(relation.target);
|
|
601
|
+
if (relation.soft || !from || !target) {
|
|
602
|
+
return true;
|
|
603
|
+
}
|
|
604
|
+
const ok =
|
|
605
|
+
Place.unmet(
|
|
606
|
+
{
|
|
607
|
+
nodes: [
|
|
608
|
+
{ id: node.id, pin: from },
|
|
609
|
+
{ id: relation.target, pin: target },
|
|
610
|
+
],
|
|
611
|
+
groups: [],
|
|
612
|
+
relations: [{ from: node.id, kind: relation.kind, target: relation.target, soft: false }],
|
|
613
|
+
groupRelations: [],
|
|
614
|
+
edges: [],
|
|
615
|
+
flow: diagram.flow,
|
|
616
|
+
},
|
|
617
|
+
new Map([
|
|
618
|
+
[node.id, from],
|
|
619
|
+
[relation.target, target],
|
|
620
|
+
]),
|
|
621
|
+
).relations.length === 0;
|
|
622
|
+
if (!ok) {
|
|
623
|
+
issues.push({
|
|
624
|
+
severity: 'warning',
|
|
625
|
+
message: `"${node.id}" cannot be ${DESCRIBE[relation.kind]} "${relation.target}": both are pinned elsewhere. The relation is ignored.`,
|
|
626
|
+
...relation.range,
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
return ok;
|
|
630
|
+
}),
|
|
631
|
+
})),
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
const sizesOf = (diagram: Semantic.Diagram) => {
|
|
635
|
+
const box = diagram.box ?? measureBox(diagram.nodes.map((node) => node.label));
|
|
636
|
+
return { box, pitch: diagram.grid ?? pitchFor(box) };
|
|
637
|
+
};
|
|
638
|
+
|
|
639
|
+
type Prepared = {
|
|
640
|
+
diagram: Semantic.Diagram;
|
|
641
|
+
input: Place.PlaceInput;
|
|
642
|
+
box: Semantic.Size;
|
|
643
|
+
pitch: Semantic.Size;
|
|
644
|
+
issues: Semantic.Issue[];
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
const prepare = (source: Semantic.Diagram): Prepared => {
|
|
648
|
+
const issues: Semantic.Issue[] = [];
|
|
649
|
+
const { box, pitch } = sizesOf(source);
|
|
650
|
+
const pins = checkPins(source, pitch, issues);
|
|
651
|
+
const diagram = checkRelations(source, pins, issues);
|
|
652
|
+
const { relations, groupRelations } = relationsOf(diagram);
|
|
653
|
+
const input: Place.PlaceInput = {
|
|
654
|
+
nodes: diagram.nodes.map((node) => ({ id: node.id, group: node.group, pin: pins.get(node.id) })),
|
|
655
|
+
groups: diagram.groups.map((group) => group.id),
|
|
656
|
+
// Appended after the stated relations, whose positions `unmetIssues` maps back to their source.
|
|
657
|
+
relations: [...relations, ...siblingRelations(diagram)],
|
|
658
|
+
groupRelations,
|
|
659
|
+
edges: diagram.edges.map((edge) => ({
|
|
660
|
+
from: edge.from.node,
|
|
661
|
+
to: edge.to.node,
|
|
662
|
+
upward: Semantic.pointsUp(edge.relation),
|
|
663
|
+
// Preferred sides are the router's to weigh; the placement keeps every side open.
|
|
664
|
+
sides: { start: edge.from.soft ? undefined : edge.from.sides, end: edge.to.soft ? undefined : edge.to.sides },
|
|
665
|
+
})),
|
|
666
|
+
flow: diagram.flow,
|
|
667
|
+
box: { x: box.w / pitch.w / 2, y: box.h / pitch.h / 2 },
|
|
668
|
+
shapes: new Map(diagram.groups.map((group) => [group.id, { maxWidth: group.maxWidth, compact: group.compact }])),
|
|
669
|
+
// The placement counts cells, which are wider than tall, so the drawing's ratio is converted to theirs.
|
|
670
|
+
...(diagram.aspect === undefined ? {} : { aspect: (diagram.aspect * pitch.h) / pitch.w }),
|
|
671
|
+
};
|
|
672
|
+
return { diagram, input, box, pitch, issues };
|
|
673
|
+
};
|
|
674
|
+
|
|
675
|
+
/** Relations the chosen layout breaks, as warnings on the relation's own source. */
|
|
676
|
+
const unmetIssues = (prepared: Prepared, cells: Map<string, Place.Cell>): Semantic.Issue[] => {
|
|
677
|
+
const unmet = Place.unmet(prepared.input, cells);
|
|
678
|
+
const nodeRelations = prepared.diagram.nodes.flatMap((node) =>
|
|
679
|
+
node.relations.map((relation) => ({ owner: node.id, relation })),
|
|
680
|
+
);
|
|
681
|
+
const groupRelations = prepared.diagram.groups.flatMap((group) =>
|
|
682
|
+
group.relations.map((relation) => ({ owner: group.id, relation })),
|
|
683
|
+
);
|
|
684
|
+
return [
|
|
685
|
+
...unmet.relations.map((position) => nodeRelations[position]),
|
|
686
|
+
...unmet.groupRelations.map((position) => groupRelations[position]),
|
|
687
|
+
].flatMap((entry) =>
|
|
688
|
+
entry
|
|
689
|
+
? [
|
|
690
|
+
{
|
|
691
|
+
severity: 'warning' as const,
|
|
692
|
+
message: `Could not place "${entry.owner}" ${DESCRIBE[entry.relation.kind]} "${entry.relation.target}" together with the other constraints; it was relaxed.`,
|
|
693
|
+
...entry.relation.range,
|
|
694
|
+
},
|
|
695
|
+
]
|
|
696
|
+
: [],
|
|
697
|
+
);
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
const DEFAULT_OBJECTIVE = Appeal.objective(Objective.DEFAULT);
|
|
701
|
+
|
|
702
|
+
type Candidate = {
|
|
703
|
+
commands: Scene.Command[];
|
|
704
|
+
cells?: Map<string, Place.Cell>;
|
|
705
|
+
/** Edges the router could not draw as asked. */
|
|
706
|
+
forced: readonly string[];
|
|
707
|
+
/** Buses drawn as separate edges because no junction served them. */
|
|
708
|
+
unbused: readonly Semantic.Bus[];
|
|
709
|
+
layout: Objective.Layout;
|
|
710
|
+
};
|
|
711
|
+
|
|
712
|
+
const candidateOf = (
|
|
713
|
+
commands: Scene.Command[],
|
|
714
|
+
cells?: Map<string, Place.Cell>,
|
|
715
|
+
forced: readonly string[] = [],
|
|
716
|
+
unbused: readonly Semantic.Bus[] = [],
|
|
717
|
+
): Candidate => {
|
|
718
|
+
const objects = objectsOf(commands);
|
|
719
|
+
return { commands, cells, forced, unbused, layout: { objects, report: Diagnostics.analyze(objects) } };
|
|
720
|
+
};
|
|
721
|
+
|
|
722
|
+
/** Relations that line a member up with a node outside its group, by the axis they align on. */
|
|
723
|
+
const ALIGNS: Record<Semantic.RelationKind, 'col' | 'row'> = {
|
|
724
|
+
'same-col': 'col',
|
|
725
|
+
'above': 'col',
|
|
726
|
+
'below': 'col',
|
|
727
|
+
'same-row': 'row',
|
|
728
|
+
'right-of': 'row',
|
|
729
|
+
'left-of': 'row',
|
|
730
|
+
};
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Groups whose frame spans a whole column or row with none of their members in it, each blamed on
|
|
734
|
+
* a relation tying a member to a node outside the group, which is what pulls the members apart.
|
|
735
|
+
*/
|
|
736
|
+
export const stretchIssues = (diagram: Semantic.Diagram, cells: Map<string, Place.Cell>): Semantic.Issue[] =>
|
|
737
|
+
diagram.groups.flatMap((group): Semantic.Issue[] => {
|
|
738
|
+
const members = new Set(diagram.nodes.filter((node) => node.group === group.id).map((node) => node.id));
|
|
739
|
+
const placed = [...members].flatMap((id) => cells.get(id) ?? []);
|
|
740
|
+
if (placed.length < 2) {
|
|
741
|
+
return [];
|
|
742
|
+
}
|
|
743
|
+
const empty = (axis: 'col' | 'row') => {
|
|
744
|
+
const values = new Set(placed.map((cell) => cell[axis]));
|
|
745
|
+
const [low, high] = [Math.min(...values), Math.max(...values)];
|
|
746
|
+
return Array.from({ length: high - low + 1 }, (_, index) => low + index).filter((value) => !values.has(value));
|
|
747
|
+
};
|
|
748
|
+
const outside = diagram.nodes.flatMap((node) =>
|
|
749
|
+
node.relations
|
|
750
|
+
// A soft relation is already the remedy the warning prescribes, so only hard ones are blamed.
|
|
751
|
+
.filter((relation) => !relation.soft && members.has(node.id) !== members.has(relation.target))
|
|
752
|
+
.map((relation) => ({ owner: node.id, relation })),
|
|
753
|
+
);
|
|
754
|
+
return (['col', 'row'] as const).flatMap((axis): Semantic.Issue[] => {
|
|
755
|
+
const gaps = empty(axis);
|
|
756
|
+
const blame = outside.find(({ relation }) => ALIGNS[relation.kind] === axis);
|
|
757
|
+
if (gaps.length === 0 || !blame) {
|
|
758
|
+
return [];
|
|
759
|
+
}
|
|
760
|
+
return [
|
|
761
|
+
{
|
|
762
|
+
severity: 'warning',
|
|
763
|
+
message: `Group "${group.id}" is stretched: ${gaps.length === 1 ? `a ${axis === 'col' ? 'column' : 'row'}` : `${gaps.length} ${axis === 'col' ? 'columns' : 'rows'}`} inside its frame ${gaps.length === 1 ? 'holds' : 'hold'} none of its members, because "${blame.owner}" is ${DESCRIBE[blame.relation.kind]} "${blame.relation.target}" outside it. Relate the groups instead, or make it soft with \`~\`.`,
|
|
764
|
+
...blame.relation.range,
|
|
765
|
+
},
|
|
766
|
+
];
|
|
767
|
+
});
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
/** Lays out a semantic diagram synchronously with the grid search alone. */
|
|
771
|
+
export const solve = (source: Semantic.Diagram, options: SolveOptions = {}): Solution => {
|
|
772
|
+
const prepared = prepare(source);
|
|
773
|
+
const { diagram, input, box, pitch, issues } = prepared;
|
|
774
|
+
if (diagram.nodes.length === 0) {
|
|
775
|
+
return { commands: [], issues, report: Diagnostics.analyze([]) };
|
|
776
|
+
}
|
|
777
|
+
const placed = Place.place(input, { restarts: options.restarts ?? 6, seeds: options.seeds ?? [], keep: 3 });
|
|
778
|
+
const candidates = [
|
|
779
|
+
...placed.map(({ cells }) => {
|
|
780
|
+
const drawn = draw(diagram, geometry(diagram, cells, box, pitch));
|
|
781
|
+
return candidateOf(drawn.commands, cells, drawn.forced, drawn.unbused);
|
|
782
|
+
}),
|
|
783
|
+
...(options.alternatives ?? []).map((commands) => candidateOf(commands)),
|
|
784
|
+
];
|
|
785
|
+
const { chosen } = Objective.select(options.objective ?? DEFAULT_OBJECTIVE, candidates);
|
|
786
|
+
const cells = chosen.candidate.cells;
|
|
787
|
+
return {
|
|
788
|
+
commands: chosen.candidate.commands,
|
|
789
|
+
issues: [
|
|
790
|
+
...issues,
|
|
791
|
+
...(cells ? unmetIssues(prepared, cells) : []),
|
|
792
|
+
...(cells ? stretchIssues(diagram, cells) : []),
|
|
793
|
+
...chosen.candidate.unbused.map((bus) => ({
|
|
794
|
+
severity: 'warning' as const,
|
|
795
|
+
message: `No junction lets the edges ${bus.direction === 'in' ? 'into' : 'out of'} "${bus.hub.node}" share one trunk here, so the bus is drawn as separate edges.`,
|
|
796
|
+
...bus.edges[0].range,
|
|
797
|
+
})),
|
|
798
|
+
...chosen.candidate.forced.flatMap((id) => {
|
|
799
|
+
const edge = diagram.edges.find((entry) => entry.id === id);
|
|
800
|
+
return edge
|
|
801
|
+
? [
|
|
802
|
+
{
|
|
803
|
+
severity: 'warning' as const,
|
|
804
|
+
message: `No route fits the sides and waypoints given for ${edge.from.node} -> ${edge.to.node}; it was routed freely.`,
|
|
805
|
+
...edge.range,
|
|
806
|
+
},
|
|
807
|
+
]
|
|
808
|
+
: [];
|
|
809
|
+
}),
|
|
810
|
+
],
|
|
811
|
+
report: chosen.candidate.layout.report,
|
|
812
|
+
};
|
|
813
|
+
};
|
|
814
|
+
|
|
815
|
+
/** Cells of each node in an emitted layout, by rank of its distinct x and y. */
|
|
816
|
+
const cellsOf = (commands: readonly Scene.Command[], ids: ReadonlySet<string>): Map<string, Place.Cell> => {
|
|
817
|
+
const origins = objectsOf(commands).flatMap((object) =>
|
|
818
|
+
ids.has(object.id) && object.origin ? [{ id: object.id, origin: object.origin }] : [],
|
|
819
|
+
);
|
|
820
|
+
const xs = [...new Set(origins.map(({ origin }) => origin.x))].sort((left, right) => left - right);
|
|
821
|
+
const ys = [...new Set(origins.map(({ origin }) => origin.y))].sort((left, right) => left - right);
|
|
822
|
+
return new Map(origins.map(({ id, origin }) => [id, { col: xs.indexOf(origin.x), row: ys.indexOf(origin.y) }]));
|
|
823
|
+
};
|
|
824
|
+
|
|
825
|
+
/** The mermaid engine's output restyled with the diagram's node and edge styles, which mermaid cannot carry. */
|
|
826
|
+
const restyle = (diagram: Semantic.Diagram, commands: readonly Scene.Command[]): Scene.Command[] => {
|
|
827
|
+
const nodes = new Map(diagram.nodes.map((node) => [node.id, node]));
|
|
828
|
+
const edges = new Map(diagram.edges.map((edge) => [edge.id, edge]));
|
|
829
|
+
return commands.map((command): Scene.Command => {
|
|
830
|
+
if (command.op !== 'upsert-object') {
|
|
831
|
+
return command;
|
|
832
|
+
}
|
|
833
|
+
const node = nodes.get(command.object.id);
|
|
834
|
+
const elements = command.object.elements.map((element): Scene.Element => {
|
|
835
|
+
if (node && element.kind === 'rect' && element.id === 'box') {
|
|
836
|
+
return {
|
|
837
|
+
...element,
|
|
838
|
+
kind: node.shape ?? 'rect',
|
|
839
|
+
...(node.color ? { color: node.color } : {}),
|
|
840
|
+
...(node.fill ? { fill: node.fill } : {}),
|
|
841
|
+
...(node.stroke ? { stroke: node.stroke } : {}),
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
const edge = command.object.id === Semantic.CONNECTORS ? edges.get(element.id.replace(/-path$/, '')) : undefined;
|
|
845
|
+
if (edge && element.kind === 'arrow') {
|
|
846
|
+
return {
|
|
847
|
+
...element,
|
|
848
|
+
...(edge.relation ? { relation: edge.relation } : {}),
|
|
849
|
+
...(edge.head ? { head: edge.head } : {}),
|
|
850
|
+
...(edge.stroke ? { stroke: edge.stroke } : {}),
|
|
851
|
+
...(edge.color ? { color: edge.color } : {}),
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
if (edge && element.kind === 'line') {
|
|
855
|
+
return {
|
|
856
|
+
...element,
|
|
857
|
+
...(edge.stroke ? { stroke: edge.stroke } : {}),
|
|
858
|
+
...(edge.color ? { color: edge.color } : {}),
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
return element;
|
|
862
|
+
});
|
|
863
|
+
return { ...command, object: { ...command.object, elements } };
|
|
864
|
+
});
|
|
865
|
+
};
|
|
866
|
+
|
|
867
|
+
/**
|
|
868
|
+
* Lays out a semantic diagram, also trying the mermaid engine's search when nothing was hinted:
|
|
869
|
+
* its placements seed the grid search and its own layout competes as a candidate. Async because
|
|
870
|
+
* ELK is; an engine failure only removes those candidates.
|
|
871
|
+
*/
|
|
872
|
+
export const compile = async (source: Semantic.Diagram, options: SolveOptions = {}): Promise<Solution> => {
|
|
873
|
+
const mermaid = source.hinted || source.nodes.length < 2 ? undefined : Semantic.toMermaid(source);
|
|
874
|
+
if (!mermaid) {
|
|
875
|
+
return solve(source, options);
|
|
876
|
+
}
|
|
877
|
+
const result = await MermaidEngine.layout(mermaid, {
|
|
878
|
+
origin: source.origin,
|
|
879
|
+
...(options.emitCandidate ? { emitCandidate: options.emitCandidate } : {}),
|
|
880
|
+
}).catch(() => undefined);
|
|
881
|
+
if (!result) {
|
|
882
|
+
return solve(source, options);
|
|
883
|
+
}
|
|
884
|
+
const ids = new Set(source.nodes.map((node) => node.id));
|
|
885
|
+
const seeds = result.ranked.slice(0, 4).map((entry) => cellsOf(entry.candidate.commands, ids));
|
|
886
|
+
return solve(source, {
|
|
887
|
+
...options,
|
|
888
|
+
seeds: [...(options.seeds ?? []), ...seeds],
|
|
889
|
+
alternatives: [...(options.alternatives ?? []), restyle(source, result.commands)],
|
|
890
|
+
});
|
|
891
|
+
};
|