- Introduction
- Preparing the Environment
- Establishing Database Configuration
- Creating the Database Engine
- Crafting the SQL Query Template
- Initializing the FastAPI Application
- Defining API Endpoints
- Running the Application
- Rendering Dynamically Generated Vector Tiles in OpenLayers
- Complete Python Code for dynamically generating Vector Tiles from PostGIS using FastAPI
- Complete OpenLayers Code for rendering dynamically generated Vector Tiles from FastAPI end point
- Conclusion
Introduction
FastAPI is a powerful and efficient web framework for creating APIs in Python that has gained popularity due to its speed and simplicity. This blog will delve into the specifics of how FastAPI can serve vector tiles from a PostGIS database. Our journey will involve SQL query templates, FastAPI endpoints, and a PostGIS database connection and finally rendering on dynamically generated vector tiles in OpenLayers Map
Preparing the Environment
Before diving into code, ensure that necessary libraries such as FastAPI
, SQLAlchemy
, uvicorn
and gzip
are installed in your Python environment. These libraries can be installed using pip:
1 2 3 |
(base) geoknight@pop-os:~$conda create -n spatial-dev.guru python=3.10 (base) geoknight@pop-os:~$conda activate spatial-dev.guru (spatial-dev.guru) geoknight@pop-os:~$conda install -c conda-forge fastapi sqlalchemy uvicorn |
Establishing Database Configuration
The first step is to define our database credentials. We create a dictionary named db_config
that contains our database name, user, password, host, and port.
1 2 3 4 5 6 7 8 |
db_config = { 'dbname': 'postgres', 'user': 'postgres', 'password': 'admin', 'host': 'localhost', 'port': '5432' } |
Creating the Database Engine
Next, we use SQLAlchemy to establish a connection to our PostGIS database. Using the database configuration defined earlier, we formulate a connection string and create a SQLAlchemy engine
. SQLAlchemy’s sessionmaker
is utilized to generate a Session, which serves as our primary interface with the database.
1 2 3 4 5 6 |
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() |
Crafting the SQL Query Template
For this application, we’ll need a SQL query that fetches geometries based on tile coordinates. In the SQL query template, we are selecting features, transforming them to web mercator, and encoding them into a Mapbox Vector Tile (MVT).
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
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; ''' |
Initializing the FastAPI Application
We begin by initializing a FastAPI instance and mounting a static file directory. This directory can serve any files needed for our web application, such as HTML, CSS, and JavaScript files.
1 2 3 |
app = FastAPI() app.mount("/static", StaticFiles(directory="static"), name="static") |
Defining API Endpoints
Our FastAPI app will have two main endpoints:
- The root endpoint (
/
) returns a simple message indicating a blank page.
1 2 3 4 |
@app.get('/') async def endpoint(): return "You have reached a blank page" |
- The tiles endpoint (
/tiles/{z}/{x}/{y}.mvt
) takes thez
,x
, andy
coordinates of a tile, fetches corresponding data from the PostGIS database, compresses the data usinggzip
, and returns the data as a response.
1 2 3 4 5 6 7 8 9 |
@app.get('/tiles/{z}/{x}/{y}.mvt') async def tiles(z: int, x: int, y: int): sql = sql_template.format(z=z, x=x, y=y) result = session.execute(sql).scalar() if result: return Response(content=gzip.compress(result.tobytes()), media_type='application/vnd.mapbox-vector-tile', headers={'Content-Encoding': 'gzip'}) else: return Response(content=gzip.compress(b''), media_type='application/vnd.mapbox-vector-tile') |
Running the Application
Finally, the FastAPI application can be started using Uvicorn, an ASGI server, with the specified host and port.
1 2 3 |
if __name__ == '__main__': uvicorn.run(app, host='0.0.0.0', port=8000) |
Your FastAPI application is now up and running and will serve vector tiles from your PostGIS database.
Rendering Dynamically Generated Vector Tiles in OpenLayers
Now that you have a FastAPI server generating vector tiles from a PostGIS database, let’s see how we can visualize these tiles in a web map using OpenLayers.
OpenLayers is a powerful, open-source JavaScript library used for displaying rich interactive maps.
Firstly, ensure that OpenLayers is included in your project. You can include the OpenLayers package directly from a CDN in your HTML file.
1 2 3 |
<script src="https://cdn.jsdelivr.net/gh/openlayers/openlayers.github.io@master/en/v6.5.0/build/ol.js"></script> <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/openlayers/openlayers.github.io@master/en/v6.5.0/css/ol.css"> |
Once you have OpenLayers ready, you can proceed to create a map and add a vector tile layer.
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 |
// Define a VectorTile source var vectorTileSource = new ol.source.VectorTile({ format: new ol.format.MVT(), url: 'http://localhost:8000/tiles/{z}/{x}/{y}.mvt' }); // Define a VectorTile layer var vectorTileLayer = new ol.layer.VectorTile({ source: vectorTileSource, style: new ol.style.Style({ fill: new ol.style.Fill({ color: 'rgba(0, 0, 255, 0.1)' }), stroke: new ol.style.Stroke({ color: '#11b', width: 2 }) }) }); // Create the map var map = new ol.Map({ layers: [ new ol.layer.Tile({ source: new ol.source.OSM() // Base map layer }), vectorTileLayer // VectorTile layer ], target: 'map', // The id of the div element in your HTML where the map will be placed view: new ol.View({ center: [0, 0], zoom: 2 }) }); |
In this JavaScript snippet, we are:
- Creating a
VectorTile
source, pointing to our FastAPI server’s tile endpoint. - Defining a
VectorTile
layer, setting its source to theVectorTile
source and providing a simple style for the features. - Creating a
Map
, adding an OSM base layer and ourVectorTile
layer, and setting a default view.
Ensure to replace 'http://localhost:8000/tiles/{z}/{x}/{y}.mvt'
with your actual FastAPI server’s tile endpoint if it’s different.
After completing these steps, you should be able to see your dynamically generated vector tiles from your FastAPI server displayed on your OpenLayers map.
Complete Python Code for dynamically generating Vector Tiles from PostGIS using FastAPI
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 |
import gzip from fastapi import FastAPI from fastapi.responses import Response from fastapi.staticfiles import StaticFiles from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker import uvicorn app = FastAPI() # Mount the static files directory to the root of the web app app.mount("/static", StaticFiles(directory="static"), name="static") # 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() # 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; ''' @app.get('/') async def endpoint(): return "You have reached a blank page" @app.get('/tiles/{z}/{x}/{y}.mvt') async def tiles(z: int, x: int, y: int): sql = sql_template.format(z=z, x=x, y=y) result = session.execute(sql).scalar() if result: return Response(content=gzip.compress(result.tobytes()), media_type='application/vnd.mapbox-vector-tile', headers={'Content-Encoding': 'gzip'}) else: return Response(content=gzip.compress(b''), media_type='application/vnd.mapbox-vector-tile') if __name__ == '__main__': uvicorn.run(app, host='0.0.0.0', port=8000) |
Complete OpenLayers Code for rendering dynamically generated Vector Tiles from FastAPI end point
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: 'http://localhost:8000/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: 14, 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> |
Conclusion
By following this guide, you’ve taken the first steps into serving vector tiles from a PostGIS database using FastAPI. You can now adapt this framework to suit the specifics of your application. FastAPI’s speed and flexibility make it an excellent tool for serving data in web applications.
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.