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 :
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.
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.
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).
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 :
sum
que nous avons
définie, les fils... (les détails sont précisés en anglais ici : d3.hierarchy)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.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
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.
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.
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);
});
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");
}
Comme nous avons déjà étudié la version minimaliste, il sera beaucoup plus facile de comprendre les ajouts réalisés ici :
mouseover
et mouseleave
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");
}
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.
positionColors
pour cette entrepriserotateValue
. 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.
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 "";
}
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");
}
VOUS POURRIEZ AIMER
COMMENTAIRES