import HistoricalMarkers from '@/components/map/HistoricalMarkers.tsx'; import MapControls from '@/components/map/MapControls.tsx'; import OrganizationCenterHandler from '@/components/map/OrganizationCenterHandler.tsx'; import OrganizationMarkers from '@/components/map/OrganizationMarkers.tsx'; import ProductServiceMarkers from '@/components/map/ProductServiceMarkers.tsx'; import SymbiosisLines from '@/components/map/SymbiosisLines.tsx'; import { useMapFilter, useMapInteraction, useMapUI, useMapViewport } from '@/contexts/MapContexts.tsx'; import bugulmaGeo from '@/data/bugulmaGeometry.json'; import { useMapData } from '@/hooks/map/useMapData.ts'; import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; import { useCallback, useEffect, useRef } from 'react'; import { GeoJSON, MapContainer, TileLayer, useMap, useMapEvents } from 'react-leaflet'; import MarkerClusterGroup from 'react-leaflet-markercluster'; import 'react-leaflet-markercluster/styles'; // 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', }); /** * Component to handle map resize when sidebar opens/closes * Leaflet needs to recalculate its size when container dimensions change */ 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); // Slightly longer than CSS transition (300ms) return () => { if (resizeTimeoutRef.current) { clearTimeout(resizeTimeoutRef.current); } }; }, [map, isSidebarOpen]); return null; }; /** * Component to sync Leaflet map view with context state * Optimized to prevent infinite loops and unnecessary updates */ 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 (only when explicitly changed, not from map events) useEffect(() => { // Prevent updates during user interaction 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); // Only update if difference is significant (prevents micro-updates) const shouldUpdate = zoomDiff > 0.1 || centerDiff > 0.0001 || !lastUpdateRef.current; if (shouldUpdate) { // Check if this update would cause a loop 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 }; // Reset flag after animation completes const timeoutId = setTimeout(() => { isUpdatingRef.current = false; }, 300); return () => { clearTimeout(timeoutId); }; } }, [map, mapCenter, zoom]); // Sync map events to context (throttled to avoid excessive updates) useMapEvents({ moveend: () => { if (isUpdatingRef.current) return; const center = map.getCenter(); const newCenter: [number, number] = [center.lat, center.lng]; // Only update if center actually changed if ( !lastUpdateRef.current || Math.abs(lastUpdateRef.current.center[0] - newCenter[0]) > 0.0001 || Math.abs(lastUpdateRef.current.center[1] - newCenter[1]) > 0.0001 ) { setMapCenter(newCenter); } }, zoomend: () => { if (isUpdatingRef.current) return; const newZoom = map.getZoom(); // Only update if zoom actually changed if (!lastUpdateRef.current || Math.abs(lastUpdateRef.current.zoom - newZoom) > 0.1) { setZoom(newZoom); } }, }); return null; }; /** * LeafletMap Component * * Renders an interactive map of Bugulma using Leaflet. * Features: * - Real city geometry from OSM Relation 9684457 * - Organization and historical landmark markers with clustering * - AI-powered symbiosis connection visualization * - Zoom and pan controls */ const LeafletMap = () => { const { mapCenter, zoom } = useMapViewport(); const { selectedOrg, hoveredOrgId, selectedLandmark, hoveredLandmarkId, symbiosisResult } = useMapInteraction(); const { historicalLandmarks } = useMapData(); const { filteredAndSortedOrgs } = useMapFilter(); const { mapViewMode, showProductsServices } = useMapUI(); // GeoJSON style function - show only edges, no fill to avoid darkening the area const geoJsonStyle = { fillColor: 'transparent', fillOpacity: 0, color: 'hsl(var(--border))', weight: 1.5, opacity: 0.8, }; // Optimize map container with performance settings const whenCreated = useCallback((mapInstance: L.Map) => { // Enable preferCanvas for better performance with many markers mapInstance.options.preferCanvas = true; // Optimize rendering mapInstance.options.fadeAnimation = true; mapInstance.options.zoomAnimation = true; mapInstance.options.zoomAnimationThreshold = 4; // Remove Leaflet attribution control (removes flag and "Leaflet" branding) mapInstance.attributionControl.remove(); }, []); return ( {/* Map tile layer */} {/* City boundary GeoJSON */} {bugulmaGeo && ( [0]['data']} style={geoJsonStyle} onEachFeature={(feature, layer) => { // Optional: Add feature interaction handlers here if needed layer.on({ mouseover: () => { layer.setStyle({ weight: 2, opacity: 1 }); }, mouseout: () => { layer.setStyle({ weight: 1.5, opacity: 0.8 }); }, }); }} /> )} {/* Symbiosis connection lines */} {mapViewMode === 'organizations' && symbiosisResult && selectedOrg && ( )} {/* Organization markers with clustering */} {mapViewMode === 'organizations' && ( { const count = cluster.getChildCount(); const size = count < 10 ? 'small' : count < 100 ? 'medium' : 'large'; const className = `marker-cluster marker-cluster-${size}`; return L.divIcon({ html: `
${count}
`, className, iconSize: L.point(40, 40), }); }} >
)} {/* Historical landmark markers */} {mapViewMode === 'historical' && ( )} {/* Products and Services markers */} {mapViewMode === 'organizations' && showProductsServices && ( { const count = cluster.getChildCount(); const size = count < 10 ? 'small' : count < 100 ? 'medium' : 'large'; const className = `marker-cluster marker-cluster-${size}`; return L.divIcon({ html: `
${count}
`, className, iconSize: L.point(40, 40), }); }} >
)} {/* Map controls */}
); }; export default LeafletMap;