Files
ArchiTools/geoportal/skill-vector-tile-serving.md
T
AI Assistant a75d0e1adc 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>
2026-03-27 10:28:20 +02:00

5.8 KiB

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


Martin: Best Practices

Always use explicit config (not auto-discovery)

Auto-discovery can drop properties, misdetect SRIDs, and behave unpredictably with nested views.

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

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:

-- 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

# 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

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',
});
  • 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

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