'use client'; /* eslint-disable import-x/no-named-as-default-member */ import mapboxgl from 'mapbox-gl'; import * as React from 'react'; import 'mapbox-gl/dist/mapbox-gl.css'; import { MAPBOX_STYLE_DARK, useMapboxStyle } from '@/lib/mapbox-style'; export interface HeatmapPoint { district: string; avgPriceM2: number; totalListings: number; medianPrice: string; } interface DistrictHeatmapProps { data: HeatmapPoint[]; city: string; className?: string; onDistrictClick?: (district: string) => void; } /** Approximate centroids for major districts. Fallback spreads unknown districts around city center. */ const DISTRICT_COORDS: Record> = { 'Ho Chi Minh': { 'Quan 1': [106.6985, 10.7756], 'Quan 2': [106.7518, 10.7870], 'Quan 3': [106.6870, 10.7830], 'Quan 4': [106.7040, 10.7580], 'Quan 5': [106.6600, 10.7540], 'Quan 6': [106.6350, 10.7480], 'Quan 7': [106.7220, 10.7340], 'Quan 8': [106.6280, 10.7380], 'Quan 9': [106.8260, 10.8480], 'Quan 10': [106.6680, 10.7720], 'Quan 11': [106.6500, 10.7620], 'Quan 12': [106.6420, 10.8670], 'Binh Thanh': [106.7130, 10.8070], 'Phu Nhuan': [106.6800, 10.7990], 'Go Vap': [106.6540, 10.8370], 'Tan Binh': [106.6530, 10.8010], 'Tan Phu': [106.6280, 10.7920], 'Thu Duc': [106.7630, 10.8560], 'Binh Tan': [106.5920, 10.7650], 'Nha Be': [106.7300, 10.6940], 'Can Gio': [106.9530, 10.4110], 'Hoc Mon': [106.5920, 10.8860], 'Cu Chi': [106.4930, 10.9730], 'Binh Chanh': [106.5420, 10.7350], }, 'Ha Noi': { 'Hoan Kiem': [105.8544, 21.0285], 'Ba Dinh': [105.8193, 21.0340], 'Dong Da': [105.8304, 21.0168], 'Hai Ba Trung': [105.8634, 21.0120], 'Cau Giay': [105.7968, 21.0340], 'Thanh Xuan': [105.8100, 21.0000], 'Tay Ho': [105.8180, 21.0720], 'Long Bien': [105.8890, 21.0450], 'Nam Tu Liem': [105.7550, 21.0180], 'Bac Tu Liem': [105.7660, 21.0520], 'Ha Dong': [105.7530, 20.9700], 'Hoang Mai': [105.8620, 20.9800], }, 'Da Nang': { 'Hai Chau': [108.2180, 16.0680], 'Thanh Khe': [108.1850, 16.0670], 'Son Tra': [108.2540, 16.1010], 'Ngu Hanh Son': [108.2530, 16.0190], 'Lien Chieu': [108.1440, 16.0820], 'Cam Le': [108.2080, 16.0230], }, }; const CITY_CENTER: Record = { 'Ho Chi Minh': [106.6600, 10.7900], 'Ha Noi': [105.8342, 21.0278], 'Da Nang': [108.2022, 16.0544], }; function getCoord(city: string, district: string, index: number): [number, number] { const cityCoords = DISTRICT_COORDS[city]; if (cityCoords?.[district]) return cityCoords[district]; const center = CITY_CENTER[city] ?? [106.66, 10.79]; // Spread unknowns in a ring around center const angle = (index * 137.5 * Math.PI) / 180; const r = 0.015 + index * 0.003; return [center[0] + Math.cos(angle) * r, center[1] + Math.sin(angle) * r]; } function priceColor(ratio: number): string { // 0 = green/cheap, 1 = red/expensive const h = 120 - ratio * 120; // 120 (green) -> 0 (red) return `hsl(${h}, 75%, 50%)`; } export function DistrictHeatmap({ data, city, className, onDistrictClick }: DistrictHeatmapProps) { const mapContainerRef = React.useRef(null); const mapRef = React.useRef(null); const markersRef = React.useRef([]); const themeStyle = useMapboxStyle(); // Heatmap uses the lighter "light-v11" backdrop for better marker contrast in // light mode; in dark mode, fall back to the shared dark style. const mapStyle = themeStyle === MAPBOX_STYLE_DARK ? MAPBOX_STYLE_DARK : 'mapbox://styles/mapbox/light-v11'; const maxPrice = React.useMemo(() => Math.max(...data.map((d) => d.avgPriceM2), 1), [data]); React.useEffect(() => { if (!mapContainerRef.current) return; const token = process.env['NEXT_PUBLIC_MAPBOX_TOKEN']; if (!token) return; mapboxgl.accessToken = token; const center = CITY_CENTER[city] ?? [106.66, 10.79]; const map = new mapboxgl.Map({ container: mapContainerRef.current, style: mapStyle, center: center as [number, number], zoom: 11, attributionControl: false, }); map.addControl(new mapboxgl.NavigationControl(), 'top-right'); mapRef.current = map; return () => { map.remove(); mapRef.current = null; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [city]); // Sync style changes with theme. The heatmap "layer" is implemented as DOM // markers (re-added by the effect below when data changes), so we also need // to re-add markers after the style finishes loading in case the style swap // raced with a data update. React.useEffect(() => { const map = mapRef.current; if (!map) return; map.setStyle(mapStyle); }, [mapStyle]); // Update markers React.useEffect(() => { const map = mapRef.current; if (!map) return; markersRef.current.forEach((m) => m.remove()); markersRef.current = []; if (data.length === 0) return; const bounds = new mapboxgl.LngLatBounds(); data.forEach((point, i) => { const coord = getCoord(city, point.district, i); const ratio = point.avgPriceM2 / maxPrice; const size = 36 + ratio * 28; // 36px to 64px // Mapbox writes `transform: translate(...)` on the marker element; // hover-scaling the outer element clobbers it. Scale an inner div // instead. const el = document.createElement('button'); el.style.cssText = `width: ${size}px; height: ${size}px; padding: 0; border: none; background: transparent; cursor: pointer;`; const inner = document.createElement('div'); inner.style.cssText = ` width: 100%; height: 100%; border-radius: 50%; border: 2px solid white; background: ${priceColor(ratio)}; opacity: 0.8; display: flex; align-items: center; justify-content: center; font-size: 10px; font-weight: 700; color: white; text-shadow: 0 1px 2px rgba(0,0,0,0.5); box-shadow: 0 2px 6px rgba(0,0,0,0.3); transition: transform 0.15s, opacity 0.15s; transform: scale(1); padding: 2px; line-height: 1.1; text-align: center; pointer-events: none; `; inner.textContent = point.district.replace(/^Quan\s*/i, 'Q.').replace(/^Huyen\s*/i, 'H.'); el.appendChild(inner); el.addEventListener('mouseenter', () => { inner.style.opacity = '1'; inner.style.transform = 'scale(1.15)'; }); el.addEventListener('mouseleave', () => { inner.style.opacity = '0.8'; inner.style.transform = 'scale(1)'; }); el.addEventListener('click', (e) => { e.stopPropagation(); onDistrictClick?.(point.district); }); const priceLabel = point.avgPriceM2 >= 1_000_000 ? `${(point.avgPriceM2 / 1_000_000).toFixed(1)} tr/m²` : `${Math.round(point.avgPriceM2 / 1000)}k/m²`; const popup = new mapboxgl.Popup({ offset: 15, closeButton: false }) .setHTML(`
${point.district}
${priceLabel}
${point.totalListings} tin đăng
`); const marker = new mapboxgl.Marker({ element: el, anchor: 'center' }) .setLngLat(coord) .setPopup(popup) .addTo(map); markersRef.current.push(marker); bounds.extend(coord); }); if (data.length > 1) { map.fitBounds(bounds, { padding: 50, maxZoom: 13 }); } }, [data, city, maxPrice, onDistrictClick]); const hasToken = typeof process !== 'undefined' && process.env['NEXT_PUBLIC_MAPBOX_TOKEN']; return (
{!hasToken && (

Thiết lập NEXT_PUBLIC_MAPBOX_TOKEN để hiển thị bản đồ nhiệt

)} {/* Legend */}
Giá trung bình/m²
Thấp
Cao
); }