9 OpenLayers学习笔记-Overlay替换聚合点

630 阅读7分钟

需求

地图聚合显示点位,聚合点的样式可以根据聚合的数量区分展示,聚合点位要有类似呼吸灯的动画样式。

实现过程

一般聚合点位的样式可以通过ol.style.Style()进行设置,但要增加css动画,可能会复杂些。采用了另一种思路,通过Overlay来实现。Overlay最终的显示是通过html元素来实现的,可自由的设置css样式。通过监听真正的聚合图层的change事件,拿到聚合点位信息,根据聚合点信息动态创建Overlay

知识点

  • clusterSource.on('change', () =>{})监听聚合图层资源的变化,地图缩放时,这个函数会被执行多次,可使用setTimeout控制下Overlay的渲染,防止重复渲染。
  • featureol_uid属性,openlayers为每个feature提供的唯一标识。
  • map.getOverlays()获取所有Overlaymap.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>

参考文章

ol.source.Cluster官网文档

ol.source.Cluster官网栗子