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)}"; } }