Interact with and animate geographic maps

Animation

Next example is an animation. It uses d3.geoInterpolate() to interpolate intermediate values along a geodesic between two locations.


const geoInterpolator = d3.geoInterpolate([4.893333, 52.373056], [-55.169722, 5.823611]);

console.log(geoInterpolator(0)); // logs: [ 4.893332999999999, 52.373056 ]
console.log(geoInterpolator(0.5)); // logs: [ -33.01747857795449, 32.4885458308826 ]
console.log(geoInterpolator(1)); // logs: [ -55.16972199999999, 5.823611 ]

d3.geoInterpolate() is a method among others that can be used for spherical geometry.


const rotation = 15,
      tilt = -23;

const amsterdamLonLat = [4.893333, 52.373056],
      paramariboLonLat = [-55.169722, 5.823611];			  

const canvas = document.querySelector("#myCanvas"),
      ctx = canvas.getContext("2d");

const width = canvas.width,
      height = canvas.height;

let geojson = {},
    previousTimeStamp,
    travel = 0;		

const projection = d3.geoOrthographic()
  .translate([width/2, height/2])
  .scale(200)
  .rotate([rotation, -10, tilt]);

const geoGenerator = d3.geoPath()
  .projection(projection)
  .context(ctx);

const geoInterpolator = d3.geoInterpolate(amsterdamLonLat, paramariboLonLat);		  

function animate(timestamp) {
  if (previousTimeStamp === undefined) {
    previousTimeStamp = timestamp;
  }
  const elapsedPerFrame = timestamp - previousTimeStamp;
  travel += elapsedPerFrame * 0.0001;
  if(travel > 1) travel = 0;
  previousTimeStamp = timestamp;
  requestAnimationFrame(animate);
  ctx.clearRect(0, 0, width, height);

  ctx.fillStyle = 'rgba(0, 230, 157, 0.6)';
  ctx.lineWidth = 0.4;

  const graticule = d3.geoGraticule();
  ctx.beginPath();
  ctx.strokeStyle = '#808080';
  geoGenerator(graticule());
  ctx.stroke();

  ctx.beginPath();
  geoGenerator({type: 'FeatureCollection', features: geojson.features});
  ctx.fill();		  

  // draw geodesic between Amsterdam and Paramaribo:
  ctx.strokeStyle = 'red';
  ctx.lineWidth = 1;		  
  ctx.beginPath();
  geoGenerator({
    type: 'Feature',
    geometry: {
      type: 'LineString',
      coordinates: [amsterdamLonLat, paramariboLonLat]
    }
  });
  ctx.stroke();		  

  // draw new position of dot on geodesic:  
  const locationDot = projection(geoInterpolator(travel));
  ctx.fillStyle = 'red';
  ctx.beginPath();
  ctx.arc(locationDot[0], locationDot[1], 4, 0, 2 * Math.PI);
  ctx.fill();		  
};

// Fetch the GeoJSON file,
// and then start the animation:
(async function() {
  try {
    geojson = await d3.json('world.json');
    requestAnimationFrame(animate);	
  }
  catch (error) {
    document.querySelector("#errorMessage").textContent = error; 
  }
})();

Event handling

Next example creates a map of all the municipalities of the Netherlands (Caribbean part of the Netherlands not included). An event handler is added to a mouse hover over the municipalities.


const width = 500;
const height = width;
const areaColor = "#bf8040";
const selectionColor = "#660066";
let geoGenerator, selectedArea;		

const svg = d3.select('#SVGmunicipalities')
  .attr("viewBox", `0 0 ${width} ${height}`);

const areasContainer = svg
  .append("g")
    .attr("stroke", "#262626")
    .attr("stroke-width", "0.5")
    .attr("fill", areaColor);

const displayNameArea = svg
  .append("text")
    .style('fill', "#fff")
    .attr("font-family", "sans-serif")
    .attr("font-size", "10px")
    .attr("text-anchor", "middle");			  

function drawMap(geojson) {
  const projection = d3.geoMercator()
    .fitExtent([[0, 0], [width, height]], geojson);

  geoGenerator = d3.geoPath()
    .projection(projection);

  areasContainer	
    .selectAll('path')
    .data(geojson.features)
    .join('path')
      .attr('d', d => geoGenerator(d))
      .on("mouseover", handleMouseOverArea);				  
};

function handleMouseOverArea(e, d) {
  const centroid = geoGenerator.centroid(d);
  if(selectedArea) {
    selectedArea
      .style('fill', areaColor);
  }
  selectedArea = d3.select(this);
  selectedArea
    .style('fill', selectionColor);
  displayNameArea
    .attr("x", centroid[0])
    .attr("y", centroid[1])
    .text(d.properties.statnaam);		
};

(async function() {
  try {
    const response = await d3.json('nl_municipalities.json');
    drawMap(response);	
  }
  catch (error) {
    document.querySelector("#errorMessage").textContent = error; 
  }
})();

Result:

Hover over the municipalities.

Download GeoJSON file.

Source: The GeoJSON file of the municipalities of the Netherlands (2023) is derived form a GitHub repository, that provides GeoJSON and TopoJSON files of the Netherlands, that are simplified versions of the ones provided by the Dutch autonomous administrative authority CBS/PDOK. The GeoJSON file uses WGS84 coordinates, or more specifically, WGS84-EPSG:4326 coordinates, i.e., geographic coordinates ([longitude, latitude]) in degrees. WGS84 is the common standard geodetic system.

The example above uses an event handler on SVG elements. <canvas> graphics do not have a DOM structure, so you cannot add event handlers. However, D3 provides d3.geoContains(GeoJSON, point) that returns true if the specified GeoJSON object contains the specified point, and false otherwise.

The next example uses <canvas> and d3.geoContains() to create the same application as the previous example, except now the event is a mouse click, instead of a mouse hover. Handling a mouse hover is very slow in this construction.

Previously we used geoGenerator({type: 'FeatureCollection', features: geojson.features}). But now we do not need to draw one combined feature collection. Instead we need to draw multiple separate features, separately colored, depending on a mouse click. With SVG, the data join() handles "iterating" over the data features. With <canvas> we need to iterate using a loop or forEach.


let clickedPosition = null,
    canvas, ctx, geojson,
	width, height,
    projection, geoGenerator;	

function init() {
  const canvasD3 = d3.select('#myCanvas')
    .on('click', function(e){
      const pos = d3.pointer(e)
      clickedPosition = projection.invert(pos)
      draw();
  });
  canvas = canvasD3.node();
  ctx = canvas.getContext("2d");

  width = canvas.getBoundingClientRect().width;
  height = width;

  projection = d3.geoMercator()
    .fitExtent([[0, 0], [width, height]], geojson);

  geoGenerator = d3.geoPath()
    .projection(projection)
    .context(ctx);  
};

function draw() {
  let centroid = [0, 0];
  let clickedName = "";
  canvas.width = width;
  canvas.height = height;  
  ctx.clearRect(0, 0, canvas.width, canvas.height);

  geojson.features.forEach(function(d) {
    ctx.beginPath();
    ctx.lineWidth = 1;
    ctx.strokeStyle = '#262626';	
	if (clickedPosition && d3.geoContains(d, clickedPosition)) {
      ctx.fillStyle = "#660066";
      geoGenerator(d);
      ctx.fill();
      ctx.stroke();
	  centroid = geoGenerator.centroid(d);
	  clickedName = d.properties.statnaam;	  
	} else {
      ctx.fillStyle = "#bf8040";
      geoGenerator(d);
      ctx.fill();
      ctx.stroke();	  
	} 
  })
  ctx.fillStyle = "#fff";
  ctx.font = "16px sans-serif";
  ctx.textAlign = "center";  
  ctx.fillText(clickedName, centroid[0], centroid[1]);  
};

(async function() {
  try {
    geojson = await d3.json('nl_municipalities.json');
    init();
    draw();
  }
  catch (error) {
    document.querySelector("#errorMessage").textContent = error; 
  }
})();

Result:

Click on a municipality.