Spiderchart sous D3JS
Ajoutez un spider chart à vos dashboards avec D3JS et tirez parti de la valeur ajoutée de ce diagramme
Le diagramme araignée, ou spider chart, est généralement utilisé pour marquer la
progression, dans le cursus d'un apprenant par exemple, ou encore dans la complétion
d'un profil. Il est très efficace pour comparer visuellement plusieurs profils ou
bien ramener un profil à la moyenne de l'effectif sur divers points de mesure.
Nous allons voir dans cet article comment implémenter un tel diagramme avec D3JS.
Objectif
Nous allons afficher le diagramme araignée résultant d'un test de langue passé par
un étudiant. Ce test est composé de 5 parties : Speaking, Reading, Writing,
Listening et Culture. Chacune de ces parties est notée sur 10. Nous voulons, qu'au
terme du test, l'étudiant voit apparaitre dans son tableau de bord sa performance,
comparée à celle des étudiants ayant déjà passé ce test.
Le résultat va ressembler à ceci :

Base html
Deux fichiers vont être nécessaires pour implémenter ce diagramme, un fichier index.html qui va constituer notre dashboard et abriter le conteneur du diagramme et un fichier spider.js qui contiendra le code source javascript.
Voici tout d'abord le contenu du fichier index.html :
<html>
<head>
<script src="https://d3js.org/d3.v7.min.js"></script>
</head>
<body>
<svg width="600" height="600"></svg>
</body>
</html>
<script type="module" src="./spider.js"></script>
Vous constatez que le contenu de ce fichier est relativement simple, nous importons tout d'abord la librairie D3JS, puis dans le corps de la page, nous déclarons un conteneur SVG qui va venir abriter notre futur diagramme. Nous décidons de suite de lui donner une taille de 600X600 pixels. Enfin, nous appelons notre script spider.js, dont nous allons voir les détails, afin de dresser le diagramme.
Implémentation du diagramme
Passons maintenant aux choses sérieuses et déroulons, pas à pas, le code à implémenter dans le fichier spider.js :
Données de travail
Nous allons commencer par initialiser les données. Nous nous concentrons en effet dans cet article sur l'implémentation du spider chart, aussi, nous allons nous contenter de stipuler les données dans le code. Néanmoins, dans un environnement productif, celles-ci proviendront d'une base de données :
//Initialisation des donnees
let categories = ["Speaking", "Reading", "Writing", "Listening", "Culture"];
let profils = [[5, 8, 5, 7, 3], [3, 6, 2, 7, 8]];
let spiderColors = ["gray", "orange"];
Nous avons ici les catégories qui composent le test ainsi que les notes qui constituent les profils dont il
faudra afficher le diagramme. Nous avons, pour cet exemple, stipulé une première série représentant les notes
moyennes de tous les étudiants suivie d'une seconde série représentant les notes de l'étudiant.
Enfin nous initialisons les couleurs des futurs diagrammes.
A noter, et ceci est valable pour les séries comme pour les couleurs, qu'il faut préciser en premier les
informations que l'on veut voir figurer en arrière-plan. Voilà pourquoi nous avons choisi de mentionner les
notes moyennes avant les notes de l'étudiant.
Configuration
Il s'agit là de déclarer plusieurs variables de configuration. Nous préférons regrouper leur initialisation en début de code afin d'éviter qu'elles soient dispersées dans le source final ou pire, qu'elles soient stipulées en dur.
//Configuration
let scale = d3.scaleLinear()
.domain([0, 10])
.range([0, 150]);
let ticks = [2, 4, 6, 8, 10];
let diagMargin = 100;
Nous commençons par déclarer notre échelle. Il s'agit d'une instance de l'objet d3js scaleLinear qui va venir
convertir linéairement une note en point sur le canvas. Notre étendue va de 0 à 150, ce qui signifie qu'une note de 10 aura
pour valeur 150.
Nous spécifions ensuite les ticks qui viendront jalonner le diagramme et faciliter ainsi la lecture, puis
la marge autour du diagramme.
Coordonnées des axes
Comme nous allons le voir un peu plus tard, nous allons travailler sur la base d'angles, aussi il va nous falloir, en fonction de l'angle associé à une catégorie, positionner son axe ainsi que son étiquette. Nous allons, par conséquent, créer une simple fonction qui nous renverra les coordonnées cartésiennes attendues.
//Conversion coordonnees polaires et cartesiennes
function getCoordinate(angle, value){
let x = Math.cos(angle) * scale(value);
let y = Math.sin(angle) * scale(value);
return {"x": width / 2 + x, "y": height / 2 - y};
}
Cette fonction va venir convertir une coordonnée polaire dans le système cartésien. Le paramètre value mentionné en entrée est à l'échelle du domaine. Enfin, comme vous le constatez dans le retour de la fonction, les coordonnées cartésiennes, une fois calculées, sont ajustées au canvas.
Formatage du canvas SVG
La première chose que nous allons faire, c'est formater le canvas sur lequel nous allons dresser notre diagramme. Le canvas va venir se positionner dans le conteneur SVG, que nous sélectionnons d'ailleurs. Nous prenons soin ensuite de spécifier la marge, c'est à dire l'espace à laisser libre de chaque côté. Nous avons initialisé cette marge à 100 pixels précédemment, ce qui signifie que le diagramme en lui-même va occuper un carré de 600-200 (marges haut/bas puis droite/gauche) soit 400 pixels de côté au milieu du conteneur.
//Formatage du canvas svg
var svg = d3.select("svg"),
margin = diagMargin,
width = svg.attr("width") - diagMargin,
height = svg.attr("height") - diagMargin;

Titre du diagramme
Nous spécifions ensuite le titre du diagramme. Rien de compliqué ici, si ce n'est le fait que pour positionner notre titre bien au centre, nous nous plaçons en abscisse au milieu du conteneur (width/2), puis en ordonnées, en milieu de marge haute. Enfin nous précisons une ancre text-anchor à "middle". Et le tour est joué !
//Titre du diagramme
svg.append("text")
.attr('x', width/2)
.attr('y', diagMargin/2)
.attr("text-anchor", "middle")
.attr("font-size", "18px")
.text("Vos resultats a ce test !")

Tracé des cercles
Nous attaquons désormais le diagramme en lui-même. Nous allons en effet commencer par tracer le cercle. Nous n'allons pas en tracer un seul mais autant de cercles concentriques que nous avons de ticks. Pour cela, nous initions une série cercle à laquelle nous associons nos données ticks. Pour chaque cercle généré, nous spécifions le centre, qui sera toujours le même, c'est à dire le centre du conteneur. Nous spécifions également un remplissage nul, une arrête noire et un rayon valant le tick à l'échelle du canvas.
//Trace du cercle
svg.selectAll("circle")
.data(ticks)
.join(
enter => enter.append("circle")
.attr("cx", width / 2)
.attr("cy", height / 2)
.attr("fill", "none")
.attr("stroke", "black")
.attr("r", d => scale(d))
);
Nous otbenons pour l'instant ceci :

Etiquettes des niveaux
Nous allons maintenant afficher chaque tick afin que l'étudiant puisse lire facilement le diagramme et situer son niveau pour chacune des catégories. Nous choisissons de n'éditer qu'une étiquette par niveau, ce sera suffisant et nous n'encombrerons ainsi pas le graphique inutilement.
Nous initions une série ticklabel puis lui associons le tableau des ticks. Pour chacun d'entre eux, via l'itération générée
par enter, nous précisons une taille de police, ses coordonnées ainsi que le texte à afficher. Ce dernier correspond au tick
lui-même.
Concernant les coordonnées, nous avons décidé d'afficher les niveaux le long du rayon vertical supérieur, aussi, l'abscisse
prend la valeur du centre du canvas (width/2) à laquelle nous ajoutons 2 pixels pour déplacer légèrement le texte vers la droite
et nous laisser ainsi la possibilité d'éditer un axe à côté. Pour l'ordonnée, nous faisons de même, à la différence que
cette fois nous réalisons un ajustement de la valeur du tick à l'échelle.
svg.selectAll(".ticklabel")
.data(ticks)
.join(
enter => enter.append("text")
.attr("font-size", "12px")
.attr("x", width / 2 + 2)
.attr("y", d => height / 2 - scale(d)+10)
.text(d => d.toString())
);
Voici le résultat :

Tracé des axes
Prochaine étape, et non des moindres : le tracé des axes.
Précisons tout d'abord que nous travaillons en base polaire. Nous allons donc pour chacune de nos catégories déterminer
l'angle que son axe prendra sur le plan. Cet angle est fonction d'une part du nombre total de catégories (ici 5) et de
l'indice de la catégorie.
Pour rappel, voici un quadrillage polaire :

Nous voulons afficher l'axe de la première catégorie verticalement le long des étiquettes de niveaux, l'angle qui lui sera associé sera donc \(\frac{\pi}{2}\). Ensuite nous allons ajouter à chaque nouvelle catégorie la mesure d'un angle correspondant à la division du cercle entier (\(2 * \pi\)) par le nombre de catégories.
Ceci étant dit, nous allons procéder à nos calculs puis stocker ces derniers dans une structure via un mapping. Dans cette
structure nous préciserons le nom de la catégorie, son angle associé puis les coordonnées cartésiennes. Ces dernières vont être
déterminées via la fonction getCoordinate que nous avons décrite au début. Puisque l'origine de chaque axe est connue, il
s'agira uniquement des coordonnées du point situé sur le bord externe du diagramme (de valeur 10).
Enfin nous en profiterons pour déterminer les coordonnées cartésiennes de l'ancre de l'étiquette de chaque catégorie, que
nous situerons à un valeur de 10.7, de façon à l'éloigner très légèrement du diagramme.
Voici tout d'abord le calcul des coordonnées cartésiennes :
//Caracterisation des categories
let categoriesData = categories.map((name, indice) => {
let angle = (Math.PI / 2) + (2 * Math.PI * indice / categories.length);
return {
"name": name,
"angle": angle,
"axe": getCoordinate(angle, 10),
"text": getCoordinate(angle, 10.7)
};
});
Puis le traçage des axes :
//Tracage des axes
svg.selectAll("line")
.data(categoriesData)
.join(
enter => enter.append("line")
.attr("x1", width / 2)
.attr("y1", height / 2)
.attr("x2", d => d.axe.x)
.attr("y2", d => d.axe.y)
.attr("stroke","black")
);
Enfin le résultat :

Affichage des catégories
Dans la structure categoriesData mise en place précédemment, nous avons également stocké les coordonnées cartésiennes de l'ancre de l'étiquette de chaque catégorie (variable text). Nous pourrions par conséquent nous contenter d'y afficher le nom des catégories, malheureusement nous arriverions à quelque chose qui ressemble à ceci :

Vous constatez que le texte est logiquement justifié à gauche sur l'ancre, ce qui occasionne un rendu indésirable. Nous allons déterminer la justification du texte en fonction de l'angle associe à la catégorie. Ainsi, comme l'illustre le schéma ci-dessous une catégorie dont l'angle est situé dans la partie bleue du cercle sera justifiée à droite, dans la partie jaune, à gauche et enfin dans la partie rouge, au milieu.

Pour arriver à ce résultat nous allons tout d'abord créer une nouvelle fonction getAnchor dont l'objectif sera de déterminer l'ancre à appliquer en fonction de l'angle, comme indiqué sur le schéma ci-dessus. Voici le code de cette fonction :
//Determination de l'ancre en fonction d'un angle
function getAnchor(angle){
let anchor = "middle";
if(angle > (7 * Math.PI / 12) && angle < (17 * Math.PI / 12)){
anchor = "end";
} else if(angle < (5 * Math.PI / 12) || angle > (19 * Math.PI / 12)){
anchor = "left";
}
return anchor;
}
Ceci étant fait, nous allons revoir légèrement notre structure categoriesData afin d'ajouter le stockage de l'ancre associée à chaque catégorie. Pour cela nous ajoutons une variable anchor dont la valeur sera le retour de la fonction getAnchor :
//Caracterisation des categories
let categoriesData = categories.map((name, indice) => {
let angle = (Math.PI / 2) + (2 * Math.PI * indice / categories.length);
return {
"name": name,
"angle": angle,
"axe": getCoordinate(angle, 10),
"text": getCoordinate(angle, 10.7),
"anchor" : getAnchor(angle)
};
});
Nous pouvons enfin afficher nos catégorie :
//Affichage des categories
svg.selectAll(".axislabel")
.data(categoriesData)
.join(
enter => enter.append("text")
.attr("x", d => d.text.x)
.attr("y", d => d.text.y)
.attr("text-anchor", d => d.anchor)
.text(d => d.name)
);
Nous initions une série axislabel à laquelle nous associons notre structure caractérisant les catégories. Nous itérons dessus via l'instruction enter pour finalement exploiter les informations calculées précédemment, à savoir les coordonnées cartésiennes des textes, leur justification et bien sur le texte en lui-même. Voici le résultat :

Affichage des profils
Nous arrivons à notre dernier étape, le tracé des profils. Nous allons tout d'abord créer une fonction dont l'objectif va être de prendre une série de données en entrée (les notes) puis selon la catégorie courante, déterminer l'angle à appliquer puis en retourner les coordonnées cartésiennes. Ceci va constituer le chemin du profil :
//Coordonnees cartesiennes du profil
function getPath(data){
let path = [];
for (var i = 0; i < categories.length; i++){
let angle = (Math.PI / 2) + (2 * Math.PI * i / categories.length);
path.push(getCoordinate(angle, data[i]));
}
return path;
}
Il ne nous reste plus qu’à initier une série path, lui associer nos profils et boucler dessus pour calculer le chemin, via notre nouvelle fonction :
//Trace des profils
let line = d3.line()
.x(d => d.x)
.y(d => d.y);
svg.selectAll("path")
.data(profils)
.join(
enter => enter.append("path")
.datum(d => getPath(d))
.attr("d", line)
.attr("stroke-width", 3)
.attr("stroke", (_, indice) => spiderColors[indice])
.attr("fill", (_, indice) => spiderColors[indice])
.attr("stroke-opacity", 1)
.attr("opacity", 0.7)
);
Voici le résultat final.
Il conviendra d'ajouter éventuellement une légende au diagramme.

Code source final
Après toutes ces étapes, voici le code qui constitue le fichier spider.js :
//Initialisation des donnees
let categories = ["Speaking", "Reading", "Writing", "Listening", "Culture"];
let profils = [[5, 8, 5, 7, 3], [3, 6, 2, 7, 8]];
let spiderColors = ["gray", "orange"];
//Configuration
let scale = d3.scaleLinear()
.domain([0, 10])
.range([0, 150]);
let ticks = [2, 4, 6, 8, 10];
let diagMargin = 100;
let anchors = ["left", "middle", "right"]
//Conversion coordonnees polaires et cartesiennes
function getCoordinate(angle, value){
let x = Math.cos(angle) * scale(value);
let y = Math.sin(angle) * scale(value);
return {"x": width / 2 + x, "y": height / 2 - y};
}
//Determination de l'ancre en fonction d'un angle
function getAnchor(angle){
let anchor = "middle";
if(angle > (7 * Math.PI / 12) && angle < (17 * Math.PI / 12)){
anchor = "end";
} else if(angle < (5 * Math.PI / 12) || angle > (19 * Math.PI / 12)){
anchor = "left";
}
return anchor;
}
//Coordonnees cartesiennes du profil
function getPath(data){
let path = [];
for (var i = 0; i < categories.length; i++){
let angle = (Math.PI / 2) + (2 * Math.PI * i / categories.length);
path.push(getCoordinate(angle, data[i]));
}
return path;
}
//Formatage du canvas svg
var svg = d3.select("svg"),
margin = diagMargin,
width = svg.attr("width") - diagMargin,
height = svg.attr("height") - diagMargin;
//Titre du diagramme
svg.append("text")
.attr('x', width/2)
.attr('y', diagMargin/2)
.attr("text-anchor", "middle")
.attr("font-size", "18px")
.text("Vos resultats a ce test !")
//Trace du cercle
svg.selectAll("circle")
.data(ticks)
.join(
enter => enter.append("circle")
.attr("cx", width / 2)
.attr("cy", height / 2)
.attr("fill", "none")
.attr("stroke", "black")
.attr("r", d => scale(d))
);
//Etiquettes des niveaux
svg.selectAll(".ticklabel")
.data(ticks)
.join(
enter => enter.append("text")
.attr("font-size", "12px")
.attr("x", width / 2 + 2)
.attr("y", d => height / 2 - scale(d)+10)
.text(d => d.toString())
);
//Caracterisation des categories
let categoriesData = categories.map((name, indice) => {
let angle = (Math.PI / 2) + (2 * Math.PI * indice / categories.length);
return {
"name": name,
"angle": angle,
"axe": getCoordinate(angle, 10),
"text": getCoordinate(angle, 10.7),
"anchor" : getAnchor(angle)
};
});
//Tracage des axes
svg.selectAll("line")
.data(categoriesData)
.join(
enter => enter.append("line")
.attr("x1", width / 2)
.attr("y1", height / 2)
.attr("x2", d => d.axe.x)
.attr("y2", d => d.axe.y)
.attr("stroke","black")
);
//Affichage des categories
svg.selectAll(".axislabel")
.data(categoriesData)
.join(
enter => enter.append("text")
.attr("x", d => d.text.x)
.attr("y", d => d.text.y)
.attr("text-anchor", d => d.anchor)
.text(d => d.name)
);
//Trace des profils
let line = d3.line()
.x(d => d.x)
.y(d => d.y);
svg.selectAll("path")
.data(profils)
.join(
enter => enter.append("path")
.datum(d => getPath(d))
.attr("d", line)
.attr("stroke-width", 3)
.attr("stroke", (_, indice) => spiderColors[indice])
.attr("fill", (_, indice) => spiderColors[indice])
.attr("stroke-opacity", 1)
.attr("opacity", 0.7)
);
Retrouvez dans la rubrique "Nos datasets" toutes les données dont vous aurez besoin pour tester et pratiquer !