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 — except on a /// failed/timed-out real step, where the exception's type and message take the line instead /// (step-logs design 2026-09-09 D8). A lane prefix is a dotted attribute head, never part of an /// exception line, so the exception text is shown verbatim. /// public string? DisplaySubtitle => (Step != null ? FlowGraphView.ExceptionSubtitle(Step) : null) ?? 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. /// Dashed — a fire-and-forget branch (cached call / notification) the source never waited for (plan D4). /// Bolt badge on the connector — the target is the first step of a trigger-spawned child execution (plan D9). public sealed record FlowLayoutEdge(string From, string To, string Path, bool CrossesBoundary, bool External, bool PredictedOnly, bool Async = false, bool Spawn = false); /// 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). Four fixed lanes /// (Central / Site / Tags / External — the fourth added by the 2026-09-06 outbound-calls plan, D2), /// 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; /// Number of swimlanes ( members, in order). public const int LaneCount = 4; /// Most distinct targets the External gutter names before folding the rest into +n. public const int ExternalSublabelTargets = 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; case FlowStepOutcome.Pending: sb.Append(" pending"); break; case FlowStepOutcome.Parked: sb.Append(" parked"); 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.Async) sb.Append(" async"); if (e.Spawn) sb.Append(" spawn"); if (e.PredictedOnly) sb.Append(" predicted"); return sb.ToString(); } /// Every connector ends in an arrowhead — a spawn edge marks itself with a bolt badge on the line instead (). private static string MarkerFor(FlowLayoutEdge e) => e.CrossesBoundary ? "url(#fg-arrow-accent)" : "url(#fg-arrow)"; /// True for a real outbound step (API call / database / notification) — the kinds that carry a status pill instead of a subtitle line. private static bool HasPill(FlowLayoutNode n) => n.Step != null && n.Kind is FlowStepKind.ApiCall or FlowStepKind.DbCall or FlowStepKind.Notify; /// Pill wording for an outbound step's folded outcome (plan D3: the latest lifecycle row decides). public static string PillText(FlowStepOutcome outcome) => outcome switch { FlowStepOutcome.Ok => "Ok", FlowStepOutcome.Pending => "Pending", FlowStepOutcome.Parked => "Parked", FlowStepOutcome.Failed => "Failed", FlowStepOutcome.TimedOut => "Timed out", FlowStepOutcome.NotTraced => "Not traced", _ => outcome.ToString(), }; private static string PillClass(FlowStepOutcome outcome) => outcome switch { FlowStepOutcome.Ok => "fg-pill ok", FlowStepOutcome.Pending => "fg-pill pending", FlowStepOutcome.Parked => "fg-pill parked", FlowStepOutcome.Failed or FlowStepOutcome.TimedOut => "fg-pill failed", _ => "fg-pill", }; /// /// The text after the pill: sync / cached / notify per the node anatomy, with a /// sync database call's operation detail (write · 3 rows) kept in front of it. The projector's /// subtitle already leads with the audit status, which the pill now carries, so that head is dropped. /// public static string OutboundMode(FlowStepKind kind, string? subtitle) { if (kind == FlowStepKind.Notify) return "notify"; var detail = subtitle ?? string.Empty; // Strip a leading "{AuditStatus} · " / a bare status word (the pill says it). var head = detail.Split(" · ", 2, StringSplitOptions.None); if (head.Length > 0 && Enum.TryParse(head[0], ignoreCase: false, out _)) detail = head.Length == 2 ? head[1] : string.Empty; if (detail is "cached" or "sync") return detail; if (detail.EndsWith(" · cached", StringComparison.Ordinal) || detail.EndsWith(" · sync", StringComparison.Ordinal)) return detail; return detail.Length == 0 ? "sync" : detail + " · sync"; } /// /// Log lines on a step folded to per-severity counts for the footer badge (step-logs design /// 2026-09-09 D8). error and warn are counted by name (ordinal-ignore-case); /// everything else — info, debug, an unknown level — counts as info, so the badge /// never silently drops a line. /// internal static (int Errors, int Warnings, int Info) LogCounts(FlowStep? s) { if (s?.Logs is not { Count: > 0 } lines) return (0, 0, 0); var errors = 0; var warnings = 0; var info = 0; foreach (var line in lines) { if (string.Equals(line.Level, "error", StringComparison.OrdinalIgnoreCase)) errors++; else if (string.Equals(line.Level, "warn", StringComparison.OrdinalIgnoreCase)) warnings++; else info++; } return (errors, warnings, info); } /// Hover text for the log badge — the non-zero levels only ("1 error, 2 warnings, 4 info"). internal static string LogTitle((int Errors, int Warnings, int Info) counts) { var (errors, warnings, info) = counts; var parts = new List(3); if (errors > 0) parts.Add($"{errors} error{(errors == 1 ? "" : "s")}"); if (warnings > 0) parts.Add($"{warnings} warning{(warnings == 1 ? "" : "s")}"); if (info > 0) parts.Add($"{info} info"); return string.Join(", ", parts); } /// /// The subtitle a failed or timed-out step shows instead of its own: {Type}: {message}, where /// the type is the exception's name without its namespace. Parsing is /// (final review M1 — the same parser the step detail panel /// uses), which takes the first line of the captured and follows /// its " ---> " chain to the innermost exception. Falls back to the line itself when it /// carries no ": ", and to when there is no usable detail. /// Null for every non-failed step. /// internal static string? ExceptionSubtitle(FlowStep s) { if (s.Outcome is not (FlowStepOutcome.Failed or FlowStepOutcome.TimedOut)) return null; if (ExceptionHeader.Parse(s.ErrorDetail, shortenType: true) is not { } head) return s.ErrorMessage; return head.Message.Length == 0 ? head.Type : head.Type + ": " + head.Message; } 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.ApiCall => "API CALL", FlowStepKind.DbCall => "DATABASE", FlowStepKind.Notify => "NOTIFY", _ => "STEP", }; /// /// The head-row duration: a step's own milliseconds, or — for a folded outbound lifecycle of more /// than one row — the submit→terminal wall time with a leading + (+4.1 s), since that /// span is elapsed time downstream of the script, not time the script spent (node anatomy). /// public static string Duration(FlowLayoutNode n) { if (n.Step?.DurationMs is not int ms) return n.PredictedOnly ? "predicted" : string.Empty; if (n.Step.Lifecycle is { Count: > 1 }) return ms < 1000 ? "+" + ms.ToString(CultureInfo.InvariantCulture) + " ms" : "+" + (ms / 1000.0).ToString("0.#", CultureInfo.InvariantCulture) + " s"; return ms.ToString(CultureInfo.InvariantCulture) + " ms"; } /// 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()); } /// /// Where an async branch's async tag sits: just right of the drop into the target's top, in /// the strip between lanes (mirrors the mockup). Null for every non-async edge. /// private (string X, string Y)? AsyncTagFor(FlowLayoutEdge e) { if (!e.Async) return null; var b = _layout.Nodes.FirstOrDefault(n => n.Key == e.To); return b is null ? null : (F(b.X + 22), F(b.Y - 4)); } /// /// Where a spawn edge's bolt badge sits: centred on the connector's vertical run (the riser between the /// lanes), so the badge is read as a property of the hop and the arrowhead stays an arrowhead. Null for /// every non-spawn edge. /// private (string X, string Y)? SpawnBadgeFor(FlowLayoutEdge e) { if (!e.Spawn) return null; var a = _layout.Nodes.FirstOrDefault(n => n.Key == e.From); var b = _layout.Nodes.FirstOrDefault(n => n.Key == e.To); if (a is null || b is null) return null; var (x, y) = SpawnBadgePoint(a, b, e.Async); return (F(x), F(y)); } /// /// Midpoint of the vertical run of the connector / /// draws from to (the middle of a same-lane straight line). /// Kept in step with those two path builders. /// public static (double X, double Y) SpawnBadgePoint(FlowLayoutNode a, FlowLayoutNode b, bool isAsync) { var x1 = a.X + NodeWidth; var y1 = a.Y + NodeHeight / 2; var x2 = b.X; var y2 = b.Y + NodeHeight / 2; if (isAsync && b.Y > a.Y) { // AsyncPath: first vertical run drops from the source's bottom-centre to the strip above the target. var strip = b.Y - LanePad / 2; return (a.X + NodeWidth / 2, (a.Y + NodeHeight + strip) / 2); } if (Math.Abs(y1 - y2) < 0.01) return ((x1 + x2) / 2, y1); if (x2 >= x1) return (x1 + (x2 - x1) / 2, (y1 + y2) / 2); var drop = Math.Max(a.Y, b.Y) + NodeHeight + 8; return ((x1 + 8 + x2 - 8) / 2, drop); } 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, bool Async, bool Spawn)>(); var externalTargets = new List(); 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; if (s.Lane == FlowLane.External && !string.IsNullOrWhiteSpace(s.Target) && !externalTargets.Contains(s.Target, StringComparer.Ordinal)) externalTargets.Add(s.Target); } sublabels[(int)FlowLane.External] = ExternalSublabel(externalTargets); 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, e.Async, e.Spawn)); } if (hasPredicted) { // Overlay: match predicted nodes to real steps by (kind, title) first, falling back to kind // alone; the leftovers are ghosts. Kind-only matching pairs steps to predicted nodes in // declaration order, which mismatches a method with multiple same-kind calls when only some // of them actually ran (arch-review F10). var unmatched = predicted!.Nodes.ToList(); foreach (var s in graph.Steps) { var m = unmatched.FirstOrDefault(p => p.Kind == s.Kind && string.Equals(p.Title, s.Title, StringComparison.OrdinalIgnoreCase)) ?? 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, e.Async, false)); } } 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); } } // The Response is the flow's final node: it sits right of everything real regardless of // its predecessor's column (the builder links it from the route call — D9 — which would // otherwise drag it back to column 2 while the site work sits to its right). var isResponse = (stepByKey.TryGetValue(k, out var sk) && sk.Kind == FlowStepKind.Response) || (predByKey.TryGetValue(k, out var pk) && pk.Kind == FlowStepKind.Response); if (isResponse && column.Count > 0) col = Math.Max(col, column.Values.Max() + 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, isAsync, spawn) in rawEdges) { var a = byKey[from]; var b = byKey[to]; var path = isAsync ? AsyncPath(a, b) : OrthogonalPath(a, b); edges.Add(new FlowLayoutEdge(from, to, path, crosses, external, predictedOnly, isAsync, spawn)); } 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}"; /// /// The External gutter: the distinct targets touched, in first-seen order, joined with · — /// up to named, the rest folded into +n /// (Delmia.AckRecipe · MesDb · Planners +1). Empty when the lane is unused. /// public static string ExternalSublabel(IReadOnlyList distinctTargets) { if (distinctTargets.Count == 0) return string.Empty; var shown = string.Join(" · ", distinctTargets.Take(ExternalSublabelTargets)); var rest = distinctTargets.Count - ExternalSublabelTargets; return rest > 0 ? $"{shown} +{rest.ToString(CultureInfo.InvariantCulture)}" : shown; } /// /// Connector for an async branch (cached call / notification): leaves the source's bottom-centre, /// runs along the strip above the target's lane and drops into the target's top — so it never /// crosses a node sitting between the two lanes. Falls back to when /// the target is not below the source. /// public static string AsyncPath(FlowLayoutNode a, FlowLayoutNode b) { if (b.Y <= a.Y) return OrthogonalPath(a, b); var x1 = a.X + NodeWidth / 2; var y1 = a.Y + NodeHeight; var strip = b.Y - LanePad / 2; var x2 = b.X + 16; return $"M {F(x1)} {F(y1)} V {F(strip)} H {F(x2)} V {F(b.Y)}"; } /// /// 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)}"; } }