diff --git a/OpenNetMeter.Tests/AxisScaleTests.cs b/OpenNetMeter.Tests/AxisScaleTests.cs new file mode 100644 index 00000000..8d219c99 --- /dev/null +++ b/OpenNetMeter.Tests/AxisScaleTests.cs @@ -0,0 +1,73 @@ +using OpenNetMeter.Views.Controls.Charting; + +namespace OpenNetMeter.Tests; + +public class AxisScaleTests +{ + [Theory] + [InlineData(1)] + [InlineData(37)] + [InlineData(100)] + [InlineData(999)] + [InlineData(1000)] + [InlineData(1234.5)] + [InlineData(0.5)] + [InlineData(9_999_999)] + public void Compute_TopIsAlwaysAtLeastMax(double max) + { + var ticks = AxisScale.Compute(max, 2); + + Assert.True(ticks.Top >= max); + } + + [Theory] + [InlineData(0)] + [InlineData(-5)] + public void Compute_NonPositiveInputDoesNotThrow(double max) + { + var ticks = AxisScale.Compute(max, 2); + + Assert.True(ticks.Top > 0); + Assert.True(ticks.Top >= max); + } + + [Fact] + public void Compute_StepIsANice1_2_5Multiple() + { + var ticks = AxisScale.Compute(37, 2); + + double step = ticks.Values[1] - ticks.Values[0]; + double magnitude = Math.Pow(10, Math.Floor(Math.Log10(step))); + double normalized = Math.Round(step / magnitude, 6); + + Assert.Contains(normalized, new[] { 1.0, 2.0, 5.0, 10.0 }); + } + + [Fact] + public void Compute_ValuesAreEvenlySpacedFromZero() + { + var ticks = AxisScale.Compute(1000, 2); + + Assert.Equal(3, ticks.Values.Count); + Assert.Equal(0, ticks.Values[0]); + double step = ticks.Values[1] - ticks.Values[0]; + Assert.Equal(step, ticks.Values[2] - ticks.Values[1], precision: 9); + Assert.Equal(ticks.Top, ticks.Values[^1], precision: 9); + } + + [Fact] + public void Compute_DesiredIntervalsControlsValueCount() + { + var ticks = AxisScale.Compute(100, 4); + + Assert.Equal(5, ticks.Values.Count); + } + + [Fact] + public void Compute_ZeroOrNegativeIntervalsFallsBackToOne() + { + var ticks = AxisScale.Compute(100, 0); + + Assert.Equal(2, ticks.Values.Count); + } +} diff --git a/OpenNetMeter.Tests/SpeedHistoryTests.cs b/OpenNetMeter.Tests/SpeedHistoryTests.cs new file mode 100644 index 00000000..fbfc4cf4 --- /dev/null +++ b/OpenNetMeter.Tests/SpeedHistoryTests.cs @@ -0,0 +1,103 @@ +using OpenNetMeter.Views.Controls.Charting; + +namespace OpenNetMeter.Tests; + +public class SpeedHistoryTests +{ + [Fact] + public void Append_TracksCountAndOrderWithinCapacity() + { + var history = new SpeedHistory(); + + history.Append(10, 20); + history.Append(30, 40); + + Assert.Equal(2, history.Count); + Assert.Equal(new SpeedSample(10, 20), history[0]); + Assert.Equal(new SpeedSample(30, 40), history[1]); + } + + [Fact] + public void Append_WrapsAroundOnceCapacityIsExceeded() + { + var history = new SpeedHistory(); + int capacity = SpeedHistory.VisibleSamples + 2; + + for (int i = 0; i < capacity + 5; i++) + history.Append(i, i); + + Assert.Equal(capacity, history.Count); + // Oldest retained sample is the one appended (capacity+5-capacity) = 5 ticks ago. + Assert.Equal(new SpeedSample(5, 5), history[0]); + Assert.Equal(new SpeedSample(capacity + 4, capacity + 4), history[history.Count - 1]); + } + + [Fact] + public void Clear_ResetsCountAndRaisesClearedEvent() + { + var history = new SpeedHistory(); + history.Append(1, 2); + history.Append(3, 4); + + bool clearedRaised = false; + history.Cleared += (_, _) => clearedRaised = true; + + history.Clear(); + + Assert.Equal(0, history.Count); + Assert.True(clearedRaised); + } + + [Fact] + public void Append_RaisesSampleAppendedEvent() + { + var history = new SpeedHistory(); + int raisedCount = 0; + history.SampleAppended += (_, _) => raisedCount++; + + history.Append(1, 2); + history.Append(3, 4); + + Assert.Equal(2, raisedCount); + } + + [Fact] + public void MaxInWindow_ReturnsMaxAcrossDownloadAndUpload() + { + var history = new SpeedHistory(); + history.Append(10, 5); + history.Append(2, 40); + history.Append(7, 3); + + Assert.Equal(40, history.MaxInWindow(3)); + } + + [Fact] + public void MaxInWindow_OnlyConsidersRequestedSampleCount() + { + var history = new SpeedHistory(); + history.Append(100, 0); + history.Append(1, 0); + history.Append(2, 0); + + Assert.Equal(2, history.MaxInWindow(2)); + } + + [Fact] + public void MaxInWindow_EmptyHistoryReturnsZero() + { + var history = new SpeedHistory(); + + Assert.Equal(0, history.MaxInWindow(SpeedHistory.VisibleSamples)); + } + + [Fact] + public void Indexer_OutOfRangeThrows() + { + var history = new SpeedHistory(); + history.Append(1, 1); + + Assert.Throws(() => history[1]); + Assert.Throws(() => history[-1]); + } +} diff --git a/OpenNetMeter/Compat/Utilities/Graph.cs b/OpenNetMeter/Compat/Utilities/Graph.cs deleted file mode 100644 index 8f70329c..00000000 --- a/OpenNetMeter/Compat/Utilities/Graph.cs +++ /dev/null @@ -1,124 +0,0 @@ - -using System.Collections.ObjectModel; -using System.ComponentModel; -using LiveChartsCore; -using LiveChartsCore.Defaults; -using LiveChartsCore.SkiaSharpView; -using LiveChartsCore.SkiaSharpView.Painting; -using SkiaSharp; - -public sealed class Graph -{ - private const int WindowSize = 35; - public ISeries[] GraphSeries { get; } - public Axis[] GraphXAxes { get; } - public Axis[] GraphYAxes { get; private set; } - // Match WPF dark theme accents: - // Download -> #367061, Upload -> #D98868 - SKColor dlColor = new SKColor(0x4A, 0xA9, 0x8C); - SKColor ulColor = new SKColor(0xE1, 0x77, 0x17); - private readonly ObservableCollection dlValues = new(); - private readonly ObservableCollection ulValues = new(); - private int tickCount; - public Graph() - { - GraphSeries = - [ - new LineSeries - { - Values = dlValues, - Stroke = new SolidColorPaint(dlColor, 2), - GeometrySize = 0, - GeometryStroke = null, - GeometryFill = null, - Fill = new SolidColorPaint(dlColor.WithAlpha(0x33)), - LineSmoothness = 0.3, - Name = "Download" - }, - new LineSeries - { - Values = ulValues, - Stroke = new SolidColorPaint(ulColor, 2), - GeometrySize = 0, - GeometryStroke = null, - GeometryFill = null, - Fill = new SolidColorPaint(ulColor.WithAlpha(0x33)), - LineSmoothness = 0.3, - Name = "Upload" - } - ]; - - GraphXAxes = - [ - new Axis - { - ShowSeparatorLines = false, - IsVisible = false, - MinLimit = 0, - MaxLimit = WindowSize - } - ]; - - GraphYAxes = CreateGraphYAxes(); - } - - public Axis[] CreateGraphYAxes() - { - return - [ - new Axis - { - Name = "Network Speed", - MinLimit = 0, - ShowSeparatorLines = true, - SeparatorsPaint = new SolidColorPaint(new SKColor(0x55, 0x55, 0x55)) { StrokeThickness = 1 }, - LabelsPaint = new SolidColorPaint(new SKColor(0xA9, 0xAB, 0xAB)), - TextSize = 10, - NameTextSize = 12, - Labeler = value => OpenNetMeter.ViewModels.SummaryViewModel.FormatSpeed((long)value) - } - ]; - } - - public void ClearOnDisconnect() - { - dlValues.Clear(); - ulValues.Clear(); - tickCount = 0; - GraphXAxes[0].MinLimit = 0; - GraphXAxes[0].MaxLimit = WindowSize; - } - - public void RefreshSpeedDisplayFormat() - { - GraphYAxes = CreateGraphYAxes(); - OnPropertyChanged(nameof(GraphYAxes)); - } - - public event PropertyChangedEventHandler? PropertyChanged; - - private void OnPropertyChanged(string propertyName) - { - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); - } - - public void AppendGraphPoint(long downloadBytes, long uploadBytes) - { - dlValues.Add(new ObservablePoint(tickCount, downloadBytes)); - ulValues.Add(new ObservablePoint(tickCount, uploadBytes)); - - while (dlValues.Count > WindowSize) - dlValues.RemoveAt(0); - while (ulValues.Count > WindowSize) - ulValues.RemoveAt(0); - - if (tickCount >= WindowSize) - { - GraphXAxes[0].MinLimit = tickCount - WindowSize; - GraphXAxes[0].MaxLimit = tickCount; - } - - tickCount++; - } - -} \ No newline at end of file diff --git a/OpenNetMeter/OpenNetMeter.csproj b/OpenNetMeter/OpenNetMeter.csproj index 19a2b872..ffa64ddd 100644 --- a/OpenNetMeter/OpenNetMeter.csproj +++ b/OpenNetMeter/OpenNetMeter.csproj @@ -14,7 +14,6 @@ - diff --git a/OpenNetMeter/ViewModels/SummaryViewModel.cs b/OpenNetMeter/ViewModels/SummaryViewModel.cs index 11e585ea..04100c44 100644 --- a/OpenNetMeter/ViewModels/SummaryViewModel.cs +++ b/OpenNetMeter/ViewModels/SummaryViewModel.cs @@ -7,12 +7,10 @@ using System.Windows.Input; using Avalonia.Threading; using Avalonia.Media; -using LiveChartsCore; -using LiveChartsCore.SkiaSharpView; -using Microsoft.Data.Sqlite; using OpenNetMeter.Models; using OpenNetMeter.PlatformAbstractions; using OpenNetMeter.Utilities; +using OpenNetMeter.Views.Controls.Charting; namespace OpenNetMeter.ViewModels; @@ -41,8 +39,9 @@ public sealed class SummaryViewModel : INotifyPropertyChanged, IDisposable private long pendingUploadBytes; private long latestDownloadBytesPerSecond; private long latestUploadBytesPerSecond; + private int weeklyTrendTickCounter; - private Graph graph; + private readonly SpeedHistory speedHistory = new(); public SummaryViewModel(INetworkCaptureService networkCaptureService, IProcessIconService processIconService, IExternalLinkService externalLinkService) { @@ -60,7 +59,7 @@ public SummaryViewModel(INetworkCaptureService networkCaptureService, IProcessIc SortProcesses(column); }); - graph = new Graph(); + WeeklyTrend = new WeeklyUsageTrendViewModel(); DateMax = DateTime.Today; DateMin = DateMax.AddDays(-ApplicationDB.DataStoragePeriodInDays); @@ -76,10 +75,9 @@ public SummaryViewModel(INetworkCaptureService networkCaptureService, IProcessIc } public ObservableCollection ActiveProcesses { get; } public ICommand SortProcessesCommand { get; } + public WeeklyUsageTrendViewModel WeeklyTrend { get; } - public ISeries[] GraphSeries => graph.GraphSeries; - public Axis[] GraphXAxes => graph.GraphXAxes; - public Axis[] GraphYAxes => graph.GraphYAxes; + public SpeedHistory SpeedHistory => speedHistory; public string? CurrentSortColumn => currentSortColumn; public bool IsSortDescending => sortDescending; @@ -147,7 +145,9 @@ public void ClearOnDisconnect() sinceDateSessionUploadBaseline = 0; activeAdapterName = string.Empty; - graph.ClearOnDisconnect(); + speedHistory.Clear(); + weeklyTrendTickCounter = 0; + WeeklyTrend.Reset(); ActiveProcesses.Clear(); processIndex.Clear(); @@ -168,6 +168,7 @@ public void SetActiveAdapter(string adapterName) activeAdapterName = normalized; RefreshSinceDateBaseline(); + RefreshWeeklyTrend(); } private void OnTrafficObserved(object? sender, NetworkTrafficEventArgs e) @@ -216,9 +217,14 @@ private void FlushPendingTraffic() currentSessionUpload += secondUploadBytes; UpdateTotalFromDateFromBaselines(); - graph.AppendGraphPoint(latestDownloadBytesPerSecond, latestUploadBytesPerSecond); + speedHistory.Append(latestDownloadBytesPerSecond, latestUploadBytesPerSecond); ApplyProcessTick(pendingSnapshot); + // The capture service pushes to the DB every 5s, so re-reading the week this often + // keeps today's bar close to live without querying on every tick. + if (++weeklyTrendTickCounter >= WeeklyTrendRefreshTicks) + RefreshWeeklyTrend(); + OnPropertyChanged(nameof(CurrentSessionDownloadText)); OnPropertyChanged(nameof(CurrentSessionUploadText)); OnPropertyChanged(nameof(TotalFromDateDownloadText)); @@ -227,6 +233,15 @@ private void FlushPendingTraffic() OnPropertyChanged(nameof(UploadSpeedText)); } + /// Flush ticks (one per second) between reloads of the weekly trend card. + private const int WeeklyTrendRefreshTicks = 15; + + private void RefreshWeeklyTrend() + { + weeklyTrendTickCounter = 0; + WeeklyTrend.Refresh(activeAdapterName); + } + private void RefreshSinceDateBaseline() { sinceDateSessionDownloadBaseline = currentSessionDownload; @@ -270,7 +285,7 @@ private static (long download, long upload) ReadDbTotals(string adapterName, Dat { try { - var dbPath = ResolveDatabasePath(); + var dbPath = UsageDatabase.ResolveDatabasePath(); if (!File.Exists(dbPath)) return (0, 0); @@ -279,7 +294,7 @@ private static (long download, long upload) ReadDbTotals(string adapterName, Dat if (toDate < fromDate) (fromDate, toDate) = (toDate, fromDate); - using var connection = OpenReadOnlyConnection(dbPath); + using var connection = UsageDatabase.OpenReadOnlyConnection(dbPath); connection.Open(); using var command = connection.CreateCommand(); command.CommandText = @@ -290,8 +305,8 @@ private static (long download, long upload) ReadDbTotals(string adapterName, Dat "WHERE a.Name = @AdapterName " + "AND (d.Year * 10000 + d.Month * 100 + d.Day) BETWEEN @StartDate AND @EndDate"; command.Parameters.AddWithValue("@AdapterName", adapterName); - command.Parameters.AddWithValue("@StartDate", ToDateInt(fromDate)); - command.Parameters.AddWithValue("@EndDate", ToDateInt(toDate)); + command.Parameters.AddWithValue("@StartDate", UsageDatabase.ToDateInt(fromDate)); + command.Parameters.AddWithValue("@EndDate", UsageDatabase.ToDateInt(toDate)); using var reader = command.ExecuteReader(); if (reader.Read()) @@ -309,28 +324,6 @@ private static (long download, long upload) ReadDbTotals(string adapterName, Dat return (0, 0); } - private static SqliteConnection OpenReadOnlyConnection(string path) - { - var csb = new SqliteConnectionStringBuilder - { - DataSource = path, - Mode = SqliteOpenMode.ReadOnly - }; - return new SqliteConnection(csb.ToString()); - } - - private static int ToDateInt(DateTime date) - { - return (date.Year * 10000) + (date.Month * 100) + date.Day; - } - - private static string ResolveDatabasePath() - { - var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); - var appFolder = Path.Combine(localAppData, "OpenNetMeter"); - return Path.Combine(appFolder, "OpenNetMeter.sqlite"); - } - private DateTimeOffset NormalizeSinceDate(DateTimeOffset? value) { var candidate = (value ?? DateTimeOffset.Now.Date).Date; @@ -401,7 +394,7 @@ private void NotifySortPropertiesChanged() public void RefreshSpeedDisplayFormat() { - graph.RefreshSpeedDisplayFormat(); + speedHistory.NotifyDisplayFormatChanged(); OnPropertyChanged(nameof(DownloadSpeedText)); OnPropertyChanged(nameof(UploadSpeedText)); } diff --git a/OpenNetMeter/ViewModels/UsageDatabase.cs b/OpenNetMeter/ViewModels/UsageDatabase.cs new file mode 100644 index 00000000..c16aa34b --- /dev/null +++ b/OpenNetMeter/ViewModels/UsageDatabase.cs @@ -0,0 +1,34 @@ +using System; +using System.IO; +using Microsoft.Data.Sqlite; + +namespace OpenNetMeter.ViewModels; + +/// +/// Read-only access helpers for the usage database shared by the summary cards. +/// Writes always go through ApplicationDB. +/// +internal static class UsageDatabase +{ + public static string ResolveDatabasePath() + { + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var appFolder = Path.Combine(localAppData, "OpenNetMeter"); + return Path.Combine(appFolder, "OpenNetMeter.sqlite"); + } + + public static SqliteConnection OpenReadOnlyConnection(string path) + { + var csb = new SqliteConnectionStringBuilder + { + DataSource = path, + Mode = SqliteOpenMode.ReadOnly + }; + return new SqliteConnection(csb.ToString()); + } + + public static int ToDateInt(DateTime date) + { + return (date.Year * 10000) + (date.Month * 100) + date.Day; + } +} diff --git a/OpenNetMeter/ViewModels/WeeklyUsageTrendViewModel.cs b/OpenNetMeter/ViewModels/WeeklyUsageTrendViewModel.cs new file mode 100644 index 00000000..d5aae934 --- /dev/null +++ b/OpenNetMeter/ViewModels/WeeklyUsageTrendViewModel.cs @@ -0,0 +1,246 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Globalization; +using System.IO; +using OpenNetMeter.Utilities; + +namespace OpenNetMeter.ViewModels; + +/// +/// Backs the summary "Weekly Trend" card: one stacked bar per day for the last seven days +/// plus a week-over-week comparison against the seven days before those. +/// +public sealed class WeeklyUsageTrendViewModel : INotifyPropertyChanged +{ + public const int DayCount = 7; + + private long weekDownload; + private long weekUpload; + private long previousWeekTotal; + + public WeeklyUsageTrendViewModel() + { + Days = []; + for (var i = 0; i < DayCount; i++) + Days.Add(new WeeklyUsageDayViewModel()); + + Reset(); + } + + public ObservableCollection Days { get; } + + public string TotalText => ByteSizeFormatter.FormatBytes(weekDownload + weekUpload); + public string DownloadText => ByteSizeFormatter.FormatBytes(weekDownload); + public string UploadText => ByteSizeFormatter.FormatBytes(weekUpload); + + /// The delta badge stays hidden until there is a prior week to compare against. + public bool HasComparison => previousWeekTotal > 0; + public bool IsIncrease => weekDownload + weekUpload >= previousWeekTotal; + + public string DeltaText + { + get + { + if (previousWeekTotal <= 0) + return string.Empty; + + var percent = (weekDownload + weekUpload - previousWeekTotal) * 100.0 / previousWeekTotal; + return Math.Abs(percent).ToString("0", CultureInfo.CurrentCulture) + "%"; + } + } + + public event PropertyChangedEventHandler? PropertyChanged; + + public void Reset() + { + weekDownload = 0; + weekUpload = 0; + previousWeekTotal = 0; + + ApplyDays(DateTime.Today, new Dictionary()); + NotifyTotalsChanged(); + } + + public void Refresh(string? adapterName) + { + if (string.IsNullOrWhiteSpace(adapterName)) + { + Reset(); + return; + } + + var today = DateTime.Today; + var dailyTotals = ReadDailyTotals(adapterName, today.AddDays(-((DayCount * 2) - 1)), today); + + weekDownload = 0; + weekUpload = 0; + previousWeekTotal = 0; + + for (var offset = 0; offset < DayCount; offset++) + { + if (dailyTotals.TryGetValue(UsageDatabase.ToDateInt(today.AddDays(-offset)), out var current)) + { + weekDownload += current.download; + weekUpload += current.upload; + } + + if (dailyTotals.TryGetValue(UsageDatabase.ToDateInt(today.AddDays(-(offset + DayCount))), out var previous)) + previousWeekTotal += previous.download + previous.upload; + } + + ApplyDays(today, dailyTotals); + NotifyTotalsChanged(); + } + + /// + /// Fills the seven bars oldest-first, scaling each one against the busiest day of the window + /// so the card reads as a trend rather than as absolute volume. + /// + private void ApplyDays(DateTime today, Dictionary dailyTotals) + { + var peak = 0L; + for (var i = 0; i < DayCount; i++) + { + dailyTotals.TryGetValue(UsageDatabase.ToDateInt(DateForSlot(today, i)), out var totals); + peak = Math.Max(peak, totals.download + totals.upload); + } + + for (var i = 0; i < DayCount; i++) + { + var date = DateForSlot(today, i); + dailyTotals.TryGetValue(UsageDatabase.ToDateInt(date), out var totals); + Days[i].Update(date, totals.download, totals.upload, peak, date == today); + } + } + + private static DateTime DateForSlot(DateTime today, int slot) => today.AddDays(slot - (DayCount - 1)); + + private static Dictionary ReadDailyTotals(string adapterName, DateTime startDate, DateTime endDate) + { + var totals = new Dictionary(); + + try + { + var dbPath = UsageDatabase.ResolveDatabasePath(); + if (!File.Exists(dbPath)) + return totals; + + using var connection = UsageDatabase.OpenReadOnlyConnection(dbPath); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = + "SELECT d.Year, d.Month, d.Day, SUM(pd.DataReceived) AS TotalRecv, SUM(pd.DataSent) AS TotalSent " + + "FROM ProcessDate pd " + + "JOIN Adapter a ON a.ID = pd.AdapterID " + + "JOIN Date d ON d.ID = pd.DateID " + + "WHERE a.Name = @AdapterName " + + "AND (d.Year * 10000 + d.Month * 100 + d.Day) BETWEEN @StartDate AND @EndDate " + + "GROUP BY d.Year, d.Month, d.Day"; + command.Parameters.AddWithValue("@AdapterName", adapterName); + command.Parameters.AddWithValue("@StartDate", UsageDatabase.ToDateInt(startDate)); + command.Parameters.AddWithValue("@EndDate", UsageDatabase.ToDateInt(endDate)); + + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + var dateInt = (reader.GetInt32(0) * 10000) + (reader.GetInt32(1) * 100) + reader.GetInt32(2); + var download = reader.IsDBNull(3) ? 0 : reader.GetInt64(3); + var upload = reader.IsDBNull(4) ? 0 : reader.GetInt64(4); + totals[dateInt] = (download, upload); + } + } + catch (Exception ex) + { + EventLogger.Error($"Failed to read weekly usage trend from database for adapter '{adapterName}'", ex); + } + + return totals; + } + + private void NotifyTotalsChanged() + { + OnPropertyChanged(nameof(TotalText)); + OnPropertyChanged(nameof(DownloadText)); + OnPropertyChanged(nameof(UploadText)); + OnPropertyChanged(nameof(HasComparison)); + OnPropertyChanged(nameof(IsIncrease)); + OnPropertyChanged(nameof(DeltaText)); + } + + private void OnPropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } +} + +/// +/// A single day column of the weekly trend card. Bar heights are pre-scaled to pixels here so +/// the view stays a plain bottom-aligned stack of two coloured borders. +/// +public sealed class WeeklyUsageDayViewModel : INotifyPropertyChanged +{ + /// Height of the bar track; kept in sync with the bar row height in Summary.axaml. + private const double BarAreaHeight = 34; + private const double MinBarHeight = 3; + + private string dayLabel = string.Empty; + private string tooltipText = string.Empty; + private bool isToday; + private double downloadBarHeight; + private double uploadBarHeight; + + public string DayLabel => dayLabel; + public string TooltipText => tooltipText; + public bool IsToday => isToday; + public double DownloadBarHeight => downloadBarHeight; + public double UploadBarHeight => uploadBarHeight; + public double TotalBarHeight => downloadBarHeight + uploadBarHeight; + + public event PropertyChangedEventHandler? PropertyChanged; + + public void Update(DateTime date, long downloadBytes, long uploadBytes, long peakBytes, bool isDateToday) + { + var total = downloadBytes + uploadBytes; + double downloadHeight = 0; + double uploadHeight = 0; + + if (total > 0) + { + var scaled = Math.Clamp(BarAreaHeight * total / Math.Max(peakBytes, total), MinBarHeight, BarAreaHeight); + + // Give each direction at least a hairline so a lopsided day still shows both colours. + downloadHeight = downloadBytes == 0 ? 0 : Math.Max(1, Math.Round(scaled * downloadBytes / total)); + uploadHeight = uploadBytes == 0 ? 0 : Math.Max(1, scaled - downloadHeight); + + // Rounding and the hairline floor can push the stack past the scaled height. + var overflow = downloadHeight + uploadHeight - scaled; + if (overflow > 0) + { + if (downloadHeight >= uploadHeight) + downloadHeight = Math.Max(1, downloadHeight - overflow); + else + uploadHeight = Math.Max(1, uploadHeight - overflow); + } + } + + dayLabel = CultureInfo.CurrentCulture.DateTimeFormat.GetShortestDayName(date.DayOfWeek); + tooltipText = $"{date.ToString("ddd, MMM d", CultureInfo.CurrentCulture)} • DL {ByteSizeFormatter.FormatBytes(downloadBytes)} • UL {ByteSizeFormatter.FormatBytes(uploadBytes)}"; + isToday = isDateToday; + downloadBarHeight = downloadHeight; + uploadBarHeight = uploadHeight; + + OnPropertyChanged(nameof(DayLabel)); + OnPropertyChanged(nameof(TooltipText)); + OnPropertyChanged(nameof(IsToday)); + OnPropertyChanged(nameof(DownloadBarHeight)); + OnPropertyChanged(nameof(UploadBarHeight)); + OnPropertyChanged(nameof(TotalBarHeight)); + } + + private void OnPropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } +} diff --git a/OpenNetMeter/Views/Controls/Charting/AxisScale.cs b/OpenNetMeter/Views/Controls/Charting/AxisScale.cs new file mode 100644 index 00000000..878b0934 --- /dev/null +++ b/OpenNetMeter/Views/Controls/Charting/AxisScale.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; + +namespace OpenNetMeter.Views.Controls.Charting; + +public readonly record struct AxisTicks(double Top, IReadOnlyList Values); + +public static class AxisScale +{ + // Below this, Math.Pow(10, floor(log10(x))) starts losing precision as it approaches the + // denormal range, and at x <= double.Epsilon it underflows to exactly 0 - which NextNiceStep + // can never step away from, spinning the loop below forever. Comfortably normal range instead. + private const double MinSafeMax = 1e-6; + private const int MaxStepIterations = 64; + + /// Computes 0..Top in even 1/2/5 x 10^n steps, with Top always >= max. + public static AxisTicks Compute(double max, int desiredIntervals) + { + if (desiredIntervals < 1) desiredIntervals = 1; + double safeMax = max > MinSafeMax ? max : MinSafeMax; + + double rawStep = safeMax / desiredIntervals; + double magnitude = Math.Pow(10, Math.Floor(Math.Log10(rawStep))); + double normalized = rawStep / magnitude; + double step = normalized <= 1 ? magnitude + : normalized <= 2 ? 2 * magnitude + : normalized <= 5 ? 5 * magnitude + : 10 * magnitude; + + double top = step * desiredIntervals; + for (int i = 0; top < safeMax && i < MaxStepIterations; i++) + { + step = NextNiceStep(step); + top = step * desiredIntervals; + } + + var values = new double[desiredIntervals + 1]; + for (int i = 0; i <= desiredIntervals; i++) + values[i] = step * i; + + return new AxisTicks(top, values); + } + + private static double NextNiceStep(double step) + { + double magnitude = Math.Pow(10, Math.Floor(Math.Log10(step))); + double normalized = Math.Round(step / magnitude, 6); + if (normalized < 2) return 2 * magnitude; + if (normalized < 5) return 5 * magnitude; + return 10 * magnitude; + } +} diff --git a/OpenNetMeter/Views/Controls/Charting/CurveBuilder.cs b/OpenNetMeter/Views/Controls/Charting/CurveBuilder.cs new file mode 100644 index 00000000..83f40f83 --- /dev/null +++ b/OpenNetMeter/Views/Controls/Charting/CurveBuilder.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; +using Avalonia; +using Avalonia.Media; + +namespace OpenNetMeter.Views.Controls.Charting; + +/// Builds smoothed line/area geometry from pixel-space points using a Catmull-Rom-derived cubic spline. +internal static class CurveBuilder +{ + public static StreamGeometry BuildStroke(IReadOnlyList points, double smoothness) + { + var geometry = new StreamGeometry(); + if (points.Count < 2) + return geometry; + + using var ctx = geometry.Open(); + ctx.BeginFigure(points[0], isFilled: false); + AppendCurve(ctx, points, smoothness, clampBelowY: null); + ctx.EndFigure(false); + return geometry; + } + + public static StreamGeometry BuildFill(IReadOnlyList points, double smoothness, double baselineY) + { + var geometry = new StreamGeometry(); + if (points.Count < 2) + return geometry; + + using var ctx = geometry.Open(); + ctx.BeginFigure(new Point(points[0].X, baselineY), isFilled: true); + ctx.LineTo(points[0]); + AppendCurve(ctx, points, smoothness, clampBelowY: baselineY); + ctx.LineTo(new Point(points[^1].X, baselineY)); + ctx.EndFigure(isClosed: true); + return geometry; + } + + private static void AppendCurve(StreamGeometryContext ctx, IReadOnlyList points, double smoothness, double? clampBelowY) + { + int n = points.Count; + for (int i = 0; i < n - 1; i++) + { + var p0 = points[Math.Max(i - 1, 0)]; + var p1 = points[i]; + var p2 = points[i + 1]; + var p3 = points[Math.Min(i + 2, n - 1)]; + + double c1X = p1.X + (p2.X - p0.X) * smoothness / 6; + double c1Y = p1.Y + (p2.Y - p0.Y) * smoothness / 6; + double c2X = p2.X - (p3.X - p1.X) * smoothness / 6; + double c2Y = p2.Y - (p3.Y - p1.Y) * smoothness / 6; + + if (clampBelowY is double baseline) + { + c1Y = Math.Min(c1Y, baseline); + c2Y = Math.Min(c2Y, baseline); + } + + ctx.CubicBezierTo(new Point(c1X, c1Y), new Point(c2X, c2Y), p2); + } + } +} diff --git a/OpenNetMeter/Views/Controls/Charting/SpeedChart.cs b/OpenNetMeter/Views/Controls/Charting/SpeedChart.cs new file mode 100644 index 00000000..1df9c8ae --- /dev/null +++ b/OpenNetMeter/Views/Controls/Charting/SpeedChart.cs @@ -0,0 +1,462 @@ +using System; +using System.Globalization; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using OpenNetMeter.ViewModels; + +namespace OpenNetMeter.Views.Controls.Charting; + +/// Custom-drawn network speed graph: two smoothed, gradient-filled +/// lines on a nice-tick Y axis, with a continuous scroll +public sealed class SpeedChart : Control +{ + private const long MinFloorBytesPerSecond = 1024; + private const int DesiredIntervals = 2; + private const double MaxEaseDurationMs = 350; + private const double TickIntervalMs = 1000.0; + private const double CurveSmoothness = 0.8; + private const double GutterWidth = 46; + private const double TopPadding = 6; + private const double BottomPadding = 16; + private const double RightPadding = 6; + + public static readonly StyledProperty HistoryProperty = + AvaloniaProperty.Register(nameof(History)); + + public static readonly StyledProperty BackgroundProperty = + AvaloniaProperty.Register(nameof(Background)); + + public static readonly StyledProperty CornerRadiusProperty = + AvaloniaProperty.Register(nameof(CornerRadius)); + + public static readonly StyledProperty DownloadBrushProperty = + AvaloniaProperty.Register(nameof(DownloadBrush)); + + public static readonly StyledProperty UploadBrushProperty = + AvaloniaProperty.Register(nameof(UploadBrush)); + + public static readonly StyledProperty FillOpacityProperty = + AvaloniaProperty.Register(nameof(FillOpacity), 0.38); + + public static readonly StyledProperty GridLineBrushProperty = + AvaloniaProperty.Register(nameof(GridLineBrush)); + + public static readonly StyledProperty LabelBrushProperty = + AvaloniaProperty.Register(nameof(LabelBrush)); + + public static readonly StyledProperty AxisNameProperty = + AvaloniaProperty.Register(nameof(AxisName), "Network Speed"); + + public static readonly StyledProperty LabelFontSizeProperty = + AvaloniaProperty.Register(nameof(LabelFontSize), 10); + + public static readonly StyledProperty AxisNameFontSizeProperty = + AvaloniaProperty.Register(nameof(AxisNameFontSize), 12); + + public static readonly StyledProperty IsAnimatedProperty = + AvaloniaProperty.Register(nameof(IsAnimated), true); + + static SpeedChart() + { + AffectsRender( + HistoryProperty, BackgroundProperty, CornerRadiusProperty, + DownloadBrushProperty, UploadBrushProperty, FillOpacityProperty, + GridLineBrushProperty, LabelBrushProperty, AxisNameProperty, + LabelFontSizeProperty, AxisNameFontSizeProperty); + } + + private Window? hostWindow; + private bool frameRequested; + private long lastSampleTimestampMs; + private double renderedMaxBytes; + private double maxAtTransitionStart; + private double targetMaxBytes; + private long maxTransitionStartMs; + private double[] tickBytesCache = Array.Empty(); + private string[] labelsCache = Array.Empty(); + private IBrush? downloadFillBrush; + private IBrush? uploadFillBrush; + + public SpeedHistory? History + { + get => GetValue(HistoryProperty); + set => SetValue(HistoryProperty, value); + } + + public IBrush? Background + { + get => GetValue(BackgroundProperty); + set => SetValue(BackgroundProperty, value); + } + + public CornerRadius CornerRadius + { + get => GetValue(CornerRadiusProperty); + set => SetValue(CornerRadiusProperty, value); + } + + public IBrush? DownloadBrush + { + get => GetValue(DownloadBrushProperty); + set => SetValue(DownloadBrushProperty, value); + } + + public IBrush? UploadBrush + { + get => GetValue(UploadBrushProperty); + set => SetValue(UploadBrushProperty, value); + } + + public double FillOpacity + { + get => GetValue(FillOpacityProperty); + set => SetValue(FillOpacityProperty, value); + } + + public IBrush? GridLineBrush + { + get => GetValue(GridLineBrushProperty); + set => SetValue(GridLineBrushProperty, value); + } + + public IBrush? LabelBrush + { + get => GetValue(LabelBrushProperty); + set => SetValue(LabelBrushProperty, value); + } + + public string AxisName + { + get => GetValue(AxisNameProperty); + set => SetValue(AxisNameProperty, value); + } + + public double LabelFontSize + { + get => GetValue(LabelFontSizeProperty); + set => SetValue(LabelFontSizeProperty, value); + } + + public double AxisNameFontSize + { + get => GetValue(AxisNameFontSizeProperty); + set => SetValue(AxisNameFontSizeProperty, value); + } + + public bool IsAnimated + { + get => GetValue(IsAnimatedProperty); + set => SetValue(IsAnimatedProperty, value); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == HistoryProperty) + { + if (change.OldValue is SpeedHistory oldHistory) + { + oldHistory.SampleAppended -= OnHistoryChanged; + oldHistory.Cleared -= OnHistoryCleared; + oldHistory.DisplayFormatChanged -= OnHistoryChanged; + } + + if (change.NewValue is SpeedHistory newHistory) + { + newHistory.SampleAppended += OnHistoryChanged; + newHistory.Cleared += OnHistoryCleared; + newHistory.DisplayFormatChanged += OnHistoryChanged; + } + + ResetAnimationState(); + } + else if (change.Property == DownloadBrushProperty || change.Property == FillOpacityProperty) + { + downloadFillBrush = null; + } + else if (change.Property == UploadBrushProperty || change.Property == FillOpacityProperty) + { + uploadFillBrush = null; + } + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + + hostWindow = TopLevel.GetTopLevel(this) as Window; + if (hostWindow is not null) + hostWindow.PropertyChanged += OnHostWindowPropertyChanged; + + ResetAnimationState(); + RequestFrame(); + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + if (hostWindow is not null) + { + hostWindow.PropertyChanged -= OnHostWindowPropertyChanged; + hostWindow = null; + } + + base.OnDetachedFromVisualTree(e); + } + + private void OnHostWindowPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e) + { + if (e.Property == Window.WindowStateProperty) + RequestFrame(); + } + + private void OnHistoryChanged(object? sender, EventArgs e) + { + lastSampleTimestampMs = Environment.TickCount64; + RecomputeTargets(); + RequestFrame(); + } + + private void OnHistoryCleared(object? sender, EventArgs e) + { + ResetAnimationState(); + InvalidateVisual(); + } + + private void ResetAnimationState() + { + lastSampleTimestampMs = Environment.TickCount64; + RecomputeTargets(); + renderedMaxBytes = targetMaxBytes; + maxAtTransitionStart = targetMaxBytes; + maxTransitionStartMs = Environment.TickCount64; + } + + private void RecomputeTargets() + { + var history = History; + long maxBytes = history is { Count: > 0 } + ? Math.Max(history.MaxInWindow(SpeedHistory.VisibleSamples), MinFloorBytesPerSecond) + : MinFloorBytesPerSecond; + + var (topBytes, tickBytes, labels) = ComputeAxis(maxBytes); + + if (tickBytesCache.Length == 0 || Math.Abs(topBytes - targetMaxBytes) > 0.5) + { + maxAtTransitionStart = renderedMaxBytes; + targetMaxBytes = topBytes; + maxTransitionStartMs = Environment.TickCount64; + } + + tickBytesCache = tickBytes; + labelsCache = labels; + } + + private static (double topBytes, double[] tickBytes, string[] labels) ComputeAxis(long maxBytesPerSecond) + { + var settings = Properties.SettingsManager.Current; + bool useBytes = settings.NetworkSpeedFormat != 0; + var magnitude = NetworkSpeed.NormalizeMagnitude(settings.NetworkSpeedMagnitude); + + double signedValue = useBytes ? maxBytesPerSecond : maxBytesPerSecond * 8.0; + var (_, mag) = NetworkSpeed.GetAdjustedSize((long)signedValue, magnitude); + + double divisor = 1L << (mag * 10); + double maxDisplay = signedValue / divisor; + + var ticks = AxisScale.Compute(maxDisplay, DesiredIntervals); + + var tickBytes = new double[ticks.Values.Count]; + var labels = new string[ticks.Values.Count]; + for (int i = 0; i < ticks.Values.Count; i++) + { + double rawValue = ticks.Values[i] * divisor; + long bytesPerSecond = (long)Math.Round(useBytes ? rawValue : rawValue / 8.0); + tickBytes[i] = bytesPerSecond; + labels[i] = SummaryViewModel.FormatSpeed(bytesPerSecond); + } + + double topBytes = ticks.Top * divisor / (useBytes ? 1.0 : 8.0); + return (topBytes, tickBytes, labels); + } + + private void RequestFrame() + { + if (frameRequested) + return; + + var topLevel = TopLevel.GetTopLevel(this); + if (topLevel is null) + return; + + frameRequested = true; + topLevel.RequestAnimationFrame(OnFrame); + } + + private void OnFrame(TimeSpan _) + { + frameRequested = false; + if (!CanAnimate()) + return; + + InvalidateVisual(); + RequestFrame(); + } + + private bool CanAnimate() => + IsAnimated && + IsEffectivelyVisible && + hostWindow is not { WindowState: WindowState.Minimized }; + + private static double Smoothstep(double p) => p * p * (3 - 2 * p); + + public override void Render(DrawingContext context) + { + var bounds = new Rect(Bounds.Size); + if (bounds.Width <= 1 || bounds.Height <= 1) + return; + + var background = Background; + if (background is not null) + context.DrawRectangle(background, null, new RoundedRect(bounds, CornerRadius)); + + var gridBrush = GridLineBrush; + var labelBrush = LabelBrush; + var typeface = Typeface.Default; + double labelFontSize = LabelFontSize; + + FormattedText? nameText = null; + double nameBandHeight = 0; + if (labelBrush is not null && !string.IsNullOrEmpty(AxisName)) + { + nameText = new FormattedText(AxisName, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, typeface, AxisNameFontSize, labelBrush); + nameBandHeight = nameText.Height + 2; + } + + var plotRect = new Rect( + bounds.X + GutterWidth, + bounds.Y + nameBandHeight + TopPadding, + Math.Max(0, bounds.Width - GutterWidth - RightPadding), + Math.Max(0, bounds.Height - nameBandHeight - TopPadding - BottomPadding)); + + if (plotRect.Width <= 0 || plotRect.Height <= 0) + return; + + UpdateMaxEasing(); + + if (nameText is not null) + { + double nameX = bounds.X + (bounds.Width - nameText.Width) / 2; + double nameY = bounds.Y + (nameBandHeight - nameText.Height) / 2; + context.DrawText(nameText, new Point(nameX, nameY)); + } + + for (int i = 0; i < tickBytesCache.Length; i++) + { + double y = ValueToY(tickBytesCache[i], plotRect); + + if (gridBrush is not null) + context.DrawLine(new Pen(gridBrush, 1), new Point(plotRect.X, y), new Point(plotRect.Right, y)); + + if (labelBrush is not null && i < labelsCache.Length) + { + var text = new FormattedText(labelsCache[i], CultureInfo.CurrentCulture, FlowDirection.LeftToRight, typeface, labelFontSize, labelBrush); + double textY = Math.Clamp(y - text.Height / 2, bounds.Y, Math.Max(bounds.Y, bounds.Bottom - text.Height)); + context.DrawText(text, new Point(GutterWidth - 6 - text.Width, textY)); + } + } + + var history = History; + if (history is null || history.Count == 0) + return; + + double slot = plotRect.Width / (SpeedHistory.VisibleSamples - 1); + double shiftPixels = ComputeShiftPixels(slot); + + using (context.PushClip(plotRect)) + { + DrawSeries(context, history, plotRect, slot, shiftPixels, isDownload: true); + DrawSeries(context, history, plotRect, slot, shiftPixels, isDownload: false); + } + } + + private double ComputeShiftPixels(double slot) + { + long elapsedMs = Environment.TickCount64 - lastSampleTimestampMs; + double progress = Math.Clamp(elapsedMs / TickIntervalMs, 0.0, 1.0); + return slot * (1 - Smoothstep(progress)); + } + + private void UpdateMaxEasing() + { + long elapsedMs = Environment.TickCount64 - maxTransitionStartMs; + double progress = Math.Clamp(elapsedMs / MaxEaseDurationMs, 0.0, 1.0); + renderedMaxBytes = maxAtTransitionStart + (targetMaxBytes - maxAtTransitionStart) * Smoothstep(progress); + } + + private double ValueToY(double bytesPerSecond, Rect plotRect) + { + double ratio = renderedMaxBytes > 0 ? bytesPerSecond / renderedMaxBytes : 0; + return plotRect.Bottom - ratio * plotRect.Height; + } + + private void DrawSeries(DrawingContext context, SpeedHistory history, Rect plotRect, double slot, double shiftPixels, bool isDownload) + { + var strokeBrush = isDownload ? DownloadBrush : UploadBrush; + if (strokeBrush is null) + return; + + int windowSize = Math.Min(history.Count, SpeedHistory.VisibleSamples + 1); + int startIndex = history.Count - windowSize; + + var points = new Point[windowSize]; + for (int i = 0; i < windowSize; i++) + { + var sample = history[startIndex + i]; + long value = isDownload ? sample.DownloadBytesPerSecond : sample.UploadBytesPerSecond; + int distanceFromNewest = (history.Count - 1) - (startIndex + i); + double x = plotRect.Right - distanceFromNewest * slot + shiftPixels; + double y = ValueToY(value, plotRect); + points[i] = new Point(x, y); + } + + double baselineY = plotRect.Bottom; + var fillBrush = GetOrBuildFillBrush(isDownload, strokeBrush); + if (fillBrush is not null) + { + var fillGeometry = CurveBuilder.BuildFill(points, CurveSmoothness, baselineY); + context.DrawGeometry(fillBrush, null, fillGeometry); + } + + var strokeGeometry = CurveBuilder.BuildStroke(points, CurveSmoothness); + var pen = new Pen(strokeBrush, 2, null, PenLineCap.Round, PenLineJoin.Round); + context.DrawGeometry(null, pen, strokeGeometry); + } + + private IBrush? GetOrBuildFillBrush(bool isDownload, IBrush strokeBrush) + { + if (isDownload) + return downloadFillBrush ??= BuildFillBrush(strokeBrush); + return uploadFillBrush ??= BuildFillBrush(strokeBrush); + } + + private IBrush? BuildFillBrush(IBrush strokeBrush) + { + if (strokeBrush is not ISolidColorBrush solid) + return null; + + var color = solid.Color; + var top = Color.FromArgb((byte)Math.Round(Math.Clamp(FillOpacity, 0, 1) * 255), color.R, color.G, color.B); + var bottom = Color.FromArgb(0, color.R, color.G, color.B); + + var gradient = new LinearGradientBrush + { + StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative), + EndPoint = new RelativePoint(0, 1, RelativeUnit.Relative) + }; + gradient.GradientStops.Add(new GradientStop(top, 0)); + gradient.GradientStops.Add(new GradientStop(bottom, 1)); + return gradient; + } +} diff --git a/OpenNetMeter/Views/Controls/Charting/SpeedHistory.cs b/OpenNetMeter/Views/Controls/Charting/SpeedHistory.cs new file mode 100644 index 00000000..dba25132 --- /dev/null +++ b/OpenNetMeter/Views/Controls/Charting/SpeedHistory.cs @@ -0,0 +1,70 @@ +using System; + +namespace OpenNetMeter.Views.Controls.Charting; + +public readonly record struct SpeedSample(long DownloadBytesPerSecond, long UploadBytesPerSecond); + +public sealed class SpeedHistory +{ + public const int VisibleSamples = 35; + private const int Capacity = VisibleSamples + 2; + + private readonly SpeedSample[] buffer = new SpeedSample[Capacity]; + private int head; + private int count; + + public int Count => count; + + public SpeedSample this[int index] + { + get + { + if ((uint)index >= (uint)count) + throw new ArgumentOutOfRangeException(nameof(index)); + return buffer[(head + index) % Capacity]; + } + } + + public event EventHandler? SampleAppended; + public event EventHandler? Cleared; + public event EventHandler? DisplayFormatChanged; + + public void Append(long downloadBytesPerSecond, long uploadBytesPerSecond) + { + var sample = new SpeedSample(downloadBytesPerSecond, uploadBytesPerSecond); + if (count < Capacity) + { + buffer[(head + count) % Capacity] = sample; + count++; + } + else + { + buffer[head] = sample; + head = (head + 1) % Capacity; + } + + SampleAppended?.Invoke(this, EventArgs.Empty); + } + + public void Clear() + { + head = 0; + count = 0; + Cleared?.Invoke(this, EventArgs.Empty); + } + + public void NotifyDisplayFormatChanged() => DisplayFormatChanged?.Invoke(this, EventArgs.Empty); + + public long MaxInWindow(int sampleCount) + { + long max = 0; + int n = Math.Min(sampleCount, count); + for (int i = count - n; i < count; i++) + { + var sample = this[i]; + if (sample.DownloadBytesPerSecond > max) max = sample.DownloadBytesPerSecond; + if (sample.UploadBytesPerSecond > max) max = sample.UploadBytesPerSecond; + } + return max; + } +} diff --git a/OpenNetMeter/Views/Controls/SortHeader.axaml b/OpenNetMeter/Views/Controls/SortHeader.axaml new file mode 100644 index 00000000..3d21e7ea --- /dev/null +++ b/OpenNetMeter/Views/Controls/SortHeader.axaml @@ -0,0 +1,22 @@ + + + + + + diff --git a/OpenNetMeter/Views/Controls/SortHeader.axaml.cs b/OpenNetMeter/Views/Controls/SortHeader.axaml.cs new file mode 100644 index 00000000..3f895d19 --- /dev/null +++ b/OpenNetMeter/Views/Controls/SortHeader.axaml.cs @@ -0,0 +1,58 @@ +using System.Windows.Input; +using Avalonia; +using Avalonia.Controls; + +namespace OpenNetMeter.Views.Controls; + +public partial class SortHeader : UserControl +{ + public static readonly StyledProperty HeaderProperty = + AvaloniaProperty.Register(nameof(Header)); + + public static readonly StyledProperty SortKeyProperty = + AvaloniaProperty.Register(nameof(SortKey)); + + public static readonly StyledProperty CurrentSortColumnProperty = + AvaloniaProperty.Register(nameof(CurrentSortColumn)); + + public static readonly StyledProperty IsSortDescendingProperty = + AvaloniaProperty.Register(nameof(IsSortDescending)); + + public static readonly StyledProperty SortCommandProperty = + AvaloniaProperty.Register(nameof(SortCommand)); + + public SortHeader() + { + InitializeComponent(); + } + + public string? Header + { + get => GetValue(HeaderProperty); + set => SetValue(HeaderProperty, value); + } + + public string? SortKey + { + get => GetValue(SortKeyProperty); + set => SetValue(SortKeyProperty, value); + } + + public string? CurrentSortColumn + { + get => GetValue(CurrentSortColumnProperty); + set => SetValue(CurrentSortColumnProperty, value); + } + + public bool IsSortDescending + { + get => GetValue(IsSortDescendingProperty); + set => SetValue(IsSortDescendingProperty, value); + } + + public ICommand? SortCommand + { + get => GetValue(SortCommandProperty); + set => SetValue(SortCommandProperty, value); + } +} diff --git a/OpenNetMeter/Views/Controls/StatRow.axaml b/OpenNetMeter/Views/Controls/StatRow.axaml new file mode 100644 index 00000000..ba334003 --- /dev/null +++ b/OpenNetMeter/Views/Controls/StatRow.axaml @@ -0,0 +1,23 @@ + + + + + + + + + + + diff --git a/OpenNetMeter/Views/Controls/StatRow.axaml.cs b/OpenNetMeter/Views/Controls/StatRow.axaml.cs new file mode 100644 index 00000000..8b589b5a --- /dev/null +++ b/OpenNetMeter/Views/Controls/StatRow.axaml.cs @@ -0,0 +1,67 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; + +namespace OpenNetMeter.Views.Controls; + +public partial class StatRow : UserControl +{ + public static readonly StyledProperty BackgroundBrushProperty = + AvaloniaProperty.Register(nameof(BackgroundBrush)); + + public static readonly StyledProperty TextBrushProperty = + AvaloniaProperty.Register(nameof(TextBrush)); + + public static readonly StyledProperty IconBrushProperty = + AvaloniaProperty.Register(nameof(IconBrush)); + + public static readonly StyledProperty HeaderProperty = + AvaloniaProperty.Register(nameof(Header)); + + public static readonly StyledProperty ValueProperty = + AvaloniaProperty.Register(nameof(Value)); + + public static readonly StyledProperty IconDataProperty = + AvaloniaProperty.Register(nameof(IconData)); + + public StatRow() + { + InitializeComponent(); + } + + public IBrush? BackgroundBrush + { + get => GetValue(BackgroundBrushProperty); + set => SetValue(BackgroundBrushProperty, value); + } + + public IBrush? TextBrush + { + get => GetValue(TextBrushProperty); + set => SetValue(TextBrushProperty, value); + } + + public IBrush? IconBrush + { + get => GetValue(IconBrushProperty); + set => SetValue(IconBrushProperty, value); + } + + public string? Header + { + get => GetValue(HeaderProperty); + set => SetValue(HeaderProperty, value); + } + + public string? Value + { + get => GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + public Geometry? IconData + { + get => GetValue(IconDataProperty); + set => SetValue(IconDataProperty, value); + } +} diff --git a/OpenNetMeter/Views/MainWindow/Summary.axaml b/OpenNetMeter/Views/MainWindow/Summary.axaml index 3c4ffe67..0a4dc408 100644 --- a/OpenNetMeter/Views/MainWindow/Summary.axaml +++ b/OpenNetMeter/Views/MainWindow/Summary.axaml @@ -3,106 +3,159 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:vm="clr-namespace:OpenNetMeter.ViewModels" - xmlns:lvc="using:LiveChartsCore.SkiaSharpView.Avalonia" xmlns:ctrl="using:OpenNetMeter.Views.Controls" + xmlns:chart="using:OpenNetMeter.Views.Controls.Charting" x:Class="OpenNetMeter.Views.MainWindowTabs.SummaryView" x:DataType="vm:SummaryViewModel" mc:Ignorable="d"> + + + + + - - + + - - + + Foreground="{DynamicResource BrushTextHeader}" + VerticalAlignment="Center" Margin="6,0,0,0"/> - - - - - - - - - - - - - - - - - - - + + - + - - - + + + Foreground="{DynamicResource BrushTextHeader}" + VerticalAlignment="Center" Margin="6,0,0,0"/> - - - - - - - - - - + + + + + + + + + + + + + - - - - - + + + + + + + - - - + + + + + + + + + + + + + + + + + + + + + + + + - + - + - - + @@ -114,80 +167,26 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + @@ -200,18 +199,24 @@ Background="Transparent" BorderThickness="0" Padding="2" Cursor="Hand" ToolTip.Tip="Search online"> - + - + - - - - + + + + @@ -222,15 +227,17 @@ - + diff --git a/OpenNetMeter/Views/Themes.axaml b/OpenNetMeter/Views/Themes.axaml index e8d958a9..56856bd0 100644 --- a/OpenNetMeter/Views/Themes.axaml +++ b/OpenNetMeter/Views/Themes.axaml @@ -28,6 +28,7 @@ + @@ -67,6 +68,7 @@ + @@ -87,6 +89,16 @@ + M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12.5,7V12.25L17,14.92L16.25,16.15L11,13V7H12.5Z + M5,20H19V18H5M19,9H15V3H9V9H5L12,16L19,9Z + M9,16V10H5L12,3L19,10H15V16H9M5,20V18H19V20H5Z + M12,3C7.58,3 4,4.79 4,7C4,9.21 7.58,11 12,11C16.42,11 20,9.21 20,7C20,4.79 16.42,3 12,3M4,9V12C4,14.21 7.58,16 12,16C16.42,16 20,14.21 20,12V9C20,11.21 16.42,13 12,13C7.58,13 4,11.21 4,9M4,14V17C4,19.21 7.58,21 12,21C16.42,21 20,19.21 20,17V14C20,16.21 16.42,18 12,18C7.58,18 4,16.21 4,14Z + M12,2A2,2 0 0,1 14,4C14,4.74 13.6,5.39 13,5.73V7H14A7,7 0 0,1 21,14H22A1,1 0 0,1 23,15V18A1,1 0 0,1 22,19H21V20A2,2 0 0,1 19,22H5A2,2 0 0,1 3,20V19H2A1,1 0 0,1 1,18V15A1,1 0 0,1 2,14H3A7,7 0 0,1 10,7H11V5.73C10.4,5.39 10,4.74 10,4A2,2 0 0,1 12,2M7.5,13A2.5,2.5 0 0,0 5,15.5A2.5,2.5 0 0,0 7.5,18A2.5,2.5 0 0,0 10,15.5A2.5,2.5 0 0,0 7.5,13M16.5,13A2.5,2.5 0 0,0 14,15.5A2.5,2.5 0 0,0 16.5,18A2.5,2.5 0 0,0 19,15.5A2.5,2.5 0 0,0 16.5,13Z + M16,11.78L20.24,4.45L21.97,5.45L16.74,14.5L10.23,10.75L5.46,19H22V21H2V3H4V17.54L9.5,8L16,11.78Z + M16,6L18.29,8.29L13.41,13.17L9.41,9.17L2,16.59L3.41,18L9.41,12L13.41,16L19.71,9.71L22,12V6H16Z + M16,18L18.29,15.71L13.41,10.83L9.41,14.83L2,7.41L3.41,6L9.41,12L13.41,8L19.71,14.29L22,12V18H16Z + M9.5,3A6.5,6.5 0 0,1 16,9.5C16,11.11 15.41,12.59 14.44,13.73L14.71,14H15.5L20.5,19L19,20.5L14,15.5V14.71L13.73,14.44C12.59,15.41 11.11,16 9.5,16A6.5,6.5 0 0,1 3,9.5A6.5,6.5 0 0,1 9.5,3M9.5,5C7,5 5,7 5,9.5C5,12 7,14 9.5,14C12,14 14,12 14,9.5C14,7 12,5 9.5,5Z + 10 11 12