D3JS - Heatmap (carte de chaleur)

Construire une représentation dynamique de l'activité moyenne horaire sur une semaine

Dernière mise à jour le 01/03/2019
d3js5.x

Introduction

Les cartes de chaleur peuvent prendre des formes variées. Ici la chaleur se traduit par le nombre d'articles publiés pour un jour et à une heure précise. Contrairement aux tutoriaux précédent nous n'utilisons pas une représentation provenant de D3JS pour nos données (comme l'aurait été une carte choroplèthe ou un nuage de mots). En fait la représentation est simplement constituée de carrés que nous construisons à partir de nos données. Ici D3JS nous aide surtout par tout ce qui gravite autour de la représentation en elle-même, à savoir la sélection, les échelles ou la manipulation de données. Dans ce tutoriel nous verrons également comment aborder l'aspect responsive d'une visualisation car la taille des rectangles s'adaptent à la largeur disponible.

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é.

Le tutoriel est un peu long car la visualisation est assez soignée et comme nous l'avons dit elle est partiellement responsive. Si vous ne la connaissez pas, vous pouvez jeter un oeil au compte Twitter de Nadieh Bremer qui présente des visualisations à couper le souffle !

Initialisation

Nous commençons par définir les jours et les heures. La fonction d3.range retourne un tableau contenant une progression arithmétique. Par défaut la progression est de 1 et notre appel permet de construire un tableau de 0 à 23. Avec le code d3.range(5, 10, 0.5) nous aurions obtenu le tableau : [5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9, 9.5], de 5 inclue à 10 exclue par palier de 0.5. Les marges sont définies de manière empirique pour laisser la place au titre, sous-titre et éléments de légende.

Ici la largeur et la hauteur correspondent uniquement aux dimensions de notre grille. Nous voulons une largeur qui soit au maximum de 1000px et au minimum de 500px. Pourquoi 500px au minimum ? Parce que en dessous de cette taille les libellés des jours et des heures se chevaucheraient et il faudrait diminuer leur font-size. Une fois cette largeur définie nous pouvons déterminer la taille de chaque élément de la grille ce qui nous permet ensuite de calculer la hauteur de cette grille. Pour construire une visualisation responsive il faut rendre les paramètres de taille dépendant de l'espace dont nous disposons à l'écran, c'est bien ce que nous faisons ici.

let  days = ["Lu", "Ma", "Me", "Je", "Ve", "Sa", "Di"],
	times = d3.range(24),
	margin = {
		top: 90,
		right: 50,
		bottom: 140,
		left: 50
	};
	
let width = Math.max(Math.min(window.innerWidth, 1000), 500) - margin.left - margin.right,
	gridSize = Math.floor(width / times.length),
	height = gridSize * days.length;

Construction du SVG

Rien de bien sorcier ici, nous sélectionnons un DIV existant dans la page (heatmap) et lui ajoutons un SVG avec les paramètres définis ci-dessus. Un groupe est ajouté à ce SVG, il contiendra tous les éléments de la visualisation.

var maingroup = d3.select('#heatmap')
	.append("svg")
	.attr("class", "svg")
	.attr("width", width + margin.left + margin.right)
	.attr("height", height + margin.top + margin.bottom)
	.append("g")
		.attr("transform", "translate(" + margin.left + "," + margin.top + ")");

Intégration des deux axes

Ajouter les jours et les heures a l'air assez simple, pourtant si l'on veut bien faire il faut être précis. La construction suit la même logique pour les deux axes. Nous précisons le jeu de données associé à chaque construction (days et times) pour ajouter autant d'éléments text que chaque jeu contient d'entrées. Pour les jours c'est y qui varie alors que pour les heures c'est x. Lorsqu'ils varient, ils le font en fonction de la gridSize et de la position dans chaque tableau. Ainsi lorsque la largeur disponible est plus petite les libellés seront plus rapprochés. Le texte est positionné avec une marge de 6px par rapport à la grille et centré sur chaque cellule, le tout grâce à la fonction transform et au style text-anchor. La classe CSS associée n'est pas la même pour différencier les périodes de travail des temps de repos.

var dayLabels = maingroup.selectAll(".dayLabel")
	.data(days)
	.enter().append("text")
		.text(function (d) { return d; })
		.attr("x", 0)
		.attr("y", function (d, i) { return i * gridSize; })
		.attr("transform", "translate(-6," + gridSize / 2 + ")")
		.attr("class", function (d, i) { return ((i >= 0 && i <= 4) ? "dayLabel axis-workweek" : "dayLabel"); })
		.style("text-anchor", "end");

var timeLabels = maingroup.selectAll(".timeLabel")
	.data(times)
	.enter().append("text")
		.text(function(d) { return d; })
		.attr("x", function(d, i) { return i * gridSize; })
		.attr("y", 0)
		.attr("transform", "translate(" + gridSize / 2 + ", -6)")
		.attr("class", function(d, i) { return ((i >= 9 && i <= 19) ? "timeLabel axis-worktime" : "timeLabel"); })
		.style("text-anchor", "middle");

Ajout du titre et des crédits

Le code est habituel pour ces éléments, ils sont centrés et associés à un CSS particulier.

maingroup.append("text")
	.attr("class", "title")
	.attr("x", width / 2)
	.attr("y", -70)
	.style("text-anchor", "middle")
	.text("Nombre d'Articles publiés sur Rue89");

maingroup.append("text")
	.attr("class", "credit")
	.attr("x", width/2)
	.attr("y", gridSize * (days.length+1) + 80)
	.style("text-anchor", "middle")
	.text("Basé sur le travail de Nadieh Bremer & Miles McCrocklin");

Traitement des données

Tout le reste du code que nous allons étudier est contenu dans la fonction de traitements de nos données. Celles-ci se matérialisent par un fichier TSV dont les premières lignes sont :

day	hour	count
2	22	3
2	21	11
2	20	33
2	23	11
4	20	47
4	23	3
4	22	7
4	21	13

Dans ce fichier on retrouve les trois colonnes utiles à notre visualisation : le jour de 0 à 6, l'heure de 0 à 23 et enfin une valeur représentant le nombre d'articles publiés en moyenne à cette heure ce jour-là. Il n'est pas nécessaire que ces données soient triées. Nous commençons simplement en chargeant ce tableau dans une variable data et en s'assurant que son contenu soit toujours reconnu comme des valeurs numériques. La deuxième partie ajoute un sous-titre à la visualisation en tenant compte de la somme des valeurs contenues dans la colonne count.

d3.tsv("d3js/day-hour-heatmap/data2010.tsv").then(function(data) {
	data.forEach(function(d) {
		d.day = +d.day;
		d.hour = +d.hour;
		d.count = +d.count;
	});

	maingroup.append("text")
		.attr("class", "subtitle")
		.attr("x", width / 2)
		.attr("y", -40)
		.style("text-anchor", "middle")
		.text("En 2010 - " + d3.sum(data, function(d) {return d.count; }) + " articles");

	// Le reste du code va ici
});

L'échelle de couleurs

C'est toujours une étape délicate, il faut tenir compte de la répartition des valeurs (ici le nombre d'articles publiés chaque heure de chaque jour), de leur min et max. Par exemple dans un précédent tutoriel (Les prix Nobel) nous avons utilisé une échelle non linéaire. Ici ce n'est pas le cas et nous décidons de construire une échelle linéaire entre un domaine composé de 0, de la moitié de la valeur max et de la valeur max vers un range composé également de trois couleurs (d'un bleu presque blanc à un bleu foncé).

var colorScale = d3.scaleLinear()
	.domain([0, d3.max(data, function(d) {return d.count; }) / 2, d3.max(data, function(d) {return d.count; })])
	.range(["#f7fbff", "#6baed6", "#08306b"]);

Construction de la heatmap

On y arrive enfin, le code pour construire tous les blocs de couleurs est assez simple, nous avons bien préparé le terrain. Pour chaque entrée de notre tableau data on ajoute un rectangle dont le x dépend de l'heure de la journée et le y du jour de la semaine. Ce rectangle est rempli grâce à notre échelle dépendant du nombre d'articles. C'est l'utilisation du CSS stroke et stroke-opacity qui fournit le quadrillage blanc que l'on peut observer sur le résultat final.

var heatMap = maingroup.selectAll(".hour")
	.data(data)
	.enter().append("rect")
		.attr("x", function(d) { return d.hour * gridSize; })
		.attr("y", function(d) { return d.day * gridSize; })
		.attr("width", gridSize)
		.attr("height", gridSize)
		.style("stroke", "white")
		.style("stroke-opacity", 0.6)
		.style("fill", function(d) { return colorScale(d.count); });

Création d'une légende continue

Contrairement au tutoriel que nous avons cité précédemùent, la légende n'est pas composée de petits rectangles de couleurs entre notre couleur la plus claire et la plus foncée. Au contraire vous pouvez constater que cette légende est parfaitement continue. Ce n'est pas facile à faire et cela nécessite de passer par du CSS. Nous n'entrons pas ici dans les détails mais voici en résumé ce que le code suivant fait :

var countScale = d3.scaleLinear()
	.domain([0, d3.max(data, function(d) {return d.count; })])
	.range([0, width])

numStops = 3;
countPoint = [0, d3.max(data, function(d) {return d.count; }) / 2, d3.max(data, function(d) {return d.count; })];

maingroup.append("defs")
	.append("linearGradient")
	.attr("id", "legend-traffic")
	.attr("x1", "0%").attr("y1", "0%")
	.attr("x2", "100%").attr("y2", "0%")
	.selectAll("stop") 
	.data(d3.range(numStops))                
	.enter().append("stop") 
		.attr("offset", function(d,i) { 
			return countScale(countPoint[i]) / width;
		})   
		.attr("stop-color", function(d,i) { 
			return colorScale(countPoint[i]); 
		});

var legendWidth = Math.min(width * 0.8, 400);
		
var legendsvg = maingroup.append("g") // groupe principal
	.attr("class", "legendWrapper")
	.attr("transform", "translate(" + (width/2) + "," + (gridSize * days.length + 40) + ")");

legendsvg.append("rect") // rectangle avec gradient
	.attr("class", "legendRect")
	.attr("x", -legendWidth/2)
	.attr("y", 0)
	.attr("width", legendWidth)
	.attr("height", 10)
	.style("fill", "url(#legend-traffic)");
		
legendsvg.append("text") // légende
	.attr("class", "legendTitle")
	.attr("x", 0)
	.attr("y", -10)
	.style("text-anchor", "middle")
	.text("Nombre d'Articles");

var xScale = d3.scaleLinear() // scale pour x-axis
	 .range([-legendWidth / 2, legendWidth / 2])
	 .domain([ 0, d3.max(data, function(d) { return d.count; })] );

legendsvg.append("g") // x axis
	.attr("class", "axis")
	.attr("transform", "translate(0," + (10) + ")")
	.call(d3.axisBottom(xScale).ticks(5));

Conclusion

Comme indiqué en début de tutoriel celui-ci est un peu long, mais il reste accessible et permet d'obtenir une visualisation propre et plutôt responsive. N'hésitez pas à nous poser des questions, sur la partie gradient ou sur tout autre aspect si ça n'est pas assez clair. Pour vous aider dans le choix de vos couleurs vous pouvez toujours jeter un oeil au site Color Brewer qui est tout indiqué pour ce genre de visualisation. Enfin voici un autre exemple de heatmap cette fois-ci sur une année complète, la construction est identique à ce que nous venons de voir, il suffit d'adapter les axes X et Y (regardez le code source pour plus de détails).

comments powered by Disqus