feat: add comparison workspace
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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