D3JS - Histogramme Empilé (Stacked Bar Chart)

Construire un diagramme empilé avec tooltip en SVG, moyenne mobile et gestion de la souris avancée
d3js7.x
Sources :

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 :

  • Construction d'un histogramme empilé avec d3.stack()
  • Construction d'une légende pour une liste de catégories
  • Construction d'un tooltip SVG dans le cadre d'un histogramme empilé (avec de nombreuses valeurs)
  • Calcul d'une moyenne mobile sur N jours
  • Ajout d'un texte positionné à la fin d'une courbe
  • Gestion de la souris avancée
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").

  • 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 abscisses
  • width est calculé de manière dynamique en fonction de la largeur de la page à laquelle on retire les marges à droite et à gauche
  • height 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 bas
  • keys représente les catégories de notre fichier qui seront utilisées pour calculer la hauteur de chaque rectangle de notre diagramme
  • colors possède la même taille que le tableau keys et contient la couleur qui sera associée à chaque catégorie
  • legendCellSize indique la taille des carrés dans la légende (largeur et hauteur donc)
  • tooltipWidth contient la largeur de notre tooltip en pixel

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

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

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

Conclusion

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

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 attribut SeriesKey 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 et this.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.... 😊