a75d0e1adc
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>
293 lines
9.0 KiB
Markdown
293 lines
9.0 KiB
Markdown
# 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 `<link>` in `useEffect` or import statically
|
|
10. **React strict mode double-mounts effects** — guard map initialization with ref check
|