Claude Skill

mapbox-ops

Mapbox GL JS v3 (web) toolkit: custom markers, symbol/geojson layers, clustering, style expressions, 3D terrain/fill-extrusion, heatmaps, choropleths, camera animation, and performance gotchas. Use for any Mapbox GL JS work — markers, layers, style, 3D, or headless map verificati

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download 0xdarkmatter-claude-mods-skills_mapbox-ops-3dfaf0b.zip · 51 KB
Part of 0xdarkmatter/claude-mods — 94 skills

Install

skills CLI npx skills add https://github.com/0xDarkMatter/claude-mods/tree/main/skills/mapbox-ops
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install 0xdarkmatter-claude-mods@llmmart
Git git clone https://github.com/0xDarkMatter/claude-mods.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole 0xdarkmatter/claude-mods collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Mapbox GL JS — advanced web toolkit (v3)

An advanced toolkit for building production Mapbox GL JS map experiences on the web: markers, thematic dataviz, 3D, terrain, cinematic camera, style composition, performance, and the hard-won gotchas that bite. Scope: mapbox-gl-js v3.x in the browser (CDN mapbox-gl-js/v3.x/) — not the native iOS/Android SDKs (different APIs). Plain GL JS, framework-agnostic. Several patterns were distilled from a production trail map; adapt the constants to your own design.

Setup invariants

  • Set mapboxgl.accessToken before new mapboxgl.Map(...).
  • The map needs 'load' before adding sources/layers/images. In a throttled or background tab 'load' can be missed — also bind 'idle' as a one-shot fallback guarded by an _inited flag (see verification.md).
  • Resolving token/style from .env: read the token FIRST (that triggers the .env load), THEN read MAPBOX_STYLE. Reading the style before the token load silently falls back to the default style. See palette.md.
  • Classic vs Standard style. Several techniques here (basemap palette recolour, the terrain boost-or-add getStyle().layers walk) assume a classic style (Streets/Outdoors/Light/Dark …-v12). The v3 default Standard style has no enumerable named layers — use slots + setConfigProperty instead. See v3-standard-style.md before porting to Standard.

Pick the technique

Read the matching reference file only when the task needs it:

Task Reference
Custom SVG/canvas markers, addImage/updateImage, namespacing, AA/fringing, circular image masks, anchoring references/markers.md
Dashed/cased trail lines, line-dasharray units, translucency over hillshade, colour-by-attribute, line-gradient/lineMetrics references/lines-and-trails.md
Hillshade, dense contours, 3D terrain (setTerrain), boost-or-add an existing style's terrain references/terrain.md
Symbol-layer text labels that never hide icons (text-optional), AllTrails-style placement references/labels.md
Recolour a base style's land/vegetation fills (palette shift / choropleth-style match) references/palette.md
Custom popups, circular photo cards, zoom-scaled offsets references/popups.md
Style expressions — interpolate/step/match/case, the zoom-outermost rule, feature-state in expressions references/expressions.md
Hover/select via feature-state (not setData), queryRenderedFeatures caveats, clustering, GeoJSON perf, event hygiene references/interaction-and-performance.md
Data viz & 3D — fill-extrusion buildings/extruded data, heatmap layer, data-join choropleth (feature-state/match), proportional symbols, sky/fog references/dataviz-and-3d.md
three.js in the map — CustomLayerInterface + shared GL context, animated 3D objects/models, the baked-matrix vs reconstructed-camera (Threebox CameraSync) fork, raycast picking, ENU-metre scene space, constant screen-size actors, terrain elevation, far-plane clipping at pitch references/three-custom-layer.md
Camera & animation — flyTo/easeTo/fitBounds padding, freeCameraOptions cinematics/orbit, flight/first-person camera (bearing+pitch choreography; roll is MapLibre-only), animated day–night cycle (setLights), HUD synced to camera, point-along-line, draw-in lines, paint transitions, spinning globe, the essential/reduced-motion gotcha references/camera-and-animation.md
Style library & composition — first-party style catalog, choosing a base by use case, custom/third-party styles, style switcher, light/dark, hand-rolled style JSON references/styles.md (+ assets/style-catalog.json)
setStyle wiping custom layers, the 0×0 resize() bug, SPA teardown / WebGL-context cap, token security, readiness events references/lifecycle.md
v3 Standard style — slots vs beforeId, setConfigProperty/lightPreset, why layer-walking (palette/terrain) breaks; localisation, RTL, globe references/v3-standard-style.md
Headless screenshot + pixel-accurate marker-alignment checks (Playwright, map.project) references/verification.md

Bundled resources

  • Starter code — assets/circular_image_marker.js: copy into a page to register a circular photo marker (canvas → premultiplied ImageBitmap, destination-in mask, contact + drop shadow). Browser-only snippet, not a CLI — adapt the frameColor/box constants to your design.

  • Verifier script — scripts/screenshot_map.py: drive headless Chromium to screenshot a served map page, assert a marker projects to its lng/lat, and surface console errors. Run it:

    python -m http.server 8777 --directory <site-dir> &          # serve the page
    uv run --with playwright scripts/screenshot_map.py \
      http://localhost:8777/preview/index.html out.png --expect 146.9 -36.1
    # exit 0 = no console errors; 10 = errors found; 5 = playwright missing; 7 = map never ready
    uv run --with playwright scripts/screenshot_map.py URL out.png --json | jq '.data'
    
  • Staleness verifier — scripts/check-mapbox-facts.py: stdlib-only (no Playwright), guards the fast-moving facts this skill encodes (SKILL-RESOURCE-PROTOCOL §7). --offline (default) asserts internal consistency — the v3 Standard config enums (lightPreset/theme), terrain tileset IDs, the weather (≥3.7) version gate, the no-native-camera-roll fact (roll is MapLibre GL JS v5, not Mapbox), and every style URL/id in assets/style-catalog.json. --live resolves the third-party style URLs and probes whether Mapbox GL JS has shipped a major past v3.

    python scripts/check-mapbox-facts.py --offline            # exit 0 ok, 4 inconsistency
    python scripts/check-mapbox-facts.py --live --json        # exit 7 network, 10 drift
    

The three highest-value gotchas (full detail in the refs)

  1. Namespace every addImage name (e.g. "rcpin-<glyph>"). Mapbox styles ship sprite icons literally named parking/toilet/etc — an un-namespaced hasImage() returns true for those and your icon is silently dropped.
  2. Register icons as premultiplied createImageBitmap(), not a raw HTMLImageElement/ImageData — straight-alpha sources make Mapbox fringe a white halo around anti-aliased edges. updateImage(name, bmp) recolours in place.
  3. Data-driven icon-offset is silently ignored in GL JS v3. Use a constant icon-offset (it scales with icon-size) or split markers into separate symbol layers, each with its own constant anchor/offset.
Files (claude-mods)
  • assets
    • .gitkeep 0 B · in bundle
    • circular_image_marker.js 4.9 KB
      // Register a circular photo marker as a Mapbox GL JS map image.
      //
      // Why a canvas + ImageBitmap (not a DOM mapboxgl.Marker, not ctx.clip()):
      //   - addImage symbol layers batch/cluster/GPU-draw — DOM markers don't.
      //   - createImageBitmap() yields PREMULTIPLIED alpha → no white fringe at the AA edge
      //     (a raw HTMLImageElement/ImageData fringes a halo).
      //   - the disc is masked with destination-in (an anti-aliased arc FILL) on a scratch
      //     canvas, NOT ctx.clip() (clip is a 1-bit hard mask → jagged circle).
      //
      // Namespace the name ("rcphoto-…") so it can't collide with a basemap sprite icon
      // (an un-namespaced hasImage() can return true for the style's sprite and silently
      // drop your image).
      //
      // Same-origin: the photo must be same-origin (or CORS-enabled) or the canvas taints
      // and createImageBitmap throws.
      //
      // Usage:
      //   await addCircularPhotoMarker(map, "rcphoto-falls", "photos/thumbs/falls.jpg");
      //   map.addLayer({ id:"photo-pts", type:"symbol", source:"pts",
      //     layout:{ "icon-image":"rcphoto-falls", "icon-anchor":"bottom",
      //              "icon-offset":[0,8],            // tip on the point (8 units of pad below tip)
      //              "icon-size":["interpolate",["linear"],["zoom"], 11,0.82, 14,1.23, 17,1.5],
      //              "icon-allow-overlap":true } });
      
      function addCircularPhotoMarker(map, name, photoUrl, opts = {}) {
        const {
          frameColor = "#355e3b",   // ring colour
          F = 6,                     // supersample factor (high DPI; never let icon-size > native)
          boxW = 44, boxH = 52,      // logical box; tip at (boxW/2, 44), 8 units of pad below
          cy = 21, rOut = 16.5, rPhoto = 13.1,
        } = opts;
      
        return new Promise((resolve) => {
          if (map.hasImage(name)) return resolve(name);
          const img = new Image();
          img.crossOrigin = "anonymous";              // allow CORS-served images
          img.onerror = () => resolve(null);
          img.onload = async () => {
            if (map.hasImage(name)) return resolve(name);
            const W = boxW * F, H = boxH * F;
            const c = document.createElement("canvas"); c.width = W; c.height = H;
            const ctx = c.getContext("2d");
            ctx.scale(F, F);                          // draw in logical (44×52) space
            ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = "high";
            const cx = boxW / 2, tipY = 44;
      
            // (1) faint contact shadow under the tip so the bubble reads as grounded
            ctx.save();
            ctx.translate(cx, tipY); ctx.scale(1, 0.36);
            const gnd = ctx.createRadialGradient(0, 0, 0, 0, 0, 13);
            gnd.addColorStop(0, "rgba(20,16,9,0.28)"); gnd.addColorStop(1, "rgba(20,16,9,0)");
            ctx.fillStyle = gnd; ctx.beginPath(); ctx.arc(0, 0, 13, 0, 2 * Math.PI); ctx.fill();
            ctx.restore();
      
            // bubble = circle + short downward spike to the tip (apex = the anchor point)
            const bubble = (r) => {
              const hb = r * 0.34;
              ctx.beginPath(); ctx.moveTo(cx - hb, cy); ctx.lineTo(cx + hb, cy);
              ctx.lineTo(cx, tipY); ctx.closePath(); ctx.fill();
              ctx.beginPath(); ctx.arc(cx, cy, r, 0, 2 * Math.PI); ctx.fill();
            };
      
            // (2) drop shadow on the frame body for lift (cleared before drawing the photo)
            ctx.save();
            ctx.shadowColor = "rgba(20,16,9,0.26)"; ctx.shadowBlur = 2.8; ctx.shadowOffsetY = 0.9;
            ctx.fillStyle = frameColor; bubble(rOut);
            ctx.restore();
      
            // (3) subtle top-lit sheen on the frame (AA arc fill)
            const sheen = ctx.createLinearGradient(0, cy - rOut, 0, cy + 2);
            sheen.addColorStop(0, "rgba(255,255,255,0.26)"); sheen.addColorStop(1, "rgba(255,255,255,0)");
            ctx.fillStyle = sheen; ctx.beginPath(); ctx.arc(cx, cy, rOut, 0, 2 * Math.PI); ctx.fill();
      
            // (4) cover-fit + AA-mask the photo into the inner disc via destination-in
            const pr = rPhoto * F;
            const sc = document.createElement("canvas"); sc.width = sc.height = pr * 2;
            const sx = sc.getContext("2d");
            sx.imageSmoothingEnabled = true; sx.imageSmoothingQuality = "high";
            const iw = img.naturalWidth || 1, ih = img.naturalHeight || 1;
            const scale = Math.max((pr * 2) / iw, (pr * 2) / ih);
            sx.drawImage(img, pr - (iw * scale) / 2, pr - (ih * scale) / 2, iw * scale, ih * scale);
            sx.globalCompositeOperation = "destination-in";        // AA circular mask (NOT clip())
            sx.beginPath(); sx.arc(pr, pr, pr, 0, 2 * Math.PI); sx.fill();
            ctx.drawImage(sc, cx - rPhoto, cy - rPhoto, rPhoto * 2, rPhoto * 2);
      
            // (5) register premultiplied (createImageBitmap) so the edge doesn't fringe
            try {
              const bmp = await createImageBitmap(c);
              if (!map.hasImage(name)) map.addImage(name, bmp, { pixelRatio: F });
            } catch (e) {
              // fallback: register the canvas directly (may fringe slightly)
              if (!map.hasImage(name)) map.addImage(name, c, { pixelRatio: F });
            }
            resolve(name);
          };
          img.src = photoUrl;
        });
      }
      
      if (typeof module !== "undefined" && module.exports) module.exports = { addCircularPhotoMarker };
      
    • style-catalog.json 5.4 KB
      {
        "_comment": "Mapbox first-party base styles, current as of GL JS v3 (2026). Copy a `url` into `new mapboxgl.Map({style})` or `map.setStyle()`. `family:standard` styles are the v3 3D composited basemap (configure via setConfigProperty, insert layers via slot — see references/v3-standard-style.md). `family:classic` styles expose named, enumerable layers (palette recolour + layer-walking work — see references/palette.md, terrain.md). Verify the latest version suffixes against https://docs.mapbox.com/api/maps/styles/#mapbox-styles before relying on them.",
        "styles": [
          { "id": "standard",            "url": "mapbox://styles/mapbox/standard",                "family": "standard", "labels": true,  "imagery": false, "three_d": true,  "use": ["3d-city", "modern", "default", "configurable-light"] },
          { "id": "standard-satellite",  "url": "mapbox://styles/mapbox/standard-satellite",      "family": "standard", "labels": true,  "imagery": true,  "three_d": true,  "use": ["3d-aerial", "satellite-with-3d"] },
          { "id": "streets-v12",         "url": "mapbox://styles/mapbox/streets-v12",             "family": "classic",  "labels": true,  "imagery": false, "three_d": false, "use": ["wayfinding", "general-reference", "busy"] },
          { "id": "outdoors-v12",        "url": "mapbox://styles/mapbox/outdoors-v12",            "family": "classic",  "labels": true,  "imagery": false, "three_d": false, "use": ["trail", "terrain", "hillshade", "outdoor"] },
          { "id": "light-v11",           "url": "mapbox://styles/mapbox/light-v11",               "family": "classic",  "labels": true,  "imagery": false, "three_d": false, "use": ["dataviz", "choropleth", "muted-base"] },
          { "id": "dark-v11",            "url": "mapbox://styles/mapbox/dark-v11",                "family": "classic",  "labels": true,  "imagery": false, "three_d": false, "use": ["dataviz", "dark-mode", "glowing-data"] },
          { "id": "satellite-v9",        "url": "mapbox://styles/mapbox/satellite-v9",            "family": "classic",  "labels": false, "imagery": true,  "three_d": false, "use": ["pure-imagery", "no-labels"] },
          { "id": "satellite-streets-v12","url": "mapbox://styles/mapbox/satellite-streets-v12",  "family": "classic",  "labels": true,  "imagery": true,  "three_d": false, "use": ["aerial-with-context", "photoreal"] },
          { "id": "navigation-day-v1",   "url": "mapbox://styles/mapbox/navigation-day-v1",       "family": "classic",  "labels": true,  "imagery": false, "three_d": false, "use": ["turn-by-turn", "routing-day"] },
          { "id": "navigation-night-v1", "url": "mapbox://styles/mapbox/navigation-night-v1",     "family": "classic",  "labels": true,  "imagery": false, "three_d": false, "use": ["turn-by-turn", "routing-night"] }
        ],
        "standard_presets": {
          "_comment": "v3 Standard style (and standard-satellite) — set at runtime via map.setConfigProperty('basemap', key, value) on style.load. One style, many looks; custom layers preserved (no reload).",
          "lightPreset": ["dawn", "day", "dusk", "night"],
          "theme": ["default", "faded", "monochrome"]
        },
        "effects": {
          "_comment": "Runtime, style-independent visual effects. Weather requires GL JS >= 3.7.",
          "rain": "map.setRain({density, intensity, color, opacity}) | map.setRain(null)",
          "snow": "map.setSnow({density, intensity, color, flakeSize}) | map.setSnow(null)",
          "fog":  "map.setFog({range, color, 'high-color', 'horizon-blend'}) — globe/terrain atmosphere"
        },
        "third_party": [
          { "id": "stadia-stamen-watercolor", "url": "https://tiles.stadiamaps.com/styles/stamen_watercolor.json", "looks": ["watercolor", "painterly", "artistic"], "key": "stadia (free tier)", "note": "Iconic hand-painted map. Stamen styles are served by Stadia." },
          { "id": "stadia-stamen-toner",      "url": "https://tiles.stadiamaps.com/styles/stamen_toner.json",      "looks": ["high-contrast", "black-and-white", "editorial"], "key": "stadia (free tier)" },
          { "id": "stadia-alidade-smooth-dark","url": "https://tiles.stadiamaps.com/styles/alidade_smooth_dark.json","looks": ["dark", "muted", "dataviz"], "key": "stadia (free tier)" },
          { "id": "carto-positron",    "url": "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json",    "looks": ["light", "minimal", "dataviz"], "key": "none (attribution)" },
          { "id": "carto-dark-matter", "url": "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json", "looks": ["dark", "glowing-data", "dashboard"], "key": "none (attribution)" },
          { "id": "carto-voyager",     "url": "https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json",     "looks": ["balanced", "labelled", "dataviz"], "key": "none (attribution)" },
          { "id": "openfreemap-liberty","url": "https://tiles.openfreemap.org/styles/liberty",                    "looks": ["streets", "osm-bright-like"], "key": "none" },
          { "id": "thunderforest",     "looks": ["spinal-map (tron)", "atlas (vintage sepia)", "pioneer", "transport"], "key": "thunderforest (freemium)", "note": "style-JSON/raster via apikey; artistic catalog." },
          { "id": "maptiler",          "looks": ["backdrop", "dataviz", "toner", "topo", "winter", "ocean", "bright"], "key": "maptiler (key)", "note": "https://api.maptiler.com/maps/<style>/style.json?key=…" },
          { "id": "protomaps",         "looks": ["light", "dark", "white", "black", "grayscale", "contrast"], "key": "none (self-host PMTiles)", "note": "protomaps-themes-base; single-file basemap via pmtiles:// protocol." }
        ]
      }
      
  • references
    • camera-and-animation.md 8.3 KB
      # Camera & animation
      
      Camera control, cinematic fly-throughs, and animated data. How-to first, with the
      footguns that bite flagged inline.
      
      ## Table of contents
      - [Camera methods](#camera-methods)
      - [fitBounds & cameraForBounds](#fitbounds--cameraforbounds)
      - [The `essential` / reduced-motion gotcha](#the-essential--reduced-motion-gotcha)
      - [freeCameraOptions — full 3D control](#freecameraoptions--full-3d-control)
      - [Animate a point along a line](#animate-a-point-along-a-line)
      - [Draw-in a line (progressive reveal)](#draw-in-a-line-progressive-reveal)
      - [Animated data & paint transitions](#animated-data--paint-transitions)
      - [Spinning globe](#spinning-globe)
      
      ## Camera methods
      
      | Method | Use |
      |---|---|
      | `jumpTo(opts)` | Instant, no animation |
      | `easeTo(opts)` | Animated, constant-feel — short hops, pitch/bearing changes |
      | `flyTo(opts)` | Zoom-out-arc "flight" — best for long distances |
      | `panTo` / `panBy` / `rotateTo` / `snapToNorth` | Single-axis moves |
      
      All take `{center, zoom, bearing, pitch, padding, duration}`. `flyTo` adds the flight
      controls: **`curve`** (default 1.42 — higher = bigger zoom-out arc), `speed`,
      `screenSpeed`, `minZoom`, `maxDuration`.
      
      ```js
      map.flyTo({ center:[146.9,-36.1], zoom:14, pitch:55, bearing:-20, curve:1.6, speed:0.8,
                  essential:true });
      ```
      
      ## fitBounds & cameraForBounds
      
      ```js
      map.fitBounds(bounds, { padding:{top:40,bottom:40,left:320,right:40}, maxZoom:15,
                              pitch: map.getPitch(), bearing: map.getBearing() });
      ```
      
      - **`padding` as an object** is essential when a sidebar/legend/controls overlay the map
        — otherwise the fitted content tucks *under* your UI. Per-edge padding offsets it clear.
      - Preserve a 3D camera across fits by passing the current `pitch`/`bearing` (a bare
        `fitBounds` resets them to 0/north).
      - **`cameraForBounds(bounds, opts)`** returns the computed `{center, zoom, …}` *without
        moving* — compute it, tweak it (nudge zoom, add pitch), then `easeTo` the result.
      
      ## The `essential` / reduced-motion gotcha
      
      Any camera animation is **cancelled immediately** (jumps to the end) for users with
      `prefers-reduced-motion: reduce` — **unless you pass `{essential: true}`**. A "fly to the
      selected result" that mysteriously *jumps* for some users and animates for others is
      almost always this. Pass `essential:true` when the motion conveys meaning; otherwise
      honour the preference deliberately. (User scroll/drag also interrupts animations — usually
      desired; `essential` doesn't change that.)
      
      ## freeCameraOptions — full 3D control
      
      When `flyTo`/`easeTo` can't express the shot (orbit a point, drone pass over terrain, a
      camera at a specific altitude looking at a target), drive the camera directly:
      
      ```js
      const cam = map.getFreeCameraOptions();
      const target = [146.9, -36.1];
      // position the camera at an altitude, then aim it at the target
      cam.position = mapboxgl.MercatorCoordinate.fromLngLat([146.85, -36.15], 4000); // 4 km up
      cam.lookAtPoint(target);
      map.setFreeCameraOptions(cam);
      ```
      
      - `MercatorCoordinate.fromLngLat([lng,lat], altitudeMetres)` — the 3rd arg is **metres**.
      - To move by real-world distance, scale with
        `MercatorCoordinate.fromLngLat(c).meterInMercatorCoordinateUnits()`.
      - Orbit: in a RAF loop, advance an angle and recompute `position` on a circle around the
        target, `lookAtPoint(target)` each frame.
      - With terrain on, the camera **collides** with the DEM (won't sink below ground); for low
        passes set an altitude safely above the terrain.
      
      ## Flight / first-person camera (drone, fly-through, sim)
      
      Continuous first-person movement — a flight sim, drone pass, or walkthrough — drives the
      camera every frame from a `{lng, lat, alt, heading, pitch, speed}` state.
      
      - **Native camera:** Mapbox GL JS has **no camera roll** — only `bearing` + `pitch`.
        "Banked" turns are choreography: sweep `bearing` through the turn while easing `pitch`
        up a few degrees; the combined motion reads as a bank even though the horizon stays
        level. Per-frame `jumpTo` is instant (no easing lag):
      
      ```js
      const s = { lng:146.9, lat:-36.1, alt:1500, heading:0, pitch:75, speed:0 };
      function fly() {
        // advance along heading by speed (deg/frame ≈ metres → deg)
        const rad = s.heading * Math.PI/180, d = s.speed * 1e-5;
        s.lat += Math.cos(rad) * d; s.lng += Math.sin(rad) * d;
        map.jumpTo({ center:[s.lng,s.lat], bearing:s.heading, pitch:s.pitch });
        requestAnimationFrame(fly);
      }
      fly();   // A/D → s.heading, W/S → s.pitch, R/F → s.speed (key handlers)
      ```
      
      - **Full positional control** (camera at an exact altitude, aimed at a target): use
        `freeCameraOptions` — set `position = MercatorCoordinate.fromLngLat([lng,lat], alt)`
        and orientation each frame. This does **not** unlock roll either: the orientation
        quaternion "must be representable using only pitch and bearing" (per the
        FreeCameraOptions docs — a non-conforming orientation is discarded). freeCamera buys
        position freedom, not a rolled horizon.
      - **Need true roll?** Switch libraries — MapLibre GL JS v5 added first-class camera roll
        (`easeTo`/`flyTo({roll})`, `map.setRoll`) plus pitch beyond 90°. Mapbox GL JS has no
        equivalent on any camera API.
      - Cap pitch near 85° — the native camera's hard maximum (`maxPitch`); a nose-down or
        looking-up shot past that is likewise MapLibre-v5-only territory.
      
      ## Animated day–night cycle
      
      - **Discrete (Standard style):** step `setConfigProperty("basemap","lightPreset", …)`
        through `dawn→day→dusk→night` on a timer — snaps between four looks (cheap, no reload).
      - **Smooth (3D lights):** drive the v3 lights API and move the sun — animate the
        directional light's direction by clock for a continuous sweep:
      
      ```js
      map.setLights([
        { id:"ambient", type:"ambient", properties:{ intensity:0.5 } },
        { id:"sun", type:"directional",
          properties:{ direction:[azimuthDeg, polarDeg], intensity:0.8, "cast-shadows":true } }
      ]);
      // in a slow loop, advance azimuth/polar to sweep the sun → moving shadows, warm→cool light
      ```
      
      ## HUD synced to camera state
      
      A telemetry overlay (speed, heading, altitude) is absolutely-positioned DOM updated from
      the camera. Read state on `"move"`/`"render"` (not a free-running RAF) so it idles cheaply:
      
      ```js
      map.on("render", () => {
        hud.heading.textContent = Math.round(map.getBearing());
        hud.pitch.textContent   = Math.round(map.getPitch());
        hud.alt.textContent     = Math.round(map.getFreeCameraOptions().position.toAltitude());
      });
      ```
      
      `getFreeCameraOptions().position.toAltitude()` is the live camera altitude in metres —
      the only way to read it (there's no `map.getAltitude()`).
      
      ## Animate a point along a line
      
      `requestAnimationFrame` loop advancing a distance; get the point with Turf
      (`@turf/along`) or manual segment interpolation; `setData` a one-point source. Rotate an
      icon with `@turf/bearing`.
      
      ```js
      let phase = 0, raf;
      const line = trail.features[0], total = turf.length(line);   // km
      function frame() {
        phase = (phase + 0.02) % total;
        const pt = turf.along(line, phase);
        map.getSource("mover").setData(pt);
        raf = requestAnimationFrame(frame);
      }
      frame();
      // cancelAnimationFrame(raf) on teardown — see lifecycle.md (leaked RAF loops survive map.remove)
      ```
      
      ## Draw-in a line (progressive reveal)
      
      Two options:
      - **Cheap:** one `line-gradient` over `["line-progress"]` and animate a single colour
        stop from 0→1 (no geometry churn). Needs `lineMetrics:true` (see
        [lines-and-trails.md](lines-and-trails.md)).
      - **Simple:** slice the coordinates up to `phase` and `setData` each frame (more work,
        but lets you also drop a moving "head" marker).
      
      ## Animated data & paint transitions
      
      **Paint** properties transition smoothly on change (layout properties do not). Set the
      transition then update the value:
      
      ```js
      map.setPaintProperty("choro", "fill-color-transition", { duration: 600, delay: 0 });
      map.setPaintProperty("choro", "fill-color", nextRamp);   // animates over 600 ms
      ```
      
      For continuous data updates, `setData` on a RAF/interval — but batch and throttle; every
      `setData` re-tiles the source (see [interaction-and-performance.md](interaction-and-performance.md)).
      
      ## Spinning globe
      
      ```js
      function spin() {
        if (userInteracting || map.getZoom() > 5) return;
        const c = map.getCenter(); c.lng -= 2;
        map.easeTo({ center: c, duration: 1000, easing: t => t });   // linear, seamless loop
      }
      map.on("moveend", spin);   // chain each ease into the next; pause on user interaction
      ```
      
    • dataviz-and-3d.md 5.7 KB
      # Data viz & 3D
      
      Thematic mapping (choropleth, heatmap, proportional symbols) and extruded 3D. Colour-
      ramp *theory* (sequential/diverging, contrast, CVD) belongs to the related `color-ops`
      skill — this file is the **Mapbox wiring** and its footguns.
      
      ## Table of contents
      - [fill-extrusion — 3D buildings & extruded data](#fill-extrusion--3d-buildings--extruded-data)
      - [Heatmap layer](#heatmap-layer)
      - [Data-join choropleth (your data isn't in the tiles)](#data-join-choropleth-your-data-isnt-in-the-tiles)
      - [Proportional symbols (√ scaling)](#proportional-symbols--scaling)
      - [Sky & atmosphere](#sky--atmosphere)
      
      ## fill-extrusion — 3D buildings & extruded data
      
      Two uses: the basemap's buildings, or **your own** polygons (parcels, footprints, 3D
      bar/prism maps).
      
      ```js
      // Basemap buildings (CLASSIC styles): composite source, source-layer "building".
      map.addLayer({ id:"3d-buildings", type:"fill-extrusion", source:"composite",
        "source-layer":"building", filter:["==",["get","extrude"],"true"], minzoom:15,
        paint:{
          "fill-extrusion-color":"#ccc",
          "fill-extrusion-height":["get","height"],          // metres, from the tile
          "fill-extrusion-base":["get","min_height"],
          "fill-extrusion-opacity":0.85,
          "fill-extrusion-vertical-gradient":true }});
      ```
      
      Footguns:
      - **Buildings only exist ~zoom 15+** and are **invisible without camera pitch** —
        `map.easeTo({pitch:55})` or you'll swear nothing rendered.
      - Heights are **metres**. With terrain on, extrusions drape on the DEM (height is *above*
        ground), which is usually what you want — but a `fill-extrusion-base` from sea-level
        data will float/sink.
      - Extrusions render in a **single pass** — you can't interleave other layers between
        faces by z-order; `fill-extrusion-opacity` < 1 looks wrong (faces show through). Keep
        them near-opaque.
      - **v3 Standard** ships 3D buildings + landmarks built-in
        (`setConfigProperty("basemap","show3dObjects",true)`), and has no `composite`/`building`
        layer to target — custom `fill-extrusion` there is only for **your own** data. See
        [v3-standard-style.md](v3-standard-style.md).
      
      Extruding your own data is the same layer with a geojson source and height from a
      property: `"fill-extrusion-height":["*",20,["get","stories"]]`.
      
      ## Heatmap layer
      
      Four knobs, and they interact with zoom:
      
      ```js
      map.addLayer({ id:"heat", type:"heatmap", source:"pts", maxzoom:15,
        paint:{
          // per-point contribution (data-driven weight)
          "heatmap-weight":["interpolate",["linear"],["get","mag"], 0,0, 6,1],
          // global multiplier — ramp UP with zoom so density stays readable
          "heatmap-intensity":["interpolate",["linear"],["zoom"], 0,1, 15,3],
          // radius in SCREEN px — ramp with zoom or it blobs/vanishes
          "heatmap-radius":["interpolate",["linear"],["zoom"], 0,2, 15,20],
          // colour ramp over density 0..1 — stop 0 MUST be transparent
          "heatmap-color":["interpolate",["linear"],["heatmap-density"],
            0,"rgba(0,0,255,0)", 0.2,"#80f", 0.5,"#f0f", 1,"#f00"],
          "heatmap-opacity":["interpolate",["linear"],["zoom"], 13,1, 15,0] }});
      ```
      
      Footguns:
      - **`heatmap-color` stop 0 must be `rgba(...,0)`** — a solid colour at density 0 washes
        the entire canvas.
      - `heatmap-radius` is **screen pixels**, so apparent density shifts with zoom; ramp
        `radius`/`intensity` across zoom and **fade to a `circle` layer** past `maxzoom` (the
        `heatmap-opacity`→0 + a circle layer taking over at high zoom is the standard handoff).
      
      ## Data-join choropleth (your data isn't in the tiles)
      
      Vector tiles carry geometry + a key (FIPS, postcode), **not your statistics**. Join at
      runtime, two ways:
      
      **1. `feature-state`** — best for interactive / changing data. Key on `promoteId`:
      
      ```js
      map.addSource("counties", { type:"vector", url:"mapbox://…",
        promoteId:{ "county": "FIPS" } });          // per-source-layer key → stable id
      map.addLayer({ id:"choro", type:"fill", source:"counties", "source-layer":"county",
        paint:{ "fill-color":["interpolate",["linear"],
          ["coalesce",["feature-state","rate"],0], 0,"#eee", 100,"#900"] }});
      
      function applyData(rows){                       // rows: {fips, rate}
        for (const r of rows)
          map.setFeatureState({source:"counties", sourceLayer:"county", id:r.fips}, {rate:r.rate});
      }
      // GOTCHA: feature-state is LOST when a tile reloads (pan/zoom). Reapply:
      map.on("sourcedata", e => { if (e.sourceId==="counties" && e.isSourceLoaded) applyData(DATA); });
      ```
      
      **2. `match` expression** — fine for **static, small/medium** sets; bakes data into the
      style. A huge `match` (thousands of entries) bloats the style and re-evals on every
      frame — use feature-state past a few hundred features.
      
      ```js
      "fill-color":["match",["get","FIPS"], "06001","#900", "06003","#c44", /* … */ "#eee"]
      ```
      
      ## Proportional symbols (√ scaling)
      
      Scale `circle-radius` by a value — but radius ∝ value makes big values **dwarf** the rest
      (area grows as r²). For honest area perception, radius ∝ **√value**:
      
      ```js
      "circle-radius":["interpolate",["linear"],["zoom"],
        10, ["*", 2, ["sqrt",["get","pop"]]],
        16, ["*", 6, ["sqrt",["get","pop"]]] ]
      ```
      
      ## Sky & atmosphere
      
      For 3D/terrain/globe scenes, add depth cheaply:
      
      ```js
      map.setFog({ range:[1,10], "horizon-blend":0.1, color:"#fff", "high-color":"#aaccff" });
      // optional sun: map.addLayer({ id:"sky", type:"sky",
      //   paint:{ "sky-type":"atmosphere", "sky-atmosphere-sun":[0,5] }});
      ```
      
      `setFog` is what makes the globe and tilted terrain read as 3D rather than flat.
      
      **Weather (GL JS ≥ 3.7):** `map.setRain({density,intensity,color,opacity})` and
      `map.setSnow({density,intensity,flakeSize})` add animated precipitation over the 3D
      scene; pass `null` to clear. Dramatic with camera pitch + the Standard `night`/`dusk`
      `lightPreset` — see [styles.md](styles.md#visually-dynamic--artistic-styles).
      
    • expressions.md 2.9 KB
      # Expressions
      
      Mapbox style expressions drive data- and zoom-dependent styling. The footguns below
      are the ones that cost real time.
      
      ## The zoom-outermost rule (the big one)
      
      A **`["zoom"]`** expression may only appear as the **top-level input to an
      `interpolate` or `step`**. You **cannot nest it deeper** — not inside a `match`/`case`
      branch, not as an inner input to another expression, not in a `filter`. Violating it
      throws *"zoom expressions are not supported"* (or silently no-ops in some contexts).
      
      To vary by **both zoom and data**, put the zoom `interpolate` **outermost** and a data
      expression in each **stop output**:
      
      ```js
      // WRONG — zoom nested inside match → error
      ["match", ["get","kind"], "big", ["interpolate",["linear"],["zoom"], 10,2, 16,6], 1]
      
      // RIGHT — zoom outermost, data expression per stop
      ["interpolate", ["linear"], ["zoom"],
        10, ["match", ["get","kind"], "big", 2, 1],
        16, ["match", ["get","kind"], "big", 6, 3]]
      ```
      
      ## `step` vs `interpolate`
      
      - **`interpolate`** — smooth blend between stops (sizes, opacities, colours, widths).
        `["interpolate",["linear"],["zoom"], 11,3, 17,6]`. Use `["exponential",base]` for a
        perceptually even ramp across many zooms.
      - **`step`** — hard jumps at thresholds (discrete tiers, e.g. cluster colour buckets).
        `["step",["get","point_count"], "#0f0", 10,"#ff0", 100,"#f00"]`.
      
      ## `match` vs `case`
      
      - **`match`** — switch on one input against literal values (fast, readable). Values can
        be arrays to share an output. Last arg is the **mandatory default**.
        `["match",["get","class"], ["wood","grass"],"#bcd29c", "#dde4d0"]`
      - **`case`** — ordered boolean conditions (ranges, compound logic). Last arg is the else.
        `["case", ["<",["get","h"],100],"#0f0", ["<",["get","h"],500],"#ff0", "#f00"]`
      
      `match` only tests equality; the moment you need `<`/`>`/`&&`, switch to `case`.
      
      ## `coalesce` for missing data
      
      Fields are often absent or typed inconsistently. Guard with `coalesce` (first non-null)
      and `to-string`/`to-number` to normalise:
      
      ```js
      ["match", ["to-string", ["coalesce", ["get","Difficulty"], ""]],
        "1","#2e7d32", /* … */ "#cc7d45"]
      ```
      
      ## `feature-state` in expressions (hover/selection)
      
      `["feature-state","hover"]` reads runtime state set by `map.setFeatureState` — the
      canonical hover/highlight pattern (no `setData`, see
      [interaction-and-performance.md](interaction-and-performance.md)). Constraints:
      
      - **Paint properties only** — not layout, not `filter`.
      - The source feature needs a **stable id** (`generateId:true` or `promoteId`).
      - Default with `coalesce`, since state is undefined until first set:
        `["case", ["boolean",["feature-state","hover"],false], "#ff0", "#888"]`
      
      ## Debugging
      
      `map.queryRenderedFeatures(point)[0].layer.paint` and the GL JS console error messages
      name the offending sub-expression. Build complex expressions incrementally — Mapbox
      validates the whole tree and a single type mismatch rejects all of it.
      
    • interaction-and-performance.md 3.4 KB
      # Interaction & performance
      
      ## Hover/select via `feature-state`, NOT `setData`
      
      Re-running `source.setData(...)` to highlight a feature re-parses and re-tiles the whole
      collection every mouse-move — janky on anything but tiny data. Instead set **feature
      state** and read it in a paint expression (see [expressions.md](expressions.md)):
      
      ```js
      map.addSource("pts", { type:"geojson", data: fc, generateId: true });  // stable ids
      map.addLayer({ id:"pts", type:"circle", source:"pts",
        paint:{ "circle-color":
          ["case",["boolean",["feature-state","hover"],false], "#ff0", "#3887be"] }});
      
      let hovered = null;
      map.on("mousemove", "pts", (e) => {
        if (hovered !== null) map.setFeatureState({source:"pts", id:hovered}, {hover:false});
        hovered = e.features[0].id;
        map.setFeatureState({source:"pts", id:hovered}, {hover:true});
      });
      map.on("mouseleave", "pts", () => {
        if (hovered !== null) map.setFeatureState({source:"pts", id:hovered}, {hover:false});
        hovered = null;
      });
      ```
      
      Ids: `generateId:true` assigns sequential ids; **`promoteId:"myKey"`** uses an existing
      property as the id (survives `setData`, unlike generated ids). Feature-state needs one.
      Vector-tile sources need `promoteId` keyed per source-layer.
      
      ## `queryRenderedFeatures` caveats
      
      - Returns only features **currently rendered in the viewport** — nothing off-screen,
        nothing in a hidden layer. Not a data query.
      - Returns **duplicates** for features spanning tile boundaries → **dedupe by id**.
      - Bare `map.queryRenderedFeatures(point)` hits every layer; pass `{layers:[...]}`.
      - For all loaded features regardless of viewport use `querySourceFeatures(source,
        {sourceLayer})` — but it's unordered and may return tile-clipped fragments.
      
      ## Clustering
      
      ```js
      map.addSource("pois", { type:"geojson", data: fc, cluster:true,
        clusterRadius:45, clusterMaxZoom:12,
        // aggregate per cluster — sum/any/etc. over member features
        clusterProperties:{ photos:["+",["case",["has","photo"],1,0]] } });
      ```
      
      Click a cluster → expand to the zoom that breaks it up:
      
      ```js
      map.on("click","clusters",(e)=>{
        const f = map.queryRenderedFeatures(e.point,{layers:["clusters"]})[0];
        map.getSource("pois").getClusterExpansionZoom(f.properties.cluster_id,(err,z)=>{
          if(!err) map.easeTo({center:f.geometry.coordinates, zoom:z});
        });
      });
      ```
      
      Gotcha: **feature-state doesn't propagate to clustered children** — hover/select on the
      unclustered points layer, not the cluster circles.
      
      ## GeoJSON source performance
      
      - Prefer **one source updated by setData** over many small sources; batch updates.
      - `tolerance` (default 0.375) simplifies geometry — raise it for dense lines you view
        zoomed out; lower for crisp detail.
      - For very large static data, serve **vector tiles** (tippecanoe → `.mbtiles` →
        hosted/`pmtiles`) instead of a megabyte GeoJSON; GL JS streams only visible tiles.
      - `buffer` and `lineMetrics:true` (needed for `line-gradient`) cost memory — enable only
        when used.
      
      ## Event hygiene
      
      - Layer-scoped `map.on("click", layerId, fn)` only fires for that layer's features;
        map-wide `map.on("click", fn)` fires everywhere (use for "click empty map to deselect").
      - Pointer cursor: `mouseenter`/`mouseleave` per interactive layer toggling
        `map.getCanvas().style.cursor`.
      - Remove listeners on teardown (see [lifecycle.md](lifecycle.md)); anonymous handlers
        can't be removed — keep named refs if the layer is transient.
      
    • labels.md 1.8 KB
      # Symbol-layer text labels
      
      ## Labels that never hide the icon (AllTrails style)
      
      Put `text-field` on the **same symbol layer** as the icon, with `text-optional: true`
      + `text-allow-overlap: false`. The result: **icons always render**; labels auto-hide
      only where they would collide, and **reappear as you zoom in**. No data is lost.
      
      ```js
      map.addLayer({ id:"poi-glyph", type:"symbol", source:"pois",
        layout:{
          "icon-image":["get","_iconimg"], "icon-anchor":"center", "icon-allow-overlap":true,
          // label centred BELOW the marker:
          "text-field":["coalesce",["get","_label"],""],
          "text-size":11.5, "text-anchor":"top", "text-offset":[0,0.9],
          "text-justify":"center", "text-max-width":8,
          "text-optional":true,            // icon stays even if label is dropped
          "text-allow-overlap":false       // labels declutter against each other
        },
        paint:{ "text-color":"#3f4035", "text-halo-color":"#fff", "text-halo-width":1.6 }});
      ```
      
      Key combination:
      - `text-anchor:"top"` + `text-offset:[0, Y]` → label sits **centred below** the marker.
      - `text-optional:true` is what protects the icon — without it, a dropped label drops
        the whole symbol (icon included).
      - `text-allow-overlap:false` (the default) lets crowded labels declutter.
      
      ## Line labels (trail name along the path)
      
      ```js
      map.addLayer({ id:"trail-label", type:"symbol", source:"trail",
        layout:{ "symbol-placement":"line", "text-field":["coalesce",["get","Name"],""],
                 "text-size":12, "text-letter-spacing":0.02 },
        paint:{ "text-color":"#753c1c", "text-halo-color":"#fff", "text-halo-width":2 }});
      ```
      
      ## Toggle labels on/off at runtime
      
      Set `text-field` to `""` to hide, restore the expression to show — no layer rebuild:
      
      ```js
      map.setLayoutProperty("poi-glyph", "text-field",
        showLabels ? ["coalesce",["get","_label"],""] : "");
      ```
      
    • lifecycle.md 2.8 KB
      # Lifecycle, resize, teardown, tokens
      
      ## `setStyle` wipes your custom layers
      
      `map.setStyle(url)` replaces the **entire** style — every `addSource`/`addLayer`/
      `addImage` you made is gone. (The default `{diff:true}` only preserves things when
      diffing *within* a style; switching base styles is a full swap.) Re-install on the
      `style.load` event, driven by one idempotent installer you also call at init:
      
      ```js
      function installCustom() {
        if (!map.getSource("pts")) map.addSource("pts", {/* … */});
        if (!map.getLayer("pts"))  map.addLayer({/* … */});
        // re-register images too — they're cleared with the style
      }
      map.on("load", installCustom);
      map.on("style.load", installCustom);   // fires after every setStyle
      map.setStyle("mapbox://styles/mapbox/dark-v11");   // triggers style.load
      ```
      
      Guard every re-add with `getSource`/`getLayer`/`hasImage` so the init path and the
      style-swap path share one function without double-add errors.
      
      ## The 0×0 / half-rendered map → `map.resize()`
      
      A map initialised in a container that was `display:none`, in a collapsed flexbox cell, or
      inside a tab/accordion shown *after* init, renders at the wrong size (blank, clipped, or
      offset clicks). Call **`map.resize()`** once the container has real dimensions:
      
      ```js
      new ResizeObserver(() => map.resize()).observe(document.getElementById("mapwrap"));
      // or after revealing a tab:  showTab(); map.resize();
      ```
      
      ## Teardown — `map.remove()` and the WebGL-context cap
      
      Browsers allow only **~16 live WebGL contexts**. An SPA that mounts a map on each route
      without disposing leaks contexts → eventually *"Too many active WebGL contexts"* and maps
      go blank. Always dispose:
      
      ```js
      // React: useEffect cleanup. Vue: onBeforeUnmount. Plain: before removing the container.
      map.remove();   // frees the GL context, sources, workers, and listeners
      ```
      
      `map.remove()` also drops all event handlers, so you don't need to unbind them first; but
      do clear external observers (the `ResizeObserver` above) and any `setInterval`/RAF loops.
      
      ## Tokens — web security
      
      - Web pages use a **public token** (`pk.…`). **Restrict it by URL** in your Mapbox
        account so a copied token can't be used off your domains.
      - **Never ship a secret token** (`sk.…`) in client code — it can create/delete tokens and
        styles. Secret tokens are for server/build/upload only.
      - Rotating a leaked `pk.` token is a dashboard click; a leaked `sk.` is an incident.
      
      ## Map readiness signals
      
      - `'load'` — style + first viewport loaded (add sources/layers after this). May be
        missed in a background tab → also bind `'idle'` once (see
        [verification.md](verification.md)).
      - `'idle'` — no more loading/rendering pending (good "everything settled" signal).
      - `'style.load'` — fires after each `setStyle`.
      - `map.loaded()` / `map.isStyleLoaded()` — synchronous checks for headless waits.
      
    • lines-and-trails.md 3.6 KB
      # Lines & trails
      
      ## `line-dasharray` units are line-widths
      
      The two numbers in `line-dasharray: [dash, gap]` are measured in **line-widths**, not
      pixels — so the pattern stays proportional as `line-width` changes. **Solid line =
      `[1, 0]`.**
      
      Gotcha: with `line-cap: "round"` each dash is extended by ~½ a line-width on **each
      end**, so neighbouring dashes **merge** and the gaps disappear. Fixes:
      
      - Widen the gap (e.g. `[1.1, 1.8]` instead of `[1.1, 0.6]`), or
      - Use `line-cap: "butt"` (square ends, no extension).
      
      ```js
      map.setPaintProperty("trail-line", "line-dasharray", isDashed ? [1.1, 1.8] : [1, 0]);
      ```
      
      ## Casing + line split
      
      Draw two stacked line layers from the same source: a wider **casing** underneath and
      the coloured **line** on top. Use zoom-interpolated widths so the trail reads at every
      scale:
      
      ```js
      const trailWidth  = s => ["interpolate",["linear"],["zoom"], 11,3*s, 14,4.5*s, 17,6*s];
      const casingWidth = s => ["interpolate",["linear"],["zoom"], 11,5*s, 14,7.5*s, 17,10*s];
      map.addLayer({ id:"trail-casing", type:"line", source:"trail",
        layout:{"line-join":"round","line-cap":"round"},
        paint:{"line-color":"#fff","line-opacity":0,"line-width":casingWidth(1)} });
      map.addLayer({ id:"trail-line", type:"line", source:"trail",
        layout:{"line-join":"round","line-cap":"round"},
        paint:{"line-color":ACCENT,"line-opacity":1,"line-width":trailWidth(1),
               "line-dasharray":[1.1,1.8]} });
      ```
      
      ## Translucency over hillshade = lies-on-the-terrain look
      
      A **translucent** trail line over a hillshade layer lets the terrain shading **bleed
      through**, so the line reads as lying *on* the 3D relief rather than floating above
      it. Cheap and convincing — drop `line-opacity` toward ~0.7:
      
      ```js
      map.setPaintProperty("trail-line", "line-opacity", 0.7);   // hillshade shades the line
      ```
      
      (For the hillshade itself see [terrain.md](terrain.md).)
      
      ## Colour by attribute (difficulty grading)
      
      Data-driven colour via a `match` expression. Coerce the field to string first — the
      same dataset often stores the grade as a number in one layer and a string in another:
      
      ```js
      const DIFF_COLOR = ["match",["to-string",["coalesce",["get","Difficulty"],""]],
        "1","#2e7d32", "2","#7cb342", "3","#f9a825", "4","#ef6c00", "5","#c62828",
        /* fallback */ "#cc7d45"];
      map.setPaintProperty("trail-line", "line-color", DIFF_COLOR);
      ```
      
      ## `line-gradient` (needs `lineMetrics`)
      
      To colour a line *along its length* (elevation, speed, progress), use `line-gradient`
      over `["line-progress"]` (0→1). Two hard requirements or it **silently renders nothing**:
      
      - the **source** must set `lineMetrics: true` (not the layer — the source);
      - the layer must **not** also use `line-dasharray` — they're mutually exclusive.
      
      ```js
      map.addSource("trail", { type:"geojson", data: fc, lineMetrics: true });   // REQUIRED
      map.addLayer({ id:"trail-grad", type:"line", source:"trail",
        layout:{"line-cap":"round"},
        paint:{ "line-width":5, "line-gradient":["interpolate",["linear"],["line-progress"],
          0,"#2e7d32", 0.5,"#f9a825", 1,"#c62828"] }});
      ```
      
      ## Direction arrows along the line
      
      A repeating chevron icon placed along the line, auto-rotated to the line direction:
      
      ```js
      map.addLayer({ id:"trail-arrows", type:"symbol", source:"trail",
        layout:{ "symbol-placement":"line", "symbol-spacing":68, "icon-image":"rc-arrow",
          "icon-size":["interpolate",["linear"],["zoom"], 12,0.32, 16,0.55],
          "icon-rotation-alignment":"map", "icon-allow-overlap":true,
          "icon-ignore-placement":true }});
      ```
      
      Draw the chevron pointing **east** (0°); Mapbox rotates it to the segment bearing. Give
      it a dark halo stroke under a white stroke so it reads on any basemap.
      
    • markers.md 7.2 KB
      # Custom markers (SVG / canvas → `addImage`)
      
      Two ways to put a custom marker on the map:
      
      - **`mapboxgl.Marker`** — a DOM element pinned to a lng/lat. Fine for a handful of
        markers; doesn't cluster, doesn't batch, repaints in the DOM. Avoid for many points.
      - **Symbol layer + `map.addImage`** (this file) — register an image once, reference
        it by name from a `symbol` layer's `icon-image`. Batches, clusters, GPU-drawn,
        data-driven. Use this for anything beyond a few markers.
      
      ## Table of contents
      - [Namespace every image name](#namespace-every-image-name) — silent-drop gotcha
      - [Register as premultiplied ImageBitmap](#register-as-premultiplied-imagebitmap) — white-fringe gotcha
      - [Recolour in place with updateImage](#recolour-in-place-with-updateimage)
      - [Circular image markers](#circular-image-markers) — mask correctly
      - [Atlas aliasing at low zoom](#atlas-aliasing-at-low-zoom) — keep edges chunky
      - [High pixelRatio, never upscale](#high-pixelratio-never-upscale)
      - [Anchoring: tip / centre / offset](#anchoring-tip--centre--offset) — data-driven offset is ignored
      
      ## Namespace every image name
      
      Mapbox base styles ship sprite icons literally named `parking`, `toilet`, `circle`,
      etc. `map.hasImage("parking")` returns **`true`** for the style's sprite even though
      *you* never added it. If you guard `addImage` with that check, your icon is never
      registered and the symbol layer **silently falls back to the style's plain glyph**.
      
      Always prefix your names so they can't collide:
      
      ```js
      const PIN = g => "rcpin-" + g;          // "rcpin-camera", "rcpin-parking", …
      if (!map.hasImage(PIN(glyph))) map.addImage(PIN(glyph), bmp, {pixelRatio: 4});
      ```
      
      ## Register as premultiplied ImageBitmap
      
      A marker drawn from an `<svg>`/`<canvas>` has **straight (non-premultiplied) alpha**
      at its anti-aliased edge. Feeding Mapbox a raw `HTMLImageElement` or `ImageData`
      makes it bleed a **white halo / fringe** around every curved edge.
      
      `createImageBitmap()` produces a **premultiplied-alpha** bitmap → clean edges. Fall
      back to the raw element only if `createImageBitmap` throws:
      
      ```js
      const img = new Image(W, H);
      img.onload = async () => {
        try {
          const bmp = await createImageBitmap(img);   // premultiplied alpha → no fringe
          if (map.hasImage(name)) map.updateImage(name, bmp);
          else map.addImage(name, bmp, {pixelRatio});
        } catch (e) {
          if (!map.hasImage(name)) map.addImage(name, img, {pixelRatio});  // fallback
        }
      };
      img.src = "data:image/svg+xml;base64," + btoa(unescape(encodeURIComponent(svg)));
      ```
      
      For a `<canvas>` source, `createImageBitmap(canvas)` works the same way.
      
      ## Recolour in place with updateImage
      
      To recolour a marker (e.g. a colour-picker in a dev panel), re-render the SVG/canvas
      with the new colour and call **`map.updateImage(name, bmp)`** — same name, no layer
      churn, the symbol layer repaints automatically. Rebuild *all* affected names:
      
      ```js
      function setPoiColor(c) { poiColor = c; buildAllPins(map, /*force*/ true); }
      // addPin(): if hasImage → updateImage(name, bmp) else addImage(name, bmp, …)
      ```
      
      ## Circular image markers
      
      To crop a photo into a disc, **never use `ctx.clip()`** — clip is a 1-bit hard mask
      and leaves a **jagged** circle edge. Mask with an **anti-aliased arc fill** via
      `globalCompositeOperation = "destination-in"` on a scratch canvas:
      
      ```js
      // scratch canvas sized to the photo disc
      const sc = document.createElement("canvas"); sc.width = sc.height = d;
      const sx = sc.getContext("2d");
      sx.imageSmoothingEnabled = true; sx.imageSmoothingQuality = "high";
      // cover-fit the photo
      const scale = Math.max(d/iw, d/ih);
      sx.drawImage(img, (d-iw*scale)/2, (d-ih*scale)/2, iw*scale, ih*scale);
      // AA circular mask: keep only pixels inside the arc
      sx.globalCompositeOperation = "destination-in";
      sx.beginPath(); sx.arc(d/2, d/2, d/2, 0, 2*Math.PI); sx.fill();
      // draw the masked disc into the marker canvas
      ctx.drawImage(sc, cx - r, cy - r, 2*r, 2*r);
      ```
      
      Full marker (frame + contact shadow + drop shadow + sheen + masked photo) in
      [../assets/circular_image_marker.js](../assets/circular_image_marker.js) — copy-paste
      starter code, adapt the colour/box constants.
      
      Same-origin caveat: if the photo is fetched cross-origin the canvas is **tainted**
      and `createImageBitmap`/`toDataURL` throw. Serve the page and images from the same
      origin, or set `img.crossOrigin = "anonymous"` and serve images with CORS headers.
      
      ## Atlas aliasing at low zoom
      
      Mapbox does **not mipmap** the icon atlas. A raster icon minified at low zoom
      **aliases/shimmers** — and thin, high-contrast features (a 1–2px white outline or
      photo rim) shimmer worst. Mitigations:
      
      - Keep small-marker edges **chunky**; drop thin white rings on small badges (we
        removed a white pin-outline and a white photo-rim purely to stop the shimmer).
      - Fade markers in over zoom so the worst-aliased small sizes barely show:
        `"icon-opacity": ["interpolate",["linear"],["zoom"], 11,0, 12,1]`.
      
      ## High pixelRatio, never upscale
      
      Render the SVG/canvas at a **high DPI** (4×–6×) and register with the matching
      `{pixelRatio}` so the icon is crisp on retina. But **don't let `icon-size` scale the
      icon past its native pixel size** — upscaling a raster icon blurs it. Size the source
      big enough that your max `icon-size` still maps ≤ 1.0 of native.
      
      ```js
      const F = 6, W = 44*F, H = 52*F;          // draw at 6×
      map.addImage(name, bmp, {pixelRatio: F}); // declare the 6×
      // icon-size ramp stays ≤ ~1.5 of the *logical* box, well within native pixels
      ```
      
      ## Anchoring: tip / centre / offset
      
      **Data-driven `icon-offset` is silently ignored in GL JS v3.** A `["get", …]` or
      `["case", …]` expression for `icon-offset` does nothing — no error, no offset. Two
      working approaches:
      
      1. **Constant `icon-offset`** — it's a layout constant that **scales with
         `icon-size`**, so one value works across zooms. Pair it with matched padding baked
         into every icon of that layer.
      2. **Split into separate symbol layers**, each with its own *constant* anchor/offset.
         This is what to do when marker families need different anchors. Example: glyph
         badges centred on the point vs photo bubbles whose tip sits on the point —
      
      ```js
      // glyph badges: disc centred on the point
      map.addLayer({ id:"poi-glyph", type:"symbol", source:"pois",
        filter:["all",["!",["has","point_count"]],["!=",["get","_kind"],"photo"]],
        layout:{ "icon-image":["get","_iconimg"], "icon-anchor":"center",
                 "icon-size":[/* zoom ramp */], "icon-allow-overlap":true }});
      
      // photo bubbles: padded tip lands on the point.
      // icon drawn in a 44×52 box with the tip at y=44 → 8 units of shadow padding below.
      // icon-anchor:"bottom" + constant icon-offset:[0,8] (× icon-size) cancels that pad.
      map.addLayer({ id:"poi-photo", type:"symbol", source:"pois",
        filter:["all",["!",["has","point_count"]],["==",["get","_kind"],"photo"]],
        layout:{ "icon-image":["get","_iconimg"], "icon-anchor":"bottom",
                 "icon-offset":[0,8], "icon-size":[/* zoom ramp */],
                 "icon-allow-overlap":true }});
      ```
      
      Rule of thumb for landing a padded marker's tip on the point: `icon-anchor:"bottom"`
      + `icon-offset:[0, padUnits]`, where `padUnits` is the shadow padding drawn below the
      tip (the offset is multiplied by `icon-size`, so it tracks zoom automatically).
      
    • palette.md 2.9 KB
      # Recolouring a base style's palette
      
      Shift an off-the-shelf base style toward a custom palette at runtime with
      `setPaintProperty` on its land/vegetation fill layers — no Studio edit, no custom
      style upload. Touch only land & vegetation; leave water/roads/labels alone.
      
      > **Classic styles only.** This relies on named, enumerable layers (`land`, `landuse`,
      > …) which exist in Streets/Outdoors/Light/Dark but **not** in the v3 Standard style.
      > For Standard, use `setConfigProperty` — see [v3-standard-style.md](v3-standard-style.md).
      
      ## The layer-type gotcha
      
      The colour property name depends on the layer **type**: a `background` layer uses
      `background-color`, a `fill` layer uses `fill-color`. Detect the type per layer:
      
      ```js
      function setFill(id, val) {
        const ly = map.getLayer(id);
        if (!ly) return;                                   // layer name varies by style — guard
        const prop = (ly.type === "background" ? "background" : "fill") + "-color";
        try { map.setPaintProperty(id, prop, val); } catch (e) {}
      }
      ```
      
      ## Green-shift example (Mapbox Outdoors / Streets layer names)
      
      ```js
      function setupPalette() {
        setFill("land", "#e8ebe0");                                            // base land (background)
        setFill("landcover-outdoors",                                          // vegetation
          ["match",["get","class"], "snow","#ffffff", /* default */ "#c6d4ac"]);
        setFill("national-park", "#aecb8e");
        setFill("landuse", ["match",["get","class"],                          // per-class choropleth
          "park","#bad49b", "pitch","#b2cd8c", "cemetery","#c4d8aa",
          ["wood","grass","scrub"],"#bcd29c", "residential","#e9ece1",
          /* default */ "#dde4d0"]);
      }
      ```
      
      The `["match",["get","class"], …]` form is the general **choropleth** pattern: colour
      each feature by a categorical property, with a trailing default. The exact layer ids
      (`land`, `landuse`, `landcover-outdoors`, `national-park`) are Mapbox-style-specific —
      inspect `map.getStyle().layers` for the style in use and guard every `getLayer`.
      
      ## Hiding the basemap's own POI labels
      
      A custom POI layer competes with the style's generic POI labels (its own car-park
      "P"s, etc.). Hide the base layer so only your markers show:
      
      ```js
      if (map.getLayer("poi-label")) map.setLayoutProperty("poi-label", "visibility", "none");
      ```
      
      ## Resolving style + token from `.env` (order matters)
      
      When a token comes from a credential store / `.env`, **resolve the token first** —
      that read is what triggers the `.env` load — and only **then** read `MAPBOX_STYLE`. If
      you read the style before the token load runs, a style set only in `.env` is invisible
      and you silently fall back to the default style. (Python example, but the ordering rule
      is general.)
      
      ```python
      token = resolve_token(cli_token)        # triggers .env load as a side effect
      style = cli_style or os.environ.get("MAPBOX_STYLE") or DEFAULT_STYLE   # now .env is loaded
      ```
      
    • popups.md 2.5 KB
      # Popups
      
      ## Circular photo card needs `overflow: visible`
      
      A circular photo popup (a disc with its own ring + drop-shadow) gets **square-clipped**
      by the base `.mapboxgl-popup-content` rule, which sets `overflow: hidden`. That clips
      the circle's drop-shadow into hard square corners. Override it on a custom class, make
      the popup chrome transparent (the disc carries its own ring/shadow), and hide the tip:
      
      ```css
      .pop-photo .mapboxgl-popup-content {
        background: transparent; box-shadow: none; padding: 0; border-radius: 0;
        overflow: visible;            /* essential — base rule clips the round shadow square */
      }
      .pop-photo .mapboxgl-popup-tip { display: none; }
      .pop-circle {
        width: 228px; height: 228px; border-radius: 50%; overflow: hidden;
        border: 6px solid #355e3b; box-shadow: 0 6px 22px rgba(0,0,0,.30);
      }
      .pop-circle img { width: 100%; height: 100%; object-fit: cover; display: block; }
      ```
      
      ```js
      new mapboxgl.Popup({ offset: photoPopupOffset(), closeButton:false,
                           closeOnClick:true, className:"pop-photo", maxWidth:"none" })
        .setLngLat(e.lngLat).setHTML(`<div class="pop-circle"><img src="${url}"></div>`).addTo(map);
      ```
      
      ## Scale the popup offset by icon-size
      
      If the marker scales with zoom, a fixed popup `offset` leaves a gap that's wrong at
      most zooms. Compute the offset from the current icon-size so the card **hugs** its
      marker at every zoom. `Popup` offset accepts a per-anchor object so the gap is right
      whether the card lands above, below, or beside the marker:
      
      ```js
      function photoPopupOffset() {
        const clear = Math.round(40 * photoIconSize(map.getZoom()) + 6);  // tip→top + small gap
        return { bottom:[0,-clear], top:[0,14], left:[14,0], right:[-14,0] };
      }
      ```
      
      Where `photoIconSize(z)` mirrors the symbol layer's `icon-size` zoom ramp (interpolate
      the same stops in JS), so the popup tracks the rendered marker exactly.
      
      ## Dismissal & cursor niceties
      
      - `closeButton:false` + `closeOnClick:true` → no X button; click anywhere off the
        popup to dismiss (hide the default close button via
        `.mapboxgl-popup-close-button{display:none}` if a stray one appears).
      - Pointer cursor on interactive layers:
        ```js
        for (const lyr of ["clusters","poi-glyph","poi-photo"]) {
          map.on("mouseenter", lyr, () => map.getCanvas().style.cursor = "pointer");
          map.on("mouseleave", lyr, () => map.getCanvas().style.cursor = "");
        }
        ```
      - Animate only opacity (`@keyframes` on `.mapboxgl-popup`) — Mapbox owns the
        positioning `transform`, so don't animate transform or the popup jumps.
      
    • styles.md 9 KB
      # Style library & composition
      
      Compose maps in a variety of looks: pick a base style by use case, switch styles at
      runtime, mix in third-party tiles, or hand-roll a style. The machine-readable catalog is
      [../assets/style-catalog.json](../assets/style-catalog.json) — load it to build a style
      switcher or to look up a `url` by `use` tag.
      
      ## Table of contents
      - [First-party style catalog](#first-party-style-catalog)
      - [Choose a base by use case](#choose-a-base-by-use-case)
      - [Setting & switching styles at runtime](#setting--switching-styles-at-runtime)
      - [Light/dark by system preference](#lightdark-by-system-preference)
      - [Custom & third-party styles](#custom--third-party-styles)
      - [Hand-rolled style JSON](#hand-rolled-style-json)
      - [Gotchas](#gotchas)
      
      ## First-party style catalog
      
      | Style | URL | Family | Best for |
      |---|---|---|---|
      | Standard | `mapbox://styles/mapbox/standard` | standard (3D) | v3 default; 3D city, modern, configurable light |
      | Standard Satellite | `mapbox://styles/mapbox/standard-satellite` | standard (3D) | 3D + aerial |
      | Streets | `mapbox://styles/mapbox/streets-v12` | classic | wayfinding, general reference |
      | Outdoors | `mapbox://styles/mapbox/outdoors-v12` | classic | trail/terrain/hillshade |
      | Light | `mapbox://styles/mapbox/light-v11` | classic | **dataviz** base (muted) |
      | Dark | `mapbox://styles/mapbox/dark-v11` | classic | **dataviz** base (dark) |
      | Satellite | `mapbox://styles/mapbox/satellite-v9` | classic | pure imagery (no labels) |
      | Satellite Streets | `mapbox://styles/mapbox/satellite-streets-v12` | classic | aerial + labels |
      | Navigation Day/Night | `mapbox://styles/mapbox/navigation-{day,night}-v1` | classic | turn-by-turn |
      
      `family` matters: **classic** styles have named, enumerable layers (palette recolour +
      the terrain layer-walk work); **standard** is the v3 composited 3D basemap (use slots +
      `setConfigProperty`). See [v3-standard-style.md](v3-standard-style.md). Version suffixes
      (`-v11`/`-v12`) move — confirm against the Mapbox Styles API before pinning.
      
      ## Choose a base by use case
      
      - **Choropleth / heatmap / any thematic data** → `light-v11` or `dark-v11`. A muted base
        lets the data carry the colour; `streets` competes with it. (Or Standard with a
        monochrome `lightPreset`.)
      - **Trail / outdoor / elevation** → `outdoors-v12` (ships contours + terrain hooks).
      - **Photoreal / inspection** → `satellite-streets-v12` (plain `satellite-v9` has no labels
        or roads — context-free imagery).
      - **3D city / showcase** → `standard` with `lightPreset:"dusk"`/`"night"`, or
        `standard-satellite`.
      - **Wayfinding / routing** → `streets-v12` or `navigation-*`.
      
      ## Setting & switching styles at runtime
      
      Set at init with `style:`; switch with `setStyle()`. **Switching wipes your custom
      sources/layers/images** — re-add them on `style.load` via one idempotent installer (full
      pattern in [lifecycle.md](lifecycle.md)):
      
      ```js
      const map = new mapboxgl.Map({ container:"map", style:"mapbox://styles/mapbox/light-v11" });
      function installCustom(){ /* guarded addSource/addLayer/addImage */ }
      map.on("load", installCustom);
      map.on("style.load", installCustom);   // fires after every setStyle
      
      document.querySelector("#styleSwitcher").addEventListener("change", e =>
        map.setStyle(e.target.value));       // installCustom re-runs on style.load
      ```
      
      A switcher UI can be generated straight from the catalog asset (map each entry to an
      `<option value="<url>">`).
      
      ## Light/dark by system preference
      
      ```js
      const dark = matchMedia("(prefers-color-scheme: dark)");
      const pick = () => dark.matches ? "mapbox://styles/mapbox/dark-v11"
                                      : "mapbox://styles/mapbox/light-v11";
      map.setStyle(pick());
      dark.addEventListener("change", () => map.setStyle(pick()));
      // On the Standard style, prefer one style + setConfigProperty('basemap','lightPreset',
      // dark.matches ? 'night' : 'day') — no full reload, keeps custom layers (no re-add).
      ```
      
      The Standard `lightPreset` route is cheaper and avoids the re-add dance — switch full
      styles only when crossing the classic↔standard or imagery↔vector boundary.
      
      ## Custom & third-party styles
      
      - **Mapbox Studio**: design → publish → use `mapbox://styles/USERNAME/STYLEID`. This is
        the right home for brand palettes, custom fonts/sprites, and curated layer visibility.
      - **Third-party vector tiles** (no Mapbox basemap): pass a full **style-JSON URL** to
        `style:` — MapTiler, Stadia, OpenFreeMap, or Protomaps/PMTiles. These use their **own**
        API key, not a Mapbox token.
      - **Mixing**: Mapbox-hosted tilesets used elsewhere in this skill
        (`mapbox.mapbox-terrain-dem-v1`, `mapbox-terrain-v2`) require a **Mapbox** token even on
        a third-party base style — so a fully token-free OSM base can't pull Mapbox DEM/contours.
        Source equivalents (e.g. AWS Terrain-RGB DEM, Terrarium tiles) exist if you must stay
        off Mapbox.
      
      ## Visually dynamic & artistic styles
      
      ### Standard presets — free, it's the default style
      
      One style, many distinct looks via `setConfigProperty("basemap", …)` (no reload, custom
      layers preserved):
      
      - **`lightPreset`** — `"dawn"|"day"|"dusk"|"night"`. `night` lights building windows and
        streetlights; `dusk`/`dawn` cast long shadows. Pair with camera pitch for drama.
      - **`theme`** — `"default"|"faded"|"monochrome"`. `monochrome` is a single-hue minimalist
        base (excellent under dataviz); `faded` desaturates so overlays pop.
      - Mix `theme` × `lightPreset` for a wide range from a single style.
      
      ```js
      map.on("style.load", () => {
        map.setConfigProperty("basemap", "lightPreset", "dusk");
        map.setConfigProperty("basemap", "theme", "monochrome");
      });
      ```
      
      ### Weather & atmosphere effects (GL JS ≥ 3.7)
      
      Animated, genuinely dynamic — particles over the 3D scene:
      
      ```js
      map.setRain({ density:0.5, intensity:1.0, color:"#a8adbc", opacity:0.7 });
      map.setSnow({ density:0.85, intensity:1.0, flakeSize:0.71 });
      map.setRain(null); map.setSnow(null);     // clear
      ```
      
      Best with camera pitch + Standard `night`/`dusk`. Fog/haze (`setFog`) for globe/terrain
      depth lives in [dataviz-and-3d.md](dataviz-and-3d.md#sky--atmosphere). All three are
      runtime calls, independent of the chosen style.
      
      ### Third-party artistic basemaps
      
      Pass these as **style-JSON URLs** to `style:` / `setStyle()`. They use their **own** key +
      attribution (not a Mapbox token) and can't pull Mapbox-hosted DEM/contours
      ([see Mixing](#custom--third-party-styles)).
      
      | Source | Standout looks | Key? |
      |---|---|---|
      | **Stadia / Stamen** | **Watercolor** (hand-painted), **Toner** (high-contrast B&W), Terrain | key (free tier) |
      | **CARTO** | Positron, **Dark Matter**, Voyager — clean dataviz classics | no key (attribution) |
      | **Thunderforest** | **Spinal Map** (Tron-like), **Atlas** (vintage sepia), Pioneer, Transport | key (freemium) |
      | **Protomaps** | light/dark/white/black/grayscale/**contrast**; single-file PMTiles | no key (self-host) |
      | **OpenFreeMap** | Liberty, Bright, Positron | no key |
      | **MapTiler** | Backdrop, Dataviz, Toner, Topo, **Winter**, **Ocean**, Bright | key |
      
      Stamen **Watercolor** and Thunderforest **Atlas** are the showstoppers for a painterly /
      vintage look; CARTO **Dark Matter** + glowing data is the canonical "dashboard at night".
      Exact style URLs live in [../assets/style-catalog.json](../assets/style-catalog.json).
      
      ### Roll your own dramatic look (e.g. synthwave / neon)
      
      For a bespoke neon/synthwave style (à la a night flight-sim), start from a near-black
      base and lean on glow: `line-color` in magenta/cyan with a wide, low-opacity `line-blur`
      casing under a bright thin line; `fill-extrusion` buildings in a dark hue with a neon
      edge; `setFog` with a saturated `high-color`. Build it in Studio (publish → `mapbox://`)
      or hand-roll the style JSON (next section). This composes with camera pitch + a `night`
      `lightPreset` for the full effect.
      
      ## Hand-rolled style JSON
      
      A style is just JSON (spec **version 8**): `sources`, `layers`, plus `sprite`, `glyphs`,
      and optional `light`/`fog`/`terrain`/`projection`. Build a bespoke base by passing an
      object instead of a URL:
      
      ```js
      const map = new mapboxgl.Map({ container:"map", style:{
        version: 8,
        glyphs: "mapbox://fonts/mapbox/{fontstack}/{range}.pbf",   // REQUIRED for any text label
        sources: { osm: { type:"raster", tiles:["https://tile.openstreetmap.org/{z}/{x}/{y}.png"], tileSize:256 } },
        layers: [
          { id:"bg", type:"background", paint:{ "background-color":"#e8ebe0" } },
          { id:"osm", type:"raster", source:"osm" }
        ]
      }});
      ```
      
      To tweak an existing base instead of starting blank: fetch its style JSON
      (`https://api.mapbox.com/styles/v1/mapbox/light-v11?access_token=…`), edit, and pass the
      object.
      
      ## Gotchas
      
      - **`version: 8`** is mandatory in a hand-rolled style; anything else fails to load.
      - **No `glyphs` URL → no text labels** render (silent). **No `sprite` → no icon-image**
        sprite icons.
      - `satellite-v9` is imagery only — no labels/roads; use `satellite-streets` for context.
      - Switching styles loses custom layers — always re-add on `style.load`
        ([lifecycle.md](lifecycle.md)).
      - classic vs standard changes which techniques apply ([v3-standard-style.md](v3-standard-style.md)).
      
    • terrain.md 4.2 KB
      # Terrain: hillshade, contours, 3D
      
      All three use Mapbox-hosted tilesets available to **any** access token — no special
      entitlement needed.
      
      ## Hillshade (shaded relief)
      
      A `raster-dem` source + a `hillshade` layer. **`hillshade-exaggeration` max is 1.0.**
      
      ```js
      map.addSource("rc-dem", { type:"raster-dem",
        url:"mapbox://mapbox.mapbox-terrain-dem-v1", tileSize:512, maxzoom:14 });
      map.addLayer({ id:"rc-hillshade", type:"hillshade", source:"rc-dem",
        paint:{ "hillshade-exaggeration":1.0,            // 1.0 = max
                "hillshade-shadow-color":"#4a3f30",
                "hillshade-highlight-color":"#faf6ec",
                "hillshade-accent-color":"#6e5b42" }}, firstSymbolLayerId);
      ```
      
      ## Contours (altitude lines)
      
      A **vector** source `mapbox://mapbox.mapbox-terrain-v2`, source-layer **`"contour"`** —
      far denser than the sparse contours most base styles ship (down to ~10 m where
      available). The `index` field flags the **index lines** (every 5th/10th) for heavier
      styling. Draw a fine base set + a bolder filtered index set:
      
      ```js
      map.addSource("rc-terrain", { type:"vector", url:"mapbox://mapbox.mapbox-terrain-v2" });
      map.addLayer({ id:"rc-contour", type:"line", source:"rc-terrain", "source-layer":"contour",
        layout:{"line-join":"round"},
        paint:{ "line-color":"#7a5733",
          "line-width":["interpolate",["linear"],["zoom"], 12,0.4, 16,0.85],
          "line-opacity":["interpolate",["linear"],["zoom"], 11,0.20, 14,0.34, 17,0.44] }},
        firstSymbolLayerId);
      map.addLayer({ id:"rc-contour-index", type:"line", source:"rc-terrain", "source-layer":"contour",
        filter:[">=",["coalesce",["get","index"],0],5],   // index (every 5th/10th) lines, bolder
        layout:{"line-join":"round"},
        paint:{ "line-color":"#5e3f1d",
          "line-width":["interpolate",["linear"],["zoom"], 12,0.9, 16,1.7],
          "line-opacity":["interpolate",["linear"],["zoom"], 11,0.30, 14,0.48, 17,0.58] }},
        firstSymbolLayerId);
      ```
      
      ## 3D terrain (extruded DEM + tilted camera)
      
      `map.setTerrain` extrudes the DEM; tilt the camera and let the user rotate it:
      
      ```js
      map.addControl(new mapboxgl.NavigationControl({ visualizePitch:true }), "bottom-right");
      // enable
      map.setTerrain({ source:"rc-dem", exaggeration:1.4 });
      map.easeTo({ pitch:64, duration:900 });
      // disable
      map.setTerrain(null);
      map.easeTo({ pitch:0, bearing:0, duration:700 });
      ```
      
      `visualizePitch:true` shows the pitch on the compass. Right-drag / ctrl-drag rotates &
      pitches the camera. When re-fitting bounds after enabling 3D, preserve the camera:
      `map.fitBounds(b, { pitch: map.getPitch(), bearing: map.getBearing() })`.
      
      ## Boost-or-add pattern (respect the base style)
      
      > **Classic styles only.** The `getStyle().layers` walk below finds named layers that
      > exist in classic styles but **not** in the v3 Standard style (which exposes no
      > enumerable basemap layers). On Standard, add your DEM/contour layers via a `slot` and
      > skip the boost step — see [v3-standard-style.md](v3-standard-style.md).
      
      Don't blindly add tilesets — the chosen style may already ship hillshade/contours.
      Walk `map.getStyle().layers`: **boost** an existing hillshade (match by `type`) or
      contour (match by id regex); **add** the Mapbox tilesets only if absent. Insert added
      layers **beneath the first `symbol` layer** so labels stay on top.
      
      ```js
      function setupTerrain() {
        const layers = map.getStyle().layers || [];
        const firstSymbol = (layers.find(l => l.type === "symbol") || {}).id;  // sit beneath labels
        let hasHill = false;
        for (const ly of layers) {
          if (ly.type === "hillshade") {                 // boost the style's own hillshade
            hasHill = true;
            map.setPaintProperty(ly.id, "hillshade-exaggeration", 1.0);
            map.setPaintProperty(ly.id, "hillshade-shadow-color", "#4a3f30");
          }
          if (/contour/i.test(ly.id) && ly.type === "line") {   // hide its sparse contour lines
            map.setLayoutProperty(ly.id, "visibility", "none"); // (keep contour LABELS — the numbers)
          }
        }
        if (!hasHill) { /* addSource rc-dem + addLayer rc-hillshade above, before firstSymbol */ }
        /* always add the dense Terrain-v2 contours above, before firstSymbol */
      }
      ```
      
      Track every layer id you boosted or added in one array so a single "Terrain" toggle
      can flip them all with `setLayoutProperty(id, "visibility", …)`.
      
    • three-custom-layer.md 7.4 KB
      # three.js inside Mapbox GL — custom layer integration
      
      Animated 3D objects (vehicles, wildlife, ambient life) living *in* the map: a
      `CustomLayerInterface` layer hosting a three.js scene that shares Mapbox's WebGL
      context. Distilled from a production ambient-vessels layer and from reviewing
      [Threebox](https://github.com/jscastro76/threebox) v2.2.7 internals (`CameraSync.js`,
      `Threebox.js`) — the de-facto mapbox↔three bridge library.
      
      ## Decide the architecture FIRST — two incompatible approaches
      
      | | A. Baked matrix (Mapbox docs pattern) | B. Reconstructed camera (Threebox `CameraSync`) |
      |---|---|---|
      | How | Bake everything into `camera.projectionMatrix` from the `render(gl, matrix)` arg | Rebuild a *real* camera each `move`: true `projectionMatrix` + true `matrixWorld`; a `world` group carries zoom/pan |
      | Code | ~30 lines, no deps | ~300 lines, or adopt Threebox |
      | Rendering | ✅ correct (terrain occlusion, fog) | ✅ correct |
      | Raycast picking / drag / hover | ❌ **impossible** — camera pose is fake | ✅ `raycaster.setFromCamera` works |
      | CSS2D labels / tooltips synced to 3D | ❌ | ✅ |
      | API surface used | public only | **private** `map.transform` internals (`_fov`, `_pitch`, `angle`, `_camera.position`, `elevation`, `_horizonShift`) — version-gated, breaks across GL JS majors |
      
      **Decision rule:** ambient/display-only objects → A. Users must click/drag/hover the
      3D objects → B (and seriously consider just using Threebox rather than hand-rolling —
      but pin its version; it vendors its own patched legacy three.js build, so check
      compatibility before mixing with a modern three from CDN).
      
      Threebox's own comment (CameraSync.js): applying the transform directly to the
      projection matrix "will work OK but break raycasting" — that's the whole fork.
      
      ## Recipe A — minimal baked-matrix layer (display-only actors)
      
      Scene space = **local ENU metres** around an origin anchor (avoids float32 precision
      loss at global mercator scale). After the matrix chain below: x=east, y=up, z=south.
      
      ```js
      const ORIGIN = [144.85, -38.15];                       // anchor near your actors
      function ll2m(p){ const d = Math.PI/180, R = 6378137;  // [lng,lat] → ENU metres
        return [ (p[0]-ORIGIN[0])*d*R*Math.cos(ORIGIN[1]*d), (p[1]-ORIGIN[1])*d*R ]; }
      
      const layer = {
        id: "actors", type: "custom", renderingMode: "3d",   // "3d" → shares depth buffer
        onAdd(map, gl){
          this.renderer = new THREE.WebGLRenderer({ canvas: map.getCanvas(), context: gl, antialias: true });
          this.renderer.autoClear = false;                   // never clear Mapbox's frame
        },
        render(gl, matrix){
          stepActors();                                      // your animation tick
          const mc = mapboxgl.MercatorCoordinate.fromLngLat({lng: ORIGIN[0], lat: ORIGIN[1]}, 0);
          const s  = mc.meterInMercatorCoordinateUnits();
          camera.projectionMatrix = new THREE.Matrix4().fromArray(matrix)
            .multiply(new THREE.Matrix4().makeTranslation(mc.x, mc.y, mc.z))
            .multiply(new THREE.Matrix4().makeScale(s, -s, s))          // mercator y grows south
            .multiply(new THREE.Matrix4().makeRotationX(Math.PI/2));    // z-up → three's y-up
          this.renderer.resetState();                        // Mapbox left GL state dirty
          this.renderer.render(scene, camera);
          map.triggerRepaint();                              // ONLY while animating
        }
      };
      ```
      
      - Place an actor at ENU `(e, n)`: `obj.position.set(e, alt, -n)` (note the `-n`).
      - Heading: model your object facing −z (north); then `rotation.y = -bearingRad`.
      - `camera = new THREE.Camera()` — a bare camera; the matrix chain is the whole pose.
      - Roll/heel about the forward axis: nest an inner group (`inner.rotation.z`) inside
        the yawed outer group — don't fight Euler order on one object.
      
      ### Constant screen-size actors (the "game token" pattern)
      
      Real-scale objects vanish at low zoom. Scale per frame so actors read like symbols
      (Threebox ships this as `fixedZoom`/`setObjectScale`, recomputed on `zoom`):
      
      ```js
      const mPerPx = 40075016.686 * Math.cos(map.getCenter().lat * Math.PI/180)
                   / (512 * 2 ** map.getZoom());
      obj.scale.setScalar(Math.max(TARGET_PX * mPerPx, MIN_METERS) / MODEL_LEN_METERS);
      ```
      
      ### Terrain
      
      - Water/sea-level actors: altitude 0 is correct even with terrain + exaggeration.
      - Land actors: sample `map.queryTerrainElevation(lngLat, {exaggerated: true})` and
        feed it into `position.y` — the custom layer matrix does NOT lift objects onto
        terrain for you.
      
      ## Recipe B — the CameraSync math worth stealing (when you need picking)
      
      From Threebox `CameraSync.js`; rebuild on every map `move` + `resize`:
      
      - `cameraToCenterDistance = 0.5 / tan(fov/2) * transform.height`
      - **Camera world matrix** (kept separate from projection — merging them is what
        breaks raycasting): `rotZ(t.angle) · rotX(t._pitch) · translateZ(cameraToCenterDistance)`
      - **World group matrix** (zoom/pan live here, not on the camera):
        `translate(-t.point.x, t.point.y, 0) · scale(t.scale·TILE/WORLD) · translateCenter(WORLD/2, -WORLD/2) · rotZ(π)`
      - **Far plane must be horizon-aware** or content clips at high pitch (GL JS ≥ 2):
        `fovAboveCenter = fov·(0.5 + centerOffset.y/height)`;
        `camToSea = (t._camera.position[2]·worldSize − minElevBelowMSL·pxPerM) / cos(pitch)`;
        `farZ = min(furthest·1.01, camToSea / t._horizonShift)`.
        Near plane: `nearZ = max((height/50)·cos(π/2 − pitch), height/50)`.
      - **Terrain**: when `t.elevation` exists, override camera height:
        `cameraWorldMatrix.elements[14] = t._camera.position[2] * worldSize`.
      - **Reset `camera.aspect` on map resize or raycasting silently breaks** (their own
        bug-fix comment — easy to miss because rendering still looks fine).
      - Picking = normalize mouse to NDC, `raycaster.setFromCamera(ndc, camera)`,
        `intersectObjects(world.children, true)`.
      
      ## Lifecycle & hygiene (either recipe)
      
      - **`setStyle` wipes custom layers** (see [lifecycle.md](lifecycle.md)): re-add via one
        idempotent installer called on `load` AND `style.load`. Keep the *scene* as
        module state so actors survive the swap; only the layer registration and the
        renderer (rebuilt in `onAdd`) are per-style. Threebox goes further: it wraps
        `setStyle` to dispose and rebuild its whole world.
      - **One layer, one scene, many actors.** N custom layers = N render passes + N
        renderer state resets. Threebox's `multiLayer` option exists precisely to route
        everything through a single driver layer that calls one `update()`.
      - **Renderer parity**: `setPixelRatio(devicePixelRatio)` and sRGB output
        (`renderer.outputColorSpace = THREE.SRGBColorSpace`, older three:
        `outputEncoding = sRGBEncoding`) — otherwise your objects look softer/washed-out
        next to Mapbox's own rendering.
      - `triggerRepaint()` only while something animates; clamp `dt` (`min(0.1, …)`) so a
        backgrounded tab doesn't teleport actors on resume.
      - Respect `prefers-reduced-motion` for ambient animation (default it off).
      - Teardown: dispose geometries/materials/renderer — the page WebGL-context cap
        (~16) is shared with the map itself.
      
      ## Extras Threebox ships that pair well
      
      - **Real sun lighting**: bundled suncalc → sun azimuth/altitude from date + lngLat
        drives a `DirectionalLight` (+ shadow camera). Pairs with map `lightPreset`/fog
        time-of-day so 3D objects and basemap agree on lighting.
      - **`BuildingShadows`** — a shader patch over `fill-extrusion` so Mapbox buildings
        receive shadows from three.js objects. Niche, but nothing else does it.
      
    • v3-standard-style.md 3.1 KB
      # v3 Standard style — slots, config, and why layer-walking breaks
      
      GL JS v3 introduced the **Standard** style (`mapbox://styles/mapbox/standard`) — a 3D,
      composited basemap that is now the default in Mapbox Studio. It behaves **differently
      from the classic styles** (Streets/Outdoors/Light/Dark, `…-v11`/`-v12`), and several
      techniques in this skill assume classic styles. Read this before applying them to
      Standard.
      
      ## Classic vs Standard — which am I on?
      
      | | Classic (`outdoors-v12`, `streets-v12`, …) | Standard (`standard`, `standard-satellite`) |
      |---|---|---|
      | Layers | Hundreds of **named, enumerable** layers (`land`, `landuse`, `contour`, `poi-label`) | A **single composited basemap** — `getStyle().layers` shows only a few opaque entries |
      | Insert position | `addLayer(layer, beforeId)` | `addLayer({…, slot})` — **`"bottom"` / `"middle"` / `"top"`** |
      | Recolour basemap | `setPaintProperty` on named fill layers | **`setConfigProperty("basemap", …)`** — not arbitrary recolour |
      | Light / time of day | n/a | `setConfigProperty("basemap","lightPreset","dawn\|day\|dusk\|night")` |
      | 3D buildings/terrain | add yourself | built in |
      
      ## What this changes in this skill
      
      - **[palette.md](palette.md)** (recolouring `land`/`landuse`/`landcover-outdoors`) and
        the **boost-or-add `getStyle().layers` walk** in **[terrain.md](terrain.md)** assume
        **classic** styles. On Standard those layer ids don't exist, so the walk finds nothing
        and the `setPaintProperty` calls no-op. The trail-map source uses
        `outdoors-v12` (classic), so that code is correct **there** — just don't port it to a
        Standard-style page unchanged.
      - For a green/verdant look on **Standard**, you can't recolour vegetation per-class; pick
        a `lightPreset`, or use a classic style / a custom Studio style instead.
      
      ## Inserting your layers with `slot`
      
      On Standard, place custom layers in a slot so the basemap's labels/roads stay correctly
      above/below them:
      
      ```js
      map.addLayer({ id:"trail-line", type:"line", source:"trail", slot:"middle",
        paint:{ /* … */ } });   // "bottom" under everything, "top" above roads/labels
      ```
      
      `slot` and `beforeId` are mutually exclusive. A `beforeId` referencing a classic layer id
      silently fails on Standard — use `slot`.
      
      ## Configuring the basemap
      
      ```js
      map.on("style.load", () => {
        map.setConfigProperty("basemap", "lightPreset", "dusk");
        map.setConfigProperty("basemap", "showPointOfInterestLabels", false);  // hide POI labels
        map.setConfigProperty("basemap", "show3dObjects", true);
      });
      ```
      
      (On classic styles you'd instead `setLayoutProperty("poi-label","visibility","none")` —
      see [palette.md](palette.md).) Available config keys vary by style; read them from the
      style's `schema`/imports or the Mapbox Standard docs.
      
      ## Other v3 niceties
      
      - **Localisation**: `map.setLanguage("fr")` / `map.setWorldview("CN")` (v3, all styles).
      - **RTL text**: `mapboxgl.setRTLTextPlugin(url, null, true)` once, before adding Arabic/
        Hebrew labels, or they render left-to-right and disjointed.
      - **Globe**: v3 defaults to `projection:"globe"` at low zoom; set
        `map.setProjection("mercator")` if a flat map is required (e.g. for pixel-exact
        `project()` overlays).
      
    • verification.md 2.1 KB
      # Headless verification
      
      Verify a Mapbox page renders and markers land where expected by driving headless
      Chromium with Playwright and screenshotting. Pixel-accurate alignment checks use
      `map.project(lngLat)`.
      
      ## Serve, don't `file://`
      
      A page that `fetch`es GeoJSON/photos at runtime must be **served** (same-origin, or the
      canvas taints and `createImageBitmap` throws — see [markers.md](markers.md)):
      
      ```bash
      python -m http.server 8777 --directory <site-dir>
      # open http://localhost:8777/preview/index.html
      ```
      
      ## The `'load'`-in-background-tab quirk
      
      Mapbox's `'load'` event **may not fire** in a throttled / backgrounded / headless tab —
      a known harness quirk; the map renders fine in the foreground. Guard your init so
      either event triggers it exactly once:
      
      ```js
      let _inited = false;
      function init() { if (_inited) return; _inited = true; /* setup layers, render */ }
      map.on("load", init);
      map.on("idle", init);   // fallback if the first 'load' frame was missed
      ```
      
      In Playwright, wait on a DOM/JS signal you control rather than the map's `'load'`:
      e.g. set `window.__mapReady = true` at the end of `init()` and
      `page.wait_for_function("window.__mapReady === true")`.
      
      ## Marker-alignment check via `map.project`
      
      `map.project([lng, lat])` returns the pixel coords **relative to the map canvas**. Add
      the canvas's page offset to compare against a screenshot or a DOM-space click:
      
      ```js
      const m = map.getCanvas().getBoundingClientRect();
      const p = map.project([lng, lat]);
      const pageXY = { x: m.left + p.x, y: m.top + p.y };   // where the marker's anchor lands
      ```
      
      The runnable harness — launch chromium, wait for ready, screenshot, and assert a
      known lng/lat projects to the expected pixel — is in
      [../scripts/screenshot_map.py](../scripts/screenshot_map.py).
      
      ## What to assert
      
      - No console errors (`page.on("console", …)`), especially `Image "<name>" already
        exists` (double-`addImage`) or style-load failures.
      - The canvas isn't blank (screenshot non-uniform, or `map.loaded() === true`).
      - A representative marker's projected pixel falls within the canvas and (optionally)
        over a non-background colour in the screenshot.
      
  • scripts
    • check-mapbox-facts.py 16.3 KB
      #!/usr/bin/env python3
      # Staleness verifier for the fast-moving facts the mapbox-ops skill encodes.
      #
      # Two modes (SKILL-RESOURCE-PROTOCOL.md §7):
      #   --offline (default): NO network. Asserts the skill is internally consistent —
      #                        style-catalog.json parses, the v3 Standard config enums
      #                        (lightPreset/theme) agree between catalog and references,
      #                        the terrain tileset IDs and the weather >= 3.7 version gate
      #                        are stated consistently, no reference attributes camera roll
      #                        to Mapbox GL JS (roll is MapLibre GL JS v5 — Mapbox has none,
      #                        and freeCameraOptions orientation must be pitch+bearing-
      #                        representable), every classic style url matches its id,
      #                        every third-party entry is
      #                        addressable. Runs in PR CI and MAY block.
      #   --live:              network. Resolves the concrete third-party style-JSON URLs
      #                        and probes whether Mapbox GL JS has shipped a major beyond
      #                        v3 (which would mean the whole skill needs a review pass).
      #                        Runs in the scheduled freshness workflow and NEVER blocks a
      #                        PR: a transient network failure is UNAVAILABLE (exit 7), only
      #                        a confirmed change is DRIFT (exit 10).
      #
      # Usage:   check-mapbox-facts.py [--offline|--live] [--json] [-q] [--timeout SEC]
      # Input:   none (reads the skill's own assets/ + references/ relative to this file)
      # Output:  stdout = data only (text findings, or the --json envelope)
      # Stderr:  headers, progress, warnings, errors
      # Exit:    0 ok, 2 usage, 3 not-found (skill files missing), 4 validation
      #          (offline inconsistency), 5 missing-dep, 7 unavailable (live network),
      #          10 drift (live: a URL 404'd, or GL JS major bumped past v3)
      #
      # Examples:
      #   check-mapbox-facts.py --offline
      #   check-mapbox-facts.py --offline --json | jq '.data[] | select(.status!="ok")'
      #   check-mapbox-facts.py --live --timeout 15
      """Staleness verifier for mapbox-ops (see header comment)."""
      
      from __future__ import annotations
      
      import argparse
      import json
      import re
      import sys
      from pathlib import Path
      
      EXIT_OK = 0
      EXIT_USAGE = 2
      EXIT_NOT_FOUND = 3
      EXIT_VALIDATION = 4
      EXIT_MISSING_DEP = 5
      EXIT_UNAVAILABLE = 7
      EXIT_DRIFT = 10
      
      SCHEMA = "claude-mods.mapbox-ops.facts/v1"
      
      SKILL_ROOT = Path(__file__).resolve().parent.parent
      CATALOG = SKILL_ROOT / "assets" / "style-catalog.json"
      REFS = SKILL_ROOT / "references"
      SKILL_MD = SKILL_ROOT / "SKILL.md"
      
      # Facts the skill commits to. Changing these is a deliberate edit; the verifier
      # asserts the skill states them consistently across catalog + references.
      EXPECTED_LIGHT_PRESET = {"dawn", "day", "dusk", "night"}
      EXPECTED_THEME = {"default", "faded", "monochrome"}
      TERRAIN_DEM_ID = "mapbox.mapbox-terrain-dem-v1"
      TERRAIN_VECTOR_ID = "mapbox.mapbox-terrain-v2"
      GLJS_MAJOR = 3  # the skill is scoped to mapbox-gl-js v3.x
      
      
      class Finding:
          __slots__ = ("check", "status", "detail")
      
          def __init__(self, check: str, status: str, detail: str) -> None:
              self.check = check
              self.status = status  # ok | fail | drift | unavailable
              self.detail = detail
      
          def as_dict(self) -> dict:
              return {"check": self.check, "status": self.status, "detail": self.detail}
      
      
      def read_text(path: Path) -> str:
          return path.read_text(encoding="utf-8", errors="replace")
      
      
      # --------------------------------------------------------------------------- #
      # Offline checks                                                              #
      # --------------------------------------------------------------------------- #
      def run_offline(findings: list[Finding]) -> None:
          # Required files present (else NOT_FOUND, distinct from inconsistency).
          missing = [p for p in (CATALOG, SKILL_MD, REFS) if not p.exists()]
          if missing:
              for p in missing:
                  findings.append(Finding("files-present", "fail", f"missing: {p}"))
              raise _NotFound()
      
          # O1 — catalog parses.
          try:
              catalog = json.loads(read_text(CATALOG))
              findings.append(Finding("catalog-json", "ok", "style-catalog.json parses"))
          except json.JSONDecodeError as exc:
              findings.append(Finding("catalog-json", "fail", f"invalid JSON: {exc}"))
              return  # nothing else is checkable
      
          presets = catalog.get("standard_presets", {})
          v3_md = read_text(REFS / "v3-standard-style.md") if (REFS / "v3-standard-style.md").exists() else ""
      
          # O2 — lightPreset enum: catalog matches the committed set AND each value is
          # documented in v3-standard-style.md.
          light = set(presets.get("lightPreset", []))
          if light != EXPECTED_LIGHT_PRESET:
              findings.append(Finding("lightPreset-enum", "fail",
                                      f"catalog {sorted(light)} != expected {sorted(EXPECTED_LIGHT_PRESET)}"))
          else:
              undoc = [v for v in EXPECTED_LIGHT_PRESET if v not in v3_md]
              if undoc:
                  findings.append(Finding("lightPreset-enum", "fail",
                                          f"values not documented in v3-standard-style.md: {undoc}"))
              else:
                  findings.append(Finding("lightPreset-enum", "ok", "dawn|day|dusk|night consistent"))
      
          # O3 — theme enum.
          theme = set(presets.get("theme", []))
          if theme != EXPECTED_THEME:
              findings.append(Finding("theme-enum", "fail",
                                      f"catalog {sorted(theme)} != expected {sorted(EXPECTED_THEME)}"))
          else:
              findings.append(Finding("theme-enum", "ok", "default|faded|monochrome consistent"))
      
          # O4 — terrain tileset IDs present in terrain.md.
          terrain_md = read_text(REFS / "terrain.md") if (REFS / "terrain.md").exists() else ""
          for tid in (TERRAIN_DEM_ID, TERRAIN_VECTOR_ID):
              if tid in terrain_md:
                  findings.append(Finding(f"terrain-id:{tid}", "ok", "present in terrain.md"))
              else:
                  findings.append(Finding(f"terrain-id:{tid}", "fail", "absent from terrain.md"))
      
          # O5 — weather version gate agrees between catalog effects comment and dataviz ref.
          effects_comment = catalog.get("effects", {}).get("_comment", "")
          dataviz_md = read_text(REFS / "dataviz-and-3d.md") if (REFS / "dataviz-and-3d.md").exists() else ""
          cat_ver = _first_gl_gate(effects_comment)
          ref_ver = _first_gl_gate(dataviz_md, near="setRain") or _first_gl_gate(dataviz_md, near="Weather")
          if cat_ver and ref_ver and cat_ver == ref_ver == "3.7":
              findings.append(Finding("weather-gate", "ok", "GL JS >= 3.7 consistent (catalog + dataviz-and-3d.md)"))
          else:
              findings.append(Finding("weather-gate", "fail",
                                      f"weather version gate mismatch (catalog={cat_ver!r}, ref={ref_ver!r}, want 3.7)"))
      
          # O6 — no native camera roll. Mapbox GL JS has no roll on any camera API
          # (FreeCameraOptions orientation "must be representable using only pitch and
          # bearing"); true roll is MapLibre GL JS v5. The reference must not attribute
          # a roll camera option / setRoll to Mapbox, and must keep the MapLibre pointer
          # for anyone who needs real roll.
          camera_md = read_text(REFS / "camera-and-animation.md") if (REFS / "camera-and-animation.md").exists() else ""
          cam_lines = camera_md.splitlines()
          roll_api = re.compile(r"setRoll|\{[^}]*\broll\b|\broll\s*:")
          misattributed = []
          for i, ln in enumerate(cam_lines):
              if roll_api.search(ln):
                  context = " ".join(cam_lines[max(0, i - 2):i + 2])
                  if "maplibre" not in context.lower():
                      misattributed.append(ln.strip())
          if misattributed:
              findings.append(Finding("no-native-roll", "fail",
                                      "roll API attributed to Mapbox GL JS (it has none): "
                                      + " | ".join(misattributed[:3])))
          elif "maplibre" not in camera_md.lower():
              findings.append(Finding("no-native-roll", "fail",
                                      "MapLibre-v5 roll pointer missing from camera-and-animation.md"))
          else:
              findings.append(Finding("no-native-roll", "ok",
                                      "no Mapbox roll claim; MapLibre v5 pointer present"))
      
          # O7 — GL JS major scope: SKILL.md says v3.
          skill_md = read_text(SKILL_MD)
          if re.search(rf"v{GLJS_MAJOR}\.x", skill_md) and re.search(rf"v{GLJS_MAJOR}\b", skill_md):
              findings.append(Finding("gljs-major", "ok", f"skill scoped to v{GLJS_MAJOR}.x"))
          else:
              findings.append(Finding("gljs-major", "fail", f"SKILL.md no longer clearly scopes v{GLJS_MAJOR}.x"))
      
          # O8 — every classic style url tail matches its id.
          bad_urls = []
          for s in catalog.get("styles", []):
              sid, url = s.get("id", ""), s.get("url", "")
              if not url.endswith("/" + sid) and not url.endswith(sid):
                  bad_urls.append(f"{sid} -> {url}")
          if bad_urls:
              findings.append(Finding("style-url-id", "fail", "url/id mismatch: " + "; ".join(bad_urls)))
          else:
              findings.append(Finding("style-url-id", "ok", f"{len(catalog.get('styles', []))} style urls match ids"))
      
          # O9 — every third-party entry is addressable (has a url or an explanatory note).
          unaddressable = [t.get("id", "?") for t in catalog.get("third_party", [])
                           if not t.get("url") and not t.get("note")]
          if unaddressable:
              findings.append(Finding("third-party-addressable", "fail",
                                      "no url and no note: " + ", ".join(unaddressable)))
          else:
              findings.append(Finding("third-party-addressable", "ok",
                                      f"{len(catalog.get('third_party', []))} third-party entries addressable"))
      
      
      def _first_gl_gate(text: str, near: str | None = None) -> str | None:
          """Return the first 'GL JS >= 3.N' version found, optionally on a line mentioning `near`."""
          pat = re.compile(r"(?:GL JS\s*)?[>≥]=?\s*(3\.\d+)")
          if near:
              for line in text.splitlines():
                  if near in line:
                      m = pat.search(line)
                      if m:
                          return m.group(1)
              return None
          m = pat.search(text)
          return m.group(1) if m else None
      
      
      class _NotFound(Exception):
          pass
      
      
      # --------------------------------------------------------------------------- #
      # Live checks                                                                 #
      # --------------------------------------------------------------------------- #
      def run_live(findings: list[Finding], timeout: float) -> None:
          import urllib.error
          import urllib.request
      
          try:
              catalog = json.loads(read_text(CATALOG))
          except (OSError, json.JSONDecodeError) as exc:
              findings.append(Finding("catalog-json", "fail", f"cannot read catalog: {exc}"))
              raise _NotFound()
      
          def probe(url: str) -> str:
              """Return resolved | notfound | unavailable for a URL (HEAD, GET fallback)."""
              for method in ("HEAD", "GET"):
                  req = urllib.request.Request(url, method=method,
                                               headers={"User-Agent": "mapbox-ops-staleness/1"})
                  try:
                      with urllib.request.urlopen(req, timeout=timeout) as resp:
                          return "resolved" if resp.status < 400 else "unavailable"
                  except urllib.error.HTTPError as e:
                      if e.code in (404, 410):
                          return "notfound"
                      if e.code in (403, 405, 429):
                          # forbidden/method-not-allowed/rate-limited: exists or can't tell.
                          if method == "HEAD":
                              continue  # retry with GET
                          return "unavailable" if e.code == 429 else "resolved"
                      return "unavailable"
                  except (urllib.error.URLError, TimeoutError, OSError):
                      return "unavailable"
              return "unavailable"
      
          # L1 — concrete third-party style URLs (skip templated/keyed ones).
          for t in catalog.get("third_party", []):
              url = t.get("url")
              if not url or "<" in url or "key=" in url:
                  continue
              res = probe(url)
              status = {"resolved": "ok", "notfound": "drift", "unavailable": "unavailable"}[res]
              findings.append(Finding(f"url:{t.get('id', url)}", status, url))
      
          # L2 — has Mapbox GL JS shipped a major beyond v3? A live v4.0.0 on the CDN
          # means the skill's scope assumption needs a human review pass (drift, not error).
          cdn = "https://api.mapbox.com/mapbox-gl-js/v{}.0.0/mapbox-gl.js"
          v3 = probe(cdn.format(GLJS_MAJOR))
          if v3 == "unavailable":
              findings.append(Finding("gljs-cdn", "unavailable", "Mapbox CDN unreachable"))
          else:
              nxt = probe(cdn.format(GLJS_MAJOR + 1))
              if nxt == "resolved":
                  findings.append(Finding("gljs-major-bump", "drift",
                                          f"mapbox-gl-js v{GLJS_MAJOR + 1}.0.0 is live — review skill scope"))
              elif nxt == "unavailable":
                  findings.append(Finding("gljs-major-bump", "unavailable",
                                          f"could not probe v{GLJS_MAJOR + 1} (network)"))
              else:
                  findings.append(Finding("gljs-major-bump", "ok",
                                          f"v{GLJS_MAJOR} current; no v{GLJS_MAJOR + 1} GA"))
      
      
      # --------------------------------------------------------------------------- #
      # Main                                                                        #
      # --------------------------------------------------------------------------- #
      def main(argv: list[str]) -> int:
          ap = argparse.ArgumentParser(add_help=True, description="mapbox-ops staleness verifier")
          mode = ap.add_mutually_exclusive_group()
          mode.add_argument("--offline", action="store_true", help="structural/internal-consistency only (default)")
          mode.add_argument("--live", action="store_true", help="resolve URLs + probe GL JS major (network)")
          ap.add_argument("--json", action="store_true", help="emit the JSON envelope on stdout")
          ap.add_argument("-q", "--quiet", action="store_true", help="suppress stderr progress")
          ap.add_argument("--timeout", type=float, default=10.0, help="per-request timeout for --live (seconds)")
          try:
              args = ap.parse_args(argv)
          except SystemExit as e:
              # argparse exits 2 on bad args (matches USAGE); 0 on --help.
              return EXIT_USAGE if e.code not in (0, None) else EXIT_OK
      
          live = args.live
          mode_name = "live" if live else "offline"
      
          def emit(msg: str) -> None:
              if not args.quiet:
                  print(msg, file=sys.stderr)
      
          findings: list[Finding] = []
          emit(f"== check-mapbox-facts ({mode_name}) ==")
          try:
              if live:
                  run_live(findings, args.timeout)
              else:
                  run_offline(findings)
          except _NotFound:
              if args.json:
                  print(json.dumps({"error": {"code": "NOT_FOUND",
                                              "message": "skill files missing",
                                              "details": [f.as_dict() for f in findings]}}))
              for f in findings:
                  emit(f"  [{f.status.upper()}] {f.check}: {f.detail}")
              return EXIT_NOT_FOUND
      
          n_fail = sum(1 for f in findings if f.status == "fail")
          n_drift = sum(1 for f in findings if f.status == "drift")
          n_unavail = sum(1 for f in findings if f.status == "unavailable")
      
          # Output: stdout is data only.
          if args.json:
              print(json.dumps({
                  "data": [f.as_dict() for f in findings],
                  "meta": {"mode": mode_name, "count": len(findings),
                           "fail": n_fail, "drift": n_drift, "unavailable": n_unavail,
                           "schema": SCHEMA},
              }, indent=2))
          else:
              for f in findings:
                  print(f"{f.check}\t{f.status}\t{f.detail}")
      
          # Progress summary to stderr.
          for f in findings:
              if f.status != "ok":
                  emit(f"  [{f.status.upper()}] {f.check}: {f.detail}")
          emit(f"-- {len(findings)} checks: {n_fail} fail, {n_drift} drift, {n_unavail} unavailable")
      
          # Exit precedence: a real inconsistency (offline) or 404 (live) is the loudest
          # signal; an unavailable network is advisory and must never mask a clean run as
          # failing — but if the ONLY non-ok results are unavailable, exit 7, never 0.
          if n_fail:
              return EXIT_VALIDATION
          if n_drift:
              return EXIT_DRIFT
          if n_unavail:
              return EXIT_UNAVAILABLE
          return EXIT_OK
      
      
      if __name__ == "__main__":
          sys.exit(main(sys.argv[1:]))
      
    • screenshot_map.py 6.9 KB
      #!/usr/bin/env python3
      """Headless screenshot + marker-alignment verifier for a served Mapbox GL JS page.
      
      Usage:   screenshot_map.py [OPTIONS] <URL> <OUT.png>
      Input:   URL of a *served* map page (http://…, not file://); PNG output path.
               Optional --expect LNG LAT to project a coordinate to its on-canvas pixel.
      Output:  stdout = data only — a human result line, or the --json envelope (§4).
      Stderr:  progress, warnings, console-error dumps, stack-trace-free diagnostics.
      Exit:    0 ok (map ready, no console errors)
               2 usage (bad/missing args)
               5 precondition (playwright not installed / browser missing)
               7 unavailable (map never signalled ready within --timeout)
               10 domain signal (page/console errors were captured) — caller branches on this
      
      Examples:
        screenshot_map.py http://localhost:8777/preview/index.html out.png
        screenshot_map.py http://localhost:8777/index.html out.png --expect 146.9 -36.1
        screenshot_map.py http://localhost:8777/index.html out.png --json | jq '.data'
      
      First run only:  uv run --with playwright python -m playwright install chromium
      
      Why served (not file://): a page that fetches GeoJSON/photos at runtime needs an HTTP
      origin and a same-origin canvas (else createImageBitmap taints). Serve one with:
        python -m http.server 8777 --directory <site-dir>
      """
      from __future__ import annotations
      
      import argparse
      import json
      import sys
      
      # Semantic exit codes (SKILL-RESOURCE-PROTOCOL §5).
      EX_OK, EX_USAGE, EX_PRECONDITION, EX_UNAVAILABLE, EX_DOMAIN = 0, 2, 5, 7, 10
      
      READY_JS = """
      () => {
        try {
          if (window.__mapReady === true) return true;
          const m = window.map;
          if (!m) return false;
          if (typeof m.loaded === 'function' && m.loaded()) return true;
          if (typeof m.isStyleLoaded === 'function' && m.isStyleLoaded()) return true;
          return false;
        } catch (e) { return false; }
      }
      """
      
      PROJECT_JS = """
      ([lng, lat]) => {
        const m = window.map;
        if (!m) return null;
        const r = m.getCanvas().getBoundingClientRect();
        const p = m.project([lng, lat]);
        return { canvas: {x: p.x, y: p.y},
                 page: {x: r.left + p.x, y: r.top + p.y},
                 size: {w: r.width, h: r.height} };
      }
      """
      
      SCHEMA = "claude-mods.mapbox-ops.screenshot_map/v1"
      
      
      def emit_json(data: dict, code: int) -> int:
          """Print the §4 success/error envelope to stdout and return the exit code."""
          if code in (EX_OK, EX_DOMAIN):
              print(json.dumps({"data": data, "meta": {"schema": SCHEMA, "exit": code}}))
          else:
              print(json.dumps({"error": {"code": data.get("code", "ERROR"),
                                          "message": data.get("message", ""),
                                          "details": data}}))
          return code
      
      
      def main() -> int:
          ap = argparse.ArgumentParser(
              prog="screenshot_map.py", add_help=True,
              description="Headless screenshot + marker-alignment verifier for a served Mapbox GL JS page.")
          ap.add_argument("url", help="served map page URL (http://…, not file://)")
          ap.add_argument("out", help="screenshot output path (.png)")
          ap.add_argument("--expect", nargs=2, type=float, metavar=("LNG", "LAT"),
                          help="project this lng/lat and report its pixel")
          ap.add_argument("--width", type=int, default=1280)
          ap.add_argument("--height", type=int, default=800)
          ap.add_argument("--timeout", type=int, default=20000, help="readiness timeout (ms)")
          ap.add_argument("--json", action="store_true", help="emit the structured §4 envelope")
          args = ap.parse_args()
      
          as_json = args.json
          if not args.url.startswith(("http://", "https://")):
              msg = "URL must be http(s):// (the page must be served, not file://)"
              print(f"error: {msg}", file=sys.stderr)
              return emit_json({"code": "USAGE", "message": msg}, EX_USAGE) if as_json else EX_USAGE
      
          try:
              from playwright.sync_api import sync_playwright
          except ImportError:
              msg = ("playwright not installed — run: uv run --with playwright "
                     "python -m playwright install chromium")
              print(f"error: {msg}", file=sys.stderr)
              return emit_json({"code": "PRECONDITION", "message": msg}, EX_PRECONDITION) if as_json else EX_PRECONDITION
      
          errors: list[str] = []
          ready = False
          projection: dict | None = None
      
          with sync_playwright() as p:
              try:
                  browser = p.chromium.launch()
              except Exception as e:                       # browser binary not installed
                  msg = f"chromium launch failed — run: python -m playwright install chromium ({e})"
                  print(f"error: {msg}", file=sys.stderr)
                  return emit_json({"code": "PRECONDITION", "message": msg}, EX_PRECONDITION) if as_json else EX_PRECONDITION
      
              page = browser.new_page(viewport={"width": args.width, "height": args.height},
                                      device_scale_factor=2)
              page.on("console", lambda m: errors.append(m.text) if m.type == "error" else None)
              page.on("pageerror", lambda e: errors.append(str(e)))
      
              page.goto(args.url, wait_until="networkidle")
              try:
                  page.wait_for_function(READY_JS, timeout=args.timeout)
                  ready = True
              except Exception:
                  print(f"warn: map not ready within {args.timeout}ms "
                        "(set window.__mapReady=true at end of init() for an exact signal)",
                        file=sys.stderr)
      
              page.screenshot(path=args.out, full_page=False)
              print(f"screenshot → {args.out}", file=sys.stderr)   # status → stderr, not data
      
              if args.expect:
                  projection = page.evaluate(PROJECT_JS, args.expect)
                  if not projection:
                      print("warn: window.map not found (expose it: `window.map = map`)", file=sys.stderr)
      
              browser.close()
      
          # Result assembly
          data = {"out": args.out, "ready": ready, "errorCount": len(errors), "errors": errors[:20]}
          if projection:
              cx, cy = projection["canvas"]["x"], projection["canvas"]["y"]
              w, h = projection["size"]["w"], projection["size"]["h"]
              projection["inside"] = bool(0 <= cx <= w and 0 <= cy <= h)
              data["projection"] = projection
      
          # Decide exit code: console/page errors are the domain signal (10); never-ready is 7.
          code = EX_DOMAIN if errors else (EX_UNAVAILABLE if not ready else EX_OK)
      
          if as_json:
              return emit_json(data, code)
      
          # plain-text data product on stdout
          line = f"ready={ready} errors={len(errors)} out={args.out}"
          if projection:
              line += (f" project={tuple(args.expect)}→canvas("
                       f"{projection['canvas']['x']:.0f},{projection['canvas']['y']:.0f}) "
                       f"{'inside' if projection['inside'] else 'OUTSIDE'}")
          print(line)
          if errors:
              print(f"\n{len(errors)} console/page error(s):", file=sys.stderr)
              for e in errors[:20]:
                  print("  - " + e, file=sys.stderr)
          return code
      
      
      if __name__ == "__main__":
          raise SystemExit(main())
      
  • tests
    • run.sh 4.8 KB
      #!/usr/bin/env bash
      # Offline self-test for the mapbox-ops skill — structure, frontmatter, script contract.
      #
      # Usage:   tests/run.sh
      # Input:   none (self-contained; no network, no playwright, no browser)
      # Output:  TAP-ish progress on stderr; final PASS/FAIL line.
      # Exit:    0 all pass (or skipped on unsupported platform), 1 any failure.
      #
      # Examples:
      #   tests/run.sh
      #   bash skills/mapbox-ops/tests/run.sh
      set -uo pipefail
      
      here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
      fail=0
      pass=0
      note() { printf '  %s %s\n' "$1" "$2" >&2; }
      ok()   { pass=$((pass+1)); note "ok  " "$1"; }
      bad()  { fail=$((fail+1)); note "FAIL" "$1"; }
      
      # Resolve a *working* python (python3, else python). The bare `command -v` is not
      # enough on Windows, where `python3` is a Microsoft Store stub that exits nonzero.
      PY=""
      for cand in python3 python; do
        if command -v "$cand" >/dev/null 2>&1 && "$cand" --version >/dev/null 2>&1; then
          PY="$cand"; break
        fi
      done
      if [ -z "$PY" ]; then
        echo "SKIP: no working python interpreter on this platform" >&2
        exit 0
      fi
      
      # 1. Required directories exist
      for d in scripts references assets tests; do
        [ -d "$here/$d" ] && ok "dir $d/ exists" || bad "missing dir $d/"
      done
      
      # 2. SKILL.md frontmatter house rules (SKILL-SUBAGENT-REFERENCE)
      skill="$here/SKILL.md"
      if [ -f "$skill" ]; then
        ok "SKILL.md present"
        grep -q '^name: mapbox-ops$' "$skill" && ok "name matches directory" || bad "name != mapbox-ops"
        grep -q '^license: MIT$' "$skill" && ok "license: MIT" || bad "missing license: MIT"
        grep -q '^  author: claude-mods$' "$skill" && ok "metadata.author" || bad "missing metadata.author"
      else
        bad "SKILL.md missing"
      fi
      
      # 3. Every reference cited from SKILL.md actually exists, and vice-versa
      for ref in "$here"/references/*.md; do
        base="references/$(basename "$ref")"
        grep -qF "$base" "$skill" && ok "cited: $base" || bad "uncited reference: $base"
      done
      
      # 4. Bundled resources referenced from SKILL.md exist on disk
      for res in assets/circular_image_marker.js scripts/screenshot_map.py; do
        [ -f "$here/$res" ] && ok "resource present: $res" || bad "missing resource: $res"
      done
      
      # 5. screenshot_map.py — script contract (§10)
      py="$here/scripts/screenshot_map.py"
      if [ -f "$py" ]; then
        "$PY" -m py_compile "$py" && ok "py_compile clean" || bad "py_compile failed"
        # Captured, not `head | grep -q`: under `set -o pipefail` grep -q's early exit
        # SIGPIPEs the producer (141) and flakes the assert even on a match.
        py_head="$(head -25 "$py")"
        grep -Eq '^(# )?Examples:' <<<"$py_head" && ok "has Examples block" || bad "no Examples block"
        "$PY" "$py" --help >/dev/null 2>&1 && ok "--help exits 0" || bad "--help nonzero"
        # USAGE (exit 2) on a file:// URL — happens before the playwright import, so this is offline-safe
        "$PY" "$py" "file:///tmp/x.html" /tmp/o.png >/dev/null 2>&1
        [ "$?" -eq 2 ] && ok "file:// URL → exit 2 (USAGE)" || bad "file:// URL did not exit 2"
      else
        bad "screenshot_map.py missing"
      fi
      
      # 5b. check-mapbox-facts.py — staleness verifier contract (§7, §10), offline-safe
      facts="$here/scripts/check-mapbox-facts.py"
      if [ -f "$facts" ]; then
        ok "resource present: scripts/check-mapbox-facts.py"
        "$PY" -m py_compile "$facts" && ok "facts: py_compile clean" || bad "facts: py_compile failed"
        facts_head="$(head -30 "$facts")"   # captured, not piped — see SIGPIPE note above
        grep -Eq '^# +Examples:' <<<"$facts_head" && ok "facts: has Examples block" || bad "facts: no Examples block"
        "$PY" "$facts" --help >/dev/null 2>&1 && ok "facts: --help exits 0" || bad "facts: --help nonzero"
        # Offline mode must pass on the skill's own content (internal consistency).
        "$PY" "$facts" --offline >/dev/null 2>&1 && ok "facts: --offline consistent (exit 0)" || bad "facts: --offline found drift"
        # Bad flag → USAGE (exit 2); stays offline.
        "$PY" "$facts" --bogus >/dev/null 2>&1
        [ "$?" -eq 2 ] && ok "facts: bad flag → exit 2 (USAGE)" || bad "facts: bad flag did not exit 2"
        # stdout is data-only: --offline --json must emit parseable JSON with no stderr leakage.
        "$PY" "$facts" --offline --json -q 2>/dev/null | "$PY" -c 'import json,sys; d=json.load(sys.stdin); assert d["meta"]["schema"].startswith("claude-mods.mapbox-ops")' \
          && ok "facts: --json envelope parses (stdout clean)" || bad "facts: --json envelope broken"
        # cited from SKILL.md
        grep -qF "scripts/check-mapbox-facts.py" "$skill" && ok "facts: cited from SKILL.md" || bad "facts: uncited"
      else
        bad "check-mapbox-facts.py missing"
      fi
      
      # 6. circular_image_marker.js — node --check if node present (optional)
      js="$here/assets/circular_image_marker.js"
      if command -v node >/dev/null 2>&1; then
        node --check "$js" >/dev/null 2>&1 && ok "js syntax (node --check)" || bad "js syntax error"
      else
        note "skip" "node absent — js syntax check skipped"
      fi
      
      echo "mapbox-ops self-test: $pass passed, $fail failed" >&2
      [ "$fail" -eq 0 ]
      
  • SKILL.md 7.8 KB
    ---
    name: mapbox-ops
    description: "Mapbox GL JS v3 (web) toolkit: custom markers, symbol/geojson layers, clustering, style expressions, 3D terrain/fill-extrusion, heatmaps, choropleths, camera animation, and performance gotchas. Use for any Mapbox GL JS work — markers, layers, style, 3D, or headless map verification."
    license: MIT
    compatibility: "Web mapbox-gl-js v3.x. screenshot_map.py needs Python 3.10+ and Playwright (chromium); check-mapbox-facts.py is stdlib-only Python 3.10+."
    metadata:
      author: claude-mods
      related-skills: "color-ops, screenshot, genart-ops, threejs-ops"
    ---
    
    # Mapbox GL JS — advanced web toolkit (v3)
    
    An advanced toolkit for building production Mapbox GL JS map experiences on the **web**:
    markers, thematic dataviz, 3D, terrain, cinematic camera, style composition, performance,
    and the hard-won gotchas that bite. Scope: **`mapbox-gl-js` v3.x in the browser** (CDN
    `mapbox-gl-js/v3.x/`) — not the native iOS/Android SDKs (different APIs). Plain GL JS,
    framework-agnostic. Several patterns were distilled from a production trail map; adapt the
    constants to your own design.
    
    ## Setup invariants
    
    - Set `mapboxgl.accessToken` before `new mapboxgl.Map(...)`.
    - The map needs **`'load'`** before adding sources/layers/images. In a throttled
      or background tab `'load'` can be missed — also bind `'idle'` as a one-shot
      fallback guarded by an `_inited` flag (see [verification.md](references/verification.md)).
    - Resolving token/style from `.env`: **read the token FIRST** (that triggers the
      `.env` load), THEN read `MAPBOX_STYLE`. Reading the style before the token load
      silently falls back to the default style. See [palette.md](references/palette.md).
    - **Classic vs Standard style.** Several techniques here (basemap palette recolour,
      the terrain boost-or-add `getStyle().layers` walk) assume a **classic** style
      (Streets/Outdoors/Light/Dark `…-v12`). The v3 default **Standard** style has no
      enumerable named layers — use slots + `setConfigProperty` instead. See
      [v3-standard-style.md](references/v3-standard-style.md) before porting to Standard.
    
    ## Pick the technique
    
    Read the matching reference file only when the task needs it:
    
    | Task | Reference |
    |------|-----------|
    | Custom SVG/canvas markers, `addImage`/`updateImage`, namespacing, AA/fringing, circular image masks, anchoring | [references/markers.md](references/markers.md) |
    | Dashed/cased trail lines, `line-dasharray` units, translucency over hillshade, colour-by-attribute, `line-gradient`/`lineMetrics` | [references/lines-and-trails.md](references/lines-and-trails.md) |
    | Hillshade, dense contours, 3D terrain (`setTerrain`), boost-or-add an existing style's terrain | [references/terrain.md](references/terrain.md) |
    | Symbol-layer text labels that never hide icons (`text-optional`), AllTrails-style placement | [references/labels.md](references/labels.md) |
    | Recolour a base style's land/vegetation fills (palette shift / choropleth-style `match`) | [references/palette.md](references/palette.md) |
    | Custom popups, circular photo cards, zoom-scaled offsets | [references/popups.md](references/popups.md) |
    | Style expressions — `interpolate`/`step`/`match`/`case`, the **zoom-outermost** rule, `feature-state` in expressions | [references/expressions.md](references/expressions.md) |
    | Hover/select via `feature-state` (not `setData`), `queryRenderedFeatures` caveats, clustering, GeoJSON perf, event hygiene | [references/interaction-and-performance.md](references/interaction-and-performance.md) |
    | Data viz & 3D — `fill-extrusion` buildings/extruded data, heatmap layer, data-join choropleth (feature-state/`match`), proportional symbols, sky/fog | [references/dataviz-and-3d.md](references/dataviz-and-3d.md) |
    | **three.js in the map** — `CustomLayerInterface` + shared GL context, animated 3D objects/models, the baked-matrix vs reconstructed-camera (Threebox `CameraSync`) fork, raycast picking, ENU-metre scene space, constant screen-size actors, terrain elevation, far-plane clipping at pitch | [references/three-custom-layer.md](references/three-custom-layer.md) |
    | Camera & animation — `flyTo`/`easeTo`/`fitBounds` padding, `freeCameraOptions` cinematics/orbit, flight/first-person camera (bearing+pitch choreography; roll is MapLibre-only), animated day–night cycle (`setLights`), HUD synced to camera, point-along-line, draw-in lines, paint transitions, spinning globe, the `essential`/reduced-motion gotcha | [references/camera-and-animation.md](references/camera-and-animation.md) |
    | Style library & composition — first-party style catalog, choosing a base by use case, custom/third-party styles, style switcher, light/dark, hand-rolled style JSON | [references/styles.md](references/styles.md) (+ [assets/style-catalog.json](assets/style-catalog.json)) |
    | `setStyle` wiping custom layers, the 0×0 `resize()` bug, SPA teardown / WebGL-context cap, token security, readiness events | [references/lifecycle.md](references/lifecycle.md) |
    | **v3 Standard style** — slots vs `beforeId`, `setConfigProperty`/`lightPreset`, why layer-walking (palette/terrain) breaks; localisation, RTL, globe | [references/v3-standard-style.md](references/v3-standard-style.md) |
    | Headless screenshot + pixel-accurate marker-alignment checks (Playwright, `map.project`) | [references/verification.md](references/verification.md) |
    
    ## Bundled resources
    
    - **Starter code** — [assets/circular_image_marker.js](assets/circular_image_marker.js):
      copy into a page to register a circular photo marker (canvas → premultiplied
      `ImageBitmap`, `destination-in` mask, contact + drop shadow). Browser-only snippet,
      not a CLI — adapt the `frameColor`/box constants to your design.
    - **Verifier script** — [scripts/screenshot_map.py](scripts/screenshot_map.py): drive
      headless Chromium to screenshot a *served* map page, assert a marker projects to its
      lng/lat, and surface console errors. Run it:
    
      ```bash
      python -m http.server 8777 --directory <site-dir> &          # serve the page
      uv run --with playwright scripts/screenshot_map.py \
        http://localhost:8777/preview/index.html out.png --expect 146.9 -36.1
      # exit 0 = no console errors; 10 = errors found; 5 = playwright missing; 7 = map never ready
      uv run --with playwright scripts/screenshot_map.py URL out.png --json | jq '.data'
      ```
    
    - **Staleness verifier** — [scripts/check-mapbox-facts.py](scripts/check-mapbox-facts.py):
      stdlib-only (no Playwright), guards the fast-moving facts this skill encodes
      (SKILL-RESOURCE-PROTOCOL §7). `--offline` (default) asserts internal consistency —
      the v3 Standard config enums (`lightPreset`/`theme`), terrain tileset IDs, the weather
      (≥3.7) version gate, the no-native-camera-roll fact (roll is MapLibre GL JS v5, not
      Mapbox), and every style URL/id in
      [assets/style-catalog.json](assets/style-catalog.json). `--live` resolves the
      third-party style URLs and probes whether Mapbox GL JS has shipped a major past v3.
    
      ```bash
      python scripts/check-mapbox-facts.py --offline            # exit 0 ok, 4 inconsistency
      python scripts/check-mapbox-facts.py --live --json        # exit 7 network, 10 drift
      ```
    
    ## The three highest-value gotchas (full detail in the refs)
    
    1. **Namespace every `addImage` name** (e.g. `"rcpin-<glyph>"`). Mapbox styles ship
       sprite icons literally named `parking`/`toilet`/etc — an un-namespaced
       `hasImage()` returns `true` for those and your icon is **silently dropped**.
    2. **Register icons as premultiplied `createImageBitmap()`**, not a raw
       `HTMLImageElement`/`ImageData` — straight-alpha sources make Mapbox **fringe a
       white halo** around anti-aliased edges. `updateImage(name, bmp)` recolours in place.
    3. **Data-driven `icon-offset` is silently ignored in GL JS v3.** Use a *constant*
       `icon-offset` (it scales with `icon-size`) or split markers into separate symbol
       layers, each with its own constant anchor/offset.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related