@diagrammo/dgmo 0.32.1 → 0.34.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/.cursorrules +7 -2
- package/.github/copilot-instructions.md +7 -2
- package/.windsurfrules +7 -2
- package/SKILL.md +7 -2
- package/dist/advanced.cjs +1434 -443
- package/dist/advanced.d.cts +41 -1
- package/dist/advanced.d.ts +41 -1
- package/dist/advanced.js +1433 -443
- package/dist/auto.cjs +1242 -318
- package/dist/auto.js +91 -91
- package/dist/auto.mjs +1242 -318
- package/dist/cli.cjs +138 -136
- package/dist/index.cjs +1308 -317
- package/dist/index.d.cts +35 -1
- package/dist/index.d.ts +35 -1
- package/dist/index.js +1307 -317
- package/docs/ai-integration.md +7 -2
- package/docs/language-reference.md +39 -8
- package/package.json +2 -2
- package/src/advanced.ts +6 -1
- package/src/boxes-and-lines/label-placement.ts +339 -0
- package/src/boxes-and-lines/layout-grouped.ts +909 -0
- package/src/boxes-and-lines/layout-search.ts +34 -3
- package/src/boxes-and-lines/layout.ts +32 -7
- package/src/boxes-and-lines/parser.ts +3 -2
- package/src/boxes-and-lines/renderer.ts +67 -139
- package/src/c4/layout.ts +2 -1
- package/src/c4/parser.ts +3 -2
- package/src/c4/renderer.ts +3 -2
- package/src/cli.ts +68 -0
- package/src/er/parser.ts +2 -1
- package/src/er/renderer.ts +2 -1
- package/src/gantt/parser.ts +3 -5
- package/src/gantt/renderer.ts +7 -6
- package/src/index.ts +6 -1
- package/src/infra/parser.ts +2 -1
- package/src/infra/renderer.ts +4 -3
- package/src/journey-map/parser.ts +27 -8
- package/src/journey-map/renderer.ts +26 -9
- package/src/kanban/parser.ts +4 -3
- package/src/kanban/renderer.ts +6 -5
- package/src/map/completion.ts +116 -0
- package/src/map/layout.ts +4 -3
- package/src/map/parser.ts +3 -2
- package/src/mindmap/parser.ts +4 -3
- package/src/mindmap/renderer.ts +2 -1
- package/src/org/layout.ts +3 -2
- package/src/org/parser.ts +3 -2
- package/src/org/renderer.ts +5 -4
- package/src/pert/parser.ts +3 -2
- package/src/sequence/renderer.ts +10 -12
- package/src/sequence/tag-resolution.ts +2 -1
- package/src/sitemap/layout.ts +3 -2
- package/src/sitemap/parser.ts +36 -2
- package/src/sitemap/renderer.ts +12 -6
- package/src/timeline/renderer.ts +4 -3
- package/src/utils/legend-d3.ts +4 -3
- package/src/utils/legend-svg.ts +3 -2
- package/src/utils/tag-groups.ts +28 -7
- package/src/visualizations/parse.ts +2 -1
|
@@ -22,6 +22,8 @@ import {
|
|
|
22
22
|
type BLLayoutEdge,
|
|
23
23
|
} from './layout';
|
|
24
24
|
import { layeredCandidates } from './layout-layered';
|
|
25
|
+
import { groupedTierCandidates } from './layout-grouped';
|
|
26
|
+
import { measureEdgeLabel, EDGE_LABEL_FONT_SIZE } from './label-placement';
|
|
25
27
|
|
|
26
28
|
type Pt = { x: number; y: number };
|
|
27
29
|
|
|
@@ -916,9 +918,14 @@ export async function layoutBoxesAndLinesSearch(
|
|
|
916
918
|
* indicator. Omit it (CLI/export) and the search runs straight through with
|
|
917
919
|
* no added latency. */
|
|
918
920
|
onProgress?: (done: number, total: number, phase: string) => void;
|
|
921
|
+
/** Last-resort label legibility: reserve dagre label space for every labeled
|
|
922
|
+
* edge so the layout opens a gap wide enough for the (wrapped) label. Only
|
|
923
|
+
* set by layout.ts when steps 1–2 left a label overlapping a node box. */
|
|
924
|
+
reserveEdgeLabels?: boolean;
|
|
919
925
|
}
|
|
920
926
|
): Promise<BLLayoutResult> {
|
|
921
927
|
const hideDescriptions = opts?.hideDescriptions ?? false;
|
|
928
|
+
const reserveEdgeLabels = opts?.reserveEdgeLabels ?? false;
|
|
922
929
|
const onProgress = opts?.onProgress;
|
|
923
930
|
// Yield to a macrotask (lets the browser repaint between heavy placements);
|
|
924
931
|
// no-op when there's no progress observer so non-interactive callers pay
|
|
@@ -1322,8 +1329,16 @@ export async function layoutBoxesAndLinesSearch(
|
|
|
1322
1329
|
for (const e of ord(parsed.edges)) {
|
|
1323
1330
|
const s = srcRep(e.source);
|
|
1324
1331
|
const t = tgtRep(e.target);
|
|
1325
|
-
if (s !== t && g.hasNode(s) && g.hasNode(t))
|
|
1326
|
-
|
|
1332
|
+
if (s !== t && g.hasNode(s) && g.hasNode(t)) {
|
|
1333
|
+
// Last-resort label legibility: reserve a virtual label node on the edge
|
|
1334
|
+
// so dagre widens the rank gap to fit the (wrapped) label.
|
|
1335
|
+
let edgeLabel: object = {};
|
|
1336
|
+
if (reserveEdgeLabels && e.label) {
|
|
1337
|
+
const m = measureEdgeLabel(e.label, EDGE_LABEL_FONT_SIZE);
|
|
1338
|
+
edgeLabel = { width: m.width, height: m.height, labelpos: 'c' };
|
|
1339
|
+
}
|
|
1340
|
+
g.setEdge(s, t, edgeLabel, edgeKey(e));
|
|
1341
|
+
}
|
|
1327
1342
|
}
|
|
1328
1343
|
dagre.layout(g);
|
|
1329
1344
|
|
|
@@ -1528,6 +1543,21 @@ export async function layoutBoxesAndLinesSearch(
|
|
|
1528
1543
|
/* ignore */
|
|
1529
1544
|
}
|
|
1530
1545
|
|
|
1546
|
+
// Tier-banded grouped candidates. The dagre path treats `[Group]` containers
|
|
1547
|
+
// as compound clusters with no tier ORDER and no cycle-aware routing, so
|
|
1548
|
+
// tiered architecture diagrams collide bands side-by-side and balloon on
|
|
1549
|
+
// back-edges. This generator orders the groups into disjoint rank bands and
|
|
1550
|
+
// routes against-tier edges around the periphery. Additive — kept only on a
|
|
1551
|
+
// strict badness win. Skipped when any group is collapsed (the generator
|
|
1552
|
+
// models fully-expanded groups only).
|
|
1553
|
+
if (parsed.groups.length > 0 && collapsedGroupLabels.size === 0) {
|
|
1554
|
+
try {
|
|
1555
|
+
layered = layered.concat(groupedTierCandidates(parsed, sizes));
|
|
1556
|
+
} catch {
|
|
1557
|
+
/* ignore */
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1531
1561
|
// Stage 1: rank the dagre pool with the cheap straight-segment counter — a
|
|
1532
1562
|
// cheap proxy to pick which candidates are worth the expensive exact scoring.
|
|
1533
1563
|
// Widen REFINE_K a little since the proxy only sees crossings, not O/P.
|
|
@@ -1536,7 +1566,8 @@ export async function layoutBoxesAndLinesSearch(
|
|
|
1536
1566
|
// for both operands on every comparison (O(C log C) calls vs C). The score is a
|
|
1537
1567
|
// pure function of the layout, so the resulting order is identical.
|
|
1538
1568
|
const fastKey = new Map<BLLayoutResult, number>();
|
|
1539
|
-
for (const lay of pool)
|
|
1569
|
+
for (const lay of pool)
|
|
1570
|
+
fastKey.set(lay, objective(lay, countCrossingsFast(lay)));
|
|
1540
1571
|
pool.sort((a, b) => fastKey.get(a)! - fastKey.get(b)!);
|
|
1541
1572
|
const refineK = Math.min(REFINE_K, pool.length);
|
|
1542
1573
|
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import type { ParsedBoxesAndLines, BLNode, BLGroup } from './types';
|
|
11
11
|
import { measureText, wrapTextToWidth } from '../utils/text-measure';
|
|
12
|
+
import { placeEdgeLabels } from './label-placement';
|
|
12
13
|
import {
|
|
13
14
|
resolveNotes,
|
|
14
15
|
buildPlacedNotes,
|
|
@@ -58,8 +59,14 @@ export interface BLLayoutEdge {
|
|
|
58
59
|
readonly bidirectional: boolean;
|
|
59
60
|
readonly lineNumber: number;
|
|
60
61
|
readonly points: ReadonlyArray<{ readonly x: number; readonly y: number }>;
|
|
62
|
+
/** Centre of the label box (set by label-placement). */
|
|
61
63
|
readonly labelX?: number;
|
|
62
64
|
readonly labelY?: number;
|
|
65
|
+
/** Wrapped label box dimensions + lines (set by label-placement; the renderer
|
|
66
|
+
* draws the halo + tspans straight from these). */
|
|
67
|
+
readonly labelWidth?: number;
|
|
68
|
+
readonly labelHeight?: number;
|
|
69
|
+
readonly labelLines?: readonly string[];
|
|
63
70
|
readonly yOffset: number;
|
|
64
71
|
readonly parallelCount: number;
|
|
65
72
|
readonly metadata: Readonly<Record<string, string>>;
|
|
@@ -205,7 +212,7 @@ export async function layoutBoxesAndLines(
|
|
|
205
212
|
}
|
|
206
213
|
): Promise<BLLayoutResult> {
|
|
207
214
|
const { layoutBoxesAndLinesSearch } = await import('./layout-search');
|
|
208
|
-
const
|
|
215
|
+
const searchOpts = {
|
|
209
216
|
...(layoutOptions?.hideDescriptions !== undefined && {
|
|
210
217
|
hideDescriptions: layoutOptions.hideDescriptions,
|
|
211
218
|
}),
|
|
@@ -215,14 +222,31 @@ export async function layoutBoxesAndLines(
|
|
|
215
222
|
...(layoutOptions?.onProgress !== undefined && {
|
|
216
223
|
onProgress: layoutOptions.onProgress,
|
|
217
224
|
}),
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
// (and shift the canvas to fit them).
|
|
221
|
-
return attachNotes(
|
|
222
|
-
applyParallelEdgeOffsets(searched),
|
|
225
|
+
};
|
|
226
|
+
const searched = await layoutBoxesAndLinesSearch(
|
|
223
227
|
parsed,
|
|
224
|
-
|
|
228
|
+
collapseInfo,
|
|
229
|
+
searchOpts
|
|
225
230
|
);
|
|
231
|
+
|
|
232
|
+
// Edge-label legibility (priority ladder): wrap + reposition labels on the
|
|
233
|
+
// chosen layout. If any label still can't clear a node box, escalate ONCE to a
|
|
234
|
+
// label-aware relayout that reserves dagre label space so a gap opens — and
|
|
235
|
+
// keep it only if it actually resolves more labels.
|
|
236
|
+
let placed = placeEdgeLabels(applyParallelEdgeOffsets(searched));
|
|
237
|
+
if (placed.unresolved.length > 0) {
|
|
238
|
+
const relaid = await layoutBoxesAndLinesSearch(parsed, collapseInfo, {
|
|
239
|
+
...searchOpts,
|
|
240
|
+
reserveEdgeLabels: true,
|
|
241
|
+
});
|
|
242
|
+
const relaidPlaced = placeEdgeLabels(applyParallelEdgeOffsets(relaid));
|
|
243
|
+
if (relaidPlaced.unresolved.length < placed.unresolved.length)
|
|
244
|
+
placed = relaidPlaced;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Engine-agnostic post-processing: float notes (and shift the canvas to fit
|
|
248
|
+
// them) on the label-placed layout.
|
|
249
|
+
return attachNotes(placed.layout, parsed, layoutOptions?.collapsedNotes);
|
|
226
250
|
}
|
|
227
251
|
|
|
228
252
|
/**
|
|
@@ -312,6 +336,7 @@ function attachNotes(
|
|
|
312
336
|
points: e.points.map((pt) => ({ x: pt.x + shiftX, y: pt.y + shiftY })),
|
|
313
337
|
...(e.labelX !== undefined && { labelX: e.labelX + shiftX }),
|
|
314
338
|
...(e.labelY !== undefined && { labelY: e.labelY + shiftY }),
|
|
339
|
+
// labelWidth/labelHeight/labelLines are shift-invariant — carried via spread.
|
|
315
340
|
}))
|
|
316
341
|
: layout.edges;
|
|
317
342
|
const finalGroups = shifted
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
stripDefaultModifier,
|
|
22
22
|
finalizeAutoTagColors,
|
|
23
23
|
AUTO_TAG_COLOR_SENTINEL,
|
|
24
|
+
tagAttrKey,
|
|
24
25
|
} from '../utils/tag-groups';
|
|
25
26
|
import type { TagGroup } from '../utils/tag-groups';
|
|
26
27
|
import type { Writable } from '../utils/brand';
|
|
@@ -399,12 +400,12 @@ export function parseBoxesAndLines(
|
|
|
399
400
|
if (tagBlockMatch.alias) {
|
|
400
401
|
metaAliasMap.set(
|
|
401
402
|
normalizeName(tagBlockMatch.alias),
|
|
402
|
-
tagBlockMatch.name
|
|
403
|
+
tagAttrKey(tagBlockMatch.name)
|
|
403
404
|
);
|
|
404
405
|
}
|
|
405
406
|
metaAliasMap.set(
|
|
406
407
|
normalizeName(tagBlockMatch.name),
|
|
407
|
-
tagBlockMatch.name
|
|
408
|
+
tagAttrKey(tagBlockMatch.name)
|
|
408
409
|
);
|
|
409
410
|
if (tagBlockMatch.inlineValues) {
|
|
410
411
|
for (const rawVal of tagBlockMatch.inlineValues) {
|
|
@@ -78,7 +78,6 @@ const GROUP_LABEL_ZONE = 32;
|
|
|
78
78
|
const RAMP_FLOOR = 15;
|
|
79
79
|
const VALUE_FONT_SIZE = 11;
|
|
80
80
|
|
|
81
|
-
type D3G = d3Selection.Selection<SVGGElement, unknown, null, undefined>;
|
|
82
81
|
type D3Svg = d3Selection.Selection<SVGSVGElement, unknown, null, undefined>;
|
|
83
82
|
|
|
84
83
|
// ── Edge path generators ───────────────────────────────────
|
|
@@ -352,78 +351,6 @@ function ensureArrowMarkers(
|
|
|
352
351
|
}
|
|
353
352
|
}
|
|
354
353
|
|
|
355
|
-
// ── Edge label placement ───────────────────────────────────
|
|
356
|
-
|
|
357
|
-
/** Point at the half-way arc length along an edge polyline — the geometric
|
|
358
|
-
* centre of the connector, so a label sits in the gap BETWEEN the two nodes
|
|
359
|
-
* (ELK's own label anchor drifts toward the target and ends up clipped under
|
|
360
|
-
* it). Falls back gracefully for degenerate point lists. */
|
|
361
|
-
function edgePolylineMidpoint(
|
|
362
|
-
points: ReadonlyArray<{ readonly x: number; readonly y: number }>
|
|
363
|
-
): { x: number; y: number } {
|
|
364
|
-
if (points.length === 0) return { x: 0, y: 0 };
|
|
365
|
-
if (points.length === 1) return { x: points[0]!.x, y: points[0]!.y };
|
|
366
|
-
let total = 0;
|
|
367
|
-
const segLen: number[] = [];
|
|
368
|
-
for (let k = 1; k < points.length; k++) {
|
|
369
|
-
const len = Math.hypot(
|
|
370
|
-
points[k]!.x - points[k - 1]!.x,
|
|
371
|
-
points[k]!.y - points[k - 1]!.y
|
|
372
|
-
);
|
|
373
|
-
segLen.push(len);
|
|
374
|
-
total += len;
|
|
375
|
-
}
|
|
376
|
-
let half = total / 2;
|
|
377
|
-
for (let k = 1; k < points.length; k++) {
|
|
378
|
-
const len = segLen[k - 1]!;
|
|
379
|
-
if (half <= len || k === points.length - 1) {
|
|
380
|
-
const t = len === 0 ? 0 : Math.min(1, half / len);
|
|
381
|
-
return {
|
|
382
|
-
x: points[k - 1]!.x + (points[k]!.x - points[k - 1]!.x) * t,
|
|
383
|
-
y: points[k - 1]!.y + (points[k]!.y - points[k - 1]!.y) * t,
|
|
384
|
-
};
|
|
385
|
-
}
|
|
386
|
-
half -= len;
|
|
387
|
-
}
|
|
388
|
-
const last = points[points.length - 1]!;
|
|
389
|
-
return { x: last.x, y: last.y };
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
// ── Edge label overlap resolution ──────────────────────────
|
|
393
|
-
|
|
394
|
-
function resolveEdgeLabelOverlaps(
|
|
395
|
-
labels: { x: number; y: number; width: number; height: number }[]
|
|
396
|
-
): void {
|
|
397
|
-
const MAX_PASSES = 8;
|
|
398
|
-
const PAD = 4;
|
|
399
|
-
for (let pass = 0; pass < MAX_PASSES; pass++) {
|
|
400
|
-
let moved = false;
|
|
401
|
-
for (let i = 0; i < labels.length; i++) {
|
|
402
|
-
for (let j = i + 1; j < labels.length; j++) {
|
|
403
|
-
// In-bounds by loop guard.
|
|
404
|
-
const a = labels[i]!;
|
|
405
|
-
const b = labels[j]!;
|
|
406
|
-
const dx = Math.abs(a.x - b.x);
|
|
407
|
-
const dy = Math.abs(a.y - b.y);
|
|
408
|
-
const overlapX = (a.width + b.width) / 2 + PAD - dx;
|
|
409
|
-
const overlapY = (a.height + b.height) / 2 + PAD - dy;
|
|
410
|
-
if (overlapX > 0 && overlapY > 0) {
|
|
411
|
-
const shift = overlapY / 2 + 1;
|
|
412
|
-
if (a.y < b.y) {
|
|
413
|
-
a.y -= shift;
|
|
414
|
-
b.y += shift;
|
|
415
|
-
} else {
|
|
416
|
-
a.y += shift;
|
|
417
|
-
b.y -= shift;
|
|
418
|
-
}
|
|
419
|
-
moved = true;
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
|
-
if (!moved) break;
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
|
|
427
354
|
// ── Main render function ───────────────────────────────────
|
|
428
355
|
|
|
429
356
|
interface BLRenderOptions {
|
|
@@ -847,18 +774,8 @@ export function renderBoxesAndLines(
|
|
|
847
774
|
}
|
|
848
775
|
|
|
849
776
|
// ── Render edges ───────────────────────────────────────
|
|
850
|
-
//
|
|
851
|
-
|
|
852
|
-
x: number;
|
|
853
|
-
y: number;
|
|
854
|
-
width: number;
|
|
855
|
-
height: number;
|
|
856
|
-
idx: number;
|
|
857
|
-
}[] = [];
|
|
858
|
-
|
|
859
|
-
// Store edge group elements for label pass
|
|
860
|
-
const edgeGroups = new Map<number, D3G>();
|
|
861
|
-
|
|
777
|
+
// Edge labels are placed in layout (label-placement.ts) and drawn AFTER the
|
|
778
|
+
// node loop (see the bl-edge-labels layer) so boxes never clip them.
|
|
862
779
|
for (let i = 0; i < layout.edges.length; i++) {
|
|
863
780
|
// In-bounds by loop guard.
|
|
864
781
|
const le = layout.edges[i]!;
|
|
@@ -885,7 +802,6 @@ export function renderBoxesAndLines(
|
|
|
885
802
|
.append('g')
|
|
886
803
|
.attr('class', 'bl-edge-group')
|
|
887
804
|
.attr('data-line-number', String(le.lineNumber));
|
|
888
|
-
edgeGroups.set(i, edgeG as unknown as D3G);
|
|
889
805
|
|
|
890
806
|
const markerId = `bl-arrow-${color.replace('#', '')}`;
|
|
891
807
|
const cx = nodeLayout.x;
|
|
@@ -950,7 +866,6 @@ export function renderBoxesAndLines(
|
|
|
950
866
|
.append('g')
|
|
951
867
|
.attr('class', 'bl-edge-group')
|
|
952
868
|
.attr('data-line-number', String(le.lineNumber));
|
|
953
|
-
edgeGroups.set(i, edgeG as unknown as D3G);
|
|
954
869
|
|
|
955
870
|
const markerId = `bl-arrow-${color.replace('#', '')}`;
|
|
956
871
|
const gen = le.straight
|
|
@@ -971,58 +886,6 @@ export function renderBoxesAndLines(
|
|
|
971
886
|
const revId = `bl-arrow-rev-${color.replace('#', '')}`;
|
|
972
887
|
path.attr('marker-start', `url(#${revId})`);
|
|
973
888
|
}
|
|
974
|
-
|
|
975
|
-
// Edge label — centred on the connector's polyline midpoint (the gap
|
|
976
|
-
// between the two nodes), NOT ELK's target-biased anchor. For parallel
|
|
977
|
-
// edges, nudge above/below so each line's label clears the line.
|
|
978
|
-
if (le.label && le.points.length > 0) {
|
|
979
|
-
const lw = measureText(le.label, sEdgeLabelFontSize);
|
|
980
|
-
const labelH = sEdgeLabelFontSize + 6;
|
|
981
|
-
const mid = edgePolylineMidpoint(le.points);
|
|
982
|
-
let ly = mid.y;
|
|
983
|
-
if (le.parallelCount > 1 && le.yOffset !== 0) {
|
|
984
|
-
ly += (le.yOffset < 0 ? -labelH : labelH) * 0.5;
|
|
985
|
-
}
|
|
986
|
-
labelPositions.push({
|
|
987
|
-
x: mid.x,
|
|
988
|
-
y: ly,
|
|
989
|
-
width: lw + 8,
|
|
990
|
-
height: labelH,
|
|
991
|
-
idx: i,
|
|
992
|
-
});
|
|
993
|
-
}
|
|
994
|
-
}
|
|
995
|
-
|
|
996
|
-
// Resolve overlaps
|
|
997
|
-
resolveEdgeLabelOverlaps(labelPositions);
|
|
998
|
-
|
|
999
|
-
// Render edge labels into their edge groups
|
|
1000
|
-
for (const lp of labelPositions) {
|
|
1001
|
-
// In-bounds: lp.idx was set from a valid index into layout.edges above.
|
|
1002
|
-
const le = layout.edges[lp.idx]!;
|
|
1003
|
-
if (!le.label) continue;
|
|
1004
|
-
|
|
1005
|
-
const edgeG = edgeGroups.get(lp.idx);
|
|
1006
|
-
const target = edgeG ?? diagramG;
|
|
1007
|
-
|
|
1008
|
-
target
|
|
1009
|
-
.append('rect')
|
|
1010
|
-
.attr('x', lp.x - lp.width / 2)
|
|
1011
|
-
.attr('y', lp.y - lp.height / 2)
|
|
1012
|
-
.attr('width', lp.width)
|
|
1013
|
-
.attr('height', lp.height)
|
|
1014
|
-
.attr('rx', 3)
|
|
1015
|
-
.attr('fill', palette.bg)
|
|
1016
|
-
.attr('opacity', 0.85);
|
|
1017
|
-
|
|
1018
|
-
target
|
|
1019
|
-
.append('text')
|
|
1020
|
-
.attr('x', lp.x)
|
|
1021
|
-
.attr('y', lp.y + sEdgeLabelFontSize / 3)
|
|
1022
|
-
.attr('text-anchor', 'middle')
|
|
1023
|
-
.attr('font-size', sEdgeLabelFontSize)
|
|
1024
|
-
.attr('fill', palette.textMuted)
|
|
1025
|
-
.text(le.label);
|
|
1026
889
|
}
|
|
1027
890
|
|
|
1028
891
|
// ── Render nodes ───────────────────────────────────────
|
|
@@ -1396,6 +1259,71 @@ export function renderBoxesAndLines(
|
|
|
1396
1259
|
}
|
|
1397
1260
|
}
|
|
1398
1261
|
|
|
1262
|
+
// ── Render edge labels ─────────────────────────────────
|
|
1263
|
+
// Drawn AFTER nodes (and groups) so the halo + text always paint on top and
|
|
1264
|
+
// are never clipped by a box. Positions/wrapping come from label-placement
|
|
1265
|
+
// (layout); this is a pure consumer. Labels stay in their own layer rather
|
|
1266
|
+
// than inside each edge group, but carry data-line-number so line-hover/click
|
|
1267
|
+
// still highlights the matching label.
|
|
1268
|
+
const labelLayer = diagramG.append('g').attr('class', 'bl-edge-labels');
|
|
1269
|
+
const labelLineHeight = sEdgeLabelFontSize * 1.3;
|
|
1270
|
+
for (const le of layout.edges) {
|
|
1271
|
+
if (!le.labelLines || le.labelLines.length === 0) continue;
|
|
1272
|
+
if (le.labelX === undefined || le.labelY === undefined) continue;
|
|
1273
|
+
const lx = le.labelX;
|
|
1274
|
+
const ly = le.labelY;
|
|
1275
|
+
|
|
1276
|
+
// Honour the same tag-value hiding as the edges themselves.
|
|
1277
|
+
if (hidden.size > 0) {
|
|
1278
|
+
let isHidden = false;
|
|
1279
|
+
for (const [groupKey, hiddenVals] of hidden) {
|
|
1280
|
+
const val = le.metadata[groupKey];
|
|
1281
|
+
if (val && hiddenVals.has(val.toLowerCase())) {
|
|
1282
|
+
isHidden = true;
|
|
1283
|
+
break;
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
if (isHidden) continue;
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
const lw = le.labelWidth ?? 0;
|
|
1290
|
+
const lh = le.labelHeight ?? sEdgeLabelFontSize + 6;
|
|
1291
|
+
const lines = le.labelLines;
|
|
1292
|
+
const labelG = labelLayer
|
|
1293
|
+
.append('g')
|
|
1294
|
+
.attr('class', 'bl-edge-label')
|
|
1295
|
+
.attr('data-line-number', String(le.lineNumber));
|
|
1296
|
+
|
|
1297
|
+
labelG
|
|
1298
|
+
.append('rect')
|
|
1299
|
+
.attr('x', lx - lw / 2)
|
|
1300
|
+
.attr('y', ly - lh / 2)
|
|
1301
|
+
.attr('width', lw)
|
|
1302
|
+
.attr('height', lh)
|
|
1303
|
+
.attr('rx', 3)
|
|
1304
|
+
.attr('fill', palette.bg)
|
|
1305
|
+
// No border; semi-transparent so the line stays faintly visible behind the
|
|
1306
|
+
// label while the (full-opacity) text on top stays crisp.
|
|
1307
|
+
.attr('opacity', 0.72);
|
|
1308
|
+
|
|
1309
|
+
const text = labelG
|
|
1310
|
+
.append('text')
|
|
1311
|
+
.attr('x', lx)
|
|
1312
|
+
.attr('text-anchor', 'middle')
|
|
1313
|
+
.attr('font-family', FONT_FAMILY)
|
|
1314
|
+
.attr('font-size', sEdgeLabelFontSize)
|
|
1315
|
+
.attr('fill', palette.textMuted);
|
|
1316
|
+
const firstY =
|
|
1317
|
+
ly - ((lines.length - 1) / 2) * labelLineHeight + sEdgeLabelFontSize / 3;
|
|
1318
|
+
lines.forEach((line, k) => {
|
|
1319
|
+
text
|
|
1320
|
+
.append('tspan')
|
|
1321
|
+
.attr('x', lx)
|
|
1322
|
+
.attr('y', firstY + k * labelLineHeight)
|
|
1323
|
+
.text(line);
|
|
1324
|
+
});
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1399
1327
|
// ── Render legend ──────────────────────────────────────
|
|
1400
1328
|
const hasDescriptions = parsed.nodes.some(
|
|
1401
1329
|
(n) => n.description && n.description.length > 0
|
package/src/c4/layout.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// C4 Context Diagram Layout Engine (dagre)
|
|
3
3
|
// ============================================================
|
|
4
4
|
|
|
5
|
+
import { tagAttrKey } from '../utils/tag-groups';
|
|
5
6
|
import dagre from '@dagrejs/dagre';
|
|
6
7
|
import type {
|
|
7
8
|
ParsedC4,
|
|
@@ -619,7 +620,7 @@ function resolveNodeColor(
|
|
|
619
620
|
(g) => g.name.toLowerCase() === activeGroupName.toLowerCase()
|
|
620
621
|
);
|
|
621
622
|
if (!group) return undefined;
|
|
622
|
-
const key = group.name
|
|
623
|
+
const key = tagAttrKey(group.name);
|
|
623
624
|
// Walk inheritance chain: element → ancestors (container → system)
|
|
624
625
|
let metaValue: string | undefined = el.metadata[key];
|
|
625
626
|
if (!metaValue && ancestors) {
|
package/src/c4/parser.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
validateTagGroupNames,
|
|
25
25
|
finalizeAutoTagColors,
|
|
26
26
|
AUTO_TAG_COLOR_SENTINEL,
|
|
27
|
+
tagAttrKey,
|
|
27
28
|
} from '../utils/tag-groups';
|
|
28
29
|
import { inferParticipantType } from '../sequence/participant-inference';
|
|
29
30
|
import {
|
|
@@ -416,12 +417,12 @@ export function parseC4(content: string, palette?: PaletteColors): ParsedC4 {
|
|
|
416
417
|
if (tagBlockMatch.alias) {
|
|
417
418
|
metaAliasMap.set(
|
|
418
419
|
normalizeName(tagBlockMatch.alias),
|
|
419
|
-
tagBlockMatch.name
|
|
420
|
+
tagAttrKey(tagBlockMatch.name)
|
|
420
421
|
);
|
|
421
422
|
}
|
|
422
423
|
metaAliasMap.set(
|
|
423
424
|
normalizeName(tagBlockMatch.name),
|
|
424
|
-
tagBlockMatch.name
|
|
425
|
+
tagAttrKey(tagBlockMatch.name)
|
|
425
426
|
);
|
|
426
427
|
result.tagGroups.push(currentTagGroup);
|
|
427
428
|
continue;
|
package/src/c4/renderer.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// C4 Context Diagram SVG Renderer
|
|
3
3
|
// ============================================================
|
|
4
4
|
|
|
5
|
+
import { tagAttrKey } from '../utils/tag-groups';
|
|
5
6
|
import * as d3Selection from 'd3-selection';
|
|
6
7
|
import * as d3Shape from 'd3-shape';
|
|
7
8
|
import { FONT_FAMILY } from '../fonts';
|
|
@@ -432,7 +433,7 @@ export function renderC4Context(
|
|
|
432
433
|
.attr('data-node-id', node.id);
|
|
433
434
|
|
|
434
435
|
if (activeTagGroup) {
|
|
435
|
-
const tagKey = activeTagGroup
|
|
436
|
+
const tagKey = tagAttrKey(activeTagGroup);
|
|
436
437
|
const tagValue = node.metadata[tagKey];
|
|
437
438
|
if (tagValue) {
|
|
438
439
|
nodeG.attr(`data-tag-${tagKey}`, tagValue.toLowerCase());
|
|
@@ -1517,7 +1518,7 @@ export function renderC4Containers(
|
|
|
1517
1518
|
.attr('data-node-id', node.id);
|
|
1518
1519
|
|
|
1519
1520
|
if (activeTagGroup) {
|
|
1520
|
-
const tagKey = activeTagGroup
|
|
1521
|
+
const tagKey = tagAttrKey(activeTagGroup);
|
|
1521
1522
|
const tagValue = node.metadata[tagKey];
|
|
1522
1523
|
if (tagValue) {
|
|
1523
1524
|
nodeG.attr(`data-tag-${tagKey}`, tagValue.toLowerCase());
|
package/src/cli.ts
CHANGED
|
@@ -26,6 +26,8 @@ import {
|
|
|
26
26
|
} from './migrate';
|
|
27
27
|
import { migrateEmbedded } from './migrate/embedded';
|
|
28
28
|
import { renderBanner } from './cli-banner';
|
|
29
|
+
import { searchMapLocations } from './map/completion';
|
|
30
|
+
import { loadMapData } from './map/load-data';
|
|
29
31
|
|
|
30
32
|
// Derived from the palette registry so new palettes are auto-included.
|
|
31
33
|
const PALETTES = getAvailablePalettes().map((p) => p.id);
|
|
@@ -470,6 +472,7 @@ function printHelp(): void {
|
|
|
470
472
|
cat input.dgmo | dgmo [options]
|
|
471
473
|
dgmo cat <file> Display file with syntax highlighting
|
|
472
474
|
dgmo migrate <path> Convert legacy "|" metadata to §1.4 grammar
|
|
475
|
+
dgmo map search <query> Find the map place token (city or IATA code)
|
|
473
476
|
|
|
474
477
|
Render a .dgmo file to PNG (default) or SVG.
|
|
475
478
|
|
|
@@ -859,6 +862,66 @@ async function runMigrateCommand(args: string[]): Promise<void> {
|
|
|
859
862
|
}
|
|
860
863
|
}
|
|
861
864
|
|
|
865
|
+
// `dgmo map search <query>` — discover the exact place token the map resolver
|
|
866
|
+
// expects (city name or bundled IATA airport code), so authors never guess
|
|
867
|
+
// (e.g. "New York" → `New York City`; "kennedy" → `JFK`). Searches cities +
|
|
868
|
+
// the bundled airport set; `--json` for machine output, `--limit N` to widen.
|
|
869
|
+
async function runMapCommand(args: string[]): Promise<void> {
|
|
870
|
+
const sub = args[0];
|
|
871
|
+
const json = args.includes('--json');
|
|
872
|
+
const limFlag = args.indexOf('--limit');
|
|
873
|
+
const limit =
|
|
874
|
+
limFlag >= 0 ? Math.max(1, Number(args[limFlag + 1]) || 20) : 20;
|
|
875
|
+
// The query is the positional args (allow unquoted multi-word: `... New York`).
|
|
876
|
+
const query = args
|
|
877
|
+
.slice(1)
|
|
878
|
+
.filter(
|
|
879
|
+
(a, i, arr) =>
|
|
880
|
+
a !== '--json' && a !== '--limit' && arr[i - 1] !== '--limit'
|
|
881
|
+
)
|
|
882
|
+
.join(' ')
|
|
883
|
+
.trim();
|
|
884
|
+
|
|
885
|
+
if (sub !== 'search' || !query) {
|
|
886
|
+
console.log('Usage: dgmo map search <query> [--json] [--limit N]');
|
|
887
|
+
console.log(
|
|
888
|
+
' Find the place token the map resolver expects (city or IATA airport code).'
|
|
889
|
+
);
|
|
890
|
+
console.log(' Examples:');
|
|
891
|
+
console.log(
|
|
892
|
+
' dgmo map search "new york" # → New York City (+ JFK/LGA/EWR airports)'
|
|
893
|
+
);
|
|
894
|
+
console.log(' dgmo map search kennedy # → JFK by airport name');
|
|
895
|
+
console.log(' dgmo map search heathrow --json');
|
|
896
|
+
if (sub === 'search' && !query) process.exitCode = 1;
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
const data = await loadMapData();
|
|
901
|
+
const results = searchMapLocations(query, data.gazetteer, {
|
|
902
|
+
limit,
|
|
903
|
+
...(data.airports && { airports: data.airports }),
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
if (json) {
|
|
907
|
+
process.stdout.write(JSON.stringify({ query, results }, null, 2) + '\n');
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
if (!results.length) {
|
|
911
|
+
console.log(
|
|
912
|
+
`No cities or airports match "${query}". Use coordinates: poi Name as <lat,lng>`
|
|
913
|
+
);
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
console.log(
|
|
917
|
+
`Places matching "${query}" — use the token column in your DGMO:`
|
|
918
|
+
);
|
|
919
|
+
for (const r of results) {
|
|
920
|
+
const tag = r.kind === 'airport' ? '✈' : '•';
|
|
921
|
+
console.log(` ${tag} ${r.token.padEnd(22)} ${r.detail}`);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
862
925
|
async function main(): Promise<void> {
|
|
863
926
|
// Subcommand dispatch — `dgmo migrate` is parsed independently from
|
|
864
927
|
// the rendering flags so its surface doesn't pollute `parseArgs`.
|
|
@@ -867,6 +930,11 @@ async function main(): Promise<void> {
|
|
|
867
930
|
return;
|
|
868
931
|
}
|
|
869
932
|
|
|
933
|
+
if (process.argv[2] === 'map') {
|
|
934
|
+
await runMapCommand(process.argv.slice(3));
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
|
|
870
938
|
const opts = parseArgs(process.argv);
|
|
871
939
|
|
|
872
940
|
if (opts.help) {
|
package/src/er/parser.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
stripDefaultModifier,
|
|
32
32
|
finalizeAutoTagColors,
|
|
33
33
|
AUTO_TAG_COLOR_SENTINEL,
|
|
34
|
+
tagAttrKey,
|
|
34
35
|
} from '../utils/tag-groups';
|
|
35
36
|
import type { TagGroup } from '../utils/tag-groups';
|
|
36
37
|
import { tryCollectNote, resolveNotes, type DiagramNote } from '../utils/notes';
|
|
@@ -608,7 +609,7 @@ export function parseERDiagram(
|
|
|
608
609
|
// Inject defaults for tables without explicit tags
|
|
609
610
|
for (const group of result.tagGroups) {
|
|
610
611
|
if (!group.defaultValue) continue;
|
|
611
|
-
const key = group.name
|
|
612
|
+
const key = tagAttrKey(group.name);
|
|
612
613
|
for (const table of result.tables) {
|
|
613
614
|
if (!table.metadata[key]) {
|
|
614
615
|
const mutableMeta = tableMetadataMap.get(table.id);
|
package/src/er/renderer.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// ER Diagram SVG Renderer
|
|
3
3
|
// ============================================================
|
|
4
4
|
|
|
5
|
+
import { tagAttrKey } from '../utils/tag-groups';
|
|
5
6
|
import * as d3Selection from 'd3-selection';
|
|
6
7
|
import * as d3Shape from 'd3-shape';
|
|
7
8
|
import { FONT_FAMILY } from '../fonts';
|
|
@@ -455,7 +456,7 @@ export function renderERDiagram(
|
|
|
455
456
|
|
|
456
457
|
// Set data-tag-* attributes for legend hover
|
|
457
458
|
if (activeTagGroup) {
|
|
458
|
-
const tagKey = activeTagGroup
|
|
459
|
+
const tagKey = tagAttrKey(activeTagGroup);
|
|
459
460
|
const tagValue = node.metadata[tagKey];
|
|
460
461
|
if (tagValue) {
|
|
461
462
|
nodeG.attr(`data-tag-${tagKey}`, tagValue.toLowerCase());
|
package/src/gantt/parser.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
emitTagLegacyDiagnostic,
|
|
26
26
|
stripDefaultModifier,
|
|
27
27
|
validateTagGroupNames,
|
|
28
|
+
tagAttrKey,
|
|
28
29
|
} from '../utils/tag-groups';
|
|
29
30
|
import {
|
|
30
31
|
measureIndent,
|
|
@@ -814,13 +815,10 @@ export function parseGantt(
|
|
|
814
815
|
if (tagMatch.alias) {
|
|
815
816
|
metaAliasMap.set(
|
|
816
817
|
normalizeName(tagMatch.alias),
|
|
817
|
-
tagMatch.name
|
|
818
|
+
tagAttrKey(tagMatch.name)
|
|
818
819
|
);
|
|
819
820
|
}
|
|
820
|
-
metaAliasMap.set(
|
|
821
|
-
normalizeName(tagMatch.name),
|
|
822
|
-
tagMatch.name.toLowerCase()
|
|
823
|
-
);
|
|
821
|
+
metaAliasMap.set(normalizeName(tagMatch.name), tagAttrKey(tagMatch.name));
|
|
824
822
|
continue;
|
|
825
823
|
}
|
|
826
824
|
|