feat: add comparison workspace

This commit is contained in:
2026-06-24 13:56:39 +08:00
parent ff6e41a6e5
commit f8fb09d2a9
6 changed files with 871 additions and 15 deletions

View File

@@ -5,14 +5,15 @@ use serde::Serialize;
use crate::error::{ApiError, ApiResult};
use crate::import_templates::{execute_import_payload, validate_import_payload};
use crate::models::{
AreaDetail, AreaMonthlyMetric, AreaProfile, AreaScore, AreaScoreLineage, AreaScoreLineageQuery,
AreaScoreModelRunResponse, AreaScoreSummary, CreateAreaScoreModelRun, CreateDataSource,
AreaComparison, AreaDetail, AreaMonthlyMetric, AreaProfile, AreaScore, AreaScoreLineage,
AreaScoreLineageQuery, AreaScoreModelRunResponse, AreaScoreSummary, CompareQuery,
ComparisonMetric, ComparisonMetricValue, CreateAreaScoreModelRun, CreateDataSource,
CreateIngestionRun, CreateRawArtifact, CreateWatchlistItem, DataSource, FinishIngestionRun,
ImportExecutionRequest, ImportExecutionResponse, ImportValidationRequest,
ImportValidationResponse, IngestionRun, IngestionRunQuery, MarketOverview, ModelRun,
MonthQuery, Neighborhood, NeighborhoodDetail, NeighborhoodMonthlyMetric, NeighborhoodQuery,
NeighborhoodScore, NeighborhoodScoreQuery, RawArtifact, RawArtifactQuery, UpdateWatchlistItem,
WatchlistItem,
MonthQuery, Neighborhood, NeighborhoodComparison, NeighborhoodComparisonItem,
NeighborhoodDetail, NeighborhoodMonthlyMetric, NeighborhoodQuery, NeighborhoodScore,
NeighborhoodScoreQuery, RawArtifact, RawArtifactQuery, UpdateWatchlistItem, WatchlistItem,
};
use crate::scoring::{compute_area_scores, previous_month, AreaMetricRow, ComputedAreaScore};
use crate::state::AppState;
@@ -133,6 +134,122 @@ pub async fn market_overview(
}))
}
pub async fn compare_areas(
State(state): State<AppState>,
Query(query): Query<CompareQuery>,
) -> ApiResult<Json<AreaComparison>> {
validate_month(&query.month)?;
let requested_ids = parse_compare_ids(&query.ids)?;
let scores = sqlx::query_as::<_, AreaScore>(
r#"
SELECT
area_id,
name,
district,
segment,
month,
investment_score,
recommendation,
liquidity_score,
momentum_score,
rent_support_score,
safety_margin_score,
credit_support_score,
supply_risk_score,
valuation_pressure_score,
transaction_count,
transaction_price_psm,
listing_count,
listing_price_psm,
rent_price_psm,
median_days_on_market,
annual_rent_yield_pct,
listing_pressure_ratio,
discount_pct,
price_momentum_pct,
volume_momentum_pct
FROM gold.area_scores
WHERE month = $1
AND area_id = ANY($2)
"#,
)
.bind(&query.month)
.bind(&requested_ids)
.fetch_all(&state.pool)
.await?;
let items = sort_by_requested_order(scores, &requested_ids, |score| &score.area_id);
let missing_ids = missing_ids(&requested_ids, &items, |score| &score.area_id);
let metrics = area_comparison_metrics(&items);
Ok(Json(AreaComparison {
month: query.month,
requested_ids,
missing_ids,
items,
metrics,
}))
}
pub async fn compare_neighborhoods(
State(state): State<AppState>,
Query(query): Query<CompareQuery>,
) -> ApiResult<Json<NeighborhoodComparison>> {
validate_month(&query.month)?;
let requested_ids = parse_compare_ids(&query.ids)?;
let scores = sqlx::query_as::<_, NeighborhoodComparisonItem>(
r#"
SELECT
s.neighborhood_id,
s.area_id,
s.name,
s.area_name,
s.district,
n.built_year,
n.property_type,
n.metro_distance_m,
n.school_quality,
s.month,
s.investment_score,
s.recommendation,
s.liquidity_score,
s.rent_support_score,
s.price_safety_score,
s.location_score,
s.building_age_score,
s.transaction_count,
s.transaction_price_psm,
s.listing_count,
s.listing_price_psm,
s.rent_price_psm,
s.median_days_on_market,
s.annual_rent_yield_pct,
s.discount_pct,
s.available_units
FROM gold.neighborhood_scores s
JOIN silver.neighborhoods n ON n.neighborhood_id = s.neighborhood_id
WHERE s.month = $1
AND s.neighborhood_id = ANY($2)
"#,
)
.bind(&query.month)
.bind(&requested_ids)
.fetch_all(&state.pool)
.await?;
let items = sort_by_requested_order(scores, &requested_ids, |score| &score.neighborhood_id);
let missing_ids = missing_ids(&requested_ids, &items, |score| &score.neighborhood_id);
let metrics = neighborhood_comparison_metrics(&items);
Ok(Json(NeighborhoodComparison {
month: query.month,
requested_ids,
missing_ids,
items,
metrics,
}))
}
pub async fn create_area_score_model_run(
State(state): State<AppState>,
Json(payload): Json<CreateAreaScoreModelRun>,
@@ -1377,6 +1494,231 @@ fn average(values: impl Iterator<Item = f64>) -> f64 {
}
}
fn parse_compare_ids(ids: &str) -> ApiResult<Vec<String>> {
let mut parsed = Vec::new();
for id in ids.split(',').map(str::trim).filter(|id| !id.is_empty()) {
if has_sensitive_text(id) || id.len() > 80 {
return Err(ApiError::BadRequest("invalid comparison id".to_string()));
}
if !parsed.iter().any(|existing| existing == id) {
parsed.push(id.to_string());
}
}
if !(2..=4).contains(&parsed.len()) {
return Err(ApiError::BadRequest(
"ids must include 2 to 4 unique values".to_string(),
));
}
Ok(parsed)
}
fn sort_by_requested_order<T, F>(mut items: Vec<T>, requested_ids: &[String], id_fn: F) -> Vec<T>
where
F: Fn(&T) -> &str,
{
items.sort_by_key(|item| {
requested_ids
.iter()
.position(|id| id == id_fn(item))
.unwrap_or(usize::MAX)
});
items
}
fn missing_ids<T, F>(requested_ids: &[String], items: &[T], id_fn: F) -> Vec<String>
where
F: Fn(&T) -> &str,
{
requested_ids
.iter()
.filter(|id| !items.iter().any(|item| id_fn(item) == id.as_str()))
.cloned()
.collect()
}
fn area_comparison_metrics(items: &[AreaScore]) -> Vec<ComparisonMetric> {
vec![
comparison_metric(
"investment_score",
"综合分",
"",
"higher",
items,
|item| Some(item.investment_score),
),
comparison_metric(
"transaction_price_psm",
"成交均价/㎡",
"元/㎡",
"neutral",
items,
|item| Some(item.transaction_price_psm),
),
comparison_metric(
"transaction_count",
"成交套数",
"",
"higher",
items,
|item| Some(item.transaction_count as f64),
),
comparison_metric(
"liquidity_score",
"流动性",
"",
"higher",
items,
|item| Some(item.liquidity_score),
),
comparison_metric(
"rent_support_score",
"租金支撑",
"",
"higher",
items,
|item| Some(item.rent_support_score),
),
comparison_metric(
"annual_rent_yield_pct",
"租金收益率",
"%",
"higher",
items,
|item| Some(item.annual_rent_yield_pct),
),
comparison_metric(
"supply_risk_score",
"供应风险",
"",
"lower",
items,
|item| Some(item.supply_risk_score),
),
comparison_metric(
"valuation_pressure_score",
"估值压力",
"",
"lower",
items,
|item| Some(item.valuation_pressure_score),
),
]
}
fn neighborhood_comparison_metrics(items: &[NeighborhoodComparisonItem]) -> Vec<ComparisonMetric> {
vec![
comparison_metric(
"investment_score",
"综合分",
"",
"higher",
items,
|item| Some(item.investment_score),
),
comparison_metric(
"transaction_price_psm",
"成交均价/㎡",
"元/㎡",
"neutral",
items,
|item| Some(item.transaction_price_psm),
),
comparison_metric(
"transaction_count",
"成交套数",
"",
"higher",
items,
|item| Some(item.transaction_count as f64),
),
comparison_metric(
"liquidity_score",
"流动性",
"",
"higher",
items,
|item| Some(item.liquidity_score),
),
comparison_metric(
"rent_support_score",
"租金支撑",
"",
"higher",
items,
|item| Some(item.rent_support_score),
),
comparison_metric(
"building_age_score",
"楼龄",
"",
"higher",
items,
|item| Some(item.building_age_score),
),
comparison_metric(
"metro_distance_m",
"地铁距离",
"",
"lower",
items,
|item| item.metro_distance_m.map(|value| value as f64),
),
comparison_metric(
"available_units",
"可售套数",
"",
"neutral",
items,
|item| Some(item.available_units as f64),
),
]
}
fn comparison_metric<T, F>(
key: &str,
label: &str,
unit: &str,
direction: &str,
items: &[T],
value_fn: F,
) -> ComparisonMetric
where
F: Fn(&T) -> Option<f64>,
T: ComparisonId,
{
ComparisonMetric {
key: key.to_string(),
label: label.to_string(),
unit: unit.to_string(),
direction: direction.to_string(),
values: items
.iter()
.map(|item| ComparisonMetricValue {
id: item.comparison_id().to_string(),
value: value_fn(item),
})
.collect(),
}
}
trait ComparisonId {
fn comparison_id(&self) -> &str;
}
impl ComparisonId for AreaScore {
fn comparison_id(&self) -> &str {
&self.area_id
}
}
impl ComparisonId for NeighborhoodComparisonItem {
fn comparison_id(&self) -> &str {
&self.neighborhood_id
}
}
fn validate_month(month: &str) -> ApiResult<()> {
let valid = month.len() == 7
&& month.as_bytes()[4] == b'-'
@@ -1426,7 +1768,7 @@ mod tests {
FinishIngestionRun, UpdateWatchlistItem,
};
use super::{validate_month, validate_sha256};
use super::{parse_compare_ids, validate_month, validate_sha256};
#[test]
fn accepts_valid_month() {
@@ -1602,4 +1944,15 @@ mod tests {
assert!(validate_sha256(&"G".repeat(64)).is_err());
assert!(validate_sha256("short").is_err());
}
#[test]
fn validates_compare_ids() {
assert_eq!(
parse_compare_ids("qiantan, zhangjiang,qiantan").unwrap(),
vec!["qiantan".to_string(), "zhangjiang".to_string()]
);
assert!(parse_compare_ids("qiantan").is_err());
assert!(parse_compare_ids("a,b,c,d,e").is_err());
assert!(parse_compare_ids("a,password,b").is_err());
}
}

View File

@@ -25,6 +25,12 @@ pub struct NeighborhoodScoreQuery {
pub area_id: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct CompareQuery {
pub month: String,
pub ids: String,
}
#[derive(Debug, Deserialize)]
pub struct IngestionRunQuery {
pub source_id: Option<i64>,
@@ -287,6 +293,69 @@ pub struct NeighborhoodScore {
pub available_units: i32,
}
#[derive(Debug, Serialize)]
pub struct ComparisonMetricValue {
pub id: String,
pub value: Option<f64>,
}
#[derive(Debug, Serialize)]
pub struct ComparisonMetric {
pub key: String,
pub label: String,
pub unit: String,
pub direction: String,
pub values: Vec<ComparisonMetricValue>,
}
#[derive(Debug, Serialize)]
pub struct AreaComparison {
pub month: String,
pub requested_ids: Vec<String>,
pub missing_ids: Vec<String>,
pub items: Vec<AreaScore>,
pub metrics: Vec<ComparisonMetric>,
}
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct NeighborhoodComparisonItem {
pub neighborhood_id: String,
pub area_id: String,
pub name: String,
pub area_name: String,
pub district: String,
pub built_year: Option<i32>,
pub property_type: String,
pub metro_distance_m: Option<i32>,
pub school_quality: Option<String>,
pub month: String,
pub investment_score: f64,
pub recommendation: String,
pub liquidity_score: f64,
pub rent_support_score: f64,
pub price_safety_score: f64,
pub location_score: f64,
pub building_age_score: f64,
pub transaction_count: i32,
pub transaction_price_psm: f64,
pub listing_count: i32,
pub listing_price_psm: f64,
pub rent_price_psm: f64,
pub median_days_on_market: f64,
pub annual_rent_yield_pct: f64,
pub discount_pct: f64,
pub available_units: i32,
}
#[derive(Debug, Serialize)]
pub struct NeighborhoodComparison {
pub month: String,
pub requested_ids: Vec<String>,
pub missing_ids: Vec<String>,
pub items: Vec<NeighborhoodComparisonItem>,
pub metrics: Vec<ComparisonMetric>,
}
#[derive(Debug, Serialize)]
pub struct NeighborhoodDetail {
pub profile: Neighborhood,

View File

@@ -4,12 +4,12 @@ use tower_http::cors::CorsLayer;
use tower_http::trace::TraceLayer;
use crate::handlers::{
archive_watchlist_item, create_area_score_model_run, create_data_source, create_ingestion_run,
create_raw_artifact, create_watchlist_item, execute_import, finish_ingestion_run,
get_area_detail, get_area_score_lineage, get_neighborhood, get_neighborhood_detail, health,
list_area_scores, list_data_sources, list_ingestion_runs, list_neighborhood_scores,
list_neighborhoods, list_raw_artifacts, list_watchlist_items, market_overview, ready,
update_watchlist_item, validate_import,
archive_watchlist_item, compare_areas, compare_neighborhoods, create_area_score_model_run,
create_data_source, create_ingestion_run, create_raw_artifact, create_watchlist_item,
execute_import, finish_ingestion_run, get_area_detail, get_area_score_lineage,
get_neighborhood, get_neighborhood_detail, health, list_area_scores, list_data_sources,
list_ingestion_runs, list_neighborhood_scores, list_neighborhoods, list_raw_artifacts,
list_watchlist_items, market_overview, ready, update_watchlist_item, validate_import,
};
use crate::state::AppState;
@@ -39,6 +39,8 @@ pub fn build_router(state: AppState) -> Router {
)
.route("/imports/validate", axum::routing::post(validate_import))
.route("/imports/execute", axum::routing::post(execute_import))
.route("/compare/areas", get(compare_areas))
.route("/compare/neighborhoods", get(compare_neighborhoods))
.route("/market/overview", get(market_overview))
.route(
"/model-runs/area-scores",

View File

@@ -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,

View File

@@ -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");
}