@diagrammo/dgmo 0.7.3 → 0.8.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.
Files changed (62) hide show
  1. package/AGENTS.md +15 -20
  2. package/README.md +56 -58
  3. package/dist/cli.cjs +188 -181
  4. package/dist/index.cjs +3522 -1072
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +196 -43
  7. package/dist/index.d.ts +196 -43
  8. package/dist/index.js +3509 -1072
  9. package/dist/index.js.map +1 -1
  10. package/docs/language-reference.md +629 -289
  11. package/package.json +1 -1
  12. package/src/c4/layout.ts +6 -9
  13. package/src/c4/parser.ts +189 -83
  14. package/src/c4/renderer.ts +8 -9
  15. package/src/chart.ts +296 -83
  16. package/src/class/parser.ts +54 -37
  17. package/src/class/renderer.ts +8 -8
  18. package/src/cli.ts +8 -8
  19. package/src/colors.ts +4 -1
  20. package/src/completion.ts +757 -10
  21. package/src/d3.ts +324 -78
  22. package/src/dgmo-router.ts +63 -8
  23. package/src/echarts.ts +735 -241
  24. package/src/er/parser.ts +94 -76
  25. package/src/er/renderer.ts +6 -5
  26. package/src/gantt/parser.ts +144 -69
  27. package/src/gantt/renderer.ts +50 -14
  28. package/src/gantt/types.ts +3 -3
  29. package/src/graph/flowchart-parser.ts +97 -37
  30. package/src/graph/flowchart-renderer.ts +4 -3
  31. package/src/graph/state-parser.ts +50 -31
  32. package/src/graph/state-renderer.ts +4 -3
  33. package/src/index.ts +14 -5
  34. package/src/infra/compute.ts +1 -0
  35. package/src/infra/layout.ts +3 -0
  36. package/src/infra/parser.ts +291 -92
  37. package/src/infra/renderer.ts +172 -30
  38. package/src/infra/types.ts +5 -0
  39. package/src/initiative-status/layout.ts +1 -1
  40. package/src/initiative-status/parser.ts +121 -47
  41. package/src/initiative-status/renderer.ts +42 -23
  42. package/src/initiative-status/types.ts +10 -2
  43. package/src/kanban/parser.ts +60 -37
  44. package/src/kanban/renderer.ts +2 -2
  45. package/src/kanban/types.ts +1 -0
  46. package/src/org/layout.ts +9 -9
  47. package/src/org/parser.ts +39 -40
  48. package/src/org/renderer.ts +5 -6
  49. package/src/org/resolver.ts +26 -19
  50. package/src/render.ts +1 -1
  51. package/src/sequence/parser.ts +304 -95
  52. package/src/sequence/renderer.ts +9 -9
  53. package/src/sitemap/layout.ts +3 -4
  54. package/src/sitemap/parser.ts +57 -49
  55. package/src/sitemap/renderer.ts +6 -7
  56. package/src/utils/arrows.ts +25 -6
  57. package/src/utils/duration.ts +43 -7
  58. package/src/utils/legend-constants.ts +26 -0
  59. package/src/utils/legend-svg.ts +167 -0
  60. package/src/utils/parsing.ts +247 -7
  61. package/src/utils/tag-groups.ts +160 -15
  62. package/src/utils/title-constants.ts +9 -0
@@ -18,15 +18,15 @@ import {
18
18
  LEGEND_HEIGHT,
19
19
  LEGEND_PILL_PAD,
20
20
  LEGEND_PILL_FONT_SIZE,
21
- LEGEND_PILL_FONT_W,
22
21
  LEGEND_CAPSULE_PAD,
23
22
  LEGEND_DOT_R,
24
23
  LEGEND_ENTRY_FONT_SIZE,
25
- LEGEND_ENTRY_FONT_W,
26
24
  LEGEND_ENTRY_DOT_GAP,
27
25
  LEGEND_ENTRY_TRAIL,
28
26
  LEGEND_GROUP_GAP,
27
+ measureLegendText,
29
28
  } from '../utils/legend-constants';
29
+ import { TITLE_FONT_SIZE, TITLE_FONT_WEIGHT, TITLE_Y, TITLE_OFFSET } from '../utils/title-constants';
30
30
 
31
31
  // ============================================================
32
32
  // Constants
@@ -49,6 +49,9 @@ const COLLAPSE_BAR_HEIGHT = 6;
49
49
  const COLLAPSE_BAR_INSET = 0;
50
50
 
51
51
  const LEGEND_FIXED_GAP = 16; // gap between fixed legend and scaled diagram — local, not shared
52
+ const SPEED_BADGE_H_PAD = 5; // horizontal padding inside active speed badge
53
+ const SPEED_BADGE_V_PAD = 3; // vertical padding inside active speed badge
54
+ const SPEED_BADGE_GAP = 6; // gap between speed option slots
52
55
 
53
56
  // Health colors (from UX spec)
54
57
  const COLOR_HEALTHY = '#22c55e';
@@ -930,6 +933,7 @@ function renderEdgePaths(
930
933
  isDark: boolean,
931
934
  animate: boolean,
932
935
  direction: 'LR' | 'TB',
936
+ speedMultiplier: number = 1,
933
937
  ) {
934
938
  const nodeMap = new Map(nodes.map((n) => [n.id, n]));
935
939
  const maxRps = Math.max(...edges.map((e) => e.computedRps), 1);
@@ -954,14 +958,18 @@ function renderEdgePaths(
954
958
  .attr('class', 'infra-edge')
955
959
  .attr('data-line-number', edge.lineNumber);
956
960
 
957
- edgeG.append('path')
961
+ const edgePath = edgeG.append('path')
958
962
  .attr('d', pathD)
959
963
  .attr('fill', 'none')
960
964
  .attr('stroke', color)
961
965
  .attr('stroke-width', strokeW);
966
+ if (edge.async) {
967
+ edgePath.attr('stroke-dasharray', '6 4');
968
+ }
962
969
 
963
970
  if (animate && edge.computedRps > 0) {
964
- const dur = flowDuration(edge.computedRps, maxRps);
971
+ const baseDur = flowDuration(edge.computedRps, maxRps);
972
+ const dur = speedMultiplier > 0 ? baseDur / speedMultiplier : baseDur;
965
973
 
966
974
  // Particles traveling along the path — always green (overloaded nodes
967
975
  // already have red styling + reject particles to show the problem)
@@ -1490,6 +1498,7 @@ function computeRejectedRps(node: InfraLayoutNode): number {
1490
1498
  function renderRejectParticles(
1491
1499
  svg: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
1492
1500
  nodes: InfraLayoutNode[],
1501
+ speedMultiplier: number = 1,
1493
1502
  ) {
1494
1503
  // Compute max rejected RPS across all nodes for scaling
1495
1504
  const rejectMap: { node: InfraLayoutNode; rejected: number }[] = [];
@@ -1504,7 +1513,8 @@ function renderRejectParticles(
1504
1513
  for (const { node, rejected } of rejectMap) {
1505
1514
  const t = Math.min(rejected / maxRejected, 1);
1506
1515
  const count = Math.round(REJECT_COUNT_MIN + t * (REJECT_COUNT_MAX - REJECT_COUNT_MIN));
1507
- const dur = REJECT_DURATION_MAX - t * (REJECT_DURATION_MAX - REJECT_DURATION_MIN);
1516
+ const baseDur = REJECT_DURATION_MAX - t * (REJECT_DURATION_MAX - REJECT_DURATION_MIN);
1517
+ const dur = speedMultiplier > 0 ? baseDur / speedMultiplier : baseDur;
1508
1518
 
1509
1519
  const nodeBottom = node.y + node.height / 2;
1510
1520
 
@@ -1593,10 +1603,10 @@ export function computeInfraLegendGroups(
1593
1603
  color: r.color,
1594
1604
  key: r.name.toLowerCase().replace(/\s+/g, '-'),
1595
1605
  }));
1596
- const pillWidth = 'Capabilities'.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
1606
+ const pillWidth = measureLegendText('Capabilities', LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
1597
1607
  let entriesWidth = 0;
1598
1608
  for (const e of entries) {
1599
- entriesWidth += LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP + e.value.length * LEGEND_ENTRY_FONT_W + LEGEND_ENTRY_TRAIL;
1609
+ entriesWidth += LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP + measureLegendText(e.value, LEGEND_ENTRY_FONT_SIZE) + LEGEND_ENTRY_TRAIL;
1600
1610
  }
1601
1611
  groups.push({
1602
1612
  name: 'Capabilities',
@@ -1614,16 +1624,16 @@ export function computeInfraLegendGroups(
1614
1624
  if (tv.color) {
1615
1625
  entries.push({
1616
1626
  value: tv.name,
1617
- color: resolveColor(tv.color, palette),
1627
+ color: resolveColor(tv.color, palette) ?? tv.color,
1618
1628
  key: tv.name.toLowerCase(),
1619
1629
  });
1620
1630
  }
1621
1631
  }
1622
1632
  if (entries.length === 0) continue;
1623
- const pillWidth = tg.name.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
1633
+ const pillWidth = measureLegendText(tg.name, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
1624
1634
  let entriesWidth = 0;
1625
1635
  for (const e of entries) {
1626
- entriesWidth += LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP + e.value.length * LEGEND_ENTRY_FONT_W + LEGEND_ENTRY_TRAIL;
1636
+ entriesWidth += LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP + measureLegendText(e.value, LEGEND_ENTRY_FONT_SIZE) + LEGEND_ENTRY_TRAIL;
1627
1637
  }
1628
1638
  groups.push({
1629
1639
  name: tg.name,
@@ -1638,6 +1648,20 @@ export function computeInfraLegendGroups(
1638
1648
  return groups;
1639
1649
  }
1640
1650
 
1651
+ /** Compute total width for the playback pill (speed only). */
1652
+ function computePlaybackWidth(playback: InfraPlaybackState | undefined): number {
1653
+ if (!playback) return 0;
1654
+ const pillWidth = measureLegendText('Playback', LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
1655
+ if (!playback.expanded) return pillWidth;
1656
+
1657
+ let entriesW = 8; // gap after pill
1658
+ entriesW += LEGEND_PILL_FONT_SIZE * 0.8 + 6; // play/pause
1659
+ for (const s of playback.speedOptions) {
1660
+ entriesW += measureLegendText(`${s}x`, LEGEND_ENTRY_FONT_SIZE) + SPEED_BADGE_H_PAD * 2 + SPEED_BADGE_GAP;
1661
+ }
1662
+ return LEGEND_CAPSULE_PAD * 2 + pillWidth + entriesW;
1663
+ }
1664
+
1641
1665
  function renderLegend(
1642
1666
  rootSvg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
1643
1667
  legendGroups: InfraLegendGroup[],
@@ -1646,8 +1670,9 @@ function renderLegend(
1646
1670
  palette: PaletteColors,
1647
1671
  isDark: boolean,
1648
1672
  activeGroup: string | null,
1673
+ playback?: InfraPlaybackState,
1649
1674
  ) {
1650
- if (legendGroups.length === 0) return;
1675
+ if (legendGroups.length === 0 && !playback) return;
1651
1676
 
1652
1677
  const legendG = rootSvg.append('g')
1653
1678
  .attr('transform', `translate(0, ${legendY})`);
@@ -1659,8 +1684,11 @@ function renderLegend(
1659
1684
  // Compute centered positions
1660
1685
  const effectiveW = (g: InfraLegendGroup) =>
1661
1686
  activeGroup != null && g.name.toLowerCase() === activeGroup.toLowerCase() ? g.width : g.minifiedWidth;
1687
+ const playbackW = computePlaybackWidth(playback);
1688
+ const trailingGaps = legendGroups.length > 0 && playbackW > 0 ? LEGEND_GROUP_GAP : 0;
1662
1689
  const totalLegendW = legendGroups.reduce((s, g) => s + effectiveW(g), 0)
1663
- + (legendGroups.length - 1) * LEGEND_GROUP_GAP;
1690
+ + (legendGroups.length - 1) * LEGEND_GROUP_GAP
1691
+ + trailingGaps + playbackW;
1664
1692
  let cursorX = (totalWidth - totalLegendW) / 2;
1665
1693
 
1666
1694
  for (const group of legendGroups) {
@@ -1671,7 +1699,7 @@ function renderLegend(
1671
1699
  : mix(palette.surface, palette.bg, 30);
1672
1700
 
1673
1701
  const pillLabel = group.name;
1674
- const pillWidth = pillLabel.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
1702
+ const pillWidth = measureLegendText(pillLabel, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
1675
1703
 
1676
1704
  const gEl = legendG
1677
1705
  .append('g')
@@ -1754,19 +1782,131 @@ function renderLegend(
1754
1782
  .attr('fill', palette.textMuted)
1755
1783
  .text(entry.value);
1756
1784
 
1757
- entryX = textX + entry.value.length * LEGEND_ENTRY_FONT_W + LEGEND_ENTRY_TRAIL;
1785
+ entryX = textX + measureLegendText(entry.value, LEGEND_ENTRY_FONT_SIZE) + LEGEND_ENTRY_TRAIL;
1758
1786
  }
1759
1787
  }
1760
1788
 
1761
1789
  cursorX += effectiveW(group) + LEGEND_GROUP_GAP;
1762
1790
  }
1763
1791
 
1792
+ // Playback pill — speed + pause only
1793
+ if (playback) {
1794
+ const isExpanded = playback.expanded;
1795
+ const groupBg = isDark
1796
+ ? mix(palette.bg, palette.text, 85)
1797
+ : mix(palette.bg, palette.text, 92);
1798
+
1799
+ const pillLabel = 'Playback';
1800
+ const pillWidth = measureLegendText(pillLabel, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
1801
+ const fullW = computePlaybackWidth(playback);
1802
+
1803
+ const pbG = legendG
1804
+ .append('g')
1805
+ .attr('transform', `translate(${cursorX}, 0)`)
1806
+ .attr('class', 'infra-legend-group infra-playback-pill')
1807
+ .style('cursor', 'pointer');
1808
+
1809
+ if (isExpanded) {
1810
+ pbG.append('rect')
1811
+ .attr('width', fullW)
1812
+ .attr('height', LEGEND_HEIGHT)
1813
+ .attr('rx', LEGEND_HEIGHT / 2)
1814
+ .attr('fill', groupBg);
1815
+ }
1816
+
1817
+ const pillXOff = isExpanded ? LEGEND_CAPSULE_PAD : 0;
1818
+ const pillYOff = isExpanded ? LEGEND_CAPSULE_PAD : 0;
1819
+ const pillH = LEGEND_HEIGHT - (isExpanded ? LEGEND_CAPSULE_PAD * 2 : 0);
1820
+
1821
+ pbG.append('rect')
1822
+ .attr('x', pillXOff).attr('y', pillYOff)
1823
+ .attr('width', pillWidth).attr('height', pillH)
1824
+ .attr('rx', pillH / 2)
1825
+ .attr('fill', isExpanded ? palette.bg : groupBg);
1826
+
1827
+ if (isExpanded) {
1828
+ pbG.append('rect')
1829
+ .attr('x', pillXOff).attr('y', pillYOff)
1830
+ .attr('width', pillWidth).attr('height', pillH)
1831
+ .attr('rx', pillH / 2)
1832
+ .attr('fill', 'none')
1833
+ .attr('stroke', mix(palette.textMuted, palette.bg, 50))
1834
+ .attr('stroke-width', 0.75);
1835
+ }
1836
+
1837
+ pbG.append('text')
1838
+ .attr('x', pillXOff + pillWidth / 2)
1839
+ .attr('y', LEGEND_HEIGHT / 2 + LEGEND_PILL_FONT_SIZE / 2 - 2)
1840
+ .attr('font-family', FONT_FAMILY)
1841
+ .attr('font-size', LEGEND_PILL_FONT_SIZE)
1842
+ .attr('font-weight', '500')
1843
+ .attr('fill', isExpanded ? palette.text : palette.textMuted)
1844
+ .attr('text-anchor', 'middle')
1845
+ .text(pillLabel);
1846
+
1847
+ if (isExpanded) {
1848
+ let entryX = pillXOff + pillWidth + 8;
1849
+ const entryY = LEGEND_HEIGHT / 2 + LEGEND_ENTRY_FONT_SIZE / 2 - 1;
1850
+
1851
+ const ppLabel = playback.paused ? '▶' : '⏸';
1852
+ pbG.append('text')
1853
+ .attr('x', entryX).attr('y', entryY)
1854
+ .attr('font-family', FONT_FAMILY)
1855
+ .attr('font-size', LEGEND_PILL_FONT_SIZE)
1856
+ .attr('fill', palette.textMuted)
1857
+ .attr('data-playback-action', 'toggle-pause')
1858
+ .style('cursor', 'pointer')
1859
+ .text(ppLabel);
1860
+ entryX += LEGEND_PILL_FONT_SIZE * 0.8 + 6;
1861
+
1862
+ for (const s of playback.speedOptions) {
1863
+ const label = `${s}x`;
1864
+ const isActive = playback.speed === s;
1865
+ const slotW = measureLegendText(label, LEGEND_ENTRY_FONT_SIZE) + SPEED_BADGE_H_PAD * 2;
1866
+ const badgeH = LEGEND_ENTRY_FONT_SIZE + SPEED_BADGE_V_PAD * 2;
1867
+ const badgeY = (LEGEND_HEIGHT - badgeH) / 2;
1868
+
1869
+ const speedG = pbG.append('g')
1870
+ .attr('data-playback-action', 'set-speed')
1871
+ .attr('data-playback-value', String(s))
1872
+ .style('cursor', 'pointer');
1873
+
1874
+ speedG.append('rect')
1875
+ .attr('x', entryX)
1876
+ .attr('y', badgeY)
1877
+ .attr('width', slotW)
1878
+ .attr('height', badgeH)
1879
+ .attr('rx', badgeH / 2)
1880
+ .attr('fill', isActive ? palette.primary : 'transparent');
1881
+
1882
+ speedG.append('text')
1883
+ .attr('x', entryX + slotW / 2).attr('y', entryY)
1884
+ .attr('font-family', FONT_FAMILY)
1885
+ .attr('font-size', LEGEND_ENTRY_FONT_SIZE)
1886
+ .attr('font-weight', isActive ? '600' : '400')
1887
+ .attr('fill', isActive ? palette.bg : palette.textMuted)
1888
+ .attr('text-anchor', 'middle')
1889
+ .text(label);
1890
+ entryX += slotW + SPEED_BADGE_GAP;
1891
+ }
1892
+ }
1893
+
1894
+ cursorX += fullW + LEGEND_GROUP_GAP;
1895
+ }
1896
+
1764
1897
  }
1765
1898
 
1766
1899
  // ============================================================
1767
1900
  // Main render
1768
1901
  // ============================================================
1769
1902
 
1903
+ export interface InfraPlaybackState {
1904
+ expanded: boolean;
1905
+ paused: boolean;
1906
+ speed: number;
1907
+ speedOptions: readonly number[];
1908
+ }
1909
+
1770
1910
  export function renderInfra(
1771
1911
  container: HTMLDivElement,
1772
1912
  layout: InfraLayoutResult,
@@ -1777,7 +1917,7 @@ export function renderInfra(
1777
1917
  tagGroups?: InfraTagGroup[],
1778
1918
  activeGroup?: string | null,
1779
1919
  animate?: boolean,
1780
- _playback?: unknown,
1920
+ playback?: InfraPlaybackState | null,
1781
1921
  expandedNodeIds?: Set<string> | null,
1782
1922
  exportMode?: boolean,
1783
1923
  collapsedNodes?: Set<string> | null,
@@ -1787,12 +1927,12 @@ export function renderInfra(
1787
1927
 
1788
1928
  // Build legend groups
1789
1929
  const legendGroups = computeInfraLegendGroups(layout.nodes, tagGroups ?? [], palette, layout.edges);
1790
- const hasLegend = legendGroups.length > 0;
1930
+ const hasLegend = legendGroups.length > 0 || !!playback;
1791
1931
  // In app mode (not export), legend is rendered as a separate fixed-size SVG
1792
1932
  const fixedLegend = !exportMode && hasLegend;
1793
1933
  const legendOffset = hasLegend && !fixedLegend ? LEGEND_HEIGHT : 0;
1794
1934
 
1795
- const titleOffset = title ? 40 : 0;
1935
+ const titleOffset = title ? TITLE_OFFSET : 0;
1796
1936
  const totalWidth = layout.width;
1797
1937
  const totalHeight = layout.height + titleOffset + legendOffset;
1798
1938
 
@@ -1800,28 +1940,29 @@ export function renderInfra(
1800
1940
 
1801
1941
  // In app mode with legend + title, render the title as a separate fixed-size SVG
1802
1942
  // so the legend can be inserted between title and diagram.
1803
- const fixedTitleH = fixedLegend && title ? 40 : 0;
1943
+ const fixedTitleH = fixedLegend && title ? TITLE_OFFSET : 0;
1804
1944
  const diagramViewHeight = fixedLegend
1805
1945
  ? layout.height + (title && !fixedTitleH ? titleOffset : 0) + legendOffset
1806
1946
  : totalHeight;
1807
1947
 
1808
1948
  if (fixedTitleH) {
1949
+ const titleContainerW = container.clientWidth || totalWidth;
1809
1950
  const titleSvg = d3Selection.select(container)
1810
1951
  .append('svg')
1811
1952
  .attr('class', 'infra-title-fixed')
1812
1953
  .attr('width', '100%')
1813
1954
  .attr('height', fixedTitleH)
1814
- .attr('viewBox', `0 0 ${totalWidth} ${fixedTitleH}`)
1955
+ .attr('viewBox', `0 0 ${titleContainerW} ${fixedTitleH}`)
1815
1956
  .attr('preserveAspectRatio', 'xMidYMid meet')
1816
1957
  .style('display', 'block');
1817
1958
  titleSvg.append('text')
1818
1959
  .attr('class', 'chart-title')
1819
- .attr('x', totalWidth / 2)
1820
- .attr('y', 28)
1960
+ .attr('x', titleContainerW / 2)
1961
+ .attr('y', TITLE_Y)
1821
1962
  .attr('text-anchor', 'middle')
1822
1963
  .attr('font-family', FONT_FAMILY)
1823
- .attr('font-size', 18)
1824
- .attr('font-weight', '700')
1964
+ .attr('font-size', TITLE_FONT_SIZE)
1965
+ .attr('font-weight', TITLE_FONT_WEIGHT)
1825
1966
  .attr('fill', palette.text)
1826
1967
  .attr('data-line-number', titleLineNumber != null ? titleLineNumber : '')
1827
1968
  .text(title!);
@@ -1890,11 +2031,11 @@ export function renderInfra(
1890
2031
  rootSvg.append('text')
1891
2032
  .attr('class', 'chart-title')
1892
2033
  .attr('x', totalWidth / 2)
1893
- .attr('y', 28)
2034
+ .attr('y', TITLE_Y)
1894
2035
  .attr('text-anchor', 'middle')
1895
2036
  .attr('font-family', FONT_FAMILY)
1896
- .attr('font-size', 18)
1897
- .attr('font-weight', '700')
2037
+ .attr('font-size', TITLE_FONT_SIZE)
2038
+ .attr('font-weight', TITLE_FONT_WEIGHT)
1898
2039
  .attr('fill', palette.text)
1899
2040
  .attr('data-line-number', titleLineNumber != null ? titleLineNumber : '')
1900
2041
  .text(title);
@@ -1902,7 +2043,8 @@ export function renderInfra(
1902
2043
 
1903
2044
  // Render layers: groups (back), edge paths, nodes, reject particles, edge labels (front)
1904
2045
  renderGroups(svg, layout.groups, palette, isDark);
1905
- renderEdgePaths(svg, layout.edges, layout.nodes, layout.groups, palette, isDark, shouldAnimate, layout.direction);
2046
+ const speedMultiplier = playback?.speed ?? 1;
2047
+ renderEdgePaths(svg, layout.edges, layout.nodes, layout.groups, palette, isDark, shouldAnimate, layout.direction, speedMultiplier);
1906
2048
  const fanoutSourceIds = collectFanoutSourceIds(layout.edges);
1907
2049
  const scaledGroupIds = new Set<string>(
1908
2050
  layout.groups
@@ -1915,7 +2057,7 @@ export function renderInfra(
1915
2057
  );
1916
2058
  renderNodes(svg, layout.nodes, palette, isDark, shouldAnimate, expandedNodeIds, activeGroup, layout.options, collapsedNodes, tagGroups ?? [], fanoutSourceIds, scaledGroupIds);
1917
2059
  if (shouldAnimate) {
1918
- renderRejectParticles(svg, layout.nodes);
2060
+ renderRejectParticles(svg, layout.nodes, speedMultiplier);
1919
2061
  }
1920
2062
  renderEdgeLabels(svg, layout.edges, layout.nodes, layout.groups, palette, isDark, shouldAnimate, layout.direction);
1921
2063
 
@@ -1933,12 +2075,12 @@ export function renderInfra(
1933
2075
  .attr('preserveAspectRatio', 'xMidYMid meet')
1934
2076
  .style('display', 'block')
1935
2077
  .style('pointer-events', 'none');
1936
- renderLegend(legendSvg, legendGroups, containerWidth, LEGEND_FIXED_GAP / 2, palette, isDark, activeGroup ?? null);
2078
+ renderLegend(legendSvg, legendGroups, containerWidth, LEGEND_FIXED_GAP / 2, palette, isDark, activeGroup ?? null, playback ?? undefined);
1937
2079
  // Re-enable pointer events on interactive legend elements
1938
2080
  legendSvg.selectAll('.infra-legend-group').style('pointer-events', 'auto');
1939
2081
  } else {
1940
2082
  // Export mode: render legend at top (below title)
1941
- renderLegend(rootSvg, legendGroups, totalWidth, titleOffset, palette, isDark, activeGroup ?? null);
2083
+ renderLegend(rootSvg, legendGroups, totalWidth, titleOffset, palette, isDark, activeGroup ?? null, playback ?? undefined);
1942
2084
  }
1943
2085
  }
1944
2086
  }
@@ -62,6 +62,7 @@ export interface InfraNode {
62
62
  groupId: string | null;
63
63
  tags: Record<string, string>; // tagGroup -> tagValue
64
64
  isEdge: boolean; // true for the `edge` entry-point component
65
+ nodeType?: string; // database, cache, queue, service, gateway, storage, function, network
65
66
  description?: string;
66
67
  lineNumber: number;
67
68
  }
@@ -70,6 +71,7 @@ export interface InfraEdge {
70
71
  sourceId: string;
71
72
  targetId: string;
72
73
  label: string;
74
+ async: boolean;
73
75
  split: number | null; // percentage 0-100, or null if not declared
74
76
  fanout: number | null; // request multiplier: target receives inbound * (split/100) * fanout RPS
75
77
  lineNumber: number;
@@ -82,6 +84,8 @@ export interface InfraGroup {
82
84
  instances?: number | string;
83
85
  /** Whether this group should be collapsed by default in the source. */
84
86
  collapsed?: boolean;
87
+ /** Pipe metadata on the group header, cascaded to children. */
88
+ metadata?: Record<string, string>;
85
89
  lineNumber: number;
86
90
  }
87
91
 
@@ -175,6 +179,7 @@ export interface ComputedInfraEdge {
175
179
  sourceId: string;
176
180
  targetId: string;
177
181
  label: string;
182
+ async: boolean;
178
183
  computedRps: number;
179
184
  split: number; // resolved split (always 0-100)
180
185
  fanout: number | null;
@@ -56,7 +56,7 @@ export interface ISLayoutResult {
56
56
  height: number;
57
57
  }
58
58
 
59
- const STATUS_PRIORITY: Record<string, number> = { todo: 3, wip: 2, done: 1, na: 0 };
59
+ const STATUS_PRIORITY: Record<string, number> = { todo: 4, blocked: 3, doing: 2, done: 1, na: 0 };
60
60
 
61
61
  export function rollUpStatus(members: { status: InitiativeStatus }[]): InitiativeStatus {
62
62
  let worst: InitiativeStatus = null;