feat: Phase 2 — leadership 大屏 (/overview) + district normalization + drawer a11y

Phase 2 of the UX modernization. Three conflict-free workstreams.

Leadership 驾驶舱 (/overview):
- Wuhan 13-district Leaflet choropleth (public/wuhan_districts.geojson, keyed
  on name, darker=higher per 高风险高亮), legend, hover/click-zoom
- 全部/门诊/住院 Segmented toggle drives choropleth + Top-5 district bar
- literal "数据截至2023-12" as-of badge (D3 honesty); raw spinner → LoadingState
- decompose OverviewDashboard 501→273; 6 components + 2 helpers under components/overview/

District normalization (backend data boundary):
- case_loader.normalize_district + load_cases_by_district_daily collapse the
  26 dirty labels (武昌/武昌区…) → 13 canonical; analysis/grid/insights repointed
  (fixes a grid-merge row-drop bug as a bonus); in-memory, schema unchanged

Shell a11y (code-review carryover):
- drawer is now a proper modal: ESC, body scroll-lock, focus-in + focus-trap
  cycle + focus-restore, role=dialog/aria-modal/aria-label, hamburger aria-expanded
- SideNav expanded state lifted to AppShell so rail+drawer stay in sync
- RouteErrorBoundary around <Outlet/> keeps shell chrome on page/chunk failure

Gates: tsc 0 · vitest 64 · e2e 19/19 (17 user-flows + 2 overview) · build ok
· backend pytest 6 new + 48 regression green

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-21 20:24:26 +08:00
parent 3db3b12480
commit 9a94156acc
22 changed files with 1378 additions and 358 deletions

View File

@@ -12,6 +12,7 @@ import pandas as pd
from pydantic import BaseModel, Field
from config import DATA_DIR, RISK_HIGH, PROJECT_ROOT, WUHAN_BOUNDS, LAT_STEP, LON_STEP
from data.case_loader import load_cases_by_district_daily
from utils.date_helpers import get_latest_date
from utils.geojson import parse_geojson_file, load_districts
from utils.geo import point_in_polygon
@@ -179,18 +180,16 @@ def _district_avg_aqi() -> dict:
def _district_total_cases() -> dict:
"""Real total recorded cases per district from cases_by_district_daily.
District labels in the case file are inconsistent ("武昌" vs "武昌区"),
so names are normalized by stripping the "" suffix and summed, then
keyed by the canonical mapping name (with ""). Returns {district: cases}.
District labels are normalized to the canonical 13 区-suffixed names at the
data-access boundary (data.case_loader), so this is a plain per-district
sum. Returns {district: cases}.
"""
path = PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet"
if not path.exists():
try:
df = load_cases_by_district_daily()
except FileNotFoundError:
return {}
df = pd.read_parquet(path, columns=["district", "total_cases"])
df = df.copy()
df["base"] = df["district"].str.replace("", "", regex=False)
by_base = df.groupby("base")["total_cases"].sum()
return {f"{base}": int(v) for base, v in by_base.items()}
by_district = df.groupby("district")["total_cases"].sum()
return {str(d): int(v) for d, v in by_district.items()}
@lru_cache(maxsize=8)