Afficher des données de PostGIS sur Google Maps et OpenStreetMap via GeoJSON

Tout le monde a déjà ouvert une carte sur un site internet que ce soit :

  • pour réserver un logement sur Airbnb ;
  • pour choisir son point relais ou son magasin sur un site e-commerce ;
  • consulter le marché immobilier sur DVF etalab ;
  • ou simplement pour trouver son itinéraire.

A travers cet article, je vais vous montrer simplement comment sauvegarder et restaurer des données Géospatiales avec PostGIS et les afficher sur votre site en utilisant Google Maps ou OpenStreetMap à travers le format de données GeoJSON.

Système d'information géographique

Définition

Un Système d'Information Géographique (SIG) ou en anglais "Geographic information system (GIS)", est un système d'information qui stocke, traite et affiche des données géographiques.

Fournisseurs de carte

L'affichage de données géographiques se fait communément sur une carte.
Il vous faudra choisir un fournisseur de carte pour le fond.
Il en existe plusieurs, des payants comme des libres de droit.

Pour les fournisseurs payants, le leader c'est Google Maps,
ensuite viennent MapBox, Michelin, Bing Maps...

Pour les versions libres, la plus connue est OpenStreetMap (OSM).

Base de donnée Géospatiale

La plupart des systèmes de gestion de base de données (SGBD) ont un plugin GIS. Le plus populaire est PostGIS.

Il existe aussi H2Gis pour les tests en java par exemple.

Format de données GeoJson et KML

Il existe deux formats standards permettant d'échanger des données géographiques, ce sont le GeoJson et le KML.

GeoJson est un format simple et libre basé sur le JSON.

Le format GeoJSON peut encapsuler 3 types d'entité :

  • Géométrie : est un type abstrait qui permet de définir des points, des courbes et des surfaces ;
  • Particularité (Feature) : l'agrégation d'une géométrie et de propriétés ;
  • Collection de Particularités (FeatureCollection).

Géométries de GeoJSON : point, chaîne de lignes, polygone, multiple points, multiple chaînes de lignes, multiple polygone, multiple géométries

Il est à noter que seuls les systèmes GIS acceptent que les polygones aient des trous.

Un modèle JAXB est disponible dans la dépendance sf-geojson pour java.
Un modèle TypeScript est disponible pour GeoJson.

KML est une alternative plus ancienne basée sur le format XML créé par Google pour Google Earth.
Il est plus complexe, car il sert aussi à faire de la modélisation en 3D.

GeoJson sera souvent préférable et suffisant avec son format JSON, son côté libre, et sa géométrie orientée planaire.

De manière générale, les fournisseurs de carte et les bases de données géospatiales gèrent de façon native les formats KML et GeoJSON.

Exemple d'utilisation de PostGIS

Créer des colonnes Géospatiales

Dans les bases de données Géospatiale, on peut définir des colonnes de type geometry ou geography, et de leurs sous-types.

Les géométries dans PostGIS sont des représentations cartésiennes sans référentiel par défaut (SRID=0).

Quant aux géographies, ce sont des représentations projetées dans un référentiel sphérique terrestre (SRID=4326).

Quand il est question de choisir l'un ou l'autre, le type geography semble plus pertinent.
Cependant les calculs basés sur le type geometry sont plus performants et offrent plus de possibilités.
Les géométries sont conseillées par PostGIS quand la rotondité de la Terre entre en jeu, c'est-à-dire au niveau terrestre,
comme calculer la distance d'un vol entre Melbourne et New York.

Exemple : Considérons une table ville.

CREATE TABLE town (
  id VARCHAR(255) NOT NULL PRIMARY KEY,
  label VARCHAR(255) NOT NULL,
  surface geometry NOT NULL,
  city_hall_position geometry(Point)
);

ou

CREATE TABLE town (
  id VARCHAR(255) NOT NULL PRIMARY KEY,
  label VARCHAR(255) NOT NULL,
  surface geography NOT NULL,
  city_hall_position geography(Point)
);

Index spatiaux

Pour des raisons de performance, il est conseillé d'utiliser les index spatiaux sur les géométries et sur les géographies pour optimiser certains calculs de la base de données.

Les index spatiaux de PostGIS utilisent une structure en R-Arbre.
C'est globalement un index sous la forme d'un arbre découpé en un rectangle très grand à la racine et en rectangles de plus en plus petit quand on tend vers les feuilles.
Ainsi chacune des géométries ou géographies est ainsi reliée au plus petit rectangle la contenant intégralement.

CREATE INDEX town__surface__idx 
ON town
USING GIST (surface);

CREATE INDEX town__city_hall_position__idx
ON town
USING GIST (city_hall_position);

Modification de données Géospatiales

Pour modifier les données géospatiales, il faut utiliser de préférence les routines PostGIS ST_GeomFromText, ST_GeogFromText, ST_GeomFromGeoJSON, ST_GeomFromKML...

INSERT INTO town(id, label, city_hall_position, surface)
VALUES (
    '1',
    'Vatican',
    ST_MakePoint(12.452936, 41.903390),
    ST_GeomFromGeoJSON('{
        "type": "MultiPolygon",
        "coordinates": [[[
          [12.458324, 41.902596], [12.458346, 41.901542], [12.457509, 41.901015],
          [12.454569, 41.900329], [12.450857, 41.900616], [12.448754,41.900888],
          [12.447767,41.900856], [12.445815, 41.901942], [12.447853, 41.903315],
          [12.448926, 41.904289], [12.450042, 41.905647], [12.451437, 41.906733], 
          [12.455385, 41.907324], [12.455728, 41.906349], [12.457638, 41.90579],
          [12.457595, 41.903363], [12.458324, 41.902596]
        ]]]
      }')
);

Récupération de géométries ou géographie au format GeoJson

Une géométrie ou une géographie dans PostGIS est un objet limité à 1GB qui peut donc être volumineux.
Plus elle est précise, plus elle est volumineuse, plus elle est longue à transférer.

De manière générale, les jeux de données proviennent de fournisseurs dont la précision est fine, inférieure au mètre prêt.
Cependant quand une carte est affichée dans un navigateur, un pixel peut représenter de 10cm à 20Km selon le zoom et la résolution.

Pour cela, afin d'augmenter les performances, PostGIS fournit des fonctions de dégradation de géométries comme ST_simplify et ST_reducePrecision.
Dans le cas de géographie, il faudra convertir en géométrie.

Combiner une fonction de dégradation à une fonction de désérialisation de géométries comme ST_asText, ST_asGeoJSON ou ST_asKML permet de récupérer rapidement des géométries à afficher dans une carte.

SELECT st_asGeoJSON(ST_reducePrecision(surface,0.001))
FROM town
WHERE id = '1'; 

Ou pour les géographies :

SELECT st_asGeoJSON(ST_reducePrecision(surface::geometry,0.001))
FROM town
WHERE id = '1'; 
{
  "type":"Polygon","coordinates":[[
    [12.458,41.902], [12.458,41.901], [12.455,41.9], [12.451,41.901], [12.449,41.901],
    [12.448,41.901], [12.446,41.902], [12.448,41.903], [12.449,41.904], [12.45,41.906],
    [12.451,41.907], [12.455,41.907], [12.456,41.906], [12.458,41.906], [12.458,41.903],
    [12.458,41.902]
  ]]
}

Il est à noter que le polygone a été réduit de 17 points à 16 points !

Ensuite pour encapsuler la géométrie dans une feature et avoir un GeoJson complet, il faudra passer un enregistrement à st_asGeoJSON.

SELECT st_asGeoJSON(reduce_town.*)
FROM
    (
        SELECT id, label, ST_reducePrecision(surface,0.001)
        FROM town     
        WHERE id = '1'
    ) as reduce_town(id,name,geom);
{
  "type": "Feature",
  "geometry": {
    "type":"Polygon",
    "coordinates":[[
      [12.458,41.902], [12.458,41.901], [12.455,41.9], [12.451,41.901], [12.449,41.901],
      [12.448,41.901], [12.446,41.902], [12.448,41.903], [12.449,41.904], [12.45,41.906],
      [12.451,41.907], [12.455,41.907], [12.456,41.906], [12.458,41.906], [12.458,41.903],
      [12.458,41.902]
    ]]
  }, 
  "properties": {
    "id": "1", 
    "name": "Vatican"
  }
}

L'image ci-dessous illustre la différence entre la géométrie originale aux bords bleus et la géométrie dégradée aux bords marrons.
En utilisant cette technique sur des géométries plus denses, et des zooms ajustés à celles-ci, l'erreur est invisible.

Comparaison de géométrie originale et réduite

Comment utiliser une carte sur une page web ?

Google Maps

Introduction à Google Maps

Pour faire des tests avec Google Maps, il vous faudra une API key !

Comme Google Maps est un service payant, il est conseillé de l'utiliser en asynchrone.
C'est à dire que la balise script pointant vers Google Maps dans votre DOM est ajoutée quand la carte doit être affichée.

Voici un module NPM pour Google Maps en asynchrone qui ajoute une Loader pour charger Google Maps en asynchrone.

npm install --save google-maps

Afficher Google Maps

Pour commencer, je crée ma page HTML dans le fichier google-maps.html qui contiendra ma carte.

<html lang="fr">
    <head>
        <title>Demo Google Maps</title>
        <link rel="stylesheet" type="text/css" href="map.css" />
    </head>
    <body>
        <div id="map">Map is loading</div>
        <script type="module" src="google-maps.ts"></script>
    </body>
</html>

Il est important de définir les dimensions de la carte pour qu'elle soit visible à l'écran,
je crée mon fichier map.css pour afficher la carte sur tout l'écran.

html, body {
  margin: 0;
}

#map {
  width: 100vw;
  height: 100vh;
}

Ensuite, je crée le module google-maps.ts juste en centrant sur Paris pour commercer.

import {Loader, LoaderOptions} from 'google-maps';

const PARIS_CENTER: google.maps.LatLngLiteral = {lat: 48.866667, lng: 2.333333};

const options: LoaderOptions = {};
const loader: Loader = new Loader('YOUR_GOOGLE_MAP_API_KEY', options);

loader.load().then((google) => {
    const map = new google.maps.Map(document.querySelector("#map") as HTMLElement, {
      center: PARIS_CENTER,
      zoom: 8
    });
});

Voici la carte que j'affiche dans mon navigateur :

Google map centré sur Paris

Afficher un GeoJSON dans Google Maps

D'abord, j'ai besoin d'un fichier GeoJSON.
Pour l'exemple, j'ai récupéré un fichier nommé limite-des-quartiers-de-lille-et-de-ses-communes-associees.geojson sur data.gouv.fr que j'ajoute à la racine de mon projet.

Pour charger le GeoJSon, j'ai besoin d'appeler la méthode loadGeoJson avec une URL.

  map.data.loadGeoJson("./limite-des-quartiers-de-lille-et-de-ses-communes-associees.geojson");

Mais appelée seule, elle ne recentre pas ! Je dois glisser la carte puis zoomer pour voir Lille.

GeoJSON de Lille non recentré affiché sur Google Maps

Pour recentrer automatiquement sur le contenu, Google Maps ne propose pas de solution directe, mais propose une méthode fitBounds qui centre sur des limites données en paramètre.

Pour recentrer, je vais appeler la méthode loadGeoJson avec un callback.

Ce callback calculera le plus petit rectangle englobant le GeoJSon chargé en parcourant l'ensemble des points définissants les géométries de celui-ci.

Illustration du calcul du plus petit rectangle avec 7 points

Les limites calculées seront finalement utilisées pour recentrer avec fitBounds.

const map: google.maps.Map = new google.maps.Map(document.querySelector("#map") as HTMLElement);
map.data.loadGeoJson("./limite-des-quartiers-de-lille-et-de-ses-communes-associees.geojson" , null,
    (features: Array<google.maps.Data.Feature>) => {
        const bounds = new google.maps.LatLngBounds();
        features.forEach((feature: google.maps.Data.Feature) => {
            feature.getGeometry().forEachLatLng((point: google.maps.LatLng) => {
                bounds.extend(point);
            });
        });
        map.fitBounds(bounds);
      });

GeoJSON de Lille recentré affiché sur Google Maps

OpenStreetMap avec OpenLayer

Introduction à OpenLayer

OpenLayer (ol) est un framework qui facilite la manipulation de cartes et d'y afficher des données géographiques sur plusieurs fournisseurs tels qu'OpenStreetMap, BingMap, CartoDB...

OpenLayer est basé sur 4 concepts :

  • Source : les sources sont les objets permettant de récupérer des ressources externes (Fournisseur de carte, GeoJson, KML...) ;
  • Vue (View) : la partie de la carte responsable de la zone à afficher (position, zoom) ;
  • Couche (Layer) : les couches sont les représentations graphiques de sources ;
  • Carte (Map) : la carte le composant principal qui contient les autres et qui l'affiche dans l'élément donné.

Pour créer une structure de projet OpenLayer

npm create ol-app nom-du-projet

Pour ajouter OpenLayer dans un projet existant

npm install --save ol

Afficher OpenStreetMap avec OpenLayer

Pour commencer, je crée une page open-street-map.html semblable à celle pour Google Maps.
J'utilise le même CSS pour définir les dimensions de la carte.
Je change le titre de la page et l'URL du module.

<html lang="fr">
    <head>
        <title>Demo OpenStreetMap avec OpenLayer</title>
        <link rel="stylesheet" type="text/css" href="map.css" />
    </head>
    <body>
        <div id="map">Map is loading</div>
        <script type="module" src="open-street-map.ts"></script>
    </body>
</html>

Ensuite, je crée mon module open-street-map.ts.
Celui importe le CSS de OpenLayer qui permet de placer les éléments au-dessus de la carte comme les boutons de zoom.
J'y convertis la longitude et la latitude de Paris dans le référentiel spatial d'OpenLayer.

import "ol/ol.css";

import Map from 'ol/Map.js';
import OpenStreetMap from 'ol/source/OSM.js';
import TileLayer from 'ol/layer/Tile.js';
import View from 'ol/View.js';

import {fromLonLat} from 'ol/proj.js';


const PARIS_CENTER: number[] = fromLonLat([2.333333, 48.866667]);

const map = new Map({
  target: 'map',
  layers: [
    new TileLayer({
      source: new OpenStreetMap(),
    }),
  ],
  view: new View({
    center: PARIS_CENTER,
    zoom: 8,
  }),
});

Le résultat sur mon navigateur est celui-ci :

Open Street map centré sur Paris

Afficher un GeoJSON dans OpenStreetMap avec OpenLayer

Pour afficher les quartiers de Lille sur la carte, je vais ajouter à la carte une couche vectorielle utilisant une source de type GeoJson.
Et je recentre plutôt facilement quand le GeoJSON est chargé.

import {Vector as VectorSource} from 'ol/source.js';
import {Vector as VectorLayer} from 'ol/layer.js';
import GeoJSON from 'ol/format/GeoJSON.js'; 

const vectorSource = new VectorSource({
  url: 'limite-des-quartiers-de-lille-et-de-ses-communes-associees.geojson',
  format: new GeoJSON(),
});

const vectorLayer = new VectorLayer({
  source: vectorSource
});

map.addLayer(vectorLayer);

vectorSource.on('featuresloadend', (vectorSourceEvent:VectorSourceEvent) => {
  map.getView().fit(vectorSource.getExtent(), {padding: [20, 20, 20, 20]});
});

Open Street map centré sur Lille avec les styles par défault

Comme le contraste n'est pas assez net par défaut, je vais modifier ma couche vectorielle vectorLayer qui utilisera une fonction pour définir les styles.

import {Fill, Stroke, Style} from 'ol/style.js';
import * as olColor from 'ol/color.js';

function styleFunction(feature) {
  const FILL_COLORS: string[] = [ "red", "indigo" , "black" , "brown", "green", "purple", "grey", "darkorange", "hotpink", "white" , "salmon", "springgreen"];
  if(! styleFunction.index) {
    styleFunction.index = 0;
  }
  const colorName: string = FILL_COLORS[styleFunction.index++ % FILL_COLORS.length];
  const [r, g, b] = olColor.asArray(colorName);
  const colorWithAlpha = olColor.asString([r, g, b, 0.5]);

  return new Style({
    stroke: new Stroke({
      color: 'black',
      width: 2,
    }),
    fill: new Fill({
      color: colorWithAlpha,
    })
  });
}


const vectorLayer = new VectorLayer({
  source: vectorSource,
  style: styleFunction,
});

Open Street map centré sur Lille avec les styles personnalisés

Conclusion

En utilisant le format GeoJSON comme pivot, pour faire le lien entre votre frontend et PostGIS, il suffit juste qu'un backend transforme les requêtes HTTP en requêtes SQL géospatiales et qu'il soit un passe plat sur le retour entre PostGIS et le front.

Trouvez votre fournisseur de données public (IGN, ...) ou privé (MBI, GeoPostcodes...)!

Et c'est parti, vous avez toutes les briques qu'il vous faut pour ajouter des cartes sur votre site ou votre application !