Geographical shapes
GIS file formats
Data used in geographic information systems (GIS) include geometries like locations of places or the course of rivers and the borders of countries. This geographical information is stored in files, encoded in some standardized GIS file format. Traditionally, those 'files' were printed maps.
Digital GIS formats can be raster or vector formats. Raster formats store a grid of pixels in an image file. On screen they look like traditional printed maps and the level of detail is (more or less) independent of the impact on performance. Apps like Google Maps use raster image tiles and piece them together in the browser to form a map. Downloading image tiles form the web server, and downloading new tiles, each time the user pans or zooms in or out on the map, does have an impact on performance. You can use JavaScript libraries like Leaflet to create raster map applications.
D3 uses vector formats to create maps. Vector graphics can be zoomed in or out, without loss of quality, and therefore, without the need for new downloads. And mathematically defined geometric shapes make it a lot easier to implement interactivity and animation. Vector maps are generally not the best choice if the map needs to show a lot of detail.
A widely used GIS data vector format is shapefile. Web-friendly GIS vector formats are GeoJSON and its significantly more compact extension TopoJSON, both based on JSON. D3 works particularly well with GeoJSON.
Cartographic data files (shapefile, GeoJSON) of a lot of countries and regions can be found on the Internet, provided by governmental departments, universities or volunteer projects like Natural Earth. A quick way to view cartographic data files (shapefile, GeoJSON, TopoJSON etc.) is to open them in mapshaper.
Mapshaper can also export to multiple file formats, for instance, from shapefile to GeoJSON. Another way to convert shapefile to GeoJSON is to use shp2json. See Mike Bostock's article Command-Line Cartography for more information.
Geographical visualizations can also use a combination of raster maps and vector maps.
GeoJSON
GeoJSON is an open standard GIS vector format. It is based on JSON. JSON is an open standard file format to store and transmit data objects. JSON (JavaScript Object Notation) was derived from JavaScript, and is syntactically identical to a JavaScript objects and arrays structure.
A typical GeoJSON file is an object with a "FeatureCollection
" property and a
features
property defining an array of
feature objects.
The FeatureCollection
in the example below contains a features
array of 2 feature objects
named "square" and "dot".
A JSON example:
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {
"name": "square"
},
"geometry": {
"type": "Polygon",
"coordinates": [[
[40, 40],
[40, 80],
[80, 80],
[80, 40],
[40, 40]
]]
}
},
{
"type": "Feature",
"properties": {
"name": "dot"
},
"geometry": {
"type": "Point",
"coordinates": [30, 20]
}
}
]
}
Visualizing this GeoJSON using D3 (how this works will be explained later, although I think the example kind of speaks for itself):
const geojson = {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {
"name": "square"
},
"geometry": {
"type": "Polygon",
"coordinates": [[
[40, 40],
[40, 80],
[80, 80],
[80, 40],
[40, 40]
]]
}
},
{
"type": "Feature",
"properties": {
"name": "dot"
},
"geometry": {
"type": "Point",
"coordinates": [30, 20]
}
}
]
}
// function to generate SVG path data from a given GeoJSON:
const geoGenerator = d3.geoPath()
.pointRadius(2);
const svg = d3.select('#svg')
.append("g")
.attr("fill", "#ff0066")
.selectAll('path')
.data(geojson.features)
.join('path')
.attr('d', geoGenerator);
Result:
PS. The example above does not use a projection , only a D3 path generator. More on path generators and projections below.
Geographic path generators
d3.geoPath
is a geographic path generator that takes a given GeoJSON geometry object and generates an SVG path data string or renders
to HTML <canvas>
. In the example above a path generator (geoGenerator
) is used to produce SVG path strings,
in an example below a path generator is used to render to a <canvas>
.
The paths can be generated directly (example above), or via a transformation by a projection (see next section).
// for SVG, no projection:
const geoGenerator = d3.geoPath();
// for SVG, with projection:
const projection = d3.geoEquirectangular();
let geoGenerator;
geoGenerator = d3.geoPath(projection);
// is equivalent to:
geoGenerator = d3.geoPath()
.projection(projection);
// for canvas, no projection:
const canvas = document.querySelector("#myCanvas");
const ctx = canvas.getContext("2d");
let geoGenerator;
geoGenerator = d3.geoPath(ctx);
// is equivalent to:
geoGenerator = d3.geoPath()
.context(ctx);
// for canvas, with projection:
const canvas = document.querySelector("#myCanvas");
const ctx = canvas.getContext("2d");
const projection = d3.geoEquirectangular();
let geoGenerator;
geoGenerator = d3.geoPath(projection, ctx);
// is equivalent to:
geoGenerator = d3.geoPath()
.projection(projection)
.context(ctx);
SVG renderings are as clear and sharp as the screen is capable of delivering, regardless of the size of the image.
They do not lose quality if they are zoomed or scaled.
SVG is represented by a DOM,
so you can add event handlers and CSS to SVG elements and manipulate style and the DOM structure programmatically.
The downside is that direct DOM manipulations and rendering SVG are relatively slow.
<canvas>
graphics do not have a DOM structure, so they are often (but not always) faster to render and are more memory and CPU efficient.
They lack the ease and advantages that the DOM API provides. There is a way however, to
"simulate" event handlers for <canvas>
graphics,
but this hack is fairly cumbersome and slow.
In addition to projection
and context
, the path generator can be configured using a
number of other methods.
One of them is, for instance, pointRadius
, as used in
one of the examples above.
pointRadius
sets the radius used to display GeoJSON Point
and MultiPoint
geometries
(e.g. to indicate the locations of cities).
Projections
Geometries in GeoJSON are defined by coordinates [x, y]
being polygon vertices.
The coordinates represent spherical geographic coordinates,
defining positions on the Earth surface as [longitude, latitude]
, aka [eastings, northings]
(in degrees).
For example, London (51.507222° North, 0.1275° West) is [-0.1275, 51.507222]
in GeoJSON geometry coordinates.
The geographic coordinates ([longitude, latitude]
) are spherical. They define points on the surface of a 3D globe.
The visualization, however, is on a 2D computer screen (or, in the old days, on flat paper).
The [longitude, latitude]
coordinates (angles) need to be transformed to 2D
Cartesian coordinates [x, y]
(typically in pixels).
Such transformation is called a map projection.
Many map projections have been specified
for various purposes in various regions.
The simplest projection is the equirectangular projection.
This transformation is basically doing nothing: each [longitude, latitude]
equals the corresponding Cartesian [x, y]
.
function equirectangular(long, lat) {
const x = long;
const y = lat;
return [x, y];
}
Next example plots the GeoJSON file world.json
without any projection.
Also the code with an equirectangular projection is given, but the result will be the same.
Also note that this example uses HTML <canvas>
, instead of SVG.
Next code is without a projection:
(async function() {
const canvas = document.querySelector("#equirectangularWorldCanvas");
const ctx = canvas.getContext("2d");
try {
const geojson = await d3.json('world.json');
canvas.width = canvas.getBoundingClientRect().width;
canvas.height = canvas.width/2;
const scaleFactor = canvas.width/380;
ctx.translate(canvas.width/2, canvas.height/2)
ctx.scale(scaleFactor,-scaleFactor);
const geoGenerator = d3.geoPath()
.context(ctx);
ctx.fillStyle = 'rgba(0, 198, 134, 0.6)';
ctx.lineWidth = 0.2;
ctx.strokeStyle = '#666';
const graticule = d3.geoGraticule();
ctx.beginPath();
geoGenerator(graticule());
ctx.stroke();
ctx.beginPath();
geoGenerator({type: 'FeatureCollection', features: geojson.features});
ctx.fill();
}
catch (error) {
document.querySelector("#errorMessage").textContent = error;
}
})();
Next code is with an equirectangular projection:
(async function() {
const canvas = document.querySelector("#equirectangularWorldCanvas");
const ctx = canvas.getContext("2d");
try {
const geojson = await d3.json('world.json');
canvas.width = canvas.getBoundingClientRect().width;
canvas.height = canvas.width/2;
const projection = d3.geoEquirectangular()
.fitExtent([[0, 0], [canvas.width, canvas.height]], geojson);
const geoGenerator = d3.geoPath()
.projection(projection)
.context(ctx);
ctx.fillStyle = 'rgba(0, 198, 134, 0.6)';
ctx.lineWidth = 0.2;
ctx.strokeStyle = '#666';
const graticule = d3.geoGraticule();
ctx.beginPath();
geoGenerator(graticule());
ctx.stroke();
ctx.beginPath();
geoGenerator({type: 'FeatureCollection', features: geojson.features});
ctx.fill();
}
catch (error) {
document.querySelector("#errorMessage").textContent = error;
}
})();
Result:
Download GeoJSON file.
The used GeoJSON file is the same as the one used in the animation at the beginning of this article. It looks different because a different projection is used. The animation uses orthographic map projection:
function orthographic(lambda, phi) {
const x = Math.cos(phi) * Math.sin(lambda);
const y = Math.sin(phi);
return [x, y];
}
PS. Longitude is often expressed as lambda (λ). Latitude is often expressed as phi (φ).
The above function is presented for illustrative purposes only.
D3 provides d3.geoOrthographic()
,
as well as methods for several other projections.
They return a projection function that take [longitude, latitude]
coordinates and return Cartesian [x, y]
coordinates.
const projection = d3.geoOrthographic();
console.log( projection([-5.2467, 23.3333]) ); // logs: [ 459.05057845830663, 151.17823165526812 ]
console.log( projection.invert([459.05057845830663, 151.17823165526812]) ); // logs: [ -5.2467, 23.3333 ]
Next example uses, again, the same GeoJSON file as uses in the previous examples, but now with the Transverse Mercator projection (and now it renders to SVG):
(async function() {
const width = 500;
const height = width;
const svg = d3.select('#mercatorWorldSVG')
.attr("viewBox", `0 0 ${width} ${height}`);
try {
const geojson = await d3.json('world.json');
const projection = d3.geoTransverseMercator()
.fitExtent([[0, 0], [width, height]], geojson);
const geoGenerator = d3.geoPath()
.projection(projection);
const graticule = d3.geoGraticule();
svg
.append("g")
.attr("stroke", "#666")
.attr("stroke-width", "0.2")
.attr("fill", "none")
.append("path")
.attr('d', geoGenerator(graticule()));
svg
.append("g")
.attr("fill", "rgba(0, 198, 134, 0.6)")
.selectAll('path')
.data(geojson.features)
.join('path')
.attr('d', d => geoGenerator(d));
//.attr('d', geoGenerator);
}
catch (error) {
document.querySelector("#errorMessage").textContent = error;
}
})();
Result:
Geodesics
A line (the shortest distance) between two locations on the Earth's surface is not a straight line, but a segment of a great-circle. Such a segment of a great-circle is called a geodesic.
Geodesics become curves in all map projections (except gnomonic). Projections in D3 perform interpolation along polygons between two points to create those curves.
Likewise, circles on Earth's surface become ellipses on a map projection.
They can be created using d3.geoCircle()
.
Next example draws a geodesic between Amsterdam and Paramaribo and a d3.geoCircle
around New York City:
const rotation = 15,
tilt = -23;
const canvas = document.querySelector("#myCanvas"),
ctx = canvas.getContext("2d");
const width = canvas.width,
height = canvas.height;
const projection = d3.geoOrthographic()
.translate([width/2, height/2])
.scale(200)
.rotate([rotation, -10, tilt]);
const geoGenerator = d3.geoPath()
.projection(projection)
.context(ctx);
function draw(geojson) {
ctx.fillStyle = 'rgba(0, 198, 134, 0.6)';
ctx.lineWidth = 0.4;
const graticule = d3.geoGraticule(); // *) see below
ctx.beginPath();
ctx.strokeStyle = '#666';
geoGenerator(graticule());
ctx.stroke();
ctx.beginPath();
geoGenerator({type: 'FeatureCollection', features: geojson.features});
ctx.fill();
// **** Draw lines and circles on Earth's surface ***
ctx.strokeStyle = 'red';
ctx.lineWidth = 1;
// draw geodesic between Amsterdam and Paramaribo:
ctx.beginPath();
geoGenerator({
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: [[4.893333, 52.373056], [-55.169722, 5.823611]]
}
});
ctx.stroke();
// draw geoCircle around New York:
const newYorkGeoCircle = d3.geoCircle()
.center([-74.0059, 40.7128])
.radius(4);
ctx.beginPath();
geoGenerator(
newYorkGeoCircle()
);
ctx.stroke();
};
// Fetch the GeoJSON file,
// and then call 'draw(geojson)' to draw the map:
(async function() {
try {
const geojson = await d3.json('world.json');
draw(geojson);
}
catch (error) {
document.querySelector("#errorMessage").textContent = error;
}
})();
*) d3.geoGraticule()
constructs a geometry generator for creating graticules: a uniform grid of
lines of longitude (meridians) and lines of latitude,
for showing projection distortion.
Projection configuration
There is a number of methods you can use to configure a projection.
You can apply a scale factor to the projection, specify a [longitude, latitude]
to be the center of the projection,
specify a [x, y]
to translate the center of projection on the screen,
specify a rotation of the projection etc.
In the examples above we used fitExtent
a couple of times.
fitExtent
sets the projection's scale and translate such that the geometry fits within a given bounding box.
Method .invert()
returns [longitude, latitude]
(in degrees) on a given projected point [x, y]
(typically in pixels).
See one of the examples above.
Choosing a map projection
There are numerous, and theoretically infinite, possible projections. So, which projection should you choose?
Area (size), shape, distance, or direction can all be measured relatively accurate on Earth or on a spherical (or more accurate, an ellipsoidal) scale model of Earth. Any projection on a flat map, however, will distort at least some of the properties. The larger the area covered by a map, the greater the distortion. If it is important that the projection accurately preserves, for instance, the mutual ratio of areas between provinces on the map, then choose a projection that does just that, and accept distortion of other properties, like shape and/or distance.
One way to classify projections is based on the type of surface onto which the globe is projected:
- Planar projections, aka azimuthal projections or zenithal projections (e.g. the stereographic projection).
- Cylindrical projections (e.g. the Mercator projection)
- Conic projections (e.g. the Albers projection)
Another way to classify projections is according to properties of the model they preserve.
- Conformal (aka orthomorphic). Preserving shape of small areas, but distort size. (e.g. the Mercator projection).
- Equal-area (aka equiareal or equivalent or authalic). Preserving relative sizes of areas, but distort shapes and directions. (e.g. the Albers conic projection).
- Azimuthal (aka planar or zenithal). Preserving direction (only from one or two points to every other point), but distort shape and area. (e.g. the Orthographic projection).
- Equidistant. Preserving distance, but only between one or two points and every other point. (e.g. the Equidistant conic projection).
- Gnomonic. Preserving shortest route and direction, but distort shape and area. The gnomonic projection is also an azimuthal projection. (only the Gnomonic projection).