
Mapbox Data Visualization Patterns
- 1.5k installs
- 71 repo stars
- Updated August 4, 2026
- mapbox/mapbox-agent-skills
mapbox-data-visualization-patterns is an agent skill for patterns for visualizing data on maps including choropleth maps, heat maps, 3d visualizations, data-driven styling, and animated data. covers layer types, color sc
About
The mapbox-data-visualization-patterns skill is designed for 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. 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. Invoke when the user asks about mapbox data visualization patterns or related SKILL.md workflows.
- 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.
Mapbox Data Visualization Patterns by the numbers
- 1,451 all-time installs (skills.sh)
- +59 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #277 of 1,880 Design & UI/UX skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
mapbox-data-visualization-patterns capabilities & compatibility
- Capabilities
- visualizing statistical data on maps (population · creating choropleth maps with color coded region · building heat maps or clustering for density vis · adding 3d visualizations (building heights, terr
- Use cases
- frontend
What mapbox-data-visualization-patterns says it does
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 perform
Patterns for visualizing data on maps including choropleth maps, heat maps, 3D visualizations, data-driven styling, and animated data. Covers layer types, color
npx skills add https://github.com/mapbox/mapbox-agent-skills --skill mapbox-data-visualization-patternsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1.5k |
|---|---|
| repo stars | ★ 71 |
| Security audit | 2 / 3 scanners passed |
| Last updated | August 4, 2026 |
| Repository | mapbox/mapbox-agent-skills ↗ |
How do I 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?
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.
Who is it for?
Developers using mapbox data visualization patterns workflows documented in SKILL.md.
Skip if: Skip when the task falls outside mapbox-data-visualization-patterns scope or needs a different stack.
When should I use this skill?
User asks about mapbox data visualization patterns or related SKILL.md workflows.
What you get
Completed mapbox-data-visualization-patterns workflow with documented commands, files, and expected deliverables.
- layer type selection
- Mapbox layer configuration patterns
- expression recommendations
By the numbers
- Covers 3 primary Mapbox layer types: fill, heatmap, and circle
- Maps 4 data shape categories: regional polygons, point density, clustering, and point magnitude
Files
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
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();
});
});`step` vs `interpolate`: The example above usesinterpolatefor smooth color gradients. For discrete color buckets (e.g., "low / medium / high"), use['step', ['get', 'population'], '#f0f0f0', 500000, '#fee0d2', 2000000, '#fc9272', 10000000, '#de2d26']instead. Preferstepwhen data has natural categories or when exact boundary values matter.
Color Scale Strategies:
// 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
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
}
});
});Best Practices
Color Accessibility
// 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 insteadError Handling
// 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);
});
});Data Size Rule
- < 1 MB: Use GeoJSON directly
- 1–10 MB: Consider either GeoJSON or vector tiles depending on complexity
- > 10 MB: Use vector tiles (upload to Mapbox as tileset)
See references/performance.md for implementation details.
Reference Files
For additional visualization patterns, load the relevant reference file:
- [references/clustering.md](references/clustering.md) — Point clustering, custom cluster properties, clustering vs heatmap comparison
- [references/3d-extrusions.md](references/3d-extrusions.md) — 3D building extrusions, custom data sources, data-driven heights
- [references/circles-lines.md](references/circles-lines.md) — Circle/bubble maps, line data visualization, traffic flow styling
- [references/animation.md](references/animation.md) — Time-series animation, real-time data updates, smooth transitions
- [references/performance.md](references/performance.md) — Vector tiles vs GeoJSON, feature state, filtering, progressive loading
- [references/legends-use-cases.md](references/legends-use-cases.md) — Legend UI, data inspector, data preprocessing, election/COVID/real-estate examples
Resources
- Mapbox Expression Reference
- ColorBrewer - Color scales for maps
- Turf.js - Spatial analysis
- Simple Statistics - Data classification
- Data Visualization Tutorials
Data Visualization Patterns
Quick reference for visualizing data on Mapbox maps.
Visualization Type Decision Matrix
| Data Type | Visualization | Layer Type | Use For |
|---|---|---|---|
| Regional/Polygons | Choropleth | fill | Statistics, demographics, elections |
| Point Density | Heat Map | heatmap | Crime, events, incident clustering |
| Point Density | Clustering | circle | Grouped markers, aggregated counts |
| Point Magnitude | Bubble/Circle | circle | Earthquakes, sales, metrics |
| 3D Data | Extrusions | fill-extrusion | Buildings, elevation, volume |
| Flow/Network | Lines | line | Traffic, routes, connections |
Data Structure
All code snippets below use Style expressions to style features based on their property data. Expressions like ['get', 'value'] access properties from your GeoJSON features:
// Example GeoJSON feature
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [-77.0323, 38.9131] // [longitude, latitude]
},
"properties": {
"magnitude": 7.8, // Custom data property
"value": 42, // Another property
"category": "coffee" // Can be any data type
}
}Accessing properties:
['get', 'magnitude']; // Returns 7.8
['get', 'value']; // Returns 42
['get', 'category']; // Returns "coffee"Choropleth Maps
Pattern: Color-code regions by data values
map.addLayer({
id: 'choropleth',
type: 'fill',
source: 'regions',
paint: {
'fill-color': [
'interpolate',
['linear'],
['get', 'value'],
0,
'#f0f9ff', // Low
50,
'#7fcdff',
100,
'#0080ff' // High
],
'fill-opacity': 0.75
}
});Color Scale Types:
<!-- prettier-ignore -->
// Linear (continuous)
['interpolate', ['linear'], ['get', 'value'], 0, '#fff', 100, '#000']
// Steps (discrete buckets)
['step', ['get', 'value'], '#fff', 25, '#ccc', 50, '#888', 75, '#000']
// Categories (qualitative)
['match', ['get', 'category'], 'A', '#ff0000', 'B', '#0000ff', '#cccccc']Heat Maps
Pattern: Show point density
map.addLayer({
id: 'heatmap',
type: 'heatmap',
source: 'points',
paint: {
'heatmap-weight': ['get', 'intensity'],
'heatmap-intensity': ['interpolate', ['linear'], ['zoom'], 0, 1, 15, 3],
'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)'
],
'heatmap-radius': ['interpolate', ['linear'], ['zoom'], 0, 2, 15, 20]
}
});
// Show individual points at high zoom
map.addLayer({
id: 'points',
type: 'circle',
source: 'points',
minzoom: 14,
paint: {
'circle-radius': 6,
'circle-color': '#ff4444'
}
});Clustering (Point Density)
Pattern: Group nearby points with aggregated counts
// Add source with clustering enabled
map.addSource('points', {
type: 'geojson',
data: data,
cluster: true,
clusterMaxZoom: 14, // Max zoom to cluster points on
clusterRadius: 50 // Radius of each cluster when clustering points (default 50)
});
// Clusters - sized by point count
map.addLayer({
id: 'clusters',
type: 'circle',
source: 'points',
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: 'points',
filter: ['has', 'point_count'],
layout: {
'text-field': ['get', '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: 'points',
filter: ['!', ['has', 'point_count']],
paint: {
'circle-color': '#11b4da',
'circle-radius': 6,
'circle-stroke-width': 1,
'circle-stroke-color': '#fff'
}
});
// Click to expand clusters
map.on('click', 'clusters', (e) => {
const features = map.queryRenderedFeatures(e.point, { layers: ['clusters'] });
const clusterId = features[0].properties.cluster_id;
map.getSource('points').getClusterExpansionZoom(clusterId, (err, zoom) => {
if (err) return;
map.easeTo({ center: features[0].geometry.coordinates, zoom: zoom });
});
});When to use clustering vs heatmaps:
- Clustering: Discrete grouping, exact counts, click to expand
- Heatmaps: Continuous density visualization, smoother appearance
Bubble Maps
Pattern: Size circles by magnitude
map.addLayer({
id: 'bubbles',
type: 'circle',
source: 'data',
paint: {
'circle-radius': ['interpolate', ['exponential', 2], ['get', 'magnitude'], 0, 2, 5, 20, 10, 100],
'circle-color': ['interpolate', ['linear'], ['get', 'magnitude'], 0, '#ffffcc', 50, '#78c679', 100, '#006837'],
'circle-opacity': 0.7,
'circle-stroke-color': '#fff',
'circle-stroke-width': 1
}
});3D Extrusions
Pattern: Extrude polygons by height
Note: This example 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.
// Add 3D buildings from basemap
map.on('load', () => {
// Insert the layer beneath any symbol layer
const layers = map.getStyle().layers;
const labelLayerId = layers.find((layer) => layer.type === 'symbol' && layer.layout['text-field']).id;
map.addLayer(
{
id: 'add-3d-buildings',
source: 'composite',
'source-layer': 'building',
filter: ['==', 'extrude', 'true'],
type: 'fill-extrusion',
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
}
},
labelLayerId
);
// Enable 3D view
map.setPitch(45);
map.setBearing(-17.6);
});Data-driven 3D (custom data):
// For your own data source
map.addLayer({
id: '3d-data',
type: 'fill-extrusion',
source: 'your-data',
paint: {
'fill-extrusion-height': ['get', 'height'],
'fill-extrusion-base': ['get', 'base_height'],
'fill-extrusion-color': [
'interpolate',
['linear'],
['get', 'height'],
0,
'#fafa6e',
100,
'#e64a45',
200,
'#a63e3e'
],
'fill-extrusion-opacity': 0.9
}
});Line Visualization
Pattern: Style lines by data
map.addLayer({
id: 'traffic',
type: 'line',
source: 'roads',
paint: {
'line-width': ['interpolate', ['exponential', 2], ['get', 'volume'], 0, 1, 10000, 15],
'line-color': [
'interpolate',
['linear'],
['get', 'speed'],
0,
'#d73027', // Stopped
30,
'#fee08b', // Moderate
60,
'#1a9850' // Free flow
]
}
});Animated Data
Time-Series:
let currentTime = 0;
function animate() {
currentTime++;
map.getSource('data').setData(getDataForTime(currentTime));
requestAnimationFrame(animate);
}Real-Time Updates:
setInterval(async () => {
const data = await fetch('/api/live-data').then((r) => r.json());
map.getSource('live').setData(data);
}, 5000);Performance
Data Size Guidelines:
| Size | Format | Strategy |
|---|---|---|
| < 5 MB | GeoJSON | Direct load |
| 5-20 MB | GeoJSON | Consider vector tiles |
| > 20 MB | Vector Tiles | Required |
Vector Tiles:
map.addSource('large-data', {
type: 'vector',
tiles: ['https://example.com/{z}/{x}/{y}.mvt']
});
map.addLayer({
id: 'data',
type: 'fill',
source: 'large-data',
'source-layer': 'layer-name'
});Feature State (Dynamic Styling):
// GeoJSON source with generateId
map.addSource('data', {
type: 'geojson',
data: data,
generateId: true // Required for feature state
});
// Update state (GeoJSON source)
map.setFeatureState({ source: 'data', id: featureId }, { hover: true });
// Vector tile source - requires sourceLayer
map.addSource('vector-data', {
type: 'vector',
tiles: ['https://example.com/{z}/{x}/{y}.mvt']
});
// Update state (vector source)
map.setFeatureState({ source: 'vector-data', id: featureId, sourceLayer: 'my-source-layer' }, { hover: true });
// Use in paint property
'fill-color': [
'case',
['boolean', ['feature-state', 'hover'], false],
'#ff0000',
'#0000ff'
]Client-Side Filtering:
// Filter without reloading data
map.setFilter('layer-id', ['>=', ['get', 'value'], threshold]);Progressive Loading:
map.on('moveend', () => {
const bounds = map.getBounds();
const visible = allData.features.filter((f) => bounds.contains(f.geometry.coordinates));
map.getSource('data').setData({ type: 'FeatureCollection', features: visible });
});Color Scales
Accessible Colors (ColorBrewer):
// Sequential (single hue)
const sequential = ['#f0f9ff', '#bae4ff', '#7fcdff', '#0080ff', '#001f5c'];
// Diverging (two hues)
const diverging = ['#d73027', '#fc8d59', '#fee08b', '#d9ef8b', '#91cf60', '#1a9850'];
// Qualitative (distinct categories)
const qualitative = ['#e41a1c', '#377eb8', '#4daf4a', '#984ea3', '#ff7f00'];Legend Component
<div class="legend">
<h4>Population Density</h4>
<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: #0080ff;"></span>
<span>1000+</span>
</div>
</div>Common Use Cases
Election Results:
'fill-color': [
'match',
['get', 'winner'],
'democrat', '#3b82f6',
'republican', '#ef4444',
'#94a3b8'
]COVID Cases:
'fill-color': [
'step',
['/', ['get', 'cases'], ['get', 'population']],
'#ffffb2',
0.001, '#fed976',
0.01, '#fc4e2a',
0.1, '#b10026'
]Real Estate:
'circle-radius': [
'interpolate',
['exponential', 2],
['get', 'price'],
100000, 5,
1000000, 20
],
'circle-color': [
'interpolate',
['linear'],
['get', 'price_per_sqft'],
0, '#ffffcc',
400, '#41b6c4',
800, '#253494'
]Quick Decisions
Need to show regional statistics? → Use choropleth with fill layer
Need to show point density? → Use heatmap layer (continuous) or clustering (discrete groups)
Need to show point magnitude? → Use circle layer with data-driven radius
Need 3D visualization? → Use fill-extrusion layer
Need to animate over time? → Use setData() with time-based filtering
Large dataset (> 20 MB)? → Use vector tiles instead of GeoJSON
Need dynamic hover effects? → Use feature state instead of updating data
Color-blind friendly? → Use blue-orange or purple-green, avoid red-green
Expression Patterns
Safe Property Access:
['case', ['has', 'property'], ['get', 'property'], defaultValue];Calculations:
<!-- prettier-ignore -->
// Divide
['/', ['get', 'numerator'], ['get', 'denominator']]
// Multiply
['*', ['get', 'value'], 1.5]
// Percentage
['*', ['/', ['get', 'part'], ['get', 'total']], 100]Resources
- Mapbox Expression Reference
- ColorBrewer - Accessible color scales
- Turf.js - Spatial analysis
{
"skill_name": "mapbox-data-visualization-patterns",
"evals": [
{
"id": 1,
"prompt": "I'm building a choropleth map showing US population by state. States range from ~500K to ~40M people. What are my options for coloring states by population in Mapbox GL JS? I'm considering both smooth gradients and discrete color buckets.",
"expectations": [
"Uses the interpolate expression with linear interpolation: ['interpolate', ['linear'], ['get', 'population'], ...]",
"Provides multiple stop values mapping population numbers to color values (at least 3-4 stops)",
"Shows the complete addLayer pattern with type 'fill' and the interpolate expression in fill-color paint property",
"Mentions that step expression is available as an alternative for discrete buckets, or explains when interpolate vs step is appropriate"
]
},
{
"id": 2,
"prompt": "I want to highlight individual countries on hover on my choropleth map — change the color when the mouse enters a feature and reset when it leaves. I don't want to re-upload GeoJSON on every hover event. What's the efficient pattern?",
"expectations": [
"Use feature state: set generateId: true in the GeoJSON source definition",
"Call map.setFeatureState({ source: 'countries', id: hoveredId }, { hover: true }) on mousemove",
"Reset with map.setFeatureState({ source: 'countries', id: hoveredId }, { hover: false }) on mouseleave",
"Reference feature state in the layer paint with ['feature-state', 'hover'] expression (not re-uploading data)"
]
},
{
"id": 3,
"prompt": "I have datasets of varying sizes I need to visualize on Mapbox maps — some are small (500KB), some medium (5MB), and one is huge (50MB of GPS records). The 50MB one freezes when loaded as GeoJSON. What's the right approach for each size?",
"expectations": [
"50MB is well above the GeoJSON threshold — use vector tiles (MVT) instead",
"The rule: <1MB use GeoJSON, 1-10MB consider either, >10MB use vector tiles",
"Explains the source type change: type: 'vector' with a tiles URL, and requires source-layer in the addLayer call",
"Vector tiles support progressive loading — only tiles in the current viewport are fetched, not all 50MB at once"
]
}
]
}
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.
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:
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:
// 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
]Animated Data Visualizations
Time-Series Animation
Pattern: Animate data over time
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
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
// 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]);
}Circle/Bubble Maps and Line Data Visualization
Circle/Bubble Maps
Best for: Point data with magnitude, proportional symbols
Pattern: Size circles based on data values
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
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
}
});
});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.
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
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) |
Legends, UI Controls, and Common Use Cases
Color Scale Legend
<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
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);
});Data Preprocessing
// 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
}
}));
}Common Use Cases
Election Results Map
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
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
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
}
});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:
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
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
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
// 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);
});Related skills
How it compares
Use mapbox-data-visualization-patterns over generic mapping tutorials when you need Mapbox-specific layer and expression choices tied to data geometry types.
FAQ
What does mapbox-data-visualization-patterns do?
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.
When should I use mapbox-data-visualization-patterns?
User asks about mapbox data visualization patterns or related SKILL.md workflows.
Is mapbox-data-visualization-patterns safe to install?
Review the Security Audits panel on this page before installing in production.