Spaces:
Sleeping
Sleeping
| "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: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>', | |
| mobile: true // Good for mobile | |
| }, | |
| osm: { | |
| name: "OpenStreetMap", | |
| url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", | |
| attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors', | |
| mobile: true | |
| }, | |
| cartoDark: { | |
| name: "CartoDB Dark", | |
| url: "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", | |
| attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>', | |
| 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<L.Map | null>(null) | |
| const mapContainerRef = useRef<HTMLDivElement>(null) | |
| const markersRef = useRef<L.Marker[]>([]) | |
| const tileLayerRef = useRef<L.TileLayer | null>(null) | |
| const markerClusterRef = useRef<L.MarkerClusterGroup | null>(null) | |
| const [currentTileProvider, setCurrentTileProvider] = useState<keyof typeof tileProviders>('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: ` | |
| <div style=" | |
| background: ${color}; | |
| color: white; | |
| padding: ${isMobile ? '3px 6px' : '4px 8px'}; | |
| border-radius: ${isMobile ? '8px' : '12px'}; | |
| font-size: ${fontSize}; | |
| font-weight: 600; | |
| text-align: center; | |
| box-shadow: 0 2px 4px rgba(0,0,0,0.2); | |
| white-space: nowrap; | |
| border: 2px solid white; | |
| transform: scale(${isSelected ? scale * 1.1 : scale}); | |
| transition: transform 0.2s ease; | |
| cursor: pointer; | |
| "> | |
| ${priceText} | |
| </div> | |
| `, | |
| 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 ( | |
| <div className={`relative w-full h-full ${className}`}> | |
| <div | |
| ref={mapContainerRef} | |
| className="w-full h-full" | |
| style={{ minHeight: isMobile ? '300px' : '400px' }} | |
| /> | |
| {/* Mobile-optimized style switcher */} | |
| <div className={`absolute ${isMobile ? 'top-2 right-2' : 'top-4 right-4'} z-[1000]`}> | |
| <select | |
| value={currentTileProvider} | |
| onChange={(e) => 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]) => ( | |
| <option key={key} value={key}> | |
| {isMobile ? provider.name.split(' ')[0] : provider.name} | |
| </option> | |
| ))} | |
| </select> | |
| </div> | |
| {/* Loading indicator */} | |
| {!mapReady && ( | |
| <div className="absolute inset-0 bg-gray-100 flex items-center justify-center z-[999]"> | |
| <div className="text-center"> | |
| <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-2"></div> | |
| <p className="text-sm text-gray-600">Loading map...</p> | |
| </div> | |
| </div> | |
| )} | |
| <style jsx global>{` | |
| .custom-property-marker { | |
| background: transparent ; | |
| border: none ; | |
| } | |
| /* Mobile-specific map optimizations */ | |
| @media (max-width: 768px) { | |
| .leaflet-container { | |
| font-size: 12px; | |
| } | |
| .leaflet-control-attribution { | |
| font-size: 10px; | |
| background: rgba(255, 255, 255, 0.8) ; | |
| } | |
| .leaflet-control-zoom a { | |
| width: 32px ; | |
| height: 32px ; | |
| line-height: 32px ; | |
| } | |
| .custom-property-marker { | |
| touch-action: manipulation; | |
| } | |
| } | |
| `}</style> | |
| </div> | |
| ) | |
| } |