From 4df6c71628e9b0d60aec5640c4815b21e8afa930 Mon Sep 17 00:00:00 2001 From: Akiba So Date: Sun, 21 Jun 2026 21:42:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20deep=20statistical=20analytics=20?= =?UTF-8?q?=E2=80=94=20clinical,=20symptoms,=20incidence,=20env=20correlat?= =?UTF-8?q?ion,=20weekday?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- backend/main.py | 3 +- backend/routers/statistics.py | 621 ++++++++++++++++++ frontend/e2e/clinical.spec.ts | 104 +++ frontend/src/components/SideNav.tsx | 1 + .../src/components/clinical/BoxPlotRows.tsx | 87 +++ .../components/clinical/ClinicalKpiRow.tsx | 47 ++ .../clinical/CostByDiseaseChart.tsx | 62 ++ .../components/clinical/CostVsLosScatter.tsx | 55 ++ .../src/components/clinical/DonutChart.tsx | 56 ++ .../components/clinical/HistogramChart.tsx | 51 ++ .../src/components/clinical/chartColors.ts | 36 + frontend/src/pages/ClinicalAnalysis.tsx | 175 +++++ frontend/src/pages/DiseaseAnalysis.tsx | 89 ++- frontend/src/pages/DistrictComparison.tsx | 127 +++- frontend/src/pages/EnvironmentalHealth.tsx | 345 +++++++++- frontend/src/pages/TrendAnalysis.tsx | 63 +- frontend/src/routes.tsx | 4 + frontend/src/services/api.ts | 44 ++ frontend/src/utils/testids.ts | 3 + 19 files changed, 1963 insertions(+), 10 deletions(-) create mode 100644 backend/routers/statistics.py create mode 100644 frontend/e2e/clinical.spec.ts create mode 100644 frontend/src/components/clinical/BoxPlotRows.tsx create mode 100644 frontend/src/components/clinical/ClinicalKpiRow.tsx create mode 100644 frontend/src/components/clinical/CostByDiseaseChart.tsx create mode 100644 frontend/src/components/clinical/CostVsLosScatter.tsx create mode 100644 frontend/src/components/clinical/DonutChart.tsx create mode 100644 frontend/src/components/clinical/HistogramChart.tsx create mode 100644 frontend/src/components/clinical/chartColors.ts create mode 100644 frontend/src/pages/ClinicalAnalysis.tsx diff --git a/backend/main.py b/backend/main.py index 744b010..c04e11d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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("/") diff --git a/backend/routers/statistics.py b/backend/routers/statistics.py new file mode 100644 index 0000000..f260dca --- /dev/null +++ b/backend/routers/statistics.py @@ -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=[]) diff --git a/frontend/e2e/clinical.spec.ts b/frontend/e2e/clinical.spec.ts new file mode 100644 index 0000000..958833f --- /dev/null +++ b/frontend/e2e/clinical.spec.ts @@ -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); + }); +}); diff --git a/frontend/src/components/SideNav.tsx b/frontend/src/components/SideNav.tsx index 7291e98..081e92c 100644 --- a/frontend/src/components/SideNav.tsx +++ b/frontend/src/components/SideNav.tsx @@ -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 }, ], }, diff --git a/frontend/src/components/clinical/BoxPlotRows.tsx b/frontend/src/components/clinical/BoxPlotRows.tsx new file mode 100644 index 0000000..0aa09cc --- /dev/null +++ b/frontend/src/components/clinical/BoxPlotRows.tsx @@ -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
暂无数据
; + } + + // 统一横轴域:覆盖所有行的 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 ( +
+ {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 ( +
+
+ {r.label} +
+
+ {/* p25–p75 箱体 */} +
+ {/* 中位刻度 */} +
+
+
+ {r.p25}–{r.median}–{r.p75} + {unit} + n={r.n} +
+
+ ); + })} +
+ ); +}); diff --git a/frontend/src/components/clinical/ClinicalKpiRow.tsx b/frontend/src/components/clinical/ClinicalKpiRow.tsx new file mode 100644 index 0000000..b49c442 --- /dev/null +++ b/frontend/src/components/clinical/ClinicalKpiRow.tsx @@ -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 ( +
+ } + label="住院总人次" + value={kpis.total_admissions.toLocaleString()} + /> + } + label="中位住院日" + value={`${kpis.median_los_days} 天`} + /> + } + label="人均费用" + value={`¥${Math.round(kpis.mean_cost).toLocaleString()}`} + /> + } + label="治愈好转率" + value={`${(kpis.cure_rate * 100).toFixed(1)}%`} + color="#16A34A" + /> + } + label="急诊入院占比" + value={`${(kpis.emergency_admit_ratio * 100).toFixed(1)}%`} + color="#D97706" + /> +
+ ); +}); diff --git a/frontend/src/components/clinical/CostByDiseaseChart.tsx b/frontend/src/components/clinical/CostByDiseaseChart.tsx new file mode 100644 index 0000000..81e1bf5 --- /dev/null +++ b/frontend/src/components/clinical/CostByDiseaseChart.tsx @@ -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
暂无数据
; + } + + const chartData = [...data] + .sort((a, b) => a.mean_cost - b.mean_cost) + .map((d) => ({ ...d, displayName: truncate(d.diagnosis, 8) })); + + return ( + + + + `¥${(v / 1000).toFixed(0)}k`} + /> + + [`¥${Math.round(v).toLocaleString()}`, '人均费用']} + /> + + + + ); +}); diff --git a/frontend/src/components/clinical/CostVsLosScatter.tsx b/frontend/src/components/clinical/CostVsLosScatter.tsx new file mode 100644 index 0000000..d5007ea --- /dev/null +++ b/frontend/src/components/clinical/CostVsLosScatter.tsx @@ -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
暂无数据
; + } + + return ( + + + + + `¥${(v / 1000).toFixed(0)}k`} + /> + + name === '费用' + ? [`¥${value.toLocaleString()}`, name] + : [`${value} 天`, name] + } + /> + + + + ); +}); diff --git a/frontend/src/components/clinical/DonutChart.tsx b/frontend/src/components/clinical/DonutChart.tsx new file mode 100644 index 0000000..9cd6728 --- /dev/null +++ b/frontend/src/components/clinical/DonutChart.tsx @@ -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; +} + +/** 通用环形图。复用于「出院结局构成」与「入院途径构成」。 */ +export const DonutChart = memo(function DonutChart({ data, colorMap }: DonutChartProps) { + if (!data || data.length === 0) { + return
暂无数据
; + } + + 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 ( + + + + {data.map((d, idx) => ( + + ))} + + [ + `${v.toLocaleString()}(${total > 0 ? ((v / total) * 100).toFixed(1) : '0'}%)`, + name, + ]} + /> + + + + ); +}); diff --git a/frontend/src/components/clinical/HistogramChart.tsx b/frontend/src/components/clinical/HistogramChart.tsx new file mode 100644 index 0000000..a06b9f2 --- /dev/null +++ b/frontend/src/components/clinical/HistogramChart.tsx @@ -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
暂无数据
; + } + + return ( + + + + + + [`${v.toLocaleString()}`, countLabel]} + /> + + + + ); +}); diff --git a/frontend/src/components/clinical/chartColors.ts b/frontend/src/components/clinical/chartColors.ts new file mode 100644 index 0000000..67d2762 --- /dev/null +++ b/frontend/src/components/clinical/chartColors.ts @@ -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, + 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; diff --git a/frontend/src/pages/ClinicalAnalysis.tsx b/frontend/src/pages/ClinicalAnalysis.tsx new file mode 100644 index 0000000..8a48f0c --- /dev/null +++ b/frontend/src/pages/ClinicalAnalysis.tsx @@ -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(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(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 = ( +
+

+ + 住院临床分析 +

+

+ 住院天数、费用、出院结局与入院途径等临床特征分析 +

+
+ ); + + if (isLoading) { + return ( +
+ {header} + +
+ ); + } + + if (error || !data) { + return ( +
+ {header} + window.location.reload()} + onDismiss={() => setError(null)} + /> +
+ ); + } + + if (isEmpty(data)) { + return ( +
+ {header} + +
+ ); + } + + // 出院结局:治愈/好转在前(按严重程度排序展示更直观)。 + 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 ( +
+
+ {header} + + {/* KPI 行 */} + + + {/* 住院天数:分布 + 各病种箱线 */} +
+ + + + + + +
+ + {/* 费用:分布 + 各病种平均费用 */} +
+ + + + + + +
+ + {/* 费用 vs 住院天数 散点 */} + + + + + {/* 出院结局 + 入院途径 双环 */} +
+ + + + + + +
+ + {/* 年龄别 BMI 箱线 */} + + + +
+
+ ); +} diff --git a/frontend/src/pages/DiseaseAnalysis.tsx b/frontend/src/pages/DiseaseAnalysis.tsx index 2d81f5e..a2d81f2 100644 --- a/frontend/src/pages/DiseaseAnalysis.tsx +++ b/frontend/src/pages/DiseaseAnalysis.tsx @@ -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
暂无数据
; + } + + // 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 ( + + + + v.toLocaleString()} + /> + + [value.toLocaleString(), '出现次数']} + /> + + + + ); +} + // --- Page Component --- export function DiseaseAnalysis() { const [diagDistribution, setDiagDistribution] = useState([]); const [seasonality, setSeasonality] = useState([]); const [districts, setDistricts] = useState([]); + const [symptoms, setSymptoms] = useState([]); + const [revisitRatio, setRevisitRatio] = useState(null); const [isLoading, setIsLoading] = useState(true); const [errors, setErrors] = useState([]); @@ -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() {
+ + {/* Chart 5: Outpatient Symptom Keyword Frequency */} +
+
+ + 门诊主诉症状词频 +
+
+ 主诉文本高频词(含复诊/随诊等就诊类型词) + {revisitRatio !== null && ( + + 复诊占比: + + {(revisitRatio * 100).toFixed(1)}% + + + )} +
+ +
); diff --git a/frontend/src/pages/DistrictComparison.tsx b/frontend/src/pages/DistrictComparison.tsx index 69b2606..f11d693 100644 --- a/frontend/src/pages/DistrictComparison.tsx +++ b/frontend/src/pages/DistrictComparison.tsx @@ -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([]); const [caseDataLoading, setCaseDataLoading] = useState(false); const [caseDataError, setCaseDataError] = useState(null); + const [incidence, setIncidence] = useState([]); + const [incidenceLoading, setIncidenceLoading] = useState(false); + const [incidenceError, setIncidenceError] = useState(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 && ( + { + 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)} + /> + )}

@@ -380,6 +430,79 @@ export function DistrictComparison() { )}

+ {/* Standardized Incidence Rate (per 10k) with count/rate toggle */} +
+
+
+ {incidenceMetric === 'rate' ? '标化发病率(每万人)区域排名' : '病例数 区域排名'} +
+ +
+

+ 标化发病率按人口归一化,可避免人口规模差异造成的误读(原始病例数会高估人口大区)。 +

+ {incidenceLoading && } + {!incidenceLoading && incidenceChart.data.length > 0 ? ( + + + + v.toLocaleString()} + /> + + { + const p = item?.payload as IncidenceRateResponse['districts'][number]; + return [ + `病例数 ${p.total_cases.toLocaleString()} · 人口 ${p.population.toLocaleString()} · 每万人 ${p.rate_per_10k.toLocaleString()}`, + incidenceMetric === 'rate' ? '标化发病率' : '病例数', + ]; + }} + /> + + + + ) : ( + !incidenceLoading && ( +
无发病率数据
+ ) + )} +
+ {/* O/I Ratio Comparison Bar Chart */}
diff --git a/frontend/src/pages/EnvironmentalHealth.tsx b/frontend/src/pages/EnvironmentalHealth.tsx index fe071a6..799796f 100644 --- a/frontend/src/pages/EnvironmentalHealth.tsx +++ b/frontend/src/pages/EnvironmentalHealth.tsx @@ -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([]); const [pollutants30, setPollutants30] = useState([]); const [caseTrend, setCaseTrend] = useState([]); + const [envCorr, setEnvCorr] = useState(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(); + 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() {
)}
+ + {/* === 污染物-病例关联分析 === */} +
+
+

+ 污染物-病例关联分析 +

+

+ 基于网格级 Pearson 相关系数(grid-level) +

+
+ + {/* (a) Pollutant × Cases correlation */} +
+
+ 污染物 × 病例相关性 +
+ {corrWithCases.length > 0 ? ( + <> + + + + + + [value.toFixed(3), '相关系数']} + /> + + + {corrWithCases.map((entry, idx) => ( + + ))} + + + +

+ 网格级 Pearson 相关系数(红=正相关,蓝=负相关,颜色深浅表示强度) +

+ + ) : ( +
+ 暂无数据 +
+ )} +
+ + {/* (b) Pollutant pairwise correlation heatmap */} +
+
+ 污染物两两相关热力图 +
+ {pairwiseGrid ? ( + <> +
+
+ {/* Header row */} +
+ {CORR_POLLUTANTS.map((key) => ( +
+ {pollutantLabel(key)} +
+ ))} + {/* Body rows */} + {pairwiseGrid.map((row, ri) => ( + +
+ {pollutantLabel(CORR_POLLUTANTS[ri])} +
+ {row.map((val, ci) => ( +
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)} +
+ ))} +
+ ))} +
+
+

+ 对角线为自相关(=1.00);红=正相关,蓝=负相关 +

+ + ) : ( +
+ 暂无数据 +
+ )} +
+ + {/* (c) PM2.5 × cases scatter + regression line */} +
+
+ PM2.5 × 病例 散点 + 回归线 +
+ {scatterPoints.length > 0 ? ( + <> + + + + + + + [ + Math.round(value).toLocaleString(), + name, + ]} + /> + + {regressionLine.length === 2 && ( + } + legendType="none" + /> + )} + + + {regression && ( +

+ 最小二乘回归:病例 ≈ {regression.slope.toFixed(2)} × PM2.5 +{' '} + {regression.intercept.toFixed(1)} +

+ )} + + ) : ( +
+ 暂无数据 +
+ )} +
+
); diff --git a/frontend/src/pages/TrendAnalysis.tsx b/frontend/src/pages/TrendAnalysis.tsx index c301dab..469eb3d 100644 --- a/frontend/src/pages/TrendAnalysis.tsx +++ b/frontend/src/pages/TrendAnalysis.tsx @@ -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(['aqi', 'pm25']); const [multiYearData, setMultiYearData] = useState>({}); const [multiYearLoading, setMultiYearLoading] = useState(false); + const [weekdayData, setWeekdayData] = useState([]); 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 = { '2022': '#94A3B8', '2023': '#3B82F6', '2024': '#EF4444' }; @@ -444,6 +459,52 @@ export function TrendAnalysis() { + + {/* Weekday case distribution (门诊/住院) */} +
+
+ 星期就诊分布 +
+ {weekdayData.length > 0 ? ( + <> + + + + + + [value.toLocaleString(), name]} + /> + + + + + +

+ 注:现有病例数据集中在12月,季节性/同比分析待更多月份数据 +

+ + ) : ( +
暂无就诊分布数据
+ )} +
); } diff --git a/frontend/src/routes.tsx b/frontend/src/routes.tsx index 939dbfb..4e4754b 100644 --- a/frontend/src/routes.tsx +++ b/frontend/src/routes.tsx @@ -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: }, ]; diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 9a90354..61e8053 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -343,4 +343,48 @@ export const reportApi = { getLatestSummary: (): Promise => 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 => cachedGet('/stats/inpatient-clinical'), + getSymptoms: (top: number = 20): Promise => cachedGet('/stats/symptoms', { top }), + getIncidenceRate: (): Promise => cachedGet('/stats/incidence-rate'), + getEnvCorrelation: (): Promise => cachedGet('/stats/env-correlation'), + getTemporal: (): Promise => cachedGet('/stats/temporal'), +}; + export default api; diff --git a/frontend/src/utils/testids.ts b/frontend/src/utils/testids.ts index 9f67f5c..eab8feb 100644 --- a/frontend/src/utils/testids.ts +++ b/frontend/src/utils/testids.ts @@ -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',