mapbox-data-visualization-patterns
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseData Visualization Patterns Skill
数据可视化模式技能(Mapbox地图适用)
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.
本文档介绍了Mapbox地图上数据可视化的各类完整模式,包括等值区域图、热力图、3D拉伸效果、数据驱动样式、动态可视化,以及针对大数据量应用的性能优化方案。
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
在以下场景使用该技能:
- 在地图上展示统计数据(人口、销售额、人口统计信息)
- 创建带有颜色编码区域的等值区域图
- 构建热力图或聚类效果以展示密度数据
- 添加3D可视化效果(建筑高度、地形海拔)
- 基于属性实现数据驱动样式
- 为时间序列数据添加动画效果
- 处理需要优化的大型数据集
Visualization Types
可视化类型
Choropleth Maps
等值区域图
Best for: Regional data (states, counties, zip codes), statistical comparisons
Pattern: Color-code polygons based on data values
javascript
map.on('load', () => {
// Add data source (GeoJSON with properties)
map.addSource('states', {
type: 'geojson',
data: 'https://example.com/states.geojson' // Features with population property
});
// Add fill layer with data-driven color
map.addLayer({
id: 'states-layer',
type: 'fill',
source: 'states',
paint: {
'fill-color': [
'interpolate',
['linear'],
['get', 'population'],
0,
'#f0f9ff', // Light blue for low population
500000,
'#7fcdff',
1000000,
'#0080ff',
5000000,
'#0040bf', // Dark blue for high population
10000000,
'#001f5c'
],
'fill-opacity': 0.75
}
});
// Add border layer
map.addLayer({
id: 'states-border',
type: 'line',
source: 'states',
paint: {
'line-color': '#ffffff',
'line-width': 1
}
});
// Add hover effect with reusable popup
const popup = new mapboxgl.Popup({
closeButton: false,
closeOnClick: false
});
map.on('mousemove', 'states-layer', (e) => {
if (e.features.length > 0) {
map.getCanvas().style.cursor = 'pointer';
const feature = e.features[0];
popup
.setLngLat(e.lngLat)
.setHTML(
`
<h3>${feature.properties.name}</h3>
<p>Population: ${feature.properties.population.toLocaleString()}</p>
`
)
.addTo(map);
}
});
map.on('mouseleave', 'states-layer', () => {
map.getCanvas().style.cursor = '';
popup.remove();
});
});Color Scale Strategies:
javascript
// Linear interpolation (continuous scale)
'fill-color': [
'interpolate',
['linear'],
['get', 'value'],
0, '#ffffcc',
25, '#78c679',
50, '#31a354',
100, '#006837'
]
// Step intervals (discrete buckets)
'fill-color': [
'step',
['get', 'value'],
'#ffffcc', // Default color
25, '#c7e9b4',
50, '#7fcdbb',
75, '#41b6c4',
100, '#2c7fb8'
]
// Case-based (categorical data)
'fill-color': [
'match',
['get', 'category'],
'residential', '#ffd700',
'commercial', '#ff6b6b',
'industrial', '#4ecdc4',
'park', '#45b7d1',
'#cccccc' // Default
]适用场景: 区域数据(州、县、邮政编码区)、统计对比
实现模式: 根据数据值为多边形赋予颜色
javascript
map.on('load', () => {
// Add data source (GeoJSON with properties)
map.addSource('states', {
type: 'geojson',
data: 'https://example.com/states.geojson' // Features with population property
});
// Add fill layer with data-driven color
map.addLayer({
id: 'states-layer',
type: 'fill',
source: 'states',
paint: {
'fill-color': [
'interpolate',
['linear'],
['get', 'population'],
0,
'#f0f9ff', // Light blue for low population
500000,
'#7fcdff',
1000000,
'#0080ff',
5000000,
'#0040bf', // Dark blue for high population
10000000,
'#001f5c'
],
'fill-opacity': 0.75
}
});
// Add border layer
map.addLayer({
id: 'states-border',
type: 'line',
source: 'states',
paint: {
'line-color': '#ffffff',
'line-width': 1
}
});
// Add hover effect with reusable popup
const popup = new mapboxgl.Popup({
closeButton: false,
closeOnClick: false
});
map.on('mousemove', 'states-layer', (e) => {
if (e.features.length > 0) {
map.getCanvas().style.cursor = 'pointer';
const feature = e.features[0];
popup
.setLngLat(e.lngLat)
.setHTML(
`
<h3>${feature.properties.name}</h3>
<p>Population: ${feature.properties.population.toLocaleString()}</p>
`
)
.addTo(map);
}
});
map.on('mouseleave', 'states-layer', () => {
map.getCanvas().style.cursor = '';
popup.remove();
});
});颜色比例策略:
javascript
// Linear interpolation (continuous scale)
'fill-color': [
'interpolate',
['linear'],
['get', 'value'],
0, '#ffffcc',
25, '#78c679',
50, '#31a354',
100, '#006837'
]
// Step intervals (discrete buckets)
'fill-color': [
'step',
['get', 'value'],
'#ffffcc', // Default color
25, '#c7e9b4',
50, '#7fcdbb',
75, '#41b6c4',
100, '#2c7fb8'
]
// Case-based (categorical data)
'fill-color': [
'match',
['get', 'category'],
'residential', '#ffd700',
'commercial', '#ff6b6b',
'industrial', '#4ecdc4',
'park', '#45b7d1',
'#cccccc' // Default
]Heat Maps
热力图
Best for: Point density, event locations, incident clustering
Pattern: Visualize density of points
javascript
map.on('load', () => {
// Add data source (points)
map.addSource('incidents', {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [-122.4194, 37.7749]
},
properties: {
intensity: 1
}
}
// ... more points
]
}
});
// Add heatmap layer
map.addLayer({
id: 'incidents-heat',
type: 'heatmap',
source: 'incidents',
maxzoom: 15,
paint: {
// Increase weight based on intensity property
'heatmap-weight': ['interpolate', ['linear'], ['get', 'intensity'], 0, 0, 6, 1],
// Increase intensity as zoom level increases
'heatmap-intensity': ['interpolate', ['linear'], ['zoom'], 0, 1, 15, 3],
// Color ramp for heatmap
'heatmap-color': [
'interpolate',
['linear'],
['heatmap-density'],
0,
'rgba(33,102,172,0)',
0.2,
'rgb(103,169,207)',
0.4,
'rgb(209,229,240)',
0.6,
'rgb(253,219,199)',
0.8,
'rgb(239,138,98)',
1,
'rgb(178,24,43)'
],
// Adjust radius by zoom level
'heatmap-radius': ['interpolate', ['linear'], ['zoom'], 0, 2, 15, 20],
// Decrease opacity at higher zoom levels
'heatmap-opacity': ['interpolate', ['linear'], ['zoom'], 7, 1, 15, 0]
}
});
// Add circle layer for individual points at high zoom
map.addLayer({
id: 'incidents-point',
type: 'circle',
source: 'incidents',
minzoom: 14,
paint: {
'circle-radius': ['interpolate', ['linear'], ['zoom'], 14, 4, 22, 30],
'circle-color': '#ff4444',
'circle-opacity': 0.8,
'circle-stroke-color': '#fff',
'circle-stroke-width': 1
}
});
});适用场景: 点密度、事件位置、事件聚类
实现模式: 展示点数据的密度
javascript
map.on('load', () => {
// Add data source (points)
map.addSource('incidents', {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [-122.4194, 37.7749]
},
properties: {
intensity: 1
}
}
// ... more points
]
}
});
// Add heatmap layer
map.addLayer({
id: 'incidents-heat',
type: 'heatmap',
source: 'incidents',
maxzoom: 15,
paint: {
// Increase weight based on intensity property
'heatmap-weight': ['interpolate', ['linear'], ['get', 'intensity'], 0, 0, 6, 1],
// Increase intensity as zoom level increases
'heatmap-intensity': ['interpolate', ['linear'], ['zoom'], 0, 1, 15, 3],
// Color ramp for heatmap
'heatmap-color': [
'interpolate',
['linear'],
['heatmap-density'],
0,
'rgba(33,102,172,0)',
0.2,
'rgb(103,169,207)',
0.4,
'rgb(209,229,240)',
0.6,
'rgb(253,219,199)',
0.8,
'rgb(239,138,98)',
1,
'rgb(178,24,43)'
],
// Adjust radius by zoom level
'heatmap-radius': ['interpolate', ['linear'], ['zoom'], 0, 2, 15, 20],
// Decrease opacity at higher zoom levels
'heatmap-opacity': ['interpolate', ['linear'], ['zoom'], 7, 1, 15, 0]
}
});
// Add circle layer for individual points at high zoom
map.addLayer({
id: 'incidents-point',
type: 'circle',
source: 'incidents',
minzoom: 14,
paint: {
'circle-radius': ['interpolate', ['linear'], ['zoom'], 14, 4, 22, 30],
'circle-color': '#ff4444',
'circle-opacity': 0.8,
'circle-stroke-color': '#fff',
'circle-stroke-width': 1
}
});
});Clustering (Point Density)
聚类效果(点密度)
Best for: Grouping nearby points, aggregated counts, large point datasets
Pattern: Client-side clustering for visualization
Clustering is a valuable point density visualization technique alongside heat maps. Use clustering when you want discrete grouping with exact counts rather than a continuous density visualization.
javascript
map.on('load', () => {
// Add data source with clustering enabled
map.addSource('locations', {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: [
// Your point features
]
},
cluster: true,
clusterMaxZoom: 14, // Max zoom to cluster points
clusterRadius: 50 // Radius of each cluster (default 50)
});
// Clustered circles - styled by point count
map.addLayer({
id: 'clusters',
type: 'circle',
source: 'locations',
filter: ['has', 'point_count'],
paint: {
// Color clusters by count (step expression)
'circle-color': ['step', ['get', 'point_count'], '#51bbd6', 10, '#f1f075', 30, '#f28cb1'],
// Size clusters by count
'circle-radius': ['step', ['get', 'point_count'], 20, 10, 30, 30, 40]
}
});
// Cluster count labels
map.addLayer({
id: 'cluster-count',
type: 'symbol',
source: 'locations',
filter: ['has', 'point_count'],
layout: {
'text-field': ['get', 'point_count_abbreviated'],
'text-font': ['DIN Offc Pro Medium', 'Arial Unicode MS Bold'],
'text-size': 12
}
});
// Individual unclustered points
map.addLayer({
id: 'unclustered-point',
type: 'circle',
source: 'locations',
filter: ['!', ['has', 'point_count']],
paint: {
'circle-color': '#11b4da',
'circle-radius': 6,
'circle-stroke-width': 1,
'circle-stroke-color': '#fff'
}
});
// Click handler to expand clusters
map.on('click', 'clusters', (e) => {
const features = map.queryRenderedFeatures(e.point, {
layers: ['clusters']
});
const clusterId = features[0].properties.cluster_id;
// Get cluster expansion zoom
map.getSource('locations').getClusterExpansionZoom(clusterId, (err, zoom) => {
if (err) return;
map.easeTo({
center: features[0].geometry.coordinates,
zoom: zoom
});
});
});
// Change cursor on hover
map.on('mouseenter', 'clusters', () => {
map.getCanvas().style.cursor = 'pointer';
});
map.on('mouseleave', 'clusters', () => {
map.getCanvas().style.cursor = '';
});
});Advanced: Custom Cluster Properties
javascript
map.addSource('locations', {
type: 'geojson',
data: data,
cluster: true,
clusterMaxZoom: 14,
clusterRadius: 50,
// Calculate custom cluster properties
clusterProperties: {
// Sum total values
sum: ['+', ['get', 'value']],
// Calculate max value
max: ['max', ['get', 'value']]
}
});
// Use custom properties in styling
'circle-color': [
'interpolate',
['linear'],
['get', 'sum'],
0,
'#51bbd6',
100,
'#f1f075',
1000,
'#f28cb1'
];When to use clustering vs heatmaps:
| Use Case | Clustering | Heatmap |
|---|---|---|
| Visual style | Discrete circles with counts | Continuous gradient |
| Interaction | Click to expand/zoom | Visual density only |
| Data granularity | Exact counts visible | Approximate density |
| Best for | Store locators, event listings | Crime maps, incident areas |
| Performance with many points | Excellent (groups automatically) | Good |
| User understanding | Clear (numbered clusters) | Intuitive (heat analogy) |
适用场景: 邻近点分组、聚合计数、大型点数据集
实现模式: 客户端聚类可视化
聚类是与热力图互补的点密度可视化技术。当你需要带有精确计数的离散分组而非连续密度可视化时,使用聚类效果。
javascript
map.on('load', () => {
// Add data source with clustering enabled
map.addSource('locations', {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: [
// Your point features
]
},
cluster: true,
clusterMaxZoom: 14, // Max zoom to cluster points
clusterRadius: 50 // Radius of each cluster (default 50)
});
// Clustered circles - styled by point count
map.addLayer({
id: 'clusters',
type: 'circle',
source: 'locations',
filter: ['has', 'point_count'],
paint: {
// Color clusters by count (step expression)
'circle-color': ['step', ['get', 'point_count'], '#51bbd6', 10, '#f1f075', 30, '#f28cb1'],
// Size clusters by count
'circle-radius': ['step', ['get', 'point_count'], 20, 10, 30, 30, 40]
}
});
// Cluster count labels
map.addLayer({
id: 'cluster-count',
type: 'symbol',
source: 'locations',
filter: ['has', 'point_count'],
layout: {
'text-field': ['get', 'point_count_abbreviated'],
'text-font': ['DIN Offc Pro Medium', 'Arial Unicode MS Bold'],
'text-size': 12
}
});
// Individual unclustered points
map.addLayer({
id: 'unclustered-point',
type: 'circle',
source: 'locations',
filter: ['!', ['has', 'point_count']],
paint: {
'circle-color': '#11b4da',
'circle-radius': 6,
'circle-stroke-width': 1,
'circle-stroke-color': '#fff'
}
});
// Click handler to expand clusters
map.on('click', 'clusters', (e) => {
const features = map.queryRenderedFeatures(e.point, {
layers: ['clusters']
});
const clusterId = features[0].properties.cluster_id;
// Get cluster expansion zoom
map.getSource('locations').getClusterExpansionZoom(clusterId, (err, zoom) => {
if (err) return;
map.easeTo({
center: features[0].geometry.coordinates,
zoom: zoom
});
});
});
// Change cursor on hover
map.on('mouseenter', 'clusters', () => {
map.getCanvas().style.cursor = 'pointer';
});
map.on('mouseleave', 'clusters', () => {
map.getCanvas().style.cursor = '';
});
});进阶:自定义聚类属性
javascript
map.addSource('locations', {
type: 'geojson',
data: data,
cluster: true,
clusterMaxZoom: 14,
clusterRadius: 50,
// Calculate custom cluster properties
clusterProperties: {
// Sum total values
sum: ['+', ['get', 'value']],
// Calculate max value
max: ['max', ['get', 'value']]
}
});
// Use custom properties in styling
'circle-color': [
'interpolate',
['linear'],
['get', 'sum'],
0,
'#51bbd6',
100,
'#f1f075',
1000,
'#f28cb1'
];聚类 vs 热力图的适用场景对比:
| 适用场景 | 聚类效果 | 热力图 |
|---|---|---|
| 视觉风格 | 带计数的离散圆形 | 连续渐变效果 |
| 交互性 | 可点击展开/缩放 | 仅展示密度信息 |
| 数据粒度 | 可查看精确计数 | 近似密度值 |
| 最佳适用场景 | 门店定位、活动列表 | 犯罪地图、事件密集区域 |
| 大数据量性能 | 优秀(自动分组) | 良好 |
| 用户理解成本 | 清晰(带编号的聚类) | 直观(热力类比) |
3D Extrusions
3D拉伸效果
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, etc.). The Mapbox Standard style includes 3D buildings with much greater detail by default.light-v11
javascript
map.on('load', () => {
// Insert the layer beneath any symbol layer for proper ordering
const layers = map.getStyle().layers;
const labelLayerId = layers.find((layer) => layer.type === 'symbol' && layer.layout['text-field']).id;
// Add 3D buildings from basemap
map.addLayer(
{
id: 'add-3d-buildings',
source: 'composite',
'source-layer': 'building',
filter: ['==', 'extrude', 'true'],
type: 'fill-extrusion',
minzoom: 15,
paint: {
'fill-extrusion-color': '#aaa',
// Smoothly transition height on zoom
'fill-extrusion-height': ['interpolate', ['linear'], ['zoom'], 15, 0, 15.05, ['get', 'height']],
'fill-extrusion-base': ['interpolate', ['linear'], ['zoom'], 15, 0, 15.05, ['get', 'min_height']],
'fill-extrusion-opacity': 0.6
}
},
labelLayerId
);
// Enable pitch and bearing for 3D view
map.setPitch(45);
map.setBearing(-17.6);
});Using Custom Data Source:
javascript
map.on('load', () => {
// Add your own buildings data
map.addSource('custom-buildings', {
type: 'geojson',
data: 'https://example.com/buildings.geojson'
});
// Add 3D buildings layer
map.addLayer({
id: '3d-custom-buildings',
type: 'fill-extrusion',
source: 'custom-buildings',
paint: {
// Height in meters
'fill-extrusion-height': ['get', 'height'],
// Base height if building on terrain
'fill-extrusion-base': ['get', 'base_height'],
// Color by building type or height
'fill-extrusion-color': [
'interpolate',
['linear'],
['get', 'height'],
0,
'#fafa6e',
50,
'#eca25b',
100,
'#e64a45',
200,
'#a63e3e'
],
'fill-extrusion-opacity': 0.9
}
});
});Data-Driven 3D Heights:
javascript
// Population density visualization
'fill-extrusion-height': [
'interpolate',
['linear'],
['get', 'density'],
0, 0,
1000, 500, // 1000 people/sq mi = 500m height
10000, 5000
]
// Revenue visualization (scale for visibility)
'fill-extrusion-height': [
'*',
['get', 'revenue'],
0.001 // Scale factor
]适用场景: 建筑高度、海拔数据、体积展示
实现模式: 根据数据拉伸多边形
注意: 以下示例仅适用于经典样式(、streets-v12、dark-v11等)。Mapbox标准样式默认包含更精细的3D建筑效果。light-v11
javascript
map.on('load', () => {
// Insert the layer beneath any symbol layer for proper ordering
const layers = map.getStyle().layers;
const labelLayerId = layers.find((layer) => layer.type === 'symbol' && layer.layout['text-field']).id;
// Add 3D buildings from basemap
map.addLayer(
{
id: 'add-3d-buildings',
source: 'composite',
'source-layer': 'building',
filter: ['==', 'extrude', 'true'],
type: 'fill-extrusion',
minzoom: 15,
paint: {
'fill-extrusion-color': '#aaa',
// Smoothly transition height on zoom
'fill-extrusion-height': ['interpolate', ['linear'], ['zoom'], 15, 0, 15.05, ['get', 'height']],
'fill-extrusion-base': ['interpolate', ['linear'], ['zoom'], 15, 0, 15.05, ['get', 'min_height']],
'fill-extrusion-opacity': 0.6
}
},
labelLayerId
);
// Enable pitch and bearing for 3D view
map.setPitch(45);
map.setBearing(-17.6);
});使用自定义数据源:
javascript
map.on('load', () => {
// Add your own buildings data
map.addSource('custom-buildings', {
type: 'geojson',
data: 'https://example.com/buildings.geojson'
});
// Add 3D buildings layer
map.addLayer({
id: '3d-custom-buildings',
type: 'fill-extrusion',
source: 'custom-buildings',
paint: {
// Height in meters
'fill-extrusion-height': ['get', 'height'],
// Base height if building on terrain
'fill-extrusion-base': ['get', 'base_height'],
// Color by building type or height
'fill-extrusion-color': [
'interpolate',
['linear'],
['get', 'height'],
0,
'#fafa6e',
50,
'#eca25b',
100,
'#e64a45',
200,
'#a63e3e'
],
'fill-extrusion-opacity': 0.9
}
});
});数据驱动的3D高度:
javascript
// Population density visualization
'fill-extrusion-height': [
'interpolate',
['linear'],
['get', 'density'],
0, 0,
1000, 500, // 1000 people/sq mi = 500m height
10000, 5000
]
// Revenue visualization (scale for visibility)
'fill-extrusion-height': [
'*',
['get', 'revenue'],
0.001 // Scale factor
]Circle/Bubble Maps
圆形/气泡图
Best for: Point data with magnitude, proportional symbols
Pattern: Size circles based on data values
javascript
map.on('load', () => {
map.addSource('earthquakes', {
type: 'geojson',
data: 'https://example.com/earthquakes.geojson'
});
// Size by magnitude, color by depth
map.addLayer({
id: 'earthquakes',
type: 'circle',
source: 'earthquakes',
paint: {
// Size circles by magnitude
'circle-radius': ['interpolate', ['exponential', 2], ['get', 'mag'], 0, 2, 5, 20, 8, 100],
// Color by depth
'circle-color': [
'interpolate',
['linear'],
['get', 'depth'],
0,
'#ffffcc',
50,
'#a1dab4',
100,
'#41b6c4',
200,
'#2c7fb8',
300,
'#253494'
],
'circle-stroke-color': '#ffffff',
'circle-stroke-width': 1,
'circle-opacity': 0.75
}
});
// Add popup on click
map.on('click', 'earthquakes', (e) => {
const props = e.features[0].properties;
new mapboxgl.Popup()
.setLngLat(e.features[0].geometry.coordinates)
.setHTML(
`
<h3>Magnitude ${props.mag}</h3>
<p>Depth: ${props.depth} km</p>
<p>Time: ${new Date(props.time).toLocaleString()}</p>
`
)
.addTo(map);
});
});适用场景: 带量级的点数据、比例符号
实现模式: 根据数据值调整圆形大小
javascript
map.on('load', () => {
map.addSource('earthquakes', {
type: 'geojson',
data: 'https://example.com/earthquakes.geojson'
});
// Size by magnitude, color by depth
map.addLayer({
id: 'earthquakes',
type: 'circle',
source: 'earthquakes',
paint: {
// Size circles by magnitude
'circle-radius': ['interpolate', ['exponential', 2], ['get', 'mag'], 0, 2, 5, 20, 8, 100],
// Color by depth
'circle-color': [
'interpolate',
['linear'],
['get', 'depth'],
0,
'#ffffcc',
50,
'#a1dab4',
100,
'#41b6c4',
200,
'#2c7fb8',
300,
'#253494'
],
'circle-stroke-color': '#ffffff',
'circle-stroke-width': 1,
'circle-opacity': 0.75
}
});
// Add popup on click
map.on('click', 'earthquakes', (e) => {
const props = e.features[0].properties;
new mapboxgl.Popup()
.setLngLat(e.features[0].geometry.coordinates)
.setHTML(
`
<h3>Magnitude ${props.mag}</h3>
<p>Depth: ${props.depth} km</p>
<p>Time: ${new Date(props.time).toLocaleString()}</p>
`
)
.addTo(map);
});
});Line Data Visualization
线数据可视化
Best for: Routes, flows, connections, networks
Pattern: Style lines based on data
javascript
map.on('load', () => {
map.addSource('traffic', {
type: 'geojson',
data: 'https://example.com/traffic.geojson'
});
// Traffic flow with data-driven styling
map.addLayer({
id: 'traffic-lines',
type: 'line',
source: 'traffic',
paint: {
// Width by traffic volume
'line-width': ['interpolate', ['exponential', 2], ['get', 'volume'], 0, 1, 1000, 5, 10000, 15],
// Color by speed (congestion)
'line-color': [
'interpolate',
['linear'],
['get', 'speed'],
0,
'#d73027', // Red: stopped
15,
'#fc8d59', // Orange: slow
30,
'#fee08b', // Yellow: moderate
45,
'#d9ef8b', // Light green: good
60,
'#91cf60', // Green: free flow
75,
'#1a9850'
],
'line-opacity': 0.8
}
});
});适用场景: 路线、流量、连接关系、网络
实现模式: 根据数据样式化线条
javascript
map.on('load', () => {
map.addSource('traffic', {
type: 'geojson',
data: 'https://example.com/traffic.geojson'
});
// Traffic flow with data-driven styling
map.addLayer({
id: 'traffic-lines',
type: 'line',
source: 'traffic',
paint: {
// Width by traffic volume
'line-width': ['interpolate', ['exponential', 2], ['get', 'volume'], 0, 1, 1000, 5, 10000, 15],
// Color by speed (congestion)
'line-color': [
'interpolate',
['linear'],
['get', 'speed'],
0,
'#d73027', // Red: stopped
15,
'#fc8d59', // Orange: slow
30,
'#fee08b', // Yellow: moderate
45,
'#d9ef8b', // Light green: good
60,
'#91cf60', // Green: free flow
75,
'#1a9850'
],
'line-opacity': 0.8
}
});
});Animated Data Visualizations
动态数据可视化
Time-Series Animation
时间序列动画
Pattern: Animate data over time
javascript
let currentTime = 0;
const times = [0, 6, 12, 18, 24]; // Hours of day
let animationId;
map.on('load', () => {
map.addSource('hourly-data', {
type: 'geojson',
data: getDataForTime(currentTime)
});
map.addLayer({
id: 'data-layer',
type: 'circle',
source: 'hourly-data',
paint: {
'circle-radius': 8,
'circle-color': ['get', 'color']
}
});
// Animation loop
function animate() {
currentTime = (currentTime + 1) % times.length;
// Update data
map.getSource('hourly-data').setData(getDataForTime(times[currentTime]));
// Update UI
document.getElementById('time-display').textContent = `${times[currentTime]}:00`;
animationId = setTimeout(animate, 1000); // Update every second
}
// Start animation
document.getElementById('play-button').addEventListener('click', () => {
if (animationId) {
clearTimeout(animationId);
animationId = null;
} else {
animate();
}
});
});
function getDataForTime(hour) {
// Fetch or generate data for specific time
return {
type: 'FeatureCollection',
features: data.filter((d) => d.properties.hour === hour)
};
}实现模式: 随时间动态展示数据
javascript
let currentTime = 0;
const times = [0, 6, 12, 18, 24]; // Hours of day
let animationId;
map.on('load', () => {
map.addSource('hourly-data', {
type: 'geojson',
data: getDataForTime(currentTime)
});
map.addLayer({
id: 'data-layer',
type: 'circle',
source: 'hourly-data',
paint: {
'circle-radius': 8,
'circle-color': ['get', 'color']
}
});
// Animation loop
function animate() {
currentTime = (currentTime + 1) % times.length;
// Update data
map.getSource('hourly-data').setData(getDataForTime(times[currentTime]));
// Update UI
document.getElementById('time-display').textContent = `${times[currentTime]}:00`;
animationId = setTimeout(animate, 1000); // Update every second
}
// Start animation
document.getElementById('play-button').addEventListener('click', () => {
if (animationId) {
clearTimeout(animationId);
animationId = null;
} else {
animate();
}
});
});
function getDataForTime(hour) {
// Fetch or generate data for specific time
return {
type: 'FeatureCollection',
features: data.filter((d) => d.properties.hour === hour)
};
}Real-Time Data Updates
实时数据更新
Pattern: Update data from live sources
javascript
map.on('load', () => {
map.addSource('live-data', {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: []
}
});
map.addLayer({
id: 'live-points',
type: 'circle',
source: 'live-data',
paint: {
'circle-radius': 6,
'circle-color': '#ff4444'
}
});
// Poll for updates every 5 seconds
setInterval(async () => {
const response = await fetch('https://api.example.com/live-data');
const data = await response.json();
// Update source
map.getSource('live-data').setData(data);
}, 5000);
// Or use WebSocket for real-time updates
const ws = new WebSocket('wss://api.example.com/live');
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
map.getSource('live-data').setData(data);
};
});实现模式: 从实时数据源更新数据
javascript
map.on('load', () => {
map.addSource('live-data', {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: []
}
});
map.addLayer({
id: 'live-points',
type: 'circle',
source: 'live-data',
paint: {
'circle-radius': 6,
'circle-color': '#ff4444'
}
});
// Poll for updates every 5 seconds
setInterval(async () => {
const response = await fetch('https://api.example.com/live-data');
const data = await response.json();
// Update source
map.getSource('live-data').setData(data);
}, 5000);
// Or use WebSocket for real-time updates
const ws = new WebSocket('wss://api.example.com/live');
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
map.getSource('live-data').setData(data);
};
});Smooth Transitions
平滑过渡
Pattern: Animate property changes
javascript
// Smoothly transition circle sizes
function updateVisualization(newData) {
map.getSource('data-source').setData(newData);
// Animate circle radius
const currentRadius = map.getPaintProperty('data-layer', 'circle-radius');
const targetRadius = ['get', 'newSize'];
// Use setPaintProperty with transition
map.setPaintProperty('data-layer', 'circle-radius', targetRadius);
// Or use expressions for smooth interpolation
map.setPaintProperty('data-layer', 'circle-radius', ['interpolate', ['linear'], ['get', 'value'], 0, 2, 100, 20]);
}实现模式: 动态属性的平滑过渡
javascript
// Smoothly transition circle sizes
function updateVisualization(newData) {
map.getSource('data-source').setData(newData);
// Animate circle radius
const currentRadius = map.getPaintProperty('data-layer', 'circle-radius');
const targetRadius = ['get', 'newSize'];
// Use setPaintProperty with transition
map.setPaintProperty('data-layer', 'circle-radius', targetRadius);
// Or use expressions for smooth interpolation
map.setPaintProperty('data-layer', 'circle-radius', ['interpolate', ['linear'], ['get', 'value'], 0, 2, 100, 20]);
}Performance Optimization
性能优化
Vector Tiles vs GeoJSON
矢量瓦片 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:
javascript
map.addSource('large-dataset', {
type: 'vector',
tiles: ['https://example.com/tiles/{z}/{x}/{y}.mvt'],
minzoom: 0,
maxzoom: 14
});
map.addLayer({
id: 'data-layer',
type: 'fill',
source: 'large-dataset',
'source-layer': 'data-layer-name', // Layer name in the tileset
paint: {
'fill-color': ['get', 'color'],
'fill-opacity': 0.7
}
});适用场景对比:
| 数据大小 | 格式 | 原因 |
|---|---|---|
| < 1 MB | GeoJSON | 简单,无需额外处理 |
| 1-10 MB | GeoJSON 或 矢量瓦片 | 考虑数据更新频率 |
| > 10 MB | 矢量瓦片 | 性能更优,支持渐进式加载 |
矢量瓦片实现模式:
javascript
map.addSource('large-dataset', {
type: 'vector',
tiles: ['https://example.com/tiles/{z}/{x}/{y}.mvt'],
minzoom: 0,
maxzoom: 14
});
map.addLayer({
id: 'data-layer',
type: 'fill',
source: 'large-dataset',
'source-layer': 'data-layer-name', // Layer name in the tileset
paint: {
'fill-color': ['get', 'color'],
'fill-opacity': 0.7
}
});Feature State for Dynamic Styling
特征状态实现动态样式
Pattern: Update styling without modifying geometry
javascript
map.on('load', () => {
map.addSource('states', {
type: 'geojson',
data: statesData,
generateId: true // Important for feature state
});
map.addLayer({
id: 'states',
type: 'fill',
source: 'states',
paint: {
'fill-color': [
'case',
['boolean', ['feature-state', 'hover'], false],
'#ff0000', // Hover color
'#3b9ddd' // Default color
]
}
});
let hoveredStateId = null;
// Update feature state on hover
map.on('mousemove', 'states', (e) => {
if (e.features.length > 0) {
if (hoveredStateId !== null) {
map.setFeatureState({ source: 'states', id: hoveredStateId }, { hover: false });
}
hoveredStateId = e.features[0].id;
map.setFeatureState({ source: 'states', id: hoveredStateId }, { hover: true });
}
});
map.on('mouseleave', 'states', () => {
if (hoveredStateId !== null) {
map.setFeatureState({ source: 'states', id: hoveredStateId }, { hover: false });
}
hoveredStateId = null;
});
});实现模式: 无需修改几何数据即可更新样式
javascript
map.on('load', () => {
map.addSource('states', {
type: 'geojson',
data: statesData,
generateId: true // Important for feature state
});
map.addLayer({
id: 'states',
type: 'fill',
source: 'states',
paint: {
'fill-color': [
'case',
['boolean', ['feature-state', 'hover'], false],
'#ff0000', // Hover color
'#3b9ddd' // Default color
]
}
});
let hoveredStateId = null;
// Update feature state on hover
map.on('mousemove', 'states', (e) => {
if (e.features.length > 0) {
if (hoveredStateId !== null) {
map.setFeatureState({ source: 'states', id: hoveredStateId }, { hover: false });
}
hoveredStateId = e.features[0].id;
map.setFeatureState({ source: 'states', id: hoveredStateId }, { hover: true });
}
});
map.on('mouseleave', 'states', () => {
if (hoveredStateId !== null) {
map.setFeatureState({ source: 'states', id: hoveredStateId }, { hover: false });
}
hoveredStateId = null;
});
});Filtering Large Datasets
大型数据集过滤
Pattern: Filter data client-side for performance
javascript
map.on('load', () => {
map.addSource('all-data', {
type: 'geojson',
data: largeDataset
});
map.addLayer({
id: 'filtered-data',
type: 'circle',
source: 'all-data',
filter: ['>=', ['get', 'value'], 50], // Only show values >= 50
paint: {
'circle-radius': 6,
'circle-color': '#ff4444'
}
});
// Update filter dynamically
function updateFilter(minValue) {
map.setFilter('filtered-data', ['>=', ['get', 'value'], minValue]);
}
// Slider for dynamic filtering
document.getElementById('filter-slider').addEventListener('input', (e) => {
updateFilter(parseFloat(e.target.value));
});
});实现模式: 客户端过滤数据以提升性能
javascript
map.on('load', () => {
map.addSource('all-data', {
type: 'geojson',
data: largeDataset
});
map.addLayer({
id: 'filtered-data',
type: 'circle',
source: 'all-data',
filter: ['>=', ['get', 'value'], 50], // Only show values >= 50
paint: {
'circle-radius': 6,
'circle-color': '#ff4444'
}
});
// Update filter dynamically
function updateFilter(minValue) {
map.setFilter('filtered-data', ['>=', ['get', 'value'], minValue]);
}
// Slider for dynamic filtering
document.getElementById('filter-slider').addEventListener('input', (e) => {
updateFilter(parseFloat(e.target.value));
});
});Progressive Loading
渐进式加载
Pattern: Load data in chunks as needed
javascript
// Helper to check if feature is in bounds
function isFeatureInBounds(feature, bounds) {
const coords = feature.geometry.coordinates;
// Handle different geometry types
if (feature.geometry.type === 'Point') {
return bounds.contains(coords);
} else if (feature.geometry.type === 'LineString') {
return coords.some((coord) => bounds.contains(coord));
} else if (feature.geometry.type === 'Polygon') {
return coords[0].some((coord) => bounds.contains(coord));
}
return false;
}
const bounds = map.getBounds();
const visibleData = allData.features.filter((feature) => isFeatureInBounds(feature, bounds));
map.getSource('data-source').setData({
type: 'FeatureCollection',
features: visibleData
});
// Reload on map move with debouncing
let updateTimeout;
map.on('moveend', () => {
clearTimeout(updateTimeout);
updateTimeout = setTimeout(() => {
const bounds = map.getBounds();
const visibleData = allData.features.filter((feature) => isFeatureInBounds(feature, bounds));
map.getSource('data-source').setData({
type: 'FeatureCollection',
features: visibleData
});
}, 150);
});实现模式: 根据需要分块加载数据
javascript
// Helper to check if feature is in bounds
function isFeatureInBounds(feature, bounds) {
const coords = feature.geometry.coordinates;
// Handle different geometry types
if (feature.geometry.type === 'Point') {
return bounds.contains(coords);
} else if (feature.geometry.type === 'LineString') {
return coords.some((coord) => bounds.contains(coord));
} else if (feature.geometry.type === 'Polygon') {
return coords[0].some((coord) => bounds.contains(coord));
}
return false;
}
const bounds = map.getBounds();
const visibleData = allData.features.filter((feature) => isFeatureInBounds(feature, bounds));
map.getSource('data-source').setData({
type: 'FeatureCollection',
features: visibleData
});
// Reload on map move with debouncing
let updateTimeout;
map.on('moveend', () => {
clearTimeout(updateTimeout);
updateTimeout = setTimeout(() => {
const bounds = map.getBounds();
const visibleData = allData.features.filter((feature) => isFeatureInBounds(feature, bounds));
map.getSource('data-source').setData({
type: 'FeatureCollection',
features: visibleData
});
}, 150);
});Legends and UI Controls
图例与UI控件
Color Scale Legend
颜色比例图例
html
<div class="legend">
<h4>Population Density</h4>
<div class="legend-scale">
<div class="legend-item">
<span class="legend-color" style="background: #f0f9ff;"></span>
<span>0-500</span>
</div>
<div class="legend-item">
<span class="legend-color" style="background: #7fcdff;"></span>
<span>500-1000</span>
</div>
<div class="legend-item">
<span class="legend-color" style="background: #0080ff;"></span>
<span>1000-5000</span>
</div>
<div class="legend-item">
<span class="legend-color" style="background: #001f5c;"></span>
<span>5000+</span>
</div>
</div>
</div>
<style>
.legend {
position: absolute;
bottom: 30px;
right: 10px;
background: white;
padding: 10px;
border-radius: 3px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
font-family: Arial, sans-serif;
font-size: 12px;
}
.legend h4 {
margin: 0 0 10px 0;
font-size: 14px;
}
.legend-item {
display: flex;
align-items: center;
margin-bottom: 5px;
}
.legend-color {
width: 20px;
height: 20px;
margin-right: 10px;
border: 1px solid #ccc;
}
</style>html
<div class="legend">
<h4>Population Density</h4>
<div class="legend-scale">
<div class="legend-item">
<span class="legend-color" style="background: #f0f9ff;"></span>
<span>0-500</span>
</div>
<div class="legend-item">
<span class="legend-color" style="background: #7fcdff;"></span>
<span>500-1000</span>
</div>
<div class="legend-item">
<span class="legend-color" style="background: #0080ff;"></span>
<span>1000-5000</span>
</div>
<div class="legend-item">
<span class="legend-color" style="background: #001f5c;"></span>
<span>5000+</span>
</div>
</div>
</div>
<style>
.legend {
position: absolute;
bottom: 30px;
right: 10px;
background: white;
padding: 10px;
border-radius: 3px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
font-family: Arial, sans-serif;
font-size: 12px;
}
.legend h4 {
margin: 0 0 10px 0;
font-size: 14px;
}
.legend-item {
display: flex;
align-items: center;
margin-bottom: 5px;
}
.legend-color {
width: 20px;
height: 20px;
margin-right: 10px;
border: 1px solid #ccc;
}
</style>Interactive Data Inspector
交互式数据检查器
javascript
map.on('click', 'data-layer', (e) => {
const feature = e.features[0];
const properties = feature.properties;
// Build properties table
const propsTable = Object.entries(properties)
.map(([key, value]) => `<tr><td><strong>${key}:</strong></td><td>${value}</td></tr>`)
.join('');
new mapboxgl.Popup()
.setLngLat(e.lngLat)
.setHTML(
`
<div style="max-width: 300px;">
<h3>Feature Details</h3>
<table style="width: 100%; font-size: 12px;">
${propsTable}
</table>
</div>
`
)
.addTo(map);
});javascript
map.on('click', 'data-layer', (e) => {
const feature = e.features[0];
const properties = feature.properties;
// Build properties table
const propsTable = Object.entries(properties)
.map(([key, value]) => `<tr><td><strong>${key}:</strong></td><td>${value}</td></tr>`)
.join('');
new mapboxgl.Popup()
.setLngLat(e.lngLat)
.setHTML(
`
<div style="max-width: 300px;">
<h3>Feature Details</h3>
<table style="width: 100%; font-size: 12px;">
${propsTable}
</table>
</div>
`
)
.addTo(map);
});Best Practices
最佳实践
Color Accessibility
颜色可访问性
javascript
// Use ColorBrewer scales for accessibility
// https://colorbrewer2.org/
// Good: Sequential (single hue)
const sequentialScale = ['#f0f9ff', '#bae4ff', '#7fcdff', '#0080ff', '#001f5c'];
// Good: Diverging (two hues)
const divergingScale = ['#d73027', '#fc8d59', '#fee08b', '#d9ef8b', '#91cf60', '#1a9850'];
// Good: Qualitative (distinct categories)
const qualitativeScale = ['#e41a1c', '#377eb8', '#4daf4a', '#984ea3', '#ff7f00'];
// Avoid: Red-green for color-blind accessibility
// Use: Blue-orange or purple-green insteadjavascript
// 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 insteadData Preprocessing
数据预处理
javascript
// Calculate statistical breaks for choropleth
// Using classybrew library (npm install classybrew)
import classybrew from 'classybrew';
function calculateJenksBreaks(values, numClasses) {
const brew = new classybrew();
brew.setSeries(values);
brew.setNumClasses(numClasses);
brew.classify('jenks');
return brew.getBreaks();
}
// Normalize data for better visualization
function normalizeData(features, property) {
const values = features.map((f) => f.properties[property]);
const max = Math.max(...values);
const min = Math.min(...values);
const range = max - min;
// Handle case where all values are the same
if (range === 0) {
return features.map((feature) => ({
...feature,
properties: {
...feature.properties,
normalized: 0.5
}
}));
}
return features.map((feature) => ({
...feature,
properties: {
...feature.properties,
normalized: (feature.properties[property] - min) / range
}
}));
}javascript
// Calculate statistical breaks for choropleth
// Using classybrew library (npm install classybrew)
import classybrew from 'classybrew';
function calculateJenksBreaks(values, numClasses) {
const brew = new classybrew();
brew.setSeries(values);
brew.setNumClasses(numClasses);
brew.classify('jenks');
return brew.getBreaks();
}
// Normalize data for better visualization
function normalizeData(features, property) {
const values = features.map((f) => f.properties[property]);
const max = Math.max(...values);
const min = Math.min(...values);
const range = max - min;
// Handle case where all values are the same
if (range === 0) {
return features.map((feature) => ({
...feature,
properties: {
...feature.properties,
normalized: 0.5
}
}));
}
return features.map((feature) => ({
...feature,
properties: {
...feature.properties,
normalized: (feature.properties[property] - min) / range
}
}));
}Error Handling
错误处理
javascript
// Handle missing or invalid data
map.on('load', () => {
map.addSource('data', {
type: 'geojson',
data: dataUrl
});
map.addLayer({
id: 'data-viz',
type: 'fill',
source: 'data',
paint: {
'fill-color': [
'case',
['has', 'value'], // Check if property exists
['interpolate', ['linear'], ['get', 'value'], 0, '#f0f0f0', 100, '#0080ff'],
'#cccccc' // Default color for missing data
]
}
});
// Handle source errors
map.on('error', (e) => {
if (e.source === 'data') {
console.error('Failed to load data:', e);
showNotification('Unable to load visualization data');
}
});
});javascript
// Handle missing or invalid data
map.on('load', () => {
map.addSource('data', {
type: 'geojson',
data: dataUrl
});
map.addLayer({
id: 'data-viz',
type: 'fill',
source: 'data',
paint: {
'fill-color': [
'case',
['has', 'value'], // Check if property exists
['interpolate', ['linear'], ['get', 'value'], 0, '#f0f0f0', 100, '#0080ff'],
'#cccccc' // Default color for missing data
]
}
});
// Handle 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
选举结果地图
javascript
map.addLayer({
id: 'election-results',
type: 'fill',
source: 'districts',
paint: {
'fill-color': [
'match',
['get', 'winner'],
'democrat',
'#3b82f6',
'republican',
'#ef4444',
'independent',
'#a855f7',
'#94a3b8' // No data
],
'fill-opacity': [
'interpolate',
['linear'],
['get', 'margin'],
0,
0.3, // Close race: light
20,
0.9 // Landslide: dark
]
}
});javascript
map.addLayer({
id: 'election-results',
type: 'fill',
source: 'districts',
paint: {
'fill-color': [
'match',
['get', 'winner'],
'democrat',
'#3b82f6',
'republican',
'#ef4444',
'independent',
'#a855f7',
'#94a3b8' // No data
],
'fill-opacity': [
'interpolate',
['linear'],
['get', 'margin'],
0,
0.3, // Close race: light
20,
0.9 // Landslide: dark
]
}
});COVID-19 Case Map
COVID-19病例地图
javascript
map.addLayer({
id: 'covid-cases',
type: 'fill',
source: 'counties',
paint: {
'fill-color': [
'step',
['/', ['get', 'cases'], ['get', 'population']], // Cases per capita
'#ffffb2',
0.001,
'#fed976',
0.005,
'#feb24c',
0.01,
'#fd8d3c',
0.02,
'#fc4e2a',
0.05,
'#e31a1c',
0.1,
'#b10026'
]
}
});javascript
map.addLayer({
id: 'covid-cases',
type: 'fill',
source: 'counties',
paint: {
'fill-color': [
'step',
['/', ['get', 'cases'], ['get', 'population']], // Cases per capita
'#ffffb2',
0.001,
'#fed976',
0.005,
'#feb24c',
0.01,
'#fd8d3c',
0.02,
'#fc4e2a',
0.05,
'#e31a1c',
0.1,
'#b10026'
]
}
});Real Estate Price Heatmap
房地产价格热力图
javascript
map.addLayer({
id: 'real-estate',
type: 'circle',
source: 'properties',
paint: {
'circle-radius': ['interpolate', ['exponential', 2], ['get', 'price'], 100000, 5, 1000000, 20, 10000000, 50],
'circle-color': [
'interpolate',
['linear'],
['get', 'price_per_sqft'],
0,
'#ffffcc',
200,
'#a1dab4',
400,
'#41b6c4',
600,
'#2c7fb8',
800,
'#253494'
],
'circle-opacity': 0.6,
'circle-stroke-color': '#ffffff',
'circle-stroke-width': 1
}
});javascript
map.addLayer({
id: 'real-estate',
type: 'circle',
source: 'properties',
paint: {
'circle-radius': ['interpolate', ['exponential', 2], ['get', 'price'], 100000, 5, 1000000, 20, 10000000, 50],
'circle-color': [
'interpolate',
['linear'],
['get', 'price_per_sqft'],
0,
'#ffffcc',
200,
'#a1dab4',
400,
'#41b6c4',
600,
'#2c7fb8',
800,
'#253494'
],
'circle-opacity': 0.6,
'circle-stroke-color': '#ffffff',
'circle-stroke-width': 1
}
});Resources
参考资源
- Mapbox Expression Reference
- ColorBrewer - Color scales for maps
- Turf.js - Spatial analysis
- Simple Statistics - Data classification
- Data Visualization Tutorials
- Mapbox Expression Reference
- ColorBrewer - 地图颜色比例工具
- Turf.js - 空间分析库
- Simple Statistics - 数据分类工具
- Data Visualization Tutorials