@@ -56,6 +56,12 @@ public void Layout(Diagram diagram, Theme theme)
5656 return ;
5757 }
5858
59+ if ( string . Equals ( diagram . DiagramType , "architecture" , StringComparison . OrdinalIgnoreCase ) )
60+ {
61+ LayoutArchitectureDiagram ( diagram , theme , minW , nodeH , hGap , vGap , pad ) ;
62+ return ;
63+ }
64+
5965 // ── Sizing pass ───────────────────────────────────────────────────────
6066 // Compute each node's width from its label so text does not overflow the
6167 // shape. MinNodeWidth remains a floor so short labels ("A", "End") do not
@@ -201,16 +207,210 @@ public void Layout(Diagram diagram, Theme theme)
201207 // own padding (especially the label-height top inset) exceeds DiagramPadding.
202208 // Right/bottom are handled separately by SvgRenderer.ComputeWidth/Height
203209 // including group extents.
204- if ( diagram . Groups . Count > 0 )
210+ ShiftDiagramForGroupPadding ( diagram ) ;
211+ }
212+
213+ private static void LayoutArchitectureDiagram (
214+ Diagram diagram ,
215+ Theme theme ,
216+ double minW ,
217+ double nodeH ,
218+ double hGap ,
219+ double vGap ,
220+ double pad )
221+ {
222+ // ── Sizing pass ───────────────────────────────────────────────────────
223+ foreach ( var node in diagram . Nodes . Values )
224+ {
225+ double fontSize = node . Label . FontSize ?? theme . FontSize ;
226+ double textW = EstimateTextWidth ( node . Label . Text , fontSize ) ;
227+ // Junctions (Shape.Circle, no label) get a small fixed size.
228+ if ( node . Shape == Models . Shape . Circle && string . IsNullOrEmpty ( node . Label . Text ) )
229+ {
230+ node . Width = 20 ;
231+ node . Height = 20 ;
232+ }
233+ else
234+ {
235+ node . Width = Math . Max ( minW , textW + 2 * theme . NodePadding ) ;
236+ node . Height = nodeH ;
237+ }
238+ }
239+
240+ // ── Constraint-based grid positioning ────────────────────────────────
241+ // Build a grid position map using edge port directions as spatial constraints.
242+ // L/R implies horizontal adjacency; T/B implies vertical adjacency.
243+ // We assign (gridCol, gridRow) to each node, then convert to pixel coordinates.
244+
245+ var gridCol = new Dictionary < string , int > ( StringComparer . Ordinal ) ;
246+ var gridRow = new Dictionary < string , int > ( StringComparer . Ordinal ) ;
247+
248+ // Seed the first node (alphabetically for determinism) at (0, 0).
249+ if ( diagram . Nodes . Count > 0 )
250+ {
251+ var firstId = diagram . Nodes . Keys . OrderBy ( k => k , StringComparer . Ordinal ) . First ( ) ;
252+ gridCol [ firstId ] = 0 ;
253+ gridRow [ firstId ] = 0 ;
254+ }
255+
256+ // Process edges iteratively; multiple passes handle chains.
257+ bool changed = true ;
258+ int maxPasses = diagram . Edges . Count + 1 ;
259+ for ( int pass = 0 ; pass < maxPasses && changed ; pass ++ )
205260 {
206- double shiftX = Math . Max ( 0 , - diagram . Groups . Min ( g => g . X ) ) ;
207- double shiftY = Math . Max ( 0 , - diagram . Groups . Min ( g => g . Y ) ) ;
208- if ( shiftX > 0 || shiftY > 0 )
261+ changed = false ;
262+ foreach ( var edge in diagram . Edges )
209263 {
210- foreach ( var n in diagram . Nodes . Values ) { n . X += shiftX ; n . Y += shiftY ; }
211- foreach ( var g in diagram . Groups ) { g . X += shiftX ; g . Y += shiftY ; }
264+ if ( ! edge . Metadata . TryGetValue ( "source:port" , out var srcPortObj )
265+ || ! edge . Metadata . TryGetValue ( "target:port" , out var dstPortObj ) )
266+ continue ;
267+
268+ var srcPort = srcPortObj is string s1 ? s1 : srcPortObj ? . ToString ( ) ?? string . Empty ;
269+ var dstPort = dstPortObj is string s2 ? s2 : dstPortObj ? . ToString ( ) ?? string . Empty ;
270+ var srcId = edge . SourceId ;
271+ var dstId = edge . TargetId ;
272+
273+ bool hasSrc = gridCol . ContainsKey ( srcId ) ;
274+ bool hasDst = gridCol . ContainsKey ( dstId ) ;
275+
276+ if ( ! hasSrc && ! hasDst )
277+ continue ;
278+
279+ // Determine the relative offset implied by the port pair.
280+ // srcPort is the port on the source side; dstPort is the port on the target side.
281+ // Example: src:R -- L:dst → src is to the left of dst → dst.col = src.col + 1
282+ int dColOffset = 0 , dRowOffset = 0 ;
283+ if ( ( srcPort == "R" && dstPort == "L" ) || ( srcPort == "L" && dstPort == "R" ) )
284+ dColOffset = srcPort == "R" ? 1 : - 1 ;
285+ else if ( ( srcPort == "B" && dstPort == "T" ) || ( srcPort == "T" && dstPort == "B" ) )
286+ dRowOffset = srcPort == "B" ? 1 : - 1 ;
287+
288+ if ( dColOffset == 0 && dRowOffset == 0 )
289+ continue ; // 90° edge or unrecognised ports — skip for now
290+
291+ if ( hasSrc && ! hasDst )
292+ {
293+ gridCol [ dstId ] = gridCol [ srcId ] + dColOffset ;
294+ gridRow [ dstId ] = gridRow [ srcId ] + dRowOffset ;
295+ changed = true ;
296+ }
297+ else if ( hasDst && ! hasSrc )
298+ {
299+ gridCol [ srcId ] = gridCol [ dstId ] - dColOffset ;
300+ gridRow [ srcId ] = gridRow [ dstId ] - dRowOffset ;
301+ changed = true ;
302+ }
303+ }
304+ }
305+
306+ // Assign any still-unpositioned nodes using BFS from already-positioned nodes,
307+ // or to a new row at the bottom if completely disconnected.
308+ int nextFallbackRow = gridRow . Count > 0 ? gridRow . Values . Max ( ) + 2 : 0 ;
309+ int fallbackCol = 0 ;
310+ foreach ( var id in diagram . Nodes . Keys . OrderBy ( k => k , StringComparer . Ordinal ) )
311+ {
312+ if ( ! gridCol . ContainsKey ( id ) )
313+ {
314+ gridCol [ id ] = fallbackCol ++ ;
315+ gridRow [ id ] = nextFallbackRow ;
212316 }
213317 }
318+
319+ // ── Shift grid so minimum col/row is 0 ───────────────────────────────
320+ int minCol = gridCol . Values . Min ( ) ;
321+ int minRow = gridRow . Values . Min ( ) ;
322+ foreach ( var id in gridCol . Keys . ToList ( ) )
323+ {
324+ gridCol [ id ] -= minCol ;
325+ gridRow [ id ] -= minRow ;
326+ }
327+
328+ // ── Compute per-column widths and per-row heights ─────────────────────
329+ int totalCols = gridCol . Values . Max ( ) + 1 ;
330+ int totalRows = gridRow . Values . Max ( ) + 1 ;
331+
332+ var colWidths = new double [ totalCols ] ;
333+ var rowHeights = new double [ totalRows ] ;
334+
335+ foreach ( var ( id , node ) in diagram . Nodes )
336+ {
337+ int col = gridCol [ id ] ;
338+ int row = gridRow [ id ] ;
339+ colWidths [ col ] = Math . Max ( colWidths [ col ] , node . Width ) ;
340+ rowHeights [ row ] = Math . Max ( rowHeights [ row ] , node . Height ) ;
341+ }
342+
343+ // ── Pixel positions ───────────────────────────────────────────────────
344+ var colX = new double [ totalCols ] ;
345+ double runX = pad ;
346+ for ( int c = 0 ; c < totalCols ; c ++ )
347+ {
348+ colX [ c ] = runX ;
349+ runX += colWidths [ c ] + hGap ;
350+ }
351+
352+ var rowY = new double [ totalRows ] ;
353+ double runY = pad ;
354+ for ( int r = 0 ; r < totalRows ; r ++ )
355+ {
356+ rowY [ r ] = runY ;
357+ runY += rowHeights [ r ] + vGap ;
358+ }
359+
360+ foreach ( var ( id , node ) in diagram . Nodes )
361+ {
362+ int col = gridCol [ id ] ;
363+ int row = gridRow [ id ] ;
364+ node . X = colX [ col ] + ( colWidths [ col ] - node . Width ) / 2 ;
365+ node . Y = rowY [ row ] + ( rowHeights [ row ] - node . Height ) / 2 ;
366+ }
367+
368+ // ── Group bounding boxes ──────────────────────────────────────────────
369+ // Recursively collect all descendant node IDs for a group (including nested groups).
370+ IEnumerable < string > AllNodeIds ( Group g )
371+ {
372+ foreach ( var nid in g . ChildNodeIds )
373+ yield return nid ;
374+
375+ foreach ( var cgid in g . ChildGroupIds )
376+ {
377+ var cg = diagram . Groups . FirstOrDefault ( x => x . Id == cgid ) ;
378+ if ( cg is not null )
379+ foreach ( var nid in AllNodeIds ( cg ) )
380+ yield return nid ;
381+ }
382+ }
383+
384+ foreach ( var group in diagram . Groups )
385+ {
386+ var members = AllNodeIds ( group )
387+ . Where ( diagram . Nodes . ContainsKey )
388+ . Select ( id => diagram . Nodes [ id ] )
389+ . ToList ( ) ;
390+
391+ if ( members . Count == 0 )
392+ {
393+ group . X = 0 ; group . Y = 0 ; group . Width = 0 ; group . Height = 0 ;
394+ continue ;
395+ }
396+
397+ double minX = members . Min ( n => n . X ) ;
398+ double minY = members . Min ( n => n . Y ) ;
399+ double maxX = members . Max ( n => n . X + n . Width ) ;
400+ double maxY = members . Max ( n => n . Y + n . Height ) ;
401+
402+ double sidePad = theme . NodePadding ;
403+ bool labeled = ! string . IsNullOrWhiteSpace ( group . Label . Text ) ;
404+ double topPad = labeled ? sidePad + theme . FontSize + 8 : sidePad ;
405+
406+ group . X = minX - sidePad ;
407+ group . Y = minY - topPad ;
408+ group . Width = ( maxX - minX ) + 2 * sidePad ;
409+ group . Height = ( maxY - minY ) + topPad + sidePad ;
410+ }
411+
412+ // Shift whole diagram if any group extends into negative space.
413+ ShiftDiagramForGroupPadding ( diagram ) ;
214414 }
215415
216416 private static void LayoutBlockDiagram (
@@ -296,6 +496,24 @@ private static double EstimateTextWidth(string? text, double fontSize)
296496 return text . Length * fontSize * AvgGlyphAdvanceEm ;
297497 }
298498
499+ /// <summary>
500+ /// Shifts all nodes and groups so that no group extends into negative coordinate space.
501+ /// Call this after group bounding boxes have been computed.
502+ /// </summary>
503+ private static void ShiftDiagramForGroupPadding ( Diagram diagram )
504+ {
505+ if ( diagram . Groups . Count == 0 )
506+ return ;
507+
508+ double shiftX = Math . Max ( 0 , - diagram . Groups . Min ( g => g . X ) ) ;
509+ double shiftY = Math . Max ( 0 , - diagram . Groups . Min ( g => g . Y ) ) ;
510+ if ( shiftX > 0 || shiftY > 0 )
511+ {
512+ foreach ( var n in diagram . Nodes . Values ) { n . X += shiftX ; n . Y += shiftY ; }
513+ foreach ( var g in diagram . Groups ) { g . X += shiftX ; g . Y += shiftY ; }
514+ }
515+ }
516+
299517 // ── Private helpers ───────────────────────────────────────────────────────
300518
301519 /// <summary>
0 commit comments