之前在做项目的时候遇到一个需求,显示一条曲线,按时间区分产品的当前状态,不同的状态要求使用不同的颜色来区分,因为之前曾经用过D3来做过项目,所以自然的想到继续使用D3来实现。
1. 实现思路:
由于产品只有3种状态,所以使用3条曲线来组合显示一条曲线,每条曲线定义不同的颜色即可,这个办法最简单,也最容易实现。
只使用一条曲线,曲线按时间段来设定颜色,这样只需要一个数据源就可以了,上层调用的时候逻辑更简单。
实际情况:2种思路都无法使用D3来实现……,D3既不支持BrokenLineGraph,也不支持MultColorLine,Google了几天也没有找到办法,倒是找到一个替代的控件,Oxyplot,
查阅文档以及建立Test Sample后发现,Oxyplot虽然支持BrokenLineGraph,功能也比D3更多,但是不支持在数据源更新后自动更新显示!必须手动调用控件的刷新方法,或者在自己的ViewModel内内嵌一个PoltViewModel,调用PoltViewModel的刷新方法,才可以更新界面显示;而且测试发现似乎Oxyplot的性能不及D3的好,而且我也不想在自己的ViewModel里内嵌一个PoltViewModel,那只有掉头回去使用D3了。
2. 自己造轮子
前面提到过Google了几天也没有找到让D3实现BrokenLineGraph或者MultColorLine,那就只有自己造轮子了,项目已经做到一半了,看着DeadLine选择了前面的第一种思路,自己实现BrokenLineGraph:在曲线断点的地方添加dobule.NaN,恢复点使用实际数据就可以了。
实验发现,只要往数据源里添加Point(dobule.Nan, dobule.Nan),已经显示在D3上的曲线就会消失,在查阅源代码及单步执行后发现,当进入BoundsHelper.GetDataBounds时,已经显示的曲线就会消失,查阅源代码可以发现,更新数据源后,D3会根据添加的数据点计算出当前曲线的边界,并根据返回的边界来决定那些数据点将会显示在界面上,从下面的源代码可以看出,当数据点Point(dobule.NaN, dobule.NaN)添加到数据源后,BoundsHelper.GetDataBounds返回的边界是无意义的,所以才会造成曲线消失:
/// <summary>Computes bounding rectangle for sequence of points</summary>
/// <param name="points">Points sequence</param>
/// <returns>Minimal axis-aligned bounding rectangle</returns>
public static Rect GetDataBounds(IEnumerable<Point> points)
{
Rect bounds = Rect.Empty;
double xMin = Double.PositiveInfinity;
double xMax = Double.NegativeInfinity;
double yMin = Double.PositiveInfinity;
double yMax = Double.NegativeInfinity;
foreach (Point p in points)
{
xMin = Math.Min(xMin, p.X);
xMax = Math.Max(xMax, p.X);
yMin = Math.Min(yMin, p.Y);
yMax = Math.Max(yMax, p.Y);
}
// were some points in collection
if (!Double.IsInfinity(xMin))
{
bounds = MathHelper.CreateRectByPoints(xMin, yMin, xMax, yMax);
}
return bounds;
}
从上面的代码可以知道: 当数据点Point(dobule.NaN, dobule.NaN)添加到数据源后,返回Bound的长宽都为dobule.NaN,所以我们需要将断点数据丢掉:
在循环内添加代码:
if (double.IsNaN(p.X) || double.IsNaN(p.Y))
continue;
这样返回的Bound就不会丢失了。
好了,BoundsHelper修改好了,现在该实现BrokenLineGraph了,先看看D3的LineGraph是如何实现的:
重写PointsGraphBase的UpdateCore方法,将数据源内的数据点转换为实际显示在屏幕上的坐标数据点;重写PointsGraphBase的OnRenderCore方法,将UpdateCore更新的FakePointList转换为StreamGeometry并调用DrawingContext的DrawGeometry方法,将数据点显示到界面上。
明白实现原理就好办了,由于StreamGeometry不接受无意义数据点Point(dobule.NaN, dobule.NaN),所以不能直接将断点数据写到StreamGeometry内,那就在UpdateCore内生成多个FakePointList就好了,有多少个FakePointList,就写多少个StreamGeometry到DrawingContext上去,这样虽然不够优雅,但是在当前项目的实际情况下,可以快速解决问题,就这么办了,下面是UpdateCore的代码:
#region UpdateCore
protected override void UpdateCore()
{
if (DataSource == null)
{
return;
}
Rect output = Viewport.Output;
var transform = GetTransform();
if (FakePointLists == null || !(transform.DataTransform is IdentityTransform))
{
IEnumerable<Point> AllPoints = GetPoints();
FakePointLists = new List<FakePointList>();
transform = GetTransform();
Point CurrentPoint;
List<Point> transformedPoints;
IEnumerator<Point> enumerator = AllPoints.GetEnumerator();
ObservableDataSource<Point> list = new ObservableDataSource<Point>();
bool HasNext = enumerator.MoveNext();
bool CurrentIsBroken = false;
while (HasNext)
{
CurrentPoint = enumerator.Current;
HasNext = enumerator.MoveNext();
CurrentIsBroken = double.IsNaN(CurrentPoint.X) || double.IsNaN(CurrentPoint.Y);
if (CurrentIsBroken || !HasNext)
{
if (list.Collection.Count > 0)
{
if (!CurrentIsBroken && !HasNext)
{
list.Collection.Add(CurrentPoint);
}
transformedPoints = transform.DataToScreen(list.GetPoints());
FakePointLists.Add(new FakePointList(FilterPoints(transformedPoints),
output.Left, output.Right));
Offset = new Vector();
list = new ObservableDataSource<Point>();
}
CurrentIsBroken = false;
}
else
{
list.Collection.Add(CurrentPoint);
}
}
}
else
{
double left = output.Left;
double right = output.Right;
double shift = Offset.X;
left -= shift;
right -= shift;
foreach (var filteredPoints in FakePointLists)
{
filteredPoints.SetXBorders(left, right);
}
}
}
#endregion
建立一个FakePointList的List,然后在OnRenderCore内转换为StreamGeometry写入到DrawingContext:
#region OnRenderCore
protected override void OnRenderCore(DrawingContext dc, RenderState state)
{
if (DataSource == null) return;
if (FakePointLists != null)
{
StreamGeometry geometry = new StreamGeometry();
using (StreamGeometryContext context = geometry.Open())
{
foreach (var filteredPoints in FakePointLists)
{
if(filteredPoints.HasPoints)
{
context.BeginFigure(filteredPoints.StartPoint, false, false);
context.PolyLineTo(filteredPoints, true, true);
}
}
}
geometry.Freeze();
const Brush brush = null;
Pen pen = LinePen;
bool isTranslated = IsTranslated;
if (isTranslated)
{
dc.PushTransform(new TranslateTransform(Offset.X, Offset.Y));
}
dc.DrawGeometry(brush, pen, geometry);
if (isTranslated)
{
dc.Pop();
}
}
}
#endregion
下面是实测效果:
下面是完整的BrokenLineGraph.cs:
/// <summary>
/// 需要修改PenDescription类的AttachCore方法
/// BoundsHelper.GetDataBounds
/// </summary>
public class BrokenLineGraph : PointsGraphBase
{
#region Constructor
#region BrokenLineGraph
/// <summary>
/// Initializes a new instance of the <see cref="LineGraph"/> class.
/// </summary>
public BrokenLineGraph()
{
Legend.SetVisibleInLegend(this, true);
ManualTranslate = true;
filters.CollectionChanged += filters_CollectionChanged;
}
#endregion
#region public LineGraph(IPointDataSource pointSource: this()
/// <summary>
/// Initializes a new instance of the <see cref="LineGraph"/> class.
/// </summary>
/// <param name="pointSource">The point source.</param>
public BrokenLineGraph(IPointDataSource pointSource)
: this()
{
DataSource = pointSource;
}
#endregion
#endregion
#region Filters
#region filters
/// <summary>Filters applied to points before rendering</summary>
private readonly FilterCollection filters = new FilterCollection();
#endregion
#region Filters
/// <summary>Provides access to filters collection</summary>
public FilterCollection Filters
{
get { return filters; }
}
#endregion
#region FilterPoints
private List<Point> FilterPoints(List<Point> points)
{
if (!filteringEnabled)
return points;
var filteredPoints = filters.Filter(points, Viewport.Output);
return filteredPoints;
}
#endregion
#region FilteringEnabled
private bool filteringEnabled = true;
public bool FilteringEnabled
{
get { return filteringEnabled; }
set
{
if (filteringEnabled != value)
{
filteringEnabled = value;
FakePointLists = null;
Update();
}
}
}
#endregion
#region filters_CollectionChanged
private void filters_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
{
FakePointLists = null;
Update();
}
#endregion
#endregion
#region FakePointLists
private List<FakePointList> FakePointLists;
#endregion
#region override
#region OnDataSourceChanged
protected override void OnDataSourceChanged(DependencyPropertyChangedEventArgs args)
{
if (args.NewValue != args.OldValue)
{
FakePointLists = null;
}
base.OnDataSourceChanged(args);
}
#endregion
#region OnOutputChanged
protected override void OnOutputChanged(Rect newRect, Rect oldRect)
{
FakePointLists = null;
base.OnOutputChanged(newRect, oldRect);
}
#endregion
#region OnDataChanged
protected override void OnDataChanged()
{
FakePointLists = null;
base.OnDataChanged();
}
#endregion
#region OnVisibleChanged
protected override void OnVisibleChanged(Rect newRect, Rect oldRect)
{
if (newRect.Size != oldRect.Size)
{
FakePointLists = null;
}
base.OnVisibleChanged(newRect, oldRect);
}
#endregion
#region UpdateCore
protected override void UpdateCore()
{
if (DataSource == null)
{
return;
}
Rect output = Viewport.Output;
var transform = GetTransform();
if (FakePointLists == null || !(transform.DataTransform is IdentityTransform))
{
IEnumerable<Point> AllPoints = GetPoints();
FakePointLists = new List<FakePointList>();
transform = GetTransform();
Point CurrentPoint;
List<Point> transformedPoints;
IEnumerator<Point> enumerator = AllPoints.GetEnumerator();
ObservableDataSource<Point> list = new ObservableDataSource<Point>();
bool HasNext = enumerator.MoveNext();
bool CurrentIsBroken = false;
while (HasNext)
{
CurrentPoint = enumerator.Current;
HasNext = enumerator.MoveNext();
CurrentIsBroken = double.IsNaN(CurrentPoint.X) || double.IsNaN(CurrentPoint.Y);
if (CurrentIsBroken || !HasNext)
{
if (list.Collection.Count > 0)
{
if (!CurrentIsBroken && !HasNext)
{
list.Collection.Add(CurrentPoint);
}
transformedPoints = transform.DataToScreen(list.GetPoints());
FakePointLists.Add(new FakePointList(FilterPoints(transformedPoints),
output.Left, output.Right));
Offset = new Vector();
list = new ObservableDataSource<Point>();
}
CurrentIsBroken = false;
}
else
{
list.Collection.Add(CurrentPoint);
}
}
}
else
{
double left = output.Left;
double right = output.Right;
double shift = Offset.X;
left -= shift;
right -= shift;
foreach (var filteredPoints in FakePointLists)
{
filteredPoints.SetXBorders(left, right);
}
}
}
#endregion
#region OnRenderCore
protected override void OnRenderCore(DrawingContext dc, RenderState state)
{
if (DataSource == null) return;
if (FakePointLists != null)
{
StreamGeometry geometry = new StreamGeometry();
using (StreamGeometryContext context = geometry.Open())
{
foreach (var filteredPoints in FakePointLists)
{
if(filteredPoints.HasPoints)
{
context.BeginFigure(filteredPoints.StartPoint, false, false);
context.PolyLineTo(filteredPoints, true, true);
}
}
}
geometry.Freeze();
const Brush brush = null;
Pen pen = LinePen;
bool isTranslated = IsTranslated;
if (isTranslated)
{
dc.PushTransform(new TranslateTransform(Offset.X, Offset.Y));
}
dc.DrawGeometry(brush, pen, geometry);
if (isTranslated)
{
dc.Pop();
}
}
}
#endregion
#endregion
#region Line Pen
#region Stroke
/// <summary>
/// Gets or sets the brush, using which polyline is plotted.
/// </summary>
/// <value>The line brush.</value>
public Brush Stroke
{
get { return LinePen.Brush; }
set
{
if (LinePen.Brush != value)
{
if (!LinePen.IsSealed)
{
LinePen.Brush = value;
InvalidateVisual();
}
else
{
Pen pen = LinePen.Clone();
pen.Brush = value;
LinePen = pen;
}
}
}
}
#endregion
#region StrokeThickness
/// <summary>
/// Gets or sets the line thickness.
/// </summary>
/// <value>The line thickness.</value>
public double StrokeThickness
{
get { return LinePen.Thickness; }
set
{
if (LinePen.Thickness != value)
{
if (!LinePen.IsSealed)
{
LinePen.Thickness = value; InvalidateVisual();
}
else
{
Pen pen = LinePen.Clone();
pen.Thickness = value;
LinePen = pen;
}
}
}
}
#endregion
#region LinePen
/// <summary>
/// Gets or sets the line pen.
/// </summary>
/// <value>The line pen.</value>
[NotNull]
public Pen LinePen
{
get { return (Pen)GetValue(LinePenProperty); }
set { SetValue(LinePenProperty, value); }
}
public static readonly DependencyProperty LinePenProperty =
DependencyProperty.Register(
"LinePen",
typeof(Pen),
typeof(BrokenLineGraph),
new FrameworkPropertyMetadata(
new Pen(Brushes.Blue, 1),
FrameworkPropertyMetadataOptions.AffectsRender
),
OnValidatePen);
#endregion
#region OnValidatePen
private static bool OnValidatePen(object value)
{
return value != null;
}
#endregion
#endregion
#region CreateDefaultDescription
protected override Description CreateDefaultDescription()
{
return new PenDescription();
}
#endregion
}