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;
/// One laid-out node of the swimlane: a real step, a predicted node, or both (matched).
/// Stable render key (e:{eventId} for real steps, p:{id} for predicted-only nodes).
/// Swimlane.
/// Zero-based column from the topological pass.
/// Left edge in SVG units.
/// Top edge in SVG units.
/// The real step, null for a predicted-only node.
/// The predicted node, null for a real step with no prediction match.
/// True for a predicted node the real graph never produced (greyed).
public sealed record FlowLayoutNode(
string Key, FlowLane Lane, int Column, double X, double Y, FlowStep? Step, PredictedNode? Predicted, bool Ghost)
{
/// True when this node comes from a prediction only (no real step yet).
public bool PredictedOnly => Step == null;
/// Display kind.
public FlowStepKind Kind => Step?.Kind ?? Predicted!.Kind;
/// Display title.
public string Title => Step?.Title ?? Predicted!.Title;
/// Display subtitle (real: the step's; predicted: the note).
public string? Subtitle => Step?.Subtitle ?? Predicted?.Note;
///
/// The dotted prefix shared by every real title in this node's lane (e.g. DelmiaReceiver.),
/// factored out of the node text and shown once in the lane gutter; null when the lane has none.
///
public string? LanePrefix { get; init; }
/// Title with removed — the discriminating tail of a dotted path.
public string DisplayTitle => FlowGraphView.StripPrefix(Title, LanePrefix) ?? Title;
/// Subtitle with removed from each comma-separated item.
public string? DisplaySubtitle => FlowGraphView.StripPrefix(Subtitle, LanePrefix);
/// Full, unclipped text for the hover tooltip.
public string FullText => string.IsNullOrEmpty(Subtitle) ? Title : Title + " — " + Subtitle;
}
/// One laid-out connector.
/// Source node key.
/// Target node key.
/// SVG path data (orthogonal, culture-invariant).
/// Accent colour — a central↔site hop.
/// Dashed — an external actor (the tag source responding).
/// Light — an edge of the prediction, not of a run.
public sealed record FlowLayoutEdge(string From, string To, string Path, bool CrossesBoundary, bool External, bool PredictedOnly);
/// A computed swimlane layout.
/// Nodes with coordinates.
/// Connectors with path data.
/// Canvas width in SVG units.
/// Canvas height in SVG units.
/// Per-lane sublabel (index = ), empty when unknown.
/// Per-lane dotted prefix factored out of the node titles (index = ), empty when none.
public sealed record FlowLayout(
IReadOnlyList Nodes,
IReadOnlyList Edges,
double Width,
double Height,
IReadOnlyList LaneSublabels,
IReadOnlyList LanePrefixes)
{
/// True when there is nothing to draw.
public bool IsEmpty => Nodes.Count == 0;
}
///
/// 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 , a , or both —
/// in which case predicted nodes the run never produced are greyed. The layout is a pure
/// function () so it is unit-tested without rendering; every
/// coordinate string is -formatted.
///
public partial class FlowGraphView : ComponentBase
{
/// Lane height in SVG units.
public const double LaneHeight = 110;
/// Node width.
public const double NodeWidth = 150;
/// Node height (eyebrow row + a two-line title + subtitle inside the lane's 110).
public const double NodeHeight = 84;
/// Left edge of column 0 (the lane label gutter sits to its left; wide enough for a 22-character mono sublabel).
public const double ColumnOrigin = 160;
/// Column pitch.
public const double ColumnPitch = 170;
/// Top padding inside a lane.
public const double LanePad = 16;
/// Right-hand canvas padding.
public const double RightPad = 20;
private const int LaneCount = 3;
/// The real graph (from a run or a replay); null before a run.
[Parameter] public FlowGraph? Graph { get; set; }
/// The predicted flow; drawn alone before a run, overlaid (ghosts) after one.
[Parameter] public PredictedFlow? Predicted { get; set; }
/// The selected real step's event id.
[Parameter] public Guid? SelectedEventId { get; set; }
/// Raised when a real step is clicked or activated with Enter/Space.
[Parameter] public EventCallback OnSelect { get; set; }
/// Zoom factor applied to the rendered size (the viewBox is fixed).
[Parameter] public double Zoom { get; set; } = 1.0;
private FlowLayout _layout = ComputeLayout(null, null);
///
protected override void OnParametersSet()
{
_layout = ComputeLayout(Graph, Predicted);
}
/// Formats a coordinate culture-invariantly.
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);
/// Clips a gutter label to what fits, keeping the full text in a title.
public static string Clip(string? text, int max)
{
if (string.IsNullOrEmpty(text)) return string.Empty;
return text.Length <= max ? text : text[..(max - 1)] + "…";
}
///
/// The longest dotted prefix (ending in .) shared by every title, or null when there are
/// fewer than two titles, no common prefix, or stripping it would leave some title empty. Node
/// titles on the Tags lane are attribute paths that mostly differ in their last segment, so the
/// shared head is shown once in the lane gutter and each node shows the tail that tells it apart.
///
public static string? CommonDottedPrefix(IReadOnlyList titles)
{
if (titles.Count < 2) return null;
var prefix = titles[0];
foreach (var t in titles.Skip(1))
{
var n = 0;
while (n < prefix.Length && n < t.Length && prefix[n] == t[n]) n++;
prefix = prefix[..n];
if (prefix.Length == 0) return null;
}
var dot = prefix.LastIndexOf('.');
if (dot < 0) return null;
prefix = prefix[..(dot + 1)];
return titles.All(t => t.Length > prefix.Length) ? prefix : null;
}
///
/// Inserts zero-width spaces (U+200B) after ., /, _ and at lower→upper
/// camel-case boundaries so a wrapped title breaks at DelmiaRecipe|Download rather than
/// mid-word; the browser still falls back to breaking anywhere for a single overlong segment.
///
public static string WrapHints(string? text)
{
if (string.IsNullOrEmpty(text)) return string.Empty;
var sb = new StringBuilder(text.Length + 8);
for (var i = 0; i < text.Length; i++)
{
var c = text[i];
if (i > 0 && char.IsUpper(c) && char.IsLower(text[i - 1]))
sb.Append('\u200B');
sb.Append(c);
if (c is '.' or '/' or '_' && i < text.Length - 1)
sb.Append('\u200B');
}
return sb.ToString();
}
///
/// Removes where it starts or a comma-separated
/// item within it (the write subtitle is a.X = 1, a.Y = 2); null/empty text passes through.
///
public static string? StripPrefix(string? text, string? prefix)
{
if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(prefix)) return text;
var s = text.StartsWith(prefix, StringComparison.Ordinal) ? text[prefix.Length..] : text;
return s.Replace(", " + prefix, ", ", StringComparison.Ordinal);
}
///
/// Emits an SVG <text> element as pre-encoded markup. Razor reserves the
/// <text> 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.
///
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("');
if (title != null) sb.Append("").Append(System.Net.WebUtility.HtmlEncode(title)).Append("");
sb.Append(System.Net.WebUtility.HtmlEncode(content)).Append("");
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;
///
/// Pure layout: assigns lanes and columns, resolves same-lane collisions by bumping to the
/// next free column, computes coordinates and orthogonal connector paths.
///
/// The real graph, or null.
/// The prediction, or null.
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();
var edges = new List();
var sublabels = new string[LaneCount];
// Node keys in insertion order + adjacency for the topological pass.
var keys = new List();
var lane = new Dictionary(StringComparer.Ordinal);
var preds = new Dictionary>(StringComparer.Ordinal);
var rawEdges = new List<(string From, string To, bool Crosses, bool External, bool PredictedOnly)>();
var stepByKey = new Dictionary(StringComparer.Ordinal);
var predByKey = new Dictionary(StringComparer.Ordinal);
var ghostKeys = new HashSet(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(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);
}
// A lane whose real titles share a dotted head (Tags: "DelmiaReceiver.X", "DelmiaReceiver.Y")
// shows that head once in the gutter and only the tail on each node.
var prefixes = new string?[LaneCount];
for (var l = 0; l < LaneCount; l++)
{
var titles = keys.Where(k => lane[k] == (FlowLane)l && stepByKey.ContainsKey(k))
.Select(k => stepByKey[k].Title).ToList();
prefixes[l] = CommonDottedPrefix(titles);
}
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))
{
LanePrefix = step is null ? null : prefixes[(int)lane[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(),
prefixes.Select(p => p ?? string.Empty).ToArray());
}
private static string JoinLabel(string? a, string? b)
=> string.IsNullOrEmpty(a) ? b ?? string.Empty : string.IsNullOrEmpty(b) ? a : $"{a} · {b}";
///
/// Orthogonal connector from the right-middle of to the left-middle
/// of : 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.
///
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)}";
}
}