D3JS - Map Hexgrid

Construire une carte choroplèthe dont les polygones sont aussi précis qu'on le souhaite grâce à la librairie hexbin
d3js7.x
Sources :
Merci de patienter on charge les communes françaises...

Introduction

Cette carte représente le nombre de communes en France réparties par hexagone. Ces hexagones sont présents sur la visualisation lorsque leur centre est contenu dans un département français. Ici, ils sont au nombre de . Une commune appartient à un hexagone lorsque son centre est dans l'hexagone (les données proviennent de ce site de La Poste). Le problème que nous essayons de résoudre est le suivant : la France ne possède pas de zone géographique plus précise que les départements pour construire une visualisation interactive dans une page HTML. Il existe bien les communes mais avec 36 000 polygones le navigateur commence à sérieusement ralentir, sans parler du temps de chargement. Ce tutoriel présente une solution : décider soi-même du nombre de polygones que l'on souhaite afficher. On pourra au choix avoir une page très fluide avec un nombre de polygones limité ou une visualisation très précise avec un plus grand nombre. L'ensemble du code présenté est directement repris du site https://larsvers.github.io/learning-d3-mapping-11-8/ auquel nous avons apporté plusieurs optimisations.

hexRadius

Sur le site mentionné il est possible de changer le rayon des hexagones directement et de constater le résultat. Dans ce tutoriel c'est dans le code qu'il faudra changer la valeur. Le tableau ci-dessous fournit le nombre de polygones et le temps de traitement en fonction de la valeur de hexRadius pour une visualisation de 800px * 800px. La ligne en vert correspond aux valeurs de ce tutoriel.

Valeur de hexRadius Nombre de polygones Temps de traitement (en secondes)
2258192.8
3116381.57
466470.96
543250.57
722830.34
119850.23
155540.19

Le temps de traitement est évidemment lié aux performances de l'ordinateur et il faut aussi considérer que notre fichier departments.json a été optimisé (voir le tutoriel Map - Optimisation pour plus de détails). Voici également les différents rendus en fonction de la valeur de hexRadius.

3 5 7 11 15
Hexgrid avec un rayon de 3 Hexgrid avec un rayon de 5 Hexgrid avec un rayon de 7 Hexgrid avec un rayon de 11 Hexgrid avec un rayon de 15

Initialisation et chargement des données

Il est peut être temps d'attaquer le code maintenant. Nous commençons par initialiser les dimensions et le fameux hexRadius. Nous déclarons également les variables qui sont partagées entre différentes fonctions puis créons le SVG. Enfin on charge notre fichier geoJSON des départements ainsi qu'un fichier de données contenant la latitude et longitude de chaque commune française.

const width = 800,
	height = 800,
	hexRadius = 5;
var projection, 
	svg,
	hexbin,
	colorScale,
	maxDatapoints;

svg = d3.select('#map').append("svg")
	.attr("id", "svg")
	.attr("width", width + hexRadius * 2)
	.attr("height", height + hexRadius * 2);

var promises = [];
promises.push(d3.json('/tutorials/d3js/map-hexgrid/departments.json'));
promises.push(d3.csv('/tutorials/d3js/map-hexgrid/communes.csv'));
Promise.all(promises).then(function(values) {
	const geojson = values[0];
	const csv = values[1];
	
	ready(geojson, csv);
});

Lorsque les deux fichiers sont chargés, la fonction ready est appelée.

Fonction principale

Cette fonction réalise une à une les différentes opérations nécessaires à l'élaboration de notre visualisation.

function ready(geojson, csv) {
	// Initialisation de la projection
	drawGeo(geojson);

	// On place les futures centres de nos hexgrid sur le SVG en fonction de sa taille
	var points = getPointGrid(hexRadius);

	// On récupère les polygones de tous les départements au format X,Y
	var frPolygons = getPolygons(geojson);

	// On ne conserve que les centres de nos hexgrid qui sont dans l'un des polygones
	var frPoints = keepPointsInPolygon(points, frPolygons);

	// Construction d'un tableau avec les coordonnées de chaque ville
	var dataPoints = getDatapoints(csv);

	// On concatène tous les centres des hexgrid avec les coordonnées de chaque ville.
	var mergedPoints = frPoints.concat(dataPoints);

	// Regroupement de nos points par hexagones en utilisant la libraire hexbin
	var hexPoints = getHexPoints(mergedPoints);

	// Ajout d'informations résumées à chaque hexagone 
	var hexPointsRolledup = rollupHexPoints(hexPoints);

	// Ajout des hexagones au SVG
	drawHexmap(hexPointsRolledup);

	// Ajout d'une légende
	drawLegend();

	// Ajout des interractions avec la souris
	mouseInteractivity();
}

Initialisation de la projection

Cette première fonction permet de construire la projection, elle sera utile par la suite pour savoir quels sont les centres des hexagones qui sont contenus dans les départements français. Si vous souhaitez dessiner les contours des départements il faudra juste décommenter le code qui suit et ajouter un peu de CSS. C'est pour cette raison que cette fonction est présentée en premier, ainsi si vous ajoutez les contours des départements ils seront bien en dessous des hexagones.

function drawGeo(geojson) {
	projection = d3.geoConicConformal().fitSize([width, height], geojson);
		
	//var geoPath = d3.geoPath()
	//	.projection(projection);

	//const deps = svg.append("g");

	//deps.selectAll("path")
    //	.data(geojson.features)
    //	.enter()
    //		.append("path")
    //		.attr("d", path);
}

Initialisation des hexagones

Pour construire notre hexgrid, on commence simplement par positionner des coordonnées X, Y sur toute la surface du SVG. Ces coordonnées sont espacées en fonction du rayon hexRadius. Au passage, si l'un de vous sait à quoi sert le 1.5, je suis preneur. En tout cas en le supprimant nous n'avons pas observé de différence.

function getPointGrid(radius) {
	var hexDistance = radius * 1.5;
	var cols = width / hexDistance;

	var rows = Math.floor(height / hexDistance);

	return d3.range(rows * cols).map(function(i) {
		return {
			x: i % cols * hexDistance,
			y: Math.floor(i / cols) * hexDistance,
			datapoint: 0
		};
	});
}

Et voici ce que ça donne si on visualise toutes ces coordonnées directement au-dessus de la France.

Première étape de la construction de notre hexgrid

Récupération des polygones des départements

La deuxième étape vise à récupérer la liste des polygones des départements sous la forme de coordonnées X,Y. Contrairement au code d'origine, il nous a fallu différencier les départements constitués de plusieurs polygones (le Finistère avec ses îles par exemple) de ceux avec un seul polygone.

function getPolygons(geojson) {
	var polygons = [];
	geojson.features.forEach(function (f) {
		if (f.geometry.type == "Polygon") {
			var featurePolygon = [];
			f.geometry.coordinates[0].forEach(function (c) {
				featurePolygon.push(projection(c));
			});
			polygons.push(featurePolygon);
		} else { // type = MultiPolygon
			f.geometry.coordinates.forEach(function (p) {
				var featurePolygon = [];
				p[0].forEach(function (c) {
					featurePolygon.push(projection(c));
				});
				polygons.push(featurePolygon);
			});
		}
	});
	return polygons;
}

Définition des hexagones à conserver

Cette fonction se sert de la méthode d3.polygonContains pour savoir si les points que nous avons positionnés sont dans un des polygones des départements français. Elle représente plus de 50% du temps de traitement et nous l'avons modifiée pour l'optimiser un peu et gagner environ 20% de temps par rapport au code initial. Ce code faisait également un forEach sur les polygon alors qu'un for permet de s'arrêter dès qu'on a trouvé un polygone contenant le point courant. On peut même aller plus loin dans l'optimisation en sauvegardant le polygone qui contient le point courant et en testant d'abord ce polygone pour le point suivant (15% de temps de traitement en moins). Pour information Edge est 4 fois plus lent que Chrome sur cette fonction.

function keepPointsInPolygon(points, frPolygons) {
	var pointsInPolygon = [];
		
	points.forEach(function(point) {
		var inPolygon = false;
		for (var i = 0; !inPolygon && i < frPolygons.length; i++) {
			if (d3.polygonContains(frPolygons[i], [point.x, point.y])) {
				inPolygon = true;
			}
		}
				
		if (inPolygon) {
			pointsInPolygon.push(point);
		}
	});
	return pointsInPolygon;
}
Troisième étape de la construction de notre hexgrid

Conversion des données d'entrée

On réalise ici la transformation de nos données qui possèdent une latitude et une longitude en coordonnées X,Y toujours grâce à notre projection. On en profite pour ajouter un libellé et définir un datapoint. Dans notre cas il est toujours de 1 car nous comptons simplement les communes. Suivant le contexte cette valeur peut bien sur dépendre du jeu de données. Par ailleurs les communes appartenant aux départements des DOM-TOM sont écartées.

function getDatapoints(csv) {
	var dataPoints = [];

	csv.forEach(function (e) {
		if (e.Code_postal.localeCompare("96000") < 0) {
			var coords = projection([+e.lng, +e.lat]);
			dataPoints.push({			
				x: coords[0],
				y: coords[1],
				datapoint: 1,
				name: e.Code_postal + " - " + e.Nom_commune
			});
		}
	});
	return dataPoints;
}

Si on ajoute toutes ces coordonnées sur la carte voici le résultat. Toutes les zones à plus forte densité verte donneront bien sûr des hexagones avec une couleur plus foncée.

Quatrième étape de la construction de notre hexgrid

Avant d'aller sur la prochaine étape nous réunissons dans un même tableau les points rouges et les points verts.

var mergedPoints = frPoints.concat(dataPoints);

Création des hexagones

Cette étape permet de regrouper l'ensemble de nos points en créant des hexagones dont la taille dépend de hexRadius. Nous utilisons la librairie d3-hexbin qu'il faudra inclure dans votre page également.

function getHexPoints(points) {
	hexbin = d3.hexbin()
		.radius(hexRadius)
		.x(function(d) { return d.x; })
		.y(function(d) { return d.y; });

	var hexPoints = hexbin(points);
	return hexPoints;
}

Voici précisément ce que fait la librairie. Elle va prendre chaque point de notre tableau points qui contient les informations suivantes.

Contenu du tableau points

En sortie la librairie produit un nouveau tableau qui regroupe ces points par hexagone. La capture ci-dessous montre la première entrée du tableau qui contient un point avec un datapoint de 0, cette entrée correspond au point de la grille que nous avons construite avec la fonction getPointGrid. La librairie a ensuite "attrapé" deux communes dans ce premier hexagone. On retrouve ces données en passant la souris sur le deuxième hexagone tout en haut de la carte.

Résultat d'appel à la librairie hexbin

Nettoyage et enrichissement des hexagones

Cette dernière fonction avant la création de la visualisation réalise diverses petites opérations. Elle commence par supprimer les points dont le datapoint vaut 0. Il n'est plus nécessaire de les conserver maintenant que nos hexagones sont construits. Ensuite elle va déterminer la valeur max des hexagones, c'est-à-dire celle dont la somme des datapoint contenus est la plus grande. On en profite également pour créer un objet cities qui concatène les informations de l'hexagone. Enfin on construit notre échelle de couleurs continue. Encore une fois nous avons modifié le code d'origine qui considérait le cas ou datapoint vaut 1 et dans ce cas-là ajoutait sa valeur mais le cas contraire n'était pas implémenté.

function rollupHexPoints(data) {
	maxDatapoints = 0;

	data.forEach(function(el) {
		for (var i = el.length - 1; i >= 0; --i) {
			if (el[i].datapoint === 0) {
				el.splice(i, 1);
			}
		}

		var datapoints = 0,
			cities = [];

		el.forEach(function(elt, i) {
			datapoints += elt.datapoint;
			cities.push({"name" : elt.name});
		});

		el.datapoints = datapoints;
		el.cities = cities;

		maxDatapoints = Math.max(maxDatapoints, datapoints);
	});

	colorScale = d3.scaleSequential(d3.interpolateViridis)
		.domain([maxDatapoints, 1]);

	return data;
}

En sortie, la première entrée de notre tableau a bien été enrichie et nettoyé.

Le tableau d'hexagones nettoyé et enrichi

Ajout des hexagones sur le SVG

Nous avons maintenant tous les éléments pour ajouter nos hexagones à la visualisation. On charge la librairie hexbin de nous fournir le polygone représentant chaqu'un d'entre eux. Le datapoint associé à notre échelle de couleur permet de le remplir avec la bonne couleur.

function drawHexmap(points) {
	var hexes = svg.append('g')
		.selectAll('.hex')
		.data(points)
		.enter().append('path')
			.attr('class', 'hex')
			.attr('transform', function(d) { return 'translate(' + (hexRadius + d.x) + ', ' + d.y + ')'; })
			.attr('d', hexbin.hexagon())
			.style('fill', function(d) { return d.datapoints === 0 ? 'none' : colorScale(d.datapoints); })
			.style('stroke', '#ccc')
			.style('stroke-width', 1);
}

Ajout de l'échelle

La seule particularité de cette échelle par rapport aux tutoriels précédents est l'utilisation de d3.range pour produire un tableau de 10 entrées également espacées entre 1 et maxDatapoints.

function drawLegend() {
	const cellSize = 15;
	const cellNumber = 10;

	var legend = svg.append('g')
	    .attr("transform", "translate(" + (width - 20) + ", " + (height / 3) + ")");
		
	legend.selectAll()
	    .data(d3.range(1, maxDatapoints, maxDatapoints / cellNumber))
	    .enter().append('svg:rect')
       		.attr('height', cellSize + 'px')
       		.attr('width', cellSize + 'px')
       		.attr('x', 5)
       		.attr('y', function(d, i) { return i * cellSize + 'px'; })
       		.style("fill", function(d) { return colorScale(d); });

	var legendScale = d3.scaleLinear()
		.domain([0, maxDatapoints])
	    .range([0, cellNumber * cellSize]);
        
	legend.append("g")
   		.attr("class", "axis")
   		.call(d3.axisLeft(legendScale));
}

Gestion de la souris

Cette fonction est moins complète que celle du code d'origine, elle se contente d'afficher un tooltip en fonction de la position de la souris. Elle commence par ajouter la gestion d'évènement sur tous les hexagones. Quand on passe la souris sur l'un d'eux et qu'il contient des informations on positionne correctement le tooltip et on définit son contenu. Quand la souris se déplace le tooltip suit le mouvement et enfin si on quitte un hexagone on fait disparaître le tooltip. Comme toujours il faut avoir un DIV déjà présent sur la page et ajouter du CSS.

function mouseInteractivity() {
	d3.selectAll('.hex').on('mouseover', mouseover);
	d3.selectAll('.hex').on('mousemove', mousemove);
	d3.selectAll('.hex').on('mouseout', mouseout);
		
	function mouseover(event) {
		var cities = d3.select(this).data()[0].cities;

		if (cities.length) { // if this hexagon has cities to show
			d3.select('#tooltip')
				.style('top', event.layerY + 'px')
				.style('left', (event.layerX + 10) + 'px')
				.style('opacity', 0.9);

			d3.select('#tip-header h1').html(function() {
				return cities.length > 1 
					? cities.length + ' villes dans cette zone'
					: cities.length + ' ville dans cette zone';
			});

			var html = "";
			cities.forEach(function (city) {
				html += city.name + "</br>";
			});
			d3.select('#tip-header p').html(html);
		}
	}

	function mousemove(event) {
		d3.select('#tooltip')
			.style('top', event.layerY + 'px')
			.style('left', (event.layerX + 10) + 'px');
	}

	function mouseout(event) {
		d3.select('#tooltip')
			.style('opacity', 0);
	}
}

Conclusion

Ce tutoriel est maintenant terminé, nous espérons avoir été assez complet. La section commentaire est toujours présente pour poser des questions ou faire des demandes de précision. Avec ce code il est possible de faire des cartes choroplèthes aussi précise qu'on le souhaite. Par ailleurs si on veut optimiser le temps de traitement il est possible de calculer le contenu de la variable points et de la sauvegarder dans un fichier. Il faut bien sûr figer le hexRadius et la taille du SVG mais cela permet d'avoir un temps de chargement imbattable. On notera quand même que ce code ne règle pas tous les problèmes. Au départ on voulait dessiner les données de la population française et on disposait uniquement de la population par commune. La question de savoir comment répartir la population d'une commune en fonction des hexagones qui se positionnent sur elle n'est pas résolue, peut-être pour un prochain tutoriel...

COMMENTAIRES