@diagrammo/dgmo 0.8.8 → 0.8.10
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 +3 -0
- package/dist/cli.cjs +181 -179
- package/dist/index.cjs +1425 -933
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +147 -1
- package/dist/index.d.ts +147 -1
- package/dist/index.js +1421 -933
- package/dist/index.js.map +1 -1
- package/docs/language-reference.md +28 -2
- package/gallery/fixtures/sitemap-full.dgmo +1 -0
- package/package.json +1 -1
- package/src/boxes-and-lines/layout.ts +48 -8
- package/src/boxes-and-lines/parser.ts +59 -13
- package/src/boxes-and-lines/renderer.ts +33 -137
- package/src/c4/renderer.ts +25 -138
- package/src/class/renderer.ts +185 -186
- package/src/d3.ts +114 -191
- package/src/echarts.ts +99 -214
- package/src/er/renderer.ts +52 -245
- package/src/gantt/renderer.ts +140 -182
- package/src/index.ts +21 -1
- package/src/infra/renderer.ts +91 -244
- package/src/kanban/renderer.ts +22 -129
- package/src/org/renderer.ts +103 -170
- package/src/render.ts +39 -9
- package/src/sequence/renderer.ts +31 -151
- package/src/sitemap/layout.ts +180 -38
- package/src/sitemap/parser.ts +64 -23
- package/src/sitemap/renderer.ts +73 -161
- package/src/utils/legend-constants.ts +6 -0
- package/src/utils/legend-d3.ts +400 -0
- package/src/utils/legend-layout.ts +495 -0
- package/src/utils/legend-svg.ts +26 -0
- package/src/utils/legend-types.ts +169 -0
package/src/sitemap/layout.ts
CHANGED
|
@@ -6,7 +6,11 @@ import dagre from '@dagrejs/dagre';
|
|
|
6
6
|
import type { ParsedSitemap, SitemapNode } from './types';
|
|
7
7
|
import type { TagGroup } from '../utils/tag-groups';
|
|
8
8
|
import { resolveTagColor, injectDefaultTagMetadata } from '../utils/tag-groups';
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
LEGEND_PILL_FONT_SIZE,
|
|
11
|
+
LEGEND_ENTRY_FONT_SIZE,
|
|
12
|
+
measureLegendText,
|
|
13
|
+
} from '../utils/legend-constants';
|
|
10
14
|
|
|
11
15
|
// ============================================================
|
|
12
16
|
// Types
|
|
@@ -38,6 +42,8 @@ export interface SitemapLayoutEdge {
|
|
|
38
42
|
label?: string;
|
|
39
43
|
color?: string;
|
|
40
44
|
lineNumber: number;
|
|
45
|
+
/** True for edges deferred from dagre (container endpoints) — use linear curve */
|
|
46
|
+
deferred?: boolean;
|
|
41
47
|
}
|
|
42
48
|
|
|
43
49
|
export interface SitemapContainerBounds {
|
|
@@ -85,6 +91,29 @@ export interface SitemapLayoutResult {
|
|
|
85
91
|
height: number;
|
|
86
92
|
}
|
|
87
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Clip a point at (cx, cy) to the border of a rectangle centered at (cx, cy)
|
|
96
|
+
* with given width/height, along the direction toward (tx, ty).
|
|
97
|
+
*/
|
|
98
|
+
function clipToRectBorder(
|
|
99
|
+
cx: number,
|
|
100
|
+
cy: number,
|
|
101
|
+
w: number,
|
|
102
|
+
h: number,
|
|
103
|
+
tx: number,
|
|
104
|
+
ty: number
|
|
105
|
+
): { x: number; y: number } {
|
|
106
|
+
const dx = tx - cx;
|
|
107
|
+
const dy = ty - cy;
|
|
108
|
+
if (dx === 0 && dy === 0) return { x: cx, y: cy };
|
|
109
|
+
const hw = w / 2;
|
|
110
|
+
const hh = h / 2;
|
|
111
|
+
const sx = dx !== 0 ? hw / Math.abs(dx) : Infinity;
|
|
112
|
+
const sy = dy !== 0 ? hh / Math.abs(dy) : Infinity;
|
|
113
|
+
const s = Math.min(sx, sy);
|
|
114
|
+
return { x: cx + dx * s, y: cy + dy * s };
|
|
115
|
+
}
|
|
116
|
+
|
|
88
117
|
// ============================================================
|
|
89
118
|
// Constants
|
|
90
119
|
// ============================================================
|
|
@@ -120,7 +149,7 @@ const LEGEND_EYE_GAP = 6;
|
|
|
120
149
|
|
|
121
150
|
function filterMetadata(
|
|
122
151
|
metadata: Record<string, string>,
|
|
123
|
-
hiddenAttributes?: Set<string
|
|
152
|
+
hiddenAttributes?: Set<string>
|
|
124
153
|
): Record<string, string> {
|
|
125
154
|
if (!hiddenAttributes || hiddenAttributes.size === 0) return metadata;
|
|
126
155
|
const filtered: Record<string, string> = {};
|
|
@@ -138,22 +167,32 @@ function computeCardWidth(label: string, meta: Record<string, string>): number {
|
|
|
138
167
|
const lineChars = key.length + 2 + value.length;
|
|
139
168
|
if (lineChars > maxChars) maxChars = lineChars;
|
|
140
169
|
}
|
|
141
|
-
return Math.max(
|
|
170
|
+
return Math.max(
|
|
171
|
+
MIN_CARD_WIDTH,
|
|
172
|
+
Math.ceil(maxChars * CHAR_WIDTH) + CARD_H_PAD * 2
|
|
173
|
+
);
|
|
142
174
|
}
|
|
143
175
|
|
|
144
176
|
function computeCardHeight(meta: Record<string, string>): number {
|
|
145
177
|
const metaCount = Object.keys(meta).length;
|
|
146
178
|
if (metaCount === 0) return HEADER_HEIGHT + CARD_V_PAD;
|
|
147
|
-
return
|
|
179
|
+
return (
|
|
180
|
+
HEADER_HEIGHT + SEPARATOR_GAP + metaCount * META_LINE_HEIGHT + CARD_V_PAD
|
|
181
|
+
);
|
|
148
182
|
}
|
|
149
183
|
|
|
150
184
|
function resolveNodeColor(
|
|
151
185
|
node: SitemapNode,
|
|
152
186
|
tagGroups: TagGroup[],
|
|
153
|
-
activeGroupName: string | null
|
|
187
|
+
activeGroupName: string | null
|
|
154
188
|
): string | undefined {
|
|
155
189
|
if (node.color) return node.color;
|
|
156
|
-
return resolveTagColor(
|
|
190
|
+
return resolveTagColor(
|
|
191
|
+
node.metadata,
|
|
192
|
+
tagGroups,
|
|
193
|
+
activeGroupName,
|
|
194
|
+
node.isContainer
|
|
195
|
+
);
|
|
157
196
|
}
|
|
158
197
|
|
|
159
198
|
const OVERLAP_GAP = 20;
|
|
@@ -164,7 +203,7 @@ const OVERLAP_GAP = 20;
|
|
|
164
203
|
|
|
165
204
|
function computeLegendGroups(
|
|
166
205
|
tagGroups: TagGroup[],
|
|
167
|
-
usedValuesByGroup?: Map<string, Set<string
|
|
206
|
+
usedValuesByGroup?: Map<string, Set<string>>
|
|
168
207
|
): SitemapLegendGroup[] {
|
|
169
208
|
const groups: SitemapLegendGroup[] = [];
|
|
170
209
|
|
|
@@ -177,7 +216,8 @@ function computeLegendGroups(
|
|
|
177
216
|
: group.entries;
|
|
178
217
|
if (visibleEntries.length === 0) continue;
|
|
179
218
|
|
|
180
|
-
const pillWidth =
|
|
219
|
+
const pillWidth =
|
|
220
|
+
measureLegendText(group.name, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
|
|
181
221
|
const minPillWidth = pillWidth;
|
|
182
222
|
|
|
183
223
|
let entriesWidth = 0;
|
|
@@ -189,7 +229,8 @@ function computeLegendGroups(
|
|
|
189
229
|
LEGEND_ENTRY_TRAIL;
|
|
190
230
|
}
|
|
191
231
|
const eyeSpace = LEGEND_EYE_SIZE + LEGEND_EYE_GAP;
|
|
192
|
-
const capsuleWidth =
|
|
232
|
+
const capsuleWidth =
|
|
233
|
+
LEGEND_CAPSULE_PAD * 2 + pillWidth + 4 + eyeSpace + entriesWidth;
|
|
193
234
|
|
|
194
235
|
groups.push({
|
|
195
236
|
name: group.name,
|
|
@@ -229,25 +270,36 @@ function flattenNodes(
|
|
|
229
270
|
parentPageId: string | null,
|
|
230
271
|
hiddenCounts: Map<string, number> | undefined,
|
|
231
272
|
hiddenAttributes: Set<string> | undefined,
|
|
232
|
-
result: FlatNode[]
|
|
273
|
+
result: FlatNode[]
|
|
233
274
|
): void {
|
|
234
275
|
for (const node of nodes) {
|
|
235
276
|
const meta = filterMetadata(node.metadata, hiddenAttributes);
|
|
236
277
|
if (node.isContainer) {
|
|
237
278
|
// Container gets added as a flat entry (not added to dagre — bounds computed post-hoc)
|
|
238
279
|
const metaCount = Object.keys(meta).length;
|
|
239
|
-
const labelHeight =
|
|
280
|
+
const labelHeight =
|
|
281
|
+
CONTAINER_LABEL_HEIGHT + metaCount * CONTAINER_META_LINE_HEIGHT;
|
|
240
282
|
result.push({
|
|
241
283
|
sitemapNode: node,
|
|
242
284
|
parentContainerId,
|
|
243
285
|
parentPageId,
|
|
244
286
|
meta,
|
|
245
287
|
fullMeta: { ...node.metadata },
|
|
246
|
-
width: Math.max(
|
|
288
|
+
width: Math.max(
|
|
289
|
+
MIN_CARD_WIDTH,
|
|
290
|
+
node.label.length * CHAR_WIDTH + CARD_H_PAD * 2
|
|
291
|
+
),
|
|
247
292
|
height: labelHeight + CONTAINER_PAD_BOTTOM,
|
|
248
293
|
});
|
|
249
294
|
// Recurse into children — container becomes parent container, parentPageId stays the same
|
|
250
|
-
flattenNodes(
|
|
295
|
+
flattenNodes(
|
|
296
|
+
node.children,
|
|
297
|
+
node.id,
|
|
298
|
+
parentPageId,
|
|
299
|
+
hiddenCounts,
|
|
300
|
+
hiddenAttributes,
|
|
301
|
+
result
|
|
302
|
+
);
|
|
251
303
|
} else {
|
|
252
304
|
result.push({
|
|
253
305
|
sitemapNode: node,
|
|
@@ -260,7 +312,14 @@ function flattenNodes(
|
|
|
260
312
|
});
|
|
261
313
|
// Pages can have children too (nested pages) — this page becomes the parentPageId
|
|
262
314
|
if (node.children.length > 0) {
|
|
263
|
-
flattenNodes(
|
|
315
|
+
flattenNodes(
|
|
316
|
+
node.children,
|
|
317
|
+
parentContainerId,
|
|
318
|
+
node.id,
|
|
319
|
+
hiddenCounts,
|
|
320
|
+
hiddenAttributes,
|
|
321
|
+
result
|
|
322
|
+
);
|
|
264
323
|
}
|
|
265
324
|
}
|
|
266
325
|
}
|
|
@@ -275,10 +334,17 @@ export function layoutSitemap(
|
|
|
275
334
|
hiddenCounts?: Map<string, number>,
|
|
276
335
|
activeTagGroup?: string | null,
|
|
277
336
|
hiddenAttributes?: Set<string>,
|
|
278
|
-
expandAllLegend?: boolean
|
|
337
|
+
expandAllLegend?: boolean
|
|
279
338
|
): SitemapLayoutResult {
|
|
280
339
|
if (parsed.roots.length === 0) {
|
|
281
|
-
return {
|
|
340
|
+
return {
|
|
341
|
+
nodes: [],
|
|
342
|
+
edges: [],
|
|
343
|
+
containers: [],
|
|
344
|
+
legend: [],
|
|
345
|
+
width: 0,
|
|
346
|
+
height: 0,
|
|
347
|
+
};
|
|
282
348
|
}
|
|
283
349
|
|
|
284
350
|
// Inject default tag metadata
|
|
@@ -288,11 +354,22 @@ export function layoutSitemap(
|
|
|
288
354
|
for (const child of node.children) collect(child);
|
|
289
355
|
};
|
|
290
356
|
for (const root of parsed.roots) collect(root);
|
|
291
|
-
injectDefaultTagMetadata(
|
|
357
|
+
injectDefaultTagMetadata(
|
|
358
|
+
allNodes,
|
|
359
|
+
parsed.tagGroups,
|
|
360
|
+
(e) => (e as SitemapNode).isContainer
|
|
361
|
+
);
|
|
292
362
|
|
|
293
363
|
// Flatten hierarchy
|
|
294
364
|
const flatNodes: FlatNode[] = [];
|
|
295
|
-
flattenNodes(
|
|
365
|
+
flattenNodes(
|
|
366
|
+
parsed.roots,
|
|
367
|
+
null,
|
|
368
|
+
null,
|
|
369
|
+
hiddenCounts,
|
|
370
|
+
hiddenAttributes,
|
|
371
|
+
flatNodes
|
|
372
|
+
);
|
|
296
373
|
|
|
297
374
|
// Build nodeMap for lookups
|
|
298
375
|
const nodeMap = new Map<string, FlatNode>();
|
|
@@ -325,7 +402,7 @@ export function layoutSitemap(
|
|
|
325
402
|
containerIds.add(flat.sitemapNode.id);
|
|
326
403
|
// A container is "collapsed" if it has no children at all in the flat list
|
|
327
404
|
const hasAnyChild = flatNodes.some(
|
|
328
|
-
(f) => f.parentContainerId === flat.sitemapNode.id
|
|
405
|
+
(f) => f.parentContainerId === flat.sitemapNode.id
|
|
329
406
|
);
|
|
330
407
|
if (!hasAnyChild) {
|
|
331
408
|
collapsedContainerIds.add(flat.sitemapNode.id);
|
|
@@ -367,20 +444,44 @@ export function layoutSitemap(
|
|
|
367
444
|
|
|
368
445
|
// Set parent relationships — dagre compound nesting keeps nodes grouped
|
|
369
446
|
for (const flat of flatNodes) {
|
|
370
|
-
if (
|
|
447
|
+
if (
|
|
448
|
+
flat.parentContainerId &&
|
|
449
|
+
!collapsedContainerIds.has(flat.parentContainerId)
|
|
450
|
+
) {
|
|
371
451
|
g.setParent(flat.sitemapNode.id, flat.parentContainerId);
|
|
372
452
|
}
|
|
373
453
|
}
|
|
374
454
|
|
|
375
|
-
//
|
|
455
|
+
// Build set of expanded (non-collapsed) container IDs — dagre can't route
|
|
456
|
+
// edges to compound parents (they have no rank of their own)
|
|
457
|
+
const expandedContainerIds = new Set<string>();
|
|
458
|
+
for (const cid of containerIds) {
|
|
459
|
+
if (!collapsedContainerIds.has(cid)) {
|
|
460
|
+
expandedContainerIds.add(cid);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Add user edges — defer edges touching expanded containers
|
|
465
|
+
const deferredEdgeIndices: number[] = [];
|
|
376
466
|
for (let i = 0; i < parsed.edges.length; i++) {
|
|
377
467
|
const edge = parsed.edges[i];
|
|
378
|
-
if (g.hasNode(edge.sourceId)
|
|
379
|
-
|
|
468
|
+
if (!g.hasNode(edge.sourceId) || !g.hasNode(edge.targetId)) continue;
|
|
469
|
+
if (
|
|
470
|
+
expandedContainerIds.has(edge.sourceId) ||
|
|
471
|
+
expandedContainerIds.has(edge.targetId)
|
|
472
|
+
) {
|
|
473
|
+
deferredEdgeIndices.push(i);
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
g.setEdge(
|
|
477
|
+
edge.sourceId,
|
|
478
|
+
edge.targetId,
|
|
479
|
+
{
|
|
380
480
|
label: edge.label ?? '',
|
|
381
481
|
minlen: 1,
|
|
382
|
-
},
|
|
383
|
-
|
|
482
|
+
},
|
|
483
|
+
`e${i}`
|
|
484
|
+
);
|
|
384
485
|
}
|
|
385
486
|
|
|
386
487
|
// Run dagre layout
|
|
@@ -412,7 +513,7 @@ export function layoutSitemap(
|
|
|
412
513
|
height: pos.height,
|
|
413
514
|
hiddenCount: hc,
|
|
414
515
|
hasChildren:
|
|
415
|
-
|
|
516
|
+
node.children.length > 0 || (hc != null && hc > 0) || undefined,
|
|
416
517
|
});
|
|
417
518
|
}
|
|
418
519
|
|
|
@@ -424,7 +525,8 @@ export function layoutSitemap(
|
|
|
424
525
|
const pos = g.node(node.id);
|
|
425
526
|
const hc = hiddenCounts?.get(node.id);
|
|
426
527
|
const metaCount = Object.keys(flat.meta).length;
|
|
427
|
-
const labelHeight =
|
|
528
|
+
const labelHeight =
|
|
529
|
+
CONTAINER_LABEL_HEIGHT + metaCount * CONTAINER_META_LINE_HEIGHT;
|
|
428
530
|
|
|
429
531
|
if (pos) {
|
|
430
532
|
layoutContainers.push({
|
|
@@ -441,7 +543,7 @@ export function layoutSitemap(
|
|
|
441
543
|
labelHeight,
|
|
442
544
|
hiddenCount: hc,
|
|
443
545
|
hasChildren:
|
|
444
|
-
|
|
546
|
+
node.children.length > 0 || (hc != null && hc > 0) || undefined,
|
|
445
547
|
});
|
|
446
548
|
} else {
|
|
447
549
|
// Fallback
|
|
@@ -459,26 +561,62 @@ export function layoutSitemap(
|
|
|
459
561
|
labelHeight,
|
|
460
562
|
hiddenCount: hc,
|
|
461
563
|
hasChildren:
|
|
462
|
-
|
|
564
|
+
node.children.length > 0 || (hc != null && hc > 0) || undefined,
|
|
463
565
|
});
|
|
464
566
|
}
|
|
465
567
|
}
|
|
466
568
|
|
|
467
|
-
// Edge waypoints from dagre (named edges for multigraph)
|
|
569
|
+
// Edge waypoints from dagre (named edges for multigraph) + deferred edges
|
|
570
|
+
const deferredSet = new Set(deferredEdgeIndices);
|
|
468
571
|
const layoutEdges: SitemapLayoutEdge[] = [];
|
|
469
572
|
for (let i = 0; i < parsed.edges.length; i++) {
|
|
470
573
|
const edge = parsed.edges[i];
|
|
471
574
|
if (!g.hasNode(edge.sourceId) || !g.hasNode(edge.targetId)) continue;
|
|
472
|
-
|
|
473
|
-
|
|
575
|
+
|
|
576
|
+
let points: { x: number; y: number }[];
|
|
577
|
+
|
|
578
|
+
if (deferredSet.has(i)) {
|
|
579
|
+
// Deferred edge (expanded container endpoint) — clip to border
|
|
580
|
+
const srcNode = g.node(edge.sourceId);
|
|
581
|
+
const tgtNode = g.node(edge.targetId);
|
|
582
|
+
if (!srcNode || !tgtNode) continue;
|
|
583
|
+
const srcPt = clipToRectBorder(
|
|
584
|
+
srcNode.x,
|
|
585
|
+
srcNode.y,
|
|
586
|
+
srcNode.width,
|
|
587
|
+
srcNode.height,
|
|
588
|
+
tgtNode.x,
|
|
589
|
+
tgtNode.y
|
|
590
|
+
);
|
|
591
|
+
const tgtPt = clipToRectBorder(
|
|
592
|
+
tgtNode.x,
|
|
593
|
+
tgtNode.y,
|
|
594
|
+
tgtNode.width,
|
|
595
|
+
tgtNode.height,
|
|
596
|
+
srcNode.x,
|
|
597
|
+
srcNode.y
|
|
598
|
+
);
|
|
599
|
+
const midX = (srcPt.x + tgtPt.x) / 2;
|
|
600
|
+
const midY = (srcPt.y + tgtPt.y) / 2;
|
|
601
|
+
points = [srcPt, { x: midX, y: midY }, tgtPt];
|
|
602
|
+
} else {
|
|
603
|
+
const edgeData = g.edge({
|
|
604
|
+
v: edge.sourceId,
|
|
605
|
+
w: edge.targetId,
|
|
606
|
+
name: `e${i}`,
|
|
607
|
+
});
|
|
608
|
+
if (!edgeData) continue;
|
|
609
|
+
points = edgeData.points ?? [];
|
|
610
|
+
}
|
|
474
611
|
|
|
475
612
|
layoutEdges.push({
|
|
476
613
|
sourceId: edge.sourceId,
|
|
477
614
|
targetId: edge.targetId,
|
|
478
|
-
points
|
|
615
|
+
points,
|
|
479
616
|
label: edge.label,
|
|
480
617
|
color: edge.color,
|
|
481
618
|
lineNumber: edge.lineNumber,
|
|
619
|
+
deferred: deferredSet.has(i) || undefined,
|
|
482
620
|
});
|
|
483
621
|
}
|
|
484
622
|
|
|
@@ -509,7 +647,8 @@ export function layoutSitemap(
|
|
|
509
647
|
}
|
|
510
648
|
|
|
511
649
|
// Main component = component containing the first root page
|
|
512
|
-
const firstRootPage = flatNodes.find((f) => !f.sitemapNode.isContainer)
|
|
650
|
+
const firstRootPage = flatNodes.find((f) => !f.sitemapNode.isContainer)
|
|
651
|
+
?.sitemapNode.id;
|
|
513
652
|
const mainRoot = firstRootPage ? ufFind(firstRootPage) : null;
|
|
514
653
|
|
|
515
654
|
// Collect isolated node IDs (not in main component)
|
|
@@ -528,7 +667,7 @@ export function layoutSitemap(
|
|
|
528
667
|
continue;
|
|
529
668
|
}
|
|
530
669
|
const members = flatNodes.filter(
|
|
531
|
-
(f) => !f.sitemapNode.isContainer && f.parentContainerId === cid
|
|
670
|
+
(f) => !f.sitemapNode.isContainer && f.parentContainerId === cid
|
|
532
671
|
);
|
|
533
672
|
if (
|
|
534
673
|
members.length > 0 &&
|
|
@@ -677,9 +816,12 @@ export function layoutSitemap(
|
|
|
677
816
|
// Legend
|
|
678
817
|
const legendGroups = computeLegendGroups(parsed.tagGroups, usedValuesByGroup);
|
|
679
818
|
|
|
680
|
-
const visibleGroups =
|
|
681
|
-
|
|
682
|
-
|
|
819
|
+
const visibleGroups =
|
|
820
|
+
activeTagGroup != null
|
|
821
|
+
? legendGroups.filter(
|
|
822
|
+
(g) => g.name.toLowerCase() === activeTagGroup.toLowerCase()
|
|
823
|
+
)
|
|
824
|
+
: legendGroups;
|
|
683
825
|
const allExpanded = expandAllLegend && activeTagGroup == null;
|
|
684
826
|
const effectiveW = (g: SitemapLegendGroup) =>
|
|
685
827
|
activeTagGroup != null || allExpanded ? g.width : g.minifiedWidth;
|
package/src/sitemap/parser.ts
CHANGED
|
@@ -46,11 +46,21 @@ const BARE_ARROW_RE = /^->\s*(.+)$/;
|
|
|
46
46
|
function parseArrowLine(
|
|
47
47
|
trimmed: string,
|
|
48
48
|
palette?: PaletteColors
|
|
49
|
-
): {
|
|
49
|
+
): {
|
|
50
|
+
label?: string;
|
|
51
|
+
color?: string;
|
|
52
|
+
target: string;
|
|
53
|
+
targetIsGroup: boolean;
|
|
54
|
+
} | null {
|
|
50
55
|
// Bare arrow: -> Target
|
|
51
56
|
const bareMatch = trimmed.match(BARE_ARROW_RE);
|
|
52
57
|
if (bareMatch) {
|
|
53
|
-
|
|
58
|
+
const rawTarget = bareMatch[1].trim();
|
|
59
|
+
const groupMatch = rawTarget.match(/^\[(.+)\]$/);
|
|
60
|
+
return {
|
|
61
|
+
target: groupMatch ? groupMatch[1].trim() : rawTarget,
|
|
62
|
+
targetIsGroup: !!groupMatch,
|
|
63
|
+
};
|
|
54
64
|
}
|
|
55
65
|
|
|
56
66
|
// Labeled/colored arrow: -label(color)-> Target
|
|
@@ -63,8 +73,14 @@ function parseArrowLine(
|
|
|
63
73
|
if (label && !color) {
|
|
64
74
|
color = inferArrowColor(label);
|
|
65
75
|
}
|
|
66
|
-
const
|
|
67
|
-
|
|
76
|
+
const rawTarget = arrowMatch[3].trim();
|
|
77
|
+
const groupMatch = rawTarget.match(/^\[(.+)\]$/);
|
|
78
|
+
return {
|
|
79
|
+
label,
|
|
80
|
+
color,
|
|
81
|
+
target: groupMatch ? groupMatch[1].trim() : rawTarget,
|
|
82
|
+
targetIsGroup: !!groupMatch,
|
|
83
|
+
};
|
|
68
84
|
}
|
|
69
85
|
|
|
70
86
|
return null;
|
|
@@ -169,10 +185,14 @@ export function parseSitemap(
|
|
|
169
185
|
// Map label (lowercased) -> node for arrow target resolution
|
|
170
186
|
const labelToNode = new Map<string, SitemapNode>();
|
|
171
187
|
|
|
188
|
+
// Map label (lowercased) -> container for group-targeted arrow resolution
|
|
189
|
+
const labelToContainer = new Map<string, SitemapNode>();
|
|
190
|
+
|
|
172
191
|
// Deferred arrows: { sourceNode, arrow info, lineNumber }
|
|
173
192
|
const deferredArrows: {
|
|
174
193
|
sourceNode: SitemapNode;
|
|
175
194
|
targetLabel: string;
|
|
195
|
+
targetIsGroup: boolean;
|
|
176
196
|
label?: string;
|
|
177
197
|
color?: string;
|
|
178
198
|
lineNumber: number;
|
|
@@ -311,6 +331,7 @@ export function parseSitemap(
|
|
|
311
331
|
deferredArrows.push({
|
|
312
332
|
sourceNode: source,
|
|
313
333
|
targetLabel: arrowInfo.target,
|
|
334
|
+
targetIsGroup: arrowInfo.targetIsGroup,
|
|
314
335
|
label: arrowInfo.label,
|
|
315
336
|
color: arrowInfo.color,
|
|
316
337
|
lineNumber,
|
|
@@ -356,7 +377,8 @@ export function parseSitemap(
|
|
|
356
377
|
};
|
|
357
378
|
|
|
358
379
|
attachNode(node, indent, indentStack, result);
|
|
359
|
-
//
|
|
380
|
+
// Register in labelToContainer for group-targeted arrows (-> [Group])
|
|
381
|
+
labelToContainer.set(label.toLowerCase(), node);
|
|
360
382
|
} else if (metadataMatch && indentStack.length > 0) {
|
|
361
383
|
// Metadata line — attach to parent
|
|
362
384
|
const rawKey = metadataMatch[1].trim().toLowerCase();
|
|
@@ -403,25 +425,44 @@ export function parseSitemap(
|
|
|
403
425
|
// --- Post-parse: resolve arrow targets ---
|
|
404
426
|
for (const arrow of deferredArrows) {
|
|
405
427
|
const targetKey = arrow.targetLabel.toLowerCase();
|
|
406
|
-
const targetNode = labelToNode.get(targetKey);
|
|
407
|
-
|
|
408
|
-
if (!targetNode) {
|
|
409
|
-
// Try suggestion
|
|
410
|
-
const allLabels = Array.from(labelToNode.keys());
|
|
411
|
-
let msg = `Arrow target "${arrow.targetLabel}" not found`;
|
|
412
|
-
const hint = suggest(targetKey, allLabels);
|
|
413
|
-
if (hint) msg += `. ${hint}`;
|
|
414
|
-
pushError(arrow.lineNumber, msg);
|
|
415
|
-
continue;
|
|
416
|
-
}
|
|
417
428
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
429
|
+
if (arrow.targetIsGroup) {
|
|
430
|
+
// Group target: look up in labelToContainer
|
|
431
|
+
const targetContainer = labelToContainer.get(targetKey);
|
|
432
|
+
if (!targetContainer) {
|
|
433
|
+
const allLabels = Array.from(labelToContainer.keys());
|
|
434
|
+
let msg = `Group '[${arrow.targetLabel}]' not found`;
|
|
435
|
+
const hint = suggest(targetKey, allLabels);
|
|
436
|
+
if (hint) msg += `. ${hint}`;
|
|
437
|
+
pushError(arrow.lineNumber, msg);
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
result.edges.push({
|
|
441
|
+
sourceId: arrow.sourceNode.id,
|
|
442
|
+
targetId: targetContainer.id,
|
|
443
|
+
label: arrow.label,
|
|
444
|
+
color: arrow.color,
|
|
445
|
+
lineNumber: arrow.lineNumber,
|
|
446
|
+
});
|
|
447
|
+
} else {
|
|
448
|
+
// Node target: look up in labelToNode (existing behavior)
|
|
449
|
+
const targetNode = labelToNode.get(targetKey);
|
|
450
|
+
if (!targetNode) {
|
|
451
|
+
const allLabels = Array.from(labelToNode.keys());
|
|
452
|
+
let msg = `Arrow target "${arrow.targetLabel}" not found`;
|
|
453
|
+
const hint = suggest(targetKey, allLabels);
|
|
454
|
+
if (hint) msg += `. ${hint}`;
|
|
455
|
+
pushError(arrow.lineNumber, msg);
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
result.edges.push({
|
|
459
|
+
sourceId: arrow.sourceNode.id,
|
|
460
|
+
targetId: targetNode.id,
|
|
461
|
+
label: arrow.label,
|
|
462
|
+
color: arrow.color,
|
|
463
|
+
lineNumber: arrow.lineNumber,
|
|
464
|
+
});
|
|
465
|
+
}
|
|
425
466
|
}
|
|
426
467
|
|
|
427
468
|
// Validate tag group values on all nodes
|