Animated maps with D3 🗺️

Animated maps with D3.js 🗺️

As we’ve seen in the previous lesson, D3.js is an amazing library to build charts. But it also excels at making maps.

In this project, we will use earthquake data to create an animated map with D3 and Svelte, as shown below. I strongly suggest completing the previous sections of the course before diving in, especially Animated charts with D3.js 🧑‍🎨.

Demo animation of earthquakes animated on a map created with D3 and Svelte.

Want to know when new lessons are available? Subscribe to the newsletter ✉️ and give a ⭐ to the GitHub repository to keep me motivated! Click here to get in touch.

Setup

We will use setup-sda to set up and install everything we need.

Open a new folder with VS Code and run deno -A jsr:@nshiab/setup-sda --svelte.

A screenshot of VS Code after running the setup-sda library.

Earthquake data and country boundaries

To retrieve earthquake data, I used the USGS Earthquake Catalog. Since 2021 appeared to be a very active year, I downloaded two CSV files for this year and pushed the files to GitHub. This is the same data as the previous D3 project.

For the country boundaries, I downloaded them from Natural Earth as a zipped shapefile.

Using the Simple Data Analysis library in the sda folder, we can easily retrieve and cache them. Update sda/main.ts, then run deno task sda to execute and watch it.

For the earthquakes:

  • We keep only rows with a type of earthquake and a status of reviewed.
  • We filter out earthquakes with a magnitude less than 5.
  • Since we will be drawing circles on our map, it’s more interesting to use the amplitude, which we can easily compute from the magnitude.
  • We rename latitude and longitude to shorter names.
  • We keep only the columns that we will be using: time, ampl, lat, and lon.
  • We round numerical values to three decimal places.
  • On our map, we want the bigger earthquakes to appear on top of the smaller ones, so we sort ampl in ascending order, as D3 draws elements in the order they appear in the data.
  • After logging the table to ensure everything is okay, we write the data to a JSON file in the src folder (not sda) to be used in our Svelte project. Since these are just points, it’s easier to keep this data tabular. We have over 2,000 earthquakes that we want to draw and animate on a map.

For the countries:

  • When fetching the data with loadDataGeo, we make sure to reproject the data to WGS84.
  • We select only the geom column, since we don’t need anything more than the boundaries.
  • After logging the table to make sure everything is okay, we write a GeoJSON file to the src folder (not sda) to be used in our Svelte project. Here, we use writeGeoData instead of writeData, and we pass the option rewind to make sure the coordinates are in the right order for D3 to draw the polygons correctly. We have 127 geospatial features that we will add to our map.
sda/main.ts
import { SimpleDB } from "@nshiab/simple-data-analysis";
 
const sdb = new SimpleDB();
 
const earthquakes = sdb.newTable("earthquakes");
await earthquakes.cache(async () => {
  await earthquakes.loadData([
    "https://raw.githubusercontent.com/nshiab/data-fetch-lesson/refs/heads/main/earthquakes-2021-1.csv",
    "https://raw.githubusercontent.com/nshiab/data-fetch-lesson/refs/heads/main/earthquakes-2021-2.csv",
  ]);
});
 
await earthquakes.keep({
  "type": "earthquake",
  "status": "reviewed",
});
await earthquakes.filter(`mag >= 5`);
await earthquakes.addColumn("ampl", "number", `POW(10, mag)`);
await earthquakes.renameColumns({ latitude: "lat", longitude: "lon" });
await earthquakes.selectColumns([
  "time",
  "ampl",
  "lat",
  "lon",
]);
await earthquakes.round(["ampl", "lat", "lon"], { decimals: 3 });
await earthquakes.removeDuplicates();
await earthquakes.sort({ ampl: "asc" });
await earthquakes.logTable();
await earthquakes.writeData("src/data/earthquakes.json");
 
const countries = sdb.newTable("countries");
await countries.cache(async () => {
  await countries.loadGeoData(
    "https://raw.githubusercontent.com/nshiab/data-fetch-lesson/refs/heads/main/ne_110m_land.shp.zip",
    { toWGS84: true },
  );
});
await countries.selectColumns("geom");
await countries.logTable();
await countries.writeGeoData("src/data/countries.json", { rewind: true });
 
await sdb.done();

A screenshot of VS Code with data tables logged in the terminal.

Exploratory dataviz

Before diving into D3 code, I always use SDA and Plot to quickly draw an initial dataviz. It helps to understand the data at hand.

Since we have data for the whole world, we can try the equal-earth projection, which is also available in D3. (More on projections below.)

Here’s a step-by-step explanation of the new code below:

  • We clone the earthquakes table.
  • We create point geometries from the lat and lon columns into a new geom column.
  • We insert the countries table, which already has a geom column.
  • We use the writeMap method with Plot to export a map as a PNG file.
  • We add sphere() and graticule() to make the map easier to interpret with the equal-earth projection.
  • We draw the countries first.
  • Then we draw the earthquakes, using their ampl values.
sda/main.ts
import { SimpleDB } from "@nshiab/simple-data-analysis";
import { geo, graticule, plot, sphere } from "@observablehq/plot";
 
const sdb = new SimpleDB();
 
const earthquakes = sdb.newTable("earthquakes");
await earthquakes.cache(async () => {
  await earthquakes.loadData([
    "https://raw.githubusercontent.com/nshiab/data-fetch-lesson/refs/heads/main/earthquakes-2021-1.csv",
    "https://raw.githubusercontent.com/nshiab/data-fetch-lesson/refs/heads/main/earthquakes-2021-2.csv",
  ]);
});
 
await earthquakes.keep({
  "type": "earthquake",
  "status": "reviewed",
});
await earthquakes.filter(`mag >= 5`);
await earthquakes.addColumn("ampl", "number", `POW(10, mag)`);
await earthquakes.renameColumns({ latitude: "lat", longitude: "lon" });
await earthquakes.selectColumns([
  "time",
  "ampl",
  "lat",
  "lon",
]);
await earthquakes.round(["ampl", "lat", "lon"], { decimals: 3 });
await earthquakes.removeDuplicates();
await earthquakes.sort({ ampl: "asc" });
await earthquakes.logTable();
await earthquakes.writeData("src/data/earthquakes.json");
 
const countries = sdb.newTable("countries");
await countries.cache(async () => {
  await countries.loadGeoData(
    "https://raw.githubusercontent.com/nshiab/data-fetch-lesson/refs/heads/main/ne_110m_land.shp.zip",
    { toWGS84: true },
  );
});
await countries.selectColumns("geom");
await countries.logTable();
await countries.writeGeoData("src/data/countries.json", { rewind: true });
 
const earthquakesAndCountries = await earthquakes.cloneTable({
  outputTable: "earthquakesAndCountries",
});
await earthquakesAndCountries.points("lat", "lon", "geom");
await earthquakesAndCountries.insertTables(countries);
await earthquakesAndCountries.writeMap((geodata) =>
  plot({
    projection: "equal-earth",
    marks: [
      sphere(),
      graticule(),
      geo(geodata.features.filter((d) => !d.properties.ampl)),
      geo(
        geodata.features.filter((d) => typeof d.properties.ampl === "number"),
        {
          r: "ampl",
          fill: "red",
          opacity: 0.5,
        },
      ),
    ],
  }), "sda/output/earthquakesAndCountries.png");
 
await sdb.done();

A screenshot of VS Code with a map displayed.

Things are looking quite good! We can now dive into our Svelte project.

Svelte component

Let’s set up a new Svelte component with a helper function for our map.

But before we do, it’s always helpful to define some types that we’ll use repeatedly. In src/lib/index.ts, we can place types and variables that will be easily accessible throughout our Svelte project.

src/lib/index.ts
type earthquake = {
  time: Date;
  lat: number;
  lon: number;
  ampl: number;
};
 
export type { earthquake };

Now let’s create the helper function drawMap.ts in the src/helpers folder (again, not sda). This is where our D3 code will live. The function will need a few things:

  • An id, which will be the id of the svg element where we will draw our map.
  • The earthquakes data.
  • The width and height of the map.

For now, let’s just log the parameters.

Note that since we used src/lib/index.ts, we can easily import our types (and anything else we put in there) with from $lib. It’s a handy shortcut!

src/helpers/drawMap.ts
import type { earthquake } from "$lib";
 
export default function drawMap(
  id: string,
  earthquakes: earthquake[],
  width: number,
  height: number,
) {
  console.log({ id, earthquakes, width, height });
}

We can now create a new Map.svelte component that:

  • Imports our earthquakes.json data as earthquakesRaw (line 2), and maps over it to convert the time values back to Date objects (lines 7–10).
  • Retrieves the id as props (line 5).
  • Creates width and height states (lines 12–13) and binds them to the clientWidth and clientHeight of the svg element where we will draw our chart (line 21). We’ll talk more about svg elements later on.
  • Uses the $effect rune to call drawMap with all the props and states. This means Svelte will recall drawMap if any of the arguments change, including width and height, making the map responsive.
  • To keep a constant aspect ratio for our map, we wrap the svg in a div and use style tags to set the div width to 100% and the svg aspect ratio to 16/9.
src/components/Map.svelte
<script lang="ts">
    import earthquakesRaw from "../data/earthquakes.json";
    import drawMap from "../helpers/drawMap";
 
    const { id }: { id: string } = $props();
 
    const earthquakes = earthquakesRaw.map((d) => ({
        ...d,
        time: new Date(d.time),
    }));
 
    let width = $state(0);
    let height = $state(0);
 
    $effect(() => {
        drawMap(id, earthquakes, width, height);
    });
</script>
 
<div>
    <svg {id} bind:clientWidth={width} bind:clientHeight={height}></svg>
</div>
 
<style>
    div {
        width: 100%;
    }
    svg {
        aspect-ratio: 16/9;
    }
</style>

And finally, we can import our new <Map /> component on our page, which is src/routes/+page.svelte. We set an appropriate id. While we’re at it, we can update the text.

If you were still watching sda/main.ts in your terminal, you can stop it (CTRL + C) and run deno task dev instead to start a local server. Open the URL provided in your terminal in your favorite web browser.

In your browser’s console, you should see the log from drawMap.ts. We’re ready to code our map!

src/routes/+page.svelte
<script lang="ts">
    import Map from "../components/Map.svelte";
</script>
 
<h1>Earthquakes</h1>
<p>
    The data used below includes only earthquakes with a magnitude of 5 or more
    that occurred in 2021.
</p>
<Map id="earthquakes" />

VS Code with a Svelte project running locally.

Drawing a map with D3

Let’s play with D3 now!

Stop your local server (CTRL + C) and install D3 with deno add npm:d3. Then rerun your local server with deno task dev.

In the previous lesson, we learned about D3 scales, which can convert data values to pixel values, colors, and more.

When working with maps, we need to do the same thing for geospatial coordinates (latitude and longitude): convert them to pixels. But… there’s a trick. The Earth is round, while our screens (and paper maps) are flat!

This is why cartographers created projections, which are kind of like scales, but for maps. Each projection and its mathematical calculations come with pros and cons. For example, the Mercator projection — probably the best-known of all — is great for navigation because directions are preserved, but distances and areas are distorted in northern and southern regions.

One reason why D3 is so great for maps is that you get easy access to a looooot of projections. In this project, we’ll use the geoEqualEarth() projection, like we did with Plot at the beginning.

Let’s update drawMap.ts to use it. Here’s what the new code does, step by step:

  • We select the svg in which we’ll draw our map, and remove any existing content to avoid stacking elements between renders (lines 10–11).
  • We create a sphere that will contain the entire map (lines 13–15). It doesn’t have coordinates, and that’s okay — D3 knows how to handle it. We’ll provide coordinates for the other features on the map.
  • We call the geoEqualEarth projection and use the fitSize method to ensure our map fits the width and height of the svg. We also pass in our sphere to ensure proper positioning. If we wanted to zoom in on a country, we could pass that country instead of the sphere. We store the projection in the projection variable.
  • We call geoPath and pass it our projection. The result, stored in geoGenerator, is a function that can draw shapes based on latitudes and longitudes.
  • We append a path to the svg, which will represent a shape.
  • In the previous lesson, we used .data() to bind an array of objects. But here, since we have just one item (sphere), we use .datum() instead.
  • We set the d attribute (which defines the shape of a path) using the geoGenerator function. It reads the bound data and translates it to coordinates — for now, it simply fills the map background.
  • Finally, we set other attributes, such as the fill color.
src/helpers/drawMap.ts
import type { earthquake } from "$lib";
import { geoEqualEarth, geoPath, select } from "d3";
 
export default function drawMap(
  id: string,
  earthquakes: earthquake[],
  width: number,
  height: number,
) {
  const svg = select(`#${id}`);
  svg.selectAll("*").remove();
 
  const sphere = {
    type: "Sphere",
  };
  const projection = geoEqualEarth()
    .fitSize([width, height], sphere);
 
  const geoGenerator = geoPath().projection(projection);
 
  svg
    .append("path")
    .datum(sphere)
    .attr("d", geoGenerator)
    .attr("fill", "black");
}

If you right-click on the black area and inspect it in your web browser, you’ll see your svg with a path. The path has a d attribute containing SVG coordinates generated by our code!

A sphere created with D3.

To make our map easier to read, we can also add graticules. The geoGraticule() function generates a GeoJSON object with graticule coordinates.

We place them after our sphere in the code to ensure they are drawn on top of it. To give them a light grey appearance, we draw them as white lines with reduced opacity.

src/helpers/drawMap.ts
import type { earthquake } from "$lib";
import { geoEqualEarth, geoGraticule, geoPath, select } from "d3";
 
export default function drawMap(
  id: string,
  earthquakes: earthquake[],
  width: number,
  height: number,
) {
  const svg = select(`#${id}`);
  svg.selectAll("*").remove();
 
  const sphere = {
    type: "Sphere",
  };
  const projection = geoEqualEarth()
    .fitSize([width, height], sphere);
 
  const geoGenerator = geoPath().projection(projection);
 
  svg
    .append("path")
    .datum(sphere)
    .attr("d", geoGenerator)
    .attr("fill", "black");
 
  svg
    .append("path")
    .datum(geoGraticule())
    .attr("d", geoGenerator)
    .attr("fill", "none")
    .attr("stroke", "white")
    .attr("opacity", 0.3);
}

Graticules created with D3.

Now would be a good time to add the countries to our map. Since we’re not interested in showing the actual borders, we can simply fill them in with grey, without any stroke.

There are over 100 of them, so we use the .data(countries.features) and .join("path") syntax to bind the data to new path elements. To make sure we don’t accidentally select previous paths, we use selectAll with the .countries class, which we set as an attribute.

We use .features in .data(countries.features) because the file is written as a GeoJSON object, where all the features are stored in a list under the features key.

src/helpers/drawMap.ts
import type { earthquake } from "$lib";
import { geoEqualEarth, geoGraticule, geoPath, select } from "d3";
import countries from "../data/countries.json" with { type: "json" };
 
export default function drawMap(
  id: string,
  earthquakes: earthquake[],
  width: number,
  height: number,
) {
  const svg = select(`#${id}`);
  svg.selectAll("*").remove();
 
  const sphere = {
    type: "Sphere",
  };
  const projection = geoEqualEarth()
    .fitSize([width, height], sphere);
 
  const geoGenerator = geoPath().projection(projection);
 
  svg
    .append("path")
    .datum(sphere)
    .attr("d", geoGenerator)
    .attr("fill", "black");
 
  svg
    .append("path")
    .datum(geoGraticule())
    .attr("d", geoGenerator)
    .attr("fill", "none")
    .attr("stroke", "white")
    .attr("opacity", 0.3);
 
  svg.selectAll(".countries")
    .data(countries.features)
    .join("path")
    .attr("class", "countries")
    .attr("d", geoGenerator)
    .attr("fill", "grey");
}

Countries on a map created with D3.

The only thing left is the earthquakes!

There are a few things we need to take care of to show them properly:

  • We use the extent function to retrieve the minimum and maximum of the ampl values (lines 51–54). This function returns an array like [min, max].
  • We create a scale for the r attribute (lines 56–58). We use a square root scale (scaleSqrt) because we want the area of the circle to be proportional to the data. We set its domain to the ampl extent and its range to radii between 2 and 20 pixels.
  • We create a color scale, also tied to the ampl extent, going from yellow to red (lines 60–61).

With the scales in place, and the projection set up earlier, we can now create circles for our earthquakes. For cx and cy, we pass the lon and lat (in that order) to the projection. It returns the pixel values as [x, y], which we use to position the circles on the map.

src/helpers/drawMap.ts
import type { earthquake } from "$lib";
import {
  extent,
  geoEqualEarth,
  geoGraticule,
  geoPath,
  scaleLinear,
  scaleSqrt,
  select,
} from "d3";
import countries from "../data/countries.json" with { type: "json" };
 
export default function drawMap(
  id: string,
  earthquakes: earthquake[],
  width: number,
  height: number,
) {
  const svg = select(`#${id}`);
  svg.selectAll("*").remove();
 
  const sphere = {
    type: "Sphere",
  };
  const projection = geoEqualEarth()
    .fitSize([width, height], sphere);
 
  const geoGenerator = geoPath().projection(projection);
 
  svg
    .append("path")
    .datum(sphere)
    .attr("d", geoGenerator)
    .attr("fill", "black");
 
  svg
    .append("path")
    .datum(geoGraticule())
    .attr("d", geoGenerator)
    .attr("fill", "none")
    .attr("stroke", "white")
    .attr("opacity", 0.3);
 
  svg.selectAll(".countries")
    .data(countries.features)
    .join("path")
    .attr("class", "countries")
    .attr("d", geoGenerator)
    .attr("fill", "grey");
 
  const amplExtent = extent(
    earthquakes,
    (d: earthquake) => d.ampl,
  );
 
  const rScale = scaleSqrt()
    .domain(amplExtent)
    .range([2, 20]);
 
  const colorScale = scaleLinear().domain(amplExtent)
    .range(["yellow", "red"]);
 
  svg.selectAll("circle")
    .data(earthquakes)
    .join("circle")
    .attr("cx", (d: earthquake) => projection([d.lon, d.lat])[0])
    .attr("cy", (d: earthquake) => projection([d.lon, d.lat])[1])
    .attr("r", (d: earthquake) => rScale(d.ampl))
    .attr("fill", (d: earthquake) => colorScale(d.ampl));
}

Earthquakes on a map created with D3.

Look at that! Isn’t it beautiful? We plotted 2,000+ earthquakes on a map, with continents and graticules, using a fancy projection! 🥳

Animating a map

Now, how can we animate this map? It would be great to show the earthquakes as time goes by.

First, let’s create a button with a state called animate. When we click the button, we want the state to toggle between true and false. And of course, we pass this new state to our drawMap function.

The drawMap function will also need an animationDuration (10s for now) and a transitionDuration (500ms). The first defines the total length of the animation, while the second controls the duration of each circle’s animation. Since we don’t expect these values to change, we can simply define them as constant variables.

We can also go ahead and plan for a new paragraph that will display the current date of the animation. We’ll set its CSS display property to inline so it appears on the same line as the button.

src/components/Map.svelte
<script lang="ts">
    import earthquakesRaw from "../data/earthquakes.json";
    import drawMap from "../helpers/drawMap";
 
    const { id }: { id: string } = $props();
 
    const earthquakes = earthquakesRaw.map((d) => ({
        ...d,
        time: new Date(d.time),
    }));
 
    let width = $state(0);
    let height = $state(0);
 
    let animate = $state(false);
    const animationDuration = 10000;
    const transitionDuration = 500;
 
    $effect(() => {
        drawMap(
            id,
            earthquakes,
            width,
            height,
            animate,
            animationDuration,
            transitionDuration,
        );
    });
</script>
 
<button
    onclick={() => {
        animate = !animate;
    }}>{animate ? "Stop ⏹" : "Play ⏵"}</button
>
<p id={`${id}-date`}></p>
<div>
    <svg {id} bind:clientWidth={width} bind:clientHeight={height}></svg>
</div>
 
<style>
    div {
        width: 100%;
    }
    svg {
        aspect-ratio: 16/9;
    }
    p {
        display: inline;
    }
</style>

Now we can work on our drawMap function. Let’s retrieve the new animate parameter and use it. If animate is true, we’ll draw the earthquakes. Otherwise, we won’t.

We’ll use animationDuration and transitionDuration later.

src/helpers/drawMap.ts
import type { earthquake } from "$lib";
import {
  extent,
  geoEqualEarth,
  geoGraticule,
  geoPath,
  scaleLinear,
  scaleSqrt,
  select,
} from "d3";
import countries from "../data/countries.json" with { type: "json" };
 
export default function drawMap(
  id: string,
  earthquakes: earthquake[],
  width: number,
  height: number,
  animate: boolean,
  animationDuration: number,
  transitionDuration: number,
) {
  const svg = select(`#${id}`);
  svg.selectAll("*").remove();
 
  const sphere = {
    type: "Sphere",
  };
  const projection = geoEqualEarth()
    .fitSize([width, height], sphere);
 
  const geoGenerator = geoPath().projection(projection);
 
  svg
    .append("path")
    .datum(sphere)
    .attr("d", geoGenerator)
    .attr("fill", "black");
 
  svg
    .append("path")
    .datum(geoGraticule())
    .attr("d", geoGenerator)
    .attr("fill", "none")
    .attr("stroke", "white")
    .attr("opacity", 0.3);
 
  svg.selectAll(".countries")
    .data(countries.features)
    .join("path")
    .attr("class", "countries")
    .attr("d", geoGenerator)
    .attr("fill", "grey");
 
  if (animate) {
    const amplExtent = extent(
      earthquakes,
      (d: earthquake) => d.ampl,
    );
 
    const rScale = scaleSqrt()
      .domain(amplExtent)
      .range([2, 20]);
 
    const colorScale = scaleLinear().domain(amplExtent)
      .range(["yellow", "red"]);
 
    svg.selectAll("circle")
      .data(earthquakes)
      .join("circle")
      .attr("cx", (d: earthquake) => projection([d.lon, d.lat])[0])
      .attr("cy", (d: earthquake) => projection([d.lon, d.lat])[1])
      .attr("r", (d: earthquake) => rScale(d.ampl))
      .attr("fill", (d: earthquake) => colorScale(d.ampl));
  }
}

This was easy — and we’re on the right path! Now we can focus on creating an animation with D3.

Toggling earthquakes on a map created with D3.

One way to reveal elements one after the other with D3 is by using .transition() with .delay(). We could first draw the circles with an r of 0, then give each a delay based on its time value. Once the delay expires, we could use .transition() again to increase the r to its actual size.

If you want to learn more about D3 transitions, be sure to check the previous lesson: Animated charts with D3.js 🧑‍🎨

And how can we compute the right delay for each circle? With a scale, of course!

Let’s code that!

src/helpers/drawMap.ts
import type { earthquake } from "$lib";
import {
  extent,
  geoEqualEarth,
  geoGraticule,
  geoPath,
  scaleLinear,
  scaleSqrt,
  select,
} from "d3";
import countries from "../data/countries.json" with { type: "json" };
 
export default function drawMap(
  id: string,
  earthquakes: earthquake[],
  width: number,
  height: number,
  animate: boolean,
  animationDuration: number,
  transitionDuration: number,
) {
  const svg = select(`#${id}`);
  svg.selectAll("*").remove();
 
  const sphere = {
    type: "Sphere",
  };
  const projection = geoEqualEarth()
    .fitSize([width, height], sphere);
 
  const geoGenerator = geoPath().projection(projection);
 
  svg
    .append("path")
    .datum(sphere)
    .attr("d", geoGenerator)
    .attr("fill", "black");
 
  svg
    .append("path")
    .datum(geoGraticule())
    .attr("d", geoGenerator)
    .attr("fill", "none")
    .attr("stroke", "white")
    .attr("opacity", 0.3);
 
  svg.selectAll(".countries")
    .data(countries.features)
    .join("path")
    .attr("class", "countries")
    .attr("d", geoGenerator)
    .attr("fill", "grey");
 
  if (animate) {
    const amplExtent = extent(
      earthquakes,
      (d: earthquake) => d.ampl,
    );
 
    const rScale = scaleSqrt()
      .domain(amplExtent)
      .range([2, 20]);
 
    const colorScale = scaleLinear().domain(amplExtent)
      .range(["yellow", "red"]);
 
    const timeExtent = extent(
      earthquakes,
      (d: earthquake) => d.time,
    );
    const delayScale = scaleLinear().domain(timeExtent)
      .range([0, animationDuration]);
 
    svg.selectAll("circle")
      .data(earthquakes)
      .join("circle")
      .attr("cx", (d: earthquake) => projection([d.lon, d.lat])[0])
      .attr("cy", (d: earthquake) => projection([d.lon, d.lat])[1])
      .attr("fill", (d: earthquake) => colorScale(d.ampl))
      .attr("r", 0)
      .transition()
      .duration(transitionDuration)
      .delay((d: earthquake) => delayScale(d.time))
      .attr("r", (d: earthquake) => rScale(d.ampl));
  }
}

And just like that, we animated our earthquakes! But it would be even better if the earthquakes grew in size and then disappeared.

Animated earthquakes on a map created with D3.

Let’s add another transition so that after growing, the circles return to an r of 0.

We can also update the paragraph we created earlier to show the current date. We’ll use the globally available setInterval function, which runs a function at a specified interval. Here, every 100ms, we use the .invert() method of our delayScale to retrieve a corresponding time value. We then format this time using the formatDate function from the journalism library, which I maintain. Finally, we insert the formatted date into the paragraph.

To stop the setInterval from running indefinitely, we store its intervalId. Once the animation has been running longer than the total duration, we stop it using clearInterval(intervalId). We also return the intervalId so it can be used in our <Map /> component. More on that in a moment.

src/helpers/drawMap.ts
import type { earthquake } from "$lib";
import {
  extent,
  geoEqualEarth,
  geoGraticule,
  geoPath,
  scaleLinear,
  scaleSqrt,
  select,
} from "d3";
import { formatDate } from "@nshiab/journalism/web";
import countries from "../data/countries.json" with { type: "json" };
 
export default function drawMap(
  id: string,
  earthquakes: earthquake[],
  width: number,
  height: number,
  animate: boolean,
  animationDuration: number,
  transitionDuration: number,
) {
  const svg = select(`#${id}`);
  svg.selectAll("*").remove();
 
  const sphere = {
    type: "Sphere",
  };
  const projection = geoEqualEarth()
    .fitSize([width, height], sphere);
 
  const geoGenerator = geoPath().projection(projection);
 
  svg
    .append("path")
    .datum(sphere)
    .attr("d", geoGenerator)
    .attr("fill", "black");
 
  svg
    .append("path")
    .datum(geoGraticule())
    .attr("d", geoGenerator)
    .attr("fill", "none")
    .attr("stroke", "white")
    .attr("opacity", 0.3);
 
  svg.selectAll(".countries")
    .data(countries.features)
    .join("path")
    .attr("class", "countries")
    .attr("d", geoGenerator)
    .attr("fill", "grey");
 
  if (animate) {
    const amplExtent = extent(
      earthquakes,
      (d: earthquake) => d.ampl,
    );
 
    const rScale = scaleSqrt()
      .domain(amplExtent)
      .range([2, 20]);
 
    const colorScale = scaleLinear().domain(amplExtent)
      .range(["yellow", "red"]);
 
    const timeExtent = extent(
      earthquakes,
      (d: earthquake) => d.time,
    );
    const delayScale = scaleLinear().domain(timeExtent)
      .range([0, animationDuration]);
 
    svg.selectAll("circle")
      .data(earthquakes)
      .join("circle")
      .attr("cx", (d: earthquake) => projection([d.lon, d.lat])[0])
      .attr("cy", (d: earthquake) => projection([d.lon, d.lat])[1])
      .attr("fill", (d: earthquake) => colorScale(d.ampl))
      .attr("r", 0)
      .transition()
      .duration(transitionDuration)
      .delay((d: earthquake) => delayScale(d.time))
      .attr("r", (d: earthquake) => rScale(d.ampl))
      .transition()
      .duration(transitionDuration)
      .attr("r", 0);
 
    const dateParagraph = document.querySelector(`#${id}-date`);
    if (dateParagraph) {
      const interval = 100;
      let duration = 0;
      const intervalId = setInterval(() => {
        if (duration > animationDuration) {
          clearInterval(intervalId);
        } else {
          const date = new Date(delayScale.invert(duration));
          dateParagraph.innerHTML = formatDate(
            date,
            "YYYY-MM-DD",
            { utc: true },
          );
          duration += interval;
        }
      }, interval);
      return intervalId;
    }
  }
}

We’re getting there! But we still need to take care of the button and the date paragraph once the animation is done.

Animated earthquakes appearing and disappearing on a map created with D3.

We can tweak our $effect in the <Map /> component to fix the last few issues.

First, we retrieve the intervalId from drawMap. We also create a setTimeout, which triggers a function after a specified amount of time. In this case, if animationDuration + transitionDuration has elapsed, it means the last circle has finished animating. We can then switch the animate state back to false, which updates the button text. We also clear the date paragraph.

But be careful: when working with events or timers, it’s important to remember that you need to clean them up manually. For example, what happens if a user clicks the button multiple times? As it stands, it would create multiple setInterval calls, and the date paragraph would be updated chaotically!

That’s why at the end of the $effect, we add a cleanup function. This function is called before the effect runs again — perfect for canceling the previous setInterval and setTimeout before starting new ones.

src/components/Map.svelte
<script lang="ts">
    import earthquakesRaw from "../data/earthquakes.json";
    import drawMap from "../helpers/drawMap";
 
    const { id }: { id: string } = $props();
 
    const earthquakes = earthquakesRaw.map((d) => ({
        ...d,
        time: new Date(d.time),
    }));
 
    let width = $state(0);
    let height = $state(0);
 
    let animate = $state(false);
    const animationDuration = 10000;
    const transitionDuration = 500;
 
    $effect(() => {
        const intervalId = drawMap(
            id,
            earthquakes,
            width,
            height,
            animate,
            animationDuration,
            transitionDuration,
        );
 
        const timeoutId = setTimeout(() => {
            animate = false;
            const dateParagraph = document.querySelector(`#${id}-date`);
            if (dateParagraph) {
                dateParagraph.textContent = "";
            }
        }, animationDuration + transitionDuration);
 
        return () => {
            clearInterval(intervalId);
            clearTimeout(timeoutId);
        };
    });
</script>
 
<button
    onclick={() => {
        animate = !animate;
    }}>{animate ? "Stop ⏹" : "Play ⏵"}</button
>
<p id={`${id}-date`}></p>
<div>
    <svg {id} bind:clientWidth={width} bind:clientHeight={height}></svg>
</div>
 
<style>
    div {
        width: 100%;
    }
    svg {
        aspect-ratio: 16/9;
    }
    p {
        display: inline;
    }
</style>

And it woooooorks! A beautiful animated map of earthquakes made with D3.js and Svelte!

Final animation of earthquakes animated on a map created with D3 and Svelte.

Building the page

So far, we’ve been running our page with a local server. If you want to build your website, run deno task build. Svelte will minimize and optimize your code and generate the website files in the build folder. You can then host these files on a server to share your work with the world!

Conclusion

Congratulations! You coded a smooth animated map. It was no easy task, but you made it to the end!

If you want to explore more D3 examples, be sure to check out the D3 gallery, especially the Maps section. All the code is open source!

I hope you enjoyed the lesson, and I can’t wait to see your next map published on the Web! 😊

Enjoyed this? Want to know when new lessons are available? Subscribe to the newsletter ✉️ and give a ⭐ to the GitHub repository to keep me motivated! Get in touch if you have any questions.