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

@@ -25,12 +25,42 @@ _cache: dict[str, Optional[pd.DataFrame | datetime]] = {
"outpatient": None,
"inpatient": None,
"combined": None,
"cases_by_district_daily": None,
"loaded_at": None,
}
# Guards the lazy build so concurrent callers don't duplicate the load/concat.
_load_lock = threading.RLock()
# Canonical Wuhan administrative districts (13), matching the `name` field in
# Datas/武汉市.geojson. All district roll-ups must collapse to exactly these.
CANONICAL_DISTRICTS = [
'江岸区', '江汉区', '硚口区', '汉阳区', '武昌区', '青山区', '洪山区',
'东西湖区', '汉南区', '蔡甸区', '江夏区', '黄陂区', '新洲区',
]
# Bare (suffix-less) base name -> canonical 区-suffixed name.
_DISTRICT_BASE_TO_CANONICAL = {d[:-1]: d for d in CANONICAL_DISTRICTS}
_DISTRICT_SUFFIXES = ('', '', '')
def normalize_district(name: str) -> str:
"""Map a district label to its canonical 区-suffixed form.
The case parquet carries both bare ("武昌") and suffixed ("武昌区") spellings
of the same district, which double-counts in any roll-up. This collapses
them: known bare names map to their canonical form; already-suffixed names
pass through unchanged; anything else gets a "" appended.
"""
if name is None:
return name
name = str(name).strip()
if name in _DISTRICT_BASE_TO_CANONICAL:
return _DISTRICT_BASE_TO_CANONICAL[name]
if name.endswith(_DISTRICT_SUFFIXES):
return name
return f"{name}"
# Wuhan district mapping
WUHAN_DISTRICTS = {
'江岸区': ['江岸'],
@@ -170,3 +200,40 @@ def get_inpatient_data() -> pd.DataFrame:
"""Return the cached inpatient dataframe"""
load_data()
return _cache["inpatient"] # type: ignore[return-value]
def load_cases_by_district_daily() -> pd.DataFrame:
"""Load processed/cases_by_district_daily.parquet with districts normalized.
The on-disk parquet carries both bare and 区-suffixed spellings of each
district (26 labels = 13 districts × 2 spellings), so any groupby on the
raw `district` column double-counts. This is the single data-access
boundary: it normalizes labels to the canonical 13 and re-aggregates
(sum of outpatient_count / inpatient_count / total_cases per
normalized district + date), so every downstream consumer
(analysis / grid / insights) sees clean, deduped 13-district data.
Returns a copy with columns [date, district, outpatient_count,
inpatient_count, total_cases]. Raises FileNotFoundError if the parquet
is missing (callers handle this as they did before).
"""
path = PROCESSED_DIR / "cases_by_district_daily.parquet"
cached = _cache.get("cases_by_district_daily")
if cached is not None:
return cast(pd.DataFrame, cached).copy()
with _load_lock:
cached = _cache.get("cases_by_district_daily")
if cached is not None:
return cast(pd.DataFrame, cached).copy()
df = pd.read_parquet(path)
df["district"] = df["district"].map(normalize_district)
agg = (
df.groupby(["date", "district"], as_index=False)[
["outpatient_count", "inpatient_count", "total_cases"]
]
.sum()
)
_cache["cases_by_district_daily"] = agg # type: ignore[assignment]
return agg.copy()