@diagrammo/dgmo 0.8.21 → 0.8.23
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/AGENTS.md +2 -1
- package/README.md +1 -0
- package/dist/cli.cjs +145 -93
- package/dist/editor.cjs +20 -3
- package/dist/editor.cjs.map +1 -1
- package/dist/editor.js +20 -3
- package/dist/editor.js.map +1 -1
- package/dist/highlight.cjs +15 -2
- package/dist/highlight.cjs.map +1 -1
- package/dist/highlight.js +15 -2
- package/dist/highlight.js.map +1 -1
- package/dist/index.cjs +20843 -14937
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +426 -17
- package/dist/index.d.ts +426 -17
- package/dist/index.js +20795 -14912
- package/dist/index.js.map +1 -1
- package/dist/internal.cjs +380 -0
- package/dist/internal.cjs.map +1 -0
- package/dist/internal.d.cts +179 -0
- package/dist/internal.d.ts +179 -0
- package/dist/internal.js +337 -0
- package/dist/internal.js.map +1 -0
- package/docs/guide/chart-cycle.md +156 -0
- package/docs/guide/chart-journey-map.md +179 -0
- package/docs/guide/chart-pyramid.md +111 -0
- package/docs/guide/chart-sitemap.md +18 -1
- package/docs/guide/chart-tech-radar.md +219 -0
- package/docs/guide/registry.json +6 -0
- package/docs/language-reference.md +177 -6
- package/gallery/fixtures/boxes-and-lines.dgmo +10 -3
- package/gallery/fixtures/c4-full.dgmo +2 -2
- package/gallery/fixtures/cycle/ooda-loop.dgmo +25 -0
- package/gallery/fixtures/cycle/pdca-circle-nodes.dgmo +12 -0
- package/gallery/fixtures/cycle/pdca-minimal.dgmo +6 -0
- package/gallery/fixtures/cycle/sprint-cycle-span.dgmo +17 -0
- package/gallery/fixtures/gantt-full.dgmo +2 -2
- package/gallery/fixtures/gantt.dgmo +2 -2
- package/gallery/fixtures/infra-full.dgmo +2 -2
- package/gallery/fixtures/infra.dgmo +1 -1
- package/gallery/fixtures/pyramid/dikw.dgmo +17 -0
- package/gallery/fixtures/pyramid/inverted-funnel.dgmo +16 -0
- package/gallery/fixtures/pyramid/minimal.dgmo +5 -0
- package/gallery/fixtures/sequence-tags-protocols.dgmo +2 -2
- package/gallery/fixtures/sequence-tags.dgmo +2 -2
- package/gallery/fixtures/tech-radar-dense.dgmo +77 -0
- package/gallery/fixtures/tech-radar.dgmo +36 -0
- package/gallery/fixtures/timeline.dgmo +1 -1
- package/package.json +11 -1
- package/src/boxes-and-lines/layout.ts +309 -33
- package/src/boxes-and-lines/parser.ts +86 -10
- package/src/boxes-and-lines/renderer.ts +250 -91
- package/src/boxes-and-lines/types.ts +1 -1
- package/src/c4/layout.ts +8 -8
- package/src/c4/parser.ts +35 -2
- package/src/c4/renderer.ts +19 -3
- package/src/c4/types.ts +1 -0
- package/src/chart.ts +14 -7
- package/src/cli.ts +5 -35
- package/src/completion.ts +233 -41
- package/src/cycle/layout.ts +723 -0
- package/src/cycle/parser.ts +352 -0
- package/src/cycle/renderer.ts +566 -0
- package/src/cycle/types.ts +98 -0
- package/src/d3.ts +107 -8
- package/src/dgmo-router.ts +82 -3
- package/src/echarts.ts +8 -5
- package/src/editor/dgmo.grammar +5 -1
- package/src/editor/dgmo.grammar.js +1 -1
- package/src/editor/keywords.ts +17 -0
- package/src/gantt/parser.ts +2 -8
- package/src/graph/flowchart-parser.ts +15 -21
- package/src/graph/state-parser.ts +5 -10
- package/src/index.ts +63 -2
- package/src/infra/layout.ts +218 -74
- package/src/infra/parser.ts +32 -8
- package/src/infra/renderer.ts +14 -8
- package/src/infra/types.ts +10 -3
- package/src/internal.ts +16 -0
- package/src/journey-map/layout.ts +386 -0
- package/src/journey-map/parser.ts +540 -0
- package/src/journey-map/renderer.ts +1521 -0
- package/src/journey-map/types.ts +47 -0
- package/src/kanban/parser.ts +3 -10
- package/src/kanban/renderer.ts +31 -15
- package/src/mindmap/parser.ts +12 -18
- package/src/mindmap/renderer.ts +14 -13
- package/src/mindmap/text-wrap.ts +22 -12
- package/src/mindmap/types.ts +2 -2
- package/src/org/collapse.ts +81 -0
- package/src/org/parser.ts +2 -6
- package/src/org/renderer.ts +212 -4
- package/src/pyramid/parser.ts +172 -0
- package/src/pyramid/renderer.ts +684 -0
- package/src/pyramid/types.ts +28 -0
- package/src/render.ts +2 -8
- package/src/sequence/parser.ts +62 -20
- package/src/sequence/renderer.ts +146 -40
- package/src/sharing.ts +1 -0
- package/src/sitemap/layout.ts +21 -6
- package/src/sitemap/parser.ts +26 -17
- package/src/sitemap/renderer.ts +34 -0
- package/src/sitemap/types.ts +1 -0
- package/src/tech-radar/index.ts +14 -0
- package/src/tech-radar/interactive.ts +1112 -0
- package/src/tech-radar/layout.ts +190 -0
- package/src/tech-radar/parser.ts +385 -0
- package/src/tech-radar/renderer.ts +1159 -0
- package/src/tech-radar/shared.ts +187 -0
- package/src/tech-radar/types.ts +81 -0
- package/src/utils/description-helpers.ts +33 -0
- package/src/utils/legend-layout.ts +3 -1
- package/src/utils/parsing.ts +47 -7
- package/src/utils/tag-groups.ts +46 -60
|
@@ -87,13 +87,125 @@ export interface BLLayoutResult {
|
|
|
87
87
|
|
|
88
88
|
// ── Node sizing ────────────────────────────────────────────
|
|
89
89
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
90
|
+
const PHI = 1.618;
|
|
91
|
+
const NODE_HEIGHT = 60;
|
|
92
|
+
const NODE_WIDTH = Math.round(NODE_HEIGHT * PHI); // ≈ 97
|
|
93
|
+
const DESC_NODE_WIDTH = 140; // wider nodes when descriptions are shown
|
|
94
|
+
const DESC_FONT_SIZE = 10; // matches infra META_FONT_SIZE
|
|
95
|
+
const DESC_LINE_HEIGHT = 1.4; // 14px row height at 10px (matches infra META_LINE_HEIGHT)
|
|
96
|
+
const DESC_PADDING = 8;
|
|
97
|
+
const SEPARATOR_GAP = 4; // matches infra NODE_SEPARATOR_GAP
|
|
98
|
+
const MAX_DESC_LINES = 6;
|
|
99
|
+
const MAX_LABEL_LINES = 3;
|
|
100
|
+
const LABEL_LINE_HEIGHT = 1.3;
|
|
101
|
+
const LABEL_PAD = 12; // top + bottom padding around label area
|
|
102
|
+
|
|
103
|
+
/** Split on camelCase boundaries */
|
|
104
|
+
function splitCamelCase(word: string): string[] {
|
|
105
|
+
const parts: string[] = [];
|
|
106
|
+
let start = 0;
|
|
107
|
+
for (let i = 1; i < word.length; i++) {
|
|
108
|
+
const prev = word[i - 1];
|
|
109
|
+
const curr = word[i];
|
|
110
|
+
const next = i + 1 < word.length ? word[i + 1] : '';
|
|
111
|
+
const lowerToUpper =
|
|
112
|
+
prev >= 'a' && prev <= 'z' && curr >= 'A' && curr <= 'Z';
|
|
113
|
+
const upperRunEnd =
|
|
114
|
+
prev >= 'A' &&
|
|
115
|
+
prev <= 'Z' &&
|
|
116
|
+
curr >= 'A' &&
|
|
117
|
+
curr <= 'Z' &&
|
|
118
|
+
next >= 'a' &&
|
|
119
|
+
next <= 'z';
|
|
120
|
+
if (lowerToUpper || upperRunEnd) {
|
|
121
|
+
parts.push(word.slice(start, i));
|
|
122
|
+
start = i;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
parts.push(word.slice(start));
|
|
126
|
+
return parts.length > 1 ? parts : [word];
|
|
127
|
+
}
|
|
95
128
|
|
|
96
|
-
|
|
129
|
+
/** Estimate how many lines a label needs (split on spaces/dashes/camelCase, font shrink 13→9) */
|
|
130
|
+
function estimateLabelLines(label: string, nodeWidth = NODE_WIDTH): number {
|
|
131
|
+
// Split on spaces and dashes, then camelCase
|
|
132
|
+
const rawParts = label.split(/[\s-]+/);
|
|
133
|
+
const words: string[] = [];
|
|
134
|
+
for (const part of rawParts) {
|
|
135
|
+
if (!part) continue;
|
|
136
|
+
words.push(...splitCamelCase(part));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
for (let fontSize = 13; fontSize >= 9; fontSize--) {
|
|
140
|
+
const charWidth = fontSize * 0.6;
|
|
141
|
+
const maxChars = Math.floor((nodeWidth - 24) / charWidth);
|
|
142
|
+
if (maxChars < 2) continue;
|
|
143
|
+
|
|
144
|
+
let lines = 1;
|
|
145
|
+
let current = '';
|
|
146
|
+
for (const word of words) {
|
|
147
|
+
const test = current ? `${current} ${word}` : word;
|
|
148
|
+
if (test.length <= maxChars) {
|
|
149
|
+
current = test;
|
|
150
|
+
} else {
|
|
151
|
+
lines++;
|
|
152
|
+
current = word;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (lines <= MAX_LABEL_LINES) return Math.min(lines, MAX_LABEL_LINES);
|
|
156
|
+
}
|
|
157
|
+
return MAX_LABEL_LINES;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function computeNodeSize(node: BLNode): { width: number; height: number } {
|
|
161
|
+
if (!node.description || node.description.length === 0) {
|
|
162
|
+
return { width: NODE_WIDTH, height: NODE_HEIGHT };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const w = DESC_NODE_WIDTH;
|
|
166
|
+
|
|
167
|
+
// Estimate label height (up to 3 lines)
|
|
168
|
+
const labelLines = estimateLabelLines(node.label, w);
|
|
169
|
+
const labelHeight = labelLines * 13 * LABEL_LINE_HEIGHT + LABEL_PAD;
|
|
170
|
+
|
|
171
|
+
// Estimate wrapped line count using word-boundary wrapping (matches renderer)
|
|
172
|
+
const charsPerLine = Math.floor((w - 24) / (DESC_FONT_SIZE * 0.6));
|
|
173
|
+
let totalRenderedLines = 0;
|
|
174
|
+
for (const line of node.description) {
|
|
175
|
+
if (line.length <= charsPerLine) {
|
|
176
|
+
totalRenderedLines += 1;
|
|
177
|
+
} else {
|
|
178
|
+
const words = line.split(/\s+/);
|
|
179
|
+
let current = '';
|
|
180
|
+
let lineCount = 0;
|
|
181
|
+
for (const word of words) {
|
|
182
|
+
// Words wider than line get truncated with "…" in renderer (1 line)
|
|
183
|
+
const fitted =
|
|
184
|
+
word.length > charsPerLine ? word.slice(0, charsPerLine) : word;
|
|
185
|
+
const test = current ? `${current} ${fitted}` : fitted;
|
|
186
|
+
if (test.length <= charsPerLine) {
|
|
187
|
+
current = test;
|
|
188
|
+
} else {
|
|
189
|
+
if (current) lineCount++;
|
|
190
|
+
current = fitted;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (current) lineCount++;
|
|
194
|
+
totalRenderedLines += lineCount;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
totalRenderedLines = Math.min(totalRenderedLines, MAX_DESC_LINES);
|
|
198
|
+
|
|
199
|
+
const descriptionHeight =
|
|
200
|
+
totalRenderedLines * DESC_FONT_SIZE * DESC_LINE_HEIGHT;
|
|
201
|
+
const totalHeight =
|
|
202
|
+
labelHeight +
|
|
203
|
+
SEPARATOR_GAP +
|
|
204
|
+
DESC_PADDING +
|
|
205
|
+
descriptionHeight +
|
|
206
|
+
DESC_PADDING;
|
|
207
|
+
|
|
208
|
+
return { width: w, height: Math.max(NODE_HEIGHT, totalHeight) };
|
|
97
209
|
}
|
|
98
210
|
|
|
99
211
|
// ── Main layout ────────────────────────────────────────────
|
|
@@ -103,8 +215,10 @@ export function layoutBoxesAndLines(
|
|
|
103
215
|
collapseInfo?: {
|
|
104
216
|
collapsedChildCounts: Map<string, number>;
|
|
105
217
|
originalGroups: import('./types').BLGroup[];
|
|
106
|
-
}
|
|
218
|
+
},
|
|
219
|
+
layoutOptions?: { hideDescriptions?: boolean }
|
|
107
220
|
): BLLayoutResult {
|
|
221
|
+
const hideDescriptions = layoutOptions?.hideDescriptions ?? false;
|
|
108
222
|
const g = new dagre.graphlib.Graph({ compound: true, multigraph: true });
|
|
109
223
|
g.setGraph({
|
|
110
224
|
rankdir: parsed.direction,
|
|
@@ -137,12 +251,9 @@ export function layoutBoxesAndLines(
|
|
|
137
251
|
}
|
|
138
252
|
|
|
139
253
|
// Add collapsed groups as regular nodes — same golden-ratio dimensions
|
|
140
|
-
const PHI = 1.618;
|
|
141
|
-
const COLLAPSED_H = 60;
|
|
142
|
-
const COLLAPSED_W = Math.round(COLLAPSED_H * PHI);
|
|
143
254
|
for (const label of collapsedGroupLabels) {
|
|
144
255
|
const gid = `__group_${label}`;
|
|
145
|
-
g.setNode(gid, { label, width:
|
|
256
|
+
g.setNode(gid, { label, width: NODE_WIDTH, height: NODE_HEIGHT });
|
|
146
257
|
}
|
|
147
258
|
|
|
148
259
|
// Add expanded group nodes as compound parents
|
|
@@ -176,9 +287,31 @@ export function layoutBoxesAndLines(
|
|
|
176
287
|
}
|
|
177
288
|
}
|
|
178
289
|
|
|
290
|
+
// Compute node sizes — described nodes share uniform height (unless hidden)
|
|
291
|
+
const nodeSizes = new Map<string, { width: number; height: number }>();
|
|
292
|
+
let maxDescHeight = 0;
|
|
293
|
+
for (const node of parsed.nodes) {
|
|
294
|
+
const size = hideDescriptions
|
|
295
|
+
? { width: NODE_WIDTH, height: NODE_HEIGHT }
|
|
296
|
+
: computeNodeSize(node);
|
|
297
|
+
nodeSizes.set(node.label, size);
|
|
298
|
+
if (!hideDescriptions && node.description && node.description.length > 0) {
|
|
299
|
+
maxDescHeight = Math.max(maxDescHeight, size.height);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
// Apply uniform height to all described nodes
|
|
303
|
+
if (maxDescHeight > 0) {
|
|
304
|
+
for (const node of parsed.nodes) {
|
|
305
|
+
if (node.description && node.description.length > 0) {
|
|
306
|
+
const size = nodeSizes.get(node.label)!;
|
|
307
|
+
nodeSizes.set(node.label, { width: size.width, height: maxDescHeight });
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
179
312
|
// Add nodes
|
|
180
313
|
for (const node of parsed.nodes) {
|
|
181
|
-
const size =
|
|
314
|
+
const size = nodeSizes.get(node.label)!;
|
|
182
315
|
g.setNode(node.label, {
|
|
183
316
|
label: node.label,
|
|
184
317
|
width: size.width,
|
|
@@ -219,8 +352,22 @@ export function layoutBoxesAndLines(
|
|
|
219
352
|
expandedGroupIds.add(`__group_${group.label}`);
|
|
220
353
|
}
|
|
221
354
|
|
|
355
|
+
// Map expanded group IDs to their first child node (for proxy edges)
|
|
356
|
+
const groupFirstChild = new Map<string, string>();
|
|
357
|
+
for (const group of parsed.groups) {
|
|
358
|
+
const gid = `__group_${group.label}`;
|
|
359
|
+
// Find first child that is a plain node (not a sub-group)
|
|
360
|
+
const firstChild = group.children.find(
|
|
361
|
+
(c) => !groupLabelSet.has(c) && g.hasNode(c)
|
|
362
|
+
);
|
|
363
|
+
if (firstChild) {
|
|
364
|
+
groupFirstChild.set(gid, firstChild);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
222
368
|
// Add edges — skip edges where either endpoint is an expanded compound parent
|
|
223
369
|
const deferredEdgeIndices: number[] = [];
|
|
370
|
+
let proxyIdx = 0;
|
|
224
371
|
for (let i = 0; i < parsed.edges.length; i++) {
|
|
225
372
|
const edge = parsed.edges[i];
|
|
226
373
|
const src = edge.source;
|
|
@@ -228,6 +375,21 @@ export function layoutBoxesAndLines(
|
|
|
228
375
|
if (!g.hasNode(src) || !g.hasNode(tgt)) continue;
|
|
229
376
|
if (expandedGroupIds.has(src) || expandedGroupIds.has(tgt)) {
|
|
230
377
|
deferredEdgeIndices.push(i);
|
|
378
|
+
// Add invisible proxy edge between child nodes so dagre ranks the groups
|
|
379
|
+
const proxySrc = expandedGroupIds.has(src)
|
|
380
|
+
? groupFirstChild.get(src)
|
|
381
|
+
: src;
|
|
382
|
+
const proxyTgt = expandedGroupIds.has(tgt)
|
|
383
|
+
? groupFirstChild.get(tgt)
|
|
384
|
+
: tgt;
|
|
385
|
+
if (proxySrc && proxyTgt && proxySrc !== proxyTgt) {
|
|
386
|
+
g.setEdge(
|
|
387
|
+
proxySrc,
|
|
388
|
+
proxyTgt,
|
|
389
|
+
{ label: '', minlen: 1 },
|
|
390
|
+
`proxy${proxyIdx++}`
|
|
391
|
+
);
|
|
392
|
+
}
|
|
231
393
|
continue;
|
|
232
394
|
}
|
|
233
395
|
g.setEdge(src, tgt, { label: edge.label ?? '', minlen: 1 }, `e${i}`);
|
|
@@ -285,6 +447,88 @@ export function layoutBoxesAndLines(
|
|
|
285
447
|
});
|
|
286
448
|
}
|
|
287
449
|
|
|
450
|
+
// Center-align groups connected by group-to-group edges.
|
|
451
|
+
// Dagre can't rank expanded compound parents directly, and collapsed groups
|
|
452
|
+
// may also end up misaligned. Post-process to share a common center axis.
|
|
453
|
+
// Track per-group shifts so regular edge points can be adjusted too.
|
|
454
|
+
const groupAlignShifts = new Map<string, number>(); // gid → shift in alignment axis
|
|
455
|
+
{
|
|
456
|
+
// Find all group-to-group edges (both deferred and regular)
|
|
457
|
+
const groupEdges: { source: string; target: string }[] = [];
|
|
458
|
+
for (const edge of parsed.edges) {
|
|
459
|
+
if (
|
|
460
|
+
edge.source.startsWith('__group_') &&
|
|
461
|
+
edge.target.startsWith('__group_')
|
|
462
|
+
) {
|
|
463
|
+
groupEdges.push(edge);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (groupEdges.length > 0) {
|
|
468
|
+
// Build connected components via union-find
|
|
469
|
+
const groupParent = new Map<string, string>();
|
|
470
|
+
const find = (x: string): string => {
|
|
471
|
+
while (groupParent.has(x) && groupParent.get(x) !== x) {
|
|
472
|
+
groupParent.set(x, groupParent.get(groupParent.get(x)!)!);
|
|
473
|
+
x = groupParent.get(x)!;
|
|
474
|
+
}
|
|
475
|
+
return x;
|
|
476
|
+
};
|
|
477
|
+
const union = (a: string, b: string) => {
|
|
478
|
+
const ra = find(a),
|
|
479
|
+
rb = find(b);
|
|
480
|
+
if (ra !== rb) groupParent.set(ra, rb);
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
for (const edge of groupEdges) {
|
|
484
|
+
if (!groupParent.has(edge.source))
|
|
485
|
+
groupParent.set(edge.source, edge.source);
|
|
486
|
+
if (!groupParent.has(edge.target))
|
|
487
|
+
groupParent.set(edge.target, edge.target);
|
|
488
|
+
union(edge.source, edge.target);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Group layout groups by connected component
|
|
492
|
+
const components = new Map<string, BLLayoutGroup[]>();
|
|
493
|
+
for (const lg of layoutGroups) {
|
|
494
|
+
const gid = `__group_${lg.label}`;
|
|
495
|
+
if (!groupParent.has(gid)) continue;
|
|
496
|
+
const root = find(gid);
|
|
497
|
+
if (!components.has(root)) components.set(root, []);
|
|
498
|
+
components.get(root)!.push(lg);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// For each component, align on the widest group's center
|
|
502
|
+
const axis = parsed.direction === 'TB' ? 'x' : 'y';
|
|
503
|
+
for (const groups of components.values()) {
|
|
504
|
+
if (groups.length < 2) continue;
|
|
505
|
+
const dim = axis === 'x' ? 'width' : 'height';
|
|
506
|
+
let widest = groups[0];
|
|
507
|
+
for (const g of groups) {
|
|
508
|
+
if (g[dim] > widest[dim]) widest = g;
|
|
509
|
+
}
|
|
510
|
+
const targetCenter = widest[axis];
|
|
511
|
+
|
|
512
|
+
for (const grp of groups) {
|
|
513
|
+
const dx = targetCenter - grp[axis];
|
|
514
|
+
if (dx === 0) continue;
|
|
515
|
+
grp[axis] += dx;
|
|
516
|
+
groupAlignShifts.set(`__group_${grp.label}`, dx);
|
|
517
|
+
// Shift child nodes in this group (expanded groups only)
|
|
518
|
+
const parsedGroup = parsed.groups.find(
|
|
519
|
+
(pg) => pg.label === grp.label
|
|
520
|
+
);
|
|
521
|
+
if (parsedGroup) {
|
|
522
|
+
for (const childLabel of parsedGroup.children) {
|
|
523
|
+
const childNode = layoutNodes.find((n) => n.label === childLabel);
|
|
524
|
+
if (childNode) childNode[axis] += dx;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
288
532
|
// Compute parallel edge offsets
|
|
289
533
|
const edgeYOffsets: number[] = new Array(parsed.edges.length).fill(0);
|
|
290
534
|
const edgeParallelCounts: number[] = new Array(parsed.edges.length).fill(1);
|
|
@@ -326,32 +570,64 @@ export function layoutBoxesAndLines(
|
|
|
326
570
|
let points: { x: number; y: number }[];
|
|
327
571
|
|
|
328
572
|
if (deferredSet.has(i)) {
|
|
329
|
-
// Deferred edge (compound parent endpoint) —
|
|
330
|
-
|
|
331
|
-
const
|
|
332
|
-
|
|
333
|
-
const srcPt = clipToRectBorder(
|
|
334
|
-
srcNode.x,
|
|
335
|
-
srcNode.y,
|
|
336
|
-
srcNode.width,
|
|
337
|
-
srcNode.height,
|
|
338
|
-
tgtNode.x,
|
|
339
|
-
tgtNode.y
|
|
573
|
+
// Deferred edge (compound parent endpoint) — use post-alignment layout
|
|
574
|
+
// positions and emit from center of the relevant border face
|
|
575
|
+
const srcLayout = layoutGroups.find(
|
|
576
|
+
(lg) => `__group_${lg.label}` === edge.source
|
|
340
577
|
);
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
tgtNode.y,
|
|
344
|
-
tgtNode.width,
|
|
345
|
-
tgtNode.height,
|
|
346
|
-
srcNode.x,
|
|
347
|
-
srcNode.y
|
|
578
|
+
const tgtLayout = layoutGroups.find(
|
|
579
|
+
(lg) => `__group_${lg.label}` === edge.target
|
|
348
580
|
);
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
581
|
+
if (!srcLayout || !tgtLayout) {
|
|
582
|
+
// Fallback to dagre node positions for collapsed groups / mixed endpoints
|
|
583
|
+
const srcNode = g.node(edge.source);
|
|
584
|
+
const tgtNode = g.node(edge.target);
|
|
585
|
+
if (!srcNode || !tgtNode) continue;
|
|
586
|
+
const srcPt = clipToRectBorder(
|
|
587
|
+
srcNode.x,
|
|
588
|
+
srcNode.y,
|
|
589
|
+
srcNode.width,
|
|
590
|
+
srcNode.height,
|
|
591
|
+
tgtNode.x,
|
|
592
|
+
tgtNode.y
|
|
593
|
+
);
|
|
594
|
+
const tgtPt = clipToRectBorder(
|
|
595
|
+
tgtNode.x,
|
|
596
|
+
tgtNode.y,
|
|
597
|
+
tgtNode.width,
|
|
598
|
+
tgtNode.height,
|
|
599
|
+
srcNode.x,
|
|
600
|
+
srcNode.y
|
|
601
|
+
);
|
|
602
|
+
const midX = (srcPt.x + tgtPt.x) / 2;
|
|
603
|
+
const midY = (srcPt.y + tgtPt.y) / 2;
|
|
604
|
+
points = [srcPt, { x: midX, y: midY }, tgtPt];
|
|
605
|
+
} else if (parsed.direction === 'TB') {
|
|
606
|
+
// TB: straight vertical line from bottom-center to top-center
|
|
607
|
+
const cx = (srcLayout.x + tgtLayout.x) / 2;
|
|
608
|
+
const srcPt = { x: cx, y: srcLayout.y + srcLayout.height / 2 };
|
|
609
|
+
const tgtPt = { x: cx, y: tgtLayout.y - tgtLayout.height / 2 };
|
|
610
|
+
const midY = (srcPt.y + tgtPt.y) / 2;
|
|
611
|
+
points = [srcPt, { x: cx, y: midY }, tgtPt];
|
|
612
|
+
} else {
|
|
613
|
+
// LR: straight horizontal line from right-center to left-center
|
|
614
|
+
const cy = (srcLayout.y + tgtLayout.y) / 2;
|
|
615
|
+
const srcPt = { x: srcLayout.x + srcLayout.width / 2, y: cy };
|
|
616
|
+
const tgtPt = { x: tgtLayout.x - tgtLayout.width / 2, y: cy };
|
|
617
|
+
const midX = (srcPt.x + tgtPt.x) / 2;
|
|
618
|
+
points = [srcPt, { x: midX, y: cy }, tgtPt];
|
|
619
|
+
}
|
|
352
620
|
} else {
|
|
353
621
|
const dagreEdge = g.edge(edge.source, edge.target, `e${i}`);
|
|
354
622
|
points = dagreEdge?.points ?? [];
|
|
623
|
+
// If endpoints were shifted by center-alignment, adjust edge points
|
|
624
|
+
const srcShift = groupAlignShifts.get(edge.source) ?? 0;
|
|
625
|
+
const tgtShift = groupAlignShifts.get(edge.target) ?? 0;
|
|
626
|
+
if (srcShift !== 0 || tgtShift !== 0) {
|
|
627
|
+
const avgShift = (srcShift + tgtShift) / 2;
|
|
628
|
+
const prop = parsed.direction === 'TB' ? 'x' : 'y';
|
|
629
|
+
points = points.map((p) => ({ ...p, [prop]: p[prop] + avgShift }));
|
|
630
|
+
}
|
|
355
631
|
}
|
|
356
632
|
|
|
357
633
|
// Compute label position at midpoint
|
|
@@ -45,9 +45,9 @@ function measureIndent(line: string): number {
|
|
|
45
45
|
function parsePipeMetadata(
|
|
46
46
|
segment: string,
|
|
47
47
|
aliasMap: Map<string, string>
|
|
48
|
-
): { metadata: Record<string, string>; description?: string } {
|
|
48
|
+
): { metadata: Record<string, string>; description?: string[] } {
|
|
49
49
|
const metadata: Record<string, string> = {};
|
|
50
|
-
let description: string | undefined;
|
|
50
|
+
let description: string[] | undefined;
|
|
51
51
|
|
|
52
52
|
const items = segment.split(',');
|
|
53
53
|
for (const item of items) {
|
|
@@ -59,7 +59,7 @@ function parsePipeMetadata(
|
|
|
59
59
|
const rawKey = trimmed.slice(0, colonIdx).trim().toLowerCase();
|
|
60
60
|
const value = trimmed.slice(colonIdx + 1).trim();
|
|
61
61
|
if (rawKey === 'description') {
|
|
62
|
-
description = value;
|
|
62
|
+
description = [value];
|
|
63
63
|
} else {
|
|
64
64
|
const resolvedKey = aliasMap.get(rawKey) ?? rawKey;
|
|
65
65
|
metadata[resolvedKey] = value;
|
|
@@ -98,6 +98,25 @@ export function parseBoxesAndLines(content: string): ParsedBoxesAndLines {
|
|
|
98
98
|
let lastNodeLabel: string | null = null;
|
|
99
99
|
let lastSourceIsGroup = false;
|
|
100
100
|
|
|
101
|
+
// Description collection state
|
|
102
|
+
let descState: {
|
|
103
|
+
nodeLabel: string;
|
|
104
|
+
indent: number;
|
|
105
|
+
lines: string[];
|
|
106
|
+
edgeSeen: boolean;
|
|
107
|
+
} | null = null;
|
|
108
|
+
|
|
109
|
+
function flushDescription() {
|
|
110
|
+
if (descState && descState.lines.length > 0) {
|
|
111
|
+
const node = result.nodes.find((n) => n.label === descState!.nodeLabel);
|
|
112
|
+
if (node) {
|
|
113
|
+
const existing = node.description ?? [];
|
|
114
|
+
node.description = [...existing, ...descState!.lines];
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
descState = null;
|
|
118
|
+
}
|
|
119
|
+
|
|
101
120
|
// Group stack for nesting
|
|
102
121
|
interface GroupState {
|
|
103
122
|
group: BLGroup;
|
|
@@ -283,7 +302,51 @@ export function parseBoxesAndLines(content: string): ParsedBoxesAndLines {
|
|
|
283
302
|
|
|
284
303
|
// Non-indented line closes tag group
|
|
285
304
|
if (currentTagGroup && indent === 0) {
|
|
286
|
-
currentTagGroup = null;
|
|
305
|
+
currentTagGroup = null;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Description collection: indented non-edge lines under a node
|
|
309
|
+
if (descState !== null) {
|
|
310
|
+
if (indent > descState.indent) {
|
|
311
|
+
// Check if this is an edge line
|
|
312
|
+
if (trimmed.includes('->') || trimmed.includes('<->')) {
|
|
313
|
+
descState.edgeSeen = true;
|
|
314
|
+
// Fall through to normal edge processing
|
|
315
|
+
} else if (descState.edgeSeen) {
|
|
316
|
+
// Text after edges — emit warning
|
|
317
|
+
result.diagnostics.push(
|
|
318
|
+
makeDgmoError(
|
|
319
|
+
lineNum,
|
|
320
|
+
`Move description lines above edges for '${descState.nodeLabel}' — descriptions must come before -> lines`,
|
|
321
|
+
'warning'
|
|
322
|
+
)
|
|
323
|
+
);
|
|
324
|
+
continue;
|
|
325
|
+
} else if (
|
|
326
|
+
/^-\s*\w/.test(trimmed) &&
|
|
327
|
+
!trimmed.startsWith('- ') &&
|
|
328
|
+
!trimmed.includes('->') &&
|
|
329
|
+
!trimmed.includes('<->')
|
|
330
|
+
) {
|
|
331
|
+
// Looks like a malformed edge (e.g. "-Target" but not "- list item")
|
|
332
|
+
result.diagnostics.push(
|
|
333
|
+
makeDgmoError(
|
|
334
|
+
lineNum,
|
|
335
|
+
`Looks like an incomplete edge — did you mean "-> ${trimmed.slice(1).trim()}"?`,
|
|
336
|
+
'warning'
|
|
337
|
+
)
|
|
338
|
+
);
|
|
339
|
+
descState.lines.push(trimmed);
|
|
340
|
+
continue;
|
|
341
|
+
} else {
|
|
342
|
+
// Collect as description
|
|
343
|
+
descState.lines.push(trimmed);
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
} else {
|
|
347
|
+
// Indent decreased — flush description
|
|
348
|
+
flushDescription();
|
|
349
|
+
}
|
|
287
350
|
}
|
|
288
351
|
|
|
289
352
|
// Close groups that are no longer scoped by indent
|
|
@@ -358,6 +421,7 @@ export function parseBoxesAndLines(content: string): ParsedBoxesAndLines {
|
|
|
358
421
|
if (groupMatch && !trimmed.includes('->') && !trimmed.includes('<->')) {
|
|
359
422
|
contentStarted = true;
|
|
360
423
|
currentTagGroup = null;
|
|
424
|
+
flushDescription();
|
|
361
425
|
const label = groupMatch[1];
|
|
362
426
|
|
|
363
427
|
// Check nesting depth
|
|
@@ -416,7 +480,18 @@ export function parseBoxesAndLines(content: string): ParsedBoxesAndLines {
|
|
|
416
480
|
|
|
417
481
|
// Indented shorthand: `-> Target` or `-label-> Target`
|
|
418
482
|
if (trimmed.startsWith('->') || /^-[^>].*->/.test(trimmed)) {
|
|
419
|
-
|
|
483
|
+
// If the edge is at group-child indent level, use the containing group
|
|
484
|
+
const gs = currentGroupState();
|
|
485
|
+
const inGroup = gs && indent > gs.indent;
|
|
486
|
+
if (inGroup) {
|
|
487
|
+
const sourcePrefix = `[${gs.group.label}]`;
|
|
488
|
+
edgeText = `${sourcePrefix} ${trimmed}`;
|
|
489
|
+
} else if (lastNodeLabel) {
|
|
490
|
+
const sourcePrefix = lastSourceIsGroup
|
|
491
|
+
? `[${lastNodeLabel}]`
|
|
492
|
+
: lastNodeLabel;
|
|
493
|
+
edgeText = `${sourcePrefix} ${trimmed}`;
|
|
494
|
+
} else {
|
|
420
495
|
result.diagnostics.push(
|
|
421
496
|
makeDgmoError(
|
|
422
497
|
lineNum,
|
|
@@ -426,10 +501,6 @@ export function parseBoxesAndLines(content: string): ParsedBoxesAndLines {
|
|
|
426
501
|
);
|
|
427
502
|
continue;
|
|
428
503
|
}
|
|
429
|
-
const sourcePrefix = lastSourceIsGroup
|
|
430
|
-
? `[${lastNodeLabel}]`
|
|
431
|
-
: lastNodeLabel;
|
|
432
|
-
edgeText = `${sourcePrefix} ${trimmed}`;
|
|
433
504
|
}
|
|
434
505
|
|
|
435
506
|
const edge = parseEdgeLine(
|
|
@@ -449,6 +520,7 @@ export function parseBoxesAndLines(content: string): ParsedBoxesAndLines {
|
|
|
449
520
|
// Node: everything else
|
|
450
521
|
contentStarted = true;
|
|
451
522
|
currentTagGroup = null;
|
|
523
|
+
flushDescription(); // Flush any pending description from previous node
|
|
452
524
|
const node = parseNodeLine(trimmed, lineNum, aliasMap, result.diagnostics);
|
|
453
525
|
if (!node) {
|
|
454
526
|
result.diagnostics.push(
|
|
@@ -486,8 +558,12 @@ export function parseBoxesAndLines(content: string): ParsedBoxesAndLines {
|
|
|
486
558
|
}
|
|
487
559
|
|
|
488
560
|
result.nodes.push(node);
|
|
561
|
+
descState = { nodeLabel: node.label, indent, lines: [], edgeSeen: false };
|
|
489
562
|
}
|
|
490
563
|
|
|
564
|
+
// Flush any remaining description
|
|
565
|
+
flushDescription();
|
|
566
|
+
|
|
491
567
|
// Close any remaining groups
|
|
492
568
|
while (groupStack.length > 0) {
|
|
493
569
|
const gs = groupStack.pop()!;
|
|
@@ -562,7 +638,7 @@ function parseNodeLine(
|
|
|
562
638
|
_diagnostics: DgmoError[]
|
|
563
639
|
): BLNode | null {
|
|
564
640
|
let metadata: Record<string, string> = {};
|
|
565
|
-
let description: string | undefined;
|
|
641
|
+
let description: string[] | undefined;
|
|
566
642
|
|
|
567
643
|
// Split on pipe for metadata
|
|
568
644
|
const pipeIdx = trimmed.indexOf('|');
|