Mapbox skills and MCP servers for building location-aware applications with AI. Includes geospatial tools, style management, and patterns for web, iOS, Android, and AI agent frameworks.
Integration patterns for Mapbox MCP DevKit Server in AI coding assistants. Covers setup, style management, token management, validation workflows, and documentation access through MCP. Use when building Mapbox applications with AI coding assistance.
# Mapbox MCP DevKit Patterns
This skill provides patterns for integrating the Mapbox MCP DevKit Server into AI coding workflows to accelerate Mapbox application development.
## What is Mapbox MCP DevKit?
The [Mapbox MCP DevKit Server](https://github.com/mapbox/mcp-devkit-server) is a Model Context Protocol (MCP) server that gives AI assistants direct access to Mapbox developer APIs:
- **Style management** - Create, list, update, delete, preview, validate, compare, and optimize styles
- **Token management** - Generate and list access tokens with scopes
- **Validation** - Validate GeoJSON, expressions, styles, and coordinates
- **Geographic utilities** - Calculate bounding boxes, convert coordinates, query tiles
- **Color tools** - Check WCAG contrast ratios for accessibility
- **Feedback** - Access user feedback and reports
- **Documentation** - Access latest Mapbox docs and references
**Key benefit:** Build Mapbox applications faster by letting AI handle developer API operations.
## Setup & Installation
### Prerequisites
- Mapbox account with access token
- AI coding assistant that supports MCP (Claude Code, Cursor, Windsurf, Cline, etc.)
### Option 1: Hosted Server (Recommended)
**Easiest setup** - Use Mapbox's hosted DevKit MCP server at:
```
https://mcp-devkit.mapbox.com/mcp
```
No installation required, just configure your AI assistant.
**Authentication:** The hosted server supports OAuth, so no token configuration needed! Simply add the server URL:
#### For Claude Desktop
Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
```json
{
"mcpServers": {
"mapbox-devkit-mcp": {
"command": "npx",
"args": ["mcp-remote", "https://mcp-devkit.mapbox.com/mcp"]
}
}
}
```
You'll be prompted to authenticate via OAuth on first use.
#### For Claude Code
Claude Code supports both user-level and project-level MCP configuration:
**User-level** (applies to all projects) - `~/.claude.json`:
```json
{
"mcpServers": {
"mapbox-devkit": {
"url": "https://mcp-devkit.mapbox.com/mcp"
}
}
}
```
**Project-level** (specific project, can commit to git) - `.mcp.json` in repository root:
```json
{
"mcpServers": {
"mapbox-devkit": {
"url": "https://mcp-devkit.mapbox.com/mcp"
}
}
}
```
See [Claude Code settings documentation](https://code.claude.com/docs/en/settings) for more details on configuration scopes.
#### For Cursor
Create or edit `.cursor/mcp.json` (project-local) or `~/.cursor/mcp.json` (global):
```json
{
"mcpServers": {
"mapbox-devkit": {
"url": "https://mcp-devkit.mapbox.com/mcp"
}
}
}
```
After saving, restart Cursor. Click "Needs authentication" when prompted and follow the OAuth flow.
#### For VS Code with Copilot
Create or edit `mcp.json`:
```json
{
"servers": {
"mapbox-devkit": {
"type": "http",
"url": "https://mcp-devkit.mapbox.com/mcp"
}
}
}
```
After saving, refresh the MCP service in VS Code. Requires GitHub Copilot with MCP support enabled.
#### For Windsurf/Cline
Similar configuration using the hosted URL with OAuth support.
### Option 2: Self-Hosted (Advanced)
For development, debugging, or customization:
```bash
# Clone the DevKit server
git clone https://github.com/mapbox/mcp-devkit-server.git
cd mcp-devkit-server
# Install dependencies
npm install
# Build the server
npm run build
```
**Configuration for self-hosted (Claude Desktop):**
```json
{
"mcpServers": {
"MapboxDevKitServer": {
"command": "node",
"args": ["/Users/username/github-projects/mcp-devkit-server/dist/esm/index.js"],
"env": {
"MAPBOX_ACCESS_TOKEN": "some token"
}
}
}
}
```
Replace `/Users/username/github-projects/` with your actual path.
### Verify Installation
Ask your AI assistant:
```
"List the available Mapbox DevKit tools"
```
You should see 30+ tools including:
- **Style tools**: `create_style_tool`, `list_styles_tool`, `update_style_tool`, `delete_style_tool`, `preview_style_tool`, etc.
- **Token tools**: `create_token_tool`, `list_tokens_tool`
- **Validation tools**: `validate_geojson_tool`, `validate_style_tool`, `validate_expression_tool`
- **Geographic tools**: `bounding_box_tool`, `coordinate_conversion_tool`, `tilequery_tool`
- **Documentation**: `get_latest_mapbox_docs_tool`
## Core Workflows
### 1. Style Management
**Create a style conversationally:**
```
"Create a dark mode Mapbox style with 3D buildings, emphasize parks in green,
and use blue for water. Name it 'app-dark-mode'."
```
The AI will use `create_style_tool` tool to:
- Generate style JSON following Mapbox Style Spec
- Upload to your Mapbox account
- Return style ID and preview URL
**Update existing style:**
```
"Update style mapbox://styles/username/style-id to make roads more prominent
and reduce building opacity to 0.6"
```
**Validate style:**
```
"Validate this style JSON: [paste style]"
```
### 2. Token Management
**Create scoped token:**
```
"Create a Mapbox token with these scopes:
- styles:read
- fonts:read
- datasets:read
Restrict it to domains: localhost, example.com"
```
**List existing tokens:**
```
"Show me all my Mapbox tokens and their scopes"
```
**Use case:** Generate tokens for different environments (development, staging, production) with appropriate restrictions.
### 3. Data Validation
**Validate GeoJSON:**
```
"Validate this GeoJSON and show any errors:
{
\"type\": \"FeatureCollection\",
\"features\": [...]
}"
```
**Validate expressions:**
```
"Is this a valid Mapbox expression?
['case', ['<', ['get', 'population'], 1000], 'small', 'large']"
```
**Coordinate conversion:**
```
"Convert longitude -122.4194, latitude 37.7749 from WGS84 to Web Mercator"
```
### 4. Documentation Access
**Get style spec info:**
```
"What properties are available for fill layers in Mapbox GL JS?"
```
**Check token scopes:**
```
"What token scopes do I need to use the Directions API?"
```
**Streets v8 fields:**
```
"What fields are available in the 'road' layer of Streets v8?"
```
## Design Patterns
### Pattern 1: Iterative Style Development
**Workflow:**
1. Describe desired style in natural language
2. AI creates initial style via MCP
3. View preview URL
4. Request adjustments
5. AI updates style via MCP
6. Repeat until satisfied
**Example conversation:**
```
You: "Create a style for a real estate app - emphasize property boundaries,
show parks prominently, muted roads"
AI: [Creates style, returns ID and preview URL]
You: "Make the property boundaries purple and thicker"
AI: [Updates style]
You: "Perfect! Now add POI icons for schools and transit"
AI: [Updates style with symbols]
```
**Benefits:**
- No manual JSON editing
- Visual feedback via preview URLs
- Rapid iteration
### Pattern 2: Environment-Specific Tokens
**Workflow:**
1. Define requirements per environment
2. AI creates tokens with appropriate scopes/restrictions
3. Store securely in environment variables
**Example:**
```
You: "Create three tokens:
1. Development - all scopes, localhost only
2. Staging - read-only scopes, staging.example.com
3. Production - minimal scopes, example.com only"
AI: [Creates three tokens with specified configurations]
```
**Benefits:**
- Least-privilege access
- Domain restrictions prevent token misuse
- Clear separation of concerns
### Pattern 3: Validation-First Development
**Workflow:**
1. Design data structure
2. Validate GeoJSON before using
3. Validate expressions before adding to style
4. Catch errors early
**Example:**
```
You: "I have GeoJSON with restaurant locations. Validate it and check for
any missing required properties"
AI: [Validates, reports any issues]
You: "Now create a style that displays these restaurants with icons sized
by rating. Validate the expression first."
AI: [Validates expression, then creates style]
```
**Benefits:**
- Catch errors before deployment
- Ensure data integrity
- Faster debugging
### Pattern 4: Documentation-Driven Development
**Workflow:**
1. Ask about Mapbox capabilities
2. Get authoritative documentation
3. Implement with correct patterns
4. Validate implementation
**Example:**
```
You: "How do I create a choropleth map in Mapbox GL JS?"
AI: [Retrieves docs, provides pattern]
You: "Create a style with that pattern for population density data"
AI: [Creates style following documented pattern]
```
**Benefits:**
- Always use latest best practices
- No outdated Stack Overflow answers
- Official Mapbox guidance
## When to Use MCP DevKit
### ✅ Use DevKit when:
- Creating/modifying styles conversationally
- Generating tokens programmatically
- Validating data during development
- Learning Mapbox APIs through natural language
- Rapid prototyping of map applications
- Automated style generation workflows
- Documentation lookup during coding
### ❌ Don't use DevKit for:
- Runtime operations in production apps
- High-frequency style updates (use Mapbox APIs directly)
- Client-side operations (DevKit is development-time only)
- Tile serving or map rendering
- User-facing features requiring low latency
## Integration with Existing Tools
### With Mapbox Studio
DevKit complements, doesn't replace Studio:
- **DevKit:** Quick iterations, automated workflows, AI assistance
- **Studio:** Visual editing, fine-tuning, team collaboration
**Pattern:** Use DevKit for initial creation, Studio for refinement.
### With Mapbox APIs
DevKit wraps Mapbox APIs but doesn't replace them:
- **DevKit:** Development-time operations via AI
- **APIs:** Production runtime operations
**Pattern:** Use DevKit during development, APIs in production code.
### With Version Control
**Pattern:** Save generated styles to git for review and rollback.
```
You: "Create a new style for the home page map and save the JSON to
styles/home-map.json"
AI: [Creates style, writes JSON to file]
You: [Review, commit to git]
```
## Best Practices
### Security
- **Never commit access tokens** - Use environment variables
- **Use scoped tokens** - Minimal necessary permissions
- **Add URL restrictions** - Limit to your domains
- **Rotate tokens regularly** - Generate new tokens periodically
### Style Management
- **Version your styles** - Save JSON to source control
- **Use meaningful names** - `prod-light-mode` not `style-123`
- **Document decisions** - Add comments explaining style choices
- **Preview before deploying** - Always check preview URL
### Validation
- **Validate early** - Check data before creating styles
- **Use strict validation** - Don't skip validation steps
- **Test expressions** - Validate before adding to styles
- **Verify coordinates** - Ensure correct format and bounds
### Documentation
- **Ask specific questions** - "What are fill-extrusion properties?"
- **Reference versions** - Specify GL JS version if relevant
- **Cross-reference** - Validate AI responses against official docs
## Troubleshooting
### DevKit not appearing in AI assistant
**Check:**
1. MCP server running? Check logs
2. Config file in correct location?
3. Token environment variable set?
4. Path to `index.js` correct?
**Solution:** Restart AI assistant after config changes.
### Style creation fails
**Check:**
1. Access token has `styles:write` scope
2. Style name is unique
3. JSON is valid Mapbox Style Spec
**Solution:** Use `validate_style_tool` tool first.
### Token creation fails
**Check:**
1. Access token has `tokens:write` scope
2. Requested scopes are valid
3. URL restrictions are well-formed
**Solution:** Check token scope documentation via DevKit.
### Validation errors
**Check:**
1. GeoJSON follows spec (RFC 7946)
2. Coordinates are [longitude, latitude] order
3. Properties match expected schema
**Solution:** Ask AI to explain validation errors.
## Example Workflows
### Build a Restaurant Finder
```
You: "I'm building a restaurant finder app. Create:
1. A light, neutral style emphasizing restaurants
2. A token for localhost with minimal scopes
3. Validate this GeoJSON with restaurant locations: [paste]"
AI: [Creates style, token, validates data]
You: "Add filters to show only 4+ star restaurants"
AI: [Updates style with expression]
You: "Generate a preview URL"
AI: [Returns preview]
```
### Create Multi-Environment Setup
```
You: "Set up styles and tokens for dev, staging, prod:
- Dev: Full access, localhost
- Staging: Read-only, staging.example.com
- Prod: Minimal scopes, example.com
Each environment needs its own style variant."
AI: [Creates 3 styles and 3 tokens with specifications]
```
### Validate Third-Party Data
```
You: "I received GeoJSON from a vendor. Validate it, check for:
- Correct coordinate order
- Valid geometry types
- Required properties: name, address, category"
AI: [Validates, reports issues]
You: "Fix the issues and save cleaned data to data/locations.json"
AI: [Fixes, saves file]
```
## Resources
- [Mapbox MCP DevKit Server](https://github.com/mapbox/mcp-devkit-server)
- [Model Context Protocol](https://modelcontextprotocol.io)
- [Mapbox Style Specification](https://docs.mapbox.com/style-spec/)
- [Mapbox API Documentation](https://docs.mapbox.com/api/)
- [Token Scopes Reference](https://docs.mapbox.com/api/accounts/tokens/)
## When to Use This Skill
Invoke this skill when:
- Setting up Mapbox development environment with AI assistance
- Creating or modifying Mapbox styles through AI
- Managing access tokens programmatically
- Validating GeoJSON or expressions during development
- Learning Mapbox APIs with AI guidance
- Automating style generation workflows
- Building Mapbox applications with AI coding assistantsOfficial integration patterns for Mapbox Maps SDK on Android. Covers installation, adding markers, user location, custom data, styles, camera control, and featureset interactions. Based on official Mapbox documentation.
# Mapbox Android Integration Patterns
Official patterns for integrating Mapbox Maps SDK v11 on Android with Kotlin, Jetpack Compose, and View system.
**Use this skill when:**
- Installing and configuring Mapbox Maps SDK for Android
- Adding markers and annotations to maps
- Showing user location and tracking with camera
- Adding custom data (GeoJSON) to maps
- Working with map styles, camera, or user interaction
- Handling feature interactions and taps
**Official Resources:**
- [Android Maps Guides](https://docs.mapbox.com/android/maps/guides/)
- [API Reference](https://docs.mapbox.com/android/maps/api-reference/)
- [Example Apps](https://github.com/mapbox/mapbox-maps-android/tree/main/Examples)
---
## Installation & Setup
### Requirements
- Android SDK 21+
- Kotlin or Java
- Android Studio
- Free Mapbox account
### Step 1: Configure Access Token
Create `app/res/values/mapbox_access_token.xml`:
```xml
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="mapbox_access_token" translatable="false"
tools:ignore="UnusedResources">YOUR_MAPBOX_ACCESS_TOKEN</string>
</resources>
```
**Get your token:** Sign in at [mapbox.com](https://account.mapbox.com/access-tokens/)
### Step 2: Add Maven Repository
In `settings.gradle.kts`:
```kotlin
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven {
url = uri("https://api.mapbox.com/downloads/v2/releases/maven")
}
}
}
```
### Step 3: Add Dependency
In module `build.gradle.kts`:
```kotlin
android {
defaultConfig {
minSdk = 21
}
}
dependencies {
implementation("com.mapbox.maps:android:11.18.1")
}
```
**For Jetpack Compose:**
```kotlin
dependencies {
implementation("com.mapbox.maps:android:11.18.1")
implementation("com.mapbox.extension:maps-compose:11.18.1")
}
```
---
## Map Initialization
### Jetpack Compose Pattern
**Basic map:**
```kotlin
import androidx.compose.runtime.*
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
import com.mapbox.maps.extension.compose.*
import com.mapbox.maps.Style
import com.mapbox.geojson.Point
@Composable
fun MapScreen() {
MapboxMap(
modifier = Modifier.fillMaxSize()
) {
// Initialize camera via MapEffect (Style.STANDARD loads by default)
MapEffect(Unit) { mapView ->
// Set initial camera position
mapView.mapboxMap.setCamera(
CameraOptions.Builder()
.center(Point.fromLngLat(-122.4194, 37.7749))
.zoom(12.0)
.build()
)
}
}
}
```
**With ornaments:**
```kotlin
MapboxMap(
modifier = Modifier.fillMaxSize(),
scaleBar = {
ScaleBar(
enabled = true,
position = Alignment.BottomStart
)
},
compass = {
Compass(enabled = true)
}
) {
// Style.STANDARD loads by default
}
```
### View System Pattern
**Layout XML (activity_map.xml):**
```xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.mapbox.maps.MapView
android:id="@+id/mapView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
```
**Activity:**
```kotlin
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.mapbox.maps.MapView
import com.mapbox.maps.Style
import com.mapbox.geojson.Point
class MapActivity : AppCompatActivity() {
private lateinit var mapView: MapView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_map)
mapView = findViewById(R.id.mapView)
mapView.mapboxMap.setCamera(
CameraOptions.Builder()
.center(Point.fromLngLat(-122.4194, 37.7749))
.zoom(12.0)
.build()
)
mapView.mapboxMap.loadStyle(Style.STANDARD)
}
override fun onStart() {
super.onStart()
mapView.onStart()
}
override fun onStop() {
super.onStop()
mapView.onStop()
}
override fun onDestroy() {
super.onDestroy()
mapView.onDestroy()
}
}
```
---
## Add Markers (Annotations)
### Point Annotations (Markers)
Point annotations are the most common way to mark locations on the map.
**Jetpack Compose:**
```kotlin
MapboxMap(modifier = Modifier.fillMaxSize()) {
MapEffect(Unit) { mapView ->
// Load style first
mapView.mapboxMap.loadStyle(Style.STANDARD)
// Create annotation manager and add markers
val annotationManager = mapView.annotations.createPointAnnotationManager()
val pointAnnotation = PointAnnotationOptions()
.withPoint(Point.fromLngLat(-122.4194, 37.7749))
.withIconImage("custom-marker")
annotationManager.create(pointAnnotation)
}
}
// Note: Compose doesn't have declarative PointAnnotation component
// Markers must be added imperatively via MapEffect
```
**View System:**
```kotlin
// Create annotation manager (once, reuse for updates)
val pointAnnotationManager = mapView.annotations.createPointAnnotationManager()
// Create marker
val pointAnnotation = PointAnnotationOptions()
.withPoint(Point.fromLngLat(-122.4194, 37.7749))
.withIconImage("custom-marker")
pointAnnotationManager.create(pointAnnotation)
```
**Multiple markers:**
```kotlin
val locations = listOf(
Point.fromLngLat(-122.4194, 37.7749),
Point.fromLngLat(-122.4094, 37.7849),
Point.fromLngLat(-122.4294, 37.7649)
)
val annotations = locations.map { point ->
PointAnnotationOptions()
.withPoint(point)
.withIconImage("marker")
}
pointAnnotationManager.create(annotations)
```
### Circle Annotations
```kotlin
val circleAnnotationManager = mapView.annotations.createCircleAnnotationManager()
val circle = CircleAnnotationOptions()
.withPoint(Point.fromLngLat(-122.4194, 37.7749))
.withCircleRadius(10.0)
.withCircleColor("#FF0000")
circleAnnotationManager.create(circle)
```
### Polyline Annotations
```kotlin
val polylineAnnotationManager = mapView.annotations.createPolylineAnnotationManager()
val polyline = PolylineAnnotationOptions()
.withPoints(listOf(point1, point2, point3))
.withLineColor("#0000FF")
.withLineWidth(4.0)
polylineAnnotationManager.create(polyline)
```
### Polygon Annotations
```kotlin
val polygonAnnotationManager = mapView.annotations.createPolygonAnnotationManager()
val points = listOf(listOf(coord1, coord2, coord3, coord1)) // Close the polygon
val polygon = PolygonAnnotationOptions()
.withPoints(points)
.withFillColor("#0000FF")
.withFillOpacity(0.5)
polygonAnnotationManager.create(polygon)
```
---
## Show User Location
### Display User Location
**Step 1: Add permissions to AndroidManifest.xml:**
```xml
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
```
**Step 2: Request permissions and show location:**
```kotlin
// Request permissions first (use ActivityResultContracts)
// Show location puck
mapView.location.updateSettings {
enabled = true
puckBearingEnabled = true
}
```
### Camera Follow User Location
To make the camera follow the user's location as they move:
```kotlin
class MapActivity : AppCompatActivity() {
private lateinit var mapView: MapView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_map)
mapView = findViewById(R.id.mapView)
mapView.mapboxMap.loadStyle(Style.STANDARD)
setupLocationTracking()
}
private fun setupLocationTracking() {
// Request permissions first (use ActivityResultContracts)
// Show user location
mapView.location.updateSettings {
enabled = true
puckBearingEnabled = true
}
// Follow user location with camera
mapView.location.addOnIndicatorPositionChangedListener { point ->
mapView.camera.easeTo(
CameraOptions.Builder()
.center(point)
.zoom(15.0)
.pitch(45.0)
.build(),
MapAnimationOptions.Builder()
.duration(1000)
.build()
)
}
// Optional: Follow bearing (direction) as well
mapView.location.addOnIndicatorBearingChangedListener { bearing ->
mapView.camera.easeTo(
CameraOptions.Builder()
.bearing(bearing)
.build(),
MapAnimationOptions.Builder()
.duration(1000)
.build()
)
}
}
override fun onStart() {
super.onStart()
mapView.onStart()
}
override fun onStop() {
super.onStop()
mapView.onStop()
}
override fun onDestroy() {
super.onDestroy()
mapView.onDestroy()
}
}
```
### Get Current Location Once
```kotlin
mapView.location.getLastLocation { location ->
location?.let {
val point = Point.fromLngLat(it.longitude, it.latitude)
mapView.camera.easeTo(
CameraOptions.Builder()
.center(point)
.zoom(14.0)
.build()
)
}
}
```
---
## Add Custom Data (GeoJSON)
Add your own data to the map using GeoJSON sources and layers.
### Add Line (Route, Path)
```kotlin
// Create coordinates for the line
val routeCoordinates = listOf(
Point.fromLngLat(-122.4194, 37.7749),
Point.fromLngLat(-122.4094, 37.7849),
Point.fromLngLat(-122.3994, 37.7949)
)
// Create GeoJSON source
val geoJsonSource = geoJsonSource("route-source") {
geometry(LineString.fromLngLats(routeCoordinates))
}
mapView.mapboxMap.style?.addSource(geoJsonSource)
// Create line layer
val lineLayer = lineLayer("route-layer", "route-source") {
lineColor(Color.BLUE)
lineWidth(4.0)
lineCap(LineCap.ROUND)
lineJoin(LineJoin.ROUND)
}
mapView.mapboxMap.style?.addLayer(lineLayer)
```
### Add Polygon (Area)
```kotlin
val polygonCoordinates = listOf(
listOf(coord1, coord2, coord3, coord1) // Close the polygon
)
val geoJsonSource = geoJsonSource("area-source") {
geometry(Polygon.fromLngLats(polygonCoordinates))
}
mapView.mapboxMap.style?.addSource(geoJsonSource)
val fillLayer = fillLayer("area-fill", "area-source") {
fillColor(Color.parseColor("#0000FF"))
fillOpacity(0.3)
fillOutlineColor(Color.parseColor("#0000FF"))
}
mapView.mapboxMap.style?.addLayer(fillLayer)
```
### Add Points from GeoJSON
```kotlin
val geojsonString = """
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [-122.4194, 37.7749]},
"properties": {"name": "Location 1"}
},
{
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [-122.4094, 37.7849]},
"properties": {"name": "Location 2"}
}
]
}
"""
val geoJsonSource = geoJsonSource("points-source") {
data(geojsonString)
}
mapView.mapboxMap.style?.addSource(geoJsonSource)
val symbolLayer = symbolLayer("points-layer", "points-source") {
iconImage("marker")
textField(Expression.get("name"))
textOffset(listOf(0.0, 1.5))
}
mapView.mapboxMap.style?.addLayer(symbolLayer)
```
### Update Layer Properties
```kotlin
mapView.mapboxMap.style?.getLayerAs<LineLayer>("route-layer")?.let { layer ->
layer.lineColor(Color.RED)
layer.lineWidth(6.0)
}
```
### Remove Layers and Sources
```kotlin
mapView.mapboxMap.style?.removeStyleLayer("route-layer")
mapView.mapboxMap.style?.removeStyleSource("route-source")
```
---
## Camera Control
### Set Camera Position
```kotlin
// Compose - Update camera state
cameraState.position = CameraPosition(
center = Point.fromLngLat(-74.0060, 40.7128),
zoom = 14.0,
bearing = 90.0,
pitch = 60.0
)
// Views - Immediate
mapView.mapboxMap.setCamera(
CameraOptions.Builder()
.center(Point.fromLngLat(-74.0060, 40.7128))
.zoom(14.0)
.bearing(90.0)
.pitch(60.0)
.build()
)
```
### Animated Camera Transitions
```kotlin
// Fly animation (dramatic arc)
mapView.camera.flyTo(
CameraOptions.Builder()
.center(destination)
.zoom(15.0)
.build(),
MapAnimationOptions.Builder()
.duration(2000)
.build()
)
// Ease animation (smooth)
mapView.camera.easeTo(
CameraOptions.Builder()
.center(destination)
.zoom(15.0)
.build(),
MapAnimationOptions.Builder()
.duration(1000)
.build()
)
```
### Fit Camera to Coordinates
```kotlin
val coordinates = listOf(coord1, coord2, coord3)
val camera = mapView.mapboxMap.cameraForCoordinates(
coordinates,
EdgeInsets(50.0, 50.0, 50.0, 50.0),
bearing = 0.0,
pitch = 0.0
)
mapView.camera.easeTo(camera)
```
---
## Map Styles
### Built-in Styles
```kotlin
// Compose - load style via MapEffect
MapboxMap(modifier = Modifier.fillMaxSize()) {
MapEffect(Unit) { mapView ->
// Style.STANDARD loads by default, explicit loading only needed for other styles
// mapView.mapboxMap.loadStyle(Style.STREETS) // Mapbox Streets
// mapView.mapboxMap.loadStyle(Style.OUTDOORS) // Mapbox Outdoors
// mapView.mapboxMap.loadStyle(Style.LIGHT) // Mapbox Light
// mapView.mapboxMap.loadStyle(Style.DARK) // Mapbox Dark
// mapView.mapboxMap.loadStyle(Style.STANDARD_SATELLITE) // Satellite imagery
// mapView.mapboxMap.loadStyle(Style.SATELLITE_STREETS) // Satellite + streets
}
}
// Views
mapView.mapboxMap.loadStyle(Style.STANDARD)
mapView.mapboxMap.loadStyle(Style.DARK)
```
### Custom Style URL
```kotlin
val customStyleUrl = "mapbox://styles/username/style-id"
// Compose
MapboxMap(modifier = Modifier.fillMaxSize()) {
MapEffect(Unit) { mapView ->
mapView.mapboxMap.loadStyle(customStyleUrl)
}
}
// Views
mapView.mapboxMap.loadStyle(customStyleUrl)
```
---
## User Interaction & Feature Taps
### Featureset Interactions (Recommended)
The modern Interactions API allows handling taps on map features with typed feature access. Works with Standard Style predefined featuresets like POIs, buildings, and place labels.
**View System Pattern:**
```kotlin
import com.mapbox.maps.interactions.ClickInteraction
class MapActivity : AppCompatActivity() {
private lateinit var mapView: MapView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_map)
mapView = findViewById(R.id.mapView)
mapView.mapboxMap.loadStyle(Style.STANDARD)
setupFeatureInteractions()
}
private fun setupFeatureInteractions() {
// Tap on POI features
mapView.mapboxMap.addInteraction(
ClickInteraction.standardPoi { poi, context ->
Log.d("MapTap", "Tapped POI: ${poi.name}")
true // Stop propagation
}
)
// Tap on buildings
mapView.mapboxMap.addInteraction(
ClickInteraction.standardBuildings { building, context ->
Log.d("MapTap", "Tapped building")
// Highlight the building
mapView.mapboxMap.setFeatureState(
building,
StandardBuildingsState {
highlight(true)
}
)
true
}
)
}
override fun onStart() {
super.onStart()
mapView.onStart()
}
override fun onStop() {
super.onStop()
mapView.onStop()
}
override fun onDestroy() {
super.onDestroy()
mapView.onDestroy()
}
}
```
**Jetpack Compose Pattern:**
````kotlin
@Composable
fun MapScreen() {
MapboxMap(modifier = Modifier.fillMaxSize()) {
MapEffect(Unit) { mapView ->
// Load Standard style
mapView.mapboxMap.loadStyle(Style.STANDARD)
// Add featureset interactions using View system API
mapView.mapboxMap.addInteraction(
ClickInteraction.standardPoi { poi, context ->
Log.d("MapTap", "Tapped POI: ${poi.name}")
true
}
)
mapView.mapboxMap.addInteraction(
ClickInteraction.standardBuildings { building, context ->
Log.d("MapTap", "Tapped building")
mapView.mapboxMap.setFeatureState(
building,
state = mapOf("select" to true)
)
true
}
)
}
}
}
// Note: Featureset interactions in Compose use MapEffect to access
// the underlying MapView and use the View system interaction API
### Tap on Custom Layers
```kotlin
mapView.mapboxMap.addInteraction(
ClickInteraction.layer("custom-layer-id") { feature, context ->
Log.d("MapTap", "Feature properties: ${feature.properties()}")
true
}
)
````
### Long Press Interactions
```kotlin
import com.mapbox.maps.interactions.LongClickInteraction
mapView.mapboxMap.addInteraction(
LongClickInteraction.standardPoi { poi, context ->
Log.d("MapTap", "Long pressed POI: ${poi.name}")
true
}
)
```
### Handle Map Clicks (Empty Space)
```kotlin
mapView.gestures.addOnMapClickListener { point ->
Log.d("MapClick", "Tapped at: ${point.latitude()}, ${point.longitude()}")
true // Consume event
}
```
### Gesture Configuration
```kotlin
// Disable specific gestures
mapView.gestures.pitchEnabled = false
mapView.gestures.rotateEnabled = false
// Configure zoom limits
mapView.mapboxMap.setCamera(
CameraOptions.Builder()
.zoom(12.0)
.build()
)
```
---
## Performance Best Practices
### Reuse Annotation Managers
```kotlin
// ❌ Don't create new managers repeatedly
fun updateMarkers() {
val manager = mapView.annotations.createPointAnnotationManager()
manager.create(markers)
}
// ✅ Create once, reuse
val pointAnnotationManager = mapView.annotations.createPointAnnotationManager()
fun updateMarkers() {
pointAnnotationManager.deleteAll()
pointAnnotationManager.create(markers)
}
```
### Batch Annotation Updates
```kotlin
// ✅ Create all at once
pointAnnotationManager.create(allAnnotations)
// ❌ Don't create one by one
allAnnotations.forEach { annotation ->
pointAnnotationManager.create(annotation)
}
```
### Lifecycle Management
```kotlin
// Always call lifecycle methods
override fun onStart() {
super.onStart()
mapView.onStart()
}
override fun onStop() {
super.onStop()
mapView.onStop()
}
override fun onDestroy() {
super.onDestroy()
mapView.onDestroy()
}
```
### Use Standard Style
```kotlin
// ✅ Standard style is optimized and recommended
Style.STANDARD
// Use other styles only when needed for specific use cases
Style.STANDARD_SATELLITE // Satellite imagery
```
---
## Troubleshooting
### Map Not Displaying
**Check:**
1. ✅ Token in `mapbox_access_token.xml`
2. ✅ Token is valid (test at mapbox.com)
3. ✅ Maven repository configured
4. ✅ Dependency added correctly
5. ✅ Internet permission in manifest
### Style Not Loading
```kotlin
mapView.mapboxMap.subscribeStyleLoaded { _ ->
Log.d("Map", "Style loaded successfully")
// Add layers and sources here
}
```
### Performance Issues
- Use `Style.STANDARD` (recommended and optimized)
- Limit visible annotations to viewport
- Reuse annotation managers
- Avoid frequent style reloads
- Call lifecycle methods (onStart, onStop, onDestroy)
- Batch annotation updates
---
## Additional Resources
- [Android Maps Guides](https://docs.mapbox.com/android/maps/guides/)
- [API Reference](https://docs.mapbox.com/android/maps/api/11.18.1/)
- [Interactions Guide](https://docs.mapbox.com/android/maps/guides/user-interaction/interactions/)
- [Jetpack Compose Guide](https://docs.mapbox.com/android/maps/guides/using-jetpack-compose/)
- [Example Apps](https://github.com/mapbox/mapbox-maps-android/tree/main/Examples)
- [Migration Guide (v10 → v11)](https://docs.mapbox.com/android/maps/guides/migrate-to-v11/)Expert guidance on map design principles, color theory, visual hierarchy, typography, and cartographic best practices for creating effective and beautiful maps with Mapbox. Use when designing map styles, choosing colors, or making cartographic decisions.
# Mapbox Cartography Skill
This skill provides expert cartographic knowledge to help you design effective, beautiful, and functional maps using Mapbox.
## Core Cartographic Principles
### Visual Hierarchy
Maps must guide the viewer's attention to what matters most:
- **Most important**: POIs, user location, route highlights
- **Secondary**: Major roads, city labels, landmarks
- **Tertiary**: Minor streets, administrative boundaries
- **Background**: Water, land use, terrain
**Implementation:**
- Use size, color intensity, and contrast to establish hierarchy
- Primary features: high contrast, larger symbols, bold colors
- Background features: low contrast, muted colors, smaller text
### Color Theory for Maps
**Color Harmony:**
- **Analogous colors**: Use colors next to each other on color wheel (blue-green-teal) for cohesive designs
- **Complementary colors**: Use opposite colors (blue/orange, red/green) for high contrast emphasis
- **Monochromatic**: Single hue with varying saturation/brightness for elegant, minimal designs
**Color Psychology:**
- **Blue**: Water, trust, calm, professional (default for water bodies)
- **Green**: Parks, nature, growth, eco-friendly (vegetation, parks)
- **Red/Orange**: Urgent, important, dining (alerts, restaurants)
- **Yellow**: Caution, highlight, attention (warnings, selected items)
- **Gray**: Neutral, background, roads (infrastructure)
**Accessibility:**
- Ensure 4.5:1 contrast ratio for text (WCAG AA)
- Don't rely solely on color to convey information
- Test designs with colorblind simulators
- Avoid red/green combinations for critical distinctions
### Typography Best Practices
**Font Selection:**
- **Sans-serif** (Roboto, Open Sans): Modern, clean, high legibility at small sizes - use for labels
- **Serif** (Noto Serif): Traditional, formal - use sparingly for titles or historic maps
- **Monospace**: Technical data, coordinates
**Text Sizing:**
```
Place labels (cities, POIs): 11-14px
Street labels: 9-11px
Feature labels (parks): 10-12px
Map title: 16-20px
Attribution: 8-9px
```
**Label Placement:**
- Point labels: Center or slightly offset (avoid overlap with symbol)
- Line labels: Follow line curve, repeat for long features
- Area labels: Center in polygon, sized appropriately
- Prioritize: Major features get labels first, minor features labeled if space allows
### Map Context Considerations
**Know Your Audience:**
- **General public**: Simplify, use familiar patterns (Google/Apple style)
- **Technical users**: Include more detail, technical layers, data precision
- **Domain experts**: Show specialized data, use domain-specific symbology
**Use Case Optimization:**
- **Navigation**: Emphasize roads, clear hierarchy, route visibility
- **Data visualization**: Muted base map, let data stand out
- **Storytelling**: Guide viewer attention, establish mood with colors
- **Location selection**: Show POIs clearly, provide context
- **Analysis**: Include relevant layers, maintain clarity at different zooms
**Platform Considerations:**
- **Mobile**: Larger touch targets (44x44px minimum), simpler designs, readable at arm's length
- **Desktop**: Can include more detail, hover interactions, complex overlays
- **Print**: Higher contrast, larger text, consider CMYK color space
- **Outdoor/Bright**: Higher contrast, avoid subtle grays
## Mapbox-Specific Guidance
### Style Layer Best Practices
**Layer Ordering (bottom to top):**
1. Background (solid color or pattern)
2. Landuse (parks, residential, commercial)
3. Water bodies (oceans, lakes, rivers)
4. Terrain/hillshade (if using elevation)
5. Buildings (3D or 2D footprints)
6. Roads (highways → local streets)
7. Borders (country, state lines)
8. Labels (place names, street names)
9. POI symbols
10. User-generated content (routes, markers)
> **Common mistake:** Developers often put their app's route line or active markers _below_ POI symbols, reasoning that "POIs must stay visible." This is backwards — user-generated content (your route, selected location, user position) is the most important layer and must render above everything, including POIs. A route line that covers a POI icon is acceptable; a route obscured by POI icons is not.
### Zoom Level Strategy
**Zoom 0-4** (World to Continent):
- Major country boundaries
- Ocean and sea labels
- Capital cities only
**Zoom 5-8** (Country to State):
- State/province boundaries
- Major cities
- Major highways
- Large water bodies
**Zoom 9-11** (Metro Area):
- City boundaries
- Neighborhoods
- All highways and major roads
- Parks and landmarks
**Zoom 12-15** (Neighborhood):
- All streets
- Building footprints
- POIs (restaurants, shops)
- Street names
> **Note:** Mapbox's hosted Streets style defaults to showing most POIs around zoom 14. For custom styles, start POIs at zoom 12 — this is the neighborhood scale where density is manageable and users are browsing. Zoom 14 is late; zoom 10 (metro-area scale) is far too early and creates severe icon clutter.
**Zoom 16-22** (Street Level):
- All detail
- House numbers
- Parking lots
- Fine-grained POIs
### Color Palette Templates
**Light Theme (Day/Professional):**
```json
{
"background": "#f5f5f5",
"water": "#a0c8f0",
"parks": "#d4e7c5",
"roads": "#ffffff",
"buildings": "#e0e0e0",
"text": "#333333"
}
```
**Dark Theme (Night Mode):**
```json
{
"background": "#1a1a1a",
"water": "#0d47a1",
"parks": "#2e7d32",
"roads": "#3a3a3a",
"buildings": "#2d2d2d",
"text": "#ffffff"
}
```
> **Road color rule for dark themes:** Roads must use neutral dark gray (`#3a3a3a`), visibly distinct from the background but not colored. Never style roads with amber, blue, or other hues — reserve color for app data layers (routes, markers). Colored base roads and colored data layers will compete visually. Local roads that blend into the background (`#1e1e1e` on `#1a1a1a`) create a "floating labels" problem where street names appear with no visible road beneath them.
**High Contrast (Accessibility):**
```json
{
"background": "#000000",
"water": "#0066ff",
"parks": "#00ff00",
"roads": "#ffffff",
"buildings": "#808080",
"text": "#ffffff"
}
```
**Vintage/Retro:**
```json
{
"background": "#f4e8d0",
"water": "#b8d4d4",
"parks": "#c8d4a4",
"roads": "#d4c4a8",
"buildings": "#e4d4c4",
"text": "#4a3828"
}
```
## Common Mapping Scenarios
### Scenario: Restaurant Finder App
**Requirements:**
- Restaurants must be highly visible
- Street context for navigation
- Muted background (food photos overlay)
**Recommendations:**
- Use bold, warm colors for restaurant markers (red, orange)
- Gray out background (low saturation)
- Keep street labels clear but not dominant
- High contrast for selected restaurant
- Mobile-optimized touch targets
### Scenario: Real Estate Map
**Requirements:**
- Property boundaries clearly visible
- Neighborhood context
- Price differentiation
**Recommendations:**
- Use color scale for price ranges (green=affordable, red=expensive)
- Show parks and amenities prominently
- Include school zones if relevant
- Label neighborhoods clearly
- Show transit access
### Scenario: Data Visualization Overlay
**Requirements:**
- Data layer is primary focus
- Base map provides context only
- Multiple data points may cluster
**Recommendations:**
- Monochromatic, low-contrast base map
- Use data-ink ratio principle (minimize non-data elements)
- Base map grayscale or single muted hue
- Remove unnecessary labels
- Consider using light base for dark data, vice versa
### Scenario: Navigation/Routing
**Requirements:**
- Route must be unmissable
- Turn-by-turn clarity
- Current location always visible
**Recommendations:**
- Route in high-contrast color (blue or purple)
- Animate route line or use dashed pattern
- Large, clear turn indicators
- Dim unrelated features
- User location: pulsing blue dot
- Next turn: prominent arrow/icon
## Performance Optimization
**Style Performance:**
- Minimize layer count (combine similar layers)
- Use expressions instead of multiple layers for variants
- Simplify complex geometries at lower zooms
- Use sprite sheets for repeated icons
- Leverage tileset simplification
**Loading Speed:**
- Preload critical zoom levels
- Use style optimization tools
- Minimize external resource calls
- Compress images in sprite sheets
## Testing Your Design
**Checklist:**
- [ ] View at all relevant zoom levels
- [ ] Test in different lighting conditions
- [ ] Check on actual devices (mobile, desktop)
- [ ] Verify color accessibility (colorblind.org)
- [ ] Review with target users
- [ ] Test with real data density
- [ ] Check label collision/overlap
- [ ] Verify performance on slower devices
## Common Mistakes to Avoid
1. **Too many colors**: Stick to 5-7 main colors maximum
2. **Insufficient contrast**: Text must be readable
3. **Overcrowding**: Not everything needs a label
4. **Ignoring zoom levels**: Show appropriate detail for scale
5. **Poor label hierarchy**: Organize by importance
6. **Inconsistent styling**: Maintain visual consistency
7. **Neglecting performance**: Complex styles slow rendering
8. **Forgetting mobile**: Test on actual devices
## When to Use This Skill
Invoke this skill when:
- Designing a new map style
- Choosing colors for map elements
- Making decisions about visual hierarchy
- Optimizing for specific use cases
- Troubleshooting visibility issues
- Ensuring accessibility
- Creating themed maps (dark mode, vintage, etc.)Patterns for visualizing data on maps including choropleth maps, heat maps, 3D visualizations, data-driven styling, and animated data. Covers layer types, color scales, and performance optimization.
# Data Visualization Patterns Skill
Comprehensive patterns for visualizing data on Mapbox maps. Covers choropleth maps, heat maps, 3D extrusions, data-driven styling, animated visualizations, and performance optimization for data-heavy applications.
## When to Use This Skill
Use this skill when:
- Visualizing statistical data on maps (population, sales, demographics)
- Creating choropleth maps with color-coded regions
- Building heat maps or clustering for density visualization
- Adding 3D visualizations (building heights, terrain elevation)
- Implementing data-driven styling based on properties
- Animating time-series data
- Working with large datasets that require optimization
## Visualization Types
### Choropleth Maps
**Best for:** Regional data (states, counties, zip codes), statistical comparisons
**Pattern:** Color-code polygons based on data values
```javascript
map.on('load', () => {
// Add data source (GeoJSON with properties)
map.addSource('states', {
type: 'geojson',
data: 'https://example.com/states.geojson' // Features with population property
});
// Add fill layer with data-driven color
map.addLayer({
id: 'states-layer',
type: 'fill',
source: 'states',
paint: {
'fill-color': [
'interpolate',
['linear'],
['get', 'population'],
0,
'#f0f9ff', // Light blue for low population
500000,
'#7fcdff',
1000000,
'#0080ff',
5000000,
'#0040bf', // Dark blue for high population
10000000,
'#001f5c'
],
'fill-opacity': 0.75
}
});
// Add border layer
map.addLayer({
id: 'states-border',
type: 'line',
source: 'states',
paint: {
'line-color': '#ffffff',
'line-width': 1
}
});
// Add hover effect with reusable popup
const popup = new mapboxgl.Popup({
closeButton: false,
closeOnClick: false
});
map.on('mousemove', 'states-layer', (e) => {
if (e.features.length > 0) {
map.getCanvas().style.cursor = 'pointer';
const feature = e.features[0];
popup
.setLngLat(e.lngLat)
.setHTML(
`
<h3>${feature.properties.name}</h3>
<p>Population: ${feature.properties.population.toLocaleString()}</p>
`
)
.addTo(map);
}
});
map.on('mouseleave', 'states-layer', () => {
map.getCanvas().style.cursor = '';
popup.remove();
});
});
```
**Color Scale Strategies:**
```javascript
// Linear interpolation (continuous scale)
'fill-color': [
'interpolate',
['linear'],
['get', 'value'],
0, '#ffffcc',
25, '#78c679',
50, '#31a354',
100, '#006837'
]
// Step intervals (discrete buckets)
'fill-color': [
'step',
['get', 'value'],
'#ffffcc', // Default color
25, '#c7e9b4',
50, '#7fcdbb',
75, '#41b6c4',
100, '#2c7fb8'
]
// Case-based (categorical data)
'fill-color': [
'match',
['get', 'category'],
'residential', '#ffd700',
'commercial', '#ff6b6b',
'industrial', '#4ecdc4',
'park', '#45b7d1',
'#cccccc' // Default
]
```
### Heat Maps
**Best for:** Point density, event locations, incident clustering
**Pattern:** Visualize density of points
```javascript
map.on('load', () => {
// Add data source (points)
map.addSource('incidents', {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [-122.4194, 37.7749]
},
properties: {
intensity: 1
}
}
// ... more points
]
}
});
// Add heatmap layer
map.addLayer({
id: 'incidents-heat',
type: 'heatmap',
source: 'incidents',
maxzoom: 15,
paint: {
// Increase weight based on intensity property
'heatmap-weight': ['interpolate', ['linear'], ['get', 'intensity'], 0, 0, 6, 1],
// Increase intensity as zoom level increases
'heatmap-intensity': ['interpolate', ['linear'], ['zoom'], 0, 1, 15, 3],
// Color ramp for heatmap
'heatmap-color': [
'interpolate',
['linear'],
['heatmap-density'],
0,
'rgba(33,102,172,0)',
0.2,
'rgb(103,169,207)',
0.4,
'rgb(209,229,240)',
0.6,
'rgb(253,219,199)',
0.8,
'rgb(239,138,98)',
1,
'rgb(178,24,43)'
],
// Adjust radius by zoom level
'heatmap-radius': ['interpolate', ['linear'], ['zoom'], 0, 2, 15, 20],
// Decrease opacity at higher zoom levels
'heatmap-opacity': ['interpolate', ['linear'], ['zoom'], 7, 1, 15, 0]
}
});
// Add circle layer for individual points at high zoom
map.addLayer({
id: 'incidents-point',
type: 'circle',
source: 'incidents',
minzoom: 14,
paint: {
'circle-radius': ['interpolate', ['linear'], ['zoom'], 14, 4, 22, 30],
'circle-color': '#ff4444',
'circle-opacity': 0.8,
'circle-stroke-color': '#fff',
'circle-stroke-width': 1
}
});
});
```
### Clustering (Point Density)
**Best for:** Grouping nearby points, aggregated counts, large point datasets
**Pattern:** Client-side clustering for visualization
Clustering is a valuable point density visualization technique alongside heat maps. Use clustering when you want **discrete grouping with exact counts** rather than a continuous density visualization.
```javascript
map.on('load', () => {
// Add data source with clustering enabled
map.addSource('locations', {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: [
// Your point features
]
},
cluster: true,
clusterMaxZoom: 14, // Max zoom to cluster points
clusterRadius: 50 // Radius of each cluster (default 50)
});
// Clustered circles - styled by point count
map.addLayer({
id: 'clusters',
type: 'circle',
source: 'locations',
filter: ['has', 'point_count'],
paint: {
// Color clusters by count (step expression)
'circle-color': ['step', ['get', 'point_count'], '#51bbd6', 10, '#f1f075', 30, '#f28cb1'],
// Size clusters by count
'circle-radius': ['step', ['get', 'point_count'], 20, 10, 30, 30, 40]
}
});
// Cluster count labels
map.addLayer({
id: 'cluster-count',
type: 'symbol',
source: 'locations',
filter: ['has', 'point_count'],
layout: {
'text-field': ['get', 'point_count_abbreviated'],
'text-font': ['DIN Offc Pro Medium', 'Arial Unicode MS Bold'],
'text-size': 12
}
});
// Individual unclustered points
map.addLayer({
id: 'unclustered-point',
type: 'circle',
source: 'locations',
filter: ['!', ['has', 'point_count']],
paint: {
'circle-color': '#11b4da',
'circle-radius': 6,
'circle-stroke-width': 1,
'circle-stroke-color': '#fff'
}
});
// Click handler to expand clusters
map.on('click', 'clusters', (e) => {
const features = map.queryRenderedFeatures(e.point, {
layers: ['clusters']
});
const clusterId = features[0].properties.cluster_id;
// Get cluster expansion zoom
map.getSource('locations').getClusterExpansionZoom(clusterId, (err, zoom) => {
if (err) return;
map.easeTo({
center: features[0].geometry.coordinates,
zoom: zoom
});
});
});
// Change cursor on hover
map.on('mouseenter', 'clusters', () => {
map.getCanvas().style.cursor = 'pointer';
});
map.on('mouseleave', 'clusters', () => {
map.getCanvas().style.cursor = '';
});
});
```
**Advanced: Custom Cluster Properties**
```javascript
map.addSource('locations', {
type: 'geojson',
data: data,
cluster: true,
clusterMaxZoom: 14,
clusterRadius: 50,
// Calculate custom cluster properties
clusterProperties: {
// Sum total values
sum: ['+', ['get', 'value']],
// Calculate max value
max: ['max', ['get', 'value']]
}
});
// Use custom properties in styling
'circle-color': [
'interpolate',
['linear'],
['get', 'sum'],
0,
'#51bbd6',
100,
'#f1f075',
1000,
'#f28cb1'
];
```
**When to use clustering vs heatmaps:**
| Use Case | Clustering | Heatmap |
| -------------------------------- | -------------------------------- | -------------------------- |
| **Visual style** | Discrete circles with counts | Continuous gradient |
| **Interaction** | Click to expand/zoom | Visual density only |
| **Data granularity** | Exact counts visible | Approximate density |
| **Best for** | Store locators, event listings | Crime maps, incident areas |
| **Performance with many points** | Excellent (groups automatically) | Good |
| **User understanding** | Clear (numbered clusters) | Intuitive (heat analogy) |
### 3D Extrusions
**Best for:** Building heights, elevation data, volumetric representation
**Pattern:** Extrude polygons based on data
> **Note:** The example below works with **classic styles only** (`streets-v12`, `dark-v11`, `light-v11`, etc.). The **Mapbox Standard style** includes 3D buildings with much greater detail by default.
```javascript
map.on('load', () => {
// Insert the layer beneath any symbol layer for proper ordering
const layers = map.getStyle().layers;
const labelLayerId = layers.find((layer) => layer.type === 'symbol' && layer.layout['text-field']).id;
// Add 3D buildings from basemap
map.addLayer(
{
id: 'add-3d-buildings',
source: 'composite',
'source-layer': 'building',
filter: ['==', 'extrude', 'true'],
type: 'fill-extrusion',
minzoom: 15,
paint: {
'fill-extrusion-color': '#aaa',
// Smoothly transition height on zoom
'fill-extrusion-height': ['interpolate', ['linear'], ['zoom'], 15, 0, 15.05, ['get', 'height']],
'fill-extrusion-base': ['interpolate', ['linear'], ['zoom'], 15, 0, 15.05, ['get', 'min_height']],
'fill-extrusion-opacity': 0.6
}
},
labelLayerId
);
// Enable pitch and bearing for 3D view
map.setPitch(45);
map.setBearing(-17.6);
});
```
**Using Custom Data Source:**
```javascript
map.on('load', () => {
// Add your own buildings data
map.addSource('custom-buildings', {
type: 'geojson',
data: 'https://example.com/buildings.geojson'
});
// Add 3D buildings layer
map.addLayer({
id: '3d-custom-buildings',
type: 'fill-extrusion',
source: 'custom-buildings',
paint: {
// Height in meters
'fill-extrusion-height': ['get', 'height'],
// Base height if building on terrain
'fill-extrusion-base': ['get', 'base_height'],
// Color by building type or height
'fill-extrusion-color': [
'interpolate',
['linear'],
['get', 'height'],
0,
'#fafa6e',
50,
'#eca25b',
100,
'#e64a45',
200,
'#a63e3e'
],
'fill-extrusion-opacity': 0.9
}
});
});
```
**Data-Driven 3D Heights:**
```javascript
// Population density visualization
'fill-extrusion-height': [
'interpolate',
['linear'],
['get', 'density'],
0, 0,
1000, 500, // 1000 people/sq mi = 500m height
10000, 5000
]
// Revenue visualization (scale for visibility)
'fill-extrusion-height': [
'*',
['get', 'revenue'],
0.001 // Scale factor
]
```
### Circle/Bubble Maps
**Best for:** Point data with magnitude, proportional symbols
**Pattern:** Size circles based on data values
```javascript
map.on('load', () => {
map.addSource('earthquakes', {
type: 'geojson',
data: 'https://example.com/earthquakes.geojson'
});
// Size by magnitude, color by depth
map.addLayer({
id: 'earthquakes',
type: 'circle',
source: 'earthquakes',
paint: {
// Size circles by magnitude
'circle-radius': ['interpolate', ['exponential', 2], ['get', 'mag'], 0, 2, 5, 20, 8, 100],
// Color by depth
'circle-color': [
'interpolate',
['linear'],
['get', 'depth'],
0,
'#ffffcc',
50,
'#a1dab4',
100,
'#41b6c4',
200,
'#2c7fb8',
300,
'#253494'
],
'circle-stroke-color': '#ffffff',
'circle-stroke-width': 1,
'circle-opacity': 0.75
}
});
// Add popup on click
map.on('click', 'earthquakes', (e) => {
const props = e.features[0].properties;
new mapboxgl.Popup()
.setLngLat(e.features[0].geometry.coordinates)
.setHTML(
`
<h3>Magnitude ${props.mag}</h3>
<p>Depth: ${props.depth} km</p>
<p>Time: ${new Date(props.time).toLocaleString()}</p>
`
)
.addTo(map);
});
});
```
### Line Data Visualization
**Best for:** Routes, flows, connections, networks
**Pattern:** Style lines based on data
```javascript
map.on('load', () => {
map.addSource('traffic', {
type: 'geojson',
data: 'https://example.com/traffic.geojson'
});
// Traffic flow with data-driven styling
map.addLayer({
id: 'traffic-lines',
type: 'line',
source: 'traffic',
paint: {
// Width by traffic volume
'line-width': ['interpolate', ['exponential', 2], ['get', 'volume'], 0, 1, 1000, 5, 10000, 15],
// Color by speed (congestion)
'line-color': [
'interpolate',
['linear'],
['get', 'speed'],
0,
'#d73027', // Red: stopped
15,
'#fc8d59', // Orange: slow
30,
'#fee08b', // Yellow: moderate
45,
'#d9ef8b', // Light green: good
60,
'#91cf60', // Green: free flow
75,
'#1a9850'
],
'line-opacity': 0.8
}
});
});
```
## Animated Data Visualizations
### Time-Series Animation
**Pattern:** Animate data over time
```javascript
let currentTime = 0;
const times = [0, 6, 12, 18, 24]; // Hours of day
let animationId;
map.on('load', () => {
map.addSource('hourly-data', {
type: 'geojson',
data: getDataForTime(currentTime)
});
map.addLayer({
id: 'data-layer',
type: 'circle',
source: 'hourly-data',
paint: {
'circle-radius': 8,
'circle-color': ['get', 'color']
}
});
// Animation loop
function animate() {
currentTime = (currentTime + 1) % times.length;
// Update data
map.getSource('hourly-data').setData(getDataForTime(times[currentTime]));
// Update UI
document.getElementById('time-display').textContent = `${times[currentTime]}:00`;
animationId = setTimeout(animate, 1000); // Update every second
}
// Start animation
document.getElementById('play-button').addEventListener('click', () => {
if (animationId) {
clearTimeout(animationId);
animationId = null;
} else {
animate();
}
});
});
function getDataForTime(hour) {
// Fetch or generate data for specific time
return {
type: 'FeatureCollection',
features: data.filter((d) => d.properties.hour === hour)
};
}
```
### Real-Time Data Updates
**Pattern:** Update data from live sources
```javascript
map.on('load', () => {
map.addSource('live-data', {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: []
}
});
map.addLayer({
id: 'live-points',
type: 'circle',
source: 'live-data',
paint: {
'circle-radius': 6,
'circle-color': '#ff4444'
}
});
// Poll for updates every 5 seconds
setInterval(async () => {
const response = await fetch('https://api.example.com/live-data');
const data = await response.json();
// Update source
map.getSource('live-data').setData(data);
}, 5000);
// Or use WebSocket for real-time updates
const ws = new WebSocket('wss://api.example.com/live');
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
map.getSource('live-data').setData(data);
};
});
```
### Smooth Transitions
**Pattern:** Animate property changes
```javascript
// Smoothly transition circle sizes
function updateVisualization(newData) {
map.getSource('data-source').setData(newData);
// Animate circle radius
const currentRadius = map.getPaintProperty('data-layer', 'circle-radius');
const targetRadius = ['get', 'newSize'];
// Use setPaintProperty with transition
map.setPaintProperty('data-layer', 'circle-radius', targetRadius);
// Or use expressions for smooth interpolation
map.setPaintProperty('data-layer', 'circle-radius', ['interpolate', ['linear'], ['get', 'value'], 0, 2, 100, 20]);
}
```
## Performance Optimization
### Vector Tiles vs GeoJSON
**When to use each:**
| Data Size | Format | Reason |
| --------- | ----------------------- | --------------------------------------- |
| < 5 MB | GeoJSON | Simple, no processing needed |
| 5-20 MB | GeoJSON or Vector Tiles | Consider data update frequency |
| > 20 MB | Vector Tiles | Better performance, progressive loading |
**Vector Tile Pattern:**
```javascript
map.addSource('large-dataset', {
type: 'vector',
tiles: ['https://example.com/tiles/{z}/{x}/{y}.mvt'],
minzoom: 0,
maxzoom: 14
});
map.addLayer({
id: 'data-layer',
type: 'fill',
source: 'large-dataset',
'source-layer': 'data-layer-name', // Layer name in the tileset
paint: {
'fill-color': ['get', 'color'],
'fill-opacity': 0.7
}
});
```
### Feature State for Dynamic Styling
**Pattern:** Update styling without modifying geometry
```javascript
map.on('load', () => {
map.addSource('states', {
type: 'geojson',
data: statesData,
generateId: true // Important for feature state
});
map.addLayer({
id: 'states',
type: 'fill',
source: 'states',
paint: {
'fill-color': [
'case',
['boolean', ['feature-state', 'hover'], false],
'#ff0000', // Hover color
'#3b9ddd' // Default color
]
}
});
let hoveredStateId = null;
// Update feature state on hover
map.on('mousemove', 'states', (e) => {
if (e.features.length > 0) {
if (hoveredStateId !== null) {
map.setFeatureState({ source: 'states', id: hoveredStateId }, { hover: false });
}
hoveredStateId = e.features[0].id;
map.setFeatureState({ source: 'states', id: hoveredStateId }, { hover: true });
}
});
map.on('mouseleave', 'states', () => {
if (hoveredStateId !== null) {
map.setFeatureState({ source: 'states', id: hoveredStateId }, { hover: false });
}
hoveredStateId = null;
});
});
```
### Filtering Large Datasets
**Pattern:** Filter data client-side for performance
```javascript
map.on('load', () => {
map.addSource('all-data', {
type: 'geojson',
data: largeDataset
});
map.addLayer({
id: 'filtered-data',
type: 'circle',
source: 'all-data',
filter: ['>=', ['get', 'value'], 50], // Only show values >= 50
paint: {
'circle-radius': 6,
'circle-color': '#ff4444'
}
});
// Update filter dynamically
function updateFilter(minValue) {
map.setFilter('filtered-data', ['>=', ['get', 'value'], minValue]);
}
// Slider for dynamic filtering
document.getElementById('filter-slider').addEventListener('input', (e) => {
updateFilter(parseFloat(e.target.value));
});
});
```
### Progressive Loading
**Pattern:** Load data in chunks as needed
```javascript
// Helper to check if feature is in bounds
function isFeatureInBounds(feature, bounds) {
const coords = feature.geometry.coordinates;
// Handle different geometry types
if (feature.geometry.type === 'Point') {
return bounds.contains(coords);
} else if (feature.geometry.type === 'LineString') {
return coords.some((coord) => bounds.contains(coord));
} else if (feature.geometry.type === 'Polygon') {
return coords[0].some((coord) => bounds.contains(coord));
}
return false;
}
const bounds = map.getBounds();
const visibleData = allData.features.filter((feature) => isFeatureInBounds(feature, bounds));
map.getSource('data-source').setData({
type: 'FeatureCollection',
features: visibleData
});
// Reload on map move with debouncing
let updateTimeout;
map.on('moveend', () => {
clearTimeout(updateTimeout);
updateTimeout = setTimeout(() => {
const bounds = map.getBounds();
const visibleData = allData.features.filter((feature) => isFeatureInBounds(feature, bounds));
map.getSource('data-source').setData({
type: 'FeatureCollection',
features: visibleData
});
}, 150);
});
```
## Legends and UI Controls
### Color Scale Legend
```html
<div class="legend">
<h4>Population Density</h4>
<div class="legend-scale">
<div class="legend-item">
<span class="legend-color" style="background: #f0f9ff;"></span>
<span>0-500</span>
</div>
<div class="legend-item">
<span class="legend-color" style="background: #7fcdff;"></span>
<span>500-1000</span>
</div>
<div class="legend-item">
<span class="legend-color" style="background: #0080ff;"></span>
<span>1000-5000</span>
</div>
<div class="legend-item">
<span class="legend-color" style="background: #001f5c;"></span>
<span>5000+</span>
</div>
</div>
</div>
<style>
.legend {
position: absolute;
bottom: 30px;
right: 10px;
background: white;
padding: 10px;
border-radius: 3px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
font-family: Arial, sans-serif;
font-size: 12px;
}
.legend h4 {
margin: 0 0 10px 0;
font-size: 14px;
}
.legend-item {
display: flex;
align-items: center;
margin-bottom: 5px;
}
.legend-color {
width: 20px;
height: 20px;
margin-right: 10px;
border: 1px solid #ccc;
}
</style>
```
### Interactive Data Inspector
```javascript
map.on('click', 'data-layer', (e) => {
const feature = e.features[0];
const properties = feature.properties;
// Build properties table
const propsTable = Object.entries(properties)
.map(([key, value]) => `<tr><td><strong>${key}:</strong></td><td>${value}</td></tr>`)
.join('');
new mapboxgl.Popup()
.setLngLat(e.lngLat)
.setHTML(
`
<div style="max-width: 300px;">
<h3>Feature Details</h3>
<table style="width: 100%; font-size: 12px;">
${propsTable}
</table>
</div>
`
)
.addTo(map);
});
```
## Best Practices
### Color Accessibility
```javascript
// Use ColorBrewer scales for accessibility
// https://colorbrewer2.org/
// Good: Sequential (single hue)
const sequentialScale = ['#f0f9ff', '#bae4ff', '#7fcdff', '#0080ff', '#001f5c'];
// Good: Diverging (two hues)
const divergingScale = ['#d73027', '#fc8d59', '#fee08b', '#d9ef8b', '#91cf60', '#1a9850'];
// Good: Qualitative (distinct categories)
const qualitativeScale = ['#e41a1c', '#377eb8', '#4daf4a', '#984ea3', '#ff7f00'];
// Avoid: Red-green for color-blind accessibility
// Use: Blue-orange or purple-green instead
```
### Data Preprocessing
```javascript
// Calculate statistical breaks for choropleth
// Using classybrew library (npm install classybrew)
import classybrew from 'classybrew';
function calculateJenksBreaks(values, numClasses) {
const brew = new classybrew();
brew.setSeries(values);
brew.setNumClasses(numClasses);
brew.classify('jenks');
return brew.getBreaks();
}
// Normalize data for better visualization
function normalizeData(features, property) {
const values = features.map((f) => f.properties[property]);
const max = Math.max(...values);
const min = Math.min(...values);
const range = max - min;
// Handle case where all values are the same
if (range === 0) {
return features.map((feature) => ({
...feature,
properties: {
...feature.properties,
normalized: 0.5
}
}));
}
return features.map((feature) => ({
...feature,
properties: {
...feature.properties,
normalized: (feature.properties[property] - min) / range
}
}));
}
```
### Error Handling
```javascript
// Handle missing or invalid data
map.on('load', () => {
map.addSource('data', {
type: 'geojson',
data: dataUrl
});
map.addLayer({
id: 'data-viz',
type: 'fill',
source: 'data',
paint: {
'fill-color': [
'case',
['has', 'value'], // Check if property exists
['interpolate', ['linear'], ['get', 'value'], 0, '#f0f0f0', 100, '#0080ff'],
'#cccccc' // Default color for missing data
]
}
});
// Handle map errors
map.on('error', (e) => {
console.error('Map error:', e.error);
});
});
```
## Common Use Cases
### Election Results Map
```javascript
map.addLayer({
id: 'election-results',
type: 'fill',
source: 'districts',
paint: {
'fill-color': [
'match',
['get', 'winner'],
'democrat',
'#3b82f6',
'republican',
'#ef4444',
'independent',
'#a855f7',
'#94a3b8' // No data
],
'fill-opacity': [
'interpolate',
['linear'],
['get', 'margin'],
0,
0.3, // Close race: light
20,
0.9 // Landslide: dark
]
}
});
```
### COVID-19 Case Map
```javascript
map.addLayer({
id: 'covid-cases',
type: 'fill',
source: 'counties',
paint: {
'fill-color': [
'step',
['/', ['get', 'cases'], ['get', 'population']], // Cases per capita
'#ffffb2',
0.001,
'#fed976',
0.005,
'#feb24c',
0.01,
'#fd8d3c',
0.02,
'#fc4e2a',
0.05,
'#e31a1c',
0.1,
'#b10026'
]
}
});
```
### Real Estate Price Heatmap
```javascript
map.addLayer({
id: 'real-estate',
type: 'circle',
source: 'properties',
paint: {
'circle-radius': ['interpolate', ['exponential', 2], ['get', 'price'], 100000, 5, 1000000, 20, 10000000, 50],
'circle-color': [
'interpolate',
['linear'],
['get', 'price_per_sqft'],
0,
'#ffffcc',
200,
'#a1dab4',
400,
'#41b6c4',
600,
'#2c7fb8',
800,
'#253494'
],
'circle-opacity': 0.6,
'circle-stroke-color': '#ffffff',
'circle-stroke-width': 1
}
});
```
## Resources
- [Mapbox Expression Reference](https://docs.mapbox.com/style-spec/reference/expressions/)
- [ColorBrewer](https://colorbrewer2.org/) - Color scales for maps
- [Turf.js](https://turfjs.org/) - Spatial analysis
- [Simple Statistics](https://simple-statistics.github.io/) - Data classification
- [Data Visualization Tutorials](https://docs.mapbox.com/help/tutorials/#data-visualization)Expert guidance on choosing the right geospatial tool based on problem type, accuracy requirements, and performance needs
# Mapbox Geospatial Operations Skill
Expert guidance for AI assistants on choosing the right geospatial tools from the Mapbox MCP Server. Focuses on selecting tools based on **what the problem requires** - geometric calculations vs routing, straight-line vs road network, and accuracy needs.
## Core Principle: Problem Type Determines Tool Choice
The Mapbox MCP Server provides two categories of geospatial tools:
1. **Offline Geometric Tools** - Use Turf.js for pure geometric/spatial calculations
2. **Routing & Navigation APIs** - Use Mapbox APIs when you need real-world routing, traffic, or travel times
**The key question: What does the problem actually require?**
### Decision Framework
| Problem Characteristic | Tool Category | Why |
| ------------------------------------------------------ | ----------------- | ---------------------------------------- |
| **Straight-line distance** (as the crow flies) | Offline geometric | Accurate for geometric distance |
| **Road/path distance** (as the crow drives) | Routing API | Only routing APIs know road networks |
| **Travel time** | Routing API | Requires routing with speed/traffic data |
| **Point containment** (is X inside Y?) | Offline geometric | Pure geometric operation |
| **Geographic shapes** (buffers, centroids, areas) | Offline geometric | Mathematical/geometric operations |
| **Traffic-aware routing** | Routing API | Requires real-time traffic data |
| **Route optimization** (best order to visit) | Routing API | Complex routing algorithm |
| **High-frequency checks** (e.g., real-time geofencing) | Offline geometric | Instant response, no latency |
## Decision Matrices by Use Case
### Distance Calculations
**User asks: "How far is X from Y?"**
| What They Actually Mean | Tool Choice | Why |
| -------------------------------------------------- | ----------------------------------- | ---------------------------------------- |
| Straight-line distance (as the crow flies) | `distance_tool` | Accurate for geometric distance, instant |
| Driving distance (as the crow drives) | `directions_tool` | Only routing knows actual road distance |
| Walking/cycling distance (as the crow walks/bikes) | `directions_tool` | Need specific path network |
| Travel time | `directions_tool` or `matrix_tool` | Requires routing with speed data |
| Distance with current traffic | `directions_tool` (driving-traffic) | Need real-time traffic consideration |
**Example: "What's the distance between these 5 warehouses?"**
- As the crow flies → `distance_tool` (10 calculations, instant)
- As the crow drives → `matrix_tool` (5×5 matrix, one API call, returns actual route distances)
**Key insight:** Use the tool that matches what "distance" means in context. Always clarify: crow flies or crow drives?
### Proximity and Containment
**User asks: "Which points are near/inside this area?"**
| Query Type | Tool Choice | Why |
| ---------------------------- | ----------------------------------------------------- | ------------------------------------------------------------- |
| "Within X meters radius" | `distance_tool` + filter | Simple geometric radius |
| "Within X minutes drive" | `isochrone_tool` → `point_in_polygon_tool` | Need routing for travel-time zone, then geometric containment |
| "Inside this polygon" | `point_in_polygon_tool` | Pure geometric containment test |
| "Reachable by car in 30 min" | `isochrone_tool` | Requires routing + traffic |
| "Nearest to this point" | `distance_tool` (geometric) or `matrix_tool` (routed) | Depends on definition of "nearest" |
**Example: "Are these 200 addresses in our 30-minute delivery zone?"**
1. Create zone → `isochrone_tool` (routing API - need travel time)
2. Check addresses → `point_in_polygon_tool` (geometric - 200 instant checks)
**Key insight:** Routing for creating travel-time zones, geometric for containment checks
### Routing and Navigation
**User asks: "What's the best route?"**
| Scenario | Tool Choice | Why |
| ----------------------------------- | ----------------------------------- | --------------------------------- |
| A to B directions | `directions_tool` | Turn-by-turn routing |
| Optimal order for multiple stops | `optimization_tool` | Solves traveling salesman problem |
| Clean GPS trace | `map_matching_tool` | Snaps to road network |
| Just need bearing/compass direction | `bearing_tool` | Simple geometric calculation |
| Route with traffic | `directions_tool` (driving-traffic) | Real-time traffic awareness |
| Fixed-order waypoints | `directions_tool` with waypoints | Routing through specific points |
**Example: "Navigate from hotel to airport"**
- Need turn-by-turn → `directions_tool`
- Just need to know "it's northeast" → `bearing_tool`
**Key insight:** Routing tools for actual navigation, geometric tools for directional info
### Area and Shape Operations
**User asks: "Create a zone around this location"**
| Requirement | Tool Choice | Why |
| ------------------------- | ---------------- | ------------------------ |
| Simple circular buffer | `buffer_tool` | Geometric circle/radius |
| Travel-time zone | `isochrone_tool` | Based on routing network |
| Calculate area size | `area_tool` | Geometric calculation |
| Simplify complex boundary | `simplify_tool` | Geometric simplification |
| Find center of shape | `centroid_tool` | Geometric centroid |
**Example: "Show 5km coverage around each store"**
- 5km radius → `buffer_tool` (geometric circles)
- "What customers can reach in 15 min?" → `isochrone_tool` (routing-based)
**Key insight:** Geometric tools for distance-based zones, routing tools for time-based zones
## Performance and Scale Considerations
### When Volume Affects Tool Choice
**Small operations (< 100 calculations):**
- Geometric tools: Instant, iterate freely
- Routing APIs: Fast enough for most uses
**Medium operations (100-1,000 calculations):**
- Geometric tools: Still fast, no concerns
- Routing APIs: Consider batch operations (matrix_tool)
**Large operations (> 1,000 calculations):**
- Geometric tools: May need optimization but still fast
- Routing APIs: Definitely use batch tools (matrix_tool handles up to 25×25)
**Key insight:** Volume rarely affects geometric tool choice, but routing APIs have batch tools for efficiency
### Real-Time vs Batch
| Use Case | Approach | Tool Choice |
| ----------------------------------- | -------------------- | ---------------------------------------- |
| Real-time geofencing (every second) | Geometric checks | `point_in_polygon_tool` (instant) |
| Route planning (one-time) | Full routing | `directions_tool` or `optimization_tool` |
| Periodic proximity checks | Geometric distance | `distance_tool` |
| Live traffic routing | Routing with traffic | `directions_tool` (driving-traffic) |
**Architecture note for high-frequency geofencing:** If the application calls containment checks at very high rates (e.g., 50 vehicles every 2 seconds = 25 checks/second), calling MCP tools over the network adds agent-reasoning overhead that makes it impractical. In those cases, recommend using Turf.js directly in-process (`turf.booleanPointInPolygon`) for the hot path, and reserve MCP tools for peripheral tasks like zone definition (`isochrone_tool`), rerouting (`directions_tool`), or visualization (`static_map_image_tool`).
## Common Scenarios and Optimal Approaches
### Scenario 1: Store Locator
**User: "Find the closest store and show 5km coverage"**
**Optimal approach:**
1. Search stores → `category_search_tool` (returns distances automatically)
2. Create coverage zone → `buffer_tool` (5km geometric circle)
3. Visualize → `static_map_image_tool`
**Why:** Search already gives distances; geometric buffer for simple radius
### Scenario 2: Delivery Route Optimization
**User: "Optimize delivery to 8 addresses"**
**Optimal approach:**
1. Geocode addresses → `search_and_geocode_tool`
2. Optimize route → `optimization_tool` (TSP solver with routing)
**Why:** Need actual routing for turn-by-turn delivery, not geometric distances
### Scenario 3: Service Area Validation
**User: "Which of these 200 addresses can we deliver to in 30 minutes?"**
**Optimal approach:**
1. Create delivery zone → `isochrone_tool` (30-minute driving)
2. Check each address → `point_in_polygon_tool` (200 geometric checks)
**Why:** Routing for accurate travel-time zone, geometric for fast containment checks
### Scenario 4: GPS Trace Analysis
**User: "How long was this bike ride?"**
**Optimal approach:**
1. Clean GPS trace → `map_matching_tool` (snap to bike paths)
2. Get distance → Use API response or calculate with `distance_tool`
**Why:** Need road/path matching; distance calculation either way works
### Scenario 5: Coverage Analysis
**User: "What's our total service area?"**
**Optimal approach:**
1. Create buffers around each location → `buffer_tool`
2. Calculate total area → `area_tool`
3. Or, if time-based → `isochrone_tool` for each location
**Why:** Geometric for distance-based coverage, routing for time-based
## Anti-Patterns: Using the Wrong Tool Type
### ❌ Don't: Use geometric tools for routing questions
```javascript
// WRONG: User asks "how long to drive there?"
distance_tool({ from: A, to: B });
// Returns 10km as the crow flies, but actual drive is 15km
// CORRECT: Need routing for driving distance
directions_tool({
coordinates: [
{ longitude: A[0], latitude: A[1] },
{ longitude: B[0], latitude: B[1] }
],
routing_profile: 'mapbox/driving'
});
// Returns actual road distance and drive time as the crow drives
```
**Why wrong:** As the crow flies ≠ as the crow drives
### ❌ Don't: Use routing APIs for geometric operations
```javascript
// WRONG: Check if point is in polygon
// (Can't do this with routing APIs)
// CORRECT: Pure geometric operation
point_in_polygon_tool({ point: location, polygon: boundary });
```
**Why wrong:** Routing APIs don't do geometric containment
### ❌ Don't: Confuse "near" with "reachable"
```javascript
// User asks: "What's reachable in 20 minutes?"
// WRONG: 20-minute distance at average speed
distance_tool + calculate 20min * avg_speed
// CORRECT: Actual routing with road network
isochrone_tool({
coordinates: {longitude: startLng, latitude: startLat},
contours_minutes: [20],
profile: "mapbox/driving"
})
```
**Why wrong:** Roads aren't straight lines; traffic varies
### ❌ Don't: Use routing when bearing is sufficient
```javascript
// User asks: "Which direction is the airport?"
// OVERCOMPLICATED: Full routing
directions_tool({
coordinates: [
{ longitude: hotel[0], latitude: hotel[1] },
{ longitude: airport[0], latitude: airport[1] }
]
});
// BETTER: Just need bearing
bearing_tool({ from: hotel, to: airport });
// Returns: "Northeast (45°)"
```
**Why better:** Simpler, instant, answers the actual question
## Hybrid Approaches: Combining Tool Types
Some problems benefit from using both geometric and routing tools:
### Pattern 1: Routing + Geometric Filter
```
1. directions_tool → Get route geometry
2. buffer_tool → Create corridor around route
3. category_search_tool → Find POIs in corridor
4. point_in_polygon_tool → Filter to those actually along route
```
**Use case:** "Find gas stations along my route"
### Pattern 2: Routing + Distance Calculation
```
1. category_search_tool → Find 10 nearby locations
2. distance_tool → Calculate straight-line distances (geometric)
3. For top 3, use directions_tool → Get actual driving time
```
**Use case:** Quickly narrow down, then get precise routing for finalists
### Pattern 3: Isochrone + Containment
```
1. isochrone_tool → Create travel-time zone (routing)
2. point_in_polygon_tool → Check hundreds of addresses (geometric)
```
**Use case:** "Which customers are in our delivery zone?"
## Decision Algorithm
When user asks a geospatial question:
```
1. Does it require routing, roads, or travel times?
YES → Use routing API (directions, matrix, isochrone, optimization)
NO → Continue
2. Does it require traffic awareness?
YES → Use directions_tool or isochrone_tool with traffic profile
NO → Continue
3. Is it a geometric/spatial operation?
- Distance between points (straight-line) → distance_tool
- Point containment → point_in_polygon_tool
- Area calculation → area_tool
- Buffer/zone → buffer_tool
- Direction/bearing → bearing_tool
- Geometric center → centroid_tool
- Bounding box → bounding_box_tool
- Simplification → simplify_tool
4. Is it a search/discovery operation?
YES → Use search tools (search_and_geocode, category_search)
```
## Key Decision Questions
Before choosing a tool, ask:
1. **Does "distance" mean as the crow flies or as the crow drives?**
- As the crow flies (straight-line) → geometric tools
- As the crow drives (road distance) → routing APIs
2. **Does the user need travel time?**
- Yes → routing APIs (only they know speeds/traffic)
- No → geometric tools may suffice
3. **Is this about roads/paths or pure spatial relationships?**
- Roads/paths → routing APIs
- Spatial relationships → geometric tools
4. **Does this need to happen in real-time with low latency?**
- Yes + geometric problem → offline tools (instant)
- Yes + routing problem → use routing APIs (still fast)
5. **Is accuracy critical, or is approximation OK?**
- Critical + routing → routing APIs
- Approximation OK → geometric tools may work
## Terminology Guide
Understanding what users mean:
| User Says | Usually Means | Tool Type |
| --------------------- | -------------------------------------------------- | ----------- |
| "Distance" | Context-dependent! Ask: crow flies or crow drives? | Varies |
| "How far" | Often as the crow drives (road distance) | Routing API |
| "Nearby" | Usually as the crow flies (straight-line radius) | Geometric |
| "Close" | Could be either - clarify! | Ask |
| "Reachable" | Travel-time based (crow drives with traffic) | Routing API |
| "Inside/contains" | Geometric containment | Geometric |
| "Navigate/directions" | Turn-by-turn routing | Routing API |
| "Bearing/direction" | Compass direction (crow flies) | Geometric |
## Quick Reference
### Geometric Operations (Offline Tools)
- `distance_tool` - Straight-line distance between two points
- `bearing_tool` - Compass direction from A to B
- `midpoint_tool` - Midpoint between two points
- `point_in_polygon_tool` - Is point inside polygon?
- `area_tool` - Calculate polygon area
- `buffer_tool` - Create circular buffer/zone
- `centroid_tool` - Geometric center of polygon
- `bbox_tool` - Min/max coordinates of geometry
- `simplify_tool` - Reduce geometry complexity
### Routing & Navigation (APIs)
- `directions_tool` - Turn-by-turn routing
- `matrix_tool` - Many-to-many travel times
- `optimization_tool` - Route optimization (TSP)
- `isochrone_tool` - Travel-time zones
- `map_matching_tool` - Snap GPS to roads
### When to Use Each Category
**Use Geometric Tools When:**
- Problem is spatial/mathematical (containment, area, bearing)
- Straight-line distance is appropriate
- Need instant results for real-time checks
- Pure geometry (no roads/traffic involved)
**Use Routing APIs When:**
- Need actual driving/walking/cycling distances
- Need travel times
- Need to consider road networks
- Need traffic awareness
- Need route optimization
- Need turn-by-turn directions
## Integration with Other Skills
**Works with:**
- **mapbox-search-patterns**: Search for locations, then use geospatial operations
- **mapbox-web-performance-patterns**: Optimize rendering of geometric calculations
- **mapbox-token-security**: Ensure requests use properly scoped tokens
## Resources
- [Mapbox MCP Server](https://github.com/mapbox/mcp-server)
- [Turf.js Documentation](https://turfjs.org/) (Powers geometric tools)
- [Mapbox Directions API](https://docs.mapbox.com/api/navigation/directions/)
- [Mapbox Isochrone API](https://docs.mapbox.com/api/navigation/isochrone/)
- [Mapbox Matrix API](https://docs.mapbox.com/api/navigation/matrix/)
- [Mapbox Optimization API](https://docs.mapbox.com/api/navigation/optimization/)Migration guide for developers moving from Google Maps Platform to Mapbox GL JS, covering API equivalents, pattern translations, and key differences
# Mapbox Google Maps Migration Skill
Comprehensive guidance for migrating from Google Maps Platform to Mapbox GL JS. Provides API equivalents, pattern translations, and strategies for successful migration.
## Core Philosophy Differences
### Google Maps: Imperative & Object-Oriented
- Create objects (Marker, Polygon, etc.)
- Add to map with `.setMap(map)`
- Update properties with setters
- Heavy reliance on object instances
### Mapbox GL JS: Declarative & Data-Driven
- Add data sources
- Define layers (visual representation)
- Style with JSON
- Update data, not object properties
**Key Insight:** Mapbox treats everything as data + styling, not individual objects.
## Map Initialization
### Google Maps
```javascript
const map = new google.maps.Map(document.getElementById('map'), {
center: { lat: 37.7749, lng: -122.4194 },
zoom: 12,
mapTypeId: 'roadmap' // or 'satellite', 'hybrid', 'terrain'
});
```
### Mapbox GL JS
```javascript
mapboxgl.accessToken = 'YOUR_MAPBOX_TOKEN';
const map = new mapboxgl.Map({
container: 'map',
style: 'mapbox://styles/mapbox/streets-v12', // or satellite-v9, outdoors-v12
center: [-122.4194, 37.7749], // [lng, lat] - note the order!
zoom: 12
});
```
**Key Differences:**
- **Coordinate order:** Google uses `{lat, lng}`, Mapbox uses `[lng, lat]`
- **Authentication:** Google uses API key in script tag, Mapbox uses access token in code
- **Styling:** Google uses map types, Mapbox uses full style URLs
## API Equivalents Reference
### Map Methods
| Google Maps | Mapbox GL JS | Notes |
| ------------------------ | -------------------------------------- | ----------------------------- |
| `map.setCenter(latLng)` | `map.setCenter([lng, lat])` | Coordinate order reversed |
| `map.getCenter()` | `map.getCenter()` | Returns LngLat object |
| `map.setZoom(zoom)` | `map.setZoom(zoom)` | Same behavior |
| `map.getZoom()` | `map.getZoom()` | Same behavior |
| `map.panTo(latLng)` | `map.panTo([lng, lat])` | Animated pan |
| `map.fitBounds(bounds)` | `map.fitBounds([[lng,lat],[lng,lat]])` | Different bound format |
| `map.setMapTypeId(type)` | `map.setStyle(styleUrl)` | Completely different approach |
| `map.getBounds()` | `map.getBounds()` | Similar |
### Map Events
| Google Maps | Mapbox GL JS | Notes |
| ------------------------------------------------- | ---------------------- | --------------------- |
| `google.maps.event.addListener(map, 'click', fn)` | `map.on('click', fn)` | Simpler syntax |
| `event.latLng` | `event.lngLat` | Event property name |
| `'center_changed'` | `'move'` / `'moveend'` | Different event names |
| `'zoom_changed'` | `'zoom'` / `'zoomend'` | Different event names |
| `'bounds_changed'` | `'moveend'` | No direct equivalent |
| `'mousemove'` | `'mousemove'` | Same |
| `'mouseout'` | `'mouseleave'` | Different name |
## Markers and Points
### Simple Marker
**Google Maps:**
```javascript
const marker = new google.maps.Marker({
position: { lat: 37.7749, lng: -122.4194 },
map: map,
title: 'San Francisco',
icon: 'custom-icon.png'
});
// Remove marker
marker.setMap(null);
```
**Mapbox GL JS:**
```javascript
// Create marker
const marker = new mapboxgl.Marker()
.setLngLat([-122.4194, 37.7749])
.setPopup(new mapboxgl.Popup().setText('San Francisco'))
.addTo(map);
// Remove marker
marker.remove();
```
### Multiple Markers
**Google Maps:**
```javascript
const markers = locations.map(
(loc) =>
new google.maps.Marker({
position: { lat: loc.lat, lng: loc.lng },
map: map
})
);
```
**Mapbox GL JS (Equivalent Approach):**
```javascript
// Same object-oriented approach
const markers = locations.map((loc) => new mapboxgl.Marker().setLngLat([loc.lng, loc.lat]).addTo(map));
```
**Mapbox GL JS (Data-Driven Approach - Recommended for 100+ points):**
```javascript
// Add as GeoJSON source + layer (uses WebGL, not DOM)
map.addSource('points', {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: locations.map((loc) => ({
type: 'Feature',
geometry: { type: 'Point', coordinates: [loc.lng, loc.lat] },
properties: { name: loc.name }
}))
}
});
map.addLayer({
id: 'points-layer',
type: 'circle', // or 'symbol' for icons
source: 'points',
paint: {
'circle-radius': 8,
'circle-color': '#ff0000'
}
});
```
**Performance Advantage:** Google Maps renders all markers as DOM elements (even when using the Data Layer), which becomes slow with 500+ markers. Mapbox's circle and symbol layers are rendered by WebGL, making them much faster for large datasets (1,000-10,000+ points). This is a significant advantage when building applications with many points.
## Info Windows / Popups
### Google Maps
```javascript
const infowindow = new google.maps.InfoWindow({
content: '<h3>Title</h3><p>Content</p>'
});
marker.addListener('click', () => {
infowindow.open(map, marker);
});
```
### Mapbox GL JS
```javascript
// Option 1: Attach to marker
const marker = new mapboxgl.Marker()
.setLngLat([-122.4194, 37.7749])
.setPopup(new mapboxgl.Popup().setHTML('<h3>Title</h3><p>Content</p>'))
.addTo(map);
// Option 2: On layer click (for data-driven markers)
map.on('click', 'points-layer', (e) => {
const coordinates = e.features[0].geometry.coordinates.slice();
const description = e.features[0].properties.description;
new mapboxgl.Popup().setLngLat(coordinates).setHTML(description).addTo(map);
});
```
## Polygons and Shapes
### Google Maps
```javascript
const polygon = new google.maps.Polygon({
paths: [
{ lat: 37.7749, lng: -122.4194 },
{ lat: 37.7849, lng: -122.4094 },
{ lat: 37.7649, lng: -122.4094 }
],
strokeColor: '#FF0000',
strokeOpacity: 0.8,
strokeWeight: 2,
fillColor: '#FF0000',
fillOpacity: 0.35,
map: map
});
```
### Mapbox GL JS
```javascript
map.addSource('polygon', {
type: 'geojson',
data: {
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-122.4194, 37.7749],
[-122.4094, 37.7849],
[-122.4094, 37.7649],
[-122.4194, 37.7749] // Close the ring
]
]
}
}
});
map.addLayer({
id: 'polygon-layer',
type: 'fill',
source: 'polygon',
paint: {
'fill-color': '#FF0000',
'fill-opacity': 0.35
}
});
// Add outline
map.addLayer({
id: 'polygon-outline',
type: 'line',
source: 'polygon',
paint: {
'line-color': '#FF0000',
'line-width': 2,
'line-opacity': 0.8
}
});
```
## Polylines / Lines
### Google Maps
```javascript
const line = new google.maps.Polyline({
path: [
{ lat: 37.7749, lng: -122.4194 },
{ lat: 37.7849, lng: -122.4094 }
],
strokeColor: '#0000FF',
strokeWeight: 3,
map: map
});
```
### Mapbox GL JS
```javascript
map.addSource('route', {
type: 'geojson',
data: {
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: [
[-122.4194, 37.7749],
[-122.4094, 37.7849]
]
}
}
});
map.addLayer({
id: 'route-layer',
type: 'line',
source: 'route',
paint: {
'line-color': '#0000FF',
'line-width': 3
}
});
```
## Custom Icons and Symbols
### Google Maps
```javascript
const marker = new google.maps.Marker({
position: { lat: 37.7749, lng: -122.4194 },
map: map,
icon: {
url: 'marker.png',
scaledSize: new google.maps.Size(32, 32)
}
});
```
### Mapbox GL JS
**Option 1: HTML Marker**
```javascript
const el = document.createElement('div');
el.className = 'marker';
el.style.backgroundImage = 'url(marker.png)';
el.style.width = '32px';
el.style.height = '32px';
new mapboxgl.Marker(el).setLngLat([-122.4194, 37.7749]).addTo(map);
```
**Option 2: Symbol Layer (Better Performance)**
```javascript
// Load image
map.loadImage('marker.png', (error, image) => {
if (error) throw error;
map.addImage('custom-marker', image);
map.addLayer({
id: 'markers',
type: 'symbol',
source: 'points',
layout: {
'icon-image': 'custom-marker',
'icon-size': 1
}
});
});
```
## Geocoding
### Google Maps
```javascript
const geocoder = new google.maps.Geocoder();
geocoder.geocode({ address: '1600 Amphitheatre Parkway' }, (results, status) => {
if (status === 'OK') {
map.setCenter(results[0].geometry.location);
}
});
```
### Mapbox GL JS
```javascript
// Use Mapbox Geocoding API v6
fetch(
`https://api.mapbox.com/search/geocode/v6/forward?q=1600+Amphitheatre+Parkway&access_token=${mapboxgl.accessToken}`
)
.then((response) => response.json())
.then((data) => {
const [lng, lat] = data.features[0].geometry.coordinates;
map.setCenter([lng, lat]);
});
// Or use mapbox-gl-geocoder plugin
const geocoder = new MapboxGeocoder({
accessToken: mapboxgl.accessToken,
mapboxgl: mapboxgl
});
map.addControl(geocoder);
```
## Directions / Routing
### Google Maps
```javascript
const directionsService = new google.maps.DirectionsService();
const directionsRenderer = new google.maps.DirectionsRenderer();
directionsRenderer.setMap(map);
directionsService.route(
{
origin: 'San Francisco, CA',
destination: 'Los Angeles, CA',
travelMode: 'DRIVING'
},
(response, status) => {
if (status === 'OK') {
directionsRenderer.setDirections(response);
}
}
);
```
### Mapbox GL JS
```javascript
// Use Mapbox Directions API
const origin = [-122.4194, 37.7749];
const destination = [-118.2437, 34.0522];
fetch(
`https://api.mapbox.com/directions/v5/mapbox/driving/${origin.join(',')};${destination.join(',')}?geometries=geojson&access_token=${mapboxgl.accessToken}`
)
.then((response) => response.json())
.then((data) => {
const route = data.routes[0].geometry;
map.addSource('route', {
type: 'geojson',
data: {
type: 'Feature',
geometry: route
}
});
map.addLayer({
id: 'route',
type: 'line',
source: 'route',
paint: {
'line-color': '#3887be',
'line-width': 5
}
});
});
// Or use @mapbox/mapbox-gl-directions plugin
const directions = new MapboxDirections({
accessToken: mapboxgl.accessToken
});
map.addControl(directions, 'top-left');
```
## Controls
### Google Maps
```javascript
// Controls are automatic, can configure:
map.setOptions({
zoomControl: true,
mapTypeControl: true,
streetViewControl: false,
fullscreenControl: true
});
```
### Mapbox GL JS
```javascript
// Add controls explicitly
map.addControl(new mapboxgl.NavigationControl()); // Zoom + rotation
map.addControl(new mapboxgl.FullscreenControl());
map.addControl(new mapboxgl.GeolocateControl());
map.addControl(new mapboxgl.ScaleControl());
// Position controls
map.addControl(new mapboxgl.NavigationControl(), 'top-right');
```
## Clustering
### Google Maps
```javascript
// Requires MarkerClusterer library
import MarkerClusterer from '@googlemaps/markerclustererplus';
const markers = locations.map((loc) => new google.maps.Marker({ position: loc, map: map }));
new MarkerClusterer(map, markers, {
imagePath: 'https://developers.google.com/maps/documentation/javascript/examples/markerclusterer/m'
});
```
### Mapbox GL JS
```javascript
// Built-in clustering support
map.addSource('points', {
type: 'geojson',
data: geojsonData,
cluster: true,
clusterMaxZoom: 14,
clusterRadius: 50
});
// Cluster circles
map.addLayer({
id: 'clusters',
type: 'circle',
source: 'points',
filter: ['has', 'point_count'],
paint: {
'circle-color': ['step', ['get', 'point_count'], '#51bbd6', 100, '#f1f075', 750, '#f28cb1'],
'circle-radius': ['step', ['get', 'point_count'], 20, 100, 30, 750, 40]
}
});
// Cluster count labels
map.addLayer({
id: 'cluster-count',
type: 'symbol',
source: 'points',
filter: ['has', 'point_count'],
layout: {
'text-field': '{point_count_abbreviated}',
'text-size': 12
}
});
// Unclustered points
map.addLayer({
id: 'unclustered-point',
type: 'circle',
source: 'points',
filter: ['!', ['has', 'point_count']],
paint: {
'circle-color': '#11b4da',
'circle-radius': 8
}
});
```
**Key Advantage:** Mapbox clustering is built-in and highly performant.
## Styling and Appearance
### Map Types vs. Styles
**Google Maps:**
- Limited map types: roadmap, satellite, hybrid, terrain
- Styling via `styles` array (complex)
**Mapbox GL JS:**
- Full control over every visual element
- Pre-built styles: standard, standard-satellite, streets, outdoors, light, dark
- Custom styles via Mapbox Studio for unique branding and design
- Dynamic styling based on data properties
- For classic styles (pre Mapbox Standard) you can modify style programmatically by using the setPaintProperty()
### Custom Styling Example
**Google Maps:**
```javascript
const styledMapType = new google.maps.StyledMapType(
[
{ elementType: 'geometry', stylers: [{ color: '#242f3e' }] },
{ elementType: 'labels.text.stroke', stylers: [{ color: '#242f3e' }] }
// ... many more rules
],
{ name: 'Dark' }
);
map.mapTypes.set('dark', styledMapType);
map.setMapTypeId('dark');
```
**Mapbox GL JS:**
```javascript
// Use pre-built style
map.setStyle('mapbox://styles/mapbox/dark-v11');
// Or create custom style in Mapbox Studio and reference it
map.setStyle('mapbox://styles/yourusername/your-style-id');
// Modify classic styles programmatically
map.setPaintProperty('water', 'fill-color', '#242f3e');
```
## Data Updates
### Google Maps
```javascript
// Update marker position
marker.setPosition({ lat: 37.7849, lng: -122.4094 });
// Update polygon path
polygon.setPath(newCoordinates);
```
### Mapbox GL JS
```javascript
// Update source data
map.getSource('points').setData(newGeojsonData);
// Or update specific features
const source = map.getSource('points');
const data = source._data;
data.features[0].geometry.coordinates = [-122.4094, 37.7849];
source.setData(data);
```
## Performance Considerations
### Google Maps
- Individual objects for each feature
- Can be slow with 1000+ markers
- Requires MarkerClusterer for performance
### Mapbox GL JS
- Data-driven rendering
- WebGL-based (hardware accelerated)
- Handles 10,000+ points smoothly
- Built-in clustering
**Migration Tip:** If you have performance issues with Google Maps (many markers), Mapbox will likely perform significantly better.
## Common Migration Patterns
### Pattern 1: Store Locator
**Google Maps approach:**
1. Create marker for each store
2. Add click listeners to each marker
3. Show info window on click
**Mapbox approach:**
1. Add all stores as GeoJSON source
2. Add symbol layer for markers
3. Use layer click event for all markers
4. More performant, cleaner code
### Pattern 2: Drawing Tools
**Google Maps:**
- Use Drawing Manager library
- Creates overlay objects
**Mapbox:**
- Use Mapbox Draw plugin
- More powerful, customizable
- Better for complex editing
### Pattern 3: Heatmaps
**Google Maps:**
```javascript
const heatmap = new google.maps.visualization.HeatmapLayer({
data: points,
map: map
});
```
**Mapbox:**
```javascript
map.addLayer({
id: 'heatmap',
type: 'heatmap',
source: 'points',
paint: {
'heatmap-intensity': 1,
'heatmap-radius': 50,
'heatmap-color': ['interpolate', ['linear'], ['heatmap-density'], 0, 'rgba(0,0,255,0)', 0.5, 'lime', 1, 'red']
}
});
```
## Migration Strategy
### Step 1: Audit Current Implementation
Identify all Google Maps features you use:
- [ ] Basic map with markers
- [ ] Info windows/popups
- [ ] Polygons/polylines
- [ ] Geocoding
- [ ] Directions
- [ ] Clustering
- [ ] Custom styling
- [ ] Drawing tools
- [ ] Street View (no Mapbox equivalent)
- [ ] Other advanced features
### Step 2: Set Up Mapbox
```html
<!-- Replace Google Maps script -->
<script src="https://api.mapbox.com/mapbox-gl-js/v3.18.1/mapbox-gl.js"></script>
<link href="https://api.mapbox.com/mapbox-gl-js/v3.18.1/mapbox-gl.css" rel="stylesheet" />
```
### Step 3: Convert Core Map
Start with basic map initialization:
1. Replace `new google.maps.Map()` with `new mapboxgl.Map()`
2. Fix coordinate order (lat,lng → lng,lat)
3. Update zoom/center
### Step 4: Convert Features One by One
Prioritize by complexity:
1. **Easy:** Map controls, basic markers
2. **Medium:** Popups, polygons, lines
3. **Complex:** Clustering, custom styling, data updates
### Step 5: Update Event Handlers
Change event syntax:
- `google.maps.event.addListener()` → `map.on()`
- Update event property names (`latLng` → `lngLat`)
### Step 6: Optimize for Mapbox
Take advantage of Mapbox features:
- Convert multiple markers to data-driven layers
- Use clustering (built-in)
- Leverage vector tiles for custom styling
- Use expressions for dynamic styling
### Step 7: Test Thoroughly
- Cross-browser testing
- Mobile responsiveness
- Performance with real data volumes
- Touch/gesture interactions
## Gotchas and Common Issues
### ❌ Coordinate Order
```javascript
// Google Maps
{ lat: 37.7749, lng: -122.4194 }
// Mapbox (REVERSED!)
[-122.4194, 37.7749]
```
**Always double-check coordinate order!**
### ❌ Event Properties
```javascript
// Google Maps
map.on('click', (e) => {
console.log(e.latLng.lat(), e.latLng.lng());
});
// Mapbox
map.on('click', (e) => {
console.log(e.lngLat.lat, e.lngLat.lng);
});
```
### ❌ Timing Issues
```javascript
// Google Maps - immediate
const marker = new google.maps.Marker({ map: map });
// Mapbox - wait for load
map.on('load', () => {
map.addSource(...);
map.addLayer(...);
});
```
### ❌ Removing Features
```javascript
// Google Maps
marker.setMap(null);
// Mapbox - must remove both
map.removeLayer('layer-id');
map.removeSource('source-id');
```
## API Services Comparison
| Service | Google Maps | Mapbox | Notes |
| --------------------- | ------------------- | -------------- | -------------------------------- |
| **Geocoding** | Geocoding API | Geocoding API | Similar capabilities |
| **Reverse Geocoding** | ✅ | ✅ | Similar |
| **Directions** | Directions API | Directions API | Mapbox has traffic-aware routing |
| **Distance Matrix** | Distance Matrix API | Matrix API | Similar |
| **Isochrones** | ❌ | ✅ | Mapbox exclusive |
| **Optimization** | ❌ | ✅ | Mapbox exclusive (TSP) |
| **Street View** | ✅ | ❌ | Google exclusive |
| **Static Maps** | ✅ | ✅ | Both supported |
| **Satellite Imagery** | ✅ | ✅ | Both supported |
| **Tilesets** | Limited | Full API | Mapbox more flexible |
## Pricing Differences
### Google Maps Platform
- Charges per API call
- Free tier: $200/month credit
- Different rates for different APIs
- Can get expensive with high traffic
### Mapbox
- Charges per map load
- Free tier: 50,000 map loads/month
- Unlimited API requests per map session
- More predictable costs
**Migration Tip:** Understand how pricing models differ for your use case.
## Plugins and Extensions
### Google Maps Plugins → Mapbox Alternatives
| Google Maps Plugin | Mapbox Alternative |
| ------------------ | ---------------------------- |
| MarkerClusterer | Built-in clustering |
| Drawing Manager | @mapbox/mapbox-gl-draw |
| Geocoder | @mapbox/mapbox-gl-geocoder |
| Directions | @mapbox/mapbox-gl-directions |
| - | @mapbox/mapbox-gl-traffic |
| - | @mapbox/mapbox-gl-compare |
## Framework Integration
### React
**Google Maps:**
```javascript
import { GoogleMap, Marker } from '@react-google-maps/api';
```
**Mapbox:**
```javascript
import Map, { Marker } from 'react-map-gl';
// or
import { useMap } from '@mapbox/mapbox-gl-react';
```
### Vue
**Google Maps:**
```javascript
import { GoogleMap } from 'vue3-google-map';
```
**Mapbox:**
```javascript
import { MglMap } from 'vue-mapbox';
```
See `mapbox-web-integration-patterns` skill for detailed framework guidance.
## Testing Strategy
### Unit Tests
```javascript
// Mock mapboxgl
jest.mock('mapbox-gl', () => ({
Map: jest.fn(() => ({
on: jest.fn(),
addSource: jest.fn(),
addLayer: jest.fn()
})),
Marker: jest.fn()
}));
```
### Integration Tests
- Test map initialization
- Test data loading and updates
- Test user interactions (click, pan, zoom)
- Test API integrations (geocoding, directions)
### Visual Regression Tests
- Compare before/after screenshots
- Ensure visual parity with Google Maps version
## Checklist: Migration Complete
- [ ] Map initializes correctly
- [ ] All markers/features display
- [ ] Click/hover interactions work
- [ ] Popups/info windows display
- [ ] Geocoding integrated
- [ ] Directions/routing working
- [ ] Custom styling applied
- [ ] Controls positioned correctly
- [ ] Mobile/touch gestures work
- [ ] Performance is acceptable
- [ ] Cross-browser tested
- [ ] API keys secured
- [ ] Error handling in place
- [ ] Analytics/monitoring updated
- [ ] Documentation updated
- [ ] Team trained on Mapbox
## When NOT to Migrate
Consider staying with Google Maps if:
- **Street View is critical** - Mapbox doesn't have equivalent
- **Tight Google Workspace integration** - Places API deeply integrated
- **Already heavily optimized** - Migration cost > benefits
- **Team expertise** - Retraining costs too high
- **Short-term project** - Not worth migration effort
## Additional Resources
- [Mapbox GL JS Documentation](https://docs.mapbox.com/mapbox-gl-js/)
- [Official Google Maps to Mapbox Migration Guide](https://docs.mapbox.com/help/tutorials/google-to-mapbox/)
- [Mapbox Examples](https://docs.mapbox.com/mapbox-gl-js/examples/)
- [Style Specification](https://docs.mapbox.com/mapbox-gl-js/style-spec/)
## Integration with Other Skills
**Works with:**
- **mapbox-web-integration-patterns**: Framework-specific migration guidance
- **mapbox-web-performance-patterns**: Optimize after migration
- **mapbox-token-security**: Secure your Mapbox tokens properly
- **mapbox-geospatial-operations**: Use Mapbox's geospatial tools effectively
- **mapbox-search-patterns**: Migrate geocoding/search functionality
## Quick Reference: Side-by-Side Comparison
```javascript
// GOOGLE MAPS
const map = new google.maps.Map(el, {
center: { lat: 37.7749, lng: -122.4194 },
zoom: 12
});
const marker = new google.maps.Marker({
position: { lat: 37.7749, lng: -122.4194 },
map: map
});
google.maps.event.addListener(map, 'click', (e) => {
console.log(e.latLng.lat(), e.latLng.lng());
});
// MAPBOX GL JS
mapboxgl.accessToken = 'YOUR_TOKEN';
const map = new mapboxgl.Map({
container: el,
center: [-122.4194, 37.7749], // REVERSED!
zoom: 12,
style: 'mapbox://styles/mapbox/streets-v12'
});
const marker = new mapboxgl.Marker()
.setLngLat([-122.4194, 37.7749]) // REVERSED!
.addTo(map);
map.on('click', (e) => {
console.log(e.lngLat.lat, e.lngLat.lng);
});
```
**Remember:** lng, lat order in Mapbox!Official integration patterns for Mapbox Maps SDK on iOS. Covers installation, adding markers, user location, custom data, styles, camera control, and featureset interactions. Based on official Mapbox documentation.
# Mapbox iOS Integration Patterns
Official patterns for integrating Mapbox Maps SDK v11 on iOS with Swift, SwiftUI, and UIKit.
**Use this skill when:**
- Installing and configuring Mapbox Maps SDK for iOS
- Adding markers and annotations to maps
- Showing user location and tracking with camera
- Adding custom data (GeoJSON) to maps
- Working with map styles, camera, or user interaction
- Handling feature interactions and taps
**Official Resources:**
- [iOS Maps Guides](https://docs.mapbox.com/ios/maps/guides/)
- [API Reference](https://docs.mapbox.com/ios/maps/api-reference/)
- [Example Apps](https://github.com/mapbox/mapbox-maps-ios/tree/main/Sources/Examples)
---
## Installation & Setup
### Requirements
- iOS 12+
- Xcode 15+
- Swift 5.9+
- Free Mapbox account
### Step 1: Configure Access Token
Add your public token to `Info.plist`:
```xml
<key>MBXAccessToken</key>
<string>pk.your_mapbox_token_here</string>
```
**Get your token:** Sign in at [mapbox.com](https://account.mapbox.com/access-tokens/)
### Step 2: Add Swift Package Dependency
1. **File → Add Package Dependencies**
2. **Enter URL:** `https://github.com/mapbox/mapbox-maps-ios.git`
3. **Version:** "Up to Next Major" from `11.0.0`
4. **Verify** four dependencies appear: MapboxCommon, MapboxCoreMaps, MapboxMaps, Turf
**Alternative:** CocoaPods or direct download ([install guide](https://docs.mapbox.com/ios/maps/guides/install/))
---
## Map Initialization
### SwiftUI Pattern (iOS 13+)
**Basic map:**
```swift
import SwiftUI
import MapboxMaps
struct ContentView: View {
@State private var viewport: Viewport = .camera(
center: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194),
zoom: 12
)
var body: some View {
Map(viewport: $viewport)
.mapStyle(.standard)
}
}
```
**With ornaments:**
```swift
Map(viewport: $viewport)
.mapStyle(.standard)
.ornamentOptions(OrnamentOptions(
scaleBar: .init(visibility: .visible),
compass: .init(visibility: .adaptive),
logo: .init(position: .bottomLeading)
))
```
### UIKit Pattern
```swift
import UIKit
import MapboxMaps
class MapViewController: UIViewController {
private var mapView: MapView!
override func viewDidLoad() {
super.viewDidLoad()
let options = MapInitOptions(
cameraOptions: CameraOptions(
center: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194),
zoom: 12
)
)
mapView = MapView(frame: view.bounds, mapInitOptions: options)
mapView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.addSubview(mapView)
mapView.mapboxMap.loadStyle(.standard)
}
}
```
---
## Add Markers (Annotations)
### Point Annotations (Markers)
Point annotations are the most common way to mark locations on the map.
**SwiftUI:**
```swift
Map(viewport: $viewport) {
PointAnnotation(coordinate: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194))
.iconImage("custom-marker")
}
```
**UIKit:**
```swift
// Create annotation manager (once, reuse for updates)
var pointAnnotationManager = mapView.annotations.makePointAnnotationManager()
// Create marker
var annotation = PointAnnotation(coordinate: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194))
annotation.image = .init(image: UIImage(named: "marker")!, name: "marker")
annotation.iconAnchor = .bottom
// Add to map
pointAnnotationManager.annotations = [annotation]
```
**Multiple markers:**
```swift
let locations = [
CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194),
CLLocationCoordinate2D(latitude: 37.7849, longitude: -122.4094),
CLLocationCoordinate2D(latitude: 37.7649, longitude: -122.4294)
]
let annotations = locations.map { coordinate in
var annotation = PointAnnotation(coordinate: coordinate)
annotation.image = .init(image: UIImage(named: "marker")!, name: "marker")
return annotation
}
pointAnnotationManager.annotations = annotations
```
### Circle Annotations
```swift
var circleAnnotationManager = mapView.annotations.makeCircleAnnotationManager()
var circle = CircleAnnotation(coordinate: coordinate)
circle.circleRadius = 10
circle.circleColor = StyleColor(.red)
circleAnnotationManager.annotations = [circle]
```
### Polyline Annotations
```swift
var polylineAnnotationManager = mapView.annotations.makePolylineAnnotationManager()
let coordinates = [coord1, coord2, coord3]
var polyline = PolylineAnnotation(lineCoordinates: coordinates)
polyline.lineColor = StyleColor(.blue)
polyline.lineWidth = 4
polylineAnnotationManager.annotations = [polyline]
```
### Polygon Annotations
```swift
var polygonAnnotationManager = mapView.annotations.makePolygonAnnotationManager()
let coordinates = [coord1, coord2, coord3, coord1] // Close the polygon
var polygon = PolygonAnnotation(polygon: .init(outerRing: .init(coordinates)))
polygon.fillColor = StyleColor(.blue.withAlphaComponent(0.5))
polygon.fillOutlineColor = StyleColor(.blue)
polygonAnnotationManager.annotations = [polygon]
```
---
## Show User Location
### Display User Location
**Step 1: Add location permission to Info.plist:**
```xml
<key>NSLocationWhenInUseUsageDescription</key>
<string>Show your location on the map</string>
```
**Step 2: Request permissions and show location:**
```swift
import CoreLocation
// Request permissions
let locationManager = CLLocationManager()
locationManager.requestWhenInUseAuthorization()
// Show user location puck
mapView.location.options.puckType = .puck2D()
mapView.location.options.puckBearingEnabled = true
```
### Camera Follow User Location
To make the camera follow the user's location as they move:
```swift
import Combine
class MapViewController: UIViewController {
private var mapView: MapView!
private var cancelables = Set<AnyCancellable>()
override func viewDidLoad() {
super.viewDidLoad()
setupMap()
setupLocationTracking()
}
func setupLocationTracking() {
// Request permissions
let locationManager = CLLocationManager()
locationManager.requestWhenInUseAuthorization()
// Show user location
mapView.location.options.puckType = .puck2D()
mapView.location.options.puckBearingEnabled = true
// Follow user location with camera
mapView.location.onLocationChange.observe { [weak self] locations in
guard let self = self, let location = locations.last else { return }
self.mapView.camera.ease(to: CameraOptions(
center: location.coordinate,
zoom: 15,
bearing: location.course >= 0 ? location.course : nil,
pitch: 45
), duration: 1.0)
}.store(in: &cancelables)
}
}
```
### Get Current Location Once
```swift
if let location = mapView.location.latestLocation {
let coordinate = location.coordinate
print("User at: \(coordinate.latitude), \(coordinate.longitude)")
// Move camera to user location
mapView.camera.ease(to: CameraOptions(
center: coordinate,
zoom: 14
), duration: 1.0)
}
```
---
## Add Custom Data (GeoJSON)
Add your own data to the map using GeoJSON sources and layers.
### Add Line (Route, Path)
```swift
// Create coordinates for the line
let routeCoordinates = [
CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194),
CLLocationCoordinate2D(latitude: 37.7849, longitude: -122.4094),
CLLocationCoordinate2D(latitude: 37.7949, longitude: -122.3994)
]
// Create GeoJSON source
var source = GeoJSONSource(id: "route-source")
source.data = .geometry(.lineString(LineString(routeCoordinates)))
try? mapView.mapboxMap.addSource(source)
// Create line layer
var layer = LineLayer(id: "route-layer", source: "route-source")
layer.lineColor = .constant(StyleColor(.blue))
layer.lineWidth = .constant(4)
layer.lineCap = .constant(.round)
layer.lineJoin = .constant(.round)
try? mapView.mapboxMap.addLayer(layer)
```
### Add Polygon (Area)
```swift
let polygonCoordinates = [coord1, coord2, coord3, coord1] // Close the polygon
var source = GeoJSONSource(id: "area-source")
source.data = .geometry(.polygon(Polygon([polygonCoordinates])))
try? mapView.mapboxMap.addSource(source)
var fillLayer = FillLayer(id: "area-fill", source: "area-source")
fillLayer.fillColor = .constant(StyleColor(.blue.withAlphaComponent(0.3)))
fillLayer.fillOutlineColor = .constant(StyleColor(.blue))
try? mapView.mapboxMap.addLayer(fillLayer)
```
### Add Points from GeoJSON
```swift
let geojsonString = """
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [-122.4194, 37.7749]},
"properties": {"name": "Location 1"}
},
{
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [-122.4094, 37.7849]},
"properties": {"name": "Location 2"}
}
]
}
"""
var source = GeoJSONSource(id: "points-source")
source.data = .string(geojsonString)
try? mapView.mapboxMap.addSource(source)
var symbolLayer = SymbolLayer(id: "points-layer", source: "points-source")
symbolLayer.iconImage = .constant(.name("marker"))
symbolLayer.textField = .constant(.expression(Exp(.get) { "name" }))
symbolLayer.textOffset = .constant([0, 1.5])
try? mapView.mapboxMap.addLayer(symbolLayer)
```
### Update Layer Properties
```swift
try? mapView.mapboxMap.updateLayer(
withId: "route-layer",
type: LineLayer.self
) { layer in
layer.lineColor = .constant(StyleColor(.red))
layer.lineWidth = .constant(6)
}
```
### Remove Layers and Sources
```swift
try? mapView.mapboxMap.removeLayer(withId: "route-layer")
try? mapView.mapboxMap.removeSource(withId: "route-source")
```
---
## Camera Control
### Set Camera Position
```swift
// SwiftUI - Update viewport state
viewport = .camera(
center: CLLocationCoordinate2D(latitude: 40.7128, longitude: -74.0060),
zoom: 14,
bearing: 90,
pitch: 60
)
// UIKit - Immediate
mapView.mapboxMap.setCamera(to: CameraOptions(
center: CLLocationCoordinate2D(latitude: 40.7128, longitude: -74.0060),
zoom: 14,
bearing: 90,
pitch: 60
))
```
### Animated Camera Transitions
```swift
// Fly animation (dramatic arc)
mapView.camera.fly(to: CameraOptions(
center: destination,
zoom: 15
), duration: 2.0)
// Ease animation (smooth)
mapView.camera.ease(to: CameraOptions(
center: destination,
zoom: 15
), duration: 1.0)
```
### Fit Camera to Coordinates
```swift
let coordinates = [coord1, coord2, coord3]
let camera = mapView.mapboxMap.camera(for: coordinates,
padding: UIEdgeInsets(top: 50, left: 50, bottom: 50, right: 50),
bearing: 0,
pitch: 0)
mapView.camera.ease(to: camera, duration: 1.0)
```
---
## Map Styles
### Built-in Styles
```swift
// SwiftUI
Map(viewport: $viewport)
.mapStyle(.standard) // Mapbox Standard (recommended)
.mapStyle(.streets) // Mapbox Streets
.mapStyle(.outdoors) // Mapbox Outdoors
.mapStyle(.light) // Mapbox Light
.mapStyle(.dark) // Mapbox Dark
.mapStyle(.standardSatellite) // Satellite imagery
// UIKit
mapView.mapboxMap.loadStyle(.standard)
mapView.mapboxMap.loadStyle(.streets)
mapView.mapboxMap.loadStyle(.dark)
```
### Custom Style URL
```swift
// SwiftUI
Map(viewport: $viewport)
.mapStyle(MapStyle(uri: StyleURI(url: customStyleURL)!))
// UIKit
mapView.mapboxMap.loadStyle(StyleURI(url: customStyleURL)!)
```
**Style from Mapbox Studio:**
```swift
let styleURL = URL(string: "mapbox://styles/username/style-id")!
```
---
## User Interaction & Feature Taps
### Featureset Interactions (Recommended)
The modern Interactions API allows handling taps on map features with typed feature access. Works with Standard Style predefined featuresets like POIs, buildings, and place labels.
**SwiftUI Pattern:**
```swift
import SwiftUI
import MapboxMaps
struct MapView: View {
@State private var viewport: Viewport = .camera(
center: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194),
zoom: 12
)
@State private var selectedBuildings = [StandardBuildingsFeature]()
var body: some View {
Map(viewport: $viewport) {
// Tap on POI features
TapInteraction(.standardPoi) { poi, context in
print("Tapped POI: \(poi.name ?? "Unknown")")
return true // Stop propagation
}
// Tap on buildings and collect selected buildings
TapInteraction(.standardBuildings) { building, context in
print("Tapped building")
selectedBuildings.append(building)
return true
}
// Apply feature state to selected buildings (highlighting)
ForEvery(selectedBuildings, id: \.id) { building in
FeatureState(building, .init(select: true))
}
}
.mapStyle(.standard)
}
}
```
**UIKit Pattern:**
```swift
import MapboxMaps
import Combine
class MapViewController: UIViewController {
private var mapView: MapView!
private var cancelables = Set<AnyCancellable>()
override func viewDidLoad() {
super.viewDidLoad()
setupMap()
setupInteractions()
}
func setupInteractions() {
// Tap on POI features
let poiToken = mapView.mapboxMap.addInteraction(
TapInteraction(.standardPoi) { [weak self] poi, context in
print("Tapped POI: \(poi.name ?? "Unknown")")
return true
}
)
// Tap on buildings
let buildingToken = mapView.mapboxMap.addInteraction(
TapInteraction(.standardBuildings) { [weak self] building, context in
print("Tapped building")
// Highlight the building using feature state
self?.mapView.mapboxMap.setFeatureState(
building,
state: ["select": true]
)
return true
}
)
// Store tokens to keep interactions active
// Cancel tokens when done: poiToken.cancel()
}
}
```
### Tap on Custom Layers
```swift
let token = mapView.mapboxMap.addInteraction(
TapInteraction(.layer("custom-layer-id")) { feature, context in
if let properties = feature.properties {
print("Feature properties: \(properties)")
}
return true
}
)
```
### Long Press Interactions
```swift
let token = mapView.mapboxMap.addInteraction(
LongPressInteraction(.standardPoi) { poi, context in
print("Long pressed POI: \(poi.name ?? "Unknown")")
return true
}
)
```
### Handle Map Taps (Empty Space)
```swift
// UIKit
mapView.gestures.onMapTap.observe { [weak self] context in
let coordinate = context.coordinate
print("Tapped map at: \(coordinate.latitude), \(coordinate.longitude)")
}.store(in: &cancelables)
```
### Gesture Configuration
```swift
// Disable specific gestures
mapView.gestures.options.pitchEnabled = false
mapView.gestures.options.rotateEnabled = false
// Configure zoom limits
mapView.mapboxMap.setCamera(to: CameraOptions(
zoom: 12,
minZoom: 10,
maxZoom: 16
))
```
---
## Performance Best Practices
### Reuse Annotation Managers
```swift
// ❌ Don't create new managers repeatedly
func updateMarkers() {
let manager = mapView.annotations.makePointAnnotationManager()
manager.annotations = markers
}
// ✅ Create once, reuse
let pointAnnotationManager: PointAnnotationManager
init() {
pointAnnotationManager = mapView.annotations.makePointAnnotationManager()
}
func updateMarkers() {
pointAnnotationManager.annotations = markers
}
```
### Batch Annotation Updates
```swift
// ✅ Update all at once
pointAnnotationManager.annotations = newAnnotations
// ❌ Don't update one by one
for annotation in newAnnotations {
pointAnnotationManager.annotations.append(annotation)
}
```
### Memory Management
```swift
// Use weak self in closures
mapView.gestures.onMapTap.observe { [weak self] context in
self?.handleTap(context.coordinate)
}.store(in: &cancelables)
// Clean up on deinit
deinit {
cancelables.forEach { $0.cancel() }
}
```
### Use Standard Style
```swift
// ✅ Standard style is optimized and recommended
.mapStyle(.standard)
// Use other styles only when needed for specific use cases
.mapStyle(.standardSatellite) // Satellite imagery
```
---
## Troubleshooting
### Map Not Displaying
**Check:**
1. ✅ `MBXAccessToken` in Info.plist
2. ✅ Token is valid (test at mapbox.com)
3. ✅ MapboxMaps framework imported
4. ✅ MapView added to view hierarchy
5. ✅ Correct frame/constraints set
### Style Not Loading
```swift
mapView.mapboxMap.onStyleLoaded.observe { [weak self] _ in
print("Style loaded successfully")
// Add layers and sources here
}.store(in: &cancelables)
```
### Performance Issues
- Use `.standard` style (recommended and optimized)
- Limit visible annotations to viewport
- Reuse annotation managers
- Avoid frequent style reloads
- Batch annotation updates
---
## Additional Resources
- [iOS Maps Guides](https://docs.mapbox.com/ios/maps/guides/)
- [API Reference](https://docs.mapbox.com/ios/maps/api/11.18.1/documentation/mapboxmaps/)
- [Interactions Guide](https://docs.mapbox.com/ios/maps/guides/user-interaction/Interactions/)
- [SwiftUI User Guide](https://docs.mapbox.com/ios/maps/api/11.18.1/documentation/mapboxmaps/swiftui-user-guide)
- [Example Apps](https://github.com/mapbox/mapbox-maps-ios/tree/main/Sources/Examples)
- [Migration Guide (v10 → v11)](https://docs.mapbox.com/ios/maps/guides/migrate-to-v11/)Guide for migrating from MapLibre GL JS to Mapbox GL JS, covering API compatibility, token setup, style configuration, and the benefits of Mapbox's official support and ecosystem
# MapLibre to Mapbox Migration Skill
Expert guidance for migrating from MapLibre GL JS to Mapbox GL JS. Covers the shared history, API compatibility, migration steps, and the advantages of Mapbox's platform.
## Understanding the Fork
### History
**MapLibre GL JS** is an open-source fork of **Mapbox GL JS v1.13.0**, created in December 2020 when Mapbox changed their license starting with v2.0.
**Timeline:**
- **Pre-2020:** Mapbox GL JS was open source (BSD license)
- **Dec 2020:** Mapbox GL JS v2.0 introduced proprietary license
- **Dec 2020:** Community forked v1.13 as MapLibre GL JS
- **Present:** Both libraries continue active development
**Key Insight:** The APIs are ~95% identical because MapLibre started as a Mapbox fork. Most code works in both with minimal changes, making migration straightforward.
## Why Migrate to Mapbox?
**Compelling reasons to choose Mapbox GL JS:**
- **Official Support & SLAs**: Enterprise-grade support with guaranteed response times
- **Superior Tile Quality**: Best-in-class vector tiles with global coverage and frequent updates
- **Better Satellite Imagery**: High-resolution, up-to-date satellite and aerial imagery
- **Rich Ecosystem**: Seamless integration with Mapbox Studio, APIs, and services
- **Advanced Features**: Traffic-aware routing, turn-by-turn directions, premium datasets
- **Geocoding & Search**: World-class address search and place lookup
- **Navigation SDK**: Mobile navigation with real-time traffic
- **No Tile Infrastructure**: No need to host or maintain your own tile servers
- **Regular Updates**: Continuous improvements and new features
- **Professional Services**: Access to Mapbox solutions team for complex projects
**Mapbox offers a generous free tier:** 50,000 map loads/month, making it suitable for many applications without cost.
## Quick Comparison
| Aspect | Mapbox GL JS | MapLibre GL JS |
| --------------------- | ----------------------------- | --------------------------------- |
| **License** | Proprietary (v2+) | BSD 3-Clause (Open Source) |
| **Support** | Official commercial support | Community support |
| **Tiles** | Premium Mapbox vector tiles | OSM or custom tile sources |
| **Satellite** | High-quality global imagery | Requires custom source |
| **Token** | Required (access token) | Optional (depends on tile source) |
| **APIs** | Full Mapbox ecosystem | Requires third-party services |
| **Studio** | Full integration | No native integration |
| **3D Terrain** | Built-in with premium data | Available (requires data source) |
| **Globe View** | v2.9+ | v3.0+ |
| **API Compatibility** | ~95% compatible with MapLibre | ~95% compatible with Mapbox |
| **Bundle Size** | ~500KB | ~450KB |
| **Setup Complexity** | Easy (just add token) | Requires tile source setup |
## Step-by-Step Migration
### 1. Create Mapbox Account
1. Sign up at [mapbox.com](https://mapbox.com)
2. Get your access token from the account dashboard
3. Review pricing: Free tier includes 50,000 map loads/month
4. Note your token (starts with `pk.` for public tokens)
### 2. Update Package
```bash
# Remove MapLibre
npm uninstall maplibre-gl
# Install Mapbox
npm install mapbox-gl
```
### 3. Update Imports
```javascript
// Before (MapLibre)
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
// After (Mapbox)
import mapboxgl from 'mapbox-gl';
import 'mapbox-gl/dist/mapbox-gl.css';
```
Or with CDN:
```html
<!-- Before (MapLibre) -->
<script src="https://unpkg.com/maplibre-gl@3.0.0/dist/maplibre-gl.js"></script>
<link href="https://unpkg.com/maplibre-gl@3.0.0/dist/maplibre-gl.css" rel="stylesheet" />
<!-- After (Mapbox) -->
<script src="https://api.mapbox.com/mapbox-gl-js/v3.0.0/mapbox-gl.js"></script>
<link href="https://api.mapbox.com/mapbox-gl-js/v3.0.0/mapbox-gl.css" rel="stylesheet" />
```
### 4. Add Access Token
```javascript
// Add this before map initialization
mapboxgl.accessToken = 'pk.your_mapbox_access_token';
```
**Token best practices:**
- Use environment variables: `process.env.VITE_MAPBOX_TOKEN` or `process.env.NEXT_PUBLIC_MAPBOX_TOKEN`
- Add URL restrictions in Mapbox dashboard for security
- Use public tokens (`pk.*`) for client-side code
- Never commit tokens to git (add to `.env` and `.gitignore`)
- Rotate tokens if compromised
See `mapbox-token-security` skill for comprehensive token security guidance.
### 5. Update Map Initialization
```javascript
// Before (MapLibre)
const map = new maplibregl.Map({
container: 'map',
style: 'https://demotiles.maplibre.org/style.json', // or your custom style
center: [-122.4194, 37.7749],
zoom: 12
});
// After (Mapbox)
mapboxgl.accessToken = 'pk.your_mapbox_access_token';
const map = new mapboxgl.Map({
container: 'map',
style: 'mapbox://styles/mapbox/standard', // Mapbox style
center: [-122.4194, 37.7749],
zoom: 12
});
```
### 6. Update Style URL
Mapbox provides professionally designed, maintained styles:
```javascript
// Mapbox built-in styles
style: 'mapbox://styles/mapbox/standard'; // Mapbox Standard (default)
style: 'mapbox://styles/mapbox/standard-satellite'; // Mapbox Standard Satellite
style: 'mapbox://styles/mapbox/streets-v12'; // Streets v12
style: 'mapbox://styles/mapbox/satellite-v9'; // Satellite imagery
style: 'mapbox://styles/mapbox/satellite-streets-v12'; // Hybrid
style: 'mapbox://styles/mapbox/outdoors-v12'; // Outdoor/recreation
style: 'mapbox://styles/mapbox/light-v11'; // Light theme
style: 'mapbox://styles/mapbox/dark-v11'; // Dark theme
style: 'mapbox://styles/mapbox/navigation-day-v1'; // Navigation (day)
style: 'mapbox://styles/mapbox/navigation-night-v1'; // Navigation (night)
```
**Custom styles:**
You can also create and use custom styles from Mapbox Studio:
```javascript
style: 'mapbox://styles/your-username/your-style-id';
```
### 7. Update All References
Replace all `maplibregl` references with `mapboxgl`:
```javascript
// Markers
const marker = new mapboxgl.Marker() // was: maplibregl.Marker()
.setLngLat([-122.4194, 37.7749])
.setPopup(new mapboxgl.Popup().setText('San Francisco'))
.addTo(map);
// Controls
map.addControl(new mapboxgl.NavigationControl(), 'top-right');
map.addControl(new mapboxgl.GeolocateControl());
map.addControl(new mapboxgl.FullscreenControl());
map.addControl(new mapboxgl.ScaleControl());
```
### 8. Update Plugins (If Used)
Some MapLibre plugins should be replaced with Mapbox versions:
| MapLibre Plugin | Mapbox Alternative |
| -------------------------------- | ---------------------------- |
| `@maplibre/maplibre-gl-geocoder` | `@mapbox/mapbox-gl-geocoder` |
| `@maplibre/maplibre-gl-draw` | `@mapbox/mapbox-gl-draw` |
| `maplibre-gl-compare` | `mapbox-gl-compare` |
Example:
```javascript
// Before (MapLibre)
import MaplibreGeocoder from '@maplibre/maplibre-gl-geocoder';
// After (Mapbox)
import MapboxGeocoder from '@mapbox/mapbox-gl-geocoder';
map.addControl(
new MapboxGeocoder({
accessToken: mapboxgl.accessToken,
mapboxgl: mapboxgl
})
);
```
### 9. Everything Else Stays the Same
All your map code, events, layers, and sources work identically:
```javascript
// This code works EXACTLY THE SAME in both libraries
map.on('load', () => {
map.addSource('points', {
type: 'geojson',
data: geojsonData
});
map.addLayer({
id: 'points-layer',
type: 'circle',
source: 'points',
paint: {
'circle-radius': 8,
'circle-color': '#ff0000'
}
});
});
// Events work identically
map.on('click', 'points-layer', (e) => {
console.log(e.features[0].properties);
});
// All map methods work the same
map.setCenter([lng, lat]);
map.setZoom(12);
map.fitBounds(bounds);
map.flyTo({ center: [lng, lat], zoom: 14 });
```
## What Changes: Summary
**Must change:**
- Package name (`maplibre-gl` → `mapbox-gl`)
- Import statements
- Add `mapboxgl.accessToken` configuration
- Style URL (switch to `mapbox://` styles)
- Plugin packages (if used)
**Stays exactly the same:**
- All map methods (`setCenter`, `setZoom`, `fitBounds`, `flyTo`, etc.)
- All event handling (`map.on('click')`, `map.on('load')`, etc.)
- Marker/Popup APIs (100% compatible)
- Layer/source APIs (100% compatible)
- GeoJSON handling
- Custom styling and expressions
- Controls (Navigation, Geolocate, Scale, etc.)
## API Compatibility Matrix
### 100% Compatible APIs
These work identically in both libraries:
```javascript
// Map methods
map.setCenter([lng, lat]);
map.setZoom(zoom);
map.fitBounds(bounds);
map.panTo([lng, lat]);
map.flyTo({ center, zoom });
map.getCenter();
map.getZoom();
map.getBounds();
map.resize();
// Events
map.on('load', callback);
map.on('click', callback);
map.on('move', callback);
map.on('zoom', callback);
map.on('rotate', callback);
// Markers
new mapboxgl.Marker();
marker.setLngLat([lng, lat]);
marker.setPopup(popup);
marker.addTo(map);
marker.remove();
marker.setDraggable(true);
// Popups
new mapboxgl.Popup();
popup.setLngLat([lng, lat]);
popup.setHTML(html);
popup.setText(text);
popup.addTo(map);
// Sources & Layers
map.addSource(id, source);
map.removeSource(id);
map.addLayer(layer);
map.removeLayer(id);
map.getSource(id);
map.getLayer(id);
// Styling
map.setPaintProperty(layerId, property, value);
map.setLayoutProperty(layerId, property, value);
map.setFilter(layerId, filter);
// Controls
map.addControl(control, position);
new mapboxgl.NavigationControl();
new mapboxgl.GeolocateControl();
new mapboxgl.FullscreenControl();
new mapboxgl.ScaleControl();
```
## Side-by-Side Example
### MapLibre GL JS (Before)
```javascript
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
// No token needed for OSM tiles
const map = new maplibregl.Map({
container: 'map',
style: 'https://demotiles.maplibre.org/style.json',
center: [-122.4194, 37.7749],
zoom: 12
});
map.on('load', () => {
new maplibregl.Marker()
.setLngLat([-122.4194, 37.7749])
.setPopup(new maplibregl.Popup().setText('San Francisco'))
.addTo(map);
});
```
### Mapbox GL JS (After)
```javascript
import mapboxgl from 'mapbox-gl';
import 'mapbox-gl/dist/mapbox-gl.css';
// Add your Mapbox token
mapboxgl.accessToken = 'pk.your_mapbox_access_token';
const map = new mapboxgl.Map({
container: 'map',
style: 'mapbox://styles/mapbox/streets-v12',
center: [-122.4194, 37.7749],
zoom: 12
});
map.on('load', () => {
new mapboxgl.Marker()
.setLngLat([-122.4194, 37.7749])
.setPopup(new mapboxgl.Popup().setText('San Francisco'))
.addTo(map);
});
```
**What's different:** Package, import, token, and style URL. **Everything else is identical.**
## Mapbox-Exclusive Features
After migration, you gain access to these Mapbox-only features:
### Premium Vector Tiles
- **Streets**: Comprehensive road network with names, shields, and routing data
- **Satellite**: High-resolution global imagery updated regularly
- **Terrain**: Elevation data with hillshading and 3D terrain
- **Traffic**: Real-time traffic data (with Navigation SDK)
### Mapbox APIs
Use these APIs alongside your map for enhanced functionality:
```javascript
// Geocoding API - Convert addresses to coordinates
const response = await fetch(
`https://api.mapbox.com/search/geocode/v6/forward?q=San+Francisco&access_token=${mapboxgl.accessToken}`
);
// Directions API - Get turn-by-turn directions
const directions = await fetch(
`https://api.mapbox.com/directions/v5/mapbox/driving/-122.42,37.78;-122.45,37.76?access_token=${mapboxgl.accessToken}`
);
// Isochrone API - Calculate travel time polygons
const isochrone = await fetch(
`https://api.mapbox.com/isochrone/v1/mapbox/driving/-122.42,37.78?contours_minutes=5,10,15&access_token=${mapboxgl.accessToken}`
);
```
### Mapbox Studio
- Visual style editor with live preview
- Dataset management and editing
- Tilesets with custom data upload
- Collaborative team features
- Style versioning and publishing
### Advanced Features (v2.9+)
- **Globe projection**: Seamless transition from globe to Mercator
- **3D buildings**: Extrusion with real building footprints
- **Custom terrain**: Use your own DEM sources
- **Sky layer**: Realistic atmospheric rendering
## Framework Integration
Migration works identically across all frameworks. See `mapbox-web-integration-patterns` skill for detailed React, Vue, Svelte, Angular patterns.
### React Example
```jsx
import { useRef, useEffect } from 'react';
import mapboxgl from 'mapbox-gl';
import 'mapbox-gl/dist/mapbox-gl.css';
// Set token once (can be in app initialization)
mapboxgl.accessToken = process.env.REACT_APP_MAPBOX_TOKEN;
function MapComponent() {
const mapRef = useRef(null);
const mapContainerRef = useRef(null);
useEffect(() => {
mapRef.current = new mapboxgl.Map({
container: mapContainerRef.current,
style: 'mapbox://styles/mapbox/streets-v12',
center: [-122.4194, 37.7749],
zoom: 12
});
return () => {
mapRef.current.remove();
};
}, []);
return <div ref={mapContainerRef} style={{ height: '100vh' }} />;
}
```
Just replace `maplibregl` with `mapboxgl` and update token/style - everything else is identical!
### Vue Example
```vue
<template>
<div ref="mapContainer" class="map-container"></div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import mapboxgl from 'mapbox-gl';
import 'mapbox-gl/dist/mapbox-gl.css';
mapboxgl.accessToken = import.meta.env.VITE_MAPBOX_TOKEN;
const mapContainer = ref(null);
let map = null;
onMounted(() => {
map = new mapboxgl.Map({
container: mapContainer.value,
style: 'mapbox://styles/mapbox/streets-v12',
center: [-122.4194, 37.7749],
zoom: 12
});
});
onUnmounted(() => {
map?.remove();
});
</script>
<style scoped>
.map-container {
height: 100vh;
}
</style>
```
## Common Migration Issues
### Issue 1: Token Not Set
**Problem:**
```javascript
// Error: "A valid Mapbox access token is required to use Mapbox GL"
const map = new mapboxgl.Map({...});
```
**Solution:**
```javascript
// Set token BEFORE creating map
mapboxgl.accessToken = 'pk.your_token';
const map = new mapboxgl.Map({...});
```
### Issue 2: Token in Git
**Problem:**
```javascript
// Token hardcoded in source
mapboxgl.accessToken = 'pk.eyJ1Ijoi...';
```
**Solution:**
```javascript
// Use environment variables
mapboxgl.accessToken = process.env.VITE_MAPBOX_TOKEN;
// Add to .env file (not committed to git)
VITE_MAPBOX_TOKEN=pk.your_token
// Add .env to .gitignore
echo ".env" >> .gitignore
```
### Issue 3: Wrong Style URL Format
**Problem:**
```javascript
// MapLibre-style URL won't work optimally
style: 'https://demotiles.maplibre.org/style.json';
```
**Solution:**
```javascript
// Use Mapbox style URL for better performance and features
style: 'mapbox://styles/mapbox/streets-v12';
```
### Issue 4: Plugin Compatibility
**Problem:**
```javascript
// MapLibre plugin won't work
import MaplibreGeocoder from '@maplibre/maplibre-gl-geocoder';
```
**Solution:**
```javascript
// Use Mapbox plugin
import MapboxGeocoder from '@mapbox/mapbox-gl-geocoder';
```
### Issue 5: CDN URLs
**Problem:**
```javascript
// Wrong CDN
<script src="https://unpkg.com/maplibre-gl@3.0.0/dist/maplibre-gl.js"></script>
```
**Solution:**
```javascript
// Use Mapbox CDN
<script src='https://api.mapbox.com/mapbox-gl-js/v3.0.0/mapbox-gl.js'></script>
<link href='https://api.mapbox.com/mapbox-gl-js/v3.0.0/mapbox-gl.css' rel='stylesheet' />
```
## Migration Checklist
Complete these steps for a successful migration:
- [ ] **Create Mapbox account** and get access token
- [ ] **Update package**: `npm install mapbox-gl` (remove maplibre-gl)
- [ ] **Update imports**: `maplibre-gl` → `mapbox-gl`
- [ ] **Update CSS imports**: `maplibre-gl.css` → `mapbox-gl.css`
- [ ] **Add token**: Set `mapboxgl.accessToken = 'pk.xxx'`
- [ ] **Use environment variables**: Store token in `.env`
- [ ] **Update style URL**: Change to `mapbox://styles/mapbox/streets-v12`
- [ ] **Update all references**: Replace `maplibregl.` with `mapboxgl.`
- [ ] **Update plugins**: Install Mapbox versions of plugins (if used)
- [ ] **Configure token security**: Add URL restrictions in dashboard
- [ ] **Test all functionality**: Verify map loads, interactions work
- [ ] **Set up billing alerts**: Monitor usage in Mapbox dashboard
- [ ] **Update documentation**: Document token setup for team
- [ ] **Add .env to .gitignore**: Ensure tokens not committed
## Why Choose Mapbox
### For Production Applications
**Reliability & Support:**
- 99.9% uptime SLA for enterprise customers
- 24/7 support with guaranteed response times
- Dedicated solutions engineers for complex projects
- Regular platform updates and improvements
**Performance:**
- Global CDN for fast tile delivery
- Optimized vector tiles for minimal bandwidth
- Automatic scaling for traffic spikes
- WebGL-accelerated rendering
**Features:**
- Professional cartography and design
- Regular map data updates
- Traffic and routing data
- Premium satellite imagery
- 3D terrain and buildings
### For Development Teams
**Developer Experience:**
- Comprehensive documentation with examples
- Active community and forums
- Regular SDK updates
- TypeScript support (via `@types/mapbox-gl`)
- Extensive example gallery
**Ecosystem Integration:**
- Seamless Studio integration
- API consistency across services
- Mobile SDKs (iOS, Android, React Native)
- Unity and Unreal Engine plugins
- Analytics and monitoring tools
### For Business
**Predictable Costs:**
- Clear, usage-based pricing
- Free tier for development and small apps
- No infrastructure costs
- Scalable pricing for growth
**Compliance & Security:**
- SOC 2 Type II certified
- GDPR compliant
- Enterprise security features
- Audit logs and monitoring
**No Infrastructure Burden:**
- No tile servers to maintain
- No storage or bandwidth concerns
- No update management
- Focus on your application, not infrastructure
## Performance Comparison
Both libraries have similar rendering performance as they share the same core codebase:
| Metric | Mapbox GL JS | MapLibre GL JS |
| ---------------- | ------------------------------ | ---------------------- |
| **Bundle size** | ~500KB | ~450KB |
| **Initial load** | Similar | Similar |
| **Rendering** | WebGL-based | WebGL-based |
| **Memory usage** | Similar | Similar |
| **Tile loading** | Faster (CDN + optimized tiles) | Depends on tile source |
**Key insight:** Choose based on features, support, and tile quality, not rendering performance. Mapbox's advantage is in tile delivery speed, data quality, and ecosystem integration.
## Integration with Other Skills
**Related skills:**
- **mapbox-web-integration-patterns**: Framework-specific patterns (React, Vue, Svelte, Angular)
- **mapbox-web-performance-patterns**: Performance optimization techniques
- **mapbox-token-security**: Comprehensive token security best practices
- **mapbox-google-maps-migration**: Migrate from Google Maps to Mapbox
## Resources
**Mapbox GL JS:**
- [Official Documentation](https://docs.mapbox.com/mapbox-gl-js/)
- [Example Gallery](https://docs.mapbox.com/mapbox-gl-js/examples/)
- [API Reference](https://docs.mapbox.com/mapbox-gl-js/api/)
- [GitHub Repository](https://github.com/mapbox/mapbox-gl-js)
- [Mapbox Studio](https://studio.mapbox.com/)
- [Pricing Information](https://www.mapbox.com/pricing/)
**Migration Support:**
- [Get Started Guide](https://docs.mapbox.com/mapbox-gl-js/guides/install/)
- [Style Specification](https://docs.mapbox.com/mapbox-gl-js/style-spec/)
- [Mapbox Community Support](https://support.mapbox.com/hc/en-us/community/topics)
## Quick Reference
### Key Differences Summary
| What | MapLibre | Mapbox |
| ------- | -------------------------------------- | ------------------------------------------- |
| Package | `maplibre-gl` | `mapbox-gl` |
| Import | `import maplibregl from 'maplibre-gl'` | `import mapboxgl from 'mapbox-gl'` |
| Token | Optional (depends on tiles) | Required: `mapboxgl.accessToken = 'pk.xxx'` |
| Style | Custom URL or OSM tiles | `mapbox://styles/mapbox/streets-v12` |
| License | BSD (Open Source) | Proprietary (v2+) |
| Support | Community | Official commercial support |
| Tiles | Requires tile source | Premium Mapbox tiles included |
| APIs | Third-party | Full Mapbox API ecosystem |
| API | ~95% compatible | ~95% compatible |
**Bottom line:** Migration is easy because APIs are nearly identical. Main changes are packaging, token setup, and style URLs. The result is access to Mapbox's premium tiles, ecosystem, and support.Integration patterns for Mapbox MCP Server in AI applications and agent frameworks. Covers runtime integration with pydantic-ai, mastra, LangChain, and custom agents. Use when building AI-powered applications that need geospatial capabilities.
# Mapbox MCP Runtime Patterns
This skill provides patterns for integrating the Mapbox MCP Server into AI applications for production use with geospatial capabilities.
## What is Mapbox MCP Server?
The [Mapbox MCP Server](https://github.com/mapbox/mcp-server) is a Model Context Protocol (MCP) server that provides AI agents with geospatial tools:
**Offline Tools (Turf.js):**
- Distance, bearing, midpoint calculations
- Point-in-polygon tests
- Area, buffer, centroid operations
- Bounding box, geometry simplification
- No API calls, instant results
**Mapbox API Tools:**
- Directions and routing
- Reverse geocoding
- POI category search
- Isochrones (reachability)
- Travel time matrices
- Static map images
- GPS trace map matching
- Multi-stop route optimization
**Utility Tools:**
- Server version info
- POI category list
**Key benefit:** Give your AI application geospatial superpowers without manually integrating multiple APIs.
## Understanding Tool Categories
Before integrating, understand the key distinctions between tools to help your LLM choose correctly:
### Distance: "As the Crow Flies" vs "Along Roads"
**Straight-line distance** (offline, instant):
- Tools: `distance_tool`, `bearing_tool`, `midpoint_tool`
- Use for: Proximity checks, "how far away is X?", comparing distances
- Example: "Is this restaurant within 2 miles?" → `distance_tool`
**Route distance** (API, traffic-aware):
- Tools: `directions_tool`, `matrix_tool`
- Use for: Navigation, drive time, "how long to drive?"
- Example: "How long to drive there?" → `directions_tool`
### Search: Type vs Specific Place
**Category/type search**:
- Tool: `category_search_tool`
- Use for: "Find coffee shops", "restaurants nearby", browsing by type
- Example: "What hotels are near me?" → `category_search_tool`
**Specific place/address**:
- Tool: `search_and_geocode_tool`, `reverse_geocode_tool`
- Use for: Named places, street addresses, landmarks
- Example: "Find 123 Main Street" → `search_and_geocode_tool`
### Travel Time: Area vs Route
**Reachable area** (what's within reach):
- Tool: `isochrone_tool`
- Returns: GeoJSON polygon of everywhere reachable
- Example: "What can I reach in 15 minutes?" → `isochrone_tool`
**Specific route** (how to get there):
- Tool: `directions_tool`
- Returns: Turn-by-turn directions to one destination
- Example: "How do I get to the airport?" → `directions_tool`
### Cost & Performance
**Offline tools** (free, instant):
- No API calls, no token usage
- Use whenever real-time data not needed
- Examples: `distance_tool`, `point_in_polygon_tool`, `area_tool`
**API tools** (requires token, counts against usage):
- Real-time traffic, live POI data, current conditions
- Use when accuracy and freshness matter
- Examples: `directions_tool`, `category_search_tool`, `isochrone_tool`
**Best practice:** Prefer offline tools when possible, use API tools when you need real-time data or routing.
## Installation & Setup
### Option 1: Hosted Server (Recommended)
**Easiest integration** - Use Mapbox's hosted MCP server at:
```
https://mcp.mapbox.com/mcp
```
No installation required. Simply pass your Mapbox access token in the `Authorization` header.
**Benefits:**
- No server management
- Always up-to-date
- Production-ready
- Lower latency (Mapbox infrastructure)
**Authentication:**
Use token-based authentication (standard for programmatic access):
```
Authorization: Bearer your_mapbox_token
```
**Note:** The hosted server also supports OAuth, but that's primarily for interactive flows (coding assistants, not production apps).
### Option 2: Self-Hosted
For custom deployments or development:
```bash
npm install @mapbox/mcp-server
```
Or use directly via npx:
```bash
npx @mapbox/mcp-server
```
**Environment setup:**
```bash
export MAPBOX_ACCESS_TOKEN="your_token_here"
```
## Integration Patterns
## Python Frameworks
### Pattern 1: Pydantic AI Integration
**Use case:** Building AI agents with type-safe tools in Python
#### Using Hosted Server (Recommended)
> **Common mistake:** When using pydantic-ai with OpenAI, the correct import is `from pydantic_ai.models.openai import OpenAIChatModel`. Do NOT use `OpenAIModel` — that class does not exist in pydantic-ai and will throw an ImportError at runtime.
```python
from pydantic_ai import Agent
from pydantic_ai.models.openai import OpenAIChatModel
import requests
import json
import os
class MapboxMCP:
"""Mapbox MCP via hosted server."""
def __init__(self, token: str = None):
self.url = 'https://mcp.mapbox.com/mcp'
self.headers = {'Content-Type': 'application/json'}
# Use token from environment or parameter
token = token or os.getenv('MAPBOX_ACCESS_TOKEN')
if token:
self.headers['Authorization'] = f'Bearer {token}'
def call_tool(self, tool_name: str, params: dict) -> dict:
"""Call MCP tool via HTTPS."""
request = {
'jsonrpc': '2.0',
'id': 1,
'method': 'tools/call',
'params': {
'name': tool_name,
'arguments': params
}
}
response = requests.post(
self.url,
headers=self.headers,
json=request
)
response.raise_for_status()
data = response.json()
if 'error' in data:
raise RuntimeError(f"MCP error: {data['error']['message']}")
return data['result']['content'][0]['text']
# Create agent with Mapbox tools
# Pass token directly or set MAPBOX_ACCESS_TOKEN env var
mapbox = MapboxMCP(token='your_token')
agent = Agent(
model=OpenAIChatModel('gateway/openai:gpt-5.2'),
tools=[
lambda from_loc, to_loc: mapbox.call_tool(
'directions_tool',
{'coordinates': [from_loc, to_loc], 'routing_profile': 'mapbox/driving-traffic'}
),
lambda address: mapbox.call_tool(
'reverse_geocode_tool',
{'coordinates': {'longitude': address[0], 'latitude': address[1]}}
)
]
)
# Use agent
result = agent.run_sync(
"What's the driving time from Boston to NYC?"
)
```
#### Using Self-Hosted Server
```python
import subprocess
class MapboxMCPLocal:
def __init__(self, token: str):
self.token = token
self.mcp_process = subprocess.Popen(
['npx', '@mapbox/mcp-server'],
env={'MAPBOX_ACCESS_TOKEN': token},
stdin=subprocess.PIPE,
stdout=subprocess.PIPE
)
def call_tool(self, tool_name: str, params: dict) -> dict:
# ... similar to hosted but via subprocess
pass
```
**Benefits:**
- Type-safe tool definitions
- Seamless MCP integration
- Python-native development
### Pattern 2: CrewAI Integration
**Use case:** Multi-agent orchestration with geospatial capabilities
CrewAI enables building autonomous agent crews with specialized roles. Integration with Mapbox MCP adds geospatial intelligence to your crew.
```python
from crewai import Agent, Task, Crew
from crewai.tools import BaseTool
import requests
import os
from typing import Type
from pydantic import BaseModel, Field
class MapboxMCP:
"""Mapbox MCP connector."""
def __init__(self, token: str = None):
self.url = 'https://mcp.mapbox.com/mcp'
token = token or os.getenv('MAPBOX_ACCESS_TOKEN')
self.headers = {
'Content-Type': 'application/json',
'Authorization': f'Bearer {token}'
}
def call_tool(self, tool_name: str, params: dict) -> str:
request = {
'jsonrpc': '2.0',
'id': 1,
'method': 'tools/call',
'params': {'name': tool_name, 'arguments': params}
}
response = requests.post(self.url, headers=self.headers, json=request)
response.raise_for_status()
data = response.json()
if 'error' in data:
raise RuntimeError(f"MCP error: {data['error']['message']}")
return data['result']['content'][0]['text']
# Create Mapbox tools for CrewAI
class DirectionsTool(BaseTool):
name: str = "directions_tool"
description: str = "Get driving directions between two locations"
class InputSchema(BaseModel):
origin: list = Field(description="Origin [lng, lat]")
destination: list = Field(description="Destination [lng, lat]")
args_schema: Type[BaseModel] = InputSchema
def __init__(self):
super().__init__()
self.mcp = MapboxMCP()
def _run(self, origin: list, destination: list) -> str:
result = self.mcp.call_tool('directions_tool', {
'coordinates': [
{'longitude': origin[0], 'latitude': origin[1]},
{'longitude': destination[0], 'latitude': destination[1]}
],
'routing_profile': 'mapbox/driving-traffic'
})
return f"Directions: {result}"
class GeocodeTool(BaseTool):
name: str = "reverse_geocode_tool"
description: str = "Convert coordinates to human-readable address"
class InputSchema(BaseModel):
coordinates: list = Field(description="Coordinates [lng, lat]")
args_schema: Type[BaseModel] = InputSchema
def __init__(self):
super().__init__()
self.mcp = MapboxMCP()
def _run(self, coordinates: list) -> str:
result = self.mcp.call_tool('reverse_geocode_tool', {
'coordinates': {'longitude': coordinates[0], 'latitude': coordinates[1]}
})
return result
class SearchPOITool(BaseTool):
name: str = "search_poi"
description: str = "Find points of interest by category near a location"
class InputSchema(BaseModel):
category: str = Field(description="POI category (restaurant, hotel, etc.)")
location: list = Field(description="Search center [lng, lat]")
args_schema: Type[BaseModel] = InputSchema
def __init__(self):
super().__init__()
self.mcp = MapboxMCP()
def _run(self, category: str, location: list) -> str:
result = self.mcp.call_tool('category_search_tool', {
'category': category,
'proximity': {'longitude': location[0], 'latitude': location[1]}
})
return result
# Create specialized agents with geospatial tools
location_analyst = Agent(
role='Location Analyst',
goal='Analyze geographic locations and provide insights',
backstory="""Expert in geographic analysis and location intelligence.
Use search_poi for finding types of places (restaurants, hotels).
Use reverse_geocode_tool for converting coordinates to addresses.""",
tools=[GeocodeTool(), SearchPOITool()],
verbose=True
)
route_planner = Agent(
role='Route Planner',
goal='Plan optimal routes and provide travel time estimates',
backstory="""Experienced logistics coordinator specializing in route optimization.
Use directions_tool for route distance along roads with traffic.
Always use when traffic-aware travel time is needed.""",
tools=[DirectionsTool()],
verbose=True
)
# Create tasks
find_restaurants_task = Task(
description="""
Find the top 5 restaurants near coordinates [-73.9857, 40.7484] (Times Square).
Provide their names and approximate distances.
""",
agent=location_analyst,
expected_output="List of 5 restaurants with distances"
)
plan_route_task = Task(
description="""
Plan a route from [-74.0060, 40.7128] (downtown NYC) to [-73.9857, 40.7484] (Times Square).
Provide driving time considering current traffic.
""",
agent=route_planner,
expected_output="Route with estimated driving time"
)
# Create and run crew
crew = Crew(
agents=[location_analyst, route_planner],
tasks=[find_restaurants_task, plan_route_task],
verbose=True
)
result = crew.kickoff()
print(result)
```
**Real-world example - Restaurant finder crew:**
```python
# Define crew for restaurant recommendation system
class RestaurantCrew:
def __init__(self):
self.mcp = MapboxMCP()
# Location specialist agent
self.location_agent = Agent(
role='Location Specialist',
goal='Find and analyze restaurant locations',
tools=[SearchPOITool(), GeocodeTool()],
backstory='Expert in finding the best dining locations'
)
# Logistics agent
self.logistics_agent = Agent(
role='Logistics Coordinator',
goal='Calculate travel times and optimal routes',
tools=[DirectionsTool()],
backstory='Specialist in urban navigation and time optimization'
)
def find_restaurants_with_commute(self, user_location: list, max_minutes: int):
# Task 1: Find nearby restaurants
search_task = Task(
description=f"Find restaurants near {user_location}",
agent=self.location_agent,
expected_output="List of restaurants with coordinates"
)
# Task 2: Calculate travel times
route_task = Task(
description=f"Calculate travel time to each restaurant from {user_location}",
agent=self.logistics_agent,
expected_output="Travel times to each restaurant",
context=[search_task] # Depends on search results
)
crew = Crew(
agents=[self.location_agent, self.logistics_agent],
tasks=[search_task, route_task],
verbose=True
)
return crew.kickoff()
# Usage
restaurant_crew = RestaurantCrew()
results = restaurant_crew.find_restaurants_with_commute(
user_location=[-73.9857, 40.7484],
max_minutes=15
)
```
**Benefits:**
- Multi-agent orchestration with geospatial tools
- Task dependencies and context passing
- Role-based agent specialization
- Autonomous crew execution
### Pattern 3: Smolagents Integration
**Use case:** Lightweight agents with geospatial capabilities (Hugging Face)
Smolagents is Hugging Face's simple, efficient agent framework. Perfect for deploying geospatial agents with minimal overhead.
```python
from smolagents import CodeAgent, Tool, HfApiModel
import requests
import os
class MapboxMCP:
"""Mapbox MCP connector."""
def __init__(self, token: str = None):
self.url = 'https://mcp.mapbox.com/mcp'
token = token or os.getenv('MAPBOX_ACCESS_TOKEN')
self.headers = {
'Content-Type': 'application/json',
'Authorization': f'Bearer {token}'
}
def call_tool(self, tool_name: str, params: dict) -> str:
request = {
'jsonrpc': '2.0',
'id': 1,
'method': 'tools/call',
'params': {'name': tool_name, 'arguments': params}
}
response = requests.post(self.url, headers=self.headers, json=request)
result = response.json()['result']
return result['content'][0]['text']
# Create Mapbox tools for Smolagents
class DirectionsTool(Tool):
name = "directions_tool"
description = """
Get driving directions between two locations.
Args:
origin: Origin coordinates as [longitude, latitude]
destination: Destination coordinates as [longitude, latitude]
Returns:
Directions with distance and travel time
"""
def __init__(self):
super().__init__()
self.mcp = MapboxMCP()
def forward(self, origin: list, destination: list) -> str:
return self.mcp.call_tool('directions_tool', {
'coordinates': [
{'longitude': origin[0], 'latitude': origin[1]},
{'longitude': destination[0], 'latitude': destination[1]}
],
'routing_profile': 'mapbox/driving-traffic'
})
class CalculateDistanceTool(Tool):
name = "distance_tool"
description = """
Calculate distance between two points (offline, instant).
Args:
from_coords: Start coordinates [longitude, latitude]
to_coords: End coordinates [longitude, latitude]
units: 'miles' or 'kilometers'
Returns:
Distance as a number
"""
def __init__(self):
super().__init__()
self.mcp = MapboxMCP()
def forward(self, from_coords: list, to_coords: list, units: str = 'miles') -> str:
return self.mcp.call_tool('distance_tool', {
'from': {'longitude': from_coords[0], 'latitude': from_coords[1]},
'to': {'longitude': to_coords[0], 'latitude': to_coords[1]},
'units': units
})
class SearchPOITool(Tool):
name = "search_poi"
description = """
Search for points of interest by category.
Args:
category: POI category (restaurant, hotel, gas_station, etc.)
location: Search center [longitude, latitude]
Returns:
List of nearby POIs with names and coordinates
"""
def __init__(self):
super().__init__()
self.mcp = MapboxMCP()
def forward(self, category: str, location: list) -> str:
return self.mcp.call_tool('category_search_tool', {
'category': category,
'proximity': {'longitude': location[0], 'latitude': location[1]}
})
class IsochroneTool(Tool):
name = "isochrone_tool"
description = """
Calculate reachable area within time limit (isochrone).
Args:
location: Center point [longitude, latitude]
minutes: Time limit in minutes
profile: 'mapbox/driving', 'mapbox/walking', or 'mapbox/cycling'
Returns:
GeoJSON polygon of reachable area
"""
def __init__(self):
super().__init__()
self.mcp = MapboxMCP()
def forward(self, location: list, minutes: int, profile: str = 'mapbox/driving') -> str:
return self.mcp.call_tool('isochrone_tool', {
'coordinates': {'longitude': location[0], 'latitude': location[1]},
'contours_minutes': [minutes],
'profile': profile
})
# Create agent with Mapbox tools
model = HfApiModel()
agent = CodeAgent(
tools=[
DirectionsTool(),
CalculateDistanceTool(),
SearchPOITool(),
IsochroneTool()
],
model=model
)
# Use agent
result = agent.run(
"Find restaurants within 10 minutes walking from Times Square NYC "
"(coordinates: -73.9857, 40.7484). Calculate distances to each."
)
print(result)
```
**Real-world example - Property search agent:**
```python
class PropertySearchAgent:
def __init__(self):
self.mcp = MapboxMCP()
# Create specialized tools
tools = [
IsochroneTool(),
SearchPOITool(),
CalculateDistanceTool()
]
self.agent = CodeAgent(
tools=tools,
model=HfApiModel()
)
def find_properties_near_work(
self,
work_location: list,
max_commute_minutes: int,
property_locations: list[dict]
):
"""Find properties within commute time of work."""
prompt = f"""
I need to find properties within {max_commute_minutes} minutes
driving of my work at {work_location}.
Property locations to check:
{property_locations}
For each property:
1. Calculate if it's within the commute time
2. Find nearby amenities (grocery stores, restaurants)
3. Calculate distances to key locations
Return a ranked list of properties with commute time and nearby amenities.
"""
return self.agent.run(prompt)
# Usage
property_agent = PropertySearchAgent()
properties = [
{'id': 1, 'address': '123 Main St', 'coords': [-122.4194, 37.7749]},
{'id': 2, 'address': '456 Oak Ave', 'coords': [-122.4094, 37.7849]},
]
results = property_agent.find_properties_near_work(
work_location=[-122.4, 37.79], # Downtown SF
max_commute_minutes=30,
property_locations=properties
)
```
**Benefits:**
- Lightweight and efficient
- Simple tool definition
- Code-based agent execution
- Great for production deployment
## JavaScript/TypeScript Frameworks
### Pattern 4: Mastra Integration
**Use case:** Building multi-agent systems with geospatial workflows
```typescript
import { Mastra } from '@mastra/core';
class MapboxMCP {
private url = 'https://mcp.mapbox.com/mcp';
private headers: Record<string, string>;
constructor(token?: string) {
const mapboxToken = token || process.env.MAPBOX_ACCESS_TOKEN;
this.headers = {
'Content-Type': 'application/json',
Authorization: `Bearer ${mapboxToken}`
};
}
async callTool(toolName: string, params: any): Promise<any> {
const request = {
jsonrpc: '2.0',
id: Date.now(),
method: 'tools/call',
params: { name: toolName, arguments: params }
};
const response = await fetch(this.url, {
method: 'POST',
headers: this.headers,
body: JSON.stringify(request)
});
const data = await response.json();
return JSON.parse(data.result.content[0].text);
}
}
// Create Mastra agent with Mapbox tools
import { Agent } from '@mastra/core/agent';
import { createTool } from '@mastra/core/tools';
import { z } from 'zod';
const mcp = new MapboxMCP();
// Create Mapbox tools
const searchPOITool = createTool({
id: 'search-poi',
description: 'Find places of a specific category near a location',
inputSchema: z.object({
category: z.string(),
location: z.array(z.number()).length(2)
}),
execute: async ({ category, location }) => {
return await mcp.callTool('category_search_tool', {
category,
proximity: { longitude: location[0], latitude: location[1] }
});
}
});
const getDirectionsTool = createTool({
id: 'get-directions',
description: 'Get driving directions with traffic',
inputSchema: z.object({
origin: z.array(z.number()).length(2),
destination: z.array(z.number()).length(2)
}),
execute: async ({ origin, destination }) => {
return await mcp.callTool('directions_tool', {
coordinates: [
{ longitude: origin[0], latitude: origin[1] },
{ longitude: destination[0], latitude: destination[1] }
],
routing_profile: 'mapbox/driving-traffic'
});
}
});
// Create location agent
const locationAgent = new Agent({
id: 'location-agent',
name: 'Location Intelligence Agent',
instructions: 'You help users find places and plan routes with geospatial tools.',
model: 'openai/gpt-5.2',
tools: {
searchPOITool,
getDirectionsTool
}
});
// Use agent
const result = await locationAgent.generate([
{ role: 'user', content: 'Find restaurants near Times Square NYC (-73.9857, 40.7484)' }
]);
```
**Benefits:**
- Multi-step geospatial workflows
- Agent orchestration
- State management
### Pattern 5: LangChain Integration
**Use case:** Building conversational AI with geospatial tools
```typescript
import { ChatOpenAI } from '@langchain/openai';
import { AgentExecutor, createToolCallingAgent } from 'langchain/agents';
import { DynamicStructuredTool } from '@langchain/core/tools';
import { ChatPromptTemplate, MessagesPlaceholder } from '@langchain/core/prompts';
import { z } from 'zod';
// MCP Server wrapper for hosted server
class MapboxMCP {
private url = 'https://mcp.mapbox.com/mcp';
private headers: Record<string, string>;
constructor(token?: string) {
const mapboxToken = token || process.env.MAPBOX_ACCESS_TOKEN;
this.headers = {
'Content-Type': 'application/json',
Authorization: `Bearer ${mapboxToken}`
};
}
async callTool(name: string, args: any): Promise<string> {
const request = {
jsonrpc: '2.0',
id: Date.now(),
method: 'tools/call',
params: { name, arguments: args }
};
const response = await fetch(this.url, {
method: 'POST',
headers: this.headers,
body: JSON.stringify(request)
});
const data = await response.json();
return data.result.content[0].text;
}
}
// Create LangChain tools from MCP
const mcp = new MapboxMCP();
const tools = [
new DynamicStructuredTool({
name: 'directions_tool',
description:
'Get turn-by-turn driving directions with traffic-aware route distance along roads. Use when you need the actual driving route or traffic-aware duration.',
schema: z.object({
origin: z.tuple([z.number(), z.number()]).describe('Origin [longitude, latitude]'),
destination: z.tuple([z.number(), z.number()]).describe('Destination [longitude, latitude]')
}) as any,
func: async ({ origin, destination }: any) => {
return await mcp.callTool('directions_tool', {
coordinates: [
{ longitude: origin[0], latitude: origin[1] },
{ longitude: destination[0], latitude: destination[1] }
],
routing_profile: 'mapbox/driving-traffic'
});
}
}),
new DynamicStructuredTool({
name: 'category_search_tool',
description:
'Find ALL places of a specific category type near a location. Use when user wants to browse places by type (restaurants, hotels, coffee, etc.).',
schema: z.object({
category: z.string().describe('POI category: restaurant, hotel, coffee, etc.'),
location: z.tuple([z.number(), z.number()]).describe('Search center [longitude, latitude]')
}) as any,
func: async ({ category, location }: any) => {
return await mcp.callTool('category_search_tool', {
category,
proximity: { longitude: location[0], latitude: location[1] }
});
}
}),
new DynamicStructuredTool({
name: 'isochrone_tool',
description:
'Calculate the AREA reachable within a time limit from a starting point. Use for "What can I reach in X minutes?" questions.',
schema: z.object({
location: z.tuple([z.number(), z.number()]).describe('Center point [longitude, latitude]'),
minutes: z.number().describe('Time limit in minutes'),
profile: z.enum(['mapbox/driving', 'mapbox/walking', 'mapbox/cycling']).optional()
}) as any,
func: async ({ location, minutes, profile }: any) => {
return await mcp.callTool('isochrone_tool', {
coordinates: { longitude: location[0], latitude: location[1] },
contours_minutes: [minutes],
profile: profile || 'mapbox/walking'
});
}
}),
new DynamicStructuredTool({
name: 'distance_tool',
description: 'Calculate straight-line distance between two points (offline, free)',
schema: z.object({
from: z.tuple([z.number(), z.number()]).describe('Start [longitude, latitude]'),
to: z.tuple([z.number(), z.number()]).describe('End [longitude, latitude]'),
units: z.enum(['miles', 'kilometers']).optional()
}) as any,
func: async ({ from, to, units }: any) => {
return await mcp.callTool('distance_tool', {
from: { longitude: from[0], latitude: from[1] },
to: { longitude: to[0], latitude: to[1] },
units: units || 'miles'
});
}
})
];
// Create agent
const llm = new ChatOpenAI({ model: 'gpt-5.2', temperature: 0 });
const prompt = ChatPromptTemplate.fromMessages([
['system', 'You are a location intelligence assistant.'],
['human', '{input}'],
new MessagesPlaceholder('agent_scratchpad')
]);
// @ts-ignore - Zod tuple schemas cause deep type recursion
const agent = await createToolCallingAgent({ llm, tools, prompt });
const executor = new AgentExecutor({ agent, tools, verbose: true });
// Use agent
const result = await executor.invoke({
input: 'Find coffee shops within 10 minutes walking from Union Square, NYC'
});
```
**Benefits:**
- Conversational interface
- Tool chaining
- Memory and context management
**TypeScript Type Considerations:**
When using `DynamicStructuredTool` with Zod schemas (especially `z.tuple()`), TypeScript may encounter deep type recursion errors. This is a known limitation with complex Zod generic types. The minimal fix is to add `as any` type assertions:
```typescript
const tool = new DynamicStructuredTool({
name: 'my_tool',
schema: z.object({
coords: z.tuple([z.number(), z.number()])
}) as any, // ← Add 'as any' to prevent type recursion
func: async ({ coords }: any) => {
// ← Type parameters as 'any'
// Implementation
}
});
// For JSON responses from external APIs
const data = (await response.json()) as any;
// For createOpenAIFunctionsAgent with complex tool types
// @ts-ignore - Zod tuple schemas cause deep type recursion
const agent = await createOpenAIFunctionsAgent({ llm, tools, prompt });
```
This doesn't affect runtime validation (Zod still validates at runtime) - it only helps TypeScript's type checker avoid infinite recursion during compilation.
### Pattern 6: Custom Agent Integration
**Use case:** Building domain-specific AI applications (Zillow-style, TripAdvisor-style)
```typescript
interface MCPTool {
name: string;
description: string;
inputSchema: any;
}
class CustomMapboxAgent {
private url = 'https://mcp.mapbox.com/mcp';
private headers: Record<string, string>;
private tools: Map<string, MCPTool> = new Map();
constructor(token?: string) {
const mapboxToken = token || process.env.MAPBOX_ACCESS_TOKEN;
this.headers = {
'Content-Type': 'application/json',
Authorization: `Bearer ${mapboxToken}`
};
}
async initialize() {
// Discover available tools from MCP server
await this.discoverTools();
}
private async discoverTools() {
const request = {
jsonrpc: '2.0',
id: 1,
method: 'tools/list'
};
const response = await this.sendMCPRequest(request);
response.result.tools.forEach((tool: MCPTool) => {
this.tools.set(tool.name, tool);
});
}
async callTool(toolName: string, params: any): Promise<any> {
const request = {
jsonrpc: '2.0',
id: Date.now(),
method: 'tools/call',
params: { name: toolName, arguments: params }
};
const response = await this.sendMCPRequest(request);
return response.result.content[0].text;
}
private async sendMCPRequest(request: any): Promise<any> {
const response = await fetch(this.url, {
method: 'POST',
headers: this.headers,
body: JSON.stringify(request)
});
const data = await response.json();
if (data.error) {
throw new Error(data.error.message);
}
return data;
}
// Domain-specific methods
async findPropertiesWithCommute(
homeLocation: [number, number],
workLocation: [number, number],
maxCommuteMinutes: number
) {
// Get isochrone from work location
const isochrone = await this.callTool('isochrone_tool', {
coordinates: { longitude: workLocation[0], latitude: workLocation[1] },
contours_minutes: [maxCommuteMinutes],
profile: 'mapbox/driving-traffic'
});
// Check if home is within isochrone
const isInRange = await this.callTool('point_in_polygon_tool', {
point: { longitude: homeLocation[0], latitude: homeLocation[1] },
polygon: JSON.parse(isochrone).features[0].geometry
});
return JSON.parse(isInRange);
}
async findRestaurantsNearby(location: [number, number], radiusMiles: number) {
// Search restaurants
const results = await this.callTool('category_search_tool', {
category: 'restaurant',
proximity: { longitude: location[0], latitude: location[1] }
});
// Filter by distance
const restaurants = JSON.parse(results);
const filtered = [];
for (const restaurant of restaurants) {
const distance = await this.callTool('distance_tool', {
from: { longitude: location[0], latitude: location[1] },
to: { longitude: restaurant.coordinates[0], latitude: restaurant.coordinates[1] },
units: 'miles'
});
if (parseFloat(distance) <= radiusMiles) {
filtered.push({
...restaurant,
distance: parseFloat(distance)
});
}
}
return filtered.sort((a, b) => a.distance - b.distance);
}
}
// Usage in Zillow-style app
const agent = new CustomMapboxAgent();
await agent.initialize();
const properties = await agent.findPropertiesWithCommute(
[-122.4194, 37.7749], // Home in SF
[-122.4, 37.79], // Work downtown
30 // Max 30min commute
);
// Usage in TripAdvisor-style app
const restaurants = await agent.findRestaurantsNearby(
[-73.9857, 40.7484], // Times Square
0.5 // Within 0.5 miles
);
```
**Benefits:**
- Full control over agent behavior
- Domain-specific abstractions
- Custom error handling
## Architecture Patterns
### Pattern: MCP as Service Layer
```
┌─────────────────────────────────────┐
│ Your Application │
│ (Next.js, Express, FastAPI, etc.) │
└────────────────┬────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ AI Agent Layer │
│ (pydantic-ai, mastra, custom) │
└────────────────┬────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ Mapbox MCP Server │
│ (Geospatial tools abstraction) │
└────────────────┬────────────────────┘
│
┌──────┴──────┐
▼ ▼
┌─────────┐ ┌──────────┐
│ Turf.js │ │ Mapbox │
│ (Local) │ │ APIs │
└─────────┘ └──────────┘
```
**Benefits:**
- Clean separation of concerns
- Easy to swap MCP server versions
- Centralized geospatial logic
### Pattern: Hybrid Approach
You can use MCP for AI agent features while using direct Mapbox APIs for other parts of your app.
```typescript
class GeospatialService {
constructor(
private mcpServer: MapboxMCPServer, // For AI features
private mapboxSdk: MapboxSDK // For direct app features
) {}
// AI Agent Feature: Natural language search
async aiSearchNearby(userQuery: string): Promise<string> {
// Let AI agent use MCP tools to interpret query and find places
// Returns natural language response
return await this.agent.execute(userQuery, [
this.mcpServer.tools.category_search_tool,
this.mcpServer.tools.directions_tool
]);
}
// Direct App Feature: Display route on map
async getRouteGeometry(origin: Point, dest: Point): Promise<LineString> {
// Direct API call for map rendering - returns GeoJSON
const result = await this.mapboxSdk.directions.getDirections({
waypoints: [origin, dest],
geometries: 'geojson'
});
return result.routes[0].geometry;
}
// Offline Feature: Distance calculations (always use MCP/Turf.js)
async calculateDistance(from: Point, to: Point): Promise<number> {
// No API cost, instant
return await this.mcpServer.callTool('distance_tool', {
from,
to,
units: 'miles'
});
}
}
```
**Architecture Decision Guide:**
| Use Case | Use This | Why |
| ---------------------------------- | -------------------------- | ------------------------------------------------ |
| AI agent natural language features | MCP Server | Simplified tool interface, AI-friendly responses |
| Map rendering, direct UI controls | Mapbox SDK | More control, better performance |
| Distance/area calculations | MCP Server (offline tools) | Free, instant, no API calls |
| Custom map styling | Mapbox SDK | Fine-grained style control |
| Conversational geospatial queries | MCP Server | AI agent can chain tools |
## Use Cases by Application Type
### Real Estate App (Zillow-style)
```typescript
// Find properties with good commute
async findPropertiesByCommute(
searchArea: Polygon,
workLocation: Point,
maxCommuteMinutes: number
) {
// 1. Get isochrone from work
const reachableArea = await mcp.callTool('isochrone_tool', {
coordinates: { longitude: workLocation[0], latitude: workLocation[1] },
contours_minutes: [maxCommuteMinutes],
profile: 'mapbox/driving'
});
// 2. Check each property
const propertiesInRange = [];
for (const property of properties) {
const inRange = await mcp.callTool('point_in_polygon_tool', {
point: { longitude: property.location[0], latitude: property.location[1] },
polygon: reachableArea
});
if (inRange) {
// 3. Get exact commute time
const directions = await mcp.callTool('directions_tool', {
coordinates: [property.location, workLocation],
routing_profile: 'mapbox/driving-traffic'
});
propertiesInRange.push({
...property,
commuteTime: directions.duration / 60
});
}
}
return propertiesInRange;
}
```
### Food Delivery App (DoorDash-style)
```typescript
// Check if restaurant can deliver to address
async canDeliver(
restaurantLocation: Point,
deliveryAddress: Point,
maxDeliveryTime: number
) {
// 1. Calculate delivery zone
const deliveryZone = await mcp.callTool('isochrone_tool', {
coordinates: restaurantLocation,
contours_minutes: [maxDeliveryTime],
profile: 'mapbox/driving'
});
// 2. Check if address is in zone
const canDeliver = await mcp.callTool('point_in_polygon_tool', {
point: deliveryAddress,
polygon: deliveryZone
});
if (!canDeliver) return false;
// 3. Get accurate delivery time
const route = await mcp.callTool('directions_tool', {
coordinates: [restaurantLocation, deliveryAddress],
routing_profile: 'mapbox/driving-traffic'
});
return {
canDeliver: true,
estimatedTime: route.duration / 60,
distance: route.distance
};
}
```
### Travel Planning App (TripAdvisor-style)
```typescript
// Build day itinerary with travel times
async buildItinerary(
hotel: Point,
attractions: Array<{name: string, location: Point}>
) {
// 1. Calculate distances from hotel
const attractionsWithDistance = await Promise.all(
attractions.map(async (attr) => ({
...attr,
distance: await mcp.callTool('distance_tool', {
from: hotel,
to: attr.location,
units: 'miles'
})
}))
);
// 2. Get travel time matrix
const matrix = await mcp.callTool('matrix_tool', {
origins: [hotel],
destinations: attractions.map(a => a.location),
profile: 'mapbox/walking'
});
// 3. Sort by walking time
return attractionsWithDistance
.map((attr, idx) => ({
...attr,
walkingTime: matrix.durations[0][idx] / 60
}))
.sort((a, b) => a.walkingTime - b.walkingTime);
}
```
## Performance Optimization
### Caching Strategy
```typescript
class CachedMapboxMCP {
private cache = new Map<string, { result: any; timestamp: number }>();
private cacheTTL = 3600000; // 1 hour
async callTool(name: string, params: any): Promise<any> {
// Cache offline tools indefinitely (deterministic)
const offlineTools = ['distance_tool', 'point_in_polygon_tool', 'bearing_tool'];
const ttl = offlineTools.includes(name) ? Infinity : this.cacheTTL;
// Check cache
const cacheKey = JSON.stringify({ name, params });
const cached = this.cache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < ttl) {
return cached.result;
}
// Call MCP
const result = await this.mcpServer.callTool(name, params);
// Store in cache
this.cache.set(cacheKey, {
result,
timestamp: Date.now()
});
return result;
}
}
```
### Batch Operations
```typescript
// ❌ Bad: Sequential calls
for (const location of locations) {
const distance = await mcp.callTool('distance_tool', {
from: userLocation,
to: location
});
}
// ✅ Good: Parallel batch
const distances = await Promise.all(
locations.map((location) =>
mcp.callTool('distance_tool', {
from: userLocation,
to: location
})
)
);
// ✅ Better: Use matrix tool
const matrix = await mcp.callTool('matrix_tool', {
origins: [userLocation],
destinations: locations
});
```
### Writing Effective Tool Descriptions
Clear, specific tool descriptions are critical for helping LLMs select the right tools. Poor descriptions lead to incorrect tool calls, wasted API requests, and user frustration.
#### Common Confusion Points
**Problem: "How far is it from A to B?"** - Could trigger either `directions_tool` OR `distance_tool`
```typescript
// ❌ Ambiguous descriptions
{
name: 'directions_tool',
description: 'Get directions between two locations' // Could mean distance
}
{
name: 'distance_tool',
description: 'Calculate distance between two points' // Unclear what kind
}
// ✅ Clear, specific descriptions
{
name: 'directions_tool',
description: 'Get turn-by-turn driving directions with traffic-aware route distance and travel time. Use when you need the actual route, navigation instructions, or driving duration. Returns route geometry, distance along roads, and time estimate.'
}
{
name: 'distance_tool',
description: 'Calculate straight-line (great-circle) distance between two points. Use for quick "as the crow flies" distance checks, proximity comparisons, or when routing is not needed. Works offline, instant, no API cost.'
}
```
**Problem: "Find coffee shops nearby"** - Could trigger `category_search_tool` OR `search_and_geocode_tool`
```typescript
// ❌ Ambiguous
{
name: 'search_poi',
description: 'Search for places'
}
// ✅ Clear when to use each
{
name: 'category_search_tool',
description: 'Find ALL places of a specific type/category (e.g., "all coffee shops", "restaurants", "gas stations") near a location. Use for browsing or discovering places by category. Returns multiple results.'
}
{
name: 'search_and_geocode_tool',
description: 'Search for a SPECIFIC named place or address (e.g., "Starbucks on Main St", "123 Market St"). Use when the user provides a business name, street address, or landmark. Returns best match.'
}
```
**Problem: "Where can I go in 15 minutes?"** - Could trigger `isochrone_tool` OR `directions_tool`
```typescript
// ❌ Confusing
{
name: 'isochrone_tool',
description: 'Calculate travel time area'
}
// ✅ Clear distinction
{
name: 'isochrone_tool',
description: 'Calculate the AREA reachable within a time limit from a starting point. Returns a GeoJSON polygon showing everywhere you can reach. Use for: "What can I reach in X minutes?", service area analysis, catchment zones, delivery zones.'
}
{
name: 'directions_tool',
description: 'Get route from point A to specific point B. Returns turn-by-turn directions to ONE destination. Use for: "How do I get to X?", "Route from A to B", navigation to a known destination.'
}
```
#### Best Practices for Tool Descriptions
1. **Start with the primary use case** in simple terms
2. **Explain WHEN to use this tool** vs alternatives
3. **Include key distinguishing details**: Does it use traffic? Is it offline? Does it cost API calls?
4. **Give concrete examples** of questions that should trigger this tool
5. **Mention what it returns** so LLMs know if it fits the user's need
```typescript
// ✅ Complete example
const searchPOITool = new DynamicStructuredTool({
name: 'category_search_tool',
description: `Find places by category type (restaurants, hotels, coffee shops, gas stations, etc.) near a location.
Use when the user wants to:
- Browse places of a certain type: "coffee shops nearby", "find restaurants"
- Discover options: "what hotels are in this area?"
- Search by industry/amenity, not by specific name
Returns: List of matching places with names, addresses, and coordinates.
DO NOT use for:
- Specific named places (use search_and_geocode_tool instead)
- Addresses (use search_and_geocode_tool or reverse_geocode_tool)`
// ... schema and implementation
});
```
#### System Prompt Guidance
Add tool selection guidance to your agent's system prompt:
```typescript
const systemPrompt = `You are a location intelligence assistant.
TOOL SELECTION RULES:
- Use distance_tool for straight-line distance ("as the crow flies")
- Use directions_tool for route distance along roads with traffic
- Use category_search_tool for finding types of places ("coffee shops")
- Use search_and_geocode_tool for specific addresses or named places ("123 Main St", "Starbucks downtown")
- Use isochrone_tool for "what can I reach in X minutes" questions
- Use offline tools (distance_tool, point_in_polygon_tool) when real-time data is not needed
When in doubt, prefer:
1. Offline tools over API calls (faster, free)
2. Specific tools over general ones
3. Asking for clarification over guessing`;
```
### Tool Selection
```typescript
// Use offline tools when possible (faster, free)
const localOps = {
distance: 'distance_tool', // Turf.js
pointInPolygon: 'point_in_polygon_tool', // Turf.js
bearing: 'bearing_tool', // Turf.js
area: 'area_tool' // Turf.js
};
// Use API tools when necessary (requires token, slower)
const apiOps = {
directions: 'directions_tool', // Mapbox API
geocoding: 'reverse_geocode_tool', // Mapbox API
isochrone: 'isochrone_tool', // Mapbox API
search: 'category_search_tool' // Mapbox API
};
// Choose based on requirements
function chooseTool(operation: string, needsRealtime: boolean) {
if (needsRealtime) {
return apiOps[operation]; // Traffic, live data
}
return localOps[operation] || apiOps[operation];
}
```
## Error Handling
```typescript
class RobustMapboxMCP {
async callToolWithRetry(name: string, params: any, maxRetries: number = 3): Promise<any> {
for (let i = 0; i < maxRetries; i++) {
try {
return await this.mcpServer.callTool(name, params);
} catch (error) {
if (error.code === 'RATE_LIMIT') {
// Exponential backoff
await this.sleep(Math.pow(2, i) * 1000);
continue;
}
if (error.code === 'INVALID_TOKEN') {
// Non-retryable error
throw error;
}
if (i === maxRetries - 1) {
throw error;
}
}
}
}
async callToolWithFallback(primaryTool: string, fallbackTool: string, params: any): Promise<any> {
try {
return await this.callTool(primaryTool, params);
} catch (error) {
console.warn(`Primary tool ${primaryTool} failed, using fallback`);
return await this.callTool(fallbackTool, params);
}
}
}
```
## Security Best Practices
### Token Management
```typescript
// ✅ Good: Use environment variables
const mcp = new MapboxMCP({
token: process.env.MAPBOX_ACCESS_TOKEN
});
// ❌ Bad: Hardcode tokens
const mcp = new MapboxMCP({
token: 'pk.ey...' // Never do this!
});
// ✅ Good: Use scoped tokens
// Create token with minimal scopes:
// - directions:read
// - geocoding:read
// - No write permissions
```
### Rate Limiting
```typescript
class RateLimitedMCP {
private requestQueue: Array<() => Promise<any>> = [];
private requestsPerMinute = 300;
private currentMinute = Math.floor(Date.now() / 60000);
private requestCount = 0;
async callTool(name: string, params: any): Promise<any> {
// Check rate limit
const minute = Math.floor(Date.now() / 60000);
if (minute !== this.currentMinute) {
this.currentMinute = minute;
this.requestCount = 0;
}
if (this.requestCount >= this.requestsPerMinute) {
// Wait until next minute
const waitMs = (this.currentMinute + 1) * 60000 - Date.now();
await this.sleep(waitMs);
}
this.requestCount++;
return await this.mcpServer.callTool(name, params);
}
}
```
## Testing
```typescript
// Mock MCP server for testing
class MockMapboxMCP {
async callTool(name: string, params: any): Promise<any> {
const mocks = {
distance_tool: () => '2.5',
directions_tool: () => JSON.stringify({
duration: 1200,
distance: 5000,
geometry: {...}
}),
point_in_polygon_tool: () => 'true'
};
return mocks[name]?.() || '{}';
}
}
// Use in tests
describe('Property search', () => {
it('finds properties within commute time', async () => {
const agent = new CustomMapboxAgent(new MockMapboxMCP());
const results = await agent.findPropertiesWithCommute(
[-122.4, 37.7],
[-122.41, 37.78],
30
);
expect(results).toHaveLength(5);
});
});
```
## Resources
- [Mapbox MCP Server](https://github.com/mapbox/mcp-server)
- [Model Context Protocol](https://modelcontextprotocol.io)
- [Pydantic AI](https://ai.pydantic.dev/)
- [Mastra](https://mastra.ai/)
- [LangChain](https://docs.langchain.com/oss/javascript/langchain/overview/)
- [Mapbox API Documentation](https://docs.mapbox.com/api/)
## When to Use This Skill
Invoke this skill when:
- Integrating Mapbox MCP Server into AI applications
- Building AI agents with geospatial capabilities
- Architecting Zillow/TripAdvisor/DoorDash-style apps with AI
- Choosing between MCP, direct APIs, or SDKs
- Optimizing geospatial operations in production
- Implementing error handling for geospatial AI features
- Testing AI applications with geospatial toolsComplete workflow for implementing Mapbox search in applications - from discovery questions to production-ready integration with best practices
# Mapbox Search Integration Skill
Expert guidance for implementing Mapbox search functionality in applications. Covers the complete workflow from asking the right discovery questions, selecting the appropriate search product, to implementing production-ready integrations following best practices from the Mapbox search team.
## Use This Skill When
User says things like:
- "I need to add search to my map"
- "I need a search bar for my mapping app"
- "How do I implement location search?"
- "I want users to search for places/addresses"
- "I need geocoding in my application"
**This skill complements `mapbox-search-patterns`:**
- `mapbox-search-patterns` = Tool and parameter selection
- `mapbox-search-integration` = Complete implementation workflow
## Discovery Phase: Ask the Right Questions
Before jumping into code, ask these questions to understand requirements:
### Question 1: What are users searching for?
**Ask:** "What do you want users to search for?"
**Common answers and implications:**
- **"Addresses"** → Focus on address geocoding, consider Search Box API or Geocoding API
- **"Points of interest / businesses"** → POI search, use Search Box API with category search
- **"Both addresses and POIs"** → Search Box API
- **"Specific types of POIs"** (restaurants, hotels, etc.) → Search Box API
- **"Countries, cities, postcodes or neighborhoods"** → Geocoding API
- **"Custom locations"** (user-created places) → May need custom data + search integration
**Follow-up if not stated initially**: "Are your users searching for points of interest data? Restaurants, stores, categories of businesses?"
**Implications:**
- **"Yes, POIs are included"** → Use the Search Box API
- **"No, the user does not need POI search"** → Use the Geocoding API
### Question 2: What's the geographic scope?
**Ask:** "Where will users be searching?"
**Common answers and implications:**
- **"Single country"** (e.g., "only USA") → Use `country` parameter, better results, lower cost
- **"Specific region"** → Use `bbox` parameter for bounding box constraint
- **"Global"** → No country restriction, but may need language parameter
- **"Multiple specific countries"** → Use `country` array parameter
**Follow-up:** "Do you need to limit results to a specific area?" (delivery zone, service area, etc.)
### Question 3: What's the search interaction pattern?
**Ask:** "How will users interact with search?"
**Common answers and implications:**
- **"Search-as-you-type / autocomplete"** → Use `auto_complete: true`, for Search Box API, or `autocomplete=true` for Geocoding; also implement debouncing
- **"Search button / final query"** → Can use either API, no autocomplete needed
- **"Both"** (autocomplete + refine) → Two-stage search, autocomplete then detailed results
- **"Voice input"** → Consider speech-to-text integration, handle longer queries
### Question 4: What platform?
**Ask:** "What platform is this for?"
**Common answers and implications:**
- **"Web application"** → Mapbox Search JS (easiest), or direct API calls for advanced cases
- **"iOS app"** → Search SDK for iOS (recommended), or direct API integration for advanced cases
- **"Android app"** → Search SDK for Android (recommended), or direct API integration for advanced cases
- **"Multiple platforms"** → Platform-specific SDKs (recommended), or direct API approach for consistency
- **"React app"** → Mapbox Search JS React (easiest with UI), or Search JS Core for custom UI
- **"Vue / Angular / Other framework"** → Mapbox Search JS Core or Web, or direct API calls
### Question 5: How will results be used?
**Ask:** "What happens when a user selects a result?"
**Common answers and implications:**
- **"Fly to location on map"** → Need coordinates, map integration
- **"Show details / info"** → Need to retrieve and display result properties
- **"Fill form fields"** → Need to parse address components
- **"Start navigation"** → Need coordinates, integrate with directions
- **"Multiple selection"** → Need to handle selection state, possibly show markers
### Question 6: Expected usage volume?
**Ask:** "How many searches do you expect per month?"
**Implications:**
- **Low volume** (< 10k) → Free tier sufficient, simple implementation
- **Medium volume** (10k-100k) → Consider caching, optimize API calls
- **High volume** (> 100k) → Implement debouncing, caching, batch operations, monitor costs
## Product Selection Decision Tree
Based on discovery answers, recommend the right product:
### Search Box API
**Use when:**
- User needs POI data
- Need session-based pricing
\*Products:\*\*
- **Search Box API** (REST) - Direct API integration
- **Mapbox Search JS** (SDK) - Web integration with three components:
- **Search JS React** - Easy search integration via React library with UI
- **Search JS Web** - Easy search integration via Web Components with UI
- **Search JS Core** - JavaScript (node or web) wrapper for API, build your own UI
- **Search SDK for iOS** - Native iOS integration
- **Search SDK for Android** - Native Android integration
### Geocoding API
**Use when:**
- No POI data needed
- Need permanent geocoding (not search)
- Batch geocoding jobs
## Integration Patterns by Platform
**Important:** Always prefer using SDKs (Mapbox Search JS, Search SDK for iOS/Android) over calling APIs directly. SDKs handle debouncing, session tokens, error handling, and provide UI components. Only use direct API calls for advanced use cases.
### Web: Mapbox Search JS (Recommended)
#### Option 1: Search JS React (Easiest - React apps with UI)
**When to use:** React application, want autocomplete UI component, fastest implementation
**Installation:**
```bash
npm install @mapbox/search-js-react
```
**Complete implementation:**
```jsx
import { SearchBox } from '@mapbox/search-js-react';
import mapboxgl from 'mapbox-gl';
function App() {
const [map, setMap] = React.useState(null);
React.useEffect(() => {
mapboxgl.accessToken = 'YOUR_MAPBOX_TOKEN';
const mapInstance = new mapboxgl.Map({
container: 'map',
style: 'mapbox://styles/mapbox/streets-v12',
center: [-122.4194, 37.7749],
zoom: 12
});
setMap(mapInstance);
}, []);
const handleRetrieve = (result) => {
const [lng, lat] = result.features[0].geometry.coordinates;
map.flyTo({ center: [lng, lat], zoom: 14 });
new mapboxgl.Marker().setLngLat([lng, lat]).addTo(map);
};
return (
<div>
<SearchBox accessToken="YOUR_MAPBOX_TOKEN" onRetrieve={handleRetrieve} placeholder="Search for places" />
<div id="map" style={{ height: '600px' }} />
</div>
);
}
```
#### Option 2: Search JS Web (Web Components with UI)
**When to use:** Vanilla JavaScript, Web Components, or any framework, want autocomplete UI
**Complete implementation:**
```html
<!DOCTYPE html>
<html>
<head>
<script src="https://api.mapbox.com/search-js/v1.0.0-beta.18/web.js"></script>
<link href="https://api.mapbox.com/search-js/v1.0.0-beta.18/web.css" rel="stylesheet" />
<script src="https://api.mapbox.com/mapbox-gl-js/v3.0.0/mapbox-gl.js"></script>
<link href="https://api.mapbox.com/mapbox-gl-js/v3.0.0/mapbox-gl.css" rel="stylesheet" />
</head>
<body>
<div id="search"></div>
<div id="map" style="height: 600px;"></div>
<script>
// Initialize map
mapboxgl.accessToken = 'YOUR_MAPBOX_TOKEN';
const map = new mapboxgl.Map({
container: 'map',
style: 'mapbox://styles/mapbox/streets-v12',
center: [-122.4194, 37.7749],
zoom: 12
});
// Initialize Search Box
const search = new MapboxSearchBox();
search.accessToken = 'YOUR_MAPBOX_TOKEN';
// CRITICAL: Set options based on discovery
search.options = {
language: 'en',
country: 'US', // If single-country (from Question 2)
proximity: 'ip', // Or specific coordinates
types: 'address,poi' // Based on Question 1
};
search.mapboxgl = mapboxgl;
search.marker = true; // Auto-add marker on result selection
// Handle result selection
search.addEventListener('retrieve', (event) => {
const result = event.detail;
// Fly to result
map.flyTo({
center: result.geometry.coordinates,
zoom: 15,
essential: true
});
// Optional: Show popup with details
new mapboxgl.Popup()
.setLngLat(result.geometry.coordinates)
.setHTML(
`<h3>${result.properties.name}</h3>
<p>${result.properties.full_address || ''}</p>`
)
.addTo(map);
});
// Attach to DOM
document.getElementById('search').appendChild(search);
</script>
</body>
</html>
```
**Key implementation notes:**
- ✅ Set `country` if single-country search (better results, lower cost)
- ✅ Set `types` based on what users search for
- ✅ Use `proximity` to bias results to user location
- ✅ Handle `retrieve` event for result selection
- ✅ Integrate with map (flyTo, markers, popups)
#### Option 3: Search JS Core (Custom UI)
**When to use:** Need custom UI design, full control over UX, works in any framework or Node.js
**Installation:**
```bash
npm install @mapbox/search-js-core
```
**Complete implementation:**
```javascript
import { SearchSession } from '@mapbox/search-js-core';
import mapboxgl from 'mapbox-gl';
// Initialize search session
const search = new SearchSession({
accessToken: 'YOUR_MAPBOX_TOKEN'
});
// Initialize map
mapboxgl.accessToken = 'YOUR_MAPBOX_TOKEN';
const map = new mapboxgl.Map({
container: 'map',
style: 'mapbox://styles/mapbox/streets-v12',
center: [-122.4194, 37.7749],
zoom: 12
});
// Your custom search input
const searchInput = document.getElementById('search-input');
const resultsContainer = document.getElementById('results');
// Handle user input
searchInput.addEventListener('input', async (e) => {
const query = e.target.value;
if (query.length < 2) {
resultsContainer.innerHTML = '';
return;
}
// Get suggestions (Search JS Core handles debouncing and session tokens)
const response = await search.suggest(query, {
proximity: map.getCenter().toArray(),
country: 'US', // Optional
types: ['address', 'poi']
});
// Render custom results UI
resultsContainer.innerHTML = response.suggestions
.map(
(suggestion) => `
<div class="result-item" data-id="${suggestion.mapbox_id}">
<strong>${suggestion.name}</strong>
<div>${suggestion.place_formatted}</div>
</div>
`
)
.join('');
});
// Handle result selection
resultsContainer.addEventListener('click', async (e) => {
const resultItem = e.target.closest('.result-item');
if (!resultItem) return;
const mapboxId = resultItem.dataset.id;
// Retrieve full details
const result = await search.retrieve(mapboxId);
const feature = result.features[0];
const [lng, lat] = feature.geometry.coordinates;
// Update map
map.flyTo({ center: [lng, lat], zoom: 15 });
new mapboxgl.Marker().setLngLat([lng, lat]).addTo(map);
// Clear search
searchInput.value = feature.properties.name;
resultsContainer.innerHTML = '';
});
```
**Key benefits:**
- ✅ Full control over UI/UX
- ✅ Search JS Core handles session tokens automatically
- ✅ Works in any framework (React, Vue, Angular, etc.)
- ✅ Can use in Node.js for server-side search
#### Option 4: Direct API Integration (Advanced - Last Resort)
**When to use:** Very specific requirements that SDKs don't support, or server-side integration where Search JS Core doesn't fit
**Important:** Only use direct API calls when SDKs don't meet your needs. You'll need to handle debouncing and session tokens manually.
**When to use:** Custom UI, framework integration, need full control
**Complete implementation with debouncing:**
```javascript
import mapboxgl from 'mapbox-gl';
class MapboxSearch {
constructor(accessToken, options = {}) {
this.accessToken = accessToken;
this.options = {
country: options.country || null, // e.g., 'US'
language: options.language || 'en',
proximity: options.proximity || 'ip',
types: options.types || 'address,poi',
limit: options.limit || 5,
...options
};
this.debounceTimeout = null;
this.sessionToken = this.generateSessionToken();
}
generateSessionToken() {
return `${Date.now()}-${Math.random().toString(36).substring(7)}`;
}
// CRITICAL: Debounce to avoid API spam
async search(query, callback, debounceMs = 300) {
clearTimeout(this.debounceTimeout);
this.debounceTimeout = setTimeout(async () => {
const results = await this.performSearch(query);
callback(results);
}, debounceMs);
}
async performSearch(query) {
if (!query || query.length < 2) return [];
const params = new URLSearchParams({
q: query,
access_token: this.accessToken,
session_token: this.sessionToken,
language: this.options.language,
limit: this.options.limit
});
// Add optional parameters
if (this.options.country) {
params.append('country', this.options.country);
}
if (this.options.types) {
params.append('types', this.options.types);
}
if (this.options.proximity && this.options.proximity !== 'ip') {
params.append('proximity', this.options.proximity);
}
try {
const response = await fetch(`https://api.mapbox.com/search/searchbox/v1/suggest?${params}`);
if (!response.ok) {
throw new Error(`Search API error: ${response.status}`);
}
const data = await response.json();
return data.suggestions || [];
} catch (error) {
console.error('Search error:', error);
return [];
}
}
async retrieve(suggestionId) {
const params = new URLSearchParams({
access_token: this.accessToken,
session_token: this.sessionToken
});
try {
const response = await fetch(`https://api.mapbox.com/search/searchbox/v1/retrieve/${suggestionId}?${params}`);
if (!response.ok) {
throw new Error(`Retrieve API error: ${response.status}`);
}
const data = await response.json();
// Session ends on retrieve - generate new token for next search
this.sessionToken = this.generateSessionToken();
return data.features[0];
} catch (error) {
console.error('Retrieve error:', error);
return null;
}
}
}
// Usage example
const search = new MapboxSearch('YOUR_MAPBOX_TOKEN', {
country: 'US', // Based on discovery Question 2
types: 'poi', // Based on discovery Question 1
proximity: [-122.4194, 37.7749] // Or 'ip' for user location
});
// Attach to input field
const input = document.getElementById('search-input');
const resultsContainer = document.getElementById('search-results');
input.addEventListener('input', (e) => {
const query = e.target.value;
search.search(query, (results) => {
displayResults(results);
});
});
function displayResults(results) {
resultsContainer.innerHTML = results
.map(
(result) => `
<div class="result" data-id="${result.mapbox_id}">
<strong>${result.name}</strong>
<p>${result.place_formatted || ''}</p>
</div>
`
)
.join('');
// Handle result selection
resultsContainer.querySelectorAll('.result').forEach((el) => {
el.addEventListener('click', async () => {
const feature = await search.retrieve(el.dataset.id);
handleResultSelection(feature);
});
});
}
function handleResultSelection(feature) {
const [lng, lat] = feature.geometry.coordinates;
// Fly map to result
map.flyTo({
center: [lng, lat],
zoom: 15
});
// Add marker
new mapboxgl.Marker().setLngLat([lng, lat]).addTo(map);
// Close results
resultsContainer.innerHTML = '';
input.value = feature.properties.name;
}
```
**Critical implementation details:**
1. ✅ **Debouncing**: Wait 300ms after user stops typing before API call
2. ✅ **Session tokens**: Use same token for suggest + retrieve, generate new after
3. ✅ **Error handling**: Handle API errors gracefully
4. ✅ **Parameter optimization**: Only send parameters you need
5. ✅ **Result display**: Show name + formatted address
6. ✅ **Selection handling**: Retrieve full feature on selection
### React Integration Pattern
**Best Practice:** Use Search JS React for easiest implementation, or Search JS Core for custom UI.
#### Option 1: Search JS React (Recommended - Easiest)
```javascript
import { SearchBox } from '@mapbox/search-js-react';
import mapboxgl from 'mapbox-gl';
import { useState } from 'react';
function MapboxSearchComponent() {
const [map, setMap] = useState(null);
useEffect(() => {
const mapInstance = new mapboxgl.Map({
container: 'map',
style: 'mapbox://styles/mapbox/streets-v12',
center: [-122.4194, 37.7749],
zoom: 12
});
setMap(mapInstance);
}, []);
const handleRetrieve = (result) => {
const [lng, lat] = result.features[0].geometry.coordinates;
map.flyTo({ center: [lng, lat], zoom: 14 });
new mapboxgl.Marker().setLngLat([lng, lat]).addTo(map);
};
return (
<div>
<SearchBox
accessToken="YOUR_MAPBOX_TOKEN"
onRetrieve={handleRetrieve}
placeholder="Search for places"
options={{
country: 'US', // Optional
types: 'address,poi'
}}
/>
<div id="map" style={{ height: '600px' }} />
</div>
);
}
```
**Benefits:**
- ✅ Complete UI component provided
- ✅ No manual debouncing needed
- ✅ No manual session token management
- ✅ Production-ready out of the box
#### Option 2: Search JS Core (Custom UI)
```javascript
import { useState, useEffect } from 'react';
import { SearchSession } from '@mapbox/search-js-core';
import mapboxgl from 'mapbox-gl';
function MapboxSearchComponent({ country, types = 'address,poi' }) {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [isLoading, setIsLoading] = useState(false);
// Search JS Core handles debouncing and session tokens automatically
const searchSession = new SearchSession({
accessToken: 'YOUR_MAPBOX_TOKEN'
});
useEffect(() => {
const performSearch = async () => {
if (!query || query.length < 2) {
setResults([]);
return;
}
setIsLoading(true);
try {
const response = await searchSession.suggest(query, {
country,
types,
limit: 5
});
setResults(response.suggestions || []);
} catch (error) {
console.error('Search error:', error);
setResults([]);
} finally {
setIsLoading(false);
}
};
performSearch();
}, [query]);
const handleResultClick = async (suggestion) => {
try {
const result = await searchSession.retrieve(suggestion);
const feature = result.features[0];
// Handle result (fly to location, add marker, etc.)
onResultSelect(feature);
setQuery(feature.properties.name);
setResults([]);
} catch (error) {
console.error('Retrieve error:', error);
}
};
return (
<div className="search-container">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search for a place..."
className="search-input"
/>
{isLoading && <div className="loading">Searching...</div>}
{results.length > 0 && (
<div className="search-results">
{results.map((result) => (
<div key={result.mapbox_id} className="search-result" onClick={() => handleResultClick(result)}>
<strong>{result.name}</strong>
{result.place_formatted && <p>{result.place_formatted}</p>}
</div>
))}
</div>
)}
</div>
);
}
```
**Benefits:**
- ✅ Full control over UI design
- ✅ Search JS Core handles debouncing automatically
- ✅ Search JS Core handles session tokens automatically
- ✅ Cleaner code than direct API calls
**Note:** For React apps, prefer Search JS React (Option 1) unless you need a completely custom UI design.
### iOS: Search SDK for iOS (Recommended)
#### Option 1: Search SDK with UI (Easiest)
**When to use:** iOS app, want pre-built search UI, fastest implementation
**Installation:**
```swift
// Add to Package.swift or SPM
dependencies: [
.package(url: "https://github.com/mapbox/mapbox-search-ios.git", from: "2.0.0")
]
```
**Complete implementation with built-in UI:**
```swift
import MapboxSearch
import MapboxMaps
class SearchViewController: UIViewController {
private var searchController: MapboxSearchController!
private var mapView: MapView!
override func viewDidLoad() {
super.viewDidLoad()
setupMap()
setupSearchWithUI()
}
func setupMap() {
mapView = MapView(frame: view.bounds)
view.addSubview(mapView)
}
func setupSearchWithUI() {
// MapboxSearchController provides complete UI automatically
searchController = MapboxSearchController()
searchController.delegate = self
// Present the search UI
present(searchController, animated: true)
}
}
extension SearchViewController: SearchControllerDelegate {
func searchResultSelected(_ searchResult: SearchResult) {
// SDK handled all the search interaction
// Just respond to selection
mapView.camera.fly(to: CameraOptions(
center: searchResult.coordinate,
zoom: 15
))
let annotation = PointAnnotation(coordinate: searchResult.coordinate)
mapView.annotations.pointAnnotations = [annotation]
dismiss(animated: true)
}
}
```
#### Option 2: Search SDK Core (Custom UI)
**When to use:** Need custom UI, integrate with UISearchController, full control over UX
**Complete implementation:**
```swift
import MapboxSearch
import MapboxMaps
class SearchViewController: UIViewController {
private var searchEngine: SearchEngine!
private var mapView: MapView!
override func viewDidLoad() {
super.viewDidLoad()
// Initialize Search Engine (SDK handles debouncing and session tokens)
searchEngine = SearchEngine(accessToken: "YOUR_MAPBOX_TOKEN")
setupSearchBar()
setupMap()
}
func setupSearchBar() {
let searchController = UISearchController(searchResultsController: nil)
searchController.searchResultsUpdater = self
searchController.obscuresBackgroundDuringPresentation = false
navigationItem.searchController = searchController
}
func setupMap() {
mapView = MapView(frame: view.bounds)
view.addSubview(mapView)
}
}
extension SearchViewController: UISearchResultsUpdating {
func updateSearchResults(for searchController: UISearchController) {
guard let query = searchController.searchBar.text, !query.isEmpty else {
return
}
// Search SDK handles debouncing automatically
searchEngine.search(query: query) { [weak self] result in
switch result {
case .success(let results):
self?.displayResults(results)
case .failure(let error):
print("Search error: \(error)")
}
}
}
func displayResults(_ results: [SearchResult]) {
// Display results in custom table view
// When user selects a result:
handleResultSelection(results[0])
}
func handleResultSelection(_ result: SearchResult) {
mapView.camera.fly(to: CameraOptions(
center: result.coordinate,
zoom: 15
))
let annotation = PointAnnotation(coordinate: result.coordinate)
mapView.annotations.pointAnnotations = [annotation]
}
}
```
#### Option 3: Direct API Integration (Advanced)
**When to use:** Very specific requirements, server-side iOS backend
**Important:** Only use if SDK doesn't meet your needs. You must handle debouncing and session tokens manually.
```swift
// Direct API calls - see Web direct API example
// Not recommended for iOS - use Search SDK instead
```
### Android: Search SDK for Android (Recommended)
#### Option 1: Search SDK with UI (Easiest)
**When to use:** Android app, want pre-built search UI, fastest implementation
**Installation:**
```gradle
// Add to build.gradle
dependencies {
implementation 'com.mapbox.search:mapbox-search-android-ui:2.0.0'
implementation 'com.mapbox.maps:android:11.0.0'
}
```
**Complete implementation with built-in UI:**
```kotlin
import com.mapbox.search.ui.view.SearchBottomSheetView
import com.mapbox.maps.MapView
class SearchActivity : AppCompatActivity() {
private lateinit var searchView: SearchBottomSheetView
private lateinit var mapView: MapView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_search)
mapView = findViewById(R.id.map_view)
// SearchBottomSheetView provides complete UI automatically
searchView = findViewById(R.id.search_view)
searchView.initializeSearch(
savedInstanceState,
SearchBottomSheetView.Configuration()
)
// Handle result selection
searchView.addOnSearchResultClickListener { searchResult ->
// SDK handled all the search interaction
val coordinate = searchResult.coordinate
mapView.getMapboxMap().flyTo(
CameraOptions.Builder()
.center(Point.fromLngLat(coordinate.longitude, coordinate.latitude))
.zoom(15.0)
.build()
)
searchView.hide()
}
}
}
```
#### Option 2: Search SDK Core (Custom UI)
**When to use:** Need custom UI, integrate with SearchView, full control over UX
**Complete implementation:**
```kotlin
import com.mapbox.search.SearchEngine
import com.mapbox.search.SearchEngineSettings
import com.mapbox.search.SearchOptions
import com.mapbox.maps.MapView
class SearchActivity : AppCompatActivity() {
private lateinit var searchEngine: SearchEngine
private lateinit var mapView: MapView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Initialize Search Engine (SDK handles debouncing and session tokens)
searchEngine = SearchEngine.createSearchEngine(
SearchEngineSettings("YOUR_MAPBOX_TOKEN")
)
setupSearchView()
setupMap()
}
private fun setupSearchView() {
val searchView = findViewById<SearchView>(R.id.search_view)
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String): Boolean {
performSearch(query)
return true
}
override fun onQueryTextChange(newText: String): Boolean {
if (newText.length >= 2) {
// Search SDK handles debouncing automatically
performSearch(newText)
}
return true
}
})
}
private fun performSearch(query: String) {
val options = SearchOptions(
countries = listOf("US"),
limit = 5
)
searchEngine.search(query, options) { results ->
results.onSuccess { searchResults ->
displayResults(searchResults)
}.onFailure { error ->
Log.e("Search", "Error: $error")
}
}
}
private fun displayResults(results: List<SearchResult>) {
// Display in custom RecyclerView
handleResultSelection(results[0])
}
private fun handleResultSelection(result: SearchResult) {
val coordinate = result.coordinate
mapView.getMapboxMap().flyTo(
CameraOptions.Builder()
.center(Point.fromLngLat(coordinate.longitude, coordinate.latitude))
.zoom(15.0)
.build()
)
}
}
```
#### Option 3: Direct API Integration (Advanced)
**When to use:** Very specific requirements, server-side Android backend
**Important:** Only use if SDK doesn't meet your needs. You must handle debouncing and session tokens manually.
```kotlin
// Direct API calls - see Web direct API example
// Not recommended for Android - use Search SDK instead
```
### Node.js: Mapbox Search JS Core (Recommended)
#### Option 1: Search JS Core (Recommended)
**When to use:** Server-side search, backend API, serverless functions
**Installation:**
```bash
npm install @mapbox/search-js-core
```
**Complete implementation:**
```javascript
import { SearchSession } from '@mapbox/search-js-core';
// Initialize search session (handles session tokens automatically)
const search = new SearchSession({
accessToken: process.env.MAPBOX_TOKEN
});
// Express.js API endpoint example
app.get('/api/search', async (req, res) => {
const { query, proximity, country } = req.query;
try {
// Get suggestions (Search JS Core handles session management)
const response = await search.suggest(query, {
proximity: proximity ? proximity.split(',').map(Number) : undefined,
country: country,
limit: 10
});
res.json(response.suggestions);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Retrieve full details for a selected result
app.get('/api/search/:id', async (req, res) => {
try {
const result = await search.retrieve(req.params.id);
res.json(result.features[0]);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
```
**Key benefits:**
- ✅ Search JS Core handles session tokens automatically
- ✅ Perfect for serverless (Vercel, Netlify, AWS Lambda)
- ✅ Same API as browser Search JS Core
- ✅ No manual debouncing needed (handle at API gateway level)
#### Option 2: Direct API Integration (Advanced)
**When to use:** Very specific requirements, need features not in Search JS Core
**Implementation:**
```javascript
import fetch from 'node-fetch';
async function searchPlaces(query, options = {}) {
const params = new URLSearchParams({
q: query,
access_token: process.env.MAPBOX_TOKEN,
session_token: generateSessionToken(), // You must manage this
...options
});
const response = await fetch(`https://api.mapbox.com/search/searchbox/v1/suggest?${params}`);
return response.json();
}
```
**Important:** Only use direct API calls if Search JS Core doesn't meet your needs. You'll need to handle session tokens manually.
## Best Practices: "The Good Parts"
### 1. Debouncing (CRITICAL for Autocomplete)
**Note:** Debouncing is only a concern if you are calling the API directly. Mapbox Search JS and the Search SDKs handle debouncing automatically.
**Problem:** Every keystroke = API call = expensive + slow
**Solution:** Wait until user stops typing (for direct API integration)
```javascript
let debounceTimeout;
function debouncedSearch(query) {
clearTimeout(debounceTimeout);
debounceTimeout = setTimeout(() => {
performSearch(query);
}, 300); // 300ms is optimal for most use cases
}
```
**Why 300ms?**
- Fast enough to feel responsive
- Slow enough to avoid spam
- Industry standard (Google uses ~300ms)
### 2. Session Token Management
**Note:** Session tokens are only a concern if you are calling the API directly. Mapbox Search JS and the Search SDKs for iOS/Android handle session tokens automatically.
**Problem:** Search Box API charges per session, not per request
**What's a session?**
- Starts with first suggest request
- Ends with retrieve request
- Use same token for all requests in session
**Implementation (direct API calls only):**
```javascript
class SearchSession {
constructor() {
this.token = this.generateToken();
}
generateToken() {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
async suggest(query) {
// Use this.token for all suggest requests
return fetch(`...?session_token=${this.token}`);
}
async retrieve(id) {
const result = await fetch(`...?session_token=${this.token}`);
// Session ends - generate new token
this.token = this.generateToken();
return result;
}
}
```
**Cost impact:**
- ✅ Correct: 1 session = unlimited suggests + 1 retrieve = 1 charge
- ❌ Wrong: No session token = each request charged separately
### 3. Geographic Filtering
**Always set location context when possible:**
```javascript
// GOOD: Specific country
{
country: 'US';
}
// GOOD: Proximity to user
{
proximity: [-122.4194, 37.7749];
}
// GOOD: Bounding box for service area
{
bbox: [-122.5, 37.7, -122.3, 37.9];
}
// BAD: No geographic context
{
} // Returns global results, slower, less relevant
```
**Tip:** Use the [Location Helper tool](https://labs.mapbox.com/location-helper/) to easily calculate bounding boxes for your service area.
**Why it matters:**
- ✅ Better result relevance
- ✅ Faster response times
- ✅ Lower ambiguity
- ✅ Better user experience
### 4. Error Handling
**Handle all failure cases:**
```javascript
async function performSearch(query) {
try {
const response = await fetch(searchUrl);
// Check HTTP status
if (!response.ok) {
if (response.status === 429) {
// Rate limited
showError('Too many requests. Please wait a moment.');
return [];
} else if (response.status === 401) {
// Invalid token
showError('Search is unavailable. Please check configuration.');
return [];
} else {
// Other error
showError('Search failed. Please try again.');
return [];
}
}
const data = await response.json();
// Check for results
if (!data.suggestions || data.suggestions.length === 0) {
showMessage('No results found. Try a different search.');
return [];
}
return data.suggestions;
} catch (error) {
// Network error
console.error('Search error:', error);
showError('Network error. Please check your connection.');
return [];
}
}
```
### 5. Result Display UX
**Show enough context for disambiguation:**
```html
<div class="search-result">
<div class="result-name">Starbucks</div>
<div class="result-address">123 Main St, San Francisco, CA</div>
<div class="result-type">Coffee Shop</div>
</div>
```
**Not just:**
```html
<div>Starbucks</div>
<!-- Which Starbucks? -->
```
### 6. Loading States
**Always show loading feedback:**
```javascript
function performSearch(query) {
showLoadingSpinner();
fetch(searchUrl)
.then((response) => response.json())
.then((data) => {
hideLoadingSpinner();
displayResults(data.suggestions);
})
.catch((error) => {
hideLoadingSpinner();
showError('Search failed');
});
}
```
### 7. Accessibility
**Make search keyboard-navigable:**
```html
<input type="search" role="combobox" aria-autocomplete="list" aria-controls="search-results" aria-expanded="false" />
<ul id="search-results" role="listbox">
<li role="option" tabindex="0">Result 1</li>
<li role="option" tabindex="0">Result 2</li>
</ul>
```
**Keyboard support:**
- ⬆️⬇️ Arrow keys: Navigate results
- Enter: Select result
- Escape: Close results
### 8. Mobile Optimizations
**iOS/Android specific considerations:**
```swift
// iOS: Adjust for keyboard
NotificationCenter.default.addObserver(
forName: UIResponder.keyboardWillShowNotification,
object: nil,
queue: .main
) { notification in
// Adjust view for keyboard
}
// Handle tap outside to dismiss
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard))
view.addGestureRecognizer(tapGesture)
```
**Make touch targets large enough:**
- Minimum: 44x44pt (iOS) / 48x48dp (Android)
- Ensure adequate spacing between results
### 9. Caching (For High-Volume Apps)
**Cache recent/popular searches:**
```javascript
class SearchCache {
constructor(maxSize = 50) {
this.cache = new Map();
this.maxSize = maxSize;
}
get(query) {
const key = query.toLowerCase();
return this.cache.get(key);
}
set(query, results) {
const key = query.toLowerCase();
// LRU eviction
if (this.cache.size >= this.maxSize) {
const firstKey = this.cache.keys().next().value;
this.cache.delete(firstKey);
}
this.cache.set(key, {
results,
timestamp: Date.now()
});
}
isValid(entry, maxAgeMs = 5 * 60 * 1000) {
return entry && Date.now() - entry.timestamp < maxAgeMs;
}
}
// Usage
const cache = new SearchCache();
async function search(query) {
const cached = cache.get(query);
if (cache.isValid(cached)) {
return cached.results;
}
const results = await performAPISearch(query);
cache.set(query, results);
return results;
}
```
### 10. Token Security
**CRITICAL: Scope tokens properly:**
```javascript
// Create token with only search scopes
// In Mapbox dashboard or via API:
{
"scopes": [
"search:read",
"styles:read", // Only if showing map
"fonts:read" // Only if showing map
],
"allowedUrls": [
"https://yourdomain.com/*"
]
}
```
**Never:**
- ❌ Use secret tokens (sk.\*) in client-side code
- ❌ Give tokens more scopes than needed
- ❌ Skip URL restrictions on public tokens
See `mapbox-token-security` skill for details.
## Common Pitfalls and How to Avoid Them
### ❌ Pitfall 1: No Debouncing
**Problem:**
```javascript
input.addEventListener('input', (e) => {
performSearch(e.target.value); // API call on EVERY keystroke!
});
```
**Impact:**
- 🔥 Expensive (hundreds of unnecessary API calls)
- 🐌 Slow (race conditions, outdated results)
- 💥 Rate limiting (429 errors)
**Solution:** Always debounce (see Best Practice #1)
### ❌ Pitfall 2: Ignoring Session Tokens
**Problem:**
```javascript
// No session token = each request charged separately
fetch('...suggest?q=query&access_token=xxx');
```
**Impact:**
- 💰 Costs 10-100x more than necessary
- Budget blown on redundant charges
**Solution:** Use session tokens (see Best Practice #2)
### ❌ Pitfall 3: No Geographic Context
**Problem:**
```javascript
// Searching globally for "Paris"
{
q: 'Paris';
} // Paris, France? Paris, Texas? Paris, Kentucky?
```
**Impact:**
- 😕 Confusing results (wrong country)
- 🐌 Slower responses
- 😞 Poor user experience
**Solution:**
```javascript
// Much better
{ q: 'Paris', country: 'US', proximity: user_location }
```
### ❌ Pitfall 4: Poor Mobile UX
**Problem:**
```html
<!-- Tiny touch targets -->
<div style="height: 20px; padding: 2px;">Search result</div>
```
**Impact:**
- 😤 Frustrating to tap
- 🎯 Accidental selections
- ⭐ Bad reviews
**Solution:**
```css
.search-result {
min-height: 48px; /* Android minimum */
padding: 12px;
margin: 4px 0;
}
```
### ❌ Pitfall 5: Not Handling Empty Results
**Problem:**
```javascript
// Just shows empty container
displayResults([]); // User sees blank space - is it loading? broken?
```
**Impact:**
- ❓ User confusion
- 🤔 Is it working?
**Solution:**
```javascript
if (results.length === 0) {
showMessage('No results found. Try a different search term.');
}
```
### ❌ Pitfall 6: Blocking on Slow Networks
**Problem:**
```javascript
// No timeout = waits forever on slow network
await fetch(searchUrl);
```
**Impact:**
- ⏰ Appears frozen
- 😫 User frustration
**Solution:**
```javascript
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
fetch(searchUrl, { signal: controller.signal }).finally(() => clearTimeout(timeout));
```
### ❌ Pitfall 7: Ignoring Result Types
**Problem:**
```javascript
// Treating all results the same
displayResult(result.name); // But is it an address? POI? Region?
```
**Impact:**
- 🤷 Unclear what was selected
- 🗺️ Wrong zoom level
- 📍 Inappropriate markers
**Solution:**
```javascript
function handleResult(result) {
const type = result.feature_type;
if (type === 'poi') {
map.flyTo({ center: coords, zoom: 17 }); // Close zoom
addPOIMarker(result);
} else if (type === 'address') {
map.flyTo({ center: coords, zoom: 16 });
addAddressMarker(result);
} else if (type === 'place') {
map.flyTo({ center: coords, zoom: 12 }); // Wider view for city
}
}
```
### ❌ Pitfall 8: Race Conditions
**Problem:**
```javascript
// Fast typing: "san francisco"
// API responses arrive out of order:
// "san f" results arrive AFTER "san francisco" results
```
**Impact:**
- 🔀 Wrong results displayed
- 😵 Confusing UX
**Solution:**
```javascript
let searchCounter = 0;
async function performSearch(query) {
const currentSearch = ++searchCounter;
const results = await fetchResults(query);
// Only display if this is still the latest search
if (currentSearch === searchCounter) {
displayResults(results);
}
}
```
## Framework-Specific Guidance
### React Best Practices
**Best Practice:** Use Search JS React or Search JS Core instead of building custom hooks with direct API calls.
#### Option 1: Use Search JS React (Recommended)
```javascript
import { SearchBox } from '@mapbox/search-js-react';
// Easiest - just use the SearchBox component
function MyComponent() {
return (
<SearchBox
accessToken="YOUR_TOKEN"
onRetrieve={(result) => {
// Handle result
}}
options={{
country: 'US',
types: 'address,poi'
}}
/>
);
}
```
#### Option 2: Custom Hook with Search JS Core
```javascript
import { useState, useCallback, useRef, useEffect } from 'react';
import { SearchSession } from '@mapbox/search-js-core';
// Custom hook using Search JS Core (handles debouncing and session tokens)
function useMapboxSearch(accessToken, options = {}) {
const [results, setResults] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
// Search JS Core handles session tokens automatically
const searchSessionRef = useRef(null);
useEffect(() => {
searchSessionRef.current = new SearchSession({ accessToken });
}, [accessToken]);
const search = useCallback(
async (query) => {
if (!query || query.length < 2) {
setResults([]);
return;
}
setIsLoading(true);
setError(null);
try {
// Search JS Core handles debouncing and session tokens
const response = await searchSessionRef.current.suggest(query, options);
setResults(response.suggestions || []);
} catch (err) {
setError(err.message);
setResults([]);
} finally {
setIsLoading(false);
}
},
[options]
);
const retrieve = useCallback(async (suggestion) => {
try {
// Search JS Core handles session tokens automatically
const result = await searchSessionRef.current.retrieve(suggestion);
return result.features[0];
} catch (err) {
setError(err.message);
throw err;
}
}, []);
return { results, isLoading, error, search, retrieve };
}
```
**Benefits of using Search JS Core:**
- ✅ No manual session token management
- ✅ No manual debouncing needed
- ✅ No race condition handling needed (SDK handles it)
- ✅ Cleaner, simpler code
- ✅ Production-ready error handling built-in
### Vue Composition API (Using Search JS Core - Recommended)
```javascript
import { ref, watch } from 'vue';
import { SearchSession } from '@mapbox/search-js-core';
export function useMapboxSearch(accessToken, options = {}) {
const query = ref('');
const results = ref([]);
const isLoading = ref(false);
// Use Search JS Core - handles debouncing and session tokens automatically
const searchSession = new SearchSession({ accessToken });
const performSearch = async (searchQuery) => {
if (!searchQuery || searchQuery.length < 2) {
results.value = [];
return;
}
isLoading.value = true;
try {
// Search JS Core handles debouncing and session tokens
const response = await searchSession.suggest(searchQuery, options);
results.value = response.suggestions || [];
} catch (error) {
console.error('Search error:', error);
results.value = [];
} finally {
isLoading.value = false;
}
};
// Watch query changes (Search JS Core handles debouncing)
watch(query, (newQuery) => {
performSearch(newQuery);
});
const retrieve = async (suggestion) => {
// Search JS Core handles session tokens automatically
const feature = await searchSession.retrieve(suggestion);
return feature;
};
return {
query,
results,
isLoading,
retrieve
};
}
```
**Key benefits:**
- ✅ Search JS Core handles debouncing automatically (no lodash needed)
- ✅ Session tokens managed automatically (no manual token generation)
- ✅ Simpler code, fewer dependencies
- ✅ Same API works in browser and Node.js
## Testing Strategy
### Unit Tests
```javascript
// Mock fetch for testing
global.fetch = jest.fn();
describe('MapboxSearch', () => {
beforeEach(() => {
fetch.mockClear();
});
test('debounces search requests', async () => {
const search = new MapboxSearch('fake-token');
// Rapid-fire searches
search.search('san');
search.search('san f');
search.search('san fr');
search.search('san francisco');
// Wait for debounce
await new Promise((resolve) => setTimeout(resolve, 400));
// Should only make one API call
expect(fetch).toHaveBeenCalledTimes(1);
});
test('handles empty results', async () => {
fetch.mockResolvedValue({
ok: true,
json: async () => ({ suggestions: [] })
});
const search = new MapboxSearch('fake-token');
const results = await search.performSearch('xyz');
expect(results).toEqual([]);
});
test('handles API errors', async () => {
fetch.mockResolvedValue({
ok: false,
status: 429
});
const search = new MapboxSearch('fake-token');
const results = await search.performSearch('test');
expect(results).toEqual([]);
});
});
```
### Integration Tests
```javascript
describe('Search Integration', () => {
test('complete search flow', async () => {
const search = new MapboxSearch(process.env.MAPBOX_TOKEN);
// Perform search
const suggestions = await search.performSearch('San Francisco');
expect(suggestions.length).toBeGreaterThan(0);
// Retrieve first result
const feature = await search.retrieve(suggestions[0].mapbox_id);
expect(feature.geometry.coordinates).toBeDefined();
expect(feature.properties.name).toBe('San Francisco');
});
});
```
## Monitoring and Analytics
### Track Key Metrics
```javascript
// Track search usage
function trackSearch(query, resultsCount) {
analytics.track('search_performed', {
query_length: query.length,
results_count: resultsCount,
had_results: resultsCount > 0
});
}
// Track selections
function trackSelection(result, position) {
analytics.track('search_result_selected', {
result_type: result.feature_type,
result_position: position,
had_address: !!result.properties.full_address
});
}
// Track errors
function trackError(errorType, query) {
analytics.track('search_error', {
error_type: errorType,
query_length: query.length
});
}
```
### Monitor for Issues
- 📊 Zero-result rate (should be < 20%)
- ⚡ Average response time
- 💥 Error rate
- 🎯 Selection rate (users selecting vs abandoning)
- 💰 API usage vs budget
## Checklist: Production-Ready Search
Before launching, verify:
**Configuration:**
- [ ] Token properly scoped (search:read only)
- [ ] URL restrictions configured
- [ ] Geographic filtering set (country, proximity, or bbox)
- [ ] Types parameter set based on use case
- [ ] Language parameter set if needed
**Implementation:**
- [ ] Debouncing implemented (300ms recommended)
- [ ] Session tokens used correctly
- [ ] Error handling for all failure cases
- [ ] Loading states shown
- [ ] Empty results handled gracefully
- [ ] Race conditions prevented
**UX:**
- [ ] Touch targets at least 44pt/48dp
- [ ] Results show enough context (name + address)
- [ ] Keyboard navigation works
- [ ] Accessibility attributes set
- [ ] Mobile keyboard handled properly
**Performance:**
- [ ] Caching implemented (if high volume)
- [ ] Request timeout set
- [ ] Minimal data fetched
- [ ] Bundle size optimized
**Testing:**
- [ ] Unit tests for core logic
- [ ] Integration tests with real API
- [ ] Tested on slow networks
- [ ] Tested with various query types
- [ ] Mobile device testing
**Monitoring:**
- [ ] Analytics tracking set up
- [ ] Error logging configured
- [ ] Usage monitoring in place
- [ ] Budget alerts configured
## Integration with Other Skills
**Works with:**
- **mapbox-search-patterns**: Parameter selection and optimization
- **mapbox-web-integration-patterns**: Framework-specific patterns
- **mapbox-token-security**: Token management and security
- **mapbox-web-performance-patterns**: Optimizing search performance
## Resources
- [Search Box API Documentation](https://docs.mapbox.com/api/search/search-box/)
- [Geocoding API Documentation](https://docs.mapbox.com/api/search/geocoding/)
- [Mapbox Search JS](https://docs.mapbox.com/mapbox-search-js/guides/)
- [Search JS React](https://docs.mapbox.com/mapbox-search-js/api/react/)
- [Search JS Web](https://docs.mapbox.com/mapbox-search-js/api/web/)
- [Search JS Core](https://docs.mapbox.com/mapbox-search-js/api/core/)
- [Search SDK for iOS](https://docs.mapbox.com/ios/search/guides/)
- [Search SDK for Android](https://docs.mapbox.com/android/search/guides/)
- [Location Helper Tool](https://labs.mapbox.com/location-helper/) - Calculate bounding boxes
## Quick Decision Guide
**User says: "I need location search"**
1. **Ask discovery questions** (Questions 1-6 above)
2. **Recommend product:**
- Search Box API or Geocoding API
- Platform SDK (mobile)
3. **Implement with:**
- ✅ Debouncing
- ✅ Session tokens
- ✅ Geographic filtering
- ✅ Error handling
- ✅ Good UX
4. **Test thoroughly**
5. **Monitor in production**
**Remember:** The best search implementation asks the right questions first, then builds exactly what the user needs - no more, no less.Expert guidance on choosing the right Mapbox search tool and parameters for geocoding, POI search, and location discovery
# Mapbox Search Patterns Skill
Expert guidance for AI assistants on using Mapbox search tools effectively. Covers tool selection, parameter optimization, and best practices for geocoding, POI search, and location discovery.
## Available Search Tools
### 1. search_and_geocode_tool
**Best for:** Specific places, addresses, brands, named locations
**Use when query contains:**
- Specific names: "Starbucks on 5th Avenue", "Empire State Building"
- Brand names: "McDonald's", "Whole Foods"
- Addresses: "123 Main Street, Seattle", "1 Times Square"
- Chain stores: "Target"
- Cities/places: "San Francisco", "Portland"
**Don't use for:** Generic categories ("coffee shops", "museums")
### 2. category_search_tool
**Best for:** Generic place types, categories, plural queries
**Use when query contains:**
- Generic types: "coffee shops", "restaurants", "gas stations"
- Plural forms: "museums", "hotels", "parks"
- Is-a phrases: "any coffee shop", "all restaurants", "nearby pharmacies"
- Industry terms: "electric vehicle chargers", "ATMs"
**Don't use for:** Specific names or brands
### 3. reverse_geocode_tool
**Best for:** Converting coordinates to addresses, cities, towns, postcodes
**Use when:**
- Have GPS coordinates, need human-readable address
- Need to identify what's at a specific location
- Converting user location to address
## Tool Selection Decision Matrix
| User Query | Tool | Reasoning |
| ------------------------------- | ----------------------- | ------------------------ |
| "Find Starbucks on Main Street" | search_and_geocode_tool | Specific brand name |
| "Find coffee shops nearby" | category_search_tool | Generic category, plural |
| "What's at 37.7749, -122.4194?" | reverse_geocode_tool | Coordinates to address |
| "Empire State Building" | search_and_geocode_tool | Specific named POI |
| "hotels in downtown Seattle" | category_search_tool | Generic type + location |
| "Target store locations" | search_and_geocode_tool | Brand name (even plural) |
| "any restaurant near me" | category_search_tool | Generic + "any" phrase |
| "123 Main St, Boston, MA" | search_and_geocode_tool | Specific address |
| "electric vehicle chargers" | category_search_tool | Industry category |
| "McDonald's" | search_and_geocode_tool | Brand name |
## Parameter Guidance
### Proximity vs Bbox vs Country
**Three ways to spatially constrain search results:**
#### 1. proximity (STRONGLY RECOMMENDED)
**What it does:** Biases results toward a location, but doesn't exclude distant matches
**Use when:**
- User says "near me", "nearby", "close to"
- Have a reference point but want some flexibility
- Want results sorted by relevance to a point
**Example:**
```json
{
"q": "pizza",
"proximity": {
"longitude": -122.4194,
"latitude": 37.7749
}
}
```
**Why this works:** API returns SF pizza places first, but might include famous NYC pizzerias if highly relevant
**⚠️ Critical:** Always set proximity when you have a reference location! Without it, results are IP-based or global.
#### 2. bbox (Bounding Box)
**What it does:** Hard constraint - ONLY returns results within the box
**Use when:**
- User specifies an area: "in downtown", "within this neighborhood"
- Have a defined service area
- Need to guarantee results are within bounds
**Example:**
```json
{
"q": "hotel",
"bbox": [-122.51, 37.7, -122.35, 37.83] // [minLon, minLat, maxLon, maxLat]
}
```
**Why this works:** Guarantees all hotels are within SF's downtown area
**⚠️ Watch out:** Too small = no results; too large = irrelevant results
#### 3. country
**What it does:** Limits results to specific countries
**Use when:**
- User specifies country: "restaurants in France"
- Building country-specific features
- Need to respect regional boundaries
- Or it is otherwise clear they want results within a specific country
**Example:**
```json
{
"q": "Paris",
"country": ["FR"] // ISO 3166 alpha-2 codes
}
```
**Why this works:** Finds Paris, France (not Paris, Texas)
**Can combine:** `proximity` + `country` + `bbox` or any combination of the three
### Decision Matrix: Spatial Filters
| Scenario | Use | Why |
| ---------------------------------- | ----------------------------------- | --------------------------------- |
| "Find coffee near me" | proximity | Bias toward user location |
| "Coffee shops in downtown Seattle" | proximity + bbox | Center on downtown, limit to area |
| "Hotels in France" | country | Hard country boundary |
| "Best pizza in San Francisco" | proximity + country ["US"] | Bias to SF, limit to US |
| "Gas stations along this route" | bbox around route | Hard constraint to route corridor |
| "Restaurants within 5 miles" | proximity (then filter by distance) | Bias nearby, filter results |
### Setting limit Parameter
**category_search_tool only** (1-25, default 10)
| Use Case | Limit | Reasoning |
| --------------------- | ----- | ----------------------- |
| Quick suggestions | 5 | Fast, focused results |
| Standard list | 10 | Default, good balance |
| Comprehensive search | 25 | Maximum allowed |
| Map visualization | 25 | Show all nearby options |
| Dropdown/autocomplete | 5 | Don't overwhelm UI |
**Performance tip:** Lower limits = faster responses
### types Parameter (search_and_geocode_tool)
**Filter by feature type:**
| Type | What It Includes | Use When |
| ---------- | ------------------------------------------ | --------------------------------- |
| `poi` | Points of interest (businesses, landmarks) | Looking for POIs, not addresses |
| `address` | Street addresses | Need specific address |
| `place` | Cities, neighborhoods, regions | Looking for area/region |
| `street` | Street names without numbers | Need street, not specific address |
| `postcode` | Postal codes | Searching by ZIP/postal code |
| `district` | Districts, neighborhoods | Area-based search |
| `locality` | Towns, villages | Municipality search |
| `country` | Country names | Country-level search |
**Example combinations:**
```json
// Only POIs and addresses, no cities
{"q": "Paris", "types": ["poi", "address"]}
// Returns Paris Hotel, Paris Street, not Paris, France
// Only places (cities)
{"q": "Paris", "types": ["place"]}
// Returns Paris, France; Paris, Texas; etc.
```
**Default behavior:** All types included (usually what you want)
### poi_category Parameter
**search_and_geocode_tool:** Narrow generic searches
```json
{
"q": "lunch",
"poi_category": ["restaurant", "cafe"],
"proximity": { "longitude": -122.4194, "latitude": 37.7749 }
}
```
**When to use:**
- Generic query that could match multiple categories
- Want to focus search within category
- User specifies type implicitly
**category_search_tool:** Use `poi_category_exclusions` instead
```json
{
"category": "food_and_drink",
"poi_category_exclusions": ["bar", "nightclub"]
}
```
**When to use:**
- Broad category but want to exclude subcategories
- "Restaurants but not fast food"
### auto_complete Parameter (search_and_geocode_tool)
**What it does:** Enables partial/fuzzy matching
| Setting | Behavior | Use When |
| ----------------- | ---------------------------- | ----------------------------- |
| `true` | Matches partial words, typos | User typing in real-time |
| `false` (default) | Exact matching | Final query, not autocomplete |
**Example:**
<!-- cspell:disable -->
```json
// User types "starb"
{ "q": "starb", "auto_complete": true }
// Returns: Starbucks, Starboard Tavern, etc.
```
**Use for:**
- Search-as-you-type interfaces
- Handling typos ("mcdonalds" → McDonald's)
<!-- cspell:enable -->
- Incomplete queries
**Don't use for:**
- Final/submitted queries (less precise)
- When you need exact matches
### ETA Parameters (search_and_geocode_tool)
**Request estimated time of arrival to results**
**Parameters:**
- `eta_type`: Set to `"navigation"`
- `navigation_profile`: `"driving"` | `"walking"` | `"cycling"`
- `origin`: Starting coordinates
**Use when:**
- User asks "how long to get there?"
- Sorting by travel time, not distance
- Need route time, not straight-line distance
**Example:**
```json
{
"q": "grocery stores",
"proximity": { "longitude": -122.4194, "latitude": 37.7749 },
"eta_type": "navigation",
"navigation_profile": "driving",
"origin": { "longitude": -122.4194, "latitude": 37.7749 }
}
```
**Returns:** Results with `eta` (travel time in seconds)
**⚠️ Cost:** Requires routing calculation per result (counts toward API quota)
**When NOT to use:**
- Just need straight-line distance (use distance_tool offline after search)
- Budget-conscious (adds API cost)
### format Parameter (category_search_tool)
**Choose output format:**
| Format | Returns | Use When |
| -------------------------- | ---------------------- | ----------------------------- |
| `formatted_text` (default) | Human-readable text | Displaying to user directly |
| `json_string` | GeoJSON as JSON string | Need to parse/process results |
**Example:**
**formatted_text:**
```
1. Blue Bottle Coffee
Address: 66 Mint St, San Francisco, CA
Coordinates: 37.7825, -122.4052
Type: poi
```
**json_string:**
```json
{
"type": "FeatureCollection",
"features": [{
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [-122.4052, 37.7825]},
"properties": {"name": "Blue Bottle Coffee", ...}
}]
}
```
**Decision:**
- Showing list to user → `formatted_text`
- Plotting on map → `json_string` (parse and use coordinates)
- Further processing → `json_string`
### language Parameter
**ISO language codes** (e.g., "en", "es", "fr", "de", "ja", "zh")
**Use when:**
- Building multilingual app
- User's language preference known
- Need localized names
**Example:**
```json
{
"q": "東京タワー",
"language": "ja"
}
// Returns results in Japanese
```
**Default:** English (if not specified)
**Tip:** Match user's locale for best experience
## Common Patterns and Workflows
### Pattern 1: "Near Me" Search
**User:** "Find coffee shops near me"
**Optimal approach:**
```
1. Get user's location (from app/browser)
2. Use category_search_tool:
- category: "coffee_shop"
- proximity: user's coordinates
- limit: 10
```
**Why:** Category tool for generic "coffee shops", proximity for "near me"
### Pattern 2: Branded Chain Lookup
**User:** "Find all Starbucks in Seattle"
**Optimal approach:**
```
1. Use search_and_geocode_tool:
- q: "Starbucks"
- proximity: Seattle coordinates
- country: ["US"]
2. Or if need strict boundary:
- bbox: Seattle city bounds
```
**Why:** Brand name = search_and_geocode_tool; proximity biases to Seattle
### Pattern 3: Address Geocoding
**User:** "What are the coordinates of 1600 Pennsylvania Ave?"
**Optimal approach:**
```
Use search_and_geocode_tool:
- q: "1600 Pennsylvania Ave, Washington DC"
- types: ["address"] // Focus on addresses
- country: ["US"] // Narrow to US
```
**Why:** Specific address with country context for disambiguation
### Pattern 4: Category Search with Area Restriction
**User:** "Show me all hotels in downtown Portland"
**Optimal approach:**
```
1. Geocode "downtown Portland" → get center point
2. Define downtown bbox (or use 1-2 mile radius)
3. Use category_search_tool:
- category: "hotel"
- bbox: downtown bounds (or proximity + filter by distance)
- limit: 25 // Get comprehensive list
```
**Why:** Category for "hotels", bbox for "in downtown" hard boundary
### Pattern 5: Reverse Geocoding
**User:** "What's at these GPS coordinates?"
**Optimal approach:**
```
Use reverse_geocode_tool:
- longitude: -122.4194
- latitude: 37.7749
- types: ["address"] // Get address (can also use place, locality, postcode, etc.)
```
**Why:** Coordinates → address is exactly what reverse geocoding does
### Pattern 6: Route-Based Search
**User:** "Find gas stations along my route"
**Optimal approach:**
```
1. Get route geometry from directions_tool
2. Create bbox around route (use bounding_box_tool)
3. Use category_search_tool:
- category: "gas_station"
- bbox: route bounding box
4. Filter results to those within X meters of route (use distance_tool)
```
**Why:** Bbox for rough filter, then distance calculation for precision
### Pattern 7: Multilingual POI Search
**User:** "Find ramen shops" (user locale: ja)
**Optimal approach:**
```
Use category_search_tool:
- category: "ramen_restaurant" (or "restaurant")
- language: "ja"
- proximity: user location
```
**Why:** Returns Japanese names/addresses for better UX
## Anti-Patterns to Avoid
### ❌ Don't: Use category_search for brands
```javascript
// BAD
category_search_tool({ category: 'starbucks' });
// "starbucks" is not a category, returns error
// GOOD
search_and_geocode_tool({ q: 'Starbucks' });
```
### ❌ Don't: Use search_and_geocode for generic categories
```javascript
// BAD
search_and_geocode_tool({ q: 'coffee shops' });
// Less precise, may return unrelated results
// GOOD
category_search_tool({ category: 'coffee_shop' });
```
### ❌ Don't: Forget proximity for local searches
```javascript
// BAD - Results may be anywhere globally
category_search_tool({ category: 'restaurant' });
// GOOD - Biased to user location
category_search_tool({
category: 'restaurant',
proximity: { longitude: -122.4194, latitude: 37.7749 }
});
```
### ❌ Don't: Use bbox when you mean proximity
```javascript
// BAD - Hard boundary may exclude good nearby results
search_and_geocode_tool({
q: 'pizza',
bbox: [-122.42, 37.77, -122.41, 37.78] // Tiny box
});
// GOOD - Bias toward point, but flexible
search_and_geocode_tool({
q: 'pizza',
proximity: { longitude: -122.4194, latitude: 37.7749 }
});
```
### ❌ Don't: Request ETA unnecessarily
```javascript
// BAD - Costs API quota for routing calculations
search_and_geocode_tool({
q: 'museums',
eta_type: 'navigation',
navigation_profile: 'driving'
});
// User didn't ask for travel time!
// GOOD - Only add ETA when needed
search_and_geocode_tool({ q: 'museums' });
// If user asks "how long to get there?", then add ETA
```
### ❌ Don't: Set limit too high for UI display
```javascript
// BAD - Overwhelming for simple dropdown
category_search_tool({
category: 'restaurant',
limit: 25
});
// Returns 25 restaurants for a 5-item dropdown
// GOOD - Match UI needs
category_search_tool({
category: 'restaurant',
limit: 5
});
```
## Performance Optimization
### Minimize API Calls
**Pattern: Geocode once, reuse coordinates**
```
// GOOD
1. User enters "Seattle"
2. Geocode "Seattle" → (lng, lat)
3. Use those coordinates for multiple category searches
4. Cache coordinates for session
// BAD
1. Geocode "Seattle" for coffee search
2. Geocode "Seattle" again for restaurant search
3. Geocode "Seattle" again for hotel search
```
### Set Appropriate Limits
| UI Context | Recommended Limit |
| --------------------- | ----------------- |
| Autocomplete dropdown | 5 |
| List view | 10 |
| Map view | 25 |
| Export/download | 25 (or paginate) |
### Use Offline Tools When Possible
**After getting search results:**
```
1. category_search_tool → Get POIs
2. distance_tool (offline) → Calculate distances
3. bearing_tool (offline) → Get directions
```
**Why:** Search once (API), then use offline tools for calculations (free, fast)
## Combining Search with Other Tools
### Search → Distance Calculation
```
1. category_search_tool({category: "hospital", proximity: user_location})
→ Returns 10 hospitals with coordinates
2. distance_tool(user_location, each_hospital)
→ Calculate exact distances offline
3. Sort by distance
```
### Search → Directions
```
1. search_and_geocode_tool({q: "Space Needle"})
→ Get destination coordinates
2. directions_tool({from: user_location, to: space_needle_coords})
→ Get turn-by-turn directions
```
### Search → Isochrone → Containment Check
```
1. search_and_geocode_tool({q: "warehouse"})
→ Get warehouse coordinates
2. isochrone_tool({coordinates: warehouse, time: 30, profile: "driving"})
→ Get 30-minute delivery zone polygon
3. point_in_polygon_tool(customer_address, delivery_zone)
→ Check if customer is in delivery zone
```
### Search → Static Map Visualization
```
1. category_search_tool({category: "restaurant", limit: 10})
→ Get restaurant coordinates
2. static_map_image_tool({
markers: restaurant_coordinates,
auto_fit: true
})
→ Create map image showing all restaurants
```
## Handling No Results
### If category_search returns no results:
**Possible reasons:**
1. Invalid category → Use `resource_reader_tool` with `mapbox://categories` to see valid categories
2. Too restrictive bbox → Expand area or use proximity instead
3. No POIs in area → Try broader category or remove spatial filters
4. Wrong country filter → Check country codes
**Example recovery:**
```
1. category_search_tool({category: "taco"}) → No results
2. Check: Is "taco" a valid category?
→ Use category_list_tool → See "mexican_restaurant" is valid
3. Retry: category_search_tool({category: "mexican_restaurant"}) → Success
```
### If search_and_geocode returns no results:
**Possible reasons:**
1. Typo in query → Retry with `auto_complete: true`
2. Too specific → Broaden search (remove address numbers, try nearby city)
3. Wrong types filter → Remove or expand types
4. Not a recognized place → Check spelling, try alternative names
## Category List Resource
**Get valid categories:** Use `resource_reader_tool` or `category_list_tool`
```
resource_reader_tool({uri: "mapbox://categories"})
```
**Returns:** All valid category IDs (e.g., "restaurant", "hotel", "gas_station")
**When to use:**
- User enters free-text category
- Need to map user terms to Mapbox categories
- Validating category before search
**Example mapping:**
- User: "places to eat" → Category: "restaurant"
- User: "gas" → Category: "gas_station"
- User: "lodging" → Category: "hotel"
## Quick Reference
### Tool Selection Flowchart
```
User query contains...
→ Specific name/brand (Starbucks, Empire State Building)
→ search_and_geocode_tool
→ Generic category/plural (coffee shops, museums, any restaurant)
→ category_search_tool
→ Coordinates → Address
→ reverse_geocode_tool
→ Address → Coordinates
→ search_and_geocode_tool with types: ["address"]
```
### Essential Parameters Checklist
**For local searches, ALWAYS set:**
- ✅ `proximity` (or bbox if strict boundary needed)
**For category searches, consider:**
- ✅ `limit` (match UI needs)
- ✅ `format` (json_string if plotting on map)
**For disambiguation, use:**
- ✅ `country` (when geographic context matters)
- ✅ `types` (when feature type matters)
**For travel-time ranking:**
- ✅ `eta_type`, `navigation_profile`, `origin` (costs API quota)
## Common Mistakes
1. **Forgetting proximity** → Results are global/IP-based
2. **Using wrong tool** → category_search for "Starbucks" (use search_and_geocode)
3. **Invalid category** → Check category_list first
4. **Bbox too small** → No results; use proximity instead
5. **Requesting ETA unnecessarily** → Adds API cost
6. **Limit too high for UI** → Overwhelming user
7. **Not filtering types** → Get cities when you want POIs
## Integration with Other Skills
**Works with:**
- **mapbox-geospatial-operations**: After search, use offline distance/bearing calculations
- **mapbox-web-integration-patterns**: Display search results on map in web app
- **mapbox-token-security**: Ensure search requests use properly scoped tokens
## Resources
- [Mapbox Search Box API Docs](https://docs.mapbox.com/api/search/search-box/)
- [Category Search API](https://docs.mapbox.com/api/search/search-box/#category-search)
- [Geocoding API](https://docs.mapbox.com/api/search/geocoding/)
- [Category List Resource](https://docs.mapbox.com/api/search/search-box/#category-list)Common patterns for building store locators, restaurant finders, and location-based search applications with Mapbox. Covers marker display, filtering, distance calculation, and interactive lists.
# Store Locator Patterns Skill
Comprehensive patterns for building store locators, restaurant finders, and location-based search applications with Mapbox GL JS. Covers marker display, filtering, distance calculation, interactive lists, and directions integration.
## When to Use This Skill
Use this skill when building applications that:
- Display multiple locations on a map (stores, restaurants, offices, etc.)
- Allow users to filter or search locations
- Calculate distances from user location
- Provide interactive lists synced with map markers
- Show location details in popups or side panels
- Integrate directions to selected locations
## Dependencies
**Required:**
- Mapbox GL JS v3.x
- [@turf/turf](https://turfjs.org/) - For spatial calculations (distance, area, etc.)
**Installation:**
```bash
npm install mapbox-gl @turf/turf
```
## Core Architecture
### Pattern Overview
A typical store locator consists of:
1. **Map Display** - Shows all locations as markers
2. **Location Data** - GeoJSON with store/location information
3. **Interactive List** - Side panel listing all locations
4. **Filtering** - Search, category filters, distance filters
5. **Detail View** - Popup or panel with location details
6. **User Location** - Geolocation for distance calculation
7. **Directions** - Route to selected location (optional)
### Data Structure
**GeoJSON format for locations:**
```json
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [-77.034084, 38.909671]
},
"properties": {
"id": "store-001",
"name": "Downtown Store",
"address": "123 Main St, Washington, DC 20001",
"phone": "(202) 555-0123",
"hours": "Mon-Sat: 9am-9pm, Sun: 10am-6pm",
"category": "retail",
"website": "https://example.com/downtown"
}
}
]
}
```
**Key properties:**
- `id` - Unique identifier for each location
- `name` - Display name
- `address` - Full address for display and geocoding
- `coordinates` - `[longitude, latitude]` format
- `category` - For filtering (retail, restaurant, office, etc.)
- Custom properties as needed (hours, phone, website, etc.)
## Basic Store Locator Implementation
### Step 1: Initialize Map and Data
```javascript
import mapboxgl from 'mapbox-gl';
import 'mapbox-gl/dist/mapbox-gl.css';
mapboxgl.accessToken = 'YOUR_MAPBOX_ACCESS_TOKEN';
// Store locations data
const stores = {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [-77.034084, 38.909671]
},
properties: {
id: 'store-001',
name: 'Downtown Store',
address: '123 Main St, Washington, DC 20001',
phone: '(202) 555-0123',
category: 'retail'
}
}
// ... more stores
]
};
const map = new mapboxgl.Map({
container: 'map',
style: 'mapbox://styles/mapbox/standard',
center: [-77.034084, 38.909671],
zoom: 11
});
```
### Step 2: Add Markers to Map
**Option 1: HTML Markers (< 100 locations)**
```javascript
const markers = {};
stores.features.forEach((store) => {
// Create marker element
const el = document.createElement('div');
el.className = 'marker';
el.style.backgroundImage = 'url(/marker-icon.png)';
el.style.width = '30px';
el.style.height = '40px';
el.style.backgroundSize = 'cover';
el.style.cursor = 'pointer';
// Create marker
const marker = new mapboxgl.Marker(el)
.setLngLat(store.geometry.coordinates)
.setPopup(
new mapboxgl.Popup({ offset: 25 }).setHTML(
`<h3>${store.properties.name}</h3>
<p>${store.properties.address}</p>
<p>${store.properties.phone}</p>`
)
)
.addTo(map);
// Store reference for later access
markers[store.properties.id] = marker;
// Handle marker click
el.addEventListener('click', () => {
flyToStore(store);
createPopup(store);
highlightListing(store.properties.id);
});
});
```
**Option 2: Symbol Layer (100-1000 locations)**
```javascript
map.on('load', () => {
// Add store data as source
map.addSource('stores', {
type: 'geojson',
data: stores
});
// Add custom marker image
map.loadImage('/marker-icon.png', (error, image) => {
if (error) throw error;
map.addImage('custom-marker', image);
// Add symbol layer
map.addLayer({
id: 'stores-layer',
type: 'symbol',
source: 'stores',
layout: {
'icon-image': 'custom-marker',
'icon-size': 0.8,
'icon-allow-overlap': true,
'text-field': ['get', 'name'],
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
'text-offset': [0, 1.5],
'text-anchor': 'top',
'text-size': 12
}
});
});
// Handle marker clicks using Interactions API (recommended)
map.addInteraction('store-click', {
type: 'click',
target: { layerId: 'stores-layer' },
handler: (e) => {
const store = e.feature;
flyToStore(store);
createPopup(store);
}
});
// Or using traditional event listener:
// map.on('click', 'stores-layer', (e) => {
// const store = e.features[0];
// flyToStore(store);
// createPopup(store);
// });
// Change cursor on hover
map.on('mouseenter', 'stores-layer', () => {
map.getCanvas().style.cursor = 'pointer';
});
map.on('mouseleave', 'stores-layer', () => {
map.getCanvas().style.cursor = '';
});
});
```
**Option 3: Clustering (> 1000 locations)**
```javascript
map.on('load', () => {
map.addSource('stores', {
type: 'geojson',
data: stores,
cluster: true,
clusterMaxZoom: 14,
clusterRadius: 50
});
// Cluster circles
map.addLayer({
id: 'clusters',
type: 'circle',
source: 'stores',
filter: ['has', 'point_count'],
paint: {
'circle-color': ['step', ['get', 'point_count'], '#51bbd6', 10, '#f1f075', 30, '#f28cb1'],
'circle-radius': ['step', ['get', 'point_count'], 20, 10, 30, 30, 40]
}
});
// Cluster count labels
map.addLayer({
id: 'cluster-count',
type: 'symbol',
source: 'stores',
filter: ['has', 'point_count'],
layout: {
'text-field': '{point_count_abbreviated}',
'text-font': ['DIN Offc Pro Medium', 'Arial Unicode MS Bold'],
'text-size': 12
}
});
// Unclustered points
map.addLayer({
id: 'unclustered-point',
type: 'circle',
source: 'stores',
filter: ['!', ['has', 'point_count']],
paint: {
'circle-color': '#11b4da',
'circle-radius': 8,
'circle-stroke-width': 1,
'circle-stroke-color': '#fff'
}
});
// Zoom on cluster click
map.on('click', 'clusters', (e) => {
const features = map.queryRenderedFeatures(e.point, {
layers: ['clusters']
});
const clusterId = features[0].properties.cluster_id;
map.getSource('stores').getClusterExpansionZoom(clusterId, (err, zoom) => {
if (err) return;
map.easeTo({
center: features[0].geometry.coordinates,
zoom: zoom
});
});
});
// Show popup on unclustered point click
map.on('click', 'unclustered-point', (e) => {
const coordinates = e.features[0].geometry.coordinates.slice();
const props = e.features[0].properties;
new mapboxgl.Popup()
.setLngLat(coordinates)
.setHTML(
`<h3>${props.name}</h3>
<p>${props.address}</p>`
)
.addTo(map);
});
});
```
### Step 3: Build Interactive Location List
```javascript
function buildLocationList(stores) {
const listingContainer = document.getElementById('listings');
stores.features.forEach((store, index) => {
const listing = listingContainer.appendChild(document.createElement('div'));
listing.id = `listing-${store.properties.id}`;
listing.className = 'listing';
const link = listing.appendChild(document.createElement('a'));
link.href = '#';
link.className = 'title';
link.id = `link-${store.properties.id}`;
link.innerHTML = store.properties.name;
const details = listing.appendChild(document.createElement('div'));
details.innerHTML = `
<p>${store.properties.address}</p>
<p>${store.properties.phone || ''}</p>
`;
// Handle listing click
link.addEventListener('click', (e) => {
e.preventDefault();
flyToStore(store);
createPopup(store);
highlightListing(store.properties.id);
});
});
}
function flyToStore(store) {
map.flyTo({
center: store.geometry.coordinates,
zoom: 15,
duration: 1000
});
}
function createPopup(store) {
const popups = document.getElementsByClassName('mapboxgl-popup');
// Remove existing popups
if (popups[0]) popups[0].remove();
new mapboxgl.Popup({ closeOnClick: true })
.setLngLat(store.geometry.coordinates)
.setHTML(
`<h3>${store.properties.name}</h3>
<p>${store.properties.address}</p>
<p>${store.properties.phone}</p>
${store.properties.website ? `<a href="${store.properties.website}" target="_blank">Visit Website</a>` : ''}`
)
.addTo(map);
}
function highlightListing(id) {
// Remove existing highlights
const activeItem = document.getElementsByClassName('active');
if (activeItem[0]) {
activeItem[0].classList.remove('active');
}
// Add highlight to selected listing
const listing = document.getElementById(`listing-${id}`);
listing.classList.add('active');
// Scroll to listing
listing.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
// Build the list on load
map.on('load', () => {
buildLocationList(stores);
});
```
### Step 4: Add Search/Filter Functionality
**Text Search:**
```javascript
function filterStores(searchTerm) {
const filtered = {
type: 'FeatureCollection',
features: stores.features.filter((store) => {
const name = store.properties.name.toLowerCase();
const address = store.properties.address.toLowerCase();
const search = searchTerm.toLowerCase();
return name.includes(search) || address.includes(search);
})
};
// Update map source
if (map.getSource('stores')) {
map.getSource('stores').setData(filtered);
}
// Rebuild listing
document.getElementById('listings').innerHTML = '';
buildLocationList(filtered);
// Fit map to filtered results
if (filtered.features.length > 0) {
const bounds = new mapboxgl.LngLatBounds();
filtered.features.forEach((feature) => {
bounds.extend(feature.geometry.coordinates);
});
map.fitBounds(bounds, { padding: 50 });
}
}
// Add search input handler
document.getElementById('search-input').addEventListener('input', (e) => {
filterStores(e.target.value);
});
```
**Category Filter:**
```javascript
function filterByCategory(category) {
const filtered =
category === 'all'
? stores
: {
type: 'FeatureCollection',
features: stores.features.filter((store) => store.properties.category === category)
};
// Update map and list
if (map.getSource('stores')) {
map.getSource('stores').setData(filtered);
}
document.getElementById('listings').innerHTML = '';
buildLocationList(filtered);
}
// Category dropdown
document.getElementById('category-select').addEventListener('change', (e) => {
filterByCategory(e.target.value);
});
```
### Step 5: Add Geolocation and Distance Calculation
```javascript
let userLocation = null;
// Add geolocation control
map.addControl(
new mapboxgl.GeolocateControl({
positionOptions: {
enableHighAccuracy: true
},
trackUserLocation: true,
showUserHeading: true
})
);
// Get user location
navigator.geolocation.getCurrentPosition(
(position) => {
userLocation = [position.coords.longitude, position.coords.latitude];
// Calculate distances and sort
const storesWithDistance = stores.features.map((store) => {
const distance = calculateDistance(userLocation, store.geometry.coordinates);
return {
...store,
properties: {
...store.properties,
distance: distance
}
};
});
// Sort by distance
storesWithDistance.sort((a, b) => a.properties.distance - b.properties.distance);
// Update data
stores.features = storesWithDistance;
// Rebuild list with distances
document.getElementById('listings').innerHTML = '';
buildLocationList(stores);
},
(error) => {
console.error('Error getting location:', error);
}
);
// Calculate distance using Turf.js (recommended)
import * as turf from '@turf/turf';
function calculateDistance(from, to) {
const fromPoint = turf.point(from);
const toPoint = turf.point(to);
const distance = turf.distance(fromPoint, toPoint, { units: 'miles' });
return distance.toFixed(1); // Distance in miles
}
// Update listing to show distance
function buildLocationList(stores) {
const listingContainer = document.getElementById('listings');
stores.features.forEach((store) => {
const listing = listingContainer.appendChild(document.createElement('div'));
listing.id = `listing-${store.properties.id}`;
listing.className = 'listing';
const link = listing.appendChild(document.createElement('a'));
link.href = '#';
link.className = 'title';
link.innerHTML = store.properties.name;
const details = listing.appendChild(document.createElement('div'));
details.innerHTML = `
${store.properties.distance ? `<p class="distance">${store.properties.distance} mi</p>` : ''}
<p>${store.properties.address}</p>
<p>${store.properties.phone || ''}</p>
`;
link.addEventListener('click', (e) => {
e.preventDefault();
flyToStore(store);
createPopup(store);
highlightListing(store.properties.id);
});
});
}
```
### Step 6: Integrate Directions (Optional)
```javascript
async function getDirections(from, to) {
const query = await fetch(
`https://api.mapbox.com/directions/v5/mapbox/driving/${from[0]},${from[1]};${to[0]},${to[1]}?` +
`steps=true&geometries=geojson&access_token=${mapboxgl.accessToken}`
);
const data = await query.json();
const route = data.routes[0];
// Display route on map
if (map.getSource('route')) {
map.getSource('route').setData({
type: 'Feature',
geometry: route.geometry
});
} else {
map.addSource('route', {
type: 'geojson',
data: {
type: 'Feature',
geometry: route.geometry
}
});
map.addLayer({
id: 'route',
type: 'line',
source: 'route',
paint: {
'line-color': '#3b9ddd',
'line-width': 5,
'line-opacity': 0.75
}
});
}
// Display directions info
const duration = Math.floor(route.duration / 60);
const distance = (route.distance * 0.000621371).toFixed(1); // Convert to miles
return { duration, distance, steps: route.legs[0].steps };
}
// Add "Get Directions" button to popup
function createPopup(store) {
const popups = document.getElementsByClassName('mapboxgl-popup');
if (popups[0]) popups[0].remove();
const popup = new mapboxgl.Popup({ closeOnClick: true })
.setLngLat(store.geometry.coordinates)
.setHTML(
`<h3>${store.properties.name}</h3>
<p>${store.properties.address}</p>
<p>${store.properties.phone}</p>
${userLocation ? '<button id="get-directions">Get Directions</button>' : ''}`
)
.addTo(map);
// Handle directions button
if (userLocation) {
document.getElementById('get-directions').addEventListener('click', async () => {
const directions = await getDirections(userLocation, store.geometry.coordinates);
// Update popup with directions
popup.setHTML(
`<h3>${store.properties.name}</h3>
<p><strong>${directions.distance} mi • ${directions.duration} min</strong></p>
<p>${store.properties.address}</p>
<div class="directions-steps">
${directions.steps.map((step) => `<p>${step.maneuver.instruction}</p>`).join('')}
</div>`
);
});
}
}
```
## Styling Patterns
### Layout Structure
```html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Store Locator</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link href="https://api.mapbox.com/mapbox-gl-js/v3.0.0/mapbox-gl.css" rel="stylesheet" />
<style>
body {
margin: 0;
padding: 0;
font-family: 'Arial', sans-serif;
}
#app {
display: flex;
height: 100vh;
}
/* Sidebar */
.sidebar {
width: 400px;
height: 100vh;
overflow-y: scroll;
background-color: #fff;
border-right: 1px solid #ddd;
}
.sidebar-header {
padding: 20px;
background-color: #f8f9fa;
border-bottom: 1px solid #ddd;
}
.sidebar-header h1 {
margin: 0 0 10px 0;
font-size: 24px;
}
/* Search */
.search-box {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
}
.filter-group {
margin-top: 10px;
}
.filter-group select {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
/* Listings */
#listings {
padding: 0;
}
.listing {
padding: 15px 20px;
border-bottom: 1px solid #eee;
cursor: pointer;
transition: background-color 0.2s;
}
.listing:hover {
background-color: #f8f9fa;
}
.listing.active {
background-color: #e3f2fd;
border-left: 3px solid #2196f3;
}
.listing .title {
display: block;
color: #333;
font-weight: bold;
font-size: 16px;
text-decoration: none;
margin-bottom: 5px;
}
.listing .title:hover {
color: #2196f3;
}
.listing p {
margin: 5px 0;
font-size: 14px;
color: #666;
}
.listing .distance {
color: #2196f3;
font-weight: bold;
}
/* Map */
#map {
flex: 1;
height: 100vh;
}
/* Popups */
.mapboxgl-popup-content {
padding: 15px;
font-family: 'Arial', sans-serif;
}
.mapboxgl-popup-content h3 {
margin: 0 0 10px 0;
font-size: 18px;
}
.mapboxgl-popup-content p {
margin: 5px 0;
font-size: 14px;
}
.mapboxgl-popup-content button {
margin-top: 10px;
padding: 8px 16px;
background-color: #2196f3;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.mapboxgl-popup-content button:hover {
background-color: #1976d2;
}
/* Responsive */
@media (max-width: 768px) {
#app {
flex-direction: column;
}
.sidebar {
width: 100%;
height: 50vh;
}
#map {
height: 50vh;
}
}
</style>
</head>
<body>
<div id="app">
<div class="sidebar">
<div class="sidebar-header">
<h1>Store Locator</h1>
<input type="text" id="search-input" class="search-box" placeholder="Search by name or address..." />
<div class="filter-group">
<select id="category-select">
<option value="all">All Categories</option>
<option value="retail">Retail</option>
<option value="restaurant">Restaurant</option>
<option value="office">Office</option>
</select>
</div>
</div>
<div id="listings"></div>
</div>
<div id="map"></div>
</div>
<script src="https://api.mapbox.com/mapbox-gl-js/v3.0.0/mapbox-gl.js"></script>
<script src="app.js"></script>
</body>
</html>
```
### Custom Marker Styling
```css
/* Custom marker styles */
.marker {
background-size: cover;
width: 30px;
height: 40px;
cursor: pointer;
transition: transform 0.2s;
}
.marker:hover {
transform: scale(1.1);
}
/* Category-specific marker colors */
.marker.retail {
background-color: #2196f3;
}
.marker.restaurant {
background-color: #f44336;
}
.marker.office {
background-color: #4caf50;
}
```
## Performance Optimization
### Debounced Search
```javascript
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
const debouncedFilter = debounce(filterStores, 300);
document.getElementById('search-input').addEventListener('input', (e) => {
debouncedFilter(e.target.value);
});
```
## Best Practices
### Data Management
```javascript
// ✅ GOOD: Load data once, filter in memory
const allStores = await fetch('/api/stores').then((r) => r.json());
function filterStores(criteria) {
return {
type: 'FeatureCollection',
features: allStores.features.filter(criteria)
};
}
// ❌ BAD: Fetch on every filter
async function filterStores(criteria) {
return await fetch(`/api/stores?filter=${criteria}`).then((r) => r.json());
}
```
### Error Handling
```javascript
// Geolocation error handling
navigator.geolocation.getCurrentPosition(
successCallback,
(error) => {
let message = 'Unable to get your location.';
switch (error.code) {
case error.PERMISSION_DENIED:
message = 'Please enable location access to see nearby stores.';
break;
case error.POSITION_UNAVAILABLE:
message = 'Location information is unavailable.';
break;
case error.TIMEOUT:
message = 'Location request timed out.';
break;
}
showNotification(message);
},
{
enableHighAccuracy: true,
timeout: 5000,
maximumAge: 0
}
);
// API error handling
async function loadStores() {
try {
const response = await fetch('/api/stores');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
console.error('Failed to load stores:', error);
showNotification('Unable to load store locations. Please try again.');
return { type: 'FeatureCollection', features: [] };
}
}
```
### Accessibility
```javascript
// Add ARIA labels
document.getElementById('search-input').setAttribute('aria-label', 'Search stores');
// Keyboard navigation
document.querySelectorAll('.listing').forEach((listing, index) => {
listing.setAttribute('tabindex', '0');
listing.setAttribute('role', 'button');
listing.setAttribute('aria-label', `View ${listing.querySelector('.title').textContent}`);
listing.addEventListener('keypress', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
listing.click();
}
});
});
// Focus management
function highlightListing(id) {
const listing = document.getElementById(`listing-${id}`);
listing.classList.add('active');
listing.focus();
listing.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
```
## Common Variations
### Mobile-First Layout
```css
/* Mobile first: stack sidebar on top */
@media (max-width: 768px) {
#app {
flex-direction: column;
}
.sidebar {
width: 100%;
height: 40vh;
max-height: 40vh;
}
#map {
height: 60vh;
}
/* Toggle sidebar */
.sidebar.collapsed {
height: 60px;
}
}
```
### Fullscreen Map with Overlay
```javascript
// Map takes full screen, list appears as overlay
const listOverlay = document.createElement('div');
listOverlay.className = 'list-overlay';
listOverlay.innerHTML = `
<button id="toggle-list">View All Locations (${stores.features.length})</button>
<div id="listings" class="hidden"></div>
`;
document.getElementById('toggle-list').addEventListener('click', () => {
document.getElementById('listings').classList.toggle('hidden');
});
```
### Map-Only View
```javascript
// No sidebar, everything in popups
function createDetailedPopup(store) {
const popup = new mapboxgl.Popup({ maxWidth: '400px' })
.setLngLat(store.geometry.coordinates)
.setHTML(
`
<div class="store-popup">
<h3>${store.properties.name}</h3>
<p class="address">${store.properties.address}</p>
<p class="phone">${store.properties.phone}</p>
<p class="hours">${store.properties.hours}</p>
${store.properties.distance ? `<p class="distance">${store.properties.distance} mi away</p>` : ''}
<div class="actions">
<button onclick="getDirections('${store.properties.id}')">Directions</button>
<button onclick="callStore('${store.properties.phone}')">Call</button>
${store.properties.website ? `<a href="${store.properties.website}" target="_blank">Website</a>` : ''}
</div>
</div>
`
)
.addTo(map);
}
```
## Framework Integration
### React Implementation
```jsx
import { useEffect, useRef, useState } from 'react';
import mapboxgl from 'mapbox-gl';
function StoreLocator({ stores }) {
const mapContainer = useRef(null);
const map = useRef(null);
const [selectedStore, setSelectedStore] = useState(null);
const [filteredStores, setFilteredStores] = useState(stores);
useEffect(() => {
if (map.current) return;
map.current = new mapboxgl.Map({
container: mapContainer.current,
style: 'mapbox://styles/mapbox/standard',
center: [-77.034084, 38.909671],
zoom: 11
});
map.current.on('load', () => {
map.current.addSource('stores', {
type: 'geojson',
data: filteredStores
});
map.current.addLayer({
id: 'stores',
type: 'circle',
source: 'stores',
paint: {
'circle-color': '#2196f3',
'circle-radius': 8
}
});
map.current.on('click', 'stores', (e) => {
setSelectedStore(e.features[0]);
});
});
return () => map.current.remove();
}, []);
// Update source when filtered stores change
useEffect(() => {
if (map.current && map.current.getSource('stores')) {
map.current.getSource('stores').setData(filteredStores);
}
}, [filteredStores]);
return (
<div className="store-locator">
<Sidebar
stores={filteredStores}
selectedStore={selectedStore}
onStoreClick={setSelectedStore}
onFilter={setFilteredStores}
/>
<div ref={mapContainer} className="map-container" />
</div>
);
}
```
## Resources
- [Turf.js](https://turfjs.org/) - Spatial analysis library (recommended for distance calculations)
- [Mapbox GL JS API](https://docs.mapbox.com/mapbox-gl-js/)
- [Interactions API Guide](https://docs.mapbox.com/mapbox-gl-js/guides/user-interactions/interactions/)
- [GeoJSON Specification](https://geojson.org/)
- [Directions API](https://docs.mapbox.com/api/navigation/directions/)
- [Store Locator Tutorial](https://docs.mapbox.com/help/tutorials/building-a-store-locator/)Common style patterns, layer configurations, and recipes for typical mapping scenarios including restaurant finders, real estate, data visualization, navigation, delivery/logistics, and more. Use when implementing specific map use cases or looking for proven style patterns.
# Mapbox Style Patterns Skill
This skill provides battle-tested style patterns and layer configurations for common mapping scenarios.
## Pattern Library
### Pattern 1: Restaurant/POI Finder
**Use case:** Consumer app showing restaurants, cafes, bars, or other points of interest
**Visual requirements:**
- POIs must be immediately visible
- Street context for navigation
- Neutral background (photos/content overlay)
- Mobile-optimized
**Recommended layers:**
```json
{
"layers": [
{
"id": "background",
"type": "background",
"paint": {
"background-color": "#f5f5f5"
}
},
{
"id": "water",
"type": "fill",
"source": "mapbox-streets",
"source-layer": "water",
"paint": {
"fill-color": "#d4e4f7",
"fill-opacity": 0.6
}
},
{
"id": "landuse-parks",
"type": "fill",
"source": "mapbox-streets",
"source-layer": "landuse",
"filter": ["==", "class", "park"],
"paint": {
"fill-color": "#e8f5e8",
"fill-opacity": 0.5
}
},
{
"id": "roads-minor",
"type": "line",
"source": "mapbox-streets",
"source-layer": "road",
"filter": ["in", "class", "street", "street_limited"],
"paint": {
"line-color": "#e0e0e0",
"line-width": {
"base": 1.5,
"stops": [
[12, 0.5],
[15, 2],
[18, 6]
]
}
}
},
{
"id": "roads-major",
"type": "line",
"source": "mapbox-streets",
"source-layer": "road",
"filter": ["in", "class", "primary", "secondary", "tertiary"],
"paint": {
"line-color": "#ffffff",
"line-width": {
"base": 1.5,
"stops": [
[10, 1],
[15, 4],
[18, 12]
]
}
}
},
{
"id": "restaurant-markers",
"type": "symbol",
"source": "restaurants",
"layout": {
"icon-image": "restaurant-15",
"icon-size": 1.5,
"icon-allow-overlap": false,
"text-field": ["get", "name"],
"text-offset": [0, 1.5],
"text-size": 12,
"text-allow-overlap": false
},
"paint": {
"icon-color": "#FF6B35",
"text-color": "#333333",
"text-halo-color": "#ffffff",
"text-halo-width": 2
}
}
]
}
```
**Key features:**
- Desaturated base map (doesn't compete with photos)
- High-contrast markers (#FF6B35 orange stands out)
- Clear road network (white on light gray)
- Parks visible but subtle
- Text halos for readability
### Pattern 2: Real Estate Map
**Use case:** Property search, neighborhood exploration, real estate listings
**Visual requirements:**
- Property boundaries clear
- Neighborhood context visible
- Amenities highlighted (schools, parks, transit)
- Price/property data display
**Recommended layers:**
```json
{
"layers": [
{
"id": "background",
"type": "background",
"paint": {
"background-color": "#fafafa"
}
},
{
"id": "parks-green-spaces",
"type": "fill",
"source": "mapbox-streets",
"source-layer": "landuse",
"filter": ["in", "class", "park", "pitch", "playground"],
"paint": {
"fill-color": "#7cb342",
"fill-opacity": 0.3
}
},
{
"id": "water",
"type": "fill",
"source": "mapbox-streets",
"source-layer": "water",
"paint": {
"fill-color": "#42a5f5",
"fill-opacity": 0.4
}
},
{
"id": "roads",
"type": "line",
"source": "mapbox-streets",
"source-layer": "road",
"paint": {
"line-color": "#e0e0e0",
"line-width": {
"base": 1.2,
"stops": [
[10, 0.5],
[15, 2],
[18, 6]
]
}
}
},
{
"id": "property-boundaries",
"type": "line",
"source": "properties",
"paint": {
"line-color": "#7e57c2",
"line-width": 2,
"line-opacity": 0.8
}
},
{
"id": "property-fills",
"type": "fill",
"source": "properties",
"paint": {
"fill-color": [
"interpolate",
["linear"],
["get", "price"],
200000,
"#4caf50",
500000,
"#ffc107",
1000000,
"#f44336"
],
"fill-opacity": 0.3
}
},
{
"id": "school-icons",
"type": "symbol",
"source": "composite",
"source-layer": "poi_label",
"filter": ["==", "class", "school"],
"layout": {
"icon-image": "school-15",
"icon-size": 1.2
},
"paint": {
"icon-opacity": 0.8
}
},
{
"id": "transit-stops",
"type": "circle",
"source": "transit",
"paint": {
"circle-radius": 6,
"circle-color": "#2196f3",
"circle-stroke-color": "#ffffff",
"circle-stroke-width": 2
}
}
]
}
```
**Key features:**
- Properties color-coded by price (green→yellow→red)
- Parks prominently visible (important for home buyers)
- Schools and transit clearly marked
- Property boundaries visible
- Clean, professional aesthetic
### Pattern 3: Data Visualization Base Map
**Use case:** Choropleth maps, heatmaps, data overlays, analytics dashboards
**Visual requirements:**
- Minimal base map (data is the focus)
- Context without distraction
- Works with various data overlay colors
- High contrast optional for dark data
**Recommended layers:**
```json
{
"layers": [
{
"id": "background",
"type": "background",
"paint": {
"background-color": "#f0f0f0"
}
},
{
"id": "water",
"type": "fill",
"source": "mapbox-streets",
"source-layer": "water",
"paint": {
"fill-color": "#d8d8d8",
"fill-opacity": 0.5
}
},
{
"id": "admin-boundaries",
"type": "line",
"source": "mapbox-streets",
"source-layer": "admin",
"filter": ["in", "admin_level", 0, 1, 2],
"paint": {
"line-color": "#999999",
"line-width": {
"base": 1,
"stops": [
[0, 0.5],
[10, 1],
[15, 2]
]
},
"line-dasharray": [3, 2]
}
},
{
"id": "roads-major-simplified",
"type": "line",
"source": "mapbox-streets",
"source-layer": "road",
"filter": ["in", "class", "motorway", "primary"],
"minzoom": 6,
"paint": {
"line-color": "#cccccc",
"line-width": {
"base": 1.2,
"stops": [
[6, 0.5],
[10, 1],
[15, 2]
]
},
"line-opacity": 0.5
}
},
{
"id": "place-labels-major",
"type": "symbol",
"source": "mapbox-streets",
"source-layer": "place_label",
"filter": ["in", "type", "city", "capital"],
"layout": {
"text-field": ["get", "name"],
"text-size": {
"base": 1,
"stops": [
[4, 10],
[10, 14]
]
},
"text-font": ["Open Sans Semibold"]
},
"paint": {
"text-color": "#666666",
"text-halo-color": "#ffffff",
"text-halo-width": 2
}
}
]
}
```
**Key features:**
- Grayscale palette (doesn't interfere with data colors)
- Minimal detail (roads, borders only)
- Major cities labeled for orientation
- Low opacity throughout
- Perfect for overlay data
### Pattern 4: Navigation/Routing Map
**Use case:** Turn-by-turn directions, route planning, delivery apps
**Visual requirements:**
- Route highly visible
- Current location always clear
- Turn points obvious
- Street names readable
- Performance optimized
**Recommended layers:**
```json
{
"layers": [
{
"id": "background",
"type": "background",
"paint": {
"background-color": "#ffffff"
}
},
{
"id": "water",
"type": "fill",
"source": "mapbox-streets",
"source-layer": "water",
"paint": {
"fill-color": "#a8d8ea"
}
},
{
"id": "landuse",
"type": "fill",
"source": "mapbox-streets",
"source-layer": "landuse",
"paint": {
"fill-color": [
"match",
["get", "class"],
"park",
"#d4edda",
"hospital",
"#f8d7da",
"school",
"#fff3cd",
"#e9ecef"
],
"fill-opacity": 0.5
}
},
{
"id": "roads-background",
"type": "line",
"source": "mapbox-streets",
"source-layer": "road",
"paint": {
"line-color": "#333333",
"line-width": {
"base": 1.5,
"stops": [
[10, 2],
[15, 8],
[18, 20]
]
},
"line-opacity": 0.3
}
},
{
"id": "roads-foreground",
"type": "line",
"source": "mapbox-streets",
"source-layer": "road",
"paint": {
"line-color": "#ffffff",
"line-width": {
"base": 1.5,
"stops": [
[10, 1],
[15, 6],
[18, 16]
]
}
}
},
{
"id": "route-casing",
"type": "line",
"source": "route",
"paint": {
"line-color": "#0d47a1",
"line-width": {
"base": 1.5,
"stops": [
[10, 8],
[15, 16],
[18, 32]
]
},
"line-opacity": 0.4
}
},
{
"id": "route-line",
"type": "line",
"source": "route",
"paint": {
"line-color": "#2196f3",
"line-width": {
"base": 1.5,
"stops": [
[10, 6],
[15, 12],
[18, 24]
]
}
}
},
{
"id": "user-location",
"type": "circle",
"source": "user-location",
"paint": {
"circle-radius": 8,
"circle-color": "#2196f3",
"circle-stroke-color": "#ffffff",
"circle-stroke-width": 3
}
},
{
"id": "user-location-pulse",
"type": "circle",
"source": "user-location",
"paint": {
"circle-radius": {
"base": 1,
"stops": [
[0, 16],
[1, 24]
]
},
"circle-color": "#2196f3",
"circle-opacity": {
"base": 1,
"stops": [
[0, 0.4],
[1, 0]
]
}
}
},
{
"id": "turn-arrows",
"type": "symbol",
"source": "route-maneuvers",
"layout": {
"icon-image": ["get", "arrow-type"],
"icon-size": 1.5,
"icon-rotation-alignment": "map",
"icon-rotate": ["get", "bearing"]
}
}
]
}
```
**Key features:**
- Thick, high-contrast route (blue on white)
- Pulsing user location indicator
- Turn arrows at maneuver points
- Simplified background (focus on route)
- Color-coded land use for context
### Pattern 5: Dark Mode / Night Theme
**Use case:** Reduced eye strain, night use, modern aesthetic, battery saving (OLED)
**Visual requirements:**
- Dark background
- Reduced brightness
- Maintained contrast
- Readable text
- Comfortable viewing
**Recommended layers:**
```json
{
"layers": [
{
"id": "background",
"type": "background",
"paint": {
"background-color": "#0a0a0a"
}
},
{
"id": "water",
"type": "fill",
"source": "mapbox-streets",
"source-layer": "water",
"paint": {
"fill-color": "#1a237e",
"fill-opacity": 0.5
}
},
{
"id": "parks",
"type": "fill",
"source": "mapbox-streets",
"source-layer": "landuse",
"filter": ["==", "class", "park"],
"paint": {
"fill-color": "#1b5e20",
"fill-opacity": 0.4
}
},
{
"id": "buildings",
"type": "fill",
"source": "mapbox-streets",
"source-layer": "building",
"paint": {
"fill-color": "#1a1a1a",
"fill-opacity": 0.8,
"fill-outline-color": "#2a2a2a"
}
},
{
"id": "roads-minor",
"type": "line",
"source": "mapbox-streets",
"source-layer": "road",
"filter": ["in", "class", "street", "street_limited"],
"paint": {
"line-color": "#2a2a2a",
"line-width": {
"base": 1.5,
"stops": [
[12, 0.5],
[15, 2],
[18, 6]
]
}
}
},
{
"id": "roads-major",
"type": "line",
"source": "mapbox-streets",
"source-layer": "road",
"filter": ["in", "class", "primary", "secondary", "motorway"],
"paint": {
"line-color": "#3a3a3a",
"line-width": {
"base": 1.5,
"stops": [
[10, 1],
[15, 4],
[18, 12]
]
}
}
},
{
"id": "labels",
"type": "symbol",
"source": "mapbox-streets",
"source-layer": "place_label",
"layout": {
"text-field": ["get", "name"],
"text-size": 12
},
"paint": {
"text-color": "#e0e0e0",
"text-halo-color": "#0a0a0a",
"text-halo-width": 2
}
}
]
}
```
**Key features:**
- Very dark background (#0a0a0a near-black)
- Subtle color differentiation (deep blues, greens)
- Light text (#e0e0e0) with dark halos
- Reduced opacity throughout
- Easy on eyes in low light
### Pattern 6: Delivery/Logistics Map
**Use case:** Food delivery, package delivery, logistics tracking, on-demand services (DoorDash, Uber Eats, courier apps)
**Visual requirements:**
- Real-time location tracking (drivers, customers)
- Delivery zones clearly defined
- Active routes highly visible
- Status indicators obvious
- Delivery radius visualization
- Performance for live updates
**Recommended layers:**
```json
{
"layers": [
{
"id": "background",
"type": "background",
"paint": {
"background-color": "#fafafa"
}
},
{
"id": "water",
"type": "fill",
"source": "mapbox-streets",
"source-layer": "water",
"paint": {
"fill-color": "#c6dff5",
"fill-opacity": 0.5
}
},
{
"id": "roads-background",
"type": "line",
"source": "mapbox-streets",
"source-layer": "road",
"paint": {
"line-color": "#e0e0e0",
"line-width": {
"base": 1.5,
"stops": [
[10, 1],
[15, 3],
[18, 8]
]
}
}
},
{
"id": "delivery-zones",
"type": "fill",
"source": "delivery-zones",
"paint": {
"fill-color": [
"match",
["get", "status"],
"available",
"#4caf50",
"busy",
"#ff9800",
"unavailable",
"#f44336",
"#9e9e9e"
],
"fill-opacity": 0.15
}
},
{
"id": "delivery-zone-borders",
"type": "line",
"source": "delivery-zones",
"paint": {
"line-color": [
"match",
["get", "status"],
"available",
"#4caf50",
"busy",
"#ff9800",
"unavailable",
"#f44336",
"#9e9e9e"
],
"line-width": 2,
"line-dasharray": [3, 2]
}
},
{
"id": "delivery-radius",
"type": "fill",
"source": "delivery-radius",
"paint": {
"fill-color": "#2196f3",
"fill-opacity": 0.1
}
},
{
"id": "delivery-radius-border",
"type": "line",
"source": "delivery-radius",
"paint": {
"line-color": "#2196f3",
"line-width": 2,
"line-dasharray": [5, 3]
}
},
{
"id": "active-route",
"type": "line",
"source": "active-route",
"paint": {
"line-color": "#1976d2",
"line-width": {
"base": 1.5,
"stops": [
[10, 4],
[15, 8],
[18, 16]
]
},
"line-opacity": 0.8
}
},
{
"id": "route-progress",
"type": "line",
"source": "route-progress",
"paint": {
"line-color": "#43a047",
"line-width": {
"base": 1.5,
"stops": [
[10, 4],
[15, 8],
[18, 16]
]
}
}
},
{
"id": "restaurant-marker",
"type": "circle",
"source": "pickup-locations",
"paint": {
"circle-radius": 12,
"circle-color": "#ff5722",
"circle-stroke-color": "#ffffff",
"circle-stroke-width": 3
}
},
{
"id": "restaurant-icon",
"type": "symbol",
"source": "pickup-locations",
"layout": {
"icon-image": "restaurant-15",
"icon-size": 1.2,
"text-field": ["get", "name"],
"text-offset": [0, 2],
"text-size": 11
},
"paint": {
"text-color": "#212121",
"text-halo-color": "#ffffff",
"text-halo-width": 2
}
},
{
"id": "customer-marker",
"type": "circle",
"source": "delivery-locations",
"paint": {
"circle-radius": 12,
"circle-color": "#4caf50",
"circle-stroke-color": "#ffffff",
"circle-stroke-width": 3
}
},
{
"id": "customer-pulse",
"type": "circle",
"source": "delivery-locations",
"paint": {
"circle-radius": {
"base": 1,
"stops": [
[0, 12],
[1, 24]
]
},
"circle-color": "#4caf50",
"circle-opacity": {
"base": 1,
"stops": [
[0, 0.3],
[1, 0]
]
}
}
},
{
"id": "driver-marker-shadow",
"type": "circle",
"source": "driver-locations",
"paint": {
"circle-radius": 14,
"circle-color": "#000000",
"circle-opacity": 0.2,
"circle-translate": [0, 2]
}
},
{
"id": "driver-marker",
"type": "circle",
"source": "driver-locations",
"paint": {
"circle-radius": 14,
"circle-color": [
"match",
["get", "status"],
"picking_up",
"#ff9800",
"en_route",
"#2196f3",
"delivered",
"#4caf50",
"#9e9e9e"
],
"circle-stroke-color": "#ffffff",
"circle-stroke-width": 3
}
},
{
"id": "driver-direction",
"type": "symbol",
"source": "driver-locations",
"layout": {
"icon-image": "arrow",
"icon-size": 0.5,
"icon-rotate": ["get", "bearing"],
"icon-rotation-alignment": "map",
"icon-allow-overlap": true
}
},
{
"id": "eta-badges",
"type": "symbol",
"source": "driver-locations",
"layout": {
"text-field": ["concat", ["get", "eta"], " min"],
"text-size": 11,
"text-offset": [0, -2.5],
"text-allow-overlap": true
},
"paint": {
"text-color": "#ffffff",
"text-halo-color": "#1976d2",
"text-halo-width": 8,
"text-halo-blur": 1
}
}
]
}
```
**Key features:**
- Color-coded delivery zones (green=available, orange=busy, red=unavailable)
- Real-time driver markers with status colors
- Pulsing customer location indicator
- Active route with completed progress shown in different color
- Delivery radius visualization with dashed border
- ETA badges on driver markers
- Direction arrows showing driver heading
- Restaurant/pickup locations clearly marked
- Shadow effects on driver markers for depth
**Load custom arrow icon:**
```javascript
// Load custom arrow icon for driver direction indicator
// Note: 'arrow' is not a standard Maki icon and must be loaded manually
map.on('load', () => {
map.loadImage('path/to/arrow-icon.png', (error, image) => {
if (error) throw error;
map.addImage('arrow', image);
});
});
```
**Real-time update pattern:**
```javascript
// Update driver location (call on GPS update)
map.getSource('driver-locations').setData({
type: 'FeatureCollection',
features: drivers.map((driver) => ({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: driver.location
},
properties: {
id: driver.id,
status: driver.status,
bearing: driver.bearing,
eta: driver.eta
}
}))
});
// Animate route progress
function updateRouteProgress(completedCoordinates) {
map.getSource('route-progress').setData({
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: completedCoordinates
}
});
}
// Pulse animation for active delivery
function pulseCustomerMarker() {
const duration = 2000;
const start = performance.now();
function animate(time) {
const elapsed = time - start;
const phase = (elapsed % duration) / duration;
// Update radius (12 to 24 pixels)
map.setPaintProperty('customer-pulse', 'circle-radius', 12 + phase * 12);
// Update opacity (fade from 0.3 to 0)
map.setPaintProperty('customer-pulse', 'circle-opacity', 0.3 * (1 - phase));
requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
}
```
**Performance tips:**
- Update driver positions every 3-5 seconds (not every GPS ping)
- Use `setData()` instead of removing/re-adding sources
- Limit visible drivers to current viewport + buffer
- Debounce rapid updates during high activity
- Use symbol layers instead of HTML markers for 50+ drivers
## Pattern Selection Guide
### Decision Tree
**Question 1: What is the primary content?**
- User-generated markers/pins → **POI Finder Pattern**
- Property data/boundaries → **Real Estate Pattern**
- Statistical/analytical data → **Data Visualization Pattern**
- Routes/directions → **Navigation Pattern**
- Real-time tracking/delivery zones → **Delivery/Logistics Pattern**
**Question 2: What is the viewing environment?**
- Daytime/office → Light theme
- Night/dark environment → **Dark Mode Pattern**
- Variable → Provide theme toggle
**Question 3: What is the user's primary action?**
- Browse/explore → Focus on POIs, rich detail
- Navigate → Focus on roads, route visibility
- Track delivery/logistics → Real-time updates, zones, status
- Analyze data → Minimize base map, maximize data
- Select location → Clear boundaries, context
**Question 4: What is the platform?**
- Mobile → Simplified, larger touch targets, less detail
- Desktop → Can include more detail and complexity
- Both → Design mobile-first, enhance for desktop
## Layer Optimization Patterns
### Performance Pattern: Simplified by Zoom
```json
{
"id": "roads",
"type": "line",
"source": "mapbox-streets",
"source-layer": "road",
"filter": [
"step",
["zoom"],
["in", "class", "motorway", "trunk"],
8,
["in", "class", "motorway", "trunk", "primary"],
12,
["in", "class", "motorway", "trunk", "primary", "secondary"],
14,
true
],
"paint": {
"line-width": {
"base": 1.5,
"stops": [
[4, 0.5],
[10, 1],
[15, 4],
[18, 12]
]
}
}
}
```
### Expression Pattern: Data-Driven Styling
```json
{
"paint": {
"circle-radius": [
"interpolate",
["linear"],
["get", "population"],
0,
3,
1000,
5,
10000,
8,
100000,
12,
1000000,
20
],
"circle-color": [
"case",
["<", ["get", "temperature"], 0],
"#2196f3",
["<", ["get", "temperature"], 20],
"#4caf50",
["<", ["get", "temperature"], 30],
"#ffc107",
"#f44336"
]
}
}
```
### Clustering Pattern: Handle Dense POIs
```json
{
"id": "clusters",
"type": "circle",
"source": "pois",
"filter": ["has", "point_count"],
"paint": {
"circle-color": [
"step",
["get", "point_count"],
"#51bbd6", 10,
"#f1f075", 30,
"#f28cb1"
],
"circle-radius": [
"step",
["get", "point_count"],
15, 10,
20, 30,
25
]
}
},
{
"id": "cluster-count",
"type": "symbol",
"source": "pois",
"filter": ["has", "point_count"],
"layout": {
"text-field": ["get", "point_count_abbreviated"],
"text-size": 12
}
}
```
## Common Modifications
### Add 3D Buildings
```json
{
"id": "3d-buildings",
"type": "fill-extrusion",
"source": "composite",
"source-layer": "building",
"minzoom": 15,
"paint": {
"fill-extrusion-color": "#aaa",
"fill-extrusion-height": ["interpolate", ["linear"], ["zoom"], 15, 0, 15.05, ["get", "height"]],
"fill-extrusion-base": ["interpolate", ["linear"], ["zoom"], 15, 0, 15.05, ["get", "min_height"]],
"fill-extrusion-opacity": 0.6
}
}
```
### Add Terrain/Hillshade
```json
{
"sources": {
"mapbox-dem": {
"type": "raster-dem",
"url": "mapbox://mapbox.mapbox-terrain-dem-v1"
}
},
"layers": [
{
"id": "hillshade",
"type": "hillshade",
"source": "mapbox-dem",
"paint": {
"hillshade-exaggeration": 0.5,
"hillshade-shadow-color": "#000000"
}
}
],
"terrain": {
"source": "mapbox-dem",
"exaggeration": 1.5
}
}
```
### Add Custom Markers
```json
{
"id": "custom-markers",
"type": "symbol",
"source": "markers",
"layout": {
"icon-image": "custom-marker",
"icon-size": 0.8,
"icon-anchor": "bottom",
"icon-allow-overlap": true,
"text-field": ["get", "name"],
"text-offset": [0, -2],
"text-anchor": "top",
"text-size": 12
},
"paint": {
"text-color": "#ffffff",
"text-halo-color": "#000000",
"text-halo-width": 2
}
}
```
## Testing Patterns
### Visual Regression Checklist
- [ ] Test at zoom levels: 4, 8, 12, 16, 20
- [ ] Verify on mobile (375px width)
- [ ] Verify on desktop (1920px width)
- [ ] Test with dense data
- [ ] Test with sparse data
- [ ] Check label collision
- [ ] Verify color contrast (WCAG)
- [ ] Test loading performance
## When to Use This Skill
Invoke this skill when:
- Starting a new map style for a specific use case
- Looking for layer configuration examples
- Implementing common mapping patterns
- Optimizing existing styles
- Need proven recipes for typical scenarios
- Debugging style issues
- Learning Mapbox style best practicesExpert guidance on validating, optimizing, and ensuring quality of Mapbox styles through validation, accessibility checks, and optimization. Use when preparing styles for production, debugging issues, or ensuring map quality standards.
# Mapbox Style Quality Skill
This skill provides expert guidance on ensuring Mapbox style quality through validation, accessibility, and optimization tools.
## When to Use Quality Tools
### Pre-Production Checklist
Before deploying any Mapbox style to production:
1. **Validate all expressions** - Catch syntax errors before runtime
2. **Check color contrast** - Ensure text is readable (WCAG compliance)
3. **Validate GeoJSON sources** - Ensure data integrity
4. **Optimize style** - Reduce file size and improve performance
5. **Compare versions** - Understand what changed
### During Development
**When adding GeoJSON data:**
- Always validate external GeoJSON with `validate_geojson_tool` before using as a source
**When writing expressions:**
- Validate expressions with `validate_expression_tool` as you write them
- Catch type mismatches early (e.g., using string operator on number)
- Verify operator availability in your Mapbox GL JS version
- Test expressions with expected data types
**When styling text/labels:**
- Check foreground/background contrast with `check_color_contrast_tool`
- Aim for WCAG AA minimum (4.5:1 for normal text, 3:1 for large text)
- Use AAA standard (7:1 for normal text) for better accessibility
- Consider different background scenarios (map tiles, overlays)
### Before Committing Changes
**Compare style versions:**
- Use `compare_styles_tool` to generate a diff report
- Review all layer changes, source modifications, and expression updates
- Understand the impact of your changes
- Document significant changes in commit messages
### Before Deployment
**Optimize the style:**
- Run `optimize_style_tool` to reduce file size
- Remove unused sources that reference deleted layers
- Eliminate duplicate layers with identical properties
- Simplify boolean expressions for better performance
- Remove empty layers that serve no purpose
## Validation Best Practices
### GeoJSON Validation
**Always validate when:**
- Loading GeoJSON from user uploads
- Fetching GeoJSON from external APIs
- Processing GeoJSON from third-party sources
- Converting between data formats
**Common GeoJSON errors:**
- Invalid coordinate ranges (longitude > 180 or < -180)
- Unclosed polygon rings (first and last coordinates must match)
- Wrong coordinate order (should be [longitude, latitude], not [latitude, longitude])
- Missing required properties (type, coordinates, geometry)
- Invalid geometry types or nesting
**Example workflow:**
```
1. Receive GeoJSON data
2. Validate with validate_geojson_tool
3. If valid: Add as source to style
4. If invalid: Fix errors, re-validate
```
### Expression Validation
**Validate expressions for:**
- Filter conditions (`filter` property on layers)
- Data-driven styling (`paint` and `layout` properties)
- Feature state expressions
- Dynamic property calculations
**Common expression errors:**
- Type mismatches (string operators on numbers)
- Invalid operator names or wrong syntax
- Wrong number of arguments for operators
- Nested expression errors
- Using unavailable operators for your GL JS version
**Prevention strategies:**
- Validate as you write expressions, not at runtime
- Test expressions with representative data
- Use type checking (expectedType parameter)
- Validate in context (layer, filter, paint, layout)
### Accessibility Validation
**WCAG Levels:**
- **AA** (minimum): 4.5:1 for normal text, 3:1 for large text
- **AAA** (enhanced): 7:1 for normal text, 4.5:1 for large text
**Text size categories:**
- **Normal**: < 18pt or < 14pt bold
- **Large**: ≥ 18pt or ≥ 14pt bold
**Common scenarios to check:**
- Text labels on map tiles
- POI labels with background colors
- Custom markers with text
- UI overlays on maps
- Legend text and symbols
- Attribution text
**Testing strategy:**
- Test against both light and dark map tiles
- Consider overlay backgrounds (popups, modals)
- Test in different lighting conditions (mobile outdoor use)
- Verify contrast at different zoom levels
## Optimization Best Practices
### When to Optimize
**Before production deployment:**
- After all development changes are complete
- After merging multiple feature branches
- When style has grown significantly over time
- Before major releases or launches
**Benefits of optimization:**
- Faster initial load times
- Reduced bandwidth usage
- Better runtime performance
- Cleaner, more maintainable code
### Optimization Types
**Remove unused sources:**
- Automatically identifies sources not referenced by any layer
- Safe to remove without affecting functionality
- Common after deleting layers or refactoring
**Remove duplicate layers:**
- Finds layers with identical properties (excluding ID)
- Can occur when copying/pasting layers
- Reduces style complexity and file size
**Simplify expressions:**
- Converts `["all", true]` → `true`
- Converts `["any", false]` → `false`
- Converts `["!", false]` → `true`
- Converts `["!", true]` → `false`
- Improves expression evaluation performance
**Remove empty layers:**
- Removes layers with no paint or layout properties
- Preserves background layers (valid even when empty)
- Cleans up incomplete or placeholder layers
**Consolidate filters:**
- Identifies groups of layers with identical filter expressions
- Highlights opportunities for layer consolidation
- Doesn't automatically consolidate (informational only)
### Optimization Strategy
**Recommended order:**
1. Remove unused sources first (reduces noise for other checks)
2. Remove duplicate layers (eliminates redundancy)
3. Simplify expressions (improves readability and performance)
4. Remove empty layers (final cleanup)
5. Review consolidation opportunities (manual step)
**Selective optimization:**
```
// All optimizations (recommended for production)
optimize_style_tool({ style })
// Specific optimizations only
optimize_style_tool({
style,
optimizations: ['remove-unused-sources', 'simplify-expressions']
})
```
**Review before deploying:**
- Check the optimization report
- Verify size savings (percentReduction)
- Review the list of changes (optimizations array)
- Test the optimized style before deployment
## Style Comparison Workflow
### When to Compare Styles
**Before merging changes:**
- Review what changed in your feature branch
- Ensure no unintended modifications
- Generate change summary for PR description
**When investigating issues:**
- Compare working version vs. broken version
- Identify what changed between versions
- Narrow down root cause of problems
**During migrations:**
- Compare old format vs. new format
- Verify data integrity after conversion
- Document transformation differences
### Comparison Best Practices
**Use ignoreMetadata flag:**
```
// Ignore metadata differences (id, owner, created, modified)
compare_styles_tool({
styleA: oldStyle,
styleB: newStyle,
ignoreMetadata: true
})
```
**Focus on meaningful changes:**
- Layer additions/removals
- Source changes
- Expression modifications
- Paint/layout property updates
**Document significant changes:**
- Note breaking changes in documentation
- Update style version numbers
- Communicate changes to team/users
## Quality Workflow Examples
### Basic Quality Check
```
1. Validate expressions in style
2. Check color contrast for text layers
3. Optimize if needed
```
### Full Pre-Production Workflow
```
1. Validate all GeoJSON sources
2. Validate all expressions (filters, paint, layout)
3. Check color contrast for all text layers
4. Compare with previous production version
5. Optimize style
6. Test optimized style
7. Deploy
```
### Troubleshooting Workflow
```
1. Compare working vs. broken style
2. Identify differences
3. Validate suspicious expressions
4. Check GeoJSON data if source-related
5. Verify color contrast if visibility issue
```
### Refactoring Workflow
```
1. Create backup of current style
2. Make refactoring changes
3. Compare before vs. after
4. Validate all modified expressions
5. Optimize to clean up
6. Review size impact
```
## Common Issues and Solutions
### Runtime Expression Errors
**Problem:** Map throws expression errors at runtime
**Solution:** Validate expressions with `validate_expression_tool` during development
**Prevention:** Add expression validation to pre-commit hooks or CI/CD
### Poor Text Readability
**Problem:** Text labels are hard to read on map
**Solution:** Check contrast with `check_color_contrast_tool`, adjust colors to meet WCAG AA
**Prevention:** Test text on both light and dark backgrounds, check at different zoom levels
### Large Style File Size
**Problem:** Style takes long to load or transfer
**Solution:** Run `optimize_style_tool` to remove redundancies and simplify
**Prevention:** Regularly optimize during development, remove unused sources immediately
### Invalid GeoJSON Source
**Problem:** GeoJSON source fails to load or render
**Solution:** Validate with `validate_geojson_tool`, fix coordinate issues, verify structure
**Prevention:** Validate all external GeoJSON before adding to style
### Unexpected Style Changes
**Problem:** Style changed but unsure what modified
**Solution:** Use `compare_styles_tool` to generate diff report
**Prevention:** Compare before/after for all significant changes, document modifications
## Integration with Development Workflow
### Git Pre-Commit Hook
```bash
# Validate expressions before commit
npm run validate-style
# Optimize before commit (optional)
npm run optimize-style
```
### CI/CD Pipeline
```
1. Validate all expressions
2. Check accessibility compliance
3. Run optimization (warning if significant savings)
4. Compare with production version
5. Generate quality report
```
### Code Review Checklist
- [ ] All expressions validated
- [ ] Text contrast meets WCAG AA
- [ ] GeoJSON sources validated
- [ ] Style optimized for production
- [ ] Changes documented in comparison report
## Best Practices Summary
**During Development:**
- Validate expressions as you write them
- Check GeoJSON data when adding sources
- Test color contrast for new text layers
**Before Committing:**
- Compare with previous version
- Document significant changes
- Validate modified expressions
**Before Production:**
- Run full validation suite
- Check accessibility compliance
- Optimize style
- Test optimized version
- Generate quality report
**Regular Maintenance:**
- Periodically optimize to prevent bloat
- Review and consolidate similar layers
- Update expressions to use simpler forms
- Remove deprecated or unused code
## Tool Quick Reference
| Tool | Use When | Output |
| --------------------------- | ---------------------- | -------------------------- |
| `validate_geojson_tool` | Adding GeoJSON sources | Valid/invalid + error list |
| `validate_expression_tool` | Writing expressions | Valid/invalid + error list |
| `check_color_contrast_tool` | Styling text labels | Passes/fails + WCAG levels |
| `compare_styles_tool` | Reviewing changes | Diff report with paths |
| `optimize_style_tool` | Before deployment | Optimized style + savings |
## Additional Resources
- [Mapbox Style Specification](https://docs.mapbox.com/mapbox-gl-js/style-spec/)
- [WCAG 2.1 Guidelines](https://www.w3.org/WAI/WCAG21/quickref/)
- [GeoJSON Specification (RFC 7946)](https://tools.ietf.org/html/rfc7946)
- [Mapbox Expression Reference](https://docs.mapbox.com/mapbox-gl-js/style-spec/expressions/)Security best practices for Mapbox access tokens, including scope management, URL restrictions, rotation strategies, and protecting sensitive data. Use when creating, managing, or advising on Mapbox token security.
# Mapbox Token Security Skill
This skill provides security expertise for managing Mapbox access tokens safely and effectively.
## Token Types and When to Use Them
### Public Tokens (pk.\*)
**Characteristics:**
- Can be safely exposed in client-side code
- Limited to specific public scopes only
- Can have URL restrictions
- Cannot access sensitive APIs
**When to use:**
- Client-side web applications
- Mobile apps
- Public-facing demos
- Embedded maps on websites
**Allowed scopes:**
- `styles:tiles` - Display style tiles (raster)
- `styles:read` - Read style specifications
- `fonts:read` - Access Mapbox fonts
- `datasets:read` - Read dataset data
- `vision:read` - Vision API access
### Secret Tokens (sk.\*)
**Characteristics:**
- **NEVER expose in client-side code**
- Full API access with any scopes
- Server-side use only
- Can create/manage other tokens
**When to use:**
- Server-side applications
- Backend services
- CI/CD pipelines
- Administrative tasks
- Token management
**Common scopes:**
- `styles:write` - Create/modify styles
- `styles:list` - List all styles
- `tokens:read` - View token information
- `tokens:write` - Create/modify tokens
- User feedback management scopes
### Temporary Tokens (tk.\*)
**Characteristics:**
- Short-lived (max 1 hour)
- Created by secret tokens
- Single-purpose use
- Automatically expire
**When to use:**
- One-time operations
- Temporary delegated access
- Short-lived demos
- Security-conscious workflows
## Scope Management Best Practices
### Principle of Least Privilege
**Always grant the minimum scopes needed:**
❌ **Bad:**
```javascript
// Overly permissive - don't do this
{
scopes: ['styles:read', 'styles:write', 'styles:list', 'styles:delete', 'tokens:read', 'tokens:write'];
}
```
✅ **Good:**
```javascript
// Only what's needed for displaying a map
{
scopes: ['styles:read', 'fonts:read'];
}
```
### Scope Combinations by Use Case
**Public Map Display (client-side):**
```json
{
"scopes": ["styles:read", "fonts:read", "styles:tiles"],
"note": "Public token for map display",
"allowedUrls": ["https://myapp.com/*"]
}
```
**Style Management (server-side):**
```json
{
"scopes": ["styles:read", "styles:write", "styles:list"],
"note": "Backend style management - SECRET TOKEN"
}
```
**Token Administration (server-side):**
```json
{
"scopes": ["tokens:read", "tokens:write"],
"note": "Token management only - SECRET TOKEN"
}
```
**Read-Only Access:**
```json
{
"scopes": ["styles:list", "styles:read", "tokens:read"],
"note": "Auditing/monitoring - SECRET TOKEN"
}
```
## URL Restrictions
### Why URL Restrictions Matter
URL restrictions limit where a public token can be used, preventing unauthorized usage if the token is exposed.
### Effective URL Patterns
✅ **Recommended patterns:**
```
https://myapp.com/* # Production domain
https://*.myapp.com/* # All subdomains
https://staging.myapp.com/* # Staging environment
http://localhost:* # Local development
```
❌ **Avoid these:**
```
* # No restriction (insecure)
http://* # Any HTTP site (insecure)
*.com/* # Too broad
```
### Multiple Environment Strategy
Create separate tokens for each environment:
```javascript
// Production
{
note: "Production - myapp.com",
scopes: ["styles:read", "fonts:read"],
allowedUrls: ["https://myapp.com/*", "https://www.myapp.com/*"]
}
// Staging
{
note: "Staging - staging.myapp.com",
scopes: ["styles:read", "fonts:read"],
allowedUrls: ["https://staging.myapp.com/*"]
}
// Development
{
note: "Development - localhost",
scopes: ["styles:read", "fonts:read"],
allowedUrls: ["http://localhost:*", "http://127.0.0.1:*"]
}
```
## Token Storage and Handling
### Server-Side (Secret Tokens)
✅ **DO:**
- Store in environment variables
- Use secret management services (AWS Secrets Manager, HashiCorp Vault)
- Encrypt at rest
- Limit access via IAM policies
- Log token usage
❌ **DON'T:**
- Hardcode in source code
- Commit to version control
- Store in plaintext configuration files
- Share via email or Slack
- Reuse across multiple services
**Example: Secure Environment Variable:**
```bash
# .env (NEVER commit this file)
MAPBOX_SECRET_TOKEN=sk.ey...
# .gitignore (ALWAYS include .env)
.env
.env.local
.env.*.local
```
### Client-Side (Public Tokens)
✅ **DO:**
- Use public tokens only
- Apply URL restrictions
- Use different tokens per app
- Rotate periodically
- Monitor usage
❌ **DON'T:**
- Expose secret tokens
- Use tokens without URL restrictions
- Share tokens between unrelated apps
- Use tokens with excessive scopes
**Example: Safe Client Usage:**
```javascript
// Public token with URL restrictions - SAFE
const mapboxToken = 'pk.YOUR_MAPBOX_TOKEN_HERE';
// This token is restricted to your domain
// and only has styles:read scope
mapboxgl.accessToken = mapboxToken;
```
## Token Rotation Strategy
### When to Rotate Tokens
**Mandatory rotation:**
- Token exposed in public repository
- Team member leaves with token access
- Suspected compromise or breach
- Service decommissioning
- Compliance requirements
**Scheduled rotation:**
- Every 90 days (recommended for production)
- Every 30 days (high-security environments)
- After major deployments
- During security audits
### Rotation Process
**Zero-downtime rotation:**
1. **Create new token** with same scopes
2. **Deploy new token** to canary/staging environment
3. **Verify functionality** with new token
4. **Gradually roll out** to production
5. **Monitor for issues** for 24-48 hours
6. **Revoke old token** after confirmation
7. **Update documentation** with rotation date
**Emergency rotation:**
1. **Immediately revoke** compromised token
2. **Create replacement** token
3. **Deploy emergency update** to all services
4. **Notify team** of incident
5. **Investigate** how compromise occurred
6. **Update procedures** to prevent recurrence
## Monitoring and Auditing
### Track Token Usage
**Metrics to monitor:**
- API request volume per token
- Geographic distribution of requests
- Error rates by token
- Unexpected spike patterns
- Requests from unauthorized domains
**Alert on:**
- Usage from unexpected IPs/regions
- Sudden traffic spikes (>200% normal)
- High error rates (>10%)
- Requests outside allowed URLs
- Off-hours access patterns
### Regular Security Audits
**Monthly checklist:**
- [ ] Review all active tokens
- [ ] Verify token scopes are still appropriate
- [ ] Check for unused tokens (revoke if inactive >30 days)
- [ ] Confirm URL restrictions are current
- [ ] Review team member access
- [ ] Check for tokens in public repositories (GitHub scan)
- [ ] Verify documentation is up-to-date
**Quarterly checklist:**
- [ ] Rotate production tokens
- [ ] Full token inventory
- [ ] Access control review
- [ ] Update incident response procedures
- [ ] Security training for team
## Common Security Mistakes
### 1. Exposing Secret Tokens in Client Code
❌ **CRITICAL ERROR:**
```javascript
// NEVER DO THIS - Secret token in client code
const map = new mapboxgl.Map({
accessToken: 'sk.YOUR_SECRET_TOKEN_HERE' // SECRET TOKEN
});
```
✅ **Correct:**
```javascript
// Public token only in client code
const map = new mapboxgl.Map({
accessToken: 'pk.YOUR_PUBLIC_TOKEN_HERE' // PUBLIC TOKEN
});
```
### 2. Overly Permissive Scopes
❌ **Too broad:**
```json
{
"scopes": ["styles:*", "tokens:*"]
}
```
✅ **Specific:**
```json
{
"scopes": ["styles:read"]
}
```
### 3. Missing URL Restrictions
❌ **No restrictions:**
```json
{
"scopes": ["styles:read"],
"allowedUrls": [] // Token works anywhere
}
```
✅ **Domain restricted:**
```json
{
"scopes": ["styles:read"],
"allowedUrls": ["https://myapp.com/*"]
}
```
### 4. Long-Lived Tokens Without Rotation
❌ **Never rotated:**
```
Token created: Jan 2020
Last rotation: Never
Still in production: Yes
```
✅ **Regular rotation:**
```
Token created: Dec 2024
Last rotation: Dec 2024
Next rotation: Mar 2025
```
### 5. Tokens in Version Control
❌ **Committed to Git:**
```javascript
// config.js (committed to repo)
export const MAPBOX_TOKEN = 'sk.YOUR_SECRET_TOKEN_HERE';
```
✅ **Environment variables:**
```javascript
// config.js
export const MAPBOX_TOKEN = process.env.MAPBOX_SECRET_TOKEN;
```
```bash
# .env (in .gitignore)
MAPBOX_SECRET_TOKEN=sk.YOUR_SECRET_TOKEN_HERE
```
## Incident Response Plan
### If a Token is Compromised
**Immediate actions (first 15 minutes):**
1. **Revoke the token** via Mapbox dashboard or API
2. **Create replacement token** with different scopes/restrictions if needed
3. **Update all services** using the compromised token
4. **Notify team** via incident channel
**Investigation (within 24 hours):** 5. **Review access logs** to understand exposure 6. **Check for unauthorized usage** in Mapbox dashboard 7. **Identify root cause** (how was it exposed?) 8. **Document incident** with timeline and impact
**Prevention (within 1 week):** 9. **Update procedures** to prevent recurrence 10. **Implement additional safeguards** (CI checks, secret scanning) 11. **Train team** on lessons learned 12. **Update documentation** with new security measures
## Best Practices Summary
### Security Checklist
**Token Creation:**
- [ ] Use public tokens for client-side, secret for server-side
- [ ] Apply principle of least privilege for scopes
- [ ] Add URL restrictions to public tokens
- [ ] Use descriptive names/notes for token identification
- [ ] Document intended use and environment
**Token Management:**
- [ ] Store secret tokens in environment variables or secret managers
- [ ] Never commit tokens to version control
- [ ] Rotate tokens every 90 days (or per policy)
- [ ] Remove unused tokens promptly
- [ ] Separate tokens by environment (dev/staging/prod)
**Monitoring:**
- [ ] Track token usage patterns
- [ ] Set up alerts for unusual activity
- [ ] Regular security audits (monthly)
- [ ] Review team access quarterly
- [ ] Scan repositories for exposed tokens
**Incident Response:**
- [ ] Documented revocation procedure
- [ ] Emergency contact list
- [ ] Rotation process documented
- [ ] Post-incident review template
- [ ] Team training on security procedures
## When to Use This Skill
Invoke this skill when:
- Creating new tokens
- Deciding between public vs secret tokens
- Setting up token restrictions
- Implementing token rotation
- Investigating security incidents
- Conducting security audits
- Training team on token security
- Reviewing code for token exposureOfficial integration patterns for Mapbox GL JS across popular web frameworks (React, Vue, Svelte, Angular). Covers setup, lifecycle management, token handling, search integration, and common pitfalls. Based on Mapbox's create-web-app scaffolding tool.
# Mapbox Integration Patterns Skill
This skill provides official patterns for integrating Mapbox GL JS into web applications using React, Vue, Svelte, Angular, and vanilla JavaScript. These patterns are based on Mapbox's `create-web-app` scaffolding tool and represent production-ready best practices.
## Version Requirements
### Mapbox GL JS
**Recommended:** v3.x (latest)
- **Minimum:** v3.0.0
- **Why v3.x:** Modern API, improved performance, active development
- **v2.x:** Legacy; no longer actively developed (see migration notes below)
**Installing via npm (recommended for production):**
```bash
npm install mapbox-gl@^3.0.0 # Installs latest v3.x
```
**CDN (for prototyping only):**
```html
<!-- Replace VERSION with latest v3.x from https://docs.mapbox.com/mapbox-gl-js/ -->
<script src="https://api.mapbox.com/mapbox-gl-js/vVERSION/mapbox-gl.js"></script>
<link href="https://api.mapbox.com/mapbox-gl-js/vVERSION/mapbox-gl.css" rel="stylesheet" />
```
⚠️ **Production apps should use npm, not CDN** - ensures consistent versions and offline builds.
### Framework Requirements
**React:**
- GL JS works with React 16.8+ (requires hooks)
- `create-web-app` scaffolds with React 19.x
**Vue:**
- GL JS works with Vue 2.x+ (Vue 3 Composition API recommended)
- Vue 2.x: Use Options API pattern (mounted/unmounted hooks)
**Svelte:**
- GL JS works with any Svelte version
- `create-web-app` scaffolds with Svelte 5.x
**Angular:**
- GL JS works with Angular 2+ (any version with lifecycle hooks)
- `create-web-app` scaffolds with Angular 19.x
**Next.js:**
- Minimum: 13.x (App Router)
- Pages Router: 12.x+
### Mapbox Search JS
**Required for search integration:**
```bash
npm install @mapbox/search-js-react@^1.0.0 # React
npm install @mapbox/search-js-web@^1.0.0 # Other frameworks
```
### Version Migration Notes
**Migrating from v2.x to v3.x:**
- WebGL 2 now required
- `optimizeForTerrain` option removed
- Improved TypeScript types
- Better tree-shaking support
- No breaking changes to core initialization patterns
**Token patterns (work in v2.x and v3.x):**
```javascript
const token = import.meta.env.VITE_MAPBOX_ACCESS_TOKEN; // Use env vars in production
// Global token (works since v1.x)
mapboxgl.accessToken = token;
const map = new mapboxgl.Map({ container: '...' });
// Per-map token (preferred for multi-map setups)
const map = new mapboxgl.Map({
accessToken: token,
container: '...'
});
```
## Core Principles
**Every Mapbox GL JS integration must:**
1. Initialize the map in the correct lifecycle hook
2. Store map instance in component state (not recreate on every render)
3. **Always call `map.remove()` on cleanup** to prevent memory leaks
4. Handle token management securely (environment variables)
5. Import CSS: `import 'mapbox-gl/dist/mapbox-gl.css'`
## Framework-Specific Patterns
### React Integration
**Pattern: useRef + useEffect with cleanup**
> **Note:** These examples use **Vite** (the bundler used in `create-web-app`). If using Create React App, replace `import.meta.env.VITE_MAPBOX_ACCESS_TOKEN` with `process.env.REACT_APP_MAPBOX_TOKEN`. See the [Token Management Patterns](#token-management-patterns) section for other bundlers.
```jsx
import { useRef, useEffect } from 'react';
import mapboxgl from 'mapbox-gl';
import 'mapbox-gl/dist/mapbox-gl.css';
function MapComponent() {
const mapRef = useRef(null); // Store map instance
const mapContainerRef = useRef(null); // Store DOM reference
useEffect(() => {
mapboxgl.accessToken = import.meta.env.VITE_MAPBOX_ACCESS_TOKEN;
mapRef.current = new mapboxgl.Map({
container: mapContainerRef.current,
center: [-71.05953, 42.3629],
zoom: 13
});
// CRITICAL: Cleanup to prevent memory leaks
return () => {
mapRef.current.remove();
};
}, []); // Empty dependency array = run once on mount
return <div ref={mapContainerRef} style={{ height: '100vh' }} />;
}
```
**Key points:**
- Use `useRef` for both map instance and container
- Initialize in `useEffect` with empty deps `[]`
- **Always return cleanup function** that calls `map.remove()`
- Never initialize map in render (causes infinite loops)
**React + Search JS:**
```jsx
import { useRef, useEffect, useState } from 'react';
import mapboxgl from 'mapbox-gl';
import { SearchBox } from '@mapbox/search-js-react';
import 'mapbox-gl/dist/mapbox-gl.css';
const accessToken = import.meta.env.VITE_MAPBOX_ACCESS_TOKEN;
const center = [-71.05953, 42.3629];
function MapWithSearch() {
const mapRef = useRef(null);
const mapContainerRef = useRef(null);
const [inputValue, setInputValue] = useState('');
useEffect(() => {
mapboxgl.accessToken = accessToken;
mapRef.current = new mapboxgl.Map({
container: mapContainerRef.current,
center: center,
zoom: 13
});
return () => {
mapRef.current.remove();
};
}, []);
return (
<>
<div
style={{
margin: '10px 10px 0 0',
width: 300,
right: 0,
top: 0,
position: 'absolute',
zIndex: 10
}}
>
<SearchBox
accessToken={accessToken}
map={mapRef.current}
mapboxgl={mapboxgl}
value={inputValue}
proximity={center}
onChange={(d) => setInputValue(d)}
marker
/>
</div>
<div ref={mapContainerRef} style={{ height: '100vh' }} />
</>
);
}
```
---
### Vue Integration
**Pattern: mounted + unmounted lifecycle hooks**
```vue
<template>
<div ref="mapContainer" class="map-container"></div>
</template>
<script>
import mapboxgl from 'mapbox-gl';
import 'mapbox-gl/dist/mapbox-gl.css';
mapboxgl.accessToken = import.meta.env.VITE_MAPBOX_ACCESS_TOKEN;
export default {
mounted() {
const map = new mapboxgl.Map({
container: this.$refs.mapContainer,
style: 'mapbox://styles/mapbox/standard',
center: [-71.05953, 42.3629],
zoom: 13
});
// Assign map instance to component property
this.map = map;
},
// CRITICAL: Clean up when component is unmounted
unmounted() {
this.map.remove();
this.map = null;
}
};
</script>
<style>
.map-container {
width: 100%;
height: 100%;
}
</style>
```
**Key points:**
- Initialize in `mounted()` hook
- Access container via `this.$refs.mapContainer`
- Store map as `this.map`
- **Always implement `unmounted()` hook** to call `map.remove()`
---
### Svelte Integration
**Pattern: onMount + onDestroy**
```svelte
<script>
import mapboxgl from 'mapbox-gl'
import 'mapbox-gl/dist/mapbox-gl.css'
import { onMount, onDestroy } from 'svelte'
let map
let mapContainer
onMount(() => {
map = new mapboxgl.Map({
container: mapContainer,
accessToken: import.meta.env.VITE_MAPBOX_ACCESS_TOKEN,
center: [-71.05953, 42.36290],
zoom: 13
})
})
// CRITICAL: Clean up on component destroy
onDestroy(() => {
map.remove()
})
</script>
<div class="map" bind:this={mapContainer}></div>
<style>
.map {
position: absolute;
width: 100%;
height: 100%;
}
</style>
```
**Key points:**
- Use `onMount` for initialization
- Bind container with `bind:this={mapContainer}`
- **Always implement `onDestroy`** to call `map.remove()`
- Can pass `accessToken` directly to Map constructor in Svelte
---
### Angular Integration
**Pattern: ngOnInit + ngOnDestroy with SSR handling**
```typescript
import { Component, ElementRef, OnDestroy, OnInit, ViewChild, inject } from '@angular/core';
import { isPlatformBrowser, CommonModule } from '@angular/common';
import { PLATFORM_ID } from '@angular/core';
import { environment } from '../../environments/environment';
@Component({
selector: 'app-map',
standalone: true,
imports: [CommonModule],
templateUrl: './map.component.html',
styleUrls: ['./map.component.scss']
})
export class MapComponent implements OnInit, OnDestroy {
@ViewChild('mapContainer', { static: false })
mapContainer!: ElementRef<HTMLDivElement>;
private map: any;
private readonly platformId = inject(PLATFORM_ID);
async ngOnInit(): Promise<void> {
// IMPORTANT: Check if running in browser (not SSR)
if (!isPlatformBrowser(this.platformId)) {
return;
}
try {
await this.initializeMap();
} catch (error) {
console.error('Failed to initialize map:', error);
}
}
private async initializeMap(): Promise<void> {
// Dynamically import to avoid SSR issues
const mapboxgl = (await import('mapbox-gl')).default;
this.map = new mapboxgl.Map({
accessToken: environment.mapboxAccessToken,
container: this.mapContainer.nativeElement,
center: [-71.05953, 42.3629],
zoom: 13
});
// Handle map errors
this.map.on('error', (e: any) => console.error('Map error:', e.error));
}
// CRITICAL: Clean up on component destroy
ngOnDestroy(): void {
if (this.map) {
this.map.remove();
}
}
}
```
**Template (map.component.html):**
```html
<div #mapContainer style="height: 100vh; width: 100%"></div>
```
**Key points:**
- Use `@ViewChild` to reference map container
- **Check `isPlatformBrowser` before initializing** (SSR support)
- **Dynamically import `mapbox-gl`** to avoid SSR issues
- Initialize in `ngOnInit()` lifecycle hook
- **Always implement `ngOnDestroy()`** to call `map.remove()`
- Handle errors with `map.on('error', ...)`
---
### Vanilla JavaScript (with Vite)
**Pattern: Module imports with initialization function**
```javascript
import mapboxgl from 'mapbox-gl';
import 'mapbox-gl/dist/mapbox-gl.css';
import './main.css';
// Set access token
mapboxgl.accessToken = import.meta.env.VITE_MAPBOX_ACCESS_TOKEN;
let map;
/**
* Initialize the map
*/
function initMap() {
map = new mapboxgl.Map({
container: 'map-container',
center: [-71.05953, 42.3629],
zoom: 13
});
map.on('load', () => {
console.log('Map is loaded');
});
}
// Initialize when script runs
initMap();
```
**HTML:**
```html
<div id="map-container" style="height: 100vh;"></div>
```
**Key points:**
- Store map in module-scoped variable
- Initialize immediately or on DOMContentLoaded
- Listen for 'load' event for post-initialization actions
---
### Vanilla JavaScript (No Bundler - CDN)
**Pattern: Script tag with inline initialization**
⚠️ **Note:** This pattern is for prototyping only. Production apps should use npm/bundler for version control and offline builds.
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mapbox GL JS - No Bundler</title>
<!-- Mapbox GL JS CSS -->
<!-- Replace 3.x.x with latest version from https://docs.mapbox.com/mapbox-gl-js/ -->
<link href="https://api.mapbox.com/mapbox-gl-js/v3.x.x/mapbox-gl.css" rel="stylesheet" />
<style>
body {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
margin: 0;
padding: 0;
}
#map-container {
height: 100%;
width: 100%;
}
</style>
</head>
<body>
<div id="map-container"></div>
<!-- Mapbox GL JS -->
<!-- Replace 3.x.x with latest version from https://docs.mapbox.com/mapbox-gl-js/ -->
<script src="https://api.mapbox.com/mapbox-gl-js/v3.x.x/mapbox-gl.js"></script>
<script>
// Set access token
mapboxgl.accessToken = 'YOUR_MAPBOX_ACCESS_TOKEN_HERE';
let map;
function initMap() {
map = new mapboxgl.Map({
container: 'map-container',
center: [-71.05953, 42.3629],
zoom: 13
});
map.on('load', () => {
console.log('Map is loaded');
});
}
// Initialize when page loads
initMap();
</script>
</body>
</html>
```
**Key points:**
- ⚠️ **Prototyping only** - not recommended for production
- Replace `3.x.x` with specific version (e.g., `3.7.0`) from [Mapbox docs](https://docs.mapbox.com/mapbox-gl-js/)
- **Don't use `/latest/`** - always pin to specific version for consistency
- Initialize after script loads (bottom of body)
- For production: Use npm + bundler instead
**Why not CDN for production?**
- ❌ Network dependency (breaks offline)
- ❌ No version locking (CDN could change)
- ❌ Slower (no bundler optimization)
- ❌ No tree-shaking
- ✅ Use npm for production: `npm install mapbox-gl@^3.0.0`
---
## Advanced Patterns
### Web Components (Framework-Agnostic)
Web Components are a W3C standard for creating reusable custom elements that work in any framework or no framework at all.
**When to use Web Components:**
- ✅ **Vanilla JavaScript apps** - No framework? Web Components are a great choice
- ✅ **Design systems** - Building component libraries used across multiple frameworks
- ✅ **Micro-frontends** - Application uses different frameworks in different parts
- ✅ **Multi-framework organizations** - Teams working with React, Vue, Svelte, etc. need shared components
- ✅ **Framework migration** - Transitioning from one framework to another incrementally
- ✅ **Long-term stability** - W3C standard, no framework lock-in
**Real-world example:** A company with React (main app), Vue (admin panel), and Svelte (marketing site) can build one `<mapbox-map>` component that works everywhere.
**When to use framework-specific patterns instead:**
- 🔧 **Already using a framework** - If you're building in React, use React patterns (simpler, better integration)
- 🔧 **Need framework features** - Deep integration with React hooks, Vue Composition API, state management, routing
- 🔧 **Team familiarity** - Team is proficient with framework patterns
> **💡 Tip:** If you're using React, Vue, Svelte, or Angular, start with the framework-specific patterns above. They're simpler and better integrated. Use Web Components when you need cross-framework compatibility or are building vanilla JavaScript apps.
---
**Pattern: Standard Custom Element with lifecycle callbacks**
Web Components provide a framework-agnostic way to encapsulate Mapbox maps using the W3C Web Components standard.
**Basic Web Component:**
```javascript
import mapboxgl from 'mapbox-gl';
import 'mapbox-gl/dist/mapbox-gl.css';
class MapboxMap extends HTMLElement {
constructor() {
super();
this.map = null;
}
connectedCallback() {
// Get configuration from attributes
const token = this.getAttribute('access-token') || import.meta.env.VITE_MAPBOX_ACCESS_TOKEN;
const mapStyle = this.getAttribute('map-style') || 'mapbox://styles/mapbox/standard';
const center = this.getAttribute('center')?.split(',').map(Number) || [-71.05953, 42.3629];
const zoom = parseFloat(this.getAttribute('zoom')) || 13;
// Initialize map
mapboxgl.accessToken = token;
this.map = new mapboxgl.Map({
container: this,
style: mapStyle,
center: center,
zoom: zoom
});
// Dispatch custom event when map loads
this.map.on('load', () => {
this.dispatchEvent(
new CustomEvent('mapload', {
detail: { map: this.map }
})
);
});
}
// CRITICAL: Clean up when element is removed
disconnectedCallback() {
if (this.map) {
this.map.remove();
this.map = null;
}
}
// Expose map instance to JavaScript
getMap() {
return this.map;
}
}
// Register the custom element
customElements.define('mapbox-map', MapboxMap);
```
**Usage in HTML:**
```html
<!-- Basic usage -->
<mapbox-map
access-token="pk.YOUR_TOKEN"
map-style="mapbox://styles/mapbox/dark-v11"
center="-122.4194,37.7749"
zoom="12"
></mapbox-map>
<style>
mapbox-map {
display: block;
height: 100vh;
width: 100%;
}
</style>
```
**Usage in React:**
```jsx
import './mapbox-map-component'; // Import to register element
function App() {
const mapRef = useRef(null);
useEffect(() => {
const handleMapLoad = (e) => {
const map = e.detail.map;
// Add markers, layers, etc.
new mapboxgl.Marker().setLngLat([-122.4194, 37.7749]).addTo(map);
};
mapRef.current?.addEventListener('mapload', handleMapLoad);
return () => {
mapRef.current?.removeEventListener('mapload', handleMapLoad);
};
}, []);
return (
<mapbox-map
ref={mapRef}
access-token={import.meta.env.VITE_MAPBOX_ACCESS_TOKEN}
map-style="mapbox://styles/mapbox/standard"
center="-122.4194,37.7749"
zoom="12"
/>
);
}
```
**Usage in Vue:**
```vue
<template>
<mapbox-map
ref="map"
:access-token="token"
map-style="mapbox://styles/mapbox/streets-v12"
center="-71.05953,42.3629"
zoom="13"
@mapload="handleMapLoad"
/>
</template>
<script>
import './mapbox-map-component';
export default {
data() {
return {
token: import.meta.env.VITE_MAPBOX_ACCESS_TOKEN
};
},
methods: {
handleMapLoad(event) {
const map = event.detail.map;
// Interact with map
}
}
};
</script>
```
**Usage in Svelte:**
```svelte
<script>
import './mapbox-map-component';
let mapElement;
function handleMapLoad(event) {
const map = event.detail.map;
// Interact with map
}
</script>
<mapbox-map
bind:this={mapElement}
access-token={import.meta.env.VITE_MAPBOX_ACCESS_TOKEN}
map-style="mapbox://styles/mapbox/standard"
center="-71.05953,42.3629"
zoom="13"
on:mapload={handleMapLoad}
/>
```
**Advanced: Reactive Attributes Pattern:**
```javascript
class MapboxMapReactive extends HTMLElement {
static get observedAttributes() {
return ['center', 'zoom', 'map-style'];
}
constructor() {
super();
this.map = null;
}
connectedCallback() {
mapboxgl.accessToken = this.getAttribute('access-token');
this.map = new mapboxgl.Map({
container: this,
style: this.getAttribute('map-style') || 'mapbox://styles/mapbox/standard',
center: this.getAttribute('center')?.split(',').map(Number) || [0, 0],
zoom: parseFloat(this.getAttribute('zoom')) || 9
});
}
disconnectedCallback() {
if (this.map) {
this.map.remove();
this.map = null;
}
}
// React to attribute changes
attributeChangedCallback(name, oldValue, newValue) {
if (!this.map || oldValue === newValue) return;
switch (name) {
case 'center':
const center = newValue.split(',').map(Number);
this.map.setCenter(center);
break;
case 'zoom':
this.map.setZoom(parseFloat(newValue));
break;
case 'map-style':
this.map.setStyle(newValue);
break;
}
}
}
customElements.define('mapbox-map-reactive', MapboxMapReactive);
```
**Key Implementation Points:**
- Use `connectedCallback()` for initialization (equivalent to mount/ngOnInit)
- **Always implement `disconnectedCallback()`** to call `map.remove()` (prevents memory leaks)
- Read configuration from HTML attributes
- Dispatch custom events for map interactions (`mapload`, etc.)
- Use `observedAttributes` + `attributeChangedCallback` for reactive updates
- Works in any framework without modification
---
## Token Management Patterns
### Environment Variables (Recommended)
Different frameworks use different prefixes for client-side environment variables:
| Framework/Bundler | Environment Variable | Access Pattern |
| -------------------- | ------------------------------- | ------------------------------------------ |
| **Vite** | `VITE_MAPBOX_ACCESS_TOKEN` | `import.meta.env.VITE_MAPBOX_ACCESS_TOKEN` |
| **Next.js** | `NEXT_PUBLIC_MAPBOX_TOKEN` | `process.env.NEXT_PUBLIC_MAPBOX_TOKEN` |
| **Create React App** | `REACT_APP_MAPBOX_TOKEN` | `process.env.REACT_APP_MAPBOX_TOKEN` |
| **Angular** | `environment.mapboxAccessToken` | Environment files (`environment.ts`) |
**Vite .env file:**
```bash
VITE_MAPBOX_ACCESS_TOKEN=pk.YOUR_MAPBOX_TOKEN_HERE
```
**Next.js .env.local file:**
```bash
NEXT_PUBLIC_MAPBOX_TOKEN=pk.YOUR_MAPBOX_TOKEN_HERE
```
**Important:**
- ✅ Always use environment variables for tokens
- ✅ Never commit `.env` files to version control
- ✅ Use public tokens (pk.\*) for client-side apps
- ✅ Add `.env` to `.gitignore`
- ✅ Provide `.env.example` template for team
**.gitignore:**
```
.env
.env.local
.env.*.local
```
**.env.example:**
```bash
VITE_MAPBOX_ACCESS_TOKEN=your_token_here
```
---
## Mapbox Search JS Integration
### Search Box Component Pattern
**Install dependency:**
```bash
npm install @mapbox/search-js-react # React
npm install @mapbox/search-js-web # Vanilla/Vue/Svelte
```
**Note:** Both packages include `@mapbox/search-js-core` as a dependency. You only need to install `-core` directly if building a custom search UI.
**React Search Pattern:**
```jsx
import { SearchBox } from '@mapbox/search-js-react';
// Inside component:
<SearchBox
accessToken={accessToken}
map={mapRef.current} // Pass map instance
mapboxgl={mapboxgl} // Pass mapboxgl library
value={inputValue}
onChange={(value) => setInputValue(value)}
proximity={centerCoordinates} // Bias results near center
marker // Show marker for selected result
/>;
```
**Key configuration options:**
- `accessToken`: Your Mapbox public token
- `map`: Map instance (must be initialized first)
- `mapboxgl`: The mapboxgl library reference
- `proximity`: `[lng, lat]` to bias results geographically
- `marker`: Boolean to show/hide result marker
- `placeholder`: Search box placeholder text
### Positioning Search Box
**Absolute positioning (overlay):**
```jsx
<div
style={{
position: 'absolute',
top: 10,
right: 10,
zIndex: 10,
width: 300
}}
>
<SearchBox {...props} />
</div>
```
**Common positions:**
- Top-right: `top: 10px, right: 10px`
- Top-left: `top: 10px, left: 10px`
- Bottom-left: `bottom: 10px, left: 10px`
---
## Common Mistakes to Avoid
### ❌ Mistake 1: Forgetting to call map.remove()
```javascript
// BAD - Memory leak!
useEffect(() => {
const map = new mapboxgl.Map({ ... })
// No cleanup function
}, [])
```
```javascript
// GOOD - Proper cleanup
useEffect(() => {
const map = new mapboxgl.Map({ ... })
return () => map.remove() // ✅ Cleanup
}, [])
```
**Why:** Every Map instance creates WebGL contexts, event listeners, and DOM nodes. Without cleanup, these accumulate and cause memory leaks.
---
### ❌ Mistake 2: Initializing map in render
```javascript
// BAD - Infinite loop in React!
function MapComponent() {
const map = new mapboxgl.Map({ ... }) // Runs on every render
return <div />
}
```
```javascript
// GOOD - Initialize in effect
function MapComponent() {
useEffect(() => {
const map = new mapboxgl.Map({ ... })
}, [])
return <div />
}
```
**Why:** React components re-render frequently. Creating a new map on every render causes infinite loops and crashes.
---
### ❌ Mistake 3: Not storing map instance properly
```javascript
// BAD - map variable lost between renders
function MapComponent() {
useEffect(() => {
let map = new mapboxgl.Map({ ... })
// map variable is not accessible later
}, [])
}
```
```javascript
// GOOD - Store in useRef
function MapComponent() {
const mapRef = useRef()
useEffect(() => {
mapRef.current = new mapboxgl.Map({ ... })
// mapRef.current accessible throughout component
}, [])
}
```
**Why:** You need to access the map instance for operations like adding layers, markers, or calling `remove()`.
---
### ❌ Mistake 4: Wrong dependency array in useEffect
```javascript
// BAD - Re-creates map on every render
useEffect(() => {
const map = new mapboxgl.Map({ ... })
return () => map.remove()
}) // No dependency array
// BAD - Re-creates map when props change
useEffect(() => {
const map = new mapboxgl.Map({ center: props.center, ... })
return () => map.remove()
}, [props.center])
```
```javascript
// GOOD - Initialize once
useEffect(() => {
const map = new mapboxgl.Map({ ... })
return () => map.remove()
}, []) // Empty array = run once
// GOOD - Update map property instead
useEffect(() => {
if (mapRef.current) {
mapRef.current.setCenter(props.center)
}
}, [props.center])
```
**Why:** Map initialization is expensive. Initialize once, then use map methods to update properties.
---
### ❌ Mistake 5: Hardcoding token in source code
```javascript
// BAD - Token exposed in source code
mapboxgl.accessToken = 'pk.YOUR_MAPBOX_TOKEN_HERE';
```
```javascript
// GOOD - Use environment variable
mapboxgl.accessToken = import.meta.env.VITE_MAPBOX_ACCESS_TOKEN;
```
**Why:** Tokens in source code get committed to version control and exposed publicly. Always use environment variables.
---
### ❌ Mistake 6: Not handling Angular SSR
```typescript
// BAD - Crashes during server-side rendering
ngOnInit() {
import('mapbox-gl').then(mapboxgl => {
this.map = new mapboxgl.Map({ ... })
})
}
```
```typescript
// GOOD - Check platform first
ngOnInit() {
if (!isPlatformBrowser(this.platformId)) {
return // Skip map init during SSR
}
import('mapbox-gl').then(mapboxgl => {
this.map = new mapboxgl.Map({ ... })
})
}
```
**Why:** Mapbox GL JS requires browser APIs (WebGL, Canvas). Angular Universal (SSR) will crash without platform check.
---
### ❌ Mistake 7: Missing CSS import
```javascript
// BAD - Map renders but looks broken
import mapboxgl from 'mapbox-gl';
// Missing CSS import
```
```javascript
// GOOD - Import CSS for proper styling
import mapboxgl from 'mapbox-gl';
import 'mapbox-gl/dist/mapbox-gl.css';
```
**Why:** The CSS file contains critical styles for map controls, popups, and markers. Without it, the map appears broken.
---
## Next.js Specific Patterns
### App Router (Recommended)
```typescript
'use client' // Mark as client component
import { useRef, useEffect } from 'react'
import mapboxgl from 'mapbox-gl'
import 'mapbox-gl/dist/mapbox-gl.css'
export default function Map() {
const mapRef = useRef<mapboxgl.Map>()
const mapContainerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!mapContainerRef.current) return
mapboxgl.accessToken = process.env.NEXT_PUBLIC_MAPBOX_TOKEN!
mapRef.current = new mapboxgl.Map({
container: mapContainerRef.current,
center: [-71.05953, 42.36290],
zoom: 13
})
return () => mapRef.current?.remove()
}, [])
return <div ref={mapContainerRef} style={{ height: '100vh' }} />
}
```
**Key points:**
- **Must use `'use client'` directive** (maps require browser APIs)
- Use `process.env.NEXT_PUBLIC_*` for environment variables
- Type `mapRef` properly with TypeScript
### Pages Router (Legacy)
```typescript
import dynamic from 'next/dynamic'
// Dynamically import to disable SSR for map component
const Map = dynamic(() => import('../components/Map'), {
ssr: false,
loading: () => <p>Loading map...</p>
})
export default function HomePage() {
return <Map />
}
```
**Key points:**
- Use `dynamic` import with `ssr: false`
- Provide loading state
- Map component itself follows standard React pattern
---
## Style Configuration
### Default Center and Zoom Guidelines
**Example defaults (used in create-web-app demos):**
- **Center**: `[-71.05953, 42.36290]` (Boston, MA)
- **Zoom**: `13` for city-level view
> **Note:** GL JS defaults to `center: [0, 0]` and `zoom: 0` if not specified. Always set these explicitly.
**Zoom level guide:**
- `0-2`: World view
- `3-5`: Continent/country
- `6-9`: Region/state
- `10-12`: City view
- `13-15`: Neighborhood
- `16-18`: Street level
- `19-22`: Building level
**Customizing for user location:**
```javascript
// Use browser geolocation
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition((position) => {
map.setCenter([position.coords.longitude, position.coords.latitude]);
map.setZoom(13);
});
}
```
---
## Testing Patterns
### Unit Testing Maps
**Mock mapbox-gl:**
```javascript
// vitest.config.js or jest.config.js
export default {
setupFiles: ['./test/setup.js']
};
```
```javascript
// test/setup.js
vi.mock('mapbox-gl', () => ({
default: {
Map: vi.fn(() => ({
on: vi.fn(),
remove: vi.fn(),
setCenter: vi.fn(),
setZoom: vi.fn()
})),
accessToken: ''
}
}));
```
**Why:** Mapbox GL JS requires WebGL and browser APIs that don't exist in test environments. Mock the library to test component logic.
---
## When to Use This Skill
Invoke this skill when:
- Setting up Mapbox GL JS in a new project
- Integrating Mapbox into a specific framework (React, Vue, Svelte, Angular, Next.js)
- Building framework-agnostic Web Components
- Creating reusable map components for component libraries
- Debugging map initialization issues
- Adding Mapbox Search functionality
- Implementing proper cleanup and lifecycle management
- Converting between frameworks (e.g., React to Vue)
- Reviewing code for Mapbox integration best practices
## Related Skills
- **mapbox-cartography**: Map design principles and styling
- **mapbox-token-security**: Token management and security
- **mapbox-style-patterns**: Common map style patterns
## Resources
- [Mapbox GL JS Documentation](https://docs.mapbox.com/mapbox-gl-js/)
- [Mapbox Search JS Documentation](https://docs.mapbox.com/mapbox-search-js/)
- [create-web-app GitHub](https://github.com/mapbox/create-web-app)Performance optimization patterns for Mapbox GL JS web applications. Covers initialization waterfalls, bundle size, rendering performance, memory management, and web optimization. Prioritized by impact on user experience.
# Mapbox Performance Patterns Skill
This skill provides performance optimization guidance for building fast, efficient Mapbox applications. Patterns are prioritized by impact on user experience, starting with the most critical improvements.
**Performance philosophy:** These aren't micro-optimizations. They show up as waiting time, jank, and repeat costs that hit every user session.
## Priority Levels
Performance issues are prioritized by their impact on user experience:
- **🔴 Critical (Fix First)**: Directly causes slow initial load or visible jank
- **🟡 High Impact**: Noticeable delays or increased resource usage
- **🟢 Optimization**: Incremental improvements for polish
---
## 🔴 Critical: Eliminate Initialization Waterfalls
**Problem:** Sequential loading creates cascading delays where each resource waits for the previous one.
**Note:** Modern bundlers (Vite, Webpack, etc.) and ESM dynamic imports automatically handle code splitting and library loading. The primary waterfall to eliminate is **data loading** - fetching map data sequentially instead of in parallel with map initialization.
### Anti-Pattern: Sequential Data Loading
```javascript
// ❌ BAD: Data loads AFTER map initializes
async function initMap() {
const map = new mapboxgl.Map({
container: 'map',
accessToken: MAPBOX_TOKEN,
style: 'mapbox://styles/mapbox/streets-v12'
});
// Wait for map to load, THEN fetch data
map.on('load', async () => {
const data = await fetch('/api/data'); // Waterfall!
map.addSource('data', { type: 'geojson', data: await data.json() });
});
}
```
**Timeline:** Map init (0.5s) → Data fetch (1s) = **1.5s total**
### Solution: Parallel Data Loading
```javascript
// ✅ GOOD: Data fetch starts immediately
async function initMap() {
// Start data fetch immediately (don't wait for map)
const dataPromise = fetch('/api/data').then((r) => r.json());
const map = new mapboxgl.Map({
container: 'map',
accessToken: MAPBOX_TOKEN,
style: 'mapbox://styles/mapbox/streets-v12'
});
// Data is ready when map loads
map.on('load', async () => {
const data = await dataPromise;
map.addSource('data', { type: 'geojson', data });
map.addLayer({
id: 'data-layer',
type: 'circle',
source: 'data'
});
});
}
```
**Timeline:** Max(map init, data fetch) = **~1s total**
### Set Precise Initial Viewport
```javascript
// ✅ Set exact center/zoom so the map fetches the right tiles immediately
const map = new mapboxgl.Map({
container: 'map',
style: 'mapbox://styles/mapbox/streets-v12',
center: [-122.4194, 37.7749],
zoom: 13
});
// Use 'idle' to know when the initial viewport is fully rendered
// (all tiles, sprites, and other resources are loaded; no transitions in progress)
map.once('idle', () => {
console.log('Initial viewport fully rendered');
});
```
If you know the exact area users will see first, setting `center` and `zoom` upfront avoids the map starting at a default view and then panning/zooming to the target, which wastes tile fetches.
### Defer Non-Critical Features
```javascript
// ✅ Load critical features first, defer others
const map = new mapboxgl.Map({
/* config */
});
map.on('load', () => {
// 1. Add critical layers immediately
addCriticalLayers(map);
// 2. Defer secondary features
// Note: Standard style 3D buildings can be toggled via config:
// map.setConfigProperty('basemap', 'show3dObjects', false);
requestIdleCallback(
() => {
addTerrain(map);
addCustom3DLayers(map); // For classic styles with custom fill-extrusion layers
},
{ timeout: 2000 }
);
// 3. Defer analytics and non-visual features
setTimeout(() => {
initializeAnalytics(map);
}, 3000);
});
```
**Impact:** Significant reduction in time-to-interactive, especially when deferring terrain and 3D layers
---
## 🔴 Critical: Optimize Initial Bundle Size
**Problem:** Large bundles delay time-to-interactive on slow networks.
**Note:** Modern bundlers (Vite, Webpack, etc.) automatically handle code splitting for framework-based applications. The guidance below is most relevant for optimizing what gets bundled and when.
### Style JSON Bundle Impact
```javascript
// ❌ BAD: Inline massive style JSON (can be 500+ KB)
const style = {
version: 8,
sources: {
/* 100s of lines */
},
layers: [
/* 100s of layers */
]
};
// ✅ GOOD: Reference Mapbox-hosted styles
const map = new mapboxgl.Map({
style: 'mapbox://styles/mapbox/streets-v12' // Fetched on demand
});
// ✅ OR: Store large custom styles externally
const map = new mapboxgl.Map({
style: '/styles/custom-style.json' // Loaded separately
});
```
**Impact:** Reduces initial bundle by 30-50% when moving from inlined to hosted styles
---
## 🟡 High Impact: Optimize Marker Count
**Problem:** Too many markers causes slow rendering and interaction lag.
### Performance Thresholds
- **< 100 markers**: HTML markers OK (Marker class)
- **100-10,000 markers**: Use symbol layers (GPU-accelerated)
- **10,000+ markers**: Clustering recommended
- **100,000+ markers**: Vector tiles with server-side clustering
### Anti-Pattern: Thousands of HTML Markers
```javascript
// ❌ BAD: 5,000 HTML markers = 5+ second render, janky pan/zoom
restaurants.forEach((restaurant) => {
const marker = new mapboxgl.Marker()
.setLngLat([restaurant.lng, restaurant.lat])
.setPopup(new mapboxgl.Popup().setHTML(restaurant.name))
.addTo(map);
});
```
**Result:** 5,000 DOM elements, slow interactions, high memory
### Solution: Use Symbol Layers (GeoJSON)
```javascript
// ✅ GOOD: GPU-accelerated rendering, smooth at 10,000+ features
map.addSource('restaurants', {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: restaurants.map((r) => ({
type: 'Feature',
geometry: { type: 'Point', coordinates: [r.lng, r.lat] },
properties: { name: r.name, type: r.type }
}))
}
});
map.addLayer({
id: 'restaurants',
type: 'symbol',
source: 'restaurants',
layout: {
'icon-image': 'restaurant',
'icon-size': 0.8,
'text-field': ['get', 'name'],
'text-size': 12,
'text-offset': [0, 1.5],
'text-anchor': 'top'
}
});
// Click handler (one listener for all features)
map.on('click', 'restaurants', (e) => {
const feature = e.features[0];
new mapboxgl.Popup().setLngLat(feature.geometry.coordinates).setHTML(feature.properties.name).addTo(map);
});
```
**Performance:** 10,000 features render in <100ms
### Solution: Clustering for High Density
```javascript
// ✅ GOOD: 50,000 markers → ~500 clusters at low zoom
map.addSource('restaurants', {
type: 'geojson',
data: restaurantsGeoJSON,
cluster: true,
clusterMaxZoom: 14, // Stop clustering at zoom 15
clusterRadius: 50 // Radius relative to tile dimensions (512 = full tile width)
});
// Cluster circle layer
map.addLayer({
id: 'clusters',
type: 'circle',
source: 'restaurants',
filter: ['has', 'point_count'],
paint: {
'circle-color': ['step', ['get', 'point_count'], '#51bbd6', 100, '#f1f075', 750, '#f28cb1'],
'circle-radius': ['step', ['get', 'point_count'], 20, 100, 30, 750, 40]
}
});
// Cluster count label
map.addLayer({
id: 'cluster-count',
type: 'symbol',
source: 'restaurants',
filter: ['has', 'point_count'],
layout: {
'text-field': '{point_count_abbreviated}',
'text-size': 12
}
});
// Individual point layer
map.addLayer({
id: 'unclustered-point',
type: 'circle',
source: 'restaurants',
filter: ['!', ['has', 'point_count']],
paint: {
'circle-color': '#11b4da',
'circle-radius': 6
}
});
```
**Impact:** 50,000 markers at 60 FPS with smooth interaction
---
## 🟡 High Impact: Optimize Data Loading Strategy
**Problem:** Loading all data upfront wastes bandwidth and slows initial render.
### GeoJSON vs Vector Tiles Decision Matrix
| Scenario | Use GeoJSON | Use Vector Tiles |
| ------------------------- | ----------- | ---------------- |
| < 5 MB data | Yes | No |
| 5-20 MB data | Consider | Yes |
| > 20 MB data | No | Yes |
| Data changes frequently | Yes | No |
| Static data, global scale | No | Yes |
| Need server-side updates | No | Yes |
### Viewport-Based Loading (GeoJSON)
**Note:** This pattern is applicable when hosting GeoJSON data locally or on external servers. Mapbox-hosted data sources are already optimized for viewport-based loading.
```javascript
// ✅ Only load data in current viewport
async function loadVisibleData(map) {
const bounds = map.getBounds();
const bbox = [bounds.getWest(), bounds.getSouth(), bounds.getEast(), bounds.getNorth()].join(',');
const data = await fetch(`/api/data?bbox=${bbox}&zoom=${map.getZoom()}`);
map.getSource('data').setData(await data.json());
}
// Update on viewport change (with debounce)
let timeout;
map.on('moveend', () => {
clearTimeout(timeout);
timeout = setTimeout(() => loadVisibleData(map), 300);
});
```
**Important:** `setData()` triggers a full re-parse of the GeoJSON in a web worker. For small datasets updated frequently, consider using `source.updateData()` (requires `dynamic: true` on the source) for partial updates. For large datasets, switch to vector tiles.
### Progressive Data Loading
**Note:** This pattern is applicable when hosting GeoJSON data locally or on external servers.
```javascript
// ✅ Load basic data first, add details progressively
async function loadDataProgressive(map) {
// 1. Load simplified data first (low-res)
const simplified = await fetch('/api/data?detail=low');
map.addSource('data', {
type: 'geojson',
data: await simplified.json()
});
addLayers(map);
// 2. Load full detail in background
const detailed = await fetch('/api/data?detail=high');
map.getSource('data').setData(await detailed.json());
}
```
### Vector Tiles for Large Datasets
**Note:** The `minzoom`/`maxzoom` optimization shown below is primarily for self-hosted vector tilesets. Mapbox-hosted tilesets have built-in optimization via [Mapbox Tiling Service (MTS)](https://docs.mapbox.com/mapbox-tiling-service/guides/) recipes that handle zoom-level optimizations automatically.
```javascript
// ✅ Server generates tiles, client loads only visible area (self-hosted tilesets)
map.addSource('large-dataset', {
type: 'vector',
tiles: ['https://api.example.com/tiles/{z}/{x}/{y}.pbf'],
minzoom: 0,
maxzoom: 14
});
map.addLayer({
id: 'large-dataset-layer',
type: 'fill',
source: 'large-dataset',
'source-layer': 'data', // Layer name in .pbf
paint: {
'fill-color': '#088',
'fill-opacity': 0.6
}
});
```
**Impact:** 10 MB dataset reduced to ~500 KB per viewport load
---
## 🟡 High Impact: Optimize Map Interactions
**Problem:** Unthrottled event handlers cause performance degradation.
### Anti-Pattern: Expensive Operations on Every Event
```javascript
// ❌ BAD: Runs ~60 times per second during pan (once per render frame)
map.on('move', () => {
updateVisibleFeatures(); // Expensive query
fetchDataFromAPI(); // Network request
updateUI(); // DOM manipulation
});
```
### Solution: Debounce/Throttle Events
```javascript
// ✅ GOOD: Throttle during interaction, finalize on idle
let throttleTimeout;
// Lightweight updates during move (throttled)
map.on('move', () => {
if (throttleTimeout) return;
throttleTimeout = setTimeout(() => {
updateMapCenter(); // Cheap update
throttleTimeout = null;
}, 100);
});
// Expensive operations after interaction stops
map.on('moveend', () => {
updateVisibleFeatures();
fetchDataFromAPI();
updateUI();
});
```
### Optimize Feature Queries
```javascript
// ❌ BAD: Query all features (expensive with many layers)
map.on('click', (e) => {
const features = map.queryRenderedFeatures(e.point);
console.log(features); // Could be 100+ features from all layers
});
// ✅ GOOD: Query specific layers only
map.on('click', (e) => {
const features = map.queryRenderedFeatures(e.point, {
layers: ['restaurants', 'shops'] // Only query these layers
});
if (features.length > 0) {
showPopup(features[0]);
}
});
// ✅ For touch targets or fuzzy clicks: Use a bounding box
map.on('click', (e) => {
const bbox = [
[e.point.x - 5, e.point.y - 5],
[e.point.x + 5, e.point.y + 5]
];
const features = map.queryRenderedFeatures(bbox, {
layers: ['restaurants'],
filter: ['==', ['get', 'type'], 'pizza'] // Further narrow results
});
});
```
### Batch DOM Updates
```javascript
// ❌ BAD: Update DOM for every feature
map.on('mousemove', 'restaurants', (e) => {
e.features.forEach((feature) => {
document.getElementById(feature.id).classList.add('highlight');
});
});
// ✅ GOOD: Batch updates with requestAnimationFrame
let pendingUpdates = new Set();
let rafScheduled = false;
map.on('mousemove', 'restaurants', (e) => {
e.features.forEach((f) => pendingUpdates.add(f.id));
if (!rafScheduled) {
rafScheduled = true;
requestAnimationFrame(() => {
pendingUpdates.forEach((id) => {
document.getElementById(id).classList.add('highlight');
});
pendingUpdates.clear();
rafScheduled = false;
});
}
});
```
**Impact:** 60 FPS maintained during interaction vs 15-20 FPS without optimization
---
## 🟡 High Impact: Memory Management
**Problem:** Memory leaks cause browser tabs to become unresponsive over time. In SPAs that create/destroy map instances, this is a common production issue.
### Always Clean Up Map Resources
```javascript
// ✅ Essential cleanup pattern
function cleanupMap(map) {
if (!map) return;
// 1. Remove event listeners
map.off('load', handleLoad);
map.off('move', handleMove);
// 2. Remove layers (if adding/removing dynamically)
if (map.getLayer('dynamic-layer')) {
map.removeLayer('dynamic-layer');
}
// 3. Remove sources (if adding/removing dynamically)
if (map.getSource('dynamic-source')) {
map.removeSource('dynamic-source');
}
// 4. Remove controls
map.removeControl(navigationControl);
// 5. CRITICAL: Remove map instance
map.remove();
}
// React example
useEffect(() => {
const map = new mapboxgl.Map({
/* config */
});
return () => {
cleanupMap(map); // Called on unmount
};
}, []);
```
### Clean Up Popups and Markers
```javascript
// ❌ BAD: Creates new popup on every click (memory leak)
map.on('click', 'restaurants', (e) => {
new mapboxgl.Popup().setLngLat(e.lngLat).setHTML(e.features[0].properties.name).addTo(map);
// Popup never removed!
});
// ✅ GOOD: Reuse single popup instance
let popup = new mapboxgl.Popup({ closeOnClick: true });
map.on('click', 'restaurants', (e) => {
popup.setLngLat(e.lngLat).setHTML(e.features[0].properties.name).addTo(map);
// Previous popup content replaced, no leak
});
// Cleanup
function cleanup() {
popup.remove();
popup = null;
}
```
### Use Feature State Instead of New Layers
```javascript
// ❌ BAD: Create new layer for hover (memory overhead, causes re-render)
let hoveredFeatureId = null;
map.on('mousemove', 'restaurants', (e) => {
if (map.getLayer('hover-layer')) {
map.removeLayer('hover-layer');
}
map.addLayer({
id: 'hover-layer',
type: 'circle',
source: 'restaurants',
filter: ['==', ['id'], e.features[0].id],
paint: { 'circle-color': 'yellow' }
});
});
// ✅ GOOD: Use feature state (efficient, no layer creation)
map.on('mousemove', 'restaurants', (e) => {
if (e.features.length > 0) {
// Remove previous hover state
if (hoveredFeatureId !== null) {
map.setFeatureState({ source: 'restaurants', id: hoveredFeatureId }, { hover: false });
}
// Set new hover state
hoveredFeatureId = e.features[0].id;
map.setFeatureState({ source: 'restaurants', id: hoveredFeatureId }, { hover: true });
}
});
// Style uses feature state
map.addLayer({
id: 'restaurants',
type: 'circle',
source: 'restaurants',
paint: {
'circle-color': [
'case',
['boolean', ['feature-state', 'hover'], false],
'#ffff00', // Yellow when hover
'#0000ff' // Blue otherwise
]
}
});
```
**Note:** Feature state requires features to have IDs. Use `generateId: true` on the GeoJSON source to auto-assign IDs, or use `promoteId` to use an existing property as the feature ID.
**Impact:** Prevents memory growth from continuous layer churn over long sessions
---
## 🟢 Optimization: Mobile Performance
**Problem:** Mobile devices have limited resources (CPU, GPU, memory, battery).
### Mobile-Specific Optimizations
```javascript
// Detect mobile device
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
const map = new mapboxgl.Map({
container: 'map',
style: 'mapbox://styles/mapbox/streets-v12',
// Mobile optimizations
...(isMobile && {
// Limit max zoom to reduce tile fetching at extreme zoom levels
maxZoom: 18,
// fadeDuration controls symbol collision fade animation only
// Reducing it makes label transitions snappier
fadeDuration: 0
})
});
// Load simpler layers on mobile
map.on('load', () => {
if (isMobile) {
// Circle layers are cheaper than symbol layers (no collision detection,
// no texture atlas, no text shaping)
map.addLayer({
id: 'markers-mobile',
type: 'circle',
source: 'data',
paint: {
'circle-radius': 8,
'circle-color': '#007cbf'
}
});
} else {
// Rich desktop rendering with icons and labels
map.addLayer({
id: 'markers-desktop',
type: 'symbol',
source: 'data',
layout: {
'icon-image': 'marker',
'icon-size': 1,
'text-field': ['get', 'name'],
'text-size': 12,
'text-offset': [0, 1.5]
}
});
}
});
```
### Touch Interaction Optimization
```javascript
// ✅ Simplify touch gestures
map.touchZoomRotate.disableRotation(); // Disable rotation (simpler gestures, fewer accidental rotations)
// Debounce expensive operations during touch
let touchTimeout;
map.on('touchmove', () => {
if (touchTimeout) clearTimeout(touchTimeout);
touchTimeout = setTimeout(() => {
updateVisibleData();
}, 500); // Wait for touch to settle
});
```
### Performance-Sensitive Constructor Options
```javascript
// These options have real GPU/performance costs -- only enable when needed
const map = new mapboxgl.Map({
container: 'map',
style: 'mapbox://styles/mapbox/streets-v12',
// Default false -- only set true if you need map.getCanvas().toDataURL()
// Costs: prevents GPU buffer optimization
preserveDrawingBuffer: false,
// Default false -- only set true if you need smooth diagonal lines
// Costs: enables MSAA which increases GPU memory and fill cost
antialias: false
});
```
---
## 🟢 Optimization: Layer and Style Performance
### Consolidate Layers
```javascript
// ❌ BAD: 20 separate layers for restaurant types
restaurantTypes.forEach((type) => {
map.addLayer({
id: `restaurants-${type}`,
type: 'symbol',
source: 'restaurants',
filter: ['==', ['get', 'type'], type],
layout: { 'icon-image': `${type}-icon` }
});
});
// ✅ GOOD: Single layer with data-driven styling
map.addLayer({
id: 'restaurants',
type: 'symbol',
source: 'restaurants',
layout: {
'icon-image': [
'match',
['get', 'type'],
'pizza',
'pizza-icon',
'burger',
'burger-icon',
'sushi',
'sushi-icon',
'default-icon' // fallback
]
}
});
```
**Impact:** Fewer layers means less rendering overhead. Each layer has fixed per-layer cost regardless of feature count.
### Simplify Expressions for Large Datasets
For datasets with 100,000+ features, simpler expressions reduce per-feature evaluation cost. For smaller datasets, the expression engine is fast enough that this won't be noticeable.
```javascript
// Zoom-dependent paint properties MUST use step or interpolate, not comparisons
// ❌ WRONG: Cannot use comparison operators on ['zoom'] in paint properties
// paint: { 'fill-extrusion-height': ['case', ['>', ['zoom'], 16], ...] }
// ✅ CORRECT: Use step for discrete zoom breakpoints
map.addLayer({
id: 'buildings',
type: 'fill-extrusion',
source: 'buildings',
paint: {
'fill-extrusion-color': ['interpolate', ['linear'], ['get', 'height'], 0, '#dedede', 50, '#a0a0a0', 100, '#606060'],
'fill-extrusion-height': [
'step',
['zoom'],
['get', 'height'], // Default: use raw height
16,
['*', ['get', 'height'], 1.5] // At zoom 16+: scale up
]
}
});
```
For very large GeoJSON datasets, pre-computing static property derivations (like color categories) into the source data can reduce per-feature expression work:
```javascript
// ✅ Pre-compute STATIC derivations for large datasets (100K+ features)
const buildingsWithColor = {
type: 'FeatureCollection',
features: buildings.features.map((f) => ({
...f,
properties: {
...f.properties,
heightColor: getColorForHeight(f.properties.height) // Pre-computed once
}
}))
};
map.addSource('buildings', { type: 'geojson', data: buildingsWithColor });
map.addLayer({
id: 'buildings',
type: 'fill-extrusion',
source: 'buildings',
paint: {
'fill-extrusion-color': ['get', 'heightColor'], // Simple property lookup
'fill-extrusion-height': ['get', 'height']
}
});
```
### Use Zoom-Based Layer Visibility
```javascript
// ✅ Only render layers at appropriate zoom levels
map.addLayer({
id: 'building-details',
type: 'fill',
source: 'buildings',
minzoom: 15, // Render at zoom 15 and above
paint: { 'fill-color': '#aaa' }
});
map.addLayer({
id: 'poi-labels',
type: 'symbol',
source: 'pois',
minzoom: 12, // Hide at low zoom levels where labels would overlap heavily
layout: {
'text-field': ['get', 'name'],
visibility: 'visible'
}
});
```
**Note:** `minzoom` is inclusive (layer visible at that zoom), `maxzoom` is exclusive (layer hidden at that zoom). A layer with `maxzoom: 16` is visible up to but not including zoom 16.
**Impact:** Reduces GPU work at zoom levels where layers aren't useful
---
## Summary: Performance Checklist
When building a Mapbox application, verify these optimizations in order:
### 🔴 Critical (Do First)
- [ ] Load map library and data in parallel (eliminate waterfalls)
- [ ] Use dynamic imports for map code (reduce initial bundle)
- [ ] Defer non-critical features (terrain, custom 3D layers, analytics)
- [ ] Use symbol layers for > 100 markers (not HTML markers)
- [ ] Implement viewport-based data loading for large datasets
### 🟡 High Impact
- [ ] Debounce/throttle map event handlers
- [ ] Optimize queryRenderedFeatures with layers filter and bounding box
- [ ] Use GeoJSON for < 5 MB, vector tiles for > 20 MB
- [ ] Always call map.remove() on cleanup in SPAs
- [ ] Reuse popup instances (don't create on every interaction)
- [ ] Use feature state instead of dynamic layers for hover/selection
### 🟢 Optimization
- [ ] Consolidate multiple layers with data-driven styling
- [ ] Add mobile-specific optimizations (circle layers, disabled rotation)
- [ ] Set minzoom/maxzoom on layers to avoid rendering at irrelevant zoom levels
- [ ] Avoid enabling preserveDrawingBuffer or antialias unless needed
### Measurement
```javascript
// Measure initial load time
console.time('map-load');
map.on('load', () => {
console.timeEnd('map-load');
// isStyleLoaded() returns true when style, sources, tiles, sprites, and models are all loaded
console.log('Style loaded:', map.isStyleLoaded());
});
// Monitor frame rate
let frameCount = 0;
map.on('render', () => frameCount++);
setInterval(() => {
console.log('FPS:', frameCount);
frameCount = 0;
}, 1000);
// Check memory usage (Chrome DevTools -> Performance -> Memory)
```
**Target metrics:**
- **Time to Interactive:** < 2 seconds on 3G
- **Frame Rate:** 60 FPS during pan/zoom
- **Memory Growth:** < 10 MB per hour of usage
- **Bundle Size:** < 500 KB initial (map lazy-loaded)