27 mar 2017

Ce billet, plus qu'un tuto classique sur un logiciel en particulier, vise à décrire dans le détail toutes les étapes d'un petit projet perso de carte animée, de l'idée de départ au produit final en passant par la récupération et la mise en forme des données. Outre le résultat qui est chouette, l'intéret, à mon avis, est qu'il fait appel à une somme de technos/compétences clés en SIG et est donc représentatif d'un projet que peut être amené à réaliser un géomaticien. 

tl;dr: le résultat final est accessible ici (cliquez sur le bouton "Play" dans le coin en haut à droite de la carte pour lancer l'animation).

Et pour les plus motivés, c'est parti pour explications détaillées !

NB: Comme indiqué, c'est une reprise très fidèle d'une dataviz faite, entre autres, par Mike Bostrock (le créateur de D3.js)  du temps où il travaillait au New York Times.

L'idée

Tout simplement, je voulais réaliser une application sous Leaflet représentant des données temporelles. En gros, lorsqu'on clique sur un bouton "Play", il se passe quelque chose et la carte "s'anime".

Je suis donc parti à la recherche de données qui me permettraient de réaliser cela. J'ai tout de suite pensé aux événements sportifs car je me disais que les données seraient plus propres (les coureurs partent en même temps et suivent plus ou moins le même parcours). De plus, pour que le résultat soit plus sympa, je voulais plus d'un individu sur la carte. J'ai d'abord regardé les résultats de courses à pieds type marathons, pensant que certains évenements publiaient les données des meilleurs coureurs mais je n'ai rien trouvé de ce côté là.

Et puis je suis tombé sur ce site, qui publie les données de l'America's Cup de 2013. La page que j'avais trouvée à l'époque n'existe plus, mais les données sont toujours accessible sur Google Drive, ici.

Les données

Le fichier AC.zip (101 Mo) contient a priori toutes les données de la compétition de 2013. Je n'ai travaillé qu'avec les données de la finale opposant un bateau américain à un bateau néo-zélandais (fichier 130925.zip). Une fois dézippé, les données se trouvent dans le dossier "csv". Nous avons besoin des 2 fichiers suivants :

  • 20130925130025-NAV-NZL.csv
  • 20130925130025-NAV-USA.csv

 Ces fichiers ont la même structure et contiennent 24 colonnes. Nous allons uniquement en garder 5 :

  • Secs (3eme colonne - temps en seconde)
  • Lat (6eme colonne - latitude du point)
  • Lon (7eme colonne - longitude du point)
  • CourseWindDirection (13eme colonne - direction du vent en degré)
  • CourseWindSpeed (14eme colonne - vitesse du vent en noeud)

 Je nomme mes 2 fichiers "us.csv" et "nz.csv". Ci-dessous, les 10 premières lignes du fichier "us.csv". En regardant la 1ere colonne, on remarque qu'on a des données toutes les 0,2 secondes.

 

Les fichiers initiaux n'avaient pas exactement le même nombre de lignes, j'ai donc manuellement supprimé les quelques lignes (donc secondes) qui n'étaient pas communes pour avoir le même nombre d'enregistrement  dans les 2 fichiers.

Le code 1/2 : structuration des données

Toutes les requêtes SQL qui suivent ont été faites sous PostgreSQL/Postgis, versions respectives 9.3 et 2.1.1.

Je ne vous montre ici que les requêtes correspondant aux données du bateau américain, mais il faut évidemment faire la même chose avec les données du bateau néo-zélandais.

Tout d'abord, l'import des données CSV dans une table appelée "data_us" :

 CREATE TABLE data.data_us -- J'ai créé au préalable un schéma intitulé "data" dans lequel je vais stocker mes différentes tables
 (
  secs NUMERIC(6, 1), -- J'arrondis les secondes à 10-1 car le fichier nz a les secondes à 10-3, ainsi j'aurai les mêmes valeurs dans les 2 tables
  lat NUMERIC,
  long NUMERIC,
  wind_dir NUMERIC,
  wind_speed NUMERIC
 );
-- J'importe les données du fichier CSV dans la table créée ci-dessus
COPY data.data_us FROM 'C:\path\us.csv' DELIMITER ',' CSV HEADER; 

On va maintenant travailler les données pour avoir à chaque instant le pourcentage de la course parcouru dans une table PostGIS. Ensuite, on convertira cette table au format GeoJSON pour pouvoir la manipuler avec Leaflet.

Les données ne concernent pas seulement la course en elle même, mais aussi une période antérieure (échauffement) et ultérieure (après-course). Comme on veut calculer la distance parcourue uniquement durant la course, il nous faut rajouter une colonne pour différencier ces 3 périodes (ces informations se trouvent dans le fichier "130925\csv\20130925130025_events.csv") :

 ALTER TABLE data.data_us ADD COLUMN section VARCHAR; -- on appelle cette colonne "section" 
-- La course commence à 13:15:00 (= 47699.8 secondes). Avant, c'est l'échauffement (warm up)
UPDATE data.data_us SET section = 'warm up' WHERE secs <= 47699.8;
UPDATE data.data_us SET section = 'race' WHERE secs > 47699.800;
-- Le bateau US termine la course à 13:38:24.077 (= 49104.0 secs) | Le bateau NZ termine la course à 13:39:08.271 (= 49148.2 secs)
UPDATE data.data_us SET section = 'finish' WHERE secs > 49104.0;  

Comme la requête pour obtenir les données telles qu'on les veut est assez compliquée, on va la faire par étape (à chaque fois, l'étape précédente est imbriquée dans celle en cours).

Premièrement, il nous faut convertir nos coordonées au format texte en format geography. Pour cela, on utilise la fonction PostGIS ST_GeogFromText. De plus, pour chaque point, nous voulons également le point précédent pour pouvoir calculer la distance qui les sépare (on le fera à l'étape suivante). Pour cela, on fait appel à la fonction lag (une des fonctions window de PostgreSQL, qui sont extrêmement puissantes et pratiques). Et comme on a beaucoup trop de données (toutes les 0,2 secondes), on va faire un sacré tri pour n'en garder que toutes les 3 secondes  :

 SELECT secs, wind_dir, wind_speed, section, geog, lag(geog) OVER (ORDER BY secs) AS geog_before
 FROM (
  SELECT secs, wind_dir, wind_speed, section, ST_GeogFromText('POINT(' || long || ' ' || lat || ')') AS geog FROM data.data_us WHERE secs % 3 = 0
 ) SEL_1; 

Avec ça, nous pouvons maintenant calculer la distance en mètres entre 2 points voisins en utilisant la fonction ST_Distance. COALESCE sert à affecter la distance nulle (0) au premier point qui n'a pas de point antérieur :

 SELECT secs, wind_dir, wind_speed, section, geog, COALESCE(ST_Distance(geog, geog_before), 0) AS distance
 FROM (
  SELECTsecs, wind_dir, wind_speed, section, geog, lag(geog) OVER (ORDER BY secs) AS geog_before
   FROM (
    SELECT secs, wind_dir, wind_speed, section, ST_GeogFromText('POINT(' || long || ' ' || lat || ')') AS geog FROM data.data_us WHERE secs % 3 = 0
   ) SEL_1
 ) SEL_2; 

 Maintenant, on calcule pour chaque point la distance cumulée (c'est à dire depuis le début), qu'on appelle 'cumul' mais seulement durant la course (section = 'race') :

 SELECT secs, wind_dir, wind_speed, section, geog, distance, sum(CASE WHEN section='race' THEN  distance ELSE 0 END) OVER (PARTITION BY section ORDER BY secs) AS cumul 
 FROM (
  SELECT secs, wind_dir, wind_speed, section, geog, COALESCE(ST_Distance(geog, geog_before), 0) AS distance
   FROM (
    SELECT secs, wind_dir, wind_speed, section, geog, lag(geog) OVER (ORDER BY secs) AS geog_before
     FROM (
      SELECT secs, wind_dir, wind_speed, section, ST_GeogFromText('POINT(' || long || ' ' || lat || ')') AS geog FROM data.data_us WHERE secs % 3 = 0
   ) SEL_1
  ) SEL_2
 ) SEL_3 ORDER BY secs 

On a maintenant la distance parcourue à chaque instant. Pour exprimer cela en pourcentage, il nous faut connaitre la distance totale parcourue durant la course. Pour l'obtenir, on calcule le maximum de la colonne 'cumul' à partir de la requête précédente (SELECT max(cumul) FROM (...).  Cette valeur est de 22106.229818404 mètres pour le bateau US et de 22766.524924026 mètres pour le bateau NZ.

On calcule maintenant le pourcentage parcouru qu'on va appeler 'run'. Cette fois-ci on va créér une nouvelle table (boat_us) qui contiendra le temps, appelé 'time' en secondes (commençant à 0, on doit donc soustraire 46833 à 'secs'), le pourcentage parcouru 'run', la direction du vent, la vitesse du vent et la géométrie du point :

 CREATE TABLE  data.boat_us AS (
 SELECT secs::INTEGER - 46833 as time, (cumul/22106.229818404 * 100)::NUMERIC(5, 2) AS run, wind_dir, wind_speed, geog
  FROM (
   SELECT secs, wind_dir, wind_speed, section, geog, distance, sum(CASE WHEN section='race' THEN  distance ELSE 0 END) OVER (PARTITION BY section ORDER BY secs) AS cumul 
    FROM (
     SELECT secs, wind_dir, wind_speed, section, geog, COALESCE(ST_Distance(geog, geog_before), 0) AS distance
      FROM (
       SELECT secs, wind_dir, wind_speed, section, geog, lag(geog) OVER (ORDER BY secs) AS geog_before
        FROM (
         SELECT secs, wind_dir, wind_speed, section, ST_GeogFromText('POINT(' || long || ' ' || lat || ')') AS geog FROM data.data_us WHERE secs % 3 = 0
        ) SEL_1
      ) SEL_2
    ) SEL_3 ORDER BY secs
  ) SEL_4
); 

A ce niveau, le pourcentage parcouru 'run' reste à zéro durant l'échauffement puis augmente progressivement durant la course jusqu'à 100% lors de l'arrivée, puis une fois la course terminée il retombe à 0 (car on a fait le calcule uniquement sur section='race'). On va changer ça pour que la valeur de 'run' reste à 100 une fois la ligne d'arrivée franchie. 2274 correspond au temps en secondes d'arrivée du bateau US (pour le bateau NZ, c'est 2316) :

 UPDATE data.boat_us SET run = 100 where time >= 2274; 

 Voici les 10 premières lignes de la table 'boat_us' :

 Comme mentionné plus haut, il faut faire la même chose pour le bateau NZ. Mais pour avoir un fichier plus léger on peut ne garder que les colonnes 'run' et 'geog' (car on utilisera les colonnes 'time', 'wind_dir' et 'wind_speed' de la couche 'boat_us').

Nos 2 nouvelles tables sont des tables PostGIS, on peut donc les ouvrir dans QGIS :

Depuis QGIS, on sauvergarde les 2 couches au format GeoJSON, en ne gardant pour les coordonnées que 5 chiffres après la virgule (ça réduit la taille du fichier et la précision est quasi la même). On nomme les fichiers "boat_us.geojson" et "boat_nz.geojson".

Dernière manip, on ouvre les 2 fichiers avec notre éditeur de texte et on rajoute au tout début 'var boat_us =' et 'var boat_nz =' respectivement, puis on sauvegarde. Cela nous permettra dans le code JavaScript d'avoir accès aux données en appelant les variables 'boat_us' et 'boat_nz'.

Voici à quoi ressemble le début du fichier "boat_us.geojson" dans un éditeur de texte :

 Ca y est, nos données sont formatées comme nous le voulons. Nous pouvons donc passer à l'étape de développement de la carte animée.

Le code 2/2 : réalisation de la carte animée

Outre Leaflet, on va utiliser JQuery et Bootstrap (qui ne sont pas indispensables mais simplifient les choses) avec les version suivantes :

  • Leaflet : 1.0.3
  • JQuery : 3.2.0
  • Bootstrap : 3.3.7

On commence par mettre en place la structure de notre page HTML. On importe d'abord le CSS. Le fichier "style.css" à la ligne 8 contiendra nos règles CSS perso. On importe les 3 couches de la ligne 29 à 31 (contenant les données du bateau US, NZ et les bouées "boats.geojson").

Enfin entre les balises <script></script> (de la ligne 32 à 42) on va ajouter le code JS. Pour commencer on créé l'objet map centré sur notre zone d'intérêt et on y ajoute un fond carte :

 <!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>America's Cup 2013 Finale: An animated map</title>
    <link rel="stylesheet" href="/path/js/bootstrap/css/bootstrap.min.css"/>
    <link rel="stylesheet" href="/path/js/leaflet/leaflet.css"/>
    <link rel="stylesheet" href="/path/css/style.css"/>
  </head>
  <body>
    <div class="row">
      <div class="col-md-1"></div>
      <div class="col-md-10" id="title">
        <h2>America's Cup 2013 Finale: An animated map</h2>
        <p id="source">Inspired by this <a href="http://www.nytimes.com/interactive/2013/09/25/sports/americas-cup-course.html">dataviz</a></p>
      </div>
      <div class="col-md-1"></div>
    </div>
    <div class="row">
      <div class="col-md-1"></div>
      <div class="col-md-10">
        <div id="map"></div>
      </div>  
      <div class="col-md-1"></div>
    </div>
    <script src="/path/js/jquery.min.js"></script>
    <script src="/path/js/bootstrap/js/bootstrap.min.js"></script>
    <script src="/path/js/leaflet/leaflet.js"></script>
    <script src='/path/layers/boat_us.geojson'></script>
    <script src='/path/layers/boat_nz.geojson'></script>
    <script src='/path/layers/floats.geojson'></script>
    <script>
      var map = L.map('map', { 
          center: [37.8175, -122.43], 
          zoom: 14 
      });

      L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/light_all/{z}/{x}/{y}.png', {
        maxZoom: 18,
        attribution: '© <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>, ©<a href="https://cartodb.com/attributions">CartoDB</a>'
      }).addTo(map);
    </script>
  </body>
</html> 

Vous pouvez consulter le résultat ici.

Notre structure HTML est donc en place et on ne va plus y toucher. Notre travail va consister à ajouter du code JS entre les lignes 41 et 42 actuelles.

On va commencer par animer un peu tout ça en affichant le parcours progressif du bateau US. Pour ça, j'utilise window.requestAnimationFrame (voici des articles intéressants sur les animations dans le navigateur).

On créé d'abord une polyligne (polyline_us) à partir des 2 premiers points de la couche 'boat_us'. Ensuite on va itérer sur les points suivants pour les ajouter un par un à polyline_us :

var point_1 = new L.LatLng(boat_us.features[0].geometry.coordinates[1], boat_us.features[0].geometry.coordinates[0]);
var point_2 = new L.LatLng(boat_us.features[1].geometry.coordinates[1], boat_us.features[1].geometry.coordinates[0]);
var pointList_us = [point_1, point_2];

var polyline_us = L.polyline(pointList_us, {color: '#d7301f',weight: 2.5}).addTo(map);

var i = 2;

function addSegment() {
  if (i++ < boat_us.features.length - 1)  {
    var point_n = new L.LatLng(boat_us.features[i].geometry.coordinates[1], boat_us.features[i].geometry.coordinates[0]);
    polyline_us.addLatLng(point_n);
  }
  window.requestAnimationFrame(addSegment);
}

window.requestAnimationFrame(addSegment); 

Vous pouvez consulter le résultat ici. Sympa, non ?

A noter que si vous consultez un autre onglet durant l'animation, elle se met en pause et ne reprend que lorsque vous revenez dessus. C'est un des avantages de window.requestAnimationFrame.

Prochaine étape, ajouter le bateau NZ et à chaque itération ne garder que les 10 points précédents pour donner une meilleure impression de mouvement et de vitesse (on créé la fonction draw). On remplace donc le code précédent par :

var point_1_us = new L.LatLng(boat_us.features[0].geometry.coordinates[1], boat_us.features[0].geometry.coordinates[0]);
var point_2_us = new L.LatLng(boat_us.features[1].geometry.coordinates[1], boat_us.features[1].geometry.coordinates[0]);
var pointList_us = [point_1_us, point_2_us];

var polyline_us = L.polyline(pointList_us, {color: '#d7301f',weight: 2.5}).addTo(map);

var point_1_nz = new L.LatLng(boat_nz.features[0].geometry.coordinates[1], boat_nz.features[0].geometry.coordinates[0]);
var point_2_nz = new L.LatLng(boat_nz.features[1].geometry.coordinates[1], boat_nz.features[1].geometry.coordinates[0]);
var pointList_nz = [point_1_nz, point_2_nz];

var polyline_nz = L.polyline(pointList_nz, {color: '#0570b0',weight: 2.5}).addTo(map);

var i = 2;

function draw(layer, polyline) {
  var point_n = new L.LatLng(layer.features[i].geometry.coordinates[1], layer.features[i].geometry.coordinates[0]);
  if (polyline._latlngs.length <11) {
      polyline.addLatLng(point_n);
  }
  else {
      polyline.getLatLngs().splice(0, 1);
      polyline.addLatLng(point_n);
  }
}

function addSegment() {
  if (i++ < boat_us.features.length - 1)  {
    draw(boat_us, polyline_us);
    draw(boat_nz, polyline_nz);
  }
  window.requestAnimationFrame(addSegment);
}

window.requestAnimationFrame(addSegment); 

Vous pouvez consulter le résultat ici.

De mieux en mieux, mais on n'a pas de maîtrise sur l'animation, elle démarre toute seule et on ne peut pas faire pause ou recommencer. On va remédier à ça en ajoutant un control Leaflet qui contriendra un bouton à 3 états :

  • play : pour démarrer ou relancer l'animation
  • pause : pour stopper l'animaton lorsqu'elle est active
  • replay : pour la relancer une fois qu'elle est terminée

On va aussi modifier le style des bateaux en mouvement en ajoutant au dessus des lignes des points représentant la position actuelle des bateaux :

 var button_animation = L.control( { position: 'topright' } );
button_animation.onAdd = function(map) {
  var button = L.DomUtil.create("button", "play btn btn-default");
  $(button).append( '<span class="glyphicon glyphicon-play" aria-hidden="true"></span>');
  return button
}
button_animation.addTo(map);

var styleBoatUS = {
  radius: 30,
  fillColor: "#d7301f",
  fillOpacity: 1,
  color: "#d7301f"
};

var styleBoatNZ = {
  radius: 30,
  fillColor: "#0570b0",
  fillOpacity: 1,
  color: "#0570b0"
};

var point_1_us = new L.LatLng(boat_us.features[0].geometry.coordinates[1], boat_us.features[0].geometry.coordinates[0]);
var point_2_us = new L.LatLng(boat_us.features[1].geometry.coordinates[1], boat_us.features[1].geometry.coordinates[0]);
var pointList_us = [point_1_us, point_2_us];

var polyline_us = L.polyline(pointList_us, {color: '#d7301f',weight: 2.5}).addTo(map);

var positionUS = L.circle([boat_us.features[0].geometry.coordinates[1], boat_us.features[0].geometry.coordinates[0]], styleBoatUS).addTo(map);

var point_1_nz = new L.LatLng(boat_nz.features[0].geometry.coordinates[1], boat_nz.features[0].geometry.coordinates[0]);
var point_2_nz = new L.LatLng(boat_nz.features[1].geometry.coordinates[1], boat_nz.features[1].geometry.coordinates[0]);
var pointList_nz = [point_1_nz, point_2_nz];

var polyline_nz = L.polyline(pointList_nz, {color: '#0570b0',weight: 2.5}).addTo(map);

var positionNZ = L.circle([boat_nz.features[0].geometry.coordinates[1], boat_nz.features[0].geometry.coordinates[0]], styleBoatNZ).addTo(map);

var i = 2;

function draw(layer, polyline, position) {
  position._latlng.lat = layer.features[i].geometry.coordinates[1];
  position._latlng.lng = layer.features[i].geometry.coordinates[0];
  position.redraw();

  var point_n = new L.LatLng(layer.features[i].geometry.coordinates[1], layer.features[i].geometry.coordinates[0]);
  if (polyline._latlngs.length < 11) {
      polyline.addLatLng(point_n);
    }
  else {
      polyline.getLatLngs().splice(0, 1);
      polyline.addLatLng(point_n);
  }
}

function addSegment() {
  if (i++ < boat_us.features.length - 1)  {
    draw(boat_us, polyline_us, positionUS);
    draw(boat_nz, polyline_nz, positionNZ);
  }
  else {
    isRunning = false;
    $("button.play").html('<span class="glyphicon glyphicon-repeat" aria-hidden="true"></span>');
    polyline_us.getLatLngs().splice(0, 11);
    polyline_nz.getLatLngs().splice(0, 11);
  }

  if(isRunning){ 
    window.requestAnimationFrame(addSegment);
  }
}

$("button.play").click( function () {
  // Play
  if ($(this).html() == '<span class="glyphicon glyphicon-play" aria-hidden="true"></span>') {
      $(this).html('<span class="glyphicon glyphicon-pause" aria-hidden="true"></span>');
      isRunning = true;
      window.requestAnimationFrame(addSegment);
  }
  // Pause
  else if ($(this).html() == '<span class="glyphicon glyphicon-pause" aria-hidden="true"></span>') {
      $(this).html('<span class="glyphicon glyphicon-play" aria-hidden="true"></span>');
      isRunning = false;
  }
  // Replay
  else {
    $(this).html('<span class="glyphicon glyphicon-pause" aria-hidden="true"></span>');
    isRunning = true;
    i = 2;
    window.requestAnimationFrame(addSegment);
  }
}); 

Vous pouvez consulter le résultat ici. Il faut cliquer sur le bouton 'Play' dans le coin en haut à droite de la carte.

On a fait le plus dur. Il ne nous reste plus qu'à fignoler en ajoutant la couche des bouées et quelques controls.

On va d'abord voir comment ajouter le control qui permet de zoomer sur les 2 bateaux et de les suivre. Et lorsqu'on clique à nouveau sur ce bouton, le zoom revient à la vue initiale. A la suite du control "button_animation", on créé le control "button_tracker" :

 var button_tracker = L.control( { position: 'topright' } );
button_tracker.onAdd = function(map) {
  var tracker = L.DomUtil.create("button", "track btn btn-default");
  $(tracker).append( '<span class="glyphicon glyphicon-screenshot" aria-hidden="true"></span>');

  L.DomEvent.addListener(tracker, 'click', function(e) { 
    var $this = $(this);
    if ($this.html() == '<span class="glyphicon glyphicon-screenshot" aria-hidden="true"></span>') {
         $this.html( '<span class="glyphicon glyphicon-globe" aria-hidden="true"></span>');
         bounds = L.latLngBounds(positionUS._latlng, positionNZ._latlng);
         map.setView(bounds.getCenter(), 15);
    } 
    else {
         $this.html( '<span class="glyphicon glyphicon-screenshot" aria-hidden="true"></span>');
          map.setView([37.8175, -122.43], 14);
    };
  });
  return tracker
}
button_tracker.addTo(map);  

On doit maintenant modifier la fonction draw afin de changer le centrage de la carte à chaque itération dans le cas où l'utilisateur a cliqué sur le bouton traker. Pour ça, on rajoute à la fin de la fonction, le code suivant :

 if ($("button.track").html() !== '') {
  bounds = L.latLngBounds(positionUS._latlng, positionNZ._latlng);
  map.setView(bounds.getCenter(), 15);
} 

Vous pouvez consulter le résultat ici.

Je ne vais pas détailler ici le code des 2 autres controls, celui contenant la vitesse et la direction du vent et celui affichant le temps et le pourcentage parcouru. Vous pouvez consulter le code final. A noter que je mets à jour ces controls toutes les 6 itérations (sinon, cela va trop vite et on a du mal à lire). Pour le premier control, il faut utiliser les propriétés "wind_speed" et "wind_dir". Pour le deuxième, "time" et "run".

Ci-dessous, on va ajouter les bouées et les lignes qui relient certaines d'entre elles ainsi que les labels.

De la ligne 1 à 27 on déclare les lignes, leur style et ensuite on les ajoute à la carte.

De la ligne 29 à 42 on déclare le style des bouées puis on ajoute la couche (la variable floats qui est dans le fichier "floats.geojson".

Enfin, on ajoute les labels associés aux lignes : 

 var lines = [{
  "type": "LineString",
  "coordinates": [[-122.45682, 37.82033], [-122.455, 37.81762]]
  }, {
      "type": "LineString",
      "coordinates": [[-122.455, 37.81762], [-122.45037, 37.81807]]
  }, {
      "type": "LineString",
      "coordinates": [[-122.4625, 37.8128], [-122.46287, 37.81443]]
  }, {
      "type": "LineString",
      "coordinates": [[-122.40079, 37.82382], [-122.40056, 37.82226]]
  }, {
      "type": "LineString",
      "coordinates": [[-122.40149, 37.81023], [-122.39897, 37.81027]]
}];

var lineStyle = {
    "color": "black",
    "dashArray": "5 4",
    "weight": 1,
    "opacity": 1
};

L.geoJSON(lines, {
    style: lineStyle
}).addTo(map);

var styleFloats = {
  radius: 3,
  fillColor: "white",
  color: "#000",
  weight: 0.5,
  opacity: 1,
  fillOpacity: 0.8
};

L.geoJSON(floats, {
  pointToLayer: function (feature, latlng) {
    return L.circleMarker(latlng, styleFloats);
  }
}).addTo(map);

var entry = L.geoJSON(lines[0], {
  stroke: false
}).addTo(map);

entry.bindTooltip("Entry", {
  permanent: true, 
  direction: 'left',
  opacity: 1
});

var start = L.geoJSON(lines[1], {
  stroke: false
}).addTo(map);

start.bindTooltip("Start", {
  permanent: true, 
  direction: 'left',
  opacity: 1,
  offset: L.point(50,10)
});

var finish = L.geoJSON(lines[4], {
  stroke: false
}).addTo(map);

finish.bindTooltip("Finish", {
  permanent: true, 
  direction: 'left',
  opacity: 1,
  offset: L.point(60,-10)
}); 

Vous pouvez consulter le résultat ici.

Voilà, en rajoutant donc les 2 controls manquants ainsi que les tooltips sur les 2 controls/boutons pour indiquer à l'utilisateur leur utilité, on obtient la version finale.

Vous pouvez télécharger les différents fichiers ici (code et données mais ne comprend pas JQuery, Bootstrap ou Leaflet).

img_slideshow: 
A propos de l'auteur: 
Pierre Vernier