@diagrammo/dgmo 0.8.20 → 0.8.22

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 (110) hide show
  1. package/AGENTS.md +2 -1
  2. package/README.md +1 -0
  3. package/dist/cli.cjs +142 -90
  4. package/dist/editor.cjs +30 -4
  5. package/dist/editor.cjs.map +1 -1
  6. package/dist/editor.js +30 -4
  7. package/dist/editor.js.map +1 -1
  8. package/dist/highlight.cjs +25 -3
  9. package/dist/highlight.cjs.map +1 -1
  10. package/dist/highlight.js +25 -3
  11. package/dist/highlight.js.map +1 -1
  12. package/dist/index.cjs +21201 -12886
  13. package/dist/index.cjs.map +1 -1
  14. package/dist/index.d.cts +646 -89
  15. package/dist/index.d.ts +646 -89
  16. package/dist/index.js +21178 -12889
  17. package/dist/index.js.map +1 -1
  18. package/docs/guide/chart-mindmap.md +198 -0
  19. package/docs/guide/chart-sequence.md +23 -1
  20. package/docs/guide/chart-sitemap.md +18 -1
  21. package/docs/guide/chart-tech-radar.md +219 -0
  22. package/docs/guide/chart-wireframe.md +100 -0
  23. package/docs/guide/index.md +8 -0
  24. package/docs/guide/registry.json +1 -0
  25. package/docs/language-reference.md +249 -4
  26. package/gallery/fixtures/boxes-and-lines.dgmo +10 -3
  27. package/gallery/fixtures/c4-full.dgmo +2 -2
  28. package/gallery/fixtures/cycle/ooda-loop.dgmo +25 -0
  29. package/gallery/fixtures/cycle/pdca-circle-nodes.dgmo +12 -0
  30. package/gallery/fixtures/cycle/pdca-minimal.dgmo +6 -0
  31. package/gallery/fixtures/cycle/sprint-cycle-span.dgmo +17 -0
  32. package/gallery/fixtures/gantt-full.dgmo +2 -2
  33. package/gallery/fixtures/gantt.dgmo +2 -2
  34. package/gallery/fixtures/infra-full.dgmo +2 -2
  35. package/gallery/fixtures/infra.dgmo +1 -1
  36. package/gallery/fixtures/sequence-tags-protocols.dgmo +2 -2
  37. package/gallery/fixtures/sequence-tags.dgmo +2 -2
  38. package/gallery/fixtures/tech-radar-dense.dgmo +77 -0
  39. package/gallery/fixtures/tech-radar.dgmo +36 -0
  40. package/gallery/fixtures/timeline.dgmo +1 -1
  41. package/package.json +1 -1
  42. package/src/boxes-and-lines/collapse.ts +21 -3
  43. package/src/boxes-and-lines/layout.ts +360 -42
  44. package/src/boxes-and-lines/parser.ts +94 -11
  45. package/src/boxes-and-lines/renderer.ts +371 -114
  46. package/src/boxes-and-lines/types.ts +2 -1
  47. package/src/c4/layout.ts +8 -8
  48. package/src/c4/parser.ts +35 -2
  49. package/src/c4/renderer.ts +19 -3
  50. package/src/c4/types.ts +1 -0
  51. package/src/chart.ts +14 -7
  52. package/src/completion.ts +253 -0
  53. package/src/cycle/layout.ts +732 -0
  54. package/src/cycle/parser.ts +352 -0
  55. package/src/cycle/renderer.ts +539 -0
  56. package/src/cycle/types.ts +77 -0
  57. package/src/d3.ts +240 -40
  58. package/src/dgmo-router.ts +15 -0
  59. package/src/echarts.ts +7 -4
  60. package/src/editor/dgmo.grammar +5 -1
  61. package/src/editor/dgmo.grammar.js +1 -1
  62. package/src/editor/keywords.ts +26 -0
  63. package/src/gantt/parser.ts +2 -8
  64. package/src/graph/flowchart-parser.ts +15 -21
  65. package/src/graph/layout.ts +73 -9
  66. package/src/graph/state-collapse.ts +78 -0
  67. package/src/graph/state-parser.ts +5 -10
  68. package/src/graph/state-renderer.ts +139 -34
  69. package/src/index.ts +78 -0
  70. package/src/infra/layout.ts +218 -74
  71. package/src/infra/parser.ts +30 -6
  72. package/src/infra/renderer.ts +14 -8
  73. package/src/infra/types.ts +10 -3
  74. package/src/journey-map/layout.ts +386 -0
  75. package/src/journey-map/parser.ts +540 -0
  76. package/src/journey-map/renderer.ts +1456 -0
  77. package/src/journey-map/types.ts +47 -0
  78. package/src/kanban/parser.ts +3 -10
  79. package/src/kanban/renderer.ts +325 -63
  80. package/src/mindmap/collapse.ts +88 -0
  81. package/src/mindmap/layout.ts +605 -0
  82. package/src/mindmap/parser.ts +373 -0
  83. package/src/mindmap/renderer.ts +544 -0
  84. package/src/mindmap/text-wrap.ts +217 -0
  85. package/src/mindmap/types.ts +55 -0
  86. package/src/org/parser.ts +2 -6
  87. package/src/render.ts +18 -21
  88. package/src/sequence/renderer.ts +273 -56
  89. package/src/sharing.ts +3 -0
  90. package/src/sitemap/layout.ts +56 -18
  91. package/src/sitemap/parser.ts +26 -17
  92. package/src/sitemap/renderer.ts +34 -0
  93. package/src/sitemap/types.ts +1 -0
  94. package/src/tech-radar/index.ts +14 -0
  95. package/src/tech-radar/interactive.ts +1058 -0
  96. package/src/tech-radar/layout.ts +190 -0
  97. package/src/tech-radar/parser.ts +385 -0
  98. package/src/tech-radar/renderer.ts +1159 -0
  99. package/src/tech-radar/shared.ts +187 -0
  100. package/src/tech-radar/types.ts +81 -0
  101. package/src/utils/description-helpers.ts +33 -0
  102. package/src/utils/export-container.ts +3 -2
  103. package/src/utils/legend-d3.ts +1 -0
  104. package/src/utils/legend-layout.ts +5 -3
  105. package/src/utils/parsing.ts +48 -7
  106. package/src/utils/tag-groups.ts +46 -60
  107. package/src/wireframe/layout.ts +460 -0
  108. package/src/wireframe/parser.ts +956 -0
  109. package/src/wireframe/renderer.ts +1293 -0
  110. package/src/wireframe/types.ts +110 -0
@@ -5,7 +5,6 @@ import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
5
5
  import { parseInArrowLabel, matchColorParens } from '../utils/arrows';
6
6
  import {
7
7
  measureIndent,
8
- extractColor,
9
8
  inferArrowColor,
10
9
  parseFirstLine,
11
10
  OPTION_NOCOLON_RE,
@@ -32,55 +31,50 @@ interface NodeRef {
32
31
  * Try to parse a node reference from a text fragment.
33
32
  * Order matters: subroutine & document before process.
34
33
  */
35
- function parseNodeRef(text: string, palette?: PaletteColors): NodeRef | null {
34
+ function parseNodeRef(text: string): NodeRef | null {
36
35
  const t = text.trim();
37
36
  if (!t) return null;
38
37
 
39
38
  // Subroutine: [[Label]]
40
39
  let m = t.match(/^\[\[([^\]]+)\]\]$/);
41
40
  if (m) {
42
- const { label, color } = extractColor(m[1].trim(), palette);
43
- return {
44
- id: nodeId('subroutine', label),
45
- label,
46
- shape: 'subroutine',
47
- color,
48
- };
41
+ const label = m[1].trim();
42
+ return { id: nodeId('subroutine', label), label, shape: 'subroutine' };
49
43
  }
50
44
 
51
45
  // Document: [Label~]
52
46
  m = t.match(/^\[([^\]]+)~\]$/);
53
47
  if (m) {
54
- const { label, color } = extractColor(m[1].trim(), palette);
55
- return { id: nodeId('document', label), label, shape: 'document', color };
48
+ const label = m[1].trim();
49
+ return { id: nodeId('document', label), label, shape: 'document' };
56
50
  }
57
51
 
58
52
  // Process: [Label]
59
53
  m = t.match(/^\[([^\]]+)\]$/);
60
54
  if (m) {
61
- const { label, color } = extractColor(m[1].trim(), palette);
62
- return { id: nodeId('process', label), label, shape: 'process', color };
55
+ const label = m[1].trim();
56
+ return { id: nodeId('process', label), label, shape: 'process' };
63
57
  }
64
58
 
65
59
  // Terminal: (Label) — use .+ (greedy) so (Label(color)) matches outermost parens
66
60
  m = t.match(/^\((.+)\)$/);
67
61
  if (m) {
68
- const { label, color } = extractColor(m[1].trim(), palette);
69
- return { id: nodeId('terminal', label), label, shape: 'terminal', color };
62
+ const label = m[1].trim();
63
+ return { id: nodeId('terminal', label), label, shape: 'terminal' };
70
64
  }
71
65
 
72
66
  // Decision: <Label>
73
67
  m = t.match(/^<([^>]+)>$/);
74
68
  if (m) {
75
- const { label, color } = extractColor(m[1].trim(), palette);
76
- return { id: nodeId('decision', label), label, shape: 'decision', color };
69
+ const label = m[1].trim();
70
+ return { id: nodeId('decision', label), label, shape: 'decision' };
77
71
  }
78
72
 
79
73
  // I/O: /Label/
80
74
  m = t.match(/^\/([^/]+)\/$/);
81
75
  if (m) {
82
- const { label, color } = extractColor(m[1].trim(), palette);
83
- return { id: nodeId('io', label), label, shape: 'io', color };
76
+ const label = m[1].trim();
77
+ return { id: nodeId('io', label), label, shape: 'io' };
84
78
  }
85
79
 
86
80
  return null;
@@ -370,7 +364,7 @@ export function parseFlowchart(
370
364
 
371
365
  if (segments.length === 1) {
372
366
  // Single node reference, no arrows
373
- const ref = parseNodeRef(segments[0], palette);
367
+ const ref = parseNodeRef(segments[0]);
374
368
  if (ref) {
375
369
  const node = getOrCreateNode(ref, lineNumber);
376
370
  indentStack.push({ nodeId: node.id, indent });
@@ -398,7 +392,7 @@ export function parseFlowchart(
398
392
  }
399
393
 
400
394
  // This is a node text segment
401
- const ref = parseNodeRef(seg, palette);
395
+ const ref = parseNodeRef(seg);
402
396
  if (!ref) continue;
403
397
 
404
398
  const node = getOrCreateNode(ref, lineNumber);
@@ -3,6 +3,7 @@ import type {
3
3
  ParsedGraph,
4
4
  GraphNode,
5
5
  GraphEdge,
6
+ GraphGroup,
6
7
  GraphShape,
7
8
  } from './types';
8
9
 
@@ -33,12 +34,20 @@ export interface LayoutGroup {
33
34
  label: string;
34
35
  color?: string;
35
36
  lineNumber: number;
37
+ collapsed?: boolean;
36
38
  x: number;
37
39
  y: number;
38
40
  width: number;
39
41
  height: number;
40
42
  }
41
43
 
44
+ export interface LayoutOptions {
45
+ /** Map of group ID → number of child nodes (for collapsed groups) */
46
+ collapsedChildCounts?: Map<string, number>;
47
+ /** Original groups before collapse (includes collapsed ones) */
48
+ originalGroups?: GraphGroup[];
49
+ }
50
+
42
51
  export interface LayoutResult {
43
52
  nodes: LayoutNode[];
44
53
  edges: LayoutEdge[];
@@ -61,8 +70,33 @@ function computeNodeHeight(shape: GraphShape): number {
61
70
  return shape === 'decision' ? 60 : 50;
62
71
  }
63
72
 
64
- export function layoutGraph(graph: ParsedGraph): LayoutResult {
65
- if (graph.nodes.length === 0) {
73
+ export function layoutGraph(
74
+ graph: ParsedGraph,
75
+ options?: LayoutOptions
76
+ ): LayoutResult {
77
+ const collapsedChildCounts = options?.collapsedChildCounts;
78
+ const originalGroups = options?.originalGroups;
79
+
80
+ // Collapsed groups become synthetic nodes in the graph
81
+ const collapsedGroupNodes: GraphNode[] = [];
82
+ if (collapsedChildCounts && originalGroups) {
83
+ for (const group of originalGroups) {
84
+ if (collapsedChildCounts.has(group.id)) {
85
+ const count = collapsedChildCounts.get(group.id)!;
86
+ collapsedGroupNodes.push({
87
+ id: group.id,
88
+ label: `${group.label} (${count} state${count !== 1 ? 's' : ''})`,
89
+ shape: 'state',
90
+ lineNumber: group.lineNumber,
91
+ ...(group.color && { color: group.color }),
92
+ });
93
+ }
94
+ }
95
+ }
96
+
97
+ const allNodes = [...graph.nodes, ...collapsedGroupNodes];
98
+
99
+ if (allNodes.length === 0) {
66
100
  return { nodes: [], edges: [], groups: [], width: 0, height: 0 };
67
101
  }
68
102
 
@@ -77,11 +111,11 @@ export function layoutGraph(graph: ParsedGraph): LayoutResult {
77
111
 
78
112
  // Build a lookup for original node data
79
113
  const nodeDataMap = new Map<string, GraphNode>();
80
- for (const node of graph.nodes) {
114
+ for (const node of allNodes) {
81
115
  nodeDataMap.set(node.id, node);
82
116
  }
83
117
 
84
- // Add group parent nodes
118
+ // Add group parent nodes (only non-collapsed groups)
85
119
  if (graph.groups) {
86
120
  for (const group of graph.groups) {
87
121
  g.setNode(group.id, {
@@ -92,12 +126,12 @@ export function layoutGraph(graph: ParsedGraph): LayoutResult {
92
126
  }
93
127
 
94
128
  // Add nodes with computed dimensions
95
- for (const node of graph.nodes) {
129
+ for (const node of allNodes) {
96
130
  const width = computeNodeWidth(node.label, node.shape);
97
131
  const height = computeNodeHeight(node.shape);
98
132
  g.setNode(node.id, { label: node.label, width, height });
99
133
 
100
- // Set parent for grouped nodes
134
+ // Set parent for grouped nodes (only for non-collapsed groups)
101
135
  if (node.group && graph.groups?.some((gr) => gr.id === node.group)) {
102
136
  g.setParent(node.id, node.group);
103
137
  }
@@ -117,7 +151,11 @@ export function layoutGraph(graph: ParsedGraph): LayoutResult {
117
151
  dagre.layout(g);
118
152
 
119
153
  // Extract positioned nodes
120
- const layoutNodes: LayoutNode[] = graph.nodes.map((node) => {
154
+ const collapsedGroupIds = collapsedChildCounts
155
+ ? new Set(collapsedChildCounts.keys())
156
+ : new Set<string>();
157
+
158
+ const layoutNodes: LayoutNode[] = allNodes.map((node) => {
121
159
  const pos = g.node(node.id);
122
160
  return {
123
161
  id: node.id,
@@ -147,10 +185,36 @@ export function layoutGraph(graph: ParsedGraph): LayoutResult {
147
185
  });
148
186
 
149
187
  // Compute group bounding boxes from member node positions
188
+ // Collapsed groups are included as layout groups with collapsed=true
189
+ // (their synthetic node is in layoutNodes for positioning)
150
190
  const layoutGroups: LayoutGroup[] = [];
151
- if (graph.groups) {
191
+ const allGroups = graph.groups ?? [];
192
+
193
+ // Also include collapsed groups from originalGroups
194
+ if (originalGroups) {
195
+ for (const group of originalGroups) {
196
+ if (collapsedGroupIds.has(group.id)) {
197
+ const syntheticNode = layoutNodes.find((n) => n.id === group.id);
198
+ if (syntheticNode) {
199
+ layoutGroups.push({
200
+ id: group.id,
201
+ label: group.label,
202
+ color: group.color,
203
+ lineNumber: group.lineNumber,
204
+ collapsed: true,
205
+ x: syntheticNode.x - syntheticNode.width / 2,
206
+ y: syntheticNode.y - syntheticNode.height / 2,
207
+ width: syntheticNode.width,
208
+ height: syntheticNode.height,
209
+ });
210
+ }
211
+ }
212
+ }
213
+ }
214
+
215
+ if (allGroups.length > 0) {
152
216
  const nodeMap = new Map(layoutNodes.map((n) => [n.id, n]));
153
- for (const group of graph.groups) {
217
+ for (const group of allGroups) {
154
218
  const members = group.nodeIds
155
219
  .map((id) => nodeMap.get(id))
156
220
  .filter((n): n is LayoutNode => n !== undefined);
@@ -0,0 +1,78 @@
1
+ // ============================================================
2
+ // State Diagram — Collapse/Expand Transform
3
+ // ============================================================
4
+
5
+ import type { ParsedGraph, GraphGroup } from './types';
6
+
7
+ export interface StateCollapseResult {
8
+ parsed: ParsedGraph;
9
+ collapsedChildCounts: Map<string, number>;
10
+ originalGroups: GraphGroup[];
11
+ }
12
+
13
+ /**
14
+ * Pure transform: returns a new ParsedGraph with collapsed groups
15
+ * removed from the diagram content.
16
+ *
17
+ * - Children of collapsed groups removed from nodes
18
+ * - Edges redirected: endpoints in collapsed groups → group ID
19
+ * - Internal edges (both in same collapsed group) dropped
20
+ * - Duplicate edges (same source, target, label) deduplicated
21
+ * - Collapsed groups removed from groups[] (layout handles as nodes)
22
+ */
23
+ export function collapseStateGroups(
24
+ parsed: ParsedGraph,
25
+ collapsedGroups: Set<string>
26
+ ): StateCollapseResult {
27
+ const originalGroups = parsed.groups ?? [];
28
+
29
+ if (collapsedGroups.size === 0 || originalGroups.length === 0) {
30
+ return { parsed, collapsedChildCounts: new Map(), originalGroups };
31
+ }
32
+
33
+ // Build group lookup by ID
34
+ const groupById = new Map<string, GraphGroup>();
35
+ for (const group of originalGroups) {
36
+ groupById.set(group.id, group);
37
+ }
38
+
39
+ // Build node → collapsed group lookup
40
+ const nodeToGroup = new Map<string, string>();
41
+ const collapsedChildCounts = new Map<string, number>();
42
+
43
+ for (const groupId of collapsedGroups) {
44
+ const group = groupById.get(groupId);
45
+ if (!group) continue;
46
+
47
+ for (const nodeId of group.nodeIds) {
48
+ nodeToGroup.set(nodeId, groupId);
49
+ }
50
+ collapsedChildCounts.set(groupId, group.nodeIds.length);
51
+ }
52
+
53
+ // Filter nodes: remove children of collapsed groups
54
+ const nodes = parsed.nodes.filter((n) => !nodeToGroup.has(n.id));
55
+
56
+ // Remap and deduplicate edges
57
+ const edgeKeys = new Set<string>();
58
+ const edges: typeof parsed.edges = [];
59
+ for (const edge of parsed.edges) {
60
+ const src = nodeToGroup.get(edge.source) ?? edge.source;
61
+ const tgt = nodeToGroup.get(edge.target) ?? edge.target;
62
+ // Drop internal edges (both endpoints in same collapsed group)
63
+ if (src === tgt && src !== edge.source) continue;
64
+ const key = `${src}|${tgt}|${edge.label ?? ''}`;
65
+ if (edgeKeys.has(key)) continue;
66
+ edgeKeys.add(key);
67
+ edges.push({ ...edge, source: src, target: tgt });
68
+ }
69
+
70
+ // Keep only groups that are not collapsed
71
+ const groups = originalGroups.filter((g) => !collapsedGroups.has(g.id));
72
+
73
+ return {
74
+ parsed: { ...parsed, nodes, edges, groups },
75
+ collapsedChildCounts,
76
+ originalGroups,
77
+ };
78
+ }
@@ -5,7 +5,6 @@ import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
5
5
  import { parseInArrowLabel, matchColorParens } from '../utils/arrows';
6
6
  import {
7
7
  measureIndent,
8
- extractColor,
9
8
  parseFirstLine,
10
9
  OPTION_NOCOLON_RE,
11
10
  ALL_CHART_TYPES,
@@ -173,10 +172,7 @@ interface NodeRef {
173
172
  color?: string;
174
173
  }
175
174
 
176
- function parseStateNodeRef(
177
- text: string,
178
- palette?: PaletteColors
179
- ): NodeRef | null {
175
+ function parseStateNodeRef(text: string): NodeRef | null {
180
176
  const t = text.trim();
181
177
  if (!t) return null;
182
178
 
@@ -189,14 +185,13 @@ function parseStateNodeRef(
189
185
  };
190
186
  }
191
187
 
192
- // State: bare text with optional (color) suffix
193
- const { label, color } = extractColor(t, palette);
188
+ // State: bare text
189
+ const label = t;
194
190
  if (!label) return null;
195
191
  return {
196
192
  id: `state:${label.toLowerCase().trim()}`,
197
193
  label,
198
194
  shape: 'state',
199
- color,
200
195
  };
201
196
  }
202
197
 
@@ -380,7 +375,7 @@ export function parseState(
380
375
 
381
376
  if (segments.length === 1) {
382
377
  // Single state reference, no arrows — this is the canonical definition
383
- const ref = parseStateNodeRef(segments[0], palette);
378
+ const ref = parseStateNodeRef(segments[0]);
384
379
  if (ref) {
385
380
  const node = getOrCreateNode(ref, lineNumber);
386
381
  // Standalone heading is the "definition" — update lineNumber so
@@ -409,7 +404,7 @@ export function parseState(
409
404
  continue;
410
405
  }
411
406
 
412
- const ref = parseStateNodeRef(seg, palette);
407
+ const ref = parseStateNodeRef(seg);
413
408
  if (!ref) continue;
414
409
 
415
410
  const node = getOrCreateNode(ref, lineNumber);
@@ -11,7 +11,11 @@ import type { ParsedGraph } from './types';
11
11
  import type { LayoutResult, LayoutNode } from './layout';
12
12
  import { parseState } from './state-parser';
13
13
  import { layoutGraph } from './layout';
14
- import { TITLE_FONT_SIZE, TITLE_FONT_WEIGHT, TITLE_Y } from '../utils/title-constants';
14
+ import {
15
+ TITLE_FONT_SIZE,
16
+ TITLE_FONT_WEIGHT,
17
+ TITLE_Y,
18
+ } from '../utils/title-constants';
15
19
 
16
20
  // ============================================================
17
21
  // Constants
@@ -38,12 +42,21 @@ function stateDefaultColor(palette: PaletteColors, colorOff?: boolean): string {
38
42
  return colorOff ? palette.textMuted : palette.colors.blue;
39
43
  }
40
44
 
41
- function stateFill(palette: PaletteColors, isDark: boolean, nodeColor?: string, colorOff?: boolean): string {
45
+ function stateFill(
46
+ palette: PaletteColors,
47
+ isDark: boolean,
48
+ nodeColor?: string,
49
+ colorOff?: boolean
50
+ ): string {
42
51
  const color = nodeColor ?? stateDefaultColor(palette, colorOff);
43
52
  return mix(color, isDark ? palette.surface : palette.bg, 25);
44
53
  }
45
54
 
46
- function stateStroke(palette: PaletteColors, nodeColor?: string, colorOff?: boolean): string {
55
+ function stateStroke(
56
+ palette: PaletteColors,
57
+ nodeColor?: string,
58
+ colorOff?: boolean
59
+ ): string {
47
60
  return nodeColor ?? stateDefaultColor(palette, colorOff);
48
61
  }
49
62
 
@@ -51,7 +64,8 @@ function stateStroke(palette: PaletteColors, nodeColor?: string, colorOff?: bool
51
64
  // Edge path generator
52
65
  // ============================================================
53
66
 
54
- const lineGenerator = d3Shape.line<{ x: number; y: number }>()
67
+ const lineGenerator = d3Shape
68
+ .line<{ x: number; y: number }>()
55
69
  .x((d) => d.x)
56
70
  .y((d) => d.y)
57
71
  .curve(d3Shape.curveBasis);
@@ -160,7 +174,10 @@ export function renderState(
160
174
  .attr('fill', palette.text)
161
175
  .attr('font-size', TITLE_FONT_SIZE)
162
176
  .attr('font-weight', TITLE_FONT_WEIGHT)
163
- .style('cursor', onClickItem && graph.titleLineNumber ? 'pointer' : 'default')
177
+ .style(
178
+ 'cursor',
179
+ onClickItem && graph.titleLineNumber ? 'pointer' : 'default'
180
+ )
164
181
  .text(graph.title);
165
182
 
166
183
  if (graph.titleLineNumber) {
@@ -168,8 +185,12 @@ export function renderState(
168
185
  if (onClickItem) {
169
186
  titleEl
170
187
  .on('click', () => onClickItem(graph.titleLineNumber!))
171
- .on('mouseenter', function () { d3Selection.select(this).attr('opacity', 0.7); })
172
- .on('mouseleave', function () { d3Selection.select(this).attr('opacity', 1); });
188
+ .on('mouseenter', function () {
189
+ d3Selection.select(this).attr('opacity', 0.7);
190
+ })
191
+ .on('mouseleave', function () {
192
+ d3Selection.select(this).attr('opacity', 1);
193
+ });
173
194
  }
174
195
  }
175
196
  }
@@ -180,12 +201,15 @@ export function renderState(
180
201
  .attr('transform', `translate(${offsetX}, ${offsetY}) scale(${scale})`);
181
202
 
182
203
  // Render groups (background layer)
204
+ // Collapsed groups are rendered in the node layer instead — skip them here
183
205
  for (const group of layout.groups) {
206
+ if (group.collapsed) continue;
184
207
  if (group.width === 0 && group.height === 0) continue;
185
208
  const gx = group.x - GROUP_EXTRA_PADDING;
186
209
  const gy = group.y - GROUP_EXTRA_PADDING - GROUP_LABEL_FONT_SIZE - 4;
187
210
  const gw = group.width + GROUP_EXTRA_PADDING * 2;
188
- const gh = group.height + GROUP_EXTRA_PADDING * 2 + GROUP_LABEL_FONT_SIZE + 4;
211
+ const gh =
212
+ group.height + GROUP_EXTRA_PADDING * 2 + GROUP_LABEL_FONT_SIZE + 4;
189
213
 
190
214
  const fillColor = group.color
191
215
  ? mix(group.color, isDark ? palette.surface : palette.bg, 10)
@@ -198,13 +222,13 @@ export function renderState(
198
222
  .append('g')
199
223
  .attr('class', 'st-group-wrapper')
200
224
  .attr('data-line-number', String(group.lineNumber))
201
- .attr('data-group-id', group.id);
202
-
203
- if (onClickItem) {
204
- groupWrapper.style('cursor', 'pointer').on('click', () => {
205
- onClickItem(group.lineNumber);
206
- });
207
- }
225
+ .attr('data-group-id', group.id)
226
+ .attr('data-group-toggle', group.id)
227
+ .attr('tabindex', '0')
228
+ .attr('role', 'button')
229
+ .attr('aria-expanded', 'true')
230
+ .attr('aria-label', `Collapse group ${group.label}`)
231
+ .style('cursor', 'pointer');
208
232
 
209
233
  groupWrapper
210
234
  .append('rect')
@@ -250,7 +274,13 @@ export function renderState(
250
274
  const LABEL_H = 16;
251
275
  const PERP_OFFSET = 10; // px offset perpendicular to edge direction
252
276
 
253
- interface LabelPos { x: number; y: number; w: number; h: number; edgeIdx: number }
277
+ interface LabelPos {
278
+ x: number;
279
+ y: number;
280
+ w: number;
281
+ h: number;
282
+ edgeIdx: number;
283
+ }
254
284
  const labelPositions: LabelPos[] = [];
255
285
 
256
286
  for (let ei = 0; ei < layout.edges.length; ei++) {
@@ -333,7 +363,8 @@ export function renderState(
333
363
 
334
364
  const lp = labelPosMap.get(ei);
335
365
  if (edge.label && lp) {
336
- edgeG.append('rect')
366
+ edgeG
367
+ .append('rect')
337
368
  .attr('x', lp.x - lp.w / 2)
338
369
  .attr('y', lp.y - lp.h / 2 - 1)
339
370
  .attr('width', lp.w)
@@ -342,7 +373,8 @@ export function renderState(
342
373
  .attr('fill', palette.bg)
343
374
  .attr('opacity', 0.85)
344
375
  .attr('class', 'st-edge-label-bg');
345
- edgeG.append('text')
376
+ edgeG
377
+ .append('text')
346
378
  .attr('x', lp.x)
347
379
  .attr('y', lp.y + 4)
348
380
  .attr('text-anchor', 'middle')
@@ -367,7 +399,8 @@ export function renderState(
367
399
 
368
400
  const lp = labelPosMap.get(ei);
369
401
  if (edge.label && lp) {
370
- edgeG.append('rect')
402
+ edgeG
403
+ .append('rect')
371
404
  .attr('x', lp.x - lp.w / 2)
372
405
  .attr('y', lp.y - lp.h / 2 - 1)
373
406
  .attr('width', lp.w)
@@ -376,7 +409,8 @@ export function renderState(
376
409
  .attr('fill', palette.bg)
377
410
  .attr('opacity', 0.85)
378
411
  .attr('class', 'st-edge-label-bg');
379
- edgeG.append('text')
412
+ edgeG
413
+ .append('text')
380
414
  .attr('x', lp.x)
381
415
  .attr('y', lp.y + 4)
382
416
  .attr('text-anchor', 'middle')
@@ -388,18 +422,36 @@ export function renderState(
388
422
  }
389
423
  }
390
424
 
425
+ // Build set of collapsed group IDs for special rendering
426
+ const collapsedGroupIds = new Set<string>();
427
+ for (const group of layout.groups) {
428
+ if (group.collapsed) collapsedGroupIds.add(group.id);
429
+ }
430
+
391
431
  // Render nodes (top layer)
392
432
  const colorOff = graph.options?.color === 'off';
393
433
  for (const node of layout.nodes) {
434
+ const isCollapsedGroup = collapsedGroupIds.has(node.id);
435
+
394
436
  const nodeG = contentG
395
437
  .append('g')
396
438
  .attr('transform', `translate(${node.x}, ${node.y})`)
397
- .attr('class', 'st-node')
439
+ .attr('class', isCollapsedGroup ? 'st-group-wrapper st-node' : 'st-node')
398
440
  .attr('data-line-number', String(node.lineNumber))
399
- .attr('data-node-id', node.id);
441
+ .attr('data-node-id', node.id)
442
+ .style('cursor', 'pointer');
400
443
 
401
- if (onClickItem) {
402
- nodeG.style('cursor', 'pointer').on('click', () => {
444
+ if (isCollapsedGroup) {
445
+ nodeG
446
+ .attr('data-group-toggle', node.id)
447
+ .attr('tabindex', '0')
448
+ .attr('role', 'button')
449
+ .attr('aria-expanded', 'false')
450
+ .attr('aria-label', `Expand group ${node.label}`);
451
+ }
452
+
453
+ if (onClickItem && !isCollapsedGroup) {
454
+ nodeG.on('click', () => {
403
455
  onClickItem(node.lineNumber);
404
456
  });
405
457
  }
@@ -413,6 +465,63 @@ export function renderState(
413
465
  .attr('r', PSEUDOSTATE_RADIUS)
414
466
  .attr('fill', palette.text)
415
467
  .attr('stroke', 'none');
468
+ } else if (isCollapsedGroup) {
469
+ // Collapsed group — distinctive rounded rect with collapse bar
470
+ const w = node.width;
471
+ const h = node.height;
472
+ const groupColor = node.color ?? stateDefaultColor(palette, colorOff);
473
+ const fillColor = mix(
474
+ groupColor,
475
+ isDark ? palette.surface : palette.bg,
476
+ 15
477
+ );
478
+ const strokeColor = groupColor;
479
+ const COLLAPSE_BAR_H = 6;
480
+
481
+ // Main rect
482
+ nodeG
483
+ .append('rect')
484
+ .attr('x', -w / 2)
485
+ .attr('y', -h / 2)
486
+ .attr('width', w)
487
+ .attr('height', h)
488
+ .attr('rx', STATE_CORNER_RADIUS)
489
+ .attr('ry', STATE_CORNER_RADIUS)
490
+ .attr('fill', fillColor)
491
+ .attr('stroke', strokeColor)
492
+ .attr('stroke-width', NODE_STROKE_WIDTH);
493
+
494
+ // Collapse indicator bar at bottom (clipped to rounded corners)
495
+ const clipId = `st-clip-${node.id.replace(/[[\]:\s]/g, '')}`;
496
+ nodeG
497
+ .append('clipPath')
498
+ .attr('id', clipId)
499
+ .append('rect')
500
+ .attr('x', -w / 2)
501
+ .attr('y', -h / 2)
502
+ .attr('width', w)
503
+ .attr('height', h)
504
+ .attr('rx', STATE_CORNER_RADIUS);
505
+ nodeG
506
+ .append('rect')
507
+ .attr('x', -w / 2)
508
+ .attr('y', h / 2 - COLLAPSE_BAR_H)
509
+ .attr('width', w)
510
+ .attr('height', COLLAPSE_BAR_H)
511
+ .attr('fill', strokeColor)
512
+ .attr('opacity', 0.5)
513
+ .attr('clip-path', `url(#${clipId})`);
514
+
515
+ // Label
516
+ nodeG
517
+ .append('text')
518
+ .attr('x', 0)
519
+ .attr('y', 0)
520
+ .attr('text-anchor', 'middle')
521
+ .attr('dominant-baseline', 'central')
522
+ .attr('fill', palette.text)
523
+ .attr('font-size', NODE_FONT_SIZE)
524
+ .text(node.label);
416
525
  } else {
417
526
  // State — rounded rectangle
418
527
  const w = node.width;
@@ -460,7 +569,8 @@ export function renderStateForExport(
460
569
 
461
570
  const container = document.createElement('div');
462
571
  const exportWidth = layout.width + DIAGRAM_PADDING * 2;
463
- const exportHeight = layout.height + DIAGRAM_PADDING * 2 + (parsed.title ? 40 : 0);
572
+ const exportHeight =
573
+ layout.height + DIAGRAM_PADDING * 2 + (parsed.title ? 40 : 0);
464
574
  container.style.width = `${exportWidth}px`;
465
575
  container.style.height = `${exportHeight}px`;
466
576
  container.style.position = 'absolute';
@@ -468,15 +578,10 @@ export function renderStateForExport(
468
578
  document.body.appendChild(container);
469
579
 
470
580
  try {
471
- renderState(
472
- container,
473
- parsed,
474
- layout,
475
- palette,
476
- isDark,
477
- undefined,
478
- { width: exportWidth, height: exportHeight }
479
- );
581
+ renderState(container, parsed, layout, palette, isDark, undefined, {
582
+ width: exportWidth,
583
+ height: exportHeight,
584
+ });
480
585
 
481
586
  const svgEl = container.querySelector('svg');
482
587
  if (!svgEl) return '';