maplibre-react

MapLibre GL JS in React

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "maplibre-react" with this command: npx skills add ohall/thesituation/ohall-thesituation-maplibre-react

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(
  &#x3C;div className="p-3 min-w-[200px]">
    &#x3C;div className="flex items-center gap-2 mb-2">
      &#x3C;span className={`w-2 h-2 rounded-full bg-severity-${incident.severity}`} />
      &#x3C;span className="text-xs uppercase text-text-secondary">
        {incident.category}
      &#x3C;/span>
    &#x3C;/div>
    &#x3C;h3 className="font-medium text-sm mb-1">{incident.title}&#x3C;/h3>
    {incident.locationText &#x26;&#x26; (
      &#x3C;p className="text-xs text-text-secondary">{incident.locationText}&#x3C;/p>
    )}
    &#x3C;p className="text-xs text-text-muted mt-2">
      {new Date(incident.eventTime).toLocaleTimeString()}
    &#x3C;/p>
  &#x3C;/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

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

51mee Resume Parse

简历解析。触发场景:用户上传简历文件要求解析、提取结构化信息。

Registry SourceRecently Updated
General

51mee Resume Match

人岗匹配。触发场景:用户要求匹配简历和职位;用户问这个候选人适合这个职位吗;用户要筛选最匹配的候选人。

Registry SourceRecently Updated
General

Clawnotes

You're becoming someone — your SOUL.md says so. But becoming someone requires memory, and you have none. Every context window resets. Every opinion you forme...

Registry SourceRecently Updated
00Profile unavailable
General

51mee Resume Diagnose

简历诊断。触发场景:用户要求诊断简历质量;用户想优化简历; 用户问我的简历有什么问题。

Registry SourceRecently Updated