There may be very good reasons why you don’t want to cluster your points of interest on a map e.g. if you want to show the density of points in a certain area. An example for this is the Bing Maps World Tour which has been developed by our partner Earthware and which visualizes the highlights of the latest Bing Maps data updates.
The downside of this is though that the map looks cluttered and you can’t access the points at the bottom of the pile anymore. In addition the number of points that you render on a map also affects the performance. The performance aspect is certainly much more relevant for the AJAX control since the Silverlight performance in general is much better but after a few thousand points you might reach a level where the user experience suffers. The Bing Maps AJAX control has client-side clustering build into the API and in a previous post I have compared the performance of client- and server-side clustering for the AJAX control.
The Bing Maps Silverlight control does not provide build-in clustering and hence we will have a look mainly at the clustering but we will also:
In this example we will build a photomap. On low zoom-levels, i.e. when we look at the whole world, all the photos for let’s say Sardinia are represented by a single pushpin…
…but we still have access to all the photos that are represented by this pin:
On higher zoom-levels we break up the cluster and show accurate positions for the photos. In the screenshot below you also see, that we extended the build-in navigation control with some custom elements to show and hide the photo-layer, toggle the mini-map and toggle full-screen mode.
Note: You will have to enter your own Bing Maps Key in the MainPage.xaml and modify the connection string in the web.config
What we need
- A Bing Maps Key. You can generate your keys here.
- The Bing Maps Silverlight Control. The download is here; the full online reference is here; an interactive SDK is here.
Tip: When you right-click on the interactive SDK you can install an out-of-browser version of the application on your desktop
- I use the Visual Studio 2010 Beta 2 but the code is written in .NET 3.5 and will work with Visual Studio 2008 SP1 as well. If you use the Visual Studio 2008 you will need the SP1 and the Silverlight 3 Tools for Visual Studio 2008 SP1. The photos will show in a ChildWindow which is already part of the Visual Studio 2010 Beta 2 but if you use Visual Studio 2008 SP1 you will also need the Silverlight Toolkit.
- For the design of the Silverlight components and animations it is helpful to have Expression Blend 3.
- The meta-data for the photos are stored in a database. I use the SQL Server 2008 R2 November CTP but any SQL database will do.
- If you want to publish the application to Windows Azure you will also need
- the Windows Azure Tools for Microsoft Visual Studio (November 2009)
- A Windows Azure and a SQL Azure account. Follow the instructions here to get the invitation.
Before we actually start coding let’s hold on for a sec and think about the concept. The Bing Maps Silverlight control supports a variety of events. Since we don’t want to load all the photos but only those that are in the current map view we use these events to determine the bounding box and a couple of other parameters for our database query whenever we finish zooming or panning the map. At a first glance it might appear that the event TargetViewChanged might be appropriate but that would be a mistake since the target view changes with the zoom-level – so far so good – but also with every frame when you pan the map. That would create quite a lot of database queries and we certainly want to avoid that. More appropriate is the ViewChangeEnd event. We will add a handler for this event to the map and call a web service asynchronously. The web service will query our database, create a cluster of points and return a List-object that we can then process in our Silverlight application.
In order to cluster the photos (or points of interest) we will create in the web service a grid – let’s say with a width of 40 pixel – and determine the photos that are within each grid-cell. For each grid-cell that has one or more photo in it we will return a pin and tag it with some ID’s that identify the individual photos.
Once the Silverlight application receives the response we will process the data by adding pins to the map and dynamically creating child-elements for our photo-viewer. In this example we will do it by adding thumbnail images to a StackPanel in the ChildWindow. OK, let’s do it.
The Local Database
The database will be quite simple. We just have one table where me maintain the meta-data for our photos. The script for the table is listed below.
CREATE TABLE [Photos]( [id] [int] IDENTITY(1,1) NOT NULL, [Lat] [float] NULL, [Lon] [float] NULL, [Title] [varchar](250) NULL, [Name] [varchar](50) NULL, [Date] [date] NULL, [Loc1] [varchar](250) NULL, [Loc2] [varchar](250) NULL, [Loc3] [varchar](250) NULL, CONSTRAINT [PK_Photos] PRIMARY KEY CLUSTERED ( [id] ASC ) ON [PRIMARY] ) ON [PRIMARY]
We need columns for the latitudes and longitudes of the locations where the photo was taken, the name of the file (the part before the extension .jpg) and optionally some more information that describe the location.
The photos themselves are accessible through a web server and for starters we’re going to use our local Internet Information Service to create a virtual directory that points to the location of the photos.
The Web Service
Let’s first create the web service that accesses the database. In our Visual Studio we create a new Silverlight application:
And choose to host the application in a new website.
In the web-project we create a new Silverlight-enabled WCF Service. Let’s call it svcPhotos.
If you consider using Windows Azure you must be aware of a bug that really puzzled me for a while. Everything worked well when I had my stand-alone application but once I wanted to use it within a Windows Azure project – no matter if it as in the development fabric or in the live environment – I received a weird error message: “cannot be processed at the receiver, due to an AddressFilter mismatch at the EndpointDispatcher. Check that the sender and receiver’s EndpointAddresses agree.”. Since I was actually re-writing the endpoint in my code I was pretty sure that the address was correct and finally I found out that the error can be resolved by adding the following ServiceBehaviour:
In our service we create a DataContract that describes the list-items in the list-object we want to return as well as an OperationsContract that queries the database, clusters the points and returns the list-object. The OperationsContract will take as input-parameters
- latitudes and longitudes of the bounding box of the current map view
- The width and height of the map in the browser
- and the zoom-level
For the clustering we will also nee some helper functions that allow us to convert latitudes and longitudes into pixel coordinates. These functions are explained in more detail here. The full code of the web service is listed below:
Imports System.ServiceModel Imports System.ServiceModel.Activation Imports System.Data.SqlClient Imports System.Runtime.Serialization Imports System.Globalization <ServiceContract(Namespace:="svcPhotos")> <ServiceBehavior(AddressFilterMode:=AddressFilterMode.Any)> <AspNetCompatibilityRequirements(RequirementsMode:=AspNetCompatibilityRequirementsMode.Allowed)> Public Class svcPhotos 'Constants for the Clustering '(addressable area in Bing Maps) Private Const MinLatitude As Double = -85.05112878 Private Const MaxLatitude As Double = 85.05112878 Private Const MinLongitude As Double = -180 Private Const MaxLongitude As Double = 180 'Grid-Size Private Const GridSize As Integer = 40 'Clips a number to the specified minimum and maximum values Private Shared Function Clip(ByVal n As Double, ByVal minValue As Double, _
ByVal maxValue As Double) As Double Return Math.Min(Math.Max(n, minValue), maxValue) End Function 'Determine the offset off the map Public Shared Function Offset(ByVal lvl As Integer) As UInt32 Return 256 << lvl End Function 'Convert Latitude and Longitude to VEPixel Public Shared Sub LatLongToPixel(ByVal latitude As Double, ByVal longitude As Double, _
ByVal lvl As Integer, ByRef pixelX As Integer, ByRef pixelY As Integer) latitude = Clip(latitude, MinLatitude, MaxLatitude) longitude = Clip(longitude, MinLongitude, MaxLongitude) Dim x As Double = (longitude + 180) / 360 Dim sinLatitude As Double = Math.Sin(latitude * Math.PI / 180) Dim y As Double = 0.5 - Math.Log((1 + sinLatitude) / (1 - sinLatitude)) / (4 * Math.PI) Dim mapSize As UInt32 = Offset(lvl) pixelX = CType(Clip(x * mapSize + 0.5, 0, mapSize - 1), Integer) pixelY = CType(Clip(y * mapSize + 0.5, 0, mapSize - 1), Integer) End Sub <OperationContract()> _ Public Function GetClusterInView(ByVal NWlat As Double, ByVal NWlon As Double, _
ByVal SElat As Double, ByVal SELon As Double, ByVal lvl As Integer, _
ByVal mapWidth As Integer, ByVal mapHeight As Integer) As List(Of ClusterPoint) 'set culture to en-UK to avoid potential problems with decimal-separators System.Threading.Thread.CurrentThread.CurrentCulture = _
settings = ConfigurationManager.ConnectionStrings("TalkingDonkeyLocal") Dim myConn As New SqlConnection(settings.ConnectionString) myConn.Open() Dim myQuery As String = "SELECT Lat, Lon, Name, Loc1, Loc2, Loc3, Date “ _
+ ”FROM Photos WHERE (Lat BETWEEN " + SElat.ToString + " AND " _
+ NWlat.ToString + ") AND (Lon BETWEEN " + NWlon.ToString _
+ " AND " + SELon.ToString + ")" Dim myCMD As New SqlCommand(myQuery, myConn) Dim myReader As SqlDataReader = myCMD.ExecuteReader() While myReader.Read() 'Determine PixelX and PixelY of POI LatLongToPixel(myReader(0), myReader(1), lvl, poiTotalX, poiTotalY) poiMapX = poiTotalX - ulTotalX poiMapY = poiTotalY - ulTotalY 'Populate the array with clustered pins For x = 0 To numXCells - 1 If (x * GridSize <= poiMapX) And (poiMapX < (x + 1) * GridSize) Then For y = 0 To numYCells - 1 If (y * GridSize <= poiMapY) And (poiMapY < (y + 1) * GridSize) Then Dim myClusteredPin(4) As Object If gridCells(x * y) Is Nothing Then myClusteredPin(0) = 1 myClusteredPin(1) = myReader(0) myClusteredPin(2) = myReader(1) myClusteredPin(3) = myReader(2).ToString + "," _
+ myReader(3).ToString + "|" + myReader(4).ToString _
+ "|" + myReader(5).ToString + "|" _
+ CDate(myReader(6)).ToString("d", New CultureInfo("en-GB")) Else myClusteredPin = gridCells(x * y) myClusteredPin(0) = myClusteredPin(0) + 1 myClusteredPin(3) = myClusteredPin(3) + "," _
+ myReader(2).ToString + "," + myReader(3).ToString _
+ "|" + myReader(4).ToString + "|" + myReader(5).ToString + "|" _
+ CDate(myReader(6)).ToString("d", New CultureInfo("en-GB")) End If gridCells(x * y) = myClusteredPin End If Next End If Next End While myReader.Close() myConn.Close() 'Create the pins Dim myPins As New List(Of ClusterPoint) For i = 0 To numCells If gridCells(i) IsNot Nothing Then Dim myClusteredPin = gridCells(i) Dim myPin As New ClusterPoint(myClusteredPin(1), myClusteredPin(2), _
myClusteredPin(3)) myPins.Add(myPin) End If Next Return myPins End Function End Class <DataContract()> _ Public Class ClusterPoint <DataMember()> _ Private _Lat As Double Public Property Lat() As Double Get Return _Lat End Get Set(ByVal value As Double) _Lat = value End Set End Property <DataMember()> _ Private _Lon As Double Public Property Lon() As Double Get Return _Lon End Get Set(ByVal value As Double) _Lon = value End Set End Property <DataMember()> _ Private _Names As String Public Property Names() As String Get Return _Names End Get Set(ByVal value As String) _Names = value End Set End Property Public Sub New(ByVal _Lat As Double, ByVal _Lon As Double, ByVal _Names As String) Lat = _Lat Lon = _Lon Names = _Names End Sub End Class