あまり意味はなく円形ゲージ(メーター)

ソースの整理がてら、ちょこっと作ってみる。
よく、工業用途向けみたいな感じで、円形の他にもリニアゲージとか、7セグメントLEDやデジタルパネルみたいなコントロールがありますが。
ああいうのって、そっち方面ではよく使われているんですかね(・∀・)?


まあ、単純なものであればお金を払わずに自分で作ってもいいやということで、その作り方。
WPFではなく、Windows Forms編(´д`)
WPFならデザインや回転が簡単にできるんだけど、まだまだWindows Formsの方が主流だし。
…っていうか、ElementHostで埋め込めば良いだけか…(´д`;)
イヤイヤ、Smart Deviceもあるし。
…って、Compact FrameworkではLinearGradientBrushが使えないので、格好良いUIを作るのにはチト問題があります(;´д`)*1


っで、そんなこんなはありますが、とりあえず円形ゲージの作成手順(´ー`)

仕様

仕様としては、こんな感じで。

  • ゲージ背景、針、目盛りの色設定が可能
  • 目盛りの開始角、終了角が設定可能
  • 目盛りは大、小の2種類で、描画する数を設定可能
  • レッドゾーンみたいなものを設定可能
  • 最小値と最大値を設定し、現在値によって針を描画

まずはメンバ、プロパティとか

っということで、まずはControlを継承したCircularGaugeコントロールを以下の様に宣言してみます(・∀・)

public partial class CircularGauge : Control
{
    private Color bodyColor = Color.SteelBlue;
    private Color needleColor = Color.OrangeRed;
    private Color tickmarkColor = Color.Gray;
    private Color tickmarkFontColor = Color.Black;

    private Font tickmarkFont;

    private float startAngle = -225;
    private float endAngle = 45;

    private int majorTickmarkCount = 11;
    private int minorTickmarkCount = 3;

    private double minValue = 0;
    private double maxValue = 100;
    private double currentValue = 0;

    private GuageZoneCollection zones = new GuageZoneCollection();

    [Category( "Custom Control" )]
    [Description( "背景色" )]
    public Color BodyColor
    {
        get { return this.bodyColor; }
        set
        {
            this.bodyColor = value;
            Invalidate();
        }
    }

...

    [Category( "Custom Control" )]
    [Description( "範囲" )]
    [Editor( typeof(CollectionEditor), typeof(UITypeEditor))]
    [DesignerSerializationVisibility(DesignerSerializationVisibility.Content)]
    public GuageZoneCollection Zones
    {
        get { return this.zones; }
        set { this.zones = value; }
    }

    /// コンストラクタ
    public CircularGauge()
    {
        InitializeComponent();

        SetStyle( ControlStyles.AllPaintingInWmPaint |
                  ControlStyles.ResizeRedraw |
                  ControlStyles.DoubleBuffer |
                  ControlStyles.SupportsTransparentBackColor,
                  true );

        this.BackColor = Color.Transparent;
    }

メンバ変数とプロパティについては、仕様を満たすのに必要なものを用意。
startAngle、endAngleは開始角、終了角で、左下45度から右下45度まで目盛りを描画する設定をデフォルトに。
majorTickmarkCountは大きい目盛りの数で、デフォルトが11なのは値の範囲のデフォルト値0〜100に対して、10毎に針を描画するため(0、10、20、…、100の11個)。
minorTickmarkCountは、大きい目盛り間に小さい目盛りをいくつ描画するかの数で、デフォルト値3は4分割(目盛り1つの値は2.5)の意味デス。


プロパティで若干特殊なのがZones(´ω`)
デザイナでコレクションを編集できるように、EditorAttributeやDesignerSerializationVisibilityAttributeを設定。
コレクションの中身のGuageZoneは以下の様な定義。

[ToolboxItem( false )]
[DesignTimeVisible( false )]
public class GuageZone : Component
{
    public Color Color { get; set; }
    public double StartValue { get; set; }
    public double EndValue { get; set; }
}

public class GuageZoneCollection : List<GuageZone> 
{
}

Component派生なのは、Colorのシリアライズを考えて。
ちょっと手抜きだけど、こんなんで一応デザイナを使ったコレクションの編集はできるかな(・∀・)?

ヘルパーメソッド

っで、次は描画領域を計算するためのヘルパーメソッドについて。

    protected float GetGuageSize()
    {
        return Math.Min( ClientRectangle.Width, ClientRectangle.Height );
    }

    protected RectangleF GetDrawRect()
    {
        float size = GetGuageSize();
        return new RectangleF( 0, 0, size - 1, size - 1 );
    }

    protected PointF GetGuageCenter()
    {
        float size = GetGuageSize();
        return new PointF( size / 2, size / 2 );
    }

    protected double CalcRadian(double value)
    {
        return value * Math.PI / 180;
    }

円形ゲージなので、描画範囲はクライアント領域縦横の小さい方にあわせるようにするためのGetGuageSize()、その描画領域を取得するGetDrawRect()、針とかを描画するのに必要な中心座標取得のGetGuageCenter()、ラジアン計算のCalcRadian()を用意。

描画

ここまでで描画の準備は出来たので、次からは本題の描画処理について(・∀・)

    protected override void OnPaint(PaintEventArgs e)
    {
        e.Graphics.SmoothingMode =SmoothingMode.AntiAlias;

        DrawBackground( e.Graphics );
        DrawBody( e.Graphics );
        DrawZones( e.Graphics );
        DrawTickmark( e.Graphics );
        DrawNeedle( e.Graphics );
    }

    protected virtual void DrawBackground(Graphics g)
    {
        using( SolidBrush br = new SolidBrush ( this.BackColor ) )
        {
            g.FillRectangle( br, this.ClientRectangle );
        }
    }

描画処理では、コントロール背景、ゲージ背景、ゾーン、目盛り、針を順番に描画。
各項目の描画処理はvirtualにして、継承先でカスタマイズ出来るように。*2

ゲージ背景の描画

    protected virtual void DrawBody(Graphics g)
    {
        RectangleF rc = GetDrawRect();

        using( LinearGradientBrush br = new LinearGradientBrush( rc, this.BodyColor, Color.Black, 45 ) )
        {
            g.FillEllipse( br, rc );
        }

        rc.Inflate( -1, -1 );

        using( LinearGradientBrush br = new LinearGradientBrush( rc, Color.White, this.BodyColor, 45 ) )
        {
            g.FillEllipse( br, rc );
        }

        rc.Inflate( -6, -6 );

        using( LinearGradientBrush br = new LinearGradientBrush( rc, Color.Black, Color.White, 45 ) )
        {
            g.FillEllipse( br, rc );
        }

        rc.Inflate( -3, -3 );

        using( LinearGradientBrush br = new LinearGradientBrush( rc, this.BodyColor, Color.White, 45 ) )
        {
            g.FillEllipse( br, rc );
        }
    }

円形ゲージのようなものを作る場合、グラデーションの明暗が逆方向の円を重ねて描画していくと、立体的に見えるものが出来ますね(`・ω・´)
上記のFillEllipse()を1つずつ描画していくと、こんな感じになります。


ゾーンの描画

っで、次はレッドゾーンとかの描画。

    protected virtual void DrawZones(Graphics g)
    {
        RectangleF rc = GetDrawRect();
        rc.Inflate( -22, -22 );

        double baseAngle = ( this.EndAngle - this.StartAngle ) / ( this.MaxValue - this.MinValue );

        foreach( GuageZone zone in Zones )
        {
            using( GraphicsPath path = new GraphicsPath() )
            using( Pen pen = new Pen( zone.Color, 9 ) )
            {
                    float startPathAngle = ( (float)( ( baseAngle * ( zone.StartValue - this.MinValue ) ) + startAngle ) );
                    float endPathAngle = ( (float)( ( baseAngle * ( zone.EndValue - zone.StartValue ) ) ) );

                path.AddArc( rc, startPathAngle, endPathAngle );
                g.DrawPath( pen, path );
            }
        }
    }

1値あたりの角度を計算して、各ゾーンの開始角から終了角までの円弧を描画しています。
ちなみに、Inflate()の値とか、ペンの幅とかは適当(´д`;)
この辺、もう少しちゃんとするならプロパティ化するか、コントロールのサイズから自動計算するようにした方が良いですね。


この時点で、こんなカンジ。


目盛りの描画

    protected virtual void DrawTickmark(Graphics g)
    {
        PointF ptCenter = GetGuageCenter();
        float sizeGuage = GetGuageSize();
        float radius = sizeGuage / 2 - 16;

        int division = ( this.MajorTickmarkCount - 1 ) * ( this.MinorTickmarkCount + 1 );

        using( Pen penMinor = new Pen( ColorUtil.BlendColor( this.TickmarkColor, Color.White, 0.25 ) ) )
        using( Pen penMajor = new Pen( this.TickmarkColor ) )
        using( SolidBrush brMajor = new SolidBrush( ColorUtil.BlendColor( this.TickmarkColor, Color.White, 0.5 ) ) )
        using( SolidBrush brFont = new SolidBrush( this.TickmarkFontColor ) )
        {
            int step = 0;
            for( int i = 0; i < this.MajorTickmarkCount; i++ )
            {
                // 大目盛り
                double value = ( this.EndAngle - this.StartAngle ) / division * step + this.StartAngle;

                using( GraphicsPath path = new GraphicsPath() )
                {
                    PointF pt1 = new PointF( (float)( ptCenter.X + radius * Math.Cos( CalcRadian( value + 1 ) ) ),
                                             (float)( ptCenter.Y + radius * Math.Sin( CalcRadian( value + 1 ) ) ) );
                    PointF pt2 = new PointF( (float)( ptCenter.X + ( radius - 18 ) * Math.Cos( CalcRadian( value + 0.5 ) ) ),
                                             (float)( ptCenter.Y + ( radius - 18 ) * Math.Sin( CalcRadian( value + 0.5 ) ) ) );
                    PointF pt3 = new PointF( (float)( ptCenter.X + ( radius - 18 ) * Math.Cos( CalcRadian( value - 0.5 ) ) ),
                                             (float)( ptCenter.Y + ( radius - 18 ) * Math.Sin( CalcRadian( value - 0.5 ) ) ) );
                    PointF pt4 = new PointF( (float)( ptCenter.X + radius * Math.Cos( CalcRadian( value - 1 ) ) ),
                                             (float)( ptCenter.Y + radius * Math.Sin( CalcRadian( value - 1 ) ) ) );

                    path.AddLine( pt1, pt2 );
                    path.AddLine( pt2, pt3 );
                    path.AddLine( pt3, pt4 );
                    path.CloseFigure();

                    g.FillPath( brMajor, path );
                    g.DrawPath( penMajor, path );
                }

                // 目盛り文字
                float x = (float)( ptCenter.X + ( radius - 28 ) * Math.Cos( CalcRadian( value ) ) );
                float y = (float)( ptCenter.Y + ( radius - 28 ) * Math.Sin( CalcRadian( value ) ) );
                string str = String.Format( "{0,0:D}", (int)( ( this.MaxValue - this.MinValue ) / division * step ) );
                SizeF size = g.MeasureString( str, this.TickmarkFont );
                g.DrawString( str, this.TickmarkFont, brFont, x - (float)( size.Width * 0.5 ), y - (float)( size.Height * 0.5 ) );

                step++;

                if( i < this.MajorTickmarkCount - 1 )
                {
                    for( int j = 0; j < this.MinorTickmarkCount; j++ )
                    {
                        // 小目盛り
                        value = ( this.EndAngle - this.StartAngle ) / division * step + this.StartAngle;

                        PointF pt1 = new PointF( (float)( ptCenter.X + radius * Math.Cos( CalcRadian( value ) ) ),
                                                 (float)( ptCenter.Y + radius * Math.Sin( CalcRadian( value ) ) ) );
                        PointF pt2 = new PointF( (float)( ptCenter.X + ( radius - 14 ) * Math.Cos( CalcRadian( value ) ) ),
                                                 (float)( ptCenter.Y + ( radius - 14 ) * Math.Sin( CalcRadian( value ) ) ) );
                        g.DrawLine( penMinor, pt1, pt2 );

                        step++;
                    }
                }
            }
        }
    }

大目盛りは台形を描画、小目盛りは線を描画しています。
ちなみに、ColorUtil.BlendColor()は中間色を作るためのヘルパー関数。
基準となる色から、暗めの色、明るめの色を作るために使っています。


最後に針の描画

    protected virtual void DrawNeedle(Graphics g)
    {
        PointF ptCenter = GetGuageCenter();
        float sizeGuage = GetGuageSize();
        float radius = sizeGuage / 2 - 16;

        double value = ( this.EndAngle - this.StartAngle ) /
                       ( this.MaxValue - this.MinValue ) *
                       ( this.CurrentValue - this.MinValue ) +
                       this.StartAngle;

        using( GraphicsPath path1 = new GraphicsPath() )
        using( GraphicsPath path2 = new GraphicsPath() )
        {
            PointF pt1 = new PointF( (float)( ptCenter.X + radius * Math.Cos( CalcRadian( value ) ) ),
                                     (float)( ptCenter.Y + radius * Math.Sin( CalcRadian( value ) ) ) );
            PointF pt2 = new PointF( (float)( ptCenter.X + 3 * Math.Cos( CalcRadian( value - 90 ) ) ),
                                     (float)( ptCenter.Y + 3 * Math.Sin( CalcRadian( value - 90 ) ) ) );
            PointF pt3 = new PointF( (float)( ptCenter.X + 3 * Math.Cos( CalcRadian( value + 90 ) ) ),
                                     (float)( ptCenter.Y + 3 * Math.Sin( CalcRadian( value + 90 ) ) ) );

            path1.AddLine( ptCenter, pt1 );
            path1.AddLine( pt1, pt2 );
            path1.CloseFigure();

            path2.AddLine( ptCenter, pt1 );
            path2.AddLine( pt1, pt3 );
            path2.CloseFigure();

            using( SolidBrush br1 = new SolidBrush( ColorUtil.BlendColor( this.NeedleColor, Color.Black, 0.1 ) ) )
            using( SolidBrush br2 = new SolidBrush( this.NeedleColor ) )
            using( Pen pen1 = new Pen( ColorUtil.BlendColor( this.NeedleColor, Color.Black, 0.1 ) ) )
            using( Pen pen2 = new Pen( this.NeedleColor ) )
            {
                g.FillPath( br2, path2 );
                g.DrawPath( pen2, path2 );

                g.FillPath( br1, path1 );
                g.DrawPath( pen1, path1 );
            }
        }

        RectangleF rcCenter = new RectangleF( ptCenter.X - 8, ptCenter.Y - 8, 16, 16 );
        using( LinearGradientBrush br = new LinearGradientBrush( rcCenter, this.NeedleColor, ColorUtil.BlendColor( this.NeedleColor, Color.Black, 0.5 ), 45 ) )
        {
            g.FillEllipse( br, rcCenter );
        }
    }

針はもっと単純な描画でも良かったんだけど、単色の描画だとゲージ背景が立体的な分、ちょっと違和感があるような気がしたので、半分ずつ色をわけるようにして、中央の●もグラデーションを使ってみました。


別バージョン

っで、プロパティを変更してみた別バーションはこんなカンジ。

それっぽく見えるかな(・∀・)?

*1:GradientFill()だとグラデーションの方向が限られるので、背景なんかは画像として事前準備かな(・ω・)?

*2:レンダーを外出しした方がもっとスッキリとカスタマイズできるけど(・ω・)