Web Maps skill for Claude Code
GPU layers, honest tile costs, points in the right place. Claude picks the map library and tile provider with licensing and per-load pricing in mind, renders data as clustered GPU layers instead of thousands of DOM markers, moves big datasets to vector tiles or PMTiles, and handles SSR and WebGL cleanup in React. A stdlib script audits GeoJSON for swapped coordinates, projected meters, open rings and wasted precision before anything ships.
- name
web-maps - size174 lines · ≈2,250 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Builds interactive web maps with MapLibre GL JS, Leaflet, or Mapbox GL — choosing library and tile provider with licensing and cost in mind, GeoJSON sources and data-driven layers, clustering, feature-state hover and selection, vector tiles and PMTiles for large datasets, React and SSR integration, attribution, and accessible list alternatives. Includes a stdlib GeoJSON audit script for swapped coordinates, excess precision, unclosed rings, and oversize files. Use when adding a store locator, delivery or asset map, markers lag, points land in the ocean, or tiles stop loading. Try asking
- Build a store locator map with clustering and a synced results list
- Our map with 30,000 markers is unusable on phones — fix it
- All our pins show up in the ocean off Africa — what is wrong with this GeoJSON?
Use it when
- Adding an interactive map to a web app
- Markers lag, maps blank out after navigation or tiles fail
- Choosing a tile provider or moving off tile.openstreetmap.org
- Loading large GeoJSON or polygon datasets in the browser
Skip it when
- The work is spatial analysis in Python or PostGIS — use geospatial-analysis
- You are building a native mobile map screen — use react-native-expo or the platform skill
How to check the result
The audit script exits 0 against the expected bbox, a throttled mobile run pans smoothly with the full dataset, spot-checked addresses land correctly, and attribution stays visible.
The common mistake it prevents
Passing coordinates as [lat, lng] to a library that expects [lng, lat], which puts London off the coast of Somalia.
A good web map loads fast on mobile, renders data as GPU layers rather than thousands of DOM markers, uses a tile source whose license and pricing fit the traffic, shows the required attribution, and offers a non-map way (a list) to reach the same information.
Step 0 — inspect
npm ls maplibre-gl leaflet mapbox-gl react-map-gl react-leaflet pmtiles supercluster
grep -rn "tile.openstreetmap.org\|accessToken\|style:" src app | head
python3 scripts/geojson_audit.py data/stores.geojson --expect -11,49,2,61 # bbox the data should fall in
Establish: number of features (now and at scale), geometry types, update frequency, expected map views per month, offline needs, and whether the app is SSR.
scripts/geojson_audit.py reports feature counts, bbox, out-of-range or projected
coordinates, swapped lat/lng (detected against the --expect bbox), coordinate precision and potential byte savings, unclosed or mis-wound polygon rings,
missing ids, and recommends clustering or vector tiles by size. Exit 1 on errors.
Library choice
| Need | Pick |
|---|---|
| Vector tiles, smooth zoom/rotate, data-driven styling, large datasets | MapLibre GL JS 5 (BSD, free, globe projection) |
| Simple raster map, few markers, smallest bundle, old devices | Leaflet 1.9 (check if the project moved to Leaflet 2 ESM builds) |
| Mapbox Studio styles, Mapbox search/navigation APIs | Mapbox GL JS v3 (proprietary license, token, billed per map load) |
| 3D city/terrain scenes, globe analytics | MapLibre with terrain, or deck.gl overlay for heavy data viz |
| React | react-map-gl (react-map-gl/maplibre entry) or react-leaflet |
Default to MapLibre unless the project already depends on another library.
Tile source and licensing
| Source | Notes |
|---|---|
| OpenFreeMap, Protomaps public builds | Free vector tiles/styles; check current usage terms |
| MapTiler, Stadia Maps, Thunderforest | Hosted with free tier, key required, priced per request |
| Self-hosted PMTiles (Protomaps) on S3/R2/CDN | One static file, HTTP range requests, no tile server, cheapest at scale |
| Mapbox | Per map load billing beyond free tier |
tile.openstreetmap.org | Not for production apps: OSM tile usage policy forbids heavy use; volunteers pay for it |
- Never commit secret tokens. Map keys are public by nature; restrict them by HTTP referrer/domain in the provider dashboard.
- Keep attribution visible (
© OpenStreetMap contributorsplus the provider). Do not hide the attribution control; compact mode is fine.
MapLibre baseline
import maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css"; // missing CSS = broken controls and popups
const map = new maplibregl.Map({
container: "map",
style: STYLE_URL,
center: [-0.1276, 51.5072], // [lng, lat] — always longitude first
zoom: 11,
cooperativeGestures: true, // embedded maps: don't hijack page scroll
attributionControl: { compact: true },
});
map.addControl(new maplibregl.NavigationControl({ showCompass: false }), "top-right");
map.on("load", () => {
map.addSource("stores", {
type: "geojson",
data: "/api/stores.geojson",
cluster: true, clusterRadius: 50, clusterMaxZoom: 14,
promoteId: "id", // enables feature-state by property id
});
map.addLayer({ id: "clusters", type: "circle", source: "stores", filter: ["has", "point_count"],
paint: { "circle-color": "#1d4ed8", "circle-radius": ["step", ["get", "point_count"], 16, 50, 22, 500, 30] } });
map.addLayer({ id: "cluster-count", type: "symbol", source: "stores", filter: ["has", "point_count"],
layout: { "text-field": ["get", "point_count_abbreviated"], "text-size": 12 }, paint: { "text-color": "#fff" } });
map.addLayer({ id: "store", type: "circle", source: "stores", filter: ["!", ["has", "point_count"]],
paint: { "circle-radius": ["case", ["boolean", ["feature-state", "selected"], false], 9, 6],
"circle-color": "#1d4ed8", "circle-stroke-width": 2, "circle-stroke-color": "#fff" } });
});
map.on("click", "clusters", async (e) => {
const f = e.features![0];
const zoom = await (map.getSource("stores") as maplibregl.GeoJSONSource).getClusterExpansionZoom(f.properties.cluster_id);
map.easeTo({ center: (f.geometry as GeoJSON.Point).coordinates as [number, number], zoom });
});
getClusterExpansionZoomreturns a Promise in MapLibre 5 (callback style in older versions) — check the installed version.- Change cursor on
mouseenter/mouseleaveof interactive layers. - Update data with
source.setData(newGeoJSON); do not remove and re-add layers. - Selection/hover:
map.setFeatureState({ source: "stores", id }, { selected: true }); requires feature ids (promoteIdor numericid, orgenerateId: true). - Globe: MapLibre 5 supports
projection: { type: "globe" }in the style or viamap.setProjectionafter load.
Scale decisions
| Features | Approach |
|---|---|
| < 100 points, rich custom UI | HTML Markers are acceptable |
| 100 – 50,000 points | GeoJSON source + circle/symbol layers, clustering when they overlap |
| > 50,000 points or GeoJSON > ~5 MB | Vector tiles: tippecanoe -o out.pmtiles -zg --drop-densest-as-needed data.geojson, serve as PMTiles |
| Heavy polygons (parcels, boundaries) | Simplify (mapshaper -simplify 10% keep-shapes), then tiles |
| Live-moving vehicles | GeoJSON source with setData throttled to 1 Hz, or a feature-state animation |
- Trim coordinates to 6 decimals (~0.1 m) or 5 (~1 m); more precision only adds bytes.
- Load data for the viewport (
bboxquery param onmoveend, debounced) when the full dataset is too big, or let tiles do it. - Leaflet with many markers:
Leaflet.markerclusterorsupercluster, andpreferCanvas: truefor vector layers.
React and SSR
- Map libraries touch
windowon import. In Next.js use a client component loaded withdynamic(() => import("./Map"), { ssr: false }); in other frameworks, import inside a mount effect or a client-only wrapper. - Create the map once per mount; keep it in a ref. Do not recreate on every render or
prop change — call
setStyle,setData,flyToinstead. - Remove it on unmount (
map.remove()) to free the WebGL context; browsers cap active contexts (~16), and leaking maps across route changes eventually blanks them. - Give the container an explicit height; a 0-height container renders nothing. Call
map.resize()after the container becomes visible (tabs, modals).
Accessibility and UX
- Pair the map with a list of the same results (synchronized selection). Many users, including screen reader and keyboard users, need the list.
- Give the map region a label (
role="region" aria-label="Store locations map") and make controls reachable by keyboard; MapLibre’s canvas handles arrow keys when focused. - Popups: move focus into them when opened via keyboard and return focus on close.
- Do not rely on color alone for categories; vary shape or add labels.
- Respect
prefers-reduced-motion: usejumpToinstead offlyTo. - Ask for geolocation only after a user action (“Use my location”), never on load.
Gotchas
[lat, lng]vs[lng, lat]: GeoJSON and MapLibre are lng-first; LeafletL.latLngand Google APIs are lat-first. Swapped values put Europe in Somalia or the Indian Ocean.- Coordinates as strings from CSV/APIs: parse to numbers before building GeoJSON.
- RFC 7946 GeoJSON is WGS84 (EPSG:4326). Data in a projected CRS (e.g. EPSG:3857 meters,
national grids) must be reprojected (
ogr2ogr -t_srs EPSG:4326). - Polygons crossing the antimeridian need splitting.
- Layers added before
load/style.loadthrow;setStylewipes custom sources and layers — re-add them onstyle.load. - Fonts for symbol labels come from the style’s
glyphsURL; a missing glyph endpoint means invisible labels. - Content Security Policy: MapLibre needs
worker-src blob:andimg-src blob: data:plus tile hosts inconnect-src.
Verification
python3 scripts/geojson_audit.py <file> --expect <bbox>exits 0 (no swapped coordinates, closed rings).- Load the page on a throttled mobile profile (DevTools “Fast 4G”, 4x CPU): map interactive within ~3 s, panning smooth with the full dataset.
- Points appear in the right places — spot-check 3 known addresses against another map.
- Navigate away and back 20 times in the SPA: no WebGL context warnings, memory stable.
- Attribution visible; keyboard reaches controls and the list; reduced motion respected.
Report format
**Library:** MapLibre GL JS 5 + react-map-gl/maplibre, style from MapTiler (key domain-restricted)
**Data:** 12,400 stores, GeoJSON 1.1 MB (coords trimmed to 6 dp), clustered (radius 50, max zoom 14)
**Interaction:** cluster click zooms, feature-state selection synced with list
**SSR:** client-only dynamic import; map.remove() on unmount
**Verified:** audit clean, mobile throttle test, spot-checked locations, attribution shown Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
scripts/geojson_audit.py
#!/usr/bin/env python3
"""Audit a GeoJSON file before putting it on a web map.
Usage:
python3 geojson_audit.py <file.geojson> [--precision 6] [--expect minLng,minLat,maxLng,maxLat]
--expect is the bbox the data should fall in (e.g. a country). In-range but
swapped [lat, lng] pairs are only detectable against an expected area.
Reports feature counts, bbox, swapped or out-of-range coordinates, coordinate
precision (and bytes saved by trimming), unclosed and mis-wound polygon rings,
null geometries, missing feature ids, and size-based delivery advice.
Exits 1 if any ERROR is found.
"""
import argparse
import json
import re
import sys
from collections import Counter
from pathlib import Path
def iter_positions(geom):
t, c = geom.get("type"), geom.get("coordinates")
if t == "Point":
yield c
elif t in ("MultiPoint", "LineString"):
yield from c
elif t in ("MultiLineString", "Polygon"):
for part in c:
yield from part
elif t == "MultiPolygon":
for poly in c:
for ring in poly:
yield from ring
elif t == "GeometryCollection":
for g in geom.get("geometries", []):
yield from iter_positions(g)
def iter_rings(geom):
t, c = geom.get("type"), geom.get("coordinates")
if t == "Polygon":
for i, ring in enumerate(c):
yield i == 0, ring
elif t == "MultiPolygon":
for poly in c:
for i, ring in enumerate(poly):
yield i == 0, ring
elif t == "GeometryCollection":
for g in geom.get("geometries", []):
yield from iter_rings(g)
def signed_area(ring):
s = 0.0
for (x1, y1, *_), (x2, y2, *_) in zip(ring, ring[1:]):
s += x1 * y2 - x2 * y1
return s / 2
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("file", type=Path)
ap.add_argument("--precision", type=int, default=6, help="target decimal places (default 6, ~0.1 m)")
ap.add_argument("--expect", help="expected bbox minLng,minLat,maxLng,maxLat")
a = ap.parse_args()
errors, warns = [], []
expect = None
if a.expect:
try:
expect = [float(v) for v in a.expect.split(",")]
assert len(expect) == 4
except (ValueError, AssertionError):
print("ERROR --expect must be minLng,minLat,maxLng,maxLat")
sys.exit(1)
outside_expect = swapped_inside = 0
try:
raw = a.file.read_text(encoding="utf-8")
data = json.loads(raw)
except (OSError, json.JSONDecodeError, UnicodeDecodeError) as e:
print(f"ERROR cannot read GeoJSON: {e}")
sys.exit(1)
size = len(raw.encode("utf-8"))
if data.get("type") == "FeatureCollection":
features = data.get("features", [])
elif data.get("type") == "Feature":
features = [data]
elif "coordinates" in data or data.get("type") == "GeometryCollection":
features = [{"type": "Feature", "geometry": data, "properties": {}}]
else:
print("ERROR not a GeoJSON object (no FeatureCollection/Feature/geometry type)")
sys.exit(1)
if "crs" in data:
warns.append("top-level 'crs' member found: RFC 7946 GeoJSON must be WGS84 lon/lat; reproject if this is not EPSG:4326")
types = Counter()
null_geoms = missing_id = string_coords = 0
out_of_range = swapped_hint = 0
total_pos = 0
max_dp = 0
excess_chars = 0
minx = miny = float("inf")
maxx = maxy = float("-inf")
unclosed = bad_winding = 0
for f in features:
g = f.get("geometry")
if f.get("id") is None and not (f.get("properties") or {}).get("id"):
missing_id += 1
if not g:
null_geoms += 1
continue
types[g.get("type")] += 1
for pos in iter_positions(g):
total_pos += 1
if len(pos) < 2:
errors.append(f"position with fewer than 2 values: {pos}")
continue
x, y = pos[0], pos[1]
if isinstance(x, str) or isinstance(y, str):
string_coords += 1
continue
minx, maxx, miny, maxy = min(minx, x), max(maxx, x), min(miny, y), max(maxy, y)
if expect:
inside = lambda lx, ly: expect[0] <= lx <= expect[2] and expect[1] <= ly <= expect[3]
if not inside(x, y):
outside_expect += 1
if inside(y, x):
swapped_inside += 1
if not (-180 <= x <= 180 and -90 <= y <= 90):
out_of_range += 1
if -90 <= x <= 90 and -180 <= y <= 180:
swapped_hint += 1
for v in (x, y):
r = repr(float(v))
m = re.search(r"\.(\d+)$", r)
if m:
dp = len(m.group(1))
max_dp = max(max_dp, dp)
if dp > a.precision:
excess_chars += dp - a.precision
for exterior, ring in iter_rings(g):
if len(ring) < 4 or ring[0][:2] != ring[-1][:2]:
unclosed += 1
continue
area = signed_area(ring)
# RFC 7946: exterior counterclockwise (positive), holes clockwise (negative)
if (exterior and area < 0) or (not exterior and area > 0):
bad_winding += 1
n = len(features)
print(f"File {a.file} ({size / 1024:.1f} KB)")
print(f"Features {n} " + ", ".join(f"{t}: {c}" for t, c in types.most_common()))
print(f"Positions {total_pos}")
if total_pos and minx != float("inf"):
print(f"BBox [{minx:.5f}, {miny:.5f}, {maxx:.5f}, {maxy:.5f}] (lng/lat)")
print(f"Precision max {max_dp} decimal places")
if string_coords:
errors.append(f"{string_coords} positions have string coordinates — parse to numbers")
if out_of_range:
msg = f"{out_of_range} positions outside lon [-180,180] / lat [-90,90]"
if swapped_hint == out_of_range:
msg += " — all would be valid if swapped: coordinates are probably [lat, lng]; GeoJSON needs [lng, lat]"
elif abs(minx) > 1000 or abs(maxx) > 1000:
msg += " — values look like projected meters (e.g. EPSG:3857); reproject to EPSG:4326"
errors.append(msg)
if expect and outside_expect:
if swapped_inside == outside_expect:
errors.append(f"{outside_expect} positions fall outside --expect but inside it when swapped: coordinates are [lat, lng]; GeoJSON needs [lng, lat]")
else:
errors.append(f"{outside_expect} positions fall outside the expected bbox ({swapped_inside} would fit if swapped)")
if unclosed:
errors.append(f"{unclosed} polygon rings are unclosed or have fewer than 4 positions")
if bad_winding:
warns.append(f"{bad_winding} rings violate RFC 7946 winding (exterior CCW, holes CW); most renderers cope, some tools do not")
if null_geoms:
warns.append(f"{null_geoms} features have null geometry and will not render")
if missing_id and n:
warns.append(f"{missing_id}/{n} features lack an id — feature-state hover/selection needs id, promoteId or generateId")
if excess_chars:
saved = excess_chars # one byte per trimmed digit (ASCII)
warns.append(f"trimming to {a.precision} dp saves ~{saved / 1024:.1f} KB ({saved / size:.0%} of file)")
points = types.get("Point", 0) + types.get("MultiPoint", 0)
if size > 5 * 1024 * 1024 or points > 50_000:
warns.append("large dataset: build vector tiles (tippecanoe → PMTiles) instead of shipping raw GeoJSON")
elif points > 500:
warns.append(f"{points} points: render as a GeoJSON layer with clustering, not DOM markers")
elif size > 1024 * 1024:
warns.append("over 1 MB: serve gzip/brotli compressed and consider simplifying geometry (mapshaper)")
print()
for w in warns:
print(f"WARN {w}")
for e in errors:
print(f"ERROR {e}")
print(f"\n{len(errors)} error(s), {len(warns)} warning(s)")
sys.exit(1 if errors else 0)
if __name__ == "__main__":
main()
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.