@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.
Files changed (138) hide show
  1. package/README.md +3 -3
  2. package/dist/advanced.cjs +5651 -3193
  3. package/dist/advanced.d.cts +272 -58
  4. package/dist/advanced.d.ts +272 -58
  5. package/dist/advanced.js +5650 -3186
  6. package/dist/auto.cjs +5511 -3070
  7. package/dist/auto.js +116 -137
  8. package/dist/auto.mjs +5510 -3069
  9. package/dist/cli.cjs +168 -189
  10. package/dist/editor.cjs +4 -0
  11. package/dist/editor.js +4 -0
  12. package/dist/highlight.cjs +4 -0
  13. package/dist/highlight.js +4 -0
  14. package/dist/index.cjs +5536 -3072
  15. package/dist/index.d.cts +33 -8
  16. package/dist/index.d.ts +33 -8
  17. package/dist/index.js +5535 -3071
  18. package/dist/internal.cjs +5651 -3193
  19. package/dist/internal.d.cts +272 -58
  20. package/dist/internal.d.ts +272 -58
  21. package/dist/internal.js +5650 -3186
  22. package/dist/map-data/PROVENANCE.json +1 -1
  23. package/dist/map-data/airport-collisions.json +1 -0
  24. package/dist/map-data/airports.json +1 -0
  25. package/docs/language-reference.md +68 -18
  26. package/gallery/fixtures/boxes-and-lines-diverging.dgmo +15 -0
  27. package/gallery/fixtures/map-choropleth-diverging.dgmo +9 -0
  28. package/gallery/fixtures/map-region-values.dgmo +13 -0
  29. package/gallery/fixtures/map-subnational-zoom.dgmo +12 -0
  30. package/gallery/fixtures/map-tagged-legs.dgmo +16 -0
  31. package/gallery/fixtures/map-undirected-edges.dgmo +12 -0
  32. package/package.json +7 -3
  33. package/src/advanced.ts +1 -6
  34. package/src/auto/index.ts +1 -1
  35. package/src/boxes-and-lines/layout-layered.ts +722 -0
  36. package/src/boxes-and-lines/layout-search.ts +1200 -0
  37. package/src/boxes-and-lines/layout.ts +202 -571
  38. package/src/boxes-and-lines/parser.ts +43 -8
  39. package/src/boxes-and-lines/renderer.ts +223 -96
  40. package/src/boxes-and-lines/types.ts +9 -2
  41. package/src/c4/layout.ts +14 -32
  42. package/src/c4/parser.ts +9 -5
  43. package/src/c4/renderer.ts +34 -39
  44. package/src/class/layout.ts +118 -18
  45. package/src/class/parser.ts +35 -0
  46. package/src/class/renderer.ts +58 -2
  47. package/src/class/types.ts +3 -0
  48. package/src/cli.ts +4 -4
  49. package/src/completion.ts +26 -12
  50. package/src/cycle/layout.ts +55 -72
  51. package/src/cycle/renderer.ts +11 -6
  52. package/src/d3.ts +78 -117
  53. package/src/diagnostics.ts +16 -0
  54. package/src/echarts.ts +46 -33
  55. package/src/editor/keywords.ts +4 -0
  56. package/src/er/layout.ts +114 -22
  57. package/src/er/parser.ts +28 -0
  58. package/src/er/renderer.ts +55 -2
  59. package/src/er/types.ts +3 -0
  60. package/src/gantt/renderer.ts +46 -38
  61. package/src/gantt/resolver.ts +9 -2
  62. package/src/graph/edge-spline.ts +29 -0
  63. package/src/graph/flowchart-parser.ts +34 -1
  64. package/src/graph/flowchart-renderer.ts +78 -64
  65. package/src/graph/layout.ts +206 -23
  66. package/src/graph/notes.ts +21 -0
  67. package/src/graph/state-parser.ts +26 -1
  68. package/src/graph/state-renderer.ts +78 -64
  69. package/src/graph/types.ts +13 -0
  70. package/src/index.ts +1 -1
  71. package/src/infra/layout.ts +46 -26
  72. package/src/infra/renderer.ts +16 -7
  73. package/src/journey-map/layout.ts +38 -49
  74. package/src/journey-map/renderer.ts +22 -45
  75. package/src/kanban/renderer.ts +15 -6
  76. package/src/label-layout.ts +3 -3
  77. package/src/map/completion.ts +77 -22
  78. package/src/map/context-labels.ts +101 -25
  79. package/src/map/data/PROVENANCE.json +1 -1
  80. package/src/map/data/airport-collisions.json +1 -0
  81. package/src/map/data/airports.json +1 -0
  82. package/src/map/data/types.ts +19 -0
  83. package/src/map/layout.ts +1212 -96
  84. package/src/map/legend-band.ts +2 -2
  85. package/src/map/load-data.ts +10 -1
  86. package/src/map/parser.ts +61 -32
  87. package/src/map/renderer.ts +284 -12
  88. package/src/map/resolved-types.ts +15 -1
  89. package/src/map/resolver.ts +132 -12
  90. package/src/map/types.ts +28 -8
  91. package/src/migrate/embedded.ts +9 -7
  92. package/src/mindmap/text-wrap.ts +13 -14
  93. package/src/org/layout.ts +19 -17
  94. package/src/org/renderer.ts +11 -4
  95. package/src/palettes/color-utils.ts +82 -21
  96. package/src/palettes/index.ts +0 -19
  97. package/src/palettes/registry.ts +1 -1
  98. package/src/palettes/types.ts +2 -2
  99. package/src/pert/layout.ts +48 -40
  100. package/src/pert/renderer.ts +30 -43
  101. package/src/pyramid/renderer.ts +4 -5
  102. package/src/raci/renderer.ts +34 -68
  103. package/src/render.ts +1 -1
  104. package/src/ring/renderer.ts +1 -2
  105. package/src/sequence/parser.ts +100 -22
  106. package/src/sequence/renderer.ts +75 -50
  107. package/src/sitemap/layout.ts +27 -19
  108. package/src/sitemap/renderer.ts +12 -5
  109. package/src/tech-radar/renderer.ts +11 -35
  110. package/src/utils/arrow-markers.ts +51 -0
  111. package/src/utils/fit-canvas.ts +64 -0
  112. package/src/utils/legend-constants.ts +8 -54
  113. package/src/utils/legend-d3.ts +10 -7
  114. package/src/utils/legend-layout.ts +7 -4
  115. package/src/utils/legend-types.ts +10 -4
  116. package/src/utils/note-box/constants.ts +25 -0
  117. package/src/utils/note-box/index.ts +11 -0
  118. package/src/utils/note-box/metrics.ts +90 -0
  119. package/src/utils/note-box/svg.ts +331 -0
  120. package/src/utils/notes/bounds.ts +30 -0
  121. package/src/utils/notes/build.ts +131 -0
  122. package/src/utils/notes/index.ts +18 -0
  123. package/src/utils/notes/model.ts +19 -0
  124. package/src/utils/notes/parse.ts +131 -0
  125. package/src/utils/notes/place.ts +177 -0
  126. package/src/utils/notes/resolve.ts +88 -0
  127. package/src/utils/number-format.ts +36 -0
  128. package/src/utils/parsing.ts +41 -0
  129. package/src/utils/reserved-key-registry.ts +4 -0
  130. package/src/utils/text-measure.ts +122 -0
  131. package/src/wireframe/layout.ts +4 -2
  132. package/src/wireframe/renderer.ts +8 -6
  133. package/src/palettes/dracula.ts +0 -68
  134. package/src/palettes/gruvbox.ts +0 -85
  135. package/src/palettes/monokai.ts +0 -68
  136. package/src/palettes/one-dark.ts +0 -70
  137. package/src/palettes/rose-pine.ts +0 -84
  138. package/src/palettes/solarized.ts +0 -77
@@ -2,26 +2,28 @@
2
2
  // Boxes and Lines Diagram — Layout Engine
3
3
  // ============================================================
4
4
  //
5
- // Uses elkjs (layered algorithm) with a multi-trial scheme that runs
6
- // several option variants and picks the best by:
7
- // 1. crossings (with a forgiveness threshold for near-zero)
8
- // 2. total area (prefer compact)
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
- const charWidth = fontSize * 0.6;
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.length <= maxChars) {
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(node: BLNode): { width: number; height: number } {
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 charsPerLine = Math.floor((w - 24) / (DESC_FONT_SIZE * 0.6));
154
+ const maxTextWidth = w - 24;
144
155
  let totalRenderedLines = 0;
145
156
  for (const line of node.description) {
146
- if (line.length <= charsPerLine) {
157
+ if (measureText(line, DESC_FONT_SIZE) <= maxTextWidth) {
147
158
  totalRenderedLines += 1;
148
159
  } else {
149
- const words = line.split(/\s+/);
150
- let current = '';
151
- let lineCount = 0;
152
- for (const word of words) {
153
- const fitted =
154
- word.length > charsPerLine ? word.slice(0, charsPerLine) : word;
155
- const test = current ? `${current} ${fitted}` : fitted;
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?: { hideDescriptions?: boolean }
398
- ): Promise<BLLayoutResult> {
399
- const hideDescriptions = layoutOptions?.hideDescriptions ?? false;
400
- const direction = parsed.direction === 'TB' ? 'DOWN' : 'RIGHT';
401
-
402
- // Determine which groups are collapsed (shown as plain nodes)
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
- if (maxDescHeight > 0) {
433
- for (const node of parsed.nodes) {
434
- if (node.description && node.description.length > 0) {
435
- const size = nodeSizes.get(node.label)!;
436
- nodeSizes.set(node.label, { width: size.width, height: maxDescHeight });
437
- }
438
- }
439
- }
440
-
441
- // Build a fresh ELK graph each variant call — elk.layout() mutates the tree
442
- // setting x/y/sections, so we can't reuse it across trials.
443
- const expandedGroupSet = new Set(parsed.groups.map((g) => g.label));
444
- const gid = (label: string) => `__group_${label}`;
445
-
446
- function buildGraph(): { roots: ElkNode[]; rootEdges: ElkLayoutEdge[] } {
447
- const nodeById = new Map<string, ElkNode>();
448
- const parentOf = new Map<string, string>();
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
- const rootEdges: ElkLayoutEdge[] = [];
523
- for (let i = 0; i < parsed.edges.length; i++) {
524
- // In-bounds by loop guard.
525
- const edge = parsed.edges[i]!;
526
- if (!nodeById.has(edge.source) || !nodeById.has(edge.target)) continue;
527
- rootEdges.push({
528
- id: `e${i}`,
529
- sources: [edge.source],
530
- targets: [edge.target],
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
- async function runVariant(variant: Variant): Promise<BLLayoutResult> {
538
- const { roots, rootEdges } = buildGraph();
539
- const elkRoot: ElkNode = {
540
- id: 'root',
541
- layoutOptions: {
542
- ...variant.options,
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
- // Trivial graphs skip multi-trial — one variant is plenty.
711
- const N = parsed.nodes.length + parsed.groups.length;
712
- const E = parsed.edges.length;
713
- const trivial = N < 8 && E < 10;
714
- // In-bounds getVariants() returns 5 variants, index 1 always exists.
715
- const variants = trivial ? [getVariants()[1]!] : getVariants();
716
-
717
- const results = await Promise.all(variants.map((v) => runVariant(v)));
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
- // In-bounds — variants is non-empty (trivial branch has 1, normal has 5).
720
- let best = results[0]!;
721
- let bestScore = scoreLayout(best);
722
- for (let i = 1; i < results.length; i++) {
723
- // In-bounds by loop guard.
724
- const resultI = results[i]!;
725
- const s = scoreLayout(resultI);
726
- if (cmpScore(s, bestScore) < 0) {
727
- best = resultI;
728
- bestScore = s;
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 best;
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
  }