map clusters

This commit is contained in:
lovebird 2026-03-30 19:51:53 +02:00
parent 6f89453225
commit b15592fa11
2 changed files with 253 additions and 65 deletions

View File

@ -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<CompetitorsMapViewProps> = ({ competit
return { type: 'FeatureCollection' as const, features: pickerPolygons.flatMap(fc => fc?.features || []) };
}, [pickerPolygons]);
const markersRef = useRef<maplibregl.Marker[]>([]);
const markersRef = useRef<maplibregl.Marker[]>([]); // 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<CompetitorsMapViewProps> = ({ competit
const lastFittedLocationIdsRef = useRef<string | null>(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<CompetitorsMapViewProps> = ({ 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<CompetitorsMapViewProps> = ({ competit
liveRadii={liveRadii}
liveNodes={liveNodes}
/>
<LocationLayers
map={map.current}
competitors={validLocations}
isDarkStyle={mapStyleKey === 'dark'}
onSelect={(loc) => {
setSelectedLocation(loc);
setInfoPanelOpen(false);
}}
selectedId={selectedLocation?.place_id}
/>
{isPosterMode && (
<MapPosterOverlay
map={map.current}

View File

@ -0,0 +1,238 @@
import { useEffect, useMemo, useRef } from 'react';
import maplibregl from 'maplibre-gl';
import { type CompetitorFull } from '@polymech/shared';
export interface LocationLayersProps {
map: maplibregl.Map | null;
competitors: (CompetitorFull & { lat: number; lon: number; label: string })[];
isDarkStyle: boolean;
onSelect: (competitor: CompetitorFull) => 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;
}