So far everything is good other than the ToolTip
of the LineChart
:
so when I get out the LineChart view, in the middle, that ToolTip remains open most of the time! It's, probably, because of the collision detection logic in onPointerIsOver
method of Circle
class. To reproduce the problem add Pointer
, Circle
, LineTip
, LineStream
and LineChart
classes in your project. Here is Pointer
:
class Pointer : UIElement
{
Pen pen;
Point start, end;
public static event Action<double> Moved;
public Pointer() {
pen = new Pen(Brushes.Blue, 1) { DashStyle = DashStyles.DashDotDot, DashCap = PenLineCap.Round };
start = new Point();
end = new Point();
IsHitTestVisible = false;
}
public void SetPointer(double x1, double y2, double labelWidth) {
start.X = end.X = x1;
start.Y = labelWidth;
end.Y = y2;
InvalidateVisual();
Moved(x1);
}
protected override void OnRender(DrawingContext drawingContext) => drawingContext.DrawLine(pen, start, end);
}
here is the Circle
:
class Circle : FrameworkElement
{
public int value;
int radius;
Path path;
EllipseGeometry circle;
RadialGradientBrush radialBrush;
GradientStop firstStop;
DoubleAnimation gradStopAnim;
DoubleAnimation scaleAnim;
double leftBound, rightBound;
LineTip tip;
public Circle(int value) {
this.value = value;
tip = new LineTip(value);
radius = 8;
firstStop = new GradientStop(Colors.CornflowerBlue, 0);
radialBrush = new RadialGradientBrush() {
GradientOrigin = new Point(0.5, 0.5),
GradientStops = {
firstStop,
new GradientStop(Colors.Coral, 1)
}
};
circle = new EllipseGeometry() { RadiusX = radius, RadiusY = radius };
path = new Path() {
Fill = radialBrush,
Data = circle
};
gradStopAnim = new DoubleAnimation() {
BeginTime = TimeSpan.FromSeconds(0.5),
Duration = TimeSpan.FromSeconds(1),
EasingFunction = new CubicEase() { EasingMode = EasingMode.EaseInOut }
};
scaleAnim = new DoubleAnimation() {
Duration = TimeSpan.FromSeconds(1),
EasingFunction = new CubicEase() { EasingMode = EasingMode.EaseInOut }
};
RenderTransform = new ScaleTransform();
Loaded += appear;
Pointer.Moved += onPointerIsOver;
Unloaded += unsubscribe;
}
void appear(object sender, RoutedEventArgs e) {
var anim = new DoubleAnimation() {
BeginTime = TimeSpan.FromSeconds(2),
Duration = TimeSpan.FromSeconds(1),
To = 0.5,
EasingFunction = new CubicEase() { EasingMode = EasingMode.EaseInOut }
};
RenderTransform.BeginAnimation(ScaleTransform.ScaleXProperty, anim);
RenderTransform.BeginAnimation(ScaleTransform.ScaleYProperty, anim);
}
void onPointerIsOver(double x) {
if (x >= leftBound && x <= rightBound) {
tip.IsOpen = true;
if (gradStopAnim.To != 1) {
gradStopAnim.To = scaleAnim.To = 1;
animate();
}
}
else {
tip.IsOpen = false;
if (gradStopAnim.To == 1) {
gradStopAnim.To = 0.25;
scaleAnim.To = 0.5;
animate();
}
}
}
void unsubscribe(object sender, RoutedEventArgs e) {
Loaded -= appear;
Pointer.Moved -= onPointerIsOver;
Unloaded -= unsubscribe;
}
void animate() {
firstStop.BeginAnimation(GradientStop.OffsetProperty, gradStopAnim);
RenderTransform.BeginAnimation(ScaleTransform.ScaleXProperty, scaleAnim);
RenderTransform.BeginAnimation(ScaleTransform.ScaleYProperty, scaleAnim);
}
public void SetCenter(Point center) {
circle.Center = center;
leftBound = center.X - radius;
rightBound = center.X + radius;
var transform = RenderTransform as ScaleTransform;
transform.ScaleX = transform.ScaleY = 0;
transform.CenterX = center.X;
transform.CenterY = center.Y;
InvalidateVisual();
}
protected override void OnRender(DrawingContext dc) => dc.DrawGeometry(radialBrush, null, path.Data);
}
In the Circle
I've an instance of LineTip
, here it is:
class LineTip : ToolTip
{
public LineTip(int value) {
BorderBrush = null;
Effect = null;
Background = null;
HasDropShadow = false;
var header = new TextBlock() {
FontSize = 16,
Foreground = Brushes.Gray,
Text = "Header"
};
var divider = new Separator() { Background = Brushes.LightGray };
var info = new Grid() {
Children = {
new TextBlock(){Text = "Value is: "},
new TextBlock(){
Text = value.ToString("N0"),
HorizontalAlignment = HorizontalAlignment.Right
}
}
};
Grid.SetRow(divider, 1);
Grid.SetRow(info, 2);
Content = new Border() {
MinWidth = 175,
Background = Brushes.White,
Effect = new DropShadowEffect() { BlurRadius = 5, ShadowDepth = 0 },
Padding = new Thickness(5),
CornerRadius = new CornerRadius(5),
Child = new Grid() {
RowDefinitions = {
new RowDefinition(){ Height = GridLength.Auto },
new RowDefinition(){ Height = GridLength.Auto },
new RowDefinition()
},
Children = { header, divider, info }
}
};
}
}
Line
and Polygon
are generated by this LineStream
:
public class LineStream : FrameworkElement
{
List<int> values;
double spacing, height, lower, upper;
Pen pen;
StreamGeometry geometry, polygon;
Path polyPath;
LinearGradientBrush fillBrush;
DoubleAnimation gradientAnim;
public LineStream(List<int> values) {
this.values = values;
geometry = new StreamGeometry();
polygon = new StreamGeometry();
fillBrush = new LinearGradientBrush() {
StartPoint = new Point(0.5, 0),
EndPoint = new Point(0.5, 1),
GradientStops = {
new GradientStop(){Offset = 0, Color = Color.FromArgb(75, 0, 0, 100)},
new GradientStop() { Offset = 0, Color = Colors.Transparent }
}
};
polyPath = new Path() { Fill = fillBrush, Data = polygon };
gradientAnim = new DoubleAnimation(1, TimeSpan.FromSeconds(2)) {
EasingFunction = new CubicEase() { EasingMode = EasingMode.EaseIn },
BeginTime = TimeSpan.FromSeconds(1)
};
pen = new Pen(Brushes.CornflowerBlue, 2);
Loaded += animateLine;
}
void animateLine(object sender, RoutedEventArgs e) {
var anim = new RectAnimation() {
From = new Rect(0, 0, 0, height),
Duration = TimeSpan.FromSeconds(2),
EasingFunction = new CubicEase() { EasingMode = EasingMode.EaseInOut }
};
Clip.BeginAnimation(RectangleGeometry.RectProperty, anim);
fillBrush.GradientStops[0].BeginAnimation(GradientStop.OffsetProperty, gradientAnim);
fillBrush.GradientStops[1].BeginAnimation(GradientStop.OffsetProperty, gradientAnim);
}
public void SetParameters(double width, double height, double lower, double upper, double spacing) {
this.height = height;
this.lower = lower;
this.upper = upper;
this.spacing = spacing;
Clip = new RectangleGeometry(new Rect(0, 0, width, height));
geometry.Clear();
polygon.Clear();
InvalidateVisual();
}
protected override void OnRender(DrawingContext drawingContext) {
var posHeight = height / (lower + upper) * upper;
var negHeight = height - posHeight;
var poly = polygon.Open();
using (var geo = geometry.Open()) {
double x = 0;
double y = values[0] < 0 ? negHeight - Math.Abs(values[0]) / lower * negHeight : values[0] / upper * posHeight + negHeight;
Point point = new(x, y);
geo.BeginFigure(point, true, false);
poly.BeginFigure(new Point(x, negHeight), true, true);
poly.LineTo(point, true, true);
for (int i = 1; i < values.Count; i++) {
x += spacing;
y = values[i] < 0 ? negHeight - Math.Abs(values[i]) / lower * negHeight : values[i] / upper * posHeight + negHeight;
point = new Point(x, y);
geo.LineTo(point, true, true);
poly.LineTo(point, true, true);
}
poly.LineTo(new Point(x, negHeight), true, true);
}
poly.Close();
polyPath.Data = polygon;
drawingContext.DrawGeometry(null, pen, geometry);
drawingContext.DrawGeometry(fillBrush, null, polygon);
}
}
and here's the LineChart
:
class LineChart : FrameworkElement
{
public IEnumerable ItemSource {
get { return (IEnumerable)GetValue(ItemSourceProperty); }
set { SetValue(ItemSourceProperty, value); }
}
public static readonly DependencyProperty ItemSourceProperty =
DependencyProperty.Register("ItemSource", typeof(IEnumerable), typeof(LineChart), new PropertyMetadata(null, onSourceChanged));
static void onSourceChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) {
var o = d as LineChart;
if (e.OldValue != null) o.Children.Clear();
var values = ((IEnumerable)e.NewValue).Cast<int>().ToList();
if (values.Count > 0) o.generateChart(values);
o.InvalidateArrange();
}
int numPoints;
Pointer pointer;
TextBlock infoBlock;
double minValue, maxValue;
Size labelDesired;
VisualCollection Children;
public LineChart() {
infoBlock = new TextBlock() {
Foreground = Brushes.Gray,
Tag = "Info",
FontSize = 14,
IsHitTestVisible = false,
LayoutTransform = new ScaleTransform() { ScaleY = -1 }
};
Children = new VisualCollection(this);
LayoutTransform = new ScaleTransform() { ScaleY = -1 };
}
void addLine() {
var line = new Line() {
StrokeThickness = 1,
Stroke = Brushes.LightBlue,
StrokeDashCap = PenLineCap.Flat,
StrokeDashArray = new DoubleCollection(new List<double> { 3, 3 }),
IsHitTestVisible = false
};
Children.Add(line);
line.Loaded += animateLine;
}
void animateLine(object sender, RoutedEventArgs e) {
var line = sender as Line;
var translateAnim = new DoubleAnimation() {
BeginTime = TimeSpan.FromSeconds(2),
To = 0,
Duration = TimeSpan.FromSeconds(2),
EasingFunction = new CubicEase() { EasingMode = EasingMode.EaseInOut }
};
var scaleAnim = new DoubleAnimation() {
From = 0,
Duration = TimeSpan.FromSeconds(2),
EasingFunction = new CubicEase() { EasingMode = EasingMode.EaseInOut }
};
var translate = new TranslateTransform(0, ActualHeight / 2 - line.Y1);
var scale = new ScaleTransform(1, 1) { CenterX = ActualWidth / 2 };
line.RenderTransform = new TransformGroup() { Children = { translate, scale } };
translate.BeginAnimation(TranslateTransform.YProperty, translateAnim);
scale.BeginAnimation(ScaleTransform.ScaleXProperty, scaleAnim);
}
void addTick(double current) {
var tick = new TextBlock() {
Tag = "Tick",
Text = current.ToString("N0"),
HorizontalAlignment = HorizontalAlignment.Right,
RenderTransform = new TransformGroup() {
Children = {
new ScaleTransform() { ScaleY = - 1 },
new TranslateTransform()
}
},
IsHitTestVisible = false
};
Children.Add(tick);
}
void addLabel(string value) {
var label = new TextBlock() {
Text = value,
IsHitTestVisible = false,
TextAlignment = TextAlignment.Right,
Padding = new Thickness(0, 0, 5, 0),
RenderTransform = new TransformGroup() {
Children = {
new ScaleTransform() { ScaleY = -1 },
new RotateTransform() { Angle = 90 }
}
}
};
Children.Add(label);
label.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
if (label.DesiredSize.Width > labelDesired.Width)
labelDesired = label.DesiredSize;
}
void generateChart(List<int> values) {
labelDesired = new Size(0, 0);
Children.Add(new Border() { Background = Brushes.Transparent });
var line = new LineStream(values);
Children.Add(line);
maxValue = 0;
foreach (var value in values) {
Children.Add(new Circle(value));
addLabel(value.ToString("N0"));
if (value > maxValue) maxValue = value;
if (value < minValue) minValue = value;
}
var min = minValue < 0 ? minValue : 0;
double step = 0d;
var current = min;
step = min < 0 ? ((maxValue) + Math.Abs(min)) / 9 : (maxValue) / 9;
for (int i = 0; i < 10; i++) {
addLine();
addTick(current);
current += step;
}
numPoints = values.Count;
pointer = new Pointer() { Visibility = Visibility.Hidden };
Children.Add(pointer);
}
protected override Size ArrangeOverride(Size finalSize) {
var testTick = new TextBlock() { Text = maxValue.ToString("N0") };
testTick.Measure(finalSize);
var tickWidth = testTick.DesiredSize.Width;
var labelHeight = labelDesired.Width;
var upperBound = maxValue;
var lowerBound = minValue < 0 ? Math.Abs(minValue) : 0;
double margin = 10;
var availableWidth = finalSize.Width - tickWidth - 2 * margin;
var availableHeight = (finalSize.Height - labelHeight - margin) / 10 * 9;
var posHeight = availableHeight / (lowerBound + upperBound) * upperBound;
var negHeight = availableHeight - posHeight;
var horizontalSpacing = (finalSize.Width - tickWidth - 2 * margin) / (numPoints - 1);
double verticalSpacing = (finalSize.Height - labelHeight - margin) / 10;
double lineY, tickY, labelX, circleX;
lineY = tickY = 0;
labelX = circleX = tickWidth + margin;
foreach (var item in Children) {
if (item is LineStream) {
var path = (LineStream)item;
path.SetParameters(availableWidth, availableHeight, lowerBound, upperBound, horizontalSpacing);
path.Arrange(new Rect(new Point(tickWidth + margin, labelHeight), path.DesiredSize));
}
else if (item is Pointer) {
var pointer = (Pointer)item;
pointer.SetPointer(0, finalSize.Height, labelHeight);
pointer.Arrange(new Rect(pointer.DesiredSize));
}
else if (item is Circle) {
var circle = (Circle)item;
double y = circle.value < 0 ? negHeight - Math.Abs(circle.value) / lowerBound * negHeight : circle.value / upperBound * posHeight + negHeight;
circle.SetCenter(new Point(circleX, y + labelHeight));
circle.Arrange(new Rect(circle.DesiredSize));
circleX += horizontalSpacing;
}
else if (item is Line) {
var line = (Line)item;
line.X2 = finalSize.Width - margin;
line.Y1 = line.Y2 = lineY + labelHeight;
line.Measure(finalSize);
line.Arrange(new Rect(line.DesiredSize));
lineY += verticalSpacing;
}
else if (item is TextBlock) {
var block = (TextBlock)item;
block.Measure(finalSize);
if (block.Tag != null) {
if (string.Equals(block.Tag.ToString(), "Tick")) {
block.Arrange(new Rect(new Point(0, tickY + block.DesiredSize.Height + labelHeight), block.DesiredSize));
tickY += verticalSpacing;
}
else {
var xPos = finalSize.Width - infoBlock.DesiredSize.Width - margin;
var yPos = finalSize.Height - infoBlock.DesiredSize.Height;
block.Arrange(new Rect(new Point(xPos, yPos), infoBlock.DesiredSize));
}
}
else {
block.Width = labelDesired.Width;
block.Height = labelDesired.Height;
block.Arrange(new Rect(new Point(labelX - labelDesired.Height / 2, 0), block.DesiredSize));
labelX += horizontalSpacing;
}
}
else if (item is Border) {
var border = (Border)item;
border.Width = finalSize.Width - tickWidth;
border.Height = finalSize.Height - labelHeight;
border.Measure(finalSize);
border.Arrange(new Rect(new Point(tickWidth, labelHeight), border.DesiredSize));
}
}
return finalSize;
}
protected override Visual GetVisualChild(int index) => Children[index];
protected override int VisualChildrenCount => Children.Count;
protected override void OnMouseEnter(MouseEventArgs e) => pointer.Visibility = Visibility.Visible;
protected override void OnMouseMove(MouseEventArgs e) => pointer.SetPointer(e.GetPosition(this).X, ActualHeight, labelDesired.Width);
protected override void OnMouseLeave(MouseEventArgs e) => pointer.Visibility = Visibility.Hidden;
}
in the OnMouseMove
of LineChart
, I call SetPointer
method of Pointer
class and there it fires the Moved
event. Circle
, the subcriber, of that event calls onPointerIsOver
when that is fired.
In your MainWindow.xaml
, add this line:
<Window x:Class="Test.MainWindow" ...>
<local:LineChart ItemSource="{Binding ChartValues}" Margin="50"/>
</Window>
and in MainWindow.xaml.cs
, have these:
public partial class MainWindow : Window
{
public List<int> ChartValues { get; set; }
public MainWindow() {
InitializeComponent();
ChartValues = new List<int>();
Random rand = new();
for (int i = 0; i < 10; i++) {
ChartValues.Add(rand.Next(50, 501));
}
DataContext = this;
}
}
and you'll see something like this: