Cartes animĂ©es avec D3 đŸ—ș

Cartes animĂ©es avec D3.js đŸ—ș

Comme nous l’avons vu dans la leçon prĂ©cĂ©dente, D3.js est une librairie formidable pour crĂ©er des graphiques. Mais elle excelle aussi dans la crĂ©ation de cartes.

Dans ce projet, nous allons utiliser des donnĂ©es sismiques pour crĂ©er une carte animĂ©e avec D3 et Svelte, comme illustrĂ© ci-dessous. Je vous recommande fortement de complĂ©ter les sections prĂ©cĂ©dentes du cours avant de vous lancer, en particulier Graphiques animĂ©s avec D3.js 🧑‍🎹.

Animation de dĂ©monstration d’un tremblement de terre animĂ© sur une carte créée avec D3 et Svelte.

Vous voulez ĂȘtre prĂ©venu quand de nouvelles leçons sont publiĂ©es ? Abonnez-vous Ă  l'infolettre ✉ et donnez une ⭐ au cours sur GitHub pour me garder motivé ! Cliquez ici pour me contacter.

Configuration

Nous allons utiliser setup-sda pour configurer et installer tout ce dont nous avons besoin.

Ouvrez un nouveau dossier avec VS Code et lancez deno -A jsr:@nshiab/setup-sda --svelte.

Capture d’écran de VS Code aprĂšs l’exĂ©cution de la librairie setup-sda.

Données sismiques et frontiÚres des pays

Pour rĂ©cupĂ©rer les donnĂ©es sismiques, j’ai utilisĂ© le catalogue des tremblements de terre de l’USGS. Comme l’annĂ©e 2021 s’est rĂ©vĂ©lĂ©e trĂšs active, j’ai tĂ©lĂ©chargĂ© deux fichiers CSV pour cette annĂ©e et les ai tĂ©lĂ©versĂ©s sur GitHub. Ce sont les mĂȘmes donnĂ©es que dans le projet D3 prĂ©cĂ©dent.

Pour les frontiÚres des pays, je les ai téléchargées depuis Natural Earth sous forme de shapefile compressé.

En utilisant la librairie Simple Data Analysis dans le dossier sda, nous pouvons facilement les récupérer et les mettre en cache. Mettez à jour sda/main.ts, puis exécutez deno task sda pour lancer et surveiller le script.

Pour les tremblements de terre :

  • Nous gardons uniquement les lignes dont le type est earthquake et le status est reviewed.
  • Nous filtrons les sĂ©ismes de magnitude infĂ©rieure Ă  5.
  • Comme nous allons dessiner des cercles sur notre carte, il est plus intĂ©ressant d’utiliser l’amplitude, que l’on peut facilement calculer Ă  partir de la magnitude.
  • Nous renommons latitude et longitude avec des noms plus courts.
  • Nous conservons uniquement les colonnes utiles : time, ampl, lat et lon.
  • Nous arrondissons les valeurs numĂ©riques Ă  trois dĂ©cimales.
  • Sur notre carte, nous voulons que les tremblements de terre les plus puissants apparaissent au-dessus des plus faibles. Nous trions donc les donnĂ©es par ampl de façon croissante, car D3 dessine les Ă©lĂ©ments dans l’ordre oĂč ils apparaissent dans les donnĂ©es.
  • AprĂšs avoir affichĂ© un aperçu de la table pour vĂ©rifier que tout est en ordre, nous Ă©crivons les donnĂ©es dans un fichier JSON dans le dossier src (et non sda) pour les utiliser dans notre projet Svelte. Comme ce sont juste des points, il est plus simple de garder ces donnĂ©es sous forme tabulaire. Nous avons plus de 2 000 tremblements de terre Ă  dessiner et animer sur la carte.

Pour les pays :

  • Lors du chargement avec loadDataGeo, nous nous assurons de reprojeter les donnĂ©es au format WGS84.
  • Nous sĂ©lectionnons uniquement la colonne geom, car nous n’avons besoin que des frontiĂšres.
  • AprĂšs avoir vĂ©rifiĂ© les donnĂ©es dans la console, nous Ă©crivons un fichier GeoJSON dans le dossier src (et non sda) Ă  utiliser dans notre projet Svelte. Ici, nous utilisons writeGeoData Ă  la place de writeData, en passant l’option rewind pour que les coordonnĂ©es soient dans le bon ordre et que D3 puisse dessiner correctement les polygones. Nous avons 127 entitĂ©s gĂ©ographiques que nous allons ajouter Ă  notre carte.
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();

Capture d’écran de VS Code avec des tables de donnĂ©es affichĂ©es dans le terminal.

Dataviz exploratoire

Avant de plonger dans le code D3, j’utilise toujours SDA et Plot pour tracer rapidement une premiĂšre dataviz. Cela aide Ă  mieux comprendre les donnĂ©es que l’on a en main.

Comme nous avons des données pour le monde entier, nous pouvons essayer la projection equal-earth, qui est également disponible dans D3. (Plus de détails sur les projections un peu plus bas.)

Voici une explication pas Ă  pas du nouveau code ci-dessous :

  • Nous clonons la table des tremblements de terre.
  • Nous crĂ©ons des gĂ©omĂ©tries de points Ă  partir des colonnes lat et lon, dans une nouvelle colonne geom.
  • Nous insĂ©rons la table des pays, qui contient dĂ©jĂ  une colonne geom.
  • Nous utilisons la mĂ©thode writeMap avec Plot pour exporter une carte au format PNG.
  • Nous ajoutons sphere() et graticule() pour rendre la carte plus lisible avec la projection equal-earth.
  • Nous dessinons d’abord les pays.
  • Puis nous dessinons les tremblements de terre, en utilisant leurs valeurs ampl.
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();

Capture d’écran de VS Code avec une carte affichĂ©e.

Tout semble bien se passer ! Nous pouvons maintenant plonger dans notre projet Svelte.

Composant Svelte

Créons un nouveau composant Svelte avec une fonction utilitaire pour notre carte.

Mais avant cela, il est toujours utile de dĂ©finir quelques types que nous utiliserons Ă  plusieurs reprises. Dans src/lib/index.ts, nous pouvons placer des types et des variables qui seront facilement accessibles dans l’ensemble de notre projet Svelte.

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

CrĂ©ons maintenant la fonction utilitaire drawMap.ts dans le dossier src/helpers (et non dans sda, encore une fois). C’est ici que notre code D3 va vivre. Cette fonction aura besoin de quelques Ă©lĂ©ments :

  • Un id, qui correspondra Ă  l’id de l’élĂ©ment svg dans lequel nous dessinerons la carte.
  • Les donnĂ©es des earthquakes (tremblements de terre).
  • La width et la height (largeur et hauteur) de la carte.

Pour l’instant, contentons-nous d’afficher les paramùtres dans la console.

À noter que comme nous avons utilisĂ© src/lib/index.ts, nous pouvons facilement importer nos types (et tout autre Ă©lĂ©ment que nous y aurons placĂ©) avec from $lib. C’est un raccourci bien pratique !

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 });
}

Nous pouvons maintenant créer un nouveau composant Map.svelte qui :

  • Importe les donnĂ©es de earthquakes.json sous le nom earthquakesRaw (ligne 2), puis les parcourt pour convertir les valeurs de time en objets Date (lignes 7–10).
  • RĂ©cupĂšre l’id depuis les props (ligne 5).
  • CrĂ©e des Ă©tats width et height (lignes 12–13) et les lie aux propriĂ©tĂ©s clientWidth et clientHeight de l’élĂ©ment svg dans lequel nous allons dessiner notre carte (ligne 21). Nous parlerons plus en dĂ©tail des Ă©lĂ©ments svg un peu plus tard.
  • Utilise la rune $effect pour appeler drawMap avec toutes les props et Ă©tats. Cela signifie que Svelte rappellera drawMap Ă  chaque fois qu’un des arguments change, y compris width et height, rendant la carte responsive.
  • Pour conserver un ratio constant pour notre carte, nous encapsulons le svg dans une div et utilisons des balises style pour fixer la largeur de la div Ă  100% et le ratio du svg Ă  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>

Et enfin, nous pouvons importer notre nouveau composant <Map /> dans notre page, situĂ©e dans src/routes/+page.svelte. Nous lui attribuons un id appropriĂ©. Tant qu’à faire, nous pouvons aussi mettre Ă  jour le texte de la page.

Si vous Ă©tiez encore en train de surveiller sda/main.ts dans votre terminal, vous pouvez arrĂȘter le processus (CTRL + C) et lancer Ă  la place deno task dev pour dĂ©marrer un serveur local. Ouvrez ensuite l’URL affichĂ©e dans votre terminal dans votre navigateur prĂ©fĂ©rĂ©.

Dans la console de votre navigateur, vous devriez voir le message provenant de drawMap.ts. On est prĂȘt Ă  coder notre carte !

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 avec un projet Svelte en cours d’exĂ©cution localement.

Dessiner une carte avec D3

Passons maintenant Ă  D3 !

ArrĂȘtez votre serveur local (CTRL + C) et installez D3 avec la commande deno add npm:d3. Ensuite, relancez le serveur local avec deno task dev.

Dans la leçon précédente, nous avons appris que les scales de D3 permettent de convertir des valeurs de données en valeurs de pixels, en couleurs, etc.

Quand on travaille avec des cartes, il faut faire la mĂȘme chose pour les coordonnĂ©es gĂ©ographiques (latitude et longitude) : les convertir en pixels. Mais
 il y a un hic. La Terre est ronde, alors que nos Ă©crans (et les cartes papier) sont plats !

C’est pour cette raison que les cartographes ont inventĂ© les projections, qui sont un peu comme des scales, mais pour les cartes. Chaque projection, avec ses calculs mathĂ©matiques, a ses avantages et ses inconvĂ©nients. Par exemple, la projection de Mercator — probablement la plus connue — est idĂ©ale pour la navigation car elle prĂ©serve les directions, mais elle dĂ©forme les distances et les surfaces dans les rĂ©gions proches des pĂŽles.

Une des raisons pour lesquelles D3 est excellent pour les cartes, c’est qu’il donne accĂšs trĂšs facilement Ă  Ă©normĂ©ment de projections. Dans ce projet, nous allons utiliser la projection geoEqualEarth(), comme nous l’avons fait avec Plot au dĂ©but.

Mettons Ă  jour drawMap.ts pour l’utiliser. Voici ce que fait le nouveau code, Ă©tape par Ă©tape :

  • Nous sĂ©lectionnons l’élĂ©ment svg dans lequel nous allons dessiner la carte, et nous supprimons tout contenu existant pour Ă©viter d’empiler les Ă©lĂ©ments Ă  chaque rendu (lignes 10–11).
  • Nous crĂ©ons une sphere qui reprĂ©sentera l’ensemble de la carte (lignes 13–15). Elle n’a pas de coordonnĂ©es, et ce n’est pas un problĂšme — D3 sait la gĂ©rer. Nous fournirons les coordonnĂ©es pour les autres Ă©lĂ©ments de la carte.
  • Nous appelons la projection geoEqualEarth et utilisons la mĂ©thode fitSize pour adapter la carte Ă  la width et la height du svg. Nous lui passons aussi la sphere pour assurer un bon positionnement. Si nous voulions zoomer sur un pays, nous pourrions passer ce pays au lieu de la sphere. La projection est stockĂ©e dans la variable projection.
  • Nous appelons geoPath et lui passons notre projection. Le rĂ©sultat, stockĂ© dans la variable geoGenerator, est une fonction capable de dessiner des formes Ă  partir des latitudes et longitudes.
  • Nous ajoutons un Ă©lĂ©ment path au svg, qui reprĂ©sentera une forme.
  • Dans la leçon prĂ©cĂ©dente, nous avions utilisĂ© .data() pour lier un tableau d’objets. Mais ici, comme nous n’avons qu’un seul Ă©lĂ©ment (sphere), nous utilisons .datum() Ă  la place.
  • Nous dĂ©finissons l’attribut d (qui dĂ©crit la forme du path) Ă  l’aide de la fonction geoGenerator. Elle lit les donnĂ©es liĂ©es et les traduit en coordonnĂ©es — ici, elle sert Ă  remplir l’arriĂšre-plan de la carte.
  • Enfin, nous dĂ©finissons d’autres attributs, comme la couleur de remplissage (fill).
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");
}

Si vous faites un clic droit sur la zone noire et que vous l’inspectez dans votre navigateur, vous verrez votre Ă©lĂ©ment svg contenant un path. Cet Ă©lĂ©ment path possĂšde un attribut d qui contient des coordonnĂ©es SVG gĂ©nĂ©rĂ©es par notre code !

Une sphÚre créée avec D3.

Pour rendre notre carte plus lisible, nous pouvons également ajouter des graticules. La fonction geoGraticule() génÚre un objet GeoJSON contenant les coordonnées des graticules.

Nous les plaçons aprĂšs la sphĂšre dans le code pour nous assurer qu’ils seront dessinĂ©s au-dessus. Pour leur donner une apparence gris clair, nous les dessinons en blanc avec une opacitĂ© rĂ©duite.

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 créés avec D3.

C’est le bon moment pour ajouter les pays à notre carte. Comme nous ne cherchons pas à afficher les frontiùres exactes, nous pouvons simplement les remplir en gris, sans contour.

Il y en a plus de 100, donc nous utilisons la syntaxe .data(countries.features) et .join("path") pour lier les données à de nouveaux éléments path. Pour éviter de sélectionner accidentellement des éléments précédents, nous utilisons selectAll avec la classe .countries, que nous définissons comme attribut.

Nous utilisons .features dans .data(countries.features) parce que le fichier est Ă©crit au format GeoJSON, oĂč toutes les entitĂ©s sont stockĂ©es dans une liste sous la clĂ© features.

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");
}

Pays sur une carte créée avec D3.

Il ne reste plus que les tremblements de terre Ă  ajouter !

Il y a quelques éléments à prendre en compte pour bien les afficher :

  • Nous utilisons la fonction extent pour rĂ©cupĂ©rer les valeurs minimale et maximale de ampl (lignes 51–54). Cette fonction retourne une liste du type [min, max].
  • Nous crĂ©ons une Ă©chelle pour l’attribut r (lignes 56–58). Nous utilisons une Ă©chelle racine carrĂ©e (scaleSqrt) car nous voulons que l’aire du cercle soit proportionnelle aux donnĂ©es. Nous dĂ©finissons son domaine avec l’intervalle de ampl, et son intervalle de sortie entre 2 et 20 pixels de rayon.
  • Nous crĂ©ons une Ă©chelle de couleur, Ă©galement basĂ©e sur l’étendue de ampl, allant du jaune au rouge (lignes 60–61).

Une fois les échelles en place et la projection définie, nous pouvons créer les cercles représentant les tremblements de terre. Pour cx et cy, nous passons lon et lat (dans cet ordre) à la projection. Elle retourne les coordonnées en pixels sous forme de [x, y], que nous utilisons pour positionner les cercles sur la carte.

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));
}

Tremblements de terre sur une carte créée avec D3.

Regardez-moi ça ! N’est-ce pas magnifique ? Nous avons affichĂ© plus de 2 000 tremblements de terre sur une carte, avec les continents et les graticules, en utilisant une projection Ă©lĂ©gante ! đŸ„ł

Animer une carte

Maintenant, comment peut-on animer cette carte ? Ce serait génial de faire apparaßtre les tremblements de terre au fil du temps.

Commençons par crĂ©er un button avec un Ă©tat appelĂ© animate. Lorsqu’on clique sur le bouton, on veut que cet Ă©tat bascule entre true et false. Et bien sĂ»r, on transmet ce nouvel Ă©tat Ă  notre fonction drawMap.

La fonction drawMap aura Ă©galement besoin de deux constantes : animationDuration (10 secondes pour l’instant) et transitionDuration (500 ms). Le premier dĂ©finit la durĂ©e totale de l’animation, tandis que le second contrĂŽle la durĂ©e de l’animation de chaque cercle. Comme ces valeurs ne sont pas censĂ©es changer, on peut simplement les dĂ©finir comme constantes.

On peut aussi dĂ©jĂ  prĂ©voir un nouveau paragraphe qui affichera la date actuelle de l’animation. On lui appliquera une propriĂ©tĂ© CSS display: inline pour qu’il apparaisse sur la mĂȘme ligne que le bouton.

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>

Nous pouvons maintenant travailler sur notre fonction drawMap. Récupérons le nouveau paramÚtre animate et utilisons-le. Si animate est à true, on dessine les tremblements de terre. Sinon, on ne les affiche pas.

Nous utiliserons animationDuration et transitionDuration un peu plus tard.

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));
  }
}

C’était facile — et on est sur la bonne voie ! On peut maintenant se concentrer sur la crĂ©ation d’une animation avec D3.

Activation/désactivation des tremblements de terre sur une carte créée avec D3.

Une façon de faire apparaĂźtre des Ă©lĂ©ments les uns aprĂšs les autres avec D3, c’est d’utiliser .transition() avec .delay(). On pourrait commencer par dessiner les cercles avec un r de 0, puis leur appliquer un dĂ©lai basĂ© sur leur valeur time. Une fois le dĂ©lai Ă©coulĂ©, on utiliserait Ă  nouveau .transition() pour augmenter leur r Ă  la taille voulue.

Si vous voulez en savoir plus sur les transitions D3, pensez Ă  consulter la leçon prĂ©cĂ©dente : Graphiques animĂ©s avec D3.js 🧑‍🎹

Et comment calculer le bon délai pour chaque cercle ? Grùce à une échelle, bien sûr !

Passons au code !

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));
  }
}

Et voilĂ  ! Nous avons animĂ© nos tremblements de terre ! Mais ce serait encore mieux s’ils grossissaient puis disparaissaient.

Tremblements de terre animés sur une carte créée avec D3.

Ajoutons donc une autre transition : aprĂšs avoir grossi, les cercles reviendront Ă  un r de 0.

On peut Ă©galement mettre Ă  jour le paragraphe que nous avions créé plus tĂŽt pour y afficher la date actuelle de l’animation. Pour cela, on utilise la fonction globale setInterval, qui exĂ©cute une fonction Ă  intervalle rĂ©gulier. Ici, toutes les 100 ms, on utilise la mĂ©thode .invert() de notre delayScale pour obtenir une valeur de temps correspondante. On formate ensuite cette valeur avec la fonction formatDate de la librairie journalism, que je maintiens. Enfin, on insĂšre cette date formatĂ©e dans le paragraphe.

Pour Ă©viter que le setInterval ne tourne indĂ©finiment, on stocke son intervalId. Une fois que l’animation a durĂ© plus longtemps que sa durĂ©e totale, on l’arrĂȘte avec clearInterval(intervalId). On retourne aussi le intervalId pour pouvoir l’utiliser dans notre composant <Map />. On y reviendra dans un instant.

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;
    }
  }
}

On y est presque ! Mais il reste encore Ă  gĂ©rer le bouton et le paragraphe de date une fois l’animation terminĂ©e.

Tremblements de terre animés apparaissant et disparaissant sur une carte créée avec D3.

On peut ajuster notre $effect dans le composant <Map /> pour corriger les derniers détails.

D’abord, on rĂ©cupĂšre le intervalId depuis drawMap. On crĂ©e aussi un setTimeout, qui dĂ©clenche une fonction aprĂšs un certain temps. Dans ce cas, si animationDuration + transitionDuration est Ă©coulĂ©, cela signifie que le dernier cercle a terminĂ© son animation. On peut alors repasser l’état animate Ă  false, ce qui met Ă  jour le texte du bouton. On efface aussi le contenu du paragraphe de date.

Mais attention: lorsqu’on travaille avec des Ă©vĂ©nements ou des minuteries, il est important de se rappeler qu’il faut les nettoyer manuellement. Par exemple, que se passe-t-il si un utilisateur clique plusieurs fois sur le bouton ? Tel quel, cela crĂ©erait plusieurs appels Ă  setInterval, et le paragraphe de date serait mis Ă  jour de maniĂšre chaotique !

C’est pourquoi, Ă  la fin du $effect, on ajoute une fonction de nettoyage. Cette fonction est appelĂ©e avant que l’effet ne soit rĂ©exĂ©cutĂ© — parfait pour annuler les prĂ©cĂ©dents setInterval et setTimeout avant d’en dĂ©marrer de nouveaux.

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>

Et ça fonctioooooonne ! Une magnifique carte animée des tremblements de terre, réalisée avec D3.js et Svelte !

Animation finale des tremblements de terre sur une carte créée avec D3 et Svelte.

Générer la page

Jusqu’à maintenant, nous avons exĂ©cutĂ© notre page via un serveur local. Si vous souhaitez construire votre site web, lancez la commande deno task build. Svelte va alors minimiser et optimiser votre code, et gĂ©nĂ©rer les fichiers du site dans le dossier build. Vous pourrez ensuite hĂ©berger ces fichiers sur un serveur pour partager votre travail avec le monde entier !

Conclusion

FĂ©licitations ! Vous avez codĂ© une carte animĂ©e fluide. Ce n’était pas une mince affaire, mais vous ĂȘtes allĂ© jusqu’au bout !

Si vous souhaitez explorer d’autres exemples avec D3, n’hĂ©sitez pas Ă  consulter la galerie D3, en particulier la section Cartes. Tout le code y est en code ouvert !

J’espĂšre que cette leçon vous a plu, et j’ai hĂąte de voir votre prochaine carte publiĂ©e sur le Web ! 😊

Vous avez aimé ? Vous voulez ĂȘtre prĂ©venu quand de nouvelles leçons sont publiĂ©es ? Abonnez-vous Ă  l'infolettre ✉ et donnez une ⭐ au cours sur GitHub pour me garder motivé ! Contactez-moi si vous avez des questions.