8 Commits

Author SHA1 Message Date
e22b004f9e feat: GeoScene frontend POC + Docker deploy for remote host
Migrate maps to @geoscene/core, polish monitoring/alerts UX, fix timeline
basemap flicker and district alert regions, and ship compose/nginx Docker
deploy assets with CBPOA_ROOT data mounts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 03:28:29 +08:00
fe8bed58f5 feat: remove cost/费用 statistics from clinical analytics
Per request — drop all monetary statistics (住院费用 is sensitive).
- Backend statistics.py: remove mean_cost KPI + cost_histogram / cost_by_disease
  / cost_vs_los from /inpatient-clinical (models, computation, response)
- Frontend: drop 人均费用 KPI card (now 4 KPIs), 住院费用分布, 各病种平均费用,
  费用×住院天数散点; delete CostByDiseaseChart + CostVsLosScatter components;
  trim statsApi type + e2e fixture + chartColors

Clinical page now: KPI(总人次/中位住院日/治愈好转率/急诊占比) + LOS dist + LOS-by-disease
box + outcome donut + admission-route donut + age-band BMI box.

Gates: backend 106 pytest · tsc 0 · build ok · clinical+user-flows e2e 19/19 ·
live endpoint confirmed cost-free

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:50:30 +08:00
4df6c71628 feat: deep statistical analytics — clinical, symptoms, incidence, env correlation, weekday
Adds a substantial layer of data-backed statistics (all grounded in verified,
clean source data — no fabricated metrics).

Backend (new routers/statistics.py, prefix /api/stats; +106 pytest still green):
- /inpatient-clinical: LOS dist + by-disease quartiles, cost dist + by-disease +
  cost-vs-LOS, outcome counts, admission-route counts, BMI-by-age, KPIs
  (5822 admissions, median LOS 4d, mean ¥6294, cure 99.1%, emergency 47%)
- /symptoms: 主诉 keyword frequencies (发热/咳嗽/肺炎…) + revisit ratio (36%)
- /incidence-rate: per-10k-population standardized rate by district (cases ÷ pop)
- /env-correlation: pollutant×cases Pearson + 7×7 pairwise matrix + PM2.5 scatter
- /temporal: weekday distribution (+ month/yoy returned but UI omits them — data
  is December-only, so seasonality/YoY would be misleading)

Frontend:
- NEW 住院临床分析 page (/analysis/clinical, nav 临床分析): 9 charts + KPI row —
  LOS histogram + box-by-disease, cost histogram + scatter + by-disease, outcome
  donut (severity-colored), admission-route donut, age-band BMI box
- DiseaseAnalysis: 主诉症状词频 horizontal bar + revisit ratio
- DistrictComparison: 标化发病率(每万人)with 病例数↔发病率 toggle (rate is
  epidemiologically correct; raw counts mislead by population)
- EnvironmentalHealth: pollutant-cases correlation bar + 7×7 correlation heatmap +
  PM2.5×cases scatter with least-squares regression line
- TrendAnalysis: 星期就诊分布 + honest "data is December-only" note
- statsApi client + types

Gates: tsc 0 · build ok · functional e2e 43/43 (incl 2 new clinical) · verified
live against real backend data via dev proxy

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:42:52 +08:00
33f0f497d3 feat: Phase 4 — responsive analysis pages + perf harness + god-component splits
Final phase of the UX modernization. Four conflict-free lanes.

Responsive (D4 — desktop+mobile 并重):
- 7 analysis pages made usable at 375px: grid-cols-4/5 → grid-cols-2 sm:*
  responsive variants; raw tables wrapped in overflow-x-auto; page overflow guards
- new e2e/responsive.spec.ts loops all 7 analysis routes at 375px asserting no
  horizontal scroll

Perf harness:
- playwright.config.ts gains an isolated `perf` project (testMatch /perf/), default
  chromium project excludes it (testIgnore)
- new e2e/perf.spec.ts: CDP Network.emulateNetworkConditions (Fast 3G) +
  PerformanceObserver LCP on /overview kpi-row + route-transition timing; numbers
  reported as a relative regression signal (dev-server, not a prod SLA), not gated

God-component splits (pure refactors, behavior-preserving):
- MonitoringDashboard 686 → 239 lines: extracted components/monitoring/* (StatsBar,
  OverviewTab, CaseStatsTab, DistrictStatsTab) + useMonitoringData hook; URL-granularity
  source-of-truth + drilldown reconcile kept in the orchestrator (no desync regression)
- AlertsDashboard 816 → 301 lines: extracted components/alerts/* (Toolbar, List,
  RiskPanel, MapPanel, DetailModal, …); role/privacy/grid-hide logic kept in the
  orchestrator — doctor-view privacy invariant (zero patient-point) still holds

Gates: tsc 0 · vitest 75 · functional e2e 37/37 (incl doctor-view privacy +
granularity + responsive) · build ok · perf project runs + reports

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:00:15 +08:00
8ad7e086bb feat: Phase 3 — 视角/perspective presets + URL-driven granularity + doctor privacy
Phase 3 of the UX modernization. Frontend-only role view-presets (D2 — no
backend, no JWT role claim, honestly labeled 视角 not 权限). Three conflict-free lanes.

Role infrastructure (worker-session):
- sessionStore with single swappable getRoleSource() seam (localStorage today,
  one-line swap to /api/auth/me for future RBAC); Role = official|community|doctor|admin
- 视角 switcher in TopNav (replaces hardcoded "admin"); on change persists role +
  navigates to that perspective's default landing
- roleViews.ts: ROLE_LABELS + roleDefaultPath (official→/overview?granularity=district,
  community→/monitoring?granularity=street, doctor→/alerts?view=cluster, admin→/monitoring)
- RoleRedirect index route → current role's default; * fallback unchanged

URL-driven granularity (worker-monitoring):
- granularity (city|district|street) query param is the source of truth; drilldownStore
  DERIVES from it via a one-way effect; deep-linkable + reload-safe
- reconciled the imperative desync — DistrictBreakdown no longer calls
  useDrilldownStore.getState(); MonitoringDashboard owns useSearchParams, writes URL
- granularity-control Segmented (全市/区域/街道); district-rollup testid

Role-aware alerts + privacy (worker-alerts):
- doctor/cluster view: individual alert markers hard-locked off (effective flag is
  single source of truth; toggle not rendered) → aggregated density only; DiseaseFilter
  mounted; privacy invariant testable via hidden patient-point DOM mirror (count=0)
- 官员: 100m grid hidden (grid-layer-wrapper unrendered); admin/community unchanged

Gates: tsc 0 · vitest 75 · e2e 30/30 (incl 10 new P3 tests) · build ok

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 20:46:38 +08:00
9a94156acc 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>
2026-06-21 20:24:26 +08:00
3db3b12480 feat(frontend): Phase 1 UX foundation — react-router v6 + responsive AppShell
Atomic foundation for the consensus-approved UX modernization (makes the
platform URL-addressable, refresh-safe, and mobile-usable for hospital demos).

- Migrate hand-rolled useState page switching → react-router v6 (routes.tsx,
  thin App.tsx auth gate, NavLink SideNav, lazy+Suspense per route)
- Add responsive AppShell: persistent rail (lg:) ⇄ off-canvas drawer + hamburger
  (<lg); kills hardcoded ml-[200px]; usable at 375px
- Add ui primitive kit (Skeleton/LoadingState/EmptyState/Card/Panel/Segmented)
- Sweep all raw "加载中..." text loaders → skeleton primitives (G4)
- Centralize test ids (utils/testids.ts); rewrite e2e for URL nav (17/17 pass:
  deep-link, refresh-preserves-page, back, 375px drawer/no-scroll)
- Harden DemographicAnalysis against API shape mismatch (defensive normalize)
- gitignore playwright-report/ and test-results/

Gates: tsc --noEmit 0 · pnpm build ok · e2e 17/17 · grep 加载中 zero outside ui/

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 20:12:13 +08:00
e95e2f1338 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
2026-06-21 17:35:03 +08:00
144 changed files with 15740 additions and 3471 deletions

2
.gitignore vendored
View File

@@ -14,6 +14,8 @@ cache/
logs/
mlruns/
.playwright-mcp/
frontend/playwright-report/
frontend/test-results/
*Zone.Identifier
# Transcription processing intermediates
Outputs/transcript/chunks/

View File

@@ -1,12 +1,12 @@
# CBPOA — 武汉儿童呼吸疾病风险评估系统
FastAPI + React + PyTorch GCN pipeline. 预测空气质量对儿童健康的空间风险。
FastAPI + React + `@geoscene/core` + PyTorch GCN pipeline. 预测空气质量对儿童健康的空间风险。
## Development
```bash
# Frontend (pnpm)
cd frontend && pnpm dev # localhost:5173 → proxies /api to :8000
cd frontend && pnpm dev # localhost:3000 → proxies /api to :8000
# Backend (Python venv)
cd backend && uvicorn main:app --reload # localhost:8000
@@ -22,6 +22,7 @@ cd scripts && python train_model.py # PyTorch + MLflow
| API endpoint | `backend/routers/` |
| Database / PostGIS | `backend/database.py` |
| UI component | `frontend/src/components/` |
| GeoScene map helpers | `frontend/src/geoscene/` |
| Page view | `frontend/src/pages/` |
| API client / cache | `frontend/src/services/api.ts` |
| State management | `frontend/src/stores/` |

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,526 @@
=== 说话人6 ===
[300.9s-301.7s] 好
=== 说话人2 ===
[318.0s-319.3s] 那不不有啊
[319.3s-320.2s] 那不有吗
=== 说话人6 ===
[320.2s-321.2s] 那不有
=== 说话人2 ===
[321.2s-322.2s] 那不有
[322.2s-322.7s] 那不有
[322.7s-323.9s] 那不有
=== 说话人6 ===
[323.9s-325.7s] 那不有
=== 说话人4 ===
[325.7s-326.8s] 那不有
=== 说话人6 ===
[332.6s-333.6s] 那不有
=== 说话人3 ===
[333.6s-351.3s] 那不有
=== 说话人6 ===
[351.3s-352.2s] 那不有
[352.2s-352.8s] 那不有
[352.8s-353.5s] 那不有
=== 说话人3 ===
[773.5s-774.9s] 因為因為住室太高了
[774.9s-776.4s] 而且他們有一個秘密
=== 说话人8 ===
[776.4s-778.3s] 只要非常趴趴坐自己
=== 说话人3 ===
[778.3s-779.6s] 但是就是趴秘密
=== 说话人1 ===
[779.6s-780.4s] 還是比較
=== 说话人3 ===
[780.4s-781.9s] 真的沒有問題
=== 说话人6 ===
[781.9s-783.3s] 沒有問題
=== 说话人8 ===
[783.3s-784.4s] 就不叫做話
=== 说话人3 ===
[784.4s-785.6s] 因為那個秘密
[785.6s-786.5s] 講的秘密
[786.5s-787.5s] 基本上是太高了
[787.5s-788.7s] 我們有一個秘密的交流
=== 说话人6 ===
[788.7s-789.2s] 其實
=== 说话人3 ===
[789.2s-790.9s] 三年秘密的交流
=== 说话人6 ===
[790.9s-791.9s] 其實就是說
=== 说话人3 ===
[791.9s-792.5s] 這個裡面
=== 说话人8 ===
[792.5s-793.4s] 趴坐這個的話
[793.4s-794.8s] 其實是真好趴
[794.8s-795.6s] 然後
[795.6s-796.2s] 然後
=== 说话人7 ===
[796.2s-797.5s] 我現在認識的話
=== 说话人6 ===
[797.5s-798.8s] 那個坐子比較多
[799.6s-800.1s] 因為
=== 说话人3 ===
[800.1s-801.4s] 現在坐
[801.4s-802.1s] 這個只有
=== 说话人8 ===
[802.1s-802.8s] 八花之後
[802.8s-803.4s] 崩潰
=== 说话人6 ===
[804.2s-805.4s] 崩潰
=== 说话人2 ===
[805.4s-806.7s] 把花穩回來
=== 说话人8 ===
[806.7s-807.4s] 它進屋
[807.4s-808.0s] 現在
=== 说话人1 ===
[808.2s-809.2s] 因為 ele ran global
[809.9s-810.5s] artist
=== 说话人4 ===
[810.5s-811.0s] 實體
=== 说话人5 ===
[811.0s-811.7s] 就不理
=== 说话人2 ===
[811.7s-812.7s] 一才玩樂
[812.7s-813.1s] 但是
=== 说话人1 ===
[813.1s-814.3s] 操方
=== 说话人2 ===
[814.3s-817.2s] 著制
[817.2s-820.7s] 因為很多
=== 说话人3 ===
[820.7s-827.4s] 太高了
=== 说话人5 ===
[1157.4s-1158.5s] 他就要到了現在
=== 说话人7 ===
[1158.5s-1160.4s] 所以他來自主持主持表
=== 说话人5 ===
[1160.4s-1162.9s] 哪些人以前的平常才見到呢
=== 说话人7 ===
[1162.9s-1165.4s] 讓你以前的在砍術店就爆發
[1165.4s-1168.1s] 但是沒有些經過的這反應的
[1168.1s-1170.2s] 這往他就可以一直了
=== 说话人1 ===
[1170.2s-1172.3s] 他只是要去砍得一百米
=== 说话人5 ===
[1172.3s-1173.8s] 所以他來自主持的一集
=== 说话人7 ===
[1173.8s-1175.8s] 但是如果到了我主持假裝
=== 说话人5 ===
[1175.8s-1177.3s] 全正的主持主持主持主持
[1177.3s-1178.8s] 來現在的嘛
[1178.8s-1180.2s] 我們來現在的這個主持
=== 说话人2 ===
[1180.8s-1182.7s] 來扮熱我主持在主持上
=== 说话人7 ===
[1182.7s-1184.0s] 主持主持上
=== 说话人5 ===
[1184.0s-1184.7s] 我們來的一集
=== 说话人1 ===
[1184.7s-1185.5s] 廖都在耍耍被豪華 hideway
=== 说话人7 ===
[1185.5s-1190.8s] 不要再拿頭、保護護金高一點
=== 说话人2 ===
[1190.8s-1191.4s] 都不能家庭
=== 说话人7 ===
[1191.4s-1193.0s] 把這個賣一次
[1193.0s-1194.6s] 給我一去回去
=== 说话人5 ===
[1194.6s-1209.6s] 我這是自各主持主持主持主持主持主持
=== 说话人1 ===
[1211.3s-1213.3s] 大家如果像
[1399.8s-1402.5s] 我感觉是找到大甲之间的事,
=== 说话人2 ===
[1403.6s-1405.8s] 因为我被人拌上自己的事,
=== 说话人5 ===
[1406.2s-1409.5s] 但是我做了一会的事,
=== 说话人8 ===
[1409.5s-1410.2s] 我做了一会的事,
=== 说话人4 ===
[1410.2s-1411.2s] 我做了一会的事,
=== 说话人5 ===
[1411.3s-1413.0s] 我做了一会的事,
=== 说话人1 ===
[1413.3s-1415.1s] 然后我一直在说,
=== 说话人5 ===
[1415.2s-1417.0s] 我一直在说,
=== 说话人8 ===
[1418.7s-1419.4s] 我一直在说,
[1569.6s-1571.6s] 然后去讲没有办法,
=== 说话人4 ===
[1571.9s-1572.2s] 这一天,
=== 说话人3 ===
[1572.2s-1573.8s] 我一直在说,
[1574.5s-1575.2s] 这个天,
[1575.3s-1577.7s] 三公里的天然是一三个天然,
[1578.1s-1579.0s] 所以这一天,
=== 说话人8 ===
[1579.3s-1580.9s] 我把这马上进去的时候,
=== 说话人3 ===
[1581.6s-1583.3s] 这个能不能进去,
=== 说话人5 ===
[1583.8s-1585.5s] 我们有多少人那种事,
=== 说话人8 ===
[1585.6s-1586.3s] 太少我,
=== 说话人6 ===
[1586.3s-1587.5s] 也有多少人,
[1589.3s-1591.2s] 也有多少人,
=== 说话人5 ===
[1593.4s-1594.3s] 也有多少人,
[1594.3s-1595.6s] 也有多少人,
=== 说话人8 ===
[1596.3s-1598.4s] 也有多少人,
=== 说话人5 ===
[1598.7s-1599.4s] 也有多少人,
=== 说话人6 ===
[1711.1s-1718.4s] 也有多少人。
=== 说话人5 ===
[2469.8s-2471.9s] 他有个笔伴的笔伴
[2471.9s-2473.8s] 去务力就走
[2473.8s-2474.8s] 关了笔伴的笔伴
[2475.4s-2477.5s] 然后就加入有超级
[2477.5s-2478.7s] 有很多笔伴的样
=== 说话人2 ===
[2478.7s-2479.4s] 三个笔伴
[2479.4s-2481.9s] 这在我们笔伴的
=== 说话人5 ===
[2481.9s-2483.7s] 可能是这就是百分之八
=== 说话人8 ===
[2483.7s-2484.4s] 之多样
=== 说话人2 ===
[2484.9s-2486.9s] 他有个笔伴的笔伴
=== 说话人5 ===
[2486.9s-2487.9s] 三个笔伴的笔伴
=== 说话人4 ===
[2487.9s-2489.2s] 这个笔伴的笔伴
=== 说话人5 ===
[2489.2s-2490.1s] 就是要务力
[2490.1s-2493.1s] 这个笔伴的笔伴
=== 说话人4 ===
[2493.3s-2493.7s] 也就是要务力
=== 说话人5 ===
[2493.7s-2495.3s] 也就是要务力
[2496.3s-2498.5s] 他在那笔伴
[2498.5s-2501.2s] 这笔伴的笔伴的笔伴
=== 说话人1 ===
[2501.2s-2502.4s] 就是要务力
=== 说话人4 ===
[2502.4s-2503.2s] 那笔伴的笔伴的笔伴
=== 说话人1 ===
[2503.2s-2503.9s] 那笔伴的笔伴
[2503.9s-2504.8s] 他都感兮
=== 说话人5 ===
[2504.8s-2505.9s] 所以他的笔伴的笔伴
=== 说话人8 ===
[2505.9s-2507.4s] 就说就像不好的笔伴
=== 说话人5 ===
[2507.4s-2508.4s] 很热闹的笔伴
=== 说话人6 ===
[2508.4s-2509.1s] 他能不能被笔伴
=== 说话人4 ===
[2509.1s-2510.1s] 他能被笔伴
=== 说话人6 ===
[2510.1s-2510.9s] 笔伴
=== 说话人5 ===
[2511.6s-2512.9s] 但是我们这笔伴
=== 说话人4 ===
[2512.9s-2514.2s] 有笔伴的笔伴
=== 说话人5 ===
[2514.2s-2515.9s] 前几个笔伴
=== 说话人2 ===
[2515.9s-2516.8s] 有没有看见的
=== 说话人4 ===
[2516.8s-2518.9s] 这笔伴的笔伴
[2518.9s-2519.5s] 那个笔伴
=== 说话人1 ===
[2519.5s-2520.2s] 没有看见
[2520.2s-2521.1s] 没有看见
[2522.8s-2523.7s] 他会到了
[2523.7s-2524.2s] 把笔伴
=== 说话人6 ===
[2528.5s-2530.4s] 接下音过找
=== 说话人8 ===
[2531.3s-2534.1s] 他一aps
=== 说话人6 ===
[2534.1s-2535.3s] 差不多
[2535.3s-2537.6s] 他上学
=== 说话人5 ===
[2537.9s-2540.0s] 我们整体加上
=== 说话人4 ===
[2540.0s-2540.8s] 那个笔伴
=== 说话人2 ===
[2540.8s-2542.5s] 你看看
=== 说话人5 ===
[2542.5s-2557.8s] 他比较感情
[4267.8s-4269.6s] 恐慌
=== 说话人7 ===
[4277.5s-4285.7s] 那国内主要我们给こちら
[4285.7s-4286.8s] 就是宪治 Show
=== 说话人3 ===
[4286.8s-4290.6s] 这是就是宪治 Audio
=== 说话人1 ===
[4290.6s-4292.5s] 他觉得他在来兜了
=== 说话人7 ===
[4292.5s-4296.2s] 不是这个话的志明
[4296.8s-4299.3s] 我来看你这种词的看法
=== 说话人4 ===
[4299.3s-4300.1s] 这个是一个人的
=== 说话人7 ===
[4301.4s-4302.8s] 所以我说一下
[4302.8s-4304.3s] 我就是诸个看法的问题
=== 说话人4 ===
[4304.3s-4306.2s] 这体的问题是一个诸个
=== 说话人5 ===
[4306.7s-4308.5s] 我听过我这口误的语语
[4308.5s-4310.5s] 这种不害怕怪怪
[4310.5s-4313.0s] 我诸了太太的语语语语
=== 说话人7 ===
[4313.0s-4314.7s] 我当时诸个不道理的
=== 说话人5 ===
[4315.8s-4318.7s] 这种不道理的语语语语语语语语语语
[4318.7s-4320.2s] 假如还有语语语语
[4320.2s-4321.7s] 这个语语语语语语语
[4321.7s-4326.1s] 这种不道理语语语语
=== 说话人2 ===
[4326.1s-4328.0s] 假如那进到了
=== 说话人7 ===
[4328.0s-4330.4s] 代表人做什么
=== 说话人5 ===
[4330.4s-4331.7s] 菠 çıkt
=== 说话人2 ===
[4331.7s-4349.8s] 摇灶的独援

View File

@@ -0,0 +1,88 @@
# 儿童呼吸道感染空间流行病学的方法、数据与可视化:文献综述
---
儿童呼吸道感染是全球5岁以下儿童死亡的第二大原因。在中国城市地区其年发病数长期居于儿科门诊病种的首位。Wu等2025在*Communications Earth & Environment*报告了一项横跨江苏省多城市的学校环境研究纳入265,076例儿童肺炎与气管炎病例采用时空分层设计估计了PM₁各化学组分与因病缺课率的剂量-反应关系。结果显示SO₄²⁻的风险比最高RR=1.119),且低温对每一种污染物组分的健康效应均表现出显著的放大作用,城乡儿童在温度-污染复合暴露下的敏感性方向相反。同年发表于*PLOS Medicine*的全国尺度研究将501个城市、跨越2008至2019年的面板数据嵌入时间分层病例交叉模型估计野火来源PM₂.₅每升高5μg/m³儿童呼吸道传染病发病率上升6.8%。值得留意的一个数字是野火PM₂.₅在总PM₂.₅中仅占2.7%却贡献了10.8%的相关病例。
尽管如此,该领域在数据配置与方法部署两个维度上均远未饱和。
环境与地理空间变量在空间流行病学中的缺席是反复被指出却极少被回应的问题。2025年发表在*Geospatial Health*的一篇系统综述遵循PRISMA 2020指南从35项纳入研究中统计出一个醒目的比例仅2.9%的研究认真纳入了环境、地理或物理空间因子。儿童呼吸系统疾病恰恰处于这一方法论盲区的核心位置——其发病不但与每日吸入的污染物组分直接相关还与绿地覆盖对微气候的调节效应、建成环境对通风条件的改变、地形对污染扩散路径的约束等多个环境过程的交互作用相关联。另一维度上近三年间GCN-Transformer混合架构Zheng等, 2025、流行病学知识嵌入的图神经网络EISTGNN, Han等, 2025以及双通道时空图卷积网络Luo等, 2025在COVID-19与流感预测任务中取得了国家级R²达0.966、ILI相关度达0.82的性能表现,但上述方法尚未被迁移至儿童呼吸道感染这一疾病场景。对此,文献中呈现的是一种近乎沉默的状态——既非技术障碍所致,也非数据可及性不足所能解释,更像流行病学建模社区与深度学习社区在研究议程上的长期分离。另一个更具工程属性的问题是,绝大多数空间流行病学研究的产出形式仍然是一篇论文附以离线分析脚本,缺乏一套能够持续接收新数据、自动更新风险估计并实时输出可视化与预警信息的运行中的系统。
本文旨在回答两个简单的问题:当前空间流行病学领域可用的统计工具有哪些,每种方法产出何种类型的图;目前可获取的数据字段涵盖哪些维度。综述范围不限于已有研究者整合过的变量,也包括分散在各数据源中、技术上完全可调用但尚未被人放到同一模型里的字段。目的是为后续研究提供一个方法—图表—数据的完整参照系。
---
## 空间统计方法与图表产出
空间自相关检验是大多数空间流行病学分析的起点。Global Moran's I的运算和使用并不复杂但理解其局限比掌握其运算本身更为重要它回答"全局上是否存在空间聚集",但不对"聚集的确切位置"提供任何信息。在不同疾病场景中该统计量的量级存在显著差异马拉维疟疾研究的Moran's I为0.044至0.074p<0.05埃塞俄比亚HIV血清阳性率的Moran's I则达到0.16p<0.001。这些I值的绝对值普遍偏低但p值表明所检测到的空间结构并非随机过程生成。其标准化输出为Moran散点图横轴为标准化观测值纵轴为空间滞后值四个象限分别对应高-高、低-低、高-低、低-高四种空间关联模式。非空间统计背景的研究者对Moran散点图的初始反应往往是困惑——图中没有任何一个点落在可识别的地图位置上。然而它比任何等值域地图都更直接地回应一个核心问题观测值在空间维度上是否存在被其邻近值牵引的现象。
Moran散点图无法指出聚集发生在哪些具体位置。这一问题的解决需要借助局部空间统计量。Getis-Ord Gi*为每个空间单元输出一个z-score与对应的p-valuez-score需要多大绝对值才能被判定为热点或冷点取决于所采用的多重比较校正策略。前端输出为分级热点地图以红-蓝连续色阶渲染红色端对应高值聚集区热点蓝色端对应低值聚集区冷点。该方法已在中国猩红热的北部与东北部高风险区定位、埃塞俄比亚HIV亚的斯亚贝巴及周边热点区识别以及儿童急性呼吸道感染风险的空间分析中被反复验证。Anselin Local Moran's I在Gi*的基础上进一步区分聚类的子类型高值被高值包围HH、低值被低值包围LL、高值被低值包围HL、低值被高值包围LH。其标准输出为LISA聚集图各区划单元的聚类类别与统计显著性以差异化的符号标注于图上。HL与LH两类通常在后续分析中被视作空间异常值在环境暴露的空间研究中它们往往比HH聚类更具分析价值因为在同一暴露梯度面上出现反直觉的低风险区通常暗示着值得深入追问的局地保护因素或未观测的混杂结构。
空间维度与时间维度的联合扫描问题由Kulldorff1997提出的时空扫描统计量所解决。实现上多以SaTScan软件包为工具载体核心算法逻辑是在研究区内移动大小与形状可变的圆柱形扫描窗口底面为空间圆或椭圆高度为时间区间对每个窗口进行泊松或伯努利概率假设下的似然比检验判定窗口内观测病例数是否显著超出随机期望。该方法已在多项研究中取得了明确的实证验证马拉维疟疾的时空聚集区集中于国土中部与东南部RR显著升高加纳疟疾扫描出一个RR=4.66p<0.001的时空聚集簇中国猩红热在全国范围内识别出9个彼此离散的时空聚类。结果在地图上的呈现形式为一系列聚类椭圆叠置于底图之上直观指示聚集区的地理边界、时间跨度与相对风险量级。
监测站点的空间分布是不均匀的。在站点间存在显著覆盖空白的情况下暴露估计需要通过空间插值方法进行补充。Kriging插值利用半变异函数拟合已知点的空间依赖结构据此对未采样位置做出最优无偏预测。其关键统计假设是二阶平稳或内蕴平稳——实践中该假设很少被严格满足但偏离假定的严重程度是否实际影响插值面的可用性取决于半变异函数的拟合质量。若半变异函数在特定方向出现明显漂移则需要考虑趋势剔除或采用泛Kriging替代。输出为连续风险面或污染物浓度面的渲染图层。
全局回归估计将暴露-健康关联的效应量假定为整个研究区上的常数这一假定在空间流行病学中几乎总被拒绝。地理加权回归GWR将回归系数从全局空间释放至局部各位置以自身带宽内的邻近点加权估计一套局域参数。其时空扩展形式GTWR进一步将系数沿时间轴展开使效应量同时随空间位置和时间变动适用于具备充足年份跨度的面板数据分析。Wang与杨等2023发表在《中国血吸虫病防治杂志》的综述已对GTWR在流行病学中的应用场景做了系统阐述。两种方法的输出形式均为回归系数的空间分布面——从该图上可直接判读哪些地理单元内PM₂.₅单位浓度变动对发病率的边际效应显著高于或低于其他区域。
上述方法均属于统计推断的范畴。预测问题则在方法论上另有归属。
近三年间图神经网络及其时空变体在传染病预测领域取得了可观的性能提升。Zheng等2025在*Physica A*提出了GCN-PEFormer架构将基于航空旅行与地理邻近性的动态图与阶段感知的Transformer编码器融合60天预测的RMSE较LSTM、GCN-LSTM、Transformer和PatchTST基线平均降低34.5%。Han等2025的EISTGNN模型走了一条不同的技术路线——将Spatio-Contact SIR仓室模型的动力学方程直接嵌入图神经网络的损失函数以有效再生数Rt约束预测方向在维持预测精度的同时获得了显著高于纯黑箱模型的可解释性。Luo等2025提出的双通道STGCN在工程设计上更为务实一条通道沿道路网络邻接矩阵传导另一条沿社会经济相关矩阵传导在台湾ILI监测数据上的预测相关度达到0.82。这一结果揭示了一个重要的经验发现两个空间上相隔甚远但社会经济结构相似的区域之间疾病传播的信号强度未必弱于地理位置相邻的区域。上述模型产出的可视化形式高度一致预测值与观测值在同一时间轴上叠置的序列曲线图下方通常配以残差分布的直方图或Q-Q图部分研究附加特征重要性的排序条形图。
贝叶斯时空层次模型在方法论取向上与前几类方法存在结构性的差异。其处理空间依赖时不依赖预先定义的邻接结构——空间条件自回归先验允许数据自行估计空间依赖的强度与空间范围。Si等2025在*Atmospheric Environment*上运用该模型对北京、广州与武汉三个城市的PM₂.₅各组分与流感传播的关联进行了估计。武汉PM₂.₅的相对风险为1.1895% CrI: 1.10-1.26且时空层次模型的拟合效果在DIC准则下显著优于不包含空间随机效应的广义线性模型。其输出形式为后验概率风险图每个网格的估计值携带完整的后验分布信息不同位置的不确定性带宽可直接在地图上进行比较这一点在面向风险沟通与决策支持的场景中具有不可替代的价值。
将上述统计方法、研究问题与图表产出关系汇总如下:
| 统计方法 | 研究问题 | 图表产出 |
|---------|---------|---------|
| Global Moran's I | 全局空间自相关是否存在 | Moran散点图 |
| Getis-Ord Gi* | 热点与冷点的空间分布位置 | 分级热点地图(红-蓝色阶) |
| Anselin Local Moran's I | 局部聚类的具体类型HH/HL/LH/LL | LISA聚集图附显著性标记 |
| Kulldorff时空扫描 | 时空聚集区的时空边界与相对风险 | 时空聚类椭圆附RR与p值 |
| Kriging插值 | 监测空白区的风险面或暴露面估计 | 连续插值面渲染 |
| GWR / GTWR | 暴露效应空间异质性的量化 | 回归系数空间分布面 |
| GCN / STGCN | 多步长风险预测17天 | 预测-观测对比曲线,残差分布图 |
| SIR/SEIR动力学模型 | 传播速率与趋势估计 | 有效再生数Rt时序曲线 |
| 贝叶斯时空层次模型 | 空间依赖结构与不确定性量化 | 后验概率风险图 |
---
## 数据字段
传统空间流行病学研究的数据视野局限于病例计数、人口分母以及少量行政级别的协变量。这一现状并非由数据可及性所限制——2026年的实际情形是大量环境遥感、路网拓扑、兴趣点分布与建筑环境数据已在公开渠道或经轻量级申请后可供获取真正稀缺的并非数据本身而是将多模态异源数据纳入同一分析框架的研究实践。
以下按数据模态组织分类,对当前可用与可获取的数据字段做系统性梳理。
**气象与大气环境。** 基础来源为地面国控监测站网络的逐小时观测记录。核心字段为六项大气污染物浓度PM₂.₅、PM₁₀、SO₂、NO₂、O₃、CO与气象要素温度、湿度、风速、气压。原始数据为站点级逐小时记录需经由空间插值转化至面域格网。上述字段仅构成了暴露特征的初级维度。暴露的健康效应极少是即时的——PM₂.₅吸入后的气道炎症反应可能在其后两至三天才反映为门诊计数的抬升。学界在流行病学建模中的惯常做法是构建多尺度滞后窗口取1d、2d、3d、5d、7d、14d共六个步长短滞后窗口用于捕获急性气道反应长滞后窗口用于捕获累积性组织损伤与继发感染的风险推动效应。24小时滚动均值将暴露剂量的时间分辨率从逐时提升至逐日O₃单独取8小时滑动均值系因臭氧的急性健康效应评价标准窗口即为8小时。在此基础之上AQI空气质量指数、首要污染物标识与空气质量等级作为复合概览指标使用。六项污染物乘以六个滞后步长得出的36个滞后特征变量构成了暴露特征在时间维度上的主要结构。
**遥感反演与栅格环境数据。** DEM数字高程模型ASTER或SRTM30m分辨率及其衍生地形因子高程、坡度、坡向用于刻画各网格单元在区域通风廊道中的空间位置。高分辨率人口密度栅格LandScan HD100m分辨率提供中国全域内暴露人口的精细空间分布。多光谱遥感方面Landsat 8/9与Sentinel-2的反演产品提供多项关键变量的连续时间序列NDVI植被指数的逐月或逐旬序列可反映绿地覆盖的季节性波动幅度地表温度LST用于量化城市热岛强度的时空变化模式气溶胶光学厚度AOD作为地面PM₂.₅监测的空间补充,在监测站点稀疏的城市远郊区域尤为有用。土地覆盖分类将地表划分为建筑区、道路、水体、绿地与裸地五个类别,逐网格计算各类别的面积占比,从而将建成环境比例转化为一个连续数值变量进入模型。
**医疗就诊记录。** 分为门诊与住院两条线数据。单条记录的字段结构明确但信息密度不低就诊入院日期、ICD-10诊断编码、年龄分层014、1544、4564、65岁以上四组、性别与现住址。地址文本经由地理编码引擎转化为经纬度坐标匹配过程采用两级策略——街道级别匹配匹配失败则回退至行政区级别——匹配成功的记录附带置信度标记。聚合方向有两个空间上可以向格网或行政区汇总时间上可以向逐日、逐周或逐月汇总。门诊权重取1、住院权重取3是一种将住院事件所代表的更高疾病严重程度以简单数值权重形式纳入统计建模的实用化处理。
**道路网络、兴趣点与建成环境。** 道路网络数据基于OpenStreetMap提取覆盖motorway至tertiary五个等级道路属性包含路段长度、限速与车道数。路网经拓扑检查与简化处理后构建为空间邻接图其邻接矩阵是图神经网络模型的空间输入骨架。兴趣点数据涵盖三类关键设施学校与幼儿园的位置及办学规模影响各网格内儿童白天暴露人口密度的估计、医疗机构的位置、等级与核定床位数影响就医可及性与病例记录的完整程度——距离医疗机构较远的网格轻症病例被记录的概率会系统性地偏低、以及公园与绿地的空间边界提供NDVI之外的绿地可达性信息。公共交通可达性以公交站点与地铁站出入口的空间密度栅格表征。人口结构数据落至街道或社区级别包含常住人口数、014岁儿童人口数与性别比。建筑环境数据涵盖建筑基底面积比例、平均层数与建成年代这些变量在分析城市内部暴露异质性时比任何宏观行政区级别的指标都更直接地反映建成环境对呼吸健康的影响机制。
**空间参考系。** 多级行政边界(省、市、区/县三级街道级视数据源而可及提供空间统计的行政分区基准。全域标准格网索引100m或更大尺度为多源数据聚合的基本空间单元。每个网格携带唯一标识码、中心经纬度坐标、WKT几何表达以及到各级行政区的归属映射。所有的环境暴露估计、病例计数与设施密度指标最终均在统一的格网坐标参考系中对齐。
---
## 现有研究缺口
数据维度的可及性与方法学工具箱的成熟度均已达到一个可以支撑更高整合度分析的阶段,但以下三个层面的整合尚未在现有文献中实现。
其一多源异构数据在单个研究中的并发使用量始终偏低。大多数空间流行病学文章仅包含两到三类数据源——气象污染物加医疗就诊计数至多再纳入人口密度作为分母变量。将遥感栅格产品、建筑环境要素、道路网络拓扑与POI设施密度同时纳入同一分析框架的研究在文献中所占比例极低。此处并非是数据的可获取性限制了研究设计而是数据整合的工程成本与研究者的学科边界共同造成了这一局面。其二深度学习时空预测方法在COVID-19与流感建模中已积累了充分的性能验证R²>0.96,相关度>0.82),但将其迁移至儿童呼吸道感染这一疾病场景的文献几乎无法检索到。这一沉默并非源自技术不可行,而更接近流行病学建模社区与深度学习社区在研究议程上的长期分离所导致的学科盲区。其三,从研究产出到可运行系统的转化步骤尚未被跨越。回顾性关联分析、风险地图绘制与学术论文发表构成了目前文献的主要产出形态。然而,一个能够持续接收数据流、按日更新网格级风险估计并自动将热点聚类推送至交互式地图与周期报告的运行中的系统组件,在现有文献中几乎不见踪迹。这严格来说并非学术问题,而是工程化部署问题,但医学地理学与公共卫生中相当比例的具有应用价值的工作,恰恰落在学术研究与工程实现的间隙中。
---
## 参考文献
1. Wu et al. Extreme temperatures amplify air pollution risks to childhood respiratory health in school environment in Jiangsu province, China. *Communications Earth & Environment*, 2025. DOI: 10.1038/s43247-025-02409-8.
2. Wildfire-related PM₂.₅ and respiratory transmitted disease among Chinese children and adolescents from 2008 to 2019: A retrospective study. *PLOS Medicine*, 2025.
3. Factors associated with the spatial distribution of leprosy: a systematic review of the published literature. *Geospatial Health*, 2025. DOI: 10.4081/gh.2025.1394. PMID: 40964901.
4. Zheng Z, Zheng X, Liu D, et al. GCN-PEFormer: A mobility-aware and patch-based transformer for predicting spatiotemporal transmission patterns of epidemics. *Physica A*, 2025. DOI: 10.1016/j.physa.2025.130721.
5. Han et al. EISTGNN: Epidemiology-informed Spatiotemporal Graph Neural Network for heterogeneity-driven interpretable epidemic forecasting. *Engineering Applications of Artificial Intelligence*, 2025, Vol. 162.
6. Luo J, Wang X, Fan X, et al. A novel graph neural network based approach for influenza-like illness nowcasting: exploring the interplay of temporal, geographical, and functional spatial features. *BMC Public Health*, 2025, 25:408.
7. Si et al. Particulate matters 2.5 and its organic constituent on seasonal influenza transmission: Insights from Bayesian spatiotemporal modelling in mainland China. *Atmospheric Environment*, 2025.
8. Wang Y, et al. 空间流行病学贝叶斯时空模型综述. *ISPRS International Journal of Geo-Information*, 2024.
9. 王喆, 杨坤等. 时空地理加权回归模型在流行病学中的应用综述. *中国血吸虫病防治杂志*, 2023, 35(2): 199.
10. 夏彦恺等. 一种基于大气环境监测与深度学习模型的儿童呼吸道疾病预警方法及装置. 中国专利: CN120236784A, 2025.
11. Galvan MC, Galvan JL. *Writing Literature Reviews*, 7th Edition. Routledge (Taylor & Francis), 2024. ISBN: 978-1-032-60826-6.
12. Pan ML, Craven JS. *Preparing Literature Reviews*, 6th Edition. Routledge (Taylor & Francis), 2024. ISBN: 978-1-032-60839-6.

1
backend/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Backend package

42
backend/auth/CLAUDE.md Normal file
View File

@@ -0,0 +1,42 @@
# Auth — JWT Authentication
## Stack
python-jose (JWT signing/verification) + passlib (bcrypt password hashing). Token-based, stateless.
## Structure
```
auth/
models.py # Pydantic models: UserCreate, UserLogin, Token, UserOut
service.py # Business logic: authenticate_user, create_user, create_access_token
dependencies.py # FastAPI Depends: get_current_user, require_admin
middleware.py # ASGI middleware (if any global auth checks)
router.py # APIRouter: /login, /register, /whoami
```
## Patterns
- Passwords hashed with bcrypt via `passlib` — never store plaintext
- JWT tokens signed with `python-jose`, include `sub` (username) and `exp`
- `get_current_user()` is the standard `Depends()` to inject user into endpoints
- Auth endpoints return Pydantic models: `Token(access_token=...)`, `UserOut(username=...)`
- HTTP status codes: 401 for bad credentials, 409 for duplicate user
## Usage in Routers
```python
from auth.dependencies import get_current_user
@router.get("/protected")
async def protected_route(current_user = Depends(get_current_user)):
...
```
## Anti-Patterns
- Don't hardcode secret keys — use `Settings` from environment
- Don't store tokens client-side without HttpOnly cookies
- Don't skip `response_model` on auth endpoints
- Don't leak whether username or password was wrong — always "incorrect username or password"
- Don't bypass `Depends(get_current_user)` for protected routes

View File

@@ -8,7 +8,13 @@ from passlib.context import CryptContext
logger = logging.getLogger("cbpoa.auth")
SECRET_KEY = os.getenv("AUTH_SECRET_KEY", "cbpoa-dev-secret-change-in-production")
_DEFAULT_SECRET = "cbpoa-dev-secret-change-in-production"
SECRET_KEY = os.getenv("AUTH_SECRET_KEY", _DEFAULT_SECRET)
if SECRET_KEY == _DEFAULT_SECRET:
logger.warning(
"AUTH_SECRET_KEY is not set — using the built-in development secret. "
"Set AUTH_SECRET_KEY in the environment before deploying; the default is public and allows token forgery."
)
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("AUTH_TOKEN_EXPIRE_MINUTES", "480"))
@@ -61,6 +67,10 @@ def create_access_token(data: dict) -> str:
def decode_access_token(token: str) -> dict | None:
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
# python-jose ignores PyJWT's options={"require": [...]}, so enforce exp manually:
# a token with no exp claim would otherwise never expire.
if "exp" not in payload:
return None
return payload
except JWTError:
return None

View File

@@ -13,7 +13,7 @@ load_dotenv()
# Paths
# ============================================================================
PROJECT_ROOT = Path(__file__).parent.parent
PROJECT_ROOT = Path(os.environ.get("CBPOA_ROOT", Path(__file__).parent.parent))
DATA_DIR = PROJECT_ROOT / "outputs" / "daily"
REPORTS_DIR = PROJECT_ROOT / "outputs" / "reports"
WUHAN_BOUNDARY_PATH = PROJECT_ROOT / "Datas" / "武汉市.geojson"

View File

@@ -3,22 +3,64 @@ Shared data-loading module for case data (outpatient + inpatient).
Extracted from routers/cases.py so both cases and reports routers can use
the same cached data without circular imports.
Performance: reads from pre-generated Parquet files (~0.1s) instead of
Excel (~10s). Falls back to Excel if parquet files are missing.
"""
import re
import logging
import threading
from typing import Optional, cast
import pandas as pd
from pathlib import Path
from datetime import datetime
logger = logging.getLogger("cbpoa.case_loader")
DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$")
# Data cache
_cache = {
_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 = {
'江岸区': ['江岸'],
@@ -40,6 +82,7 @@ WUHAN_DISTRICTS = {
PROJECT_ROOT = Path(__file__).parent.parent.parent
DATA_DIR = PROJECT_ROOT / "Datas"
PROCESSED_DIR = PROJECT_ROOT / "processed"
def _extract_district(addr: str) -> str:
@@ -54,52 +97,143 @@ def _extract_district(addr: str) -> str:
return '其他'
def _load_from_parquet() -> bool:
"""Try to load data from pre-generated Parquet files. Returns True on success."""
outpatient_path = PROCESSED_DIR / "cases_outpatient.parquet"
inpatient_path = PROCESSED_DIR / "cases_inpatient.parquet"
if not outpatient_path.exists() or not inpatient_path.exists():
logger.info("Parquet files not found, falling back to Excel")
return False
try:
_cache["outpatient"] = pd.read_parquet(outpatient_path)
_cache["inpatient"] = pd.read_parquet(inpatient_path)
_cache["loaded_at"] = datetime.now()
logger.info("Loaded case data from Parquet (%d outpatient, %d inpatient)",
len(_cache["outpatient"]), len(_cache["inpatient"]))
return True
except Exception as e:
logger.warning("Parquet load failed (%s), falling back to Excel", e)
return False
def _load_from_excel():
"""Load data from Excel files (slow fallback)."""
df_out = pd.read_excel(DATA_DIR / "view_门诊.xlsx")
df_out['date'] = pd.to_datetime(df_out['门诊日期_re'])
df_out['district'] = df_out['现住址区'].fillna('未知')
_cache["outpatient"] = df_out
df_in = pd.read_excel(DATA_DIR / "view_住院.xlsx")
df_in['date'] = pd.to_datetime(df_in['入院日期_re'])
df_in['district'] = df_in['现住址_脱敏'].apply(_extract_district)
_cache["inpatient"] = df_in
_cache["loaded_at"] = datetime.now()
def load_data():
"""Load and cache outpatient + inpatient data from Excel files"""
"""Load and cache outpatient + inpatient data.
Uses pre-generated Parquet files for fast loading (~0.1s).
Falls back to Excel files (~10s) if Parquet is unavailable.
"""
if _cache["loaded_at"] is not None:
return
try:
# Load outpatient data
df_out = pd.read_excel(DATA_DIR / "view_门诊.xlsx")
df_out['date'] = pd.to_datetime(df_out['门诊日期_re'])
df_out['district'] = df_out['现住址区'].fillna('未知')
_cache["outpatient"] = df_out
# Load inpatient data
df_in = pd.read_excel(DATA_DIR / "view_住院.xlsx")
df_in['date'] = pd.to_datetime(df_in['入院日期_re'])
df_in['district'] = df_in['现住址_脱敏'].apply(_extract_district)
_cache["inpatient"] = df_in
_cache["loaded_at"] = datetime.now()
except Exception as e:
raise RuntimeError(f"Data loading failed: {str(e)}")
with _load_lock:
if _cache["loaded_at"] is not None: # another thread loaded while we waited
return
if not _load_from_parquet():
try:
_load_from_excel()
except Exception as e:
raise RuntimeError(f"Data loading failed: {str(e)}")
def get_combined_data():
"""Return merged outpatient + inpatient data with unified diagnosis column"""
def get_combined_data() -> pd.DataFrame:
"""Return merged outpatient + inpatient data with unified diagnosis column.
Caches the result in memory after first call (~0.1s on cached hit).
"""
if _cache["combined"] is not None:
return cast(pd.DataFrame, _cache["combined"])
with _load_lock:
if _cache["combined"] is not None: # built while we waited for the lock
return cast(pd.DataFrame, _cache["combined"])
load_data()
df_out = cast(pd.DataFrame, _cache["outpatient"])
df_in = cast(pd.DataFrame, _cache["inpatient"])
df_out = df_out[['date', 'district', '初诊', '主诉']].copy()
df_out['type'] = 'outpatient'
df_out['diagnosis'] = df_out['初诊']
df_in = df_in[['date', 'district', '诊断名称']].copy()
df_in['type'] = 'inpatient'
df_in['diagnosis'] = df_in['诊断名称']
df_in['主诉'] = None
_cache["combined"] = pd.concat([df_out, df_in], ignore_index=True) # type: ignore[assignment]
return cast(pd.DataFrame, _cache["combined"])
def get_diagnoses() -> list[str]:
"""Return sorted list of unique diagnosis names (fast: reads from cached DataFrames)."""
load_data()
df_out = _cache["outpatient"][['date', 'district', '初诊', '主诉']].copy()
df_out['type'] = 'outpatient'
df_out['diagnosis'] = df_out['初诊']
df_in = _cache["inpatient"][['date', 'district', '诊断名称']].copy()
df_in['type'] = 'inpatient'
df_in['diagnosis'] = df_in['诊断名称']
df_in['主诉'] = None
return pd.concat([df_out, df_in], ignore_index=True)
out_diag = cast(pd.DataFrame, _cache["outpatient"])['初诊'].dropna().unique()
in_diag = cast(pd.DataFrame, _cache["inpatient"])['诊断名称'].dropna().unique()
return sorted(set(out_diag.tolist() + in_diag.tolist()))
def get_outpatient_data():
def get_outpatient_data() -> pd.DataFrame:
"""Return the cached outpatient dataframe"""
load_data()
return _cache["outpatient"]
return _cache["outpatient"] # type: ignore[return-value]
def get_inpatient_data():
def get_inpatient_data() -> pd.DataFrame:
"""Return the cached inpatient dataframe"""
load_data()
return _cache["inpatient"]
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()

View File

@@ -17,7 +17,7 @@ class Settings(BaseSettings):
POSTGRES_USER: str = ""
POSTGRES_PASSWORD: str = ""
POSTGRES_DB: str = ""
class Config:
env_file = ".env"
@@ -33,10 +33,10 @@ if not settings.POSTGRES_USER or not settings.POSTGRES_PASSWORD or not settings.
class Database:
"""Async database connection pool manager"""
def __init__(self):
self.pool: Optional[asyncpg.Pool] = None
async def connect(self):
"""Initialize database connection pool"""
if self.pool is None:
@@ -48,29 +48,29 @@ class Database:
command_timeout=60
)
logger.info("Database connection pool created successfully")
async def disconnect(self):
"""Close database connection pool"""
if self.pool:
await self.pool.close()
self.pool = None
logger.info("Database connection pool closed")
@asynccontextmanager
async def get_connection(self):
"""Get a connection from the pool"""
if self.pool is None:
await self.connect()
assert self.pool is not None
async with self.pool.acquire() as connection:
yield connection
@asynccontextmanager
async def get_transaction(self):
"""Get a transaction context"""
if self.pool is None:
await self.connect()
assert self.pool is not None
async with self.pool.acquire() as connection:
async with connection.transaction():
yield connection

View File

@@ -13,7 +13,7 @@ from logging_config import setup_logging
from middleware.request_logger import RequestLoggerMiddleware
from auth.router import router as auth_router
from auth.service import seed_default_admin
from routers import risk, alerts, analysis, insights, reports, cases, geocoded, grid, chat
from routers import risk, alerts, analysis, insights, reports, cases, geocoded, grid, chat, environment, statistics
setup_logging()
@@ -27,7 +27,7 @@ app = FastAPI(
)
app.add_middleware(RequestLoggerMiddleware)
app.add_middleware(GZipMiddleware, minimum_size=1000)
app.add_middleware(GZipMiddleware, minimum_size=1000, compresslevel=1)
logger = logging.getLogger("cbpoa.main")
@@ -58,6 +58,8 @@ app.include_router(cases.router)
app.include_router(geocoded.router)
app.include_router(grid.router)
app.include_router(chat.router)
app.include_router(environment.router)
app.include_router(statistics.router)
@app.get("/")

View File

@@ -2,7 +2,7 @@
Pydantic models for CBPOA risk assessment API
Aligned with frontend types from CBPOA/frontend/src/types/index.ts
"""
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
from typing import Optional, List, Literal
from datetime import datetime
@@ -280,6 +280,9 @@ class MultiDayPredictionRequest(BaseModel):
class MultiDayPredictionResponse(BaseModel):
"""Response for multi-day grid predictions"""
# `model_version` collides with Pydantic's protected `model_` namespace; opt out.
model_config = ConfigDict(protected_namespaces=())
predictions: List[GridPrediction] = Field(..., description="Grid predictions")
total_grids: int = Field(..., description="Total grids predicted")
date_range: tuple[str, str] = Field(..., description="Prediction date range")

View File

@@ -16,3 +16,4 @@ pandas>=2.0.0
numpy>=1.24.0
pyarrow>=14.0.0
openpyxl>=3.1.0
Pillow>=10.0.0

47
backend/routers/CLAUDE.md Normal file
View File

@@ -0,0 +1,47 @@
# Routers — API Endpoints
## Pattern
Each router file defines one `APIRouter(prefix=..., tags=[...])` with typed endpoints.
```python
from fastapi import APIRouter
from models import SomeResponse
router = APIRouter(prefix="/api/domain", tags=["domain"])
@router.get("/endpoint", response_model=SomeResponse)
async def get_something(...):
...
```
## Conventions
- Return Pydantic models (`response_model=`), never raw dicts
- Use `Annotated[Type, Query(...)]` / `Path(...)` for request params
- Spatial queries use `scipy.spatial.KDTree` for nearest-neighbor lookups
- GeoJSON parsing is delegated to `utils/geojson.py`
- Risk level mapping is in `utils/risk.py` — use `risk_value_to_level()` not inline thresholds
- Large repeated queries use `@lru_cache` (from `functools`)
- Date helpers from `utils/date_helpers.py` — always `get_latest_date()`, never guess
## File Map
| File | Domain |
|------|--------|
| `risk.py` | Risk maps, grid detail, history (largest router, ~42 file reads) |
| `alerts.py` | Alert feed, stats |
| `cases.py` | Medical case queries (age, disease, district filters) |
| `analysis.py` | Trend analysis, statistics |
| `grid.py` | Grid metadata, elevation, population |
| `reports.py` | Report generation, export |
| `insights.py` | AI-generated insights |
| `chat.py` | Chatbot endpoint |
| `geocoded.py` | Geocoded case data |
## Anti-Patterns
- Don't use sync I/O in `async def` — use `async with db.get_connection()` for DB
- Don't catch bare `Exception` — use specific HTTPException or let it propagate to middleware
- Don't return raw GeoJSON dicts without Pydantic validation
- Don't inline risk thresholds — use `utils/risk.risk_value_to_level()`

View File

@@ -5,12 +5,15 @@ 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
from models import Alert, AlertResponse
from utils.date_helpers import get_latest_date, validate_date_format
from utils.risk import risk_value_to_level
from utils.district_lookup import district_for_grid
router = APIRouter(prefix="/api/alerts", tags=["alerts"])
@@ -32,8 +35,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.
@@ -88,13 +96,14 @@ def generate_alerts_for_date(date: str) -> List[Alert]:
lat, lon = grid_id_to_center(grid_id)
risk_level = risk_value_to_level(max_risk)
district = district_for_grid(grid_id)
alerts.append(
Alert(
alert_id=f"alert_{date}_{grid_id}",
grid_id=grid_id,
region="武汉市",
street=f"Grid {grid_id}",
region=district,
street=grid_id,
latitude=lat,
longitude=lon,
risk_value=max_risk,
@@ -111,6 +120,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 +137,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 +159,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 +175,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 +192,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 +209,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(

View File

@@ -5,15 +5,20 @@ Time series trends, district aggregation, and weather-health correlations
from fastapi import APIRouter, HTTPException, Query
from datetime import datetime, timedelta
from typing import List, Literal
from functools import lru_cache
import random
import pandas as pd
from pydantic import BaseModel, Field
from config import DATA_DIR, RISK_HIGH
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
from utils.risk import calculate_trend
from utils.daily_risk_avg import daily_avg_risk
from utils.district_lookup import grid_district_lookup
router = APIRouter(prefix="/api/analysis", tags=["analysis"])
@@ -27,11 +32,13 @@ class TrendResponse(BaseModel):
class DistrictRisk(BaseModel):
"""District-level risk aggregation"""
name: str = Field(..., description="District name")
district: str = Field(..., description="District name")
avg_risk: float = Field(..., description="Average risk value")
avg_aqi: float = Field(..., description="Average AQI from weather stations in this district")
population: int = Field(..., description="Population (sum of 100m LandScan cells in district)")
high_risk_count: int = Field(..., description="Count of high risk grids")
total_grids: int = Field(..., description="Total grids in district")
total_cases: int = Field(..., description="Estimated total cases")
total_cases: int = Field(..., description="Total recorded cases (real, from cases_by_district_daily)")
class DistrictsResponse(BaseModel):
@@ -78,25 +85,10 @@ async def get_trend(days: int = Query(default=7, ge=1, le=30)):
for i in range(days):
date = base_date - timedelta(days=days - 1 - i)
date_str = date.strftime("%Y%m%d")
filepath = DATA_DIR / f"risk_{date_str}.geojson"
if filepath.exists():
grids = parse_geojson_file(filepath)
if grids:
avg_risk = sum(g["risk_value"] for g in grids) / len(grids)
values.append(round(avg_risk, 4))
else:
values.append(0)
else:
values.append(0)
# Disk+memory cached mean — avoids re-parsing ~45MB GeoJSON every request
values.append(daily_avg_risk(date_str))
dates.append(date.strftime("%Y-%m-%d"))
# Filter out zero values
valid_data = [(d, v) for d, v in zip(dates, values) if v > 0]
if valid_data:
dates, values = zip(*valid_data)
dates, values = list(dates), list(values)
trend_direction = calculate_trend(values)
return TrendResponse(
@@ -106,101 +98,144 @@ async def get_trend(days: int = Query(default=7, ge=1, le=30)):
)
@lru_cache(maxsize=1)
def _grid_district_lookup() -> dict:
"""Backward-compatible alias — prefer utils.district_lookup."""
return grid_district_lookup()
@lru_cache(maxsize=1)
def _district_population() -> dict:
"""Real population per district.
Sums the LandScan-derived population_density of every 100m cell
(grid_100m_with_dem_pop.parquet) grouped by district via the
grid->district mapping. Returns {district_name: total_population}.
"""
pop_path = PROJECT_ROOT / "processed" / "grid_100m_with_dem_pop.parquet"
map_path = PROJECT_ROOT / "processed" / "grid_district_mapping.parquet"
if not pop_path.exists() or not map_path.exists():
return {}
pop = pd.read_parquet(pop_path, columns=["grid_id", "population_density"])
mapping = pd.read_parquet(map_path).dropna(subset=["district_name"])
joined = pop.merge(mapping, on="grid_id", how="inner")
by_d = joined.groupby("district_name")["population_density"].sum()
return {str(k): int(round(v)) for k, v in by_d.items()}
@lru_cache(maxsize=1)
def _district_avg_aqi() -> dict:
"""Real average AQI per district from weather station daily data.
Each station (with lat/lon) is assigned to a district using the same
grid->district mapping (100m grid spacing of 1/1110 deg, the convention
the mapping was built with), then AQI is averaged per district across
all daily observations. Returns {district_name: avg_aqi}. Districts with
no station fall back to the city-wide mean in the caller.
"""
map_path = PROJECT_ROOT / "processed" / "grid_district_mapping.parquet"
station_path = PROJECT_ROOT / "processed" / "weather" / "station_daily_2022.parquet"
if not map_path.exists() or not station_path.exists():
return {}
mapping = pd.read_parquet(map_path).dropna(subset=["district_name"])
lookup = dict(zip(mapping["grid_id"].astype(str), mapping["district_name"].astype(str)))
station = pd.read_parquet(station_path, columns=["station_id", "lat", "lon", "AQI"])
step = 1.0 / 1110.0 # mapping grid spacing in degrees
min_lat = WUHAN_BOUNDS["min_lat"]
min_lon = WUHAN_BOUNDS["min_lon"]
coords = station[["station_id", "lat", "lon"]].drop_duplicates()
station_to_district = {}
for _, r in coords.iterrows():
row = int((r["lat"] - min_lat) / step)
col = int((r["lon"] - min_lon) / step)
station_to_district[r["station_id"]] = lookup.get(f"r{row}_c{col}", "其他")
station = station.copy()
station["district"] = station["station_id"].map(station_to_district)
in_district = station[station["district"] != "其他"]
by_d = in_district.groupby("district")["AQI"].mean()
return {str(k): round(float(v), 1) for k, v in by_d.items()}
@lru_cache(maxsize=1)
def _district_total_cases() -> dict:
"""Real total recorded cases per district from cases_by_district_daily.
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}.
"""
try:
df = load_cases_by_district_daily()
except FileNotFoundError:
return {}
by_district = df.groupby("district")["total_cases"].sum()
return {str(d): int(v) for d, v in by_district.items()}
@lru_cache(maxsize=8)
def _aggregate_districts(date: str) -> list:
"""Aggregate per-district risk for a date.
Assigns each 100m risk grid to a district via the precomputed
grid->district mapping (O(1) dict lookup per grid) instead of per-grid
point-in-polygon (which is ~100x slower over 140k grids). Cached by date.
"""
grids = parse_geojson_file(DATA_DIR / f"risk_{date}.geojson")
lookup = _grid_district_lookup()
agg: dict = {}
for g in grids:
row = int((g["latitude"] - WUHAN_BOUNDS["min_lat"]) / LAT_STEP)
col = int((g["longitude"] - WUHAN_BOUNDS["min_lon"]) / LON_STEP)
name = lookup.get(f"r{row}_c{col}", "其他")
a = agg.setdefault(name, {"sum": 0.0, "count": 0, "high": 0})
risk = g["risk_value"]
a["sum"] += risk
a["count"] += 1
if risk >= RISK_HIGH:
a["high"] += 1
pop_by_district = _district_population()
aqi_by_district = _district_avg_aqi()
cases_by_district = _district_total_cases()
# City-wide mean AQI as fallback for districts without a weather station.
city_avg_aqi = round(sum(aqi_by_district.values()) / len(aqi_by_district), 1) if aqi_by_district else 0.0
result = []
for name, a in agg.items():
if a["count"] == 0:
continue
avg = a["sum"] / a["count"]
result.append({
"district": name,
"avg_risk": round(avg, 4),
"avg_aqi": aqi_by_district.get(name, city_avg_aqi),
"population": pop_by_district.get(name, 0),
"high_risk_count": a["high"],
"total_grids": a["count"],
"total_cases": cases_by_district.get(name, 0),
})
# '其他' (unassigned) last, otherwise by descending risk
result.sort(key=lambda d: (d["district"] == "其他", -d["avg_risk"]))
return result
@router.get("/districts", response_model=DistrictsResponse)
async def get_districts():
"""
Get district-level risk aggregation
Returns:
District-level risk data with averages and counts
"""
"""Get district-level risk aggregation (cached per date)."""
latest_date = get_latest_date()
filepath = DATA_DIR / f"risk_{latest_date}.geojson"
if not filepath.exists():
raise HTTPException(status_code=404, detail=f"No data found for date {latest_date}")
grids = parse_geojson_file(filepath)
districts = load_districts()
if not districts:
# Fallback: return city-wide aggregation
avg_risk = sum(g["risk_value"] for g in grids) / len(grids) if grids else 0
high_risk_count = sum(1 for g in grids if g["risk_value"] >= RISK_HIGH)
return DistrictsResponse(
districts=[
DistrictRisk(
name="武汉市",
avg_risk=round(avg_risk, 4),
high_risk_count=high_risk_count,
total_grids=len(grids),
total_cases=int(len(grids) * avg_risk * 0.1) # Mock case rate
)
],
timestamp=datetime.now().isoformat()
)
# Aggregate grids by district using point-in-polygon
district_data = {d["name"]: {"grids": [], "high_risk": 0} for d in districts}
unassigned = {"grids": [], "high_risk": 0}
for grid in grids:
assigned = False
for district in districts:
if point_in_polygon(grid["latitude"], grid["longitude"], district["coordinates"]):
district_data[district["name"]]["grids"].append(grid)
if grid["risk_value"] >= RISK_HIGH:
district_data[district["name"]]["high_risk"] += 1
assigned = True
break
if not assigned:
unassigned["grids"].append(grid)
if grid["risk_value"] >= RISK_HIGH:
unassigned["high_risk"] += 1
# Build response
result = []
for district in districts:
name = district["name"]
grids_in_district = district_data[name]["grids"]
if not grids_in_district:
continue
avg_risk = sum(g["risk_value"] for g in grids_in_district) / len(grids_in_district)
high_risk_count = district_data[name]["high_risk"]
# Mock total cases based on risk and grid count
total_cases = int(len(grids_in_district) * avg_risk * 0.1)
result.append(
DistrictRisk(
name=name,
avg_risk=round(avg_risk, 4),
high_risk_count=high_risk_count,
total_grids=len(grids_in_district),
total_cases=total_cases
)
)
# Add unassigned as "其他" if significant
if unassigned["grids"]:
avg_risk = sum(g["risk_value"] for g in unassigned["grids"]) / len(unassigned["grids"])
result.append(
DistrictRisk(
name="其他",
avg_risk=round(avg_risk, 4),
high_risk_count=unassigned["high_risk"],
total_grids=len(unassigned["grids"]),
total_cases=int(len(unassigned["grids"]) * avg_risk * 0.1)
)
)
districts = [DistrictRisk(**d) for d in _aggregate_districts(latest_date)]
return DistrictsResponse(
districts=result,
timestamp=datetime.now().isoformat()
districts=districts,
timestamp=datetime.now().isoformat(),
)

View File

@@ -96,9 +96,10 @@ async def get_cases_stats(
total_outpatient = len(df_out)
total_inpatient = len(df_in)
# 日期范围
min_date = min(df_out['date'].min(), df_in['date'].min())
max_date = max(df_out['date'].max(), df_in['date'].max())
# 日期范围(过滤后可能为空,需防御 NaT
all_dates = pd.concat([df_out['date'], df_in['date']]).dropna()
date_start = all_dates.min().strftime("%Y-%m-%d") if len(all_dates) else ""
date_end = all_dates.max().strftime("%Y-%m-%d") if len(all_dates) else ""
# 区域统计
out_districts = df_out[df_out['district'] != '未知']['district'].value_counts().head(10)
@@ -120,8 +121,8 @@ async def get_cases_stats(
total_outpatient=total_outpatient,
total_inpatient=total_inpatient,
date_range={
"start": min_date.strftime("%Y-%m-%d"),
"end": max_date.strftime("%Y-%m-%d")
"start": date_start,
"end": date_end
},
top_districts=top_districts,
top_diagnoses=top_diagnoses
@@ -147,7 +148,7 @@ async def get_cases_trend(
if end_date and not DATE_PATTERN.match(end_date):
raise HTTPException(status_code=400, detail="Invalid end_date format. Use YYYY-MM-DD")
df = get_combined_data()
df = get_combined_data().copy()
# 日期过滤
if start_date:
@@ -203,6 +204,8 @@ def _compute_cases_districts(
case_type: Optional[str],
min_count: int,
diagnosis: Optional[str],
start_date: Optional[str] = None,
end_date: Optional[str] = None,
) -> DistrictsResponse:
"""Run the full pandas aggregation pipeline (called in thread pool)."""
df = get_combined_data()
@@ -211,6 +214,12 @@ def _compute_cases_districts(
if diagnosis:
df = df[df['diagnosis'].str.contains(diagnosis, na=False, case=False)]
# 日期过滤
if start_date:
df = df[df['date'] >= pd.to_datetime(start_date)]
if end_date:
df = df[df['date'] <= pd.to_datetime(end_date)]
# 类型过滤
if case_type == "outpatient":
df = df[df['type'] == 'outpatient']
@@ -255,18 +264,21 @@ async def get_cases_districts(
case_type: Optional[str] = Query(None, description="病例类型outpatient, inpatient, all"),
min_count: int = Query(10, description="最小病例数过滤"),
diagnosis: Optional[str] = Query(None, description="Filter by diagnosis name"),
start_date: Optional[str] = Query(None, description="开始日期 (YYYY-MM-DD)"),
end_date: Optional[str] = Query(None, description="结束日期 (YYYY-MM-DD)"),
):
"""
获取病例区域分布数据
- 支持按病例类型筛选
- 可设置最小病例数过滤
- 支持日期范围过滤
- 返回各区门诊、住院量及占比
Pandas processing runs in a thread pool to avoid blocking the async event loop.
"""
return await asyncio.to_thread(
_compute_cases_districts, case_type, min_count, diagnosis
_compute_cases_districts, case_type, min_count, diagnosis, start_date, end_date
)
@@ -413,10 +425,19 @@ class DiseaseSeasonalityPoint(BaseModel):
total: int
class DiseaseDistrictItem(BaseModel):
"""单个诊断的区域分布(按病例数排序的前若干区)"""
diagnosis: str
district: str
total: int
class DiseaseSeasonalityResponse(BaseModel):
"""疾病季节性响应"""
seasonality: list[DiseaseSeasonalityPoint]
diagnoses: list[str]
# 每个诊断的真实区域分布(按区聚合),使前端可为每个诊断显示其各自的"主要区域"
diagnosis_districts: list[DiseaseDistrictItem]
@router.get("/seasonal", response_model=SeasonalResponse, summary="获取季节性月度聚合数据")
@@ -562,16 +583,22 @@ async def get_cases_demographics():
# ============== Disease Seasonality Endpoint ==============
@router.get("/disease-seasonality", response_model=DiseaseSeasonalityResponse, summary="获取疾病季节性数据")
async def get_disease_seasonality():
async def get_disease_seasonality(
diagnosis: Optional[str] = Query(None, description="Filter by diagnosis name"),
):
"""
获取各诊断的月度季节性分布数据
- 基于门诊+住院合并数据
- 按月聚合所有年份,返回 top 10 诊断的月度分布
- 支持可选诊断过滤
- 用于疾病季节性热力图、雷达图等
"""
df = get_combined_data()
if diagnosis:
df = df[df['diagnosis'].str.contains(diagnosis, na=False, case=False)]
# Extract month
df = df.copy()
df['month'] = df['date'].dt.month
@@ -608,7 +635,25 @@ async def get_disease_seasonality():
total=out_count + in_count,
))
# Per-diagnosis district distribution (real aggregation by diagnosis × district).
# Previously the frontend showed the same "主要区域" for every diagnosis because
# no per-diagnosis district data was exposed. Top 3 districts per diagnosis.
df_districts = df_top[(df_top['district'] != '未知') & (df_top['district'] != '其他')]
diag_district_counts = df_districts.groupby(['diagnosis', 'district']).size()
diagnosis_districts: list[DiseaseDistrictItem] = []
for diag in top_diagnoses:
if diag not in diag_district_counts.index.get_level_values('diagnosis'):
continue
top_d = diag_district_counts.loc[diag].sort_values(ascending=False).head(3)
for district_name, count in top_d.items():
diagnosis_districts.append(DiseaseDistrictItem(
diagnosis=str(diag),
district=str(district_name),
total=int(count),
))
return DiseaseSeasonalityResponse(
seasonality=seasonality,
diagnoses=[str(d) for d in top_diagnoses],
diagnosis_districts=diagnosis_districts,
)

View File

@@ -0,0 +1,325 @@
"""
环境数据 API 路由
提供空气污染物时间序列和滞后相关性分析接口
"""
import logging
from pathlib import Path
from typing import Optional
import pandas as pd
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from scipy.stats import pearsonr
logger = logging.getLogger("cbpoa.environment")
PROJECT_ROOT = Path(__file__).parent.parent.parent
router = APIRouter(prefix="/api/environment", tags=["environment"])
# 污染物列表CO 无基值列,仅存在于 lag_features.parquet 的滞后列中)
POLLUTANTS = ["AQI", "PM2.5", "PM10", "SO2", "NO2", "O3"]
LAGS = [1, 2, 3, 5, 7, 14]
# station_daily 列名映射PM25 无点号 -> PM2.5 带点号)
_STATION_COL_MAP: dict[str, str] | None = None
# lag_features 基值列映射
_LAG_BASE_MAP: dict[str, str] | None = None
# ============== Response Models ==============
class LagCorrelationItem(BaseModel):
pollutant: str # "AQI", "PM2.5", "PM10", "SO2", "NO2", "O3"
lag_days: int # 1, 2, 3, 5, 7, 14
correlation: float # Pearson r
class LagCorrelationResponse(BaseModel):
correlations: list[LagCorrelationItem]
data_note: str # "CO excluded - no base column in lag_features.parquet"
class PollutantPoint(BaseModel):
date: str
AQI: float
PM25: float
PM10: float
SO2: float
NO2: float
O3: float
CO: float
class PollutantResponse(BaseModel):
data: list[PollutantPoint]
station_count: int
date_range: dict # {start, end}
# ============== Helper Functions ==============
def _get_station_col_map() -> dict[str, str]:
"""返回 station_daily parquet 中实际列名到标准名称的映射。
station_daily 文件中 PM2.5 列名为 "PM25"(无点号),
需要映射到前端期望的 "PM2.5"
"""
global _STATION_COL_MAP
if _STATION_COL_MAP is not None:
return _STATION_COL_MAP
path = PROJECT_ROOT / "processed" / "weather" / "station_daily_2022.parquet"
df = pd.read_parquet(path)
cols = set(df.columns)
col_map = {}
for standard in POLLUTANTS:
if standard in cols:
col_map[standard] = standard
elif "PM25" in cols and standard == "PM2.5":
col_map[standard] = "PM25"
else:
col_map[standard] = standard # fallback
# CO is in station_daily but not in POLLUTANTS
if "CO" in cols:
col_map["CO"] = "CO"
_STATION_COL_MAP = col_map
return _STATION_COL_MAP
def _get_lag_base_map() -> dict[str, str]:
"""返回 lag_features parquet 中基值列名到标准名称的映射。
lag_features 中 PM2.5 列名为 "PM2.5"(带点号),与标准名称一致。
此函数在运行时验证实际列名。
"""
global _LAG_BASE_MAP
if _LAG_BASE_MAP is not None:
return _LAG_BASE_MAP
path = PROJECT_ROOT / "processed" / "weather" / "lag_features.parquet"
df = pd.read_parquet(path)
cols = set(df.columns)
base_map = {}
for p in POLLUTANTS:
if p in cols:
base_map[p] = p
elif p == "PM2.5" and "PM25" in cols:
base_map[p] = "PM25"
else:
base_map[p] = p # will be checked later
_LAG_BASE_MAP = base_map
return _LAG_BASE_MAP
def _load_lag_features() -> pd.DataFrame:
"""加载 lag_features.parquet 并转换日期列。"""
path = PROJECT_ROOT / "processed" / "weather" / "lag_features.parquet"
if not path.exists():
raise FileNotFoundError(f"lag_features.parquet not found at {path}")
df = pd.read_parquet(path)
df["date"] = pd.to_datetime(df["date"])
return df
def _load_cases_daily() -> pd.DataFrame:
"""加载 cases_combined.parquet 并按日期汇总每日总病例数。"""
path = PROJECT_ROOT / "processed" / "cases_combined.parquet"
if not path.exists():
raise FileNotFoundError(f"cases_combined.parquet not found at {path}")
df = pd.read_parquet(path)
df["date"] = pd.to_datetime(df["date"])
daily = df.groupby("date").size().reset_index(name="total_cases")
return daily
def _load_station_daily() -> pd.DataFrame:
"""加载并合并 station_daily_2022.parquet 和 station_daily_2023.parquet。"""
dfs = []
for year in [2022, 2023]:
path = PROJECT_ROOT / "processed" / "weather" / f"station_daily_{year}.parquet"
if not path.exists():
logger.warning("station_daily_%s.parquet not found at %s", year, path)
continue
df = pd.read_parquet(path)
dfs.append(df)
if not dfs:
raise FileNotFoundError("No station_daily parquet files found")
combined = pd.concat(dfs, ignore_index=True)
combined["date"] = pd.to_datetime(combined["date"])
return combined
# ============== Endpoints ==============
@router.get("/lag-correlations", response_model=LagCorrelationResponse)
async def get_lag_correlations():
"""获取污染物滞后相关性分析数据。
计算各污染物在不同滞后天数1, 2, 3, 5, 7, 14 天)下
与每日病例总数之间的 Pearson 相关系数。
Returns:
LagCorrelationResponse: 包含 36 个相关系数6 种污染物 × 6 个滞后天数)
"""
try:
lag_df = _load_lag_features()
cases_daily = _load_cases_daily()
except FileNotFoundError as e:
logger.warning("Data file not found for lag-correlations: %s", e)
return LagCorrelationResponse(
correlations=[],
data_note="CO excluded - no base column in lag_features.parquet",
)
# 计算每日全市均值(按日期聚合,对 23 个站点取平均)
base_map = _get_lag_base_map()
mean_cols = {p: base_map.get(p, p) for p in POLLUTANTS}
daily_mean = lag_df.groupby("date")[list(mean_cols.values())].mean().reset_index()
# 重命名列为标准名称以便一致访问
rename_map = {v: k for k, v in mean_cols.items() if v != k}
if rename_map:
daily_mean = daily_mean.rename(columns=rename_map)
# 对每种污染物计算每日均值
# 对齐污染物时间序列与病例数据
merged = daily_mean.merge(cases_daily, on="date", how="inner")
merged = merged.sort_values("date")
results: list[LagCorrelationItem] = []
for pollutant in POLLUTANTS:
# 构建滞后列映射lag_features 文件名用点号 "PM2.5"
lag_base_name = base_map.get(pollutant, pollutant)
for lag in LAGS:
lag_col = f"{lag_base_name}_lag{lag}"
if lag_col not in lag_df.columns:
logger.debug("Lag column %s not found, skipping", lag_col)
continue
# 从原始 lag_features 提取该污染物的滞后数据(按日期取全市均值)
lag_series = lag_df.groupby("date")[lag_col].mean().reset_index()
lag_series = lag_series.rename(columns={lag_col: f"{pollutant}_lag{lag}"})
# 将滞后污染物数据与病例数据对齐
# 滞后列的值代表的是 t-lag 时刻的污染物,病例是 t 时刻
# 所以将 lag 列的时间向后平移 lag 天,使其与病例时间对齐
lag_series["align_date"] = lag_series["date"] + pd.Timedelta(days=lag)
combined = lag_series.merge(
cases_daily, left_on="align_date", right_on="date", how="inner"
)
if len(combined) < 10:
logger.debug(
"Insufficient data for %s lag%d: %d rows, skipping",
pollutant,
lag,
len(combined),
)
continue
# 计算 Pearson 相关系数
r, _ = pearsonr(
combined[f"{pollutant}_lag{lag}"], combined["total_cases"]
)
results.append(
LagCorrelationItem(
pollutant=pollutant, lag_days=lag, correlation=round(float(r), 4)
)
)
return LagCorrelationResponse(
correlations=results,
data_note="CO excluded - no base column in lag_features.parquet",
)
@router.get("/pollutants", response_model=PollutantResponse)
async def get_pollutants(
days: Optional[int] = Query(default=30, ge=1, le=730, description="返回最近 N 天的数据"),
start_date: Optional[str] = Query(default=None, description="开始日期 YYYY-MM-DD"),
end_date: Optional[str] = Query(default=None, description="结束日期 YYYY-MM-DD"),
):
"""获取每日全市均值污染物时间序列。
合并 2022/2023 两个年度的站点日数据,按日期聚合所有站点取均值。
可通过 ?days=N默认 30或 ?start_date=YYYY-MM-DD&end_date=YYYY-MM-DD 筛选日期范围。
"""
try:
df = _load_station_daily()
except FileNotFoundError as e:
logger.warning("Data file not found for pollutants: %s", e)
return PollutantResponse(data=[], station_count=0, date_range={})
station_count = df["station_id"].nunique()
# 获取列名映射
col_map = _get_station_col_map()
# 选择污染物列
poll_cols = [col_map.get(p, p) for p in POLLUTANTS] + ["CO"]
# 确保需要的列都存在
available_cols = [c for c in poll_cols if c in df.columns]
# 按日期聚合取均值
daily_mean = df.groupby("date")[available_cols].mean().reset_index()
# 重命名为标准名称
rename_map = {}
for std_name, actual_name in col_map.items():
if actual_name != std_name and actual_name in daily_mean.columns:
rename_map[actual_name] = std_name
if rename_map:
daily_mean = daily_mean.rename(columns=rename_map)
# 日期筛选
if start_date and end_date:
start_dt = pd.to_datetime(start_date)
end_dt = pd.to_datetime(end_date)
date_mask = (daily_mean["date"] >= start_dt) & (
daily_mean["date"] <= end_dt
)
daily_mean = daily_mean[date_mask].copy()
else:
daily_mean = daily_mean.sort_values("date").tail(days)
daily_mean = daily_mean.sort_values("date")
# 构建响应
data: list[PollutantPoint] = []
for _, row in daily_mean.iterrows():
pt = PollutantPoint(
date=row["date"].strftime("%Y-%m-%d"),
AQI=round(float(row.get("AQI", 0)), 2),
PM25=round(float(row.get("PM2.5", 0)), 2),
PM10=round(float(row.get("PM10", 0)), 2),
SO2=round(float(row.get("SO2", 0)), 2),
NO2=round(float(row.get("NO2", 0)), 2),
O3=round(float(row.get("O3", 0)), 2),
CO=round(float(row.get("CO", 0)), 2),
)
data.append(pt)
date_range = {}
if daily_mean.shape[0] > 0:
date_range = {
"start": daily_mean["date"].iloc[0].strftime("%Y-%m-%d"),
"end": daily_mean["date"].iloc[-1].strftime("%Y-%m-%d"),
}
return PollutantResponse(
data=data, station_count=station_count, date_range=date_range
)

View File

@@ -17,7 +17,7 @@ PROJECT_ROOT = Path(__file__).parent.parent.parent
DATA_DIR = PROJECT_ROOT / "outputs"
@lru_cache(maxsize=1)
@lru_cache(maxsize=4)
def _load_csv(path: Path) -> pd.DataFrame:
return pd.read_csv(path)
@@ -109,19 +109,21 @@ async def get_grid_cases():
async def get_geocoded_cases(
limit: int = 1000,
district: Optional[str] = None,
date: Optional[str] = Query(None, description="Filter by date (YYYY-MM-DD)"),
):
"""
Get individual geocoded case data.
Args:
limit: Maximum number of cases to return (for performance)
district: Filter by district name
date: Filter by specific date
"""
cases_file = DATA_DIR / "geocoded_all_cases.csv"
if not cases_file.exists():
raise HTTPException(status_code=404, detail="Geocoded data not found")
try:
df = _load_csv(cases_file)
@@ -132,6 +134,11 @@ async def get_geocoded_cases(
swapped = df['latitude'] > 50 # longitude values are >113
df.loc[swapped, ['latitude', 'longitude']] = df.loc[swapped, ['longitude', 'latitude']].values
# Filter by date if specified
if date and 'date' in df.columns:
df['date_str'] = pd.to_datetime(df['date']).dt.strftime('%Y-%m-%d')
df = df[df['date_str'] == date]
# Filter by district if specified
if district:
df = df[df['district'] == district]

View File

@@ -1,4 +1,5 @@
from fastapi import APIRouter, HTTPException, Query
import asyncio
from fastapi import APIRouter, HTTPException, Query, Response
from datetime import datetime, timedelta
from functools import lru_cache
from pathlib import Path
@@ -6,6 +7,7 @@ from typing import Optional
import logging
import sys
import math
import pandas as pd
PROJECT_ROOT = Path(__file__).parent.parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
@@ -19,49 +21,35 @@ from models import (
MultiDayPredictionRequest,
MultiDayPredictionResponse,
)
from data.case_loader import load_cases_by_district_daily
router = APIRouter(prefix="/api", tags=["grid"])
logger = logging.getLogger("cbpoa.grid")
_parquet_cache: dict[str, "pd.DataFrame"] = {}
_parquet_cache: dict[str, pd.DataFrame] = {}
def _load_parquet(path: Path) -> "pd.DataFrame":
import pandas as pd
def _load_parquet(path: Path) -> pd.DataFrame:
key = str(path)
if key not in _parquet_cache:
_parquet_cache[key] = pd.read_parquet(path)
return _parquet_cache[key]
@router.get("/history/aggregated", response_model=HistoricalAggregationResponse)
async def get_historical_aggregated(
start_date: str = Query(..., description="Start date (YYYY-MM-DD)"),
end_date: str = Query(..., description="End date (YYYY-MM-DD)"),
aggregation: str = Query("daily", description="Aggregation level: daily, weekly, monthly"),
district: Optional[str] = Query(None, description="Filter by district name"),
):
"""
Historical data aggregation API.
Returns aggregated case and weather data by district and date.
"""
def _compute_historical_aggregation(
start: datetime,
end: datetime,
aggregation: str,
district: Optional[str],
) -> HistoricalAggregationResponse:
"""Run the full pandas aggregation pipeline (called in thread pool)."""
try:
start = datetime.strptime(start_date, "%Y-%m-%d")
end = datetime.strptime(end_date, "%Y-%m-%d")
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
if (end - start).days > 365:
raise HTTPException(status_code=400, detail="Date range exceeds 365 days")
import pandas as pd
try:
cases_df = _load_parquet(PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet")
cases_df = load_cases_by_district_daily()
except FileNotFoundError:
return HistoricalAggregationResponse(
aggregations=[], total_records=0,
date_range=(start_date, end_date), timestamp=datetime.now().isoformat(),
date_range=(start.strftime("%Y-%m-%d"), end.strftime("%Y-%m-%d")),
timestamp=datetime.now().isoformat(),
)
cases_df['date'] = pd.to_datetime(cases_df['date'])
@@ -76,6 +64,7 @@ async def get_historical_aggregated(
]
if aggregation == "weekly":
filtered_cases = filtered_cases.copy()
filtered_cases['period'] = filtered_cases['date'].dt.to_period('W').astype(str)
grouped = filtered_cases.groupby(['period', 'district']).agg({
'total_cases': 'sum',
@@ -84,6 +73,7 @@ async def get_historical_aggregated(
}).reset_index()
grouped['date'] = grouped['period']
elif aggregation == "monthly":
filtered_cases = filtered_cases.copy()
filtered_cases['period'] = filtered_cases['date'].dt.to_period('M').astype(str)
grouped = filtered_cases.groupby(['period', 'district']).agg({
'total_cases': 'sum',
@@ -98,7 +88,8 @@ async def get_historical_aggregated(
try:
weather_df = _load_parquet(PROJECT_ROOT / "processed" / "weather" / "station_daily_2022.parquet")
except FileNotFoundError:
weather_df = pd.DataFrame(columns=['date', 'AQI', 'PM25', 'PM10'])
weather_df = pd.DataFrame({'date': pd.Series(dtype='str'), 'AQI': pd.Series(dtype='float64'), 'PM25': pd.Series(dtype='float64'), 'PM10': pd.Series(dtype='float64')})
weather_df = weather_df.copy()
weather_df['date'] = pd.to_datetime(weather_df['date']).dt.strftime('%Y-%m-%d')
# Weather data doesn't have district - aggregate by date only
@@ -114,135 +105,173 @@ async def get_historical_aggregated(
aggregations = []
for _, row in merged.iterrows():
aggregations.append(DistrictAggregation(
district=row['district'],
district=str(row['district']),
date=str(row['date']),
total_cases=int(row['total_cases']),
outpatient_count=int(row['outpatient_count']),
inpatient_count=int(row['inpatient_count']),
avg_AQI=float(row['AQI']) if pd.notna(row['AQI']) else 0.0,
avg_PM25=float(row['PM25']) if pd.notna(row['PM25']) else 0.0,
avg_PM10=float(row['PM10']) if pd.notna(row['PM10']) else 0.0,
avg_AQI=float(row['AQI']) if bool(pd.notna(row['AQI'])) else 0.0,
avg_PM25=float(row['PM25']) if bool(pd.notna(row['PM25'])) else 0.0,
avg_PM10=float(row['PM10']) if bool(pd.notna(row['PM10'])) else 0.0,
))
return HistoricalAggregationResponse(
aggregations=aggregations,
total_records=len(aggregations),
date_range=(start_date, end_date),
date_range=(start.strftime("%Y-%m-%d"), end.strftime("%Y-%m-%d")),
timestamp=datetime.now().isoformat(),
)
@router.get("/history/aggregated", response_model=HistoricalAggregationResponse)
async def get_historical_aggregated(
start_date: str = Query(..., description="Start date (YYYY-MM-DD)"),
end_date: str = Query(..., description="End date (YYYY-MM-DD)"),
aggregation: str = Query("daily", description="Aggregation level: daily, weekly, monthly"),
district: Optional[str] = Query(None, description="Filter by district name"),
):
"""
Historical data aggregation API.
Returns aggregated case and weather data by district and date.
Pandas processing runs in a thread pool to avoid blocking the async event loop.
"""
try:
start = datetime.strptime(start_date, "%Y-%m-%d")
end = datetime.strptime(end_date, "%Y-%m-%d")
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
if (end - start).days > 365:
raise HTTPException(status_code=400, detail="Date range exceeds 365 days")
# Offload all pandas I/O and processing to a thread pool
# to prevent blocking the async event loop
return await asyncio.to_thread(
_compute_historical_aggregation, start, end, aggregation, district
)
@lru_cache(maxsize=1)
def _grid_geojson_base():
"""Date-independent base merge: grid centroid + district + real population.
Merged once and cached (the source frames are ~1M rows each, so the join
must not run per request). Raises FileNotFoundError if the core grid files
are missing (caller handles it).
"""
import pandas as pd
grid_df = _load_parquet(PROJECT_ROOT / "processed" / "grid_100m_index.parquet")
district_map = _load_parquet(PROJECT_ROOT / "processed" / "grid_district_mapping.parquet")
base = grid_df.merge(district_map, on='grid_id', how='left')
try:
pop_df = _load_parquet(PROJECT_ROOT / "processed" / "grid_100m_with_dem_pop.parquet")
base = base.merge(pop_df[['grid_id', 'population_density']], on='grid_id', how='left')
except FileNotFoundError:
base['population_density'] = 0.0
base['population_density'] = base['population_density'].fillna(0.0)
return base
def _risk_level_of(v: float) -> str:
if v >= 0.7:
return "high"
if v >= 0.5:
return "medium"
if v >= 0.3:
return "medium_low"
return "low"
@lru_cache(maxsize=32)
def _grids_geojson_body(date: str, district: Optional[str], risk_level: Optional[str]) -> str:
"""Build + serialize the grid GeoJSON once per (date, district, risk_level).
Risk is computed vectorised over the full grid (no per-row Python loop) and
the highest-risk grids are returned as hotspots, so the map shows real
high→low variation. Cached, so warm calls are near-instant. Raises
FileNotFoundError if the core grid files are missing.
"""
merged = _grid_geojson_base()
if district:
merged = merged[merged['district_name'].str.contains(district.replace('', ''), na=False, regex=False)]
cases_df = load_cases_by_district_daily()
cases_df['date'] = pd.to_datetime(cases_df['date']).dt.strftime('%Y-%m-%d')
cases_df = cases_df[cases_df['date'] == date]
# Normalise district case load to 0..1 across districts for this date.
max_district_cases = float(cases_df['total_cases'].max()) if len(cases_df) else 0.0
if max_district_cases <= 0:
max_district_cases = 1.0
merged = merged.merge(cases_df[['district', 'total_cases']], left_on='district_name', right_on='district', how='left')
merged = merged.copy()
merged['total_cases'] = merged['total_cases'].fillna(0).astype(int)
merged['center_lon'] = pd.to_numeric(merged['center_lon'], errors='coerce').fillna(0.0)
merged['center_lat'] = pd.to_numeric(merged['center_lat'], errors='coerce').fillna(0.0)
merged['population_density'] = merged['population_density'].fillna(0.0).clip(lower=0.0)
# Drop grids without coordinates.
merged = merged[(merged['center_lon'] != 0.0) | (merged['center_lat'] != 0.0)]
# Demo risk model (vectorised): a district's relative case load × each grid's
# own population exposure. Sparse cells stay low; densely-populated cells in
# high-case districts rise toward 1.0.
district_load = (merged['total_cases'] / max_district_cases).clip(upper=1.0)
pop_factor = (merged['population_density'] / 50.0).clip(upper=1.0)
merged['risk_value'] = (0.1 + 0.85 * district_load * pop_factor).clip(upper=1.0).round(3)
# Show the highest-risk grids (hotspots), not arbitrary cells.
merged = merged.nlargest(10000, 'risk_value')
features = []
for rec in merged.to_dict('records'):
rv = float(rec['risk_value'])
lvl = _risk_level_of(rv)
if risk_level and lvl != risk_level:
continue
name = rec.get('district_name')
if not isinstance(name, str):
name = "未知"
lon = round(float(rec['center_lon']), 6)
lat = round(float(rec['center_lat']), 6)
features.append({
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [lon, lat]},
"properties": {
"grid_id": str(rec.get('grid_id', '')),
"latitude": lat,
"longitude": lon,
"district": name,
"total_cases": int(rec.get('total_cases', 0)),
"population_density": round(float(rec.get('population_density', 0.0)), 2),
"risk_value": rv,
"risk_level": lvl,
}
})
return GridGeoJSONResponse(
type="FeatureCollection",
features=features,
timestamp=datetime.now().isoformat(),
).model_dump_json()
@router.get("/grids/geojson", response_model=GridGeoJSONResponse)
async def get_grids_geojson(
date: str = Query(..., description="Date (YYYY-MM-DD)"),
district: Optional[str] = Query(None, description="Filter by district"),
risk_level: Optional[str] = Query(None, description="Filter by risk level"),
):
"""
Get grid data as GeoJSON for map visualization.
"""
import pandas as pd
"""Get grid data as GeoJSON for map visualization (cached per query)."""
try:
grid_df = _load_parquet(PROJECT_ROOT / "processed" / "grid_100m_index.parquet")
# Offload the parquet merges + vectorised compute to a thread so the
# cold-cache build doesn't block the event loop.
body = await asyncio.to_thread(_grids_geojson_body, date, district, risk_level)
except FileNotFoundError:
return GridGeoJSONResponse(type="FeatureCollection", features=[], timestamp=datetime.now().isoformat())
try:
district_map = _load_parquet(PROJECT_ROOT / "processed" / "grid_district_mapping.parquet")
except FileNotFoundError:
return GridGeoJSONResponse(type="FeatureCollection", features=[], timestamp=datetime.now().isoformat())
merged = grid_df.merge(district_map, on='grid_id', how='left')
if district:
merged = merged[merged['district_name'].str.contains(district.replace('', ''), na=False, regex=False)]
try:
cases_df = _load_parquet(PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet")
except FileNotFoundError:
return GridGeoJSONResponse(type="FeatureCollection", features=[], timestamp=datetime.now().isoformat())
cases_df['date'] = pd.to_datetime(cases_df['date']).dt.strftime('%Y-%m-%d')
cases_df = cases_df[cases_df['date'] == date]
merged = merged.merge(cases_df, left_on='district_name', right_on='district', how='left')
merged['total_cases'] = merged['total_cases'].fillna(0).astype(int)
def safe_float(val, default=0.0):
try:
v = float(val)
return default if math.isnan(v) or math.isinf(v) else v
except (TypeError, ValueError):
return default
def sanitize(obj):
"""Replace NaN/Inf with None for JSON serialization."""
if isinstance(obj, float):
if math.isnan(obj) or math.isinf(obj):
return None
return obj
if isinstance(obj, dict):
return {k: sanitize(v) for k, v in obj.items()}
if isinstance(obj, list):
return [sanitize(v) for v in obj]
return obj
features = []
for _, row in merged.iterrows():
lon = safe_float(row.get('center_lon'))
lat = safe_float(row.get('center_lat'))
if lon == 0.0 and lat == 0.0:
continue
# MVP: Simple risk calculation based on cases and population density
total_cases = safe_float(row.get('total_cases', 0), 0)
total_cases = int(total_cases)
pop_density = safe_float(row.get('population_density', 0))
# Risk formula: cases per 10k population + baseline
risk_value = min(1.0, (total_cases / max(pop_density, 1)) * 10 + 0.1)
if risk_value >= 0.7:
risk_level = "high"
elif risk_value >= 0.5:
risk_level = "medium"
elif risk_value >= 0.3:
risk_level = "medium_low"
else:
risk_level = "low"
district = row.get('district_name')
if isinstance(district, float) and (math.isnan(district) or math.isinf(district)):
district = "未知"
feature = {
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [lon, lat]
},
"properties": {
"grid_id": str(row.get('grid_id', '')),
"latitude": lat,
"longitude": lon,
"district": district,
"total_cases": total_cases,
"population_density": pop_density,
"risk_value": round(risk_value, 3),
"risk_level": risk_level,
}
}
features.append(feature)
if len(features) >= 10000:
break
return GridGeoJSONResponse(
type="FeatureCollection",
features=features,
timestamp=datetime.now().isoformat(),
)
return Response(content=body, media_type="application/json")
@router.post("/predict/multi-day", response_model=MultiDayPredictionResponse)
@@ -278,9 +307,9 @@ async def predict_multi_day(request: MultiDayPredictionRequest):
]
for _, row in features_df.iterrows():
risk_1d = float(row.get('risk_1day', 0.5))
risk_3d = float(row.get('risk_3day', 0.5))
risk_7d = float(row.get('risk_7day', 0.5))
risk_1d = float(row.get('risk_1day', 0.5)) # type: ignore[arg-type]
risk_3d = float(row.get('risk_3day', 0.5)) # type: ignore[arg-type]
risk_7d = float(row.get('risk_7day', 0.5)) # type: ignore[arg-type]
if risk_1d >= 0.8:
risk_level = "high"
@@ -323,14 +352,8 @@ async def predict_multi_day(request: MultiDayPredictionRequest):
)
@router.get("/grids/{grid_id}/history")
async def get_grid_history(
grid_id: str,
days: int = Query(30, ge=1, le=365, description="Number of days of history"),
):
"""
Get historical data for a specific grid cell.
"""
def _compute_grid_history(grid_id: str, days: int) -> dict:
"""Heavy synchronous parquet reads + per-row loop (called in thread pool)."""
import pandas as pd
district_map = _load_parquet(PROJECT_ROOT / "processed" / "grid_district_mapping.parquet")
@@ -341,7 +364,7 @@ async def get_grid_history(
district = grid_info.iloc[0]['district_name']
cases_df = _load_parquet(PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet")
cases_df = load_cases_by_district_daily()
cases_df['date'] = pd.to_datetime(cases_df['date'])
end_date = datetime.now()
@@ -367,4 +390,18 @@ async def get_grid_history(
"district": district,
"history": history,
"timestamp": datetime.now().isoformat(),
}
}
@router.get("/grids/{grid_id}/history")
async def get_grid_history(
grid_id: str,
days: int = Query(30, ge=1, le=365, description="Number of days of history"),
):
"""
Get historical data for a specific grid cell.
Parquet reads + aggregation run in a thread pool to avoid blocking the
async event loop.
"""
return await asyncio.to_thread(_compute_grid_history, grid_id, days)

View File

@@ -4,12 +4,14 @@ 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
from typing import Dict, List, Literal
from config import DATA_DIR, RISK_HIGH, PROJECT_ROOT
from data.case_loader import load_cases_by_district_daily
from models import (
InsightsResponse,
InsightTrend,
@@ -20,6 +22,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 +50,7 @@ class InsightCardResponse(BaseModel):
warning_count: int
info_count: int
success_count: int
danger_count: int
cards: list[InsightCardItem]
@@ -482,22 +492,22 @@ 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)
# Districts already normalized to the canonical 13 区-suffixed names.
cases_df = load_cases_by_district_daily()
cases_df["date"] = pd.to_datetime(cases_df["date"])
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("", "")
district_daily = latest_cases.groupby("base_district")["total_cases"].sum().sort_values(ascending=False)
latest_cases = cases_df[cases_df["date"] == latest_case_date]
district_daily = latest_cases.groupby("district")["total_cases"].sum().sort_values(ascending=False)
total_daily = int(district_daily.sum())
top_name = district_daily.index[0]
top_val = int(district_daily.iloc[0])
num_districts = len(district_daily)
week_ago = latest_case_date - pd.Timedelta(days=6)
week_cases = cases_df[cases_df["date"] >= week_ago].copy()
week_cases["base_district"] = week_cases["district"].str.replace("", "")
week_cases = cases_df[cases_df["date"] >= week_ago]
daily_totals = week_cases.groupby("date")["total_cases"].sum()
avg_daily = int(daily_totals.mean())
week_district = week_cases.groupby("base_district")["total_cases"].sum().sort_values(ascending=False)
week_district = week_cases.groupby("district")["total_cases"].sum().sort_values(ascending=False)
week_top_val = int(week_district.iloc[0])
date_str = latest_case_date.strftime("%m月%d")
@@ -506,8 +516,8 @@ async def get_insights_cards():
title=f"日病例统计 ({date_str})",
description=(
f"最近统计日({date_str})全市{num_districts}个区共记录{total_daily}例儿童呼吸道疾病病例,"
f"{top_name}{top_val}例为当日最高。近7日日均{avg_daily}例,"
f"{week_district.index[0]}累计{week_top_val}例居首。"
f"{top_name}{top_val}例为当日最高。近7日日均{avg_daily}例,"
f"{week_district.index[0]}累计{week_top_val}例居首。"
),
type="warning",
metric="日病例",
@@ -533,7 +543,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 +584,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 +674,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,
)

View File

@@ -2,10 +2,10 @@
Router for CBPOA risk assessment endpoints
Reads from GeoJSON files in outputs/daily/ directory
"""
from fastapi import APIRouter, HTTPException, Path, Query
from fastapi import APIRouter, HTTPException, Path, Query, Response
from datetime import datetime, timedelta
from pathlib import Path
from typing import Annotated, List, Literal
import asyncio
import json
import glob
import re
@@ -28,8 +28,12 @@ from utils.risk import risk_value_to_level
router = APIRouter(prefix="/api/risk", tags=["risk"])
# Cells with risk at/below this are culled from LOD responses; the frontend
# discards risk==0 cells anyway, so emitting them only bloats the payload.
LOD_RISK_EPSILON = 1e-6
@lru_cache(maxsize=3)
@lru_cache(maxsize=8)
def get_risk_data(date: str) -> tuple[list[list], dict]:
filepath = DATA_DIR / f"risk_{date}.geojson"
if not filepath.exists():
@@ -55,7 +59,7 @@ def get_risk_data(date: str) -> tuple[list[list], dict]:
return grids, grid_map
@lru_cache(maxsize=3)
@lru_cache(maxsize=8)
def get_kdtree_and_risks(date: str):
grids, _ = get_risk_data(date)
if not grids:
@@ -70,6 +74,8 @@ def generate_lod_grid(zoom: int, forecast_day: Literal[1, 3, 7] = 1,
bounds: dict | None = None) -> dict:
date = get_latest_date()
kdtree, risk_values = get_kdtree_and_risks(date)
if kdtree is None or risk_values is None:
return {"lod": "empty", "zoom": zoom, "aggregate": 1, "grids": [], "total_count": 0, "bounds": bounds or WUHAN_BOUNDS}
risk_idx = forecast_day - 1
@@ -117,7 +123,12 @@ def generate_lod_grid(zoom: int, forecast_day: Literal[1, 3, 7] = 1,
risks = risk_array[indices]
risks[dists > LOD_MAX_RADIUS] = 0.0
lod_grids = np.column_stack([lat_grid.ravel(), lon_grid.ravel(), risks]).tolist()
# Drop zero-risk cells (incl. out-of-radius). The frontend discards
# them anyway, so culling here shrinks the payload substantially.
flat_lat = lat_grid.ravel()
flat_lon = lon_grid.ravel()
keep = risks > LOD_RISK_EPSILON
lod_grids = np.column_stack([flat_lat[keep], flat_lon[keep], risks[keep]]).tolist()
return {
"lod": lod_name,
@@ -143,8 +154,9 @@ def generate_lod_grid(zoom: int, forecast_day: Literal[1, 3, 7] = 1,
cell_lat = (WUHAN_BOUNDS["max_lat"] - WUHAN_BOUNDS["min_lat"]) / lat_count
cell_lon = (WUHAN_BOUNDS["max_lon"] - WUHAN_BOUNDS["min_lon"]) / lon_count
# Apply viewport bounds filtering for zoom >= 10
if bounds and zoom >= 10:
# Apply viewport bounds filtering at all zoom levels when bounds are given,
# so even the coarse zoom<=9 lod1 grid is clipped to the viewport.
if bounds:
b_min_lat = max(bounds["min_lat"], WUHAN_BOUNDS["min_lat"])
b_max_lat = min(bounds["max_lat"], WUHAN_BOUNDS["max_lat"])
b_min_lon = max(bounds["min_lon"], WUHAN_BOUNDS["min_lon"])
@@ -176,7 +188,11 @@ def generate_lod_grid(zoom: int, forecast_day: Literal[1, 3, 7] = 1,
risks[dists > LOD_MAX_RADIUS] = 0.0
lod_grids = np.column_stack([lat_grid.ravel(), lon_grid.ravel(), risks]).tolist()
# Drop zero-risk cells (incl. out-of-radius) before serialization.
flat_lat = lat_grid.ravel()
flat_lon = lon_grid.ravel()
keep = risks > LOD_RISK_EPSILON
lod_grids = np.column_stack([flat_lat[keep], flat_lon[keep], risks[keep]]).tolist()
return {
"lod": lod_name,
@@ -188,6 +204,21 @@ def generate_lod_grid(zoom: int, forecast_day: Literal[1, 3, 7] = 1,
}
@lru_cache(maxsize=8)
def _risk_map_body(date: str) -> str:
"""Serialize the full ~140k-grid risk map for a date once (cached).
The 140k-element response costs ~0.5s of Pydantic validation + JSON
serialization; caching the serialized body makes warm calls ~instant.
"""
grids = parse_geojson_file(DATA_DIR / f"risk_{date}.geojson")
return RiskMapResponse(
grids=grids,
total_count=len(grids),
timestamp=datetime.now().isoformat(),
).model_dump_json()
@router.get("/map", response_model=RiskMapResponse)
async def get_risk_map(date: str | None = None):
if date is None:
@@ -197,13 +228,7 @@ async def get_risk_map(date: str | None = None):
if not filepath.exists():
raise HTTPException(status_code=404, detail=f"No data found for date {date}")
grids = parse_geojson_file(filepath)
return RiskMapResponse(
grids=grids,
total_count=len(grids),
timestamp=datetime.now().isoformat()
)
return Response(content=_risk_map_body(date), media_type="application/json")
@router.get("/current", response_model=RiskMapResponse)
@@ -214,28 +239,7 @@ async def get_current_risk():
if not filepath.exists():
raise HTTPException(status_code=404, detail=f"No data found for date {date}")
with open(filepath, 'r', encoding='utf-8') as f:
geojson = json.load(f)
grids: list[dict[str, str | float]] = []
for feature in geojson.get("features", []):
props = feature.get("properties", {})
coords = feature.get("geometry", {}).get("coordinates", [0, 0])
risk_value = props.get("risk_1d", 0)
grids.append({
"grid_id": str(props.get("node_id", "")),
"latitude": props.get("lat", coords[1] if len(coords) > 1 else 0),
"longitude": props.get("lon", coords[0] if len(coords) > 0 else 0),
"risk_value": risk_value,
"risk_level": risk_value_to_level(risk_value),
})
return RiskMapResponse(
grids=grids,
total_count=len(grids),
timestamp=datetime.now().isoformat()
)
return Response(content=_risk_map_body(date), media_type="application/json")
@router.get("/precomputed", response_model=RiskMapResponse)
@@ -247,7 +251,7 @@ async def get_precomputed_risk():
grids = []
for _, row in df.iterrows():
risk_index = float(row.get('risk_index', 0))
risk_index = float(row.get('risk_index', 0)) # type: ignore[arg-type]
grids.append({
"grid_id": str(row['grid_id']),
"latitude": float(row['center_y']),
@@ -263,6 +267,18 @@ async def get_precomputed_risk():
)
@lru_cache(maxsize=8)
def _fullgrid_body(date: str) -> str:
"""Serialize the compact full-grid payload once (cached)."""
grids, _ = get_risk_data(date)
return json.dumps({
"date": date,
"total_count": len(grids),
"columns": ["lat", "lon", "risk_1d", "risk_3d", "risk_7d"],
"grids": grids,
})
@router.get("/fullgrid")
async def get_full_grid(date: str | None = None):
if date is None:
@@ -272,26 +288,7 @@ async def get_full_grid(date: str | None = None):
if not filepath.exists():
raise HTTPException(status_code=404, detail=f"No data found for date {date}")
with open(filepath, 'r', encoding='utf-8') as f:
geojson = json.load(f)
grids = []
for feature in geojson.get("features", []):
props = feature.get("properties", {})
grids.append([
round(props.get("lat", 0), 6),
round(props.get("lon", 0), 6),
round(props.get("risk_1d", 0), 4),
round(props.get("risk_3d", 0), 4),
round(props.get("risk_7d", 0), 4),
])
return {
"date": date,
"total_count": len(grids),
"columns": ["lat", "lon", "risk_1d", "risk_3d", "risk_7d"],
"grids": grids,
}
return Response(content=_fullgrid_body(date), media_type="application/json")
@router.get("/lod-grid")
@@ -366,50 +363,44 @@ async def get_lod_tile(
@router.get("/history/{grid_id}", response_model=RiskHistoryResponse)
async def get_risk_history(grid_id: str, days: int = 7):
async def get_risk_history(grid_id: str, days: Annotated[int, Query(ge=1, le=30)] = 7):
date = get_latest_date()
filepath = DATA_DIR / f"risk_{date}.geojson"
if not filepath.exists():
raise HTTPException(status_code=404, detail=f"No data found for date {date}")
with open(filepath, 'r', encoding='utf-8') as f:
geojson = json.load(f)
# Use cached parsed grids + cached KDTree instead of re-reading the ~44MB file.
grids = parse_geojson_file(filepath)
if not grids:
raise HTTPException(status_code=404, detail=f"No data found for date {date}")
target_feature = None
for feature in geojson.get("features", []):
props = feature.get("properties", {})
if str(props.get("node_id", "")) == grid_id:
target_feature = feature
base_risk = None
# Exact node_id match
for g in grids:
if g["grid_id"] == grid_id:
base_risk = g["risk_value"]
break
if not target_feature and re.match(r'r\d+_c\d+', grid_id):
# Fallback: nearest grid for r{row}_c{col} ids
if base_risk is None and re.match(r'r\d+_c\d+', grid_id):
parts = grid_id.replace("r", "").split("_c")
row, col = int(parts[0]), int(parts[1])
center_lat = WUHAN_BOUNDS["min_lat"] + (row + 0.5) * LAT_STEP
center_lon = WUHAN_BOUNDS["min_lon"] + (col + 0.5) * LON_STEP
points = []
features_list = []
for feature in geojson.get("features", []):
props = feature.get("properties", {})
points.append([props.get("lat", 0), props.get("lon", 0)])
features_list.append(feature)
if points:
tree = KDTree(points)
_, idx = tree.query([center_lat, center_lon])
target_feature = features_list[idx]
kdtree, risk_values = get_kdtree_and_risks(date)
if kdtree is not None and risk_values is not None:
_, idx = kdtree.query([center_lat, center_lon])
base_risk = risk_values[idx][0] # risk_1d
if not target_feature:
if base_risk is None:
raise HTTPException(status_code=404, detail=f"Grid {grid_id} not found")
props = target_feature.get("properties", {})
base_risk = props.get("risk_1d", 0)
history = []
for i in range(days):
history.append({
"date": (datetime.now() - timedelta(days=i)).strftime("%Y-%m-%d"),
"risk_value": base_risk * (1 - i * 0.05)
"risk_value": max(0.0, base_risk * (1 - i * 0.05))
})
return RiskHistoryResponse(
@@ -432,7 +423,7 @@ async def get_forecast_map(
if not filepath.exists():
# Fall back to current data
return await get_current_risk_map()
return await get_current_risk()
grids = parse_geojson_file(filepath)
if not grids:
@@ -495,3 +486,71 @@ async def get_stats(date: str | None = None):
high_risk_count=distribution["high"],
timestamp=datetime.now().isoformat()
)
# ---------------------------------------------------------------------------
# Raster LOD tiles — full-Wuhan 100m risk grid served as XYZ map tiles.
# The browser loads PNG images (cached by Leaflet); no per-cell JS work.
# See utils/risk_raster.py for the rendering engine.
# ---------------------------------------------------------------------------
from utils import risk_raster # noqa: E402 (kept local to this feature block)
_VALID_DAYS = {1, 3, 7}
@router.get("/tiles/{z}/{x}/{y}.png")
async def get_risk_tile(
z: Annotated[int, Path(ge=0, le=22)],
x: Annotated[int, Path(ge=0)],
y: Annotated[int, Path(ge=0)],
date: str | None = None,
day: Annotated[int, Query()] = 1,
):
"""Render one web-mercator risk tile (256x256 PNG) for the 100m grid."""
if day not in _VALID_DAYS:
raise HTTPException(status_code=400, detail="day must be 1, 3, or 7")
if date is None:
date = get_latest_date()
if not (DATA_DIR / f"risk_{date}.geojson").exists():
raise HTTPException(status_code=404, detail=f"No data found for date {date}")
png = await asyncio.to_thread(risk_raster.render_tile, z, x, y, date, day)
return Response(
content=png,
media_type="image/png",
headers={"Cache-Control": "public, max-age=3600"},
)
@router.get("/grid-stats")
async def get_risk_grid_stats(
date: str | None = None,
day: Annotated[int, Query()] = 1,
):
"""Aggregate stats over the in-boundary 100m grid (cell count / avg / max / high)."""
if day not in _VALID_DAYS:
raise HTTPException(status_code=400, detail="day must be 1, 3, or 7")
if date is None:
date = get_latest_date()
if not (DATA_DIR / f"risk_{date}.geojson").exists():
raise HTTPException(status_code=404, detail=f"No data found for date {date}")
return await asyncio.to_thread(risk_raster.grid_stats, date, day)
@router.get("/cell")
async def get_risk_cell(
lat: Annotated[float, Query(ge=-90, le=90)],
lon: Annotated[float, Query(ge=-180, le=180)],
date: str | None = None,
day: Annotated[int, Query()] = 1,
):
"""Risk at the 100m cell containing (lat, lon) — used for click-to-inspect."""
if day not in _VALID_DAYS:
raise HTTPException(status_code=400, detail="day must be 1, 3, or 7")
if date is None:
date = get_latest_date()
if not (DATA_DIR / f"risk_{date}.geojson").exists():
raise HTTPException(status_code=404, detail=f"No data found for date {date}")
return await asyncio.to_thread(risk_raster.query_cell, lat, lon, date, day)

View File

@@ -0,0 +1,566 @@
"""
统计分析 API 路由 (prefix /api/stats)
为前端统计仪表盘提供聚合后的临床、症状、发病率、环境相关性和时序数据。
所有数据从 processed/*.parquet 计算得出(文件型后端,无数据库)。
设计原则:
- 模块级缓存载入的 parquet与其他路由一致
- 仅返回聚合结果,绝不直接 dump 原始行,保持 payload 小。
- 每个端点用 try/except 包裹,失败时返回合法的空结构(绝不让 UI 收到 500
- pandas 计算放入线程池 (asyncio.to_thread),避免阻塞事件循环。
"""
import asyncio
import glob
import logging
import threading
from pathlib import Path
from typing import Optional, cast
import numpy as np
import pandas as pd
from fastapi import APIRouter, Query
from pydantic import BaseModel
from data.case_loader import (
get_inpatient_data,
get_outpatient_data,
get_combined_data,
load_cases_by_district_daily,
normalize_district,
)
logger = logging.getLogger("cbpoa.statistics")
router = APIRouter(prefix="/api/stats", tags=["statistics"])
PROJECT_ROOT = Path(__file__).parent.parent.parent
PROCESSED_DIR = PROJECT_ROOT / "processed"
# ============== Module-level caches ==============
_cache: dict[str, object] = {}
_cache_lock = threading.RLock()
# 7 个污染物(与 feature snapshot 列名一致)
POLLUTANTS = ["AQI", "PM25", "PM10", "SO2", "NO2", "O3", "CO"]
# 主诉症状关键词(固定列表,子串匹配)。注意顺序:更具体的在前避免被宽泛词吞掉,
# 但因为是独立子串计数,顺序不影响结果,仅为可读性分组。
SYMPTOM_KEYWORDS = [
"发热", "咳嗽", "", "喘息", "", "流涕", "鼻塞", "咽痛", "咽喉",
"", "气促", "呼吸困难", "肺炎", "复诊", "随诊", "复查",
"腹泻", "呕吐", "头痛", "乏力", "胸闷", "鼻涕", "发烧", "感冒",
]
REVISIT_KEYWORDS = ["复诊", "随诊", "复查"]
def _district_population() -> pd.Series:
"""各区人口population_density 求和,按 grid_district_mapping 归属)。
返回 index 为规范化区名(13)、值为人口的 Series。结果缓存。
"""
cached = _cache.get("district_population")
if cached is not None:
return cast(pd.Series, cached)
with _cache_lock:
cached = _cache.get("district_population")
if cached is not None:
return cast(pd.Series, cached)
mapping = pd.read_parquet(PROCESSED_DIR / "grid_district_mapping.parquet")
grid = pd.read_parquet(
PROCESSED_DIR / "grid_100m_with_dem_pop.parquet",
columns=["grid_id", "population_density"],
)
joined = mapping.merge(grid, on="grid_id", how="inner")
joined = joined.dropna(subset=["district_name"])
joined["district_name"] = joined["district_name"].map(normalize_district)
pop = joined.groupby("district_name")["population_density"].sum()
_cache["district_population"] = pop
return pop
def _feature_snapshots() -> pd.DataFrame:
"""合并所有可用的 features_*.parquet 快照(缓存)。
用于污染物 vs 病例的相关性分析。每个快照按格点给出污染物 + 病例计数 + 区。
"""
cached = _cache.get("features")
if cached is not None:
return cast(pd.DataFrame, cached)
with _cache_lock:
cached = _cache.get("features")
if cached is not None:
return cast(pd.DataFrame, cached)
paths = sorted(glob.glob(str(PROCESSED_DIR / "features_*.parquet")))
if not paths:
df = pd.DataFrame(
columns=POLLUTANTS + ["outpatient_count", "inpatient_count", "total_cases", "district"]
)
else:
frames = [pd.read_parquet(p) for p in paths]
df = pd.concat(frames, ignore_index=True)
_cache["features"] = df
return df
# ============== Response Models ==============
class KeyValueCount(BaseModel):
bin_label: str
count: int
class InpatientKpis(BaseModel):
total_admissions: int
median_los_days: float
cure_rate: float
emergency_admit_ratio: float
class LosByDisease(BaseModel):
diagnosis: str
p25: float
median: float
p75: float
n: int
class LabelCount(BaseModel):
outcome: Optional[str] = None
route: Optional[str] = None
count: int
class OutcomeCount(BaseModel):
outcome: str
count: int
class RouteCount(BaseModel):
route: str
count: int
class BmiByAge(BaseModel):
age_band: str
p25: float
median: float
p75: float
n: int
class InpatientClinicalResponse(BaseModel):
kpis: InpatientKpis
los_histogram: list[KeyValueCount]
los_by_disease: list[LosByDisease]
outcome_counts: list[OutcomeCount]
admission_route_counts: list[RouteCount]
bmi_by_age_band: list[BmiByAge]
class SymptomItem(BaseModel):
keyword: str
count: int
class SymptomsResponse(BaseModel):
symptoms: list[SymptomItem]
revisit_ratio: float
class IncidenceItem(BaseModel):
district: str
total_cases: int
population: float
rate_per_10k: float
class IncidenceResponse(BaseModel):
districts: list[IncidenceItem]
class CorrItem(BaseModel):
pollutant: str
corr_with_cases: float
class ScatterPoint(BaseModel):
pm25: float
aqi: float
cases: float
class PairwiseCorr(BaseModel):
a: str
b: str
corr: float
class EnvCorrelationResponse(BaseModel):
correlation_matrix: list[CorrItem]
scatter: list[ScatterPoint]
pollutant_pairwise: list[PairwiseCorr]
class WeekdayPoint(BaseModel):
weekday: str
outpatient: int
inpatient: int
total: int
class MonthYearPoint(BaseModel):
year: int
month: int
total: int
class YoYPoint(BaseModel):
period: str
current: int
previous: int
growth_pct: float
class TemporalResponse(BaseModel):
weekday: list[WeekdayPoint]
month_year: list[MonthYearPoint]
yoy: list[YoYPoint]
# ============== Helpers ==============
def _empty_inpatient_clinical() -> InpatientClinicalResponse:
return InpatientClinicalResponse(
kpis=InpatientKpis(
total_admissions=0, median_los_days=0.0,
cure_rate=0.0, emergency_admit_ratio=0.0,
),
los_histogram=[], los_by_disease=[], outcome_counts=[],
admission_route_counts=[], bmi_by_age_band=[],
)
def _safe_float(v) -> float:
try:
f = float(v)
if np.isnan(f) or np.isinf(f):
return 0.0
return round(f, 4)
except (TypeError, ValueError):
return 0.0
# ============== Endpoint 1: inpatient clinical ==============
def _compute_inpatient_clinical() -> InpatientClinicalResponse:
df = get_inpatient_data().copy()
if df.empty:
return _empty_inpatient_clinical()
# LOS = (出院日期 - 入院日期).days, valid 0-60
in_date = pd.to_datetime(df["入院日期"], errors="coerce")
out_date = pd.to_datetime(df["出院日期"], errors="coerce")
df["los"] = (out_date - in_date).dt.days
df_los = df[(df["los"] >= 0) & (df["los"] <= 60)]
total = len(df)
median_los = float(df_los["los"].median()) if len(df_los) else 0.0
outcome = df["出院情况"].fillna("未知")
cure_n = int(outcome.isin(["治愈", "好转"]).sum())
cure_rate = cure_n / total if total else 0.0
route = df["入院途径"].fillna("未知")
emerg_n = int((route == "急诊").sum())
emerg_ratio = emerg_n / total if total else 0.0
kpis = InpatientKpis(
total_admissions=total,
median_los_days=round(median_los, 2),
cure_rate=round(cure_rate, 4),
emergency_admit_ratio=round(emerg_ratio, 4),
)
# LOS histogram bins: 0,1,2,3,4,5,6,7,8-14,15+
los_histogram: list[KeyValueCount] = []
los_vals = df_los["los"]
for b in range(0, 8):
los_histogram.append(KeyValueCount(bin_label=str(b), count=int((los_vals == b).sum())))
los_histogram.append(KeyValueCount(bin_label="8-14", count=int(((los_vals >= 8) & (los_vals <= 14)).sum())))
los_histogram.append(KeyValueCount(bin_label="15+", count=int((los_vals >= 15).sum())))
# LOS by disease (top 8 diagnoses by n)
los_by_disease: list[LosByDisease] = []
if len(df_los):
top_diag = df_los["诊断名称"].value_counts().head(8).index.tolist()
for d in top_diag:
grp = df_los[df_los["诊断名称"] == d]["los"]
los_by_disease.append(LosByDisease(
diagnosis=str(d),
p25=round(float(grp.quantile(0.25)), 2),
median=round(float(grp.median()), 2),
p75=round(float(grp.quantile(0.75)), 2),
n=int(len(grp)),
))
# outcome counts
outcome_counts = [
OutcomeCount(outcome=str(k), count=int(v))
for k, v in outcome.value_counts().items()
]
# admission route counts
admission_route_counts = [
RouteCount(route=str(k), count=int(v))
for k, v in route.value_counts().items()
]
# BMI by age band. BMI = 体重kg / (身高m)^2; plausible 8-40.
bmi_by_age_band: list[BmiByAge] = []
h = pd.to_numeric(df["身高"], errors="coerce") # cm
w = pd.to_numeric(df["体重"], errors="coerce") # kg
age = pd.to_numeric(df["年龄"], errors="coerce")
bmi = w / ((h / 100.0) ** 2)
bmi_df = pd.DataFrame({"age": age, "bmi": bmi})
bmi_df = bmi_df[(bmi_df["bmi"] >= 8) & (bmi_df["bmi"] <= 40) & bmi_df["age"].notna()]
age_bands = [(0, 3, "0-2"), (3, 6, "3-5"), (6, 9, "6-8"),
(9, 12, "9-11"), (12, 15, "12-14"), (15, 19, "15-18")]
for lo, hi, label in age_bands:
grp = bmi_df[(bmi_df["age"] >= lo) & (bmi_df["age"] < hi)]["bmi"]
if len(grp) == 0:
continue
bmi_by_age_band.append(BmiByAge(
age_band=label,
p25=round(float(grp.quantile(0.25)), 2),
median=round(float(grp.median()), 2),
p75=round(float(grp.quantile(0.75)), 2),
n=int(len(grp)),
))
return InpatientClinicalResponse(
kpis=kpis,
los_histogram=los_histogram,
los_by_disease=los_by_disease,
outcome_counts=outcome_counts,
admission_route_counts=admission_route_counts,
bmi_by_age_band=bmi_by_age_band,
)
@router.get("/inpatient-clinical", response_model=InpatientClinicalResponse, summary="住院临床统计")
async def inpatient_clinical():
"""住院临床概览KPI、住院天数(LOS)分布、费用分布、转归、入院途径、BMI 分布。"""
try:
return await asyncio.to_thread(_compute_inpatient_clinical)
except Exception:
logger.exception("inpatient-clinical failed")
return _empty_inpatient_clinical()
# ============== Endpoint 2: symptoms ==============
def _compute_symptoms(top: int) -> SymptomsResponse:
df = get_outpatient_data()
if df.empty or "主诉" not in df.columns:
return SymptomsResponse(symptoms=[], revisit_ratio=0.0)
chief = df["主诉"].dropna().astype(str)
total = len(chief)
if total == 0:
return SymptomsResponse(symptoms=[], revisit_ratio=0.0)
counts: list[SymptomItem] = []
for kw in SYMPTOM_KEYWORDS:
c = int(chief.str.contains(kw, regex=False).sum())
if c > 0:
counts.append(SymptomItem(keyword=kw, count=c))
counts.sort(key=lambda x: x.count, reverse=True)
counts = counts[:top]
revisit_mask = chief.str.contains("|".join(REVISIT_KEYWORDS), regex=True)
revisit_ratio = float(revisit_mask.sum()) / total if total else 0.0
return SymptomsResponse(symptoms=counts, revisit_ratio=round(revisit_ratio, 4))
@router.get("/symptoms", response_model=SymptomsResponse, summary="门诊主诉症状词频")
async def symptoms(top: int = Query(20, ge=1, le=50, description="返回前 N 个症状词")):
"""从门诊主诉中提取固定症状关键词的出现频次,并计算复诊比例。"""
try:
return await asyncio.to_thread(_compute_symptoms, top)
except Exception:
logger.exception("symptoms failed")
return SymptomsResponse(symptoms=[], revisit_ratio=0.0)
# ============== Endpoint 3: incidence rate ==============
def _compute_incidence() -> IncidenceResponse:
daily = load_cases_by_district_daily()
if daily.empty:
return IncidenceResponse(districts=[])
case_totals = daily.groupby("district")["total_cases"].sum()
pop = _district_population()
items: list[IncidenceItem] = []
for district in case_totals.index:
total_cases = int(case_totals.get(district, 0))
population = float(pop.get(district, 0.0))
rate = (total_cases / population * 10000) if population > 0 else 0.0
items.append(IncidenceItem(
district=str(district),
total_cases=total_cases,
population=round(population, 1),
rate_per_10k=round(rate, 2),
))
items.sort(key=lambda x: x.rate_per_10k, reverse=True)
return IncidenceResponse(districts=items)
@router.get("/incidence-rate", response_model=IncidenceResponse, summary="各区发病率")
async def incidence_rate():
"""各区病例总数 / 区人口 * 10000得到每万人发病率13 区)。"""
try:
return await asyncio.to_thread(_compute_incidence)
except Exception:
logger.exception("incidence-rate failed")
return IncidenceResponse(districts=[])
# ============== Endpoint 4: env correlation ==============
def _compute_env_correlation() -> EnvCorrelationResponse:
df = _feature_snapshots()
if df.empty or "total_cases" not in df.columns:
return EnvCorrelationResponse(correlation_matrix=[], scatter=[], pollutant_pairwise=[])
# 污染物 vs 病例 的 Pearson 相关(按格点,汇集所有快照)
correlation_matrix: list[CorrItem] = []
cases = pd.to_numeric(df["total_cases"], errors="coerce")
for p in POLLUTANTS:
if p not in df.columns:
continue
series = pd.to_numeric(df[p], errors="coerce")
valid = series.notna() & cases.notna()
if valid.sum() < 2 or series[valid].std() == 0 or cases[valid].std() == 0:
corr = 0.0
else:
corr = float(series[valid].corr(cases[valid]))
correlation_matrix.append(CorrItem(pollutant=p, corr_with_cases=_safe_float(corr)))
# scatter: 采样 cases>0 的格点up to 500
scatter: list[ScatterPoint] = []
has_cols = all(c in df.columns for c in ["PM25", "AQI", "total_cases"])
if has_cols:
sdf = df[["PM25", "AQI", "total_cases"]].copy()
sdf = sdf[pd.to_numeric(sdf["total_cases"], errors="coerce") > 0].dropna()
if len(sdf) > 500:
sdf = sdf.sample(n=500, random_state=42)
for _, r in sdf.iterrows():
scatter.append(ScatterPoint(
pm25=_safe_float(r["PM25"]),
aqi=_safe_float(r["AQI"]),
cases=_safe_float(r["total_cases"]),
))
# pollutant pairwise (upper triangle)
pollutant_pairwise: list[PairwiseCorr] = []
present = [p for p in POLLUTANTS if p in df.columns]
pol_df = df[present].apply(pd.to_numeric, errors="coerce")
corr_mat = pol_df.corr()
for i, a in enumerate(present):
for b in present[i + 1:]:
try:
v = corr_mat.loc[a, b]
except KeyError:
v = 0.0
pollutant_pairwise.append(PairwiseCorr(a=a, b=b, corr=_safe_float(v)))
return EnvCorrelationResponse(
correlation_matrix=correlation_matrix,
scatter=scatter,
pollutant_pairwise=pollutant_pairwise,
)
@router.get("/env-correlation", response_model=EnvCorrelationResponse, summary="环境-病例相关性")
async def env_correlation():
"""污染物与病例的相关矩阵、PM2.5/AQI 散点、污染物两两相关(热力图)。"""
try:
return await asyncio.to_thread(_compute_env_correlation)
except Exception:
logger.exception("env-correlation failed")
return EnvCorrelationResponse(correlation_matrix=[], scatter=[], pollutant_pairwise=[])
# ============== Endpoint 5: temporal ==============
_WEEKDAY_LABELS = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
def _compute_temporal() -> TemporalResponse:
df = get_combined_data().copy()
if df.empty:
return TemporalResponse(weekday=[], month_year=[], yoy=[])
df["date"] = pd.to_datetime(df["date"], errors="coerce")
df = df[df["date"].notna()]
if df.empty:
return TemporalResponse(weekday=[], month_year=[], yoy=[])
# weekday (0=周一..6=周日)
df["wd"] = df["date"].dt.weekday
weekday: list[WeekdayPoint] = []
for wd in range(7):
sub = df[df["wd"] == wd]
out = int((sub["type"] == "outpatient").sum())
inp = int((sub["type"] == "inpatient").sum())
weekday.append(WeekdayPoint(
weekday=_WEEKDAY_LABELS[wd], outpatient=out, inpatient=inp, total=out + inp,
))
# month_year (seasonality grid)
df["year"] = df["date"].dt.year
df["month"] = df["date"].dt.month
my = df.groupby(["year", "month"]).size()
month_year = [
MonthYearPoint(year=int(y), month=int(m), total=int(c))
for (y, m), c in my.items()
]
month_year.sort(key=lambda x: (x.year, x.month))
# yoy: monthly current vs same-month-prior-year (only if multiple years exist)
yoy: list[YoYPoint] = []
years = sorted(df["year"].unique().tolist())
if len(years) > 1:
monthly_totals = {(int(y), int(m)): int(c) for (y, m), c in my.items()}
for (y, m), cur in sorted(monthly_totals.items()):
prev = monthly_totals.get((y - 1, m))
if prev is None:
continue
growth = ((cur - prev) / prev * 100) if prev else 0.0
yoy.append(YoYPoint(
period=f"{y}-{m:02d}",
current=cur,
previous=prev,
growth_pct=round(growth, 2),
))
return TemporalResponse(weekday=weekday, month_year=month_year, yoy=yoy)
@router.get("/temporal", response_model=TemporalResponse, summary="时序统计")
async def temporal():
"""按星期、年-月(季节性网格)聚合,以及同比(YoY)增长(若有多年数据)。"""
try:
return await asyncio.to_thread(_compute_temporal)
except Exception:
logger.exception("temporal failed")
return TemporalResponse(weekday=[], month_year=[], yoy=[])

46
backend/tests/CLAUDE.md Normal file
View File

@@ -0,0 +1,46 @@
# Backend Tests
## Framework
pytest + FastAPI `TestClient` (sync, in-process). No database mocking needed — tests hit real endpoints with real data files.
## Structure
- `conftest.py` — shared fixtures (`client`, `auth_headers`, test data)
- `test_api.py` — endpoint integration tests, organized by router class
- `test_auth.py` — authentication flow tests
- `test_error_handling.py` — edge cases, error responses
- `test_utils.py` — pure utility function tests
## Patterns
- Tests organized in classes: `class TestRiskEndpoints:`
- One test method per scenario: `test_current_risk_map()`, `test_risk_map_with_date()`
- Fixture naming: `client: TestClient`, `auth_headers: dict`
- Assert response status, then JSON structure, then field types/values
```python
class TestSomeRouter:
def test_something(self, client: TestClient):
resp = client.get("/api/some/endpoint")
assert resp.status_code == 200
data = resp.json()
assert "key" in data
assert isinstance(data["key"], list)
```
## Running
```bash
cd backend
source venv/bin/activate
pytest tests/ -v
pytest tests/test_api.py -v -k "test_risk"
```
## Anti-Patterns
- Don't mock endpoints you can test with real data
- Don't hardcode test dates that will go stale
- Don't skip assertions on response structure just because status is 200
- Don't share mutable state between test classes — use fixtures

View File

@@ -0,0 +1,82 @@
"""Tests for district label normalization at the case-loader boundary.
The processed/cases_by_district_daily.parquet carries both bare ("武昌") and
区-suffixed ("武昌区") spellings of each district (26 labels = 13 districts × 2
spellings), which double-counts in any roll-up. data.case_loader normalizes
these to the canonical 13 区-suffixed names and re-aggregates. These tests pin
that behavior.
"""
import sys
from pathlib import Path
import pandas as pd
import pytest
# Ensure the backend package root is importable at collection time (mirrors the
# sys.path handling other modules rely on once the app is imported).
BACKEND_ROOT = Path(__file__).parent.parent
if str(BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(BACKEND_ROOT))
from data.case_loader import ( # noqa: E402
CANONICAL_DISTRICTS,
normalize_district,
load_cases_by_district_daily,
)
PROJECT_ROOT = Path(__file__).parent.parent.parent
RAW_PARQUET = PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet"
def test_normalize_district_known_bare_forms():
"""Every known bare form maps to its canonical 区-suffixed name."""
cases = {
"武昌": "武昌区", "汉阳": "汉阳区", "江岸": "江岸区", "硚口": "硚口区",
"青山": "青山区", "洪山": "洪山区", "东西湖": "东西湖区", "汉南": "汉南区",
"蔡甸": "蔡甸区", "江夏": "江夏区", "黄陂": "黄陂区", "新洲": "新洲区",
"江汉": "江汉区",
}
for bare, canonical in cases.items():
assert normalize_district(bare) == canonical
def test_normalize_district_already_suffixed_passes_through():
for d in CANONICAL_DISTRICTS:
assert normalize_district(d) == d
def test_canonical_set_is_exactly_thirteen():
assert len(CANONICAL_DISTRICTS) == 13
assert len(set(CANONICAL_DISTRICTS)) == 13
@pytest.mark.skipif(not RAW_PARQUET.exists(), reason="case parquet not present")
def test_loader_collapses_to_thirteen_canonical_districts():
df = load_cases_by_district_daily()
districts = set(df["district"].unique())
# (a) exactly 13 unique districts, all canonical
assert len(districts) == 13, f"expected 13 districts, got {len(districts)}: {sorted(districts)}"
assert districts == set(CANONICAL_DISTRICTS)
# (b) no bare / unsuffixed duplicates remain
for name in districts:
assert name.endswith(("", "", "")), f"unsuffixed district leaked: {name}"
@pytest.mark.skipif(not RAW_PARQUET.exists(), reason="case parquet not present")
def test_loader_preserves_totals_no_rows_dropped_or_double_counted():
"""Sum integrity: normalized total == raw parquet total."""
raw = pd.read_parquet(RAW_PARQUET)
normalized = load_cases_by_district_daily()
assert int(normalized["total_cases"].sum()) == int(raw["total_cases"].sum())
assert int(normalized["outpatient_count"].sum()) == int(raw["outpatient_count"].sum())
assert int(normalized["inpatient_count"].sum()) == int(raw["inpatient_count"].sum())
@pytest.mark.skipif(not RAW_PARQUET.exists(), reason="case parquet not present")
def test_raw_parquet_actually_has_dirty_labels():
"""Sanity: the raw file really has the 26-label problem we are fixing."""
raw = pd.read_parquet(RAW_PARQUET)
assert raw["district"].nunique() > 13

View File

@@ -37,8 +37,11 @@ class TestMissingResources:
class TestInvalidForecastDay:
def test_forecast_out_of_range(self, client: TestClient):
# days=0 violates the ge=1 bound on /forecast/{days}, so FastAPI returns 422.
# (This previously returned 200 because `Path` was shadowed by `pathlib.Path`,
# silently disabling validation — fixed by the risk.py import correction.)
resp = client.get("/api/risk/forecast/0")
assert resp.status_code in (200, 404)
assert resp.status_code == 422
def test_forecast_too_large(self, client: TestClient):
resp = client.get("/api/risk/forecast/999")

View File

@@ -0,0 +1,81 @@
"""Fast daily city-wide mean risk_1d with on-disk cache.
Avoids re-parsing ~45MB GeoJSON on every /analysis/trend request.
"""
from __future__ import annotations
import json
import logging
from functools import lru_cache
from pathlib import Path
from config import DATA_DIR, PROJECT_ROOT
logger = logging.getLogger(__name__)
_CACHE_PATH = PROJECT_ROOT / "processed" / "daily_avg_risk.json"
def _read_disk_cache() -> dict[str, float]:
if not _CACHE_PATH.exists():
return {}
try:
raw = json.loads(_CACHE_PATH.read_text(encoding="utf-8"))
return {str(k): float(v) for k, v in raw.items()}
except (OSError, json.JSONDecodeError, TypeError, ValueError):
return {}
def _write_disk_cache(cache: dict[str, float]) -> None:
try:
_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
_CACHE_PATH.write_text(
json.dumps(cache, ensure_ascii=False, separators=(",", ":")),
encoding="utf-8",
)
except OSError as e:
logger.warning("Failed to persist daily avg risk cache: %s", e)
def _compute_mean_risk_1d(filepath: Path) -> float:
"""Parse one risk GeoJSON and return mean risk_1d (0 if empty/missing)."""
try:
with open(filepath, "r", encoding="utf-8") as f:
geojson = json.load(f)
except (OSError, json.JSONDecodeError) as e:
logger.warning("Failed to parse %s: %s", filepath, e)
return 0.0
total = 0.0
n = 0
for feature in geojson.get("features", []):
props = feature.get("properties") or {}
r = props.get("risk_1d")
if r is None:
continue
total += float(r)
n += 1
return round(total / n, 4) if n else 0.0
@lru_cache(maxsize=64)
def daily_avg_risk(date_yyyymmdd: str) -> float:
"""Mean risk_1d for YYYYMMDD. Memory + disk cached."""
disk = _read_disk_cache()
if date_yyyymmdd in disk:
return disk[date_yyyymmdd]
filepath = DATA_DIR / f"risk_{date_yyyymmdd}.geojson"
if not filepath.exists():
return 0.0
avg = _compute_mean_risk_1d(filepath)
disk[date_yyyymmdd] = avg
_write_disk_cache(disk)
return avg
def warm_daily_avg_risk(dates: list[str]) -> None:
"""Precompute missing dates into the disk cache (blocking)."""
for d in dates:
daily_avg_risk(d)

View File

@@ -0,0 +1,21 @@
"""Map 100m grid_id (r{row}_c{col}) → Wuhan district name."""
from functools import lru_cache
import pandas as pd
from config import PROJECT_ROOT
@lru_cache(maxsize=1)
def grid_district_lookup() -> dict[str, str]:
"""Loaded once from processed/grid_district_mapping.parquet."""
path = PROJECT_ROOT / "processed" / "grid_district_mapping.parquet"
if not path.exists():
return {}
df = pd.read_parquet(path)
df = df.dropna(subset=["district_name"])
return dict(zip(df["grid_id"].astype(str), df["district_name"].astype(str)))
def district_for_grid(grid_id: str, default: str = "其他") -> str:
return grid_district_lookup().get(grid_id, default)

View File

@@ -9,7 +9,7 @@ def point_in_polygon(lat: float, lon: float, polygon_coords: list) -> bool:
return False
# MultiPolygon: check each polygon
if isinstance(polygon_coords[0], list) and isinstance(polygon_coords[0][0], list):
if isinstance(polygon_coords[0], list) and polygon_coords[0] and isinstance(polygon_coords[0][0], list):
for polygon in polygon_coords:
if polygon and isinstance(polygon[0], list):
ring = polygon[0] if isinstance(polygon[0][0], list) else polygon
@@ -25,6 +25,8 @@ def point_in_polygon(lat: float, lon: float, polygon_coords: list) -> bool:
def point_in_ring(lat: float, lon: float, ring: list) -> bool:
"""Ray casting algorithm for point-in-ring test."""
n = len(ring)
if n < 3:
return False
inside = False
x, y = lon, lat

View File

@@ -13,7 +13,7 @@ from utils.risk import risk_value_to_level
logger = logging.getLogger(__name__)
@lru_cache(maxsize=8)
@lru_cache(maxsize=16)
def parse_geojson_file(filepath: Path) -> list[dict[str, Any]]:
"""Parse GeoJSON file and extract grid data with standard fields."""
try:

View File

@@ -0,0 +1,375 @@
"""
Risk raster tile engine — renders the full-Wuhan 100m risk grid as XYZ map tiles.
Why this exists
---------------
The model emits risk at ~140k GCN nodes per day. The product needs to display this
over the full Wuhan 100m grid (~1.5M in-boundary cells) with smooth LOD. Shipping
that many cells to the browser as vectors is impossible, so we rasterize server-side:
1. Build a dense per-cell risk raster R[row, col] once per (date, forecast_day):
scatter each node's risk onto its 100m cell (max per cell), then nearest-fill
empty cells via a Euclidean distance transform (Voronoi over nodes, quantized
to the 100m grid). Cells outside the Wuhan boundary are masked out.
2. Build a max-pooled pyramid for clean LOD at low zoom.
3. Render standard 256x256 web-mercator PNG tiles by sampling the pyramid level
that matches the tile's zoom. Tiles are cached; the browser just loads images.
Coordinate conventions (calibrated from processed/grid_100m_index.parquet):
lat = MIN_LAT + (row + 0.5) * LAT_STEP -> row 0 is SOUTH, row increases north
lon = MIN_LON + (col + 0.5) * LON_STEP -> col 0 is WEST, col increases east
Everything below maps lat/lon -> (row, col) the same way, so orientation is coherent
end to end. Tile pixel py=0 is north (high lat -> high row); we build the RGBA array
with py as the first axis so north ends up at the top of the PNG.
"""
from __future__ import annotations
import io
import json
import math
from functools import lru_cache
from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw
from scipy.ndimage import distance_transform_edt
from config import DATA_DIR, WUHAN_BOUNDS, PROJECT_ROOT
# --- Grid definition (geographically-correct ~100m grid over the Wuhan bbox) ---
MIN_LON = WUHAN_BOUNDS["min_lon"]
MAX_LON = WUHAN_BOUNDS["max_lon"]
MIN_LAT = WUHAN_BOUNDS["min_lat"]
MAX_LAT = WUHAN_BOUNDS["max_lat"]
NROWS = 1550 # matches processed/grid_100m_with_dem_pop.parquet row extent
NCOLS = 1336 # matches its col extent
LAT_STEP = (MAX_LAT - MIN_LAT) / NROWS
LON_STEP = (MAX_LON - MIN_LON) / NCOLS
BOUNDARY_GEOJSON = PROJECT_ROOT / "Datas" / "武汉市.geojson"
# Forecast-day -> property suffix on the risk geojson features.
_DAY_TO_KEY = {1: "risk_1d", 3: "risk_3d", 7: "risk_7d"}
MAX_PYRAMID_LEVEL = 7 # full-res + 7 downsamples covers world zoom range
TILE_PX = 256
# ----------------------------------------------------------------------------
# Affine helpers (lat/lon <-> grid row/col)
# ----------------------------------------------------------------------------
def latlon_to_rowcol(lat: float, lon: float) -> tuple[int, int]:
row = int((lat - MIN_LAT) / LAT_STEP)
col = int((lon - MIN_LON) / LON_STEP)
row = max(0, min(NROWS - 1, row))
col = max(0, min(NCOLS - 1, col))
return row, col
# ----------------------------------------------------------------------------
# Boundary mask (rasterized once)
# ----------------------------------------------------------------------------
@lru_cache(maxsize=1)
def _boundary_mask() -> np.ndarray:
"""Boolean (NROWS, NCOLS) mask, True for cells inside the Wuhan boundary."""
img = Image.new("1", (NCOLS, NROWS), 0)
draw = ImageDraw.Draw(img)
if not BOUNDARY_GEOJSON.exists():
# No boundary file -> color the whole bbox rather than nothing.
return np.ones((NROWS, NCOLS), dtype=bool)
with open(BOUNDARY_GEOJSON, "r", encoding="utf-8") as f:
gj = json.load(f)
def _draw_ring(ring):
pts = []
for lon, lat in ring:
col = (lon - MIN_LON) / LON_STEP
row = (lat - MIN_LAT) / LAT_STEP
pts.append((col, row))
if len(pts) >= 3:
draw.polygon(pts, fill=1)
def _walk(geom):
gtype = geom.get("type")
coords = geom.get("coordinates", [])
if gtype == "Polygon":
for ring in coords:
_draw_ring(ring)
elif gtype == "MultiPolygon":
for poly in coords:
for ring in poly:
_draw_ring(ring)
if gj.get("type") == "FeatureCollection":
for feat in gj.get("features", []):
_walk(feat.get("geometry", {}))
elif gj.get("type") == "Feature":
_walk(gj.get("geometry", {}))
else:
_walk(gj)
return np.array(img, dtype=bool)
# ----------------------------------------------------------------------------
# Node loading (cached per date)
# ----------------------------------------------------------------------------
@lru_cache(maxsize=8)
def _load_nodes(date: str) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Return (rows, cols, risks[3]) arrays for all nodes on a date.
risks is shape (n_nodes, 3) for [risk_1d, risk_3d, risk_7d].
"""
filepath = DATA_DIR / f"risk_{date}.geojson"
if not filepath.exists():
raise FileNotFoundError(f"No risk data for date {date}")
with open(filepath, "r", encoding="utf-8") as f:
gj = json.load(f)
feats = gj.get("features", [])
n = len(feats)
rows = np.empty(n, dtype=np.int32)
cols = np.empty(n, dtype=np.int32)
risks = np.zeros((n, 3), dtype=np.float32)
for i, feat in enumerate(feats):
p = feat.get("properties", {})
lat = p.get("lat", 0.0)
lon = p.get("lon", 0.0)
r, c = latlon_to_rowcol(lat, lon)
rows[i] = r
cols[i] = c
risks[i, 0] = p.get("risk_1d", 0.0)
risks[i, 1] = p.get("risk_3d", 0.0)
risks[i, 2] = p.get("risk_7d", 0.0)
return rows, cols, risks
# ----------------------------------------------------------------------------
# Risk raster + pyramid (cached per date+day)
# ----------------------------------------------------------------------------
def _maxpool2(a: np.ndarray) -> np.ndarray:
"""Downsample by 2 taking the NaN-aware max of each 2x2 block."""
h, w = a.shape
h2, w2 = (h + 1) // 2, (w + 1) // 2
out = np.full((h2, w2), np.nan, dtype=np.float32)
# Pad to even dims with NaN so the reshape is clean.
ph, pw = h2 * 2, w2 * 2
pad = np.full((ph, pw), np.nan, dtype=np.float32)
pad[:h, :w] = a
blocks = pad.reshape(h2, 2, w2, 2)
# np.nanmax over the 2x2 block axes; suppress all-NaN warnings.
with np.errstate(invalid="ignore"):
out = np.nanmax(blocks, axis=(1, 3))
return out.astype(np.float32)
@lru_cache(maxsize=6)
def _risk_pyramid(date: str, day: int) -> tuple[np.ndarray, ...]:
"""Build the nearest-filled, boundary-masked risk raster and its LOD pyramid.
Returns a tuple of arrays, level 0 = full res (NROWS, NCOLS), each subsequent
level downsampled 2x. NaN marks "no data / outside boundary".
"""
if day not in _DAY_TO_KEY:
raise ValueError(f"invalid forecast day {day}")
day_idx = {1: 0, 3: 1, 7: 2}[day]
rows, cols, risks = _load_nodes(date)
vals = risks[:, day_idx]
# Scatter nodes onto the grid, taking the max risk per cell.
R = np.full((NROWS, NCOLS), -np.inf, dtype=np.float32)
np.maximum.at(R, (rows, cols), vals)
known = np.isfinite(R)
# Nearest-fill empty cells (Voronoi over nodes, quantized to the 100m grid).
if known.any():
idx = distance_transform_edt(~known, return_distances=False, return_indices=True)
R = R[tuple(idx)]
R = R.astype(np.float32)
# Mask out everything outside the Wuhan boundary.
mask = _boundary_mask()
R[~mask] = np.nan
pyramid = [R]
for _ in range(MAX_PYRAMID_LEVEL):
nxt = _maxpool2(pyramid[-1])
pyramid.append(nxt)
if nxt.shape[0] <= 2 or nxt.shape[1] <= 2:
break
return tuple(pyramid)
# ----------------------------------------------------------------------------
# Colormap (risk 0..1 -> RGBA), built once as a 256-entry LUT
# ----------------------------------------------------------------------------
@lru_cache(maxsize=1)
def _color_lut() -> np.ndarray:
"""256x4 uint8 LUT. Green -> yellow -> orange -> red, alpha grows with risk.
Risk below ~0.25 is rendered transparent to keep the map readable.
"""
lut = np.zeros((256, 4), dtype=np.uint8)
# control points: (risk, R, G, B)
stops = [
(0.00, 56, 176, 0), # green (low)
(0.40, 250, 204, 21), # yellow (medium)
(0.60, 249, 115, 22), # orange (high)
(0.80, 239, 68, 68), # red (critical)
(1.00, 153, 27, 27), # dark red (extreme)
]
xs = [s[0] for s in stops]
for i in range(256):
t = i / 255.0
# piecewise-linear RGB interpolation
for k in range(len(stops) - 1):
if xs[k] <= t <= xs[k + 1]:
f = (t - xs[k]) / (xs[k + 1] - xs[k] + 1e-9)
r = stops[k][1] + f * (stops[k + 1][1] - stops[k][1])
g = stops[k][2] + f * (stops[k + 1][2] - stops[k][2])
b = stops[k][3] + f * (stops[k + 1][3] - stops[k][3])
break
else:
r, g, b = stops[-1][1:]
# alpha: transparent below 0.25, then ramp 90 -> 235
if t < 0.25:
a = 0.0
else:
a = 90 + (t - 0.25) / 0.75 * (235 - 90)
lut[i] = (int(r), int(g), int(b), int(a))
return lut
# ----------------------------------------------------------------------------
# Tile rendering
# ----------------------------------------------------------------------------
def _tile_pixel_latlon(z: int, x: int, y: int) -> tuple[np.ndarray, np.ndarray]:
"""Return (lat[256,256], lon[256,256]) for each pixel center of a tile."""
n = 2.0 ** z
px = (np.arange(TILE_PX) + 0.5) / TILE_PX
# longitude is linear in tile-x
X = (x + px) / n
lon = X * 360.0 - 180.0 # shape (256,)
# latitude via inverse web-mercator (nonlinear in tile-y)
Y = (y + px) / n
lat = np.degrees(np.arctan(np.sinh(np.pi * (1.0 - 2.0 * Y)))) # shape (256,)
lon2d = np.broadcast_to(lon, (TILE_PX, TILE_PX)) # varies along axis 1 (px)
lat2d = np.broadcast_to(lat[:, None], (TILE_PX, TILE_PX)) # varies along axis 0 (py)
return lat2d, lon2d
def _level_for_zoom(z: int) -> int:
"""Pick the pyramid level so ~1 source cell maps to ~1 screen pixel."""
# meters/pixel at lat ~30.6: 156543.03 * cos(lat) / 2^z ; /100m per cell
cells_per_px = (156543.03 * math.cos(math.radians(30.6)) / (2.0 ** z)) / 100.0
if cells_per_px <= 1.0:
return 0
return max(0, min(MAX_PYRAMID_LEVEL, int(math.floor(math.log2(cells_per_px)))))
def render_tile(z: int, x: int, y: int, date: str, day: int = 1) -> bytes:
"""Render a single XYZ tile to PNG bytes. Fully transparent tiles return a
tiny cached blank PNG. Result is cached per (z,x,y,date,day)."""
return _render_tile_cached(z, x, y, date, day)
@lru_cache(maxsize=1024)
def _render_tile_cached(z: int, x: int, y: int, date: str, day: int) -> bytes:
pyramid = _risk_pyramid(date, day)
level = _level_for_zoom(z)
level = min(level, len(pyramid) - 1)
R = pyramid[level]
factor = 2 ** level
lh, lw = R.shape
lat2d, lon2d = _tile_pixel_latlon(z, x, y)
# lat/lon -> full-res row/col -> level row/col
row = ((lat2d - MIN_LAT) / LAT_STEP).astype(np.int32) // factor
col = ((lon2d - MIN_LON) / LON_STEP).astype(np.int32) // factor
inside = (row >= 0) & (row < lh) & (col >= 0) & (col < lw)
rc = np.clip(row, 0, lh - 1)
cc = np.clip(col, 0, lw - 1)
sampled = R[rc, cc] # (256,256) float32, NaN where no data
valid = inside & np.isfinite(sampled)
# Map risk -> LUT index (NaN cells become 0 then are zeroed-out below)
lut = _color_lut()
safe = np.nan_to_num(sampled, nan=0.0)
idx = np.clip((safe * 255.0), 0, 255).astype(np.uint8)
rgba = lut[idx] # (256,256,4)
rgba[~valid] = (0, 0, 0, 0) # transparent outside data/boundary
img = Image.fromarray(rgba, mode="RGBA")
buf = io.BytesIO()
img.save(buf, format="PNG", optimize=False)
return buf.getvalue()
# ----------------------------------------------------------------------------
# Point query (for click-to-inspect)
# ----------------------------------------------------------------------------
@lru_cache(maxsize=16)
def grid_stats(date: str, day: int = 1) -> dict:
"""Lightweight aggregate stats over the in-boundary 100m grid for a date/day.
Computed from the cached raster, so this is cheap after the first tile build.
Replaces the old heavy per-viewport LOD fetch the overlay used to do.
"""
R = _risk_pyramid(date, day)[0]
finite = np.isfinite(R)
n = int(finite.sum())
if n == 0:
return {"cell_count": 0, "avg_risk": 0.0, "max_risk": 0.0,
"high_risk_count": 0, "forecast_day": day, "date": date}
vals = R[finite]
return {
"cell_count": n,
"avg_risk": round(float(vals.mean()), 4),
"max_risk": round(float(vals.max()), 4),
"high_risk_count": int((vals >= 0.8).sum()),
"forecast_day": day,
"date": date,
}
def query_cell(lat: float, lon: float, date: str, day: int = 1) -> dict:
"""Return the 100m cell risk at a lat/lon for the given date.
Includes all three forecast horizons (1d/3d/7d) so the click panel can show
them without a separate heavy grid fetch. `risk_value` is the requested day.
"""
row, col = latlon_to_rowcol(lat, lon)
def _sample(d: int) -> tuple[float, bool]:
v = _risk_pyramid(date, d)[0][row, col]
ok = bool(np.isfinite(v))
return (round(float(v), 4) if ok else 0.0), ok
r1, in_b = _sample(1)
r3, _ = _sample(3)
r7, _ = _sample(7)
current = {1: r1, 3: r3, 7: r7}[day]
return {
"grid_id": f"r{row}_c{col}",
"row": row,
"col": col,
"center_lat": round(MIN_LAT + (row + 0.5) * LAT_STEP, 6),
"center_lon": round(MIN_LON + (col + 0.5) * LON_STEP, 6),
"risk_value": current,
"risk_1d": r1,
"risk_3d": r3,
"risk_7d": r7,
"in_boundary": in_b,
"forecast_day": day,
"date": date,
}

44
deploy/CLAUDE.md Normal file
View File

@@ -0,0 +1,44 @@
# Deploy — Docker Compose
## Stack
- Docker Compose with 3 services: `api` (FastAPI), `frontend` (nginx/React), `mlflow`
- Multi-stage Dockerfiles: build stage → production stage
- Env vars via `.env` file (see `.env.example` for template)
## Files
```
deploy/
docker-compose.yml # Main: api + frontend + PostgreSQL/PostGIS
docker-compose.mlflow.yml # MLflow tracking server
.env.example # Required env vars template
backend/Dockerfile # FastAPI app image
backend/.dockerignore
frontend/Dockerfile # nginx serving built React app
frontend/.dockerignore
```
## Running
```bash
# Full stack
docker compose -f deploy/docker-compose.yml up -d
# With MLflow
docker compose -f deploy/docker-compose.yml -f deploy/docker-compose.mlflow.yml up -d
```
## Conventions
- Never commit `.env` — use `.env.example` as template
- Dockerfiles use multi-stage builds to minimize image size
- Frontend is built during Docker build, served by nginx
- Backend runs uvicorn with `--host 0.0.0.0` inside container
## Anti-Patterns
- Don't hardcode ports in docker-compose — use env vars
- Don't commit secrets to `.env.example` — placeholder values only
- Don't run as root in Dockerfiles — create non-root user
- Don't skip `.dockerignore` — keeps build context small

View File

@@ -1,32 +1,26 @@
FROM python:3.11-slim
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq-dev \
libpq-dev curl \
&& rm -rf /var/lib/apt/lists/*
# Create non-root user
RUN groupadd --gid 1000 appgroup && \
useradd --uid 1000 --gid appgroup --shell /bin/bash --create-home appuser
WORKDIR /home/appuser
WORKDIR /app
# Copy requirements and install dependencies
COPY --chown=appuser:appgroup requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN pip install --no-cache-dir -r requirements.txt \
-i https://pypi.tuna.tsinghua.edu.cn/simple
# Copy backend code
COPY --chown=appuser:appgroup . .
# Switch to non-root user
USER appuser
# Expose port
ENV CBPOA_ROOT=/data
EXPOSE 8000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
HEALTHCHECK --interval=30s --timeout=10s --start-period=20s --retries=3 \
CMD curl -f http://localhost:8000/docs || exit 1
# Run uvicorn
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

17
deploy/cbpoa-api.service Normal file
View File

@@ -0,0 +1,17 @@
[Unit]
Description=CBPOA FastAPI backend
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/opt/cbpoa/backend
Environment=PATH=/opt/cbpoa/backend/.venv/bin:/usr/bin
EnvironmentFile=-/opt/cbpoa/backend/.env
ExecStart=/opt/cbpoa/backend/.venv/bin/uvicorn main:app --host 127.0.0.1 --port 8000 --workers 1
Restart=on-failure
RestartSec=5
LimitNOFILE=65535
[Install]
WantedBy=multi-user.target

View File

@@ -1,83 +1,47 @@
version: '3.8'
services:
postgres:
image: postgis/postgis:15-3.3
container_name: wuhan_postgres
environment:
POSTGRES_DB: wuhan_disease
POSTGRES_USER: wuhan_user
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-wuhan_password}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U wuhan_user -d wuhan_disease"]
interval: 5s
timeout: 5s
retries: 5
networks:
- wuhan_network
backend:
build:
context: ../backend
dockerfile: Dockerfile
container_name: wuhan_backend
dockerfile: ../deploy/backend/Dockerfile
container_name: cbpoa_backend
environment:
DATABASE_URL: postgresql://wuhan_user:password@postgres:5432/wuhan_disease
POSTGRES_HOST: postgres
POSTGRES_PORT: 5432
depends_on:
postgres:
condition: service_healthy
CBPOA_ROOT: /data
CORS_ORIGINS: "*"
volumes:
- ../outputs:/data/outputs:ro
- ../processed:/data/processed:ro
- ../Datas:/data/Datas:ro
ports:
- "8000:8000"
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8000/docs || exit 1"]
interval: 10s
interval: 15s
timeout: 5s
retries: 5
start_period: 30s
start_period: 40s
restart: unless-stopped
networks:
- wuhan_network
- cbpoa_net
frontend:
build:
context: ../frontend
dockerfile: Dockerfile
container_name: wuhan_frontend
environment:
VITE_API_URL: http://localhost:8000
dockerfile: ../deploy/frontend/Dockerfile
container_name: cbpoa_frontend
depends_on:
- backend
backend:
condition: service_healthy
ports:
- "3000:80"
- "80:80"
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:80 || exit 1"]
interval: 10s
test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost/ || exit 1"]
interval: 15s
timeout: 5s
retries: 5
restart: unless-stopped
networks:
- wuhan_network
# jupyter:
# image: jupyter/scipy-notebook:latest
# container_name: wuhan_jupyter
# ports:
# - "8888:8888"
# volumes:
# - ../processed:/home/jovyan/processed
# - ../Datas:/home/jovyan/Datas
# networks:
# - wuhan_network
volumes:
postgres_data:
driver: local
- cbpoa_net
networks:
wuhan_network:
driver: bridge
cbpoa_net:
driver: bridge

View File

@@ -3,4 +3,4 @@ node_modules
*.md
tests
.env*
dist
dist

View File

@@ -5,16 +5,11 @@ FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package.json pnpm-lock.yaml ./
# Install dependencies (using pnpm since lock file is pnpm-lock.yaml)
RUN npm install -g pnpm && pnpm install --frozen-lockfile
# Copy source code
COPY . .
# Build the application
ENV VITE_API_URL=/api
RUN pnpm run build
# =============================================================================
@@ -22,15 +17,10 @@ RUN pnpm run build
# =============================================================================
FROM nginx:alpine AS production
# Copy custom nginx config for SPA routing
COPY --from=builder /app/nginx.conf /etc/nginx/conf.d/default.conf
# Copy built assets from builder
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/dist /usr/share/nginx/html
# Expose port 80
EXPOSE 80
# Health check for nginx
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-redirect --quiet --tries=1 --spider http://localhost/ || exit 1
CMD wget --no-redirect --quiet --tries=1 --spider http://localhost/ || exit 1

37
deploy/nginx-cbpoa.conf Normal file
View File

@@ -0,0 +1,37 @@
server {
listen 80;
server_name _;
root /var/www/cbpoa;
index index.html;
client_max_body_size 20m;
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/geo+json;
gzip_min_length 1000;
location /api/ {
proxy_pass http://127.0.0.1:8000/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
}
# Gaode basemap proxy (same as Vite /basemap-gaode)
location ~ ^/basemap-gaode/(\d+)/(\d+)/(\d+) {
proxy_pass https://webrd01.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&z=$1&x=$2&y=$3;
proxy_set_header Host webrd01.is.autonavi.com;
proxy_ssl_server_name on;
proxy_hide_header Set-Cookie;
expires 1d;
add_header Cache-Control "public";
}
location / {
try_files $uri $uri/ /index.html;
}
}

15
frontend/.env.example Normal file
View File

@@ -0,0 +1,15 @@
# Frontend env (Vite)
# API (default: Vite proxy /api → :8000)
# VITE_API_URL=/api
# 天地图个人密钥 https://console.tianditu.gov.cn/
# 不设则用高德矢量底图GeoScene CN 仅有 tianditu-* 命名底图,且内置 tk 会 418
# VITE_TIANDITU_TK=
# GeoScene Enterprise (optional — unused in POC)
# VITE_GEOSCENE_PORTAL_URL=https://cn18:7443/geoscene
# VITE_LAYER_DISTRICTS_URL=
# VITE_LAYER_RISK_URL=
# VITE_LAYER_CASES_URL=
# VITE_GEOSCENE_WEBMAP_ID=

View File

@@ -1,19 +1,21 @@
# Frontend — React + TypeScript + Leaflet
# Frontend — React + TypeScript + GeoScene Maps SDK
## Stack
- React 18, TypeScript 5, Vite 5
- Tailwind CSS, Recharts, Zustand (state), Axios
- Leaflet / react-leaflet (maps)
- `@geoscene/core` (Maps SDK for JavaScript) — POC uses Tianditu basemap + local GeoJSON / FastAPI risk tiles
- Playwright (e2e tests)
## Structure
```
frontend/src/
main.tsx # Entry point
main.tsx # Entry (+ GeoScene theme CSS)
App.tsx # Router setup
geoscene/ # MapView helpers + layer factories
components/ # Reusable UI (maps, charts, nav)
legacy/ # Unused former Leaflet map experiments
pages/ # Route-level views
services/api.ts # Axios client with TTL cache + request dedup
stores/ # Zustand stores
@@ -28,15 +30,21 @@ frontend/src/
## Patterns
- Components: PascalCase, one per file, default export
- API calls: use `services/api.ts` wrappers (`riskApi`, `alertApi`, `caseApi`, `gridApi`) — they handle caching and request dedup
- State: Zustand stores in `stores/`, typed with TypeScript interfaces from `types/`
- Styling: Tailwind utility classes, no CSS modules
- Map components: imperative `@geoscene/core` via `createMapView` — do not pass MapView instances between components
- Coordinates: GeoScene uses `[longitude, latitude]`
- API calls: use `services/api.ts` wrappers (`riskApi`, `alertApi`, `caseApi`, `gridApi`)
- State: Zustand stores in `stores/`
- Styling: Tailwind utility classes
## Env (optional Enterprise later)
See `.env.example` for `VITE_GEOSCENE_PORTAL_URL` / `VITE_LAYER_*`. POC runs without them.
## Running
```bash
cd frontend
pnpm dev # localhost:5173, proxies /api → localhost:8000
pnpm dev # localhost:3000, proxies /api → localhost:8000
pnpm build # tsc + vite build → dist/
```
@@ -47,3 +55,4 @@ pnpm build # tsc + vite build → dist/
- Don't mix data fetching with presentation — fetch in pages, render in components
- Don't inline styles when Tailwind classes work
- Don't create god components (>200 lines) — extract sub-components
- Don't reintroduce Leaflet or role/perspective switchers

View File

@@ -0,0 +1,90 @@
/**
* 住院临床分析页(/analysis/clinical验收测试。
* 与 user-flows.spec.ts 一致的鉴权策略addInitScript 注入 cbpoa_token
* 用 page.route 拦截 /api/**,对 inpatient-clinical 返回合法小样本,其余返回 {}。
*/
import { test, expect, Page } from '@playwright/test';
import { TESTIDS } from '../src/utils/testids';
const CLINICAL_FIXTURE = {
kpis: {
total_admissions: 5822,
median_los_days: 4,
cure_rate: 0.991,
emergency_admit_ratio: 0.47,
},
los_histogram: [
{ bin_label: '1-2', count: 1200 },
{ bin_label: '3-4', count: 2100 },
{ bin_label: '5-7', count: 1500 },
],
los_by_disease: [
{ diagnosis: '肺炎', p25: 3, median: 5, p75: 7, n: 800 },
{ diagnosis: '支气管炎', p25: 2, median: 4, p75: 6, n: 600 },
],
outcome_counts: [
{ outcome: '治愈', count: 3474 },
{ outcome: '好转', count: 2298 },
{ outcome: '其他', count: 35 },
{ outcome: '未愈', count: 12 },
{ outcome: '死亡', count: 3 },
],
admission_route_counts: [
{ route: '急诊', count: 2700 },
{ route: '门诊', count: 3122 },
],
bmi_by_age_band: [
{ age_band: '0-2', p25: 14, median: 16, p75: 18, n: 400 },
{ age_band: '3-6', p25: 15, median: 17, p75: 19, n: 500 },
],
};
async function seedAuthAndMockApi(page: Page) {
await page.addInitScript(() => {
localStorage.setItem('cbpoa_token', 'e2e-test-token');
});
await page.route('/api/**', (route) => {
const url = route.request().url();
if (url.includes('/stats/inpatient-clinical')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(CLINICAL_FIXTURE),
});
return;
}
// 其余接口返回空对象,本页不依赖。
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({}),
});
});
}
test.describe('住院临床分析页', () => {
test.beforeEach(async ({ page }) => {
await seedAuthAndMockApi(page);
});
test('deep-link /analysis/clinical mounts page-clinical + clinical-kpis', async ({ page }) => {
await page.goto('/analysis/clinical');
await expect(page.locator(`[data-testid="${TESTIDS.pageClinical}"]`)).toBeVisible();
await expect(page.locator(`[data-testid="${TESTIDS.clinicalKpis}"]`)).toBeVisible();
});
test('no horizontal scroll at 375px', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await page.goto('/analysis/clinical');
await expect(page.locator(`[data-testid="${TESTIDS.pageClinical}"]`)).toBeVisible();
await expect(page.locator(`[data-testid="${TESTIDS.clinicalKpis}"]`)).toBeVisible();
const noHorizontalScroll = await page.evaluate(
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth
);
expect(noHorizontalScroll).toBe(true);
});
});

View File

@@ -0,0 +1,78 @@
import { test, expect, Page } from '@playwright/test';
import { TESTIDS } from '../src/utils/testids';
// 与 user-flows.spec.ts 一致的鉴权策略:注入 token 绕过登录门,并 mock /api/**
// 让用例脱离活的后端 hermetic 运行。
async function seedAuthAndMockApi(page: Page) {
await page.addInitScript(() => {
localStorage.setItem('cbpoa_token', 'e2e-test-token');
});
await page.route('/api/**', (route) => {
const url = route.request().url();
if (url.includes('/history/aggregated')) {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ aggregations: [], total_records: 0 }) });
return;
}
if (url.includes('/grids')) {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ type: 'FeatureCollection', features: [] }) });
return;
}
if (url.includes('/streets')) {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ streets: [] }) });
return;
}
if (url.includes('/cases/districts') || url.includes('/districts')) {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) });
return;
}
if (url.includes('/cases/trend') || url.includes('/cases')) {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ data: [], trend: [], total: 0 }) });
return;
}
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ data: [], items: [], total: 0 }) });
});
}
// 粒度granularity以 URL query 参数为真相来源source of truth
// 监测页通过 useSearchParams 读取它drilldownStore 单向派生。
test.describe('monitoring granularity URL source-of-truth', () => {
test.beforeEach(async ({ page }) => {
await seedAuthAndMockApi(page);
});
test('deep-link granularity=street mounts page and reflects street in control', async ({ page }) => {
await page.goto('/monitoring?granularity=street');
await expect(page.getByTestId(TESTIDS.pageMonitoring)).toBeVisible();
const control = page.getByTestId(TESTIDS.granularityControl);
await expect(control).toBeVisible();
// 街道分段为激活态Segmented 给激活按钮加 bg-primary
const streetBtn = page.getByTestId(`${TESTIDS.granularityControl}-street`);
await expect(streetBtn).toHaveClass(/bg-primary/);
});
test('clicking a granularity control updates the URL granularity param', async ({ page }) => {
await page.goto('/monitoring?granularity=street');
await expect(page.getByTestId(TESTIDS.pageMonitoring)).toBeVisible();
// 切到「区域」应把 URL 写为 granularity=district。
await page.getByTestId(`${TESTIDS.granularityControl}-district`).click();
await expect(page).toHaveURL(/granularity=district/);
// 切到「全市」应把 URL 写为 granularity=city。
await page.getByTestId(`${TESTIDS.granularityControl}-city`).click();
await expect(page).toHaveURL(/granularity=city/);
});
test('deep-link granularity=district survives a reload', async ({ page }) => {
await page.goto('/monitoring?granularity=district');
await expect(page.getByTestId(TESTIDS.pageMonitoring)).toBeVisible();
await expect(page).toHaveURL(/granularity=district/);
await page.reload();
await expect(page.getByTestId(TESTIDS.pageMonitoring)).toBeVisible();
await expect(page).toHaveURL(/granularity=district/);
await expect(page.getByTestId(`${TESTIDS.granularityControl}-district`)).toHaveClass(/bg-primary/);
});
});

View File

@@ -0,0 +1,119 @@
/**
* 综合概览大屏 (/overview) 验收测试。
*
* 与 user-flows.spec.ts 一致:用 addInitScript 注入 cbpoa_token 绕过登录门,
* page.route 拦截 /api/** 使套件 hermetic无需 :8000。/wuhan_districts.geojson
* 走真实静态资源dev server 提供),由 Leaflet 取用。
*/
import { test, expect, Page } from '@playwright/test';
import { TESTIDS } from '../src/utils/testids';
/** 13 区里造两条数据,断言 choropleth 能着色、toggle 能切换。 */
function districtPayload() {
return {
districts: [
{ district: '武昌区', outpatient: 120, inpatient: 30, total: 150, outpatient_ratio: 0.8, inpatient_ratio: 0.2 },
{ district: '江岸', outpatient: 60, inpatient: 10, total: 70, outpatient_ratio: 0.86, inpatient_ratio: 0.14 },
],
total: 220,
};
}
async function seedAuthAndMockApi(page: Page) {
await page.addInitScript(() => {
localStorage.setItem('cbpoa_token', 'e2e-test-token');
});
await page.route('/api/**', (route) => {
const url = route.request().url();
const json = (body: unknown) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
if (url.includes('/alerts')) {
return json({ alerts: [], total: 0 });
}
if (url.includes('/cases/stats')) {
return json({
total_outpatient: 1000,
total_inpatient: 200,
date_range: { start: '2023-01-01', end: '2023-12-01' },
top_districts: [],
top_diagnoses: [
{ diagnosis: '上呼吸道感染', outpatient: 300, inpatient: 40 },
{ diagnosis: '肺炎', outpatient: 120, inpatient: 80 },
],
});
}
if (url.includes('/cases/trend')) {
return json({
trend: [
{ date: '2023-11-01', outpatient: 10, inpatient: 2, total: 12 },
{ date: '2023-11-02', outpatient: 14, inpatient: 3, total: 17 },
],
summary: {
total_outpatient: 24,
total_inpatient: 5,
period_count: 2,
avg_daily_outpatient: 12,
avg_daily_inpatient: 2.5,
},
});
}
if (url.includes('/cases/districts')) {
return json(districtPayload());
}
if (url.includes('/risk/stats')) {
return json({ high_risk_count: 7, total_grids: 100, avg_risk: 0.4 });
}
if (url.includes('/environment/pollutants')) {
return json({ data: [{ date: '2023-11-01', AQI: 80 }, { date: '2023-11-02', AQI: 95 }] });
}
return json({ data: [], items: [], total: 0 });
});
}
test.describe('Overview 大屏', () => {
test.use({ viewport: { width: 1280, height: 900 } });
test.beforeEach(async ({ page }) => {
await seedAuthAndMockApi(page);
});
test('renders kpi-row, choropleth, as-of badge and metric toggle', async ({ page }) => {
await page.goto('/overview');
await expect(page.locator(`[data-testid="${TESTIDS.pageOverview}"]`)).toBeVisible();
await expect(page.locator(`[data-testid="${TESTIDS.kpiRow}"]`)).toBeVisible();
await expect(page.locator(`[data-testid="${TESTIDS.choroplethWrapper}"]`)).toBeVisible();
// Literal honesty badge — exact text.
const badge = page.locator(`[data-testid="${TESTIDS.asofBadge}"]`);
await expect(badge).toBeVisible();
await expect(badge).toHaveText('数据截至2023-12');
});
test('门诊/住院 toggle switches active segment without error', async ({ page }) => {
await page.goto('/overview');
await expect(page.locator(`[data-testid="${TESTIDS.choroplethWrapper}"]`)).toBeVisible();
const outBtn = page.locator(`[data-testid="${TESTIDS.outinpatientToggle}-outpatient"]`);
const inBtn = page.locator(`[data-testid="${TESTIDS.outinpatientToggle}-inpatient"]`);
const allBtn = page.locator(`[data-testid="${TESTIDS.outinpatientToggle}-all"]`);
// Default: 全部 active (primary background).
await expect(allBtn).toHaveClass(/bg-primary/);
await outBtn.click();
await expect(outBtn).toHaveClass(/bg-primary/);
await expect(allBtn).not.toHaveClass(/bg-primary/);
await inBtn.click();
await expect(inBtn).toHaveClass(/bg-primary/);
await expect(outBtn).not.toHaveClass(/bg-primary/);
// Wrapper still mounted after toggling — no render crash.
await expect(page.locator(`[data-testid="${TESTIDS.choroplethWrapper}"]`)).toBeVisible();
});
});

232
frontend/e2e/perf.spec.ts Normal file
View File

@@ -0,0 +1,232 @@
/**
* Performance-measurement harness for the leadership 大屏 (/overview).
*
* Runs ONLY in the dedicated `perf` Playwright project (see playwright.config.ts
* testMatch) so emulated network throttling never pollutes the functional suite.
*
* What it measures:
* 1. LCP (Largest Contentful Paint) of /overview under emulated Fast 3G.
* 2. Client-side route-transition time from /overview → /monitoring.
*
* Throttling model: /api/** is mocked to resolve INSTANTLY (see seedAuthAndMockApi),
* so the backend contributes ~0ms. That is deliberate — it isolates the realistic
* SPA cost on a slow link: the *static asset graph* (app JS/CSS bundle + the
* /wuhan_districts.geojson choropleth payload, which is served by the real dev
* server, not mocked). Fast 3G therefore shapes exactly the bytes a cold-cache
* leadership client must pull before first paint, which is what LCP should reflect.
*
* Assertion policy (per the UX-modernization plan): LCP and route-transition
* targets (2500ms LCP / 800ms transition) are REPORTED, not hard CI gates — a
* miss under throttle on a loaded CI box must not fail the build. We therefore
* record each number against its target as a test annotation + console line and
* let the test PASS regardless of the target. (Note: `expect.soft` would still
* mark the test failed at teardown, so it's the wrong tool for a report-only
* target — annotations are.) Hard assertions guard ONLY that the measurement
* machinery worked: LCP was observed (> 0) and the nav actually landed.
*
* Caveat on absolute values: this runs against the Vite DEV server (unbundled,
* unminified ESM with per-module requests). Dev LCP under Fast 3G is therefore
* far higher than a production build would be — these numbers are a relative
* regression signal for this harness, not a production SLA.
*/
import { test, expect, Page } from '@playwright/test';
import { TESTIDS } from '../src/utils/testids';
// Reported (soft) targets — see file header.
const LCP_TARGET_MS = 2500;
const ROUTE_TRANSITION_TARGET_MS = 800;
// Emulated "Fast 3G" network conditions (Chrome DevTools preset).
const FAST_3G = {
offline: false,
downloadThroughput: (1.6 * 1024 * 1024) / 8, // 1.6 Mbps
uploadThroughput: (750 * 1024) / 8, // 750 Kbps
latency: 150, // ms RTT
};
/**
* Seed auth + mock /api/** so the page renders hermetically. Mirrors the helper
* in user-flows.spec.ts, with one deliberate difference: /wuhan_districts.geojson
* is a real static asset and is NOT under /api, so page.route('/api/**') already
* lets it pass through to the dev server (the realistic, throttled payload).
*/
async function seedAuthAndMockApi(page: Page) {
await page.addInitScript(() => {
localStorage.setItem('cbpoa_token', 'e2e-test-token');
});
// Mock backend responses instantly so Fast-3G shapes only the static asset
// graph (JS/CSS + geojson), not API latency.
await page.route('/api/**', (route) => {
const url = route.request().url();
if (url.includes('/alerts')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ alerts: [], total: 0 }),
});
return;
}
if (url.includes('/history/aggregated')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ aggregations: [], total_records: 0 }),
});
return;
}
if (url.includes('/grids')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ type: 'FeatureCollection', features: [] }),
});
return;
}
if (url.includes('/cases/demographics')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
age_distribution: [],
gender_split: { male: { outpatient: 0, inpatient: 0 }, female: { outpatient: 0, inpatient: 0 } },
age_diagnosis_matrix: [],
}),
});
return;
}
if (url.includes('/cases/diagnosis-distribution') || url.includes('/cases/disease-seasonality')) {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) });
return;
}
if (url.includes('/cases/districts') || url.includes('/districts')) {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) });
return;
}
if (url.includes('/cases/trend') || url.includes('/cases')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ data: [], total: 0 }),
});
return;
}
// Default fallback — safe empty shape.
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ data: [], items: [], total: 0 }),
});
});
}
test.describe('Performance — /overview under emulated Fast 3G', () => {
test('LCP and route-transition are measured and reported', async ({ page }, testInfo) => {
// Fast-3G throttling makes the cold asset-graph download slow; the default 30s
// test budget can be eaten by the initial /overview load alone. Give the whole
// measurement flow generous headroom — this bounds the harness, not the metrics.
test.setTimeout(120_000);
await seedAuthAndMockApi(page);
// Install the LCP observer BEFORE any navigation so it captures the very
// first paint. buffered:true also replays entries emitted before observe().
await page.addInitScript(() => {
(window as unknown as { __lcp: number }).__lcp = 0;
new PerformanceObserver((list) => {
const entries = list.getEntries();
(window as unknown as { __lcp: number }).__lcp = entries[entries.length - 1].startTime;
}).observe({ type: 'largest-contentful-paint', buffered: true });
});
// Apply Fast 3G throttling via CDP before navigating.
const client = await page.context().newCDPSession(page);
await client.send('Network.enable');
await client.send('Network.emulateNetworkConditions', FAST_3G);
// --- LCP measurement -----------------------------------------------------
// Generous wait: under Fast 3G the throttled JS bundle download dominates, so
// first meaningful paint legitimately exceeds the 5s default expect timeout.
// The LCP NUMBER we read is the real measured value — this timeout only bounds
// how long we'll wait for the asset graph to arrive before failing the harness.
await page.goto('/overview');
await expect(page.locator(`[data-testid="${TESTIDS.kpiRow}"]`)).toBeVisible({ timeout: 30_000 });
// LCP finalizes on the last contentful paint; give the observer a beat to flush
// the entry for the kpi-row we just saw before reading it.
await page.waitForTimeout(200);
const lcp = await page.evaluate(() => (window as unknown as { __lcp: number }).__lcp);
// --- Route-transition measurement ---------------------------------------
// Expand the 监测 module if its NavLink is collapsed, then click it.
const railSel = `[data-testid="${TESTIDS.sidebarRail}"]`;
const navMonitoring = page.locator(`${railSel} [data-testid="${TESTIDS.navMonitoring}"]`);
if (!(await navMonitoring.isVisible())) {
await page.locator(`${railSel} button`).filter({ hasText: '监测' }).first().click();
}
await expect(navMonitoring).toBeVisible();
const t0 = await page.evaluate(() => performance.now());
await navMonitoring.click();
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible({
timeout: 30_000,
});
const t1 = await page.evaluate(() => performance.now());
const routeTransitionMs = t1 - t0;
// --- Report --------------------------------------------------------------
// eslint-disable-next-line no-console
console.log(`[perf] /overview LCP (Fast 3G): ${lcp.toFixed(0)} ms (target < ${LCP_TARGET_MS})`);
// eslint-disable-next-line no-console
console.log(
`[perf] /overview → /monitoring route transition: ${routeTransitionMs.toFixed(0)} ms (target < ${ROUTE_TRANSITION_TARGET_MS})`
);
await testInfo.attach('perf-metrics', {
contentType: 'application/json',
body: JSON.stringify(
{
lcpMs: Math.round(lcp),
lcpTargetMs: LCP_TARGET_MS,
routeTransitionMs: Math.round(routeTransitionMs),
routeTransitionTargetMs: ROUTE_TRANSITION_TARGET_MS,
network: 'Fast 3G (emulated via CDP)',
},
null,
2
),
});
// --- Reported targets (NOT gates) ---------------------------------------
// Record each metric vs. its target as a passing/over annotation. A miss is
// visible in the report and console but does NOT fail the test.
const lcpVerdict = lcp < LCP_TARGET_MS ? 'within' : 'over';
const routeVerdict = routeTransitionMs < ROUTE_TRANSITION_TARGET_MS ? 'within' : 'over';
testInfo.annotations.push({
type: 'perf-lcp',
description: `${Math.round(lcp)}ms (target ${LCP_TARGET_MS}ms — ${lcpVerdict})`,
});
testInfo.annotations.push({
type: 'perf-route-transition',
description: `${Math.round(routeTransitionMs)}ms (target ${ROUTE_TRANSITION_TARGET_MS}ms — ${routeVerdict})`,
});
if (lcpVerdict === 'over' || routeVerdict === 'over') {
// eslint-disable-next-line no-console
console.warn(
`[perf] target exceeded (LCP ${lcpVerdict}, route ${routeVerdict}) — reported, not gated (dev-server throttled run).`
);
}
// --- Hard assertions (gates) --------------------------------------------
// Only the measurement machinery is gated: the observer fired and the nav
// landed (page-monitoring visibility is already hard-asserted above).
expect(lcp, 'LCP observer should have recorded a paint').toBeGreaterThan(0);
expect(routeTransitionMs, 'route transition should elapse measurable time').toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,135 @@
/**
* Phase-4 responsive acceptance: every analysis page must be usable at 375px
* (the narrowest mobile viewport in D4) with NO horizontal scroll.
*
* Auth + backend mocking mirror e2e/user-flows.spec.ts (seedAuthAndMockApi):
* seed localStorage['cbpoa_token'] so the login gate is skipped, then mock all
* /api/** calls so the suite runs hermetically without a live :8000 backend.
*/
import { test, expect, Page } from '@playwright/test';
import { TESTIDS } from '../src/utils/testids';
// Each analysis route paired with its page-* mount testid.
const ANALYSIS_PAGES: Array<{ route: string; testid: string }> = [
{ route: '/analysis/trend', testid: TESTIDS.pageTrend },
{ route: '/analysis/district', testid: TESTIDS.pageDistrict },
{ route: '/analysis/insights', testid: TESTIDS.pageInsights },
{ route: '/analysis/reports', testid: TESTIDS.pageReports },
{ route: '/analysis/demographics', testid: TESTIDS.pageDemographics },
{ route: '/analysis/disease', testid: TESTIDS.pageDisease },
{ route: '/analysis/environment', testid: TESTIDS.pageEnvironment },
];
/** Seed auth token and mock all /api/** calls before each page load. */
async function seedAuthAndMockApi(page: Page) {
await page.addInitScript(() => {
localStorage.setItem('cbpoa_token', 'e2e-test-token');
});
// Mock backend responses so the suite is hermetic — no live :8000 required.
// Each response must match the TypeScript interface shape; returning {} causes
// pages to throw when accessing expected array properties.
await page.route('/api/**', (route) => {
const url = route.request().url();
if (url.includes('/alerts')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ alerts: [], total: 0 }),
});
return;
}
if (url.includes('/history/aggregated')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ aggregations: [], total_records: 0 }),
});
return;
}
if (url.includes('/grids')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ type: 'FeatureCollection', features: [] }),
});
return;
}
if (url.includes('/cases/demographics')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
age_distribution: [],
gender_split: { male: { outpatient: 0, inpatient: 0 }, female: { outpatient: 0, inpatient: 0 } },
age_diagnosis_matrix: [],
}),
});
return;
}
if (url.includes('/cases/diagnosis-distribution') || url.includes('/cases/disease-seasonality')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([]),
});
return;
}
if (url.includes('/cases/districts') || url.includes('/districts')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([]),
});
return;
}
if (url.includes('/cases/trend') || url.includes('/cases')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ data: [], total: 0 }),
});
return;
}
// Default fallback — return a safe empty object.
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ data: [], items: [], total: 0 }),
});
});
}
test.describe('Responsive — analysis pages @375px', () => {
test.use({ viewport: { width: 375, height: 812 } });
test.beforeEach(async ({ page }) => {
await seedAuthAndMockApi(page);
});
for (const { route, testid } of ANALYSIS_PAGES) {
test(`${route} mounts and has no horizontal scroll at 375px`, async ({ page }) => {
await page.goto(route);
// Page must mount.
await expect(page.locator(`[data-testid="${testid}"]`)).toBeVisible();
// No horizontal overflow: scrollWidth must not exceed clientWidth (+1px slack
// for sub-pixel rounding).
const noHorizontalScroll = await page.evaluate(
() =>
document.documentElement.scrollWidth <=
document.documentElement.clientWidth + 1
);
expect(noHorizontalScroll, `${route} overflows horizontally at 375px`).toBe(true);
});
}
});

View File

@@ -1,242 +1,291 @@
/**
* US-007 + US-008: E2E user flow and UI state tests.
* Simulates real user workflows through the CBPOA system.
* Phase-1 acceptance tests: URL-based navigation, responsive layout, and core user flows.
* Rewrites the previous click-nav suite for react-router v6 URL navigation.
*
* Auth strategy: seed localStorage['cbpoa_token'] via addInitScript (App.tsx gates on
* token presence only; no server validation). Backend is mocked via page.route so the
* suite runs hermetically without a live :8000 backend.
*/
import { test, expect } from '@playwright/test';
import { test, expect, Page } from '@playwright/test';
import { TESTIDS } from '../src/utils/testids';
const BASE_URL = 'http://localhost:3000';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
test.describe('认证流程 (Authentication Flow)', () => {
test('显示登录页面', async ({ page }) => {
await page.goto(BASE_URL);
await page.waitForTimeout(1000);
// Should see login form or app (if cached token)
const isLogin = await page.locator('input').count();
const isApp = await page.locator('nav').count();
expect(isLogin > 0 || isApp > 0).toBeTruthy();
/** Seed auth token and mock all /api/** calls before each page load. */
async function seedAuthAndMockApi(page: Page) {
// Prevent login gate from appearing.
await page.addInitScript(() => {
localStorage.setItem('cbpoa_token', 'e2e-test-token');
});
test('登录表单可交互', async ({ page }) => {
await page.goto(BASE_URL);
await page.waitForTimeout(1000);
// Mock backend responses so the suite is hermetic — no live :8000 required.
// Each response must match the TypeScript interface shape; returning {} causes
// pages to throw when accessing expected array properties.
await page.route('/api/**', (route) => {
const url = route.request().url();
const inputs = page.locator('input');
const count = await inputs.count();
if (count >= 2) {
// Login page is shown
await inputs.first().fill('admin');
await inputs.nth(1).fill('admin123');
const loginBtn = page.locator('button[type="submit"], button:has-text("登录"), button:has-text("Login")');
const btnCount = await loginBtn.count();
if (btnCount > 0) {
await loginBtn.first().click();
await page.waitForTimeout(2000);
}
if (url.includes('/alerts')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ alerts: [], total: 0 }),
});
return;
}
// If no inputs, user is already logged in (token in localStorage)
});
});
test.describe('监测面板 (Monitoring Dashboard)', () => {
test('面板加载并显示统计卡片', async ({ page }) => {
await page.goto(BASE_URL);
await page.waitForTimeout(3000);
// Should show monitoring page by default
const statCards = page.locator('[class*="stat"], [class*="card"], [class*="Stat"]');
const cardsCount = await statCards.count();
// Should see some content
const bodyText = await page.textContent('body');
expect(bodyText).toBeTruthy();
});
test('时间线控件可交互', async ({ page }) => {
await page.goto(BASE_URL);
await page.waitForTimeout(3000);
// Look for timeline controls
const playButton = page.locator('button:has-text("播放"), button[title*="play" i], button[class*="play" i]');
const prevButton = page.locator('button:has-text("前一天"), button[title*="prev" i]');
const nextButton = page.locator('button:has-text("后一天"), button[title*="next" i]');
if (await playButton.count() > 0) {
await playButton.first().click();
await page.waitForTimeout(1000);
if (url.includes('/history/aggregated')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ aggregations: [], total_records: 0 }),
});
return;
}
});
test('疾病筛选器可用', async ({ page }) => {
await page.goto(BASE_URL);
await page.waitForTimeout(3000);
const selects = page.locator('select, [role="combobox"], [class*="select" i], [class*="filter" i]');
const count = await selects.count();
expect(count >= 0).toBeTruthy();
});
});
test.describe('预警面板 (Alerts Dashboard)', () => {
test('导航到预警面板', async ({ page }) => {
await page.goto(BASE_URL);
await page.waitForTimeout(2000);
// Navigate to alerts - click sidebar link
const alertsLink = page.locator('a[href*="alert" i], button:has-text("预警"), button:has-text("告警"), span:has-text("预警"), span:has-text("告警")');
if (await alertsLink.count() > 0) {
await alertsLink.first().click();
await page.waitForTimeout(2000);
if (url.includes('/grids')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ type: 'FeatureCollection', features: [] }),
});
return;
}
});
test('预警列表加载', async ({ page }) => {
await page.goto(BASE_URL);
await page.waitForTimeout(2000);
const alertsLink = page.locator('a[href*="alert" i], button:has-text("预警"), span:has-text("预警")');
if (await alertsLink.count() > 0) {
await alertsLink.first().click();
await page.waitForTimeout(3000);
const bodyText = await page.textContent('body');
expect(bodyText).toBeTruthy();
// DemographicsResponse — used by DemographicAnalysis page.
if (url.includes('/cases/demographics')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
age_distribution: [],
gender_split: { male: { outpatient: 0, inpatient: 0 }, female: { outpatient: 0, inpatient: 0 } },
age_diagnosis_matrix: [],
}),
});
return;
}
});
});
test.describe('趋势分析 (Trend Analysis)', () => {
test('导航到趋势分析页面', async ({ page }) => {
await page.goto(BASE_URL);
await page.waitForTimeout(2000);
const trendLink = page.locator('button:has-text("趋势"), span:has-text("趋势"), a[href*="trend" i]');
if (await trendLink.count() > 0) {
await trendLink.first().click();
await page.waitForTimeout(2000);
// DiseaseAnalysis calls: diagnosis-distribution, seasonality, districts.
if (url.includes('/cases/diagnosis-distribution') || url.includes('/cases/disease-seasonality')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([]),
});
return;
}
});
test('趋势图渲染', async ({ page }) => {
await page.goto(BASE_URL);
await page.waitForTimeout(2000);
const trendLink = page.locator('button:has-text("趋势"), span:has-text("趋势")');
if (await trendLink.count() > 0) {
await trendLink.first().click();
await page.waitForTimeout(3000);
// Recharts renders SVG charts
const svgCharts = page.locator('svg.recharts-surface');
const chartCount = await svgCharts.count();
expect(chartCount >= 0).toBeTruthy();
if (url.includes('/cases/districts') || url.includes('/districts')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([]),
});
return;
}
});
});
test.describe('区县对比 (District Comparison)', () => {
test('导航到区县对比页面', async ({ page }) => {
await page.goto(BASE_URL);
await page.waitForTimeout(2000);
const districtLink = page.locator('button:has-text("区县"), button:has-text("对比"), span:has-text("区县")');
if (await districtLink.count() > 0) {
await districtLink.first().click();
await page.waitForTimeout(2000);
// Trend / time-series endpoints.
if (url.includes('/cases/trend') || url.includes('/cases')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ data: [], total: 0 }),
});
return;
}
});
});
test.describe('报告中心 (Reports Center)', () => {
test('导航到报告中心', async ({ page }) => {
await page.goto(BASE_URL);
await page.waitForTimeout(2000);
const reportsLink = page.locator('button:has-text("报告"), span:has-text("报告"), a[href*="report" i]');
if (await reportsLink.count() > 0) {
await reportsLink.first().click();
await page.waitForTimeout(2000);
}
});
test('报告列表加载', async ({ page }) => {
await page.goto(BASE_URL);
await page.waitForTimeout(2000);
const reportsLink = page.locator('button:has-text("报告"), span:has-text("报告")');
if (await reportsLink.count() > 0) {
await reportsLink.first().click();
await page.waitForTimeout(3000);
const bodyText = await page.textContent('body');
expect(bodyText).toBeTruthy();
}
});
});
test.describe('UI 状态与错误处理 (UI States & Error Handling)', () => {
test('页面加载显示加载指示器而非白屏', async ({ page }) => {
await page.goto(BASE_URL);
await page.waitForTimeout(500);
const bodyHTML = await page.innerHTML('body');
// Should have some content, even during loading
expect(bodyHTML.length).toBeGreaterThan(0);
});
test('侧边栏导航切换页面正常', async ({ page }) => {
await page.goto(BASE_URL);
await page.waitForTimeout(2000);
const navLinks = page.locator('nav a, nav button, [class*="side" i] a, [class*="side" i] button');
const count = await navLinks.count();
if (count >= 2) {
await navLinks.first().click();
await page.waitForTimeout(1000);
await navLinks.nth(1).click();
await page.waitForTimeout(1000);
}
});
test('未出现明显 console 报错', async ({ page }) => {
const errors: string[] = [];
page.on('console', (msg) => {
if (msg.type() === 'error') {
errors.push(msg.text());
}
});
page.on('pageerror', (err) => {
errors.push(err.message);
// Default fallback — return a safe empty object.
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ data: [], items: [], total: 0 }),
});
});
}
await page.goto(BASE_URL);
await page.waitForTimeout(3000);
// ---------------------------------------------------------------------------
// URL-based navigation (react-router v6)
// ---------------------------------------------------------------------------
const filtered = errors.filter(
(e) => !e.includes('favicon') && !e.includes('404') && !e.includes('OLMap')
test.describe('URL-based navigation', () => {
test.beforeEach(async ({ page }) => {
await seedAuthAndMockApi(page);
});
test('deep-link: /analysis/disease mounts page-disease directly', async ({ page }) => {
await page.goto('/analysis/disease');
await expect(page.locator(`[data-testid="${TESTIDS.pageDisease}"]`)).toBeVisible();
});
test('refresh preserves page: reload on /analysis/disease keeps URL and mounts page-disease', async ({
page,
}) => {
await page.goto('/analysis/disease');
await expect(page.locator(`[data-testid="${TESTIDS.pageDisease}"]`)).toBeVisible();
await page.reload();
await expect(page).toHaveURL(/\/analysis\/disease/);
await expect(page.locator(`[data-testid="${TESTIDS.pageDisease}"]`)).toBeVisible();
});
test('browser back: from /analysis/trend back to /monitoring restores page-monitoring', async ({
page,
}) => {
await page.goto('/monitoring');
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible();
await page.goto('/analysis/trend');
await expect(page.locator(`[data-testid="${TESTIDS.pageTrend}"]`)).toBeVisible();
await page.goBack();
await expect(page).toHaveURL(/\/monitoring/);
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible();
});
test('NavLink click updates URL to /alerts and mounts page-alerts', async ({ page }) => {
// Start on /monitoring. The SideNav collapses all modules except the active one,
// so nav-alerts (inside the "预警" module) is hidden behind a collapsed section.
// We must expand the "预警" module first by clicking its header button.
await page.goto('/monitoring');
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible();
// Expand the "预警" module section so nav-alerts NavLink becomes visible.
// Both sidebar-rail and app-drawer render a SideNav; scope to sidebar-rail to avoid
// strict-mode ambiguity (the app-drawer's copy is also in the DOM but off-screen).
await page
.locator(`[data-testid="${TESTIDS.sidebarRail}"] button`)
.filter({ hasText: '预警' })
.click();
await expect(
page.locator(`[data-testid="${TESTIDS.sidebarRail}"] [data-testid="${TESTIDS.navAlerts}"]`)
).toBeVisible();
await page
.locator(`[data-testid="${TESTIDS.sidebarRail}"] [data-testid="${TESTIDS.navAlerts}"]`)
.click();
await expect(page).toHaveURL(/\/alerts/);
await expect(page.locator(`[data-testid="${TESTIDS.pageAlerts}"]`)).toBeVisible();
});
test('root / redirects to /monitoring', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveURL(/\/monitoring/);
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible();
});
test('unknown path redirects to /monitoring', async ({ page }) => {
await page.goto('/does-not-exist');
await expect(page).toHaveURL(/\/monitoring/);
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible();
});
});
// ---------------------------------------------------------------------------
// Responsive layout
// ---------------------------------------------------------------------------
test.describe('Responsive layout — mobile @375px', () => {
test.use({ viewport: { width: 375, height: 812 } });
test.beforeEach(async ({ page }) => {
await seedAuthAndMockApi(page);
});
test('hamburger is visible and sidebar-rail is hidden at 375px', async ({ page }) => {
await page.goto('/monitoring');
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible();
await expect(page.locator(`[data-testid="${TESTIDS.hamburger}"]`)).toBeVisible();
await expect(page.locator(`[data-testid="${TESTIDS.sidebarRail}"]`)).not.toBeVisible();
});
test('tapping hamburger slides app-drawer into viewport', async ({ page }) => {
await page.goto('/monitoring');
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible();
// Drawer should be off-screen (translate-x-full) before toggle.
const drawer = page.locator(`[data-testid="${TESTIDS.appDrawer}"]`);
await expect(drawer).not.toBeInViewport();
await page.locator(`[data-testid="${TESTIDS.hamburger}"]`).click();
// After toggle, drawer slides in and becomes visible in viewport.
await expect(drawer).toBeInViewport();
});
test('no horizontal scroll on default route at 375px', async ({ page }) => {
await page.goto('/monitoring');
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible();
const noHorizontalScroll = await page.evaluate(
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth
);
expect(filtered).toHaveLength(0);
expect(noHorizontalScroll).toBe(true);
});
});
test.describe('响应式布局 (Responsive Layout)', () => {
test('移动端视口下不崩溃', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await page.goto(BASE_URL);
await page.waitForTimeout(2000);
test.describe('Responsive layout — desktop @1280px', () => {
test.use({ viewport: { width: 1280, height: 800 } });
const bodyText = await page.textContent('body');
expect(bodyText).toBeTruthy();
test.beforeEach(async ({ page }) => {
await seedAuthAndMockApi(page);
});
test('平板视口下正常显示', async ({ page }) => {
await page.setViewportSize({ width: 768, height: 1024 });
await page.goto(BASE_URL);
await page.waitForTimeout(2000);
test('sidebar-rail is visible and hamburger is hidden at 1280px', async ({ page }) => {
await page.goto('/monitoring');
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible();
const bodyText = await page.textContent('body');
expect(bodyText).toBeTruthy();
await expect(page.locator(`[data-testid="${TESTIDS.sidebarRail}"]`)).toBeVisible();
await expect(page.locator(`[data-testid="${TESTIDS.hamburger}"]`)).not.toBeVisible();
});
});
// ---------------------------------------------------------------------------
// Core page loading
// ---------------------------------------------------------------------------
test.describe('Core pages load via URL nav', () => {
test.beforeEach(async ({ page }) => {
await seedAuthAndMockApi(page);
});
test('/monitoring loads page-monitoring', async ({ page }) => {
await page.goto('/monitoring');
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible();
});
test('/alerts loads page-alerts', async ({ page }) => {
await page.goto('/alerts');
await expect(page.locator(`[data-testid="${TESTIDS.pageAlerts}"]`)).toBeVisible();
});
test('/analysis/trend loads page-trend', async ({ page }) => {
await page.goto('/analysis/trend');
await expect(page.locator(`[data-testid="${TESTIDS.pageTrend}"]`)).toBeVisible();
});
test('/analysis/district loads page-district', async ({ page }) => {
await page.goto('/analysis/district');
await expect(page.locator(`[data-testid="${TESTIDS.pageDistrict}"]`)).toBeVisible();
});
test('/analysis/reports loads page-reports', async ({ page }) => {
await page.goto('/analysis/reports');
await expect(page.locator(`[data-testid="${TESTIDS.pageReports}"]`)).toBeVisible();
});
test('/analysis/demographics loads page-demographics', async ({ page }) => {
await page.goto('/analysis/demographics');
await expect(page.locator(`[data-testid="${TESTIDS.pageDemographics}"]`)).toBeVisible();
});
test('/analysis/environment loads page-environment', async ({ page }) => {
await page.goto('/analysis/environment');
await expect(page.locator(`[data-testid="${TESTIDS.pageEnvironment}"]`)).toBeVisible();
});
});

View File

@@ -4,11 +4,10 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>武汉儿童呼吸疾病风险预测平台</title>
<title>CBPOA · 武汉儿童呼吸疾病风险评估系统</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Noto+Sans+SC:wght@400;500;600&family=Source+Sans+Pro:wght@400;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@500;600&family=Noto+Sans+SC:wght@400;500;600;700&family=Outfit:wght@500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>

35
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,35 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
client_max_body_size 20m;
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/geo+json;
gzip_min_length 1000;
location /api/ {
proxy_pass http://backend:8000/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
}
location ~ ^/basemap-gaode/(\d+)/(\d+)/(\d+) {
proxy_pass https://webrd01.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&z=$1&x=$2&y=$3;
proxy_set_header Host webrd01.is.autonavi.com;
proxy_ssl_server_name on;
proxy_hide_header Set-Cookie;
expires 1d;
add_header Cache-Control "public";
}
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -9,12 +9,12 @@
"preview": "vite preview"
},
"dependencies": {
"@geoscene/core": "4.32.10",
"axios": "^1.6.7",
"leaflet": "^1.9.4",
"lucide-react": "^0.330.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-leaflet": "^4.2.1",
"react-router-dom": "^6.30.4",
"recharts": "^2.12.0",
"zustand": "^4.5.0"
},
@@ -22,7 +22,6 @@
"@playwright/test": "^1.59.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^14.3.1",
"@types/leaflet": "^1.9.8",
"@types/react": "^18.2.55",
"@types/react-dom": "^18.2.19",
"@vitejs/plugin-react": "^4.2.1",

View File

@@ -14,6 +14,16 @@ export default defineConfig({
projects: [
{
name: 'chromium',
// Functional suite. Exclude the throttled perf spec so emulated Fast-3G
// latency never bleeds into (or slows) the normal acceptance run.
testIgnore: /perf\.spec\.ts/,
use: { ...devices['Desktop Chrome'] },
},
{
// Dedicated perf project — only perf.spec.ts runs here, under CDP network
// throttling. Kept separate so functional and perf measurements don't mix.
name: 'perf',
testMatch: /perf\.spec\.ts/,
use: { ...devices['Desktop Chrome'] },
},
],

517
frontend/pnpm-lock.yaml generated
View File

@@ -8,12 +8,12 @@ importers:
.:
dependencies:
'@geoscene/core':
specifier: 4.32.10
version: 4.32.10
axios:
specifier: ^1.6.7
version: 1.15.2
leaflet:
specifier: ^1.9.4
version: 1.9.4
lucide-react:
specifier: ^0.330.0
version: 0.330.0(react@18.3.1)
@@ -23,9 +23,9 @@ importers:
react-dom:
specifier: ^18.2.0
version: 18.3.1(react@18.3.1)
react-leaflet:
specifier: ^4.2.1
version: 4.2.1(leaflet@1.9.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react-router-dom:
specifier: ^6.30.4
version: 6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
recharts:
specifier: ^2.12.0
version: 2.15.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -42,9 +42,6 @@ importers:
'@testing-library/react':
specifier: ^14.3.1
version: 14.3.1(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@types/leaflet':
specifier: ^1.9.8
version: 1.9.21
'@types/react':
specifier: ^18.2.55
version: 18.3.28
@@ -85,6 +82,18 @@ packages:
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'}
'@arcgis/lumina@4.34.9':
resolution: {integrity: sha512-efqO+SwR+1IYf29AATh1l2FUeypRyRINTBNkaJY+KkaFe+8gqSJ45qOmputhyzF5WTRDb7WhOYgnChjp6VYPpA==}
peerDependencies:
'@lit/context': ^1.1.5
lit: ^3.3.0
peerDependenciesMeta:
'@lit/context':
optional: true
'@arcgis/toolkit@4.34.9':
resolution: {integrity: sha512-wFST+eVnCwmg9NyICVyn9bsBnR+TlWklsGqG3L7xqSTgfXo6TuCThE7wtTb8xWxsTBkGvImqMUgpgLuwQuTQ1g==}
'@asamuzakjp/css-color@3.2.0':
resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==}
@@ -341,6 +350,32 @@ packages:
cpu: [x64]
os: [win32]
'@esri/arcgis-html-sanitizer@4.1.0':
resolution: {integrity: sha512-einEveDJ/k1180NOp78PB/4Hje9eBy3dyOGLLtLn6bSkizpUfCwuYBIXOA7Y3F/k/BsTQXgKqUVwQ0eiscWMdA==}
engines: {node: '>=18.0.0'}
'@esri/calcite-components@3.3.3':
resolution: {integrity: sha512-tw+EfJ3pb+Odj71W6E9GUkm8rMbNxfW1KeiI8GgsKDzhr39hMKwY+zYYFFYuO0FONxWGvAB+B8yqB0NvH7WeHw==}
'@esri/calcite-ui-icons@4.3.0':
resolution: {integrity: sha512-iOOuRurpjFxFVw6+aXW2JpSkRBrdOpBcbdibfPOmSPqMd1aoHBtYmYXetKoH9vfrXoBiPyO2PkDnczhsu/N9IA==}
hasBin: true
'@floating-ui/core@1.8.0':
resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==}
'@floating-ui/dom@1.8.0':
resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==}
'@floating-ui/utils@0.2.12':
resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==}
'@geoscene/core@4.32.10':
resolution: {integrity: sha512-suiMmwX2dGbAfRlu0KRfAzwQOZrVz6eU/YU0kAsU4VmRSZxCir+iSnvZQzoFAdTmoiivKMRSfnKwSeOqIN4ptg==}
'@interactjs/types@1.10.27':
resolution: {integrity: sha512-BUdv0cvs4H5ODuwft2Xp4eL8Vmi3LcihK42z0Ft/FbVJZoRioBsxH+LlsBdK4tAie7PqlKGy+1oyOncu1nQ6eA==}
'@jest/schemas@29.6.3':
resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@@ -361,6 +396,12 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@lit-labs/ssr-dom-shim@1.6.0':
resolution: {integrity: sha512-VHb0ALPMTlgKjM6yIxxoQNnpKyUKLD04VzeQdsiXkMqkvYlAHxq9glGLmgbb889/1GsohSOAjvQYoiBppXFqrQ==}
'@lit/reactive-element@2.1.2':
resolution: {integrity: sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==}
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@@ -373,17 +414,20 @@ packages:
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
engines: {node: '>= 8'}
'@open-wc/dedupe-mixin@1.4.0':
resolution: {integrity: sha512-Sj7gKl1TLcDbF7B6KUhtvr+1UCxdhMbNY5KxdU5IfMFWqL8oy1ZeAcCANjoB1TL0AJTcPmcCFsCbHf8X2jGDUA==}
'@playwright/test@1.59.1':
resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==}
engines: {node: '>=18'}
hasBin: true
'@react-leaflet/core@2.1.0':
resolution: {integrity: sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==}
peerDependencies:
leaflet: ^1.9.0
react: ^18.0.0
react-dom: ^18.0.0
'@polymer/polymer@3.5.2':
resolution: {integrity: sha512-fWwImY/UH4bb2534DVSaX+Azs2yKg8slkMBHOyGeU2kKx7Xmxp6Lee0jP8p6B3d7c1gFUPB2Z976dTUtX81pQA==}
'@remix-run/router@1.23.3':
resolution: {integrity: sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==}
engines: {node: '>=14.0.0'}
'@rolldown/pluginutils@1.0.0-beta.27':
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
@@ -589,12 +633,6 @@ packages:
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/geojson@7946.0.16':
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
'@types/leaflet@1.9.21':
resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==}
'@types/prop-types@15.7.15':
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
@@ -606,6 +644,55 @@ packages:
'@types/react@18.3.28':
resolution: {integrity: sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==}
'@types/sortablejs@1.15.9':
resolution: {integrity: sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==}
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
'@vaadin/a11y-base@24.6.11':
resolution: {integrity: sha512-yBZ0QGPngbItIJQx3FRIa9IXDW2Ftf6SFFPGhbdAZafJPBlFi6FElP9cVtL3qjJlI5KKBp/UXEcC8ehPK207gw==}
'@vaadin/checkbox@24.6.11':
resolution: {integrity: sha512-Uvd6gZ3xQQrZTtCJL6f4uLbg6mXsAKjiZto7Je39yJwUHz8r5MIQr+4mLF4zc6mYVSH/Ihj/a4n9FOuTwSEuQw==}
'@vaadin/component-base@24.6.11':
resolution: {integrity: sha512-7jR6vcJeCBgY2CNbAPLOcUTsxYspqdkA0slUGk3GwfgsRDD5FLkzqQDSM5+yE6O2+4Wah2Tk+kG/GsKGtlUlwg==}
'@vaadin/field-base@24.6.11':
resolution: {integrity: sha512-dRjxKzbW3xQAau1xuO8uZepVWaImS2wEyKDK9Oh+y8iiu4smYEmo9e4aqMqQN/sOHU6OSa4YtbyJZlvD1sBXrA==}
'@vaadin/grid@24.6.11':
resolution: {integrity: sha512-10ra384y81iIPwrVCsJwZda4vrdVeDk7SaZSXHe+pM8dVNAvBfmCNomdc9XdC6Q289GHt1AHn/3SaN+G3Wr7FQ==}
'@vaadin/icon@24.6.11':
resolution: {integrity: sha512-CKOh+I84+GZRfMHrhtATtrw3bSW5eUArgGT4cKsOY3asoCZXUdTObPD/PqKfP4e2uAA1bgLl27kOc+W8dmibJA==}
'@vaadin/input-container@24.6.11':
resolution: {integrity: sha512-fT1DK1QDp6VNKaHKkHxuuF3OlbNWbZOtK9IcLs3Q78/9jzhs8gg/nhIbQbyvhIhvjjHncIhzp0vPiw1l7Xxl+Q==}
'@vaadin/lit-renderer@24.6.11':
resolution: {integrity: sha512-JugFumbBQP4r28+HcbdDUVVGs5VRsqanLsifjkVrz/xb4saWv460lEYco5ES+StH+xZ2IuJZmEjEFUBSrVR/tA==}
'@vaadin/text-field@24.6.11':
resolution: {integrity: sha512-pqDPTf5AGwz5CcMfyFmF2215WzwWpjfudKlCje6u2qOcA/9kqBYCTQolemVYCtMDwn0yHXFSp4dU8UasxMCUJA==}
'@vaadin/vaadin-development-mode-detector@2.0.7':
resolution: {integrity: sha512-9FhVhr0ynSR3X2ao+vaIEttcNU5XfzCbxtmYOV8uIRnUCtNgbvMOIcyGBvntsX9I5kvIP2dV3cFAOG9SILJzEA==}
'@vaadin/vaadin-lumo-styles@24.6.11':
resolution: {integrity: sha512-WRluczao8lZgImdtl66v09YjFULb1iLAhcU48aiR9igAT7h6aLeHYBvRH3AA/gBlUNwHd4xlBSl89p4HP2GGog==}
'@vaadin/vaadin-material-styles@24.6.11':
resolution: {integrity: sha512-tDumwlaDp/s9u++MPi64I1o2ls/drWOZf4xVPhztUjt3NwYJUeVXtwu39q0wBRIeRM7UBrs06kug2CVT72U4qQ==}
'@vaadin/vaadin-themable-mixin@24.6.11':
resolution: {integrity: sha512-xCmn3X+2C7nI9LQn2OqLLkLw7VeJOCo99DlHwnxeLZpJJ/s8bjDXcIWflS+IOChzHixgEFkDSoLcNYoCR1RvYg==}
'@vaadin/vaadin-usage-statistics@2.1.3':
resolution: {integrity: sha512-8r4TNknD7OJQADe3VygeofFR7UNAXZ2/jjBFP5dgI8+2uMfnuGYgbuHivasKr9WSQ64sPej6m8rDoM1uSllXjQ==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
'@vitejs/plugin-react@4.7.0':
resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
engines: {node: ^14.18.0 || >=16.0.0}
@@ -627,6 +714,13 @@ packages:
'@vitest/utils@1.6.1':
resolution: {integrity: sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==}
'@webcomponents/shadycss@1.11.2':
resolution: {integrity: sha512-vRq+GniJAYSBmTRnhCYPAPq6THYqovJ/gzGThWbgEZUQaBccndGTi1hdiUP15HzEco0I6t4RCtXyX0rsSmwgPw==}
'@zip.js/zip.js@2.7.73':
resolution: {integrity: sha512-I2UP8/rdQE5hTtVVL08B7P8XuwXiKuuMUPjNuFOVL/9b+8IsExR9S5jz2H58u0rJjU4M1BikLgqEMG8gZJZVBw==}
engines: {bun: '>=0.7.0', deno: '>=1.0.0', node: '>=16.5.0'}
acorn-walk@8.3.5:
resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==}
engines: {node: '>=0.4.0'}
@@ -757,17 +851,41 @@ packages:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
color-convert@3.1.3:
resolution: {integrity: sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==}
engines: {node: '>=14.6'}
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
color-name@2.1.0:
resolution: {integrity: sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==}
engines: {node: '>=12.20'}
color-string@2.1.4:
resolution: {integrity: sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==}
engines: {node: '>=18'}
color@5.0.3:
resolution: {integrity: sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==}
engines: {node: '>=18'}
combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
commander@2.20.3:
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
commander@4.1.1:
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
engines: {node: '>= 6'}
composed-offset-position@0.0.6:
resolution: {integrity: sha512-Q7dLompI6lUwd7LWyIcP66r4WcS9u7AL2h8HaeipiRfCRPLMWqRx8fYsjb4OHi6UQFifO7XtNC2IlEJ1ozIFxw==}
peerDependencies:
'@floating-ui/utils': ^0.2.5
confbox@0.1.8:
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
@@ -786,6 +904,9 @@ packages:
engines: {node: '>=4'}
hasBin: true
cssfilter@0.0.10:
resolution: {integrity: sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw==}
cssstyle@4.6.0:
resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==}
engines: {node: '>=18'}
@@ -925,6 +1046,9 @@ packages:
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
engines: {node: '>= 0.4'}
es-toolkit@1.49.0:
resolution: {integrity: sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==}
esbuild@0.21.5:
resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
engines: {node: '>=12'}
@@ -968,6 +1092,9 @@ packages:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
focus-trap@7.8.0:
resolution: {integrity: sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==}
follow-redirects@1.16.0:
resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==}
engines: {node: '>=4.0'}
@@ -1082,6 +1209,9 @@ packages:
resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
engines: {node: '>=8'}
interactjs@1.10.27:
resolution: {integrity: sha512-y/8RcCftGAF24gSp76X2JS3XpHiUvDQyhF8i7ujemBz77hwiHDuJzftHx7thY8cxGogwGiPJ+o97kWB6eAXnsA==}
internal-slot@1.1.0:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'}
@@ -1212,9 +1342,6 @@ packages:
engines: {node: '>=6'}
hasBin: true
leaflet@1.9.4:
resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==}
lilconfig@3.1.3:
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
engines: {node: '>=14'}
@@ -1222,6 +1349,15 @@ packages:
lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
lit-element@4.2.2:
resolution: {integrity: sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w==}
lit-html@3.3.3:
resolution: {integrity: sha512-el8M6jK2o3RXBnrSHX3ZKrsN8zEV63pSExTO1wYJz7QndGYZ8353e2a5PPX+qHe2aGayfnchQmkAojaWAREOIA==}
lit@3.3.3:
resolution: {integrity: sha512-fycuvZg/hkpozL00lm1pEJH5nN/lr9ZXd6mJI2HSN4+Bzc+LDNdEApJ6HFbPkdFNHLvOplIIuJvxkS4XUxqirw==}
local-pkg@0.5.1:
resolution: {integrity: sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==}
engines: {node: '>=14'}
@@ -1247,6 +1383,10 @@ packages:
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0
luxon@3.5.0:
resolution: {integrity: sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==}
engines: {node: '>=12'}
lz-string@1.5.0:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true
@@ -1254,6 +1394,11 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
marked@15.0.12:
resolution: {integrity: sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==}
engines: {node: '>= 18'}
hasBin: true
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@@ -1493,17 +1638,23 @@ packages:
react-is@18.3.1:
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
react-leaflet@4.2.1:
resolution: {integrity: sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==}
peerDependencies:
leaflet: ^1.9.0
react: ^18.0.0
react-dom: ^18.0.0
react-refresh@0.17.0:
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
engines: {node: '>=0.10.0'}
react-router-dom@6.30.4:
resolution: {integrity: sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==}
engines: {node: '>=14.0.0'}
peerDependencies:
react: '>=16.8'
react-dom: '>=16.8'
react-router@6.30.4:
resolution: {integrity: sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==}
engines: {node: '>=14.0.0'}
peerDependencies:
react: '>=16.8'
react-smooth@4.0.4:
resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==}
peerDependencies:
@@ -1628,6 +1779,9 @@ packages:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
sortablejs@1.15.7:
resolution: {integrity: sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -1669,6 +1823,9 @@ packages:
symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
tabbable@6.5.0:
resolution: {integrity: sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==}
tailwindcss@3.4.19:
resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==}
engines: {node: '>=14.0.0'}
@@ -1681,6 +1838,10 @@ packages:
thenify@3.3.1:
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
timezone-groups@0.10.4:
resolution: {integrity: sha512-AnkJYrbb7uPkDCEqGeVJiawZNiwVlSkkeX4jZg1gTEguClhyX+/Ezn07KB6DT29tG3UN418ldmS/W6KqGOTDjg==}
engines: {node: '>=18.12.0'}
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
@@ -1714,10 +1875,17 @@ packages:
ts-interface-checker@0.1.13:
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
type-detect@4.1.0:
resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==}
engines: {node: '>=4'}
type-fest@4.41.0:
resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==}
engines: {node: '>=16'}
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
@@ -1873,6 +2041,11 @@ packages:
xmlchars@2.2.0:
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
xss@1.0.13:
resolution: {integrity: sha512-clu7dxTm1e8Mo5fz3n/oW3UCXBfV89xZ72jM8yzo1vR/pIS0w3sgB3XV2H8Vm6zfGnHL0FzvLJPJEBhd86/z4Q==}
engines: {node: '>= 0.10.0'}
hasBin: true
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
@@ -1901,6 +2074,17 @@ snapshots:
'@alloc/quick-lru@5.2.0': {}
'@arcgis/lumina@4.34.9(lit@3.3.3)':
dependencies:
'@arcgis/toolkit': 4.34.9
csstype: 3.2.3
lit: 3.3.3
tslib: 2.8.1
'@arcgis/toolkit@4.34.9':
dependencies:
tslib: 2.8.1
'@asamuzakjp/css-color@3.2.0':
dependencies:
'@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
@@ -2112,6 +2296,56 @@ snapshots:
'@esbuild/win32-x64@0.21.5':
optional: true
'@esri/arcgis-html-sanitizer@4.1.0':
dependencies:
xss: 1.0.13
'@esri/calcite-components@3.3.3':
dependencies:
'@arcgis/lumina': 4.34.9(lit@3.3.3)
'@arcgis/toolkit': 4.34.9
'@esri/calcite-ui-icons': 4.3.0
'@floating-ui/dom': 1.8.0
'@floating-ui/utils': 0.2.12
'@types/sortablejs': 1.15.9
color: 5.0.3
composed-offset-position: 0.0.6(@floating-ui/utils@0.2.12)
es-toolkit: 1.49.0
focus-trap: 7.8.0
interactjs: 1.10.27
lit: 3.3.3
sortablejs: 1.15.7
timezone-groups: 0.10.4
type-fest: 4.41.0
transitivePeerDependencies:
- '@lit/context'
'@esri/calcite-ui-icons@4.3.0': {}
'@floating-ui/core@1.8.0':
dependencies:
'@floating-ui/utils': 0.2.12
'@floating-ui/dom@1.8.0':
dependencies:
'@floating-ui/core': 1.8.0
'@floating-ui/utils': 0.2.12
'@floating-ui/utils@0.2.12': {}
'@geoscene/core@4.32.10':
dependencies:
'@esri/arcgis-html-sanitizer': 4.1.0
'@esri/calcite-components': 3.3.3
'@vaadin/grid': 24.6.11
'@zip.js/zip.js': 2.7.73
luxon: 3.5.0
marked: 15.0.12
transitivePeerDependencies:
- '@lit/context'
'@interactjs/types@1.10.27': {}
'@jest/schemas@29.6.3':
dependencies:
'@sinclair/typebox': 0.27.10
@@ -2135,6 +2369,12 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@lit-labs/ssr-dom-shim@1.6.0': {}
'@lit/reactive-element@2.1.2':
dependencies:
'@lit-labs/ssr-dom-shim': 1.6.0
'@nodelib/fs.scandir@2.1.5':
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -2147,15 +2387,17 @@ snapshots:
'@nodelib/fs.scandir': 2.1.5
fastq: 1.20.1
'@open-wc/dedupe-mixin@1.4.0': {}
'@playwright/test@1.59.1':
dependencies:
playwright: 1.59.1
'@react-leaflet/core@2.1.0(leaflet@1.9.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
'@polymer/polymer@3.5.2':
dependencies:
leaflet: 1.9.4
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@webcomponents/shadycss': 1.11.2
'@remix-run/router@1.23.3': {}
'@rolldown/pluginutils@1.0.0-beta.27': {}
@@ -2315,12 +2557,6 @@ snapshots:
'@types/estree@1.0.8': {}
'@types/geojson@7946.0.16': {}
'@types/leaflet@1.9.21':
dependencies:
'@types/geojson': 7946.0.16
'@types/prop-types@15.7.15': {}
'@types/react-dom@18.3.7(@types/react@18.3.28)':
@@ -2332,6 +2568,118 @@ snapshots:
'@types/prop-types': 15.7.15
csstype: 3.2.3
'@types/sortablejs@1.15.9': {}
'@types/trusted-types@2.0.7': {}
'@vaadin/a11y-base@24.6.11':
dependencies:
'@open-wc/dedupe-mixin': 1.4.0
'@polymer/polymer': 3.5.2
'@vaadin/component-base': 24.6.11
lit: 3.3.3
'@vaadin/checkbox@24.6.11':
dependencies:
'@open-wc/dedupe-mixin': 1.4.0
'@polymer/polymer': 3.5.2
'@vaadin/a11y-base': 24.6.11
'@vaadin/component-base': 24.6.11
'@vaadin/field-base': 24.6.11
'@vaadin/vaadin-lumo-styles': 24.6.11
'@vaadin/vaadin-material-styles': 24.6.11
'@vaadin/vaadin-themable-mixin': 24.6.11
lit: 3.3.3
'@vaadin/component-base@24.6.11':
dependencies:
'@open-wc/dedupe-mixin': 1.4.0
'@polymer/polymer': 3.5.2
'@vaadin/vaadin-development-mode-detector': 2.0.7
'@vaadin/vaadin-usage-statistics': 2.1.3
lit: 3.3.3
'@vaadin/field-base@24.6.11':
dependencies:
'@open-wc/dedupe-mixin': 1.4.0
'@polymer/polymer': 3.5.2
'@vaadin/a11y-base': 24.6.11
'@vaadin/component-base': 24.6.11
lit: 3.3.3
'@vaadin/grid@24.6.11':
dependencies:
'@open-wc/dedupe-mixin': 1.4.0
'@polymer/polymer': 3.5.2
'@vaadin/a11y-base': 24.6.11
'@vaadin/checkbox': 24.6.11
'@vaadin/component-base': 24.6.11
'@vaadin/lit-renderer': 24.6.11
'@vaadin/text-field': 24.6.11
'@vaadin/vaadin-lumo-styles': 24.6.11
'@vaadin/vaadin-material-styles': 24.6.11
'@vaadin/vaadin-themable-mixin': 24.6.11
lit: 3.3.3
'@vaadin/icon@24.6.11':
dependencies:
'@open-wc/dedupe-mixin': 1.4.0
'@polymer/polymer': 3.5.2
'@vaadin/component-base': 24.6.11
'@vaadin/vaadin-lumo-styles': 24.6.11
'@vaadin/vaadin-themable-mixin': 24.6.11
lit: 3.3.3
'@vaadin/input-container@24.6.11':
dependencies:
'@polymer/polymer': 3.5.2
'@vaadin/component-base': 24.6.11
'@vaadin/vaadin-lumo-styles': 24.6.11
'@vaadin/vaadin-material-styles': 24.6.11
'@vaadin/vaadin-themable-mixin': 24.6.11
lit: 3.3.3
'@vaadin/lit-renderer@24.6.11':
dependencies:
lit: 3.3.3
'@vaadin/text-field@24.6.11':
dependencies:
'@open-wc/dedupe-mixin': 1.4.0
'@polymer/polymer': 3.5.2
'@vaadin/a11y-base': 24.6.11
'@vaadin/component-base': 24.6.11
'@vaadin/field-base': 24.6.11
'@vaadin/input-container': 24.6.11
'@vaadin/vaadin-lumo-styles': 24.6.11
'@vaadin/vaadin-material-styles': 24.6.11
'@vaadin/vaadin-themable-mixin': 24.6.11
lit: 3.3.3
'@vaadin/vaadin-development-mode-detector@2.0.7': {}
'@vaadin/vaadin-lumo-styles@24.6.11':
dependencies:
'@polymer/polymer': 3.5.2
'@vaadin/component-base': 24.6.11
'@vaadin/icon': 24.6.11
'@vaadin/vaadin-themable-mixin': 24.6.11
'@vaadin/vaadin-material-styles@24.6.11':
dependencies:
'@polymer/polymer': 3.5.2
'@vaadin/component-base': 24.6.11
'@vaadin/vaadin-themable-mixin': 24.6.11
'@vaadin/vaadin-themable-mixin@24.6.11':
dependencies:
'@open-wc/dedupe-mixin': 1.4.0
lit: 3.3.3
'@vaadin/vaadin-usage-statistics@2.1.3':
dependencies:
'@vaadin/vaadin-development-mode-detector': 2.0.7
'@vitejs/plugin-react@4.7.0(vite@5.4.21)':
dependencies:
'@babel/core': 7.29.0
@@ -2373,6 +2721,10 @@ snapshots:
loupe: 2.3.7
pretty-format: 29.7.0
'@webcomponents/shadycss@1.11.2': {}
'@zip.js/zip.js@2.7.73': {}
acorn-walk@8.3.5:
dependencies:
acorn: 8.16.0
@@ -2510,14 +2862,35 @@ snapshots:
dependencies:
color-name: 1.1.4
color-convert@3.1.3:
dependencies:
color-name: 2.1.0
color-name@1.1.4: {}
color-name@2.1.0: {}
color-string@2.1.4:
dependencies:
color-name: 2.1.0
color@5.0.3:
dependencies:
color-convert: 3.1.3
color-string: 2.1.4
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
commander@2.20.3: {}
commander@4.1.1: {}
composed-offset-position@0.0.6(@floating-ui/utils@0.2.12):
dependencies:
'@floating-ui/utils': 0.2.12
confbox@0.1.8: {}
convert-source-map@2.0.0: {}
@@ -2532,6 +2905,8 @@ snapshots:
cssesc@3.0.0: {}
cssfilter@0.0.10: {}
cssstyle@4.6.0:
dependencies:
'@asamuzakjp/css-color': 3.2.0
@@ -2681,6 +3056,8 @@ snapshots:
has-tostringtag: 1.0.2
hasown: 2.0.3
es-toolkit@1.49.0: {}
esbuild@0.21.5:
optionalDependencies:
'@esbuild/aix-ppc64': 0.21.5
@@ -2749,6 +3126,10 @@ snapshots:
dependencies:
to-regex-range: 5.0.1
focus-trap@7.8.0:
dependencies:
tabbable: 6.5.0
follow-redirects@1.16.0: {}
for-each@0.3.5:
@@ -2853,6 +3234,10 @@ snapshots:
indent-string@4.0.0: {}
interactjs@1.10.27:
dependencies:
'@interactjs/types': 1.10.27
internal-slot@1.1.0:
dependencies:
es-errors: 1.3.0
@@ -2988,12 +3373,26 @@ snapshots:
json5@2.2.3: {}
leaflet@1.9.4: {}
lilconfig@3.1.3: {}
lines-and-columns@1.2.4: {}
lit-element@4.2.2:
dependencies:
'@lit-labs/ssr-dom-shim': 1.6.0
'@lit/reactive-element': 2.1.2
lit-html: 3.3.3
lit-html@3.3.3:
dependencies:
'@types/trusted-types': 2.0.7
lit@3.3.3:
dependencies:
'@lit/reactive-element': 2.1.2
lit-element: 4.2.2
lit-html: 3.3.3
local-pkg@0.5.1:
dependencies:
mlly: 1.8.2
@@ -3019,12 +3418,16 @@ snapshots:
dependencies:
react: 18.3.1
luxon@3.5.0: {}
lz-string@1.5.0: {}
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
marked@15.0.12: {}
math-intrinsics@1.1.0: {}
merge-stream@2.0.0: {}
@@ -3224,14 +3627,19 @@ snapshots:
react-is@18.3.1: {}
react-leaflet@4.2.1(leaflet@1.9.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
react-refresh@0.17.0: {}
react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@react-leaflet/core': 2.1.0(leaflet@1.9.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
leaflet: 1.9.4
'@remix-run/router': 1.23.3
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react-router: 6.30.4(react@18.3.1)
react-refresh@0.17.0: {}
react-router@6.30.4(react@18.3.1):
dependencies:
'@remix-run/router': 1.23.3
react: 18.3.1
react-smooth@4.0.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
@@ -3415,6 +3823,8 @@ snapshots:
signal-exit@4.1.0: {}
sortablejs@1.15.7: {}
source-map-js@1.2.1: {}
stackback@0.0.2: {}
@@ -3454,6 +3864,8 @@ snapshots:
symbol-tree@3.2.4: {}
tabbable@6.5.0: {}
tailwindcss@3.4.19:
dependencies:
'@alloc/quick-lru': 5.2.0
@@ -3490,6 +3902,8 @@ snapshots:
dependencies:
any-promise: 1.3.0
timezone-groups@0.10.4: {}
tiny-invariant@1.3.3: {}
tinybench@2.9.0: {}
@@ -3520,8 +3934,12 @@ snapshots:
ts-interface-checker@0.1.13: {}
tslib@2.8.1: {}
type-detect@4.1.0: {}
type-fest@4.41.0: {}
typescript@5.9.3: {}
ufo@1.6.4: {}
@@ -3679,6 +4097,11 @@ snapshots:
xmlchars@2.2.0: {}
xss@1.0.13:
dependencies:
commander: 2.20.3
cssfilter: 0.0.10
yallist@3.1.1: {}
yocto-queue@1.2.2: {}

Binary file not shown.

View File

@@ -1,16 +1,9 @@
import { useEffect, useState, Component, ReactNode, Suspense, lazy, useCallback } from 'react';
import { TopNav } from '@/components/TopNav';
import { SideNav } from '@/components/SideNav';
import { useEffect, useState, Component, ReactNode, useCallback } from 'react';
import { BrowserRouter, Routes, Route, useRoutes } from 'react-router-dom';
import { AppShell } from '@/components/AppShell';
import { useRiskStore } from '@/stores';
import { Login } from '@/pages/Login';
const MonitoringDashboard = lazy(() => import('@/pages/MonitoringDashboard').then(m => ({ default: m.MonitoringDashboard })));
const AlertsDashboard = lazy(() => import('@/pages/AlertsDashboard').then(m => ({ default: m.AlertsDashboard })));
const TrendAnalysis = lazy(() => import('@/pages/TrendAnalysis').then(m => ({ default: m.TrendAnalysis })));
const DistrictComparison = lazy(() => import('@/pages/DistrictComparison').then(m => ({ default: m.DistrictComparison })));
const Insights = lazy(() => import('@/pages/Insights').then(m => ({ default: m.Insights })));
const ReportsCenter = lazy(() => import('@/pages/ReportsCenter').then(m => ({ default: m.ReportsCenter })));
import { appRoutes } from '@/routes';
interface Props {
children: ReactNode;
@@ -52,28 +45,25 @@ class ErrorBoundary extends Component<Props, State> {
}
}
function PageLoader() {
return (
<div className="flex items-center justify-center h-[60vh]">
<div className="text-text-secondary text-[13px]">...</div>
</div>
);
// 已登录AppShell 提供布局骨架,子路由表渲染到其 <Outlet/>。
function AuthedApp({ onLogout }: { onLogout: () => void }) {
const element = useRoutes([
{
element: <AppShell onLogout={onLogout} />,
children: appRoutes,
},
]);
return element;
}
function App() {
const [activePage, setActivePage] = useState('monitoring');
const [token, setToken] = useState<string | null>(() => localStorage.getItem('cbpoa_token'));
const alerts = useRiskStore((s) => s.alerts);
const fetchAlerts = useRiskStore((s) => s.fetchAlerts);
useEffect(() => {
if (token) fetchAlerts();
}, [fetchAlerts, token]);
const handlePageChange = useCallback((page: string) => {
setActivePage(page);
}, []);
const handleLogin = useCallback((newToken: string) => {
setToken(newToken);
}, []);
@@ -83,38 +73,23 @@ function App() {
setToken(null);
}, []);
if (!token) {
return (
<ErrorBoundary>
<Login onLogin={handleLogin} />
</ErrorBoundary>
);
}
return (
<ErrorBoundary>
<div className="min-h-screen bg-bg-page">
<TopNav onLogout={handleLogout} />
<div className="flex pt-[52px]">
<SideNav
activePage={activePage}
onPageChange={handlePageChange}
alertCount={alerts.length}
/>
<main className="flex-1 ml-[200px] p-5">
<Suspense fallback={<PageLoader />}>
{activePage === 'monitoring' && <MonitoringDashboard />}
{activePage === 'alerts' && <AlertsDashboard />}
{activePage === 'trend-analysis' && <TrendAnalysis />}
{activePage === 'district-comparison' && <DistrictComparison />}
{activePage === 'insights' && <Insights />}
{activePage === 'reports' && <ReportsCenter />}
</Suspense>
</main>
</div>
</div>
<BrowserRouter
future={{
v7_startTransition: true,
v7_relativeSplatPath: true,
}}
>
{token ? (
<AuthedApp onLogout={handleLogout} />
) : (
// 鉴权门:无 token 时所有路由都进入登录页。
<Routes>
<Route path="*" element={<Login onLogin={handleLogin} />} />
</Routes>
)}
</BrowserRouter>
</ErrorBoundary>
);
}

View File

@@ -7,82 +7,82 @@ import { describe, it, expect } from 'vitest';
describe('Component exports', () => {
it('TopNav 可以被导入', async () => {
const mod = await import('@/components/TopNav');
expect(mod.default || mod.TopNav).toBeDefined();
expect((mod as any).default || mod.TopNav).toBeDefined();
});
it('SideNav 可以被导入', async () => {
const mod = await import('@/components/SideNav');
expect(mod.default || mod.SideNav).toBeDefined();
expect((mod as any).default || mod.SideNav).toBeDefined();
});
it('StatCard 可以被导入', async () => {
const mod = await import('@/components/StatCard');
expect(mod.default || mod.StatCard).toBeDefined();
expect((mod as any).default || mod.StatCard).toBeDefined();
});
it('ErrorBanner 可以被导入', async () => {
const mod = await import('@/components/ErrorBanner');
expect(mod.default || mod.ErrorBanner).toBeDefined();
expect((mod as any).default || mod.ErrorBanner).toBeDefined();
});
it('DiseaseFilter 可以被导入', async () => {
const mod = await import('@/components/DiseaseFilter');
expect(mod.default || mod.DiseaseFilter).toBeDefined();
});
it('ChatBot 可以被导入', async () => {
const mod = await import('@/components/ChatBot');
expect(mod.default || mod.ChatBot).toBeDefined();
expect((mod as any).default || mod.DiseaseFilter).toBeDefined();
});
it('TimelinePlayer 可以被导入', async () => {
const mod = await import('@/components/TimelinePlayer');
expect(mod.default || mod.TimelinePlayer).toBeDefined();
expect((mod as any).default || mod.TimelinePlayer).toBeDefined();
});
it('StatisticalCharts 可以被导入', async () => {
const mod = await import('@/components/StatisticalCharts');
expect(mod.default || mod.StatisticalCharts).toBeDefined();
});
it('RiskMap 可以被导入', async () => {
const mod = await import('@/components/RiskMap');
expect(mod.default || mod.RiskMap).toBeDefined();
expect((mod as any).default || mod.StatisticalCharts).toBeDefined();
});
it('AlertMap 可以被导入', async () => {
const mod = await import('@/components/AlertMap');
expect(mod.default || mod.AlertMap).toBeDefined();
expect((mod as any).default || mod.AlertMap).toBeDefined();
});
it('CaseLocationMap 可以被导入', async () => {
const mod = await import('@/components/CaseLocationMap');
expect(mod.default || mod.CaseLocationMap).toBeDefined();
expect((mod as any).default || mod.CaseLocationMap).toBeDefined();
});
it('CaseMap 可以被导入', async () => {
const mod = await import('@/components/CaseMap');
expect(mod.default || mod.CaseMap).toBeDefined();
it('geoscene createMapView 可以被导入', async () => {
const mod = await import('@/geoscene');
expect(mod.createMapView).toBeDefined();
});
it('DistributionChart 可以被导入', async () => {
const mod = await import('@/components/DistributionChart');
expect(mod.default || mod.DistributionChart).toBeDefined();
expect((mod as any).default || mod.DistributionChart).toBeDefined();
});
it('GridStatsOverlay 可以被导入', async () => {
const mod = await import('@/components/GridStatsOverlay');
expect(mod.default || mod.GridStatsOverlay).toBeDefined();
});
it('LodGridLayer 可以被导入', async () => {
const mod = await import('@/components/LodGridLayer');
expect(mod.default || mod.LodGridLayer).toBeDefined();
expect((mod as any).default || mod.GridStatsOverlay).toBeDefined();
});
it('AdminBreadcrumb 可以被导入', async () => {
const mod = await import('@/components/AdminBreadcrumb');
expect(mod.default || mod.AdminBreadcrumb).toBeDefined();
expect((mod as any).default || mod.AdminBreadcrumb).toBeDefined();
});
it('CalendarHeatmap 可以被导入', async () => {
const mod = await import('@/components/CalendarHeatmap');
expect((mod as any).default || mod.CalendarHeatmap).toBeDefined();
});
it('MetricHeatmapTable 可以被导入', async () => {
const mod = await import('@/components/MetricHeatmapTable');
expect((mod as any).default || mod.MetricHeatmapTable).toBeDefined();
});
it('AnomalyMarkers 可以被导入', async () => {
const mod = await import('@/components/AnomalyMarkers');
expect((mod as any).default || mod.AnomalyMarkers).toBeDefined();
});
});

View File

@@ -1,15 +1,27 @@
import { useEffect, useRef, useState, useCallback, memo } from 'react';
import L from 'leaflet';
import { useRiskStore } from '@/stores';
import { LodGridLayer } from '@/components/LodGridLayer';
import type MapView from '@geoscene/core/views/MapView';
import type WebTileLayer from '@geoscene/core/layers/WebTileLayer';
import type GraphicsLayer from '@geoscene/core/layers/GraphicsLayer';
import Graphic from '@geoscene/core/Graphic';
import Point from '@geoscene/core/geometry/Point';
import Polygon from '@geoscene/core/geometry/Polygon';
import SimpleFillSymbol from '@geoscene/core/symbols/SimpleFillSymbol';
import * as reactiveUtils from '@geoscene/core/core/reactiveUtils';
import { GridStatsOverlay } from '@/components/GridStatsOverlay';
import { useLodGrid } from '@/hooks/useLodGrid';
import type { Alert, GridRisk } from '@/types';
import { riskApi } from '@/services/api';
import type { RiskGridStats } from '@/services/api';
import type { Alert } from '@/types';
import { createMapView } from '@/geoscene/createMapView';
import { createRiskTileLayer, createGraphicsLayer, pointGraphic } from '@/geoscene/layers';
export interface CellInfo {
lat: number;
lon: number;
risk: number;
grid_id: string;
risk_1d: number;
risk_3d: number;
risk_7d: number;
nearestAlertId: string | null;
nearestAlertDist: number;
}
@@ -22,16 +34,14 @@ interface AlertMapProps {
showAlertMarkers?: boolean;
showGrid?: boolean;
filteredAlerts?: Alert[];
riskRange?: [number, number];
isFullscreen?: boolean;
}
const WUHAN_CENTER: [number, number] = [30.59, 114.31];
const GRID_OPACITY = 0.72;
const RISK_COLORS: [number, number, string][] = [
[0.0, 0.2, '#22c55e'],
[0.2, 0.4, '#3b82f6'],
[0.4, 0.6, '#eab308'],
const RISK_LEGEND: [number, number, string][] = [
[0.25, 0.4, '#38b000'],
[0.4, 0.6, '#facc15'],
[0.6, 0.8, '#f97316'],
[0.8, 1.0, '#ef4444'],
];
@@ -40,12 +50,9 @@ function getRiskLabel(value: number): string {
if (value >= 0.8) return '高风险';
if (value >= 0.6) return '中高';
if (value >= 0.4) return '中风险';
if (value >= 0.2) return '中低';
return '低风险';
}
const EMPTY_GRIDS: GridRisk[] = [];
function AlertMapComponent({
selectedGridId,
onGridClick,
@@ -54,243 +61,314 @@ function AlertMapComponent({
showAlertMarkers = true,
showGrid = true,
filteredAlerts = [],
riskRange,
isFullscreen = false,
}: AlertMapProps) {
const mapRef = useRef<HTMLDivElement>(null);
const mapInstanceRef = useRef<L.Map | null>(null);
const alertLayerRef = useRef<L.LayerGroup | null>(null);
const selectedMarkerRef = useRef<L.Rectangle | null>(null);
const viewRef = useRef<MapView | null>(null);
const destroyRef = useRef<(() => void) | null>(null);
const riskLayerRef = useRef<WebTileLayer | null>(null);
const alertLayerRef = useRef<GraphicsLayer | null>(null);
const selectLayerRef = useRef<GraphicsLayer | null>(null);
const clickHandlerRef = useRef(onGridClick);
const [currentZoom, setCurrentZoom] = useState(10);
const cellInfoRef = useRef(onCellInfo);
const inputsRef = useRef({ filteredAlerts, showAlertMarkers, forecastDay });
const grids = useRiskStore((s) => s.grids ?? EMPTY_GRIDS);
// LOD grid data for stats overlay
const { count, avgRisk, maxRisk, loading } = useLodGrid(currentZoom, forecastDay);
const [gridStats, setGridStats] = useState<RiskGridStats | null>(null);
const [statsLoading, setStatsLoading] = useState(false);
const [mapReady, setMapReady] = useState(false);
useEffect(() => {
clickHandlerRef.current = onGridClick;
}, [onGridClick]);
// Initialize map
useEffect(() => {
if (!mapRef.current || mapInstanceRef.current) return;
cellInfoRef.current = onCellInfo;
}, [onCellInfo]);
useEffect(() => {
inputsRef.current = { filteredAlerts, showAlertMarkers, forecastDay };
}, [filteredAlerts, showAlertMarkers, forecastDay]);
const map = L.map(mapRef.current, {
center: WUHAN_CENTER,
zoom: 9,
zoomControl: true,
preferCanvas: true,
useEffect(() => {
if (!mapRef.current || viewRef.current) return;
const { map, view, destroy } = createMapView({
container: mapRef.current,
zoom: 10,
});
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
maxZoom: 19,
}).addTo(map);
const riskTiles = createRiskTileLayer(forecastDay, showGrid ? GRID_OPACITY : 0);
const alertLayer = createGraphicsLayer('预警点');
const selectLayer = createGraphicsLayer('选中');
map.on('zoomend', () => {
setCurrentZoom(map.getZoom());
});
map.addMany([riskTiles, alertLayer, selectLayer]);
mapInstanceRef.current = map;
riskLayerRef.current = riskTiles;
alertLayerRef.current = alertLayer;
selectLayerRef.current = selectLayer;
viewRef.current = view;
destroyRef.current = destroy;
return () => {
map.remove();
mapInstanceRef.current = null;
};
}, []);
// Render alert markers overlay
const renderAlertMarkers = useCallback(() => {
const map = mapInstanceRef.current;
if (!map) return;
if (alertLayerRef.current) {
try { map.removeLayer(alertLayerRef.current); } catch { /* ok */ }
alertLayerRef.current = null;
try {
view.ui.move('zoom', 'bottom-left');
} catch {
/* ignore */
}
if (!showAlertMarkers || !filteredAlerts || filteredAlerts.length === 0) return;
view.when(() => setMapReady(true)).catch(() => setMapReady(true));
const layer = L.layerGroup();
const mapBounds = map.getBounds();
const maxMarkers = 500;
const step = Math.max(1, Math.floor(filteredAlerts.length / maxMarkers));
for (let i = 0; i < filteredAlerts.length; i += step) {
const alert = filteredAlerts[i];
if (!alert.latitude || !alert.longitude) continue;
// Skip if outside viewport
if (
alert.latitude < mapBounds.getSouth() ||
alert.latitude > mapBounds.getNorth() ||
alert.longitude < mapBounds.getWest() ||
alert.longitude > mapBounds.getEast()
) {
continue;
}
const isP1 = alert.priority === 'P1';
const marker = L.circleMarker(
[alert.latitude, alert.longitude],
{
radius: isP1 ? 6 : 4,
fillColor: isP1 ? '#ef4444' : '#f97316',
fillOpacity: 0.7,
color: isP1 ? '#ef4444' : '#f97316',
weight: 2,
dashArray: isP1 ? undefined : '4 2',
}
);
marker.bindTooltip(
`<div style="font-size:12px;">
<strong>${alert.priority}</strong> · ${(alert.risk_value * 100).toFixed(0)}%<br/>
${alert.region || ''} ${alert.street || ''}
</div>`,
{ direction: 'top', offset: [0, -5] }
);
marker.on('click', () => {
if (alert.grid_id) clickHandlerRef.current(alert.grid_id);
});
marker.addTo(layer);
}
layer.addTo(map);
alertLayerRef.current = layer;
}, [filteredAlerts, showAlertMarkers]);
// Re-render alert markers when data changes
useEffect(() => {
renderAlertMarkers();
}, [renderAlertMarkers]);
// Also re-render on map zoom/pan
useEffect(() => {
const map = mapInstanceRef.current;
if (!map) return;
const handleMove = () => renderAlertMarkers();
map.on('moveend', handleMove);
return () => { map.off('moveend', handleMove); };
}, [renderAlertMarkers]);
// Selected grid highlight
useEffect(() => {
const map = mapInstanceRef.current;
if (!map) return;
if (selectedMarkerRef.current) {
try { map.removeLayer(selectedMarkerRef.current); } catch { /* ok */ }
selectedMarkerRef.current = null;
}
if (selectedGridId) {
let grid = grids.find((g) => g.grid_id === selectedGridId);
if (!grid) {
const selectedAlertObj = filteredAlerts.find((a) => a.grid_id === selectedGridId);
if (selectedAlertObj) {
grid = grids.find((g) =>
Math.abs(g.latitude - selectedAlertObj.latitude) < 0.001 &&
Math.abs(g.longitude - selectedAlertObj.longitude) < 0.001
);
}
}
if (grid) {
const latHalf = 0.00045;
const lonHalf = 0.00052;
const marker = L.rectangle(
[
[grid.latitude - latHalf, grid.longitude - lonHalf],
[grid.latitude + latHalf, grid.longitude + lonHalf],
],
{
fillColor: '#3b82f6',
fillOpacity: 0.3,
color: '#3b82f6',
weight: 3,
}
).addTo(map);
selectedMarkerRef.current = marker;
map.flyTo([grid.latitude, grid.longitude], Math.max(map.getZoom(), 12), { duration: 0.5 });
}
}
}, [selectedGridId, grids]);
// Handle LOD grid cell click → find nearest alert
const handleCellClick = useCallback(
(lat: number, lon: number, risk: number) => {
let nearestId: string | null = null;
let minDist = Infinity;
if (filteredAlerts) {
for (const a of filteredAlerts) {
const d = Math.sqrt((a.latitude - lat) ** 2 + (a.longitude - lon) ** 2);
if (d < minDist) {
minDist = d;
const clickHandle = view.on('click', async (event) => {
if (!event.mapPoint) return;
const lat = event.mapPoint.latitude;
const lng = event.mapPoint.longitude;
if (lat == null || lng == null) return;
const { filteredAlerts: alerts, forecastDay: day } = inputsRef.current;
try {
const cell = await riskApi.getCell(lat, lng, day);
let nearestId: string | null = null;
let minSq = Infinity;
for (const a of alerts) {
const dx = a.latitude - lat;
const dy = a.longitude - lng;
const d = dx * dx + dy * dy;
if (d < minSq) {
minSq = d;
nearestId = a.grid_id;
}
}
const nearestDist = Math.sqrt(minSq);
if (nearestId && nearestDist < 0.01) {
clickHandlerRef.current(nearestId);
} else if (cellInfoRef.current) {
cellInfoRef.current({
lat,
lon: lng,
risk: cell.risk_value,
grid_id: cell.grid_id,
risk_1d: cell.risk_1d,
risk_3d: cell.risk_3d,
risk_7d: cell.risk_7d,
nearestAlertId: nearestId,
nearestAlertDist: nearestDist,
});
}
} catch {
/* ignore transient click errors */
}
});
if (nearestId && minDist < 0.01) {
clickHandlerRef.current(nearestId);
} else if (onCellInfo) {
onCellInfo({ lat, lon, risk, nearestAlertId: nearestId, nearestAlertDist: minDist });
}
},
[filteredAlerts, onCellInfo]
);
return () => {
clickHandle.remove();
riskLayerRef.current = null;
alertLayerRef.current = null;
selectLayerRef.current = null;
viewRef.current = null;
destroy();
destroyRef.current = null;
setMapReady(false);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Invalidate Leaflet size after fullscreen toggle
useEffect(() => {
const map = mapInstanceRef.current;
if (!map) return;
const timer = setTimeout(() => map.invalidateSize({ animate: true }), 100);
const map = viewRef.current?.map;
if (!map || !mapReady) return;
if (riskLayerRef.current) {
map.remove(riskLayerRef.current);
riskLayerRef.current.destroy();
}
const next = createRiskTileLayer(forecastDay, showGrid ? GRID_OPACITY : 0);
map.add(next);
riskLayerRef.current = next;
let cancelled = false;
setStatsLoading(true);
riskApi
.getGridStats(forecastDay)
.then((s) => {
if (!cancelled) setGridStats(s);
})
.catch(() => {
if (!cancelled) setGridStats(null);
})
.finally(() => {
if (!cancelled) setStatsLoading(false);
});
return () => {
cancelled = true;
};
}, [forecastDay, mapReady]);
useEffect(() => {
if (riskLayerRef.current) {
riskLayerRef.current.opacity = showGrid ? GRID_OPACITY : 0;
}
}, [showGrid]);
const renderAlertMarkers = useCallback(() => {
const layer = alertLayerRef.current;
const view = viewRef.current;
if (!layer || !view) return;
layer.removeAll();
const { filteredAlerts: alerts, showAlertMarkers: showMarkers } = inputsRef.current;
if (!showMarkers || !alerts?.length) return;
const extent = view.extent;
const maxMarkers = 500;
const step = Math.max(1, Math.floor(alerts.length / maxMarkers));
const graphics: Graphic[] = [];
for (let i = 0; i < alerts.length; i += step) {
const alert = alerts[i];
if (!alert.latitude || !alert.longitude) continue;
if (extent) {
if (
alert.longitude < extent.xmin ||
alert.longitude > extent.xmax ||
alert.latitude < extent.ymin ||
alert.latitude > extent.ymax
) {
continue;
}
}
const isP1 = alert.priority === 'P1';
const g = pointGraphic(
alert.longitude,
alert.latitude,
isP1 ? '#ef4444' : '#f97316',
isP1 ? 10 : 7,
{
grid_id: alert.grid_id,
priority: alert.priority,
risk_value: alert.risk_value,
region: alert.region,
street: alert.street,
}
);
g.popupTemplate = {
title: '{priority}',
content: '{region} {street}<br/>风险 {(risk_value * 100).toFixed(0)}%',
};
graphics.push(g);
}
layer.addMany(graphics);
}, []);
useEffect(() => {
if (!mapReady) return;
renderAlertMarkers();
}, [filteredAlerts, showAlertMarkers, mapReady, renderAlertMarkers]);
useEffect(() => {
const view = viewRef.current;
if (!view || !mapReady) return;
let throttle: ReturnType<typeof setTimeout> | null = null;
const handle = reactiveUtils.watch(
() => view.extent,
() => {
if (throttle) return;
throttle = setTimeout(() => {
throttle = null;
renderAlertMarkers();
}, 150);
}
);
return () => {
handle.remove();
if (throttle) clearTimeout(throttle);
};
}, [mapReady, renderAlertMarkers]);
useEffect(() => {
const layer = selectLayerRef.current;
const view = viewRef.current;
if (!layer || !view) return;
layer.removeAll();
if (!selectedGridId) return;
const alert = filteredAlerts.find((a) => a.grid_id === selectedGridId);
if (!alert) return;
const latHalf = 0.00045;
const lonHalf = 0.00052;
const ring = [
[alert.longitude - lonHalf, alert.latitude - latHalf],
[alert.longitude + lonHalf, alert.latitude - latHalf],
[alert.longitude + lonHalf, alert.latitude + latHalf],
[alert.longitude - lonHalf, alert.latitude + latHalf],
[alert.longitude - lonHalf, alert.latitude - latHalf],
];
layer.add(
new Graphic({
geometry: new Polygon({ rings: [ring], spatialReference: { wkid: 4326 } }),
symbol: new SimpleFillSymbol({
color: [59, 130, 246, 0.3],
outline: { color: [59, 130, 246], width: 2 },
}),
})
);
view.goTo(
{
center: new Point({ longitude: alert.longitude, latitude: alert.latitude }),
zoom: Math.max(view.zoom, 13),
},
{ duration: 500 }
).catch(() => undefined);
}, [selectedGridId, filteredAlerts]);
useEffect(() => {
// MapView observes container size; force a layout tick after fullscreen CSS settles.
const timer = setTimeout(() => {
const el = mapRef.current;
if (el) {
el.style.height = el.style.height;
}
}, 200);
return () => clearTimeout(timer);
}, [isFullscreen]);
const containerHeight = isFullscreen ? 'calc(100vh - 120px)' : 'calc(100vh - 280px)';
// 工作台零内边距后,给地图更多垂直空间(非小卡片)
const containerHeight = isFullscreen ? 'calc(100vh - 100px)' : 'calc(100vh - 220px)';
return (
<div className="relative">
<div ref={mapRef} className="w-full rounded-lg overflow-hidden" style={{ height: containerHeight }} />
{/* LOD Grid Layer */}
<LodGridLayer
map={mapInstanceRef.current}
forecastDay={forecastDay}
visible={showGrid}
riskRange={riskRange}
onCellClick={handleCellClick}
<div
ref={mapRef}
className="w-full rounded-lg overflow-hidden bg-slate-100"
style={{ height: containerHeight }}
/>
{!mapReady && (
<div className="absolute inset-0 flex items-center justify-center bg-bg-card/70 rounded-lg text-[13px] text-text-muted">
</div>
)}
{/* Stats overlay */}
<GridStatsOverlay
count={count}
avgRisk={avgRisk}
maxRisk={maxRisk}
loading={loading}
count={gridStats?.cell_count ?? 0}
avgRisk={gridStats?.avg_risk ?? 0}
maxRisk={gridStats?.max_risk ?? 0}
loading={statsLoading}
forecastDay={forecastDay}
/>
{/* Legend */}
<div className="absolute bottom-4 right-4 bg-bg-card/90 backdrop-blur rounded-lg border border-border-light shadow-sm z-[1000] px-4 py-3">
<div className="text-[11px] font-semibold text-text-secondary mb-2"></div>
<div className="text-[11px] font-semibold text-text-secondary mb-2"> (100m )</div>
<div className="space-y-1.5">
{RISK_COLORS.slice().reverse().map(([min, max, color]) => (
<div key={color} className="flex items-center gap-2">
<div className="w-4 h-4 rounded" style={{ backgroundColor: color }} />
<span className="text-[11px] text-text-secondary">
{getRiskLabel((min + max) / 2)} ({(min * 100).toFixed(0)}-{(max * 100).toFixed(0)}%)
</span>
</div>
))}
{RISK_LEGEND.slice()
.reverse()
.map(([min, max, color]) => (
<div key={color} className="flex items-center gap-2">
<div className="w-4 h-4 rounded" style={{ backgroundColor: color }} />
<span className="text-[11px] text-text-secondary">
{getRiskLabel((min + max) / 2)} ({(min * 100).toFixed(0)}-{(max * 100).toFixed(0)}%)
</span>
</div>
))}
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded border border-border-light bg-transparent" />
<span className="text-[11px] text-text-muted">&lt;25% </span>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,76 @@
import React from 'react';
interface AnomalyMarkersProps {
anomalies: Array<{ date: string; value: number; description: string }>;
}
export const AnomalyMarkers = React.memo(function AnomalyMarkers({
anomalies,
}: AnomalyMarkersProps) {
if (anomalies.length === 0) return null;
return (
<div className="absolute inset-0 pointer-events-none" aria-hidden>
{anomalies.map((a, i) => {
// Parse date to position marker horizontally
// This requires the parent to position relative; markers are
// positioned via CSS custom properties set by the consumer.
// For a data-driven overlay, we expose the anomalies as a
// data list that the chart-library integration uses.
return (
<div
key={`${a.date}-${i}`}
data-anomaly-date={a.date}
data-anomaly-value={a.value}
title={`${a.date}: ${a.value}${a.description}`}
className="absolute w-2.5 h-2.5 rounded-full bg-red-500 border border-red-300 pointer-events-auto cursor-help"
style={{
// Positioned via CSS custom properties set by parent
left: `var(--anomaly-x-${i})`,
top: `var(--anomaly-y-${i})`,
transform: 'translate(-50%, -50%)',
}}
/>
);
})}
</div>
);
});
/**
* Helper to compute anomaly positions as percentages within a chart area.
*
* Usage inside a Recharts chart component:
* - Import { computeAnomalyPositions } from './AnomalyMarkers'
* - Call with bounds: computeAnomalyPositions(chartData, anomalies, dateKey, valueKey, xDomain, yDomain)
* - Apply returned style vars on parent container
*/
export function computeAnomalyPositions(
allDates: string[],
anomalies: Array<{ date: string; value: number }>,
xMin: number,
xMax: number,
yMin: number,
yMax: number,
): Record<string, string> {
const vars: Record<string, string> = {};
const xRange = xMax - xMin || 1;
const yRange = yMax - yMin || 1;
// Build a date→index map once for O(1) lookups instead of indexOf per anomaly.
const dateIndex = new Map<string, number>();
for (let i = 0; i < allDates.length; i++) {
if (!dateIndex.has(allDates[i])) dateIndex.set(allDates[i], i);
}
for (let i = 0; i < anomalies.length; i++) {
const a = anomalies[i];
const dateIdx = dateIndex.get(a.date) ?? -1;
if (dateIdx === -1) continue;
const xPct = ((dateIdx - xMin) / xRange) * 100;
const yPct = 100 - ((a.value - yMin) / yRange) * 100;
vars[`--anomaly-x-${i}`] = `${xPct}%`;
vars[`--anomaly-y-${i}`] = `${yPct}%`;
}
return vars;
}

View File

@@ -0,0 +1,155 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import { Outlet, useLocation } from 'react-router-dom';
import { TopNav } from '@/components/TopNav';
import { SideNav } from '@/components/SideNav';
import { RouteErrorBoundary } from '@/components/RouteErrorBoundary';
import { useRiskStore } from '@/stores';
import { TESTIDS } from '@/utils/testids';
interface AppShellProps {
onLogout?: () => void;
}
function getFocusable(container: HTMLElement): HTMLElement[] {
return Array.from(
container.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])'
)
).filter((el) => el.offsetParent !== null || el === document.activeElement);
}
/** 监测 / 预警:地图工作台,主区零内边距、禁止外层滚动,把高度留给地图。 */
function isMapWorkbench(pathname: string): boolean {
return pathname.startsWith('/monitoring') || pathname.startsWith('/alerts');
}
export function AppShell({ onLogout }: AppShellProps) {
const alerts = useRiskStore((s) => s.alerts);
const location = useLocation();
const mapWorkbench = isMapWorkbench(location.pathname);
const [drawerOpen, setDrawerOpen] = useState(false);
const [expandedNav, setExpandedNav] = useState<string | null>('monitoring');
const drawerRef = useRef<HTMLElement>(null);
const openDrawer = useCallback(() => setDrawerOpen(true), []);
const closeDrawer = useCallback(() => setDrawerOpen(false), []);
useEffect(() => {
if (!drawerOpen) return;
const opener = document.activeElement as HTMLElement | null;
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const drawer = drawerRef.current;
const focusables = drawer ? getFocusable(drawer) : [];
(focusables[0] ?? drawer)?.focus();
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
closeDrawer();
return;
}
if (e.key === 'Tab' && drawer) {
const items = getFocusable(drawer);
if (items.length === 0) {
e.preventDefault();
drawer.focus();
return;
}
const first = items[0];
const last = items[items.length - 1];
const active = document.activeElement;
if (e.shiftKey && (active === first || active === drawer)) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
}
}
};
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('keydown', onKeyDown);
document.body.style.overflow = prevOverflow;
const restoreTarget =
opener ??
document.querySelector<HTMLElement>(`[data-testid="${TESTIDS.hamburger}"]`);
restoreTarget?.focus();
};
}, [drawerOpen, closeDrawer]);
return (
<div
data-testid={TESTIDS.appShell}
className="h-screen bg-bg-page flex flex-col overflow-hidden"
>
<TopNav onLogout={onLogout} onToggleMenu={openDrawer} isMenuOpen={drawerOpen} />
<div className="flex flex-1 min-h-0">
<aside
data-testid={TESTIDS.sidebarRail}
className="hidden lg:flex lg:flex-col w-[212px] shrink-0 bg-bg-card/95 border-r border-border backdrop-blur-sm"
>
<div className="px-4 pt-4 pb-2">
<p className="text-[10px] font-semibold uppercase tracking-[0.14em] text-text-muted">
</p>
</div>
<SideNav
alertCount={alerts.length}
expanded={expandedNav}
onExpandedChange={setExpandedNav}
/>
</aside>
{drawerOpen && (
<div
className="fixed inset-0 z-40 bg-slate-900/35 lg:hidden"
onClick={closeDrawer}
aria-hidden="true"
/>
)}
<aside
ref={drawerRef}
id="app-drawer"
role="dialog"
aria-modal="true"
aria-label="导航菜单"
tabIndex={-1}
data-testid={TESTIDS.appDrawer}
className={`fixed top-0 left-0 bottom-0 z-50 w-[280px] max-w-[85vw] bg-bg-card border-r border-border shadow-lift transition-transform duration-200 lg:hidden ${
drawerOpen ? 'translate-x-0' : '-translate-x-full'
}`}
>
<div className="px-4 pt-5 pb-2 border-b border-border-light">
<p className="brand-mark text-xl leading-none">CBPOA</p>
<p className="mt-1 text-[11px] text-text-muted"></p>
</div>
<SideNav
alertCount={alerts.length}
onNavigate={closeDrawer}
expanded={expandedNav}
onExpandedChange={setExpandedNav}
/>
</aside>
<main
className={
mapWorkbench
? 'flex-1 min-w-0 min-h-0 overflow-hidden flex flex-col'
: 'flex-1 min-w-0 overflow-auto p-5'
}
>
<RouteErrorBoundary>
<Outlet />
</RouteErrorBoundary>
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,38 @@
# Components — Reusable UI
## Conventions
- One component per file, PascalCase, default export
- Props interface: `{ComponentName}Props`, typed strictly (no `any`)
- Wrap pure display components in `memo()` for render optimization
- All styling via Tailwind utility classes — no CSS modules, no inline styles
## Component Types
**Map components** (`*Map.tsx`, `DistrictChoropleth`) — `@geoscene/core`:
- Use `createMapView` / layer factories from `@/geoscene`
- Coordinate system: `[longitude, latitude]` (GeoScene convention)
- Destroy MapView on unmount
**Chart components** (`*Chart*.tsx`) — Recharts:
- Responsive containers with `width="100%" height={...}`
**Navigation** (`TopNav.tsx`, `SideNav.tsx`):
- No data fetching — pure navigation/presentation
- No role/perspective switcher
**Overlay/Utility** (`GridStatsOverlay`, `ErrorBanner`, `StatCard`, `TimelinePlayer`):
- Small, focused, reusable across pages
## Data Flow
- Components receive data via props, never fetch directly (maps may call tile/cell APIs they own)
- Callbacks passed as props: `onGridSelect`, `onClosePanel`, `onForecastChange`
## Anti-Patterns
- Don't fetch page-level data in leaf components — receive via props or store hooks
- Don't create god components (>200 lines) — extract sub-components
- Don't use `any` in prop types — use `unknown` and narrow
- Don't pass MapView instances between components
- Don't use CSS modules or inline styles — Tailwind only (map symbol colors excepted)

View File

@@ -0,0 +1,198 @@
import React, { useMemo } from 'react';
interface CalendarHeatmapProps {
data: Array<{ date: string; value: number }>;
year: number;
onDayClick?: (date: string) => void;
}
function getColor(value: number): string {
if (value < 50) return '#10B981';
if (value < 100) return '#F59E0B';
if (value < 150) return '#F97316';
if (value < 200) return '#EF4444';
return '#7C3AED';
}
function getLabel(value: number): string {
if (value < 50) return '优';
if (value < 100) return '良';
if (value < 150) return '轻度';
if (value < 200) return '中度';
return '重度';
}
const MONTH_NAMES = [
'1月', '2月', '3月', '4月', '5月', '6月',
'7月', '8月', '9月', '10月', '11月', '12月',
];
const DAY_NAMES = ['一', '二', '三', '四', '五', '六', '日'];
export const CalendarHeatmap = React.memo(function CalendarHeatmap({
data,
year,
onDayClick,
}: CalendarHeatmapProps) {
const dataMap = useMemo(() => {
const map = new Map<string, number>();
for (const d of data) {
map.set(d.date, d.value);
}
return map;
}, [data]);
const months = useMemo(() => {
const result: Array<{
month: number;
name: string;
weeks: Array<Array<{ date: string; day: number; value: number | null }>>;
}> = [];
for (let m = 0; m < 12; m++) {
const daysInMonth = new Date(year, m + 1, 0).getDate();
const cells: Array<{ date: string; day: number; value: number | null }> = [];
for (let d = 1; d <= daysInMonth; d++) {
const dateObj = new Date(year, m, d);
const dateStr = dateObj.toISOString().slice(0, 10);
cells.push({
date: dateStr,
day: d,
value: dataMap.get(dateStr) ?? null,
});
}
// Calculate start day of week (1=Monday, 0=Sunday → JS getDay: 0=Sun)
const firstDay = new Date(year, m, 1).getDay();
// Convert JS Sunday=0 to Monday=0
const startOffset = firstDay === 0 ? 6 : firstDay - 1;
// Pad beginning with empty cells
const padded: Array<{ date: string; day: number; value: number | null } | null> = [];
for (let i = 0; i < startOffset; i++) {
padded.push(null);
}
for (const cell of cells) {
padded.push(cell);
}
// Split into weeks of 7
const weeks: Array<Array<{ date: string; day: number; value: number | null }>> = [];
for (let i = 0; i < padded.length; i += 7) {
const week = padded.slice(i, i + 7).filter(Boolean) as Array<{
date: string;
day: number;
value: number | null;
}>;
if (week.length > 0) {
weeks.push(week);
}
}
result.push({ month: m, name: MONTH_NAMES[m], weeks });
}
return result;
}, [year, dataMap]);
return (
<div className="space-y-4">
{/* Legend */}
<div className="flex items-center gap-1.5 text-[10px] text-gray-500">
{[
{ color: '#10B981', label: '优 <50' },
{ color: '#F59E0B', label: '良 50-100' },
{ color: '#F97316', label: '轻度 100-150' },
{ color: '#EF4444', label: '中度 150-200' },
{ color: '#7C3AED', label: '重度 ≥200' },
{ color: '#E5E7EB', label: '无数据' },
].map((item) => (
<span key={item.label} className="inline-flex items-center gap-1">
<span
className="inline-block w-2.5 h-2.5 rounded-sm"
style={{ backgroundColor: item.color }}
/>
{item.label}
</span>
))}
</div>
{/* Calendar grid */}
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-12 gap-4">
{months.map((month) => (
<div key={month.month} className="flex flex-col items-center">
<div className="text-[11px] font-medium text-gray-500 mb-1">
{month.name}
</div>
{/* Day header row */}
<div className="grid grid-cols-7 gap-px mb-0.5" style={{ width: 98 }}>
{DAY_NAMES.map((d) => (
<div
key={d}
className="text-[8px] text-gray-400 text-center leading-3 w-[14px] h-3"
>
{d}
</div>
))}
</div>
{/* Weeks */}
{month.weeks.map((week, wi) => (
<div key={wi} className="flex gap-px">
{Array.from({ length: 7 }).map((_, di) => {
// Match by day-of-week index
const dayOfWeekMap = [1, 2, 3, 4, 5, 6, 0]; // Mon=1..Sun=0
const matchedCell = week.find(
(c) => new Date(c.date).getDay() === dayOfWeekMap[di],
);
if (!matchedCell) {
return (
<div
key={di}
className="w-[14px] h-[14px]"
aria-hidden
/>
);
}
const bg =
matchedCell.value === null
? '#E5E7EB'
: getColor(matchedCell.value);
return (
<div
key={di}
title={
matchedCell.value !== null
? `${matchedCell.date}: AQI ${matchedCell.value} (${getLabel(matchedCell.value)})`
: `${matchedCell.date}: 无数据`
}
onClick={() => onDayClick?.(matchedCell.date)}
className={`w-[14px] h-[14px] rounded-sm transition-transform hover:scale-125 ${
onDayClick ? 'cursor-pointer' : ''
}`}
style={{ backgroundColor: bg }}
role={onDayClick ? 'button' : undefined}
tabIndex={onDayClick ? 0 : undefined}
onKeyDown={
onDayClick
? (e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onDayClick(matchedCell.date);
}
}
: undefined
}
/>
);
})}
</div>
))}
</div>
))}
</div>
</div>
);
});

View File

@@ -1,55 +1,117 @@
import { useEffect, useRef, useState, memo } from 'react';
import L from 'leaflet';
import type MapView from '@geoscene/core/views/MapView';
import type GraphicsLayer from '@geoscene/core/layers/GraphicsLayer';
import Graphic from '@geoscene/core/Graphic';
import { geocodedApi } from '@/services/api';
import { TESTIDS } from '@/utils/testids';
import type { GeocodedCase } from '@/types';
import { createMapView } from '@/geoscene/createMapView';
import { createGraphicsLayer, pointGraphic, jitterLonLat } from '@/geoscene/layers';
const WUHAN_CENTER: [number, number] = [30.59, 114.31];
type CaseMapMode = 'points' | 'density';
interface CaseLocationMapProps {
height?: string;
district?: string | null;
street?: string | null;
date?: string | null;
mode?: CaseMapMode;
}
function CaseLocationMapComponent({ height = '400px', district = null, street = null }: CaseLocationMapProps) {
interface DensityCluster {
key: string;
label: string;
latitude: number;
longitude: number;
count: number;
}
function aggregateClusters(cases: GeocodedCase[]): DensityCluster[] {
const groups: Record<string, { latSum: number; lonSum: number; count: number; label: string }> = {};
for (const c of cases) {
if (!c.latitude || !c.longitude) continue;
const key = `${c.district}/${c.street || ''}`;
const label = c.street ? `${c.district} ${c.street}` : c.district;
if (!groups[key]) groups[key] = { latSum: 0, lonSum: 0, count: 0, label };
groups[key].latSum += c.latitude;
groups[key].lonSum += c.longitude;
groups[key].count += 1;
}
return Object.entries(groups).map(([key, g]) => ({
key,
label: g.label,
latitude: g.latSum / g.count,
longitude: g.lonSum / g.count,
count: g.count,
}));
}
function CaseLocationMapComponent({
height = '400px',
district = null,
street = null,
date = null,
mode = 'points',
}: CaseLocationMapProps) {
const mapRef = useRef<HTMLDivElement>(null);
const mapInstanceRef = useRef<L.Map | null>(null);
const layerRef = useRef<L.LayerGroup | null>(null);
const viewRef = useRef<MapView | null>(null);
const layerRef = useRef<GraphicsLayer | null>(null);
const cancelledRef = useRef(false);
const fittedScopeRef = useRef<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [caseCount, setCaseCount] = useState(0);
const [pointKeys, setPointKeys] = useState<string[]>([]);
const [clusterCount, setClusterCount] = useState(0);
const [mapReady, setMapReady] = useState(false);
useEffect(() => {
if (!mapRef.current || mapInstanceRef.current) return;
if (!mapRef.current || viewRef.current) return;
setIsLoading(true);
cancelledRef.current = false;
const map = L.map(mapRef.current, {
center: WUHAN_CENTER,
const { map, view, destroy } = createMapView({
container: mapRef.current,
zoom: 11,
zoomControl: true,
});
const layer = createGraphicsLayer('病例');
map.add(layer);
layerRef.current = layer;
viewRef.current = view;
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap',
maxZoom: 18,
}).addTo(map);
// 缩放控件移到左下,避开右上观测日与图例
try {
view.ui.move('zoom', 'bottom-left');
} catch {
/* zoom widget may be absent */
}
mapInstanceRef.current = map;
layerRef.current = L.layerGroup().addTo(map);
view.when(() => setMapReady(true)).catch(() => setMapReady(true));
// Fetch case locations
geocodedApi.getGeocoded({ limit: 5000, district: district || undefined })
return () => {
cancelledRef.current = true;
layerRef.current = null;
viewRef.current = null;
fittedScopeRef.current = null;
destroy();
setMapReady(false);
};
}, []);
useEffect(() => {
if (!mapReady || !viewRef.current || !layerRef.current) return;
cancelledRef.current = false;
const layer = layerRef.current;
const view = viewRef.current;
const scopeKey = `${district ?? ''}|${street ?? ''}|${mode}`;
const shouldFit = fittedScopeRef.current !== scopeKey;
geocodedApi
.getGeocoded({ limit: 5000, district: district || undefined, date: date || undefined })
.then((data) => {
if (cancelledRef.current) return;
const cases: GeocodedCase[] = data.cases || [];
const layer = layerRef.current;
if (!layer) return;
layer.removeAll();
layer.clearLayers();
// Deduplicate by case_id to avoid overlapping markers
const seen = new Set<string>();
let unique: GeocodedCase[] = [];
for (const c of cases) {
@@ -58,44 +120,64 @@ function CaseLocationMapComponent({ height = '400px', district = null, street =
unique.push(c);
}
}
// Client-side street filtering
if (street) {
unique = unique.filter((c) => c.street === street);
}
if (cancelledRef.current) return;
for (const c of unique) {
if (!c.latitude || !c.longitude) continue;
const color = c.case_type === 'inpatient' ? '#ef4444' : '#3b82f6';
const marker = L.circleMarker([c.latitude, c.longitude], {
radius: 3,
fillColor: color,
fillOpacity: 0.6,
color: color,
weight: 1,
if (mode === 'density') {
const clusters = aggregateClusters(unique);
const maxCount = clusters.reduce((m, c) => Math.max(m, c.count), 1);
const graphics = clusters.map((cl) => {
const size = 10 + Math.round((cl.count / maxCount) * 22);
const g = pointGraphic(cl.longitude, cl.latitude, '#7c3aed', size, {
label: cl.label,
count: cl.count,
});
g.popupTemplate = {
title: '{label}',
content: '病例数: {count}',
};
return g;
});
marker.bindTooltip(
`<div style="font-size:12px">
<strong>${c.district}</strong> ${c.street}<br/>
类型: ${c.case_type === 'inpatient' ? '住院' : '门诊'}
</div>`,
{ direction: 'top', offset: [0, -4] }
);
marker.addTo(layer);
layer.addMany(graphics);
setClusterCount(clusters.length);
setCaseCount(unique.length);
setPointKeys([]);
setIsLoading(false);
if (shouldFit && graphics.length > 0) {
fittedScopeRef.current = scopeKey;
view.goTo(graphics).catch(() => undefined);
}
return;
}
const keys: string[] = [];
const graphics: Graphic[] = [];
for (const c of unique) {
if (!c.latitude || !c.longitude) continue;
const color = c.case_type === 'inpatient' ? '#ef4444' : '#3b82f6';
const [lon, lat] = jitterLonLat(c.longitude, c.latitude, c.case_id, 70);
const g = pointGraphic(lon, lat, color, 6, {
district: c.district,
street: c.street,
case_type: c.case_type,
});
g.popupTemplate = {
title: '{district} {street}',
content: '类型: {case_type}',
};
graphics.push(g);
keys.push(c.case_id);
}
layer.addMany(graphics);
setClusterCount(0);
setCaseCount(unique.length);
setPointKeys(keys);
setIsLoading(false);
// Fit bounds to case locations
if (unique.length > 0) {
const bounds = L.latLngBounds(unique.map((c) => [c.latitude, c.longitude]));
map.fitBounds(bounds, { padding: [30, 30] });
if (shouldFit && graphics.length > 0) {
fittedScopeRef.current = scopeKey;
view.goTo(graphics).catch(() => undefined);
}
})
.catch(() => {
@@ -104,26 +186,74 @@ function CaseLocationMapComponent({ height = '400px', district = null, street =
return () => {
cancelledRef.current = true;
map.remove();
mapInstanceRef.current = null;
};
}, [district, street]);
}, [district, street, date, mode, mapReady]);
const isDensity = mode === 'density';
const fillParent = height === '100%' || height === '100vh';
return (
<div className="relative">
<div ref={mapRef} style={{ height, width: '100%', borderRadius: '8px' }} />
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-white/80 rounded-lg">
<div className="text-sm text-gray-500">...</div>
<div
className={`relative ${fillParent ? 'h-full min-h-0' : ''}`}
data-case-map-mode={mode}
>
<div
ref={mapRef}
className={`w-full overflow-hidden bg-bg-hover ${fillParent ? 'h-full rounded-none' : 'rounded-xl'}`}
style={{ height: fillParent ? '100%' : height, width: '100%' }}
/>
{!mapReady && (
<div className="absolute inset-0 flex items-center justify-center bg-bg-card/80">
<div className="text-[13px] text-text-muted"></div>
</div>
)}
{!isLoading && (
<div className="absolute top-2 right-2 bg-white/90 px-3 py-1.5 rounded shadow text-xs">
<span className="text-blue-600 font-semibold">{caseCount.toLocaleString()}</span>
<span className="ml-2 text-red-500"> </span>
<span className="ml-1 text-blue-500"> </span>
{mapReady && isLoading && (
<div className="absolute top-14 right-3 z-[1000] bg-bg-card/95 px-2.5 py-1 rounded-md border border-border shadow-soft text-[11px] text-text-muted">
</div>
)}
{!isLoading && mapReady && (
<div
className="absolute bottom-3 left-14 z-[1000] bg-bg-card/95 px-3 py-2 rounded-lg border border-border shadow-soft text-[12px] max-w-[220px]"
aria-label="病例图例"
>
<div className="text-[11px] font-semibold text-text-secondary mb-1.5 tracking-wide">
</div>
{!isDensity ? (
<>
<div className="font-mono tabular-nums text-text-primary mb-1.5">
<span className="text-primary font-semibold">{caseCount.toLocaleString()}</span>
<span className="text-text-muted ml-1"></span>
</div>
<div className="flex items-center gap-3 text-[11px] text-text-muted">
<span className="inline-flex items-center gap-1">
<span className="w-2.5 h-2.5 rounded-full bg-[#3b82f6]" aria-hidden />
</span>
<span className="inline-flex items-center gap-1">
<span className="w-2.5 h-2.5 rounded-full bg-[#ef4444]" aria-hidden />
</span>
</div>
</>
) : (
<>
<div className="font-mono tabular-nums text-text-primary mb-1">
<span className="font-semibold text-mist-deep">{clusterCount.toLocaleString()}</span>
<span className="text-text-muted ml-1"></span>
</div>
<div className="text-[11px] text-text-muted">/ · </div>
</>
)}
</div>
)}
<div className="hidden" aria-hidden="true">
{pointKeys.map((id) => (
<span key={id} data-testid={TESTIDS.patientPoint} data-case-id={id} />
))}
</div>
</div>
);
}

View File

@@ -1,376 +0,0 @@
import { memo, useEffect, useRef, useState, useCallback } from 'react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { geocodedApi } from '@/services/api';
import type { CaseGrid, GeocodedCase } from '@/types';
interface CaseMapProps {
height?: string;
}
type ViewMode = 'grid' | 'point';
// Grid is 100m x 100m at Wuhan latitude (~30.5°N)
const GRID_HALF_SIZE_LAT = 0.00045; // ~50m in degrees
const GRID_HALF_SIZE_LON = 0.00052; // ~50m in degrees
function getGridBounds(g: { latitude: number; longitude: number }) {
if (typeof g.latitude !== 'number' || typeof g.longitude !== 'number') {
return null;
}
return {
lat_min: g.latitude - GRID_HALF_SIZE_LAT,
lat_max: g.latitude + GRID_HALF_SIZE_LAT,
lon_min: g.longitude - GRID_HALF_SIZE_LON,
lon_max: g.longitude + GRID_HALF_SIZE_LON,
};
}
const RISK_COLORS = {
high: '#ff4444',
medium: '#ffaa44',
low: '#44bb44',
};
function getRiskColor(riskIndex: number): string {
if (riskIndex >= 0.67) return RISK_COLORS.high;
if (riskIndex >= 0.33) return RISK_COLORS.medium;
return RISK_COLORS.low;
}
function getRiskLabel(riskIndex: number): string {
if (riskIndex >= 0.67) return '高风险';
if (riskIndex >= 0.33) return '中风险';
return '低风险';
}
function debounce<T extends (...args: any[]) => void>(fn: T, ms: number) {
let timer: ReturnType<typeof setTimeout> | null = null;
return (...args: Parameters<T>) => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => fn(...args), ms);
};
}
function CaseMapComponent({ height = '480px' }: CaseMapProps) {
const mapDivRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<any>(null);
const gridLayerRef = useRef<any>(null);
const pointLayerRef = useRef<any>(null);
const [viewMode, setViewMode] = useState<ViewMode>('grid');
const [grids, setGrids] = useState<CaseGrid[]>([]);
const [cases, setCases] = useState<GeocodedCase[]>([]);
const [totalCases, setTotalCases] = useState(0);
const [gridCount, setGridCount] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function fetchData() {
setIsLoading(true);
setError(null);
try {
const [gridRes, geoRes] = await Promise.all([
geocodedApi.getGrid(),
geocodedApi.getGeocoded({ limit: 5000 }),
]);
if (cancelled) return;
setGrids(gridRes.grids || []);
setGridCount(gridRes.total_count || 0);
setTotalCases(gridRes.total_cases || 0);
setCases(geoRes.cases || []);
} catch (err) {
if (cancelled) return;
setError(err instanceof Error ? err.message : '加载失败');
} finally {
if (!cancelled) setIsLoading(false);
}
}
fetchData();
return () => { cancelled = true; };
}, []);
useEffect(() => {
if (!mapDivRef.current || mapRef.current) return;
const map = L.map(mapDivRef.current, {
center: [30.59, 114.31],
zoom: 11,
zoomControl: true,
preferCanvas: false,
});
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
maxZoom: 19,
}).addTo(map);
mapRef.current = map;
const handleZoom = debounce(() => renderLayers(), 150);
const handleMove = debounce(() => renderLayers(), 150);
map.on('zoomend', handleZoom);
map.on('moveend', handleMove);
return () => {
if (mapRef.current) {
mapRef.current.remove();
mapRef.current = null;
gridLayerRef.current = null;
pointLayerRef.current = null;
}
};
}, []);
useEffect(() => {
if (!mapRef.current) return;
renderLayers();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [grids, cases, viewMode]);
const renderLayers = useCallback(() => {
if (!mapRef.current) return;
const map = mapRef.current;
if (gridLayerRef.current) {
try { map.removeLayer(gridLayerRef.current); } catch { /* silent */ }
gridLayerRef.current = null;
}
if (pointLayerRef.current) {
try { map.removeLayer(pointLayerRef.current); } catch { /* silent */ }
pointLayerRef.current = null;
}
const zoom = map.getZoom();
if (viewMode === 'grid') {
const gridLayer = L.layerGroup();
const bounds = map.getBounds();
let rendered = 0;
const maxRender = 5000;
for (const g of grids) {
if (rendered >= maxRender) break;
const gBounds = getGridBounds(g);
if (!gBounds) continue;
if (
gBounds.lat_max < bounds.getSouth() ||
gBounds.lat_min > bounds.getNorth() ||
gBounds.lon_max < bounds.getWest() ||
gBounds.lon_min > bounds.getEast()
) {
continue;
}
const color = getRiskColor(g.risk_index);
const opacity = 0.5 + g.risk_index * 0.35;
const rect = L.rectangle(
[[gBounds.lat_min, gBounds.lon_min], [gBounds.lat_max, gBounds.lon_max]],
{
fillColor: color,
fillOpacity: opacity,
color: color,
weight: zoom >= 14 ? 1 : 0,
opacity: 0.3,
}
);
rect.bindTooltip(
`<div style="font-size: 12px;">
<strong>网格 ${g.grid_id}</strong><br/>
病例数: ${g.total_cases.toLocaleString()}<br/>
风险指数: ${(g.risk_index * 100).toFixed(1)}%<br/>
<span style="color: ${color}; font-weight: 600;">${getRiskLabel(g.risk_index)}</span>
</div>`,
{ direction: 'top', offset: [0, -5] }
);
rect.addTo(gridLayer);
rendered++;
}
gridLayer.addTo(map);
gridLayerRef.current = gridLayer;
} else {
const pointLayer = L.layerGroup();
const bounds = map.getBounds();
const caseColor = (c: GeocodedCase) =>
c.case_type === 'inpatient' ? '#DC2626' : '#2563EB';
// Viewport culling + maxRender to avoid Leaflet canvas intersects bug
const maxRender = 500;
let rendered = 0;
for (const c of cases) {
if (rendered >= maxRender) break;
if (typeof c.latitude !== 'number' || typeof c.longitude !== 'number') continue;
// Viewport culling - skip points outside visible area
if (
c.latitude < bounds.getSouth() ||
c.latitude > bounds.getNorth() ||
c.longitude < bounds.getWest() ||
c.longitude > bounds.getEast()
) {
continue;
}
// Use tiny rectangles instead of circleMarker to avoid Leaflet 1.9.4 intersects bug
const size = zoom >= 14 ? 0.00005 : zoom >= 12 ? 0.00003 : 0.00002;
const rect = L.rectangle(
[[c.latitude - size, c.longitude - size], [c.latitude + size, c.longitude + size]],
{
fillColor: caseColor(c),
fillOpacity: 0.8,
color: '#FFFFFF',
weight: 0.5,
}
);
rect.bindTooltip(
`<div style="font-size: 12px;">
<strong>${c.case_type === 'inpatient' ? '住院' : '门诊'}病例</strong><br/>
坐标:${c.latitude.toFixed(5)}, ${c.longitude.toFixed(5)}
</div>`,
{ direction: 'top', offset: [0, -5] }
);
rect.addTo(pointLayer);
rendered++;
}
pointLayer.addTo(map);
pointLayerRef.current = pointLayer;
}
}, [grids, cases, viewMode]);
const handleToggle = useCallback((mode: ViewMode) => {
setViewMode(mode);
}, []);
return (
<div className="card">
<div className="flex items-center justify-between px-4 py-3 border-b border-border-light">
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-primary" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.5 3l-.16.03L15 5.1 9 3 3.36 4.9c-.21.07-.36.25-.36.48V20.5c0 .28.22.5.5.5l.16-.03L9 18.9l6 2.1 5.64-1.9c.21-.07.36-.25.36-.48V3.5c0-.28-.22-.5-.5-.5zM15 19l-6-2.11V5l6 2.11V19z"/>
</svg>
<span className="font-medium text-[14px]"></span>
</div>
<div className="flex items-center gap-3">
<div className="flex gap-0.5 bg-bg-page p-0.5 rounded">
<button
onClick={() => handleToggle('grid')}
className={`px-3 py-1 text-[12px] font-medium rounded transition-colors ${
viewMode === 'grid'
? 'bg-bg-card text-primary shadow-sm'
: 'text-text-secondary hover:text-text-primary'
}`}
>
</button>
<button
onClick={() => handleToggle('point')}
className={`px-3 py-1 text-[12px] font-medium rounded transition-colors ${
viewMode === 'point'
? 'bg-bg-card text-primary shadow-sm'
: 'text-text-secondary hover:text-text-primary'
}`}
>
</button>
</div>
<div className="text-[11px] text-text-muted">
{viewMode === 'grid' ? '100×100m 网格' : '个体病例定位'}
</div>
</div>
</div>
<div className="relative" style={{ height }}>
<div ref={mapDivRef} className="w-full h-full overflow-hidden rounded-lg" />
<div className="absolute bottom-4 right-4 bg-bg-card/90 backdrop-blur rounded-lg border border-border-light shadow-sm z-[1000] px-4 py-3">
{viewMode === 'grid' ? (
<>
<div className="text-[11px] font-semibold text-text-secondary mb-2"></div>
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded" style={{ backgroundColor: RISK_COLORS.high }} />
<span className="text-[11px] text-text-secondary"> (&gt;67%)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded" style={{ backgroundColor: RISK_COLORS.medium }} />
<span className="text-[11px] text-text-secondary"> (33-67%)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded" style={{ backgroundColor: RISK_COLORS.low }} />
<span className="text-[11px] text-text-secondary"> (&lt;33%)</span>
</div>
</div>
</>
) : (
<>
<div className="text-[11px] font-semibold text-text-secondary mb-2"></div>
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: '#DC2626' }} />
<span className="text-[11px] text-text-secondary"></span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: '#2563EB' }} />
<span className="text-[11px] text-text-secondary"></span>
</div>
</div>
</>
)}
</div>
<div className="absolute top-4 left-4 space-y-2 z-[1000]">
<div className="bg-bg-card/90 backdrop-blur rounded-lg border border-border-light shadow-sm px-3 py-2">
<div className="text-[11px] text-text-secondary">
{isLoading ? (
<span className="text-text-muted">...</span>
) : error ? (
<span className="text-danger">: {error}</span>
) : (
<>
<span className="font-semibold text-text-primary">{totalCases.toLocaleString()}</span>
<span className="mx-2 text-border">|</span>
{viewMode === 'grid' ? (
<>
<span className="font-semibold text-text-primary">{gridCount.toLocaleString()}</span>
</>
) : (
<>
<span className="font-semibold text-text-primary">{cases.length.toLocaleString()}</span>
</>
)}
</>
)}
</div>
</div>
{!isLoading && !error && viewMode === 'grid' && (
<div className="bg-success/10 backdrop-blur rounded-lg border border-success/30 shadow-sm px-3 py-2">
<div className="text-[11px] text-success font-medium">
</div>
</div>
)}
</div>
</div>
</div>
);
}
export const CaseMap = memo(CaseMapComponent);

View File

@@ -1,185 +0,0 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { MessageSquare, X, Send, RefreshCw, Loader2 } from 'lucide-react';
import { chatApi } from '@/services/api';
interface Message {
role: 'user' | 'assistant';
content: string;
}
export function ChatBot() {
const [isOpen, setIsOpen] = useState(false);
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const scrollToBottom = useCallback(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, []);
useEffect(() => {
scrollToBottom();
}, [messages, isLoading, scrollToBottom]);
useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus();
}
}, [isOpen]);
const handleSend = useCallback(async () => {
const trimmed = input.trim();
if (!trimmed || isLoading) return;
const userMessage: Message = { role: 'user', content: trimmed };
const updatedMessages = [...messages, userMessage];
setMessages(updatedMessages);
setInput('');
setError(null);
setIsLoading(true);
try {
const data = await chatApi.sendMessage(
updatedMessages.map((m) => ({ role: m.role, content: m.content }))
);
setMessages((prev) => [...prev, { role: 'assistant', content: data.reply }]);
} catch (err: any) {
const errMsg = err?.response?.data?.detail || err?.message || '请求失败,请稍后重试';
setError(errMsg);
} finally {
setIsLoading(false);
}
}, [input, isLoading, messages]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
},
[handleSend]
);
const handleRetry = useCallback(() => {
setError(null);
handleSend();
}, [handleSend]);
return (
<>
{/* Float toggle button */}
<button
onClick={() => setIsOpen((prev) => !prev)}
className={`fixed bottom-5 right-5 z-50 flex h-12 w-12 items-center justify-center rounded-full bg-primary shadow-lg transition-all hover:bg-primary-light ${
isOpen ? 'scale-0 opacity-0' : 'scale-100 opacity-100'
}`}
aria-label={isOpen ? '关闭聊天' : '打开聊天'}
>
<MessageSquare className="h-5 w-5 text-white" />
</button>
{/* Chat panel */}
{isOpen && (
<div className="fixed bottom-20 right-5 z-50 flex w-[380px] flex-col rounded-xl border border-border bg-bg-card shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between rounded-t-xl bg-primary p-3 text-white">
<h3 className="text-[14px] font-semibold">AI </h3>
<button
onClick={() => setIsOpen(false)}
className="rounded p-1 transition-colors hover:bg-white/20"
aria-label="关闭"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Messages area */}
<div
ref={scrollRef}
className="flex flex-col gap-3 overflow-y-auto p-4"
style={{ height: '420px' }}
>
{messages.length === 0 && !error && (
<div className="flex flex-1 flex-col items-center justify-center py-12 text-center">
<MessageSquare className="mb-3 h-10 w-10 text-text-muted" />
<p className="text-[13px] text-text-muted">
</p>
</div>
)}
{messages.map((msg, i) => (
<div
key={i}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[80%] rounded-2xl px-4 py-2 text-[13px] leading-relaxed ${
msg.role === 'user'
? 'rounded-br-sm bg-primary text-white'
: 'rounded-bl-sm bg-bg-page text-text-primary'
}`}
>
{msg.content}
</div>
</div>
))}
{isLoading && (
<div className="flex justify-start">
<div className="flex items-center gap-2 rounded-2xl rounded-bl-sm bg-bg-page px-4 py-3">
<Loader2 className="h-4 w-4 animate-spin text-text-muted" />
<span className="text-[12px] text-text-muted">...</span>
</div>
</div>
)}
{error && (
<div className="flex flex-col items-start gap-2 rounded-lg border border-danger/20 bg-danger-light px-4 py-3">
<span className="text-[13px] text-danger">{error}</span>
<button
onClick={handleRetry}
className="flex items-center gap-1 rounded px-2.5 py-1 text-[12px] font-medium text-danger transition-colors hover:bg-danger/10"
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
{/* Input area */}
<div className="flex gap-2 border-t border-border p-3">
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="输入您的问题..."
disabled={isLoading}
className="flex-1 rounded-lg border border-border bg-bg-page px-3 py-2 text-[13px] text-text-primary placeholder-text-muted outline-none transition-colors focus:border-primary disabled:opacity-50"
/>
<button
onClick={handleSend}
disabled={isLoading || !input.trim()}
className="flex items-center justify-center rounded-lg bg-primary px-4 text-[13px] font-medium text-white transition-colors hover:bg-primary-light disabled:cursor-not-allowed disabled:opacity-50"
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</button>
</div>
</div>
)}
</>
);
}

View File

@@ -1,366 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import L from 'leaflet';
import { useLodGrid, type MapBounds } from '@/hooks/useLodGrid';
const RISK_COLORS: [number, number, string][] = [
[0.0, 0.2, '#22c55e'],
[0.2, 0.4, '#3b82f6'],
[0.4, 0.6, '#eab308'],
[0.6, 0.8, '#f97316'],
[0.8, 1.0, '#ef4444'],
];
// Pre-computed color buckets for fillStyle caching
const COLOR_BUCKETS: Record<string, { full: string; dim: string }> = {};
for (const [, , color] of RISK_COLORS) {
COLOR_BUCKETS[color] = { full: color, dim: color + '14' };
}
function getRiskColor(value: number): string {
for (const [min, max, color] of RISK_COLORS) {
if (value >= min && value <= max) return color;
}
return '#22c55e';
}
// 100m grid step in degrees
const LAT_STEP = 0.0009;
const LON_STEP = 0.001046;
// Mercator helpers (avoid per-cell latLngToContainerPoint)
function latToMercY(lat: number): number {
return 128 - (256 * Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI) / 360))) / (2 * Math.PI);
}
function lonToMercX(lon: number): number {
return ((lon + 180) / 360) * 256;
}
interface LodGridLayerProps {
map: L.Map | null;
forecastDay: 1 | 3 | 7;
visible?: boolean;
riskRange?: [number, number];
onCellClick?: (lat: number, lon: number, risk: number) => void;
}
export function LodGridLayer({
map,
forecastDay,
visible = true,
riskRange,
onCellClick,
}: LodGridLayerProps) {
const [zoom, setZoom] = useState(map?.getZoom() ?? 10);
const [mapBounds, setMapBounds] = useState<MapBounds | undefined>();
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const paneRef = useRef<HTMLElement | null>(null);
const animFrameRef = useRef<number>(0);
const clickCallbackRef = useRef(onCellClick);
const gridsRef = useRef<number[][]>([]);
const forecastDayRef = useRef(forecastDay);
const riskRangeRef = useRef(riskRange);
const visibleRef = useRef(visible);
const drawnOriginRef = useRef<{ x: number; y: number } | null>(null);
// Keep refs in sync
useEffect(() => { clickCallbackRef.current = onCellClick; }, [onCellClick]);
useEffect(() => { forecastDayRef.current = forecastDay; }, [forecastDay]);
useEffect(() => { riskRangeRef.current = riskRange; }, [riskRange]);
useEffect(() => { visibleRef.current = visible; }, [visible]);
// Track map bounds and zoom
useEffect(() => {
if (!map) return;
const update = () => {
const b = map.getBounds();
setMapBounds({
min_lat: b.getSouth(),
max_lat: b.getNorth(),
min_lon: b.getWest(),
max_lon: b.getEast(),
});
setZoom(map.getZoom());
};
update();
map.on('moveend', update);
map.on('zoomend', update);
return () => {
map.off('moveend', update);
map.off('zoomend', update);
};
}, [map]);
const { grids } = useLodGrid(zoom, forecastDay, mapBounds);
// Update gridsRef only when we have actual data (preserve stale data during loading)
useEffect(() => {
if (grids.length > 0) {
gridsRef.current = grids;
}
}, [grids]);
// Create canvas overlay pane and attach to map
useEffect(() => {
if (!map) return;
const pane = map.createPane('lod-grid-pane');
pane.style.zIndex = '450';
pane.style.pointerEvents = 'none';
paneRef.current = pane;
const canvas = document.createElement('canvas');
canvas.style.position = 'absolute';
canvas.style.top = '0';
canvas.style.left = '0';
canvas.style.width = '100%';
canvas.style.height = '100%';
canvas.style.pointerEvents = 'none';
pane.appendChild(canvas);
canvasRef.current = canvas;
// Handle map clicks for grid cell selection
const handleMapClick = (e: L.LeafletMouseEvent) => {
if (!clickCallbackRef.current) return;
const currentGrids = gridsRef.current;
if (!currentGrids || currentGrids.length === 0) return;
const { lat, lng } = e.latlng;
const riskIdx = forecastDayRef.current === 1 ? 2 : forecastDayRef.current === 3 ? 3 : 4;
let nearestDist = Infinity;
let nearestRisk = 0;
let nearestLat = 0;
let nearestLon = 0;
for (const g of currentGrids) {
const d = Math.sqrt((g[0] - lat) ** 2 + (g[1] - lng) ** 2);
if (d < nearestDist) {
nearestDist = d;
nearestRisk = g[riskIdx] ?? 0;
nearestLat = g[0];
nearestLon = g[1];
}
}
if (nearestDist < 0.01) {
clickCallbackRef.current(nearestLat, nearestLon, nearestRisk);
}
};
map.on('click', handleMapClick);
// Full redraw function
const redraw = () => {
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
animFrameRef.current = requestAnimationFrame(() => {
const container = map.getContainer();
const w = container.clientWidth;
const h = container.clientHeight;
const dpr = window.devicePixelRatio || 1;
canvas.width = w * dpr;
canvas.height = h * dpr;
canvas.style.width = w + 'px';
canvas.style.height = h + 'px';
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
// Reset drift transform after redraw
canvas.style.transform = '';
drawnOriginRef.current = null;
if (!visibleRef.current) return;
const currentGrids = gridsRef.current;
if (!currentGrids || currentGrids.length === 0) return;
const z = map.getZoom();
const riskIdx = forecastDayRef.current === 1 ? 2 : forecastDayRef.current === 3 ? 3 : 4;
const range = riskRangeRef.current;
const mapBounds = map.getBounds();
const south = mapBounds.getSouth();
const north = mapBounds.getNorth();
const west = mapBounds.getWest();
const east = mapBounds.getEast();
// Use Mercator math for pixel conversion (avoids per-cell latLngToContainerPoint)
const scale = 2 ** z;
const origin = map.getPixelOrigin();
drawnOriginRef.current = { x: origin.x, y: origin.y };
// Pre-compute Mercator Y steps for cell size at this zoom
const halfLat = LAT_STEP / 2;
const halfLon = LON_STEP / 2;
// Group cells by color to minimize fillStyle changes
const colorGroups: Record<string, { x: number; y: number; w: number; h: number }[]> = {};
// Viewport culling margin in degrees
const margin = 0.02;
const isHighZoom = z >= 12;
const isMedZoom = z >= 10;
for (const g of currentGrids) {
const lat = g[0];
const lon = g[1];
const risk = g[riskIdx] ?? 0;
// Pre-filter: skip zero-risk cells (majority of cells at most zooms)
if (risk === 0) continue;
// Viewport culling
if (lat < south - margin || lat > north + margin ||
lon < west - margin || lon > east + margin) {
continue;
}
// Risk range filter
let alpha = 0.85;
if (range) {
if (risk < range[0]) {
alpha = 0.08;
} else if (risk > range[1]) {
alpha = 0.3;
}
}
const color = getRiskColor(risk);
if (isHighZoom) {
// Compute cell rectangle using Mercator math
const lx = lonToMercX(lon - halfLon) * scale - origin.x;
const rx = lonToMercX(lon + halfLon) * scale - origin.x;
const ty = latToMercY(lat + halfLat) * scale - origin.y;
const by = latToMercY(lat - halfLat) * scale - origin.y;
const cellW = rx - lx;
const cellH = by - ty;
if (cellW < 0.5 || cellH < 0.5) continue;
// Group by color+alpha for batch rendering
const key = alpha < 1 ? `${color}_${alpha}` : color;
if (!colorGroups[key]) colorGroups[key] = [];
colorGroups[key].push({ x: lx, y: ty, w: cellW, h: cellH });
} else {
// Medium/low zoom: compute center pixel
const cx = lonToMercX(lon) * scale - origin.x;
const cy = latToMercY(lat) * scale - origin.y;
const key = alpha < 1 ? `${color}_${alpha}` : color;
if (!colorGroups[key]) colorGroups[key] = [];
colorGroups[key].push({ x: cx, y: cy, w: 0, h: 0 });
}
}
// Render grouped cells
for (const [key, cells] of Object.entries(colorGroups)) {
const parts = key.split('_');
const color = parts[0];
const alpha = parts.length > 1 ? parseFloat(parts[1]) : 1;
ctx.globalAlpha = alpha;
ctx.fillStyle = color;
if (isHighZoom) {
for (const c of cells) {
ctx.fillRect(c.x, c.y, c.w, c.h);
}
// Stroke only at high enough cell sizes
ctx.globalAlpha = 0.4;
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 0.5;
for (const c of cells) {
if (c.w > 2 && c.h > 2) {
ctx.strokeRect(c.x, c.y, c.w, c.h);
}
}
} else if (isMedZoom) {
const size = Math.max(2, Math.min(6, z - 7));
const halfSize = size / 2;
for (const c of cells) {
ctx.fillRect(c.x - halfSize, c.y - halfSize, size, size);
}
} else {
const radius = Math.max(1, Math.min(3, z - 5));
for (const c of cells) {
ctx.beginPath();
ctx.arc(c.x, c.y, radius, 0, Math.PI * 2);
ctx.fill();
}
}
}
ctx.globalAlpha = 1;
});
};
// Debounced full redraw (avoid thrashing during rapid pan/zoom)
let redrawTimer: ReturnType<typeof setTimeout> | null = null;
const debouncedRedraw = () => {
if (redrawTimer) clearTimeout(redrawTimer);
redrawTimer = setTimeout(redraw, 300);
};
// During pan: apply CSS transform to track tile movement (fixes drift)
const onMove = () => {
const drawn = drawnOriginRef.current;
if (!drawn) {
// No previous draw yet, just request a redraw
redraw();
return;
}
const current = map.getPixelOrigin();
const dx = drawn.x - current.x;
const dy = drawn.y - current.y;
canvas.style.transform = `translate(${dx}px, ${dy}px)`;
};
// On moveend/zoomend: reset transform and do debounced full redraw
const onMoveEnd = () => {
canvas.style.transform = '';
drawnOriginRef.current = null;
debouncedRedraw();
};
const onResize = () => redraw();
map.on('move', onMove);
map.on('moveend', onMoveEnd);
map.on('zoomend', onMoveEnd);
map.on('resize', onResize);
// Store redraw reference for external triggers
(canvas as any).__lodRedraw = redraw;
// Initial draw
redraw();
return () => {
map.off('move', onMove);
map.off('moveend', onMoveEnd);
map.off('zoomend', onMoveEnd);
map.off('resize', onResize);
map.off('click', handleMapClick);
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
if (redrawTimer) clearTimeout(redrawTimer);
pane.removeChild(canvas);
if (pane.parentNode) pane.parentNode.removeChild(pane);
canvasRef.current = null;
paneRef.current = null;
};
}, [map]);
// Trigger redraw when data changes
useEffect(() => {
const canvas = canvasRef.current;
if (canvas && (canvas as any).__lodRedraw) {
(canvas as any).__lodRedraw();
}
}, [grids, forecastDay, riskRange, visible]);
return null;
}

View File

@@ -0,0 +1,123 @@
import React, { useMemo } from 'react';
interface MetricHeatmapTableProps {
rows: string[];
columns: Array<{ key: string; label: string }>;
data: Record<string, Record<string, number>>;
onSort?: (column: string) => void;
onCellClick?: (row: string, column: string) => void;
}
function colorForValue(value: number, min: number, max: number): string {
if (max - min === 0) return 'rgb(255, 255, 255)';
const ratio = (value - min) / (max - min);
// Green (low) → Yellow (mid) → Red (high)
if (ratio <= 0.5) {
const r = Math.round(ratio * 2 * 245);
const g = 220;
return `rgb(${r}, ${g}, 230)`;
}
const r = 245;
const g = Math.round(220 - (ratio - 0.5) * 2 * 190);
return `rgb(${r}, ${g}, 230)`;
}
function textColorForValue(value: number, min: number, max: number): string {
if (max - min === 0) return '#374151';
const ratio = (value - min) / (max - min);
return ratio > 0.6 ? '#7F1D1D' : '#374151';
}
export const MetricHeatmapTable = React.memo(function MetricHeatmapTable({
rows,
columns,
data,
onSort,
onCellClick,
}: MetricHeatmapTableProps) {
const columnStats = useMemo(() => {
return columns.map((col) => {
const values = rows
.map((row) => data[row]?.[col.key])
.filter((v): v is number => v !== undefined && v !== null);
const min = values.length > 0 ? Math.min(...values) : 0;
const max = values.length > 0 ? Math.max(...values) : 0;
return { key: col.key, min, max };
});
}, [columns, rows, data]);
const colStatMap = useMemo(() => {
const map = new Map<string, { min: number; max: number }>();
for (const stat of columnStats) {
map.set(stat.key, { min: stat.min, max: stat.max });
}
return map;
}, [columnStats]);
return (
<div className="overflow-auto max-h-96">
<table className="w-full border-collapse text-xs">
<thead className="sticky top-0 z-10">
<tr>
<th className="bg-gray-100 border border-gray-200 px-2 py-1.5 text-left font-medium text-gray-600 sticky left-0 z-20">
&nbsp;
</th>
{columns.map((col) => (
<th
key={col.key}
onClick={() => onSort?.(col.key)}
className={`bg-gray-100 border border-gray-200 px-2 py-1.5 text-center font-medium text-gray-600 ${
onSort ? 'cursor-pointer hover:bg-gray-200 select-none' : ''
}`}
>
{col.label}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row}>
<td className="bg-white border border-gray-200 px-2 py-1 text-left font-medium text-gray-700 sticky left-0 z-10">
{row}
</td>
{columns.map((col) => {
const value = data[row]?.[col.key];
const stats = colStatMap.get(col.key);
const hasValue = value !== undefined && value !== null;
return (
<td
key={col.key}
onClick={() =>
onCellClick && hasValue
? onCellClick(row, col.key)
: undefined
}
className={`border border-gray-200 px-2 py-1 text-center ${
onCellClick && hasValue
? 'cursor-pointer hover:ring-1 hover:ring-blue-400'
: ''
}`}
style={
hasValue && stats && stats.max > stats.min
? {
backgroundColor: colorForValue(value, stats.min, stats.max),
color: textColorForValue(value, stats.min, stats.max),
}
: hasValue && stats && stats.max === stats.min
? { backgroundColor: 'rgb(255, 255, 255)' }
: {}
}
>
{hasValue ? value.toLocaleString() : '-'}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
);
});

View File

@@ -1,318 +0,0 @@
import { memo, useEffect, useRef, useMemo, useCallback } from 'react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import type { GridRisk, GridDetail, ForecastDay } from '@/types';
interface RiskMapProps {
grids: GridRisk[];
selectedGridId: string | null;
selectedGrid: GridDetail | null;
forecastDay: ForecastDay;
onGridSelect: (gridId: string) => void;
onClosePanel: () => void;
onFullscreen: () => void;
onForecastChange: (day: ForecastDay) => void;
isFullscreen?: boolean;
}
const RISK_COLORS: Record<string, string> = {
low: '#22c55e',
medium_low: '#3b82f6',
medium: '#eab308',
medium_high: '#f97316',
high: '#ef4444',
};
const RISK_LABELS: Record<string, string> = {
low: '低风险',
medium_low: '中低',
medium: '中风险',
medium_high: '中高',
high: '高风险',
};
const WUHAN_BOUNDS = {
minLat: 29.97,
maxLat: 31.37,
minLon: 113.69,
maxLon: 115.07,
};
function debounce<T extends (...args: any[]) => void>(fn: T, ms: number) {
let timer: ReturnType<typeof setTimeout> | null = null;
return (...args: Parameters<T>) => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => fn(...args), ms);
};
}
function RiskMapComponent(props: RiskMapProps) {
const {
grids,
selectedGrid,
forecastDay,
onGridSelect,
onClosePanel,
onFullscreen,
onForecastChange,
isFullscreen,
} = props;
const mapDivRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<any>(null);
const gridLayerRef = useRef<any>(null);
const zoomRef = useRef(9);
const callbacksRef = useRef({ onGridSelect, onClosePanel, onFullscreen, onForecastChange });
useEffect(() => {
callbacksRef.current = { onGridSelect, onClosePanel, onFullscreen, onForecastChange };
}, [onGridSelect, onClosePanel, onFullscreen, onForecastChange]);
const containerHeight = isFullscreen ? 'calc(100vh - 52px)' : '420px';
const gridMap = useMemo(() => {
const map = new Map<string, GridRisk>();
grids.forEach((g) => {
const key = `${g.latitude.toFixed(4)}-${g.longitude.toFixed(4)}`;
map.set(key, g);
});
return map;
}, [grids]);
useEffect(() => {
if (!mapDivRef.current || mapRef.current) return;
const map = L.map(mapDivRef.current, {
center: [(WUHAN_BOUNDS.minLat + WUHAN_BOUNDS.maxLat) / 2, (WUHAN_BOUNDS.minLon + WUHAN_BOUNDS.maxLon) / 2],
zoom: 9,
zoomControl: true,
preferCanvas: true,
});
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
maxZoom: 19,
}).addTo(map);
mapRef.current = map;
const handleZoom = debounce(() => {
zoomRef.current = map.getZoom();
renderGridLayer();
}, 150);
const handleMove = debounce(() => {
renderGridLayer();
}, 150);
map.on('zoomend', handleZoom);
map.on('moveend', handleMove);
function renderGridLayer() {
if (!mapRef.current) return;
const map = mapRef.current;
if (gridLayerRef.current) {
try {
map.removeLayer(gridLayerRef.current);
} catch {
// ignore
}
gridLayerRef.current = null;
}
const zoom = map.getZoom();
let cellSize: number;
let step: number;
if (zoom <= 8) {
cellSize = 0.1;
step = 10;
} else if (zoom <= 10) {
cellSize = 0.025;
step = 4;
} else if (zoom <= 12) {
cellSize = 0.01;
step = 2;
} else {
cellSize = 0.005;
step = 2;
}
const bounds = map.getBounds();
const minLat = Math.max(bounds.getSouth(), WUHAN_BOUNDS.minLat);
const maxLat = Math.min(bounds.getNorth(), WUHAN_BOUNDS.maxLat);
const minLon = Math.max(bounds.getWest(), WUHAN_BOUNDS.minLon);
const maxLon = Math.min(bounds.getEast(), WUHAN_BOUNDS.maxLon);
const latStart = Math.floor((minLat - WUHAN_BOUNDS.minLat) / cellSize) * cellSize + WUHAN_BOUNDS.minLat;
const lonStart = Math.floor((minLon - WUHAN_BOUNDS.minLon) / cellSize) * cellSize + WUHAN_BOUNDS.minLon;
const gridLayer = L.layerGroup();
const currentGridMap = gridMap;
let count = 0;
const maxCount = 1500;
if (currentGridMap.size > 5000) {
console.warn(`[RiskMap] Data too dense: ${currentGridMap.size} grid cells, rendering may be slow`);
}
for (let lat = latStart; lat < maxLat && count < maxCount; lat += cellSize * step) {
for (let lon = lonStart; lon < maxLon && count < maxCount; lon += cellSize * step) {
const key = `${lat.toFixed(4)}-${lon.toFixed(4)}`;
const grid = currentGridMap.get(key);
const riskValue = grid?.risk_value ?? 0.5;
let riskLevel = 'medium';
if (riskValue >= 0.7) riskLevel = 'high';
else if (riskValue >= 0.5) riskLevel = 'medium_high';
else if (riskValue >= 0.3) riskLevel = 'medium_low';
else riskLevel = 'low';
const color = RISK_COLORS[riskLevel];
const rect = L.rectangle(
[[lat, lon], [lat + cellSize * step, lon + cellSize * step]],
{
fillColor: color,
fillOpacity: 0.6,
color: 'transparent',
weight: 0,
}
);
if (grid) {
const gridId = grid.grid_id;
rect.bindTooltip(
`<b>${gridId}</b><br/>风险:${Math.round(riskValue * 100)}%`,
{ direction: 'center', permanent: false }
);
rect.on('click', () => {
callbacksRef.current.onGridSelect(gridId);
});
}
rect.addTo(gridLayer);
count++;
}
}
gridLayer.addTo(map);
gridLayerRef.current = gridLayer;
}
// Initial render
renderGridLayer();
return () => {
if (mapRef.current) {
mapRef.current.remove();
mapRef.current = null;
gridLayerRef.current = null;
}
};
}, [gridMap]);
const handleForecastChange = useCallback((d: ForecastDay) => {
callbacksRef.current.onForecastChange(d);
}, []);
const handleFullscreen = useCallback(() => {
callbacksRef.current.onFullscreen();
}, []);
const handleClosePanel = useCallback(() => {
callbacksRef.current.onClosePanel();
}, []);
return (
<div className="card">
<div className="flex items-center justify-between px-5 py-3.5 border-b border-gray-100">
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-blue-500" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.5 3l-.16.03L15 5.1 9 3 3.36 4.9c-.21.07-.36.25-.36.48V20.5c0 .28.22.5.5.5l.16-.03L9 18.9l6 2.1 5.64-1.9c.21-.07.36-.25.36-.48V3.5c0-.28-.22-.5-.5-.5zM15 19l-6-2.11V5l6 2.11V19z"/>
</svg>
<span className="font-medium text-[14px]"></span>
</div>
<div className="flex gap-0.5 bg-gray-100 p-0.5 rounded">
{([0, 1, 3, 7] as ForecastDay[]).map((d) => (
<button
key={d}
onClick={() => handleForecastChange(d)}
className={`px-3 py-1 text-[12px] font-medium rounded transition-colors ${
forecastDay === d ? 'bg-blue-500 text-white' : 'text-gray-600 hover:text-blue-500'
}`}
>
{d === 0 ? '今日' : d + '天后'}
</button>
))}
</div>
<button
onClick={handleFullscreen}
className="px-3 py-1.5 text-[12px] text-gray-600 bg-gray-100 border border-gray-200 rounded hover:border-blue-400 transition-colors"
>
</button>
</div>
<div className="relative" style={{ height: containerHeight }}>
<div ref={mapDivRef} className="w-full h-full overflow-hidden rounded-lg" />
<div className="absolute bottom-4 right-4 bg-white px-4 py-3 rounded-lg border border-gray-200 shadow-sm z-[1000]">
<div className="text-[11px] font-semibold text-gray-600 mb-2"></div>
<div className="flex flex-wrap gap-3">
{Object.entries(RISK_LABELS).map(([level, label]) => (
<div key={level} className="flex items-center gap-1.5 text-[11px] text-gray-600">
<div className="w-4 h-4 rounded" style={{ backgroundColor: RISK_COLORS[level] }} />
<span>{label}</span>
</div>
))}
</div>
</div>
<div className="absolute top-4 left-4 bg-white px-3 py-2 rounded-lg border border-gray-200 shadow-sm z-[1000]">
<div className="text-[11px] text-gray-600">
<span className="font-semibold text-gray-900">{grids.length.toLocaleString()}</span>
<span className="mx-2 text-gray-300">|</span>
{forecastDay === 0 ? '实时监测' : forecastDay + '天预报'}
</div>
</div>
{selectedGrid && (
<div className="absolute top-4 right-4 w-[280px] bg-white border border-gray-200 rounded-lg shadow-lg z-[1001]">
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100">
<span className="text-[13px] font-semibold"></span>
<button onClick={handleClosePanel} className="w-6 h-6 flex items-center justify-center rounded hover:bg-gray-100">
<svg className="w-3.5 h-3.5 fill-gray-400" viewBox="0 0 24 24">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
</svg>
</button>
</div>
<div className="p-4">
<div className={`rounded-md p-3 mb-4 ${selectedGrid.risk_value >= 0.7 ? 'bg-red-50' : 'bg-yellow-50'}`}>
<div className="text-[12px] text-gray-500 mb-1"></div>
<div className={`text-[24px] font-bold ${selectedGrid.risk_value >= 0.7 ? 'text-red-600' : 'text-yellow-600'}`}>
{Math.round(selectedGrid.risk_value * 100)}%
</div>
</div>
<div className="space-y-2 text-[12px]">
<div className="flex justify-between py-1.5 border-b border-gray-100">
<span className="text-gray-400"></span>
<span className="font-medium">{selectedGrid.region || '--'}</span>
</div>
<div className="flex justify-between py-1.5 border-b border-gray-100">
<span className="text-gray-400"></span>
<span className="font-medium">{selectedGrid.street || '--'}</span>
</div>
</div>
</div>
</div>
)}
</div>
</div>
);
}
export const RiskMap = memo(RiskMapComponent);

View File

@@ -0,0 +1,45 @@
import { Component, ReactNode } from 'react';
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
}
// 路由级错误边界:单个页面(含懒加载 chunk崩溃时只降级内容区
// 保留外层骨架(顶栏 + 侧栏),避免整页白屏。
export class RouteErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
private reset = () => {
this.setState({ hasError: false });
};
render() {
if (this.state.hasError) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<div className="text-danger text-base mb-2"></div>
<button
onClick={this.reset}
className="mt-2 px-4 py-2 bg-primary text-white rounded text-sm"
>
</button>
</div>
</div>
);
}
return this.props.children;
}
}

View File

@@ -1,113 +1,147 @@
import { useState } from 'react';
import { NavLink, useLocation } from 'react-router-dom';
import { TESTIDS } from '@/utils/testids';
interface SideNavProps {
activePage: string;
onPageChange: (page: string) => void;
alertCount?: number;
onNavigate?: () => void;
expanded?: string | null;
onExpandedChange?: (moduleId: string | null) => void;
}
const modules: { id: string; label: string; icon: React.ReactNode; items: { id: string; label: string }[] }[] = [
interface NavItem {
to: string;
label: string;
testid: string;
}
const modules: { id: string; label: string; icon: React.ReactNode; items: NavItem[] }[] = [
{
id: 'monitoring',
label: '监测',
icon: (
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z"/>
<path d="M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z" />
</svg>
),
items: [
{ id: 'monitoring', label: '监测面板' },
],
items: [{ to: '/monitoring', label: '监测面板', testid: TESTIDS.navMonitoring }],
},
{
id: 'alert',
label: '预警',
icon: (
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/>
<path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z" />
</svg>
),
items: [
{ id: 'alerts', label: '预警地图' },
],
items: [{ to: '/alerts', label: '预警地图', testid: TESTIDS.navAlerts }],
},
{
id: 'analysis',
label: '分析',
icon: (
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM9 17H7v-7h2v7zm4 0h-2V7h2v10zm4 0h-2v-4h2v4z"/>
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM9 17H7v-7h2v7zm4 0h-2V7h2v10zm4 0h-2v-4h2v4z" />
</svg>
),
items: [
{ id: 'trend-analysis', label: '趋势分析' },
{ id: 'district-comparison', label: '区域对比' },
{ id: 'insights', label: '智能洞察' },
{ id: 'reports', label: '报表中心' },
{ to: '/overview', label: '总览', testid: TESTIDS.navOverview },
{ to: '/analysis/trend', label: '趋势分析', testid: TESTIDS.navTrend },
{ to: '/analysis/district', label: '区域对比', testid: TESTIDS.navDistrict },
{ to: '/analysis/insights', label: '智能洞察', testid: TESTIDS.navInsights },
{ to: '/analysis/reports', label: '报表中心', testid: TESTIDS.navReports },
{ to: '/analysis/demographics', label: '人群分析', testid: TESTIDS.navDemographics },
{ to: '/analysis/disease', label: '疾病分析', testid: TESTIDS.navDisease },
{ to: '/analysis/clinical', label: '临床分析', testid: TESTIDS.navClinical },
{ to: '/analysis/environment', label: '环境健康', testid: TESTIDS.navEnvironment },
],
},
];
export function SideNav({
activePage,
onPageChange,
alertCount = 0,
onNavigate,
expanded: expandedProp,
onExpandedChange,
}: SideNavProps) {
const [expanded, setExpanded] = useState<string | null>('monitoring');
const location = useLocation();
const handleItemClick = (moduleId: string, itemId: string) => {
setExpanded(moduleId);
onPageChange(itemId);
const moduleForPath = (pathname: string) =>
modules.find((m) => m.items.some((item) => pathname.startsWith(item.to)))?.id ?? 'monitoring';
const [internalExpanded, setInternalExpanded] = useState<string | null>(() =>
moduleForPath(location.pathname)
);
const isControlled = expandedProp !== undefined;
const expanded = isControlled ? expandedProp : internalExpanded;
const setExpanded = (next: string | null) => {
if (isControlled) onExpandedChange?.(next);
else setInternalExpanded(next);
};
const isActiveModule = (moduleId: string) => {
const module = modules.find(m => m.id === moduleId);
const module = modules.find((m) => m.id === moduleId);
if (!module) return false;
return module.items.some(item => item.id === activePage);
return module.items.some((item) => location.pathname.startsWith(item.to));
};
return (
<aside className="w-[200px] bg-bg-card border-r border-border fixed top-[52px] left-0 bottom-0 overflow-y-auto py-4 px-2">
<nav className="h-full overflow-y-auto py-2 px-2.5">
{modules.map((module) => (
<div key={module.id} className="mb-4">
<div key={module.id} className="mb-1.5">
<button
type="button"
onClick={() => setExpanded(expanded === module.id ? null : module.id)}
className={`w-full flex items-center gap-[10px] px-3 py-[9px] rounded-md text-[14px] font-semibold transition-colors ${
className={`w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-[13px] font-semibold transition-colors ${
isActiveModule(module.id)
? 'bg-primary-muted text-primary'
: 'text-text-primary hover:bg-bg-hover'
}`}
>
<span className="w-4 h-4 flex items-center justify-center">
{module.icon}
</span>
<span>{module.label}</span>
<span className="w-4 h-4 flex items-center justify-center opacity-90">{module.icon}</span>
<span className="flex-1 text-left">{module.label}</span>
{module.id === 'alert' && alertCount > 0 && (
<span className="ml-auto bg-danger-light text-danger text-[10px] font-semibold px-[5px] py-[2px] rounded">
<span className="bg-danger-light text-danger text-[10px] font-semibold px-1.5 py-0.5 rounded-md tabular-nums">
{alertCount > 99 ? '99+' : alertCount}
</span>
)}
<svg
className={`w-3.5 h-3.5 text-text-muted shrink-0 transition-transform ${
expanded === module.id ? 'rotate-180' : ''
}`}
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
aria-hidden
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{expanded === module.id && (
<div className="mt-1 pl-7">
<div className="mt-0.5 ml-3 pl-3 border-l border-border-light">
{module.items.map((item) => (
<button
key={item.id}
onClick={() => handleItemClick(module.id, item.id)}
className={`w-full text-left px-3 py-[7px] rounded text-[13px] font-medium transition-colors ${
activePage === item.id
? 'bg-bg-active text-primary'
: 'text-text-secondary hover:bg-bg-hover hover:text-text-primary'
}`}
<NavLink
key={item.to}
to={item.to}
data-testid={item.testid}
onClick={onNavigate}
className={({ isActive }) =>
`block w-full text-left px-2.5 py-1.5 rounded-md text-[12.5px] font-medium transition-colors ${
isActive
? 'bg-bg-active text-primary'
: 'text-text-secondary hover:bg-bg-hover hover:text-text-primary'
}`
}
>
{item.label}
</button>
</NavLink>
))}
</div>
)}
</div>
))}
</aside>
</nav>
);
}

View File

@@ -1,44 +1,101 @@
import React from 'react';
interface StatCardProps {
icon?: React.ReactNode;
label: string;
value: string | number;
change?: string;
changeType?: 'up' | 'down' | 'neutral';
progress?: number;
progressColor?: string;
trend?: {
direction: 'up' | 'down' | 'stable';
value: string;
};
sparkline?: {
data: number[];
color: string;
};
color?: string;
onClick?: () => void;
}
export function StatCard({
export const StatCard = React.memo(function StatCard({
icon,
label,
value,
change,
changeType = 'neutral',
progress,
progressColor = 'bg-warning',
trend,
sparkline,
color,
onClick,
}: StatCardProps) {
const trendIndicator = trend ? (
<span
className={`inline-flex items-center gap-0.5 text-[11px] font-medium ${
trend.direction === 'up'
? 'text-danger'
: trend.direction === 'down'
? 'text-success'
: 'text-text-muted'
}`}
>
{trend.direction === 'up' && <span aria-hidden>&#9650;</span>}
{trend.direction === 'down' && <span aria-hidden>&#9660;</span>}
{trend.direction === 'stable' && <span aria-hidden>&#9644;</span>}
{trend.value}
</span>
) : null;
const sparklineSvg =
sparkline && sparkline.data.length >= 2 ? (
<svg width="64" height="26" className="shrink-0" aria-hidden="true">
<polyline
fill="none"
stroke={sparkline.color}
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
points={sparkline.data
.map((val, i) => {
const x = (i / (sparkline.data.length - 1)) * 62 + 1;
const max = Math.max(...sparkline.data);
const min = Math.min(...sparkline.data);
const range = max - min || 1;
const y = 24 - ((val - min) / range) * 20 - 1;
return `${x},${y}`;
})
.join(' ')}
/>
</svg>
) : null;
return (
<div className="card p-4">
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-1.5">
{label}
<div
onClick={onClick}
role={onClick ? 'button' : undefined}
tabIndex={onClick ? 0 : undefined}
onKeyDown={
onClick
? (e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick();
}
}
: undefined
}
className={`stat-card ${onClick ? 'cursor-pointer' : ''}`}
>
<div className="flex items-center gap-2 text-[12px] text-text-secondary mb-1.5 pl-1">
{icon}
<span className="font-medium tracking-wide">{label}</span>
</div>
<div className="font-display text-[26px] font-bold text-text-primary mb-1">
{value}
<div className="flex items-end justify-between gap-2 pl-1">
<div
className="data-num text-[22px] leading-none"
style={color ? { color } : undefined}
>
{value}
</div>
{sparklineSvg}
</div>
{change && (
<div className={`text-[11px] ${
changeType === 'up' ? 'text-danger' :
changeType === 'down' ? 'text-success' : 'text-text-muted'
}`}>
{change}
</div>
)}
{progress !== undefined && (
<div className="h-[3px] bg-bg-page rounded mt-2.5 overflow-hidden">
<div
className={`h-full rounded ${progressColor}`}
style={{ width: `${progress}%` }}
/>
</div>
)}
{trendIndicator && <div className="mt-1.5 pl-1">{trendIndicator}</div>}
</div>
);
}
});

View File

@@ -38,8 +38,9 @@ export function StatisticalCharts({
const secondHalf = values.slice(Math.floor(values.length / 2));
const firstAvg = firstHalf.reduce((a, b) => a + b, 0) / firstHalf.length;
const secondAvg = secondHalf.reduce((a, b) => a + b) / secondHalf.length;
const secondAvg = secondHalf.reduce((a, b) => a + b, 0) / secondHalf.length;
if (firstAvg === 0) return secondAvg > 0 ? 'up' : 'stable';
const change = ((secondAvg - firstAvg) / firstAvg) * 100;
if (change > 10) return 'up';
@@ -48,7 +49,11 @@ export function StatisticalCharts({
};
const stats = useMemo(() => {
if (data.length === 0) return null;
const noData = data.length === 0;
if (noData) {
return { totalCases: 0, avgCases: 0, maxCases: 0, trend: 'stable' as const, noData: true };
}
const totalCases = data.reduce((sum, item) => sum + item.cases, 0);
const avgCases = totalCases / data.length;
@@ -60,6 +65,7 @@ export function StatisticalCharts({
avgCases: Math.round(avgCases),
maxCases,
trend,
noData: false,
};
}, [data]);
@@ -146,7 +152,7 @@ export function StatisticalCharts({
</div>
{/* Stats cards */}
{stats && activeChart === 'cases' && (
{activeChart === 'cases' && (
<div className="grid grid-cols-3 gap-4 mb-4">
<div className="bg-blue-50 rounded-lg p-3">
<div className="text-sm text-gray-600"></div>
@@ -163,6 +169,11 @@ export function StatisticalCharts({
</div>
)}
{/* No data banner */}
{stats.noData && (
<div className="text-center text-sm text-gray-400 py-4"></div>
)}
{/* Chart */}
<div style={{ height }}>
<ResponsiveContainer width="100%" height="100%">

View File

@@ -26,6 +26,11 @@ export function TimelinePlayer({
}: TimelinePlayerProps) {
const [playing, setPlaying] = useState(isPlaying);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const advanceRef = useRef<() => void>(() => {});
useEffect(() => {
setPlaying(isPlaying);
}, [isPlaying]);
const generateDateRange = useCallback((start: string, end: string) => {
const dates: string[] = [];
@@ -40,9 +45,22 @@ export function TimelinePlayer({
return dates;
}, []);
const dateRange = useMemo(() => generateDateRange(startDate, endDate), [startDate, endDate, generateDateRange]);
const currentIndex = useMemo(() => dateRange.indexOf(currentDate), [dateRange, currentDate]);
const progress = useMemo(() => ((currentIndex + 1) / dateRange.length) * 100, [currentIndex, dateRange.length]);
const dateRange = useMemo(
() => generateDateRange(startDate, endDate),
[startDate, endDate, generateDateRange]
);
const currentIndex = useMemo(() => {
if (dateRange.length === 0) return -1;
const ms = new Date(currentDate).getTime() - new Date(startDate).getTime();
const idx = Math.round(ms / 86400000);
return idx >= 0 && idx < dateRange.length ? idx : dateRange.indexOf(currentDate);
}, [startDate, currentDate, dateRange]);
const progress = useMemo(
() => ((currentIndex + 1) / dateRange.length) * 100,
[currentIndex, dateRange.length]
);
const play = useCallback(() => {
setPlaying(true);
@@ -55,11 +73,8 @@ export function TimelinePlayer({
}, [onPlayPause]);
const togglePlay = () => {
if (playing) {
pause();
} else {
play();
}
if (playing) pause();
else play();
};
const goToNext = useCallback(() => {
@@ -72,25 +87,23 @@ export function TimelinePlayer({
};
useEffect(() => {
if (playing) {
const interval = 1000 / speed;
timerRef.current = setInterval(() => {
goToNext();
}, interval);
return () => {
if (timerRef.current) {
clearInterval(timerRef.current);
}
};
}
}, [playing, speed, goToNext]);
advanceRef.current = goToNext;
}, [goToNext]);
useEffect(() => {
if (currentIndex >= dateRange.length - 1) {
pause();
if (playing) {
const interval = 1000 / speed;
timerRef.current = setInterval(() => {
advanceRef.current();
}, interval);
return () => {
if (timerRef.current) clearInterval(timerRef.current);
};
}
}, [playing, speed]);
useEffect(() => {
if (currentIndex >= dateRange.length - 1) pause();
}, [currentIndex, dateRange.length, pause]);
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -99,24 +112,15 @@ export function TimelinePlayer({
};
const handleSpeedChange = () => {
const currentIndex = SPEEDS.indexOf(speed);
const nextIndex = (currentIndex + 1) % SPEEDS.length;
const idx = SPEEDS.indexOf(speed);
const nextIndex = (idx + 1) % SPEEDS.length;
onSpeedChange?.(SPEEDS[nextIndex]);
};
const formatSpeed = (s: number) => {
return s >= 1 ? `${s}x` : `${s.toFixed(1)}x`;
};
const formatSpeed = (s: number) => (s >= 1 ? `${s}x` : `${s.toFixed(1)}x`);
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
const today = new Date();
const isToday = date.toDateString() === today.toDateString();
if (isToday) {
return `今天 ${date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}`;
}
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
@@ -124,72 +128,82 @@ export function TimelinePlayer({
});
};
const fmtShort = (d: string) =>
new Date(d).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
return (
<div className="fixed right-4 top-1/2 -translate-y-1/2 z-[9999] w-64">
<div className="bg-white/95 backdrop-blur-xl border border-gray-200/80 rounded-2xl shadow-[0_8px_32px_rgba(0,0,0,0.12)] px-4 py-3">
{/* Date display */}
<div className="text-center mb-3">
<div className="font-medium text-gray-900 text-sm">{formatDate(currentDate)}</div>
<div className="text-xs text-gray-400 mt-0.5">
{currentIndex + 1} / {dateRange.length}
<div className="timeline-dock" role="region" aria-label="时间轴播放器">
<div className="timeline-dock__inner">
<div className="flex items-center gap-3 sm:gap-4">
{/* 播放控件 */}
<div className="flex items-center gap-1 shrink-0">
<button
type="button"
onClick={goToStart}
className="p-1.5 text-text-muted hover:text-text-primary hover:bg-bg-hover rounded-lg transition-colors"
title="跳到开始"
aria-label="跳到开始"
>
<SkipBack className="w-4 h-4" />
</button>
<button
type="button"
onClick={togglePlay}
className="p-2 bg-primary text-white rounded-xl hover:bg-primary-deep transition-colors shadow-brand"
aria-label={playing ? '暂停' : '播放'}
>
{playing ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4 ml-0.5" />}
</button>
<button
type="button"
onClick={goToNext}
className="p-1.5 text-text-muted hover:text-text-primary hover:bg-bg-hover rounded-lg transition-colors"
title="跳到下一天"
aria-label="跳到下一天"
>
<SkipForward className="w-4 h-4" />
</button>
</div>
</div>
{/* Vertical slider */}
<div className="flex justify-center mb-3">
<input
type="range"
min="0"
max="100"
value={progress}
onChange={handleSliderChange}
className="h-1.5 w-full bg-gray-200 rounded-full appearance-none cursor-pointer accent-blue-600"
style={{
background: `linear-gradient(to right, #2563eb 0%, #2563eb ${progress}%, #e5e7eb ${progress}%, #e5e7eb 100%)`,
}}
/>
</div>
<div className="flex justify-between text-[10px] text-gray-400 mb-3">
<span>{new Date(startDate).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })}</span>
<span>{new Date(endDate).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })}</span>
</div>
{/* 进度轨 */}
<div className="flex-1 min-w-0">
<div className="flex items-baseline justify-between gap-2 mb-1.5">
<div className="font-mono text-[13px] font-semibold tabular-nums text-text-primary">
{formatDate(currentDate)}
</div>
<div className="text-[11px] text-text-muted tabular-nums font-mono">
{currentIndex + 1} / {dateRange.length}
</div>
</div>
<div className="relative">
<div className="timeline-dock__track">
<div className="timeline-dock__fill" style={{ width: `${progress}%` }} />
</div>
<input
type="range"
min="0"
max="100"
value={progress}
onChange={handleSliderChange}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
aria-label="时间进度"
/>
</div>
<div className="flex justify-between text-[10px] text-text-muted mt-1">
<span>{fmtShort(startDate)}</span>
<span>{fmtShort(endDate)}</span>
</div>
</div>
{/* Transport controls */}
<div className="flex items-center justify-center gap-2">
<button
onClick={goToStart}
className="p-1.5 text-gray-400 hover:text-gray-700 hover:bg-gray-100 rounded-full transition-colors"
title="跳到开始"
>
<SkipBack className="w-4 h-4" />
</button>
<button
onClick={togglePlay}
className="p-2.5 bg-blue-600 text-white rounded-full hover:bg-blue-700 transition-colors shadow-md"
>
{playing ? (
<Pause className="w-5 h-5" />
) : (
<Play className="w-5 h-5 ml-0.5" />
)}
</button>
<button
onClick={goToNext}
className="p-1.5 text-gray-400 hover:text-gray-700 hover:bg-gray-100 rounded-full transition-colors"
title="跳到下一天"
>
<SkipForward className="w-4 h-4" />
</button>
</div>
{/* Speed */}
<div className="flex items-center justify-center gap-2 mt-2">
{/* 倍速 */}
<button
type="button"
onClick={handleSpeedChange}
className="px-2 py-0.5 text-xs font-medium text-gray-600 bg-gray-100/80 rounded-full hover:bg-gray-200 transition-colors"
className="shrink-0 px-2.5 py-1 text-[11px] font-mono font-semibold text-text-secondary
bg-bg-hover border border-border rounded-lg hover:border-primary hover:text-primary
transition-colors"
title="调整播放速度"
aria-label={`播放速度 ${formatSpeed(speed)}`}
>
{formatSpeed(speed)}
</button>

View File

@@ -1,7 +1,10 @@
import { useState, useEffect } from 'react';
import { TESTIDS } from '@/utils/testids';
interface TopNavProps {
onLogout?: () => void;
onToggleMenu?: () => void;
isMenuOpen?: boolean;
}
function Clock() {
@@ -10,44 +13,64 @@ function Clock() {
const id = setInterval(() => setTime(new Date()), 1000);
return () => clearInterval(id);
}, []);
return <span>{time.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}</span>;
return (
<span className="font-mono tabular-nums">
{time.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}
</span>
);
}
export function TopNav({ onLogout }: TopNavProps) {
export function TopNav({ onLogout, onToggleMenu, isMenuOpen = false }: TopNavProps) {
return (
<nav className="h-[52px] bg-bg-card border-b border-border flex items-center px-5 fixed top-0 left-0 right-0 z-50">
<div className="flex items-center gap-3">
<div className="w-7 h-7 bg-primary rounded-md flex items-center justify-center">
<nav className="h-[54px] shrink-0 bg-bg-card/95 border-b border-border flex items-center px-4 sm:px-5 z-50 backdrop-blur-md">
{onToggleMenu && (
<button
type="button"
onClick={onToggleMenu}
aria-label="打开菜单"
aria-expanded={isMenuOpen}
aria-controls="app-drawer"
data-testid={TESTIDS.hamburger}
className="lg:hidden mr-2.5 -ml-0.5 w-9 h-9 flex items-center justify-center rounded-lg text-text-secondary hover:bg-bg-hover transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
)}
<div className="flex items-center gap-3 min-w-0">
<div
className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-mist flex items-center justify-center shadow-soft shrink-0"
aria-hidden
>
<svg className="w-4 h-4 fill-white" viewBox="0 0 24 24">
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-7 3c1.93 0 3.5 1.57 3.5 3.5S13.93 13 12 13s-3.5-1.57-3.5-3.5S10.07 6 12 6zm7 13H5v-.23c0-.62.28-1.2.76-1.58C7.47 15.82 9.64 15 12 15s4.53.82 6.24 2.19c.48.38.76.97.76 1.58V19z"/>
<path d="M12 3c-1.2 2.4-3.5 4-6 4 .6 3.4 2.8 6.2 6 7.5 3.2-1.3 5.4-4.1 6-7.5-2.5 0-4.8-1.6-6-4zm0 14.5c-2.2-.9-4-2.5-5.2-4.5C5.5 15.2 4 18 4 21h16c0-3-1.5-5.8-2.8-8-1.2 2-3 3.6-5.2 4.5z" />
</svg>
</div>
<span className="font-display font-semibold text-[15px] text-text-primary">
WuhanChildRisk
</span>
<div className="min-w-0 leading-tight">
<span className="brand-mark text-[16px] block truncate">CBPOA</span>
<span className="text-[11px] text-text-muted hidden sm:block truncate">
</span>
</div>
</div>
<div className="w-px h-5 bg-border ml-4 mr-4" />
<div className="w-px h-6 bg-border ml-4 mr-4 hidden md:block" />
<span className="text-[13px] text-text-secondary">
<span className="text-[12px] text-text-secondary hidden md:inline truncate">
· ·
</span>
<div className="ml-auto flex items-center gap-5">
<span className="text-[12px] text-text-muted">
<div className="ml-auto flex items-center gap-4">
<span className="text-[12px] text-text-muted hidden sm:inline">
<Clock />
</span>
<div className="flex items-center gap-2 text-[13px] text-text-secondary">
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
</svg>
admin
</div>
{onLogout && (
<button
type="button"
onClick={onLogout}
className="text-[12px] text-text-muted hover:text-danger transition-colors"
className="text-[12px] text-text-muted hover:text-danger transition-colors px-2 py-1 rounded-md hover:bg-danger-light/60"
>
退
</button>

View File

@@ -0,0 +1,108 @@
import React from 'react';
import type { CellInfo } from '@/components/AlertMap';
import { HORIZON_LABELS } from './types';
import type { ExtendedAlert } from './types';
interface CellInfoPanelProps {
cellInfo: CellInfo;
onClose: () => void;
}
// Cell info panel - shown when clicking grid cell without alert
export const CellInfoPanel = React.memo(function CellInfoPanel({ cellInfo, onClose }: CellInfoPanelProps) {
return (
<div className="fixed bottom-5 left-1/2 -translate-x-1/2 bg-bg-card rounded-lg border border-border-light shadow-lg z-50 px-5 py-4 min-w-[320px]">
<div className="flex items-center justify-between mb-3">
<span className="text-[14px] font-semibold text-text-primary"> (100m)</span>
<button onClick={onClose} className="text-text-muted hover:text-text-primary text-[18px] leading-none">&times;</button>
</div>
<div className="space-y-2 text-[12px]">
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span className="font-mono text-text-primary">{cellInfo.grid_id}</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span className="font-mono text-text-primary">{cellInfo.lat.toFixed(4)}, {cellInfo.lon.toFixed(4)}</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span className={`font-bold ${cellInfo.risk >= 0.8 ? 'text-danger' : cellInfo.risk >= 0.6 ? 'text-warning' : cellInfo.risk >= 0.4 ? 'text-primary' : 'text-success'}`}>
{(cellInfo.risk * 100).toFixed(1)}%
</span>
</div>
<div className="flex gap-3 pt-1">
<div className="flex-1 text-center p-1.5 rounded bg-bg-page">
<div className="text-[10px] text-text-muted">1</div>
<div className="font-bold text-[13px]">{(cellInfo.risk_1d * 100).toFixed(0)}%</div>
</div>
<div className="flex-1 text-center p-1.5 rounded bg-bg-page">
<div className="text-[10px] text-text-muted">3</div>
<div className="font-bold text-[13px]">{(cellInfo.risk_3d * 100).toFixed(0)}%</div>
</div>
<div className="flex-1 text-center p-1.5 rounded bg-bg-page">
<div className="text-[10px] text-text-muted">7</div>
<div className="font-bold text-[13px]">{(cellInfo.risk_7d * 100).toFixed(0)}%</div>
</div>
</div>
{cellInfo.nearestAlertId && (
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span className="text-text-primary">{(cellInfo.nearestAlertDist * 111).toFixed(1)} km</span>
</div>
)}
{!cellInfo.nearestAlertId && (
<div className="text-[11px] text-text-muted mt-1 pt-2 border-t border-border">
</div>
)}
</div>
</div>
);
});
interface AlertDetailModalProps {
alert: ExtendedAlert;
onClose: () => void;
}
// Alert detail modal
export const AlertDetailModal = React.memo(function AlertDetailModal({ alert, onClose }: AlertDetailModalProps) {
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center" onClick={onClose}>
<div className="bg-bg-card rounded-lg p-6 max-w-md w-full mx-4" onClick={e => e.stopPropagation()}>
<h3 className="font-display text-[16px] font-semibold mb-3"></h3>
<div className="space-y-2 text-[13px]">
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span className={`font-bold ${alert.priority === 'P1' ? 'text-danger' : 'text-warning'}`}>
{alert.priority}
</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span className="font-bold">{Math.round(alert.risk_value * 100)}%</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span>{HORIZON_LABELS[alert.forecast_horizon]}</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span>{alert.region}</span>
</div>
<div className="pt-2 border-t border-border">
<div className="text-text-muted mb-1"></div>
<div className="text-[12px]">{alert.reason}</div>
</div>
</div>
<button
onClick={onClose}
className="mt-4 w-full px-4 py-2 bg-primary text-white rounded hover:bg-primary/80 transition-colors text-[13px]"
>
</button>
</div>
</div>
);
});

View File

@@ -0,0 +1,179 @@
import React from 'react';
import { TESTIDS } from '@/utils/testids';
import { HORIZON_LABELS } from './types';
interface AlertsFilterBarProps {
selectedHorizon: number | 'all';
onHorizonChange: (horizon: number | 'all') => void;
selectedPriority: 'all' | 'P1' | 'P2';
onPriorityChange: (priority: 'all' | 'P1' | 'P2') => void;
riskRange: [number, number];
onRiskRangeChange: (range: [number, number]) => void;
showMap: boolean;
onToggleMap: () => void;
showAlertMarkers: boolean;
onToggleAlertMarkers: () => void;
showGrid: boolean;
onToggleGrid: () => void;
sortBy: 'risk' | 'time';
onSortByChange: (sortBy: 'risk' | 'time') => void;
}
export const AlertsFilterBar = React.memo(function AlertsFilterBar({
selectedHorizon,
onHorizonChange,
selectedPriority,
onPriorityChange,
riskRange,
onRiskRangeChange,
showMap,
onToggleMap,
showAlertMarkers,
onToggleAlertMarkers,
showGrid,
onToggleGrid,
sortBy,
onSortByChange,
}: AlertsFilterBarProps) {
return (
<div className="card p-3 mb-4">
<div className="flex items-center gap-4 flex-wrap">
<div className="flex items-center gap-2">
<span className="text-[12px] text-text-muted"></span>
<div className="flex gap-1">
{(['all', 1, 3, 7] as const).map((horizon) => (
<button
key={horizon}
onClick={() => onHorizonChange(horizon)}
className={`px-3 py-1.5 text-[12px] font-medium rounded transition-colors ${
selectedHorizon === horizon
? 'bg-primary text-white'
: 'bg-bg-page text-text-secondary border border-border hover:border-primary'
}`}
>
{horizon === 'all' ? '全部' : HORIZON_LABELS[horizon]}
</button>
))}
</div>
</div>
<div className="w-px h-6 bg-border" />
<div className="flex items-center gap-2">
<span className="text-[12px] text-text-muted"></span>
<div className="flex gap-1">
{(['all', 'P1', 'P2'] as const).map((priority) => (
<button
key={priority}
onClick={() => onPriorityChange(priority)}
className={`px-3 py-1.5 text-[12px] font-medium rounded transition-colors ${
selectedPriority === priority
? priority === 'P1'
? 'bg-danger text-white'
: priority === 'P2'
? 'bg-warning text-white'
: 'bg-primary text-white'
: 'bg-bg-page text-text-secondary border border-border hover:border-primary'
}`}
>
{priority === 'all' ? '全部' : priority}
</button>
))}
</div>
</div>
<div className="w-px h-6 bg-border" />
<div className="flex items-center gap-2">
<span className="text-[12px] text-text-muted"></span>
<div className="flex items-center gap-2">
<input
type="number"
min={0}
max={1}
step={0.05}
value={riskRange[0]}
onChange={(e) => onRiskRangeChange([parseFloat(e.target.value) || 0, riskRange[1]])}
className="w-16 px-2 py-1.5 text-[12px] border border-border rounded bg-bg-page text-text-primary focus:outline-none focus:border-primary"
/>
<span className="text-[12px] text-text-muted">-</span>
<input
type="number"
min={0}
max={1}
step={0.05}
value={riskRange[1]}
onChange={(e) => onRiskRangeChange([riskRange[0], parseFloat(e.target.value) || 1])}
className="w-16 px-2 py-1.5 text-[12px] border border-border rounded bg-bg-page text-text-primary focus:outline-none focus:border-primary"
/>
</div>
</div>
<div className="w-px h-6 bg-border" />
<div className="flex items-center gap-1">
<button
onClick={onToggleMap}
className={`px-2.5 py-1.5 text-[12px] font-medium rounded transition-colors ${
showMap
? 'bg-primary/10 text-primary border border-primary/30'
: 'bg-bg-page text-text-muted border border-border'
}`}
>
</button>
<button
onClick={onToggleAlertMarkers}
className={`px-2.5 py-1.5 text-[12px] font-medium rounded transition-colors ${
showAlertMarkers
? 'bg-primary/10 text-primary border border-primary/30'
: 'bg-bg-page text-text-muted border border-border'
}`}
>
</button>
<div data-testid={TESTIDS.gridLayerWrapper}>
<button
onClick={onToggleGrid}
className={`px-2.5 py-1.5 text-[12px] font-medium rounded transition-colors ${
showGrid
? 'bg-primary/10 text-primary border border-primary/30'
: 'bg-bg-page text-text-muted border border-border'
}`}
>
</button>
</div>
</div>
<div className="w-px h-6 bg-border" />
<div className="flex items-center gap-2">
<span className="text-[12px] text-text-muted"></span>
<div className="flex gap-1">
<button
onClick={() => onSortByChange('risk')}
className={`px-3 py-1.5 text-[12px] font-medium rounded transition-colors ${
sortBy === 'risk'
? 'bg-bg-card text-primary border border-primary'
: 'bg-bg-page text-text-secondary border border-border hover:border-primary'
}`}
>
</button>
<button
onClick={() => onSortByChange('time')}
className={`px-3 py-1.5 text-[12px] font-medium rounded transition-colors ${
sortBy === 'time'
? 'bg-bg-card text-primary border border-primary'
: 'bg-bg-page text-text-secondary border border-border hover:border-primary'
}`}
>
</button>
</div>
</div>
</div>
</div>
);
});

View File

@@ -0,0 +1,74 @@
import React from 'react';
interface AlertsHeaderProps {
total: number;
p1: number;
p2: number;
activeTab: 'list' | 'stats';
onTabChange: (tab: 'list' | 'stats') => void;
}
export const AlertsHeader = React.memo(function AlertsHeader({
total,
p1,
p2,
activeTab,
onTabChange,
}: AlertsHeaderProps) {
return (
<>
<div className="flex items-end justify-between mb-4 flex-wrap gap-x-4 gap-y-3">
<div className="min-w-0">
<h1 className="font-display text-[20px] font-semibold text-text-primary mb-0.5">
</h1>
<p className="text-[12px] text-text-muted truncate">
100m · ·
</p>
</div>
{/* 指挥台式计数,非 badge 堆叠 */}
<div
className="command-rail !flex-none stagger-children"
role="group"
aria-label="预警计数"
>
<div className="command-rail__cell !py-2 !px-4" style={{ flex: '0 0 auto' }}>
<div className="command-rail__label"></div>
<div className="command-rail__value text-[20px]">{total}</div>
</div>
<div className="command-rail__cell !py-2 !px-4" style={{ flex: '0 0 auto' }}>
<div className="command-rail__label text-danger">P1 </div>
<div className="command-rail__value text-[20px] text-danger">{p1}</div>
</div>
<div className="command-rail__cell !py-2 !px-4" style={{ flex: '0 0 auto' }}>
<div className="command-rail__label text-warning">P2 </div>
<div className="command-rail__value text-[20px] text-warning">{p2}</div>
</div>
</div>
</div>
<div className="tab-strip mb-4 border-b border-border" role="tablist" aria-label="预警视图">
{(
[
{ key: 'list' as const, label: '预警列表' },
{ key: 'stats' as const, label: '风险统计' },
] as const
).map((tab) => (
<button
key={tab.key}
type="button"
role="tab"
aria-selected={activeTab === tab.key}
onClick={() => onTabChange(tab.key)}
className={`tab-strip__item ${
activeTab === tab.key ? 'tab-strip__item--active' : ''
}`}
>
{tab.label}
</button>
))}
</div>
</>
);
});

View File

@@ -0,0 +1,157 @@
import React, { useCallback } from 'react';
import { HORIZON_LABELS } from './types';
import type { ExtendedAlert, RiskStats } from './types';
interface RiskDistributionSummaryProps {
riskStats: RiskStats;
total: number;
}
/** 预警列表顶部风险分布 — 连续 risk-strip非四块同质小卡。 */
export const RiskDistributionSummary = React.memo(function RiskDistributionSummary({
riskStats,
total,
}: RiskDistributionSummaryProps) {
const pct = (n: number) => (total > 0 ? (n / total) * 100 : 0);
return (
<div className="risk-strip mb-4" role="group" aria-label="风险分布">
<div className="risk-strip__cell">
<div className="risk-strip__label"> (0.8)</div>
<div className="risk-strip__value text-danger">{riskStats.high}</div>
<div className="mt-2 h-1 bg-bg-hover rounded-full overflow-hidden">
<div className="h-full bg-danger rounded-full" style={{ width: `${pct(riskStats.high)}%` }} />
</div>
</div>
<div className="risk-strip__cell">
<div className="risk-strip__label"> (0.60.8)</div>
<div className="risk-strip__value text-warning">{riskStats.mediumHigh}</div>
<div className="mt-2 h-1 bg-bg-hover rounded-full overflow-hidden">
<div
className="h-full bg-warning rounded-full"
style={{ width: `${pct(riskStats.mediumHigh)}%` }}
/>
</div>
</div>
<div className="risk-strip__cell">
<div className="risk-strip__label"> (0.40.6)</div>
<div className="risk-strip__value text-primary">{riskStats.medium}</div>
<div className="mt-2 h-1 bg-bg-hover rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full"
style={{ width: `${pct(riskStats.medium)}%` }}
/>
</div>
</div>
<div className="risk-strip__cell">
<div className="risk-strip__label"></div>
<div className="risk-strip__value">{(riskStats.avgRisk * 100).toFixed(1)}%</div>
<div className="mt-2 text-[10px] text-text-muted truncate">
{' '}
{riskStats.topDistricts
.slice(0, 2)
.map(([d, n]) => `${d}(${n})`)
.join(' · ') || '—'}
</div>
</div>
</div>
);
});
interface AlertCardProps {
alert: ExtendedAlert;
isSelected?: boolean;
alertId: string;
onCardClick: (id: string) => void;
}
const AlertCard = React.memo(function AlertCard({ alert, isSelected, alertId, onCardClick }: AlertCardProps) {
const isP1 = alert.priority === 'P1';
const riskPercent = Math.round(alert.risk_value * 100);
const handleClick = useCallback(() => {
onCardClick(alertId);
}, [alertId, onCardClick]);
return (
<div
className={`relative overflow-hidden rounded-xl border transition-all cursor-pointer ${
isSelected
? 'border-primary bg-primary-muted/40 shadow-soft ring-1 ring-primary/30'
: 'border-border/80 bg-bg-card/90 hover:border-primary/40 hover:shadow-soft'
}`}
onClick={handleClick}
>
<div
className={`absolute left-0 top-0 bottom-0 w-1 ${isP1 ? 'bg-danger' : 'bg-warning'}`}
aria-hidden
/>
<div className="pl-4 pr-3.5 py-3">
<div className="flex items-center justify-between gap-2 mb-2">
<div className="flex items-center gap-2 min-w-0">
<span
className={`text-[10px] font-bold tracking-wide px-1.5 py-0.5 rounded ${
isP1 ? 'bg-danger-light text-danger' : 'bg-warning-light text-warning'
}`}
>
{alert.priority}
</span>
<span className="text-[10px] text-text-muted truncate">
{HORIZON_LABELS[alert.forecast_horizon] || '未知'}
</span>
</div>
<span className={`data-num text-[18px] ${isP1 ? 'text-danger' : 'text-warning'}`}>
{riskPercent}%
</span>
</div>
<div className="mb-2">
<div className="text-[13px] font-semibold text-text-primary truncate">
{alert.region} · {alert.street}
</div>
<div className="text-[11px] text-text-muted font-mono">{alert.grid_id}</div>
</div>
<div
className={`text-[12px] px-2.5 py-1.5 rounded-lg mb-2 line-clamp-2 ${
isP1 ? 'bg-danger/10 text-danger' : 'bg-warning/10 text-warning'
}`}
>
{alert.reason}
</div>
<div className="flex items-center justify-between text-[10px] text-text-muted gap-2">
<span className="truncate"> {alert.forecast_time}</span>
<span className="shrink-0"> {alert.timestamp}</span>
</div>
</div>
</div>
);
});
interface AlertsListProps {
filteredAlerts: ExtendedAlert[];
selectedAlert: string | null;
onCardClick: (id: string) => void;
}
export const AlertsList = React.memo(function AlertsList({ filteredAlerts, selectedAlert, onCardClick }: AlertsListProps) {
return (
<div className="space-y-2 max-h-none overflow-visible">
{filteredAlerts.slice(0, 50).map((alert) => (
<AlertCard
key={alert.alert_id}
alert={alert}
isSelected={selectedAlert === alert.alert_id}
alertId={alert.alert_id}
onCardClick={onCardClick}
/>
))}
{filteredAlerts.length > 50 && (
<div className="text-center text-text-muted text-[12px] py-2">
{filteredAlerts.length - 50}
</div>
)}
</div>
);
});

View File

@@ -0,0 +1,133 @@
import React from 'react';
import { LoadingState } from '@/components/ui';
import type { CellInfo } from '@/components/AlertMap';
import { AlertsToolbar } from './AlertsToolbar';
import { AlertsFilterBar } from './AlertsFilterBar';
import { AlertsMapPanel } from './AlertsMapPanel';
import { AlertsList, RiskDistributionSummary } from './AlertsList';
import type { ExtendedAlert, RiskStats } from './types';
interface AlertsListTabProps {
forecastDay: 1 | 3 | 7;
onForecastDayChange: (day: 1 | 3 | 7) => void;
isFullscreen: boolean;
onToggleFullscreen: () => void;
onExportCsv: () => void;
onExportJson: () => void;
selectedHorizon: number | 'all';
onHorizonChange: (horizon: number | 'all') => void;
selectedPriority: 'all' | 'P1' | 'P2';
onPriorityChange: (priority: 'all' | 'P1' | 'P2') => void;
riskRange: [number, number];
onRiskRangeChange: (range: [number, number]) => void;
showMap: boolean;
onToggleMap: () => void;
showAlertMarkers: boolean;
onToggleAlertMarkers: () => void;
showGrid: boolean;
onToggleGrid: () => void;
sortBy: 'risk' | 'time';
onSortByChange: (sortBy: 'risk' | 'time') => void;
riskStats: RiskStats;
filteredAlerts: ExtendedAlert[];
isLoading: boolean;
selectedGridId: string | null;
selectedAlert: string | null;
onGridClick: (gridId: string) => void;
onCellInfo: (info: CellInfo) => void;
onCardClick: (id: string) => void;
}
export const AlertsListTab = React.memo(function AlertsListTab(props: AlertsListTabProps) {
const { filteredAlerts, isLoading, isFullscreen, showMap, riskStats } = props;
return (
<>
<AlertsToolbar
forecastDay={props.forecastDay}
onForecastDayChange={props.onForecastDayChange}
isFullscreen={isFullscreen}
onToggleFullscreen={props.onToggleFullscreen}
onExportCsv={props.onExportCsv}
onExportJson={props.onExportJson}
/>
<AlertsFilterBar
selectedHorizon={props.selectedHorizon}
onHorizonChange={props.onHorizonChange}
selectedPriority={props.selectedPriority}
onPriorityChange={props.onPriorityChange}
riskRange={props.riskRange}
onRiskRangeChange={props.onRiskRangeChange}
showMap={showMap}
onToggleMap={props.onToggleMap}
showAlertMarkers={props.showAlertMarkers}
onToggleAlertMarkers={props.onToggleAlertMarkers}
showGrid={props.showGrid}
onToggleGrid={props.onToggleGrid}
sortBy={props.sortBy}
onSortByChange={props.onSortByChange}
/>
<RiskDistributionSummary riskStats={riskStats} total={filteredAlerts.length} />
{isLoading ? (
<div className="workbench-panel p-8">
<LoadingState />
</div>
) : filteredAlerts.length === 0 && !showMap ? (
<div className="workbench-panel p-8 text-center">
<svg
className="w-12 h-12 mx-auto mb-3 text-text-muted opacity-50"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z" />
</svg>
<div className="text-text-muted text-[13px]"></div>
</div>
) : (
<div
className={`grid gap-0 overflow-hidden rounded-2xl border border-border shadow-soft ${
isFullscreen ? 'grid-cols-1' : 'grid-cols-1 lg:grid-cols-[1fr_380px]'
}`}
>
{showMap && (
<div className="map-stage min-h-[420px] relative">
<div className="map-chrome">
<div className="map-chrome__chip">
<span className="h-1.5 w-1.5 rounded-full bg-danger animate-pulse" aria-hidden />
<span className="text-[12px] font-semibold text-text-primary"></span>
</div>
</div>
<div className="absolute inset-0">
<AlertsMapPanel
selectedGridId={props.selectedGridId}
onGridClick={props.onGridClick}
onCellInfo={props.onCellInfo}
forecastDay={props.forecastDay}
showAlertMarkers={props.showAlertMarkers}
showGrid={props.showGrid}
filteredAlerts={filteredAlerts}
isFullscreen={isFullscreen}
/>
</div>
</div>
)}
{!isFullscreen && (
<aside className="glass-wing max-h-[min(720px,70vh)] overflow-auto border-l-0 lg:border-l border-t lg:border-t-0 border-border">
<div className="glass-wing__section !border-b-0 flex-1">
<h3 className="glass-wing__title"></h3>
<AlertsList
filteredAlerts={filteredAlerts}
selectedAlert={props.selectedAlert}
onCardClick={props.onCardClick}
/>
</div>
</aside>
)}
</div>
)}
</>
);
});

View File

@@ -0,0 +1,39 @@
import React from 'react';
import { AlertMap } from '@/components/AlertMap';
import type { CellInfo } from '@/components/AlertMap';
import type { ExtendedAlert } from './types';
interface AlertsMapPanelProps {
selectedGridId: string | null;
onGridClick: (gridId: string) => void;
onCellInfo: (info: CellInfo) => void;
forecastDay: 1 | 3 | 7;
showAlertMarkers: boolean;
showGrid: boolean;
filteredAlerts: ExtendedAlert[];
isFullscreen: boolean;
}
export const AlertsMapPanel = React.memo(function AlertsMapPanel({
selectedGridId,
onGridClick,
onCellInfo,
forecastDay,
showAlertMarkers,
showGrid,
filteredAlerts,
isFullscreen,
}: AlertsMapPanelProps) {
return (
<AlertMap
selectedGridId={selectedGridId}
onGridClick={onGridClick}
onCellInfo={onCellInfo}
forecastDay={forecastDay}
showAlertMarkers={showAlertMarkers}
showGrid={showGrid}
filteredAlerts={filteredAlerts}
isFullscreen={isFullscreen}
/>
);
});

View File

@@ -0,0 +1,159 @@
import React, { useMemo } from 'react';
import { LoadingState } from '@/components/ui';
import { StatisticalCharts } from '@/components/StatisticalCharts';
import { PieChart, Pie, Cell, Tooltip as RechartsTooltip, Legend, ResponsiveContainer } from 'recharts';
import type { RiskStats } from './types';
interface AlertsRiskPanelProps {
riskStats: RiskStats;
trendData: Array<{ date: string; cases: number; risk: number }>;
trendLoading: boolean;
trendError: string | null;
}
export const AlertsRiskPanel = React.memo(function AlertsRiskPanel({
riskStats,
trendData,
trendLoading,
trendError,
}: AlertsRiskPanelProps) {
const alertPie = useMemo(
() => [
{ name: 'P1 (紧急)', value: riskStats.p1, color: '#C2410C' },
{ name: 'P2 (关注)', value: riskStats.p2, color: '#C27803' },
],
[riskStats.p1, riskStats.p2]
);
const topDistrictMax = useMemo(
() => riskStats.topDistricts.reduce((m, [, n]) => Math.max(m, n), 0),
[riskStats.topDistricts]
);
return (
<div className="space-y-5">
{/* 风险分布连续条 */}
<div className="risk-strip" role="group" aria-label="风险等级统计">
<div className="risk-strip__cell">
<div className="risk-strip__label"> (0.8)</div>
<div className="risk-strip__value text-danger">{riskStats.high}</div>
</div>
<div className="risk-strip__cell">
<div className="risk-strip__label"> (0.60.8)</div>
<div className="risk-strip__value text-warning">{riskStats.mediumHigh}</div>
</div>
<div className="risk-strip__cell">
<div className="risk-strip__label"> (0.40.6)</div>
<div className="risk-strip__value text-primary">{riskStats.medium}</div>
</div>
<div className="risk-strip__cell">
<div className="risk-strip__label"></div>
<div className="risk-strip__value">{(riskStats.avgRisk * 100).toFixed(1)}%</div>
</div>
</div>
{/* 趋势图 */}
<section className="workbench-panel" aria-label="风险趋势">
<div className="workbench-panel__head">
<div>
<h3 className="workbench-panel__title"></h3>
<p className="workbench-panel__sub"> 14 </p>
</div>
</div>
<div className="workbench-panel__body">
{trendLoading ? (
<LoadingState />
) : trendError ? (
<div className="text-center py-8 text-danger text-[13px]">{trendError}</div>
) : trendData.length === 0 ? (
<div className="text-center py-8 text-text-muted text-[13px]"></div>
) : (
<StatisticalCharts data={trendData} showCases={false} showRisk height={280} />
)}
</div>
</section>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
<section className="workbench-panel" aria-label="高风险区域">
<div className="workbench-panel__head">
<div>
<h3 className="workbench-panel__title"> Top 5</h3>
<p className="workbench-panel__sub"></p>
</div>
</div>
<div className="workbench-panel__body space-y-3">
{riskStats.topDistricts.length > 0 ? (
riskStats.topDistricts.map(([district, count], i) => (
<div key={district} className="district-bar !cursor-default hover:!bg-transparent !px-0">
<div className="w-4 text-[10px] font-mono text-text-muted tabular-nums">{i + 1}</div>
<div className="w-16 text-[12px] font-medium text-text-secondary shrink-0 truncate">
{district}
</div>
<div className="district-bar__track">
<div
className="district-bar__fill bg-danger/80"
style={{
width: `${topDistrictMax > 0 ? (count / topDistrictMax) * 100 : 0}%`,
}}
/>
</div>
<div className="w-12 text-right data-num text-[12px]">{count}</div>
</div>
))
) : (
<div className="text-center py-8 text-text-muted text-[13px]"></div>
)}
</div>
</section>
<section className="workbench-panel" aria-label="预警严重度">
<div className="workbench-panel__head">
<div>
<h3 className="workbench-panel__title"></h3>
<p className="workbench-panel__sub">P1 / P2 </p>
</div>
</div>
<div className="workbench-panel__body">
{riskStats.p1 > 0 || riskStats.p2 > 0 ? (
<ResponsiveContainer width="100%" height={240}>
<PieChart>
<Pie
data={alertPie}
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={80}
paddingAngle={4}
dataKey="value"
nameKey="name"
>
{alertPie.map((entry) => (
<Cell key={entry.name} fill={entry.color} />
))}
</Pie>
<RechartsTooltip
contentStyle={{
backgroundColor: '#FFFFFF',
border: '1px solid #D4DEE4',
borderRadius: '10px',
fontSize: '12px',
}}
formatter={(value: number, name: string) => [value, name]}
/>
<Legend
wrapperStyle={{ fontSize: '12px' }}
formatter={(value: string) => (
<span className="text-text-secondary">{value}</span>
)}
/>
</PieChart>
</ResponsiveContainer>
) : (
<div className="text-center py-8 text-text-muted text-[13px]"></div>
)}
</div>
</section>
</div>
</div>
);
});

View File

@@ -0,0 +1,73 @@
import React from 'react';
interface AlertsToolbarProps {
forecastDay: 1 | 3 | 7;
onForecastDayChange: (day: 1 | 3 | 7) => void;
isFullscreen: boolean;
onToggleFullscreen: () => void;
onExportCsv: () => void;
onExportJson: () => void;
}
// Toolbar Row 1: 网格预测时效 + 全屏 + 导出.
export const AlertsToolbar = React.memo(function AlertsToolbar({
forecastDay,
onForecastDayChange,
isFullscreen,
onToggleFullscreen,
onExportCsv,
onExportJson,
}: AlertsToolbarProps) {
return (
<div className="card p-3 mb-3">
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-2">
<span className="text-[12px] text-text-muted"></span>
<div className="flex gap-0.5 bg-bg-page p-0.5 rounded">
{([1, 3, 7] as const).map((day) => (
<button
key={day}
onClick={() => onForecastDayChange(day)}
className={`px-3 py-1 text-[12px] font-medium rounded transition-colors ${
forecastDay === day
? 'bg-bg-card text-primary shadow-sm'
: 'text-text-secondary hover:text-text-primary'
}`}
>
{day}
</button>
))}
</div>
</div>
<div className="w-px h-6 bg-border" />
<button
onClick={onToggleFullscreen}
className={`px-3 py-1.5 text-[12px] font-medium rounded transition-colors ${
isFullscreen
? 'bg-bg-card text-primary border border-primary'
: 'bg-bg-page text-text-secondary border border-border'
}`}
>
{isFullscreen ? '退出全屏' : '全屏'}
</button>
<div className="w-px h-6 bg-border" />
<button
onClick={onExportCsv}
className="px-3 py-1.5 text-[12px] font-medium rounded bg-bg-page text-text-secondary border border-border hover:border-primary transition-colors"
>
CSV
</button>
<button
onClick={onExportJson}
className="px-3 py-1.5 text-[12px] font-medium rounded bg-bg-page text-text-secondary border border-border hover:border-primary transition-colors"
>
JSON
</button>
</div>
</div>
);
});

View File

@@ -0,0 +1,32 @@
// Shared types for the alerts dashboard subcomponents.
export interface ExtendedAlert {
alert_id: string;
grid_id: string;
region: string;
street: string;
latitude: number;
longitude: number;
risk_value: number;
risk_level: 'high' | 'medium_high' | 'medium' | 'medium_low' | 'low';
priority: 'P1' | 'P2';
forecast_horizon: number;
forecast_time: string;
reason: string;
timestamp: string;
}
export const HORIZON_LABELS: Record<number, string> = {
1: '1 天后',
3: '3 天后',
7: '7 天后',
};
export interface RiskStats {
p1: number;
p2: number;
high: number;
mediumHigh: number;
medium: number;
avgRisk: number;
topDistricts: [string, number][];
}

View File

@@ -0,0 +1,163 @@
import { useState, useMemo, useEffect, useCallback } from 'react';
import { useRiskStore } from '@/stores';
import { analysisApi } from '@/services/api';
import type { ExtendedAlert, RiskStats } from './types';
interface UseAlertsDataParams {
selectedHorizon: number | 'all';
selectedPriority: 'all' | 'P1' | 'P2';
sortBy: 'risk' | 'time';
debouncedRiskRange: [number, number];
activeTab: 'list' | 'stats';
}
interface TrendPoint { date: string; cases: number; risk: number }
// 预警仪表盘的数据层:派生 extendedAlerts/filteredAlerts/riskStats、按需拉取风险趋势、
// 以及 CSV/JSON 导出辅助。角色/隐私计算保留在 orchestrator不在此处。
export function useAlertsData({
selectedHorizon,
selectedPriority,
sortBy,
debouncedRiskRange,
activeTab,
}: UseAlertsDataParams) {
const alerts = useRiskStore((s) => s.alerts);
// Risk-trend data for the 风险统计 tab, fetched on demand
const [trendData, setTrendData] = useState<TrendPoint[]>([]);
const [trendLoading, setTrendLoading] = useState(false);
const [trendError, setTrendError] = useState<string | null>(null);
const [trendLoaded, setTrendLoaded] = useState(false);
// Fetch real risk-trend data when the 风险统计 tab is first opened
useEffect(() => {
if (activeTab !== 'stats' || trendLoaded) return;
let cancelled = false;
setTrendLoading(true);
setTrendError(null);
analysisApi
.getTrend(14)
.then((res: { dates?: string[]; values?: number[] }) => {
if (cancelled) return;
const dates = res?.dates ?? [];
const values = res?.values ?? [];
setTrendData(dates.map((date, i) => ({ date, cases: 0, risk: values[i] ?? 0 })));
setTrendLoaded(true);
})
.catch((err: unknown) => {
if (cancelled) return;
setTrendError(err instanceof Error ? err.message : '加载风险趋势失败');
})
.finally(() => {
if (!cancelled) setTrendLoading(false);
});
return () => { cancelled = true; };
}, [activeTab, trendLoaded]);
const extendedAlerts: ExtendedAlert[] = useMemo(() => {
const now = Date.now();
return (alerts || []).map((alert) => {
const forecastDate = new Date(alert.forecast_time);
const diffDays = Math.ceil((forecastDate.getTime() - now) / (1000 * 60 * 60 * 24));
const horizon = diffDays <= 1 ? 1 : diffDays <= 3 ? 3 : 7;
return {
...alert,
latitude: alert.latitude || 0,
longitude: alert.longitude || 0,
forecast_horizon: horizon,
};
});
}, [alerts]);
const filteredAlerts = useMemo(() => {
return extendedAlerts
.filter((alert) => {
const horizonMatch = selectedHorizon === 'all' || alert.forecast_horizon === selectedHorizon;
const priorityMatch = selectedPriority === 'all' || alert.priority === selectedPriority;
const riskMatch = alert.risk_value >= debouncedRiskRange[0] && alert.risk_value <= debouncedRiskRange[1];
return horizonMatch && priorityMatch && riskMatch;
})
.sort((a, b) => {
if (sortBy === 'risk') {
return b.risk_value - a.risk_value;
}
return new Date(b.forecast_time).getTime() - new Date(a.forecast_time).getTime();
});
}, [extendedAlerts, selectedHorizon, selectedPriority, sortBy, debouncedRiskRange]);
// Risk distribution stats (includes p1/p2 counts) — single pass over each array
const riskStats: RiskStats = useMemo(() => {
// p1/p2 reflect the full (unfiltered) alert set
let p1 = 0;
let p2 = 0;
for (const a of extendedAlerts) {
if (a.priority === 'P1') p1++;
else if (a.priority === 'P2') p2++;
}
// Single pass over filteredAlerts: counters + sum + district map
let high = 0;
let mediumHigh = 0;
let medium = 0;
let sum = 0;
const byDistrict: Record<string, number> = {};
for (const a of filteredAlerts) {
const v = a.risk_value;
if (v >= 0.8) high++;
else if (v >= 0.6) mediumHigh++;
else if (v >= 0.4) medium++;
sum += v;
const d = a.region || '未知';
byDistrict[d] = (byDistrict[d] || 0) + 1;
}
const avgRisk = filteredAlerts.length > 0 ? sum / filteredAlerts.length : 0;
const topDistricts = Object.entries(byDistrict)
.sort((a, b) => b[1] - a[1])
.slice(0, 5);
return { p1, p2, high, mediumHigh, medium, avgRisk, topDistricts };
}, [extendedAlerts, filteredAlerts]);
// Export utilities
const exportToCsv = useCallback(() => {
const headers = ['alert_id', 'grid_id', 'region', 'street', 'latitude', 'longitude', 'risk_value', 'priority', 'forecast_horizon', 'reason', 'timestamp'];
const rows = filteredAlerts.map(a => [
a.alert_id, a.grid_id, a.region, a.street,
a.latitude, a.longitude, a.risk_value, a.priority,
a.forecast_horizon, `"${a.reason}"`, a.timestamp,
]);
const csv = [headers.join(','), ...rows.map(r => r.join(','))].join('\n');
const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `alerts_${new Date().toISOString().split('T')[0]}.csv`;
a.click();
URL.revokeObjectURL(url);
}, [filteredAlerts]);
const exportToJson = useCallback(() => {
const json = JSON.stringify(filteredAlerts, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `alerts_${new Date().toISOString().split('T')[0]}.json`;
a.click();
URL.revokeObjectURL(url);
}, [filteredAlerts]);
return {
extendedAlerts,
filteredAlerts,
riskStats,
trendData,
trendLoading,
trendError,
exportToCsv,
exportToJson,
};
}

View File

@@ -0,0 +1,87 @@
import { memo } from 'react';
import { CLINICAL_COLORS } from './chartColors';
export interface BoxRow {
label: string;
p25: number;
median: number;
p75: number;
n: number;
}
interface BoxPlotRowsProps {
rows: BoxRow[];
/** 数值单位后缀,如 "天" / ""。 */
unit?: string;
/** 标签列宽px。 */
labelWidth?: number;
}
/**
* 横向箱线图p25中位p75。Recharts 无原生 box plot
* 故用纯 div 渲染:每行一条从 p25 到 p75 的横条,中位处一根竖向刻度。
* 复用于「各病种住院天数」与「年龄别BMI」。
*/
export const BoxPlotRows = memo(function BoxPlotRows({
rows,
unit = '',
labelWidth = 96,
}: BoxPlotRowsProps) {
if (!rows || rows.length === 0) {
return <div className="text-center py-8 text-text-muted text-sm"></div>;
}
// 统一横轴域:覆盖所有行的 p25..p75留一点边距。
const domainMin = Math.min(...rows.map((r) => r.p25));
const domainMax = Math.max(...rows.map((r) => r.p75));
const span = domainMax - domainMin || 1;
const pct = (v: number) => ((v - domainMin) / span) * 100;
return (
<div className="space-y-2.5">
{rows.map((r) => {
const left = pct(r.p25);
const right = pct(r.p75);
const width = Math.max(right - left, 0.5);
const medianLeft = pct(r.median);
return (
<div key={r.label} className="flex items-center gap-2 text-[11px]">
<div
className="shrink-0 truncate text-text-secondary text-right"
style={{ width: labelWidth }}
title={r.label}
>
{r.label}
</div>
<div className="relative flex-1 h-5 rounded bg-bg-hover">
{/* p25p75 箱体 */}
<div
className="absolute top-1 bottom-1 rounded-sm"
style={{
left: `${left}%`,
width: `${width}%`,
backgroundColor: CLINICAL_COLORS.box,
opacity: 0.35,
}}
/>
{/* 中位刻度 */}
<div
className="absolute top-0.5 bottom-0.5 w-[2px] rounded"
style={{
left: `${medianLeft}%`,
backgroundColor: CLINICAL_COLORS.boxMedian,
}}
title={`中位 ${r.median}${unit}`}
/>
</div>
<div className="shrink-0 w-28 text-text-muted tabular-nums">
{r.p25}<span className="font-semibold text-text-secondary">{r.median}</span>{r.p75}
{unit}
<span className="ml-1 text-[10px] text-text-muted">n={r.n}</span>
</div>
</div>
);
})}
</div>
);
});

View File

@@ -0,0 +1,42 @@
import { memo } from 'react';
import { Users, CalendarDays, HeartPulse, Siren } from 'lucide-react';
import { StatCard } from '@/components/StatCard';
import { TESTIDS } from '@/utils/testids';
import type { InpatientClinicalResponse } from '@/services/api';
interface ClinicalKpiRowProps {
kpis: InpatientClinicalResponse['kpis'];
}
/** 住院临床 4 项核心指标。375px 下 2 列sm 起 4 列。 */
export const ClinicalKpiRow = memo(function ClinicalKpiRow({ kpis }: ClinicalKpiRowProps) {
return (
<div
data-testid={TESTIDS.clinicalKpis}
className="grid grid-cols-2 sm:grid-cols-4 gap-3"
>
<StatCard
icon={<Users className="w-4 h-4 text-primary" />}
label="住院总人次"
value={kpis.total_admissions.toLocaleString()}
/>
<StatCard
icon={<CalendarDays className="w-4 h-4 text-primary" />}
label="中位住院日"
value={`${kpis.median_los_days}`}
/>
<StatCard
icon={<HeartPulse className="w-4 h-4 text-success" />}
label="治愈好转率"
value={`${(kpis.cure_rate * 100).toFixed(1)}%`}
color="#16A34A"
/>
<StatCard
icon={<Siren className="w-4 h-4 text-warning" />}
label="急诊入院占比"
value={`${(kpis.emergency_admit_ratio * 100).toFixed(1)}%`}
color="#D97706"
/>
</div>
);
});

View File

@@ -0,0 +1,56 @@
import { memo } from 'react';
import { PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import { CLINICAL_COLORS, TOOLTIP_STYLE } from './chartColors';
export interface DonutSlice {
name: string;
value: number;
}
interface DonutChartProps {
data: DonutSlice[];
/** name -> color。未命中时按 palette 顺序回退。 */
colorMap?: Record<string, string>;
}
/** 通用环形图。复用于「出院结局构成」与「入院途径构成」。 */
export const DonutChart = memo(function DonutChart({ data, colorMap }: DonutChartProps) {
if (!data || data.length === 0) {
return <div className="text-center py-8 text-text-muted text-sm"></div>;
}
const total = data.reduce((s, d) => s + d.value, 0);
const colorFor = (name: string, idx: number) =>
colorMap?.[name] ??
CLINICAL_COLORS.routePalette[idx % CLINICAL_COLORS.routePalette.length] ??
CLINICAL_COLORS.outcomeFallback;
return (
<ResponsiveContainer width="100%" height={280}>
<PieChart>
<Pie
data={data}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
innerRadius={56}
outerRadius={88}
paddingAngle={2}
>
{data.map((d, idx) => (
<Cell key={d.name} fill={colorFor(d.name, idx)} />
))}
</Pie>
<Tooltip
contentStyle={TOOLTIP_STYLE}
formatter={(v: number, name: string) => [
`${v.toLocaleString()}${total > 0 ? ((v / total) * 100).toFixed(1) : '0'}%`,
name,
]}
/>
<Legend wrapperStyle={{ fontSize: '11px' }} />
</PieChart>
</ResponsiveContainer>
);
});

View File

@@ -0,0 +1,51 @@
import { memo } from 'react';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
import { CLINICAL_COLORS, TOOLTIP_STYLE } from './chartColors';
interface HistogramChartProps {
data: { bin_label: string; count: number }[];
color?: string;
/** tooltip 中数量的标签,如 "住院天数"。 */
countLabel?: string;
}
/** 通用直方图。用于「住院天数分布」。 */
export const HistogramChart = memo(function HistogramChart({
data,
color = CLINICAL_COLORS.los,
countLabel = '人次',
}: HistogramChartProps) {
if (!data || data.length === 0) {
return <div className="text-center py-8 text-text-muted text-sm"></div>;
}
return (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={data} margin={{ top: 5, right: 12, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke={CLINICAL_COLORS.grid} vertical={false} />
<XAxis
dataKey="bin_label"
tick={{ fontSize: 10, fill: CLINICAL_COLORS.axis }}
interval={0}
angle={-30}
textAnchor="end"
height={50}
/>
<YAxis tick={{ fontSize: 10, fill: CLINICAL_COLORS.axis }} width={40} />
<Tooltip
contentStyle={TOOLTIP_STYLE}
formatter={(v: number) => [`${v.toLocaleString()}`, countLabel]}
/>
<Bar dataKey="count" fill={color} radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
);
});

View File

@@ -0,0 +1,31 @@
/**
* 住院临床分析页图表字面色值集中处。
* 对齐江雾青绿 / 雾蓝体系,避免紫系默认配色。
*/
export const CLINICAL_COLORS = {
primary: '#0F766E',
los: '#0F766E',
box: '#14B8A6',
boxMedian: '#0D5C56',
grid: '#D4DEE4',
axis: '#5A6F7A',
axisLabel: '#1A2B33',
tooltipBorder: '#D4DEE4',
tooltipText: '#1A2B33',
outcome: {
: '#0D9488',
: '#5EEAD4',
: '#8A9BA5',
: '#C27803',
: '#C2410C',
} as Record<string, string>,
outcomeFallback: '#8A9BA5',
routePalette: ['#0F766E', '#5B8FA8', '#14B8A6', '#C27803', '#0D9488', '#C2410C'],
} as const;
export const TOOLTIP_STYLE = {
backgroundColor: '#FFFFFF',
border: `1px solid ${CLINICAL_COLORS.tooltipBorder}`,
borderRadius: '10px',
fontSize: '12px',
} as const;

View File

@@ -0,0 +1,194 @@
import { memo } from 'react';
import {
LineChart,
Line,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
import { ErrorBanner } from '@/components/ErrorBanner';
import { CalendarHeatmap } from '@/components/CalendarHeatmap';
import type { TopDiagnosis } from './types';
function formatDateLabel(dateStr: string): string {
const d = new Date(dateStr);
return `${d.getMonth() + 1}/${d.getDate()}`;
}
const TOOLTIP = {
backgroundColor: '#FFFFFF',
border: '1px solid #D4DEE4',
borderRadius: '10px',
fontSize: '12px',
boxShadow: '0 4px 16px rgba(26,43,51,0.08)',
};
interface CaseStatsTabProps {
loading: boolean;
loaded: boolean;
error: string | null;
currentDate: string;
topDiagnoses: TopDiagnosis[];
caseTrend: Array<{ date: string; cases: number; aqi: number }>;
heatmapData: Array<{ date: string; value: number }>;
heatmapYear: number | null;
onRetry: () => void;
onDismissError: () => void;
}
/** 病例统计 tab — 诊断 / 趋势 / 日历,工作台面板构图。 */
export const CaseStatsTab = memo(function CaseStatsTab({
loading,
loaded,
error,
currentDate,
topDiagnoses,
caseTrend,
heatmapData,
heatmapYear,
onRetry,
onDismissError,
}: CaseStatsTabProps) {
if (loading && !loaded) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary border-t-transparent" />
</div>
);
}
return (
<div className="space-y-5 max-w-[1400px]">
{error && <ErrorBanner error={error} onRetry={onRetry} onDismiss={onDismissError} />}
<div className="grid grid-cols-1 xl:grid-cols-5 gap-5">
{/* Top 5 诊断 — 占 2 列 */}
<section className="workbench-panel xl:col-span-2" aria-label="Top 5 诊断分布">
<div className="workbench-panel__head">
<div>
<h3 className="workbench-panel__title">Top 5 </h3>
<p className="workbench-panel__sub"> / </p>
</div>
</div>
<div className="workbench-panel__body">
{topDiagnoses.length > 0 ? (
<ResponsiveContainer width="100%" height={260}>
<BarChart
data={[...topDiagnoses].reverse()}
layout="vertical"
margin={{ top: 0, right: 12, left: 8, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#E8EEF1" horizontal={false} />
<XAxis type="number" tick={{ fontSize: 10, fill: '#8A9BA5' }} />
<YAxis
type="category"
dataKey="diagnosis"
tick={{ fontSize: 11, fill: '#5A6F7A' }}
width={96}
axisLine={false}
tickLine={false}
/>
<Tooltip
contentStyle={TOOLTIP}
formatter={(value: number) => [value.toLocaleString(), '病例数']}
/>
<Legend wrapperStyle={{ fontSize: '11px' }} />
<Bar dataKey="outpatient" stackId="a" fill="#C27803" name="门诊" barSize={18} radius={[0, 0, 0, 0]} />
<Bar dataKey="inpatient" stackId="a" fill="#C2410C" name="住院" barSize={18} radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
) : (
<div className="text-center py-12 text-text-muted text-sm"></div>
)}
</div>
</section>
{/* 病例与 AQI 趋势 — 占 3 列 */}
<section className="workbench-panel xl:col-span-3" aria-label="病例与AQI趋势">
<div className="workbench-panel__head">
<div>
<h3 className="workbench-panel__title"> AQI </h3>
<p className="workbench-panel__sub"> {currentDate} 30 </p>
</div>
</div>
<div className="workbench-panel__body">
{caseTrend.length > 0 ? (
<ResponsiveContainer width="100%" height={260}>
<LineChart data={caseTrend} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#E8EEF1" />
<XAxis
dataKey="date"
tickFormatter={formatDateLabel}
tick={{ fontSize: 10, fill: '#8A9BA5' }}
interval="preserveStartEnd"
axisLine={{ stroke: '#D4DEE4' }}
/>
<YAxis
yAxisId="left"
tick={{ fontSize: 10, fill: '#8A9BA5' }}
axisLine={{ stroke: '#D4DEE4' }}
/>
<YAxis
yAxisId="right"
orientation="right"
tick={{ fontSize: 10, fill: '#C27803' }}
axisLine={{ stroke: '#D4DEE4' }}
/>
<Tooltip contentStyle={TOOLTIP} labelStyle={{ color: '#1A2B33', fontWeight: 600 }} />
<Legend wrapperStyle={{ fontSize: '11px' }} />
<Line
yAxisId="left"
type="monotone"
dataKey="cases"
name="病例数"
stroke="#0F766E"
strokeWidth={2.25}
dot={false}
activeDot={{ r: 3 }}
/>
<Line
yAxisId="right"
type="monotone"
dataKey="aqi"
name="AQI"
stroke="#C27803"
strokeWidth={2}
strokeDasharray="5 4"
dot={false}
activeDot={{ r: 3 }}
/>
</LineChart>
</ResponsiveContainer>
) : (
<div className="text-center py-12 text-text-muted text-sm"></div>
)}
</div>
</section>
</div>
{/* 日历热力图 — 通栏 */}
<section className="workbench-panel" aria-label="每日病例日历">
<div className="workbench-panel__head">
<div>
<h3 className="workbench-panel__title">
{heatmapYear ? `${heatmapYear}` : ''}
</h3>
<p className="workbench-panel__sub"></p>
</div>
</div>
<div className="workbench-panel__body">
{heatmapYear && heatmapData.length > 0 ? (
<CalendarHeatmap data={heatmapData} year={heatmapYear} />
) : (
<div className="text-center py-12 text-text-muted text-sm"></div>
)}
</div>
</section>
</div>
);
});

View File

@@ -0,0 +1,73 @@
import { memo } from 'react';
import { ErrorBanner } from '@/components/ErrorBanner';
import { MetricHeatmapTable } from '@/components/MetricHeatmapTable';
interface DistrictStatsTabProps {
loading: boolean;
loaded: boolean;
error: string | null;
rows: string[];
data: Record<string, Record<string, number>>;
onRetry: () => void;
onDismissError: () => void;
onSort: (col: string) => void;
}
/** 区域统计 tab — 热力表工作台面板。 */
export const DistrictStatsTab = memo(function DistrictStatsTab({
loading,
loaded,
error,
rows,
data,
onRetry,
onDismissError,
onSort,
}: DistrictStatsTabProps) {
if (loading && !loaded) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary border-t-transparent" />
</div>
);
}
return (
<div className="space-y-5 max-w-[1400px]">
{error && <ErrorBanner error={error} onRetry={onRetry} onDismiss={onDismissError} />}
<section className="workbench-panel" aria-label="区域指标热力表">
<div className="workbench-panel__head">
<div>
<h3 className="workbench-panel__title"></h3>
<p className="workbench-panel__sub"> · </p>
</div>
{rows.length > 0 && (
<span className="text-[11px] font-mono tabular-nums text-text-muted bg-bg-hover px-2.5 py-1 rounded-md border border-border">
{rows.length}
</span>
)}
</div>
<div className="workbench-panel__body">
{rows.length > 0 ? (
<div className="rounded-xl border border-border/80 overflow-hidden bg-bg-elevated/50">
<MetricHeatmapTable
rows={rows}
columns={[
{ key: 'total', label: '病例' },
{ key: 'outpatient', label: '门诊' },
{ key: 'inpatient', label: '住院' },
{ key: 'inpatient_ratio', label: '住院占比%' },
]}
data={data}
onSort={onSort}
/>
</div>
) : (
<div className="text-center py-12 text-text-muted text-sm"></div>
)}
</div>
</section>
</div>
);
});

View File

@@ -0,0 +1,119 @@
import { memo } from 'react';
import { Calendar, Activity, TrendingUp, TrendingDown, Stethoscope, Zap, BarChart3 } from 'lucide-react';
import type { MonitoringStats } from './types';
interface MonitoringStatsBarProps {
stats: MonitoringStats;
sparkline7d: number[];
}
function MiniSpark({ data, color }: { data: number[]; color: string }) {
if (data.length < 2) return null;
const max = Math.max(...data);
const min = Math.min(...data);
const range = max - min || 1;
const points = data
.map((val, i) => {
const x = (i / (data.length - 1)) * 72 + 1;
const y = 22 - ((val - min) / range) * 18 - 1;
return `${x},${y}`;
})
.join(' ');
const area = `1,23 ${points} 73,23`;
return (
<svg width="74" height="24" className="shrink-0" aria-hidden="true">
<polygon fill={`${color}22`} points={area} />
<polyline
fill="none"
stroke={color}
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
points={points}
/>
</svg>
);
}
/** 监测页指挥台指标带 — 6 KPI + sparkline连续仪表条构图。 */
export const MonitoringStatsBar = memo(function MonitoringStatsBar({
stats,
sparkline7d,
}: MonitoringStatsBarProps) {
const trendLabel = stats.trend === 'up' ? '上升' : stats.trend === 'down' ? '下降' : '平稳';
const trendClass =
stats.trend === 'up' ? 'text-danger' : stats.trend === 'down' ? 'text-success' : 'text-text-muted';
return (
<div className="command-rail flex-1 min-w-0 stagger-children" role="group" aria-label="监测关键指标">
<div className="command-rail__cell command-rail__cell--hero">
<div className="command-rail__label">
<Calendar className="w-3.5 h-3.5 text-primary" aria-hidden />
</div>
<div className="command-rail__value">
{stats.todayCases !== null ? stats.todayCases.toLocaleString() : '--'}
</div>
</div>
<div className="command-rail__cell">
<div className="command-rail__label">
<Activity className="w-3.5 h-3.5 text-mist" aria-hidden />
7
</div>
<div className="flex items-end justify-between gap-2">
<div className="command-rail__value text-[20px]">{stats.avg7d.toLocaleString()}</div>
{sparkline7d.length >= 2 && <MiniSpark data={sparkline7d} color="#0F766E" />}
</div>
</div>
<div className="command-rail__cell">
<div className="command-rail__label">
{stats.trend === 'up' ? (
<TrendingUp className="w-3.5 h-3.5 text-danger" aria-hidden />
) : stats.trend === 'down' ? (
<TrendingDown className="w-3.5 h-3.5 text-success" aria-hidden />
) : (
<Activity className="w-3.5 h-3.5 text-text-muted" aria-hidden />
)}
</div>
<div className={`command-rail__value text-[20px] ${trendClass}`}>{trendLabel}</div>
</div>
<div className="command-rail__cell">
<div className="command-rail__label">
<Zap className="w-3.5 h-3.5 text-warning" aria-hidden />
</div>
<div className="command-rail__value text-[18px]">
{stats.maxDay.cases.toLocaleString()}
<span className="ml-1.5 text-[12px] font-medium text-text-muted font-sans tracking-normal">
{stats.maxDay.date.slice(5)}
</span>
</div>
</div>
<div className="command-rail__cell">
<div className="command-rail__label">
<BarChart3 className="w-3.5 h-3.5 text-mist-deep" aria-hidden />
</div>
<div className="command-rail__value text-[20px]">{stats.stdDev.toLocaleString()}</div>
</div>
<div className="command-rail__cell">
<div className="command-rail__label">
<Stethoscope className="w-3.5 h-3.5 text-primary-light" aria-hidden />
/
</div>
<div className="command-rail__value text-[17px]">
<span className="text-warning">{stats.totalOutpatient.toLocaleString()}</span>
<span className="mx-1 text-text-muted font-sans font-normal">/</span>
<span className="text-danger">{stats.totalInpatient.toLocaleString()}</span>
</div>
</div>
</div>
);
});

View File

@@ -0,0 +1,215 @@
import { memo, useMemo, useCallback } from 'react';
import { StatisticalCharts } from '@/components/StatisticalCharts';
import { CaseLocationMap } from '@/components/CaseLocationMap';
import { Segmented } from '@/components/ui';
import { TESTIDS } from '@/utils/testids';
import type { Granularity, DistrictCaseRow } from './types';
interface OverviewTabProps {
isLoading: boolean;
chartData: Array<{ date: string; cases: number; aqi?: number }>;
districtCases: DistrictCaseRow[];
selectedDistrict: string | null;
selectedStreet: string | null;
currentDate: string;
granularity: Granularity;
onGranularityChange: (g: Granularity) => void;
onDistrictSelect: (district: string) => void;
}
/**
* 概览 tab — 沉浸式地图舞台 + 玻璃统计翼(趋势 / 区县 roll-up
* 功能保留病例地图、AQI/病例趋势、区县分解、粒度切换。
* 播放时间轴时地图始终挂载,仅侧翼显示刷新态,避免底图闪烁。
*/
export const OverviewTab = memo(function OverviewTab({
isLoading,
chartData,
districtCases,
selectedDistrict,
selectedStreet,
currentDate,
granularity,
onGranularityChange,
onDistrictSelect,
}: OverviewTabProps) {
const totalDistrictCases = useMemo(
() => districtCases.reduce((s, d) => s + d.total, 0),
[districtCases]
);
const showWingSkeleton = isLoading && districtCases.length === 0;
return (
<div className="flex flex-col xl:flex-row h-full min-h-0 pb-[4.5rem]">
{/* 地图舞台 — 全高沉浸;永不因 isLoading 卸载 */}
<section className="map-stage min-h-[380px] xl:min-h-0 border-b xl:border-b-0">
<div className="map-chrome">
<div className="map-chrome__chip">
<span className="text-[11px] text-text-muted"></span>
<span className="font-mono text-[12px] font-semibold tabular-nums text-text-primary">
{currentDate}
</span>
</div>
</div>
<div
className="pointer-events-none absolute inset-x-0 bottom-0 z-[6] h-16
bg-gradient-to-t from-[#dce6eb]/70 to-transparent"
aria-hidden
/>
<div className="absolute inset-0">
<CaseLocationMap
height="100%"
district={selectedDistrict}
street={selectedStreet}
date={currentDate}
/>
</div>
</section>
{/* 玻璃统计翼 */}
<aside className="glass-wing xl:w-[400px] 2xl:w-[440px] shrink-0 max-h-[48vh] xl:max-h-none overflow-auto">
{showWingSkeleton ? (
<div className="flex items-center justify-center min-h-[280px]">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary border-t-transparent" />
</div>
) : (
<>
<div className="glass-wing__section">
<h3 className="glass-wing__title"> AQI </h3>
<div className="rounded-xl border border-border/70 bg-bg-card/80 p-1.5 shadow-soft">
<StatisticalCharts data={chartData} height={240} showCases={true} showAQI={true} />
</div>
</div>
<div data-testid={TESTIDS.districtRollup} className="glass-wing__section !pb-4">
<div className="flex items-center justify-between mb-1 gap-2 flex-wrap">
<div>
<h3 className="glass-wing__title !mb-1"></h3>
<p className="text-[11px] text-text-muted mb-2">
{' '}
<span className="data-num text-text-secondary text-[12px]">
{totalDistrictCases.toLocaleString()}
</span>
{selectedDistrict ? (
<span className="ml-2 text-primary">· {selectedDistrict}</span>
) : null}
{isLoading ? (
<span className="ml-2 text-text-muted"></span>
) : null}
</p>
</div>
<Segmented<Granularity>
testid={TESTIDS.granularityControl}
size="sm"
options={[
{ value: 'city', label: '全市' },
{ value: 'district', label: '区域' },
{ value: 'street', label: '街道' },
]}
value={granularity}
onChange={onGranularityChange}
/>
</div>
<div className="space-y-0.5 max-h-[min(360px,42vh)] overflow-y-auto pr-0.5 -mx-1">
<DistrictBreakdown
districtCases={districtCases}
selectedDistrict={selectedDistrict}
onDistrictSelect={onDistrictSelect}
/>
</div>
<div className="flex items-center gap-5 mt-3 pt-3 border-t border-border-light">
<div className="flex items-center gap-1.5 text-[11px] text-text-muted">
<span className="w-3 h-2 rounded-sm bg-warning/85" aria-hidden />
</div>
<div className="flex items-center gap-1.5 text-[11px] text-text-muted">
<span className="w-3 h-2 rounded-sm bg-danger/75" aria-hidden />
</div>
</div>
</div>
</>
)}
</aside>
</div>
);
});
interface DistrictBreakdownProps {
districtCases: DistrictCaseRow[];
selectedDistrict: string | null;
onDistrictSelect: (district: string) => void;
}
const DistrictBreakdown = memo(function DistrictBreakdown({
districtCases,
selectedDistrict,
onDistrictSelect,
}: DistrictBreakdownProps) {
const sortedCases = useMemo(
() => [...districtCases].sort((a, b) => b.total - a.total),
[districtCases]
);
const maxTotal = useMemo(
() => (sortedCases.length > 0 ? sortedCases[0].total : 1),
[sortedCases]
);
const handleDistrictClick = useCallback(
(district: string) => {
onDistrictSelect(district);
},
[onDistrictSelect]
);
return (
<>
{sortedCases.map((d, rank) => {
const outPct = d.total > 0 ? (d.outpatient / d.total) * 100 : 0;
const inPct = d.total > 0 ? (d.inpatient / d.total) * 100 : 0;
const barWidth = (d.total / maxTotal) * 100;
const isActive = selectedDistrict === d.district;
return (
<div
key={d.district}
role="button"
tabIndex={0}
aria-pressed={isActive}
className={`district-bar ${isActive ? 'district-bar--active' : ''}`}
onClick={() => handleDistrictClick(d.district)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleDistrictClick(d.district);
}
}}
>
<div className="w-4 text-[10px] font-mono text-text-muted tabular-nums shrink-0 text-right">
{rank + 1}
</div>
<div className="w-[3.25rem] text-[12px] font-medium text-text-secondary shrink-0 truncate">
{d.district}
</div>
<div className="district-bar__track" title={`门诊 ${d.outpatient} · 住院 ${d.inpatient}`}>
<div className="district-bar__fill" style={{ width: `${barWidth}%` }}>
<div
className="h-full bg-warning/85"
style={{ width: `${outPct}%` }}
/>
<div
className="h-full bg-danger/75"
style={{ width: `${inPct}%` }}
/>
</div>
</div>
<div className="w-[3.75rem] text-right data-num text-[12px] shrink-0">
{d.total.toLocaleString()}
</div>
</div>
);
})}
</>
);
});

View File

@@ -0,0 +1,38 @@
// 监测页内部共享类型。Granularity 的真相来源仍是 URL由 MonitoringDashboard 拥有;
// 此处只暴露类型与子组件复用的 props 形状。
export type Granularity = 'city' | 'district' | 'street';
export const GRANULARITY_VALUES: readonly Granularity[] = ['city', 'district', 'street'] as const;
export function parseGranularity(raw: string | null): Granularity {
return GRANULARITY_VALUES.includes(raw as Granularity) ? (raw as Granularity) : 'city';
}
// 概览 tab 区县条目所需的最小字段(来自 monitoringStore 的 districtCases
export interface DistrictCaseRow {
district: string;
total: number;
outpatient: number;
inpatient: number;
}
export interface MonitoringStats {
totalCases: number;
avgCases: number;
maxDay: { date: string; cases: number };
minDay: { date: string; cases: number };
stdDev: number;
trend: 'up' | 'down' | 'stable';
totalOutpatient: number;
totalInpatient: number;
avg7d: number;
todayCases: number | null;
noData: boolean;
}
export interface TopDiagnosis {
diagnosis: string;
outpatient: number;
inpatient: number;
total: number;
}

View File

@@ -0,0 +1,317 @@
import { useEffect, useState, useMemo, useRef, useCallback } from 'react';
import { useMonitoringStore } from '@/stores';
import { useDiseaseStore } from '@/stores/diseaseStore';
import { gridApi, caseApi, envApi } from '@/services/api';
import type { DistrictCaseData } from '@/types';
import type { MonitoringStats, TopDiagnosis } from './types';
type MonitoringTab = 'overview' | 'cases' | 'districts';
interface UseMonitoringDataArgs {
activeTab: MonitoringTab;
currentDate: string;
selectedDistrict: string | null;
}
// 监测页数据层:图表 90 天窗口、病例统计/区域统计两个按需 tab 的加载与派生。
// 不触碰 URL/drilldown粒度真相来源仍由 MonitoringDashboard 持有),只消费 currentDate 与
// selectedDistrict 作为入参,避免把 store-mutation 逻辑下沉到子组件。
export function useMonitoringData({ activeTab, currentDate, selectedDistrict }: UseMonitoringDataArgs) {
const [chartData, setChartData] = useState<Array<{ date: string; cases: number; aqi?: number }>>([]);
// --- 病例统计 tab state (fetched on demand) ---
const [topDiagnoses, setTopDiagnoses] = useState<TopDiagnosis[]>([]);
const [caseTrend, setCaseTrend] = useState<Array<{ date: string; cases: number; aqi: number }>>([]);
const [heatmapData, setHeatmapData] = useState<Array<{ date: string; value: number }>>([]);
const [heatmapYear, setHeatmapYear] = useState<number | null>(null);
const [casesTabLoaded, setCasesTabLoaded] = useState(false);
const [casesTabLoading, setCasesTabLoading] = useState(false);
const [casesTabError, setCasesTabError] = useState<string | null>(null);
// --- 区域统计 tab state (fetched on demand) ---
const [districtMetrics, setDistrictMetrics] = useState<DistrictCaseData[]>([]);
const [districtSortKey, setDistrictSortKey] = useState<string>('total');
const [districtTabLoaded, setDistrictTabLoaded] = useState(false);
const [districtTabLoading, setDistrictTabLoading] = useState(false);
const [districtTabError, setDistrictTabError] = useState<string | null>(null);
const districtCases = useMonitoringStore((s) => s.districtCases);
const fetchDistrictCases = useMonitoringStore((s) => s.fetchDistrictCases);
const { selectedDiagnoses } = useDiseaseStore();
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Load chart data for 90-day window ending at the given reference date
const loadChartData = useCallback((refDate: string, district?: string) => {
const end = new Date(refDate);
const start = new Date(refDate);
start.setDate(start.getDate() - 90);
const startStr = start.toISOString().split('T')[0];
const endStr = end.toISOString().split('T')[0];
if (selectedDiagnoses.length > 0 && selectedDiagnoses.length <= 3) {
caseApi.getTrend({
start_date: startStr,
end_date: endStr,
group_by: 'day',
diagnosis: selectedDiagnoses.join(','),
}).then((data) => {
const trend = data.trend || [];
setChartData(
trend.map((t: { date: string; total: number }) => ({ date: t.date, cases: t.total }))
);
}).catch((e) => { console.error('Failed to load chart data:', e); });
} else {
gridApi.getHistoricalAggregated(startStr, endStr, 'daily', district)
.then((data) => {
const rows = data.aggregations || [];
const dailyCases: Record<string, number> = {};
rows.forEach((item: { date: string; total_cases: number }) => {
dailyCases[item.date] = (dailyCases[item.date] || 0) + item.total_cases;
});
setChartData(
Object.entries(dailyCases)
.map(([date, cases]) => ({ date, cases }))
.sort((a, b) => a.date.localeCompare(b.date))
);
}).catch((e) => { console.error('Failed to load chart data:', e); });
}
// Fetch districtCases with date filter (single day = currentDate)
const diagnosisParam = selectedDiagnoses.length > 0 ? selectedDiagnoses.join(',') : undefined;
fetchDistrictCases(diagnosisParam, undefined, refDate);
}, [fetchDistrictCases, selectedDiagnoses]);
// 提供给外部(手动刷新 / 病种过滤)触发的去抖加载。
const debouncedLoadChart = useCallback(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
loadChartData(currentDate, selectedDistrict || undefined);
}, 300);
}, [loadChartData, currentDate, selectedDistrict]);
// Re-fetch when currentDate, district, or diagnoses change
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
loadChartData(currentDate, selectedDistrict || undefined);
}, 300);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [currentDate, selectedDistrict, loadChartData]);
// Enhanced stats: window stats + current-date snapshot
const stats = useMemo<MonitoringStats>(() => {
const noData = chartData.length === 0;
const totalCases = noData ? 0 : chartData.reduce((sum, d) => sum + d.cases, 0);
const avgCases = noData ? 0 : Math.round(totalCases / chartData.length);
let maxDay = { date: '--', cases: 0 };
let minDay = { date: '--', cases: 0 };
let stdDev = 0;
let trend: 'up' | 'down' | 'stable' = 'stable';
if (!noData) {
maxDay = chartData.reduce((max, d) => d.cases > max.cases ? d : max, chartData[0]);
minDay = chartData.reduce((min, d) => d.cases < min.cases ? d : min, chartData[0]);
const variance = chartData.reduce((sum, d) => sum + (d.cases - avgCases) ** 2, 0) / chartData.length;
stdDev = Math.round(Math.sqrt(variance));
const halfIdx = Math.floor(chartData.length / 2);
const firstHalf = chartData.slice(0, halfIdx);
const secondHalf = chartData.slice(halfIdx);
const firstAvg = firstHalf.reduce((s, d) => s + d.cases, 0) / firstHalf.length;
const secondAvg = secondHalf.reduce((s, d) => s + d.cases, 0) / secondHalf.length;
trend = secondAvg > firstAvg * 1.1 ? 'up' : secondAvg < firstAvg * 0.9 ? 'down' : 'stable';
}
// 7-day moving average (last 7 days of the window)
const last7 = chartData.slice(-7);
const avg7d = last7.length > 0 ? Math.round(last7.reduce((s, d) => s + d.cases, 0) / last7.length) : 0;
// Current date snapshot: find the data point matching currentDate
const todaySnapshot = chartData.find((d) => d.date === currentDate);
const todayCases = todaySnapshot?.cases ?? null;
// Case type breakdown from districtCases
const totalOutpatient = districtCases.reduce((s, d) => s + d.outpatient, 0);
const totalInpatient = districtCases.reduce((s, d) => s + d.inpatient, 0);
return {
totalCases, avgCases, maxDay, minDay,
stdDev, trend, totalOutpatient, totalInpatient,
avg7d, todayCases, noData,
};
}, [chartData, districtCases, currentDate]);
// 7-day sparkline for the StatCard bar (last 7 days of the loaded window)
const sparkline7d = useMemo(() => chartData.slice(-7).map((d) => d.cases), [chartData]);
// --- On-demand loader: 病例统计 tab ---
// Drives the trend off the Monitoring timeline (30-day window ending at currentDate),
// NOT a fixed now-30d window. Year for the heatmap is derived from the data.
const loadCasesTab = useCallback(async (refDate: string) => {
setCasesTabLoading(true);
setCasesTabError(null);
const end = new Date(refDate);
const start = new Date(refDate);
start.setDate(start.getDate() - 30);
const startStr = start.toISOString().split('T')[0];
const endStr = end.toISOString().split('T')[0];
const yearStart = `${end.getFullYear()}-01-01`;
const yearEnd = `${end.getFullYear()}-12-31`;
const [statsR, trendR, pollutantsR, yearTrendR] = await Promise.allSettled([
caseApi.getStats(),
caseApi.getTrend({ start_date: startStr, end_date: endStr, group_by: 'day' }),
envApi.getPollutants(30),
caseApi.getTrend({ start_date: yearStart, end_date: yearEnd, group_by: 'day' }),
]);
const errs: string[] = [];
if (statsR.status === 'fulfilled') {
const topDiag = statsR.value.top_diagnoses || [];
setTopDiagnoses(
topDiag.slice(0, 5).map((d) => ({
diagnosis: d.diagnosis,
outpatient: d.outpatient,
inpatient: d.inpatient,
total: d.outpatient + d.inpatient,
}))
);
} else {
errs.push('诊断分布加载失败');
}
const aqiMap: Record<string, number> = {};
if (pollutantsR.status === 'fulfilled') {
for (const p of pollutantsR.value.data || []) {
aqiMap[p.date] = p.AQI || 0;
}
}
if (trendR.status === 'fulfilled') {
const trend = trendR.value.trend || [];
setCaseTrend(
trend.map((t) => ({ date: t.date, cases: t.total, aqi: aqiMap[t.date] || 0 }))
);
} else {
errs.push('趋势数据加载失败');
}
// Calendar heatmap: daily cases for the data's actual year (derived from trend data)
if (yearTrendR.status === 'fulfilled') {
const yearTrend = yearTrendR.value.trend || [];
if (yearTrend.length > 0) {
const derivedYear = new Date(yearTrend[0].date).getFullYear();
setHeatmapYear(derivedYear);
setHeatmapData(yearTrend.map((t) => ({ date: t.date, value: t.total })));
} else {
setHeatmapYear(end.getFullYear());
setHeatmapData([]);
}
} else {
errs.push('日历热力图加载失败');
}
setCasesTabError(errs.length > 0 ? errs.join('') : null);
setCasesTabLoading(false);
setCasesTabLoaded(true);
}, []);
// --- On-demand loader: 区域统计 tab ---
const loadDistrictTab = useCallback(async () => {
setDistrictTabLoading(true);
setDistrictTabError(null);
try {
const res = await caseApi.getDistricts();
setDistrictMetrics(res.districts || []);
setDistrictTabError(null);
} catch {
setDistrictTabError('区域统计加载失败');
} finally {
setDistrictTabLoading(false);
setDistrictTabLoaded(true);
}
}, []);
// Fetch tab data the first time a tab is opened (avoids loading everything upfront)
useEffect(() => {
if (activeTab === 'cases' && !casesTabLoaded && !casesTabLoading) {
loadCasesTab(currentDate);
}
if (activeTab === 'districts' && !districtTabLoaded && !districtTabLoading) {
loadDistrictTab();
}
}, [activeTab, casesTabLoaded, casesTabLoading, districtTabLoaded, districtTabLoading, currentDate, loadCasesTab, loadDistrictTab]);
// When the timeline date moves, refresh an already-opened 病例统计 tab so its
// trend window tracks the Monitoring timeline rather than going stale.
useEffect(() => {
if (activeTab === 'cases' && casesTabLoaded) {
loadCasesTab(currentDate);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentDate]);
// 区域统计 table: sortable district rows + heatmap columns
const districtTableRows = useMemo(() => {
const sorted = [...districtMetrics].sort((a, b) => {
switch (districtSortKey) {
case 'outpatient': return b.outpatient - a.outpatient;
case 'inpatient': return b.inpatient - a.inpatient;
case 'inpatient_ratio': return (b.inpatient_ratio ?? 0) - (a.inpatient_ratio ?? 0);
default: return b.total - a.total;
}
});
return sorted.map((d) => d.district);
}, [districtMetrics, districtSortKey]);
const districtTableData = useMemo(() => {
const map: Record<string, Record<string, number>> = {};
for (const d of districtMetrics) {
map[d.district] = {
total: d.total,
outpatient: d.outpatient,
inpatient: d.inpatient,
inpatient_ratio: Math.round((d.inpatient_ratio ?? 0) * 1000) / 10,
};
}
return map;
}, [districtMetrics]);
return {
// 概览
chartData,
stats,
sparkline7d,
districtCases,
// 病例统计
topDiagnoses,
caseTrend,
heatmapData,
heatmapYear,
casesTabLoaded,
casesTabLoading,
casesTabError,
setCasesTabError,
loadCasesTab,
// 区域统计
districtTableRows,
districtTableData,
districtTabLoaded,
districtTabLoading,
districtTabError,
setDistrictTabError,
setDistrictSortKey,
loadDistrictTab,
// 图表手动加载(错误重试 / 病种过滤)
loadChartData,
debouncedLoadChart,
};
}

View File

@@ -0,0 +1,82 @@
import { memo } from 'react';
import { PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import { EmptyState } from '@/components/ui';
import { CHART_COLORS } from './chartColors';
export interface AlertSlice {
name: string;
value: number;
color: string;
}
interface AlertSeverityDonutProps {
data: AlertSlice[];
embed?: boolean;
}
const tooltipStyle = {
backgroundColor: '#FFFFFF',
border: `1px solid ${CHART_COLORS.tooltipBorder}`,
borderRadius: '10px',
fontSize: '12px',
};
function AlertSeverityDonutComponent({ data, embed }: AlertSeverityDonutProps) {
const hasData = data.some((d) => d.value > 0);
const body = (
<>
{embed ? (
<div className="workbench-panel__head">
<div>
<h3 className="workbench-panel__title"></h3>
<p className="workbench-panel__sub">P1 / P2 </p>
</div>
</div>
) : (
<div className="text-[11px] font-medium text-text-secondary uppercase tracking-wide mb-4">
</div>
)}
<div className={embed ? 'workbench-panel__body' : undefined}>
{hasData ? (
<div className="flex items-center justify-center">
<ResponsiveContainer width="100%" height={240}>
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={80}
paddingAngle={4}
dataKey="value"
nameKey="name"
>
{data.map((entry) => (
<Cell key={entry.name} fill={entry.color} />
))}
</Pie>
<Tooltip
contentStyle={tooltipStyle}
formatter={(value: number, name: string) => [value, name]}
/>
<Legend
wrapperStyle={{ fontSize: '12px' }}
formatter={(value: string) => <span className="text-text-primary">{value}</span>}
/>
</PieChart>
</ResponsiveContainer>
</div>
) : (
<EmptyState title="暂无预警数据" />
)}
</div>
</>
);
if (embed) return <>{body}</>;
return <div className="card p-4">{body}</div>;
}
export const AlertSeverityDonut = memo(AlertSeverityDonutComponent);

View File

@@ -0,0 +1,116 @@
import { memo } from 'react';
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
import { EmptyState } from '@/components/ui';
import { CHART_COLORS } from './chartColors';
export interface MergedTrendItem {
date: string;
cases: number;
aqi: number;
}
interface CaseAqiTrendProps {
data: MergedTrendItem[];
/** 嵌入 workbench-panel 时去掉外层 card */
embed?: boolean;
}
function formatDateLabel(dateStr: string): string {
const d = new Date(dateStr);
return `${d.getMonth() + 1}/${d.getDate()}`;
}
const tooltipStyle = {
backgroundColor: '#FFFFFF',
border: `1px solid ${CHART_COLORS.tooltipBorder}`,
borderRadius: '10px',
fontSize: '12px',
};
function CaseAqiTrendComponent({ data, embed }: CaseAqiTrendProps) {
const body = (
<>
{embed ? (
<div className="workbench-panel__head">
<div>
<h3 className="workbench-panel__title"> 30 AQI</h3>
<p className="workbench-panel__sub"></p>
</div>
</div>
) : (
<div className="text-[11px] font-medium text-text-secondary uppercase tracking-wide mb-4">
30AQI趋势
</div>
)}
<div className={embed ? 'workbench-panel__body' : undefined}>
{data.length > 0 ? (
<ResponsiveContainer width="100%" height={embed ? 280 : 200}>
<LineChart data={data} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_COLORS.grid} />
<XAxis
dataKey="date"
tickFormatter={formatDateLabel}
tick={{ fontSize: 10, fill: CHART_COLORS.axis }}
interval="preserveStartEnd"
axisLine={{ stroke: CHART_COLORS.grid }}
/>
<YAxis
yAxisId="left"
tick={{ fontSize: 10, fill: CHART_COLORS.axis }}
axisLine={{ stroke: CHART_COLORS.grid }}
/>
<YAxis
yAxisId="right"
orientation="right"
tick={{ fontSize: 10, fill: CHART_COLORS.aqi }}
axisLine={{ stroke: CHART_COLORS.grid }}
/>
<Tooltip
contentStyle={tooltipStyle}
labelStyle={{ color: CHART_COLORS.tooltipText, fontWeight: 600 }}
/>
<Legend wrapperStyle={{ fontSize: '11px' }} />
<Line
yAxisId="left"
type="monotone"
dataKey="cases"
name="病例数"
stroke={CHART_COLORS.cases}
strokeWidth={2.25}
dot={false}
activeDot={{ r: 3 }}
/>
<Line
yAxisId="right"
type="monotone"
dataKey="aqi"
name="AQI"
stroke={CHART_COLORS.aqi}
strokeWidth={2}
strokeDasharray="5 4"
dot={false}
activeDot={{ r: 3 }}
/>
</LineChart>
</ResponsiveContainer>
) : (
<EmptyState title="暂无数据" />
)}
</div>
</>
);
if (embed) return <div className="h-full flex flex-col">{body}</div>;
return <div className="card p-4">{body}</div>;
}
export const CaseAqiTrend = memo(CaseAqiTrendComponent);

View File

@@ -0,0 +1,197 @@
import { memo, useEffect, useMemo, useRef, useState } from 'react';
import type MapView from '@geoscene/core/views/MapView';
import type GraphicsLayer from '@geoscene/core/layers/GraphicsLayer';
import Graphic from '@geoscene/core/Graphic';
import Polygon from '@geoscene/core/geometry/Polygon';
import SimpleFillSymbol from '@geoscene/core/symbols/SimpleFillSymbol';
import { CHART_COLORS } from './chartColors';
import { createMapView } from '@/geoscene/createMapView';
import { createGraphicsLayer } from '@/geoscene/layers';
import { TESTIDS } from '@/utils/testids';
interface DistrictChoroplethProps {
metricLookup: Record<string, number>;
metricLabel: string;
}
interface WuhanFeatureProps {
name: string;
}
interface WuhanFeature {
type: 'Feature';
properties: WuhanFeatureProps;
geometry: {
type: 'Polygon' | 'MultiPolygon';
coordinates: number[][][] | number[][][][];
};
}
interface WuhanFeatureCollection {
type: 'FeatureCollection';
features: WuhanFeature[];
}
function colorForValue(value: number, max: number): string {
const scale = CHART_COLORS.choropleth;
if (max <= 0 || value <= 0) return CHART_COLORS.choroplethEmpty;
const ratio = value / max;
const idx = Math.min(scale.length - 1, Math.floor(ratio * scale.length));
return scale[idx];
}
function hexToRgba(hex: string, alpha = 0.78): number[] {
const h = hex.replace('#', '');
const r = parseInt(h.slice(0, 2), 16);
const g = parseInt(h.slice(2, 4), 16);
const b = parseInt(h.slice(4, 6), 16);
return [r, g, b, alpha];
}
function ringsFromGeometry(geometry: WuhanFeature['geometry']): number[][][] {
if (geometry.type === 'Polygon') {
return geometry.coordinates as number[][][];
}
return (geometry.coordinates as number[][][][]).flat();
}
function DistrictChoroplethComponent({ metricLookup, metricLabel }: DistrictChoroplethProps) {
const mapDivRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<MapView | null>(null);
const layerRef = useRef<GraphicsLayer | null>(null);
const geoDataRef = useRef<WuhanFeatureCollection | null>(null);
const [mapReady, setMapReady] = useState(false);
const maxValue = useMemo(() => {
const vals = Object.values(metricLookup);
return vals.length ? Math.max(...vals) : 0;
}, [metricLookup]);
useEffect(() => {
if (!mapDivRef.current || viewRef.current) return;
const { map, view, destroy } = createMapView({
container: mapDivRef.current,
zoom: 9,
constraints: { minZoom: 8, maxZoom: 14 },
});
const layer = createGraphicsLayer('区县填色');
map.add(layer);
layerRef.current = layer;
viewRef.current = view;
view.ui.remove('zoom');
view.when(() => setMapReady(true)).catch(() => setMapReady(true));
let cancelled = false;
fetch('/wuhan_districts.geojson')
.then((r) => r.json())
.then((data: WuhanFeatureCollection) => {
if (cancelled) return;
geoDataRef.current = data;
})
.catch(() => undefined);
return () => {
cancelled = true;
layerRef.current = null;
viewRef.current = null;
destroy();
setMapReady(false);
};
}, []);
useEffect(() => {
const layer = layerRef.current;
const view = viewRef.current;
const data = geoDataRef.current;
if (!layer || !view || !mapReady) return;
const draw = (fc: WuhanFeatureCollection) => {
layer.removeAll();
const graphics: Graphic[] = [];
for (const feature of fc.features) {
const name = feature.properties?.name ?? '未知';
const value = metricLookup[name] ?? 0;
const rings = ringsFromGeometry(feature.geometry);
if (!rings.length) continue;
graphics.push(
new Graphic({
geometry: new Polygon({ rings, spatialReference: { wkid: 4326 } }),
symbol: new SimpleFillSymbol({
color: hexToRgba(colorForValue(value, maxValue)),
outline: { color: hexToRgba(CHART_COLORS.choroplethStroke, 1), width: 1 },
}),
attributes: { name, value, metricLabel },
popupTemplate: {
title: '{name}',
content: `${metricLabel}{value}`,
},
})
);
}
layer.addMany(graphics);
if (graphics.length > 0) {
view.goTo(graphics).catch(() => undefined);
}
};
if (data) {
draw(data);
return;
}
let cancelled = false;
fetch('/wuhan_districts.geojson')
.then((r) => r.json())
.then((fc: WuhanFeatureCollection) => {
if (cancelled) return;
geoDataRef.current = fc;
draw(fc);
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, [metricLookup, maxValue, metricLabel, mapReady]);
const legendStops = useMemo(() => {
const scale = CHART_COLORS.choropleth;
return scale.map((color, i) => ({
color,
label: maxValue > 0 ? Math.round((maxValue * (i + 1)) / scale.length).toLocaleString() : '0',
}));
}, [maxValue]);
return (
<div data-testid={TESTIDS.choroplethWrapper} className="relative">
<div
ref={mapDivRef}
className="w-full rounded-lg overflow-hidden bg-slate-100"
style={{ height: 420 }}
/>
{!mapReady && (
<div className="absolute inset-0 flex items-center justify-center bg-bg-card/70 rounded-lg text-[13px] text-text-muted">
</div>
)}
<div className="absolute bottom-3 right-3 z-[1000] bg-bg-card/95 px-3 py-2 rounded-lg border border-border shadow-sm">
<div className="text-[11px] font-semibold text-text-secondary mb-1.5">{metricLabel}</div>
<div className="flex items-center gap-0">
{legendStops.map((s) => (
<div key={s.color} className="flex flex-col items-center">
<div className="w-7 h-3" style={{ backgroundColor: s.color }} />
</div>
))}
</div>
<div className="flex justify-between mt-1 text-[10px] text-text-muted">
<span></span>
<span></span>
</div>
</div>
</div>
);
}
export const DistrictChoropleth = memo(DistrictChoroplethComponent);

View File

@@ -0,0 +1,124 @@
import { memo } from 'react';
import {
Activity,
AlertTriangle,
Droplets,
Building2,
TrendingUp,
TrendingDown,
Users,
} from 'lucide-react';
import { TESTIDS } from '@/utils/testids';
import { CHART_COLORS } from './chartColors';
export interface KpiData {
totalCases: number;
todayCases: number;
changeRatio: number | null;
activeAlerts: number;
highRiskGrids: number;
avgAQI: number;
}
interface KpiRowProps {
kpi: KpiData | null;
}
function changeTrendOf(ratio: number | null | undefined) {
if (ratio == null) return undefined;
if (ratio > 0) return { direction: 'up' as const, value: `${ratio.toFixed(1)}%` };
if (ratio < 0) return { direction: 'down' as const, value: `${Math.abs(ratio).toFixed(1)}%` };
return { direction: 'stable' as const, value: '0%' };
}
function KpiRowComponent({ kpi }: KpiRowProps) {
const changeTrend = changeTrendOf(kpi?.changeRatio);
const trendClass =
changeTrend?.direction === 'up'
? 'text-danger'
: changeTrend?.direction === 'down'
? 'text-success'
: 'text-text-muted';
return (
<div
data-testid={TESTIDS.kpiRow}
className="command-rail stagger-children"
role="group"
aria-label="综合关键指标"
>
<div className="command-rail__cell command-rail__cell--hero">
<div className="command-rail__label">
<Users className="w-3.5 h-3.5 text-primary" aria-hidden />
</div>
<div className="command-rail__value">
{kpi?.totalCases?.toLocaleString() ?? '--'}
</div>
</div>
<div className="command-rail__cell">
<div className="command-rail__label">
<Activity className="w-3.5 h-3.5 text-success" aria-hidden />
</div>
<div className="command-rail__value text-[20px]">
{kpi?.todayCases?.toLocaleString() ?? '--'}
</div>
</div>
<div className="command-rail__cell">
<div className="command-rail__label">
{changeTrend?.direction === 'up' ? (
<TrendingUp className="w-3.5 h-3.5 text-danger" aria-hidden />
) : changeTrend?.direction === 'down' ? (
<TrendingDown className="w-3.5 h-3.5 text-success" aria-hidden />
) : (
<Activity className="w-3.5 h-3.5 text-text-muted" aria-hidden />
)}
7
</div>
<div className={`command-rail__value text-[20px] ${trendClass}`}>
{changeTrend ? changeTrend.value : '--'}
</div>
</div>
<div className="command-rail__cell">
<div className="command-rail__label">
<AlertTriangle className="w-3.5 h-3.5 text-warning" aria-hidden />
</div>
<div
className="command-rail__value text-[20px]"
style={
kpi && kpi.activeAlerts > 0 ? { color: CHART_COLORS.alertP1 } : undefined
}
>
{kpi?.activeAlerts?.toLocaleString() ?? '--'}
</div>
</div>
<div className="command-rail__cell">
<div className="command-rail__label">
<Building2 className="w-3.5 h-3.5 text-danger" aria-hidden />
</div>
<div className="command-rail__value text-[20px]">
{kpi?.highRiskGrids?.toLocaleString() ?? '--'}
</div>
</div>
<div className="command-rail__cell">
<div className="command-rail__label">
<Droplets className="w-3.5 h-3.5 text-primary-light" aria-hidden />
AQI
</div>
<div className="command-rail__value text-[20px]">
{kpi?.avgAQI?.toLocaleString() ?? '--'}
</div>
</div>
</div>
);
}
export const KpiRow = memo(KpiRowComponent);

View File

@@ -0,0 +1,79 @@
import { memo } from 'react';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
import { EmptyState } from '@/components/ui';
import type { DiagnosisBreakdown } from '@/types';
import { CHART_COLORS } from './chartColors';
interface TopDiagnosesBarProps {
diagnoses: DiagnosisBreakdown[];
embed?: boolean;
}
const tooltipStyle = {
backgroundColor: '#FFFFFF',
border: `1px solid ${CHART_COLORS.tooltipBorder}`,
borderRadius: '10px',
fontSize: '12px',
};
function TopDiagnosesBarComponent({ diagnoses, embed }: TopDiagnosesBarProps) {
const body = (
<>
{embed ? (
<div className="workbench-panel__head">
<div>
<h3 className="workbench-panel__title">Top 5 </h3>
<p className="workbench-panel__sub"> / </p>
</div>
</div>
) : (
<div className="text-[11px] font-medium text-text-secondary uppercase tracking-wide mb-4">
Top 5
</div>
)}
<div className={embed ? 'workbench-panel__body' : undefined}>
{diagnoses.length > 0 ? (
<ResponsiveContainer width="100%" height={220}>
<BarChart
data={[...diagnoses].reverse()}
layout="vertical"
margin={{ top: 0, right: 10, left: 60, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_COLORS.grid} horizontal={false} />
<XAxis type="number" tick={{ fontSize: 10, fill: CHART_COLORS.axis }} />
<YAxis
type="category"
dataKey="diagnosis"
tick={{ fontSize: 11, fill: CHART_COLORS.axisLabel }}
width={100}
axisLine={false}
tickLine={false}
/>
<Tooltip
contentStyle={tooltipStyle}
formatter={(value: number, name: string) => [value.toLocaleString(), name]}
/>
<Bar dataKey="outpatient" stackId="a" fill={CHART_COLORS.outpatient} name="门诊" barSize={16} />
<Bar dataKey="inpatient" stackId="a" fill={CHART_COLORS.inpatient} name="住院" barSize={16} />
</BarChart>
</ResponsiveContainer>
) : (
<EmptyState title="暂无数据" />
)}
</div>
</>
);
if (embed) return <>{body}</>;
return <div className="card p-4">{body}</div>;
}
export const TopDiagnosesBar = memo(TopDiagnosesBarComponent);

View File

@@ -0,0 +1,109 @@
import { memo, useMemo } from 'react';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
import { EmptyState } from '@/components/ui';
import { CHART_COLORS } from './chartColors';
import { metricValue, type DistrictMetric, type MetricKey } from './districtNormalize';
interface TopDistrictsBarProps {
districts: DistrictMetric[];
metric: MetricKey;
metricLabel: string;
embed?: boolean;
}
const tooltipStyle = {
backgroundColor: '#FFFFFF',
border: `1px solid ${CHART_COLORS.tooltipBorder}`,
borderRadius: '10px',
fontSize: '12px',
};
function TopDistrictsBarComponent({
districts,
metric,
metricLabel,
embed,
}: TopDistrictsBarProps) {
const top5 = useMemo(() => {
return [...districts]
.sort((a, b) => metricValue(b, metric) - metricValue(a, metric))
.slice(0, 5)
.map((d) => ({
district: d.district,
outpatient: d.outpatient,
inpatient: d.inpatient,
value: metricValue(d, metric),
}))
.reverse();
}, [districts, metric]);
const hasData = top5.some((d) => d.value > 0);
const showStack = metric === 'all';
const body = (
<>
{embed ? (
<div className="workbench-panel__head">
<div>
<h3 className="workbench-panel__title">Top 5 {metricLabel}</h3>
<p className="workbench-panel__sub"></p>
</div>
</div>
) : (
<div className="text-[11px] font-medium text-text-secondary uppercase tracking-wide mb-4">
Top 5 {metricLabel}
</div>
)}
<div className={embed ? 'workbench-panel__body' : undefined}>
{hasData ? (
<ResponsiveContainer width="100%" height={220}>
<BarChart data={top5} layout="vertical" margin={{ top: 0, right: 10, left: 30, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_COLORS.grid} horizontal={false} />
<XAxis type="number" tick={{ fontSize: 10, fill: CHART_COLORS.axis }} />
<YAxis
type="category"
dataKey="district"
tick={{ fontSize: 11, fill: CHART_COLORS.axisLabel }}
width={64}
axisLine={false}
tickLine={false}
/>
<Tooltip
contentStyle={tooltipStyle}
formatter={(value: number, name: string) => [value.toLocaleString(), name]}
/>
{showStack ? (
<>
<Bar dataKey="outpatient" stackId="a" fill={CHART_COLORS.outpatient} name="门诊" barSize={20} />
<Bar dataKey="inpatient" stackId="a" fill={CHART_COLORS.inpatient} name="住院" barSize={20} />
</>
) : (
<Bar
dataKey="value"
fill={metric === 'inpatient' ? CHART_COLORS.inpatient : CHART_COLORS.outpatient}
name={metricLabel}
barSize={20}
/>
)}
</BarChart>
</ResponsiveContainer>
) : (
<EmptyState title="暂无数据" />
)}
</div>
</>
);
if (embed) return <>{body}</>;
return <div className="card p-4">{body}</div>;
}
export const TopDistrictsBar = memo(TopDistrictsBarComponent);

View File

@@ -0,0 +1,22 @@
/**
* 概览大屏图表与地图使用的字面色值集中处。
* Recharts / GeoScene 符号需要原始 hex无法用 Tailwind class故在此集中定义。
* 色系对齐「江雾」青绿 / 雾蓝 / 石板体系。
*/
export const CHART_COLORS = {
outpatient: '#0F766E', // primary teal
inpatient: '#C2410C', // danger warm
cases: '#0F766E',
aqi: '#C27803', // warning
grid: '#D4DEE4',
axis: '#5A6F7A',
axisLabel: '#1A2B33',
tooltipBorder: '#D4DEE4',
tooltipText: '#1A2B33',
alertP1: '#C2410C',
alertP2: '#C27803',
// choropleth 顺序色阶(浅雾 → 深青),高值高亮
choropleth: ['#E6F4F1', '#CCFBF1', '#99F6E4', '#5EEAD4', '#2DD4BF', '#14B8A6', '#0F766E'],
choroplethEmpty: '#E8EEF1',
choroplethStroke: '#FFFFFF',
} as const;

Some files were not shown because too many files have changed in this diff Show More