Compare commits
1 Commits
feature/ge
...
feat/cases
| Author | SHA1 | Date | |
|---|---|---|---|
| f58f2f612c |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -14,8 +14,6 @@ cache/
|
||||
logs/
|
||||
mlruns/
|
||||
.playwright-mcp/
|
||||
frontend/playwright-report/
|
||||
frontend/test-results/
|
||||
*Zone.Identifier
|
||||
# Transcription processing intermediates
|
||||
Outputs/transcript/chunks/
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# CBPOA — 武汉儿童呼吸疾病风险评估系统
|
||||
|
||||
FastAPI + React + `@geoscene/core` + PyTorch GCN pipeline. 预测空气质量对儿童健康的空间风险。
|
||||
FastAPI + React + PyTorch GCN pipeline. 预测空气质量对儿童健康的空间风险。
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Frontend (pnpm)
|
||||
cd frontend && pnpm dev # localhost:3000 → proxies /api to :8000
|
||||
cd frontend && pnpm dev # localhost:5173 → proxies /api to :8000
|
||||
|
||||
# Backend (Python venv)
|
||||
cd backend && uvicorn main:app --reload # localhost:8000
|
||||
@@ -22,7 +22,6 @@ 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
@@ -1,526 +0,0 @@
|
||||
|
||||
=== 说话人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] 摇灶的独援
|
||||
@@ -1,88 +0,0 @@
|
||||
# 儿童呼吸道感染空间流行病学的方法、数据与可视化:文献综述
|
||||
|
||||
---
|
||||
|
||||
儿童呼吸道感染是全球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.074(p<0.05),埃塞俄比亚HIV血清阳性率的Moran's I则达到0.16(p<0.001)。这些I值的绝对值普遍偏低,但p值表明所检测到的空间结构并非随机过程生成。其标准化输出为Moran散点图:横轴为标准化观测值,纵轴为空间滞后值,四个象限分别对应高-高、低-低、高-低、低-高四种空间关联模式。非空间统计背景的研究者对Moran散点图的初始反应往往是困惑——图中没有任何一个点落在可识别的地图位置上。然而,它比任何等值域地图都更直接地回应一个核心问题:观测值在空间维度上是否存在被其邻近值牵引的现象。
|
||||
|
||||
Moran散点图无法指出聚集发生在哪些具体位置。这一问题的解决需要借助局部空间统计量。Getis-Ord Gi*为每个空间单元输出一个z-score与对应的p-value,z-score需要多大绝对值才能被判定为热点或冷点,取决于所采用的多重比较校正策略。前端输出为分级热点地图,以红-蓝连续色阶渲染:红色端对应高值聚集区(热点),蓝色端对应低值聚集区(冷点)。该方法已在中国猩红热的北部与东北部高风险区定位、埃塞俄比亚HIV亚的斯亚贝巴及周边热点区识别,以及儿童急性呼吸道感染风险的空间分析中被反复验证。Anselin Local Moran's I在Gi*的基础上进一步区分聚类的子类型:高值被高值包围(HH)、低值被低值包围(LL)、高值被低值包围(HL)、低值被高值包围(LH)。其标准输出为LISA聚集图,各区划单元的聚类类别与统计显著性以差异化的符号标注于图上。HL与LH两类通常在后续分析中被视作空间异常值;在环境暴露的空间研究中,它们往往比HH聚类更具分析价值,因为在同一暴露梯度面上出现反直觉的低风险区,通常暗示着值得深入追问的局地保护因素或未观测的混杂结构。
|
||||
|
||||
空间维度与时间维度的联合扫描问题,由Kulldorff(1997)提出的时空扫描统计量所解决。实现上多以SaTScan软件包为工具载体,核心算法逻辑是在研究区内移动大小与形状可变的圆柱形扫描窗口(底面为空间圆或椭圆,高度为时间区间),对每个窗口进行泊松或伯努利概率假设下的似然比检验,判定窗口内观测病例数是否显著超出随机期望。该方法已在多项研究中取得了明确的实证验证:马拉维疟疾的时空聚集区集中于国土中部与东南部,RR显著升高;加纳疟疾扫描出一个RR=4.66(p<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.18(95% 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 | 多步长风险预测(1–7天) | 预测-观测对比曲线,残差分布图 |
|
||||
| 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或SRTM,30m分辨率)及其衍生地形因子(高程、坡度、坡向)用于刻画各网格单元在区域通风廊道中的空间位置。高分辨率人口密度栅格(LandScan HD,100m分辨率)提供中国全域内暴露人口的精细空间分布。多光谱遥感方面,Landsat 8/9与Sentinel-2的反演产品提供多项关键变量的连续时间序列:NDVI植被指数的逐月或逐旬序列可反映绿地覆盖的季节性波动幅度;地表温度(LST)用于量化城市热岛强度的时空变化模式;气溶胶光学厚度(AOD)作为地面PM₂.₅监测的空间补充,在监测站点稀疏的城市远郊区域尤为有用。土地覆盖分类将地表划分为建筑区、道路、水体、绿地与裸地五个类别,逐网格计算各类别的面积占比,从而将建成环境比例转化为一个连续数值变量进入模型。
|
||||
|
||||
**医疗就诊记录。** 分为门诊与住院两条线数据。单条记录的字段结构明确但信息密度不低:就诊(入院)日期、ICD-10诊断编码、年龄分层(0–14、15–44、45–64、65岁以上四组)、性别与现住址。地址文本经由地理编码引擎转化为经纬度坐标,匹配过程采用两级策略——街道级别匹配,匹配失败则回退至行政区级别——匹配成功的记录附带置信度标记。聚合方向有两个:空间上可以向格网或行政区汇总,时间上可以向逐日、逐周或逐月汇总。门诊权重取1、住院权重取3,是一种将住院事件所代表的更高疾病严重程度以简单数值权重形式纳入统计建模的实用化处理。
|
||||
|
||||
**道路网络、兴趣点与建成环境。** 道路网络数据基于OpenStreetMap提取,覆盖motorway至tertiary五个等级,道路属性包含路段长度、限速与车道数。路网经拓扑检查与简化处理后构建为空间邻接图,其邻接矩阵是图神经网络模型的空间输入骨架。兴趣点数据涵盖三类关键设施:学校与幼儿园的位置及办学规模(影响各网格内儿童白天暴露人口密度的估计)、医疗机构的位置、等级与核定床位数(影响就医可及性与病例记录的完整程度——距离医疗机构较远的网格,轻症病例被记录的概率会系统性地偏低)、以及公园与绿地的空间边界(提供NDVI之外的绿地可达性信息)。公共交通可达性以公交站点与地铁站出入口的空间密度栅格表征。人口结构数据落至街道或社区级别,包含常住人口数、0–14岁儿童人口数与性别比。建筑环境数据涵盖建筑基底面积比例、平均层数与建成年代,这些变量在分析城市内部暴露异质性时,比任何宏观行政区级别的指标都更直接地反映建成环境对呼吸健康的影响机制。
|
||||
|
||||
**空间参考系。** 多级行政边界(省、市、区/县三级,街道级视数据源而可及)提供空间统计的行政分区基准。全域标准格网索引(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 +0,0 @@
|
||||
# Backend package
|
||||
@@ -1,42 +0,0 @@
|
||||
# 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
|
||||
@@ -8,13 +8,7 @@ from passlib.context import CryptContext
|
||||
|
||||
logger = logging.getLogger("cbpoa.auth")
|
||||
|
||||
_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."
|
||||
)
|
||||
SECRET_KEY = os.getenv("AUTH_SECRET_KEY", "cbpoa-dev-secret-change-in-production")
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("AUTH_TOKEN_EXPIRE_MINUTES", "480"))
|
||||
|
||||
@@ -67,10 +61,6 @@ 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
|
||||
|
||||
@@ -13,7 +13,7 @@ load_dotenv()
|
||||
# Paths
|
||||
# ============================================================================
|
||||
|
||||
PROJECT_ROOT = Path(os.environ.get("CBPOA_ROOT", Path(__file__).parent.parent))
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
DATA_DIR = PROJECT_ROOT / "outputs" / "daily"
|
||||
REPORTS_DIR = PROJECT_ROOT / "outputs" / "reports"
|
||||
WUHAN_BOUNDARY_PATH = PROJECT_ROOT / "Datas" / "武汉市.geojson"
|
||||
|
||||
@@ -3,64 +3,22 @@ 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: dict[str, Optional[pd.DataFrame | datetime]] = {
|
||||
_cache = {
|
||||
"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 = {
|
||||
'江岸区': ['江岸'],
|
||||
@@ -82,7 +40,6 @@ 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:
|
||||
@@ -97,143 +54,52 @@ 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.
|
||||
|
||||
Uses pre-generated Parquet files for fast loading (~0.1s).
|
||||
Falls back to Excel files (~10s) if Parquet is unavailable.
|
||||
"""
|
||||
"""Load and cache outpatient + inpatient data from Excel files"""
|
||||
if _cache["loaded_at"] is not None:
|
||||
return
|
||||
|
||||
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)}")
|
||||
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)}")
|
||||
|
||||
|
||||
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)."""
|
||||
def get_combined_data():
|
||||
"""Return merged outpatient + inpatient data with unified diagnosis column"""
|
||||
load_data()
|
||||
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()))
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def get_outpatient_data() -> pd.DataFrame:
|
||||
def get_outpatient_data():
|
||||
"""Return the cached outpatient dataframe"""
|
||||
load_data()
|
||||
return _cache["outpatient"] # type: ignore[return-value]
|
||||
return _cache["outpatient"]
|
||||
|
||||
|
||||
def get_inpatient_data() -> pd.DataFrame:
|
||||
def get_inpatient_data():
|
||||
"""Return the cached inpatient dataframe"""
|
||||
load_data()
|
||||
return _cache["inpatient"] # type: ignore[return-value]
|
||||
|
||||
|
||||
def load_cases_by_district_daily() -> pd.DataFrame:
|
||||
"""Load processed/cases_by_district_daily.parquet with districts normalized.
|
||||
|
||||
The on-disk parquet carries both bare and 区-suffixed spellings of each
|
||||
district (26 labels = 13 districts × 2 spellings), so any groupby on the
|
||||
raw `district` column double-counts. This is the single data-access
|
||||
boundary: it normalizes labels to the canonical 13 and re-aggregates
|
||||
(sum of outpatient_count / inpatient_count / total_cases per
|
||||
normalized district + date), so every downstream consumer
|
||||
(analysis / grid / insights) sees clean, deduped 13-district data.
|
||||
|
||||
Returns a copy with columns [date, district, outpatient_count,
|
||||
inpatient_count, total_cases]. Raises FileNotFoundError if the parquet
|
||||
is missing (callers handle this as they did before).
|
||||
"""
|
||||
path = PROCESSED_DIR / "cases_by_district_daily.parquet"
|
||||
cached = _cache.get("cases_by_district_daily")
|
||||
if cached is not None:
|
||||
return cast(pd.DataFrame, cached).copy()
|
||||
|
||||
with _load_lock:
|
||||
cached = _cache.get("cases_by_district_daily")
|
||||
if cached is not None:
|
||||
return cast(pd.DataFrame, cached).copy()
|
||||
|
||||
df = pd.read_parquet(path)
|
||||
df["district"] = df["district"].map(normalize_district)
|
||||
agg = (
|
||||
df.groupby(["date", "district"], as_index=False)[
|
||||
["outpatient_count", "inpatient_count", "total_cases"]
|
||||
]
|
||||
.sum()
|
||||
)
|
||||
_cache["cases_by_district_daily"] = agg # type: ignore[assignment]
|
||||
return agg.copy()
|
||||
return _cache["inpatient"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, environment, statistics
|
||||
from routers import risk, alerts, analysis, insights, reports, cases, geocoded, grid, chat
|
||||
|
||||
|
||||
setup_logging()
|
||||
@@ -27,7 +27,7 @@ app = FastAPI(
|
||||
)
|
||||
|
||||
app.add_middleware(RequestLoggerMiddleware)
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1000, compresslevel=1)
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
||||
|
||||
logger = logging.getLogger("cbpoa.main")
|
||||
|
||||
@@ -58,8 +58,6 @@ 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("/")
|
||||
|
||||
@@ -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, ConfigDict, Field
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List, Literal
|
||||
from datetime import datetime
|
||||
|
||||
@@ -280,9 +280,6 @@ 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")
|
||||
|
||||
@@ -16,4 +16,3 @@ pandas>=2.0.0
|
||||
numpy>=1.24.0
|
||||
pyarrow>=14.0.0
|
||||
openpyxl>=3.1.0
|
||||
Pillow>=10.0.0
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
# 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()`
|
||||
@@ -5,15 +5,12 @@ 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"])
|
||||
|
||||
@@ -35,13 +32,8 @@ def grid_id_to_center(grid_id: str) -> tuple[float, float]:
|
||||
return lat, lon
|
||||
|
||||
|
||||
@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).
|
||||
def generate_alerts_for_date(date: str) -> List[Alert]:
|
||||
"""Generate alerts for high-risk grids on a specific date.
|
||||
|
||||
Phase 1: iterate features, aggregate max risk per 100m grid cell.
|
||||
Phase 2: build Alert objects from aggregated grid cells.
|
||||
@@ -96,14 +88,13 @@ def _generate_alerts_cached(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=district,
|
||||
street=grid_id,
|
||||
region="武汉市",
|
||||
street=f"Grid {grid_id}",
|
||||
latitude=lat,
|
||||
longitude=lon,
|
||||
risk_value=max_risk,
|
||||
@@ -120,16 +111,6 @@ def _generate_alerts_cached(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):
|
||||
@@ -137,7 +118,7 @@ async def list_alerts(date: str | None = None, priority: str | None = None, min_
|
||||
if date is None:
|
||||
date = get_latest_date()
|
||||
|
||||
alerts = await generate_alerts_for_date(date)
|
||||
alerts = generate_alerts_for_date(date)
|
||||
|
||||
if priority:
|
||||
alerts = [a for a in alerts if a.priority == priority]
|
||||
@@ -159,7 +140,7 @@ async def get_alert(alert_id: str, date: str | None = None):
|
||||
if date is None:
|
||||
date = get_latest_date()
|
||||
|
||||
alerts = await generate_alerts_for_date(date)
|
||||
alerts = generate_alerts_for_date(date)
|
||||
|
||||
for alert in alerts:
|
||||
if alert.alert_id == alert_id:
|
||||
@@ -175,7 +156,7 @@ async def get_p1_alerts(date: str | None = None):
|
||||
if date is None:
|
||||
date = get_latest_date()
|
||||
|
||||
alerts = await generate_alerts_for_date(date)
|
||||
alerts = generate_alerts_for_date(date)
|
||||
p1_alerts = [a for a in alerts if a.priority == "P1"]
|
||||
|
||||
return AlertResponse(
|
||||
@@ -192,7 +173,7 @@ async def get_p2_alerts(date: str | None = None):
|
||||
if date is None:
|
||||
date = get_latest_date()
|
||||
|
||||
alerts = await generate_alerts_for_date(date)
|
||||
alerts = generate_alerts_for_date(date)
|
||||
p2_alerts = [a for a in alerts if a.priority == "P2"]
|
||||
|
||||
return AlertResponse(
|
||||
@@ -209,7 +190,7 @@ async def get_grid_alerts(grid_id: str, date: str | None = None):
|
||||
if date is None:
|
||||
date = get_latest_date()
|
||||
|
||||
alerts = await generate_alerts_for_date(date)
|
||||
alerts = generate_alerts_for_date(date)
|
||||
grid_alerts = [a for a in alerts if a.grid_id == grid_id]
|
||||
|
||||
return AlertResponse(
|
||||
|
||||
@@ -5,20 +5,15 @@ 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, PROJECT_ROOT, WUHAN_BOUNDS, LAT_STEP, LON_STEP
|
||||
from data.case_loader import load_cases_by_district_daily
|
||||
from config import DATA_DIR, RISK_HIGH
|
||||
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"])
|
||||
|
||||
@@ -32,13 +27,11 @@ class TrendResponse(BaseModel):
|
||||
|
||||
class DistrictRisk(BaseModel):
|
||||
"""District-level risk aggregation"""
|
||||
district: str = Field(..., description="District name")
|
||||
name: 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="Total recorded cases (real, from cases_by_district_daily)")
|
||||
total_cases: int = Field(..., description="Estimated total cases")
|
||||
|
||||
|
||||
class DistrictsResponse(BaseModel):
|
||||
@@ -85,10 +78,25 @@ 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")
|
||||
# Disk+memory cached mean — avoids re-parsing ~45MB GeoJSON every request
|
||||
values.append(daily_avg_risk(date_str))
|
||||
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)
|
||||
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(
|
||||
@@ -98,144 +106,101 @@ 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 (cached per date)."""
|
||||
"""
|
||||
Get district-level risk aggregation
|
||||
|
||||
Returns:
|
||||
District-level risk data with averages and counts
|
||||
"""
|
||||
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}")
|
||||
|
||||
districts = [DistrictRisk(**d) for d in _aggregate_districts(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)
|
||||
)
|
||||
)
|
||||
|
||||
return DistrictsResponse(
|
||||
districts=districts,
|
||||
timestamp=datetime.now().isoformat(),
|
||||
districts=result,
|
||||
timestamp=datetime.now().isoformat()
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -96,10 +96,9 @@ async def get_cases_stats(
|
||||
total_outpatient = len(df_out)
|
||||
total_inpatient = len(df_in)
|
||||
|
||||
# 日期范围(过滤后可能为空,需防御 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 ""
|
||||
# 日期范围
|
||||
min_date = min(df_out['date'].min(), df_in['date'].min())
|
||||
max_date = max(df_out['date'].max(), df_in['date'].max())
|
||||
|
||||
# 区域统计
|
||||
out_districts = df_out[df_out['district'] != '未知']['district'].value_counts().head(10)
|
||||
@@ -121,8 +120,8 @@ async def get_cases_stats(
|
||||
total_outpatient=total_outpatient,
|
||||
total_inpatient=total_inpatient,
|
||||
date_range={
|
||||
"start": date_start,
|
||||
"end": date_end
|
||||
"start": min_date.strftime("%Y-%m-%d"),
|
||||
"end": max_date.strftime("%Y-%m-%d")
|
||||
},
|
||||
top_districts=top_districts,
|
||||
top_diagnoses=top_diagnoses
|
||||
@@ -148,7 +147,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().copy()
|
||||
df = get_combined_data()
|
||||
|
||||
# 日期过滤
|
||||
if start_date:
|
||||
@@ -204,8 +203,6 @@ 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()
|
||||
@@ -214,12 +211,6 @@ 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']
|
||||
@@ -264,21 +255,18 @@ 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, start_date, end_date
|
||||
_compute_cases_districts, case_type, min_count, diagnosis
|
||||
)
|
||||
|
||||
|
||||
@@ -425,19 +413,10 @@ 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="获取季节性月度聚合数据")
|
||||
@@ -583,22 +562,16 @@ async def get_cases_demographics():
|
||||
# ============== Disease Seasonality Endpoint ==============
|
||||
|
||||
@router.get("/disease-seasonality", response_model=DiseaseSeasonalityResponse, summary="获取疾病季节性数据")
|
||||
async def get_disease_seasonality(
|
||||
diagnosis: Optional[str] = Query(None, description="Filter by diagnosis name"),
|
||||
):
|
||||
async def get_disease_seasonality():
|
||||
"""
|
||||
获取各诊断的月度季节性分布数据
|
||||
|
||||
- 基于门诊+住院合并数据
|
||||
- 按月聚合所有年份,返回 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
|
||||
@@ -635,25 +608,7 @@ 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,
|
||||
)
|
||||
|
||||
@@ -1,325 +0,0 @@
|
||||
"""
|
||||
环境数据 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
|
||||
)
|
||||
@@ -17,7 +17,7 @@ PROJECT_ROOT = Path(__file__).parent.parent.parent
|
||||
DATA_DIR = PROJECT_ROOT / "outputs"
|
||||
|
||||
|
||||
@lru_cache(maxsize=4)
|
||||
@lru_cache(maxsize=1)
|
||||
def _load_csv(path: Path) -> pd.DataFrame:
|
||||
return pd.read_csv(path)
|
||||
|
||||
@@ -109,21 +109,19 @@ 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)
|
||||
|
||||
@@ -134,11 +132,6 @@ 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]
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import asyncio
|
||||
from fastapi import APIRouter, HTTPException, Query, Response
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from datetime import datetime, timedelta
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
@@ -7,7 +6,6 @@ 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))
|
||||
@@ -21,35 +19,49 @@ 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:
|
||||
def _load_parquet(path: Path) -> "pd.DataFrame":
|
||||
import pandas as pd
|
||||
key = str(path)
|
||||
if key not in _parquet_cache:
|
||||
_parquet_cache[key] = pd.read_parquet(path)
|
||||
return _parquet_cache[key]
|
||||
|
||||
|
||||
def _compute_historical_aggregation(
|
||||
start: datetime,
|
||||
end: datetime,
|
||||
aggregation: str,
|
||||
district: Optional[str],
|
||||
) -> HistoricalAggregationResponse:
|
||||
"""Run the full pandas aggregation pipeline (called in thread pool)."""
|
||||
@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.
|
||||
"""
|
||||
try:
|
||||
cases_df = load_cases_by_district_daily()
|
||||
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")
|
||||
except FileNotFoundError:
|
||||
return HistoricalAggregationResponse(
|
||||
aggregations=[], total_records=0,
|
||||
date_range=(start.strftime("%Y-%m-%d"), end.strftime("%Y-%m-%d")),
|
||||
timestamp=datetime.now().isoformat(),
|
||||
date_range=(start_date, end_date), timestamp=datetime.now().isoformat(),
|
||||
)
|
||||
cases_df['date'] = pd.to_datetime(cases_df['date'])
|
||||
|
||||
@@ -64,7 +76,6 @@ def _compute_historical_aggregation(
|
||||
]
|
||||
|
||||
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',
|
||||
@@ -73,7 +84,6 @@ def _compute_historical_aggregation(
|
||||
}).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',
|
||||
@@ -88,8 +98,7 @@ def _compute_historical_aggregation(
|
||||
try:
|
||||
weather_df = _load_parquet(PROJECT_ROOT / "processed" / "weather" / "station_daily_2022.parquet")
|
||||
except FileNotFoundError:
|
||||
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 = pd.DataFrame(columns=['date', 'AQI', 'PM25', 'PM10'])
|
||||
weather_df['date'] = pd.to_datetime(weather_df['date']).dt.strftime('%Y-%m-%d')
|
||||
|
||||
# Weather data doesn't have district - aggregate by date only
|
||||
@@ -105,173 +114,135 @@ def _compute_historical_aggregation(
|
||||
aggregations = []
|
||||
for _, row in merged.iterrows():
|
||||
aggregations.append(DistrictAggregation(
|
||||
district=str(row['district']),
|
||||
district=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 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,
|
||||
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,
|
||||
))
|
||||
|
||||
return HistoricalAggregationResponse(
|
||||
aggregations=aggregations,
|
||||
total_records=len(aggregations),
|
||||
date_range=(start.strftime("%Y-%m-%d"), end.strftime("%Y-%m-%d")),
|
||||
date_range=(start_date, end_date),
|
||||
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 (cached per query)."""
|
||||
"""
|
||||
Get grid data as GeoJSON for map visualization.
|
||||
"""
|
||||
import pandas as pd
|
||||
|
||||
try:
|
||||
# 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)
|
||||
grid_df = _load_parquet(PROJECT_ROOT / "processed" / "grid_100m_index.parquet")
|
||||
except FileNotFoundError:
|
||||
return GridGeoJSONResponse(type="FeatureCollection", features=[], timestamp=datetime.now().isoformat())
|
||||
return Response(content=body, media_type="application/json")
|
||||
|
||||
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(),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/predict/multi-day", response_model=MultiDayPredictionResponse)
|
||||
@@ -307,9 +278,9 @@ async def predict_multi_day(request: MultiDayPredictionRequest):
|
||||
]
|
||||
|
||||
for _, row in features_df.iterrows():
|
||||
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]
|
||||
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))
|
||||
|
||||
if risk_1d >= 0.8:
|
||||
risk_level = "high"
|
||||
@@ -352,8 +323,14 @@ async def predict_multi_day(request: MultiDayPredictionRequest):
|
||||
)
|
||||
|
||||
|
||||
def _compute_grid_history(grid_id: str, days: int) -> dict:
|
||||
"""Heavy synchronous parquet reads + per-row loop (called in thread pool)."""
|
||||
@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.
|
||||
"""
|
||||
import pandas as pd
|
||||
|
||||
district_map = _load_parquet(PROJECT_ROOT / "processed" / "grid_district_mapping.parquet")
|
||||
@@ -364,7 +341,7 @@ def _compute_grid_history(grid_id: str, days: int) -> dict:
|
||||
|
||||
district = grid_info.iloc[0]['district_name']
|
||||
|
||||
cases_df = load_cases_by_district_daily()
|
||||
cases_df = _load_parquet(PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet")
|
||||
cases_df['date'] = pd.to_datetime(cases_df['date'])
|
||||
|
||||
end_date = datetime.now()
|
||||
@@ -390,18 +367,4 @@ def _compute_grid_history(grid_id: str, days: int) -> dict:
|
||||
"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)
|
||||
}
|
||||
@@ -4,14 +4,12 @@ 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,
|
||||
@@ -22,13 +20,6 @@ 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
|
||||
|
||||
@@ -50,7 +41,6 @@ class InsightCardResponse(BaseModel):
|
||||
warning_count: int
|
||||
info_count: int
|
||||
success_count: int
|
||||
danger_count: int
|
||||
cards: list[InsightCardItem]
|
||||
|
||||
|
||||
@@ -492,22 +482,22 @@ async def get_insights_cards():
|
||||
|
||||
cases_path = PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet"
|
||||
if cases_path.exists():
|
||||
# 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"])
|
||||
cases_df = pd.read_parquet(cases_path)
|
||||
latest_case_date = cases_df["date"].max()
|
||||
latest_cases = cases_df[cases_df["date"] == latest_case_date]
|
||||
district_daily = latest_cases.groupby("district")["total_cases"].sum().sort_values(ascending=False)
|
||||
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)
|
||||
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]
|
||||
week_cases = cases_df[cases_df["date"] >= week_ago].copy()
|
||||
week_cases["base_district"] = week_cases["district"].str.replace("区", "")
|
||||
daily_totals = week_cases.groupby("date")["total_cases"].sum()
|
||||
avg_daily = int(daily_totals.mean())
|
||||
week_district = week_cases.groupby("district")["total_cases"].sum().sort_values(ascending=False)
|
||||
week_district = week_cases.groupby("base_district")["total_cases"].sum().sort_values(ascending=False)
|
||||
week_top_val = int(week_district.iloc[0])
|
||||
|
||||
date_str = latest_case_date.strftime("%m月%d日")
|
||||
@@ -516,8 +506,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="日病例",
|
||||
@@ -543,7 +533,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 = _cached_parquet(str(mapping_path))
|
||||
mapping = pd.read_parquet(mapping_path)
|
||||
merged = grids_df.merge(mapping, on="grid_id", how="inner")
|
||||
|
||||
if len(merged) > 0:
|
||||
@@ -584,7 +574,7 @@ async def get_insights_cards():
|
||||
|
||||
weather_path = PROJECT_ROOT / "processed" / "weather" / "station_daily_2022.parquet"
|
||||
if weather_path.exists():
|
||||
weather_df = _cached_parquet(str(weather_path))
|
||||
weather_df = pd.read_parquet(weather_path)
|
||||
daily_wx = weather_df.groupby("date").agg(
|
||||
AQI=("AQI", "mean"), PM25=("PM25", "mean"), PM10=("PM10", "mean"),
|
||||
).reset_index()
|
||||
@@ -674,13 +664,11 @@ 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,
|
||||
)
|
||||
|
||||
@@ -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, Response
|
||||
from fastapi import APIRouter, HTTPException, Path, Query
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Annotated, List, Literal
|
||||
import asyncio
|
||||
import json
|
||||
import glob
|
||||
import re
|
||||
@@ -28,12 +28,8 @@ 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=8)
|
||||
@lru_cache(maxsize=3)
|
||||
def get_risk_data(date: str) -> tuple[list[list], dict]:
|
||||
filepath = DATA_DIR / f"risk_{date}.geojson"
|
||||
if not filepath.exists():
|
||||
@@ -59,7 +55,7 @@ def get_risk_data(date: str) -> tuple[list[list], dict]:
|
||||
return grids, grid_map
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
@lru_cache(maxsize=3)
|
||||
def get_kdtree_and_risks(date: str):
|
||||
grids, _ = get_risk_data(date)
|
||||
if not grids:
|
||||
@@ -74,8 +70,6 @@ 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
|
||||
|
||||
@@ -123,12 +117,7 @@ def generate_lod_grid(zoom: int, forecast_day: Literal[1, 3, 7] = 1,
|
||||
risks = risk_array[indices]
|
||||
risks[dists > LOD_MAX_RADIUS] = 0.0
|
||||
|
||||
# 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()
|
||||
lod_grids = np.column_stack([lat_grid.ravel(), lon_grid.ravel(), risks]).tolist()
|
||||
|
||||
return {
|
||||
"lod": lod_name,
|
||||
@@ -154,9 +143,8 @@ 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 at all zoom levels when bounds are given,
|
||||
# so even the coarse zoom<=9 lod1 grid is clipped to the viewport.
|
||||
if bounds:
|
||||
# Apply viewport bounds filtering for zoom >= 10
|
||||
if bounds and zoom >= 10:
|
||||
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"])
|
||||
@@ -188,11 +176,7 @@ def generate_lod_grid(zoom: int, forecast_day: Literal[1, 3, 7] = 1,
|
||||
|
||||
risks[dists > LOD_MAX_RADIUS] = 0.0
|
||||
|
||||
# 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()
|
||||
lod_grids = np.column_stack([lat_grid.ravel(), lon_grid.ravel(), risks]).tolist()
|
||||
|
||||
return {
|
||||
"lod": lod_name,
|
||||
@@ -204,21 +188,6 @@ 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:
|
||||
@@ -228,7 +197,13 @@ 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}")
|
||||
|
||||
return Response(content=_risk_map_body(date), media_type="application/json")
|
||||
grids = parse_geojson_file(filepath)
|
||||
|
||||
return RiskMapResponse(
|
||||
grids=grids,
|
||||
total_count=len(grids),
|
||||
timestamp=datetime.now().isoformat()
|
||||
)
|
||||
|
||||
|
||||
@router.get("/current", response_model=RiskMapResponse)
|
||||
@@ -239,7 +214,28 @@ async def get_current_risk():
|
||||
if not filepath.exists():
|
||||
raise HTTPException(status_code=404, detail=f"No data found for date {date}")
|
||||
|
||||
return Response(content=_risk_map_body(date), media_type="application/json")
|
||||
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()
|
||||
)
|
||||
|
||||
|
||||
@router.get("/precomputed", response_model=RiskMapResponse)
|
||||
@@ -251,7 +247,7 @@ async def get_precomputed_risk():
|
||||
|
||||
grids = []
|
||||
for _, row in df.iterrows():
|
||||
risk_index = float(row.get('risk_index', 0)) # type: ignore[arg-type]
|
||||
risk_index = float(row.get('risk_index', 0))
|
||||
grids.append({
|
||||
"grid_id": str(row['grid_id']),
|
||||
"latitude": float(row['center_y']),
|
||||
@@ -267,18 +263,6 @@ 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:
|
||||
@@ -288,7 +272,26 @@ 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}")
|
||||
|
||||
return Response(content=_fullgrid_body(date), media_type="application/json")
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/lod-grid")
|
||||
@@ -363,44 +366,50 @@ async def get_lod_tile(
|
||||
|
||||
|
||||
@router.get("/history/{grid_id}", response_model=RiskHistoryResponse)
|
||||
async def get_risk_history(grid_id: str, days: Annotated[int, Query(ge=1, le=30)] = 7):
|
||||
async def get_risk_history(grid_id: str, days: int = 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}")
|
||||
|
||||
# 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}")
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
geojson = json.load(f)
|
||||
|
||||
base_risk = None
|
||||
# Exact node_id match
|
||||
for g in grids:
|
||||
if g["grid_id"] == grid_id:
|
||||
base_risk = g["risk_value"]
|
||||
target_feature = None
|
||||
for feature in geojson.get("features", []):
|
||||
props = feature.get("properties", {})
|
||||
if str(props.get("node_id", "")) == grid_id:
|
||||
target_feature = feature
|
||||
break
|
||||
|
||||
# Fallback: nearest grid for r{row}_c{col} ids
|
||||
if base_risk is None and re.match(r'r\d+_c\d+', grid_id):
|
||||
if not target_feature 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
|
||||
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
|
||||
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]
|
||||
|
||||
if base_risk is None:
|
||||
if not target_feature:
|
||||
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": max(0.0, base_risk * (1 - i * 0.05))
|
||||
"risk_value": base_risk * (1 - i * 0.05)
|
||||
})
|
||||
|
||||
return RiskHistoryResponse(
|
||||
@@ -423,7 +432,7 @@ async def get_forecast_map(
|
||||
|
||||
if not filepath.exists():
|
||||
# Fall back to current data
|
||||
return await get_current_risk()
|
||||
return await get_current_risk_map()
|
||||
|
||||
grids = parse_geojson_file(filepath)
|
||||
if not grids:
|
||||
@@ -486,71 +495,3 @@ 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)
|
||||
|
||||
@@ -1,566 +0,0 @@
|
||||
"""
|
||||
统计分析 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=[])
|
||||
@@ -1,46 +0,0 @@
|
||||
# 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
|
||||
@@ -1,82 +0,0 @@
|
||||
"""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
|
||||
@@ -37,11 +37,8 @@ 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 == 422
|
||||
assert resp.status_code in (200, 404)
|
||||
|
||||
def test_forecast_too_large(self, client: TestClient):
|
||||
resp = client.get("/api/risk/forecast/999")
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,21 +0,0 @@
|
||||
"""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)
|
||||
@@ -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 polygon_coords[0] and isinstance(polygon_coords[0][0], list):
|
||||
if isinstance(polygon_coords[0], list) 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,8 +25,6 @@ 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
|
||||
|
||||
@@ -13,7 +13,7 @@ from utils.risk import risk_value_to_level
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@lru_cache(maxsize=16)
|
||||
@lru_cache(maxsize=8)
|
||||
def parse_geojson_file(filepath: Path) -> list[dict[str, Any]]:
|
||||
"""Parse GeoJSON file and extract grid data with standard fields."""
|
||||
try:
|
||||
|
||||
@@ -1,375 +0,0 @@
|
||||
"""
|
||||
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,
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
# 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
|
||||
@@ -1,26 +1,32 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpq-dev curl \
|
||||
libpq-dev \
|
||||
&& 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 /app
|
||||
WORKDIR /home/appuser
|
||||
|
||||
# Copy requirements and install dependencies
|
||||
COPY --chown=appuser:appgroup requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt \
|
||||
-i https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy backend code
|
||||
COPY --chown=appuser:appgroup . .
|
||||
|
||||
# Switch to non-root user
|
||||
USER appuser
|
||||
|
||||
ENV CBPOA_ROOT=/data
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=20s --retries=3 \
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:8000/docs || exit 1
|
||||
|
||||
# Run uvicorn
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
[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
|
||||
@@ -1,47 +1,83 @@
|
||||
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: ../deploy/backend/Dockerfile
|
||||
container_name: cbpoa_backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: wuhan_backend
|
||||
environment:
|
||||
CBPOA_ROOT: /data
|
||||
CORS_ORIGINS: "*"
|
||||
volumes:
|
||||
- ../outputs:/data/outputs:ro
|
||||
- ../processed:/data/processed:ro
|
||||
- ../Datas:/data/Datas:ro
|
||||
DATABASE_URL: postgresql://wuhan_user:password@postgres:5432/wuhan_disease
|
||||
POSTGRES_HOST: postgres
|
||||
POSTGRES_PORT: 5432
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "8000:8000"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:8000/docs || exit 1"]
|
||||
interval: 15s
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 40s
|
||||
restart: unless-stopped
|
||||
start_period: 30s
|
||||
networks:
|
||||
- cbpoa_net
|
||||
- wuhan_network
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ../frontend
|
||||
dockerfile: ../deploy/frontend/Dockerfile
|
||||
container_name: cbpoa_frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: wuhan_frontend
|
||||
environment:
|
||||
VITE_API_URL: http://localhost:8000
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
- backend
|
||||
ports:
|
||||
- "80:80"
|
||||
- "3000:80"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost/ || exit 1"]
|
||||
interval: 15s
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:80 || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- cbpoa_net
|
||||
- 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
|
||||
|
||||
networks:
|
||||
cbpoa_net:
|
||||
driver: bridge
|
||||
wuhan_network:
|
||||
driver: bridge
|
||||
@@ -3,4 +3,4 @@ node_modules
|
||||
*.md
|
||||
tests
|
||||
.env*
|
||||
dist
|
||||
dist
|
||||
@@ -5,11 +5,16 @@ 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 . .
|
||||
ENV VITE_API_URL=/api
|
||||
|
||||
# Build the application
|
||||
RUN pnpm run build
|
||||
|
||||
# =============================================================================
|
||||
@@ -17,10 +22,15 @@ RUN pnpm run build
|
||||
# =============================================================================
|
||||
FROM nginx:alpine AS production
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
# 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 --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
|
||||
@@ -1,37 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
# 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=
|
||||
@@ -1,21 +1,19 @@
|
||||
# Frontend — React + TypeScript + GeoScene Maps SDK
|
||||
# Frontend — React + TypeScript + Leaflet
|
||||
|
||||
## Stack
|
||||
|
||||
- React 18, TypeScript 5, Vite 5
|
||||
- Tailwind CSS, Recharts, Zustand (state), Axios
|
||||
- `@geoscene/core` (Maps SDK for JavaScript) — POC uses Tianditu basemap + local GeoJSON / FastAPI risk tiles
|
||||
- Leaflet / react-leaflet (maps)
|
||||
- Playwright (e2e tests)
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
frontend/src/
|
||||
main.tsx # Entry (+ GeoScene theme CSS)
|
||||
main.tsx # Entry point
|
||||
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
|
||||
@@ -30,21 +28,15 @@ frontend/src/
|
||||
## Patterns
|
||||
|
||||
- Components: PascalCase, one per file, default export
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
pnpm dev # localhost:3000, proxies /api → localhost:8000
|
||||
pnpm dev # localhost:5173, proxies /api → localhost:8000
|
||||
pnpm build # tsc + vite build → dist/
|
||||
```
|
||||
|
||||
@@ -55,4 +47,3 @@ 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
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
/**
|
||||
* 住院临床分析页(/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);
|
||||
});
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
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/);
|
||||
});
|
||||
});
|
||||
@@ -1,119 +0,0 @@
|
||||
/**
|
||||
* 综合概览大屏 (/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();
|
||||
});
|
||||
});
|
||||
@@ -1,232 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
@@ -1,135 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,291 +1,242 @@
|
||||
/**
|
||||
* 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.
|
||||
* US-007 + US-008: E2E user flow and UI state tests.
|
||||
* Simulates real user workflows through the CBPOA system.
|
||||
*/
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
import { TESTIDS } from '../src/utils/testids';
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
const BASE_URL = 'http://localhost:3000';
|
||||
|
||||
/** 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.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();
|
||||
});
|
||||
|
||||
// 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();
|
||||
test('登录表单可交互', async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
if (url.includes('/alerts')) {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ alerts: [], total: 0 }),
|
||||
});
|
||||
return;
|
||||
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 no inputs, user is already logged in (token in localStorage)
|
||||
});
|
||||
});
|
||||
|
||||
if (url.includes('/history/aggregated')) {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ aggregations: [], total_records: 0 }),
|
||||
});
|
||||
return;
|
||||
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('/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(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);
|
||||
}
|
||||
});
|
||||
|
||||
// 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('预警列表加载', 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 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.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);
|
||||
}
|
||||
});
|
||||
|
||||
if (url.includes('/cases/districts') || url.includes('/districts')) {
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 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('区县对比 (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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Default fallback — return a safe empty object.
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ data: [], items: [], total: 0 }),
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// URL-based navigation (react-router v6)
|
||||
// ---------------------------------------------------------------------------
|
||||
await page.goto(BASE_URL);
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
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
|
||||
const filtered = errors.filter(
|
||||
(e) => !e.includes('favicon') && !e.includes('404') && !e.includes('OLMap')
|
||||
);
|
||||
expect(noHorizontalScroll).toBe(true);
|
||||
expect(filtered).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Responsive layout — desktop @1280px', () => {
|
||||
test.use({ viewport: { width: 1280, height: 800 } });
|
||||
test.describe('响应式布局 (Responsive Layout)', () => {
|
||||
test('移动端视口下不崩溃', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
await page.goto(BASE_URL);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await seedAuthAndMockApi(page);
|
||||
const bodyText = await page.textContent('body');
|
||||
expect(bodyText).toBeTruthy();
|
||||
});
|
||||
|
||||
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();
|
||||
test('平板视口下正常显示', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 768, height: 1024 });
|
||||
await page.goto(BASE_URL);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
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();
|
||||
const bodyText = await page.textContent('body');
|
||||
expect(bodyText).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
<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>CBPOA · 武汉儿童呼吸疾病风险评估系统</title>
|
||||
<title>武汉儿童呼吸道疾病风险预测平台</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=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">
|
||||
<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="" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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-router-dom": "^6.30.4",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"recharts": "^2.12.0",
|
||||
"zustand": "^4.5.0"
|
||||
},
|
||||
@@ -22,6 +22,7 @@
|
||||
"@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",
|
||||
|
||||
@@ -14,16 +14,6 @@ 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
517
frontend/pnpm-lock.yaml
generated
@@ -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-router-dom:
|
||||
specifier: ^6.30.4
|
||||
version: 6.30.4(react-dom@18.3.1(react@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)
|
||||
recharts:
|
||||
specifier: ^2.12.0
|
||||
version: 2.15.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
@@ -42,6 +42,9 @@ 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
|
||||
@@ -82,18 +85,6 @@ 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==}
|
||||
|
||||
@@ -350,32 +341,6 @@ 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}
|
||||
@@ -396,12 +361,6 @@ 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'}
|
||||
@@ -414,20 +373,17 @@ 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
|
||||
|
||||
'@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'}
|
||||
'@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
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-beta.27':
|
||||
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
|
||||
@@ -633,6 +589,12 @@ 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==}
|
||||
|
||||
@@ -644,55 +606,6 @@ 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}
|
||||
@@ -714,13 +627,6 @@ 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'}
|
||||
@@ -851,41 +757,17 @@ 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==}
|
||||
|
||||
@@ -904,9 +786,6 @@ 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'}
|
||||
@@ -1046,9 +925,6 @@ 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'}
|
||||
@@ -1092,9 +968,6 @@ 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'}
|
||||
@@ -1209,9 +1082,6 @@ 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'}
|
||||
@@ -1342,6 +1212,9 @@ 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'}
|
||||
@@ -1349,15 +1222,6 @@ 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'}
|
||||
@@ -1383,10 +1247,6 @@ 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
|
||||
@@ -1394,11 +1254,6 @@ 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'}
|
||||
@@ -1638,23 +1493,17 @@ 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:
|
||||
@@ -1779,9 +1628,6 @@ 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'}
|
||||
@@ -1823,9 +1669,6 @@ 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'}
|
||||
@@ -1838,10 +1681,6 @@ 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==}
|
||||
|
||||
@@ -1875,17 +1714,10 @@ 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'}
|
||||
@@ -2041,11 +1873,6 @@ 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==}
|
||||
|
||||
@@ -2074,17 +1901,6 @@ 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)
|
||||
@@ -2296,56 +2112,6 @@ 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
|
||||
@@ -2369,12 +2135,6 @@ 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
|
||||
@@ -2387,17 +2147,15 @@ 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
|
||||
|
||||
'@polymer/polymer@3.5.2':
|
||||
'@react-leaflet/core@2.1.0(leaflet@1.9.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@webcomponents/shadycss': 1.11.2
|
||||
|
||||
'@remix-run/router@1.23.3': {}
|
||||
leaflet: 1.9.4
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-beta.27': {}
|
||||
|
||||
@@ -2557,6 +2315,12 @@ 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)':
|
||||
@@ -2568,118 +2332,6 @@ 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
|
||||
@@ -2721,10 +2373,6 @@ 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
|
||||
@@ -2862,35 +2510,14 @@ 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: {}
|
||||
@@ -2905,8 +2532,6 @@ snapshots:
|
||||
|
||||
cssesc@3.0.0: {}
|
||||
|
||||
cssfilter@0.0.10: {}
|
||||
|
||||
cssstyle@4.6.0:
|
||||
dependencies:
|
||||
'@asamuzakjp/css-color': 3.2.0
|
||||
@@ -3056,8 +2681,6 @@ 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
|
||||
@@ -3126,10 +2749,6 @@ 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:
|
||||
@@ -3234,10 +2853,6 @@ 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
|
||||
@@ -3373,26 +2988,12 @@ 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
|
||||
@@ -3418,16 +3019,12 @@ 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: {}
|
||||
@@ -3627,19 +3224,14 @@ snapshots:
|
||||
|
||||
react-is@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):
|
||||
react-leaflet@4.2.1(leaflet@1.9.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
'@remix-run/router': 1.23.3
|
||||
'@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
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
react-router: 6.30.4(react@18.3.1)
|
||||
|
||||
react-router@6.30.4(react@18.3.1):
|
||||
dependencies:
|
||||
'@remix-run/router': 1.23.3
|
||||
react: 18.3.1
|
||||
react-refresh@0.17.0: {}
|
||||
|
||||
react-smooth@4.0.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
@@ -3823,8 +3415,6 @@ snapshots:
|
||||
|
||||
signal-exit@4.1.0: {}
|
||||
|
||||
sortablejs@1.15.7: {}
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
stackback@0.0.2: {}
|
||||
@@ -3864,8 +3454,6 @@ snapshots:
|
||||
|
||||
symbol-tree@3.2.4: {}
|
||||
|
||||
tabbable@6.5.0: {}
|
||||
|
||||
tailwindcss@3.4.19:
|
||||
dependencies:
|
||||
'@alloc/quick-lru': 5.2.0
|
||||
@@ -3902,8 +3490,6 @@ snapshots:
|
||||
dependencies:
|
||||
any-promise: 1.3.0
|
||||
|
||||
timezone-groups@0.10.4: {}
|
||||
|
||||
tiny-invariant@1.3.3: {}
|
||||
|
||||
tinybench@2.9.0: {}
|
||||
@@ -3934,12 +3520,8 @@ 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: {}
|
||||
@@ -4097,11 +3679,6 @@ 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.
@@ -1,9 +1,16 @@
|
||||
import { useEffect, useState, Component, ReactNode, useCallback } from 'react';
|
||||
import { BrowserRouter, Routes, Route, useRoutes } from 'react-router-dom';
|
||||
import { AppShell } from '@/components/AppShell';
|
||||
import { useEffect, useState, Component, ReactNode, Suspense, lazy, useCallback } from 'react';
|
||||
import { TopNav } from '@/components/TopNav';
|
||||
import { SideNav } from '@/components/SideNav';
|
||||
import { useRiskStore } from '@/stores';
|
||||
import { Login } from '@/pages/Login';
|
||||
import { appRoutes } from '@/routes';
|
||||
|
||||
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 })));
|
||||
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
@@ -45,25 +52,28 @@ class ErrorBoundary extends Component<Props, State> {
|
||||
}
|
||||
}
|
||||
|
||||
// 已登录:AppShell 提供布局骨架,子路由表渲染到其 <Outlet/>。
|
||||
function AuthedApp({ onLogout }: { onLogout: () => void }) {
|
||||
const element = useRoutes([
|
||||
{
|
||||
element: <AppShell onLogout={onLogout} />,
|
||||
children: appRoutes,
|
||||
},
|
||||
]);
|
||||
return element;
|
||||
function PageLoader() {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[60vh]">
|
||||
<div className="text-text-secondary text-[13px]">加载中...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}, []);
|
||||
@@ -73,23 +83,38 @@ function App() {
|
||||
setToken(null);
|
||||
}, []);
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<Login onLogin={handleLogin} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<BrowserRouter
|
||||
future={{
|
||||
v7_startTransition: true,
|
||||
v7_relativeSplatPath: true,
|
||||
}}
|
||||
>
|
||||
{token ? (
|
||||
<AuthedApp onLogout={handleLogout} />
|
||||
) : (
|
||||
// 鉴权门:无 token 时所有路由都进入登录页。
|
||||
<Routes>
|
||||
<Route path="*" element={<Login onLogin={handleLogin} />} />
|
||||
</Routes>
|
||||
)}
|
||||
</BrowserRouter>
|
||||
<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>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,82 +7,82 @@ import { describe, it, expect } from 'vitest';
|
||||
describe('Component exports', () => {
|
||||
it('TopNav 可以被导入', async () => {
|
||||
const mod = await import('@/components/TopNav');
|
||||
expect((mod as any).default || mod.TopNav).toBeDefined();
|
||||
expect(mod.default || mod.TopNav).toBeDefined();
|
||||
});
|
||||
|
||||
it('SideNav 可以被导入', async () => {
|
||||
const mod = await import('@/components/SideNav');
|
||||
expect((mod as any).default || mod.SideNav).toBeDefined();
|
||||
expect(mod.default || mod.SideNav).toBeDefined();
|
||||
});
|
||||
|
||||
it('StatCard 可以被导入', async () => {
|
||||
const mod = await import('@/components/StatCard');
|
||||
expect((mod as any).default || mod.StatCard).toBeDefined();
|
||||
expect(mod.default || mod.StatCard).toBeDefined();
|
||||
});
|
||||
|
||||
it('ErrorBanner 可以被导入', async () => {
|
||||
const mod = await import('@/components/ErrorBanner');
|
||||
expect((mod as any).default || mod.ErrorBanner).toBeDefined();
|
||||
expect(mod.default || mod.ErrorBanner).toBeDefined();
|
||||
});
|
||||
|
||||
it('DiseaseFilter 可以被导入', async () => {
|
||||
const mod = await import('@/components/DiseaseFilter');
|
||||
expect((mod as any).default || mod.DiseaseFilter).toBeDefined();
|
||||
expect(mod.default || mod.DiseaseFilter).toBeDefined();
|
||||
});
|
||||
|
||||
it('ChatBot 可以被导入', async () => {
|
||||
const mod = await import('@/components/ChatBot');
|
||||
expect(mod.default || mod.ChatBot).toBeDefined();
|
||||
});
|
||||
|
||||
it('TimelinePlayer 可以被导入', async () => {
|
||||
const mod = await import('@/components/TimelinePlayer');
|
||||
expect((mod as any).default || mod.TimelinePlayer).toBeDefined();
|
||||
expect(mod.default || mod.TimelinePlayer).toBeDefined();
|
||||
});
|
||||
|
||||
it('StatisticalCharts 可以被导入', async () => {
|
||||
const mod = await import('@/components/StatisticalCharts');
|
||||
expect((mod as any).default || mod.StatisticalCharts).toBeDefined();
|
||||
expect(mod.default || mod.StatisticalCharts).toBeDefined();
|
||||
});
|
||||
|
||||
it('RiskMap 可以被导入', async () => {
|
||||
const mod = await import('@/components/RiskMap');
|
||||
expect(mod.default || mod.RiskMap).toBeDefined();
|
||||
});
|
||||
|
||||
it('AlertMap 可以被导入', async () => {
|
||||
const mod = await import('@/components/AlertMap');
|
||||
expect((mod as any).default || mod.AlertMap).toBeDefined();
|
||||
expect(mod.default || mod.AlertMap).toBeDefined();
|
||||
});
|
||||
|
||||
it('CaseLocationMap 可以被导入', async () => {
|
||||
const mod = await import('@/components/CaseLocationMap');
|
||||
expect((mod as any).default || mod.CaseLocationMap).toBeDefined();
|
||||
expect(mod.default || mod.CaseLocationMap).toBeDefined();
|
||||
});
|
||||
|
||||
it('geoscene createMapView 可以被导入', async () => {
|
||||
const mod = await import('@/geoscene');
|
||||
expect(mod.createMapView).toBeDefined();
|
||||
it('CaseMap 可以被导入', async () => {
|
||||
const mod = await import('@/components/CaseMap');
|
||||
expect(mod.default || mod.CaseMap).toBeDefined();
|
||||
});
|
||||
|
||||
it('DistributionChart 可以被导入', async () => {
|
||||
const mod = await import('@/components/DistributionChart');
|
||||
expect((mod as any).default || mod.DistributionChart).toBeDefined();
|
||||
expect(mod.default || mod.DistributionChart).toBeDefined();
|
||||
});
|
||||
|
||||
it('GridStatsOverlay 可以被导入', async () => {
|
||||
const mod = await import('@/components/GridStatsOverlay');
|
||||
expect((mod as any).default || mod.GridStatsOverlay).toBeDefined();
|
||||
expect(mod.default || mod.GridStatsOverlay).toBeDefined();
|
||||
});
|
||||
|
||||
it('LodGridLayer 可以被导入', async () => {
|
||||
const mod = await import('@/components/LodGridLayer');
|
||||
expect(mod.default || mod.LodGridLayer).toBeDefined();
|
||||
});
|
||||
|
||||
it('AdminBreadcrumb 可以被导入', async () => {
|
||||
const mod = await import('@/components/AdminBreadcrumb');
|
||||
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();
|
||||
expect(mod.default || mod.AdminBreadcrumb).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,27 +1,15 @@
|
||||
import { useEffect, useRef, useState, useCallback, memo } from 'react';
|
||||
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 L from 'leaflet';
|
||||
import { useRiskStore } from '@/stores';
|
||||
import { LodGridLayer } from '@/components/LodGridLayer';
|
||||
import { GridStatsOverlay } from '@/components/GridStatsOverlay';
|
||||
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';
|
||||
import { useLodGrid } from '@/hooks/useLodGrid';
|
||||
import type { Alert, GridRisk } from '@/types';
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -34,14 +22,16 @@ interface AlertMapProps {
|
||||
showAlertMarkers?: boolean;
|
||||
showGrid?: boolean;
|
||||
filteredAlerts?: Alert[];
|
||||
riskRange?: [number, number];
|
||||
isFullscreen?: boolean;
|
||||
}
|
||||
|
||||
const GRID_OPACITY = 0.72;
|
||||
const WUHAN_CENTER: [number, number] = [30.59, 114.31];
|
||||
|
||||
const RISK_LEGEND: [number, number, string][] = [
|
||||
[0.25, 0.4, '#38b000'],
|
||||
[0.4, 0.6, '#facc15'],
|
||||
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'],
|
||||
];
|
||||
@@ -50,9 +40,12 @@ 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,
|
||||
@@ -61,314 +54,243 @@ function AlertMapComponent({
|
||||
showAlertMarkers = true,
|
||||
showGrid = true,
|
||||
filteredAlerts = [],
|
||||
riskRange,
|
||||
isFullscreen = false,
|
||||
}: AlertMapProps) {
|
||||
const mapRef = useRef<HTMLDivElement>(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 mapInstanceRef = useRef<L.Map | null>(null);
|
||||
const alertLayerRef = useRef<L.LayerGroup | null>(null);
|
||||
const selectedMarkerRef = useRef<L.Rectangle | null>(null);
|
||||
const clickHandlerRef = useRef(onGridClick);
|
||||
const cellInfoRef = useRef(onCellInfo);
|
||||
const inputsRef = useRef({ filteredAlerts, showAlertMarkers, forecastDay });
|
||||
const [currentZoom, setCurrentZoom] = useState(10);
|
||||
|
||||
const [gridStats, setGridStats] = useState<RiskGridStats | null>(null);
|
||||
const [statsLoading, setStatsLoading] = useState(false);
|
||||
const [mapReady, setMapReady] = useState(false);
|
||||
const grids = useRiskStore((s) => s.grids ?? EMPTY_GRIDS);
|
||||
|
||||
// LOD grid data for stats overlay
|
||||
const { count, avgRisk, maxRisk, loading } = useLodGrid(currentZoom, forecastDay);
|
||||
|
||||
useEffect(() => {
|
||||
clickHandlerRef.current = onGridClick;
|
||||
}, [onGridClick]);
|
||||
useEffect(() => {
|
||||
cellInfoRef.current = onCellInfo;
|
||||
}, [onCellInfo]);
|
||||
useEffect(() => {
|
||||
inputsRef.current = { filteredAlerts, showAlertMarkers, forecastDay };
|
||||
}, [filteredAlerts, showAlertMarkers, forecastDay]);
|
||||
|
||||
// Initialize map
|
||||
useEffect(() => {
|
||||
if (!mapRef.current || viewRef.current) return;
|
||||
if (!mapRef.current || mapInstanceRef.current) return;
|
||||
|
||||
const { map, view, destroy } = createMapView({
|
||||
container: mapRef.current,
|
||||
zoom: 10,
|
||||
const map = L.map(mapRef.current, {
|
||||
center: WUHAN_CENTER,
|
||||
zoom: 9,
|
||||
zoomControl: true,
|
||||
preferCanvas: true,
|
||||
});
|
||||
|
||||
const riskTiles = createRiskTileLayer(forecastDay, showGrid ? GRID_OPACITY : 0);
|
||||
const alertLayer = createGraphicsLayer('预警点');
|
||||
const selectLayer = createGraphicsLayer('选中');
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||
maxZoom: 19,
|
||||
}).addTo(map);
|
||||
|
||||
map.addMany([riskTiles, alertLayer, selectLayer]);
|
||||
map.on('zoomend', () => {
|
||||
setCurrentZoom(map.getZoom());
|
||||
});
|
||||
|
||||
riskLayerRef.current = riskTiles;
|
||||
alertLayerRef.current = alertLayer;
|
||||
selectLayerRef.current = selectLayer;
|
||||
viewRef.current = view;
|
||||
destroyRef.current = destroy;
|
||||
mapInstanceRef.current = map;
|
||||
|
||||
try {
|
||||
view.ui.move('zoom', 'bottom-left');
|
||||
} catch {
|
||||
/* ignore */
|
||||
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;
|
||||
}
|
||||
|
||||
view.when(() => setMapReady(true)).catch(() => setMapReady(true));
|
||||
if (!showAlertMarkers || !filteredAlerts || filteredAlerts.length === 0) return;
|
||||
|
||||
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;
|
||||
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;
|
||||
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 */
|
||||
}
|
||||
});
|
||||
|
||||
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
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
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;
|
||||
}
|
||||
if (nearestId && minDist < 0.01) {
|
||||
clickHandlerRef.current(nearestId);
|
||||
} else if (onCellInfo) {
|
||||
onCellInfo({ lat, lon, risk, nearestAlertId: nearestId, nearestAlertDist: minDist });
|
||||
}
|
||||
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);
|
||||
}, []);
|
||||
},
|
||||
[filteredAlerts, onCellInfo]
|
||||
);
|
||||
|
||||
// Invalidate Leaflet size after fullscreen toggle
|
||||
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);
|
||||
const map = mapInstanceRef.current;
|
||||
if (!map) return;
|
||||
const timer = setTimeout(() => map.invalidateSize({ animate: true }), 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, [isFullscreen]);
|
||||
|
||||
// 工作台零内边距后,给地图更多垂直空间(非小卡片)
|
||||
const containerHeight = isFullscreen ? 'calc(100vh - 100px)' : 'calc(100vh - 220px)';
|
||||
const containerHeight = isFullscreen ? 'calc(100vh - 120px)' : 'calc(100vh - 280px)';
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<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>
|
||||
)}
|
||||
<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}
|
||||
/>
|
||||
|
||||
{/* Stats overlay */}
|
||||
<GridStatsOverlay
|
||||
count={gridStats?.cell_count ?? 0}
|
||||
avgRisk={gridStats?.avg_risk ?? 0}
|
||||
maxRisk={gridStats?.max_risk ?? 0}
|
||||
loading={statsLoading}
|
||||
count={count}
|
||||
avgRisk={avgRisk}
|
||||
maxRisk={maxRisk}
|
||||
loading={loading}
|
||||
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">风险等级 (100m 网格)</div>
|
||||
<div className="text-[11px] font-semibold text-text-secondary mb-2">风险等级</div>
|
||||
<div className="space-y-1.5">
|
||||
{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"><25% 不显示</span>
|
||||
</div>
|
||||
{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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
# 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)
|
||||
@@ -1,198 +0,0 @@
|
||||
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>
|
||||
);
|
||||
});
|
||||
@@ -1,117 +1,55 @@
|
||||
import { useEffect, useRef, useState, memo } 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 L from 'leaflet';
|
||||
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';
|
||||
|
||||
type CaseMapMode = 'points' | 'density';
|
||||
const WUHAN_CENTER: [number, number] = [30.59, 114.31];
|
||||
|
||||
interface CaseLocationMapProps {
|
||||
height?: string;
|
||||
district?: string | null;
|
||||
street?: string | null;
|
||||
date?: string | null;
|
||||
mode?: CaseMapMode;
|
||||
}
|
||||
|
||||
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) {
|
||||
function CaseLocationMapComponent({ height = '400px', district = null, street = null }: CaseLocationMapProps) {
|
||||
const mapRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<MapView | null>(null);
|
||||
const layerRef = useRef<GraphicsLayer | null>(null);
|
||||
const mapInstanceRef = useRef<L.Map | null>(null);
|
||||
const layerRef = useRef<L.LayerGroup | 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 || viewRef.current) return;
|
||||
if (!mapRef.current || mapInstanceRef.current) return;
|
||||
|
||||
setIsLoading(true);
|
||||
cancelledRef.current = false;
|
||||
const { map, view, destroy } = createMapView({
|
||||
container: mapRef.current,
|
||||
|
||||
const map = L.map(mapRef.current, {
|
||||
center: WUHAN_CENTER,
|
||||
zoom: 11,
|
||||
zoomControl: true,
|
||||
});
|
||||
const layer = createGraphicsLayer('病例');
|
||||
map.add(layer);
|
||||
layerRef.current = layer;
|
||||
viewRef.current = view;
|
||||
|
||||
// 缩放控件移到左下,避开右上观测日与图例
|
||||
try {
|
||||
view.ui.move('zoom', 'bottom-left');
|
||||
} catch {
|
||||
/* zoom widget may be absent */
|
||||
}
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap',
|
||||
maxZoom: 18,
|
||||
}).addTo(map);
|
||||
|
||||
view.when(() => setMapReady(true)).catch(() => setMapReady(true));
|
||||
mapInstanceRef.current = map;
|
||||
layerRef.current = L.layerGroup().addTo(map);
|
||||
|
||||
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 })
|
||||
// Fetch case locations
|
||||
geocodedApi.getGeocoded({ limit: 5000, district: district || undefined })
|
||||
.then((data) => {
|
||||
if (cancelledRef.current) return;
|
||||
const cases: GeocodedCase[] = data.cases || [];
|
||||
layer.removeAll();
|
||||
const layer = layerRef.current;
|
||||
if (!layer) return;
|
||||
|
||||
layer.clearLayers();
|
||||
|
||||
// Deduplicate by case_id to avoid overlapping markers
|
||||
const seen = new Set<string>();
|
||||
let unique: GeocodedCase[] = [];
|
||||
for (const c of cases) {
|
||||
@@ -120,64 +58,44 @@ function CaseLocationMapComponent({
|
||||
unique.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
// Client-side street filtering
|
||||
if (street) {
|
||||
unique = unique.filter((c) => c.street === street);
|
||||
}
|
||||
|
||||
if (cancelledRef.current) return;
|
||||
|
||||
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;
|
||||
});
|
||||
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,
|
||||
const marker = L.circleMarker([c.latitude, c.longitude], {
|
||||
radius: 3,
|
||||
fillColor: color,
|
||||
fillOpacity: 0.6,
|
||||
color: color,
|
||||
weight: 1,
|
||||
});
|
||||
g.popupTemplate = {
|
||||
title: '{district} {street}',
|
||||
content: '类型: {case_type}',
|
||||
};
|
||||
graphics.push(g);
|
||||
keys.push(c.case_id);
|
||||
|
||||
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(0);
|
||||
|
||||
setCaseCount(unique.length);
|
||||
setPointKeys(keys);
|
||||
setIsLoading(false);
|
||||
if (shouldFit && graphics.length > 0) {
|
||||
fittedScopeRef.current = scopeKey;
|
||||
view.goTo(graphics).catch(() => undefined);
|
||||
|
||||
// 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] });
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -186,74 +104,26 @@ function CaseLocationMapComponent({
|
||||
|
||||
return () => {
|
||||
cancelledRef.current = true;
|
||||
map.remove();
|
||||
mapInstanceRef.current = null;
|
||||
};
|
||||
}, [district, street, date, mode, mapReady]);
|
||||
|
||||
const isDensity = mode === 'density';
|
||||
|
||||
const fillParent = height === '100%' || height === '100vh';
|
||||
}, [district, street]);
|
||||
|
||||
return (
|
||||
<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 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>
|
||||
)}
|
||||
{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">
|
||||
更新病例…
|
||||
{!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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
376
frontend/src/components/CaseMap.tsx
Normal file
376
frontend/src/components/CaseMap.tsx
Normal file
@@ -0,0 +1,376 @@
|
||||
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">高风险 (>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">低风险 (<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);
|
||||
185
frontend/src/components/ChatBot.tsx
Normal file
185
frontend/src/components/ChatBot.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
366
frontend/src/components/LodGridLayer.tsx
Normal file
366
frontend/src/components/LodGridLayer.tsx
Normal file
@@ -0,0 +1,366 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
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">
|
||||
|
||||
</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>
|
||||
);
|
||||
});
|
||||
318
frontend/src/components/RiskMap.tsx
Normal file
318
frontend/src/components/RiskMap.tsx
Normal file
@@ -0,0 +1,318 @@
|
||||
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);
|
||||
@@ -1,45 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,147 +1,113 @@
|
||||
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;
|
||||
}
|
||||
|
||||
interface NavItem {
|
||||
to: string;
|
||||
label: string;
|
||||
testid: string;
|
||||
}
|
||||
|
||||
const modules: { id: string; label: string; icon: React.ReactNode; items: NavItem[] }[] = [
|
||||
const modules: { id: string; label: string; icon: React.ReactNode; items: { id: string; label: string }[] }[] = [
|
||||
{
|
||||
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: [{ to: '/monitoring', label: '监测面板', testid: TESTIDS.navMonitoring }],
|
||||
items: [
|
||||
{ id: 'monitoring', label: '监测面板' },
|
||||
],
|
||||
},
|
||||
{
|
||||
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: [{ to: '/alerts', label: '预警地图', testid: TESTIDS.navAlerts }],
|
||||
items: [
|
||||
{ id: 'alerts', label: '预警地图' },
|
||||
],
|
||||
},
|
||||
{
|
||||
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: [
|
||||
{ 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 },
|
||||
{ id: 'trend-analysis', label: '趋势分析' },
|
||||
{ id: 'district-comparison', label: '区域对比' },
|
||||
{ id: 'insights', label: '智能洞察' },
|
||||
{ id: 'reports', label: '报表中心' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function SideNav({
|
||||
activePage,
|
||||
onPageChange,
|
||||
alertCount = 0,
|
||||
onNavigate,
|
||||
expanded: expandedProp,
|
||||
onExpandedChange,
|
||||
}: SideNavProps) {
|
||||
const location = useLocation();
|
||||
const [expanded, setExpanded] = useState<string | null>('monitoring');
|
||||
|
||||
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 handleItemClick = (moduleId: string, itemId: string) => {
|
||||
setExpanded(moduleId);
|
||||
onPageChange(itemId);
|
||||
};
|
||||
|
||||
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) => location.pathname.startsWith(item.to));
|
||||
return module.items.some(item => item.id === activePage);
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="h-full overflow-y-auto py-2 px-2.5">
|
||||
<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">
|
||||
{modules.map((module) => (
|
||||
<div key={module.id} className="mb-1.5">
|
||||
<div key={module.id} className="mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(expanded === module.id ? null : module.id)}
|
||||
className={`w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-[13px] font-semibold transition-colors ${
|
||||
className={`w-full flex items-center gap-[10px] px-3 py-[9px] rounded-md text-[14px] 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 opacity-90">{module.icon}</span>
|
||||
<span className="flex-1 text-left">{module.label}</span>
|
||||
<span className="w-4 h-4 flex items-center justify-center">
|
||||
{module.icon}
|
||||
</span>
|
||||
<span>{module.label}</span>
|
||||
{module.id === 'alert' && alertCount > 0 && (
|
||||
<span className="bg-danger-light text-danger text-[10px] font-semibold px-1.5 py-0.5 rounded-md tabular-nums">
|
||||
<span className="ml-auto bg-danger-light text-danger text-[10px] font-semibold px-[5px] py-[2px] rounded">
|
||||
{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-0.5 ml-3 pl-3 border-l border-border-light">
|
||||
<div className="mt-1 pl-7">
|
||||
{module.items.map((item) => (
|
||||
<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'
|
||||
}`
|
||||
}
|
||||
<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'
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,101 +1,44 @@
|
||||
import React from 'react';
|
||||
|
||||
interface StatCardProps {
|
||||
icon?: React.ReactNode;
|
||||
label: string;
|
||||
value: string | number;
|
||||
trend?: {
|
||||
direction: 'up' | 'down' | 'stable';
|
||||
value: string;
|
||||
};
|
||||
sparkline?: {
|
||||
data: number[];
|
||||
color: string;
|
||||
};
|
||||
color?: string;
|
||||
onClick?: () => void;
|
||||
change?: string;
|
||||
changeType?: 'up' | 'down' | 'neutral';
|
||||
progress?: number;
|
||||
progressColor?: string;
|
||||
}
|
||||
|
||||
export const StatCard = React.memo(function StatCard({
|
||||
icon,
|
||||
export function StatCard({
|
||||
label,
|
||||
value,
|
||||
trend,
|
||||
sparkline,
|
||||
color,
|
||||
onClick,
|
||||
change,
|
||||
changeType = 'neutral',
|
||||
progress,
|
||||
progressColor = 'bg-warning',
|
||||
}: 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>▲</span>}
|
||||
{trend.direction === 'down' && <span aria-hidden>▼</span>}
|
||||
{trend.direction === 'stable' && <span aria-hidden>▬</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
|
||||
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 className="card p-4">
|
||||
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-1.5">
|
||||
{label}
|
||||
</div>
|
||||
<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 className="font-display text-[26px] font-bold text-text-primary mb-1">
|
||||
{value}
|
||||
</div>
|
||||
{change && (
|
||||
<div className={`text-[11px] ${
|
||||
changeType === 'up' ? 'text-danger' :
|
||||
changeType === 'down' ? 'text-success' : 'text-text-muted'
|
||||
}`}>
|
||||
{change}
|
||||
</div>
|
||||
{sparklineSvg}
|
||||
</div>
|
||||
{trendIndicator && <div className="mt-1.5 pl-1">{trendIndicator}</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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -38,9 +38,8 @@ 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, 0) / secondHalf.length;
|
||||
const secondAvg = secondHalf.reduce((a, b) => a + b) / secondHalf.length;
|
||||
|
||||
if (firstAvg === 0) return secondAvg > 0 ? 'up' : 'stable';
|
||||
const change = ((secondAvg - firstAvg) / firstAvg) * 100;
|
||||
|
||||
if (change > 10) return 'up';
|
||||
@@ -49,11 +48,7 @@ export function StatisticalCharts({
|
||||
};
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const noData = data.length === 0;
|
||||
|
||||
if (noData) {
|
||||
return { totalCases: 0, avgCases: 0, maxCases: 0, trend: 'stable' as const, noData: true };
|
||||
}
|
||||
if (data.length === 0) return null;
|
||||
|
||||
const totalCases = data.reduce((sum, item) => sum + item.cases, 0);
|
||||
const avgCases = totalCases / data.length;
|
||||
@@ -65,7 +60,6 @@ export function StatisticalCharts({
|
||||
avgCases: Math.round(avgCases),
|
||||
maxCases,
|
||||
trend,
|
||||
noData: false,
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
@@ -152,7 +146,7 @@ export function StatisticalCharts({
|
||||
</div>
|
||||
|
||||
{/* Stats cards */}
|
||||
{activeChart === 'cases' && (
|
||||
{stats && 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>
|
||||
@@ -169,11 +163,6 @@ 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%">
|
||||
|
||||
@@ -26,11 +26,6 @@ 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[] = [];
|
||||
@@ -45,22 +40,9 @@ export function TimelinePlayer({
|
||||
return dates;
|
||||
}, []);
|
||||
|
||||
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 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 play = useCallback(() => {
|
||||
setPlaying(true);
|
||||
@@ -73,8 +55,11 @@ export function TimelinePlayer({
|
||||
}, [onPlayPause]);
|
||||
|
||||
const togglePlay = () => {
|
||||
if (playing) pause();
|
||||
else play();
|
||||
if (playing) {
|
||||
pause();
|
||||
} else {
|
||||
play();
|
||||
}
|
||||
};
|
||||
|
||||
const goToNext = useCallback(() => {
|
||||
@@ -86,24 +71,26 @@ export function TimelinePlayer({
|
||||
onDateChange(dateRange[0]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
advanceRef.current = goToNext;
|
||||
}, [goToNext]);
|
||||
|
||||
useEffect(() => {
|
||||
if (playing) {
|
||||
const interval = 1000 / speed;
|
||||
|
||||
timerRef.current = setInterval(() => {
|
||||
advanceRef.current();
|
||||
goToNext();
|
||||
}, interval);
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
}
|
||||
};
|
||||
}
|
||||
}, [playing, speed]);
|
||||
}, [playing, speed, goToNext]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentIndex >= dateRange.length - 1) pause();
|
||||
if (currentIndex >= dateRange.length - 1) {
|
||||
pause();
|
||||
}
|
||||
}, [currentIndex, dateRange.length, pause]);
|
||||
|
||||
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -112,15 +99,24 @@ export function TimelinePlayer({
|
||||
};
|
||||
|
||||
const handleSpeedChange = () => {
|
||||
const idx = SPEEDS.indexOf(speed);
|
||||
const nextIndex = (idx + 1) % SPEEDS.length;
|
||||
const currentIndex = SPEEDS.indexOf(speed);
|
||||
const nextIndex = (currentIndex + 1) % SPEEDS.length;
|
||||
onSpeedChange?.(SPEEDS[nextIndex]);
|
||||
};
|
||||
|
||||
const formatSpeed = (s: number) => (s >= 1 ? `${s}x` : `${s.toFixed(1)}x`);
|
||||
const formatSpeed = (s: number) => {
|
||||
return 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',
|
||||
@@ -128,82 +124,72 @@ export function TimelinePlayer({
|
||||
});
|
||||
};
|
||||
|
||||
const fmtShort = (d: string) =>
|
||||
new Date(d).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
|
||||
|
||||
return (
|
||||
<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 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>
|
||||
</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>
|
||||
{/* 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>
|
||||
|
||||
{/* 倍速 */}
|
||||
{/* 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="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"
|
||||
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"
|
||||
title="调整播放速度"
|
||||
aria-label={`播放速度 ${formatSpeed(speed)}`}
|
||||
>
|
||||
{formatSpeed(speed)}
|
||||
</button>
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { TESTIDS } from '@/utils/testids';
|
||||
|
||||
interface TopNavProps {
|
||||
onLogout?: () => void;
|
||||
onToggleMenu?: () => void;
|
||||
isMenuOpen?: boolean;
|
||||
}
|
||||
|
||||
function Clock() {
|
||||
@@ -13,64 +10,44 @@ function Clock() {
|
||||
const id = setInterval(() => setTime(new Date()), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
return (
|
||||
<span className="font-mono tabular-nums">
|
||||
{time.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
);
|
||||
return <span>{time.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}</span>;
|
||||
}
|
||||
|
||||
export function TopNav({ onLogout, onToggleMenu, isMenuOpen = false }: TopNavProps) {
|
||||
return (
|
||||
<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>
|
||||
)}
|
||||
export function TopNav({ onLogout }: TopNavProps) {
|
||||
|
||||
<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
|
||||
>
|
||||
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">
|
||||
<svg className="w-4 h-4 fill-white" viewBox="0 0 24 24">
|
||||
<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" />
|
||||
<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"/>
|
||||
</svg>
|
||||
</div>
|
||||
<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>
|
||||
<span className="font-display font-semibold text-[15px] text-text-primary">
|
||||
WuhanChildRisk
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-6 bg-border ml-4 mr-4 hidden md:block" />
|
||||
<div className="w-px h-5 bg-border ml-4 mr-4" />
|
||||
|
||||
<span className="text-[12px] text-text-secondary hidden md:inline truncate">
|
||||
监测 · 预警 · 空间风险
|
||||
<span className="text-[13px] text-text-secondary">
|
||||
儿童呼吸道疾病风险监测预警平台
|
||||
</span>
|
||||
|
||||
<div className="ml-auto flex items-center gap-4">
|
||||
<span className="text-[12px] text-text-muted hidden sm:inline">
|
||||
<div className="ml-auto flex items-center gap-5">
|
||||
<span className="text-[12px] text-text-muted">
|
||||
<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 px-2 py-1 rounded-md hover:bg-danger-light/60"
|
||||
className="text-[12px] text-text-muted hover:text-danger transition-colors"
|
||||
>
|
||||
退出
|
||||
</button>
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
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">×</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>
|
||||
);
|
||||
});
|
||||
@@ -1,179 +0,0 @@
|
||||
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>
|
||||
);
|
||||
});
|
||||
@@ -1,74 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -1,157 +0,0 @@
|
||||
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.6–0.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.4–0.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>
|
||||
);
|
||||
});
|
||||
@@ -1,133 +0,0 @@
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -1,159 +0,0 @@
|
||||
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.6–0.8)</div>
|
||||
<div className="risk-strip__value text-warning">{riskStats.mediumHigh}</div>
|
||||
</div>
|
||||
<div className="risk-strip__cell">
|
||||
<div className="risk-strip__label">中风险 (0.4–0.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>
|
||||
);
|
||||
});
|
||||
@@ -1,73 +0,0 @@
|
||||
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>
|
||||
);
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
// 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][];
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
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">
|
||||
{/* p25–p75 箱体 */}
|
||||
<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>
|
||||
);
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
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>
|
||||
);
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
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>
|
||||
);
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
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>
|
||||
);
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
/**
|
||||
* 住院临床分析页图表字面色值集中处。
|
||||
* 对齐江雾青绿 / 雾蓝体系,避免紫系默认配色。
|
||||
*/
|
||||
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;
|
||||
@@ -1,194 +0,0 @@
|
||||
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>
|
||||
);
|
||||
});
|
||||
@@ -1,73 +0,0 @@
|
||||
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>
|
||||
);
|
||||
});
|
||||
@@ -1,119 +0,0 @@
|
||||
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>
|
||||
);
|
||||
});
|
||||
@@ -1,215 +0,0 @@
|
||||
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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
// 监测页内部共享类型。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;
|
||||
}
|
||||
@@ -1,317 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
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);
|
||||
@@ -1,116 +0,0 @@
|
||||
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">
|
||||
近30日病例与AQI趋势
|
||||
</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);
|
||||
@@ -1,197 +0,0 @@
|
||||
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);
|
||||
@@ -1,124 +0,0 @@
|
||||
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);
|
||||
@@ -1,79 +0,0 @@
|
||||
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);
|
||||
@@ -1,109 +0,0 @@
|
||||
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);
|
||||
@@ -1,22 +0,0 @@
|
||||
/**
|
||||
* 概览大屏图表与地图使用的字面色值集中处。
|
||||
* 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
Reference in New Issue
Block a user