# 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: ```typescript // 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: ```typescript // 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 ```typescript 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) ```typescript layout: { 'symbol-sort-key': ['*', -1, ['get', 'area_value']], // larger areas get priority } ``` --- ## Selection and Interaction Patterns ### Click Selection (single feature) ```typescript 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 ```typescript // 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: ```typescript // 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: ```typescript 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 ```typescript 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) ```typescript // 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 thread** — `JSON.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 `` in `useEffect` or import statically 10. **React strict mode double-mounts effects** — guard map initialization with ref check