6 OpenLayers学习笔记-点位聚合

775 阅读11分钟

需求

用户点击行政区划等操作后,从后台获取区域内的点位数据,在地图上聚合显示。用户手动取消聚合,点位直接渲染在地图上。

实现过程

  1. 使用后台返回的点位数据,通过new ol.source.Vector({features})创建矢量数据源。
  2. 使用new ol.source.Cluster({source})创建聚合标注数据源,source参数设置为上一步创建的矢量数据源。
  3. 最后创建一个矢量图层new ol.layer.Vector({source})source参数设置为上一步创建的聚合标注数据源,再将矢量图层添加到地图上即可。
  4. 取消聚合可以通过两种方式实现。
    • 方式一:通过设置两种不同的聚合样式:聚合数量大于1的样式和聚合数量等于1的样式。将聚合数量等于1的样式设置成点位的样式,来模拟不聚合的点位展示效果。
    • 方式二:通过隐藏和显示图层实现。默认将点位图层也添加到地图上,在需要显示的时机,将聚合图层隐藏,显示点位图层。
  5. 可通过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聚合点featurefeatures属性的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>

参考文章

ol.source.Cluster官网文档

ol.source.Cluster官网栗子

ol.style.Circle官网文档

ol.style.Text官方文档