Thursday, March 28, 2013

Creating a Bing Maps Canvas TileLayer (Part 1 - Introduction)

Part 1 - Introduction
Part 2 - Bing Maps Module

I've already done some experiments with Canvas on Bing Maps. You can check the blog post here and the end-result here.

The approach that I've shown in that post uses a single canvas on top of the map and updates it every time the map view changes. This allows for some really funky results, but it's a tad intensive on the CPU and sometimes that just goes to waste if there's no change on the data or its representation.

What I would like is the ability to do something like I've done previously with Leaflet: creating a client-side canvas tile layer. Thus, the tiles would be created dynamically and Bing Maps would treat them as regular images, leveraging all its goodness regarding panning, zooming and such.


I've actually managed to do it in a really, really simple way:
  • Creating a TileSource in Bing Maps that uses a custom UriConstructor
  • Creating a TileLayer with that TileSource
  • The Custom UriConstructor, instead of using a server-url, returns a Data-Uri, built using Canvas
That might seem a little bit confusing, so let me show you the full skeleton of this implementation:
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" 
     "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html>
  <head>
    <title>Bing Maps Canvas Layer</title>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
  </head>
  <body onload="loadMap();"> 
      
    <div id='mapDiv' style="width:100%; height: 100%;"></div>
    <canvas id="tileCanvas" width="256px" height="256px"></canvas>
      
    <script type="text/javascript"
       src="http://ecn.dev.virtualearth.net/mapcontrol/mapcontrol.ashx?v=7.0">
    </script>
    <script type="text/javascript">
      function loadMap()
      { 
        MM = Microsoft.Maps;
        var map = new MM.Map(document.getElementById("mapDiv"), {
                center: new Microsoft.Maps.Location(55, 20),
                mapTypeId: Microsoft.Maps.MapTypeId.road,
                zoom: 4,
                credentials:"YOUR CREDENTIALS HERE"});
    
        var tileSource = new MM.TileSource({uriConstructor: getTilePath});
        var canvasTilelayer= new MM.TileLayer({ mercator: tileSource});
        map.entities.push(canvasTilelayer);   
      }
  
      function getTilePath(tile) {
        var canvas = document.getElementById('tileCanvas');
        var context = canvas.getContext('2d');
        context.clearRect (0,0, 256, 256);
        drawTile(context, tile);
        return canvas.toDataURL();
      }
    
      function drawTile(context, tile) {
        //use the canvas context to draw stuff...
      }
</script>
</body>
</html>
Yep, as simple as that. Let me explain it a little bit better. Bing Maps has a class called TileLayer which provides the ability to overlay additional layers on top of the map. It's typically used like this (example from the above link):
 // Create the tile layer source
 var tileSource = new Microsoft.Maps.TileSource({uriConstructor:  
     'http://www.microsoft.com/maps/isdk/ajax/layers/lidar/{quadkey}.png'});

 // Construct the layer using the tile source
 var tilelayer= new Microsoft.Maps.TileLayer({ mercator: tileSource, opacity: .7 });

 // Push the tile layer to the map
 map.entities.push(tilelayer);
You create a TileSource, which is typically an URL with a placeholder for the tile identification, and pass it to a TileLayer which is then added to the map.

What some people don't know is that the "uriConstructor" of TileSource may in fact be a function, and each tile may have a custom programmatic URL. For example, to load Google Maps compatible tiles in Bing Maps (instead of using the quadkey) one could change the above example to:
 // Create the tile layer source
 var tileSource = new Microsoft.Maps.TileSource({uriConstructor: getTilePath});

 // Construct the layer using the tile source (remains unchanged)
 var tilelayer= new Microsoft.Maps.TileLayer({ mercator: tileSource, opacity: .7 });

 // Push the tile layer to the map
 map.entities.push(tilelayer);

 function getTilePath(tile) {
   //Assuming the tiles exist as "http://somedain/{z}/{x}/{y}.png"
   return "http://somedomain/" + tile.levelOfDetail + "/" + tile.x + "/" + tile.y + ".png";

 }
Now, I want to create an image dynamically in client-side inside "getTilePath" and return a local URL to it. For that I'm using something called a Data URI Scheme. It basically allows one to include data in-line as if it was loaded from an external source associated with an URI. For example, an image may be declared in HTML as:
<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA
AAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO
9TXL0Y4OHwAAAABJRU5ErkJggg==" alt="Red dot">

The final piece of the puzzle is generating this kind of URI scheme on the fly. Fortunately canvas already provides support for that and you just need to call the "Canvas.toDataURL" function to get a working URI
Ok, let me change the "drawTile" to display something. For each tile I'll show a square with the Z/X/Y of it.
function drawTile(context, tile) {

    var tileDescription = "(" + tile.levelOfDetail + ", " 
                              + tile.x + ", " 
                              + tile.y + ")";
  
    context.font = "bold 18px Arial";
    context.fillStyle = "navy";
    context.textAlign = 'center';
    context.fillText(tileDescription, 128, 128);
    
    context.setLineDash([5,2]);
    context.strokeRect(0,0,256,266);
}


Online demo here

Obviously you wouldn't need Canvas for something as simple as drawing squares and text. Let's make something a little bit more interesting, like creating some fancy canvas based pushpins (with a gradient, shadow and a dynamic text). So, assuming an array of points declared as:
var points = [
 { lat: 40.723697, lon: -8.468368 },
 { lat: 37.829701, lon: -7.943891 },
 { lat: 41.552968, lon: -8.309867 },
 { lat: 41.509392, lon: -6.859326 },
 { lat: 39.946475, lon: -7.50164 },
 { lat: 40.204391, lon: -8.33593 },
 { lat: 38.603914, lon: -7.841794 },
 { lat: 37.243653, lon: -8.131754 },
 { lat: 40.641346, lon: -7.229598 },
 { lat: 39.717187, lon: -8.775258 },
 { lat: 38.998077, lon: -9.163589 },
 { lat: 39.190066, lon: -7.620413 },
 { lat: 41.224799, lon: -8.352842 },
 { lat: 39.293463, lon: -8.477529 },
 { lat: 38.318513, lon: -8.653012 },
 { lat: 41.877865, lon: -8.507078 },
 { lat: 41.555004, lon: -7.631723 },
 { lat: 40.798902, lon: -7.870874 }];
The drawTile function could then be something like:
function drawTile(context, tile) {

 var z = tile.levelOfDetail;
 var x = tile.x;
 var y = tile.y;
   
 //Get the mercator tile coordinates
 var tilePixelX = x * 256;
        var tilePixelY = y * 256; 
   
 var radius = 15;
  
 for (var i = 0; i < points.length; i++) {

  // get pixel coordinate
  var p = latLongToPixelXY(points[i].lat, points[i].lon, z);
  
  // canvas pixel coordinates
  var x = (p.x - tilePixelX) | 0;
  var y = (p.y - tilePixelY) | 0;

  // Circle
  context.beginPath();
  context.arc(x, y, radius, 0, 2 * Math.PI, false);

  // Fill (Gradient)
  var grd = context.createRadialGradient(x, y, 5, x, y, radius);
  grd.addColorStop(0, "#8ED6FF");
  grd.addColorStop(1, "#004CB3");
  context.fillStyle = grd;

  // Shadow
  context.shadowColor = "#666666";
  context.shadowBlur = 5;
  context.shadowOffsetX = 7;
  context.shadowOffsetY = 7;
  context.fill()

  context.lineWidth = 2;
  context.strokeStyle = "black";
  context.stroke();

  // Text
  context.lineWidth = 1;
  context.fillStyle = "#000000";
  context.lineStyle = "#000000";
  context.font = "12px sans-serif";
  context.textAlign = "center";
  context.textBaseline = "middle";
  context.fillText(i + 1, x, y);

 }
}

Voilá. 18 points corresponding to the Portuguese Continental Districts.

Online demo here

This could be optimized as each tile is looking at all the points instead of just the ones inside its bounds. Anyway, just talking about 18 points here so not that important.

Now, what are the caveats of using this approach?
  •  Being Canvas based you don't have out-of-the box support from IE < 9 (although you can use something like excanvas to add canvas support)
  • The Data URI Scheme has supposedly some limitations in IE < 9 (haven't tried it)
  • The generated tiles are not cached by the browser, thus being regenerated each time Bing Maps requests the tiles (but I'll address this in a second)
Caching tiles

If you want to cache the images you need to use a Javascript library for that, like JsCache. It provides a simple LRU mechanism that also uses Local Storage if supported by the Browser.

Quick and dirty implementation. After importing the JS file declare the js cache:
var cache = new Cache();
Now, instead of generating the tile try to fetch it from cache. Thus, our "drawTile" function will be unchanged  but "getTilePath" will become:
function getTilePath(tile) {
    var tileKey = tile.levelOfDetail + "_" + tile.x + "_" + tile.y;
  
    var dataUrl = cache.getItem(tileKey);
   
    if(dataUrl == undefined) {  
        var canvas = document.getElementById('tileCanvas');
 var context = canvas.getContext('2d');
 context.clearRect (0,0, 256, 256);
 drawTile(context, tile);
 dataUrl = canvas.toDataURL();
 cache.setItem(tileKey, dataUrl);
    } 
   
    return dataUrl;
}

Online demo here

For this particular demo the performance improvement is not that great, but could make a lot of difference in more complex scenarios.


In my next post I'm going to tweak this code a little bit, create a Bing Maps Module out of it, including these and other examples.

2 comments:

  1. I was trying to integrate map apps with Bing, but was facing some issues in the process. I researched about it, but couldn't get exact solution. but this coding given here is helping me out. Hopefully, i should get it done. This is a great sharing. Thanks.
    County Apps

    ReplyDelete
  2. This comment has been removed by the author.

    ReplyDelete