From 300fadc4db6a9b02131b0359d8f8d500396704ba Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 08:57:37 +0700 Subject: [PATCH 1/6] P1D.2D: add coherent phasor role-set selection --- Services/ComtradePhasorWorkspaceMath.cs | 109 ++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 Services/ComtradePhasorWorkspaceMath.cs diff --git a/Services/ComtradePhasorWorkspaceMath.cs b/Services/ComtradePhasorWorkspaceMath.cs new file mode 100644 index 000000000..e9a0a0639 --- /dev/null +++ b/Services/ComtradePhasorWorkspaceMath.cs @@ -0,0 +1,109 @@ +namespace ArIED61850Tester.Services; + +internal sealed record ComtradePhasorChannelDescriptor( + uint Index, + int Role, + int PhaseRole, + string Label, + string Phase, + string Circuit, + string Units); + +internal static class ComtradePhasorWorkspaceMath +{ + internal const int RoleVoltage = 1; + internal const int RoleCurrent = 2; + internal const int PhaseL1 = 1; + internal const int PhaseL2 = 2; + internal const int PhaseL3 = 3; + internal const int PhaseNeutral = 4; + + internal static IReadOnlyList SelectRoleSet( + IEnumerable channels, + int role) + { + ArgumentNullException.ThrowIfNull(channels); + var candidates = channels + .Where(channel => channel.Role == role && IsDisplayPhase(channel.PhaseRole)) + .OrderBy(channel => channel.Index) + .ToArray(); + if (candidates.Length == 0) + return Array.Empty(); + + // A polar diagram has one radial scale, therefore do not silently compare unlike + // engineering units. Prefer the most complete same-circuit + same-unit phase set. + var coherentGroups = candidates + .GroupBy(channel => (Circuit: Normalize(channel.Circuit), Units: Normalize(channel.Units))) + .Select(group => new + { + Items = group.ToArray(), + PhaseCount = group.Select(item => item.PhaseRole).Distinct().Count(), + FirstIndex = group.Min(item => item.Index) + }) + .OrderByDescending(group => group.PhaseCount) + .ThenBy(group => group.FirstIndex) + .ToArray(); + + var chosen = coherentGroups[0].Items; + if (coherentGroups[0].PhaseCount < 2) + { + // Some recorders leave circuit blank/inconsistent per phase. In that case retain the + // dimension-safe unit boundary and choose the unit family with the best phase coverage. + chosen = candidates + .GroupBy(channel => Normalize(channel.Units)) + .Select(group => new + { + Items = group.ToArray(), + PhaseCount = group.Select(item => item.PhaseRole).Distinct().Count(), + FirstIndex = group.Min(item => item.Index) + }) + .OrderByDescending(group => group.PhaseCount) + .ThenBy(group => group.FirstIndex) + .First() + .Items; + } + + return chosen + .GroupBy(channel => channel.PhaseRole) + .Select(group => group.OrderBy(channel => channel.Index).First()) + .OrderBy(channel => PhaseRank(channel.PhaseRole)) + .ThenBy(channel => channel.Index) + .ToArray(); + } + + internal static int PhaseRoleFromCanonicalName(string? phase) + => (phase ?? string.Empty).Trim().ToUpperInvariant() switch + { + "L1" or "A" => PhaseL1, + "L2" or "B" => PhaseL2, + "L3" or "C" => PhaseL3, + "N" or "E" => PhaseNeutral, + _ => 0 + }; + + internal static string CanonicalPhaseName(int phaseRole, string? fallback = null) + => phaseRole switch + { + PhaseL1 => "L1", + PhaseL2 => "L2", + PhaseL3 => "L3", + PhaseNeutral => "E", + _ => string.IsNullOrWhiteSpace(fallback) ? "Other" : fallback.Trim() + }; + + private static bool IsDisplayPhase(int phaseRole) + => phaseRole is PhaseL1 or PhaseL2 or PhaseL3 or PhaseNeutral; + + private static int PhaseRank(int phaseRole) + => phaseRole switch + { + PhaseL1 => 0, + PhaseL2 => 1, + PhaseL3 => 2, + PhaseNeutral => 3, + _ => 9 + }; + + private static string Normalize(string? value) + => (value ?? string.Empty).Trim().ToUpperInvariant(); +} From 89ccb0232a6b3513334a79ad54bdf969234d46a8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 08:58:01 +0700 Subject: [PATCH 2/6] P1D.2D: cover phasor role-set selection --- .../ComtradePhasorWorkspaceMathTests.cs | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 tests/ARSAS.Tests/ComtradePhasorWorkspaceMathTests.cs diff --git a/tests/ARSAS.Tests/ComtradePhasorWorkspaceMathTests.cs b/tests/ARSAS.Tests/ComtradePhasorWorkspaceMathTests.cs new file mode 100644 index 000000000..529da7532 --- /dev/null +++ b/tests/ARSAS.Tests/ComtradePhasorWorkspaceMathTests.cs @@ -0,0 +1,101 @@ +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class ComtradePhasorWorkspaceMathTests +{ + [Fact] + public void SelectRoleSet_OrdersElectricalPhasesAndKeepsVoltageCurrentIndependent() + { + var channels = new[] + { + D(8, 2, 3, "IC", "C", "Bay 1", "A"), + D(2, 1, 2, "VB", "B", "Bay 1", "V"), + D(7, 2, 1, "IA", "A", "Bay 1", "A"), + D(1, 1, 1, "VA", "A", "Bay 1", "V"), + D(3, 1, 3, "VC", "C", "Bay 1", "V"), + D(9, 2, 2, "IB", "B", "Bay 1", "A") + }; + + var voltage = ComtradePhasorWorkspaceMath.SelectRoleSet(channels, ComtradePhasorWorkspaceMath.RoleVoltage); + var current = ComtradePhasorWorkspaceMath.SelectRoleSet(channels, ComtradePhasorWorkspaceMath.RoleCurrent); + + Assert.Equal(new uint[] { 1, 2, 3 }, voltage.Select(item => item.Index)); + Assert.Equal(new uint[] { 7, 9, 8 }, current.Select(item => item.Index)); + } + + [Fact] + public void SelectRoleSet_PrefersMostCompleteSameCircuitAndUnitFamily() + { + var channels = new[] + { + D(0, 1, 1, "VA-Bay2", "A", "Bay 2", "V"), + D(1, 1, 1, "VA", "A", "Bay 1", "V"), + D(2, 1, 2, "VB", "B", "Bay 1", "V"), + D(3, 1, 3, "VC", "C", "Bay 1", "V"), + D(4, 1, 2, "VB-kV", "B", "Bay 1", "kV") + }; + + var selected = ComtradePhasorWorkspaceMath.SelectRoleSet(channels, ComtradePhasorWorkspaceMath.RoleVoltage); + + Assert.Equal(new uint[] { 1, 2, 3 }, selected.Select(item => item.Index)); + Assert.All(selected, item => Assert.Equal("V", item.Units)); + Assert.All(selected, item => Assert.Equal("Bay 1", item.Circuit)); + } + + [Fact] + public void SelectRoleSet_FallsBackToSameUnitWhenCircuitMetadataIsSparse() + { + var channels = new[] + { + D(0, 2, 1, "IA", "A", "CT-A", "A"), + D(1, 2, 2, "IB", "B", "CT-B", "A"), + D(2, 2, 3, "IC", "C", "CT-C", "A"), + D(3, 2, 1, "IA-kA", "A", "Other", "kA") + }; + + var selected = ComtradePhasorWorkspaceMath.SelectRoleSet(channels, ComtradePhasorWorkspaceMath.RoleCurrent); + + Assert.Equal(new uint[] { 0, 1, 2 }, selected.Select(item => item.Index)); + Assert.All(selected, item => Assert.Equal("A", item.Units)); + } + + [Fact] + public void SelectRoleSet_KeepsOneChannelPerPhaseAndIgnoresOtherPhase() + { + var channels = new[] + { + D(0, 1, 1, "VA-1", "A", "Bay", "V"), + D(1, 1, 1, "VA-duplicate", "A", "Bay", "V"), + D(2, 1, 4, "VE", "E", "Bay", "V"), + D(3, 1, 0, "Aux", "", "Bay", "V") + }; + + var selected = ComtradePhasorWorkspaceMath.SelectRoleSet(channels, ComtradePhasorWorkspaceMath.RoleVoltage); + + Assert.Equal(new uint[] { 0, 2 }, selected.Select(item => item.Index)); + } + + [Theory] + [InlineData("A", 1)] + [InlineData("L1", 1)] + [InlineData("B", 2)] + [InlineData("L2", 2)] + [InlineData("C", 3)] + [InlineData("L3", 3)] + [InlineData("N", 4)] + [InlineData("E", 4)] + [InlineData("Other", 0)] + public void PhaseRoleFromCanonicalName_MapsExpectedNames(string phase, int expected) + => Assert.Equal(expected, ComtradePhasorWorkspaceMath.PhaseRoleFromCanonicalName(phase)); + + private static ComtradePhasorChannelDescriptor D( + uint index, + int role, + int phaseRole, + string label, + string phase, + string circuit, + string units) + => new(index, role, phaseRole, label, phase, circuit, units); +} From ba3d05c9d51ff624b13d37e182a925eb100aa549 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 08:58:55 +0700 Subject: [PATCH 3/6] P1D.2D: render dual voltage/current phasor workstation --- Controls/ComtradePhasorView.cs | 237 +++++++++++++++++++++++++-------- 1 file changed, 183 insertions(+), 54 deletions(-) diff --git a/Controls/ComtradePhasorView.cs b/Controls/ComtradePhasorView.cs index 91cd2a4da..228debeb6 100644 --- a/Controls/ComtradePhasorView.cs +++ b/Controls/ComtradePhasorView.cs @@ -13,23 +13,33 @@ internal sealed record ComtradePhasorVector( public sealed class ComtradePhasorView : FrameworkElement { - private IReadOnlyList _vectors = Array.Empty(); - private string _title = "Phasor"; - private string _subtitle = "Select an analog signal"; + private IReadOnlyList _voltageVectors = Array.Empty(); + private IReadOnlyList _currentVectors = Array.Empty(); + private string _referenceLabel = "C1"; + private string _referenceDetail = "Select a valid analysis reference"; + private string _message = string.Empty; - internal void ShowPhasors(string title, string subtitle, IReadOnlyList vectors) + internal void ShowPhasors( + string referenceLabel, + string referenceDetail, + IReadOnlyList voltageVectors, + IReadOnlyList currentVectors) { - _title = title; - _subtitle = subtitle; - _vectors = vectors.Where(v => double.IsFinite(v.MagnitudeRms) && double.IsFinite(v.AngleDegrees)).ToArray(); + _referenceLabel = string.IsNullOrWhiteSpace(referenceLabel) ? "Reference" : referenceLabel; + _referenceDetail = referenceDetail ?? string.Empty; + _voltageVectors = Filter(voltageVectors); + _currentVectors = Filter(currentVectors); + _message = string.Empty; InvalidateVisual(); } internal void ShowMessage(string title, string message) { - _title = title; - _subtitle = message; - _vectors = Array.Empty(); + _referenceLabel = string.IsNullOrWhiteSpace(title) ? "Phasor" : title; + _referenceDetail = message ?? string.Empty; + _voltageVectors = Array.Empty(); + _currentVectors = Array.Empty(); + _message = message ?? string.Empty; InvalidateVisual(); } @@ -38,45 +48,117 @@ protected override void OnRender(DrawingContext dc) base.OnRender(dc); var bounds = new Rect(0, 0, Math.Max(0, ActualWidth), Math.Max(0, ActualHeight)); dc.DrawRectangle(Brushes.White, null, bounds); - if (bounds.Width < 260 || bounds.Height < 220) return; + if (bounds.Width < 320 || bounds.Height < 260) return; var dpi = VisualTreeHelper.GetDpi(this).PixelsPerDip; var body = new Typeface("Segoe UI"); var semibold = new Typeface("Segoe UI Semibold"); - DrawText(dc, _title, 15, semibold, Color.FromRgb(31, 50, 74), new Point(18, 14), dpi); - DrawText(dc, _subtitle, 10.8, body, Color.FromRgb(103, 120, 141), new Point(18, 39), dpi); + var header = new Rect(0, 0, bounds.Width, 58); + dc.DrawRectangle(new SolidColorBrush(Color.FromRgb(248, 250, 253)), null, header); + dc.DrawLine(FrozenPen(Color.FromRgb(225, 232, 241), 1), new Point(0, header.Bottom), new Point(bounds.Right, header.Bottom)); - if (_vectors.Count == 0) + DrawText(dc, $"Fundamental phasors at {_referenceLabel}", 14.5, semibold, + Color.FromRgb(37, 56, 79), new Point(16, 10), dpi); + DrawText(dc, _referenceDetail, 10.2, body, + Color.FromRgb(102, 119, 139), new Point(16, 34), dpi, Math.Max(100, bounds.Width - 32)); + + if (_voltageVectors.Count == 0 && _currentVectors.Count == 0) { - DrawText(dc, "No valid phasor at the analysis reference.", 12, body, - Color.FromRgb(126, 139, 156), new Point(24, 82), dpi); + DrawText(dc, + string.IsNullOrWhiteSpace(_message) ? "No valid voltage or current phasors at this reference." : _message, + 12, body, Color.FromRgb(126, 139, 156), new Point(24, 90), dpi, + Math.Max(100, bounds.Width - 48)); return; } - var legendWidth = Math.Clamp(bounds.Width * 0.29, 210, 300); - var plotArea = new Rect(18, 65, Math.Max(120, bounds.Width - legendWidth - 48), Math.Max(120, bounds.Height - 88)); - var radius = Math.Max(45, Math.Min(plotArea.Width, plotArea.Height) * 0.43); - var center = new Point(plotArea.Left + plotArea.Width * 0.5, plotArea.Top + plotArea.Height * 0.5); + const double outer = 10; + const double gap = 8; + var content = new Rect(outer, header.Bottom + outer, bounds.Width - outer * 2, bounds.Height - header.Bottom - outer * 2); + if (bounds.Width >= 820) + { + var panelWidth = Math.Max(240, (content.Width - gap) / 2.0); + DrawPanel(dc, new Rect(content.Left, content.Top, panelWidth, content.Height), + "VOLTAGE PHASORS", _voltageVectors, dpi, body, semibold); + DrawPanel(dc, new Rect(content.Left + panelWidth + gap, content.Top, content.Width - panelWidth - gap, content.Height), + "CURRENT PHASORS", _currentVectors, dpi, body, semibold); + } + else + { + var panelHeight = Math.Max(190, (content.Height - gap) / 2.0); + DrawPanel(dc, new Rect(content.Left, content.Top, content.Width, panelHeight), + "VOLTAGE PHASORS", _voltageVectors, dpi, body, semibold); + DrawPanel(dc, new Rect(content.Left, content.Top + panelHeight + gap, content.Width, content.Height - panelHeight - gap), + "CURRENT PHASORS", _currentVectors, dpi, body, semibold); + } + } + + private static IReadOnlyList Filter(IReadOnlyList? vectors) + => (vectors ?? Array.Empty()) + .Where(vector => double.IsFinite(vector.MagnitudeRms) && vector.MagnitudeRms >= 0 && double.IsFinite(vector.AngleDegrees)) + .ToArray(); + + private static void DrawPanel( + DrawingContext dc, + Rect panel, + string title, + IReadOnlyList vectors, + double dpi, + Typeface body, + Typeface semibold) + { + if (panel.Width <= 1 || panel.Height <= 1) return; + var background = new SolidColorBrush(Color.FromRgb(252, 253, 255)); background.Freeze(); + dc.DrawRoundedRectangle(background, FrozenPen(Color.FromRgb(205, 216, 229), 1), panel, 5, 5); + DrawText(dc, title, 10.2, semibold, Color.FromRgb(58, 71, 84), new Point(panel.Left + 11, panel.Top + 8), dpi); + + if (vectors.Count == 0) + { + DrawText(dc, "No mapped channels with a valid one-cycle phasor.", 10.5, body, + Color.FromRgb(126, 139, 156), new Point(panel.Left + 14, panel.Top + 43), dpi, + Math.Max(80, panel.Width - 28)); + return; + } + + var distinctUnits = vectors.Select(vector => vector.Units?.Trim() ?? string.Empty) + .Where(unit => unit.Length > 0) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + var unitSummary = distinctUnits.Length == 1 ? distinctUnits[0] : distinctUnits.Length > 1 ? "mixed units" : string.Empty; + DrawText(dc, $"{vectors.Count} vector{(vectors.Count == 1 ? string.Empty : "s")}" + + (string.IsNullOrWhiteSpace(unitSummary) ? string.Empty : $" • {unitSummary}"), + 8.8, body, Color.FromRgb(119, 132, 149), new Point(panel.Left + 11, panel.Top + 26), dpi, + Math.Max(60, panel.Width - 22)); + + var legendRows = Math.Min(4, vectors.Count); + var legendHeight = Math.Clamp(legendRows * 25.0 + 12.0, 42.0, 112.0); + var plotTop = panel.Top + 47; + var plotBottom = panel.Bottom - legendHeight - 4; + var plot = new Rect(panel.Left + 9, plotTop, Math.Max(60, panel.Width - 18), Math.Max(70, plotBottom - plotTop)); + var radius = Math.Max(30, Math.Min(plot.Width, plot.Height) * 0.39); + var center = new Point(plot.Left + plot.Width * 0.5, plot.Top + plot.Height * 0.5); DrawPolarGrid(dc, center, radius, dpi, body); - var maxMagnitude = _vectors.Max(v => Math.Abs(v.MagnitudeRms)); + var maxMagnitude = vectors.Max(vector => Math.Abs(vector.MagnitudeRms)); if (!double.IsFinite(maxMagnitude) || maxMagnitude <= 1e-12) maxMagnitude = 1.0; - foreach (var vector in _vectors) + foreach (var vector in vectors) DrawVector(dc, center, radius, maxMagnitude, vector, dpi, semibold); - DrawLegend(dc, new Point(bounds.Right - legendWidth - 14, 72), legendWidth, dpi, body, semibold); + var maxUnit = distinctUnits.Length == 1 ? $" {distinctUnits[0]}" : string.Empty; + DrawText(dc, $"100% = {maxMagnitude:G6}{maxUnit}", 8.1, body, Color.FromRgb(135, 147, 162), + new Point(plot.Left + 5, plot.Bottom - 15), dpi); + DrawCompactLegend(dc, + new Rect(panel.Left + 10, panel.Bottom - legendHeight + 2, panel.Width - 20, legendHeight - 7), + vectors, dpi, body, semibold); } private static void DrawPolarGrid(DrawingContext dc, Point center, double radius, double dpi, Typeface body) { - var minorPen = FrozenPen(Color.FromRgb(229, 234, 241), 1.0); - var axisPen = FrozenPen(Color.FromRgb(183, 195, 209), 1.05); + var minorPen = FrozenPen(Color.FromRgb(233, 237, 242), 0.9); + var axisPen = FrozenPen(Color.FromRgb(181, 190, 200), 1.0); for (var ring = 1; ring <= 4; ring++) { var r = radius * ring / 4.0; dc.DrawEllipse(null, ring == 4 ? axisPen : minorPen, center, r, r); - DrawText(dc, $"{ring * 25}%", 8.8, body, Color.FromRgb(143, 154, 169), - new Point(center.X + 4, center.Y - r + 2), dpi); } for (var degrees = 0; degrees < 360; degrees += 30) @@ -86,50 +168,84 @@ private static void DrawPolarGrid(DrawingContext dc, Point center, double radius dc.DrawLine(degrees % 90 == 0 ? axisPen : minorPen, center, end); } - DrawText(dc, "0°", 9.2, body, Color.FromRgb(108, 122, 140), new Point(center.X + radius + 5, center.Y - 7), dpi); - DrawText(dc, "+90°", 9.2, body, Color.FromRgb(108, 122, 140), new Point(center.X - 14, center.Y - radius - 17), dpi); - DrawText(dc, "±180°", 9.2, body, Color.FromRgb(108, 122, 140), new Point(center.X - radius - 42, center.Y - 7), dpi); - DrawText(dc, "−90°", 9.2, body, Color.FromRgb(108, 122, 140), new Point(center.X - 15, center.Y + radius + 5), dpi); - dc.DrawEllipse(new SolidColorBrush(Color.FromRgb(83, 101, 122)), null, center, 2.5, 2.5); + DrawText(dc, "0°", 7.8, body, Color.FromRgb(119, 132, 149), new Point(center.X + radius + 3, center.Y - 6), dpi); + DrawText(dc, "+90°", 7.8, body, Color.FromRgb(119, 132, 149), new Point(center.X - 13, center.Y - radius - 14), dpi); + DrawText(dc, "±180°", 7.8, body, Color.FromRgb(119, 132, 149), new Point(center.X - radius - 34, center.Y - 6), dpi); + DrawText(dc, "−90°", 7.8, body, Color.FromRgb(119, 132, 149), new Point(center.X - 13, center.Y + radius + 3), dpi); + dc.DrawEllipse(new SolidColorBrush(Color.FromRgb(80, 95, 112)), null, center, 2.2, 2.2); } - private static void DrawVector(DrawingContext dc, Point center, double radius, double maxMagnitude, - ComtradePhasorVector vector, double dpi, Typeface typeface) + private static void DrawVector( + DrawingContext dc, + Point center, + double radius, + double maxMagnitude, + ComtradePhasorVector vector, + double dpi, + Typeface typeface) { var color = PhaseColor(vector.Phase); var fraction = Math.Clamp(Math.Abs(vector.MagnitudeRms) / maxMagnitude, 0.0, 1.0); var length = radius * fraction; var radians = vector.AngleDegrees * Math.PI / 180.0; var end = new Point(center.X + Math.Cos(radians) * length, center.Y - Math.Sin(radians) * length); - var pen = FrozenPen(color, 2.0); + var pen = FrozenPen(color, vector.Phase is "E" or "N" ? 1.5 : 2.0); dc.DrawLine(pen, center, end); if (length > 8) { - var head = 8.0; - var leftAngle = radians + Math.PI * 0.88; - var rightAngle = radians - Math.PI * 0.88; - dc.DrawLine(pen, end, new Point(end.X + Math.Cos(leftAngle) * head, end.Y - Math.Sin(leftAngle) * head)); - dc.DrawLine(pen, end, new Point(end.X + Math.Cos(rightAngle) * head, end.Y - Math.Sin(rightAngle) * head)); + const double head = 7.5; + var leftAngle = radians + Math.PI * 0.86; + var rightAngle = radians - Math.PI * 0.86; + var geometry = new StreamGeometry(); + using (var context = geometry.Open()) + { + context.BeginFigure(end, true, true); + context.LineTo(new Point(end.X + Math.Cos(leftAngle) * head, end.Y - Math.Sin(leftAngle) * head), true, false); + context.LineTo(new Point(end.X + Math.Cos(rightAngle) * head, end.Y - Math.Sin(rightAngle) * head), true, false); + } + geometry.Freeze(); + var brush = new SolidColorBrush(color); brush.Freeze(); + dc.DrawGeometry(brush, null, geometry); } - var labelPoint = new Point(end.X + (Math.Cos(radians) >= 0 ? 6 : -30), end.Y - 15); - DrawText(dc, vector.Label, 9.2, typeface, color, labelPoint, dpi); + if (length > radius * 0.22) + { + var labelPoint = new Point(end.X + (Math.Cos(radians) >= 0 ? 5 : -28), end.Y - 13); + DrawText(dc, vector.Phase, 8.7, typeface, color, labelPoint, dpi, 32); + } } - private void DrawLegend(DrawingContext dc, Point origin, double width, double dpi, Typeface body, Typeface semibold) + private static void DrawCompactLegend( + DrawingContext dc, + Rect area, + IReadOnlyList vectors, + double dpi, + Typeface body, + Typeface semibold) { - DrawText(dc, "RMS phasors", 11.5, semibold, Color.FromRgb(49, 70, 94), origin, dpi); - var y = origin.Y + 25; - foreach (var vector in _vectors.Take(10)) + var columns = area.Width >= 420 ? 2 : 1; + var rows = (int)Math.Ceiling(vectors.Count / (double)columns); + rows = Math.Max(1, rows); + var columnWidth = area.Width / columns; + var rowHeight = Math.Max(21, area.Height / rows); + for (var index = 0; index < vectors.Count; index++) { + var column = index / rows; + var row = index % rows; + if (column >= columns) break; + var vector = vectors[index]; + var x = area.Left + column * columnWidth; + var y = area.Top + row * rowHeight; var color = PhaseColor(vector.Phase); - dc.DrawEllipse(new SolidColorBrush(color), null, new Point(origin.X + 4, y + 6), 3.5, 3.5); - DrawText(dc, vector.Label, 10.5, semibold, Color.FromRgb(48, 65, 86), new Point(origin.X + 15, y - 1), dpi); + dc.DrawLine(FrozenPen(color, 2.0), new Point(x, y + 8), new Point(x + 12, y + 8)); + DrawText(dc, vector.Label, 8.8, semibold, Color.FromRgb(65, 78, 94), + new Point(x + 17, y), dpi, Math.Max(40, columnWidth * 0.42)); var unit = string.IsNullOrWhiteSpace(vector.Units) ? string.Empty : " " + vector.Units; - DrawText(dc, $"{vector.MagnitudeRms:G6}{unit} ∠ {vector.AngleDegrees:+0.##;-0.##;0}°", - 9.8, body, Color.FromRgb(103, 119, 139), new Point(origin.X + 15, y + 14), dpi); - y += 43; + DrawText(dc, $"{vector.MagnitudeRms:G6}{unit} ∠{vector.AngleDegrees:+0.##;-0.##;0}°", + 8.3, body, Color.FromRgb(103, 117, 135), + new Point(x + Math.Max(86, columnWidth * 0.43), y), dpi, + Math.Max(40, columnWidth - Math.Max(86, columnWidth * 0.43) - 4)); } } @@ -153,10 +269,23 @@ private static Pen FrozenPen(Color color, double thickness) return pen; } - private static void DrawText(DrawingContext dc, string text, double size, Typeface typeface, Color color, Point point, double dpi) + private static void DrawText( + DrawingContext dc, + string text, + double size, + Typeface typeface, + Color color, + Point point, + double dpi, + double maxWidth = double.PositiveInfinity) { var brush = new SolidColorBrush(color); brush.Freeze(); - dc.DrawText(new FormattedText(text, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, - typeface, size, brush, dpi), point); + var formatted = new FormattedText(text ?? string.Empty, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, + typeface, size, brush, dpi) + { + MaxTextWidth = double.IsFinite(maxWidth) ? Math.Max(1, maxWidth) : 10000, + Trimming = TextTrimming.CharacterEllipsis + }; + dc.DrawText(formatted, point); } } From 58337022ac004158006f9607a6d305a7bb55a198 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 08:59:52 +0700 Subject: [PATCH 4/6] P1D.2D: add dual-cursor phasor workstation controls --- ComtradeWorkspaceWindow.xaml | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/ComtradeWorkspaceWindow.xaml b/ComtradeWorkspaceWindow.xaml index 97a0ce940..467690abc 100644 --- a/ComtradeWorkspaceWindow.xaml +++ b/ComtradeWorkspaceWindow.xaml @@ -177,16 +177,29 @@ - -