@diagrammo/dgmo 0.7.0 → 0.7.1

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.
@@ -27,7 +27,7 @@ import {
27
27
  import type { PaletteColors } from '../palettes';
28
28
  import type { D3ExportDimensions } from '../d3';
29
29
  import type { ResolvedSchedule, ResolvedTask, ResolvedGroup, Weekday } from './types';
30
- import type { TagGroup } from '../utils/tag-groups';
30
+ import type { TagGroup, TagEntry } from '../utils/tag-groups';
31
31
 
32
32
  // ── Constants ───────────────────────────────────────────────
33
33
 
@@ -68,7 +68,7 @@ export function renderGantt(
68
68
  // Clear previous content
69
69
  container.innerHTML = '';
70
70
 
71
- if (resolved.error || resolved.tasks.length === 0) return;
71
+ if (resolved.tasks.length === 0) return;
72
72
 
73
73
  // ── Destructure options ─────────────────────────────────
74
74
 
@@ -118,6 +118,7 @@ export function renderGantt(
118
118
  const titleHeight = title ? 50 : 20;
119
119
  const tagLegendReserve = resolved.tagGroups.length > 0 ? LEGEND_HEIGHT + 8 : 0;
120
120
  const topDateLabelReserve = 22; // tick (6) + gap (4) + label height (~12)
121
+ const CONTENT_TOP_PAD = 16; // breathing room between scale labels and first row
121
122
 
122
123
  const marginTop = titleHeight + tagLegendReserve + topDateLabelReserve;
123
124
 
@@ -125,7 +126,7 @@ export function renderGantt(
125
126
  const contentH = isTagMode
126
127
  ? totalRows * (BAR_H + ROW_GAP)
127
128
  : totalRows * (BAR_H + ROW_GAP) + GROUP_GAP * resolved.groups.length;
128
- const innerHeight = contentH;
129
+ const innerHeight = CONTENT_TOP_PAD + contentH;
129
130
  const outerHeight = marginTop + innerHeight + BOTTOM_MARGIN;
130
131
 
131
132
  const containerWidth = exportDims?.width ?? (container.clientWidth || 800);
@@ -186,6 +187,7 @@ export function renderGantt(
186
187
  currentSwimlaneGroup,
187
188
  onSwimlaneChange,
188
189
  viewMode,
190
+ resolved.tasks,
189
191
  );
190
192
  }
191
193
  }
@@ -256,7 +258,7 @@ export function renderGantt(
256
258
  }
257
259
  }
258
260
  }
259
- let yOffset = 0;
261
+ let yOffset = CONTENT_TOP_PAD;
260
262
 
261
263
  for (const row of rows) {
262
264
  if (row.type === 'lane-header') {
@@ -358,8 +360,9 @@ export function renderGantt(
358
360
  const indent = ' '.repeat(group.depth);
359
361
  const toggleIcon = isCollapsed ? '►' : '▼';
360
362
 
361
- // Group label with toggle
362
- const groupColor = group.color || palette.textMuted;
363
+ // Group label with toggle — resolve tag color from group metadata
364
+ const tagColor = resolveTagColor(group.metadata, resolved.tagGroups, currentActiveGroup, true);
365
+ const groupColor = (tagColor && tagColor !== '#999999') ? tagColor : (group.color || palette.textMuted);
363
366
  const labelG = svg
364
367
  .append('g')
365
368
  .attr('class', 'gantt-group-label')
@@ -505,7 +508,11 @@ export function renderGantt(
505
508
  if (onClickItem) onClickItem(task.lineNumber);
506
509
  })
507
510
  .on('mouseenter', () => {
508
- highlightTask(g, svg, task.id);
511
+ if (rt.isMilestone) {
512
+ highlightMilestone(g, svg, task.id);
513
+ } else {
514
+ highlightTask(g, svg, task.id);
515
+ }
509
516
  })
510
517
  .on('mouseleave', () => {
511
518
  resetHighlight(g, svg);
@@ -534,13 +541,14 @@ export function renderGantt(
534
541
  .attr('stroke-width', 1.5)
535
542
  .attr('data-line-number', String(task.lineNumber))
536
543
  .attr('data-task-name', task.label)
544
+ .attr('data-task-id', task.id)
537
545
  .attr('data-group', topGroup)
538
546
  .style('cursor', onClickItem ? 'pointer' : 'default')
539
547
  .on('click', () => {
540
548
  if (onClickItem) onClickItem(task.lineNumber);
541
549
  })
542
550
  .on('mouseenter', () => {
543
- highlightTaskLabel(svg, task.lineNumber);
551
+ highlightMilestone(g, svg, task.id);
544
552
  showGanttDateIndicators(g, xScale, rt.startDate, null, innerHeight, barColor);
545
553
  // Show label next to diamond
546
554
  g.append('text')
@@ -555,7 +563,7 @@ export function renderGantt(
555
563
  .text(task.label);
556
564
  })
557
565
  .on('mouseleave', () => {
558
- resetTaskLabels(svg);
566
+ resetHighlight(g, svg);
559
567
  hideGanttDateIndicators(g);
560
568
  g.selectAll('.gantt-milestone-hover-label').remove();
561
569
  });
@@ -1138,6 +1146,7 @@ function renderTagLegend(
1138
1146
  currentSwimlaneGroup?: string | null,
1139
1147
  onSwimlaneChange?: (group: string | null) => void,
1140
1148
  legendViewMode?: boolean,
1149
+ resolvedTasks?: ResolvedTask[],
1141
1150
  ): void {
1142
1151
  const groupBg = isDark
1143
1152
  ? mix(palette.surface, palette.bg, 50)
@@ -1155,6 +1164,32 @@ function renderTagLegend(
1155
1164
  visibleGroups = tagGroups;
1156
1165
  }
1157
1166
 
1167
+ // Build set of used tag values per group from resolved tasks
1168
+ const usedValues = new Map<string, Set<string>>();
1169
+ if (resolvedTasks) {
1170
+ for (const group of visibleGroups) {
1171
+ const key = group.name.toLowerCase();
1172
+ const used = new Set<string>();
1173
+ for (const rt of resolvedTasks) {
1174
+ const val = rt.effectiveMetadata[key];
1175
+ if (val) used.add(val.toLowerCase());
1176
+ }
1177
+ usedValues.set(key, used);
1178
+ }
1179
+ }
1180
+
1181
+ // Filter entries to only those used in the current view
1182
+ const filteredEntries = new Map<string, TagEntry[]>();
1183
+ for (const group of visibleGroups) {
1184
+ const key = group.name.toLowerCase();
1185
+ const used = usedValues.get(key);
1186
+ if (used && used.size > 0) {
1187
+ filteredEntries.set(key, group.entries.filter(e => used.has(e.value.toLowerCase())));
1188
+ } else {
1189
+ filteredEntries.set(key, group.entries);
1190
+ }
1191
+ }
1192
+
1158
1193
  // Compute per-group widths
1159
1194
  const groupWidths: number[] = [];
1160
1195
  let totalW = 0;
@@ -1166,8 +1201,9 @@ function renderTagLegend(
1166
1201
  const pillW = group.name.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD + iconReserve;
1167
1202
  let groupW = pillW;
1168
1203
  if (isActive) {
1204
+ const entries = filteredEntries.get(group.name.toLowerCase()) ?? group.entries;
1169
1205
  let entriesW = 0;
1170
- for (const entry of group.entries) {
1206
+ for (const entry of entries) {
1171
1207
  entriesW += LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP + entry.value.length * LEGEND_ENTRY_FONT_W + LEGEND_ENTRY_TRAIL;
1172
1208
  }
1173
1209
  groupW = LEGEND_CAPSULE_PAD * 2 + pillW + 4 + entriesW;
@@ -1278,11 +1314,12 @@ function renderTagLegend(
1278
1314
  });
1279
1315
  }
1280
1316
 
1281
- // Entries (when active — expanded color group)
1317
+ // Entries (when active — expanded color group, only used values)
1282
1318
  if (isActive) {
1283
1319
  const tagKey = group.name.toLowerCase();
1320
+ const entries = filteredEntries.get(tagKey) ?? group.entries;
1284
1321
  let ex = pillXOff + pillW + LEGEND_CAPSULE_PAD + 4;
1285
- for (const entry of group.entries) {
1322
+ for (const entry of entries) {
1286
1323
  const entryValue = entry.value.toLowerCase();
1287
1324
 
1288
1325
  // Wrap dot + label in a <g> for hover targeting
@@ -1456,48 +1493,72 @@ function renderErasAndMarkers(
1456
1493
  });
1457
1494
  }
1458
1495
 
1459
- // Markers: vertical dashed lines
1496
+ // Markers: label → diamond → dashed line (same layout as timeline)
1460
1497
  for (const marker of resolved.markers) {
1461
1498
  const color = marker.color || palette.accent || '#d08770';
1462
1499
  const mx = xScale(parseDateToFractionalYear(marker.date));
1463
1500
  const markerDate = parseDateStringToDate(marker.date);
1501
+ const diamondSize = 5;
1502
+ const labelY = -24;
1503
+ const diamondY = labelY + 14;
1464
1504
 
1465
1505
  const markerG = g.append('g')
1466
- .attr('class', 'gantt-marker-group');
1506
+ .attr('class', 'gantt-marker-group')
1507
+ .style('cursor', 'pointer');
1508
+
1509
+ // Invisible hit rect for easier clicking/hovering
1510
+ markerG.append('rect')
1511
+ .attr('x', mx - 40)
1512
+ .attr('y', labelY - 12)
1513
+ .attr('width', 80)
1514
+ .attr('height', innerHeight - labelY + 12)
1515
+ .attr('fill', 'transparent')
1516
+ .attr('pointer-events', 'all');
1517
+
1518
+ // Label above diamond
1519
+ markerG.append('text')
1520
+ .attr('class', 'gantt-marker-label')
1521
+ .attr('x', mx)
1522
+ .attr('y', labelY)
1523
+ .attr('text-anchor', 'middle')
1524
+ .attr('font-size', '11px')
1525
+ .attr('font-weight', '600')
1526
+ .attr('fill', color)
1527
+ .text(marker.label);
1528
+
1529
+ // Diamond below label
1530
+ markerG.append('path')
1531
+ .attr('d', `M${mx},${diamondY - diamondSize} l${diamondSize},${diamondSize} l-${diamondSize},${diamondSize} l-${diamondSize},-${diamondSize} Z`)
1532
+ .attr('fill', color)
1533
+ .attr('opacity', 0.9);
1467
1534
 
1535
+ // Dashed line from diamond down
1468
1536
  markerG.append('line')
1469
1537
  .attr('class', 'gantt-marker')
1470
1538
  .attr('x1', mx)
1471
- .attr('y1', 0)
1539
+ .attr('y1', diamondY + diamondSize)
1472
1540
  .attr('x2', mx)
1473
1541
  .attr('y2', innerHeight)
1474
1542
  .attr('stroke', color)
1475
1543
  .attr('stroke-width', 1.5)
1476
- .attr('stroke-dasharray', '6 3')
1477
- .attr('opacity', 0.5);
1478
-
1479
- // Diamond indicator (at top of chart area)
1480
- markerG.append('polygon')
1481
- .attr('points', diamondPoints(mx, 6, 8))
1482
- .attr('fill', color)
1544
+ .attr('stroke-dasharray', '6 4')
1483
1545
  .attr('opacity', 0.5);
1484
1546
 
1485
- // Label (inside chart at top)
1486
- markerG.append('text')
1487
- .attr('class', 'gantt-marker-label')
1488
- .attr('x', mx + 8)
1489
- .attr('y', 10)
1490
- .attr('font-size', '9px')
1491
- .attr('fill', color)
1492
- .attr('opacity', 0.7)
1493
- .attr('pointer-events', 'none')
1494
- .text(marker.label);
1495
-
1547
+ // Hide marker visuals on hover — showGanttDateIndicators replaces them
1548
+ const markerLine = markerG.select('.gantt-marker');
1549
+ const markerLabel = markerG.select('.gantt-marker-label');
1550
+ const markerDiamond = markerG.select('path');
1496
1551
  markerG
1497
1552
  .on('mouseenter', () => {
1553
+ markerLine.attr('opacity', 0);
1554
+ markerLabel.attr('opacity', 0);
1555
+ markerDiamond.attr('opacity', 0);
1498
1556
  showGanttDateIndicators(g, xScale, markerDate, null, innerHeight, color);
1499
1557
  })
1500
1558
  .on('mouseleave', () => {
1559
+ markerLine.attr('opacity', 0.5);
1560
+ markerLabel.attr('opacity', 1);
1561
+ markerDiamond.attr('opacity', 0.9);
1501
1562
  hideGanttDateIndicators(g);
1502
1563
  });
1503
1564
  }
@@ -1615,6 +1676,8 @@ function highlightGroup(
1615
1676
  // Fade lane elements
1616
1677
  svg.selectAll<SVGGElement, unknown>('.gantt-lane-header').attr('opacity', FADE_OPACITY);
1617
1678
  g.selectAll<SVGElement, unknown>('.gantt-lane-band, .gantt-lane-accent').attr('opacity', FADE_OPACITY);
1679
+ // Fade markers
1680
+ g.selectAll<SVGElement, unknown>('.gantt-marker-group').attr('opacity', FADE_OPACITY);
1618
1681
  }
1619
1682
 
1620
1683
  function highlightLane(
@@ -1654,6 +1717,8 @@ function highlightLane(
1654
1717
  // Fade group elements (not relevant in lane mode)
1655
1718
  g.selectAll<SVGElement, unknown>('.gantt-group-bar, .gantt-group-summary').attr('opacity', FADE_OPACITY);
1656
1719
  svg.selectAll<SVGGElement, unknown>('.gantt-group-label').attr('opacity', FADE_OPACITY);
1720
+ // Fade markers
1721
+ g.selectAll<SVGElement, unknown>('.gantt-marker-group').attr('opacity', FADE_OPACITY);
1657
1722
  }
1658
1723
 
1659
1724
  function highlightTask(
@@ -1679,6 +1744,35 @@ function highlightTask(
1679
1744
  svg.selectAll<SVGGElement, unknown>('.gantt-lane-header').attr('opacity', FADE_OPACITY);
1680
1745
  g.selectAll<SVGElement, unknown>('.gantt-lane-band, .gantt-lane-accent, .gantt-lane-band-group').attr('opacity', FADE_OPACITY);
1681
1746
  g.selectAll<SVGElement, unknown>('.gantt-dep-arrow, .gantt-dep-arrowhead').attr('opacity', FADE_OPACITY);
1747
+ // Fade markers
1748
+ g.selectAll<SVGElement, unknown>('.gantt-marker-group').attr('opacity', FADE_OPACITY);
1749
+ }
1750
+
1751
+ function highlightMilestone(
1752
+ g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
1753
+ svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
1754
+ taskId: string,
1755
+ ): void {
1756
+ // Fade tasks
1757
+ g.selectAll<SVGGElement, unknown>('.gantt-task').attr('opacity', FADE_OPACITY);
1758
+ // Fade milestones not matching
1759
+ g.selectAll<SVGElement, unknown>('.gantt-milestone').each(function () {
1760
+ const el = d3Selection.select(this);
1761
+ el.attr('opacity', el.attr('data-task-id') === taskId ? 1 : FADE_OPACITY);
1762
+ });
1763
+ // Fade task labels not matching
1764
+ svg.selectAll<SVGTextElement, unknown>('.gantt-task-label').each(function () {
1765
+ const el = d3Selection.select(this);
1766
+ el.attr('opacity', el.attr('data-task-id') === taskId ? 1 : FADE_OPACITY);
1767
+ });
1768
+ // Fade group/lane elements
1769
+ g.selectAll<SVGElement, unknown>('.gantt-group-bar, .gantt-group-summary').attr('opacity', FADE_OPACITY);
1770
+ svg.selectAll<SVGGElement, unknown>('.gantt-group-label').attr('opacity', FADE_OPACITY);
1771
+ svg.selectAll<SVGGElement, unknown>('.gantt-lane-header').attr('opacity', FADE_OPACITY);
1772
+ g.selectAll<SVGElement, unknown>('.gantt-lane-band, .gantt-lane-accent, .gantt-lane-band-group').attr('opacity', FADE_OPACITY);
1773
+ g.selectAll<SVGElement, unknown>('.gantt-dep-arrow, .gantt-dep-arrowhead').attr('opacity', FADE_OPACITY);
1774
+ // Fade markers
1775
+ g.selectAll<SVGElement, unknown>('.gantt-marker-group').attr('opacity', FADE_OPACITY);
1682
1776
  }
1683
1777
 
1684
1778
  function highlightTaskLabel(
@@ -1709,6 +1803,7 @@ function resetHighlight(
1709
1803
  svg.selectAll<SVGGElement, unknown>('.gantt-lane-header').attr('opacity', 1);
1710
1804
  g.selectAll<SVGElement, unknown>('.gantt-lane-band, .gantt-lane-accent, .gantt-lane-band-group').attr('opacity', 1);
1711
1805
  g.selectAll<SVGElement, unknown>('.gantt-dep-arrow, .gantt-dep-arrowhead').attr('opacity', 0.5);
1806
+ g.selectAll<SVGElement, unknown>('.gantt-marker-group').attr('opacity', 1);
1712
1807
  }
1713
1808
 
1714
1809
  // ── Row Building ────────────────────────────────────────────
@@ -1821,20 +1916,16 @@ export function buildTagLaneRowList(
1821
1916
  }
1822
1917
  }
1823
1918
 
1824
- // Emit lanes in tag entry declaration order
1919
+ // Emit lanes in tag entry declaration order (skip empty lanes)
1825
1920
  for (const entry of tagGroup.entries) {
1826
1921
  const entryKey = entry.value.toLowerCase();
1827
1922
  const tasks = buckets.get(entryKey) ?? [];
1923
+ if (tasks.length === 0) continue;
1828
1924
  // Sort tasks within lane by start date
1829
1925
  tasks.sort((a, b) => a.startDate.getTime() - b.startDate.getTime());
1830
1926
 
1831
- // Compute aggregate progress
1832
- const progressValues = tasks
1833
- .map(t => t.task.progress)
1834
- .filter((p): p is number => p !== null);
1835
- const aggregateProgress = progressValues.length > 0
1836
- ? progressValues.reduce((a, b) => a + b, 0) / progressValues.length
1837
- : null;
1927
+ // Compute duration-weighted aggregate progress (tasks without progress count as 0%)
1928
+ const aggregateProgress = durationWeightedProgress(tasks);
1838
1929
 
1839
1930
  // Compute lane date range from tasks
1840
1931
  const laneStartDate = tasks.length > 0 ? new Date(Math.min(...tasks.map(t => t.startDate.getTime()))) : null;
@@ -1861,12 +1952,7 @@ export function buildTagLaneRowList(
1861
1952
  // Append unbucketed tasks as "No {GroupName}" lane
1862
1953
  if (unbucketed.length > 0) {
1863
1954
  unbucketed.sort((a, b) => a.startDate.getTime() - b.startDate.getTime());
1864
- const progressValues = unbucketed
1865
- .map(t => t.task.progress)
1866
- .filter((p): p is number => p !== null);
1867
- const aggregateProgress = progressValues.length > 0
1868
- ? progressValues.reduce((a, b) => a + b, 0) / progressValues.length
1869
- : null;
1955
+ const aggregateProgress = durationWeightedProgress(unbucketed);
1870
1956
 
1871
1957
  const noLaneStartDate = unbucketed.length > 0 ? new Date(Math.min(...unbucketed.map(t => t.startDate.getTime()))) : null;
1872
1958
  const noLaneEndDate = unbucketed.length > 0 ? new Date(Math.max(...unbucketed.map(t => t.endDate.getTime()))) : null;
@@ -1895,6 +1981,22 @@ export function buildTagLaneRowList(
1895
1981
 
1896
1982
  // ── Helpers ─────────────────────────────────────────────────
1897
1983
 
1984
+ /** Duration-weighted progress: tasks without explicit progress count as 0%. Returns null if no task has progress. */
1985
+ function durationWeightedProgress(tasks: ResolvedTask[]): number | null {
1986
+ let totalDuration = 0;
1987
+ let totalProgress = 0;
1988
+ let hasProgress = false;
1989
+ for (const rt of tasks) {
1990
+ const dur = rt.endDate.getTime() - rt.startDate.getTime();
1991
+ totalDuration += dur;
1992
+ if (rt.task.progress !== null) {
1993
+ totalProgress += rt.task.progress * dur;
1994
+ hasProgress = true;
1995
+ }
1996
+ }
1997
+ return hasProgress && totalDuration > 0 ? totalProgress / totalDuration : null;
1998
+ }
1999
+
1898
2000
  function dateToFractionalYear(d: Date): number {
1899
2001
  const y = d.getFullYear();
1900
2002
  const startOfYear = new Date(y, 0, 1);
@@ -1970,6 +2072,22 @@ function showGanttDateIndicators(
1970
2072
  const endPos = xScale(dateToFractionalYear(endDate));
1971
2073
  const endLabel = formatGanttDate(endDate);
1972
2074
 
2075
+ // When dates are close, push labels apart so they don't overlap.
2076
+ // ~90px is roughly the width of a date label like "Aug 12, 2026" at 10px.
2077
+ const minLabelGap = 90;
2078
+ const gap = endPos - startPos;
2079
+ let startLabelX = startPos;
2080
+ let endLabelX = endPos;
2081
+ let startAnchor = 'middle';
2082
+ let endAnchor = 'middle';
2083
+ if (gap < minLabelGap) {
2084
+ const mid = (startPos + endPos) / 2;
2085
+ startLabelX = mid - minLabelGap / 2;
2086
+ endLabelX = mid + minLabelGap / 2;
2087
+ startAnchor = 'middle';
2088
+ endAnchor = 'middle';
2089
+ }
2090
+
1973
2091
  // End date — dashed vertical line
1974
2092
  g.append('line')
1975
2093
  .attr('class', 'gantt-hover-date')
@@ -1982,12 +2100,20 @@ function showGanttDateIndicators(
1982
2100
  .attr('stroke-dasharray', '4 4')
1983
2101
  .attr('opacity', 0.6);
1984
2102
 
2103
+ // Reposition start labels to avoid overlap
2104
+ g.selectAll<SVGTextElement, unknown>('text.gantt-hover-date').each(function () {
2105
+ const el = d3Selection.select(this);
2106
+ if (el.text() === startLabel) {
2107
+ el.attr('x', startLabelX).attr('text-anchor', startAnchor);
2108
+ }
2109
+ });
2110
+
1985
2111
  // End date — top label
1986
2112
  g.append('text')
1987
2113
  .attr('class', 'gantt-hover-date')
1988
- .attr('x', endPos)
2114
+ .attr('x', endLabelX)
1989
2115
  .attr('y', -tickLen - 4)
1990
- .attr('text-anchor', 'middle')
2116
+ .attr('text-anchor', endAnchor)
1991
2117
  .attr('fill', color)
1992
2118
  .attr('font-size', '10px')
1993
2119
  .attr('font-weight', '600')
@@ -1996,9 +2122,9 @@ function showGanttDateIndicators(
1996
2122
  // End date — bottom label
1997
2123
  g.append('text')
1998
2124
  .attr('class', 'gantt-hover-date')
1999
- .attr('x', endPos)
2125
+ .attr('x', endLabelX)
2000
2126
  .attr('y', innerHeight + tickLen + 12)
2001
- .attr('text-anchor', 'middle')
2127
+ .attr('text-anchor', endAnchor)
2002
2128
  .attr('fill', color)
2003
2129
  .attr('font-size', '10px')
2004
2130
  .attr('font-weight', '600')
package/src/org/parser.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  measureIndent,
8
8
  extractColor,
9
9
  parsePipeMetadata,
10
+ MULTIPLE_PIPE_WARNING,
10
11
  CHART_TYPE_RE,
11
12
  TITLE_RE,
12
13
  OPTION_RE,
@@ -280,14 +281,14 @@ export function parseOrg(
280
281
  // Otherwise it's an orphan metadata error
281
282
  if (indent === 0) {
282
283
  // Treat as a node label (e.g., "Dr. Smith: Surgeon" is a valid name)
283
- const node = parseNodeLabel(trimmed, indent, lineNumber, palette, ++nodeCounter, aliasMap);
284
+ const node = parseNodeLabel(trimmed, indent, lineNumber, palette, ++nodeCounter, aliasMap, pushWarning);
284
285
  attachNode(node, indent, indentStack, result);
285
286
  } else {
286
287
  pushError(lineNumber, 'Metadata has no parent node');
287
288
  }
288
289
  } else {
289
290
  // It's a node label — possibly with single-line pipe-delimited metadata
290
- const node = parseNodeLabel(trimmed, indent, lineNumber, palette, ++nodeCounter, aliasMap);
291
+ const node = parseNodeLabel(trimmed, indent, lineNumber, palette, ++nodeCounter, aliasMap, pushWarning);
291
292
  attachNode(node, indent, indentStack, result);
292
293
  }
293
294
  }
@@ -326,15 +327,16 @@ function parseNodeLabel(
326
327
  lineNumber: number,
327
328
  palette: PaletteColors | undefined,
328
329
  counter: number,
329
- aliasMap: Map<string, string> = new Map()
330
+ aliasMap: Map<string, string> = new Map(),
331
+ warnFn?: (line: number, msg: string) => void,
330
332
  ): OrgNode {
331
- // Check for single-line compact metadata: "Alice Park | role: Senior | location: NY"
333
+ // Check for single-line compact metadata: "Alice Park | role: Senior, location: NY"
332
334
  const segments = trimmed.split('|').map((s) => s.trim());
333
335
 
334
336
  let rawLabel = segments[0];
335
337
  const { label, color } = extractColor(rawLabel, palette);
336
338
 
337
- const metadata = parsePipeMetadata(segments, aliasMap);
339
+ const metadata = parsePipeMetadata(segments, aliasMap, warnFn ? () => warnFn(lineNumber, MULTIPLE_PIPE_WARNING) : undefined);
338
340
 
339
341
  return {
340
342
  id: `node-${counter}`,
@@ -6,7 +6,7 @@ import { inferParticipantType } from './participant-inference';
6
6
  import type { DgmoError } from '../diagnostics';
7
7
  import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
8
8
  import { parseArrow } from '../utils/arrows';
9
- import { measureIndent, extractColor, parsePipeMetadata } from '../utils/parsing';
9
+ import { measureIndent, extractColor, parsePipeMetadata, MULTIPLE_PIPE_WARNING } from '../utils/parsing';
10
10
  import type { TagGroup } from '../utils/tag-groups';
11
11
  import { matchTagBlockHeading, validateTagValues } from '../utils/tag-groups';
12
12
 
@@ -237,12 +237,13 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
237
237
  const aliasMap = new Map<string, string>();
238
238
 
239
239
  /** Split pipe metadata from a line: "core | k: v" → { core, meta } */
240
- const splitPipe = (text: string): { core: string; meta?: Record<string, string> } => {
240
+ const splitPipe = (text: string, ln?: number): { core: string; meta?: Record<string, string> } => {
241
241
  const idx = text.indexOf('|');
242
242
  if (idx < 0) return { core: text };
243
243
  const core = text.substring(0, idx).trimEnd();
244
244
  const segments = text.substring(idx).split('|');
245
- const meta = parsePipeMetadata(segments, aliasMap);
245
+ const warnFn = ln != null ? () => pushWarning(ln, MULTIPLE_PIPE_WARNING) : undefined;
246
+ const meta = parsePipeMetadata(segments, aliasMap, warnFn);
246
247
  return Object.keys(meta).length > 0 ? { core, meta } : { core };
247
248
  };
248
249
 
@@ -287,7 +288,7 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
287
288
  if (gpipeIdx >= 0) {
288
289
  const nameAndColor = groupName.substring(0, gpipeIdx).trimEnd();
289
290
  const segments = groupName.substring(gpipeIdx).split('|');
290
- const meta = parsePipeMetadata(segments, aliasMap);
291
+ const meta = parsePipeMetadata(segments, aliasMap, () => pushWarning(lineNumber, MULTIPLE_PIPE_WARNING));
291
292
  if (Object.keys(meta).length > 0) groupMeta = meta;
292
293
  // Re-extract color from name part
293
294
  const colorSuffix = nameAndColor.match(/^(.+?)\(([^)]+)\)$/);
@@ -444,7 +445,7 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
444
445
  }
445
446
 
446
447
  // Parse "Name is a type [aka Alias]" declarations (always top-level)
447
- const { core: isACore, meta: isAMeta } = splitPipe(trimmed);
448
+ const { core: isACore, meta: isAMeta } = splitPipe(trimmed, lineNumber);
448
449
  const isAMatch = isACore.match(IS_A_PATTERN);
449
450
  if (isAMatch) {
450
451
  contentStarted = true;
@@ -491,7 +492,7 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
491
492
  }
492
493
 
493
494
  // Parse standalone "Name position N" (no "is a" type)
494
- const { core: posCore, meta: posMeta } = splitPipe(trimmed);
495
+ const { core: posCore, meta: posMeta } = splitPipe(trimmed, lineNumber);
495
496
  const posOnlyMatch = posCore.match(POSITION_ONLY_PATTERN);
496
497
  if (posOnlyMatch) {
497
498
  contentStarted = true;
@@ -523,7 +524,7 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
523
524
 
524
525
  // Colored participant declaration — "Name(color)" at any level
525
526
  // Color syntax is deprecated — emit warning and register without color
526
- const { core: colorCore, meta: colorMeta } = splitPipe(trimmed);
527
+ const { core: colorCore, meta: colorMeta } = splitPipe(trimmed, lineNumber);
527
528
  const coloredMatch = colorCore.match(COLORED_PARTICIPANT_PATTERN);
528
529
  if (coloredMatch && !ARROW_PATTERN.test(colorCore)) {
529
530
  const id = coloredMatch[1];
@@ -554,7 +555,7 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
554
555
  // Bare participant name — either inside an active group (indented) or top-level declaration
555
556
  // Supports pipe metadata: " API | c: Gateway" or "Tapin2 | l:Park"
556
557
  {
557
- const { core: bareCore, meta: bareMeta } = splitPipe(trimmed);
558
+ const { core: bareCore, meta: bareMeta } = splitPipe(trimmed, lineNumber);
558
559
  const inGroup = activeGroup && measureIndent(raw) > 0;
559
560
  if (/^\S+$/.test(bareCore) && !ARROW_PATTERN.test(bareCore) && (inGroup || !contentStarted || bareMeta)) {
560
561
  contentStarted = true;
@@ -600,7 +601,7 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
600
601
  }
601
602
 
602
603
  // Split pipe metadata before arrow parsing (arrows use $ anchor)
603
- const { core: arrowCore, meta: arrowMeta } = splitPipe(trimmed);
604
+ const { core: arrowCore, meta: arrowMeta } = splitPipe(trimmed, lineNumber);
604
605
 
605
606
  // Parse message lines first — arrows take priority over keywords
606
607
  // Reject "async" keyword prefix — use ~> instead
@@ -11,6 +11,7 @@ import {
11
11
  measureIndent,
12
12
  extractColor,
13
13
  parsePipeMetadata,
14
+ MULTIPLE_PIPE_WARNING,
14
15
  CHART_TYPE_RE,
15
16
  TITLE_RE,
16
17
  OPTION_RE,
@@ -360,7 +361,7 @@ export function parseSitemap(
360
361
  } else if (metadataMatch && indentStack.length === 0) {
361
362
  // Could be a node label containing ':'
362
363
  if (indent === 0) {
363
- const node = parseNodeLabel(trimmed, lineNumber, palette, ++nodeCounter, aliasMap);
364
+ const node = parseNodeLabel(trimmed, lineNumber, palette, ++nodeCounter, aliasMap, pushWarning);
364
365
  attachNode(node, indent, indentStack, result);
365
366
  labelToNode.set(node.label.toLowerCase(), node);
366
367
  } else {
@@ -368,7 +369,7 @@ export function parseSitemap(
368
369
  }
369
370
  } else {
370
371
  // Node label — possibly with pipe-delimited metadata
371
- const node = parseNodeLabel(trimmed, lineNumber, palette, ++nodeCounter, aliasMap);
372
+ const node = parseNodeLabel(trimmed, lineNumber, palette, ++nodeCounter, aliasMap, pushWarning);
372
373
  attachNode(node, indent, indentStack, result);
373
374
  labelToNode.set(node.label.toLowerCase(), node);
374
375
  }
@@ -430,11 +431,12 @@ function parseNodeLabel(
430
431
  palette: PaletteColors | undefined,
431
432
  counter: number,
432
433
  aliasMap: Map<string, string> = new Map(),
434
+ warnFn?: (line: number, msg: string) => void,
433
435
  ): SitemapNode {
434
436
  const segments = trimmed.split('|').map((s) => s.trim());
435
437
  const rawLabel = segments[0];
436
438
  const { label, color } = extractColor(rawLabel, palette);
437
- const metadata = parsePipeMetadata(segments, aliasMap);
439
+ const metadata = parsePipeMetadata(segments, aliasMap, warnFn ? () => warnFn(lineNumber, MULTIPLE_PIPE_WARNING) : undefined);
438
440
 
439
441
  return {
440
442
  id: `node-${counter}`,
@@ -118,23 +118,34 @@ export function parseSeriesNames(
118
118
  return { series, names, nameColors, newIndex };
119
119
  }
120
120
 
121
- /** Parse pipe-delimited metadata from segments after the first (name) segment. */
121
+ /** Warning message for multiple pipes on a single line. */
122
+ export const MULTIPLE_PIPE_WARNING =
123
+ 'Use a single "|" to start metadata, then separate items with commas.';
124
+
125
+ /**
126
+ * Parse metadata from segments after the first (name) segment.
127
+ * A single `|` separates the label from metadata; items after the pipe are comma-delimited.
128
+ * Multiple pipes are treated as commas for backward compatibility but trigger a warning.
129
+ */
122
130
  export function parsePipeMetadata(
123
131
  segments: string[],
124
132
  aliasMap: Map<string, string> = new Map(),
133
+ warnMultiplePipes?: () => void,
125
134
  ): Record<string, string> {
135
+ if (segments.length > 2 && warnMultiplePipes) {
136
+ warnMultiplePipes();
137
+ }
126
138
  const metadata: Record<string, string> = {};
127
- for (let j = 1; j < segments.length; j++) {
128
- for (const part of segments[j].split(',')) {
129
- const trimmedPart = part.trim();
130
- if (!trimmedPart) continue;
131
- const colonIdx = trimmedPart.indexOf(':');
132
- if (colonIdx > 0) {
133
- const rawKey = trimmedPart.substring(0, colonIdx).trim().toLowerCase();
134
- const key = aliasMap.get(rawKey) ?? rawKey;
135
- const value = trimmedPart.substring(colonIdx + 1).trim();
136
- metadata[key] = value;
137
- }
139
+ const raw = segments.slice(1).join(',');
140
+ for (const part of raw.split(',')) {
141
+ const trimmedPart = part.trim();
142
+ if (!trimmedPart) continue;
143
+ const colonIdx = trimmedPart.indexOf(':');
144
+ if (colonIdx > 0) {
145
+ const rawKey = trimmedPart.substring(0, colonIdx).trim().toLowerCase();
146
+ const key = aliasMap.get(rawKey) ?? rawKey;
147
+ const value = trimmedPart.substring(colonIdx + 1).trim();
148
+ metadata[key] = value;
138
149
  }
139
150
  }
140
151
  return metadata;