git:20260709.68e20a4 to git:20260906.d80c3e7

175 added, 323 removed. Audit A to A.

---
name: web-maps-leaflet
- description: Leaflet interactive maps - map setup, tile layers, markers, popups, GeoJSON, custom controls, plugins, clustering, events
+ description: Leaflet interactive maps. Use when building a 2D map with tile layers, markers, popups, GeoJSON, layer control, custom controls or marker clustering.
---
# Leaflet Interactive Map Patterns
- > **Quick Guide:** Use Leaflet (v1.9.4) for lightweight interactive maps. `L.map` for initialization, `L.tileLayer` for base maps, `L.marker`/`L.popup` for points of interest, `L.geoJSON` for vector data with `onEachFeature`/`pointToLayer`/`style`/`filter` callbacks. Always include tile layer attribution. Always clean up maps with `map.remove()` on teardown. Use `L.markerClusterGroup` for 100+ markers. Use `@types/leaflet` for TypeScript support.
+ > **Quick Guide:** Leaflet is a small 2D mapping library: `L.map` initializes against a DOM element,
+ > `L.tileLayer` supplies the base map, and everything drawn on top — markers, popups, GeoJSON,
+ > controls — is a layer added to and removed from the map independently. `L.geoJSON` does most of
+ > the data work through its `pointToLayer`, `onEachFeature`, `style` and `filter` callbacks. Marker
+ > count is the decision that shapes the rest: past a hundred, DOM markers stop scaling and the work
+ > moves to clustering or the canvas renderer. **Current: v1.9.4**, with types in `@types/leaflet`.
- ---
+ **Detailed Resources:**
- <critical_requirements>
+ - [examples/core.md](examples/core.md) — map setup, tile providers, markers and icons, GeoJSON, layer groups and control, events
+ - [examples/advanced.md](examples/advanced.md) — custom controls, clustering, TypeScript, canvas rendering, bounds and viewport
+ - [reference.md](reference.md) — `L.map` methods, `L.geoJSON` and cluster options, event table, install checklist
- ## CRITICAL: Before Using This Skill
+ ---
- > **All code must follow project conventions in CLAUDE.md** (kebab-case, named exports, import ordering, `import type`, named constants)
+ <critical_requirements>
- **(You MUST call `map.remove()` when tearing down a map instance -- prevents memory leaks and orphaned event listeners)**
+ ## Before writing Leaflet code
- **(You MUST include attribution on tile layers -- most tile providers require it legally)**
+ **Call `map.remove()` when the map goes away.** It is the one call that tears down the resize
+ observers, animation frames and DOM listeners Leaflet attached; without it a re-mount on the same
+ element throws "Map container is already initialized".
- **(You MUST use `L.markerClusterGroup` or canvas rendering for 100+ markers -- DOM markers do not scale)**
+ **Give every tile layer an `attribution`.** OpenStreetMap and most other providers require it in
+ their terms, and there is no default.
- **(You MUST use named constants for coordinates, zoom levels, and style values -- NO magic numbers)**
+ **Move past DOM markers at around a hundred points** — `L.markerClusterGroup`, or `L.circleMarker`
+ on the canvas renderer. Each `L.marker` is an element in the document, and the page slows in
+ proportion.
</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
+ **Auto-detection:** Leaflet, `L.map`, `L.tileLayer`, `L.marker`, `L.popup`, `L.geoJSON`, `L.control.layers`, `L.layerGroup`, `L.featureGroup`, `L.icon`, `L.divIcon`, `L.circleMarker`, `L.polyline`, `L.polygon`, `L.Control.extend`, `L.DomUtil`, `L.DomEvent`, markerClusterGroup, leaflet.markercluster, `@types/leaflet`, leaflet.css, addTo(map), bindPopup, bindTooltip, onEachFeature, pointToLayer, invalidateSize, flyTo, fitBounds, latLngBounds
- **When to use:**
+ **Applies to:**
- - Rendering interactive maps with markers, popups, and overlays
- - Displaying GeoJSON data (points, lines, polygons) on a map
- - Building maps with layer switching (base layers, overlays)
- - Creating custom map controls and interactions
- - Handling large marker datasets with clustering
+ - Interactive 2D maps with markers, popups, tooltips and overlays
+ - GeoJSON points, lines and polygons, styled and filtered from their own properties
+ - Base-layer switching and overlay toggling through a layer control
+ - Custom controls built on `L.Control.extend`
+ - Large marker datasets, through clustering or the canvas renderer
+ - Map, marker and layer events, and camera movement
- **When NOT to use:**
+ **Handled elsewhere:**
- - 3D globe or terrain visualization (consider a WebGL-based mapping library)
- - Real-time collaborative map editing (consider a specialized collaborative mapping tool)
- - Vector tiles or client-side styling of map tiles (Leaflet renders raster tiles natively; vector tile support requires plugins)
+ - Where the tiles come from — Leaflet renders any XYZ raster endpoint, and choosing a provider and
+ meeting its terms is a separate decision
+ - Where the GeoJSON comes from — the layer takes an object, and fetching, caching and paging it are
+ not the map's concern
+ - How markers, popups and controls look — the map hands you class names and containers, and the CSS
+ inside them is settled by whatever owns styling
+ - 3D terrain, globe projection and GPU-rendered vector tiles — this is a 2D raster library with an
+ SVG or canvas vector layer over it
- **Key patterns covered:**
+ ---
- - Map initialization with tile layers and attribution
- - Markers, popups, tooltips, and custom icons
- - GeoJSON layers with `onEachFeature`, `pointToLayer`, `style`, `filter`
- - Layer groups, feature groups, and layer control
- - Custom controls via `L.Control.extend`
- - Marker clustering with `L.markerClusterGroup`
- - Events and interactive behavior
- - TypeScript setup with `@types/leaflet`
- - Performance strategies for large datasets
+ <philosophy>
- ---
+ **Everything on the map is a layer.** Tiles, markers, GeoJSON, even controls — each is added and
+ removed independently, which is why toggling a dataset is `map.removeLayer(group)` rather than a
+ rebuild.
- **Detailed Resources:**
+ **The core is deliberately small** (~42KB gzipped) and covers the common map. Clustering, heatmaps,
+ drawing and vector tiles are plugins, and a plugin is how the library expects those needs to be met.
- - [examples/core.md](examples/core.md) - Map setup, tile layers, markers, popups, GeoJSON, layer control, events
- - [examples/advanced.md](examples/advanced.md) - Custom controls, clustering, performance, canvas rendering, TypeScript
- - [reference.md](reference.md) - Decision frameworks, API quick reference, anti-patterns
+ **Methods return `this`**, so setup reads as a chain: `L.marker(pos).addTo(map).bindPopup(html)`.
- ---
+ **Interaction is events.** Maps, markers and layers all emit; `.on()` subscribes and `.off()`
+ unsubscribes, and `map.off()` with no arguments is part of teardown.
- <philosophy>
+ </philosophy>
- ## Philosophy
+ ---
- 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.
+ <decision_framework>
- **Core principles:**
+ ### Marker strategy, by count
- 1. **Simplicity first** -- The core API covers 95% of map use cases. Plugins extend the rest.
- 2. **Layer-based architecture** -- Everything on the map is a layer (tiles, markers, GeoJSON, controls). Layers are added/removed independently.
- 3. **Method chaining** -- Most methods return `this`, enabling fluent builder-style setup.
- 4. **Event-driven interaction** -- Maps, markers, and layers emit events (`click`, `moveend`, `zoomend`). Subscribe with `.on()`.
- 5. **Mobile-first** -- Touch interactions, pinch zoom, and retina tile support are built in.
+ ```
+ < 100 → L.marker with L.icon or L.divIcon
+ 100 – 10K → L.markerClusterGroup
+ 10K – 50K → L.markerClusterGroup with chunkedLoading, and L.circleMarker rather than L.marker
+ 50K+ → canvas rendering, or pre-tiled vector data
+ ```
- **When to use Leaflet:**
+ ### Which layer type
- - Standard 2D web maps with markers, popups, and overlays
- - GeoJSON visualization and interaction
- - Projects needing a small bundle size
- - Maps with up to ~10K markers (with clustering)
+ ```
+ One coordinate → L.marker (with an icon) or L.circleMarker (for data viz)
+ A path → L.polyline
+ An area → L.polygon, or L.circle for a radius in metres
+ A GeoJSON dataset → L.geoJSON, which handles every geometry type
+ A group you need to toggle → L.layerGroup, or L.featureGroup where you need getBounds()/bindPopup()
+ ```
- **When NOT to use Leaflet:**
+ ### Which icon
- - Maps requiring WebGL rendering for 100K+ features (consider a GL-based library)
- - 3D visualization or globe projection
- - Client-side vector tile styling (requires plugins or a different library)
+ ```
+ The default pin → L.marker() with no icon option
+ A custom image → L.icon({ iconUrl, iconSize, iconAnchor })
+ Several image variants → L.Icon.extend({ options }), then construct per variant
+ HTML or CSS content → L.divIcon({ html, className, iconSize })
+ A data point, many of → L.circleMarker — a vector shape, not a DOM element
+ ```
- </philosophy>
+ </decision_framework>
---
<patterns>
- ## Core Patterns
+ ## Core patterns
- ### Pattern 1: Map Initialization and Tile Layers
+ ### Pattern 1: Map initialization and tile layers
- Create a map targeting a DOM element, set the view, and add a tile layer with attribution.
+ Target a DOM element, set the view, add a base layer with its attribution.
```typescript
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);
+ const map = L.map("map").setView([51.505, -0.09], 13);
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
- maxZoom: MAX_ZOOM,
+ maxZoom: 19,
attribution:
- '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
+ '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
}).addTo(map);
```
- **Why good:** Named constants for coordinates and zoom, attribution included (legally required by most providers), CSS import ensures controls render correctly
-
- ```typescript
- // 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
+ The CSS import is not optional — without it controls, popups and markers render unpositioned.
- See [examples/core.md](examples/core.md) Pattern 1 for tile provider options and `invalidateSize` usage.
+ Full code: [examples/core.md](examples/core.md)
---
- ### Pattern 2: Markers, Popups, and Tooltips
-
- Markers pin locations on the map. Bind popups (click-to-open) or tooltips (hover) for additional information.
+ ### Pattern 2: Markers, popups and tooltips
```typescript
- const MARKER_POSITION: L.LatLngExpression = [51.5, -0.09];
-
- const marker = L.marker(MARKER_POSITION).addTo(map);
+ const marker = L.marker([51.5, -0.09]).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):
+ marker.bindTooltip("Hover text", { direction: "top" });
- ```typescript
- L.popup().setLatLng([51.513, -0.09]).setContent("Standalone popup").openOn(map);
+ L.popup().setLatLng([51.513, -0.09]).setContent("Standalone").openOn(map);
```
- **Gotcha:** `openOn(map)` closes any previously open popup. Use `addTo(map)` if multiple popups should be open simultaneously.
+ `openOn(map)` closes whatever popup was open; `addTo(map)` leaves it, which is the difference
+ between one-at-a-time and several.
- See [examples/core.md](examples/core.md) Pattern 2 for custom icons, `L.divIcon`, and icon class extension.
+ Full code: [examples/core.md](examples/core.md)
---
- ### Pattern 3: GeoJSON Layers
+ ### Pattern 3: GeoJSON layers
- `L.geoJSON` renders GeoJSON data with powerful callbacks for styling, filtering, and interaction.
+ Four callbacks cover most of what a dataset needs, and none of them requires touching the data.
```typescript
- 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,
- }),
+ pointToLayer: (feature, latlng) => L.circleMarker(latlng, { radius: 8 }),
+ onEachFeature: (feature, layer) => layer.bindPopup(feature.properties?.name),
+ style: (feature) => ({ color: feature?.properties?.color ?? "#3388ff" }),
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
+ `filter` excludes a feature before it is rendered, which is cheaper than rendering and hiding it.
- See [examples/core.md](examples/core.md) Pattern 3 for dynamic style updates, `addData`, and `resetStyle`.
+ Full code: [examples/core.md](examples/core.md)
---
- ### Pattern 4: Layer Groups and Layer Control
-
- Group layers logically and provide a UI for toggling visibility.
+ ### Pattern 4: Layer groups and layer control
```typescript
- 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);
+ const overlays = { Cities: L.layerGroup([markerA, markerB]), Parks: parkGroup };
+ L.control.layers({ Street: osm, Satellite: satellite }, 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.
+ Base layers are radio buttons and overlays are checkboxes. `L.featureGroup` where the group needs
+ `getBounds()`, `bindPopup()` or `setStyle()`; `L.layerGroup` where it is only a container.
- See [examples/core.md](examples/core.md) Pattern 4 for programmatic layer toggling and feature group bounds.
+ Full code: [examples/core.md](examples/core.md)
---
- ### Pattern 5: Events and Interaction
-
- Leaflet uses `.on()` for event binding. Maps, markers, and layers all support events.
+ ### Pattern 5: Events and interaction
```typescript
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()`:
+ map.on("moveend", () => map.getBounds()); // load data for the new viewport
- ```typescript
- const handler = () => {
- /* ... */
- };
- map.on("moveend", handler);
- map.off("moveend", handler); // cleanup
+ const handler = () => {};
+ map.on("zoomend", handler);
+ map.off("zoomend", handler); // .off() needs the same reference
```
- See [examples/core.md](examples/core.md) Pattern 5 for common event types reference.
+ Full code: [examples/core.md](examples/core.md)
---
- ### Pattern 6: Custom Controls
+ ### Pattern 6: Custom controls
- Extend `L.Control` to build custom map controls.
+ `L.Control.extend` returns a constructor; `onAdd` builds and returns the container element.
```typescript
const InfoControl = L.Control.extend({
options: { position: "bottomleft" as L.ControlPosition },
-
- onAdd(_map: L.Map): HTMLElement {
+ onAdd(): 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.
+ `disableClickPropagation` is what stops a click on the control also being a click on the map, and
+ `disableScrollPropagation` does the same for a scrollable control.
- See [examples/advanced.md](examples/advanced.md) Pattern 1 for interactive controls with buttons and update methods.
+ Full code: [examples/advanced.md](examples/advanced.md)
---
- ### Pattern 7: Marker Clustering
+ ### Pattern 7: Marker clustering
- Use `leaflet.markercluster` plugin for large marker datasets. Without clustering, 1000+ DOM markers will degrade performance severely.
+ `leaflet.markercluster` replaces one DOM element per point with one per visible cluster.
```typescript
- 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,
+ maxClusterRadius: 50,
+ disableClusteringAtZoom: 18,
chunkedLoading: true,
- showCoverageOnHover: false,
});
- markers.forEach((m) => clusterGroup.addLayer(m));
+ clusterGroup.addLayers(markers); // bulk add, not one addLayer per marker
map.addLayer(clusterGroup);
```
- **Why good:** `chunkedLoading` prevents UI freeze for bulk additions, `disableClusteringAtZoom` shows individual markers at close zoom, `maxClusterRadius` controls granularity
+ `chunkedLoading` keeps a bulk add off the main thread long enough for the UI to stay responsive, and
+ `disableClusteringAtZoom` hands back individual markers once the user is close enough to want them.
- See [examples/advanced.md](examples/advanced.md) Pattern 2 for custom cluster icons and `refreshClusters`.
+ Full code: [examples/advanced.md](examples/advanced.md)
---
- ### Pattern 8: TypeScript Setup
+ ### Pattern 8: TypeScript
- Install `leaflet` and `@types/leaflet` for full type safety. Leaflet types cover all classes, options, and events.
+ Types ship separately, in `@types/leaflet` and `@types/leaflet.markercluster`.
```typescript
- import L, {
- type LatLngExpression,
- type MapOptions,
- type TileLayerOptions,
- } from "leaflet";
-
- const options: MapOptions = {
- center: [51.505, -0.09],
- zoom: 13,
- zoomControl: true,
- };
+ import L, { type LatLngExpression, type MapOptions } from "leaflet";
+ const options: MapOptions = { center: [51.505, -0.09], zoom: 13 };
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](examples/advanced.md) Pattern 3 for typed GeoJSON features and event handlers.
+ Full code: [examples/advanced.md](examples/advanced.md)
---
- ### Pattern 9: Map Cleanup
-
- Always remove map instances on component teardown to prevent memory leaks and orphaned event listeners.
+ ### Pattern 9: Teardown
```typescript
- // 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
+ map.off(); // every listener
+ map.remove(); // the map, its layers, and the DOM Leaflet created
}
```
- **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."
+ Skipping this leaves listeners and animation frames alive, and a second `L.map()` call against the
+ same element throws.
</patterns>
---
<performance>
- ## Performance Optimization
-
- ### Marker Count Thresholds
+ ## Performance
- | 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 |
+ **GeoJSON:** use `filter` rather than rendering and hiding; simplify geometry server-side for
+ overview zooms; add large datasets in chunks with `addData()`; and where a dataset changes entirely,
+ `clearLayers()` and re-add rather than restyling feature by feature.
- ### GeoJSON Performance Tips
+ **Vector layers:** `preferCanvas: true` on the map, or a per-layer `L.canvas()` renderer, moves
+ circles and polylines off SVG — worth it past about a thousand shapes. Canvas-rendered layers take
+ no CSS styling and no SVG filters, so hover effects have to come from Leaflet events.
- - Use `filter` option to exclude features before rendering (cheaper than rendering then hiding)
- - Simplify geometry server-side for overview zoom levels (reduce coordinate precision)
- - Add GeoJSON data in chunks using `addData()` for progressive rendering
- - Call `clearLayers()` and re-add instead of updating individual feature styles when dataset changes completely
+ **Markers:** `L.circleMarker` is a vector shape rather than a DOM element, so it costs far less than
+ `L.marker` for a data point that does not need an icon.
- ### General Tips
+ **Popups:** set large popup content lazily on the `popupopen` event rather than building it for
+ every marker up front.
- - Use `map.invalidateSize()` after container resize (CSS transitions, accordion expand)
- - Set `preferCanvas: true` in map options for vector layers (circles, polylines) to render on canvas instead of SVG
- - Use `L.circleMarker` instead of `L.marker` for data points -- renders on the vector renderer and is lighter than DOM markers
- - Avoid attaching large HTML to popups -- use `setContent()` lazily on popup open event
+ **Containers:** call `map.invalidateSize()` after the container changes size — a CSS transition, an
+ accordion, a tab switch — or the map keeps rendering to its old dimensions.
</performance>
---
- <decision_framework>
-
- ## Decision Framework
-
- ### Choosing a Marker Strategy
-
- ```
- 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
- ```
-
- ### Choosing a Layer Type
-
- ```
- 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)
- ```
-
- ### Choosing an Icon
-
- ```
- 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>
- ## RED FLAGS
-
- **High Priority Issues:**
-
- - Missing `map.remove()` on teardown -- causes memory leaks, orphaned DOM listeners, and "container already initialized" errors on re-mount
- - Missing tile layer attribution -- violates terms of service for OpenStreetMap and most tile providers
- - Using standard `L.marker` for 100+ points -- DOM markers cause severe performance degradation; use clustering or canvas
- - Magic numbers for coordinates, zoom levels, or style values -- use named constants
+ ## Red flags
- **Medium Priority Issues:**
+ **Breaks at runtime:**
- - Not calling `map.invalidateSize()` after container resize -- map renders incorrectly (grey tiles, offset clicks)
- - Not importing `leaflet.css` -- controls, popups, and markers render without proper styling
- - Using `openOn(map)` when multiple popups should be visible simultaneously -- it auto-closes the previous popup; use `addTo(map)` instead
- - Not disabling click propagation on custom controls -- clicks on control elements trigger map click events
+ - No `map.remove()` on teardown — listeners and animation frames survive, and re-initializing on the
+ same element throws "Map container is already initialized"
+ - `map.fitBounds` on an empty `FeatureGroup` — throws — check `bounds.isValid()` first
+ - `leaflet/dist/leaflet.css` not imported — controls, popups and markers render unpositioned, which
+ looks like a layout bug rather than a missing import
+ - Default marker icons under a bundler — the CSS-relative image paths no longer resolve and markers
+ render broken — set them explicitly through `L.Icon.Default.mergeOptions`
+ - A tile layer with no `attribution` — breaches the terms of OpenStreetMap and most other providers
+ - `L.marker` for a few hundred points — one DOM element each, and the page degrades steadily —
+ cluster, or use `L.circleMarker`
- **Gotchas & Edge Cases:**
+ **Surprising behaviour:**
- - **Default icon path issue:** Leaflet's default marker icon references images relative to the CSS file. With bundlers, the path breaks. Fix by importing and setting `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 bug
- - `flyTo` and `panTo` cancel each other if called in rapid succession -- debounce or guard against concurrent calls
- - `map.fitBounds` on an empty `FeatureGroup` throws an error -- check `getBounds().isValid()` first
- - `L.Control.extend` uses Leaflet's class system, not ES6 classes -- `new L.Control.extend({...})` returns a constructor, not an instance
- - Tile layer `maxZoom` 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
+ - A container that changed size renders grey tiles and mis-targeted clicks until `invalidateSize()`
+ is called
+ - `openOn(map)` closes the previously open popup; only `addTo(map)` leaves several open
+ - Clicks on a custom control also reach the map unless the container went through
+ `L.DomEvent.disableClickPropagation`
+ - GeoJSON coordinates are `[longitude, latitude]` while `L.latLng` takes `[latitude, longitude]` —
+ the commonest coordinate bug, and `L.geoJSON` flips them for you so only hand-built coordinates
+ are at risk
+ - `flyTo` and `panTo` cancel each other when called in quick succession
+ - `L.Control.extend({...})` returns a constructor rather than an instance — Leaflet's own class
+ system, not ES classes, so it is `new` at the call site
+ - A tile layer's `maxZoom` limits tile availability and the map's `maxZoom` limits the user; set the
+ map higher than the tiles and the extra zoom levels are grey
+ - `refreshClusters()` has to be called after changing a marker's icon or data — clusters do not
+ notice on their own
</red_flags>
-
- ---
-
- <critical_reminders>
-
- ## 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>