Retour complet sur la création d'une carte par anamorphose, bien utile pour accentuer les valeurs extrêmes de données.
Ça faisait un moment que je voulais faire un long article sur la relation entre abstention et vote FN aux élections présidentielles. Loin de moi l'idée de prouver une relation de cause à effet. On ne le répétera jamais assez : corrélation n'est pas causalité.
Je trouvais simplement assez étrange que l'on fasse des caisses à longueur de temps sur les électeurs FN, et qu'on ne produise à côté de ça que de courtes références sur les abstentionnistes, qui sont pourtant bien plus nombreux dans le scrutin présidentiel.
Une carte me paraissait être une illustration très appropriée pour comparer les deux, et surtout des foyers d'abstentionnistes ou d'électeurs FN étalés dans le temps ou non.
A la recherche du meilleur type de cartes
Quand on se lance dans de la cartographie de données très basique, on est souvent tenté par les cartes choroplèthes, comme celle que vous trouverez ci-dessous :
Le gros avantage de ce type de cartes est qu'il est très facilement compréhensible par le lecteur.
Quel que soit le paramètre considéré (qualité de l'air, production de poireaux ou vote écologiste), on devine aisément que les zones les plus foncées sont celles où le paramètre est le plus élevé.
Le vrai problème pour moi était d'ordre technique : je ne savais pas comment faire pour rassembler des jeux de données différents sur une même carte (par type d'électeurs et par année), bien que j'aie pu tomber sur l'un ou l'autre exemple avec des cases à cocher/décocher.
Mais rien qui m'inspire plus que ça, jusqu'à ce que...
Les cartogrammes passent par là...
J'ai fini par tomber un peu par hasard sur des cartogrammes.
Pour résumer grossièrement, un des algorithmes les plus rencontrés pour créer ce type de cartes consiste à appliquer une force sur des polygones, calculée notamment à partir de l'aire "absolue" du polygone et d'une valeur rattachée.
Avec cet algo, les zones géographiques ne se contentent plus d'être colorées, elles sont concrètement déformées par les valeurs que l'on souhaite comparer. Les zones les plus "hautes" sont très enflées, tandis que les plus "basses" sont comme aspirées.
Première piste
J'ai voulu essayer de créer un cartogramme avec les voix de l'abstention et avec celles du FN sur plusieurs présidentielles, et commencé à chercher des outils qui me permettent de le faire.
La première méthode que j'ai testée consistait à :
- joindre une base de données aux polygones départementaux grâce à QGis, avant d'exporter le résultat en .shp
- me servir ensuite de l'outil open source Scape Toad pour créer le cartogramme à partir des données contenus dans le .shp pour ensuite exporter le résultat en .svg
- animer les différents .svg (un par paramètre couplé à une année de présidentielle) et programmer des transitions entre
Plusieurs difficultés se sont vite manifestées, dont :
- la manœuvre un peu tirée par les cheveux et surtout le temps qu'elle représentait
- surtout, l'impossibilité pour ScapeToad de correctement interpréter les données que je voulais utiliser dans le cartogramme. J'ai eu beau vérifier méticuleusement dans QGis que mes colonnes abstention et/ou voix FN contenaient bien des chiffres, ça n'a pas mieux marché. Aïe...
J'ai gardé l'idée d'un cartogramme avec ces deux types de données dans un coin avant de voir le travail diablement efficace d'Etienne Côme avec la France du Bon Coin.
Dans ses sources, il cite un exemple états-unien qui a la particularité d'utiliser un double champ de sélection (un pour les années, un pour le type de données). Tiens tiens...
La recette
Pour arriver à ce résultat qui permet d'animer une carte anamorphosée par différentes données contenues dans un même .csv (miam miam !), il faut utiliser quatre librairies JS :
- TopoJSON, qui permet d'encoder les polygones pour un résultat plus léger et optimal que le GeoJSON, ce qui n'est pas rien quand on veut s'amuser à les déformer
- D3.js pour l'affichage de la carte, la gestion des transitions, de l'affichage des informations, etc...
- cartogram.js, pour créer ledit cartogramme
- colorbrewer.js, pour colorer les polygones à la manière des cartes choroplèthes
En réalité, on peut gruger la partie TopoJSON en utilisant Mapshaper. Cet outil permet en deux temps trois mouvements de convertir des fichiers .shp ou .geojson en .topojson et, cerise sur le gâteau, d'appliquer une simplification comme dans l'illustration suivante :
Ensuite, il faut que le fichier .csv soit correctement paramétré pour que le passage par les champs de sélection se fasse sans heurt.
Dans cet exemple, toutes les colonnes possèdent les trois premières lettre du paramètre suivies de l'année (par exemple ABS2002). Evidemment, rien n'empêche d'imaginer glisser plus de paramètres ou plus d'années, le principe sera strictement le même.
Attention tout de même !
Les plus observateur auront peut-être remarqué dans le lien précédent des id très différentes de nos codes départementaux. C'est une des joyeusetés à côté desquelles je suis passé à côté au début : bien distinguer les id des polygones d'une colonne "id".
Pour essayer d'être plus clair, observons cette capture d'écran des polygones départementaux piochés sur ce stock de données :
En réalité, les id qui nous intéressent sont dans la colonne tout à gauche (0 pour la Somme, 1 pour l'Eure, 2 pour la Seine-Maritime, etc...) et un id égal à 0 peut poser problème pour la suite.
Différentes techniques existent pour un peu modifier le fichier .topojson. Perso j'ai un peu fait flamber jEdit pour corriger tout ça et modifier mon .csv en conséquence. Bref, ça prend un peu de temps, mais une fois que c'est fait le fichier peut être réutilisé à l'envie.
Et dernier point avant de rentrer dans le code : D3 interprète mal le double point en valeur de séparation pour un .csv, il vaut mieux de fait utiliser la virgule. Un remerciement spécial à Etienne Côme qui a repéré l'erreur 😉 !
Le code
Rentrons dans le vif du sujet avec le code en lui-même. Voici pour commencer les éléments du qui vont permettre d'afficher la carte et de naviguer entre les données :
<form>
<p>
<label>Choisissez le facteur de déformation <select id="field"></select></label>
<label>pendant la présidentielle de <select id="year"></select></label>
<span id="status"></span>
</p>
</form>
<div id="container">
<svg id="map"></svg>
</div>
Voici enfin le script qui paramètre tout ça :
<script>
// cache le formulaire si l'explorateur ne fait pas du SVG
if (!document.createElementNS) {
document.getElementsByTagName("form")[0].style.display = "none";
}
/* déclaration des premières variables, notamment celles des champs de sélection
les différentes "key" doivent correspondre aux colonnes du CSV
(%d s'adaptera à la valeur de #year) */
var percent = (function() {
var fmt = d3.format(".2f");
return function(n) { return fmt(n) + "%"; };
})(),
fields = [
{name: "Aucun", id: "none"},
{name: "Abstention", id: "abs", key: "ABS%d", format: percent},
{name: "Vote FN", id: "fn", key: "FN%d", format: percent},
],
years = [2012, 2007,2002,1995],
fieldsById = d3.nest()
.key(function(d) { return d.id; })
.rollup(function(d) { return d[0]; })
.map(fields),
field = fields[0],
year = years[0],
colorsFN = colorbrewer.Greys[8];
var colorFN = d3.scale.quantize().range(colorsFN).domain([0,25]);
colorsAbs = colorbrewer.Reds[8];
var colorAbs = d3.scale.quantize().range(colorsAbs).domain([0,45]);
var body = d3.select("body");
// paramètre la navigation dans le champ #field
var fieldSelect = d3.select("#field")
.on("change", function(e) {
field = fields[this.selectedIndex];
update();
});
fieldSelect.selectAll("option")
.data(fields)
.enter()
.append("option")
.attr("value", function(d) { return d.id; })
.text(function(d) { return d.name; });
// paramètre de la même manière la nav' dans le champ #year
var yearSelect = d3.select("#year")
.on("change", function(e) {
year = years[this.selectedIndex];
update();
});
yearSelect.selectAll("option")
.data(years)
.enter()
.append("option")
.attr("value", function(y) { return y; })
.text(function(y) { return y; })
// déclaration de la carte
var map = d3.select("#map"),
zoom = d3.behavior.zoom()
.translate([-38, 32])
.scale(.94)
.scaleExtent([0.5, 10.0])
.on("zoom", updateZoom),
layer = map.append("g")
.attr("id", "layer"),
states = layer.append("g")
.attr("id", "states")
.selectAll("path");
updateZoom();
function updateZoom() {
var scale = zoom.scale();
layer.attr("transform",
"translate(" + zoom.translate() + ") " +
"scale(" + [scale, scale] + ")");
}
// on passe à la déclaration de la projection et du cartogramme, notamment
var projection = d3.geo.albers().origin([8.5, 45.7]).scale(2600).parallels([40, 52]),
topology,
geometries,
rawData,
dataById = {},
carto = d3.cartogram()
.projection(projection)
.properties(function(d) {
var c= dataById[d.id];
return dataById[d.id];
})
.value(function(d) {
return +d.properties[field.key.replace("%d",year)];
});
// on charge les polygones et les données
d3.json("dpts_fr_topo.json", function(topo) {
topology = topo;
geometries = topology.objects.layer1.geometries;
d3.csv("presidentielle_fn_abstention_final.csv", function(data) {
rawData = data;
dataById = d3.nest()
.key(function(d) { return d.id; })
.rollup(function(d) { return d[0]; })
.map(data);
init();
});
});
// on initialise la carte via les variables précédentes
function init() {
var features = carto.features(topology, geometries),
path = d3.geo.path()
.projection(projection);
states = states.data(features)
.enter()
.append("path")
.attr("class", "state")
/* .attr("id", function(d) {
return d.properties.id;
}) */
.attr("fill", "#fafafa")
.attr("d", path);
states.append("title");
update();
}
// et on met à jour à chaque changement dans un champ de sélection
function update() {
if(field.key!=undefined){
body.classed("updating", true);
var key = field.key.replace("%d",year);
var fmt = (typeof field.format === "function")
? field.format
: d3.format(field.format || ","),
value = function(d) {
if(d.properties != undefined){
return +d.properties[key];
}else{ return NaN};
};
var values = states.data().map(value)
.filter(function(n) {
return !isNaN(n);
})
.sort(d3.ascending);
lo = values[0],
hi = values[values.length - 1];
// normalise l'échelle en nombre positif
var scale = d3.scale.linear()
.domain([lo, hi])
.range([1, 1000]);
// dit au cartogramme d'utiliser les valeurs échelonnées
carto.value(function(d) {
return scale(value(d));
});
// génère les nouvelles caractéristiques, pré projetées
var features = carto(topology, geometries).features;
// met à jour les données
states.data(features)
.select("title")
.text(function(d) {
if(d.properties != undefined){
return [d.properties.NAME, fmt(value(d))].join(": ");
}
});
states.transition()
.duration(750)
.ease("linear")
.attr("fill", function(d) {
if(field.id=="fn"){
return colorFN(value(d))
}else{
return colorAbs(value(d))
}
})
.attr("d", carto.path);
body.classed("updating", false);
}else{
var features = carto.features(topology, geometries);
// met à jour les données
states.data(features)
path = d3.geo.path()
.projection(projection);
states.transition()
.duration(750)
.ease("linear")
.attr("fill","#fafafa")
.attr("d", path);
}
}
</script>
Pour aller plus loin
J'ai publié l'ensemble des fichiers utilisés pour cette carte sur gitHub.