@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.
Files changed (60) hide show
  1. package/.cursorrules +7 -2
  2. package/.github/copilot-instructions.md +7 -2
  3. package/.windsurfrules +7 -2
  4. package/SKILL.md +7 -2
  5. package/dist/advanced.cjs +1434 -443
  6. package/dist/advanced.d.cts +41 -1
  7. package/dist/advanced.d.ts +41 -1
  8. package/dist/advanced.js +1433 -443
  9. package/dist/auto.cjs +1242 -318
  10. package/dist/auto.js +91 -91
  11. package/dist/auto.mjs +1242 -318
  12. package/dist/cli.cjs +138 -136
  13. package/dist/index.cjs +1308 -317
  14. package/dist/index.d.cts +35 -1
  15. package/dist/index.d.ts +35 -1
  16. package/dist/index.js +1307 -317
  17. package/docs/ai-integration.md +7 -2
  18. package/docs/language-reference.md +39 -8
  19. package/package.json +2 -2
  20. package/src/advanced.ts +6 -1
  21. package/src/boxes-and-lines/label-placement.ts +339 -0
  22. package/src/boxes-and-lines/layout-grouped.ts +909 -0
  23. package/src/boxes-and-lines/layout-search.ts +34 -3
  24. package/src/boxes-and-lines/layout.ts +32 -7
  25. package/src/boxes-and-lines/parser.ts +3 -2
  26. package/src/boxes-and-lines/renderer.ts +67 -139
  27. package/src/c4/layout.ts +2 -1
  28. package/src/c4/parser.ts +3 -2
  29. package/src/c4/renderer.ts +3 -2
  30. package/src/cli.ts +68 -0
  31. package/src/er/parser.ts +2 -1
  32. package/src/er/renderer.ts +2 -1
  33. package/src/gantt/parser.ts +3 -5
  34. package/src/gantt/renderer.ts +7 -6
  35. package/src/index.ts +6 -1
  36. package/src/infra/parser.ts +2 -1
  37. package/src/infra/renderer.ts +4 -3
  38. package/src/journey-map/parser.ts +27 -8
  39. package/src/journey-map/renderer.ts +26 -9
  40. package/src/kanban/parser.ts +4 -3
  41. package/src/kanban/renderer.ts +6 -5
  42. package/src/map/completion.ts +116 -0
  43. package/src/map/layout.ts +4 -3
  44. package/src/map/parser.ts +3 -2
  45. package/src/mindmap/parser.ts +4 -3
  46. package/src/mindmap/renderer.ts +2 -1
  47. package/src/org/layout.ts +3 -2
  48. package/src/org/parser.ts +3 -2
  49. package/src/org/renderer.ts +5 -4
  50. package/src/pert/parser.ts +3 -2
  51. package/src/sequence/renderer.ts +10 -12
  52. package/src/sequence/tag-resolution.ts +2 -1
  53. package/src/sitemap/layout.ts +3 -2
  54. package/src/sitemap/parser.ts +36 -2
  55. package/src/sitemap/renderer.ts +12 -6
  56. package/src/timeline/renderer.ts +4 -3
  57. package/src/utils/legend-d3.ts +4 -3
  58. package/src/utils/legend-svg.ts +3 -2
  59. package/src/utils/tag-groups.ts +28 -7
  60. 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
- g.setEdge(s, t, {}, edgeKey(e));
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) fastKey.set(lay, objective(lay, countCrossingsFast(lay)));
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 searched = await layoutBoxesAndLinesSearch(parsed, collapseInfo, {
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
- // Engine-agnostic post-processing: fan parallel edges, then float notes
220
- // (and shift the canvas to fit them).
221
- return attachNotes(
222
- applyParallelEdgeOffsets(searched),
225
+ };
226
+ const searched = await layoutBoxesAndLinesSearch(
223
227
  parsed,
224
- layoutOptions?.collapsedNotes
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.toLowerCase()
403
+ tagAttrKey(tagBlockMatch.name)
403
404
  );
404
405
  }
405
406
  metaAliasMap.set(
406
407
  normalizeName(tagBlockMatch.name),
407
- tagBlockMatch.name.toLowerCase()
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
- // Collect label positions for overlap resolution
851
- const labelPositions: {
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.toLowerCase();
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.toLowerCase()
420
+ tagAttrKey(tagBlockMatch.name)
420
421
  );
421
422
  }
422
423
  metaAliasMap.set(
423
424
  normalizeName(tagBlockMatch.name),
424
- tagBlockMatch.name.toLowerCase()
425
+ tagAttrKey(tagBlockMatch.name)
425
426
  );
426
427
  result.tagGroups.push(currentTagGroup);
427
428
  continue;
@@ -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.toLowerCase();
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.toLowerCase();
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.toLowerCase();
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);
@@ -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.toLowerCase();
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());
@@ -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.toLowerCase()
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