D3JS - Histogramme Empilé (Stacked Bar Chart)

Consommation de lait sur une période de 3 mois

Dernière mise à jour le 06/10/2020

Introduction

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 :

Les données que nous avons retenues ne sont pas les plus pertinentes pour ce type de graphique. Ici les catégories sont simplement le premier biberon, puis le second... Habituellement on utilise les histogrammes empilés pour différencier des catégories de manière plus explicite. Par exemple on peut étudier le chiffre d'affaire d'une entreprise et séparer celui-ci par type d'activité. Dans ce cas là on utilise généralement un schéma de couleurs dédié (voir Categorical colors) pour lequel il existe une couleur différente par catégorie. Voici les premières lignes de notre fichier de données (au format TSV) :

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	

Initialisation

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").

const 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 + ")");

Une version minimaliste

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.tsv("d3js/stacked-barchart/biberons.txt", function(error, 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.

Stacked Bar Chart, Diagramme empilé en version minimale

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"

Stacked Bar Chart, contenu de nos données

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 TSV correspondant à la date). Pour lire l'ensemble, il faut procéder ainsi :

  • La première série correspondant à la catégorie "Biberon 1" est contenu dans la variable 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])
  • La deuxième série correspondant à la catégorie "Biberon 2" est contenu dans la variable 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 ml

Stacked Bar Chart, d3.stack(), transformation de nos données

Fonction principale

La fonction principale de chargement du fichier TSV est identique à celle étudiée ci-dessus avec l'ajout des axes et des appels à d'autres méthodes.

d3.tsv("d3js/stacked-barchart/biberons.txt", function(error, 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);
});

Calcul et ajout de la moyenne mobile

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

Ajout de la légende

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

Ajout du tooltip

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

Gestion de la souris

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.

Stacked Bar Chart, gestion de la souris via un polygone

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() {
			tooltip.style("opacity", 1);
		})
		.on("mouseout", function() {
			tooltip.style("opacity", 0);
		})
		.on("mousemove", function() {
			// 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.mouse(this),
				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]);
			}
		});
}

Conclusion

Ce tutoriel est maintenant terminé. Comme toujours la section commentaire est là pour échanger si vous avez des questions.

VOUS POURRIEZ AIMER


D3JS - Création d'un histogramme (Bar Chart)

Histogramme (Bar Chart)

Comment réaliser un histogramme et lui associer une légende

D3JS - Création d'une courbe de données élégante

Lignes avancées (Linear Chart)

Création d'un tooltip directement en SVG, ajout d'un dégradé sous la courbe grâce à l'utilisation d'un gradiant, point de suivi des valeurs fluide...

D3JS - Carte choroplèthe avancée

Carte choroplèthe avancée

Comprendre les nuances de couleurs, construire une légende agréable et être pertinent dans une cartographie

comments powered by Disqus