使用 WPF 生成圖形


 

下載代碼示例

基於一組與測試有關的數據來生成圖形是一項常見的軟件開發任務。根據我的經驗,最常用的方法是將數據導入 Excel 電子表格,然后使用 Excel 內置的繪圖功能手動生成圖形。這種做法適用於大多數情況,但是如果基礎數據頻繁更改,則手動創建圖形可能很快就變得枯燥乏味。在本月的專欄中,我將向您演示如何使用 Windows Presentation Foundation (WPF) 技術自動執行該過程。若要了解我所闡述的觀點,請看圖 1。該圖按日期顯示打開和已關閉的錯誤的計數,是使用從簡單文本文件讀取數據的一個短小 WPF 程序動態生成的。

 

 


圖 1 以編程方式生成的錯誤計數圖

打開的錯誤(用藍色線條上的紅圈表示)在開發工作開始后不久迅速增多,然后隨時間推移逐漸減少(這是在估計零錯誤反彈日期時可能十分有用的信息)。已關閉的錯誤(綠色線條上的三角形標記)則穩步增多。

雖然這些信息可能十分有用,但在生產環境中,開發資源通常是有限的,因此手動生成這類圖形可能不太值得。但是使用我將說明的技術,可快速而輕松地創建這類圖形。

在下面幾節中,我將詳細展示和說明用於生成圖 1 中圖形的 C# 代碼。本專欄假設您已具備 C# 編碼方面的中級知識,並對 WPF 有最基本的了解。不過,即使您從前沒有接觸過這兩個領域,我認為您也能夠理解我所討論的內容。我相信您會發現這項技術對於您的綜合技能是個有趣且有用的補充。

建立項目

我首先啟動 Visual Studio 2008,並使用 WPF 應用程序模板新建一個 C# 項目。從“新建項目”對話框右上方區域的下拉控件中選擇 .NET Framework 3.5 庫。將項目命名為 BugGraph。雖然您可以使用 WPF 基元以編程方式生成圖形,但我使用了方便的 DynamicDataDisplay 庫(由 Microsoft 研究院實驗室開發)。

您可以從位於 codeplex.com/dynamicdatadisplay 的 CodePlex 開源托管站點下載該庫。我將副本保存在 BugGraph 項目的根目錄中,然后右鍵單擊項目名稱,選擇“添加引用”選項並指向根目錄中的 DLL 文件,從而在項目中添加對 DLL 的引用。

接下來創建源數據。在生產環境中,您的數據可以位於 Excel 電子表格、SQL 數據庫或 XML 文件中。為簡單起見,我使用簡單文本文件。在 Visual Studio 解決方案資源管理器窗口中,右鍵單擊項目名稱,然后從上下文菜單中選擇“添加”|“新建項”。然后選擇“文本文件”項,將文件重命名為 BugInfo.txt,並單擊“添加”按鈕。下面是虛擬數據:

01/15/2010:0:0
02/15/2010:12:5
03/15/2010:60:10
04/15/2010:88:20
05/15/2010:75:50
06/15/2010:50:70
07/15/2010:40:85
08/15/2010:25:95
09/15/2010:18:98
10/15/2010:10:99

每行中的第一個冒號分隔字段包含一個日期,第二個字段包含關聯日期的打開錯誤數,第三個字段顯示已關閉錯誤數。正如稍后您將看到的那樣,DynamicDataDisplay 庫可以處理大多數類型的數據。

接下來,我雙擊 Window1.xaml 文件,以加載項目的 UI 定義。添加對繪圖庫 DLL 的引用,並對 WPF 顯示區域的默認 Width、Height 和 Background 特性稍加修改,如下所示:

 
復制
xmlns:d3="http://research.microsoft.com/DynamicDataDisplay/1.0" 
Title="Window1" WindowState="Normal" Height="500" Width="800" Background="Wheat">

然后,添加關鍵的繪圖對象,如圖 2 所示。

圖 2 添加關鍵的繪圖對象

 
復制
<d3:ChartPlotter Name="plotter" Margin="10,10,20,10">
  <d3:ChartPlotter.HorizontalAxis>
    <d3:HorizontalDateTimeAxis Name="dateAxis"/>
  </d3:ChartPlotter.HorizontalAxis>
  <d3:ChartPlotter.VerticalAxis>
    <d3:VerticalIntegerAxis Name="countAxis"/>
  </d3:ChartPlotter.VerticalAxis>

  <d3:Header FontFamily="Arial" Content="Bug Information"/>
  <d3:VerticalAxisTitle FontFamily="Arial" Content="Count"/>
  <d3:HorizontalAxisTitle FontFamily="Arial" Content="Date"/>
</d3:ChartPlotter>

ChartPlotter 元素是主要顯示對象。在該元素的定義中,我添加了水平日期軸和垂直整數軸的聲明。DynamicDataDisplay 庫的默認軸類型是具有小數部分的數字(在 C# 術語中稱為 double 類型);該類型無需顯式軸聲明。我還添加了一個標頭標題聲明和軸標題聲明。圖 3 顯示迄今為止的設計。


圖 3 BugGraph 程序設計

轉到源代碼

配置了項目的靜態內容后,便已准備就緒,可以添加用於讀取源數據並以編程方式生成圖形的代碼。在解決方案資源管理器窗口中雙擊 Window1.xaml.cs 文件,以將該 C# 文件加載到代碼編輯器中。圖 4 列出了生成圖 1 中圖形的程序的完整源代碼。

圖 4 BugGraph 項目的源代碼

 
復制
using System;
using System.Collections.Generic;
using System.Windows;
using System.Windows.Media; // Pen

using System.IO;
using Microsoft.Research.DynamicDataDisplay; // Core functionality
using Microsoft.Research.DynamicDataDisplay.DataSources; // EnumerableDataSource
using Microsoft.Research.DynamicDataDisplay.PointMarkers; // CirclePointMarker

namespace BugGraph
{
  public partial class Window1 : Window
  {
    public Window1()
    {
      InitializeComponent();
      Loaded += new RoutedEventHandler(Window1_Loaded);
    }

    private void Window1_Loaded(object sender, RoutedEventArgs e)
    {
      List<BugInfo> bugInfoList = LoadBugInfo("..\\..\\BugInfo.txt");

      DateTime[] dates = new DateTime[bugInfoList.Count];
      int[] numberOpen = new int[bugInfoList.Count];
      int[] numberClosed = new int[bugInfoList.Count];

      for (int i = 0; i < bugInfoList.Count; ++i)
      {
        dates[i] = bugInfoList[i].date;
        numberOpen[i] = bugInfoList[i].numberOpen;
        numberClosed[i] = bugInfoList[i].numberClosed;
      }

      var datesDataSource = new EnumerableDataSource<DateTime>(dates);
      datesDataSource.SetXMapping(x => dateAxis.ConvertToDouble(x));

      var numberOpenDataSource = new EnumerableDataSource<int>(numberOpen);
      numberOpenDataSource.SetYMapping(y => y);

      var numberClosedDataSource = new EnumerableDataSource<int>(numberClosed);
      numberClosedDataSource.SetYMapping(y => y);

      CompositeDataSource compositeDataSource1 = new
        CompositeDataSource(datesDataSource, numberOpenDataSource);
      CompositeDataSource compositeDataSource2 = new
        CompositeDataSource(datesDataSource, numberClosedDataSource);

      plotter.AddLineGraph(compositeDataSource1,
        new Pen(Brushes.Blue, 2),
        new CirclePointMarker { Size = 10.0, Fill = Brushes.Red },
        new PenDescription("Number bugs open"));

      plotter.AddLineGraph(compositeDataSource2,
        new Pen(Brushes.Green, 2),
        new TrianglePointMarker { Size = 10.0,
          Pen = new Pen(Brushes.Black, 2.0),
            Fill = Brushes.GreenYellow },
        new PenDescription("Number bugs closed"));

      plotter.Viewport.FitToView();

    } // Window1_Loaded()

    private static List<BugInfo> LoadBugInfo(string fileName)
    {
      var result = new List<BugInfo>();
      FileStream fs = new FileStream(fileName, FileMode.Open);
      StreamReader sr = new StreamReader(fs);
     
      string line = "";
      while ((line = sr.ReadLine()) != null)
      {
        string[] pieces = line.Split(':');
        DateTime d = DateTime.Parse(pieces[0]);
        int numopen = int.Parse(pieces[1]);
        int numclosed = int.Parse(pieces[2]);
        BugInfo bi = new BugInfo(d, numopen, numclosed);
        result.Add(bi);
      }
      sr.Close();
      fs.Close();
      return result;
    }

  } // class Window1

  public class BugInfo {
  public DateTime date;
  public int numberOpen;
  public int numberClosed;

  public BugInfo(DateTime date, int numberOpen, int numberClosed) {
    this.date = date;
    this.numberOpen = numberOpen;
    this.numberClosed = numberClosed;
  }

}} // ns

我刪除了 Visual Studio 模板生成的不必要的 using 命名空間語句(如 System.Windows.Shapes)。然后向 DynamicDataDisplay 庫中的三個命名空間添加了 using 語句,從而不必完全限定其名稱。接下來,在 Window1 構造函數中為程序定義的主例程添加一個事件:

 
復制
Loaded += new RoutedEventHandler(Window1_Loaded);

下面是該主例程的開頭部分:

 
復制
private void Window1_Loaded(object sender, RoutedEventArgs e)
{
  List<BugInfo> bugInfoList = LoadBugInfo("..\\..\\BugInfo.txt");
  ...

我聲明了一個泛型列表對象 bugInfoList,並使用一個程序定義的幫助器方法(名為 LoadBugInfo)將文件 BugInfo.txt 中的虛擬數據填充到該列表中。為了組織我的錯誤信息,我聲明了一個小幫助器類 BugInfo,如圖 5 所示。

圖 5 幫助器類 BugInfo

 
復制
public class BugInfo {
  public DateTime date;
  public int numberOpen;
  public int numberClosed;

  public BugInfo(DateTime date, int numberOpen, int numberClosed) {
    this.date = date;
    this.numberOpen = numberOpen;
    this.numberClosed = numberClosed;
  }
}

為簡單起見,我將三個數據字段聲明為公共類型,而不是聲明為與 get 和 set 屬性相結合的私有類型。因為 BugInfo 只是數據,所以我可以使用 C# 結構而不使用類。LoadBugInfo 方法打開 BugInfo.txt 文件並遍歷該文件,分析每個字段,然后實例化 BugInfo 對象,並將每個 BugInfo 對象存儲到結果列表中,如圖 6 所示。

圖 6 LoadBugInfo 方法

 
復制
private static List<BugInfo> LoadBugInfo(string fileName)
{
  var result = new List<BugInfo>();
  FileStream fs = new FileStream(fileName, FileMode.Open);
  StreamReader sr = new StreamReader(fs);
     
  string line = "";
  while ((line = sr.ReadLine()) != null)
  {
    string[] pieces = line.Split(':');
    DateTime d = DateTime.Parse(pieces[0]);
    int numopen = int.Parse(pieces[1]);
    int numclosed = int.Parse(pieces[2]);
    BugInfo bi = new BugInfo(d, numopen, numclosed);
    result.Add(bi);
  }
  sr.Close();
  fs.Close();
  return result;
}

我可以使用 File.ReadAllLines 方法將數據文件中的所有行讀入一個字符串數組,而不是讀取並處理該文件中的每一行。請注意,為了使代碼短小、清晰,我省略了常規的錯誤檢查步驟,但您在生產環境中應執行該檢查。

接下來,我對三個數組進行聲明並賦值,如圖 7 所示。

圖 7 構建數組

 
復制
DateTime[] dates = new DateTime[bugInfoList.Count];
  int[] numberOpen = new int[bugInfoList.Count];
  int[] numberClosed = new int[bugInfoList.Count];

  for (int i = 0; i < bugInfoList.Count; ++i)
  {
    dates[i] = bugInfoList[i].date;
    numberOpen[i] = bugInfoList[i].numberOpen;
    numberClosed[i] = bugInfoList[i].numberClosed;
  }
  ...

使用 DynamicDataDisplay 庫時,將顯示數據組織為一維數組集通常很方便。作為我的程序設計(即將數據讀入一個列表對象,然后將列表數據傳輸到數組)的替代方法,我可以將數據直接讀入數組。

接下來,我將數據數組轉換為特殊的 EnumerableDataSource 類型:

 
復制
var datesDataSource = new EnumerableDataSource<DateTime>(dates);
datesDataSource.SetXMapping(x => dateAxis.ConvertToDouble(x));

var numberOpenDataSource = new EnumerableDataSource<int>(numberOpen);
numberOpenDataSource.SetYMapping(y => y);

var numberClosedDataSource = new EnumerableDataSource<int>(numberClosed);
numberClosedDataSource.SetYMapping(y => y);
...

對於 DynamicDataDisplay 庫,要繪制的所有數據都必須為統一格式。我只是將三個數據數組傳遞給泛型 EnumerableDataSource 構造函數。此外,必須告知該庫與每個數據源關聯的軸(x 軸或 y 軸)。SetXMapping 和 SetYMapping 方法接受將方法委托作為參數。我使用了 lambda 表達式來創建匿名方法,而不是定義顯式委托。DynamicDataDisplay 庫的基本軸數據類型是 double。SetXMapping 和 SetYMapping 方法將我的特殊數據類型映射到 double 類型。

x 軸上,我使用 ConvertToDouble 方法將 DateTime 數據顯式轉換為 double 類型。在 y 軸上,我只是編寫 y => y(讀作“y 轉為 y”),將輸入 int y 隱式轉換為輸出 double y。我也可以通過編寫 SetYMapping(y => Convert.ToDouble(y) 來顯式進行類型映射。我可以任意選擇 xy 作為 lambda 表達式的參數,即,我可以使用任意參數名稱。

下一步是組合 x 軸和 y 軸數據源:

 
復制
CompositeDataSource compositeDataSource1 = new
  CompositeDataSource(datesDataSource, numberOpenDataSource);

CompositeDataSource compositeDataSource2 = new
  CompositeDataSource(datesDataSource, numberClosedDataSource);

...

圖 1 中的屏幕截圖顯示了在同一個圖形中繪制的兩個數據系列,即打開的錯誤數和已關閉的錯誤數。每個復合數據源定義一個數據系列,因此,我在此處需要兩個單獨的數據源:一個用於打開的錯誤數,一個用於已關閉的錯誤數。當數據全都准備好時,實際上只需一條語句便可繪制數據點:

 
復制
plotter.AddLineGraph(compositeDataSource1,
  new Pen(Brushes.Blue, 2),
  new CirclePointMarker { Size = 10.0, Fill = Brushes.Red },
  new PenDescription("Number bugs open"));

...

AddLineGraph 方法接受 CompositeDataSource,后者定義要繪制的錯誤以及有關確切的繪制方式的信息。此處,我指示名為 plotter 的繪圖器對象(在 Window1.xaml 文件中定義)執行以下操作:使用粗細為 2 的藍色線條繪制一個圖形,放置具有紅色邊框和紅色填充且大小為 10 的圓圈標記,並添加系列標題 Number bugs open。太巧妙了!作為許多備選方法中的一種,我可以使用

 
復制
plotter.AddLineGraph(compositeDataSource1, Colors.Red, 1, "Number Open")

來繪制不帶標記的細紅色線條。或者,我也可以創建虛線而不是實線:

 
復制
Pen dashedPen = new Pen(Brushes.Magenta, 3);
dashedPen.DashStyle = DashStyles.DashDot;
plotter.AddLineGraph(compositeDataSource1, dashedPen,
  new PenDescription("Open bugs"));

我的程序最后會繪制第二個數據系列:

 
復制
... 
    plotter.AddLineGraph(compositeDataSource2,
    new Pen(Brushes.Green, 2),
    new TrianglePointMarker { Size = 10.0,
      Pen = new Pen(Brushes.Black, 2.0),
      Fill = Brushes.GreenYellow },
    new PenDescription("Number bugs closed"));

  plotter.Viewport.FitToView();

} // Window1_Loaded()

此處,我指示繪圖器使用帶有三角形標記的綠色線條,這些三角形標記具有黑色邊框和黃綠色填充。FitToView 方法將圖形縮放為 WPF 窗口的大小。

指示 Visual Studio 生成 BugGraph 項目后,我獲得 BugGraph.exe 可執行文件,可以隨時以手動方式或編程方式啟動該文件。我只需編輯 BugInfo.txt 文件就可更新基礎數據。因為整個系統基於 .NET Framework 代碼,所以我可將繪圖功能輕松地集成到任何 WPF 項目中,而不必處理跨技術問題。DynamicDataDisplay 庫還有一個 Silverlight 版本,因此我也可以向 Web 應用程序中添加編程繪圖功能。

散點圖

前一節中展示的技術可以應用於所有類型的數據,而不僅是與測試相關的數據。我們來簡單了解一下另一個簡單但令人印象相當深刻的示例。圖 8 中的屏幕截圖顯示了 13,509 個美國城市。


圖 8 散點圖示例

您可能可以識別出福羅里達州、德克薩斯州、南加利福尼亞州以及五大湖的位置。我從一個庫獲得了該散點圖的數據,該庫中的數據旨在用於旅行商問題 (www.iwr.uni-heidelberg.de/groups/comopt/software/TSPLIB95),這在計算機科學領域是一個最有名且廣為研究的主題之一。我使用的文件 usa13509.tsp.gz 類似於:

 
復制
NAME : usa13509
(other header information)
1 245552.778 817827.778
2 247133.333 810905.556
3 247205.556 810188.889
...

13507 489663.889 972433.333
13508 489938.889 1227458.333
13509 490000.000 1222636.111

第一個字段是從 1 開始的索引 ID。第二個和第三個字段表示從具有 500 或更多人口的美國城市的緯度和經度派生而來的坐標。我按照前一節中所述創建了一個新 WPF 應用程序,向項目中添加了一個文本文件項,並將城市數據復制到該文件中。我在數據文件的標頭行前面添加了雙斜杠 (//) 字符,從而注釋掉這些行。

若要創建圖 8 中所示的散點圖,我只需對前一節中展示的示例稍加更改即可。我修改了 MapInfo 類成員,如下所示:

 
復制
public int id;
  public double lat;
  public double lon;

圖 9 顯示了修改后的 LoadMapInfo 方法中的關鍵處理循環。

圖 9 散點圖的循環

 
復制
while ((line = sr.ReadLine()) != null)
{
  if (line.StartsWith("//"))
    continue;
  else {
    string[] pieces = line.Split(' ');
    int id = int.Parse(pieces[0]);
    double lat = double.Parse(pieces[1]);  
    double lon = -1.0 * double.Parse(pieces[2]);  
    MapInfo mi = new MapInfo(id, lat, lon);
    result.Add(mi);
  }
}

我讓代碼檢查當前行是否以程序定義的注釋標記開頭,如果是,則跳過該行。請注意,我將經度派生的字段乘以 -1.0,因為經度在 x 軸方向上是從東向西(或從右向左)。如果不使用 -1.0 因子,則我的地圖將是正確方向的鏡像圖像。

我填充原始數據數組時,只需確保將緯度和經度分別與 y 軸和 x 軸關聯即可:

 
復制
for (int i = 0; i < mapInfoList.Count; ++i)
{
  ids[i] = mapInfoList[i].id;
  xs[i] = mapInfoList[i].lon;
  ys[i] = mapInfoList[i].lat;
}

如果我顛倒關聯順序,則產生的地圖會沿其邊緣傾斜。當我繪制數據時,只需要稍微調整一下便可創建散點圖而不是折線圖:

 
復制
plotter.AddLineGraph(compositeDataSource,
  new Pen(Brushes.White, 0),
  new CirclePointMarker { Size = 2.0, Fill = Brushes.Red },
  new PenDescription("U.S. cities"));

通過向 Pen 構造函數傳遞 0 值,我指定了一根寬度為 0 的線條,這可有效地刪除該線條,從而創建散點圖而不是折線圖。產生的圖形效果很棒,而且只需要幾分鍾就可編寫出生成該圖形的程序。相信我,我嘗試過其他很多種方法來繪制地理數據,將 WPF 和 DynamicDataDisplay 庫結合使用是我找到的最好的解決方案之一。

輕松繪圖

我在此處展示的技術可用於以編程方式生成圖形。該技術的關鍵是 Microsoft 研究院提供的 DynamicDataDisplay 庫。如果在軟件生產環境中用作獨立技術來生成圖形,則該方法在基礎數據頻繁更改時最為有用。如果在應用程序中用作集成技術來生成圖形,則該方法對於 WPF 或 Silverlight 應用程序最為有用。隨着這兩種技術的演變,我確信將會看到更多基於這兩種技術的優秀視覺顯示庫。     

 

James McCaffrey 博士供職於 Volt Information Sciences, Inc.,在該公司他負責管理對華盛頓州雷蒙德市沃什灣 Microsoft 總部園區的軟件工程師進行的技術培訓。他曾參與過多項 Microsoft 產品的研發工作,其中包括 Internet Explorer 和 MSN Search。McCaffrey 是《.NET Test Automation Recipes:A Problem-Solution Approach》(Apress,2006 年)一書的作者。可通過 jammc@microsoft.com 與他聯系。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM