simpse / src /components /OptimizedLeafletMap.tsx
canboi99's picture
Deploy Simpse real estate platform with Vancouver multifamily building interactive map
0d2425e verified
Raw
History Blame Contribute Delete
11.7 kB
"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 !important;
border: none !important;
}
/* 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) !important;
}
.leaflet-control-zoom a {
width: 32px !important;
height: 32px !important;
line-height: 32px !important;
}
.custom-property-marker {
touch-action: manipulation;
}
}
`}</style>
</div>
)
}