Adding Opacity and WMS support to tile layers in WP8.1 Maps

The maps in WP8 did not support adding an opacity to a tile layer. The only way around this was to set the opacity in your tiles or to use a proxy service and change the opacity of the tiles on the fly. With WP8.1 a new map API was released which is available through the Windows.UI.Xaml.Controls.Maps namespace. In this map API there is an HttpMapTileDataSource class which allows you to specify a URL that points to a set of maps tiles to be overlaid on top the map. This is likely to be the most commonly used method to add tile layers to the map. Unfortunately this class does support specifying an opacity which leaves us with the same limitation we had in WP8. However, all is not lost, in this blog post I’ll show how to create a custom MapTileDataSource which lets you specify an Http URL to a set of tiles and supports setting the opacity of the tile layer. We will then see how we can extend this class to support Web Mapping Services (WMS).

Full source code for this blog post can be found in the MSDN Code Samples here.

Setting up the project

To get started open Visual Studio and create a new Windows Phone Apps project in C# or Visual Basic. Select the Blank App Template and call the project TileLayerWithOpacityWP81.

clip_image002

Once the project is loaded open the MainPage.xaml file. In here we will load the map to fill the full screen. To do this we will need to add a reference to the Windows.UI.Xaml.Controls.Maps namespace and then add a MapControl object to the base Grid element. We will also set the map style to AerialWithRoads so that we can better see the tile layer examples I’ll be using later. Update this file with the following XAML:

 <Page
    x:Class="TileLayerWithOpacityWP81.MainPage"
    xmlns="https://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="https://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="using:TileLayerWithOpacityWP81"
    xmlns:d="https://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="https://schemas.openxmlformats.org/markup-compatibility/2006"
    xmlns:m="using:Windows.UI.Xaml.Controls.Maps"
    mc:Ignorable="d"
    Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">

    <Grid>
        <m:MapControl Name="MyMap" Style="AerialWithRoads"/>
    </Grid>
</Page>

If you run the application you will see a map that fills the screen. There will be a message at the bottom of the screen that says “Warning: MapServiceToken not specified.”. We can ignore this while we do development but you will need to specify a MapServiceToken before publishing your app as documented here.

Understanding the Bing Maps Tile System

Before we can get to work we need to understand how the Bing Maps tile system works. Bing Maps provides a world map that users can directly manipulate to pan and zoom. To make this interaction as fast and responsive as possible, the map is broken up into a large number of images at different levels of detail. These images are often referred to as tiles, as they are stitched together by the map control like a tiled surface to create a complete map.

To optimize the performance of map retrieval and display, the rendered map is cut into tiles of 256 x 256 pixels each. As the number of pixels differs at each level of detail (zoom level), so does the number of tiles:

map width = map height = 256 * 2zoom level

Each tile is given an XY index ranging from (0, 0) in the upper left to (2zoom level–1, 2zoom level–1) in the lower right. For example, at level 3 the tile index ranges from (0, 0) to (7, 7) as follows:

clip_image004

To optimize the indexing and storage of tiles, the two-dimensional tile XY indices are combined into one-dimensional strings called quadtree keys, or “quadkeys” for short. Each quadkey uniquely identifies a single tile at a particular zoom level. To convert a tile index into a quadkey, the bits of the X and Y components are interleaved, and the result is interpreted as a base-4 number (with leading zeroes maintained) and converted into a string. Quadkeys have several interesting properties. First, the length of a quadkey (the number of digits) is equal to the zoom level of the corresponding tile. Second, the quadkey of any tile starts with the quadkey of its parent tile (the containing tile at the previous level). As shown in the example below, tile 2 is the parent of tiles 20 through 23, and tile 13 is the parent of tiles 130 through 133.

clip_image006

The mathematics required to do calculations with the Bing Maps tile system is well documented here.

Creating a Custom MapTileDataSource

In the WP8.1 Maps API the HttpMapTileDataSource class is only one of the ways you can create a tile layer. There are two other classes that can also be used; LocalMapTileDataSource and CustomMapTileDataSource which you can find documented here. The LocalMapTileDataSource class allows you to point to locally store map tiles whereas the CustomMapTileDataSource lets you specify the raw pixel data for each individual tile. We will use the CustomMapTileDataSource to create a custom MapTileDataSource that loads in tiles using a URL and adds an opacity to them. To do this create a new class called TileDataSourceWithOpacity. We will have this class inherit from the CustomMapTileDataSource class. When loaded it will take in a URL that has the same format as required by the HttpMapTileDataSource class and an opacity value to be used for the layer specified. We will then add an event handler to the BitmapRequested event and will later add our logic for loading a map tile and adding an opacity to it. To do this create new class file called TileDataSourceWithOpacity.cs and update it with the following code:

 using System;
using System.IO;
using System.Net;
using System.Text;
using System.Threading.Tasks;
using Windows.Storage.Streams;
using Windows.UI.Xaml.Controls.Maps;

namespace TileLayerWithOpacityWP81
{
    public class TileDataSourceWithOpacity : CustomMapTileDataSource
    {
        private string _tileUrl;
        private byte _opacity;

        public TileDataSourceWithOpacity(string tileUrl, byte opacity)
        {
            _tileUrl = tileUrl;
            _opacity = opacity;
            this.BitmapRequested += TileDataSourceWithOpacity_BitmapRequested;
        } 

        private async void BitmapRequestedHandler(CustomMapTileDataSource sender, MapTileBitmapRequestedEventArgs args)
        {

        }
    }
}

If using Visual Basic update the TileDataSourceWithOpacity.vb file with the following code:

 Imports Microsoft.VisualBasic
Imports System.Net
Imports System.Text
Imports Windows.Storage.Streams
Imports Windows.UI.Xaml.Controls.Maps

Public Class TileDataSourceWithOpacity
    Inherits CustomMapTileDataSource

    Private _tileUrl As String
    Private _opacity As Byte

    Public Sub New(tileUrl As String, opacity As Byte)
        _tileUrl = tileUrl
        _opacity = opacity
        AddHandler Me.BitmapRequested, AddressOf BitmapRequestedHandler
    End Sub

    Private Async Sub BitmapRequestedHandler(sender As CustomMapTileDataSource, args As MapTileBitmapRequestedEventArgs)
    End Sub
End Class

Before we add the logic to the BitmapRequestHandler event handler we will create two helper methods. The first one is an asynchronous method that will allow us to pass in the tile XY index values and a zoom level. It will then use this information to along with the base tile URL that was specified when this class is initialized and update the {x}, {y}, {zoomlevel}, and {quadkey} values in the URL. It will then use this URL to download the tile and return it as a MemoryStream. The second method will allow us to calculate the quadkey value for a tile using the XY index and zoom level information. Add the following methods to the TileDataSourceWithOpacity.cs file:

 private Task<MemoryStream> GetTileAsStreamAsync(int x, int y, int zoom)
{
    var tcs = new TaskCompletionSource<MemoryStream>();

    var quadkey = TileXYZoomToQuadKey(x, y, zoom);
    var url = _tileUrl.Replace("{x}", x.ToString()).Replace("{y}", y.ToString()).Replace("{zoomlevel}", zoom.ToString()).Replace("{quadkey}", quadkey);

    var request = HttpWebRequest.Create(url);
    request.BeginGetResponse(async (a) =>
    {
        var r = (HttpWebRequest)a.AsyncState;
        HttpWebResponse response = (HttpWebResponse)r.EndGetResponse(a);

        using (var s = response.GetResponseStream())
        {
            var ms = new MemoryStream();
            await s.CopyToAsync(ms);
            ms.Position = 0;
            tcs.SetResult(ms);
        }
    }, request);

    return tcs.Task;
}

private string TileXYZoomToQuadKey(int tileX, int tileY, int zoom)
{
    var quadKey = new StringBuilder();
    for (int i = zoom; i > 0; i--)
    {
        char digit = '0';
        int mask = 1 << (i - 1);
        if ((tileX & mask) != 0)
        {
            digit++;
        }
        if ((tileY & mask) != 0)
        {
            digit++;
            digit++;
        }
        quadKey.Append(digit);
    }
    return quadKey.ToString();
}

If using Visual Basic add the following methods to the TileDataSourceWithOpacity.vb file:

 Private Function GetTileAsStreamAsync(x As Integer, y As Integer, zoom As Integer) As Task(Of MemoryStream)
    Dim tcs = New TaskCompletionSource(Of MemoryStream)()

    Dim quadkey = TileXYZoomToQuadKey(x, y, zoom)

    Dim url = _tileUrl.Replace("{x}", x.ToString()).Replace("{y}", y.ToString()).Replace("{zoomlevel}", zoom.ToString()).Replace("{quadkey}", quadkey)

    Dim request = HttpWebRequest.Create(url)
    request.BeginGetResponse(Async Sub(a)
                                 Dim r = DirectCast(a.AsyncState, HttpWebRequest)
                                 Dim response As HttpWebResponse = DirectCast(r.EndGetResponse(a), HttpWebResponse)

                                 Using s = response.GetResponseStream()
                                     Dim ms = New MemoryStream()
                                     Await s.CopyToAsync(ms)
                                     ms.Position = 0
                                     tcs.SetResult(ms)
                                 End Using
                             End Sub, request)

    Return tcs.Task
End Function

Private Function TileXYZoomToQuadKey(tileX As Integer, tileY As Integer, zoom As Integer) As String
    Dim quadKey = New StringBuilder()
    For i As Integer = zoom To 1 Step -1
        Dim digit As Char = "0"c
        Dim mask As Integer = 1 << (i - 1)
        If (tileX And mask) <> 0 Then
            ChrW(AscW(digit) + 1)
        End If
        If (tileY And mask) <> 0 Then
            ChrW(AscW(digit) + 1)
            ChrW(AscW(digit) + 1)
        End If
        quadKey.Append(digit)
    Next
    Return quadKey.ToString()
End Function

Now that we have the helper methods we can create the logic for the BitmapRequestHandler event handler. When this event handler is called we will first need to get the require map tile by using GetTileAsStreamAsync helper method. Once we have the tile as a MemoryStream we can decode the image using the BitmapDecoder class. By doing this we can then get access to the pixel data for the image. We can then loop through all the pixel data and override the opacity (alpha) pixel color values to the opacity value that was specified when the class was initialized. From here we can then create an InMemoryRandomAccessStream, add the updated pixel data to it, turn it into a RandomAccessStreamReference and then use this to set the value of the PixelData property on the bitmap request. Update the BitmapRequestHandler event handler in the TileDataSourceWithOpacity.cs file with the following code:

 private async void TileDataSourceWithOpacity_BitmapRequested(CustomMapTileDataSource sender, MapTileBitmapRequestedEventArgs args)
{
    var deferral = args.Request.GetDeferral();

    using (var imgStream = await GetTileAsStreamAsync(args.X, args.Y, args.ZoomLevel))
    {
        //https://msdn.microsoft.com/en-us/library/windows/apps/xaml/jj709936.aspx
        var memStream = imgStream.AsRandomAccessStream();
        var decoder = await Windows.Graphics.Imaging.BitmapDecoder.CreateAsync(memStream);

        var pixelProvider = await decoder.GetPixelDataAsync(
            Windows.Graphics.Imaging.BitmapPixelFormat.Rgba8,
            Windows.Graphics.Imaging.BitmapAlphaMode.Straight,
            new Windows.Graphics.Imaging.BitmapTransform(),
            Windows.Graphics.Imaging.ExifOrientationMode.RespectExifOrientation,
            Windows.Graphics.Imaging.ColorManagementMode.ColorManageToSRgb);

        var pixels = pixelProvider.DetachPixelData();

        var width = decoder.OrientedPixelWidth;
        var height = decoder.OrientedPixelHeight;

        Parallel.For(0, height, (i)=>
        {
            for (var j = 0; j < width; j++)
            {
                var idx = (i * height + j) * 4 + 3; // Alpha channel Index (RGBA)

                //Only change the opacity of a pixel if it isn't transparent
                if (pixels[idx] != 0)
                {
                    pixels[idx] = _opacity;
                }
            }
        });

        //https://msdn.microsoft.com/en-US/library/windows/apps/xaml/dn632728.aspx
        var randomAccessStream = new InMemoryRandomAccessStream();
        var outputStream = randomAccessStream.GetOutputStreamAt(0);
        var writer = new DataWriter(outputStream);
        writer.WriteBytes(pixels);
        await writer.StoreAsync();
        await writer.FlushAsync();

        args.Request.PixelData = RandomAccessStreamReference.CreateFromStream(randomAccessStream);
    }

    deferral.Complete();
}

If using Visual Basic update the BitmapRequestHandler event handler in the TileDataSourceWithOpacity.vb file with the following code:

 Private Async Sub BitmapRequestedHandler(sender As CustomMapTileDataSource, args As MapTileBitmapRequestedEventArgs)
    Dim deferral = args.Request.GetDeferral()

    Try
        Using imgStream = Await GetTileAsStreamAsync(args.X, args.Y, args.ZoomLevel)
            'https://msdn.microsoft.com/en-us/library/windows/apps/xaml/jj709936.aspx
            Dim memStream = imgStream.AsRandomAccessStream()
            Dim decoder = Await Windows.Graphics.Imaging.BitmapDecoder.CreateAsync(memStream)

            Dim pixelProvider = Await decoder.GetPixelDataAsync(Windows.Graphics.Imaging.BitmapPixelFormat.Rgba8, Windows.Graphics.Imaging.BitmapAlphaMode.Straight, New Windows.Graphics.Imaging.BitmapTransform(), Windows.Graphics.Imaging.ExifOrientationMode.RespectExifOrientation, Windows.Graphics.Imaging.ColorManagementMode.ColorManageToSRgb)

            Dim pixels = pixelProvider.DetachPixelData()

            Dim width = decoder.OrientedPixelWidth
            Dim height = decoder.OrientedPixelHeight

            Parallel.[For](0, height, Sub(i)
                                          For j As Integer = 0 To width - 1
                                              ' Alpha channel Index (RGBA)
                                              Dim idx = (i * height + j) * 4 + 3

                                              'Only change the opacity of a pixel if it isn't transparent
                                              If pixels(idx) <> 0 Then
                                                  pixels(idx) = _opacity
                                              End If
                                          Next
                                      End Sub)

            'https://msdn.microsoft.com/en-US/library/windows/apps/xaml/dn632728.aspx
            Dim randomAccessStream = New InMemoryRandomAccessStream()
            Dim outputStream = randomAccessStream.GetOutputStreamAt(0)
            Dim writer = New DataWriter(outputStream)
            writer.WriteBytes(pixels)
            Await writer.StoreAsync()
            Await writer.FlushAsync()

            args.Request.PixelData = RandomAccessStreamReference.CreateFromStream(randomAccessStream)
        End Using
    Catch
    End Try

    deferral.Complete()
End Sub

Implementing the TileDataSourceWithOpacity class

To try this out we will overlay some map tiles from OpenPisteMap which show the skiing and snowboarding trails around the world. The URL we will use to access these tiles is as follows.

https://tiles.openpistemap.org/nocontours/ {zoomlevel} / {x} / {y} .png

From here we can open the MainPage.xaml.cs file and add a Loaded event to our map. When the map loads we will create a new instance of the TileDataSourceWithOpacity class that points to this tile URL and has an opacity of 100. We will then add this tile data source to a new instance of a MapTileSource class and set the IsTransparencyEnabled property to true. We will then add this tile source to the TileSources property of the map. To do all this update the MainPage.xaml.cs file with the following code:

 using Windows.UI.Xaml.Controls;
using Windows.UI.Xaml.Controls.Maps;
using Windows.UI.Xaml.Navigation;

namespace TileLayerWithOpacityWP81
{
    public sealed partial class MainPage : Page
    {
        public MainPage()
        {
            this.InitializeComponent();

            this.NavigationCacheMode = NavigationCacheMode.Required;

            MyMap.Loaded += (s, e) =>
            {
                var tileSource = new TileDataSourceWithOpacity("https://tiles.openpistemap.org/nocontours/{zoomlevel}/{x}/{y}.png", 100);

                var tileLayer = new MapTileSource(tileSource);
                tileLayer.IsTransparencyEnabled = true;

                MyMap.TileSources.Add(tileLayer);
            };
        }
    }
}

If using Visual Basic update the MainPage.xaml.vb file with the following code:

 Imports Windows.UI.Xaml.Controls.Maps

Public NotInheritable Class MainPage
    Inherits Page

    Public Sub New()
        Me.InitializeComponent()

        Me.NavigationCacheMode = NavigationCacheMode.Required

        AddHandler MyMap.Loaded,
            Sub(s, e)
                Dim tileSource = New TileDataSourceWithOpacity("https://tiles.openpistemap.org/nocontours/{zoomlevel}/{x}/{y}.png", 100)

                Dim tileLayer = New MapTileSource(tileSource)
                tileLayer.IsTransparencyEnabled = True

                MyMap.TileSources.Add(tileLayer)
            End Sub
    End Sub
End Class

At this point the application is complete. If you run the application and zoom into an area where there is a well-known ski resort you will see different colored lines marking all the ski and snowboard trails. Here is a screenshot of Whistler, British Columbia in Canada.

clip_image007

Going a step further with WMS support

There are many companies that expose mapping data in the form of a Web Mapping Service (WMS) or Web Map Tile Service (WMTS). These services can be used to generate tiles, however some of them may require additional information such as the bounding box of a tile. We can easily modify our new tile data source class to be able to handle these types of tile services and really increase the usefulness of our new class. To do this we will add a Boolean property to our class that indicates if tile data source is a WMS service or not. We will also add a helper method that calculates the bounding coordinates of a tile using the XY index and zoom level. Add the following code to the TileDataSourceWithOpacity.cs file:

 public bool IsWMS { get; set; }

private void TileXYZoomToBBox(int x, int y, int zoom, out double north, out double south, out double east, out double west)
{
    double mapSize = Math.Pow(2, zoom);

    west = ((x * 360) / mapSize) - 180; 
    east = (((x + 1) * 360) / mapSize) - 180;

    double efactor = Math.Exp((0.5 - y / mapSize) * 4 * Math.PI);
    north = (Math.Asin((efactor - 1) / (efactor + 1))) * (180 / Math.PI);

    efactor = Math.Exp((0.5 - (y + 1) / mapSize) * 4 * Math.PI); 
    south = (Math.Asin((efactor - 1) / (efactor + 1))) * (180 / Math.PI);
}

If using Visual Basic add the following code to the TileDataSourceWithOpacity.vb file:

 Public Property IsWMS As Boolean

Private Sub TileXYZoomToBBox(x As Integer, y As Integer, zoom As Integer, ByRef north As Double, ByRef south As Double, ByRef east As Double, _
    ByRef west As Double)
    Dim mapSize As Double = Math.Pow(2, zoom)

    west = ((x * 360) / mapSize) - 180
    east = (((x + 1) * 360) / mapSize) - 180

    Dim efactor As Double = Math.Exp((0.5 - y / mapSize) * 4 * Math.PI)
    north = (Math.Asin((efactor - 1) / (efactor + 1))) * (180 / Math.PI)

    efactor = Math.Exp((0.5 - (y + 1) / mapSize) * 4 * Math.PI)
    south = (Math.Asin((efactor - 1) / (efactor + 1))) * (180 / Math.PI)
End Sub

Next we will need to update the GetTileAsStreamAsync method so that it makes it generates the correct tile URL when the IsWMS property is set to true. WMS services require bounding box information to be specified in the format: “[West],[South],[East],[North]”. We will add support for a new placeholder value in the tile URL, “ {boundingbox} ”, which we will replace with the required bounding box information. In the GetTileAsStreamAsync method update the line of code that generates the url value with the following code:

 string url;

if (IsWMS)
{
    double north, south, east, west;
    TileXYZoomToBBox(x, y, zoom, out north, out south, out east, out west);
    url = _tileUrl.Replace("{boundingbox}", string.Format("{0:N5},{1:N5},{2:N5},{3:N5}", west, south, east, north));
}
else
{
    url = _tileUrl.Replace("{x}", x.ToString()).Replace("{y}", y.ToString()).Replace("{zoomlevel}", zoom.ToString()).Replace("{quadkey}", quadkey);
}

If using Visual Basic use this code:

 Dim url As String

If IsWMS Then
    Dim north As Double, south As Double, east As Double, west As Double
    TileXYZoomToBBox(x, y, zoom, north, south, east, west)
    url = _tileUrl.Replace("{boundingbox}", String.Format("{0:N5},{1:N5},{2:N5},{3:N5}", west, south, east, north))
Else
    url = _tileUrl.Replace("{x}", x.ToString()).Replace("{y}", y.ToString()).Replace("{zoomlevel}", zoom.ToString()).Replace("{quadkey}", quadkey)
End If

You could then implement a WMS tile layer by modifying the code in the MainPage.xaml.cs file for loading the tile layer to the following:

 var tileSource = new TileDataSourceWithOpacity("https://firms.modaps.eosdis.nasa.gov/wms/?SERVICE=WMS&VERSION=1.1.1&REQUEST=GetMap&LAYERS=fires48&width=256&height=256&BBOX={boundingbox}", 100);
tileSource.IsWMS = true;

var tileLayer = new MapTileSource(tileSource);
tileLayer.IsTransparencyEnabled = true;

MyMap.TileSources.Add(tileLayer);

If using Visual Basic the code would look like this:

 Dim tileSource = New TileDataSourceWithOpacity("https://firms.modaps.eosdis.nasa.gov/wms/?SERVICE=WMS&VERSION=1.1.1&REQUEST=GetMap&LAYERS=fires48&width=256&height=256&BBOX={boundingbox}", 200)
tileSource.IsWMS = True

Dim tileLayer = New MapTileSource(tileSource)
tileLayer.IsTransparencyEnabled = True

MyMap.TileSources.Add(tileLayer)

The URL in this example retrieves tiles from NASA’s active fire in the last 48 hours WMS service. If you run this code in your application you should see a lot of red areas appear on the map where there where fires.

clip_image008

As mentioned at the beginning of this blog post the full source code can be found in the MSDN Code Samples here.