Autopsie d'une dataviz [5.3] : des switchers sur mapbox.js

Troisième et dernier volet d'une trilogie "dataïste" et cartographique avec la méthode pour obtenir des switchers efficaces sur mapbox.js.

Résumé des épisodes précédents : un cartographe amateur a, après une préparation d'un gros .shp sur QGis, réussi à obtenir une choroplèthe interactive sur TileMill. Reste plus qu'à animer tout ça sur mapbox.js...

Et autant le dire tout de suite : ça n'a pas été une partie de plaisir. Mon Javascript était déjà bien rouillé à la base, mais les développeurs de MapBox ont aussi une manière particulière d'appréhender les calques sur leur app'.

Mais avec beaucoup de pugnacité de mon côté et de patience du leur, je peux maintenant dresser une feuille de route pour ceux qui voudraient programmer un switcher simple et fonctionnel sur mapbox.js.

Un bel exemple obsolète

Tout d'abord, une brève présentation de mapbox.js. Cette librairie est basée essentiellement sur leaflet.js, une librairie de cartographie open source créée par Vladimir Agafonkin.

Outre sa licence, cette librairie a l'atout majeur d'être très bien optimisée pour les OS mobiles, ce qui est plutôt intéressant à une époque où beaucoup de consommateurs d'info s'exportent sur smartphones et tablettes.

Le défi pour enfin terminer ma cartographie électorale du Grand-Est était le suivant : programmer une carte interactive avec un switcher qui permettrait de naviguer entre plusieurs années de scrutin.

En furetant un peu sur la Toile, je suis tombé sur ce boulot titanesque de Bjørn Sandvik avec la Nouvelle-Zélande.

Il avait exactement créé ce que je cherchais à faire, et détaillé par le menu toutes les étapes de son travail sur son blog.

J'ai donc d'abord commencé à essayer d'adapter son exemple avec mes propres cartes.

Voici le résultat, publié premièrement sur Rue89Strasbourg :

D'un point de vue fonctionnel, la carte fait bien le job sur ordinateur, mais j'ai très vite trouvé quelques défauts :

  • la police d'écriture des fenêtres interactives n'était pas exactement la même que mes autres cartes. A la limite, pas super grave
  • je n'avais pas réussi à afficher mes légendes. A la limite, pas super grave non plus
  • sur mobile, un zoom faisait automatiquement planter la carte. Ça, c'était en revanche assez moyen.

Tout ceci venait malheureusement du code, devenu complètement obsolète après la version 1.0.0 de l'application :

<script>
  var map = mapbox.map('map');
  var layers = document.getElementById('map-ui');

  // on commence par déclarer chacun de nos calques (un par année de scrutin) en appliquant bien 
  // un map.interaction.auto pour synchroniquer les zones interactive avec le chargement de la carte
  map.addLayer(mapbox.layer().composite(false).id('raphadasilva.h5o8i9lo', map.interaction.auto), map.setZoomRange(7, 10));
  map.addLayer(mapbox.layer().composite(false).id('raphadasilva.h5oa0na3', map.interaction.auto), map.setZoomRange(7, 10));
  map.centerzoom({ lat: 48.36, lon: 5.62 }, 7);

  // le switcher est créé à partir du nombre de calques déclarés avant
  for (var i = 0; i < map.getLayers().length; i++) {
      var n = map.getLayerAt(i).name;
      var item = document.createElement('li');
      var layer = document.createElement('a');
          layer.href = '#';
          layer.id = n;
          layer.innerHTML = (2014-5*(i+1));

      if (i === 0) {
          layer.className = 'active';
          map.getLayerAt(i).enable();
      } else {
          map.getLayerAt(i).disable();
      }

      layer.onclick = function(e) {
          e.preventDefault();
          e.stopPropagation();
          // on désactive le calque actif jusqu'à présent
          for (var j = 0; j < map.getLayers().length; j++) {
              map.getLayerAt(j).disable();
              layers.childNodes[j].childNodes[0].className = '';
          }
          // et on active celui qui est sélectionné
          this.className = 'active';
          map.getLayer(this.id).enable();
          map.interaction.refresh();
      };
      item.appendChild(layer);
      layers.appendChild(item);
  }
</script>

Sauf que depuis la 1.0.0, l'intégration de Leaflet était devenue encore plus importante, et cette syntaxe ne permettait pas d'afficher une carte correcte sur OS mobile.

Il fallait donc arriver à une mise à jour sur une version actuelle de l'app'.

Bien distinguer les types de calques

La déclaration d'une carte avec un calque unique sur mapbox.js est ultra simple :

<script type='text/javascript'>

 L.mapbox.map('map', 'raphadasilva.h610d5ea').setView([47.88, 5.5613], 7);

</script>

Ce qui nous donne concrètement avec un MBTile préparé convenable sur TileMill :

En affectant quelques options à une div map, on se retrouve avec une carte qui :

  • est bien interactive
  • a une légende rattachée au fichier MBTiles d'origine
  • on peut zoomer sur OS mobile sans souci

On pourrait facilement imaginer que, pour obtenir un switcher opérationnel, il suffirait de réaffecter de nouvelles options à la div map, comme ceci :

<div id='map' class='map'>
  <div id='layer' class='layers'><a id='carte1' href='#'>2009</a><a id='carte2' href='#'>2004</a></div>
</div>
<script>
// on déclare une première carte
var map = L.mapbox.map('map','raphadasilva.h5o8i9lo', {zoomControl: false}).setView([47.88, 5.5613], 7);

//on fait une fonction pour chaque clic qui vide et remplit
// à nouveau la div 'map'
document.getElementById('carte2').onClick = function() {

  $('#map').empty;
  map = L.mapbox.map('map','raphadasilva.raphadasilva.h5oa0na3', {zoomControl: false}).setView([47.88, 5.5613], 7);
}

document.getElementById('carte1').onClick = function() {

  $('#map').empty;
  map = L.mapbox.map('map','raphadasilva.h5o8i9lo', {zoomControl: false}).setView([47.88, 5.5613], 7);
}

</script>

** Sauf que ça ne marche pas**. Mince...

En réalité, la meilleure méthode est de considérer le remplissage de la div 'map' comme un simple fond de carte, sur lequel on va appliquer ensuite des transformations.

Pour cela, il faut bien distinguer :

  • les tileLayers, qui correspondent grossièrement aux tuiles "brutes" d'un calque
  • les gridLayers, qui correspondent grossièrement aux tuiles "interactives" d'un calque

Ainsi, rien n'empêche d'utiliser un fond de carte bien travaillé sur TileMille ou Mapbox (voir ces adaptations de Snazzy Maps) pour ensuite lui coller des zones interactives. Exemple ici :

Et voici le code associé :

<script>
//on commence par déclarer la carte avec le fond statique
var map = L.mapbox.map('map', 'raphadasilva.h9f3ogf1').setView([47.88, 12.6562], 4);
map.options.maxZoom = 6;
map.options.minZoom = 4;

//on charge les tuiles, sans interaction
var layer = L.mapbox.tileLayer('raphadasilva.hbmoelap');
 map.addLayer(layer);

//on charge le calque avec l'interaction
var gridLayer = L.mapbox.gridLayer('raphadasilva.hbmoelap');
map.addLayer(gridLayer);

//on ajoute la fenêtre interactive qui s'affichera automatiquement
map.addControl(L.mapbox.gridControl(gridLayer));

</script>

Attention tout de même : si le MBTiles qui finira en TileLayer a été paramétré pour s'afficher dans une fourchette de zoom, il faut installer un maxZoom et un minZoom avec cette même fourchette sur la div 'map' pour ne pas qu'il disparaisse.

Un premier switcher pas à pas

Voici un premier switcher fonctionnel :

Et voici le code qui lui est associé :

<ul id='map-ui'>
        <li><a href="#" id="carte2009">2009</a></li>
	<li><a href="#" id="carte2004">2004</a></li>
</ul>
<div id='map'></div>

<script type='text/javascript'>
document.getElementById('carte2009').className = 'active';
//on déclare le fond de carte, avec niveaux de zoom déjà intégrés
var map = L.mapbox.map('map','raphadasilva.had5l5i7', {zoomControl: false}).setView([47.88, 5.5613], 7);

//on déclare chaque tileLayer et gridLayer
layer2009 = L.mapbox.tileLayer('raphadasilva.h5o8i9lo');
grid2009=L.mapbox.gridLayer('raphadasilva.h5o8i9lo');
layer2004 = L.mapbox.tileLayer('raphadasilva.h5oa0na3');
grid2004=L.mapbox.gridLayer('raphadasilva.h5oa0na3');

//on ajoute d'emblée les calques de 2009
map.addLayer(layer2009);
map.addLayer(grid2009);

//on ajoute un gridControl
//le follow: true implique que la fenêtre interactive va suivre le curseur
var gridControl = L.mapbox.gridControl(grid2009, {follow: true}).addTo(map);

//au clic sur le lien "2004", on crée une fonction qui change le CSS
// met à jour le gridControl, efface les calques associés à 2009
//et affiche les calques de 2004
document.getElementById('carte2004').onclick = function(e) {
        e.preventDefault();
        e.stopPropagation();
		document.getElementById('carte2004').className = 'active';
		document.getElementById('carte2009').className = '';
		gridControl = L.mapbox.gridControl(grid2004, {follow: true}).addTo(map);
            map.removeLayer(layer2009);
            map.removeLayer(grid2009);
			map.addLayer(layer2004);
			map.addLayer(grid2004);

    };

//on fait une fonction semblable avec le lien "2009"
document.getElementById('carte2009').onclick = function(e) {
        e.preventDefault();
        e.stopPropagation();
		document.getElementById('carte2009').className = 'active';
		document.getElementById('carte2004').className = '';
			gridControl = L.mapbox.gridControl(grid2009, {follow: true}).addTo(map);
            map.removeLayer(layer2004);
            map.removeLayer(grid2004);
			map.addLayer(layer2009);
			map.addLayer(grid2009);
    };

</script>

 Passage à la boucle for

Il sera peut-être plus évident d'automatiser la méthode avec une boucle "for" pour arriver à ce résultat :

Soulignons l'importance du gridControl dans ce cas précis.

Si on l'oublie, les fenêtres interactives se superposent à chaque changement de calque, et on finit par ne plus s'y retrouver. Heureusement, une fonction gridControl.hide() permet d'y parvenir facilement.

Passons enfin au code :

<div id=map class=map>
<div id=layers class=layers></div>
</div>

<script>
//on paramètre le fond de carte
var map = L.mapbox.map('map', 'raphadasilva.had5l5i7', {
    zoomControl: false
}).setView([48.18, 5.5613], 7);

// on met dans un tableau les identifiants des tileLayers/gridLayers
var maps = [{
    id: 'raphadasilva.h5o8i9lo'
}, {
    id: 'raphadasilva.h5oa0na3'
}];
var layers = document.getElementById('layers');

//ne surtout pas oublier le gridControl
var gridLayer;
var gridControl;
var layer;

//on crée à chaque entrée du tableau un lien relié aux calques de chaque id
for (var i = 0; i < maps.length; i++) {
    var link = document.createElement('a');
    link.href = '#';
    link.innerHTML = 2014 - 5 * (i + 1);
    link.setAttribute('data-layer', i);
    link.onclick = function () {
        if (/active/.test(this.className)) {
            this.className = this.className.replace(/active/, '').replace(/\s\s*$/, '');
        } else {
            var siblings = layers.getElementsByTagName('a');
            for (var i = 0; i < siblings.length; i++) {
                siblings[i].className = siblings[i].className.replace(/active/, '').replace(/\s\s*$/, '');
            };
            this.className += ' active';
            var m = maps[this.getAttribute('data-layer')];

            // on vire chaque layer/gridLayer/gridControl
            if (layer) map.removeLayer(layer);
            if (gridLayer) map.removeLayer(gridLayer);
            if (gridControl) map.removeControl(gridControl);

           //on met à jour les variables...
            gridLayer = L.mapbox.gridLayer(m.id);
            layer = L.mapbox.tileLayer(m.id);
            gridControl = L.mapbox.gridControl(gridLayer);

           //et on les affecte à la div 'map'
            map.addLayer(layer);
            map.addLayer(gridLayer);
            map.addControl(gridControl);
        }
        return false;
    };
    if (i === 0) link.onclick();
    layers.appendChild(link);
}

};

 Un template gitHub

Pour ceux que ça intéresse, j'ai mis en ligne un template du dernier switcher sur gitHub.

Dès que je trouverai le temps, j'essayerais de montrer des switchers plus élaborés, notamment avec des fonds de carte changeants.