docs/overview/1.5.mappers.md
You can plot anything in a chart as long as you let the library know how to handle that object. LiveCharts already supports
the types short, int, long, float, double, decimal, their nullable versions short?, int?, long?, float?,
double?, decimal?, and also some objects that are able to automatically update the changes to the UI like the
[ObservableValue](https://lvcharts.com/api/{{ version }}/LiveChartsCore.Defaults.ObservableValue),
[ObservablePoint](https://lvcharts.com/api/{{ version }}/LiveChartsCore.Defaults.ObservablePoint) (useful to specify both, X and Y),
[WeightedPoint](https://lvcharts.com/api/{{ version }}/LiveChartsCore.Defaults.WeightedPoint) (used in bubble charts),
[DateTimePoint](https://lvcharts.com/api/{{ version }}/LiveChartsCore.Defaults.DateTimePoint) (the X coordinate is of type DateTime),
[TimeSpanPoint](https://lvcharts.com/api/{{ version }}/LiveChartsCore.Defaults.TimeSpanPoint) (the X coordinate is of type TimeSpan),
[ObservablePolarPoint](https://lvcharts.com/api/{{ version }}/LiveChartsCore.Defaults.ObservablePolarPoint) (used in polar charts)
[FinancialPoint](https://lvcharts.com/api/{{ version }}/LiveChartsCore.Defaults.FinancialPoint) and
[FinancialPointI](https://lvcharts.com/api/{{ version }}/LiveChartsCore.Defaults.FinancialPointI) (to create candlestick charts).
Imagine the case where we have a JSON file that contains the temperature of a CPU at a given time, and we want to build a chart with that data.
[
{
"Time": 1,
"Temperature": 65.65,
"Unit": "Celsius"
},
{
"Time": 5,
"Temperature": 62.23,
"Unit": "Celsius"
},
{
"Time": 8,
"Temperature": 85.12,
"Unit": "Celsius"
}
]
We can read that file, and deserialize it to an array of the TempSample class.
using var streamReader = new StreamReader("data.json");
var samples = JsonSerializer.Deserialize<TempSample[]>(streamReader.ReadToEnd());
// now let's build the chart
var chart = new SKCartesianChart
{
Width = 900,
Height = 600,
Series = new[]
{
new LineSeries<TempSample>
{
Values = samples
}
},
XAxes = new[] { new Axis { Labeler = value => $"{value} seconds" } },
YAxes = new[] { new Axis { Labeler = value => $"{value} °C" } }
};
chart.SaveImage("chart.png");
The code above will throw because LiveCharts needs to know how to plot the TempSample class. We can teach LiveCharts how to handle
the TempSample class by setting a Mapper or implementing IChartEntity in our TempSample class.
Mappers are the easiest way but have a performance cost. A mapper is a function that takes both the instance
(each TempSample in our data collection) and the index of the instance in the collection as parameters,
and returns a Coordinate in the chart.
using var streamReader = new StreamReader("data.json");
var samples = JsonSerializer.Deserialize<TempSample[]>(streamReader.ReadToEnd());
// now we just build the chart
var chart = new SKCartesianChart
{
Width = 900,
Height = 600,
Series = new[]
{
new LineSeries<TempSample>
{
Values = samples,
// use the Temperature property in the Y axis // mark
// and the Time property in the X axis // mark
Mapping = (sample, index) => new(sample.Time, sample.Temperature) // mark
}
},
XAxes = new[] { new Axis { Labeler = value => $"{value} seconds" } },
YAxes = new[] { new Axis { Labeler = value => $"{value} °C" } }
};
chart.SaveImage("chart.png");
Now it works! You can also register the mapper globally, this means that every time the TempSample class is used in a
chart all over our application, the library will use the mapper we indicated.
// ideally this code must be placed where your application starts
LiveCharts.Configure(config =>
config.HasMap<TempSample>(
(sample, index) => new(sample.Time, sample.Temperature));
Global mappers are unique for a type, this means that every time a TempSample instance is in a chart, LiveCharts will use this mapper,
if you register again a global mapper for the TempSample class, then the previous will be replaced by the new one.
If the series specifies the Mapping property, then the global mapper will be ignored and instead it will use the series instance mapper.
The IChartEntity interface forces our points to have a Coordinate,
LiveCharts will use this property to build the plot, when the interface is implemented correctly, you will notice a considerable
performance improvement, specially on large data sets.
Imagine the same case we used in the previous sample where we have a json file that contains the temperature of a CPU at a given time, we want to build a chart with that data.
[
{
"Time": 1,
"Temperature": 65.65,
"Unit": "Celsius"
},
{
"Time": 5,
"Temperature": 62.23,
"Unit": "Celsius"
},
{
"Time": 8,
"Temperature": 85.12,
"Unit": "Celsius"
}
]
We need a class to deserialize the json file, we can try this:
public class TempSample
{
public int Time { get; set; }
public double Temperature { get; set; }
}
That is enough to read the data from the json file, but there are 2 things missing in that class, 1. It does not notify
the UI to update when a property changes, 2. LiveCharts doesn't know how to draw this class. We need to implement INotifyPropertyChanged
and IChartEntity to fix both issues.
To reduce the amount of boilerplate we will use the CommunityToolkit.Mvvm, it will help us to implement INotifyPropertyChanged:
public partial class TempSample : ObservableObject
{
[ObservableProperty]
private int _time;
[ObservableProperty]
private double _temperature;
}
We marked the class as partial and inherited from ObservableObject, finally we marked our fields with the ObservableProperty attribute, with these changes now the class implements INotifyPropertyChanged and also created the Time and
Temperature properties, the class is ready to notify the UI to update when it changes.
Now to implement IChartEntity we need to add 2 properties the Coordinate (the location of the point in the UI) and the MetaData (just some
information LiveCharts needs to build the chart).
public partial class TempSample : ObservableObject, IChartEntity
{
[ObservableProperty]
private int _time;
[ObservableProperty]
private double _temperature;
public Coordinate Coordinate => new(Time, Temperature); // mark
public ChartEntityMetaData? MetaData { get; set; } // mark
}
Now LiveCharts knows that we want the Time property in the X axis and the Temperature in the Y axis, we are ready to build charts with
this class. But this example is creating a new instance of the Coordinate struct every time we access the property, we can cache the coordinate
and only update the value of it when the Time or the Temperature properties change:
public partial class TempSample : ObservableObject, IChartEntity
{
[ObservableProperty]
private int _time;
[ObservableProperty]
private double _temperature;
public Coordinate Coordinate { get; protected set; } // mark
public ChartEntityMetaData? MetaData { get; set; }
protected override void OnPropertyChanged(PropertyChangedEventArgs e) // mark
{ // mark
Coordinate = new(Time, Temperature); // mark
base.OnPropertyChanged(e); // mark
} // mark
}
The change we made makes an important improvement in performance than the previous version, specially in large data sets,
This is a general implementation that might work for most of the cases, it has a good performance, but there cases where you can
simplify a lot in the implementation of IChartEntity, there are multiple ways to optimize it for performance, actually you can remove INotifyPropertyChanged interface it is not required by LiveCharts.
When LiveCharts finds a null instance or the coordinate is set to Coordinate.Empty it will skip the point:
Series = new ISeries[]
{
new ColumnSeries<double?>
{
Values = new double?[] { 5, 4, null, 3, 2, 6, 5, 6, 2 }
},
new LineSeries<double?>
{
Values = new double?[] { 2, 6, 5, 3, null, 5, 2, 4, null }
},
new LineSeries<ObservablePoint?>
{
Values = new ObservablePoint?[]
{
new ObservablePoint { X = 0, Y = 1 },
new ObservablePoint { X = 1, Y = 4 },
null,
new ObservablePoint { X = 4, Y = 5 },
new ObservablePoint { X = 6, Y = 1 },
new ObservablePoint { X = 8, Y = 6 },
}
}
};
You can also set the Coordinate to Coordinate.Empty inside a mapper, for example imagine a case where we need
to skip a point when the Y property is null, in that case we could:
using LiveChartsCore.Kernel;
Series = new ISeries[]
{
new LineSeries<City?>
{
Values = new City?[]
{
new City("London", 10),
new City("Paris", 8),
new City("Rome", null),
new City("Berlin", 7),
},
Mapping = (city, chartPoint) =>
{
chartPoint.Coordinate =
city.Population is null
? Coordinate.Empty
: new Coordinate(chartPoint.Index, city.Population.Value);
}
}
};
LiveCharts does not silently rewrite double.NaN, double.PositiveInfinity or
double.NegativeInfinity to a gap. Non-finite values pass through both the
default mappers and the built-in Defaults (ObservableValue,
ObservablePoint, ...) and end up inside the geometry's animation state, where
they prevent subsequent finite values from interpolating correctly (see
issue #1847).
This is intentional: how a non-finite value should be drawn is a domain decision.
You override the default by mapping non-finite to whichever Coordinate
represents your intent. Two common policies:
Coordinate.Empty. The point is invisible and
not tooltipable, like a null entry.new Coordinate(index, 0) so the bar collapses to a stub but the column's
hover area survives. Pair this with a YToolTipLabelFormatter that prints
"+∞" / "-∞" / "NaN" by inspecting the original value, so the user can
still see what was at that index on hover.Pick whichever fits your domain - both are valid overrides of the default pass-through behavior.
double / float values)For ad-hoc series, scope the mapper via the Mapping property:
using LiveChartsCore.Kernel;
using LiveChartsCore.SkiaSharpView;
new ColumnSeries<double>
{
Values = values,
// Policy A - treat as missing:
// Mapping = (value, index) =>
// double.IsNaN(value) || double.IsInfinity(value)
// ? Coordinate.Empty
// : new Coordinate(index, value),
// Policy B - keep visible, tooltip-friendly:
Mapping = (value, index) =>
double.IsNaN(value) || double.IsInfinity(value)
? new Coordinate(index, 0)
: new Coordinate(index, value),
// Companion to Policy B: print the original non-finite value on hover.
YToolTipLabelFormatter = p =>
double.IsPositiveInfinity(p.Model) ? "+∞"
: double.IsNegativeInfinity(p.Model) ? "-∞"
: double.IsNaN(p.Model) ? "NaN"
: p.Model.ToString()
}
Or register the policy globally so every series of that type uses it:
LiveCharts.Configure(config =>
config.HasMap<double>((value, index) =>
double.IsNaN(value) || double.IsInfinity(value)
? Coordinate.Empty // or new Coordinate(index, 0)
: new Coordinate(index, value)));
IChartEntity typesIChartEntity types compute their own Coordinate and never go through the
global mapper. Apply the same logic inside the entity's coordinate-update path:
using System.ComponentModel;
using System.Runtime.CompilerServices;
using LiveChartsCore.Kernel;
public class SafeValue : IChartEntity, INotifyPropertyChanged
{
public SafeValue() => MetaData = new ChartEntityMetaData(OnCoordinateChanged);
public SafeValue(double value) : this() => Value = value;
public double Value { get; set { field = value; OnPropertyChanged(); } }
public ChartEntityMetaData? MetaData { get; set; }
public Coordinate Coordinate { get; set; } = Coordinate.Empty;
public event PropertyChangedEventHandler? PropertyChanged;
private void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
if (MetaData is not null) OnCoordinateChanged(MetaData.EntityIndex);
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
private void OnCoordinateChanged(int index)
{
// Same two policies as the mapper recipe - swap one for the other.
Coordinate = double.IsNaN(Value) || double.IsInfinity(Value)
// ? Coordinate.Empty // Policy A
? new Coordinate(index, 0) // Policy B (tooltip-friendly)
: new Coordinate(index, Value);
}
}
For Policy B with IChartEntity, set the YToolTipLabelFormatter on the
series to format p.Model.Value the same way as the mapper recipe above.
The SpecialCasesTests.Issue1847_* snapshot tests in the repository lock the
sentinel form for both recipes.