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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user