D3JS - Word Cloud (nuage de mots)

Construire et paramétrer un nuage de mots avec la librairie d3-cloud de Jason Davis
d3js7.x
Sources :

Introduction

Les nuages de mots tout le monde connait, on les a vu fleurir un peu partout sur Internet il y a quelques années maintenant. Ils sont souvent utilisés pour leur esthétisme uniquement et dans ces cas-là parfaitement inutiles. Néanmoins cette représentation de données peut être très intéressante pour rendre compte de "quoi on parle" dans un ensemble de mots ou dans un texte. Associé à une chronologie elle permet de visualiser directement l'évolution ou les tendances d'un site Internet par exemple. La représentation que vous voyez ci-dessus n'est composée que d'éléments HTML, ce qui signifie qu'un moteur de recherche va indexer son contenu. Ce tutoriel utilise la librairie développée par Jason Davis que vous pouvez récupérer sur Github. Nous verrons par l'exemple à quoi correspond les différents paramétrages qu'elle peut offrir et les questions qu'il faut se poser pour construire un nuage de mot.

Les données que nous présentons ci-dessus proviennent du site Rue89 qui n'existe plus aujourd'hui. Wikipedia pourra vous renseigner plus en détails sur ce site crée en 2007 et racheté ensuite par le Nouvel Obs. A sa création ce journal purement numérique se voulait ouvert et était plutôt orienté à gauche, il offrait un espace important à ses lecteurs dans la section commentaire avec une modération équilibrée rendant les échanges enrichissants. Malheureusement pour lui il n'a jamais atteint l'équilibre financier et il subsiste aujourd'hui comme une rubrique du journal qui l'a racheté.

Fonctionnement

La réalisation d'un nuage de mot se décompose en trois étapes qu'on retrouve assez souvent lorsqu'on projette des données sur une représentation particulière. On commence par initialiser différentes variables. Ensuite on fait appel à la représentation (layout) pour qu'elle calcule les meilleurs choix (positionnement des mots afin d'éviter le chevauchement) puis on construit le SVG avec les informations fournies.

Initialisation

Nous commençons donc par initialiser un certain nombre de paramètres. Si la largeur et la hauteur sont assez habituelles, il faudra attacher une importance toute particulière à la police de caractères et à sa taille. Le premier peut changer la nature de votre nuage de mot, lui donner une apparence moderne avec une police bien choisie ou au contraire le rendre un peu vieillot. La taille par contre est l'élément essentiel qui assure la bonne construction de la représentation, nous verrons d'ailleurs que la librairie possède un défaut quelque peu gênant dans un contexte de réutilisation.

const width = document.getElementById("container").offsetWidth * 0.95,
	height = 500,
	fontFamily = "Open Sans",
	fontScale = d3.scaleLinear().range([20, 120]), // Construction d'une échelle linéaire continue qui va d'une font de 20px à 120px
	fillScale = d3.scaleOrdinal(d3.schemeCategory10); // Construction d'une échelle discrète composée de 10 couleurs différentes

Calcul de la représentation

C'est uniquement ici que nous faisons appel à la librarie d3-cloud. Nous lui fournissons notre tableau de mots (qui doit contenir pour chaque entrée une variable text et une variable size) ainsi que les différents paramètres que nous avons initialisés. Ce ne sera peut-être pas le cas pour vous mais dans notre contexte les données proviennent d'un fichier CSV que nous devons parser.

d3.csv("/tutorials/d3js/word-cloud/wordsCount2016.csv").then(function(csv) {
	var words = [];
	csv.forEach(function(e,i) {
		words.push({"text": e.LABEL, "size": +e.COUNT});
	});
	words.length = 100; // Nous ne voulons que les 100 premiers mots

	// Calcul du domain d'entrée de notre fontScale
	// L'objectif est que la plus petite occurence d'un mot soit associée à une font de 20px
	// La plus grande occurence d'un mot est associée à une font de 120px
	let minSize = d3.min(words, d => d.size);
	let maxSize = d3.max(words, d => d.size);

	// Nous projettons le domaine [plus_petite_occurence, plus_grande_occurence] vers le range [20, 120]
	// Ainsi les mots les moins fréquents seront plus petits et les plus fréquents plus grands
	fontScale.domain([minSize, maxSize]);

	d3.layout.cloud()
		.size([width, height])
		.words(words)
		.padding(1)
		.rotate(function() {
			return ~~(Math.random() * 2) * 45;
		})
		.spiral("rectangular")
		.font(fontFamily)
		.fontSize(d => fontScale(d.size))
		.on("end", draw)
		.start();
	
	// La méthode draw sera définie ici
});

Nous étudierons les différents paramètres un peu plus bas. Notez juste que le code ne fait qu'initialiser des paramètres et c'est l'appel de la méthode start qui déclenche le traitement. Avant de définir la méthode draw qui est appelée à la fin du traitement regardons le travail réalisé par la librairie. Grâce à la console de Chrome par exemple nous pouvons observer le contenu d'un élément du tableau avant et après le traitement.

Contenu du tableau Explication
Initialisation réalisée par nos soins du texte et de son nombre d'occurrences
Après traitement la librairie a enrichie notre tableau de nombreuses variables.

On retrouve d'abord notre font, le padding spécifié à la construction du layout et une size de 120px ce qui correspond bien à la valeur la plus grande de notre fontScale (Facebook étant le mot avec le plus d'occurrences).

Parmi les autres variables ajoutées considérons x et y qui correspondent à la position du mot dans le SVG et rotate qui défini une rotation de ce texte. Les valeurs de x et y sont négatives car la position est calculée depuis le centre du SVG (nous devrons donc centrer le contenu de notre SVG). Les autres variables ne sont pas utiles dans notre contexte mais servent en interne à la librairie pour réaliser le positionnement de tous les mots.

Construction du SVG

Tout le travail est déjà fait, il nous suffit de prendre notre tableau pour ajouter autant d'éléments text qu'il contient en utilisant les paramètres de chaque entrée. Le code doit être défini dans une méthode draw et dans notre cas positionner à l'intérieur du traitement du CSV.

function draw() {
	d3.select("#word-cloud").append("svg") // Ajout d'un élément SVG sur un DIV existant de la page
		.attr("class", "svg")
		.attr("width", width)
		.attr("height", height)
		.append("g") // Ajout du groupe qui contiendra tout les mots
			.attr("transform", "translate(" + width / 2 + ", " + height / 2 + ")") // Centrage du groupe
			.selectAll("text")
			.data(words)
			.enter().append("text") // Ajout de chaque mot avec ses propriétés
				.style("font-size", d => d.size + "px")
				.style("font-family", fontFamily)
				.style("fill", d => fillScale(d.size))
				.attr("text-anchor", "middle")
				.attr("transform", d => "translate(" + [d.x, d.y] + ")rotate(" + d.rotate + ")")
				.text(d => d.text);
}

Le résultat obtenu par ce code est visible dans l'image ci-dessous. Notez que chaque exécution du layout peut engendrer un positionnement différent des mots.

Nuage de mots standard

Paramétrage

Dans cette partie nous allons passer en revue les différents paramètres de la librairie pour obtenir le nuage de mots que vous souhaitez.

padding

En CSS, le padding correspond aux marges internes d'un élément HTML, il est exprimé en pixels. Dans le contexte d'un nuage de mots chaque mot est représenté par un rectangle. On fournit à la librarie la font-size ainsi que la font-family car elle doit calculer la taille de chaque rectangle. Afin d'espacer les mots entre eux on ajoute donc du padding. Il est logique que la librarie prenne connaissance de ce paramètre puisqu'il changera la façon d'arranger les mots comme il agrandit la taille des rectangles. On peut observer dans les images ci-dessous le résultat de différentes valeurs de ce paramètre.

padding = 0 padding = 2 padding = 5
Nuage de mots avec padding de 0px Nuage de mots avec padding de 2px Nuage de mots avec padding de 5px

Le padding s'exerce des quatre côtés du mot, pour cette raison de petites valeurs éloignent assez rapidement les mots.

rotate

Ce paramètre n'est pas défini par une valeur statique mais par une fonction qui retourne un angle. Dans l'exemple de départ cette fonction retournait ~~(Math.random() * 2) * 45. Le code mérite d'être analysé. La fonction Math.random renvoie un nombre flottant pseudo-aléatoire compris dans l'intervalle [0, 1[ (ce qui signifie que 0 est compris dans l'intervalle mais que 1 en est exclu) . En le multipliant par 2 on obtient donc un nombre aléatoire dans l'intervalle [0, 2[. L'opérateur ~~ est un raccourci de la fonction Math.floor qui permet d'obtenir un arrondi. Les deux valeurs possibles sont donc 0 ou 1. En la multipliant par 45 on obtient donc un angle de 0° ou de 45°. Voici différentes alternatives pour cette fonction.

Tous les mots sur une ligne
return 0;
Avec un angle de 0°, 45° ou 90°
return ~~(Math.random() * 3) * 45;
Avec un angle compris entre 0° et 90°
return ~~(Math.random() * 91);
Nuage de mots avec un angle de 0° Nuage de mots un angle de 0°, 45° ou 90° Nuage de mots avec un angle compris entre 0° et 90°

spiral

Ce paramètre est également défini par une fonction. Elle représente le type de spirale qui sera utilisée pour positionner les mots. Deux fonctions sont prédéfinis : "rectangular" et "archimedean". Il est possible de définir manuellement cette fonction sous la forme d'une formule mathématique mais c'est très vite complexe et pas forcément pertinent.

"rectangular" "archimedean"
Nuage de mots sous forme de spirale rectangulaire
Nuage de mots sous forme de spirale archimédienne

Caché sous le tapis

Lorsqu'on lit des tutoriaux sur Internet, les détails problématiques sont souvent masqués pour ne présenter que la partie positive. Dans le cas d'un nuage de mots et de l'utilisation de cette librairie il y a pourtant deux points très importants à considérer.

La liste des mots

Sauf si on vous donne une liste de mots avec leur nombre d'occurrence directement vous allez devoir la constituer vous-même. Nous concernant nous avons récupéré tous les titres des articles du site Rue89 mais avant d'obtenir notre liste de mots nous avons dû réaliser un certains nombres d'opérations. La première consiste à supprimer tout ce qui n'est pas un mot, les virgules, les guillemets... Pour cela nous avons utilisé la fonction replaceAll en Java et le code suivant : wordsText.replaceAll("[^a-zA-Z]", " "); qui remplace tout ce qui n'est pas constitué des caractères de l'alphabet par un espace. Ensuite vous vous rendrez assez vite compte qu'il y a des mots qui ne sont pas intéressants comme "le" ou "tous". A ce niveau se pose la question d'éliminer tous les mots qui ne font pas sens dans le contexte qui est le vôtre. Pour vous donner une idée voici la liste de tous les mots que nous avons supprimés.

public static String[] STOP_LIST = {"au", "aux", "avec", "bien", "ce", "celui", "ces", "ci", "cm", "comme", "dans", "de", "des", "du", "elle", "en", "et", "eux", "il", "je", 
		"la", "le", "leur", "lui", "ma", "mais", "me", "meme", "mes", "moi", "mm", "mon", "ne", "nos", "notre", "nous", "on", "ou", "plus", "par", "pas",
		"pour", "qu", "que", "qui", "sa", "se", "ses", "son", "sur", "ta", "te", "tes", "toi", "ton", "tout", "tu", "un", "une", "vos", "votre", "voir",
		"vous", "ceci", "cela", "cet", "cette", "ici", "ils", "les", "leurs", "quel", "quels", "quelle", "quelles", "sans", "soi", "est", "tres", "lot", "ref",
		"fait", "ans", "ai", "rue", "si", "pourquoi", "sont", "faire", "etre", "ont", "comment", "ca", "apres", "mois", "veut", "euros", "suis", "deux", "peut", 
		"va", "quand", "entre", "chez", "tous", "quoi", "ou", "ete", "non", "aussi", "faut", "trop", "sous", "etait", "contre", "dit", "autres", "rien", "trois",
		"encore", "avoir", "toujours", "moins", "vraiment", "tres", "avant", "font", "jour", "histoire", "bon", "dix", "cinq", "grand", "passe", "face", "petit",
		"retour", "peu", "jamais", "questions", "mieux", "tour", "the", "temps", "avez", "autre", "selon", "dire", "bonne", "heures", "enfin", "fautil", "tete",
		"ni", "quatre", "meme", "voici", "france", "met", "fin", "mis", "vu", "deja", "veulent", "eu", "an", "existe", "ex", "super", "jeu", "prix", "donne",
		"tres", "fin", "mere", "nuit", "petite", "doit", "avait", "aime", "envie", "devenu", "village", "livre", "allez", "payer", "re", "ur", "soir", "sort",
		"porte", "pied", "lire", "nom", "sait", "etes", "puis", "fois", "sera", "petits", "sort", "toute", "autour", "appris", "demande", "mettre", "aller",
		"six", "amis", "reste", "fois", "merci", "depuis", "derriere", "parle", "vue", "jour", "gens", "idees", "ville", "jours", "of", "hui", "prend", "marche",
		"devient", "tre", "decouvrez", "jeux", "devenir", "annee", "joue"};

En regardant le nuage généré au début de ce tutoriel on peut vite se rendre compte que certains mots sont passés dans les mailles du filet comme "dix", "mieux" ou "autres". Il n'est pas évident de déterminer cette liste de mots et si votre ensemble de mots est dynamique ça vous prendra du temps pour arriver à la produire.

Un gros défaut dans la librairie

Il y a un paramètre que nous n'avons pas trop détaillé qui est la font-size choisie. Dans la section paramétrage nous utilisons une font-size entre 15px et 60px. Comment avons-nous déterminé ces valeurs ? La réponse est simple : en tâtonnant. Nous avons choisi la limite inférieure à 15px pour que les mots ne soient pas trop petits mais pour la limite supérieure il nous a fallu tester plusieurs fois. Pour vous donner une idée du problème voici le nuage de mots dans un SVG qui fait 300px en largeur et 300px en hauteur.

Nuage de mots avec mots manquants

Le mot qui possède le plus d'occurrences (FACEBOOK) est absent ainsi que de nombreux autres mots. Si la librairie ne peut pas faire rentrer votre mot elle va simplement l'ignorer. Le pire est qu'à chaque exécution le positionnement de vos mots sera différent (vous pouvez le vérifier en rafraichissant plusieurs fois cette page et en regardant le nuage de mot tout en haut). Il est donc possible qu'à un moment tous les mots soient présents mais qu'il en manque un dans l'exécution suivante ! Sur Github un ticket a été ouvert pour discuter de ce problème et plusieurs solutions sont proposées. Aucune n'est vraiment complète. Pourtant vous pouvez faire le test avec notre nuage de mot en haut de cette page il possèdera toujours les 100 mots de notre tableau d'entré. C'est simplement parce que nous avons mis en place un code qui relance le layout autant de fois que nécessaire et qu'à chaque fois on diminue la font-size supérieure jusqu'à ce que tous les mots soient présents. C'est aussi ce qui explique que le nuage de mots met un certain temps à apparaitre. Voici le code qui permet de réaliser ce traitement.

d3.csv("word-cloud/wordsCount2016.csv").then(function(csv) {
		var words = [];
		csv.forEach(function(e,i) {
			words.push({"text": e.LABEL, "size": +e.COUNT});
		});
		words.length = 100;

		// Nous essayons de déterminer la font-size maximale que nous pouvons utiliser.
		// Pour cela nous plaçons dans la page le mot avec le plus d'occurence et récupérons la taille qu'il occupe en pixels avec une font de "500px"
		// Voir le code source et le DIV avec un id = test-width associé à un CSS portant le même nom
		// Grâce à une règle de trois et l'utilisation de la largeur de notre SVG nous obtenons une maxFontSize qui garantie que le mot passera dans le SVG.
		var testDiv = document.getElementById("test-width");
		testDiv.innerHTML = words[0].text;
		var testWidth = testDiv.clientWidth;
		var maxFontSize = width * 500 / testWidth;

		let minSize = d3.min(words, d => d.size);
		let maxSize = d3.max(words, d => d.size);
		
		computeAndDraw(words, maxFontSize);
		function computeAndDraw(tmp_words, max_font_size) { // Nous allons apeller cette fonction tant que tous les mots ne sont pas présents en sortie
			let fontScale = d3.scaleLinear()
				.domain([minSize, maxSize])
				.range([10, max_font_size]);

			d3.layout.cloud()
				.size([width, height])
				.words(tmp_words)
				.padding(1)
				.rotate(function() {
					return ~~(Math.random() * 2) * 45;
				})
				.spiral("rectangular")
				.font(fontFamily)
				.fontSize(function(d) { return fontScale(d.size); })
				.on("end", function(output) {
					// Le code intéressant se situe ici. Nous vérifions si l'output possède bien tous les mots.
					// Si c'est le cas nous apellons la fonction draw sinon nous rapellons computeAndDraw en diminuant max_font_size de 5px
					// A noter qu'il est nécessaire de reconstruire le tableau d'entré sinon ça ne fonctionne pas
					if (output.length !== words.length) {
						var tmp_words = [];
						csv.forEach(function(e,i) {
							tmp_words.push({"text": e.LABEL, "size": +e.COUNT});
						});
						tmp_words.length = 100;
						computeAndDraw(tmp_words, max_font_size - 5);
					} else {
						draw(output);
					}
				})
				.start();
			
			function draw(output) {
				d3.select("#word-cloud").append("svg")
					.attr("class", "svg")
					.attr("width", width)
					.attr("height", height)
					.append("g")
						.attr("transform", "translate(" + width / 2 + ", " + height / 2 + ")")
						.selectAll("text")
						.data(output)
						.enter().append("text")
							.style("font-size", d => d.size + "px")
							.style("font-family", fontFamily)
							.style("fill", d => fillScale(d.size))
							.attr("text-anchor", "middle")
							.attr("transform", d => "translate(" + [d.x, d.y] + ")rotate(" + d.rotate + ")")
							.text(d => d.text);
			}
		}
	});

Ce code est particulièrement utile si votre tableau d'entré varie et que vous voulez utiliser le même code pour l'afficher. Impossible pour vous de connaître le nombre maximal d'occurrence et donc de procéder à tâton. Néanmoins si vous voulez mettre en place un nuage de mots fixe nous vous conseillons plutôt de procéder par ajustements successifs et de trouver une valeur suffisamment basse pour la font-size maximale afin que tous les mots soient toujours présents après plusieurs itérations. Cela garantira que vous nuage s'affiche rapidement.

Conclusion

La mise en oeuvre d'un nuage de mots est facile et ne présente aucune difficulté en soi. Néanmoins nous avons vu qu'il faut préparer correctement ses données et bien connaître les limites de la librairie de Jason Davis. Comme toujours la section commentaire vous permet d'aborder des sujets que nous aurions pu oublier.

COMMENTAIRES