725 lines
34 KiB
C#
725 lines
34 KiB
C#
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>
|
|
/// The dotted prefix shared by every real title in this node's lane (e.g. <c>DelmiaReceiver.</c>),
|
|
/// factored out of the node text and shown once in the lane gutter; null when the lane has none.
|
|
/// </summary>
|
|
public string? LanePrefix { get; init; }
|
|
|
|
/// <summary>Title with <see cref="LanePrefix"/> removed — the discriminating tail of a dotted path.</summary>
|
|
public string DisplayTitle => FlowGraphView.StripPrefix(Title, LanePrefix) ?? Title;
|
|
|
|
/// <summary>
|
|
/// Subtitle with <see cref="LanePrefix"/> 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.
|
|
/// </summary>
|
|
public string? DisplaySubtitle => (Step != null ? FlowGraphView.ExceptionSubtitle(Step) : null)
|
|
?? FlowGraphView.StripPrefix(Subtitle, LanePrefix);
|
|
|
|
/// <summary>Full, unclipped text for the hover tooltip.</summary>
|
|
public string FullText => string.IsNullOrEmpty(Subtitle) ? Title : Title + " — " + Subtitle;
|
|
}
|
|
|
|
/// <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>
|
|
/// <param name="Async">Dashed — a fire-and-forget branch (cached call / notification) the source never waited for (plan D4).</param>
|
|
/// <param name="Spawn">Bolt badge on the connector — the target is the first step of a trigger-spawned child execution (plan D9).</param>
|
|
public sealed record FlowLayoutEdge(string From, string To, string Path, bool CrossesBoundary, bool External, bool PredictedOnly, bool Async = false, bool Spawn = false);
|
|
|
|
/// <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>
|
|
/// <param name="LanePrefixes">Per-lane dotted prefix factored out of the node titles (index = <see cref="FlowLane"/>), empty when none.</param>
|
|
public sealed record FlowLayout(
|
|
IReadOnlyList<FlowLayoutNode> Nodes,
|
|
IReadOnlyList<FlowLayoutEdge> Edges,
|
|
double Width,
|
|
double Height,
|
|
IReadOnlyList<string> LaneSublabels,
|
|
IReadOnlyList<string> LanePrefixes)
|
|
{
|
|
/// <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). 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
|
|
/// <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 (eyebrow row + a two-line title + subtitle inside the lane's 110).</summary>
|
|
public const double NodeHeight = 84;
|
|
/// <summary>Left edge of column 0 (the lane label gutter sits to its left; wide enough for a 22-character mono sublabel).</summary>
|
|
public const double ColumnOrigin = 160;
|
|
/// <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;
|
|
|
|
/// <summary>Number of swimlanes (<see cref="FlowLane"/> members, in order).</summary>
|
|
public const int LaneCount = 4;
|
|
/// <summary>Most distinct targets the External gutter names before folding the rest into <c>+n</c>.</summary>
|
|
public const int ExternalSublabelTargets = 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;
|
|
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();
|
|
}
|
|
|
|
/// <summary>Every connector ends in an arrowhead — a spawn edge marks itself with a bolt badge on the line instead (<see cref="SpawnBadgeFor"/>).</summary>
|
|
private static string MarkerFor(FlowLayoutEdge e)
|
|
=> e.CrossesBoundary ? "url(#fg-arrow-accent)" : "url(#fg-arrow)";
|
|
|
|
/// <summary>True for a real outbound step (API call / database / notification) — the kinds that carry a status pill instead of a subtitle line.</summary>
|
|
private static bool HasPill(FlowLayoutNode n)
|
|
=> n.Step != null && n.Kind is FlowStepKind.ApiCall or FlowStepKind.DbCall or FlowStepKind.Notify;
|
|
|
|
/// <summary>Pill wording for an outbound step's folded outcome (plan D3: the latest lifecycle row decides).</summary>
|
|
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",
|
|
};
|
|
|
|
/// <summary>
|
|
/// The text after the pill: <c>sync</c> / <c>cached</c> / <c>notify</c> per the node anatomy, with a
|
|
/// sync database call's operation detail (<c>write · 3 rows</c>) 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.
|
|
/// </summary>
|
|
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<Commons.Types.Enums.AuditStatus>(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";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Log lines on a step folded to per-severity counts for the footer badge (step-logs design
|
|
/// 2026-09-09 D8). <c>error</c> and <c>warn</c> are counted by name (ordinal-ignore-case);
|
|
/// everything else — <c>info</c>, <c>debug</c>, an unknown level — counts as info, so the badge
|
|
/// never silently drops a line.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>Hover text for the log badge — the non-zero levels only ("1 error, 2 warnings, 4 info").</summary>
|
|
internal static string LogTitle((int Errors, int Warnings, int Info) counts)
|
|
{
|
|
var (errors, warnings, info) = counts;
|
|
var parts = new List<string>(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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The subtitle a failed or timed-out step shows instead of its own: <c>{Type}: {message}</c>, where
|
|
/// the type is the exception's name without its namespace, parsed from the first line of the captured
|
|
/// <see cref="FlowStep.ErrorDetail"/> (a .NET <c>ToString()</c> opens with <c>Namespace.Type: message</c>).
|
|
/// A wrapper's first line chains its inner exceptions with <c>" ---> "</c>; the LAST segment is taken,
|
|
/// since the innermost exception is the one the script author has to act on. The namespace is stripped
|
|
/// only from the head of the type name — any generic arity/argument suffix (<c>`1[System.String]</c>)
|
|
/// is kept verbatim. Falls back to the line itself when it carries no <c>": "</c>, and to
|
|
/// <see cref="FlowStep.ErrorMessage"/> when there is no detail. Null for every non-failed step.
|
|
/// </summary>
|
|
internal static string? ExceptionSubtitle(FlowStep s)
|
|
{
|
|
if (s.Outcome is not (FlowStepOutcome.Failed or FlowStepOutcome.TimedOut)) return null;
|
|
if (string.IsNullOrWhiteSpace(s.ErrorDetail)) return s.ErrorMessage;
|
|
|
|
var first = s.ErrorDetail.Split('\n', 2)[0].TrimEnd('\r').Trim();
|
|
// A wrapper (AggregateException, a rethrow) chains inner exceptions on the same line — the last is innermost.
|
|
var inner = first.LastIndexOf(" ---> ", StringComparison.Ordinal);
|
|
if (inner >= 0) first = first[(inner + " ---> ".Length)..].Trim();
|
|
if (first.Length == 0) return s.ErrorMessage;
|
|
|
|
var split = first.IndexOf(": ", StringComparison.Ordinal);
|
|
if (split < 0) return first;
|
|
|
|
var type = first[..split];
|
|
var message = first[(split + 2)..];
|
|
// Shorten only the leading namespace: everything from a generic marker on is part of the name.
|
|
var generic = type.IndexOfAny(['`', '[']);
|
|
var head = generic < 0 ? type : type[..generic];
|
|
var dot = head.LastIndexOf('.');
|
|
if (dot >= 0 && dot < head.Length - 1)
|
|
type = head[(dot + 1)..] + (generic < 0 ? string.Empty : type[generic..]);
|
|
return type + ": " + 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",
|
|
};
|
|
|
|
/// <summary>
|
|
/// 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 <c>+</c> (<c>+4.1 s</c>), since that
|
|
/// span is elapsed time downstream of the script, not time the script spent (node anatomy).
|
|
/// </summary>
|
|
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";
|
|
}
|
|
|
|
/// <summary>Clips a gutter label to what fits, 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>
|
|
/// The longest dotted prefix (ending in <c>.</c>) 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.
|
|
/// </summary>
|
|
public static string? CommonDottedPrefix(IReadOnlyList<string> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Inserts zero-width spaces (U+200B) after <c>.</c>, <c>/</c>, <c>_</c> and at lower→upper
|
|
/// camel-case boundaries so a wrapped title breaks at <c>DelmiaRecipe|Download</c> rather than
|
|
/// mid-word; the browser still falls back to breaking anywhere for a single overlong segment.
|
|
/// </summary>
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Removes <paramref name="prefix"/> where it starts <paramref name="text"/> or a comma-separated
|
|
/// item within it (the write subtitle is <c>a.X = 1, a.Y = 2</c>); null/empty text passes through.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <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());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Where an async branch's <c>async</c> 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.
|
|
/// </summary>
|
|
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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Midpoint of the vertical run of the connector <see cref="OrthogonalPath"/> / <see cref="AsyncPath"/>
|
|
/// draws from <paramref name="a"/> to <paramref name="b"/> (the middle of a same-lane straight line).
|
|
/// Kept in step with those two path builders.
|
|
/// </summary>
|
|
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;
|
|
|
|
/// <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, bool Async, bool Spawn)>();
|
|
var externalTargets = new List<string>();
|
|
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;
|
|
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<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);
|
|
}
|
|
}
|
|
|
|
// 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}";
|
|
|
|
/// <summary>
|
|
/// The External gutter: the distinct targets touched, in first-seen order, joined with <c>·</c> —
|
|
/// up to <see cref="ExternalSublabelTargets"/> named, the rest folded into <c>+n</c>
|
|
/// (<c>Delmia.AckRecipe · MesDb · Planners +1</c>). Empty when the lane is unused.
|
|
/// </summary>
|
|
public static string ExternalSublabel(IReadOnlyList<string> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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 <see cref="OrthogonalPath"/> when
|
|
/// the target is not below the source.
|
|
/// </summary>
|
|
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)}";
|
|
}
|
|
|
|
/// <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)}";
|
|
}
|
|
}
|