庫存系統做到最後,資料都在資料庫裡躺得好好的——可是老闆想看的不是一列列的數字,而是「哪一類貨壓最多錢」。這時候把 Dictionary 統計結果丟給 WinForms 的 Chart 控件,就能畫出下面這張圖:上面是各分類的數量柱狀圖,下面是各分類的金額環形圖,一眼看出「生活類數量只有 20 件,卻壓了 77 萬的庫存」。

這張圖不是截別人的,就是本文這段程式碼實際跑出來的結果。很多教學把 Chart 當黑盒子,貼一段 code 說「這樣就會出圖」——但只要資料形狀一變就整個壞掉,因為你根本不知道每一行在做什麼。這篇反過來,把畫圖那段程式碼一行一行拆開講,看完你會知道:兩個圖表怎麼上下擺、每個資料點怎麼綁上去、顏色怎麼自動配。
先解決最容易卡的一步:NuGet 套件
在寫任何程式碼之前,先裝對套件,不然 Chart 這個型別根本找不到。這裡是新手最常卡死的地方:
- 要裝:社群維護的
WinForms.DataVisualization(相容 .NET 8) - 不要裝:官方的
System.Windows.Forms.DataVisualization(那是 .NET Framework 限定,在 .NET 8 裝不起來)
裝好之後,程式最上面引用命名空間——注意套件名和命名空間不一樣,這是另一個卡點:
using System.Windows.Forms.DataVisualization.Charting;套件叫 WinForms.DataVisualization,using 卻是 System.Windows.Forms.DataVisualization.Charting。記住這個落差,找不到 Chart 型別時第一個檢查這裡。
資料從哪來:Statistics 回傳的 decimal[2]
畫圖之前要有資料。庫存裡每個 Product 有「分類、數量、總金額」,我們先用一個 Dictionary 把同分類的加總起來:
public static Dictionary<string, decimal[]> Statistics(List<Product> all)
{
var stat = new Dictionary<string, decimal[]>();
foreach (var p in all)
{
if (!stat.ContainsKey(p.Category)) // 第一次看到這分類
stat[p.Category] = new decimal[2]; // 先開一格 [數量=0, 金額=0]
stat[p.Category][0] += p.Quantity; // 累加數量
stat[p.Category][1] += p.TotalValue; // 累加金額(數量 × 單價)
}
return stat;
}重點只有一個:每個分類對應一個長度 2 的 decimal[],[0] 放數量、[1] 放金額。所以跑完之後 stat 長這樣:
"生活" → [20, 776000]
"文具" → [200, 3000]
"食品" → [689, 66463]
"電子" → [20, 11980]上面那張圖的柱狀圖高度來自 [0]、環形圖的比例來自 [1]。理解這個資料形狀,下面的繪圖程式碼才看得懂——畫圖說到底就是把這個 Dictionary 的每一格搬到畫布上。
Chart 的三個零件
WinForms 的 Chart 控件由三層組成,順序記熟就不會亂:
1. Chart:整塊畫布(你從工具箱拖到表單上的那個控件)。 2. ChartArea:畫布裡的一個「座標系」。一個 Chart 可以放多個 ChartArea——我們就是靠這個把柱狀圖和環形圖上下分開。 3. Series:一組資料。柱狀圖是一個 Series、環形圖是另一個 Series,各自指定要畫在哪個 ChartArea。
流程永遠是:先鋪畫布 → 建座標系(ChartArea)→ 建資料組(Series)→ 把點 AddXY 進去。接下來兩段程式碼就是照這個順序走。
第一段:搭好版面(InitializeChartInfrastructure)
這個方法在建構子裡呼叫一次,負責把「兩個座標系上下擺好、圖例放底部」這種只需做一次的骨架搭起來。逐行看:
private void InitializeChartInfrastructure()
{
chart.ChartAreas.Clear();
chart.Legends.Clear();第一件事就是 Clear()。為什麼?因為你從工具箱把 Chart 拖到表單上時,Designer 會自動幫你塞一個空的 ChartArea1 和 Legend1 進去。如果不清掉,等一下自己加的座標系會跟它疊在一起打架。拖拉出來的 Chart 第一步一定先 Clear,這是 Designer 版最容易漏的陷阱。
// --- 1. 上:數量直條圖區域 ---
var areaQty = new ChartArea("AreaQuantity");
areaQty.AxisX.MajorGrid.Enabled = false;
areaQty.AxisY.MajorGrid.Enabled = false;
areaQty.Position = new ElementPosition(0, 0, 100, 48); // 佔上半版面
chart.ChartAreas.Add(areaQty);new ChartArea("AreaQuantity"):建一個座標系,取名AreaQuantity。名字很重要,等一下 Series 要靠這個字串指定「我要畫在哪個座標系」。AxisX.MajorGrid.Enabled = false和AxisY.MajorGrid.Enabled = false:關掉 X 軸和 Y 軸的主格線。柱狀圖不需要背景那些橫線豎線,關掉比較乾淨。Position = new ElementPosition(0, 0, 100, 48):這行是雙圖表的關鍵。四個數字是(X, Y, 寬, 高),單位是「佔整塊 Chart 的百分比」。所以這是「從左上角 (0,0) 開始,寬佔滿 100%、高佔 48%」——也就是上半塊。chart.ChartAreas.Add(areaQty):把座標系加進畫布。
// --- 2. 下:金額環形圖區域 ---
var areaValue = new ChartArea("AreaValue");
areaValue.Position = new ElementPosition(0, 50, 100, 48); // 佔下半版面
chart.ChartAreas.Add(areaValue);第二個座標系 AreaValue,Position 是 (0, 50, 100, 48)——從 Y=50% 開始往下,寬 100%、高 48%,正好是下半塊。上面 0~48、下面 50~98,中間留 2% 當縫,兩塊就不會重疊。
最容易被忽略的坑:如果不設Position,兩個 ChartArea 會整個疊在一起,畫面看起來一團亂但不會報任何錯——編譯照過、程式照跑,只是圖爛掉。看到雙圖表疊在一起,第一個檢查Position有沒有設。
// 圖例統一設定於下方置中
var legend = new Legend("L")
{
Docking = Docking.Bottom,
Alignment = StringAlignment.Center
};
chart.Legends.Add(legend);new Legend("L"):建一個圖例,取名L(等一下 DrawChart 也會用這個名字找它)。Docking = Docking.Bottom:圖例貼在整塊 Chart 的底部。Alignment = StringAlignment.Center:底部置中。就是成品圖最下面那排「■生活 ■文具 ■食品 ■電子」。
var stat = ProductService.Statistics(_all); // 統計各分類
DrawChart(stat); // 繪製圖表
lblTotal.Text = $"總庫存數量:{stat.Values.Sum(v => v[0])}";
}骨架搭好後:先叫 Statistics 算出那個 Dictionary,交給 DrawChart 去畫。最後一行用 LINQ 的 Sum 把每個分類的 v[0](數量)全部加起來,塞進表單下方的 lblTotal 標籤——就是成品圖最底下的「總庫存數量:929」。
Designer 命名陷阱:從工具箱拖 Chart 出來,它預設叫chart1;拖 Label 出來預設叫label1。這裡程式用的是chart和lblTotal,所以拖出來後要去屬性面板把名字改掉,不然會編譯錯誤「目前內容中沒有 chart 的定義」。
第二段:把資料畫上去(DrawChart)——本文重點
前面都是鋪路,這個方法才是真正「畫圖」的地方。它吃一個 Dictionary<string, decimal[]>,把每個分類變成柱狀圖的一根柱子+環形圖的一塊扇形。一段一段拆:
先清乾淨
private void DrawChart(Dictionary<string, decimal[]> stat)
{
chart.Series.Clear();
chart.Legends["L"].CustomItems.Clear();
var areaQty = chart.ChartAreas["AreaQuantity"];chart.Series.Clear():清掉舊資料組。這樣設計是為了「重畫」——資料變了就再呼叫一次 DrawChart,先清空再重畫,不會殘留舊資料。chart.Legends["L"].CustomItems.Clear():清掉圖例L的自訂項目(同理,避免重畫時累加)。chart.ChartAreas["AreaQuantity"]:用名字把上半塊那個座標系抓出來,存成areaQty方便下面調 X 軸。看到沒——這裡就是靠剛才取的名字AreaQuantity找回座標系的。
X 軸防重疊處理
areaQty.AxisX.Interval = stat.Count > 15 ? 0 : 1;
areaQty.AxisX.LabelStyle.Interval = stat.Count > 15 ? 0 : 1;
areaQty.AxisX.LabelStyle.Angle = stat.Count > 5 ? -45 : 0;這三行處理「分類太多時 X 軸標籤擠成一團」的問題,用三元運算子做自適應:
AxisX.Interval:軸的刻度間隔。分類超過 15 個就設0(讓它自動決定,不硬要每格都標),否則1(每格都標)。AxisX.LabelStyle.Interval:標籤的顯示間隔,邏輯同上。AxisX.LabelStyle.Angle:標籤傾斜角度。分類超過 5 個,就把文字轉-45度斜著放,避免「生活 文具 食品 電子…」橫著排擠在一起;5 個以內就0度平放。本例 4 個分類,所以是平放的。
建兩個 Series(兩組資料)
// --- 數列 1:數量直條圖 ---
var sQty = new Series("庫存量")
{
ChartType = SeriesChartType.Column,
ChartArea = "AreaQuantity",
IsValueShownAsLabel = true,
LabelForeColor = Color.Black,
IsVisibleInLegend = false
};第一組資料 sQty(柱狀圖),逐個屬性看:
new Series("庫存量"):建資料組,名字「庫存量」。ChartType = SeriesChartType.Column:圖表類型=直條圖。這一行就是「畫成柱狀」的開關——想換成折線圖,把Column改成Line就好,其他都不用動。ChartArea = "AreaQuantity":指定畫在上半塊。這就是 Series 和 ChartArea 的連結——用名字對上。IsValueShownAsLabel = true:在每根柱子上方直接顯示數值(成品圖柱子上的「20、200、689、20」)。LabelForeColor = Color.Black:那些數值標籤用黑色。IsVisibleInLegend = false:這組不進圖例。因為底部圖例已經用環形圖那組的分類名了,柱狀圖再進去會重複,所以關掉。
// --- 數列 2:金額環形圖 ---
var sValue = new Series("總金額")
{
ChartType = SeriesChartType.Doughnut, // 改為具現代感的環形圖
ChartArea = "AreaValue",
IsValueShownAsLabel = true,
LabelForeColor = Color.Black,
Label = "#VALX \n #VAL{C0}"
};
// 強制將標籤放置於圓環外部,避免文字互相重疊
sValue["PieLabelStyle"] = "Outside";第二組資料 sValue(環形圖):
- ChartType = SeriesChartType.Doughnut:環形圖(中間有洞的圓餅圖)。想要實心圓餅就改成 Pie。
- ChartArea = "AreaValue":畫在下半塊。
- Label = "#VALX \n #VAL{C0}":這是標籤格式字串,用了 Chart 的關鍵字:
- #VALX:這個點的 X 值(也就是分類名,例如「生活」)。
- \n:換行。
- #VAL{C0}:這個點的 Y 值,{C0} 是格式碼,C = 貨幣(Currency)、0 = 小數 0 位。所以會顯示成「NT$776,000」。
- 合起來就是成品圖環形外面那些「生活 / NT$776,000」的兩行標籤。
- sValue["PieLabelStyle"] = "Outside":把標籤放到圓環外面。這是環形圖的特殊設定,寫成索引子的形式(字串當 key)。放外面的好處是各塊小扇形的文字不會擠在環上重疊,Chart 還會自動畫牽引線把文字連到對應的扇形。
核心迴圈:把每個分類綁成一個點
int n = stat.Count, xi = 1, ci = 0;
foreach (var kv in stat)
{
var color = GetColor(ci, n);
decimal qtyAmount = kv.Value[0];
decimal totalValue = kv.Value[1];n = stat.Count:總共幾個分類(本例 4),配色會用到。xi = 1:柱狀圖的 X 座標,從 1 開始,每畫一根加 1。ci = 0:顏色索引,從 0 開始。foreach (var kv in stat):逐一走過 Dictionary 的每個分類。kv.Key是分類名、kv.Value是那個decimal[2]。GetColor(ci, n):算出這個分類該用的顏色(下一節細講)。同一個分類,柱狀圖和環形圖用同一個顏色,這樣兩張圖才對得起來。qtyAmount = kv.Value[0]:取數量(陣列第 0 格)。totalValue = kv.Value[1]:取金額(陣列第 1 格)。還記得Statistics就是這樣裝的嗎——這裡把它取回來。
// 1. 左側直條圖綁定
int ptQtyIdx = sQty.Points.AddXY(xi, (double)qtyAmount);
sQty.Points[ptQtyIdx].AxisLabel = kv.Key;
sQty.Points[ptQtyIdx].Color = color;畫柱狀圖的一根柱子:
sQty.Points.AddXY(xi, (double)qtyAmount):加一個資料點,X=xi(第幾根)、Y=數量。AddXY回傳這個點在集合裡的索引,存成ptQtyIdx好接著設定它。注意decimal要轉double,因為AddXY收double。.AxisLabel = kv.Key:把這根柱子在 X 軸下方的文字設成分類名(「生活」「文具」…)。因為 X 值是數字 1、2、3、4,要靠AxisLabel顯示成中文分類名。.Color = color:這根柱子的顏色。
// 2. 右側環形圖綁定
int ptValIdx = sValue.Points.AddXY(kv.Key, (double)totalValue);
sValue.Points[ptValIdx].Color = color;
sValue.Points[ptValIdx].LegendText = kv.Key;畫環形圖的一塊扇形:
sValue.Points.AddXY(kv.Key, (double)totalValue):加一個資料點,這次 X 直接放分類名字串、Y=金額。環形圖的每一塊扇形就是一個點,扇形大小由金額佔比決定。.Color = color:跟上面柱子同一個color——所以「生活」在上圖是紅柱、在下圖也是紅扇形,視覺上對得起來。.LegendText = kv.Key:這塊扇形在底部圖例顯示的文字=分類名。
xi++;
ci++;
}
chart.Series.Add(sQty);
chart.Series.Add(sValue);
}迴圈結尾把 xi、ci 各加 1,換下一個分類。跑完整個迴圈後,才把兩組資料一次加進 Chart——Add(sQty) 和 Add(sValue)。到這裡,圖就出來了。
顏色怎麼來的:HSL 自動配色
上面一直用到的 GetColor,是為了讓每個分類自動拿到一個「均勻分佈、飽和度一致」的顏色,不用手動一個個指定色碼:
private static Color GetColor(int index, int total) =>
ColorFromHsl(360f * index / Math.Max(total, 1), 0.65f, 0.52f);360f * index / total:把 0~360 度的色相環(Hue)平均切成 total 份,第 index 個分類拿第 index 份。4 個分類就是 0°、90°、180°、270°——紅、綠、青、紫,剛好是成品圖那四個顏色。Math.Max(total, 1):防呆,避免total是 0 時除以零。0.65f(飽和度)、0.52f(亮度):固定值,讓所有顏色鮮豔度、明暗一致,看起來像同一套配色。
ColorFromHsl 則是把 HSL 換算成 WinForms 認得的 RGB Color:
private static Color ColorFromHsl(float h, float s, float l)
{
float c = (1 - Math.Abs(2 * l - 1)) * s;
float x = c * (1 - Math.Abs(h / 60 % 2 - 1));
float m = l - c / 2;
(float r, float g, float b) = h switch
{
< 60 => (c, x, 0f),
< 120 => (x, c, 0f),
< 180 => (0f, c, x),
< 240 => (0f, x, c),
< 300 => (x, 0f, c),
_ => (c, 0f, x)
};
int R = Math.Clamp((int)((r + m) * 255), 0, 255);
int G = Math.Clamp((int)((g + m) * 255), 0, 255);
int B = Math.Clamp((int)((b + m) * 255), 0, 255);
return Color.FromArgb(R, G, B);
}這段是標準的 HSL→RGB 數學公式,不用背,知道它在做什麼就好:
c、x、m是公式裡的中間量(chroma 彩度、次要分量、亮度補償)。h switch { ... }:用 C# 的 switch 運算式,看色相落在哪個 60 度區間(< 60、< 120…),決定 RGB 三個分量怎麼組合。這比一長串if-else乾淨很多。Math.Clamp((int)((r + m) * 255), 0, 255):把 0~1 的分量放大到 0~255,Clamp夾在合法範圍內防止溢位。Color.FromArgb(R, G, B):組出最終顏色。
如果你不需要這麼講究,其實可以直接準備一個固定色盤 Color[] palette = { Color.Red, Color.Green, ... } 然後 palette[index % palette.Length] 輪流用,效果類似、更好懂。這裡用 HSL 只是為了「分類數不固定」時也能均勻配色。
常見卡點快速對照
把上面散落的坑集中成一張表,遇到問題照著查:
- 找不到
Chart型別:NuGet 沒裝,或裝成官方 Framework 版。要裝社群版WinForms.DataVisualization。 - 編譯錯誤「沒有 chart 的定義」:拖出來的
chart1/label1忘了改名成chart/lblTotal。 - 兩張圖疊在一起、畫面很亂但不報錯:ChartArea 的
Position沒設。 - 圖是空白的:忘了
ChartAreas.Add(...),或忘了在 DrawChart 開頭Clear()掉 Designer 自動加的空 Series/ChartArea。 NullReferenceException或IndexOutOfRange:Statistics裡忘了先new decimal[2]就直接+=。- 環形圖標籤擠成一團:
sValue["PieLabelStyle"] = "Outside"沒設。
換一個字就換一種圖
看懂上面的結構後,你會發現「換圖表類型」其實只改一個字。SeriesChartType 那行:
ChartType = SeriesChartType.Column, // 直條圖
// ChartType = SeriesChartType.Bar, // 橫條圖
// ChartType = SeriesChartType.Line, // 折線圖
// ChartType = SeriesChartType.Pie, // 圓餅圖
// ChartType = SeriesChartType.Doughnut, // 環形圖資料綁定(AddXY 那段)完全不用動,Chart 會自己換一種畫法。這也是為什麼建議先把資料統計和畫圖分開——資料對了,要換什麼圖都只是一行的事。
這段程式碼是 C# 庫存整合專案的收尾。如果你想從變數、物件導向一路練到 WinForms 桌面應用、SQLite 資料庫、再到這種資料視覺化,C# .NET 實戰:桌面程式與資料庫開發 這門課用同一個「商品/庫存系統」當主線,把 CRUD 螺旋練四次,最後就是做出這個可以展示的專案。