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>
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 labelstext-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: '© 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/maxzoomper 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
minzoomon layers to avoid rendering at useless zoom levels text-allow-overlap: falseon all symbol layers- Use
text-optional: truefor labels - Don't add GeoJSON sources for >20K features
- Use
queryRenderedFeatures(notquerySourceFeatures) 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
- GeoJSON
setData()freezes main thread —JSON.stringifyruns synchronously for every update queryRenderedFeaturesreturns simplified geometry — don't use for area/distance calculations- Vector tile properties may be truncated — tile servers can drop properties to fit tile size limits
- Basemap switch requires full map recreation — save/restore view state and re-add all overlay layers
text-fontmust match basemap fonts — if using vector basemap, use its font stack; if raster, you need a glyph server- Popup/tooltip on dense data causes flicker — debounce mousemove handlers
- Large fill layers without
minzoomtank performance — 100K polygons at z0 is pathological map.setFilterwith huge ID lists is slow — for >1000 selected features, consider a separate GeoJSON source- MapLibre CSS must be loaded manually in SSR frameworks — inject
<link>inuseEffector import statically - React strict mode double-mounts effects — guard map initialization with ref check