@liminis/editor 0.1.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. package/LICENSE +34 -0
  2. package/README.md +376 -0
  3. package/dist/annotations/anchor-align.d.ts +50 -0
  4. package/dist/annotations/anchor-align.js +74 -0
  5. package/dist/annotations/anchor-model.d.ts +81 -0
  6. package/dist/annotations/anchor-model.js +80 -0
  7. package/dist/annotations/anchor-resolver.d.ts +92 -0
  8. package/dist/annotations/anchor-resolver.js +231 -0
  9. package/dist/annotations/block-structure.d.ts +65 -0
  10. package/dist/annotations/block-structure.js +113 -0
  11. package/dist/annotations/types.d.ts +159 -0
  12. package/dist/annotations/types.js +50 -0
  13. package/dist/annotations.d.ts +25 -0
  14. package/dist/annotations.js +27 -0
  15. package/dist/app/App.d.ts +64 -0
  16. package/dist/app/App.js +180 -0
  17. package/dist/app/editor/AmbientCorrectionPlugin.d.ts +18 -0
  18. package/dist/app/editor/AmbientCorrectionPlugin.js +185 -0
  19. package/dist/app/editor/AnchorScrollPlugin.d.ts +16 -0
  20. package/dist/app/editor/AnchorScrollPlugin.js +134 -0
  21. package/dist/app/editor/AnnotationMarkerPlugin.d.ts +24 -0
  22. package/dist/app/editor/AnnotationMarkerPlugin.js +224 -0
  23. package/dist/app/editor/AnnotationPlugin.d.ts +42 -0
  24. package/dist/app/editor/AnnotationPlugin.js +135 -0
  25. package/dist/app/editor/AnnotationSurface.d.ts +34 -0
  26. package/dist/app/editor/AnnotationSurface.js +115 -0
  27. package/dist/app/editor/AssetContext.d.ts +20 -0
  28. package/dist/app/editor/AssetContext.js +81 -0
  29. package/dist/app/editor/BlockClickPlugin.d.ts +8 -0
  30. package/dist/app/editor/BlockClickPlugin.js +126 -0
  31. package/dist/app/editor/CodeBlockPlugin.d.ts +1 -0
  32. package/dist/app/editor/CodeBlockPlugin.js +219 -0
  33. package/dist/app/editor/CorrectionPanelPlugin.d.ts +14 -0
  34. package/dist/app/editor/CorrectionPanelPlugin.js +333 -0
  35. package/dist/app/editor/DragHandlePlugin.d.ts +1 -0
  36. package/dist/app/editor/DragHandlePlugin.js +300 -0
  37. package/dist/app/editor/Editor.d.ts +84 -0
  38. package/dist/app/editor/Editor.js +539 -0
  39. package/dist/app/editor/FrontmatterPlugin.d.ts +19 -0
  40. package/dist/app/editor/FrontmatterPlugin.js +301 -0
  41. package/dist/app/editor/ImageModal.d.ts +13 -0
  42. package/dist/app/editor/ImageModal.js +267 -0
  43. package/dist/app/editor/ImagePlugin.d.ts +3 -0
  44. package/dist/app/editor/ImagePlugin.js +187 -0
  45. package/dist/app/editor/LinkClickPlugin.d.ts +23 -0
  46. package/dist/app/editor/LinkClickPlugin.js +90 -0
  47. package/dist/app/editor/MarkdownShortcutsPlugin.d.ts +49 -0
  48. package/dist/app/editor/MarkdownShortcutsPlugin.js +373 -0
  49. package/dist/app/editor/OrderedTaskListPlugin.d.ts +21 -0
  50. package/dist/app/editor/OrderedTaskListPlugin.js +78 -0
  51. package/dist/app/editor/SearchPlugin.d.ts +1 -0
  52. package/dist/app/editor/SearchPlugin.js +379 -0
  53. package/dist/app/editor/SelectionContextMenuPlugin.d.ts +29 -0
  54. package/dist/app/editor/SelectionContextMenuPlugin.js +160 -0
  55. package/dist/app/editor/SlashMenu.d.ts +11 -0
  56. package/dist/app/editor/SlashMenu.js +494 -0
  57. package/dist/app/editor/SlashMenuPlugin.d.ts +1 -0
  58. package/dist/app/editor/SlashMenuPlugin.js +168 -0
  59. package/dist/app/editor/TableActionsPlugin.d.ts +1 -0
  60. package/dist/app/editor/TableActionsPlugin.js +398 -0
  61. package/dist/app/editor/TogglePlugin.d.ts +1 -0
  62. package/dist/app/editor/TogglePlugin.js +159 -0
  63. package/dist/app/editor/Toolbar.d.ts +10 -0
  64. package/dist/app/editor/Toolbar.js +263 -0
  65. package/dist/app/editor/WikiLinkExistencePlugin.d.ts +17 -0
  66. package/dist/app/editor/WikiLinkExistencePlugin.js +104 -0
  67. package/dist/app/editor/WikiLinkFormatPlugin.d.ts +17 -0
  68. package/dist/app/editor/WikiLinkFormatPlugin.js +203 -0
  69. package/dist/app/editor/annotation-marks.d.ts +177 -0
  70. package/dist/app/editor/annotation-marks.js +750 -0
  71. package/dist/app/editor/annotationCommands.d.ts +13 -0
  72. package/dist/app/editor/annotationCommands.js +11 -0
  73. package/dist/app/editor/c4/C4InteractiveRenderer.d.ts +35 -0
  74. package/dist/app/editor/c4/C4InteractiveRenderer.js +299 -0
  75. package/dist/app/editor/c4/edge-clipping.d.ts +24 -0
  76. package/dist/app/editor/c4/edge-clipping.js +139 -0
  77. package/dist/app/editor/c4/hooks/useC4DiagramDrag.d.ts +38 -0
  78. package/dist/app/editor/c4/hooks/useC4DiagramDrag.js +112 -0
  79. package/dist/app/editor/c4/layout.d.ts +25 -0
  80. package/dist/app/editor/c4/layout.js +839 -0
  81. package/dist/app/editor/c4/parser.d.ts +19 -0
  82. package/dist/app/editor/c4/parser.js +410 -0
  83. package/dist/app/editor/c4/render-to-string.d.ts +24 -0
  84. package/dist/app/editor/c4/render-to-string.js +34 -0
  85. package/dist/app/editor/c4/renderer.d.ts +64 -0
  86. package/dist/app/editor/c4/renderer.js +569 -0
  87. package/dist/app/editor/c4/types.d.ts +203 -0
  88. package/dist/app/editor/c4/types.js +43 -0
  89. package/dist/app/editor/correction-yaml.d.ts +10 -0
  90. package/dist/app/editor/correction-yaml.js +40 -0
  91. package/dist/app/editor/editorNodes.d.ts +9 -0
  92. package/dist/app/editor/editorNodes.js +36 -0
  93. package/dist/app/editor/index.d.ts +1 -0
  94. package/dist/app/editor/index.js +1 -0
  95. package/dist/app/editor/mark-overlap-resolver.d.ts +6 -0
  96. package/dist/app/editor/mark-overlap-resolver.js +45 -0
  97. package/dist/app/editor/nodes/C4Component.d.ts +14 -0
  98. package/dist/app/editor/nodes/C4Component.js +284 -0
  99. package/dist/app/editor/nodes/C4Node.d.ts +32 -0
  100. package/dist/app/editor/nodes/C4Node.js +112 -0
  101. package/dist/app/editor/nodes/CalloutNode.d.ts +23 -0
  102. package/dist/app/editor/nodes/CalloutNode.js +94 -0
  103. package/dist/app/editor/nodes/CustomLinkNode.d.ts +57 -0
  104. package/dist/app/editor/nodes/CustomLinkNode.js +209 -0
  105. package/dist/app/editor/nodes/CustomListItemNode.d.ts +46 -0
  106. package/dist/app/editor/nodes/CustomListItemNode.js +120 -0
  107. package/dist/app/editor/nodes/CustomListNode.d.ts +27 -0
  108. package/dist/app/editor/nodes/CustomListNode.js +54 -0
  109. package/dist/app/editor/nodes/DefinitionListNode.d.ts +44 -0
  110. package/dist/app/editor/nodes/DefinitionListNode.js +151 -0
  111. package/dist/app/editor/nodes/DiagramContextMenu.d.ts +45 -0
  112. package/dist/app/editor/nodes/DiagramContextMenu.js +116 -0
  113. package/dist/app/editor/nodes/EquationComponent.d.ts +17 -0
  114. package/dist/app/editor/nodes/EquationComponent.js +130 -0
  115. package/dist/app/editor/nodes/EquationNode.d.ts +43 -0
  116. package/dist/app/editor/nodes/EquationNode.js +179 -0
  117. package/dist/app/editor/nodes/FootnoteNode.d.ts +40 -0
  118. package/dist/app/editor/nodes/FootnoteNode.js +151 -0
  119. package/dist/app/editor/nodes/FrontmatterNode.d.ts +24 -0
  120. package/dist/app/editor/nodes/FrontmatterNode.js +88 -0
  121. package/dist/app/editor/nodes/HorizontalRuleNode.d.ts +17 -0
  122. package/dist/app/editor/nodes/HorizontalRuleNode.js +57 -0
  123. package/dist/app/editor/nodes/HtmlNode.d.ts +32 -0
  124. package/dist/app/editor/nodes/HtmlNode.js +121 -0
  125. package/dist/app/editor/nodes/ImageComponent.d.ts +11 -0
  126. package/dist/app/editor/nodes/ImageComponent.js +228 -0
  127. package/dist/app/editor/nodes/ImageNode.d.ts +53 -0
  128. package/dist/app/editor/nodes/ImageNode.js +183 -0
  129. package/dist/app/editor/nodes/ListItemParagraphBreakNode.d.ts +42 -0
  130. package/dist/app/editor/nodes/ListItemParagraphBreakNode.js +95 -0
  131. package/dist/app/editor/nodes/MermaidComponent.d.ts +11 -0
  132. package/dist/app/editor/nodes/MermaidComponent.js +216 -0
  133. package/dist/app/editor/nodes/MermaidNode.d.ts +27 -0
  134. package/dist/app/editor/nodes/MermaidNode.js +96 -0
  135. package/dist/app/editor/nodes/ToggleNode.d.ts +56 -0
  136. package/dist/app/editor/nodes/ToggleNode.js +237 -0
  137. package/dist/app/editor/nodes/diagram-context-menu.d.ts +11 -0
  138. package/dist/app/editor/nodes/diagram-context-menu.js +70 -0
  139. package/dist/app/editor/nodes/diagram-utils.d.ts +13 -0
  140. package/dist/app/editor/nodes/diagram-utils.js +29 -0
  141. package/dist/app/editor/nodes/index.d.ts +30 -0
  142. package/dist/app/editor/nodes/index.js +17 -0
  143. package/dist/app/editor/utils.d.ts +2 -0
  144. package/dist/app/editor/utils.js +17 -0
  145. package/dist/app/mapper/index.d.ts +3 -0
  146. package/dist/app/mapper/index.js +2 -0
  147. package/dist/app/mapper/lexicalToMdast.d.ts +62 -0
  148. package/dist/app/mapper/lexicalToMdast.js +1937 -0
  149. package/dist/app/mapper/mdastToLexical.d.ts +31 -0
  150. package/dist/app/mapper/mdastToLexical.js +1075 -0
  151. package/dist/contract.d.ts +8 -0
  152. package/dist/contract.js +8 -0
  153. package/dist/headless.d.ts +40 -0
  154. package/dist/headless.js +45 -0
  155. package/dist/host/context.d.ts +20 -0
  156. package/dist/host/context.js +24 -0
  157. package/dist/host/defaults.d.ts +21 -0
  158. package/dist/host/defaults.js +47 -0
  159. package/dist/host/messages.d.ts +26 -0
  160. package/dist/host/messages.js +47 -0
  161. package/dist/host/types.d.ts +75 -0
  162. package/dist/host/types.js +13 -0
  163. package/dist/index.d.ts +35 -0
  164. package/dist/index.js +51 -0
  165. package/dist/markdown/annotate-sentinels.d.ts +42 -0
  166. package/dist/markdown/annotate-sentinels.js +86 -0
  167. package/dist/markdown/parse.d.ts +56 -0
  168. package/dist/markdown/parse.js +516 -0
  169. package/dist/markdown/stringify.d.ts +7 -0
  170. package/dist/markdown/stringify.js +439 -0
  171. package/dist/markdown/vendor/mdast-util-wiki-link/LICENSE +21 -0
  172. package/dist/markdown/vendor/mdast-util-wiki-link/README.md +41 -0
  173. package/dist/markdown/vendor/mdast-util-wiki-link/from-markdown.d.ts +72 -0
  174. package/dist/markdown/vendor/mdast-util-wiki-link/from-markdown.js +127 -0
  175. package/dist/markdown/vendor/mdast-util-wiki-link/index.d.ts +8 -0
  176. package/dist/markdown/vendor/mdast-util-wiki-link/index.js +6 -0
  177. package/dist/markdown/vendor/mdast-util-wiki-link/to-markdown.d.ts +17 -0
  178. package/dist/markdown/vendor/mdast-util-wiki-link/to-markdown.js +33 -0
  179. package/dist/markdown.d.ts +28 -0
  180. package/dist/markdown.js +44 -0
  181. package/dist/mathjax-config.d.ts +89 -0
  182. package/dist/mathjax-config.js +133 -0
  183. package/dist/nodes.d.ts +34 -0
  184. package/dist/nodes.js +34 -0
  185. package/dist/stores/correctionStore.d.ts +15 -0
  186. package/dist/stores/correctionStore.js +12 -0
  187. package/dist/stores/editorStore.d.ts +16 -0
  188. package/dist/stores/editorStore.js +27 -0
  189. package/dist/styles.css +2477 -0
  190. package/dist/types.d.ts +296 -0
  191. package/dist/types.js +91 -0
  192. package/dist/utils/file-types.d.ts +10 -0
  193. package/dist/utils/file-types.js +46 -0
  194. package/docs/annotations.md +134 -0
  195. package/docs/decisions/README.md +72 -0
  196. package/docs/decisions/adr-002.md +27 -0
  197. package/docs/decisions/adr-007.md +27 -0
  198. package/docs/decisions/adr-010.md +26 -0
  199. package/docs/decisions/adr-024.md +124 -0
  200. package/docs/decisions/adr-024b.md +0 -0
  201. package/docs/decisions/adr-025.md +103 -0
  202. package/docs/decisions/adr-027.md +66 -0
  203. package/docs/decisions/adr-057.md +67 -0
  204. package/docs/decisions/adr-070.md +131 -0
  205. package/docs/decisions/adr-075.md +410 -0
  206. package/docs/decisions/adr-076.md +183 -0
  207. package/docs/decisions/adr-077.md +410 -0
  208. package/docs/decisions/adr-078.md +243 -0
  209. package/docs/decisions/adr-079.md +167 -0
  210. package/docs/decisions/adr-080.md +325 -0
  211. package/docs/decisions/adr-081.md +108 -0
  212. package/docs/decisions/adr-082.md +123 -0
  213. package/docs/decisions/adr-083.md +204 -0
  214. package/docs/decisions/adr-084.md +179 -0
  215. package/docs/editor-api.md +106 -0
  216. package/docs/markdown-pipeline.md +282 -0
  217. package/docs/provenance.md +106 -0
  218. package/docs/zusammen-editor-capability-map.md +232 -0
  219. package/package.json +175 -0
@@ -0,0 +1,839 @@
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
+ }