@liminis/editor 0.2.2 → 0.4.0
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/LICENSE +0 -13
- package/README.md +158 -103
- package/dist/app/editor/CorrectionPanelPlugin.js +10 -11
- package/dist/app/editor/DragHandlePlugin.js +1 -1
- package/dist/app/editor/Editor.js +17 -5
- package/dist/app/editor/SelectionContextMenuPlugin.js +4 -4
- package/dist/app/editor/nodes/C4Component.js +8 -10
- package/dist/app/editor/nodes/C4Node.d.ts +1 -1
- package/dist/app/editor/nodes/DiagramContextMenu.js +5 -5
- package/dist/headless.d.ts +3 -5
- package/dist/headless.js +2 -4
- package/dist/index.d.ts +1 -1
- package/dist/styles.css +428 -325
- package/docs/decisions/adr-92-lexical-peer-range-policy.md +152 -0
- package/docs/decisions/adr-93-liminis-editor-defined-aliases.md +315 -0
- package/docs/decisions/adr-98-invert-token-direction.md +309 -0
- package/package.json +28 -27
- package/dist/app/editor/c4/C4InteractiveRenderer.d.ts +0 -35
- package/dist/app/editor/c4/C4InteractiveRenderer.js +0 -299
- package/dist/app/editor/c4/edge-clipping.d.ts +0 -24
- package/dist/app/editor/c4/edge-clipping.js +0 -139
- package/dist/app/editor/c4/hooks/useC4DiagramDrag.d.ts +0 -38
- package/dist/app/editor/c4/hooks/useC4DiagramDrag.js +0 -112
- package/dist/app/editor/c4/layout.d.ts +0 -25
- package/dist/app/editor/c4/layout.js +0 -839
- package/dist/app/editor/c4/parser.d.ts +0 -19
- package/dist/app/editor/c4/parser.js +0 -410
- package/dist/app/editor/c4/render-to-string.d.ts +0 -24
- package/dist/app/editor/c4/render-to-string.js +0 -34
- package/dist/app/editor/c4/renderer.d.ts +0 -64
- package/dist/app/editor/c4/renderer.js +0 -569
- package/dist/app/editor/c4/types.d.ts +0 -203
- package/dist/app/editor/c4/types.js +0 -43
|
@@ -1,839 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* C4 Layout Engine
|
|
3
|
-
*
|
|
4
|
-
* Uses @dagrejs/dagre for directed graph auto-layout of C4 architecture diagrams.
|
|
5
|
-
* Supports nested elements (systems containing containers containing components)
|
|
6
|
-
* with proper boundary group padding.
|
|
7
|
-
*/
|
|
8
|
-
import dagre from '@dagrejs/dagre';
|
|
9
|
-
// =============================================================================
|
|
10
|
-
// CONSTANTS
|
|
11
|
-
// =============================================================================
|
|
12
|
-
const DEFAULT_OPTIONS = {
|
|
13
|
-
nodeWidth: 240,
|
|
14
|
-
nodeHeight: 80,
|
|
15
|
-
nodePadding: 30,
|
|
16
|
-
rankSep: 100,
|
|
17
|
-
edgeSep: 25,
|
|
18
|
-
};
|
|
19
|
-
/** Padding around boundary group contents */
|
|
20
|
-
const BOUNDARY_PADDING = 40;
|
|
21
|
-
/** Gap between a boundary and external elements outside it */
|
|
22
|
-
const EXTERNAL_GAP = 80;
|
|
23
|
-
/** Space reserved for boundary header (title + optional tech badge) */
|
|
24
|
-
const BOUNDARY_HEADER_HEIGHT = 55;
|
|
25
|
-
/** Additional height for elements with tech badges or descriptions */
|
|
26
|
-
const TECH_BADGE_HEIGHT = 16;
|
|
27
|
-
const DESCRIPTION_HEIGHT = 14;
|
|
28
|
-
/** Person element dimensions */
|
|
29
|
-
const PERSON_WIDTH = 120;
|
|
30
|
-
const PERSON_HEIGHT = 120;
|
|
31
|
-
// =============================================================================
|
|
32
|
-
// DIMENSION CALCULATIONS
|
|
33
|
-
// =============================================================================
|
|
34
|
-
/**
|
|
35
|
-
* Calculate node dimensions based on element content.
|
|
36
|
-
* Accounts for name length, tech badge, and description text.
|
|
37
|
-
*/
|
|
38
|
-
function calculateNodeDimensions(element, options) {
|
|
39
|
-
// Person elements have fixed smaller size
|
|
40
|
-
if (element.type === 'person') {
|
|
41
|
-
return { width: PERSON_WIDTH, height: PERSON_HEIGHT };
|
|
42
|
-
}
|
|
43
|
-
// Start with base dimensions
|
|
44
|
-
let width = options.nodeWidth;
|
|
45
|
-
let height = options.nodeHeight;
|
|
46
|
-
// Estimate width based on name length (roughly 9px per character)
|
|
47
|
-
const nameWidth = element.name.length * 9 + 48;
|
|
48
|
-
width = Math.max(width, nameWidth);
|
|
49
|
-
// Add height for tech badge if present
|
|
50
|
-
if (element.properties.tech) {
|
|
51
|
-
height += TECH_BADGE_HEIGHT;
|
|
52
|
-
// Account for [] brackets around tech text
|
|
53
|
-
const techWidth = (element.properties.tech.length + 2) * 7 + 48;
|
|
54
|
-
width = Math.max(width, techWidth);
|
|
55
|
-
}
|
|
56
|
-
// Add height for description if present
|
|
57
|
-
if (element.properties.description) {
|
|
58
|
-
height += DESCRIPTION_HEIGHT;
|
|
59
|
-
// Description wraps, so limit width contribution
|
|
60
|
-
const descWidth = Math.min(element.properties.description.length * 7, 360);
|
|
61
|
-
width = Math.max(width, descWidth);
|
|
62
|
-
}
|
|
63
|
-
// Cylinder shapes are slightly taller
|
|
64
|
-
if (element.properties.shape === 'cylinder') {
|
|
65
|
-
height += 20;
|
|
66
|
-
}
|
|
67
|
-
// Queue shapes need extra width for the wavy right edge
|
|
68
|
-
if (element.properties.shape === 'queue') {
|
|
69
|
-
width += 20;
|
|
70
|
-
}
|
|
71
|
-
return { width, height };
|
|
72
|
-
}
|
|
73
|
-
// =============================================================================
|
|
74
|
-
// DAGRE DIRECTION MAPPING
|
|
75
|
-
// =============================================================================
|
|
76
|
-
/**
|
|
77
|
-
* Map C4 direction to dagre rankdir.
|
|
78
|
-
*/
|
|
79
|
-
function mapDirection(direction) {
|
|
80
|
-
switch (direction) {
|
|
81
|
-
case 'down':
|
|
82
|
-
return 'TB'; // Top to Bottom
|
|
83
|
-
case 'up':
|
|
84
|
-
return 'BT'; // Bottom to Top
|
|
85
|
-
case 'left':
|
|
86
|
-
return 'RL'; // Right to Left
|
|
87
|
-
case 'right':
|
|
88
|
-
return 'LR'; // Left to Right
|
|
89
|
-
default:
|
|
90
|
-
return 'TB'; // Top to Bottom (C4 convention)
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
// =============================================================================
|
|
94
|
-
// LAYOUT CALCULATION
|
|
95
|
-
// =============================================================================
|
|
96
|
-
/**
|
|
97
|
-
* Get top-level elements (elements without parents).
|
|
98
|
-
*/
|
|
99
|
-
function getTopLevelElements(elements) {
|
|
100
|
-
return elements.filter((e) => !e.parent);
|
|
101
|
-
}
|
|
102
|
-
/**
|
|
103
|
-
* Layout a group of elements using dagre.
|
|
104
|
-
* Returns positioned nodes with their children recursively laid out.
|
|
105
|
-
*/
|
|
106
|
-
function layoutGroup(elements, relationships, options, parentDirection) {
|
|
107
|
-
if (elements.length === 0) {
|
|
108
|
-
return [];
|
|
109
|
-
}
|
|
110
|
-
// Create dagre graph
|
|
111
|
-
const g = new dagre.graphlib.Graph();
|
|
112
|
-
// Determine layout direction from parent or use default
|
|
113
|
-
const direction = parentDirection || 'down';
|
|
114
|
-
g.setGraph({
|
|
115
|
-
rankdir: mapDirection(direction),
|
|
116
|
-
nodesep: options.nodePadding,
|
|
117
|
-
ranksep: options.rankSep,
|
|
118
|
-
edgesep: options.edgeSep,
|
|
119
|
-
marginx: BOUNDARY_PADDING,
|
|
120
|
-
marginy: BOUNDARY_PADDING,
|
|
121
|
-
});
|
|
122
|
-
g.setDefaultEdgeLabel(() => ({}));
|
|
123
|
-
// Map to store layout nodes for building result
|
|
124
|
-
const layoutNodes = new Map();
|
|
125
|
-
// Add nodes to graph
|
|
126
|
-
for (const element of elements) {
|
|
127
|
-
// First, recursively layout children if any
|
|
128
|
-
let childNodes = [];
|
|
129
|
-
let childBounds = { width: 0, height: 0 };
|
|
130
|
-
if (element.children.length > 0) {
|
|
131
|
-
// Get child direction from this element, or inherit from parent
|
|
132
|
-
const childDirection = element.properties.direction || parentDirection;
|
|
133
|
-
// Filter relationships relevant to this element's children:
|
|
134
|
-
// Include relationships between direct children AND relationships
|
|
135
|
-
// between descendants of different children (for virtual edge creation)
|
|
136
|
-
const allDescendantIds = new Set();
|
|
137
|
-
function collectDescendants(el) {
|
|
138
|
-
allDescendantIds.add(el.id);
|
|
139
|
-
for (const child of el.children) {
|
|
140
|
-
collectDescendants(child);
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
for (const child of element.children) {
|
|
144
|
-
collectDescendants(child);
|
|
145
|
-
}
|
|
146
|
-
const childRelationships = relationships.filter((r) => allDescendantIds.has(r.sourceId) || allDescendantIds.has(r.targetId));
|
|
147
|
-
childNodes = layoutGroup(element.children, childRelationships, options, childDirection);
|
|
148
|
-
// Calculate bounding box of children
|
|
149
|
-
if (childNodes.length > 0) {
|
|
150
|
-
const maxX = Math.max(...childNodes.map((n) => n.x + n.width));
|
|
151
|
-
const maxY = Math.max(...childNodes.map((n) => n.y + n.height));
|
|
152
|
-
childBounds = {
|
|
153
|
-
width: maxX + BOUNDARY_PADDING,
|
|
154
|
-
height: maxY + BOUNDARY_PADDING,
|
|
155
|
-
};
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
// Calculate this node's dimensions
|
|
159
|
-
const baseDimensions = calculateNodeDimensions(element, options);
|
|
160
|
-
// If this is a boundary/system with children, use child bounds
|
|
161
|
-
const isBoundary = element.type === 'system' ||
|
|
162
|
-
element.properties.style === 'boundary' ||
|
|
163
|
-
element.children.length > 0;
|
|
164
|
-
const nodeWidth = isBoundary
|
|
165
|
-
? Math.max(baseDimensions.width, childBounds.width)
|
|
166
|
-
: baseDimensions.width;
|
|
167
|
-
const nodeHeight = isBoundary
|
|
168
|
-
? Math.max(baseDimensions.height, childBounds.height + BOUNDARY_HEADER_HEIGHT)
|
|
169
|
-
: baseDimensions.height;
|
|
170
|
-
// Add to dagre graph
|
|
171
|
-
g.setNode(element.id, { width: nodeWidth, height: nodeHeight });
|
|
172
|
-
// Store preliminary layout node
|
|
173
|
-
layoutNodes.set(element.id, {
|
|
174
|
-
id: element.id,
|
|
175
|
-
x: 0,
|
|
176
|
-
y: 0,
|
|
177
|
-
width: nodeWidth,
|
|
178
|
-
height: nodeHeight,
|
|
179
|
-
element,
|
|
180
|
-
children: childNodes,
|
|
181
|
-
});
|
|
182
|
-
}
|
|
183
|
-
// Add edges to graph (only those between elements in this group)
|
|
184
|
-
// Use label width to set minimum edge length so labels have room to display.
|
|
185
|
-
// In TB layout, one rank ≈ nodeHeight + rankSep. We estimate label width in px
|
|
186
|
-
// and compute how many ranks the label needs to not overlap nodes.
|
|
187
|
-
const elementIds = new Set(elements.map((e) => e.id));
|
|
188
|
-
const rankHeight = options.nodeHeight + options.rankSep; // ~180px per rank
|
|
189
|
-
const LABEL_CHAR_WIDTH = 6; // px per char at 11px font
|
|
190
|
-
const MAX_MINLEN = 4; // cap to avoid huge diagrams
|
|
191
|
-
for (const rel of relationships) {
|
|
192
|
-
if (elementIds.has(rel.sourceId) && elementIds.has(rel.targetId)) {
|
|
193
|
-
const labelLen = rel.label?.length ?? 0;
|
|
194
|
-
// Extract actual description from step-numbered labels "N [description]"
|
|
195
|
-
const stepMatch = rel.label ? /^\d+\s*\[(.+)\]$/.exec(rel.label) : null;
|
|
196
|
-
const displayLen = stepMatch ? stepMatch[1].length : labelLen;
|
|
197
|
-
// Estimate label width in px, then how many ranks needed
|
|
198
|
-
const labelWidthPx = displayLen * LABEL_CHAR_WIDTH;
|
|
199
|
-
// For two-line labels (split at ~30 chars), halve the effective width
|
|
200
|
-
const effectiveWidth = displayLen > 30 ? labelWidthPx / 2 : labelWidthPx;
|
|
201
|
-
// minlen: need at least enough ranks for label to fit alongside the edge
|
|
202
|
-
// One rank gives ~rankHeight px of vertical space
|
|
203
|
-
const minlen = Math.min(MAX_MINLEN, Math.max(1, Math.ceil(effectiveWidth / rankHeight)));
|
|
204
|
-
g.setEdge(rel.sourceId, rel.targetId, { minlen });
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
// Add virtual edges between boundaries when their children have relationships.
|
|
208
|
-
// This gives dagre the rank ordering it needs (e.g., macOS above Cloud Services
|
|
209
|
-
// when Context Graph Service → Neo4j AuraDB crosses that boundary).
|
|
210
|
-
// Map every descendant to its top-level ancestor in this group
|
|
211
|
-
const childToParent = new Map();
|
|
212
|
-
function mapDescendants(element, topAncestor) {
|
|
213
|
-
for (const child of element.children) {
|
|
214
|
-
childToParent.set(child.id, topAncestor);
|
|
215
|
-
mapDescendants(child, topAncestor);
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
for (const element of elements) {
|
|
219
|
-
mapDescendants(element, element.id);
|
|
220
|
-
}
|
|
221
|
-
// Also track which IDs are direct group elements (for imbalanced Rels)
|
|
222
|
-
const groupElementIds = new Set(elements.map((e) => e.id));
|
|
223
|
-
const virtualEdges = new Set();
|
|
224
|
-
for (const rel of relationships) {
|
|
225
|
-
let sourceParent = childToParent.get(rel.sourceId);
|
|
226
|
-
let targetParent = childToParent.get(rel.targetId);
|
|
227
|
-
// Handle imbalanced Rels: if one side is a boundary group element
|
|
228
|
-
// directly (peer to the other side's parent), use it as its own parent.
|
|
229
|
-
// Only for boundaries (elements with children) — not leaf elements.
|
|
230
|
-
if (!sourceParent && groupElementIds.has(rel.sourceId)) {
|
|
231
|
-
const el = elements.find((e) => e.id === rel.sourceId);
|
|
232
|
-
if (el && el.children.length > 0)
|
|
233
|
-
sourceParent = rel.sourceId;
|
|
234
|
-
}
|
|
235
|
-
if (!targetParent && groupElementIds.has(rel.targetId)) {
|
|
236
|
-
const el = elements.find((e) => e.id === rel.targetId);
|
|
237
|
-
if (el && el.children.length > 0)
|
|
238
|
-
targetParent = rel.targetId;
|
|
239
|
-
}
|
|
240
|
-
if (sourceParent && targetParent && sourceParent !== targetParent) {
|
|
241
|
-
const key = `${sourceParent}->${targetParent}`;
|
|
242
|
-
if (!virtualEdges.has(key)) {
|
|
243
|
-
virtualEdges.add(key);
|
|
244
|
-
g.setEdge(sourceParent, targetParent);
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
// Run dagre layout
|
|
249
|
-
dagre.layout(g);
|
|
250
|
-
// Extract positions from dagre
|
|
251
|
-
const result = [];
|
|
252
|
-
for (const element of elements) {
|
|
253
|
-
const dagreNode = g.node(element.id);
|
|
254
|
-
const layoutNode = layoutNodes.get(element.id);
|
|
255
|
-
// Dagre gives center coordinates, convert to top-left
|
|
256
|
-
layoutNode.x = dagreNode.x - dagreNode.width / 2;
|
|
257
|
-
layoutNode.y = dagreNode.y - dagreNode.height / 2;
|
|
258
|
-
// Offset children to be centered inside parent boundary
|
|
259
|
-
if (layoutNode.children && layoutNode.children.length > 0) {
|
|
260
|
-
// Calculate children's actual bounding box (dagre may not start at 0)
|
|
261
|
-
const childMinX = Math.min(...layoutNode.children.map((n) => n.x));
|
|
262
|
-
const childMaxX = Math.max(...layoutNode.children.map((n) => n.x + n.width));
|
|
263
|
-
const childrenWidth = childMaxX - childMinX;
|
|
264
|
-
// Center children horizontally within parent
|
|
265
|
-
const availableWidth = layoutNode.width - BOUNDARY_PADDING * 2;
|
|
266
|
-
const centerOffsetX = (availableWidth - childrenWidth) / 2;
|
|
267
|
-
const offsetX = layoutNode.x + BOUNDARY_PADDING + Math.max(0, centerOffsetX) - childMinX;
|
|
268
|
-
const offsetY = layoutNode.y + BOUNDARY_HEADER_HEIGHT;
|
|
269
|
-
for (const child of layoutNode.children) {
|
|
270
|
-
offsetLayoutNode(child, offsetX, offsetY);
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
result.push(layoutNode);
|
|
274
|
-
}
|
|
275
|
-
return result;
|
|
276
|
-
}
|
|
277
|
-
/**
|
|
278
|
-
* Recursively offset a layout node and its children.
|
|
279
|
-
*/
|
|
280
|
-
function offsetLayoutNode(node, offsetX, offsetY) {
|
|
281
|
-
node.x += offsetX;
|
|
282
|
-
node.y += offsetY;
|
|
283
|
-
if (node.children) {
|
|
284
|
-
for (const child of node.children) {
|
|
285
|
-
offsetLayoutNode(child, offsetX, offsetY);
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
/**
|
|
290
|
-
* Flatten layout nodes into a single array including all nested children.
|
|
291
|
-
*/
|
|
292
|
-
function flattenLayoutNodes(nodes) {
|
|
293
|
-
const result = [];
|
|
294
|
-
function visit(node) {
|
|
295
|
-
result.push(node);
|
|
296
|
-
if (node.children) {
|
|
297
|
-
for (const child of node.children) {
|
|
298
|
-
visit(child);
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
for (const node of nodes) {
|
|
303
|
-
visit(node);
|
|
304
|
-
}
|
|
305
|
-
return result;
|
|
306
|
-
}
|
|
307
|
-
/**
|
|
308
|
-
* Calculate edge paths between elements.
|
|
309
|
-
* Uses center points with simple routing.
|
|
310
|
-
*/
|
|
311
|
-
function calculateEdges(relationships, nodeMap) {
|
|
312
|
-
const edges = [];
|
|
313
|
-
// Count parallel edges between the same pair (in either direction)
|
|
314
|
-
const pairCounts = new Map();
|
|
315
|
-
const pairIndex = new Map();
|
|
316
|
-
for (const rel of relationships) {
|
|
317
|
-
const key = [rel.sourceId, rel.targetId].sort().join('::');
|
|
318
|
-
pairCounts.set(key, (pairCounts.get(key) ?? 0) + 1);
|
|
319
|
-
}
|
|
320
|
-
for (const rel of relationships) {
|
|
321
|
-
const sourceNode = nodeMap.get(rel.sourceId);
|
|
322
|
-
const targetNode = nodeMap.get(rel.targetId);
|
|
323
|
-
if (!sourceNode || !targetNode) {
|
|
324
|
-
continue;
|
|
325
|
-
}
|
|
326
|
-
// Determine offset for parallel edges
|
|
327
|
-
const pairKey = [rel.sourceId, rel.targetId].sort().join('::');
|
|
328
|
-
const totalParallel = pairCounts.get(pairKey) ?? 1;
|
|
329
|
-
const currentIndex = pairIndex.get(pairKey) ?? 0;
|
|
330
|
-
pairIndex.set(pairKey, currentIndex + 1);
|
|
331
|
-
// Calculate center points
|
|
332
|
-
const sourceCenter = {
|
|
333
|
-
x: sourceNode.x + sourceNode.width / 2,
|
|
334
|
-
y: sourceNode.y + sourceNode.height / 2,
|
|
335
|
-
};
|
|
336
|
-
const targetCenter = {
|
|
337
|
-
x: targetNode.x + targetNode.width / 2,
|
|
338
|
-
y: targetNode.y + targetNode.height / 2,
|
|
339
|
-
};
|
|
340
|
-
// Calculate edge points from node boundaries (unoffset)
|
|
341
|
-
const sourcePoint = calculateEdgePoint(sourceNode, targetCenter);
|
|
342
|
-
const targetPoint = calculateEdgePoint(targetNode, sourceCenter);
|
|
343
|
-
// For parallel edges, offset both points perpendicular to the edge
|
|
344
|
-
if (totalParallel > 1) {
|
|
345
|
-
// Use a consistent direction for the pair regardless of A→B vs B→A
|
|
346
|
-
// Sort the IDs and always compute the vector from the "lesser" to "greater" ID
|
|
347
|
-
const [sortedFirst, sortedSecond] = [rel.sourceId, rel.targetId].sort();
|
|
348
|
-
const firstNode = nodeMap.get(sortedFirst);
|
|
349
|
-
const secondNode = nodeMap.get(sortedSecond);
|
|
350
|
-
const dx = (secondNode.x + secondNode.width / 2) - (firstNode.x + firstNode.width / 2);
|
|
351
|
-
const dy = (secondNode.y + secondNode.height / 2) - (firstNode.y + firstNode.height / 2);
|
|
352
|
-
const len = Math.sqrt(dx * dx + dy * dy);
|
|
353
|
-
if (len > 0) {
|
|
354
|
-
const perpX = -dy / len;
|
|
355
|
-
const perpY = dx / len;
|
|
356
|
-
const isHorizontal = Math.abs(dx) > Math.abs(dy);
|
|
357
|
-
const spread = isHorizontal ? 35 : 25;
|
|
358
|
-
const offset = (currentIndex - (totalParallel - 1) / 2) * spread;
|
|
359
|
-
sourcePoint.x += perpX * offset;
|
|
360
|
-
sourcePoint.y += perpY * offset;
|
|
361
|
-
targetPoint.x += perpX * offset;
|
|
362
|
-
targetPoint.y += perpY * offset;
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
edges.push({
|
|
366
|
-
source: rel.sourceId,
|
|
367
|
-
target: rel.targetId,
|
|
368
|
-
points: [sourcePoint, targetPoint],
|
|
369
|
-
label: rel.label,
|
|
370
|
-
});
|
|
371
|
-
}
|
|
372
|
-
return edges;
|
|
373
|
-
}
|
|
374
|
-
/**
|
|
375
|
-
* Calculate the point where an edge exits/enters a rectangular node.
|
|
376
|
-
*/
|
|
377
|
-
function calculateEdgePoint(node, target) {
|
|
378
|
-
const centerX = node.x + node.width / 2;
|
|
379
|
-
const centerY = node.y + node.height / 2;
|
|
380
|
-
// Vector from center to target
|
|
381
|
-
const dx = target.x - centerX;
|
|
382
|
-
const dy = target.y - centerY;
|
|
383
|
-
// Handle edge case of same position
|
|
384
|
-
if (dx === 0 && dy === 0) {
|
|
385
|
-
return { x: centerX, y: centerY };
|
|
386
|
-
}
|
|
387
|
-
// Calculate intersection with rectangle boundary
|
|
388
|
-
const halfWidth = node.width / 2;
|
|
389
|
-
const halfHeight = node.height / 2;
|
|
390
|
-
// Check which edge we intersect
|
|
391
|
-
const scaleX = halfWidth / Math.abs(dx || 0.001);
|
|
392
|
-
const scaleY = halfHeight / Math.abs(dy || 0.001);
|
|
393
|
-
const scale = Math.min(scaleX, scaleY);
|
|
394
|
-
return {
|
|
395
|
-
x: centerX + dx * scale,
|
|
396
|
-
y: centerY + dy * scale,
|
|
397
|
-
};
|
|
398
|
-
}
|
|
399
|
-
// =============================================================================
|
|
400
|
-
// CROSS-BOUNDARY ALIGNMENT
|
|
401
|
-
// =============================================================================
|
|
402
|
-
/**
|
|
403
|
-
* After layout, nudge non-boundary top-level elements toward their
|
|
404
|
-
* cross-boundary relationship targets, without overlapping boundaries.
|
|
405
|
-
*
|
|
406
|
-
* For example, if "Knowledge Worker" relates to "MCP Host" inside a boundary,
|
|
407
|
-
* shift "Knowledge Worker" toward "MCP Host"'s X position, but don't let it
|
|
408
|
-
* overlap the boundary rectangle.
|
|
409
|
-
*/
|
|
410
|
-
function alignCrossBoundaryElements(topLevelNodes, relationships, nodeMap) {
|
|
411
|
-
// Compute dynamic external gap based on longest cross-boundary label
|
|
412
|
-
// (horizontal edges need room for their labels between boundary and external element)
|
|
413
|
-
const LABEL_CHAR_WIDTH = 6;
|
|
414
|
-
const MIN_EXTERNAL_GAP = EXTERNAL_GAP;
|
|
415
|
-
const MAX_EXTERNAL_GAP = 400;
|
|
416
|
-
let maxCrossBoundaryLabelWidth = 0;
|
|
417
|
-
for (const rel of relationships) {
|
|
418
|
-
const source = nodeMap.get(rel.sourceId);
|
|
419
|
-
const target = nodeMap.get(rel.targetId);
|
|
420
|
-
if (!source || !target)
|
|
421
|
-
continue;
|
|
422
|
-
// Cross-boundary: one has a parent, the other doesn't
|
|
423
|
-
const isCrossBoundary = (!!source.element.parent) !== (!!target.element.parent);
|
|
424
|
-
if (isCrossBoundary && rel.label) {
|
|
425
|
-
// Extract display length from step-numbered labels
|
|
426
|
-
const stepMatch = /^\d+\s*\[(.+)\]$/.exec(rel.label);
|
|
427
|
-
const displayLen = stepMatch ? stepMatch[1].length : rel.label.length;
|
|
428
|
-
const labelWidth = displayLen * LABEL_CHAR_WIDTH + 40; // padding for circle + margin
|
|
429
|
-
maxCrossBoundaryLabelWidth = Math.max(maxCrossBoundaryLabelWidth, labelWidth);
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
const dynamicGap = Math.min(MAX_EXTERNAL_GAP, Math.max(MIN_EXTERNAL_GAP, maxCrossBoundaryLabelWidth));
|
|
433
|
-
const leafNodes = topLevelNodes.filter((n) => !n.children || n.children.length === 0);
|
|
434
|
-
const boundaryNodes = topLevelNodes.filter((n) => n.children && n.children.length > 0);
|
|
435
|
-
if (leafNodes.length === 0 || boundaryNodes.length === 0)
|
|
436
|
-
return;
|
|
437
|
-
// Step 1: Align each leaf's Y with its primary cross-boundary target,
|
|
438
|
-
// and nudge X toward the boundary edge nearest to the target
|
|
439
|
-
for (const leaf of leafNodes) {
|
|
440
|
-
const targets = [];
|
|
441
|
-
for (const rel of relationships) {
|
|
442
|
-
let partnerId;
|
|
443
|
-
if (rel.sourceId === leaf.id)
|
|
444
|
-
partnerId = rel.targetId;
|
|
445
|
-
else if (rel.targetId === leaf.id)
|
|
446
|
-
partnerId = rel.sourceId;
|
|
447
|
-
else
|
|
448
|
-
continue;
|
|
449
|
-
const partner = nodeMap.get(partnerId);
|
|
450
|
-
if (!partner?.element.parent)
|
|
451
|
-
continue;
|
|
452
|
-
targets.push({
|
|
453
|
-
x: partner.x + partner.width / 2,
|
|
454
|
-
y: partner.y + partner.height / 2,
|
|
455
|
-
});
|
|
456
|
-
}
|
|
457
|
-
if (targets.length === 0)
|
|
458
|
-
continue;
|
|
459
|
-
// Align Y with the average target Y (center the leaf vertically)
|
|
460
|
-
const avgTargetY = targets.reduce((s, t) => s + t.y, 0) / targets.length;
|
|
461
|
-
leaf.y = avgTargetY - leaf.height / 2;
|
|
462
|
-
// Nudge X: keep leaf outside boundaries but closer to its targets
|
|
463
|
-
const avgTargetX = targets.reduce((s, t) => s + t.x, 0) / targets.length;
|
|
464
|
-
const currentCenterX = leaf.x + leaf.width / 2;
|
|
465
|
-
const nudgedCenterX = currentCenterX + (avgTargetX - currentCenterX) * 0.3;
|
|
466
|
-
leaf.x = nudgedCenterX - leaf.width / 2;
|
|
467
|
-
}
|
|
468
|
-
// Step 2: Ensure leaves don't overlap boundaries
|
|
469
|
-
for (const leaf of leafNodes) {
|
|
470
|
-
const originalCenterX = leaf.x + leaf.width / 2;
|
|
471
|
-
for (const boundary of boundaryNodes) {
|
|
472
|
-
const leafRight = leaf.x + leaf.width;
|
|
473
|
-
const leafLeft = leaf.x;
|
|
474
|
-
const yOverlap = leaf.y < boundary.y + boundary.height + BOUNDARY_PADDING &&
|
|
475
|
-
leaf.y + leaf.height + BOUNDARY_PADDING > boundary.y;
|
|
476
|
-
// Use base gap for left side (no long labels there), dynamic gap for right side
|
|
477
|
-
const leftGap = MIN_EXTERNAL_GAP;
|
|
478
|
-
const rightGap = dynamicGap;
|
|
479
|
-
if (yOverlap && leafRight > boundary.x - leftGap && leafLeft < boundary.x + boundary.width + rightGap) {
|
|
480
|
-
if (originalCenterX < boundary.x + boundary.width / 2) {
|
|
481
|
-
leaf.x = boundary.x - leaf.width - leftGap;
|
|
482
|
-
}
|
|
483
|
-
else {
|
|
484
|
-
leaf.x = boundary.x + boundary.width + rightGap;
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
|
-
// Step 3: Resolve leaf-to-leaf overlaps by pushing apart vertically
|
|
490
|
-
leafNodes.sort((a, b) => a.y - b.y || a.x - b.x);
|
|
491
|
-
for (let i = 0; i < leafNodes.length; i++) {
|
|
492
|
-
for (let j = i + 1; j < leafNodes.length; j++) {
|
|
493
|
-
const a = leafNodes[i];
|
|
494
|
-
const b = leafNodes[j];
|
|
495
|
-
const gap = BOUNDARY_PADDING;
|
|
496
|
-
const xOverlap = a.x < b.x + b.width + gap &&
|
|
497
|
-
a.x + a.width + gap > b.x;
|
|
498
|
-
const yOverlap = a.y < b.y + b.height + gap &&
|
|
499
|
-
a.y + a.height + gap > b.y;
|
|
500
|
-
if (xOverlap && yOverlap) {
|
|
501
|
-
b.y = a.y + a.height + gap;
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
// Step 4: Align peer leaves on the same side of a boundary to share X
|
|
506
|
-
// Group leaves by which side of which boundary they're on
|
|
507
|
-
for (const boundary of boundaryNodes) {
|
|
508
|
-
const boundaryCenterX = boundary.x + boundary.width / 2;
|
|
509
|
-
const leftPeers = leafNodes.filter((n) => n.x + n.width / 2 < boundaryCenterX);
|
|
510
|
-
const rightPeers = leafNodes.filter((n) => n.x + n.width / 2 >= boundaryCenterX);
|
|
511
|
-
// Align left peers to the same X (use the minimum X so none overlap boundary)
|
|
512
|
-
if (leftPeers.length > 1) {
|
|
513
|
-
const alignX = Math.min(...leftPeers.map((n) => n.x));
|
|
514
|
-
for (const peer of leftPeers) {
|
|
515
|
-
peer.x = alignX;
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
// Align right peers to the same X (closest to boundary, not furthest)
|
|
519
|
-
if (rightPeers.length > 1) {
|
|
520
|
-
const alignX = Math.min(...rightPeers.map((n) => n.x));
|
|
521
|
-
// Ensure it's still outside the boundary
|
|
522
|
-
const minX = boundary.x + boundary.width + dynamicGap;
|
|
523
|
-
for (const peer of rightPeers) {
|
|
524
|
-
peer.x = Math.max(alignX, minX);
|
|
525
|
-
}
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
// Step 5: Re-resolve overlaps after peer alignment may have re-introduced them
|
|
529
|
-
leafNodes.sort((a, b) => a.y - b.y || a.x - b.x);
|
|
530
|
-
for (let i = 0; i < leafNodes.length; i++) {
|
|
531
|
-
for (let j = i + 1; j < leafNodes.length; j++) {
|
|
532
|
-
const a = leafNodes[i];
|
|
533
|
-
const b = leafNodes[j];
|
|
534
|
-
const gap = BOUNDARY_PADDING;
|
|
535
|
-
const xOverlap = a.x < b.x + b.width + gap &&
|
|
536
|
-
a.x + a.width + gap > b.x;
|
|
537
|
-
const yOverlap = a.y < b.y + b.height + gap &&
|
|
538
|
-
a.y + a.height + gap > b.y;
|
|
539
|
-
if (xOverlap && yOverlap) {
|
|
540
|
-
b.y = a.y + a.height + gap;
|
|
541
|
-
}
|
|
542
|
-
}
|
|
543
|
-
}
|
|
544
|
-
}
|
|
545
|
-
// =============================================================================
|
|
546
|
-
// MANUAL LAYOUT
|
|
547
|
-
// =============================================================================
|
|
548
|
-
/**
|
|
549
|
-
* Build layout nodes with computed dimensions, applying manual positions.
|
|
550
|
-
* Recursively processes nested elements, computing dimensions and applying positions.
|
|
551
|
-
*/
|
|
552
|
-
function buildLayoutNodesWithManualPositions(elements, positions, options, defaultX, defaultY) {
|
|
553
|
-
const nodes = [];
|
|
554
|
-
const currentX = defaultX;
|
|
555
|
-
let currentY = defaultY;
|
|
556
|
-
for (const element of elements) {
|
|
557
|
-
// Determine this node's position first (needed for boundary size calculation)
|
|
558
|
-
const manualPos = positions[element.id];
|
|
559
|
-
const x = manualPos?.x ?? currentX;
|
|
560
|
-
const y = manualPos?.y ?? currentY;
|
|
561
|
-
// Recursively process children
|
|
562
|
-
let childNodes = [];
|
|
563
|
-
if (element.children.length > 0) {
|
|
564
|
-
// Position children inside the boundary header area
|
|
565
|
-
childNodes = buildLayoutNodesWithManualPositions(element.children, positions, options, BOUNDARY_PADDING, BOUNDARY_HEADER_HEIGHT);
|
|
566
|
-
}
|
|
567
|
-
// Calculate this node's base dimensions
|
|
568
|
-
const baseDimensions = calculateNodeDimensions(element, options);
|
|
569
|
-
// If this is a boundary with children, expand to contain them
|
|
570
|
-
const isBoundary = element.type === 'system' ||
|
|
571
|
-
element.properties.style === 'boundary' ||
|
|
572
|
-
element.children.length > 0;
|
|
573
|
-
let nodeWidth = baseDimensions.width;
|
|
574
|
-
let nodeHeight = baseDimensions.height;
|
|
575
|
-
if (isBoundary && childNodes.length > 0) {
|
|
576
|
-
// Calculate children bounding box
|
|
577
|
-
const childMaxX = Math.max(...childNodes.map((n) => n.x + n.width));
|
|
578
|
-
const childMaxY = Math.max(...childNodes.map((n) => n.y + n.height));
|
|
579
|
-
// Children may have absolute positions (manual) or relative positions (auto-placed).
|
|
580
|
-
// Check if any children have manual positions to determine coordinate mode.
|
|
581
|
-
const hasAbsoluteChildren = childNodes.some((n) => positions[n.id]);
|
|
582
|
-
if (hasAbsoluteChildren) {
|
|
583
|
-
// Children are in absolute coordinates — compute size relative to parent
|
|
584
|
-
nodeWidth = Math.max(baseDimensions.width, childMaxX - x + BOUNDARY_PADDING);
|
|
585
|
-
nodeHeight = Math.max(baseDimensions.height, childMaxY - y + BOUNDARY_PADDING);
|
|
586
|
-
}
|
|
587
|
-
else {
|
|
588
|
-
// Children are in relative coordinates (starting from BOUNDARY_PADDING, BOUNDARY_HEADER_HEIGHT)
|
|
589
|
-
const childBounds = {
|
|
590
|
-
width: childMaxX + BOUNDARY_PADDING,
|
|
591
|
-
height: childMaxY + BOUNDARY_PADDING,
|
|
592
|
-
};
|
|
593
|
-
nodeWidth = Math.max(baseDimensions.width, childBounds.width);
|
|
594
|
-
nodeHeight = Math.max(baseDimensions.height, childBounds.height);
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
const layoutNode = {
|
|
598
|
-
id: element.id,
|
|
599
|
-
x,
|
|
600
|
-
y,
|
|
601
|
-
width: nodeWidth,
|
|
602
|
-
height: nodeHeight,
|
|
603
|
-
element,
|
|
604
|
-
children: childNodes.length > 0 ? childNodes : undefined,
|
|
605
|
-
};
|
|
606
|
-
// Offset children to be inside parent's coordinate space
|
|
607
|
-
// Children with manual positions are already in absolute coordinates,
|
|
608
|
-
// so only offset auto-placed children
|
|
609
|
-
if (layoutNode.children) {
|
|
610
|
-
for (const child of layoutNode.children) {
|
|
611
|
-
if (!positions[child.id]) {
|
|
612
|
-
offsetLayoutNode(child, x, y);
|
|
613
|
-
}
|
|
614
|
-
}
|
|
615
|
-
}
|
|
616
|
-
nodes.push(layoutNode);
|
|
617
|
-
// Update default position for next element without manual position
|
|
618
|
-
currentY += nodeHeight + options.nodePadding;
|
|
619
|
-
}
|
|
620
|
-
return nodes;
|
|
621
|
-
}
|
|
622
|
-
/**
|
|
623
|
-
* Recursively resize boundaries to contain their children.
|
|
624
|
-
* Must be called after children positions are finalized (bottom-up resize).
|
|
625
|
-
* Handles expansion in all four directions — if a child is dragged above
|
|
626
|
-
* or left of the boundary origin, the boundary shifts position and expands.
|
|
627
|
-
*/
|
|
628
|
-
function resizeBoundariesToContainChildren(nodes) {
|
|
629
|
-
for (const node of nodes) {
|
|
630
|
-
if (node.children && node.children.length > 0) {
|
|
631
|
-
// First resize nested boundaries
|
|
632
|
-
resizeBoundariesToContainChildren(node.children);
|
|
633
|
-
// Find the bounding box of all children
|
|
634
|
-
const childMinX = Math.min(...node.children.map((n) => n.x));
|
|
635
|
-
const childMinY = Math.min(...node.children.map((n) => n.y));
|
|
636
|
-
const childMaxX = Math.max(...node.children.map((n) => n.x + n.width));
|
|
637
|
-
const childMaxY = Math.max(...node.children.map((n) => n.y + n.height));
|
|
638
|
-
// Expand leftward/upward if children extend beyond boundary origin
|
|
639
|
-
const requiredLeft = childMinX - BOUNDARY_PADDING;
|
|
640
|
-
const requiredTop = childMinY - BOUNDARY_HEADER_HEIGHT;
|
|
641
|
-
if (requiredLeft < node.x) {
|
|
642
|
-
const shift = node.x - requiredLeft;
|
|
643
|
-
node.width += shift;
|
|
644
|
-
node.x = requiredLeft;
|
|
645
|
-
}
|
|
646
|
-
if (requiredTop < node.y) {
|
|
647
|
-
const shift = node.y - requiredTop;
|
|
648
|
-
node.height += shift;
|
|
649
|
-
node.y = requiredTop;
|
|
650
|
-
}
|
|
651
|
-
// Expand rightward/downward
|
|
652
|
-
const requiredWidth = childMaxX - node.x + BOUNDARY_PADDING;
|
|
653
|
-
const requiredHeight = childMaxY - node.y + BOUNDARY_PADDING;
|
|
654
|
-
node.width = Math.max(node.width, requiredWidth);
|
|
655
|
-
node.height = Math.max(node.height, requiredHeight);
|
|
656
|
-
}
|
|
657
|
-
}
|
|
658
|
-
}
|
|
659
|
-
/**
|
|
660
|
-
* Push sibling nodes apart when they overlap after boundary expansion.
|
|
661
|
-
* Nodes with manual positions are anchored — only unanchored nodes or
|
|
662
|
-
* the node further from center gets pushed.
|
|
663
|
-
*/
|
|
664
|
-
function resolveTopLevelOverlaps(nodes, manualPositions) {
|
|
665
|
-
const gap = BOUNDARY_PADDING;
|
|
666
|
-
// Multiple passes to handle cascading pushes
|
|
667
|
-
for (let pass = 0; pass < 3; pass++) {
|
|
668
|
-
let changed = false;
|
|
669
|
-
for (let i = 0; i < nodes.length; i++) {
|
|
670
|
-
for (let j = i + 1; j < nodes.length; j++) {
|
|
671
|
-
const a = nodes[i];
|
|
672
|
-
const b = nodes[j];
|
|
673
|
-
// Check for overlap (with gap)
|
|
674
|
-
const overlapX = Math.min(a.x + a.width + gap, b.x + b.width + gap) -
|
|
675
|
-
Math.max(a.x, b.x);
|
|
676
|
-
const overlapY = Math.min(a.y + a.height + gap, b.y + b.height + gap) -
|
|
677
|
-
Math.max(a.y, b.y);
|
|
678
|
-
if (overlapX <= 0 || overlapY <= 0)
|
|
679
|
-
continue; // No overlap
|
|
680
|
-
// Decide which node to push: prefer pushing the one without
|
|
681
|
-
// a manual position. If both have manual positions AND neither
|
|
682
|
-
// is a boundary that may have expanded, allow the overlap to
|
|
683
|
-
// avoid jitter. But if one is an expanded boundary, push the other.
|
|
684
|
-
const aAnchored = !!manualPositions[a.id];
|
|
685
|
-
const bAnchored = !!manualPositions[b.id];
|
|
686
|
-
const aBoundary = !!(a.children && a.children.length > 0);
|
|
687
|
-
const bBoundary = !!(b.children && b.children.length > 0);
|
|
688
|
-
let target;
|
|
689
|
-
if (aAnchored && bAnchored && !aBoundary && !bBoundary) {
|
|
690
|
-
continue; // Both anchored leaves — allow overlap to avoid jitter
|
|
691
|
-
}
|
|
692
|
-
else if (aBoundary && !bBoundary) {
|
|
693
|
-
// A is an expanded boundary — push B away
|
|
694
|
-
target = b;
|
|
695
|
-
}
|
|
696
|
-
else if (bBoundary && !aBoundary) {
|
|
697
|
-
target = a;
|
|
698
|
-
}
|
|
699
|
-
else if (aAnchored && !bAnchored) {
|
|
700
|
-
target = b;
|
|
701
|
-
}
|
|
702
|
-
else if (!aAnchored && bAnchored) {
|
|
703
|
-
target = a;
|
|
704
|
-
}
|
|
705
|
-
else {
|
|
706
|
-
// Neither anchored — push the one further right/down
|
|
707
|
-
target = (a.x + a.y > b.x + b.y) ? a : b;
|
|
708
|
-
}
|
|
709
|
-
// Push along the axis of least overlap, away from the other node
|
|
710
|
-
const other = target === b ? a : b;
|
|
711
|
-
if (overlapX < overlapY) {
|
|
712
|
-
const pushDir = (target.x + target.width / 2 > other.x + other.width / 2) ? 1 : -1;
|
|
713
|
-
shiftNodeAndChildren(target, overlapX * pushDir, 0);
|
|
714
|
-
}
|
|
715
|
-
else {
|
|
716
|
-
const pushDir = (target.y + target.height / 2 > other.y + other.height / 2) ? 1 : -1;
|
|
717
|
-
shiftNodeAndChildren(target, 0, overlapY * pushDir);
|
|
718
|
-
}
|
|
719
|
-
changed = true;
|
|
720
|
-
}
|
|
721
|
-
}
|
|
722
|
-
if (!changed)
|
|
723
|
-
break;
|
|
724
|
-
}
|
|
725
|
-
}
|
|
726
|
-
/**
|
|
727
|
-
* Shift a node and all its children by the given delta.
|
|
728
|
-
*/
|
|
729
|
-
function shiftNodeAndChildren(node, dx, dy) {
|
|
730
|
-
node.x += dx;
|
|
731
|
-
node.y += dy;
|
|
732
|
-
if (node.children) {
|
|
733
|
-
for (const child of node.children) {
|
|
734
|
-
shiftNodeAndChildren(child, dx, dy);
|
|
735
|
-
}
|
|
736
|
-
}
|
|
737
|
-
}
|
|
738
|
-
/**
|
|
739
|
-
* Layout a C4 diagram using manual positions instead of dagre auto-layout.
|
|
740
|
-
* Used when the user has positioned elements manually.
|
|
741
|
-
*/
|
|
742
|
-
function layoutWithManualPositions(diagram, options, manualPositions) {
|
|
743
|
-
const topLevelElements = getTopLevelElements(diagram.elements);
|
|
744
|
-
// Build layout nodes with manual positions applied
|
|
745
|
-
const layoutNodes = buildLayoutNodesWithManualPositions(topLevelElements, manualPositions, options, BOUNDARY_PADDING, BOUNDARY_PADDING);
|
|
746
|
-
// Resize boundaries to contain their children (bottom-up)
|
|
747
|
-
resizeBoundariesToContainChildren(layoutNodes);
|
|
748
|
-
// Push sibling nodes apart if boundary expansion caused overlaps
|
|
749
|
-
resolveTopLevelOverlaps(layoutNodes, manualPositions);
|
|
750
|
-
// Flatten all nodes for the result
|
|
751
|
-
const allNodes = flattenLayoutNodes(layoutNodes);
|
|
752
|
-
// Build node lookup for edge calculation
|
|
753
|
-
const nodeMap = new Map();
|
|
754
|
-
for (const node of allNodes) {
|
|
755
|
-
nodeMap.set(node.id, node);
|
|
756
|
-
}
|
|
757
|
-
// Calculate edges using existing function
|
|
758
|
-
const edges = calculateEdges(diagram.relationships, nodeMap);
|
|
759
|
-
// Calculate diagram bounding box (nodes may have negative coordinates
|
|
760
|
-
// when dragged past the top/left edge)
|
|
761
|
-
let minX = Infinity;
|
|
762
|
-
let minY = Infinity;
|
|
763
|
-
let maxX = 0;
|
|
764
|
-
let maxY = 0;
|
|
765
|
-
for (const node of allNodes) {
|
|
766
|
-
minX = Math.min(minX, node.x);
|
|
767
|
-
minY = Math.min(minY, node.y);
|
|
768
|
-
maxX = Math.max(maxX, node.x + node.width);
|
|
769
|
-
maxY = Math.max(maxY, node.y + node.height);
|
|
770
|
-
}
|
|
771
|
-
// Use viewBox origin to handle negative coordinates instead of shifting
|
|
772
|
-
// nodes — this keeps stored positions and rendered positions in sync
|
|
773
|
-
const viewBoxX = Math.min(0, minX - BOUNDARY_PADDING);
|
|
774
|
-
const viewBoxY = Math.min(0, minY - BOUNDARY_PADDING);
|
|
775
|
-
const width = maxX + BOUNDARY_PADDING - viewBoxX;
|
|
776
|
-
const height = maxY + BOUNDARY_PADDING - viewBoxY;
|
|
777
|
-
return {
|
|
778
|
-
nodes: allNodes,
|
|
779
|
-
edges,
|
|
780
|
-
width,
|
|
781
|
-
height,
|
|
782
|
-
viewBoxX,
|
|
783
|
-
viewBoxY,
|
|
784
|
-
};
|
|
785
|
-
}
|
|
786
|
-
// =============================================================================
|
|
787
|
-
// PUBLIC API
|
|
788
|
-
// =============================================================================
|
|
789
|
-
/**
|
|
790
|
-
* Layout a C4 diagram using dagre for auto-positioning,
|
|
791
|
-
* or manual positions if provided.
|
|
792
|
-
*
|
|
793
|
-
* @param diagram - The parsed C4 diagram AST
|
|
794
|
-
* @param options - Layout configuration options
|
|
795
|
-
* @param manualPositions - Optional manual positions for elements (bypasses dagre)
|
|
796
|
-
* @returns Layout result with positioned nodes and routed edges
|
|
797
|
-
*/
|
|
798
|
-
export function layoutC4Diagram(diagram, options, manualPositions) {
|
|
799
|
-
const mergedOptions = {
|
|
800
|
-
...DEFAULT_OPTIONS,
|
|
801
|
-
...options,
|
|
802
|
-
};
|
|
803
|
-
// Use manual layout if positions are provided
|
|
804
|
-
if (manualPositions && Object.keys(manualPositions).length > 0) {
|
|
805
|
-
return layoutWithManualPositions(diagram, mergedOptions, manualPositions);
|
|
806
|
-
}
|
|
807
|
-
const topLevelElements = getTopLevelElements(diagram.elements);
|
|
808
|
-
// Layout top-level elements, using diagram direction if specified
|
|
809
|
-
const layoutNodes = layoutGroup(topLevelElements, diagram.relationships, mergedOptions, diagram.direction);
|
|
810
|
-
// Flatten all nodes for the result
|
|
811
|
-
const allNodes = flattenLayoutNodes(layoutNodes);
|
|
812
|
-
// Build node lookup for edge calculation
|
|
813
|
-
const nodeMap = new Map();
|
|
814
|
-
for (const node of allNodes) {
|
|
815
|
-
nodeMap.set(node.id, node);
|
|
816
|
-
}
|
|
817
|
-
// Post-layout: align non-boundary elements with their cross-boundary targets
|
|
818
|
-
alignCrossBoundaryElements(layoutNodes, diagram.relationships, nodeMap);
|
|
819
|
-
// Calculate edges
|
|
820
|
-
const edges = calculateEdges(diagram.relationships, nodeMap);
|
|
821
|
-
// Calculate total diagram dimensions
|
|
822
|
-
let width = 0;
|
|
823
|
-
let height = 0;
|
|
824
|
-
for (const node of allNodes) {
|
|
825
|
-
width = Math.max(width, node.x + node.width);
|
|
826
|
-
height = Math.max(height, node.y + node.height);
|
|
827
|
-
}
|
|
828
|
-
// Add margin
|
|
829
|
-
width += BOUNDARY_PADDING;
|
|
830
|
-
height += BOUNDARY_PADDING;
|
|
831
|
-
return {
|
|
832
|
-
nodes: allNodes,
|
|
833
|
-
edges,
|
|
834
|
-
width,
|
|
835
|
-
height,
|
|
836
|
-
viewBoxX: 0,
|
|
837
|
-
viewBoxY: 0,
|
|
838
|
-
};
|
|
839
|
-
}
|