diff --git a/AGENTS.md b/AGENTS.md
index 833716d..122f533 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -335,8 +335,9 @@ No abbreviations in names.
shows, then rebuilds the sidebar. The chart is never rebuilt whole
(`docs/architecture/overview.md#the-live-catalogue`).
- 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 and the
- chart's hover readout both go through it (`docs/architecture/ui-text.md`).
+ 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`).
- 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
@@ -344,7 +345,8 @@ No abbreviations in names.
errors. A `catch`, or an Rx `onError`, that only logs is a defect
(`docs/architecture/data-integration.md#no-failure-stops-at-the-log`). Code-behind reaches the
panel through its view model (`TrendChartViewModel.ReportFailure`, `MainWindowViewModel.ReportFailure`,
- `PenEditorViewModel.ReportFailure`, `StartupFailureViewModel.ReportFailure`).
+ `PenEditorViewModel.ReportFailure`, `StartupFailureViewModel.ReportFailure`,
+ `AxisScalePanelViewModel.ReportFailure`).
### Data-source projects
diff --git a/SemiPlot/SemiPlot.Core/Data/PenCatalogue.cs b/SemiPlot/SemiPlot.Core/Data/PenCatalogue.cs
index d8615a9..87aa82b 100644
--- a/SemiPlot/SemiPlot.Core/Data/PenCatalogue.cs
+++ b/SemiPlot/SemiPlot.Core/Data/PenCatalogue.cs
@@ -15,7 +15,8 @@ public sealed record StoredPen(
PenLineStyle LineStyle,
bool EnabledOnStart,
double? ScaleMinOnStart,
- double? ScaleMaxOnStart);
+ double? ScaleMaxOnStart,
+ bool LogScaleOnStart);
/// One semiplot_groups row and the ids of its member pens, in ascending order.
public sealed record StoredGroup(int Id, string Name, IReadOnlyList MemberPenIds);
diff --git a/SemiPlot/SemiPlot.Core/Data/PenSettingChange.cs b/SemiPlot/SemiPlot.Core/Data/PenSettingChange.cs
index 35eb68f..871b29c 100644
--- a/SemiPlot/SemiPlot.Core/Data/PenSettingChange.cs
+++ b/SemiPlot/SemiPlot.Core/Data/PenSettingChange.cs
@@ -25,4 +25,6 @@ public sealed record LineStyle(PenLineStyle Value) : PenSettingChange;
public sealed record EnabledOnStart(bool Value) : PenSettingChange;
public sealed record ScaleOnStart(double? Min, double? Max) : PenSettingChange;
+
+ public sealed record LogScaleOnStart(bool Value) : PenSettingChange;
}
diff --git a/SemiPlot/SemiPlot.Core/Trends/LogAxis.cs b/SemiPlot/SemiPlot.Core/Trends/LogAxis.cs
new file mode 100644
index 0000000..b03f08c
--- /dev/null
+++ b/SemiPlot/SemiPlot.Core/Trends/LogAxis.cs
@@ -0,0 +1,299 @@
+namespace SemiPlot.Core.Trends;
+
+/// A tick on a log10 axis; the position is in decades.
+public readonly record struct LogTick(double Position, bool IsMajor);
+
+public static class LogAxis
+{
+ private const double MinimumMajorGap = 40.0;
+ private const double MinimumMinorGap = 1.0;
+ private const double LinearMinorSteps = 4.0;
+ private const double LargestExactInteger = 9007199254740992.0;
+ private const double Tolerance = 1e-9;
+ private static readonly double[] _oneTwoFive = [1.0, 2.0, 5.0];
+ private static readonly double[] _skippedByOneTwoFive = [3.0, 4.0, 6.0, 7.0, 8.0, 9.0];
+ private static readonly double[] _insideDecade = [2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0];
+ private static readonly double[] _strideMantissas = [1.0, 2.0, 3.0, 5.0];
+
+ /// A log10 axis needs a minimum above zero; no minimum (autoscale) always qualifies.
+ public static bool AdmitsMinimum(double? minimum)
+ {
+ return minimum is not { } value || value > 0.0;
+ }
+
+ /// A gap stays NaN; any value below the floor, zero and negatives included, lands on it.
+ public static double Project(double value, double floor)
+ {
+ if (double.IsNaN(value))
+ {
+ return value;
+ }
+
+ return value > 0.0 ? Math.Max(Math.Log10(value), floor) : floor;
+ }
+
+ public static IReadOnlyList Ticks(double minLog, double maxLog, double pixelLength)
+ {
+ var span = maxLog - minLog;
+ if (!double.IsFinite(minLog) || !double.IsFinite(maxLog) || !double.IsFinite(span) || span <= 0.0
+ || !double.IsFinite(pixelLength) || pixelLength <= 0.0)
+ {
+ return [];
+ }
+
+ var axis = new AxisSpan(minLog, maxLog, pixelLength / span, (int)(pixelLength / MinimumMajorGap) + 2);
+ var layout = ChooseMajors(axis);
+ var ticks = new List();
+
+ foreach (var major in layout.Majors)
+ {
+ ticks.Add(new LogTick(major, IsMajor: true));
+ }
+
+ AddMinors(layout, axis, ticks);
+ ticks.Sort((left, right) => left.Position.CompareTo(right.Position));
+
+ return ticks;
+ }
+
+ private static MajorLayout ChooseMajors(AxisSpan axis)
+ {
+ MajorLayout? chosen = null;
+
+ foreach (var candidate in new[] { LinearMajors(axis), OneTwoFiveMajors(axis), DecadeMajors(axis) })
+ {
+ if (candidate is { } layout && axis.IsReadable(layout.Majors)
+ && (chosen is null || layout.Majors.Count > chosen.Value.Majors.Count))
+ {
+ chosen = layout;
+ }
+ }
+
+ return chosen ?? new MajorLayout(MajorFamily.RangeEnds, [axis.MinLog, axis.MaxLog], Step: 0.0);
+ }
+
+ private static MajorLayout? LinearMajors(AxisSpan axis)
+ {
+ var bottom = Math.Pow(10.0, axis.MinLog);
+ var top = Math.Pow(10.0, axis.MaxLog);
+ var narrowestGapAtTop = 1.0 - Math.Pow(10.0, -MinimumMajorGap / axis.PixelsPerDecade);
+ var step = SmallestStepAtLeast(top * narrowestGapAtTop, _oneTwoFive);
+ var first = Math.Max(1.0, Math.Ceiling((bottom / step) - Tolerance));
+ var last = Math.Floor((top / step) + Tolerance);
+ var majors = LatticeMajors(axis, first, last, multiple => Math.Log10(multiple * step));
+
+ return majors is null ? null : new MajorLayout(MajorFamily.Linear, majors, step);
+ }
+
+ private static MajorLayout? OneTwoFiveMajors(AxisSpan axis)
+ {
+ var decades = axis.DecadesTouched();
+ if (decades - 1 > axis.MajorLimit)
+ {
+ return null;
+ }
+
+ var majors = new List();
+ for (var index = 0; index < decades; index++)
+ {
+ AddMantissas(axis, Math.Floor(axis.MinLog) + index, _oneTwoFive, majors);
+ if (majors.Count > axis.MajorLimit)
+ {
+ return null;
+ }
+ }
+
+ return new MajorLayout(MajorFamily.OneTwoFive, majors, Step: 0.0);
+ }
+
+ private static MajorLayout? DecadeMajors(AxisSpan axis)
+ {
+ var stride = Math.Max(1.0, SmallestStepAtLeast(MinimumMajorGap / axis.PixelsPerDecade, _strideMantissas));
+ var first = Math.Ceiling((axis.MinLog / stride) - Tolerance);
+ var last = Math.Floor((axis.MaxLog / stride) + Tolerance);
+ var majors = LatticeMajors(axis, first, last, multiple => multiple * stride);
+
+ return majors is null ? null : new MajorLayout(MajorFamily.Decades, majors, stride);
+ }
+
+ /// The majors on the multiples first..last; null for fewer than two or more than the axis holds.
+ private static List? LatticeMajors(
+ AxisSpan axis,
+ double first,
+ double last,
+ Func positionOfMultiple)
+ {
+ var count = last - first + 1.0;
+
+ if (!(count >= 2.0 && count <= axis.MajorLimit))
+ {
+ return null;
+ }
+
+ var majors = new List((int)count);
+ for (var index = 0; index < count; index++)
+ {
+ if (axis.TryPlace(positionOfMultiple(first + index), out var position))
+ {
+ majors.Add(position);
+ }
+ }
+
+ return majors;
+ }
+
+ /// The smallest mantissa times a power of ten at or above the minimum; NaN for no finite one.
+ private static double SmallestStepAtLeast(double minimum, double[] mantissas)
+ {
+ if (!double.IsFinite(minimum) || minimum <= 0.0)
+ {
+ return double.NaN;
+ }
+
+ var decade = Math.Pow(10.0, Math.Floor(Math.Log10(minimum)));
+ foreach (var mantissa in mantissas)
+ {
+ if (mantissa * decade >= minimum * (1.0 - Tolerance))
+ {
+ return mantissa * decade;
+ }
+ }
+
+ return 10.0 * decade;
+ }
+
+ private static void AddMinors(MajorLayout layout, AxisSpan axis, List ticks)
+ {
+ var minors = new List();
+
+ switch (layout.Family)
+ {
+ case MajorFamily.Linear:
+ AddLinearMinors(axis, layout.Step, minors);
+ break;
+ case MajorFamily.OneTwoFive:
+ AddMantissasOfEveryDecade(axis, _skippedByOneTwoFive, minors);
+ break;
+ case MajorFamily.Decades when layout.Step == 1.0:
+ AddMantissasOfEveryDecade(axis, _insideDecade, minors);
+ break;
+ case MajorFamily.Decades:
+ AddSkippedDecades(axis, layout.Step, minors);
+ break;
+ case MajorFamily.RangeEnds:
+ break;
+ }
+
+ foreach (var minor in minors)
+ {
+ ticks.Add(new LogTick(minor, IsMajor: false));
+ }
+ }
+
+ private static void AddLinearMinors(AxisSpan axis, double step, List minors)
+ {
+ var minorStep = step / LinearMinorSteps;
+ var first = Math.Max(1.0, Math.Ceiling((Math.Pow(10.0, axis.MinLog) / minorStep) - Tolerance));
+ var last = Math.Floor((Math.Pow(10.0, axis.MaxLog) / minorStep) + Tolerance);
+ var count = last - first + 1.0;
+ var minorLimit = LinearMinorSteps * (axis.MajorLimit + 2);
+
+ if (!(count <= minorLimit) || last > LargestExactInteger)
+ {
+ return;
+ }
+
+ for (var index = 0L; index < count; index++)
+ {
+ var multiple = first + index;
+ if (multiple % LinearMinorSteps != 0.0 && axis.TryPlace(Math.Log10(multiple * minorStep), out var position))
+ {
+ minors.Add(position);
+ }
+ }
+ }
+
+ private static void AddMantissasOfEveryDecade(AxisSpan axis, double[] mantissas, List minors)
+ {
+ var decades = axis.DecadesTouched();
+ for (var index = 0; index < decades; index++)
+ {
+ AddMantissas(axis, Math.Floor(axis.MinLog) + index, mantissas, minors);
+ }
+ }
+
+ private static void AddMantissas(AxisSpan axis, double decade, double[] mantissas, List positions)
+ {
+ foreach (var mantissa in mantissas)
+ {
+ if (axis.TryPlace(decade + Math.Log10(mantissa), out var position))
+ {
+ positions.Add(position);
+ }
+ }
+ }
+
+ private static void AddSkippedDecades(AxisSpan axis, double stride, List minors)
+ {
+ if (axis.PixelsPerDecade < MinimumMinorGap)
+ {
+ return;
+ }
+
+ var first = Math.Ceiling(axis.MinLog - Tolerance);
+ var count = Math.Floor(axis.MaxLog + Tolerance) - first + 1.0;
+
+ for (var index = 0L; index < count; index++)
+ {
+ var decade = first + index;
+ if (decade % stride != 0.0 && axis.TryPlace(decade, out var position))
+ {
+ minors.Add(position);
+ }
+ }
+ }
+
+ private enum MajorFamily
+ {
+ Linear,
+ OneTwoFive,
+ Decades,
+ RangeEnds
+ }
+
+ /// Step is the value step of the linear family and the decade stride of the decade family.
+ private readonly record struct MajorLayout(MajorFamily Family, List Majors, double Step);
+
+ private readonly record struct AxisSpan(double MinLog, double MaxLog, double PixelsPerDecade, int MajorLimit)
+ {
+ public double DecadesTouched()
+ {
+ return Math.Floor(MaxLog) - Math.Floor(MinLog) + 1.0;
+ }
+
+ public bool TryPlace(double position, out double placed)
+ {
+ placed = Math.Clamp(position, MinLog, MaxLog);
+
+ return position >= MinLog - Tolerance && position <= MaxLog + Tolerance;
+ }
+
+ public bool IsReadable(List majors)
+ {
+ if (majors.Count < 2 || majors.Count > MajorLimit)
+ {
+ return false;
+ }
+
+ for (var index = 1; index < majors.Count; index++)
+ {
+ if ((majors[index] - majors[index - 1]) * PixelsPerDecade < MinimumMajorGap - Tolerance)
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+ }
+}
diff --git a/SemiPlot/SemiPlot.Core/Trends/Pen.cs b/SemiPlot/SemiPlot.Core/Trends/Pen.cs
index 064d97b..1eadc27 100644
--- a/SemiPlot/SemiPlot.Core/Trends/Pen.cs
+++ b/SemiPlot/SemiPlot.Core/Trends/Pen.cs
@@ -10,6 +10,7 @@ public sealed record Pen(
bool EnabledOnStart = true,
double? ScaleMinOnStart = null,
double? ScaleMaxOnStart = null,
+ bool LogScaleOnStart = false,
PenLineStyle LineStyle = PenLineStyle.Interpolated)
{
/// Compares element by element, so two reads of one stored pen are equal.
@@ -25,6 +26,7 @@ public bool Equals(Pen? other)
&& EnabledOnStart == other.EnabledOnStart
&& Nullable.Equals(ScaleMinOnStart, other.ScaleMinOnStart)
&& Nullable.Equals(ScaleMaxOnStart, other.ScaleMaxOnStart)
+ && LogScaleOnStart == other.LogScaleOnStart
&& LineStyle == other.LineStyle;
}
@@ -46,6 +48,7 @@ public override int GetHashCode()
hash.Add(EnabledOnStart);
hash.Add(ScaleMinOnStart);
hash.Add(ScaleMaxOnStart);
+ hash.Add(LogScaleOnStart);
hash.Add(LineStyle);
return hash.ToHashCode();
diff --git a/SemiPlot/SemiPlot.Core/Trends/PenScaleModel.cs b/SemiPlot/SemiPlot.Core/Trends/PenScaleModel.cs
index 94784ea..ecd1bf5 100644
--- a/SemiPlot/SemiPlot.Core/Trends/PenScaleModel.cs
+++ b/SemiPlot/SemiPlot.Core/Trends/PenScaleModel.cs
@@ -4,8 +4,12 @@ public sealed class PenScaleModel
{
private const double AutoPaddingFraction = 0.05;
private const double FlatRangePadding = 0.5;
+ private const double FlatLogRangeDecades = 0.5;
private const double LogFallbackMin = 1.0;
private const double LogFallbackMax = 10.0;
+ // 10 to either power is a finite, normal double.
+ private const double LowestLogDecade = -307.0;
+ private const double HighestLogDecade = 308.0;
public IReadOnlyList Compute(
IReadOnlyList settings,
@@ -66,10 +70,15 @@ private static (double Min, double Max) ComputeRange(
private static (double Min, double Max) SanitizeManualRange(PenScaleSettings setting, bool isLogarithmic)
{
+ if (!double.IsFinite(setting.ManualMin) || !double.IsFinite(setting.ManualMax))
+ {
+ return DefaultRange(isLogarithmic);
+ }
+
var min = Math.Min(setting.ManualMin, setting.ManualMax);
var max = Math.Max(setting.ManualMin, setting.ManualMax);
- if (isLogarithmic && min <= 0.0)
+ if (isLogarithmic && !LogAxis.AdmitsMinimum(min))
{
min = max > 0.0 ? Math.Min(LogFallbackMin, max) : LogFallbackMin;
if (max <= min)
@@ -134,26 +143,28 @@ private static int FirstAtOrAfter(IReadOnlyList timestamps, DateTime w
private static (double Min, double Max) PadRange(double min, double max, bool isLogarithmic)
{
- if (min == max)
+ if (isLogarithmic)
{
- return ClampLowerToPositive(min - FlatRangePadding, max + FlatRangePadding, isLogarithmic);
+ return PadLogRange(min, max);
}
- var padding = (max - min) * AutoPaddingFraction;
+ var padding = min == max ? FlatRangePadding : (max - min) * AutoPaddingFraction;
- return ClampLowerToPositive(min - padding, max + padding, isLogarithmic);
+ return (min - padding, max + padding);
}
- // A log axis has no defined range below zero, so a padded lower bound is clamped to keep the
- // minimum positive instead of dipping the auto padding past zero.
- private static (double Min, double Max) ClampLowerToPositive(double min, double max, bool isLogarithmic)
+ // docs/architecture/charting.md#log10-y-axis
+ private static (double Min, double Max) PadLogRange(double min, double max)
{
- if (isLogarithmic && min <= 0.0)
- {
- min = Math.Min(LogFallbackMin, max);
- }
-
- return (min, max);
+ var lowerDecade = Math.Clamp(Math.Log10(min), LowestLogDecade, HighestLogDecade);
+ var upperDecade = Math.Clamp(Math.Log10(max), LowestLogDecade, HighestLogDecade);
+ var padding = lowerDecade == upperDecade
+ ? FlatLogRangeDecades
+ : (upperDecade - lowerDecade) * AutoPaddingFraction;
+
+ return (
+ Math.Pow(10.0, Math.Max(lowerDecade - padding, LowestLogDecade)),
+ Math.Pow(10.0, Math.Min(upperDecade + padding, HighestLogDecade)));
}
private static (double Min, double Max) DefaultRange(bool isLogarithmic)
diff --git a/SemiPlot/SemiPlot.Core/Trends/PenScaleSettings.cs b/SemiPlot/SemiPlot.Core/Trends/PenScaleSettings.cs
index 26de818..1cf146b 100644
--- a/SemiPlot/SemiPlot.Core/Trends/PenScaleSettings.cs
+++ b/SemiPlot/SemiPlot.Core/Trends/PenScaleSettings.cs
@@ -9,7 +9,7 @@ public sealed record PenScaleSettings(
{
public static PenScaleSettings InitialFor(Pen pen)
{
- var settings = new PenScaleSettings(pen.PenId);
+ var settings = new PenScaleSettings(pen.PenId, IsLogarithmic: pen.LogScaleOnStart);
if (pen.ScaleMinOnStart is { } min && pen.ScaleMaxOnStart is { } max)
{
diff --git a/SemiPlot/SemiPlot.DataSource.Postgres/ArchiveStatements.cs b/SemiPlot/SemiPlot.DataSource.Postgres/ArchiveStatements.cs
index 61ea841..8ca4917 100644
--- a/SemiPlot/SemiPlot.DataSource.Postgres/ArchiveStatements.cs
+++ b/SemiPlot/SemiPlot.DataSource.Postgres/ArchiveStatements.cs
@@ -1,7 +1,7 @@
namespace SemiPlot.DataSource.Postgres;
///
-/// The column order of and, up to , of
+/// The column order of and, up to , of
/// , named so a transposition shows.
///
internal static class PenCatalogColumn
@@ -15,7 +15,8 @@ internal static class PenCatalogColumn
public const int EnabledOnStart = 6;
public const int ScaleMinOnStart = 7;
public const int ScaleMaxOnStart = 8;
- public const int Groups = 9;
+ public const int LogScaleOnStart = 9;
+ public const int Groups = 10;
}
/// The column order of , named so a transposition shows.
@@ -65,7 +66,7 @@ internal static class ArchiveStatements
///
public const string StoredPens = """
SELECT id, name, unit, format, color, line_style, enabled_on_start,
- scale_min_on_start, scale_max_on_start
+ scale_min_on_start, scale_max_on_start, log_scale_on_start
FROM semiplot_tags
ORDER BY id;
""";
@@ -105,6 +106,10 @@ UPDATE semiplot_tags
WHERE id = @id;
""";
+ public const string UpdatePenLogScaleOnStart = """
+ UPDATE semiplot_tags SET log_scale_on_start = @value WHERE id = @id;
+ """;
+
public const string CreateGroup = "INSERT INTO semiplot_groups (name) VALUES (@name) RETURNING id;";
public const string RenameGroup = "UPDATE semiplot_groups SET name = @name WHERE id = @id;";
@@ -132,6 +137,7 @@ DELETE FROM semiplot_pen_groups
public const string PenCatalog = """
SELECT tag.id, tag.name, tag.unit, tag.format, tag.color, tag.line_style,
tag.enabled_on_start, tag.scale_min_on_start, tag.scale_max_on_start,
+ tag.log_scale_on_start,
coalesce(array_agg(grp.name ORDER BY grp.name)
FILTER (WHERE grp.name IS NOT NULL), '{}') AS groups
FROM semiplot_tags tag
diff --git a/SemiPlot/SemiPlot.DataSource.Postgres/PostgresDataProvider.cs b/SemiPlot/SemiPlot.DataSource.Postgres/PostgresDataProvider.cs
index c8e7e0d..36d5bda 100644
--- a/SemiPlot/SemiPlot.DataSource.Postgres/PostgresDataProvider.cs
+++ b/SemiPlot/SemiPlot.DataSource.Postgres/PostgresDataProvider.cs
@@ -474,6 +474,7 @@ private Pen ReadPen(NpgsqlDataReader reader)
reader.GetBoolean(PenCatalogColumn.EnabledOnStart),
scaleMin,
scaleMax,
+ reader.GetBoolean(PenCatalogColumn.LogScaleOnStart),
ReadLineStyle(reader, penId));
}
diff --git a/SemiPlot/SemiPlot.DataSource.Postgres/PostgresPenCatalogueEditor.cs b/SemiPlot/SemiPlot.DataSource.Postgres/PostgresPenCatalogueEditor.cs
index 4710daf..65a1b8b 100644
--- a/SemiPlot/SemiPlot.DataSource.Postgres/PostgresPenCatalogueEditor.cs
+++ b/SemiPlot/SemiPlot.DataSource.Postgres/PostgresPenCatalogueEditor.cs
@@ -168,6 +168,8 @@ PenSettingChange.EnabledOnStart enabledOnStart
=> Value(ArchiveStatements.UpdatePenEnabledOnStart, NpgsqlDbType.Boolean, enabledOnStart.Value),
PenSettingChange.ScaleOnStart scale
=> (ArchiveStatements.UpdatePenScaleOnStart, ScaleOnStartParameters(scale)),
+ PenSettingChange.LogScaleOnStart logScaleOnStart
+ => Value(ArchiveStatements.UpdatePenLogScaleOnStart, NpgsqlDbType.Boolean, logScaleOnStart.Value),
_ => throw new ArgumentOutOfRangeException(nameof(change), change, "Unknown pen setting change.")
};
}
@@ -258,7 +260,8 @@ private StoredPen ReadStoredPen(NpgsqlDataReader reader)
StoredLineStyle.Read(storedLineStyle, penId, _logger, () => LogLevel.Warning),
reader.GetBoolean(PenCatalogColumn.EnabledOnStart),
ReadBound(reader, PenCatalogColumn.ScaleMinOnStart),
- ReadBound(reader, PenCatalogColumn.ScaleMaxOnStart));
+ ReadBound(reader, PenCatalogColumn.ScaleMaxOnStart),
+ reader.GetBoolean(PenCatalogColumn.LogScaleOnStart));
}
private static string? ReadText(NpgsqlDataReader reader, int ordinal)
diff --git a/SemiPlot/SemiPlot.Tests.Integration/PenCatalogueEditorTests.cs b/SemiPlot/SemiPlot.Tests.Integration/PenCatalogueEditorTests.cs
index 97aff56..5e3558e 100644
--- a/SemiPlot/SemiPlot.Tests.Integration/PenCatalogueEditorTests.cs
+++ b/SemiPlot/SemiPlot.Tests.Integration/PenCatalogueEditorTests.cs
@@ -62,7 +62,8 @@ public async Task ReadReturnsEveryPenAndGroupAsStored()
PenLineStyle.Interpolated,
true,
0,
- 100));
+ 100,
+ false));
catalogue.Groups.Select(group => group.Name).Should().Equal(HeatersGroup, VacuumGroup);
Group(catalogue, HeatersGroup).MemberPenIds.Should().Equal(_heaterIds);
Group(catalogue, VacuumGroup).MemberPenIds.Should().Equal(ChamberPressureId);
@@ -90,6 +91,7 @@ public async Task ReadReturnsANullColourAsNullAndAnUnusableMaskAsItIsStored()
[InlineData(nameof(PenSettingChange.LineStyle))]
[InlineData(nameof(PenSettingChange.EnabledOnStart))]
[InlineData(nameof(PenSettingChange.ScaleOnStart))]
+ [InlineData(nameof(PenSettingChange.LogScaleOnStart))]
public async Task EachChangeWritesItsOwnColumnAndNothingElse(string arm)
{
using var services = Build();
@@ -104,6 +106,26 @@ public async Task EachChangeWritesItsOwnColumnAndNothingElse(string arm)
(await PenAsync(editor, ChamberPressureId)).Should().Be(expected);
}
+ [Fact]
+ public async Task AWrittenLogScaleOnStartReadsBackThroughBothReads()
+ {
+ using var services = Build();
+
+ var editor = Editor(services);
+ var pen = await PenAsync(editor, ChamberPressureId);
+
+ var result = await editor.ChangeAsync(pen, new PenSettingChange.LogScaleOnStart(true));
+
+ result.IsSuccess.Should().BeTrue(ArchiveReadSupport.Describe(result));
+ (await PenAsync(editor, ChamberPressureId)).LogScaleOnStart.Should().BeTrue();
+
+ var pens = await services.GetRequiredService().QueryPensAsync();
+
+ pens.IsSuccess.Should().BeTrue(ArchiveReadSupport.Describe(pens));
+ pens.Value.Should().ContainSingle(read => read.PenId == ChamberPressureId)
+ .Which.LogScaleOnStart.Should().BeTrue();
+ }
+
[Fact]
public async Task TwoInstancesWritingTwoColumnsOfOnePenBothSurvive()
{
@@ -424,6 +446,8 @@ private static (PenSettingChange Change, StoredPen Expected) Arm(string arm, Sto
=> (new PenSettingChange.EnabledOnStart(false), pen with { EnabledOnStart = false }),
nameof(PenSettingChange.ScaleOnStart)
=> (new PenSettingChange.ScaleOnStart(-1, 1), pen with { ScaleMinOnStart = -1, ScaleMaxOnStart = 1 }),
+ nameof(PenSettingChange.LogScaleOnStart)
+ => (new PenSettingChange.LogScaleOnStart(true), pen with { LogScaleOnStart = true }),
_ => throw new ArgumentOutOfRangeException(nameof(arm), arm, "Unknown change arm.")
};
}
diff --git a/SemiPlot/SemiPlot.Tests.Integration/PostgresCatalogReadTests.cs b/SemiPlot/SemiPlot.Tests.Integration/PostgresCatalogReadTests.cs
index edf3725..d35350a 100644
--- a/SemiPlot/SemiPlot.Tests.Integration/PostgresCatalogReadTests.cs
+++ b/SemiPlot/SemiPlot.Tests.Integration/PostgresCatalogReadTests.cs
@@ -54,6 +54,14 @@ INSERT INTO public.semiplot_tags (id, name, format, color, line_style)
(9998, 'Shaping mask', '0.##0', '#123456', 0);
""";
+ private const int LogScaleTagId = 9995;
+
+ private const string LogScaleTagCommand =
+ """
+ INSERT INTO public.semiplot_tags (id, name, color, line_style, log_scale_on_start)
+ VALUES (9995, 'Log scale', '#123456', 0, true);
+ """;
+
private const int NullColumnTagId = 9999;
// Every nullable column of SemiBase's semiplot_tags at once, and no membership: the state a pen
@@ -105,6 +113,12 @@ public async Task SeededCatalogueCarriesTheCommissionedFieldsOfEachPen()
commissioned.EnabledOnStart.Should().BeTrue();
Single(result.Value, SyntheticPenCatalog.HiddenOnStartPenId).EnabledOnStart.Should().BeFalse();
+
+ var logScale = Single(result.Value, SyntheticPenCatalog.LogScalePenId);
+
+ logScale.LogScaleOnStart.Should().BeTrue();
+ logScale.Format.Should().Be("0.0E+0");
+ logScale.ScaleMinOnStart.Should().Be(1e-6);
}
// One row, not two: the memberships aggregate into the array the record carries.
@@ -169,6 +183,24 @@ await ArchiveDatabase.ExecuteAsync(
Single(result.Value, ScalingMaskTagId).Format.Should().BeNull();
}
+ [Fact]
+ public async Task AStoredLogScaleOnStartReadsBackAndTheDefaultReadsLinear()
+ {
+ await using var database = await postgresContainerFixture.CloneTemplateAsync(
+ TestContext.Current.CancellationToken);
+
+ await ArchiveDatabase.ExecuteAsync(
+ database.AdminConnectionString,
+ LogScaleTagCommand,
+ TestContext.Current.CancellationToken);
+
+ var result = await ReadCatalogueAsync(database.PlotConnectionString);
+
+ result.IsSuccess.Should().BeTrue(ArchiveReadSupport.Describe(result));
+ Single(result.Value, LogScaleTagId).LogScaleOnStart.Should().BeTrue();
+ Single(result.Value, SyntheticPenCatalog.TwoGroupPenId).LogScaleOnStart.Should().BeFalse();
+ }
+
[Fact]
public async Task SeededCatalogueLineStylesReadBackAsTheStoredOrdinals()
{
@@ -291,6 +323,7 @@ private static Pen ToPen(SyntheticPen pen)
pen.EnabledOnStart,
pen.ScaleMinOnStart,
pen.ScaleMaxOnStart,
+ pen.LogScaleOnStart,
pen.LineStyle);
}
diff --git a/SemiPlot/SemiPlot.Tests.Integration/TagCatalogWriterTests.cs b/SemiPlot/SemiPlot.Tests.Integration/TagCatalogWriterTests.cs
index 1ad6a56..34f5c1d 100644
--- a/SemiPlot/SemiPlot.Tests.Integration/TagCatalogWriterTests.cs
+++ b/SemiPlot/SemiPlot.Tests.Integration/TagCatalogWriterTests.cs
@@ -29,6 +29,9 @@ FROM public.semiplot_pen_groups membership
private const string StoredScalesCommand =
"SELECT id, scale_min_on_start, scale_max_on_start FROM public.semiplot_tags ORDER BY id;";
+ private const string LogScaleFlagsCommand =
+ "SELECT id, log_scale_on_start FROM public.semiplot_tags ORDER BY id;";
+
[Fact]
public async Task TheGroupTableHoldsEveryGroupTheSliceNames()
{
@@ -69,6 +72,26 @@ .. Slice()
&& scale.Max == null);
}
+ [Fact]
+ public async Task TheStoredLogScaleFlagIsTheSyntheticPensOwn()
+ {
+ await using var database = await WrittenSliceAsync();
+
+ var written = await ReadAsync(
+ database,
+ LogScaleFlagsCommand,
+ reader => (PenId: reader.GetInt32(0), LogScaleOnStart: reader.GetBoolean(1)));
+
+ written.Should().Equal(
+ [
+ .. Slice()
+ .Select(pen => (pen.PenId, pen.LogScaleOnStart))
+ .OrderBy(flag => flag.PenId)
+ ]);
+ written.Should().ContainSingle(flag => flag.LogScaleOnStart)
+ .Which.PenId.Should().Be(SyntheticPenCatalog.LogScalePenId);
+ }
+
// A template rebuild runs the writer over a catalogue it already wrote; the memberships are
// replaced, so a rerun over the same slice duplicates none of them.
[Fact]
diff --git a/SemiPlot/SemiPlot.Tests.Unit/Core/Trends/LogAxisTests.cs b/SemiPlot/SemiPlot.Tests.Unit/Core/Trends/LogAxisTests.cs
new file mode 100644
index 0000000..5e9b514
--- /dev/null
+++ b/SemiPlot/SemiPlot.Tests.Unit/Core/Trends/LogAxisTests.cs
@@ -0,0 +1,266 @@
+using AwesomeAssertions;
+
+using SemiPlot.Core.Trends;
+
+using Xunit;
+
+namespace SemiPlot.Tests.Unit.Core.Trends;
+
+[Trait("Component", "Core")]
+[Trait("Area", "Chart")]
+[Trait("Category", "Unit")]
+public sealed class LogAxisTests
+{
+ private const double MinimumMajorGap = 40.0;
+ private const double Floor = -9.0;
+ private const double ProjectionTolerance = 1e-12;
+ private const double PositionTolerance = 1e-9;
+ private const double RelativeValueTolerance = 1e-9;
+ private const double GapTolerance = 1e-6;
+ private static readonly TimeSpan _ticksDeadline = TimeSpan.FromSeconds(5);
+
+ [Fact]
+ public void Project_PositiveValue_GivesItsDecade()
+ {
+ LogAxis.Project(100.0, Floor).Should().BeApproximately(2.0, ProjectionTolerance);
+ LogAxis.Project(1e-6, Floor).Should().BeApproximately(-6.0, ProjectionTolerance);
+ }
+
+ [Theory]
+ [InlineData(0.0)]
+ [InlineData(-5.0)]
+ public void Project_NonPositiveValue_LandsOnTheFloor(double value)
+ {
+ LogAxis.Project(value, Floor).Should().Be(Floor);
+ }
+
+ [Fact]
+ public void Project_PositiveValueBelowTheFloor_LandsOnTheFloor()
+ {
+ LogAxis.Project(1e-12, Floor).Should().Be(Floor);
+ }
+
+ [Fact]
+ public void Project_Gap_StaysNaN()
+ {
+ double.IsNaN(LogAxis.Project(double.NaN, Floor)).Should().BeTrue();
+ }
+
+ [Theory]
+ [InlineData(null, true)]
+ [InlineData(1e-6, true)]
+ [InlineData(0.0, false)]
+ [InlineData(-5.0, false)]
+ public void AdmitsMinimum_AdmitsNoMinimumAndAnyAboveZero(double? minimum, bool admits)
+ {
+ LogAxis.AdmitsMinimum(minimum).Should().Be(admits);
+ }
+
+ [Fact]
+ public void Ticks_SixDecadesOver400Pixels_PutMajorsOnDecadesAndMinorsInside()
+ {
+ var ticks = LogAxis.Ticks(-6.0, -1.0, 400.0);
+
+ Positions(ticks, isMajor: true).Should().Equal([-6.0, -5.0, -4.0, -3.0, -2.0, -1.0], SamePosition);
+
+ var expectedMinors = new List();
+ for (var decade = -6; decade <= -2; decade++)
+ {
+ for (var mantissa = 2; mantissa <= 9; mantissa++)
+ {
+ expectedMinors.Add(decade + Math.Log10(mantissa));
+ }
+ }
+
+ Positions(ticks, isMajor: false).Should().Equal(expectedMinors, SamePosition);
+ }
+
+ [Fact]
+ public void Ticks_SixDecadesOver400Pixels_StayWithinThePerCallBound()
+ {
+ const double PixelLength = 400.0;
+ var majorBound = (int)(PixelLength / MinimumMajorGap) + 2;
+
+ var ticks = LogAxis.Ticks(-6.0, -1.0, PixelLength);
+
+ ticks.Count(tick => tick.IsMajor).Should().BeLessThanOrEqualTo(majorBound);
+ ticks.Should().HaveCountLessThanOrEqualTo(majorBound * 9);
+ }
+
+ [Fact]
+ public void Ticks_TwelveDecadesOver200Pixels_StrideDecadesAndMarkTheSkippedOnes()
+ {
+ var ticks = LogAxis.Ticks(-12.0, 0.0, 200.0);
+
+ Positions(ticks, isMajor: true).Should().Equal([-12.0, -9.0, -6.0, -3.0, 0.0], SamePosition);
+ Positions(ticks, isMajor: false).Should().Equal(
+ [-11.0, -10.0, -8.0, -7.0, -5.0, -4.0, -2.0, -1.0], SamePosition);
+ }
+
+ [Theory]
+ [InlineData(2.0, 50.0, new[] { 2.0, 5.0, 10.0, 20.0, 50.0 })]
+ [InlineData(0.2, 5.0, new[] { 0.2, 0.5, 1.0, 2.0, 5.0 })]
+ [InlineData(1.5, 14.0, new[] { 2.0, 5.0, 10.0 })]
+ [InlineData(0.9, 1.4, new[] { 0.9, 1.0, 1.1, 1.2, 1.3, 1.4 })]
+ [InlineData(1.05, 1.2, new[] { 1.06, 1.08, 1.1, 1.12, 1.14, 1.16, 1.18, 1.2 })]
+ public void Ticks_RangeUnderAFewDecades_LabelsReadableValues(double bottom, double top, double[] expected)
+ {
+ var ticks = LogAxis.Ticks(Math.Log10(bottom), Math.Log10(top), 400.0);
+
+ Values(ticks, isMajor: true).Should().Equal(expected, SameValue);
+ }
+
+ [Fact]
+ public void Ticks_LinearSteps_PlaceMinorsInThePartialIntervalsAtBothEnds()
+ {
+ var ticks = LogAxis.Ticks(Math.Log10(1.05), Math.Log10(1.19), 400.0);
+
+ Values(ticks, isMajor: true).Should().Equal([1.06, 1.08, 1.1, 1.12, 1.14, 1.16, 1.18], SameValue);
+ Values(ticks, isMajor: false).Should().Equal(
+ [
+ 1.05, 1.055, 1.065, 1.07, 1.075, 1.085, 1.09, 1.095, 1.105, 1.11, 1.115,
+ 1.125, 1.13, 1.135, 1.145, 1.15, 1.155, 1.165, 1.17, 1.175, 1.185, 1.19
+ ],
+ SameValue);
+ }
+
+ [Fact]
+ public void Ticks_OneTwoFive_PlaceMinorsOnTheMantissasItSkips()
+ {
+ var ticks = LogAxis.Ticks(Math.Log10(2.0), Math.Log10(50.0), 400.0);
+
+ Values(ticks, isMajor: true).Should().Equal([2.0, 5.0, 10.0, 20.0, 50.0], SameValue);
+ Values(ticks, isMajor: false).Should().Equal([3.0, 4.0, 6.0, 7.0, 8.0, 9.0, 30.0, 40.0], SameValue);
+ }
+
+ [Fact]
+ public void Ticks_AxisTooShortForAnyFamily_MakesTheRangeEndsTheMajorsWithNoMinors()
+ {
+ var ticks = LogAxis.Ticks(-6.0, -1.0, 30.0);
+
+ ticks.Should().Equal(new LogTick(-6.0, IsMajor: true), new LogTick(-1.0, IsMajor: true));
+ }
+
+ [Fact]
+ public void Ticks_Decades_PlaceMinorsInThePartialDecadesAtBothEnds()
+ {
+ var ticks = LogAxis.Ticks(Math.Log10(1.5e-6), Math.Log10(5e-2), 400.0);
+
+ Positions(ticks, isMajor: true).Should().Equal([-5.0, -4.0, -3.0, -2.0], SamePosition);
+ var minors = Positions(ticks, isMajor: false);
+ minors.Should().Contain(position => position < -5.0);
+ minors.Should().Contain(position => position > -2.0);
+ }
+
+ [Fact]
+ public void Ticks_SpanEndingOnADecade_KeepsTheDecadeAsTheTopMajor()
+ {
+ var maxLog = Math.Log10(0.1);
+
+ var ticks = LogAxis.Ticks(Math.Log10(3e-3), maxLog, 400.0);
+
+ var top = ticks.Last(tick => tick.IsMajor);
+ top.Position.Should().BeApproximately(maxLog, PositionTolerance);
+ ticks[^1].Should().Be(top);
+ }
+
+ [Theory]
+ [InlineData(1e-6, 1e-1, 400.0)]
+ [InlineData(1e-12, 1.0, 200.0)]
+ [InlineData(2.0, 50.0, 400.0)]
+ [InlineData(0.2, 5.0, 400.0)]
+ [InlineData(1.5, 14.0, 400.0)]
+ [InlineData(0.9, 1.4, 400.0)]
+ [InlineData(1.05, 1.2, 400.0)]
+ [InlineData(1.05, 1.19, 400.0)]
+ [InlineData(1.5e-6, 5e-2, 400.0)]
+ [InlineData(3e-3, 1e-1, 400.0)]
+ public void Ticks_AnyRange_StayInsideItAscendingWithReadableMajors(double bottom, double top, double pixelLength)
+ {
+ var minLog = Math.Log10(bottom);
+ var maxLog = Math.Log10(top);
+ var pixelsPerDecade = pixelLength / (maxLog - minLog);
+
+ var ticks = LogAxis.Ticks(minLog, maxLog, pixelLength);
+
+ ticks.Select(tick => tick.Position).Should().AllSatisfy(
+ position => position.Should().BeInRange(minLog, maxLog));
+ ticks.Select(tick => tick.Position).Should().BeInAscendingOrder();
+ ticks.Select(tick => tick.Position).Should().OnlyHaveUniqueItems();
+
+ var majors = Positions(ticks, isMajor: true);
+ majors.Should().HaveCountGreaterThanOrEqualTo(2);
+ majors.Zip(majors.Skip(1), (lower, upper) => (upper - lower) * pixelsPerDecade)
+ .Should().AllSatisfy(gap => gap.Should().BeGreaterThanOrEqualTo(MinimumMajorGap - GapTolerance));
+ }
+
+ [Theory]
+ [InlineData(1.0, 1.0000000000000007, 400.0)]
+ [InlineData(2.5, 2.5000000000000044, 300.0)]
+ [InlineData(2.5, 2.5000000000000044, 400.0)]
+ [InlineData(2.5, 2.5000000000000044, 900.0)]
+ public async Task Ticks_SpanOfFloatNoise_ReturnsTicksInsideTheRange(double bottom, double top, double pixelLength)
+ {
+ var minLog = Math.Log10(bottom);
+ var maxLog = Math.Log10(top);
+
+ var ticks = await TicksWithinDeadline(minLog, maxLog, pixelLength);
+
+ ticks.Select(tick => tick.Position).Should().AllSatisfy(
+ position => position.Should().BeInRange(minLog, maxLog));
+ }
+
+ [Fact]
+ public async Task Ticks_DecadesBeyondExactIntegers_ReturnMajorsWithNoMinors()
+ {
+ var ticks = await TicksWithinDeadline(-1e20, 1e20, 400.0);
+
+ ticks.Should().NotBeEmpty();
+ ticks.Should().OnlyContain(tick => tick.IsMajor);
+ }
+
+ [Theory]
+ [InlineData(-6.0, double.PositiveInfinity, 400.0)]
+ [InlineData(double.NegativeInfinity, -1.0, 400.0)]
+ [InlineData(double.NaN, -1.0, 400.0)]
+ [InlineData(-3.0, -3.0, 400.0)]
+ [InlineData(-1.0, -6.0, 400.0)]
+ [InlineData(-6.0, -1.0, 0.0)]
+ [InlineData(-6.0, -1.0, -400.0)]
+ [InlineData(-6.0, -1.0, double.NaN)]
+ public void Ticks_DegenerateInput_GivesNoTicks(double minLog, double maxLog, double pixelLength)
+ {
+ LogAxis.Ticks(minLog, maxLog, pixelLength).Should().BeEmpty();
+ }
+
+ private static async Task> TicksWithinDeadline(
+ double minLog,
+ double maxLog,
+ double pixelLength)
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+
+ return await Task.Run(() => LogAxis.Ticks(minLog, maxLog, pixelLength), cancellationToken)
+ .WaitAsync(_ticksDeadline, cancellationToken);
+ }
+
+ private static List Positions(IReadOnlyList ticks, bool isMajor)
+ {
+ return [.. ticks.Where(tick => tick.IsMajor == isMajor).Select(tick => tick.Position)];
+ }
+
+ private static List Values(IReadOnlyList ticks, bool isMajor)
+ {
+ return [.. Positions(ticks, isMajor).Select(position => Math.Pow(10.0, position))];
+ }
+
+ private static bool SamePosition(double actual, double expected)
+ {
+ return Math.Abs(actual - expected) < PositionTolerance;
+ }
+
+ private static bool SameValue(double actual, double expected)
+ {
+ return Math.Abs(actual - expected) < expected * RelativeValueTolerance;
+ }
+}
diff --git a/SemiPlot/SemiPlot.Tests.Unit/Core/Trends/PenListDeltaTests.cs b/SemiPlot/SemiPlot.Tests.Unit/Core/Trends/PenListDeltaTests.cs
index be875c4..8d125f2 100644
--- a/SemiPlot/SemiPlot.Tests.Unit/Core/Trends/PenListDeltaTests.cs
+++ b/SemiPlot/SemiPlot.Tests.Unit/Core/Trends/PenListDeltaTests.cs
@@ -38,6 +38,7 @@ public sealed class PenListDeltaTests
nameof(Pen.EnabledOnStart),
nameof(Pen.ScaleMinOnStart),
nameof(Pen.ScaleMaxOnStart),
+ nameof(Pen.LogScaleOnStart),
nameof(Pen.LineStyle)
];
@@ -136,6 +137,17 @@ public void AnEnabledOnStartChange_GivesARevision()
delta.Revised.Should().ContainSingle().Which.EnabledOnStart.Should().BeFalse();
}
+ [Fact]
+ public void ALogScaleOnStartChangeAlone_GivesARevision()
+ {
+ var logarithmic = _pressure with { LogScaleOnStart = true };
+
+ var delta = PenListDelta.Between([_pressure], [logarithmic]);
+
+ delta.Revised.Should().ContainSingle().Which.LogScaleOnStart.Should().BeTrue();
+ delta.ChangesPenSet.Should().BeFalse();
+ }
+
[Fact]
public void ANewPen_IsAddedAndChangesThePenSet()
{
@@ -198,6 +210,7 @@ private static Pen WithOneMemberChanged(Pen pen, string member)
nameof(Pen.EnabledOnStart) => pen with { EnabledOnStart = !pen.EnabledOnStart },
nameof(Pen.ScaleMinOnStart) => pen with { ScaleMinOnStart = -5.0 },
nameof(Pen.ScaleMaxOnStart) => pen with { ScaleMaxOnStart = 500.0 },
+ nameof(Pen.LogScaleOnStart) => pen with { LogScaleOnStart = !pen.LogScaleOnStart },
nameof(Pen.LineStyle) => pen with { LineStyle = PenLineStyle.Stepped },
_ => throw new ArgumentOutOfRangeException(nameof(member), member, "No change is defined for it.")
};
diff --git a/SemiPlot/SemiPlot.Tests.Unit/Core/Trends/PenScaleModelTests.cs b/SemiPlot/SemiPlot.Tests.Unit/Core/Trends/PenScaleModelTests.cs
index c38a778..2ca5e0b 100644
--- a/SemiPlot/SemiPlot.Tests.Unit/Core/Trends/PenScaleModelTests.cs
+++ b/SemiPlot/SemiPlot.Tests.Unit/Core/Trends/PenScaleModelTests.cs
@@ -155,9 +155,59 @@ public void Compute_LogarithmicAxis_DropsNonPositiveValuesBeforeComputingRange()
var scales = model.Compute(settings, envelopes, activePenId: 1, _origin, _origin.AddHours(3));
var logScale = scales.Should().ContainSingle().Which;
+ var paddingDecades = (Math.Log10(50.0) - Math.Log10(2.0)) * 0.05;
logScale.IsLogarithmic.Should().BeTrue();
- logScale.Min.Should().BeGreaterThan(0.0, "a log axis lower bound is clamped positive after padding");
- logScale.Max.Should().BeApproximately(50.0 + (48.0 * 0.05), 1e-9);
+ logScale.Min.Should().BeApproximately(2.0 / Math.Pow(10.0, paddingDecades), 1e-9);
+ logScale.Max.Should().BeApproximately(50.0 * Math.Pow(10.0, paddingDecades), 1e-9);
+ }
+
+ [Fact]
+ public void Compute_LogarithmicAxisOverDecades_PadsBeyondBothEnds()
+ {
+ var model = new PenScaleModel();
+ var settings = new[] { new PenScaleSettings(PenId: 1, IsLogarithmic: true) };
+ var envelopes = new Dictionary { [1] = Envelope(1, (1e-6, 1e-4), (1e-3, 1e-2)) };
+
+ var scales = model.Compute(settings, envelopes, activePenId: 1, _origin, _origin.AddHours(2));
+
+ var logScale = scales.Should().ContainSingle().Which;
+ logScale.Min.Should().BeGreaterThan(0.0).And.BeLessThan(1e-6);
+ logScale.Max.Should().BeGreaterThan(1e-2);
+ }
+
+ [Fact]
+ public void Compute_FlatLogarithmicLine_PadsHalfADecadeOnEachSide()
+ {
+ var model = new PenScaleModel();
+ var settings = new[] { new PenScaleSettings(PenId: 1, IsLogarithmic: true) };
+ var envelopes = new Dictionary { [1] = Envelope(1, (0.3, 0.3)) };
+
+ var scales = model.Compute(settings, envelopes, activePenId: 1, _origin, _origin.AddHours(1));
+
+ var flat = scales.Should().ContainSingle().Which;
+ flat.Min.Should().BeLessThan(0.3);
+ flat.Max.Should().BeGreaterThan(0.3);
+ flat.Min.Should().BeApproximately(0.3 / Math.Sqrt(10.0), 1e-12);
+ flat.Max.Should().BeApproximately(0.3 * Math.Sqrt(10.0), 1e-12);
+ }
+
+ [Theory]
+ [InlineData(1.0, double.PositiveInfinity)]
+ [InlineData(double.Epsilon, 1.0)]
+ [InlineData(double.Epsilon, double.MaxValue)]
+ [InlineData(double.MaxValue, double.PositiveInfinity)]
+ public void Compute_LogarithmicAxisAtTheEdgesOfTheDoubleRange_KeepsFiniteDecadeLimits(double low, double high)
+ {
+ var model = new PenScaleModel();
+ var settings = new[] { new PenScaleSettings(PenId: 1, IsLogarithmic: true) };
+ var envelopes = new Dictionary { [1] = Envelope(1, (low, low), (high, high)) };
+
+ var scales = model.Compute(settings, envelopes, activePenId: 1, _origin, _origin.AddHours(2));
+
+ var logScale = scales.Should().ContainSingle().Which;
+ double.IsFinite(Math.Log10(logScale.Min)).Should().BeTrue();
+ double.IsFinite(Math.Log10(logScale.Max)).Should().BeTrue();
+ logScale.Min.Should().BeLessThan(logScale.Max);
}
[Fact]
@@ -246,6 +296,36 @@ public void Compute_ManualLogarithmic_SanitizesNonPositiveLowerBound()
manual.Max.Should().Be(100.0);
}
+ [Theory]
+ [InlineData(1.0, double.PositiveInfinity, true, 1.0, 10.0)]
+ [InlineData(1.0, double.NaN, true, 1.0, 10.0)]
+ [InlineData(double.NegativeInfinity, 100.0, true, 1.0, 10.0)]
+ [InlineData(1.0, double.PositiveInfinity, false, 0.0, 1.0)]
+ [InlineData(double.NaN, 100.0, false, 0.0, 1.0)]
+ public void Compute_ManualModeWithANonFiniteBound_FallsBackToTheDefaultRange(
+ double manualMin,
+ double manualMax,
+ bool isLogarithmic,
+ double defaultMin,
+ double defaultMax)
+ {
+ var model = new PenScaleModel();
+ var settings = new[]
+ {
+ new PenScaleSettings(PenId: 1, Mode: ScaleMode.Manual, ManualMin: manualMin, ManualMax: manualMax)
+ {
+ IsLogarithmic = isLogarithmic
+ }
+ };
+ var envelopes = new Dictionary { [1] = Envelope(1, (2.0, 50.0)) };
+
+ var scales = model.Compute(settings, envelopes, activePenId: 1, _origin, _origin.AddHours(1));
+
+ var manual = scales.Should().ContainSingle().Which;
+ manual.Min.Should().Be(defaultMin);
+ manual.Max.Should().Be(defaultMax);
+ }
+
private static PenHistoryEnvelope Envelope(int penId, params (double Min, double Max)[] columns)
{
var timestamps = new DateTime[columns.Length];
diff --git a/SemiPlot/SemiPlot.Tests.Unit/RawLayerGeneratorTests.cs b/SemiPlot/SemiPlot.Tests.Unit/RawLayerGeneratorTests.cs
index 91122cb..8d11705 100644
--- a/SemiPlot/SemiPlot.Tests.Unit/RawLayerGeneratorTests.cs
+++ b/SemiPlot/SemiPlot.Tests.Unit/RawLayerGeneratorTests.cs
@@ -202,7 +202,7 @@ public void PensAreTakenRoundRobinAcrossTheGroupsRatherThanFirstN()
}
[Fact]
- public void TheStandardSliceCarriesTheThreeCommissioningStates()
+ public void TheStandardSliceCarriesTheFourCommissioningStates()
{
var pens = RawLayerGenerator.SelectPens(SeederOptions.DefaultPenCount);
@@ -211,6 +211,13 @@ public void TheStandardSliceCarriesTheThreeCommissioningStates()
pens.Should().ContainSingle(pen => pen.PenId == SyntheticPenCatalog.HiddenOnStartPenId)
.Which.EnabledOnStart.Should().BeFalse();
+ var logScale = pens.Should().ContainSingle(pen => pen.LogScaleOnStart).Which;
+
+ logScale.PenId.Should().Be(SyntheticPenCatalog.LogScalePenId);
+ logScale.Format.Should().Be("0.0E+0");
+ logScale.ScaleMinOnStart.Should().Be(1e-6);
+ logScale.ScaleMaxOnStart.Should().Be(1e-1);
+
var uncommissioned = pens.Should()
.ContainSingle(pen => pen.PenId == SyntheticPenCatalog.UncommissionedPenId).Which;
@@ -221,6 +228,22 @@ public void TheStandardSliceCarriesTheThreeCommissioningStates()
uncommissioned.ScaleMaxOnStart.Should().BeNull();
}
+ [Fact]
+ public void TheLogPenCoversAtLeastFourDecadesOfItsRangeOverADay()
+ {
+ var options = BenchOptions.For(days: 1);
+ var pen = RawLayerGenerator.SelectPens(options.PenCount)
+ .Single(candidate => candidate.PenId == SyntheticPenCatalog.LogScalePenId);
+
+ var values = RawLayerGenerator.Generate(options)
+ .Where(row => row.Id == pen.PenId)
+ .Select(row => row.Value)
+ .ToArray();
+
+ values.Should().AllSatisfy(value => value.Should().BeInRange(pen.MinValue, pen.MaxValue));
+ Math.Log10(values.Max() / values.Min()).Should().BeGreaterThanOrEqualTo(4.0);
+ }
+
[Fact]
public void TheStandardSliceGivesEveryPenItsOwnColour()
{
diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/AxisScalePanelViewModelTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/AxisScalePanelViewModelTests.cs
index 4f5f20a..cce9e86 100644
--- a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/AxisScalePanelViewModelTests.cs
+++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/AxisScalePanelViewModelTests.cs
@@ -1,10 +1,17 @@
+using System.ComponentModel;
+using System.Globalization;
+
using Avalonia.Headless.XUnit;
using AwesomeAssertions;
+using Microsoft.Reactive.Testing;
+
using SemiPlot.Core.Trends;
+using SemiPlot.Tests.Unit.UI.Bridge;
using SemiPlot.UI.Chart;
using SemiPlot.UI.Localization;
+using SemiPlot.UI.Messages;
using SemiPlot.UI.PenEditor;
using Xunit;
@@ -40,7 +47,8 @@ public void Seed_FillsTheFieldsAndTheHeaderFromTheActivePen()
chart.AxisScale.PenUnit.Should().Be("sccm");
chart.AxisScale.MaximumText.Should().Be(PenFormRules.FormatBound(90.5));
chart.AxisScale.MinimumText.Should().Be(PenFormRules.FormatBound(10.0));
- chart.AxisScale.IsValid.Should().BeTrue();
+ chart.AxisScale.IsMinimumValid.Should().BeTrue();
+ chart.AxisScale.IsMaximumValid.Should().BeTrue();
chart.AxisScale.ValidationMessage.Should().BeEmpty();
}
@@ -75,7 +83,7 @@ public void Seed_WhenTheActivePenIsHidden_OpensNothing()
}
[AvaloniaFact]
- public void ASmallMagnitudeScale_IsShownAndWrittenBackWithoutLoss()
+ public void ASmallMagnitudeScale_IsShownAndKeptWithoutLoss()
{
var (chart, _, _, provider) = CreateViewModel();
chart.ApplyCatalogue(provider.Pens);
@@ -86,7 +94,7 @@ public void ASmallMagnitudeScale_IsShownAndWrittenBackWithoutLoss()
chart.AxisScale.MinimumText.Should().Be(PenValueFormat.Format(1e-7, null));
chart.AxisScale.MaximumText.Should().Be(PenValueFormat.Format(5e-6, null));
- chart.AxisScale.ApplyCommand.Execute().Subscribe();
+ chart.AxisScale.CommitBounds();
chart.ScaleRangeForPen(1)!.Value.Should().Be((1e-7, 5e-6));
}
@@ -120,7 +128,7 @@ public void Seed_WithoutAMask_FallsBackToTheDefaultMask()
}
[AvaloniaFact]
- public void Apply_WithBothFieldsUntouched_KeepsTheExactBoundsAMaskRounded()
+ public void CommitBounds_WithBothFieldsUntouched_KeepsTheExactBoundsAMaskRounded()
{
using var culture = new CultureScope("en-US");
var (chart, _, _, provider) = CreateViewModel();
@@ -131,13 +139,42 @@ public void Apply_WithBothFieldsUntouched_KeepsTheExactBoundsAMaskRounded()
chart.AxisScale.MinimumText.Should().Be("0");
chart.AxisScale.MaximumText.Should().Be("10");
- chart.AxisScale.ApplyCommand.Execute().Subscribe();
+ chart.AxisScale.CommitBounds();
chart.ScaleRangeForPen(1)!.Value.Should().Be((0.4, 9.6));
}
[AvaloniaFact]
- public void Apply_WithOneFieldEdited_WritesTheTypedValueAndKeepsTheOtherExact()
+ public void CommitBounds_OnAnAutoscaledPenWithUntouchedFields_WritesNothing()
+ {
+ var (chart, _, _, provider) = CreateViewModel();
+ chart.ApplyCatalogue(provider.Pens);
+ chart.AxisScale.Seed();
+
+ chart.AxisScale.CommitBounds();
+
+ chart.ScaleSettings[1].Should().Be(new PenScaleSettings(1));
+ }
+
+ [AvaloniaFact]
+ public void CommitBounds_WithTheSeededValuesRetypedInAnotherNotation_WritesNothing()
+ {
+ using var culture = new CultureScope("en-US");
+ var (chart, _, _, provider) = CreateViewModel();
+ chart.ApplyCatalogue(provider.Pens);
+ chart.AxisScale.Seed();
+ var (minimum, maximum) = chart.ScaleRangeForPen(1)!.Value;
+
+ chart.AxisScale.MinimumText = minimum.ToString("E16", CultureInfo.CurrentCulture);
+ chart.AxisScale.MaximumText = maximum.ToString("E16", CultureInfo.CurrentCulture);
+ chart.AxisScale.MaximumText.Should().NotBe(PenValueFormat.Format(maximum, null));
+ chart.AxisScale.CommitBounds();
+
+ chart.ScaleSettings[1].Mode.Should().Be(ScaleMode.Auto, "the typed pair is the pair the panel was seeded with");
+ }
+
+ [AvaloniaFact]
+ public void CommitBounds_WithOneFieldEdited_WritesTheTypedValueAndKeepsTheOtherExact()
{
using var culture = new CultureScope("en-US");
var (chart, _, _, provider) = CreateViewModel();
@@ -146,14 +183,15 @@ public void Apply_WithOneFieldEdited_WritesTheTypedValueAndKeepsTheOtherExact()
chart.AxisScale.Seed();
chart.AxisScale.MaximumText = "20";
- chart.AxisScale.ApplyCommand.Execute().Subscribe();
+ chart.AxisScale.CommitBounds();
chart.ScaleRangeForPen(1)!.Value.Should().Be((0.4, 20.0));
}
[AvaloniaFact]
- public void Apply_WritesBothBoundsAsAManualScaleOnThePenThePanelWasOpenedFor()
+ public void CommitBounds_WritesAChangedPairOnThePenThePanelWasOpenedFor_AndReseedsWithThePanelOpen()
{
+ using var culture = new CultureScope("en-US");
var (chart, _, _, provider) = CreateViewModel();
chart.ApplyCatalogue(provider.Pens);
chart.SetActivePen(2);
@@ -161,9 +199,9 @@ public void Apply_WritesBothBoundsAsAManualScaleOnThePenThePanelWasOpenedFor()
var closes = 0;
using var subscription = chart.AxisScale.CloseRequests.Subscribe(_ => closes++);
- chart.AxisScale.MaximumText = PenFormRules.FormatBound(120.0);
- chart.AxisScale.MinimumText = PenFormRules.FormatBound(-20.0);
- chart.AxisScale.ApplyCommand.Execute().Subscribe();
+ chart.AxisScale.MaximumText = "120.0";
+ chart.AxisScale.MinimumText = "-20.00";
+ chart.AxisScale.CommitBounds();
chart.ScaleSettings[2].Should().Be(new PenScaleSettings(2)
{
@@ -172,7 +210,23 @@ public void Apply_WritesBothBoundsAsAManualScaleOnThePenThePanelWasOpenedFor()
ManualMax = 120.0
});
chart.ScaleSettings[1].Mode.Should().Be(ScaleMode.Auto, "the other pen is not touched");
- closes.Should().Be(1);
+ chart.AxisScale.MaximumText.Should().Be("120", "the write re-seeds the fields");
+ chart.AxisScale.MinimumText.Should().Be("-20");
+ closes.Should().Be(0);
+ }
+
+ [AvaloniaFact]
+ public void ReportFailure_ReachesTheChartsMessagePanel()
+ {
+ var scheduler = new TestScheduler();
+ var provider = new FakeDataProvider(scheduler, TimeSpan.FromHours(1));
+ using var messagePanel = new MessagePanelViewModel();
+ using var chart = CreateChart(scheduler, CreateCoordinator(scheduler, provider), messagePanel);
+
+ chart.AxisScale.ReportFailure(new InvalidOperationException("the focus handler threw"));
+
+ messagePanel.Entries.Should().ContainSingle()
+ .Which.View.Detail.Should().Contain("the focus handler threw");
}
[AvaloniaFact]
@@ -190,9 +244,8 @@ public void AnInvertedPair_IsRefusedOnTheMessageLine_AndNothingIsWritten()
chart.AxisScale.ValidationMessage.Should().Be(Resources.AxisScaleMinimumBelowMaximum);
chart.AxisScale.IsMinimumValid.Should().BeFalse();
chart.AxisScale.IsMaximumValid.Should().BeFalse();
- CanApply(chart.AxisScale).Should().BeFalse();
- chart.AxisScale.ApplyCommand.Execute().Subscribe();
+ chart.AxisScale.CommitBounds();
chart.ScaleRangeForPen(1)!.Value.Should().Be((10.0, 90.0));
closes.Should().Be(0);
@@ -209,7 +262,10 @@ public void AnEqualPair_IsRefused()
chart.AxisScale.MinimumText = "90";
chart.AxisScale.ValidationMessage.Should().Be(Resources.AxisScaleMinimumBelowMaximum);
- CanApply(chart.AxisScale).Should().BeFalse();
+
+ chart.AxisScale.CommitBounds();
+
+ chart.ScaleRangeForPen(1)!.Value.Should().Be((10.0, 90.0));
}
[AvaloniaTheory]
@@ -236,9 +292,8 @@ public void AnEmptyField_IsRefusedAndMarkedInvalid(bool emptyMaximum)
chart.AxisScale.ValidationMessage.Should().Be(Resources.AxisScaleBoundsRequired);
chart.AxisScale.IsMaximumValid.Should().Be(!emptyMaximum);
chart.AxisScale.IsMinimumValid.Should().Be(emptyMaximum);
- CanApply(chart.AxisScale).Should().BeFalse();
- chart.AxisScale.ApplyCommand.Execute().Subscribe();
+ chart.AxisScale.CommitBounds();
chart.ScaleRangeForPen(1)!.Value.Should().Be((10.0, 90.0));
closes.Should().Be(0);
@@ -256,15 +311,12 @@ public void ABoundThatAMaskShowsAsBlank_StaysValidWithNoMessageWhileAsSeeded()
chart.AxisScale.MinimumText.Should().Be(" ");
chart.AxisScale.ValidationMessage.Should().BeEmpty();
- chart.AxisScale.IsValid.Should().BeTrue();
chart.AxisScale.IsMinimumValid.Should().BeTrue();
- CanApply(chart.AxisScale).Should().BeTrue();
chart.AxisScale.MinimumText = string.Empty;
chart.AxisScale.ValidationMessage.Should().Be(Resources.AxisScaleBoundsRequired);
chart.AxisScale.IsMinimumValid.Should().BeFalse();
- CanApply(chart.AxisScale).Should().BeFalse();
}
[AvaloniaTheory]
@@ -288,9 +340,8 @@ public void AnUnreadableField_IsRefusedAndNothingIsWritten(string cultureName, s
chart.AxisScale.ValidationMessage.Should().Be(Resources.AxisScaleBoundInvalid);
chart.AxisScale.IsMaximumValid.Should().BeFalse();
chart.AxisScale.IsMinimumValid.Should().BeTrue();
- CanApply(chart.AxisScale).Should().BeFalse();
- chart.AxisScale.ApplyCommand.Execute().Subscribe();
+ chart.AxisScale.CommitBounds();
chart.ScaleRangeForPen(1)!.Value.Should().Be((10.0, 90.0));
closes.Should().Be(0);
@@ -306,13 +357,13 @@ public void AFractionInTheCurrentCulturesSeparator_IsWritten()
chart.AxisScale.MinimumText = "1,5";
chart.AxisScale.MaximumText = "150,5";
- chart.AxisScale.ApplyCommand.Execute().Subscribe();
+ chart.AxisScale.CommitBounds();
chart.ScaleRangeForPen(1)!.Value.Should().Be((1.5, 150.5));
}
[AvaloniaFact]
- public void Cancel_WritesNothingAndAsksToClose()
+ public void Cancel_WritesNothingAndAsksToClose_AndTheCommitOnDetachWritesNothing()
{
var (chart, _, _, provider) = CreateViewModel();
chart.ApplyCatalogue(provider.Pens);
@@ -323,6 +374,7 @@ public void Cancel_WritesNothingAndAsksToClose()
using var subscription = chart.AxisScale.CloseRequests.Subscribe(_ => closes++);
chart.AxisScale.CancelCommand.Execute().Subscribe();
+ chart.AxisScale.CommitBounds();
chart.ScaleRangeForPen(1)!.Value.Should().Be((10.0, 90.0));
closes.Should().Be(1);
@@ -404,7 +456,7 @@ public void WhenThePenIsNoLongerDrawn_ThePanelClosesAndEveryCommandWritesNothing
closes.Should().Be(1, "the pen the panel was opened for is no longer the one whose axis is drawn");
chart.AxisScale.MaximumText = "500";
- chart.AxisScale.ApplyCommand.Execute().Subscribe();
+ chart.AxisScale.CommitBounds();
chart.AxisScale.AutoscaleCommand.Execute().Subscribe();
chart.AxisScale.InitialScaleCommand.Execute().Subscribe();
@@ -418,7 +470,7 @@ public void WhenThePenIsNoLongerDrawn_ThePanelClosesAndEveryCommandWritesNothing
}
[AvaloniaFact]
- public void Apply_LeavesTheNavigationWindowAndDeltaCursorsUntouched()
+ public void CommitBounds_LeavesTheNavigationWindowAndDeltaCursorsUntouched()
{
var (chart, _, _, provider) = CreateViewModel();
chart.ApplyCatalogue(provider.Pens);
@@ -428,7 +480,7 @@ public void Apply_LeavesTheNavigationWindowAndDeltaCursorsUntouched()
chart.AxisScale.MaximumText = "42";
chart.AxisScale.MinimumText = "1";
- chart.AxisScale.ApplyCommand.Execute().Subscribe();
+ chart.AxisScale.CommitBounds();
chart.Navigation.From.Should().Be(fromBefore);
chart.Navigation.To.Should().Be(toBefore);
@@ -438,7 +490,7 @@ public void Apply_LeavesTheNavigationWindowAndDeltaCursorsUntouched()
}
[AvaloniaFact]
- public void Apply_PutsBothBoundsOnTheRenderedAxis()
+ public void CommitBounds_PutsBothBoundsOnTheRenderedAxis()
{
var (chart, _, _, provider) = CreateViewModel();
chart.ApplyCatalogue(provider.Pens);
@@ -446,18 +498,326 @@ public void Apply_PutsBothBoundsOnTheRenderedAxis()
chart.AxisScale.MaximumText = "120";
chart.AxisScale.MinimumText = "-20";
- chart.AxisScale.ApplyCommand.Execute().Subscribe();
+ chart.AxisScale.CommitBounds();
chart.Plot.RenderInMemory(600, 400);
chart.ActivePenAxis!.Range.Min.Should().Be(-20.0);
chart.ActivePenAxis.Range.Max.Should().Be(120.0);
}
- private static bool CanApply(AxisScalePanelViewModel panel)
+ [AvaloniaFact]
+ public void Seed_ReadsTheAxisTypeFromTheChartsSettings()
+ {
+ var (chart, _, _, provider) = CreateViewModel();
+ chart.ApplyCatalogue([.. provider.Pens.Select(pen => pen with { LogScaleOnStart = pen.PenId == 2 })]);
+
+ chart.AxisScale.Seed();
+ chart.AxisScale.IsLogarithmic.Should().BeFalse();
+
+ chart.SetActivePen(2);
+ chart.AxisScale.Seed();
+ chart.AxisScale.IsLogarithmic.Should().BeTrue();
+ }
+
+ [AvaloniaFact]
+ public void ToggleLogarithmic_WritesThePendingPairFlipsTheFlagAndReseedsWithoutClosing()
+ {
+ using var culture = new CultureScope("en-US");
+ var (chart, _, _, provider) = CreateViewModel();
+ chart.ApplyCatalogue(provider.Pens);
+ chart.SetAxisLimits(1, 0.5, 200.0);
+ chart.AxisScale.Seed();
+ chart.AxisScale.MaximumText = "500.0";
+ var closes = 0;
+ using var subscription = chart.AxisScale.CloseRequests.Subscribe(_ => closes++);
+
+ chart.AxisScale.ToggleLogarithmicCommand.Execute().Subscribe();
+
+ chart.ScaleSettings[1].Should().Be(new PenScaleSettings(1)
+ {
+ Mode = ScaleMode.Manual,
+ IsLogarithmic = true,
+ ManualMin = 0.5,
+ ManualMax = 500.0
+ });
+ chart.AxisScale.IsLogarithmic.Should().BeTrue();
+ chart.AxisScale.MaximumText.Should().Be("500", "the switch re-seeds the bounds");
+ chart.AxisScale.ValidationMessage.Should().BeEmpty();
+ closes.Should().Be(0);
+
+ chart.AxisScale.ToggleLogarithmicCommand.Execute().Subscribe();
+
+ chart.ScaleSettings[1].IsLogarithmic.Should().BeFalse();
+ chart.AxisScale.IsLogarithmic.Should().BeFalse();
+ closes.Should().Be(0);
+ }
+
+ [AvaloniaFact]
+ public void ToggleLogarithmic_OverAManualMinimumOfZero_IsRefusedAndNamesTheRule()
+ {
+ var (chart, _, _, provider) = CreateViewModel();
+ chart.ApplyCatalogue(provider.Pens);
+ chart.SetAxisLimits(1, 0.0, 100.0);
+ chart.AxisScale.Seed();
+ var closes = 0;
+ using var subscription = chart.AxisScale.CloseRequests.Subscribe(_ => closes++);
+
+ chart.AxisScale.ToggleLogarithmicCommand.Execute().Subscribe();
+
+ chart.ScaleSettings[1].Should().Be(new PenScaleSettings(1)
+ {
+ Mode = ScaleMode.Manual,
+ ManualMin = 0.0,
+ ManualMax = 100.0
+ });
+ chart.AxisScale.IsLogarithmic.Should().BeFalse();
+ chart.AxisScale.ValidationMessage.Should().Be(Resources.ScaleLogMinimumPositive);
+ chart.AxisScale.IsMinimumValid.Should().BeTrue("zero is a valid minimum on the linear axis it stays on");
+ closes.Should().Be(0);
+
+ chart.AxisScale.Seed();
+
+ chart.AxisScale.ValidationMessage.Should().BeEmpty("a new seed clears the refusal");
+ }
+
+ [AvaloniaFact]
+ public void ARefusedTick_DropsItsMessageOnceTheMinimumIsRetyped()
+ {
+ var (chart, _, _, provider) = CreateViewModel();
+ chart.ApplyCatalogue(provider.Pens);
+ chart.SetAxisLimits(1, 0.0, 100.0);
+ chart.AxisScale.Seed();
+ chart.AxisScale.ToggleLogarithmicCommand.Execute().Subscribe();
+ chart.AxisScale.ValidationMessage.Should().Be(Resources.ScaleLogMinimumPositive);
+
+ chart.AxisScale.MinimumText = PenFormRules.FormatBound(5.0);
+
+ chart.AxisScale.ValidationMessage.Should().BeEmpty("the typed minimum is what the operator applies next");
+ chart.AxisScale.IsMinimumValid.Should().BeTrue();
+ }
+
+ [AvaloniaFact]
+ public void TypedOne_ThenToggleOn_WritesTheMinimumAndSwitchesTheAxisOn()
+ {
+ var (chart, _, _, provider) = CreateViewModel();
+ chart.ApplyCatalogue(provider.Pens);
+ chart.SetAxisLimits(1, 0.0, 100.0);
+ chart.AxisScale.Seed();
+ var closes = 0;
+ using var subscription = chart.AxisScale.CloseRequests.Subscribe(_ => closes++);
+
+ chart.AxisScale.MinimumText = "1";
+ chart.AxisScale.ToggleLogarithmicCommand.Execute().Subscribe();
+
+ chart.ScaleSettings[1].Should().Be(new PenScaleSettings(1)
+ {
+ Mode = ScaleMode.Manual,
+ IsLogarithmic = true,
+ ManualMin = 1.0,
+ ManualMax = 100.0
+ });
+ chart.AxisScale.IsLogarithmic.Should().BeTrue();
+ chart.AxisScale.ValidationMessage.Should().BeEmpty();
+ closes.Should().Be(0);
+ }
+
+ [AvaloniaFact]
+ public void TypedZero_ThenToggleOff_SwitchesTheAxisOffAndWritesZeroUnderTheLinearRule()
+ {
+ var (chart, _, _, provider) = CreateViewModel();
+ chart.ApplyCatalogue(provider.Pens);
+ chart.SetAxisLimits(1, 1.0, 100.0);
+ chart.SetLogarithmic(1, true);
+ chart.AxisScale.Seed();
+
+ chart.AxisScale.MinimumText = "0";
+ chart.AxisScale.ValidationMessage.Should().Be(Resources.ScaleLogMinimumPositive);
+ chart.AxisScale.ToggleLogarithmicCommand.Execute().Subscribe();
+
+ chart.ScaleSettings[1].Should().Be(new PenScaleSettings(1)
+ {
+ Mode = ScaleMode.Manual,
+ ManualMin = 0.0,
+ ManualMax = 100.0
+ });
+ chart.AxisScale.IsLogarithmic.Should().BeFalse();
+ chart.AxisScale.MinimumText.Should().Be("0");
+ chart.AxisScale.ValidationMessage.Should().BeEmpty();
+ }
+
+ [AvaloniaTheory]
+ [InlineData("0")]
+ [InlineData("-5")]
+ public void ATypedMinimumTheLogRuleRefuses_ThenToggleOn_IsWrittenOnTheLinearAxisAndTheRuleIsNamed(string typed)
+ {
+ using var culture = new CultureScope("en-US");
+ var (chart, _, _, provider) = CreateViewModel();
+ chart.ApplyCatalogue(provider.Pens);
+ chart.SetAxisLimits(1, 5.0, 100.0);
+ chart.AxisScale.Seed();
+
+ chart.AxisScale.MinimumText = typed;
+ chart.AxisScale.ToggleLogarithmicCommand.Execute().Subscribe();
+
+ chart.ScaleSettings[1].Should().Be(new PenScaleSettings(1)
+ {
+ Mode = ScaleMode.Manual,
+ ManualMin = double.Parse(typed, CultureInfo.InvariantCulture),
+ ManualMax = 100.0
+ });
+ chart.AxisScale.IsLogarithmic.Should().BeFalse();
+ chart.AxisScale.MinimumText.Should().Be(typed);
+ chart.AxisScale.ValidationMessage.Should().Be(Resources.ScaleLogMinimumPositive);
+ }
+
+ [AvaloniaTheory]
+ [InlineData("abc", false)]
+ [InlineData("", false)]
+ [InlineData("200", false)]
+ [InlineData("abc", true)]
+ [InlineData("200", true)]
+ public void APairThatDoesNotRead_RefusesTheToggle_AndKeepsTheText(string typed, bool startsLogarithmic)
{
- var canExecute = false;
- using var subscription = panel.ApplyCommand.CanExecute.Subscribe(value => canExecute = value);
+ var (chart, _, _, provider) = CreateViewModel();
+ chart.ApplyCatalogue(provider.Pens);
+ chart.SetAxisLimits(1, 1.0, 100.0);
+ chart.SetLogarithmic(1, startsLogarithmic);
+ chart.AxisScale.Seed();
+ chart.AxisScale.MinimumText = typed;
+ var raised = new List();
+ chart.AxisScale.PropertyChanged += Record;
- return canExecute;
+ chart.AxisScale.ToggleLogarithmicCommand.Execute().Subscribe();
+
+ chart.AxisScale.PropertyChanged -= Record;
+ chart.ScaleSettings[1].Should().Be(new PenScaleSettings(1)
+ {
+ Mode = ScaleMode.Manual,
+ IsLogarithmic = startsLogarithmic,
+ ManualMin = 1.0,
+ ManualMax = 100.0
+ });
+ chart.AxisScale.IsLogarithmic.Should().Be(startsLogarithmic);
+ chart.AxisScale.MinimumText.Should().Be(typed);
+ chart.AxisScale.ValidationMessage.Should().Be(typed switch
+ {
+ "" => Resources.AxisScaleBoundsRequired,
+ "200" => Resources.AxisScaleMinimumBelowMaximum,
+ _ => Resources.AxisScaleBoundInvalid
+ });
+ raised.Should().Contain(
+ nameof(AxisScalePanelViewModel.IsLogarithmic),
+ "the box ticked itself and must re-read");
+
+ void Record(object? sender, PropertyChangedEventArgs change)
+ {
+ raised.Add(change.PropertyName);
+ }
+ }
+
+ [AvaloniaFact]
+ public void ALogPenWhoseMinimumTheMaskShowsAsZero_StaysValidAndKeepsTheExactBound()
+ {
+ using var culture = new CultureScope("en-US");
+ var (chart, _, _, provider) = CreateViewModel();
+ chart.ApplyCatalogue([.. provider.Pens.Select(pen => pen with
+ {
+ Format = "0.000",
+ ScaleMinOnStart = 1e-6,
+ ScaleMaxOnStart = 1e-1,
+ LogScaleOnStart = true
+ })]);
+
+ chart.AxisScale.Seed();
+
+ chart.AxisScale.MinimumText.Should().Be("0.000");
+ chart.AxisScale.IsLogarithmic.Should().BeTrue();
+ chart.AxisScale.IsMinimumValid.Should().BeTrue();
+ chart.AxisScale.ValidationMessage.Should().BeEmpty();
+
+ chart.AxisScale.CommitBounds();
+
+ chart.ScaleRangeForPen(1)!.Value.Should().Be((1e-6, 1e-1));
+ }
+
+ [AvaloniaFact]
+ public void ToggleLogarithmic_SwitchesTheRenderedAxisToDecadesAndBack()
+ {
+ var (chart, _, _, provider) = CreateViewModel();
+ chart.ApplyCatalogue(provider.Pens);
+ chart.SetAxisLimits(1, 1e-6, 1e-1);
+ chart.AxisScale.Seed();
+
+ chart.AxisScale.ToggleLogarithmicCommand.Execute().Subscribe();
+ chart.Plot.RenderInMemory(600, 400);
+
+ chart.ActivePenAxis!.TickGenerator.Should().BeOfType();
+ chart.ActivePenAxis.Range.Min.Should().BeApproximately(-6.0, 1e-9);
+ chart.ActivePenAxis.Range.Max.Should().BeApproximately(-1.0, 1e-9);
+
+ chart.AxisScale.ToggleLogarithmicCommand.Execute().Subscribe();
+ chart.Plot.RenderInMemory(600, 400);
+
+ chart.ActivePenAxis.TickGenerator.Should().NotBeOfType();
+ chart.ActivePenAxis.Range.Min.Should().Be(1e-6);
+ chart.ActivePenAxis.Range.Max.Should().Be(1e-1);
+ }
+
+ [AvaloniaFact]
+ public void ToggleLogarithmic_OnAnAutoscaledPen_Succeeds()
+ {
+ var (chart, _, _, provider) = CreateViewModel();
+ chart.ApplyCatalogue(provider.Pens);
+ chart.AxisScale.Seed();
+
+ chart.AxisScale.ToggleLogarithmicCommand.Execute().Subscribe();
+
+ chart.ScaleSettings[1].Should().Be(new PenScaleSettings(1) { IsLogarithmic = true });
+ chart.AxisScale.IsLogarithmic.Should().BeTrue();
+ chart.AxisScale.ValidationMessage.Should().BeEmpty();
+ }
+
+ [AvaloniaFact]
+ public void InitialScale_RestoresTheStoredAxisType()
+ {
+ var (chart, _, _, provider) = CreateViewModel();
+ chart.ApplyCatalogue([.. provider.Pens.Select(pen => pen with
+ {
+ ScaleMinOnStart = 1e-6,
+ ScaleMaxOnStart = 1e-1,
+ LogScaleOnStart = true
+ })]);
+ chart.AxisScale.Seed();
+ chart.AxisScale.ToggleLogarithmicCommand.Execute().Subscribe();
+ chart.ScaleSettings[1].IsLogarithmic.Should().BeFalse();
+
+ chart.AxisScale.InitialScaleCommand.Execute().Subscribe();
+
+ chart.ScaleSettings[1].IsLogarithmic.Should().BeTrue();
+ chart.ScaleRangeForPen(1)!.Value.Should().Be((1e-6, 1e-1));
+ }
+
+ [AvaloniaFact]
+ public void WithTheFlagOn_AMinimumOfZero_IsRefusedAndNotWritten()
+ {
+ var (chart, _, _, provider) = CreateViewModel();
+ chart.ApplyCatalogue(provider.Pens);
+ chart.SetAxisLimits(1, 1.0, 100.0);
+ chart.SetLogarithmic(1, true);
+ chart.AxisScale.Seed();
+ var closes = 0;
+ using var subscription = chart.AxisScale.CloseRequests.Subscribe(_ => closes++);
+
+ chart.AxisScale.MinimumText = "0";
+
+ chart.AxisScale.ValidationMessage.Should().Be(Resources.ScaleLogMinimumPositive);
+ chart.AxisScale.IsMinimumValid.Should().BeFalse();
+ chart.AxisScale.IsMaximumValid.Should().BeTrue();
+
+ chart.AxisScale.CommitBounds();
+
+ chart.ScaleRangeForPen(1)!.Value.Should().Be((1.0, 100.0));
+ chart.ScaleSettings[1].IsLogarithmic.Should().BeTrue();
+ closes.Should().Be(0);
}
}
diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/AxisScalePanelViewTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/AxisScalePanelViewTests.cs
index ad25a46..97ece66 100644
--- a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/AxisScalePanelViewTests.cs
+++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/AxisScalePanelViewTests.cs
@@ -59,30 +59,12 @@ public void ClickWhereAHiddenPensAxisWas_OpensNothing()
FlyoutBase.GetAttachedFlyout(shown.PlotControl)!.IsOpen.Should().BeFalse();
}
- [AvaloniaFact]
- public void TypedBoundsAndTheApplyButton_WriteAManualScaleAndClosePanel()
- {
- using var viewModel = CreateLoadedViewModel();
- using var shown = ShowChart(viewModel);
- var (flyout, panel) = OpenPanel(viewModel, shown);
-
- TypeInto(shown.Window, panel, "AxisScaleMaximum", "120");
- TypeInto(shown.Window, panel, "AxisScaleMinimum", "-20");
- HeadlessInput.Click(shown.Window, panel.FindControl