简述
本节继续讲聚合,这是一个官方的例子
示例2
代码
js
map.on('load', () => {
// Add a new source from our GeoJSON data and
// set the 'cluster' option to true. GL-JS will
// add the point_count property to your source data.
map.addSource('earthquakes', {
type: 'geojson',
// Point to GeoJSON data. This example visualizes all M1.0+ earthquakes
// from 12/22/15 to 1/21/16 as logged by USGS' Earthquake hazards program.
data: 'https://docs.mapbox.com/mapbox-gl-js/assets/earthquakes.geojson',
cluster: true,
clusterMaxZoom: 14, // Max zoom to cluster points on
clusterRadius: 50 // Radius of each cluster when clustering points (defaults to 50)
});
map.addLayer({
id: 'clusters',
type: 'circle',
source: 'earthquakes',
filter: ['has', 'point_count'],
paint: {
// Use step expressions (https://docs.mapbox.com/style-spec/reference/expressions/#step)
// with three steps to implement three types of circles:
// * Blue, 20px circles when point count is less than 100
// * Yellow, 30px circles when point count is between 100 and 750
// * Pink, 40px circles when point count is greater than or equal to 750
'circle-color': [
'step',
['get', 'point_count'],
'#51bbd6',
100,
'#f1f075',
750,
'#f28cb1'
],
'circle-radius': [
'step',
['get', 'point_count'],
20,
100,
30,
750,
40
]
}
});
map.addLayer({
id: 'cluster-count',
type: 'symbol',
source: 'earthquakes',
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: 'earthquakes',
filter: ['!', ['has', 'point_count']],
paint: {
'circle-color': '#11b4da',
'circle-radius': 4,
'circle-stroke-width': 1,
'circle-stroke-color': '#fff'
}
});
// inspect a cluster on click
map.on('click', 'clusters', (e) => {
const features = map.queryRenderedFeatures(e.point, {
layers: ['clusters']
});
const clusterId = features[0].properties.cluster_id;
map.getSource('earthquakes').getClusterExpansionZoom(
clusterId,
(err, zoom) => {
if (err) return;
map.easeTo({
center: features[0].geometry.coordinates,
zoom: zoom
});
}
);
});
// When a click event occurs on a feature in
// the unclustered-point layer, open a popup at
// the location of the feature, with
// description HTML from its properties.
map.on('click', 'unclustered-point', (e) => {
const coordinates = e.features[0].geometry.coordinates.slice();
const mag = e.features[0].properties.mag;
const tsunami =
e.features[0].properties.tsunami === 1 ? 'yes' : 'no';
// Ensure that if the map is zoomed out such that
// multiple copies of the feature are visible, the
// popup appears over the copy being pointed to.
while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) {
coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360;
}
new mapboxgl.Popup()
.setLngLat(coordinates)
.setHTML(
`magnitude: ${mag}<br>Was there a tsunami?: ${tsunami}`
)
.addTo(map);
});
map.on('mouseenter', 'clusters', () => {
map.getCanvas().style.cursor = 'pointer';
});
map.on('mouseleave', 'clusters', () => {
map.getCanvas().style.cursor = '';
});
});
示例逻辑
这个例子和第一个例子非常相似,简单过一下这个例子
- 添加source
- 添加layer
- 添加点击事件
这个例子的效果是:圆会被聚合,圆上会显示被聚合的数量,放大后,展示单点圆
解析
- 添加source
js
map.addSource('earthquakes', {
type: 'geojson',
// 地震数据
data: 'https://docs.mapbox.com/mapbox-gl-js/assets/earthquakes.geojson',
cluster: true, // 设置聚合
clusterMaxZoom: 14, // 最大聚合层级
clusterRadius: 50 // 聚合半径
});
- 添加layer
例子中,使用了三次addLayer
添加了3个图层,source
均为第一步添加的数据。
第一个Layer设置了被聚合的圆,通过filter
过滤拥有point_count
的元素,上节说过,被聚合的元素会添加一些聚合属性,通过这一方式区分聚合和非聚合元素,随后根据被聚合数量设置圆的颜色和半径。
第二个Layer设置了被聚合显示的数量(Symbol),这一步类似与第一步,同样设置了过滤,这样就在被聚合圆上显示被聚合的数量了。
第三个Layer仅显示未聚合的圆,设置的过滤条件与前两者相反,这样便完成了整体的聚合效果
- 添加点击事件
给被聚合圆和未聚合圆的分别设置点击事件:
js
map.on('click', 'clusters', (e) => {
const features = map.queryRenderedFeatures(e.point, {
layers: ['clusters']
});
const clusterId = features[0].properties.cluster_id;
map.getSource('earthquakes').getClusterExpansionZoom(
clusterId,
(err, zoom) => {
if (err) return;
map.easeTo({
center: features[0].geometry.coordinates,
zoom: zoom
});
}
);
});
queryRenderedFeatures
,这个方法前面有出现过,但没有具体解释过:根据传入的geometry查询范围内的非隐藏的元素,在这个例子中,传入的是鼠标点击的位置(像素),并且在第二个参数中限制了查询范围(layer),那么查询的便是clusters图层
点击位置中有哪些元素,即注册了圆的点击事件。
getClusterExpansionZoom
,该方法属于GeoJsonSource的方法,通过第一步获取的聚合id(被聚合的图层产生的聚合属性)获取"适用"于该聚合的缩放层级,这里的适用是指返回的层级可以让聚合展开(非聚合)。
easeTo
同 flyTo
那么整体的点击事件实现的效果是:点击聚合圆,放大层级,展开聚合。
js
map.on('click', 'unclustered-point', (e) => {
const coordinates = e.features[0].geometry.coordinates.slice();
const mag = e.features[0].properties.mag;
const tsunami =
e.features[0].properties.tsunami === 1 ? 'yes' : 'no';
while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) {
coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360;
}
new mapboxgl.Popup()
.setLngLat(coordinates)
.setHTML(
`magnitude: ${mag}<br>Was there a tsunami?: ${tsunami}`
)
.addTo(map);
});
这个点击事件没有陌生的方法,达到的效果是:点击非聚合点,弹出一个Marker,展示一些信息。
示例的最后设置了clusters图层
的mouseenter
和mouseleave
, 达到的效果是:鼠标移入聚合圆时,鼠标变成手指,鼠标移出聚合圆时,鼠标变成手掌。
示例3
仍然是官方的例子,这个例子逻辑上和前两个是相似的,但是会有几个麻烦的方法。
代码
js
// filters for classifying earthquakes into five categories based on magnitude
const mag1 = ['<', ['get', 'mag'], 2];
const mag2 = ['all', ['>=', ['get', 'mag'], 2], ['<', ['get', 'mag'], 3]];
const mag3 = ['all', ['>=', ['get', 'mag'], 3], ['<', ['get', 'mag'], 4]];
const mag4 = ['all', ['>=', ['get', 'mag'], 4], ['<', ['get', 'mag'], 5]];
const mag5 = ['>=', ['get', 'mag'], 5];
// colors to use for the categories
const colors = ['#fed976', '#feb24c', '#fd8d3c', '#fc4e2a', '#e31a1c'];
map.on('load', () => {
// add a clustered GeoJSON source for a sample set of earthquakes
map.addSource('earthquakes', {
'type': 'geojson',
'data': 'https://docs.mapbox.com/mapbox-gl-js/assets/earthquakes.geojson',
'cluster': true,
'clusterRadius': 80,
'clusterProperties': {
// keep separate counts for each magnitude category in a cluster
'mag1': ['+', ['case', mag1, 1, 0]],
'mag2': ['+', ['case', mag2, 1, 0]],
'mag3': ['+', ['case', mag3, 1, 0]],
'mag4': ['+', ['case', mag4, 1, 0]],
'mag5': ['+', ['case', mag5, 1, 0]]
}
});
// circle and symbol layers for rendering individual earthquakes (unclustered points)
map.addLayer({
'id': 'earthquake_circle',
'type': 'circle',
'source': 'earthquakes',
'filter': ['!=', 'cluster', true],
'paint': {
'circle-color': [
'case',
mag1,
colors[0],
mag2,
colors[1],
mag3,
colors[2],
mag4,
colors[3],
colors[4]
],
'circle-opacity': 0.6,
'circle-radius': 12
}
});
map.addLayer({
'id': 'earthquake_label',
'type': 'symbol',
'source': 'earthquakes',
'filter': ['!=', 'cluster', true],
'layout': {
'text-field': [
'number-format',
['get', 'mag'],
{ 'min-fraction-digits': 1, 'max-fraction-digits': 1 }
],
'text-font': ['Open Sans Semibold', 'Arial Unicode MS Bold'],
'text-size': 10
},
'paint': {
'text-color': [
'case',
['<', ['get', 'mag'], 3],
'black',
'white'
]
}
});
// objects for caching and keeping track of HTML marker objects (for performance)
const markers = {};
let markersOnScreen = {};
function updateMarkers() {
const newMarkers = {};
const features = map.querySourceFeatures('earthquakes');
// for every cluster on the screen, create an HTML marker for it (if we didn't yet),
// and add it to the map if it's not there already
for (const feature of features) {
const coords = feature.geometry.coordinates;
const props = feature.properties;
if (!props.cluster) continue;
const id = props.cluster_id;
let marker = markers[id];
if (!marker) {
const el = createDonutChart(props);
marker = markers[id] = new mapboxgl.Marker({
element: el
}).setLngLat(coords);
}
newMarkers[id] = marker;
if (!markersOnScreen[id]) marker.addTo(map);
}
// for every marker we've added previously, remove those that are no longer visible
for (const id in markersOnScreen) {
if (!newMarkers[id]) markersOnScreen[id].remove();
}
markersOnScreen = newMarkers;
}
// after the GeoJSON data is loaded, update markers on the screen on every frame
map.on('render', () => {
if (!map.isSourceLoaded('earthquakes')) return;
updateMarkers();
});
});
// code for creating an SVG donut chart from feature properties
function createDonutChart(props) {
const offsets = [];
const counts = [
props.mag1,
props.mag2,
props.mag3,
props.mag4,
props.mag5
];
let total = 0;
for (const count of counts) {
offsets.push(total);
total += count;
}
const fontSize =
total >= 1000 ? 22 : total >= 100 ? 20 : total >= 10 ? 18 : 16;
const r =
total >= 1000 ? 50 : total >= 100 ? 32 : total >= 10 ? 24 : 18;
const r0 = Math.round(r * 0.6);
const w = r * 2;
let html = `<div>
<svg width="${w}" height="${w}" viewbox="0 0 ${w} ${w}" text-anchor="middle" style="font: ${fontSize}px sans-serif; display: block">`;
for (let i = 0; i < counts.length; i++) {
html += donutSegment(
offsets[i] / total,
(offsets[i] + counts[i]) / total,
r,
r0,
colors[i]
);
}
html += `<circle cx="${r}" cy="${r}" r="${r0}" fill="white" />
<text dominant-baseline="central" transform="translate(${r}, ${r})">
${total.toLocaleString()}
</text>
</svg>
</div>`;
const el = document.createElement('div');
el.innerHTML = html;
return el.firstChild;
}
function donutSegment(start, end, r, r0, color) {
if (end - start === 1) end -= 0.00001;
const a0 = 2 * Math.PI * (start - 0.25);
const a1 = 2 * Math.PI * (end - 0.25);
const x0 = Math.cos(a0),
y0 = Math.sin(a0);
const x1 = Math.cos(a1),
y1 = Math.sin(a1);
const largeArc = end - start > 0.5 ? 1 : 0;
// draw an SVG path
return `<path d="M ${r + r0 * x0} ${r + r0 * y0} L ${r + r * x0} ${
r + r * y0
} A ${r} ${r} 0 ${largeArc} 1 ${r + r * x1} ${r + r * y1} L ${
r + r0 * x1
} ${r + r0 * y1} A ${r0} ${r0} 0 ${largeArc} 0 ${r + r0 * x0} ${
r + r0 * y0
}" fill="${color}" />`;
}
示例逻辑
- 声明一些变量
- 添加Source
- 添加Layer
- 设置render时更新Marker
这个示例达到的效果是:被聚合的是饼图,饼图是地震级别的占比和被聚合的数量,放大后,展示单点的地震级别
解析
- 声明变量
js
const mag1 = ['<', ['get', 'mag'], 2];
const mag2 = ['all', ['>=', ['get', 'mag'], 2], ['<', ['get', 'mag'], 3]];
const mag3 = ['all', ['>=', ['get', 'mag'], 3], ['<', ['get', 'mag'], 4]];
const mag4 = ['all', ['>=', ['get', 'mag'], 4], ['<', ['get', 'mag'], 5]];
const mag5 = ['>=', ['get', 'mag'], 5];
const colors = ['#fed976', '#feb24c', '#fd8d3c', '#fc4e2a', '#e31a1c'];
首先需要知道的是,mag
是示例数据的字段,为地震的级别,声明的这五个表达式将地震级别分成五个等级:02 345~∞;之后再声明了一个色带。
- 添加source
js
map.addSource('earthquakes', {
'type': 'geojson',
'data': 'https://docs.mapbox.com/mapbox-gl-js/assets/earthquakes.geojson',
'cluster': true,
'clusterRadius': 80,
'clusterProperties': {
'mag1': ['+', ['case', mag1, 1, 0]],
'mag2': ['+', ['case', mag2, 1, 0]],
'mag3': ['+', ['case', mag3, 1, 0]],
'mag4': ['+', ['case', mag4, 1, 0]],
'mag5': ['+', ['case', mag5, 1, 0]]
}
});
和上一个例子一样设置聚合,只不过添加了一个新的字段clusterProperties
,这是一个计算属性,能够在聚合时利用表达式生成新的字段,结合上面的表达式,生成每个地震级别的数量(被聚合)。
- 添加图层
js
map.addLayer({
'id': 'earthquake_circle',
'type': 'circle',
'source': 'earthquakes',
'filter': ['!=', 'cluster', true],
'paint': {
'circle-color': [
'case',
mag1,
colors[0],
mag2,
colors[1],
mag3,
colors[2],
mag4,
colors[3],
colors[4]
],
'circle-opacity': 0.6,
'circle-radius': 12
}
});
map.addLayer({
'id': 'earthquake_label',
'type': 'symbol',
'source': 'earthquakes',
'filter': ['!=', 'cluster', true],
'layout': {
'text-field': [
'number-format',
['get', 'mag'],
{ 'min-fraction-digits': 1, 'max-fraction-digits': 1 }
],
'text-font': ['Open Sans Semibold', 'Arial Unicode MS Bold'],
'text-size': 10
},
'paint': {
'text-color': [
'case',
['<', ['get', 'mag'], 3],
'black',
'white'
]
}
});
这一步添加的图层没有特别要讲的,根据过滤条件可知,将未聚合的点用圆表示,并根据不同地震级别设置;添加地震级别作为文字的图层。
js
['number-format',
['get', 'mag'],
{ 'min-fraction-digits': 1, 'max-fraction-digits': 1 }]
这个表示式并不常用,将输入的数字通过后面的配置项转换为字符串,'min-fraction-digits'
和'max-fraction-digits'
设置了最大最小的有效小数位。
- 设置render时更新Marker
js
// after the GeoJSON data is loaded, update markers on the screen on every frame
map.on('render', () => {
if (!map.isSourceLoaded('earthquakes')) return;
updateMarkers();
});
这一步是重点,先看map.on('render', () => {})
这个方法的含义是,在地图每次渲染时触发绑定事件,需要注意的是,默认设置 的render
并不会每帧触发,如果视角内未有新数据更新,它并不会触发。
该例子中,如果earthquakes
被加载,在每次render中触发updateMarkers
。
js
const markers = {};
let markersOnScreen = {};
function updateMarkers() {
const newMarkers = {};
const features = map.querySourceFeatures('earthquakes');
// for every cluster on the screen, create an HTML marker for it (if we didn't yet),
// and add it to the map if it's not there already
for (const feature of features) {
const coords = feature.geometry.coordinates;
const props = feature.properties;
if (!props.cluster) continue;
const id = props.cluster_id;
let marker = markers[id];
if (!marker) {
const el = createDonutChart(props);
marker = markers[id] = new mapboxgl.Marker({
element: el
}).setLngLat(coords);
}
newMarkers[id] = marker;
if (!markersOnScreen[id]) marker.addTo(map);
}
// for every marker we've added previously, remove those that are no longer visible
for (const id in markersOnScreen) {
if (!newMarkers[id]) markersOnScreen[id].remove();
}
markersOnScreen = newMarkers;
}
在这个函数中,先执行了querySourceFeatures
这个方法将返回某一source
的Features,需要注意的是,该方法并不会返回所有的元素,仅返回屏幕上已加载瓦片的元素。
循环这些Feature,获取它的经纬度和属性,如果不是被聚合的点则跳过该Feature,如果是,再判断之前是否生成过,没有则根据Feature创建Marker(这里不具体讲饼图的生成逻辑,有兴趣的自己可以研究一下,涉及svg绘制相关内容),有则继续使用之前创建的Marker。
这里需要注意的是,示例中有两个Marker数组进行存储,markers用于缓存之前生成的所有Marker对象(不管是否显示),减少不必要的重复创建,markersOnScreen当前正在显示的Marker。
在第二个循环中,将不在屏幕显示的Markers从地图中移除。
总结
本节是聚合的第二和第三个例子,过程是相似的,区别在于后者使用Marker更容易创建好的可视效果,当然性能上不如前者,使用时需要注意性能的优化。
如有错误,欢迎指正;如有疑问,评论留言。