はじめに
ScottPlotは、無料でオープンソースのデータ可視化コントロールで、C#言語で記述されています。 大規模なデータを簡単に可視化し、インタラクティブに操作できます。 ScottPlot Cookbook のサンプルでは、数行のコードで折れ線グラフ、ヒストグラム、円グラフ、散布図を作成する方法を学べます。
- ScottPlot Cookbooks でScottPlotの使い方を学ぶ。
- ScottPlot Demo でScottPlotができることを確認する。
クイックスタート:
- コンソールアプリケーション
- Windows Forms
- WPF
- WinUI
- MAUI
- Uno Platform
- Avalonia
- Eto
- .NET Core API
- Blazor WASM
- PowerShell
- .NET Notebook
- IronPython

ソースコードのフレームワーク
100万の浮動小数点数で実際にテストしてみると、操作は滑らかで、グラフは嘘をつきません!とてもクールです!では、どのようにしてこれを実現しているのか、早速見ていきましょう。
ソースコードをダウンロード: GitHub - ScottPlot
ソースコードディレクトリの分析
ダウンロードしたソースコードを開くと、src に2つのバージョンがあります。安定版の ScottPlot4 (./ScottPlot/src/ScottPlot4/) を見てみましょう。(サイト管理者注:著者の原文は2022年に書かれましたが、現在2024年10月31日時点では5.Xバージョンが1年以上リリースされており、安心して使用できます)
ScottPlot4のディレクトリには WinForms、WPF などの.NETフレームワークのものが含まれており、これらはScottPlotのシェルとして捉えることができます。
ScottPlot ディレクトリがこのコントロールの中核ディレクトリです。
以下は簡略化した ScottPlot のディレクトリ構造です:
ScottPlot/
├── AxisLimits.cs
├── Coordinate.cs
├── Control
│ ├── Backend.cs
│ ├── Configuration.cs
│ ├── DisplayScale.cs
│ └── EventProcess
│ ├── EventFactory.cs
│ ├── Events
│ │ ├── RenderHighQuality.cs
│ │ └── RenderLowQuality.cs
│ ├── EventsProcessor.cs
│ └── IUIEvent.cs
├── Drawing
│ ├── Font.cs
│ ├── GDI.cs
│ ├── Palette.cs
│ └── Tools.cs
├── Plot
│ ├── Plot.Add.cs
│ ├── Plot.Axis.cs
│ ├── Plot.cs
│ ├── Plot.Obsolete.cs
│ └── Plot.Render.cs
├── PlotDimensions.cs
├── Plottable
│ ├── AxisLine.cs
│ ├── Image.cs
│ ├── IPlottable.cs
│ ├── MinMaxSearchStrategies
│ │ ├── IMinMaxSearchStrategy.cs
│ │ ├── LinearDoubleOnlyMinMaxStrategy.cs
│ │ ├── LinearFastDoubleMinMaxSearchStrategy.cs
│ │ ├── LinearMinMaxSearchStrategy.cs
│ │ └── SegmentedTreeMinMaxSearchStrategy.cs
│ ├── PiePlot.cs
│ ├── Polygon.cs
│ ├── ScatterPlot.cs
│ ├── SignalPlotBase.cs
│ ├── SignalPlot.cs
│ └── SignalPlotXY.cs
├── README.md
├── Renderable
│ ├── Axis.cs
│ ├── AxisDimensions.cs
│ ├── AxisLabel.cs
│ ├── AxisLine.cs
│ └── IRenderable.cs
基本概念
上記のディレクトリ構造に基づき、上から順に分析します:
Control/Backend.cs ----> バックエンド管理、データ、設定、イベント関連
Drawing/GDI.cs ----> 低レベル描画インターフェース
Plot/Plot.cs ----> コントロールAPI、ユーザー向け
Plottable/IPlottable.cs ----> 描画可能なコンポーネント
Renderable/IRenderable.cs ----> レンダリング可能なコンポーネント
これにより、ScottPlot ディレクトリの構造は非常に明確です。これらの基本概念を理解すれば、ソースコードの読み解きはずっと楽になります。
図でまとめると:

ソースコード分析のエントリポイント
デモは WinForm を使用しているため、ScottPlot.WinForms/FormsPlot.cs をソースコード分析のエントリポイントとします。
class FormsPlot : UserControl
{
public FormsPlot()
{
Backend = new Control.ControlBackEnd(1, 1, "FormsPlot");
Backend.Resize(Width, Height, useDelayedRendering: false);
Backend.BitmapChanged += new EventHandler(OnBitmapChanged);
Backend.BitmapUpdated += new EventHandler(OnBitmapUpdated);
Backend.CursorChanged += new EventHandler(OnCursorChanged);
Backend.RightClicked += new EventHandler(OnRightClicked);
Backend.LeftClicked += new EventHandler(OnLeftClicked);
Backend.LeftClickedPlottable += new EventHandler(OnLeftClickedPlottable);
Backend.AxesChanged += new EventHandler(OnAxesChanged);
Backend.PlottableDragged += new EventHandler(OnPlottableDragged);
Backend.PlottableDropped += new EventHandler(OnPlottableDropped);
Configuration = Backend.Configuration;
}
}
FormsPlot のコンストラクタで、バックエンド管理の Backend を作成し、Backend にデリゲートイベントを登録します。したがって、Backend のイベントは FormsPlot が引き継ぎます。設定も Backend の設定を使用します。
バックエンド管理
/Control/Backend.cs
public ControlBackEnd(float width, float height, string name = "UnamedControl")
{
Cursor = Configuration.DefaultCursor; // マウスカーソルスタイル
EventFactory = new UIEventFactory(Configuration, Settings, Plot); // イベントファクトリ
EventsProcessor = new EventsProcessor(
renderAction: (lowQuality) => Render(lowQuality),
renderDelay: (int)Configuration.ScrollWheelZoomHighQualityDelay); // イベントの実行
ControlName = name;
Reset(width, height); // ここでは width = 1, height = 1、主に Plot インスタンスの作成に使用
}
/// <summary>
/// 指定されたサイズで完全に新しいプロットを作成してバックエンドをリセットする
/// </summary>
public void Reset(float width, float height) => Reset(width, height, new Plot());
この場所で Plot インスタンスを作成するのは少し奇妙に感じます。一つの画像に対して複数の Plot を持つためでしょうか?知っている方はコメントを残してください!
/Control/Backend.cs
public void Reset(float width, float height, Plot newPlot)
{
Plot = newPlot;
Settings = Plot.GetSettings(false);
EventFactory = new UIEventFactory(Configuration, Settings, Plot);
WasManuallyRendered = false;
Resize(width, height, useDelayedRendering: false);
}
public void Resize(float width, float height, bool useDelayedRendering)
{
// GUI が表示している Bitmap を破棄すると例外が発生します。
// 古い Bitmap を追跡して後で破棄できるようにします。
OldBitmaps.Enqueue(Bmp);
Bmp = new System.Drawing.Bitmap((int)width, (int)height);
BitmapRenderCount = 0;
if (useDelayedRendering)
RenderRequest(RenderType.HighQualityDelayed);
else
Render();
}
描画用の Bitmap を作成し、以前の Bitmap は Old キューに入れられます。Bitmap のサイズは FormsPlot のコンストラクタで Backend.Resize() によって決定されます。Bitmap ができたら、その上に描画できます。
ScottPlot コンポーネント
Render() 関数の実装を見る前に、描画コンポーネントの概念をより深く理解しましょう。
Plot コントロール API
Plot はコントロール API であり、ユーザー向けです。
1) Plot のコンストラクタ
すべての設定は settings に保存されます。
private readonly Settings settings = new Settings();
最終的に、ユーザーの設定は Plot の settings に保存されます。
2) x,y 軸の範囲の設定
/Plot/Plot.Axis.cs
public void SetAxisLimits(double? xMin = null, double? xMax = null, double? yMin = null,
double? yMax = null, int xAxisIndex = 0, int yAxisIndex = 0)
{
//1) Plot/Plot.cs の settings
settings.AxisSet(xMin, xMax, yMin, yMax, xAxisIndex, yAxisIndex);
}
3) Y 軸信号の追加
/Plot/Plot.Add.cs
public SignalPlot AddSignal(double[] ys, double sampleRate = 1, Color? color = null, string label = null)
{
SignalPlot signal = new SignalPlot()
{
Ys = ys,
SampleRate = sampleRate, // レンダリング時にサンプルレートが使用される
Color = color ?? settings.GetNextColor(),
Label = label,
// TODO: これを修正!!!
MinRenderIndex = 0,
MaxRenderIndex = ys.Length - 1,
};
Add(signal);
return signal
}
public void Add(IPlottable plottable)
{
settings.Plottables.Add(plottable);
}
ここでの SignalPlot は Plottable オブジェクトであり、表示する 100 万のデータを描画可能なオブジェクトにまとめ、Add() を呼び出して settings.Plottables に格納します。settings.Plottables には描画するすべてのオブジェクトが保持されます。
4) レンダリング関数
/Plot/Plot.Render.cs
public interface IPlottable
{
/// <summary>
/// プロットをレンダリングするかどうか、および自動軸制限の検出に寄与するかを制御します。
/// </summary>
bool IsVisible { get; set; }
/// <summary>
/// このプロッタブルが座標/ピクセル変換に使用する水平軸のインデックス。
/// 0 は下軸、1 は上軸、それ以上の数値は追加のカスタム軸です。
/// </summary>
int XAxisIndex { get; set; }
/// <summary>
/// このプロッタブルが座標/ピクセル変換に使用する垂直軸のインデックス。
/// 0 は左軸、1 は右軸、それ以上の数値は追加のカスタム軸です。
/// </summary>
int YAxisIndex { get; set; }
/// <summary>
/// キャンバスにプロッタブルを描画するタイミングで呼び出されます。
/// </summary>
/// <param name="dims">プロットとすべての軸に関する空間情報。座標/ピクセル変換を支援します。</param>
/// <param name="bmp">このプロッタブルが描画される画像。</param>
/// <param name="lowQuality">true の場合、アンチエイリアス線とテキストを無効にして高速レンダリングを実現します。</param>
void Render(PlotDimensions dims, System.Drawing.Bitmap bmp, bool lowQuality = false);
}
Bitmap 更新イベントが発生すると、Plot.Render.cs:RenderPlottables() 関数を通じて、settings.Plottables 内のすべてのオブジェクトの Render() が呼び出されます。
/Plot/Plot.Render.cs
private void RenderPlottables(Bitmap bmp, bool lowQuality, double scaleFactor)
{
foreach (var plottable in settings.Plottables)
{
if (plottable.IsVisible == false)
continue;
plottable.Render(dims, bmp, lowQuality);
}
}
Renderable
public interface IRenderable
{
bool IsVisible { get; set; }
void Render(PlotDimensions dims, Bitmap bmp, bool lowQuality = false);
}
インターフェース定義を見ると、IPlottable と似ており、おそらく古いコードでしょう。
SignalPlot レンダリングアルゴリズムの分析
100万のデータを画像に表示し、マウスでの移動・ズーム時にも滑らかな操作感を維持するにはどうすればよいでしょうか?その設計思想は非常にシンプルです。100万のデータをx軸の解像度に従ってサンプリングすることです。
SignalPlot は SignalPlotBase を継承していますが、Render() をオーバーライドしていません。そのため、Plottable.Render() が呼び出されると、SignalPlotBase の Render() が呼び出されます。
/Plottable/SignalPlotBase.cs
public virtual void Render(PlotDimensions dims, Bitmap bmp, bool lowQuality = false)
{
// 以前にAddSignal()で _SamplePeriod = 1/SampleRate が初期化されています
double dataSpanUnits = _Ys.Length * _SamplePeriod;
double columnSpanUnits = dims.XSpan / dims.DataWidth;
// x軸の各間隔に含まれるYデータの数
double columnPointCount = (columnSpanUnits / dataSpanUnits) * _Ys.Length;
// OffsetX は画像左上の原点からデータ表示領域までのオフセット
double offsetUnits = dims.XMin - OffsetX;
double offsetPoints = offsetUnits / _SamplePeriod;
int visibleIndex1 = (int)(offsetPoints);
int visibleIndex2 = (int)(offsetPoints + columnPointCount * (dims.DataWidth + 1));
int visiblePointCount = visibleIndex2 - visibleIndex1;
// x軸の各間隔に含まれる点の数
double pointsPerPixelColumn = visiblePointCount / dims.DataWidth;
double dataWidthPx2 = visibleIndex2 - visibleIndex1 + 2;
bool densityLevelsAvailable = DensityLevelCount > 0 && pointsPerPixelColumn > DensityLevelCount;
double firstPointX = dims.GetPixelX(OffsetX); // ピクセル座標に変換
double lastPointX = dims.GetPixelX(_SamplePeriod * (_Ys.Length - 1) + OffsetX);
double dataWidthPx = lastPointX - firstPointX;
double columnsWithData = Math.Min(dataWidthPx, dataWidthPx2);
if (columnsWithData < 1 && Ys.Length > 1)
{
RenderSingleLine(dims, gfx, penHD);
}
else if (pointsPerPixelColumn > 1 && Ys.Length > 1)
{
if (densityLevelsAvailable)
RenderHighDensityDistributionParallel(dims, gfx, offsetPoints, columnPointCount);
else
// データが多い場合にこの関数を呼び出す
RenderHighDensity(dims, gfx, offsetPoints, columnPointCount, penHD);
}
else
{
RenderLowDensity(dims, gfx, visibleIndex1, visibleIndex2, brush, penLD, penHD);
}
}
private void RenderHighDensity(PlotDimensions dims, Graphics gfx, double offsetPoints, double columnPointCount, Pen penHD)
{
int dataColumnFirst = (int)Math.Ceiling((-1 - offsetPoints + MinRenderIndex) / columnPointCount - 1);
int dataColumnLast = (int)Math.Ceiling((MaxRenderIndex - offsetPoints) / columnPointCount);
var columns = Enumerable.Range(dataColumnFirst, dataColumnLast - dataColumnFirst);
// シリアル同期的に、x軸の各列内のYデータを計算(最大値・最小値を取得)
intervals = columns
.Select(xPx => CalcInterval(xPx, offsetPoints, columnPointCount, dims));
PointF[] linePoints = intervals
.SelectMany(c => c.GetPoints())
.ToArray();
for (int i = 0; i < linePoints.Length; i++)
linePoints[i].X += dims.DataOffsetX;
if (linePoints.Length > 0)
{
ValidatePoints(linePoints);
gfx.DrawLines(penHD, linePoints);
}
}
// 最大値、最小値を取得
private IntervalMinMax CalcInterval(int xPx, double offsetPoints, double columnPointCount, PlotDimensions dims)
{
// この列の最小値と最大値を取得
Strategy.MinMaxRangeQuery(index1, index2, out double lowestValue, out double highestValue);
float yPxHigh = dims.GetPixelY(lowestValue + OffsetYAsDouble);
float yPxLow = dims.GetPixelY(highestValue + OffsetYAsDouble);
return new IntervalMinMax(xPx, yPxLow, yPxHigh);
}
GetPoints() で最大値と最小値を交互に取得
private class IntervalMinMax
{
public float x;
public float Min;
public float Max;
public IntervalMinMax(float x, float Min, float Max)
{
this.x = x;
this.Min = Min;
this.Max = Max;
}
public IEnumerable<PointF> GetPoints()
{
// 最大値と最小値を交互に返す
yield return new PointF(x, Min);
yield return new PointF(x, Max);
}
}
最小値・最大値検索戦略
ソースコードには3つの戦略があり、事前計算も動的計算も可能です。
/Plottable/MinMaxSearchStrategy/IMinMaxSearchStrategy.cs
public interface IMinMaxSearchStrategy<T>
{
T[] SourceArray { get; set; }
void MinMaxRangeQuery(int l, int r, out double lowestValue, out double highestValue);
void updateElement(int index, T newValue);
void updateRange(int from, int to, T[] newData, int fromData = 0);
double SourceElement(int index);
}
/Plottable/MinMaxSearchStrategy/LinearDoubleOnlyMinMaxStrategy.cs
public void MinMaxRangeQuery(int l, int r, out double lowestValue, out double highestValue)
{
lowestValue = sourceArray[l];
highestValue = sourceArray[l];
for (int i = l; i <= r; i++)
{
if (sourceArray[i] < lowestValue)
lowestValue = sourceArray[i];
if (sourceArray[i] > highestValue)
highestValue = sourceArray[i];
}
}
イベント
イベントの種類に応じて、異なる ProcessEvent() を実行します。これは Plottable タイプの設計思想と同じです。
イベントを検出すると、UIEventFactory のメソッドを使用して対応する Event を構築します。
/Control/EventProcess/Events/IUIEvent.cs
public interface IUIEvent
{
public RenderType RenderType { get; }
void ProcessEvent();
}
/Control/EventProcess/Events/MouseZoomEvent.cs
public void ProcessEvent()
{
float x = Input.ShiftDown ? Settings.MouseDownX : Input.X;
float y = Input.CtrlDown ? Settings.MouseDownY : Input.Y;
Settings.MouseZoom(x, y);
}
イベント実行フロー
まず、FormsPlot.cs の PictureBox1 がマウスイベントを受け取ります。
次に、Backend.MouseMove() を呼び出します。
最後に、イベントに対応する ProcessEvent() を呼び出します。
/ScottPlot.Winforms/FormsPlot.cs
private void PictureBox1_MouseMove(object sender, MouseEventArgs e)
{
Backend.MouseMove(GetInputState(e)); base.OnMouseMove(e);
}
public void MouseMove(InputState input)
{
mouseMoveEvent = EventFactory.CreateMouseZoom(input);
ProcessEvent(mouseMoveEvent);
}
おわりに
ScottPlot は素晴らしいソフトウェアで、大量のデータ表示の問題を解決でき、パフォーマンスも強力です。MIT オープンソースライセンスを使用しており、本当に素晴らしいです!
以前はネット上で ScottPlot の紹介や使用方法の記事しか見つからず、ソースコードの解析はありませんでした。今回、ScottPlot のソースコード解析記事を書いて補足します。
記事を書くのは久しぶりで、ソースコード解析記事を書くのはかなり難しいと感じました。不足している点があればご容赦ください。気に入っていただければ幸いです!
最後に、ScottPlot のコントリビューターの皆様に感謝します!
- リポジトリ: https://github.com/ScottPlot/ScottPlot
- ドキュメントサイト: https://scottplot.net/