csharpc-sharpdotnetxamlavaloniauicross-platformcross-platform-xamlavaloniaguimulti-platformuser-interfacedotnetcore
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
812 lines
24 KiB
812 lines
24 KiB
using System;
|
|
using System.Collections.Generic;
|
|
using System.Collections.Specialized;
|
|
using System.Globalization;
|
|
using System.Linq;
|
|
using System.Reactive.Disposables;
|
|
using Avalonia;
|
|
using Avalonia.Controls;
|
|
using Avalonia.Input;
|
|
using Avalonia.Interactivity;
|
|
using Avalonia.Media;
|
|
using MathNet;
|
|
|
|
namespace ControlCatalog
|
|
{
|
|
public partial class LineChart : Control
|
|
{
|
|
private readonly Dictionary<INotifyCollectionChanged, IDisposable> _collectionChangedSubscriptions;
|
|
|
|
public LineChart()
|
|
{
|
|
_collectionChangedSubscriptions = new Dictionary<INotifyCollectionChanged, IDisposable>();
|
|
|
|
AddHandler(PointerPressedEvent, PointerPressedHandler, RoutingStrategies.Tunnel);
|
|
AddHandler(PointerReleasedEvent, PointerReleasedHandler, RoutingStrategies.Tunnel);
|
|
AddHandler(PointerMovedEvent, PointerMovedHandler, RoutingStrategies.Tunnel);
|
|
AddHandler(PointerLeaveEvent, PointerLeaveHandler, RoutingStrategies.Tunnel);
|
|
}
|
|
|
|
private static double Clamp(double val, double min, double max)
|
|
{
|
|
return Math.Min(Math.Max(val, min), max);
|
|
}
|
|
|
|
private static Geometry CreateFillGeometry(IReadOnlyList<Point> points, double width, double height)
|
|
{
|
|
var geometry = new StreamGeometry();
|
|
using var context = geometry.Open();
|
|
context.BeginFigure(points[0], true);
|
|
for (var i = 1; i < points.Count; i++)
|
|
{
|
|
context.LineTo(points[i]);
|
|
}
|
|
|
|
context.LineTo(new Point(width, height));
|
|
context.LineTo(new Point(0, height));
|
|
context.EndFigure(true);
|
|
return geometry;
|
|
}
|
|
|
|
private static Geometry CreateStrokeGeometry(IReadOnlyList<Point> points)
|
|
{
|
|
var geometry = new StreamGeometry();
|
|
using var context = geometry.Open();
|
|
context.BeginFigure(points[0], false);
|
|
for (var i = 1; i < points.Count; i++)
|
|
{
|
|
context.LineTo(points[i]);
|
|
}
|
|
|
|
context.EndFigure(false);
|
|
return geometry;
|
|
}
|
|
|
|
private static FormattedText CreateFormattedText(string text, Typeface typeface, TextAlignment alignment,
|
|
double fontSize, Size constraint)
|
|
{
|
|
return new FormattedText()
|
|
{
|
|
Typeface = typeface,
|
|
Text = text,
|
|
TextAlignment = alignment,
|
|
TextWrapping = TextWrapping.NoWrap,
|
|
FontSize = fontSize,
|
|
Constraint = constraint
|
|
};
|
|
}
|
|
|
|
private void UpdateXAxisCursorPosition(double x)
|
|
{
|
|
var xAxisValues = XAxisValues;
|
|
if (xAxisValues is null || xAxisValues.Count == 0)
|
|
{
|
|
XAxisCurrentValue = double.NaN;
|
|
return;
|
|
}
|
|
|
|
var areaWidth = Bounds.Width - AreaMargin.Left - AreaMargin.Right;
|
|
var value = Clamp(x - AreaMargin.Left, 0, areaWidth);
|
|
var factor = value / areaWidth;
|
|
var index = (int)((xAxisValues.Count - 1) * factor);
|
|
var currentValue = xAxisValues[index];
|
|
XAxisCurrentValue = currentValue;
|
|
}
|
|
|
|
private Rect? GetXAxisCursorHitTestRect()
|
|
{
|
|
var chartWidth = Bounds.Width;
|
|
var chartHeight = Bounds.Height;
|
|
var areaMargin = AreaMargin;
|
|
var areaWidth = chartWidth - areaMargin.Left - areaMargin.Right;
|
|
var areaHeight = chartHeight - areaMargin.Top - areaMargin.Bottom;
|
|
var areaRect = new Rect(areaMargin.Left, areaMargin.Top, areaWidth, areaHeight);
|
|
var cursorPosition = GetCursorPosition(areaWidth);
|
|
if (double.IsNaN(cursorPosition))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var cursorHitTestSize = 5;
|
|
var cursorStrokeThickness = CursorStrokeThickness;
|
|
var cursorHitTestRect = new Rect(
|
|
areaMargin.Left + cursorPosition - cursorHitTestSize + cursorStrokeThickness / 2,
|
|
areaRect.Top,
|
|
cursorHitTestSize + cursorHitTestSize,
|
|
areaRect.Height);
|
|
return cursorHitTestRect;
|
|
}
|
|
|
|
private void PointerLeaveHandler(object? sender, PointerEventArgs e)
|
|
{
|
|
Cursor = new Cursor(StandardCursorType.Arrow);
|
|
}
|
|
|
|
private void PointerMovedHandler(object? sender, PointerEventArgs e)
|
|
{
|
|
var position = e.GetPosition(this);
|
|
if (_captured)
|
|
{
|
|
UpdateXAxisCursorPosition(position.X);
|
|
}
|
|
else
|
|
{
|
|
if (CursorStroke is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var cursorHitTestRect = GetXAxisCursorHitTestRect();
|
|
var cursorSizeWestEast = cursorHitTestRect is not null && cursorHitTestRect.Value.Contains(position);
|
|
Cursor = cursorSizeWestEast
|
|
? new Cursor(StandardCursorType.SizeWestEast)
|
|
: new Cursor(StandardCursorType.Arrow);
|
|
}
|
|
}
|
|
|
|
private void PointerReleasedHandler(object? sender, PointerReleasedEventArgs e)
|
|
{
|
|
if (!_captured)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var position = e.GetPosition(this);
|
|
var cursorHitTestRect = GetXAxisCursorHitTestRect();
|
|
var cursorSizeWestEast = cursorHitTestRect is not null && cursorHitTestRect.Value.Contains(position);
|
|
if (!cursorSizeWestEast)
|
|
{
|
|
Cursor = new Cursor(StandardCursorType.Arrow);
|
|
}
|
|
|
|
_captured = false;
|
|
}
|
|
|
|
private void PointerPressedHandler(object? sender, PointerPressedEventArgs e)
|
|
{
|
|
var position = e.GetPosition(this);
|
|
UpdateXAxisCursorPosition(position.X);
|
|
Cursor = new Cursor(StandardCursorType.SizeWestEast);
|
|
_captured = true;
|
|
}
|
|
|
|
private LineChartState CreateChartState(double width, double height)
|
|
{
|
|
var state = new LineChartState
|
|
{
|
|
ChartWidth = width,
|
|
ChartHeight = height,
|
|
AreaMargin = AreaMargin
|
|
};
|
|
|
|
state.AreaWidth = width - state.AreaMargin.Left - state.AreaMargin.Right;
|
|
state.AreaHeight = height - state.AreaMargin.Top - state.AreaMargin.Bottom;
|
|
|
|
SetStateAreaPoints(state);
|
|
|
|
SetStateXAxisLabels(state);
|
|
SetStateYAxisLabels(state);
|
|
|
|
SetStateXAxisCursor(state);
|
|
|
|
return state;
|
|
}
|
|
|
|
private static IEnumerable<(double x, double y)> SplineInterpolate(double[] xs, double[] ys)
|
|
{
|
|
const int Divisions = 256;
|
|
|
|
if (xs.Length > 2)
|
|
{
|
|
var spline = CubicSpline.InterpolatePchipSorted(xs, ys);
|
|
|
|
for (var i = 0; i < xs.Length - 1; i++)
|
|
{
|
|
var a = xs[i];
|
|
var b = xs[i + 1];
|
|
var range = b - a;
|
|
var step = range / Divisions;
|
|
|
|
var t0 = xs[i];
|
|
|
|
var xt0 = spline.Interpolate(xs[i]);
|
|
|
|
yield return (t0, xt0);
|
|
|
|
for (var t = a + step; t < b; t += step)
|
|
{
|
|
var xt = spline.Interpolate(t);
|
|
|
|
yield return (t, xt);
|
|
}
|
|
}
|
|
|
|
var tn = xs[xs.Length - 1];
|
|
var xtn = spline.Interpolate(xs[xs.Length - 1]);
|
|
|
|
yield return (tn, xtn);
|
|
}
|
|
else
|
|
{
|
|
for (var i = 0; i < xs.Length; i++)
|
|
{
|
|
yield return (xs[i], ys[i]);
|
|
}
|
|
}
|
|
}
|
|
|
|
private void SetStateAreaPoints(LineChartState state)
|
|
{
|
|
var xAxisValues = XAxisValues;
|
|
var yAxisValues = YAxisValues;
|
|
|
|
if (xAxisValues is null || xAxisValues.Count <= 1 || yAxisValues is null || yAxisValues.Count <= 1)
|
|
{
|
|
state.XAxisLabelStep = double.NaN;
|
|
state.YAxisLabelStep = double.NaN;
|
|
state.Points = null;
|
|
return;
|
|
}
|
|
|
|
var logarithmicScale = YAxisLogarithmicScale;
|
|
|
|
var yAxisValuesLogScaled = logarithmicScale
|
|
? yAxisValues.Select(y => Math.Log(y)).ToList()
|
|
: yAxisValues.ToList();
|
|
|
|
var yAxisValuesLogScaledMax = yAxisValuesLogScaled.Max();
|
|
|
|
var yAxisScaler = new StraightLineFormula();
|
|
yAxisScaler.CalculateFrom(yAxisValuesLogScaledMax, 0, 0, state.AreaHeight);
|
|
|
|
var yAxisValuesScaled = yAxisValuesLogScaled
|
|
.Select(y => yAxisScaler.GetYforX(y))
|
|
.ToList();
|
|
|
|
var xAxisValuesEnumerable = xAxisValues as IEnumerable<double>;
|
|
|
|
switch (XAxisPlotMode)
|
|
{
|
|
case AxisPlotMode.Normal:
|
|
var min = XAxisMinimum ?? xAxisValues.Min();
|
|
var max = xAxisValues.Max();
|
|
|
|
var xAxisScaler = new StraightLineFormula();
|
|
xAxisScaler.CalculateFrom(min, max, 0, state.AreaWidth);
|
|
|
|
xAxisValuesEnumerable = xAxisValuesEnumerable.Select(x => xAxisScaler.GetYforX(x));
|
|
break;
|
|
|
|
case AxisPlotMode.EvenlySpaced:
|
|
var pointStep = state.AreaWidth / (xAxisValues.Count - 1);
|
|
|
|
xAxisValuesEnumerable = Enumerable.Range(0, xAxisValues.Count).Select(x => pointStep * x);
|
|
break;
|
|
|
|
case AxisPlotMode.Logarithmic:
|
|
break;
|
|
}
|
|
|
|
if (SmoothCurve)
|
|
{
|
|
var interpolated = SplineInterpolate(xAxisValuesEnumerable.ToArray(), yAxisValuesScaled.ToArray());
|
|
|
|
state.Points = interpolated.Select(pt => new Point(pt.x, pt.y)).ToArray();
|
|
}
|
|
else
|
|
{
|
|
state.Points = new Point[xAxisValues.Count];
|
|
|
|
using (var enumerator = xAxisValuesEnumerable.GetEnumerator())
|
|
{
|
|
for (var i = 0; i < yAxisValuesScaled.Count; i++)
|
|
{
|
|
enumerator.MoveNext();
|
|
state.Points[i] = new Point(enumerator.Current, yAxisValuesScaled[i]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private void SetStateXAxisLabels(LineChartState state)
|
|
{
|
|
var xAxisLabels = XAxisLabels;
|
|
|
|
if (xAxisLabels is not null)
|
|
{
|
|
state.XAxisLabelStep = xAxisLabels.Count <= 1
|
|
? double.NaN
|
|
: state.AreaWidth / (xAxisLabels.Count - 1);
|
|
|
|
state.XAxisLabels = xAxisLabels.ToList();
|
|
}
|
|
else
|
|
{
|
|
AutoGenerateXAxisLabels(state);
|
|
}
|
|
}
|
|
|
|
private void SetStateYAxisLabels(LineChartState state)
|
|
{
|
|
var yAxisLabels = YAxisLabels;
|
|
|
|
if (yAxisLabels is not null)
|
|
{
|
|
state.YAxisLabelStep = yAxisLabels.Count <= 1
|
|
? double.NaN
|
|
: state.AreaHeight / (yAxisLabels.Count - 1);
|
|
|
|
state.YAxisLabels = yAxisLabels.ToList();
|
|
}
|
|
else
|
|
{
|
|
AutoGenerateYAxisLabels(state);
|
|
}
|
|
}
|
|
|
|
private void AutoGenerateXAxisLabels(LineChartState state)
|
|
{
|
|
var xAxisValues = XAxisValues;
|
|
|
|
state.XAxisLabelStep = xAxisValues is null || xAxisValues.Count <= 1
|
|
? double.NaN
|
|
: state.AreaWidth / (xAxisValues.Count - 1);
|
|
|
|
if (XAxisStroke is not null && XAxisValues is not null)
|
|
{
|
|
state.XAxisLabels = XAxisValues.Select(x => x.ToString(CultureInfo.InvariantCulture)).ToList();
|
|
}
|
|
}
|
|
|
|
private void AutoGenerateYAxisLabels(LineChartState state)
|
|
{
|
|
var yAxisValues = YAxisValues;
|
|
|
|
state.YAxisLabelStep = yAxisValues is null || yAxisValues.Count <= 1
|
|
? double.NaN
|
|
: state.AreaHeight / (yAxisValues.Count - 1);
|
|
|
|
if (YAxisStroke is not null && YAxisValues is not null)
|
|
{
|
|
state.YAxisLabels = YAxisValues.Select(x => x.ToString(CultureInfo.InvariantCulture)).ToList();
|
|
}
|
|
}
|
|
|
|
private double GetCursorPosition(double areaWidth)
|
|
{
|
|
var xAxisCurrentValue = XAxisCurrentValue;
|
|
var xAxisValues = XAxisValues;
|
|
if (double.IsNaN(xAxisCurrentValue) || xAxisValues is null || xAxisValues.Count == 0)
|
|
{
|
|
return double.NaN;
|
|
}
|
|
|
|
for (var i = 0; i < xAxisValues.Count; i++)
|
|
{
|
|
if (xAxisValues[i] <= xAxisCurrentValue)
|
|
{
|
|
return areaWidth / xAxisValues.Count * i;
|
|
}
|
|
}
|
|
|
|
return double.NaN;
|
|
}
|
|
|
|
private void SetStateXAxisCursor(LineChartState state)
|
|
{
|
|
state.XAxisCursorPosition = GetCursorPosition(state.AreaWidth);
|
|
}
|
|
|
|
private void DrawAreaFill(DrawingContext context, LineChartState state)
|
|
{
|
|
var brush = AreaFill;
|
|
if (brush is null
|
|
|| state.Points is null
|
|
|| state.AreaWidth <= 0
|
|
|| state.AreaHeight <= 0
|
|
|| state.AreaWidth < AreaMinViableWidth
|
|
|| state.AreaHeight < AreaMinViableHeight)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var deflate = 0.5;
|
|
var geometry = CreateFillGeometry(state.Points, state.AreaWidth, state.AreaHeight);
|
|
var transform = context.PushPreTransform(
|
|
Matrix.CreateTranslation(
|
|
state.AreaMargin.Left + deflate,
|
|
state.AreaMargin.Top + deflate));
|
|
context.DrawGeometry(brush, null, geometry);
|
|
transform.Dispose();
|
|
}
|
|
|
|
private void DrawAreaStroke(DrawingContext context, LineChartState state)
|
|
{
|
|
var brush = AreaStroke;
|
|
if (brush is null
|
|
|| state.Points is null
|
|
|| state.AreaWidth <= 0
|
|
|| state.AreaHeight <= 0
|
|
|| state.AreaWidth < AreaMinViableWidth
|
|
|| state.AreaHeight < AreaMinViableHeight)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var thickness = AreaStrokeThickness;
|
|
var dashStyle = AreaStrokeDashStyle;
|
|
var lineCap = AreaStrokeLineCap;
|
|
var lineJoin = AreaStrokeLineJoin;
|
|
var miterLimit = AreaStrokeMiterLimit;
|
|
var pen = new Pen(brush, thickness, dashStyle, lineCap, lineJoin, miterLimit);
|
|
var deflate = thickness * 0.5;
|
|
var geometry = CreateStrokeGeometry(state.Points);
|
|
var transform = context.PushPreTransform(
|
|
Matrix.CreateTranslation(
|
|
state.AreaMargin.Left + deflate,
|
|
state.AreaMargin.Top + deflate));
|
|
context.DrawGeometry(null, pen, geometry);
|
|
transform.Dispose();
|
|
}
|
|
|
|
private void DrawCursor(DrawingContext context, LineChartState state)
|
|
{
|
|
var brush = CursorStroke;
|
|
if (brush is null
|
|
|| double.IsNaN(state.XAxisCursorPosition)
|
|
|| state.AreaWidth <= 0
|
|
|| state.AreaHeight <= 0
|
|
|| state.AreaWidth < AreaMinViableWidth
|
|
|| state.AreaHeight < AreaMinViableHeight)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var thickness = CursorStrokeThickness;
|
|
var dashStyle = CursorStrokeDashStyle;
|
|
var lineCap = CursorStrokeLineCap;
|
|
var lineJoin = CursorStrokeLineJoin;
|
|
var miterLimit = CursorStrokeMiterLimit;
|
|
var pen = new Pen(brush, thickness, dashStyle, lineCap, lineJoin, miterLimit);
|
|
var deflate = thickness * 0.5;
|
|
var p1 = new Point(state.XAxisCursorPosition + deflate, 0);
|
|
var p2 = new Point(state.XAxisCursorPosition + deflate, state.AreaHeight);
|
|
var transform = context.PushPreTransform(
|
|
Matrix.CreateTranslation(
|
|
state.AreaMargin.Left,
|
|
state.AreaMargin.Top));
|
|
context.DrawLine(pen, p1, p2);
|
|
transform.Dispose();
|
|
}
|
|
|
|
private void DrawXAxis(DrawingContext context, LineChartState state)
|
|
{
|
|
var brush = XAxisStroke;
|
|
if (brush is null
|
|
|| state.AreaWidth <= 0
|
|
|| state.AreaHeight <= 0
|
|
|| state.AreaWidth < XAxisMinViableWidth
|
|
|| state.AreaHeight < XAxisMinViableHeight)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var size = XAxisArrowSize;
|
|
var opacity = XAxisOpacity;
|
|
var thickness = XAxisStrokeThickness;
|
|
var pen = new Pen(brush, thickness, null, PenLineCap.Round);
|
|
var deflate = thickness * 0.5;
|
|
var offset = XAxisOffset;
|
|
var p1 = new Point(
|
|
state.AreaMargin.Left + offset.X,
|
|
state.AreaMargin.Top + state.AreaHeight + offset.Y + deflate);
|
|
var p2 = new Point(
|
|
state.AreaMargin.Left + state.AreaWidth,
|
|
state.AreaMargin.Top + state.AreaHeight + offset.Y + deflate);
|
|
var opacityState = context.PushOpacity(opacity);
|
|
context.DrawLine(pen, p1, p2);
|
|
var p3 = new Point(p2.X, p2.Y);
|
|
var p4 = new Point(p2.X - size, p2.Y - size);
|
|
context.DrawLine(pen, p3, p4);
|
|
var p5 = new Point(p2.X, p2.Y);
|
|
var p6 = new Point(p2.X - size, p2.Y + size);
|
|
context.DrawLine(pen, p5, p6);
|
|
opacityState.Dispose();
|
|
}
|
|
|
|
private void DrawXAxisLabels(DrawingContext context, LineChartState state)
|
|
{
|
|
var foreground = XAxisLabelForeground;
|
|
if (foreground is null
|
|
|| state.XAxisLabels is null
|
|
|| double.IsNaN(state.XAxisLabelStep)
|
|
|| state.ChartWidth <= 0
|
|
|| state.ChartHeight <= 0
|
|
|| state.ChartHeight - state.AreaMargin.Top < state.AreaMargin.Bottom)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var opacity = XAxisLabelOpacity;
|
|
var fontFamily = XAxisLabelFontFamily;
|
|
var fontStyle = XAxisLabelFontStyle;
|
|
var fontWeight = XAxisLabelFontWeight;
|
|
var typeface = new Typeface(fontFamily, fontStyle, fontWeight);
|
|
var fontSize = XAxisLabelFontSize;
|
|
var offset = XAxisLabelOffset;
|
|
var angleRadians = Math.PI / 180.0 * XAxisLabelAngle;
|
|
var alignment = XAxisLabelAlignment;
|
|
var originTop = state.AreaMargin.Top + state.AreaHeight;
|
|
var formattedTextLabels = new List<FormattedText>();
|
|
var constrainWidthMax = 0.0;
|
|
var constrainHeightMax = 0.0;
|
|
|
|
foreach (var label in state.XAxisLabels)
|
|
{
|
|
var formattedText = CreateFormattedText(label, typeface, alignment, fontSize, Size.Empty);
|
|
formattedTextLabels.Add(formattedText);
|
|
constrainWidthMax = Math.Max(constrainWidthMax, formattedText.Bounds.Width);
|
|
constrainHeightMax = Math.Max(constrainHeightMax, formattedText.Bounds.Height);
|
|
}
|
|
|
|
var constraintMax = new Size(constrainWidthMax, constrainHeightMax);
|
|
var offsetTransform = context.PushPreTransform(Matrix.CreateTranslation(offset.X, offset.Y));
|
|
|
|
for (var i = 0; i < formattedTextLabels.Count; i++)
|
|
{
|
|
formattedTextLabels[i].Constraint = constraintMax;
|
|
|
|
var origin = new Point(i * state.XAxisLabelStep - constraintMax.Width / 2 + state.AreaMargin.Left,
|
|
originTop);
|
|
var offsetCenter = new Point(constraintMax.Width / 2 - constraintMax.Width / 2, 0);
|
|
var xPosition = origin.X + constraintMax.Width / 2;
|
|
var yPosition = origin.Y + constraintMax.Height / 2;
|
|
var matrix = Matrix.CreateTranslation(-xPosition, -yPosition)
|
|
* Matrix.CreateRotation(angleRadians)
|
|
* Matrix.CreateTranslation(xPosition, yPosition);
|
|
var labelTransform = context.PushPreTransform(matrix);
|
|
var opacityState = context.PushOpacity(opacity);
|
|
context.DrawText(foreground, origin + offsetCenter, formattedTextLabels[i]);
|
|
opacityState.Dispose();
|
|
labelTransform.Dispose();
|
|
}
|
|
|
|
offsetTransform.Dispose();
|
|
}
|
|
|
|
private void DrawXAxisTitle(DrawingContext context, LineChartState state)
|
|
{
|
|
// TODO: Draw XAxis title.
|
|
}
|
|
|
|
private void DrawYAxis(DrawingContext context, LineChartState state)
|
|
{
|
|
var brush = YAxisStroke;
|
|
if (brush is null
|
|
|| state.AreaWidth <= 0
|
|
|| state.AreaHeight <= 0
|
|
|| state.AreaWidth < YAxisMinViableWidth
|
|
|| state.AreaHeight < YAxisMinViableHeight)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var size = YAxisArrowSize;
|
|
var opacity = YAxisOpacity;
|
|
var thickness = YAxisStrokeThickness;
|
|
var pen = new Pen(brush, thickness, null, PenLineCap.Round);
|
|
var deflate = thickness * 0.5;
|
|
var offset = YAxisOffset;
|
|
var p1 = new Point(
|
|
state.AreaMargin.Left + offset.X + deflate,
|
|
state.AreaMargin.Top);
|
|
var p2 = new Point(
|
|
state.AreaMargin.Left + offset.X + deflate,
|
|
state.AreaMargin.Top + state.AreaHeight + offset.Y);
|
|
var opacityState = context.PushOpacity(opacity);
|
|
context.DrawLine(pen, p1, p2);
|
|
var p3 = new Point(p1.X, p1.Y);
|
|
var p4 = new Point(p1.X - size, p1.Y + size);
|
|
context.DrawLine(pen, p3, p4);
|
|
var p5 = new Point(p1.X, p1.Y);
|
|
var p6 = new Point(p1.X + size, p1.Y + size);
|
|
context.DrawLine(pen, p5, p6);
|
|
opacityState.Dispose();
|
|
}
|
|
|
|
private void DrawYAxisLabels(DrawingContext context, LineChartState state)
|
|
{
|
|
var foreground = YAxisLabelForeground;
|
|
if (foreground is null
|
|
|| state.YAxisLabels is null
|
|
|| double.IsNaN(state.YAxisLabelStep)
|
|
|| state.ChartWidth <= 0
|
|
|| state.ChartWidth - state.AreaMargin.Right < state.AreaMargin.Left
|
|
|| state.ChartHeight <= 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var opacity = YAxisLabelOpacity;
|
|
var fontFamily = YAxisLabelFontFamily;
|
|
var fontStyle = YAxisLabelFontStyle;
|
|
var fontWeight = YAxisLabelFontWeight;
|
|
var typeface = new Typeface(fontFamily, fontStyle, fontWeight);
|
|
var fontSize = YAxisLabelFontSize;
|
|
var offset = YAxisLabelOffset;
|
|
var angleRadians = Math.PI / 180.0 * YAxisLabelAngle;
|
|
var alignment = YAxisLabelAlignment;
|
|
var originLeft = state.AreaMargin.Left;
|
|
var formattedTextLabels = new List<FormattedText>();
|
|
var constrainWidthMax = 0.0;
|
|
var constrainHeightMax = 0.0;
|
|
|
|
for (var index = state.YAxisLabels.Count - 1; index >= 0; index--)
|
|
{
|
|
var label = state.YAxisLabels[index];
|
|
var formattedText = CreateFormattedText(label, typeface, alignment, fontSize, Size.Empty);
|
|
formattedTextLabels.Add(formattedText);
|
|
constrainWidthMax = Math.Max(constrainWidthMax, formattedText.Bounds.Width);
|
|
constrainHeightMax = Math.Max(constrainHeightMax, formattedText.Bounds.Height);
|
|
}
|
|
|
|
var constraintMax = new Size(constrainWidthMax, constrainHeightMax);
|
|
var offsetTransform = context.PushPreTransform(Matrix.CreateTranslation(offset.X, offset.Y));
|
|
|
|
for (var i = 0; i < formattedTextLabels.Count; i++)
|
|
{
|
|
formattedTextLabels[i].Constraint = constraintMax;
|
|
|
|
var origin = new Point(originLeft - constraintMax.Width,
|
|
i * state.YAxisLabelStep - constraintMax.Height / 2 + state.AreaMargin.Top);
|
|
var offsetCenter = new Point(constraintMax.Width / 2 - constraintMax.Width / 2, 0);
|
|
var xPosition = origin.X + constraintMax.Width / 2;
|
|
var yPosition = origin.Y + constraintMax.Height / 2;
|
|
var matrix = Matrix.CreateTranslation(-xPosition, -yPosition)
|
|
* Matrix.CreateRotation(angleRadians)
|
|
* Matrix.CreateTranslation(xPosition, yPosition);
|
|
var labelTransform = context.PushPreTransform(matrix);
|
|
var opacityState = context.PushOpacity(opacity);
|
|
context.DrawText(foreground, origin + offsetCenter, formattedTextLabels[i]);
|
|
opacityState.Dispose();
|
|
labelTransform.Dispose();
|
|
}
|
|
|
|
offsetTransform.Dispose();
|
|
}
|
|
|
|
private void DrawYAxisTitle(DrawingContext context, LineChartState state)
|
|
{
|
|
var foreground = YAxisTitleForeground;
|
|
if (foreground is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (state.AreaWidth <= 0
|
|
|| state.AreaHeight <= 0
|
|
|| state.AreaWidth < YAxisMinViableWidth
|
|
|| state.AreaHeight < YAxisMinViableHeight)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var opacity = YAxisTitleOpacity;
|
|
var fontFamily = YAxisTitleFontFamily;
|
|
var fontStyle = YAxisTitleFontStyle;
|
|
var fontWeight = YAxisTitleFontWeight;
|
|
var typeface = new Typeface(fontFamily, fontStyle, fontWeight);
|
|
var fontSize = YAxisTitleFontSize;
|
|
var offset = YAxisTitleOffset;
|
|
var size = YAxisTitleSize;
|
|
var angleRadians = Math.PI / 180.0 * YAxisTitleAngle;
|
|
var alignment = YAxisTitleAlignment;
|
|
var offsetTransform = context.PushPreTransform(Matrix.CreateTranslation(offset.X, offset.Y));
|
|
var origin = new Point(state.AreaMargin.Left, state.AreaHeight + state.AreaMargin.Top);
|
|
var constraint = new Size(size.Width, size.Height);
|
|
var formattedText = CreateFormattedText(YAxisTitle, typeface, alignment, fontSize, constraint);
|
|
var xPosition = origin.X + size.Width / 2;
|
|
var yPosition = origin.Y + size.Height / 2;
|
|
var matrix = Matrix.CreateTranslation(-xPosition, -yPosition)
|
|
* Matrix.CreateRotation(angleRadians)
|
|
* Matrix.CreateTranslation(xPosition, yPosition);
|
|
var labelTransform = context.PushPreTransform(matrix);
|
|
var offsetCenter = new Point(0, size.Height / 2 - formattedText.Bounds.Height / 2);
|
|
var opacityState = context.PushOpacity(opacity);
|
|
context.DrawText(foreground, origin + offsetCenter, formattedText);
|
|
opacityState.Dispose();
|
|
labelTransform.Dispose();
|
|
offsetTransform.Dispose();
|
|
}
|
|
|
|
private void DrawBorder(DrawingContext context, LineChartState state)
|
|
{
|
|
var brush = BorderBrush;
|
|
if (brush is null || state.AreaWidth <= 0 || state.AreaHeight <= 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var thickness = BorderThickness;
|
|
var radiusX = BorderRadiusX;
|
|
var radiusY = BorderRadiusY;
|
|
var pen = new Pen(brush, thickness, null, PenLineCap.Round);
|
|
var rect = new Rect(0, 0, state.ChartWidth, state.ChartHeight);
|
|
var rectDeflate = rect.Deflate(thickness * 0.5);
|
|
context.DrawRectangle(Brushes.Transparent, pen, rectDeflate, radiusX, radiusY);
|
|
}
|
|
|
|
private void UpdateSubscription(INotifyCollectionChanged? oldValue, INotifyCollectionChanged? newValue)
|
|
{
|
|
if (oldValue is { } && _collectionChangedSubscriptions.ContainsKey(oldValue))
|
|
{
|
|
_collectionChangedSubscriptions[oldValue].Dispose();
|
|
_collectionChangedSubscriptions.Remove(oldValue);
|
|
}
|
|
|
|
if (newValue is { })
|
|
{
|
|
newValue.CollectionChanged += ItemsPropertyCollectionChanged;
|
|
|
|
_collectionChangedSubscriptions[newValue] = Disposable.Create(() =>
|
|
{
|
|
newValue.CollectionChanged -= ItemsPropertyCollectionChanged;
|
|
});
|
|
}
|
|
}
|
|
|
|
private void ItemsPropertyCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
|
{
|
|
InvalidateVisual();
|
|
}
|
|
|
|
protected override void OnPropertyChanged<T>(AvaloniaPropertyChangedEventArgs<T> change)
|
|
{
|
|
base.OnPropertyChanged(change);
|
|
|
|
if (change.Property == XAxisValuesProperty || change.Property == YAxisValuesProperty ||
|
|
change.Property == XAxisLabelsProperty || change.Property == YAxisLabelsProperty)
|
|
{
|
|
UpdateSubscription(
|
|
change.OldValue.GetValueOrDefault<INotifyCollectionChanged>(),
|
|
change.NewValue.GetValueOrDefault<INotifyCollectionChanged>());
|
|
}
|
|
}
|
|
|
|
public override void Render(DrawingContext context)
|
|
{
|
|
base.Render(context);
|
|
|
|
var state = CreateChartState(Bounds.Width, Bounds.Height);
|
|
|
|
DrawAreaFill(context, state);
|
|
DrawAreaStroke(context, state);
|
|
DrawCursor(context, state);
|
|
|
|
DrawXAxis(context, state);
|
|
DrawXAxisTitle(context, state);
|
|
DrawXAxisLabels(context, state);
|
|
|
|
DrawYAxis(context, state);
|
|
DrawYAxisTitle(context, state);
|
|
DrawYAxisLabels(context, state);
|
|
|
|
DrawBorder(context, state);
|
|
}
|
|
|
|
private class LineChartState
|
|
{
|
|
public double ChartWidth { get; set; }
|
|
public double ChartHeight { get; set; }
|
|
public double AreaWidth { get; set; }
|
|
public double AreaHeight { get; set; }
|
|
public Thickness AreaMargin { get; set; }
|
|
public Point[]? Points { get; set; }
|
|
public List<string>? XAxisLabels { get; set; }
|
|
public double XAxisLabelStep { get; set; }
|
|
public List<string>? YAxisLabels { get; set; }
|
|
public double YAxisLabelStep { get; set; }
|
|
public double XAxisCursorPosition { get; set; }
|
|
}
|
|
}
|
|
}
|
|
|