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

@@ -4,6 +4,7 @@ Provides comprehensive analytics, trends, hotspots, and correlations
"""
from fastapi import APIRouter, HTTPException, Query
from datetime import datetime, timedelta
from functools import lru_cache
import random
from pydantic import BaseModel, Field
@@ -20,6 +21,13 @@ from models import (
)
from utils.date_helpers import get_latest_date
from utils.geojson import parse_geojson_file, load_districts
@lru_cache(maxsize=8)
def _cached_parquet(path_str: str):
"""Load a parquet file once and reuse it (read-only) across requests."""
import pandas as pd
return pd.read_parquet(path_str)
from utils.geo import point_in_polygon
from utils.risk import calculate_trend as calculate_trend_direction
@@ -41,6 +49,7 @@ class InsightCardResponse(BaseModel):
warning_count: int
info_count: int
success_count: int
danger_count: int
cards: list[InsightCardItem]
@@ -482,7 +491,7 @@ async def get_insights_cards():
cases_path = PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet"
if cases_path.exists():
cases_df = pd.read_parquet(cases_path)
cases_df = _cached_parquet(str(cases_path))
latest_case_date = cases_df["date"].max()
latest_cases = cases_df[cases_df["date"] == latest_case_date].copy()
latest_cases["base_district"] = latest_cases["district"].str.replace("", "")
@@ -533,7 +542,7 @@ async def get_insights_cards():
grids_df["col"] = ((grids_df["longitude"] - MIN_LON) / STEP).astype(int)
grids_df["grid_id"] = "r" + grids_df["row"].astype(str) + "_c" + grids_df["col"].astype(str)
mapping = pd.read_parquet(mapping_path)
mapping = _cached_parquet(str(mapping_path))
merged = grids_df.merge(mapping, on="grid_id", how="inner")
if len(merged) > 0:
@@ -574,7 +583,7 @@ async def get_insights_cards():
weather_path = PROJECT_ROOT / "processed" / "weather" / "station_daily_2022.parquet"
if weather_path.exists():
weather_df = pd.read_parquet(weather_path)
weather_df = _cached_parquet(str(weather_path))
daily_wx = weather_df.groupby("date").agg(
AQI=("AQI", "mean"), PM25=("PM25", "mean"), PM10=("PM10", "mean"),
).reset_index()
@@ -664,11 +673,13 @@ async def get_insights_cards():
warning_count = sum(1 for c in cards if c.type == "warning")
info_count = sum(1 for c in cards if c.type == "info")
success_count = sum(1 for c in cards if c.type == "success")
danger_count = sum(1 for c in cards if c.type == "danger")
return InsightCardResponse(
total_insights=len(cards),
warning_count=warning_count,
info_count=info_count,
success_count=success_count,
danger_count=danger_count,
cards=cards,
)