drawing countries in the browser

9 min read
  • DataViz
  • GIS
  • SoftwareDevelopment

I landed back in London at 6am after 13h, it was a beautiful summer day, patchy clouds with bright splashes of sun, 20 degrees cooler than Bangkok. I dropped my heavy luggage at home and without much sleep in my body I went to Hampstead Heath to meet with a friend for a coffee. We walked around the park and caught up among the breezy green canopies, it wasn’t long before I looked around just to realize I had no idea where we were anymore. A place I would have thought I know by heart by now, still treats me with the gift of getting lost.

I’ve loved maps since I was a kid. I remember being gifted a Nat Geo atlas of the world, one of those big heavy things you could spend hours flipping through, pressing your finger on mountain ranges and coastlines. Then in 2005 Google Earth launched and like a million others I quickly found myself losing whole evenings drifting around the globe, zooming in on random islands, rivers, lakes and cities.

Learning to work with geospatial data has been on my bucket list for a long time, but switching careers to software engineering from a creative background is not for the faint of heart. There are always so many other things (bundling, auth, containers, load balancers, caching, reverse proxies, monitoring and a long long tail of skills) competing for your free time and attention.

Coming back from Bangkok felt like a natural reset. I had spent two months looking at maps from “the other side”, walking along a network of bridges that connect BTS lines with skyscrappers. Thai name streets on my phone screen every day lighted that forgotten spark again, like a 7Eleven in the middle of the night, it is the right moment to figure out how all of this works.


finding the right dataset

There are many free datasets out in the wild, each of them best suited for different purposes and the information you care about rendering. For now I thought drawing the outline of countries is probably a great place to start, Natural Earth seems to be a perfect fit for that: public domain, no attribution required, shipping at three resolutions that are aesthetically pleasant and ready to use. Their data is already ubiquitous to the web, you will bump into it in D3 examples, Observable notebooks, and most major newsroom graphics teams reach for it first.

Natural Earth organises data into physical, cultural and raster downloads. The Cultural Admin 0 dataset comes in two variants: countries, polygons that extend to the shoreline of every major lake, and countries without boundary lakes, the same polygons but with lake surfaces punched out as interior holes.

This data, as it comes from the download is still not usable by the browser though. Every download is a Shapefile, a format from the early 1990s that stores geometry and attributes across a set of companion files that, like a school of fish or a flock of birds, always travel together. Browsers cannot read any of these files, none of them is something a fetch() call can make sense of. We need to convert them to a format that the browser can decipher.

GeoJSON was created in 2008 precisely to solve this problem: to provide a lightweight standardized format for encoding geographic data through the web.

To convert the Shapefile to GeoJSON we can reach to a command line tool from the 90s which seems to have aged extremely well: ogr2ogr, part of GDAL.

Transforming the whole 10m Shapefile to GeoJSON with ogr2ogr would look like this:

ogr2ogr \
  -f GeoJSON \
  ne_10m_admin_0_countries.geojson \
  ne_10m_admin_0_countries/ne_110m_admin_0_countries.shp

That works, but the output carries all 70 attribute columns per feature. For rendering borders we need none of that. The -select flag picks which columns to keep:

ogr2ogr \
  -f GeoJSON \
  -select "NAME,ISO_A3" \
  ne_10m_admin_0_countries.geojson \
  ne_10m_admin_0_countries/ne_10m_admin_0_countries.shp

We can filter to a single country using -where, which accepts a SQL expression against the .dbf attribute table. This is how we would isolate the data for Iceland for instance:

ogr2ogr \
  -f GeoJSON \
  -select "NAME,ISO_A3" \
  -where "ISO_A3 = 'ISL'" \
  thailand_110m.geojson \
  ne_110m_admin_0_countries/ne_110m_admin_0_countries.shp

Natural Earth data comes in three resolutions: 1:110m, tiny file sizes, great for rendering the whole world in the size of a postcard, 1:50m, a middle ground for continental and regional views. 1:10m, the most detailed, right for a single country filling a browser viewport.

Here is Iceland at each of these resolutions:


rendering one country

GeoJSON coordinates are longitude/latitude pairs in degrees. A browser viewport is a flat rectangle measured in pixels. To go from one to the other you need a map projection, a mathematical function that maps points on a sphere onto a plane.

Implementing a projection from scratch is not trivial, you would need to handle many edge cases: polygons that cross the antimeridian, rings that need to be wound correctly so fills work, clipping to viewport bounds.

It is the kind of thing that would be a fun coding retreat project, lost in the woods, no good broadband to ask Claude existential questions about your life, and loads of free time in your hands to reinvent the wheel just for leisure.

But until that times comes d3-geo is a very handy choice to abstract details away, it ships with dozens of projections, generates path geometry from GeoJSON, and handles all the edge cases above. More than that, like most d3 libraries, it is not tied to a specific output format — the same geoPath() call can write SVG path d strings or draw directly to a Canvas 2D context, which could turn very handy if and when performance becomes a bottleneck.

The code to render country borders with d3 is so simple that fits in a tiny code snippet:

import { geoMercator, geoPath } from 'd3';

const svg = document.getElementById('svg');
const w = svg.clientWidth;
const h = svg.clientHeight;

// assuming you prepared nepal's data with ogr2ogr
const res = await fetch('./data/nepal.geojson');
const geojson = await res.json();

svg.setAttribute('viewBox', `0 0 ${w} ${h}`);

const projection = geoMercator().fitExtent([[20, 20], [w - 20, h - 20]], geojson);
const path = geoPath(projection);

for (const feature of geojson.features) {
  const el = document.createElementNS('http://www.w3.org/2000/svg', 'path');
  el.setAttribute('d', path(feature));
  svg.appendChild(el);
}

geoMercator uses the Mercator map projection, which even if invented in 1569 still remains the most popular among map makers today. Mercator preserves shapes and angles (directions) making it a perfect fit for navigating with a compass. It also happens to form perfect right angles on a grid, which integrates seamlessly with the zooming and panning features of digital mapping services.

fitExtent inspects the GeoJSON bounding box and automatically scales and centres the projection to fill the given pixel bounds.

geoPath then converts each GeoJSON feature into an SVG path element, using the given projection.

Lastly we loop over all the geojson features, and draw the lines that form the contours of our country of choice:


rendering a boundary lake

In GeoJSON, a Polygon geometry is an array of rings. The first ring (coordinates[0]) is the outer boundary. Any rings that follow (coordinates[1], coordinates[2], …) are polygon holes, areas subtracted from the fill.

A MultiPolygon is an array of those, used when a country consists of disconnected land masses.

The plain Countries file contains only outer rings: every polygon is a solid shape with no holes. The without boundary lakes file punches the lake surfaces out as interior rings. The lake perimeter is stored as an extra ring inside each country’s coordinates array:

{
  "type": "Polygon",
  "coordinates": [
    [[...], [...], ...],  // outer ring — the country border
    [[...], [...], ...],  // outer ring — small island 
    [[...], [...], ...]   // hole — inner lake 
  ]
}

d3.geoPath() handles this automatically, so we don’t need to worry about it. SVG path d strings encode outer rings and hole rings using the same M ... Z move-and-close syntax, and the browser fills them using the evenodd rule: any area enclosed by an odd number of rings is filled, any area enclosed by an even number is not.

The lake areas end up enclosed by two rings (the country border and the lake ring), so they fall outside the fill and show the background through.

Lake Victoria is a perfect example of a boundary lake. Draped across the borders of Kenya, Uganda and Tanzania, in East Africa, pouring its waters into the Nile and flowing northwards 6650km to the Mediterranean Sea. It was around its shores that some of the oldest known stone tools have been found, dating back nearly 3 million years.


rendering many countries

Once you know how to draw a country with d3, rendering many is a trivial step. We will use the same projection, the same path generator, the same loop. The only difference is that the FeatureCollection that we pass will contain more features in it.

Lets extract all of Africa at 110m from the Natural Earth Shapefile, you can filter by the CONTINENT attribute instead of an ISO code:

ogr2ogr \
  -f GeoJSON \
  -select "NAME,ISO_A3" \
  -where "CONTINENT = 'Africa'" \
  africa_110m.geojson \
  ne_110m_admin_0_countries/ne_110m_admin_0_countries.shp

Scaling up to the whole planet requires one tiny change: the projection. geoMercator is fine for a single country or continent, but it distorts heavily towards the poles, Greenland would appear larger than Africa. d3 provide us for a handy function to project natural earth data: geoNaturalEarth1. It is a pseudocylindrical projection that keeps relative areas roughly accurate and is gentle on the poles.

import { geoNaturalEarth1, geoPath } from 'd3';

The only other adjustment is what you pass to fitExtent. For a single country or a continent, fitting to the GeoJSON bounding box makes sense, as the projection centres on the data. For a world map lets pass a Sphere object, which represents the full globe with a consistent, centred framing regardless of what data is rendered:

const projection = geoNaturalEarth1().fitExtent(
  [[20, 20], [width - 20, height - 20]],
  { type: 'Sphere' }
);

This is it, my humble beginnings into rendering geospatial data in the browser. There is so much more I want to experiment with in the coming months, so if you enjoy this and you want to learn more about maps and data visualization feel free to subscribe to the blog, follow me or reach out. Byeeeeeee 👋