D3JS - Sunburst

Construction d'un graphique hiérarchique, variation d'une couleur en HSL et utilisation de patterns SVG

Dernière mise à jour le 16/02/2020
d3js5.x

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 :

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

Info: 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 :

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

	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
}

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.

	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(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(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);
		});
}
comments powered by Disqus