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.
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) |
---|---|---|
2 | 25819 | 2.8 |
3 | 11638 | 1.57 |
4 | 6647 | 0.96 |
5 | 4325 | 0.57 |
7 | 2283 | 0.34 |
11 | 985 | 0.23 |
15 | 554 | 0.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 |
---|---|---|---|---|
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.
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();
}
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);
}
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.
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;
}
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;
}
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.
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);
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.
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.
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é.
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);
}
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));
}
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);
}
}
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...
VOUS POURRIEZ AIMER
COMMENTAIRES