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 ( `
${ feature . properties . name }
Population: ${ feature . properties . population . toLocaleString ( ) }
)
.
addTo
(
map
)
;
}
}
)
;
map
.
on
(
'mouseleave'
,
'states-layer'
,
(
)
=>
{
map
.
getCanvas
(
)
.
style
.
cursor
=
''
;
popup
.
remove
(
)
;
}
)
;
}
)
;
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
}
}
)
;
}
)
;
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)
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
]
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
(
Magnitude ${ props . mag }
Depth: ${ props . depth } km
Time: ${ new Date ( props . time ) . toLocaleString ( ) }
)
.
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
}
}
)
;
}
)
;
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
]
)
;
}
Performance Optimization
Vector Tiles vs GeoJSON
When to use each:
Data Size
Format
Reason
< 1 MB
GeoJSON
Simple, no processing needed
1-10 MB
GeoJSON or Vector Tiles
Consider data update frequency
10 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 ) ; } ) ; Legends and UI Controls 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 : 30 px ; right : 10 px ; background : white ; padding : 10 px ; border-radius : 3 px ; box-shadow : 0 1 px 2 px rgba ( 0 , 0 , 0 , 0.1 ) ; font-family : Arial , sans-serif ; font-size : 12 px ; } .legend h4 { margin : 0 0 10 px 0 ; font-size : 14 px ; } .legend-item { display : flex ; align-items : center ; margin-bottom : 5 px ; } .legend-color { width : 20 px ; height : 20 px ; margin-right : 10 px ; border : 1 px 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 ] ) => `
)
.
join
(
''
)
;
new
mapboxgl
.
Popup
(
)
.
setLngLat
(
e
.
lngLat
)
.
setHTML
(
Feature Details
` ) . addTo ( map ) ; } ) ; 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 instead 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 } } ) ) ; } Error 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 source errors map . on ( 'error' , ( e ) => { if ( e . source === 'data' ) { console . error ( 'Failed to load data:' , e ) ; showNotification ( 'Unable to load visualization data' ) ; } } ) ; } ) ; 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 } } ) ; Resources Mapbox Expression Reference ColorBrewer - Color scales for maps Turf.js - Spatial analysis Simple Statistics - Data classification Data Visualization Tutorials