需求
地图聚合显示点位,聚合点的样式可以根据聚合的数量区分展示,聚合点位要有类似呼吸灯的动画样式。
实现过程
一般聚合点位的样式可以通过ol.style.Style()进行设置,但要增加css动画,可能会复杂些。采用了另一种思路,通过Overlay来实现。Overlay最终的显示是通过html元素来实现的,可自由的设置css样式。通过监听真正的聚合图层的change事件,拿到聚合点位信息,根据聚合点信息动态创建Overlay。
知识点
clusterSource.on('change', () =>{})监听聚合图层资源的变化,地图缩放时,这个函数会被执行多次,可使用setTimeout控制下Overlay的渲染,防止重复渲染。feature的ol_uid属性,openlayers为每个feature提供的唯一标识。map.getOverlays()获取所有Overlay,map.getOverlays().clear()清除所有Overlay。feature.getGeometry().getCoordinates()用户获取要素所在的经纬度信息。layer.setOpacity用于设置图层的透明度。clusterLayer.getSource().setDistance()用于设置聚合图层的点位聚合距离。ol.source.Cluster聚合图层资源的构造函数,可通过source instanceof ol.source.Cluster判断图层是不是聚合图层。feature.set('count', 1)给feature设置自定义属性,后期可通过feature.get('count')获取。- 由于使用
Overlay替代聚合点位,需要把聚合图层隐藏,可通过设置聚合图层的层级zIndex和透明度opacity来隐藏聚合图层。但这两种方式都存在一个缺点:用户仍能点击到聚合图层,触发聚合图层的点击事件。所以,如果聚合图层没有特殊要求,不要做聚合图层点击事件的处理。如有场景需要显示聚合图层,并有点击的逻辑,可通过变量判断,在聚合图层隐藏的情况下,不触发点击事件内的逻辑。
代码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>Overlay展示点位聚合</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
height: 100%;
height: 100%;
}
#container {
width: 100%;
height: 100%;
overflow: hidden;
position: absolute;
}
#app {
width: 100vw;
height: 100vh;
}
.app-map {
height: 100vh;
}
.zoom {
position: fixed;
right: 10px;
bottom: 10px;
padding: 10px;
background-color: #eee;
border-radius: 8px;
color: #07c160;
font-weight: bold;
}
.app-btns {
position: fixed;
right: 10px;
top: 10px;
background-color: #fff;
box-shadow: 0 2px 12px 0 rgba(0,0,0,.1);
width: 210px;
padding: 25px;
text-align: center;
border-radius: 5px;
display: flex;
flex-direction: column;
}
.app-btns button {
font-size: 18px;
border: none;
padding: 12px 20px;
border-radius: 4px;
color: #fff;
background-color: #409eff;
border-color: #409eff;
cursor: pointer;
border: 1px solid #dcdfe6;
margin-bottom: 5px;
}
.app-btns button.active,
.app-btns button:hover {
background-color: rgba(7, 193, 96, 0.8);
}
@keyframes scale-animation {
0% { transform: scale(1); }
50% { transform: scale(1.2); }
100% { transform: scale(1); }
}
.cluster-marker {
animation: scale-animation 2s infinite;
border-radius: 50%;
z-index: 3;
cursor: pointer;
}
.cluster-marker.c30 {
width: 20px;
height: 20px;
line-height: 20px;
text-align: center;
font-size: 14px;
background-color: rgba(0, 255, 168, 0.7);
box-shadow: 0px 0px 8px 6px rgba(0, 255, 168,1);
}
.cluster-marker.c90 {
width: 40px;
height: 40px;
line-height: 40px;
text-align: center;
font-size: 16px;
background-color: rgba(32, 177, 170, 0.7);
box-shadow: 0px 0px 8px 6px rgba(32, 177, 170,1);
}
.cluster-marker.c150 {
width: 70px;
height: 70px;
line-height: 70px;
text-align: center;
font-size: 18px;
background-color: rgba(7, 193, 96, 0.7);
box-shadow: 0px 0px 8px 6px rgba(7, 193, 96,1);
}
.cluster-marker.c250 {
width: 100px;
height: 100px;
line-height: 100px;
text-align: center;
font-size: 20px;
background-color: rgba(255, 215, 0, 0.7);
box-shadow: 0px 0px 8px 6px rgba(255, 215, 0,1);
}
</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 @click='handleClickCluster(btn)' v-for='btn in btnData' v-text='btn.text' :class='{active: currentDis === btn.px}' :key='btn.px'></button>
<button @click='toggleCluster'>{{clusterZIndex === -1 ? '显示' : '隐藏'}}聚合图层</button>
<button @click='toggleOverlay'>{{overlayVisible ? '隐藏' : '显示'}}Overlay图层</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;
// 生成点位聚合显示的数字样式
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, // 当前聚合距离
overlayArr: [], // overlay数组
clusterLayer: {}, // 聚合图层
clusterZIndex: -1, // 聚合图层层级
overlayVisible: true // overlay的visible
}
},
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}'
}),
name: '初始化地图图层',
layerID: 'base',
index: 2
});
// 初始化地图
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(5000);
},
// 绑定地图事件
bindMapEvt() {
// 监听鼠标点击
this.map.on('click', (evt) => {
const clickPoint = ol.proj.transform(evt.coordinate, 'EPSG:3857', 'EPSG:4326')
console.log('当前点击坐标为 : ' + clickPoint[0].toFixed(7) + ',' + clickPoint[1].toFixed(7));
const feature = this.map.forEachFeatureAtPixel(evt.pixel, function(feature) {
return feature;
});
// 如果点击的是聚合点,进一步放大层级。如果点击的是具体的点位了(聚合数量是1),获取点位ID,进行下一步操作
if (feature) {
// 处理点击聚合图层
if (this.clusterZIndex === -1) {
return;
}
// 判断是否是聚合点位
const iamcluster = feature.get('iamcluster');
if (iamcluster) {
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}。`);
}
}
}
});
// 监听鼠标移动,移动到聚合点位上时,鼠标变为可点击的状态
this.map.on('pointermove', (e) => {
let pixel = this.map.getEventPixel(e.originalEvent);
let feature = this.map.forEachFeatureAtPixel(pixel, (feature) => {
return feature
});
if (feature) {
const iamcluster = feature.get('iamcluster');
if (iamcluster && this.clusterZIndex == 1) {
this.map.getTargetElement().style.cursor = 'pointer';
} else {
this.map.getTargetElement().style.cursor = 'auto';
}
} else {
this.map.getTargetElement().style.cursor = 'auto';
}
});
// 移动事件,包括鼠标左键移动和缩放,地图右上角显示当前地图层级
this.map.on('moveend', () => {
this.mapZoom = this.map.getView().getZoom().toFixed(1);
});
// overlay的点击事件
document.querySelector('body').addEventListener('click', e => {
if (e.target.classList.contains('cluster-marker')) {
console.log('overlay下聚合点位数量: ', e.target.dataset.count);
const ol_uid = e.target.dataset.ol_uid;
const { features } = this.clusterLayer.getSource();
for (let i = 0; i < features.length; i++) {
if (features[i].ol_uid == ol_uid) {
const featuresInfo = features[i].get('features');
alert('共有' + featuresInfo.length + '个点位,详细信息已在控制台打印');
console.log('聚合点位数据为: ', featuresInfo);
break;
}
}
}
})
},
// 根据数据创建聚合图层
createCluster(points, layerID) {
// 根据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.clusterLayer = new ol.layer.Vector({
source: clusterSource,
layerID: layerID,
style: (feature) => {
return this.setFeatureStyle(feature)
},
zIndex: this.clusterZIndex,
opacity: 0
});
// 将矢量图层添加到地图上
this.map.addLayer(this.clusterLayer);
let changed = 0; // 设置overlay的定时器,防止change事件触发多次重复添加overlay
// 监听图层变化事件,变换后,更新overlay
clusterSource.on('change', (e) => {
if (this.overlayVisible === false) {
return;
}
if (changed) {
clearTimeout(changed);
}
changed = setTimeout(() => {
console.log('渲染overlay');
this.map.getOverlays().clear();
this.overlayArr = [];
if (e.target.features && e.target.features.length > 0) {
for (let i = 0; i < e.target.features.length; i++) {
var count = e.target.features[i].get('count');
const { ol_uid } = e.target.features[i]
this.createClusterOverlay(e.target.features[i].getGeometry().getCoordinates(), count, ol_uid);
}
}
}, 20);
});
},
// 创建随机点位的聚合点位数据图层
createRandomCluster(num) {
const positions = this.createPointsByRange(num); // 生成坐标数据
this.createCluster(positions, 'all');
},
// 根据经纬度创建overlay
createClusterOverlay (coordinate, count, ol_uid) {
if (!count) {
return;
}
let element = document.createElement('div');
element.innerText = count;
element.dataset.count = count;
element.dataset.ol_uid = ol_uid;
element.className = 'cluster-marker ' + this.getOverlayCls(count); // 应用 CSS 类
// 将元素添加到 Overlay 容器中
const overlay = new ol.Overlay({
element: element,
position: coordinate,
positioning: 'center-center'
});
this.overlayArr.push(overlay);
this.map.addOverlay(overlay);
},
// 根据数量判断overlay的dom类名
getOverlayCls (count) {
return count < 30 ? 'c30' :
count < 90 ? 'c90' :
count < 150 ? 'c150' :
'c250';
},
// 切换聚合图层的显示和隐藏
toggleCluster () {
this.clusterZIndex = this.clusterZIndex === -1 ? 1 : -1;
this.clusterLayer.setZIndex(this.clusterZIndex);
this.clusterLayer.setOpacity(this.clusterZIndex === -1 ? 0 : 1);
},
// 切换overlay的显示和隐藏
toggleOverlay () {
if (this.overlayVisible) { // 清空
this.map.getOverlays().clear();
} else { // 获取聚合图层的feature,转换为overlay
const { features } = this.clusterLayer.getSource();
this.overlayArr = [];
for (let i = 0; i < features.length; i++) {
const count = features[i].get('features').length;
const coordinates = features[i].get('geometry').getCoordinates()
this.createClusterOverlay(coordinates, count);
}
}
this.overlayVisible = !this.overlayVisible;
},
// 设置聚合点的样式
setFeatureStyle(feature) {
// 获取聚合点小有几个点位
const size = feature.get('features').length;
// 设置聚合点的count参数
feature.set('count', size); // 设置数量
feature.set('iamcluster', true); // 增加标识
// 如果是聚合点,查看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) {
source.setDistance(btn.px);
}
});
},
// 根据范围随机生成经纬度点位 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;
},
},
mounted() {
this.initMap();
}
}).mount('#app')
</script>
</body>
</html>