Skip to content

Commit 95c0fb6

Browse files
authored
Merge pull request #118 from jongalloway/copilot/add-tree-conceptual-diagram
Add tree conceptual diagram type with org chart style
2 parents 94b50fe + 15bb386 commit 95c0fb6

15 files changed

+1048
-12
lines changed

doc/prd.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,14 +135,14 @@ Examples that should use Mermaid rather than the Conceptual DSL:
135135

136136
- Venn / overlapping sets
137137
- Generic relationship diagrams
138-
- Hierarchy / org-chart-style trees
139138
- Timelines and simple roadmaps
140139

141140
Examples that remain good candidates for the Conceptual DSL:
142141

143142
- Matrix
144143
- Pyramid
145144
- Cycle ✓ (implemented)
145+
- Tree / hierarchy / org chart (indent-based syntax with optional `style: orgchart` preset; Mermaid flowchart/mindmap produces materially worse results for formal tree structures)
146146
- Funnel
147147
- Chevron process
148148
- Radial / hub-and-spoke

src/DiagramForge/Layout/DefaultLayoutEngine.Conceptual.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ private delegate void ConceptualLayoutHandler(
2424
["pillars"] = LayoutPillarsDiagram,
2525
["pyramid"] = LayoutPyramidDiagram,
2626
["radial"] = LayoutRadialDiagram,
27+
["tree"] = LayoutTreeDiagram,
2728
}.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
2829

2930
private static bool TryLayoutConceptualDiagram(
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
using DiagramForge.Models;
2+
3+
namespace DiagramForge.Layout;
4+
5+
public sealed partial class DefaultLayoutEngine
6+
{
7+
private const double TreeHGap = 30;
8+
private const double TreeVGap = 50;
9+
10+
private static void LayoutTreeDiagram(
11+
Diagram diagram,
12+
Theme theme,
13+
double minW,
14+
double nodeH,
15+
double pad)
16+
{
17+
if (diagram.Nodes.Count == 0)
18+
return;
19+
20+
bool isOrgChart = diagram.Nodes.Values
21+
.Any(n => n.Metadata.ContainsKey("tree:orgchart"));
22+
23+
double orgMinWidth = isOrgChart ? 120 : minW;
24+
25+
// ── Build adjacency: parent → children ────────────────────────────────
26+
var childrenOf = new Dictionary<string, List<string>>(StringComparer.Ordinal);
27+
var hasParent = new HashSet<string>(StringComparer.Ordinal);
28+
29+
foreach (var edge in diagram.Edges)
30+
{
31+
if (!childrenOf.TryGetValue(edge.SourceId, out var list))
32+
{
33+
list = [];
34+
childrenOf[edge.SourceId] = list;
35+
}
36+
list.Add(edge.TargetId);
37+
hasParent.Add(edge.TargetId);
38+
}
39+
40+
// Roots are nodes with no incoming edges, ordered by their numeric suffix
41+
// to preserve the parser's insertion order (node_0, node_1, …, node_10, …).
42+
var roots = diagram.Nodes.Values
43+
.Where(n => !hasParent.Contains(n.Id))
44+
.OrderBy(n => TryParseNodeIndex(n.Id))
45+
.ToList();
46+
47+
if (roots.Count == 0)
48+
return;
49+
50+
// ── Sizing pass ───────────────────────────────────────────────────────
51+
foreach (var node in diagram.Nodes.Values)
52+
SizeStandardNode(node, theme, orgMinWidth, nodeH);
53+
54+
// ── Compute subtree widths (bottom-up) ────────────────────────────────
55+
var subtreeWidth = new Dictionary<string, double>(StringComparer.Ordinal);
56+
57+
double ComputeSubtreeWidth(string nodeId)
58+
{
59+
var node = diagram.Nodes[nodeId];
60+
if (!childrenOf.TryGetValue(nodeId, out var children) || children.Count == 0)
61+
{
62+
subtreeWidth[nodeId] = node.Width;
63+
return node.Width;
64+
}
65+
66+
double childrenTotalWidth = 0;
67+
foreach (var childId in children)
68+
childrenTotalWidth += ComputeSubtreeWidth(childId);
69+
70+
// Add gaps between children
71+
childrenTotalWidth += (children.Count - 1) * TreeHGap;
72+
73+
subtreeWidth[nodeId] = Math.Max(node.Width, childrenTotalWidth);
74+
return subtreeWidth[nodeId];
75+
}
76+
77+
foreach (var root in roots)
78+
ComputeSubtreeWidth(root.Id);
79+
80+
// ── X assignment (top-down, centering children under parent) ──────────
81+
void AssignX(string nodeId, double leftX)
82+
{
83+
var node = diagram.Nodes[nodeId];
84+
double mySubtreeW = subtreeWidth[nodeId];
85+
86+
// Center this node in its allocated subtree span
87+
node.X = leftX + (mySubtreeW - node.Width) / 2;
88+
89+
if (!childrenOf.TryGetValue(nodeId, out var children) || children.Count == 0)
90+
return;
91+
92+
// Distribute children within the subtree span
93+
double childX = leftX;
94+
foreach (var childId in children)
95+
{
96+
AssignX(childId, childX);
97+
childX += subtreeWidth[childId] + TreeHGap;
98+
}
99+
}
100+
101+
double titleOffset = !string.IsNullOrWhiteSpace(diagram.Title) ? theme.TitleFontSize + 8 : 0;
102+
103+
// Lay out roots side-by-side
104+
double currentX = pad;
105+
foreach (var root in roots)
106+
{
107+
AssignX(root.Id, currentX);
108+
currentX += subtreeWidth[root.Id] + TreeHGap;
109+
}
110+
111+
// ── Y assignment (depth-based layers) ─────────────────────────────────
112+
void AssignY(string nodeId, int depth)
113+
{
114+
var node = diagram.Nodes[nodeId];
115+
node.Y = pad + titleOffset + depth * (nodeH + TreeVGap);
116+
117+
if (childrenOf.TryGetValue(nodeId, out var children))
118+
{
119+
foreach (var childId in children)
120+
AssignY(childId, depth + 1);
121+
}
122+
}
123+
124+
foreach (var root in roots)
125+
AssignY(root.Id, 0);
126+
}
127+
}

src/DiagramForge/Parsers/Conceptual/ConceptualDslParser.Dispatch.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ public sealed partial class ConceptualDslParser
1919
["pillars"] = ParsePillarsDiagram,
2020
["pyramid"] = ParsePyramidDiagram,
2121
["radial"] = ParseRadialDiagram,
22+
["tree"] = ParseTreeDiagram,
2223
}.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
2324

2425
private static readonly FrozenSet<string> KnownTypes =
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
using DiagramForge.Abstractions;
2+
using DiagramForge.Models;
3+
4+
namespace DiagramForge.Parsers.Conceptual;
5+
6+
public sealed partial class ConceptualDslParser
7+
{
8+
private static void ParseTreeDiagram(string[] lines, IDiagramSemanticModelBuilder builder)
9+
{
10+
// ── Parse optional style: section ─────────────────────────────────────
11+
string? stylePreset = null;
12+
int styleLine = FindSectionLine(lines, "style");
13+
if (styleLine >= 0)
14+
{
15+
var trimmed = lines[styleLine].Trim();
16+
var colonPos = trimmed.IndexOf(':', StringComparison.Ordinal);
17+
if (colonPos >= 0)
18+
{
19+
var value = trimmed[(colonPos + 1)..].Trim();
20+
if (!string.IsNullOrEmpty(value))
21+
stylePreset = value.ToLowerInvariant();
22+
}
23+
}
24+
25+
// ── Parse optional colors: section ────────────────────────────────────
26+
var colorMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
27+
int colorsLine = FindSectionLine(lines, "colors");
28+
if (colorsLine >= 0)
29+
{
30+
for (int i = colorsLine + 1; i < lines.Length; i++)
31+
{
32+
var line = lines[i];
33+
var trimmed = line.Trim();
34+
if (string.IsNullOrEmpty(trimmed))
35+
continue;
36+
37+
// Stop at the next top-level section key
38+
if (GetIndent(line) == 0 && trimmed.EndsWith(':') && !trimmed.StartsWith('-'))
39+
break;
40+
41+
// Expect "name: \"#hex\"" or "name: #hex"
42+
var sep = trimmed.IndexOf(':', StringComparison.Ordinal);
43+
if (sep > 0)
44+
{
45+
var key = trimmed[..sep].Trim();
46+
var val = trimmed[(sep + 1)..].Trim().Trim('"');
47+
if (!string.IsNullOrEmpty(key) && !string.IsNullOrEmpty(val))
48+
colorMap[key] = val;
49+
}
50+
}
51+
}
52+
53+
// ── Parse required tree: section ──────────────────────────────────────
54+
int treeLine = FindSectionLine(lines, "tree");
55+
if (treeLine < 0)
56+
throw new DiagramParseException("Missing required section 'tree:' in tree diagram.");
57+
58+
int baseIndent = -1;
59+
var stack = new Stack<(int indent, string nodeId)>();
60+
int nodeCounter = 0;
61+
bool isOrgChart = string.Equals(stylePreset, "orgchart", StringComparison.OrdinalIgnoreCase);
62+
63+
for (int i = treeLine + 1; i < lines.Length; i++)
64+
{
65+
var line = lines[i];
66+
var trimmed = line.Trim();
67+
if (string.IsNullOrEmpty(trimmed))
68+
continue;
69+
70+
// Stop at next top-level section key
71+
if (GetIndent(line) == 0 && trimmed.EndsWith(':') && !trimmed.StartsWith('-'))
72+
break;
73+
74+
int indent = GetIndent(line);
75+
if (baseIndent < 0)
76+
baseIndent = indent;
77+
78+
// Normalize indent relative to base
79+
int relIndent = indent - baseIndent;
80+
81+
// Parse optional [color-group] tag
82+
string label = trimmed;
83+
string? colorGroup = null;
84+
int bracketStart = trimmed.LastIndexOf('[');
85+
int bracketEnd = trimmed.LastIndexOf(']');
86+
if (bracketStart >= 0 && bracketEnd > bracketStart)
87+
{
88+
colorGroup = trimmed[(bracketStart + 1)..bracketEnd].Trim();
89+
label = trimmed[..bracketStart].Trim();
90+
}
91+
92+
if (string.IsNullOrEmpty(label))
93+
continue;
94+
95+
var nodeId = $"node_{nodeCounter++}";
96+
var node = new Node(nodeId, label);
97+
98+
// Apply fill color from color map
99+
if (colorGroup is not null && colorMap.TryGetValue(colorGroup, out var color))
100+
node.FillColor = color;
101+
102+
// Store tree metadata – depth equals the current ancestor count
103+
// (i.e. the stack size after popping), which is independent of indent width.
104+
node.Metadata["tree:depth"] = stack.Count;
105+
if (isOrgChart)
106+
node.Metadata["tree:orgchart"] = true;
107+
108+
builder.AddNode(node);
109+
110+
// Pop stack until we find a parent with strictly smaller indentation
111+
while (stack.Count > 0 && stack.Peek().indent >= relIndent)
112+
stack.Pop();
113+
114+
if (stack.Count > 0)
115+
{
116+
var edge = new Edge(stack.Peek().nodeId, nodeId)
117+
{
118+
Routing = EdgeRouting.Orthogonal,
119+
ArrowHead = ArrowHeadStyle.None,
120+
};
121+
edge.Metadata["tree:edge"] = true;
122+
builder.AddEdge(edge);
123+
}
124+
125+
stack.Push((relIndent, nodeId));
126+
}
127+
128+
if (nodeCounter == 0)
129+
throw new DiagramParseException("Section 'tree' contains no items.");
130+
131+
builder.WithLayoutHints(new LayoutHints { Direction = LayoutDirection.TopToBottom });
132+
}
133+
}

src/DiagramForge/Parsers/Conceptual/ConceptualDslParser.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ namespace DiagramForge.Parsers.Conceptual;
1717
/// - Now
1818
/// - Next
1919
/// </code>
20-
/// <para>Supported diagram types: matrix, pyramid, cycle, pillars, funnel, radial.</para>
20+
/// <para>Supported diagram types: chevrons, cycle, funnel, matrix, pillars, pyramid, radial, tree.</para>
2121
/// </remarks>
2222
public sealed partial class ConceptualDslParser : IDiagramParser
2323
{

src/DiagramForge/Rendering/SvgStructureWriter.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ private static (double X, double Y) ComputeEndLabelPosition(double x, double y,
206206

207207
private static bool PreferHorizontalForEdge(Edge edge, LayoutHints hints, double dx, double dy)
208208
{
209-
if (IsHierarchyEdge(edge))
209+
if (IsHierarchyEdge(edge) || IsTreeEdge(edge))
210210
{
211211
return hints.Direction is LayoutDirection.LeftToRight or LayoutDirection.RightToLeft;
212212
}
@@ -220,6 +220,9 @@ private static bool IsHierarchyEdge(Edge edge) =>
220220
&& (string.Equals(relType, "inheritance", StringComparison.Ordinal)
221221
|| string.Equals(relType, "realization", StringComparison.Ordinal));
222222

223+
private static bool IsTreeEdge(Edge edge) =>
224+
edge.Metadata.ContainsKey("tree:edge");
225+
223226
/// <summary>
224227
/// Builds an orthogonal (rectilinear) SVG path with rounded corners between two anchor points.
225228
/// The path uses a Z-shape (two 90° bends) with SVG arcs replacing each sharp corner.

0 commit comments

Comments
 (0)