Skip to content

Commit c517327

Browse files
Copilotjongalloway
andauthored
Merge remote-tracking branch 'origin/main' into copilot/add-parse-render-rect-blocks
# Conflicts: # tests/DiagramForge.Tests/Parsers/Mermaid/MermaidSequenceParserTests.cs Co-authored-by: jongalloway <68539+jongalloway@users.noreply.github.com>
2 parents cdfff2a + 4a6c6a3 commit c517327

7 files changed

Lines changed: 253 additions & 2 deletions

File tree

src/DiagramForge/Layout/DefaultLayoutEngine.Sequence.cs

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,16 @@ public sealed partial class DefaultLayoutEngine
99
// renderer always reads the same value that the canvas-width calculation uses.
1010
private const double SelfMessageLoopWidth = 40;
1111

12+
// Extra horizontal space reserved on the left when autonumber is active so that
13+
// the numbered circle badge fits between the canvas edge and the first lifeline.
14+
private const double SequenceAutonumberBadgeExtraLeft = 36;
15+
16+
// Multiplier applied to ShadowBlur when deriving additional autonumber left clearance.
17+
// A Gaussian blur of stdDeviation σ is visually significant within ~2σ from the source
18+
// edge, so multiplying ShadowBlur by 2 gives a conservative estimate of how far the
19+
// filter region extends beyond the node boundary.
20+
private const double SequenceAutonumberShadowClearanceMultiplier = 2.0;
21+
1222
private static void LayoutSequenceDiagram(
1323
Diagram diagram,
1424
Theme theme,
@@ -31,7 +41,15 @@ private static void LayoutSequenceDiagram(
3141
// Reserve vertical space for the title and/or subtitle so they don't overlap participants.
3242
double headingOffset = ComputeHeadingOffset(diagram, theme);
3343

34-
double runX = pad;
44+
// Reserve horizontal space for autonumber badges to the left of participants.
45+
// When node shadows are active the SVG filter extends beyond the node boundary,
46+
// which can visually encroach on the badge. Add clearance proportional to
47+
// ShadowBlur so the badge remains visible across all shadow themes.
48+
bool hasAutonumber = diagram.Metadata.ContainsKey("sequence:autonumber");
49+
double shadowExtra = theme.UseNodeShadows ? SequenceAutonumberShadowClearanceMultiplier * theme.ShadowBlur : 0.0;
50+
double autonumberExtraLeft = hasAutonumber ? SequenceAutonumberBadgeExtraLeft + shadowExtra : 0;
51+
52+
double runX = pad + autonumberExtraLeft;
3553
double participantStripHeight = ordered.Max(node => node.Height);
3654
foreach (var node in ordered)
3755
{
@@ -43,6 +61,9 @@ private static void LayoutSequenceDiagram(
4361
double firstMessageY = pad + headingOffset + participantStripHeight + vGap / 2;
4462
double messageRowHeight = vGap;
4563

64+
if (hasAutonumber)
65+
diagram.Metadata["sequence:autonumberBadgeX"] = pad + autonumberExtraLeft / 2;
66+
4667
// Self-messages (source == target) need 2× the normal row height to
4768
// accommodate a loopback arc. Walk edges in message-index order so that
4869
// the running-Y accumulates correctly regardless of storage order.

src/DiagramForge/Parsers/Mermaid/MermaidSequenceParser.cs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ public Diagram Parse(MermaidDocument document)
2727
var participants = new Dictionary<string, Node>(StringComparer.Ordinal);
2828
int participantIndex = 0;
2929
int messageIndex = 0;
30+
bool autonumber = false;
31+
int autonumberIndex = 1;
3032

3133
// Stack for open rect blocks: each entry holds (group, startMessageIndex).
3234
var openRects = new Stack<(Group Group, int StartIndex)>();
@@ -48,6 +50,13 @@ Node GetOrCreateParticipant(string id)
4850
{
4951
var line = document.Lines[i];
5052

53+
// autonumber — enable numbered badges on each message
54+
if (line.Equals("autonumber", StringComparison.OrdinalIgnoreCase))
55+
{
56+
autonumber = true;
57+
continue;
58+
}
59+
5160
// title: <text> — diagram title directive
5261
if (line.StartsWith("title:", StringComparison.OrdinalIgnoreCase))
5362
{
@@ -136,14 +145,20 @@ Node GetOrCreateParticipant(string id)
136145
};
137146
edge.Metadata["sequence:messageIndex"] = messageIndex++;
138147

148+
if (autonumber)
149+
edge.Metadata["sequence:autonumberIndex"] = autonumberIndex++;
150+
139151
if (!string.IsNullOrEmpty(msgLabel))
140152
edge.Label = new Label(msgLabel!);
141153

142154
builder.AddEdge(edge);
143155
}
144156
}
145157

146-
return builder.Build();
158+
var diagram = builder.Build();
159+
if (autonumber)
160+
diagram.Metadata["sequence:autonumber"] = true;
161+
return diagram;
147162
}
148163

149164
/// <summary>

src/DiagramForge/Rendering/SvgRenderer.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,10 @@ int GetGroupDepth(Group group, HashSet<string>? visiting = null)
152152
SvgStructureWriter.AppendEdge(sb, edge, source, target, theme, diagram.LayoutHints);
153153
}
154154

155+
// Sequence-diagram autonumber badges: numbered circles rendered over arrows but under nodes.
156+
if (diagram.Metadata.ContainsKey("sequence:autonumber"))
157+
SvgStructureWriter.AppendSequenceAutonumberBadges(sb, diagram, theme);
158+
155159
// Nodes (pass index for palette cycling)
156160
int nodeIndex = 0;
157161
foreach (var node in diagram.Nodes.Values)

src/DiagramForge/Rendering/SvgStructureWriter.cs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -699,6 +699,43 @@ internal static void AppendLifelines(StringBuilder sb, Diagram diagram, Theme th
699699
}
700700
}
701701

702+
internal static void AppendSequenceAutonumberBadges(StringBuilder sb, Diagram diagram, Theme theme)
703+
{
704+
if (!diagram.Metadata.TryGetValue("sequence:autonumberBadgeX", out var badgeXObj))
705+
return;
706+
707+
double badgeX = Convert.ToDouble(badgeXObj, System.Globalization.CultureInfo.InvariantCulture);
708+
const double BadgeRadius = 12;
709+
string fillColor = SvgRenderSupport.Escape(theme.AccentColor);
710+
string textColor = SvgRenderSupport.Escape(ColorUtils.IsLight(theme.AccentColor) ? "#0F172A" : "#FFFFFF");
711+
string fontFamily = SvgRenderSupport.Escape(theme.FontFamily);
712+
double fontSize = theme.FontSize * 0.78;
713+
714+
foreach (var edge in diagram.Edges)
715+
{
716+
if (!edge.Metadata.TryGetValue("sequence:messageY", out var msgYObj))
717+
continue;
718+
719+
if (!edge.Metadata.TryGetValue("sequence:autonumberIndex", out var numObj))
720+
continue;
721+
722+
double msgY = Convert.ToDouble(msgYObj, System.Globalization.CultureInfo.InvariantCulture);
723+
int num = Convert.ToInt32(numObj, System.Globalization.CultureInfo.InvariantCulture);
724+
725+
// Self-messages span 2× the row height; vertically center the badge in that range.
726+
double badgeY = msgY;
727+
if (edge.Metadata.TryGetValue("sequence:selfMessage", out var selfMsgObj) && selfMsgObj is true)
728+
{
729+
double selfH = TryGetMetadataDouble(edge.Metadata, "sequence:selfMessageHeight", out var ah) ? ah : 40;
730+
badgeY = msgY + selfH / 2;
731+
}
732+
733+
string numText = SvgRenderSupport.Escape(num.ToString(System.Globalization.CultureInfo.InvariantCulture));
734+
sb.AppendLine($""" <circle cx="{SvgRenderSupport.F(badgeX)}" cy="{SvgRenderSupport.F(badgeY)}" r="{SvgRenderSupport.F(BadgeRadius)}" fill="{fillColor}"/>""");
735+
sb.AppendLine($""" <text x="{SvgRenderSupport.F(badgeX)}" y="{SvgRenderSupport.F(badgeY + fontSize * 0.38)}" text-anchor="middle" font-family="{fontFamily}" font-size="{SvgRenderSupport.F(fontSize)}" fill="{textColor}" font-weight="600">{numText}</text>""");
736+
}
737+
}
738+
702739
internal static void AppendXyChartAxes(StringBuilder sb, Diagram diagram, Theme theme)
703740
{
704741
double chartX = Convert.ToDouble(diagram.Metadata["xychart:chartX"], System.Globalization.CultureInfo.InvariantCulture);
Lines changed: 69 additions & 0 deletions
Loading
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
theme: dark
3+
---
4+
sequenceDiagram
5+
autonumber
6+
participant A as Alice
7+
participant B as Bob
8+
A->>B: Login request
9+
B-->>A: Challenge
10+
A->>B: Credentials
11+
B-->>A: Token
12+
A->>A: Store token

tests/DiagramForge.Tests/Parsers/Mermaid/MermaidSequenceParserTests.cs

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,99 @@ public void Parse_TitleAndSubtitleDirectives_DoNotCreateParticipants()
326326
Assert.True(diagram.Nodes.ContainsKey("B"));
327327
}
328328

329+
// ── Autonumber ────────────────────────────────────────────────────────────
330+
331+
[Fact]
332+
public void Parse_Autonumber_SetsDiagramMetadata()
333+
{
334+
var diagram = _parser.Parse("sequenceDiagram\n autonumber\n A->>B: Hello");
335+
336+
Assert.True(diagram.Metadata.ContainsKey("sequence:autonumber"));
337+
Assert.True(diagram.Metadata["sequence:autonumber"] is true);
338+
}
339+
340+
[Fact]
341+
public void Parse_Autonumber_CaseInsensitive()
342+
{
343+
var diagram = _parser.Parse("sequenceDiagram\n AUTONUMBER\n A->>B: Hello");
344+
345+
Assert.True(diagram.Metadata.ContainsKey("sequence:autonumber"));
346+
}
347+
348+
[Fact]
349+
public void Parse_Autonumber_EdgeHasAutonumberIndex()
350+
{
351+
var diagram = _parser.Parse("sequenceDiagram\n autonumber\n A->>B: Hello");
352+
353+
var edge = Assert.Single(diagram.Edges);
354+
Assert.True(edge.Metadata.ContainsKey("sequence:autonumberIndex"));
355+
Assert.Equal(1, Convert.ToInt32(edge.Metadata["sequence:autonumberIndex"],
356+
System.Globalization.CultureInfo.InvariantCulture));
357+
}
358+
359+
[Fact]
360+
public void Parse_Autonumber_MultipleMessages_IndexesStartAtOne()
361+
{
362+
const string text = """
363+
sequenceDiagram
364+
autonumber
365+
A->>B: First
366+
B-->>A: Second
367+
A->>B: Third
368+
""";
369+
370+
var diagram = _parser.Parse(text);
371+
372+
Assert.Equal(3, diagram.Edges.Count);
373+
for (int i = 0; i < diagram.Edges.Count; i++)
374+
{
375+
int idx = Convert.ToInt32(diagram.Edges[i].Metadata["sequence:autonumberIndex"],
376+
System.Globalization.CultureInfo.InvariantCulture);
377+
Assert.Equal(i + 1, idx);
378+
}
379+
}
380+
381+
[Fact]
382+
public void Parse_NoAutonumber_DiagramMetadataKeyAbsent()
383+
{
384+
var diagram = _parser.Parse("sequenceDiagram\n A->>B: Hello");
385+
386+
Assert.False(diagram.Metadata.ContainsKey("sequence:autonumber"));
387+
}
388+
389+
[Fact]
390+
public void Parse_NoAutonumber_EdgeDoesNotHaveAutonumberIndex()
391+
{
392+
var diagram = _parser.Parse("sequenceDiagram\n A->>B: Hello");
393+
394+
var edge = Assert.Single(diagram.Edges);
395+
Assert.False(edge.Metadata.ContainsKey("sequence:autonumberIndex"));
396+
}
397+
398+
[Fact]
399+
public void Parse_Autonumber_MessagesBeforeKeyword_AreNotNumbered()
400+
{
401+
const string text = """
402+
sequenceDiagram
403+
A->>B: Before
404+
autonumber
405+
B-->>A: After
406+
""";
407+
408+
var diagram = _parser.Parse(text);
409+
410+
Assert.Equal(2, diagram.Edges.Count);
411+
// Find edge with label "Before" — it should have no autonumber index
412+
var beforeEdge = diagram.Edges.First(e => e.Label?.Text == "Before");
413+
Assert.False(beforeEdge.Metadata.ContainsKey("sequence:autonumberIndex"));
414+
415+
// Find edge with label "After" — it should be numbered 1
416+
var afterEdge = diagram.Edges.First(e => e.Label?.Text == "After");
417+
Assert.True(afterEdge.Metadata.ContainsKey("sequence:autonumberIndex"));
418+
Assert.Equal(1, Convert.ToInt32(afterEdge.Metadata["sequence:autonumberIndex"],
419+
System.Globalization.CultureInfo.InvariantCulture));
420+
}
421+
329422
// ── Rect blocks ───────────────────────────────────────────────────────────
330423

331424
[Fact]

0 commit comments

Comments
 (0)