'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 { Card, CardContent, CardDescription, CardHeader, CardTitle, } from '@/components/ui/card'; import { formatPrice, formatPricePerM2 } from '@/lib/currency'; import { useMapboxStyle } from '@/lib/mapbox-style'; import type { ValuationComparable } from '@/lib/valuation-api'; interface ComparablesMapProps { comparables: ValuationComparable[]; subjectLatitude?: number; subjectLongitude?: number; className?: string; } const DEFAULT_CENTER: [number, number] = [106.6297, 10.8231]; const DEFAULT_ZOOM = 11; function similarityColor(sim: number): string { if (sim >= 0.85) return '#16a34a'; if (sim >= 0.7) return '#eab308'; return '#dc2626'; } function escapeHtml(str: string): string { return str .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } export function ComparablesMap({ comparables, subjectLatitude, subjectLongitude, className, }: ComparablesMapProps) { const mapContainerRef = React.useRef(null); const mapRef = React.useRef(null); const markersRef = React.useRef([]); const mapStyle = useMapboxStyle(); const geoComparables = React.useMemo( () => comparables.filter( (c) => c.latitude != null && c.longitude != null, ), [comparables], ); React.useEffect(() => { if (!mapContainerRef.current) return; const token = process.env['NEXT_PUBLIC_MAPBOX_TOKEN']; if (!token) return; mapboxgl.accessToken = token; const map = new mapboxgl.Map({ container: mapContainerRef.current, style: mapStyle, center: DEFAULT_CENTER, zoom: DEFAULT_ZOOM, attributionControl: false, }); map.addControl(new mapboxgl.NavigationControl(), 'top-right'); map.addControl( new mapboxgl.AttributionControl({ compact: true }), 'bottom-right', ); mapRef.current = map; return () => { map.remove(); mapRef.current = null; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); React.useEffect(() => { const map = mapRef.current; if (!map) return; map.setStyle(mapStyle); }, [mapStyle]); React.useEffect(() => { const map = mapRef.current; if (!map) return; markersRef.current.forEach((m) => m.remove()); markersRef.current = []; const bounds = new mapboxgl.LngLatBounds(); let extended = false; if (subjectLatitude != null && subjectLongitude != null) { const subjectEl = document.createElement('div'); subjectEl.setAttribute('data-testid', 'comparables-map-subject'); subjectEl.style.cssText = ` width: 22px; height: 22px; border-radius: 50%; background: hsl(221.2, 83.2%, 53.3%); border: 3px solid white; box-shadow: 0 0 0 2px hsl(221.2, 83.2%, 53.3%), 0 2px 6px rgba(0,0,0,0.3); `; const marker = new mapboxgl.Marker({ element: subjectEl }) .setLngLat([subjectLongitude, subjectLatitude]) .addTo(map); markersRef.current.push(marker); bounds.extend([subjectLongitude, subjectLatitude]); extended = true; } geoComparables.forEach((comp) => { const color = similarityColor(comp.similarity); // Mapbox owns `transform: translate(...)` on the outer element; // apply hover scale to an inner wrapper to avoid clobbering it. const el = document.createElement('div'); el.setAttribute('data-testid', 'comparables-map-marker'); const inner = document.createElement('div'); inner.style.cssText = ` background: hsl(var(--card)); color: hsl(var(--card-foreground)); border-radius: 8px; padding: 4px 8px; font-size: 11px; font-weight: 600; box-shadow: 0 2px 6px rgba(0,0,0,0.15); white-space: nowrap; cursor: pointer; border-left: 3px solid ${color}; transition: transform 0.15s; transform: scale(1); max-width: 180px; overflow: hidden; text-overflow: ellipsis; `; inner.textContent = formatPrice(comp.priceVND); el.appendChild(inner); el.addEventListener('mouseenter', () => { inner.style.transform = 'scale(1.08)'; }); el.addEventListener('mouseleave', () => { inner.style.transform = 'scale(1)'; }); const popup = new mapboxgl.Popup({ offset: 15, maxWidth: '280px', closeButton: false, }).setHTML( `

${escapeHtml(comp.title)}

${escapeHtml(comp.address)}

${formatPrice(comp.priceVND)} VNĐ ${formatPricePerM2(comp.pricePerM2)}

${comp.areaM2} m² · Tương đồng ${Math.round(comp.similarity * 100)}%

`, ); const marker = new mapboxgl.Marker({ element: el, anchor: 'left' }) .setLngLat([comp.longitude!, comp.latitude!]) .setPopup(popup) .addTo(map); markersRef.current.push(marker); bounds.extend([comp.longitude!, comp.latitude!]); extended = true; }); if (!extended) return; if (!bounds.isEmpty() && markersRef.current.length > 1) { map.fitBounds(bounds, { padding: 60, maxZoom: 14 }); } else if (markersRef.current.length === 1) { const first = geoComparables[0] ?? { latitude: subjectLatitude, longitude: subjectLongitude, }; if (first.latitude != null && first.longitude != null) { map.flyTo({ center: [first.longitude, first.latitude], zoom: 14 }); } } }, [geoComparables, subjectLatitude, subjectLongitude]); const hasToken = typeof process !== 'undefined' && Boolean(process.env['NEXT_PUBLIC_MAPBOX_TOKEN']); const hasAnyGeo = geoComparables.length > 0 || (subjectLatitude != null && subjectLongitude != null); return ( Bản đồ so sánh Vị trí các bất động sản tương tự được sử dụng trong mô hình AVM
{!hasToken && (
Thiết lập NEXT_PUBLIC_MAPBOX_TOKEN để hiển thị bản đồ
)} {hasToken && !hasAnyGeo && (
Không có toạ độ cho các BĐS so sánh
)}
{geoComparables.length} BĐS so sánh
); }