Introduction
This is the second and conclusive article about an example of a Bing Maps extension using Silverlight. Let me briefly recall the objective: in the first article I wrote about the need which may arise when planning an itinerary, I underlined that knowing the elevation profile would be useful. Having this functionality using the Maps Silverlight Control is not difficult. In the following sections we will see how to get elevation data as well as to plot them on a graph. You can enjoy a demo here and download the code here.
Where to get the elevation data
There is a series of web services for finding geography-related information around the world provided by some associations and simple websites for free. A valuable source of geographical web services is the GeoNames geographical database (http://www.geonames.org/ ). It offers a series of REST web services to get the elevation data from different measurement models. As you can see in the image below within the application you can choose between 4 different Elevation Digital Models.
.
The ASTER Global Digital Elevation Model (GDEM), whose details you can find here, is done with a sample area of c.a 30m x 30 m and gives the elevation in meters. The SRTM3 model is based on the Shuttle Radar Topography Mission and considers a sample area of ca 90m x 90m. The GTOPO30 is a Global Digital Elevation Model with a sample area of c.a. 1 km x 1km made by the Earth Resources Observation and Science (EROS) Center. The SRTM USG04 option is provided by the http://www.earthtools.org/ website and it is based essentially on the SRTM model described above, integrated and improved using other data sources like the National Elevation Dataset (NED), the Canadian Digital Elevation Data (CDED), and the work of single experts.
Among the others we can also mention the U.S. Geological survey that offers a complete Elevation Query Web Service at the following link:
http://gisdata.usgs.net/XMLWebServices2/Elevation_Service.php
Calling the web services: things get hard
With the contents of the first article and the paragraph above, now we have all the elements to complete the application. In sum, either by choosing the “Route Profile” option or the “Arbitrary Profile” button you obtain a collection of geographic points (i.e. points with latitude and longitude) and we want to know their elevation. This can be accomplished by using one of the web services listed above, and, as you know, Silverlight uses an asynchronous model for all network related calls. On one hand this guarantees that the UI thread will not be blocked by a web request, on the other hand this makes things more difficult when performing a number of requests (one for each geographical point essentially). You could cycle between all the geographical points and each time fire a web request. Then in the Callback you have to link the returned elevation data to the respective geographical point and above all you must be able to understand when all the web requests have been completed before launching the creation of the graph. If you hook an event to each web request, you can set that event at the end of the Callback in order to get a notification, but you cannot obviously wait for that event on the UI thread. So using threads seems to be the solution.
Calling the web services: using threads
For a good developer the first rule of using threads should be “avoid them if you can”. Indeed, threads can be problematic, tricky and tend to make applications harder to debug. However, I was not able to solve the problems listed in the previous paragraph without using threads. The image below explains the method I used.
First of all, I created a background thread and I passed to the thread procedure an object containing the collection of geographical points and the GDEM to use. In the thread procedure a number of AutoReset Events equalling the number of points is created. Then, always in the thread procedure, a threadPool launches a number of WebClient calls passing each of them an object containing a point , the GDEM to use and an Event. When each request is executed, the elevation data is saved into the object passed and the relevant Event is set. In the meantime the main thread procedure is stopped by a WaitAll(). After the completion of all the requests the main thread procedure updates the collection of points with the data received and invokes the BuildGraph() method of the UI thread to create the graph.
As you can see in the procedure of the main thread listed in the portion of code below, the requests are splitted in chunks of 64 because the WaitAll() function does not support more than 64 WaitHandles.
public void MainThreadFunc(object objData)
{
mainThreadData mthData = (mainThreadData)objData;
ThreadManager.dataProfileArray = mthData.dataProfileArray;
int itemsNum = ThreadManager.dataProfileArray.Length;
ThreadManager.eventsArray = new AutoResetEvent[itemsNum];
ThreadManager.ThreadObjectInfo[] thInfoArray = new ThreadManager.ThreadObjectInfo[itemsNum];
for (int i = 0; i < itemsNum; i++)
{
thInfoArray[i] = new ThreadManager.ThreadObjectInfo(i,
new AutoResetEvent(false),
ThreadManager.dataProfileArray[i],
mthData.serviceType
);
}
// for each waypoint we connect to the server in order to obtain the elevation
// if the num. of waypoints is > 64, then we need to split the requests into chunk of 64
// because the WaitAll function does not support more than 64 WaitHandles
try
{
int startindex = 0;
int endindex = 64;
int chunkWsRequestNum = (int)Math.Ceiling(((double)itemsNum / 64.00));
if (itemsNum < 64)
endindex = itemsNum;
AutoResetEvent[] tempArray = new AutoResetEvent[endindex];
for (int chunk = 0; chunk < chunkWsRequestNum; chunk++)
{
for (int i = startindex; i < endindex; i++)
{
tempArray[i - 64 * chunk] = thInfoArray[i].RequestEvent;
ThreadPool.QueueUserWorkItem(new WaitCallback(ThreadManager.DoElevationRequests), thInfoArray[i]);
}
// the main thread sets a timeout of 40 sec for the getting of elevation data
AutoResetEvent.WaitAll(tempArray, 40000);
startindex += 64;
endindex += 64;
if (endindex > itemsNum)
endindex = itemsNum;
}
lock (theLock)
{
// after all threads ended, we can fill the dataProfileArray with elevation data
for (int i = 0; i < itemsNum; i++)
{
dataProfileCollection[i].Elevation = Convert.ToDouble(thInfoArray[i].LocationToSearch.Elevation, CultureInfo.InvariantCulture);
}
}
}
catch (Exception ex)
{
throw new ArgumentException(ex.Message);
}
Dispatcher.BeginInvoke(delegate()
{
buildGraph();
ElevationIsInProgress = false;
});
}
Since each of the web services I inserted as an option have slightly different modalities of calling and contents of response, I used this simple interface:
public interface IElevationServiceUsage
{
Uri GetUri(string lat, string lng);
double GetElevation(string result);
}
which exposes two methods; the GetUri() method builds the url with parameters to make the call. The GetElevation() method parses the content of the response. Below, the class for the request of SRTM data.
public class SRTM3WSUsage : IElevationServiceUsage
{
private const string webServiceUrl = "http://ws.geonames.org/srtm3XML?";
public Uri GetUri(string lat, string lng)
{
return new Uri(webServiceUrl + "lat=" + lat + "&lng=" + lng);
}
public double GetElevation(string result)
{
// parse data
XDocument doc = XDocument.Parse(result);
var elevation = from value in doc.Descendants("srtm3")
select value;
return Convert.ToDouble(elevation.First().Value, CultureInfo.InvariantCulture);
}
}
Creation of the graph
The graph used is a line series graph and displays the elevation profile putting the distance in X and the elevation in Y. The source of data is an ObservableCollection of GeoLocation data where the GeoLocation class is shown below:
public class GeoLocation : Pair
{
private object _latitude;
private object _longitude;
/// <summary>
/// Gets or sets the Latitude value.
/// </summary>
public object Latitude
{
get
{
return _latitude;
}
set
{
_latitude = value;
}
}
/// <summary>
/// Gets or sets the Longitude value.
/// </summary>
public object Longitude
{
get
{
return _longitude;
}
set
{
_longitude = value;
}
}
/// <summary>
/// Gets or sets the Distance value from preceding location.
/// </summary>
public object Distance
{
get
{
return First;
}
set
{
First = value;
}
}
/// <summary>
/// Gets or sets the elevation at given lat/long
/// </summary>
public object Elevation
{
get
{
return Second;
}
set
{
Second = value;
}
}
}
The GeoLocation class is derived from the Pair class which is defined below:
public class Pair : INotifyPropertyChanged, IComparable
{
private object _first;
private object _second;
private static PairSortMethod _sortOrder;
/// <summary>
/// Gets or sets the first value.
/// </summary>
public object First
{
get
{
return _first;
}
set
{
_first = value;
OnPropertyChanged("First");
}
}
/// <summary>
/// Gets or sets the second value.
/// </summary>
public object Second
{
get
{
return _second;
}
set
{
_second = value;
OnPropertyChanged("Second");
}
}
/// <summary>
/// Implements the INotifyPropertyChanged interface.
/// </summary>
public event PropertyChangedEventHandler PropertyChanged;
/// <summary>
/// Fires the PropertyChanged event.
/// </summary>
/// <param name="propertyName">Name of the property that changed.</param>
private void OnPropertyChanged(string propertyName)
{
PropertyChangedEventHandler handler = PropertyChanged;
if (null != handler)
{
handler.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
public static PairSortMethod SortOrder
{
get { return _sortOrder; }
set { _sortOrder = value; }
}
int IComparable.CompareTo(object obj)
{
if (obj is Pair)
{
Pair p2 = (Pair)obj;
return ((Double)First).CompareTo(p2.First) + ComparerHelper.PairComparison(Second, p2.Second);
}
else
throw new ArgumentException("Object is not a Pair.");
}
}
The creation of the graph preliminarily implies the creation of the linear Axes X and Y and the creation of a LineSeries object to which the datasource is assigned. In order to correctly dimension the graph, you have to set the max and min value along the X and Y axes. This can be accomplished using the AsQueryable() operator as you may see in the code snippet below:
public void BuildGraph(ObservableCollection<GeoLocation> newPairCollection, string dependentVariable, string independentVariable)
{
ECChartBusyIndicator.IsBusy = true;
object _first = double.NaN;
object _second = double.NaN;
double firstMin = (double) (from firstSerie in newPairCollection.AsQueryable()
where (firstSerie.First != null)
select firstSerie.First).Min();
double firstMax = (double)(from firstSerie in newPairCollection.AsQueryable()
where (firstSerie.First != null)
select firstSerie.First).Max();
double secondMin = (double)(from SecondSerie in newPairCollection.AsQueryable()
where (SecondSerie.Second != null)
select SecondSerie.Second).Min();
double secondMax = (double)(from SecondSerie in newPairCollection.AsQueryable()
where (SecondSerie.Second != null)
select SecondSerie.Second).Max();
// Add Axes
if(ECChartElement.Axes != null)
ECChartElement.Axes.Clear();
rangeX = Math.Abs(firstMax - firstMin);
rangeY = Math.Abs(secondMax - secondMin);
IAxis myAxisX = new LinearAxis
{
Orientation = AxisOrientation.X,
Minimum = firstMin - rangeX / 10,
Maximum = firstMax + rangeX / 10,
ShowGridLines = true
};
ECChartElement.Axes.Add(myAxisX);
IAxis myAxisY = new LinearAxis
{
Orientation = AxisOrientation.Y,
Minimum = secondMin - rangeY / 10,
Maximum = secondMax + rangeY / 10,
ShowGridLines = true
};
ECChartElement.Axes.Add(myAxisY);
LineSeries mySerie = new LineSeries();
mySerie.Name = dependentVariable + independentVariable;
mySerie.Title = "";
mySerie.ItemsSource = newPairCollection;
mySerie.IndependentValueBinding = new Binding(independentVariable);
mySerie.DependentValueBinding = new Binding(dependentVariable);
ECChartElement.Series.Clear();
ECChartElement.Series.Add(mySerie);
ECChartElement.Title = "Elevation profile";
ECChartElement.LegendTitle = "";
ECChartElement.DataContext = newPairCollection;
mySerie.Loaded += new RoutedEventHandler(mainSerie_Loaded);
}
Selecting portions of the graph
The graph offers some kind of interactivity by means of two markers that can be moved over the points. The area between them is highlighted in green and some statistical data related to the area are shown in a small box on the left top corner of the graph. To create the highlighted effect of the area selected I used a Polygon primitive ( ECAreaSelectedElement in the code snippet below).
private void DrawAreaSelected()
{
ECAreaSelectedElement.Points.Clear();
LineSeries mainLS = (LineSeries)ECChartElement.Series[0];
if (mainLS == null)
return;
double firstMarkerY = (double)ECFirstDelimiterElement.GetValue(Canvas.TopProperty) + ECFirstDelimiterElement.Height / 2;
double firstMarkerX = (double)ECFirstDelimiterElement.GetValue(Canvas.LeftProperty) + ECFirstDelimiterElement.Width / 2;
double secondMarkerY = (double)ECSecondDelimiterElement.GetValue(Canvas.TopProperty) + ECSecondDelimiterElement.Height / 2;
double secondMarkerX = (double)ECSecondDelimiterElement.GetValue(Canvas.LeftProperty) + ECSecondDelimiterElement.Width / 2;
double stepX = ECFirstDelimiterElement.Height / 2;
double stepY = ECFirstDelimiterElement.Width / 2;
List<Point> avgPoints = new List<Point>();
int objCounter = 0;
foreach (Point lspt in mainLS.Points)
{
Point asPt = FromLineSeriesToCanvasCoord(lspt);
Point asPtbase = new Point(asPt.X, originOfLSInCanvasSpace.Y + pixelExtentY);
if ((Math.Abs(asPt.X - firstMarkerX) <= (stepX)))
{
ECAreaSelectedElement.Points.Add(asPtbase);
ECAreaSelectedElement.Points.Add(asPt);
avgPoints.Add(GetPointValuesTroughReflection(objCounter));
}
if ((asPt.X - firstMarkerX) > stepX && (asPt.X - secondMarkerX) < -stepX)
{
ECAreaSelectedElement.Points.Add(asPt);
avgPoints.Add(GetPointValuesTroughReflection(objCounter));
}
if ((Math.Abs(asPt.X - secondMarkerX) <= (stepX)))
{
ECAreaSelectedElement.Points.Add(asPt);
ECAreaSelectedElement.Points.Add(asPtbase);
avgPoints.Add(GetPointValuesTroughReflection(objCounter));
}
objCounter += 1;
}
ECAreaSelectedElement.Visibility = Visibility.Visible;
double avg = (double)(from P in avgPoints.AsQueryable()
select P.Y).Average();
double distance = avgPoints[avgPoints.Count - 1].X - avgPoints[0].X;
double drop = avgPoints[avgPoints.Count - 1].Y - avgPoints[0].Y;
double slope = drop / (distance * 1000.0) * 100.0;
ECAvgValueElement.Text = "Avg elevation = " + avg.ToString("F2") + " m\n";
ECAvgValueElement.Text += "Tot distance = " + distance.ToString("F2") + " km\n";
ECAvgValueElement.Text += "Avg drop = " + drop.ToString("F2") + " m\n";
ECAvgValueElement.Text += "Avg slope = " + slope.ToString("F2") + " %";
ECAvgBorderElement.SetValue(Canvas.TopProperty, 6.0);
ECAvgBorderElement.SetValue(Canvas.LeftProperty, 2.0);
}
The basic concept is really simple; the X and Y coordinates of the points of the LineSeries are compared with those of the markers, each point included in the range of the markers is added to the Polygon. To make the polygon appear as an area, two other points are added: one at the beginning on the left and the other at the end on the right side, both of them on the X Axe and with an Y value equal to, respectively, the first and last point.
Summary
In this second part we have carried on the description of the application anticipated in the first article where we spoke of extending the Bing Maps with an elevation profile. In particular, we have given detailed indications about the main free resources for getting elevation data, then we have described a possible solution via threads to get the elevation data allowing the user to interact with the application in the meantime. Finally we briefly described how the graph is created and how the functionality of selection was added. The improvements that can be done are adding right mouse inputs handling and allowing the zooming on the chart as I had already done in my previous article “An excel file viewer in silverlight”.