Introduction aux Systèmes d'Information Géographiques – Cas d'étude bureaux de vote

Table of Contents

1 Récupération des données

1.1 Téléchargement

Les données se trouvent sur le portail OpenData du gouvernement : https://www.data.gouv.fr/fr/datasets/cartographie-des-grandes-villes-francaises-a-l-echelle-des-bureaux-de-vote/

L'archive contient un certain nombre de fichiers Excel représentant les résultats par bureau de vote, et des fichiers shapefile représentant les emprises spatiales des bureaux de vote :

  • fichier .shp : formes géométriques ;
  • fichier .dbf : données attributaires ;
  • fichier .shx : index de la géométrie ;
  • fichier .prj : WKT représentant le système de référence spatiale (RGF93 en l'occurrence).

1.2 Vérification et visualisation des données

  • License : CC-By-NC -> OK pour nous
  • Visualisation des fichiers Shapefile sous QGIS, avec représentation sous forme colorée des départements.

2 Instanciation d'une base de données spatiale Postgres / PostGIS

2.1 Création de la base de données

[sylvain@msnordlys]~ $ sudo -s
Mot de passe [sudo] pour sylvain :

[root@msnordlys]~ # su postgres
postgres@msnordlys:/home/sylvain$ psql
psql (9.4.5)
Type "help" for help.

postgres=# CREATE DATABASE vote;
CREATE DATABASE
postgres=# ALTER DATABASE vote OWNER TO gis;
ALTER DATABASE

2.2 Activation des fonctions spatiales PostGIS sur la base

postgres@msnordlys:~$ psql -d vote
psql (9.4.5)
Type "help" for help.

vote=# CREATE EXTENSION postgis;
CREATE EXTENSION
vote=# CREATE EXTENSION postgis_topology;
CREATE EXTENSION

2.3 Importation des données des bureaux de vote

Attention, en important les données des bureaux de vote, il y a deux problèmes principaux :

  • l'encodage du fichier attributaire n'est pas UTF8, mais LATIN1
  • shp2pgsql ne prend pas en compte le SRS par défaut. Il faut lui préciser. Pour cela, il faut récupérer le nom du SRS dans le fichier .prj, puis lui associer un SRID PostGIS

Le SRS est le RGF 93 : le SRID est 2154.

L'importation proprement dite :

[sylvain@msnordlys]~/base_cartelec_2007_2010 $ shp2pgsql -s 2154 -W "LATIN1" \
 fond0710.shp bureaux |psql -U gis -d vote  
Password for user gis: 

[...]

2.4 Test avec QGIS

On ajoute une nouvelle couche vecteur PostGIS. Si tout se passe bien, on devrait avoir une couche qui se superpose à la couche précédente.

3 Traitement des données quantitatives des élections 2007

3.1 Création de la table elections07

On jette un œil aux données : c'est au format XLS. Il faudra donc les transformer les données en un format utilisable : CSV (LibreOffice fait ça très bien). Il y a un onglet métadonnées qui décrit les colonnes.

On regarde quelle colonne peut faire le lien entre les données du fichier data07.csv et les bureaux de vote dans la base : a priori, la colonne correspondante dans la table bureaux est la colonne bureau, qui correspond à la colonne bvcom dans le fichier csv. Cette colonne est de type VARCHAR(11). Lors de la création de la table elections07, on utilisera donc le même type de données pour la colonne bvcom.

Remarque : si l'on veut mettre une contrainte de clef étrangère sur bvcom et bureaux pour vérifier la cohérence des données, il faut au préalable que la colonne bureau de la table bureaux soit contrainte à être unique. Il faut donc altérer cette table pour rajouter la contrainte :

ALTER TABLE bureaux ADD CONSTRAINT bureau_unique UNIQUE (bureau);

Ainsi, à partir de la première ligne du fichier CSV, on peut lancer la commande de création de table suivante :

CREATE TABLE elections07 (
    bvcom VARCHAR(11) PRIMARY KEY,
    DATA07 INT,
    INS07T1 INT,
    ABS07T1 INT,
    VOT07T1 INT,
    BLC07T1 INT,
    EXP07T1 INT,
    BAY07T1 INT,
    BES07T1 INT,
    BOV07T1 INT,
    BUF07T1 INT,
    DEV07T1 INT,
    LAG07T1 INT,
    LEP07T1 INT,
    NIH07T1 INT,
    ROY07T1 INT,
    SAR07T1 INT,
    SCH07T1 INT,
    VOY07T1 INT,
    INS07T2 INT,
    ABS07T2 INT,
    VOT07T2 INT,
    BLC07T2 INT,
    EXP07T2 INT,
    ROY07T2 INT,
    SAR07T2 INT,
    FOREIGN KEY (bvcom) REFERENCES bureaux(bureau)
);

3.2 Chargement de la table elections07

Postgres accepte un chargement sous format CSV grâce à la commande COPY. Il nous faut juste supprimer la première ligne du fichier qui correspond à l'en-tête des colonnes. On peut tout faire en une seule commande, soit en utilisant la command tail, soit directement avec la directive HEADER de la commande COPY de SQL :

[sylvain@msnordlys]~/base_cartelec_2007_2010 $ psql -U gis -d vote -c \
'COPY elections07 FROM STDIN WITH (FORMAT csv, HEADER)' < data07.csv
Password for user gis: 
ERROR:  invalid input syntax for integer: "NA"
CONTEXT:  COPY elections07, line 2734, column ins07t1: "NA"

Problème : vu que l'on a utilisé des types entiers, cette commande échoue à cause des valeurs "NA". Il faut donc supprimer ces valeurs et les remplacer par des chaînes vides (afin qu'elles soient considérées comme des valeurs NULL.

[sylvain@msnordlys]~/base_cartelec_2007_2010 $ sed 's/NA//g' data07.csv \
| psql -U gis -d vote -c 'COPY elections07 FROM STDIN WITH (FORMAT csv, HEADER)'
Password for user gis: 
COPY 50578

3.3 Affichage dans QGIS

Pour pouvoir afficher la liste des données électorales dans QGIS, il faut joindre la table elections07 avec la table bureaux. Pour cela, on peut créer une vue (matérialisée si on veut) dans postgres pour pouvoir l'exploiter avec QGIS.

CREATE MATERIALIZED VIEW resBureaux AS (
    SELECT * FROM bureaux JOIN elections07 ON bureaux.bureau = elections07.bvcom
);

Ensuite, on peut utiliser cette vue jointure pour l'afficher dans PostGIS, par exemple :

  • un calque donnant la couleur politique des votes du second tour, avec une expression complexe du type if(roy072t >= sar072t, 1, 0) ;
  • un calque donnant le taux de votes exprimés, avec une expression complexe du type (1 - abst07t2 / insc07t2).

4 Intégrer des départements…

Si l'on veut réaliser les statistiques non pas au niveau des bureaux de vote, mais à un niveau d'agrégation supérieur (départements par exemple), la difficulté principale est d'obtenir le contour de ces départements. Il y a plusieurs manières de procéder.

La première manière est de tenter une agrégation des bureaux de vote pour obtenir le contour des départements. C'est un peu délicat et en tout cas je n'ai pas réussi à le faire.

La seconde manière est de récupérer les données concernant les contours de départements et de les intégrer à notre base. Ça tombe bien, ces contours sont disponibles sur le portail Open Data, de la part de l'IGN (GEOFLA) : https://www.data.gouv.fr/fr/datasets/geofla-departements-30383060/. Un petit coup d'œil sur les métadonnées nous indique que la license est celle d'Etalab (ouverte).

4.1 Chargement des données

Comme ci-avant, un petit coup de shp2pgsql fera l'affaire :

[sylvain@msnordlys]...-ED151/DEPARTEMENT $ shp2pgsql -s 2154 DEPARTEMENT.SHP departements |\
psql -U gis -d vote

4.2 Création de la vue agrégée

Pour accéder aux données par département (avec QGIS par exemple), nous allons créer une vue construite par agrégation et regroupement selon le code de département. Le calcul de cette vue étant relativement lourd, nous allons créer une vue matérialisée (calculée une seule fois).

CREATE MATERIALIZED VIEW dpt AS (
    SELECT codedep, nom_dept, departements.geom, SUM(INS07T2) AS "ins",
        SUM(ABS07T2) AS "abs", SUM(ROY07T2) AS "roy", SUM(SAR07T2) AS "sar"
    FROM elections07 JOIN bureaux ON elections07.bvcom = bureaux.bureau
        JOIN departements ON bureaux.codedep = departements.code_dept
    GROUP BY codedep, nom_dept, departements.geom
);

Grâce à cette vue matérialisée, on peut afficher le résultat des élections dans QGIS.

5 Intégration dans un navigateur Web

5.1 Préparation de la page HTML

Pour intégrer nos données dans une page Web, nous allons utiliser la bibliothèque Javascript Leaflet (libre). D'autres choix sont possibles : Openlayers 2 et Openlayers 3 par exemple.

Le canvas de la page HTML est le suivant :

<!DOCTYPE html>
<html style="width: 100%; height: 100%; padding: 0px; margin: 0px;">
<head>
        <title>Ma belle visualisation de données</title>
        <meta charset="utf-8" />
        <meta name="viewport" content="initial-scale=1.0, user-scalable=no" />
        <link rel="stylesheet" href="./css/lib/leaflet.css" />
</head>
<body style="width: 100%; height: 100%; padding: 0px; margin: 0px;">
        <div id="map" style="width: 100%; height: 100%; padding: 0px; margin: 0px;"></div>

        <script src="./js/lib/leaflet.js"></script>
        <script src="./js/lib/jquery-1.10.1.min.js"></script>
</body>
</html>

Ce fichier html affiche une div en pleine page. Cette div est destinée à contenir la carte.

La documentation de Leaflet nous indique que le format vectoriel supporté est principalement le GeoJSON. Nous allons donc convertir nos couches dans ce format. Pour cela, le plus simple est d'utiliser QGIS. Nous allons travailler sur les départements. Attention : Leaflet ne travaille qu'avec des coordonnées en WGS84. Il nous faut donc convertir les données dans ce SRS à la création du geojson.

5.2 Premier affichage Javascript

Le plus simple pour charger des données au format (Geo)JSON est de les importer directement dans la page sous forme de variable javascript. Pour cela, on modifie le fichier précédemment créé (disons ./js/departements.geojson) en ajoutant

var resultats = 

en début de fichier, sans oublier le point-virgule en fin de ficher.

On l'importe en ajoutant la ligne :

<script src="./js/departements.geojson"></script>

dans le fichier HTML.

Passons maintenant à la partie Javascript à proprement parler. On crée un script ./js/vote.js, que l'on importe dans le fichier HTML (après le div contenant la carte) :

<script src="./js/vote.js"></script>

Dans ce fichier, on ajoute dans un premier temps les commandes permettant de créer la carte et d'y inclure un calque vectoriel contenant les données. Pour cela, il nous faut savoir où centrer la vue. Le plus simple pour le savoir est de calculer les coordonnées du centroïde de la France métropolitaine :

SELECT ST_AsText(ST_Transform(ST_Centroid(ST_Collect(geom)), 4326)) FROM departements;

Réponse :

                st_astext                 
------------------------------------------
 POINT(2.57701734441836 46.5111411237982)

Voici donc le code Javascript :

var map = L.map('map').setView([46.511, 2.577], 6);
L.geoJson(resultats).addTo(map);

5.3 Un peu de couleur…

L.geoJson(resultats, {
    style: function(feature) {
        var color = "#1f78b4";
        if (parseInt(feature.properties.roy) >= parseInt(feature.properties.sar)) {
            color = "#fb9a99";
        }
        return ({
            fillColor: color,
            color: "#ffffff",
            weight: 1,
            fillOpacity: 1
        });
    },   
}).addTo(map);

5.4 On superpose des couches

Si l'on veut ajouter la carte de l'abstention…

L.geoJson(resultats, {
    style: function(feature) {
        var level = 4.0 * feature.properties.abs / feature.properties.ins;
        var color = "rgb(" + (Math.floor(level * 255)) + ", " 
           + (Math.floor(level * 255)) + ", " + (Math.floor(level * 255)) + ")";
        return ({
            fillColor: color,
            color: "#ffffff",
            weight: 1,
            fillOpacity: 0.7
        });
    },   
}).addTo(map);

Avec des contrôles permettant de montrer ou cacher les couches :

var layer1 = L.geoJson(resultats, {
[...]
});

var layer2 = L.geoJson(resultats, {
[...]   
});

var baseMaps = [];
var overlayMaps = {
    "Répartition des votes": layer1,
    "Abstention": layer2
};
L.control.layers(baseMaps, overlayMaps).addTo(map);
map.addLayer(layer1); // Pour afficher initialement la couche 1

5.5 On ajoute un fond de carte…

var CartoDB_Positron = L.tileLayer('http://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png', {
        attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <a href="http://cartodb.com/attributions">CartoDB</a>',
        subdomains: 'abcd',
        maxZoom: 19
});

var baseMaps = {"Fond de carte":  CartoDB_Positron};
map.addLayer(CartoDB_Positron);

5.6 Un peu d'interaction ?

Ajoutons une infobox au bas de la page html :

<div id="infoBox" style="width: 300px; height: 300px; padding: 10px; margin: 0px; position: absolute; bottom: 10px; left: 10px; z-index: 100;"></div>

Le passage de la souris sur un département va provoquer un changement de style du département en question, et va afficher les informations détaillées dans l'infobox.

function onEachFeature(feature, layer) {
    var infoContent = "<p><strong>" + feature.properties.nom_dept + "</strong></p><ul>"
    infoContent += "<li>S. Royal : "
      + (Math.round(1000 * feature.properties.roy / feature.properties.ins) / 10) + " %</li>"
    infoContent += "<li>N. Sarkozy : "
      + (Math.round(1000 * feature.properties.sar / feature.properties.ins) / 10) + " %</li>"
    infoContent += "<li>Abstention : "
      + (Math.round(1000 * feature.properties.abs / feature.properties.ins) / 10) + " %</li>"
    infoContent += "</ul>"

    layer.on('mouseover', function (e) {
        $("#infoBox").html(infoContent);
        layer.setStyle({
            weight: 1,
            fillOpacity: 1,
            opacity: 1
        });
    });
    layer.on('mouseout', function (e) {
        $("#infoBox").html("");
        layer.setStyle({
            weight: 1,
            fillOpacity: 0.5,
            opacity: 0.5
        });
    });
}

Ne pas oublier d'ajouter la propriété onEachFeature sur le calque :

var layer1 = L.geoJson(resultats, {
    style: function(feature) {
    [...]
    },
    onEachFeature: onEachFeature
});

6 Des navigateurs et des bureaux de vote

Malheureusement, le fichier complet des résultats par bureau de vote fait 268 Mo environ. Autant dire que le chargement dans une page web n'est pas raisonnable…

Il faudra donc utiliser une autre technique : travailler par exemple côté serveur et envoyer du raster.

6.1 Installation du serveur géographique

Pour pouvoir traiter les données côté serveur, nous allons utiliser un outil dédié à la gestion de données géographiques : un serveur géographique. Il en existe deux principaux : MapServer et GeoServer. Le plus simple à installer et à utiliser est probablement Geoserver. C'est celui-ci que nous allons utiliser.

L'installation est finalement assez simple, car il s'agit d'une application Web distribuée sous forme de fichier War interprétable avec un serveur de servlets / JSP du type Tomcat ou Jetty.

Geoserver peut être téléchargé sous deux formes : fichier war à exécuter dans un conteneur de servlets quelconque, ou alors application autonome (en fait d'application autonome, il s'agit en fait d'une application qui embarque le fichier war et le conteneur de servlets Jetty 6. Pour lancer l'application autonome :

JAVA_HOME=/usr/lib/jvm/default-java/ ./startup.sh

Nous allons choisir ici d'installer notre propre conteneur de servlets et de déployer le fichier war dans ce conteneur.

Note importante : Tomcat semble avoir de gros problèmes de performances lors du rendu de couches faisant appel à un volume important de données. Nous allons plutôt utiliser Jetty, version 8 (pas moins, car sinon la configuration CORS sera difficile – voir plus loin).

Le déploiement du fichier war se fait sans problème : il suffit de le copier dans le répertoire /var/lib/jetty8/webapps. L'application est alors accessible à l'URL http://localhost:8080/geoserver.

Seul problème : vu que l'application est redéployée à chaque redémarrage de Jetty (donc a fortiori au redémarrage du serveur), toutes les modifications – ajout d'entrepôts, de couches, etc. – sont perdues d'une fois sur l'autre. Afin de pallier ce problème, il faut configurer le répertoire de sauvegarde des données du geoserver de manière adéquate.

  1. On crée un répertoire de sauvegarde : /var/lib/geoserver_data par exemple.
  2. On extrait les fichiers de geoserver.war
[sylvain@msnordlys]~/Téléchargements/Firefox/geoserver/ $ jar xvf geoserver.war
  1. On ajoute les lignes suivantes dans la balise <web-app> du fichier WEB-INF/web.xml :
<context-param>
   <param-name>GEOSERVER_DATA_DIR</param-name>
    <param-value>/var/lib/geoserver_data</param-value>
</context-param> 
  1. On recrée l'archive :
[sylvain@msnordlys]~/Téléchargements/Firefox/geoserver/ $ jar cvf geoserver.war .

6.2 Opération préliminaire côté Postgres

À cause d'un bug (?), Geoserver ne semble pas prendre en compte les vues matérialisées… Nous allons donc convertir nos vues matérialisées en tables réelles. Heureusement, Postgres sait faire ça très bien.

elections=> CREATE TABLE resbureaux AS SELECT * FROM resultatsbureaux;
SELECT 50578
elections=> CREATE TABLE resdepartements AS SELECT * FROM resultatsdepartements;
SELECT 96

Mise-à-jour : En fait, il semble que le bug n'existe plus sous Geoserver 2.8 + Jetty. Cette étape n'est donc a priori pas indispensable.

6.3 Configuration d'une nouvelle couche

Il faut tout d'abord configurer un nouvel entrepôt de données de type PostGIS (éventuellement en ajoutant au préalable un nouvel espace de travail).

Une fois l'entrepôt créé, on peut directement choisir de publier la couche qui va bien. Une petite prévisualisation de la couche (long !) nous montre qu'il a bien réussi à la charger.

6.4 Intégration dans Leaflet

On peut vérifier que tout fonctionne bien en reprenant notre page web Leaflet et en intégrant notre couche WMS.

var layer1 = L.tileLayer.wms("http://localhost:8080/geoserver/vote/wms", {
    layers: 'vote:resbureaux',
    format: 'image/png',
    transparent: true,
    attribution: "Données des élections 2007 © Cartelec"
});

Tout se passe bien normalement, la carte des départements apparaît en superposition sur notre fond de carte Mapbox.

6.5 Le fichier de style

Tout ceci est très intéressant, mais peu informatif. Pour que la carte ait de la valeur, il faut que l'on soit capable de distinguer les départements selon le résultat, donc d'ajouter de la couleur, comme dans QGIS. Pour cela, il y a deux solutions :

  • soit on renvoie un format vectoriel (WFS) et on style tout ça directement à l'aide des propriétés CSS au niveau du client ;
  • soit, si l'on se tient au renvoi de données matricielles (WMS), il faut ajouter le style à la couche au niveau du serveur.

Nous allons utiliser la seconde solution (car encore une fois, notre objectif est de renvoyer des tuiles matricielles, pour éviter de saturer notre client d'informations vectorielles. Pour ajouter du style à une couche dans GeoServer, il faut passer par un fichier SLD (Styled Layer Descriptor).

Voici un aperçu d'un fichier de style possible :

<?xml version="1.0" encoding="UTF-8"?>
<StyledLayerDescriptor version="1.0.0"
                       xsi:schemaLocation="http://www.opengis.net/sld StyledLayerDescriptor.xsd"
                       xmlns="http://www.opengis.net/sld"
                       xmlns:ogc="http://www.opengis.net/ogc"
                       xmlns:xlink="http://www.w3.org/1999/xlink"
                       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <!-- a Named Layer is the basic building block of an SLD document -->
  <NamedLayer>
    <Name>Deuxième tour</Name>
    <UserStyle>
      <Title>Deuxième tour</Title>
      <Abstract>Un style dédié à l'affichage des résultats du deuxième tour</Abstract>
      <FeatureTypeStyle>
        <Rule>
          <Name>Sarkozy</Name>
          <Title>Nicolas Sarkozy majoritaire</Title>
          <ogc:Filter>
            <ogc:PropertyIsLessThan>
              <ogc:PropertyName>roy07t2</ogc:PropertyName>
              <ogc:PropertyName>sar07t2</ogc:PropertyName>
            </ogc:PropertyIsLessThan>
          </ogc:Filter>
          <PolygonSymbolizer>
            <Fill>
              <CssParameter name="fill">#1f78b4</CssParameter>
            </Fill>
            <Stroke>
              <CssParameter name="stroke">#1f78b4</CssParameter>
              <CssParameter name="stroke-width">0.3</CssParameter>
            </Stroke>
          </PolygonSymbolizer>
        </Rule>
        <Rule>
          <Name>Royal</Name>
          <Title>Ségolène Royal majoritaire</Title>
          <ogc:Filter>
            <ogc:PropertyIsGreaterThan>
              <ogc:PropertyName>roy07t2</ogc:PropertyName>
              <ogc:PropertyName>sar07t2</ogc:PropertyName>
            </ogc:PropertyIsGreaterThan>
          </ogc:Filter>
          <PolygonSymbolizer>
            <Fill>
              <CssParameter name="fill">#fb9a99</CssParameter>
            </Fill>
            <Stroke>
              <CssParameter name="stroke">#fb9a99</CssParameter>
              <CssParameter name="stroke-width">0.3</CssParameter>
            </Stroke>
          </PolygonSymbolizer>
        </Rule>     
        <Rule>
          <Name>Egalité</Name>
          <Title>Autant de voix pour chaque candidat</Title>
          <ogc:Filter>
            <ogc:PropertyIsEqualTo>
              <ogc:PropertyName>roy07t2</ogc:PropertyName>
              <ogc:PropertyName>sar07t2</ogc:PropertyName>
            </ogc:PropertyIsEqualTo>
          </ogc:Filter>
          <PolygonSymbolizer>
            <Fill>
              <CssParameter name="fill">#fb9a99</CssParameter>
            </Fill>
          </PolygonSymbolizer>
          <PolygonSymbolizer>
            <Fill>
              <GraphicFill>
                <Graphic>
                  <Mark>
                    <WellKnownName>shape://slash</WellKnownName>
                    <Stroke>
                      <CssParameter name="stroke">#1f78b4</CssParameter>
                      <CssParameter name="stroke-width">3</CssParameter>
                    </Stroke>
                  </Mark>
                  <Size>8</Size>
                </Graphic>
              </GraphicFill>
            </Fill>
          </PolygonSymbolizer>
        </Rule>
        <Rule>
          <Name>Pas de données</Name>
          <Title>Données inexistantes</Title>
          <ogc:Filter>
            <ogc:PropertyIsNull>
              <ogc:PropertyName>roy07t2</ogc:PropertyName>
            </ogc:PropertyIsNull>
          </ogc:Filter>
          <PolygonSymbolizer>
            <Fill>
              <CssParameter name="fill">#dddddd</CssParameter>
            </Fill>
          </PolygonSymbolizer>
        </Rule> 
      </FeatureTypeStyle>
    </UserStyle>
  </NamedLayer>
</StyledLayerDescriptor>

Une petite visualisation de la couche nous montre que c'est OK. On vérifie également dans notre page HTML / Leaflet -> magnifique…

7 Un peu d'interaction

7.1 Récupération des données d'un bureau de vote

Cette carte est très jolie, mais ce serait mieux avec un peu d'interaction, notamment avec une petite fonction qui nous permettrait de récupérer de l'information concernant un bureau de vote, lorsque l'on clique sur une zone de la carte. Ça tombe bien, le standard WMS a une opération qui permet de faire exactement cela : getFeatureInfo. On peut d'ailleurs voir comment ça marche en regardant l'aperçu OpenLayers de Geoserver pour une couche donnée : lorsque l'on clique sur une zone de la carte, une requête AJAX est lancée pour aller récupérer les données à l'aide de l'opération getFeatureInfo en WMS.

7.2 Construction de l'appel AJAX

Le principe est simple : il faut lier une fonction à l'événement de type clic de souris sur la couche WMS. Cette fonction est en charge de réaliser un appel AJAX sur le Geoserver, avec les bons paramètres, pour récupérer les informations sur le feature situé sous le pointeur de la souris.

Ça tombe bien, quelqu'un a déjà codé exactement ce que nous voulons faire en étendant TileLayer.WMS. C'est ici : https://gist.github.com/rclark/6908938.

Nous allons modifier quelques petites choses pour satisfaire nos besoins. Tout d'abord, on change le format de sortie (HTML, ce n'est pas facile à traiter) :

info_format: 'application/json'

Il faut également modifier la première ligne de la fonction success car la valeur retournée ne sera pas de type string mais de type object.

var err = typeof data === 'object' ? null : data;

Enfin, c'est la fonction showGetFeatureInfo que l'on va modifier afin que dans un premier temps elle renvoie sur la console le contenu des données :

if (err) { console.log(err); return; } // do nothing if there's an error
console.log(content);

Il faut enfin ajouter l'inclusion des fichiers Javascript dans le code HTML :

<script src="L.TileLayer.BetterWMS.js"></script>

Puis modifier le type de la couche que nous incluons sur la carte (dans le fichier ./js/vote.js)

var layer1 = L.tileLayer.betterWms("http://localhost:8080/geoserver/vote/wms", {

Testons… Ça ne fonctionne pas. Un coup d'œil sur la console Javascript nous donne un message abscons sur CORS. La section suivante est dédiée à la résolution de ce problème.

7.3 Cross-Origin Resource Sharing

Afin que les requêtes AJAX soient correctement traitées et non refusées par le serveur, il faut configurer notre serveur pour qu'il autorise le Cross-Origin Resource Sharing 1. On peut le faire globalement au niveau du conteneur de servlets, ou plus localement au niveau de chaque application. Nous allons choisir cette deuxième option.

Pour cela, il faut décompresser à nouveau le fichier geoserver.war et éditer à nouveau le fichier WEB-INF/web.xml pour ajouter, à l'intérieur de la balise <web-app> le code XML suivant :

<filter>
    <filter-name>cross-origin</filter-name>
    <filter-class>org.eclipse.jetty.servlets.CrossOriginFilter</filter-class>
    <init-param>
        <param-name>allowedOrigins</param-name>
        <param-value>*</param-value>
    </init-param>
    <init-param>
        <param-name>allowedMethods</param-name>
        <param-value>GET,POST,HEAD</param-value>
    </init-param>
    <init-param>
        <param-name>allowedHeaders</param-name>
        <param-value>X-Requested-With,Content-Type,Accept,Origin</param-value>
    </init-param>
</filter>
<filter-mapping>
    <filter-name>cross-origin</filter-name>
    <url-pattern>/*</url-pattern>
</filter-mapping>

Il n'est pas dit que tout soit réellement nécessaire, mais cette configuration fonctionne pour Jetty 8.

7.4 Affichage des données

Nous avons maintenant un moyen de récupérer les données concernant un bureau de vote, suite à un clic sur la zone. Nous allons maintenant tâcher de les exploiter.

Pour cela, dans un premier temps, nous allons faire une fonction qui reformate légèrement les données à notre sauce :

var results = [];

function buildResultsFromData(data) {
    results[0] = {"pourcentage": data.features[0].properties.roy07t2 / data.features[0].properties.ins07t2,
                  "couleur": "#fb9a99", "cat": "S. Royal", "population": data.features[0].properties.roy07t2};
    results[1] = {"pourcentage": data.features[0].properties.sar07t2 / data.features[0].properties.ins07t2,
                  "couleur": "#1f78b4", "cat": "N. Sarkozy", "population": data.features[0].properties.sar07t2};
    results[2] = {"pourcentage": data.features[0].properties.abs07t2 / data.features[0].properties.ins07t2,
                  "couleur": "#ffffff", "cat": "Abstention", "population": data.features[0].properties.abs07t2};
    results[3] = {"pourcentage": data.features[0].properties.blc07t2 / data.features[0].properties.ins07t2,
                  "couleur": "#cccccc", "cat": "Blanc ou Nul", "population": data.features[0].properties.blc07t2};
}

Nous allons maintenant afficher les résultats dans notre infoBox de tout-à-l'heure. Il suffit pour cela de modifier notre fonction showGetFeatureInfo.

showGetFeatureInfo: function (err, latlng, content) {
    if (err) { console.log(err); return; } // do nothing if there's an error

    buildResultsFromData(content);

    var str = "<p>" + content.features[0].properties.nom + "</p><ul>";
    for (l in results) {
          str += "<li>" + results[l].cat + " (" + (Math.round(1000 * results[l].pourcentage) / 10) + "%)";
    }
    str += "</ul>";
    $("#infoBox").html(str);      
}

Et ça marche, encore une fois.

8 La cerise graphique sur le gâteau

Tout ce texte est très informatif, mais bien triste. Nous allons tâcher de faire comme les pros, à savoir, utiliser des graphismes glacés et sophistiqués, tant il est vrai que la vie serait plus triste sans graphisme.

Pour cela, nous allons utiliser une bibliothèque à graphismes glacés et sophistiqués : D3. D3 permet de lier des données (arrivant sous la forme d'un tableau) à des éléments graphiques de type SVG, construits dynamiquement. Nous allons faire un beau diagramme en Donut.

Commençons par ajouter la bibliothèque D3 dans notre fichier HTML, ainsi qu'un fichier que nous appellerons piechart.js; et qui servira à construire notre donut à partir des données.

<script src="./js/lib/d3.v3.min.js"></script>
<script src="./js/piechart.js"></script>        

Nous enlevons en outre notre infoBox qui ne nous sera plus d'aucune utilité ici, puisqu'elle sera ajoutée dynamiquement par le code Javascript.

Voici le code de piechart.js, qui se passe de commentaires.

var width = 300,
    height = 300,
    radius = Math.min(width, height) / 2;

var arc = d3.svg.arc()
    .outerRadius(radius - 10)
    .innerRadius(radius - 70);

var pie = d3.layout.pie()
    .sort(null)
    .value(function(d) { return d.population; });

var svg = d3.select("body").append("svg")
    .attr("width", width)
    .attr("height", height)
    .style("position", "absolute")
    .style("bottom", "10px")
    .style("left", "10px")
    .style("z-index", "10")
    .style("opacity", "0.9")
    .style("background", "rgba(255, 255, 255, 0.5)")
    .style("font-family", "Ubuntu, Trebuchet, Verdana, Sans Serif")
    .style("font-size", "80%")
    .style("color", "#fff");

var svgg = svg.append("g")
    .attr("transform", "translate(" + width / 2 + "," + height / 2 + ")");

svg.append("g")
    .attr("transform", "translate(" + width / 2 + "," + height / 2 + ")")
    .append("text")
    .attr("class", "nomBureau")
    .attr("dy", ".35em")
    .style("text-anchor", "middle")
    .style("color", "#fff")
    .text("(aucun bureau sélectionné)");

function displayPieChart(data, nomBureau) {
    svg.select(".nomBureau").text(nomBureau);

    console.log(data);

    data.forEach(function(d) {
        d.population = +d.population;
    });

    var arcs = svgg.selectAll(".arc")
        .data(pie(data));

    var labels = svgg.selectAll(".label")
        .data(pie(data));

    var ga = arcs.enter().append("g");
    var gl = labels.enter().append("g");

    ga.attr("class", "arc").append("path");
    gl.attr("class", "label").append("text");

    arcs.select("path")
        .style("fill", function(d) { return d.data.couleur; })
        .style("stroke", "#fff")    
        .attr("d", arc);

    labels.select("text")
        .attr("dy", ".35em")
        .style("text-anchor", "middle")
        .style("z-index", "11")
        .text(function(d) { return d.data.cat + " (" + (Math.floor(1000 * d.data.pourcentage) / 10) + "%)"; })
        .transition().duration(750) 
        .attr("transform", function(d) { return "translate(" + arc.centroid(d) + ")"; });    
}

Enfin, nous modifions notre fonction showGetFeatureInfo pour qu'elle appelle la fonction displayPieChart au lieu d'afficher dans l'infoBox.

displayPieChart(results, content.features[0].properties.nom + " - " + content.features[0].properties.bureau + "");

9 Une fonction de recherche

Accéder aux informations concernant un bureau en cliquant sur la carte est très bien, mais peut être assez rapidement fastidieux. Une petite fonction de recherche par nom de bureau serait fort utile.

Commençons par mettre en place le code HTML :

<div id="research" style="width: 100%; height: 30px; padding: 5px 0px; margin: 0px; position: absolute; top: 0px; right: 0px; background: rgba(255, 255, 255, 0.5); color: #666; text-align: center; z-index: 1000;">
  <input type="text" style="width: 300px;height: 20px;" id="researchbox" onkeydown="if (event.keyCode == 13) search();"/>
  <button onclick="search()">Rechercher</button>
</div>

Ce code affiche une barre de recherche en haut de la page, qui déclenche l'appel à une fonction search() lorsque le bouton est cliqué (ou que l'on appuie sur « entrée »).

9.1 Du WFS

Le principe de la fonction est similaire à celui du getFeatureInfo de tout-à-l'heure : on va chercher les informations concernant une feature (une entité géographique de la couche). Cependant, il y a une différence notable : on ne filtre plus selon le point cliqué, mais selon le nom. Pour cela, le plus simple est de passer par le protocole WFS. Ce protocole renvoie des données vectorielles (exactement ce qu'il nous faut), éventuellement en filtrant sur des conditions. On peut spécifier directement ces conditions en utilisant une fonction de filtre, un CQL_FILTER.

Voici exactement comment est implantée la fonction search :

function search() {
    var str = $("#researchbox").val();
    var url = getWFSURL('http://localhost:8080/geoserver/elections/ows', str);

    $.ajax({
        url: url,
        success: function (data, status, xhr) {
            if (data.features.length > 0) {
                var nom = data.features[0].properties.nom;

                $("#researchbox").val(nom);
            }
        },
        error: function (xhr, status, error) {
            return [];  
        }
    });
}

function getWFSURL(url, str) {
    return url + "?service=WFS&version=1.0.0&request=GetFeature&typeName=vote:resbureaux"
        + "&maxFeatures=50&srsName=EPSG:4326&outputFormat=application%2Fjson&CQL_FILTER=nom%20LIKE%20%27%25"
        + encodeURI(str.replace(/'/g, "''")) + '%25%27';
}

Dans un premier temps, on se contente de remplacer dans la boîte de recherche le texte par le premier nom concordant trouvé 2. On peut voir dans la fonction getWFSURL la construction de la requête WFS. Si l'on fait un console.log() de la valeur retournée par la requête, on se rend compte qu'il s'agit bien d'un ensemble d'entités géographiques au format GeoJSON.

9.2 De l'interaction avec la carte

Ce n'est pas tout-à-fait terminé. Dans l'absolu, on aimerait bien, en plus, afficher le diagramme Donut correspondant au bureau de vote, et si possible centrer et zoomer la carte sur ce bureau de vote. Pour le premier besoin, c'est assez simple. Il suffit d'ajouter l'appel à

buildResultsFromData(data);
displayPieChart(results, data.features[0].properties.nom + " - " + data.features[0].properties.bureau + "");            

dans la fonction search(). Nous avons toutes les données nécessaires, dans le même format que l'appel WMS de tout-à-l'heure.

Pour ce qui est du centrage de la carte, c'est légèrement plus compliqué, car les données géométriques nous arrivent sous la forme de multipolygones, donc de séries de point. Ce qui nous intéresserait serait d'avoir la boîte englobante, ou le centroïde du bureau de vote. En outre, si l'on jette un œil à ces points, ils sont dans le système de référence spatiale natif, à savoir RGF93. Il faut donc reprojeter en WGS84. Deux possibilités :

  1. On traite les données à la main dans le Javascript, en calculant à la main (ou en utilisant une bibliothèque) le centroïde et en reprojetant à la main. Pas simple.
  2. On calcule les centroïdes côté serveur, et on renvoie directement ces données.

On va choisir la solution 2, plus simple à mettre en œuvre vu que l'on a accès au serveur. On va donc créer une vue SQL pour récupérer les centroïdes 3:

CREATE VIEW centroides AS (
  SELECT ST_Centroid(geom), nom, sar07t2, roy07t2, ins07t2, abs07t2, blc07t2, bureau FROM resbureaux
);

Il faut ensuite créer la couche qui va bien pour accéder à cette vue depuis Geoserver. Nul besoin de styler cette couche, car elle ne nous servira qu'en WFS. On modifie l'URL WFS pour accéder à la bonne couche, et on peut centrer la carte de cette manière :

var centroid = data.features[0].geometry.coordinates;
map.setView([centroid[1], centroid[0]], 13, animate=true);              

Attention, petite subtilité : il faut inverser l'ordre des arguments utilisés dans l'appel à setView car les coordonnées ne sont pas exprimées dans le même ordre…

Footnotes:

1

On pourrait faire autrement et faire du JSON-padding, mais nul besoin ici, vu que l'on peut accéder à la configuration du serveur.

2

Dans l'absolu, le mieux serait de faire de l'autocomplétion et de proposer à l'utilisateur de choisir entre diverses possibilités, mais restons simple…

3

Cette vue peut être créée au sein de Geoserver, directement.

Date: 2015-11-05 jeu. 00:00

Author: Sylvain B.

Created: 2022-01-25 mar. 16:21

Validate