@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.
- package/dist/cli.cjs +178 -178
- package/dist/index.cjs +218 -87
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +218 -87
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/c4/parser.ts +3 -2
- package/src/d3.ts +7 -9
- package/src/er/parser.ts +5 -3
- package/src/gantt/calculator.ts +51 -11
- package/src/gantt/parser.ts +26 -20
- package/src/gantt/renderer.ts +177 -51
- package/src/org/parser.ts +7 -5
- package/src/sequence/parser.ts +10 -9
- package/src/sitemap/parser.ts +5 -3
- package/src/utils/parsing.ts +23 -12
package/src/gantt/renderer.ts
CHANGED
|
@@ -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.
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
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',
|
|
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
|
|
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
|
-
//
|
|
1486
|
-
markerG.
|
|
1487
|
-
|
|
1488
|
-
|
|
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
|
|
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
|
|
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',
|
|
2114
|
+
.attr('x', endLabelX)
|
|
1989
2115
|
.attr('y', -tickLen - 4)
|
|
1990
|
-
.attr('text-anchor',
|
|
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',
|
|
2125
|
+
.attr('x', endLabelX)
|
|
2000
2126
|
.attr('y', innerHeight + tickLen + 12)
|
|
2001
|
-
.attr('text-anchor',
|
|
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
|
|
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}`,
|
package/src/sequence/parser.ts
CHANGED
|
@@ -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
|
|
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
|
package/src/sitemap/parser.ts
CHANGED
|
@@ -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}`,
|
package/src/utils/parsing.ts
CHANGED
|
@@ -118,23 +118,34 @@ export function parseSeriesNames(
|
|
|
118
118
|
return { series, names, nameColors, newIndex };
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
/**
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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;
|