# Skill: PMTiles Generation Pipeline from PostGIS ## When to Use When you need to pre-generate vector tiles from PostGIS data for fast static serving. Ideal for overview/boundary layers that change infrequently, serving from S3/MinIO/CDN without a tile server, or eliminating database load for tile serving. --- ## Complete Pipeline ### Prerequisites | Tool | Purpose | Install | |---|---|---| | ogr2ogr (GDAL) | PostGIS export + reprojection | `apt install gdal-bin` or Docker | | tippecanoe | MVT tile generation → PMTiles | `ghcr.io/felt/tippecanoe` Docker image | | mc (MinIO client) | Upload to MinIO/S3 | `brew install minio/stable/mc` | ### Step 1: Export from PostGIS ```bash # Single layer — FlatGeobuf is fastest for tippecanoe input ogr2ogr -f FlatGeobuf \ -s_srs EPSG:3844 \ # source SRID (your data) -t_srs EPSG:4326 \ # tippecanoe REQUIRES WGS84 parcels.fgb \ "PG:host=10.10.10.166 dbname=mydb user=myuser password=mypass" \ -sql "SELECT id, name, area, geom FROM my_view WHERE geom IS NOT NULL" # Multiple layers in parallel ogr2ogr -f FlatGeobuf -s_srs EPSG:3844 -t_srs EPSG:4326 \ parcels.fgb "PG:..." -sql "SELECT ... FROM gis_terenuri" & ogr2ogr -f FlatGeobuf -s_srs EPSG:3844 -t_srs EPSG:4326 \ buildings.fgb "PG:..." -sql "SELECT ... FROM gis_cladiri" & ogr2ogr -f FlatGeobuf -s_srs EPSG:3844 -t_srs EPSG:4326 \ uats.fgb "PG:..." -sql "SELECT ... FROM gis_uats_z12" & wait ``` **Why FlatGeobuf over GeoJSON:** - Binary columnar format — tippecanoe reads it 3-5x faster - No JSON parsing overhead - Streaming read (no need to load entire file in memory) - tippecanoe native support since v2.17+ ### Step 2: Generate PMTiles with tippecanoe ```bash # Single layer tippecanoe \ -o parcels.pmtiles \ --name="Parcels" \ --layer="parcels" \ --minimum-zoom=6 \ --maximum-zoom=15 \ --base-zoom=15 \ --drop-densest-as-needed \ --extend-zooms-if-still-dropping \ --detect-shared-borders \ --simplification=10 \ --hilbert \ --force \ parcels.fgb # Multi-layer (combined file) tippecanoe \ -o combined.pmtiles \ --named-layer=parcels:parcels.fgb \ --named-layer=buildings:buildings.fgb \ --named-layer=uats:uats.fgb \ --minimum-zoom=0 \ --maximum-zoom=15 \ --drop-densest-as-needed \ --detect-shared-borders \ --hilbert \ --force ``` #### Key tippecanoe Flags | Flag | Purpose | When to Use | |---|---|---| | `--minimum-zoom=N` | Lowest zoom level | Always set | | `--maximum-zoom=N` | Highest zoom level (full detail) | Always set | | `--base-zoom=N` | Zoom where ALL features kept (no dropping) | Set to max-zoom | | `--drop-densest-as-needed` | Drop features in dense areas at low zoom | Large polygon datasets | | `--extend-zooms-if-still-dropping` | Auto-increase max zoom if needed | Safety net | | `--detect-shared-borders` | Prevent gaps between adjacent polygons | **Critical for parcels/admin boundaries** | | `--coalesce-densest-as-needed` | Merge small features at low zoom | Building footprints | | `--simplification=N` | Pixel tolerance for geometry simplification | Reduce tile size at low zoom | | `--hilbert` | Hilbert curve ordering | Better compression, always use | | `-y col1 -y col2` | Include ONLY these properties | Reduce tile size | | `-x col1 -x col2` | Exclude these properties | Remove large/unnecessary fields | | `--force` | Overwrite existing output | Scripts | | `--no-feature-limit` | No limit per tile | When density matters | | `--no-tile-size-limit` | No tile byte limit | When completeness matters | #### Property Control ```bash # Include only specific properties (whitelist) tippecanoe -o out.pmtiles -y name -y area -y type parcels.fgb # Exclude specific properties (blacklist) tippecanoe -o out.pmtiles -x raw_json -x internal_id parcels.fgb # Zoom-dependent properties (different attributes per zoom) # Use tippecanoe-json format with per-feature "tippecanoe" key ``` ### Step 3: Upload to MinIO (Atomic Swap) ```bash # Upload to temp name first mc cp combined.pmtiles myminio/tiles/combined_new.pmtiles # Atomic rename (zero-downtime swap) mc mv myminio/tiles/combined_new.pmtiles myminio/tiles/combined.pmtiles ``` ### Step 4: MinIO CORS Configuration ```bash # Required for browser-direct Range Requests mc admin config set myminio api cors_allow_origin="https://tools.beletage.ro" # Or bucket policy for public read mc anonymous set download myminio/tiles ``` MinIO CORS must expose Range/Content-Range headers: ```json { "CORSRules": [{ "AllowedOrigins": ["https://your-domain.com"], "AllowedMethods": ["GET", "HEAD"], "AllowedHeaders": ["Range", "If-None-Match"], "ExposeHeaders": ["Content-Range", "Content-Length", "ETag"], "MaxAgeSeconds": 3600 }] } ``` --- ## MapLibre GL JS Integration ```bash npm install pmtiles ``` ```typescript import maplibregl from 'maplibre-gl'; import { Protocol } from 'pmtiles'; // Register ONCE at app initialization const protocol = new Protocol(); maplibregl.addProtocol('pmtiles', protocol.tile); // Add source to map map.addSource('my-tiles', { type: 'vector', url: 'pmtiles://https://minio.example.com/tiles/combined.pmtiles', }); // Add layers map.addLayer({ id: 'parcels-fill', type: 'fill', source: 'my-tiles', 'source-layer': 'parcels', // layer name from tippecanoe --layer or --named-layer minzoom: 10, maxzoom: 16, paint: { 'fill-color': '#22c55e', 'fill-opacity': 0.15 }, }); // Cleanup on unmount maplibregl.removeProtocol('pmtiles'); ``` --- ## Hybrid Architecture (PMTiles + Live Tile Server) ``` Zoom 0-14: PMTiles from MinIO (pre-generated, ~5ms, zero DB load) Zoom 14+: Martin from PostGIS (live, always-current, ~50-200ms) ``` ```typescript // PMTiles for overview map.addSource('overview', { type: 'vector', url: 'pmtiles://https://minio/tiles/overview.pmtiles', }); // Martin for detail map.addSource('detail', { type: 'vector', tiles: ['https://tiles.example.com/{source}/{z}/{x}/{y}'], minzoom: 14, maxzoom: 18, }); // Layers with zoom handoff map.addLayer({ id: 'parcels-overview', source: 'overview', 'source-layer': 'parcels', minzoom: 6, maxzoom: 14, // PMTiles handles low zoom ... }); map.addLayer({ id: 'parcels-detail', source: 'detail', 'source-layer': 'gis_terenuri', minzoom: 14, // Martin handles high zoom ... }); ``` --- ## Rebuild Strategies ### Nightly Cron ```bash # crontab -e 0 2 * * * /opt/scripts/rebuild-tiles.sh >> /var/log/tile-rebuild.log 2>&1 ``` ### After Data Sync (webhook/API trigger) ```bash # Call from sync completion handler curl -X POST http://n8n:5678/webhook/rebuild-tiles ``` ### Partial Rebuild (single layer update) ```bash # Rebuild just parcels, then merge with existing layers tippecanoe -o parcels_new.pmtiles ... parcels.fgb tile-join -o combined_new.pmtiles --force \ parcels_new.pmtiles \ buildings_existing.pmtiles \ uats_existing.pmtiles mc cp combined_new.pmtiles myminio/tiles/combined.pmtiles ``` --- ## Build Time Estimates | Features | Type | Zoom Range | Time | Output Size | |---|---|---|---|---| | 500 | Polygons (UAT) | z0-z12 | <5s | 10-30 MB | | 100K | Polygons (buildings) | z12-z15 | 30-90s | 100-200 MB | | 330K | Polygons (parcels) | z6-z15 | 2-5 min | 200-400 MB | | 1M | Polygons (mixed) | z0-z15 | 8-15 min | 500 MB-1 GB | tippecanoe is highly optimized and uses parallel processing. --- ## Common Pitfalls 1. **tippecanoe only accepts WGS84 (EPSG:4326)** — always reproject with ogr2ogr first 2. **`--detect-shared-borders` is critical for parcels** — without it, gaps appear between adjacent polygons 3. **GeoJSON input is slow** — use FlatGeobuf for 3-5x faster reads 4. **No incremental updates** — must rebuild entire file (use `tile-join` for layer-level replacement) 5. **MinIO needs CORS for browser-direct access** — Range + Content-Range headers must be exposed 6. **Large properties bloat tile size** — use `-y`/`-x` flags to control what goes into tiles 7. **`--no-tile-size-limit` can produce huge tiles** — use with `--drop-densest-as-needed` safety valve 8. **Atomic upload prevents serving partial files** — always upload as temp name then rename