# 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: '&copy; 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
