Skip to content

Commit 88091e0

Browse files
Copilotjongalloway
andcommitted
Add Mermaid timeline diagram support
Co-authored-by: jongalloway <68539+jongalloway@users.noreply.github.com>
1 parent 16b27ea commit 88091e0

8 files changed

Lines changed: 519 additions & 2 deletions

File tree

src/DiagramForge/Layout/DefaultLayoutEngine.cs

Lines changed: 72 additions & 0 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, "timeline", StringComparison.OrdinalIgnoreCase))
60+
{
61+
LayoutTimelineDiagram(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
@@ -281,6 +287,72 @@ private static void LayoutBlockDiagram(
281287
}
282288
}
283289

290+
private static void LayoutTimelineDiagram(
291+
Diagram diagram,
292+
Theme theme,
293+
double minW,
294+
double nodeH,
295+
double hGap,
296+
double vGap,
297+
double pad)
298+
{
299+
// Size all nodes from their labels.
300+
foreach (var node in diagram.Nodes.Values)
301+
{
302+
double fontSize = node.Label.FontSize ?? theme.FontSize;
303+
double textW = EstimateTextWidth(node.Label.Text, fontSize);
304+
node.Width = Math.Max(minW, textW + 2 * theme.NodePadding);
305+
node.Height = nodeH;
306+
}
307+
308+
// Collect period nodes in declaration order.
309+
var periodNodes = diagram.Nodes.Values
310+
.Where(n => n.Metadata.TryGetValue("timeline:kind", out var k) && "period".Equals(k as string, StringComparison.Ordinal))
311+
.OrderBy(n => Convert.ToInt32(n.Metadata["timeline:periodIndex"], System.Globalization.CultureInfo.InvariantCulture))
312+
.ToList();
313+
314+
if (periodNodes.Count == 0)
315+
return;
316+
317+
// Collect event nodes grouped by period index.
318+
var eventNodes = diagram.Nodes.Values
319+
.Where(n => n.Metadata.TryGetValue("timeline:kind", out var k) && "event".Equals(k as string, StringComparison.Ordinal))
320+
.ToList();
321+
322+
// Use a uniform column width so all period columns are evenly spaced.
323+
// The column must be wide enough for the widest node in any column.
324+
double colWidth = periodNodes.Max(n => n.Width);
325+
if (eventNodes.Count > 0)
326+
colWidth = Math.Max(colWidth, eventNodes.Max(n => n.Width));
327+
328+
// Place period nodes in a single horizontal row.
329+
double periodY = pad;
330+
for (int i = 0; i < periodNodes.Count; i++)
331+
{
332+
var pn = periodNodes[i];
333+
pn.X = pad + i * (colWidth + hGap);
334+
pn.Y = periodY;
335+
pn.Width = colWidth;
336+
}
337+
338+
// Place event nodes in columns below their owning period.
339+
foreach (var eventNode in eventNodes)
340+
{
341+
int pIdx = Convert.ToInt32(eventNode.Metadata["timeline:periodIndex"], System.Globalization.CultureInfo.InvariantCulture);
342+
int eIdx = Convert.ToInt32(eventNode.Metadata["timeline:eventIndex"], System.Globalization.CultureInfo.InvariantCulture);
343+
344+
var periodNode = periodNodes.Find(n =>
345+
Convert.ToInt32(n.Metadata["timeline:periodIndex"], System.Globalization.CultureInfo.InvariantCulture) == pIdx);
346+
347+
if (periodNode is null)
348+
continue;
349+
350+
eventNode.X = periodNode.X;
351+
eventNode.Y = periodY + nodeH + vGap + eIdx * (nodeH + vGap);
352+
eventNode.Width = colWidth;
353+
}
354+
}
355+
284356
// ── Text measurement ──────────────────────────────────────────────────────
285357

286358
/// <summary>

src/DiagramForge/Parsers/Mermaid/MermaidDiagramKind.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@ internal enum MermaidDiagramKind
77
Mindmap,
88
StateDiagram,
99
BlockDiagram,
10+
Timeline,
1011
}

src/DiagramForge/Parsers/Mermaid/MermaidDocument.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,6 @@ public static bool TryParse(string diagramText, [System.Diagnostics.CodeAnalysis
6262
"gantt",
6363
"pie",
6464
"gitgraph",
65-
"timeline",
6665
"quadrantchart",
6766
"requirementdiagram",
6867
"packet-beta",
@@ -124,6 +123,12 @@ private static bool TryDetectKind(string headerLine, out MermaidDiagramKind kind
124123
return true;
125124
}
126125

126+
if (normalizedHeader.Equals("timeline", StringComparison.Ordinal))
127+
{
128+
kind = MermaidDiagramKind.Timeline;
129+
return true;
130+
}
131+
127132
var spaceIndex = normalizedHeader.IndexOf(' ');
128133
var keyword = spaceIndex >= 0 ? normalizedHeader[..spaceIndex] : normalizedHeader;
129134
if (KnownUnsupportedMermaidKeywords.Contains(keyword))

src/DiagramForge/Parsers/Mermaid/MermaidParser.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ namespace DiagramForge.Parsers.Mermaid;
1313
/// <item>Mindmap</item>
1414
/// <item>State diagram (stateDiagram / stateDiagram-v2)</item>
1515
/// <item>Block diagram (block / block-beta)</item>
16+
/// <item>Timeline</item>
1617
/// </list>
1718
/// </remarks>
1819
public sealed class MermaidParser : IDiagramParser
@@ -23,9 +24,10 @@ public sealed class MermaidParser : IDiagramParser
2324
new MermaidMindmapParser(),
2425
new MermaidStateParser(),
2526
new MermaidBlockParser(),
27+
new MermaidTimelineParser(),
2628
];
2729

28-
private static readonly string[] SupportedDiagramTypes = ["flowchart", "mindmap", "statediagram", "block"];
30+
private static readonly string[] SupportedDiagramTypes = ["flowchart", "mindmap", "statediagram", "block", "timeline"];
2931

3032
public string SyntaxId => "mermaid";
3133

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
using DiagramForge.Abstractions;
2+
using DiagramForge.Models;
3+
4+
namespace DiagramForge.Parsers.Mermaid;
5+
6+
internal sealed class MermaidTimelineParser : IMermaidDiagramParser
7+
{
8+
public bool CanParse(MermaidDiagramKind kind) => kind == MermaidDiagramKind.Timeline;
9+
10+
public Diagram Parse(MermaidDocument document)
11+
{
12+
var builder = new DiagramSemanticModelBuilder()
13+
.WithSourceSyntax("mermaid")
14+
.WithDiagramType("timeline");
15+
16+
builder.WithLayoutHints(new LayoutHints { Direction = LayoutDirection.LeftToRight });
17+
18+
int periodIndex = -1;
19+
int eventIndex = 0;
20+
21+
// Skip index 0 (the "timeline" header).
22+
for (int i = 1; i < document.Lines.Length; i++)
23+
{
24+
var line = document.Lines[i];
25+
26+
// Optional title declaration.
27+
if (line.StartsWith("title ", StringComparison.OrdinalIgnoreCase))
28+
{
29+
builder.WithTitle(line["title ".Length..].Trim());
30+
continue;
31+
}
32+
33+
// Continuation event line: begins with ':'.
34+
if (line.StartsWith(":", StringComparison.Ordinal))
35+
{
36+
if (periodIndex < 0)
37+
continue; // Orphan event before any period — ignore.
38+
39+
AddEvent(builder, line[1..].Trim(), periodIndex, ref eventIndex);
40+
continue;
41+
}
42+
43+
// Period line (bare text), with an optional inline first event separated by ':'.
44+
// e.g. "Q1 : Research" → period "Q1" + event "Research"
45+
// e.g. "Q1" → period "Q1" with no inline event
46+
if (!string.IsNullOrWhiteSpace(line))
47+
{
48+
periodIndex++;
49+
eventIndex = 0;
50+
51+
int colonIdx = line.IndexOf(':');
52+
string periodText;
53+
string? inlineEventText = null;
54+
55+
if (colonIdx >= 0)
56+
{
57+
periodText = line[..colonIdx].Trim();
58+
var candidate = line[(colonIdx + 1)..].Trim();
59+
if (!string.IsNullOrEmpty(candidate))
60+
inlineEventText = candidate;
61+
}
62+
else
63+
{
64+
periodText = line.Trim();
65+
}
66+
67+
var periodId = $"period_{periodIndex}";
68+
var periodNode = new Node(periodId, periodText) { Shape = Shape.Rectangle };
69+
periodNode.Metadata["timeline:kind"] = "period";
70+
periodNode.Metadata["timeline:periodIndex"] = periodIndex;
71+
builder.AddNode(periodNode);
72+
73+
if (inlineEventText is not null)
74+
AddEvent(builder, inlineEventText, periodIndex, ref eventIndex);
75+
}
76+
}
77+
78+
return builder.Build();
79+
}
80+
81+
private static void AddEvent(
82+
IDiagramSemanticModelBuilder builder,
83+
string eventText,
84+
int periodIndex,
85+
ref int eventIndex)
86+
{
87+
if (string.IsNullOrEmpty(eventText))
88+
return;
89+
90+
var eventId = $"event_{periodIndex}_{eventIndex}";
91+
var eventNode = new Node(eventId, eventText) { Shape = Shape.Rectangle };
92+
eventNode.Metadata["timeline:kind"] = "event";
93+
eventNode.Metadata["timeline:periodIndex"] = periodIndex;
94+
eventNode.Metadata["timeline:eventIndex"] = eventIndex;
95+
96+
builder.AddNode(eventNode);
97+
builder.AddEdge(new Edge($"period_{periodIndex}", eventId));
98+
eventIndex++;
99+
}
100+
}
Lines changed: 46 additions & 0 deletions
Loading
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
timeline
2+
title Product Roadmap
3+
Q1 : Research
4+
: Prototype
5+
Q2 : Beta
6+
Q3 : GA
7+
: Scale

0 commit comments

Comments
 (0)