@diagrammo/dgmo 0.26.0 → 0.28.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/README.md +3 -3
- package/dist/advanced.cjs +5651 -3193
- package/dist/advanced.d.cts +272 -58
- package/dist/advanced.d.ts +272 -58
- package/dist/advanced.js +5650 -3186
- package/dist/auto.cjs +5511 -3070
- package/dist/auto.js +116 -137
- package/dist/auto.mjs +5510 -3069
- package/dist/cli.cjs +168 -189
- package/dist/editor.cjs +4 -0
- package/dist/editor.js +4 -0
- package/dist/highlight.cjs +4 -0
- package/dist/highlight.js +4 -0
- package/dist/index.cjs +5536 -3072
- package/dist/index.d.cts +33 -8
- package/dist/index.d.ts +33 -8
- package/dist/index.js +5535 -3071
- package/dist/internal.cjs +5651 -3193
- package/dist/internal.d.cts +272 -58
- package/dist/internal.d.ts +272 -58
- package/dist/internal.js +5650 -3186
- package/dist/map-data/PROVENANCE.json +1 -1
- package/dist/map-data/airport-collisions.json +1 -0
- package/dist/map-data/airports.json +1 -0
- package/docs/language-reference.md +68 -18
- package/gallery/fixtures/boxes-and-lines-diverging.dgmo +15 -0
- package/gallery/fixtures/map-choropleth-diverging.dgmo +9 -0
- package/gallery/fixtures/map-region-values.dgmo +13 -0
- package/gallery/fixtures/map-subnational-zoom.dgmo +12 -0
- package/gallery/fixtures/map-tagged-legs.dgmo +16 -0
- package/gallery/fixtures/map-undirected-edges.dgmo +12 -0
- package/package.json +7 -3
- package/src/advanced.ts +1 -6
- package/src/auto/index.ts +1 -1
- package/src/boxes-and-lines/layout-layered.ts +722 -0
- package/src/boxes-and-lines/layout-search.ts +1200 -0
- package/src/boxes-and-lines/layout.ts +202 -571
- package/src/boxes-and-lines/parser.ts +43 -8
- package/src/boxes-and-lines/renderer.ts +223 -96
- package/src/boxes-and-lines/types.ts +9 -2
- package/src/c4/layout.ts +14 -32
- package/src/c4/parser.ts +9 -5
- package/src/c4/renderer.ts +34 -39
- package/src/class/layout.ts +118 -18
- package/src/class/parser.ts +35 -0
- package/src/class/renderer.ts +58 -2
- package/src/class/types.ts +3 -0
- package/src/cli.ts +4 -4
- package/src/completion.ts +26 -12
- package/src/cycle/layout.ts +55 -72
- package/src/cycle/renderer.ts +11 -6
- package/src/d3.ts +78 -117
- package/src/diagnostics.ts +16 -0
- package/src/echarts.ts +46 -33
- package/src/editor/keywords.ts +4 -0
- package/src/er/layout.ts +114 -22
- package/src/er/parser.ts +28 -0
- package/src/er/renderer.ts +55 -2
- package/src/er/types.ts +3 -0
- package/src/gantt/renderer.ts +46 -38
- package/src/gantt/resolver.ts +9 -2
- package/src/graph/edge-spline.ts +29 -0
- package/src/graph/flowchart-parser.ts +34 -1
- package/src/graph/flowchart-renderer.ts +78 -64
- package/src/graph/layout.ts +206 -23
- package/src/graph/notes.ts +21 -0
- package/src/graph/state-parser.ts +26 -1
- package/src/graph/state-renderer.ts +78 -64
- package/src/graph/types.ts +13 -0
- package/src/index.ts +1 -1
- package/src/infra/layout.ts +46 -26
- package/src/infra/renderer.ts +16 -7
- package/src/journey-map/layout.ts +38 -49
- package/src/journey-map/renderer.ts +22 -45
- package/src/kanban/renderer.ts +15 -6
- package/src/label-layout.ts +3 -3
- package/src/map/completion.ts +77 -22
- package/src/map/context-labels.ts +101 -25
- package/src/map/data/PROVENANCE.json +1 -1
- package/src/map/data/airport-collisions.json +1 -0
- package/src/map/data/airports.json +1 -0
- package/src/map/data/types.ts +19 -0
- package/src/map/layout.ts +1212 -96
- package/src/map/legend-band.ts +2 -2
- package/src/map/load-data.ts +10 -1
- package/src/map/parser.ts +61 -32
- package/src/map/renderer.ts +284 -12
- package/src/map/resolved-types.ts +15 -1
- package/src/map/resolver.ts +132 -12
- package/src/map/types.ts +28 -8
- package/src/migrate/embedded.ts +9 -7
- package/src/mindmap/text-wrap.ts +13 -14
- package/src/org/layout.ts +19 -17
- package/src/org/renderer.ts +11 -4
- package/src/palettes/color-utils.ts +82 -21
- package/src/palettes/index.ts +0 -19
- package/src/palettes/registry.ts +1 -1
- package/src/palettes/types.ts +2 -2
- package/src/pert/layout.ts +48 -40
- package/src/pert/renderer.ts +30 -43
- package/src/pyramid/renderer.ts +4 -5
- package/src/raci/renderer.ts +34 -68
- package/src/render.ts +1 -1
- package/src/ring/renderer.ts +1 -2
- package/src/sequence/parser.ts +100 -22
- package/src/sequence/renderer.ts +75 -50
- package/src/sitemap/layout.ts +27 -19
- package/src/sitemap/renderer.ts +12 -5
- package/src/tech-radar/renderer.ts +11 -35
- package/src/utils/arrow-markers.ts +51 -0
- package/src/utils/fit-canvas.ts +64 -0
- package/src/utils/legend-constants.ts +8 -54
- package/src/utils/legend-d3.ts +10 -7
- package/src/utils/legend-layout.ts +7 -4
- package/src/utils/legend-types.ts +10 -4
- package/src/utils/note-box/constants.ts +25 -0
- package/src/utils/note-box/index.ts +11 -0
- package/src/utils/note-box/metrics.ts +90 -0
- package/src/utils/note-box/svg.ts +331 -0
- package/src/utils/notes/bounds.ts +30 -0
- package/src/utils/notes/build.ts +131 -0
- package/src/utils/notes/index.ts +18 -0
- package/src/utils/notes/model.ts +19 -0
- package/src/utils/notes/parse.ts +131 -0
- package/src/utils/notes/place.ts +177 -0
- package/src/utils/notes/resolve.ts +88 -0
- package/src/utils/number-format.ts +36 -0
- package/src/utils/parsing.ts +41 -0
- package/src/utils/reserved-key-registry.ts +4 -0
- package/src/utils/text-measure.ts +122 -0
- package/src/wireframe/layout.ts +4 -2
- package/src/wireframe/renderer.ts +8 -6
- package/src/palettes/dracula.ts +0 -68
- package/src/palettes/gruvbox.ts +0 -85
- package/src/palettes/monokai.ts +0 -68
- package/src/palettes/one-dark.ts +0 -70
- package/src/palettes/rose-pine.ts +0 -84
- package/src/palettes/solarized.ts +0 -77
|
@@ -2,26 +2,28 @@
|
|
|
2
2
|
// Boxes and Lines Diagram — Layout Engine
|
|
3
3
|
// ============================================================
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// 3. bend count (prefer fewer corners)
|
|
5
|
+
// Node sizing + the public `layoutBoxesAndLines` entry. Placement and edge
|
|
6
|
+
// routing are delegated to the dagre placement-search engine (layout-search.ts);
|
|
7
|
+
// this module owns node sizing, parallel-edge fan offsets, and note floating —
|
|
8
|
+
// the engine-agnostic post-passes applied to whatever the engine returns.
|
|
10
9
|
|
|
11
|
-
import ELK from 'elkjs/lib/elk.bundled.js';
|
|
12
10
|
import type { ParsedBoxesAndLines, BLNode, BLGroup } from './types';
|
|
11
|
+
import { measureText, wrapTextToWidth } from '../utils/text-measure';
|
|
12
|
+
import {
|
|
13
|
+
resolveNotes,
|
|
14
|
+
buildPlacedNotes,
|
|
15
|
+
noteCanvasShift,
|
|
16
|
+
type PlacedNote,
|
|
17
|
+
} from '../utils/notes';
|
|
13
18
|
|
|
14
19
|
// ── Constants ──────────────────────────────────────────────
|
|
15
20
|
const MARGIN = 40;
|
|
16
|
-
const CONTAINER_PAD_X = 30;
|
|
17
|
-
const CONTAINER_PAD_TOP = 40;
|
|
18
|
-
const CONTAINER_PAD_BOTTOM = 24;
|
|
19
21
|
const MAX_PARALLEL_EDGES = 5;
|
|
20
22
|
const PARALLEL_SPACING = 22;
|
|
21
23
|
|
|
22
24
|
const PHI = 1.618;
|
|
23
|
-
const NODE_HEIGHT = 60;
|
|
24
|
-
const NODE_WIDTH = Math.round(NODE_HEIGHT * PHI);
|
|
25
|
+
export const NODE_HEIGHT = 60;
|
|
26
|
+
export const NODE_WIDTH = Math.round(NODE_HEIGHT * PHI);
|
|
25
27
|
const DESC_NODE_WIDTH = 140;
|
|
26
28
|
const DESC_FONT_SIZE = 10;
|
|
27
29
|
const DESC_LINE_HEIGHT = 1.4;
|
|
@@ -31,6 +33,11 @@ const MAX_DESC_LINES = 6;
|
|
|
31
33
|
const MAX_LABEL_LINES = 3;
|
|
32
34
|
const LABEL_LINE_HEIGHT = 1.3;
|
|
33
35
|
const LABEL_PAD = 12;
|
|
36
|
+
// Bottom value-row reserved on a DESCRIBED node under `show-values`: a thin
|
|
37
|
+
// divider + a "Metric: value" footer line (replaces the old corner badge).
|
|
38
|
+
const VALUE_ROW_FONT = 11;
|
|
39
|
+
const VALUE_ROW_H =
|
|
40
|
+
SEPARATOR_GAP + VALUE_ROW_FONT * DESC_LINE_HEIGHT + DESC_PADDING;
|
|
34
41
|
|
|
35
42
|
// ── Result types ───────────────────────────────────────────
|
|
36
43
|
|
|
@@ -40,6 +47,8 @@ export interface BLLayoutNode {
|
|
|
40
47
|
readonly y: number;
|
|
41
48
|
readonly width: number;
|
|
42
49
|
readonly height: number;
|
|
50
|
+
/** A note floated beside this box (never moves the box). */
|
|
51
|
+
readonly note?: PlacedNote;
|
|
43
52
|
}
|
|
44
53
|
|
|
45
54
|
export interface BLLayoutEdge {
|
|
@@ -113,15 +122,14 @@ function estimateLabelLines(label: string, nodeWidth = NODE_WIDTH): number {
|
|
|
113
122
|
if (!part) continue;
|
|
114
123
|
words.push(...splitCamelCase(part));
|
|
115
124
|
}
|
|
125
|
+
const maxTextWidth = nodeWidth - 24;
|
|
116
126
|
for (let fontSize = 13; fontSize >= 9; fontSize--) {
|
|
117
|
-
|
|
118
|
-
const maxChars = Math.floor((nodeWidth - 24) / charWidth);
|
|
119
|
-
if (maxChars < 2) continue;
|
|
127
|
+
if (maxTextWidth < measureText('MM', fontSize)) continue;
|
|
120
128
|
let lines = 1;
|
|
121
129
|
let current = '';
|
|
122
130
|
for (const word of words) {
|
|
123
131
|
const test = current ? `${current} ${word}` : word;
|
|
124
|
-
if (test
|
|
132
|
+
if (measureText(test, fontSize) <= maxTextWidth) {
|
|
125
133
|
current = test;
|
|
126
134
|
} else {
|
|
127
135
|
lines++;
|
|
@@ -133,35 +141,31 @@ function estimateLabelLines(label: string, nodeWidth = NODE_WIDTH): number {
|
|
|
133
141
|
return MAX_LABEL_LINES;
|
|
134
142
|
}
|
|
135
143
|
|
|
136
|
-
function computeNodeSize(
|
|
144
|
+
export function computeNodeSize(
|
|
145
|
+
node: BLNode,
|
|
146
|
+
reserveValueRow: boolean
|
|
147
|
+
): { width: number; height: number } {
|
|
137
148
|
if (!node.description || node.description.length === 0) {
|
|
138
149
|
return { width: NODE_WIDTH, height: NODE_HEIGHT };
|
|
139
150
|
}
|
|
140
151
|
const w = DESC_NODE_WIDTH;
|
|
141
152
|
const labelLines = estimateLabelLines(node.label, w);
|
|
142
153
|
const labelHeight = labelLines * 13 * LABEL_LINE_HEIGHT + LABEL_PAD;
|
|
143
|
-
const
|
|
154
|
+
const maxTextWidth = w - 24;
|
|
144
155
|
let totalRenderedLines = 0;
|
|
145
156
|
for (const line of node.description) {
|
|
146
|
-
if (line
|
|
157
|
+
if (measureText(line, DESC_FONT_SIZE) <= maxTextWidth) {
|
|
147
158
|
totalRenderedLines += 1;
|
|
148
159
|
} else {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
if (test.length <= charsPerLine) {
|
|
157
|
-
current = test;
|
|
158
|
-
} else {
|
|
159
|
-
if (current) lineCount++;
|
|
160
|
-
current = fitted;
|
|
160
|
+
// Hard-break long words to match the renderer's slicing behaviour.
|
|
161
|
+
totalRenderedLines += wrapTextToWidth(
|
|
162
|
+
line,
|
|
163
|
+
DESC_FONT_SIZE,
|
|
164
|
+
maxTextWidth,
|
|
165
|
+
{
|
|
166
|
+
hardBreak: true,
|
|
161
167
|
}
|
|
162
|
-
|
|
163
|
-
if (current) lineCount++;
|
|
164
|
-
totalRenderedLines += lineCount;
|
|
168
|
+
).length;
|
|
165
169
|
}
|
|
166
170
|
}
|
|
167
171
|
totalRenderedLines = Math.min(totalRenderedLines, MAX_DESC_LINES);
|
|
@@ -172,220 +176,11 @@ function computeNodeSize(node: BLNode): { width: number; height: number } {
|
|
|
172
176
|
SEPARATOR_GAP +
|
|
173
177
|
DESC_PADDING +
|
|
174
178
|
descriptionHeight +
|
|
175
|
-
DESC_PADDING
|
|
179
|
+
DESC_PADDING +
|
|
180
|
+
(reserveValueRow ? VALUE_ROW_H : 0);
|
|
176
181
|
return { width: w, height: Math.max(NODE_HEIGHT, totalHeight) };
|
|
177
182
|
}
|
|
178
183
|
|
|
179
|
-
// ── ELK types (minimal) ────────────────────────────────────
|
|
180
|
-
|
|
181
|
-
interface ElkPoint {
|
|
182
|
-
x: number;
|
|
183
|
-
y: number;
|
|
184
|
-
}
|
|
185
|
-
interface ElkEdgeSection {
|
|
186
|
-
id?: string;
|
|
187
|
-
startPoint: ElkPoint;
|
|
188
|
-
endPoint: ElkPoint;
|
|
189
|
-
bendPoints?: ElkPoint[];
|
|
190
|
-
}
|
|
191
|
-
interface ElkLayoutEdge {
|
|
192
|
-
id: string;
|
|
193
|
-
sources: string[];
|
|
194
|
-
targets: string[];
|
|
195
|
-
sections?: ElkEdgeSection[];
|
|
196
|
-
/** ELK marks the container whose local frame the section coords are in */
|
|
197
|
-
container?: string;
|
|
198
|
-
}
|
|
199
|
-
interface ElkNode {
|
|
200
|
-
id: string;
|
|
201
|
-
width?: number;
|
|
202
|
-
height?: number;
|
|
203
|
-
x?: number;
|
|
204
|
-
y?: number;
|
|
205
|
-
children?: ElkNode[];
|
|
206
|
-
edges?: ElkLayoutEdge[];
|
|
207
|
-
labels?: { text: string; width?: number; height?: number }[];
|
|
208
|
-
layoutOptions?: Record<string, string>;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
let elkInstance: InstanceType<typeof ELK> | null = null;
|
|
212
|
-
function getElk(): InstanceType<typeof ELK> {
|
|
213
|
-
if (!elkInstance) elkInstance = new ELK();
|
|
214
|
-
return elkInstance;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
// ── ELK option variants ────────────────────────────────────
|
|
218
|
-
|
|
219
|
-
interface Variant {
|
|
220
|
-
name: string;
|
|
221
|
-
options: Record<string, string>;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
function baseOptions(): Record<string, string> {
|
|
225
|
-
return {
|
|
226
|
-
'elk.algorithm': 'layered',
|
|
227
|
-
// INCLUDE_CHILDREN lets ELK route edges across container boundaries.
|
|
228
|
-
'elk.hierarchyHandling': 'INCLUDE_CHILDREN',
|
|
229
|
-
'elk.edgeRouting': 'ORTHOGONAL',
|
|
230
|
-
'elk.layered.unnecessaryBendpoints': 'true',
|
|
231
|
-
// Let edges leave from top/bottom of nodes (not just the flow-direction
|
|
232
|
-
// sides) when it reduces crossings.
|
|
233
|
-
'elk.layered.allowNonFlowPortsToSwitchSides': 'true',
|
|
234
|
-
};
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
function bkBaseline(): Record<string, string> {
|
|
238
|
-
return {
|
|
239
|
-
...baseOptions(),
|
|
240
|
-
'elk.layered.nodePlacement.strategy': 'BRANDES_KOEPF',
|
|
241
|
-
'elk.layered.nodePlacement.bk.fixedAlignment': 'BALANCED',
|
|
242
|
-
'elk.layered.nodePlacement.bk.edgeStraightening': 'IMPROVE_STRAIGHTNESS',
|
|
243
|
-
'elk.layered.compaction.connectedComponents': 'true',
|
|
244
|
-
'elk.layered.spacing.nodeNodeBetweenLayers': '90',
|
|
245
|
-
'elk.spacing.nodeNode': '55',
|
|
246
|
-
'elk.spacing.edgeNode': '55',
|
|
247
|
-
'elk.spacing.edgeEdge': '18',
|
|
248
|
-
};
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
function getVariants(): Variant[] {
|
|
252
|
-
const bk = bkBaseline();
|
|
253
|
-
return [
|
|
254
|
-
{
|
|
255
|
-
name: 'bk-baseline',
|
|
256
|
-
options: {
|
|
257
|
-
...bk,
|
|
258
|
-
'elk.layered.crossingMinimization.greedySwitch.type': 'ONE_SIDED',
|
|
259
|
-
},
|
|
260
|
-
},
|
|
261
|
-
{
|
|
262
|
-
name: 'bk-aggressive',
|
|
263
|
-
options: {
|
|
264
|
-
...bk,
|
|
265
|
-
'elk.layered.crossingMinimization.greedySwitch.type': 'TWO_SIDED',
|
|
266
|
-
'elk.layered.thoroughness': '50',
|
|
267
|
-
},
|
|
268
|
-
},
|
|
269
|
-
{
|
|
270
|
-
name: 'bk-wide',
|
|
271
|
-
options: {
|
|
272
|
-
...bk,
|
|
273
|
-
'elk.layered.crossingMinimization.greedySwitch.type': 'TWO_SIDED',
|
|
274
|
-
'elk.layered.thoroughness': '50',
|
|
275
|
-
'elk.spacing.nodeNode': '70',
|
|
276
|
-
'elk.spacing.edgeNode': '75',
|
|
277
|
-
'elk.spacing.edgeEdge': '22',
|
|
278
|
-
'elk.layered.spacing.nodeNodeBetweenLayers': '120',
|
|
279
|
-
},
|
|
280
|
-
},
|
|
281
|
-
{
|
|
282
|
-
name: 'longest-path',
|
|
283
|
-
options: {
|
|
284
|
-
...bk,
|
|
285
|
-
'elk.layered.layering.strategy': 'LONGEST_PATH',
|
|
286
|
-
'elk.layered.crossingMinimization.greedySwitch.type': 'TWO_SIDED',
|
|
287
|
-
'elk.layered.thoroughness': '50',
|
|
288
|
-
},
|
|
289
|
-
},
|
|
290
|
-
{
|
|
291
|
-
name: 'bounded-width',
|
|
292
|
-
options: {
|
|
293
|
-
...bk,
|
|
294
|
-
'elk.layered.layering.strategy': 'COFFMAN_GRAHAM',
|
|
295
|
-
'elk.layered.layering.coffmanGraham.layerBound': '3',
|
|
296
|
-
'elk.layered.crossingMinimization.greedySwitch.type': 'TWO_SIDED',
|
|
297
|
-
'elk.layered.thoroughness': '50',
|
|
298
|
-
},
|
|
299
|
-
},
|
|
300
|
-
];
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
// ── Crossing / quality counters ────────────────────────────
|
|
304
|
-
|
|
305
|
-
/**
|
|
306
|
-
* Count visible edge crossings in a layout. Each pair of edge segments is
|
|
307
|
-
* checked for proper intersection (interior, not endpoint-touch).
|
|
308
|
-
* O((E × P)²) where P = avg points per edge. For E~30, P~5, ~22k pairs ≈ 1-3ms.
|
|
309
|
-
*/
|
|
310
|
-
function countCrossings(edges: readonly BLLayoutEdge[]): number {
|
|
311
|
-
let count = 0;
|
|
312
|
-
for (let i = 0; i < edges.length; i++) {
|
|
313
|
-
// In-bounds by loop guard.
|
|
314
|
-
const edgeI = edges[i]!;
|
|
315
|
-
const a = edgeI.points;
|
|
316
|
-
if (a.length < 2) continue;
|
|
317
|
-
for (let j = i + 1; j < edges.length; j++) {
|
|
318
|
-
// In-bounds by loop guard.
|
|
319
|
-
const edgeJ = edges[j]!;
|
|
320
|
-
const b = edgeJ.points;
|
|
321
|
-
if (b.length < 2) continue;
|
|
322
|
-
// Skip edges that share an endpoint — they meet at a node, not a crossing
|
|
323
|
-
if (edgeI.source === edgeJ.source) continue;
|
|
324
|
-
if (edgeI.source === edgeJ.target) continue;
|
|
325
|
-
if (edgeI.target === edgeJ.source) continue;
|
|
326
|
-
if (edgeI.target === edgeJ.target) continue;
|
|
327
|
-
for (let ai = 0; ai < a.length - 1; ai++) {
|
|
328
|
-
for (let bi = 0; bi < b.length - 1; bi++) {
|
|
329
|
-
// In-bounds by loop guard (ai < a.length - 1, bi < b.length - 1).
|
|
330
|
-
if (segmentsCross(a[ai]!, a[ai + 1]!, b[bi]!, b[bi + 1]!)) count++;
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
return count;
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
function segmentsCross(
|
|
339
|
-
p1: ElkPoint,
|
|
340
|
-
p2: ElkPoint,
|
|
341
|
-
p3: ElkPoint,
|
|
342
|
-
p4: ElkPoint
|
|
343
|
-
): boolean {
|
|
344
|
-
const d1x = p2.x - p1.x;
|
|
345
|
-
const d1y = p2.y - p1.y;
|
|
346
|
-
const d2x = p4.x - p3.x;
|
|
347
|
-
const d2y = p4.y - p3.y;
|
|
348
|
-
const denom = d1x * d2y - d1y * d2x;
|
|
349
|
-
if (Math.abs(denom) < 1e-9) return false;
|
|
350
|
-
const t = ((p3.x - p1.x) * d2y - (p3.y - p1.y) * d2x) / denom;
|
|
351
|
-
const s = ((p3.x - p1.x) * d1y - (p3.y - p1.y) * d1x) / denom;
|
|
352
|
-
const EPS = 0.001;
|
|
353
|
-
return t > EPS && t < 1 - EPS && s > EPS && s < 1 - EPS;
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
function countTotalBends(edges: readonly BLLayoutEdge[]): number {
|
|
357
|
-
let bends = 0;
|
|
358
|
-
for (const e of edges) bends += Math.max(0, e.points.length - 2);
|
|
359
|
-
return bends;
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
interface LayoutScore {
|
|
363
|
-
crossings: number;
|
|
364
|
-
bends: number;
|
|
365
|
-
area: number;
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
/** Up to this many crossings count as equivalent — among near-zero results,
|
|
369
|
-
* compactness decides. Prevents the optimizer picking a sprawling 0-crossing
|
|
370
|
-
* layout over a compact 1-crossing one. */
|
|
371
|
-
const CROSSINGS_FORGIVENESS = 1;
|
|
372
|
-
|
|
373
|
-
function scoreLayout(layout: BLLayoutResult): LayoutScore {
|
|
374
|
-
return {
|
|
375
|
-
crossings: countCrossings(layout.edges),
|
|
376
|
-
bends: countTotalBends(layout.edges),
|
|
377
|
-
area: layout.width * layout.height,
|
|
378
|
-
};
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
function cmpScore(a: LayoutScore, b: LayoutScore): number {
|
|
382
|
-
const aBucket = a.crossings <= CROSSINGS_FORGIVENESS ? 0 : a.crossings;
|
|
383
|
-
const bBucket = b.crossings <= CROSSINGS_FORGIVENESS ? 0 : b.crossings;
|
|
384
|
-
if (aBucket !== bBucket) return aBucket - bBucket;
|
|
385
|
-
if (a.area !== b.area) return a.area - b.area;
|
|
386
|
-
return a.bends - b.bends;
|
|
387
|
-
}
|
|
388
|
-
|
|
389
184
|
// ── Main layout ────────────────────────────────────────────
|
|
390
185
|
|
|
391
186
|
export async function layoutBoxesAndLines(
|
|
@@ -394,339 +189,175 @@ export async function layoutBoxesAndLines(
|
|
|
394
189
|
collapsedChildCounts: Map<string, number>;
|
|
395
190
|
originalGroups: readonly BLGroup[];
|
|
396
191
|
},
|
|
397
|
-
layoutOptions?: {
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
const collapsedGroupLabels = new Set<string>();
|
|
404
|
-
if (collapseInfo) {
|
|
405
|
-
const missingGroups = new Set<string>();
|
|
406
|
-
for (const og of collapseInfo.originalGroups) {
|
|
407
|
-
if (!parsed.groups.some((g) => g.label === og.label)) {
|
|
408
|
-
missingGroups.add(og.label);
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
for (const label of missingGroups) {
|
|
412
|
-
const og = collapseInfo.originalGroups.find((g) => g.label === label);
|
|
413
|
-
const parentLabel = og?.parentGroup;
|
|
414
|
-
if (!parentLabel || !missingGroups.has(parentLabel)) {
|
|
415
|
-
collapsedGroupLabels.add(label);
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
// Compute node sizes with uniform-height pass for described nodes
|
|
421
|
-
const nodeSizes = new Map<string, { width: number; height: number }>();
|
|
422
|
-
let maxDescHeight = 0;
|
|
423
|
-
for (const node of parsed.nodes) {
|
|
424
|
-
const size = hideDescriptions
|
|
425
|
-
? { width: NODE_WIDTH, height: NODE_HEIGHT }
|
|
426
|
-
: computeNodeSize(node);
|
|
427
|
-
nodeSizes.set(node.label, size);
|
|
428
|
-
if (!hideDescriptions && node.description && node.description.length > 0) {
|
|
429
|
-
maxDescHeight = Math.max(maxDescHeight, size.height);
|
|
430
|
-
}
|
|
192
|
+
layoutOptions?: {
|
|
193
|
+
hideDescriptions?: boolean;
|
|
194
|
+
collapsedNotes?: ReadonlySet<number>;
|
|
195
|
+
/** Previous node positions (label → {x,y}) for layout stability —
|
|
196
|
+
* minimizes node drift on edit/collapse. */
|
|
197
|
+
previousPositions?: ReadonlyMap<string, { x: number; y: number }>;
|
|
431
198
|
}
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
//
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
for (const node of parsed.nodes) {
|
|
451
|
-
const size = nodeSizes.get(node.label)!;
|
|
452
|
-
nodeById.set(node.label, {
|
|
453
|
-
id: node.label,
|
|
454
|
-
width: size.width,
|
|
455
|
-
height: size.height,
|
|
456
|
-
labels: [{ text: node.label }],
|
|
457
|
-
});
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
for (const group of parsed.groups) {
|
|
461
|
-
nodeById.set(gid(group.label), {
|
|
462
|
-
id: gid(group.label),
|
|
463
|
-
labels: [{ text: group.label }],
|
|
464
|
-
layoutOptions: {
|
|
465
|
-
'elk.padding': `[top=${CONTAINER_PAD_TOP},left=${CONTAINER_PAD_X},bottom=${CONTAINER_PAD_BOTTOM},right=${CONTAINER_PAD_X}]`,
|
|
466
|
-
// Suggest square-ish containers — has limited effect with
|
|
467
|
-
// INCLUDE_CHILDREN but doesn't hurt.
|
|
468
|
-
'elk.aspectRatio': '1.4',
|
|
469
|
-
},
|
|
470
|
-
children: [],
|
|
471
|
-
edges: [],
|
|
472
|
-
});
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
for (const label of collapsedGroupLabels) {
|
|
476
|
-
nodeById.set(gid(label), {
|
|
477
|
-
id: gid(label),
|
|
478
|
-
width: NODE_WIDTH,
|
|
479
|
-
height: NODE_HEIGHT,
|
|
480
|
-
labels: [{ text: label }],
|
|
481
|
-
});
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
for (const group of parsed.groups) {
|
|
485
|
-
if (group.parentGroup && nodeById.has(gid(group.parentGroup))) {
|
|
486
|
-
parentOf.set(gid(group.label), gid(group.parentGroup));
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
|
-
if (collapseInfo) {
|
|
490
|
-
for (const label of collapsedGroupLabels) {
|
|
491
|
-
const og = collapseInfo.originalGroups.find((g) => g.label === label);
|
|
492
|
-
if (
|
|
493
|
-
og?.parentGroup &&
|
|
494
|
-
!collapsedGroupLabels.has(og.parentGroup) &&
|
|
495
|
-
nodeById.has(gid(og.parentGroup))
|
|
496
|
-
) {
|
|
497
|
-
parentOf.set(gid(label), gid(og.parentGroup));
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
for (const group of parsed.groups) {
|
|
502
|
-
for (const child of group.children) {
|
|
503
|
-
if (expandedGroupSet.has(child)) continue;
|
|
504
|
-
if (nodeById.has(child)) {
|
|
505
|
-
parentOf.set(child, gid(group.label));
|
|
506
|
-
}
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
const roots: ElkNode[] = [];
|
|
511
|
-
for (const [id, node] of nodeById) {
|
|
512
|
-
const parentId = parentOf.get(id);
|
|
513
|
-
if (parentId) {
|
|
514
|
-
const parent = nodeById.get(parentId)!;
|
|
515
|
-
parent.children = parent.children ?? [];
|
|
516
|
-
parent.children.push(node);
|
|
517
|
-
} else {
|
|
518
|
-
roots.push(node);
|
|
519
|
-
}
|
|
520
|
-
}
|
|
199
|
+
): Promise<BLLayoutResult> {
|
|
200
|
+
const { layoutBoxesAndLinesSearch } = await import('./layout-search');
|
|
201
|
+
const searched = layoutBoxesAndLinesSearch(parsed, collapseInfo, {
|
|
202
|
+
...(layoutOptions?.hideDescriptions !== undefined && {
|
|
203
|
+
hideDescriptions: layoutOptions.hideDescriptions,
|
|
204
|
+
}),
|
|
205
|
+
...(layoutOptions?.previousPositions !== undefined && {
|
|
206
|
+
previousPositions: layoutOptions.previousPositions,
|
|
207
|
+
}),
|
|
208
|
+
});
|
|
209
|
+
// Engine-agnostic post-processing: fan parallel edges, then float notes
|
|
210
|
+
// (and shift the canvas to fit them).
|
|
211
|
+
return attachNotes(
|
|
212
|
+
applyParallelEdgeOffsets(searched),
|
|
213
|
+
parsed,
|
|
214
|
+
layoutOptions?.collapsedNotes
|
|
215
|
+
);
|
|
216
|
+
}
|
|
521
217
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
218
|
+
/**
|
|
219
|
+
* Float notes beside their boxes on the chosen layout (runs after variant
|
|
220
|
+
* selection — notes don't affect scoring). `no-notes` opts out. A note placed
|
|
221
|
+
* above/left can land off-canvas, so the whole layout is shifted to fit.
|
|
222
|
+
* Un-annotated diagrams are returned unchanged (min coords stay ≥ 0).
|
|
223
|
+
*/
|
|
224
|
+
function attachNotes(
|
|
225
|
+
layout: BLLayoutResult,
|
|
226
|
+
parsed: ParsedBoxesAndLines,
|
|
227
|
+
collapsedNotes?: ReadonlySet<number>
|
|
228
|
+
): BLLayoutResult {
|
|
229
|
+
const notesSuppressed = parsed.options?.['no-notes'] === 'on';
|
|
230
|
+
const noteByNode =
|
|
231
|
+
notesSuppressed || !parsed.notes
|
|
232
|
+
? new Map()
|
|
233
|
+
: resolveNotes(
|
|
234
|
+
parsed.notes,
|
|
235
|
+
parsed.nodes.map((n) => ({ id: n.label, label: n.label }))
|
|
236
|
+
);
|
|
237
|
+
if (noteByNode.size === 0) return layout;
|
|
238
|
+
|
|
239
|
+
const placed = buildPlacedNotes(
|
|
240
|
+
layout.nodes.map((n) => ({
|
|
241
|
+
id: n.label,
|
|
242
|
+
x: n.x,
|
|
243
|
+
y: n.y,
|
|
244
|
+
width: n.width,
|
|
245
|
+
height: n.height,
|
|
246
|
+
})),
|
|
247
|
+
noteByNode,
|
|
248
|
+
parsed.direction === 'TB' ? 'TB' : 'LR',
|
|
249
|
+
collapsedNotes
|
|
250
|
+
);
|
|
251
|
+
const notedNodes: BLLayoutNode[] = layout.nodes.map((n) => {
|
|
252
|
+
const note = placed.get(n.label);
|
|
253
|
+
return note ? { ...n, note } : n;
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// Content bbox over nodes (+ their floated notes) and groups — matches the
|
|
257
|
+
// prior max-extent computation plus the notes.
|
|
258
|
+
let bbMinX = Infinity;
|
|
259
|
+
let bbMinY = Infinity;
|
|
260
|
+
let bbMaxX = -Infinity;
|
|
261
|
+
let bbMaxY = -Infinity;
|
|
262
|
+
const extend = (l: number, t: number, r: number, b: number): void => {
|
|
263
|
+
if (l < bbMinX) bbMinX = l;
|
|
264
|
+
if (t < bbMinY) bbMinY = t;
|
|
265
|
+
if (r > bbMaxX) bbMaxX = r;
|
|
266
|
+
if (b > bbMaxY) bbMaxY = b;
|
|
267
|
+
};
|
|
268
|
+
for (const n of notedNodes) {
|
|
269
|
+
extend(
|
|
270
|
+
n.x - n.width / 2,
|
|
271
|
+
n.y - n.height / 2,
|
|
272
|
+
n.x + n.width / 2,
|
|
273
|
+
n.y + n.height / 2
|
|
274
|
+
);
|
|
275
|
+
if (n.note && !n.note.collapsed) {
|
|
276
|
+
extend(
|
|
277
|
+
n.x + n.note.x,
|
|
278
|
+
n.y + n.note.y,
|
|
279
|
+
n.x + n.note.x + n.note.width,
|
|
280
|
+
n.y + n.note.y + n.note.height
|
|
281
|
+
);
|
|
532
282
|
}
|
|
533
|
-
|
|
534
|
-
return { roots, rootEdges };
|
|
535
283
|
}
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
'elk.direction': direction,
|
|
544
|
-
'elk.padding': `[top=${MARGIN},left=${MARGIN},bottom=${MARGIN},right=${MARGIN}]`,
|
|
545
|
-
},
|
|
546
|
-
children: roots,
|
|
547
|
-
edges: rootEdges,
|
|
548
|
-
};
|
|
549
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
550
|
-
const result = (await getElk().layout(elkRoot as any)) as ElkNode;
|
|
551
|
-
return extractLayout(result);
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
function extractLayout(result: ElkNode): BLLayoutResult {
|
|
555
|
-
const layoutNodes: BLLayoutNode[] = [];
|
|
556
|
-
const layoutGroups: BLLayoutGroup[] = [];
|
|
557
|
-
const allEdges: ElkLayoutEdge[] = [];
|
|
558
|
-
const containerAbs = new Map<string, { x: number; y: number }>();
|
|
559
|
-
|
|
560
|
-
function walk(
|
|
561
|
-
n: ElkNode,
|
|
562
|
-
offsetX: number,
|
|
563
|
-
offsetY: number,
|
|
564
|
-
isRoot: boolean
|
|
565
|
-
): void {
|
|
566
|
-
const nx = (n.x ?? 0) + offsetX;
|
|
567
|
-
const ny = (n.y ?? 0) + offsetY;
|
|
568
|
-
const nw = n.width ?? 0;
|
|
569
|
-
const nh = n.height ?? 0;
|
|
570
|
-
|
|
571
|
-
if (isRoot) {
|
|
572
|
-
containerAbs.set('root', { x: nx, y: ny });
|
|
573
|
-
} else {
|
|
574
|
-
const isGroup = n.id.startsWith('__group_');
|
|
575
|
-
if (isGroup) {
|
|
576
|
-
const label = n.id.slice('__group_'.length);
|
|
577
|
-
const collapsed = collapsedGroupLabels.has(label);
|
|
578
|
-
const og = collapseInfo?.originalGroups.find(
|
|
579
|
-
(g) => g.label === label
|
|
580
|
-
);
|
|
581
|
-
const pg = parsed.groups.find((g) => g.label === label);
|
|
582
|
-
const childCount = collapsed
|
|
583
|
-
? (collapseInfo?.collapsedChildCounts.get(label) ?? 0)
|
|
584
|
-
: undefined;
|
|
585
|
-
layoutGroups.push({
|
|
586
|
-
label,
|
|
587
|
-
lineNumber: pg?.lineNumber ?? og?.lineNumber ?? 0,
|
|
588
|
-
x: nx + nw / 2,
|
|
589
|
-
y: ny + nh / 2,
|
|
590
|
-
width: nw,
|
|
591
|
-
height: nh,
|
|
592
|
-
collapsed,
|
|
593
|
-
...(childCount !== undefined && { childCount }),
|
|
594
|
-
});
|
|
595
|
-
if (!collapsed) containerAbs.set(n.id, { x: nx, y: ny });
|
|
596
|
-
} else {
|
|
597
|
-
layoutNodes.push({
|
|
598
|
-
label: n.id,
|
|
599
|
-
x: nx + nw / 2,
|
|
600
|
-
y: ny + nh / 2,
|
|
601
|
-
width: nw,
|
|
602
|
-
height: nh,
|
|
603
|
-
});
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
if (n.edges) for (const e of n.edges) allEdges.push(e);
|
|
608
|
-
if (n.children) for (const c of n.children) walk(c, nx, ny, false);
|
|
609
|
-
}
|
|
610
|
-
walk(result, 0, 0, true);
|
|
611
|
-
|
|
612
|
-
// Parallel edge offsets
|
|
613
|
-
const edgeYOffsets: number[] = new Array(parsed.edges.length).fill(0);
|
|
614
|
-
const edgeParallelCounts: number[] = new Array(parsed.edges.length).fill(1);
|
|
615
|
-
const parallelGroups = new Map<string, number[]>();
|
|
616
|
-
for (let i = 0; i < parsed.edges.length; i++) {
|
|
617
|
-
// In-bounds by loop guard.
|
|
618
|
-
const edge = parsed.edges[i]!;
|
|
619
|
-
const [a, b] =
|
|
620
|
-
edge.source < edge.target
|
|
621
|
-
? [edge.source, edge.target]
|
|
622
|
-
: [edge.target, edge.source];
|
|
623
|
-
const key = `${a}\x00${b}`;
|
|
624
|
-
if (!parallelGroups.has(key)) parallelGroups.set(key, []);
|
|
625
|
-
parallelGroups.get(key)!.push(i);
|
|
626
|
-
}
|
|
627
|
-
for (const group of parallelGroups.values()) {
|
|
628
|
-
const capped = group.slice(0, MAX_PARALLEL_EDGES);
|
|
629
|
-
for (const idx of group.slice(MAX_PARALLEL_EDGES)) {
|
|
630
|
-
edgeParallelCounts[idx] = 0;
|
|
631
|
-
}
|
|
632
|
-
if (capped.length < 2) continue;
|
|
633
|
-
for (let j = 0; j < capped.length; j++) {
|
|
634
|
-
// In-bounds by loop guard.
|
|
635
|
-
const cappedJ = capped[j]!;
|
|
636
|
-
edgeYOffsets[cappedJ] =
|
|
637
|
-
(j - (capped.length - 1) / 2) * PARALLEL_SPACING;
|
|
638
|
-
edgeParallelCounts[cappedJ] = capped.length;
|
|
639
|
-
}
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
const edgeById = new Map<string, ElkLayoutEdge>();
|
|
643
|
-
for (const e of allEdges) edgeById.set(e.id, e);
|
|
644
|
-
|
|
645
|
-
const layoutEdges: BLLayoutEdge[] = [];
|
|
646
|
-
for (let i = 0; i < parsed.edges.length; i++) {
|
|
647
|
-
// In-bounds by loop guard.
|
|
648
|
-
const edge = parsed.edges[i]!;
|
|
649
|
-
if (edgeParallelCounts[i] === 0) continue;
|
|
650
|
-
const elkEdge = edgeById.get(`e${i}`);
|
|
651
|
-
if (!elkEdge?.sections || elkEdge.sections.length === 0) continue;
|
|
652
|
-
const container = elkEdge.container ?? 'root';
|
|
653
|
-
const off = containerAbs.get(container) ?? { x: 0, y: 0 };
|
|
654
|
-
// In-bounds — length check above guarantees sections[0] exists.
|
|
655
|
-
const s = elkEdge.sections[0]!;
|
|
656
|
-
const points = [
|
|
657
|
-
{ x: s.startPoint.x + off.x, y: s.startPoint.y + off.y },
|
|
658
|
-
...(s.bendPoints ?? []).map((p) => ({
|
|
659
|
-
x: p.x + off.x,
|
|
660
|
-
y: p.y + off.y,
|
|
661
|
-
})),
|
|
662
|
-
{ x: s.endPoint.x + off.x, y: s.endPoint.y + off.y },
|
|
663
|
-
];
|
|
664
|
-
let labelX: number | undefined;
|
|
665
|
-
let labelY: number | undefined;
|
|
666
|
-
if (edge.label && points.length >= 2) {
|
|
667
|
-
const mid = Math.floor(points.length / 2);
|
|
668
|
-
// In-bounds — mid < points.length guaranteed by length >= 2 check.
|
|
669
|
-
const midPoint = points[mid]!;
|
|
670
|
-
labelX = midPoint.x;
|
|
671
|
-
labelY = midPoint.y - 10;
|
|
672
|
-
}
|
|
673
|
-
layoutEdges.push({
|
|
674
|
-
source: edge.source,
|
|
675
|
-
target: edge.target,
|
|
676
|
-
...(edge.label !== undefined && { label: edge.label }),
|
|
677
|
-
bidirectional: edge.bidirectional,
|
|
678
|
-
lineNumber: edge.lineNumber,
|
|
679
|
-
points,
|
|
680
|
-
...(labelX !== undefined && { labelX }),
|
|
681
|
-
...(labelY !== undefined && { labelY }),
|
|
682
|
-
// In-bounds — i < parsed.edges.length, arrays sized to that length.
|
|
683
|
-
yOffset: edgeYOffsets[i]!,
|
|
684
|
-
parallelCount: edgeParallelCounts[i]!,
|
|
685
|
-
metadata: edge.metadata,
|
|
686
|
-
deferred: true,
|
|
687
|
-
});
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
let maxX = 0;
|
|
691
|
-
let maxY = 0;
|
|
692
|
-
for (const node of layoutNodes) {
|
|
693
|
-
maxX = Math.max(maxX, node.x + node.width / 2);
|
|
694
|
-
maxY = Math.max(maxY, node.y + node.height / 2);
|
|
695
|
-
}
|
|
696
|
-
for (const group of layoutGroups) {
|
|
697
|
-
maxX = Math.max(maxX, group.x + group.width / 2);
|
|
698
|
-
maxY = Math.max(maxY, group.y + group.height / 2);
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
return {
|
|
702
|
-
nodes: layoutNodes,
|
|
703
|
-
edges: layoutEdges,
|
|
704
|
-
groups: layoutGroups,
|
|
705
|
-
width: maxX + MARGIN,
|
|
706
|
-
height: maxY + MARGIN,
|
|
707
|
-
};
|
|
284
|
+
for (const grp of layout.groups) {
|
|
285
|
+
extend(
|
|
286
|
+
grp.x - grp.width / 2,
|
|
287
|
+
grp.y - grp.height / 2,
|
|
288
|
+
grp.x + grp.width / 2,
|
|
289
|
+
grp.y + grp.height / 2
|
|
290
|
+
);
|
|
708
291
|
}
|
|
292
|
+
if (!Number.isFinite(bbMinX)) return { ...layout, nodes: notedNodes };
|
|
293
|
+
|
|
294
|
+
const { shiftX, shiftY } = noteCanvasShift(bbMinX, bbMinY);
|
|
295
|
+
const shifted = shiftX !== 0 || shiftY !== 0;
|
|
296
|
+
const finalNodes = shifted
|
|
297
|
+
? notedNodes.map((n) => ({ ...n, x: n.x + shiftX, y: n.y + shiftY }))
|
|
298
|
+
: notedNodes;
|
|
299
|
+
const finalEdges = shifted
|
|
300
|
+
? layout.edges.map((e) => ({
|
|
301
|
+
...e,
|
|
302
|
+
points: e.points.map((pt) => ({ x: pt.x + shiftX, y: pt.y + shiftY })),
|
|
303
|
+
...(e.labelX !== undefined && { labelX: e.labelX + shiftX }),
|
|
304
|
+
...(e.labelY !== undefined && { labelY: e.labelY + shiftY }),
|
|
305
|
+
}))
|
|
306
|
+
: layout.edges;
|
|
307
|
+
const finalGroups = shifted
|
|
308
|
+
? layout.groups.map((grp) => ({
|
|
309
|
+
...grp,
|
|
310
|
+
x: grp.x + shiftX,
|
|
311
|
+
y: grp.y + shiftY,
|
|
312
|
+
}))
|
|
313
|
+
: layout.groups;
|
|
709
314
|
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
315
|
+
return {
|
|
316
|
+
nodes: finalNodes,
|
|
317
|
+
edges: finalEdges,
|
|
318
|
+
groups: finalGroups,
|
|
319
|
+
width: bbMaxX + shiftX + MARGIN,
|
|
320
|
+
height: bbMaxY + shiftY + MARGIN,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
718
323
|
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
324
|
+
/**
|
|
325
|
+
* Assign parallel-edge fan offsets on any layout (engine-agnostic). Edges sharing
|
|
326
|
+
* an unordered {source,target} pair are bundled at their ports and spread in the
|
|
327
|
+
* middle by the renderer using `yOffset`/`parallelCount`; beyond `MAX_PARALLEL_EDGES`
|
|
328
|
+
* the extras are dropped (`parallelCount: 0` ⇒ renderer skips them). The ELK path
|
|
329
|
+
* computes this inside extractLayout; the search engine produces a single set of
|
|
330
|
+
* points per pair, so it needs the same offsets applied here.
|
|
331
|
+
*/
|
|
332
|
+
function applyParallelEdgeOffsets(layout: BLLayoutResult): BLLayoutResult {
|
|
333
|
+
const groups = new Map<string, number[]>();
|
|
334
|
+
layout.edges.forEach((e, i) => {
|
|
335
|
+
const [a, b] =
|
|
336
|
+
e.source < e.target ? [e.source, e.target] : [e.target, e.source];
|
|
337
|
+
const key = `${a}\x00${b}`;
|
|
338
|
+
const arr = groups.get(key);
|
|
339
|
+
if (arr) arr.push(i);
|
|
340
|
+
else groups.set(key, [i]);
|
|
341
|
+
});
|
|
342
|
+
if ([...groups.values()].every((g) => g.length < 2)) return layout;
|
|
343
|
+
|
|
344
|
+
const yOffset = new Array(layout.edges.length).fill(0);
|
|
345
|
+
const count = new Array(layout.edges.length).fill(1);
|
|
346
|
+
for (const idxs of groups.values()) {
|
|
347
|
+
const capped = idxs.slice(0, MAX_PARALLEL_EDGES);
|
|
348
|
+
for (const drop of idxs.slice(MAX_PARALLEL_EDGES)) count[drop] = 0;
|
|
349
|
+
if (capped.length < 2) continue;
|
|
350
|
+
capped.forEach((idx, j) => {
|
|
351
|
+
yOffset[idx] = (j - (capped.length - 1) / 2) * PARALLEL_SPACING;
|
|
352
|
+
count[idx] = capped.length;
|
|
353
|
+
});
|
|
730
354
|
}
|
|
731
|
-
return
|
|
355
|
+
return {
|
|
356
|
+
...layout,
|
|
357
|
+
edges: layout.edges.map((e, i) => ({
|
|
358
|
+
...e,
|
|
359
|
+
yOffset: yOffset[i]!,
|
|
360
|
+
parallelCount: count[i]!,
|
|
361
|
+
})),
|
|
362
|
+
};
|
|
732
363
|
}
|