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:

Another way to classify projections is according to properties of the model they preserve.