Bing mapcontrol Offline Tiles Solution #WPDev #WP7Dev

Please click here to download the source

Lets start by looking at Bing Maps control. We are going to control map loading so we need to ensure that Map.Mode is set to MercatorMode. I have set the TileSource to a custom tile source.

<my:Map.Mode>
    <myCore:MercatorMode></myCore:MercatorMode>
</my:Map.Mode>

<my:MapLayer x:Name="mlTiles">                
    <my:MapTileLayer>
        <my:MapTileLayer.TileSources>
            <locals:BingMapTileSource></locals:BingMapTileSource>
        </my:MapTileLayer.TileSources>
    </my:MapTileLayer>
</my:MapLayer>

now we define tile sources we are going to use. For this example, lets look at bing maps tile source. I have used this mechanism with OpenStreet, OSMRender, Google and Nokia maps as well.

public class BingMapTileSource : TileSource
{
    public BingMapTileSource() : base("http://ecn.t2.tiles.virtualearth.net/tiles/r{quadkey}.png?g=838") { }

    public override System.Uri GetUri(int x, int y, int zoomLevel)
    {
        OfflineTile ot = new OfflineTile(base.GetUri(x, y, zoomLevel), x, y, zoomLevel, TileSourceDef.OpenStreetMaps);
        TileManager.EnqueueRequest(ot);
        return null;
    }
}

As you can see I have a uri for tile source. and I use that to create instance of OfflineTile class and I queue it up.

public enum TileSourceDef
{
    Bing,
    OpenStreetMaps,
}

public class OfflineTile
{
    public string tilePath;
    public Location location;
        
    public bool Ready;

    public OfflineTile(Uri sourceUri, int x, int y, int zoom, TileSourceDef tileSource)
    {
        double lat, lon;
        TileSystem.TileXYToLatLong(x, y, zoom, out lat, out lon);
        location = new Location() { Latitude = lat, Longitude = lon }; 
        IsolatedStorageFile isf = IsolatedStorageFile.GetUserStoreForApplication();
        string destDir = string.Format("/Offline/{0}", tileSource);
        if (!isf.DirectoryExists(destDir))
            isf.CreateDirectory(destDir);

        string fileName = Path.Combine(destDir, String.Format("{0}.png", TileSystem.TileXYToQuadKey(x, y, zoom)));

        tilePath = fileName;

        if (!isf.FileExists(fileName))
        {
            WebClient wc = new WebClient();
            wc.OpenReadCompleted += (s, ev) =>
            {
                if (ev.Error == null)
                {
                    using (IsolatedStorageFileStream output = isf.OpenFile(fileName, FileMode.OpenOrCreate))
                        ev.Result.CopyTo(output);

                    this.Ready = true;
                }
            };
            wc.OpenReadAsync(sourceUri);
        }
        else
        {
            this.Ready = true;
        }
    }

    public Image GetTileImage()
    {
        IsolatedStorageFile isf = IsolatedStorageFile.GetUserStoreForApplication();
        BitmapImage bi = new BitmapImage();
            bi.SetSource(isf.OpenFile(this.tilePath, FileMode.Open, FileAccess.Read));

        return new Image() { Source = bi, Height = bi.PixelHeight, Width = bi.PixelWidth };
    }
}

I use another class to translate from X, Y and Z to Latitude and Longitude. There where TileSystem comes into play. I found this in one of the msdn samples and I added just 1 method to it. The method is TileSystem.TileXYToLatLong. In the tile source, the map already passes X, Y. Took me a while to figure out that it was TileX and TileY and that I needed to use Latitude and Longitudes when inserting the tile image. As you can see I used methods already present in the class to do the dirty work 🙂

static class TileSystem
{
    private const double EarthRadius = 6378137;
    private const double MinLatitude = -85.05112878;
    private const double MaxLatitude = 85.05112878;
    private const double MinLongitude = -180;
    private const double MaxLongitude = 180;


    /// <summary>
    /// Clips a number to the specified minimum and maximum values.
    /// </summary>
    /// <param name="n">The number to clip.</param>
    /// <param name="minValue">Minimum allowable value.</param>
    /// <param name="maxValue">Maximum allowable value.</param>
    /// <returns>The clipped value.</returns>
    private static double Clip(double n, double minValue, double maxValue)
    {
        return Math.Min(Math.Max(n, minValue), maxValue);
    }



    /// <summary>
    /// Determines the map width and height (in pixels) at a specified level
    /// of detail.
    /// </summary>
    /// <param name="levelOfDetail">Level of detail, from 1 (lowest detail)
    /// to 23 (highest detail).</param>
    /// <returns>The map width and height in pixels.</returns>
    public static uint MapSize(int levelOfDetail)
    {
        return (uint)256 << levelOfDetail;
    }



    /// <summary>
    /// Determines the ground resolution (in meters per pixel) at a specified
    /// latitude and level of detail.
    /// </summary>
    /// <param name="latitude">Latitude (in degrees) at which to measure the
    /// ground resolution.</param>
    /// <param name="levelOfDetail">Level of detail, from 1 (lowest detail)
    /// to 23 (highest detail).</param>
    /// <returns>The ground resolution, in meters per pixel.</returns>
    public static double GroundResolution(double latitude, int levelOfDetail)
    {
        latitude = Clip(latitude, MinLatitude, MaxLatitude);
        return Math.Cos(latitude * Math.PI / 180) * 2 * Math.PI * EarthRadius / MapSize(levelOfDetail);
    }



    /// <summary>
    /// Determines the map scale at a specified latitude, level of detail,
    /// and screen resolution.
    /// </summary>
    /// <param name="latitude">Latitude (in degrees) at which to measure the
    /// map scale.</param>
    /// <param name="levelOfDetail">Level of detail, from 1 (lowest detail)
    /// to 23 (highest detail).</param>
    /// <param name="screenDpi">Resolution of the screen, in dots per inch.</param>
    /// <returns>The map scale, expressed as the denominator N of the ratio 1 : N.</returns>
    public static double MapScale(double latitude, int levelOfDetail, int screenDpi)
    {
        return GroundResolution(latitude, levelOfDetail) * screenDpi / 0.0254;
    }



    /// <summary>
    /// Converts a point from latitude/longitude WGS-84 coordinates (in degrees)
    /// into pixel XY coordinates at a specified level of detail.
    /// </summary>
    /// <param name="latitude">Latitude of the point, in degrees.</param>
    /// <param name="longitude">Longitude of the point, in degrees.</param>
    /// <param name="levelOfDetail">Level of detail, from 1 (lowest detail)
    /// to 23 (highest detail).</param>
    /// <param name="pixelX">Output parameter receiving the X coordinate in pixels.</param>
    /// <param name="pixelY">Output parameter receiving the Y coordinate in pixels.</param>
    public static void LatLongToPixelXY(double latitude, double longitude, int levelOfDetail, out int pixelX, out int pixelY)
    {
        latitude = Clip(latitude, MinLatitude, MaxLatitude);
        longitude = Clip(longitude, MinLongitude, MaxLongitude);

        double x = (longitude + 180) / 360;
        double sinLatitude = Math.Sin(latitude * Math.PI / 180);
        double y = 0.5 - Math.Log((1 + sinLatitude) / (1 - sinLatitude)) / (4 * Math.PI);

        uint mapSize = MapSize(levelOfDetail);
        pixelX = (int)Clip(x * mapSize + 0.5, 0, mapSize - 1);
        pixelY = (int)Clip(y * mapSize + 0.5, 0, mapSize - 1);
    }



    /// <summary>
    /// Converts a pixel from pixel XY coordinates at a specified level of detail
    /// into latitude/longitude WGS-84 coordinates (in degrees).
    /// </summary>
    /// <param name="pixelX">X coordinate of the point, in pixels.</param>
    /// <param name="pixelY">Y coordinates of the point, in pixels.</param>
    /// <param name="levelOfDetail">Level of detail, from 1 (lowest detail)
    /// to 23 (highest detail).</param>
    /// <param name="latitude">Output parameter receiving the latitude in degrees.</param>
    /// <param name="longitude">Output parameter receiving the longitude in degrees.</param>
    public static void PixelXYToLatLong(int pixelX, int pixelY, int levelOfDetail, out double latitude, out double longitude)
    {
        double mapSize = MapSize(levelOfDetail);
        double x = (Clip(pixelX, 0, mapSize - 1) / mapSize) - 0.5;
        double y = 0.5 - (Clip(pixelY, 0, mapSize - 1) / mapSize);

        latitude = 90 - 360 * Math.Atan(Math.Exp(-y * 2 * Math.PI)) / Math.PI;
        longitude = 360 * x;
    }



    /// <summary>
    /// Converts pixel XY coordinates into tile XY coordinates of the tile containing
    /// the specified pixel.
    /// </summary>
    /// <param name="pixelX">Pixel X coordinate.</param>
    /// <param name="pixelY">Pixel Y coordinate.</param>
    /// <param name="tileX">Output parameter receiving the tile X coordinate.</param>
    /// <param name="tileY">Output parameter receiving the tile Y coordinate.</param>
    public static void PixelXYToTileXY(int pixelX, int pixelY, out int tileX, out int tileY)
    {
        tileX = pixelX / 256;
        tileY = pixelY / 256;
    }



    /// <summary>
    /// Converts tile XY coordinates into pixel XY coordinates of the upper-left pixel
    /// of the specified tile.
    /// </summary>
    /// <param name="tileX">Tile X coordinate.</param>
    /// <param name="tileY">Tile Y coordinate.</param>
    /// <param name="pixelX">Output parameter receiving the pixel X coordinate.</param>
    /// <param name="pixelY">Output parameter receiving the pixel Y coordinate.</param>
    public static void TileXYToPixelXY(int tileX, int tileY, out int pixelX, out int pixelY)
    {
        pixelX = tileX * 256;
        pixelY = tileY * 256;
    }

    public static void TileXYToLatLong(int tileX, int tileY, int zoomLevel, out double latitude, out double longitude)
    {
        int pixelX;
        int pixelY;

        TileXYToPixelXY(tileX, tileY, out pixelX, out pixelY);

        PixelXYToLatLong(pixelX, pixelY, zoomLevel, out latitude, out longitude);
    }

    /// <summary>
    /// Converts tile XY coordinates into a QuadKey at a specified level of detail.
    /// </summary>
    /// <param name="tileX">Tile X coordinate.</param>
    /// <param name="tileY">Tile Y coordinate.</param>
    /// <param name="levelOfDetail">Level of detail, from 1 (lowest detail)
    /// to 23 (highest detail).</param>
    /// <returns>A string containing the QuadKey.</returns>
    public static string TileXYToQuadKey(int tileX, int tileY, int levelOfDetail)
    {
        StringBuilder quadKey = new StringBuilder();
        for (int i = levelOfDetail; 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();
    }

        
    /// <summary>
    /// Converts a QuadKey into tile XY coordinates.
    /// </summary>
    /// <param name="quadKey">QuadKey of the tile.</param>
    /// <param name="tileX">Output parameter receiving the tile X coordinate.</param>
    /// <param name="tileY">Output parameter receiving the tile Y coordinate.</param>
    /// <param name="levelOfDetail">Output parameter receiving the level of detail.</param>
    public static void QuadKeyToTileXY(string quadKey, out int tileX, out int tileY, out int levelOfDetail)
    {
        tileX = tileY = 0;
        levelOfDetail = quadKey.Length;
        for (int i = levelOfDetail; i > 0; i--)
        {
            int mask = 1 << (i - 1);
            switch (quadKey[levelOfDetail - i])
            {
                case '0':
                    break;

                case '1':
                    tileX |= mask;
                    break;

                case '2':
                    tileY |= mask;
                    break;

                case '3':
                    tileX |= mask;
                    tileY |= mask;
                    break;

                default:
                    throw new ArgumentException("Invalid QuadKey digit sequence.");
            }
        }
 \   }
}

Finally lets see what actually happens to the queued tile. All it does is spawn off the request using ThreadPool

public class TileManager
{
    static Queue<OfflineTile> requestQueue = new Queue<OfflineTile>();
    public static Queue<OfflineTile> ResponseQueue = new Queue<OfflineTile>();

    static ManualResetEvent waitEvent;

    static volatile bool WaitWhileProcessing = false;

    public static void Initialise(ManualResetEvent mreWait)
    {
        waitEvent = mreWait;
    }

    public static void EnqueueRequest(OfflineTile ot)
    {
        requestQueue.Enqueue(ot);

        if (!WaitWhileProcessing)
        {
            WaitWhileProcessing = true;
            ThreadPool.QueueUserWorkItem(ProcessTiles);
        }
    }

    public static void ProcessTiles(Object stateInfo)
    {
        waitEvent.Set();
        while (requestQueue.Count > 0)
        {
            OfflineTile t = requestQueue.Peek();
            if (t.Ready)
            {
                t = requestQueue.Dequeue();

                //Dispatcher.BeginInvoke(() => mapLayer.AddChild(t.TileBitmap, new System.Device.Location.GeoCoordinate(t.y, t.X)));
                ResponseQueue.Enqueue(t);
            }
            else
                Thread.Sleep(10);
        }

        waitEvent.Reset();
        WaitWhileProcessing = false;
    }
}

Now in the code behind this is what I do. I spawn off another thread that monitors for any available tiles and adds them to UI.

ManualResetEvent mrt = new ManualResetEvent(false);
Thread t = null;

private void ManageMapTiles()
{
    while (true)
    {
        App.WaitEvent.WaitOne(10);

        if (TileManager.ResponseQueue.Count > 0)
        {
            OfflineTile ot = TileManager.ResponseQueue.Dequeue();
            Dispatcher.BeginInvoke(() => this.SetTile(ot));
        }
    }
}

private object lockobj = new object();

private void SetTile(OfflineTile ot)
{
    lock (lockobj)
    {
        IsolatedStorageFile isf = IsolatedStorageFile.GetUserStoreForApplication();
        BitmapImage bi = new BitmapImage();
        bi.SetSource(isf.OpenFile(ot.tilePath, FileMode.Open, FileAccess.Read));

        Image img = new Image() { Source = bi, Height = bi.PixelHeight, Width = bi.PixelWidth, Opacity = 1, Stretch = System.Windows.Media.Stretch.None };
        mlTiles.AddChild(img, new GeoCoordinate(ot.location.Latitude, ot.location.Longitude));
    }
}
private void PhoneApplicationPage_Loaded(object sender, RoutedEventArgs e)
{
    t = new Thread(new ThreadStart(ManageMapTiles));
    t.Start();

    watcher = new GeoCoordinateWatcher(GeoPositionAccuracy.High);
    watcher.MovementThreshold = 10;

    watcher.StatusChanged += new EventHandler<GeoPositionStatusChangedEventArgs>(watcher_StatusChanged);

    watcher.PositionChanged += new EventHandler<GeoPositionChangedEventArgs<GeoCoordinate>>(watcher_PositionChanged);

    // start up LocServ in bg; watcher_StatusChanged will be called when complete.
    new Thread(startLocServInBackground).Start();
}

now let me just tell you there is much optimisation that can be done and I did for GoA2B..its a shame I never released it. Instead of just one tile layer, you need to have multiple map layers one for each zoom levels and you show / hide desired (depending upon zoom) the correct layer.

9 thoughts on “Bing mapcontrol Offline Tiles Solution #WPDev #WP7Dev

  1. How can we deal with the expired tile from the MapLayer? because the tile picture will take all of us memory.
    Thanks.

    • mapcontrol automatically manages the images it loads. since you are manually feeding the same images I do not see why it would not unload those that are not in view any more.

      I’d suggest that you log the memory usage of app and give it a run. I tested go a2b for a while and it was okay on memory usage.

  2. It’s calling GetUri probably like a million times. I guess since you don’t return a simple uri but actually always load an Image this fills up the memory pretty quickly, at least in the wp8 emulator(immedeatly trhows outofmemory)

    • it doesn’t call it a million times 🙂 but it does call it many many times. i have stepped through the code so it couldn’t have been that bad.

      there is little use of that code with #wp8. we already have offline map control

      • but the new control doesn’t support custom layers(not without the base nokia layer always enabled). So it seems the depreceated bing control is the only option if we want a custom tilesource(like your openstreetmaps tilesource). There aren’t many thirdparty map controls out there either(I know only of one relatively expensive).

  3. Are the images added to the map layer be able to zoomed as normal base map? I have tested an short example, and find that the images only aligned correctly at integral zoom level.

  4. Pingback: How do I deal with this windows phone webclient asynchronous issue?

Leave a comment