What are Vector Tiles?
Vector tiles are a way of storing and serving geographic data in a compact and efficient format. Unlike traditional raster tiles, which are pre-rendered images, vector tiles contain vector data that can be rendered dynamically on the client side. This means that they can be styled and labeled in real-time, allowing for interactive maps that are highly customizable and responsive to user input.
Vector tiles are typically used in web mapping applications, where they are served to the client side using technologies like Mapbox or OpenLayers. They are also used in desktop GIS software like QGIS and ArcGIS Pro.
To get full source code for this tutorial, click here.
To check the live demo for this tutorial, click here.
Why are Vector Tiles Great for Map Rendering?
Vector tiles have several advantages over traditional raster tiles:
- Flexible styling: Because vector tiles contain actual vector data, they can be styled in a highly flexible and customizable way. This allows for more creative and unique map designs that can be tailored to the specific needs of the application.
- Faster rendering: Vector tiles are smaller and more efficient than raster tiles, which means they can be rendered more quickly and with less strain on the client’s hardware.
- Smaller file size: Vector tiles are typically much smaller in size than raster tiles, which makes them faster to download and easier to store.
- Better resolution: Because vector tiles are not pre-rendered images, they can be rendered at any scale without loss of resolution or quality.
- Interactive: Vector tiles are interactive. We can style and interact with them on client side. In this tutorial, we will use openlayers to render and style them on the fly
This tutorial will help you to generate vector tiles and store them in local storage or cache them and directly render them in OpenLayers Map from stored location.
Generating Vector Tiles Using Python and PostGIS
In this tutorial, we will generate vector tiles for a layer hosted in postgis database and store those vector tiles in local disk and render them on web map using openlayers.
Now, let’s look at how to generate vector tiles using Python and PostGIS.
First, we need to set up a PostGIS database and connect to it using SQLAlchemy. We also need to import the necessary libraries, including GeoPandas, os and math
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
import os from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker import geopandas as gpd import math # Set up your PostGIS connection db_config = { 'dbname': 'postgres', 'user': 'postgres', 'password': 'admin', 'host': 'localhost', 'port': '5432' } # Connect to the database connection_string = f"postgresql://{db_config['user']}:{db_config['password']}@{db_config['host']}:{db_config['port']}/{db_config['dbname']}" engine = create_engine(connection_string) Session = sessionmaker(bind=engine) session = Session() |
To generate vector tiles for a specific area of interest, we can use GeoPandas to read in our geographic data and obtain the bounding coordinates for the layers using the total_bounds function. The total_bounds function returns the minimum and maximum coordinates for all geometries within a GeoDataFrame.
By obtaining the bounding coordinates, we can limit the generation of vector tiles to only the area within those bounds. This can significantly reduce the amount of data that needs to be processed and displayed, resulting in improved performance and faster load times for the OpenLayers map.
1 2 |
tricity = gpd.read_postgis(sql="select * from osm.tricity", con=engine); min_x, min_y, max_x, max_y = tricity.total_bounds |
You can also use postgis query to get the extent of a layer by using following approach
1 2 |
extent_query = 'SELECT ST_XMin(ST_Extent(geom)) as min_x, ST_YMin(ST_Extent(geom)) as min_y, ST_XMax(ST_Extent(geom)) as max_x, ST_YMax(ST_Extent(geom)) as max_y from osm.multipolygons_tricity' min_x, min_y, max_x, max_y</code> = session.execute(extent_query).all()[0] |
We also need to define a function called deg2num()
that converts latitude, longitude, and zoom level to tile coordinates. This function takes in three arguments: lat_deg
, lon_deg
, and zoom
. In summary, the deg2num()
function converts latitude, longitude, and zoom level to tile coordinates or tile column and row number for which we have to gnerate vector tiles for a point on the Earth’s surface. These coordinates can be used to generate vector tiles using PostGIS.
1 2 3 4 5 6 |
def deg2num(lat_deg, lon_deg, zoom): lat_rad = math.radians(lat_deg) n = 2.0 ** zoom xtile = int((lon_deg + 180.0) / 360.0 * n) ytile = int((1.0 - math.asinh(math.tan(lat_rad)) / math.pi) / 2.0 * n) return (xtile, ytile |
We can now define the extent and zoom levels for our vector tiles. In this example, we are using the total_bounds
property of our tricity
GeoDataFrame to define the extent, and a list of zoom levels from 0 to 17.
1 2 3 4 5 6 |
pythonCopy code<code># Define the extent min_x, min_y, max_x, max_y = tricity.total_bounds # Example extent covering the world # Define the zoom levels zoom_levels = list(range(0, 18)) # Example zoom levels from 0 to 17 |
Next, we define a SQL query template that fetches geometries for each zoom level. The template uses the ST_AsMVT()
function from PostGIS to generate vector tiles. It also uses the ST_TileEnvelope()
function to define the bounds of each tile, and the ST_Transform()
function to convert the geometries to the Web Mercator projection (EPSG 3857).
- The
ST_AsMVT()
function generates a vector tile by taking the geometries in a given layer and converting them to a binary format that can be displayed on a map. - The
q
subquery selects the geometries that intersect with the bounding box of a given tile, and clips and simplifies them to fit within the tile bounds. - The
ST_Transform()
function converts the geometries from their original projection to the Web Mercator projection, which is the standard projection used by most web maps. - The
ST_TileEnvelope()
function calculates the bounding box of a given tile in the Web Mercator projection. - The
WHERE
clause filters out geometries that do not intersect with the bounding box of a given tile.
Overall, the SQL query template generates vector tiles by selecting and clipping the geometries that intersect with a given tile, and converting them to the binary format used by vector tiles.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
# Define the SQL query template to fetch geometries for each zoom level sql_template = ''' SELECT ST_AsMVT(q, 'lines_tricity', 4096, 'geom') as mvt FROM ( SELECT ST_AsMVTGeom( ST_Transform(geom, 3857), ST_TileEnvelope({z}, {x}, {y}), 4096, 0, true ) AS geom, * FROM osm.lines_tricity WHERE geom && ST_Transform(ST_TileEnvelope({z}, {x}, {y}), 4326) ) q; ''' |
Finally, we loop through the zoom levels and tile coordinates, execute the SQL query for each tile, and save the resulting vector tile as a .mvt
file.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
# Generate vector tiles for the specified extent and zoom levels for z in zoom_levels: min_tile_x, max_tile_y = deg2num(min_y, min_x, z) max_tile_x, min_tile_y = deg2num(max_y, max_x, z) for x in range(min_tile_x, max_tile_x + 1): for y in range(min_tile_y, max_tile_y + 1): print(z, x, y) sql = sql_template.format(z=z, x=x, y=y) result = session.execute(sql).scalar() if result: tile_path = f"tiles/{z}/{x}" os.makedirs(tile_path, exist_ok=True) with open(f"{tile_path}/{y}.mvt", "wb") as tile_file: tile_file.write(result) |
The resulting vector tiles are saved in a directory called tiles
, organized by zoom level and tile coordinate. You can see the below image for reference.
To display the generated tiles on an OpenLayers map, we can create a vector tile layer using OpenLayers and provide the path to the generated vector tiles. In this case, on line 39, the URL path for the vector tiles has been defined as ’tiles/{z}/{x}/{y}.mvt’.
This URL path is a template that will be filled with values for the zoom level (z), the tile column (x), and the tile row (y) to fetch the appropriate vector tile for the current view of the map. The .mvt file extension indicates that these are vector tiles encoded using the Mapbox Vector Tile format.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 |
<html> <head> <title>Lat Lon</title> <script src="cdn/ol.js"></script> <link rel="stylesheet" href="cdn/ol.css"> <link rel="stylesheet" href="cdn/style.css"> </head> <body> <div id="map"> <div id="popup"></div> </div> </body> <script> var style_simple = new ol.style.Style({ fill: new ol.style.Fill({ color: '#ADD8E6' }), stroke: new ol.style.Stroke({ color: '#880000', width: 5 }) }); let osmLayer = new ol.layer.Tile({ source: new ol.source.OSM() }) let vectortile_layer = new ol.layer.VectorTile({ style: style_simple, source: new ol.source.VectorTile({ tilePixelRatio: 1, // oversampling when > 1 tileGrid: ol.tilegrid.createXYZ({ maxZoom: 19 }), format: new ol.format.MVT(), url: 'tiles/{z}/{x}/{y}.mvt' }), minZoom: 14, maxZoom: 18 }); // Create vector layer for highlighting the feature on popup click let selectionLayer = new ol.layer.Vector({ source: new ol.source.Vector({}), name: 'selectionLayer', style: new ol.style.Style({ stroke: new ol.style.Stroke({ color: 'red', width: 10, }), fill: new ol.style.Fill({ color: 'rgba(200,20,20,0.4)', }), }) }); var layer = 'postgres:germany_landuse_4326'; var projection_epsg_no = '4326'; var map = new ol.Map({ target: 'map', view: new ol.View({ center: [ 8542295.27212957, 3595868.3527581077], zoom: 13, // minZoom: 10, maxZoom: 18 }), layers: [ osmLayer, vectortile_layer, selectionLayer ] }); let element = document.getElementById("popup"), offset = [0, 0], positioning = 'bottom-center', className = 'ol-tooltip-measure ol-tooltip .ol-tooltip-static'; let overlay = new ol.Overlay({ element: element, offset: offset, positioning: positioning, className: className }); overlay.setPosition([0, 0]); overlay.element.style.display = 'block'; map.addOverlay(overlay); // Add a click event listener to the map map.on('singleclick', function (evt) { overlay.element.innerHTML = '' overlay.setPosition([0, 0]); selectionLayer.getSource().clear(); var viewResolution = map.getView().getResolution(); var coordinate = evt.coordinate; console.log(evt); // Retrieve features at the clicked pixel map.forEachFeatureAtPixel(evt.pixel, function (feature, layer) { // Print feature properties let properties = feature.getProperties(); if (layer == vectortile_layer) { let table = document.createElement('table'); Object.entries(properties).forEach((value) => { if (value[0] != 'geom') { let tr = document.createElement('tr'); let td1 = document.createElement('th') td1.style.textAlign = "left"; let td2 = document.createElement('td') td2.style.textAlign = "left"; td1.innerHTML = value[0]; td2.innerHTML = value[1]; tr.append(td1); tr.append(td2); table.append(tr); } }); overlay.element.append(table); overlay.setPosition(evt.coordinate); let wkb = new ol.format.WKB({ }); let pointFeature = new ol.Feature({ geometry: wkb.readGeometry(feature.getProperties().geom).transform('EPSG:4326', 'EPSG:3857') }); selectionLayer.getSource().addFeature(pointFeature); } }); }); var layer = new ol.layer.Vector({ source: new ol.source.Vector(), style: new ol.style.Style({ stroke: new ol.style.Stroke({ color: 'black', width: 1, }), fill: new ol.style.Fill({ color: 'rgba(200,20,20,0.0)', }), }) }); map.addLayer(layer); map.on('moveend', function (e) { // layer.getSource().clear(); var extent = map.getView().calculateExtent(map.getSize()); var zoom = map.getView().getZoom(); var resolution = map.getView().getResolution(); var tileSize = 256 * Math.pow(2, zoom); var tileGrid = ol.tilegrid.createXYZ({ extent: extent, tileSize: [256, 256], maxZoom: 20 }); // calculate the tile coordinate for the top-left tile var tileCoord = tileGrid.getTileCoordForCoordAndResolution([extent[0], extent[3]], resolution); // clear the existing features from the layer layer.getSource().clear(); // add a polygon feature for each tile in the current extent and zoom level tileGrid.forEachTileCoord(extent, tileCoord[0], function (tileCoord) { var tileExtent = tileGrid.getTileCoordExtent(tileCoord); var tileGeom = ol.geom.Polygon.fromExtent(tileExtent); var tileFeature = new ol.Feature(tileGeom); layer.getSource().addFeature(tileFeature); }); }); </script> </html> |
In summary, generating vector tiles using Python and PostGIS involves setting up a PostGIS database, defining the extent and zoom levels, and executing a SQL query to generate vector tiles using the ST_AsMVT()
function. The resulting vector tiles can be served to web mapping applications or desktop GIS software, allowing for flexible and customizable map rendering.
I hope this tutorial will create a good foundation for you. If you want tutorials on another GIS topic or you have any queries, please send an email at contact@spatial-dev.guru.