@diagrammo/dgmo 0.8.4 → 0.8.6
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/.claude/commands/dgmo.md +300 -0
- package/.cursorrules +20 -2
- package/.github/copilot-instructions.md +20 -2
- package/.windsurfrules +20 -2
- package/AGENTS.md +23 -3
- package/dist/cli.cjs +191 -189
- package/dist/editor.cjs +5 -18
- package/dist/editor.cjs.map +1 -1
- package/dist/editor.js +5 -18
- package/dist/editor.js.map +1 -1
- package/dist/highlight.cjs +543 -0
- package/dist/highlight.cjs.map +1 -0
- package/dist/highlight.d.cts +32 -0
- package/dist/highlight.d.ts +32 -0
- package/dist/highlight.js +513 -0
- package/dist/highlight.js.map +1 -0
- package/dist/index.cjs +3253 -3356
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +77 -56
- package/dist/index.d.ts +77 -56
- package/dist/index.js +3247 -3349
- package/dist/index.js.map +1 -1
- package/docs/ai-integration.md +1 -1
- package/docs/language-reference.md +113 -33
- package/gallery/fixtures/boxes-and-lines.dgmo +64 -0
- package/gallery/fixtures/slope.dgmo +7 -6
- package/package.json +26 -6
- package/src/boxes-and-lines/collapse.ts +78 -0
- package/src/boxes-and-lines/layout.ts +319 -0
- package/src/boxes-and-lines/parser.ts +694 -0
- package/src/boxes-and-lines/renderer.ts +848 -0
- package/src/boxes-and-lines/types.ts +40 -0
- package/src/c4/parser.ts +10 -5
- package/src/c4/renderer.ts +232 -56
- package/src/chart.ts +9 -4
- package/src/cli.ts +49 -6
- package/src/completion.ts +25 -33
- package/src/d3.ts +187 -46
- package/src/dgmo-router.ts +3 -7
- package/src/echarts.ts +38 -2
- package/src/editor/highlight-api.ts +444 -0
- package/src/editor/keywords.ts +6 -19
- package/src/er/parser.ts +10 -4
- package/src/gantt/parser.ts +7 -4
- package/src/gantt/renderer.ts +3 -5
- package/src/index.ts +106 -50
- package/src/infra/parser.ts +7 -5
- package/src/infra/renderer.ts +2 -2
- package/src/kanban/parser.ts +7 -5
- package/src/kanban/renderer.ts +43 -18
- package/src/org/parser.ts +7 -4
- package/src/org/renderer.ts +40 -29
- package/src/sequence/parser.ts +11 -5
- package/src/sequence/renderer.ts +114 -45
- package/src/sitemap/parser.ts +8 -4
- package/src/sitemap/renderer.ts +137 -57
- package/src/utils/legend-svg.ts +44 -20
- package/src/utils/parsing.ts +1 -1
- package/src/utils/tag-groups.ts +21 -1
- package/gallery/fixtures/initiative-status-full.dgmo +0 -46
- package/gallery/fixtures/initiative-status-phases.dgmo +0 -29
- package/gallery/fixtures/initiative-status.dgmo +0 -9
- package/src/initiative-status/collapse.ts +0 -76
- package/src/initiative-status/filter.ts +0 -63
- package/src/initiative-status/layout.ts +0 -650
- package/src/initiative-status/parser.ts +0 -629
- package/src/initiative-status/renderer.ts +0 -1199
- package/src/initiative-status/types.ts +0 -57
|
@@ -1,650 +0,0 @@
|
|
|
1
|
-
// ============================================================
|
|
2
|
-
// Initiative Status Diagram — Layout
|
|
3
|
-
//
|
|
4
|
-
// Uses dagre for rank assignment and crossing minimization.
|
|
5
|
-
// Post-dagre grid quantization snaps Y positions to a fixed
|
|
6
|
-
// grid for horizontal alignment across columns.
|
|
7
|
-
// ============================================================
|
|
8
|
-
|
|
9
|
-
import dagre from '@dagrejs/dagre';
|
|
10
|
-
import type { ParsedInitiativeStatus, InitiativeStatus } from './types';
|
|
11
|
-
import type { CollapseResult } from './collapse';
|
|
12
|
-
|
|
13
|
-
export interface ISLayoutNode {
|
|
14
|
-
label: string;
|
|
15
|
-
status: import('./types').InitiativeStatus;
|
|
16
|
-
shape: import('../sequence/parser').ParticipantType;
|
|
17
|
-
lineNumber: number;
|
|
18
|
-
x: number;
|
|
19
|
-
y: number;
|
|
20
|
-
width: number;
|
|
21
|
-
height: number;
|
|
22
|
-
metadata: Record<string, string>;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export interface ISLayoutEdge {
|
|
26
|
-
source: string;
|
|
27
|
-
target: string;
|
|
28
|
-
label?: string;
|
|
29
|
-
status: import('./types').InitiativeStatus;
|
|
30
|
-
lineNumber: number;
|
|
31
|
-
// Layout contract for points[]:
|
|
32
|
-
// Back-edges: 5 points — [src.top/bottom_center, depart_ctrl, arc_control, approach_ctrl, tgt.top/bottom_center]
|
|
33
|
-
// Top/bottom-exit: 4 points — [src.top/bottom_center, depart_ctrl, tgt_approach, tgt.left_center]
|
|
34
|
-
// 4-point elbow: points[0] and points[last] pinned at node center Y; interior fans via yOffset
|
|
35
|
-
// fixedDagrePoints: points[0]=src.right, points[last]=tgt.left; interior from dagre
|
|
36
|
-
points: { x: number; y: number }[];
|
|
37
|
-
parallelCount: number; // 1 for unique edges, >1 for parallel groups — used by renderer to narrow hit area
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export interface ISLayoutGroup {
|
|
41
|
-
label: string;
|
|
42
|
-
status: InitiativeStatus;
|
|
43
|
-
x: number;
|
|
44
|
-
y: number;
|
|
45
|
-
width: number;
|
|
46
|
-
height: number;
|
|
47
|
-
lineNumber: number;
|
|
48
|
-
collapsed: boolean;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export interface ISLayoutResult {
|
|
52
|
-
nodes: ISLayoutNode[];
|
|
53
|
-
edges: ISLayoutEdge[];
|
|
54
|
-
groups: ISLayoutGroup[];
|
|
55
|
-
width: number;
|
|
56
|
-
height: number;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const STATUS_PRIORITY: Record<string, number> = { todo: 4, blocked: 3, doing: 2, done: 1, na: 0 };
|
|
60
|
-
|
|
61
|
-
export function rollUpStatus(members: { status: InitiativeStatus }[]): InitiativeStatus {
|
|
62
|
-
let worst: InitiativeStatus = null;
|
|
63
|
-
let worstPri = -1;
|
|
64
|
-
for (const m of members) {
|
|
65
|
-
const pri = m.status ? (STATUS_PRIORITY[m.status] ?? -1) : -1;
|
|
66
|
-
if (pri > worstPri) {
|
|
67
|
-
worstPri = pri;
|
|
68
|
-
worst = m.status;
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
return worst;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
const PHI = 1.618;
|
|
75
|
-
const NODE_HEIGHT = 60;
|
|
76
|
-
const NODE_WIDTH = Math.round(NODE_HEIGHT * PHI);
|
|
77
|
-
const GROUP_PADDING = 20;
|
|
78
|
-
const GROUP_LABEL_HEIGHT = 20; // approximate height of the group label text rendered above the box
|
|
79
|
-
const NODESEP = 100;
|
|
80
|
-
const RANKSEP = 160;
|
|
81
|
-
const PARALLEL_SPACING = 16; // px between parallel edges sharing same source→target (~27% of NODE_HEIGHT)
|
|
82
|
-
const PARALLEL_EDGE_MARGIN = 12; // total vertical margin reserved at top+bottom of node for edge bundles (6px each side)
|
|
83
|
-
const MAX_PARALLEL_EDGES = 5; // at most this many edges rendered between any directed source→target pair
|
|
84
|
-
const BACK_EDGE_MARGIN = 40; // clearance below/above nodes for back-edge arcs (~half NODESEP)
|
|
85
|
-
const BACK_EDGE_MIN_SPREAD = Math.round(NODE_WIDTH * 0.75); // minimum horizontal arc spread for near-same-X back-edges
|
|
86
|
-
const TOP_EXIT_STEP = 10; // px: control-point offset giving near-vertical departure tangent for top/bottom-exit elbows
|
|
87
|
-
const CHAR_WIDTH_RATIO = 0.6;
|
|
88
|
-
const NODE_FONT_SIZE = 13;
|
|
89
|
-
const NODE_TEXT_PADDING = 12;
|
|
90
|
-
const GRID_ROW_HEIGHT = NODESEP; // 80px — one node (60px) + gap (20px)
|
|
91
|
-
const COLUMN_X_TOLERANCE = 5; // px — dagre may offset same-rank nodes slightly
|
|
92
|
-
|
|
93
|
-
// ============================================================
|
|
94
|
-
// Grid quantization — replaces dagre's freeform Y with a fixed
|
|
95
|
-
// grid while preserving dagre's rank assignment and crossing-
|
|
96
|
-
// minimized within-column ordering.
|
|
97
|
-
// ============================================================
|
|
98
|
-
|
|
99
|
-
interface GridNode {
|
|
100
|
-
label: string;
|
|
101
|
-
x: number;
|
|
102
|
-
y: number;
|
|
103
|
-
width: number;
|
|
104
|
-
height: number;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/** Find nearest available grid row, searching outward. Tie-breaks downward. */
|
|
108
|
-
function findNearestAvailable(preferred: number, taken: Set<number>): number {
|
|
109
|
-
if (!taken.has(preferred)) return preferred;
|
|
110
|
-
for (let delta = 1; ; delta++) {
|
|
111
|
-
if (!taken.has(preferred + delta)) return preferred + delta;
|
|
112
|
-
if (!taken.has(preferred - delta)) return preferred - delta;
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
export function gridQuantize(
|
|
117
|
-
nodes: GridNode[],
|
|
118
|
-
edges: { source: string; target: string }[]
|
|
119
|
-
): void {
|
|
120
|
-
if (nodes.length === 0) return;
|
|
121
|
-
|
|
122
|
-
// 1. Cluster columns by X with tolerance
|
|
123
|
-
const columns: GridNode[][] = [];
|
|
124
|
-
const sorted = [...nodes].sort((a, b) => a.x - b.x);
|
|
125
|
-
for (const node of sorted) {
|
|
126
|
-
const lastCol = columns[columns.length - 1];
|
|
127
|
-
if (lastCol && Math.abs(node.x - lastCol[0].x) <= COLUMN_X_TOLERANCE) {
|
|
128
|
-
lastCol.push(node);
|
|
129
|
-
} else {
|
|
130
|
-
columns.push([node]);
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// Normalize X within each column to the mean, sort nodes by dagre Y
|
|
135
|
-
for (const col of columns) {
|
|
136
|
-
const meanX = col.reduce((s, n) => s + n.x, 0) / col.length;
|
|
137
|
-
for (const n of col) n.x = meanX;
|
|
138
|
-
col.sort((a, b) => a.y - b.y);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// 2. Build upstream map: target → source labels
|
|
142
|
-
const upstreamMap = new Map<string, string[]>();
|
|
143
|
-
for (const edge of edges) {
|
|
144
|
-
const list = upstreamMap.get(edge.target);
|
|
145
|
-
if (list) list.push(edge.source);
|
|
146
|
-
else upstreamMap.set(edge.target, [edge.source]);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// 3. Assign grid rows column by column, left to right
|
|
150
|
-
const rowAssignment = new Map<string, number>();
|
|
151
|
-
|
|
152
|
-
for (const col of columns) {
|
|
153
|
-
const takenRows = new Set<number>();
|
|
154
|
-
const preferredRows: number[] = [];
|
|
155
|
-
|
|
156
|
-
for (const node of col) {
|
|
157
|
-
const upstreams = upstreamMap.get(node.label);
|
|
158
|
-
let preferred: number;
|
|
159
|
-
|
|
160
|
-
if (upstreams && upstreams.length > 0) {
|
|
161
|
-
const upstreamRows = upstreams
|
|
162
|
-
.map((l) => rowAssignment.get(l))
|
|
163
|
-
.filter((r): r is number => r !== undefined);
|
|
164
|
-
|
|
165
|
-
if (upstreamRows.length === 1) {
|
|
166
|
-
preferred = upstreamRows[0];
|
|
167
|
-
} else if (upstreamRows.length > 1) {
|
|
168
|
-
upstreamRows.sort((a, b) => a - b);
|
|
169
|
-
const mid = Math.floor(upstreamRows.length / 2);
|
|
170
|
-
preferred =
|
|
171
|
-
upstreamRows.length % 2 === 0
|
|
172
|
-
? Math.round((upstreamRows[mid - 1] + upstreamRows[mid]) / 2)
|
|
173
|
-
: upstreamRows[mid];
|
|
174
|
-
} else {
|
|
175
|
-
preferred = preferredRows.length;
|
|
176
|
-
}
|
|
177
|
-
} else {
|
|
178
|
-
preferred = preferredRows.length;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
preferredRows.push(preferred);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
// Order preservation: preferred rows must be monotonically non-decreasing
|
|
185
|
-
let monotone = true;
|
|
186
|
-
for (let i = 1; i < preferredRows.length; i++) {
|
|
187
|
-
if (preferredRows[i] < preferredRows[i - 1]) {
|
|
188
|
-
monotone = false;
|
|
189
|
-
break;
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
if (!monotone) {
|
|
194
|
-
const minRow = Math.min(...preferredRows);
|
|
195
|
-
for (let i = 0; i < col.length; i++) {
|
|
196
|
-
preferredRows[i] = minRow + i;
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
// Resolve collisions
|
|
201
|
-
for (let i = 0; i < col.length; i++) {
|
|
202
|
-
const row = findNearestAvailable(preferredRows[i], takenRows);
|
|
203
|
-
takenRows.add(row);
|
|
204
|
-
rowAssignment.set(col[i].label, row);
|
|
205
|
-
col[i].y = row * GRID_ROW_HEIGHT;
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
// 4. Vertical centering, ensure minY >= 20
|
|
210
|
-
const allY = nodes.map((n) => n.y);
|
|
211
|
-
const minY = Math.min(...allY);
|
|
212
|
-
const maxY = Math.max(...allY);
|
|
213
|
-
const centerOffset = -(minY + maxY) / 2;
|
|
214
|
-
for (const n of nodes) n.y += centerOffset;
|
|
215
|
-
|
|
216
|
-
const adjustedMinY = Math.min(...nodes.map((n) => n.y));
|
|
217
|
-
if (adjustedMinY < 20) {
|
|
218
|
-
const shift = 20 - adjustedMinY;
|
|
219
|
-
for (const n of nodes) n.y += shift;
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
// ============================================================
|
|
224
|
-
// Main layout function
|
|
225
|
-
// ============================================================
|
|
226
|
-
|
|
227
|
-
export function layoutInitiativeStatus(
|
|
228
|
-
parsed: ParsedInitiativeStatus,
|
|
229
|
-
collapseResult?: CollapseResult
|
|
230
|
-
): ISLayoutResult {
|
|
231
|
-
if (parsed.nodes.length === 0 && (!collapseResult || collapseResult.collapsedGroupStatuses.size === 0)) {
|
|
232
|
-
return { nodes: [], edges: [], groups: [], width: 0, height: 0 };
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
// Derive collapse context
|
|
236
|
-
const originalGroups = collapseResult?.originalGroups ?? parsed.groups;
|
|
237
|
-
const collapsedGroupStatuses = collapseResult?.collapsedGroupStatuses ?? new Map<string, InitiativeStatus>();
|
|
238
|
-
const collapsedGroupLabels = new Set(
|
|
239
|
-
originalGroups
|
|
240
|
-
.map((g) => g.label)
|
|
241
|
-
.filter((l) => !parsed.groups.some((g) => g.label === l))
|
|
242
|
-
);
|
|
243
|
-
|
|
244
|
-
// Build and run dagre graph
|
|
245
|
-
const hasGroups = parsed.groups.length > 0 || collapsedGroupLabels.size > 0;
|
|
246
|
-
const g = new dagre.graphlib.Graph({ multigraph: true, compound: hasGroups });
|
|
247
|
-
g.setGraph({ rankdir: 'LR', nodesep: NODESEP, ranksep: RANKSEP });
|
|
248
|
-
g.setDefaultEdgeLabel(() => ({}));
|
|
249
|
-
|
|
250
|
-
// Collapsed groups → regular dagre nodes (no compound parent)
|
|
251
|
-
for (const group of originalGroups) {
|
|
252
|
-
if (collapsedGroupLabels.has(group.label)) {
|
|
253
|
-
const collapsedW = Math.max(
|
|
254
|
-
NODE_WIDTH,
|
|
255
|
-
Math.ceil(group.label.length * CHAR_WIDTH_RATIO * NODE_FONT_SIZE) + NODE_TEXT_PADDING * 2
|
|
256
|
-
);
|
|
257
|
-
g.setNode(group.label, { label: group.label, width: collapsedW, height: NODE_HEIGHT });
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
// Expanded groups → compound parents
|
|
262
|
-
for (const group of parsed.groups) {
|
|
263
|
-
g.setNode(`__group_${group.label}`, { label: group.label, clusterLabelPos: 'top' });
|
|
264
|
-
}
|
|
265
|
-
for (const node of parsed.nodes) {
|
|
266
|
-
g.setNode(node.label, { label: node.label, width: NODE_WIDTH, height: NODE_HEIGHT });
|
|
267
|
-
}
|
|
268
|
-
for (const group of parsed.groups) {
|
|
269
|
-
const groupId = `__group_${group.label}`;
|
|
270
|
-
for (const nodeLabel of group.nodeLabels) {
|
|
271
|
-
if (g.hasNode(nodeLabel)) g.setParent(nodeLabel, groupId);
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
for (let i = 0; i < parsed.edges.length; i++) {
|
|
275
|
-
const edge = parsed.edges[i];
|
|
276
|
-
g.setEdge(edge.source, edge.target, { label: edge.label ?? '' }, `e${i}`);
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
dagre.layout(g);
|
|
280
|
-
|
|
281
|
-
// Extract node positions from dagre
|
|
282
|
-
const layoutNodes: ISLayoutNode[] = parsed.nodes.map((node) => {
|
|
283
|
-
const pos = g.node(node.label);
|
|
284
|
-
return {
|
|
285
|
-
label: node.label,
|
|
286
|
-
status: node.status,
|
|
287
|
-
shape: node.shape,
|
|
288
|
-
lineNumber: node.lineNumber,
|
|
289
|
-
x: pos.x,
|
|
290
|
-
y: pos.y,
|
|
291
|
-
width: pos.width,
|
|
292
|
-
height: pos.height,
|
|
293
|
-
metadata: node.metadata,
|
|
294
|
-
};
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
// Collect collapsed group positions for grid quantization
|
|
298
|
-
const collapsedGroupPositions: GridNode[] = [];
|
|
299
|
-
for (const label of collapsedGroupLabels) {
|
|
300
|
-
const pos = g.node(label);
|
|
301
|
-
if (pos) collapsedGroupPositions.push({ label, x: pos.x, y: pos.y, width: pos.width, height: pos.height });
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
// Grid-quantize all node positions (regular + collapsed groups)
|
|
305
|
-
const allGridNodes: GridNode[] = [
|
|
306
|
-
...layoutNodes.map((n) => ({ label: n.label, x: n.x, y: n.y, width: n.width, height: n.height })),
|
|
307
|
-
...collapsedGroupPositions,
|
|
308
|
-
];
|
|
309
|
-
gridQuantize(allGridNodes, parsed.edges);
|
|
310
|
-
|
|
311
|
-
// Write quantized positions back
|
|
312
|
-
const quantizedMap = new Map(allGridNodes.map((n) => [n.label, n]));
|
|
313
|
-
for (const node of layoutNodes) {
|
|
314
|
-
const q = quantizedMap.get(node.label)!;
|
|
315
|
-
node.x = q.x;
|
|
316
|
-
node.y = q.y;
|
|
317
|
-
}
|
|
318
|
-
for (const cgp of collapsedGroupPositions) {
|
|
319
|
-
const q = quantizedMap.get(cgp.label)!;
|
|
320
|
-
cgp.x = q.x;
|
|
321
|
-
cgp.y = q.y;
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
// Build a unified position map covering both regular nodes and collapsed groups
|
|
326
|
-
interface NodePos { x: number; y: number; width: number; height: number }
|
|
327
|
-
const posMap = new Map<string, NodePos>(layoutNodes.map((n) => [n.label, n]));
|
|
328
|
-
for (const cgp of collapsedGroupPositions) {
|
|
329
|
-
posMap.set(cgp.label, { x: cgp.x, y: cgp.y, width: cgp.width, height: cgp.height });
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
// Compute group bounding boxes BEFORE edge routing so overlap resolution
|
|
333
|
-
// can fix node positions before edges are computed.
|
|
334
|
-
const layoutGroups: ISLayoutGroup[] = [];
|
|
335
|
-
|
|
336
|
-
// Collapsed groups
|
|
337
|
-
for (const group of originalGroups) {
|
|
338
|
-
if (collapsedGroupLabels.has(group.label)) {
|
|
339
|
-
const cgp = collapsedGroupPositions.find((p) => p.label === group.label);
|
|
340
|
-
if (!cgp) continue;
|
|
341
|
-
layoutGroups.push({
|
|
342
|
-
label: group.label,
|
|
343
|
-
status: collapsedGroupStatuses.get(group.label) ?? null,
|
|
344
|
-
x: cgp.x - cgp.width / 2,
|
|
345
|
-
y: cgp.y - cgp.height / 2,
|
|
346
|
-
width: cgp.width,
|
|
347
|
-
height: cgp.height,
|
|
348
|
-
lineNumber: group.lineNumber,
|
|
349
|
-
collapsed: true,
|
|
350
|
-
});
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
// Expanded groups: bounding box from member positions
|
|
355
|
-
if (parsed.groups.length > 0) {
|
|
356
|
-
const nMap = new Map(layoutNodes.map((n) => [n.label, n]));
|
|
357
|
-
for (const group of parsed.groups) {
|
|
358
|
-
const members = group.nodeLabels
|
|
359
|
-
.map((label) => nMap.get(label))
|
|
360
|
-
.filter((n): n is ISLayoutNode => n !== undefined);
|
|
361
|
-
if (members.length === 0) continue;
|
|
362
|
-
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
363
|
-
for (const member of members) {
|
|
364
|
-
const left = member.x - member.width / 2;
|
|
365
|
-
const right = member.x + member.width / 2;
|
|
366
|
-
const top = member.y - member.height / 2;
|
|
367
|
-
const bottom = member.y + member.height / 2;
|
|
368
|
-
if (left < minX) minX = left;
|
|
369
|
-
if (right > maxX) maxX = right;
|
|
370
|
-
if (top < minY) minY = top;
|
|
371
|
-
if (bottom > maxY) maxY = bottom;
|
|
372
|
-
}
|
|
373
|
-
layoutGroups.push({
|
|
374
|
-
label: group.label,
|
|
375
|
-
status: rollUpStatus(members),
|
|
376
|
-
x: minX - GROUP_PADDING,
|
|
377
|
-
y: minY - GROUP_PADDING,
|
|
378
|
-
width: maxX - minX + GROUP_PADDING * 2,
|
|
379
|
-
height: maxY - minY + GROUP_PADDING * 2,
|
|
380
|
-
lineNumber: group.lineNumber,
|
|
381
|
-
collapsed: false,
|
|
382
|
-
});
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
// Resolve overlaps between expanded group boxes and non-member nodes.
|
|
387
|
-
// Must happen BEFORE edge routing so edges use final node positions.
|
|
388
|
-
if (layoutGroups.length > 0) {
|
|
389
|
-
const groupMemberLabels = new Set(parsed.groups.flatMap((gr) => gr.nodeLabels));
|
|
390
|
-
let changed = true;
|
|
391
|
-
let iterations = 0;
|
|
392
|
-
while (changed && iterations < 10) {
|
|
393
|
-
changed = false;
|
|
394
|
-
iterations++;
|
|
395
|
-
for (const group of layoutGroups) {
|
|
396
|
-
if (group.collapsed) continue;
|
|
397
|
-
// Use rendered group bounds (includes GROUP_EXTRA_PADDING + label)
|
|
398
|
-
const gTop = group.y - GROUP_LABEL_HEIGHT - GROUP_PADDING;
|
|
399
|
-
const gBottom = group.y + group.height + GROUP_PADDING;
|
|
400
|
-
const gLeft = group.x - GROUP_PADDING;
|
|
401
|
-
const gRight = group.x + group.width + GROUP_PADDING;
|
|
402
|
-
for (const node of layoutNodes) {
|
|
403
|
-
if (groupMemberLabels.has(node.label)) continue;
|
|
404
|
-
const nTop = node.y - node.height / 2;
|
|
405
|
-
const nBottom = node.y + node.height / 2;
|
|
406
|
-
const nLeft = node.x - node.width / 2;
|
|
407
|
-
const nRight = node.x + node.width / 2;
|
|
408
|
-
if (nRight <= gLeft || nLeft >= gRight) continue;
|
|
409
|
-
if (nBottom < gTop || nTop > gBottom) continue;
|
|
410
|
-
const groupCenterY = group.y + group.height / 2;
|
|
411
|
-
if (node.y < groupCenterY) {
|
|
412
|
-
node.y = gTop - node.height / 2 - GROUP_PADDING;
|
|
413
|
-
} else {
|
|
414
|
-
node.y = gBottom + node.height / 2 + GROUP_PADDING;
|
|
415
|
-
}
|
|
416
|
-
const pm = posMap.get(node.label);
|
|
417
|
-
if (pm) pm.y = node.y;
|
|
418
|
-
changed = true;
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
if (changed) {
|
|
422
|
-
const nMap = new Map(layoutNodes.map((n) => [n.label, n]));
|
|
423
|
-
for (const group of layoutGroups) {
|
|
424
|
-
if (group.collapsed) continue;
|
|
425
|
-
const pg = parsed.groups.find((gr) => gr.label === group.label);
|
|
426
|
-
if (!pg) continue;
|
|
427
|
-
const members = pg.nodeLabels
|
|
428
|
-
.map((label) => nMap.get(label))
|
|
429
|
-
.filter((n): n is ISLayoutNode => n !== undefined);
|
|
430
|
-
if (members.length === 0) continue;
|
|
431
|
-
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
432
|
-
for (const member of members) {
|
|
433
|
-
const left = member.x - member.width / 2;
|
|
434
|
-
const right = member.x + member.width / 2;
|
|
435
|
-
const top = member.y - member.height / 2;
|
|
436
|
-
const bottom = member.y + member.height / 2;
|
|
437
|
-
if (left < minX) minX = left;
|
|
438
|
-
if (right > maxX) maxX = right;
|
|
439
|
-
if (top < minY) minY = top;
|
|
440
|
-
if (bottom > maxY) maxY = bottom;
|
|
441
|
-
}
|
|
442
|
-
group.x = minX - GROUP_PADDING;
|
|
443
|
-
group.y = minY - GROUP_PADDING;
|
|
444
|
-
group.width = maxX - minX + GROUP_PADDING * 2;
|
|
445
|
-
group.height = maxY - minY + GROUP_PADDING * 2;
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
// Normalize Y: ensure all coordinates are non-negative after overlap resolution
|
|
452
|
-
{
|
|
453
|
-
let minNodeY = Infinity;
|
|
454
|
-
for (const node of layoutNodes) {
|
|
455
|
-
const top = node.y - node.height / 2;
|
|
456
|
-
if (top < minNodeY) minNodeY = top;
|
|
457
|
-
}
|
|
458
|
-
for (const group of layoutGroups) {
|
|
459
|
-
const top = group.collapsed ? group.y : group.y - GROUP_LABEL_HEIGHT;
|
|
460
|
-
if (top < minNodeY) minNodeY = top;
|
|
461
|
-
}
|
|
462
|
-
if (minNodeY < 20) {
|
|
463
|
-
const shift = 20 - minNodeY;
|
|
464
|
-
for (const node of layoutNodes) {
|
|
465
|
-
node.y += shift;
|
|
466
|
-
const pm = posMap.get(node.label);
|
|
467
|
-
if (pm) pm.y = node.y;
|
|
468
|
-
}
|
|
469
|
-
for (const group of layoutGroups) {
|
|
470
|
-
group.y += shift;
|
|
471
|
-
}
|
|
472
|
-
for (const cgp of collapsedGroupPositions) {
|
|
473
|
-
cgp.y += shift;
|
|
474
|
-
const pm = posMap.get(cgp.label);
|
|
475
|
-
if (pm) pm.y = cgp.y;
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
const allNodeX = [...posMap.values()].map((n) => n.x);
|
|
481
|
-
const avgNodeY = layoutNodes.length > 0
|
|
482
|
-
? layoutNodes.reduce((s, n) => s + n.y, 0) / layoutNodes.length
|
|
483
|
-
: 0;
|
|
484
|
-
const avgNodeX = layoutNodes.length > 0
|
|
485
|
-
? layoutNodes.reduce((s, n) => s + n.x, 0) / layoutNodes.length
|
|
486
|
-
: 0;
|
|
487
|
-
|
|
488
|
-
// Adjacent-rank edges: 4-point elbow (perpendicular exit/entry, no crossings).
|
|
489
|
-
// Multi-rank edges: dagre's interior waypoints for obstacle avoidance, with
|
|
490
|
-
// first/last points pinned to exact node boundaries at node-center Y.
|
|
491
|
-
|
|
492
|
-
// Precompute Y offsets and parallel counts for parallel edges (same directed source→target).
|
|
493
|
-
// Edges beyond MAX_PARALLEL_EDGES in a group are marked with parallelCount=0 and excluded from layout.
|
|
494
|
-
const edgeYOffsets: number[] = new Array(parsed.edges.length).fill(0);
|
|
495
|
-
const edgeParallelCounts: number[] = new Array(parsed.edges.length).fill(1);
|
|
496
|
-
const parallelGroups = new Map<string, number[]>();
|
|
497
|
-
for (let i = 0; i < parsed.edges.length; i++) {
|
|
498
|
-
const edge = parsed.edges[i];
|
|
499
|
-
const key = `${edge.source}\x00${edge.target}`; // null-byte separator — safe in all label strings
|
|
500
|
-
parallelGroups.set(key, parallelGroups.get(key) ?? []);
|
|
501
|
-
parallelGroups.get(key)!.push(i);
|
|
502
|
-
}
|
|
503
|
-
for (const group of parallelGroups.values()) {
|
|
504
|
-
// Cap group to MAX_PARALLEL_EDGES; mark excess edges for exclusion
|
|
505
|
-
const capped = group.slice(0, MAX_PARALLEL_EDGES);
|
|
506
|
-
for (const idx of group.slice(MAX_PARALLEL_EDGES)) {
|
|
507
|
-
edgeParallelCounts[idx] = 0; // sentinel: exclude from layout
|
|
508
|
-
}
|
|
509
|
-
if (capped.length < 2) continue;
|
|
510
|
-
// Clamp spacing so the bundle fits within node bounds regardless of edge count
|
|
511
|
-
const effectiveSpacing = Math.min(PARALLEL_SPACING, (NODE_HEIGHT - PARALLEL_EDGE_MARGIN) / (capped.length - 1));
|
|
512
|
-
for (let j = 0; j < capped.length; j++) {
|
|
513
|
-
edgeYOffsets[capped[j]] = (j - (capped.length - 1) / 2) * effectiveSpacing;
|
|
514
|
-
edgeParallelCounts[capped[j]] = capped.length;
|
|
515
|
-
}
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
const layoutEdges: ISLayoutEdge[] = [];
|
|
519
|
-
for (let i = 0; i < parsed.edges.length; i++) {
|
|
520
|
-
const edge = parsed.edges[i];
|
|
521
|
-
const src = posMap.get(edge.source);
|
|
522
|
-
const tgt = posMap.get(edge.target);
|
|
523
|
-
// Exclude edges beyond the parallel cap and edges with missing node positions
|
|
524
|
-
if (edgeParallelCounts[i] === 0) continue;
|
|
525
|
-
if (!src || !tgt) continue;
|
|
526
|
-
const yOffset = edgeYOffsets[i];
|
|
527
|
-
const parallelCount = edgeParallelCounts[i];
|
|
528
|
-
const exitX = src.x + src.width / 2;
|
|
529
|
-
const enterX = tgt.x - tgt.width / 2;
|
|
530
|
-
const dagreEdge = g.edge(edge.source, edge.target, `e${i}`);
|
|
531
|
-
const dagrePoints: { x: number; y: number }[] = dagreEdge?.points ?? [];
|
|
532
|
-
const hasIntermediateRank = allNodeX.some((x) => x > src.x + 20 && x < tgt.x - 20);
|
|
533
|
-
const step = Math.max(0, Math.min((enterX - exitX) * 0.15, 20)); // clamped ≥0: guards overlapping nodes
|
|
534
|
-
|
|
535
|
-
// 5-branch routing: isBackEdge → isTopExit → isBottomExit → 4-point elbow → fixedDagrePoints
|
|
536
|
-
const isBackEdge = tgt.x < src.x - 5; // 5px epsilon: same-rank same-X nodes must not false-match
|
|
537
|
-
// Guards: tgt.x > src.x (strict) keeps step positive; !hasIntermediateRank defers multi-rank
|
|
538
|
-
// displaced edges to fixedDagrePoints so dagre can route around intermediate nodes.
|
|
539
|
-
const isTopExit = !isBackEdge && tgt.x > src.x && !hasIntermediateRank && tgt.y < src.y - NODESEP;
|
|
540
|
-
const isBottomExit = !isBackEdge && tgt.x > src.x && !hasIntermediateRank && tgt.y > src.y + NODESEP;
|
|
541
|
-
|
|
542
|
-
let points: { x: number; y: number }[];
|
|
543
|
-
|
|
544
|
-
if (isBackEdge) {
|
|
545
|
-
// 3-point arc via bottom (or top) of both nodes — bypasses dagre entirely so arrowhead is visible.
|
|
546
|
-
// curveMonotoneX requires monotone-decreasing X (src.x > tgt.x for back-edges) ✓
|
|
547
|
-
// Parallel back-edges share the same arc (yOffset ignored) — acknowledged limitation, out of scope.
|
|
548
|
-
const routeAbove = Math.min(src.y, tgt.y) > avgNodeY;
|
|
549
|
-
const srcHalfH = src.height / 2;
|
|
550
|
-
const tgtHalfH = tgt.height / 2;
|
|
551
|
-
const rawMidX = (src.x + tgt.x) / 2;
|
|
552
|
-
const spreadDir = avgNodeX < rawMidX ? 1 : -1;
|
|
553
|
-
// Clamp midX to [tgt.x, src.x] to preserve monotone-decreasing X for curveMonotoneX.
|
|
554
|
-
// When nodes are near-same-X the arc stays narrow but valid.
|
|
555
|
-
const unclamped = Math.abs(src.x - tgt.x) < NODE_WIDTH
|
|
556
|
-
? rawMidX + spreadDir * BACK_EDGE_MIN_SPREAD
|
|
557
|
-
: rawMidX;
|
|
558
|
-
const midX = Math.min(src.x, Math.max(tgt.x, unclamped));
|
|
559
|
-
// Clamped departure/approach control points give near-orthogonal tangents at node edges.
|
|
560
|
-
// For narrow back-edges (|src.x - tgt.x| < 2*TOP_EXIT_STEP), clamps degrade to midX±1 — valid.
|
|
561
|
-
const srcDepart = Math.max(midX + 1, src.x - TOP_EXIT_STEP);
|
|
562
|
-
const tgtApproach = Math.min(midX - 1, tgt.x + TOP_EXIT_STEP);
|
|
563
|
-
if (routeAbove) {
|
|
564
|
-
const arcY = Math.min(src.y - srcHalfH, tgt.y - tgtHalfH) - BACK_EDGE_MARGIN;
|
|
565
|
-
points = [
|
|
566
|
-
{ x: src.x, y: src.y - srcHalfH },
|
|
567
|
-
{ x: srcDepart, y: src.y - srcHalfH - TOP_EXIT_STEP },
|
|
568
|
-
{ x: midX, y: arcY },
|
|
569
|
-
{ x: tgtApproach, y: tgt.y - tgtHalfH - TOP_EXIT_STEP },
|
|
570
|
-
{ x: tgt.x, y: tgt.y - tgtHalfH },
|
|
571
|
-
];
|
|
572
|
-
} else {
|
|
573
|
-
const arcY = Math.max(src.y + srcHalfH, tgt.y + tgtHalfH) + BACK_EDGE_MARGIN;
|
|
574
|
-
points = [
|
|
575
|
-
{ x: src.x, y: src.y + srcHalfH },
|
|
576
|
-
{ x: srcDepart, y: src.y + srcHalfH + TOP_EXIT_STEP },
|
|
577
|
-
{ x: midX, y: arcY },
|
|
578
|
-
{ x: tgtApproach, y: tgt.y + tgtHalfH + TOP_EXIT_STEP },
|
|
579
|
-
{ x: tgt.x, y: tgt.y + tgtHalfH },
|
|
580
|
-
];
|
|
581
|
-
}
|
|
582
|
-
} else if (isTopExit) {
|
|
583
|
-
// 4-point top-exit elbow: exits top of source ~vertically, arrives left of target horizontally.
|
|
584
|
-
// Top exit keeps this edge ABOVE the horizontal right-exit bundle → avoids crossings.
|
|
585
|
-
// yOffset repurposed as X-spread for top/bottom-exit branches (same magnitude, different axis).
|
|
586
|
-
// p1x: floor at src.x prevents negative-yOffset edges from going left of origin (breaks monotone X);
|
|
587
|
-
// ceiling at midpoint-1 prevents overshooting for large positive yOffset (±32px for 5 parallel edges).
|
|
588
|
-
const exitY = src.y - src.height / 2;
|
|
589
|
-
const p1x = Math.min(Math.max(src.x, src.x + yOffset + TOP_EXIT_STEP), (src.x + enterX) / 2 - 1);
|
|
590
|
-
points = [
|
|
591
|
-
{ x: src.x, y: exitY },
|
|
592
|
-
{ x: p1x, y: exitY - TOP_EXIT_STEP },
|
|
593
|
-
{ x: enterX - step, y: tgt.y + yOffset },
|
|
594
|
-
{ x: enterX, y: tgt.y },
|
|
595
|
-
];
|
|
596
|
-
} else if (isBottomExit) {
|
|
597
|
-
// 4-point bottom-exit elbow: mirror of top-exit. Keeps edge BELOW the horizontal bundle.
|
|
598
|
-
const exitY = src.y + src.height / 2;
|
|
599
|
-
const p1x = Math.min(Math.max(src.x, src.x + yOffset + TOP_EXIT_STEP), (src.x + enterX) / 2 - 1);
|
|
600
|
-
points = [
|
|
601
|
-
{ x: src.x, y: exitY },
|
|
602
|
-
{ x: p1x, y: exitY + TOP_EXIT_STEP },
|
|
603
|
-
{ x: enterX - step, y: tgt.y + yOffset },
|
|
604
|
-
{ x: enterX, y: tgt.y },
|
|
605
|
-
];
|
|
606
|
-
} else if (tgt.x > src.x && !hasIntermediateRank) {
|
|
607
|
-
// 4-point elbow: adjacent-rank forward edges (unchanged)
|
|
608
|
-
points = [
|
|
609
|
-
{ x: exitX, y: src.y }, // exits node center — stays pinned
|
|
610
|
-
{ x: exitX + step, y: src.y + yOffset }, // fans out
|
|
611
|
-
{ x: enterX - step, y: tgt.y + yOffset }, // still fanned
|
|
612
|
-
{ x: enterX, y: tgt.y }, // enters node center — stays pinned
|
|
613
|
-
];
|
|
614
|
-
} else {
|
|
615
|
-
// fixedDagrePoints: multi-rank forward edges — dagre interior waypoints for obstacle avoidance.
|
|
616
|
-
// dagrePoints is still fetched above and available here.
|
|
617
|
-
points = dagrePoints.length >= 2 ? [
|
|
618
|
-
{ x: exitX, y: src.y + yOffset },
|
|
619
|
-
...dagrePoints.slice(1, -1),
|
|
620
|
-
{ x: enterX, y: tgt.y + yOffset },
|
|
621
|
-
] : dagrePoints;
|
|
622
|
-
}
|
|
623
|
-
layoutEdges.push({ source: edge.source, target: edge.target, label: edge.label,
|
|
624
|
-
status: edge.status, lineNumber: edge.lineNumber, points, parallelCount });
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
// Compute total dimensions
|
|
628
|
-
let totalWidth = 0;
|
|
629
|
-
let totalHeight = 0;
|
|
630
|
-
for (const node of layoutNodes) {
|
|
631
|
-
const right = node.x + node.width / 2;
|
|
632
|
-
const bottom = node.y + node.height / 2;
|
|
633
|
-
if (right > totalWidth) totalWidth = right;
|
|
634
|
-
if (bottom > totalHeight) totalHeight = bottom;
|
|
635
|
-
}
|
|
636
|
-
for (const group of layoutGroups) {
|
|
637
|
-
if (group.x + group.width > totalWidth) totalWidth = group.x + group.width;
|
|
638
|
-
if (group.y + group.height > totalHeight) totalHeight = group.y + group.height;
|
|
639
|
-
}
|
|
640
|
-
for (const edge of layoutEdges) {
|
|
641
|
-
for (const pt of edge.points) {
|
|
642
|
-
if (pt.x > totalWidth) totalWidth = pt.x;
|
|
643
|
-
if (pt.y > totalHeight) totalHeight = pt.y;
|
|
644
|
-
}
|
|
645
|
-
}
|
|
646
|
-
totalWidth += 40;
|
|
647
|
-
totalHeight += 40;
|
|
648
|
-
|
|
649
|
-
return { nodes: layoutNodes, edges: layoutEdges, groups: layoutGroups, width: totalWidth, height: totalHeight };
|
|
650
|
-
}
|