feat(central-ui): FlowGraphView SVG swimlane renderer

Dependency-free inline SVG: three fixed 110px lanes (Central / Site / Tags, sublabelled
from the steps' site/instance), columns from a topological pass over the graph edges with
same-lane collision bumping so independent siblings never overlap, 150x78 nodes on a
130+col*170 grid, orthogonal connectors with arrow markers (accent for boundary crossings,
dashed for external actors, light for predicted edges). Renders a real FlowGraph, a
PredictedFlow (dashed nodes, input-dependent finer-dashed), or both — predicted nodes
matched to real steps by kind in order, the leftovers appended greyed as ghosts. Selected
node = accent 2px on --active-bg; failed/timed-out/not-traced outcomes styled; each node a
role=button tabindex=0 <g> with click + Enter/Space selection; svg role=img with a summary
aria-label. Layout is a pure static ComputeLayout, every coordinate InvariantCulture.
Razor reserves the <text> tag, so SVG text elements are emitted through a small
HTML-encoding MarkupString helper.

Claude-Session: https://claude.ai/code/session_01GSLkkHKz9JrAtmJjmQK3cP
This commit is contained in:
Joseph Doherty
2026-09-06 00:25:54 -04:00
parent f9b8a2cf8f
commit ba15a38327
4 changed files with 741 additions and 0 deletions
@@ -0,0 +1,65 @@
@namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging
@using ZB.MOM.WW.ScadaBridge.ScriptAnalysis
@* Flow Debugger swimlane (design 2026-09-05 §4). Dependency-free inline SVG:
three fixed lanes, one node per step, orthogonal connectors. All coordinate
math lives in the code-behind (InvariantCulture throughout). *@
<div class="fg-wrap" data-test="flow-graph">
<svg width="@RenderedWidth" height="@RenderedHeight" viewBox="@ViewBox"
role="img" aria-label="@AriaSummary" class="fg-svg">
<defs>
<marker id="fg-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" class="fg-arrowhead" />
</marker>
<marker id="fg-arrow-accent" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" class="fg-arrowhead accent" />
</marker>
</defs>
@* Lanes *@
@for (var lane = 0; lane < 3; lane++)
{
var i = lane;
<g class="fg-lane" data-lane="@LaneName(i)">
<rect x="0" y="@LaneY(i)" width="@F(_layout.Width)" height="@F(LaneHeight)" class="fg-lane-bg @(i % 2 == 1 ? "alt" : "")" />
<line x1="0" y1="@LaneY(i)" x2="@F(_layout.Width)" y2="@LaneY(i)" class="fg-lane-rule" />
@SvgText("12", LaneLabelY(i), "fg-lane-label", LaneName(i))
@if (!string.IsNullOrEmpty(_layout.LaneSublabels[i]))
{
@SvgText("12", F(i * LaneHeight + 40), "fg-lane-sublabel", Clip(_layout.LaneSublabels[i], 16), title: _layout.LaneSublabels[i])
}
</g>
}
@if (_layout.IsEmpty)
{
@SvgText(F(ColumnOrigin), F(LanePad + 44), "fg-empty", "No steps", dataTest: "flow-graph-empty")
}
@* Connectors (under the nodes) *@
@foreach (var e in _layout.Edges)
{
<path d="@e.Path" class="@EdgeClass(e)" marker-end="@MarkerFor(e)" data-test="flow-edge" data-from="@e.From" data-to="@e.To" />
}
@* Nodes *@
@foreach (var n in _layout.Nodes)
{
var node = n;
<g class="@NodeClass(node)" transform="translate(@F(node.X) @F(node.Y))"
role="button" tabindex="0"
aria-label="@(Eyebrow(node) + ": " + node.Title)"
data-test="flow-node" data-key="@node.Key" data-kind="@node.Kind" data-lane="@node.Lane" data-column="@node.Column"
@onclick="() => SelectAsync(node)" @onkeydown="e => OnKeyAsync(e, node)">
<title>@node.Title@(string.IsNullOrEmpty(node.Subtitle) ? "" : " — " + node.Subtitle)</title>
<rect x="0" y="0" width="@F(NodeWidth)" height="@F(NodeHeight)" rx="4" ry="4" class="fg-node-box" />
@SvgText("10", "16", "fg-eyebrow", Eyebrow(node))
@SvgText(F(NodeWidth - 10), "16", "fg-duration", Duration(node), anchor: "end")
@SvgText("10", "40", "fg-title", Clip(node.Title, 20))
@SvgText("10", "60", "fg-subtitle", Clip(node.Subtitle, 24))
</g>
}
</svg>
</div>