feat: add analysis pages and raster risk map

Ship a new app version with broader analytics, restructured
dashboards, and a server-rendered risk map.

Frontend:
- Add Overview, Demographic, Disease, and Environmental Health
  analysis pages
- Add AnomalyMarkers, CalendarHeatmap, and MetricHeatmapTable
  components
- Rebuild Alerts map onto server-rendered raster risk tiles;
  expand Monitoring, Trend, and District Comparison views
- Extend API client, stores, and TypeScript types

Backend:
- Add environment router (pollutants, lag correlations)
- Add risk_raster util serving XYZ 100m risk tiles
- Expand cases endpoints (demographics, seasonality, diagnoses)
  and insights; harden auth and file-based loaders

Data & tooling:
- Add processed outpatient/inpatient/combined case parquet (LFS)
- Add nested CLAUDE.md guides, pyrightconfig, and test updates
This commit is contained in:
2026-06-21 17:35:03 +08:00
parent f092c3c550
commit e95e2f1338
63 changed files with 8534 additions and 988 deletions

View File

@@ -5,6 +5,8 @@ Generates alerts from high-risk grids in GeoJSON files
from fastapi import APIRouter, HTTPException, Query
from datetime import datetime
from typing import List
from functools import lru_cache
import asyncio
import json
from config import DATA_DIR, ALERT_P1_RISK, ALERT_P2_RISK, WUHAN_BOUNDS, LAT_STEP, LON_STEP, MAX_ALERTS
@@ -32,8 +34,13 @@ def grid_id_to_center(grid_id: str) -> tuple[float, float]:
return lat, lon
def generate_alerts_for_date(date: str) -> List[Alert]:
"""Generate alerts for high-risk grids on a specific date.
@lru_cache(maxsize=8)
def _generate_alerts_cached(date: str) -> List[Alert]:
"""Heavy synchronous worker: parse the ~45MB GeoJSON and build alerts.
Cached by date so the file is parsed once per date. This runs blocking
json.load + per-feature loops, so callers must invoke it off the event
loop (see generate_alerts_for_date).
Phase 1: iterate features, aggregate max risk per 100m grid cell.
Phase 2: build Alert objects from aggregated grid cells.
@@ -111,6 +118,16 @@ def generate_alerts_for_date(date: str) -> List[Alert]:
return alerts[:MAX_ALERTS]
async def generate_alerts_for_date(date: str) -> List[Alert]:
"""Async accessor: run the cached heavy parser in a thread pool.
Offloading the blocking json.load + per-feature aggregation keeps the
event loop free. The lru_cache lives on the worker, so warm dates return
near-instantly without re-parsing.
"""
return await asyncio.to_thread(_generate_alerts_cached, date)
@router.get("", response_model=AlertResponse)
async def list_alerts(date: str | None = None, priority: str | None = None, min_risk: float | None = None):
if date is not None and not validate_date_format(date):
@@ -118,7 +135,7 @@ async def list_alerts(date: str | None = None, priority: str | None = None, min_
if date is None:
date = get_latest_date()
alerts = generate_alerts_for_date(date)
alerts = await generate_alerts_for_date(date)
if priority:
alerts = [a for a in alerts if a.priority == priority]
@@ -140,7 +157,7 @@ async def get_alert(alert_id: str, date: str | None = None):
if date is None:
date = get_latest_date()
alerts = generate_alerts_for_date(date)
alerts = await generate_alerts_for_date(date)
for alert in alerts:
if alert.alert_id == alert_id:
@@ -156,7 +173,7 @@ async def get_p1_alerts(date: str | None = None):
if date is None:
date = get_latest_date()
alerts = generate_alerts_for_date(date)
alerts = await generate_alerts_for_date(date)
p1_alerts = [a for a in alerts if a.priority == "P1"]
return AlertResponse(
@@ -173,7 +190,7 @@ async def get_p2_alerts(date: str | None = None):
if date is None:
date = get_latest_date()
alerts = generate_alerts_for_date(date)
alerts = await generate_alerts_for_date(date)
p2_alerts = [a for a in alerts if a.priority == "P2"]
return AlertResponse(
@@ -190,7 +207,7 @@ async def get_grid_alerts(grid_id: str, date: str | None = None):
if date is None:
date = get_latest_date()
alerts = generate_alerts_for_date(date)
alerts = await generate_alerts_for_date(date)
grid_alerts = [a for a in alerts if a.grid_id == grid_id]
return AlertResponse(