Skip to content

Commit 2007b72

Browse files
authored
Merge pull request #31 from jongalloway/copilot/add-mermaid-architecture-diagram-support
Add Mermaid architecture-beta diagram support
2 parents 54b7d58 + 3b4205c commit 2007b72

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
@@ -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>

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)