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
Install
npx skills add https://github.com/0xDarkMatter/claude-mods/tree/main/skills/mapbox-ops
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install 0xdarkmatter-claude-mods@llmmart
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.accessTokenbeforenew 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_initedflag (see verification.md). - Resolving token/style from
.env: read the token FIRST (that triggers the.envload), THEN readMAPBOX_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().layerswalk) assume a classic style (Streets/Outdoors/Light/Dark…-v12). The v3 default Standard style has no enumerable named layers — use slots +setConfigPropertyinstead. 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-inmask, contact + drop shadow). Browser-only snippet, not a CLI — adapt theframeColor/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.--liveresolves 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)
- Namespace every
addImagename (e.g."rcpin-<glyph>"). Mapbox styles ship sprite icons literally namedparking/toilet/etc — an un-namespacedhasImage()returnstruefor those and your icon is silently dropped. - Register icons as premultiplied
createImageBitmap(), not a rawHTMLImageElement/ImageData— straight-alpha sources make Mapbox fringe a white halo around anti-aliased edges.updateImage(name, bmp)recolours in place. - Data-driven
icon-offsetis silently ignored in GL JS v3. Use a constanticon-offset(it scales withicon-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.
Reviews (0)
No reviews yet.
No comments yet.