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