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>
273 lines
8.0 KiB
Markdown
273 lines
8.0 KiB
Markdown
# 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
|