feat: deep statistical analytics — clinical, symptoms, incidence, env correlation, weekday
Adds a substantial layer of data-backed statistics (all grounded in verified, clean source data — no fabricated metrics). Backend (new routers/statistics.py, prefix /api/stats; +106 pytest still green): - /inpatient-clinical: LOS dist + by-disease quartiles, cost dist + by-disease + cost-vs-LOS, outcome counts, admission-route counts, BMI-by-age, KPIs (5822 admissions, median LOS 4d, mean ¥6294, cure 99.1%, emergency 47%) - /symptoms: 主诉 keyword frequencies (发热/咳嗽/肺炎…) + revisit ratio (36%) - /incidence-rate: per-10k-population standardized rate by district (cases ÷ pop) - /env-correlation: pollutant×cases Pearson + 7×7 pairwise matrix + PM2.5 scatter - /temporal: weekday distribution (+ month/yoy returned but UI omits them — data is December-only, so seasonality/YoY would be misleading) Frontend: - NEW 住院临床分析 page (/analysis/clinical, nav 临床分析): 9 charts + KPI row — LOS histogram + box-by-disease, cost histogram + scatter + by-disease, outcome donut (severity-colored), admission-route donut, age-band BMI box - DiseaseAnalysis: 主诉症状词频 horizontal bar + revisit ratio - DistrictComparison: 标化发病率(每万人)with 病例数↔发病率 toggle (rate is epidemiologically correct; raw counts mislead by population) - EnvironmentalHealth: pollutant-cases correlation bar + 7×7 correlation heatmap + PM2.5×cases scatter with least-squares regression line - TrendAnalysis: 星期就诊分布 + honest "data is December-only" note - statsApi client + types Gates: tsc 0 · build ok · functional e2e 43/43 (incl 2 new clinical) · verified live against real backend data via dev proxy Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,7 +13,7 @@ from logging_config import setup_logging
|
||||
from middleware.request_logger import RequestLoggerMiddleware
|
||||
from auth.router import router as auth_router
|
||||
from auth.service import seed_default_admin
|
||||
from routers import risk, alerts, analysis, insights, reports, cases, geocoded, grid, chat, environment
|
||||
from routers import risk, alerts, analysis, insights, reports, cases, geocoded, grid, chat, environment, statistics
|
||||
|
||||
|
||||
setup_logging()
|
||||
@@ -59,6 +59,7 @@ app.include_router(geocoded.router)
|
||||
app.include_router(grid.router)
|
||||
app.include_router(chat.router)
|
||||
app.include_router(environment.router)
|
||||
app.include_router(statistics.router)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
||||
621
backend/routers/statistics.py
Normal file
621
backend/routers/statistics.py
Normal file
@@ -0,0 +1,621 @@
|
||||
"""
|
||||
统计分析 API 路由 (prefix /api/stats)
|
||||
|
||||
为前端统计仪表盘提供聚合后的临床、症状、发病率、环境相关性和时序数据。
|
||||
所有数据从 processed/*.parquet 计算得出(文件型后端,无数据库)。
|
||||
|
||||
设计原则:
|
||||
- 模块级缓存载入的 parquet(与其他路由一致)。
|
||||
- 仅返回聚合结果,绝不直接 dump 原始行,保持 payload 小。
|
||||
- 每个端点用 try/except 包裹,失败时返回合法的空结构(绝不让 UI 收到 500)。
|
||||
- pandas 计算放入线程池 (asyncio.to_thread),避免阻塞事件循环。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import glob
|
||||
import logging
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Optional, cast
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from fastapi import APIRouter, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
from data.case_loader import (
|
||||
get_inpatient_data,
|
||||
get_outpatient_data,
|
||||
get_combined_data,
|
||||
load_cases_by_district_daily,
|
||||
normalize_district,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("cbpoa.statistics")
|
||||
|
||||
router = APIRouter(prefix="/api/stats", tags=["statistics"])
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
||||
PROCESSED_DIR = PROJECT_ROOT / "processed"
|
||||
|
||||
# ============== Module-level caches ==============
|
||||
|
||||
_cache: dict[str, object] = {}
|
||||
_cache_lock = threading.RLock()
|
||||
|
||||
# 7 个污染物(与 feature snapshot 列名一致)
|
||||
POLLUTANTS = ["AQI", "PM25", "PM10", "SO2", "NO2", "O3", "CO"]
|
||||
|
||||
# 主诉症状关键词(固定列表,子串匹配)。注意顺序:更具体的在前避免被宽泛词吞掉,
|
||||
# 但因为是独立子串计数,顺序不影响结果,仅为可读性分组。
|
||||
SYMPTOM_KEYWORDS = [
|
||||
"发热", "咳嗽", "咳", "喘息", "喘", "流涕", "鼻塞", "咽痛", "咽喉",
|
||||
"痰", "气促", "呼吸困难", "肺炎", "复诊", "随诊", "复查",
|
||||
"腹泻", "呕吐", "头痛", "乏力", "胸闷", "鼻涕", "发烧", "感冒",
|
||||
]
|
||||
REVISIT_KEYWORDS = ["复诊", "随诊", "复查"]
|
||||
|
||||
|
||||
def _district_population() -> pd.Series:
|
||||
"""各区人口(population_density 求和,按 grid_district_mapping 归属)。
|
||||
|
||||
返回 index 为规范化区名(13)、值为人口的 Series。结果缓存。
|
||||
"""
|
||||
cached = _cache.get("district_population")
|
||||
if cached is not None:
|
||||
return cast(pd.Series, cached)
|
||||
with _cache_lock:
|
||||
cached = _cache.get("district_population")
|
||||
if cached is not None:
|
||||
return cast(pd.Series, cached)
|
||||
mapping = pd.read_parquet(PROCESSED_DIR / "grid_district_mapping.parquet")
|
||||
grid = pd.read_parquet(
|
||||
PROCESSED_DIR / "grid_100m_with_dem_pop.parquet",
|
||||
columns=["grid_id", "population_density"],
|
||||
)
|
||||
joined = mapping.merge(grid, on="grid_id", how="inner")
|
||||
joined = joined.dropna(subset=["district_name"])
|
||||
joined["district_name"] = joined["district_name"].map(normalize_district)
|
||||
pop = joined.groupby("district_name")["population_density"].sum()
|
||||
_cache["district_population"] = pop
|
||||
return pop
|
||||
|
||||
|
||||
def _feature_snapshots() -> pd.DataFrame:
|
||||
"""合并所有可用的 features_*.parquet 快照(缓存)。
|
||||
|
||||
用于污染物 vs 病例的相关性分析。每个快照按格点给出污染物 + 病例计数 + 区。
|
||||
"""
|
||||
cached = _cache.get("features")
|
||||
if cached is not None:
|
||||
return cast(pd.DataFrame, cached)
|
||||
with _cache_lock:
|
||||
cached = _cache.get("features")
|
||||
if cached is not None:
|
||||
return cast(pd.DataFrame, cached)
|
||||
paths = sorted(glob.glob(str(PROCESSED_DIR / "features_*.parquet")))
|
||||
if not paths:
|
||||
df = pd.DataFrame(
|
||||
columns=POLLUTANTS + ["outpatient_count", "inpatient_count", "total_cases", "district"]
|
||||
)
|
||||
else:
|
||||
frames = [pd.read_parquet(p) for p in paths]
|
||||
df = pd.concat(frames, ignore_index=True)
|
||||
_cache["features"] = df
|
||||
return df
|
||||
|
||||
|
||||
# ============== Response Models ==============
|
||||
|
||||
|
||||
class KeyValueCount(BaseModel):
|
||||
bin_label: str
|
||||
count: int
|
||||
|
||||
|
||||
class InpatientKpis(BaseModel):
|
||||
total_admissions: int
|
||||
median_los_days: float
|
||||
mean_cost: float
|
||||
cure_rate: float
|
||||
emergency_admit_ratio: float
|
||||
|
||||
|
||||
class LosByDisease(BaseModel):
|
||||
diagnosis: str
|
||||
p25: float
|
||||
median: float
|
||||
p75: float
|
||||
n: int
|
||||
|
||||
|
||||
class CostByDisease(BaseModel):
|
||||
diagnosis: str
|
||||
mean_cost: float
|
||||
n: int
|
||||
|
||||
|
||||
class CostVsLos(BaseModel):
|
||||
los: int
|
||||
cost: float
|
||||
|
||||
|
||||
class LabelCount(BaseModel):
|
||||
outcome: Optional[str] = None
|
||||
route: Optional[str] = None
|
||||
count: int
|
||||
|
||||
|
||||
class OutcomeCount(BaseModel):
|
||||
outcome: str
|
||||
count: int
|
||||
|
||||
|
||||
class RouteCount(BaseModel):
|
||||
route: str
|
||||
count: int
|
||||
|
||||
|
||||
class BmiByAge(BaseModel):
|
||||
age_band: str
|
||||
p25: float
|
||||
median: float
|
||||
p75: float
|
||||
n: int
|
||||
|
||||
|
||||
class InpatientClinicalResponse(BaseModel):
|
||||
kpis: InpatientKpis
|
||||
los_histogram: list[KeyValueCount]
|
||||
los_by_disease: list[LosByDisease]
|
||||
cost_histogram: list[KeyValueCount]
|
||||
cost_by_disease: list[CostByDisease]
|
||||
cost_vs_los: list[CostVsLos]
|
||||
outcome_counts: list[OutcomeCount]
|
||||
admission_route_counts: list[RouteCount]
|
||||
bmi_by_age_band: list[BmiByAge]
|
||||
|
||||
|
||||
class SymptomItem(BaseModel):
|
||||
keyword: str
|
||||
count: int
|
||||
|
||||
|
||||
class SymptomsResponse(BaseModel):
|
||||
symptoms: list[SymptomItem]
|
||||
revisit_ratio: float
|
||||
|
||||
|
||||
class IncidenceItem(BaseModel):
|
||||
district: str
|
||||
total_cases: int
|
||||
population: float
|
||||
rate_per_10k: float
|
||||
|
||||
|
||||
class IncidenceResponse(BaseModel):
|
||||
districts: list[IncidenceItem]
|
||||
|
||||
|
||||
class CorrItem(BaseModel):
|
||||
pollutant: str
|
||||
corr_with_cases: float
|
||||
|
||||
|
||||
class ScatterPoint(BaseModel):
|
||||
pm25: float
|
||||
aqi: float
|
||||
cases: float
|
||||
|
||||
|
||||
class PairwiseCorr(BaseModel):
|
||||
a: str
|
||||
b: str
|
||||
corr: float
|
||||
|
||||
|
||||
class EnvCorrelationResponse(BaseModel):
|
||||
correlation_matrix: list[CorrItem]
|
||||
scatter: list[ScatterPoint]
|
||||
pollutant_pairwise: list[PairwiseCorr]
|
||||
|
||||
|
||||
class WeekdayPoint(BaseModel):
|
||||
weekday: str
|
||||
outpatient: int
|
||||
inpatient: int
|
||||
total: int
|
||||
|
||||
|
||||
class MonthYearPoint(BaseModel):
|
||||
year: int
|
||||
month: int
|
||||
total: int
|
||||
|
||||
|
||||
class YoYPoint(BaseModel):
|
||||
period: str
|
||||
current: int
|
||||
previous: int
|
||||
growth_pct: float
|
||||
|
||||
|
||||
class TemporalResponse(BaseModel):
|
||||
weekday: list[WeekdayPoint]
|
||||
month_year: list[MonthYearPoint]
|
||||
yoy: list[YoYPoint]
|
||||
|
||||
|
||||
# ============== Helpers ==============
|
||||
|
||||
|
||||
def _empty_inpatient_clinical() -> InpatientClinicalResponse:
|
||||
return InpatientClinicalResponse(
|
||||
kpis=InpatientKpis(
|
||||
total_admissions=0, median_los_days=0.0, mean_cost=0.0,
|
||||
cure_rate=0.0, emergency_admit_ratio=0.0,
|
||||
),
|
||||
los_histogram=[], los_by_disease=[], cost_histogram=[],
|
||||
cost_by_disease=[], cost_vs_los=[], outcome_counts=[],
|
||||
admission_route_counts=[], bmi_by_age_band=[],
|
||||
)
|
||||
|
||||
|
||||
def _safe_float(v) -> float:
|
||||
try:
|
||||
f = float(v)
|
||||
if np.isnan(f) or np.isinf(f):
|
||||
return 0.0
|
||||
return round(f, 4)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
# ============== Endpoint 1: inpatient clinical ==============
|
||||
|
||||
|
||||
def _compute_inpatient_clinical() -> InpatientClinicalResponse:
|
||||
df = get_inpatient_data().copy()
|
||||
if df.empty:
|
||||
return _empty_inpatient_clinical()
|
||||
|
||||
# LOS = (出院日期 - 入院日期).days, valid 0-60
|
||||
in_date = pd.to_datetime(df["入院日期"], errors="coerce")
|
||||
out_date = pd.to_datetime(df["出院日期"], errors="coerce")
|
||||
df["los"] = (out_date - in_date).dt.days
|
||||
df_los = df[(df["los"] >= 0) & (df["los"] <= 60)]
|
||||
|
||||
total = len(df)
|
||||
median_los = float(df_los["los"].median()) if len(df_los) else 0.0
|
||||
cost = pd.to_numeric(df["住院总费用"], errors="coerce")
|
||||
mean_cost = float(cost.mean()) if cost.notna().any() else 0.0
|
||||
|
||||
outcome = df["出院情况"].fillna("未知")
|
||||
cure_n = int(outcome.isin(["治愈", "好转"]).sum())
|
||||
cure_rate = cure_n / total if total else 0.0
|
||||
|
||||
route = df["入院途径"].fillna("未知")
|
||||
emerg_n = int((route == "急诊").sum())
|
||||
emerg_ratio = emerg_n / total if total else 0.0
|
||||
|
||||
kpis = InpatientKpis(
|
||||
total_admissions=total,
|
||||
median_los_days=round(median_los, 2),
|
||||
mean_cost=round(mean_cost, 2),
|
||||
cure_rate=round(cure_rate, 4),
|
||||
emergency_admit_ratio=round(emerg_ratio, 4),
|
||||
)
|
||||
|
||||
# LOS histogram bins: 0,1,2,3,4,5,6,7,8-14,15+
|
||||
los_histogram: list[KeyValueCount] = []
|
||||
los_vals = df_los["los"]
|
||||
for b in range(0, 8):
|
||||
los_histogram.append(KeyValueCount(bin_label=str(b), count=int((los_vals == b).sum())))
|
||||
los_histogram.append(KeyValueCount(bin_label="8-14", count=int(((los_vals >= 8) & (los_vals <= 14)).sum())))
|
||||
los_histogram.append(KeyValueCount(bin_label="15+", count=int((los_vals >= 15).sum())))
|
||||
|
||||
# LOS by disease (top 8 diagnoses by n)
|
||||
los_by_disease: list[LosByDisease] = []
|
||||
if len(df_los):
|
||||
top_diag = df_los["诊断名称"].value_counts().head(8).index.tolist()
|
||||
for d in top_diag:
|
||||
grp = df_los[df_los["诊断名称"] == d]["los"]
|
||||
los_by_disease.append(LosByDisease(
|
||||
diagnosis=str(d),
|
||||
p25=round(float(grp.quantile(0.25)), 2),
|
||||
median=round(float(grp.median()), 2),
|
||||
p75=round(float(grp.quantile(0.75)), 2),
|
||||
n=int(len(grp)),
|
||||
))
|
||||
|
||||
# Cost histogram: 0-2k,2-4k,4-6k,6-8k,8-10k,10k+
|
||||
cost_valid = cost.dropna()
|
||||
cost_bins = [(0, 2000, "0-2k"), (2000, 4000, "2-4k"), (4000, 6000, "4-6k"),
|
||||
(6000, 8000, "6-8k"), (8000, 10000, "8-10k")]
|
||||
cost_histogram: list[KeyValueCount] = []
|
||||
for lo, hi, label in cost_bins:
|
||||
cost_histogram.append(KeyValueCount(
|
||||
bin_label=label, count=int(((cost_valid >= lo) & (cost_valid < hi)).sum())))
|
||||
cost_histogram.append(KeyValueCount(bin_label="10k+", count=int((cost_valid >= 10000).sum())))
|
||||
|
||||
# Cost by disease (top 8 by n)
|
||||
cost_by_disease: list[CostByDisease] = []
|
||||
df_cost = df[cost.notna()].copy()
|
||||
df_cost["_cost"] = cost[cost.notna()]
|
||||
if len(df_cost):
|
||||
top_cd = df_cost["诊断名称"].value_counts().head(8).index.tolist()
|
||||
for d in top_cd:
|
||||
grp = df_cost[df_cost["诊断名称"] == d]["_cost"]
|
||||
cost_by_disease.append(CostByDisease(
|
||||
diagnosis=str(d),
|
||||
mean_cost=round(float(grp.mean()), 2),
|
||||
n=int(len(grp)),
|
||||
))
|
||||
|
||||
# cost vs los scatter (up to 500 points)
|
||||
cost_vs_los: list[CostVsLos] = []
|
||||
scatter_df = df_los[cost.reindex(df_los.index).notna()].copy()
|
||||
scatter_df["_cost"] = cost.reindex(scatter_df.index)
|
||||
if len(scatter_df) > 500:
|
||||
scatter_df = scatter_df.sample(n=500, random_state=42)
|
||||
for _, r in scatter_df.iterrows():
|
||||
cost_vs_los.append(CostVsLos(los=int(r["los"]), cost=round(float(r["_cost"]), 2)))
|
||||
|
||||
# outcome counts
|
||||
outcome_counts = [
|
||||
OutcomeCount(outcome=str(k), count=int(v))
|
||||
for k, v in outcome.value_counts().items()
|
||||
]
|
||||
|
||||
# admission route counts
|
||||
admission_route_counts = [
|
||||
RouteCount(route=str(k), count=int(v))
|
||||
for k, v in route.value_counts().items()
|
||||
]
|
||||
|
||||
# BMI by age band. BMI = 体重kg / (身高m)^2; plausible 8-40.
|
||||
bmi_by_age_band: list[BmiByAge] = []
|
||||
h = pd.to_numeric(df["身高"], errors="coerce") # cm
|
||||
w = pd.to_numeric(df["体重"], errors="coerce") # kg
|
||||
age = pd.to_numeric(df["年龄"], errors="coerce")
|
||||
bmi = w / ((h / 100.0) ** 2)
|
||||
bmi_df = pd.DataFrame({"age": age, "bmi": bmi})
|
||||
bmi_df = bmi_df[(bmi_df["bmi"] >= 8) & (bmi_df["bmi"] <= 40) & bmi_df["age"].notna()]
|
||||
age_bands = [(0, 3, "0-2"), (3, 6, "3-5"), (6, 9, "6-8"),
|
||||
(9, 12, "9-11"), (12, 15, "12-14"), (15, 19, "15-18")]
|
||||
for lo, hi, label in age_bands:
|
||||
grp = bmi_df[(bmi_df["age"] >= lo) & (bmi_df["age"] < hi)]["bmi"]
|
||||
if len(grp) == 0:
|
||||
continue
|
||||
bmi_by_age_band.append(BmiByAge(
|
||||
age_band=label,
|
||||
p25=round(float(grp.quantile(0.25)), 2),
|
||||
median=round(float(grp.median()), 2),
|
||||
p75=round(float(grp.quantile(0.75)), 2),
|
||||
n=int(len(grp)),
|
||||
))
|
||||
|
||||
return InpatientClinicalResponse(
|
||||
kpis=kpis,
|
||||
los_histogram=los_histogram,
|
||||
los_by_disease=los_by_disease,
|
||||
cost_histogram=cost_histogram,
|
||||
cost_by_disease=cost_by_disease,
|
||||
cost_vs_los=cost_vs_los,
|
||||
outcome_counts=outcome_counts,
|
||||
admission_route_counts=admission_route_counts,
|
||||
bmi_by_age_band=bmi_by_age_band,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/inpatient-clinical", response_model=InpatientClinicalResponse, summary="住院临床统计")
|
||||
async def inpatient_clinical():
|
||||
"""住院临床概览:KPI、住院天数(LOS)分布、费用分布、转归、入院途径、BMI 分布。"""
|
||||
try:
|
||||
return await asyncio.to_thread(_compute_inpatient_clinical)
|
||||
except Exception:
|
||||
logger.exception("inpatient-clinical failed")
|
||||
return _empty_inpatient_clinical()
|
||||
|
||||
|
||||
# ============== Endpoint 2: symptoms ==============
|
||||
|
||||
|
||||
def _compute_symptoms(top: int) -> SymptomsResponse:
|
||||
df = get_outpatient_data()
|
||||
if df.empty or "主诉" not in df.columns:
|
||||
return SymptomsResponse(symptoms=[], revisit_ratio=0.0)
|
||||
chief = df["主诉"].dropna().astype(str)
|
||||
total = len(chief)
|
||||
if total == 0:
|
||||
return SymptomsResponse(symptoms=[], revisit_ratio=0.0)
|
||||
|
||||
counts: list[SymptomItem] = []
|
||||
for kw in SYMPTOM_KEYWORDS:
|
||||
c = int(chief.str.contains(kw, regex=False).sum())
|
||||
if c > 0:
|
||||
counts.append(SymptomItem(keyword=kw, count=c))
|
||||
counts.sort(key=lambda x: x.count, reverse=True)
|
||||
counts = counts[:top]
|
||||
|
||||
revisit_mask = chief.str.contains("|".join(REVISIT_KEYWORDS), regex=True)
|
||||
revisit_ratio = float(revisit_mask.sum()) / total if total else 0.0
|
||||
|
||||
return SymptomsResponse(symptoms=counts, revisit_ratio=round(revisit_ratio, 4))
|
||||
|
||||
|
||||
@router.get("/symptoms", response_model=SymptomsResponse, summary="门诊主诉症状词频")
|
||||
async def symptoms(top: int = Query(20, ge=1, le=50, description="返回前 N 个症状词")):
|
||||
"""从门诊主诉中提取固定症状关键词的出现频次,并计算复诊比例。"""
|
||||
try:
|
||||
return await asyncio.to_thread(_compute_symptoms, top)
|
||||
except Exception:
|
||||
logger.exception("symptoms failed")
|
||||
return SymptomsResponse(symptoms=[], revisit_ratio=0.0)
|
||||
|
||||
|
||||
# ============== Endpoint 3: incidence rate ==============
|
||||
|
||||
|
||||
def _compute_incidence() -> IncidenceResponse:
|
||||
daily = load_cases_by_district_daily()
|
||||
if daily.empty:
|
||||
return IncidenceResponse(districts=[])
|
||||
case_totals = daily.groupby("district")["total_cases"].sum()
|
||||
pop = _district_population()
|
||||
|
||||
items: list[IncidenceItem] = []
|
||||
for district in case_totals.index:
|
||||
total_cases = int(case_totals.get(district, 0))
|
||||
population = float(pop.get(district, 0.0))
|
||||
rate = (total_cases / population * 10000) if population > 0 else 0.0
|
||||
items.append(IncidenceItem(
|
||||
district=str(district),
|
||||
total_cases=total_cases,
|
||||
population=round(population, 1),
|
||||
rate_per_10k=round(rate, 2),
|
||||
))
|
||||
items.sort(key=lambda x: x.rate_per_10k, reverse=True)
|
||||
return IncidenceResponse(districts=items)
|
||||
|
||||
|
||||
@router.get("/incidence-rate", response_model=IncidenceResponse, summary="各区发病率")
|
||||
async def incidence_rate():
|
||||
"""各区病例总数 / 区人口 * 10000,得到每万人发病率(13 区)。"""
|
||||
try:
|
||||
return await asyncio.to_thread(_compute_incidence)
|
||||
except Exception:
|
||||
logger.exception("incidence-rate failed")
|
||||
return IncidenceResponse(districts=[])
|
||||
|
||||
|
||||
# ============== Endpoint 4: env correlation ==============
|
||||
|
||||
|
||||
def _compute_env_correlation() -> EnvCorrelationResponse:
|
||||
df = _feature_snapshots()
|
||||
if df.empty or "total_cases" not in df.columns:
|
||||
return EnvCorrelationResponse(correlation_matrix=[], scatter=[], pollutant_pairwise=[])
|
||||
|
||||
# 污染物 vs 病例 的 Pearson 相关(按格点,汇集所有快照)
|
||||
correlation_matrix: list[CorrItem] = []
|
||||
cases = pd.to_numeric(df["total_cases"], errors="coerce")
|
||||
for p in POLLUTANTS:
|
||||
if p not in df.columns:
|
||||
continue
|
||||
series = pd.to_numeric(df[p], errors="coerce")
|
||||
valid = series.notna() & cases.notna()
|
||||
if valid.sum() < 2 or series[valid].std() == 0 or cases[valid].std() == 0:
|
||||
corr = 0.0
|
||||
else:
|
||||
corr = float(series[valid].corr(cases[valid]))
|
||||
correlation_matrix.append(CorrItem(pollutant=p, corr_with_cases=_safe_float(corr)))
|
||||
|
||||
# scatter: 采样 cases>0 的格点(up to 500)
|
||||
scatter: list[ScatterPoint] = []
|
||||
has_cols = all(c in df.columns for c in ["PM25", "AQI", "total_cases"])
|
||||
if has_cols:
|
||||
sdf = df[["PM25", "AQI", "total_cases"]].copy()
|
||||
sdf = sdf[pd.to_numeric(sdf["total_cases"], errors="coerce") > 0].dropna()
|
||||
if len(sdf) > 500:
|
||||
sdf = sdf.sample(n=500, random_state=42)
|
||||
for _, r in sdf.iterrows():
|
||||
scatter.append(ScatterPoint(
|
||||
pm25=_safe_float(r["PM25"]),
|
||||
aqi=_safe_float(r["AQI"]),
|
||||
cases=_safe_float(r["total_cases"]),
|
||||
))
|
||||
|
||||
# pollutant pairwise (upper triangle)
|
||||
pollutant_pairwise: list[PairwiseCorr] = []
|
||||
present = [p for p in POLLUTANTS if p in df.columns]
|
||||
pol_df = df[present].apply(pd.to_numeric, errors="coerce")
|
||||
corr_mat = pol_df.corr()
|
||||
for i, a in enumerate(present):
|
||||
for b in present[i + 1:]:
|
||||
try:
|
||||
v = corr_mat.loc[a, b]
|
||||
except KeyError:
|
||||
v = 0.0
|
||||
pollutant_pairwise.append(PairwiseCorr(a=a, b=b, corr=_safe_float(v)))
|
||||
|
||||
return EnvCorrelationResponse(
|
||||
correlation_matrix=correlation_matrix,
|
||||
scatter=scatter,
|
||||
pollutant_pairwise=pollutant_pairwise,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/env-correlation", response_model=EnvCorrelationResponse, summary="环境-病例相关性")
|
||||
async def env_correlation():
|
||||
"""污染物与病例的相关矩阵、PM2.5/AQI 散点、污染物两两相关(热力图)。"""
|
||||
try:
|
||||
return await asyncio.to_thread(_compute_env_correlation)
|
||||
except Exception:
|
||||
logger.exception("env-correlation failed")
|
||||
return EnvCorrelationResponse(correlation_matrix=[], scatter=[], pollutant_pairwise=[])
|
||||
|
||||
|
||||
# ============== Endpoint 5: temporal ==============
|
||||
|
||||
_WEEKDAY_LABELS = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
|
||||
|
||||
|
||||
def _compute_temporal() -> TemporalResponse:
|
||||
df = get_combined_data().copy()
|
||||
if df.empty:
|
||||
return TemporalResponse(weekday=[], month_year=[], yoy=[])
|
||||
df["date"] = pd.to_datetime(df["date"], errors="coerce")
|
||||
df = df[df["date"].notna()]
|
||||
if df.empty:
|
||||
return TemporalResponse(weekday=[], month_year=[], yoy=[])
|
||||
|
||||
# weekday (0=周一..6=周日)
|
||||
df["wd"] = df["date"].dt.weekday
|
||||
weekday: list[WeekdayPoint] = []
|
||||
for wd in range(7):
|
||||
sub = df[df["wd"] == wd]
|
||||
out = int((sub["type"] == "outpatient").sum())
|
||||
inp = int((sub["type"] == "inpatient").sum())
|
||||
weekday.append(WeekdayPoint(
|
||||
weekday=_WEEKDAY_LABELS[wd], outpatient=out, inpatient=inp, total=out + inp,
|
||||
))
|
||||
|
||||
# month_year (seasonality grid)
|
||||
df["year"] = df["date"].dt.year
|
||||
df["month"] = df["date"].dt.month
|
||||
my = df.groupby(["year", "month"]).size()
|
||||
month_year = [
|
||||
MonthYearPoint(year=int(y), month=int(m), total=int(c))
|
||||
for (y, m), c in my.items()
|
||||
]
|
||||
month_year.sort(key=lambda x: (x.year, x.month))
|
||||
|
||||
# yoy: monthly current vs same-month-prior-year (only if multiple years exist)
|
||||
yoy: list[YoYPoint] = []
|
||||
years = sorted(df["year"].unique().tolist())
|
||||
if len(years) > 1:
|
||||
monthly_totals = {(int(y), int(m)): int(c) for (y, m), c in my.items()}
|
||||
for (y, m), cur in sorted(monthly_totals.items()):
|
||||
prev = monthly_totals.get((y - 1, m))
|
||||
if prev is None:
|
||||
continue
|
||||
growth = ((cur - prev) / prev * 100) if prev else 0.0
|
||||
yoy.append(YoYPoint(
|
||||
period=f"{y}-{m:02d}",
|
||||
current=cur,
|
||||
previous=prev,
|
||||
growth_pct=round(growth, 2),
|
||||
))
|
||||
|
||||
return TemporalResponse(weekday=weekday, month_year=month_year, yoy=yoy)
|
||||
|
||||
|
||||
@router.get("/temporal", response_model=TemporalResponse, summary="时序统计")
|
||||
async def temporal():
|
||||
"""按星期、年-月(季节性网格)聚合,以及同比(YoY)增长(若有多年数据)。"""
|
||||
try:
|
||||
return await asyncio.to_thread(_compute_temporal)
|
||||
except Exception:
|
||||
logger.exception("temporal failed")
|
||||
return TemporalResponse(weekday=[], month_year=[], yoy=[])
|
||||
104
frontend/e2e/clinical.spec.ts
Normal file
104
frontend/e2e/clinical.spec.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* 住院临床分析页(/analysis/clinical)验收测试。
|
||||
* 与 user-flows.spec.ts 一致的鉴权策略:addInitScript 注入 cbpoa_token,
|
||||
* 用 page.route 拦截 /api/**,对 inpatient-clinical 返回合法小样本,其余返回 {}。
|
||||
*/
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
import { TESTIDS } from '../src/utils/testids';
|
||||
|
||||
const CLINICAL_FIXTURE = {
|
||||
kpis: {
|
||||
total_admissions: 5822,
|
||||
median_los_days: 4,
|
||||
mean_cost: 6294,
|
||||
cure_rate: 0.991,
|
||||
emergency_admit_ratio: 0.47,
|
||||
},
|
||||
los_histogram: [
|
||||
{ bin_label: '1-2', count: 1200 },
|
||||
{ bin_label: '3-4', count: 2100 },
|
||||
{ bin_label: '5-7', count: 1500 },
|
||||
],
|
||||
los_by_disease: [
|
||||
{ diagnosis: '肺炎', p25: 3, median: 5, p75: 7, n: 800 },
|
||||
{ diagnosis: '支气管炎', p25: 2, median: 4, p75: 6, n: 600 },
|
||||
],
|
||||
cost_histogram: [
|
||||
{ bin_label: '0-3k', count: 1800 },
|
||||
{ bin_label: '3k-6k', count: 2200 },
|
||||
],
|
||||
cost_by_disease: [
|
||||
{ diagnosis: '肺炎', mean_cost: 7200, n: 800 },
|
||||
{ diagnosis: '支气管炎', mean_cost: 5100, n: 600 },
|
||||
],
|
||||
cost_vs_los: [
|
||||
{ los: 3, cost: 5000 },
|
||||
{ los: 5, cost: 7200 },
|
||||
{ los: 7, cost: 9100 },
|
||||
],
|
||||
outcome_counts: [
|
||||
{ outcome: '治愈', count: 3474 },
|
||||
{ outcome: '好转', count: 2298 },
|
||||
{ outcome: '其他', count: 35 },
|
||||
{ outcome: '未愈', count: 12 },
|
||||
{ outcome: '死亡', count: 3 },
|
||||
],
|
||||
admission_route_counts: [
|
||||
{ route: '急诊', count: 2700 },
|
||||
{ route: '门诊', count: 3122 },
|
||||
],
|
||||
bmi_by_age_band: [
|
||||
{ age_band: '0-2', p25: 14, median: 16, p75: 18, n: 400 },
|
||||
{ age_band: '3-6', p25: 15, median: 17, p75: 19, n: 500 },
|
||||
],
|
||||
};
|
||||
|
||||
async function seedAuthAndMockApi(page: Page) {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('cbpoa_token', 'e2e-test-token');
|
||||
});
|
||||
|
||||
await page.route('/api/**', (route) => {
|
||||
const url = route.request().url();
|
||||
|
||||
if (url.includes('/stats/inpatient-clinical')) {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(CLINICAL_FIXTURE),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 其余接口返回空对象,本页不依赖。
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('住院临床分析页', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await seedAuthAndMockApi(page);
|
||||
});
|
||||
|
||||
test('deep-link /analysis/clinical mounts page-clinical + clinical-kpis', async ({ page }) => {
|
||||
await page.goto('/analysis/clinical');
|
||||
await expect(page.locator(`[data-testid="${TESTIDS.pageClinical}"]`)).toBeVisible();
|
||||
await expect(page.locator(`[data-testid="${TESTIDS.clinicalKpis}"]`)).toBeVisible();
|
||||
});
|
||||
|
||||
test('no horizontal scroll at 375px', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
await page.goto('/analysis/clinical');
|
||||
await expect(page.locator(`[data-testid="${TESTIDS.pageClinical}"]`)).toBeVisible();
|
||||
await expect(page.locator(`[data-testid="${TESTIDS.clinicalKpis}"]`)).toBeVisible();
|
||||
|
||||
const noHorizontalScroll = await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth
|
||||
);
|
||||
expect(noHorizontalScroll).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -55,6 +55,7 @@ const modules: { id: string; label: string; icon: React.ReactNode; items: NavIte
|
||||
{ to: '/analysis/reports', label: '报表中心', testid: TESTIDS.navReports },
|
||||
{ to: '/analysis/demographics', label: '人群分析', testid: TESTIDS.navDemographics },
|
||||
{ to: '/analysis/disease', label: '疾病分析', testid: TESTIDS.navDisease },
|
||||
{ to: '/analysis/clinical', label: '临床分析', testid: TESTIDS.navClinical },
|
||||
{ to: '/analysis/environment', label: '环境健康', testid: TESTIDS.navEnvironment },
|
||||
],
|
||||
},
|
||||
|
||||
87
frontend/src/components/clinical/BoxPlotRows.tsx
Normal file
87
frontend/src/components/clinical/BoxPlotRows.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { memo } from 'react';
|
||||
import { CLINICAL_COLORS } from './chartColors';
|
||||
|
||||
export interface BoxRow {
|
||||
label: string;
|
||||
p25: number;
|
||||
median: number;
|
||||
p75: number;
|
||||
n: number;
|
||||
}
|
||||
|
||||
interface BoxPlotRowsProps {
|
||||
rows: BoxRow[];
|
||||
/** 数值单位后缀,如 "天" / ""。 */
|
||||
unit?: string;
|
||||
/** 标签列宽(px)。 */
|
||||
labelWidth?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 横向箱线图(p25–中位–p75)。Recharts 无原生 box plot,
|
||||
* 故用纯 div 渲染:每行一条从 p25 到 p75 的横条,中位处一根竖向刻度。
|
||||
* 复用于「各病种住院天数」与「年龄别BMI」。
|
||||
*/
|
||||
export const BoxPlotRows = memo(function BoxPlotRows({
|
||||
rows,
|
||||
unit = '',
|
||||
labelWidth = 96,
|
||||
}: BoxPlotRowsProps) {
|
||||
if (!rows || rows.length === 0) {
|
||||
return <div className="text-center py-8 text-text-muted text-sm">暂无数据</div>;
|
||||
}
|
||||
|
||||
// 统一横轴域:覆盖所有行的 p25..p75,留一点边距。
|
||||
const domainMin = Math.min(...rows.map((r) => r.p25));
|
||||
const domainMax = Math.max(...rows.map((r) => r.p75));
|
||||
const span = domainMax - domainMin || 1;
|
||||
const pct = (v: number) => ((v - domainMin) / span) * 100;
|
||||
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
{rows.map((r) => {
|
||||
const left = pct(r.p25);
|
||||
const right = pct(r.p75);
|
||||
const width = Math.max(right - left, 0.5);
|
||||
const medianLeft = pct(r.median);
|
||||
return (
|
||||
<div key={r.label} className="flex items-center gap-2 text-[11px]">
|
||||
<div
|
||||
className="shrink-0 truncate text-text-secondary text-right"
|
||||
style={{ width: labelWidth }}
|
||||
title={r.label}
|
||||
>
|
||||
{r.label}
|
||||
</div>
|
||||
<div className="relative flex-1 h-5 rounded bg-bg-hover">
|
||||
{/* p25–p75 箱体 */}
|
||||
<div
|
||||
className="absolute top-1 bottom-1 rounded-sm"
|
||||
style={{
|
||||
left: `${left}%`,
|
||||
width: `${width}%`,
|
||||
backgroundColor: CLINICAL_COLORS.box,
|
||||
opacity: 0.35,
|
||||
}}
|
||||
/>
|
||||
{/* 中位刻度 */}
|
||||
<div
|
||||
className="absolute top-0.5 bottom-0.5 w-[2px] rounded"
|
||||
style={{
|
||||
left: `${medianLeft}%`,
|
||||
backgroundColor: CLINICAL_COLORS.boxMedian,
|
||||
}}
|
||||
title={`中位 ${r.median}${unit}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="shrink-0 w-28 text-text-muted tabular-nums">
|
||||
{r.p25}–<span className="font-semibold text-text-secondary">{r.median}</span>–{r.p75}
|
||||
{unit}
|
||||
<span className="ml-1 text-[10px] text-text-muted">n={r.n}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
47
frontend/src/components/clinical/ClinicalKpiRow.tsx
Normal file
47
frontend/src/components/clinical/ClinicalKpiRow.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { memo } from 'react';
|
||||
import { Users, CalendarDays, Wallet, HeartPulse, Siren } from 'lucide-react';
|
||||
import { StatCard } from '@/components/StatCard';
|
||||
import { TESTIDS } from '@/utils/testids';
|
||||
import type { InpatientClinicalResponse } from '@/services/api';
|
||||
|
||||
interface ClinicalKpiRowProps {
|
||||
kpis: InpatientClinicalResponse['kpis'];
|
||||
}
|
||||
|
||||
/** 住院临床 5 项核心指标。375px 下 2 列,sm 起 5 列。 */
|
||||
export const ClinicalKpiRow = memo(function ClinicalKpiRow({ kpis }: ClinicalKpiRowProps) {
|
||||
return (
|
||||
<div
|
||||
data-testid={TESTIDS.clinicalKpis}
|
||||
className="grid grid-cols-2 sm:grid-cols-5 gap-3"
|
||||
>
|
||||
<StatCard
|
||||
icon={<Users className="w-4 h-4 text-primary" />}
|
||||
label="住院总人次"
|
||||
value={kpis.total_admissions.toLocaleString()}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<CalendarDays className="w-4 h-4 text-primary" />}
|
||||
label="中位住院日"
|
||||
value={`${kpis.median_los_days} 天`}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Wallet className="w-4 h-4 text-primary" />}
|
||||
label="人均费用"
|
||||
value={`¥${Math.round(kpis.mean_cost).toLocaleString()}`}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<HeartPulse className="w-4 h-4 text-success" />}
|
||||
label="治愈好转率"
|
||||
value={`${(kpis.cure_rate * 100).toFixed(1)}%`}
|
||||
color="#16A34A"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Siren className="w-4 h-4 text-warning" />}
|
||||
label="急诊入院占比"
|
||||
value={`${(kpis.emergency_admit_ratio * 100).toFixed(1)}%`}
|
||||
color="#D97706"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
62
frontend/src/components/clinical/CostByDiseaseChart.tsx
Normal file
62
frontend/src/components/clinical/CostByDiseaseChart.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { memo } from 'react';
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import { CLINICAL_COLORS, TOOLTIP_STYLE } from './chartColors';
|
||||
|
||||
interface CostByDiseaseChartProps {
|
||||
data: { diagnosis: string; mean_cost: number; n: number }[];
|
||||
}
|
||||
|
||||
function truncate(s: string, max: number): string {
|
||||
return s.length > max ? s.slice(0, max) + '…' : s;
|
||||
}
|
||||
|
||||
/** 各病种平均费用横向柱状图。 */
|
||||
export const CostByDiseaseChart = memo(function CostByDiseaseChart({
|
||||
data,
|
||||
}: CostByDiseaseChartProps) {
|
||||
if (!data || data.length === 0) {
|
||||
return <div className="text-center py-8 text-text-muted text-sm">暂无数据</div>;
|
||||
}
|
||||
|
||||
const chartData = [...data]
|
||||
.sort((a, b) => a.mean_cost - b.mean_cost)
|
||||
.map((d) => ({ ...d, displayName: truncate(d.diagnosis, 8) }));
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={Math.max(240, chartData.length * 34)}>
|
||||
<BarChart
|
||||
data={chartData}
|
||||
layout="vertical"
|
||||
margin={{ top: 5, right: 20, left: 12, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={CLINICAL_COLORS.grid} horizontal={false} />
|
||||
<XAxis
|
||||
type="number"
|
||||
tick={{ fontSize: 10, fill: CLINICAL_COLORS.axis }}
|
||||
tickFormatter={(v: number) => `¥${(v / 1000).toFixed(0)}k`}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="displayName"
|
||||
tick={{ fontSize: 10, fill: CLINICAL_COLORS.axisLabel }}
|
||||
width={72}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={TOOLTIP_STYLE}
|
||||
formatter={(v: number) => [`¥${Math.round(v).toLocaleString()}`, '人均费用']}
|
||||
/>
|
||||
<Bar dataKey="mean_cost" fill={CLINICAL_COLORS.cost} barSize={16} radius={[0, 3, 3, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
});
|
||||
55
frontend/src/components/clinical/CostVsLosScatter.tsx
Normal file
55
frontend/src/components/clinical/CostVsLosScatter.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { memo } from 'react';
|
||||
import {
|
||||
ScatterChart,
|
||||
Scatter,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import { CLINICAL_COLORS, TOOLTIP_STYLE } from './chartColors';
|
||||
|
||||
interface CostVsLosScatterProps {
|
||||
data: { los: number; cost: number }[];
|
||||
}
|
||||
|
||||
/** 费用 vs 住院天数散点。 */
|
||||
export const CostVsLosScatter = memo(function CostVsLosScatter({ data }: CostVsLosScatterProps) {
|
||||
if (!data || data.length === 0) {
|
||||
return <div className="text-center py-8 text-text-muted text-sm">暂无数据</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<ScatterChart margin={{ top: 10, right: 16, left: 6, bottom: 16 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={CLINICAL_COLORS.grid} />
|
||||
<XAxis
|
||||
type="number"
|
||||
dataKey="los"
|
||||
name="住院天数"
|
||||
unit="天"
|
||||
tick={{ fontSize: 10, fill: CLINICAL_COLORS.axis }}
|
||||
/>
|
||||
<YAxis
|
||||
type="number"
|
||||
dataKey="cost"
|
||||
name="费用"
|
||||
tick={{ fontSize: 10, fill: CLINICAL_COLORS.axis }}
|
||||
width={52}
|
||||
tickFormatter={(v: number) => `¥${(v / 1000).toFixed(0)}k`}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={TOOLTIP_STYLE}
|
||||
cursor={{ strokeDasharray: '3 3' }}
|
||||
formatter={(value: number, name: string) =>
|
||||
name === '费用'
|
||||
? [`¥${value.toLocaleString()}`, name]
|
||||
: [`${value} 天`, name]
|
||||
}
|
||||
/>
|
||||
<Scatter data={data} fill={CLINICAL_COLORS.scatter} fillOpacity={0.5} />
|
||||
</ScatterChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
});
|
||||
56
frontend/src/components/clinical/DonutChart.tsx
Normal file
56
frontend/src/components/clinical/DonutChart.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { memo } from 'react';
|
||||
import { PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
||||
import { CLINICAL_COLORS, TOOLTIP_STYLE } from './chartColors';
|
||||
|
||||
export interface DonutSlice {
|
||||
name: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface DonutChartProps {
|
||||
data: DonutSlice[];
|
||||
/** name -> color。未命中时按 palette 顺序回退。 */
|
||||
colorMap?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** 通用环形图。复用于「出院结局构成」与「入院途径构成」。 */
|
||||
export const DonutChart = memo(function DonutChart({ data, colorMap }: DonutChartProps) {
|
||||
if (!data || data.length === 0) {
|
||||
return <div className="text-center py-8 text-text-muted text-sm">暂无数据</div>;
|
||||
}
|
||||
|
||||
const total = data.reduce((s, d) => s + d.value, 0);
|
||||
const colorFor = (name: string, idx: number) =>
|
||||
colorMap?.[name] ??
|
||||
CLINICAL_COLORS.routePalette[idx % CLINICAL_COLORS.routePalette.length] ??
|
||||
CLINICAL_COLORS.outcomeFallback;
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={56}
|
||||
outerRadius={88}
|
||||
paddingAngle={2}
|
||||
>
|
||||
{data.map((d, idx) => (
|
||||
<Cell key={d.name} fill={colorFor(d.name, idx)} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={TOOLTIP_STYLE}
|
||||
formatter={(v: number, name: string) => [
|
||||
`${v.toLocaleString()}(${total > 0 ? ((v / total) * 100).toFixed(1) : '0'}%)`,
|
||||
name,
|
||||
]}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: '11px' }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
});
|
||||
51
frontend/src/components/clinical/HistogramChart.tsx
Normal file
51
frontend/src/components/clinical/HistogramChart.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { memo } from 'react';
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import { CLINICAL_COLORS, TOOLTIP_STYLE } from './chartColors';
|
||||
|
||||
interface HistogramChartProps {
|
||||
data: { bin_label: string; count: number }[];
|
||||
color?: string;
|
||||
/** tooltip 中数量的标签,如 "住院天数" / "费用区间"。 */
|
||||
countLabel?: string;
|
||||
}
|
||||
|
||||
/** 通用直方图。复用于「住院天数分布」与「住院费用分布」。 */
|
||||
export const HistogramChart = memo(function HistogramChart({
|
||||
data,
|
||||
color = CLINICAL_COLORS.los,
|
||||
countLabel = '人次',
|
||||
}: HistogramChartProps) {
|
||||
if (!data || data.length === 0) {
|
||||
return <div className="text-center py-8 text-text-muted text-sm">暂无数据</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={data} margin={{ top: 5, right: 12, left: 0, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={CLINICAL_COLORS.grid} vertical={false} />
|
||||
<XAxis
|
||||
dataKey="bin_label"
|
||||
tick={{ fontSize: 10, fill: CLINICAL_COLORS.axis }}
|
||||
interval={0}
|
||||
angle={-30}
|
||||
textAnchor="end"
|
||||
height={50}
|
||||
/>
|
||||
<YAxis tick={{ fontSize: 10, fill: CLINICAL_COLORS.axis }} width={40} />
|
||||
<Tooltip
|
||||
contentStyle={TOOLTIP_STYLE}
|
||||
formatter={(v: number) => [`${v.toLocaleString()}`, countLabel]}
|
||||
/>
|
||||
<Bar dataKey="count" fill={color} radius={[3, 3, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
});
|
||||
36
frontend/src/components/clinical/chartColors.ts
Normal file
36
frontend/src/components/clinical/chartColors.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 住院临床分析页图表字面色值集中处。
|
||||
* Recharts 需要原始 hex,无法用 Tailwind class,故在此集中定义,避免散落 magic hex。
|
||||
*/
|
||||
export const CLINICAL_COLORS = {
|
||||
primary: '#2563EB', // primary
|
||||
los: '#2563EB',
|
||||
cost: '#0891B2', // cyan — 费用维度
|
||||
scatter: '#7C3AED', // violet — 散点
|
||||
box: '#3B82F6', // 箱体填充
|
||||
boxMedian: '#1D4ED8', // 中位刻度
|
||||
grid: '#E2E8F0',
|
||||
axis: '#64748B',
|
||||
axisLabel: '#374151',
|
||||
tooltipBorder: '#E2E8F0',
|
||||
tooltipText: '#1E293B',
|
||||
// 出院结局按严重程度配色:治愈/好转偏绿,未愈/死亡偏红,其他中性
|
||||
outcome: {
|
||||
治愈: '#16A34A',
|
||||
好转: '#4ADE80',
|
||||
其他: '#94A3B8',
|
||||
未愈: '#F97316',
|
||||
死亡: '#DC2626',
|
||||
} as Record<string, string>,
|
||||
outcomeFallback: '#94A3B8',
|
||||
// 入院途径 donut 顺序色板
|
||||
routePalette: ['#2563EB', '#0891B2', '#7C3AED', '#D97706', '#16A34A', '#DC2626'],
|
||||
} as const;
|
||||
|
||||
/** Recharts tooltip 通用样式。 */
|
||||
export const TOOLTIP_STYLE = {
|
||||
backgroundColor: '#FFFFFF',
|
||||
border: `1px solid ${CLINICAL_COLORS.tooltipBorder}`,
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
} as const;
|
||||
175
frontend/src/pages/ClinicalAnalysis.tsx
Normal file
175
frontend/src/pages/ClinicalAnalysis.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Activity } from 'lucide-react';
|
||||
import { statsApi, type InpatientClinicalResponse } from '@/services/api';
|
||||
import { Card, LoadingState, EmptyState } from '@/components/ui';
|
||||
import { ErrorBanner } from '@/components/ErrorBanner';
|
||||
import { TESTIDS } from '@/utils/testids';
|
||||
import { ClinicalKpiRow } from '@/components/clinical/ClinicalKpiRow';
|
||||
import { HistogramChart } from '@/components/clinical/HistogramChart';
|
||||
import { BoxPlotRows, type BoxRow } from '@/components/clinical/BoxPlotRows';
|
||||
import { CostVsLosScatter } from '@/components/clinical/CostVsLosScatter';
|
||||
import { CostByDiseaseChart } from '@/components/clinical/CostByDiseaseChart';
|
||||
import { DonutChart, type DonutSlice } from '@/components/clinical/DonutChart';
|
||||
import { CLINICAL_COLORS } from '@/components/clinical/chartColors';
|
||||
|
||||
/** 数据是否完全为空(KPI 0 人次且各序列均空)。 */
|
||||
function isEmpty(d: InpatientClinicalResponse): boolean {
|
||||
return (
|
||||
(!d.kpis || d.kpis.total_admissions === 0) &&
|
||||
(d.los_histogram?.length ?? 0) === 0 &&
|
||||
(d.outcome_counts?.length ?? 0) === 0
|
||||
);
|
||||
}
|
||||
|
||||
export function ClinicalAnalysis() {
|
||||
const [data, setData] = useState<InpatientClinicalResponse | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await statsApi.getInpatientClinical();
|
||||
if (cancelled) return;
|
||||
setData(res);
|
||||
} catch {
|
||||
if (cancelled) return;
|
||||
setError('住院临床数据加载失败');
|
||||
} finally {
|
||||
if (!cancelled) setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const header = (
|
||||
<div>
|
||||
<h1 className="font-display text-[18px] font-semibold mb-1 flex items-center gap-2">
|
||||
<Activity className="w-5 h-5 text-primary" />
|
||||
住院临床分析
|
||||
</h1>
|
||||
<p className="text-[12px] text-text-secondary">
|
||||
住院天数、费用、出院结局与入院途径等临床特征分析
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div data-testid={TESTIDS.pageClinical} className="flex flex-col h-full overflow-auto p-6 space-y-6">
|
||||
{header}
|
||||
<LoadingState label="正在加载住院临床数据…" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div data-testid={TESTIDS.pageClinical} className="flex flex-col h-full overflow-auto p-6 space-y-6">
|
||||
{header}
|
||||
<ErrorBanner
|
||||
error={error ?? '住院临床数据加载失败'}
|
||||
onRetry={() => window.location.reload()}
|
||||
onDismiss={() => setError(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isEmpty(data)) {
|
||||
return (
|
||||
<div data-testid={TESTIDS.pageClinical} className="flex flex-col h-full overflow-auto p-6 space-y-6">
|
||||
{header}
|
||||
<EmptyState title="暂无住院临床数据" description="当前筛选范围内没有可用的住院记录。" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 出院结局:治愈/好转在前(按严重程度排序展示更直观)。
|
||||
const outcomeSlices: DonutSlice[] = (data.outcome_counts ?? []).map((o) => ({
|
||||
name: o.outcome,
|
||||
value: o.count,
|
||||
}));
|
||||
const routeSlices: DonutSlice[] = (data.admission_route_counts ?? []).map((r) => ({
|
||||
name: r.route,
|
||||
value: r.count,
|
||||
}));
|
||||
|
||||
const losBox: BoxRow[] = (data.los_by_disease ?? []).map((d) => ({
|
||||
label: d.diagnosis,
|
||||
p25: d.p25,
|
||||
median: d.median,
|
||||
p75: d.p75,
|
||||
n: d.n,
|
||||
}));
|
||||
const bmiBox: BoxRow[] = (data.bmi_by_age_band ?? []).map((d) => ({
|
||||
label: d.age_band,
|
||||
p25: d.p25,
|
||||
median: d.median,
|
||||
p75: d.p75,
|
||||
n: d.n,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid={TESTIDS.pageClinical}
|
||||
className="flex flex-col h-full overflow-auto"
|
||||
>
|
||||
<div className="p-6 space-y-6">
|
||||
{header}
|
||||
|
||||
{/* KPI 行 */}
|
||||
<ClinicalKpiRow kpis={data.kpis} />
|
||||
|
||||
{/* 住院天数:分布 + 各病种箱线 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card title="住院天数分布">
|
||||
<HistogramChart data={data.los_histogram ?? []} color={CLINICAL_COLORS.los} countLabel="人次" />
|
||||
</Card>
|
||||
<Card title="各病种住院天数(P25–中位–P75)">
|
||||
<BoxPlotRows rows={losBox} unit="天" />
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 费用:分布 + 各病种平均费用 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card title="住院费用分布">
|
||||
<HistogramChart data={data.cost_histogram ?? []} color={CLINICAL_COLORS.cost} countLabel="人次" />
|
||||
</Card>
|
||||
<Card title="各病种平均费用">
|
||||
<CostByDiseaseChart data={data.cost_by_disease ?? []} />
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 费用 vs 住院天数 散点 */}
|
||||
<Card title="费用 vs 住院天数">
|
||||
<CostVsLosScatter data={data.cost_vs_los ?? []} />
|
||||
</Card>
|
||||
|
||||
{/* 出院结局 + 入院途径 双环 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card title="出院结局构成">
|
||||
<DonutChart data={outcomeSlices} colorMap={CLINICAL_COLORS.outcome} />
|
||||
</Card>
|
||||
<Card title="入院途径构成">
|
||||
<DonutChart data={routeSlices} />
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 年龄别 BMI 箱线 */}
|
||||
<Card title="年龄别 BMI(P25–中位–P75)">
|
||||
<BoxPlotRows rows={bmiBox} />
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,8 +11,9 @@ import {
|
||||
Cell,
|
||||
ReferenceLine,
|
||||
} from 'recharts';
|
||||
import { Stethoscope, Activity } from 'lucide-react';
|
||||
import { caseApi } from '@/services/api';
|
||||
import { Stethoscope, Activity, MessageSquareText } from 'lucide-react';
|
||||
import { caseApi, statsApi } from '@/services/api';
|
||||
import type { SymptomsResponse } from '@/services/api';
|
||||
import { ErrorBanner } from '@/components/ErrorBanner';
|
||||
import type {
|
||||
DiagnosisDistributionItem,
|
||||
@@ -374,12 +375,64 @@ function DiagnosisSummaryTable({
|
||||
);
|
||||
}
|
||||
|
||||
// --- Chart 5: Outpatient Symptom Keyword Frequency ---
|
||||
|
||||
function SymptomFrequencyChart({ data }: { data: SymptomsResponse['symptoms'] }) {
|
||||
if (!data || data.length === 0) {
|
||||
return <div className="text-center py-8 text-gray-400 text-sm">暂无数据</div>;
|
||||
}
|
||||
|
||||
// Sort by count desc; chart renders bottom-up so reverse for top-at-top display.
|
||||
const sorted = [...data].sort((a, b) => b.count - a.count);
|
||||
const chartData = sorted.map((d) => ({
|
||||
...d,
|
||||
displayName: truncate(d.keyword, 8),
|
||||
}));
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={Math.max(320, chartData.length * 22)}>
|
||||
<BarChart
|
||||
data={[...chartData].reverse()}
|
||||
layout="vertical"
|
||||
margin={{ top: 5, right: 20, left: 40, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" horizontal={false} />
|
||||
<XAxis
|
||||
type="number"
|
||||
tick={{ fontSize: 10, fill: '#64748B' }}
|
||||
tickFormatter={(v) => v.toLocaleString()}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="displayName"
|
||||
tick={{ fontSize: 10, fill: '#374151' }}
|
||||
width={70}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#FFFFFF',
|
||||
border: '1px solid #E2E8F0',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
formatter={(value: number) => [value.toLocaleString(), '出现次数']}
|
||||
/>
|
||||
<Bar dataKey="count" name="count" fill="#3B82F6" barSize={14} radius={[0, 3, 3, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Page Component ---
|
||||
|
||||
export function DiseaseAnalysis() {
|
||||
const [diagDistribution, setDiagDistribution] = useState<DiagnosisDistributionItem[]>([]);
|
||||
const [seasonality, setSeasonality] = useState<DiseaseSeasonalityPoint[]>([]);
|
||||
const [districts, setDistricts] = useState<DistrictCaseData[]>([]);
|
||||
const [symptoms, setSymptoms] = useState<SymptomsResponse['symptoms']>([]);
|
||||
const [revisitRatio, setRevisitRatio] = useState<number | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [errors, setErrors] = useState<string[]>([]);
|
||||
|
||||
@@ -390,10 +443,11 @@ export function DiseaseAnalysis() {
|
||||
setIsLoading(true);
|
||||
setErrors([]);
|
||||
|
||||
const [distR, seasonR, districtR] = await Promise.allSettled([
|
||||
const [distR, seasonR, districtR, symptomR] = await Promise.allSettled([
|
||||
caseApi.getDiagnosisDistribution(15),
|
||||
caseApi.getDiseaseSeasonality(),
|
||||
caseApi.getDistricts(),
|
||||
statsApi.getSymptoms(20),
|
||||
]);
|
||||
|
||||
if (cancelled) return;
|
||||
@@ -418,6 +472,15 @@ export function DiseaseAnalysis() {
|
||||
newErrors.push('区县数据加载失败');
|
||||
}
|
||||
|
||||
if (symptomR.status === 'fulfilled') {
|
||||
setSymptoms(symptomR.value.symptoms || []);
|
||||
setRevisitRatio(
|
||||
typeof symptomR.value.revisit_ratio === 'number' ? symptomR.value.revisit_ratio : null,
|
||||
);
|
||||
} else {
|
||||
newErrors.push('症状词频数据加载失败');
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
setIsLoading(false);
|
||||
};
|
||||
@@ -497,6 +560,26 @@ export function DiseaseAnalysis() {
|
||||
</div>
|
||||
<DiagnosisSummaryTable diagnoses={diagDistribution} districtsData={districts} />
|
||||
</div>
|
||||
|
||||
{/* Chart 5: Outpatient Symptom Keyword Frequency */}
|
||||
<div data-testid="symptom-freq" className="card p-4">
|
||||
<div className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-1 flex items-center gap-2">
|
||||
<MessageSquareText className="w-3.5 h-3.5 text-gray-400" />
|
||||
门诊主诉症状词频
|
||||
</div>
|
||||
<div className="text-[11px] text-gray-500 mb-4">
|
||||
主诉文本高频词(含复诊/随诊等就诊类型词)
|
||||
{revisitRatio !== null && (
|
||||
<span className="ml-2">
|
||||
复诊占比:
|
||||
<span className="font-semibold text-gray-700">
|
||||
{(revisitRatio * 100).toFixed(1)}%
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<SymptomFrequencyChart data={symptoms} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { LoadingState } from '@/components/ui';
|
||||
import { LoadingState, Segmented } from '@/components/ui';
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
ReferenceLine,
|
||||
} from 'recharts';
|
||||
import { useAnalysisStore } from '@/stores/analysisStore';
|
||||
import { caseApi } from '@/services/api';
|
||||
import { caseApi, statsApi } from '@/services/api';
|
||||
import type { IncidenceRateResponse } from '@/services/api';
|
||||
import { ErrorBanner } from '@/components/ErrorBanner';
|
||||
import { MetricHeatmapTable } from '@/components/MetricHeatmapTable';
|
||||
import { BarChart3, MapPin, Users, Shield } from 'lucide-react';
|
||||
@@ -36,6 +37,10 @@ export function DistrictComparison() {
|
||||
const [caseDistrictData, setCaseDistrictData] = useState<DistrictCaseData[]>([]);
|
||||
const [caseDataLoading, setCaseDataLoading] = useState(false);
|
||||
const [caseDataError, setCaseDataError] = useState<string | null>(null);
|
||||
const [incidence, setIncidence] = useState<IncidenceRateResponse['districts']>([]);
|
||||
const [incidenceLoading, setIncidenceLoading] = useState(false);
|
||||
const [incidenceError, setIncidenceError] = useState<string | null>(null);
|
||||
const [incidenceMetric, setIncidenceMetric] = useState<'rate' | 'count'>('rate');
|
||||
|
||||
useEffect(() => {
|
||||
fetchDistricts();
|
||||
@@ -61,6 +66,26 @@ export function DistrictComparison() {
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setIncidenceLoading(true);
|
||||
setIncidenceError(null);
|
||||
statsApi.getIncidenceRate()
|
||||
.then((res) => {
|
||||
if (!cancelled) {
|
||||
setIncidence(res.districts || []);
|
||||
setIncidenceLoading(false);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) {
|
||||
setIncidenceError((e as Error).message || '加载发病率数据失败');
|
||||
setIncidenceLoading(false);
|
||||
}
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const metricConfig = {
|
||||
avg_aqi: { label: '平均AQI', color: '#2563EB', unit: '' },
|
||||
avg_risk: { label: '平均风险', color: '#DC2626', unit: '' },
|
||||
@@ -104,6 +129,12 @@ export function DistrictComparison() {
|
||||
return { data, cityOIAvg };
|
||||
}, [caseDistrictData]);
|
||||
|
||||
const incidenceChart = useMemo(() => {
|
||||
const valueKey = incidenceMetric === 'rate' ? 'rate_per_10k' : 'total_cases';
|
||||
const data = [...incidence].sort((a, b) => b[valueKey] - a[valueKey]);
|
||||
return { data, valueKey };
|
||||
}, [incidence, incidenceMetric]);
|
||||
|
||||
const heatmapMetrics = useMemo(() => {
|
||||
const caseMap = new Map(caseDistrictData.map((d) => [d.district, d]));
|
||||
const rows: string[] = [];
|
||||
@@ -155,6 +186,25 @@ export function DistrictComparison() {
|
||||
onDismiss={() => setCaseDataError(null)}
|
||||
/>
|
||||
)}
|
||||
{incidenceError && (
|
||||
<ErrorBanner
|
||||
error={incidenceError}
|
||||
onRetry={() => {
|
||||
setIncidenceError(null);
|
||||
setIncidenceLoading(true);
|
||||
statsApi.getIncidenceRate()
|
||||
.then((res) => {
|
||||
setIncidence(res.districts || []);
|
||||
setIncidenceLoading(false);
|
||||
})
|
||||
.catch((e) => {
|
||||
setIncidenceError((e as Error).message || '加载发病率数据失败');
|
||||
setIncidenceLoading(false);
|
||||
});
|
||||
}}
|
||||
onDismiss={() => setIncidenceError(null)}
|
||||
/>
|
||||
)}
|
||||
<div className="mb-5">
|
||||
<h1 className="font-display text-[18px] font-semibold mb-1 flex items-center gap-2">
|
||||
<BarChart3 className="w-5 h-5 text-primary" />
|
||||
@@ -380,6 +430,79 @@ export function DistrictComparison() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Standardized Incidence Rate (per 10k) with count/rate toggle */}
|
||||
<div data-testid="incidence-rate" className="card p-4 mb-4">
|
||||
<div className="flex items-center justify-between gap-2 mb-4 flex-wrap">
|
||||
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide">
|
||||
{incidenceMetric === 'rate' ? '标化发病率(每万人)区域排名' : '病例数 区域排名'}
|
||||
</div>
|
||||
<Segmented
|
||||
testid="incidence-rate-toggle"
|
||||
size="sm"
|
||||
value={incidenceMetric}
|
||||
onChange={setIncidenceMetric}
|
||||
options={[
|
||||
{ value: 'count', label: '病例数' },
|
||||
{ value: 'rate', label: '每万人发病率' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[11px] text-text-muted mb-4">
|
||||
标化发病率按人口归一化,可避免人口规模差异造成的误读(原始病例数会高估人口大区)。
|
||||
</p>
|
||||
{incidenceLoading && <LoadingState />}
|
||||
{!incidenceLoading && incidenceChart.data.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={380}>
|
||||
<BarChart
|
||||
data={incidenceChart.data}
|
||||
margin={{ top: 5, right: 20, left: 10, bottom: 5 }}
|
||||
layout="vertical"
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" horizontal={false} />
|
||||
<XAxis
|
||||
type="number"
|
||||
tick={{ fontSize: 12, fill: '#64748B' }}
|
||||
axisLine={{ stroke: '#E2E8F0' }}
|
||||
tickFormatter={(v: number) => v.toLocaleString()}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="district"
|
||||
tick={{ fontSize: 12, fill: '#1E293B', fontWeight: 500 }}
|
||||
axisLine={{ stroke: '#E2E8F0' }}
|
||||
width={80}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#FFFFFF',
|
||||
border: '1px solid #E2E8F0',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
formatter={(_value: number, _name, item) => {
|
||||
const p = item?.payload as IncidenceRateResponse['districts'][number];
|
||||
return [
|
||||
`病例数 ${p.total_cases.toLocaleString()} · 人口 ${p.population.toLocaleString()} · 每万人 ${p.rate_per_10k.toLocaleString()}`,
|
||||
incidenceMetric === 'rate' ? '标化发病率' : '病例数',
|
||||
];
|
||||
}}
|
||||
/>
|
||||
<Bar
|
||||
dataKey={incidenceChart.valueKey}
|
||||
name={incidenceMetric === 'rate' ? '每万人发病率' : '病例数'}
|
||||
radius={[0, 4, 4, 0]}
|
||||
maxBarSize={32}
|
||||
fill={incidenceMetric === 'rate' ? '#DC2626' : '#2563EB'}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
!incidenceLoading && (
|
||||
<div className="text-center py-8 text-text-secondary">无发病率数据</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* O/I Ratio Comparison Bar Chart */}
|
||||
<div className="card p-4 mb-4">
|
||||
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { useEffect, useState, useMemo, Fragment } from 'react';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
@@ -12,9 +12,13 @@ import {
|
||||
ResponsiveContainer,
|
||||
ReferenceLine,
|
||||
Cell,
|
||||
ScatterChart,
|
||||
Scatter,
|
||||
ZAxis,
|
||||
} from 'recharts';
|
||||
import { Wind } from 'lucide-react';
|
||||
import { envApi, caseApi } from '@/services/api';
|
||||
import { envApi, caseApi, statsApi } from '@/services/api';
|
||||
import type { EnvCorrelationResponse } from '@/services/api';
|
||||
import { ErrorBanner } from '@/components/ErrorBanner';
|
||||
import { CalendarHeatmap } from '@/components/CalendarHeatmap';
|
||||
import type {
|
||||
@@ -58,6 +62,55 @@ function getAQICategory(aqi: number): typeof AQI_CATEGORIES[number] {
|
||||
return AQI_CATEGORIES[AQI_CATEGORIES.length - 1];
|
||||
}
|
||||
|
||||
// Canonical pollutant order for the 7×7 correlation matrix.
|
||||
const CORR_POLLUTANTS = ['AQI', 'PM25', 'PM10', 'SO2', 'NO2', 'O3', 'CO'];
|
||||
|
||||
function pollutantLabel(key: string): string {
|
||||
switch (key) {
|
||||
case 'PM25':
|
||||
return 'PM2.5';
|
||||
case 'SO2':
|
||||
return 'SO₂';
|
||||
case 'NO2':
|
||||
return 'NO₂';
|
||||
case 'O3':
|
||||
return 'O₃';
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
// Diverging color scale for a correlation value in [-1, 1].
|
||||
// Blue (negative) → white (0) → red (positive); opacity scales with |corr|.
|
||||
function corrColor(corr: number): string {
|
||||
const v = Math.max(-1, Math.min(1, corr));
|
||||
if (v >= 0) return `rgba(220, 38, 38, ${0.12 + 0.88 * v})`;
|
||||
return `rgba(37, 99, 235, ${0.12 + 0.88 * -v})`;
|
||||
}
|
||||
|
||||
// Least-squares linear regression: returns slope/intercept over (x, y) points.
|
||||
function linearRegression(
|
||||
points: { x: number; y: number }[],
|
||||
): { slope: number; intercept: number } | null {
|
||||
const n = points.length;
|
||||
if (n < 2) return null;
|
||||
let sx = 0;
|
||||
let sy = 0;
|
||||
let sxx = 0;
|
||||
let sxy = 0;
|
||||
for (const p of points) {
|
||||
sx += p.x;
|
||||
sy += p.y;
|
||||
sxx += p.x * p.x;
|
||||
sxy += p.x * p.y;
|
||||
}
|
||||
const denom = n * sxx - sx * sx;
|
||||
if (Math.abs(denom) < 1e-9) return null;
|
||||
const slope = (n * sxy - sx * sy) / denom;
|
||||
const intercept = (sy - slope * sx) / n;
|
||||
return { slope, intercept };
|
||||
}
|
||||
|
||||
function findMaxLag(correlations: LagCorrelationItem[], pollutant: string): number | null {
|
||||
if (correlations.length === 0) return null;
|
||||
const pollData = correlations.filter(
|
||||
@@ -83,6 +136,7 @@ export function EnvironmentalHealth() {
|
||||
const [pollutants365, setPollutants365] = useState<PollutantPoint[]>([]);
|
||||
const [pollutants30, setPollutants30] = useState<PollutantPoint[]>([]);
|
||||
const [caseTrend, setCaseTrend] = useState<CaseTrendPoint[]>([]);
|
||||
const [envCorr, setEnvCorr] = useState<EnvCorrelationResponse | null>(null);
|
||||
|
||||
// UI states
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -104,11 +158,12 @@ export function EnvironmentalHealth() {
|
||||
setIsLoading(true);
|
||||
setErrors([]);
|
||||
|
||||
const [lagR, p365R, p30R, caseTrendR] = await Promise.allSettled([
|
||||
const [lagR, p365R, p30R, caseTrendR, envCorrR] = await Promise.allSettled([
|
||||
envApi.getLagCorrelations(),
|
||||
envApi.getPollutants(365),
|
||||
envApi.getPollutants(30),
|
||||
caseApi.getTrend({ group_by: 'day' }),
|
||||
statsApi.getEnvCorrelation(),
|
||||
]);
|
||||
|
||||
if (cancelled) return;
|
||||
@@ -145,6 +200,12 @@ export function EnvironmentalHealth() {
|
||||
newErrors.push('病例趋势数据加载失败');
|
||||
}
|
||||
|
||||
if (envCorrR.status === 'fulfilled') {
|
||||
setEnvCorr(envCorrR.value);
|
||||
} else {
|
||||
newErrors.push('污染物关联分析数据加载失败');
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
setIsLoading(false);
|
||||
};
|
||||
@@ -266,6 +327,61 @@ export function EnvironmentalHealth() {
|
||||
}));
|
||||
}, [pollutants30]);
|
||||
|
||||
// --- Correlation: pollutant × cases (grid-level Pearson) ---
|
||||
const corrWithCases = useMemo(() => {
|
||||
const matrix = envCorr?.correlation_matrix ?? [];
|
||||
return matrix
|
||||
.map((m) => ({
|
||||
pollutant: m.pollutant,
|
||||
label: pollutantLabel(m.pollutant),
|
||||
corr: m.corr_with_cases,
|
||||
}))
|
||||
.sort((a, b) => Math.abs(b.corr) - Math.abs(a.corr));
|
||||
}, [envCorr]);
|
||||
|
||||
// --- Correlation: pollutant pairwise 7×7 heatmap ---
|
||||
// pollutant_pairwise carries the upper triangle (21 pairs); we mirror it into
|
||||
// a symmetric lookup and fill the diagonal with 1.
|
||||
const pairwiseGrid = useMemo(() => {
|
||||
const pairs = envCorr?.pollutant_pairwise ?? [];
|
||||
if (pairs.length === 0) return null;
|
||||
const lookup = new Map<string, number>();
|
||||
for (const p of pairs) {
|
||||
lookup.set(`${p.a}|${p.b}`, p.corr);
|
||||
lookup.set(`${p.b}|${p.a}`, p.corr);
|
||||
}
|
||||
return CORR_POLLUTANTS.map((rowKey) =>
|
||||
CORR_POLLUTANTS.map((colKey) => {
|
||||
if (rowKey === colKey) return 1;
|
||||
return lookup.get(`${rowKey}|${colKey}`) ?? null;
|
||||
}),
|
||||
);
|
||||
}, [envCorr]);
|
||||
|
||||
// --- Scatter: PM2.5 × cases + least-squares regression line ---
|
||||
const scatterPoints = useMemo(() => {
|
||||
return (envCorr?.scatter ?? []).map((s) => ({ pm25: s.pm25, cases: s.cases }));
|
||||
}, [envCorr]);
|
||||
|
||||
const regression = useMemo(() => {
|
||||
return linearRegression(scatterPoints.map((p) => ({ x: p.pm25, y: p.cases })));
|
||||
}, [scatterPoints]);
|
||||
|
||||
// Two endpoints across the observed PM2.5 range to draw the trend line.
|
||||
const regressionLine = useMemo(() => {
|
||||
if (!regression || scatterPoints.length === 0) return [];
|
||||
let minX = Infinity;
|
||||
let maxX = -Infinity;
|
||||
for (const p of scatterPoints) {
|
||||
if (p.pm25 < minX) minX = p.pm25;
|
||||
if (p.pm25 > maxX) maxX = p.pm25;
|
||||
}
|
||||
return [
|
||||
{ pm25: minX, cases: regression.slope * minX + regression.intercept },
|
||||
{ pm25: maxX, cases: regression.slope * maxX + regression.intercept },
|
||||
];
|
||||
}, [regression, scatterPoints]);
|
||||
|
||||
// --- Loading state ---
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -706,6 +822,229 @@ export function EnvironmentalHealth() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* === 污染物-病例关联分析 === */}
|
||||
<div data-testid="env-correlation" className="space-y-6">
|
||||
<div>
|
||||
<h2 className="font-display text-[15px] font-semibold mb-1">
|
||||
污染物-病例关联分析
|
||||
</h2>
|
||||
<p className="text-[11px] text-text-muted">
|
||||
基于网格级 Pearson 相关系数(grid-level)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* (a) Pollutant × Cases correlation */}
|
||||
<div className="card p-4">
|
||||
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
|
||||
污染物 × 病例相关性
|
||||
</div>
|
||||
{corrWithCases.length > 0 ? (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart
|
||||
data={corrWithCases}
|
||||
layout="vertical"
|
||||
margin={{ top: 5, right: 20, left: 50, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="#E2E8F0"
|
||||
horizontal={false}
|
||||
/>
|
||||
<XAxis
|
||||
type="number"
|
||||
domain={[-1, 1]}
|
||||
tick={{ fontSize: 11, fill: '#64748B' }}
|
||||
axisLine={{ stroke: '#E2E8F0' }}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 11, fill: '#374151' }}
|
||||
width={50}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#FFFFFF',
|
||||
border: '1px solid #E2E8F0',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
formatter={(value: number) => [value.toFixed(3), '相关系数']}
|
||||
/>
|
||||
<ReferenceLine x={0} stroke="#94A3B8" strokeWidth={1} />
|
||||
<Bar dataKey="corr" barSize={20} radius={[0, 4, 4, 0]}>
|
||||
{corrWithCases.map((entry, idx) => (
|
||||
<Cell key={idx} fill={corrColor(entry.corr)} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<p className="text-[10px] text-text-muted mt-3 text-center">
|
||||
网格级 Pearson 相关系数(红=正相关,蓝=负相关,颜色深浅表示强度)
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-8 text-text-muted text-sm">
|
||||
暂无数据
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* (b) Pollutant pairwise correlation heatmap */}
|
||||
<div className="card p-4">
|
||||
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
|
||||
污染物两两相关热力图
|
||||
</div>
|
||||
{pairwiseGrid ? (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<div
|
||||
className="grid gap-px min-w-[320px]"
|
||||
style={{
|
||||
gridTemplateColumns: `48px repeat(${CORR_POLLUTANTS.length}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{/* Header row */}
|
||||
<div />
|
||||
{CORR_POLLUTANTS.map((key) => (
|
||||
<div
|
||||
key={`h-${key}`}
|
||||
className="text-[10px] font-medium text-text-muted text-center py-1"
|
||||
>
|
||||
{pollutantLabel(key)}
|
||||
</div>
|
||||
))}
|
||||
{/* Body rows */}
|
||||
{pairwiseGrid.map((row, ri) => (
|
||||
<Fragment key={`r-${CORR_POLLUTANTS[ri]}`}>
|
||||
<div className="text-[10px] font-medium text-text-muted flex items-center justify-end pr-2">
|
||||
{pollutantLabel(CORR_POLLUTANTS[ri])}
|
||||
</div>
|
||||
{row.map((val, ci) => (
|
||||
<div
|
||||
key={`c-${ri}-${ci}`}
|
||||
className="aspect-square flex items-center justify-center text-[9px] font-medium rounded-sm"
|
||||
style={{
|
||||
backgroundColor:
|
||||
val === null ? '#F1F5F9' : corrColor(val),
|
||||
color:
|
||||
val !== null && Math.abs(val) > 0.55
|
||||
? '#FFFFFF'
|
||||
: '#475569',
|
||||
}}
|
||||
title={`${pollutantLabel(CORR_POLLUTANTS[ri])} × ${pollutantLabel(
|
||||
CORR_POLLUTANTS[ci],
|
||||
)}: ${val === null ? 'N/A' : val.toFixed(2)}`}
|
||||
>
|
||||
{val === null ? '-' : val.toFixed(2)}
|
||||
</div>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-text-muted mt-3 text-center">
|
||||
对角线为自相关(=1.00);红=正相关,蓝=负相关
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-8 text-text-muted text-sm">
|
||||
暂无数据
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* (c) PM2.5 × cases scatter + regression line */}
|
||||
<div className="card p-4">
|
||||
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
|
||||
PM2.5 × 病例 散点 + 回归线
|
||||
</div>
|
||||
{scatterPoints.length > 0 ? (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={320}>
|
||||
<ScatterChart margin={{ top: 5, right: 20, left: 10, bottom: 15 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
|
||||
<XAxis
|
||||
type="number"
|
||||
dataKey="pm25"
|
||||
name="PM2.5"
|
||||
unit="μg/m³"
|
||||
tick={{ fontSize: 11, fill: '#64748B' }}
|
||||
axisLine={{ stroke: '#E2E8F0' }}
|
||||
label={{
|
||||
value: 'PM2.5 (μg/m³)',
|
||||
position: 'insideBottom',
|
||||
offset: -8,
|
||||
fontSize: 11,
|
||||
fill: '#64748B',
|
||||
}}
|
||||
/>
|
||||
<YAxis
|
||||
type="number"
|
||||
dataKey="cases"
|
||||
name="病例数"
|
||||
tick={{ fontSize: 11, fill: '#64748B' }}
|
||||
axisLine={{ stroke: '#E2E8F0' }}
|
||||
label={{
|
||||
value: '病例数',
|
||||
angle: -90,
|
||||
position: 'insideLeft',
|
||||
offset: 0,
|
||||
fontSize: 11,
|
||||
fill: '#64748B',
|
||||
}}
|
||||
/>
|
||||
<ZAxis range={[30, 30]} />
|
||||
<Tooltip
|
||||
cursor={{ strokeDasharray: '3 3' }}
|
||||
contentStyle={{
|
||||
backgroundColor: '#FFFFFF',
|
||||
border: '1px solid #E2E8F0',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
formatter={(value: number, name: string) => [
|
||||
Math.round(value).toLocaleString(),
|
||||
name,
|
||||
]}
|
||||
/>
|
||||
<Scatter
|
||||
name="网格点"
|
||||
data={scatterPoints}
|
||||
fill="#7C3AED"
|
||||
fillOpacity={0.4}
|
||||
/>
|
||||
{regressionLine.length === 2 && (
|
||||
<Scatter
|
||||
name="回归线"
|
||||
data={regressionLine}
|
||||
line={{ stroke: '#DC2626', strokeWidth: 2 }}
|
||||
lineType="joint"
|
||||
fill="#DC2626"
|
||||
shape={() => <g />}
|
||||
legendType="none"
|
||||
/>
|
||||
)}
|
||||
</ScatterChart>
|
||||
</ResponsiveContainer>
|
||||
{regression && (
|
||||
<p className="text-[10px] text-text-muted mt-3 text-center">
|
||||
最小二乘回归:病例 ≈ {regression.slope.toFixed(2)} × PM2.5 +{' '}
|
||||
{regression.intercept.toFixed(1)}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-8 text-text-muted text-sm">
|
||||
暂无数据
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -16,7 +16,8 @@ import {
|
||||
ReferenceLine,
|
||||
} from 'recharts';
|
||||
import { useAnalysisStore } from '@/stores/analysisStore';
|
||||
import { caseApi } from '@/services/api';
|
||||
import { caseApi, statsApi } from '@/services/api';
|
||||
import type { TemporalResponse } from '@/services/api';
|
||||
import { ErrorBanner } from '@/components/ErrorBanner';
|
||||
import { TrendingUp, Calendar, Activity } from 'lucide-react';
|
||||
import type { CaseTrendPoint } from '@/types';
|
||||
@@ -48,6 +49,7 @@ export function TrendAnalysis() {
|
||||
const [selectedPollutants, setSelectedPollutants] = useState<string[]>(['aqi', 'pm25']);
|
||||
const [multiYearData, setMultiYearData] = useState<Record<string, CaseTrendPoint[]>>({});
|
||||
const [multiYearLoading, setMultiYearLoading] = useState(false);
|
||||
const [weekdayData, setWeekdayData] = useState<TemporalResponse['weekday']>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTrend(selectedDays);
|
||||
@@ -82,6 +84,19 @@ export function TrendAnalysis() {
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
statsApi
|
||||
.getTemporal()
|
||||
.then((res) => {
|
||||
if (!cancelled) setWeekdayData(res.weekday || []);
|
||||
})
|
||||
.catch(() => {
|
||||
// weekday distribution is supplementary — fail silently
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
// Merge multi-year data by month (data is monthly) over a fixed 1..12 sequence
|
||||
const mergedMultiYearData = (() => {
|
||||
const yearColors: Record<string, string> = { '2022': '#94A3B8', '2023': '#3B82F6', '2024': '#EF4444' };
|
||||
@@ -444,6 +459,52 @@ export function TrendAnalysis() {
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Weekday case distribution (门诊/住院) */}
|
||||
<div data-testid="weekday-dist" className="card p-4 mb-4">
|
||||
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
|
||||
星期就诊分布
|
||||
</div>
|
||||
{weekdayData.length > 0 ? (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<BarChart
|
||||
data={weekdayData}
|
||||
margin={{ top: 5, right: 20, left: 10, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
|
||||
<XAxis
|
||||
dataKey="weekday"
|
||||
tick={{ fontSize: 12, fill: '#64748B' }}
|
||||
axisLine={{ stroke: '#E2E8F0' }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12, fill: '#64748B' }}
|
||||
axisLine={{ stroke: '#E2E8F0' }}
|
||||
label={{ value: '就诊量', angle: -90, position: 'insideLeft', offset: 0, fontSize: 11, fill: '#64748B' }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#FFFFFF',
|
||||
border: '1px solid #E2E8F0',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
formatter={(value: number, name: string) => [value.toLocaleString(), name]}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: '12px', paddingTop: '12px' }} />
|
||||
<Bar dataKey="outpatient" name="门诊" stackId="visits" fill="#3B82F6" radius={[0, 0, 0, 0]} />
|
||||
<Bar dataKey="inpatient" name="住院" stackId="visits" fill="#EF4444" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<p className="text-[10px] text-text-muted mt-3 text-center">
|
||||
注:现有病例数据集中在12月,季节性/同比分析待更多月份数据
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-8 text-text-muted text-sm">暂无就诊分布数据</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,9 @@ const DiseaseAnalysis = lazy(() =>
|
||||
const EnvironmentalHealth = lazy(() =>
|
||||
import('@/pages/EnvironmentalHealth').then((m) => ({ default: m.EnvironmentalHealth }))
|
||||
);
|
||||
const ClinicalAnalysis = lazy(() =>
|
||||
import('@/pages/ClinicalAnalysis').then((m) => ({ default: m.ClinicalAnalysis }))
|
||||
);
|
||||
|
||||
// 用 Suspense 包裹懒加载页面,统一加载态。
|
||||
function lazyElement(Page: ComponentType): JSX.Element {
|
||||
@@ -56,6 +59,7 @@ export const appRoutes: RouteObject[] = [
|
||||
{ path: 'analysis/demographics', element: lazyElement(DemographicAnalysis) },
|
||||
{ path: 'analysis/disease', element: lazyElement(DiseaseAnalysis) },
|
||||
{ path: 'analysis/environment', element: lazyElement(EnvironmentalHealth) },
|
||||
{ path: 'analysis/clinical', element: lazyElement(ClinicalAnalysis) },
|
||||
// 未知路径回退到监测面板。
|
||||
{ path: '*', element: <Navigate to="/monitoring" replace /> },
|
||||
];
|
||||
|
||||
@@ -343,4 +343,48 @@ export const reportApi = {
|
||||
getLatestSummary: (): Promise<ReportSummary> => cachedGet('/reports/summary/latest'),
|
||||
};
|
||||
|
||||
// ===== 深度统计分析(/api/stats)=====
|
||||
export interface InpatientClinicalResponse {
|
||||
kpis: {
|
||||
total_admissions: number;
|
||||
median_los_days: number;
|
||||
mean_cost: number;
|
||||
cure_rate: number;
|
||||
emergency_admit_ratio: number;
|
||||
};
|
||||
los_histogram: { bin_label: string; count: number }[];
|
||||
los_by_disease: { diagnosis: string; p25: number; median: number; p75: number; n: number }[];
|
||||
cost_histogram: { bin_label: string; count: number }[];
|
||||
cost_by_disease: { diagnosis: string; mean_cost: number; n: number }[];
|
||||
cost_vs_los: { los: number; cost: number }[];
|
||||
outcome_counts: { outcome: string; count: number }[];
|
||||
admission_route_counts: { route: string; count: number }[];
|
||||
bmi_by_age_band: { age_band: string; p25: number; median: number; p75: number; n: number }[];
|
||||
}
|
||||
export interface SymptomsResponse {
|
||||
symptoms: { keyword: string; count: number }[];
|
||||
revisit_ratio: number;
|
||||
}
|
||||
export interface IncidenceRateResponse {
|
||||
districts: { district: string; total_cases: number; population: number; rate_per_10k: number }[];
|
||||
}
|
||||
export interface EnvCorrelationResponse {
|
||||
correlation_matrix: { pollutant: string; corr_with_cases: number }[];
|
||||
scatter: { pm25: number; aqi: number; cases: number }[];
|
||||
pollutant_pairwise: { a: string; b: string; corr: number }[];
|
||||
}
|
||||
export interface TemporalResponse {
|
||||
weekday: { weekday: string; outpatient: number; inpatient: number; total: number }[];
|
||||
month_year: { year: number; month: number; total: number }[];
|
||||
yoy: { period: string; current: number; previous: number; growth_pct: number }[];
|
||||
}
|
||||
|
||||
export const statsApi = {
|
||||
getInpatientClinical: (): Promise<InpatientClinicalResponse> => cachedGet('/stats/inpatient-clinical'),
|
||||
getSymptoms: (top: number = 20): Promise<SymptomsResponse> => cachedGet('/stats/symptoms', { top }),
|
||||
getIncidenceRate: (): Promise<IncidenceRateResponse> => cachedGet('/stats/incidence-rate'),
|
||||
getEnvCorrelation: (): Promise<EnvCorrelationResponse> => cachedGet('/stats/env-correlation'),
|
||||
getTemporal: (): Promise<TemporalResponse> => cachedGet('/stats/temporal'),
|
||||
};
|
||||
|
||||
export default api;
|
||||
|
||||
@@ -22,6 +22,7 @@ export const TESTIDS = {
|
||||
navDemographics: 'nav-demographics',
|
||||
navDisease: 'nav-disease',
|
||||
navEnvironment: 'nav-environment',
|
||||
navClinical: 'nav-clinical',
|
||||
|
||||
// 页面挂载点
|
||||
pageMonitoring: 'page-monitoring',
|
||||
@@ -34,6 +35,8 @@ export const TESTIDS = {
|
||||
pageDemographics: 'page-demographics',
|
||||
pageDisease: 'page-disease',
|
||||
pageEnvironment: 'page-environment',
|
||||
pageClinical: 'page-clinical',
|
||||
clinicalKpis: 'clinical-kpis',
|
||||
|
||||
// 综合概览 大屏
|
||||
kpiRow: 'kpi-row',
|
||||
|
||||
Reference in New Issue
Block a user