mapbox-data-visualization-patterns by mapbox/mapbox-agent-skills
npx skills add https://github.com/mapbox/mapbox-agent-skills --skill mapbox-data-visualization-patterns在 Mapbox 地图上可视化数据的综合模式。涵盖分级统计图、热力图、3D 挤出、数据驱动样式、动画可视化以及处理数据密集型应用的性能优化。
在以下情况下使用此技能:
最适合: 区域数据(州、县、邮政编码)、统计比较
模式: 根据数据值为多边形着色
map.on('load', () => {
// 添加数据源(包含属性的 GeoJSON)
map.addSource('states', {
type: 'geojson',
data: 'https://example.com/states.geojson' // 包含人口属性的要素
});
// 添加具有数据驱动颜色的填充图层
map.addLayer({
id: 'states-layer',
type: 'fill',
source: 'states',
paint: {
'fill-color': [
'interpolate',
['linear'],
['get', 'population'],
0,
'#f0f9ff', // 低人口:浅蓝色
500000,
'#7fcdff',
1000000,
'#0080ff',
5000000,
'#0040bf', // 高人口:深蓝色
10000000,
'#001f5c'
],
'fill-opacity': 0.75
}
});
// 添加边框图层
map.addLayer({
id: 'states-border',
type: 'line',
source: 'states',
paint: {
'line-color': '#ffffff',
'line-width': 1
}
});
// 添加带有可重用弹出窗口的悬停效果
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();
});
});
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
色标策略:
// 线性插值(连续比例尺)
'fill-color': [
'interpolate',
['linear'],
['get', 'value'],
0, '#ffffcc',
25, '#78c679',
50, '#31a354',
100, '#006837'
]
// 阶梯间隔(离散区间)
'fill-color': [
'step',
['get', 'value'],
'#ffffcc', // 默认颜色
25, '#c7e9b4',
50, '#7fcdbb',
75, '#41b6c4',
100, '#2c7fb8'
]
// 基于条件(分类数据)
'fill-color': [
'match',
['get', 'category'],
'residential', '#ffd700',
'commercial', '#ff6b6b',
'industrial', '#4ecdc4',
'park', '#45b7d1',
'#cccccc' // 默认
]
最适合: 点密度、事件位置、事件聚类
模式: 可视化点的密度
map.on('load', () => {
// 添加数据源(点)
map.addSource('incidents', {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [-122.4194, 37.7749]
},
properties: {
intensity: 1
}
}
// ... 更多点
]
}
});
// 添加热力图图层
map.addLayer({
id: 'incidents-heat',
type: 'heatmap',
source: 'incidents',
maxzoom: 15,
paint: {
// 根据强度属性增加权重
'heatmap-weight': ['interpolate', ['linear'], ['get', 'intensity'], 0, 0, 6, 1],
// 随着缩放级别增加而增加强度
'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],
// 在较高缩放级别降低不透明度
'heatmap-opacity': ['interpolate', ['linear'], ['zoom'], 7, 1, 15, 0]
}
});
// 在高缩放级别为单个点添加圆形图层
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
}
});
});
最适合: 分组附近的点、聚合计数、大型点数据集
模式: 用于可视化的客户端聚类
聚类是与热力图并列的一种有价值的点密度可视化技术。当您想要具有精确计数的离散分组,而不是连续的密度可视化时,请使用聚类。
map.on('load', () => {
// 添加启用聚类的数据源
map.addSource('locations', {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: [
// 您的点要素
]
},
cluster: true,
clusterMaxZoom: 14, // 聚类点的最大缩放级别
clusterRadius: 50 // 每个聚类的半径(默认 50)
});
// 聚类圆圈 - 按点数样式化
map.addLayer({
id: 'clusters',
type: 'circle',
source: 'locations',
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]
}
});
// 聚类计数标签
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
}
});
// 单个未聚类点
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'
}
});
// 点击处理程序以展开聚类
map.on('click', 'clusters', (e) => {
const features = map.queryRenderedFeatures(e.point, {
layers: ['clusters']
});
const clusterId = features[0].properties.cluster_id;
// 获取聚类展开的缩放级别
map.getSource('locations').getClusterExpansionZoom(clusterId, (err, zoom) => {
if (err) return;
map.easeTo({
center: features[0].geometry.coordinates,
zoom: zoom
});
});
});
// 悬停时更改光标
map.on('mouseenter', 'clusters', () => {
map.getCanvas().style.cursor = 'pointer';
});
map.on('mouseleave', 'clusters', () => {
map.getCanvas().style.cursor = '';
});
});
高级:自定义聚类属性
map.addSource('locations', {
type: 'geojson',
data: data,
cluster: true,
clusterMaxZoom: 14,
clusterRadius: 50,
// 计算自定义聚类属性
clusterProperties: {
// 总和值
sum: ['+', ['get', 'value']],
// 计算最大值
max: ['max', ['get', 'value']]
}
});
// 在样式中使用自定义属性
'circle-color': [
'interpolate',
['linear'],
['get', 'sum'],
0,
'#51bbd6',
100,
'#f1f075',
1000,
'#f28cb1'
];
何时使用聚类与热力图:
| 使用场景 | 聚类 | 热力图 |
|---|---|---|
| 视觉风格 | 带有计数的离散圆圈 | 连续渐变 |
| 交互 | 点击展开/缩放 | 仅视觉密度 |
| 数据粒度 | 可见的精确计数 | 近似密度 |
| 最适合 | 商店定位器、活动列表 | 犯罪地图、事件区域 |
| 处理大量点时的性能 | 优秀(自动分组) | 良好 |
| 用户理解 | 清晰(带编号的聚类) | 直观(热度类比) |
最适合: 建筑高度、高程数据、体积表示
模式: 根据数据挤出多边形
注意: 下面的示例仅适用于经典样式(
streets-v12、dark-v11、light-v11等)。Mapbox Standard 样式默认包含细节更丰富的 3D 建筑。
map.on('load', () => {
// 将图层插入到任何符号图层下方以进行正确排序
const layers = map.getStyle().layers;
const labelLayerId = layers.find((layer) => layer.type === 'symbol' && layer.layout['text-field']).id;
// 从底图添加 3D 建筑
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
);
// 启用俯仰角和方位角以查看 3D 视图
map.setPitch(45);
map.setBearing(-17.6);
});
使用自定义数据源:
map.on('load', () => {
// 添加您自己的建筑数据
map.addSource('custom-buildings', {
type: 'geojson',
data: 'https://example.com/buildings.geojson'
});
// 添加 3D 建筑图层
map.addLayer({
id: '3d-custom-buildings',
type: 'fill-extrusion',
source: 'custom-buildings',
paint: {
// 高度(米)
'fill-extrusion-height': ['get', 'height'],
// 建筑在地形上的基础高度
'fill-extrusion-base': ['get', 'base_height'],
// 按建筑类型或高度着色
'fill-extrusion-color': [
'interpolate',
['linear'],
['get', 'height'],
0,
'#fafa6e',
50,
'#eca25b',
100,
'#e64a45',
200,
'#a63e3e'
],
'fill-extrusion-opacity': 0.9
}
});
});
数据驱动的 3D 高度:
// 人口密度可视化
'fill-extrusion-height': [
'interpolate',
['linear'],
['get', 'density'],
0, 0,
1000, 500, // 1000 人/平方英里 = 500 米高度
10000, 5000
]
// 收入可视化(为可见性缩放)
'fill-extrusion-height': [
'*',
['get', 'revenue'],
0.001 // 缩放因子
]
最适合: 具有量级的点数据、比例符号
模式: 根据数据值调整圆圈大小
map.on('load', () => {
map.addSource('earthquakes', {
type: 'geojson',
data: 'https://example.com/earthquakes.geojson'
});
// 按震级确定大小,按深度确定颜色
map.addLayer({
id: 'earthquakes',
type: 'circle',
source: 'earthquakes',
paint: {
// 按震级确定圆圈大小
'circle-radius': ['interpolate', ['exponential', 2], ['get', 'mag'], 0, 2, 5, 20, 8, 100],
// 按深度确定颜色
'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
}
});
// 点击时添加弹出窗口
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);
});
});
最适合: 路线、流量、连接、网络
模式: 根据数据设置线条样式
map.on('load', () => {
map.addSource('traffic', {
type: 'geojson',
data: 'https://example.com/traffic.geojson'
});
// 具有数据驱动样式的交通流量
map.addLayer({
id: 'traffic-lines',
type: 'line',
source: 'traffic',
paint: {
// 按交通量确定宽度
'line-width': ['interpolate', ['exponential', 2], ['get', 'volume'], 0, 1, 1000, 5, 10000, 15],
// 按速度着色(拥堵情况)
'line-color': [
'interpolate',
['linear'],
['get', 'speed'],
0,
'#d73027', // 红色:停止
15,
'#fc8d59', // 橙色:缓慢
30,
'#fee08b', // 黄色:中等
45,
'#d9ef8b', // 浅绿色:良好
60,
'#91cf60', // 绿色:畅通
75,
'#1a9850'
],
'line-opacity': 0.8
}
});
});
模式: 随时间推移为数据制作动画
let currentTime = 0;
const times = [0, 6, 12, 18, 24]; // 一天中的小时
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']
}
});
// 动画循环
function animate() {
currentTime = (currentTime + 1) % times.length;
// 更新数据
map.getSource('hourly-data').setData(getDataForTime(times[currentTime]));
// 更新 UI
document.getElementById('time-display').textContent = `${times[currentTime]}:00`;
animationId = setTimeout(animate, 1000); // 每秒更新一次
}
// 开始动画
document.getElementById('play-button').addEventListener('click', () => {
if (animationId) {
clearTimeout(animationId);
animationId = null;
} else {
animate();
}
});
});
function getDataForTime(hour) {
// 获取或生成特定时间的数据
return {
type: 'FeatureCollection',
features: data.filter((d) => d.properties.hour === hour)
};
}
模式: 从实时源更新数据
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'
}
});
// 每 5 秒轮询更新
setInterval(async () => {
const response = await fetch('https://api.example.com/live-data');
const data = await response.json();
// 更新源
map.getSource('live-data').setData(data);
}, 5000);
// 或使用 WebSocket 进行实时更新
const ws = new WebSocket('wss://api.example.com/live');
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
map.getSource('live-data').setData(data);
};
});
模式: 为属性变化制作动画
// 平滑过渡圆圈大小
function updateVisualization(newData) {
map.getSource('data-source').setData(newData);
// 为圆圈半径制作动画
const currentRadius = map.getPaintProperty('data-layer', 'circle-radius');
const targetRadius = ['get', 'newSize'];
// 使用 setPaintProperty 并带有过渡效果
map.setPaintProperty('data-layer', 'circle-radius', targetRadius);
// 或使用表达式进行平滑插值
map.setPaintProperty('data-layer', 'circle-radius', ['interpolate', ['linear'], ['get', 'value'], 0, 2, 100, 20]);
}
何时使用每种格式:
| 数据大小 | 格式 | 原因 |
|---|---|---|
| < 5 MB | GeoJSON | 简单,无需处理 |
| 5-20 MB | GeoJSON 或矢量瓦片 | 考虑数据更新频率 |
| > 20 MB | 矢量瓦片 | 更好的性能,渐进式加载 |
矢量瓦片模式:
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', // 瓦片集中的图层名称
paint: {
'fill-color': ['get', 'color'],
'fill-opacity': 0.7
}
});
模式: 更新样式而无需修改几何图形
map.on('load', () => {
map.addSource('states', {
type: 'geojson',
data: statesData,
generateId: true // 对要素状态很重要
});
map.addLayer({
id: 'states',
type: 'fill',
source: 'states',
paint: {
'fill-color': [
'case',
['boolean', ['feature-state', 'hover'], false],
'#ff0000', // 悬停颜色
'#3b9ddd' // 默认颜色
]
}
});
let hoveredStateId = null;
// 悬停时更新要素状态
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;
});
});
模式: 在客户端过滤数据以提高性能
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], // 仅显示值 >= 50 的数据
paint: {
'circle-radius': 6,
'circle-color': '#ff4444'
}
});
// 动态更新过滤器
function updateFilter(minValue) {
map.setFilter('filtered-data', ['>=', ['get', 'value'], minValue]);
}
// 用于动态过滤的滑块
document.getElementById('filter-slider').addEventListener('input', (e) => {
updateFilter(parseFloat(e.target.value));
});
});
模式: 根据需要分块加载数据
// 检查要素是否在边界内的辅助函数
function isFeatureInBounds(feature, bounds) {
const coords = feature.geometry.coordinates;
// 处理不同的几何类型
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
});
// 在防抖后重新加载地图移动
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);
});
<div class="legend">
<h4>人口密度</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>
map.on('click', 'data-layer', (e) => {
const feature = e.features[0];
const properties = feature.properties;
// 构建属性表
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>要素详情</h3>
<table style="width: 100%; font-size: 12px;">
${propsTable}
</table>
</div>
`
)
.addTo(map);
});
// 使用 ColorBrewer 色标确保可访问性
// https://colorbrewer2.org/
// 良好:顺序(单色调)
const sequentialScale = ['#f0f9ff', '#bae4ff', '#7fcdff', '#0080ff', '#001f5c'];
// 良好:发散(双色调)
const divergingScale = ['#d73027', '#fc8d59', '#fee08b', '#d9ef8b', '#91cf60', '#1a9850'];
// 良好:定性(不同类别)
const qualitativeScale = ['#e41a1c', '#377eb8', '#4daf4a', '#984ea3', '#ff7f00'];
// 避免:红绿色(色盲可访问性差)
// 使用:蓝橙色或紫绿色替代
// 为分级统计图计算统计断点
// 使用 classybrew 库 (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();
}
// 为更好的可视化归一化数据
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;
// 处理所有值相同的情况
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
}
}));
}
// 处理缺失或无效数据
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'], // 检查属性是否存在
['interpolate', ['linear'], ['get', 'value'], 0, '#f0f0f0', 100, '#0080ff'],
'#cccccc' // 缺失数据的默认颜色
]
}
});
// 处理地图错误
map.on('error', (e) => {
console.error('地图错误:', e.error);
});
});
map.addLayer({
id: 'election-results',
type: 'fill',
source: 'districts',
paint: {
'fill-color': [
'match',
['get', 'winner'],
'democrat',
'#3b82f6',
'republican',
'#ef4444',
'independent',
'#a855f7',
'#94a3b8' // 无数据
],
'fill-opacity': [
'interpolate',
['linear'],
['get', 'margin'],
0,
0.3, // 势均力敌:浅色
20,
0.9 // 压倒性胜利:深色
]
}
});
map.addLayer({
id: 'covid-cases',
type: 'fill',
source: 'counties',
paint: {
'fill-color': [
'step',
['/', ['get', 'cases'], ['get', 'population']], // 人均病例数
'#ffffb2',
0.001,
'#fed976',
0.005,
'#feb24c',
0.01,
'#fd8d3c',
0.02,
'#fc4e2a',
0.05,
'#e31a1c',
0.1,
'#b10026'
]
}
});
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
}
});
每周安装次数
240
仓库
GitHub 星标数
35
首次出现
2026年2月19日
安全审计
安装于
github-copilot229
opencode228
gemini-cli227
codex227
amp225
kimi-cli225
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.
Use this skill when:
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();
});
});
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
]
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 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) |
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
]
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);
});
});
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
}
});
});
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)
};
}
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);
};
});
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]);
}
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
}
});
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;
});
});
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));
});
});
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);
});
<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>
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);
});
// 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
// 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
}
}));
}
// 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);
});
});
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
]
}
});
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'
]
}
});
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
}
});
Weekly Installs
240
Repository
GitHub Stars
35
First Seen
Feb 19, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
github-copilot229
opencode228
gemini-cli227
codex227
amp225
kimi-cli225
GSAP 框架集成指南:Vue、Svelte 等框架中 GSAP 动画最佳实践
1,700 周安装