| "use client" |
|
|
| import { useEffect, useRef, useState, useCallback, useMemo } from "react" |
| import L from "leaflet" |
| import { debounce, throttle } from "lodash" |
|
|
| |
| import "leaflet/dist/leaflet.css" |
|
|
| |
| 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 |
| }, |
| 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 |
|
|
| |
| 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 |
| } |
|
|
| |
| const formatCurrency = (() => { |
| const formatter = new Intl.NumberFormat('en-CA', { |
| style: 'currency', |
| currency: 'CAD', |
| minimumFractionDigits: 0, |
| maximumFractionDigits: 0, |
| notation: 'compact' |
| }) |
| 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() |
| } |
| })() |
|
|
| |
| 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) |
|
|
| |
| 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) |
| }, []) |
|
|
| |
| 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]) |
|
|
| |
| 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] |
| ) |
|
|
| |
| const updateMarkers = useCallback(() => { |
| if (!mapRef.current || !mapReady) return |
|
|
| |
| markersRef.current.forEach(marker => marker.remove()) |
| markersRef.current = [] |
|
|
| |
| const batch = isMobile ? 10 : 20 |
| 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), |
| |
| riseOnHover: !isMobile |
| }).addTo(mapRef.current!) |
|
|
| marker.on('click', () => handlePropertyClick(property)) |
| |
| |
| if (isMobile) { |
| marker.on('touchstart', (e) => { |
| e.originalEvent.preventDefault() |
| handlePropertyClick(property) |
| }) |
| } |
|
|
| markersRef.current.push(marker) |
| } |
| |
| index = endIndex |
| |
| |
| if (index < properties.length) { |
| requestAnimationFrame(addBatch) |
| } |
| } |
|
|
| addBatch() |
| }, [properties, selectedProperty, createOptimizedIcon, handlePropertyClick, mapReady, isMobile]) |
|
|
| |
| useEffect(() => { |
| if (!mapContainerRef.current || mapRef.current) return |
|
|
| |
| const mapOptions: L.MapOptions = { |
| center: [56.1304, -96.0000], |
| zoom: isMobile ? 3 : 4, |
| zoomControl: !isMobile, |
| attributionControl: !isMobile, |
| preferCanvas: true, |
| |
| tap: isMobile, |
| touchZoom: isMobile, |
| bounceAtZoomLimits: false, |
| maxBoundsViscosity: isMobile ? 0.1 : 1 |
| } |
|
|
| mapRef.current = L.map(mapContainerRef.current, mapOptions) |
|
|
| |
| if (isMobile) { |
| |
| L.control.attribution({ |
| prefix: false, |
| position: 'bottomright' |
| }).addTo(mapRef.current) |
| |
| |
| L.control.zoom({ |
| position: 'topright' |
| }).addTo(mapRef.current) |
| } |
|
|
| |
| const provider = tileProviders[currentTileProvider] |
| tileLayerRef.current = L.tileLayer(provider.url, { |
| attribution: provider.attribution, |
| subdomains: 'abcd', |
| maxZoom: 19, |
| |
| keepBuffer: isMobile ? 1 : 2, |
| updateWhenIdle: isMobile, |
| updateWhenZooming: !isMobile |
| }).addTo(mapRef.current) |
|
|
| |
| const timer = setTimeout(() => { |
| setMapReady(true) |
| }, 100) |
|
|
| return () => { |
| clearTimeout(timer) |
| if (mapRef.current) { |
| mapRef.current.remove() |
| mapRef.current = null |
| } |
| markersRef.current = [] |
| setMapReady(false) |
| } |
| }, [currentTileProvider, isMobile]) |
|
|
| |
| useEffect(() => { |
| updateMarkers() |
| }, [updateMarkers]) |
|
|
| |
| 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]) |
|
|
| |
| 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 !important; |
| border: none !important; |
| } |
| |
| |
| @media (max-width: 768px) { |
| .leaflet-container { |
| font-size: 12px; |
| } |
| |
| .leaflet-control-attribution { |
| font-size: 10px; |
| background: rgba(255, 255, 255, 0.8) !important; |
| } |
| |
| .leaflet-control-zoom a { |
| width: 32px !important; |
| height: 32px !important; |
| line-height: 32px !important; |
| } |
| |
| .custom-property-marker { |
| touch-action: manipulation; |
| } |
| } |
| `}</style> |
| </div> |
| ) |
| } |