@diagrammo/dgmo 0.8.19 → 0.8.21
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 +92 -131
- package/dist/editor.cjs +13 -1
- package/dist/editor.cjs.map +1 -1
- package/dist/editor.js +13 -1
- package/dist/editor.js.map +1 -1
- package/dist/highlight.cjs +13 -1
- package/dist/highlight.cjs.map +1 -1
- package/dist/highlight.js +13 -1
- package/dist/highlight.js.map +1 -1
- package/dist/index.cjs +4524 -1511
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +427 -186
- package/dist/index.d.ts +427 -186
- package/dist/index.js +4526 -1503
- package/dist/index.js.map +1 -1
- package/docs/guide/chart-mindmap.md +198 -0
- package/docs/guide/chart-sequence.md +23 -1
- package/docs/guide/chart-wireframe.md +100 -0
- package/docs/guide/index.md +8 -0
- package/docs/language-reference.md +210 -2
- package/package.json +22 -9
- package/src/boxes-and-lines/collapse.ts +21 -3
- package/src/boxes-and-lines/layout.ts +51 -9
- package/src/boxes-and-lines/parser.ts +16 -4
- package/src/boxes-and-lines/renderer.ts +121 -23
- package/src/boxes-and-lines/types.ts +1 -0
- package/src/c4/parser.ts +8 -7
- package/src/class/parser.ts +6 -0
- package/src/cli.ts +1 -9
- package/src/completion.ts +26 -0
- package/src/d3.ts +169 -266
- package/src/dgmo-router.ts +103 -5
- package/src/diagnostics.ts +16 -6
- package/src/echarts.ts +43 -10
- package/src/editor/keywords.ts +12 -0
- package/src/er/parser.ts +22 -2
- package/src/gantt/renderer.ts +2 -2
- package/src/graph/flowchart-parser.ts +89 -52
- package/src/graph/layout.ts +73 -9
- package/src/graph/state-collapse.ts +78 -0
- package/src/graph/state-parser.ts +60 -35
- package/src/graph/state-renderer.ts +139 -34
- package/src/index.ts +41 -16
- package/src/infra/parser.ts +9 -2
- package/src/kanban/renderer.ts +305 -59
- package/src/mindmap/collapse.ts +88 -0
- package/src/mindmap/layout.ts +605 -0
- package/src/mindmap/parser.ts +379 -0
- package/src/mindmap/renderer.ts +543 -0
- package/src/mindmap/text-wrap.ts +207 -0
- package/src/mindmap/types.ts +55 -0
- package/src/palettes/color-utils.ts +4 -12
- package/src/palettes/index.ts +0 -4
- package/src/render.ts +31 -20
- package/src/sequence/parser.ts +7 -2
- package/src/sequence/renderer.ts +141 -21
- package/src/sharing.ts +2 -0
- package/src/sitemap/layout.ts +35 -12
- package/src/sitemap/renderer.ts +1 -6
- package/src/utils/arrows.ts +180 -11
- package/src/utils/d3-types.ts +4 -0
- package/src/utils/export-container.ts +3 -2
- package/src/utils/legend-constants.ts +0 -4
- package/src/utils/legend-d3.ts +1 -0
- package/src/utils/legend-layout.ts +2 -2
- package/src/utils/parsing.ts +2 -0
- package/src/utils/time-ticks.ts +213 -0
- package/src/wireframe/layout.ts +460 -0
- package/src/wireframe/parser.ts +956 -0
- package/src/wireframe/renderer.ts +1293 -0
- package/src/wireframe/types.ts +110 -0
- package/src/branding.ts +0 -67
- package/src/dgmo-mermaid.ts +0 -262
- package/src/palettes/mermaid-bridge.ts +0 -220
package/src/graph/layout.ts
CHANGED
|
@@ -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(
|
|
65
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
|
@@ -2,6 +2,7 @@ import { resolveColorWithDiagnostic } from '../colors';
|
|
|
2
2
|
import type { DgmoError } from '../diagnostics';
|
|
3
3
|
import type { PaletteColors } from '../palettes';
|
|
4
4
|
import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
|
|
5
|
+
import { parseInArrowLabel, matchColorParens } from '../utils/arrows';
|
|
5
6
|
import {
|
|
6
7
|
measureIndent,
|
|
7
8
|
extractColor,
|
|
@@ -31,6 +32,8 @@ const GROUP_BRACKET_RE = /^\[([^\]]+)\](?:\(([^)]+)\))?\s*$/;
|
|
|
31
32
|
* Arrows: `->`, `-label->`, `-(color)->`, `-label(color)->`
|
|
32
33
|
*/
|
|
33
34
|
function splitArrows(line: string): string[] {
|
|
35
|
+
// Mirrors flowchart-parser.ts splitArrows. TD-9 longest-match: arrow token
|
|
36
|
+
// is the maximal run of `-+>`. See that file for the full algorithm rationale.
|
|
34
37
|
const segments: string[] = [];
|
|
35
38
|
const arrowPositions: {
|
|
36
39
|
start: number;
|
|
@@ -40,41 +43,52 @@ function splitArrows(line: string): string[] {
|
|
|
40
43
|
}[] = [];
|
|
41
44
|
|
|
42
45
|
let searchFrom = 0;
|
|
46
|
+
let scanFloor = 0;
|
|
43
47
|
while (searchFrom < line.length) {
|
|
44
48
|
const idx = line.indexOf('->', searchFrom);
|
|
45
49
|
if (idx === -1) break;
|
|
46
50
|
|
|
47
|
-
let
|
|
51
|
+
let runStart = idx;
|
|
52
|
+
while (runStart > scanFloor && line[runStart - 1] === '-') runStart--;
|
|
53
|
+
const arrowEnd = idx + 2;
|
|
54
|
+
|
|
55
|
+
let arrowStart: number;
|
|
48
56
|
let label: string | undefined;
|
|
49
57
|
let color: string | undefined;
|
|
50
58
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
59
|
+
let openingStart = -1;
|
|
60
|
+
for (let i = scanFloor; i < runStart; i++) {
|
|
61
|
+
if (line[i] !== '-') continue;
|
|
62
|
+
const prevIsWsOrFloor =
|
|
63
|
+
i === 0 || i === scanFloor || /\s/.test(line[i - 1]);
|
|
64
|
+
if (prevIsWsOrFloor) {
|
|
65
|
+
openingStart = i;
|
|
66
|
+
break;
|
|
55
67
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}
|
|
72
|
-
arrowStart = scanBack;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (openingStart !== -1) {
|
|
71
|
+
let openingEnd = openingStart;
|
|
72
|
+
while (openingEnd < runStart && line[openingEnd] === '-') openingEnd++;
|
|
73
|
+
|
|
74
|
+
const arrowContent = line.substring(openingEnd, runStart);
|
|
75
|
+
const colorMatch = arrowContent.match(/\(([^)]+)\)\s*$/);
|
|
76
|
+
if (colorMatch) {
|
|
77
|
+
color = colorMatch[1].trim();
|
|
78
|
+
const labelPart = arrowContent.substring(0, colorMatch.index!).trim();
|
|
79
|
+
if (labelPart) label = labelPart;
|
|
80
|
+
} else {
|
|
81
|
+
const labelPart = arrowContent.trim();
|
|
82
|
+
if (labelPart) label = labelPart;
|
|
73
83
|
}
|
|
84
|
+
arrowStart = openingStart;
|
|
85
|
+
} else {
|
|
86
|
+
arrowStart = runStart;
|
|
74
87
|
}
|
|
75
88
|
|
|
76
|
-
arrowPositions.push({ start: arrowStart, end:
|
|
77
|
-
searchFrom =
|
|
89
|
+
arrowPositions.push({ start: arrowStart, end: arrowEnd, label, color });
|
|
90
|
+
searchFrom = arrowEnd;
|
|
91
|
+
scanFloor = arrowEnd;
|
|
78
92
|
}
|
|
79
93
|
|
|
80
94
|
if (arrowPositions.length === 0) return [line];
|
|
@@ -111,19 +125,30 @@ function parseArrowToken(
|
|
|
111
125
|
diagnostics: DgmoError[]
|
|
112
126
|
): ArrowInfo {
|
|
113
127
|
if (token === '->') return {};
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
128
|
+
// TD-11: `-(X)->` is a color if and only if X is a recognized palette
|
|
129
|
+
// color; otherwise the whole `(X)` becomes the label. Delegate recognition
|
|
130
|
+
// to the shared `matchColorParens` helper.
|
|
131
|
+
const bareParen = token.match(/^-(\([A-Za-z]+\))->$/);
|
|
132
|
+
if (bareParen) {
|
|
133
|
+
const colorName = matchColorParens(bareParen[1]);
|
|
134
|
+
if (colorName) {
|
|
135
|
+
return {
|
|
136
|
+
color: resolveColorWithDiagnostic(
|
|
137
|
+
colorName,
|
|
138
|
+
lineNumber,
|
|
139
|
+
diagnostics,
|
|
140
|
+
palette
|
|
141
|
+
),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
// fall through — whole `(X)` becomes label
|
|
145
|
+
}
|
|
124
146
|
const m = token.match(/^-(.+?)(?:\(([^)]+)\))?->$/);
|
|
125
147
|
if (m) {
|
|
126
|
-
const
|
|
148
|
+
const rawLabel = m[1] ?? '';
|
|
149
|
+
const labelResult = parseInArrowLabel(rawLabel, lineNumber);
|
|
150
|
+
diagnostics.push(...labelResult.diagnostics);
|
|
151
|
+
const label = labelResult.label;
|
|
127
152
|
const color = m[2]
|
|
128
153
|
? resolveColorWithDiagnostic(
|
|
129
154
|
m[2].trim(),
|
|
@@ -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 {
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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 () {
|
|
172
|
-
|
|
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 =
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
402
|
-
nodeG
|
|
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 =
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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 '';
|