Requête à un serveur cartographique sous client web Leaflet

Etude du code prototype projet Leaflet

Nous avons généré automatiquement dans QGIS un prototype qui permet de visualiser une carte au travers d’un client web, explorons le code…

Le meilleur moyen d’aborder Leaflet est de passer par le tutoriel de démarrage et d’explorer la librairie.

Concept

La composante de base de Leaflet est la carte (L.map). Cette carte est rendue dans un conteneur cible, en pratique dans un élément div de la page Web qui contient la carte.

Les propriétés de la carte peuvent être configurées au moment de sa construction, ou ultérieurement par des méthodes spécifiques.

Le conteneur cible qui a été généré pour nous est un élément div d’identifiant map

<div id="map"></div>

Ce conteneur possède un style d’affichage paramétré dans le fichier de style css/own_style.css, le style est paramétré ici pour un affichage à 100% de la taille de la fenêtre du navigateur.

...
	html, body, #map {
		height: 100%;
		width: 100%;
		padding: 0;
		margin: 0;
	}
...

Notez que la librairie Leaflet possède son propre fichier de style qui est chargé en ressource en entête de fichier html depuis un CDN.
Les fichiers de style des extensions Leaflet.markercluster et Leaflet.label sont également chargés, ainsi que la feuille de style personnalisée pour le projet.

        <link rel="stylesheet" href="http://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.3/leaflet.css" />
        <link rel="stylesheet" href="css/MarkerCluster.css" />
        <link rel="stylesheet" href="css/MarkerCluster.Default.css" />
        <link rel="stylesheet" type="text/css" href="css/own_style.css">
        <link rel="stylesheet" href="css/label.css" />

L.Map gère le niveau de zoom et la projection de la carte.

Pour obtenir les données à représenter pour une couche, Leaflet utilise des classes pour chaque type de couche vecteur, ou raster. Ces couches permettent d’alimenter la carte au travers de serveurs de tuiles, serveur WMS, images, données vectorielles et format GeoJSON. Voyons comment sont configurées les sources par défaut dans notre prototype.

Chargement des ressources javascript

Premièrement le prototype charge la librairie JQuery que l’on ne présente plus, puis la librairie Autolinker qui est un utilitaire qui permet de formater proprement des urls en javascript.

        <script src="http://code.jquery.com/jquery-1.11.1.min.js"></script>
        <script src="js/Autolinker.min.js"></script>

Puis une il fait appel à la librairie Leaflet et à ses extensions, leaflet-hash, Leaflet.label et Leaflet.markercluster.

        <script src="http://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.3/leaflet.js"></script>
        <script src="js/leaflet-hash.js"></script>
        <script src="js/label.js"></script>
        <script src="js/leaflet.markercluster.js"></script>

Que font ces extensions ?

  • leaflet-hash va ajouter dynamiquement à l’url du navigateur une ancre qui permet aux utilisateurs de repérer une vue spécifique de la carte (par ex. #11/37.5508/-122.2895
  • Leaflet.label permet d’ajouter des étiquettes de texte aux marqueurs sur la carte et couches vectorielles
  • Leaflet.markercluster permet de manipuler des groupes de marqueurs de façon performante avec des animations Javascript

Le script charge ensuite les données de chaque couche, ces données ont été transformées au format Geojson par l’extension installée sous QGIS, ce sont ces données qui sont les sources de notre prototype par défaut.

Si l’on considère la couche qui comporte les informations départementales, le script va faire appel à la couche au format Géojson

<script src="data/exp_Departement.js"></script>

qui a pour contenu

var exp_Departement = {
   "type": "FeatureCollection",
   "crs": { "type": "name", "properties": { "name": "urn:ogc:def:crs:OGC:1.3:CRS84" } },
   "features": [
 { "type": "Feature", "properties": { "color_qgis2leaf": 'none', "border_color_qgis2leaf": '#00ff00', "radius_qgis2leaf": 1.65, "transp_qgis2leaf": 0.75, "transp_fill_qgis2leaf": 1.0,   "id_geofla": 1, "code_dept": "01", "nom_dept": "AIN", "code_chf": "053", "nom_chf": "BOURG-EN-BRESSE", "x_chf_lieu": 8717, "y_chf_lieu": 65696, "x_centroid": 8814, "y_centroid": 65582, "code_reg": "82", "nom_region": "RHONE-ALPES" }, "geometry": { "type": "MultiPolygon", "coordinates": [ [ [ [ 5.831226413621037, 45.938459578293219 ], [ 5.82212102507634, 45.930135953523816 ], [ 5.829154566881813, 45.917237123525148 ], ... } },
                   ...

GeoJson est un format qui permet d’encoder des structures de données géographiques, nous ne nous attarderons pas sur le format, notre objectif est de remplacer ces données statiques par des données fournies dynamiquement par notre serveur cartographique.

Une fois les données chargées, le programme passe au travail d’initialisation et de rendu de la carte.

Initialisation et rendu de la carte

var map = L.map('map', {zoomControl: true}).fitBounds([[36.1453640773, -8.27037589111], [54.0946095281, 6.49685600911]]);

Le script commence par créer un objet carte L.map, il passe en paramètre l’identifiant de l’élément div qui va contenir la représentation ainsi qu’un tableau d’options. L’option zoomControl permet d’indiquer que le contrôle de gestion de l’agrandissement sera intégré à la carte.

La méthode fitBounds() est chaînée à la méthode de création, elle définit la vue de carte qui contient les limites géographiques données avec le niveau de zoom maximum possible.
Les limites sont exprimées en couples de points, latitude et longitude.

var hash = new L.Hash(map);

Cette instruction permet d’utiliser l’extension leaflet-hash pour ajouter dynamiquement une ancre nommée à l’url fonction de la vue en cours qui permet de partager facilement l’état de la vue sur la carte. Elle peut être ignorée.

Le script va ensuite construire la liste des couches à afficher, deux instructions se trouvent dans le code, l’une utilise un featureGroup, l’autre un LayerGroup. Seule la première est exploitée.

var feature_group = new L.featureGroup([]);

Va déclarer un groupe qui permet de manipuler plusieurs couche à la fois au sein d’un seul élément. Ce groupe gère les événements souris.

La première couche sert à la représentation de la carte de base, le fond de carte.

            var basemap_0 = L.tileLayer('http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
                attribution: additional_attrib + '&copy; <a href="http://openstreetmap.org">OpenStreetMap</a> contributors,<a href="http://creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA</a>'});
            basemap_0.addTo(map);

L.tileLayer indique de charger une source de données depuis un serveur de tuiles (images pré-rendues).
La source de données est configurée pour interroger les serveurs de tuiles d’OpenStreetMap.
Le paramètre attribution est une chaîne de caractère représentatif qui sera affiché dans le contrôle qui gère les attributions
Ces attributions font parties des conditions d’utilisation d’OSM.

La couche est ajoutée directement à la carte.

Chaque couche de données est alors crée l’une à la suite de l’autre avec une source de données, un style et d’autres paramètres puis elle est ajoutée au groupe… qui au final n’est pas non plus utilisé ! L’extension sous QGIS manque d’aboutissement, et on doit faire abstraction de beaucoup de code généré. A chaque ajout de couche, la couche est temporisée dans un tableau, toutes les couches de la carte sont retirées et réinsérées dans l’ordre du tableau… passons, nous ne conserverons que les instructions d’ajout de couche à la carte.

Si l’on considère la couche des départements.

            var exp_DepartementJSON = new L.geoJson(exp_Departement, {
                onEachFeature: pop_Departement,
                style: function (feature) {
                    return {color: feature.properties.border_color_qgis2leaf,
                        fillColor: feature.properties.color_qgis2leaf,
                        weight: feature.properties.radius_qgis2leaf,
                        opacity: feature.properties.transp_qgis2leaf,
                        fillOpacity: feature.properties.transp_qgis2leaf};
                }
            });
...
map.addLayer(exp_DepartementJSON);

Ces instructions permettent de créer une couche de données vecteur, rendue côté client. La source de la couche est au format GeoJSON, elle accepte un objet GeoJSON, exp_Departement, qui a été construit lors de l’export du projet QGIS et chargé en amont dans les ressources Javascript.

L.geoJson() étend la classe featureGroup qui permet de gérer les événements souris.

Le style de rendu de la couche vectorielle est déclaré lors de la construction, il est fonction des éléments de la couche vectorielle, il est donc appliqué individuellement à chaque objet de la couche. Par défaut le code récupère le style depuis les propriétés exportées de l’extension Qgis2leaf, par exemple dans le Geojson vous pouvez trouver une propriété border_color_qgis2leaf positionnée à la couleur #00ff00.

Je peux modifier le style pour impacter tous les éléments avec pour l’exemple une couleur de remplissage rouge et une opacité qui permet de superposer cette couche au fond de carte.

                style: function (feature) {
                    return {color: feature.properties.border_color_qgis2leaf,
                        fillColor: '#FF0000',
                        weight: feature.properties.radius_qgis2leaf,
                        opacity: feature.properties.transp_qgis2leaf,
                        fillOpacity: 0.2};
                }

Remarquez alors le popup qui se déclenche au clic souris sur un département.

L’option onEachFeature permet d’indiquer une fonction qui sera appelée à la création d’une caractéristique vectorielle (chaque objet de la couche déclaré dans le format Geojson), ce qui permet d’attacher des événements aux caractéristiques vectorielles. Dans le code généré, une fonction est définie pour chaque couche, ici pop_Departement. Ce code ne fonctionne sur mon navigateur que si je modifie les styles pour l’affichage des départements. Il permet de manipuler chaque élément, par exemple d’afficher la propriété qui correspond au nom du département au clic sur le département sur la carte.

            function pop_Departement(feature, layer) {
                var popupContent = 'Departement ' + feature.properties['code_dept'];
                layer.bindPopup(popupContent);
            }

La fonction prend en argument les données géométriques et la couche vectorielle. Il est alors possible de récupérer les propriétés de l’objet, la méthode bindPopup() attache un événement au clic qui affichera le contenu html en paramètre dans un popup.

Ajout de contrôles à la carte

Un contrôle L.control( est un widget représenté à l’écran. Un contrôle peut être seulement informatif ou bien peut permettre d’interagir avec l’utilisateur. Nous avons vu lors de la création qu’un contrôle qui permettait de manipuler le niveau d’agrandissement était ajouté lors de la déclaration. Si vous jetez un œil à la carte vous verrez en fait quatre autres contrôles : le titre, l’échelle, les attributions et le contrôle qui permet de sélectionner les couches à afficher ou masquer.

Ces contrôles sont initialisés et ajoutés manuellement à la carte.

Contrôle pour affichage du titre

            var title = new L.Control();
            title.onAdd = function (map) {
                this._div = L.DomUtil.create('div', 'info');
                this.update();
                return this._div;
            };
            title.update = function () {
                this._div.innerHTML = '<h2>Etablissements scolaires de Gironde a 50 m parcelle cultivee</h2>Test client web leaflet'
            };
            title.addTo(map);

Le titre n’est pas un contrôle prédéfini, c’est un contrôle basique qui va implémenter l’interface IControl, qui lui donne accès à la méthode onAdd(). L’aspect visuel du contrôle est construit à l’ajout du contrôle à la carte, le but de la méthode d’ajout est de créer tous les éléments du DOM nécessaire pour le contrôle et d’ajouter des écouteurs d’événements, elle renvoie l’élément contenant le contrôle.

L’instruction L.DomUtil.create(‘div’, ‘info’) va créer dans le DOM un élément DIV de classe info.

L’élément est ensuite manipulé pour insérer un titre html.

Le système est souple et accorde beaucoup de liberté, la position du contrôle est quand à elle fournie en option à la création, à l’ajout à la carte, ou encore précisée post création.

La position est paramétrable dans les angles (haut droit, bas droit, haut gauche, bas gauche). Les contrôles sont rendus par des styles css qui permettent de gérer les marges par exemple, remarquez que le contrôle d’affichage du titre et le contrôle de visualisation des couches sont tous deux dans le coin haut droit, et empilés l’un au dessous de l’autre.

Contrôle pour affichage de l’échelle

L.control.scale({options: {position: 'bottomleft', maxWidth: 100, metric: true, imperial: false, updateWhenIdle: false}}).addTo(map);

L.control.scale() est un contrôle prédéfini qui permet l’affichage d’une échelle.

Contrôle pour visualisation des couches

L.control.layers(baseMaps, {"etabparcelinters50m": exp_etabparcelinters50mJSON, "etabssel": exp_etabsselJSON, "etabstamponselec": exp_etabstamponselecJSON, "DepartementWFS": exp_DepartementWFSJSON, "Departement": exp_DepartementJSON}, {collapsed: false}).addTo(map);

L.control.layers() est un contrôle prédéfini qui permet de sélectionner les couches représentées ou pas. Ce contrôle possède une option collapsed qui permet de représenter le contrôle sous forme d’icône cliquable, le contrôle s’ouvre ou se referme au clic.

Contrôle pour affichage des attributions

Le contrôle est créé et positionné par défaut sur la carte à moins de demander explicitement le contraire lors de la création de carte grâce au paramètre attributionControl positionné à faux. Les attributions sont récupérées des options de chaque couche. (La valeur pour la couche OSM est renseignée à la création)

L.control.attribution() permet de créer le contrôle manuellement, afin par exemple de modifier sa position.

Adaptation du code pour une source de données par serveur cartographique

Nous avons a disposition pour le projet deux services qui tournent sous un serveur cartographique QGIS Server.

Les capacités WFS et WMS des services peuvent être interrogées par les url suivantes

http://92.222.27.205/cgi-bin/projet1/qgis_mapserv.fcgi?SERVICE=WFS&VERSION=1.1.0&REQUEST=GetCapabilities
http://92.222.27.205/cgi-bin/projet1/qgis_mapserv.fcgi?SERVICE=WMS&VERSION=1.3.0&REQUEST=GetCapabilities

C’est ce dernier service que l’on va exploiter en premier.

Appel à un service WMS QGIS Server sous Leaflet

Le nom de la couche des départements nous intéresse particulièrement, on peut le récupérer depuis le fichier xml généré par la requête aux capacités du serveur.

<Layer queryable="1">
<Name>Departement</Name>
<Title>Departement</Title>
<CRS>EPSG:4326</CRS>
<EX_GeographicBoundingBox>
<westBoundLongitude>-5.21251</westBoundLongitude>
<eastBoundLongitude>9.63332</eastBoundLongitude>
<southBoundLatitude>41.3141</southBoundLatitude>
<northBoundLatitude>51.1376</northBoundLatitude>
</EX_GeographicBoundingBox>
<BoundingBox CRS="EPSG:4326" maxx="51.1376" minx="41.3141" maxy="9.63332" miny="-5.21251"/>
<Style>
<Name>default</Name>
<Title>default</Title>
<LegendURL>
<Format>image/png</Format>
<OnlineResource xmlns:xlink="http://www.w3.org/1999/xlink" xlink:type="simple" xlink:href="http://92.222.27.205/cgi-bin/projet1/qgis_mapserv.fcgi?SERVICE=WMS&VERSION=1.3.0&REQUEST=GetLegendGraphic&LAYER=Departement&FORMAT=image/png&STYLE=default&SLD_VERSION=1.1.0"/>
</LegendURL>
</Style>
</Layer>

Leaflet est en mesure de charger des sources de données depuis un serveur cartographique via le service WMS, il utilise à cette fin TileLayer.WMS.

Nous pouvons remplacer la source de données Geojson par notre source WMS, la déclaration de la source se fait ainsi :

            var wmsDepts = L.tileLayer.wms("http://92.222.27.205/cgi-bin/projet1/qgis_mapserv.fcgi", {
                layers: 'Departement',
                format: 'image/png',
                transparent: true,
            });

Le serveur cartographique retourne une image, de ce fait on perd les possibilités offertes par la couche vectorielle Geojson. L’avantage d’une image est souvent sa légèreté sur le réseau par rapport à un fichier de données, mais le format vectoriel nous permet d’interagir en javascript avec les objets vectoriels, on peut par exemple sélectionner un objet et modifier son style, supprimer ou ajouter un objet.

Interagir avec les objets peut permettre de créer des cartes plus interactives, en créant des composants de formulaire en widget par exemple on peut imaginer de représenter dynamiquement une carte choroplèthe ou des symboles proportionnels en fonction d’un paramètre sélectionné et d’une requête asynchrone qui va chercher les valeurs d’indicateurs à représenter.

Appel à un service WFS QGIS Server sous Leaflet

Si l’on regarde attentivement le code généré par l’extension sous QGIS, la solution est déjà implémentée mais ne fonctionne pas, le code tente non pas d’accéder au service WMS mais bien au service WFS paramétré sous QGIS. Il va effectuer une requête asynchrone en javascript sur le service WFS (de type GetFeature), le résultat est exploité comme couche vectorielle.

Comment s’y prend-il ?

            var DepartementWFSURL = 'http://92.222.27.205/cgi-bin/projet1/qgis_mapserv.fcgi?SERVICE=WFS&VERSION=1.0.0&REQUEST=GetFeature&TYPENAME=Departement&SRSNAME=EPSG:4326&outputFormat=text%2Fjavascript&format_options=callback%3AgetDepartementWFSJson';
            DepartementWFSURL = DepartementWFSURL.replace(/SRSNAME\=EPSG\:\d+/, 'SRSNAME=EPSG:4326');

Premièrement l’url d’appel au service WFS est construite. Cette url comporte le paramètre format_options qui ne fait pas parti du standard WFS, mais est interprété sur un serveur cartographique Geoserver. Ce paramètre permet au serveur d’encapsuler les données au format JSON dans une fonction javascript, les données retournées sont donc indiquées au format de sortie text-javascript. Ce mécanisme, nommé sous l’appellation JSONP, permet de contourner les appels de requêtes sur domaines croisés.

Si l’on effectue une requête sur notre serveur afin de récupérer des données JSON depuis une page html côté client (en Ajax donc), le navigateur va interdire l’opération si le serveur n’autorise pas explicitement les accès en origine croisée par l’ajout d’une entête de contrôle d’accès.

Notre problème est double. Premièrement QGIS Server possède les capacités pour servir du GeoJSON, qui est simplement du JSON avec une convention, par contre il ne propose pas le format de sortie JSONP qui encapsulerait simplement le GeoJSON dans une fonction Javascript. Il nous resterait comme option de configurer le serveur pour accepter les requêtes d’origine croisées (CORS), cependant le serveur QGIS fonctionne en mode CGI et Apache ne peut pas à ma connaissance ajouter une entête d’autorisation sur la sortie produite par QGIS Server.

La solution lorsque l’on ne peut ni modifier le format de sortie ni configurer le serveur pour ajouter des autorisations est d’utiliser un proxy. Très simplement un script sera hébergé sur le même serveur, il acceptera en paramètres un nom de fonction qui servira à l’encapsulation javascript du format JSON, et l’url qu’il sera en mesure d’interroger depuis le même domaine.

Corrigée pour le serveur QGIS, l’url qui permet de récupérer les objets de la couche des départements est la suivante :

var DepartementWFSURL = 'http://92.222.27.205/cgi-bin/projet1/qgis_mapserv.fcgi?SERVICE=WFS&VERSION=1.0.0&REQUEST=GetFeature&TYPENAME=Departement&SRSNAME=EPSG:4326&outputFormat=GeoJSON';

J’ai créé un simple script en langage PHP qui servira de proxy. Vous pourrez probablement trouver des solutions plus complètes…

<?php
if(!isset($_GET['callback']) || !isset($_GET['url'])) {
  header('status: 400 Bad Request', true, 400);
  exit;
}
extract(parse_url($_GET['url']));       

if (! ( in_array($host, array($_SERVER['HTTP_HOST'], $_SERVER['SERVER_ADDR']) )
      || ( basename($path) != 'qgis_mapserv.fcgi' ) )) {
  header('status: 400 Bad Request', true, 400);
  exit;
}
if (!$content = file_get_contents(filter_var($_GET['url'], FILTER_SANITIZE_URL))) {
  header('status: 400 Bad Request', true, 400);
  exit;
}
header('content-type: application/javascript; charset=utf-8');
header("access-control-allow-origin: *");
echo filter_var($_GET['callback'], FILTER_SANITIZE_ENCODED), '(', $content, ');';

Ce script nommé proxy.php est placé à la racine du répertoire web. Il doit être appelé avec deux paramètres, callback et url.

Le paramètre url doit être encodé…

http://io.gchatelier.fr/proxy.php?callback=getDepartementJson&url=...

Le script effectue quelques contrôles, premièrement il n’aboutit pas si les paramètres ne sont pas renseignés. Il vérifie ensuite que l’url à interroger est sur le même domaine que le serveur, puis vérifie que l’on cherche bien à interroger un script CGI qui correspond à QGIS Server. Le script filtre ensuite les paramètres et exécute la requête. Le contenu de la requête est retourné encapsulé dans la fonction de rappel Javascript avec les bons entêtes : premièrement l’entête d’application javascript (et non pas l’entête d’application JSON), puis l’entête de contrôle d’accès qui autorise le proxy à être interrogé depuis un autre domaine.

Bien… ce problème réglé le code peut fonctionner comme attendu.

            var proxy = 'http://io.gchatelier.fr/proxy.php?callback=getDepartementJson&url=';
            var proxyURL = proxy + encodeURIComponent(DepartementWFSURL);

Construit l’url d’appel à notre proxy en précisant la fonction de rappel et l’url d’origine du service.

            var exp_DepartementWFSJSON = L.geoJson(null, {
                style: function (feature) {
                    return {color: '#00ff00',
                        fillColor: '#ff0000',
                        weight: 1.65,
                        opacity: 0.75,
                        fillOpacity: 0.10};
                },
                onEachFeature: function (feature, layer) {
                    var popupContent = 'Departement ' + feature.properties['code_dept'];
                    layer.bindPopup(popupContent);
                }
            });

Le code commence par créer une couche GeoJSON vide. Le mécanisme est le même que nous avons rencontré précédemment, avec application d’un style (sur mon navigateur le style de remplissage doit être renseigné pour que le popup fonctionne), et application d’une fonction javascript à exécuter sur chaque objet de la couche vectorielle. Comme précédemment on créé un popup au clic souris sur le département pour afficher son code.

Cette couche vide sera remplie ultérieurement par un appel javascript asynchrone qui va récupérer les objets de la couche vectorielle depuis le service WFS.

            var DepartementWFSajax = $.ajax({
                url: proxyURL,
                dataType: 'jsonp',
                jsonpCallback: 'getDepartementJson',
                success: function (response) {
                    L.geoJson(response, {
                        onEachFeature: function (feature, layer) {
                            exp_DepartementWFSJSON.addData(feature)
                        }
                    });
                }
            });

            exp_DepartementWFSJSON.addTo(map);

L’appel Ajax accepte ces paramètres :

  • url : url vers notre proxy qui interrogera le serveur WFS
  • dataType : notre proxy retourne du javascript au format JSONP, on doit préciser le type de données car les entêtes retournées sont application javascript
  • jsonpCallback : le nom de la fonction de rappel dans le fichier JSONP retournée par le proxy
  • success : En cas de réussite une fonction anonyme est exécutée avec la réponse en paramètre, notre GeoJSON… la réponse est parcourue dans une couche L.geoJson qui duplique chaque objet dans la couche vide

Le développeur de l’extension d’export sous QGIS a fait le choix de dupliquer la couche en cas de réussite ce qui permet d’initialiser une couche vectorielle y compris au cas où la requête Ajax échoue.

Nous avons fait le tour des codes sources générés sous une extension QGIS pour la librairie Leaflet. Vous pouvez également vous référer au même document adapté pour la librairie OpenLayers3.

Nous sommes désormais en mesure de construire des applications interactives pour le web depuis des services cartographiques, ce sera l’objet du prochain document de cette série qui détaillera la création d’une carte interactive sous la librairie Leaflet.

Faites découvrir ce billet...Email this to someonePrint this pageShare on FacebookTweet about this on TwitterShare on Google+Share on LinkedIn