次の方法で共有



January 2010

Volume 25 Number 01

すてきな UI - データ テンプレートを使用した折れ線グラフ

Charles Petzold | January 2010

コード サンプルのダウンロード

最近のコンピューター グラフィックスは高度な表現 (アニメーションや 3-D など) を数多く実現していますが、最も重要なのは、いつの世も変わらず、棒グラフ、円グラフ、折れ線グラフといった従来からのグラフでの基本的なデータ視覚表現ではないでしょうか。

データを表形式で表現すると、規則性のない数字の羅列のように見えますが、こうしたデータをグラフに表すと、数値に隠されている傾向や興味深い情報がはるかに把握しやすくなります。

Windows Presentation Foundation (WPF) と、これから派生した Web ベースの Silverlight の登場によって、視覚表現をコードではなくマークアップで定義すると便利なことがわかりました。Extensible Application Markup Language (XAML) は、コードよりも変更しやすく、テストも容易で、ツールに取り込みやすいため、対話によって視覚表現を定義したり、他の方法を試したりできます。

実際、視覚表現を完全に XAML で定義するメリットはあまりに大きいため、WPF プログラマーは、より強力で柔軟な XAML を実現するためのコード作成に多くの時間を割くようになるでしょう。これは、私が「XAML のためのコーディング」と呼ぶ現象で、WPF がアプリケーション開発へのアプローチを変えるいくつかの方法の 1 つです。

WPF での最も強力な手法の多くでは ItemsControl を使用します。ItemsControl は、(通常は同じ種類の) 項目のコレクションを表示するための基本コントロールです (ItemsControl の派生コントロールでよく使用されるものの 1 つは ListBox です。ListBox では、表示のほか、ナビゲーションや選択も可能です)。

ItemsControl にはどのような型のオブジェクトでも設定できます。組み込みのテキスト表現または視覚表現を持たないビジネス オブジェクトでさえ設定できます。このような離れ業を可能にしているのがデータ テンプレートです。データ テンプレートは必ずと言ってよいほど XAML で定義され、設定されたオブジェクトをオブジェクト自体のプロパティを基に視覚的に表現します。

3 月号のコラムでは、ItemsControls とデータ テンプレートを使用して、XAML で棒グラフと円グラフを定義する方法を紹介しました。もともと、このコラムには折れ線グラフも含めるつもりでしたが、折れ線グラフの重要性 (と難易度) を考えると、完全にコラムの 1 回分を割いて説明する必要があると考えました。

折れ線グラフの問題点

折れ線グラフは、実は散布図の形をとります。散布図は、横軸に 1 つと縦軸に 1 つの変数を持つデカルト座標系です。折れ線グラフとの大きな違いの 1 つは、横軸に沿って配置される値が通常は並べ替えられることです。この横軸の値は日付や時刻であることが非常に多く、折れ線グラフでは、多くの場合、時間の経過に沿って変数の変化が示されます。

もう 1 つの大きな違いは、多くの場合、個々のデータ ポイントが線で結ばれることです。この線は、折れ線グラフの視覚表現として基本のパーツの 1 つであることは明らかですが、XAML でグラフを実現するうえで、実は、大きな障害になっています。データ テンプレートでは ItemsControl 内の各項目のレンダリング方法を記述しますが、項目間を線で結ぶには複数のポイントにアクセスする必要があります。この場合、Polyline 要素と併用できる PointCollection が理想的です。まず、この PointCollection を生成する必要があることから、折れ線グラフ データの前処理を実行するためにカスタム クラスが必要になると予想できます。

折れ線グラフでは、他のグラフに比べて軸に注意する必要が大きくなります。実際には、横軸と縦軸自体を追加の ItemsControl にすると便利です。それにより、これら 2 つの ItemsControl 用に追加で用意したデータ テンプレートを使用して、軸の目盛とラベルの書式設定を完全に XAML で定義できます。

まとめると、まず、横軸に対応するプロパティと縦軸に対応するプロパティの 2 つのプロパティを持つデータ項目のコレクションを作成します。XAML でグラフを実現するには、このコレクションから特定の項目を取得する必要があります。まず、各データ項目の Point オブジェクトが必要です (各データ ポイントのレンダリングのため)。また、すべてのデータ項目を保持する PointCollection (ポイント間を結ぶ線に使用) と、XAML で横軸と縦軸をレンダリングするための十分な情報 (ラベルのデータやラベルと目盛を配置するためのオフセットなど) を保持する追加のコレクションが 2 つ必要です。

これらの Point オブジェクトとオフセットの計算になんらかの情報が必要なことは明らかです。具体的には、グラフの幅と高さ、横軸と縦軸に沿って配置するデータの最小値と最大値が必要です。

しかし、これだけでは十分ではありません。たとえば、縦軸の最小値が 127 で、最大値が 232 だとします。このような場合は、縦軸の範囲を実際には 100 ~ 250 にして 25 単位ごとに目盛を設定することになるでしょう。あるいは、このようなグラフであれば、常に 0 を含めて縦軸を 0 ~ 250 にしたり、最大値を常に 100 の倍数にして 0 ~ 300 にするようなことも考えられます。値の範囲が -125 ~ 237 の場合は、0 を中央にして、軸の範囲を -300 ~ 300 にすることもできます。

軸に表示する値を決める場合、さまざまな方針が考えられます。その方針によって、各データ項目に関連付ける Point 値をどのように計算するかが決まります。この方針は非常に多岐にわたる可能性があるため、"プラグイン" オプションを提供して、特定のグラフ要件に合わせて、追加の軸方針を定義できるようにすることをお勧めします。

最初の試み

プログラミングの失敗は、プログラミングの成功と同じぐらい、ためになることがあります。XAML からアクセスできる折れ線グラフのクラスを作成しようとした私の最初の試みは、まさに完全な失敗でしたが、たしかにその方向に進んでいました。

Point オブジェクトのコレクションを生成するためには、ItemsControl に保持されている項目のコレクションと、ItemsControl の ActualWidth および ActualHeight にアクセスする必要があるのは明らかでした。そのため、LineChartItemsControl と名付けた ItemsControl の派生クラスを作成することが、理にかなっているように思えたのです。

LineChartItemsControl では、いくつか次のような新しい読み取り/書き込みのプロパティを定義しました。まず、HorizontalAxisPropertyName および VerticalAxisPropertyName というプロパティで、グラフに配置する項目のプロパティの名前を指定しました。その他の 4 つの新しいプロパティでは、横軸と縦軸の最小値と最大値を LineChartItemsControl に設定しました (これは、軸を処理する非常に簡単な方法の 1 つであり、いずれ改良しなくてはならないことはわかっていました)。

また、LineChartItemsControl では、XAML でのデータ バインド用に、次の 3 種類の読み取り専用の依存関係プロパティも定義しました。PointCollection 型のプロパティ Points と、軸のレンダリングに使用する 2 つのプロパティ HorizontalAxisInfo および VerticalAxisInfo です。

LineChartItemsControl では、項目のコレクション内で変更が発生した場合に通知を受け取れるように OnItemsSourceChanged メソッドと OnItemsChanged メソッドをオーバーライドしたほか、SizeChanged イベントのハンドラーをインストールしました。後は、非常に単純で、すべての使用可能な情報を総合して、3 つの読み取り専用の依存関係プロパティを計算するだけです。

しかし、XAML で LineChartItemsControl を使用したことは、散々な結果を招きました。簡単だったのは、ポイントどうしを結ぶ線のレンダリングです。これは、Polyline 要素の Points プロパティを LineChartItemsControl の Points プロパティにバインドすることで、実現しました。しかし、個々のデータを配置するデータ テンプレートを定義することは、非常に困難でした。データ テンプレートは、特定の 1 データ項目のプロパティにしかアクセスできません。データ テンプレートはバインディングによって ItemsControl 自体にはアクセスできますが、その特定のデータ項目に対応する配置情報にアクセスするにはどうしたらよいでしょう。

私の解決策は、RelativeSource バインドを保持し、BindingConverter を参照する MultiBinding の RenderTransform セットを利用することでした。しかし、これはあまりに複雑で、コーディングした翌日には、どんなしくみだったかよくわからなくなってしまいました。

この解決策の複雑さからして、まったく異なるアプローチが必要なことは明らかです。

実用的な折れ線グラフ ジェネレーター

新たに思いついた解決策が、LineChartGenerator と名付けたクラスです。このクラスがグラフの視覚表現を完全に XAML で定義するために必要なすべての "材料" を生成することから、この名前を付けました。1 つのコレクション (実際のビジネス オブジェクト) が渡されると、データ ポイント用に 1 つ、データ ポイントを結ぶ線の描画用に 1 つ、横軸と縦軸用に 2 つの合計 4 つのコレクションが生成されます。これにより、複数の ItemsControl を含むグラフを XAML で作成できます (通常は 4 列 x 4 行のグリッドに配置されますが、タイトルや他のラベルを含める場合は拡張できます)。このとき、各 ItemsControl には、生成されたコレクションの表示に使用するデータ テンプレートが個別に設定されています。

実際にこれがどのように機能するかを見てみましょう (このコラムのダウンロード可能なソース コードは、LineChartsWithDataTemplates という 1 つの Visual Studio プロジェクトに含まれています。この解決策には、LineChartLib という DLL プロジェクトが 1 つと、デモ プログラムが 3 つあります)。

PopulationLineChart プロジェクトには、Year および Population という int 型の 2 つのプロパティを定義する CensusDatum という構造体があります。CensusData は、CensusDatum 型の ObservableCollection から派生したもので、10 年おきに実施されている米国の国勢調査の 1790 年 (当時の人口は 3,929,214 人) ~ 2000 年 (281,421,906 人) までのデータを保持しています。図 1 は結果のグラフを示しています。

図 1 PopulationLineChart の表示

PopulationLineChart の表示

このグラフの XAML コードはすべて、PopulationLineChart プロジェクトの Window1.xaml ファイルにあります。図 2 は、このファイルの Resources セクションを示しています。LineChartGenerator には独自の ItemsSource プロパティがあります。このコラムの例では、このプロパティは CensusData オブジェクトに設定します。また、LineChartGenerator には、Width プロパティと Height プロパティも設定する必要があります (これらの値を設定する場所としては LineChartGenerator は最適ではなく、WPF で推奨される配置方法に従っていないことはわかっていますが、これ以上よい解決策を考え出せませんでした)。Width プロパティと Height プロパティの値は、横軸と縦軸を含まない、グラフの内側のサイズを表します。

図 2 PopulationLineChart の Resources セクション

<Window.Resources>
    <src:CensusData x:Key="censusData" />

    <charts:LineChartGenerator 
            x:Key="generator"
            ItemsSource="{Binding Source={StaticResource censusData}}"
            Width="300"
            Height="200">

        <charts:LineChartGenerator.HorizontalAxis>
            <charts:AutoAxis PropertyName="Year" />
        </charts:LineChartGenerator.HorizontalAxis>

        <charts:LineChartGenerator.VerticalAxis>
            <charts:IncrementAxis PropertyName="Population"
                                  Increment="50000000"
                                  IsFlipped="True" />
        </charts:LineChartGenerator.VerticalAxis>
    </charts:LineChartGenerator>
</Window.Resources>

LineChartGenerator には、HorizontalAxis と VerticalAxis という 2 つの AxisStrategy 型のプロパティもあります。AxisStrategy は抽象クラスで、この軸に沿ってグラフに配置するデータ オブジェクトのプロパティを示す PropertyName など、いくつかのプロパティを定義しています。WPF の座標系では、左から右、上から下に向かうにつれて値が大きくなります。しかし、ほとんどの場合は、下から上へ向かうにつれて値が大きくなるように、縦軸の IsFlipped プロパティを True に設定することになるでしょう。

AxisStrategy の派生クラスの 1 つに IncrementAxis があり、これには Increment というプロパティが 1 つあります。IncrementAxis では、目盛間の増分を指定します。最小値と最大値は、この増分値の倍数に設定します。ここでは、IncrementAxis を使用して人口のスケールを設定しています。

AxisStrategy のもう 1 つの派生クラスは AutoAxis で、AutoAxis には固有の追加プロパティは定義していません。このコラムの例では、AutoAxis を横軸に使用しました。この AutoAxis では、横軸に実際の値を使用するという処理のみを行っています (ここでは説明していませんが、言うまでもなく ExplicitAxis も AxisStrategy の派生クラスです。ExplicitAxis では、軸に表示される値の一覧を指定します)。

LineChartGenerator クラスでは、2 つの読み取り専用の依存関係プロパティを定義しています。1 つ目は PointCollection 型の Points です。このプロパティは、データ ポイント間を結ぶ線の描画に使用します。

<Polyline Points="{Binding Source={StaticResource generator}, 
                           Path=Points}"
          Stroke="Blue" />

2 つ目の LineChartGenerator のプロパティは、ItemPointCollection 型の ItemPoints です。ItemPoint には、Item と Point という 2 つのプロパティがあります。Item は、コレクションに本来含まれているオブジェクトです。この例では、Item は CensusDatum 型のオブジェクトです。Point は、その項目が配置されるグラフ内の位置を表します。

図 3 は、グラフの本体を表示する ItemsControl です。ItemsControl の ItemsSource は、LineChartGenerator の ItemPoints プロパティにバインドしています。ItemsPanel テンプレートはグリッドで、ItemTemplate は EllipseGeometry と ToolTip を設定した Path です。EllipseGeometry の Center プロパティは ItemPoint オブジェクトの Point プロパティにバインドし、ToolTip は、Item プロパティの Year プロパティと Population プロパティにアクセスします。

図 3 PopulationLineChart の本体となる ItemsControl

<ItemsControl ItemsSource="{Binding Source={StaticResource generator}, 
                                    Path=ItemPoints}">
    <ItemsControl.ItemsPanel>
        <ItemsPanelTemplate>
            <Grid IsItemsHost="True" />
        </ItemsPanelTemplate>
    </ItemsControl.ItemsPanel>

    <ItemsControl.ItemTemplate>
        <DataTemplate>
            <Path Fill="Red" RenderTransform="2 0 0 2 0 0">
                <Path.Data>
                    <EllipseGeometry Center="{Binding Point}"
                                     RadiusX="4"
                                     RadiusY="4"
                                     Transform="0.5 0 0 0.5 0 0" />
                </Path.Data>
                <Path.ToolTip>
                    <StackPanel Orientation="Horizontal">
                        <TextBlock Text="{Binding Item.Year}" />
                        <TextBlock Text="{Binding Item.Population, 
                            StringFormat=’: {0:N0}’}" />
                    </StackPanel>
                </Path.ToolTip>
            </Path>
        </DataTemplate>
    </ItemsControl.ItemTemplate>
</ItemsControl>

EllipseGeometry オブジェクトの Transform は何かと思われているかもしれません。Transform は、Path 要素に設定されている RenderTransform プロパティのセットによりオフセットされています。これは、便宜的な処置です。これを設定しないと、曲線はかなり右寄りに表示され、グラフ内に収まらない部分が発生します。私は、これを ClipToBounds を使用して修正できませんでした。

Polyline とこの本体の ItemsControl は、同じ単一セルのグリッドを共有しています。このグリッドの Width と Height は LineChartGenerator の値にバインドしています。

<Grid Width="{Binding Source=
{StaticResource generator}, Path=Width}"
      Height="{Binding Source=
{StaticResource generator}, Path=Height}">

Polyline は、この例では ItemsControl 以下にあります。

AxisStrategy クラスでは、このクラスに固有の AxisItems という読み取り専用の依存関係プロパティを定義しています。このプロパティは、AxisItem 型のオブジェクトのコレクションで、Item と Offset という 2 つのプロパティがあります。これは、各軸の ItemsControl に使用するコレクションです。Item プロパティはオブジェクト型に定義さしていますが、実際には、その軸に関連付けられているプロパティと同じ型になります。Offset は、上端または左端からの距離です。

図 4 は、横軸の ItemsControl です。縦軸の ItemsControl も同様の定義になります。ItemsControl の ItemsSource プロパティは、LineChartGenerator の HorizontalAxis プロパティの AxisItems プロパティにバインドしています。したがって、ItemsControl には AxisItem 型のオブジェクトが設定されます。TextBlock の Text プロパティは Items プロパティにバインドし、Offset プロパティは軸の目盛とテキストの変換に使用しています。

図 4 PopulationLineChart の横軸のマークアップ

<ItemsControl Grid.Row="2"
              Grid.Column="1"
              Margin="4 0"
              ItemsSource="{Binding Source={StaticResource generator}, 
                                    Path=HorizontalAxis.AxisItems}">
    <ItemsControl.ItemsPanel>
        <ItemsPanelTemplate>
            <Grid IsItemsHost="True" />
        </ItemsPanelTemplate>
    </ItemsControl.ItemsPanel>

    <ItemsControl.ItemTemplate>
        <DataTemplate>
            <StackPanel>
                <Line Y2="10" Stroke="Black" />
                <TextBlock Text="{Binding Item}"
                           FontSize="8"
                           LayoutTransform="0 -1 1 0 0 0"
                           RenderTransform="1 0 0 1 -6 1"/>

                <StackPanel.RenderTransform>
                    <TranslateTransform X="{Binding Offset}" />
                </StackPanel.RenderTransform>
            </StackPanel>
        </DataTemplate>
    </ItemsControl.ItemTemplate>
</ItemsControl>

これら 3 つの ItemsControl は単にグリッドの 3 つのセルに配置されるだけなので、XAML での配置の設計者が、これらの ItemsControl が正しく配置されるように処置する必要があります。これらのコントロールに適用される罫線、余白、またはパディングは、一貫性が保たれている必要があります。図 4 の ItemsControl では横方向の余白を 4、縦軸の ItemsControl では縦方向の余白を 4 に設定しています。この値にしたのは、Polyline とグラフ自体を格納している単一セルのグリッドを囲む Border の BorderThickness と Padding に合わせるためです。

<Border Grid.Row="1"
        Grid.Column="1" 
        Background="Yellow"
        BorderBrush="Black"
        BorderThickness="1"
        Padding="3">

データ型の整合性

LineChartGenerator クラス自体は、あまり面白味はありません。このクラスは ItemsSource コレクションが既に並べ替えられていることを前提としており、基本的に、ItemsSource プロパティが変更されたときに、確実にすべてが更新されるようにする処理のみを行います。ICollectionChanged を実装する ItemsSource にコレクションを設定している場合も、このコレクションの項目が追加または削除されると、グラフは更新されます。コレクション内の項目が INotifyPropertyChanged を実装する場合は、項目自体が変更されたときに、グラフが更新されます。

実際の作業のほとんどは、AxisStrategy と派生クラスによって処理します。これらの AxisStrategy の派生クラスは、LineChartGenerator の HorizontalAxis プロパティと VerticalAxis プロパティに設定するクラスです。

AxisStrategy 自体は、重要な PropertyName プロパティを定義しています。このプロパティでは、グラフに配置されるオブジェクトのどのプロパティが、その軸に関連付けられるかを指定します。AxisStrategy はリフレクションを使用して、PropertyName プロパティで指定されたコレクション内のオブジェクトのプロパティにアクセスします。しかし、そのプロパティにアクセスするだけでは不十分です。AxisStrategy (とその派生クラス) は、このプロパティの値を基に計算を実行して、Point オブジェクトと目盛のオフセットを取得する必要があります。この計算には、乗算と除算が含まれます。

計算が必要なことから、グラフに配置されるプロパティには数値型 (整数または浮動小数点数) が必要であることが推測されます。しかし、折れ線グラフの横軸での使用されることがきわめて多いデータ型は、数値型ではなく、日付型または時刻型です。Microsoft .NET Framework では、DateTime 型のオブジェクトがこれに該当します。

すべての数値データ型と DateTime に共通していることと言えば、IConvertible インターフェイスを実装することです。つまり、いずれも相互に変換を実行する一連のメソッドを備えており、Convert 静的クラスの同じ名前のメソッドと併用できます。したがって、グラフに配置するプロパティに IConvertible の実装を必須とすることは、理にかなっているように思えました。そうすれば、AxisStrategy (とその派生クラス) が、プロパティの値を倍精度浮動小数点数に変換して、必要な計算を実行できます。

しかし、DateTime のプロパティは、ToDouble メソッドを使用しても、Convert.ToDouble 静的メソッドを使用しても、実は倍精度浮動小数点型に変換できないことがすぐにわかりました。つまり、DateTime 型のプロパティは、実際、特殊なロジックを使用して処理する必要がありました。ただし、これはさいわいなことに、面倒な処理にはなりませんでした。DateTime が定義する Ticks プロパティは 64 ビットの整数で、これは倍精度浮動小数点型に変換できます。また、倍精度浮動小数点型の場合は、まず 64 ビットの整数に変換してから値を DateTime コンストラクターに渡すことで、DateTime に戻すことができます。簡単なテストで、DateTime から倍精度浮動小数点数に変換し、再度 DateTime に戻したところ、ミリ秒まで正確に変換されました。

AxisStrategy には、Recalculate メソッドがあります。このメソッドは、親の ItemsSource コレクション内のすべての項目を反復処理し、各オブジェクトの指定されたプロパティを倍精度浮動小数点型に変換して、最小値と最大値を特定します。AxisStrategy では、この 2 つの値に影響を与える可能性がある、次の 3 つのプロパティを定義します。実際の値の範囲をわずかに超える最小値と最大値を使用できるようにする Margin、すべての値がゼロより大きくても小さくても軸に必ず値ゼロが含まれるようにする IncludeZero、および最大値は正の値に、最小値は負の値にしますが、この 2 つの絶対値は同じにする IsSymmetricAroundZero の 3 つです。

これらの調整が済んだら、AxisStrategy は抽象メソッドの CalculateAxisItems を呼び出します。

protected abstract void CalculateAxisItems(Type propertyType, ref double minValue, ref double maxValue);

最初の引数は、その軸に対応するプロパティの型です。AxisStrategy の派生クラスはこのメソッドを実装して、自身の AxisItems コレクションを構成する項目とオフセットを定義できるようにする必要があります。

CalculateAxisItems も、おそらく、新しい最小値と最大値を設定します。CalculateAxisItems から値が返されると、AxisStrategy はこれらの値とグラフの幅と高さを使用して、すべての項目の Point 値を計算します。

XML データ ソース

数値プロパティと DateTime 型のプロパティの処理のほかに、AxisStategy は ItemsSource コレクションが XmlNode 型の場合にも対応する必要があります。これは、ItemsSource が XmlDataProvider にバインドされている場合にコレクションに保持される型で、外部の XML ファイルまたは XAML ファイル内の XML データ アイランドを参照します。

AxisStrategy は、DataTemplates と同じ名前付け規則を使用します。名前自体が XML 要素を表し、記号 @ より前の名前は XML 属性です。AxisStrategy は、これらの値を文字列として取得します。万一、値が実際に日付や時刻であった場合、AxisStrategy は、倍精度浮動小数点型へ変換する前に、まずこれらの文字列から DateTime オブジェクトへの変換を試みます。これには、DateTime.TryParseExact を使用しますが、対象とするのはインバリアント カルチャーの書式設定仕様の "R"、"s"、"u"、および "o" のみです。

SalesByMonth プロジェクトでは、XML データのグラフへの配置とその他のいくつかの機能を確認できます。Window1.xaml ファイルには、Widgets と Doodads という 2 つの製品の架空の 12 か月分の売上データを保持している XmlDataProvider が含まれています。

<XmlDataProvider x:Key="sales"
                 XPath="YearSales">
    <x:XData>
        <YearSales >
            <MonthSales Date="2009-01-01T00:00:00">
                <Widgets>13</Widgets>
                <Doodads>285</Doodads>
            </MonthSales>

        ...

            <MonthSales Date="2009-12-01T00:00:00">
                <Widgets>29</Widgets>
                <Doodads>160</Doodads>
            </MonthSales>
        </YearSales>
    </x:XData>
</XmlDataProvider>

Resources セクションには、この 2 つの製品用に非常によく似た 2 つの LineChartGenerator オブジェクトもあります。Widgets 用の LineChartGenerator オブジェクトを以下に示します。

<charts:LineChartGenerator 
               x:Key="widgetsGenerator"
               ItemsSource=
               "{Binding Source={StaticResource sales}, 
                                     XPath=MonthSales}"
               Width="250" Height="150">
    <charts:LineChartGenerator.HorizontalAxis>
        <charts:AutoAxis PropertyName="@Date" />
    </charts:LineChartGenerator.HorizontalAxis>
    
    <charts:LineChartGenerator.VerticalAxis>
        <charts:AdaptableIncrementAxis 
        PropertyName="Widgets"
        IncludeZero="True"
        IsFlipped="True" />
    </charts:LineChartGenerator.VerticalAxis>
</charts:LineChartGenerator>

横軸は Date の XML 属性に関連付けています。縦軸は、AxisStrategy から派生した AdaptableIncrementAxis 型で、さらに 2 つのプロパティを定義しています。

•       Increments (DoubleCollection 型)

•       MaximumItems (int 型)

Increments コレクションには既定値の 1、2、および 5 が設定され、MaximumItems プロパティには既定値の 10 が設定されています。SalesByMonth プロジェクトは、これらの既定値をそのまま使用しています。AdaptableIncrementAxis は、目盛の最適な増分値を決定して、軸の項目数が MaximumItems を超えないようにします。既定の設定の場合、増分値として 1、2、および 5 をテストし、次に 10、20、および 50 を、その次に 100、200、および 500 をテストするという形でテストを進めます。また、0.5、0.2、0.1 のように、逆方向の増分テストも行います。

もちろん、AdaptableIncrementAxis の Increments プロパティに別の値を設定することもできます。常に増分値を 10 の倍数にする場合は、1 を唯一の値として設定します。また、場合によっては 1、2、および 5 に代わる値として 1、2.5、および 5 がより適切な可能性があります。

おそらく、AdaptableIncrementAxis (または独自の何か) は、軸の数値が予想できないとき、特に動的に変化する値がグラフに含まれるか、グラフ全体のサイズが大きくなるときに最適な選択肢でしょう。AdaptableIncrementAxis の Increments プロパティは DoubleCollection 型であるため、DateTime 値には適していません。DateTime に使用できる別のプロパティについては、このコラムで後ほど説明します。

SalesByMonth プロジェクトの XAML ファイルでは、2 つの製品に対応する 2 つの LineChartGenerator オブジェクトを定義しています。これにより、図 5 の複合グラフが実現されます。

図 5 SalesByMonth の表示

SalesByMonth の表示

複合グラフを作成するこの方法では、LineChartLib を構成するクラスに特別な処置は必要ありませんでした。このコードでは、XAML で柔軟に処理できるコレクションの生成のみを実行します。

すべてのラベルと軸を表示するため、グラフ全体のサイズを変更して、4 行と 5 列から成るグリッドにします。このグリッドには、グラフ自体のデータ項目の 2 つのコレクションのために 2 つ、左右の軸のスケールのために 2 つ、そして横軸のために 1 つ、合計 5 つの ItemsControls が含まれます。

2 製品を区別するための色の設定は、XAML で簡単に実装できます。しかし、この 2 製品は、データ ポイントの形を三角形と四角形にすることで、さらに区別しています。三角形の項目は、次のデータ テンプレートによってレンダリングします。

<DataTemplate>
    <Path Fill="Blue"
          Data="M 0 -4 L 4 4 -4 4Z">
        <Path.RenderTransform>
            <TranslateTransform X="{Binding Point.X}"
                                Y="{Binding Point.Y}" />
        </Path.RenderTransform>
    </Path>
</DataTemplate>

実際の例では、2 つの製品に実際に関連付けられている図や、さらには小さなビットマップを使用することも考えられます。

この例のデータ ポイント間を結ぶ線は、標準の Polyline 要素ではなく、CanonicalSpline という Shape のカスタムの派生要素です (正準スプラインまたはカーディナル スプラインは、Windows Forms には含まれていましたが、WPF には継承されませんでした。データ ポイントの各ペアは、そのペアを囲むさらに 2 つの位置をアルゴリズムに取り入れ、曲線で結んでいます)。また、この目的で他のカスタム クラスを作成することもできます。その場合は、おそらくデータ ポイントに最小二乗補間を実行して結果を表示するようなクラスになるでしょう。

LineChartChartGenerator の HorizontalAxis.AxisItems プロパティは、DateTime 型の ObservableCollection です。つまり、Binding クラスの StringFormat 機能と標準の日付/時刻書式指定文字列を使用して項目の書式を設定できます。

横軸のデータ テンプレートでは “MMMM” 書式指定文字列を使用して、すべての月の名前を表示しています。

<DataTemplate>
    <StackPanel HorizontalAlignment="Left">
        <Line Y2="10" Stroke="Black" />
        <TextBlock Text="{Binding Item, StringFormat=MMMM}"
                   RenderTransform="1 0 0 1 -4 -4">
            <TextBlock.LayoutTransform>
                <RotateTransform Angle="45" />        
            </TextBlock.LayoutTransform>
        </TextBlock>
        
        <StackPanel.RenderTransform>
            <TranslateTransform X="{Binding Offset}" />
        </StackPanel.RenderTransform>
    </StackPanel>
</DataTemplate>

日付と時刻

折れ線グラフの横軸に DateTime オブジェクトを使用することは非常に一般的であるため、DataTime オブジェクトを処理するための AxisStrategy をコーディングする時間をとる価値はあります。折れ線グラフの中には、株価や環境に関する測定値など、おそらく 1 時間おきなどに新しい項目が追加されるような、データが累積されるものがあります。その場合は、グラフに表示される項目の中でも、DateTime 値の範囲に応じて変化する AxisStrategy を用意すると便利です。

私が作成したこのようなクラスのスタブは AdaptableDateTimeAxis で、数秒から数年まで幅広い範囲で DateTime データに対応できるようにしています。

AdaptableDateTimeAxis には MaximumItems プロパティ (既定値の 10 に設定されています) と、SecondIncrements、MinuteIncrements、HourIncrements、DayIncrements、MonthIncrements、YearIncrements という 6 つのコレクションがあります。AdaptableDateTimeAxis は、項目の数が MaximumItems を超えないように、体系的に目盛の増分値の特定を試みます。既定の設定の場合、AdaptableDateTimeAxis は、増分値として 1、2、5、15、30 秒をテストし、次に 1、2、5、15、30 分、1、2、4、6、12 時間、1、2、5、10 日、1、2、4、6 か月の順にテストします。年単位に達したら、1、2、5 年を試し、その後は 10、20、50 年というように続きます。

AdaptableDateTimeAxis でも、DateTimeInterval という読み取り専用の依存関係プロパティを定義しています (DateTimeInterval は、Second、Minute、Hour などのメンバーを持つ列挙体の名前でもあります)。このプロパティは、AdaptableDateTimeAxis クラスによって決定された軸目盛の増分値の単位を示します。このプロパティを使用することで、増分値に基づいて DateTime の書式設定を変更する DataTriggers を XAML で定義できます。図 6 は、このような書式指定の選択を行うサンプルのデータ テンプレートです。

図 6 TemperatureHistory の横軸の DataTemplate

<DataTemplate>
    <StackPanel HorizontalAlignment="Left">
        <Line Y2="10" Stroke="Black" />

        <TextBlock Name="txtblk"
                   RenderTransform="1 0 0 1 -4 -4">
            <TextBlock.LayoutTransform>
                <RotateTransform Angle="45" />        
            </TextBlock.LayoutTransform>
        </TextBlock>

        <StackPanel.RenderTransform>
            <TranslateTransform X="{Binding Offset}" />
        </StackPanel.RenderTransform>
    </StackPanel>

    <DataTemplate.Triggers>
        <DataTrigger Binding="{Binding Source={StaticResource generator}, 
                                       Path=HorizontalAxis.
                                       DateTimeInterval}" 
                     Value="Second">
            <Setter TargetName="txtblk" Property="Text" 
                 Value="{Binding Item, StringFormat=h:mm:ss d MMM yy}" />
        </DataTrigger>
        <DataTrigger Binding="{Binding Source={StaticResource generator}, 
                                       Path=HorizontalAxis.
                                       DateTimeInterval}" 
                     Value="Minute">
            <Setter TargetName="txtblk" Property="Text" 
                    Value="{Binding Item, StringFormat=h:mm d MMM yy}" />
        </DataTrigger>
        <DataTrigger Binding="{Binding Source={StaticResource generator}, 
                                       Path=HorizontalAxis.
                                       DateTimeInterval}" 
                     Value="Hour">
            <Setter TargetName="txtblk" Property="Text" 
                 Value="{Binding Item, StringFormat=’h tt, d MMM yy’}" />
        </DataTrigger>
        <DataTrigger Binding="{Binding Source={StaticResource generator}, 
                                       Path=HorizontalAxis.
                                       DateTimeInterval}" 
                     Value="Day">
            <Setter TargetName="txtblk" Property="Text" 
                    Value="{Binding Item, StringFormat=d}" />
        </DataTrigger>
        <DataTrigger Binding="{Binding Source={StaticResource generator}, 
                                       Path=HorizontalAxis.
                                       DateTimeInterval}" 
                     Value="Month">
            <Setter TargetName="txtblk" Property="Text" 
                    Value="{Binding Item, StringFormat=MMM yy}" />
        </DataTrigger>
        <DataTrigger Binding="{Binding Source={StaticResource generator}, 
                                       Path=HorizontalAxis.
                                       DateTimeInterval}" 
                     Value="Year">
            <Setter TargetName="txtblk" Property="Text" 
                    Value="{Binding Item, StringFormat=MMMM}" />
        </DataTrigger>
    </DataTemplate.Triggers>
</DataTemplate>

このテンプレートは TemperatureHistory プロジェクトに含まれています。TemperatureHistory プロジェクトは、米国国立測候所の Web サイトにアクセスして、ニューヨーク市セントラルパークの気温測定値を 1 時間おきに取得します。図 7 は、プログラムを数時間実行した後の TemperatureHistory の表示で、図 8 は数日間実行した後の表示です。

図 7 時間単位での TemperatureHistory の表示

時間単位での TemperatureHistory の表示

図 8 日単位での TemperatureHistory の表示

日単位での TemperatureHistory の表示

もちろん、私が作成した折れ線グラフのクラスは、現時点では、テキスト ラベルに関連付けられていない目盛を独自に描画する手段がないなど、完全に柔軟であるとは言えませんが、XAML のみで折れ線グラフの視覚表現を定義するために十分な情報を提供できる、実用的で強力な方法を示していると思います。

Charles Petzold は MSDN Magazine の記事を長期にわたって担当している寄稿編集者です。最新の著書には『The Annotated Turing: A Guided Tour through Alan Turing's Historic Paper on Computability and the Turing Machine』(Wiley、2008 年) があります。彼の Web サイトは charlespetzold.com (英語) です。

この記事のレビューに協力してくれた技術スタッフの David Teitelbaum に心より感謝いたします。