-
-
Save do-me/c3f3216542d1587ff30b3c9aecec8825 to your computer and use it in GitHub Desktop.
DuckDB Vector Tile Serve w/ Flask + MapLibre. Loads geoparquet file with spatial sampling and uv tooling. Run with uv run app.py
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
| # /// script | |
| # dependencies = [ | |
| # "duckdb", | |
| # "flask" | |
| # ] | |
| # /// | |
| import duckdb | |
| import flask | |
| import gzip | |
| import os | |
| # Configuration | |
| GEOPARQUET_PATH = "your_geoparquet_file.parquet" | |
| DB_PATH = "tiles_cache.db" | |
| POINTS_PER_TILE_GOAL = 10000 | |
| app = flask.Flask(__name__) | |
| # --- AUTOMATIC CLEANUP ON START --- | |
| if os.path.exists(DB_PATH): | |
| print(f"Cleaning up old database: {DB_PATH}") | |
| os.remove(DB_PATH) | |
| TOTAL_POINTS = 0 | |
| def initialize_database(): | |
| global TOTAL_POINTS | |
| conn = duckdb.connect(DB_PATH) | |
| conn.execute("LOAD spatial") | |
| print("Initializing database...") | |
| # Hash gives uniform distribution 0-1 | |
| conn.execute(f""" | |
| CREATE TABLE geo_data AS | |
| SELECT DISTINCT ON (ST_X(geometry), ST_Y(geometry)) | |
| *, | |
| hash(ST_X(geometry), ST_Y(geometry)) / 18446744073709551615.0 as priority, | |
| ST_Transform(ST_FlipCoordinates(geometry), 'EPSG:4326', 'EPSG:3857') as geom_3857 | |
| FROM read_parquet('{GEOPARQUET_PATH}')""") | |
| TOTAL_POINTS = conn.execute("SELECT COUNT(*) FROM geo_data").fetchone()[0] | |
| print(f"Total points in dataset: {TOTAL_POINTS}") | |
| print("Creating indexes...") | |
| conn.execute("CREATE INDEX idx_spatial ON geo_data USING RTREE (geom_3857)") | |
| conn.execute("CREATE INDEX idx_priority ON geo_data (priority)") | |
| conn.close() | |
| def get_db(): | |
| conn = duckdb.connect(DB_PATH, read_only=True) | |
| conn.execute("LOAD spatial") | |
| return conn | |
| @app.route('/tiles/<int:z>/<int:x>/<int:y>.pbf') | |
| def get_tile(z, x, y): | |
| if TOTAL_POINTS == 0: return b'' | |
| threshold = (POINTS_PER_TILE_GOAL * (4 ** z)) / TOTAL_POINTS | |
| threshold = max(0.00001, min(1.0, threshold)) | |
| conn = get_db() | |
| try: | |
| query = """ | |
| WITH tile_env AS ( | |
| SELECT ST_TileEnvelope($z, $x, $y) as env_geom, | |
| ST_Extent(ST_TileEnvelope($z, $x, $y))::BOX_2D as env_box | |
| ), | |
| sampled_data AS ( | |
| SELECT | |
| geom_3857, | |
| COLUMNS(* EXCLUDE (geom_3857, geometry, priority)) | |
| FROM geo_data | |
| WHERE ST_Intersects(geom_3857, (SELECT env_geom FROM tile_env)) | |
| AND priority <= $threshold | |
| LIMIT 2000 | |
| ), | |
| mvt_prep AS ( | |
| SELECT | |
| ST_AsMVTGeom(geom_3857, (SELECT env_box FROM tile_env), 4096, 256, true) as geom, | |
| * EXCLUDE (geom_3857) | |
| FROM sampled_data | |
| ) | |
| SELECT ST_AsMVT(mvt_prep, 'layer') FROM mvt_prep WHERE geom IS NOT NULL | |
| """ | |
| result = conn.execute(query, { | |
| "z": z, "x": x, "y": y, | |
| "threshold": threshold | |
| }).fetchone() | |
| tile_data = result[0] if result and result[0] else b'' | |
| if not tile_data: | |
| return flask.Response(b'', mimetype='application/x-protobuf') | |
| response = flask.Response(gzip.compress(tile_data), mimetype='application/x-protobuf') | |
| response.headers['Content-Encoding'] = 'gzip' | |
| response.headers['Access-Control-Allow-Origin'] = '*' | |
| return response | |
| finally: | |
| conn.close() | |
| @app.route("/") | |
| def index(): | |
| return """ | |
| <!DOCTYPE html> | |
| <html> | |
| <head> | |
| <title>DuckDB Density-Preserving Tiles</title> | |
| <script src='https://unpkg.com/maplibre-gl@4.0.0/dist/maplibre-gl.js'></script> | |
| <link href='https://unpkg.com/maplibre-gl@4.0.0/dist/maplibre-gl.css' rel='stylesheet' /> | |
| <style> | |
| body { margin: 0; font-family: sans-serif; } | |
| #map { position: absolute; width: 100%; height: 100%; } | |
| #controls { | |
| position: absolute; | |
| top: 10px; | |
| left: 10px; | |
| background: white; | |
| padding: 15px; | |
| border-radius: 8px; | |
| box-shadow: 0 0 10px rgba(0,0,0,0.1); | |
| width: 250px; | |
| z-index: 100; | |
| } | |
| .control-group { margin-bottom: 12px; } | |
| .control-group label { display: block; font-size: 12px; font-weight: bold; margin-bottom: 5px; color: #333; } | |
| .control-group input[type="text"] { width: 100%; padding: 5px; box-sizing: border-box; font-size: 12px; margin-bottom: 5px;} | |
| .control-group button { width: 100%; padding: 5px; background: #3b82f6; color: white; border: none; border-radius: 4px; cursor: pointer; } | |
| .control-group button:hover { background: #2563eb; } | |
| .row { display: flex; align-items: center; justify-content: space-between; } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="controls"> | |
| <div class="control-group"> | |
| <label>Point Radius (Base)</label> | |
| <div class="row"> | |
| <input type="range" id="radius" min="1" max="20" value="4" step="0.5" style="flex-grow:1; margin-right: 10px;"> | |
| <span id="radius-val" style="font-size:12px;">4px</span> | |
| </div> | |
| </div> | |
| <div class="control-group"> | |
| <label>Point Color</label> | |
| <div class="row"> | |
| <input type="color" id="color" value="#ef4444" style="width: 100%; height: 30px; border: none; padding: 0;"> | |
| </div> | |
| </div> | |
| <div class="control-group"> | |
| <label>Map Style URL</label> | |
| <input type="text" id="style-url" value="https://tiles.openfreemap.org/styles/liberty"> | |
| <button onclick="updateStyle()">Apply Style</button> | |
| </div> | |
| </div> | |
| <div id="map"></div> | |
| <script> | |
| // 1. Define configuration objects outside the map load event | |
| // so they can be reused easily. | |
| const SOURCE_ID = 'duckdb'; | |
| const LAYER_ID = 'pts'; | |
| const duckdbSourceConfig = { | |
| type: 'vector', | |
| tiles: [window.location.origin + '/tiles/{z}/{x}/{y}.pbf'], | |
| minzoom: 0, maxzoom: 22 | |
| }; | |
| const duckdbLayerConfig = { | |
| id: LAYER_ID, | |
| type: 'circle', | |
| source: SOURCE_ID, | |
| 'source-layer': 'layer', | |
| paint: { | |
| 'circle-radius': ['interpolate', ['linear'], ['zoom'], 3, 1.5, 10, 4], | |
| 'circle-color': '#ef4444', | |
| 'circle-stroke-width': 1, | |
| 'circle-stroke-color': '#fff', | |
| 'circle-opacity': 0.8 | |
| } | |
| }; | |
| const map = new maplibregl.Map({ | |
| container: 'map', | |
| style: 'https://tiles.openfreemap.org/styles/liberty', | |
| center: [15, 55], zoom: 4, | |
| fadeDuration: 0 | |
| }); | |
| // 2. Helper function to safely add layers back | |
| function restoreLayers() { | |
| if (!map.getSource(SOURCE_ID)) { | |
| map.addSource(SOURCE_ID, duckdbSourceConfig); | |
| } | |
| if (!map.getLayer(LAYER_ID)) { | |
| map.addLayer(duckdbLayerConfig); | |
| } | |
| // Apply current UI settings to the restored layer | |
| updateLayerPaint(); | |
| } | |
| map.on('load', () => { | |
| restoreLayers(); | |
| }); | |
| // --- UI Interactions --- | |
| const radiusInput = document.getElementById('radius'); | |
| const radiusVal = document.getElementById('radius-val'); | |
| const colorInput = document.getElementById('color'); | |
| function updateLayerPaint() { | |
| if (!map.getLayer(LAYER_ID)) return; | |
| const r = parseFloat(radiusInput.value); | |
| radiusVal.innerText = r + 'px'; | |
| const c = colorInput.value; | |
| map.setPaintProperty(LAYER_ID, 'circle-color', c); | |
| // Recalculate interpolation based on new base size | |
| map.setPaintProperty(LAYER_ID, 'circle-radius', [ | |
| 'interpolate', ['linear'], ['zoom'], | |
| 3, r * 0.375, | |
| 10, r | |
| ]); | |
| } | |
| radiusInput.addEventListener('input', updateLayerPaint); | |
| colorInput.addEventListener('input', updateLayerPaint); | |
| function updateStyle() { | |
| const url = document.getElementById('style-url').value; | |
| if (!url) return; | |
| // setStyle removes all sources/layers. | |
| // We listen for the 'style.load' event (triggered when new style is ready) | |
| // to add our custom data back. | |
| map.setStyle(url); | |
| map.once('style.load', () => { | |
| restoreLayers(); | |
| }); | |
| } | |
| </script> | |
| </body> | |
| </html> | |
| """ | |
| if __name__ == '__main__': | |
| initialize_database() | |
| app.run(threaded=True, host='0.0.0.0', port=5002) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment