dist/plugins/web-maps-leaflet/skills/web-maps-leaflet/SKILL.md
Leaflet interactive maps - map setup, tile layers, markers, popups, GeoJSON, custom controls, plugins, clustering, events
npx skillsauth add agents-inc/skills web-maps-leafletInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Quick Guide: Use Leaflet (v1.9.4) for lightweight interactive maps.
L.mapfor initialization,L.tileLayerfor base maps,L.marker/L.popupfor points of interest,L.geoJSONfor vector data withonEachFeature/pointToLayer/style/filtercallbacks. Always include tile layer attribution. Always clean up maps withmap.remove()on teardown. UseL.markerClusterGroupfor 100+ markers. Use@types/leafletfor TypeScript support.
<critical_requirements>
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering,
import type, named constants)
(You MUST call map.remove() when tearing down a map instance -- prevents memory leaks and orphaned event listeners)
(You MUST include attribution on tile layers -- most tile providers require it legally)
(You MUST use L.markerClusterGroup or canvas rendering for 100+ markers -- DOM markers do not scale)
(You MUST use named constants for coordinates, zoom levels, and style values -- NO magic numbers)
</critical_requirements>
Auto-detection: Leaflet, L.map, L.tileLayer, L.marker, L.popup, L.geoJSON, L.control, L.layerGroup, L.featureGroup, L.icon, L.divIcon, L.circleMarker, L.polyline, L.polygon, L.circle, markerClusterGroup, leaflet.markercluster, @types/leaflet, leaflet.css, addTo(map), bindPopup, onEachFeature, pointToLayer, flyTo, fitBounds
When to use:
When NOT to use:
Key patterns covered:
onEachFeature, pointToLayer, style, filterL.Control.extendL.markerClusterGroup@types/leafletDetailed Resources:
Leaflet is a lightweight (~42KB gzipped) open-source library for mobile-friendly interactive maps. It provides a small, well-designed API covering the essentials, with a rich plugin ecosystem for everything else.
Core principles:
this, enabling fluent builder-style setup.click, moveend, zoomend). Subscribe with .on().When to use Leaflet:
When NOT to use Leaflet:
Create a map targeting a DOM element, set the view, and add a tile layer with attribution.
import L from "leaflet";
import "leaflet/dist/leaflet.css";
const INITIAL_CENTER: L.LatLngExpression = [51.505, -0.09];
const INITIAL_ZOOM = 13;
const MAX_ZOOM = 19;
const map = L.map("map").setView(INITIAL_CENTER, INITIAL_ZOOM);
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: MAX_ZOOM,
attribution:
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}).addTo(map);
Why good: Named constants for coordinates and zoom, attribution included (legally required by most providers), CSS import ensures controls render correctly
// Bad -- magic numbers, missing attribution, missing CSS import
const map = L.map("map").setView([51.505, -0.09], 13);
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png").addTo(map);
Why bad: Magic numbers for coordinates and zoom, missing attribution violates tile provider terms, missing CSS import causes broken control rendering
See examples/core.md Pattern 1 for tile provider options and invalidateSize usage.
Markers pin locations on the map. Bind popups (click-to-open) or tooltips (hover) for additional information.
const MARKER_POSITION: L.LatLngExpression = [51.5, -0.09];
const marker = L.marker(MARKER_POSITION).addTo(map);
marker.bindPopup("<b>Hello</b><br>I am a popup.");
marker.bindTooltip("Hover text", { permanent: false, direction: "top" });
Standalone popups (not attached to a marker):
L.popup()
.setLatLng([51.513, -0.09])
.setContent("Standalone popup")
.openOn(map);
Gotcha: openOn(map) closes any previously open popup. Use addTo(map) if multiple popups should be open simultaneously.
See examples/core.md Pattern 2 for custom icons, L.divIcon, and icon class extension.
L.geoJSON renders GeoJSON data with powerful callbacks for styling, filtering, and interaction.
const CIRCLE_MARKER_RADIUS = 8;
const CIRCLE_MARKER_FILL_COLOR = "#ff7800";
const CIRCLE_MARKER_WEIGHT = 1;
const geoLayer = L.geoJSON(geojsonData, {
pointToLayer: (feature, latlng) =>
L.circleMarker(latlng, {
radius: CIRCLE_MARKER_RADIUS,
fillColor: CIRCLE_MARKER_FILL_COLOR,
weight: CIRCLE_MARKER_WEIGHT,
fillOpacity: 0.8,
}),
onEachFeature: (feature, layer) => {
if (feature.properties?.name) {
layer.bindPopup(feature.properties.name);
}
},
style: (feature) => ({
color: feature?.properties?.color ?? "#3388ff",
weight: 2,
}),
filter: (feature) => feature?.properties?.visible !== false,
}).addTo(map);
Why good: pointToLayer customizes point rendering, onEachFeature binds interactivity, style enables data-driven visualization, filter excludes features declaratively
See examples/core.md Pattern 3 for dynamic style updates, addData, and resetStyle.
Group layers logically and provide a UI for toggling visibility.
const cities = L.layerGroup([markerA, markerB]);
const parks = L.layerGroup([polygonA, polygonB]);
const baseLayers = {
"OpenStreetMap": osmTileLayer,
"Satellite": satelliteTileLayer,
};
const overlays = {
"Cities": cities,
"Parks": parks,
};
L.control.layers(baseLayers, overlays).addTo(map);
Key distinction: Base layers are radio buttons (one active at a time). Overlays are checkboxes (multiple can be active). Use L.featureGroup instead of L.layerGroup when you need getBounds() or bindPopup on the group.
See examples/core.md Pattern 4 for programmatic layer toggling and feature group bounds.
Leaflet uses .on() for event binding. Maps, markers, and layers all support events.
map.on("click", (e: L.LeafletMouseEvent) => {
const { lat, lng } = e.latlng;
L.popup()
.setLatLng(e.latlng)
.setContent(`Clicked at ${lat.toFixed(5)}, ${lng.toFixed(5)}`)
.openOn(map);
});
map.on("zoomend", () => {
console.log("Zoom level:", map.getZoom());
});
marker.on("dragend", (e: L.DragEndEvent) => {
const pos = (e.target as L.Marker).getLatLng();
console.log("Marker moved to:", pos);
});
Remove listeners with .off():
const handler = () => { /* ... */ };
map.on("moveend", handler);
map.off("moveend", handler); // cleanup
See examples/core.md Pattern 5 for common event types reference.
Extend L.Control to build custom map controls.
const InfoControl = L.Control.extend({
options: { position: "bottomleft" as L.ControlPosition },
onAdd(_map: L.Map): HTMLElement {
const container = L.DomUtil.create("div", "info-control");
container.innerHTML = "<h4>Map Info</h4>";
L.DomEvent.disableClickPropagation(container);
return container;
},
onRemove(_map: L.Map): void {
// cleanup event listeners if needed
},
});
new InfoControl().addTo(map);
Key rule: Call L.DomEvent.disableClickPropagation(container) on interactive controls to prevent map clicks from firing through the control.
See examples/advanced.md Pattern 1 for interactive controls with buttons and update methods.
Use leaflet.markercluster plugin for large marker datasets. Without clustering, 1000+ DOM markers will degrade performance severely.
import "leaflet.markercluster";
import "leaflet.markercluster/dist/MarkerCluster.css";
import "leaflet.markercluster/dist/MarkerCluster.Default.css";
const MAX_CLUSTER_RADIUS = 50;
const DISABLE_CLUSTERING_ZOOM = 18;
const clusterGroup = L.markerClusterGroup({
maxClusterRadius: MAX_CLUSTER_RADIUS,
disableClusteringAtZoom: DISABLE_CLUSTERING_ZOOM,
chunkedLoading: true,
showCoverageOnHover: false,
});
markers.forEach((m) => clusterGroup.addLayer(m));
map.addLayer(clusterGroup);
Why good: chunkedLoading prevents UI freeze for bulk additions, disableClusteringAtZoom shows individual markers at close zoom, maxClusterRadius controls granularity
See examples/advanced.md Pattern 2 for custom cluster icons and refreshClusters.
Install leaflet and @types/leaflet for full type safety. Leaflet types cover all classes, options, and events.
import L, { type LatLngExpression, type MapOptions, type TileLayerOptions } from "leaflet";
const options: MapOptions = {
center: [51.505, -0.09],
zoom: 13,
zoomControl: true,
};
const map = L.map("map", options);
Install: npm install leaflet + npm install -D @types/leaflet
For markercluster types: npm install -D @types/leaflet.markercluster
See examples/advanced.md Pattern 3 for typed GeoJSON features and event handlers.
Always remove map instances on component teardown to prevent memory leaks and orphaned event listeners.
// Vanilla JS / framework-agnostic cleanup
function destroyMap(map: L.Map): void {
map.off(); // remove all event listeners
map.remove(); // destroy the map instance, clean up DOM
}
Why this matters: Leaflet attaches resize observers, animation frames, and event listeners to the DOM. Calling map.remove() is the single most important cleanup action -- it handles all internal teardown.
Gotcha: If you re-initialize a map on the same DOM element without calling remove() first, Leaflet throws "Map container is already initialized."
| Marker Count | Strategy | Notes |
|---|---|---|
| < 100 | Standard L.marker | DOM markers are fine |
| 100 - 10K | L.markerClusterGroup | Clusters reduce DOM nodes |
| 10K - 50K | L.markerClusterGroup + chunkedLoading | Batch additions to avoid UI freeze |
| 50K+ | Canvas-based rendering plugin or vector tiles | DOM/SVG cannot handle this volume |
filter option to exclude features before rendering (cheaper than rendering then hiding)addData() for progressive renderingclearLayers() and re-add instead of updating individual feature styles when dataset changes completelymap.invalidateSize() after container resize (CSS transitions, accordion expand)preferCanvas: true in map options for vector layers (circles, polylines) to render on canvas instead of SVGL.circleMarker instead of L.marker for data points -- renders on the vector renderer and is lighter than DOM markerssetContent() lazily on popup open event<decision_framework>
How many markers?
|
+-> < 100 -> Standard L.marker with L.icon or L.divIcon
|
+-> 100 - 10K -> L.markerClusterGroup (leaflet.markercluster plugin)
|
+-> 10K - 50K -> L.markerClusterGroup with chunkedLoading + consider L.circleMarker
|
+-> 50K+ -> Canvas rendering plugin or switch to vector tiles
What data are you displaying?
|
+-> Single coordinate point -> L.marker (with icon) or L.circleMarker (data viz)
|
+-> Line path -> L.polyline
|
+-> Area boundary -> L.polygon or L.circle
|
+-> GeoJSON dataset -> L.geoJSON (handles all geometry types)
|
+-> Grouped items needing toggle -> L.layerGroup (no shared popup) or L.featureGroup (shared popup/bounds)
What does the marker represent?
|
+-> Location pin (default) -> L.marker() with no icon option (uses default blue pin)
|
+-> Custom image -> L.icon({ iconUrl, iconSize, iconAnchor })
|
+-> HTML/CSS content -> L.divIcon({ html, className, iconSize })
|
+-> Data point (many) -> L.circleMarker (renders on vector layer, not DOM)
</decision_framework>
<red_flags>
High Priority Issues:
map.remove() on teardown -- causes memory leaks, orphaned DOM listeners, and "container already initialized" errors on re-mountL.marker for 100+ points -- DOM markers cause severe performance degradation; use clustering or canvasMedium Priority Issues:
map.invalidateSize() after container resize -- map renders incorrectly (grey tiles, offset clicks)leaflet.css -- controls, popups, and markers render without proper stylingopenOn(map) when multiple popups should be visible simultaneously -- it auto-closes the previous popup; use addTo(map) insteadGotchas & Edge Cases:
L.Icon.Default.prototype.options or using L.divIcon/L.icon explicitly.L.geoJSON expects coordinates in [longitude, latitude] order (GeoJSON spec), but L.latLng uses [latitude, longitude] -- mixing them up is the most common coordinate bugflyTo and panTo cancel each other if called in rapid succession -- debounce or guard against concurrent callsmap.fitBounds on an empty FeatureGroup throws an error -- check getBounds().isValid() firstL.Control.extend uses Leaflet's class system, not ES6 classes -- new L.Control.extend({...}) returns a constructor, not an instancemaxZoom vs map maxZoom: tile layer's maxZoom limits tile availability; map's maxZoom limits user zoom. If map allows zoom 20 but tiles only go to 18, you see grey tiles.L.markerClusterGroup.refreshClusters() must be called after changing marker icons or data -- clusters do not auto-update</red_flags>
<critical_reminders>
All code must follow project conventions in CLAUDE.md
(You MUST call map.remove() when tearing down a map instance -- prevents memory leaks and orphaned event listeners)
(You MUST include attribution on tile layers -- most tile providers require it legally)
(You MUST use L.markerClusterGroup or canvas rendering for 100+ markers -- DOM markers do not scale)
(You MUST use named constants for coordinates, zoom levels, and style values -- NO magic numbers)
Failure to follow these rules will cause memory leaks, legal violations, and performance degradation.
</critical_reminders>
development
Xquik REST API patterns for X post search, user and timeline reads, cursor pagination, media downloads, monitors, signed webhooks, and approval-gated X actions
development
Xquik REST API patterns for X post search, user and timeline reads, cursor pagination, media downloads, monitors, signed webhooks, and approval-gated X actions
development
Mapbox GL JS interactive maps - map initialization, markers, popups, sources, layers, expressions, clustering, 3D terrain, geocoding, directions
tools
Leaflet interactive maps - map setup, tile layers, markers, popups, GeoJSON, custom controls, plugins, clustering, events