beto-rodriguez / LiveCharts2

Simple, flexible, interactive & powerful charts, maps and gauges for .Net, LiveCharts2 can now practically run everywhere Maui, Uno Platform, Blazor-wasm, WPF, WinForms, Xamarin, Avalonia, WinUI, UWP.
https://livecharts.dev
MIT License
4.25k stars 551 forks source link

Action defined in `Series.Mapping` is ignored with `DateTimePoint` values #1210

Closed schnerring closed 1 year ago

schnerring commented 1 year ago

Describe the bug

From the logarithmic scale sample, I use the following as a working baseline:

<reactiveUi:ReactiveUserControl
    x:TypeArguments="qBittorrent:QBitViewModel"
    xmlns="https://github.com/avaloniaui"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    xmlns:avalonia="clr-namespace:LiveChartsCore.SkiaSharpView.Avalonia;assembly=LiveChartsCore.SkiaSharpView.Avalonia"
    xmlns:reactiveUi="http://reactiveui.net"
    xmlns:qBittorrent="clr-namespace:Spore.QBittorrent"
    mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
    x:Class="Spore.QBittorrent.TransferChartView">

    <avalonia:CartesianChart x:Name="TransferChart" Height="250"/>

</reactiveUi:ReactiveUserControl>
public partial class TestView : UserControl
{
    public TestView()
    {
        InitializeComponent();

        var points = new ObservableCollection<LogarithmicPoint>();

        var logBase = 2;

        TestChart.Series = new[]
        {
            new LineSeries<LogarithmicPoint>
            {
                Mapping = (logPoint, chartPoint) =>
                {
                    chartPoint.Coordinate = new(logPoint.X, Math.Log(logPoint.Y, logBase));
                },
                Values = points
            }
        };

        TestChart.YAxes = new[]
        {
            new LogaritmicAxis(logBase)
            {
                SeparatorsPaint = new SolidColorPaint
                {
                    Color = SKColors.Black.WithAlpha(100),
                    StrokeThickness = 1,
                },
                SubseparatorsPaint = new SolidColorPaint
                {
                    Color = SKColors.Black.WithAlpha(50),
                    StrokeThickness = 0.5f
                },
                SubseparatorsCount = 9,
            }
        }; 

        points.AddRange(
            new LogarithmicPoint[]
            {
                new() { X = 1, Y = 1 },
                new() { X = 2, Y = 10 },
                new() { X = 3, Y = 100 },
                new() { X = 4, Y = 1000 },
                new() { X = 5, Y = 10000 },
                new() { X = 6, Y = 100000 },
                new() { X = 7, Y = 1000000 },
                new() { X = 8, Y = 10000000 }
            });
    }
}

public class LogarithmicPoint
{
    public double X { get; set; }
    public double Y { get; set; }
}

This produces the following result:

image

Now let's replace the LogarithmicPoints with DateTimePoints:

public partial class TestView : UserControl
{
    public TestView()
    {
        InitializeComponent();

        var points = new ObservableCollection<DateTimePoint>();

        var logBase = 2;

        TestChart.Series = new[]
        {
            new LineSeries<DateTimePoint>
            {
                Mapping = (datePoint, chartPoint) =>
                {
                    chartPoint.Coordinate = !datePoint.Value.HasValue
                        ? Coordinate.Empty
                        : new Coordinate(datePoint.DateTime.Ticks, Math.Log(datePoint.Value.Value, logBase));
                },
                Values = points
            }
        };

        TestChart.YAxes = new[]
        {
            new LogaritmicAxis(logBase)
            {
                SeparatorsPaint = new SolidColorPaint
                {
                    Color = SKColors.Black.WithAlpha(100),
                    StrokeThickness = 1,
                },
                SubseparatorsPaint = new SolidColorPaint
                {
                    Color = SKColors.Black.WithAlpha(50),
                    StrokeThickness = 0.5f
                },
                SubseparatorsCount = 9,
            }
        }; 

        points.AddRange(
            new DateTimePoint[]
            {
                new() { DateTime = DateTime.Now.AddDays(1), Value = 1 },
                new() { DateTime = DateTime.Now.AddDays(2), Value = 10 },
                new() { DateTime = DateTime.Now.AddDays(3), Value = 100 },
                new() { DateTime = DateTime.Now.AddDays(4), Value = 1000 },
                new() { DateTime = DateTime.Now.AddDays(5), Value = 10000 },
                new() { DateTime = DateTime.Now.AddDays(6), Value = 100000 },
                new() { DateTime = DateTime.Now.AddDays(7), Value = 1000000 },
                new() { DateTime = DateTime.Now.AddDays(8), Value = 10000000 }
            });
    }
}

I set a breakpoint inside the Mapping action, and it is never executed. Here's the rendered result:

image

To Reproduce

Define a line series with DateTimePoint values and a mapping function

Expected behavior

So while writing this issue I digged into the source code a bit and found this:

https://github.com/beto-rodriguez/LiveCharts2/blob/5dcc732235a99033516790fa12484b4a44df57da/src/LiveChartsCore/Kernel/Providers/DataFactory.cs#L335-L340

Do I understand correctly that because DateTimePoints are "chart entities" (IChartEntity), they already "define a point with a visual representation in the user interface" and hence shouldn't be remapped? It kind of makes sense that we want to map models instead, but it's still a bit confusing that "chart entities" are just ignored by the mapping function. If my understanding is correct here, I think it would be nice if an exception was thrown if the user tries to remap chart entities.

The obvious solution to this is applying Math.Log in the constructor of DateTimePoint, or is there another way I'm unaware of?

        points.AddRange(
            new DateTimePoint[]
            {
                new(DateTime.Now.AddDays(1), Math.Log(1, logBase)),
                new(DateTime.Now.AddDays(2), Math.Log(10, logBase)),
                new(DateTime.Now.AddDays(3), Math.Log(100, logBase)),
                new(DateTime.Now.AddDays(4), Math.Log(1000, logBase)),
                new(DateTime.Now.AddDays(5), Math.Log(10000, logBase)),
                new(DateTime.Now.AddDays(6), Math.Log(100000, logBase)),
                new(DateTime.Now.AddDays(7), Math.Log(1000000, logBase)),
                new(DateTime.Now.AddDays(8), Math.Log(10000000, logBase))
            });

Desktop (please complete the following information):

beto-rodriguez commented 1 year ago

Hello

There are 2 ways to map an object to the chart coordinates, using mappers, or implementing IChartEntity.

The thing here is that DateTime point already implements IChartEntity, that is why your mapper is ignored.

The magic to use the DateTime type, is to map it to the `Ticks property:

public class LogarithmicDateTimePoint
{
    public DateTime X { get; set; }
    public double Y { get; set; }
}

// then on your mapper:
public ISeries[] Series { get; set; } =
{
    new LineSeries<LogarithmicDateTimePoint>
    {
        Mapping = (logPoint, chartPoint) =>
        {
            // USE DATETIME.TICKS
            chartPoint.Coordinate = new(logPoint.X.Ticks, Math.Log(logPoint.Y, s_logBase));
        },
        Values = new LogarithmicDateTimePoint[]
        {
            new() { X = s_start.AddDays(1), Y = 1 },
            new() { X = s_start.AddDays(2), Y = 10 },
            new() { X = s_start.AddDays(3), Y = 100 },
            new() { X = s_start.AddDays(4), Y = 1000 },
            new() { X = s_start.AddDays(5), Y = 10000 },
            new() { X = s_start.AddDays(6), Y = 100000 },
            new() { X = s_start.AddDays(7), Y = 1000000 },
            new() { X = s_start.AddDays(8), Y = 10000000 }
        }
    }
};

// Finally, you can use the `DateTimeAxis` so it handles the ticks property for you:
public Axis[] XAxes { get; set; } = new Axis[]
{
    new DateTimeAxis(TimeSpan.FromDays(1), date => date.ToString("dd MMM"))
};

And that's it!

image