D3JS - Graphique en essaim (Swarm Chart)

Variation du cours des actions du SRD

Dernière mise à jour le 10/02/2021
d3js5.x

Introduction

Ce graphique représente l'ensemble des actions du compartiment SRD (239 actions) réparties par catégories. Il permet de constater l'évolution du cours des actions sur un an entre le 10 janvier 2020 et le 10 janvier 2021. On parle de graphique en essaim ou swarm chart en anglais du fait de la forme que prend le graphique avec un regroupement fort à une position précise et quelques éléments qui gravitent autour. En plus de ces informations le graphique indique la capitalisation boursière de chaque action par la taille de son cercle. Au programme de ce tutoriel :

Pourquoi un graphique en essaim ?

Un graphique en essaim visualise les données à un niveau très granulaire. Il permet un mappage un à un entre le nombre d'éléments de votre ensemble de données et les cercles qui les représentent dans le graphique. En plus des dimensions x et y conventionnelles, il nous permet également de visualiser des dimensions supplémentaires grâce à l'application de la taille et de la couleur.

Notre jeu de données

Nos données contiennent l'ensemble des actions du SRD (c'est un type d'action du marché Euronext), ce qui représente 253 actions. Elles proviennent d'un fichier CSV que nous avons construit par copier-coller à partir du site Boursorama. Elles contiennent le code de chaque action, son libellé, son ISIN, la dernière valeur de cotation, sa variation sur un an, le volume d'échange lors de la dernière journée de cotation, sa capitalisation, son secteur ainsi qu'une version du secteur limité à 10 catégories (secteur10). Cette dernière colonne a été construite à la main. Les seules colonnes importantes pour la visualisation sont : var1an, capitalisation et secteur10.

libelle dernier var1an volume capitalisation isin secteur secteur10
LVMH525.300+23.75%458 513265 148.852FR0000121014Habillement et accessoiresBiens de consommation
L'OREAL306.300+16.55%397 361171 484.199FR0000120321Produits de soin personnelBiens de consommation
TOTAL37.485-23.49%6 404 25799 452.203FR0000120271Sociétés pétrolières et gazières intégréesPétrole et gaz
SANOFI78.790-14.36%2 428 46799 193.458FR0000120578PharmacieSanté

Initialisation

On ne présente plus cette étape qui est identique sur toutes nos visualisations. On notera seulement la définition de swarmSpace qui représente l'espacement horizontal en pixels entre nos différents essaims.

const margin = {top: 20, right: 40, bottom: 60, left: 40},
	swarmSpace = 100,
	width = document.getElementById("container").offsetWidth - margin.left - margin.right,
	height = 600 - margin.top - margin.bottom;

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

Fonction principale

Cette fonction réalise le chargement du fichier CSV et calcule les différents domaines qui seront utilisés. Enfin, elle appelle la construction des différents éléments du graphique, les axes, les cercles et lance l'exécution d'une simulation pour éviter la superposition des cercles.

d3.csv('d3js/srd-2020/srd2020.csv').then(function(csv) {
	// Conversion des données
	csv.forEach(d => {
       	d.capitalisation = +d.capitalisation.replace(" ", "");
       	d.var1an = +d.var1an.replace("%", "");
    });

	// Utilisation d'un Set pour supprimer les dupliqués
	let sectors = Array.from(new Set(csv.map(d => d.secteur10)));

	// Séparation sur l'axe horizontal entre chaque essaim
	let xCoords = sectors.map((d, i) => swarmSpace + i * swarmSpace);
	let xScale = d3.scaleOrdinal().domain(sectors).range(xCoords);

	let yScale = d3.scaleLinear()
		.domain(d3.extent(csv.map(d => d.var1an)))
		.range([height, 0]);

	let color = d3.scaleOrdinal().domain(sectors).range(d3.schemeSpectral[10]);

	// Notre domaine utilise la racine carré de la capitalisation afin de limiter les différences de taille
	// Nous projetons ce domain vers des cercles entre 3px et 25px
	let size = d3.scaleLinear()
		.domain(d3.extent(csv.map(d => Math.sqrt(d.capitalisation))))
		.range([3, 25]);
		
	addXAxis(xScale);
	addYAxis(yScale);
	addSwarmCircles(csv, color, size, xScale, yScale); // Cette méthode doit bien être exécutée après la construction des axes
	addSimulation(csv, xScale, yScale, size);
});

Ajout des essaims

Cette fonction est assez simple maintenant que tous les paramètres sont définis. On parcourt notre fichier CSV et pour chaque entrée on ajoute un cercle. Sa couleur, sa taille, sa position sur l'axe X et l'axe Y sont déterminées par les fonctions en paramètre dont nous avons défini le comportement dans la fonction principale.

function addSwarmCircles(csv, color, size, xScale, yScale) {
	svg.selectAll(".circ")
		.data(csv)
		.enter()
		.append("circle")
		.attr("class", "circ")
		.attr("stroke", "black")
		.attr("fill", d => color(d.secteur10))
		.attr("r", d => size(Math.sqrt(d.capitalisation)))
		.attr("cx", d => xScale(d.secteur10))
		.attr("cy", d => yScale(d.var1an));
}

L'exécution de ce code de quelques lignes permet déjà d'obtenir un résultat très intéressant. Évidemment on peut trouver une forte superposition de cercles à la même position. C'est pour cette raison qu'on utilise d3.forceSimulation.

Graphique en essaim sans utilisation de d3.forceSimulation

d3.forceSimulation

C'est la partie la plus intéressante de ce tutoriel. En construisant un graphique en essaims, il faut bien comprendre les forces qui sont en jeu sur chaque élément du graphique. Le contrôle de ces forces vous permettra toute sorte de créativité. D3 fait tout le travail, il suffit de lui dire ce qui est important pour nous :

function addSimulation(csv, xScale, yScale, size) {
	let simulation = d3.forceSimulation(csv)
		.force("x", d3.forceX(d => {
			return xScale(d.secteur10);
		}).strength(0.2))
		.force("y", d3.forceY(d => {
			return yScale(d.var1an);
		}).strength(1))
		.force("collide", d3.forceCollide(d => {
			return size(Math.sqrt(d.capitalisation));
		}))
		.alphaDecay(0)
		.alpha(0.3)
		.on("tick", tick);

	setTimeout(function () {
		simulation.alphaDecay(0.1);
	}, 3000);
}

function tick() {
	d3.selectAll(".circ")
		.attr("cx", d => d.x)
		.attr("cy", d => d.y);
}

Les paramètres méritent d'être testés en fonction de votre contexte. Par exemple dans le cas précis de notre exemple, si l'espacement entre les essaims est inférieur à 100 (swarmSpace) il arrivait qu'un cercle change de catégorie et dans ce cas-là il faudrait renforcer la valeur de la force sur l'axe X. Cette force doit d'ailleurs être comprise entre 0 et 1. Le paramètre fourni à la fonction alpha indique à quelle vitesse la simulation va se stabiliser. Avec un alphaDecay défini à 0, les forces vont s'exercer de manière indéfinie. On précise également une fonction exécutée sur l'évènement tick. Cette fonction sert simplement à tenir compte de la simulation qui est exécutée étape par étape et à modifier la position de nos cercles. C'est comme l'animation dans un dessin animé ou à chaque frame on va déplacer les éléments à gauche ou à droite, en haut et en bas.

Une simulation peut tourner indéfiniment, s'il y a une tension dans une zone donnée liée aux forces qui s'y exercent. Si vous retournez en haut de cette page, faite un rafraichissement et vous verrez que les cercles se stabilisent en une fraction de seconde à leur position finale mais qu'il y a quelques cercles qui continuent de bouger pendant 3 secondes environ. Ces 3 secondes correspondent au paramètre passé à la fonction setTimeout qui permet d'arrêter la simulation au bout de cette durée.

Ajout des axes

La construction des axes est assez classique, on pourra noter quatre points particuliers :

function addXAxis(xScale) {
	let xAxisGroup = svg.append("g")
		.attr("class", "xAxis")
	    .attr("transform", "translate(0," + height + ")")
    	.call(d3.axisBottom(xScale).tickSize(0));
	
	xAxisGroup.selectAll(".tick text")
		.call(wrap, swarmSpace);
		
	// On masque la ligne horizontal
	xAxisGroup.select('.domain')
       	.attr('stroke-width', 0);
}

function wrap(text, width) {
	text.each(function() {
		var text = d3.select(this),
			words = text.text().split(/\s+/).reverse(),
			word,
			line = [],
			lineNumber = 0,
			lineHeight = 1.1, // ems
			y = text.attr("y"),
			dy = parseFloat(text.attr("dy")),
			tspan = text.text(null).append("tspan").attr("x", 0).attr("y", y).attr("dy", dy + "em")
		while (word = words.pop()) {
			line.push(word)
			tspan.text(line.join(" "))
			if (tspan.node().getComputedTextLength() > width) {
				line.pop()
				tspan.text(line.join(" "))
				line = [word]
				tspan = text.append("tspan").attr("x", 0).attr("y", y).attr("dy", `${++lineNumber * lineHeight + dy}em`).text(word)
			}
		}
	});
}

function addYAxis(yScale) {
	let yAxisGroup = svg.append("g")
		.attr("class", "yAxis")
		.call(d3.axisLeft(yScale))
       	.append("text")
       	.attr("fill", "#000")
       	.attr("transform", "rotate(-90)")
       	.attr("y", 6)
       	.attr("dy", "0.71em")
       	.style("text-anchor", "end")
       	.text("%");
		
	d3.selectAll(".yAxis text").each(function(d) {
		this.setAttribute("color", d < 0 ? "#f11c3a" : "#2cc357"); // rouge ou vert
	});

	// Ajout de la grille horizontale (pour l'axe Y donc). Pour chaque tiret (ticks), on ajoute une ligne qui va 
	// de la gauche à la droite du graphique et qui se situe à la bonne hauteur.
	svg.selectAll("y axis")
		.data(yScale.ticks())
		.enter()
			.append("line")
			.attr("class", d => ( d == 0 ? "horizontal0" : "horizontalGrid"))
			.attr("x1", 0)
			.attr("x2", width)
			.attr("y1", d => yScale(d))
			.attr("y2", d => yScale(d));
}

Il y a un peu de CSS associé à ces axes.

.xAxis, .yAxis {
	font-size: 12px;
	font-weight: 500;
}

.horizontalGrid {
	fill : none;
	shape-rendering : crispEdges;
	stroke : lightgrey;
	stroke-width : 1px;
}

.horizontal0 {
	fill : none;
	shape-rendering : crispEdges;
	stroke : black;
	stroke-width : 1px;
	stroke-dasharray: 5,5; /* pour les pointillés */
}

Conclusion

Ce tutoriel est maintenant terminé. Comme à chaque fois nous avons fourni un code complet qui permet une réalisation avancée d'un graphique en essaim. Si certains sujets n'ont pas été traités n'hésitez pas à poser des questions dans les commentaires.

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 - Sunburst Chart

Sunburst Chart

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

D3JS - Histogramme Empilé (Stacked Bar Chart)

Histogramme Empilé (Stacked Bar Chart)

Construire un diagramme empilé avec tooltip en SVG, moyenne mobile et gestion de la souris avancée

comments powered by Disqus