D3JS - Sunburst Chart

Construction d'un graphique hiérarchique de type Sunburst, variation d'une couleur en HSL et utilisation de patterns SVG
d3js7.x
Sources :

Introduction

Ce graphique, qu'on nomme Sunburst Chart en anglais, présente les données de la base transparence santé qui rend accessible l'ensemble des informations déclarées par les entreprises sur les liens d'intérêts qu'elles entretiennent avec les acteurs du secteur de la santé (site). Nous avons retenu uniquement les 9 entreprises qui déclarent les montants les plus importants durant une période d'un an entre juillet 2018 et juin 2019. Ce tutoriel vous permettra d'en apprendre plus sur :

  • La préparation de données sous forme hiérarchique
  • La construction d'un graphique de type Sunburst avec couleur dynamique et tri spécifique à chaque niveau
  • La Définition de patterns dynamiques en SVG pour remplir une forme graphique
  • L'utilisation de D3 pour créer des variations autour d'une couleur grâce à sa représentation HSL
  • La manipulation de la séquence sélectionnée par le survol de la souris pour mettre en avant cette sélection et mettre à jour dynamiquement la légende
Par commodité nous avons réduit le texte de deux catégories. "Académies, Fondation, ..." correspond dans sa dénomination complète à "Académies, Fondation, sociétés savantes, organismes de conseils". De même "Personnes morales assurant..." correspond à "Personnes morales assurant la formation initiale ou continue des professionnels de santé".

Préparation des données

Nous avons dédié un tutoriel à la préparation des données depuis le site data.gouv.fr qui fournit les fichiers de la base Transparence Santé (plusieurs Go de fichiers CSV) en utilisant le langage R pour obtenir des fichiers consolidés par entreprise. Après une petite analyse sous Excel nous avons pu sélectionner les entreprises qui dépensaient les sommes les plus importantes et construire en javascript des données adaptées à un graphique hiérarchique : Voir le tutoriel que nous allons utiliser.

Les données

Ce tutoriel montre comment obtenir des données directement adaptées au package d3.hierarchy. Notez que l'attribut name n'est pas obligatoire mais dans ce cas il est plus difficile d'associer une légende. Quand on construit la hiérarchie D3JS ne veut connaitre que l'attribut qui permet de sommer les valeurs à afficher (ici c'est amount). On doit partir d'un noeud principal et lui associer un tableau de children. Chaque fils pouvant à son tour contenir un tableau. On ne positionne les valeurs que sur les feuilles (le niveau final) et c'est D3JS qui se charge de faire le calcul global par niveau. L'attribut matchingNames ne sert qu'à l'étape de construction des données et l'attribut position est utile pour gérer précisément les couleurs dans notre cas.

Structure des données adaptée à d3.hierarchy

Vous êtes libre de créer autant de niveaux que vous le souhaitez, chacun des niveaux n'a pas besoin d'être défini comme dans cet exemple. Nos données possèdent uniquement trois niveaux (entreprise, type de montant et catégorie).

Une version minimaliste

Nous construisons pour commencer une version minimaliste de notre graphique. Elle fonctionne parfaitement puisque nous obtenons bien une répartition sur trois niveaux de nos données et la taille de chaque bloc représente bien les montants de nos données. Mis à part la création des données (la variable json) tout le code javascript est présent. Voici dans le détail les opérations réalisées :

  • Initialisation de la taille du SVG et du diamètre de notre graphique
  • Ajout du SVG sur un DIV de la page. Notez que nous ajoutons directement un groupe dont les coordonnées 0,0 sont déplacées au centre du SVG, c'est parce qu'on dessine un cercle à partir de son centre.
  • Définition de la fonction de construction de chaque segment qui sont des arcs possédant une certaine épaisseur.
  • Transformation de nos données (déjà hiérarchique) en un objet D3JS enrichi, cet objet est aussi une hiérarchie qui possède pour chaque noeud notre donnée, une profondeur, un parent, une valeur produite par la fonction sum que nous avons définie, les fils... (les détails sont précisés en anglais ici : d3.hierarchy)
  • Initialisation du layout utilisé, ici une partition. Nous fournissons les dimensions de notre représentation graphique (un cercle tout simplement). Nous souhaitons donc répartir des données dans l'espace en fonction de leur propriété. D3 permet une répartition sous forme de rectangle (exemple) ou sous forme d'arc.
  • La dernière étape va utiliser notre fonction de construction d'arc pour représenter nos données passées entre les mains de la partition. Sans la ligne concernant le display on aurait un disque plein en son centre. Ici on affiche pas d'arc pour le noeud racine. C'est l'appel de la fonction partitionMin(root) qui fait tout le travail. Elle produit l'agencement parfait de nos segments en fonction de la valeur de chaque noeud. Si on inspecte l'objet qu'elle retourne nous pouvons voir les variables x0, x1, y0 et y1 dont a besoin la fonction arcMin
  • Résultat de l'exécution d'un partionnement par D3JS
const widthMin = 500, heightMin = 500, radiusMin = 250;
const visMin = d3.select("#min-chart").append("svg")
    .attr("width", widthMin)
    .attr("height", heightMin)
    .append("g")
        .attr("transform", "translate(" + widthMin / 2 + "," + heightMin / 2 + ")");

var arcMin = d3.arc()
    .startAngle(function(d) { return d.x0; })
    .endAngle(function(d) { return d.x1; })
    .innerRadius(function(d) { return Math.sqrt(d.y0); })
    .outerRadius(function(d) { return Math.sqrt(d.y1); });

var root = d3.hierarchy(json)
    .sum(function(d) { return d.amount; });

var partitionMin = d3.partition()
    .size([2 * Math.PI, radiusMin * radiusMin]);

visMin.selectAll("path")
    .data(partitionMin(root).descendants())
    .enter().append("path")
        .attr("display", function(d) { return d.depth ? null : "none"; })
        .attr("d", arcMin);

Maintenant que nous avons vu le principe général nous allons pouvoir entrer dans le détail.

Initialisation et couleurs

Cette partie est assez habituelle, la taille de notre SVG est dynamique en fonction de la largeur et on précise un radius qui déterminera le diamètre de notre visualisation. Nous définissons aussi une variable pour sauvegarder le total de notre hiérarchie. Par contre les couleurs des 9 entreprises ont été choisies précisément et stockées dans positionColors. L'attribut rotationColors définit la rotation de la teinte pour le niveau 3 (catégorie). Une des représentations de la couleur est HSL (Hue/Saturation/Lightness) qui se traduit en français par Teinte/Saturation/Valeur. L'autre plus connue est RGB.

const width = document.getElementById("container").offsetWidth * 0.95,
    height = 800
    radius = Math.min(width, height) / 2;

var total = 0;

var positionColors = ["#da1d23", "#ebb40f", "#187a2f", "#0aa3b5", "#c94930", "#ad213e", "#a87b64", "#e65832", "#da0d68"];

var rotationColors = {
    "Académies, Fondation, sociétés savantes, organismes de conseils": 5,
    "Association d'étudiants": 10,
    "Association professionnel de santé": 15,
    "Association usager de santé": 20,
    "Editeur de logiciel": 25,
    "Etablissement de santé": 30,
    "Etudiant": 35,
    "Personnes morales assurant la formation initiale ou continue des professionnels de santé": 40,
    "Presse et média": 45,
    "Professionnel de santé": 50,
    "Vétérinaire": 55,
    "Vétérinaire Personne Morale": 60
};

Pour comprendre ce principe de rotation, il suffit de regarder la représentation en HSL du spectre des couleurs. Le paramètre teinte ou "hue" en anglais correspond à un angle sur le disque ci-dessous. Si l'angle varie la couleur varie également. Une variation au niveau de la saturation signifie s'éloigner ou se rapprocher du centre du disque. La troisième dimension "valeur" n'est pas visible sur ce schéma et nécessite une troisième dimension.

Représentation HSL des couleurs

Construction du SVG et chargement des données

Nous construisons notre SVG déjà vu ci-dessus puis nous ajoutons un objet SVG de type defs qui servira pour remplir nos segments de manière non uniforme (avec des patterns). Quand les données sont chargées et après conversion en hiérarchie nous pouvons préparer les textes affichés au centre du disque puis créer la visualisation en elle-même.

const vis = d3.select("#chart").append("svg")
    .attr("class", "svg")
    .attr("width", width)
    .attr("height", height)
    .append("g")
    .attr("id", "container")
    .attr("transform", "translate(" + width / 2 + "," + height / 2 + ")");
    
vis.append("defs")
    .attr("id", "defs");
    
d3.text("/tutorials/d3js/sunburst-chart/transparence_data.csv").then(function(raw) {
    let dsv = d3.dsvFormat(';');
    let data = dsv.parse(raw);
    let json = buildHierarchy(data);
    addTextElement();
    createVisualization(json);
});

Ajout du texte

L'ajout du texte n'a rien d'original, nous définissons un groupe qui contiendra les trois lignes de texte. Il faut un id pour chaque texte afin de le mettre à jour quand on passe la souris sur la visualisation et il y a un CSS associé pour faire varier la police.

function addTextElement() {
    var textGroup = vis.append("g");

    textGroup.append("text")
        .attr("id", "entreprise")
        .attr("y", -100)
        .attr("class", "entreprise")
        .attr("text-anchor", "middle");
    
    textGroup.append("text")
        .attr("id", "type-amount")
        .attr("y", -80)
        .attr("class", "type-amount")
        .attr("text-anchor", "middle");
    textGroup.append("text")
        .attr("id", "category-amount")
        .attr("y", -60)
        .attr("class", "category-amount")
        .attr("text-anchor", "middle");
    textGroup.append("text")
        .attr("id", "amount")
        .attr("class", "amount")
        .attr("text-anchor", "middle");
}

Création de la visualisation

Comme nous avons déjà étudié la version minimaliste, il sera beaucoup plus facile de comprendre les ajouts réalisés ici :

  1. Ajout d'un cercle complet pour que la détection de sortie du parent g (chart-container) ne se fasse pas au milieu du graphique
  2. Ajout d'un tri sur la hiérarchie dépendant du niveau de profondeur : on veut les entreprises qui dépensent le plus en premiers (depth = 1) sinon nous trions par nom pour conserver la cohérence des patterns (depth = 2) et des changements de saturation (depth = 3)
  3. Ajout d'un filtre au moment de la création de la partition pour ne pas garder les valeurs trop petites qui ne seraient pas visibles à l'écran
  4. Utilisation d'une fonction de remplissage pour avoir autre chose que du noir
  5. Ajout d'une gestion de la souris pour les évènements de mouseover et mouseleave
  6. Définition du montant total de la hiérarchie
function createVisualization(json) {
    var arc = d3.arc()
        .startAngle(function(d) { return d.x0; })
        .endAngle(function(d) { return d.x1; })
        .innerRadius(function(d) { return Math.sqrt(d.y0); })
        .outerRadius(function(d) { return Math.sqrt(d.y1); });
    
    var partition = d3.partition()
        .size([2 * Math.PI, radius * radius]);

    vis.append("circle") // 1
        .attr("r", radius)
        .style("opacity", 0);

    var root = d3.hierarchy(json)
        .sum(function(d) { return d.amount; })
        .sort(function(a, b) { // 2
            if (a.depth === 1) {
                return b.value - a.value; 
            } else {
                return b.data.name.localeCompare(a.data.name) * -1;
            }
        });
		
	total = root.value;

    var nodes = partition(root).descendants()
        .filter(function(d) { // 3
            return (d.x1 - d.x0 > 0.005); // 0.005 radians = 0.29 degrees
        });

    var path = vis.selectAll("path")
        .data(nodes)
        .enter().append("path")
            .attr("display", function(d) { return d.depth ? null : "none"; })
            .attr("d", arc)
            .style("fill", function(d) { return getFillValue(d); }) // 4
            .on("mouseover", mouseover); // 5

    d3.select("#chart-container").on("mouseleave", mouseleave); // 5

	d3.select("#amount") // 6
		.text(total.toLocaleString('fr-FR', {minimumFractionDigits: 0, style: 'currency', currency: 'EUR'}));
	d3.select("#entreprise")
		.text("TOTAL");
}

Définition de la fonction de remplissage (fill)

Il s'agit de la partie la plus délicate de ce tutoriel bien que rien ne vous oblige à avoir une gestion aussi compliquée du remplissage de vos niveaux. La fonction prend en paramètre l'objet courant pour lequel on doit fournir une valeur de remplissage. En fonction du niveau de profondeur de l'arborescence notre traitement est différent.

  • Lorsque la profondeur est de 1 (le niveau de l'entreprise) nous retournons simplement la couleur définit dans le tableau positionColors pour cette entreprise
  • Lorsque la profondeur est de 2 (le niveau type de montant) nous définissons des patterns (3 en tout, un pour chaque type : avantage, convention et rémunération). Ce tutoriel n'a pas pour objet de discuter patterns mais si vous avez des questions à ce sujet n'hésitez pas à les poser dans la section commentaire. La seule subtilité à retenir est l'utilisation de rotateValue. Pour obtenir un positionnement des patterns identiques sur le cercle, on fait varier la valeur rotate en fonction de la position de d. On considère que 1 radian (unité des arcs dans D3) = 57.29 degrée. Notre calcul permet d'avoir toujours le même pattern relativement au centre du cercle au milieu de l'arc dessiné. Dans le cas du type rémunération nous ajoutons 90° pour que les traits soient horizontaux par rapport au centre du cercle.
  • Lorsque la profondeur est de 3 (le niveau catégorie) nous augmentons la valeur Hue (saturation) de la couleur de base de l'entreprise en fonction du nom de la catégorie et du tableau rotationColors
	function getFillValue(d) {
    if (d.depth === 1) {
        return positionColors[d.data.position];
    }

    if (d.depth === 2) {
        let parentColor = positionColors[d.parent.data.position];
        let rotateValue = (d.x0 + d.x1) / 2 * 57.29;
        let patternId = d.data.name + d.parent.data.position;

        if (d.data.name == "Avantage") {
            let pattern = d3.select("#defs")
                .append("pattern")
                .attr("id", patternId)
                .attr("width", "8")
                .attr("height", "8")
                .attr("patternUnits", "userSpaceOnUse")
                .attr("patternTransform", "rotate(" + rotateValue + ")");
            pattern.append("rect")
                .attr("width", "4")
                .attr("height", "8")
                .attr("fill", parentColor);
            return "url(#" + patternId +")";
        } else if (d.data.name == "Convention") {
            let pattern = d3.select("#defs")
                .append("pattern")
                .attr("id", patternId)
                .attr("width", "10")
                .attr("height", "10")
                .attr("patternUnits", "userSpaceOnUse");
            pattern.append("circle")
                .attr("r", "5")
                .attr("fill", parentColor);
            return "url(#" + patternId +")";
        } else if (d.data.name == "Rémunération") {
            let pattern = d3.select("#defs")
                .append("pattern")
                .attr("id", patternId)
                .attr("width", "8")
                .attr("height", "8")
                .attr("patternUnits", "userSpaceOnUse")
                .attr("patternTransform", "rotate(" + (rotateValue + 90) + ")");
            pattern.append("rect")
                .attr("width", "1")
                .attr("height", "8")
                .attr("fill", parentColor);
            return "url(#" + patternId +")";
        }
    }

    if (d.depth === 3) {
        let parentColor = d3.hsl(positionColors[d.parent.parent.data.position]);
        parentColor.h += rotationColors[d.data.name];
        return  parentColor + "";
    }

    return "";
}

Gestion de la souris et du texte

La gestion du mouseover est relativement simple. Elle vise à la fois à définir les textes au milieu du graphique et à sélectionner le bon chemin pour le mettre en avant. On considère que si la souris passe sur un segment, il est au minimum de profondeur 1 et possède donc un montant. On commence donc par convertir le montant de la donnée passée en paramètre en chaîne de caractères avec le symbole euro. On récupère ensuite les ancêtres de notre donnée (on obtient donc un tableau contenant au maximum trois valeurs) dont la racine est exclue. Après avoir remis à blanc le texte de la catégorie et du type de montant on peut lire notre séquence et mettre à jour le texte en fonction. Enfin tous les segments sont grisés puis on remet en valeur ceux de la séquence.

function mouseover(event, d) {
    d3.select("#amount")
        .text(d.value.toLocaleString('fr-FR', {minimumFractionDigits: 0, style: 'currency', currency: 'EUR'}));

    var sequenceArray = d.ancestors().reverse();
    sequenceArray.shift(); // suppression de la racine

    d3.select("#category-amount")
        .text("");
    d3.select("#type-amount")
        .text("");

    sequenceArray.forEach(d => {
        if (d.depth === 1) {
            d3.select("#entreprise")
                .text(d.data.name);
        } else if (d.depth === 2) {
            d3.select("#type-amount")
                .text(d.data.name);
        } else if (d.depth === 3) {
            let text = d.data.name
                .replace("Académies, Fondation, sociétés savantes, organismes de conseils", "Académies, Fondation, ...")
                .replace("Personnes morales assurant la formation initiale ou continue des professionnels de santé", "Personnes morales assurant ...");
            d3.select("#category-amount")
                .text(text);
        }
    });

    d3.selectAll("path") // On grise tous les segments
        .style("opacity", 0.3);

    vis.selectAll("path") // Ensuite on met en valeur uniquement ceux qui sont ancêtres de la sélection
        .filter(function(node) {
            return (sequenceArray.indexOf(node) >= 0);
        })
    .style("opacity", 1);
}

Pour le mouseleave le traitement consiste à revenir à l'état initial.

function mouseleave(event, d) {
    // On désactive la fonction mouseover le temps de la transition
    d3.selectAll("path").on("mouseover", null);

    // Transition pour revenir à l'état d'origine et on remet le mouseover
    d3.selectAll("path")
        .transition()
        .duration(1000)
        .style("opacity", 1)
        .on("end", function() {
            d3.select(this).on("mouseover", mouseover);
        });

    // Nous effaçons les différents textes définir le montant total de la hiérarchie
	d3.select("#category-amount")
		.text("");
	d3.select("#type-amount")
		.text("");

	d3.select("#amount")
		.text(total.toLocaleString('fr-FR', {minimumFractionDigits: 0, style: 'currency', currency: 'EUR'}));
	d3.select("#entreprise")
		.text("TOTAL");
}

COMMENTAIRES