Skip to content

Commit d0dd1ca

Browse files
committed
Resolve merge conflicts: integrate timeline and architecture changes into sequence branch
2 parents e14e0c9 + 873ae73 commit d0dd1ca

17 files changed

Lines changed: 1475 additions & 10 deletions

src/DiagramForge/Layout/DefaultLayoutEngine.cs

Lines changed: 302 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -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>

src/DiagramForge/Models/Edge.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ public Edge(string sourceId, string targetId)
3232
/// <summary>Override stroke color (null = inherit from theme).</summary>
3333
public string? Color { get; set; }
3434

35-
/// <summary>Arbitrary parser-specific metadata shared with layout and rendering.</summary>
35+
/// <summary>Arbitrary metadata from the parser (e.g., port directions for architecture diagrams).</summary>
3636
public Dictionary<string, object> Metadata { get; } = new();
3737
}
3838

src/DiagramForge/Models/Shape.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,5 @@ public enum Shape
1919
ArrowLeft,
2020
ArrowUp,
2121
ArrowDown,
22+
Cloud,
2223
}

0 commit comments

Comments
 (0)