saas-market-analysis-dubai/frontend/src/components/GeographicHeatMap.jsx

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='&copy; <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