"use client" import { useEffect, useRef, useState, useCallback, useMemo } from "react" import L from "leaflet" import { debounce, throttle } from "lodash" // Optimized imports - only import CSS once import "leaflet/dist/leaflet.css" // Memoized tile providers configuration const tileProviders = { carto: { name: "CartoDB Light", url: "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png", attribution: '© OpenStreetMap contributors © CARTO', mobile: true // Good for mobile }, osm: { name: "OpenStreetMap", url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", attribution: '© OpenStreetMap contributors', mobile: true }, cartoDark: { name: "CartoDB Dark", url: "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", attribution: '© OpenStreetMap contributors © CARTO', mobile: true } } as const // Optimized property interface interface Property { id: number coordinates: [number, number] price: number type: string bedrooms: number bathrooms: number sqft: number address: string status: 'active' | 'pending' | 'sold' } interface OptimizedLeafletMapProps { properties: Property[] onPropertySelect: (property: Property | null) => void selectedProperty: Property | null className?: string } // Memoized currency formatter const formatCurrency = (() => { const formatter = new Intl.NumberFormat('en-CA', { style: 'currency', currency: 'CAD', minimumFractionDigits: 0, maximumFractionDigits: 0, notation: 'compact' // Use compact notation on mobile }) return (value: number, compact = false) => { if (compact && value >= 1000000) { return formatter.format(value).replace('CAD', '').replace('$', '$').trim() } return formatter.format(value).replace('CAD', '').replace('$', '$').trim() } })() // Optimized marker colors const markerColors = { active: '#10B981', pending: '#F59E0B', sold: '#EF4444' } as const export default function OptimizedLeafletMap({ properties, onPropertySelect, selectedProperty, className = "" }: OptimizedLeafletMapProps) { const mapRef = useRef(null) const mapContainerRef = useRef(null) const markersRef = useRef([]) const tileLayerRef = useRef(null) const markerClusterRef = useRef(null) const [currentTileProvider, setCurrentTileProvider] = useState('carto') const [isMobile, setIsMobile] = useState(false) const [mapReady, setMapReady] = useState(false) // Detect mobile device useEffect(() => { const checkMobile = () => { setIsMobile(window.innerWidth < 768 || 'ontouchstart' in window) } checkMobile() const debouncedResize = debounce(checkMobile, 150) window.addEventListener('resize', debouncedResize) return () => window.removeEventListener('resize', debouncedResize) }, []) // Memoized icon creation with mobile optimizations const createOptimizedIcon = useCallback((property: Property, isSelected = false) => { const color = markerColors[property.status] || '#3B82F6' const priceText = formatCurrency(property.price, isMobile) const scale = isMobile ? 0.9 : 1 const fontSize = isMobile ? '11px' : '12px' return L.divIcon({ html: ` ${priceText} `, className: 'custom-property-marker', iconSize: isMobile ? [100, 24] : [120, 30], iconAnchor: isMobile ? [50, 24] : [60, 30] }) }, [isMobile]) // Throttled property selection handler const handlePropertyClick = useCallback( throttle((property: Property) => { onPropertySelect(property) if (mapRef.current) { mapRef.current.setView(property.coordinates, isMobile ? 14 : 12, { animate: true, duration: isMobile ? 0.5 : 1 }) } }, 300), [onPropertySelect, isMobile] ) // Optimized marker management const updateMarkers = useCallback(() => { if (!mapRef.current || !mapReady) return // Clear existing markers efficiently markersRef.current.forEach(marker => marker.remove()) markersRef.current = [] // Add new markers with batching for performance const batch = isMobile ? 10 : 20 // Smaller batches on mobile let index = 0 const addBatch = () => { const endIndex = Math.min(index + batch, properties.length) for (let i = index; i < endIndex; i++) { const property = properties[i] const isSelected = selectedProperty?.id === property.id const marker = L.marker(property.coordinates, { icon: createOptimizedIcon(property, isSelected), // Add riseOnHover for better UX on desktop riseOnHover: !isMobile }).addTo(mapRef.current!) marker.on('click', () => handlePropertyClick(property)) // Add touch events for mobile if (isMobile) { marker.on('touchstart', (e) => { e.originalEvent.preventDefault() handlePropertyClick(property) }) } markersRef.current.push(marker) } index = endIndex // Continue with next batch if there are more properties if (index < properties.length) { requestAnimationFrame(addBatch) } } addBatch() }, [properties, selectedProperty, createOptimizedIcon, handlePropertyClick, mapReady, isMobile]) // Initialize map with mobile optimizations useEffect(() => { if (!mapContainerRef.current || mapRef.current) return // Mobile-optimized map options const mapOptions: L.MapOptions = { center: [56.1304, -96.0000], zoom: isMobile ? 3 : 4, zoomControl: !isMobile, // Hide zoom controls on mobile to save space attributionControl: !isMobile, preferCanvas: true, // Better performance for markers // Mobile touch optimizations tap: isMobile, touchZoom: isMobile, bounceAtZoomLimits: false, maxBoundsViscosity: isMobile ? 0.1 : 1 } mapRef.current = L.map(mapContainerRef.current, mapOptions) // Add mobile-specific controls if (isMobile) { // Add minimal attribution L.control.attribution({ prefix: false, position: 'bottomright' }).addTo(mapRef.current) // Add mobile-friendly zoom control in better position L.control.zoom({ position: 'topright' }).addTo(mapRef.current) } // Add optimized tile layer const provider = tileProviders[currentTileProvider] tileLayerRef.current = L.tileLayer(provider.url, { attribution: provider.attribution, subdomains: 'abcd', maxZoom: 19, // Performance optimizations keepBuffer: isMobile ? 1 : 2, updateWhenIdle: isMobile, updateWhenZooming: !isMobile }).addTo(mapRef.current) // Performance optimization: defer marker loading slightly const timer = setTimeout(() => { setMapReady(true) }, 100) return () => { clearTimeout(timer) if (mapRef.current) { mapRef.current.remove() mapRef.current = null } markersRef.current = [] setMapReady(false) } }, [currentTileProvider, isMobile]) // Update markers when properties or selection changes useEffect(() => { updateMarkers() }, [updateMarkers]) // Handle tile provider changes efficiently const changeTileProvider = useCallback((newProvider: keyof typeof tileProviders) => { if (!mapRef.current || !tileLayerRef.current) return mapRef.current.removeLayer(tileLayerRef.current) const provider = tileProviders[newProvider] tileLayerRef.current = L.tileLayer(provider.url, { attribution: provider.attribution, subdomains: 'abcd', maxZoom: 19, keepBuffer: isMobile ? 1 : 2, updateWhenIdle: isMobile, updateWhenZooming: !isMobile }).addTo(mapRef.current) setCurrentTileProvider(newProvider) }, [isMobile]) // Public methods for parent component const flyToProperty = useCallback((coordinates: [number, number]) => { if (mapRef.current) { mapRef.current.setView(coordinates, isMobile ? 14 : 12, { animate: true, duration: isMobile ? 0.5 : 1 }) } }, [isMobile]) const resetView = useCallback(() => { if (mapRef.current) { mapRef.current.setView([56.1304, -96.0000], isMobile ? 3 : 4, { animate: true, duration: isMobile ? 0.5 : 1 }) } }, [isMobile]) return ( {/* Mobile-optimized style switcher */} changeTileProvider(e.target.value as keyof typeof tileProviders)} className={` px-2 py-1 bg-white border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 ${isMobile ? 'text-xs' : 'text-sm'} `} aria-label="Select map style" > {Object.entries(tileProviders).map(([key, provider]) => ( {isMobile ? provider.name.split(' ')[0] : provider.name} ))} {/* Loading indicator */} {!mapReady && ( Loading map... )} ) }
Loading map...