Skip to content

Commit 4ae51c3

Browse files
committed
feat: add snake timeline diagram type
Add a new 'snake' conceptual diagram type that renders a colorful serpentine timeline with circles connected by a winding gradient path. Parser: - ConceptualDslParser.Snake.cs handles 'diagram: snake' with title, steps (Label: Description), and icon references - Minimum 3 steps required; descriptions are optional Layout: - DefaultLayoutEngine.Snake.cs positions circles along a center line connected by tiling semicircular arcs alternating above/below - Multi-word labels auto-wrap to 2 lines with large font sizing - Description text alternates above/below circles - Icons sized and positioned above label text within circles - Gradient stops use paired positions so each circle gets a solid color band matching its width, with smooth transitions in gaps Rendering: - SvgStructureWriter.AppendSnakePath() renders a linearGradient path with an outline stroke for definition against the background - Description text with word-wrapping support - Snake path rendered before nodes/edges for correct z-ordering Additional: - Heroicons missing-package warning when heroicons: prefix is used but the icon pack is not registered - Gallery build script updated with general SVG ID de-duplication - README updated with gallery images and syntax reference - 12 parser unit tests, 8 layout tests, 2 E2E snapshot fixtures (Presentation theme + Dracula theme)
1 parent 30bbf88 commit 4ae51c3

18 files changed

Lines changed: 1230 additions & 3 deletions

README.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,22 @@ DiagramForge currently supports more than a dozen diagram types across Mermaid a
122122
<br />
123123
<sub>Conceptual Radial + Icons</sub>
124124
</td>
125+
<td align="center" valign="top" width="33%">
126+
<a href="https://github.com/jongalloway/DiagramForge/blob/main/tests/DiagramForge.E2ETests/Fixtures/conceptual-snake-presentation.expected.svg">
127+
<img src="https://raw.githubusercontent.com/jongalloway/DiagramForge/main/tests/DiagramForge.E2ETests/Fixtures/conceptual-snake-presentation.expected.svg" alt="Snake timeline (Presentation)" height="96" />
128+
</a>
129+
<br />
130+
<sub>Snake Timeline</sub>
131+
</td>
132+
</tr>
133+
<tr>
134+
<td align="center" valign="top" width="33%">
135+
<a href="https://github.com/jongalloway/DiagramForge/blob/main/tests/DiagramForge.E2ETests/Fixtures/conceptual-snake-dracula.expected.svg">
136+
<img src="https://raw.githubusercontent.com/jongalloway/DiagramForge/main/tests/DiagramForge.E2ETests/Fixtures/conceptual-snake-dracula.expected.svg" alt="Snake timeline (Dracula)" height="96" />
137+
</a>
138+
<br />
139+
<sub>Snake Timeline (Dracula)</sub>
140+
</td>
125141
</tr>
126142
</table>
127143

@@ -750,6 +766,7 @@ Rule of thumb: if the diagram is already easy to describe as Mermaid, use Mermai
750766
| Iterative process / feedback loop (3–6 steps) | Conceptual DSL | `diagram: cycle\nsteps:\n - Plan\n - Build\n - Measure\n - Learn` |
751767
| Sequential stage process (slide-style chevrons) | Conceptual DSL | `diagram: chevrons\nsteps:\n - Discover\n - Build\n - Launch\n - Learn` |
752768
| Central concept with surrounding pillars / capabilities (3–8 items) | Conceptual DSL | `diagram: radial\ncenter: Platform\nitems:\n - Security\n - Reliability\n - Observability` |
769+
| Visual step-by-step journey / snake timeline (3+ steps) | Conceptual DSL | `diagram: snake\ntitle: Journey\nsteps:\n - Start: Begin here\n - Middle: Keep going\n - End: Arrive` |
753770

754771
Planned conceptual additions are aimed at presentation-native graphics that Mermaid does not cover idiomatically, such as tree hierarchies / org charts.
755772

@@ -852,6 +869,23 @@ items:
852869

853870
Supported: 3–8 items. Items are placed evenly around the center at equal angles, starting at the top (12 o'clock).
854871

872+
#### snake
873+
874+
Snake timeline layout for journeys, processes, and step-by-step narratives. Steps are rendered as large circles connected by a weaving semicircular path that alternates above and below. Each step supports an optional icon and description.
875+
876+
```text
877+
diagram: snake
878+
title: The Fellowship's Journey
879+
steps:
880+
- icon:heroicons:globe-alt The Shire: Bilbo's farewell party and Frodo inherits the One Ring
881+
- icon:heroicons:user-group Rivendell: The Council of Elrond forms the Fellowship of the Ring
882+
- icon:heroicons:fire Moria: Gandalf falls battling the Balrog on the Bridge of Khazad-dum
883+
- icon:heroicons:shield-check Amon Hen: The Fellowship breaks as Boromir falls defending the hobbits
884+
- icon:heroicons:eye Mordor: Frodo and Sam destroy the Ring in the fires of Mount Doom
885+
```
886+
887+
Requires at least 3 steps. Each step follows the format `Label: Description` — the description is optional. Icons use the standard `icon:pack:name` prefix.
888+
855889
## Architecture
856890

857891
```mermaid

scripts/Build-Gallery.ps1

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,9 +184,18 @@ function Write-GallerySvg {
184184
$inner = $inner -replace '<\?xml[^?]*\?>\s*', ''
185185
$inner = $inner -replace '<svg[^>]*>', ''
186186
$inner = $inner -replace '</svg>\s*$', ''
187+
188+
# Namespace all IDs to avoid cross-fixture collisions in the combined SVG.
189+
# Arrowhead markers:
187190
$inner = $inner -replace 'id="arrowhead"', "id=`"arrowhead-$i`""
188191
$inner = $inner -replace 'url\(#arrowhead\)', "url(#arrowhead-$i)"
189192
$inner = $inner -replace '#arrowhead"', "#arrowhead-$i`""
193+
# Node gradients/filters (node-0-fill-gradient, node-0-soft-shadow, etc.):
194+
$inner = $inner -replace 'id="(node-\d+)', "id=`"g${i}-`$1"
195+
$inner = $inner -replace 'url\(#(node-\d+)', "url(#g${i}-`$1"
196+
# Snake path gradient:
197+
$inner = $inner -replace 'id="snake-gradient"', "id=`"snake-gradient-$i`""
198+
$inner = $inner -replace 'url\(#snake-gradient\)', "url(#snake-gradient-$i)"
190199

191200
[void]$sb.AppendLine($inner.Trim())
192201
[void]$sb.AppendLine(" </svg>")

src/DiagramForge/DiagramRenderer.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,8 @@ public DiagramRenderer RegisterParser(IDiagramParser parser)
207207

208208
private void ResolveIcons(Diagram diagram)
209209
{
210+
bool warnedHeroicons = false;
211+
210212
foreach (var node in diagram.Nodes.Values)
211213
{
212214
if (node.IconRef is not null)
@@ -221,10 +223,28 @@ private void ResolveIcons(Diagram diagram)
221223
? icon with { SvgContent = sanitized }
222224
: null;
223225
}
226+
else if (!warnedHeroicons && IsHeroiconsReference(node.IconRef))
227+
{
228+
warnedHeroicons = true;
229+
Console.ForegroundColor = ConsoleColor.Yellow;
230+
Console.Error.WriteLine(
231+
$"Warning: Icon reference '{node.IconRef}' looks like a Heroicons icon, " +
232+
"but the Heroicons pack is not registered.");
233+
Console.Error.WriteLine(
234+
" Install the NuGet package and call .UseHeroicons():");
235+
Console.Error.WriteLine(
236+
" dotnet add package DiagramForge.Icons.Heroicons");
237+
Console.Error.WriteLine(
238+
" https://www.nuget.org/packages/DiagramForge.Icons.Heroicons");
239+
Console.ResetColor();
240+
}
224241
}
225242
}
226243
}
227244

245+
private static bool IsHeroiconsReference(string iconRef) =>
246+
iconRef.StartsWith("heroicons:", StringComparison.OrdinalIgnoreCase);
247+
228248
private IDiagramParser? FindParser(string diagramText)
229249
{
230250
foreach (var parser in _parsers)

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+
["snake"] = LayoutSnakeDiagram,
2728
["tree"] = LayoutTreeDiagram,
2829
}.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
2930

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
using System.Globalization;
2+
using DiagramForge.Models;
3+
4+
namespace DiagramForge.Layout;
5+
6+
public sealed partial class DefaultLayoutEngine
7+
{
8+
private static void LayoutSnakeDiagram(
9+
Diagram diagram,
10+
Theme theme,
11+
double minW,
12+
double nodeH,
13+
double pad)
14+
{
15+
var orderedNodes = diagram.Nodes.Values
16+
.OrderBy(n => GetMetadataInt(n.Metadata, "snake:stepIndex"))
17+
.ToList();
18+
19+
if (orderedNodes.Count == 0)
20+
return;
21+
22+
double fontSize = theme.FontSize;
23+
double titleOffset = !string.IsNullOrWhiteSpace(diagram.Title) ? theme.TitleFontSize * 1.4 + 12 : 0;
24+
25+
// ── Wrap all multi-word labels to two lines for larger text ───────────
26+
foreach (var node in orderedNodes)
27+
{
28+
string text = node.Label.Text;
29+
if (text.Contains(' ', StringComparison.Ordinal) && !text.Contains('\n', StringComparison.Ordinal))
30+
{
31+
// Split at the space nearest the middle
32+
int mid = text.Length / 2;
33+
int bestSplit = -1;
34+
int bestDist = int.MaxValue;
35+
for (int j = 0; j < text.Length; j++)
36+
{
37+
if (text[j] == ' ' && Math.Abs(j - mid) < bestDist)
38+
{
39+
bestDist = Math.Abs(j - mid);
40+
bestSplit = j;
41+
}
42+
}
43+
44+
if (bestSplit > 0)
45+
{
46+
node.Label.Text = text[..bestSplit] + "\n" + text[(bestSplit + 1)..];
47+
node.Label.Lines = [text[..bestSplit], text[(bestSplit + 1)..]];
48+
}
49+
}
50+
}
51+
52+
// ── Sizing ────────────────────────────────────────────────────────────
53+
// Set a generous minimum circle diameter first, then derive font size from it.
54+
double minCircleDiameter = fontSize * 10;
55+
double circleDiameter = Math.Max(minCircleDiameter, Math.Max(minW, nodeH));
56+
circleDiameter = Math.Max(circleDiameter, EnsureIconHeight(orderedNodes[0], circleDiameter));
57+
58+
// Derive label font size from circle diameter so text fills the circle.
59+
// For two-line labels the inscribed width at ~60% height is ~0.8 × diameter.
60+
double snakeFontSize = circleDiameter * 0.14;
61+
62+
// Verify labels fit; if the widest label overflows, grow the circle
63+
double availableTextWidth = circleDiameter * 0.75; // inscribed text area
64+
double widestLine = orderedNodes.Max(node =>
65+
{
66+
string t = node.Label.Text;
67+
string longest = t.Contains('\n') ? t.Split('\n').MaxBy(l => l.Length)! : t;
68+
return EstimateTextWidth(longest, snakeFontSize);
69+
});
70+
if (widestLine > availableTextWidth)
71+
{
72+
circleDiameter *= widestLine / availableTextWidth;
73+
snakeFontSize = circleDiameter * 0.14;
74+
}
75+
76+
// Store the snake-specific font size and icon size per node
77+
double iconSize = circleDiameter * 0.3;
78+
foreach (var node in orderedNodes)
79+
{
80+
node.Label.FontSize = snakeFontSize;
81+
node.Metadata["icon:size"] = iconSize;
82+
// Position icon snugly above the label text inside the circle.
83+
// label:centerY is at circleDiameter/2; the label then shifts down
84+
// by iconAreaHeight/2, so effective text top ≈ center + gap/2.
85+
// Place icon just above that, with a small gap.
86+
double iconGap = snakeFontSize * 0.15;
87+
double iconY = circleDiameter / 2 - iconSize - iconGap;
88+
node.Metadata["icon:y"] = iconY;
89+
}
90+
91+
// Description text blocks — proportional to circle size for readability
92+
double descFontSize = snakeFontSize * 0.85;
93+
double descMaxWidth = circleDiameter * 2.0;
94+
double descLineHeight = descFontSize * 1.35;
95+
96+
// Measure max description block height
97+
double maxDescHeight = 0;
98+
foreach (var node in orderedNodes)
99+
{
100+
if (node.Metadata.TryGetValue("snake:description", out var descObj) && descObj is string desc)
101+
{
102+
int lineCount = EstimateWrappedLineCount(desc, descFontSize, descMaxWidth);
103+
double blockHeight = lineCount * descLineHeight;
104+
maxDescHeight = Math.Max(maxDescHeight, blockHeight);
105+
}
106+
}
107+
108+
// Gap between circle edge and description text
109+
double descGap = 12;
110+
111+
// ── Snake curve geometry ──────────────────────────────────────────────
112+
// The snake connector wraps around each circle as a semicircular arc,
113+
// alternating above and below. The arcs tile seamlessly — each arc's
114+
// endpoint is the next arc's start point, so no horizontal segments.
115+
double arcGap = circleDiameter * 0.25; // visual gap between arc and circle edge
116+
double arcRadius = circleDiameter / 2 + arcGap;
117+
double snakeStrokeWidth = circleDiameter * 0.22;
118+
119+
// Arcs tile directly: each circle center is 2 × arcRadius apart
120+
double hSpacing = 2 * arcRadius;
121+
122+
// Vertical space needed for descriptions that sit above/below the circles
123+
double descSpace = maxDescHeight > 0 ? maxDescHeight + descGap : 0;
124+
125+
// Center line Y: leave room for title, top descriptions, and arc extent
126+
double centerY = pad + titleOffset + descSpace + arcRadius + circleDiameter / 2;
127+
128+
// ── Palette ───────────────────────────────────────────────────────────
129+
// For the snake diagram we want vibrant, saturated colors for the circles.
130+
// Use the node palette colors but boost their saturation for this diagram type.
131+
string[] palette = theme.NodePalette is { Count: > 0 }
132+
? [.. theme.NodePalette]
133+
: [theme.NodeFillColor];
134+
135+
string[] strokePalette = theme.NodeStrokePalette is { Count: > 0 }
136+
? [.. theme.NodeStrokePalette]
137+
: palette.Select(c => ColorUtils.Darken(c, 0.18)).ToArray();
138+
139+
// ── Position circles ──────────────────────────────────────────────────
140+
for (int i = 0; i < orderedNodes.Count; i++)
141+
{
142+
var node = orderedNodes[i];
143+
double cx = pad + circleDiameter / 2 + i * hSpacing;
144+
double cy = centerY;
145+
146+
node.Shape = Shape.Circle;
147+
node.Width = circleDiameter;
148+
node.Height = circleDiameter;
149+
node.X = cx - circleDiameter / 2;
150+
node.Y = cy - circleDiameter / 2;
151+
152+
// Assign vibrant colors from palette
153+
node.FillColor = palette[i % palette.Length];
154+
node.StrokeColor = strokePalette[i % strokePalette.Length];
155+
156+
SetLabelCenter(node, circleDiameter / 2, circleDiameter / 2);
157+
}
158+
159+
// ── Store snake path data in diagram metadata for the renderer ────────
160+
// The connector is a series of tiled semicircular arcs wrapping around
161+
// each circle, alternating below and above. Because hSpacing = 2·arcR,
162+
// each arc's right endpoint is exactly the next arc's left endpoint.
163+
//
164+
// Even-indexed circles: arc wraps BELOW (sweep-flag=1 → clockwise)
165+
// Odd-indexed circles: arc wraps ABOVE (sweep-flag=0 → counter-clockwise)
166+
var pathSegments = new List<string>();
167+
int n = orderedNodes.Count;
168+
169+
// Start at the left edge of the first circle's arc
170+
double firstCx = orderedNodes[0].X + circleDiameter / 2;
171+
pathSegments.Add($"M {Fmt(firstCx - arcRadius)},{Fmt(centerY)}");
172+
173+
for (int i = 0; i < n; i++)
174+
{
175+
double cx = orderedNodes[i].X + circleDiameter / 2;
176+
177+
// Semicircular arc around circle i — arcs tile with no gap
178+
int sweep = (i % 2 == 0) ? 1 : 0;
179+
pathSegments.Add(
180+
$"A {Fmt(arcRadius)} {Fmt(arcRadius)} 0 0 {sweep} {Fmt(cx + arcRadius)},{Fmt(centerY)}");
181+
}
182+
183+
string snakePathData = string.Join(" ", pathSegments);
184+
185+
// Build per-segment color list and paired stop positions for the gradient.
186+
// Each circle gets a solid-color band equal to the circle's width, with
187+
// smooth gradient transitions only in the gaps between circles.
188+
//
189+
// Path spans 2·n·arcRadius. Circle i centre = (2i+1)·arcRadius.
190+
// Solid band edges: centre ± circleRadius.
191+
// ratio r = circleRadius / arcRadius (≈ 0.667 for default arcGap).
192+
// solidStart% = ((2i+1) − r) / (2n) × 100
193+
// solidEnd% = ((2i+1) + r) / (2n) × 100
194+
double circleRadius = circleDiameter / 2;
195+
double r = circleRadius / arcRadius;
196+
var segmentColors = new List<string>();
197+
var segmentStops = new List<(double Start, double End)>();
198+
for (int i = 0; i < n; i++)
199+
{
200+
segmentColors.Add(palette[i % palette.Length]);
201+
double solidStart = ((2.0 * i + 1) - r) / (2.0 * n) * 100;
202+
double solidEnd = ((2.0 * i + 1) + r) / (2.0 * n) * 100;
203+
segmentStops.Add((solidStart, solidEnd));
204+
}
205+
206+
diagram.Metadata["snake:pathData"] = snakePathData;
207+
diagram.Metadata["snake:strokeWidth"] = snakeStrokeWidth;
208+
diagram.Metadata["snake:segmentColors"] = segmentColors;
209+
diagram.Metadata["snake:segmentStops"] = segmentStops;
210+
diagram.Metadata["snake:nodeCount"] = n;
211+
diagram.Metadata["snake:descFontSize"] = descFontSize;
212+
213+
// ── Store description text positions ──────────────────────────────────
214+
for (int i = 0; i < orderedNodes.Count; i++)
215+
{
216+
var node = orderedNodes[i];
217+
if (!node.Metadata.TryGetValue("snake:description", out var descObj) || descObj is not string desc)
218+
continue;
219+
220+
double cx = node.X + circleDiameter / 2;
221+
double nodeCy = node.Y + circleDiameter / 2;
222+
223+
// Alternate: even indices arc below → description below; odd arc above → desc above
224+
bool descBelow = i % 2 == 0;
225+
double descY = descBelow
226+
? nodeCy + circleDiameter / 2 + arcGap + descGap
227+
: nodeCy - circleDiameter / 2 - arcGap - descGap;
228+
229+
node.Metadata["snake:descX"] = cx;
230+
node.Metadata["snake:descY"] = descY;
231+
node.Metadata["snake:descBelow"] = descBelow;
232+
node.Metadata["snake:descMaxWidth"] = descMaxWidth;
233+
}
234+
235+
// ── Set canvas height to account for all elements ─────────────────────
236+
double canvasBottom = centerY + circleDiameter / 2 + arcRadius + descSpace + pad;
237+
double canvasTop = pad;
238+
double totalHeight = canvasBottom + pad;
239+
240+
// Store so ComputeHeight can use the full extent
241+
diagram.Metadata["snake:canvasHeight"] = totalHeight;
242+
}
243+
244+
private static int EstimateWrappedLineCount(string text, double fontSize, double maxWidth)
245+
{
246+
double charWidth = fontSize * AvgGlyphAdvanceEm;
247+
int charsPerLine = Math.Max(1, (int)(maxWidth / charWidth));
248+
int lineCount = (int)Math.Ceiling((double)text.Length / charsPerLine);
249+
return Math.Max(1, lineCount);
250+
}
251+
252+
private static string Fmt(double v) => v.ToString("F2", CultureInfo.InvariantCulture);
253+
}

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+
["snake"] = ParseSnakeDiagram,
2223
["tree"] = ParseTreeDiagram,
2324
}.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
2425

0 commit comments

Comments
 (0)