fix(geoportal): mount Martin config + upgrade v1.4 + enable building labels
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>
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
# Skill: Vector Tile Serving from PostGIS
|
||||
|
||||
## When to Use
|
||||
|
||||
When building a web map that serves vector tiles from PostgreSQL/PostGIS data. Applies to any project using MapLibre GL JS, Mapbox GL JS, or OpenLayers with MVT tiles from a spatial database.
|
||||
|
||||
---
|
||||
|
||||
## Core Architecture Decision
|
||||
|
||||
**Always use a dedicated tile server over GeoJSON for datasets >20K features.**
|
||||
|
||||
GeoJSON limits:
|
||||
- 20K polygons: visible jank on `setData()`, 200-400ms freezes
|
||||
- 50K polygons: multi-second freezes, 500MB+ browser memory
|
||||
- 100K+ polygons: crashes mobile browsers, 1-2GB memory on desktop
|
||||
- `JSON.stringify` runs on main thread — blocks UI proportional to data size
|
||||
|
||||
Vector tiles (MVT) solve this:
|
||||
- Only visible tiles loaded (~50-200KB per viewport)
|
||||
- Incremental pan/zoom (no re-fetch)
|
||||
- ~100-200MB client memory regardless of total dataset size
|
||||
- Works on mobile
|
||||
|
||||
---
|
||||
|
||||
## Tile Server Rankings (Rechsteiner Benchmark, April 2025)
|
||||
|
||||
| Rank | Server | Language | Speed | Notes |
|
||||
|---|---|---|---|---|
|
||||
| 1 | **Martin** | Rust | 1x | Clear winner, 95-122ms range |
|
||||
| 2 | Tegola | Go | 2-3x slower | Only supports SRID 3857/4326 |
|
||||
| 3 | BBOX | Rust | ~same as Tegola | Unified raster+vector |
|
||||
| 4 | pg_tileserv | Go | ~4x slower | Zero-config but limited control |
|
||||
| 5 | TiPg | Python | Slower | Not for production scale |
|
||||
| 6 | ldproxy | Java | 4-70x slower | Enterprise/OGC compliance |
|
||||
|
||||
Source: [github.com/FabianRechsteiner/vector-tiles-benchmark](https://github.com/FabianRechsteiner/vector-tiles-benchmark)
|
||||
|
||||
---
|
||||
|
||||
## Martin: Best Practices
|
||||
|
||||
### Always use explicit config (not auto-discovery)
|
||||
|
||||
Auto-discovery can drop properties, misdetect SRIDs, and behave unpredictably with nested views.
|
||||
|
||||
```yaml
|
||||
postgres:
|
||||
connection_string: ${DATABASE_URL}
|
||||
default_srid: 3844 # your source SRID
|
||||
auto_publish: false # explicit sources only
|
||||
tables:
|
||||
my_layer:
|
||||
schema: public
|
||||
table: my_view_name
|
||||
geometry_column: geom
|
||||
srid: 3844
|
||||
bounds: [20.2, 43.5, 30.0, 48.3] # approximate extent
|
||||
minzoom: 10
|
||||
maxzoom: 18
|
||||
properties:
|
||||
object_id: text # explicit column name: pg_type
|
||||
name: text
|
||||
area: float8
|
||||
```
|
||||
|
||||
### Docker deployment
|
||||
|
||||
```yaml
|
||||
martin:
|
||||
image: ghcr.io/maplibre/martin:v1.4.0
|
||||
command: ["--config", "/config/martin.yaml"]
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://user:pass@host:5432/db
|
||||
volumes:
|
||||
- ./martin.yaml:/config/martin.yaml:ro
|
||||
ports:
|
||||
- "3010:3000"
|
||||
```
|
||||
|
||||
### Custom SRID handling
|
||||
|
||||
Martin handles non-4326/3857 SRIDs natively. Set `default_srid` globally or `srid` per source. Martin reprojects to Web Mercator (3857) internally for tile envelope calculations. Your PostGIS spatial indexes on the source SRID are used correctly.
|
||||
|
||||
### Zoom-dependent simplification
|
||||
|
||||
Create separate views per zoom range with `ST_SimplifyPreserveTopology`:
|
||||
|
||||
```sql
|
||||
-- z0-5: heavy simplification (2000m tolerance)
|
||||
CREATE VIEW my_layer_z0 AS
|
||||
SELECT id, name, ST_SimplifyPreserveTopology(geom, 2000) AS geom
|
||||
FROM my_table;
|
||||
|
||||
-- z8-12: moderate (50m)
|
||||
CREATE VIEW my_layer_z8 AS
|
||||
SELECT id, name, ST_SimplifyPreserveTopology(geom, 50) AS geom
|
||||
FROM my_table;
|
||||
|
||||
-- z12+: full precision
|
||||
CREATE VIEW my_layer_z12 AS
|
||||
SELECT * FROM my_table;
|
||||
```
|
||||
|
||||
### Performance at 1M+ features
|
||||
|
||||
- Set `minzoom` per source to avoid pathological low-zoom tiles
|
||||
- Buildings: minzoom 14 (skip at overview levels)
|
||||
- Use zoom-dependent simplified views for boundaries
|
||||
- Add HTTP cache (nginx proxy_cache) in front of Martin
|
||||
- Consider PMTiles for static overview layers
|
||||
|
||||
---
|
||||
|
||||
## PMTiles: Pre-generated Tile Archives
|
||||
|
||||
Best for: static/rarely-changing layers, overview zoom levels, eliminating DB load.
|
||||
|
||||
### Pipeline
|
||||
|
||||
```bash
|
||||
# 1. Export from PostGIS, reproject to WGS84
|
||||
ogr2ogr -f FlatGeobuf -s_srs EPSG:3844 -t_srs EPSG:4326 \
|
||||
layer.fgb "PG:dbname=mydb" \
|
||||
-sql "SELECT id, name, geom FROM my_table"
|
||||
|
||||
# 2. Generate PMTiles
|
||||
tippecanoe -o output.pmtiles \
|
||||
--layer="my_layer" layer.fgb \
|
||||
--minimum-zoom=0 --maximum-zoom=14 \
|
||||
--drop-densest-as-needed \
|
||||
--detect-shared-borders \
|
||||
--hilbert --force
|
||||
|
||||
# 3. Serve from any HTTP server with Range request support (MinIO, nginx, CDN)
|
||||
```
|
||||
|
||||
### MapLibre integration
|
||||
|
||||
```typescript
|
||||
import { Protocol } from 'pmtiles';
|
||||
maplibregl.addProtocol('pmtiles', new Protocol().tile);
|
||||
|
||||
// Add source
|
||||
map.addSource('overview', {
|
||||
type: 'vector',
|
||||
url: 'pmtiles://https://my-server/tiles/overview.pmtiles',
|
||||
});
|
||||
```
|
||||
|
||||
### Hybrid approach (recommended for large datasets)
|
||||
|
||||
- PMTiles for overview (z0-z14): pre-generated, ~5ms serving, zero DB load
|
||||
- Martin for detail (z14+): live from PostGIS, always-current data
|
||||
- Rebuild PMTiles on schedule (nightly) or after data sync
|
||||
|
||||
---
|
||||
|
||||
## MLT (MapLibre Tiles) — Next-Gen Format (2026)
|
||||
|
||||
- 6x better compression than MVT (column-oriented layout)
|
||||
- 3.7-4.4x faster client decode (SIMD-friendly)
|
||||
- Martin v1.3+ supports serving MLT
|
||||
- MapLibre GL JS 5.x supports decoding MLT
|
||||
- Spec: [github.com/maplibre/maplibre-tile-spec](https://github.com/maplibre/maplibre-tile-spec)
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Martin auto-discovery drops properties** — always use explicit config with `auto_publish: false`
|
||||
2. **Nested views lose SRID metadata** — cast geometry: `geom::geometry(Geometry, 3844)`
|
||||
3. **GisUat.geometry is huge** — always `select` to exclude in list queries
|
||||
4. **Low-zoom tiles scan entire dataset** — use zoom-dependent simplified views
|
||||
5. **No tile cache by default** — add nginx/Varnish in front of any tile server
|
||||
6. **tippecanoe requires WGS84** — reproject from custom SRID before generating PMTiles
|
||||
7. **PMTiles not incrementally updatable** — full rebuild required on data change
|
||||
8. **Tegola doesn't support custom SRIDs** — only 3857/4326, requires ST_Transform everywhere
|
||||
9. **pg_tileserv `ST_Estimated_Extent` fails on views** — use materialized views or function layers
|
||||
10. **Martin caches source schema at startup** — restart after view DDL changes
|
||||
Reference in New Issue
Block a user