import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; import React, { useCallback, useEffect, useRef } from 'react'; import { GeoJSON, MapContainer, TileLayer, useMap, useMapEvents } from 'react-leaflet'; import { useMapUI, useMapViewport } from '@/contexts/MapContexts.tsx'; import bugulmaGeo from '@/data/bugulmaGeometry.json'; import MapControls from '@/components/map/MapControls.tsx'; import MatchLines from '@/components/map/MatchLines.tsx'; import ResourceFlowMarkers from '@/components/map/ResourceFlowMarkers.tsx'; import type { BackendMatch } from '@/schemas/backend/match'; // Fix for default marker icon issue in Leaflet with webpack/vite delete (L.Icon.Default.prototype as unknown as { _getIconUrl?: unknown })._getIconUrl; L.Icon.Default.mergeOptions({ iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png', iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png', shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png', }); interface MatchesMapProps { matches: BackendMatch[]; selectedMatchId: string | null; onMatchSelect: (matchId: string) => void; } /** * Component to handle map resize when sidebar opens/closes */ const MapResizeHandler = () => { const map = useMap(); const { isSidebarOpen } = useMapUI(); const resizeTimeoutRef = useRef(null); useEffect(() => { // Clear any pending resize if (resizeTimeoutRef.current) { clearTimeout(resizeTimeoutRef.current); } // Delay resize to allow CSS transition to complete resizeTimeoutRef.current = setTimeout(() => { map.invalidateSize(); }, 350); return () => { if (resizeTimeoutRef.current) { clearTimeout(resizeTimeoutRef.current); } }; }, [map, isSidebarOpen]); return null; }; /** * Component to sync Leaflet map view with context state */ const MapSync = () => { const { mapCenter, zoom, setZoom, setMapCenter } = useMapViewport(); const map = useMap(); const isUpdatingRef = useRef(false); const lastUpdateRef = useRef<{ center: [number, number]; zoom: number } | null>(null); // Sync context state to map useEffect(() => { if (isUpdatingRef.current) return; const currentCenter = map.getCenter(); const centerDiff = Math.abs(currentCenter.lat - mapCenter[0]) + Math.abs(currentCenter.lng - mapCenter[1]); const zoomDiff = Math.abs(map.getZoom() - zoom); const shouldUpdate = zoomDiff > 0.1 || centerDiff > 0.0001 || !lastUpdateRef.current; if (shouldUpdate) { const lastUpdate = lastUpdateRef.current; if ( lastUpdate && lastUpdate.center[0] === mapCenter[0] && lastUpdate.center[1] === mapCenter[1] && lastUpdate.zoom === zoom ) { return; } isUpdatingRef.current = true; map.setView(mapCenter, zoom, { animate: true }); lastUpdateRef.current = { center: mapCenter, zoom }; const timeoutId = setTimeout(() => { isUpdatingRef.current = false; }, 300); return () => clearTimeout(timeoutId); } }, [map, mapCenter, zoom]); // Sync map state to context const mapEvents = useMapEvents({ moveend: () => { if (!isUpdatingRef.current) { const center = mapEvents.getCenter(); setMapCenter([center.lat, center.lng]); } }, zoomend: () => { if (!isUpdatingRef.current) { setZoom(mapEvents.getZoom()); } }, }); return null; }; const MatchesMap: React.FC = ({ matches, selectedMatchId, onMatchSelect }) => { const { mapCenter, zoom } = useMapViewport(); const whenCreated = useCallback((map: L.Map) => { // Fit bounds to Bugulma area on initial load if (bugulmaGeo) { const bounds = L.geoJSON(bugulmaGeo as GeoJSON.GeoJsonObject).getBounds(); map.fitBounds(bounds, { padding: [20, 20] }); } }, []); // GeoJSON styling for city boundary const geoJsonStyle = { color: 'hsl(var(--primary))', weight: 1.5, opacity: 0.8, fillOpacity: 0.1, }; return ( {/* Map tile layer */} {/* City boundary GeoJSON */} {bugulmaGeo && ( [0]['data']} style={geoJsonStyle} onEachFeature={(feature, layer) => { layer.on({ mouseover: () => { layer.setStyle({ weight: 2, opacity: 1 }); }, mouseout: () => { layer.setStyle({ weight: 1.5, opacity: 0.8 }); }, }); }} /> )} {/* Match connection lines */} {/* Resource flow markers */} {/* Map controls */} ); }; export default React.memo(MatchesMap);