map clusters
This commit is contained in:
parent
6f89453225
commit
b15592fa11
@ -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}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user