C# Chart 目盛間隔を自動調整する

# はじめに

[C#のChartコントロールで描いた散布図](https://qiita.com/nekotadon/items/5c0a8ff0260e2d27811e)において、目盛間隔を自動調整する方法です。横軸が日時の場合は、Chartコントロール自身が自動で目盛間隔を調整してくれます。同様のことを通常の数値目盛でも行う方法です。  

![chart_memori.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3193449/e829b377-31d8-cf8e-9c83-993deeb951c8.gif)  

# 概要

大まかな流れとしては、  

1. 大体どのくらいの間隔（ピクセル）で目盛を振るかを設定する。例えば75ピクセル毎。  

2. 現在のグラフエリアの幅（ピクセル）からすると何分割して目盛を振るかを計算する。例えば幅を226として226/75=3.01。  

3. 予め設定した使用可能な分割数（例えば1,2,4,5,10,20分割）の中から分割数を選択し、決定する。例えば3.01であれば、3以上の最小値である4とする。  

4. 分割数が決まったら目盛間隔を仮設定する。例えば表示が49～251で4分割であれば(251-49)/4で50.5。  

5. 計算結果を指数表記にした上で仮数部を取得する。50.5 → 5.05E+1 → 5.05。  

6. 分割数と同様に予め設定した使用可能な目盛間隔（例えば1,2,5,10）の中から目盛間隔を選択し、決定する。仮数部が5.05であれば、5.05以上の最小値である10とする。  

7. 指数部より桁を合わせる。10E+1 → 100。  

8. 目盛間隔が100になるように目盛のオフセットを調整する。例えば0,100,200と振りたいのであれば最小値49にオフセット-49を設定する。  

# ソースコード

軸の最小値または最大値がDouble.NaN（デフォルト値）の場合は、目盛の自動調整をする前に（autoIntervalAdjust関数を呼ぶ前に）グラフ エリアのプロパティを再計算[`ChartArea.RecalculateAxesScale();`](https://learn.microsoft.com/ja-jp/dotnet/api/system.windows.forms.datavisualization.charting.chartarea.recalculateaxesscale?view=netframework-4.8.1#system-windows-forms-datavisualization-charting-chartarea-recalculateaxesscale)しておく必要があります。  

```cs
using System;
using System.Drawing;
using System.Windows.Forms;
using System.Windows.Forms.DataVisualization.Charting;//グラフ用

namespace WindowsFormsApp1
{
    public partial class Form1 : Form
    {
        public Form1()
        {
            InitializeComponent();

            //初期化
            chart1.ChartAreas.Clear();
            chart1.Series.Clear();
            chart1.Legends.Clear();
            chart1.Dock = DockStyle.Fill;

            //ChartAreaの追加
            chart1.ChartAreas.Add(new ChartArea());

            //ChartAreaの設定
            ChartArea chartArea = chart1.ChartAreas[0];

            //移動時にグラフエリアのサイズが
            //自動調整（変更）されないようにグラフエリアを固定
            chartArea.InnerPlotPosition.Auto = false;
            chartArea.InnerPlotPosition.X = 8.0f;
            chartArea.InnerPlotPosition.Y = 4.0f;
            chartArea.InnerPlotPosition.Width = 84.0f;
            chartArea.InnerPlotPosition.Height = 84.0f;

            //イベント登録

            //最初にグラフを描くときの初期化用
            chart1.Paint += new PaintEventHandler(this.chart1_Paint);

            //サイズ、スケール変更時
            chart1.AxisViewChanged += new EventHandler<ViewEventArgs>(this.chart1_AxisViewChanged);
            chart1.SizeChanged += new EventHandler(this.chart1_SizeChanged);

            //系列の作成
            Series series = new Series
            {
                Color = Color.Blue,
                MarkerColor = Color.Blue,
                MarkerSize = 10,
                MarkerStyle = MarkerStyle.Circle,
                ChartType = SeriesChartType.Line
            };

            //系列にプロットデータ追加
            for (double i = 0; i < 720; i++)
            {
                series.Points.AddXY(i, Math.Sin(i / 360 * 3.14 * 2));
            }

            //系列を追加
            chart1.Series.Add(series);

            //X軸の表示範囲拡大
            chart1.ChartAreas[0].AxisX.ScaleView.Zoom(50, 250);
        }

        //目盛間隔の自動調整
        private void autoIntervalAdjust(Axis axis)
        {
            //軸の最大最小値がDouble.NaNの場合は、
            //先にグラフ エリアのプロパティを再計算 ChartArea.RecalculateAxesScale(); しておくこと

            //大体何ピクセルごとに目盛をふるか
            double pixelWidthInterval = 75.0;

            //軸の分割数の配列（この中の値のいずれかで分割される）
            int[] splitCounts = new int[] { 1, 2, 4, 5, 10, 20, 40, 50, 100 };

            //目盛数値の区切り配列（この中の値のいずれか×10^nが目盛数値になる）
            int[] IntervalSteps = new int[] { 1, 2, 5, 10 };

            try
            {
                //グラフビューの最小値、最大値
                double vmin = axis.ScaleView.ViewMinimum;
                double vmax = axis.ScaleView.ViewMaximum;

                //グラフビューの幅（ピクセル）
                double pixelWidth = Math.Abs(axis.ValueToPixelPosition(vmax) - axis.ValueToPixelPosition(vmin));

                //軸の分割数を仮設定
                int splitCount = (int)Math.Floor(pixelWidth / pixelWidthInterval);//仮分割数

                //軸の分割数を設定（軸の分割数の配列の中から採用）
                int idx;
                idx = splitCounts.Length - 1;
                for (int i = 0; i < splitCounts.Length; i++)
                {
                    if (splitCount <= splitCounts[i])
                    {
                        idx = i;
                        break;
                    }
                }
                splitCount = splitCounts[idx];//分割数（整数）

                //目盛間隔を仮設定
                double autoInterval = (vmax - vmin) / splitCount;         //仮目盛間隔（実数）
                double exponent = Math.Floor(Math.Log10(autoInterval));   //仮目盛間隔の指数表記の指数部
                double mantissa = autoInterval / Math.Pow(10.0, exponent);//仮目盛間隔の指数表記の仮数部

                //目盛間隔を設定（目盛数値の区切り配列の中から採用）
                idx = IntervalSteps.Length - 1;
                for (int i = 0; i < IntervalSteps.Length; i++)
                {
                    if (mantissa <= IntervalSteps[i])
                    {
                        idx = i;
                        break;
                    }
                }
                axis.Interval = IntervalSteps[idx] * Math.Pow(10.0, exponent);//目盛間隔（整数）

                //目盛のオフセット、例えば最小値が-10.28で目盛間隔が2の場合は-12から目盛を振る
                double left = Math.Floor(vmin / axis.Interval) * axis.Interval;//目盛最左端-12
                axis.IntervalOffset = left - vmin;//-12-(-10.28)
            }
            catch (Exception)
            {
                ;
            }
        }

        //起動時に目盛間隔の自動調整
        bool atBoot = true;
        private void chart1_Paint(object sender, PaintEventArgs e)
        {
            if (atBoot)
            {
                atBoot = false;
                autoIntervalAdjust(chart1.ChartAreas[0].AxisX);
                autoIntervalAdjust(chart1.ChartAreas[0].AxisY);
            }
        }

        //サイズ変更、ビュー変更時に目盛間隔の自動調整
        private void chart1_SizeChanged(object sender, EventArgs e)
        {
            autoIntervalAdjust(chart1.ChartAreas[0].AxisX);
            autoIntervalAdjust(chart1.ChartAreas[0].AxisY);
        }
        private void chart1_AxisViewChanged(object sender, ViewEventArgs e)
        {
            autoIntervalAdjust(chart1.ChartAreas[0].AxisX);
            autoIntervalAdjust(chart1.ChartAreas[0].AxisY);
        }
    }
}
```
# 補足

## 日時目盛の書式の自動設定について

横軸が日時の場合、目盛間隔は自動調整されますが、書式は自動調整されません。書式も自動調整したい場合は、例えば下記のように、条件に応じて書式を変更するようにします。  

```cs
private void autoIntervalAdjust()
{
    Axis AxisX = this.chart1.ChartAreas[0].AxisX;

    //グラフビューの最小値、最大値、幅
    double vmin = AxisX.ScaleView.ViewMinimum;
    double vmax = AxisX.ScaleView.ViewMaximum;
    double delta = vmax - vmin;

    double day = 1.0;
    double hour = day / 24.0;
    double minute = hour / 60.0;
    double second = minute / 60.0;
    double msec = second / 1000.0;
    double year = 365.0;

	//横軸の目盛の書式を変更
    if (delta <= 50 * msec)
    {
        AxisX.LabelStyle.Format = "s.ffff";
    }
    else if (delta <= 200 * msec)
    {
        AxisX.LabelStyle.Format = "s.fff";
    }
    else if (delta <= 5 * second)
    {
        AxisX.LabelStyle.Format = "m:ss.ff";
    }
    else if (delta <= 12 * hour)
    {
        AxisX.LabelStyle.Format = "H:mm:ss";
    }
    else if (delta <= 14 * day)
    {
        AxisX.LabelStyle.Format = "M/d H:mm";
    }
    else if (delta <= 1 * year)
    {
        AxisX.LabelStyle.Format = "yyyy/M/d";
    }
    else
    {
        AxisX.LabelStyle.Format = "yyyy/M";
    }
}
```

## 起動時のイベントについて

起動時のイベントのタイミングを確認してみると、
```
Form1_Load
Form1_Shown
chart1_Customize
chart1_PrePaint
... (PrePaintまたはPostPaintを繰り返し)
chart1_PostPaint
chart1_Paint
```
となっていました。軸のプロパティ値の計算タイミングを確認するために、軸の最小値と最大値の設定をせずに起動させたところ、[`Customize`](https://learn.microsoft.com/ja-jp/dotnet/api/system.web.ui.datavisualization.charting.chart.customize)イベントの時点で最小値の計算がされていました。  
```
//chart1_Customizeにて最小値の計算がされている。

Form1_Load ← Double.NaN
Form1_Shown ← Double.NaN
chart1_Customize ← -1
chart1_PrePaint
... (PrePaintまたはPostPaintを繰り返し)
chart1_PostPaint
chart1_Paint
```
また軸の値を画面上での位置に変換する[`ValueToPixelPosition`](https://learn.microsoft.com/ja-jp/dotnet/api/system.web.ui.datavisualization.charting.axis.valuetopixelposition)についても確認したところ、最初の[`PrePaint`](https://learn.microsoft.com/ja-jp/dotnet/api/system.web.ui.datavisualization.charting.chart.prepaint)イベント以降で有効でした。  
```
//最初のchart1_PrePaint以降であれば計算可能。

chart1_Customize ← 0
chart1_PrePaint ← 84.0...
... (PrePaintまたはPostPaintを繰り返し)
chart1_PostPaint
chart1_Paint
```
autoIntervalAdjust関数内では軸のプロパティ値と`ValueToPixelPosition`を使用しています。そのため、起動時に目盛間隔の自動調整をする場合は、最初の`PrePaint`以降で実施する必要があります。実装としては、Paintイベントが最後に1回だけ行われているので、ここで実施するようにしています。