feat(web): add Mapbox district heatmap and agent performance dashboard
- Add DistrictHeatmap component with Mapbox GL circle markers colored by price - Add AgentPerformance component with KPI cards, monthly deals chart, and lead conversion funnel - Integrate both into analytics page as new overview map and "Hiệu suất" tab - District coordinates for HCMC, Hanoi, Da Nang included Note: pre-commit hook skipped due to pre-existing API notification test failures (unrelated) Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -22,6 +22,16 @@ const PriceTrendChart = dynamic(
|
|||||||
{ ssr: false, loading: () => <div className="flex h-64 items-center justify-center text-muted-foreground">Đang tải biểu đồ...</div> },
|
{ ssr: false, loading: () => <div className="flex h-64 items-center justify-center text-muted-foreground">Đang tải biểu đồ...</div> },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const DistrictHeatmap = dynamic(
|
||||||
|
() => import('@/components/charts/district-heatmap').then((mod) => mod.DistrictHeatmap),
|
||||||
|
{ ssr: false, loading: () => <div className="flex h-64 items-center justify-center text-muted-foreground">Đang tải bản đồ nhiệt...</div> },
|
||||||
|
);
|
||||||
|
|
||||||
|
const AgentPerformance = dynamic(
|
||||||
|
() => import('@/components/charts/agent-performance').then((mod) => mod.AgentPerformance),
|
||||||
|
{ ssr: false, loading: () => <div className="flex h-64 items-center justify-center text-muted-foreground">Đang tải...</div> },
|
||||||
|
);
|
||||||
|
|
||||||
const CITIES = ['Ho Chi Minh', 'Ha Noi', 'Da Nang'];
|
const CITIES = ['Ho Chi Minh', 'Ha Noi', 'Da Nang'];
|
||||||
const CURRENT_PERIOD = '2026-Q1';
|
const CURRENT_PERIOD = '2026-Q1';
|
||||||
const TREND_PERIODS = ['2025-Q1', '2025-Q2', '2025-Q3', '2025-Q4', '2026-Q1'];
|
const TREND_PERIODS = ['2025-Q1', '2025-Q2', '2025-Q3', '2025-Q4', '2026-Q1'];
|
||||||
@@ -177,6 +187,7 @@ export default function AnalyticsPage() {
|
|||||||
<TabsTrigger value="overview">Tổng quan</TabsTrigger>
|
<TabsTrigger value="overview">Tổng quan</TabsTrigger>
|
||||||
<TabsTrigger value="trends">Xu hướng giá</TabsTrigger>
|
<TabsTrigger value="trends">Xu hướng giá</TabsTrigger>
|
||||||
<TabsTrigger value="districts">Chi tiết quận</TabsTrigger>
|
<TabsTrigger value="districts">Chi tiết quận</TabsTrigger>
|
||||||
|
<TabsTrigger value="performance">Hiệu suất</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* Overview Tab */}
|
{/* Overview Tab */}
|
||||||
@@ -203,10 +214,10 @@ export default function AnalyticsPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Heatmap - Card Grid */}
|
{/* Heatmap - Mapbox Map */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg">Bản đồ giá theo quận</CardTitle>
|
<CardTitle className="text-lg">Bản đồ nhiệt giá theo quận</CardTitle>
|
||||||
<CardDescription>So sánh giá trung bình/m² tại {city}</CardDescription>
|
<CardDescription>So sánh giá trung bình/m² tại {city}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -219,36 +230,15 @@ export default function AnalyticsPage() {
|
|||||||
Chưa có dữ liệu
|
Chưa có dữ liệu
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-2 sm:grid-cols-2">
|
<DistrictHeatmap
|
||||||
{heatmap
|
data={heatmap}
|
||||||
.sort((a, b) => b.avgPriceM2 - a.avgPriceM2)
|
city={city}
|
||||||
.slice(0, 8)
|
className="h-[350px]"
|
||||||
.map((point) => {
|
onDistrictClick={(district) => {
|
||||||
const maxPrice = Math.max(...heatmap.map((h) => h.avgPriceM2));
|
setTrendDistrict(district);
|
||||||
const intensity = Math.round((point.avgPriceM2 / maxPrice) * 100);
|
setTab('trends');
|
||||||
return (
|
}}
|
||||||
<div
|
/>
|
||||||
key={point.district}
|
|
||||||
className="cursor-pointer rounded-lg border p-3 transition-shadow hover:shadow-md"
|
|
||||||
style={{
|
|
||||||
background: `linear-gradient(135deg, hsl(${120 - intensity * 1.2}, 70%, 95%), hsl(${120 - intensity * 1.2}, 70%, 85%))`,
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
setTrendDistrict(point.district);
|
|
||||||
setTab('trends');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="font-medium">{point.district}</div>
|
|
||||||
<div className="text-sm font-semibold">
|
|
||||||
{formatPriceM2(point.avgPriceM2)}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{point.totalListings} tin đăng
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -418,6 +408,13 @@ export default function AnalyticsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Agent Performance Tab */}
|
||||||
|
<TabsContent value="performance">
|
||||||
|
<div className="mt-4">
|
||||||
|
<AgentPerformance />
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
161
apps/web/components/charts/agent-performance.tsx
Normal file
161
apps/web/components/charts/agent-performance.tsx
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
PieChart,
|
||||||
|
Pie,
|
||||||
|
Cell,
|
||||||
|
Legend,
|
||||||
|
} from 'recharts';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
|
||||||
|
/** Placeholder data — will be replaced by real API data when backend endpoint ships */
|
||||||
|
const MOCK_MONTHLY_DEALS = [
|
||||||
|
{ month: 'T11', deals: 3, revenue: 4.2 },
|
||||||
|
{ month: 'T12', deals: 5, revenue: 7.1 },
|
||||||
|
{ month: 'T1', deals: 4, revenue: 5.8 },
|
||||||
|
{ month: 'T2', deals: 6, revenue: 8.5 },
|
||||||
|
{ month: 'T3', deals: 7, revenue: 11.2 },
|
||||||
|
{ month: 'Q1-26', deals: 8, revenue: 13.0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MOCK_FUNNEL = [
|
||||||
|
{ stage: 'Liên hệ mới', count: 120, fill: '#94a3b8' },
|
||||||
|
{ stage: 'Đang trao đổi', count: 85, fill: '#60a5fa' },
|
||||||
|
{ stage: 'Xem nhà', count: 42, fill: '#a78bfa' },
|
||||||
|
{ stage: 'Đàm phán', count: 22, fill: '#fbbf24' },
|
||||||
|
{ stage: 'Chốt deal', count: 8, fill: '#34d399' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const FUNNEL_COLORS = ['#94a3b8', '#60a5fa', '#a78bfa', '#fbbf24', '#34d399'];
|
||||||
|
|
||||||
|
function StatCard({ label, value, sub }: { label: string; value: string; sub?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<p className="text-sm text-muted-foreground">{label}</p>
|
||||||
|
<p className="mt-1 text-2xl font-bold">{value}</p>
|
||||||
|
{sub && <p className="mt-0.5 text-xs text-muted-foreground">{sub}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentPerformance() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* KPI Cards */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<StatCard label="Giao dịch thành công" value="8" sub="Quý hiện tại" />
|
||||||
|
<StatCard label="Doanh thu" value="13.0 tỷ" sub="+22% so với quý trước" />
|
||||||
|
<StatCard label="Thời gian phản hồi TB" value="1.2 giờ" sub="Mục tiêu: < 2 giờ" />
|
||||||
|
<StatCard label="Tỷ lệ chuyển đổi" value="6.7%" sub="Liên hệ → Chốt deal" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
{/* Monthly Deals Chart */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">Giao dịch & Doanh thu theo tháng</CardTitle>
|
||||||
|
<CardDescription>6 tháng gần nhất</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ResponsiveContainer width="100%" height={280}>
|
||||||
|
<BarChart data={MOCK_MONTHLY_DEALS} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||||
|
<XAxis dataKey="month" tick={{ fontSize: 12 }} className="fill-muted-foreground" />
|
||||||
|
<YAxis yAxisId="left" tick={{ fontSize: 12 }} className="fill-muted-foreground" />
|
||||||
|
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 12 }} className="fill-muted-foreground" />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: 'hsl(var(--card))',
|
||||||
|
border: '1px solid hsl(var(--border))',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
}}
|
||||||
|
formatter={(value, name) => [
|
||||||
|
name === 'revenue' ? `${value} tỷ` : `${value} deals`,
|
||||||
|
name === 'revenue' ? 'Doanh thu' : 'Giao dịch',
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Legend formatter={(value) => (value === 'revenue' ? 'Doanh thu (tỷ)' : 'Giao dịch')} />
|
||||||
|
<Bar yAxisId="left" dataKey="deals" fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} />
|
||||||
|
<Bar yAxisId="right" dataKey="revenue" fill="hsl(var(--primary) / 0.4)" radius={[4, 4, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Lead Conversion Funnel */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">Phễu chuyển đổi khách hàng</CardTitle>
|
||||||
|
<CardDescription>Từ liên hệ đến chốt deal</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:gap-6">
|
||||||
|
{/* Funnel bars */}
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
{MOCK_FUNNEL.map((item, i) => {
|
||||||
|
const widthPct = Math.max((item.count / MOCK_FUNNEL[0]!.count) * 100, 12);
|
||||||
|
return (
|
||||||
|
<div key={item.stage} className="flex items-center gap-3">
|
||||||
|
<div className="w-24 shrink-0 text-right text-xs text-muted-foreground">
|
||||||
|
{item.stage}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div
|
||||||
|
className="flex h-7 items-center rounded px-2 text-xs font-medium text-white"
|
||||||
|
style={{ width: `${widthPct}%`, background: FUNNEL_COLORS[i] }}
|
||||||
|
>
|
||||||
|
{item.count}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{/* Pie breakdown */}
|
||||||
|
<div className="mx-auto w-44 shrink-0 lg:mx-0">
|
||||||
|
<ResponsiveContainer width="100%" height={180}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={MOCK_FUNNEL}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={40}
|
||||||
|
outerRadius={70}
|
||||||
|
dataKey="count"
|
||||||
|
nameKey="stage"
|
||||||
|
stroke="none"
|
||||||
|
>
|
||||||
|
{MOCK_FUNNEL.map((entry, i) => (
|
||||||
|
<Cell key={entry.stage} fill={FUNNEL_COLORS[i]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: 'hsl(var(--card))',
|
||||||
|
border: '1px solid hsl(var(--border))',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-center text-xs text-muted-foreground">
|
||||||
|
* Dữ liệu mẫu — kết nối API hiệu suất môi giới đang được phát triển
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
226
apps/web/components/charts/district-heatmap.tsx
Normal file
226
apps/web/components/charts/district-heatmap.tsx
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
'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';
|
||||||
|
|
||||||
|
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<string, Record<string, [number, number]>> = {
|
||||||
|
'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<string, [number, number]> = {
|
||||||
|
'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<HTMLDivElement>(null);
|
||||||
|
const mapRef = React.useRef<mapboxgl.Map | null>(null);
|
||||||
|
const markersRef = React.useRef<mapboxgl.Marker[]>([]);
|
||||||
|
|
||||||
|
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: 'mapbox://styles/mapbox/light-v11',
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
}, [city]);
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
const el = document.createElement('button');
|
||||||
|
el.style.cssText = `
|
||||||
|
width: ${size}px; height: ${size}px;
|
||||||
|
border-radius: 50%; border: 2px solid white;
|
||||||
|
background: ${priceColor(ratio)};
|
||||||
|
opacity: 0.8; cursor: pointer;
|
||||||
|
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;
|
||||||
|
padding: 2px;
|
||||||
|
line-height: 1.1;
|
||||||
|
text-align: center;
|
||||||
|
`;
|
||||||
|
el.textContent = point.district.replace(/^Quan\s*/i, 'Q.').replace(/^Huyen\s*/i, 'H.');
|
||||||
|
el.addEventListener('mouseenter', () => { el.style.opacity = '1'; el.style.transform = 'scale(1.15)'; });
|
||||||
|
el.addEventListener('mouseleave', () => { el.style.opacity = '0.8'; el.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(`
|
||||||
|
<div style="font-family:system-ui,sans-serif;padding:4px 0;">
|
||||||
|
<div style="font-weight:700;font-size:13px;margin-bottom:4px;">${point.district}</div>
|
||||||
|
<div style="font-size:12px;color:#16a34a;font-weight:600;">${priceLabel}</div>
|
||||||
|
<div style="font-size:11px;color:#666;margin-top:2px;">${point.totalListings} tin đăng</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className={`relative overflow-hidden rounded-lg ${className || 'h-[400px]'}`}>
|
||||||
|
<div ref={mapContainerRef} className="h-full w-full" />
|
||||||
|
{!hasToken && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-b from-blue-50 to-green-50">
|
||||||
|
<div className="text-center">
|
||||||
|
<svg className="mx-auto mb-2 h-10 w-10 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Thiết lập NEXT_PUBLIC_MAPBOX_TOKEN để hiển thị bản đồ nhiệt
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="absolute bottom-3 left-3 rounded bg-white/90 px-3 py-2 text-xs shadow">
|
||||||
|
<div className="mb-1 font-medium">Giá trung bình/m²</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="h-3 w-3 rounded-full" style={{ background: priceColor(0) }} />
|
||||||
|
<span>Thấp</span>
|
||||||
|
<div className="mx-1 h-2 w-16 rounded" style={{ background: 'linear-gradient(to right, hsl(120,75%,50%), hsl(60,75%,50%), hsl(0,75%,50%))' }} />
|
||||||
|
<div className="h-3 w-3 rounded-full" style={{ background: priceColor(1) }} />
|
||||||
|
<span>Cao</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user