Map LINQ
Create Dynamic Maps with Visual Basic 9.0 and WPF
Scott Wisniewski
Code download available at:MapsWithVB9WPF2007_12.exe(30813 KB)
This article is based on a prerelease version of Visual Studio. All information herein is subject to change.
This article discusses:
|
This article uses the following technologies: WPF, Visual Basic 9.0, LINQ |
Contents
WPF Data Binding
Application Object Model
Map Classes
Importing Map Data
Map Data Format
Importing the Data
Drawing the Map
Visualizing Population Data
Final Thoughts
I've always had a bit of a fascination with drawing maps. I also think that Visual Studio® 2008 and Visual Basic® 9.0 are amazing. As a result, when I got the opportunity to play around with Visual Studio and then write an article about it, I figured the perfect topic would be a tutorial on how to draw maps using Visual Basic. This would give me the chance to not only demonstrate some cool Visual Basic features, but also to give you a working sample you could use as a basis for adding similar functionality to your own programs.
The result of my work is shown in Figure 1, which displays a screenshot of an application that uses a heat map to visualize the population of the United States. The application was built by heavily leveraging the Windows® Presentation Foundation (WPF) data-binding infrastructure, which has granted me the ability to effectively separate my application's domain-specific logic from the user interface visualization.
Figure 1** Population Mapping Application **(Click the image for a larger view)
I'll start with a brief overview of some preliminary WPF data-binding topics in the next section, then I'll spend the rest of the article walking through how I wrote the application. I'll first describe the application's Visual Basic object model, and then I'll discuss how I was able to use the innovative new XML features in Visual Basic 9.0 and LINQ to implement my data-processing logic. I'll conclude by showing you how to use WPF to effectively visualize the application's data.
WPF Data Binding
The WPF data-binding infrastructure is based around the concepts of dependency objects and dependency properties. A dependency object provides support for change notification and the ability to dynamically fetch and retrieve property values. A dependency property is a property associated with a dependency object. Together, they form the core infrastructure behind many areas of WPF, including data binding, animation, and styles.
Dependency objects are represented by instances of types derived from the DependencyObject class. Similarly, dependency properties are represented by instances of DependencyProperty that have been registered with the WPF property system.
When used in an application's data model, dependency properties vastly simplify code. They allow many tasks to be implemented directly rather than requiring controls to be explicitly manipulated by the programmer. Through the WPF declarative data binding and styling mechanisms, changes can then be automatically propagated to the UI without the need for custom logic.
This makes the code underlying WPF user interfaces significantly easier to read, write, and maintain. It also promotes cleaner application architecture by encouraging better separation between individual layers. Finally, and most importantly, it lets you take advantage of tools such as Expression BlendTM to design user interfaces in a visual manner, concentrating on elements of style rather than on application logic.
For a simple example of using dependency properties, see Figure 2. There I show a portion of the definition of the MapRegion class. MapRegion inherits from DependencyObject. Its primary purpose is to represent a geographical region within a map. It declares two dependency properties, RegionName and IsSelected. The dependency properties are declared by calling the shared Register method on the DependencyProperty class, providing the name of the property, its type, and the type of the class containing it (in this case MapRegion). The returned dependency properties are then assigned to public, shared, read-only fields declared on the class. This is done mainly to conform to WPF conventions, which expose the metadata for dependency properties as shared fields on the classes that define them.
Figure 2 Snippet of the MapRegion Class
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
The dependency properties are then exposed to code via wrapper properties that invoke the GetValue and SetValue methods defined in the DependencyObject class, providing the appropriate DependencyProperty object as a parameter. This enables consumers of the MapRegion class to access dependency properties like any other normal instance property, while simultaneously taking advantage of the rich dependency property system in WPF.
It's worth mentioning that the actual property data is not stored directly as fields in the MapRegion class. Instead, WPF manages the storage for each dependency property attached to a dependency object, making them readily available through calls to SetValue and GetValue.
You should also note that a dependency property can be registered with a dependency object only once. Attempting to register two dependency properties with the same name on the same type will result in an exception.
In my example, I ensure that each dependency property is registered only once by declaring shared read-only fields with explicit initialization expressions that call DependencyProperty.Register. This causes the registration to be executed as part of the MapRegion class's static constructor, which the common language runtime (CLR) ensures will be run exactly once (before any code referencing the MapRegion class executes).
The XAML code shown in Figure 3 shows an example of how dependency properties can be used to automatically propagate changes from a domain model to an application's user interface. It defines a style that, when applied to a Polygon control, will cause the polygon to be rendered with a thick orange border when its associated MapRegion has its IsSelected property set to true, and with a thin black border otherwise. This enables the code driving the application to simply mark a region as selected and then have the UI respond accordingly.
Figure 3 Polygon Style Utilizing Data Triggers
<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>
Application Object Model
WPF data binding is awesome, so I set out to enable it in my application wherever possible. My goals were to be able to write clear, concise, and domain-specific code to drive the app, and to enable the use of Expression Blend to design its interface.
To accomplish this, I created an object model for representing map information that heavily utilizes the WPF dependency property system. It consists of three main classes—Maps, MapRegion, and MapPolygon—all derived from DependencyObject. The Map class is the root of the object model and is used to represent a map containing a list of regions. The MapRegion class represents a region or geographic entity on a map. It contains a list of polygons, with each polygon describing the boundaries of one of the contiguous geographic areas defining the region. The MapPolygon class represents a polygon within a region and contains a collection of Point objects describing its vertices.
The Map class has five dependency properties: BoundingBox, ScaleX, ScaleY, TranslateX, and TranslateY. The BoundingBox property is an instance of the Rect class and stores the smallest rectangular area that contains all the regions on the map. The ScaleX, ScaleY, TranslateX, and TranslateY properties are defined to support zooming and panning on the map. These properties are important because the coordinates of the points on the map represent offsets, in kilometers, from the intersection of the equator and the prime meridian. WPF, on the other hand, uses a different coordinate system, one that is defined in terms of offsets, in logical pixels (one logical pixel is equal to 1/96th of an inch), from the upper-left corner of a window.
Because of this, the ScaleX, ScaleY, TranslateX, and TranslateY properties are used to do the following:
- Scale the map so that it can fit within a window.
- Flip the map vertically so that it is not drawn upside down. (In the world view, Y coordinates increase as you travel north; in the WPF view, Y coordinates increase as you move toward the bottom of a window.)
- Move the map so that its upper-left corner corresponds to the upper-left corner of the control that displays the map.
You can also use those properties to implement zoom and pan functionality within the UI. For example, multiplying the ScaleX and ScaleY values by 1.5 causes the map to be zoomed in by 150 percent, and adding 50 to its TranslateX property causes the view of the map to be moved 50 kilometers to the west. It's worth pointing out that changing these values does not mutate the coordinates in the map. Instead, the values are used to inform the user interface of what it needs to do in order to display the map.
The Map class exposes its region collection via the Regions property. Unlike the other properties on the class, however, Regions is not a dependency property. Instead, it is a read-only property typed as an ObservableCollection(of MapRegion). It's not declared as a dependency property because I did not need the ability to assign an entire collection at a time. The collection itself is, however, mutable, and regions can be added and removed from the map using the Add and Remove methods of the ObservableCollection class. Also, because the collection is exposed as an ObservableCollection, it can still participate in the WPF data-binding system. For example, if a ListBox is data-bound to an ObservableCollection, then simply adding a new item to that collection will cause a new item to appear in the ListBox. Similarly, removing an item from the collection will cause it to be removed from the ListBox.
Map Classes
The MapRegion class defines two dependency properties: BoundingBox and RegionName. The BoundingBox property defines the rectangular area containing the region and RegionName stores the name of the region. The class also defines two collection properties: Polygons and FipsCodes. The Polygons collection stores the polygons that define the region and FipsCodes stores the set of numeric FIPS (Federal Information Processing Standard) codes associated with the region. The class uses FIPS codes because the polygon data I use in the application was downloaded from the United States Census Bureau, which uses FIPS codes to uniquely identify geographical entities within the United States. More information on the Federal Information Processing Standard can be downloaded from www.nist.gov/itl/fipscurrent.cfm. Also, technical information about the U.S. Census Bureau's public domain TIGER Geographic Database can be accessed at census.gov/geo/maps-data/data/tiger.html.
The MapPolygon class defines two properties. The first is a read-only dependency property named Region that stores a reference to the region containing the polygon. The second is a collection property named Points that stores the set of Point structures that define the vertices of the polygon. You can see the full source for the class in 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
The Region property is exposed as a read-only dependency property. This allows the WPF property system to detect changes to the property value but does not allow it to be modified by WPF. A read-only dependency property is declared by calling the RegisterReadOnly method of the DependencyProperty class. Unlike the Register method, RegisterReadOnly returns a DependencyPropertyKey instance instead of returning a DependencyProperty.
A dependency property key is an object that allows read-only dependency properties to be modified. They are useful for implementing classes that need to update property values internally and have those changes detected by other types but do not allow external modification of the value. As a result, DependencyPropertyKeys are generally not exposed from the class that defines them. That is why the RegionPropertyKey field is marked as private. A second shared field, RegionProperty, is then used to publicly expose a read-only alias of it.
The Region property is read-only because it was designed to not be externally editable. Instead, the MapRegion class will automatically set and clear the value as a polygon is added or removed from its region collection. It does this by handling the CollectionChangedEvent for the polygon collection and propagating the appropriate Region instance through to the affected polygons.
The implementation of the event handler is shown in Figure 5. It works by calling the SetRegion extension method on the NewItems and OldItems collections stored in the handler's NotifyCollectionChangedEventArgs parameter. The extension method then calls the MapPolygon class's SetRegion method on each item in the target collection. The instance method is marked as friend to discourage it from being called. The extension method implementation looks like this:
Figure 5 MapRegion.PolygonCollectionChanged Method
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
Unlike the collection properties on the Map and MapRegion classes, the Points collection on the MapPolygon class is not an ObservableCollection(of T). Instead it uses the built-in WPF PointsCollection class. This is the same type that the WPF Polygon control uses to store the set of points it contains. By using this class in the data model, it is then possible to data bind a Polygon control directly to a MapPolygon instance.
Importing Map Data
The application loads its map data from XML files stored on disk. I created these files by downloading raw ASCII cartographic boundary files from the U.S. Census Bureau, which describe the boundaries of every state and county (and equivalent areas) within the United States. The state boundary data is used to draw a map of the United States and the county boundary data is used to visualize population data on the map. I then took the downloaded data and converted it into XML files for easier processing. The code for converting the files into XML is available in this article's download. The raw data files can be downloaded at https://www.census.gov/geo/maps-data/data/tiger.html.
Using the Visual Studio Create Schema feature, I then automatically generated an XML schema that described the contents of the XML files. This enabled me to then use the new Visual Basic XML IntelliSense® feature, which shows XML member access expressions based on any schema files included in a project (see Figure 6).
Figure 6** Visual Basic XML IntelliSense **(Click the image for a larger view)
Map Data Format
The schema describing the XML map files defines a simple format consisting of a root File tag that in turn contains a sequence of Region tags. Each Region tag contains a series of FipsCode and Polygon tags, with each Polygon tag containing a sequence of Vertex and Island tags.
A Region tag is used to describe a single region. It defines two attributes, both of which are required: Type and Name. The Type attribute provides a description of the type of the region. For the purposes of my application, I always use the values State or County. In general, however, any value can be used for the Type attribute. The Name attribute describes the region's name.
Each FipsCode tag defined in a region describes one of its FIPS code entries. The last FipsCode tag within a Region defines the region's numeric ID. The other FipsCode tags recursively describe the numeric IDs of the region's parents.
For the purposes of my application, all regions will have at most two associated FipsCode values. In particular, regions describing states (or state equivalent areas) will always have one entry that describes the FIPS code associated with that state. Regions describing counties (or county equivalent areas) will always have two entries, with the first entry denoting the FIPS code of the state that contains the county and the second denoting the FIPS code of the county itself. The numeric ID of any particular region is always guaranteed to be unique within the scope of its immediate parent.
Each of a region's Polygon tags describes a single polygon contained within it. The polygon is composed of a series of Vertex tags, with each tag describing a single vertex. The Vertex tag defines five attributes, all of which are required: Ordinal, Longitude, Latitude, X, and Y. The Ordinal attribute defines an ordering for a polygon's vertices. Its primary purpose is to make it possible to correctly order vertices should they be processed by queries that do not preserve ordering. It is currently not used by my application. The Longitude and Latitude attributes describe the longitudinal and latitudinal coordinates of the vertex. Similarly, the X and Y attributes define the coordinates of the vertex when projected into rectangular space and are what the application uses for drawing maps.
The X and Y attributes were computed using the simple projection shown in Figure 7. This projection is not a particularly accurate one and may not be suitable for some scenarios. In those cases, it is possible to calculate new values directly using Longitude and Latitude rather than using the X and Y values stored in the file. For my application, which does not require a high degree of geographical precision, this was sufficient.
Figure 7** An Incredibly Simple Map Projection **
An Island tag within a polygon defines a hole enclosed within it that is not part of its containing region. It contains a collection of Vertex tags that describe the boundaries of the hole. I've included them in the data files primarily for completeness; they are not used by my application. This does mean that there may be a few very small areas of the map that show slightly incorrect values when data is visualized on it. For the purposes of my application, this was not an issue, and so I simply ignored it.
If you have an application where accuracy in this regard is important, or are mapping regions where polygon islands are prevalent, you easily can solve the problem by controlling the order in which the map is rendered. By first drawing the polygon containing an island and then drawing the island on top of it, it is possible to accurately render polygon islands. In many cases this may also require extra processing to determine areas of overlap and construct the appropriate ordering.
Importing the Data
Using LINQ, importing map data into the application is relatively easy. The code that does this is shown in Figure 8. It defines a procedure, LoadFile, that takes two parameters: file and list. The file parameter is a string that contains the full path of the XML file to load from disk. The list parameter is a reference to a collection of MapRegion instances. The procedure then opens the XML file, processes it, and inserts all of the regions it defines into the provided collection.
Figure 8 Loading MapRegion Instances from an XML File
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
The body of LoadFile is extremely simple. The procedure works by first loading the provided file into an XDocument instance, defining a query to traverse the document and convert it into a collection of MapRegion instances, and then finally executing the query and inserting its results into the provided output collection.
The query is composed of several different pieces. The first piece is its From clause, which uses the XML Member access expression doc.<File>.<Region>.<Polygon>.<Vertex> to retrieve all polygon Vertex tags in the document as the basis for the query. It's worth noting that this does not use doc...<Vertex>, which would grab all Vertex tags in the document, including those defined inside of Island tags. Instead, it only includes Vertex tags that are directly defined inside Polygon tags. The second part of the query is the Let clause. This clause introduces two new variables—Polygon and Region—which are assigned to the Polygon and Region tags that contain each vertex.
The third part of the query is its first Group By clause. This simply groups the list of vertices by their containing polygon and then calculates the polygon's bounding box. The region containing the polygon is also included in the grouping key, thus enabling it to be used later in the query. The polygon's bounding box is computed by calculating the minimum and maximum values of its vertex's X and Y attributes. This is done by calling the custom aggregate functions MinDBL and MaxDBL, which are defined by two extension methods:
<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
These simply define aggregate functions that calculate minimum or maximum values on their provided arguments after first converting it to type Double.
The Group By clause is followed by a Select clause that converts the raw group by results into a more usable form. In particular, it defines a subquery that takes the group resulting from the Group By, projects out just the vertex from it, and then converts the resulting collection into an instance of the MapPolygon class using the ToMapPolygon extension method:
<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
It also converts the raw MinX, MinY, MaxX, and MaxY variables into an instance of the Rect class.
The next part of the query is its second Group By clause, which aggregates the results of the preceding select by Region and calculates the bounding box containing all the region's polygons. The bounding box is calculated using the Enclose custom aggregate function, whose definition is shown in Figure 9. The last part of the query is the final Select clause, which constructs a MapRegion instance for each result returned from the second Group By. It does this using two subqueries, one of which projects out the polygon instance stored in every member of Group, and the other of which extracts the set of FIPS codes stored in Region.
Figure 9 Enclose Aggregate Function
<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
Drawing the Map
Once a map has been loaded into memory, displaying it in a window is trivial. The XAML code in Figure 10 shows an easy way to do this. Here I define a simple Canvas control containing two transformations for its Render Transform, and a single ItemsControl named MapViewer as its content. The transformations are used to translate the coordinates of the regions in the map so that they will correctly display within the canvas. The ItemsControl is used to visually render the map's contents. All data binding is done using implicit data contexts. In the case of the MasterCanvas control, this will always be set to an instance of the Map class.
Figure 10 XAML Code for Displaying a Map
<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 binds its TranslateX and TranslateY properties to the equivalent properties on the Map class. Typically, these values are set on the map class to be the negated value of the upper left-hand corner of the Map's bounding box. This has the effect of moving the map's contents so that its upper left-hand corner corresponds with the upper left-hand corner of the canvas. Similarly, the ScaleTransform binds its ScaleX and ScaleY properties to the associated properties on the Map class. In most cases, they are set to the ratio of the canvas's width to the map's width and the negated ratio of the canvas's height to the map's height.
The following code shows the implementation of the ScaleTo method in the Map class:
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
When given a Size structure that contains the height and width of MasterCanvas, the method will transform the map so that it is displayed entirely within the canvas. This method is called in response to the canvas's SizeChanged event, thereby causing the map to scale to fill available space when the canvas's containing window is resized:
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
It's worth noting that the ordering of the transformations within the containing TransformGroup is important. In particular, the TranslateTransformation must happen before the ScaleTransform. This allows arguments to both transformations to be specified in terms of the domain model, rather than in terms of the UI. If the ScaleTransform was listed first, then the values of the TranslateTransform would need to be specified in pixels rather than in kilometers, which would require an explicit conversion. Specifying the TranslateTransform first greatly simplifies the application's object model.
The ItemsControl is used to render the contents of the map onto the canvas. It behaves similarly to the ListBox control. In fact, ItemsControl is the base class for ListBox and defines most of its data binding behavior. Unlike the ListBox, however, the ItemsControl does not render items as list. This makes it much better suited for rendering maps, which clearly should not be displayed as a list.
ItemsControl binds its ItemsSource property to the map's Regions collection and utilizes the SimpleCanvasTemplate and RegionTemplate resources to define how everything is rendered. The SimpleCanvasTemplate, used for the ItemsControl's ItemsPanel property, simply creates an empty Canvas. Its purpose is to define the container used to host each of the items created by the control's ItemTemplate. A canvas is used primarily because canvases allow their contents to be explicitly positioned. Other "container controls," such as a StackPanel, would attempt to apply automatic layout logic that ultimately would draw the map incorrectly.
The RegionTemplate resource defines the visual layout used for each Region in the map. It does this by simply declaring a nested ItemsControl for each region and binding its ItemSource to the region's Polygon collection. Like MapViewer, the nested ItemsControl defined in RegionTemplate uses a canvas for its ItemsPanel (in fact it reuses SimpleCanvasTemplate). Unlike MapViewer, however, it uses the PolygonTemplate resource to render its items.
The PolygonTemplate resource defines a data template that renders a WPF polygon control given an instance of the MapPolygon class. It binds each Polygon's Points collection to the Points property on the MapPolygon instance. It's worth mentioning that the Polygon control has its Fill property explicitly set to Transparent. This is necessary to enable the Polygon to respond to many mouse events. WPF differentiates between polygons that have a Fill property set and those that do not. A polygon with a missing Fill brush is considered to be a shell polygon, while a polygon with an explicitly set Fill brush is considered to be a filled polygon. Mouse events such as MouseUp, MouseEnter, and MouseLeave for a filled polygon will fire when the mouse is inside the polygon's interior. With shell polygons, the inside of the shape is not considered part of its definition. In that case, placing the mouse inside the polygon will not cause any of its mouse events to fire. By setting a polygon's fill to transparent, however, it becomes possible to enable interaction with the mouse without obscuring other data drawn on the map.
Another interesting aspect of the polygon template is that it uses declarative, trigger-based styles to drive the appearance of the polygon. In particular, it references the RegionPolygonStyle resource that defines two distinct styles for rendering polygons. Those polygons whose regions have IsSelected values of true are rendered with a thick orange border, and those whose IsSelected values are false are rendered with a regular, thin black border. This event-handling code shows how such styles can be easily used to implement cool UI features like input tracking:
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
Figure 11 shows an example of this in action.
Figure 11** Using a Mouse Event Trigger for Highlighting a Region **(Click the image for a larger view)
Visualizing Population Data
Visualizing population data on top of the map is also simple. XAML code that does this is shown in Figure 12. It modifies the XAML used to draw the map by adding a second ItemsControl named DataLayer to MasterCanvas. Because DataLayer is located inside MasterCanvas, its contents will be transformed in the same way as MapViewer's. This allows the data bound to DataLayer to also be specified in terms of the underlying domain model rather than in terms of the user interface.
Figure 12 Modified XAML for Displaying a Heat-Map
<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>
Unlike MapViewer, DataLayer is not intended to inherit its data context directly from MasterCanvas. Instead, it is designed to have the data to be visualized set explicitly as its ItemsSource. The data provided should be a collection of objects defining two properties: Color and Points. The Color property should be of type Brush and defines how the polygon described by Points should be colored on the map. The Points property should be an instance of PointsCollection that defines the vertices of the polygon to be colored. This is the DataTemplate used to render DataLayer's elements:
<DataTemplate x:Key="DataLayerTemplate"> <Polygon Stroke="Transparent" Fill="{Binding Path=Color}" Points="{Binding Path=Points}" /> </DataTemplate>
The way it works is by simply creating a polygon and binding its Fill and Points properties to the appropriate properties on the underlying element.
The LoadData method shown in Figure 13 is responsible for loading the primary map data into an instance of the Map class, setting it up as the DataContext for MasterCanvas, constructing the heat map, and then binding DataLayer to it.
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
The portion of the method that is particularly interesting is the query, which is used to build the heat map. It works by joining the set of states displayed on the map, the set of counties loaded from disk, and XML population data. It joins the set of states with the set of counties in order to filter out counties that are defined in states that are not shown on the map. The key for the join, on both sides, is each region's first FIPS code. In the case of a State, this will correspond to its numeric ID. In the case of a County, it corresponds to the counties containing a state's numeric ID. The XML population data is then joined with results in order to associate each county's population value with the MapRegion instance defining the county.
The results of the join are then filtered to include population data only for the year 2006. This is necessary because the XML population data contains data for multiple years, and in this case I am only interested in showing one particular year on the map. Failing to filter based on year will also result in multiple overlapping polygons with different values being drawn on the map for the same region. The filtered results are cross-joined with the Polygons collection for each county. This effectively expands a collection of county regions into a collection of polygons.
The final portion of the query is a Select statement that extracts the point collection from each polygon, calculates the color it should be displayed in based on its associated population value, and then constructs a SolidColorBrush that will render the region in that color. The color values are calculated using the colorMapEntries array, which defines a mapping between population values and colors. Essentially, each entry in the array defines a color and an associated upper-bound population value. The particular color used for a county is then interpolated from the two color map entries between which its population sits.
The interpolation is implemented by the Interpolate extension method defined in Figure 14. It works by doing a binary search through the colorMapEntries array, looking for the smallest entry that has a population value greater than the provided value. It then takes that entry and the preceding entry and uses them to interpolate the appropriate color value. The way this is done is by calling the Interpolate instance method that is defined on the ColorMapEntry class.
Figure 14 Interpolate Extension Method
<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
Final Thoughts
The great thing about my app is that it was easy to write. In particular, I made extensive use of Expression Blend to define its interface, thus making it a breeze to add stunning visual effects. I also used XML to store all the data the application uses to run. This enabled me to use the new integrated XML features in Visual Basic, along with LINQ, to painlessly implement all of the data manipulation logic. I also designed the interface to be built on top of the WPF data-binding infrastructure, thereby creating a cleanly separated, well-architected application that was built on top of a rich object model.
Essentially, by using Visual Basic, WPF, Expression Blend, and LINQ, I was able to quickly and effectively throw together an application that makes relatively sophisticated visualizations from an existing body of data. This application could be easily extended to view data from different years, or manipulate the data in various ways. All of the code is available in the download for this article, so feel free to experiment and see what is possible.
Scott Wisniewski is a Software Design Engineer at Microsoft, where he works on the Visual Basic compiler. For the upcoming Visual Studio "Orcas" release, he worked on several features, including nullable types, error correction, and extension methods. You can e-mail Scott at scottwis@microsoft.com or contact him through the Visual Basic Team blog (blogs.msdn.com/vbteam).