From 0f6e14bc1ddb12f56d02e5416a21cd26beb7818b Mon Sep 17 00:00:00 2001
From: Oleg <18466855+EvaTheSalmon@users.noreply.github.com>
Date: Fri, 16 Oct 2026 17:25:12 +0300
Subject: [PATCH 1/3] fix(chart): render the delta under the pen's mask
The delta readout rendered the active pen's difference under a fixed 0.###, so a pressure pen read every difference below 0.0005 as 0. PenValueFormat.FormatDifference renders it under the positive section of the pen's mask, and a catalogue apply refreshes the readout.
---
AGENTS.md | 4 ++--
.../SemiPlot.Core/Trends/PenValueFormat.cs | 11 +++++++++++
.../Core/Trends/PenValueFormatTests.cs | 15 +++++++++++++++
.../UI/Chart/ChartDeltaCursorReaderTests.cs | 17 ++++++++++++++---
.../UI/Chart/TrendChartViewModelTests.cs | 19 +++++++++++++++++++
.../Chart/ChartDeltaCursorReader.cs | 6 ++----
.../SemiPlot.UI/Chart/TrendChartViewModel.cs | 3 ++-
docs/architecture/charting.md | 4 +++-
docs/architecture/trend-interaction.md | 4 ++--
9 files changed, 70 insertions(+), 13 deletions(-)
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..c49be7b 100644
--- a/SemiPlot/SemiPlot.Core/Trends/PenValueFormat.cs
+++ b/SemiPlot/SemiPlot.Core/Trends/PenValueFormat.cs
@@ -63,6 +63,17 @@ 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, so the sign always
+ /// prints and a zero difference reads as a number (docs/architecture/charting.md).
+ ///
+ public static string FormatDifference(double value, string? mask)
+ {
+ var positiveSection = IsAcceptable(mask) ? mask.Split(';')[0] : FallbackMask;
+
+ return value.ToString(positiveSection, 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)
diff --git a/SemiPlot/SemiPlot.Tests.Unit/Core/Trends/PenValueFormatTests.cs b/SemiPlot/SemiPlot.Tests.Unit/Core/Trends/PenValueFormatTests.cs
index 12d75bc..45d1c82 100644
--- a/SemiPlot/SemiPlot.Tests.Unit/Core/Trends/PenValueFormatTests.cs
+++ b/SemiPlot/SemiPlot.Tests.Unit/Core/Trends/PenValueFormatTests.cs
@@ -71,6 +71,21 @@ public void ARejectedMaskRendersUnderTheFallback(string? mask)
});
}
+ // A difference is often negative and often zero, so it renders under the positive section alone: a
+ // reading's negative section may leave the sign out and its zero section may print a dash.
+ [Theory]
+ [InlineData("0.0;(0.0);-", -5.25, "-5.3")]
+ [InlineData("0.0;(0.0);-", 0.0, "0.0")]
+ [InlineData("0.0;0.0", -1.5, "-1.5")]
+ [InlineData("0.00E+00", -1.2e-5, "-1.20E-05")]
+ [InlineData("#,##0.00", 1234.5678, "1,234.57")]
+ [InlineData(null, -1.5, "-1.5")]
+ [InlineData("qqq", 0.0, "0")]
+ public void ADifferenceRendersUnderThePositiveSectionOfTheMask(string? mask, double value, string expected)
+ {
+ UnderTheInvariantCulture(() => 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..655deaf 100644
--- a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/TrendChartViewModelTests.cs
+++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/TrendChartViewModelTests.cs
@@ -1929,6 +1929,25 @@ 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 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..205ddba 100644
--- a/docs/architecture/charting.md
+++ b/docs/architecture/charting.md
@@ -650,7 +650,9 @@ 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: a difference is often negative and often zero, and a reading's negative or zero
+ section (a sign left out, a dash for no reading) would misstate it. 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`.
From 40a114b313079c915b28edb016ae9e8c6b2a3b14 Mon Sep 17 00:00:00 2001
From: Oleg <18466855+EvaTheSalmon@users.noreply.github.com>
Date: Fri, 16 Oct 2026 17:37:42 +0300
Subject: [PATCH 2/3] fix(chart): drop sign literals from the delta mask
A positive section carrying a literal +, ( or ) rendered a negative difference as -+5.0 or -(5.0), because .NET puts its own minus ahead of the section. FormatDifference drops those literals and keeps the exponent's sign.
---
.../SemiPlot.Core/Trends/PenValueFormat.cs | 15 +++++++++++----
.../Core/Trends/PenValueFormatTests.cs | 4 ++++
.../UI/Chart/TrendChartViewModelTests.cs | 19 +++++++++++++++++++
docs/architecture/ui-text.md | 3 ++-
4 files changed, 36 insertions(+), 5 deletions(-)
diff --git a/SemiPlot/SemiPlot.Core/Trends/PenValueFormat.cs b/SemiPlot/SemiPlot.Core/Trends/PenValueFormat.cs
index c49be7b..e48e91d 100644
--- a/SemiPlot/SemiPlot.Core/Trends/PenValueFormat.cs
+++ b/SemiPlot/SemiPlot.Core/Trends/PenValueFormat.cs
@@ -64,14 +64,16 @@ public static string Format(double value, string? mask)
}
///
- /// A difference of two readings under the positive section of the pen's mask alone, so the sign always
- /// prints and a zero difference reads as a number (docs/architecture/charting.md).
+ /// 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 positiveSection = IsAcceptable(mask) ? mask.Split(';')[0] : FallbackMask;
+ var differenceMask = IsAcceptable(mask)
+ ? SignLiteral().Replace(mask.Split(';')[0], string.Empty)
+ : FallbackMask;
- return value.ToString(positiveSection, CultureInfo.CurrentCulture);
+ return value.ToString(differenceMask, CultureInfo.CurrentCulture);
}
// The positive and negative sections must carry a 0, which always prints; the third is the zero
@@ -112,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(@"(? chart.DeltaReadoutText);
+ viewModel.DeltaReadoutText.Should().MatchRegex($@"{Resources.DeltaValueLabel} -?\d+\.\d{{4}}$");
+ }
+
[AvaloniaFact]
public void ExitingDeltaMode_ReturnsToPanAndClearsCursors()
{
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
From 7fbc2e6b44d741cf2af7d658d71e6659a4dc1dd7 Mon Sep 17 00:00:00 2001
From: Oleg <18466855+EvaTheSalmon@users.noreply.github.com>
Date: Fri, 16 Oct 2026 17:40:35 +0300
Subject: [PATCH 3/3] docs(architecture): state the delta mask's sign rule
---
docs/architecture/charting.md | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/docs/architecture/charting.md b/docs/architecture/charting.md
index 205ddba..3d8b2b8 100644
--- a/docs/architecture/charting.md
+++ b/docs/architecture/charting.md
@@ -651,8 +651,9 @@ quoting). `SemiPlot.Core.Export.ArchiveRowSlices` holds the 15 minute slice widt
- `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 delta readout's Δy goes through `FormatDifference`, which keeps only the mask's
- positive section: a difference is often negative and often zero, and a reading's negative or zero
- section (a sign left out, a dash for no reading) would misstate it. The rule runs once, in `PostgresDataProvider.ReadPen`,
+ 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`