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 đ§âđš.
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
.
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
estearthquake
et lestatus
estreviewed
. - 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
etlongitude
avec des noms plus courts. - Nous conservons uniquement les colonnes utiles :
time
,ampl
,lat
etlon
. - 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 nonsda
) 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 formatWGS84
. - 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 nonsda
) Ă utiliser dans notre projet Svelte. Ici, nous utilisonswriteGeoData
Ă la place dewriteData
, en passant lâoptionrewind
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.
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();
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
etlon
, dans une nouvelle colonnegeom
. - 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()
etgraticule()
pour rendre la carte plus lisible avec la projectionequal-earth
. - Nous dessinons dâabord les pays.
- Puis nous dessinons les tremblements de terre, en utilisant leurs valeurs
ampl
.
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();
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.
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Ă©mentsvg
dans lequel nous dessinerons la carte. - Les données des
earthquakes
(tremblements de terre). - La
width
et laheight
(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 !
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 nomearthquakesRaw
(ligne 2), puis les parcourt pour convertir les valeurs detime
en objetsDate
(lignes 7â10). - RĂ©cupĂšre lâ
id
depuis lesprops
(ligne 5). - Crée des états
width
etheight
(lignes 12â13) et les lie aux propriĂ©tĂ©sclientWidth
etclientHeight
de lâĂ©lĂ©mentsvg
dans lequel nous allons dessiner notre carte (ligne 21). Nous parlerons plus en détail des élémentssvg
un peu plus tard. - Utilise la rune
$effect
pour appelerdrawMap
avec toutes les props et états. Cela signifie que Svelte rappelleradrawMap
Ă chaque fois quâun des arguments change, y compriswidth
etheight
, rendant la carte responsive. - Pour conserver un ratio constant pour notre carte, nous encapsulons le
svg
dans unediv
et utilisons des balisesstyle
pour fixer la largeur de ladiv
Ă100%
et le ratio dusvg
Ă16/9
.
<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 !
<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" />
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éthodefitSize
pour adapter la carte Ă lawidth
et laheight
dusvg
. Nous lui passons aussi lasphere
pour assurer un bon positionnement. Si nous voulions zoomer sur un pays, nous pourrions passer ce pays au lieu de lasphere
. La projection est stockée dans la variableprojection
. - Nous appelons
geoPath
et lui passons notreprojection
. Le résultat, stocké dans la variablegeoGenerator
, est une fonction capable de dessiner des formes à partir des latitudes et longitudes. - Nous ajoutons un élément
path
ausvg
, 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 dupath
) Ă lâaide de la fonctiongeoGenerator
. 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
).
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 !
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.
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);
}
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
.
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");
}
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 deampl
(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 deampl
, 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.
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));
}
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.
<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.
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.
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 !
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.
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.
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.
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.
<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 !
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 ! đ