需求
用户点击行政区划等操作后,从后台获取区域内的点位数据,在地图上聚合显示。用户手动取消聚合,点位直接渲染在地图上。
实现过程
- 使用后台返回的点位数据,通过
new ol.source.Vector({features})创建矢量数据源。 - 使用
new ol.source.Cluster({source})创建聚合标注数据源,source参数设置为上一步创建的矢量数据源。 - 最后创建一个矢量图层
new ol.layer.Vector({source}),source参数设置为上一步创建的聚合标注数据源,再将矢量图层添加到地图上即可。 - 取消聚合可以通过两种方式实现。
- 方式一:通过设置两种不同的聚合样式:聚合数量大于1的样式和聚合数量等于1的样式。将聚合数量等于1的样式设置成点位的样式,来模拟不聚合的点位展示效果。
- 方式二:通过隐藏和显示图层实现。默认将点位图层也添加到地图上,在需要显示的时机,将聚合图层隐藏,显示点位图层。
- 可通过
source.setDistance(number)设置聚合的像素,设置为0时,所有点位都不聚合,即可达到不聚合的效果。
知识点
new ol.style.Circle({radius, stroke, fill})创建一个圆形样式,可设置半径,描边颜色和填充颜色。new ol.style.Text({text,fill,scale}),创建一个字体,可设置字体内容,颜色和缩放比例。map.getView().animate({center, zoom})可跳转到指定的经纬度和显示层级。- 聚合点的
feature有一个features属性,是一个存放着这个聚合下面的所有feature的数组,可通过feature.get('features')获得。 new ol.source.Cluster({distance, source})创建聚合标注数据源,通过source设置聚合的资源。通过distance设置聚合的距离。比如distance设置为150,表示如果2个点位之间的距离小于150px,即聚合成1个聚合点②。new ol.layer.Vector({style})的style参数可以是一个回调函数,函数的第一个参数是feature,可根据feature的不同,return不同的样式。clusterLayer.getSource().setDistance(number)可在初始化聚合图层结束后,再次设置聚合距离,设置为0可实现不聚合的效果。feature.get('features').length聚合点feature的features属性的length是此聚合点下点位的数量。map.getView().getZoom()可获得当前地图的展示层级,有小数点。layers.setVisible(true/false)可设置图层的显示和隐藏。- 通过
map.on('moveend', () => {}可以监听地图移动事件,在地图平移和缩放结束后会触发此事件回调函数。可通过层级的变化,区分是平移还是缩放。 '#' + parseInt(Math.random() * 0xffffff).toString(16).padStart(6, '0')可随机生成16进制颜色值。- 在测试切换两种不同的实现不聚合的方式时,在地图层级小的时候,比如5级,切换后会感觉两种方式的点位不同。但如果将地图逐步放大,再次进行切换的时候,就不会有差异。最后发现是在聚合图层和直接渲染点位的图层中,重叠点位的处理不同,在聚合模式下,这个监控在上面,但是在直接渲染点位的图层中,这个监控却在下面。在地图层级小的时候,点位都聚集在了一起,重叠的数据非常多,导致切换时大量的点位层级变化,觉得异常。但当地图不断放大后,点位直接不重叠了,这时切换就感觉不到点位有什么变化了。
HTML+CSS+JS
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/openlayers/8.2.0/ol.min.css" integrity="sha512-bc9nJM5uKHN+wK7rtqMnzlGicwJBWR11SIDFJlYBe5fVOwjHGtXX8KMyYZ4sMgSL0CoUjo4GYgIBucOtqX/RUQ==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<title>点位聚合</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--top-height: 50px;
}
html,
body,
#app,
.app-map {
height: 100%;
height: 100%;
}
.app-btns {
position: fixed;
right: 10px;
top: 10px;
background-color: #fff;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, .5);
width: 210px;
padding: 25px;
text-align: center;
border-radius: 5px;
display: flex;
flex-direction: column;
z-index: 2;
}
.app-btns button {
font-size: 18px;
border: none;
padding: 12px 10px;
border-radius: 4px;
color: #fff;
background-color: #409eff;
border-color: #409eff;
cursor: pointer;
border: 1px solid #dcdfe6;
margin-bottom: 5px;
}
.app-btns button:hover {
background: #66b1ff;
border-color: #66b1ff;
}
.app-btns button.active {
background-color: #07c160;
}
hr {
margin: 20px 0;
}
.zoom {
position: fixed;
right: 10px;
bottom: 10px;
z-index: 10;
color: #20b1aa;
font-weight: bold;
}
</style>
</head>
<body>
<div id="app">
<div class="app-map" id="app-map"></div>
<span v-text='mapZoom' class="zoom"></span>
<div class="app-btns">
<button type="button" @click='handleClickCluster(btn)' v-for='btn in btnData' v-text='btn.text' :class='{active: currentDis === btn.px}' :key='btn.px'></button>
<hr>
<button type="button" :class='[{active: cancalClusterType === 1}, "hide-type"]' @click='handleClickCancalCluster(1)'>样式区别 不聚合</button>
<button type="button" :class='[{active: cancalClusterType === 2}, "hide-type"]' @click='handleClickCancalCluster(2)'>隐藏显示 不聚合</button>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/openlayers/8.2.0/dist/ol.min.js" integrity="sha512-+nvfloZUX7awRy1yslYBsicmHKh/qFW5w79+AiGiNcbewg0nBy7AS4G3+aK/Rm+eGPOKlO3tLuVphMxFXeKeOQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/3.4.14/vue.global.prod.min.js" integrity="sha512-huEQFMCpBzGkSDSPVAeQFMfvWuQJWs09DslYxQ1xHeaCGQlBiky9KKZuXX7zfb0ytmgvfpTIKKAmlCZT94TAlQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script>
const { createApp } = Vue;
// feature图片
const base64Img = 'data:image/png;base64,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';
// 基础样式
const basePointStyle = new ol.style.Style({
image: new ol.style.Icon({
src: base64Img,
scale: 1,
anchor: [0.5, 0.5],
rotateWithView: true,
rotation: 0,
opacity: 1
}),
count: 1
});
// 生成点位聚合显示的数字样式
const createCountPointStyle = (size) => {
// 计算一个动态的 radius
const radius = 20 + Math.max(0, (String(size).length - 2)) * 10;
const rcolor = '#' + parseInt(Math.random() * 0xffffff).toString(16).padStart(6, '0');
return new ol.style.Style({
image: new ol.style.Circle({
radius,
stroke: new ol.style.Stroke({
color: rcolor
}),
fill: new ol.style.Fill({
color: rcolor
})
}),
text: new ol.style.Text({
text: size.toString(),
fill: new ol.style.Fill({
color: '#fff'
}),
scale: 2,
textBaseline: 'middle'
})
})
};
// 存放各个聚合数字的样式,用于不重复生成各个数量的样式
const countStyles = {};
const vm = createApp({
data() {
return {
map: {},
mapZoom: 5,
btnData: [{
text: '50px聚合',
px: 50,
}, {
text: '100px聚合',
px: 100,
}, {
text: '150px聚合',
px: 150,
}],
currentDis: 150, // 聚合距离
pointsLayers: null, // 不聚合的点位图层
allPoints: [], // 全国范围内点位聚合
bjPoints: [], // 北京点位聚合
allCluster: null, // 随机的聚合图层
beijingCluster: null, // 北京的聚合图层
cancalClusterType: 0 // 取消聚合的方式,1 采用两种聚合样式的方式, 2 采用隐藏和显示图层的方式
}
},
methods: {
// 初始化地图
initMap() {
// 高德地图瓦片地址
const vectorLayer = new ol.layer.Tile({
source: new ol.source.XYZ({
url: 'http://wprd04.is.autonavi.com/appmaptile?lang=zh_cn&size=1&style=7&x={x}&y={y}&z={z}'
}),
layerID: 'base'
});
// 初始化地图
this.map = new ol.Map({
target: 'app-map',
layers: [vectorLayer],
view: new ol.View({
projection: 'EPSG:3857',
//设定中心点,因为默认坐标系为 3587,所以要将我们常用的经纬度坐标系4326 转换为 3587坐标系
center: ol.proj.transform([111.8453154, 32.7383500], 'EPSG:4326', 'EPSG:3857'),
zoom: 5,
})
});
// 绑定地图事件
this.bindMapEvt();
// 创建随机点位的聚合图层
this.createRandomCluster(10000);
// 创建北京的聚合图层
this.createBjCluster(1000);
// 创建所有点位图层,在取消聚合的时候展示出来
this.createAllPonitsLayer();
},
// 绑定地图事件
bindMapEvt() {
// 监听鼠标点击
this.map.on('click', (evt) => {
const coordinate = ol.proj.transform(evt.coordinate, 'EPSG:3857', 'EPSG:4326')
console.log('当前点击坐标为 : ' + coordinate[0].toFixed(7) + ',' + coordinate[1].toFixed(7));
const feature = this.map.forEachFeatureAtPixel(evt.pixel, function(feature) {
return feature;
});
// 如果点击的是聚合点,进一步放大层级。如果点击的是具体的点位了(聚合数量是1),获取点位ID,进行下一步操作
if (feature) {
console.log(feature, 'feature');
// 获取count属性
const count = feature.get('count');
// 放大地图层级
if (count > 1) {
alert(`这是一个聚合点,下面共有 ${count} 个点位,将以此为中心,增大一级地图显示层级。`);
const czoom = this.map.getView().getZoom();
this.map.getView().animate({
center: evt.coordinate,
zoom: czoom + 1
});
// 弹出ID
} else {
const custom = feature.get('features')[0].get('custom');
alert(`这是一个具体的点位,点位的ID是 ${custom.id}。`);
}
}
});
window.aaa = this.map;
// 监听鼠标移动,移动到feature上时,鼠标变为可点击的状态
this.map.on('pointermove', (e) => {
let pixel = this.map.getEventPixel(e.originalEvent);
let feature = this.map.forEachFeatureAtPixel(pixel, (feature) => {
return feature
});
if (feature) {
this.map.getTargetElement().style.cursor = 'pointer';
} else {
this.map.getTargetElement().style.cursor = 'auto';
}
});
// 移动事件,包括鼠标左键移动和缩放,地图右上角显示当前地图层级
this.map.on('moveend', () => {
this.mapZoom = this.map.getView().getZoom().toFixed(1);
});
},
// 创建随机点位的聚合点位数据图层
createRandomCluster(num) {
const positions = this.createPointsByRange(num); // 生成坐标数据
this.createCluster(positions, 'allCluster', 3);
this.allPoints = positions;
},
// 创建北京的聚合数据
createBjCluster(num) {
const bjPoints = this.createPointsByRange(num, [39.9037, 40.1892, 115.2000, 117.4000]);
this.createCluster(bjPoints, 'beijingCluster', 2);
this.bjPoints = bjPoints;
},
// 根据数据创建聚合图层
createCluster(points, layerID, zindex) {
// 根据points创建一个新的数据源和要素数组,
const vectorSource = new ol.source.Vector({
features: points.map(e => {
// ol.proj.fromLonLat用于将经纬度坐标从 WGS84 坐标系转换为地图投影坐标系
const feature = new ol.Feature({
geometry: new ol.geom.Point(ol.proj.fromLonLat(e)),
custom: {
id: Math.ceil(Math.random() * 100000)
}
});
return feature;
})
});
// 根据点位创建聚合资源
const clusterSource = new ol.source.Cluster({
distance: this.currentDis, // 设置多少像素以内的点位进行聚合
source: vectorSource,
});
// 创建带有数据源的矢量图层,将创建的聚合字段作为source
this[layerID] = new ol.layer.Vector({
source: clusterSource,
layerID: layerID,
style: (feature) => {
return this.setFeatureStyle(feature); // 设置聚合点的样式
}
});
// 将矢量图层添加到地图上
this.map.addLayer(this[layerID]);
this[layerID].setZIndex(zindex); // 设置层级
},
// 设置聚合点的样式
setFeatureStyle(feature) {
// 获取聚合点小有几个点位
const size = feature.get('features').length;
// 设置聚合点的count参数
feature.set('count', size);
// 如果是1,直接展示点位的样式
if (size === 1) {
return basePointStyle;
} else {
// 如果是聚合点,查看countStyles是否存储了这个聚合点的数字样式,如果不存在,生成一个并存储
if (!countStyles[size]) {
countStyles[size] = createCountPointStyle(size);
}
return countStyles[size];
}
},
// 设置聚合图层的聚合距离
handleClickCluster(btn) {
// 聚合距离相同,直接返回不处理
if (this.currentDis === btn.px) {
return;
}
this.currentDis = btn.px;
// 设置聚合距离
// 获取到图层,遍历判断是不是 Cluster 图层,是的话设置聚合距离
this.map.getLayers().getArray().forEach(layer => {
const source = layer.getSource();
if (source instanceof ol.source.Cluster) {
!layer.getVisible() && layer.setVisible(true); // 如果聚合图层隐藏着呢,将其显示
source.setDistance(btn.px);
}
});
// 如果点位图层显示着呢,将其隐藏
this.pointsLayers.getVisible() && this.pointsLayers.setVisible(false); // 隐藏点位图层
},
// 根据type取消聚合显示
handleClickCancalCluster(type) {
if (this.cancalClusterType === type) { // 防止二次点击不聚合
return;
}
this.cancalClusterType = type;
this.currentDis = 0;
if (this.cancalClusterType === 1) {
// 遍历所有图层,将聚合图层的聚合距离设置为0,使其表现为布局和的样子
this.map.getLayers().getArray().forEach(layer => {
const source = layer.getSource();
if (source instanceof ol.source.Cluster) {
source.setDistance(0); // 设置聚合距离
layer.setVisible(true); // 将图层显示出来
}
});
this.pointsLayers.setVisible(false);
console.log('样式区别');
} else {
// 隐藏聚合图层
this.map.getLayers().getArray().forEach(layer => {
const source = layer.getSource();
if (source instanceof ol.source.Cluster) {
layer.setVisible(false);
}
});
this.pointsLayers.setVisible(true); // 显示点位图层
console.log('隐藏显示');
}
},
// 根据范围随机生成经纬度点位 rangeArr = [minLat, maxLat, minLon, maxLon]
createPointsByRange(num, rangeArr = [3.86, 53.56, 73.66, 135.05]) {
const [minLat, maxLat, minLon, maxLon] = rangeArr;
const points = [];
for (var i = 0; i < num; i++) {
var lat = Math.random() * (maxLat - minLat) + minLat;
var lon = Math.random() * (maxLon - minLon) + minLon;
points.push([lon, lat]);
}
return points;
},
// 创建所有点位图层,在取消聚合的时候展示出来
createAllPonitsLayer() {
const { bjPoints, allPoints } = this;
this.pointsLayers = new ol.layer.Vector({
source: new ol.source.Vector({
features: [...bjPoints, ...allPoints].map(e => {
const feature = new ol.Feature({
geometry: new ol.geom.Point(ol.proj.fromLonLat(e)),
custom: {
id: Math.ceil(Math.random() * 100000)
}
});
return feature;
})
}),
layerID: 'appPoints',
style: basePointStyle
});
this.map.addLayer(this.pointsLayers);
this.pointsLayers.setVisible(false); // 默认隐藏点位图层
this.pointsLayers.setZIndex(3); // 设置层级
}
},
mounted() {
this.initMap();
}
}).mount('#app')
</script>
</body>
</html>