Skip to content

Commit ce74fb8

Browse files
Copilotjongalloway
andcommitted
Merge main (PR 31 architecture support) and resolve conflicts
Co-authored-by: jongalloway <68539+jongalloway@users.noreply.github.com>
1 parent 721571f commit ce74fb8

13 files changed

Lines changed: 914 additions & 8 deletions

File tree

src/DiagramForge/Layout/DefaultLayoutEngine.cs

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

src/DiagramForge/Models/Edge.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ public Edge(string sourceId, string targetId)
3131

3232
/// <summary>Override stroke color (null = inherit from theme).</summary>
3333
public string? Color { get; set; }
34+
35+
/// <summary>Arbitrary metadata from the parser (e.g., port directions for architecture diagrams).</summary>
36+
public Dictionary<string, object> Metadata { get; } = new();
3437
}
3538

3639
public enum EdgeLineStyle

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)