Files
ArchiTools/geoportal/skill-maplibre-performance.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

9.0 KiB

Skill: MapLibre GL JS Performance for Large GIS Datasets

When to Use

When building web maps with MapLibre GL JS that display large spatial datasets (>10K features). Covers source type selection, layer optimization, label rendering, and client-side performance tuning.


Source Type Decision Matrix

Dataset Size Recommended Source Reason
<2K features GeoJSON Simple, full property access, smooth
2K-20K features GeoJSON (careful) Works but setData() updates lag 200-400ms
20K-100K features Vector tiles (MVT) GeoJSON causes multi-second freezes
100K+ features Vector tiles (MVT) GeoJSON crashes mobile, 1GB+ memory on desktop
Static/archival PMTiles Pre-generated, ~5ms per tile, zero server load

GeoJSON Memory Profile

Features (polygons, ~20 coords each) JSON Size Browser Memory Load Time
1K 0.8 MB ~50 MB <1s
10K 8 MB ~200 MB 1-3s
50K 41 MB ~600 MB 5-15s freeze
100K 82 MB ~1.2 GB 15-30s freeze
330K 270 MB ~1.5 GB+ Crash

The bottleneck is JSON.stringify on the main thread when data is transferred to the Web Worker for geojson-vt tiling.


Vector Tile Source Configuration

Zoom-Dependent Source Loading

Don't load data you don't need. Set minzoom/maxzoom on sources and layers:

// Source: only request tiles in useful zoom range
map.addSource('parcels', {
  type: 'vector',
  tiles: ['https://tiles.example.com/parcels/{z}/{x}/{y}'],
  minzoom: 10,   // don't request below z10
  maxzoom: 18,   // server maxzoom (tiles overzoom beyond this)
});

// Layer: only render when meaningful
map.addLayer({
  id: 'parcels-fill',
  type: 'fill',
  source: 'parcels',
  'source-layer': 'parcels',
  minzoom: 13,   // visible from z13 (even if source loads from z10)
  maxzoom: 20,   // render up to z20 (overzooming tiles from z18)
  paint: { ... },
});

Multiple Sources at Different Detail Levels

For large datasets, serve simplified versions at low zoom:

// Simplified overview (server: ST_Simplify, fewer properties)
map.addSource('parcels-overview', {
  type: 'vector',
  tiles: ['https://tiles.example.com/parcels_simplified/{z}/{x}/{y}'],
  minzoom: 6, maxzoom: 14,
});

// Full detail
map.addSource('parcels-detail', {
  type: 'vector',
  tiles: ['https://tiles.example.com/parcels/{z}/{x}/{y}'],
  minzoom: 14, maxzoom: 18,
});

// Layers with zoom handoff
map.addLayer({
  id: 'parcels-overview-fill', source: 'parcels-overview',
  minzoom: 10, maxzoom: 14,  // disappears at z14
  ...
});
map.addLayer({
  id: 'parcels-detail-fill', source: 'parcels-detail',
  minzoom: 14,  // appears at z14
  ...
});

Label Rendering Best Practices

Text Labels on Polygons

map.addLayer({
  id: 'parcel-labels',
  type: 'symbol',
  source: 'parcels',
  'source-layer': 'parcels',
  minzoom: 16,  // only show labels at high zoom
  layout: {
    'text-field': ['coalesce', ['get', 'cadastral_ref'], ''],
    'text-font': ['Noto Sans Regular'],
    'text-size': 10,
    'text-anchor': 'center',
    'text-allow-overlap': false,    // prevent label collisions
    'text-max-width': 8,           // wrap long labels (in ems)
    'text-optional': true,          // label is optional — feature renders without it
    'symbol-placement': 'point',    // placed at polygon centroid
  },
  paint: {
    'text-color': '#1e3a5f',
    'text-halo-color': '#ffffff',
    'text-halo-width': 1,          // readability on any background
  },
});

Performance Tips for Labels

  • text-allow-overlap: false — essential for dense datasets, MapLibre auto-removes colliding labels
  • text-optional: true — allow symbol layer to show icon without text if text collides
  • High minzoom (16+) — labels are expensive to render, only show when meaningful
  • text-font — use fonts available in the basemap style. Custom fonts require glyph server.
  • symbol-sort-key — prioritize which labels show first (e.g., larger parcels)
layout: {
  'symbol-sort-key': ['*', -1, ['get', 'area_value']],  // larger areas get priority
}

Selection and Interaction Patterns

Click Selection (single feature)

map.on('click', 'parcels-fill', (e) => {
  const feature = e.features?.[0];
  if (!feature) return;
  const props = feature.properties;

  // Highlight via filter
  map.setFilter('selection-highlight', ['==', 'object_id', props.object_id]);
});

queryRenderedFeatures for Box/Polygon Selection

// Rectangle selection
const features = map.queryRenderedFeatures(
  [[x1, y1], [x2, y2]],  // pixel bbox
  { layers: ['parcels-fill'] }
);

// Features are from rendered tiles — properties may be limited
// For full properties, fetch from API by ID

Important: queryRenderedFeatures only returns features currently rendered in the viewport tiles. Properties in MVT tiles may be a subset of the full database record. For detailed properties, use a separate API endpoint.

Highlight Layer Pattern

Dedicated layer with dynamic filter for selection highlighting:

// Add once during map setup
map.addLayer({
  id: 'selection-fill',
  type: 'fill',
  source: 'parcels',
  'source-layer': 'parcels',
  filter: ['==', 'object_id', '__NONE__'],  // show nothing initially
  paint: { 'fill-color': '#f59e0b', 'fill-opacity': 0.5 },
});

// Update filter on selection
const ids = Array.from(selectedIds);
map.setFilter('selection-fill',
  ids.length > 0
    ? ['in', ['to-string', ['get', 'object_id']], ['literal', ids]]
    : ['==', 'object_id', '__NONE__']
);

Basemap Management

Multiple Basemap Support

Switching basemaps requires recreating the map (MapLibre limitation). Preserve view state:

const viewStateRef = useRef({ center: DEFAULT_CENTER, zoom: DEFAULT_ZOOM });

// Save on every move
map.on('moveend', () => {
  viewStateRef.current = {
    center: map.getCenter().toArray(),
    zoom: map.getZoom(),
  };
});

// On basemap switch: destroy map, recreate with saved view state
// All sources + layers must be re-added after style load

Raster Basemaps

const style: StyleSpecification = {
  version: 8,
  sources: {
    basemap: {
      type: 'raster',
      tiles: ['https://mt0.google.com/vt/lyrs=s&x={x}&y={y}&z={z}'],
      tileSize: 256,
      attribution: '&copy; Google',
    },
  },
  layers: [{
    id: 'basemap', type: 'raster', source: 'basemap',
    minzoom: 0, maxzoom: 20,
  }],
};

Vector Basemaps (OpenFreeMap, MapTiler)

// Style URL — includes all sources + layers
const map = new maplibregl.Map({
  style: 'https://tiles.openfreemap.org/styles/liberty',
});

// Hide unwanted built-in layers (e.g., admin boundaries you'll replace)
for (const layer of map.getStyle().layers) {
  if (/boundar|admin/i.test(layer.id)) {
    map.setLayoutProperty(layer.id, 'visibility', 'none');
  }
}

Performance Checklist

Server Side

  • Spatial index (GiST) on geometry column
  • Zoom-dependent simplified views for overview levels
  • minzoom/maxzoom per tile source to prevent pathological tiles
  • HTTP cache (nginx proxy_cache / Varnish) in front of tile server
  • PMTiles for static layers (no DB hit)
  • Exclude large geometry columns from list queries

Client Side

  • Set minzoom on layers to avoid rendering at useless zoom levels
  • text-allow-overlap: false on all symbol layers
  • Use text-optional: true for labels
  • Don't add GeoJSON sources for >20K features
  • Use queryRenderedFeatures (not querySourceFeatures) for interaction
  • Preserve view state across basemap switches (ref, not state)
  • Debounce viewport-dependent API calls (search, feature loading)

Memory Management

  • Remove unused sources/layers when switching views
  • Clear GeoJSON sources with setData(emptyFeatureCollection) before removing
  • Use map.remove() in cleanup (useEffect return)
  • Don't store large GeoJSON in React state (use refs)

Common Pitfalls

  1. GeoJSON setData() freezes main threadJSON.stringify runs synchronously for every update
  2. queryRenderedFeatures returns simplified geometry — don't use for area/distance calculations
  3. Vector tile properties may be truncated — tile servers can drop properties to fit tile size limits
  4. Basemap switch requires full map recreation — save/restore view state and re-add all overlay layers
  5. text-font must match basemap fonts — if using vector basemap, use its font stack; if raster, you need a glyph server
  6. Popup/tooltip on dense data causes flicker — debounce mousemove handlers
  7. Large fill layers without minzoom tank performance — 100K polygons at z0 is pathological
  8. map.setFilter with huge ID lists is slow — for >1000 selected features, consider a separate GeoJSON source
  9. MapLibre CSS must be loaded manually in SSR frameworks — inject <link> in useEffect or import statically
  10. React strict mode double-mounts effects — guard map initialization with ref check