在 WinForms/WPF 應用中繪製專業級曲線圖表,Chart 控制項雖強大但部署繁瑣。本文提供純 GDI+ 繪製的 CurveChart 元件:自動計算座標範圍與刻度、支援多曲線同屏顯示、動態適應容器大小、高效能雙緩衝繪圖、完整樣式客製化。零第三方依賴,程式碼開放可直接嵌入專案。
Resize 時自動重新計算佈局與刻度
public class CurveSeries
{
public string Name { get; set; }
public float[] Values { get; set; }
public Color Color { get; set; }
public int LineWidth { get; set; } = 2;
public float Tension { get; set; } = 0.5f; // Catmull-Rom spline 張力
}
public class CurveChartOptions
{
public string Title { get; set; } = "曲線圖";
public string XAxisLabel { get; set; } = "X 軸";
public string YAxisLabel { get; set; } = "Y 軸";
public string[] XLabels { get; set; } // X 軸標籤 (如月份)
public float YSliceValue { get; set; } = 20; // Y 軸刻度數值間距
public Color BackColor { get; set; } = Color.White;
public Color GridColor { get; set; } = Color.LightGray;
public Color AxisColor { get; set; } = Color.Black;
public Color TextColor { get; set; } = Color.Black;
public int FontSize { get; set; } = 9;
public float XRotateAngle { get; set; } = 30; // X 標籤旋轉角度
}
using System;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Windows.Forms;
public class CurveChart : Control
{
private CurveChartOptions _options = new CurveChartOptions();
private CurveSeries[] _series = Array.Empty();
private Bitmap _backBuffer;
private readonly object _drawLock = new object();
public CurveChartOptions Options { get => _options; set { _options = value; Invalidate(); } }
public CurveSeries[] Series { get => _series; set { _series = value; Fit(); Invalidate(); } }
public CurveChart()
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
DoubleBuffered = true;
}
// ===== 自動計算佈局參數 =====
public void Fit()
{
if (_series.Length == 0 || _options.XLabels?.Length == 0) return;
int padding = _options.FontSize + 5;
float space = Math.Min(Width, Height) / 6f;
float xSpace = space, ySpace = space;
// X 軸刻度寬度
float xSlice = (Width - 2 * xSpace) / (_options.XLabels.Length - 1);
// Y 軸範圍計算
float minVal = float.MaxValue, maxVal = float.MinValue;
foreach (var s in _series)
foreach (var v in s.Values)
{
if (v < minVal) minVal = v;
if (v > maxVal) maxVal = v;
}
// 向下取整到刻度倍數
float ySliceBegin = (float)Math.Floor(minVal / _options.YSliceValue) * _options.YSliceValue;
int ySliceCount = (int)Math.Ceiling((maxVal - ySliceBegin) / _options.YSliceValue);
float ySlice = (Height - 2 * ySpace) / ySliceCount;
// 快取佈局參數供 Paint 使用
_layout = new LayoutParams
{
XSpace = xSpace, YSpace = ySpace,
XSlice = xSlice, YSlice = ySlice,
YSliceBegin = ySliceBegin,
YSliceCount = ySliceCount,
FontSpace = padding
};
}
private LayoutParams _layout;
private class LayoutParams
{
public float XSpace, YSpace, XSlice, YSlice, YSliceBegin;
public int YSliceCount, FontSpace;
}
// ===== 核心繪製邏輯 =====
protected override void OnPaint(PaintEventArgs e)
{
base.OnPaint(e);
if (_series.Length == 0 || _options.XLabels?.Length == 0) return;
lock (_drawLock)
{
// 雙緩衝:先畫到 Bitmap 再一次性輸出
if (_backBuffer == null || _backBuffer.Size != ClientSize)
_backBuffer = new Bitmap(ClientSize.Width, ClientSize.Height);
using (var g = Graphics.FromImage(_backBuffer))
{
g.SmoothingMode = SmoothingMode.AntiAlias;
g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.ClearTypeGridFit;
g.Clear(_options.BackColor);
DrawBorderAndBackground(g);
DrawAxes(g);
DrawGridLines(g);
DrawXAxisLabels(g);
DrawYAxisLabels(g);
DrawTitle(g);
DrawCurves(g);
}
e.Graphics.DrawImageUnscaled(_backBuffer, 0, 0);
}
}
private void DrawBorderAndBackground(Graphics g)
{
g.DrawRectangle(new Pen(_options.AxisColor), 0, 0, Width - 1, Height - 1);
}
private void DrawAxes(Graphics g)
{
var p = new Pen(_options.AxisColor);
float x1 = _layout.XSpace, y1 = Height - _layout.YSpace;
float x2 = Width - _layout.XSpace + _layout.XSlice / 2, y2 = y1;
g.DrawLine(p, x1, y1, x2, y2); // X 軸
x1 = _layout.XSpace; y1 = Height - _layout.YSpace;
x2 = _layout.XSpace; y2 = _layout.YSpace - _layout.YSlice / 2;
g.DrawLine(p, x1, y1, x2, y2); // Y 軸
}
private void DrawGridLines(Graphics g)
{
var dashPen = new Pen(_options.GridColor) { DashStyle = DashStyle.Dash };
// 垂直網格線 (對應 X 刻度)
for (int i = 0; i < _options.XLabels.Length; i++)
{
float x = _layout.XSpace + i * _layout.XSlice;
g.DrawLine(dashPen, x, _layout.YSpace, x, Height - _layout.YSpace);
}
// 水平網格線 (對應 Y 刻度)
for (int i = 0; i <= _layout.YSliceCount; i++)
{
float y = Height - _layout.YSpace - i * _layout.YSlice;
g.DrawLine(dashPen, _layout.XSpace, y, Width - _layout.XSpace, y);
}
}
private void DrawXAxisLabels(Graphics g)
{
var font = new Font("Microsoft JhengHei", _options.FontSize);
var brush = new SolidBrush(_options.TextColor);
float y = Height - _layout.YSpace + 2;
for (int i = 0; i < _options.XLabels.Length; i++)
{
float x = _layout.XSpace + i * _layout.XSlice;
var size = g.MeasureString(_options.XLabels[i], font);
g.TranslateTransform(x, y);
g.RotateTransform(_options.XRotateAngle);
g.DrawString(_options.XLabels[i], font, brush, -size.Width / 2, 0);
g.ResetTransform();
}
}
private void DrawYAxisLabels(Graphics g)
{
var font = new Font("Microsoft JhengHei", _options.FontSize);
var brush = new SolidBrush(_options.TextColor);
float x = _layout.XSpace - 5;
for (int i = 0; i <= _layout.YSliceCount; i++)
{
float val = _layout.YSliceBegin + i * _options.YSliceValue;
float y = Height - _layout.YSpace - i * _layout.YSlice;
string text = val.ToString("0.##");
var size = g.MeasureString(text, font);
g.TranslateTransform(x - size.Width, y - size.Height / 2);
g.RotateTransform(_options.XRotateAngle); // 同樣角度或 0
g.DrawString(text, font, brush, 0, 0);
g.ResetTransform();
}
}
private void DrawTitle(Graphics g)
{
var font = new Font("Microsoft JhengHei", _options.FontSize + 2, FontStyle.Bold);
var brush = new SolidBrush(_options.TextColor);
var size = g.MeasureString(_options.Title, font);
float x = (Width - size.Width) / 2;
float y = _layout.YSpace / 2 - size.Height / 2;
g.DrawString(_options.Title, font, brush, x, y);
}
private void DrawCurves(Graphics g)
{
if (_series.Length == 0) return;
int pointCount = _options.XLabels.Length;
for (int s = 0; s < _series.Length; s++)
{
var series = _series[s];
if (series.Values?.Length != pointCount) continue;
var points = new PointF[pointCount];
for (int i = 0; i < pointCount; i++)
{
float x = _layout.XSpace + i * _layout.XSlice;
// Y 軸座標轉換:畫面座標系 Y 向下,數值座標系 Y 向上
float normalizedY = (series.Values[i] - _layout.YSliceBegin) / _options.YSliceValue;
float y = Height - _layout.YSpace - normalizedY * _layout.YSlice;
points[i] = new PointF(x, y);
}
using (var pen = new Pen(series.Color, series.LineWidth))
{
g.DrawCurve(pen, points, series.Tension);
}
// 繪製資料點標記 (可選)
foreach (var pt in points)
g.FillEllipse(Brushes.White, pt.X - 3, pt.Y - 3, 6, 6);
}
}
protected override void OnResize(EventArgs e)
{
base.OnResize(e);
Fit(); // 重新計算佈局
Invalidate();
}
}
public partial class MainForm : Form
{
private CurveChart _chart;
public MainForm()
{
InitializeComponent();
InitializeChart();
LoadSampleData();
}
private void InitializeChart()
{
_chart = new CurveChart
{
Dock = DockStyle.Fill,
Options = new CurveChartOptions
{
Title = "月度銷售趨勢比較",
XAxisLabel = "月份",
YAxisLabel = "銷售額 (萬元)",
XLabels = new[] { "1月", "2月", "3月", "4月", "5月", "6月",
"7月", "8月", "9月", "10月", "11月", "12月" },
YSliceValue = 50,
FontSize = 10
}
};
this.Controls.Add(_chart);
}
private void LoadSampleData()
{
_chart.Series = new[]
{
new CurveSeries
{
Name = "2023 年",
Values = new float[] { 120, 135, 150, 165, 180, 195, 210, 200, 190, 185, 175, 160 },
Color = Color.Blue
},
new CurveSeries
{
Name = "2024 年",
Values = new float[] { 130, 145, 160, 175, 190, 210, 225, 220, 210, 200, 190, 180 },
Color = Color.Red
},
new CurveSeries
{
Name = "目標值",
Values = new float[] { 150, 150, 150, 150, 150, 150, 150, 150, 150, 150, 150, 150 },
Color = Color.Green,
LineWidth = 1,
Tension = 0f // 直線
}
};
_chart.Fit();
}
}
| 技術 | 實作 | 效益 |
|---|---|---|
| 雙緩衝 | ControlStyles.OptimizedDoubleBuffer + 手動 Bitmap 後台繪製 | 消除閃爍,支援 60fps 以上更新 |
| 佈局快取 | Fit() 計算佈局參數存入 LayoutParams,Paint 直接使用 | 避免每幀重複計算座標轉換 |
| 筆刷/筆重用 | using 區塊內建立,或靜態快取常用樣式 | 減少 GDI 物件建立/銷毀開銷 |
| 區域失效 | 僅資料更新時 Invalidate(),非完整重繪 | 局部更新效能提升 |
MouseMove 事件計算最近資料點,顯示 ToolTipBitmap.Save 輸出 PNG、複製到剪貼簿DrawingContext + WriteableBitmap 實現同樣 APICurveChart 元件以 純 GDI+、零依賴、全原始碼開放 實現專業級多曲線圖表:自動縮放適應資料範圍、動態響應容器大小變化、雙緩衝高效能渲染、豐富樣式客製化。程式碼結構清晰,易於擴充工具提示、縮放平移、圖例等進階互動功能,是 WinForms 應用內建圖表的理想選擇。