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.

  • nameweb-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

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.

~/.claude/skills/web-maps/SKILL.md

--- name: web-maps description: Builds interactive web maps with MapLibre GL JS, Leaflet, or Mapbox GL — choosing library and tile provider with licensing and cost in… license: MIT ---

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

NeedPick
Vector tiles, smooth zoom/rotate, data-driven styling, large datasetsMapLibre GL JS 5 (BSD, free, globe projection)
Simple raster map, few markers, smallest bundle, old devicesLeaflet 1.9 (check if the project moved to Leaflet 2 ESM builds)
Mapbox Studio styles, Mapbox search/navigation APIsMapbox GL JS v3 (proprietary license, token, billed per map load)
3D city/terrain scenes, globe analyticsMapLibre with terrain, or deck.gl overlay for heavy data viz
Reactreact-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

SourceNotes
OpenFreeMap, Protomaps public buildsFree vector tiles/styles; check current usage terms
MapTiler, Stadia Maps, ThunderforestHosted with free tier, key required, priced per request
Self-hosted PMTiles (Protomaps) on S3/R2/CDNOne static file, HTTP range requests, no tile server, cheapest at scale
MapboxPer map load billing beyond free tier
tile.openstreetmap.orgNot 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 contributors plus 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 });
});
  • getClusterExpansionZoom returns a Promise in MapLibre 5 (callback style in older versions) — check the installed version.
  • Change cursor on mouseenter/mouseleave of 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 (promoteId or numeric id, or generateId: true).
  • Globe: MapLibre 5 supports projection: { type: "globe" } in the style or via map.setProjection after load.

Scale decisions

FeaturesApproach
< 100 points, rich custom UIHTML Markers are acceptable
100 – 50,000 pointsGeoJSON source + circle/symbol layers, clustering when they overlap
> 50,000 points or GeoJSON > ~5 MBVector 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 vehiclesGeoJSON 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 (bbox query param on moveend, debounced) when the full dataset is too big, or let tiles do it.
  • Leaflet with many markers: Leaflet.markercluster or supercluster, and preferCanvas: true for vector layers.

React and SSR

  • Map libraries touch window on import. In Next.js use a client component loaded with dynamic(() => 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, flyTo instead.
  • 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: use jumpTo instead of flyTo.
  • 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; Leaflet L.latLng and 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.load throw; setStyle wipes custom sources and layers — re-add them on style.load.
  • Fonts for symbol labels come from the style’s glyphs URL; a missing glyph endpoint means invisible labels.
  • Content Security Policy: MapLibre needs worker-src blob: and img-src blob: data: plus tile hosts in connect-src.

Verification

  1. python3 scripts/geojson_audit.py <file> --expect <bbox> exits 0 (no swapped coordinates, closed rings).
  2. Load the page on a throttled mobile profile (DevTools “Fast 4G”, 4x CPU): map interactive within ~3 s, panning smooth with the full dataset.
  3. Points appear in the right places — spot-check 3 known addresses against another map.
  4. Navigate away and back 20 times in the SPA: no WebGL context warnings, memory stable.
  5. 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 206 lines · 8.1 KB
#!/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.

esc