You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Mapbox聚类地图点击触发全部弹窗及集群缩放失效问题求助

解决Mapbox聚类地图的弹窗与集群缩放问题

问题描述

我正在创建一个包含同一区域内多个位置的Mapbox聚类地图,弹窗数据来自API,已为每个位置设置点击切换弹窗的事件。但遇到两个问题:

  • 即使指定了唯一ID作为触发标识,点击时仍会打开所有弹窗
  • 集群计数圆圈的缩放功能无法正常工作

原代码

import mapboxgl from 'mapbox-gl';
import MapboxLanguage from '@mapbox/mapbox-gl-language';
import apiFetch from '@wordpress/api-fetch';

(() => {
    const mapContainer = document.querySelector('[data-gewoon-wonen]');

    if (!mapContainer) {
        return;
    }

    mapboxgl.accessToken = process.env.MAP_TOKEN_KEY;

    const filterGroup = document.getElementById('map-filter');

    const resetButton = filterGroup.querySelector('.hidden');

    const center = [4.387733, 51.862419];

    const locations = {
        type: 'FeatureCollection',
        features: []
    };

    apiFetch({ path: '/wp-json/wp/v2/map?_embed' }).then((maps) => {
        maps.forEach((item) => {
            const {
                id,
                title: { rendered: title },
                _embedded,
                acf
            } = item;

            const image =
                _embedded && _embedded['wp:featuredmedia'][0]?.source_url;

            const {
                map_location_subtitle: subtitle,
                map_location_delivery: delivery,
                map_location_project: project,
                map_location_content: description,
                map_location_coordinates_lat: lat,
                map_location_coordinates_lng: lng,
                map_location_status: status,
                map_location_website: website
            } = acf;

            const getStatus = (currentStatus) => {
                let statusObj = {
                    bouwfase: 'marker-gray',
                    planfase: 'marker-bright-pink',
                    opgeleverd: 'marker-bright-blue',
                    default: ''
                };

                let icon = statusObj[currentStatus] || statusObj['default'];

                return icon;
            };

            const object = {
                type: 'Feature',
                properties: {
                    id,
                    status,
                    image,
                    icon: getStatus(status),
                    title,
                    subtitle,
                    project,
                    website,
                    delivery,
                    description
                },
                geometry: {
                    type: 'Point',
                    coordinates: [lng, lat]
                }
            };

            locations.features.push(object);
        });
    });

    const map = new mapboxgl.Map({
        container: mapContainer,
        style: 'mapbox://styles/.../clcz9eocm000p14o3vh42tqfj',
        center,
        zoom: 10,
        minZoom: 10,
        maxZoom: 18,
        attributionControl: false,
        cooperativeGestures: true
    });

    map.addControl(
        new MapboxLanguage({
            defaultLanguage: 'mul'
        })
    );

    const resetActiveItems = () => {
        document
            .querySelectorAll('.active')
            .forEach((e) => e.classList.remove('active'));
    };

    const closePopUps = () => {
        const popups = document.getElementsByClassName('mapboxgl-popup');

        if (popups.length) {
            popups[0].remove();
        }
    };

    map.on('load', () => {
        map.addSource('locations', {
            type: 'geojson',
            data: locations,
            cluster: true,
            clusterMaxZoom: 14, // Max zoom to cluster points on
            clusterRadius: 50 // Radius of each cluster when clustering points (defaults to 50)s
        });

        locations.features.forEach((location) => {
            const {
                description,
                delivery,
                title,
                subtitle,
                project,
                status,
                image,
                website,
                id
            } = location.properties;
            const { coordinates } = location.geometry;
            const layerID = `status-${status}-${id}`;

            // Add a layer for this symbol type if it hasn't been added already.
            if (!map.getLayer(layerID)) {
                map.addLayer({
                    id: layerID,
                    type: 'symbol',
                    source: 'locations',
                    layout: {
                        'icon-image': ['get', 'icon'],
                        'icon-size': 0.9,
                        'icon-allow-overlap': true
                    },
                    // filter: ['==', 'id', id]
                    filter: ['!', ['has', 'point_count']]
                });
                map.addLayer({
                    id: `clusters-${id}`,
                    type: 'circle',
                    source: 'locations',
                    filter: ['has', 'point_count'],
                    paint: {
                        // Use step expressions (https://docs.mapbox.com/mapbox-gl-js/style-spec/#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-${id}`,
                    type: 'symbol',
                    source: 'locations',
                    filter: ['has', 'point_count'],
                    layout: {
                        'text-field': ['get', 'point_count_abbreviated'],
                        'text-font': [
                            'DIN Offc Pro Medium',
                            'Arial Unicode MS Bold'
                        ],
                        'text-size': 12
                    }
                });
            }

            const hasImage = image
                ? `<img src=${image} class="map__image" />`
                : '';

            const statusClass = image ? 'map__status--overlay' : '';

            const popup = new mapboxgl.Popup({
                offset: 25,
                maxWidth: null,
                className: image ? 'map__content--image' : ''
            });

            const content = `
                ${hasImage}
                <span class="map__status map__status--${status} ${statusClass}">${status}</span>
                <h4 class="map__title">${title}</h4>
                <p class="map__subtitle">${subtitle}</p>
                <p class="map__subtitle">${
                    delivery ? `Oplevering ${delivery}` : ''
                }</p>
                <p class="map__project">${project}</p>
                <p class="map__info">${description}</p>
                <a href=${website} class="btn btn--small btn__pink btn__arrow" target="_blank">Meer informatie</a>
            `;

            map.on('click', layerID, (e) => {
                // Copy coordinates array.
                const coordinateSlice = coordinates.slice();

                resetButton.classList.remove('hidden');

                // 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 - coordinateSlice[0]) > 180) {
                    coordinateSlice[0] +=
                        e.lngLat.lng > coordinateSlice[0] ? 360 : -360;
                }

                popup.setLngLat(coordinates).setHTML(content).addTo(map);
            });

            // Change the cursor to a pointer when the mouse is over the places layer.
            map.on('mouseenter', layerID, () => {
                map.getCanvas().style.cursor = 'pointer';
            });

            // Change it back to a pointer when it leaves.
            map.on('mouseleave', layerID, () => {
                map.getCanvas().style.cursor = '';
            });
        });

        const statusTypes = locations.features.map(
            (feature) => feature.properties.status
        );

        const uniqueStatusTypes = Array.from(new Set(statusTypes));

        const newGeoJSON = { ...locations };

        resetButton.addEventListener('click', () => {
            newGeoJSON.features = [...locations.features];
            map.getSource('locations').setData(newGeoJSON);
            map.setZoom(10).setCenter(center);
            resetButton.classList.add('hidden');
            resetActiveItems();
            closePopUps();
        });

        const sortedStatusTypes = [
            uniqueStatusTypes[0],
            uniqueStatusTypes[2],
            uniqueStatusTypes[1]
        ].filter((status) => status !== undefined && status !== null);

        sortedStatusTypes.forEach((statusType) => {
            const input = document.createElement('input');
            input.value = statusType;
            input.name = statusType;
            input.type = 'button';
            input.className = `status--${statusType}`;
            input.innerText = statusType;
            filterGroup.appendChild(input);

            input.onclick = () => {
                const statusType = input.value;

                resetActiveItems();
                closePopUps();

                if (statusType) {
                    input.classList.add('active');
                    resetButton.classList.remove('hidden');

                    newGeoJSON.features = locations.features.filter(
                        (feature) => feature.properties.status === statusType
                    );
                }
                map.getSource('locations').setData(newGeoJSON);
            };
        });

        // disable map rotation using right click + drag
        map.dragRotate.disable();

        // disable map rotation using touch rotation gesture
        map.touchZoomRotate.disableRotation();

        // Add zoom and rotation controls to the map.
        const nav = new mapboxgl.NavigationControl({
            showCompass: false
        });

        const fullscreen = new mapboxgl.FullscreenControl();

        map.addControl(fullscreen, 'top-right');
        map.addControl(nav, 'bottom-right');
    });
})();

问题分析与修复

1. 点击打开所有弹窗的问题

原因:循环中为每个点位创建独立图层,所有图层的过滤条件都是['!', ['has', 'point_count']],这意味着所有非集群点会匹配所有图层,点击时所有图层的click事件都会触发,导致所有弹窗打开。同时,提前为每个点绑定弹窗的方式冗余且容易冲突。

修复方案:

  • 按状态分组创建共享图层,而非每个点单独创建
  • 在通用图层的click事件中,通过e.features[0]获取当前点击的特征,动态生成弹窗内容

2. 集群缩放失效的问题

原因:为每个点创建了独立的集群图层,多个集群图层重叠导致逻辑混乱,集群缩放无法正常触发。集群图层只需要创建一次即可。

修复方案:

  • 将集群相关图层(clusters、cluster-count)移到循环外,仅创建一次
  • 确保集群图层的过滤条件正确指向集群特征,并添加集群点击缩放事件

修复后的完整代码

import mapboxgl from 'mapbox-gl';
import MapboxLanguage from '@mapbox/mapbox-gl-language';
import apiFetch from '@wordpress/api-fetch';

(() => {
    const mapContainer = document.querySelector('[data-gewoon-wonen]');

    if (!mapContainer) {
        return;
    }

    mapboxgl.accessToken = process.env.MAP_TOKEN_KEY;

    const filterGroup = document.getElementById('map-filter');
    const resetButton = filterGroup.querySelector('.hidden');
    const center = [4.387733, 51.862419];
    const locations = { type: 'FeatureCollection', features: [] };

    // 获取API数据
    apiFetch({ path: '/wp-json/wp/v2/map?_embed' }).then((maps) => {
        maps.forEach((item) => {
            const { id, title: { rendered: title }, _embedded, acf } = item;
            const image = _embedded && _embedded['wp:featuredmedia'][0]?.source_url;
            const {
                map_location_subtitle: subtitle,
                map_location_delivery: delivery,
                map_location_project: project,
                map_location_content: description,
                map_location_coordinates_lat: lat,
                map_location_coordinates_lng: lng,
                map_location_status: status,
                map_location_website: website
            } = acf;

            const getStatus = (currentStatus) => {
                const statusObj = {
                    bouwfase: 'marker-gray',
                    planfase: 'marker-bright-pink',
                    opgeleverd: 'marker-bright-blue',
                    default: ''
                };
                return statusObj[currentStatus] || statusObj['default'];
            };

            locations.features.push({
                type: 'Feature',
                properties: {
                    id,
                    status,
                    image,
                    icon: getStatus(status),
                    title,
                    subtitle,
                    project,
                    website,
                    delivery,
                    description
                },
                geometry: { type: 'Point', coordinates: [lng, lat] }
            });
        });

        // 数据加载完成后初始化地图图层
        initMapLayers();
    });

    const map = new mapboxgl.Map({
        container: mapContainer,
        style: 'mapbox://styles/.../clcz9eocm000p14o3vh42tqfj',
        center,
        zoom: 10,
        minZoom: 10,
        maxZoom: 18,
        attributionControl: false,
        cooperativeGestures: true
    });

    map.addControl(new MapboxLanguage({ defaultLanguage: 'mul' }));

    const resetActiveItems = () => {
        document.querySelectorAll('.active').forEach(e => e.classList.remove('active'));
    };

    const closePopUps = () => {
        const popups = document.getElementsByClassName('mapboxgl-popup');
        if (popups.length) popups[0].remove();
    };

    const initMapLayers = () => {
        map.on('load', () => {
            // 添加数据源
            map.addSource('locations', {
                type: 'geojson',
                data: locations,
                cluster: true,
                clusterMaxZoom: 14,
                clusterRadius: 50
            });

            // 创建集群图层(仅一次)
            map.addLayer({
                id: 'clusters',
                type: 'circle',
                source: 'locations',
                filter: ['has', 'point_count'],
                paint: {
                    '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: 'locations',
                filter: ['has', 'point_count'],
                layout: {
                    'text-field': ['get', 'point_count_abbreviated'],
                    'text-font': ['DIN Offc Pro Medium', 'Arial Unicode MS Bold'],
                    'text-size': 12
                }
            });

            // 获取唯一状态值,按状态创建点位图层
            const uniqueStatuses = [...new Set(locations.features.map(f => f.properties.status))];
            uniqueStatuses.forEach(status => {
                const layerId = `status-${status}`;
                if (!map.getLayer(layerId)) {
                    map.addLayer({
                        id: layerId,
                        type: 'symbol',
                        source: 'locations',
                        layout: {
                            'icon-image': ['get', 'icon'],
                            'icon-size': 0.9,
                            'icon-allow-overlap': true
                        },
                        filter: ['all', ['!', ['has', 'point_count']], ['==', 'status', status]]
                    });
                }
            });

            // 为所有点位图层绑定点击事件
            const pointLayers = uniqueStatuses.map(s => `status-${s}`);
            pointLayers.forEach(layerId => {
                map.on('click', layerId, (e) => {
                    closePopUps();
                    resetButton.classList.remove('hidden');
                    const feature = e.features[0];
                    const { properties, geometry } = feature;
                    const { title, subtitle, delivery, project, description
相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.02 13:46:05