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:
@@ -0,0 +1,392 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Web;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
|
||||
using ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
||||
|
||||
/// <summary>One laid-out node of the swimlane: a real step, a predicted node, or both (matched).</summary>
|
||||
/// <param name="Key">Stable render key (<c>e:{eventId}</c> for real steps, <c>p:{id}</c> for predicted-only nodes).</param>
|
||||
/// <param name="Lane">Swimlane.</param>
|
||||
/// <param name="Column">Zero-based column from the topological pass.</param>
|
||||
/// <param name="X">Left edge in SVG units.</param>
|
||||
/// <param name="Y">Top edge in SVG units.</param>
|
||||
/// <param name="Step">The real step, null for a predicted-only node.</param>
|
||||
/// <param name="Predicted">The predicted node, null for a real step with no prediction match.</param>
|
||||
/// <param name="Ghost">True for a predicted node the real graph never produced (greyed).</param>
|
||||
public sealed record FlowLayoutNode(
|
||||
string Key, FlowLane Lane, int Column, double X, double Y, FlowStep? Step, PredictedNode? Predicted, bool Ghost)
|
||||
{
|
||||
/// <summary>True when this node comes from a prediction only (no real step yet).</summary>
|
||||
public bool PredictedOnly => Step == null;
|
||||
|
||||
/// <summary>Display kind.</summary>
|
||||
public FlowStepKind Kind => Step?.Kind ?? Predicted!.Kind;
|
||||
|
||||
/// <summary>Display title.</summary>
|
||||
public string Title => Step?.Title ?? Predicted!.Title;
|
||||
|
||||
/// <summary>Display subtitle (real: the step's; predicted: the note).</summary>
|
||||
public string? Subtitle => Step?.Subtitle ?? Predicted?.Note;
|
||||
}
|
||||
|
||||
/// <summary>One laid-out connector.</summary>
|
||||
/// <param name="From">Source node key.</param>
|
||||
/// <param name="To">Target node key.</param>
|
||||
/// <param name="Path">SVG path data (orthogonal, culture-invariant).</param>
|
||||
/// <param name="CrossesBoundary">Accent colour — a central↔site hop.</param>
|
||||
/// <param name="External">Dashed — an external actor (the tag source responding).</param>
|
||||
/// <param name="PredictedOnly">Light — an edge of the prediction, not of a run.</param>
|
||||
public sealed record FlowLayoutEdge(string From, string To, string Path, bool CrossesBoundary, bool External, bool PredictedOnly);
|
||||
|
||||
/// <summary>A computed swimlane layout.</summary>
|
||||
/// <param name="Nodes">Nodes with coordinates.</param>
|
||||
/// <param name="Edges">Connectors with path data.</param>
|
||||
/// <param name="Width">Canvas width in SVG units.</param>
|
||||
/// <param name="Height">Canvas height in SVG units.</param>
|
||||
/// <param name="LaneSublabels">Per-lane sublabel (index = <see cref="FlowLane"/>), empty when unknown.</param>
|
||||
public sealed record FlowLayout(
|
||||
IReadOnlyList<FlowLayoutNode> Nodes,
|
||||
IReadOnlyList<FlowLayoutEdge> Edges,
|
||||
double Width,
|
||||
double Height,
|
||||
IReadOnlyList<string> LaneSublabels)
|
||||
{
|
||||
/// <summary>True when there is nothing to draw.</summary>
|
||||
public bool IsEmpty => Nodes.Count == 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Custom-SVG swimlane renderer for the Flow Debugger (design 2026-09-05 §4). Three fixed lanes
|
||||
/// (Central / Site / Tags), columns from a topological pass over the graph's edges, orthogonal
|
||||
/// connectors. Renders a real <see cref="FlowGraph"/>, a <see cref="PredictedFlow"/>, or both —
|
||||
/// in which case predicted nodes the run never produced are greyed. The layout is a pure
|
||||
/// function (<see cref="ComputeLayout"/>) so it is unit-tested without rendering; every
|
||||
/// coordinate string is <see cref="CultureInfo.InvariantCulture"/>-formatted.
|
||||
/// </summary>
|
||||
public partial class FlowGraphView : ComponentBase
|
||||
{
|
||||
/// <summary>Lane height in SVG units.</summary>
|
||||
public const double LaneHeight = 110;
|
||||
/// <summary>Node width.</summary>
|
||||
public const double NodeWidth = 150;
|
||||
/// <summary>Node height.</summary>
|
||||
public const double NodeHeight = 78;
|
||||
/// <summary>Left edge of column 0 (the lane label gutter sits to its left).</summary>
|
||||
public const double ColumnOrigin = 130;
|
||||
/// <summary>Column pitch.</summary>
|
||||
public const double ColumnPitch = 170;
|
||||
/// <summary>Top padding inside a lane.</summary>
|
||||
public const double LanePad = 16;
|
||||
/// <summary>Right-hand canvas padding.</summary>
|
||||
public const double RightPad = 20;
|
||||
|
||||
private const int LaneCount = 3;
|
||||
|
||||
/// <summary>The real graph (from a run or a replay); null before a run.</summary>
|
||||
[Parameter] public FlowGraph? Graph { get; set; }
|
||||
|
||||
/// <summary>The predicted flow; drawn alone before a run, overlaid (ghosts) after one.</summary>
|
||||
[Parameter] public PredictedFlow? Predicted { get; set; }
|
||||
|
||||
/// <summary>The selected real step's event id.</summary>
|
||||
[Parameter] public Guid? SelectedEventId { get; set; }
|
||||
|
||||
/// <summary>Raised when a real step is clicked or activated with Enter/Space.</summary>
|
||||
[Parameter] public EventCallback<FlowStep> OnSelect { get; set; }
|
||||
|
||||
/// <summary>Zoom factor applied to the rendered size (the viewBox is fixed).</summary>
|
||||
[Parameter] public double Zoom { get; set; } = 1.0;
|
||||
|
||||
private FlowLayout _layout = ComputeLayout(null, null);
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
_layout = ComputeLayout(Graph, Predicted);
|
||||
}
|
||||
|
||||
/// <summary>Formats a coordinate culture-invariantly.</summary>
|
||||
public static string F(double v) => v.ToString("0.##", CultureInfo.InvariantCulture);
|
||||
|
||||
private string RenderedWidth => F(_layout.Width * Math.Max(0.1, Zoom));
|
||||
private string RenderedHeight => F(_layout.Height * Math.Max(0.1, Zoom));
|
||||
private string ViewBox => $"0 0 {F(_layout.Width)} {F(_layout.Height)}";
|
||||
|
||||
private static string LaneName(int lane) => ((FlowLane)lane).ToString();
|
||||
private static string LaneY(int lane) => F(lane * LaneHeight);
|
||||
private static string LaneLabelY(int lane) => F(lane * LaneHeight + 24);
|
||||
|
||||
private string AriaSummary
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_layout.IsEmpty) return "Flow diagram: no steps.";
|
||||
var real = _layout.Nodes.Count(n => !n.PredictedOnly);
|
||||
var predicted = _layout.Nodes.Count(n => n.PredictedOnly);
|
||||
var crossings = _layout.Edges.Count(e => e.CrossesBoundary);
|
||||
return $"Flow diagram: {real} steps, {predicted} predicted, {crossings} boundary crossings.";
|
||||
}
|
||||
}
|
||||
|
||||
private string NodeClass(FlowLayoutNode n)
|
||||
{
|
||||
var sb = new StringBuilder("fg-node");
|
||||
if (n.PredictedOnly) sb.Append(" predicted");
|
||||
if (n.Ghost) sb.Append(" ghost");
|
||||
if (n.Predicted?.InputDependent == true) sb.Append(" input-dependent");
|
||||
if (n.Step != null && SelectedEventId == n.Step.EventId) sb.Append(" selected");
|
||||
switch (n.Step?.Outcome)
|
||||
{
|
||||
case FlowStepOutcome.Failed: sb.Append(" failed"); break;
|
||||
case FlowStepOutcome.TimedOut: sb.Append(" timed-out"); break;
|
||||
case FlowStepOutcome.NotTraced: sb.Append(" not-traced"); break;
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string EdgeClass(FlowLayoutEdge e)
|
||||
{
|
||||
var sb = new StringBuilder("fg-edge");
|
||||
if (e.CrossesBoundary) sb.Append(" boundary");
|
||||
if (e.External) sb.Append(" external");
|
||||
if (e.PredictedOnly) sb.Append(" predicted");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string MarkerFor(FlowLayoutEdge e)
|
||||
=> e.CrossesBoundary ? "url(#fg-arrow-accent)" : "url(#fg-arrow)";
|
||||
|
||||
private static string Eyebrow(FlowLayoutNode n) => n.Kind switch
|
||||
{
|
||||
FlowStepKind.InboundRequest => "REQUEST",
|
||||
FlowStepKind.RouteCall => "ROUTE CALL",
|
||||
FlowStepKind.ScriptRun => "SCRIPT RUN",
|
||||
FlowStepKind.AttributeWrite => "WRITE",
|
||||
FlowStepKind.AttributeWait => "WAIT",
|
||||
FlowStepKind.Response => "RESPONSE",
|
||||
FlowStepKind.External => "EXTERNAL",
|
||||
_ => "STEP",
|
||||
};
|
||||
|
||||
private static string Duration(FlowLayoutNode n)
|
||||
=> n.Step?.DurationMs is int ms ? ms.ToString(CultureInfo.InvariantCulture) + " ms" : (n.PredictedOnly ? "predicted" : string.Empty);
|
||||
|
||||
/// <summary>Clips a label to what fits the node width, keeping the full text in a <c>title</c>.</summary>
|
||||
public static string Clip(string? text, int max)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text)) return string.Empty;
|
||||
return text.Length <= max ? text : text[..(max - 1)] + "…";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits an SVG <c><text></c> element as pre-encoded markup. Razor reserves the
|
||||
/// <c><text></c> tag for its own markup-mode switch and refuses attributes on it,
|
||||
/// so the SVG element of the same name cannot be written literally inside a code block.
|
||||
/// </summary>
|
||||
public static MarkupString SvgText(string x, string y, string cssClass, string content, string? anchor = null, string? title = null, string? dataTest = null)
|
||||
{
|
||||
var sb = new StringBuilder("<text x=\"").Append(x).Append("\" y=\"").Append(y).Append("\" class=\"").Append(cssClass).Append('"');
|
||||
if (anchor != null) sb.Append(" text-anchor=\"").Append(anchor).Append('"');
|
||||
if (dataTest != null) sb.Append(" data-test=\"").Append(dataTest).Append('"');
|
||||
sb.Append('>');
|
||||
if (title != null) sb.Append("<title>").Append(System.Net.WebUtility.HtmlEncode(title)).Append("</title>");
|
||||
sb.Append(System.Net.WebUtility.HtmlEncode(content)).Append("</text>");
|
||||
return new MarkupString(sb.ToString());
|
||||
}
|
||||
|
||||
private Task SelectAsync(FlowLayoutNode n)
|
||||
=> n.Step != null && OnSelect.HasDelegate ? OnSelect.InvokeAsync(n.Step) : Task.CompletedTask;
|
||||
|
||||
private Task OnKeyAsync(KeyboardEventArgs e, FlowLayoutNode n)
|
||||
=> e.Key is "Enter" or " " or "Spacebar" ? SelectAsync(n) : Task.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// Pure layout: assigns lanes and columns, resolves same-lane collisions by bumping to the
|
||||
/// next free column, computes coordinates and orthogonal connector paths.
|
||||
/// </summary>
|
||||
/// <param name="graph">The real graph, or null.</param>
|
||||
/// <param name="predicted">The prediction, or null.</param>
|
||||
public static FlowLayout ComputeLayout(FlowGraph? graph, PredictedFlow? predicted)
|
||||
{
|
||||
var hasReal = graph is { Steps.Count: > 0 };
|
||||
var hasPredicted = predicted is { Nodes.Count: > 0 };
|
||||
|
||||
var nodes = new List<FlowLayoutNode>();
|
||||
var edges = new List<FlowLayoutEdge>();
|
||||
var sublabels = new string[LaneCount];
|
||||
|
||||
// Node keys in insertion order + adjacency for the topological pass.
|
||||
var keys = new List<string>();
|
||||
var lane = new Dictionary<string, FlowLane>(StringComparer.Ordinal);
|
||||
var preds = new Dictionary<string, List<string>>(StringComparer.Ordinal);
|
||||
var rawEdges = new List<(string From, string To, bool Crosses, bool External, bool PredictedOnly)>();
|
||||
var stepByKey = new Dictionary<string, FlowStep>(StringComparer.Ordinal);
|
||||
var predByKey = new Dictionary<string, PredictedNode>(StringComparer.Ordinal);
|
||||
var ghostKeys = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
if (hasReal)
|
||||
{
|
||||
foreach (var s in graph!.Steps)
|
||||
{
|
||||
var k = "e:" + s.EventId.ToString("N");
|
||||
keys.Add(k);
|
||||
lane[k] = s.Lane;
|
||||
stepByKey[k] = s;
|
||||
if (s.Lane == FlowLane.Site && string.IsNullOrEmpty(sublabels[(int)FlowLane.Site]))
|
||||
sublabels[(int)FlowLane.Site] = JoinLabel(s.SiteId, s.Instance);
|
||||
if (s.Lane == FlowLane.Tags && string.IsNullOrEmpty(sublabels[(int)FlowLane.Tags]))
|
||||
sublabels[(int)FlowLane.Tags] = s.Instance ?? string.Empty;
|
||||
}
|
||||
|
||||
foreach (var e in graph.Edges)
|
||||
{
|
||||
var from = "e:" + e.FromEventId.ToString("N");
|
||||
var to = "e:" + e.ToEventId.ToString("N");
|
||||
if (lane.ContainsKey(from) && lane.ContainsKey(to))
|
||||
rawEdges.Add((from, to, e.CrossesBoundary, e.External, false));
|
||||
}
|
||||
|
||||
if (hasPredicted)
|
||||
{
|
||||
// Overlay: match predicted nodes to real steps by kind, in order; the leftovers are ghosts.
|
||||
var unmatched = predicted!.Nodes.ToList();
|
||||
foreach (var s in graph.Steps)
|
||||
{
|
||||
var m = unmatched.FirstOrDefault(p => p.Kind == s.Kind);
|
||||
if (m != null)
|
||||
{
|
||||
unmatched.Remove(m);
|
||||
predByKey["e:" + s.EventId.ToString("N")] = m;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var p in unmatched)
|
||||
{
|
||||
var k = "p:" + p.Id;
|
||||
keys.Add(k);
|
||||
lane[k] = p.Lane;
|
||||
predByKey[k] = p;
|
||||
ghostKeys.Add(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (hasPredicted)
|
||||
{
|
||||
foreach (var p in predicted!.Nodes)
|
||||
{
|
||||
var k = "p:" + p.Id;
|
||||
keys.Add(k);
|
||||
lane[k] = p.Lane;
|
||||
predByKey[k] = p;
|
||||
}
|
||||
|
||||
foreach (var e in predicted.Edges)
|
||||
{
|
||||
var from = "p:" + e.From;
|
||||
var to = "p:" + e.To;
|
||||
if (lane.ContainsKey(from) && lane.ContainsKey(to))
|
||||
rawEdges.Add((from, to, e.CrossesBoundary, false, true));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var (from, to, _, _, _) in rawEdges)
|
||||
{
|
||||
if (!preds.TryGetValue(to, out var list))
|
||||
preds[to] = list = [];
|
||||
list.Add(from);
|
||||
}
|
||||
|
||||
// Topological column assignment in insertion order (the builders already emit a
|
||||
// time/dependency-consistent order, so a single pass converges), with same-lane
|
||||
// collision bumping so two independent siblings never overlap.
|
||||
var column = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
var occupied = new HashSet<(FlowLane, int)>();
|
||||
foreach (var k in keys)
|
||||
{
|
||||
if (ghostKeys.Contains(k))
|
||||
continue;
|
||||
|
||||
var col = 0;
|
||||
if (preds.TryGetValue(k, out var ps))
|
||||
{
|
||||
foreach (var p in ps)
|
||||
{
|
||||
if (column.TryGetValue(p, out var pc))
|
||||
col = Math.Max(col, pc + 1);
|
||||
}
|
||||
}
|
||||
|
||||
while (!occupied.Add((lane[k], col)))
|
||||
col++;
|
||||
column[k] = col;
|
||||
}
|
||||
|
||||
// Ghosts go after everything real, one column each in their own lane.
|
||||
var maxCol = column.Count == 0 ? -1 : column.Values.Max();
|
||||
foreach (var k in keys.Where(ghostKeys.Contains))
|
||||
{
|
||||
var col = maxCol + 1;
|
||||
while (!occupied.Add((lane[k], col)))
|
||||
col++;
|
||||
column[k] = col;
|
||||
maxCol = Math.Max(maxCol, col);
|
||||
}
|
||||
|
||||
foreach (var k in keys)
|
||||
{
|
||||
var col = column[k];
|
||||
var x = ColumnOrigin + col * ColumnPitch;
|
||||
var y = LanePad + (int)lane[k] * LaneHeight;
|
||||
stepByKey.TryGetValue(k, out var step);
|
||||
predByKey.TryGetValue(k, out var pn);
|
||||
nodes.Add(new FlowLayoutNode(k, lane[k], col, x, y, step, pn, ghostKeys.Contains(k)));
|
||||
}
|
||||
|
||||
var byKey = nodes.ToDictionary(n => n.Key, StringComparer.Ordinal);
|
||||
foreach (var (from, to, crosses, external, predictedOnly) in rawEdges)
|
||||
{
|
||||
var a = byKey[from];
|
||||
var b = byKey[to];
|
||||
edges.Add(new FlowLayoutEdge(from, to, OrthogonalPath(a, b), crosses, external, predictedOnly));
|
||||
}
|
||||
|
||||
var width = ColumnOrigin + (maxCol + 1) * ColumnPitch + RightPad;
|
||||
if (maxCol < 0) width = ColumnOrigin + ColumnPitch + RightPad;
|
||||
var height = LaneCount * LaneHeight + LanePad;
|
||||
|
||||
return new FlowLayout(nodes, edges, width, height, sublabels.Select(s => s ?? string.Empty).ToArray());
|
||||
}
|
||||
|
||||
private static string JoinLabel(string? a, string? b)
|
||||
=> string.IsNullOrEmpty(a) ? b ?? string.Empty : string.IsNullOrEmpty(b) ? a : $"{a} · {b}";
|
||||
|
||||
/// <summary>
|
||||
/// Orthogonal connector from the right-middle of <paramref name="a"/> to the left-middle
|
||||
/// of <paramref name="b"/>: straight when in the same lane, otherwise right-down/up-right
|
||||
/// through the inter-column gap. A backwards edge (target left of source) loops under.
|
||||
/// </summary>
|
||||
public static string OrthogonalPath(FlowLayoutNode a, FlowLayoutNode b)
|
||||
{
|
||||
var x1 = a.X + NodeWidth;
|
||||
var y1 = a.Y + NodeHeight / 2;
|
||||
var x2 = b.X;
|
||||
var y2 = b.Y + NodeHeight / 2;
|
||||
|
||||
if (Math.Abs(y1 - y2) < 0.01)
|
||||
return $"M {F(x1)} {F(y1)} H {F(x2)}";
|
||||
|
||||
if (x2 >= x1)
|
||||
{
|
||||
var mid = x1 + (x2 - x1) / 2;
|
||||
return $"M {F(x1)} {F(y1)} H {F(mid)} V {F(y2)} H {F(x2)}";
|
||||
}
|
||||
|
||||
// Backwards: drop out of the source, run left, and come in from the target's left.
|
||||
var drop = Math.Max(a.Y, b.Y) + NodeHeight + 8;
|
||||
return $"M {F(x1)} {F(y1)} H {F(x1 + 8)} V {F(drop)} H {F(x2 - 8)} V {F(y2)} H {F(x2)}";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user