D3JS - Graphique en essaims (Swarm Chart)

Construire un graphique en essaims avec d3.forceSimulation et un paramétrage avancé des axes
d3js7.x
Sources :

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 :

  • Construction d'un graphique en essaim avec les outils habituels
  • Construction des axes avec valeurs négatives et deux couleurs différentes
  • Retour à la ligne automatique pour les libellés trop longs (wrapping)
  • Utilisation de d3.forceSimulation pour parfaire la visualisation

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

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

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 :

  • Sur l'axe X, nous voulons attirer tous les éléments appartenant au même secteur vers la même ligne verticale imaginaire représentant notre secteur. Mais cette attraction est moins importante que la suivante, nous n'avons pas besoin de mettre tous les cercles sur la même ligne vertical, il faut juste que ces cercles n'en soient pas trop éloignés au point de changer de secteur. Pour cette raison nous définissons une force de 0.2
  • Sur l'axe Y, nous voulons attirer les éléments vers la valeur représentant la variation du cours sur un an. Pour obtenir un graphique qui a du sens la force d'attraction est plus important, c'est pourquoi nous l'avons défini à 1
  • On indique enfin à D3 comment éviter les collisions en fournissant la taille de chacun de nos cercles. Cela permet d'éviter les superpositions.

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 :

  • Sur l'axe X, on masque l'axe horizontal car le graphique représente des catégories, il n'y a pas de continuité entre elles
  • Pour chaque texte de l'axe X, appel de la fonction wrap qui va automatiquement remplacer le text par une succession de tspan en effectuant un retour à la ligne quand il y a un espace
  • Sur l'axe Y, après avoir ajouté l'axe on change la couleur du texte en fonction de sa valeur
  • La grille horizontale devient pointillée lorsqu'elle est sur 0

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.

COMMENTAIRES