mapbox-gl | 3.2 聚合(下)

简述

本节继续讲聚合,这是一个官方的例子

示例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 = '';
        });
    });

示例逻辑

这个例子和第一个例子非常相似,简单过一下这个例子

  1. 添加source
  2. 添加layer
  3. 添加点击事件

这个例子的效果是:圆会被聚合,圆上会显示被聚合的数量,放大后,展示单点圆

解析

  1. 添加source
js 复制代码
        map.addSource('earthquakes', {
            type: 'geojson',
            // 地震数据
            data: 'https://docs.mapbox.com/mapbox-gl-js/assets/earthquakes.geojson',
            cluster: true, // 设置聚合
            clusterMaxZoom: 14, // 最大聚合层级
            clusterRadius: 50 // 聚合半径
        });
  1. 添加layer

例子中,使用了三次addLayer添加了3个图层,source均为第一步添加的数据。

第一个Layer设置了被聚合的圆,通过filter过滤拥有point_count的元素,上节说过,被聚合的元素会添加一些聚合属性,通过这一方式区分聚合和非聚合元素,随后根据被聚合数量设置圆的颜色和半径。

第二个Layer设置了被聚合显示的数量(Symbol),这一步类似与第一步,同样设置了过滤,这样就在被聚合圆上显示被聚合的数量了。

第三个Layer仅显示未聚合的圆,设置的过滤条件与前两者相反,这样便完成了整体的聚合效果

  1. 添加点击事件

给被聚合圆和未聚合圆的分别设置点击事件:

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(被聚合的图层产生的聚合属性)获取"适用"于该聚合的缩放层级,这里的适用是指返回的层级可以让聚合展开(非聚合)。

easeToflyTo

那么整体的点击事件实现的效果是:点击聚合圆,放大层级,展开聚合。

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图层mouseentermouseleave, 达到的效果是:鼠标移入聚合圆时,鼠标变成手指,鼠标移出聚合圆时,鼠标变成手掌。

示例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}" />`;
    }

示例逻辑

  1. 声明一些变量
  2. 添加Source
  3. 添加Layer
  4. 设置render时更新Marker

这个示例达到的效果是:被聚合的是饼图,饼图是地震级别的占比和被聚合的数量,放大后,展示单点的地震级别

解析

  1. 声明变量
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~∞;之后再声明了一个色带。

  1. 添加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,这是一个计算属性,能够在聚合时利用表达式生成新的字段,结合上面的表达式,生成每个地震级别的数量(被聚合)。

  1. 添加图层
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'设置了最大最小的有效小数位。

  1. 设置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更容易创建好的可视效果,当然性能上不如前者,使用时需要注意性能的优化。

如有错误,欢迎指正;如有疑问,评论留言。

相关推荐
崔庆才丨静觅9 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby606110 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了10 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅10 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅10 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅11 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment11 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅11 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊11 小时前
jwt介绍
前端
爱敲代码的小鱼11 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax