回文章列表
C#16 分鐘閱讀2026年7月15日

C# WinForms 統計圖表逐行解析:一段程式碼畫出柱狀圖+環形圖

#C##WinForms#資料視覺化#Chart

文章目錄

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

C# WinForms 畫出的統計圖表:上方數量柱狀圖、下方金額環形圖,底部圖例置中
C# WinForms 畫出的統計圖表:上方數量柱狀圖、下方金額環形圖,底部圖例置中

這張圖不是截別人的,就是本文這段程式碼實際跑出來的結果。很多教學把 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 螺旋練四次,最後就是做出這個可以展示的專案。

相關課程

C# 物件導向與桌面程式

練透物件導向四大支柱:類別與物件、封裝、繼承與多型、介面,再進入 WinForms 桌面應用開發,學會用控件與 DataGridView 打造圖形化介面,最後補上檔案讀寫與 CSV 存讀,讓程式能保存資料。

查看課程內容