Monday, February 4, 2013

Mapping with D3js: Canvas or SVG?

I am using this blog to learn new stuff about mapping. I have been testing Kartograph, and now I'm starting with d3.js.
I wanted to post a first tutorial about mapping with it, but I have found one which, in my opinion,  has everything: http://bost.ocks.org/mike/map/ Besides, the entry links with many good examples.
So I will post how to display a rotating globe, with the two possibilities: SVG and Canvas. I didn't know how to do it, and haven't found a tutorial about that.
As usual, all the code is available at GitHub, where you can find also all the working examples:

Getting the data

In this case, I have used the file used in the d3.js docs. The data must be in the TopoJSON format to work with the example. How to convert a regular GeoJSON into a TopoJSON is also covered in the recommended article.

Basic map using SVG

SVG is an XML vector format, so all the lines and polygons drawn are kept as shapes and can be, therefore, manipulated and styled after their creation.
In this example, a map like the one at the picture above is generated.
var diameter = 320,

var projection = d3.geo.orthographic()
.clipAngle(90)
.rotate([10,0,0]);

var svg = d3.select("#map").append("svg")
.attr("width", diameter)
.attr("height", diameter);

var path = d3.geo.path()
.projection(projection);

d3.json("./world-110m.json", function(error, world) {

var globe = {type: "Sphere"};
svg.append("path")
.datum(globe)
.attr("class", "foreground")
.attr("d", path);

var land = topojson.object(world, world.objects.land);

svg.insert("path")
.datum(topojson.object(world, world.objects.land))
.attr("class", "land")
.attr("d", path);

});


1. The projection variable is set using an ortographic projection, just to see the globe.
1. The scale is set from the radius to fit all the globe in the SVG size.
2. The translate method is a bit tricky. If it's not used, the center of the globe will be at the upper-left corner.
3. The clipAngle method forces the script to stop drawing at 90 grades. If the angle is not set, the land that is supposed to be hidden is drawn. Just try changing the value.
4. The rotate method is not necessary, but I have used it to show how it works. The array represents the three axis. So in this case the globe is rotated in the east-west direction.
2. To create the SVG element, the div with the id=map is selected, and the size is set to the diameter of the globe so the globe fits the space.
3. The path element is created, with the projection defined before. This is the object that will be used to project the geographic coordinates into the SVG coordinates.
4. Then, the TopoJSON file is loaded, and only after this is done, the map is drawn.
5. And here is where the map is drawn. First, the globe is drawn, with a dark border and a blue background that represents the sea. Note that the class foreground is assigned. At the top of the file a style is defined for this class, the same way than in a regular html element.
Note how a globe is defined, as a sphere type and assigning it with the datum method.
6. Then the land elements of the TopoJSON are drawn (using the datum method), using land as the class.

Basic map using Canvas

The canvas element is part of HTML5, and allows to draw elements on a raster. It's different from SVG, since once a shape is drawn, there is no way to modify it or style it. And no event handlers on every shape can be set. The memory use of a Canvas is then much lower than the one used by the same drawing using SVG, since there is no DOM to handle.
The Canvas element is better for non-static images, like a rotating globe. This is why most of the map animatinos use the Canvas option.
The map generated in this example is exactly the same than the SVG:
var diameter = 320,

var projection = d3.geo.orthographic()
.clipAngle(90)
.rotate([10,0,0]);

var canvas = d3.select("#map").append("canvas")
.attr("width", diameter)
.attr("height", diameter);

var path = d3.geo.path()
.projection(projection);

d3.json("./world-110m.json", function(error, world) {
var land = topojson.object(world, world.objects.land),
globe = {type: "Sphere"};
context = canvas.node().getContext("2d");

context.strokeStyle = '#766951';

context.fillStyle = '#d8ffff';
context.beginPath(), path.context(context)(globe), context.fill(), context.stroke();

context.beginPath(), path.context(context)(land), context.fill(), context.stroke();

});

The basic parts are more or less the same, but:
1. Of course, no SVG element is generated, but a canvas one
2. The stroke and fill colors are set before drawing the elements using strokeStyle and fillStyle. A light blue for the sea and a light brown for the land.
3. Every time a new kind of element has to be drawn, a path is created with beginPath, and a projection is assigned to it.
4. The strokes and polygons are drawn using different methods (fill and stroke)

Rotating map using SVG

One of the cool things about D3.js is to make things move. In this example, the projection will be given a rotation to the globe seems to be rotating. The JavaScript part, then must be changed this way:

var diameter = 320,
velocity = .01,
then = Date.now();

var projection = d3.geo.orthographic()
.clipAngle(90);

var svg = d3.select("#map").append("svg")
.attr("width", diameter)
.attr("height", diameter);

var path = d3.geo.path()
.projection(projection);

var globe = {type: "Sphere"};
svg.append("path")
.datum(globe)
.attr("class", "foreground")
.attr("d", path);

d3.json("./world-110m.json", function(error, world) {

var land = topojson.object(world, world.objects.land),
globe = {type: "Sphere"};

svg.insert("path", ".graticule")
.datum(topojson.object(world, world.objects.land))
.attr("class", "land")
.attr("d", path);

d3.timer(function() {
var angle = velocity * (Date.now() - then);
projection.rotate([angle,0,0]);
svg.selectAll("path")
.attr("d", path.projection(projection));
});

});

1. The SVG paths are inserted the same way, but a timer is set after appending the land zones (the globe stays with the same shape, so no rotatino is needed)
2. The timer changes the angle in the step defined in the velocity variable, and tht projection is changed to this angle.
3. The path is modified with the path that has the new projection.
Using SVG, no path has to be re-drawn.

Rotating map using Canvas

The main difference in the example using Canvas is that all the picture has to be drawn with each rotation:
var diameter = 320,
velocity = .01,
then = Date.now();

var projection = d3.geo.orthographic()
.clipAngle(90);

var canvas = d3.select("#map").append("canvas")
.attr("width", diameter)
.attr("height", diameter);

var path = d3.geo.path()
.projection(projection);

d3.json("./world-110m.json", function(error, world) {
var land = topojson.object(world, world.objects.land),
globe = {type: "Sphere"};

d3.timer(function() {
var angle = velocity * (Date.now() - then);
projection.rotate([angle,0,0]);

context = canvas.node().getContext("2d");
context.clearRect(0, 0, diameter, diameter);

context.strokeStyle = '#766951';

context.fillStyle = '#d8ffff';
context.beginPath(), path.context(context)(globe), context.fill();
context.beginPath(), path.context(context)(globe), context.stroke();

context.beginPath(), path.context(context)(land), context.fill();
context.beginPath(), path.context(context)(land), context.stroke();

});
});
2. In each step, the angle is calculated like in the SVG, but then, all the Canvas is cleared with the method clearRect.
3. The map is re-drawn again like in the static example.

1. I want to use your example offline, but I get an error running with the Chrome-Browser (Firefox-Browser is o.k.).
My starting folder begins with 'file:///C:/', the file 'world-110m.json' is in my starting folder.

The inducing sourceline is:
queue()
.defer(d3.json, "world-110m.json")
...

The error message is:
XMLHttpRequest cannot load file:///C:/ ... /world-110m.json. Cross origin requests are only supported for HTTP.

2. You are trying run the code without a web server, if I understand you. This usually fails, because the browser is not capable to open other resources called from the code.
You should run the application from a web server. If you are unsing Windows, as I think you are, you can try with easyphp: http://www.easyphp.org/ which installs you a web server, but only runs when you need it.

I hope it helps.

3. But the User should run the WebApp without a server.
Isn't that possible?

4. Well, as far as I know, depends on the case. If you don't make AJAX calls, there is no problem, but if you use AJAX, the browser doesn't let to do it for security reasons. I don't know if there is a way to override the restriction.

5. I don't make AJAX calls, but where is the solution without a server?
(you write only: 'there is no problem')

6. json runs with a server. i used IIS, before you run that code you must simple tell the IIS to run the json file on that server inside the MIME type, once it is properly configured it will run using FF,chrome,Opera,Safari but not in IE, either the latest version of IE cant read the json file..

7. This is the first i read the blog like yours..I your blog i found all my queries..I really appreciate your effort..This information is useful for me as well as for users also...

8. Can anyone help me add markers on the Globe drawn using canvas?