In my previous post, we fixed up a C# GPS library to provide more support, and wrote all of the UI for a VB GPS application, all based on (but modified from) a Mobile GPS sample in the Windows Mobile 6.0 SDK. In this post, we’ll finish up the app by enabling a bunch of cool functionality not exposed in the original sample.
The C# sample uses a helper function called UpdateData – I will too (as noted above), except that the code is going to deviate significantly. We’ll start out by checking is the GPS is actually running, just in case we called outside of an event:
Protected Sub UpdateData(ByVal sender As Object, ByVal args As System.EventArgs)
If vbgps.Opened Then
First, we’ll update the screen with whatever cached device data we have:
If device IsNot Nothing Then
Me.Text = "VBGPS: " & device.FriendlyName.ToString
Me.StatusLabel.Text = device.DeviceState.ToString
End If
This will change the title bar to include the name of the GPS device in use (in case you have multiple devices) and will set the StatusLabel to whatever the state of the GPS is (e.g., “On”).
All other interesting information comes from the cached position object:
If position IsNot Nothing Then
We’ll start with the altitude. There are two ways of calculating altitude – sea level (self-explanatory), and ellipsoid (which calculates altitude against a “perfect” ellipsoid representation of the otherwise pear-shaped earth). We’re going to go with ellipsoid. We’ll need to check if the information for it is reported as being valid, and if so, assigned the value to the appropriate label. The value is returned to us in meters, and we’ll also show it in feet by multiplying the result by 3.281 feet/meters. Again, we’ll format the results so that we only have two decimal places:
If position.EllipsoidAltitudeValid Then
Me.AltLabel.Text = CDbl(position.EllipsoidAltitude).ToString("####0.00") _
& " m (" & CDbl(position.EllipsoidAltitude * 3.281).ToString("####0.00") _
& "ft)"
Next is velocity. The speed value will be in meters per second; We’ll show it in kph and mph. I’ll also show the heading (if valid), and separate the two by an “@” sign (e.g., “88.00 kph (54.69 mph) @ 45.23°”:
If position.SpeedValid Then
Me.VelLabel.Text = CDbl(position.Speed * 1.852).ToString("###0.00") _
& " kph (" & CDbl(position.Speed * 1.151).ToString("###0.00") & " mph)"
If position.HeadingValid Then
Me.VelLabel.Text = Me.VelLabel.Text & " @ " _
& position.Heading.ToString("##0.00") & "°"
Latitude and longitude are next. We’ll be showing them in both DMS and DM format, using the methods we wrote in C#. The only interesting thing here is converting negative values to W or S indicators:
If position.LatitudeValid Then
Me.LatLabel.Text = position.LatitudeInDegreesMinutesSeconds.DMSString() _
& " (" & position.LatitudeInDegreesMinutesSeconds.DMString & ")"
If position.Latitude < 0 Then
Me.LatLabel.Text = Me.LatLabel.Text & " S"
Else
Me.LatLabel.Text = Me.LatLabel.Text & " N"
If position.LongitudeValid Then
Me.LongLabel.Text = position.LongitudeInDegreesMinutesSeconds.DMSString() _
& " (" & position.LongitudeInDegreesMinutesSeconds.DMString & ")"
If position.Longitude < 0 Then
Me.LongLabel.Text = Me.LongLabel.Text & " W"
Me.LongLabel.Text = Me.LongLabel.Text & " E"
Satellites are a bit more complicated, and if you do a straight port of the C# sample, you’ll get runtime errors. This is because the C# code references values which may be NULL even if the values are valid – my VB code checks to see if the values are Nothing before referencing them. (A value of Nothing is equivalent to zero satellites in this case, since the C# library uses the existence and size of the satellite array to generate a value.)
There are three satellite components that we need to use – the number of satellites used to create a positioning solution, the number of them in view, and the total count of satellites – the resulting string should look something like (for example) “4/7 (8)”, depending on the actual number of satellites involved.
If position.SatellitesInSolutionValid AndAlso _
position.SatellitesInViewValid AndAlso _
position.SatelliteCountValid Then
Dim SatSol As Satellite() = position.GetSatellitesInSolution()
Dim SatView As Satellite() = position.GetSatellitesInView()
Dim SatStr As String
If SatSol IsNot Nothing Then
SatStr = SatSol.Length & "/"
SatStr = "0/"
If SatView IsNot Nothing Then
SatStr += SatView.Length & " ("
SatStr += "0 ("
Me.SatLabel.Text = SatStr & position.SatelliteCount & ")"
You can actually get more information on each satellite (such as its location in orbit, etc.) simply by exploring the methods and fields on the satellite object, but for now this will do fine for our project.
Now that we’ve shown the information as to where we are, we need to tell the user how to get to where they are going. Creating maps and routes is just a bit beyond the scope of this blog, but we can certainly indicate direction and distance. Let’s assume the existence of a helper function called UpdateTargetInfo at this point – we’ll define it later:
UpdateTargetInfo()
End Sub
Now, you’ll recall that we added fields to allow the user to specify the target point. We’ll want to translate the users entries into actual lat/long coordinates, and it would also be really cool if we could update the distance and direction in real-time. There are six fields and two combo boxes that are involved with the target, so let’s create a handler to deal with all of their change events (TextChanged for the text boxes, SelectedIndexChanged for the combo boxes):
Private Sub Target_TextChanged(ByVal sender As System.Object, ByVal e As System.EventArgs) Handles _
LatDegTB.TextChanged, LatMinTB.TextChanged, LatSecTB.TextChanged, _
LongDegTB.TextChanged, LongMinTB.TextChanged, LongSecTB.TextChanged, _
LatPosCB.SelectedIndexChanged, LongPosCB.SelectedIndexChanged
I’m going to use a helper function to collect the data, because collecting latitude data is identical to collecting longitude data, so we might as well treat them generically. The helper method will be called “CacheCoordinate” and we’ll define it later. As arguments, we’ll pass through the relevant controls and caches, and then update the distance and direction afterwards:
CacheCoordinate(LatDegTB, LatMinTB, LatSecTB, LatPosCB, TargetLatitude)
CacheCoordinate(LongDegTB, LongMinTB, LongSecTB, LongPosCB, TargetLongitude)
Now, the user could enter all sorts of errors accidentally into the text fields, any of which could cause an exception to be thrown. I could check and recheck for text and oddly-formed numbers being inserted into those fields, but for this exercise, I’m going to go with the moral equivalent of duct tape and simply use a Try block around the code we just wrote. If there’s an error, I’ll simply tell the user that their entered target information is invalid, and so the distance and direction cannot be determined:
Try
Catch ex As Exception
Me.DistLabel.Text = "Invalid target."
Me.DirLabel.Text = "Invalid target."
TargetLatitude = Nothing
TargetLongitude = Nothing
End Try
Now, we’ll start to parse together the data, starting with latitude. The combo box for it has two values – “N” or “E” (at the 0 position) and “S” or “W”(at the 1 position) – so a simple check on the index will tell us if we’ll be using positive or negative coordinates:
Private Sub CacheCoordinate(ByVal DegField As TextBox, ByVal MinField As TextBox, _
ByVal SecField As TextBox, ByVal PosField As ComboBox, _
ByRef cache As DegreesMinutesSeconds)
Dim isPositive As Boolean ' South and west are negative values
isPositive = (PosField.SelectedIndex = 0)
Note that the cache needs to be passed in as ByRef, since we’ll be allocating new memory around it!
So, a user might want to enter data in three ways: degrees with a decimal portion; degrees and minutes, with minutes having a decimal portion; degrees, minutes, and seconds, with seconds having a decimal portion. To help the user, we’ll hide all subsequent fields if a decimal point is used in one of the earlier fields. That is, if the user enters “47.” in the degrees field, then we know that he/she won’t need the minutes or seconds fields, so we’ll just hide them:
If DegField.Text.Contains(".") Then
MinField.Hide()
SecField.Hide()
Next, we’ll get the degrees value and makes sure that it is between 0 and 180 degrees:
Dim ddeg As Double = CDbl(DegField.Text)
If ddeg > 180 OrElse ddeg < 0 Then
Throw New Exception()
Now, if the coordinate is south or west, we’ll have to multiply by -1 to indicate that:
If Not isPositive Then
ddeg *= -1.0
And then finally cache the coordinate:
cache = New DegreesMinutesSeconds(ddeg)
Now, if the degrees field didn’t have a decimal, we’ll need to remember to show the minutes field in case it had been hidden earlier:
MinField.Show()
We also know that degrees will be a whole number, so let’s cache that away:
Dim deg As UInteger = CUInt(DegField.Text)
The next case is the degree-minutes case – if the minutes field has a decimal, we’ll know that seconds won’t be needed, so we’ll hide it:
If MinField.Text.Contains(".") Then
We then validate both the degrees (0…180) and the minutes (0…60), and then cache the target using the appropriate DegreesMinutesSeconds constructor that we created earlier:
Dim dmin As Double = CDbl(MinField.Text)
If deg > 180 OrElse deg < 0 _
OrElse dmin > 60 OrElse dmin < 0 Then
cache = New DegreesMinutesSeconds(isPositive, deg, dmin)
If we’ve gotten this far without finding a decimal place, then we know that we’re using all of the fields (degrees, minutes, and seconds), so we should verify that seconds is showing, do the validation, and cache the target:
SecField.Show()
Dim min As UInteger = CUInt(MinField.Text)
Dim dsec As Double = CDbl(SecField.Text)
OrElse min > 180 OrElse min < 0 _
OrElse dsec > 60 OrElse dsec < 0 Then
' Cache this data for later use
cache = New DegreesMinutesSeconds(isPositive, deg, min, dsec)
It’s time for us to write this UpdateTargetInfo() code. That code will be responsible for calculating both the distance and direction from our current position to our desired position. We’ll be using the Math library to do these calculations. The functions in the math library use radians, not degrees, but we can whip up a quick translation between the two:
Private Function DegToRad(ByVal deg As Double) As Double
Return (deg * Math.PI) / 180
End Function
Private Function RadToDeg(ByVal rad As Double) As Double
Return (rad * 180) / Math.PI
To calculate distance, we’ll be using the Haversine formula that we all learned in high-school trigonometry. I refreshed my memory on this formula (and the direction formula) at http://www.movable-type.co.uk/scripts/latlong.html. These formulae do calculations based on a great-circle, which (if you’ll recall) is actually the path to follow for the shortest distance between two points on a sphere. (Thus, if your destination is on the same line of latitude as you, the direction you want to go will not actually be due west or east unless you are at the equator.)
Assuming our current position is valid, we’ll go ahead & get out starting point, as well as define the radius of the earth:
Private Sub UpdateTargetInfo()
If position IsNot Nothing AndAlso position.LatitudeValid AndAlso _
position.LongitudeValid AndAlso _
TargetLatitude IsNot Nothing AndAlso TargetLongitude IsNot Nothing Then
Dim radius As Double = 6371 ' kilometers
Dim rlat1 As Double = DegToRad(position.Latitude)
Dim rlat2 As Double = DegToRad(TargetLatitude.ToDecimalDegrees)
Dim rlong1 As Double = DegToRad(position.Longitude)
Dim rlong2 As Double = DegToRad(TargetLongitude.ToDecimalDegrees)
We’ll also get the difference in latitude and longitude handy:
Dim rdeltaLat As Double = rlat2 - rlat1
Dim rdeltaLong As Double = rlong2 - rlong1
Now, we can calculate distance based on Haversine:
Dim a As Double = Math.Pow(Math.Sin(rdeltaLat / 2), 2) + _
Math.Cos(rlat1) * Math.Cos(rlat2) * Math.Pow(Math.Sin(rdeltaLong / 2), 2)
Dim c As Double = 2.0 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a))
Dim distance As Double = radius * c
[Edit 8/7/09: Fixed typo in calculation of a -- used rlong2 instead of rlat2 accidentally. Will repost on Temple of VB site.]
The distance is described in kilometers at this point. If we’re under one kilometer, it’d be easier for the user if we switch to meters instead – and of course, we’d like to show miles and feet as well, all formatted nicely using ToString formatting:
If distance > 1.0 Then
Me.DistLabel.Text = distance.ToString("####0.00") & " km (" & CDbl(distance * 0.621).ToString("####0.00") & "mi)"
Me.DistLabel.Text = (distance * 1000.0).ToString("####0.00") & " m (" & CDbl(distance * 3280.84).ToString("####0.00") & "ft)"
Next is direction:
Dim theta As Double
theta = Math.Atan2(Math.Sin(rdeltaLong) * Math.Cos(rlat2), _
Math.Cos(rlat1) * Math.Sin(rlat2) - _
Math.Sin(rlat1) * Math.Cos(rlat2) * Math.Cos(rdeltaLong))
Dim degTheta As Double = (RadToDeg(theta) + 360.0) Mod 360
Me.DirLabel.Text = degTheta.ToString("##0.00") & "°"
And of course if the position information isn’t valid, then we need to tell the user:
Me.DistLabel.Text = "No GPS data available."
Me.DirLabel.Text = "No GPS data available."
And that’s it! Debugging it is a bit tricky, since you have to take it outside to try it out (I was basically running in and out of my house to try out new ideas I’d just coded), but it’s fully functional and would be useful for (for instance) geocaching or surveying. Going forward, I’ll probably add more functional to mark areas that I was interested in persisting – once I get some more free time. J The current version’s code is available at my Temple of VB site – it also includes a setup project for CABbing up the file, as well as a console application to test out the direction/distance functionality. Enjoy!
‘Til next time,
--Matt--*