Freigeben über


ExtremeUI

Liniendiagramme mit Datenvorlagen

Charles Petzold

Beispielcode herunterladen.

Trotz der vielen fortgeschrittenen Manifestationen von Computergrafikfunktionen (einschließlich Animation und 3D) vermute ich, dass die grundlegende Darstellung von Daten in traditionellen Diagrammen mit Balken, Kreisen und Linien immer die wichtigste Funktion bleiben wird.

Eine Tabelle mit Daten kann wie ein Wirrwarr von Zufallszahlen aussehen, Trends oder in den Zahlen verborgene interessante Informationen werden jedoch erkennbar, wenn die Zahlen in einem Diagramm angezeigt werden.

Dank der Windows Presentation Foundation (WPF) und ihrem webbasierten Ableger Silverlight haben wir erkannt, welche Vorteile die Definition grafischer Darstellungen im Markup gegenüber der Definition im Code bietet. XAML (Extensible Application Markup Language) lässt sich einfacher ändern als Code, es lässt sich damit einfacher experimentieren und XAML können leichter mit Tools bearbeiten, sodass wir die grafischen Elemente interaktiv definieren und mit alternativen Ansätzen herumspielen können.

Visuelle Elemente ganz in XAML zu definieren ist in der Tat so vorteilhaft, dass WPF-Programmierer viel Zeit damit verwenden, speziellen Code zu schreiben, um leistungsfähigeres und flexibleres XAML zu ermöglichen. Das ist ein Phänomen, das ich "Programmierung für XAML" nenne, und dies ist nur ein Beispiel dafür, wie die WPF unseren Ansatz zur Anwendungsentwicklung verändert hat.

An vielen der leistungsfähigsten WPF-Techniken ist das ItemsControl-Steuerelement beteiligt. Dies ist das grundlegende Steuerelement zum Anzeigen einer Auflistung von Elementen, die im Allgemeinen vom gleichen Typ sind. (Ein bekanntes von ItemsControl abgeleitetes Steuerelement ist das ListBox-Steuerelement, das die Navigation, Auswahl und Anzeige ermöglicht.)

Das ItemsControl-Steuerelement kann mit Objekten beliebigen Typs gefüllt werden, sogar mit Geschäftsobjekten, die an und für sich keine textbasierte oder visuelle Darstellung aufweisen. Das Zaubermittel ist ein DataTemplate-Element, das fast immer in XAML definiert ist und diesen Geschäftsobjekten eine visuelle Darstellung verleiht, die auf den Objekteigenschaften basiert.

In der Ausgabe des vom März wurde gezeigt, wie mithilfe von ItemsControl- und DataTemplate-Elementen Balkendiagramme und Kreisdiagramme in XAML definiert werden. Ursprünglich wollte ich in diesem Artikel auch auf Liniendiagramme eingehen, aber die Bedeutung (und Schwierigkeit) von Liniendiagrammen erfordert einen eigenen Artikel zu diesem Thema.

Probleme mit Liniendiagrammen

Liniendiagramme sind eigentlich eine Form von Punktdiagrammen, ein kartesisches Koordinatensystem mit einer Variablen auf der horizontalen und einer anderen Variablen auf der vertikalen Achse. Ein großer Unterschied gegenüber dem Punktdiagramm besteht darin, dass in Liniendiagrammen die auf der horizontalen Achse angetragenen Werte in der Regel sortiert sind. Sehr häufig handelt es sich bei diesen Werten um Datums- oder Uhrzeitangaben, d. h., Liniendiagramme zeigen sehr oft, wie sich eine Variable über einen Zeitraum hinweg ändert.

Der andere große Unterschied ist, dass die einzelnen Datenpunkte meist durch eine Linie verbunden werden. Obwohl diese Linie offensichtlich ein grundlegender Bestandteil der Liniendiagrammdarstellung ist, stellt sie eine große Schwierigkeit in der Umsetzung des Diagramms in XAML dar. Das DataTemplate-Element beschreibt, wie die einzelnen im ItemsControl-Steuerelement enthaltenen Elemente gerendert werden. Um die Elemente verbinden zu können, muss auf mehrere Punkte zugegriffen werden, die sich idealerweise in einer PointCollection-Auflistung befinden und dann mit einem Polyline-Element verwendet werden können. Das Erfordernis, diese PointCollection zu generieren, war der erste Hinweis darauf, dass eine benutzerdefinierte Klasse zum Vorverarbeiten der Liniendiagrammdaten erforderlich sein würde.

Mehr als andere Diagramme erfordern Liniendiagramme, dass den Achsen weit mehr Beachtung geschenkt wird. Es ist sogar sinnvoll, die horizontale und die vertikale Achse selbst durch zusätzliche ItemsControl-Steuerelemente darzustellen. Dann können zusätzliche DataTemplate-Elemente für diese weiteren ItemsControl-Steuerelemente verwendet werden, um die Achsenteilstriche und -beschriftungen ganz in XAML zu formatieren.

Kurz gesagt, Ausgangspunkt ist eine Auflistung von Datenelementen mit zwei Eigenschaften: einer Eigenschaft, die der horizontalen Achse zugeordnet ist, und einer zweiten Eigenschaft für die vertikale Achse. Um ein Diagramm in XAML realisieren zu können, müssen wir bestimmte Dinge aus diesen Daten gewinnen: Erstens brauchen wir Punktobjekte für jedes Datenelement (zum Rendern der einzelnen Datenpunkte). Zweitens brauchen wir eine PointCollection-Auflistung aller Datenelemente (für die Verbindungslinie zwischen den Punkten) und zwei weitere Auflistungen, die ausreichend Informationen zum Rendern der horizontalen und vertikalen Achsen in XAML enthalten, einschließlich Daten für die Beschriftungen und Offsets zum Positionieren der Beschriftungen und Teilstriche.

Die Berechnung dieser Point-Objekte und Offsets erfordert offensichtlich einige Informationen: Breite und Höhe des Diagramms sowie die Minimal- und Maximalwerte der Daten, die auf der horizontalen und vertikalen Achse eingetragen werden sollen.

Das ist jedoch noch nicht genug. Angenommen, der Minimalwert für die vertikale Achse lautet 127 und der Maximalwert lautet 232. In diesem Fall kann die vertikale Achse tatsächlich den Wertebereich von 100 bis 250 mit Teilstrichen nach jeweils 25 Einheiten umfassen. Bei diesem speziellen Diagramm kann aber auch der Nullpunkt (0) einbezogen werden, sodass sich die vertikale Achse von 0 bis 250 erstreckt. Vielleicht möchten Sie aber auch, dass der Maximalwert ein Vielfaches von 100 ist, dann verläuft die Achse von 0 bis 300. Wenn die Werte im Bereich -127 bis 237 liegen, soll 0 vielleicht zentriert werden, sodass die Achse den Bereich von -300 bis +300 umfasst.

Potenziell gibt es viele verschiedene Strategien, um zu bestimmen, welche Werte auf den Achsen dargestellt werden sollen. Die Achsen bestimmen dann wiederum die Berechnung der Point-Werte, die jedem Datenelement zugeordnet sind. Diese Strategien können so unterschiedlich sein, dass es sinnvoll ist, eine "Plug-in"-Option anzubieten, mit der bei Bedarf zusätzliche Achsenstrategien für ein bestimmtes Diagramm definiert werden können.

Der erste Versuch

Programmierfehler sind manchmal ebenso lehrreich wie Programmiererfolge. Mein erster Versuch, eine Klasse zum Erstellen von Liniendiagrammen zu programmieren, auf die aus XAML heraus zugegriffen werden konnte, war kein kompletter Fehlschlag, tendierte aber sicher in diese Richtung.

Ich wusste, dass ich offenbar Zugriff auf die Auflistung der im ItemsControl-Steuerelement enthaltenen Elemente und die Werte der Eigenschaften ActualWidth und ActualHeight dieses Steuerelements brauchte, um die Auflistung von Point-Elementen erzeugen zu können. Daher erschien es mir logisch, eine Klasse von ItemsControl abzuleiten, die ich LineChartItemsControl nannte.

LineChartItemsControl definierte einige neue Lese-/Schreibeigenschaften: HorizontalAxisPropertyName und VerticalAxisPropertyName stellen die Namen der Eigenschaften der Elemente bereit, die grafisch darzustellen sind. Vier weitere neue Eigenschaften lieferten LineChartItemsControl die Minimal- und Maximalwerte für die horizontale und die vertikale Achse. (Dies war ein sehr einfacher Ansatz, die Achsen zu handhaben, die meiner Kenntnis nach zu einem späteren Zeitpunkt nachgebessert werden mussten.)

Das benutzerdefinierte Steuerelement definierte auch drei schreibgeschützte Abhängigkeitseigenschaften zur Datenbindung in XAML: eine Eigenschaft namens Points vom Typ PointCollection und zwei Eigenschaften namens HorizontalAxisInfo und VerticalAxisInfo zum Rendern der Achsen.

In der LineChartItemsControl-Klasse wurden die Methoden OnItemsSourceChanged und OnItemsChanged überschrieben, um darüber informiert zu werden, sobald die Auflistung der Elemente verändert wurde, und in der Klasse wurde ein Handler für das SizeChanged-Ereignis installiert. Es war dann recht einfach, die verfügbaren Informationen zusammenzustellen, um die drei schreibgeschützten Abhängigkeitseigenschaften zu berechnen.

Allerdings war die Verwendung von LineChartItemsControl in XAML alles andere als einfach. Der leichteste Teil war noch das Rendern der Verbindungslinie. Dies geschah mit dem Polyline-Element, indem dessen Points-Eigenschaft and die Points-Eigenschaft der LineChartItemsControl-Instanz gebunden wurden. Die Definition eines DataTemplate-Elements, das die einzelnen Daten platzieren konnte, war jedoch äußerst schwierig. Das DataTemplate-Element hat nur Zugriff auf die Eigenschaften eines bestimmten Datenelements. Über Bindungen kann das DataTemplate-Element auf das ItemsControl-Steuerelement zugreifen, aber wie erhalten Sie die Positionsdaten, die zu diesem Datenelement gehören?

Meine Lösung beinhaltete einen RenderTransform-Satz aus einer MultiBinding-Instanz, der sowohl eine RelativeSource-Bindung enthielt als auch auf ein BindingConverter-Objekt verwies. Sie war so komplex, dass ich bereits einen Tag, nachdem ich sie programmiert hatte, nicht mehr genau wusste, wie sie funktionierte!

Die Komplexität dieser Lösung war ein deutliches Anzeichen dafür, dass ich einen ganz anderen Ansatz verfolgen musste.

Der Liniendiagramm-Generator in der Praxis

Die Lösung, die ich mir daraufhin ausdachte, war eine Klasse, die ich LineChartGenerator nannte, weil sie alle erforderlichen Rohdaten generierte, um die visuellen Elemente des Diagramms nur in XAML zu definieren. Eine Auflistung bildet die Eingabe (die Geschäftsobjekte) und vier Auflistungen bilden die Ausgabe (eine für die Datenpunkte, eine für das Zeichnen der Verbindungslinie und zwei weitere für die horizontale und die vertikale Achse). Auf diese Weise kann ein Diagramm in XAML konstruiert werden, das mehrere ItemsControl-Elemente enthält (die in der Regel in einem Raster im Format 4 x 4 angeordnet sind oder in einem größeren Raster, wenn Titel oder andere Beschriftungen hinzugefügt werden sollen), die jeweils über ein eigenes DataTemplate-Element zum Anzeigen dieser Auflistungen verfügen.

Sehen wir uns diese Lösung in der Praxis an. (Der gesamte herunterzuladende Quellcode ist in einem einzigen Visual Studio-Projekt namens LineChartsWithDataTemplates enthalten. Diese Lösung umfasst ein DLL-Projekt namens LineChartLib und drei Beispielprogramme.)

Das PopulationLineChart-Projekt enthält eine Struktur namens CensusDatum, die zwei Eigenschaften namens Year und Population vom Typ int definiert. Die CensusData-Klasse ist von ObservableCollection vom Typ CensusDatum abgeleitet und füllt die Auflistung mit Daten der alle zehn Jahre stattfindenden amerikanischen Volkszählungen aus den Jahren 1790 (als die USA 3.929.214 Einwohner hatten) bis 2000 (281.421.906). Abbildung 1 zeigt das resultierende Diagramm.

Abbildung 1 Die PopulationLineChart-Anzeige

image: The PopulationLineChart Display

Der gesamte XAML-Code für dieses Diagramm ist in der Datei "Window1.xaml" im Projekt PopulationLineChart enthalten. Abbildung 2 zeigt den Abschnitt "Resourcen" dieser Datei. LineChartGenerator besitzt eine eigene ItemsSource-Eigenschaft, die in diesem Beispiel auf das CensusData-Objekt festgelegt wurde. Es ist zudem notwendig, hier die Eigenschaften Width und Height festzulegen. (Ich weiß, dass dies nicht der optimale Ort für diese Werte und außerdem der bevorzugten Layoutmethode in WPF nicht unbedingt zuträglich ist, aber ich konnte keine bessere Lösung ausarbeiten.) Diese Werte geben die inneren Maße des Diagramm an, ausschließlich der horizontalen und vertikalen Achse.

Abbildung 2 Der Abschnitt "Resources" von PopulationLineChart

<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 besitzt auch zwei Eigenschaften vom Typ AxisStrategy namens HorizontalAxis und VerticalAxis. AxisStrategy ist eine abstrakte Klasse, die verschiedene Eigenschaften definiert, darunter PropertyName, über die die Eigenschaft der Datenobjekte angegeben wird, die auf dieser Achse angetragen werden soll. Entsprechend dem WPF-Koordinatensystem werden Werte von links nach rechts und von oben nach unten größer. Fast immer ist es ratsam, die IsFlipped-Eigenschaft der vertikalen Achse auf True festzulegen, sodass die Werte von unten nach oben größer werden.

Eine der von AxisStrategy abgeleiteten Klassen heißt IncrementAxis, und diese Klasse definiert eine Eigenschaft namens Increment. Mit der IncrementAxis-Strategie geben Sie das Inkrement für die Werte zwischen den Teilstrichen an. Minimal- und Maximalwert werden als Vielfaches des Inkrements festgelegt. IncrementAxis wurde für die Einwohnerskala verwendet.

Eine andere von AxisStrategy abgeleitete Klassen heißt AutoAxis, und diese Klasse definiert keine eigenen Eigenschaften. Ich verwendete diese Klasse für die horizontale Achse. Die Klasse trägt lediglich die tatsächlichen Werte auf der Achse ein. (Eine offensichtliche AxisStrategy-Ableitung, die ich nicht geschrieben habe, ist ExplicitAxis, der eine Liste der Werte übergeben werden, die auf der Achse angezeigt werden sollen.)

Die LineChartGenerator -Klasse definiert zwei schreibgeschützte Eigenschaften. Die Erste heißt "Points" und ist vom Typ PointCollection. Sie verwenden diese Eigenschaft zum Zeichnen der Verbindungslinie zwischen den Punkten.

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

Die zweite LineChartGenerator-Eigenschaft heißt ItemPoints und ist vom Typ ItemPointCollection Ein ItemPoint-Objekt besitzt zwei Eigenschaften namens Item und Point. Item enthält das ursprüngliche Objekt in der Auflistung. Im vorliegenden Beispiel ist Item ein Objekt vom Typ CensusDatum. Point ist der Punkt, an dem das Element im Diagramm angezeigt werden soll.

Abbildung 3 zeigt das ItemsControl-Steuerelement, dass den Hauptteil des Diagramms anzeigt. Beachten Sie, dass die ItemsSource-Eigenschaft an die ItemPoints-Eigenschaft der LineChartGenerator-Instanz gebunden wurde. Die ItemsPanel-Vorlage ist ein Grid-Steuerelement, und ItemTemplate ist ein Pfad mit einer EllipseGeometry-Instanz und einer QuickInfo. Die Center-Eigenschaft der EllipseGeometry-Instanz wird an die Point-Eigenschaft des ItemPoint-Objekts gebunden, während ToolTip auf die Eigenschaften Year und Population der Item-Eigenschaften zugreift.

Abbildung 3 Die Steuerelemente für die wichtigsten Elemente von PopulationLineChart

<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>

Sie fragen sich möglicherweise, was es mit dem Transform-Satz für das EllipseGeometry-Objekt auf sich hat, das um einen RenderTransform-Eigenschaftensatz am Path-Element versetzt wird. Dies ist eine Behelfslösung. Ohne sie würde die Ellipse am äußeren rechten Ende abgeschnitten, und ich konnte das mit ClipToBounds nicht korrigieren.

Das Polyline-Objekt und dieses primäre ItemsControl-Element nutzen dasselbe einzellige Grid-Objekt, dessen Width-Eigenschaft und Height-Eigenschaft an die Werte aus der LineChartGenerator-Instanz gebunden werden.

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

In diesem Beispiel liegt das PolylineObjekt dem ItemsControl-Steuerelement zugrunde.

Die AxisStrategy-Klasse definiert eine eigene schreibgeschützte Abhängigkeitseigenschaft namens AxisItems, eine Auflistung von Objekten vom Typ AxisItem, die über zwei Eigenschaften namens Item und Offset verfügt. Dies ist die Auflistung, die für die ItemsControl-Elemente für die beiden Achsen verwendet wird. Obwohl die Item-Eigenschaft definitionsgemäß vom Typ object ist, hat sie eigentlich denselben Typ wie die Eigenschaft, die der Achse zugeordnet ist. Offset ist ein Abstand von oben oder links.

Abbildung 4 zeigt das ItemsControl-Element für die horizontale-Achse; die vertikale Achse ist ähnlich. Die ItemsSource-Eigenschaft des ItemsControl-Objekts wird an die AxisItems-Eigenschaft der HorizontalAxis-Eigenschaft gebunden. Das ItemsControl-Element wird daher mit Objekten vom Typ AxisItem gefüllt. Die Text-Eigenschaft des TextBlock-Objekts wird an die Items-Eigenschaft gebunden, und mithilfe der Offset-Eigenschaft werden Teilstriche und Text an der Achse übersetzt.

Abbildung 4 Markup für die horizontale Achse von 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>

Weil diese drei ItemsControl-Elemente einfach drei Zellen eines Grid-Objekts besetzen, ist die Person, die das Layout in XAML entwirft, dafür verantwortlich, sie richtig auszurichten. Rahmen, Ränder oder Textabstände, die auf dieses Steuerelement angewendet werden, müssen konsistent sein. Das ItemsControl-Steuerelement in Abbildung 4 verfügt über einen horizontalen Rand von 4, und das ItemsControl-Steuerelement für die vertikale Achse hat einen vertikalen Rand von 4. Ich wählte diese Werte entsprechend den BorderThickness- und Padding-Werten eines Border-Elements, das das einzellige Grid-Objekt umgibt, das das Polyline-Objekt und das Diagramm enthält.

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

Datentypkonsistenz

Die LineChartGenerator-Klasse selbst ist nicht sehr interessant. Sie unterstellt, das die ItemsSource-Auflistung bereits sortiert ist, und ist größtenteils damit beschäftigt, sicherzustellen, dass alles aktualisiert wird, wenn sich die ItemsSource-Eigenschaft ändert. Wenn die ItemsSource zugewiesene Auflistung die ICollectionChanged-Schnittstelle implementiert, wird das Diagramm auch aktualisiert, sobald Elemente der Auflistung hinzugefügt oder daraus entfernt werden. Wenn die in der Auflistung enthaltenen Elemente die INotifyPropertyChanged-Schnittstelle implementieren, wird das Diagramm aktualisiert, sobald sich die Elemente selbst ändern.

Die meiste echte Arbeit wird von AxisStrategy und den davon abgeleiteten Klassen geleistet. Diese von AxisStrategy abgeleiteten Klassen werden den Eigenschaften HorizontalAxis und VerticalAxis der LineChartGenerator-Instanz zugewiesen.

AxisStrategy selbst definiert die wichtige PropertyName-Eigenschaft, die angibt, welche Eigenschaft der im Diagramm dargestellten Objekt dieser Achse zugeordnet ist. AxisStrategy greift mittels Spiegelung auf diese Eigenschaft der in der Auflistung enthaltenen Objekte zu. Es genügt jedoch nicht, einfach auf diese Eigenschaft zuzugreifen. AxisStrategy (und die davon abgeleiteten Klassen) muss Berechnungen mit den Werten dieser Eigenschaft durchführen, um die Point-Objekte und die Teilstriche zu erhalten. Zu diesen Berechnungen gehören Multiplikation und Division.

Die Notwendigkeit von Berechnungen legt dringend nahe, dass die im Diagramm darzustellenden Eigenschaften einen numerischen Typ aufweisen müssen, also Ganzzahlen oder Gleitkommawerte sein müssen. Allerdings ist ein gängiger Datentyp, der häufig an der horizontalen Achse von Liniendiagrammen verwendet ist, überhaupt keine Zahl, sondern ein Datum oder eine Uhrzeit. In Microsoft .NET Framework sprechen wir von einem Objekt des Typs DateTime.

Was haben alle numerischen Datentypen und DateTime gemeinsam? Alle implementieren die IConvertible-Schnittstelle, was bedeutet, dass sie alle Methoden enthalten, mit denen sie in die anderen Typen umgewandelt werden können, und alle können mit denselben benannten Methoden der statischen Convert-Klasse verwendet werden. Daher schien es mir vernünftig, zu fordern, dass die Eigenschaften, die im Diagramm dargestellt werden, die IConvertible-Schnittstelle implementieren. AxisStrategy (und die davon abgeleiteten Klassen) könnte dann einfach die Eigenschaftswerte in double-Werte konvertieren, um die notwendigen Berechnungen durchführen zu können.

Allerdings stellte ich bald fest, dass Eigenschaften des Typs DateTime sich in der Tat nicht in double-Werte konvertieren lassen, weder mit der ToDouble-Methode noch mit der statischen Convert.ToDouble-Methode. Also mussten die Eigenschaften vom Typ DateTime mit spezieller Logik behandelt werden, die glücklicherweise nicht allzu schwierig war. Die in DateTime definierte Ticks-Eigenschaft ist eine 64-Bit-Ganzzahl, die in einen double-Wert konvertiert werden kann. Ein double-Wert lässt sich wieder in ein DateTime-Objekt konvertieren, indem er zuerst in eine 64-Bit-Ganzzahl umgewandelt wird, die dann einem DateTime-Konstruktor übergeben wird. Etwas Herumexperimentieren zeigte, dass die Hin- und Rückkonvertierung auf die Millisekunde genau war.

AxisStrategy verfügt über die Recalculate-Methode, die in einer Schleife alle Elemente der übergeordneten ItemsSource-Auflistung durchläuft, die angegebene Eigenschaft jedes Objekts in einen double-Wert konvertiert und den Minimal- und Maximalwert bestimmt. AxisStrategy definiert drei Eigenschaften, die diese beiden Werte potenziell beeinflussen. die Margin-Eigenschaft (die zulässt, dass Minimum und Maximum etwas unter bzw. über dem tatsächlichen Wertebereich liegen), die IncludeZero-Eigenschaft (damit die Achse stets den Wert Null (0) beinhaltet, auch wenn alle Werte größer oder kleiner als null sind) und die IsSymmetricAroundZero-Eigenschaft, die bedeutet, dass der Maximalwert der Achse positiv und der Minimalwert der Achse negativ sein sollte, dass beide Werte jedoch den gleichen absoluten Wert haben sollten.

Nach diesen Anpassungen ruft AxisStrategy die abstrakte CalculateAxisItems-Methode auf.

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

Das erste Argument ist vom Typ der Eigenschaften, die dieser Achse zugeordnet sind. Jede Klasse, die von AxisStrategy abgeleitet ist, muss diese Methode implementieren und die Gelegenheit nutzen, die Elemente und Offsets zu definieren, die die AxisItems-Auflistung bilden.

Es ist sehr wahrscheinlich, dass CalculateAxisItems auch neue Minimal- und Maximalwerte festlegt. Wenn CalculateAxisItems zurückgegeben wird, verwendet AxisStrategy diese Werte zusammen mit der Breite und Höhe des Diagramms, um für alle Elemente die Point-Werte zu berechnen.

XML-Datenquellen

AxisStrategy muss nicht nur mit den numerischen Eigenschaften und Eigenschaften vom Typ DateTime umgehen, sondern auch den Fall handhaben, in dem Elemente der ItemsSource-Auflistung vom Typ XmlNode sind. Die Auflistung enthält Elemente dieses Typs, wenn ItemsSource an eine XmlDataProvider-Instanz gebunden wird, indem auf eine externe XML-Datei oder eine XML-Dateninsel in einer XAML-Datei verwiesen wird.

AxisStrategy verwendet dieselben Konventionen wie DataTemplates: Ein bloßer Name verweist auf ein XML-Element und ein Name mit vorangestelltem @-Zeichen steht für ein XML-Attribut. AxisStrategy ruft diese Werte als Zeichenfolgen ab. Für den Fall, dass es sich hierbei um Datums- oder Uhrzeitangaben handelt, versucht AxisStrategy zuerst, diese Zeichenfolgen in DateTime-Objekte zu konvertieren, bevor sie in double-Werte umgewandelt werden. DateTime.TryParseExact wird hierfür verwendet und zwar nur für die vom Gebietsschema unabhängigen Formatspezifikationen "R", "s", "u" und "o".

Das SalesByMonth-Projekt veranschaulicht neben einigen anderen Features, wie XML-Daten grafisch dargestellt werden. Die Datei "Window1.xaml" enthält eine XmlDataProvider-Instanz mit Daten zu den in 12 Monaten erzielten fiktiven Umsätzen für die beiden Produkte Widgets und Doodads:

<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>

Der Abschnitt "Resources" enthält zwei ganz ähnliche LineChartGenerator-Objekte für die beiden Produkte. So sieht das Objekt für Widgets aus:

<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>

Beachten Sie, dass die horizontale Achse dem XML-Attribut Date zugeordnet ist. Die vertikale Achse ist vom Typ AdaptableIncrementAxis, der von AxisStrategy abgeleitet ist und zwei zusätzliche Eigenschaften definiert:

•       Increments vom Typ DoubleCollection

•       MaximumItems vom Typ int

Die Increments-Auflistung enthält die Standardwerte 1, 2 und 5, und die MaximumItems-Eigenschaft hat den Standardwert 10. Im SalesByMonth-Projekt werden einfach diese Standardwerte verwendet. AdaptableIncrementAxis bestimmt das optimale Inkrement zwischen Teilstrichen, sodass die Anzahl von Achsenelementen den Wert von MaximumItems nicht übersteigt. Mit den Standardeinstellungen werden die Inkrementwerte 1, 2 und 5, danach 0, 20 und 50 und dann 100, 200 und 500 usw. getestet. Dies geht auch in der entgegengesetzten Richtung weiter: Hier werden die Inkrementwerte 0,5, 0,2, 0,1 usw. getestet.

Sie können die Increments-Eigenschaft von AdaptableIncrementAxis natürlich auch mit anderen Werten füllen. Wenn das Inkrement immer ein Vielfaches von 10 sein soll, geben Sie einfach nur den Wert 1 an. Eine Alternative zu den Werten 1,2 und 5, die in manchen Situationen angemessener sein mag, ist 1, 2,5 und 5.

AdaptableIncrementAxis (oder eine ähnliche, von Ihnen definierte Klasse) ist wahrscheinlich die beste Wahl, wenn die numerischen Werte einer Achse nicht vorhersagbar sind, insbesondere, wenn das Diagramm Daten enthält, die sich dynamisch ändern oder die insgesamt wachsen. Weil die Increments-Eigenschaft von AdaptableIncrementAxis vom Typ DoubleCollection ist, eignet sie sich nicht für DateTime-Werte. An späterer Stelle in diesem Artikel wird eine Alternative für DateTime beschrieben.

In der XAML-Datei im SalesByMonth-Projekt sind zwei LineChartGenerator-Objekte für die beiden Produkte definiert, die das in Abbildung 5 dargestellte zusammengesetzte Diagramm ermöglichen.

Abbildung 5 Die SalesByMonth-Anzeige

image: The SalesByMonth Display

Diese Option zur Erstellung eines zusammengesetzten Diagramm erforderte keine speziellen Anpassungen der Klassen, die LineChartLib bilden. Der Code generiert lediglich Auflistungen, die in XAML flexibel gehandhabt werden können.

Um alle Beschriftungen und Achsen unterzubringen, ist das gesamte Diagramm als Grid-Objekt mit vier Zeilen und fünf Spalten angelegt, das fünf ItemsControl-Steuerelemente enthält: zwei für die beiden Auflistungen der Datenelemente des Diagramms, zwei für die Achsenskalen links und rechts und eines für die horizontale Achse.

Die Farbcodierung zur Unterscheidung der beiden Produkte ist in XAML einfach zu implementieren. Beachten Sie jedoch, dass die beiden Produkte zudem durch dreieckige und quadratische Datenpunkte voneinander unterschieden werden. Die dreieckigen Elemente werden durch die folgende DataTemplate-Instanz gerendert:

<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>

In der Praxis sollten Sie Formen, die tatsächlich mit den beiden Produkten verknüpft sind, oder sogar kleine Bitmaps verwenden.

Die Linie, die die Punkte in diesem Beispiel verbindet, ist kein Standard-Polyline-Element, sondern ein benutzerdefinierter von Shape abgeleiteter Typ namens CanonicalSpline. (Die kanonisch Splinekurve – auch kardinale Spline genannt – ist Bestandteil von Windows Forms, wurde jedoch nicht in WPF aufgenommen.) Die einzelnen Punktepaare werden durch eine Kurve verbunden, die algorithmisch von den beiden zusätzlichen Punkten abhängt, die das Punktepaar umgeben.) Es ist auch möglich, eine andere benutzerdefinierte Klasse zu diesem Zweck zu schreiben, vielleicht eine Klasse, die für die Punkte eine Interpolation nach der Kleinstequadratemethode durchführt und das Ergebnis anzeigt.

Die HorizontalAxis.AxisItems-Eigenschaft des LineChartChartGenerator-Objekts ist eine ObservableCollection-Auflistung vom Typ DateTime, und das bedeutet, dass die Elemente mit der StringFormat-Funktion der Binding-Klasse und Standardformatzeichenfolgen für Datums-/Uhrzeitwerte formatiert werden können.

In der DataTemplate-Instanz für die horizontale Achse wird die Formatzeichenfolge "MMMM" verwendet, um den ganzen Monatsnamen anzuzeigen:

<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>

Datums- und Uhrzeitangaben

Die Verwendung von DateTime-Objekten auf der horizontalen Achse eines Liniendiagramms ist so gängig, dass sich die Mühe lohnt, eine AxisStrategy-Klasse zu programmieren, die speziell zur Handhabung dieser Objekte dient. In manchen Liniendiagrammen werden Daten wie Aktienpreise oder Umweltdaten zusammengetragen, wobei möglicherweise stündlich neue Daten hinzugefügt werden. Hier wäre es schön, eine AxisStrategy-Klasse zu haben, die sich auf der Grundlage des Bereichs der DateTime-Werte der Diagrammelemente selbst anpasst.

Mein Versuch einer solchen Klasse heißt AdaptableDateTimeAxis und soll einen breiten Bereich von DateTime-Daten, der von Sekunden bis Jahren reicht, handhaben können.

AdaptableDateTimeAxis verfügt über die MaximumItems-Eigenschaft (mit der Standardeinstellung 10) und sechs Auflistungen namens SecondIncrements, MinuteIncrements, HourIncrements, DayIncrements, MonthIncrements und YearIncrements. Die Klasse versucht systematisch, ein Inkrement zwischen Teilstrichen zu finden, sodass die Anzahl von Elementen den Wert von MaximumItems nicht übersteigt. In der Standardeinstellung testet die AdaptableDateTimeAxis-Klasse Inkremente von 1 Sekunde, 2 Sekunden, 5, 15 und 30 Sekunden, dann 1, 2, 5, 15 und 30 Minuten, und ferner 1, 2, 4, 6 und 12 Stunden, 1, 2, 5 und 10 Tage und 1, 2, 4 und 6 Monate. Sobald die Einheit Jahr erreicht wird, werden Inkremente von 1, 2 und 5 Jahren, dann 10, 20 und 50 usw. ausprobiert.

AdaptableDateTimeAxis definiert auch eine schreibgeschützte Abhängigkeitseigenschaft namens DateTimeInterval (auch der Name einer Aufzählung mit den Elementen Second, Minute, Hour usw.), die die Einheit des von der Klasse ermittelten Wertzuwachses einer Achse angibt. Dies Eigenschaft ermöglicht die Definition von DataTrigger-Elementen in XAML, die die DateTime-Formatierung abhängig vom Inkrement ändern. Abbildung 6 veranschaulicht ein Beispiel für eine DataTemplate-Instanz, die eine solche Formatauswahl durchführt.

Abbildung 6 DataTemplate für die horizontale Achse von TemperatureHistory

<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>

Diese Vorlage stammt aus dem TemperatureHistory-Projekt, das auf die Website des Wetterdiensts National Weather Service zugreift und die im Central Park in New York City stündlich gemessenen Temperaturwerte abruft. Abbildung 7 zeigt die TemperatureHistory-Anzeige, nachdem das Programm einige Stunden lang ausgeführt wurde; Abbildung 8 zeigt die Anzeige nach mehreren Tagen.

Abbildung 7 Die TemperatureHistory-Anzeige mit Stunden

image: The TemperatureHistory Display with Hours

Abbildung 8 Die TemperatureHistory-Anzeige mit Tagen

image: The TemperatureHistory Display with Days

Natürlich sind meine Liniendiagrammklassen nicht völlig flexibel – beispielsweise gibt es derzeit keine Möglichkeit, Teilstriche zu zeichnen, die keinen Textbeschriftungen zugeordnet sind –, aber ich denke, sie veranschaulichen einen gangbaren und leistungsfähigen Ansatz, ausreichend Informationen zur Definition von Liniendiagrammen in XAML bereitzustellen.

Charles Petzold schreibt seit langem redaktionelle Beiträge für das MSDN Magazin*. Sein neuestes Buch heißt* The Annotated Turing: A Guided Tour through Alan Turing’s Historic Paper on Computability and the Turing Machine (Wiley, 2008). Die Adresse seiner Website lautet charlespetzold.com.

Unser Dank gilt dem folgenden technischen Experten für die Durchsicht dieses Artikels: David Teitelbaum