Last active
November 22, 2025 20:25
-
-
Save Maxxen/37e4a9f8595ea5e6a20c0c8fbbefe955 to your computer and use it in GitHub Desktop.
DuckDB Vector Tile Serve w/ Flask + MapLibre
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import duckdb | |
| import flask | |
| # Initialize Flask app | |
| app = flask.Flask(__name__) | |
| # Setup a global DuckDB connection with spatial extension loaded | |
| # Connect to a persistent database file with the geometry data | |
| config = {"allow_unsigned_extensions": "true"} | |
| con = duckdb.connect("tiles.db", True, config) | |
| # Install spatial from wherever you built it | |
| #con.execute("INSTALL spatial from <some path>") | |
| con.execute("load spatial") | |
| # Tile endpoint to serve vector tiles | |
| @app.route('/tiles/<int:z>/<int:x>/<int:y>.pbf') | |
| def get_tile(z, x, y): | |
| # Query to get the tile data from DuckDB | |
| # - Note that the geometry in table `t1` is assumed to be projected to `EPSG:3857` (Web Mercator) | |
| # - You may want to create an R-Tree index on the geometry column, or create a separate bounding box struct column | |
| # to perform range-filtering, or somehow pre-filter the geometries some other way before feeding them into | |
| # ST_AsMVTGeom if your dataset is large (and on disk) | |
| # Use con.cursor() to avoid threading issues with Flask | |
| with con.cursor() as local_con: | |
| tile_blob = local_con.execute(""" | |
| SELECT ST_AsMVT({ | |
| "geometry": ST_AsMVTGeom( | |
| geometry, | |
| ST_Extent(ST_TileEnvelope($1, $2, $3)) | |
| ) | |
| }) | |
| FROM t1 | |
| WHERE ST_Intersects(geometry, ST_TileEnvelope($1, $2, $3)) | |
| """, [z, x, y]).fetchone() | |
| # Send the tile data as a response | |
| tile = tile_blob[0] if tile_blob and tile_blob[0] else b'' | |
| return flask.Response(tile, mimetype='application/x-protobuf') | |
| # HTML content for the index page | |
| INDEX_HTML = """ | |
| <!DOCTYPE html> | |
| <html> | |
| <head> | |
| <meta charset="utf-8"> | |
| <title>Vector Tile Viewer</title> | |
| <meta name="viewport" content="initial-scale=1,maximum-scale=1,user-scalable=no"> | |
| <script src='https://unpkg.com/[email protected]/dist/maplibre-gl.js'></script> | |
| <link href='https://unpkg.com/[email protected]/dist/maplibre-gl.css' rel='stylesheet' /> | |
| <style> | |
| body { margin: 0; padding: 0; } | |
| #map { position: absolute; top: 0; bottom: 0; width: 100%; } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="map"></div> | |
| <script> | |
| const map = new maplibregl.Map({ | |
| container: 'map', | |
| style: { | |
| version: 8, | |
| sources: { | |
| 'buildings': { | |
| type: 'vector', | |
| tiles: [`${window.location.origin}/tiles/{z}/{x}/{y}.pbf`], | |
| minzoom: 10 | |
| }, | |
| // Also use a public open source basemap | |
| 'osm': { | |
| type: 'raster', | |
| tiles: [ | |
| 'https://a.tile.openstreetmap.org/{z}/{x}/{y}.png', | |
| 'https://b.tile.openstreetmap.org/{z}/{x}/{y}.png', | |
| 'https://c.tile.openstreetmap.org/{z}/{x}/{y}.png' | |
| ], | |
| tileSize: 256, | |
| minzoom: 10 | |
| } | |
| }, | |
| layers: [ | |
| { | |
| id: 'background', | |
| type: 'background', | |
| paint: { 'background-color': '#a0c8f0' } | |
| }, | |
| { | |
| id: 'osm', | |
| type: 'raster', | |
| source: 'osm', | |
| minzoom: 10, | |
| maxzoom: 19 | |
| }, | |
| { | |
| id: 'buildings-fill', | |
| type: 'fill', | |
| source: 'buildings', | |
| 'source-layer': 'layer', | |
| paint: { | |
| 'fill-color': 'blue', | |
| 'fill-opacity': 0.6, | |
| 'fill-outline-color': '#ffffff' | |
| } | |
| }, | |
| { | |
| id: 'buildings-stroke', | |
| type: 'line', | |
| source: 'buildings', | |
| 'source-layer': 'layer', | |
| paint: { | |
| 'line-color': 'black', | |
| 'line-width': 0.5 | |
| } | |
| } | |
| ] | |
| }, | |
| // Zoom in on new york | |
| center: [-74.0060, 40.7128], | |
| zoom: 12 | |
| }); | |
| map.addControl(new maplibregl.NavigationControl()); | |
| // Add click handler to show feature properties | |
| map.on('click', 'buildings-fill', (e) => { | |
| const coordinates = e.lngLat; | |
| const properties = e.features[0].properties; | |
| let popupContent = '<h3>Building Properties</h3>'; | |
| for (const [key, value] of Object.entries(properties)) { | |
| popupContent += `<p><strong>${key}:</strong> ${value}</p>`; | |
| } | |
| new maplibregl.Popup() | |
| .setLngLat(coordinates) | |
| .setHTML(popupContent) | |
| .addTo(map); | |
| }); | |
| // Change cursor on hover | |
| map.on('mouseenter', 'buildings-fill', () => { | |
| map.getCanvas().style.cursor = 'pointer'; | |
| }); | |
| map.on('mouseleave', 'buildings-fill', () => { | |
| map.getCanvas().style.cursor = ''; | |
| }); | |
| </script> | |
| </body> | |
| </html> | |
| """ | |
| # Serve the static HTML file for the index page | |
| @app.route("/") | |
| def index(): | |
| return flask.Response(INDEX_HTML, mimetype='text/html') | |
| if __name__ == '__main__': | |
| # Start on localhost | |
| app.run(debug=True) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Here is duckdb SQL to generate a
tiles.dbfile (~170mb) you can use. It holds New York City buildings using data from Overture Maps Buildings.