Skip to content

Commit

Permalink
🎨 Refactor the hacky charts into a dedicated control.
Browse files Browse the repository at this point in the history
  • Loading branch information
hexawyz committed Apr 19, 2024
1 parent ab167be commit 8d0b836
Show file tree
Hide file tree
Showing 9 changed files with 314 additions and 144 deletions.
2 changes: 1 addition & 1 deletion Exo.Settings.Ui/App.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
<ResourceDictionary.MergedDictionaries>
<XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" />
<local:DataTemplates />
<ResourceDictionary Source="Controls\LineChart.xaml" />
</ResourceDictionary.MergedDictionaries>
<ResourceDictionary.ThemeDictionaries>
<ResourceDictionary x:Key="Light">
Expand Down Expand Up @@ -51,7 +52,6 @@
<local:StringFormatConverter x:Key="StringFormatConverter" />
<local:BatteryStateToGlyphConverter x:Key="BatteryStateToGlyphConverter" />
<local:ByteToDoubleConverter x:Key="ByteToDoubleConverter" />
<local:HistoryDataToPathGeometryConverter x:Key="HistoryDataToPathGeometryConverter" />

<local:RgbLightingDefaultPalette x:Key="RgbLightingDefaultPalette" />

Expand Down
11 changes: 11 additions & 0 deletions Exo.Settings.Ui/Controls/ITimeSeries.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace Exo.Settings.Ui.Controls;

internal interface ITimeSeries
{
DateTime StartTime { get; }
TimeSpan Interval { get; }
int Length { get; }
double this[int index] { get; }

event EventHandler Changed;
}
195 changes: 195 additions & 0 deletions Exo.Settings.Ui/Controls/LineChart.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media;
using Windows.Foundation;
using Path = Microsoft.UI.Xaml.Shapes.Path;

namespace Exo.Settings.Ui.Controls;

[TemplatePart(Name = LayoutGridPartName, Type = typeof(Grid))]
[TemplatePart(Name = StrokePathPartName, Type = typeof(Path))]
[TemplatePart(Name = FillPathPartName, Type = typeof(Path))]
internal class LineChart : Control
{
private const string LayoutGridPartName = "PART_LayoutGrid";
private const string StrokePathPartName = "PART_StrokePath";
private const string FillPathPartName = "PART_FillPath";

public ITimeSeries? Series
{
get => (ITimeSeries)GetValue(SeriesProperty);
set => SetValue(SeriesProperty, value);
}

public static readonly DependencyProperty SeriesProperty = DependencyProperty.Register("Series", typeof(ITimeSeries), typeof(LineChart), new PropertyMetadata(null, OnSeriesChanged));

// TODO: Should be nullable once the WinUI bug is fixed.
public double ScaleYMinimum
{
get => (double)GetValue(ScaleYMinimumProperty);
set => SetValue(ScaleYMinimumProperty, value);
}

public static readonly DependencyProperty ScaleYMinimumProperty = DependencyProperty.Register("ScaleYMinimum", typeof(double), typeof(LineChart), new PropertyMetadata(double.PositiveInfinity, OnScaleChanged));

// TODO: Should be nullable once the WinUI bug is fixed.
public double ScaleYMaximum
{
get => (double)GetValue(ScaleYMaximumProperty);
set => SetValue(ScaleYMaximumProperty, value);
}

public static readonly DependencyProperty ScaleYMaximumProperty = DependencyProperty.Register("ScaleYMaximum", typeof(double), typeof(LineChart), new PropertyMetadata(double.NegativeInfinity, OnScaleChanged));

public Brush AreaFill
{
get => (Brush)GetValue(AreaFillProperty);
set => SetValue(AreaFillProperty, value);
}

public static readonly DependencyProperty AreaFillProperty = DependencyProperty.Register("AreaFill", typeof(Brush), typeof(LineChart), new PropertyMetadata(new SolidColorBrush()));

public double AreaOpacity
{
get => (double)GetValue(AreaOpacityProperty);
set => SetValue(AreaOpacityProperty, value);
}

public static readonly DependencyProperty AreaOpacityProperty = DependencyProperty.Register("AreaOpacity", typeof(double), typeof(LineChart), new PropertyMetadata(1d));

public Brush Stroke
{
get => (Brush)GetValue(StrokeProperty);
set => SetValue(StrokeProperty, value);
}

public static readonly DependencyProperty StrokeProperty = DependencyProperty.Register("Stroke", typeof(Brush), typeof(LineChart), new PropertyMetadata(new SolidColorBrush()));

public PenLineJoin StrokeLineJoin
{
get => (PenLineJoin)GetValue(StrokeLineJoinProperty);
set => SetValue(StrokeLineJoinProperty, value);
}

public static readonly DependencyProperty StrokeLineJoinProperty = DependencyProperty.Register("StrokeLineJoin", typeof(PenLineJoin), typeof(LineChart), new PropertyMetadata(PenLineJoin.Round));

private Path? _strokePath;
private Path? _fillPath;
private Grid? _layoutGrid;
private readonly EventHandler _seriesDataChanged;

public LineChart()
{
_seriesDataChanged = OnSeriesDataChanged;
}

private static void OnSeriesChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) => ((LineChart)d).OnSeriesChanged(e);

private void OnSeriesChanged(DependencyPropertyChangedEventArgs e)
{
if (e.OldValue is ITimeSeries old) old.Changed -= _seriesDataChanged;
if (e.NewValue is ITimeSeries @new) @new.Changed += _seriesDataChanged;
RefreshChart();
}

private static void OnScaleChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) => ((LineChart)d).OnScaleChanged(e);

private void OnScaleChanged(DependencyPropertyChangedEventArgs e) => RefreshChart();

private void OnSeriesDataChanged(object? sender, EventArgs e) => RefreshChart();

protected override void OnApplyTemplate()
{
DetachParts();
_strokePath = GetTemplateChild(StrokePathPartName) as Path;
_fillPath = GetTemplateChild(FillPathPartName) as Path;
_layoutGrid = GetTemplateChild(LayoutGridPartName) as Grid;
AttachParts();
RefreshChart();
}

private void DetachParts()
{
if (_strokePath is not null) _strokePath.Data = null;
if (_fillPath is not null) _fillPath.Data = null;
}

private void AttachParts()
{
if (_strokePath is not null) _strokePath.Data = null;
if (_fillPath is not null) _fillPath.Data = null;
}

private void RefreshChart()
{
if (Series is null)
{
if (_strokePath is { }) _strokePath.Data = null;
if (_fillPath is { }) _fillPath.Data = null;
}
else
{
var (stroke, fill) = GenerateCurves
(
Series,
ScaleYMinimum,
ScaleYMaximum,
_layoutGrid?.ActualWidth ?? ActualWidth,
_layoutGrid?.ActualHeight ?? ActualHeight
);
if (_strokePath is { }) _strokePath.Data = stroke;
if (_fillPath is { }) _fillPath.Data = fill;
}
}

private (PathGeometry Stroke, PathGeometry Fill) GenerateCurves(ITimeSeries series, double minValue, double maxValue, double outputWidth, double outputHeight)
{
// NB: This is very rough and WIP.
// It should probably be ported to a dedicated chart drawing component afterwards.

for (int i = 0; i < series.Length; i++)
{
double value = series[i];
minValue = Math.Min(value, minValue);
maxValue = Math.Max(value, maxValue);
}

// Anchor the scale to zero if necessary.
if (maxValue < 0) maxValue = 0;
if (minValue > 0) minValue = 0;

// Force the chart to not be fully empty if the min and max are both zero. (result of previous adjustments)
if (minValue == maxValue) maxValue = 1;

var (scaleMin, scaleMax, _) = NiceScale.Compute(minValue, maxValue);

double scaleAmplitudeX = series.Length - 1;
double scaleAmplitudeY = maxValue - minValue;
double outputAmplitudeX = outputWidth;
double outputAmplitudeY = outputHeight;

var outlineFigure = new PathFigure();
var fillFigure = new PathFigure();

fillFigure.StartPoint = new(0, outputAmplitudeY - -minValue * outputAmplitudeY / scaleAmplitudeY);

var point = new Point(0, outputAmplitudeY - (series[0] - minValue) * outputAmplitudeY / scaleAmplitudeY);
outlineFigure.StartPoint = point;
fillFigure.Segments.Add(new LineSegment { Point = point });
for (int j = 1; j < series.Length; j++)
{
double value = series[j];
double x = j * outputAmplitudeX / scaleAmplitudeX;
double y = outputAmplitudeY - (value - minValue) * outputAmplitudeY / scaleAmplitudeY;
point = new Point(x, y);
outlineFigure.Segments.Add(new LineSegment() { Point = point });
fillFigure.Segments.Add(new LineSegment() { Point = point });
}

fillFigure.Segments.Add(new LineSegment() { Point = new(outputAmplitudeX, fillFigure.StartPoint.Y) });

return (new PathGeometry() { Figures = { outlineFigure } }, new PathGeometry() { Figures = { fillFigure } });
}

protected override Size MeasureOverride(Size availableSize) => availableSize;
}
25 changes: 25 additions & 0 deletions Exo.Settings.Ui/Controls/LineChart.xaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:Exo.Settings.Ui.Controls">
<Style TargetType="local:LineChart">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="local:LineChart">
<Border
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}">
<Grid x:Name="PART_LayoutGrid">
<Path x:Name="PART_FillPath" Fill="{TemplateBinding AreaFill}" Opacity="{TemplateBinding AreaOpacity}" />
<Path x:Name="PART_StrokePath" Stroke="{TemplateBinding Stroke}" StrokeLineJoin="{TemplateBinding StrokeLineJoin}" />
</Grid>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>
50 changes: 50 additions & 0 deletions Exo.Settings.Ui/Controls/NiceScale.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
namespace Exo.Settings.Ui.Controls;

// See: https://stackoverflow.com/a/16363437
internal static class NiceScale
{
public static (double Min, double Max, double TickSpacing) Compute(double min, double max, double tickCount = 10)
{
double range = MakeNice(max - min, false);
double tickSpacing = MakeNice(range / (tickCount - 1), true);
double niceMin = min < 0 ?
Math.Ceiling(min / tickSpacing) * tickSpacing :
Math.Floor(min / tickSpacing) * tickSpacing;
double niceMax = min < 0 ?
Math.Floor(max / tickSpacing) * tickSpacing :
Math.Ceiling(max / tickSpacing) * tickSpacing;

return (niceMin, niceMax, tickSpacing);
}

public static double MakeNice(double number, bool round)
{
double exponent;
double fraction;
double niceFraction;

exponent = Math.Floor(Math.Log10(number));
fraction = number / Math.Pow(10, exponent);

if (round)
if (fraction < 1.5)
niceFraction = 1;
else if (fraction < 3)
niceFraction = 2;
else if (fraction < 7)
niceFraction = 5;
else
niceFraction = 10;
else
if (fraction <= 1)
niceFraction = 1;
else if (fraction <= 2)
niceFraction = 2;
else if (fraction <= 5)
niceFraction = 5;
else
niceFraction = 10;

return niceFraction * Math.Pow(10, exponent);
}
}
6 changes: 6 additions & 0 deletions Exo.Settings.Ui/Exo.Settings.Ui.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
<DefineConstants>$(DefineConstants);DISABLE_XAML_GENERATED_MAIN</DefineConstants>
</PropertyGroup>
<ItemGroup>
<None Remove="Controls\LineChart.xaml" />
<None Remove="CustomMenuPage.xaml" />
<None Remove="DataTemplates.xaml" />
<None Remove="DevicePage.xaml" />
Expand Down Expand Up @@ -67,6 +68,11 @@
<ProjectReference Include="..\Exo.Contracts.Ui.Settings\Exo.Contracts.Ui.Settings.csproj" />
<ProjectReference Include="..\Exo.Ui.Core\Exo.Ui.Core.csproj" />
</ItemGroup>
<ItemGroup>
<Page Update="Controls\LineChart.xaml">
<Generator>MSBuild:Compile</Generator>
</Page>
</ItemGroup>
<ItemGroup>
<Page Update="SensorsPage.xaml">
<Generator>MSBuild:Compile</Generator>
Expand Down
Loading

0 comments on commit 8d0b836

Please sign in to comment.