feat: add comparison workspace
This commit is contained in:
@@ -19,6 +19,11 @@ import {
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
PolarAngleAxis,
|
||||
PolarGrid,
|
||||
PolarRadiusAxis,
|
||||
Radar,
|
||||
RadarChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
@@ -33,9 +38,11 @@ import {
|
||||
createWatchlistItem,
|
||||
fetchDataSources,
|
||||
fetchAreaScores,
|
||||
fetchAreaComparison,
|
||||
fetchAreaDetail,
|
||||
fetchIngestionRuns,
|
||||
fetchMarketOverview,
|
||||
fetchNeighborhoodComparison,
|
||||
fetchNeighborhoodDetail,
|
||||
fetchNeighborhoodScores,
|
||||
fetchRawArtifacts,
|
||||
@@ -44,13 +51,17 @@ import {
|
||||
} from "@/lib/api";
|
||||
import type {
|
||||
AreaScore,
|
||||
AreaComparison,
|
||||
AreaDetail,
|
||||
ComparisonMetric,
|
||||
CreateDataSource,
|
||||
CreateIngestionRun,
|
||||
CreateRawArtifact,
|
||||
CreateWatchlistItem,
|
||||
DataSource,
|
||||
IngestionRun,
|
||||
NeighborhoodComparison,
|
||||
NeighborhoodComparisonItem,
|
||||
NeighborhoodDetail,
|
||||
NeighborhoodScore,
|
||||
RawArtifact,
|
||||
@@ -76,6 +87,8 @@ export function MarketDashboard() {
|
||||
const [selectedAreaId, setSelectedAreaId] = useState("");
|
||||
const [detailAreaId, setDetailAreaId] = useState("qiantan");
|
||||
const [detailNeighborhoodId, setDetailNeighborhoodId] = useState("");
|
||||
const [comparisonAreaIds, setComparisonAreaIds] = useState<string[]>([]);
|
||||
const [comparisonNeighborhoodIds, setComparisonNeighborhoodIds] = useState<string[]>([]);
|
||||
const overview = useQuery({
|
||||
queryKey: ["market-overview", MONTH],
|
||||
queryFn: () => fetchMarketOverview(MONTH),
|
||||
@@ -98,6 +111,16 @@ export function MarketDashboard() {
|
||||
queryFn: () => fetchNeighborhoodDetail(detailNeighborhoodId, MONTH),
|
||||
enabled: Boolean(detailNeighborhoodId),
|
||||
});
|
||||
const areaComparison = useQuery({
|
||||
queryKey: ["area-comparison", comparisonAreaIds, MONTH],
|
||||
queryFn: () => fetchAreaComparison(comparisonAreaIds, MONTH),
|
||||
enabled: comparisonAreaIds.length >= 2,
|
||||
});
|
||||
const neighborhoodComparison = useQuery({
|
||||
queryKey: ["neighborhood-comparison", comparisonNeighborhoodIds, MONTH],
|
||||
queryFn: () => fetchNeighborhoodComparison(comparisonNeighborhoodIds, MONTH),
|
||||
enabled: comparisonNeighborhoodIds.length >= 2,
|
||||
});
|
||||
const watchlist = useQuery({
|
||||
queryKey: ["watchlist"],
|
||||
queryFn: fetchWatchlistItems,
|
||||
@@ -164,11 +187,31 @@ export function MarketDashboard() {
|
||||
}
|
||||
}, [detailNeighborhoodId, neighborhoodScores.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (comparisonAreaIds.length === 0 && scores.data && scores.data.length >= 2) {
|
||||
setComparisonAreaIds(scores.data.slice(0, 3).map((score) => score.area_id));
|
||||
}
|
||||
}, [comparisonAreaIds.length, scores.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
comparisonNeighborhoodIds.length === 0 &&
|
||||
neighborhoodScores.data &&
|
||||
neighborhoodScores.data.length >= 2
|
||||
) {
|
||||
setComparisonNeighborhoodIds(
|
||||
neighborhoodScores.data.slice(0, 3).map((score) => score.neighborhood_id),
|
||||
);
|
||||
}
|
||||
}, [comparisonNeighborhoodIds.length, neighborhoodScores.data]);
|
||||
|
||||
const isLoading =
|
||||
overview.isLoading ||
|
||||
scores.isLoading ||
|
||||
areaDetail.isLoading ||
|
||||
neighborhoodDetail.isLoading ||
|
||||
areaComparison.isLoading ||
|
||||
neighborhoodComparison.isLoading ||
|
||||
neighborhoodScores.isLoading ||
|
||||
watchlist.isLoading ||
|
||||
dataSources.isLoading ||
|
||||
@@ -179,6 +222,8 @@ export function MarketDashboard() {
|
||||
scores.isError ||
|
||||
areaDetail.isError ||
|
||||
neighborhoodDetail.isError ||
|
||||
areaComparison.isError ||
|
||||
neighborhoodComparison.isError ||
|
||||
neighborhoodScores.isError ||
|
||||
watchlist.isError ||
|
||||
dataSources.isError ||
|
||||
@@ -204,6 +249,8 @@ export function MarketDashboard() {
|
||||
void scores.refetch();
|
||||
void areaDetail.refetch();
|
||||
void neighborhoodDetail.refetch();
|
||||
void areaComparison.refetch();
|
||||
void neighborhoodComparison.refetch();
|
||||
void neighborhoodScores.refetch();
|
||||
void watchlist.refetch();
|
||||
void dataSources.refetch();
|
||||
@@ -337,6 +384,18 @@ export function MarketDashboard() {
|
||||
onCreateWatchlist={(payload) => createMutation.mutate(payload)}
|
||||
/>
|
||||
|
||||
<ComparisonWorkspace
|
||||
areaScores={scores.data ?? []}
|
||||
neighborhoodScores={neighborhoodScores.data ?? []}
|
||||
areaComparison={areaComparison.data ?? null}
|
||||
neighborhoodComparison={neighborhoodComparison.data ?? null}
|
||||
selectedAreaIds={comparisonAreaIds}
|
||||
selectedNeighborhoodIds={comparisonNeighborhoodIds}
|
||||
loading={areaComparison.isLoading || neighborhoodComparison.isLoading}
|
||||
onAreaIdsChange={setComparisonAreaIds}
|
||||
onNeighborhoodIdsChange={setComparisonNeighborhoodIds}
|
||||
/>
|
||||
|
||||
<WatchlistPanel
|
||||
scores={scores.data ?? []}
|
||||
neighborhoodScores={neighborhoodScores.data ?? []}
|
||||
@@ -1456,6 +1515,320 @@ function NeighborhoodProfileTable({ detail }: { detail: NeighborhoodDetail }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ComparisonWorkspace({
|
||||
areaScores,
|
||||
neighborhoodScores,
|
||||
areaComparison,
|
||||
neighborhoodComparison,
|
||||
selectedAreaIds,
|
||||
selectedNeighborhoodIds,
|
||||
loading,
|
||||
onAreaIdsChange,
|
||||
onNeighborhoodIdsChange,
|
||||
}: {
|
||||
areaScores: AreaScore[];
|
||||
neighborhoodScores: NeighborhoodScore[];
|
||||
areaComparison: AreaComparison | null;
|
||||
neighborhoodComparison: NeighborhoodComparison | null;
|
||||
selectedAreaIds: string[];
|
||||
selectedNeighborhoodIds: string[];
|
||||
loading: boolean;
|
||||
onAreaIdsChange: (ids: string[]) => void;
|
||||
onNeighborhoodIdsChange: (ids: string[]) => void;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div>
|
||||
<CardTitle>对比工作台</CardTitle>
|
||||
<p className="mt-1 text-sm text-[var(--muted-foreground)]">
|
||||
板块与小区并排比较
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid w-full gap-3 xl:w-[640px] xl:grid-cols-2">
|
||||
<ComparisonSelector
|
||||
label="板块"
|
||||
options={areaScores.map((score) => ({
|
||||
id: score.area_id,
|
||||
label: score.name,
|
||||
detail: score.district,
|
||||
}))}
|
||||
selectedIds={selectedAreaIds}
|
||||
onChange={onAreaIdsChange}
|
||||
/>
|
||||
<ComparisonSelector
|
||||
label="小区"
|
||||
options={neighborhoodScores.map((score) => ({
|
||||
id: score.neighborhood_id,
|
||||
label: score.name,
|
||||
detail: score.area_name,
|
||||
}))}
|
||||
selectedIds={selectedNeighborhoodIds}
|
||||
onChange={onNeighborhoodIdsChange}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
{loading ? <div className="h-72 rounded-md bg-[var(--muted)]" /> : null}
|
||||
{!loading ? (
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<ComparisonMatrix
|
||||
title="板块矩阵"
|
||||
comparison={areaComparison}
|
||||
getId={(item) => item.area_id}
|
||||
getName={(item) => item.name}
|
||||
getMeta={(item) => `${item.district} · ${item.segment}`}
|
||||
/>
|
||||
<ComparisonRadar
|
||||
title="板块雷达"
|
||||
metrics={areaComparison?.metrics ?? []}
|
||||
items={areaComparison?.items ?? []}
|
||||
getId={(item) => item.area_id}
|
||||
getName={(item) => item.name}
|
||||
/>
|
||||
<ComparisonMatrix
|
||||
title="小区矩阵"
|
||||
comparison={neighborhoodComparison}
|
||||
getId={(item) => item.neighborhood_id}
|
||||
getName={(item) => item.name}
|
||||
getMeta={(item) =>
|
||||
`${item.area_name} · ${item.built_year ?? "-"} · ${item.property_type}`
|
||||
}
|
||||
/>
|
||||
<ComparisonRadar
|
||||
title="小区雷达"
|
||||
metrics={neighborhoodComparison?.metrics ?? []}
|
||||
items={neighborhoodComparison?.items ?? []}
|
||||
getId={(item) => item.neighborhood_id}
|
||||
getName={(item) => item.name}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ComparisonSelector({
|
||||
label,
|
||||
options,
|
||||
selectedIds,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
options: Array<{ id: string; label: string; detail: string }>;
|
||||
selectedIds: string[];
|
||||
onChange: (ids: string[]) => void;
|
||||
}) {
|
||||
function toggle(id: string) {
|
||||
if (selectedIds.includes(id)) {
|
||||
if (selectedIds.length > 2) {
|
||||
onChange(selectedIds.filter((selectedId) => selectedId !== id));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (selectedIds.length < 4) {
|
||||
onChange([...selectedIds, id]);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-[var(--border)] p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-slate-950">{label}</p>
|
||||
<Badge variant="muted">{selectedIds.length}/4</Badge>
|
||||
</div>
|
||||
<div className="grid max-h-44 gap-2 overflow-y-auto pr-1">
|
||||
{options.map((option) => {
|
||||
const selected = selectedIds.includes(option.id);
|
||||
const locked = selected && selectedIds.length <= 2;
|
||||
return (
|
||||
<label
|
||||
key={option.id}
|
||||
className={
|
||||
selected
|
||||
? "flex cursor-pointer items-center gap-2 rounded-md border border-teal-200 bg-teal-50 px-2 py-2"
|
||||
: "flex cursor-pointer items-center gap-2 rounded-md border border-[var(--border)] px-2 py-2 hover:bg-slate-50"
|
||||
}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
disabled={locked}
|
||||
onChange={() => toggle(option.id)}
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-medium text-slate-950">
|
||||
{option.label}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-[var(--muted-foreground)]">
|
||||
{option.detail}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ComparisonMatrix<T>({
|
||||
title,
|
||||
comparison,
|
||||
getId,
|
||||
getName,
|
||||
getMeta,
|
||||
}: {
|
||||
title: string;
|
||||
comparison: { items: T[]; metrics: ComparisonMetric[]; missing_ids: string[] } | null;
|
||||
getId: (item: T) => string;
|
||||
getName: (item: T) => string;
|
||||
getMeta: (item: T) => string;
|
||||
}) {
|
||||
if (!comparison || comparison.items.length === 0) {
|
||||
return (
|
||||
<div className="rounded-md border border-[var(--border)] px-4 py-8 text-sm text-[var(--muted-foreground)]">
|
||||
暂无对比数据
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const nameById = new Map(comparison.items.map((item) => [getId(item), getName(item)]));
|
||||
const metaById = new Map(comparison.items.map((item) => [getId(item), getMeta(item)]));
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-md border border-[var(--border)]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{title}</TableHead>
|
||||
{comparison.items.map((item) => (
|
||||
<TableHead key={getId(item)} className="min-w-36 text-right">
|
||||
<span className="block text-slate-950">{getName(item)}</span>
|
||||
<span className="block font-normal text-[var(--muted-foreground)]">
|
||||
{metaById.get(getId(item))}
|
||||
</span>
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{comparison.metrics.map((metric) => (
|
||||
<TableRow key={metric.key}>
|
||||
<TableCell className="font-medium">
|
||||
{metric.label}
|
||||
<span className="ml-1 text-xs font-normal text-[var(--muted-foreground)]">
|
||||
{metric.unit}
|
||||
</span>
|
||||
</TableCell>
|
||||
{comparison.items.map((item) => {
|
||||
const id = getId(item);
|
||||
const value = metric.values.find((metricValue) => metricValue.id === id)?.value;
|
||||
return (
|
||||
<TableCell key={`${metric.key}-${id}`} className="text-right">
|
||||
{formatMetricValue(value, metric.unit)}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
{comparison.missing_ids.length > 0 ? (
|
||||
<TableRow>
|
||||
<TableCell className="text-[var(--muted-foreground)]">缺失</TableCell>
|
||||
<TableCell
|
||||
colSpan={comparison.items.length}
|
||||
className="text-right text-[var(--muted-foreground)]"
|
||||
>
|
||||
{comparison.missing_ids.map((id) => nameById.get(id) ?? id).join(", ")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ComparisonRadar<T>({
|
||||
title,
|
||||
metrics,
|
||||
items,
|
||||
getId,
|
||||
getName,
|
||||
}: {
|
||||
title: string;
|
||||
metrics: ComparisonMetric[];
|
||||
items: T[];
|
||||
getId: (item: T) => string;
|
||||
getName: (item: T) => string;
|
||||
}) {
|
||||
const radarMetrics = metrics.filter(
|
||||
(metric) =>
|
||||
metric.unit === "分" ||
|
||||
metric.key === "annual_rent_yield_pct" ||
|
||||
metric.key === "transaction_count",
|
||||
);
|
||||
const data = radarMetrics.map((metric) => {
|
||||
const row: Record<string, string | number> = { metric: metric.label };
|
||||
for (const item of items) {
|
||||
const id = getId(item);
|
||||
const value = metric.values.find((metricValue) => metricValue.id === id)?.value ?? 0;
|
||||
row[id] = metric.direction === "lower" ? 100 - value : value;
|
||||
}
|
||||
return row;
|
||||
});
|
||||
const colors = ["#0f766e", "#2563eb", "#b45309", "#be123c"];
|
||||
|
||||
if (data.length === 0 || items.length === 0) {
|
||||
return (
|
||||
<div className="rounded-md border border-[var(--border)] px-4 py-8 text-sm text-[var(--muted-foreground)]">
|
||||
暂无雷达图
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-80 rounded-md border border-[var(--border)] p-3">
|
||||
<p className="mb-2 text-sm font-semibold text-slate-950">{title}</p>
|
||||
<ResponsiveContainer width="100%" height="90%">
|
||||
<RadarChart data={data}>
|
||||
<PolarGrid stroke="#dbe1ea" />
|
||||
<PolarAngleAxis dataKey="metric" tick={{ fontSize: 12 }} />
|
||||
<PolarRadiusAxis angle={90} domain={[0, 100]} tick={false} axisLine={false} />
|
||||
<Tooltip formatter={(value) => [formatNumber(Number(value)), "标准化"]} />
|
||||
{items.map((item, index) => (
|
||||
<Radar
|
||||
key={getId(item)}
|
||||
name={getName(item)}
|
||||
dataKey={getId(item)}
|
||||
stroke={colors[index % colors.length]}
|
||||
fill={colors[index % colors.length]}
|
||||
fillOpacity={0.14}
|
||||
/>
|
||||
))}
|
||||
</RadarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatMetricValue(value: number | null | undefined, unit: string) {
|
||||
if (value === null || value === undefined) {
|
||||
return "-";
|
||||
}
|
||||
if (unit === "元/㎡" || unit === "米") {
|
||||
return formatPrice(value);
|
||||
}
|
||||
if (unit === "%") {
|
||||
return `${formatNumber(value, 2)}%`;
|
||||
}
|
||||
if (unit === "套") {
|
||||
return formatNumber(value, 0);
|
||||
}
|
||||
return formatNumber(value);
|
||||
}
|
||||
|
||||
function WatchlistPanel({
|
||||
scores,
|
||||
neighborhoodScores,
|
||||
|
||||
@@ -288,6 +288,42 @@ export type NeighborhoodDetail = {
|
||||
watchlist_items: WatchlistItem[];
|
||||
};
|
||||
|
||||
export type ComparisonMetricValue = {
|
||||
id: string;
|
||||
value: number | null;
|
||||
};
|
||||
|
||||
export type ComparisonMetric = {
|
||||
key: string;
|
||||
label: string;
|
||||
unit: string;
|
||||
direction: "higher" | "lower" | "neutral";
|
||||
values: ComparisonMetricValue[];
|
||||
};
|
||||
|
||||
export type AreaComparison = {
|
||||
month: string;
|
||||
requested_ids: string[];
|
||||
missing_ids: string[];
|
||||
items: AreaScore[];
|
||||
metrics: ComparisonMetric[];
|
||||
};
|
||||
|
||||
export type NeighborhoodComparisonItem = NeighborhoodScore & {
|
||||
built_year: number | null;
|
||||
property_type: string;
|
||||
metro_distance_m: number | null;
|
||||
school_quality: string | null;
|
||||
};
|
||||
|
||||
export type NeighborhoodComparison = {
|
||||
month: string;
|
||||
requested_ids: string[];
|
||||
missing_ids: string[];
|
||||
items: NeighborhoodComparisonItem[];
|
||||
metrics: ComparisonMetric[];
|
||||
};
|
||||
|
||||
const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL?.replace(/\/$/, "") ?? "http://127.0.0.1:8080";
|
||||
|
||||
@@ -318,6 +354,24 @@ export async function fetchNeighborhoodDetail(
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchAreaComparison(
|
||||
areaIds: string[],
|
||||
month: string,
|
||||
): Promise<AreaComparison> {
|
||||
return fetchJson(
|
||||
`/api/v1/compare/areas?month=${encodeURIComponent(month)}&ids=${encodeURIComponent(areaIds.join(","))}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchNeighborhoodComparison(
|
||||
neighborhoodIds: string[],
|
||||
month: string,
|
||||
): Promise<NeighborhoodComparison> {
|
||||
return fetchJson(
|
||||
`/api/v1/compare/neighborhoods?month=${encodeURIComponent(month)}&ids=${encodeURIComponent(neighborhoodIds.join(","))}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchWatchlistItems(): Promise<WatchlistItem[]> {
|
||||
return fetchJson("/api/v1/watchlist");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user