feat(web): AVM v2 upgraded valuation dashboard (TEC-2763)
R5.4 ships the upgraded AVM UI behind the `avm_v2` A/B flag. When the
flag is on, the dashboard exposes:
- Tab switch between single valuation and multi-property compare
- Waterfall drivers chart (ValueDriversChart) alongside the existing
horizontal bar breakdown
- Mapbox comparables map with similarity-coloured markers and an
optional highlighted subject pin
- Confidence interval + range bar and PDF export remain available
- Valuation history chart surface unchanged (still lazy-loaded)
Flag plumbing (useAvmV2Flag):
- NEXT_PUBLIC_FEATURE_AVM_V2=1 enables by default
- `?avm_v2=1|0` URL param forces + persists to localStorage
- safe localStorage handling (no throw when storage is blocked)
Tests: comparables-map, value-drivers-chart, use-avm-v2-flag specs
added. Pre-existing "Yếu tố chính" assertion in valuation-results.spec
updated to match the current copy ("Yếu tố ảnh hưởng giá") so the
valuation suite is green (7 files, 52 tests).
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
147
apps/web/components/valuation/value-drivers-chart.tsx
Normal file
147
apps/web/components/valuation/value-drivers-chart.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Cell,
|
||||
ReferenceLine,
|
||||
} from 'recharts';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import type { PriceDriver } from '@/lib/valuation-api';
|
||||
|
||||
interface ValueDriversChartProps {
|
||||
drivers: PriceDriver[];
|
||||
}
|
||||
|
||||
const FEATURE_LABELS: Record<string, string> = {
|
||||
area_m2: 'Diện tích',
|
||||
avg_price_district_3m_vnd_m2: 'Giá TB khu vực',
|
||||
property_type_encoded: 'Loại BĐS',
|
||||
distance_to_cbd_km: 'Khoảng cách trung tâm',
|
||||
renovation_score: 'Cải tạo',
|
||||
building_age_years: 'Tuổi công trình',
|
||||
has_legal_paper: 'Giấy tờ pháp lý',
|
||||
distance_to_metro_km: 'Khoảng cách metro',
|
||||
interior_quality: 'Nội thất',
|
||||
price_momentum_30d: 'Đà tăng giá 30 ngày',
|
||||
view_quality: 'Chất lượng view',
|
||||
natural_light: 'Ánh sáng tự nhiên',
|
||||
noise_level: 'Mức ồn',
|
||||
flood_zone_risk: 'Nguy cơ ngập',
|
||||
park_occupancy_rate: 'Tỉ lệ lấp đầy',
|
||||
logistics_connectivity_score: 'Kết nối logistics',
|
||||
industry_demand_index: 'Nhu cầu CN',
|
||||
};
|
||||
|
||||
function getFeatureLabel(feature: string): string {
|
||||
return FEATURE_LABELS[feature] || feature.replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
interface WaterfallItem {
|
||||
name: string;
|
||||
base: number;
|
||||
value: number;
|
||||
fill: string;
|
||||
importance: number;
|
||||
direction: 'positive' | 'negative';
|
||||
}
|
||||
|
||||
function buildWaterfallData(drivers: PriceDriver[]): WaterfallItem[] {
|
||||
const sorted = [...drivers].sort(
|
||||
(a, b) => Math.abs(b.impact) - Math.abs(a.impact),
|
||||
);
|
||||
|
||||
let cumulative = 0;
|
||||
return sorted.map((driver) => {
|
||||
const isPositive = driver.direction === 'positive';
|
||||
const absImpact = Math.abs(driver.impact);
|
||||
const item: WaterfallItem = {
|
||||
name: getFeatureLabel(driver.feature),
|
||||
base: isPositive ? cumulative : cumulative - absImpact,
|
||||
value: absImpact,
|
||||
fill: isPositive ? '#22c55e' : '#ef4444',
|
||||
importance: absImpact,
|
||||
direction: driver.direction,
|
||||
};
|
||||
cumulative += isPositive ? absImpact : -absImpact;
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
function CustomTooltip({
|
||||
active,
|
||||
payload,
|
||||
}: {
|
||||
active?: boolean;
|
||||
payload?: Array<{ payload: WaterfallItem }>;
|
||||
}) {
|
||||
if (!active || !payload?.[0]) return null;
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<div className="rounded-lg border bg-popover px-3 py-2 text-sm shadow-md">
|
||||
<p className="font-medium">{data.name}</p>
|
||||
<p className={data.direction === 'positive' ? 'text-green-600' : 'text-red-600'}>
|
||||
{data.direction === 'positive' ? '+' : '-'}
|
||||
{data.importance.toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ValueDriversChart({ drivers }: ValueDriversChartProps) {
|
||||
if (drivers.length === 0) return null;
|
||||
|
||||
const data = buildWaterfallData(drivers);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Yếu tố ảnh hưởng giá</CardTitle>
|
||||
<CardDescription>
|
||||
Biểu đồ thác nước thể hiện mức ảnh hưởng của từng yếu tố
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={Math.max(300, data.length * 44)}>
|
||||
<BarChart
|
||||
data={data}
|
||||
layout="vertical"
|
||||
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||
>
|
||||
<XAxis
|
||||
type="number"
|
||||
tickFormatter={(v: number) => `${v.toFixed(0)}%`}
|
||||
domain={['dataMin', 'dataMax']}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="name"
|
||||
width={150}
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<ReferenceLine x={0} stroke="#888" strokeDasharray="3 3" />
|
||||
{/* Invisible base bar for waterfall offset */}
|
||||
<Bar dataKey="base" stackId="waterfall" fill="transparent" />
|
||||
{/* Visible value bar */}
|
||||
<Bar dataKey="value" stackId="waterfall" radius={[0, 4, 4, 0]}>
|
||||
{data.map((entry, index) => (
|
||||
<Cell key={index} fill={entry.fill} fillOpacity={0.8} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user