ScottPlot ソースコード解析

ScottPlot ソースコード解析

ScottPlotは、C#で書かれた無料のオープンソースのデータ可視化コントロールです。大量のデータを簡単に可視化し、インタラクティブに操作できます。

最終更新 2024/10/31 5:29
笨免牙
読了目安 9 分
カテゴリ
.NET
タグ
.NET C# オープンソース グラフ パフォーマンス

はじめに

ScottPlotは、無料でオープンソースのデータ可視化コントロールで、C#言語で記述されています。 大規模なデータを簡単に可視化し、インタラクティブに操作できます。 ScottPlot Cookbook のサンプルでは、数行のコードで折れ線グラフ、ヒストグラム、円グラフ、散布図を作成する方法を学べます。

クイックスタート:

アニメーションカバー


ソースコードのフレームワーク

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 ディレクトリの構造は非常に明確です。これらの基本概念を理解すれば、ソースコードの読み解きはずっと楽になります。

図でまとめると:

img

ソースコード分析のエントリポイント

デモは 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軸の解像度に従ってサンプリングすることです。

SignalPlotSignalPlotBase を継承していますが、Render() をオーバーライドしていません。そのため、Plottable.Render() が呼び出されると、SignalPlotBaseRender() が呼び出されます。

/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.csPictureBox1 がマウスイベントを受け取ります。

次に、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 のコントリビューターの皆様に感謝します!

さらに探索

関連読書

その他の記事
同じカテゴリ / 同じタグ 2026/04/22

各OSバージョンの.NETサポート状況(250707更新)

仮想マシンとテストマシンを使用して、各OSバージョンの.NETサポート状況を確認します。OSインストール後、対応するランタイムをインストールし、Stardustエージェントを実行できることを確認します(合格条件)。

続きを読む
同じカテゴリ / 同じタグ 2026/02/07

AOTの使用経験のまとめ

プロジェクト作成当初から、新機能を追加したり新しい構文を使用したりした場合には、すぐにAOT公開テストを実施するという良い習慣を身につけるべきです。

続きを読む