maplibre-react
SKILL.md
MapLibre GL JS in React
Basic Map Component
// src/components/map/TacticalMap.tsx
'use client';
import { useEffect, useRef, useCallback } from 'react';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import { useIncidentStore } from '@/stores/incidents';
const NYC_CENTER: [number, number] = [-73.98, 40.75];
const DEFAULT_ZOOM = 11;
export function TacticalMap() {
const mapContainer = useRef<HTMLDivElement>(null);
const map = useRef<maplibregl.Map | null>(null);
const { incidents, selectIncident } = useIncidentStore();
// Initialize map once
useEffect(() => {
if (!mapContainer.current || map.current) return;
map.current = new maplibregl.Map({
container: mapContainer.current,
style: '/map-style-dark.json',
center: NYC_CENTER,
zoom: DEFAULT_ZOOM,
maxZoom: 18,
minZoom: 8,
});
map.current.on('load', () => {
setupSources(map.current!);
setupLayers(map.current!);
setupEventHandlers(map.current!);
});
// Cleanup
return () => {
map.current?.remove();
map.current = null;
};
}, []);
// Update incidents when data changes
useEffect(() => {
if (!map.current?.isStyleLoaded()) return;
const source = map.current.getSource('incidents') as maplibregl.GeoJSONSource;
if (source) {
source.setData(incidentsToGeoJSON(incidents));
}
}, [incidents]);
return (
<div
ref={mapContainer}
className="w-full h-full"
style={{ background: '#0a0a0f' }}
/>
);
}
GeoJSON Conversion
// src/lib/geo.ts
import type { Incident } from '@/types/incidents';
interface GeoJSONFeature {
type: 'Feature';
geometry: {
type: 'Point';
coordinates: [number, number];
};
properties: {
id: string;
title: string;
category: string;
severity: string;
eventTime: string;
};
}
export function incidentsToGeoJSON(incidents: Incident[]): GeoJSON.FeatureCollection {
const features: GeoJSONFeature[] = incidents
.filter(i => i.location)
.map(incident => ({
type: 'Feature',
geometry: incident.location!,
properties: {
id: incident.id,
title: incident.title,
category: incident.category,
severity: incident.severity,
eventTime: incident.eventTime.toISOString(),
},
}));
return {
type: 'FeatureCollection',
features,
};
}
Source Setup with Clustering
function setupSources(map: maplibregl.Map) {
map.addSource('incidents', {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
cluster: true,
clusterMaxZoom: 14, // Cluster until zoom 14
clusterRadius: 50, // Pixel radius for clustering
clusterProperties: {
// Aggregate severity counts per cluster
critical: ['+', ['case', ['==', ['get', 'severity'], 'critical'], 1, 0]],
high: ['+', ['case', ['==', ['get', 'severity'], 'high'], 1, 0]],
},
});
}
Layer Setup
// Severity color mapping
const SEVERITY_COLORS = {
critical: '#ff2d55',
high: '#ff6b35',
moderate: '#ffb800',
low: '#00d4ff',
info: '#a1a1aa',
};
function setupLayers(map: maplibregl.Map) {
// Cluster circles
map.addLayer({
id: 'clusters',
type: 'circle',
source: 'incidents',
filter: ['has', 'point_count'],
paint: {
'circle-color': [
'case',
['>', ['get', 'critical'], 0], SEVERITY_COLORS.critical,
['>', ['get', 'high'], 0], SEVERITY_COLORS.high,
SEVERITY_COLORS.moderate,
],
'circle-radius': [
'step',
['get', 'point_count'],
20, // 20px for < 10
10, 30, // 30px for 10-49
50, 40, // 40px for 50+
],
'circle-opacity': 0.8,
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff',
},
});
// Cluster count labels
map.addLayer({
id: 'cluster-count',
type: 'symbol',
source: 'incidents',
filter: ['has', 'point_count'],
layout: {
'text-field': '{point_count_abbreviated}',
'text-font': ['Open Sans Bold'],
'text-size': 14,
},
paint: {
'text-color': '#ffffff',
},
});
// Individual incident points
map.addLayer({
id: 'incidents-point',
type: 'circle',
source: 'incidents',
filter: ['!', ['has', 'point_count']],
paint: {
'circle-color': [
'match',
['get', 'severity'],
'critical', SEVERITY_COLORS.critical,
'high', SEVERITY_COLORS.high,
'moderate', SEVERITY_COLORS.moderate,
'low', SEVERITY_COLORS.low,
SEVERITY_COLORS.info,
],
'circle-radius': 8,
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff',
},
});
// Pulsing animation for critical incidents
map.addLayer({
id: 'incidents-pulse',
type: 'circle',
source: 'incidents',
filter: ['all',
['!', ['has', 'point_count']],
['==', ['get', 'severity'], 'critical'],
],
paint: {
'circle-color': SEVERITY_COLORS.critical,
'circle-radius': 16,
'circle-opacity': 0.3,
},
});
}
Event Handlers
function setupEventHandlers(map: maplibregl.Map) {
// Change cursor on hover
map.on('mouseenter', 'incidents-point', () => {
map.getCanvas().style.cursor = 'pointer';
});
map.on('mouseleave', 'incidents-point', () => {
map.getCanvas().style.cursor = '';
});
// Click handler for incidents
map.on('click', 'incidents-point', (e) => {
if (!e.features?.length) return;
const feature = e.features[0];
const id = feature.properties?.id;
if (id) {
// Update store
useIncidentStore.getState().selectIncident(id);
// Center on incident
const coords = (feature.geometry as GeoJSON.Point).coordinates as [number, number];
map.flyTo({ center: coords, zoom: 15 });
}
});
// Click handler for clusters
map.on('click', 'clusters', async (e) => {
const features = map.queryRenderedFeatures(e.point, { layers: ['clusters'] });
if (!features.length) return;
const clusterId = features[0].properties?.cluster_id;
const source = map.getSource('incidents') as maplibregl.GeoJSONSource;
const zoom = await source.getClusterExpansionZoom(clusterId);
const coords = (features[0].geometry as GeoJSON.Point).coordinates as [number, number];
map.flyTo({ center: coords, zoom });
});
}
Popup Component
// src/components/map/IncidentPopup.tsx
'use client';
import { useEffect, useRef } from 'react';
import maplibregl from 'maplibre-gl';
import { createRoot } from 'react-dom/client';
import type { Incident } from '@/types/incidents';
interface Props {
map: maplibregl.Map;
incident: Incident;
onClose: () => void;
}
export function IncidentPopup({ map, incident, onClose }: Props) {
const popupRef = useRef<maplibregl.Popup | null>(null);
useEffect(() => {
if (!incident.location) return;
const container = document.createElement('div');
const root = createRoot(container);
root.render(
<div className="p-3 min-w-[200px]">
<div className="flex items-center gap-2 mb-2">
<span className={`w-2 h-2 rounded-full bg-severity-${incident.severity}`} />
<span className="text-xs uppercase text-text-secondary">
{incident.category}
</span>
</div>
<h3 className="font-medium text-sm mb-1">{incident.title}</h3>
{incident.locationText && (
<p className="text-xs text-text-secondary">{incident.locationText}</p>
)}
<p className="text-xs text-text-muted mt-2">
{new Date(incident.eventTime).toLocaleTimeString()}
</p>
</div>
);
popupRef.current = new maplibregl.Popup({
closeButton: true,
closeOnClick: false,
className: 'incident-popup',
})
.setLngLat(incident.location.coordinates as [number, number])
.setDOMContent(container)
.addTo(map);
popupRef.current.on('close', onClose);
return () => {
root.unmount();
popupRef.current?.remove();
};
}, [map, incident, onClose]);
return null;
}
Popup Styles
/* src/app/globals.css */
.incident-popup .maplibregl-popup-content {
background: var(--bg-secondary);
border: 1px solid var(--bg-tertiary);
border-radius: 8px;
padding: 0;
color: var(--text-primary);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
}
.incident-popup .maplibregl-popup-tip {
border-top-color: var(--bg-secondary);
}
.incident-popup .maplibregl-popup-close-button {
color: var(--text-secondary);
font-size: 18px;
padding: 4px 8px;
}
Dark Tactical Map Style
// public/map-style-dark.json
{
"version": 8,
"name": "Tactical Dark",
"sources": {
"protomaps": {
"type": "vector",
"url": "pmtiles:///tiles/nyc.pmtiles"
}
},
"layers": [
{
"id": "background",
"type": "background",
"paint": { "background-color": "#0a0a0f" }
},
{
"id": "water",
"type": "fill",
"source": "protomaps",
"source-layer": "water",
"paint": { "fill-color": "#12121a" }
},
{
"id": "roads",
"type": "line",
"source": "protomaps",
"source-layer": "roads",
"paint": {
"line-color": "#1a1a24",
"line-width": 1
}
},
{
"id": "buildings",
"type": "fill",
"source": "protomaps",
"source-layer": "buildings",
"paint": {
"fill-color": "#15151f",
"fill-opacity": 0.5
}
}
]
}
Fit Bounds to Incidents
function fitToIncidents(map: maplibregl.Map, incidents: Incident[]) {
const validIncidents = incidents.filter(i => i.location);
if (validIncidents.length === 0) return;
const bounds = new maplibregl.LngLatBounds();
validIncidents.forEach(incident => {
bounds.extend(incident.location!.coordinates as [number, number]);
});
map.fitBounds(bounds, {
padding: 50,
maxZoom: 15,
duration: 1000,
});
}
Animate to Location
function flyToIncident(map: maplibregl.Map, incident: Incident) {
if (!incident.location) return;
map.flyTo({
center: incident.location.coordinates as [number, number],
zoom: 16,
duration: 1500,
essential: true,
});
}
Get Visible Bounds
function getVisibleBbox(map: maplibregl.Map): [number, number, number, number] {
const bounds = map.getBounds();
return [
bounds.getWest(), // sw_lng
bounds.getSouth(), // sw_lat
bounds.getEast(), // ne_lng
bounds.getNorth(), // ne_lat
];
}
Performance Tips
- Use clustering — essential for 1000+ points
- Debounce updates — don't update source on every state change
- Limit features — query with viewport bounds
- Use WebGL layers — avoid HTML markers for many points
- Simplify geometries — reduce polygon complexity server-side