diff --git a/AGENTS.md b/AGENTS.md index 0920072..013a406 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -372,8 +372,8 @@ No abbreviations in names. `git grep -n "new HistoryRequest(" -- SemiPlot/SemiPlot.UI` prints only `ChartHistoryPlanner.cs` lines. - A pen's reading is rendered through `SemiPlot.Core.Trends.PenValueFormat` and nowhere else: it owns the stored mask's character-and-section rule and the `0.###` fallback, and the sidebar row, the - chart's hover readout and the major labels of a log10 axis (`Chart/LogTickGenerator`) all go through it - (`docs/architecture/ui-text.md`). + chart's hover readout, the major labels of a log10 axis (`Chart/LogTickGenerator`) and the delta + readout's Δy (`FormatDifference`) all go through it (`docs/architecture/ui-text.md`). - Every failure the operator should see goes to `Messages/MessagePanelViewModel` through `Messages/ArchiveFailureMapper.Map`, which assigns the severity in its own per-kind switch — never at the call site. The one message with no error behind it is diff --git a/SemiPlot/SemiPlot.Core/Trends/PenValueFormat.cs b/SemiPlot/SemiPlot.Core/Trends/PenValueFormat.cs index 9897afc..e48e91d 100644 --- a/SemiPlot/SemiPlot.Core/Trends/PenValueFormat.cs +++ b/SemiPlot/SemiPlot.Core/Trends/PenValueFormat.cs @@ -63,6 +63,19 @@ public static string Format(double value, string? mask) return value.ToString(IsAcceptable(mask) ? mask : FallbackMask, CultureInfo.CurrentCulture); } + /// + /// A difference of two readings under the positive section of the pen's mask alone, its sign literals + /// dropped, so .NET prints the one sign and a zero difference reads as a number (docs/architecture/charting.md). + /// + public static string FormatDifference(double value, string? mask) + { + var differenceMask = IsAcceptable(mask) + ? SignLiteral().Replace(mask.Split(';')[0], string.Empty) + : FallbackMask; + + return value.ToString(differenceMask, CultureInfo.CurrentCulture); + } + // The positive and negative sections must carry a 0, which always prints; the third is the zero // literal, where a dash standing for "no reading" is the operator's own idiom. private static bool IsAcceptableSection(ReadOnlySpan section, bool mustPrintTheReading) @@ -101,4 +114,9 @@ private static bool IsDigitPlaceholder(ReadOnlySpan section, int index) // the reading rather than only shaping it. [GeneratedRegex(@"^[0#.,;()Ee+\- ]+$")] private static partial Regex AcceptableMask(); + + // A + or - right after E or e is the exponent's own sign; any other one, and a parenthesis, is a literal + // that .NET would print after the minus it puts ahead of a negative value. + [GeneratedRegex(@"(? PenValueFormat.FormatDifference(value, mask).Should().Be(expected)); + } + // Format renders under the operator's culture, so the shapes above are stated in one this machine // cannot move. private static void UnderTheInvariantCulture(Action assertion) diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartDeltaCursorReaderTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartDeltaCursorReaderTests.cs index 76c941b..aa9de90 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartDeltaCursorReaderTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartDeltaCursorReaderTests.cs @@ -19,7 +19,7 @@ public void FormatReadout_WithoutADeltaY_RendersTheNoValuePlaceholder() { var readout = new DeltaReadout(TimeSpan.FromSeconds(30.0), null); - var text = ChartDeltaCursorReader.FormatReadout(readout); + var text = ChartDeltaCursorReader.FormatReadout(readout, null); text.Should() .Contain(Resources.DeltaValueLabel) @@ -32,13 +32,24 @@ public void FormatReadout_WithADeltaY_RendersTheNumber() using var culture = new CultureScope("en-US"); var readout = new DeltaReadout(TimeSpan.FromSeconds(30.0), 1.5); - var text = ChartDeltaCursorReader.FormatReadout(readout); + var text = ChartDeltaCursorReader.FormatReadout(readout, null); text.Should() .Contain("1.5") .And.NotContain(Resources.NoValuePlaceholder); } + [Fact] + public void FormatReadout_RendersTheDeltaYUnderThePensMask() + { + using var culture = new CultureScope("en-US"); + var readout = new DeltaReadout(TimeSpan.FromSeconds(30.0), -1.2e-5); + + var text = ChartDeltaCursorReader.FormatReadout(readout, "0.00E+00"); + + text.Should().EndWith($"{Resources.DeltaValueLabel} -1.20E-05"); + } + [Theory] [InlineData(30.0, "30s")] [InlineData(90.0, "1m 30s")] @@ -47,7 +58,7 @@ public void FormatReadout_RendersTheDeltaTimeInItsLargestWholeUnits(double secon { var readout = new DeltaReadout(TimeSpan.FromSeconds(seconds), 1.5); - var text = ChartDeltaCursorReader.FormatReadout(readout); + var text = ChartDeltaCursorReader.FormatReadout(readout, null); text.Should().Contain($"{Resources.DeltaTimeLabel} {expected} "); } diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/TrendChartViewModelTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/TrendChartViewModelTests.cs index 762971e..65d4461 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/TrendChartViewModelTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/TrendChartViewModelTests.cs @@ -1929,6 +1929,44 @@ public void DeltaMode_TwoClicks_PlaceBothCursorsAndSurfaceDeltaTimeAndActivePenD .And.Contain(Resources.DeltaValueLabel); } + [AvaloniaFact] + public void DeltaMode_RendersTheDeltaYUnderTheActivePensMask_AndFollowsAMaskTheCatalogueChanges() + { + using var culture = new CultureScope("en-US"); + var (viewModel, scheduler, _, _) = CreateViewModel(); + viewModel.AddPen(FakeDataProvider.FirstPen with { Format = "0.00" }); + LoadInitialHistory(viewModel, scheduler, From, To); + viewModel.SetDeltaModeEnabled(true); + viewModel.PlaceDeltaCursor(From); + viewModel.PlaceDeltaCursor(To.AddHours(1.0)); + viewModel.DeltaReadoutText.Should().EndWith($"{Resources.DeltaValueLabel} 1.00"); + using var monitor = viewModel.Monitor(); + + viewModel.ApplyCatalogue([FakeDataProvider.FirstPen with { Format = "0.000" }]); + + monitor.Should().RaisePropertyChangeFor(chart => chart.DeltaReadoutText); + viewModel.DeltaReadoutText.Should().EndWith($"{Resources.DeltaValueLabel} 1.000"); + } + + [AvaloniaFact] + public void DeltaMode_SwitchingTheActivePen_RendersItsDeltaYUnderItsOwnMask() + { + using var culture = new CultureScope("en-US"); + var (viewModel, scheduler, _, _) = CreateViewModel(); + viewModel.AddPen(FakeDataProvider.FirstPen with { Format = "0.00" }); + viewModel.AddPen(FakeDataProvider.SecondPen with { Format = "0.0000" }); + LoadInitialHistory(viewModel, scheduler, From, To); + viewModel.SetDeltaModeEnabled(true); + viewModel.PlaceDeltaCursor(From); + viewModel.PlaceDeltaCursor(To.AddHours(1.0)); + using var monitor = viewModel.Monitor(); + + viewModel.SetActivePen(FakeDataProvider.SecondPen.PenId); + + monitor.Should().RaisePropertyChangeFor(chart => chart.DeltaReadoutText); + viewModel.DeltaReadoutText.Should().MatchRegex($@"{Resources.DeltaValueLabel} -?\d+\.\d{{4}}$"); + } + [AvaloniaFact] public void ExitingDeltaMode_ReturnsToPanAndClearsCursors() { diff --git a/SemiPlot/SemiPlot.UI/Chart/ChartDeltaCursorReader.cs b/SemiPlot/SemiPlot.UI/Chart/ChartDeltaCursorReader.cs index b0e4fa8..b822836 100644 --- a/SemiPlot/SemiPlot.UI/Chart/ChartDeltaCursorReader.cs +++ b/SemiPlot/SemiPlot.UI/Chart/ChartDeltaCursorReader.cs @@ -1,5 +1,3 @@ -using System.Globalization; - using SemiPlot.Core.Trends; using SemiPlot.UI.Localization; @@ -37,7 +35,7 @@ public void Place(DateTime cursorTime) return _deltaCursor.Compute(envelope); } - public static string FormatReadout(DeltaReadout? readout) + public static string FormatReadout(DeltaReadout? readout, string? penMask) { if (readout is null) { @@ -46,7 +44,7 @@ public static string FormatReadout(DeltaReadout? readout) var deltaTime = FormatDeltaTime(readout.DeltaTime); var deltaY = readout.DeltaY is { } value - ? value.ToString("0.###", CultureInfo.CurrentCulture) + ? PenValueFormat.FormatDifference(value, penMask) : Resources.NoValuePlaceholder; return $"{Resources.DeltaTimeLabel} {deltaTime} {Resources.DeltaValueLabel} {deltaY}"; diff --git a/SemiPlot/SemiPlot.UI/Chart/TrendChartViewModel.cs b/SemiPlot/SemiPlot.UI/Chart/TrendChartViewModel.cs index 941574b..53a6ac0 100644 --- a/SemiPlot/SemiPlot.UI/Chart/TrendChartViewModel.cs +++ b/SemiPlot/SemiPlot.UI/Chart/TrendChartViewModel.cs @@ -171,7 +171,7 @@ private set } } - public string DeltaReadoutText => ChartDeltaCursorReader.FormatReadout(DeltaReadout); + public string DeltaReadoutText => ChartDeltaCursorReader.FormatReadout(DeltaReadout, FindPen(ActivePenId)?.Pen.Format); public void Dispose() { @@ -260,6 +260,7 @@ public void ApplyCatalogue(IReadOnlyList catalogue) this.RaisePropertyChanged(nameof(Pens)); this.RaisePropertyChanged(nameof(DrawnPenId)); this.RaisePropertyChanged(nameof(HasNoPens)); + this.RaisePropertyChanged(nameof(DeltaReadoutText)); RequestRedraw(); } diff --git a/docs/architecture/charting.md b/docs/architecture/charting.md index 5df8f61..3d8b2b8 100644 --- a/docs/architecture/charting.md +++ b/docs/architecture/charting.md @@ -650,7 +650,10 @@ quoting). `SemiPlot.Core.Export.ArchiveRowSlices` holds the 15 minute slice widt `LogAxis.AdmitsMinimum` refuses (`trend-interaction.md`, "Mouse gestures on the active pen's scale"). - `PenValueFormat` — the `0.###` fallback mask, the character rule that accepts a stored mask, and the render under `CultureInfo.CurrentCulture`, for the sidebar row, the hover readout and the major labels - of a log axis. The rule runs once, in `PostgresDataProvider.ReadPen`, + of a log axis. The delta readout's Δy goes through `FormatDifference`, which keeps only the mask's + positive section without its sign literals: a difference is often negative and often zero, a reading's + negative or zero section (a sign left out, a dash for no reading) would misstate it, and a literal `+` + or parenthesis would print after the minus .NET puts ahead of a negative value. The rule runs once, in `PostgresDataProvider.ReadPen`, which is where the logger is; a rejected mask reaches the record as `null`, so `Pen.Format` in the row is always usable and the row neither validates nor logs. The rule is a character set rather than a `try`/`catch` because .NET throws on almost no bad mask: `qqq` prints literally and `%0.0` diff --git a/docs/architecture/trend-interaction.md b/docs/architecture/trend-interaction.md index 7334f0a..dde9258 100644 --- a/docs/architecture/trend-interaction.md +++ b/docs/architecture/trend-interaction.md @@ -309,8 +309,8 @@ pointer, so a second finger neither moves, pans nor ends the first finger's drag - **Delta cursors (Δt / Δy) via an explicit navigation-bar mode.** The bar's "Delta" toggle (`NavigationBarViewModel.IsDeltaModeEnabled`) sets the chart into `DeltaPlacement`: two left clicks place the cursors and drag does NOT pan; toggling off clears the placed cursors and hides the - lines. Δt and the **active-pen** Δy (`Core/Trends/DeltaCursorModel` → `DeltaReadout`) are shown in - an inline readout next to the toggle. + lines. Δt and the **active-pen** Δy (`Core/Trends/DeltaCursorModel` → `DeltaReadout`, Δy under the + positive section of the pen's mask, `charting.md`) are shown in an inline readout next to the toggle. - **Y-axis click-region scale edit.** A press on the active pen's Y-axis panel band (`Chart/ChartAxisRegion`, computed from the last render layout) is handled before Ctrl, the time axis, pan and delta routing and opens the axis scale panel (below). The same region routes the wheel to `ScaleActivePen`. diff --git a/docs/architecture/ui-text.md b/docs/architecture/ui-text.md index 6641046..a950820 100644 --- a/docs/architecture/ui-text.md +++ b/docs/architecture/ui-text.md @@ -44,7 +44,8 @@ machine, and the generated `Format*` methods pass the same null `Culture` to `st argument inside a failure detail follows the machine too. A pen's reading is the one number with a rule of its own, and `SemiPlot.Core.Trends.PenValueFormat` is that rule's single home: it applies the pen's stored mask under `CultureInfo.CurrentCulture`, and both the sidebar row and the chart's hover readout -render through it, so one reading never appears in two shapes on one screen. +render through it, so one reading never appears in two shapes on one screen. The delta readout's Δy +renders through its `FormatDifference`: the mask's positive section without its sign literals. ## The generated accessor