Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
99 commits
Select commit Hold shift + click to select a range
5c2c9cc
fix(smv-ui): split blue-white measurements voltage/current and enlarg…
masarray Oct 23, 2026
830c623
fix(smv-ui): split blue-white measurements voltage/current and enlarg…
masarray Oct 23, 2026
8ebac58
fix(smv-ui): split blue-white measurements voltage/current and enlarg…
masarray Oct 23, 2026
c5ce2f7
fix(smv-ui): split blue-white measurements voltage/current and enlarg…
masarray Oct 23, 2026
5e81a65
test(smv-ui): lock split grids, palette, and raw vs 9-2LE group identity
masarray Oct 23, 2026
c66fb2e
fix(smv-ui): keep measurement groups scrollable at reduced window hei…
masarray Oct 23, 2026
80b9ea6
fix(smv): preserve true RMS, format V/A prefixes, mark phase and anch…
masarray Oct 23, 2026
356bbc6
fix(smv): preserve true RMS, format V/A prefixes, mark phase and anch…
masarray Oct 23, 2026
e91fa22
fix(smv): preserve true RMS, format V/A prefixes, mark phase and anch…
masarray Oct 23, 2026
edec217
fix(smv): preserve true RMS, format V/A prefixes, mark phase and anch…
masarray Oct 23, 2026
7f1e391
feat(smv): retain visible total RMS and expose fundamental RMS contex…
masarray Oct 23, 2026
700be37
feat(smv-wave): passive sample-quantized cursor hover and truthful st…
masarray Oct 23, 2026
6d2fe9d
feat(smv-ui): collapse shared Command Dock by default in SMV while re…
masarray Oct 23, 2026
69c0a41
perf(smv-cursor): zero-allocation pointer hit test and source-sample …
masarray Oct 23, 2026
2e04730
test(command-dock): recognize independent SMV default collapse withou…
masarray Oct 23, 2026
8fa20ec
fix(smv): make RMS tooltip timebase-agnostic and preserve DSP evidenc…
masarray Oct 23, 2026
09fbe64
test(smv): lock RMS truth, dynamic V/A formatting, Ua phase reference…
masarray Oct 23, 2026
3bc4e1b
fix(smv-wave): suppress actual silent-zero neutral trace in overlay w…
masarray Oct 23, 2026
85aad8c
test(smv): protect overlay against permanent zero-neutral trace
masarray Oct 23, 2026
33808c0
perf(smv): reuse single zero-axis label per stacked pane render
masarray Oct 23, 2026
7912c5a
feat(smv): deterministic bounded collision-free phase callout placeme…
masarray Oct 23, 2026
1542ec2
fix(smv-wave): restore overlay zero, per-phase intersection arrow cal…
masarray Oct 23, 2026
a0c7e67
ux(smv): phase-aware waveform legend and callout naming only in expli…
masarray Oct 23, 2026
4465077
ux(smv-cursor): preserve milli-unit readout precision without noisy c…
masarray Oct 23, 2026
7b61d1e
test(smv-cursor): layout bounds and collisions, true intersections, r…
masarray Oct 23, 2026
708eb58
test(smv): align zero-neutral layout regression anchor
masarray Oct 23, 2026
a79ffb6
fix(smv): never show negative zero on phase-reference angle
masarray Oct 23, 2026
a97bd23
perf(smv): bound cursor time readout to snapshot without enumerable a…
masarray Oct 23, 2026
67acff3
test(smv): explicit phase names match 9-2LE plots and zero angle has …
masarray Oct 23, 2026
0e5ce75
feat(smv-p3): bounded true power and fundamental reactive measurement…
masarray Oct 23, 2026
9d66323
feat(smv-p3): bind trusted publisher sync and profile-gated bounded p…
masarray Oct 23, 2026
af2195f
feat(smv-p3): honest IEC smpSynch global/local/none/unknown status pa…
masarray Oct 23, 2026
1e15e39
feat(smv-p3): publisher sync badge and compact expandable 3-phase pow…
masarray Oct 23, 2026
b277b5f
feat(smv-p3): readable phasor label with explicit measured relative a…
masarray Oct 23, 2026
2b0cd6f
feat(smv-p3): legible Inter semibold cursor callouts and Ua-reference…
masarray Oct 23, 2026
0219645
feat(smv-p3): bounded Ua rising-zero visual time origin and RAW frame…
masarray Oct 23, 2026
78639a7
style(p3): route visible workstation typography through bundled Inter…
masarray Oct 23, 2026
7a68640
style(p3): route visible workstation typography through bundled Inter…
masarray Oct 23, 2026
2758dee
style(p3): route visible workstation typography through bundled Inter…
masarray Oct 23, 2026
4260107
style(p3): route visible workstation typography through bundled Inter…
masarray Oct 23, 2026
dcae561
style(p3): route visible workstation typography through bundled Inter…
masarray Oct 23, 2026
91fbc53
style(p3): route visible workstation typography through bundled Inter…
masarray Oct 23, 2026
2b6b919
fix(typography): resolve bundled Inter from app scope in static and i…
masarray Oct 23, 2026
4c51aec
fix(typography): resolve bundled Inter from app scope in static and i…
masarray Oct 23, 2026
70168ae
fix(typography): resolve bundled Inter from app scope in static and i…
masarray Oct 23, 2026
32302d8
fix(typography): resolve bundled Inter from app scope in static and i…
masarray Oct 23, 2026
c1db5aa
test(smv-p3): exact P/Q1/S/PF math, sync mapping, Ua-axis origin and …
masarray Oct 23, 2026
fa6d73b
test(smv-p3): construct class-based waveform snapshot without record …
masarray Oct 23, 2026
06bed81
fix(smv-p3): choose 5ms tick interval when physically readable else 10ms
masarray Oct 23, 2026
8fe2166
ux(smv-p3): explicitly label sampled Ua rising-crossing versus frame-…
masarray Oct 23, 2026
69b253c
ux(smv-p3): show honest Ua time reference beside sync badge
masarray Oct 23, 2026
9a9b0be
test(smv-p3): lock Inter, timeline, sync and bounded power UI present…
masarray Oct 23, 2026
9f70704
fix(smv-p3-fat): seven explicit measurement rows and compact power st…
masarray Oct 23, 2026
6ea78b0
fix(smv-p3-fat): compact power metadata and explicit RAW vs 9-2LE map…
masarray Oct 23, 2026
abe970d
fix(smv-wave): use Ua voltage as top lane in 9-2LE without changing R…
masarray Oct 23, 2026
95bd868
perf(smv): atomic DSP+waveform publish on UI dispatcher and increment…
masarray Oct 23, 2026
5285cbb
test(smv-fat): align compact power and retained eight-row DataGrid co…
masarray Oct 23, 2026
43e656f
test(smv-fat): align compact power and retained eight-row DataGrid co…
masarray Oct 23, 2026
502b017
test(smv-p3-fat): require compact nonoverlapping power panel and per-…
masarray Oct 23, 2026
c35a67a
test(smv-fat): nonoverlapping seven-row power panel, U-before-I order…
masarray Oct 23, 2026
791cde1
perf(smv-fat): single-flight latest snapshot analyzer with bounded ba…
masarray Oct 23, 2026
ed127b5
test(smv-fat): lock single-flight coalescing and selection-generation…
masarray Oct 23, 2026
1bb4f37
fix(test): replace accidentally escaped literal newline with valid C#…
masarray Oct 23, 2026
a226400
test(smv): update native-tab source contract to atomic snapshot bundl…
masarray Oct 23, 2026
5c78aad
feat(smv-p0): single 0–360 degree display formatter without modifying…
masarray Oct 23, 2026
111a8a2
feat(smv-p0): display table angles normalized 0–360 using one present…
masarray Oct 23, 2026
c916e7e
feat(smv-p0): phasor leader labels show fundamental magnitude and 0–3…
masarray Oct 23, 2026
e8be659
perf(smv-p0): stable notification-only measurement rows without DataG…
masarray Oct 23, 2026
93d0499
fix(smv-p0): retain last coherent visuals and sync across SV switch, …
masarray Oct 23, 2026
e2d8c05
fix(smv-p0): physically collapse 3-phase power panel in RAW counts or…
masarray Oct 23, 2026
dd472c7
fix(smv-p0): reevaluate grouping only on actual RAW/9-2LE mode transi…
masarray Oct 23, 2026
ff5b403
test(smv-p0): update assertions for retained rows and 0–360 phasor ma…
masarray Oct 23, 2026
56d2831
test(smv-p0): update assertions for retained rows and 0–360 phasor ma…
masarray Oct 23, 2026
f93a887
test(smv-p0): update assertions for retained rows and 0–360 phasor ma…
masarray Oct 23, 2026
bddcab7
test(smv-p0): update assertions for retained rows and 0–360 phasor ma…
masarray Oct 23, 2026
3c43bde
test(smv-p0): angle wrap math, magnitude labels, stable row notificat…
masarray Oct 23, 2026
c909bbf
perf(smv-p0): deduplicate static header, sync and power visibility in…
masarray Oct 23, 2026
8f10bce
fix(smv-p1): compact RAW phase-angle callouts, concise V/A magnitudes…
masarray Oct 24, 2026
80f91d7
style(smv-p1): content-fit measurement rows, grow phasor from reclaim…
masarray Oct 24, 2026
a3ee6a7
test(smv-p1): align existing FAT assertions with concise phase callou…
masarray Oct 24, 2026
1f986cf
test(smv-p1): align existing FAT assertions with concise phase callou…
masarray Oct 24, 2026
68d31bd
test(smv-p1): align existing FAT assertions with concise phase callou…
masarray Oct 24, 2026
2639b31
test(smv-p1): RAW no-count callouts, concise V/A labels, measured tex…
masarray Oct 24, 2026
f8ddf90
perf(smv-p1): stable mode-specific callout rails prevent phasor ring …
masarray Oct 24, 2026
46c5d7f
fix(smv-p1): narrow-width phasor falls back to channel+angle rather t…
masarray Oct 24, 2026
865fe58
test(smv-p1): cover responsive phasor text formatting call site
masarray Oct 24, 2026
78e0f97
test(smv-p1): bounded narrow-host fallback preserves readable ring an…
masarray Oct 24, 2026
25f79f9
style(smv-p1): concise measurement and reference labels, short sync H…
masarray Oct 24, 2026
63b68e6
style(smv-p1): remove duplicate captions, wrap sync tooltip, keep tec…
masarray Oct 24, 2026
e8e5099
style(smv-p1): remove redundant group mapping and pending frequency l…
masarray Oct 24, 2026
5dafbe8
style(smv-p1): use one concise phasor line, preserve separate-scale d…
masarray Oct 24, 2026
3c709a8
style(smv-p1): short safety-aware SV capture readout with full diagno…
masarray Oct 24, 2026
c69a341
style(smv-p1): one-line live status, phasor calibration details on hover
masarray Oct 24, 2026
4525a88
test(smv-p1): align source guards with contextual metadata and compac…
masarray Oct 24, 2026
6d12fdd
test(smv-p1): align source guards with contextual metadata and compac…
masarray Oct 24, 2026
526c0e8
test(smv-p1): align source guards with contextual metadata and compac…
masarray Oct 24, 2026
79f0e46
test(smv-p1): mapped waveform titles are concise voltage/current whil…
masarray Oct 24, 2026
2671ca1
test(smv-p1): instrument-first visibility with preserved bounded diag…
masarray Oct 24, 2026
4b2b62f
test(smv-p1): align RAW group source assertions with approved concise…
masarray Oct 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion App.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@
Static faces are intentional because WPF does not provide reliable variable-font
axis selection. Ideal metrics + ClearType keep compact engineering text smooth. -->
<FontFamily x:Key="AppFontFamily">./Assets/Fonts/#Inter</FontFamily>
<FontFamily x:Key="AppCodeFontFamily">Cascadia Mono, Consolas</FontFamily>
<FontFamily x:Key="AppCodeFontFamily">./Assets/Fonts/#Inter</FontFamily>

<Style TargetType="{x:Type Window}">
<Setter Property="FontFamily" Value="{StaticResource AppFontFamily}"/>
Expand Down
2 changes: 1 addition & 1 deletion IoListTestingWindow.CommandPanel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,7 @@ private FatCommandRowView BuildFatCommandRow(SignalDefinition signal)
row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1.55, GridUnitType.Star), MinWidth = 190 });

var reference = FatCommandText(signal.ObjectReference, 11.0, FontWeights.SemiBold);
reference.FontFamily = new FontFamily("Cascadia Mono, Consolas");
reference.FontFamily = (FontFamily)System.Windows.Application.Current.FindResource("AppFontFamily");
reference.ToolTip = signal.ObjectReference;
AddFatCommandCell(row, reference, 0);

Expand Down
4 changes: 2 additions & 2 deletions IoListTestingWindow.FatV2Ux.cs
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ private static DataGridTextColumn TextColumn(
style.Setters.Add(new Setter(TextBlock.FontSizeProperty, 11.2));
style.Setters.Add(new Setter(TextBlock.ForegroundProperty, new SolidColorBrush(Color.FromRgb(68, 84, 107))));
if (monospace)
style.Setters.Add(new Setter(TextBlock.FontFamilyProperty, new FontFamily("Cascadia Mono, Consolas")));
style.Setters.Add(new Setter(TextBlock.FontFamilyProperty, (FontFamily)System.Windows.Application.Current.FindResource("AppFontFamily")));

return new DataGridTextColumn
{
Expand Down Expand Up @@ -234,7 +234,7 @@ private DataGridTemplateColumn CreateValueColumn(FatValueSlot slot)
? nameof(IoTestPointPlan.Value1RelayTimestampText)
: nameof(IoTestPointPlan.Value2RelayTimestampText)));
timestamp.SetValue(TextBlock.FontSizeProperty, 8.8);
timestamp.SetValue(TextBlock.FontFamilyProperty, new FontFamily("Cascadia Mono, Consolas"));
timestamp.SetValue(TextBlock.FontFamilyProperty, (FontFamily)System.Windows.Application.Current.FindResource("AppFontFamily"));
timestamp.SetValue(TextBlock.ForegroundProperty, new SolidColorBrush(Color.FromRgb(112, 126, 145)));
panel.AppendChild(timestamp);

Expand Down
2 changes: 1 addition & 1 deletion IoListTestingWindow.P0BenchUx.cs
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ private static DataTemplate BuildP0EvidenceValueTemplate(FatValueSlot slot)
? nameof(IoTestPointPlan.Value1RelayTimestampText)
: nameof(IoTestPointPlan.Value2RelayTimestampText)));
timestamp.SetValue(TextBlock.FontSizeProperty, 8.8);
timestamp.SetValue(TextBlock.FontFamilyProperty, new FontFamily("Cascadia Mono, Consolas"));
timestamp.SetValue(TextBlock.FontFamilyProperty, (FontFamily)System.Windows.Application.Current.FindResource("AppFontFamily"));
timestamp.SetValue(TextBlock.ForegroundProperty, new SolidColorBrush(Color.FromRgb(112, 126, 145)));
panel.AppendChild(timestamp);

Expand Down
2 changes: 1 addition & 1 deletion IoListTestingWindow.PrintPreview.cs
Original file line number Diff line number Diff line change
Expand Up @@ -435,7 +435,7 @@ private static DataTemplate CenteredTemplate(
text.SetValue(TextBlock.FontSizeProperty, fontSize);
text.SetValue(TextBlock.FontWeightProperty, fontWeight);
if (mono)
text.SetValue(TextBlock.FontFamilyProperty, new FontFamily("Cascadia Mono, Consolas"));
text.SetValue(TextBlock.FontFamilyProperty, (FontFamily)System.Windows.Application.Current.FindResource("AppFontFamily"));
return new DataTemplate { VisualTree = text };
}

Expand Down
19 changes: 14 additions & 5 deletions MainWindow.PersistentWorkbench.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ private sealed class PersistentWorkbenchState
// Preserve its user-chosen expansion state across navigation. Replaying
// different per-tab defaults repeatedly unloaded/reloaded command rows.
public bool DockExpanded { get; set; } = true;
// SV inspection is independent of MMS command admission. Default
// collapsed, but retain the operator's explicit override per workspace.
public bool SmvDockExpanded { get; set; } = false;
}

[ModuleInitializer]
Expand Down Expand Up @@ -381,20 +384,26 @@ private void CaptureP0CommandDockState(bool expanded)
if (state == null || state.ApplyingDockState || state.SuppressDockStateCapture)
return;

state.DockExpanded = expanded;
if (MainTabs.SelectedIndex == EngineeringNavigationRoute.SmvWorkspaceIndex)
state.SmvDockExpanded = expanded;
else
state.DockExpanded = expanded;
}

private void ApplyP0CommandDockState(PersistentWorkbenchState state, int workspaceIndex)
{
// Navigation is read-only to dock state. No WPF unload/reload or
// implicit expanded-event MMS inspection on tab change.
if (CommandPanelExpander.IsExpanded == state.DockExpanded)
// Restore the operator choice for each purpose, while initially hiding
// MMS commands on passive SV inspection. Do not recreate the panel or
// invoke any control/refresh operation as a navigation side effect.
var desiredExpanded = workspaceIndex == EngineeringNavigationRoute.SmvWorkspaceIndex
? state.SmvDockExpanded : state.DockExpanded;
if (CommandPanelExpander.IsExpanded == desiredExpanded)
return;

state.ApplyingDockState = true;
try
{
CommandPanelExpander.IsExpanded = state.DockExpanded;
CommandPanelExpander.IsExpanded = desiredExpanded;
}
finally
{
Expand Down
95 changes: 74 additions & 21 deletions Ports/DigSubAnalyzer/Controls/PhasorDiagramControl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Windows;
using System.Windows.Media;
using ProcessBus.Core.Models;
using ArIED61850Tester.Views;
using ProcessBus.Core.Presentation;

namespace ProcessBus.App.Wpf.Controls;
Expand Down Expand Up @@ -139,10 +140,38 @@ protected override void OnRender(DrawingContext dc)
if (width < 130 || height < 130)
return;

// Reserve side rails for collision-free labels, rather than letting text
// overlap the arrows or escape beyond the narrow analyzer workspace.
var center = new Point(width / 2.0, height / 2.0 - 2);
var radius = Math.Max(0, Math.Min(width / 2.0 - 69, height / 2.0 - 35));
// P1: fit the polar plot to the ACTUAL annotated text, not a fixed
// 69-DIP side rail. This prevents value labels from crossing the ring
// at narrower window widths or at different Windows text scaling.
// At most six FormattedText objects are measured once per repaint.
// At very narrow host widths the value remains available in the
// measurements table. Prefer a legible vector + phase callout over
// an unreadable ring pushed out of view by six magnitude strings.
var fitNarrow = width < 420;
var labelCache = _visualItems.Where(IsRenderable)
.ToDictionary(item => item, item =>
CreateText(FormatPhasorLabel(item, fitNarrow), 11.3,
PhaseBrush(PhaseIndex(item.Name)), LabelTypeface));
// Mode-wide minimum text lanes keep the ring/grid fixed while
// numbers update (e.g. 9.9V -> 100V). Widths beyond the reserve
// still grow safely; the common case never makes the plot breathe.
var engineeringLabels = !fitNarrow && labelCache.Keys.Any(item => item.Unit != "counts");
var leftRail = engineeringLabels ? 142.0 : 92.0;
var rightRail = engineeringLabels ? 142.0 : 92.0;
foreach (var pair in labelCache)
{
var rad = pair.Key.AngleDegrees!.Value * Math.PI / 180.0;
if (Math.Cos(rad) < -1e-8)
leftRail = Math.Max(leftRail, pair.Value.Width + 10.0);
else
rightRail = Math.Max(rightRail, pair.Value.Width + 10.0);
}
// Leave a safety corridor between ring and horizontal callout text;
// scale the ring, never reduce font size or clip individual labels.
var center = new Point((leftRail + width - rightRail) / 2.0, height / 2.0 - 2);
var radius = Math.Max(0, Math.Min(
Math.Min(center.X - leftRail, width - rightRail - center.X) - 6,
height / 2.0 - 35));
if (radius < 12)
return;

Expand Down Expand Up @@ -196,8 +225,7 @@ protected override void OnRender(DrawingContext dc)
dc.DrawLine(pen, center, tip);
DrawArrow(dc, center, tip, arrowPen, item.Family == PhasorFamily.Voltage ? 9.5 : 8.0);
dc.Pop();
vectors.Add(new RenderedVector(item, tip, brush,
CreateText(ToPhaseLabel(item.Name), 11.0, brush, LabelTypeface)));
vectors.Add(new RenderedVector(item, tip, brush, labelCache[item]));
}

dc.DrawEllipse(OriginInnerBrush, null, center, 4.2, 4.2);
Expand Down Expand Up @@ -245,21 +273,14 @@ protected override void OnRender(DrawingContext dc)
}
dc.Pop();

// Numeric scale evidence comes from the SAME six vectors as arrows.
// This footer is a visual reference (per-family maximum), not a
// calibration claim or one shared physical voltage/current scale.
var voltageUnit = _visualItems.FirstOrDefault(item =>
item.Family == PhasorFamily.Voltage && IsRenderable(item))?.Unit ?? "counts";
var currentUnit = _visualItems.FirstOrDefault(item =>
item.Family == PhasorFamily.Current && IsRenderable(item))?.Unit ?? "counts";
var voltageScaleText = PhasorScaleCaption.Format(voltageUnit == "V" ? "U" : "A", voltageMax, voltageUnit);
var currentScaleText = PhasorScaleCaption.Format(currentUnit == "A" ? "I" : "B", currentMax, currentUnit);
DrawText(dc, $"{voltageScaleText} · {currentScaleText}",
10.0, new Point(6, height - 31), TextSecondaryBrush, BodyTypeface);
DrawText(dc, voltageUnit == "V" && currentUnit == "A"
? "9-2LE · U solid / I dashed · independent scales"
: "RAW A solid · RAW B dashed · independent count scales",
9.5, new Point(6, height - 15), TextMutedBrush, BodyTypeface);
// The six callouts already display magnitudes. Avoid two further
// lines repeating normalization/calibration explanations below them.
// Still distinguish the independently normalized vector families.
var mapped = _visualItems.Any(item => item.Unit is "V" or "A");
DrawText(dc, mapped
? "U solid · I dashed · separate scales"
: "RAW A solid · B dashed · separate scales",
9.8, new Point(6, height - 17), TextMutedBrush, BodyTypeface);
if (!IsStreamLive)
DrawText(dc, "STALE · last valid samples", 11, new Point(7, 5), StaleBrush, LabelTypeface);
}
Expand All @@ -284,6 +305,38 @@ private static bool IsRenderable(PhasorDisplayItem item) =>
_ => PhaseRBrush
};

public static string FormatPhasorLabel(PhasorDisplayItem item, bool fitNarrow = false)
{
if (item.AngleDegrees is not double angle || !double.IsFinite(angle))
return item.Name;

// RAW has no proven U/I mapping or engineering scale. The grid below
// already shows exact counts: repeating eight-digit counts on the
// diagram consumes side rails and produces collisions. Keep identity
// + angle on the polar diagram; retain all data in Measurements.
var degrees = SmvAnglePresentation.Normalize360(angle);
var angleText = "∠" + degrees.ToString("0.#", CultureInfo.InvariantCulture) + "°";
if (item.Unit == "counts" || fitNarrow)
return item.Name + " " + angleText;

if (item.Magnitude is not double magnitude || !double.IsFinite(magnitude))
return item.Name + " " + angleText;

// Engineering callouts are compact approximate readouts only. Full
// precision remains in the Measurements table; magnitude here is
// fundamental RMS, not a silently invented waveform peak.
var value = CompactMagnitude(magnitude, item.Unit);
return item.Name + " " + value + " " + angleText;
}

private static string CompactMagnitude(double magnitude, string unit)
{
if ((unit == "V" || unit == "A") && Math.Abs(magnitude) >= 1000)
return (magnitude / 1000.0).ToString("0.##", CultureInfo.InvariantCulture) +
(unit == "V" ? "kV" : "kA");
return magnitude.ToString(unit == "A" ? "0.###" : "0.##", CultureInfo.InvariantCulture) + unit;
}

private static string ToPhaseLabel(string name) => name switch
{
"Ua" => "Ua (R)",
Expand Down
52 changes: 52 additions & 0 deletions Ports/DigSubAnalyzer/Controls/SmvWaveformTimeAxis.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
using ProcessBus.Core.Models;

namespace ProcessBus.App.Wpf.Controls;

/// <summary>
/// Presentation time axis only. In mapped IEC 9-2LE the first rising Ua
/// zero crossing within the first cycle is t=0; sample order and sample
/// counter evidence are NOT re-timed or modified. In RAW/nonmapped input,
/// t=0 is the first visible sample, NOT a fabricated voltage phase.
/// </summary>
public static class SmvWaveformTimeAxis
{
public static int AvailableSamples(WaveformSnapshot snapshot)
{
var count = 0;
foreach (var row in snapshot.VoltageSeries)
count = Math.Max(count, row.Samples.Count);
foreach (var row in snapshot.CurrentSeries)
count = Math.Max(count, row.Samples.Count);
return count;
}

public static double ReferenceSample(WaveformSnapshot snapshot, int visibleSamples)
{
if (visibleSamples < 2)
return 0;
// CurrentSeries in this legacy visualization contract contains wire
// Ch5-8 (voltage in 9-2LE). Prefer semantic name, not container name.
WaveformSeriesModel? ua = null;
foreach (var row in snapshot.CurrentSeries)
if (row.Name == "Ua") { ua = row; break; }
if (ua is null)
foreach (var row in snapshot.VoltageSeries)
if (row.Name == "Ua") { ua = row; break; }
if (ua is null || ua.Samples.Count < 2)
return 0;

var count = Math.Min(visibleSamples, ua.Samples.Count);
var offset = ua.Samples.Count - count;
var span = Math.Min(count, snapshot.SamplesPerCycle > 1 ? snapshot.SamplesPerCycle : count);
for (var i = 1; i < span; i++)
{
var before = ua.Samples[offset + i - 1];
var after = ua.Samples[offset + i];
if (!double.IsFinite(before) || !double.IsFinite(after))
continue;
if (before <= 0 && after > 0)
return i - 1 + (-before / (after - before));
}
return 0; // No visible Ua rising crossing: frame-relative axis, no claim.
}
}
74 changes: 74 additions & 0 deletions Ports/DigSubAnalyzer/Controls/WaveformCursorCalloutLayout.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
using System.Windows;

namespace ProcessBus.App.Wpf.Controls;

/// <summary>
/// Deterministic geometry for live SV cursor callouts. The input is limited to
/// one lane (at most four phase/neutral curves); no scheduling, animation,
/// packet work or canvas measurements are performed here.
/// </summary>
public static class WaveformCursorCalloutLayout
{
public readonly record struct Anchor(int Index, double Y, double Width);
public readonly record struct Placement(int Index, Rect Box, bool LabelRight);

public static Placement[] Arrange(Rect plot, double cursorX, IReadOnlyList<Anchor> anchors)
{
if (anchors.Count == 0 || plot.Width <= 0 || plot.Height <= 0)
return [];

const double height = 22;
const double gutter = 24;
const double inset = 5;
var sorted = new Anchor[anchors.Count];
for (var i = 0; i < sorted.Length; i++)
sorted[i] = anchors[i];
Array.Sort(sorted, static (a, b) =>
{
var order = a.Y.CompareTo(b.Y);
return order != 0 ? order : a.Index.CompareTo(b.Index);
});

var rightSpace = plot.Right - cursorX - gutter - inset;
var leftSpace = cursorX - plot.Left - gutter - inset;
var maxWidth = 0.0;
for (var i = 0; i < sorted.Length; i++)
maxWidth = Math.Max(maxWidth, sorted[i].Width);
var labelRight = rightSpace >= maxWidth ||
(rightSpace >= leftSpace && leftSpace < maxWidth);
var widthLimit = Math.Max(35, Math.Min(plot.Width - inset * 2,
labelRight ? rightSpace : leftSpace));

var topMin = plot.Top + inset;
var bottomMax = plot.Bottom - inset;
var gap = Math.Max(0, Math.Min(5.0, (bottomMax - topMin - sorted.Length * height) /
Math.Max(1, sorted.Length - 1)));
var positions = new double[sorted.Length];
for (var i = 0; i < sorted.Length; i++)
{
var proposed = Math.Clamp(sorted[i].Y - height / 2, topMin,
Math.Max(topMin, bottomMax - height));
positions[i] = i == 0 ? proposed : Math.Max(proposed, positions[i - 1] + height + gap);
}
// Move any overflowing group upwards, preserving order and separation.
if (positions[^1] + height > bottomMax)
{
positions[^1] = bottomMax - height;
for (var i = positions.Length - 2; i >= 0; i--)
positions[i] = Math.Min(positions[i], positions[i + 1] - height - gap);
}

var output = new Placement[sorted.Length];
for (var i = 0; i < sorted.Length; i++)
{
var width = Math.Min(sorted[i].Width, widthLimit);
// Keep labels in the plot even when the cursor approaches a boundary.
var x = labelRight
? Math.Min(cursorX + gutter, plot.Right - inset - width)
: Math.Max(plot.Left + inset, cursorX - gutter - width);
output[i] = new Placement(sorted[i].Index,
new Rect(x, Math.Max(topMin, positions[i]), width, height), labelRight);
}
return output;
}
}
Loading
Loading