WP7 Code: Distance Computations with the GeoLocation API
In my previous post I showed the most interesting code fragments for a location-aware Windows Phone 7 application. The code generates an event stream corresponding to location readings from the phone’s location subsystem. However, there are many applications that instead of lat/long readings need to compute the traveled distance (for example, when driving, biking, running, or hiking). This post shows how to convert the position readings from the Windows Phone location subsystem into distance measurements.
To begin with remember that motion on earth’s surface occurs on a(n approximate) sphere rather than on a plane. Consequently Euclidean geometry no longer does it; instead, the Haversine formula provides the distance between 2 locations. As implementing Haversine in C# has nothing to do with the phone I will reuse some code surfaced by a Web search. The implementation’s use of C# extension methods is in line with what I used to bridge between .NET events and RxLINQ event streams. In addition, they make the code read like English, which is pretty neat. The C# Haversine code follows, with the argument types updated to match those from the WP7 GeoLocation API:
public enum DistanceIn { Miles, Kilometers };
public static class Haversine
{public static double Between(this DistanceIn @in, GeoPosition<GeoCoordinate> here, GeoPosition<GeoCoordinate> there)
{
var r = (@in == DistanceIn.Miles) ? 3960 : 6371;
var dLat = (there.Location.Latitude - here.Location.Latitude).ToRadian();
var dLon = (there.Location.Longitude - here.Location.Longitude).ToRadian();
var a = Math.Sin(dLat / 2) * Math.Sin(dLat / 2) +
Math.Cos(here.Location.Latitude.ToRadian()) * Math.Cos(there.Location.Latitude.ToRadian()) *
Math.Sin(dLon / 2) * Math.Sin(dLon / 2);
var c = 2 * Math.Asin(Math.Min(1, Math.Sqrt(a)));
var d = r * c;
return d;
}private static double ToRadian(this double val)
{
return (Math.PI / 180) * val;
}
}
Start with the application from my previous blog post and change the XAML (MainPage.xaml) to include a text block and a button in the content panel (new code in green):
<Grid x:Name="ContentPanel" Grid.Row="1" Margin="12,0,12,0">
<TextBlock Height="30" HorizontalAlignment="Left" Margin="36,69,0,0" Name="textBlock1" Text="(no reading)" VerticalAlignment="Top" Width="392" />
<Button Content="Start" Height="84" HorizontalAlignment="Left" Margin="121,493,0,0" Name="button1" VerticalAlignment="Top" Width="227" />
</Grid>
Next wire the button such that taps (i.e., Click events) start or stop the GeoLocationWatcher. As clicks are asynchronous events RxLINQ provides an elegant solution to deal with them. First add to the Helpers class an extension method that brings Click events into the realm of Rx:
public static IObservable<RoutedEventArgs> GetClickEventStream(this Button button)
{
return Observable.Create<RoutedEventArgs>(observable =>
{
RoutedEventHandler handler = (s, e) =>
{
observable.OnNext(e);
};
button.Click += handler;
return () => { button.Click -= handler; };
});
}
Next remove the gcw.Start() from OnNavigatedTo override and add an RxLINQ query and subscriber to the button click event stream (new code in green):
protected override void OnNavigatedTo(System.Windows.Navigation.NavigationEventArgs e)
{
base.OnNavigatedTo(e);if (gcw == null)
gcw = new GeoCoordinateWatcher();ShowGeoLocation();
}// snip
public void ShowGeoLocation()
{
var statusChanges = from statusChanged in gcw.GetStatusChangedEventStream()
where statusChanged.Status == GeoPositionStatus.Ready
select statusChanged;button1.GetClickEventStream().Scan( false
, (isStarted, _) =>
{
if (isStarted)
gcw.Stop();
else
gcw.Start();
return !isStarted;
}).Subscribe(isStarted => button1.Content = isStarted ? "Stop" : "Start" );
The Scan operator allows the query to carry state from one Click event to the other. Each click event starts or stops the GeoLocationWatcher instance depending on the accumulator’s value, and then toggles it. The subscriber updates the button’s content accordingly.
Next the code must ensure that the distance computations takes into account only readings that have different lat/long values. The DistinctUntilChanged RxLINQ operator with a comparator that considers only the Latitude and Longitude values provides an elegant solution to the deduplication problem. Here’s the comparator’s code:
public class PositionComparator : IEqualityComparer<GeoPosition<GeoCoordinate>>
{public bool Equals(GeoPosition<GeoCoordinate> x, GeoPosition<GeoCoordinate> y)
{
return (x.Location.Latitude == y.Location.Latitude && x.Location.Longitude == y.Location.Longitude);
}// snip
}
And here’s the updated positions query (new code in green):
var positions = (from position in positionChanges
where position.Location.HorizontalAccuracy <= 100
select position).DistinctUntilChanged(new PositionComparator());
Computing distances requires two points. An elegant solution to using the current and previous location events is to combine the location event stream with itself such that every event pair represents the current and previous coordinates. The RxLINQ Zip operator combines the event streams, and the Skip operator provides the shift required for this pairing. Here’s the query that computes the distance in Km (for imperial units replace Kilometers with Miles):
var distances = positions.Zip(positions.Skip(1), (l, r) => DistanceIn.Kilometers.Between(r, l));
Finally, the Scan operator applied to the distances event stream computes the total distance; the accumulator’s initial value is 0.0, and the display shows meters:
var distance = distances.Scan(0.0, (a, e) => a + e);
distance.Subscribe(d => this.textBlock1.Text = string.Format("Distance so far {0:00.000} m",d*1000));
In summary, this post has shown:
- How to convert Windows Phone 7 lat/long readings into distances,
- How to use RxLINQ queries for UI (button click events), and
- How to perform calculations on adjacent elements in an event stream via the Zip and Skip operators.
Comments
Anonymous
September 29, 2010
Nitpicking, I know, but... I would personally use a switch statement on the DistanceIn parameter to get the radius of the earth (that's it, right?) constant in the right unit, with a default that throws a NotImplementedException on unknown units. This might be to much future-proofing for a blog post, I know, but I just thought I'd mention it anyway.Anonymous
September 29, 2010
Since you are using coordinates based on the WGS84 datum, you should be using it's earth radius for this calculation, which is 6378.137km at equator and 6356.7523 along a meridianAnonymous
October 07, 2010
I'm curious why you use your own Haversine calculator and not the GeoCoordinate.GetDistanceTo(GeoCoordinate) methodAnonymous
October 08, 2010
Good point, I missed GetDistanceTo(). I'll use that in my next post. Thanks! Dragos