##はじめに
c#始めて1年が経ちました。
最初にやったのがChartコントロールで、その時は普通の使い方で済んだのですが、最近関係したChartプログラムは思い切り性能の問題がありました。
例えば10msごとに変化する10種類のデータを過去5分間分表示すると、プロット数は30万個になります。これを100msに1回表示更新するとどうなるか。
標準Chartだとたぶんダメなのでサンプリングを10回に1回にするとプロット数は3万個。
妥協も必要ですが、精度を落とした上これで本当に大丈夫と言えるでしょうか?
##スクロールチャートの高速化
スクロールチャートの前提
ここで実装するスクロールチャートとは以下のようなものです。
- X軸が経過時間、Y軸が値で最新値を右側(または左側)に表示します。
- 短い時間にデータが入力されるので高頻度での更新に耐えられるものとします。
- 棒グラフでも可能ですが今回は折れ線グラプ(Lineチャート)を扱います。
- 最大のプロット数を決め、最大プロット数を超えた場合、最旧データを削除します。
- ほぼ定周期でデータを得られるデータを対象とし、データ数から経過時間を推定します。
- Y軸の値は無効データ(DataPoint#IsEmpty=true)を含んでも良いものとします。
高速化の要約
最終的な実装だけ知るのであれば途中経過は必要ないかも知れませんね。
プロット数が1万点のときの描画時間です。
プロット数が30万点でも問題なく描画できました。
時間は目安です。描画量や描画面積により大きく変動します。
定周期でデータを取得するなど、時刻が厳密な意味を持たない場合FastLineを利用した方が良いと思います。
ただFastLineでもプロット数が多いと描画に時間がかかるため、Graphicsでの直接描画を試すことにしました。
###Graphicsで直接描画
Graphicsで描画するためにはChartのプロット領域を割り出す必要があります。
当初簡単に取得する方法が判らなかったので以下のように計算しましたが、現在はChartPaintEventArgsから求める実装に変更しました。
###DrawImageの使用
DrawPathを使用しても所詮データ量が多いと遅くなります。
そこで毎回全点を描画しない方法を考えてみました。
以下のような感じです。
(1)N回に1回全点描画し背景が透明なBitmapに保存する
(2)プロットデータを更新するごとにBitmapを水平移動してDrawImageで描画する
(3)(1)以降に更新されたプロットデータを描画する
(4)プロットデータがN回更新されたら(1)から繰り返す
最終的に掲載のソースコードでは(1)のBitmap生成時、その前のBitmapを平行移動して重ねることで全点描画しない実装としました。
何も指定しないと以下の図(左側)のようにImageを移動して重ねるごとにぼやけてきます。
これについてはGraphics#InterpolationModeをNearestNeighborとすることで解決しました。
ただ移動量の合計はfloatの誤差と思いますが多少縮んでいます(series1の左端)。
##まとめ
今回のアイデアは実際には採用されていません。個人的に試してみただけのものです。
途中間引きによる高速化も検討しました。
「Largest Triangle Three Bucketsアルゴリズム」というものです。
ノイズなど特異点を捨てないという利点があり、欠測もfloat.MaxValueとかに置き換えれば機能します。
しかしながら最初のデータが毎回変わると結果も変わるため、スクロールチャートには不向きであることが判りました。但し起点を変えない工夫をすれば実用になると思います。
またソースは取りあえずひとまとめにして動作確認したというだけで、コンポーネントとしてはまったく不十分ですよね。
MsChartのカスタマイズは(私には)ほとんど無理だということが判りましたが、それ以前に公開されている情報も十分活用できていません。
それでもこの記事がどなたかのお役に立てれば嬉しいです。
##更新履歴
2019/7/12
系列の処理などを見直しました。
(1)プロットは常に右側からとしました。左からプロットするコードも残っています。
(2)X値を時間軸などとしてXY値でプロットすることも可能としました。
(3)ChartType=Pointとしてマーカーを使えるようにしました。
2019/12/2
アクセスが多いので使用例をもう少し判りやすくしようと思ったのですが、結果的にバグ対策を含め色々手を加えてしまいました。
(1)スクロール方向(右→左、左→右)を指定できるようにしました。
(2)描画エリアのサイズ変更などが無い限り全点のLINE描画をしないモードをほぼ実用にしました。
(3)その他実装を見直しました。呼び出し方法も変更になっています。
2019/12/6
暇なので少しずつ修正しています。
(1)プロット領域をChartPaintEventArgsから求めるように変更しました。
(2)直接描画をChart#OnPostPaintで行うことで標準系列とスクロール系列が混在しても系列の表示順序が正しくなるように修正しました。
##開発環境
-
PC --- Windows7(Intel Core i5-2300,メモリ8GB)
Windows10(Intel Core i7-2600,メモリ16GB) -
開発環境 --- Visual Studio Community 2017, 2019
-
ターゲットフレームワーク --- .NET Fremawork 4.6.1, 4.7.2
##ソースコート
###チャートプログラム
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Windows.Forms;
using System.Windows.Forms.DataVisualization.Charting;
namespace ChartSample1
{
public partial class ScrollChart : Chart
{
public ScrollChart()
{
//InitializeComponent(); //カスタムコンポーネントして生成した場合
this.SizeChanged += new System.EventHandler(this.Chart_SizeChanged);
//SuppressExceptions = true; //軽微なエラーを無視する場合
}
/// <summary>
/// series#ScrollPointsにデータをAddしたときrepaintするスマートな方法が判らず本メソッドを追加。
/// 本メソッドを使わずSeriesプロパティからseriesをgetする場合、メイン側でChart#Invalidateを呼ぶ必要がある。
/// </summary>
/// <param name="series"></param>
public void AddSeries(Series series)
{
Series.Add(series);
if(series is ScrollSeries scrollSeries)
{
scrollSeries.ScrollPoints.CollectionChanged += ScrollCollectionChanged;
}
}
private void ScrollCollectionChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
Invalidate();
}
public void ChartUpdate()
{
foreach (var ser in Series)
{
if(ser is ScrollSeries scrollSeries)
scrollSeries.ResetImage();
}
Invalidate();
}
protected override void OnPostPaint(ChartPaintEventArgs e)
{
base.OnPostPaint(e);
if(e.ChartElement is ScrollSeries scrollSeries)
{
var plotArea = new PlotArea(this, e);
var g = e.ChartGraphics.Graphics;
g.SmoothingMode = SmoothingMode.AntiAlias;
g.SetClip(plotArea.Rect);
scrollSeries.DrawSeriesChart(g, plotArea, this.Width, this.Height);
}
}
private void Chart_SizeChanged(object sender, EventArgs e)
{
ChartUpdate();
}
}
public class ScrollSeries : Series
{
public int MaxPoints { get; set; } //最大プロット数
public int SlicePoints { get; set; } //イメージ更新が発生するプロット更新数
public bool ScrollLeft { get; set; } = true;
public bool CompositeImage { get; set; } = true; //trueのとき前回イメージを合成して新しいイメージを作成
public bool UseXValue { get; set; } = false;
private bool AlwaysScroll { get; } = true; //UseXValue=falseのとき
public ObservableCollection<DataPoint> ScrollPoints;
private int _plotCounter; //イメージを作成してから更新されたプロット数
private Bitmap _chartImage = null;
private int _ImageDataCount = 0; //最大プロット数になるまで平行移動を抑止する(フラグ)
public ScrollSeries() : base()
{
ScrollPoints = new ObservableCollection<DataPoint>();
base.Points.Add(new DataPoint(0, 0) { IsEmpty = true }); //軸を表示するため
}
//Pointsプロパティを拡張できれば良いが、専用メソッドを用意する
public void AddScrollPoint(DataPoint dataPoint)
{
ScrollPoints.Add(dataPoint);
if (ScrollPoints.Count > MaxPoints + 1)
{
ScrollPoints.RemoveAt(0);
}
_plotCounter++;
}
public void ResetImage()
{
_plotCounter = Math.Min(ScrollPoints.Count, MaxPoints);
_chartImage = null;
}
private void DrawImage(Graphics g, PlotArea plotArea)
{
if (_chartImage != null)
{
var shift = 0;
if (ScrollPoints.Count >= MaxPoints)
{
shift = Math.Max(0, _plotCounter - (ScrollPoints.Count - _ImageDataCount));
}
if (AlwaysScroll)
{
shift = _plotCounter;
}
var direct = ScrollLeft ? -1 : 1; //スクロール方向
//前回保存したイメージを平行移動
if (UseXValue)
{
var sdata = ScrollPoints[ScrollPoints.Count - shift - 1];
var dWith = plotArea.Rect.Width * (float)((plotArea.ChartArea0.AxisX.Maximum - sdata.XValue) / (plotArea.ChartArea0.AxisX.Maximum - plotArea.ChartArea0.AxisX.Minimum));
g.DrawImage(_chartImage, new PointF(direct * (float)dWith, 0));
}
else
{
g.DrawImage(_chartImage, new PointF(direct * shift * (float)plotArea.Rect.Width / MaxPoints, 0));
}
}
}
private void PlotData(Graphics g, PlotArea plotArea, int size, bool isFixedColor = false)
{
if (size > 0)
{
Pen pen;
if (isFixedColor)
{
//var hilight = HighLight(BaseSeries.Color, 50);
//pen = new Pen(hilight, BaseSeries.BorderWidth);
pen = new Pen(Color.Cyan, BorderWidth);
}
else
{
pen = new Pen(Color, BorderWidth);
}
if (ChartType == SeriesChartType.FastLine)
{
DrawPathLines(g, pen, plotArea, size);
}
else if (ChartType == SeriesChartType.Line)
{
DrawLines(g, pen, plotArea, size);
}
else if (ChartType == SeriesChartType.Point)
{
DrawPoints(g, plotArea, size);
}
pen.Dispose();
}
}
internal void DrawSeriesChart(Graphics g, PlotArea plotArea, int width, int height)
{
if (_plotCounter > SlicePoints)
{
var ChartImage2 = new Bitmap(width, height);
var gx = Graphics.FromImage(ChartImage2);
gx.InterpolationMode = InterpolationMode.NearestNeighbor;
gx.SmoothingMode = SmoothingMode.AntiAlias;
if (CompositeImage)
{
//前回保存したイメージを平行移動し追加分を描画
//画像がだんだんぼやけてくる
DrawImage(gx, plotArea);
PlotData(gx, plotArea, _plotCounter);
}
else
{
//全点描画してイメージを作り直す
PlotData(gx, plotArea, ScrollPoints.Count);
}
_ImageDataCount = ScrollPoints.Count;
_plotCounter = 0;
gx.Dispose();
_chartImage = ChartImage2;
}
//イメージを平行移動し追加分を描画
DrawImage(g, plotArea);
PlotData(g, plotArea, _plotCounter, true);
}
private void DrawLines(Graphics g, Pen pen, PlotArea plotArea, int size)
{
if (ScrollPoints.Count > 0)
{
var path = new GraphicsPath();
DataPoint prePoint = null;
var x0 = 0f;
var y0 = 0f;
for (var count = 0; count < size + 1; count++)
{
var posi = ScrollPoints.Count - size + count - 1;
if (posi < 0) continue;
var dataPoint = ScrollPoints[posi];
if (dataPoint.IsEmpty)
{
prePoint = null;
}
else
{
var xPosi = posi;
if (AlwaysScroll)
{
xPosi = MaxPoints - size + count;
}
float x1;
if (UseXValue)
{
x1 = plotArea.PositionX(ScrollLeft, plotArea.ChartArea0.AxisX.Minimum, plotArea.ChartArea0.AxisX.Maximum, dataPoint.XValue);
}
else
{
x1 = plotArea.PositionX(ScrollLeft, MaxPoints, xPosi);
}
var y1 = plotArea.PositionY((float)plotArea.ChartArea0.AxisY.Minimum, (float)plotArea.ChartArea0.AxisY.Maximum, (float)dataPoint.YValues[0]);
if (prePoint == null)
{
path.StartFigure();
prePoint = dataPoint;
x0 = x1;
y0 = y1;
}
path.AddLine(x0, y0, x1, y1);
prePoint = dataPoint;
x0 = x1;
y0 = y1;
}
}
g.DrawPath(pen, path);
}
}
private void DrawPathLines(Graphics g, Pen pen, PlotArea plotArea, int size)
{
var path = new GraphicsPath();
var pathPoints = new List<PointF>();
if (ScrollPoints.Count > 0)
{
//指定数より1つ前からプロット
for (var count = 0; count < size + 1; count++)
{
var posi = ScrollPoints.Count - size + count - 1;
if (posi < 0) continue;
//var i = sposi + count;
var dataPoint = ScrollPoints[posi];
//欠測の場合線を繋がない
if (dataPoint.IsEmpty)
{
//1点の場合「点」は描かない
if (pathPoints.Count > 1) //2点以上ないとエラーになる
{
path.AddLines(pathPoints.ToArray());
}
path.StartFigure();
pathPoints.Clear();
}
else
{
var xPosi = posi;
if (AlwaysScroll)
{
xPosi = MaxPoints - size + count;
}
float x0;
if (UseXValue)
{
x0 = plotArea.PositionX(ScrollLeft, plotArea.ChartArea0.AxisX.Minimum, plotArea.ChartArea0.AxisX.Maximum, dataPoint.XValue);
}
else
{
x0 = plotArea.PositionX(ScrollLeft, MaxPoints, xPosi);
}
var plot = new PointF(x0, plotArea.PositionY((float)plotArea.ChartArea0.AxisY.Minimum, (float)plotArea.ChartArea0.AxisY.Maximum, (float)dataPoint.YValues[0]));
pathPoints.Add(plot);
}
}
}
if (pathPoints.Count > 1)
{
path.AddLines(pathPoints.ToArray());
}
g.DrawPath(pen, path);
}
private void DrawPoints(Graphics g, PlotArea plotArea, int size)
{
if (ScrollPoints.Count > 0)
{
SolidBrush brush;
if (MarkerStyle != MarkerStyle.None && MarkerColor != Color.Empty)
{
brush = new SolidBrush(MarkerColor);
}
else
{
brush = new SolidBrush(Color);
}
for (var count = 0; count < size; count++)
{
var posi = ScrollPoints.Count - size + count;
var dataPoint = ScrollPoints[posi];
if (!dataPoint.IsEmpty)
{
var xPosi = posi;
if (AlwaysScroll)
{
xPosi = MaxPoints - size + 1 + count;
}
float x;
if (UseXValue)
{
x = plotArea.PositionX(ScrollLeft, plotArea.ChartArea0.AxisX.Minimum, plotArea.ChartArea0.AxisX.Maximum, dataPoint.XValue);
}
else
{
x = (float)plotArea.PositionX(ScrollLeft, MaxPoints, xPosi);
}
var y = (float)plotArea.PositionY((float)plotArea.ChartArea0.AxisY.Minimum, (float)plotArea.ChartArea0.AxisY.Maximum, (float)dataPoint.YValues[0]);
if (MarkerStyle != MarkerStyle.None)
{
DrawMark(g, brush, MarkerStyle, x, y, (float)MarkerSize);
}
else
{
DrawRectangle(g, brush, x, y, BorderWidth);
}
}
}
}
}
private void DrawRectangle(Graphics g, SolidBrush brush, float x, float y, int width)
{
g.FillRectangle(brush, x - (width / 2f), y - (width / 2f), width, width);
}
private void DrawMark(Graphics g, SolidBrush brush, MarkerStyle style, float x, float y, float size)
{
var half = size / 2.0f;
if (style == MarkerStyle.Circle)
{
g.FillEllipse(brush, x - half, y - half, size, size);
}
else if (style == MarkerStyle.Square)
{
g.FillRectangle(brush, x - half, y - half, size, size);
}
else if (style == MarkerStyle.Diamond)
{
PointF[] points = {
new PointF(x, y-half),
new PointF(x+half, y),
new PointF(x, y+half),
new PointF(x-half, y) };
g.FillPolygon(brush, points);
}
else if (style == MarkerStyle.Triangle)
{
PointF[] points = {
new PointF(x, y-half),
new PointF(x+half, y+half),
new PointF(x-half, y+half) };
g.FillPolygon(brush, points);
}
}
}
internal class PlotArea
{
public ChartArea ChartArea0;
public RectangleF Rect { get; set; }
public PlotArea(Chart chart, ChartPaintEventArgs e)
{
var g = e.ChartGraphics;
ChartArea0 = chart.ChartAreas[0];
var xMax = g.GetPositionFromAxis(ChartArea0.Name, AxisName.X, ChartArea0.AxisX.Maximum);
var xMin = g.GetPositionFromAxis(ChartArea0.Name, AxisName.X, ChartArea0.AxisX.Minimum);
var yMax = g.GetPositionFromAxis(ChartArea0.Name, AxisName.Y, ChartArea0.AxisY.Minimum);
var yMin = g.GetPositionFromAxis(ChartArea0.Name, AxisName.Y, ChartArea0.AxisY.Maximum);
var width = xMax - xMin;
var height = yMax - yMin;
Rect = g.GetAbsoluteRectangle(new RectangleF((float)xMin, (float)yMin, (float)width, (float)height));
}
public float PositionX(bool scrollToLeft, double max, double posi)
{
if (scrollToLeft)
{
return (float)(Rect.X + (Rect.Width * posi / max));
}
else
{
return (float)(Rect.X + Rect.Width * (1 - posi / max));
}
}
public float PositionX(bool scrollToLeft, double xmin, double xmax, double xValue)
{
var per = (xValue - xmin) / (xmax - xmin);
if (scrollToLeft)
{
return (float)(Rect.X + (Rect.Width * per));
}
else
{
return (float)(Rect.X + Rect.Width * (1 - per));
}
}
public float PositionY(float ymin, float ymax, float yValue)
{
return (float)(Rect.Y + Rect.Height - (Rect.Height * (yValue - ymin) / (ymax - ymin)));
}
}
}
###呼び出し例
- ScrollSeriesでスクロースする系列,最大プロット数,リアルタイムに描画するプロット数,スクロール方向などを指定します。
- スクロール系列(ScrollSeries)と標準系列(Series)は混在することができます。
- ScrollSeriesの場合、Chart#Series.Pointsによるデータポイントの登録は行わず、その代わりScrollSeries#AddScrollPointを使用します。
Chart#Series.Pointsを使用しないのでX軸ラベルとY軸ラベルは自動算出されず、固定で上下限値などを設定します。 - 必要あればFormatNumberイベントかCustomLabelで軸ラベルを指定します。
public partial class SampleChart : Form
{
private int MAX_POINTS = 60;
private Random rand = new Random();
private ScrollSeries series1; //スクロール系列:Line
private Series series2; //標準系列:LINE(スクロールするがオーバヘッド大)
private ScrollSeries series3; //スクロール系列:Point
private int counter = 0;
private void SampleChart_Load(object sender, EventArgs e)
{
chart1.FormatNumber += new System.EventHandler<FormatNumberEventArgs>(chart1_FormatNumber);
var area = chart1.ChartAreas[0];
area.AxisX.Enabled = AxisEnabled.True;
area.AxisY.Enabled = AxisEnabled.True;
area.AxisX.Minimum = 0;
area.AxisX.Maximum = MAX_POINTS;
area.AxisX.Interval = MAX_POINTS / 6;
area.AxisY.Minimum = -40;
area.AxisY.Maximum = 40;
area.AxisY.Interval = 40;
chart1.Series.Clear();
series1 = new ScrollSeries() { ChartType = SeriesChartType.Line, Color = Color.Yellow, BorderWidth = 2
, MaxPoints = MAX_POINTS, SlicePoints = 10 };
series2 = new Series() { ChartType = SeriesChartType.Line, Color = Color.Green, BorderWidth = 2 };
series3 = new ScrollSeries() { ChartType = SeriesChartType.Point,Color = Color.Red, BorderWidth = 5
, MaxPoints = MAX_POINTS, SlicePoints = 10 };
chart1.AddSeries(series1); //メインでchart1.Invalidate()とする場合、chart1.Series.Add(series1)でもよい
chart1.AddSeries(series2); //標準系列なのでchart1.Series.Add(series2)でもよい
chart1.AddSeries(series3);
//X値を使用する例
series1 = new ScrollSeries() { ChartType = SeriesChartType.Line, Color = Color.Yellow, BorderWidth = 2
, UseXValue = true, MaxPoints = MAX_POINTS, SlicePoints = 10 };
var range = TimeAxisHelper.MsecRange(MAX_PLOTS * 100, DateTime.Now);
area.AxisX.Minimum = range.min;
area.AxisX.Maximum = range.max;
area.AxisX.Interval = 1000;
chart1.AddSeries(series1);
}
//100msごとに動作
private void Timer1_Tick(object sender, EventArgs e)
{
var yValue = Math.Sin(Math.PI * (counter-6) / 6d) * 30;
series1.AddScrollPoint(new DataPoint(0, yValue));
series2.Points.Insert(0,new DataPoint(0, yValue));
if(series2.Points.Count > MAX_POINTS)
{
series2.Points.RemoveAt(MAX_POINTS);
}
counter = (++counter) % 12;
bool empty = rand.Next(0,10) == 0 ? false : true;
series3.AddScrollPoint(new DataPoint(0, 5){ IsEmpty = empty});
//chart1.Invalidate(); // chart1.AddSeriesの代わりにchart1.Series.Addを使用する場合
}
private void chart1_FormatNumber(object sender, FormatNumberEventArgs e)
{
var axis = sender as Axis;
if (axis == null || chart1.ChartAreas.Count == 0 || chart1.ChartAreas[0].AxisX != axis)
{
return;
}
var n = ((axis.CustomLabels.Count - (MAX_POINTS / axis.Interval)) * axis.Interval) / 10;
e.LocalizedValue = n.ToString();
//var dt = DateTime.Now;
//// UTC時間に変換
//dt = dt.ToUniversalTime();
//var te = (dt - UNIX_TIME).TotalMilliseconds;
//var span = e.Value;
//var past = (int)(te - span)/1000;
//e.LocalizedValue = past.ToString();
}
private class TimeAxisHelper
{
private static DateTime UNIX_TIME = new DateTime(1970, 1, 1, 0, 0, 0, 0);
public static (double min,double max) MsecRange(double maxMsec, DateTime lastTime)
{
var xMax = PointMsec(lastTime);
var span = TimeSpan.FromMilliseconds(maxMsec);
var xMin = PointMsec(lastTime - span);
return (xMin, xMax);
}
public static double PointMsec(DateTime dt)
{
dt = dt.ToUniversalTime();
var te = dt - UNIX_TIME;
return te.TotalMilliseconds;
}
}
}