From b15592fa111eb669df07dab8300e0227fd6d5b8c Mon Sep 17 00:00:00 2001 From: Babayaga Date: Mon, 30 Mar 2026 19:51:53 +0200 Subject: [PATCH] map clusters --- .../src/modules/places/CompetitorsMapView.tsx | 80 ++---- .../components/map-layers/LocationLayers.tsx | 238 ++++++++++++++++++ 2 files changed, 253 insertions(+), 65 deletions(-) create mode 100644 packages/ui/src/modules/places/components/map-layers/LocationLayers.tsx diff --git a/packages/ui/src/modules/places/CompetitorsMapView.tsx b/packages/ui/src/modules/places/CompetitorsMapView.tsx index ca4c1758..3c1a5af4 100644 --- a/packages/ui/src/modules/places/CompetitorsMapView.tsx +++ b/packages/ui/src/modules/places/CompetitorsMapView.tsx @@ -21,6 +21,7 @@ import { MapLayerToggles } from './components/MapLayerToggles'; import { MapOverlayToolbars } from './components/MapOverlayToolbars'; import { LiveSearchLayers } from './components/map-layers/LiveSearchLayers'; import { MapPosterOverlay } from './components/MapPosterOverlay'; +import { LocationLayers } from './components/map-layers/LocationLayers'; import { T, translate } from '../../i18n'; const safeSetStyle = (m: maplibregl.Map, style: any) => { @@ -372,7 +373,7 @@ export const CompetitorsMapView: React.FC = ({ competit return { type: 'FeatureCollection' as const, features: pickerPolygons.flatMap(fc => fc?.features || []) }; }, [pickerPolygons]); - const markersRef = useRef([]); + const markersRef = useRef([]); // To be removed or kept as empty for ref safety if used elsewhere const onMapMoveRef = useRef(onMapMove); useEffect(() => { @@ -430,72 +431,11 @@ export const CompetitorsMapView: React.FC = ({ competit const lastFittedLocationIdsRef = useRef(null); - // Handle Markers & Data Updates + // Handle Data Updates - No longer creates manual markers. LocationLayers handles this via WebGL. useEffect(() => { if (!map.current) return; - // Clear existing markers - markersRef.current.forEach(marker => marker.remove()); - markersRef.current = []; - - // Add markers - validLocations.forEach(loc => { - // Create custom marker element - const el = document.createElement('div'); - el.className = 'custom-marker'; - // Set explicit low z-index so Pacman (9999) easily over-renders it - el.style.zIndex = '10'; - - const isDark = mapStyleKey === 'dark'; - const isSelected = selectedLocation && loc.place_id === selectedLocation.place_id; - - // Inner div for the pin to handle hover transform isolation - const pin = document.createElement('div'); - - // Theme-aware, more elegant pin styling with highlighting for the active one - pin.className = `w-7 h-7 rounded-full flex items-center justify-center font-bold text-xs shadow-md border-[1.5px] cursor-pointer transition-all duration-300 backdrop-blur-sm ` + - (isSelected - ? (isDark - ? 'bg-amber-500 border-amber-300 text-amber-900 scale-[1.35] z-50 ring-4 ring-amber-500/30 shadow-[0_0_15px_rgba(245,158,11,0.5)]' - : 'bg-amber-400 border-white text-amber-900 scale-[1.35] z-50 ring-4 ring-amber-400/40 shadow-[0_0_15px_rgba(251,191,36,0.6)]') - : (isDark - ? 'bg-indigo-900/80 border-indigo-400/60 text-indigo-100 hover:bg-indigo-800/90 hover:border-indigo-300 hover:scale-125' - : 'bg-indigo-600 border-white text-white shadow-lg hover:bg-indigo-700 hover:scale-125') - ); - - pin.innerHTML = loc.label; - - el.appendChild(pin); - - // Attach click handler to select location - el.addEventListener('click', (e) => { - e.stopPropagation(); - setSelectedLocation(loc); - - // Fly to location - if (map.current) { - map.current.flyTo({ center: [loc.lon, loc.lat], zoom: 15, duration: 500 }); - } - setInfoPanelOpen(false); // Close info if opening location detail - }); - - // Popup logic - const popup = new maplibregl.Popup({ offset: 25, className: 'map-popup-override', closeButton: false, maxWidth: '300px' }) - .setHTML(renderPopupHtml(loc)); - - const marker = new maplibregl.Marker({ element: el }) - .setLngLat([loc.lon, loc.lat]) - .setPopup(popup) - .addTo(map.current!); - - // Ensure the maplibre container honors the z-index - const wrapper = marker.getElement(); - if (wrapper) wrapper.style.zIndex = '10'; - - markersRef.current.push(marker); - }); - - // Fit bounds only when the core locations set changes (not on every state toggle) + // Fit bounds only when the core locations set changes if (validLocations.length > 0 && lastFittedLocationIdsRef.current !== locationIds) { const isInitialLoad = lastFittedLocationIdsRef.current === null; @@ -506,7 +446,7 @@ export const CompetitorsMapView: React.FC = ({ competit } lastFittedLocationIdsRef.current = locationIds; } - }, [locationIds, validLocations, sidebarWidth, initialCenter, mapStyleKey, selectedLocation, isPosterMode]); + }, [locationIds, validLocations, initialCenter, isPosterMode]); // Sync Theme/Style @@ -809,6 +749,16 @@ export const CompetitorsMapView: React.FC = ({ competit liveRadii={liveRadii} liveNodes={liveNodes} /> + { + setSelectedLocation(loc); + setInfoPanelOpen(false); + }} + selectedId={selectedLocation?.place_id} + /> {isPosterMode && ( void; + selectedId?: string; +} + +const emptyFc: GeoJSON.FeatureCollection = { type: 'FeatureCollection', features: [] }; + +export function LocationLayers({ + map, + competitors, + isDarkStyle, + onSelect, + selectedId +}: LocationLayersProps) { + const data = useMemo(() => { + if (!competitors.length) return emptyFc; + return { + type: 'FeatureCollection', + features: competitors.map(c => ({ + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [c.lon, c.lat] + }, + properties: { + place_id: c.place_id, + label: c.label, + title: c.title, + isSelected: c.place_id === selectedId + } + })) + } as GeoJSON.FeatureCollection; + }, [competitors, selectedId]); + + const isDarkStyleRef = useRef(isDarkStyle); + isDarkStyleRef.current = isDarkStyle; + + const onSelectRef = useRef(onSelect); + onSelectRef.current = onSelect; + + useEffect(() => { + if (!map) return; + + const setupLayers = () => { + if (!map.getStyle()) return; + + // Sources + if (!map.getSource('locations-source')) { + map.addSource('locations-source', { + type: 'geojson', + data: data, + cluster: true, + clusterMaxZoom: 14, + clusterRadius: 40 + }); + } + + // Layer: Clusters (Circles) + if (!map.getLayer('clusters')) { + map.addLayer({ + id: 'clusters', + type: 'circle', + source: 'locations-source', + filter: ['has', 'point_count'], + paint: { + 'circle-color': [ + 'step', + ['get', 'point_count'], + '#51bbd6', // < 100 + 100, '#f1f075', // 100-750 + 750, '#f28cb1' // >= 750 + ], + 'circle-radius': [ + 'step', + ['get', 'point_count'], + 20, + 100, 30, + 750, 40 + ], + 'circle-stroke-width': 2, + 'circle-stroke-color': '#fff' + } + }); + } + + // Layer: Cluster Count Labels + if (!map.getLayer('cluster-count')) { + map.addLayer({ + id: 'cluster-count', + type: 'symbol', + source: 'locations-source', + filter: ['has', 'point_count'], + layout: { + 'text-field': '{point_count}', + 'text-font': ['Open Sans Semibold', 'Arial Unicode MS Bold'], + 'text-size': 12 + } + }); + } + + // Layer: Unclustered Points (Individual Pins) + if (!map.getLayer('unclustered-point-circle')) { + map.addLayer({ + id: 'unclustered-point-circle', + type: 'circle', + source: 'locations-source', + filter: ['!', ['has', 'point_count']], + paint: { + 'circle-color': [ + 'case', + ['get', 'isSelected'], + '#f59e0b', // Amber (selected) + isDarkStyleRef.current ? '#4f46e5' : '#4f46e5' // Indigo + ], + 'circle-radius': [ + 'case', + ['get', 'isSelected'], + 16, + 12 + ], + 'circle-stroke-width': 2, + 'circle-stroke-color': '#fff' + } + }); + } + + if (!map.getLayer('unclustered-point-label')) { + map.addLayer({ + id: 'unclustered-point-label', + type: 'symbol', + source: 'locations-source', + filter: ['!', ['has', 'point_count']], + layout: { + 'text-field': '{label}', + 'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'], + 'text-size': 10, + 'text-allow-overlap': true, + 'text-ignore-placement': true + }, + paint: { + 'text-color': '#fff' + } + }); + } + + // Click Handlers + const handleClusterClick = async (e: maplibregl.MapMouseEvent) => { + const features = map.queryRenderedFeatures(e.point, { layers: ['clusters'] }); + if (!features.length) return; + + const clusterId = features[0].properties.cluster_id; + const source = map.getSource('locations-source') as maplibregl.GeoJSONSource; + + try { + // MapLibre v5+ getClusterExpansionZoom returns a Promise and doesn't take a callback + const zoom = await (source as any).getClusterExpansionZoom(clusterId); + map.easeTo({ + center: (features[0].geometry as any).coordinates, + zoom: zoom + }); + } catch (err) { + console.error('Error expanding cluster', err); + } + }; + + const handlePointClick = (e: maplibregl.MapMouseEvent) => { + const features = map.queryRenderedFeatures(e.point, { layers: ['unclustered-point-circle'] }); + if (features.length > 0) { + const placeId = features[0].properties.place_id; + const competitor = competitors.find(c => c.place_id === placeId); + if (competitor) { + onSelectRef.current(competitor); + map.easeTo({ center: (features[0].geometry as any).coordinates }); + } + } + }; + + map.on('click', 'clusters', handleClusterClick); + map.on('click', 'unclustered-point-circle', handlePointClick); + map.on('mouseenter', 'clusters', () => map.getCanvas().style.cursor = 'pointer'); + map.on('mouseenter', 'unclustered-point-circle', () => map.getCanvas().style.cursor = 'pointer'); + map.on('mouseleave', 'clusters', () => map.getCanvas().style.cursor = ''); + map.on('mouseleave', 'unclustered-point-circle', () => map.getCanvas().style.cursor = ''); + + return () => { + map.off('click', 'clusters', handleClusterClick); + map.off('click', 'unclustered-point-circle', handlePointClick); + }; + }; + + if (map.getStyle()) setupLayers(); + map.on('styledata', setupLayers); + map.on('style.load', setupLayers); + + return () => { + map.off('styledata', setupLayers); + map.off('style.load', setupLayers); + if (map.getSource('locations-source')) { + if (map.getLayer('clusters')) map.removeLayer('clusters'); + if (map.getLayer('cluster-count')) map.removeLayer('cluster-count'); + if (map.getLayer('unclustered-point-circle')) map.removeLayer('unclustered-point-circle'); + if (map.getLayer('unclustered-point-label')) map.removeLayer('unclustered-point-label'); + map.removeSource('locations-source'); + } + }; + }, [map, competitors]); + + // Update data when it changes + useEffect(() => { + if (!map) return; + const source = map.getSource('locations-source') as maplibregl.GeoJSONSource; + if (source) { + source.setData(data); + } + }, [map, data]); + + // Update style-dependent properties + useEffect(() => { + if (!map) return; + if (map.getLayer('unclustered-point-circle')) { + map.setPaintProperty('unclustered-point-circle', 'circle-color', [ + 'case', + ['get', 'isSelected'], + '#f59e0b', + isDarkStyle ? '#4f46e5' : '#4f46e5' + ]); + } + }, [map, isDarkStyle]); + + return null; +}