Carte LINQ
Création de cartes dynamiques avec Visual Basic 9.0 et WPF
Scott Wisniewski
Cet article est basé sur une version préliminaire de Visual Studio. Toutes les informations contenues dans le présent document peuvent faire l’objet de modifications.
Cet article aborde les sujets suivants:
- Propriétés des dépendances WPF
- Requêtes XML avec LINQ
- Dessin de la carte
- Visualisation des données de la carte
|
Cet article utilise les technologies suivantes:
WPF, Visual Basic 9.0, LINQ
|

Sommaire
J'ai toujours été fasciné par le dessin de cartes. Je pense également que Visual Studio® 2008 et Visual Basic® 9.0 sont formidables. Par conséquent, lorsque j'ai eu la possibilité d'explorer Visual Studio et d'écrire un article à ce sujet, je me suis dit que la meilleure approche possible serait un didacticiel sur le dessin de cartes avec Visual Basic. Ceci me permettrait non seulement de démontrer certaines fonctionnalités sympas de Visual Basic, mais encore de vous donner un exemple fonctionnel que vous pourrez utiliser comme référence pour ajouter des fonctionnalités similaires à vos propres programmes.
Le résultat de mon travail est illustré à la figure 1, qui est une capture d'écran d'une application qui emploie une carte thermique pour visualiser la population des États-Unis. L'application a été générée en s'appuyant largement sur l'infrastructure de liaison de données de Windows® Presentation Foundation (WPF), qui m'a permis de séparer de façon efficace la logique propre au domaine de mon application de la visualisation d'interface utilisateur.
Figure 1 Application de cartographie de la population (Cliquer sur l'image pour l'agrandir)
Je commencerai par un bref aperçu de sujets préliminaires relatifs à la liaison de données WPF dans la section suivante, puis je consacrerai le reste de cet article à une description détaillée de la façon dont j'ai écrit l'application. Je décrirai tout d'abord le modèle d'objet Visual Basic de l'application, puis expliquerai comment utiliser les nouvelles fonctionnalités XML innovantes de Visual Basic 9.0 et LINQ pour mettre en œuvre ma logique de traitement des données. Je conclurai en détaillant comment utiliser WPF de façon efficace pour visualiser les données de l'application.
Liaison de données WPF
L'infrastructure de liaison de données WPF repose sur les concepts d'objets de dépendance et de propriétés de dépendance. Un objet de dépendance fournit la prise en charge de la notification de modification et la possibilité d'extraire et récupérer de façon dynamique des valeurs de propriété. Une propriété de dépendance est une propriété associée à un objet de dépendance. Ensemble, ils forment l'infrastructure de base de nombreux secteurs de WPF, notamment la liaison des données, l'animation et les styles.
Les objets de dépendance sont représentés par des instances de types dérivés de la classe DependencyObject. De même, les propriétés de dépendance sont représentées par des instances de DependencyProperty qui ont été inscrites dans le système de propriétés de WPF.
Lorsque les propriétés de dépendance sont utilisées dans un modèle de données d'application, elles simplifient considérablement le code. Elles permettent d'implémenter directement un grand nombre de tâches sans nécessiter de contrôles devant être manipulés de façon explicite par le programmeur. Grâce aux mécanismes de liaison de données déclaratives et de contrôle du style de WPF, les modifications peuvent être propagées automatiquement à l'interface utilisateur sans nécessiter une logique personnalisée.
Ceci rend le code des interfaces utilisateur WPF beaucoup plus facile à lire, écrire et maintenir. Cela promeut également une architecture d'applications plus propre en favorisant une meilleure séparation des différentes couches. Enfin, et encore plus important, cela permet d'utiliser des outils tels qu'Expression BlendTM pour concevoir des interfaces utilisateur de façon visuelle, en se concentrant sur les éléments de style plutôt que sur la logique d'application.
Pour un exemple simple d'utilisation des propriétés de dépendance, consultez la figure 2. Cette figure illustre une partie de la définition de la classe MapRegion. MapRegion hérite de DependencyObject. Son objectif principal est de représenter une zone géographique dans une carte. Elle déclare deux propriétés de dépendance, RegionName et IsSelected. Les propriétés de dépendance sont déclarées en appelant la méthode partagée Register sur la classe DependencyProperty, en fournissant le nom de la propriété, son type et le type de la classe qui la contient (en l'occurrence MapRegion). Les propriétés de dépendance renvoyées sont ensuite affectées aux champs publics, partagés et en lecture seule déclarés sur la classe. Ceci se fait principalement par souci de conformité aux conventions WPF, qui exposent les métadonnées des propriétés de dépendance en tant que champs partagés sur les classes qui les définissent.

Figure 2 Extrait de code de la classe MapRegion
Imports System.Collections.ObjectModel
Public Class MapRegion
Inherits DependencyObject
Public Shared ReadOnly RegionNameProperty As DependencyProperty _
= DependencyProperty.Register("RegionName", GetType(String), _
GetType(MapRegion))
Public Shared ReadOnly IsSelectedProperty As DependencyProperty _
= DependencyProperty.Register("IsSelected", GetType(Boolean), _
GetType(MapRegion))
'...
Public Property RegionName() As String
Get
Return CStr(GetValue(RegionNameProperty))
End Get
Set(ByVal value As String)
SetValue(RegionNameProperty, value)
End Set
End Property
Public Property IsSelected() As Boolean
Get
Return CBool(GetValue(IsSelectedProperty))
End Get
Set(ByVal value As Boolean)
SetValue(IsSelectedProperty, value)
End Set
End Property
Les propriétés de dépendance sont ensuite exposées au code par l'intermédiaire de propriétés de wrapper qui appellent les méthodes GetValue et SetValue définies dans la classe DependencyObject, fournissant ainsi l'objet DependencyProperty en tant que paramètre. Ceci permet aux consommateurs de la classe MapRegion d'accéder aux propriétés de dépendance comme toute autre propriété d'instance normale, tout en profitant du riche système de propriétés de WPF.
Il est utile de mentionner que les données de propriété réelles ne sont pas enregistrées directement en tant que champ dans la classe MapRegion. Au lieu de cela, WPF gère le stockage de chaque propriété de dépendance comme attachée à un objet de dépendance, les rendant ainsi facilement disponibles par l'intermédiaire d'appels à SetValue et GetValue.
Vous devez également tenir compte du fait qu'une propriété de dépendance ne peut être enregistrée avec un objet de dépendance qu'une seule fois. Toute tentative d'enregistrement de deux propriétés de dépendance du même nom sur le même type entraînera une exception.
Dans mon exemple, je m'assure que chaque propriété de dépendance n'est enregistrée qu'une seule fois en déclarant les champs partagés en lecture seule avec des expressions d'initialisation explicites qui appellent DependencyProperty.Register. Ceci entraîne l'exécution de l'enregistrement dans le cadre du constructeur statique de la classe MapRegion, dont le CLR (Common Language Runtime) assure l'exécution unique (avant l'exécution de tout code faisant référence à la classe MapRegion).
Le code XAML présenté à la figure 3 illustre comment les propriétés de dépendance peuvent être utilisées pour propager automatiquement des modifications d'un modèle de domaine vers l'interface utilisateur d'une application. Il définit un style qui, lorsqu'il est appliqué à un contrôle Polygon, présente le polygone avec une bordure orange épaisse lorsque sa région MapRegion associée voit sa propriété IsSelected définie sur True, et avec une fine bordure noire dans tous les autres cas. Ceci permet au code régissant l'application de simplement marquer une région comme sélectionnée, puis de demander à l'interface utilisateur de réagir en conséquence.

Figure 3 Style de polygone utilisant des déclencheurs de données
<Style x:Key="RegionPolygonStyle" TargetType="Polygon">
<Style.Triggers>
<DataTrigger Binding="{Binding Path=Region.IsSelected}"
Value="True">
<Setter Property="Stroke" Value="Orange" />
<Setter Property="StrokeThickness" Value="20"/>
</DataTrigger>
<DataTrigger Binding="{Binding Path=Region.IsSelected}"
Value="False">
<Setter Property="Stroke" Value="Black"/>
<Setter Property="StrokeThickness" Value="5" />
</DataTrigger>
</Style.Triggers>
</Style>
Modèle d'objet Application
Les données de liaison WPF sont impressionnantes, je vais donc m'efforcer de les activer dans mon application aussi souvent que possible. Mes objectifs étaient d'écrire du code clair, concis et spécifique au domaine pour gérer l'application et permettre l'utilisation d'Expression Blend pour concevoir son interface.
Pour ce faire, j'ai créé un modèle d'objet permettant de représenter les informations cartographiques qui tire largement parti du système de propriétés des dépendances WPF. Il consiste en trois grandes classes, Map, MapRegion et MapPolygon, qui dérivent toutes de DependencyObject. La classe Map correspond à la racine du modèle d'objet et permet de représenter une carte contenant une liste de régions. La classe MapRegion représente une région ou une entité géographique sur une carte. Elle contient une liste de polygones où chaque polygone décrit les limites de l'un des secteurs géographiques contigus qui définissent la région. La classe MapPolygon représente un polygone dans une région et contient une collection d'objets Point décrivant ses sommets.
La classe Map comporte cinq propriétés de dépendance : BoundingBox, ScaleX, ScaleY, TranslateX et TranslateY. La propriété BoundingBox est une instance de la classe Rect et enregistre le plus petit secteur rectangulaire qui contient toutes les régions de la carte. Les propriétés ScaleX, ScaleY, TranslateX et TranslateY sont définies pour prendre en charge le grossissement ou la réduction de la carte, ainsi que les vues panoramiques. Ces propriétés sont importantes dans la mesure où les coordonnées des points de la carte représentent des positions, en kilomètres, relatives à l'intersection de l'équateur et du premier méridien. WPF, en revanche, emploie un système de coordonnée différent, qui est défini en termes de positions relatives, en pixels logiques (un pixel logique est égal à 1/96ème de pouce), à partir du coin supérieur gauche d'une fenêtre.
De ce fait, les propriétés ScaleX, ScaleY, TranslateX et TranslateY servent à exécuter les actions suivantes :
- Mettre la carte à l'échelle pour qu'elle s'adapte à une fenêtre.
- Renverser la carte verticalement pour ne pas la dessiner à l'envers. (Dans la vue « monde », les coordonnées Y augmentent lorsque vous vous déplacez vers le nord. Dans la vue WPF, les coordonnées Y augmentent lorsque vous vous déplacez vers le bas d'une fenêtre.)
- Déplacez la carte de sorte que son coin supérieur gauche corresponde au coin supérieur gauche du contrôle qui affiche la carte.
Vous pouvez également utiliser ces propriétés pour implémenter le grossissement et la réduction, ainsi que l'affichage panoramique au sein de l'interface utilisateur. Par exemple, si vous multipliez les valeurs de ScaleX et ScaleY par 1,5, la carte sera agrandie de 150 %, et en ajoutant 50 à la propriété TranslateX, la zone représentée se déplace de 50 kilomètres vers l'ouest. Il convient ici de signaler que la modification de ces valeurs ne modifie pas les coordonnées de la carte. Ces valeurs permettent d'informer l'interface utilisateur de ce qui doit être fait pour afficher la carte.
La classe Map expose sa collection de régions à l'aide de la propriété Regions. Cependant, contrairement aux autres propriétés de la classe, Regions n'est pas une propriété de dépendance. Il s'agit en fait d'une propriété en lecture seule typée en tant que ObservableCollection(de MapRegion). Elle n'est pas déclarée en tant que propriété de dépendance, car je n'avais pas besoin de pouvoir affecter toute une collection à la fois. La collection reste elle-même muable et des régions peuvent être ajoutées et supprimées de la carte à l'aide des méthodes Add et Remove de la classe ObservableCollection. En outre, dans la mesure où la collection est exposée sous la forme ObservableCollection, elle peut toujours participer au système de liaison de données WPF. Par exemple, si ListBox est lié aux données pour ObservableCollection, le simple ajout d'un nouvel élément à cette collection entraînera l'apparition d'un nouvel élément dans le contrôle ListBox. De même, la suppression d'un élément de la collection entraînera sa suppression du contrôle ListBox.
Classes Map
La classe MapRegion définit deux propriétés de dépendance : BoundingBox et RegionName. La propriété BoundingBox définit le secteur rectangulaire contenant la région et RegionName enregistre le nom de la région. Cette classe définit également deux propriétés de collection : Polygons et FipsCodes. La collection Polygons contient les polygones qui définissent la région et FipsCodes contient la série de codes FIPS numériques (Federal Information Processing Standard) associés à cette région. Cette classe utilise des codes FIPS car les données de polygone que j'utilise dans l'application ont été téléchargées à partir du site de l'Office américain de Recensement (United States Census Bureau), qui emploie des codes FIPS pour identifier de façon unique les entités géographiques des États-Unis. Vous trouverez plus de détails sur la norme FIPS à l'adresse
itl.nist.gov/fipspubs/by-num.htm. Vous trouverez également des informations techniques sur le domaine public de la base de données géographique TIGER de l'Office de Recensement américain à l'adresse
census.gov/geo/www/cob.
La classe MapPolygon définit deux propriétés. La première est une propriété de dépendance en lecture seule appelée Region qui contient une référence à la région comprenant le polygone. La deuxième est une propriété de collection appelée Points et qui contient la série de structures Point qui définissent les sommets du polygone. Vous trouverez la source complète de la classe à la figure 4.

Figure 4 MapPolygon.vb
Public Class MapPolygon
Inherits DependencyObject
Private Shared ReadOnly RegionPropertyKey As DependencyPropertyKey _
= DependencyProperty.RegisterReadOnly("Region", _
GetType(MapRegion), GetType(MapPolygon), New PropertyMetadata())
Public Shared ReadOnly RegionProperty As DependencyProperty
Private m_Points As PointCollection
Shared Sub New()
RegionProperty = RegionPropertyKey.DependencyProperty
End Sub
Sub New()
SetRegion(Nothing)
m_Points = New PointCollection
End Sub
Friend Sub SetRegion(ByVal r As MapRegion)
SetValue(RegionPropertyKey, r)
End Sub
Public ReadOnly Property Region() As MapRegion
Get
Return CType(GetValue(RegionProperty), MapRegion)
End Get
End Property
Public ReadOnly Property Points() As PointCollection
Get
Return m_Points
End Get
End Property
End Class
La propriété Region est exposée en tant que propriété de dépendance en lecture seule. Cela permet au système de propriétés WPF de détecter les modifications de la valeur de la propriété, mais ne lui permet pas d'être modifié par WPF. Une propriété de dépendance en lecture seule est déclarée en appelant la méthode RegisterReadOnly de la classe DependencyProperty. Contrairement à la méthode Register, RegisterReadOnly renvoie une instance de DependencyPropertyKey et non pas DependencyProperty.
Les clés de propriété de dépendance sont des objets qui permettent de modifier les propriétés de dépendances en lecture seule. Elles permettent d'implémenter des classes qui doivent mettre à jour des valeurs de propriété en interne et permettent la détection de ces modifications par les autres types sans pour autant autoriser les modifications externes de ces valeurs. Par conséquent, les clés DependencyPropertyKeys ne sont généralement pas exposées à partir de la classe qui les définit. C'est pourquoi le champ RegionPropertyKey est marqué comme privé. Un deuxième champ partagé, RegionProperty, est ensuite utilisé pour exposer publiquement un alias en lecture seule.
La propriété Region est en lecture seule, car elle a été conçue pour ne pas pouvoir être modifiée en externe. Au lieu de cela, la classe MapRegion définira et supprimera automatiquement la valeur lorsqu'un polygone est ajouté ou supprimé de sa collection de régions. Pour ce faire, elle traite l'événement CollectionChangedEvent pour la collection de polygones et propage l'instance de Region appropriée aux polygones concernés.
L'implémentation du gestionnaire d'événements est illustrée à la figure 5. Elle fonctionne en appelant la méthode d'extension SetRegion sur les collections NewItems et OldItems enregistrées dans le paramètre NotifyCollectionChangedEventArgs du gestionnaire. La méthode d'extension appelle ensuite la méthode SetRegion de la classe MapPolygon sur chaque élément de la collection cible. La méthode d'instance est marquée en tant qu'ami pour en éviter tout appel. L'implémentation de la méthode d'extension se présente comme suit :

Figure 5 Méthode MapRegion.PolygonCollectionChanged
Private Sub PolygonCollectionChanged(ByVal sender As Object, ByVal e As _
System.Collections.Specialized.NotifyCollectionChangedEventArgs) _
Handles m_polygons.CollectionChanged
Select Case e.Action
Case Specialized.NotifyCollectionChangedAction.Add
e.NewItems.Cast(Of MapPolygon).SetRegion(Me)
Case Specialized.NotifyCollectionChangedAction.Remove
e.OldItems.Cast(Of MapPolygon).SetRegion(Nothing)
Case Specialized.NotifyCollectionChangedAction.Replace
e.NewItems.Cast(Of MapPolygon).SetRegion(Me)
e.OldItems.Cast(Of MapPolygon).SetRegion(Nothing)
End Select
End Sub
<Extension()> _
Sub SetRegion(ByVal x As IEnumerable(Of MapPolygon), _
ByVal r As MapRegion)
For Each item In x
item.SetRegion(r)
Next
End Sub
Contrairement aux propriétés de collection des classes Map et MapRegion, la collection Points de la classe MapPolygon n'est pas de type ObservableCollection(of T). Elle utilise plutôt la classe PointsCollection intégrée de WPF. Il s'agit du même type que celui utilisé par le contrôle Polygon de WPF pour enregistrer la série de points qu'il contient. En utilisant cette classe dans le modèle de données, il est ensuite possible de lier les données d'un contrôle Polygon directement à une instance de MapPolygon.
Importation des données cartographiques
L'application charge ses données cartographiques à partir de fichiers XML stockés sur le disque. J'ai créé ces fichiers en téléchargeant les fichiers bruts de limites cartographiques au format ASCII à partir du site de l'Office américain du Recensement, qui décrivent les limites de chaque État et de chaque comté (et les secteurs équivalents) aux États-Unis. Les données de limite des États permettent de créer une carte des États-Unis, tandis que les données de limite des comtés permettent de visualiser les données démographiques sur la carte. J'ai ensuite pris les données téléchargées et les ai converties en fichiers XML afin d'en faciliter le traitement. Le code de conversion des fichiers au format XML figure dans le téléchargement accompagnant cet article. Les fichiers de données brutes peuvent être téléchargés à l'adresse
census.gov/geo/www/cob/bdy_files.html.
Á l'aide de la fonctionnalité de création de schéma de Visual Studio, j'ai ensuite créé un schéma XML qui décrit le contenu des fichiers XML. Ceci m'a permis d'ensuite utiliser la nouvelle fonctionnalité XML IntelliSense® de Visual Basic, qui présente les expressions d'accès au membre XML en fonction des fichiers de schéma inclus dans un projet (voir la figure 6).
Figure 6 XML IntelliSense de Visual Basic (Cliquer sur l'image pour l'agrandir)
Format des données cartographiques
Le schéma décrivant les fichiers cartographiques XML définit un format simple consistant en une balise File racine qui à son tour contient une séquence de balises Region. Chaque balise Region contient une série de balises FipsCode et Polygon, où chaque balise Polygon contient une série de balises Vertex et Island.
Les balises Region sont utilisées pour décrire une région unique. Elles définissent deux attributs, qui sont tous deux obligatoires : Type et Name. L'attribut Type décrit le type de la région. Dans le cadre de mon application, j'utilise toujours les valeurs State ou County. En général, cependant, toute autre valeur peut être utilisée pour l'attribut Type. L'attribut Name décrit le nom de la région.
Chaque balise FipsCode définie dans une région décrit une de ses entrées de code FIPS. La dernière balise FipsCode incorporée dans une balise Region définit l'ID numérique de la région. Les autres balises FipsCode décrivent de façon récursive les ID numériques des parents de la région.
Dans le cadre de mon application, toutes les régions ont au moins deux valeurs FipsCode associées. En particulier, les régions qui décrivent des États (ou les secteurs équivalents à des État) disposent toujours d'une entrée qui décrit le code FIPS associé à cet État. Les régions décrivant des comtés (ou des secteurs équivalents à des comtés) ont toujours deux entrées. La première indique le code FIPS de l'État qui contient le comté et la seconde indique le code FIPS du comté lui-même. L'ID numérique d'une région est toujours garantie comme unique dans l'étendue de son parent immédiat.
Chacune des balises Polygon d'une région décrit le polygone qu'elle contient. Le polygone est composé d'une série de balises Vertex, où chaque balise décrit un sommet unique. La balise Vertex définit cinq attributs, qui sont tous requis : Ordinal, Longitude, Latitude, X et Y. L'attribut Ordinal définit l'ordre des sommets d'un polygone. Son objectif principal est de pouvoir classer les sommets dans l'ordre s'ils sont traités par des requêtes qui ne conservent pas cet ordre. Cet attribut n'est pas utilisé actuellement par mon application. Les attributs Longitude et Latitude décrivent les coordonnées correspondantes du sommet. De même, les attributs X et Y définissent les coordonnées du sommet lorsqu'elles sont projetées dans un espace rectangulaire et sont utilisées pour dessiner les cartes.
Les attributs X et Y ont été calculés à l'aide de la projection simple illustrée à la figure 7. Cette projection n'est pas particulièrement précise et peut ne pas convenir à certains scénarios. Le cas échéant, il est possible de calculer de nouvelles valeurs en utilisant directement les valeurs de Longitude et Latitude au lieu d'utiliser les valeurs X et Y enregistrées dans le fichier. Pour mon application, qui ne nécessite pas un degré de précision géographique élevé, ceci était suffisant.
Figure 7 Une projection cartographique incroyablement simple
La balise Island d'un polygone définit un espace à l'intérieur de ce dernier qui ne fait pas partie de la région qui le contient. Elle contient une collection de balises Vertex qui décrivent les limites de cet espace. Je les ai inclues dans les fichiers de données principalement pour des raisons d'exhaustivité ; elles ne sont pas utilisées par mon application. Cela signifie que la carte peut comporter quelques minuscules secteurs qui affichent des valeurs légèrement incorrectes lors de la représentation des données. Dans le cadre de mon application, ce n'est pas un problème et je n'en ai donc pas tenu compte.
Si votre application nécessite davantage de précision à ce niveau, ou si vous cartographiez des zones où les îles de polygone sont majoritaires, vous pouvez facilement résoudre ce problème en contrôlant l'ordre de rendu de la carte. En commençant par dessiner le polygone contenant une île, puis en dessinant l'île au dessus, il est possible de rendre avec précision les îles de polygone. Dans de nombreuses situations, ceci peut également nécessiter un traitement supplémentaire pour déterminer des secteurs de superposition et mettre en place l'ordre qui convient.
Importation des données
Avec LINQ, l'importation des données cartographiques dans l'application est relativement facile. Le code qui permet d'effectuer cette opération est reproduit à la figure 8. Il définit une procédure, LoadFile, qui prend deux paramètres : file et list. Le paramètre file est une chaîne qui contient le chemin complet du fichier XML à charger à partir du disque. Le paramètre list est une référence à une collection d'instances de MapRegion. La procédure ouvre ensuite le fichier XML, le traite, puis insère toutes les régions qu'il définit dans la collection fournie.

Figure 8 Chargement des instances MapRegion à partir d'un fichier XML
Private Sub LoadFile(ByVal file As String, ByVal list As ICollection(Of _
MapRegion))
Dim doc = XDocument.Load(file)
Dim q = _
From v In doc.<File>.<Region>.<Polygon>.<Vertex> _
Let _
Polygon = v.Parent, _
Region = v.Parent.Parent _
Group By _
Polygon, _
Region _
Into _
Group, _
MinX = MinDBL(v.@X), _
MinY = MinDBL(v.@Y), _
MaxX = MaxDBL(v.@X), _
MaxY = MaxDBL(v.@Y) _
Select _
TheMapPolygon = (From tmp In Group Select _
tmp.v).ToMapPolygon(), _
Region, _
BoundingBox = New Rect(New Point(MinX, _
MinY), New Point(MaxX, MaxY)) _
Group By _
Region _
Into _
Group, _
BoundingBox = Enclose(BoundingBox) _
Select _
Region = New MapRegion(Region.@Name, (From item In Group _
Select item.TheMapPolygon), (From f In _
Region...<FipsCode> Select CInt(f.Value)), BoundingBox)
list.AddRange(q)
End Sub
Le corps de LoadFile est extrêmement simple. Cette procédure fonctionne en chargeant tout d'abord le fichier fourni dans une instance XDocument, en définissant une requête pour parcourir le document et le convertir en une collection d'instances de MapRegion, et enfin en exécutant la requête et en insérant ses résultats dans la collection de sortie fournie.
La requête est composée de plusieurs sections différentes. La première est sa clause From, qui utilise l'expression d'accès du membre XML doc.<File>.<Region>.<Polygon>.<Vertex> pour extraire toutes les balises Vertex de polygone du document comme base de la requête. Il est bon de noter que cette requête n'utilise pas doc...<Vertex>, qui couvrirait toutes les balises Vertex du document, y compris celles qui sont définies dans les balises Island. Au lieu de cela, elle inclut uniquement les balises Vertex qui sont directement définies dans les balises Polygon. La deuxième partie de la requête est la clause Let. Cette clause introduit deux nouvelles variables, Polygon et Region, qui sont affectées aux balises Polygon et Region qui contiennent chaque sommet.
La troisième partie de la requête est sa première clause Group By. Celle-ci regroupe simplement la liste de sommets en fonction du polygone conteneur, puis calcule le rectangle englobant du polygone. La région contenant le polygone est également incluse dans la clé de groupement, ce qui permet de l'utiliser à un stade ultérieur de la requête. Le rectangle englobant du polygone est calculé en fonction des valeurs maximales et minimales des attributs X et Y de son sommet. Il convient pour cela d'appeler les fonctions agrégées personnalisés MinDBL et MaxDBL, qui sont définies par deux méthodes d'extension :
<Extension()> _
Function MinDBL(Of T)(ByVal x As IEnumerable(Of T), _
ByVal y As Func(Of T, String)) As Double
Return x.Min(Function(z) CDbl(y(z)))
End Function
<Extension()> _
Function MaxDBL(Of T)(ByVal x As IEnumerable(Of T), _
ByVal y As Func(Of T, String)) As Double
Return x.Max(Function(z) CDbl(y(z)))
End Function
Celles-ci définissent simplement des fonctions agrégées qui calculent les valeurs minimales ou maximales en fonction des arguments fournis après conversion vers le type Double.
La clause Group by est suivie par une clause Select qui convertit le groupe brut en résultats de forme plus utilisable. En particulier, elle définit une sous-requête qui prend le groupe résultant de Group By, en projette uniquement les sommets, puis convertit la collection résultante en une instance de la classe MapPolygon à l'aide de la méthode d'extension ToMapPolygon :
<Extension()> _
Function ToMapPolygon(ByVal items As IEnumerable(Of XElement)) As _
MapPolygon
Dim ret As New MapPolygon
ret.Points.AddRange(From item In items Select item.ToPoint())
Return ret
End Function
<Extension()> _
Function ToPoint(ByVal x As XElement) As Point
Return New Point(x.@X, x.@Y)
End Function
Elle convertit également les variables brutes MinX, MinY, MaxX et MaxY en une instance de la classe Rect.
La partie suivante de la requête est sa deuxième clause Group By, qui agrège les résultats de la sélection précédente par Region et calcule le rectangle englobant contenant tous les polygones de la région. Ce rectangle englobant est calculé à l'aide de la fonction agrégée personnalisée Enclose, dont la définition est illustrée à la figure 9. La dernière partie de la requête est la clause finale Select, qui crée une instance MapRegion pour chaque résultat renvoyé à partir du deuxième Group By. Pour ce faire, elle utilise deux sous-requêtes, dont l'une projette l'instance de polygone contenue dans chaque membre de Group et l'autre extrait la série de codes FIPS enregistrés dans Region.

Figure 9 Fonction d'agrégation Enclose
<Extension()> _
Function Enclose(Of T)(ByVal collection As IEnumerable(Of T), ByVal _
selector As Func(Of T, Rect)) As Rect
Dim ret As Rect
Dim first As Boolean = True
For Each item In collection
If first Then
ret = selector(item)
first = False
Else
ret.Enclose(selector(item))
End If
Next
Return ret
End Function
<Extension()> _
Sub Enclose(ByRef theBox As Rect, ByVal otherBox As Rect)
Dim X = Math.Min(theBox.X, otherBox.X)
Dim Y = Math.Min(theBox.Y, otherBox.Y)
Dim Width = Math.Max(theBox.BottomRight.X, otherBox.BottomRight.X) _
- X + 1
Dim Height = Math.Max(theBox.BottomRight.Y, otherBox.BottomRight.Y) _
- Y + 1
theBox.X = X
theBox.Y = Y
theBox.Width = Width
theBox.Height = Height
End Sub
Dessin de la carte
Une fois une carte chargée en mémoire, son affichage dans une fenêtre est réellement facile. Le code XAML de la figure 10 illustre comment procéder simplement. Ici je définis un contrôle Canvas simple contenant deux transformations pour son rendu de transformation, et un contrôle ItemsControl nommé MapViewer en tant que contenu. Les transformations sont utilisées pour traduire les coordonnées des régions sur la carte de façon à les afficher correctement dans le canevas. Le contrôle ItemsControl permet de rendre visuellement le contenu de la carte. Toutes les liaisons de données se font à l'aide de contextes de données implicites. Dans le cas du contrôle MasterCanvas, les liaisons de données seront toujours définies sur une instance de la classe Map.

Figure 10 Code XAML d'affichage d'une carte
<Canvas Grid.Row="2" Grid.Column="1" Name="MasterCanvas">
<Canvas.RenderTransform>
<TransformGroup>
<TranslateTransform X="{Binding Path=TranslateX}"
Y="{Binding Path=TranslateY}"/>
<ScaleTransform ScaleX="{Binding Path=ScaleX}"
ScaleY="{Binding Path=ScaleY}" />
</TransformGroup>
</Canvas.RenderTransform>
<ItemsControl Name="MapViewer" ItemsPanel="{DynamicResource SimpleCanvasTemplate}" ItemsSource="{Binding Path=Regions}" ItemTemplate="{DynamicResource RegionTemplate}" />
</Canvas>
TranslateTransform lie ses propriétés TranslateX et TranslateY aux propriétés équivalentes de la classe Map. De façon générale, ces valeurs sont définies sur la classe Map pour correspondre à la valeur négative du coin supérieur gauche du rectangle englobant de Map. Ceci a pour effet de déplacer le contenu de la carte de sorte que son coin supérieur gauche corresponde avec le coin à gauche supérieur du canevas. De même, ScaleTransform lie ses propriétés ScaleX et ScaleY aux propriétés équivalentes de la classe Map. Dans la plupart des cas, elles sont définies en fonction du rapport entre la largeur du canevas et celle de la carte, et du rapport négatif entre la hauteur du canevas par rapport et la hauteur de la carte.
Le code suivant présente l'implémentation de la méthode ScaleTo dans la classe Map :
Public Sub ScaleTo(ByVal size As Size)
If BoundingBox.Width <> 0 AndAlso BoundingBox.Height <> 0 Then
ScaleX = size.Width / BoundingBox.Width
ScaleY = -size.Height / BoundingBox.Height
TranslateX = -BoundingBox.Left
TranslateY = -BoundingBox.Bottom
End If
End Sub
Lorsqu'elle reçoit une structure Size qui contient la hauteur et la largeur de MasterCanvas, la méthode transforme la carte de façon à en afficher l'intégralité dans le canevas. Cette méthode est appelée en réponse à l'événement SizeChanged du canevas, ce qui redimensionne la carte en fonction de l'espace disponible lorsque la fenêtre du canevas est redimensionnée :
Private Sub WindowSizeChanged() Handles Me.SizeChanged
If m_Map IsNot Nothing Then
m_Map.ScaleTo(New Size(MasterCanvas.ActualWidth,
MasterCanvas.ActualHeight))
End If
End Sub
Il est intéressant de noter que l'ordre des transformations dans le conteneur TransformGroup est important. En particulier, TranslateTransformation doit se produire avant ScaleTransform. Ceci permet de spécifier les arguments des deux transformations en termes de modèle de domaine, plutôt qu'en termes d'interface utilisateur. Si ScaleTransform apparaît en premier dans la liste, les valeurs de TranslateTransform doivent être spécifiées en pixels et non en kilomètres, car ceci nécessiterait une conversion explicite. La spécification de TranslateTransform simplifie considérablement le modèle d'objet de l'application.
Le contrôle ItemsControl permet de rendre le contenu de la carte sur le canevas. Il se comporte de la même façon que le contrôle ListBox. En fait, ItemsControl constitue la classe de base de ListBox et définit la majeure partie de son comportement de liaison de données. Contrairement à ListBox, cependant, ItemsControl ne rend pas les éléments sous forme de liste. Par conséquent, ce contrôle est beaucoup plus adapté au rendu de cartes, qui ne doivent bien sûr pas être affichées sous forme de liste.
ItemsControl lie sa propriété ItemsSource à la collection Regions de la carte et utilise les ressources de SimpleCanvasTemplate et RegionTemplate pour définir la façon dont tout est affiché. Le modèle SimpleCanvasTemplate, utilisé pour la propriété ItemsPanel du contrôle ItemsControl, crée simplement un objet Canvas vide. Son but est de définir le conteneur à utiliser pour héberger chacun des éléments créés par le modèle ItemTemplate du contrôle. Un objet Canvas est utilisé car ces objets permettent de positionner leur contenu de façon explicite. D'autres « contrôles de conteneur », tels que StackPanel, appliqueraient une logique de disposition automatique qui dessinerait la carte de façon incorrecte.
La ressource RegionTemplate définit la disposition visuelle des différentes régions de la carte. Pour ce faire, elle déclare simplement un contrôle ItemsControl imbriqué pour chaque région et lie ItemSource à la collection Polygon de la région. Á l'instar de MapViewer, le contrôle ItemsControl imbriqué défini dans RegionTemplate utilise un objet Canvas pour la propriété ItemsPanel (il utilise à nouveau SimpleCanvasTemplate). Contrairement à MapViewer, cependant, il exploite la ressource PolygonTemplate pour afficher ses éléments.
La ressource PolygonTemplate définit un modèle de données qui rend un contrôle de polygone WPF à partir d'une instance de la classe MapPolygon. Elle lie chaque collection Points de Polygon à la propriété Points sur l'instance MapPolygon. Il est important de préciser que le contrôle Polygon voit sa propriété Fill définie de façon explicite sur Transparent. Il est nécessaire d'activer Polygon pour répondre aux nombreux événements de souris. WPF fait la différence entre les polygones dont la propriété Fill est définie et ceux pour lesquels elle ne l'est pas. Un polygone où avec la propriété Fill brush n'est pas définie est traité comme un polygone vide, tandis qu'un polygone où cette propriété est définie de façon explicite est traité comme un polygone plein. Les événements de souris, tels que MouseUp, MouseEnter et MouseLeave pour un polygone plein se déclenchent lorsque la souris est à l'intérieur du polygone. Avec les polygones vides, l'intérieur de la forme n'est pas considéré comme une partie de sa définition. Dans ce cas, le fait de placer la souris dans le polygone ne déclenche aucun de ses événements. En définissant la propriété Fill d'un polygone sur transparent, cependant, il devient possible d'activer l'interaction avec la souris sans obscurcir les autres données dessinées sur la carte.
Un autre aspect intéressant du modèle de polygone réside dans le fait qu'il utilise des styles déclaratifs basés sur des déclencheurs pour conditionner l'aspect du polygone. En particulier, il référence la ressource RegionPolygonStyle qui définit deux styles distincts pour le rendu de polygones. Les polygones dont les régions comportent des valeurs IsSelected définies sur True sont rendus avec une bordure orange épaisse, et celles dont les valeurs IsSelected sont définies sur False sont rendues avec une fine bordure noire. Ce code de gestion des événements indique comment ces styles peuvent être facilement utilisés pour implémenter des fonctionnalités d'interface utilisateur très sympas, telles que le suivi des entrées :
Private Sub Polygon_MouseEnter(ByVal sender As Polygon, ByVal e As _
System.Windows.Input.MouseEventArgs)
CType(sender.DataContext, MapPolygon).Region.IsSelected = True
End Sub
Private Sub Polygon_MouseLeave(ByVal sender As Polygon, ByVal e As _
System.Windows.Input.MouseEventArgs)
CType(sender.DataContext, MapPolygon).Region.IsSelected = False
End Sub
La figure 11 propose un exemple de cette action.
Figure 11 Utilisation d'un déclencheur d'événement de souris pour sélectionner une région (Cliquer sur l'image pour l'agrandir)
Affichage des données démographiques
La superposition des données démographiques sur la carte se fait également de façon très simple. Le code XAML qui permet d'effectuer cette opération est reproduit à la figure 12. Il modifie le code XAML utilisé pour dessiner la carte en ajoutant à MasterCanvas un deuxième contrôle ItemsControl appelé DataLayer. Dans la mesure où DataLayer est situé dans MasterCanvas, son contenu sera transformé de la même façon que MapViewer. Ceci permet aux données liées à DataLayer d'être également spécifiées en termes de modèle de domaine sous-jacent et non pas en termes d'interface utilisateur.

Figure 12 Code XAML modifié pour l'affichage d'une carte thermique
<Canvas Grid.Row="2" Grid.Column="1" Name="MasterCanvas">
<Canvas.RenderTransform>
<TransformGroup>
<TranslateTransform X="{Binding Path=TranslateX}"
Y="{Binding Path=TranslateY}"/>
<ScaleTransform ScaleX="{Binding Path=ScaleX}" ScaleY="{Binding Path=ScaleY}" />
</TransformGroup>
</Canvas.RenderTransform>
<ItemsControl Name="MapViewer" ItemsPanel="{DynamicResource SimpleCanvasTemplate}" ItemsSource="{Binding Path=Regions}" ItemTemplate=
"{DynamicResource RegionTemplate}" />
<ItemsControl Name="DataLayer" ItemsPanel=
"{DynamicResource SimpleCanvasTemplate}" ItemTemplate=
"{DynamicResource DataLayerTemplate}"/>
</Canvas>
Contrairement à MapViewer, DataLayer n'a pas été conçu pour hériter de son contexte de données directement depuis MasterCanvas. Il est plutôt conçu pour que les données à visualiser soient définies de façon explicite en tant qu'élément ItemsSource. Les données fournies doivent être une collection d'objets définissant deux propriétés : Color et Points. La propriété Color doit être de type Brush et définit la façon dont le polygone décrit par Points doit être coloré sur la carte. La propriété Points doit être une instance de PointsCollection qui définit les sommets du polygone à colorer. Voici le modèle DataTemplate utilisé pour rendre les éléments de DataLayer :
<DataTemplate x:Key="DataLayerTemplate">
<Polygon Stroke="Transparent" Fill="{Binding Path=Color}" Points="{Binding Path=Points}" />
</DataTemplate>
Ce dernier fonctionne en créant simplement un polygone et en liant ses propriétés Fill et Points aux propriétés correspondantes de l'élément sous-jacent.
La méthode LoadData illustrée à la figure 13 est responsable de charger les données de carte primaires dans une instance de la classe Map, en la définissant en tant que DataContext pour MasterCanvas, ce qui crée la carte thermique, puis en y liant DataLayer.

Figure 13 LoadData
Public Sub LoadData()
Dim list As New List(Of MapRegion)
LoadFile("PolygonData\2000\states.xml", list)
Dim q = From r In list Where Not excludedStates.Contains(r.RegionName)
m_Map = New Map()
Dim first As Boolean = True
Dim bb As Rect
For Each item In q
m_Map.Regions.Add(item)
If (first) Then
bb = item.BoundingBox
first = False
Else
bb.Enclose(item.BoundingBox)
End If
Next
m_Map.BoundingBox = bb
MasterCanvas.DataContext = m_Map
m_Map.ScaleTo(New Size(MasterCanvas.ActualWidth, _
MasterCanvas.ActualHeight))
Dim counties = New List(Of MapRegion)
LoadFile("PolygonData\2000\counties.xml", counties)
Dim doc = XDocument.Load("PopulationData\CountyPopulation.xml")
Dim q2 = _
From _
state In m_Map.Regions _
Join _
county In counties _
On _
state.FipsCodes(0) Equals county.FipsCodes(0) _
Join _
pd In doc.<CountyPopulationFile>.<CountyPopulation> _
On _
CInt(pd.@CountyFipsCode) Equals county.FipsCodes(1) And _
CInt(pd.@StateFipsCode) Equals state.FipsCodes(0) _
Where _
CDate(pd.@Date).Year = 2006 _
From _
Polygon In county.Polygons _
Select _
Color = New SolidColorBrush(colorMapEntries.Interprolate( _
CDbl(pd.@Population))), Points = Polygon.Points
q2 = q2.ToList()
DataLayer.ItemsSource = q2
'Force the state boundaries to be on top of the county regions....
MasterCanvas.Children.Remove(MapViewer)
MasterCanvas.Children.Add(MapViewer)
Dim legendInfo(colorMapEntries.Length - 2) As Object
For i = 0 To colorMapEntries.Length - 2
legendInfo(colorMapEntries.Length - (i + 2)) = _
New With{.StartColor = colorMapEntries(i).FillColor, _
.StopColor = colorMapEntries(i + 1).FillColor, .Value = _
If(colorMapEntries(i + 1).ShowInIndex, colorMapEntries( _
i + 1).Value.ToString("n"), "")}
Next
Legend.ItemsSource = legendInfo
End Sub
La partie de la méthode la plus intéressante est la requête, qui est utilisée pour dessiner la carte thermique. Elle fonctionne en joignant l'ensemble des États affichés sur la carte, l'ensemble des comtés chargés à partir du disque et les données démographiques au format XML. Elle joint la série d'États avec la série de comtés afin d'éliminer les comtés qui appartiennent à des États non représentés sur la carte. La clé de la jointure, des deux côtés, est le code FIPS de chaque région. Dans le cas d'un État, ceci correspond à son ID numérique. Dans le cas d'un comté, ceci correspond aux comtés contenant un ID numérique d'État. Les données de population XML sont ensuite jointes aux résultats, de façon à associer chaque valeur de population du comté avec l'instance MapRegion définissant le comté.
Les résultats de la jointure sont ensuite filtrés pour inclure les données démographiques de l'année 2006 uniquement. Ceci est important, car les données démographiques XML couvrent plusieurs années et je veux me limiter à une année spécifique sur ma carte. Si cette étape est ignorée, plusieurs polygones vont se chevaucher avec différentes valeurs pour une même région de la carte. Les résultats filtrés sont joints de façon croisée avec la collection Polygons pour chaque comté. Ceci permet de transformer la collection de comtés en collection de polygones.
La dernière section de la requête est une instruction Select qui extrait la collection Point de chaque polygone, calcule la couleur à afficher en fonction de la valeur de population associée, puis crée un contrôle SolidColorBrush qui rendra la région dans cette couleur. Les valeurs de couleur sont calculées à l'aide du tableau colorMapEntries, qui définit les correspondances entre les valeurs démographiques et les couleurs. En fait, chaque entrée du tableau définit une couleur et une valeur de limite supérieure de population correspondante. La couleur utilisée pour un comté est ensuite interpolée à partir des deux entrées de couleur de la carte entre lesquelles repose sa population.
L'interpolation est implémentée par la méthode d'extension Interpolate qui est définie à la figure 14. Pour ce faire, la méthode procède à une recherche binaire sur le tableau colorMapEntries, en recherchant la plus petite entrée présentant une valeur de population supérieure à la valeur fournie. Elle prend ensuite cette entrée et l'entrée précédente et les utilise pour interpoler la valeur de couleur requise. Cette opération s'effectue en appelant la méthode Interpolate instance qui est définie sur la classe ColorMapEntry.

Figure 14 Méthode d'extension Interpolate
<Extension()> _
Function Interpolate(ByVal entries() As ColorMapEntry, ByVal value As _
Double) As Color
Dim upperBound = entries.LeastUpperBound(value)
Dim c As Color = Nothing
If upperBound >= entries.Length Then
c = entries(upperBound - 1).FillColor()
ElseIf upperBound = 0 Then
c = entries(0).FillColor
Else
c = entries(upperBound - 1).Interpolate(entries( _
upperBound), value)
End If
Return c
End Function
<Extension()> _
Function LeastUpperBound(ByVal entries() As ColorMapEntry, ByVal v As _
Double) As Integer
Dim ret = Array.BinarySearch(entries, v, _
New ColorMapEntryToValueComparer)
If ret < 0 Then
ret = ret Xor -1
End If
Return ret
End Function
Réflexions finales
L'un des grands avantages de mon application est qu'elle a été facile à écrire. En particulier, j'ai beaucoup utilisé Expression Blend pour définir son interface, ce qui a permis d'ajouter des effets à fort impact visuel en un tournemain. J'ai également exploité le code XML pour enregistrer toutes les données indispensables à l'exécution de l'application. Ceci m'a permis d'exploiter les nouvelles fonctionnalités XML intégrées de Visual Basic, ainsi que LINQ, pour implémenter la logique de manipulation des données sans effort. J'ai également conçu l'interface pour être générée au dessus de l'infrastructure de liaison de données WPF, ce qui donne une application bien cloisonnée et avec une bonne architecture reposant sur modèle d'objet riche.
En résumé, grâce à Visual Basic, WPF, Expression Blend et LINQ, j'ai pu mettre sur pied de façon rapide et efficace une application qui offre des effets visuels relativement complexes à partir d'un corpus de données. Cette application pourrait facilement être étendue pour afficher les données de différentes années ou manipuler les données de diverses façons. Tout le code est disponible dans le téléchargement accompagnant cet article. N'hésitez pas à faire des essais pour voir par vous même ce qui est possible.
Scott Wisniewskiest ingénieur en conception logicielle chez Microsoft où il travaille sur le compilateur Visual Basic. Pour la prochaine version « Orcas » de Visual Studio, il a participé à l’élaboration de plusieurs fonctionnalités, y compris les types nullables, la correction d’erreurs et les méthodes d’extension. Vous pouvez envoyer un message électronique à Scott à l'adresse
scottwis@microsoft.com ou le contacter par l'intermédiaire du blog de l'équipe Visual Basic (
blogs.msdn.com/vbteam).