Je travaille beaucoup avec les tuiles vectorielles qui sont pour moi une véritable révolution dans le monde de la cartographie sur le web.

Dans de nombreux articles que j’ai pu lire sur internet, la génération de ces tuiles se fait systématiquement en passant par une base Postgis. Elles sont générées soit directement par la base soit avec d’autres scripts, mais toujours en requêtant la base.

Vous pouvez lire ici un article de Frédéric Rodrigo de Makina Corpus qui témoigne des challenges pour arriver à prégénérer des tuiles dans un temps raisonnable.

Mais est-ce nécessaire d’utiliser une base de données spatiale pour générer des mvt ?

La meilleure solution pour répondre à cette question c’est de tester … Je vous présente donc mes démarches. J’ai utilisé les données du cadastre pour rester dans le thème du dernier post et parce qu’il possède beaucoup de données à des échelles différentes tout en étant bien structuré contrairement aux données OSM

Les tuiles sont générées à partir d’un certain niveau de zoom et jusqu’a un plus petit. Il est par exemple aberrant de vouloir afficher les parcelles ou les bâtiments au niveau de la France.

1 : Préparer les données

La première étape est donc de déterminer pour chaque objet, à quelles tuiles il appartient. La pyramide de tuile étant hiérarchique, une tuile de zoom Z aura 4 “enfants” au zoom Z+1. Chaque objet appartenant à une tuile “enfant” appartiendra nécessairement à son parent. On donc réellement besoin de connaitre l’appartenance à la tuile d’apparition (au niveau de zoom le plus faible), les autres pourront être déterminés par un simple calcul.

Pour faire cela, il existe tile-cover, on lui passe la géométrie de l’objet (façon geojson), le niveau de zoom, et il nous renvoie les tuiles qui le couvrent ( sous forme de [ z, x, y]). C’est très efficace, comme tout ce que fait @mouner

Il faut alors stocker ces informations quelque part. J’ai d’abord testé dans des fichiers textes sous cette forme /z/x/x/layer.json. Ça marchait très bien, mais le poids de ces fichiers devenait très important et je n’avais pas beaucoup de place sur mon disque dur …

Je me suis alors orienté vers le format sqlite qui servira également à la création des mvt. Pour réduire la taille des “features”, j’ai utilisé geobuf (rapide et léger et toujours by @mouner)

Le fichier ainsi créé (en utilisant better-sqlite3 se nomme prepare.db et contient qu’une seule table structurée ainsi:

  • z
  • x
  • y
  • layer : nom du layer
  • uniq_key : un identifiant permettant de ne pas insérer un doublon ( les communes sont par exemple présentes dans chaque feuille)
  • data : la feature (au sens geojson) au format geobuf

A noter qu’un objet peut être présent plusieurs fois s’il chevauche plusieurs tuiles( la contrainte d’unicité se fait sur z,x,y, uniq_key)

Petit bonus, dans l’optique de créer une éventuelle application permettant de localiser une parcelle, une section ou une commune, je profite de cette étape pour insérer quelques données attributaires de cet élément dans une base nommée data. J’inclus également la bounding box de l’objet afin de pouvoir zoomer dessus.

2 : Création des tuiles par layer

A ce stade, on peut récupérer tous les objets d’un layer et d’une tuile par une simple requête (les index sont sur ‘layer, z, x, y’);

Pour la génération de la tuile, j’utilise geojson-vt de @mouner, encore et toujours…

Dans un premier temps, on lui passe le geojson constitué des features que l’on vient de récupérer pour qu’il nous génère “l’index des tuiles”

// build an initial index of tiles
var tileIndex = geojsonvt(geoJSON);

Le tileIndex étant généré, il est peu couteux de récupérer également les tuiles ‘enfants’, on en profite donc pour les générer ( récupérer les identifiants des sous-tuiles)

  const dbTiles = [];
  for (let child of tilesChildren) {
    const tile = getTileVT(
      tileIndex,
      child[0],
      child[1],
      child[2],
      tileConfig.name
    );
    dbTiles.push({
      z: child[0],
      x: child[1],
      y: Math.pow(2, child[0]) - 1 - child[2],
      tile: tile
    });
  }

Et voilà on stocke ces tuiles dans une base sqlite qui se nomme /mbtiles/{layer}.mbtiles en respectant bien sûr la structure indiquée dans les spécifications des mbtiles.

Par contre, les tuiles ne sont pas compressées au format .gz, et il n’y a qu’un seul layer par tuile. Heureusement , les tuiles vectorielles sont pensées pour être fusionnées par une simple concaténation des “buffer”, il est ainsi possible de le faire côté serveur juste avant de les servir

const tilesAllLayers = Buffer.concat([tiles1, tiles2, tiles3]);
// compressé en .gz en utilisant _pako_
const gziped = Buffer.from(pako.gzip(tilesAllLayers))

3 : Fusion des tuiles par layer

Si vous souhaitez directement fusionner et compresser les tuiles des différents layers, c’est possible avec le dernier script. Rien de très compliqué, il suffit de récupérer, toutes les tuiles ayant un même x y z de tous les layer et de les concaténer (comme expliqué au-dessus) On compresse le résultat en .gz en utilisant pako et on l’insert dans la table tiles de la nouvelle base sqlite dans le dossier (merged).

Pour la France entière, il aura fallu un peu moins de 1 heure de calculs pour générer les tuiles, 45 minutes pour les fusionner (5 millions) et un peu moins de 5 heures pour préparer l’Edigeo

Vous pouvez trouver l’utilisation des scripts et plus de détails sur Github.

Visualiser ces mbtiles

Il est possible de visualiser les “mbtiles” ainsi générés avec mbview de mapbox

Vous pouvez également servir ces tuiles depuis un serveur web. En Node.js , vous avez besoin juste d’un serveur web et “driver” permettant d’accéder à une base sqlite

Un petit exemple en utilisant Fastify comme server web et better-sqlite3

const fastify = require('fastify')({ logger: true })
const Database = require("better-sqlite3", {
    readonly: true,
    fileMustExist: true
  });

const mergedDb = new Database(`/data/tiles_2020/merged/cadastre.mbtiles`);

  fastify.get("/cadastre/:z/:x/:y", (request, reply) => {
    const z = parseInt(request.params.z);
    const x = parseInt(request.params.x);
    let y = parseInt(request.params.y);
    y = (1 << z) - 1 - y;

    const resReq = mergedDb.prepare("SELECT tile_data FROM tiles WHERE zoom_level=? AND tile_column=? AND tile_row=? ").get([z, x, y]);
        if (resReq){
            const tile = resReq.tile_data
            reply
            .code(200)
            .header('Content-Type', 'application/x-protobuf')
            .header('Content-Encoding', 'gzip')
            .send(tile)
        } 
        else {
            reply
            .code(204)
        }
  });
  
  fastify.listen(3000, (err, address) => {
    if (err) throw err
    fastify.log.info(`server listening on ${address}`)
  })