Whenever I’m a bit lost in a shopping mall or a museum, I search for a map, but at the same time I often feel some anxiety about what I’ll find. I’m pretty sure the map will feature an arrow labeled, “You are here,” but how will the map be oriented? If the map is mounted vertically, does the right side of the map actually correspond to my right and the bottom correspond to what’s behind me? Or does the map need to be mentally removed from its mount and twisted in space to line up with the actual layout?
Maps that are mounted at an angle or parallel with the floor are much better—provided, that is, they’re oriented correctly to begin with. Regardless of one’s mental agility with spatial relations, maps are easier to read when they’re parallel to the earth, or can be swiveled forward to align with the earth. Prior to the age of GPS, it was common to see people grappling with paper road maps by wildly twisting them to the right, left and upside down in search of the proper orientation.
Maps that are implemented in software on phones and other mobile devices have the potential to orient themselves based on a compass reading. This is the impetus behind my quest to display a map on a Windows Phone device that rotates relative to the phone. Such a map should be able to align itself with the surrounding landscape and potentially be more helpful to the lost among us.
Orienting the Map
My initial goal was a bit more ambitious than a rotating map. The program I envisioned would actually float a map in 3D space so it would always be parallel to the surface of the Earth, as well as being oriented with the compass.
A little experimentation convinced me that this approach was somewhat more extravagant than I needed. Although a tilted map with perspective is fine for GPS displays in automobiles, I think that’s because the map is always tilted the same degree, and it somewhat mimics what you’re seeing out the windshield. With a map on a mobile device, the tilting has the effect of compressing the map visuals without providing any additional information. A simple two-dimensional rotation seemed to be sufficient.
In my November column, I discussed how to use the Bing Maps SOAP Services to download and assemble 256-pixel square tiles into a map (“Assembling Bing Map Tiles on Windows Phone,” msdn.microsoft.com/magazine/jj721603). The tiles available from this Web service are organized into zoom levels, where each higher level has double the resolution of the previous level, which means that each tile covers the same area as four tiles in the next-higher level.
The program in last month’s column contained application bar buttons labeled with plus and minus signs to increase and decrease the zoom level in discrete jumps. That type of interface is adequate for a map on a Web site, but for a phone, the only description that seems appropriate is “totally lame.”
This means it’s now time to implement a real touch interface that allows the map to be zoomed continuously.
Once I began adding that touch interface—a single finger to pan, two fingers to zoom in and out—I acquired a deep and enduring respect for the Maps application on Windows Phone, and for the Silverlight Map control. These maps obviously implement a much more sophisticated touch interface than what I’ve been able to manage.
For example, I don’t think I’ve ever seen a black hole open up in the Maps app because a tile is missing. It’s my experience that the screen is always entirely covered—although obviously sometimes with a tile that has been stretched beyond the point of recognition. Tiles are replaced with tiles of better resolution with a fade animation. Inertia is implemented in a very natural way, and the UI never gets jumpy while tiles are being downloaded.
My OrientingMap program (which you can download) comes nowhere close to the real Maps application. The panning and expansion is often jumpy, there’s no inertia and blank areas frequently appear if tiles aren’t downloaded quickly enough.
Despite these deficiencies, my program does succeed in maintaining an orientation of the map with the world it portrays.
The Basic Issue
The Bing Maps SOAP Services give a program access to 256-pixel square map tiles from which it can construct larger composite maps. For road and aerial views, Bing Maps makes available 21 levels of zoom, where level 1 covers the Earth with four tiles, level 2 with 16 tiles, level 3 with 64 and so forth. Each level provides double the horizontal resolution and double the vertical resolution of the next lower level.
Tiles have a parent-child relationship: Except for tiles in level 21, every tile has four children in the next-higher level that together cover the same area as itself but with double the resolution.
When a program sticks to integral zoom levels—as the program presented in last month’s column does—the individual tiles can be displayed in their actual pixel sizes. Last month’s program always displays 25 tiles in a 5 × 5 array, for a total size of 1,280 pixels square. The program always positions this array of tiles so that the center of the screen corresponds to the phone’s location on the map, which is a location somewhere in the center tile. Do the math and you’ll find that even if a corner of the center tile sits in the center of the screen, this 1,280 pixel square size is adequate for the 480 × 800 screen size of the phone, regardless how it’s rotated.
Because last month’s program supports only discrete zoom levels and always centers the tiles based on the phone’s location, it implements an extremely simplistic logic by completely replacing these 25 tiles whenever a change occurs. Fortunately, the download cache makes this process fairly fast if tiles are being replaced with previously downloaded tiles.
With a touch interface, this simple approach is no longer acceptable.
The hard part is definitely the scaling: For example, suppose the program begins by displaying map tiles of level 12 in their pixel sizes. Now the user puts two fingers on the screen and moves the fingers apart to expand the screen. The program must respond by scaling the tiles beyond their 256-pixel sizes. It can do this either with a ScaleTransform on the tiles themselves, or with a ScaleTransform applied to a Canvas on which the tiles are assembled.
But you don’t want to scale these tiles indefinitely! At some point you want to replace each tile with four child tiles of the next-higher level and half the scaling factor. This replacement process would be fairly trivial if the child tiles were instantly available but, of course, they’re not. They must be downloaded, which means that child tiles must be positioned visually on top of the parent, and only when all four child tiles have been downloaded can the parent be removed from the Canvas.
The opposite process must occur in a zoom out. As the user pinches two fingers together, the entire array of tiles can be scaled down, but at some point each group of four tiles should be replaced with a parent tile visually underneath the four tiles. Only when that parent tile has been downloaded can the four children be removed.
Additional Classes
As I discussed in last month’s column, Bing Maps uses a numbering system called a “quadkey” to uniquely identify map tiles. A quadkey is a base-4 number: The number of digits in the quadkey indicates the zoom level, and the digits themselves encode an interleaved longitude and latitude.
To assist the OrientingMap program in working with quadkeys, the project includes a QuadKey class that defines properties to obtain parent and child quadkeys.
The OrientingMap project also has a new MapTile class that derives from UserControl. The XAML file for this control is shown in Figure 1. It has an Image element with its Source property set to a BitmapImage object for displaying the bitmap tile, as well as a ScaleTransform for scaling the entire tile up or down. (In practice, individual tiles are only scaled by positive and negative integral powers of 2.) For debugging, I put a TextBlock in the XAML file that displays the quadkey, and I’ve left that in: Simply change the Visibility attribute to Visible to see it.
Figure 1 The MapTile.xaml File from OrientingMap
<UserControl x:Class="OrientingMap.MapTile" ... >
<Grid>
<Image Stretch="None">
<Image.Source>
<BitmapImage x:Name="bitmapImage"
ImageOpened="OnBitmapImageOpened" />
</Image.Source>
</Image>
<!-- Display quadkey for debugging purposes -->
<TextBlock Name="txtblk"
Visibility="Collapsed"
Foreground="Red" />
</Grid>
<UserControl.RenderTransform>
<ScaleTransform x:Name="scale" />
</UserControl.RenderTransform>
</UserControl>
The codebehind file for MapTile defines several handy properties: The QuadKey property allows the MapTile class itself to obtain the URI for accessing the map tile; a Scale property lets external code set the scaling factor; an IsImageOpened property indicates when the bitmap has been downloaded; and an ImageOpened property provides external access to the ImageOpened event of the BitmapImage object. These last two properties help the program determine when an image has been loaded so the program can remove any tiles that the image is replacing.
While developing this program, I initially pursued a scheme where each MapTile object would use its Scale property to determine when it should be replaced with a group of four child MapTile objects, or a parent MapTile. The MapTile itself would handle the creation and positioning of these new objects, setting handlers for the ImageOpened events, and would also be responsible for removing itself from the Canvas.
But I couldn’t get this scheme to work very well. Consider an array of 25 map tiles that the user expands through the touch interface. These 25 tiles are replaced with 100 tiles, and then the 100 tiles are replaced with 400 tiles. Does this make sense? No, it doesn’t, because the scaling has effectively moved many of these potential new tiles too far off the screen to be visible. Most of them shouldn’t be created or downloaded at all!
Instead, I shifted this logic to MainPage. This class maintains a currentMapTiles field of type Dictionary<QuadKey, MapTile>. This stores all the MapTile objects currently on the display, even if they’re still in the process of being downloaded. A method named RefreshDisplay uses the current location of the map and a scaling factor to assemble a validQuadKeys field of type List<QuadKey>. If a QuadKey object exists in validQuadKeys but not in currentMapTiles, a new MapTile is created and added to both the Canvas and currentMapTiles.
RefreshDisplay does not remove MapTile objects that are no longer needed, either because they’ve been panned off the screen or replaced with parents or children. That’s the responsibility of a second important method named Cleanup. This method compares the validQuadKeys collection with currentMapTiles. If it finds an item in currentMapTiles that’s not in validQuadKeys, it only removes that MapTile if validQuadKeys has no children, or if the children in validQuadKeys have all been downloaded, or if validQuadKeys contains a parent of that MapTile and that parent has been downloaded.
Making the RefreshDisplay and Cleanup methods more efficient—and invoking them less frequently—is one approach to improving the performance of OrientingMap.
Nested Canvases
The UI for the OrientingMap program requires two types of graphics transforms: translation for single-finger panning and scaling for two-finger pinch operations. In addition, orienting the map with the direction of north requires a rotation transform. To implement these with efficient Silverlight transforms, the MainPage.xaml file contains three levels of Canvas panels, as shown in Figure 2.
Figure 2 Much of the MainPage.xaml File for OrientingMap
<phone:PhoneApplicationPage x:Class="OrientingMap.MainPage" ... >
<Grid x:Name="LayoutRoot" Background="Transparent">
<Grid x:Name="ContentPanel" Grid.Row="1" Margin="12">
<TextBlock Name="errorTextBlock"
HorizontalAlignment="Center"
VerticalAlignment="Top"
TextWrapping="Wrap" />
<!-- Rotating Canvas with origin in center of screen -->
<Canvas HorizontalAlignment="Center"
VerticalAlignment="Center">
<!-- Translating Canvas for panning -->
<Canvas>
<!-- Scaled Canvas for images -->
<Canvas Name="imageCanvas"
HorizontalAlignment="Center"
VerticalAlignment="Center">
<Canvas.RenderTransform>
<ScaleTransform x:Name="imageCanvasScale" />
</Canvas.RenderTransform>
</Canvas>
<!-- Circle to show location -->
<Ellipse Name="locationDisplay"
Width="24"
Height="24"
Stroke="Red"
StrokeThickness="3"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Visibility="Collapsed">
<Ellipse.RenderTransform>
<TranslateTransform x:Name="locationTranslate" />
</Ellipse.RenderTransform>
</Ellipse>
<Canvas.RenderTransform>
<TranslateTransform x:Name="imageCanvasTranslate" />
</Canvas.RenderTransform>
</Canvas>
<Canvas.RenderTransform>
<RotateTransform x:Name="imageCanvasRotate" />
</Canvas.RenderTransform>
</Canvas>
<!-- Arrow to show north -->
<Border HorizontalAlignment="Left"
VerticalAlignment="Top"
Background="Black"
Width="36"
Height="36"
CornerRadius="18">
<Path Stroke="White"
StrokeThickness="3"
Data="M 18 4 L 18 24 M 12 12 L 18 4 24 12">
<Path.RenderTransform>
<RotateTransform x:Name="northArrowRotate"
CenterX="18"
CenterY="18" />
</Path.RenderTransform>
</Path>
</Border>
<!-- "powered by bing" display -->
<Border Background="Black"
HorizontalAlignment="Center"
VerticalAlignment="Bottom"
CornerRadius="12"
Padding="3">
<StackPanel Name="poweredByDisplay"
Orientation="Horizontal"
Visibility="Collapsed">
<TextBlock Text=" powered by "
Foreground="White"
VerticalAlignment="Center" />
<Image Stretch="None">
<Image.Source>
<BitmapImage x:Name="poweredByBitmap" />
</Image.Source>
</Image>
</StackPanel>
</Border>
</Grid>
</Grid>
...
</phone:PhoneApplicationPage>
The Grid named ContentPanel contains the outermost Canvas as well as three elements that are always displayed in fixed locations on the screen: a TextBlock to report initialization errors, a Border containing a rotating arrow to display the direction of north and another Border to display the Bing logo.
The outermost Canvas has its HorizontalAlignment and VerticalAlignment properties set to Center, which shrinks the Canvas to a zero size positioned in the center of the Grid. The (0, 0) coordinate of this Canvas is therefore the center of the display. This centering is convenient for positioning tiles, and also allows scaling and rotation to occur around the origin.
The outermost Canvas is the one that’s rotated based on the direction of north. Within this outermost Canvas is a second Canvas that has a TranslateTransform. This is for panning. Whenever a single finger sweeps across the screen, the entire map can be moved simply by setting the X and Y properties of this TranslateTransform.
Within this second Canvas is an Ellipse used to indicate the current location of the phone relative to the center of the map. When the user pans the map, this Ellipse moves as well. But if the phone’s GPS reports a change in the location, a separate TranslateTransform on the Ellipse moves it relative to the map.
The innermost Canvas is named imageCanvas, and it’s here that the map tiles are actually assembled. The ScaleTransform applied to this Canvas allows the program to increase or decrease this entire assemblage of map tiles based on the user zooming in or out with a pinch manipulation.
To accommodate the continuous zoom, the program maintains a zoomFactor field of type double. This zoomFactor has the same range as the tile levels—from 1 to 21—which means that it’s actually the base-2 logarithm of the total map-scaling factor. Whenever the zoomFactor increases by 1, the scaling of the map doubles.
The first time the program is run, zoomFactor is initialized to 12, but the first time the user touches the screen with two fingers, it becomes a non-integral value and very likely remains a non-integral value thereafter. The program saves zoomFactor as a user setting and reloads it the next time the program is run. An initial integral baseLevel is calculated with a simple truncation:
baseLevel = (int)zoomFactor;
This baseLevel is always an integer in the range between 1 and 21, and hence it’s directly suitable for retrieving tiles. From these two numbers, the program calculates a non-logarithmic scaling factor of type double:
canvasScale = Math.Pow(2, zoomFactor - baseLevel);
This is the scaling factor applied to the innermost Canvas. For example, if the zoomFactor is 10.5, then the baseLevel used to retrieve tiles is 10, and canvasScale is 1.414.
If the initial zoomFactor is 10.9, it might make more sense to set baseLevel at 11 and canvasZoom at 0.933. The program doesn’t do that, but it’s obviously a possible refinement.
One- and Two-Finger Touch Input
For touch input, I felt more comfortable using the XNA TouchPanel than the Silverlight Manipulation events. The MainPage constructor enables four types of XNA gestures: FreeDrag (panning), DragComplete, Pinch and PinchComplete. The TouchPanel is checked for input in a handler for the CompositionTarget.Rendering event, as shown in Figure 3. Due to its complexity, only a little of the Pinch processing is shown here.
Figure 3 Touch Processing in OrientingMap
void OnCompositionTargetRendering(object sender, EventArgs args)
{
while (TouchPanel.IsGestureAvailable)
{
GestureSample gesture = TouchPanel.ReadGesture();
switch (gesture.GestureType)
{
case GestureType.FreeDrag:
// Adjust delta for rotation of canvas
Vector2 delta = TransformGestureToMap(gesture.Delta);
// Translate the canvas
imageCanvasTranslate.X += delta.X;
imageCanvasTranslate.Y += delta.Y;
// Adjust the center longitude and latitude
centerRelativeLongitude -= delta.X / (1 << baseLevel + 8) / canvasScale;
centerRelativeLatitude -= delta.Y / (1 << baseLevel + 8) / canvasScale;
// Accumulate the panning distance
accumulatedDeltaX += delta.X;
accumulatedDeltaY += delta.Y;
// Check if that's sufficient to warrant a screen refresh
if (Math.Abs(accumulatedDeltaX) > 256 ||
Math.Abs(accumulatedDeltaY) > 256)
{
RefreshDisplay();
accumulatedDeltaX = 0;
accumulatedDeltaY = 0;
}
break;
case GestureType.DragComplete:
Cleanup();
break;
case GestureType.Pinch:
// Get the old and new finger positions relative to canvas origin
Vector2 newPoint1 = gesture.Position - canvasOrigin;
Vector2 oldPoint1 = newPoint1 - gesture.Delta;
Vector2 newPoint2 = gesture.Position2 - canvasOrigin;
Vector2 oldPoint2 = newPoint2 - gesture.Delta2;
// Rotate in accordance with the current rotation angle
oldPoint1 = TransformGestureToMap(oldPoint1);
newPoint1 = TransformGestureToMap(newPoint1);
oldPoint2 = TransformGestureToMap(oldPoint2);
newPoint2 = TransformGestureToMap(newPoint2);
...
RefreshDisplay();
break;
case GestureType.PinchComplete:
Cleanup();
break;
}
}
}
The FreeDrag input is accompanied by Position and Delta values (both of type Vector2) indicating the current position of the finger, and how the finger has moved since the last TouchPanel event. The Pinch input supplements these with Position2 and Delta2 values for the second finger.
However, keep in mind that these Vector2 values are in-screen coordinates! Because the map is rotated relative to the screen—and the user expects the map to pan in the same direction as a finger moves—these values must be rotated based on the current map rotation, which occurs in a little method named TransformGestureToMap.
For FreeDrag processing, the delta value is then applied to the TranslateTransform in the XAML file, as well as two floating-point fields named centerRelativeLongitude and centerRelativeLatitude. These values range from 0 to 1 and indicate the longitude and latitude corresponding to the center of the screen.
At some point, the user might pan the map to a sufficient degree that new tiles need to be loaded. To avoid checking for that possibility with each touch event, the program maintains two fields named accumulatedDeltaX and accumulatedDeltaY, and only calls RefreshDisplay when either value goes above 256, which is the pixel size of the map tiles.
Because RefreshDisplay has a big job to do—determining what tiles should be visible on the screen based on centerRelativeLongitude and centerRelativeLatitude and the current canvasScale, and creating new tiles if necessary—it’s best that it not be called for every change in touch input. One definite enhancement to the program would limit RefreshDisplay calls during Pinch input.
During touch processing, the Cleanup method is only called when the finger or fingers have left the screen. Cleanup is also called whenever a map tile has completed downloading.
The criteria for changing baseLevel—and thereby initiating a replacement of a parent map tile by children, or children by a parent—is very relaxed. The baseLevel is only incremented when canvasScale becomes greater than 2, and decremented when canvasScale drops to less than 0.5. Setting better transition points is another obvious enhancement.
The program now has only two application bar buttons: The first toggles between road and aerial view, and the second positions the map so that the current location is in the center.
Now I just need to figure out how to make the program help me navigate shopping malls and museums.
Charles Petzold is a longtime contributor to MSDN Magazine, and the author of “Programming Windows, 6th edition” (O’Reilly Media, 2012), a book about writing applications for Windows 8. His Web site is charlespetzold.com.
Thanks to the following technical expert for reviewing this article: Thomas Petchel
Comments (0)