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 :
d3.forceSimulation
pour parfaire la visualisationUn 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.
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 |
---|---|---|---|---|---|---|---|
LVMH | 525.300 | +23.75% | 458 513 | 265 148.852 | FR0000121014 | Habillement et accessoires | Biens de consommation |
L'OREAL | 306.300 | +16.55% | 397 361 | 171 484.199 | FR0000120321 | Produits de soin personnel | Biens de consommation |
TOTAL | 37.485 | -23.49% | 6 404 257 | 99 452.203 | FR0000120271 | Sociétés pétrolières et gazières intégrées | Pétrole et gaz |
SANOFI | 78.790 | -14.36% | 2 428 467 | 99 193.458 | FR0000120578 | Pharmacie | Santé |
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: 0, bottom: 60, left: 30},
swarmSpace = 97,
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 + ")");
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('tutorials/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);
});
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
.
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 :
0.2
1
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.
La construction des axes est assez classique, on pourra noter quatre points particuliers :
wrap
qui va automatiquement remplacer le text
par une succession de tspan
en effectuant un retour à la ligne quand il y a un espace
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 */
}
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
COMMENTAIRES