a75d0e1adc
Root cause: martin.yaml was never mounted in docker-compose.yml — Martin ran in auto-discovery mode which dropped cadastral_ref from gis_cladiri tiles. Changes: - docker-compose: mount martin.yaml, upgrade Martin v0.15→v1.4.0, use --config - map-viewer: add cladiriLabel layer (cadastral_ref at z16+), wire into visibility - martin.yaml: update version comment - geoportal/: tile server evaluation doc + 3 skill files (vector tiles, PMTiles, MapLibre perf) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
8.0 KiB
8.0 KiB
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
# 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
# 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
# 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)
# 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
# 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:
{
"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
npm install pmtiles
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)
// 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
# crontab -e
0 2 * * * /opt/scripts/rebuild-tiles.sh >> /var/log/tile-rebuild.log 2>&1
After Data Sync (webhook/API trigger)
# Call from sync completion handler
curl -X POST http://n8n:5678/webhook/rebuild-tiles
Partial Rebuild (single layer update)
# 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
- tippecanoe only accepts WGS84 (EPSG:4326) — always reproject with ogr2ogr first
--detect-shared-bordersis critical for parcels — without it, gaps appear between adjacent polygons- GeoJSON input is slow — use FlatGeobuf for 3-5x faster reads
- No incremental updates — must rebuild entire file (use
tile-joinfor layer-level replacement) - MinIO needs CORS for browser-direct access — Range + Content-Range headers must be exposed
- Large properties bloat tile size — use
-y/-xflags to control what goes into tiles --no-tile-size-limitcan produce huge tiles — use with--drop-densest-as-neededsafety valve- Atomic upload prevents serving partial files — always upload as temp name then rename