Cette visualisation présente les données de consommation de lait d'un bébé depuis sa naissance jusqu'à ses trois mois. Sous forme d'histogramme empilé (Stacked Bar Chart), les biberons de chaque jour sont mesurés en mililitre et le nombre de rectangles dans une colonne indique le nombre de biberons consommés dans la journée. Nous avons en plus ajouté une moyenne mobile sur 10 jours (MM10) représentant la courbe de consommation totale. Nous allons étudier les différents éléments suivant :
d3.stack()
date Biberon 1 Biberon 2 Biberon 3 Biberon 4 Biberon 5 Biberon 6 Biberon 7 Biberon 8 Biberon 9 Total
06/03/2020 10 8 15 10 15 10 20 88
07/03/2020 20 30 27 50 50 177
08/03/2020 30 45 40 42 50 60 45 312
Dans la mesure du possible, lorsque des valeurs numériques sont porteuses de sens ou utilisées plusieurs fois dans un code, il est préférable de les définir comme constante dans le code. Cela évite d'avoir à mettre un commentaire
pour expliquer à quoi correspond une valeur. C'est le cas par exemple de la largeur du tooltip qui conditionne le comportement du mouseover
. En informatique on appelle ces valeurs dans le code des "Magic Number" ou
plus simplement "Constantes numériques non nommées" (voir sur Wikipédia "Nombre Magique").
margin
permet de définir les 4 marges de notre graphique qui s'appliqueront au diagramme empilé. Sans titre, la marge du haut (top) est petite. A gauche (left) nous avons les valeurs associées à l'axe des ordonnées.
A droite (right) la marge permet d'écrire le texte "MM10". Enfin en bas (bottom) nous retrouvons l'axe des abscisseswidth
est calculé de manière dynamique en fonction de la largeur de la page à laquelle on retire les marges à droite et à gaucheheight
est fixe et arbitraire. On décide que l'ensemble doit s'afficher sur 600px de hauteur. On retire les marges en haut et en baskeys
représente les catégories de notre fichier qui seront utilisées pour calculer la hauteur de chaque rectangle de notre diagrammecolors
possède la même taille que le tableau keys
et contient la couleur qui sera associée à chaque catégorielegendCellSize
indique la taille des carrés dans la légende (largeur et hauteur donc)tooltipWidth
contient la largeur de notre tooltip en pixelconst margin = {top: 20, right: 40, bottom: 60, left: 50},
width = document.getElementById("container").offsetWidth * 0.95 - margin.left - margin.right,
height = 600 - margin.top - margin.bottom,
keys = ["Biberon 1", "Biberon 2", "Biberon 3", "Biberon 4", "Biberon 5", "Biberon 6", "Biberon 7", "Biberon 8", "Biberon 9"],
colors = ["#f7fcf0", "#e0f3db", "#ccebc5", "#a8ddb5", "#7bccc4", "#4eb3d3", "#2b8cbe", "#0868ac", "#084081"],
legendCellSize = 20,
tooltipWidth = 210;
Nous pouvons ensuite créer notre SVG à partir de ces données. Il faut disposer sur la page d'un DIV dont l'ID est chart
.
const svg = d3.select("#chart").append("svg")
.attr("id", "svg")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom)
.append("g")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
Ce tutoriel propose de créer un diagramme empilé, nous allons donc nous concentrer sur ce sujet dans cette section. Comme à son habitude, D3JS propose des fonctionnalités facilitant la création de diagrammes
empilés (voir en anglais d3.stack
). Voici le code commenté permettant d'obtenir une version minimaliste.
d3.csv("stacked-barchart/biberons.csv").then(function(data) {
// Construction d'un générateur de diagramme empilé avec les valeurs par défaut.
// C'est ici que nous fournissons la variable keys indiquant nos différentes catégories
var stack = d3.stack()
.keys(keys)
.order(d3.stackOrderNone)
.offset(d3.stackOffsetNone);
// Nos données en TSV ont été chargées, elles peuvent directement être fournies au générateur
// La variable series contient des données structurées sous forme de matrice auxquelles ont été appliquées les paramètres du générateur
var series = stack(data);
// A l'horizontale nous avons nos dates. Nous souhaitons pouvoir afficher toutes les dates de nos données (le domain) sur la largeur
// prédéfinie (le range). On précise également qu'un espace (padding) sera appliqué entre chaque barre verticale
const x = d3.scaleBand()
.domain(data.map(d => d.date))
.range([0, width])
.padding(0.1);
// A la verticale, notre range est la hauteur du graphique et notre domaine va de 0 à la valeur maximale des séries
// Voir un peu plus bas l'objet series
const y = d3.scaleLinear()
.domain([0, d3.max(series[series.length - 1], d => d[1])])
.range([height, 0]);
// Pour chaque série (chaque catégorie) nous créons un groupe
// Nous pouvons alors associer la couleur correspondant à cette série en fonction de son indice
let groups = svg.selectAll("g.biberon")
.data(series)
.enter()
.append("g")
.style("fill", (d, i) => colors[i]);
// Pour chaque élément d'une série nous construisons un rectangle dont la position sur l'axe X est liée à sa date,
// Sa largeur est dépendante du nombre de données et fournie par x.bandWidth()
// Sur l'axe Y, la hauteur du rectangle est donnée par d[0] et d[1] correspondant au début et à la fin du rectangle.
let rect = groups.selectAll("rect")
.data(d => d)
.enter()
.append("rect")
.attr("x", d => x(d.data.date))
.attr("width", x.bandwidth())
.attr("y", d => y(d[1]))
.attr("height", d => height - y(d[1] - d[0]));
});
Le résultat obtenu est visible dans l'image ci-dessous. Tout le travail de construction du diagramme est donc déjà fait. Il nous reste uniquement à ajouter les axes, une légende et dans notre cas la courbe représentant la moyenne et le tooltip.
Afin de bien comprendre comment fonctionne le générateur de stack, nous allons partir de données plus simples. Voici par exemple notre fichier TSV chargé, limité à seulement deux catégories et à trois lignes. On y retrouve bien la date correspondant à l'axe X puis les deux catégories "Biberon 1" et "Biberon 2"
Maintenant appliquons le générateur de stack fournit par D3JS sur ces données.
var stack = d3.stack()
.keys(["Biberon 1", "Biberon 2"])
.order(d3.stackOrderNone)
.offset(d3.stackOffsetNone);
var series = stack(data);
La variable series
contient un tableau avec deux entrées, une pour chaque catégorie. L'entrée 0 correspond à "Biberon 1", c'est ce qu'indique l'attribut key
.
Il y a ensuite trois sous-tableaux correspondant à nos trois dates. Ces sous-tableaux sont composés de deux valeurs puis d'un attribut data
qui n'est pas ouvert dans la capture
(il contient les données CSV correspondant à la date). Pour lire l'ensemble, il faut procéder ainsi :
series[0]
, elle possède trois données, le biberon du 06/03/2020 qui va de 0 à 10 ml
(series[0][0]
), le biberon du 07/03/2020 qui va de 0 à 20 ml (series[0][1]
) et le biberon du 08/03/2020 qui va de 0 à 30 ml (series[0][2]
)
series[1]
, elle possède trois données, le biberon du 06/03/2020 qui va de 10 à 18 ml
(à cette date dans notre fichier TSV, il y a un premier biberon de 10 ml et un second de 8 ml donc le "Biberon 2" à cette date va de 10 à 18, on empile donc les biberons), le biberon du
07/03/2020 qui va de 20 à 50 ml et le biberon du 08/03/2020 qui va de 30 à 75 mlLa fonction principale de chargement du fichier CSV est identique à celle étudiée ci-dessus avec l'ajout des axes et des appels à d'autres méthodes.
d3.csv("/tutorials/d3js/stacked-barchart/biberons.csv").then(function(data) {
var stack = d3.stack()
.keys(keys)
.order(d3.stackOrderNone)
.offset(d3.stackOffsetNone);
var series = stack(data);
const x = d3.scaleBand()
.domain(data.map(d => d.date))
.range([0, width])
.padding(0.1);
const y = d3.scaleLinear()
.domain([0, d3.max(series[series.length - 1], d => d[1])])
.range([height, 0]);
// Sur l'axe horizontal, on filtre les dates afficher
const xAxis = d3.axisBottom(x)
.tickValues(x.domain().filter(d => (d.includes("06/0") || d.includes("21/0"))));
svg.append("g")
.attr("class", "x axis")
.attr("transform", "translate(0," + height + ")")
.call(xAxis)
.selectAll("text")
.style("text-anchor", "middle");
svg.append("g")
.attr("class", "y axis")
.call(d3.axisLeft(y))
.append("text")
.attr("fill", "#000")
.attr("transform", "rotate(-90)")
.attr("y", 6)
.attr("dy", "0.71em")
.style("text-anchor", "end")
.text("ml");
let groups = svg.selectAll("g.biberon")
.data(series)
.enter().append("g")
.style("fill", (d, i) => colors[i]);
let rect = groups.selectAll("rect")
.data(d => d)
.enter()
.append("rect")
.attr("x", d => x(d.data.date))
.attr("width", x.bandwidth())
.attr("y", d => y(d[1]))
.attr("height", d => height - y(d[1] - d[0]));
addMovingAverage(data, x, y);
addLegend(colors);
let tooltip = addTooltip(keys.length);
handleMouseEvent(data, x, y, tooltip);
});
Le calcul de la moyenne mobile est assez facile à réaliser avec D3JS, son principe est décrit sur cet article de Wikipédia.
Nous avons retenu le point de vue classique. Nous réalisons un Math.floor
afin de garantir la manipulation de valeurs entières.
function movingAverage(array, count) {
var result = [], val;
for (var i = Math.floor(count / 2), len = array.length - count / 2; i < len; i++) {
val = d3.mean(array.slice(i - count / 2, i + count / 2), d => d.Total);
result.push({"date": array[i].date, "value": val});
}
return result;
}
La fonction permettant l'ajout de la courbe représentant la moyenne mobile est relativement simple. Si vous avez besoin de plus de précision vous pouvez consulter notre tutoriel D3JS - Ligne (Linear Chart).
function addMovingAverage(data, x, y) {
const line = d3.line()
.x(d => (x.bandwidth() / 2) + x(d.date)) // décalage pour centrer au milieu des barres
.y(d => y(d.value))
.curve(d3.curveMonotoneX); // Fonction de courbe permettant de l'adoucir
let mm10array = movingAverage(data, 10); // Moyenne mobile sur 10 jours
svg.append("path")
.datum(mm10array)
.attr("d", line)
.style("fill", "none")
.style("stroke", "#ffab00")
.style("stroke-width", 3);
let lastEntry = mm10array[mm10array.length - 1];
svg.append("text")
.attr("transform", "translate(" + x(lastEntry.date) + "," + y(lastEntry.value) + ")") // Le dernier point de la courbe
.attr("dx", "0.5em") // Auquel on ajoute un léger décalage sur X
.attr("dy", "0.5em") // et sur Y
.style("fill", "#ffab00")
.style("font-size", "0.8em")
.style("font-weight", "500")
.text("MM10");
}
L'ajout d'une légende est toujours essentiel à un graphique car sans celle-ci le graphique n'a pas de sens précis. Dans notre cas la légende est associée aux couleurs des différentes catégories. Il faut savoir ou positionner la légende, cela peut-être en dehors du graphique bien que nous concernant nous avons pu la positionner à l'intérieur car les données le permettent.
function addLegend(colors) {
let reverseColors = colors.reverse(); // Pour présenter les catégories dans le même sens qu'elles sont utilisées
let reverseKeys = keys.reverse();
let legend = svg.append('g')
.attr('transform', 'translate(10, 20)'); // Représente le point précis en haut à gauche du premier carré de couleur
// Pour chaque couleur, on ajoute un carré toujours positionné au même endroit sur l'axe X et décalé en fonction de la
// taille du carré et de l'indice de la couleur traitée sur l'axe Y
legend.selectAll()
.data(reverseColors)
.enter().append('rect')
.attr('height', legendCellSize + 'px')
.attr('width', legendCellSize + 'px')
.attr('x', 5)
.attr('y', (d,i) => i * legendCellSize)
.style("fill", d => d);
// On procéde de la même façon sur les libellés avec un positionement sur l'axe X de la taille des carrés
// à laquelle on rajoute 10 px de marge
legend.selectAll()
.data(reverseKeys)
.enter().append('text')
.attr("transform", (d,i) => "translate(" + (legendCellSize + 10) + ", " + (i * legendCellSize) + ")")
.attr("dy", legendCellSize / 1.6) // Pour centrer le texte par rapport aux carrés
.style("font-size", "13px")
.style("fill", "grey")
.text(d => d);
}
L'ajout du tooltip n'est pas très différent des précédents tutoriaux au détail près qu'on doit gérer une liste de valeur. Au départ nous voulions utiliser un tableau mais cet objet n'existe pas en SVG. Nous avons donc simuler ce tableau en insérant
autant de tspan
qu'il y a de catégories à notre graphique. Si le libellé de vos catégories est plus long il faudra probablement prévoir une zone fixe ou faire apparaitre le tooltip.
function addTooltip(nbCategories) {
let values = d3.range(1, nbCategories + 1);
let band = tooltipWidth / values.length;
var tooltip = svg.append("g") // On regroupe tout le tooltip et on lui attribut un ID
.attr("id", "tooltip")
.style("opacity", 0);
tooltip.append("rect")
.attr("x", 0)
.attr("y", 0)
.attr("width", tooltipWidth)
.attr("height", 80)
.style("opacity","0.9")
.style("fill", "white")
.style("stroke-width","1")
.style("stroke","#929292")
.style("padding", "1em");
tooltip.append("line") // La ligne entre le titre et les valeurs
.attr("x1", 40)
.attr("y1", 25)
.attr("x2", 160)
.attr("y2", 25)
.style("stroke","#929292")
.style("stroke-width","0.5")
.attr("transform", "translate(0, 5)");
var text = tooltip.append("text") // Ce TEXT contiendra tous les TSPAN
.style("font-size", "13px")
.style("fill", "grey")
.attr("transform", "translate(0, 20)");
text.append("tspan") // Le titre qui contient la date avec définition d'un ID
.attr("x", tooltipWidth / 2)
.attr("y", 0)
.attr("id", "tooltip-date")
.attr("text-anchor", "middle")
.style("font-weight", "600")
.style("font-size", "16px");
text.selectAll("text.biberon") // Le nom des catégories, ici "1", "2"...
.data(values)
.enter().append("tspan")
.attr("x", d => band / 2 + band * (d - 1))
.attr("y", 30)
.attr("text-anchor", "middle")
.style("fill", "grey")
.text(d => d);
text.selectAll("text.biberon") // La valeur des catégories avec définition d'un ID : "tooltip-1", "tooltip-2"...
.data(values)
.enter().append("tspan")
.attr("x", d => band / 2 + band * (d - 1))
.attr("y", 45)
.attr("id", d => "tooltip-" + d)
.attr("text-anchor", "middle")
.style("fill", "grey")
.style("font-size", "0.8em")
.style("font-weight", "bold");
return tooltip;
}
Assez naturellement, on serait tenté d'ajouter les évènements de la souris sur les rectangles représentant nos données, directement dans la fonction principale. Si on procède ainsi le tooltip va clignoter quand on déplace la souris à cause du padding que nous avons entre les barres. Du coup nous avons décidé de réaliser un seul polygone qui représente la zone ou le tooltip doit apparaitre. Ce polygone couvre donc tout le graphique et se construit grâce à la fonction suivante.
function buildMousePolygon(data, x, y) {
const tmpline = d3.line()
.x(d => x.bandwidth() + x(d.date))
.y(d => y(d.value))
.curve(d3.curveStepBefore); // Nous utilisons une courbe sous forme d'escalier pour coller à nos barres
let tmpArray = [];
for (let i = 0; i < data.length; ++i) {
tmpArray.push({"date": data[i].date, "value": data[i].Total});
}
// Création d'un groupe qui n'est pas ajouté à la page
const detachedGroup = d3.create("g");
detachedGroup.append("path")
.datum(tmpArray)
.attr("d", tmpline);
// Le path ajouté ci-dessous ne forme pas un chemin fermé sur lui même, nous le complétons avec ce chemin construit manuellement
// https://www.dashingd3js.com/svg-paths-and-d3js
let strPath = "M " + x.bandwidth() + " " + y(data[0].Total) + " H 0 V " + height + " H " + width + " V " + y(data[data.length - 1].Total);
detachedGroup.append("path")
.attr("d", strPath);
// Réunion de tous les path en un seul
var mergedPath = "";
detachedGroup.selectAll("path")
.each(function() { mergedPath += d3.select(this).attr("d"); });
return mergedPath;
}
Le polygon ainsi construit est inséré dans le graphique de manière transparente. Pour avoir une bonne idée de ce qu'il représente, le voici directement sur le graphique avec un contour rouge. Il entoure parfaitement l'ensemble des rectangles.
Nous pouvons maintenant définir la fonction de gestion de la souris qui fait apparaitre le tooltip, renseigne les valeurs des catégories en fonction de la souris et fait disparaitre le tooltip si on sort du polygone.
function handleMouseEvent(data, x, y, tooltip) {
let mergedPath = buildMousePolygon(data, x, y); // construction du polygone
svg.append("path")
.attr("d", mergedPath)
.style("opacity", 0) // Ajout du polygone avec une opacity de 0
.on("mouseover", function(event) {
tooltip.style("opacity", 1);
})
.on("mouseout", function(event) {
tooltip.style("opacity", 0);
})
.on("mousemove", function(event {
// D3JS ne fournit pas de fonction pour retrouver les données associées à la position de la souris comme il le fait les courbes.
// Il faut donc procéder par calcul pour retrouver quelle donnée est associée à la position de la souris.
// https://stackoverflow.com/questions/38633082/d3-getting-invert-value-of-band-scales
let mouse = d3.pointer(event),
i = Math.floor((mouse[0] / x.step())), // step = bandWidth + paddingInner : https://observablehq.com/@d3/d3-scaleband
d = data[i];
if (d === undefined) { return ; }
// On empèche ici le tooltip de sortir du graphique lorsque la souris se rapproche des bords
let boundedX = mouse[0] < (tooltipWidth / 2) ? 0 : mouse[0] > (width - (tooltipWidth / 2)) ? width - tooltipWidth : mouse[0] - (tooltipWidth / 2);
tooltip.attr("transform", "translate(" + boundedX + "," + (mouse[1] - 90) + ")");
tooltip.select('#tooltip-date')
.text("Biberons du " + d.date);
for (let i = 1; i <= 9; i++) {
tooltip.select('#tooltip-' + i)
.text(d["Biberon " + i]);
}
});
}
Ce tutoriel est maintenant terminé. Comme toujours la section commentaire est là pour échanger si vous avez des questions.
VOUS POURRIEZ AIMER
COMMENTAIRES
Escanor2096
bonjour, je suis etudiant et j'aimerais apprendre le d3js mais dans votre exemple nous n'avons pas accès au jeu de données.
merci d'avance
ericfrigot
Bonjour, les données que j'utilise peuvent être récupérées via la console de debug de Chrome par exemple. Elles sont accessibles sur ce lien : https://www.datavis.fr/d3js.... Je note aussi qu'il y a un petit bug, chez moi la visualisation ne s'affiche plus, je regarderais ce soir. Eric
ericfrigot
Afin de résoudre mon soucis je suis passé au format CSV qui est disponible ici : https://www.datavis.fr/d3js...
Escanor2096
MERCI BEAUCOUP
Benjamin Golinvaux
Petite question : je suis en train d'essayer de créer un graphe empilé (avec D3 v6) en m'inspirant de cet article et je bute sur un problème en apparence anodin : j'aimerais, dans l'événement souris, avoir accès à la série. Dans cet exemple, cela équivaudrait à afficher "Biberon 3", par exemple, lorsque la souris se trouve sur un rect de la série correspondante.
Or, l'événement souris ne reçoit que le datum, qui contient la date et les bornes y[0] et y[1] permettant le dessin. M'est-il possible d'identifier la série sans écrire du code ad hoc passant en revue toutes les séries et leurs valeurs à cette date ?
Merci d'avance
Benjamin Golinvaux
J'ai trouvé la solution (une solution, devrais-je écrire), si cela intéresse qqun : j'ajoute au groupe SVG
g.biberon
un attributSeriesKey
qui dénote la catégorie :keys[i]
Ensuite, dans la gestion de l'événement souris, j'utilise
this.parentNode
pour retrouver ce groupe SVG etthis.parentNode.getAttribute('SeriesKey')
me permet de retrouver la catégorie (Biberon 1, Biberon 2...)À toutes fins utiles...
ericfrigot
Bonjour Benjamin,
Merci pour ces éléments. Effectivement c'est une solution pratique, on ajoute nos données aux éléments HTML associés.
Benjamin Golinvaux
Je pense qu'il s'est glissé une toute petite erreur dans le texte. Dans le paragraphe "Une version minimaliste", il me semble que, dans la phrase "Ces sous-tableaux sont composés de deux valeurs puis d'un attribut data qui n'est pas ouvert dans la capture
(il contient les données TSV correspondant à la date).", il faudrait écrire "...puis d'un attribut date" ?
ericfrigot
Bonjour, merci pour votre vigilance mais j'ai un doute. La capture d'écran montre bien un attribut "data", on parle de celui qui contient pour valeur "{...}". A confirmer mais cela me parait juste.
Benjamin Golinvaux
Vous avez raison. J'étais absolument convaincu de l'existence de cette erreur il y a une semaine, mais je constate que vous avez raison :) Toujours se méfier de ses certitudes et relire à tête reposée.... 😊