Skip to main content

C’est quoi une tuile ?

Pour diffuser des données spatiales sur une carte, il existe différents formats de données dont celui de la tuile vectorielle qui apporte de nouvelles possibilités d’affichage de données (rapidité d’accès à la donnée, facilité de modification du style, interaction directe des utilisateurs avec les objets).

En effet, une carte numérique peut théoriquement afficher des données à n’importe quelle échelle et à n’importe quel endroit, mais cette flexibilité peut poser des problèmes de performance.

La mosaïque de tuiles améliore la vitesse et l’efficacité d’affichage :

  • En limitant les échelles disponibles. Chaque échelle est deux fois plus détaillée que la précédente.
  • En utilisant une grille fixe. Les zones que l’on souhaite afficher sont composées de collections de tuiles appropriées.

La plupart des systèmes de tuiles commencent avec une seule tuile englobant le monde entier, appelée « niveau de zoom 0 », et chaque niveau de zoom suivant augmente le nombre de tuiles par un facteur de 4, en doublant verticalement et horizontalement.

Coordonnées des tuiles

Chaque tuile d’une carte en mosaïque est référencée par son niveau de zoom, sa position horizontale et sa position verticale. Le schéma d’adressage couramment utilisé est XYZ.

Voici un exemple au zoom 2 :

On va récupérer la tuile à l’aide de l’adressage XYZ suivant :

http://server/{z}/{x}/{y}.format

Par exemple, vous pourrez voir la tuile image de l’Australie à l’adresse suivante, c’est le même principe pour les tuiles vectorielles :https://tile.openstreetmap.org/2/3/2.png

Principe d’une tuile vectorielle

Les tuiles vectorielles ressemblent aux tuiles raster, mais à la place d’une image, elles contiennent des données permettant de décrire les éléments qui la composent avec des tracés vectoriels. Au lieu d’avoir des ensembles de pixels, il y a donc des ensembles de coordonnées de points, auxquels on peut associer plusieurs informations attributaires.

Comme leurs cousines raster, les tuiles vectorielles peuvent être mises en cache, côté serveur et côté navigateur, et permettent de naviguer dans des cartes immenses rapidement, même sur des terminaux légers.

Comment l’utilise-t-on ?

Produire des tuiles vectorielles à la volée depuis PostGIS

Il existe différentes briques pour publier des données à la volée depuis PostGIS, voici notre sélection :

Dans le cadre de ce blog, nous resterons sur l’utilisation de pg_tileserv.

pg_tileserv

Par défaut, pg_tileserv permet de produire des tuiles vectorielles à partir des tables géographiques qui sont dans la base de données.

Tout l’intérêt d’utiliser un outil comme pg_tileserv réside dans la génération de tuiles produites à partir d’une analyse plus fine.

Voici un exemple de requête que l’on peut utiliser pour afficher une donnée cartographique. Cette requête va permettre de simplifier la géométrie en fonction du seuil de zoom et filtrer la donnée en fonction de l’opérateur et de la techno.

CREATE OR REPLACE FUNCTION generate_couvertures_tiles(
  z integer,
  x integer,
  y integer,
  in_operateur bigint,
  in_techno character varying)
    RETURNS bytea
    LANGUAGE 'plpgsql'
    COST 100
    VOLATILE PARALLEL UNSAFE
AS $BODY$

#variable_conflict use_variable
begin
  return (
    WITH
    bounds AS (
      SELECT ST_TileEnvelope(z, x, y) AS geom,
     (CASE
      when z >= 12 then 0
      when z = 11 then 0
      when z = 10 then 10
      when z = 9 then 100
      when z = 8 then 300
      when z = 7 then 900
      when z = 6 then 1500
      when z <= 5 then 3000
      ELSE 1 END
       ) as simplify_tolerance
    ),

    mvtgeom AS (
      SELECT fid, operateur, date, techno, usage, niveau, dept, filename,
      ST_AsMVTGeom(
          ST_Simplify(t.geom,simplify_tolerance), bounds.geom) AS geom

      FROM couverture_theorique t, bounds
      WHERE ST_Intersects(t.geom, bounds.geom )
      and operateur = in_operateur
      and techno = in_techno
    )
    SELECT ST_AsMVT(mvtgeom)
    FROM mvtgeom
  );
end;
$BODY$;

Pour appeler cette fonction, on va utiliser l’url formatée de la manière suivante :

https://hostname/tileserv/public.generate_couvertures_tiles/{Z}/{X}/{Y}.pbf?in_operateur=28020&in_techno=3G

Vous voilà en mesure de faire parler votre imagination pour récupérer les données que vous souhaitez et répondre aux besoins de votre carte.

On peut penser à la création de Cluster, d’analyse thématique, d’agrégation de couche ou que sais-je.

Et la performance dans tout ça ?

Toute cette liberté acquise va nous plonger dans des requêtes de plus en plus complexes et donc nous apporter une performance dégradée. Si chaque tuile prend 10 secondes à se générer, l’expérience utilisateur sera mauvaise.

Pour remédier à ce contretemps, rappelons-nous que la tuile vectorielle repose sur une grille fixe. Il est donc possible de générer du cache (mise en cache). Cool ! mais comment fait-on cela ?

C’est parti :

            1 – Créer une table de cache de tuiles

CREATE TABLE IF NOT EXISTS tiles_cache
(
    z integer NOT NULL,
    x integer NOT NULL,
    y integer NOT NULL,
    operateur bigint NOT NULL,
    techno character varying COLLATE pg_catalog."default" NOT NULL,
    mvt bytea NOT NULL,
    CONSTRAINT tiles_pkey PRIMARY KEY (z, x, y, operateur, techno)
)

2 – Générer le cache

Pour générer le cache, dans un premier temps on récupère les grilles sur lesquelles on a des données. Le seuil de zoom maximum (max_zoom) peut être défini dans la fonction suivante.

CREATE OR REPLACE FUNCTION gettilesintersectinglayer(
  liste_operateur bigint,
  liste_techno character varying)
    RETURNS TABLE(x integer, y integer, z integer)
    LANGUAGE 'plpgsql'
    COST 100
    VOLATILE PARALLEL UNSAFE
    ROWS 1000

AS $BODY$
DECLARE
  tile_bounds public.GEOMETRY;
  max_zoom INTEGER := 7;
BEGIN
  FOR current_zoom IN 1..max_zoom LOOP
    FOR _x IN 0..(2 ^ current_zoom - 1)
    LOOP
      FOR _y IN 0..(2 ^ current_zoom - 1)
      LOOP
        tile_bounds := ST_TileEnvelope(current_zoom, _x, _y);
        IF EXISTS (
          SELECT 1 FROM couverture_theorique
          WHERE ST_Intersects(geom, tile_bounds)
          AND operateur = liste_operateur
          AND techno = liste_techno
        )
        THEN
          RAISE NOTICE 'Traitement %', current_zoom || ', ' || _x || ', ' || _y;
          z := current_zoom;
          x := _x;
          y := _y;
          RETURN NEXT;
        END IF;
      END LOOP;
    END LOOP;
  END LOOP;
END;
$BODY$;

À l’aide de ce tableau de grilles, on va générer l’ensemble des tuiles et les injecter dans la table de cache.

with oper as (
  SELECT distinct operateur from couverture_theorique
),
techno as (
  SELECT distinct techno from couverture_theorique
)
insert into tiles_cache_couverture(z, x, y, operateur, techno, mvt)
select tile.z, tile.x, tile.y, oper.operateur,  techno.techno,
generate_couvertures_tiles(tile.z, tile.x, tile.y, oper.operateur, techno.techno)
from techno, oper, GetTilesIntersectingLayer(operateur, techno) as tile;

            3 – Le rendu mixte cache ou requête

Une fois que les tuiles sont insérées dans la table de cache, lorsque l’on va vouloir récupérer la tuile, il va falloir aiguiller la recherche pour que la fonction aille soit récupérer la tuile dans la table de cache ou la générer à la volée.

CREATE FUNCTION couvertures(z integer, x integer, y integer, liste_operateur integer[], liste_techno character varying[]) RETURNS bytea
    LANGUAGE plpgsql
    AS $$

#variable_conflict use_variable
begin
  if (z <= 7 and array_length(liste_operateur,1) = 1) then
    return (
      SELECT mvt
      from tiles_cache_couverture
      Where tiles_cache_couverture.x=x
        AND tiles_cache_couverture.y=y
        AND tiles_cache_couverture.z=z
        and operateur = any(liste_operateur)
        and techno = any(liste_techno)
    );
  else
    return (
      WITH
      bounds AS (
        SELECTST_TileEnvelope(z, x, y) AS geom,
       (CASE
        when z >= 12 then 0
        when z = 11 then 0
        when z = 10 then 10
        when z = 9 then 100
        when z = 8 then 300
        when z = 7 then 900
        when z = 6 then 1500
        when z <= 5 then 3000
        ELSE 1 END
         ) as simplify_tolerance
      ),

      mvtgeom AS (
        SELECT fid, operateur, date, techno, usage, niveau, dept, filename,
        public.ST_AsMVTGeom(
           ST_Simplify(t.geom,simplify_tolerance), bounds.geom) AS geom

        FROM couverture_theorique t, bounds
        WHEREST_Intersects(t.geom, bounds.geom )
        and operateur = any(liste_operateur)
        and techno = any(liste_techno)
      )
      SELECTST_AsMVT(mvtgeom)
      FROM mvtgeom
    );
  end if;
end;
$$;

Maintenant en appelant la couche « couvertures » dans pg_tileserv, sur les zooms les plus petits (inférieur à 8), et donc les plus gourmands pour calculer la simplification géométrique, nous allons utiliser le cache de tuiles. Cependant, lorsque l’on sera relativement proche, on va utiliser la génération des tuiles à la volée car les performances sont bonnes.

Pour les plus ardus, je vous mets un petit bonus. Un exemple de couche cluster générée coté base de données. Le cluster va s’adapter au seuil de zoom, pour clustériser au niveau départemental, puis communal, puis sous forme de cluster naturel (St_ClusterDBSCAN) avec un espacement dynamique pour chaque seuil de zoom, et enfin un affichage par objet quand on est très proche. On aurait pu imaginer un cluster en nid d’abeille que je trouve plus efficace car le problème du cluster de PostGIS, c’est qu’il va être calculé dans l’emprise de chaque tuile. Cela signifie qu’il découpe des clusters de façon arbitraire quand on a une densité importante entre 2 tuiles.

CREATE FUNCTION cluster_filtres(z integer, x integer, y integer, filtres text[]) RETURNS bytea
    LANGUAGE plpgsql
    AS $_$

#variable_conflict use_variable
DECLARE
  query text;
  result bytea;
begin
  --MISE EN PLACE DES FILTRES

  if ( z < 9) then
    --Vue par departement
    query := '
      WITH
      bounds AS (
        SELECT ST_TileEnvelope($1, $2, $3) AS geom
      ),
      item_fitler AS (
        --On récupère nos données filtrées
        SELECT distinct t.id
        FROM table t
        WHERE ' || filtres || '
      ),
      tot_dept AS (
        select code_departement, count(1) as tot_item
        from departement d
        INNER JOIN table t ON ST_Intersects(d.geom, t.geom )
        INNER JOIN item_fitler ON item_fitler.id = t.id
        group by code_departement
      ),
      mvtgeom AS (
        SELECT tot_dept.code_departement, tot_dept.tot_item,
        ST_AsMVTGeom(ST_PointOnSurface(tot_dept.geom), bounds.geom) AS geom
        FROM tot_dept
        INNER JOIN bounds ON ST_Intersects(tot_dept.geom, bounds.geom )
        WHERE tot_item is not null
      )
      SELECT ST_AsMVT(mvtgeom)
      FROM mvtgeom
    ';
    --RAISE NOTICE 'Calling query (%)', query;
    EXECUTE query INTO result USING z, x, y, filtres;
        return result;
   
  elsif ( z <= 10) then
    --Vue par commune, On ajoute un buffer pour récupérer les items autour de la tuile sans devoir le faire sur la France entière
    query := '
      WITH
      bounds AS (
        SELECT ST_TileEnvelope($1, $2, $3) AS geom
      ),
      item_fitler AS (
        --On récupère nos données filtrées
        SELECT distinct t.id
        FROM table t
        INNER JOIN bounds ON ST_Intersects(t.geom, ST_Buffer(bounds.geom, 10000) )
        WHERE ' || filtres || '
      ),
      tot_com AS (
        select insee_com, count(1) as tot_item
        from commune c
        INNER JOIN table t ON ST_Intersects(c.geom, t.geom )
        INNER JOIN item_fitler ON item_fitler.id = t.id
        group by insee_com
        having count(1) > 0
      ),
      mvtgeom AS (
        SELECT tot_com.insee_com, tot_com.tot_item,
        ST_AsMVTGeom(ST_PointOnSurface(tot_com.geom), bounds.geom) AS geom
        FROM tot_com
        INNER JOIN bounds ON ST_Intersects(tot_com.geom, bounds.geom )
        WHERE tot_item is not null
      )
      SELECT ST_AsMVT(mvtgeom)
      FROM mvtgeom
    ';
    --RAISE NOTICE 'Calling query (%)', query;
    EXECUTE query INTO result USING z, x, y, filtres;
        return result;
   
  elsif ( z <= 15) then
    --Vue par cluster
    query := '
      WITH
      bounds AS (
        SELECT ST_TileEnvelope($1, $2, $3) AS geom
      ),
      item_fitler AS (
        --On récupère nos données filtrées
        SELECT distinct t.id
        FROM table t
        INNER JOIN bounds ON ST_Intersects(t.geom, bounds.geom )
        WHERE ' || filtres || '
      ),
      clustered_points AS (
        SELECT ST_ClusterDBSCAN(t.geom, eps :=
          (CASE
            when $1 = 11 then 500
            when $1 = 12 then 385
            when $1 = 13 then 280
            when $1 = 14 then 150
            when $1 = 15 then 75
            ELSE 1 END
          ) , minpoints := 1) over() AS cid,
        t.fid, t.geom
        FROM table t
        INNER JOIN item_fitler s ON s.id = t.id
        group by t.id, t.geom
      ),
      mvtgeom AS (
        SELECT cid, array_agg(id) as ids,
        count(1) as tot_item,
        ST_AsMVTGeom(ST_PointOnSurface(ST_Collect(c.geom)), bounds.geom) AS geom
        FROM clustered_points c, bounds
        WHERE ST_Intersects(c.geom, bounds.geom )
        group by cid, bounds.geom
      )
      SELECT public.ST_AsMVT(mvtgeom)
      FROM mvtgeom
    ';
    RAISE NOTICE 'Calling query (%)', query;
    EXECUTE query INTO result USING z, x, y, filtres;
        return result;
  else
    --vue par objet
    query := '
      WITH
      bounds AS (
        SELECT ST_TileEnvelope($1, $2, $3) AS geom
      ),
      item_fitler AS (
        --On récupère nos données filtrées
        SELECT distinct t.id
        FROM table t
        INNER JOIN bounds ON ST_Intersects(t.geom, bounds.geom )
        WHERE ' || filtres || '
      ),
      mvtgeom as (
        SELECT ST_AsMVTGeom(t.geom, bounds.geom) AS geom, t.id
        FROM item_fitler t , bounds
        WHERE ST_Intersects(t.geom, bounds.geom )
      )
      SELECT ST_AsMVT(mvtgeom)
      FROM mvtgeom
    ';
    EXECUTE query INTO result USING z, x, y, filtres;
        return result;
  end if;
end;
$_$;

Finalement quels avantages ?

Fond de plan

Dans les choix des fonds de plan, les avantages sont multiples.

On peut assez facilement personnaliser un fond de plan en modifiant les paramètres d’affichage que l’on souhaite utiliser pour chaque élément. Pour faire cela, on peut s’appuyer sur des fichiers de style comme ceux proposés par Etalab : https://openmaptiles.geo.data.gouv.fr/.

Si l’on héberge soit même les fichiers de style, on peut les modifier pour choisir le style des éléments. Voici à quoi ressemble le paramétrage d’un élément issu d’une tuile vectorielle :

    {
      "id": "landuse-commercial",
      "type": "fill",
      "source": "openmaptiles",
      "source-layer": "landuse",
      "filter": [
        "all",
        ["==", "$type", "Polygon"],
        ["==", "class", "commercial"]
      ],
      "layout": {
        "visibility": "visible"
      },
      "paint": {
        "fill-color": "hsla(0, 60%, 87%, 0.23)"
      }
    },

Il est par exemple possible d’extruder les bâtiments si on souhaite obtenir un rendu 3D

L’affichage vectorielle permet également d’afficher les libellés / icônes toujours dans le bon sens de lecture.

Choix de l’ordre d’affichage des couches

Ensuite il est intéressant de rappeler qu’il est possible de modifier l’ordre d’affichage des couches qui sont issues du fond de plan et des couches métiers, ce qui n’est pas possible avec les fonds de plan de type WTMS. On peut donc faire ressortir les libellés des communes sur une couche métier.

Couche vectorielle

La couche étant vectorielle, il est également possible de récupérer des attributs de celle-ci.

Outil de d’édition du style

Il existe plusieurs outils permettant de modifier le style des données vectorielles. Cependant je vous conseille maputnik qui est très complet et accessible => https://maplibre.org/maputnik/#0.77/0/0

Conclusion

En conclusion, les tuiles vectorielles représentent une avancée significative dans la diffusion et l’affichage de données spatiales, et permettent de créer des applications cartographiques avancées. Encore faut-il les intégrer dans des outils robustes et des méthodologies performantes, afin de répondre aux besoins croissants de précision et de réactivité. La technologie des tuiles vectorielles est essentielle pour le futur de la cartographie numérique, offrant un équilibre entre performance, flexibilité et interactivité.

Pistes futures ?

L’authentification et les rôles à travers pg_tileserv. On pourrait imaginer dans un futur blog, comment fusionner l’authentification via un token, le service pg_tileserv et la sécurité de PostgreSQL avec le Row Level Sécurity. Cela permettrait de gérer les droits au niveau de l’objet et nativement dans PostgreSQL.


Rédacteur : Matthieu Etourneau