Felix Delattre

Tile-based Data Storage and Visualization

Efficiently Serving Geospatial Data Using Vector Tiles and PostGIS


Tiles are not only pivotal for visualizing map data in browsers, but they can also revolutionize the way we store geospatial data. This article explores the use of a ’tile-based GIS’ for storing data like population distribution, utilizing PostGIS to efficiently export this data into vector tiles (MVT) for web-based dissemination."

Certain datasets, such as global population distribution or OpenStreetMap buildings’ completeness assessments, are naturally suited to a grid or ’tile-based GIS’ format. This format, relying on either coordinate system grids (like a 30 arc-seconds grid) or tile concepts, can be adeptly managed using the quadtree structure with quadkeys.

This blog post focuses on addressing tile-based data using quadkeys and demonstrates how to serve this data efficiently using PostgreSQL’s PostGIS extension, specifically its ST_asMVT function.

Understanding Tiles

Tiles refer to partitioning a two-dimensional space, such as a projected Earth, by recursively subdividing it into quadrants. Each subdivision step, or zoom level, further divides each quadrant into smaller ones. Tiles are typically addressed using simple coordinates (x, y) along with the zoom level (z).

XYZ explanation

Quadkeys

Quadkeys, based on the quadtree structure, offer a convenient method for addressing tiles. Each quadkey digit represents a zoom level, making them intuitive for both reading and applying aggregation logic.

Quadkeys explanation

PostgreSQL/PostGIS Functions

The following PostgreSQL/PostGIS functions facilitate the conversion between XYZ tile coordinates and quadkeys:

---- CONVERT XYZ TO QUADKEY
 Especially for the visualization they became 
CREATE OR REPLACE FUNCTION public.convert_xyz_to_quadkey(x INTEGER, y INTEGER, z INTEGER)
RETURNS TEXT AS
$$
    DECLARE
        quadkey TEXT;
        quadkey_digit INTEGER;
        rx INTEGER;
        ry INTEGER;
    BEGIN
        FOR i IN 1..z LOOP
            rx = x % 2;
            x = x / 2;
            ry = y % 2;
            y = y / 2;
            IF rx = 0 AND ry = 0 THEN
                quadkey_digit = 0;
            ELSIF rx = 1 AND ry = 0 THEN
                quadkey_digit = 1;
            ELSIF rx = 0 AND ry = 1 THEN
                quadkey_digit = 2;
            ELSIF rx = 1 AND ry = 1 THEN
                quadkey_digit = 3;
            ELSE
                RAISE EXCEPTION 'Failure converting xyz to Quadkey';
            END IF;
            quadkey = CONCAT(CAST(quadkey_digit AS CHAR), quadkey);
        END LOOP;
        RETURN quadkey;
    END;
$$
LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE;
COMMENT ON FUNCTION public.convert_xyz_to_quadkey IS 'Converts a tile identifier from the format x,y,z to a Quadkey.';
---- CONVERT QUADKEY TO XYZ

CREATE OR REPLACE FUNCTION public.convert_quadkey_to_xyz(quadkey TEXT)
RETURNS SETOF INTEGER AS
$$
    DECLARE
        x INTEGER := 0;
        y INTEGER := 0;
        z INTEGER := 0;
        quadkey_digit TEXT;
    BEGIN
        FOR i IN 1..LENGTH($1) LOOP
            z = z + 1;
            quadkey_digit = SUBSTRING(quadkey, i, 1);
            IF quadkey_digit = '0' THEN
                x = x * 2; y = y * 2;
            ELSIF quadkey_digit = '1' THEN
                x = x * 2 + 1; y = y * 2;
            ELSIF quadkey_digit = '2' THEN
                x = x * 2; y = y * 2 + 1;
            ELSIF quadkey_digit = '3' THEN
                x = x * 2 + 1; y = y * 2 + 1;
            ELSE
                RAISE EXCEPTION 'Failure: a Quadkey can only contain numbers from `0` to `3`.';
            END IF;
        END LOOP;
        RETURN QUERY VALUES (x), (y), (z);
    END
$$
LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE;
COMMENT ON FUNCTION public.convert_quadkey_to_xyz IS 'Converts a tile identifier from the format Quadkey to x,y,z.';

Serving Tiles with PostGIS

To serve this data as vector tiles over the internet, we use the ST_AsMVT function in PostGIS. This function can efficiently transform spatial data stored in PostGIS into vector tiles that can be rendered in a web map.

Tile-based Data Visualization

---- Generates tile geometry from Quadkey

CREATE OR REPLACE FUNCTION public.generate_tile_geometry_from_quadkey(quadkey TEXT)
RETURNS GEOMETRY -- Using Web Mercator, EPSG 3857
AS $$
    DECLARE
        earth_rad FLOAT; x FLOAT; y FLOAT; off_set FLOAT; q TEXT;
    BEGIN
        SELECT 6378137.0 INTO earth_rad; -- EPSG 3857 definition
        SELECT 0 INTO x;
        SELECT 0 INTO y;
        SELECT earth_rad * pi() INTO off_set; -- Tile centroid
        FOR i IN 1..LENGTH($1) LOOP
            q = SUBSTRING(quadkey, i, 1);
            off_set = off_set * .5;
            IF q = '0' THEN
                x = x - off_set; y = y + off_set;
            ELSIF q = '1' THEN
                x = x + off_set; y = y + off_set;
            ELSIF q = '2' THEN
                x = x - off_set; y = y - off_set;
            ELSIF q = '3' THEN
                x = x + off_set; y = y - off_set;
            ELSE RAISE EXCEPTION 'Failure: a Quadkey can only contain numbers from `0` to `3`.';
            END IF;
        END LOOP;
        RETURN ST_MakeEnvelope(x - off_set, y - off_set, x + off_set, y + off_set, 3857);
    END
$$
LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE;
---- Generates vector tiles containing their own geometry

CREATE OR REPLACE FUNCTION public.generate_vector_tile_with_geometry(z INTEGER, x INTEGER, y INTEGER)
RETURNS BYTEA AS
$$
    DECLARE
        vector_tile BYTEA DEFAULT '';
    BEGIN
        WITH mvtdata AS (
            SELECT ST_MakeEnvelope(0, 0, 4096, 4096) AS geom,
            z as zoom
        )
        SELECT ST_AsMVT(mvtdata.*, 'tiles' 4096, 'geom') FROM mvtdata INTO vector_tile;
        RETURN vector_tile;
    END;
$$
LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE;

---- Generates vector tiles containing geometries of tiles with a specified zoom level and allows for vizualization on lower zoom levels

CREATE OR REPLACE FUNCTION public.generate_vector_tile_containing_geometries_of_sub_tiles(z INTEGER, x INTEGER, y INTEGER, target_zoom_level INTEGER)
RETURNS BYTEA AS
$$
    DECLARE
        vector_tile BYTEA DEFAULT '';
        inside_tiles INTEGER; -- Number of tiles at zoom level `target_zoom_level` along one axis of a tile with zoom level `z`.
        inside_tile_pixels INTEGER;
    BEGIN
        IF z > target_zoom_level THEN
            RAISE NOTICE 'Vector tiles cannot be be created for zoom levels higher than the indicated in `target_zoom_level`: %.', target_zoom_level;
        ELSE
            inside_tiles = 2 ^ (target_zoom_level - z);
            inside_tile_pixels = 4096 / inside_tiles;
            WITH mvtdata AS (
                SELECT ST_MakeEnvelope(
                    inside_tile_pixels * tile_x, inside_tile_pixels * tile_y,
                    inside_tile_pixels * (tile_x + 1), inside_tile_pixels * (tile_y + 1)
                ) AS geom
                FROM
                    generate_series(0, inside_tiles) AS tile_x,
                    generate_series(0, inside_tiles) AS tile_y
            )
            SELECT ST_AsMVT(mvtdata.*, 'tiles', 4096, 'geom') FROM mvtdata INTO vector_tile;
        END IF;
        RETURN vector_tile;
    END;
$$
LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE;