Navigating the Era of Vector Tiles: Techniques and Applications

Uncategorized

I

8–12 minutes

What is a tile?

To display spatial data on a map, there are different data formats, including the vector tile, which brings new possibilities for displaying data (fast data access, easy style modification, direct user interaction with objects).

Indeed, a digital map can theoretically display data at any scale and in any location, but this flexibility can cause performance issues.

Tile mosaicking improves display speed and efficiency:

  • By limiting the available scales. Each scale is twice as detailed as the previous one.
  • By using a fixed grid. The areas we want to display are made up of collections of appropriate tiles.

Most tile systems start with a single tile covering the entire world, called “zoom level 0,” and each subsequent zoom level increases the number of tiles by a factor of 4, doubling both vertically and horizontally.

Tile coordinates

Each tile in a mosaicked map is referenced by its zoom level, horizontal position, and vertical position. The commonly used addressing scheme is XYZ.

Here is an example at zoom level 2:

We will retrieve the tile using the following XYZ addressing:

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

For example, you can view the image tile of Australia at the following address; the same principle applies to vector tiles:https://tile.openstreetmap.org/2/3/2.png

How a vector tile works

Vector tiles look like raster tiles, but instead of an image, they contain data describing the elements that make them up using vector paths. Instead of sets of pixels, there are sets of point coordinates, to which several attribute pieces of information can be associated.

Like their raster cousins, vector tiles can be cached, both server-side and browser-side, allowing users to navigate huge maps quickly, even on lightweight devices.

How is it used?

Generating vector tiles on the fly from PostGIS

There are different building blocks for publishing data on the fly from PostGIS. Here is our selection:

For the purposes of this blog, we will focus on using pg_tileserv.

pg_tileserv

By default, pg_tileserv allows you to generate vector tiles from the geographic tables in the database.

The real benefit of using a tool like pg_tileserv lies in generating tiles based on more refined analysis.

Here is an example of a query that can be used to display map data. This query will simplify the geometry based on the zoom threshold and filter the data based on the operator and technology.

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$;

To call this function, we will use a URL formatted as follows:

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

Now you’re ready to let your imagination run wild to retrieve the data you want and meet the needs of your map.

We can think of creating clusters, thematic analysis, layer aggregation, or whatever else comes to mind.

What about performance?

All this newfound freedom will lead us into increasingly complex queries, and therefore degraded performance. If each tile takes 10 seconds to generate, the user experience will be poor.

To address this issue, let’s remember that the vector tile is based on a fixed grid. It is therefore possible to generate a cache (caching). Great! But how do we do that?

Here we go:

1 – Create a tile cache table

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 – Generate the cache

To generate the cache, we first retrieve the grids on which we have data. The maximum zoom threshold (max_zoom) can be defined in the following function.

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$;

Using this grid table, we will generate all the tiles and insert them into the cache table.

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 – Mixed cache/query rendering

Once the tiles have been inserted into the cache table, when we want to retrieve a tile, we need to route the lookup so that the function either retrieves the tile from the cache table or generates it on the fly.

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;
$$;

Now, by calling the “coverages” layer in pg_tileserv, at the smallest zoom levels (below 8), which are the most demanding in terms of computing geometric simplification, we will use the tile cache. However, when we get closer, we will use on-the-fly tile generation since performance is good.

For the more advanced users, here’s a little bonus: an example of a cluster layer generated on the database side. The cluster adapts to the zoom threshold, clustering at the departmental level, then the municipal level, then as a natural cluster (St_ClusterDBSCAN) with dynamic spacing for each zoom threshold, and finally displaying by individual object when very close. We could have imagined a honeycomb-style cluster, which I find more effective, since the issue with PostGIS clustering is that it is calculated within the extent of each tile. This means it splits clusters arbitrarily when there is high density between two tiles.

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;
$_$;

So, what are the benefits?

Basemap

When it comes to choosing basemaps, there are many advantages.

You can fairly easily customize a basemap by modifying the display settings you want to use for each element. To do this, you can rely on style files like those provided by Etalab: https://openmaptiles.geo.data.gouv.fr/.

If you host the style files yourself, you can modify them to choose the style of the elements. Here’s what the configuration of an element from a vector tile looks like:

    {
      "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)"
      }
    },

For example, it is possible to extrude buildings if you want a 3D rendering

Vector display also allows labels/icons to always be displayed in the correct reading direction.

Choosing the layer display order

It’s also worth noting that it is possible to change the display order of layers from the basemap and business layers, which is not possible with WMTS-type basemaps. This allows municipality labels to stand out on top of a business layer.

Vector layer

Since the layer is vector-based, it is also possible to retrieve its attributes.

Style editing tool

There are several tools available for modifying the style of vector data. However, I recommend Maputnik, which is very comprehensive and accessible => https://maplibre.org/maputnik/#0.77/0/0

Conclusion

In conclusion, vector tiles represent a significant advance in the dissemination and display of spatial data, enabling the creation of advanced mapping applications. However, they still need to be integrated into robust tools and high-performance methodologies in order to meet the growing need for precision and responsiveness. Vector tile technology is essential for the future of digital mapping, offering a balance between performance, flexibility, and interactivity.

Future directions?

Authentication and roles through pg_tileserv. In a future blog post, we could explore how to combine token-based authentication, the pg_tileserv service, and PostgreSQL security through Row Level Security. This would allow permissions to be managed at the object level, natively within PostgreSQL.


Author: Matthieu Etourneau

Articles similaires

ProductsUncategorized

OneGeo Chat v2.2: a redesigned interface, smarter conversations

Uncategorized

Tutorial: Effectively Train My First Neural Network

ProductsUncategorized

Your OneGeo Chat chatbot: a technical breakdown