Friday, February 10, 2012

Bing Maps and HTML5 (Part 2 - Canvas)


In my previous post I've created a very simple example of a map that fetches a bunch of coordinates from a Node.js server using HTML5 websockets.

As promised, I'm going to extend that example so that much more coordinates are fetched and displayed.

I've used a MongoDB database and loaded it with most of the Portuguese localities, on a total of 142912 coordinates. As one may suspect, it's impossible to draw so many pushpins on a map without resorting to some type of clustering technique. I won't do the clustering (at least on this post), and will instead draw them using one of the HTML5 goodies: the Canvas element.




Note: I've extended the example of my previous post a little bit so that some log messages are shown with the duration of each task.

So, the map is loaded and then the points are fetched from the server. The end result is the following:


The points take almost 10 seconds to be fetched and "stored" in a javascript array in client-side. Not particularly impressive but they will only be loaded once. For so many coordinates I should follow a different approach, which will be the topic for a future post.

Anyway, after each map event (pan, zoom) a canvas is drawn on top of the map. At this zoom level the following image is produced.


It takes 750 ms to display a canvas on top of the map with all the localities.

To optimize the process a little bit, I only draw the coordinates that fit inside the map boundaries. So, when zooming in the redraw becomes faster...


And faster...


75 ms to iterate all the points and draw 10.000 of them. Not that bad.

Here is the complete source-code.

Server
var mongo = require('mongoskin');

var io = require('socket.io').listen(88);

io.sockets.on('connection', function (socket) {

    var coordinates = new Array();

    mongo.db('localhost:27017/test')
         .collection('coordinates')
         .find()
         .toArray(function (err, items) {
             socket.emit('coordinates', items);
         });
});
I've removed the redundant serialization to JSON on the server and the deserialization on the client. Not sure if there was any performance gain but heck, it's certainly not worse, and gives a nice sense of "compatibility" between the client and the server.
Also, the coordinates are fetched from a pre-populated MongoDB database. I've used the Mongoskin module, as it's much more user friendly than the default one.

Client
<html>
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
    <title>NodeJS + WebSockets (using socket.io)</title>
    <style type="text/css">
        .map
        {
            width: 400px;
            height: 400px;
            position: relative;
        }
        
        ul
        {
            list-style: none;
            font-size: 10px;
        }
    </style>
    <script type="text/javascript" src="socket.io.js"></script>
    <script type="text/javascript" src="jquery-1.4.2.js"></script>
    <script type="text/javascript" src="lib/proj4js-combined.js"></script>
    <script type="text/javascript">

        var canvas;
        var points;
        var MM;
        var map;
        var bingMapsURL =
                'http://ecn.dev.virtualearth.net/mapcontrol/mapcontrol.ashx?v=7.0';

        $(function () {

            addMessage("Loading map...");

            var date1 = new Date().getTime();

            $.ajax({
                url: bingMapsURL,
                dataType: 'jsonp',
                jsonp: 'onscriptload',
                success: function (data) {

                    MM = Microsoft.Maps;

                    map = new MM.Map($('#mapDiv')[0], {
                        credentials: "BING MAP KEY GOES HERE",
                        showCopyright: false,
                        showDashboard: false,
                        mapTypeId: Microsoft.Maps.MapTypeId.road,
                        showLogo: false,
                        showMapTypeSelector: false,
                        showScalebar: false,
                        center: new Microsoft.Maps.Location(39.5, -8.5),
                        zoom: 6
                    });

                    var date2 = new Date().getTime();

                    addMessage("Map Loaded", date2-date1);

                    addMessage("Loading Points...");

                    var socket = io.connect('http://localhost:88');
                    socket.on('coordinates', function (items) {

                        points = items;

                        var date3 = new Date().getTime();

                        addMessage(points.length + " Points Loaded", date3-date2);
                        loadCanvas();

                    });
                }
            });
        });


        function loadCanvas() {

            canvas = document.createElement('canvas');
            canvas.id = 'pointscanvas'
            canvas.style.position = 'relative';
            canvas.height = map.getHeight();
            canvas.width = map.getWidth();

            var mapDiv = map.getRootElement();
            mapDiv.parentNode.lastChild.appendChild(canvas);

            Microsoft.Maps.Events.addHandler(map, 'viewchangestart', clearCanvas);
            Microsoft.Maps.Events.addHandler(map, 'viewchangeend', drawCanvas);
        }

        function clearCanvas() {

            var context = canvas.getContext("2d");
            context.clearRect(0, 0, canvas.width, canvas.height);
        }

        function drawCanvas() {

            var date1 = new Date().getTime();

            var context = canvas.getContext("2d");
            var bounds = map.getBounds();

            var maxLatitude = bounds.getNorth();
            var minLatitude = bounds.getSouth();
            var maxLongitude = bounds.getEast();
            var minLongitude = bounds.getWest();

            var imageData = context.createImageData(canvas.width, canvas.height);

            var pointsDrawn = 0;

            for (var i = 0; i < points.length; i++) {

                var loc = points[i];

                //discard coordinates outside the current map view
                if (loc.lat >= minLatitude && loc.lat <= maxLatitude && 
                    loc.lon >= minLongitude && loc.lon <= maxLongitude) {

                    pointsDrawn++;
                    var pixelCoordinate = map.tryLocationToPixel(
                        new MM.Location(loc.lat, loc.lon), 
                        MM.PixelReference.control);
                    var x = (0.5 + pixelCoordinate.x) | 0;
                    var y = (0.5 + pixelCoordinate.y) | 0;
                    setPixel(imageData, x, y, 255, 0, 0, 255);
                }
            }

            var date2 = new Date().getTime();
            addMessage(pointsDrawn + " Points Drawn", date2-date1);
            context.putImageData(imageData, 0, 0);
        }

        function setPixel(imageData, x, y, r, g, b, a) {

            //find the pixel index based on it's coordinates
            index = (x + y * imageData.width) * 4;

            imageData.data[index + 0] = r;
            imageData.data[index + 1] = g;
            imageData.data[index + 2] = b;
            imageData.data[index + 3] = a;
        }

        function addMessage(message, milliseconds) {

            if (milliseconds === undefined) {
            }
            else {
                message = message + ' [' + milliseconds.toString() + ' ms]';
            }

            $("#lblStatus").append('<li>' + message + '</li>');

        }
    
    </script>
</head>
<body>
    <div id="mapDiv" class="map" />
    <ul id="lblStatus" style="float: left; margin-left: 420px; width: 300px">
    </ul>
</body>
</html>

A canvas is placed on top of the map on the "loadCanvas" function. Bing Maps V7 does not support (yet) custom tiles on client-side, thus the "hack".
canvas = document.createElement('canvas');
canvas.id = 'pointscanvas'
canvas.style.position = 'relative';
canvas.height = map.getHeight();
canvas.width = map.getWidth();

var mapDiv = map.getRootElement();
mapDiv.parentNode.lastChild.appendChild(canvas);
The drawing is triggered when the viewport changes:
Microsoft.Maps.Events.addHandler(map, 'viewchangestart', clearCanvas);
Microsoft.Maps.Events.addHandler(map, 'viewchangeend', drawCanvas);
The painting algorithm is very straightforward:
  • Clear canvas
  • Iterate coordinates
  • Check if coordinate is in-view
  • Convert geographic coordinate to pixel coordinate
  • Fill the corresponding pixel on an ImageData
  • After iterating send the whole ImageData to the canvas Context
The most important part of the code is:
var pixelCoordinate = map.tryLocationToPixel(
    new MM.Location(loc.lat, loc.lon), 
    MM.PixelReference.control); 

var x = (0.5 + pixelCoordinate.x) | 0;
var y = (0.5 + pixelCoordinate.y) | 0;
setPixel(imageData, x, y, 255, 0, 0, 255);
The geographic coordinates are converted and rounded (using a trick that I found on jsperf).


In my next post I'm going to handle some of the bottlenecks of this example. The two main problems are:
  • The retrieval of the data. 10 seconds is just too much
  • The conversion from geographical coordinates to pixel coordinates takes 95% of the time taken to draw the canvas. Much room for improvement here
Also, I have many different ideas to try out, and I expect to try some of them and post here my results.


Anyway, this was supposed to be an all-around tech blog, so expect some posts regarding misc stuff.

Go to Part 3

No comments:

Post a Comment