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
相关产品推荐
相关产品推荐

