Graphiques animés avec D3.js 🧑🎨
D3.js est la librairie de visualisation de données la plus célèbre, et pour une bonne raison : c’est une collection ingénieusement conçue de fonctions et de méthodes qui permettent de créer des visualisations de données entièrement personnalisables sur le Web sous forme d’images SVG. Elle a été créée vers 2011 par Mike Bostock et d’autres informaticiens. Plus récemment, Philippe Rivière est devenu l’un des principaux mainteneurs et contributeurs.
Dans une leçon précédente, nous avons utilisé la librairie Plot pour nos visualisations. En coulisses, Plot utilise… D3 ! Et elle est principalement maintenue par Bostock et Rivière ! Surprise ! 😁 Plot est formidable et je l’utilise la majorité du temps pour mes dataviz. Mais lorsque je veux créer quelque chose de très personnalisé, en particulier avec des animations, D3 reste ma préférence.
Dans ce projet, nous allons utiliser des données sur les tremblements de terre pour créer un nuage de points animés avec D3 et Svelte, comme montré ci-dessous. Je vous recommande fortement de compléter les sections précédentes du cours avant de vous lancer. Je ne passerai pas beaucoup de temps sur la configuration, l’utilisation de la librairie Simple Data Analysis ou du framework Svelte.
Configuration
Nous allons utiliser setup-sda pour tout configurer et installer ce dont nous avons besoin.
Ouvrez un nouveau dossier avec VS Code et exécutez deno -A jsr:@nshiab/setup-sda --svelte
.
Données sur les tremblements de terre
Pour récupérer les données sur les tremblements de terre, j’ai utilisé le catalogue USGS Earthquake. Comme l’année 2021 semblait particulièrement active, j’ai téléchargé deux fichiers CSV pour cette année et je les ai mis en ligne sur GitHub.
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 l’exécuter et l’observer.
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.logTable();
await sdb.done();
Comme vous pouvez le voir, notre jeu de données contient plus de 28 000 tremblements de terre et de nombreuses colonnes. Nous pouvons le filtrer et ne garder que ce qui nous intéresse :
- Nous ne voulons que
earthquake
dans la colonnetype
etreviewed
dans la colonnestatus
. - Nous filtrons pour ne garder que les tremblements de terre qui pourraient causer des dommages, avec une magnitude de 5 ou plus.
- Nous renommons les colonnes
latitude
etlongitude
avec des noms plus courts. - Nous ne gardons que les colonnes
time
,lat
,lon
,depth
etmag
. - Nous arrondissons les valeurs numériques à 3 décimales.
- Pour que ce soit plus logique, nous rendons les valeurs de
depth
négatives. - Et enfin, nous supprimons les doublons.
Nous obtenons environ 2 000 tremblements de terre.
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.sort({ time: "asc" });
await earthquakes.keep({
"type": "earthquake",
"status": "reviewed",
});
await earthquakes.filter(`mag >= 5`);
await earthquakes.renameColumns({ latitude: "lat", longitude: "lon" });
await earthquakes.selectColumns([
"time",
"lat",
"lon",
"depth",
"mag",
]);
await earthquakes.round(["lat", "lon", "depth", "mag"], {
decimals: 3,
});
await earthquakes.updateColumn("depth", `depth * -1`);
await earthquakes.removeDuplicates();
await earthquakes.logTable();
await sdb.done();
Exploration des données
Avant de plonger dans des visualisations personnalisées, il est important d’explorer un peu les données. Nous pouvons utiliser la fonction writeChart
avec Plot pour dessiner rapidement quelques graphiques et avoir une idée de ce avec quoi nous travaillons :
sda/output/earthquakes-lat-lon.png
nous montre où se produisent la plupart des tremblements de terre — le long des failles sismiques. Toutes les coordonnées semblent correctes.- Avec
sda/output/earthquakes-time-mag.png
, on peut clairement voir les tremblements de terre les plus puissants. Les trois de magnitude supérieure à 8 correspondent à la Liste des tremblements de terre en 2021 sur Wikipédia. sda/output/earthquakes-mag-depth.png
suggère que la plupart des séismes se produisent à moins de 250 km de profondeur. Les quatre plus puissants étaient proches de la surface.
import { SimpleDB } from "@nshiab/simple-data-analysis";
import { dot, plot } 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.sort({ time: "asc" });
await earthquakes.keep({
"type": "earthquake",
"status": "reviewed",
});
await earthquakes.filter(`mag >= 5`);
await earthquakes.renameColumns({ latitude: "lat", longitude: "lon" });
await earthquakes.selectColumns([
"time",
"lat",
"lon",
"depth",
"mag",
]);
await earthquakes.round(["lat", "lon", "depth", "mag"], {
decimals: 3,
});
await earthquakes.updateColumn("depth", `depth * -1`);
await earthquakes.removeDuplicates();
await earthquakes.logTable();
await earthquakes.writeChart(
(data) =>
plot({
marks: [
dot(data, {
x: "lon",
y: "lat",
}),
],
}),
"sda/output/earthquakes-lat-lon.png",
);
await earthquakes.writeChart(
(data) =>
plot({
marks: [
dot(data, {
x: "time",
y: "mag",
}),
],
}),
"sda/output/earthquakes-time-mag.png",
);
await earthquakes.writeChart(
(data) =>
plot({
y: { labelArrow: "down" },
marks: [
dot(data, {
x: "mag",
y: "depth",
}),
],
}),
"sda/output/earthquakes-mag-depth.png",
);
await sdb.done();
Pour ouvrir deux onglets l’un au-dessus de l’autre, faites un clic droit sur l’onglet que vous voulez en bas et cliquez sur Split down
. Vous pouvez aussi utiliser Split left
, Split right
ou Split top
. Vous pouvez également faire glisser un onglet vers un autre écran, si vous en avez plusieurs.
Écriture des données pour le web
Comme nos données sont prêtes, nous pouvons maintenant les écrire dans un fichier JSON que Svelte — et tout navigateur web — pourra lire. Il suffit d’ajouter une ligne avec la méthode writeData
et de s’assurer d’écrire le fichier dans le dossier src
(au lieu de sda
, où nous avons travaillé jusqu’ici).
Si vous vous souvenez des leçons précédentes, writeData
crée une liste d’objets. C’est exactement ce dont D3 a besoin. 😉
Notez que les fichiers JSON ne peuvent pas stocker d’objets Date
, donc nos dates ont été sérialisées. Elles sont enregistrées en tant que chaînes de caractères au format ISO 8601. Elles seront faciles à reconvertir en dates.
import { SimpleDB } from "@nshiab/simple-data-analysis";
import { dot, plot } 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.sort({ time: "asc" });
await earthquakes.keep({
"type": "earthquake",
"status": "reviewed",
});
await earthquakes.filter(`mag >= 5`);
await earthquakes.renameColumns({ latitude: "lat", longitude: "lon" });
await earthquakes.selectColumns([
"time",
"lat",
"lon",
"depth",
"mag",
]);
await earthquakes.round(["lat", "lon", "depth", "mag"], {
decimals: 3,
});
await earthquakes.updateColumn("depth", `depth * -1`);
await earthquakes.removeDuplicates();
await earthquakes.logTable();
await earthquakes.writeChart(
(data) =>
plot({
marks: [
dot(data, {
x: "lon",
y: "lat",
}),
],
}),
"sda/output/earthquakes-lat-lon.png",
);
await earthquakes.writeChart(
(data) =>
plot({
marks: [
dot(data, {
x: "time",
y: "mag",
}),
],
}),
"sda/output/earthquakes-time-mag.png",
);
await earthquakes.writeChart(
(data) =>
plot({
y: { labelArrow: "down" },
marks: [
dot(data, {
x: "mag",
y: "depth",
}),
],
}),
"sda/output/earthquakes-mag-depth.png",
);
await earthquakes.writeData("src/data/earthquakes.json");
await sdb.done();
Composant de graphique
Créons un nouveau composant Svelte avec une fonction utilitaire pour notre nuage de points.
Mais avant cela, il est toujours utile de définir quelques types que nous allons utiliser à plusieurs reprises. Dans src/lib/index.ts
, nous pouvons placer les types et variables qui seront facilement accessibles dans tout notre projet Svelte.
Nous pouvons créer les types earthquake
et variable
, et les exporter. Ils seront très pratiques par la suite.
type earthquake = {
time: Date;
lat: number;
lon: number;
depth: number;
mag: number;
};
type variable = keyof earthquake;
export type { earthquake, variable };
Créons maintenant la fonction utilitaire drawChart.ts
dans le dossier src/helpers
(et non dans sda
, encore une fois). C’est là que notre code D3 va vivre. Cette fonction aura besoin de quelques éléments :
- Un
id
, qui sera l’identifiant de l’élémentsvg
dans lequel nous allons dessiner notre graphique. Plus d’informations sursvg
ci-dessous. - Les données
earthquakes
. - Les variables
x
,y
etr
(le rayon de nos points). - La
width
et laheight
du graphique.
Pour l’instant, contentons-nous d’afficher ces paramètres dans la console.
Notez que comme nous utilisons src/lib/index.ts
, nous pouvons facilement importer nos types (et tout ce que nous voulons y mettre) avec from $lib
. C’est un raccourci bien pratique !
import type { earthquake, variable } from "$lib";
export default function drawChart(
id: string,
earthquakes: earthquake[],
x: variable,
y: variable,
r: variable,
width: number,
height: number,
) {
console.log({ id, earthquakes, x, y, r, width, height });
}
Nous pouvons maintenant créer un nouveau composant Chart.svelte
qui :
- Importe les données de
earthquakes.json
sous le nomearthquakesRaw
(ligne 3), puis les transforme pour convertir les valeurstime
en objetsDate
(lignes 13–16). - Récupère
id
,x
,y
etr
commeprops
(lignes 6–11). - Crée les états
width
etheight
(lignes 18–19) et les lie àclientWidth
etclientHeight
de l’élémentsvg
dans lequel nous allons dessiner notre graphique (ligne 26). Nous parlerons plus en détail des élémentssvg
plus tard. - Utilise la rune
$effect
pour appelerdrawChart
avec toutes lesprops
et les états. Cela signifie que Svelte rappelleradrawChart
si un des arguments change, y compriswidth
etheight
, rendant ainsi le graphique réactif. - Définit une
margin-top
, unewidth
et uneheight
pour lesvg
dans les balisesstyle
.
<script lang="ts">
import type { variable } from "$lib";
import earthquakesRaw from "../data/earthquakes.json";
import drawChart from "../helpers/drawChart";
const { id, x, y, r }: {
id: string;
x: variable;
y: variable;
r: variable
} = $props();
const earthquakes = earthquakesRaw.map((d) => ({
...d,
time: new Date(d.time),
}));
let width = $state(0);
let height = $state(0);
$effect(() => {
drawChart(id, earthquakes, x, y, r, width, height);
});
</script>
<svg {id} bind:clientWidth={width} bind:clientHeight={height}></svg>
<style>
svg {
margin-top: 2rem;
width: 100%;
height: 400px;
}
</style>
Et enfin, nous pouvons importer notre nouveau composant <Chart />
dans notre page, qui se trouve dans src/routes/+page.svelte
. Nous définissons les props
appropriés : id
, x
, y
et r
. Pour commencer, dessinons un graphique des tremblements de terre et de leur magnitude au fil du temps. Au passage, nous pouvons ajouter des titres et un peu de texte.
Si vous surveillez encore sda/main.ts
, vous pouvez l’arrêter (CTRL
+ C
) et lancer deno task dev
à la place pour démarrer un serveur local. Ouvrez l’URL affichée dans votre terminal dans votre navigateur préféré.
Dans la console de votre navigateur, vous devriez voir le log provenant de drawChart.ts
. Nous sommes prêts à coder notre graphique !
<script lang="ts">
import Chart from "../components/Chart.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>
<h2>Scatter plot</h2>
<Chart id="scatterplot" x="time" y="mag" r="mag" />
Dessiner avec D3
Toute cette configuration peut sembler compliquée… mais il y a en réalité beaucoup d’avantages à compartimenter votre code. Quand chaque fichier est dédié à une seule tâche, il est plus facile à déboguer. Il y a moins de répétition dans votre projet. De plus, de petits morceaux de code qui ne font des choses précises sont plus faciles à retravailler qu’un gros fichier qui fait tout. Cela deviendra particulièrement évident quand nous ajouterons des animations.
Mais nous avons assez attendu — jouons enfin avec D3 !
Arrêtez votre serveur local (CTRL
+ C
) et installez D3 avec deno add npm:d3
. Puis relancez votre serveur local avec deno task dev
.
Commençons doucement en dessinant un grand cercle bleu dans notre svg
avec la fonction drawChart
. Dans le code ci-dessous, D3 :
- Sélectionne l’élément
svg
avec l’id
donné. - Ajoute un élément
circle
ausvg
. - Spécifie plusieurs attributs pour le cercle en enchaînant les méthodes.
cx
etcy
sont les coordonnées du centre du cercle. Nous le plaçons au centre dusvg
en utilisantwidth / 2
etheight / 2
.r
est le rayon du cercle — ici, 50 pixels.fill
est la couleur à l’intérieur du cercle — ici, bleu.
import type { earthquake, variable } from "$lib";
import { select } from "d3";
export default function drawChart(
id: string,
earthquakes: earthquake[],
x: variable,
y: variable,
r: variable,
width: number,
height: number,
) {
const svg = select(`#${id}`);
svg.append("circle")
.attr("cx", width / 2)
.attr("cy", height / 2)
.attr("r", 50)
.attr("fill", "blue");
}
Si vous faites un clic droit sur votre cercle bleu et que vous l’inspectez dans votre navigateur, vous verrez votre svg
avec le circle
ajouté au code de votre page. C’est ainsi que l’on dessine des éléments SVG avec D3 : en lui indiquant exactement ce que l’on veut et où le placer.
Pour l’instant, ce cercle n’est lié à aucune donnée. Et nous avons plus de deux mille tremblements de terre. Comment les afficher tous ?
D’abord, nous avons besoin d’échelles (scales
) pour convertir les valeurs des tremblements de terre en pixels, rayons et couleurs. Les échelles D3 (il y en a beaucoup) ont besoin de deux choses : un domain et un range.
Par exemple, dans le code ci-dessous, nous utilisons la fonction extent
pour récupérer les valeurs minimale et maximale des données x
. Cette fonction retourne une liste du type [min, max]
. Au cas où vous ne vous en souvenez pas, x
est défini comme time
dans src/routes/+page.svelte
.
Nous créons ensuite une scaleTime
(car x
contient des dates) avec :
- les valeurs minimales et maximales de temps comme domain
[0, width]
comme range
Désormais, cette échelle peut convertir des objets Date
en valeurs de pixels. Au lieu de width / 2
pour cx
, nous pouvons maintenant utiliser une date en 2021 ! xScale
la convertira automatiquement en la bonne position en pixels.
import type { earthquake, variable } from "$lib";
import { extent, scaleTime, select } from "d3";
export default function drawChart(
id: string,
earthquakes: earthquake[],
x: variable,
y: variable,
r: variable,
width: number,
height: number,
) {
const svg = select(`#${id}`);
const xDomain = extent(earthquakes, (d: earthquake) => d[x]);
const xScale = scaleTime().domain(xDomain).range([0, width]);
svg.append("circle")
.attr("cx", xScale(new Date("2021-06-01T00:00:00Z")))
.attr("cy", height / 2)
.attr("r", 50)
.attr("fill", "blue");
}
Nous pouvons faire la même chose pour y
(qui est actuellement défini sur mag
) et cy
en utilisant une scaleLinear
.
Encore une fois, modifiez la valeur de magnitude à la ligne 23 pour voir yScale
en action. Rappelez-vous que les magnitudes dans nos données varient entre 5 et 8.
import type { earthquake, variable } from "$lib";
import { extent, scaleLinear, scaleTime, select } from "d3";
export default function drawChart(
id: string,
earthquakes: earthquake[],
x: variable,
y: variable,
r: variable,
width: number,
height: number,
) {
const svg = select(`#${id}`);
const xDomain = extent(earthquakes, (d: earthquake) => d[x]);
const xScale = scaleTime().domain(xDomain).range([0, width]);
const yDomain = extent(earthquakes, (d: earthquake) => d[y]);
const yScale = scaleLinear().domain(yDomain).range([height, 0]);
svg.append("circle")
.attr("cx", xScale(new Date("2021-06-01T00:00:00Z")))
.attr("cy", yScale(7))
.attr("r", 50)
.attr("fill", "blue");
}
Nous pouvons aussi ajouter une échelle pour l’attribut r
— cette fois en utilisant une échelle racine carrée (scaleSqrt
), car nous voulons que la surface du cercle soit proportionnelle aux données.
Et ajoutons une autre échelle pour la couleur, qui pourrait également être liée à rDomain
.
Oui, je sais — les échelles D3 sont incroyables ! Et comme ce sont simplement des fonctions, vous pouvez les utiliser pour tout ce que vous voulez, que ce soit dans des graphiques D3 ou ailleurs !
import type { earthquake, variable } from "$lib";
import { extent, scaleLinear, scaleSqrt, scaleTime, select } from "d3";
export default function drawChart(
id: string,
earthquakes: earthquake[],
x: variable,
y: variable,
r: variable,
width: number,
height: number,
) {
const svg = select(`#${id}`);
const xDomain = extent(earthquakes, (d: earthquake) => d[x]);
const xScale = scaleTime().domain(xDomain).range([0, width]);
const yDomain = extent(earthquakes, (d: earthquake) => d[y]);
const yScale = scaleLinear().domain(yDomain).range([height, 0]);
const rDomain = extent(earthquakes, (d: earthquake) => d[r]);
const rScale = scaleSqrt().domain(rDomain).range([0, 20]);
const fillScale = scaleLinear().domain(rDomain).range(["yellow", "red"]);
svg.append("circle")
.attr("cx", xScale(new Date("2021-06-01T00:00:00Z")))
.attr("cy", yScale(7))
.attr("r", rScale(7))
.attr("fill", fillScale(7));
}
Maintenant que nous avons nos échelles, nous pouvons exploiter tout le potentiel de D3 ! Au lieu d’ajouter un seul cercle, attachons des éléments SVG à nos données.
Voici une explication étape par étape du nouveau code ci-dessous :
- D’abord, nous sélectionnons tous les cercles dans le SVG (ligne 25). Lors du premier rendu, il n’y en a aucun — et c’est normal.
- Ensuite, nous lions les données (ligne 26). Ici, nous avons plus de 2 000 tremblements de terre. Cela signifie que nous disons à D3 : « Hé, je vais te demander de dessiner quelque chose plus de 2 000 fois. »
- Nous lions les données aux éléments SVG (ligne 27). Pour chaque tremblement de terre, nous allons dessiner un
circle
. - Nous pouvons maintenant utiliser des fonctions pour indiquer à D3 quels attributs nous voulons pour chaque tremblement de terre. Ici, nous utilisons
x
,y
etr
avec les échelles appropriées.
Comme certains tremblements de terre se chevauchent, j’ai également défini l’opacity
à 0.5
.
import type { earthquake, variable } from "$lib";
import { extent, scaleLinear, scaleSqrt, scaleTime, select } from "d3";
export default function drawChart(
id: string,
earthquakes: earthquake[],
x: variable,
y: variable,
r: variable,
width: number,
height: number,
) {
const svg = select(`#${id}`);
const xDomain = extent(earthquakes, (d: earthquake) => d[x]);
const xScale = scaleTime().domain(xDomain).range([0, width]);
const yDomain = extent(earthquakes, (d: earthquake) => d[y]);
const yScale = scaleLinear().domain(yDomain).range([height, 0]);
const rDomain = extent(earthquakes, (d: earthquake) => d[r]);
const rScale = scaleSqrt().domain(rDomain).range([0, 20]);
const fillScale = scaleLinear().domain(rDomain).range(["yellow", "red"]);
svg.selectAll("circle")
.data(earthquakes)
.join("circle")
.attr("cx", (d: earthquake) => xScale(d[x]))
.attr("cy", (d: earthquake) => yScale(d[y]))
.attr("r", (d: earthquake) => rScale(d[r]))
.attr("fill", (d: earthquake) => fillScale(d[r]))
.attr("opacity", 0.5);
}
Et regardez-moi ça ! Vous avez maintenant tous vos tremblements de terre ajoutés à votre svg
! Et si vous inspectez un cercle et consultez ses Properties
, vous verrez les données du tremblement de terre. Elles sont réellement liées à l’élément SVG.
Axes
Nous avons dessiné nos tremblements de terre, mais ce serait bien d’ajouter des axes x
et y
. Pour nous assurer qu’il y ait suffisamment d’espace autour, nous devons aussi définir des marges.
Créons un objet margins
pour ajouter de l’espace autour du graphique et pour positionner correctement nos axes.
Pour dessiner les axes, nous pouvons utiliser les fonctions axisLeft
et axisBottom
avec nos échelles. En général, on les place dans un élément g
(pour group) afin de pouvoir facilement les identifier (avec une class
ou un id
) ou les déplacer si nécessaire.
Les axes D3 sont assez intelligents et essaient automatiquement de créer des étiquettes de graduation pertinentes. Ici, comme nous utilisons une scaleTime
pour l’axe du bas (axisBottom
), et que son domaine est limité à 2021, la fonction affiche 2021 au début, puis uniquement les mois. Toutefois, pour éviter le chevauchement des étiquettes, j’ai défini le nombre de ticks à 3.
Aussi, pour éviter de redessiner les axes encore et encore lorsqu’une prop
ou un état change (comme lors du redimensionnement de la fenêtre), je leur ai donné une class
appelée axis
et je l’utilise pour les sélectionner et les supprimer avant de les redessiner (ligne 55). Notez que nous n’avons pas besoin de faire cela pour les cercles car ils sont liés à leurs données, même entre les rendus.
import type { earthquake, variable } from "$lib";
import {
axisBottom,
axisLeft,
extent,
scaleLinear,
scaleSqrt,
scaleTime,
select,
} from "d3";
export default function drawChart(
id: string,
earthquakes: earthquake[],
x: variable,
y: variable,
r: variable,
width: number,
height: number,
) {
const svg = select(`#${id}`);
const margins = {
top: 20,
right: 20,
bottom: 35,
left: 80,
};
const xDomain = extent(earthquakes, (d: earthquake) => d[x]);
const xScale = scaleTime().domain(xDomain).range([
margins.left,
width - margins.right,
]);
const yDomain = extent(earthquakes, (d: earthquake) => d[y]);
const yScale = scaleLinear().domain(yDomain).range([
height - margins.bottom,
margins.top,
]);
const rDomain = extent(earthquakes, (d: earthquake) => d[r]);
const rScale = scaleSqrt().domain(rDomain).range([0, 20]);
const fillScale = scaleLinear().domain(rDomain).range(["yellow", "red"]);
svg.selectAll("circle")
.data(earthquakes)
.join("circle")
.attr("cx", (d: earthquake) => xScale(d[x]))
.attr("cy", (d: earthquake) => yScale(d[y]))
.attr("r", (d: earthquake) => rScale(d[r]))
.attr("fill", (d: earthquake) => fillScale(d[r]))
.attr("opacity", 0.5);
svg.selectAll(".axis").remove();
svg
.append("g")
.attr("class", "axis")
.attr(
"transform",
`translate(0, ${height - margins.bottom})`,
)
.call(axisBottom(xScale).ticks(3));
svg
.append("g")
.attr("class", "axis")
.attr("transform", `translate(${margins.left}, 0)`)
.call(axisLeft(yScale));
}
Enfin, ce serait une bonne idée d’ajouter des étiquettes aux axes également — et peut-être un peu de marge intérieure pour éviter que les cercles ne chevauchent les axes.
Pour avoir de vraies étiquettes au lieu des abréviations utilisées dans nos données, j’ai créé un objet labels
que nous pouvons utiliser pour le texte.
import type { earthquake, variable } from "$lib";
import {
axisBottom,
axisLeft,
extent,
scaleLinear,
scaleSqrt,
scaleTime,
select,
} from "d3";
export default function drawChart(
id: string,
earthquakes: earthquake[],
x: variable,
y: variable,
r: variable,
width: number,
height: number,
) {
const svg = select(`#${id}`);
const margins = {
top: 20,
right: 20,
bottom: 35,
left: 80,
};
const inset = 10;
const xDomain = extent(earthquakes, (d: earthquake) => d[x]);
const xScale = scaleTime().domain(xDomain).range([
margins.left,
width - margins.right,
]);
const yDomain = extent(earthquakes, (d: earthquake) => d[y]);
const yScale = scaleLinear().domain(yDomain).range([
height - margins.bottom,
margins.top,
]);
const rDomain = extent(earthquakes, (d: earthquake) => d[r]);
const rScale = scaleSqrt().domain(rDomain).range([0, 20]);
const fillScale = scaleLinear().domain(rDomain).range(["yellow", "red"]);
svg.selectAll("circle")
.data(earthquakes)
.join("circle")
.attr("cx", (d: earthquake) => xScale(d[x]))
.attr("cy", (d: earthquake) => yScale(d[y]))
.attr("r", (d: earthquake) => rScale(d[r]))
.attr("fill", (d: earthquake) => fillScale(d[r]))
.attr("opacity", 0.5);
svg.selectAll(".axis").remove();
svg
.append("g")
.attr("class", "axis")
.attr(
"transform",
`translate(0, ${height - margins.bottom + inset})`,
)
.call(axisBottom(xScale).ticks(3));
svg
.append("g")
.attr("class", "axis")
.attr("transform", `translate(${margins.left - inset}, 0)`)
.call(axisLeft(yScale));
svg.selectAll(".labels").remove();
const labels: { [key: string]: string } = {
"time": "Time",
"mag": "Magnitude",
};
svg.append("text")
.attr("class", "labels")
.attr("x", width - margins.right)
.attr("y", height)
.attr("font-size", 12)
.attr("text-anchor", "end")
.text(`${labels[x]} →`);
svg.append("text")
.attr("class", "labels")
.attr("x", margins.left - inset / 2)
.attr("y", margins.top - inset)
.attr("font-size", 12)
.attr("text-anchor", "end")
.text(`${labels[y]} ↑`);
}
Ce rendu est vraiment chouette pour un premier graphique D3 ! Et en plus, il est entièrement réactif ! Essayez de redimensionner votre navigateur et admirez la magie !
Animations
Notre graphique est super, mais soyons honnêtes : on pourrait créer la même chose beaucoup plus rapidement et facilement avec Plot…
Cependant, il y a une chose qui est difficile à faire avec Plot : les animations.
Mettons à jour notre fichier src/routes/+page.svelte
pour offrir à l’utilisateur deux types de graphiques. Nous pouvons utiliser le composant pré-codé <Radio />
, qui crée des boutons radio, et lier (bind
) la variable chartType
à l’option sélectionnée.
En fonction de l’état de chartType
, nous pouvons mettre à jour les états x
et y
nouvellement créés, qui seront ensuite transmis à notre composant <Chart />
.
<script lang="ts">
import type { variable } from "$lib";
import Chart from "../components/Chart.svelte";
import Radio from "../components/Radio.svelte";
let chartType = $state("Time/Magnitude");
let x = $state<variable>("time");
let y = $state<variable>("mag");
$effect(() => {
if (chartType === "Time/Magnitude") {
x = "time";
y = "mag";
} else {
x = "mag";
y = "depth";
}
});
</script>
<h1>Earthquakes</h1>
<p>
The data used below includes only earthquakes with a magnitude of 5 or more
that occurred in 2021.
</p>
<h2>Scatter plot</h2>
<Radio
bind:value={chartType}
values={["Time/Magnitude", "Magnitude/Depth"]}
label="Pick a chart:"
/>
<Chart id="scatterplot" {x} {y} r="mag" />
Vous vous demandez peut-être ce que signifie <variable>
aux lignes 8 et 9. Tout comme vous pouvez passer des arguments à certaines fonctions, vous pouvez aussi passer des types. C’est la syntaxe prévue pour cela. Ici, cela indique à $state
que l’état créé devra être du type variable
.
Maintenant, chaque fois que nous changeons le type de graphique, notre composant <Chart />
est re-rendu avec de nouvelles valeurs x
et y
, qui sont utilisées par notre fonction drawChart
!
Il y a quelques petits bugs visuels que nous pouvons corriger tout de suite dans src/helpers/drawChart.ts
:
- Si
x
esttime
, nous devons utiliser unescaleTime
, mais si c’estmag
, nous devons utiliserscaleLinear
. - Si
x
esttime
, nous pouvons garder 3 ticks, mais si c’estmag
, on peut laisser D3 décider. - Nous pouvons ajouter
Depth
à notre objetlabels
et inverser la flèche de l’axe Y si c’estdepth
. - Nous pouvons ajuster les
margins
et la position des étiquettes pour éviter que notre nouveau texte soit coupé.
import type { earthquake, variable } from "$lib";
import {
axisBottom,
axisLeft,
extent,
scaleLinear,
scaleSqrt,
scaleTime,
select,
} from "d3";
export default function drawChart(
id: string,
earthquakes: earthquake[],
x: variable,
y: variable,
r: variable,
width: number,
height: number,
) {
const svg = select(`#${id}`);
const margins = {
top: 20,
right: 20,
bottom: 45,
left: 85,
};
const inset = 10;
const xDomain = extent(earthquakes, (d: earthquake) => d[x]);
const xRange = [
margins.left,
width - margins.right,
];
const xScale = x === "time"
? scaleTime().domain(xDomain).range(xRange)
: scaleLinear().domain(xDomain).range(xRange);
const yDomain = extent(earthquakes, (d: earthquake) => d[y]);
const yScale = scaleLinear().domain(yDomain).range([
height - margins.bottom,
margins.top,
]);
const rDomain = extent(earthquakes, (d: earthquake) => d[r]);
const rScale = scaleSqrt().domain(rDomain).range([0, 20]);
const fillScale = scaleLinear().domain(rDomain).range(["yellow", "red"]);
svg.selectAll("circle")
.data(earthquakes)
.join("circle")
.attr("cx", (d: earthquake) => xScale(d[x]))
.attr("cy", (d: earthquake) => yScale(d[y]))
.attr("r", (d: earthquake) => rScale(d[r]))
.attr("fill", (d: earthquake) => fillScale(d[r]))
.attr("opacity", 0.5);
svg.selectAll(".axis").remove();
svg
.append("g")
.attr("class", "axis")
.attr(
"transform",
`translate(0, ${height - margins.bottom + inset})`,
)
.call(x === "time" ? axisBottom(xScale).ticks(3) : axisBottom(xScale));
svg
.append("g")
.attr("class", "axis")
.attr("transform", `translate(${margins.left - inset}, 0)`)
.call(axisLeft(yScale));
svg.selectAll(".labels").remove();
const labels: { [key: string]: string } = {
"time": "Time",
"mag": "Magnitude",
"depth": "Depth (km)",
};
svg.append("text")
.attr("class", "labels")
.attr("x", width - margins.right)
.attr("y", height - 3)
.attr("font-size", 12)
.attr("text-anchor", "end")
.text(`${labels[x]} →`);
svg.append("text")
.attr("class", "labels")
.attr("x", margins.left - inset / 2)
.attr("y", margins.top - inset)
.attr("font-size", 12)
.attr("text-anchor", "end")
.text(y === "depth" ? `${labels[y]} ↓` : `${labels[y]} ↑`);
}
C’est sympa, mais ce n’est pas animé. Le graphique est simplement redessiné à chaque fois, sans aucune transition. Reprenons src/helpers/drawChart.ts
pour créer une transition fluide des cercles.
Créer des animations avec D3 est très simple. Il suffit d’abord de sélectionner les éléments que vous voulez animer, d’appeler .transition()
, puis d’enchaîner les attributs que vous souhaitez modifier.
Dans notre cas, nous devons d’abord vérifier s’il faut dessiner ou animer. Pour cela, on regarde s’il y a déjà quelque chose dans le svg
(ligne 23). S’il n’y a rien, cela signifie qu’on doit dessiner (lignes 53–60). Sinon, on veut animer les éléments déjà présents (lignes 62–65).
Pour animer, on sélectionne tous les cercles, on appelle .transition()
pour dire à D3 d’interpoler entre leurs attributs actuels et les nouveaux. Ici, on met simplement à jour cx
et cy
à l’aide du domain et range mis à jour de xScale
et yScale
.
Facile !
import type { earthquake, variable } from "$lib";
import {
axisBottom,
axisLeft,
extent,
scaleLinear,
scaleSqrt,
scaleTime,
select,
} from "d3";
export default function drawChart(
id: string,
earthquakes: earthquake[],
x: variable,
y: variable,
r: variable,
width: number,
height: number,
) {
const svg = select(`#${id}`);
const animate = svg.selectAll("*").nodes().length > 0;
const margins = {
top: 20,
right: 20,
bottom: 45,
left: 85,
};
const inset = 10;
const xDomain = extent(earthquakes, (d: earthquake) => d[x]);
const xRange = [
margins.left,
width - margins.right,
];
const xScale = x === "time"
? scaleTime().domain(xDomain).range(xRange)
: scaleLinear().domain(xDomain).range(xRange);
const yDomain = extent(earthquakes, (d: earthquake) => d[y]);
const yScale = scaleLinear().domain(yDomain).range([
height - margins.bottom,
margins.top,
]);
const rDomain = extent(earthquakes, (d: earthquake) => d[r]);
const rScale = scaleSqrt().domain(rDomain).range([0, 20]);
const fillScale = scaleLinear().domain(rDomain).range(["yellow", "red"]);
if (!animate) {
svg.selectAll("circle")
.data(earthquakes)
.join("circle")
.attr("cx", (d: earthquake) => xScale(d[x]))
.attr("cy", (d: earthquake) => yScale(d[y]))
.attr("r", (d: earthquake) => rScale(d[r]))
.attr("fill", (d: earthquake) => fillScale(d[r]))
.attr("opacity", 0.5);
} else {
svg.selectAll("circle")
.transition()
.attr("cx", (d: earthquake) => xScale(d[x]))
.attr("cy", (d: earthquake) => yScale(d[y]));
}
svg.selectAll(".axis").remove();
svg
.append("g")
.attr("class", "axis")
.attr(
"transform",
`translate(0, ${height - margins.bottom + inset})`,
)
.call(x === "time" ? axisBottom(xScale).ticks(3) : axisBottom(xScale));
svg
.append("g")
.attr("class", "axis")
.attr("transform", `translate(${margins.left - inset}, 0)`)
.call(axisLeft(yScale));
svg.selectAll(".labels").remove();
const labels: { [key: string]: string } = {
"time": "Time",
"mag": "Magnitude",
"depth": "Depth (km)",
};
svg.append("text")
.attr("class", "labels")
.attr("x", width - margins.right)
.attr("y", height - 3)
.attr("font-size", 12)
.attr("text-anchor", "end")
.text(`${labels[x]} →`);
svg.append("text")
.attr("class", "labels")
.attr("x", margins.left - inset / 2)
.attr("y", margins.top - inset)
.attr("font-size", 12)
.attr("text-anchor", "end")
.text(y === "depth" ? `${labels[y]} ↓` : `${labels[y]} ↑`);
}
Mais si vous testez l’animation maintenant, ce n’est pas très intéressant. Elle est trop rapide, et tous les points bougent en même temps. On peut rendre ça plus sympa en ajoutant une duration
, une ease
et un delay
.
Pour la durée, j’ai choisi 1 000 millisecondes. Pour l’easing, j’aime bien easeCubicInOut
, mais vous avez plein d’options avec D3. Et pour le délai, j’utilise simplement la durée multipliée par un nombre aléatoire entre 0 et 1. Ainsi, chaque point aura un délai différent — compris entre 0 et la durée choisie.
Maintenant, l’ensemble paraît plus organique et agréable à regarder !
import type { earthquake, variable } from "$lib";
import {
axisBottom,
axisLeft,
easeCubicInOut,
extent,
scaleLinear,
scaleSqrt,
scaleTime,
select,
} from "d3";
export default function drawChart(
id: string,
earthquakes: earthquake[],
x: variable,
y: variable,
r: variable,
width: number,
height: number,
) {
const svg = select(`#${id}`);
const animate = svg.selectAll("*").nodes().length > 0;
const duration = 1000;
const margins = {
top: 20,
right: 20,
bottom: 45,
left: 85,
};
const inset = 10;
const xDomain = extent(earthquakes, (d: earthquake) => d[x]);
const xRange = [
margins.left,
width - margins.right,
];
const xScale = x === "time"
? scaleTime().domain(xDomain).range(xRange)
: scaleLinear().domain(xDomain).range(xRange);
const yDomain = extent(earthquakes, (d: earthquake) => d[y]);
const yScale = scaleLinear().domain(yDomain).range([
height - margins.bottom,
margins.top,
]);
const rDomain = extent(earthquakes, (d: earthquake) => d[r]);
const rScale = scaleSqrt().domain(rDomain).range([0, 20]);
const fillScale = scaleLinear().domain(rDomain).range(["yellow", "red"]);
if (!animate) {
svg.selectAll("circle")
.data(earthquakes)
.join("circle")
.attr("cx", (d: earthquake) => xScale(d[x]))
.attr("cy", (d: earthquake) => yScale(d[y]))
.attr("r", (d: earthquake) => rScale(d[r]))
.attr("fill", (d: earthquake) => fillScale(d[r]))
.attr("opacity", 0.5);
} else {
svg.selectAll("circle")
.transition()
.duration(duration)
.ease(easeCubicInOut)
.delay(() => Math.random() * duration)
.attr("cx", (d: earthquake) => xScale(d[x]))
.attr("cy", (d: earthquake) => yScale(d[y]));
}
svg.selectAll(".axis").remove();
svg
.append("g")
.attr("class", "axis")
.attr(
"transform",
`translate(0, ${height - margins.bottom + inset})`,
)
.call(x === "time" ? axisBottom(xScale).ticks(3) : axisBottom(xScale));
svg
.append("g")
.attr("class", "axis")
.attr("transform", `translate(${margins.left - inset}, 0)`)
.call(axisLeft(yScale));
svg.selectAll(".labels").remove();
const labels: { [key: string]: string } = {
"time": "Time",
"mag": "Magnitude",
"depth": "Depth (km)",
};
svg.append("text")
.attr("class", "labels")
.attr("x", width - margins.right)
.attr("y", height - 3)
.attr("font-size", 12)
.attr("text-anchor", "end")
.text(`${labels[x]} →`);
svg.append("text")
.attr("class", "labels")
.attr("x", margins.left - inset / 2)
.attr("y", margins.top - inset)
.attr("font-size", 12)
.attr("text-anchor", "end")
.text(y === "depth" ? `${labels[y]} ↓` : `${labels[y]} ↑`);
}
Nous pourrions aussi animer les axes et les étiquettes.
Pour l’axe des x, comme il passe de dates à des nombres et inversement, il n’y a pas de manière fluide de faire la transition des valeurs comme on le fait avec l’axe des y. À la place, j’ai enchaîné des transitions pour le faire disparaître, le mettre à jour, puis le faire réapparaître. C’est le même principe pour les étiquettes de texte.
À noter également : nous n’avons plus besoin de supprimer les axes ou les étiquettes, puisqu’on les met désormais à jour directement.
import type { earthquake, variable } from "$lib";
import {
axisBottom,
axisLeft,
easeCubicInOut,
extent,
scaleLinear,
scaleSqrt,
scaleTime,
select,
} from "d3";
export default function drawChart(
id: string,
earthquakes: earthquake[],
x: variable,
y: variable,
r: variable,
width: number,
height: number,
) {
const svg = select(`#${id}`);
const animate = svg.selectAll("*").nodes().length > 0;
const duration = 1000;
const margins = {
top: 20,
right: 20,
bottom: 45,
left: 85,
};
const inset = 10;
const xDomain = extent(earthquakes, (d: earthquake) => d[x]);
const xRange = [
margins.left,
width - margins.right,
];
const xScale = x === "time"
? scaleTime().domain(xDomain).range(xRange)
: scaleLinear().domain(xDomain).range(xRange);
const yDomain = extent(earthquakes, (d: earthquake) => d[y]);
const yScale = scaleLinear().domain(yDomain).range([
height - margins.bottom,
margins.top,
]);
const rDomain = extent(earthquakes, (d: earthquake) => d[r]);
const rScale = scaleSqrt().domain(rDomain).range([0, 20]);
const fillScale = scaleLinear().domain(rDomain).range(["yellow", "red"]);
if (!animate) {
svg.selectAll("circle")
.data(earthquakes)
.join("circle")
.attr("cx", (d: earthquake) => xScale(d[x]))
.attr("cy", (d: earthquake) => yScale(d[y]))
.attr("r", (d: earthquake) => rScale(d[r]))
.attr("fill", (d: earthquake) => fillScale(d[r]))
.attr("opacity", 0.5);
} else {
svg.selectAll("circle")
.transition()
.duration(duration)
.ease(easeCubicInOut)
.delay(() => Math.random() * duration)
.attr("cx", (d: earthquake) => xScale(d[x]))
.attr("cy", (d: earthquake) => yScale(d[y]));
}
if (!animate) {
svg
.append("g")
.attr("id", "x-axis")
.attr(
"transform",
`translate(0, ${height - margins.bottom + inset})`,
)
.call(x === "time" ? axisBottom(xScale).ticks(3) : axisBottom(xScale));
} else {
svg.select("#x-axis")
.attr("opacity", 1)
.transition()
.duration(duration / 2)
.attr("opacity", 0)
.transition()
.duration(0)
.call(x === "time" ? axisBottom(xScale).ticks(3) : axisBottom(xScale))
.transition()
.duration(duration / 2)
.attr("opacity", 1);
}
if (!animate) {
svg
.append("g")
.attr("id", "y-axis")
.attr("transform", `translate(${margins.left - inset}, 0)`)
.call(axisLeft(yScale));
} else {
svg.select("#y-axis")
.transition()
.duration(duration).call(axisLeft(yScale));
}
const labels: { [key: string]: string } = {
"time": "Time",
"mag": "Magnitude",
"depth": "Depth (km)",
};
if (!animate) {
svg.append("text")
.attr("id", "label-x")
.attr("x", width - margins.right)
.attr("y", height - 3)
.attr("font-size", 12)
.attr("text-anchor", "end")
.text(`${labels[x]} →`);
svg.append("text")
.attr("id", "label-y")
.attr("x", margins.left - inset / 2)
.attr("y", margins.top - inset)
.attr("font-size", 12)
.attr("text-anchor", "end")
.text(y === "depth" ? `${labels[y]} ↓` : `${labels[y]} ↑`);
} else {
svg.select("#label-x")
.attr("opacity", 1)
.transition()
.duration(duration / 2)
.attr("opacity", 0)
.transition()
.duration(0)
.text(`${labels[x]} →`)
.transition()
.duration(duration / 2)
.attr("opacity", 1);
svg.select("#label-y")
.attr("opacity", 1)
.transition()
.duration(duration / 2)
.attr("opacity", 0)
.transition()
.duration(0)
.text(y === "depth" ? `${labels[y]} ↓` : `${labels[y]} ↑`)
.transition()
.duration(duration / 2)
.attr("opacity", 1);
}
}
Construction de la page
Jusqu’à présent, nous avons exécuté notre page avec un serveur local. Si vous souhaitez construire votre site web, exécutez la commande deno task build
. Svelte minimisera et optimisera votre code et créera les fichiers de votre site web dans le dossier build
. Vous pourrez ensuite héberger ces fichiers sur un serveur pour partager votre travail avec le monde entier !
Conclusion
Notre graphique est magniiifiiiique ! Nous pourrions continuer à itérer pour l’améliorer encore plus, mais je pense que nous en avons déjà fait beaucoup. Vous pouvez être fier de vous.
Si vous souhaitez explorer davantage d’exemples D3, n’oubliez pas de consulter la galerie D3. Tout le code est en code ouvert !
Si vous êtes prêt à relever un défi supplémentaire, nous n’avons pas utilisé les valeurs lat
et lon
. Essayez d’ajouter une autre option aux boutons radio qui met à jour x
vers lon
et y
vers lat
, puis ajustez la fonction drawChart
pour vous assurer que tout fonctionne correctement avec cette nouvelle option.
D3 n’est pas seulement bon pour les graphiques. C’est aussi G-É-N-I-A-L pour les cartes ! Et c’est ce dont traitera la prochaine leçon. À bientôt !