333 lines
14 KiB
JavaScript
333 lines
14 KiB
JavaScript
import React, { useEffect, useState } from 'react'
|
|
import { MapContainer, TileLayer, CircleMarker, Popup, Tooltip } from 'react-leaflet'
|
|
// import MarkerClusterGroup from 'react-leaflet-cluster'
|
|
import L from 'leaflet'
|
|
import 'leaflet/dist/leaflet.css'
|
|
import './MapStyles.css'
|
|
import { MapPin, TrendingUp, DollarSign, Building2 } from 'lucide-react'
|
|
|
|
// Fix for default markers in react-leaflet
|
|
delete L.Icon.Default.prototype._getIconUrl
|
|
L.Icon.Default.mergeOptions({
|
|
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png',
|
|
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png',
|
|
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png',
|
|
})
|
|
|
|
const GeographicHeatMap = ({
|
|
data = [],
|
|
height = '500px',
|
|
onAreaClick,
|
|
showClusters = true,
|
|
colorScheme = 'viridis'
|
|
}) => {
|
|
const [mapCenter, setMapCenter] = useState([25.2048, 55.2708]) // Dubai coordinates
|
|
const [mapZoom, setMapZoom] = useState(11)
|
|
|
|
|
|
// Handle case where data might be an object with a results property
|
|
const actualData = data && typeof data === 'object' && !Array.isArray(data) && data.results
|
|
? data.results
|
|
: Array.isArray(data)
|
|
? data
|
|
: []
|
|
|
|
// Dubai area coordinates mapping
|
|
const areaCoordinates = {
|
|
'Downtown Dubai': [25.1972, 55.2744],
|
|
'Dubai Marina': [25.0772, 55.1308],
|
|
'Jumeirah': [25.2100, 55.2600],
|
|
'Palm Jumeirah': [25.1124, 55.1390],
|
|
'Business Bay': [25.1881, 55.2653],
|
|
'DIFC': [25.2138, 55.2792],
|
|
'JBR': [25.0772, 55.1308],
|
|
'Dubai Hills': [25.1500, 55.3000],
|
|
'Arabian Ranches': [25.1000, 55.2000],
|
|
'Jumeirah Village': [25.1500, 55.2500],
|
|
'Dubai Silicon Oasis': [25.1167, 55.3833],
|
|
'Dubai Sports City': [25.0500, 55.2000],
|
|
'Dubai Investment Park': [25.0167, 55.2000],
|
|
'International City': [25.1333, 55.4000],
|
|
'Discovery Gardens': [25.0167, 55.2000],
|
|
'Jumeirah Lake Towers': [25.0667, 55.1500],
|
|
'Dubai Healthcare City': [25.2333, 55.3000],
|
|
'Dubai International Financial Centre': [25.2138, 55.2792],
|
|
'Dubai Creek Harbour': [25.2000, 55.3500],
|
|
'Dubai Hills Estate': [25.1500, 55.3000],
|
|
}
|
|
|
|
// Color schemes for different metrics
|
|
const colorSchemes = {
|
|
viridis: ['#440154', '#482777', '#3f4a8a', '#31678e', '#26838f', '#1f9d8a', '#6cce5a', '#b6de2b', '#fee825'],
|
|
plasma: ['#0d0887', '#46039f', '#7201a8', '#9c179e', '#bd3786', '#d8576b', '#ed7953', '#fb9f3a', '#fdca26'],
|
|
inferno: ['#000004', '#1b0c42', '#4a0c6b', '#781c6d', '#a52c60', '#cf4446', '#ed6925', '#fb9a06', '#fcce25'],
|
|
magma: ['#000004', '#1d1147', '#51127c', '#822681', '#b63679', '#e65164', '#fb8861', '#fec287', '#fcfdbf'],
|
|
cool: ['#003f5c', '#2e4a62', '#4d5568', '#6c606e', '#8b6b74', '#aa767a', '#c98180', '#e88c86', '#ff9a8c'],
|
|
hot: ['#000000', '#330000', '#660000', '#990000', '#cc0000', '#ff0000', '#ff3300', '#ff6600', '#ff9900']
|
|
}
|
|
|
|
const getColorForValue = (value, maxValue, minValue = 0) => {
|
|
const colors = colorSchemes[colorScheme] || colorSchemes.viridis
|
|
const normalizedValue = (value - minValue) / (maxValue - minValue)
|
|
const colorIndex = Math.floor(normalizedValue * (colors.length - 1))
|
|
return colors[Math.max(0, Math.min(colorIndex, colors.length - 1))]
|
|
}
|
|
|
|
const getRadiusForValue = (value, maxValue, minValue = 0) => {
|
|
const minRadius = 5
|
|
const maxRadius = 25
|
|
const normalizedValue = (value - minValue) / (maxValue - minValue)
|
|
return minRadius + (normalizedValue * (maxRadius - minRadius))
|
|
}
|
|
|
|
// Process data to get min/max values for normalization
|
|
const processedData = Array.isArray(actualData) ? actualData.map(item => {
|
|
if (!item) return null
|
|
const coords = areaCoordinates[item.area_en] || [25.2048 + (Math.random() - 0.5) * 0.1, 55.2708 + (Math.random() - 0.5) * 0.1]
|
|
return {
|
|
...item,
|
|
coordinates: coords,
|
|
value: item.transaction_count || item.total_value || item.average_price || 0
|
|
}
|
|
}).filter(Boolean) : []
|
|
|
|
const maxValue = processedData.length > 0 ? Math.max(...processedData.map(item => item.value)) : 0
|
|
const minValue = processedData.length > 0 ? Math.min(...processedData.map(item => item.value)) : 0
|
|
|
|
const formatValue = (value, type = 'count') => {
|
|
if (type === 'price') {
|
|
return `AED ${typeof value === 'number' ? value.toLocaleString() : '0'}`
|
|
} else if (type === 'count') {
|
|
return typeof value === 'number' ? value.toLocaleString() : '0'
|
|
}
|
|
return value
|
|
}
|
|
|
|
const getMetricType = () => {
|
|
if (!actualData || !Array.isArray(actualData) || actualData.length === 0) return 'count'
|
|
const firstItem = actualData[0]
|
|
if (firstItem && firstItem.total_value) return 'price'
|
|
if (firstItem && firstItem.average_price) return 'price'
|
|
return 'count'
|
|
}
|
|
|
|
const metricType = getMetricType()
|
|
|
|
// Show loading state if data is not ready
|
|
if (!actualData || actualData === undefined) {
|
|
return (
|
|
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
|
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-2">
|
|
Geographic Heat Map
|
|
</h3>
|
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
|
Property activity distribution across Dubai areas
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div style={{ height }} className="flex items-center justify-center">
|
|
<div className="text-center">
|
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
|
<p className="text-gray-500 dark:text-gray-400">Loading map data...</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
|
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-2">
|
|
Geographic Heat Map
|
|
</h3>
|
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
|
Property activity distribution across Dubai areas
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center space-x-4">
|
|
<div className="flex items-center space-x-2">
|
|
<div className="w-3 h-3 rounded-full bg-blue-500"></div>
|
|
<span className="text-sm text-gray-600 dark:text-gray-400">Low Activity</span>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<div className="w-3 h-3 rounded-full bg-yellow-500"></div>
|
|
<span className="text-sm text-gray-600 dark:text-gray-400">Medium</span>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<div className="w-3 h-3 rounded-full bg-red-500"></div>
|
|
<span className="text-sm text-gray-600 dark:text-gray-400">High Activity</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ height }} className="relative">
|
|
{processedData.length > 0 ? (
|
|
<MapContainer
|
|
center={mapCenter}
|
|
zoom={mapZoom}
|
|
style={{ height: '100%', width: '100%' }}
|
|
className="z-0"
|
|
>
|
|
<TileLayer
|
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
|
/>
|
|
|
|
{/* Temporarily disabled clustering */}
|
|
{false && showClusters ? (
|
|
<MarkerClusterGroup
|
|
chunkedLoading
|
|
spiderfyOnMaxZoom
|
|
showCoverageOnHover
|
|
zoomToBoundsOnClick
|
|
maxClusterRadius={50}
|
|
style={{
|
|
fillColor: '#3b82f6',
|
|
color: '#1e40af',
|
|
weight: 2,
|
|
opacity: 1,
|
|
fillOpacity: 0.7
|
|
}}
|
|
>
|
|
{processedData.map((item, index) => (
|
|
<CircleMarker
|
|
key={index}
|
|
center={item.coordinates}
|
|
radius={getRadiusForValue(item.value, maxValue, minValue)}
|
|
pathOptions={{
|
|
fillColor: getColorForValue(item.value, maxValue, minValue),
|
|
color: '#ffffff',
|
|
weight: 2,
|
|
opacity: 0.8,
|
|
fillOpacity: 0.7
|
|
}}
|
|
eventHandlers={{
|
|
click: () => onAreaClick && onAreaClick(item)
|
|
}}
|
|
>
|
|
<Popup>
|
|
<div className="p-2 min-w-[200px]">
|
|
<div className="flex items-center space-x-2 mb-2">
|
|
<MapPin className="h-4 w-4 text-blue-500" />
|
|
<h4 className="font-semibold text-gray-900">{item.area_en}</h4>
|
|
</div>
|
|
<div className="space-y-1 text-sm">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-gray-600">Transactions:</span>
|
|
<span className="font-medium">{formatValue(item.transaction_count || 0, 'count')}</span>
|
|
</div>
|
|
{item.total_value && (
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-gray-600">Total Value:</span>
|
|
<span className="font-medium">{formatValue(item.total_value, 'price')}</span>
|
|
</div>
|
|
)}
|
|
{item.average_price && (
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-gray-600">Avg Price:</span>
|
|
<span className="font-medium">{formatValue(item.average_price, 'price')}</span>
|
|
</div>
|
|
)}
|
|
{item.price_per_sqft && (
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-gray-600">Price/Sqft:</span>
|
|
<span className="font-medium">AED {typeof item.price_per_sqft === 'number' ? item.price_per_sqft.toFixed(2) : '0'}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Popup>
|
|
<Tooltip direction="top" offset={[0, -10]} opacity={1}>
|
|
<div className="text-center">
|
|
<div className="font-semibold">{item.area_en}</div>
|
|
<div className="text-sm">
|
|
{formatValue(item.value, metricType)}
|
|
</div>
|
|
</div>
|
|
</Tooltip>
|
|
</CircleMarker>
|
|
))}
|
|
</MarkerClusterGroup>
|
|
) : (
|
|
processedData.map((item, index) => (
|
|
<CircleMarker
|
|
key={index}
|
|
center={item.coordinates}
|
|
radius={getRadiusForValue(item.value, maxValue, minValue)}
|
|
pathOptions={{
|
|
fillColor: getColorForValue(item.value, maxValue, minValue),
|
|
color: '#ffffff',
|
|
weight: 2,
|
|
opacity: 0.8,
|
|
fillOpacity: 0.7
|
|
}}
|
|
eventHandlers={{
|
|
click: () => onAreaClick && onAreaClick(item)
|
|
}}
|
|
>
|
|
<Popup>
|
|
<div className="p-2 min-w-[200px]">
|
|
<div className="flex items-center space-x-2 mb-2">
|
|
<MapPin className="h-4 w-4 text-blue-500" />
|
|
<h4 className="font-semibold text-gray-900">{item.area_en}</h4>
|
|
</div>
|
|
<div className="space-y-1 text-sm">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-gray-600">Transactions:</span>
|
|
<span className="font-medium">{formatValue(item.transaction_count || 0, 'count')}</span>
|
|
</div>
|
|
{item.total_value && (
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-gray-600">Total Value:</span>
|
|
<span className="font-medium">{formatValue(item.total_value, 'price')}</span>
|
|
</div>
|
|
)}
|
|
{item.average_price && (
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-gray-600">Avg Price:</span>
|
|
<span className="font-medium">{formatValue(item.average_price, 'price')}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Popup>
|
|
</CircleMarker>
|
|
))
|
|
)}
|
|
</MapContainer>
|
|
) : (
|
|
<div className="flex items-center justify-center h-full">
|
|
<div className="text-center">
|
|
<MapPin className="h-16 w-16 text-gray-400 mx-auto mb-4" />
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
|
No Geographic Data
|
|
</h3>
|
|
<p className="text-gray-500 dark:text-gray-400">
|
|
{!Array.isArray(actualData)
|
|
? 'Data format error - expected array but received: ' + typeof actualData
|
|
: 'No location data available for the selected filters'
|
|
}
|
|
</p>
|
|
{!Array.isArray(actualData) && (
|
|
<p className="text-xs text-gray-400 mt-2">
|
|
Debug: {JSON.stringify(actualData)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default GeographicHeatMap
|