@@ -62,6 +62,18 @@ public void Layout(Diagram diagram, Theme theme)
6262 return ;
6363 }
6464
65+ if ( string . Equals ( diagram . DiagramType , "timeline" , StringComparison . OrdinalIgnoreCase ) )
66+ {
67+ LayoutTimelineDiagram ( diagram , theme , minW , nodeH , hGap , vGap , pad ) ;
68+ return ;
69+ }
70+
71+ if ( string . Equals ( diagram . DiagramType , "architecture" , StringComparison . OrdinalIgnoreCase ) )
72+ {
73+ LayoutArchitectureDiagram ( diagram , theme , minW , nodeH , hGap , vGap , pad ) ;
74+ return ;
75+ }
76+
6577 // ── Sizing pass ───────────────────────────────────────────────────────
6678 // Compute each node's width from its label so text does not overflow the
6779 // shape. MinNodeWidth remains a floor so short labels ("A", "End") do not
@@ -207,16 +219,210 @@ public void Layout(Diagram diagram, Theme theme)
207219 // own padding (especially the label-height top inset) exceeds DiagramPadding.
208220 // Right/bottom are handled separately by SvgRenderer.ComputeWidth/Height
209221 // including group extents.
210- if ( diagram . Groups . Count > 0 )
222+ ShiftDiagramForGroupPadding ( diagram ) ;
223+ }
224+
225+ private static void LayoutArchitectureDiagram (
226+ Diagram diagram ,
227+ Theme theme ,
228+ double minW ,
229+ double nodeH ,
230+ double hGap ,
231+ double vGap ,
232+ double pad )
233+ {
234+ // ── Sizing pass ───────────────────────────────────────────────────────
235+ foreach ( var node in diagram . Nodes . Values )
236+ {
237+ double fontSize = node . Label . FontSize ?? theme . FontSize ;
238+ double textW = EstimateTextWidth ( node . Label . Text , fontSize ) ;
239+ // Junctions (Shape.Circle, no label) get a small fixed size.
240+ if ( node . Shape == Models . Shape . Circle && string . IsNullOrEmpty ( node . Label . Text ) )
241+ {
242+ node . Width = 20 ;
243+ node . Height = 20 ;
244+ }
245+ else
246+ {
247+ node . Width = Math . Max ( minW , textW + 2 * theme . NodePadding ) ;
248+ node . Height = nodeH ;
249+ }
250+ }
251+
252+ // ── Constraint-based grid positioning ────────────────────────────────
253+ // Build a grid position map using edge port directions as spatial constraints.
254+ // L/R implies horizontal adjacency; T/B implies vertical adjacency.
255+ // We assign (gridCol, gridRow) to each node, then convert to pixel coordinates.
256+
257+ var gridCol = new Dictionary < string , int > ( StringComparer . Ordinal ) ;
258+ var gridRow = new Dictionary < string , int > ( StringComparer . Ordinal ) ;
259+
260+ // Seed the first node (alphabetically for determinism) at (0, 0).
261+ if ( diagram . Nodes . Count > 0 )
262+ {
263+ var firstId = diagram . Nodes . Keys . OrderBy ( k => k , StringComparer . Ordinal ) . First ( ) ;
264+ gridCol [ firstId ] = 0 ;
265+ gridRow [ firstId ] = 0 ;
266+ }
267+
268+ // Process edges iteratively; multiple passes handle chains.
269+ bool changed = true ;
270+ int maxPasses = diagram . Edges . Count + 1 ;
271+ for ( int pass = 0 ; pass < maxPasses && changed ; pass ++ )
272+ {
273+ changed = false ;
274+ foreach ( var edge in diagram . Edges )
275+ {
276+ if ( ! edge . Metadata . TryGetValue ( "source:port" , out var srcPortObj )
277+ || ! edge . Metadata . TryGetValue ( "target:port" , out var dstPortObj ) )
278+ continue ;
279+
280+ var srcPort = srcPortObj is string s1 ? s1 : srcPortObj ? . ToString ( ) ?? string . Empty ;
281+ var dstPort = dstPortObj is string s2 ? s2 : dstPortObj ? . ToString ( ) ?? string . Empty ;
282+ var srcId = edge . SourceId ;
283+ var dstId = edge . TargetId ;
284+
285+ bool hasSrc = gridCol . ContainsKey ( srcId ) ;
286+ bool hasDst = gridCol . ContainsKey ( dstId ) ;
287+
288+ if ( ! hasSrc && ! hasDst )
289+ continue ;
290+
291+ // Determine the relative offset implied by the port pair.
292+ // srcPort is the port on the source side; dstPort is the port on the target side.
293+ // Example: src:R -- L:dst → src is to the left of dst → dst.col = src.col + 1
294+ int dColOffset = 0 , dRowOffset = 0 ;
295+ if ( ( srcPort == "R" && dstPort == "L" ) || ( srcPort == "L" && dstPort == "R" ) )
296+ dColOffset = srcPort == "R" ? 1 : - 1 ;
297+ else if ( ( srcPort == "B" && dstPort == "T" ) || ( srcPort == "T" && dstPort == "B" ) )
298+ dRowOffset = srcPort == "B" ? 1 : - 1 ;
299+
300+ if ( dColOffset == 0 && dRowOffset == 0 )
301+ continue ; // 90° edge or unrecognised ports — skip for now
302+
303+ if ( hasSrc && ! hasDst )
304+ {
305+ gridCol [ dstId ] = gridCol [ srcId ] + dColOffset ;
306+ gridRow [ dstId ] = gridRow [ srcId ] + dRowOffset ;
307+ changed = true ;
308+ }
309+ else if ( hasDst && ! hasSrc )
310+ {
311+ gridCol [ srcId ] = gridCol [ dstId ] - dColOffset ;
312+ gridRow [ srcId ] = gridRow [ dstId ] - dRowOffset ;
313+ changed = true ;
314+ }
315+ }
316+ }
317+
318+ // Assign any still-unpositioned nodes using BFS from already-positioned nodes,
319+ // or to a new row at the bottom if completely disconnected.
320+ int nextFallbackRow = gridRow . Count > 0 ? gridRow . Values . Max ( ) + 2 : 0 ;
321+ int fallbackCol = 0 ;
322+ foreach ( var id in diagram . Nodes . Keys . OrderBy ( k => k , StringComparer . Ordinal ) )
323+ {
324+ if ( ! gridCol . ContainsKey ( id ) )
325+ {
326+ gridCol [ id ] = fallbackCol ++ ;
327+ gridRow [ id ] = nextFallbackRow ;
328+ }
329+ }
330+
331+ // ── Shift grid so minimum col/row is 0 ───────────────────────────────
332+ int minCol = gridCol . Values . Min ( ) ;
333+ int minRow = gridRow . Values . Min ( ) ;
334+ foreach ( var id in gridCol . Keys . ToList ( ) )
335+ {
336+ gridCol [ id ] -= minCol ;
337+ gridRow [ id ] -= minRow ;
338+ }
339+
340+ // ── Compute per-column widths and per-row heights ─────────────────────
341+ int totalCols = gridCol . Values . Max ( ) + 1 ;
342+ int totalRows = gridRow . Values . Max ( ) + 1 ;
343+
344+ var colWidths = new double [ totalCols ] ;
345+ var rowHeights = new double [ totalRows ] ;
346+
347+ foreach ( var ( id , node ) in diagram . Nodes )
348+ {
349+ int col = gridCol [ id ] ;
350+ int row = gridRow [ id ] ;
351+ colWidths [ col ] = Math . Max ( colWidths [ col ] , node . Width ) ;
352+ rowHeights [ row ] = Math . Max ( rowHeights [ row ] , node . Height ) ;
353+ }
354+
355+ // ── Pixel positions ───────────────────────────────────────────────────
356+ var colX = new double [ totalCols ] ;
357+ double runX = pad ;
358+ for ( int c = 0 ; c < totalCols ; c ++ )
359+ {
360+ colX [ c ] = runX ;
361+ runX += colWidths [ c ] + hGap ;
362+ }
363+
364+ var rowY = new double [ totalRows ] ;
365+ double runY = pad ;
366+ for ( int r = 0 ; r < totalRows ; r ++ )
367+ {
368+ rowY [ r ] = runY ;
369+ runY += rowHeights [ r ] + vGap ;
370+ }
371+
372+ foreach ( var ( id , node ) in diagram . Nodes )
373+ {
374+ int col = gridCol [ id ] ;
375+ int row = gridRow [ id ] ;
376+ node . X = colX [ col ] + ( colWidths [ col ] - node . Width ) / 2 ;
377+ node . Y = rowY [ row ] + ( rowHeights [ row ] - node . Height ) / 2 ;
378+ }
379+
380+ // ── Group bounding boxes ──────────────────────────────────────────────
381+ // Recursively collect all descendant node IDs for a group (including nested groups).
382+ IEnumerable < string > AllNodeIds ( Group g )
383+ {
384+ foreach ( var nid in g . ChildNodeIds )
385+ yield return nid ;
386+
387+ foreach ( var cgid in g . ChildGroupIds )
388+ {
389+ var cg = diagram . Groups . FirstOrDefault ( x => x . Id == cgid ) ;
390+ if ( cg is not null )
391+ foreach ( var nid in AllNodeIds ( cg ) )
392+ yield return nid ;
393+ }
394+ }
395+
396+ foreach ( var group in diagram . Groups )
211397 {
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 )
398+ var members = AllNodeIds ( group )
399+ . Where ( diagram . Nodes . ContainsKey )
400+ . Select ( id => diagram . Nodes [ id ] )
401+ . ToList ( ) ;
402+
403+ if ( members . Count == 0 )
215404 {
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 ; }
405+ group . X = 0 ; group . Y = 0 ; group . Width = 0 ; group . Height = 0 ;
406+ continue ;
218407 }
408+
409+ double minX = members . Min ( n => n . X ) ;
410+ double minY = members . Min ( n => n . Y ) ;
411+ double maxX = members . Max ( n => n . X + n . Width ) ;
412+ double maxY = members . Max ( n => n . Y + n . Height ) ;
413+
414+ double sidePad = theme . NodePadding ;
415+ bool labeled = ! string . IsNullOrWhiteSpace ( group . Label . Text ) ;
416+ double topPad = labeled ? sidePad + theme . FontSize + 8 : sidePad ;
417+
418+ group . X = minX - sidePad ;
419+ group . Y = minY - topPad ;
420+ group . Width = ( maxX - minX ) + 2 * sidePad ;
421+ group . Height = ( maxY - minY ) + topPad + sidePad ;
219422 }
423+
424+ // Shift whole diagram if any group extends into negative space.
425+ ShiftDiagramForGroupPadding ( diagram ) ;
220426 }
221427
222428 private static void LayoutSequenceDiagram (
@@ -344,6 +550,78 @@ private static void LayoutBlockDiagram(
344550 }
345551 }
346552
553+ private static void LayoutTimelineDiagram (
554+ Diagram diagram ,
555+ Theme theme ,
556+ double minW ,
557+ double nodeH ,
558+ double hGap ,
559+ double vGap ,
560+ double pad )
561+ {
562+ // Size all nodes from their labels.
563+ foreach ( var node in diagram . Nodes . Values )
564+ {
565+ double fontSize = node . Label . FontSize ?? theme . FontSize ;
566+ double textW = EstimateTextWidth ( node . Label . Text , fontSize ) ;
567+ node . Width = Math . Max ( minW , textW + 2 * theme . NodePadding ) ;
568+ node . Height = nodeH ;
569+ }
570+
571+ // Collect period nodes in declaration order.
572+ var periodNodes = diagram . Nodes . Values
573+ . Where ( n => n . Metadata . TryGetValue ( "timeline:kind" , out var k ) && "period" . Equals ( k as string , StringComparison . Ordinal ) )
574+ . OrderBy ( n => Convert . ToInt32 ( n . Metadata [ "timeline:periodIndex" ] , System . Globalization . CultureInfo . InvariantCulture ) )
575+ . ToList ( ) ;
576+
577+ if ( periodNodes . Count == 0 )
578+ return ;
579+
580+ // Collect event nodes grouped by period index.
581+ var eventNodes = diagram . Nodes . Values
582+ . Where ( n => n . Metadata . TryGetValue ( "timeline:kind" , out var k ) && "event" . Equals ( k as string , StringComparison . Ordinal ) )
583+ . ToList ( ) ;
584+
585+ // Use a uniform column width so all period columns are evenly spaced.
586+ // The column must be wide enough for the widest node in any column.
587+ double colWidth = periodNodes . Max ( n => n . Width ) ;
588+ if ( eventNodes . Count > 0 )
589+ colWidth = Math . Max ( colWidth , eventNodes . Max ( n => n . Width ) ) ;
590+
591+ // When a title is present, shift the first row down to clear it. The title
592+ // is rendered by SvgRenderer at y=(DiagramPadding - 4); it needs
593+ // (TitleFontSize + 8) of vertical room, matching the identical offset that
594+ // SvgRenderer.ComputeHeight already reserves at the canvas bottom.
595+ double titleOffset = ! string . IsNullOrWhiteSpace ( diagram . Title ) ? theme . TitleFontSize + 8 : 0 ;
596+
597+ // Place period nodes in a single horizontal row.
598+ double periodY = pad + titleOffset ;
599+ for ( int i = 0 ; i < periodNodes . Count ; i ++ )
600+ {
601+ var pn = periodNodes [ i ] ;
602+ pn . X = pad + i * ( colWidth + hGap ) ;
603+ pn . Y = periodY ;
604+ pn . Width = colWidth ;
605+ }
606+
607+ // Place event nodes in columns below their owning period.
608+ foreach ( var eventNode in eventNodes )
609+ {
610+ int pIdx = Convert . ToInt32 ( eventNode . Metadata [ "timeline:periodIndex" ] , System . Globalization . CultureInfo . InvariantCulture ) ;
611+ int eIdx = Convert . ToInt32 ( eventNode . Metadata [ "timeline:eventIndex" ] , System . Globalization . CultureInfo . InvariantCulture ) ;
612+
613+ var periodNode = periodNodes . Find ( n =>
614+ Convert . ToInt32 ( n . Metadata [ "timeline:periodIndex" ] , System . Globalization . CultureInfo . InvariantCulture ) == pIdx ) ;
615+
616+ if ( periodNode is null )
617+ continue ;
618+
619+ eventNode . X = periodNode . X ;
620+ eventNode . Y = periodY + nodeH + vGap + eIdx * ( nodeH + vGap ) ;
621+ eventNode . Width = colWidth ;
622+ }
623+ }
624+
347625 // ── Text measurement ──────────────────────────────────────────────────────
348626
349627 /// <summary>
@@ -359,6 +637,24 @@ private static double EstimateTextWidth(string? text, double fontSize)
359637 return text . Length * fontSize * AvgGlyphAdvanceEm ;
360638 }
361639
640+ /// <summary>
641+ /// Shifts all nodes and groups so that no group extends into negative coordinate space.
642+ /// Call this after group bounding boxes have been computed.
643+ /// </summary>
644+ private static void ShiftDiagramForGroupPadding ( Diagram diagram )
645+ {
646+ if ( diagram . Groups . Count == 0 )
647+ return ;
648+
649+ double shiftX = Math . Max ( 0 , - diagram . Groups . Min ( g => g . X ) ) ;
650+ double shiftY = Math . Max ( 0 , - diagram . Groups . Min ( g => g . Y ) ) ;
651+ if ( shiftX > 0 || shiftY > 0 )
652+ {
653+ foreach ( var n in diagram . Nodes . Values ) { n . X += shiftX ; n . Y += shiftY ; }
654+ foreach ( var g in diagram . Groups ) { g . X += shiftX ; g . Y += shiftY ; }
655+ }
656+ }
657+
362658 // ── Private helpers ───────────────────────────────────────────────────────
363659
364660 /// <summary>
0 commit comments