038eda5d91
The swimlane's node text was clipped by character count (20/24), so dotted attribute
paths showed only their shared head ("DelmiaReceiver.Reci…" five times in a row) and
the subtitle spilled past the box. It was also unstyled: the SVG <text> elements are
emitted as raw markup (Razor reserves the <text> tag), so they never carried the CSS
isolation scope attribute and every .fg-* rule silently missed.
- Node body is now HTML inside an SVG foreignObject: the browser measures the text,
the title wraps to two lines and ellipsizes by width, the subtitle by width on one
line, nothing can overflow the box, and the div's title attribute carries the full
unstripped text as a tooltip. Node height 78 -> 84.
- A lane whose real titles share a dotted head (CommonDottedPrefix) shows it once in
the lane gutter ("DelmiaReceiver.*") and each node shows only the tail that tells
it apart; the write subtitle strips the same head per item. Predicted-only nodes
are never stripped.
- WrapHints inserts zero-width spaces at camel-case and ./_/ boundaries so a wrapped
title breaks at "DelmiaRecipe|Download" rather than mid-word.
- Gutter widened 130 -> 160 with the sublabel clip raised to 22 so "site-a · DelmiaSim01"
fits; the remaining SvgText gutter labels are styled through ::deep.
Verified live on the rig (replay of the Task 22 run): all three lanes readable, no
overflow, gutter shows the factored prefix. CentralUI suite 1199 pass.
Claude-Session: https://claude.ai/code/session_01GSLkkHKz9JrAtmJjmQK3cP
482 lines
21 KiB
C#
482 lines
21 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.</summary>
|
|
public string? DisplaySubtitle => 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>
|
|
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>
|
|
/// <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). 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 (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;
|
|
|
|
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 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());
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
// 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}";
|
|
|
|
/// <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)}";
|
|
}
|
|
}
|