Compare commits
1 Commits
feat/cases
...
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| e95e2f1338 |
1010
Outputs/transcript/transcript_raw.json
Normal file
1010
Outputs/transcript/transcript_raw.json
Normal file
File diff suppressed because it is too large
Load Diff
526
Outputs/transcript/transcript_speakers.txt
Normal file
526
Outputs/transcript/transcript_speakers.txt
Normal file
@@ -0,0 +1,526 @@
|
||||
|
||||
=== 说话人6 ===
|
||||
|
||||
[300.9s-301.7s] 好
|
||||
|
||||
=== 说话人2 ===
|
||||
|
||||
[318.0s-319.3s] 那不不有啊
|
||||
[319.3s-320.2s] 那不有吗
|
||||
|
||||
=== 说话人6 ===
|
||||
|
||||
[320.2s-321.2s] 那不有
|
||||
|
||||
=== 说话人2 ===
|
||||
|
||||
[321.2s-322.2s] 那不有
|
||||
[322.2s-322.7s] 那不有
|
||||
[322.7s-323.9s] 那不有
|
||||
|
||||
=== 说话人6 ===
|
||||
|
||||
[323.9s-325.7s] 那不有
|
||||
|
||||
=== 说话人4 ===
|
||||
|
||||
[325.7s-326.8s] 那不有
|
||||
|
||||
=== 说话人6 ===
|
||||
|
||||
[332.6s-333.6s] 那不有
|
||||
|
||||
=== 说话人3 ===
|
||||
|
||||
[333.6s-351.3s] 那不有
|
||||
|
||||
=== 说话人6 ===
|
||||
|
||||
[351.3s-352.2s] 那不有
|
||||
[352.2s-352.8s] 那不有
|
||||
[352.8s-353.5s] 那不有
|
||||
|
||||
=== 说话人3 ===
|
||||
|
||||
[773.5s-774.9s] 因為因為住室太高了
|
||||
[774.9s-776.4s] 而且他們有一個秘密
|
||||
|
||||
=== 说话人8 ===
|
||||
|
||||
[776.4s-778.3s] 只要非常趴趴坐自己
|
||||
|
||||
=== 说话人3 ===
|
||||
|
||||
[778.3s-779.6s] 但是就是趴秘密
|
||||
|
||||
=== 说话人1 ===
|
||||
|
||||
[779.6s-780.4s] 還是比較
|
||||
|
||||
=== 说话人3 ===
|
||||
|
||||
[780.4s-781.9s] 真的沒有問題
|
||||
|
||||
=== 说话人6 ===
|
||||
|
||||
[781.9s-783.3s] 沒有問題
|
||||
|
||||
=== 说话人8 ===
|
||||
|
||||
[783.3s-784.4s] 就不叫做話
|
||||
|
||||
=== 说话人3 ===
|
||||
|
||||
[784.4s-785.6s] 因為那個秘密
|
||||
[785.6s-786.5s] 講的秘密
|
||||
[786.5s-787.5s] 基本上是太高了
|
||||
[787.5s-788.7s] 我們有一個秘密的交流
|
||||
|
||||
=== 说话人6 ===
|
||||
|
||||
[788.7s-789.2s] 其實
|
||||
|
||||
=== 说话人3 ===
|
||||
|
||||
[789.2s-790.9s] 三年秘密的交流
|
||||
|
||||
=== 说话人6 ===
|
||||
|
||||
[790.9s-791.9s] 其實就是說
|
||||
|
||||
=== 说话人3 ===
|
||||
|
||||
[791.9s-792.5s] 這個裡面
|
||||
|
||||
=== 说话人8 ===
|
||||
|
||||
[792.5s-793.4s] 趴坐這個的話
|
||||
[793.4s-794.8s] 其實是真好趴
|
||||
[794.8s-795.6s] 然後
|
||||
[795.6s-796.2s] 然後
|
||||
|
||||
=== 说话人7 ===
|
||||
|
||||
[796.2s-797.5s] 我現在認識的話
|
||||
|
||||
=== 说话人6 ===
|
||||
|
||||
[797.5s-798.8s] 那個坐子比較多
|
||||
[799.6s-800.1s] 因為
|
||||
|
||||
=== 说话人3 ===
|
||||
|
||||
[800.1s-801.4s] 現在坐
|
||||
[801.4s-802.1s] 這個只有
|
||||
|
||||
=== 说话人8 ===
|
||||
|
||||
[802.1s-802.8s] 八花之後
|
||||
[802.8s-803.4s] 崩潰
|
||||
|
||||
=== 说话人6 ===
|
||||
|
||||
[804.2s-805.4s] 崩潰
|
||||
|
||||
=== 说话人2 ===
|
||||
|
||||
[805.4s-806.7s] 把花穩回來
|
||||
|
||||
=== 说话人8 ===
|
||||
|
||||
[806.7s-807.4s] 它進屋
|
||||
[807.4s-808.0s] 現在
|
||||
|
||||
=== 说话人1 ===
|
||||
|
||||
[808.2s-809.2s] 因為 ele ran global
|
||||
[809.9s-810.5s] artist
|
||||
|
||||
=== 说话人4 ===
|
||||
|
||||
[810.5s-811.0s] 實體
|
||||
|
||||
=== 说话人5 ===
|
||||
|
||||
[811.0s-811.7s] 就不理
|
||||
|
||||
=== 说话人2 ===
|
||||
|
||||
[811.7s-812.7s] 一才玩樂
|
||||
[812.7s-813.1s] 但是
|
||||
|
||||
=== 说话人1 ===
|
||||
|
||||
[813.1s-814.3s] 操方
|
||||
|
||||
=== 说话人2 ===
|
||||
|
||||
[814.3s-817.2s] 著制
|
||||
[817.2s-820.7s] 因為很多
|
||||
|
||||
=== 说话人3 ===
|
||||
|
||||
[820.7s-827.4s] 太高了
|
||||
|
||||
=== 说话人5 ===
|
||||
|
||||
[1157.4s-1158.5s] 他就要到了現在
|
||||
|
||||
=== 说话人7 ===
|
||||
|
||||
[1158.5s-1160.4s] 所以他來自主持主持表
|
||||
|
||||
=== 说话人5 ===
|
||||
|
||||
[1160.4s-1162.9s] 哪些人以前的平常才見到呢
|
||||
|
||||
=== 说话人7 ===
|
||||
|
||||
[1162.9s-1165.4s] 讓你以前的在砍術店就爆發
|
||||
[1165.4s-1168.1s] 但是沒有些經過的這反應的
|
||||
[1168.1s-1170.2s] 這往他就可以一直了
|
||||
|
||||
=== 说话人1 ===
|
||||
|
||||
[1170.2s-1172.3s] 他只是要去砍得一百米
|
||||
|
||||
=== 说话人5 ===
|
||||
|
||||
[1172.3s-1173.8s] 所以他來自主持的一集
|
||||
|
||||
=== 说话人7 ===
|
||||
|
||||
[1173.8s-1175.8s] 但是如果到了我主持假裝
|
||||
|
||||
=== 说话人5 ===
|
||||
|
||||
[1175.8s-1177.3s] 全正的主持主持主持主持
|
||||
[1177.3s-1178.8s] 來現在的嘛
|
||||
[1178.8s-1180.2s] 我們來現在的這個主持
|
||||
|
||||
=== 说话人2 ===
|
||||
|
||||
[1180.8s-1182.7s] 來扮熱我主持在主持上
|
||||
|
||||
=== 说话人7 ===
|
||||
|
||||
[1182.7s-1184.0s] 主持主持上
|
||||
|
||||
=== 说话人5 ===
|
||||
|
||||
[1184.0s-1184.7s] 我們來的一集
|
||||
|
||||
=== 说话人1 ===
|
||||
|
||||
[1184.7s-1185.5s] 廖都在耍耍被豪華 hideway
|
||||
|
||||
=== 说话人7 ===
|
||||
|
||||
[1185.5s-1190.8s] 不要再拿頭、保護護金高一點
|
||||
|
||||
=== 说话人2 ===
|
||||
|
||||
[1190.8s-1191.4s] 都不能家庭
|
||||
|
||||
=== 说话人7 ===
|
||||
|
||||
[1191.4s-1193.0s] 把這個賣一次
|
||||
[1193.0s-1194.6s] 給我一去回去
|
||||
|
||||
=== 说话人5 ===
|
||||
|
||||
[1194.6s-1209.6s] 我這是自各主持主持主持主持主持主持
|
||||
|
||||
=== 说话人1 ===
|
||||
|
||||
[1211.3s-1213.3s] 大家如果像
|
||||
[1399.8s-1402.5s] 我感觉是找到大甲之间的事,
|
||||
|
||||
=== 说话人2 ===
|
||||
|
||||
[1403.6s-1405.8s] 因为我被人拌上自己的事,
|
||||
|
||||
=== 说话人5 ===
|
||||
|
||||
[1406.2s-1409.5s] 但是我做了一会的事,
|
||||
|
||||
=== 说话人8 ===
|
||||
|
||||
[1409.5s-1410.2s] 我做了一会的事,
|
||||
|
||||
=== 说话人4 ===
|
||||
|
||||
[1410.2s-1411.2s] 我做了一会的事,
|
||||
|
||||
=== 说话人5 ===
|
||||
|
||||
[1411.3s-1413.0s] 我做了一会的事,
|
||||
|
||||
=== 说话人1 ===
|
||||
|
||||
[1413.3s-1415.1s] 然后我一直在说,
|
||||
|
||||
=== 说话人5 ===
|
||||
|
||||
[1415.2s-1417.0s] 我一直在说,
|
||||
|
||||
=== 说话人8 ===
|
||||
|
||||
[1418.7s-1419.4s] 我一直在说,
|
||||
[1569.6s-1571.6s] 然后去讲没有办法,
|
||||
|
||||
=== 说话人4 ===
|
||||
|
||||
[1571.9s-1572.2s] 这一天,
|
||||
|
||||
=== 说话人3 ===
|
||||
|
||||
[1572.2s-1573.8s] 我一直在说,
|
||||
[1574.5s-1575.2s] 这个天,
|
||||
[1575.3s-1577.7s] 三公里的天然是一三个天然,
|
||||
[1578.1s-1579.0s] 所以这一天,
|
||||
|
||||
=== 说话人8 ===
|
||||
|
||||
[1579.3s-1580.9s] 我把这马上进去的时候,
|
||||
|
||||
=== 说话人3 ===
|
||||
|
||||
[1581.6s-1583.3s] 这个能不能进去,
|
||||
|
||||
=== 说话人5 ===
|
||||
|
||||
[1583.8s-1585.5s] 我们有多少人那种事,
|
||||
|
||||
=== 说话人8 ===
|
||||
|
||||
[1585.6s-1586.3s] 太少我,
|
||||
|
||||
=== 说话人6 ===
|
||||
|
||||
[1586.3s-1587.5s] 也有多少人,
|
||||
[1589.3s-1591.2s] 也有多少人,
|
||||
|
||||
=== 说话人5 ===
|
||||
|
||||
[1593.4s-1594.3s] 也有多少人,
|
||||
[1594.3s-1595.6s] 也有多少人,
|
||||
|
||||
=== 说话人8 ===
|
||||
|
||||
[1596.3s-1598.4s] 也有多少人,
|
||||
|
||||
=== 说话人5 ===
|
||||
|
||||
[1598.7s-1599.4s] 也有多少人,
|
||||
|
||||
=== 说话人6 ===
|
||||
|
||||
[1711.1s-1718.4s] 也有多少人。
|
||||
|
||||
=== 说话人5 ===
|
||||
|
||||
[2469.8s-2471.9s] 他有个笔伴的笔伴
|
||||
[2471.9s-2473.8s] 去务力就走
|
||||
[2473.8s-2474.8s] 关了笔伴的笔伴
|
||||
[2475.4s-2477.5s] 然后就加入有超级
|
||||
[2477.5s-2478.7s] 有很多笔伴的样
|
||||
|
||||
=== 说话人2 ===
|
||||
|
||||
[2478.7s-2479.4s] 三个笔伴
|
||||
[2479.4s-2481.9s] 这在我们笔伴的
|
||||
|
||||
=== 说话人5 ===
|
||||
|
||||
[2481.9s-2483.7s] 可能是这就是百分之八
|
||||
|
||||
=== 说话人8 ===
|
||||
|
||||
[2483.7s-2484.4s] 之多样
|
||||
|
||||
=== 说话人2 ===
|
||||
|
||||
[2484.9s-2486.9s] 他有个笔伴的笔伴
|
||||
|
||||
=== 说话人5 ===
|
||||
|
||||
[2486.9s-2487.9s] 三个笔伴的笔伴
|
||||
|
||||
=== 说话人4 ===
|
||||
|
||||
[2487.9s-2489.2s] 这个笔伴的笔伴
|
||||
|
||||
=== 说话人5 ===
|
||||
|
||||
[2489.2s-2490.1s] 就是要务力
|
||||
[2490.1s-2493.1s] 这个笔伴的笔伴
|
||||
|
||||
=== 说话人4 ===
|
||||
|
||||
[2493.3s-2493.7s] 也就是要务力
|
||||
|
||||
=== 说话人5 ===
|
||||
|
||||
[2493.7s-2495.3s] 也就是要务力
|
||||
[2496.3s-2498.5s] 他在那笔伴
|
||||
[2498.5s-2501.2s] 这笔伴的笔伴的笔伴
|
||||
|
||||
=== 说话人1 ===
|
||||
|
||||
[2501.2s-2502.4s] 就是要务力
|
||||
|
||||
=== 说话人4 ===
|
||||
|
||||
[2502.4s-2503.2s] 那笔伴的笔伴的笔伴
|
||||
|
||||
=== 说话人1 ===
|
||||
|
||||
[2503.2s-2503.9s] 那笔伴的笔伴
|
||||
[2503.9s-2504.8s] 他都感兮
|
||||
|
||||
=== 说话人5 ===
|
||||
|
||||
[2504.8s-2505.9s] 所以他的笔伴的笔伴
|
||||
|
||||
=== 说话人8 ===
|
||||
|
||||
[2505.9s-2507.4s] 就说就像不好的笔伴
|
||||
|
||||
=== 说话人5 ===
|
||||
|
||||
[2507.4s-2508.4s] 很热闹的笔伴
|
||||
|
||||
=== 说话人6 ===
|
||||
|
||||
[2508.4s-2509.1s] 他能不能被笔伴
|
||||
|
||||
=== 说话人4 ===
|
||||
|
||||
[2509.1s-2510.1s] 他能被笔伴
|
||||
|
||||
=== 说话人6 ===
|
||||
|
||||
[2510.1s-2510.9s] 笔伴
|
||||
|
||||
=== 说话人5 ===
|
||||
|
||||
[2511.6s-2512.9s] 但是我们这笔伴
|
||||
|
||||
=== 说话人4 ===
|
||||
|
||||
[2512.9s-2514.2s] 有笔伴的笔伴
|
||||
|
||||
=== 说话人5 ===
|
||||
|
||||
[2514.2s-2515.9s] 前几个笔伴
|
||||
|
||||
=== 说话人2 ===
|
||||
|
||||
[2515.9s-2516.8s] 有没有看见的
|
||||
|
||||
=== 说话人4 ===
|
||||
|
||||
[2516.8s-2518.9s] 这笔伴的笔伴
|
||||
[2518.9s-2519.5s] 那个笔伴
|
||||
|
||||
=== 说话人1 ===
|
||||
|
||||
[2519.5s-2520.2s] 没有看见
|
||||
[2520.2s-2521.1s] 没有看见
|
||||
[2522.8s-2523.7s] 他会到了
|
||||
[2523.7s-2524.2s] 把笔伴
|
||||
|
||||
=== 说话人6 ===
|
||||
|
||||
[2528.5s-2530.4s] 接下音过找
|
||||
|
||||
=== 说话人8 ===
|
||||
|
||||
[2531.3s-2534.1s] 他一aps
|
||||
|
||||
=== 说话人6 ===
|
||||
|
||||
[2534.1s-2535.3s] 差不多
|
||||
[2535.3s-2537.6s] 他上学
|
||||
|
||||
=== 说话人5 ===
|
||||
|
||||
[2537.9s-2540.0s] 我们整体加上
|
||||
|
||||
=== 说话人4 ===
|
||||
|
||||
[2540.0s-2540.8s] 那个笔伴
|
||||
|
||||
=== 说话人2 ===
|
||||
|
||||
[2540.8s-2542.5s] 你看看
|
||||
|
||||
=== 说话人5 ===
|
||||
|
||||
[2542.5s-2557.8s] 他比较感情
|
||||
[4267.8s-4269.6s] 恐慌
|
||||
|
||||
=== 说话人7 ===
|
||||
|
||||
[4277.5s-4285.7s] 那国内主要我们给こちら
|
||||
[4285.7s-4286.8s] 就是宪治 Show
|
||||
|
||||
=== 说话人3 ===
|
||||
|
||||
[4286.8s-4290.6s] 这是就是宪治 Audio
|
||||
|
||||
=== 说话人1 ===
|
||||
|
||||
[4290.6s-4292.5s] 他觉得他在来兜了
|
||||
|
||||
=== 说话人7 ===
|
||||
|
||||
[4292.5s-4296.2s] 不是这个话的志明
|
||||
[4296.8s-4299.3s] 我来看你这种词的看法
|
||||
|
||||
=== 说话人4 ===
|
||||
|
||||
[4299.3s-4300.1s] 这个是一个人的
|
||||
|
||||
=== 说话人7 ===
|
||||
|
||||
[4301.4s-4302.8s] 所以我说一下
|
||||
[4302.8s-4304.3s] 我就是诸个看法的问题
|
||||
|
||||
=== 说话人4 ===
|
||||
|
||||
[4304.3s-4306.2s] 这体的问题是一个诸个
|
||||
|
||||
=== 说话人5 ===
|
||||
|
||||
[4306.7s-4308.5s] 我听过我这口误的语语
|
||||
[4308.5s-4310.5s] 这种不害怕怪怪
|
||||
[4310.5s-4313.0s] 我诸了太太的语语语语
|
||||
|
||||
=== 说话人7 ===
|
||||
|
||||
[4313.0s-4314.7s] 我当时诸个不道理的
|
||||
|
||||
=== 说话人5 ===
|
||||
|
||||
[4315.8s-4318.7s] 这种不道理的语语语语语语语语语语
|
||||
[4318.7s-4320.2s] 假如还有语语语语
|
||||
[4320.2s-4321.7s] 这个语语语语语语语
|
||||
[4321.7s-4326.1s] 这种不道理语语语语
|
||||
|
||||
=== 说话人2 ===
|
||||
|
||||
[4326.1s-4328.0s] 假如那进到了
|
||||
|
||||
=== 说话人7 ===
|
||||
|
||||
[4328.0s-4330.4s] 代表人做什么
|
||||
|
||||
=== 说话人5 ===
|
||||
|
||||
[4330.4s-4331.7s] 菠 çıkt
|
||||
|
||||
=== 说话人2 ===
|
||||
|
||||
[4331.7s-4349.8s] 摇灶的独援
|
||||
88
Outputs/项目能力综述_数据维度与技术体系.md
Normal file
88
Outputs/项目能力综述_数据维度与技术体系.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# 儿童呼吸道感染空间流行病学的方法、数据与可视化:文献综述
|
||||
|
||||
---
|
||||
|
||||
儿童呼吸道感染是全球5岁以下儿童死亡的第二大原因。在中国城市地区,其年发病数长期居于儿科门诊病种的首位。Wu等(2025)在*Communications Earth & Environment*报告了一项横跨江苏省多城市的学校环境研究,纳入265,076例儿童肺炎与气管炎病例,采用时空分层设计估计了PM₁各化学组分与因病缺课率的剂量-反应关系。结果显示,SO₄²⁻的风险比最高(RR=1.119),且低温对每一种污染物组分的健康效应均表现出显著的放大作用,城乡儿童在温度-污染复合暴露下的敏感性方向相反。同年发表于*PLOS Medicine*的全国尺度研究将501个城市、跨越2008至2019年的面板数据嵌入时间分层病例交叉模型,估计野火来源PM₂.₅每升高5μg/m³,儿童呼吸道传染病发病率上升6.8%。值得留意的一个数字是:野火PM₂.₅在总PM₂.₅中仅占2.7%,却贡献了10.8%的相关病例。
|
||||
|
||||
尽管如此,该领域在数据配置与方法部署两个维度上均远未饱和。
|
||||
|
||||
环境与地理空间变量在空间流行病学中的缺席,是反复被指出却极少被回应的问题。2025年发表在*Geospatial Health*的一篇系统综述遵循PRISMA 2020指南,从35项纳入研究中统计出一个醒目的比例:仅2.9%的研究认真纳入了环境、地理或物理空间因子。儿童呼吸系统疾病恰恰处于这一方法论盲区的核心位置——其发病不但与每日吸入的污染物组分直接相关,还与绿地覆盖对微气候的调节效应、建成环境对通风条件的改变、地形对污染扩散路径的约束等多个环境过程的交互作用相关联。另一维度上,近三年间GCN-Transformer混合架构(Zheng等, 2025)、流行病学知识嵌入的图神经网络(EISTGNN, Han等, 2025)以及双通道时空图卷积网络(Luo等, 2025)在COVID-19与流感预测任务中取得了国家级R²达0.966、ILI相关度达0.82的性能表现,但上述方法尚未被迁移至儿童呼吸道感染这一疾病场景。对此,文献中呈现的是一种近乎沉默的状态——既非技术障碍所致,也非数据可及性不足所能解释,更像流行病学建模社区与深度学习社区在研究议程上的长期分离。另一个更具工程属性的问题是,绝大多数空间流行病学研究的产出形式仍然是一篇论文附以离线分析脚本,缺乏一套能够持续接收新数据、自动更新风险估计并实时输出可视化与预警信息的运行中的系统。
|
||||
|
||||
本文旨在回答两个简单的问题:当前空间流行病学领域可用的统计工具有哪些,每种方法产出何种类型的图;目前可获取的数据字段涵盖哪些维度。综述范围不限于已有研究者整合过的变量,也包括分散在各数据源中、技术上完全可调用但尚未被人放到同一模型里的字段。目的是为后续研究提供一个方法—图表—数据的完整参照系。
|
||||
|
||||
---
|
||||
|
||||
## 空间统计方法与图表产出
|
||||
|
||||
空间自相关检验是大多数空间流行病学分析的起点。Global Moran's I的运算和使用并不复杂,但理解其局限比掌握其运算本身更为重要:它回答"全局上是否存在空间聚集",但不对"聚集的确切位置"提供任何信息。在不同疾病场景中,该统计量的量级存在显著差异:马拉维疟疾研究的Moran's I为0.044至0.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
backend/__init__.py
Normal file
1
backend/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Backend package
|
||||
42
backend/auth/CLAUDE.md
Normal file
42
backend/auth/CLAUDE.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Auth — JWT Authentication
|
||||
|
||||
## Stack
|
||||
|
||||
python-jose (JWT signing/verification) + passlib (bcrypt password hashing). Token-based, stateless.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
auth/
|
||||
models.py # Pydantic models: UserCreate, UserLogin, Token, UserOut
|
||||
service.py # Business logic: authenticate_user, create_user, create_access_token
|
||||
dependencies.py # FastAPI Depends: get_current_user, require_admin
|
||||
middleware.py # ASGI middleware (if any global auth checks)
|
||||
router.py # APIRouter: /login, /register, /whoami
|
||||
```
|
||||
|
||||
## Patterns
|
||||
|
||||
- Passwords hashed with bcrypt via `passlib` — never store plaintext
|
||||
- JWT tokens signed with `python-jose`, include `sub` (username) and `exp`
|
||||
- `get_current_user()` is the standard `Depends()` to inject user into endpoints
|
||||
- Auth endpoints return Pydantic models: `Token(access_token=...)`, `UserOut(username=...)`
|
||||
- HTTP status codes: 401 for bad credentials, 409 for duplicate user
|
||||
|
||||
## Usage in Routers
|
||||
|
||||
```python
|
||||
from auth.dependencies import get_current_user
|
||||
|
||||
@router.get("/protected")
|
||||
async def protected_route(current_user = Depends(get_current_user)):
|
||||
...
|
||||
```
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
- Don't hardcode secret keys — use `Settings` from environment
|
||||
- Don't store tokens client-side without HttpOnly cookies
|
||||
- Don't skip `response_model` on auth endpoints
|
||||
- Don't leak whether username or password was wrong — always "incorrect username or password"
|
||||
- Don't bypass `Depends(get_current_user)` for protected routes
|
||||
@@ -8,7 +8,13 @@ from passlib.context import CryptContext
|
||||
|
||||
logger = logging.getLogger("cbpoa.auth")
|
||||
|
||||
SECRET_KEY = os.getenv("AUTH_SECRET_KEY", "cbpoa-dev-secret-change-in-production")
|
||||
_DEFAULT_SECRET = "cbpoa-dev-secret-change-in-production"
|
||||
SECRET_KEY = os.getenv("AUTH_SECRET_KEY", _DEFAULT_SECRET)
|
||||
if SECRET_KEY == _DEFAULT_SECRET:
|
||||
logger.warning(
|
||||
"AUTH_SECRET_KEY is not set — using the built-in development secret. "
|
||||
"Set AUTH_SECRET_KEY in the environment before deploying; the default is public and allows token forgery."
|
||||
)
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("AUTH_TOKEN_EXPIRE_MINUTES", "480"))
|
||||
|
||||
@@ -61,6 +67,10 @@ def create_access_token(data: dict) -> str:
|
||||
def decode_access_token(token: str) -> dict | None:
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
# python-jose ignores PyJWT's options={"require": [...]}, so enforce exp manually:
|
||||
# a token with no exp claim would otherwise never expire.
|
||||
if "exp" not in payload:
|
||||
return None
|
||||
return payload
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
@@ -3,22 +3,34 @@ Shared data-loading module for case data (outpatient + inpatient).
|
||||
|
||||
Extracted from routers/cases.py so both cases and reports routers can use
|
||||
the same cached data without circular imports.
|
||||
|
||||
Performance: reads from pre-generated Parquet files (~0.1s) instead of
|
||||
Excel (~10s). Falls back to Excel if parquet files are missing.
|
||||
"""
|
||||
|
||||
import re
|
||||
import logging
|
||||
import threading
|
||||
from typing import Optional, cast
|
||||
import pandas as pd
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger("cbpoa.case_loader")
|
||||
|
||||
DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
||||
|
||||
# Data cache
|
||||
_cache = {
|
||||
_cache: dict[str, Optional[pd.DataFrame | datetime]] = {
|
||||
"outpatient": None,
|
||||
"inpatient": None,
|
||||
"combined": None,
|
||||
"loaded_at": None,
|
||||
}
|
||||
|
||||
# Guards the lazy build so concurrent callers don't duplicate the load/concat.
|
||||
_load_lock = threading.RLock()
|
||||
|
||||
# Wuhan district mapping
|
||||
WUHAN_DISTRICTS = {
|
||||
'江岸区': ['江岸'],
|
||||
@@ -40,6 +52,7 @@ WUHAN_DISTRICTS = {
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
||||
DATA_DIR = PROJECT_ROOT / "Datas"
|
||||
PROCESSED_DIR = PROJECT_ROOT / "processed"
|
||||
|
||||
|
||||
def _extract_district(addr: str) -> str:
|
||||
@@ -54,52 +67,106 @@ def _extract_district(addr: str) -> str:
|
||||
return '其他'
|
||||
|
||||
|
||||
def _load_from_parquet() -> bool:
|
||||
"""Try to load data from pre-generated Parquet files. Returns True on success."""
|
||||
outpatient_path = PROCESSED_DIR / "cases_outpatient.parquet"
|
||||
inpatient_path = PROCESSED_DIR / "cases_inpatient.parquet"
|
||||
|
||||
if not outpatient_path.exists() or not inpatient_path.exists():
|
||||
logger.info("Parquet files not found, falling back to Excel")
|
||||
return False
|
||||
|
||||
try:
|
||||
_cache["outpatient"] = pd.read_parquet(outpatient_path)
|
||||
_cache["inpatient"] = pd.read_parquet(inpatient_path)
|
||||
_cache["loaded_at"] = datetime.now()
|
||||
logger.info("Loaded case data from Parquet (%d outpatient, %d inpatient)",
|
||||
len(_cache["outpatient"]), len(_cache["inpatient"]))
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning("Parquet load failed (%s), falling back to Excel", e)
|
||||
return False
|
||||
|
||||
|
||||
def _load_from_excel():
|
||||
"""Load data from Excel files (slow fallback)."""
|
||||
df_out = pd.read_excel(DATA_DIR / "view_门诊.xlsx")
|
||||
df_out['date'] = pd.to_datetime(df_out['门诊日期_re'])
|
||||
df_out['district'] = df_out['现住址区'].fillna('未知')
|
||||
_cache["outpatient"] = df_out
|
||||
|
||||
df_in = pd.read_excel(DATA_DIR / "view_住院.xlsx")
|
||||
df_in['date'] = pd.to_datetime(df_in['入院日期_re'])
|
||||
df_in['district'] = df_in['现住址_脱敏'].apply(_extract_district)
|
||||
_cache["inpatient"] = df_in
|
||||
|
||||
_cache["loaded_at"] = datetime.now()
|
||||
|
||||
|
||||
def load_data():
|
||||
"""Load and cache outpatient + inpatient data from Excel files"""
|
||||
"""Load and cache outpatient + inpatient data.
|
||||
|
||||
Uses pre-generated Parquet files for fast loading (~0.1s).
|
||||
Falls back to Excel files (~10s) if Parquet is unavailable.
|
||||
"""
|
||||
if _cache["loaded_at"] is not None:
|
||||
return
|
||||
|
||||
try:
|
||||
# Load outpatient data
|
||||
df_out = pd.read_excel(DATA_DIR / "view_门诊.xlsx")
|
||||
df_out['date'] = pd.to_datetime(df_out['门诊日期_re'])
|
||||
df_out['district'] = df_out['现住址区'].fillna('未知')
|
||||
_cache["outpatient"] = df_out
|
||||
|
||||
# Load inpatient data
|
||||
df_in = pd.read_excel(DATA_DIR / "view_住院.xlsx")
|
||||
df_in['date'] = pd.to_datetime(df_in['入院日期_re'])
|
||||
df_in['district'] = df_in['现住址_脱敏'].apply(_extract_district)
|
||||
_cache["inpatient"] = df_in
|
||||
|
||||
_cache["loaded_at"] = datetime.now()
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Data loading failed: {str(e)}")
|
||||
with _load_lock:
|
||||
if _cache["loaded_at"] is not None: # another thread loaded while we waited
|
||||
return
|
||||
if not _load_from_parquet():
|
||||
try:
|
||||
_load_from_excel()
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Data loading failed: {str(e)}")
|
||||
|
||||
|
||||
def get_combined_data():
|
||||
"""Return merged outpatient + inpatient data with unified diagnosis column"""
|
||||
def get_combined_data() -> pd.DataFrame:
|
||||
"""Return merged outpatient + inpatient data with unified diagnosis column.
|
||||
|
||||
Caches the result in memory after first call (~0.1s on cached hit).
|
||||
"""
|
||||
if _cache["combined"] is not None:
|
||||
return cast(pd.DataFrame, _cache["combined"])
|
||||
|
||||
with _load_lock:
|
||||
if _cache["combined"] is not None: # built while we waited for the lock
|
||||
return cast(pd.DataFrame, _cache["combined"])
|
||||
|
||||
load_data()
|
||||
|
||||
df_out = cast(pd.DataFrame, _cache["outpatient"])
|
||||
df_in = cast(pd.DataFrame, _cache["inpatient"])
|
||||
|
||||
df_out = df_out[['date', 'district', '初诊', '主诉']].copy()
|
||||
df_out['type'] = 'outpatient'
|
||||
df_out['diagnosis'] = df_out['初诊']
|
||||
|
||||
df_in = df_in[['date', 'district', '诊断名称']].copy()
|
||||
df_in['type'] = 'inpatient'
|
||||
df_in['diagnosis'] = df_in['诊断名称']
|
||||
df_in['主诉'] = None
|
||||
|
||||
_cache["combined"] = pd.concat([df_out, df_in], ignore_index=True) # type: ignore[assignment]
|
||||
return cast(pd.DataFrame, _cache["combined"])
|
||||
|
||||
|
||||
def get_diagnoses() -> list[str]:
|
||||
"""Return sorted list of unique diagnosis names (fast: reads from cached DataFrames)."""
|
||||
load_data()
|
||||
|
||||
df_out = _cache["outpatient"][['date', 'district', '初诊', '主诉']].copy()
|
||||
df_out['type'] = 'outpatient'
|
||||
df_out['diagnosis'] = df_out['初诊']
|
||||
|
||||
df_in = _cache["inpatient"][['date', 'district', '诊断名称']].copy()
|
||||
df_in['type'] = 'inpatient'
|
||||
df_in['diagnosis'] = df_in['诊断名称']
|
||||
df_in['主诉'] = None
|
||||
|
||||
return pd.concat([df_out, df_in], ignore_index=True)
|
||||
out_diag = cast(pd.DataFrame, _cache["outpatient"])['初诊'].dropna().unique()
|
||||
in_diag = cast(pd.DataFrame, _cache["inpatient"])['诊断名称'].dropna().unique()
|
||||
return sorted(set(out_diag.tolist() + in_diag.tolist()))
|
||||
|
||||
|
||||
def get_outpatient_data():
|
||||
def get_outpatient_data() -> pd.DataFrame:
|
||||
"""Return the cached outpatient dataframe"""
|
||||
load_data()
|
||||
return _cache["outpatient"]
|
||||
return _cache["outpatient"] # type: ignore[return-value]
|
||||
|
||||
|
||||
def get_inpatient_data():
|
||||
def get_inpatient_data() -> pd.DataFrame:
|
||||
"""Return the cached inpatient dataframe"""
|
||||
load_data()
|
||||
return _cache["inpatient"]
|
||||
return _cache["inpatient"] # type: ignore[return-value]
|
||||
|
||||
@@ -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
|
||||
from routers import risk, alerts, analysis, insights, reports, cases, geocoded, grid, chat, environment
|
||||
|
||||
|
||||
setup_logging()
|
||||
@@ -27,7 +27,7 @@ app = FastAPI(
|
||||
)
|
||||
|
||||
app.add_middleware(RequestLoggerMiddleware)
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1000, compresslevel=1)
|
||||
|
||||
logger = logging.getLogger("cbpoa.main")
|
||||
|
||||
@@ -58,6 +58,7 @@ 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.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, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from typing import Optional, List, Literal
|
||||
from datetime import datetime
|
||||
|
||||
@@ -280,6 +280,9 @@ class MultiDayPredictionRequest(BaseModel):
|
||||
|
||||
class MultiDayPredictionResponse(BaseModel):
|
||||
"""Response for multi-day grid predictions"""
|
||||
# `model_version` collides with Pydantic's protected `model_` namespace; opt out.
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
predictions: List[GridPrediction] = Field(..., description="Grid predictions")
|
||||
total_grids: int = Field(..., description="Total grids predicted")
|
||||
date_range: tuple[str, str] = Field(..., description="Prediction date range")
|
||||
|
||||
@@ -16,3 +16,4 @@ pandas>=2.0.0
|
||||
numpy>=1.24.0
|
||||
pyarrow>=14.0.0
|
||||
openpyxl>=3.1.0
|
||||
Pillow>=10.0.0
|
||||
|
||||
47
backend/routers/CLAUDE.md
Normal file
47
backend/routers/CLAUDE.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# Routers — API Endpoints
|
||||
|
||||
## Pattern
|
||||
|
||||
Each router file defines one `APIRouter(prefix=..., tags=[...])` with typed endpoints.
|
||||
|
||||
```python
|
||||
from fastapi import APIRouter
|
||||
from models import SomeResponse
|
||||
|
||||
router = APIRouter(prefix="/api/domain", tags=["domain"])
|
||||
|
||||
@router.get("/endpoint", response_model=SomeResponse)
|
||||
async def get_something(...):
|
||||
...
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
- Return Pydantic models (`response_model=`), never raw dicts
|
||||
- Use `Annotated[Type, Query(...)]` / `Path(...)` for request params
|
||||
- Spatial queries use `scipy.spatial.KDTree` for nearest-neighbor lookups
|
||||
- GeoJSON parsing is delegated to `utils/geojson.py`
|
||||
- Risk level mapping is in `utils/risk.py` — use `risk_value_to_level()` not inline thresholds
|
||||
- Large repeated queries use `@lru_cache` (from `functools`)
|
||||
- Date helpers from `utils/date_helpers.py` — always `get_latest_date()`, never guess
|
||||
|
||||
## File Map
|
||||
|
||||
| File | Domain |
|
||||
|------|--------|
|
||||
| `risk.py` | Risk maps, grid detail, history (largest router, ~42 file reads) |
|
||||
| `alerts.py` | Alert feed, stats |
|
||||
| `cases.py` | Medical case queries (age, disease, district filters) |
|
||||
| `analysis.py` | Trend analysis, statistics |
|
||||
| `grid.py` | Grid metadata, elevation, population |
|
||||
| `reports.py` | Report generation, export |
|
||||
| `insights.py` | AI-generated insights |
|
||||
| `chat.py` | Chatbot endpoint |
|
||||
| `geocoded.py` | Geocoded case data |
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
- Don't use sync I/O in `async def` — use `async with db.get_connection()` for DB
|
||||
- Don't catch bare `Exception` — use specific HTTPException or let it propagate to middleware
|
||||
- Don't return raw GeoJSON dicts without Pydantic validation
|
||||
- Don't inline risk thresholds — use `utils/risk.risk_value_to_level()`
|
||||
@@ -5,6 +5,8 @@ Generates alerts from high-risk grids in GeoJSON files
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
from functools import lru_cache
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from config import DATA_DIR, ALERT_P1_RISK, ALERT_P2_RISK, WUHAN_BOUNDS, LAT_STEP, LON_STEP, MAX_ALERTS
|
||||
@@ -32,8 +34,13 @@ def grid_id_to_center(grid_id: str) -> tuple[float, float]:
|
||||
return lat, lon
|
||||
|
||||
|
||||
def generate_alerts_for_date(date: str) -> List[Alert]:
|
||||
"""Generate alerts for high-risk grids on a specific date.
|
||||
@lru_cache(maxsize=8)
|
||||
def _generate_alerts_cached(date: str) -> List[Alert]:
|
||||
"""Heavy synchronous worker: parse the ~45MB GeoJSON and build alerts.
|
||||
|
||||
Cached by date so the file is parsed once per date. This runs blocking
|
||||
json.load + per-feature loops, so callers must invoke it off the event
|
||||
loop (see generate_alerts_for_date).
|
||||
|
||||
Phase 1: iterate features, aggregate max risk per 100m grid cell.
|
||||
Phase 2: build Alert objects from aggregated grid cells.
|
||||
@@ -111,6 +118,16 @@ def generate_alerts_for_date(date: str) -> List[Alert]:
|
||||
return alerts[:MAX_ALERTS]
|
||||
|
||||
|
||||
async def generate_alerts_for_date(date: str) -> List[Alert]:
|
||||
"""Async accessor: run the cached heavy parser in a thread pool.
|
||||
|
||||
Offloading the blocking json.load + per-feature aggregation keeps the
|
||||
event loop free. The lru_cache lives on the worker, so warm dates return
|
||||
near-instantly without re-parsing.
|
||||
"""
|
||||
return await asyncio.to_thread(_generate_alerts_cached, date)
|
||||
|
||||
|
||||
@router.get("", response_model=AlertResponse)
|
||||
async def list_alerts(date: str | None = None, priority: str | None = None, min_risk: float | None = None):
|
||||
if date is not None and not validate_date_format(date):
|
||||
@@ -118,7 +135,7 @@ async def list_alerts(date: str | None = None, priority: str | None = None, min_
|
||||
if date is None:
|
||||
date = get_latest_date()
|
||||
|
||||
alerts = generate_alerts_for_date(date)
|
||||
alerts = await generate_alerts_for_date(date)
|
||||
|
||||
if priority:
|
||||
alerts = [a for a in alerts if a.priority == priority]
|
||||
@@ -140,7 +157,7 @@ async def get_alert(alert_id: str, date: str | None = None):
|
||||
if date is None:
|
||||
date = get_latest_date()
|
||||
|
||||
alerts = generate_alerts_for_date(date)
|
||||
alerts = await generate_alerts_for_date(date)
|
||||
|
||||
for alert in alerts:
|
||||
if alert.alert_id == alert_id:
|
||||
@@ -156,7 +173,7 @@ async def get_p1_alerts(date: str | None = None):
|
||||
if date is None:
|
||||
date = get_latest_date()
|
||||
|
||||
alerts = generate_alerts_for_date(date)
|
||||
alerts = await generate_alerts_for_date(date)
|
||||
p1_alerts = [a for a in alerts if a.priority == "P1"]
|
||||
|
||||
return AlertResponse(
|
||||
@@ -173,7 +190,7 @@ async def get_p2_alerts(date: str | None = None):
|
||||
if date is None:
|
||||
date = get_latest_date()
|
||||
|
||||
alerts = generate_alerts_for_date(date)
|
||||
alerts = await generate_alerts_for_date(date)
|
||||
p2_alerts = [a for a in alerts if a.priority == "P2"]
|
||||
|
||||
return AlertResponse(
|
||||
@@ -190,7 +207,7 @@ async def get_grid_alerts(grid_id: str, date: str | None = None):
|
||||
if date is None:
|
||||
date = get_latest_date()
|
||||
|
||||
alerts = generate_alerts_for_date(date)
|
||||
alerts = await generate_alerts_for_date(date)
|
||||
grid_alerts = [a for a in alerts if a.grid_id == grid_id]
|
||||
|
||||
return AlertResponse(
|
||||
|
||||
@@ -5,11 +5,13 @@ Time series trends, district aggregation, and weather-health correlations
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Literal
|
||||
from functools import lru_cache
|
||||
import random
|
||||
|
||||
import pandas as pd
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from config import DATA_DIR, RISK_HIGH
|
||||
from config import DATA_DIR, RISK_HIGH, PROJECT_ROOT, WUHAN_BOUNDS, LAT_STEP, LON_STEP
|
||||
from utils.date_helpers import get_latest_date
|
||||
from utils.geojson import parse_geojson_file, load_districts
|
||||
from utils.geo import point_in_polygon
|
||||
@@ -27,11 +29,13 @@ class TrendResponse(BaseModel):
|
||||
|
||||
class DistrictRisk(BaseModel):
|
||||
"""District-level risk aggregation"""
|
||||
name: str = Field(..., description="District name")
|
||||
district: str = Field(..., description="District name")
|
||||
avg_risk: float = Field(..., description="Average risk value")
|
||||
avg_aqi: float = Field(..., description="Average AQI from weather stations in this district")
|
||||
population: int = Field(..., description="Population (sum of 100m LandScan cells in district)")
|
||||
high_risk_count: int = Field(..., description="Count of high risk grids")
|
||||
total_grids: int = Field(..., description="Total grids in district")
|
||||
total_cases: int = Field(..., description="Estimated total cases")
|
||||
total_cases: int = Field(..., description="Total recorded cases (real, from cases_by_district_daily)")
|
||||
|
||||
|
||||
class DistrictsResponse(BaseModel):
|
||||
@@ -91,12 +95,9 @@ async def get_trend(days: int = Query(default=7, ge=1, le=30)):
|
||||
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)
|
||||
|
||||
# Preserve the full requested date range: a "7天" request must return 7
|
||||
# contiguous points. Days with no geojson (or empty grids) stay 0 rather
|
||||
# than being dropped, which previously produced fewer, non-contiguous points.
|
||||
trend_direction = calculate_trend(values)
|
||||
|
||||
return TrendResponse(
|
||||
@@ -106,101 +107,153 @@ async def get_trend(days: int = Query(default=7, ge=1, le=30)):
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _grid_district_lookup() -> dict:
|
||||
"""Map precomputed r{row}_c{col} grid id -> district name (loaded once)."""
|
||||
path = PROJECT_ROOT / "processed" / "grid_district_mapping.parquet"
|
||||
if not path.exists():
|
||||
return {}
|
||||
df = pd.read_parquet(path)
|
||||
# Some grids have a null district_name; drop them so the lookup only ever
|
||||
# returns valid strings (missing keys fall back to "其他").
|
||||
df = df.dropna(subset=["district_name"])
|
||||
return dict(zip(df["grid_id"].astype(str), df["district_name"].astype(str)))
|
||||
|
||||
|
||||
@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 in the case file are inconsistent ("武昌" vs "武昌区"),
|
||||
so names are normalized by stripping the "区" suffix and summed, then
|
||||
keyed by the canonical mapping name (with "区"). Returns {district: cases}.
|
||||
"""
|
||||
path = PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet"
|
||||
if not path.exists():
|
||||
return {}
|
||||
df = pd.read_parquet(path, columns=["district", "total_cases"])
|
||||
df = df.copy()
|
||||
df["base"] = df["district"].str.replace("区", "", regex=False)
|
||||
by_base = df.groupby("base")["total_cases"].sum()
|
||||
return {f"{base}区": int(v) for base, v in by_base.items()}
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def _aggregate_districts(date: str) -> list:
|
||||
"""Aggregate per-district risk for a date.
|
||||
|
||||
Assigns each 100m risk grid to a district via the precomputed
|
||||
grid->district mapping (O(1) dict lookup per grid) instead of per-grid
|
||||
point-in-polygon (which is ~100x slower over 140k grids). Cached by date.
|
||||
"""
|
||||
grids = parse_geojson_file(DATA_DIR / f"risk_{date}.geojson")
|
||||
lookup = _grid_district_lookup()
|
||||
|
||||
agg: dict = {}
|
||||
for g in grids:
|
||||
row = int((g["latitude"] - WUHAN_BOUNDS["min_lat"]) / LAT_STEP)
|
||||
col = int((g["longitude"] - WUHAN_BOUNDS["min_lon"]) / LON_STEP)
|
||||
name = lookup.get(f"r{row}_c{col}", "其他")
|
||||
a = agg.setdefault(name, {"sum": 0.0, "count": 0, "high": 0})
|
||||
risk = g["risk_value"]
|
||||
a["sum"] += risk
|
||||
a["count"] += 1
|
||||
if risk >= RISK_HIGH:
|
||||
a["high"] += 1
|
||||
|
||||
pop_by_district = _district_population()
|
||||
aqi_by_district = _district_avg_aqi()
|
||||
cases_by_district = _district_total_cases()
|
||||
# City-wide mean AQI as fallback for districts without a weather station.
|
||||
city_avg_aqi = round(sum(aqi_by_district.values()) / len(aqi_by_district), 1) if aqi_by_district else 0.0
|
||||
|
||||
result = []
|
||||
for name, a in agg.items():
|
||||
if a["count"] == 0:
|
||||
continue
|
||||
avg = a["sum"] / a["count"]
|
||||
result.append({
|
||||
"district": name,
|
||||
"avg_risk": round(avg, 4),
|
||||
"avg_aqi": aqi_by_district.get(name, city_avg_aqi),
|
||||
"population": pop_by_district.get(name, 0),
|
||||
"high_risk_count": a["high"],
|
||||
"total_grids": a["count"],
|
||||
"total_cases": cases_by_district.get(name, 0),
|
||||
})
|
||||
# '其他' (unassigned) last, otherwise by descending risk
|
||||
result.sort(key=lambda d: (d["district"] == "其他", -d["avg_risk"]))
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/districts", response_model=DistrictsResponse)
|
||||
async def get_districts():
|
||||
"""
|
||||
Get district-level risk aggregation
|
||||
|
||||
Returns:
|
||||
District-level risk data with averages and counts
|
||||
"""
|
||||
"""Get district-level risk aggregation (cached per date)."""
|
||||
latest_date = get_latest_date()
|
||||
filepath = DATA_DIR / f"risk_{latest_date}.geojson"
|
||||
|
||||
if not filepath.exists():
|
||||
raise HTTPException(status_code=404, detail=f"No data found for date {latest_date}")
|
||||
|
||||
grids = parse_geojson_file(filepath)
|
||||
districts = load_districts()
|
||||
|
||||
if not districts:
|
||||
# Fallback: return city-wide aggregation
|
||||
avg_risk = sum(g["risk_value"] for g in grids) / len(grids) if grids else 0
|
||||
high_risk_count = sum(1 for g in grids if g["risk_value"] >= RISK_HIGH)
|
||||
|
||||
return DistrictsResponse(
|
||||
districts=[
|
||||
DistrictRisk(
|
||||
name="武汉市",
|
||||
avg_risk=round(avg_risk, 4),
|
||||
high_risk_count=high_risk_count,
|
||||
total_grids=len(grids),
|
||||
total_cases=int(len(grids) * avg_risk * 0.1) # Mock case rate
|
||||
)
|
||||
],
|
||||
timestamp=datetime.now().isoformat()
|
||||
)
|
||||
|
||||
# Aggregate grids by district using point-in-polygon
|
||||
district_data = {d["name"]: {"grids": [], "high_risk": 0} for d in districts}
|
||||
unassigned = {"grids": [], "high_risk": 0}
|
||||
|
||||
for grid in grids:
|
||||
assigned = False
|
||||
for district in districts:
|
||||
if point_in_polygon(grid["latitude"], grid["longitude"], district["coordinates"]):
|
||||
district_data[district["name"]]["grids"].append(grid)
|
||||
if grid["risk_value"] >= RISK_HIGH:
|
||||
district_data[district["name"]]["high_risk"] += 1
|
||||
assigned = True
|
||||
break
|
||||
|
||||
if not assigned:
|
||||
unassigned["grids"].append(grid)
|
||||
if grid["risk_value"] >= RISK_HIGH:
|
||||
unassigned["high_risk"] += 1
|
||||
|
||||
# Build response
|
||||
result = []
|
||||
for district in districts:
|
||||
name = district["name"]
|
||||
grids_in_district = district_data[name]["grids"]
|
||||
|
||||
if not grids_in_district:
|
||||
continue
|
||||
|
||||
avg_risk = sum(g["risk_value"] for g in grids_in_district) / len(grids_in_district)
|
||||
high_risk_count = district_data[name]["high_risk"]
|
||||
|
||||
# Mock total cases based on risk and grid count
|
||||
total_cases = int(len(grids_in_district) * avg_risk * 0.1)
|
||||
|
||||
result.append(
|
||||
DistrictRisk(
|
||||
name=name,
|
||||
avg_risk=round(avg_risk, 4),
|
||||
high_risk_count=high_risk_count,
|
||||
total_grids=len(grids_in_district),
|
||||
total_cases=total_cases
|
||||
)
|
||||
)
|
||||
|
||||
# Add unassigned as "其他" if significant
|
||||
if unassigned["grids"]:
|
||||
avg_risk = sum(g["risk_value"] for g in unassigned["grids"]) / len(unassigned["grids"])
|
||||
result.append(
|
||||
DistrictRisk(
|
||||
name="其他",
|
||||
avg_risk=round(avg_risk, 4),
|
||||
high_risk_count=unassigned["high_risk"],
|
||||
total_grids=len(unassigned["grids"]),
|
||||
total_cases=int(len(unassigned["grids"]) * avg_risk * 0.1)
|
||||
)
|
||||
)
|
||||
|
||||
districts = [DistrictRisk(**d) for d in _aggregate_districts(latest_date)]
|
||||
return DistrictsResponse(
|
||||
districts=result,
|
||||
timestamp=datetime.now().isoformat()
|
||||
districts=districts,
|
||||
timestamp=datetime.now().isoformat(),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -8,10 +8,11 @@ from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from datetime import datetime, date
|
||||
import asyncio
|
||||
import pandas as pd
|
||||
import json
|
||||
|
||||
from data.case_loader import load_data, get_combined_data, get_outpatient_data, get_inpatient_data, WUHAN_DISTRICTS, DATE_PATTERN
|
||||
from data.case_loader import load_data, get_combined_data, get_outpatient_data, get_inpatient_data, get_diagnoses, WUHAN_DISTRICTS, DATE_PATTERN
|
||||
|
||||
router = APIRouter(prefix="/api/cases", tags=["cases"])
|
||||
|
||||
@@ -90,37 +91,38 @@ async def get_cases_stats(
|
||||
if diagnosis:
|
||||
df_out = df_out[df_out['初诊'].str.contains(diagnosis, na=False, case=False)]
|
||||
df_in = df_in[df_in['诊断名称'].str.contains(diagnosis, na=False, case=False)]
|
||||
|
||||
|
||||
# 计算统计
|
||||
total_outpatient = len(df_out)
|
||||
total_inpatient = len(df_in)
|
||||
|
||||
# 日期范围
|
||||
min_date = min(df_out['date'].min(), df_in['date'].min())
|
||||
max_date = max(df_out['date'].max(), df_in['date'].max())
|
||||
|
||||
|
||||
# 日期范围(过滤后可能为空,需防御 NaT)
|
||||
all_dates = pd.concat([df_out['date'], df_in['date']]).dropna()
|
||||
date_start = all_dates.min().strftime("%Y-%m-%d") if len(all_dates) else ""
|
||||
date_end = all_dates.max().strftime("%Y-%m-%d") if len(all_dates) else ""
|
||||
|
||||
# 区域统计
|
||||
out_districts = df_out[df_out['district'] != '未知']['district'].value_counts().head(10)
|
||||
in_districts = df_in[df_in['district'] != '其他']['district'].value_counts().head(10)
|
||||
|
||||
|
||||
combined_districts = pd.concat([out_districts, in_districts]).groupby(level=0).sum().nlargest(10)
|
||||
top_districts = [{"district": d, "count": int(c)} for d, c in combined_districts.items()]
|
||||
|
||||
|
||||
# 诊断统计
|
||||
out_diagnoses = df_out['初诊'].value_counts().head(10)
|
||||
in_diagnoses = df_in['诊断名称'].value_counts().head(10)
|
||||
|
||||
|
||||
top_diagnoses = [
|
||||
{"diagnosis": str(d), "outpatient": int(out_diagnoses.get(d, 0)), "inpatient": int(in_diagnoses.get(d, 0))}
|
||||
for d in set(list(out_diagnoses.index[:5]) + list(in_diagnoses.index[:5]))
|
||||
][:10]
|
||||
|
||||
|
||||
return StatsResponse(
|
||||
total_outpatient=total_outpatient,
|
||||
total_inpatient=total_inpatient,
|
||||
date_range={
|
||||
"start": min_date.strftime("%Y-%m-%d"),
|
||||
"end": max_date.strftime("%Y-%m-%d")
|
||||
"start": date_start,
|
||||
"end": date_end
|
||||
},
|
||||
top_districts=top_districts,
|
||||
top_diagnoses=top_diagnoses
|
||||
@@ -146,7 +148,7 @@ async def get_cases_trend(
|
||||
if end_date and not DATE_PATTERN.match(end_date):
|
||||
raise HTTPException(status_code=400, detail="Invalid end_date format. Use YYYY-MM-DD")
|
||||
|
||||
df = get_combined_data()
|
||||
df = get_combined_data().copy()
|
||||
|
||||
# 日期过滤
|
||||
if start_date:
|
||||
@@ -157,7 +159,7 @@ async def get_cases_trend(
|
||||
# 诊断过滤
|
||||
if diagnosis:
|
||||
df = df[df['diagnosis'].str.contains(diagnosis, na=False, case=False)]
|
||||
|
||||
|
||||
# 分组
|
||||
if group_by == "week":
|
||||
df['period'] = df['date'].dt.to_period('W').dt.start_time
|
||||
@@ -165,13 +167,13 @@ async def get_cases_trend(
|
||||
df['period'] = df['date'].dt.to_period('M').dt.start_time
|
||||
else:
|
||||
df['period'] = df['date'].dt.date
|
||||
|
||||
|
||||
# 聚合
|
||||
out_trend = df[df['type'] == 'outpatient'].groupby('period').size()
|
||||
in_trend = df[df['type'] == 'inpatient'].groupby('period').size()
|
||||
|
||||
|
||||
periods = sorted(set(out_trend.index.tolist() + in_trend.index.tolist()))
|
||||
|
||||
|
||||
trend = []
|
||||
total_out = total_in = 0
|
||||
for p in periods:
|
||||
@@ -180,12 +182,12 @@ async def get_cases_trend(
|
||||
total_out += out_count
|
||||
total_in += in_count
|
||||
trend.append(TrendPoint(
|
||||
date=pd.Timestamp(p).strftime("%Y-%m-%d"),
|
||||
date=str(p).split(' ')[0] if hasattr(p, 'strftime') else str(p)[:10],
|
||||
outpatient=out_count,
|
||||
inpatient=in_count,
|
||||
total=out_count + in_count
|
||||
))
|
||||
|
||||
|
||||
return TrendResponse(
|
||||
trend=trend,
|
||||
summary={
|
||||
@@ -198,69 +200,93 @@ async def get_cases_trend(
|
||||
)
|
||||
|
||||
|
||||
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()
|
||||
|
||||
# 诊断过滤
|
||||
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']
|
||||
elif case_type == "inpatient":
|
||||
df = df[df['type'] == 'inpatient']
|
||||
|
||||
# 过滤未知区域
|
||||
df = df[(df['district'] != '未知') & (df['district'] != '其他')]
|
||||
|
||||
# 聚合
|
||||
district_stats = df.groupby(['district', 'type']).size().unstack(fill_value=0)
|
||||
|
||||
if 'outpatient' not in district_stats.columns:
|
||||
district_stats['outpatient'] = 0
|
||||
if 'inpatient' not in district_stats.columns:
|
||||
district_stats['inpatient'] = 0
|
||||
|
||||
district_stats['total'] = district_stats['outpatient'] + district_stats['inpatient']
|
||||
|
||||
# 过滤
|
||||
district_stats = district_stats[district_stats['total'] >= min_count]
|
||||
district_stats = district_stats.sort_values('total', ascending=False)
|
||||
|
||||
total = int(district_stats['total'].sum())
|
||||
|
||||
districts = []
|
||||
for district, row in district_stats.iterrows():
|
||||
districts.append(DistrictData(
|
||||
district=str(district),
|
||||
outpatient=int(row['outpatient']),
|
||||
inpatient=int(row['inpatient']),
|
||||
total=int(row['total']),
|
||||
outpatient_ratio=round(float(row['outpatient']) / float(row['total']) * 100, 2) if row['total'] > 0 else 0,
|
||||
inpatient_ratio=round(float(row['inpatient']) / float(row['total']) * 100, 2) if row['total'] > 0 else 0
|
||||
))
|
||||
|
||||
return DistrictsResponse(districts=districts, total=total)
|
||||
|
||||
|
||||
@router.get("/districts", response_model=DistrictsResponse, summary="获取区域分布数据")
|
||||
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)"),
|
||||
):
|
||||
"""
|
||||
获取病例区域分布数据
|
||||
|
||||
- 支持按病例类型筛选
|
||||
- 可设置最小病例数过滤
|
||||
- 支持日期范围过滤
|
||||
- 返回各区门诊、住院量及占比
|
||||
"""
|
||||
df = get_combined_data()
|
||||
|
||||
# 诊断过滤
|
||||
if diagnosis:
|
||||
df = df[df['diagnosis'].str.contains(diagnosis, na=False, case=False)]
|
||||
|
||||
# 类型过滤
|
||||
if case_type == "outpatient":
|
||||
df = df[df['type'] == 'outpatient']
|
||||
elif case_type == "inpatient":
|
||||
df = df[df['type'] == 'inpatient']
|
||||
|
||||
# 过滤未知区域
|
||||
df = df[(df['district'] != '未知') & (df['district'] != '其他')]
|
||||
|
||||
# 聚合
|
||||
district_stats = df.groupby(['district', 'type']).size().unstack(fill_value=0)
|
||||
|
||||
if 'outpatient' not in district_stats.columns:
|
||||
district_stats['outpatient'] = 0
|
||||
if 'inpatient' not in district_stats.columns:
|
||||
district_stats['inpatient'] = 0
|
||||
|
||||
district_stats['total'] = district_stats['outpatient'] + district_stats['inpatient']
|
||||
|
||||
# 过滤
|
||||
district_stats = district_stats[district_stats['total'] >= min_count]
|
||||
district_stats = district_stats.sort_values('total', ascending=False)
|
||||
|
||||
total = int(district_stats['total'].sum())
|
||||
|
||||
districts = []
|
||||
for district, row in district_stats.iterrows():
|
||||
districts.append(DistrictData(
|
||||
district=district,
|
||||
outpatient=int(row['outpatient']),
|
||||
inpatient=int(row['inpatient']),
|
||||
total=int(row['total']),
|
||||
outpatient_ratio=round(row['outpatient'] / row['total'] * 100, 2) if row['total'] > 0 else 0,
|
||||
inpatient_ratio=round(row['inpatient'] / row['total'] * 100, 2) if row['total'] > 0 else 0
|
||||
))
|
||||
|
||||
return DistrictsResponse(districts=districts, total=total)
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
@router.get("/realtime", response_model=RealtimeData, summary="获取实时数据")
|
||||
async def get_cases_realtime():
|
||||
"""
|
||||
获取实时病例数据
|
||||
|
||||
|
||||
- 今日就诊量
|
||||
- 近 7 日平均值
|
||||
- 变化率
|
||||
@@ -270,23 +296,23 @@ async def get_cases_realtime():
|
||||
|
||||
today = pd.Timestamp.today().normalize()
|
||||
last_7d = today - pd.Timedelta(days=7)
|
||||
|
||||
|
||||
# 今日数据
|
||||
today_data = df[df['date'] >= today]
|
||||
today_total = len(today_data)
|
||||
today_out = len(today_data[today_data['type'] == 'outpatient'])
|
||||
today_in = len(today_data[today_data['type'] == 'inpatient'])
|
||||
|
||||
|
||||
# 近 7 日平均
|
||||
last_7d_data = df[(df['date'] >= last_7d) & (df['date'] < today)]
|
||||
last_7d_avg = round(len(last_7d_data) / 7, 2) if len(last_7d_data) > 0 else 0
|
||||
|
||||
|
||||
# 变化率
|
||||
if last_7d_avg > 0:
|
||||
change_ratio = round((today_total - last_7d_avg) / last_7d_avg * 100, 2)
|
||||
else:
|
||||
change_ratio = 0.0
|
||||
|
||||
|
||||
# 状态评估
|
||||
if change_ratio > 20:
|
||||
status = "偏高"
|
||||
@@ -294,12 +320,12 @@ async def get_cases_realtime():
|
||||
status = "偏低"
|
||||
else:
|
||||
status = "正常"
|
||||
|
||||
|
||||
return RealtimeData(
|
||||
today_outpatient=today_out,
|
||||
today_inpatient=today_in,
|
||||
today_total=today_total,
|
||||
last_7d_avg=last_7d_avg,
|
||||
last_7d_avg=int(last_7d_avg),
|
||||
change_ratio=change_ratio,
|
||||
status=status
|
||||
)
|
||||
@@ -311,8 +337,323 @@ class DiagnosesResponse(BaseModel):
|
||||
|
||||
|
||||
@router.get("/diagnoses", response_model=DiagnosesResponse, summary="获取所有诊断名称列表")
|
||||
async def get_diagnoses():
|
||||
"""Returns deduplicated, sorted list of unique diagnosis names"""
|
||||
df = get_combined_data()
|
||||
diagnoses = sorted(df['diagnosis'].dropna().unique().tolist())
|
||||
async def get_diagnoses_list():
|
||||
"""Returns deduplicated, sorted list of unique diagnosis names (cached, fast)."""
|
||||
diagnoses = get_diagnoses()
|
||||
return DiagnosesResponse(diagnoses=diagnoses)
|
||||
|
||||
|
||||
# ============== Seasonal & Distribution Endpoints ==============
|
||||
|
||||
class SeasonalPoint(BaseModel):
|
||||
"""月度聚合数据点"""
|
||||
month: int # 1-12
|
||||
month_label: str # "1月", "2月", ...
|
||||
outpatient: int
|
||||
inpatient: int
|
||||
total: int
|
||||
|
||||
|
||||
class SeasonalResponse(BaseModel):
|
||||
"""月度季节性响应"""
|
||||
monthly: list[SeasonalPoint]
|
||||
period_years: list[int] # e.g. [2022, 2023, 2024]
|
||||
total_cases: int
|
||||
|
||||
|
||||
class DiagnosisDistributionItem(BaseModel):
|
||||
"""诊断分布数据项"""
|
||||
diagnosis: str
|
||||
outpatient: int
|
||||
inpatient: int
|
||||
total: int
|
||||
percentage: float
|
||||
|
||||
|
||||
class DiagnosisDistributionResponse(BaseModel):
|
||||
"""诊断分布响应"""
|
||||
diagnoses: list[DiagnosisDistributionItem]
|
||||
total_cases: int
|
||||
|
||||
|
||||
# ============== Demographics Models ==============
|
||||
|
||||
class AgeBin(BaseModel):
|
||||
"""年龄分段数据"""
|
||||
age_bin: int # 0-17
|
||||
outpatient: int
|
||||
inpatient: int
|
||||
|
||||
|
||||
class GenderSplit(BaseModel):
|
||||
"""性别拆分数据"""
|
||||
outpatient: int
|
||||
inpatient: int
|
||||
|
||||
|
||||
class GenderSplitData(BaseModel):
|
||||
"""性别分布响应内层"""
|
||||
male: GenderSplit
|
||||
female: GenderSplit
|
||||
|
||||
|
||||
class AgeDiagnosisMatrixItem(BaseModel):
|
||||
"""年龄-诊断矩阵项"""
|
||||
age_group: str # "0-1", "1-3", "3-6", "6-12", "12-18"
|
||||
diagnosis: str
|
||||
outpatient: int
|
||||
inpatient: int
|
||||
total: int
|
||||
|
||||
|
||||
class DemographicsResponse(BaseModel):
|
||||
"""人口统计响应"""
|
||||
age_distribution: list[AgeBin]
|
||||
gender_split: GenderSplitData
|
||||
age_diagnosis_matrix: list[AgeDiagnosisMatrixItem]
|
||||
|
||||
|
||||
# ============== Disease Seasonality Models ==============
|
||||
|
||||
class DiseaseSeasonalityPoint(BaseModel):
|
||||
"""疾病月度季节性数据点"""
|
||||
diagnosis: str
|
||||
month: int # 1-12
|
||||
month_label: str # "1月"-"12月"
|
||||
outpatient: int
|
||||
inpatient: int
|
||||
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="获取季节性月度聚合数据")
|
||||
async def get_cases_seasonal(
|
||||
diagnosis: Optional[str] = Query(None, description="Filter by diagnosis name"),
|
||||
):
|
||||
"""
|
||||
按月聚合所有年份的病例数据
|
||||
|
||||
- 返回 1-12 月各月门诊/住院/总计均值
|
||||
- 支持诊断过滤
|
||||
- 用于季节性分解图表
|
||||
"""
|
||||
df = get_combined_data()
|
||||
|
||||
if diagnosis:
|
||||
df = df[df['diagnosis'].str.contains(diagnosis, na=False, case=False)]
|
||||
|
||||
# Extract month and aggregate
|
||||
df = df.copy()
|
||||
df['month'] = df['date'].dt.month
|
||||
years = sorted(df['date'].dt.year.unique().tolist())
|
||||
|
||||
out_monthly = df[df['type'] == 'outpatient'].groupby('month').size()
|
||||
in_monthly = df[df['type'] == 'inpatient'].groupby('month').size()
|
||||
|
||||
month_labels = ['1月', '2月', '3月', '4月', '5月', '6月',
|
||||
'7月', '8月', '9月', '10月', '11月', '12月']
|
||||
|
||||
monthly = []
|
||||
total_cases = 0
|
||||
for m in range(1, 13):
|
||||
out_count = int(out_monthly.get(m, 0))
|
||||
in_count = int(in_monthly.get(m, 0))
|
||||
total_cases += out_count + in_count
|
||||
monthly.append(SeasonalPoint(
|
||||
month=m,
|
||||
month_label=month_labels[m - 1],
|
||||
outpatient=out_count,
|
||||
inpatient=in_count,
|
||||
total=out_count + in_count,
|
||||
))
|
||||
|
||||
return SeasonalResponse(
|
||||
monthly=monthly,
|
||||
period_years=years,
|
||||
total_cases=total_cases,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/diagnosis-distribution", response_model=DiagnosisDistributionResponse, summary="获取诊断分布统计")
|
||||
async def get_diagnosis_distribution(
|
||||
limit: int = Query(default=20, ge=1, le=50, description="Maximum diagnoses to return"),
|
||||
):
|
||||
"""
|
||||
获取诊断名称分布统计(门诊+住院分列)
|
||||
|
||||
- 返回前 N 个诊断及门诊/住院/总计/占比
|
||||
- 用于诊断分布饼图、树图等
|
||||
"""
|
||||
df = get_combined_data()
|
||||
|
||||
# Compute O/I counts per diagnosis
|
||||
breakdown = df.groupby(['diagnosis', 'type']).size().unstack(fill_value=0)
|
||||
if 'outpatient' not in breakdown.columns:
|
||||
breakdown['outpatient'] = 0
|
||||
if 'inpatient' not in breakdown.columns:
|
||||
breakdown['inpatient'] = 0
|
||||
breakdown['total'] = breakdown['outpatient'] + breakdown['inpatient']
|
||||
breakdown = breakdown.sort_values('total', ascending=False).head(limit)
|
||||
|
||||
grand_total = int(breakdown['total'].sum())
|
||||
|
||||
diagnoses = []
|
||||
for diagnosis_name, row in breakdown.iterrows():
|
||||
diagnoses.append(DiagnosisDistributionItem(
|
||||
diagnosis=str(diagnosis_name),
|
||||
outpatient=int(row['outpatient']),
|
||||
inpatient=int(row['inpatient']),
|
||||
total=int(row['total']),
|
||||
percentage=round(float(row['total']) / float(grand_total) * 100, 2) if grand_total > 0 else 0,
|
||||
))
|
||||
|
||||
return DiagnosisDistributionResponse(
|
||||
diagnoses=diagnoses,
|
||||
total_cases=grand_total,
|
||||
)
|
||||
|
||||
|
||||
# ============== Demographics Endpoint ==============
|
||||
|
||||
@router.get("/demographics", response_model=DemographicsResponse, summary="获取人口统计信息")
|
||||
async def get_cases_demographics():
|
||||
"""
|
||||
获取病例人口统计信息
|
||||
|
||||
- 年龄分布(0-17岁,按1岁分段,仅住院数据)
|
||||
- 性别分布(仅住院数据)
|
||||
- 年龄-诊断矩阵(按年龄段分组,仅住院数据)
|
||||
|
||||
注意:门诊数据不包含人口统计信息(性别/年龄),因此门诊计数均为 0。
|
||||
"""
|
||||
df = get_inpatient_data()
|
||||
df = df.copy()
|
||||
df['age_bin'] = df['年龄'].clip(0, 17).astype(int)
|
||||
|
||||
# --- Age distribution: 1-year bins from 0 to 17 ---
|
||||
age_counts = df.groupby('age_bin').size()
|
||||
age_distribution = [
|
||||
AgeBin(age_bin=a, outpatient=0, inpatient=int(age_counts.get(a, 0)))
|
||||
for a in range(0, 18)
|
||||
]
|
||||
|
||||
# --- Gender split ---
|
||||
gender_counts = df['性别'].value_counts()
|
||||
gender_split = GenderSplitData(
|
||||
male=GenderSplit(outpatient=0, inpatient=int(gender_counts.get('男性', 0))),
|
||||
female=GenderSplit(outpatient=0, inpatient=int(gender_counts.get('女性', 0))),
|
||||
)
|
||||
|
||||
# --- Age-diagnosis matrix ---
|
||||
age_bins = [
|
||||
(0, 1, "0-1"), (1, 3, "1-3"), (3, 6, "3-6"),
|
||||
(6, 12, "6-12"), (12, 18, "12-18"),
|
||||
]
|
||||
|
||||
matrix_rows: list[AgeDiagnosisMatrixItem] = []
|
||||
for low, high, label in age_bins:
|
||||
group = df[(df['年龄'] >= low) & (df['年龄'] < high)]
|
||||
for diag, count in group['诊断名称'].value_counts().items():
|
||||
matrix_rows.append(AgeDiagnosisMatrixItem(
|
||||
age_group=label, diagnosis=str(diag),
|
||||
outpatient=0, inpatient=int(count), total=int(count),
|
||||
))
|
||||
|
||||
return DemographicsResponse(
|
||||
age_distribution=age_distribution,
|
||||
gender_split=gender_split,
|
||||
age_diagnosis_matrix=matrix_rows,
|
||||
)
|
||||
|
||||
|
||||
# ============== 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"),
|
||||
):
|
||||
"""
|
||||
获取各诊断的月度季节性分布数据
|
||||
|
||||
- 基于门诊+住院合并数据
|
||||
- 按月聚合所有年份,返回 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
|
||||
|
||||
# Get top 10 diagnoses by total case count
|
||||
diag_totals = df.groupby('diagnosis').size().nlargest(10)
|
||||
top_diagnoses = diag_totals.index.tolist()
|
||||
|
||||
month_labels = ['1月', '2月', '3月', '4月', '5月', '6月',
|
||||
'7月', '8月', '9月', '10月', '11月', '12月']
|
||||
|
||||
# Filter to top diagnoses
|
||||
df_top = df[df['diagnosis'].isin(top_diagnoses)]
|
||||
|
||||
# Group by diagnosis + month
|
||||
breakdown = df_top.groupby(['diagnosis', 'month', 'type']).size().unstack(fill_value=0)
|
||||
if 'outpatient' not in breakdown.columns:
|
||||
breakdown['outpatient'] = 0
|
||||
if 'inpatient' not in breakdown.columns:
|
||||
breakdown['inpatient'] = 0
|
||||
|
||||
seasonality: list[DiseaseSeasonalityPoint] = []
|
||||
for diag in top_diagnoses:
|
||||
for m in range(1, 13):
|
||||
row = breakdown.loc[(diag, m)] if (diag, m) in breakdown.index else None
|
||||
out_count = int(row['outpatient']) if row is not None else 0
|
||||
in_count = int(row['inpatient']) if row is not None else 0
|
||||
seasonality.append(DiseaseSeasonalityPoint(
|
||||
diagnosis=str(diag),
|
||||
month=m,
|
||||
month_label=month_labels[m - 1],
|
||||
outpatient=out_count,
|
||||
inpatient=in_count,
|
||||
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,
|
||||
)
|
||||
|
||||
325
backend/routers/environment.py
Normal file
325
backend/routers/environment.py
Normal file
@@ -0,0 +1,325 @@
|
||||
"""
|
||||
环境数据 API 路由
|
||||
|
||||
提供空气污染物时间序列和滞后相关性分析接口
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import pandas as pd
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from scipy.stats import pearsonr
|
||||
|
||||
logger = logging.getLogger("cbpoa.environment")
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
||||
|
||||
router = APIRouter(prefix="/api/environment", tags=["environment"])
|
||||
|
||||
# 污染物列表(CO 无基值列,仅存在于 lag_features.parquet 的滞后列中)
|
||||
POLLUTANTS = ["AQI", "PM2.5", "PM10", "SO2", "NO2", "O3"]
|
||||
LAGS = [1, 2, 3, 5, 7, 14]
|
||||
|
||||
# station_daily 列名映射(PM25 无点号 -> PM2.5 带点号)
|
||||
_STATION_COL_MAP: dict[str, str] | None = None
|
||||
# lag_features 基值列映射
|
||||
_LAG_BASE_MAP: dict[str, str] | None = None
|
||||
|
||||
|
||||
# ============== Response Models ==============
|
||||
|
||||
|
||||
class LagCorrelationItem(BaseModel):
|
||||
pollutant: str # "AQI", "PM2.5", "PM10", "SO2", "NO2", "O3"
|
||||
lag_days: int # 1, 2, 3, 5, 7, 14
|
||||
correlation: float # Pearson r
|
||||
|
||||
|
||||
class LagCorrelationResponse(BaseModel):
|
||||
correlations: list[LagCorrelationItem]
|
||||
data_note: str # "CO excluded - no base column in lag_features.parquet"
|
||||
|
||||
|
||||
class PollutantPoint(BaseModel):
|
||||
date: str
|
||||
AQI: float
|
||||
PM25: float
|
||||
PM10: float
|
||||
SO2: float
|
||||
NO2: float
|
||||
O3: float
|
||||
CO: float
|
||||
|
||||
|
||||
class PollutantResponse(BaseModel):
|
||||
data: list[PollutantPoint]
|
||||
station_count: int
|
||||
date_range: dict # {start, end}
|
||||
|
||||
|
||||
# ============== Helper Functions ==============
|
||||
|
||||
|
||||
def _get_station_col_map() -> dict[str, str]:
|
||||
"""返回 station_daily parquet 中实际列名到标准名称的映射。
|
||||
|
||||
station_daily 文件中 PM2.5 列名为 "PM25"(无点号),
|
||||
需要映射到前端期望的 "PM2.5"。
|
||||
"""
|
||||
global _STATION_COL_MAP
|
||||
if _STATION_COL_MAP is not None:
|
||||
return _STATION_COL_MAP
|
||||
|
||||
path = PROJECT_ROOT / "processed" / "weather" / "station_daily_2022.parquet"
|
||||
df = pd.read_parquet(path)
|
||||
cols = set(df.columns)
|
||||
|
||||
col_map = {}
|
||||
for standard in POLLUTANTS:
|
||||
if standard in cols:
|
||||
col_map[standard] = standard
|
||||
elif "PM25" in cols and standard == "PM2.5":
|
||||
col_map[standard] = "PM25"
|
||||
else:
|
||||
col_map[standard] = standard # fallback
|
||||
|
||||
# CO is in station_daily but not in POLLUTANTS
|
||||
if "CO" in cols:
|
||||
col_map["CO"] = "CO"
|
||||
|
||||
_STATION_COL_MAP = col_map
|
||||
return _STATION_COL_MAP
|
||||
|
||||
|
||||
def _get_lag_base_map() -> dict[str, str]:
|
||||
"""返回 lag_features parquet 中基值列名到标准名称的映射。
|
||||
|
||||
lag_features 中 PM2.5 列名为 "PM2.5"(带点号),与标准名称一致。
|
||||
此函数在运行时验证实际列名。
|
||||
"""
|
||||
global _LAG_BASE_MAP
|
||||
if _LAG_BASE_MAP is not None:
|
||||
return _LAG_BASE_MAP
|
||||
|
||||
path = PROJECT_ROOT / "processed" / "weather" / "lag_features.parquet"
|
||||
df = pd.read_parquet(path)
|
||||
cols = set(df.columns)
|
||||
|
||||
base_map = {}
|
||||
for p in POLLUTANTS:
|
||||
if p in cols:
|
||||
base_map[p] = p
|
||||
elif p == "PM2.5" and "PM25" in cols:
|
||||
base_map[p] = "PM25"
|
||||
else:
|
||||
base_map[p] = p # will be checked later
|
||||
|
||||
_LAG_BASE_MAP = base_map
|
||||
return _LAG_BASE_MAP
|
||||
|
||||
|
||||
def _load_lag_features() -> pd.DataFrame:
|
||||
"""加载 lag_features.parquet 并转换日期列。"""
|
||||
path = PROJECT_ROOT / "processed" / "weather" / "lag_features.parquet"
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"lag_features.parquet not found at {path}")
|
||||
df = pd.read_parquet(path)
|
||||
df["date"] = pd.to_datetime(df["date"])
|
||||
return df
|
||||
|
||||
|
||||
def _load_cases_daily() -> pd.DataFrame:
|
||||
"""加载 cases_combined.parquet 并按日期汇总每日总病例数。"""
|
||||
path = PROJECT_ROOT / "processed" / "cases_combined.parquet"
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"cases_combined.parquet not found at {path}")
|
||||
df = pd.read_parquet(path)
|
||||
df["date"] = pd.to_datetime(df["date"])
|
||||
daily = df.groupby("date").size().reset_index(name="total_cases")
|
||||
return daily
|
||||
|
||||
|
||||
def _load_station_daily() -> pd.DataFrame:
|
||||
"""加载并合并 station_daily_2022.parquet 和 station_daily_2023.parquet。"""
|
||||
dfs = []
|
||||
for year in [2022, 2023]:
|
||||
path = PROJECT_ROOT / "processed" / "weather" / f"station_daily_{year}.parquet"
|
||||
if not path.exists():
|
||||
logger.warning("station_daily_%s.parquet not found at %s", year, path)
|
||||
continue
|
||||
df = pd.read_parquet(path)
|
||||
dfs.append(df)
|
||||
if not dfs:
|
||||
raise FileNotFoundError("No station_daily parquet files found")
|
||||
combined = pd.concat(dfs, ignore_index=True)
|
||||
combined["date"] = pd.to_datetime(combined["date"])
|
||||
return combined
|
||||
|
||||
|
||||
# ============== Endpoints ==============
|
||||
|
||||
|
||||
@router.get("/lag-correlations", response_model=LagCorrelationResponse)
|
||||
async def get_lag_correlations():
|
||||
"""获取污染物滞后相关性分析数据。
|
||||
|
||||
计算各污染物在不同滞后天数(1, 2, 3, 5, 7, 14 天)下
|
||||
与每日病例总数之间的 Pearson 相关系数。
|
||||
|
||||
Returns:
|
||||
LagCorrelationResponse: 包含 36 个相关系数(6 种污染物 × 6 个滞后天数)
|
||||
"""
|
||||
try:
|
||||
lag_df = _load_lag_features()
|
||||
cases_daily = _load_cases_daily()
|
||||
except FileNotFoundError as e:
|
||||
logger.warning("Data file not found for lag-correlations: %s", e)
|
||||
return LagCorrelationResponse(
|
||||
correlations=[],
|
||||
data_note="CO excluded - no base column in lag_features.parquet",
|
||||
)
|
||||
|
||||
# 计算每日全市均值(按日期聚合,对 23 个站点取平均)
|
||||
base_map = _get_lag_base_map()
|
||||
mean_cols = {p: base_map.get(p, p) for p in POLLUTANTS}
|
||||
daily_mean = lag_df.groupby("date")[list(mean_cols.values())].mean().reset_index()
|
||||
|
||||
# 重命名列为标准名称以便一致访问
|
||||
rename_map = {v: k for k, v in mean_cols.items() if v != k}
|
||||
if rename_map:
|
||||
daily_mean = daily_mean.rename(columns=rename_map)
|
||||
|
||||
# 对每种污染物计算每日均值
|
||||
# 对齐污染物时间序列与病例数据
|
||||
merged = daily_mean.merge(cases_daily, on="date", how="inner")
|
||||
merged = merged.sort_values("date")
|
||||
|
||||
results: list[LagCorrelationItem] = []
|
||||
|
||||
for pollutant in POLLUTANTS:
|
||||
# 构建滞后列映射(lag_features 文件名用点号 "PM2.5")
|
||||
lag_base_name = base_map.get(pollutant, pollutant)
|
||||
|
||||
for lag in LAGS:
|
||||
lag_col = f"{lag_base_name}_lag{lag}"
|
||||
if lag_col not in lag_df.columns:
|
||||
logger.debug("Lag column %s not found, skipping", lag_col)
|
||||
continue
|
||||
|
||||
# 从原始 lag_features 提取该污染物的滞后数据(按日期取全市均值)
|
||||
lag_series = lag_df.groupby("date")[lag_col].mean().reset_index()
|
||||
lag_series = lag_series.rename(columns={lag_col: f"{pollutant}_lag{lag}"})
|
||||
|
||||
# 将滞后污染物数据与病例数据对齐
|
||||
# 滞后列的值代表的是 t-lag 时刻的污染物,病例是 t 时刻
|
||||
# 所以将 lag 列的时间向后平移 lag 天,使其与病例时间对齐
|
||||
lag_series["align_date"] = lag_series["date"] + pd.Timedelta(days=lag)
|
||||
combined = lag_series.merge(
|
||||
cases_daily, left_on="align_date", right_on="date", how="inner"
|
||||
)
|
||||
|
||||
if len(combined) < 10:
|
||||
logger.debug(
|
||||
"Insufficient data for %s lag%d: %d rows, skipping",
|
||||
pollutant,
|
||||
lag,
|
||||
len(combined),
|
||||
)
|
||||
continue
|
||||
|
||||
# 计算 Pearson 相关系数
|
||||
r, _ = pearsonr(
|
||||
combined[f"{pollutant}_lag{lag}"], combined["total_cases"]
|
||||
)
|
||||
results.append(
|
||||
LagCorrelationItem(
|
||||
pollutant=pollutant, lag_days=lag, correlation=round(float(r), 4)
|
||||
)
|
||||
)
|
||||
|
||||
return LagCorrelationResponse(
|
||||
correlations=results,
|
||||
data_note="CO excluded - no base column in lag_features.parquet",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/pollutants", response_model=PollutantResponse)
|
||||
async def get_pollutants(
|
||||
days: Optional[int] = Query(default=30, ge=1, le=730, description="返回最近 N 天的数据"),
|
||||
start_date: Optional[str] = Query(default=None, description="开始日期 YYYY-MM-DD"),
|
||||
end_date: Optional[str] = Query(default=None, description="结束日期 YYYY-MM-DD"),
|
||||
):
|
||||
"""获取每日全市均值污染物时间序列。
|
||||
|
||||
合并 2022/2023 两个年度的站点日数据,按日期聚合所有站点取均值。
|
||||
|
||||
可通过 ?days=N(默认 30)或 ?start_date=YYYY-MM-DD&end_date=YYYY-MM-DD 筛选日期范围。
|
||||
"""
|
||||
try:
|
||||
df = _load_station_daily()
|
||||
except FileNotFoundError as e:
|
||||
logger.warning("Data file not found for pollutants: %s", e)
|
||||
return PollutantResponse(data=[], station_count=0, date_range={})
|
||||
|
||||
station_count = df["station_id"].nunique()
|
||||
|
||||
# 获取列名映射
|
||||
col_map = _get_station_col_map()
|
||||
|
||||
# 选择污染物列
|
||||
poll_cols = [col_map.get(p, p) for p in POLLUTANTS] + ["CO"]
|
||||
# 确保需要的列都存在
|
||||
available_cols = [c for c in poll_cols if c in df.columns]
|
||||
|
||||
# 按日期聚合取均值
|
||||
daily_mean = df.groupby("date")[available_cols].mean().reset_index()
|
||||
|
||||
# 重命名为标准名称
|
||||
rename_map = {}
|
||||
for std_name, actual_name in col_map.items():
|
||||
if actual_name != std_name and actual_name in daily_mean.columns:
|
||||
rename_map[actual_name] = std_name
|
||||
if rename_map:
|
||||
daily_mean = daily_mean.rename(columns=rename_map)
|
||||
|
||||
# 日期筛选
|
||||
if start_date and end_date:
|
||||
start_dt = pd.to_datetime(start_date)
|
||||
end_dt = pd.to_datetime(end_date)
|
||||
date_mask = (daily_mean["date"] >= start_dt) & (
|
||||
daily_mean["date"] <= end_dt
|
||||
)
|
||||
daily_mean = daily_mean[date_mask].copy()
|
||||
else:
|
||||
daily_mean = daily_mean.sort_values("date").tail(days)
|
||||
|
||||
daily_mean = daily_mean.sort_values("date")
|
||||
|
||||
# 构建响应
|
||||
data: list[PollutantPoint] = []
|
||||
for _, row in daily_mean.iterrows():
|
||||
pt = PollutantPoint(
|
||||
date=row["date"].strftime("%Y-%m-%d"),
|
||||
AQI=round(float(row.get("AQI", 0)), 2),
|
||||
PM25=round(float(row.get("PM2.5", 0)), 2),
|
||||
PM10=round(float(row.get("PM10", 0)), 2),
|
||||
SO2=round(float(row.get("SO2", 0)), 2),
|
||||
NO2=round(float(row.get("NO2", 0)), 2),
|
||||
O3=round(float(row.get("O3", 0)), 2),
|
||||
CO=round(float(row.get("CO", 0)), 2),
|
||||
)
|
||||
data.append(pt)
|
||||
|
||||
date_range = {}
|
||||
if daily_mean.shape[0] > 0:
|
||||
date_range = {
|
||||
"start": daily_mean["date"].iloc[0].strftime("%Y-%m-%d"),
|
||||
"end": daily_mean["date"].iloc[-1].strftime("%Y-%m-%d"),
|
||||
}
|
||||
|
||||
return PollutantResponse(
|
||||
data=data, station_count=station_count, date_range=date_range
|
||||
)
|
||||
@@ -17,7 +17,7 @@ PROJECT_ROOT = Path(__file__).parent.parent.parent
|
||||
DATA_DIR = PROJECT_ROOT / "outputs"
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
@lru_cache(maxsize=4)
|
||||
def _load_csv(path: Path) -> pd.DataFrame:
|
||||
return pd.read_csv(path)
|
||||
|
||||
@@ -109,19 +109,21 @@ async def get_grid_cases():
|
||||
async def get_geocoded_cases(
|
||||
limit: int = 1000,
|
||||
district: Optional[str] = None,
|
||||
date: Optional[str] = Query(None, description="Filter by date (YYYY-MM-DD)"),
|
||||
):
|
||||
"""
|
||||
Get individual geocoded case data.
|
||||
|
||||
|
||||
Args:
|
||||
limit: Maximum number of cases to return (for performance)
|
||||
district: Filter by district name
|
||||
date: Filter by specific date
|
||||
"""
|
||||
cases_file = DATA_DIR / "geocoded_all_cases.csv"
|
||||
|
||||
|
||||
if not cases_file.exists():
|
||||
raise HTTPException(status_code=404, detail="Geocoded data not found")
|
||||
|
||||
|
||||
try:
|
||||
df = _load_csv(cases_file)
|
||||
|
||||
@@ -132,6 +134,11 @@ async def get_geocoded_cases(
|
||||
swapped = df['latitude'] > 50 # longitude values are >113
|
||||
df.loc[swapped, ['latitude', 'longitude']] = df.loc[swapped, ['longitude', 'latitude']].values
|
||||
|
||||
# Filter by date if specified
|
||||
if date and 'date' in df.columns:
|
||||
df['date_str'] = pd.to_datetime(df['date']).dt.strftime('%Y-%m-%d')
|
||||
df = df[df['date_str'] == date]
|
||||
|
||||
# Filter by district if specified
|
||||
if district:
|
||||
df = df[df['district'] == district]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
import asyncio
|
||||
from fastapi import APIRouter, HTTPException, Query, Response
|
||||
from datetime import datetime, timedelta
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
@@ -6,6 +7,7 @@ from typing import Optional
|
||||
import logging
|
||||
import sys
|
||||
import math
|
||||
import pandas as pd
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
@@ -22,47 +24,33 @@ from models import (
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["grid"])
|
||||
|
||||
logger = logging.getLogger("cbpoa.grid")
|
||||
|
||||
_parquet_cache: dict[str, "pd.DataFrame"] = {}
|
||||
_parquet_cache: dict[str, pd.DataFrame] = {}
|
||||
|
||||
def _load_parquet(path: Path) -> "pd.DataFrame":
|
||||
import pandas as pd
|
||||
def _load_parquet(path: Path) -> pd.DataFrame:
|
||||
key = str(path)
|
||||
if key not in _parquet_cache:
|
||||
_parquet_cache[key] = pd.read_parquet(path)
|
||||
return _parquet_cache[key]
|
||||
|
||||
|
||||
@router.get("/history/aggregated", response_model=HistoricalAggregationResponse)
|
||||
async def get_historical_aggregated(
|
||||
start_date: str = Query(..., description="Start date (YYYY-MM-DD)"),
|
||||
end_date: str = Query(..., description="End date (YYYY-MM-DD)"),
|
||||
aggregation: str = Query("daily", description="Aggregation level: daily, weekly, monthly"),
|
||||
district: Optional[str] = Query(None, description="Filter by district name"),
|
||||
):
|
||||
"""
|
||||
Historical data aggregation API.
|
||||
|
||||
Returns aggregated case and weather data by district and date.
|
||||
"""
|
||||
try:
|
||||
start = datetime.strptime(start_date, "%Y-%m-%d")
|
||||
end = datetime.strptime(end_date, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
|
||||
|
||||
if (end - start).days > 365:
|
||||
raise HTTPException(status_code=400, detail="Date range exceeds 365 days")
|
||||
|
||||
import pandas as pd
|
||||
|
||||
def _compute_historical_aggregation(
|
||||
start: datetime,
|
||||
end: datetime,
|
||||
aggregation: str,
|
||||
district: Optional[str],
|
||||
) -> HistoricalAggregationResponse:
|
||||
"""Run the full pandas aggregation pipeline (called in thread pool)."""
|
||||
try:
|
||||
cases_df = _load_parquet(PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet")
|
||||
except FileNotFoundError:
|
||||
return HistoricalAggregationResponse(
|
||||
aggregations=[], total_records=0,
|
||||
date_range=(start_date, end_date), timestamp=datetime.now().isoformat(),
|
||||
date_range=(start.strftime("%Y-%m-%d"), end.strftime("%Y-%m-%d")),
|
||||
timestamp=datetime.now().isoformat(),
|
||||
)
|
||||
cases_df = cases_df.copy()
|
||||
cases_df['date'] = pd.to_datetime(cases_df['date'])
|
||||
|
||||
filtered_cases = cases_df[
|
||||
@@ -76,6 +64,7 @@ async def get_historical_aggregated(
|
||||
]
|
||||
|
||||
if aggregation == "weekly":
|
||||
filtered_cases = filtered_cases.copy()
|
||||
filtered_cases['period'] = filtered_cases['date'].dt.to_period('W').astype(str)
|
||||
grouped = filtered_cases.groupby(['period', 'district']).agg({
|
||||
'total_cases': 'sum',
|
||||
@@ -84,6 +73,7 @@ async def get_historical_aggregated(
|
||||
}).reset_index()
|
||||
grouped['date'] = grouped['period']
|
||||
elif aggregation == "monthly":
|
||||
filtered_cases = filtered_cases.copy()
|
||||
filtered_cases['period'] = filtered_cases['date'].dt.to_period('M').astype(str)
|
||||
grouped = filtered_cases.groupby(['period', 'district']).agg({
|
||||
'total_cases': 'sum',
|
||||
@@ -98,7 +88,8 @@ async def get_historical_aggregated(
|
||||
try:
|
||||
weather_df = _load_parquet(PROJECT_ROOT / "processed" / "weather" / "station_daily_2022.parquet")
|
||||
except FileNotFoundError:
|
||||
weather_df = pd.DataFrame(columns=['date', 'AQI', 'PM25', 'PM10'])
|
||||
weather_df = pd.DataFrame({'date': pd.Series(dtype='str'), 'AQI': pd.Series(dtype='float64'), 'PM25': pd.Series(dtype='float64'), 'PM10': pd.Series(dtype='float64')})
|
||||
weather_df = weather_df.copy()
|
||||
weather_df['date'] = pd.to_datetime(weather_df['date']).dt.strftime('%Y-%m-%d')
|
||||
|
||||
# Weather data doesn't have district - aggregate by date only
|
||||
@@ -114,135 +105,173 @@ async def get_historical_aggregated(
|
||||
aggregations = []
|
||||
for _, row in merged.iterrows():
|
||||
aggregations.append(DistrictAggregation(
|
||||
district=row['district'],
|
||||
district=str(row['district']),
|
||||
date=str(row['date']),
|
||||
total_cases=int(row['total_cases']),
|
||||
outpatient_count=int(row['outpatient_count']),
|
||||
inpatient_count=int(row['inpatient_count']),
|
||||
avg_AQI=float(row['AQI']) if pd.notna(row['AQI']) else 0.0,
|
||||
avg_PM25=float(row['PM25']) if pd.notna(row['PM25']) else 0.0,
|
||||
avg_PM10=float(row['PM10']) if pd.notna(row['PM10']) else 0.0,
|
||||
avg_AQI=float(row['AQI']) if bool(pd.notna(row['AQI'])) else 0.0,
|
||||
avg_PM25=float(row['PM25']) if bool(pd.notna(row['PM25'])) else 0.0,
|
||||
avg_PM10=float(row['PM10']) if bool(pd.notna(row['PM10'])) else 0.0,
|
||||
))
|
||||
|
||||
return HistoricalAggregationResponse(
|
||||
aggregations=aggregations,
|
||||
total_records=len(aggregations),
|
||||
date_range=(start_date, end_date),
|
||||
date_range=(start.strftime("%Y-%m-%d"), end.strftime("%Y-%m-%d")),
|
||||
timestamp=datetime.now().isoformat(),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/history/aggregated", response_model=HistoricalAggregationResponse)
|
||||
async def get_historical_aggregated(
|
||||
start_date: str = Query(..., description="Start date (YYYY-MM-DD)"),
|
||||
end_date: str = Query(..., description="End date (YYYY-MM-DD)"),
|
||||
aggregation: str = Query("daily", description="Aggregation level: daily, weekly, monthly"),
|
||||
district: Optional[str] = Query(None, description="Filter by district name"),
|
||||
):
|
||||
"""
|
||||
Historical data aggregation API.
|
||||
|
||||
Returns aggregated case and weather data by district and date.
|
||||
Pandas processing runs in a thread pool to avoid blocking the async event loop.
|
||||
"""
|
||||
try:
|
||||
start = datetime.strptime(start_date, "%Y-%m-%d")
|
||||
end = datetime.strptime(end_date, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
|
||||
|
||||
if (end - start).days > 365:
|
||||
raise HTTPException(status_code=400, detail="Date range exceeds 365 days")
|
||||
|
||||
# Offload all pandas I/O and processing to a thread pool
|
||||
# to prevent blocking the async event loop
|
||||
return await asyncio.to_thread(
|
||||
_compute_historical_aggregation, start, end, aggregation, district
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _grid_geojson_base():
|
||||
"""Date-independent base merge: grid centroid + district + real population.
|
||||
|
||||
Merged once and cached (the source frames are ~1M rows each, so the join
|
||||
must not run per request). Raises FileNotFoundError if the core grid files
|
||||
are missing (caller handles it).
|
||||
"""
|
||||
import pandas as pd
|
||||
grid_df = _load_parquet(PROJECT_ROOT / "processed" / "grid_100m_index.parquet")
|
||||
district_map = _load_parquet(PROJECT_ROOT / "processed" / "grid_district_mapping.parquet")
|
||||
base = grid_df.merge(district_map, on='grid_id', how='left')
|
||||
try:
|
||||
pop_df = _load_parquet(PROJECT_ROOT / "processed" / "grid_100m_with_dem_pop.parquet")
|
||||
base = base.merge(pop_df[['grid_id', 'population_density']], on='grid_id', how='left')
|
||||
except FileNotFoundError:
|
||||
base['population_density'] = 0.0
|
||||
base['population_density'] = base['population_density'].fillna(0.0)
|
||||
return base
|
||||
|
||||
|
||||
def _risk_level_of(v: float) -> str:
|
||||
if v >= 0.7:
|
||||
return "high"
|
||||
if v >= 0.5:
|
||||
return "medium"
|
||||
if v >= 0.3:
|
||||
return "medium_low"
|
||||
return "low"
|
||||
|
||||
|
||||
@lru_cache(maxsize=32)
|
||||
def _grids_geojson_body(date: str, district: Optional[str], risk_level: Optional[str]) -> str:
|
||||
"""Build + serialize the grid GeoJSON once per (date, district, risk_level).
|
||||
|
||||
Risk is computed vectorised over the full grid (no per-row Python loop) and
|
||||
the highest-risk grids are returned as hotspots, so the map shows real
|
||||
high→low variation. Cached, so warm calls are near-instant. Raises
|
||||
FileNotFoundError if the core grid files are missing.
|
||||
"""
|
||||
merged = _grid_geojson_base()
|
||||
if district:
|
||||
merged = merged[merged['district_name'].str.contains(district.replace('区', ''), na=False, regex=False)]
|
||||
|
||||
cases_df = _load_parquet(PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet").copy()
|
||||
cases_df['date'] = pd.to_datetime(cases_df['date']).dt.strftime('%Y-%m-%d')
|
||||
cases_df = cases_df[cases_df['date'] == date]
|
||||
|
||||
# Normalise district case load to 0..1 across districts for this date.
|
||||
max_district_cases = float(cases_df['total_cases'].max()) if len(cases_df) else 0.0
|
||||
if max_district_cases <= 0:
|
||||
max_district_cases = 1.0
|
||||
|
||||
merged = merged.merge(cases_df[['district', 'total_cases']], left_on='district_name', right_on='district', how='left')
|
||||
merged = merged.copy()
|
||||
merged['total_cases'] = merged['total_cases'].fillna(0).astype(int)
|
||||
merged['center_lon'] = pd.to_numeric(merged['center_lon'], errors='coerce').fillna(0.0)
|
||||
merged['center_lat'] = pd.to_numeric(merged['center_lat'], errors='coerce').fillna(0.0)
|
||||
merged['population_density'] = merged['population_density'].fillna(0.0).clip(lower=0.0)
|
||||
|
||||
# Drop grids without coordinates.
|
||||
merged = merged[(merged['center_lon'] != 0.0) | (merged['center_lat'] != 0.0)]
|
||||
|
||||
# Demo risk model (vectorised): a district's relative case load × each grid's
|
||||
# own population exposure. Sparse cells stay low; densely-populated cells in
|
||||
# high-case districts rise toward 1.0.
|
||||
district_load = (merged['total_cases'] / max_district_cases).clip(upper=1.0)
|
||||
pop_factor = (merged['population_density'] / 50.0).clip(upper=1.0)
|
||||
merged['risk_value'] = (0.1 + 0.85 * district_load * pop_factor).clip(upper=1.0).round(3)
|
||||
|
||||
# Show the highest-risk grids (hotspots), not arbitrary cells.
|
||||
merged = merged.nlargest(10000, 'risk_value')
|
||||
|
||||
features = []
|
||||
for rec in merged.to_dict('records'):
|
||||
rv = float(rec['risk_value'])
|
||||
lvl = _risk_level_of(rv)
|
||||
if risk_level and lvl != risk_level:
|
||||
continue
|
||||
name = rec.get('district_name')
|
||||
if not isinstance(name, str):
|
||||
name = "未知"
|
||||
lon = round(float(rec['center_lon']), 6)
|
||||
lat = round(float(rec['center_lat']), 6)
|
||||
features.append({
|
||||
"type": "Feature",
|
||||
"geometry": {"type": "Point", "coordinates": [lon, lat]},
|
||||
"properties": {
|
||||
"grid_id": str(rec.get('grid_id', '')),
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
"district": name,
|
||||
"total_cases": int(rec.get('total_cases', 0)),
|
||||
"population_density": round(float(rec.get('population_density', 0.0)), 2),
|
||||
"risk_value": rv,
|
||||
"risk_level": lvl,
|
||||
}
|
||||
})
|
||||
|
||||
return GridGeoJSONResponse(
|
||||
type="FeatureCollection",
|
||||
features=features,
|
||||
timestamp=datetime.now().isoformat(),
|
||||
).model_dump_json()
|
||||
|
||||
|
||||
@router.get("/grids/geojson", response_model=GridGeoJSONResponse)
|
||||
async def get_grids_geojson(
|
||||
date: str = Query(..., description="Date (YYYY-MM-DD)"),
|
||||
district: Optional[str] = Query(None, description="Filter by district"),
|
||||
risk_level: Optional[str] = Query(None, description="Filter by risk level"),
|
||||
):
|
||||
"""
|
||||
Get grid data as GeoJSON for map visualization.
|
||||
"""
|
||||
import pandas as pd
|
||||
|
||||
"""Get grid data as GeoJSON for map visualization (cached per query)."""
|
||||
try:
|
||||
grid_df = _load_parquet(PROJECT_ROOT / "processed" / "grid_100m_index.parquet")
|
||||
# Offload the parquet merges + vectorised compute to a thread so the
|
||||
# cold-cache build doesn't block the event loop.
|
||||
body = await asyncio.to_thread(_grids_geojson_body, date, district, risk_level)
|
||||
except FileNotFoundError:
|
||||
return GridGeoJSONResponse(type="FeatureCollection", features=[], timestamp=datetime.now().isoformat())
|
||||
|
||||
try:
|
||||
district_map = _load_parquet(PROJECT_ROOT / "processed" / "grid_district_mapping.parquet")
|
||||
except FileNotFoundError:
|
||||
return GridGeoJSONResponse(type="FeatureCollection", features=[], timestamp=datetime.now().isoformat())
|
||||
|
||||
merged = grid_df.merge(district_map, on='grid_id', how='left')
|
||||
|
||||
if district:
|
||||
merged = merged[merged['district_name'].str.contains(district.replace('区', ''), na=False, regex=False)]
|
||||
|
||||
try:
|
||||
cases_df = _load_parquet(PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet")
|
||||
except FileNotFoundError:
|
||||
return GridGeoJSONResponse(type="FeatureCollection", features=[], timestamp=datetime.now().isoformat())
|
||||
cases_df['date'] = pd.to_datetime(cases_df['date']).dt.strftime('%Y-%m-%d')
|
||||
|
||||
cases_df = cases_df[cases_df['date'] == date]
|
||||
|
||||
merged = merged.merge(cases_df, left_on='district_name', right_on='district', how='left')
|
||||
merged['total_cases'] = merged['total_cases'].fillna(0).astype(int)
|
||||
|
||||
def safe_float(val, default=0.0):
|
||||
try:
|
||||
v = float(val)
|
||||
return default if math.isnan(v) or math.isinf(v) else v
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
def sanitize(obj):
|
||||
"""Replace NaN/Inf with None for JSON serialization."""
|
||||
if isinstance(obj, float):
|
||||
if math.isnan(obj) or math.isinf(obj):
|
||||
return None
|
||||
return obj
|
||||
if isinstance(obj, dict):
|
||||
return {k: sanitize(v) for k, v in obj.items()}
|
||||
if isinstance(obj, list):
|
||||
return [sanitize(v) for v in obj]
|
||||
return obj
|
||||
|
||||
features = []
|
||||
for _, row in merged.iterrows():
|
||||
lon = safe_float(row.get('center_lon'))
|
||||
lat = safe_float(row.get('center_lat'))
|
||||
if lon == 0.0 and lat == 0.0:
|
||||
continue
|
||||
|
||||
# MVP: Simple risk calculation based on cases and population density
|
||||
total_cases = safe_float(row.get('total_cases', 0), 0)
|
||||
total_cases = int(total_cases)
|
||||
pop_density = safe_float(row.get('population_density', 0))
|
||||
|
||||
# Risk formula: cases per 10k population + baseline
|
||||
risk_value = min(1.0, (total_cases / max(pop_density, 1)) * 10 + 0.1)
|
||||
|
||||
if risk_value >= 0.7:
|
||||
risk_level = "high"
|
||||
elif risk_value >= 0.5:
|
||||
risk_level = "medium"
|
||||
elif risk_value >= 0.3:
|
||||
risk_level = "medium_low"
|
||||
else:
|
||||
risk_level = "low"
|
||||
|
||||
district = row.get('district_name')
|
||||
if isinstance(district, float) and (math.isnan(district) or math.isinf(district)):
|
||||
district = "未知"
|
||||
|
||||
feature = {
|
||||
"type": "Feature",
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [lon, lat]
|
||||
},
|
||||
"properties": {
|
||||
"grid_id": str(row.get('grid_id', '')),
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
"district": district,
|
||||
"total_cases": total_cases,
|
||||
"population_density": pop_density,
|
||||
"risk_value": round(risk_value, 3),
|
||||
"risk_level": risk_level,
|
||||
}
|
||||
}
|
||||
features.append(feature)
|
||||
|
||||
if len(features) >= 10000:
|
||||
break
|
||||
|
||||
return GridGeoJSONResponse(
|
||||
type="FeatureCollection",
|
||||
features=features,
|
||||
timestamp=datetime.now().isoformat(),
|
||||
)
|
||||
return Response(content=body, media_type="application/json")
|
||||
|
||||
|
||||
@router.post("/predict/multi-day", response_model=MultiDayPredictionResponse)
|
||||
@@ -278,9 +307,9 @@ async def predict_multi_day(request: MultiDayPredictionRequest):
|
||||
]
|
||||
|
||||
for _, row in features_df.iterrows():
|
||||
risk_1d = float(row.get('risk_1day', 0.5))
|
||||
risk_3d = float(row.get('risk_3day', 0.5))
|
||||
risk_7d = float(row.get('risk_7day', 0.5))
|
||||
risk_1d = float(row.get('risk_1day', 0.5)) # type: ignore[arg-type]
|
||||
risk_3d = float(row.get('risk_3day', 0.5)) # type: ignore[arg-type]
|
||||
risk_7d = float(row.get('risk_7day', 0.5)) # type: ignore[arg-type]
|
||||
|
||||
if risk_1d >= 0.8:
|
||||
risk_level = "high"
|
||||
@@ -323,14 +352,8 @@ async def predict_multi_day(request: MultiDayPredictionRequest):
|
||||
)
|
||||
|
||||
|
||||
@router.get("/grids/{grid_id}/history")
|
||||
async def get_grid_history(
|
||||
grid_id: str,
|
||||
days: int = Query(30, ge=1, le=365, description="Number of days of history"),
|
||||
):
|
||||
"""
|
||||
Get historical data for a specific grid cell.
|
||||
"""
|
||||
def _compute_grid_history(grid_id: str, days: int) -> dict:
|
||||
"""Heavy synchronous parquet reads + per-row loop (called in thread pool)."""
|
||||
import pandas as pd
|
||||
|
||||
district_map = _load_parquet(PROJECT_ROOT / "processed" / "grid_district_mapping.parquet")
|
||||
@@ -367,4 +390,18 @@ async def get_grid_history(
|
||||
"district": district,
|
||||
"history": history,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get("/grids/{grid_id}/history")
|
||||
async def get_grid_history(
|
||||
grid_id: str,
|
||||
days: int = Query(30, ge=1, le=365, description="Number of days of history"),
|
||||
):
|
||||
"""
|
||||
Get historical data for a specific grid cell.
|
||||
|
||||
Parquet reads + aggregation run in a thread pool to avoid blocking the
|
||||
async event loop.
|
||||
"""
|
||||
return await asyncio.to_thread(_compute_grid_history, grid_id, days)
|
||||
|
||||
@@ -4,6 +4,7 @@ Provides comprehensive analytics, trends, hotspots, and correlations
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from datetime import datetime, timedelta
|
||||
from functools import lru_cache
|
||||
import random
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -20,6 +21,13 @@ from models import (
|
||||
)
|
||||
from utils.date_helpers import get_latest_date
|
||||
from utils.geojson import parse_geojson_file, load_districts
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def _cached_parquet(path_str: str):
|
||||
"""Load a parquet file once and reuse it (read-only) across requests."""
|
||||
import pandas as pd
|
||||
return pd.read_parquet(path_str)
|
||||
from utils.geo import point_in_polygon
|
||||
from utils.risk import calculate_trend as calculate_trend_direction
|
||||
|
||||
@@ -41,6 +49,7 @@ class InsightCardResponse(BaseModel):
|
||||
warning_count: int
|
||||
info_count: int
|
||||
success_count: int
|
||||
danger_count: int
|
||||
cards: list[InsightCardItem]
|
||||
|
||||
|
||||
@@ -482,7 +491,7 @@ async def get_insights_cards():
|
||||
|
||||
cases_path = PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet"
|
||||
if cases_path.exists():
|
||||
cases_df = pd.read_parquet(cases_path)
|
||||
cases_df = _cached_parquet(str(cases_path))
|
||||
latest_case_date = cases_df["date"].max()
|
||||
latest_cases = cases_df[cases_df["date"] == latest_case_date].copy()
|
||||
latest_cases["base_district"] = latest_cases["district"].str.replace("区", "")
|
||||
@@ -533,7 +542,7 @@ async def get_insights_cards():
|
||||
grids_df["col"] = ((grids_df["longitude"] - MIN_LON) / STEP).astype(int)
|
||||
grids_df["grid_id"] = "r" + grids_df["row"].astype(str) + "_c" + grids_df["col"].astype(str)
|
||||
|
||||
mapping = pd.read_parquet(mapping_path)
|
||||
mapping = _cached_parquet(str(mapping_path))
|
||||
merged = grids_df.merge(mapping, on="grid_id", how="inner")
|
||||
|
||||
if len(merged) > 0:
|
||||
@@ -574,7 +583,7 @@ async def get_insights_cards():
|
||||
|
||||
weather_path = PROJECT_ROOT / "processed" / "weather" / "station_daily_2022.parquet"
|
||||
if weather_path.exists():
|
||||
weather_df = pd.read_parquet(weather_path)
|
||||
weather_df = _cached_parquet(str(weather_path))
|
||||
daily_wx = weather_df.groupby("date").agg(
|
||||
AQI=("AQI", "mean"), PM25=("PM25", "mean"), PM10=("PM10", "mean"),
|
||||
).reset_index()
|
||||
@@ -664,11 +673,13 @@ async def get_insights_cards():
|
||||
warning_count = sum(1 for c in cards if c.type == "warning")
|
||||
info_count = sum(1 for c in cards if c.type == "info")
|
||||
success_count = sum(1 for c in cards if c.type == "success")
|
||||
danger_count = sum(1 for c in cards if c.type == "danger")
|
||||
|
||||
return InsightCardResponse(
|
||||
total_insights=len(cards),
|
||||
warning_count=warning_count,
|
||||
info_count=info_count,
|
||||
success_count=success_count,
|
||||
danger_count=danger_count,
|
||||
cards=cards,
|
||||
)
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
Router for CBPOA risk assessment endpoints
|
||||
Reads from GeoJSON files in outputs/daily/ directory
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Path, Query
|
||||
from fastapi import APIRouter, HTTPException, Path, Query, Response
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Annotated, List, Literal
|
||||
import asyncio
|
||||
import json
|
||||
import glob
|
||||
import re
|
||||
@@ -28,8 +28,12 @@ from utils.risk import risk_value_to_level
|
||||
|
||||
router = APIRouter(prefix="/api/risk", tags=["risk"])
|
||||
|
||||
# Cells with risk at/below this are culled from LOD responses; the frontend
|
||||
# discards risk==0 cells anyway, so emitting them only bloats the payload.
|
||||
LOD_RISK_EPSILON = 1e-6
|
||||
|
||||
@lru_cache(maxsize=3)
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def get_risk_data(date: str) -> tuple[list[list], dict]:
|
||||
filepath = DATA_DIR / f"risk_{date}.geojson"
|
||||
if not filepath.exists():
|
||||
@@ -55,7 +59,7 @@ def get_risk_data(date: str) -> tuple[list[list], dict]:
|
||||
return grids, grid_map
|
||||
|
||||
|
||||
@lru_cache(maxsize=3)
|
||||
@lru_cache(maxsize=8)
|
||||
def get_kdtree_and_risks(date: str):
|
||||
grids, _ = get_risk_data(date)
|
||||
if not grids:
|
||||
@@ -70,6 +74,8 @@ def generate_lod_grid(zoom: int, forecast_day: Literal[1, 3, 7] = 1,
|
||||
bounds: dict | None = None) -> dict:
|
||||
date = get_latest_date()
|
||||
kdtree, risk_values = get_kdtree_and_risks(date)
|
||||
if kdtree is None or risk_values is None:
|
||||
return {"lod": "empty", "zoom": zoom, "aggregate": 1, "grids": [], "total_count": 0, "bounds": bounds or WUHAN_BOUNDS}
|
||||
|
||||
risk_idx = forecast_day - 1
|
||||
|
||||
@@ -117,7 +123,12 @@ def generate_lod_grid(zoom: int, forecast_day: Literal[1, 3, 7] = 1,
|
||||
risks = risk_array[indices]
|
||||
risks[dists > LOD_MAX_RADIUS] = 0.0
|
||||
|
||||
lod_grids = np.column_stack([lat_grid.ravel(), lon_grid.ravel(), risks]).tolist()
|
||||
# Drop zero-risk cells (incl. out-of-radius). The frontend discards
|
||||
# them anyway, so culling here shrinks the payload substantially.
|
||||
flat_lat = lat_grid.ravel()
|
||||
flat_lon = lon_grid.ravel()
|
||||
keep = risks > LOD_RISK_EPSILON
|
||||
lod_grids = np.column_stack([flat_lat[keep], flat_lon[keep], risks[keep]]).tolist()
|
||||
|
||||
return {
|
||||
"lod": lod_name,
|
||||
@@ -143,8 +154,9 @@ def generate_lod_grid(zoom: int, forecast_day: Literal[1, 3, 7] = 1,
|
||||
cell_lat = (WUHAN_BOUNDS["max_lat"] - WUHAN_BOUNDS["min_lat"]) / lat_count
|
||||
cell_lon = (WUHAN_BOUNDS["max_lon"] - WUHAN_BOUNDS["min_lon"]) / lon_count
|
||||
|
||||
# Apply viewport bounds filtering for zoom >= 10
|
||||
if bounds and zoom >= 10:
|
||||
# Apply viewport bounds filtering at all zoom levels when bounds are given,
|
||||
# so even the coarse zoom<=9 lod1 grid is clipped to the viewport.
|
||||
if bounds:
|
||||
b_min_lat = max(bounds["min_lat"], WUHAN_BOUNDS["min_lat"])
|
||||
b_max_lat = min(bounds["max_lat"], WUHAN_BOUNDS["max_lat"])
|
||||
b_min_lon = max(bounds["min_lon"], WUHAN_BOUNDS["min_lon"])
|
||||
@@ -176,7 +188,11 @@ def generate_lod_grid(zoom: int, forecast_day: Literal[1, 3, 7] = 1,
|
||||
|
||||
risks[dists > LOD_MAX_RADIUS] = 0.0
|
||||
|
||||
lod_grids = np.column_stack([lat_grid.ravel(), lon_grid.ravel(), risks]).tolist()
|
||||
# Drop zero-risk cells (incl. out-of-radius) before serialization.
|
||||
flat_lat = lat_grid.ravel()
|
||||
flat_lon = lon_grid.ravel()
|
||||
keep = risks > LOD_RISK_EPSILON
|
||||
lod_grids = np.column_stack([flat_lat[keep], flat_lon[keep], risks[keep]]).tolist()
|
||||
|
||||
return {
|
||||
"lod": lod_name,
|
||||
@@ -188,6 +204,21 @@ def generate_lod_grid(zoom: int, forecast_day: Literal[1, 3, 7] = 1,
|
||||
}
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def _risk_map_body(date: str) -> str:
|
||||
"""Serialize the full ~140k-grid risk map for a date once (cached).
|
||||
|
||||
The 140k-element response costs ~0.5s of Pydantic validation + JSON
|
||||
serialization; caching the serialized body makes warm calls ~instant.
|
||||
"""
|
||||
grids = parse_geojson_file(DATA_DIR / f"risk_{date}.geojson")
|
||||
return RiskMapResponse(
|
||||
grids=grids,
|
||||
total_count=len(grids),
|
||||
timestamp=datetime.now().isoformat(),
|
||||
).model_dump_json()
|
||||
|
||||
|
||||
@router.get("/map", response_model=RiskMapResponse)
|
||||
async def get_risk_map(date: str | None = None):
|
||||
if date is None:
|
||||
@@ -197,13 +228,7 @@ async def get_risk_map(date: str | None = None):
|
||||
if not filepath.exists():
|
||||
raise HTTPException(status_code=404, detail=f"No data found for date {date}")
|
||||
|
||||
grids = parse_geojson_file(filepath)
|
||||
|
||||
return RiskMapResponse(
|
||||
grids=grids,
|
||||
total_count=len(grids),
|
||||
timestamp=datetime.now().isoformat()
|
||||
)
|
||||
return Response(content=_risk_map_body(date), media_type="application/json")
|
||||
|
||||
|
||||
@router.get("/current", response_model=RiskMapResponse)
|
||||
@@ -214,28 +239,7 @@ async def get_current_risk():
|
||||
if not filepath.exists():
|
||||
raise HTTPException(status_code=404, detail=f"No data found for date {date}")
|
||||
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
geojson = json.load(f)
|
||||
|
||||
grids: list[dict[str, str | float]] = []
|
||||
for feature in geojson.get("features", []):
|
||||
props = feature.get("properties", {})
|
||||
coords = feature.get("geometry", {}).get("coordinates", [0, 0])
|
||||
|
||||
risk_value = props.get("risk_1d", 0)
|
||||
grids.append({
|
||||
"grid_id": str(props.get("node_id", "")),
|
||||
"latitude": props.get("lat", coords[1] if len(coords) > 1 else 0),
|
||||
"longitude": props.get("lon", coords[0] if len(coords) > 0 else 0),
|
||||
"risk_value": risk_value,
|
||||
"risk_level": risk_value_to_level(risk_value),
|
||||
})
|
||||
|
||||
return RiskMapResponse(
|
||||
grids=grids,
|
||||
total_count=len(grids),
|
||||
timestamp=datetime.now().isoformat()
|
||||
)
|
||||
return Response(content=_risk_map_body(date), media_type="application/json")
|
||||
|
||||
|
||||
@router.get("/precomputed", response_model=RiskMapResponse)
|
||||
@@ -247,7 +251,7 @@ async def get_precomputed_risk():
|
||||
|
||||
grids = []
|
||||
for _, row in df.iterrows():
|
||||
risk_index = float(row.get('risk_index', 0))
|
||||
risk_index = float(row.get('risk_index', 0)) # type: ignore[arg-type]
|
||||
grids.append({
|
||||
"grid_id": str(row['grid_id']),
|
||||
"latitude": float(row['center_y']),
|
||||
@@ -263,6 +267,18 @@ async def get_precomputed_risk():
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def _fullgrid_body(date: str) -> str:
|
||||
"""Serialize the compact full-grid payload once (cached)."""
|
||||
grids, _ = get_risk_data(date)
|
||||
return json.dumps({
|
||||
"date": date,
|
||||
"total_count": len(grids),
|
||||
"columns": ["lat", "lon", "risk_1d", "risk_3d", "risk_7d"],
|
||||
"grids": grids,
|
||||
})
|
||||
|
||||
|
||||
@router.get("/fullgrid")
|
||||
async def get_full_grid(date: str | None = None):
|
||||
if date is None:
|
||||
@@ -272,26 +288,7 @@ async def get_full_grid(date: str | None = None):
|
||||
if not filepath.exists():
|
||||
raise HTTPException(status_code=404, detail=f"No data found for date {date}")
|
||||
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
geojson = json.load(f)
|
||||
|
||||
grids = []
|
||||
for feature in geojson.get("features", []):
|
||||
props = feature.get("properties", {})
|
||||
grids.append([
|
||||
round(props.get("lat", 0), 6),
|
||||
round(props.get("lon", 0), 6),
|
||||
round(props.get("risk_1d", 0), 4),
|
||||
round(props.get("risk_3d", 0), 4),
|
||||
round(props.get("risk_7d", 0), 4),
|
||||
])
|
||||
|
||||
return {
|
||||
"date": date,
|
||||
"total_count": len(grids),
|
||||
"columns": ["lat", "lon", "risk_1d", "risk_3d", "risk_7d"],
|
||||
"grids": grids,
|
||||
}
|
||||
return Response(content=_fullgrid_body(date), media_type="application/json")
|
||||
|
||||
|
||||
@router.get("/lod-grid")
|
||||
@@ -366,50 +363,44 @@ async def get_lod_tile(
|
||||
|
||||
|
||||
@router.get("/history/{grid_id}", response_model=RiskHistoryResponse)
|
||||
async def get_risk_history(grid_id: str, days: int = 7):
|
||||
async def get_risk_history(grid_id: str, days: Annotated[int, Query(ge=1, le=30)] = 7):
|
||||
date = get_latest_date()
|
||||
filepath = DATA_DIR / f"risk_{date}.geojson"
|
||||
|
||||
if not filepath.exists():
|
||||
raise HTTPException(status_code=404, detail=f"No data found for date {date}")
|
||||
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
geojson = json.load(f)
|
||||
# Use cached parsed grids + cached KDTree instead of re-reading the ~44MB file.
|
||||
grids = parse_geojson_file(filepath)
|
||||
if not grids:
|
||||
raise HTTPException(status_code=404, detail=f"No data found for date {date}")
|
||||
|
||||
target_feature = None
|
||||
for feature in geojson.get("features", []):
|
||||
props = feature.get("properties", {})
|
||||
if str(props.get("node_id", "")) == grid_id:
|
||||
target_feature = feature
|
||||
base_risk = None
|
||||
# Exact node_id match
|
||||
for g in grids:
|
||||
if g["grid_id"] == grid_id:
|
||||
base_risk = g["risk_value"]
|
||||
break
|
||||
|
||||
if not target_feature and re.match(r'r\d+_c\d+', grid_id):
|
||||
# Fallback: nearest grid for r{row}_c{col} ids
|
||||
if base_risk is None and re.match(r'r\d+_c\d+', grid_id):
|
||||
parts = grid_id.replace("r", "").split("_c")
|
||||
row, col = int(parts[0]), int(parts[1])
|
||||
center_lat = WUHAN_BOUNDS["min_lat"] + (row + 0.5) * LAT_STEP
|
||||
center_lon = WUHAN_BOUNDS["min_lon"] + (col + 0.5) * LON_STEP
|
||||
points = []
|
||||
features_list = []
|
||||
for feature in geojson.get("features", []):
|
||||
props = feature.get("properties", {})
|
||||
points.append([props.get("lat", 0), props.get("lon", 0)])
|
||||
features_list.append(feature)
|
||||
if points:
|
||||
tree = KDTree(points)
|
||||
_, idx = tree.query([center_lat, center_lon])
|
||||
target_feature = features_list[idx]
|
||||
kdtree, risk_values = get_kdtree_and_risks(date)
|
||||
if kdtree is not None and risk_values is not None:
|
||||
_, idx = kdtree.query([center_lat, center_lon])
|
||||
base_risk = risk_values[idx][0] # risk_1d
|
||||
|
||||
if not target_feature:
|
||||
if base_risk is None:
|
||||
raise HTTPException(status_code=404, detail=f"Grid {grid_id} not found")
|
||||
|
||||
props = target_feature.get("properties", {})
|
||||
base_risk = props.get("risk_1d", 0)
|
||||
|
||||
history = []
|
||||
for i in range(days):
|
||||
history.append({
|
||||
"date": (datetime.now() - timedelta(days=i)).strftime("%Y-%m-%d"),
|
||||
"risk_value": base_risk * (1 - i * 0.05)
|
||||
"risk_value": max(0.0, base_risk * (1 - i * 0.05))
|
||||
})
|
||||
|
||||
return RiskHistoryResponse(
|
||||
@@ -432,7 +423,7 @@ async def get_forecast_map(
|
||||
|
||||
if not filepath.exists():
|
||||
# Fall back to current data
|
||||
return await get_current_risk_map()
|
||||
return await get_current_risk()
|
||||
|
||||
grids = parse_geojson_file(filepath)
|
||||
if not grids:
|
||||
@@ -495,3 +486,71 @@ async def get_stats(date: str | None = None):
|
||||
high_risk_count=distribution["high"],
|
||||
timestamp=datetime.now().isoformat()
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Raster LOD tiles — full-Wuhan 100m risk grid served as XYZ map tiles.
|
||||
# The browser loads PNG images (cached by Leaflet); no per-cell JS work.
|
||||
# See utils/risk_raster.py for the rendering engine.
|
||||
# ---------------------------------------------------------------------------
|
||||
from utils import risk_raster # noqa: E402 (kept local to this feature block)
|
||||
|
||||
_VALID_DAYS = {1, 3, 7}
|
||||
|
||||
|
||||
@router.get("/tiles/{z}/{x}/{y}.png")
|
||||
async def get_risk_tile(
|
||||
z: Annotated[int, Path(ge=0, le=22)],
|
||||
x: Annotated[int, Path(ge=0)],
|
||||
y: Annotated[int, Path(ge=0)],
|
||||
date: str | None = None,
|
||||
day: Annotated[int, Query()] = 1,
|
||||
):
|
||||
"""Render one web-mercator risk tile (256x256 PNG) for the 100m grid."""
|
||||
if day not in _VALID_DAYS:
|
||||
raise HTTPException(status_code=400, detail="day must be 1, 3, or 7")
|
||||
if date is None:
|
||||
date = get_latest_date()
|
||||
if not (DATA_DIR / f"risk_{date}.geojson").exists():
|
||||
raise HTTPException(status_code=404, detail=f"No data found for date {date}")
|
||||
|
||||
png = await asyncio.to_thread(risk_raster.render_tile, z, x, y, date, day)
|
||||
return Response(
|
||||
content=png,
|
||||
media_type="image/png",
|
||||
headers={"Cache-Control": "public, max-age=3600"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/grid-stats")
|
||||
async def get_risk_grid_stats(
|
||||
date: str | None = None,
|
||||
day: Annotated[int, Query()] = 1,
|
||||
):
|
||||
"""Aggregate stats over the in-boundary 100m grid (cell count / avg / max / high)."""
|
||||
if day not in _VALID_DAYS:
|
||||
raise HTTPException(status_code=400, detail="day must be 1, 3, or 7")
|
||||
if date is None:
|
||||
date = get_latest_date()
|
||||
if not (DATA_DIR / f"risk_{date}.geojson").exists():
|
||||
raise HTTPException(status_code=404, detail=f"No data found for date {date}")
|
||||
|
||||
return await asyncio.to_thread(risk_raster.grid_stats, date, day)
|
||||
|
||||
|
||||
@router.get("/cell")
|
||||
async def get_risk_cell(
|
||||
lat: Annotated[float, Query(ge=-90, le=90)],
|
||||
lon: Annotated[float, Query(ge=-180, le=180)],
|
||||
date: str | None = None,
|
||||
day: Annotated[int, Query()] = 1,
|
||||
):
|
||||
"""Risk at the 100m cell containing (lat, lon) — used for click-to-inspect."""
|
||||
if day not in _VALID_DAYS:
|
||||
raise HTTPException(status_code=400, detail="day must be 1, 3, or 7")
|
||||
if date is None:
|
||||
date = get_latest_date()
|
||||
if not (DATA_DIR / f"risk_{date}.geojson").exists():
|
||||
raise HTTPException(status_code=404, detail=f"No data found for date {date}")
|
||||
|
||||
return await asyncio.to_thread(risk_raster.query_cell, lat, lon, date, day)
|
||||
|
||||
46
backend/tests/CLAUDE.md
Normal file
46
backend/tests/CLAUDE.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Backend Tests
|
||||
|
||||
## Framework
|
||||
|
||||
pytest + FastAPI `TestClient` (sync, in-process). No database mocking needed — tests hit real endpoints with real data files.
|
||||
|
||||
## Structure
|
||||
|
||||
- `conftest.py` — shared fixtures (`client`, `auth_headers`, test data)
|
||||
- `test_api.py` — endpoint integration tests, organized by router class
|
||||
- `test_auth.py` — authentication flow tests
|
||||
- `test_error_handling.py` — edge cases, error responses
|
||||
- `test_utils.py` — pure utility function tests
|
||||
|
||||
## Patterns
|
||||
|
||||
- Tests organized in classes: `class TestRiskEndpoints:`
|
||||
- One test method per scenario: `test_current_risk_map()`, `test_risk_map_with_date()`
|
||||
- Fixture naming: `client: TestClient`, `auth_headers: dict`
|
||||
- Assert response status, then JSON structure, then field types/values
|
||||
|
||||
```python
|
||||
class TestSomeRouter:
|
||||
def test_something(self, client: TestClient):
|
||||
resp = client.get("/api/some/endpoint")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "key" in data
|
||||
assert isinstance(data["key"], list)
|
||||
```
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
source venv/bin/activate
|
||||
pytest tests/ -v
|
||||
pytest tests/test_api.py -v -k "test_risk"
|
||||
```
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
- Don't mock endpoints you can test with real data
|
||||
- Don't hardcode test dates that will go stale
|
||||
- Don't skip assertions on response structure just because status is 200
|
||||
- Don't share mutable state between test classes — use fixtures
|
||||
@@ -37,8 +37,11 @@ class TestMissingResources:
|
||||
|
||||
class TestInvalidForecastDay:
|
||||
def test_forecast_out_of_range(self, client: TestClient):
|
||||
# days=0 violates the ge=1 bound on /forecast/{days}, so FastAPI returns 422.
|
||||
# (This previously returned 200 because `Path` was shadowed by `pathlib.Path`,
|
||||
# silently disabling validation — fixed by the risk.py import correction.)
|
||||
resp = client.get("/api/risk/forecast/0")
|
||||
assert resp.status_code in (200, 404)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_forecast_too_large(self, client: TestClient):
|
||||
resp = client.get("/api/risk/forecast/999")
|
||||
|
||||
@@ -9,7 +9,7 @@ def point_in_polygon(lat: float, lon: float, polygon_coords: list) -> bool:
|
||||
return False
|
||||
|
||||
# MultiPolygon: check each polygon
|
||||
if isinstance(polygon_coords[0], list) and isinstance(polygon_coords[0][0], list):
|
||||
if isinstance(polygon_coords[0], list) and polygon_coords[0] and isinstance(polygon_coords[0][0], list):
|
||||
for polygon in polygon_coords:
|
||||
if polygon and isinstance(polygon[0], list):
|
||||
ring = polygon[0] if isinstance(polygon[0][0], list) else polygon
|
||||
@@ -25,6 +25,8 @@ def point_in_polygon(lat: float, lon: float, polygon_coords: list) -> bool:
|
||||
def point_in_ring(lat: float, lon: float, ring: list) -> bool:
|
||||
"""Ray casting algorithm for point-in-ring test."""
|
||||
n = len(ring)
|
||||
if n < 3:
|
||||
return False
|
||||
inside = False
|
||||
|
||||
x, y = lon, lat
|
||||
|
||||
@@ -13,7 +13,7 @@ from utils.risk import risk_value_to_level
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
@lru_cache(maxsize=16)
|
||||
def parse_geojson_file(filepath: Path) -> list[dict[str, Any]]:
|
||||
"""Parse GeoJSON file and extract grid data with standard fields."""
|
||||
try:
|
||||
|
||||
375
backend/utils/risk_raster.py
Normal file
375
backend/utils/risk_raster.py
Normal file
@@ -0,0 +1,375 @@
|
||||
"""
|
||||
Risk raster tile engine — renders the full-Wuhan 100m risk grid as XYZ map tiles.
|
||||
|
||||
Why this exists
|
||||
---------------
|
||||
The model emits risk at ~140k GCN nodes per day. The product needs to display this
|
||||
over the full Wuhan 100m grid (~1.5M in-boundary cells) with smooth LOD. Shipping
|
||||
that many cells to the browser as vectors is impossible, so we rasterize server-side:
|
||||
|
||||
1. Build a dense per-cell risk raster R[row, col] once per (date, forecast_day):
|
||||
scatter each node's risk onto its 100m cell (max per cell), then nearest-fill
|
||||
empty cells via a Euclidean distance transform (Voronoi over nodes, quantized
|
||||
to the 100m grid). Cells outside the Wuhan boundary are masked out.
|
||||
2. Build a max-pooled pyramid for clean LOD at low zoom.
|
||||
3. Render standard 256x256 web-mercator PNG tiles by sampling the pyramid level
|
||||
that matches the tile's zoom. Tiles are cached; the browser just loads images.
|
||||
|
||||
Coordinate conventions (calibrated from processed/grid_100m_index.parquet):
|
||||
lat = MIN_LAT + (row + 0.5) * LAT_STEP -> row 0 is SOUTH, row increases north
|
||||
lon = MIN_LON + (col + 0.5) * LON_STEP -> col 0 is WEST, col increases east
|
||||
Everything below maps lat/lon -> (row, col) the same way, so orientation is coherent
|
||||
end to end. Tile pixel py=0 is north (high lat -> high row); we build the RGBA array
|
||||
with py as the first axis so north ends up at the top of the PNG.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import math
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
from scipy.ndimage import distance_transform_edt
|
||||
|
||||
from config import DATA_DIR, WUHAN_BOUNDS, PROJECT_ROOT
|
||||
|
||||
# --- Grid definition (geographically-correct ~100m grid over the Wuhan bbox) ---
|
||||
MIN_LON = WUHAN_BOUNDS["min_lon"]
|
||||
MAX_LON = WUHAN_BOUNDS["max_lon"]
|
||||
MIN_LAT = WUHAN_BOUNDS["min_lat"]
|
||||
MAX_LAT = WUHAN_BOUNDS["max_lat"]
|
||||
|
||||
NROWS = 1550 # matches processed/grid_100m_with_dem_pop.parquet row extent
|
||||
NCOLS = 1336 # matches its col extent
|
||||
LAT_STEP = (MAX_LAT - MIN_LAT) / NROWS
|
||||
LON_STEP = (MAX_LON - MIN_LON) / NCOLS
|
||||
|
||||
BOUNDARY_GEOJSON = PROJECT_ROOT / "Datas" / "武汉市.geojson"
|
||||
|
||||
# Forecast-day -> property suffix on the risk geojson features.
|
||||
_DAY_TO_KEY = {1: "risk_1d", 3: "risk_3d", 7: "risk_7d"}
|
||||
|
||||
MAX_PYRAMID_LEVEL = 7 # full-res + 7 downsamples covers world zoom range
|
||||
TILE_PX = 256
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Affine helpers (lat/lon <-> grid row/col)
|
||||
# ----------------------------------------------------------------------------
|
||||
def latlon_to_rowcol(lat: float, lon: float) -> tuple[int, int]:
|
||||
row = int((lat - MIN_LAT) / LAT_STEP)
|
||||
col = int((lon - MIN_LON) / LON_STEP)
|
||||
row = max(0, min(NROWS - 1, row))
|
||||
col = max(0, min(NCOLS - 1, col))
|
||||
return row, col
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Boundary mask (rasterized once)
|
||||
# ----------------------------------------------------------------------------
|
||||
@lru_cache(maxsize=1)
|
||||
def _boundary_mask() -> np.ndarray:
|
||||
"""Boolean (NROWS, NCOLS) mask, True for cells inside the Wuhan boundary."""
|
||||
img = Image.new("1", (NCOLS, NROWS), 0)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
if not BOUNDARY_GEOJSON.exists():
|
||||
# No boundary file -> color the whole bbox rather than nothing.
|
||||
return np.ones((NROWS, NCOLS), dtype=bool)
|
||||
|
||||
with open(BOUNDARY_GEOJSON, "r", encoding="utf-8") as f:
|
||||
gj = json.load(f)
|
||||
|
||||
def _draw_ring(ring):
|
||||
pts = []
|
||||
for lon, lat in ring:
|
||||
col = (lon - MIN_LON) / LON_STEP
|
||||
row = (lat - MIN_LAT) / LAT_STEP
|
||||
pts.append((col, row))
|
||||
if len(pts) >= 3:
|
||||
draw.polygon(pts, fill=1)
|
||||
|
||||
def _walk(geom):
|
||||
gtype = geom.get("type")
|
||||
coords = geom.get("coordinates", [])
|
||||
if gtype == "Polygon":
|
||||
for ring in coords:
|
||||
_draw_ring(ring)
|
||||
elif gtype == "MultiPolygon":
|
||||
for poly in coords:
|
||||
for ring in poly:
|
||||
_draw_ring(ring)
|
||||
|
||||
if gj.get("type") == "FeatureCollection":
|
||||
for feat in gj.get("features", []):
|
||||
_walk(feat.get("geometry", {}))
|
||||
elif gj.get("type") == "Feature":
|
||||
_walk(gj.get("geometry", {}))
|
||||
else:
|
||||
_walk(gj)
|
||||
|
||||
return np.array(img, dtype=bool)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Node loading (cached per date)
|
||||
# ----------------------------------------------------------------------------
|
||||
@lru_cache(maxsize=8)
|
||||
def _load_nodes(date: str) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||
"""Return (rows, cols, risks[3]) arrays for all nodes on a date.
|
||||
|
||||
risks is shape (n_nodes, 3) for [risk_1d, risk_3d, risk_7d].
|
||||
"""
|
||||
filepath = DATA_DIR / f"risk_{date}.geojson"
|
||||
if not filepath.exists():
|
||||
raise FileNotFoundError(f"No risk data for date {date}")
|
||||
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
gj = json.load(f)
|
||||
|
||||
feats = gj.get("features", [])
|
||||
n = len(feats)
|
||||
rows = np.empty(n, dtype=np.int32)
|
||||
cols = np.empty(n, dtype=np.int32)
|
||||
risks = np.zeros((n, 3), dtype=np.float32)
|
||||
|
||||
for i, feat in enumerate(feats):
|
||||
p = feat.get("properties", {})
|
||||
lat = p.get("lat", 0.0)
|
||||
lon = p.get("lon", 0.0)
|
||||
r, c = latlon_to_rowcol(lat, lon)
|
||||
rows[i] = r
|
||||
cols[i] = c
|
||||
risks[i, 0] = p.get("risk_1d", 0.0)
|
||||
risks[i, 1] = p.get("risk_3d", 0.0)
|
||||
risks[i, 2] = p.get("risk_7d", 0.0)
|
||||
|
||||
return rows, cols, risks
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Risk raster + pyramid (cached per date+day)
|
||||
# ----------------------------------------------------------------------------
|
||||
def _maxpool2(a: np.ndarray) -> np.ndarray:
|
||||
"""Downsample by 2 taking the NaN-aware max of each 2x2 block."""
|
||||
h, w = a.shape
|
||||
h2, w2 = (h + 1) // 2, (w + 1) // 2
|
||||
out = np.full((h2, w2), np.nan, dtype=np.float32)
|
||||
# Pad to even dims with NaN so the reshape is clean.
|
||||
ph, pw = h2 * 2, w2 * 2
|
||||
pad = np.full((ph, pw), np.nan, dtype=np.float32)
|
||||
pad[:h, :w] = a
|
||||
blocks = pad.reshape(h2, 2, w2, 2)
|
||||
# np.nanmax over the 2x2 block axes; suppress all-NaN warnings.
|
||||
with np.errstate(invalid="ignore"):
|
||||
out = np.nanmax(blocks, axis=(1, 3))
|
||||
return out.astype(np.float32)
|
||||
|
||||
|
||||
@lru_cache(maxsize=6)
|
||||
def _risk_pyramid(date: str, day: int) -> tuple[np.ndarray, ...]:
|
||||
"""Build the nearest-filled, boundary-masked risk raster and its LOD pyramid.
|
||||
|
||||
Returns a tuple of arrays, level 0 = full res (NROWS, NCOLS), each subsequent
|
||||
level downsampled 2x. NaN marks "no data / outside boundary".
|
||||
"""
|
||||
if day not in _DAY_TO_KEY:
|
||||
raise ValueError(f"invalid forecast day {day}")
|
||||
day_idx = {1: 0, 3: 1, 7: 2}[day]
|
||||
|
||||
rows, cols, risks = _load_nodes(date)
|
||||
vals = risks[:, day_idx]
|
||||
|
||||
# Scatter nodes onto the grid, taking the max risk per cell.
|
||||
R = np.full((NROWS, NCOLS), -np.inf, dtype=np.float32)
|
||||
np.maximum.at(R, (rows, cols), vals)
|
||||
known = np.isfinite(R)
|
||||
|
||||
# Nearest-fill empty cells (Voronoi over nodes, quantized to the 100m grid).
|
||||
if known.any():
|
||||
idx = distance_transform_edt(~known, return_distances=False, return_indices=True)
|
||||
R = R[tuple(idx)]
|
||||
R = R.astype(np.float32)
|
||||
|
||||
# Mask out everything outside the Wuhan boundary.
|
||||
mask = _boundary_mask()
|
||||
R[~mask] = np.nan
|
||||
|
||||
pyramid = [R]
|
||||
for _ in range(MAX_PYRAMID_LEVEL):
|
||||
nxt = _maxpool2(pyramid[-1])
|
||||
pyramid.append(nxt)
|
||||
if nxt.shape[0] <= 2 or nxt.shape[1] <= 2:
|
||||
break
|
||||
return tuple(pyramid)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Colormap (risk 0..1 -> RGBA), built once as a 256-entry LUT
|
||||
# ----------------------------------------------------------------------------
|
||||
@lru_cache(maxsize=1)
|
||||
def _color_lut() -> np.ndarray:
|
||||
"""256x4 uint8 LUT. Green -> yellow -> orange -> red, alpha grows with risk.
|
||||
|
||||
Risk below ~0.25 is rendered transparent to keep the map readable.
|
||||
"""
|
||||
lut = np.zeros((256, 4), dtype=np.uint8)
|
||||
# control points: (risk, R, G, B)
|
||||
stops = [
|
||||
(0.00, 56, 176, 0), # green (low)
|
||||
(0.40, 250, 204, 21), # yellow (medium)
|
||||
(0.60, 249, 115, 22), # orange (high)
|
||||
(0.80, 239, 68, 68), # red (critical)
|
||||
(1.00, 153, 27, 27), # dark red (extreme)
|
||||
]
|
||||
xs = [s[0] for s in stops]
|
||||
for i in range(256):
|
||||
t = i / 255.0
|
||||
# piecewise-linear RGB interpolation
|
||||
for k in range(len(stops) - 1):
|
||||
if xs[k] <= t <= xs[k + 1]:
|
||||
f = (t - xs[k]) / (xs[k + 1] - xs[k] + 1e-9)
|
||||
r = stops[k][1] + f * (stops[k + 1][1] - stops[k][1])
|
||||
g = stops[k][2] + f * (stops[k + 1][2] - stops[k][2])
|
||||
b = stops[k][3] + f * (stops[k + 1][3] - stops[k][3])
|
||||
break
|
||||
else:
|
||||
r, g, b = stops[-1][1:]
|
||||
# alpha: transparent below 0.25, then ramp 90 -> 235
|
||||
if t < 0.25:
|
||||
a = 0.0
|
||||
else:
|
||||
a = 90 + (t - 0.25) / 0.75 * (235 - 90)
|
||||
lut[i] = (int(r), int(g), int(b), int(a))
|
||||
return lut
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Tile rendering
|
||||
# ----------------------------------------------------------------------------
|
||||
def _tile_pixel_latlon(z: int, x: int, y: int) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Return (lat[256,256], lon[256,256]) for each pixel center of a tile."""
|
||||
n = 2.0 ** z
|
||||
px = (np.arange(TILE_PX) + 0.5) / TILE_PX
|
||||
# longitude is linear in tile-x
|
||||
X = (x + px) / n
|
||||
lon = X * 360.0 - 180.0 # shape (256,)
|
||||
# latitude via inverse web-mercator (nonlinear in tile-y)
|
||||
Y = (y + px) / n
|
||||
lat = np.degrees(np.arctan(np.sinh(np.pi * (1.0 - 2.0 * Y)))) # shape (256,)
|
||||
lon2d = np.broadcast_to(lon, (TILE_PX, TILE_PX)) # varies along axis 1 (px)
|
||||
lat2d = np.broadcast_to(lat[:, None], (TILE_PX, TILE_PX)) # varies along axis 0 (py)
|
||||
return lat2d, lon2d
|
||||
|
||||
|
||||
def _level_for_zoom(z: int) -> int:
|
||||
"""Pick the pyramid level so ~1 source cell maps to ~1 screen pixel."""
|
||||
# meters/pixel at lat ~30.6: 156543.03 * cos(lat) / 2^z ; /100m per cell
|
||||
cells_per_px = (156543.03 * math.cos(math.radians(30.6)) / (2.0 ** z)) / 100.0
|
||||
if cells_per_px <= 1.0:
|
||||
return 0
|
||||
return max(0, min(MAX_PYRAMID_LEVEL, int(math.floor(math.log2(cells_per_px)))))
|
||||
|
||||
|
||||
def render_tile(z: int, x: int, y: int, date: str, day: int = 1) -> bytes:
|
||||
"""Render a single XYZ tile to PNG bytes. Fully transparent tiles return a
|
||||
tiny cached blank PNG. Result is cached per (z,x,y,date,day)."""
|
||||
return _render_tile_cached(z, x, y, date, day)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1024)
|
||||
def _render_tile_cached(z: int, x: int, y: int, date: str, day: int) -> bytes:
|
||||
pyramid = _risk_pyramid(date, day)
|
||||
level = _level_for_zoom(z)
|
||||
level = min(level, len(pyramid) - 1)
|
||||
R = pyramid[level]
|
||||
factor = 2 ** level
|
||||
lh, lw = R.shape
|
||||
|
||||
lat2d, lon2d = _tile_pixel_latlon(z, x, y)
|
||||
|
||||
# lat/lon -> full-res row/col -> level row/col
|
||||
row = ((lat2d - MIN_LAT) / LAT_STEP).astype(np.int32) // factor
|
||||
col = ((lon2d - MIN_LON) / LON_STEP).astype(np.int32) // factor
|
||||
|
||||
inside = (row >= 0) & (row < lh) & (col >= 0) & (col < lw)
|
||||
rc = np.clip(row, 0, lh - 1)
|
||||
cc = np.clip(col, 0, lw - 1)
|
||||
sampled = R[rc, cc] # (256,256) float32, NaN where no data
|
||||
valid = inside & np.isfinite(sampled)
|
||||
|
||||
# Map risk -> LUT index (NaN cells become 0 then are zeroed-out below)
|
||||
lut = _color_lut()
|
||||
safe = np.nan_to_num(sampled, nan=0.0)
|
||||
idx = np.clip((safe * 255.0), 0, 255).astype(np.uint8)
|
||||
rgba = lut[idx] # (256,256,4)
|
||||
rgba[~valid] = (0, 0, 0, 0) # transparent outside data/boundary
|
||||
|
||||
img = Image.fromarray(rgba, mode="RGBA")
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG", optimize=False)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Point query (for click-to-inspect)
|
||||
# ----------------------------------------------------------------------------
|
||||
@lru_cache(maxsize=16)
|
||||
def grid_stats(date: str, day: int = 1) -> dict:
|
||||
"""Lightweight aggregate stats over the in-boundary 100m grid for a date/day.
|
||||
|
||||
Computed from the cached raster, so this is cheap after the first tile build.
|
||||
Replaces the old heavy per-viewport LOD fetch the overlay used to do.
|
||||
"""
|
||||
R = _risk_pyramid(date, day)[0]
|
||||
finite = np.isfinite(R)
|
||||
n = int(finite.sum())
|
||||
if n == 0:
|
||||
return {"cell_count": 0, "avg_risk": 0.0, "max_risk": 0.0,
|
||||
"high_risk_count": 0, "forecast_day": day, "date": date}
|
||||
vals = R[finite]
|
||||
return {
|
||||
"cell_count": n,
|
||||
"avg_risk": round(float(vals.mean()), 4),
|
||||
"max_risk": round(float(vals.max()), 4),
|
||||
"high_risk_count": int((vals >= 0.8).sum()),
|
||||
"forecast_day": day,
|
||||
"date": date,
|
||||
}
|
||||
|
||||
|
||||
def query_cell(lat: float, lon: float, date: str, day: int = 1) -> dict:
|
||||
"""Return the 100m cell risk at a lat/lon for the given date.
|
||||
|
||||
Includes all three forecast horizons (1d/3d/7d) so the click panel can show
|
||||
them without a separate heavy grid fetch. `risk_value` is the requested day.
|
||||
"""
|
||||
row, col = latlon_to_rowcol(lat, lon)
|
||||
|
||||
def _sample(d: int) -> tuple[float, bool]:
|
||||
v = _risk_pyramid(date, d)[0][row, col]
|
||||
ok = bool(np.isfinite(v))
|
||||
return (round(float(v), 4) if ok else 0.0), ok
|
||||
|
||||
r1, in_b = _sample(1)
|
||||
r3, _ = _sample(3)
|
||||
r7, _ = _sample(7)
|
||||
current = {1: r1, 3: r3, 7: r7}[day]
|
||||
return {
|
||||
"grid_id": f"r{row}_c{col}",
|
||||
"row": row,
|
||||
"col": col,
|
||||
"center_lat": round(MIN_LAT + (row + 0.5) * LAT_STEP, 6),
|
||||
"center_lon": round(MIN_LON + (col + 0.5) * LON_STEP, 6),
|
||||
"risk_value": current,
|
||||
"risk_1d": r1,
|
||||
"risk_3d": r3,
|
||||
"risk_7d": r7,
|
||||
"in_boundary": in_b,
|
||||
"forecast_day": day,
|
||||
"date": date,
|
||||
}
|
||||
44
deploy/CLAUDE.md
Normal file
44
deploy/CLAUDE.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Deploy — Docker Compose
|
||||
|
||||
## Stack
|
||||
|
||||
- Docker Compose with 3 services: `api` (FastAPI), `frontend` (nginx/React), `mlflow`
|
||||
- Multi-stage Dockerfiles: build stage → production stage
|
||||
- Env vars via `.env` file (see `.env.example` for template)
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
deploy/
|
||||
docker-compose.yml # Main: api + frontend + PostgreSQL/PostGIS
|
||||
docker-compose.mlflow.yml # MLflow tracking server
|
||||
.env.example # Required env vars template
|
||||
backend/Dockerfile # FastAPI app image
|
||||
backend/.dockerignore
|
||||
frontend/Dockerfile # nginx serving built React app
|
||||
frontend/.dockerignore
|
||||
```
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
# Full stack
|
||||
docker compose -f deploy/docker-compose.yml up -d
|
||||
|
||||
# With MLflow
|
||||
docker compose -f deploy/docker-compose.yml -f deploy/docker-compose.mlflow.yml up -d
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
- Never commit `.env` — use `.env.example` as template
|
||||
- Dockerfiles use multi-stage builds to minimize image size
|
||||
- Frontend is built during Docker build, served by nginx
|
||||
- Backend runs uvicorn with `--host 0.0.0.0` inside container
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
- Don't hardcode ports in docker-compose — use env vars
|
||||
- Don't commit secrets to `.env.example` — placeholder values only
|
||||
- Don't run as root in Dockerfiles — create non-root user
|
||||
- Don't skip `.dockerignore` — keeps build context small
|
||||
@@ -10,6 +10,9 @@ const TrendAnalysis = lazy(() => import('@/pages/TrendAnalysis').then(m => ({ de
|
||||
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 })));
|
||||
const DemographicAnalysis = lazy(() => import('@/pages/DemographicAnalysis').then(m => ({ default: m.DemographicAnalysis })));
|
||||
const DiseaseAnalysis = lazy(() => import('@/pages/DiseaseAnalysis').then(m => ({ default: m.DiseaseAnalysis })));
|
||||
const EnvironmentalHealth = lazy(() => import('@/pages/EnvironmentalHealth').then(m => ({ default: m.EnvironmentalHealth })));
|
||||
|
||||
|
||||
interface Props {
|
||||
@@ -93,17 +96,17 @@ function App() {
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<div className="min-h-screen bg-bg-page">
|
||||
<div className="min-h-screen bg-bg-page flex flex-col">
|
||||
<TopNav onLogout={handleLogout} />
|
||||
|
||||
<div className="flex pt-[52px]">
|
||||
<div className="flex flex-1 pt-[52px]">
|
||||
<SideNav
|
||||
activePage={activePage}
|
||||
onPageChange={handlePageChange}
|
||||
alertCount={alerts.length}
|
||||
/>
|
||||
|
||||
<main className="flex-1 ml-[200px] p-5">
|
||||
<main className="flex-1 ml-[200px] p-5 min-w-0">
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
{activePage === 'monitoring' && <MonitoringDashboard />}
|
||||
{activePage === 'alerts' && <AlertsDashboard />}
|
||||
@@ -111,6 +114,9 @@ function App() {
|
||||
{activePage === 'district-comparison' && <DistrictComparison />}
|
||||
{activePage === 'insights' && <Insights />}
|
||||
{activePage === 'reports' && <ReportsCenter />}
|
||||
{activePage === 'demographics' && <DemographicAnalysis />}
|
||||
{activePage === 'disease' && <DiseaseAnalysis />}
|
||||
{activePage === 'environment' && <EnvironmentalHealth />}
|
||||
</Suspense>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -7,82 +7,97 @@ import { describe, it, expect } from 'vitest';
|
||||
describe('Component exports', () => {
|
||||
it('TopNav 可以被导入', async () => {
|
||||
const mod = await import('@/components/TopNav');
|
||||
expect(mod.default || mod.TopNav).toBeDefined();
|
||||
expect((mod as any).default || mod.TopNav).toBeDefined();
|
||||
});
|
||||
|
||||
it('SideNav 可以被导入', async () => {
|
||||
const mod = await import('@/components/SideNav');
|
||||
expect(mod.default || mod.SideNav).toBeDefined();
|
||||
expect((mod as any).default || mod.SideNav).toBeDefined();
|
||||
});
|
||||
|
||||
it('StatCard 可以被导入', async () => {
|
||||
const mod = await import('@/components/StatCard');
|
||||
expect(mod.default || mod.StatCard).toBeDefined();
|
||||
expect((mod as any).default || mod.StatCard).toBeDefined();
|
||||
});
|
||||
|
||||
it('ErrorBanner 可以被导入', async () => {
|
||||
const mod = await import('@/components/ErrorBanner');
|
||||
expect(mod.default || mod.ErrorBanner).toBeDefined();
|
||||
expect((mod as any).default || mod.ErrorBanner).toBeDefined();
|
||||
});
|
||||
|
||||
it('DiseaseFilter 可以被导入', async () => {
|
||||
const mod = await import('@/components/DiseaseFilter');
|
||||
expect(mod.default || mod.DiseaseFilter).toBeDefined();
|
||||
expect((mod as any).default || mod.DiseaseFilter).toBeDefined();
|
||||
});
|
||||
|
||||
it('ChatBot 可以被导入', async () => {
|
||||
const mod = await import('@/components/ChatBot');
|
||||
expect(mod.default || mod.ChatBot).toBeDefined();
|
||||
expect((mod as any).default || mod.ChatBot).toBeDefined();
|
||||
});
|
||||
|
||||
it('TimelinePlayer 可以被导入', async () => {
|
||||
const mod = await import('@/components/TimelinePlayer');
|
||||
expect(mod.default || mod.TimelinePlayer).toBeDefined();
|
||||
expect((mod as any).default || mod.TimelinePlayer).toBeDefined();
|
||||
});
|
||||
|
||||
it('StatisticalCharts 可以被导入', async () => {
|
||||
const mod = await import('@/components/StatisticalCharts');
|
||||
expect(mod.default || mod.StatisticalCharts).toBeDefined();
|
||||
expect((mod as any).default || mod.StatisticalCharts).toBeDefined();
|
||||
});
|
||||
|
||||
it('RiskMap 可以被导入', async () => {
|
||||
const mod = await import('@/components/RiskMap');
|
||||
expect(mod.default || mod.RiskMap).toBeDefined();
|
||||
expect((mod as any).default || mod.RiskMap).toBeDefined();
|
||||
});
|
||||
|
||||
it('AlertMap 可以被导入', async () => {
|
||||
const mod = await import('@/components/AlertMap');
|
||||
expect(mod.default || mod.AlertMap).toBeDefined();
|
||||
expect((mod as any).default || mod.AlertMap).toBeDefined();
|
||||
});
|
||||
|
||||
it('CaseLocationMap 可以被导入', async () => {
|
||||
const mod = await import('@/components/CaseLocationMap');
|
||||
expect(mod.default || mod.CaseLocationMap).toBeDefined();
|
||||
expect((mod as any).default || mod.CaseLocationMap).toBeDefined();
|
||||
});
|
||||
|
||||
it('CaseMap 可以被导入', async () => {
|
||||
const mod = await import('@/components/CaseMap');
|
||||
expect(mod.default || mod.CaseMap).toBeDefined();
|
||||
expect((mod as any).default || mod.CaseMap).toBeDefined();
|
||||
});
|
||||
|
||||
it('DistributionChart 可以被导入', async () => {
|
||||
const mod = await import('@/components/DistributionChart');
|
||||
expect(mod.default || mod.DistributionChart).toBeDefined();
|
||||
expect((mod as any).default || mod.DistributionChart).toBeDefined();
|
||||
});
|
||||
|
||||
it('GridStatsOverlay 可以被导入', async () => {
|
||||
const mod = await import('@/components/GridStatsOverlay');
|
||||
expect(mod.default || mod.GridStatsOverlay).toBeDefined();
|
||||
expect((mod as any).default || mod.GridStatsOverlay).toBeDefined();
|
||||
});
|
||||
|
||||
it('LodGridLayer 可以被导入', async () => {
|
||||
const mod = await import('@/components/LodGridLayer');
|
||||
expect(mod.default || mod.LodGridLayer).toBeDefined();
|
||||
expect((mod as any).default || mod.LodGridLayer).toBeDefined();
|
||||
});
|
||||
|
||||
it('AdminBreadcrumb 可以被导入', async () => {
|
||||
const mod = await import('@/components/AdminBreadcrumb');
|
||||
expect(mod.default || mod.AdminBreadcrumb).toBeDefined();
|
||||
expect((mod as any).default || mod.AdminBreadcrumb).toBeDefined();
|
||||
});
|
||||
|
||||
it('CalendarHeatmap 可以被导入', async () => {
|
||||
const mod = await import('@/components/CalendarHeatmap');
|
||||
expect((mod as any).default || mod.CalendarHeatmap).toBeDefined();
|
||||
});
|
||||
|
||||
it('MetricHeatmapTable 可以被导入', async () => {
|
||||
const mod = await import('@/components/MetricHeatmapTable');
|
||||
expect((mod as any).default || mod.MetricHeatmapTable).toBeDefined();
|
||||
});
|
||||
|
||||
it('AnomalyMarkers 可以被导入', async () => {
|
||||
const mod = await import('@/components/AnomalyMarkers');
|
||||
expect((mod as any).default || mod.AnomalyMarkers).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { useEffect, useRef, useState, useCallback, memo } from 'react';
|
||||
import L from 'leaflet';
|
||||
import { useRiskStore } from '@/stores';
|
||||
import { LodGridLayer } from '@/components/LodGridLayer';
|
||||
import { GridStatsOverlay } from '@/components/GridStatsOverlay';
|
||||
import { useLodGrid } from '@/hooks/useLodGrid';
|
||||
import type { Alert, GridRisk } from '@/types';
|
||||
import { riskApi } from '@/services/api';
|
||||
import type { RiskGridStats } from '@/services/api';
|
||||
import type { Alert } from '@/types';
|
||||
|
||||
export interface CellInfo {
|
||||
lat: number;
|
||||
lon: number;
|
||||
risk: number;
|
||||
grid_id: string;
|
||||
risk_1d: number;
|
||||
risk_3d: number;
|
||||
risk_7d: number;
|
||||
nearestAlertId: string | null;
|
||||
nearestAlertDist: number;
|
||||
}
|
||||
@@ -22,16 +25,16 @@ interface AlertMapProps {
|
||||
showAlertMarkers?: boolean;
|
||||
showGrid?: boolean;
|
||||
filteredAlerts?: Alert[];
|
||||
riskRange?: [number, number];
|
||||
isFullscreen?: boolean;
|
||||
}
|
||||
|
||||
const WUHAN_CENTER: [number, number] = [30.59, 114.31];
|
||||
const GRID_OPACITY = 0.72;
|
||||
|
||||
const RISK_COLORS: [number, number, string][] = [
|
||||
[0.0, 0.2, '#22c55e'],
|
||||
[0.2, 0.4, '#3b82f6'],
|
||||
[0.4, 0.6, '#eab308'],
|
||||
// Mirrors the server-side colormap in backend/utils/risk_raster.py.
|
||||
const RISK_LEGEND: [number, number, string][] = [
|
||||
[0.25, 0.4, '#38b000'],
|
||||
[0.4, 0.6, '#facc15'],
|
||||
[0.6, 0.8, '#f97316'],
|
||||
[0.8, 1.0, '#ef4444'],
|
||||
];
|
||||
@@ -40,12 +43,9 @@ function getRiskLabel(value: number): string {
|
||||
if (value >= 0.8) return '高风险';
|
||||
if (value >= 0.6) return '中高';
|
||||
if (value >= 0.4) return '中风险';
|
||||
if (value >= 0.2) return '中低';
|
||||
return '低风险';
|
||||
}
|
||||
|
||||
const EMPTY_GRIDS: GridRisk[] = [];
|
||||
|
||||
function AlertMapComponent({
|
||||
selectedGridId,
|
||||
onGridClick,
|
||||
@@ -54,26 +54,31 @@ function AlertMapComponent({
|
||||
showAlertMarkers = true,
|
||||
showGrid = true,
|
||||
filteredAlerts = [],
|
||||
riskRange,
|
||||
isFullscreen = false,
|
||||
}: AlertMapProps) {
|
||||
const mapRef = useRef<HTMLDivElement>(null);
|
||||
const mapInstanceRef = useRef<L.Map | null>(null);
|
||||
const riskTileRef = useRef<L.TileLayer | null>(null);
|
||||
const alertLayerRef = useRef<L.LayerGroup | null>(null);
|
||||
const markerMapRef = useRef<Map<string, L.CircleMarker>>(new Map());
|
||||
const selectedMarkerRef = useRef<L.Rectangle | null>(null);
|
||||
const clickHandlerRef = useRef(onGridClick);
|
||||
const [currentZoom, setCurrentZoom] = useState(10);
|
||||
const cellInfoRef = useRef(onCellInfo);
|
||||
const resizeObserverRef = useRef<ResizeObserver | null>(null);
|
||||
// Latest inputs the once-subscribed map handlers read, so we never have to
|
||||
// re-subscribe (and tear down listeners) when prop/callback identities change.
|
||||
const inputsRef = useRef({ filteredAlerts, showAlertMarkers, forecastDay });
|
||||
|
||||
const grids = useRiskStore((s) => s.grids ?? EMPTY_GRIDS);
|
||||
|
||||
// LOD grid data for stats overlay
|
||||
const { count, avgRisk, maxRisk, loading } = useLodGrid(currentZoom, forecastDay);
|
||||
const [gridStats, setGridStats] = useState<RiskGridStats | null>(null);
|
||||
const [statsLoading, setStatsLoading] = useState(false);
|
||||
|
||||
useEffect(() => { clickHandlerRef.current = onGridClick; }, [onGridClick]);
|
||||
useEffect(() => { cellInfoRef.current = onCellInfo; }, [onCellInfo]);
|
||||
useEffect(() => {
|
||||
clickHandlerRef.current = onGridClick;
|
||||
}, [onGridClick]);
|
||||
inputsRef.current = { filteredAlerts, showAlertMarkers, forecastDay };
|
||||
}, [filteredAlerts, showAlertMarkers, forecastDay]);
|
||||
|
||||
// Initialize map
|
||||
// --- Initialize map once ---
|
||||
useEffect(() => {
|
||||
if (!mapRef.current || mapInstanceRef.current) return;
|
||||
|
||||
@@ -88,63 +93,137 @@ function AlertMapComponent({
|
||||
maxZoom: 19,
|
||||
}).addTo(map);
|
||||
|
||||
map.on('zoomend', () => {
|
||||
setCurrentZoom(map.getZoom());
|
||||
// Full-Wuhan 100m risk grid as raster tiles. The browser only fetches PNGs
|
||||
// (Leaflet caches them per z/x/y); LOD is inherent in the tile pyramid.
|
||||
const riskTiles = L.tileLayer(riskApi.tileUrlTemplate(forecastDay), {
|
||||
opacity: showGrid ? GRID_OPACITY : 0,
|
||||
maxNativeZoom: 16,
|
||||
maxZoom: 19,
|
||||
updateWhenZooming: false,
|
||||
keepBuffer: 2,
|
||||
zIndex: 200,
|
||||
}).addTo(map);
|
||||
riskTileRef.current = riskTiles;
|
||||
|
||||
const alertLayer = L.layerGroup().addTo(map);
|
||||
alertLayerRef.current = alertLayer;
|
||||
|
||||
// Click-to-inspect: query the 100m cell under the cursor. Clicks on alert
|
||||
// markers are consumed by the marker handler and never reach this.
|
||||
map.on('click', async (e: L.LeafletMouseEvent) => {
|
||||
const { lat, lng } = e.latlng;
|
||||
const { filteredAlerts: alerts, forecastDay: day } = inputsRef.current;
|
||||
try {
|
||||
const cell = await riskApi.getCell(lat, lng, day);
|
||||
// Nearest alert (squared degree distance — cheap, no sqrt).
|
||||
let nearestId: string | null = null;
|
||||
let minSq = Infinity;
|
||||
for (const a of alerts) {
|
||||
const dx = a.latitude - lat;
|
||||
const dy = a.longitude - lng;
|
||||
const d = dx * dx + dy * dy;
|
||||
if (d < minSq) { minSq = d; nearestId = a.grid_id; }
|
||||
}
|
||||
const nearestDist = Math.sqrt(minSq);
|
||||
if (nearestId && nearestDist < 0.01) {
|
||||
clickHandlerRef.current(nearestId);
|
||||
} else if (cellInfoRef.current) {
|
||||
cellInfoRef.current({
|
||||
lat, lon: lng,
|
||||
risk: cell.risk_value,
|
||||
grid_id: cell.grid_id,
|
||||
risk_1d: cell.risk_1d,
|
||||
risk_3d: cell.risk_3d,
|
||||
risk_7d: cell.risk_7d,
|
||||
nearestAlertId: nearestId,
|
||||
nearestAlertDist: nearestDist,
|
||||
});
|
||||
}
|
||||
} catch { /* transient fetch error — ignore the click */ }
|
||||
});
|
||||
|
||||
mapInstanceRef.current = map;
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
mapInstanceRef.current?.invalidateSize({ animate: false });
|
||||
});
|
||||
resizeObserver.observe(mapRef.current);
|
||||
resizeObserverRef.current = resizeObserver;
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
resizeObserverRef.current = null;
|
||||
markerMapRef.current.clear();
|
||||
alertLayerRef.current = null;
|
||||
riskTileRef.current = null;
|
||||
map.remove();
|
||||
mapInstanceRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Render alert markers overlay
|
||||
// --- Update risk tiles + stats when the forecast horizon changes ---
|
||||
useEffect(() => {
|
||||
const layer = riskTileRef.current;
|
||||
if (layer) layer.setUrl(riskApi.tileUrlTemplate(forecastDay));
|
||||
|
||||
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]);
|
||||
|
||||
// --- Toggle grid visibility without rebuilding tiles ---
|
||||
useEffect(() => {
|
||||
riskTileRef.current?.setOpacity(showGrid ? GRID_OPACITY : 0);
|
||||
}, [showGrid]);
|
||||
|
||||
// --- Render alert markers via diffing against a persistent layer group ---
|
||||
const renderAlertMarkers = useCallback(() => {
|
||||
const map = mapInstanceRef.current;
|
||||
if (!map) return;
|
||||
const layer = alertLayerRef.current;
|
||||
if (!map || !layer) return;
|
||||
|
||||
if (alertLayerRef.current) {
|
||||
try { map.removeLayer(alertLayerRef.current); } catch { /* ok */ }
|
||||
alertLayerRef.current = null;
|
||||
const markerMap = markerMapRef.current;
|
||||
const { filteredAlerts: alerts, showAlertMarkers: showMarkers } = inputsRef.current;
|
||||
|
||||
if (!showMarkers || !alerts || alerts.length === 0) {
|
||||
if (markerMap.size > 0) { layer.clearLayers(); markerMap.clear(); }
|
||||
return;
|
||||
}
|
||||
|
||||
if (!showAlertMarkers || !filteredAlerts || filteredAlerts.length === 0) return;
|
||||
|
||||
const layer = L.layerGroup();
|
||||
const mapBounds = map.getBounds();
|
||||
const b = map.getBounds();
|
||||
const south = b.getSouth(), north = b.getNorth(), west = b.getWest(), east = b.getEast();
|
||||
const maxMarkers = 500;
|
||||
const step = Math.max(1, Math.floor(filteredAlerts.length / maxMarkers));
|
||||
const step = Math.max(1, Math.floor(alerts.length / maxMarkers));
|
||||
|
||||
for (let i = 0; i < filteredAlerts.length; i += step) {
|
||||
const alert = filteredAlerts[i];
|
||||
const desired = new Map<string, Alert>();
|
||||
for (let i = 0; i < alerts.length; i += step) {
|
||||
const alert = alerts[i];
|
||||
if (!alert.latitude || !alert.longitude) continue;
|
||||
if (alert.latitude < south || alert.latitude > north ||
|
||||
alert.longitude < west || alert.longitude > east) continue;
|
||||
const key = alert.grid_id || `${alert.latitude},${alert.longitude},${i}`;
|
||||
desired.set(key, alert);
|
||||
}
|
||||
|
||||
// Skip if outside viewport
|
||||
if (
|
||||
alert.latitude < mapBounds.getSouth() ||
|
||||
alert.latitude > mapBounds.getNorth() ||
|
||||
alert.longitude < mapBounds.getWest() ||
|
||||
alert.longitude > mapBounds.getEast()
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
for (const [key, marker] of markerMap) {
|
||||
if (!desired.has(key)) { layer.removeLayer(marker); markerMap.delete(key); }
|
||||
}
|
||||
|
||||
for (const [key, alert] of desired) {
|
||||
if (markerMap.has(key)) 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',
|
||||
}
|
||||
);
|
||||
|
||||
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/>
|
||||
@@ -152,106 +231,60 @@ function AlertMapComponent({
|
||||
</div>`,
|
||||
{ direction: 'top', offset: [0, -5] }
|
||||
);
|
||||
|
||||
marker.on('click', () => {
|
||||
if (alert.grid_id) clickHandlerRef.current(alert.grid_id);
|
||||
});
|
||||
|
||||
const gridId = alert.grid_id;
|
||||
marker.on('click', () => { if (gridId) clickHandlerRef.current(gridId); });
|
||||
marker.addTo(layer);
|
||||
markerMap.set(key, marker);
|
||||
}
|
||||
}, []);
|
||||
|
||||
layer.addTo(map);
|
||||
alertLayerRef.current = layer;
|
||||
}, [filteredAlerts, showAlertMarkers]);
|
||||
|
||||
// Re-render alert markers when data changes
|
||||
useEffect(() => {
|
||||
renderAlertMarkers();
|
||||
}, [renderAlertMarkers]);
|
||||
}, [filteredAlerts, showAlertMarkers, renderAlertMarkers]);
|
||||
|
||||
// Also re-render on map zoom/pan
|
||||
// Re-render markers on pan/zoom, throttled, subscribed once per map instance.
|
||||
useEffect(() => {
|
||||
const map = mapInstanceRef.current;
|
||||
if (!map) return;
|
||||
|
||||
const handleMove = () => renderAlertMarkers();
|
||||
let throttle: ReturnType<typeof setTimeout> | null = null;
|
||||
const handleMove = () => {
|
||||
if (throttle) return;
|
||||
throttle = setTimeout(() => { throttle = null; renderAlertMarkers(); }, 150);
|
||||
};
|
||||
map.on('moveend', handleMove);
|
||||
return () => { map.off('moveend', handleMove); };
|
||||
return () => {
|
||||
map.off('moveend', handleMove);
|
||||
if (throttle) clearTimeout(throttle);
|
||||
};
|
||||
}, [renderAlertMarkers]);
|
||||
|
||||
// Selected grid highlight
|
||||
// --- Selected alert highlight (located from the alert list, no grid scan) ---
|
||||
useEffect(() => {
|
||||
const map = mapInstanceRef.current;
|
||||
if (!map) return;
|
||||
|
||||
if (selectedMarkerRef.current) {
|
||||
try { map.removeLayer(selectedMarkerRef.current); } catch { /* ok */ }
|
||||
selectedMarkerRef.current = null;
|
||||
}
|
||||
if (!selectedGridId) return;
|
||||
const alert = filteredAlerts.find((a) => a.grid_id === selectedGridId);
|
||||
if (!alert) return;
|
||||
const latHalf = 0.00045, lonHalf = 0.00052;
|
||||
const rect = L.rectangle(
|
||||
[[alert.latitude - latHalf, alert.longitude - lonHalf],
|
||||
[alert.latitude + latHalf, alert.longitude + lonHalf]],
|
||||
{ fillColor: '#3b82f6', fillOpacity: 0.3, color: '#3b82f6', weight: 3 }
|
||||
).addTo(map);
|
||||
selectedMarkerRef.current = rect;
|
||||
map.flyTo([alert.latitude, alert.longitude], Math.max(map.getZoom(), 13), { duration: 0.5 });
|
||||
}, [selectedGridId, filteredAlerts]);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (nearestId && minDist < 0.01) {
|
||||
clickHandlerRef.current(nearestId);
|
||||
} else if (onCellInfo) {
|
||||
onCellInfo({ lat, lon, risk, nearestAlertId: nearestId, nearestAlertDist: minDist });
|
||||
}
|
||||
},
|
||||
[filteredAlerts, onCellInfo]
|
||||
);
|
||||
|
||||
// Invalidate Leaflet size after fullscreen toggle
|
||||
// --- Invalidate size after fullscreen toggle (CSS transition ~200ms) ---
|
||||
useEffect(() => {
|
||||
const map = mapInstanceRef.current;
|
||||
if (!map) return;
|
||||
const timer = setTimeout(() => map.invalidateSize({ animate: true }), 100);
|
||||
map.invalidateSize({ animate: false });
|
||||
const timer = setTimeout(() => map.invalidateSize({ animate: true }), 200);
|
||||
return () => clearTimeout(timer);
|
||||
}, [isFullscreen]);
|
||||
|
||||
@@ -261,29 +294,18 @@ function AlertMapComponent({
|
||||
<div className="relative">
|
||||
<div ref={mapRef} className="w-full rounded-lg overflow-hidden" style={{ height: containerHeight }} />
|
||||
|
||||
{/* LOD Grid Layer */}
|
||||
<LodGridLayer
|
||||
map={mapInstanceRef.current}
|
||||
forecastDay={forecastDay}
|
||||
visible={showGrid}
|
||||
riskRange={riskRange}
|
||||
onCellClick={handleCellClick}
|
||||
/>
|
||||
|
||||
{/* Stats overlay */}
|
||||
<GridStatsOverlay
|
||||
count={count}
|
||||
avgRisk={avgRisk}
|
||||
maxRisk={maxRisk}
|
||||
loading={loading}
|
||||
count={gridStats?.cell_count ?? 0}
|
||||
avgRisk={gridStats?.avg_risk ?? 0}
|
||||
maxRisk={gridStats?.max_risk ?? 0}
|
||||
loading={statsLoading}
|
||||
forecastDay={forecastDay}
|
||||
/>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="absolute bottom-4 right-4 bg-bg-card/90 backdrop-blur rounded-lg border border-border-light shadow-sm z-[1000] px-4 py-3">
|
||||
<div className="text-[11px] font-semibold text-text-secondary mb-2">风险等级</div>
|
||||
<div className="text-[11px] font-semibold text-text-secondary mb-2">风险等级 (100m 网格)</div>
|
||||
<div className="space-y-1.5">
|
||||
{RISK_COLORS.slice().reverse().map(([min, max, color]) => (
|
||||
{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">
|
||||
@@ -291,6 +313,10 @@ function AlertMapComponent({
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
76
frontend/src/components/AnomalyMarkers.tsx
Normal file
76
frontend/src/components/AnomalyMarkers.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import React from 'react';
|
||||
|
||||
interface AnomalyMarkersProps {
|
||||
anomalies: Array<{ date: string; value: number; description: string }>;
|
||||
}
|
||||
|
||||
export const AnomalyMarkers = React.memo(function AnomalyMarkers({
|
||||
anomalies,
|
||||
}: AnomalyMarkersProps) {
|
||||
if (anomalies.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 pointer-events-none" aria-hidden>
|
||||
{anomalies.map((a, i) => {
|
||||
// Parse date to position marker horizontally
|
||||
// This requires the parent to position relative; markers are
|
||||
// positioned via CSS custom properties set by the consumer.
|
||||
// For a data-driven overlay, we expose the anomalies as a
|
||||
// data list that the chart-library integration uses.
|
||||
return (
|
||||
<div
|
||||
key={`${a.date}-${i}`}
|
||||
data-anomaly-date={a.date}
|
||||
data-anomaly-value={a.value}
|
||||
title={`${a.date}: ${a.value} — ${a.description}`}
|
||||
className="absolute w-2.5 h-2.5 rounded-full bg-red-500 border border-red-300 pointer-events-auto cursor-help"
|
||||
style={{
|
||||
// Positioned via CSS custom properties set by parent
|
||||
left: `var(--anomaly-x-${i})`,
|
||||
top: `var(--anomaly-y-${i})`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper to compute anomaly positions as percentages within a chart area.
|
||||
*
|
||||
* Usage inside a Recharts chart component:
|
||||
* - Import { computeAnomalyPositions } from './AnomalyMarkers'
|
||||
* - Call with bounds: computeAnomalyPositions(chartData, anomalies, dateKey, valueKey, xDomain, yDomain)
|
||||
* - Apply returned style vars on parent container
|
||||
*/
|
||||
export function computeAnomalyPositions(
|
||||
allDates: string[],
|
||||
anomalies: Array<{ date: string; value: number }>,
|
||||
xMin: number,
|
||||
xMax: number,
|
||||
yMin: number,
|
||||
yMax: number,
|
||||
): Record<string, string> {
|
||||
const vars: Record<string, string> = {};
|
||||
const xRange = xMax - xMin || 1;
|
||||
const yRange = yMax - yMin || 1;
|
||||
|
||||
// Build a date→index map once for O(1) lookups instead of indexOf per anomaly.
|
||||
const dateIndex = new Map<string, number>();
|
||||
for (let i = 0; i < allDates.length; i++) {
|
||||
if (!dateIndex.has(allDates[i])) dateIndex.set(allDates[i], i);
|
||||
}
|
||||
|
||||
for (let i = 0; i < anomalies.length; i++) {
|
||||
const a = anomalies[i];
|
||||
const dateIdx = dateIndex.get(a.date) ?? -1;
|
||||
if (dateIdx === -1) continue;
|
||||
const xPct = ((dateIdx - xMin) / xRange) * 100;
|
||||
const yPct = 100 - ((a.value - yMin) / yRange) * 100;
|
||||
vars[`--anomaly-x-${i}`] = `${xPct}%`;
|
||||
vars[`--anomaly-y-${i}`] = `${yPct}%`;
|
||||
}
|
||||
return vars;
|
||||
}
|
||||
38
frontend/src/components/CLAUDE.md
Normal file
38
frontend/src/components/CLAUDE.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Components — Reusable UI
|
||||
|
||||
## Conventions
|
||||
|
||||
- One component per file, PascalCase, default export
|
||||
- Props interface: `{ComponentName}Props`, typed strictly (no `any`)
|
||||
- Wrap pure display components in `memo()` for render optimization
|
||||
- All styling via Tailwind utility classes — no CSS modules, no inline styles
|
||||
|
||||
## Component Types
|
||||
|
||||
**Map components** (`*Map.tsx`) — Leaflet-based maps:
|
||||
- Use `react-leaflet` / direct Leaflet manipulation via `useRef`
|
||||
- Risk coloring: centralized `RISK_COLORS` and `RISK_LABELS` constants
|
||||
- Coordinate system: `[lat, lng]` (Leaflet convention, NOT `[lng, lat]`)
|
||||
|
||||
**Chart components** (`*Chart*.tsx`) — Recharts:
|
||||
- Responsive containers with `width="100%" height={...}`
|
||||
|
||||
**Navigation** (`TopNav.tsx`, `SideNav.tsx`):
|
||||
- No data fetching — pure navigation/presentation
|
||||
|
||||
**Overlay/Utility** (`GridStatsOverlay`, `ErrorBanner`, `StatCard`, `TimelinePlayer`):
|
||||
- Small, focused, reusable across pages
|
||||
|
||||
## Data Flow
|
||||
|
||||
- Components receive data via props, never fetch directly
|
||||
- Callbacks passed as props: `onGridSelect`, `onClosePanel`, `onForecastChange`
|
||||
- Complex stateful behavior extracted to custom hooks (e.g., `useTimelineStore`)
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
- Don't fetch data in 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 Leaflet map instances between components — each map manages its own instance
|
||||
- Don't use CSS modules or inline styles — Tailwind only
|
||||
198
frontend/src/components/CalendarHeatmap.tsx
Normal file
198
frontend/src/components/CalendarHeatmap.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
interface CalendarHeatmapProps {
|
||||
data: Array<{ date: string; value: number }>;
|
||||
year: number;
|
||||
onDayClick?: (date: string) => void;
|
||||
}
|
||||
|
||||
function getColor(value: number): string {
|
||||
if (value < 50) return '#10B981';
|
||||
if (value < 100) return '#F59E0B';
|
||||
if (value < 150) return '#F97316';
|
||||
if (value < 200) return '#EF4444';
|
||||
return '#7C3AED';
|
||||
}
|
||||
|
||||
function getLabel(value: number): string {
|
||||
if (value < 50) return '优';
|
||||
if (value < 100) return '良';
|
||||
if (value < 150) return '轻度';
|
||||
if (value < 200) return '中度';
|
||||
return '重度';
|
||||
}
|
||||
|
||||
const MONTH_NAMES = [
|
||||
'1月', '2月', '3月', '4月', '5月', '6月',
|
||||
'7月', '8月', '9月', '10月', '11月', '12月',
|
||||
];
|
||||
|
||||
const DAY_NAMES = ['一', '二', '三', '四', '五', '六', '日'];
|
||||
|
||||
export const CalendarHeatmap = React.memo(function CalendarHeatmap({
|
||||
data,
|
||||
year,
|
||||
onDayClick,
|
||||
}: CalendarHeatmapProps) {
|
||||
const dataMap = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
for (const d of data) {
|
||||
map.set(d.date, d.value);
|
||||
}
|
||||
return map;
|
||||
}, [data]);
|
||||
|
||||
const months = useMemo(() => {
|
||||
const result: Array<{
|
||||
month: number;
|
||||
name: string;
|
||||
weeks: Array<Array<{ date: string; day: number; value: number | null }>>;
|
||||
}> = [];
|
||||
|
||||
for (let m = 0; m < 12; m++) {
|
||||
const daysInMonth = new Date(year, m + 1, 0).getDate();
|
||||
const cells: Array<{ date: string; day: number; value: number | null }> = [];
|
||||
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
const dateObj = new Date(year, m, d);
|
||||
const dateStr = dateObj.toISOString().slice(0, 10);
|
||||
cells.push({
|
||||
date: dateStr,
|
||||
day: d,
|
||||
value: dataMap.get(dateStr) ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate start day of week (1=Monday, 0=Sunday → JS getDay: 0=Sun)
|
||||
const firstDay = new Date(year, m, 1).getDay();
|
||||
// Convert JS Sunday=0 to Monday=0
|
||||
const startOffset = firstDay === 0 ? 6 : firstDay - 1;
|
||||
|
||||
// Pad beginning with empty cells
|
||||
const padded: Array<{ date: string; day: number; value: number | null } | null> = [];
|
||||
for (let i = 0; i < startOffset; i++) {
|
||||
padded.push(null);
|
||||
}
|
||||
for (const cell of cells) {
|
||||
padded.push(cell);
|
||||
}
|
||||
|
||||
// Split into weeks of 7
|
||||
const weeks: Array<Array<{ date: string; day: number; value: number | null }>> = [];
|
||||
for (let i = 0; i < padded.length; i += 7) {
|
||||
const week = padded.slice(i, i + 7).filter(Boolean) as Array<{
|
||||
date: string;
|
||||
day: number;
|
||||
value: number | null;
|
||||
}>;
|
||||
if (week.length > 0) {
|
||||
weeks.push(week);
|
||||
}
|
||||
}
|
||||
|
||||
result.push({ month: m, name: MONTH_NAMES[m], weeks });
|
||||
}
|
||||
return result;
|
||||
}, [year, dataMap]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Legend */}
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-gray-500">
|
||||
{[
|
||||
{ color: '#10B981', label: '优 <50' },
|
||||
{ color: '#F59E0B', label: '良 50-100' },
|
||||
{ color: '#F97316', label: '轻度 100-150' },
|
||||
{ color: '#EF4444', label: '中度 150-200' },
|
||||
{ color: '#7C3AED', label: '重度 ≥200' },
|
||||
{ color: '#E5E7EB', label: '无数据' },
|
||||
].map((item) => (
|
||||
<span key={item.label} className="inline-flex items-center gap-1">
|
||||
<span
|
||||
className="inline-block w-2.5 h-2.5 rounded-sm"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
{item.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Calendar grid */}
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-12 gap-4">
|
||||
{months.map((month) => (
|
||||
<div key={month.month} className="flex flex-col items-center">
|
||||
<div className="text-[11px] font-medium text-gray-500 mb-1">
|
||||
{month.name}
|
||||
</div>
|
||||
{/* Day header row */}
|
||||
<div className="grid grid-cols-7 gap-px mb-0.5" style={{ width: 98 }}>
|
||||
{DAY_NAMES.map((d) => (
|
||||
<div
|
||||
key={d}
|
||||
className="text-[8px] text-gray-400 text-center leading-3 w-[14px] h-3"
|
||||
>
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Weeks */}
|
||||
{month.weeks.map((week, wi) => (
|
||||
<div key={wi} className="flex gap-px">
|
||||
{Array.from({ length: 7 }).map((_, di) => {
|
||||
// Match by day-of-week index
|
||||
const dayOfWeekMap = [1, 2, 3, 4, 5, 6, 0]; // Mon=1..Sun=0
|
||||
const matchedCell = week.find(
|
||||
(c) => new Date(c.date).getDay() === dayOfWeekMap[di],
|
||||
);
|
||||
|
||||
if (!matchedCell) {
|
||||
return (
|
||||
<div
|
||||
key={di}
|
||||
className="w-[14px] h-[14px]"
|
||||
aria-hidden
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const bg =
|
||||
matchedCell.value === null
|
||||
? '#E5E7EB'
|
||||
: getColor(matchedCell.value);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={di}
|
||||
title={
|
||||
matchedCell.value !== null
|
||||
? `${matchedCell.date}: AQI ${matchedCell.value} (${getLabel(matchedCell.value)})`
|
||||
: `${matchedCell.date}: 无数据`
|
||||
}
|
||||
onClick={() => onDayClick?.(matchedCell.date)}
|
||||
className={`w-[14px] h-[14px] rounded-sm transition-transform hover:scale-125 ${
|
||||
onDayClick ? 'cursor-pointer' : ''
|
||||
}`}
|
||||
style={{ backgroundColor: bg }}
|
||||
role={onDayClick ? 'button' : undefined}
|
||||
tabIndex={onDayClick ? 0 : undefined}
|
||||
onKeyDown={
|
||||
onDayClick
|
||||
? (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onDayClick(matchedCell.date);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -9,13 +9,15 @@ interface CaseLocationMapProps {
|
||||
height?: string;
|
||||
district?: string | null;
|
||||
street?: string | null;
|
||||
date?: string | null;
|
||||
}
|
||||
|
||||
function CaseLocationMapComponent({ height = '400px', district = null, street = null }: CaseLocationMapProps) {
|
||||
function CaseLocationMapComponent({ height = '400px', district = null, street = null, date = null }: CaseLocationMapProps) {
|
||||
const mapRef = useRef<HTMLDivElement>(null);
|
||||
const mapInstanceRef = useRef<L.Map | null>(null);
|
||||
const layerRef = useRef<L.LayerGroup | null>(null);
|
||||
const cancelledRef = useRef(false);
|
||||
const resizeObserverRef = useRef<ResizeObserver | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [caseCount, setCaseCount] = useState(0);
|
||||
|
||||
@@ -39,8 +41,19 @@ function CaseLocationMapComponent({ height = '400px', district = null, street =
|
||||
mapInstanceRef.current = map;
|
||||
layerRef.current = L.layerGroup().addTo(map);
|
||||
|
||||
// ResizeObserver: auto-invalidate when container size changes (window resize, layout shifts)
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (mapInstanceRef.current) {
|
||||
mapInstanceRef.current.invalidateSize({ animate: false });
|
||||
}
|
||||
});
|
||||
if (mapRef.current) {
|
||||
resizeObserver.observe(mapRef.current);
|
||||
}
|
||||
resizeObserverRef.current = resizeObserver;
|
||||
|
||||
// Fetch case locations
|
||||
geocodedApi.getGeocoded({ limit: 5000, district: district || undefined })
|
||||
geocodedApi.getGeocoded({ limit: 5000, district: district || undefined, date: date || undefined })
|
||||
.then((data) => {
|
||||
if (cancelledRef.current) return;
|
||||
const cases: GeocodedCase[] = data.cases || [];
|
||||
@@ -104,10 +117,14 @@ function CaseLocationMapComponent({ height = '400px', district = null, street =
|
||||
|
||||
return () => {
|
||||
cancelledRef.current = true;
|
||||
if (resizeObserverRef.current) {
|
||||
resizeObserverRef.current.disconnect();
|
||||
resizeObserverRef.current = null;
|
||||
}
|
||||
map.remove();
|
||||
mapInstanceRef.current = null;
|
||||
};
|
||||
}, [district, street]);
|
||||
}, [district, street, date]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
|
||||
@@ -18,9 +18,9 @@ for (const [, , color] of RISK_COLORS) {
|
||||
|
||||
function getRiskColor(value: number): string {
|
||||
for (const [min, max, color] of RISK_COLORS) {
|
||||
if (value >= min && value <= max) return color;
|
||||
if (value >= min && value < max) return color;
|
||||
}
|
||||
return '#22c55e';
|
||||
return '#ef4444';
|
||||
}
|
||||
|
||||
// 100m grid step in degrees
|
||||
@@ -116,6 +116,7 @@ export function LodGridLayer({
|
||||
canvas.style.width = '100%';
|
||||
canvas.style.height = '100%';
|
||||
canvas.style.pointerEvents = 'none';
|
||||
canvas.style.display = visibleRef.current ? '' : 'none';
|
||||
pane.appendChild(canvas);
|
||||
canvasRef.current = canvas;
|
||||
|
||||
@@ -173,8 +174,8 @@ export function LodGridLayer({
|
||||
canvas.style.transform = '';
|
||||
drawnOriginRef.current = null;
|
||||
|
||||
if (!visibleRef.current) return;
|
||||
|
||||
// Visibility is controlled via canvas CSS display (see visible effect),
|
||||
// so we still draw pixels even when hidden to keep them ready on re-show.
|
||||
const currentGrids = gridsRef.current;
|
||||
if (!currentGrids || currentGrids.length === 0) return;
|
||||
|
||||
@@ -354,13 +355,21 @@ export function LodGridLayer({
|
||||
};
|
||||
}, [map]);
|
||||
|
||||
// Trigger redraw when data changes
|
||||
// Trigger redraw when data/geometry-affecting inputs change.
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas && (canvas as any).__lodRedraw) {
|
||||
(canvas as any).__lodRedraw();
|
||||
}
|
||||
}, [grids, forecastDay, riskRange, visible]);
|
||||
}, [grids, forecastDay, riskRange]);
|
||||
|
||||
// Visibility toggle: hide/show via CSS instead of a full geometry redraw.
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) {
|
||||
canvas.style.display = visible ? '' : 'none';
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
123
frontend/src/components/MetricHeatmapTable.tsx
Normal file
123
frontend/src/components/MetricHeatmapTable.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
interface MetricHeatmapTableProps {
|
||||
rows: string[];
|
||||
columns: Array<{ key: string; label: string }>;
|
||||
data: Record<string, Record<string, number>>;
|
||||
onSort?: (column: string) => void;
|
||||
onCellClick?: (row: string, column: string) => void;
|
||||
}
|
||||
|
||||
function colorForValue(value: number, min: number, max: number): string {
|
||||
if (max - min === 0) return 'rgb(255, 255, 255)';
|
||||
const ratio = (value - min) / (max - min);
|
||||
// Green (low) → Yellow (mid) → Red (high)
|
||||
if (ratio <= 0.5) {
|
||||
const r = Math.round(ratio * 2 * 245);
|
||||
const g = 220;
|
||||
return `rgb(${r}, ${g}, 230)`;
|
||||
}
|
||||
const r = 245;
|
||||
const g = Math.round(220 - (ratio - 0.5) * 2 * 190);
|
||||
return `rgb(${r}, ${g}, 230)`;
|
||||
}
|
||||
|
||||
function textColorForValue(value: number, min: number, max: number): string {
|
||||
if (max - min === 0) return '#374151';
|
||||
const ratio = (value - min) / (max - min);
|
||||
return ratio > 0.6 ? '#7F1D1D' : '#374151';
|
||||
}
|
||||
|
||||
export const MetricHeatmapTable = React.memo(function MetricHeatmapTable({
|
||||
rows,
|
||||
columns,
|
||||
data,
|
||||
onSort,
|
||||
onCellClick,
|
||||
}: MetricHeatmapTableProps) {
|
||||
const columnStats = useMemo(() => {
|
||||
return columns.map((col) => {
|
||||
const values = rows
|
||||
.map((row) => data[row]?.[col.key])
|
||||
.filter((v): v is number => v !== undefined && v !== null);
|
||||
const min = values.length > 0 ? Math.min(...values) : 0;
|
||||
const max = values.length > 0 ? Math.max(...values) : 0;
|
||||
return { key: col.key, min, max };
|
||||
});
|
||||
}, [columns, rows, data]);
|
||||
|
||||
const colStatMap = useMemo(() => {
|
||||
const map = new Map<string, { min: number; max: number }>();
|
||||
for (const stat of columnStats) {
|
||||
map.set(stat.key, { min: stat.min, max: stat.max });
|
||||
}
|
||||
return map;
|
||||
}, [columnStats]);
|
||||
|
||||
return (
|
||||
<div className="overflow-auto max-h-96">
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead className="sticky top-0 z-10">
|
||||
<tr>
|
||||
<th className="bg-gray-100 border border-gray-200 px-2 py-1.5 text-left font-medium text-gray-600 sticky left-0 z-20">
|
||||
|
||||
</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>
|
||||
);
|
||||
});
|
||||
@@ -46,6 +46,22 @@ function debounce<T extends (...args: any[]) => void>(fn: T, ms: number) {
|
||||
};
|
||||
}
|
||||
|
||||
// Mercator helpers (avoid per-cell latLngToContainerPoint) — mirrors LodGridLayer.
|
||||
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;
|
||||
}
|
||||
|
||||
function riskColorForValue(riskValue: number): string {
|
||||
if (riskValue >= 0.7) return RISK_COLORS.high;
|
||||
if (riskValue >= 0.5) return RISK_COLORS.medium_high;
|
||||
if (riskValue >= 0.3) return RISK_COLORS.medium_low;
|
||||
return RISK_COLORS.low;
|
||||
}
|
||||
|
||||
function RiskMapComponent(props: RiskMapProps) {
|
||||
const {
|
||||
grids,
|
||||
@@ -60,9 +76,14 @@ function RiskMapComponent(props: RiskMapProps) {
|
||||
|
||||
const mapDivRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<any>(null);
|
||||
const gridLayerRef = useRef<any>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const paneRef = useRef<HTMLElement | null>(null);
|
||||
const animFrameRef = useRef<number>(0);
|
||||
const redrawRef = useRef<() => void>(() => {});
|
||||
const zoomRef = useRef(9);
|
||||
const callbacksRef = useRef({ onGridSelect, onClosePanel, onFullscreen, onForecastChange });
|
||||
const resizeObserverRef = useRef<ResizeObserver | null>(null);
|
||||
const gridMapRef = useRef<Map<string, GridRisk>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
callbacksRef.current = { onGridSelect, onClosePanel, onFullscreen, onForecastChange };
|
||||
@@ -70,6 +91,19 @@ function RiskMapComponent(props: RiskMapProps) {
|
||||
|
||||
const containerHeight = isFullscreen ? 'calc(100vh - 52px)' : '420px';
|
||||
|
||||
// Invalidate map size when container size changes (window resize, fullscreen, layout shifts)
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
||||
// Fullscreen transition: wait for CSS transition to complete
|
||||
const timer = setTimeout(() => {
|
||||
map.invalidateSize({ animate: true });
|
||||
}, 150);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [isFullscreen, containerHeight]);
|
||||
|
||||
const gridMap = useMemo(() => {
|
||||
const map = new Map<string, GridRisk>();
|
||||
grids.forEach((g) => {
|
||||
@@ -79,6 +113,13 @@ function RiskMapComponent(props: RiskMapProps) {
|
||||
return map;
|
||||
}, [grids]);
|
||||
|
||||
// Keep gridMap accessible to the canvas render fn (read via ref, no re-init).
|
||||
useEffect(() => {
|
||||
gridMapRef.current = gridMap;
|
||||
redrawRef.current();
|
||||
}, [gridMap]);
|
||||
|
||||
// Create the map + tile layer + canvas overlay + handlers ONCE.
|
||||
useEffect(() => {
|
||||
if (!mapDivRef.current || mapRef.current) return;
|
||||
|
||||
@@ -95,6 +136,139 @@ function RiskMapComponent(props: RiskMapProps) {
|
||||
|
||||
mapRef.current = map;
|
||||
|
||||
// Canvas overlay pane for batched grid rendering (replaces per-cell rectangles).
|
||||
const pane = map.createPane('risk-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;
|
||||
|
||||
// ResizeObserver: auto-invalidate map size when container changes
|
||||
const resizeObserver = new ResizeObserver(
|
||||
debounce(() => {
|
||||
if (mapRef.current) {
|
||||
mapRef.current.invalidateSize({ animate: false });
|
||||
}
|
||||
}, 100)
|
||||
);
|
||||
if (mapDivRef.current) {
|
||||
resizeObserver.observe(mapDivRef.current);
|
||||
}
|
||||
resizeObserverRef.current = resizeObserver;
|
||||
|
||||
// Batched canvas render: group cells by color and fillRect on one canvas.
|
||||
function renderGridLayer() {
|
||||
if (!mapRef.current || !canvasRef.current) return;
|
||||
const map = mapRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
|
||||
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);
|
||||
|
||||
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 currentGridMap = gridMapRef.current;
|
||||
if (currentGridMap.size > 5000) {
|
||||
console.warn(`[RiskMap] Data too dense: ${currentGridMap.size} grid cells, rendering may be slow`);
|
||||
}
|
||||
|
||||
const scale = 2 ** zoom;
|
||||
const origin = map.getPixelOrigin();
|
||||
const cellDeg = cellSize * step;
|
||||
|
||||
// Group cells by color to minimize fillStyle changes.
|
||||
const colorGroups: Record<string, { x: number; y: number; w: number; h: number }[]> = {};
|
||||
|
||||
let count = 0;
|
||||
const maxCount = 1500;
|
||||
for (let lat = latStart; lat < maxLat && count < maxCount; lat += cellDeg) {
|
||||
for (let lon = lonStart; lon < maxLon && count < maxCount; lon += cellDeg) {
|
||||
const key = `${lat.toFixed(4)}-${lon.toFixed(4)}`;
|
||||
const grid = currentGridMap.get(key);
|
||||
const riskValue = grid?.risk_value ?? 0.5;
|
||||
const color = riskColorForValue(riskValue);
|
||||
|
||||
const lx = lonToMercX(lon) * scale - origin.x;
|
||||
const rx = lonToMercX(lon + cellDeg) * scale - origin.x;
|
||||
const ty = latToMercY(lat + cellDeg) * scale - origin.y;
|
||||
const by = latToMercY(lat) * scale - origin.y;
|
||||
|
||||
if (!colorGroups[color]) colorGroups[color] = [];
|
||||
colorGroups[color].push({ x: lx, y: ty, w: rx - lx, h: by - ty });
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 0.6;
|
||||
for (const [color, cells] of Object.entries(colorGroups)) {
|
||||
ctx.fillStyle = color;
|
||||
for (const c of cells) {
|
||||
ctx.fillRect(c.x, c.y, c.w, c.h);
|
||||
}
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
});
|
||||
}
|
||||
|
||||
redrawRef.current = renderGridLayer;
|
||||
|
||||
// Single map-level click handler: nearest-cell lookup (replaces 1500 handlers).
|
||||
const handleMapClick = (e: L.LeafletMouseEvent) => {
|
||||
const currentGridMap = gridMapRef.current;
|
||||
if (currentGridMap.size === 0) return;
|
||||
const { lat, lng } = e.latlng;
|
||||
let nearestDist = Infinity;
|
||||
let nearestGrid: GridRisk | null = null;
|
||||
for (const grid of currentGridMap.values()) {
|
||||
const d = (grid.latitude - lat) ** 2 + (grid.longitude - lng) ** 2;
|
||||
if (d < nearestDist) {
|
||||
nearestDist = d;
|
||||
nearestGrid = grid;
|
||||
}
|
||||
}
|
||||
if (nearestGrid && nearestDist < 0.01 * 0.01) {
|
||||
callbacksRef.current.onGridSelect(nearestGrid.grid_id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleZoom = debounce(() => {
|
||||
zoomRef.current = map.getZoom();
|
||||
renderGridLayer();
|
||||
@@ -106,112 +280,27 @@ function RiskMapComponent(props: RiskMapProps) {
|
||||
|
||||
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;
|
||||
}
|
||||
map.on('resize', renderGridLayer);
|
||||
map.on('click', handleMapClick);
|
||||
|
||||
// Initial render
|
||||
renderGridLayer();
|
||||
|
||||
return () => {
|
||||
if (resizeObserverRef.current) {
|
||||
resizeObserverRef.current.disconnect();
|
||||
resizeObserverRef.current = null;
|
||||
}
|
||||
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
|
||||
redrawRef.current = () => {};
|
||||
if (mapRef.current) {
|
||||
mapRef.current.remove();
|
||||
mapRef.current = null;
|
||||
gridLayerRef.current = null;
|
||||
}
|
||||
canvasRef.current = null;
|
||||
paneRef.current = null;
|
||||
};
|
||||
}, [gridMap]);
|
||||
}, []);
|
||||
|
||||
const handleForecastChange = useCallback((d: ForecastDay) => {
|
||||
callbacksRef.current.onForecastChange(d);
|
||||
|
||||
@@ -44,6 +44,9 @@ const modules: { id: string; label: string; icon: React.ReactNode; items: { id:
|
||||
{ id: 'district-comparison', label: '区域对比' },
|
||||
{ id: 'insights', label: '智能洞察' },
|
||||
{ id: 'reports', label: '报表中心' },
|
||||
{ id: 'demographics', label: '人群分析' },
|
||||
{ id: 'disease', label: '疾病分析' },
|
||||
{ id: 'environment', label: '环境健康' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,44 +1,107 @@
|
||||
import React from 'react';
|
||||
|
||||
interface StatCardProps {
|
||||
icon?: React.ReactNode;
|
||||
label: string;
|
||||
value: string | number;
|
||||
change?: string;
|
||||
changeType?: 'up' | 'down' | 'neutral';
|
||||
progress?: number;
|
||||
progressColor?: string;
|
||||
trend?: {
|
||||
direction: 'up' | 'down' | 'stable';
|
||||
value: string;
|
||||
};
|
||||
sparkline?: {
|
||||
data: number[];
|
||||
color: string;
|
||||
};
|
||||
color?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function StatCard({
|
||||
export const StatCard = React.memo(function StatCard({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
change,
|
||||
changeType = 'neutral',
|
||||
progress,
|
||||
progressColor = 'bg-warning',
|
||||
trend,
|
||||
sparkline,
|
||||
color,
|
||||
onClick,
|
||||
}: StatCardProps) {
|
||||
const trendIndicator = trend ? (
|
||||
<span
|
||||
className={`inline-flex items-center gap-0.5 text-xs font-medium ${
|
||||
trend.direction === 'up'
|
||||
? 'text-green-600'
|
||||
: trend.direction === 'down'
|
||||
? 'text-red-600'
|
||||
: 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{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="60"
|
||||
height="24"
|
||||
className="shrink-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke={sparkline.color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
points={sparkline.data
|
||||
.map((val, i) => {
|
||||
const x = (i / (sparkline.data.length - 1)) * 58 + 1;
|
||||
const max = Math.max(...sparkline.data);
|
||||
const min = Math.min(...sparkline.data);
|
||||
const range = max - min || 1;
|
||||
const y = 22 - ((val - min) / range) * 20 - 1;
|
||||
return `${x},${y}`;
|
||||
})
|
||||
.join(' ')}
|
||||
/>
|
||||
</svg>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="card p-4">
|
||||
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-1.5">
|
||||
{label}
|
||||
<div
|
||||
onClick={onClick}
|
||||
role={onClick ? 'button' : undefined}
|
||||
tabIndex={onClick ? 0 : undefined}
|
||||
onKeyDown={
|
||||
onClick
|
||||
? (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className={`bg-white rounded-lg border border-gray-200 p-4 ${
|
||||
onClick ? 'cursor-pointer hover:shadow-md transition-shadow' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 mb-1">
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
<div className="font-display text-[26px] font-bold text-text-primary mb-1">
|
||||
{value}
|
||||
<div className="flex items-end justify-between gap-2">
|
||||
<div
|
||||
className="text-2xl font-bold text-gray-900"
|
||||
style={color ? { color } : undefined}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
{sparklineSvg}
|
||||
</div>
|
||||
{change && (
|
||||
<div className={`text-[11px] ${
|
||||
changeType === 'up' ? 'text-danger' :
|
||||
changeType === 'down' ? 'text-success' : 'text-text-muted'
|
||||
}`}>
|
||||
{change}
|
||||
</div>
|
||||
)}
|
||||
{progress !== undefined && (
|
||||
<div className="h-[3px] bg-bg-page rounded mt-2.5 overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded ${progressColor}`}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{trendIndicator && <div className="mt-1">{trendIndicator}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -38,8 +38,9 @@ export function StatisticalCharts({
|
||||
const secondHalf = values.slice(Math.floor(values.length / 2));
|
||||
|
||||
const firstAvg = firstHalf.reduce((a, b) => a + b, 0) / firstHalf.length;
|
||||
const secondAvg = secondHalf.reduce((a, b) => a + b) / secondHalf.length;
|
||||
const secondAvg = secondHalf.reduce((a, b) => a + b, 0) / secondHalf.length;
|
||||
|
||||
if (firstAvg === 0) return secondAvg > 0 ? 'up' : 'stable';
|
||||
const change = ((secondAvg - firstAvg) / firstAvg) * 100;
|
||||
|
||||
if (change > 10) return 'up';
|
||||
@@ -48,7 +49,11 @@ export function StatisticalCharts({
|
||||
};
|
||||
|
||||
const stats = useMemo(() => {
|
||||
if (data.length === 0) return null;
|
||||
const noData = data.length === 0;
|
||||
|
||||
if (noData) {
|
||||
return { totalCases: 0, avgCases: 0, maxCases: 0, trend: 'stable' as const, noData: true };
|
||||
}
|
||||
|
||||
const totalCases = data.reduce((sum, item) => sum + item.cases, 0);
|
||||
const avgCases = totalCases / data.length;
|
||||
@@ -60,6 +65,7 @@ export function StatisticalCharts({
|
||||
avgCases: Math.round(avgCases),
|
||||
maxCases,
|
||||
trend,
|
||||
noData: false,
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
@@ -146,7 +152,7 @@ export function StatisticalCharts({
|
||||
</div>
|
||||
|
||||
{/* Stats cards */}
|
||||
{stats && activeChart === 'cases' && (
|
||||
{activeChart === 'cases' && (
|
||||
<div className="grid grid-cols-3 gap-4 mb-4">
|
||||
<div className="bg-blue-50 rounded-lg p-3">
|
||||
<div className="text-sm text-gray-600">总病例数</div>
|
||||
@@ -163,6 +169,11 @@ export function StatisticalCharts({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No data banner */}
|
||||
{stats.noData && (
|
||||
<div className="text-center text-sm text-gray-400 py-4">该时段暂无统计数据</div>
|
||||
)}
|
||||
|
||||
{/* Chart */}
|
||||
<div style={{ height }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
|
||||
@@ -26,6 +26,10 @@ export function TimelinePlayer({
|
||||
}: TimelinePlayerProps) {
|
||||
const [playing, setPlaying] = useState(isPlaying);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const advanceRef = useRef<() => void>(() => {});
|
||||
|
||||
// Keep internal play state in sync when the parent/store changes isPlaying.
|
||||
useEffect(() => { setPlaying(isPlaying); }, [isPlaying]);
|
||||
|
||||
const generateDateRange = useCallback((start: string, end: string) => {
|
||||
const dates: string[] = [];
|
||||
@@ -41,7 +45,13 @@ export function TimelinePlayer({
|
||||
}, []);
|
||||
|
||||
const dateRange = useMemo(() => generateDateRange(startDate, endDate), [startDate, endDate, generateDateRange]);
|
||||
const currentIndex = useMemo(() => dateRange.indexOf(currentDate), [dateRange, currentDate]);
|
||||
// Compute index arithmetically from the day difference instead of indexOf.
|
||||
const currentIndex = useMemo(() => {
|
||||
if (dateRange.length === 0) return -1;
|
||||
const ms = new Date(currentDate).getTime() - new Date(startDate).getTime();
|
||||
const idx = Math.round(ms / 86400000);
|
||||
return idx >= 0 && idx < dateRange.length ? idx : dateRange.indexOf(currentDate);
|
||||
}, [startDate, currentDate, dateRange]);
|
||||
const progress = useMemo(() => ((currentIndex + 1) / dateRange.length) * 100, [currentIndex, dateRange.length]);
|
||||
|
||||
const play = useCallback(() => {
|
||||
@@ -71,12 +81,19 @@ export function TimelinePlayer({
|
||||
onDateChange(dateRange[0]);
|
||||
};
|
||||
|
||||
// Keep the advance logic in a ref so the interval doesn't get recreated each
|
||||
// tick when goToNext's identity changes.
|
||||
useEffect(() => {
|
||||
advanceRef.current = goToNext;
|
||||
}, [goToNext]);
|
||||
|
||||
// Interval is created once per play/speed change (not per tick).
|
||||
useEffect(() => {
|
||||
if (playing) {
|
||||
const interval = 1000 / speed;
|
||||
|
||||
timerRef.current = setInterval(() => {
|
||||
goToNext();
|
||||
advanceRef.current();
|
||||
}, interval);
|
||||
|
||||
return () => {
|
||||
@@ -85,7 +102,7 @@ export function TimelinePlayer({
|
||||
}
|
||||
};
|
||||
}
|
||||
}, [playing, speed, goToNext]);
|
||||
}, [playing, speed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentIndex >= dateRange.length - 1) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { cachedGet } from '../services/api';
|
||||
|
||||
export interface LodGridResult {
|
||||
@@ -24,13 +24,44 @@ export interface MapBounds {
|
||||
max_lon: number;
|
||||
}
|
||||
|
||||
// Snap bounds to a coarse grid so small pans don't produce a new identity.
|
||||
const BOUNDS_SNAP = 0.05;
|
||||
function snapBounds(b: MapBounds): MapBounds {
|
||||
return {
|
||||
min_lat: Math.floor(b.min_lat / BOUNDS_SNAP) * BOUNDS_SNAP,
|
||||
max_lat: Math.ceil(b.max_lat / BOUNDS_SNAP) * BOUNDS_SNAP,
|
||||
min_lon: Math.floor(b.min_lon / BOUNDS_SNAP) * BOUNDS_SNAP,
|
||||
max_lon: Math.ceil(b.max_lon / BOUNDS_SNAP) * BOUNDS_SNAP,
|
||||
};
|
||||
}
|
||||
|
||||
// True if `inner` is fully contained within `outer`.
|
||||
function boundsContains(outer: MapBounds, inner: MapBounds): boolean {
|
||||
return (
|
||||
outer.min_lat <= inner.min_lat &&
|
||||
outer.max_lat >= inner.max_lat &&
|
||||
outer.min_lon <= inner.min_lon &&
|
||||
outer.max_lon >= inner.max_lon
|
||||
);
|
||||
}
|
||||
|
||||
export function useLodGrid(zoom: number, forecastDay: 1 | 3 | 7, bounds?: MapBounds): LodGridResult {
|
||||
const [result, setResult] = useState<LodGridResult>(EMPTY_RESULT);
|
||||
const prevResultRef = useRef<LodGridResult>(EMPTY_RESULT);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
const abortRef = useRef<AbortController>();
|
||||
const seqRef = useRef(0);
|
||||
// Track the snapped bounds + zoom of the last successful fetch so we can skip
|
||||
// refetches when the new viewport is already contained in fetched data.
|
||||
const lastFetchRef = useRef<{ zoom: number; day: 1 | 3 | 7; bounds?: MapBounds } | null>(null);
|
||||
|
||||
const fetchData = useCallback(async (z: number, day: 1 | 3 | 7, b?: MapBounds) => {
|
||||
// Abort the previous in-flight request and bump the sequence guard.
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
const seq = ++seqRef.current;
|
||||
|
||||
setResult((prev) => ({ ...prev, loading: true }));
|
||||
|
||||
try {
|
||||
@@ -41,7 +72,9 @@ export function useLodGrid(zoom: number, forecastDay: 1 | 3 | 7, bounds?: MapBou
|
||||
params.min_lon = b.min_lon;
|
||||
params.max_lon = b.max_lon;
|
||||
}
|
||||
const data = await cachedGet<any>('/risk/lod-grid', params);
|
||||
const data = await cachedGet<any>('/risk/lod-grid', params, controller.signal);
|
||||
// Out-of-order guard: only the latest request commits its result.
|
||||
if (seq !== seqRef.current) return;
|
||||
const grids: number[][] = data.grids || [];
|
||||
const count = data.total_count || grids.length;
|
||||
|
||||
@@ -63,26 +96,50 @@ export function useLodGrid(zoom: number, forecastDay: 1 | 3 | 7, bounds?: MapBou
|
||||
};
|
||||
|
||||
prevResultRef.current = newResult;
|
||||
lastFetchRef.current = { zoom: z, day, bounds: b };
|
||||
setResult(newResult);
|
||||
} catch (err: unknown) {
|
||||
if ((err as Error)?.name === 'AbortError') return;
|
||||
if ((err as Error)?.name === 'AbortError' || (err as Error)?.name === 'CanceledError') return;
|
||||
if (seq !== seqRef.current) return;
|
||||
// Keep previous data on error, just stop loading
|
||||
setResult((prev) => ({ ...prev, loading: false }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Snap bounds and stabilize identity: the memo only changes when the snapped
|
||||
// rounded values change, so small pans within the same coarse cell are no-ops.
|
||||
const snapped = bounds ? snapBounds(bounds) : undefined;
|
||||
const snappedKey = snapped
|
||||
? `${snapped.min_lat},${snapped.max_lat},${snapped.min_lon},${snapped.max_lon}`
|
||||
: '';
|
||||
const stableBounds = useMemo(() => snapped, [snappedKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
const roundedZoom = Math.round(zoom);
|
||||
|
||||
// Skip refetch when the new viewport is already contained within the last
|
||||
// fetched bounds at the same zoom/day (no new data needed).
|
||||
const last = lastFetchRef.current;
|
||||
if (
|
||||
last &&
|
||||
last.zoom === roundedZoom &&
|
||||
last.day === forecastDay &&
|
||||
((!stableBounds && !last.bounds) ||
|
||||
(stableBounds && last.bounds && boundsContains(last.bounds, stableBounds)))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
|
||||
debounceRef.current = setTimeout(() => {
|
||||
const roundedZoom = Math.round(zoom);
|
||||
fetchData(roundedZoom, forecastDay, bounds);
|
||||
fetchData(roundedZoom, forecastDay, stableBounds);
|
||||
}, 150);
|
||||
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [zoom, forecastDay, bounds, fetchData]);
|
||||
}, [zoom, forecastDay, stableBounds, fetchData]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
|
||||
@@ -24,11 +24,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Leaflet overrides */
|
||||
/* Leaflet overrides — keep z-index below TopNav (z-50) and SideNav */
|
||||
.leaflet-container {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.leaflet-pane {
|
||||
z-index: 1 !important;
|
||||
}
|
||||
|
||||
.leaflet-top,
|
||||
.leaflet-bottom {
|
||||
z-index: 5 !important;
|
||||
}
|
||||
|
||||
.leaflet-popup-content-wrapper {
|
||||
@apply rounded-lg shadow-lg;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
||||
import React from 'react';
|
||||
import { useRiskStore } from '@/stores';
|
||||
import { useLodGrid } from '@/hooks/useLodGrid';
|
||||
import { AlertMap } from '@/components/AlertMap';
|
||||
import type { CellInfo } from '@/components/AlertMap';
|
||||
import { ErrorBanner } from '@/components/ErrorBanner';
|
||||
import { StatCard } from '@/components/StatCard';
|
||||
import { StatisticalCharts } from '@/components/StatisticalCharts';
|
||||
import { analysisApi } from '@/services/api';
|
||||
import { PieChart, Pie, Cell, Tooltip as RechartsTooltip, Legend, ResponsiveContainer } from 'recharts';
|
||||
|
||||
interface ExtendedAlert {
|
||||
alert_id: string;
|
||||
@@ -48,8 +51,14 @@ export function AlertsDashboard() {
|
||||
const [showGrid, setShowGrid] = useState(true);
|
||||
const [cellInfo, setCellInfo] = useState<CellInfo | null>(null);
|
||||
|
||||
// LOD grid data for cell info lookup (1d/3d/7d risk values)
|
||||
const { grids: lodGrids } = useLodGrid(10, forecastDay);
|
||||
// In-page tab strip (no router) — matches existing activePage pattern
|
||||
const [activeTab, setActiveTab] = useState<'list' | 'stats'>('list');
|
||||
|
||||
// Risk-trend data for the 风险统计 tab, fetched on demand
|
||||
const [trendData, setTrendData] = useState<Array<{ date: string; cases: number; risk: number }>>([]);
|
||||
const [trendLoading, setTrendLoading] = useState(false);
|
||||
const [trendError, setTrendError] = useState<string | null>(null);
|
||||
const [trendLoaded, setTrendLoaded] = useState(false);
|
||||
|
||||
// Debounce riskRange for filteredAlerts computation
|
||||
useEffect(() => {
|
||||
@@ -63,11 +72,36 @@ export function AlertsDashboard() {
|
||||
fetchAlerts();
|
||||
}, [fetchRiskMap, fetchAlerts]);
|
||||
|
||||
// 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 now = new Date();
|
||||
const diffDays = Math.ceil((forecastDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
||||
const diffDays = Math.ceil((forecastDate.getTime() - now) / (1000 * 60 * 60 * 24));
|
||||
const horizon = diffDays <= 1 ? 1 : diffDays <= 3 ? 3 : 7;
|
||||
|
||||
return {
|
||||
@@ -95,22 +129,33 @@ export function AlertsDashboard() {
|
||||
});
|
||||
}, [extendedAlerts, selectedHorizon, selectedPriority, sortBy, debouncedRiskRange]);
|
||||
|
||||
// Risk distribution stats (includes p1/p2 counts)
|
||||
// Risk distribution stats (includes p1/p2 counts) — single pass over each array
|
||||
const riskStats = useMemo(() => {
|
||||
const p1 = extendedAlerts.filter(a => a.priority === 'P1').length;
|
||||
const p2 = extendedAlerts.filter(a => a.priority === 'P2').length;
|
||||
const high = filteredAlerts.filter(a => a.risk_value >= 0.8).length;
|
||||
const mediumHigh = filteredAlerts.filter(a => a.risk_value >= 0.6 && a.risk_value < 0.8).length;
|
||||
const medium = filteredAlerts.filter(a => a.risk_value >= 0.4 && a.risk_value < 0.6).length;
|
||||
const avgRisk = filteredAlerts.length > 0
|
||||
? filteredAlerts.reduce((s, a) => s + a.risk_value, 0) / filteredAlerts.length
|
||||
: 0;
|
||||
// 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);
|
||||
@@ -122,6 +167,17 @@ export function AlertsDashboard() {
|
||||
return filteredAlerts.find(a => a.alert_id === selectedAlert);
|
||||
}, [filteredAlerts, selectedAlert]);
|
||||
|
||||
// Severity donut data (P1/P2) for the 风险统计 tab
|
||||
const alertPie = useMemo(() => ([
|
||||
{ name: 'P1 (紧急)', value: riskStats.p1, color: '#ef4444' },
|
||||
{ name: 'P2 (关注)', value: riskStats.p2, color: '#f59e0b' },
|
||||
]), [riskStats.p1, riskStats.p2]);
|
||||
|
||||
const topDistrictMax = useMemo(
|
||||
() => riskStats.topDistricts.reduce((m, [, n]) => Math.max(m, n), 0),
|
||||
[riskStats.topDistricts],
|
||||
);
|
||||
|
||||
const selectedGridId = useMemo(() => {
|
||||
if (!selectedAlert) return null;
|
||||
const alert = filteredAlerts.find(a => a.alert_id === selectedAlert);
|
||||
@@ -184,20 +240,6 @@ export function AlertsDashboard() {
|
||||
URL.revokeObjectURL(url);
|
||||
}, [filteredAlerts]);
|
||||
|
||||
const nearestGrid = useMemo(() => {
|
||||
if (!cellInfo || !lodGrids.length) return null;
|
||||
let best: { lat: number; lon: number; risk_1d: number; risk_3d: number; risk_7d: number } | null = null;
|
||||
let bestDist = Infinity;
|
||||
for (const g of lodGrids) {
|
||||
const d = Math.sqrt((g[0] - cellInfo.lat) ** 2 + (g[1] - cellInfo.lon) ** 2);
|
||||
if (d < bestDist) {
|
||||
bestDist = d;
|
||||
best = { lat: g[0], lon: g[1], risk_1d: g[2] ?? 0, risk_3d: g[3] ?? 0, risk_7d: g[4] ?? 0 };
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}, [cellInfo, lodGrids]);
|
||||
|
||||
return (
|
||||
<div className={isFullscreen ? 'fixed inset-0 z-40 bg-bg-page pt-[52px] p-5' : 'p-5'}>
|
||||
{error && (
|
||||
@@ -209,20 +251,42 @@ export function AlertsDashboard() {
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4 flex-wrap gap-x-4 gap-y-2">
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-display text-[18px] font-semibold mb-1">风险预警</h1>
|
||||
<p className="text-[12px] text-text-muted">
|
||||
<p className="text-[12px] text-text-muted truncate">
|
||||
100m网格风险预测 · 多时间尺度预警 · 病例-气象关联分析
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-[11px]">
|
||||
<div className="flex items-center gap-3 text-[11px] shrink-0 flex-wrap">
|
||||
<span className="text-text-muted">共 <span className="font-semibold text-text-primary">{filteredAlerts.length}</span> 条预警</span>
|
||||
<span className="px-2 py-1 bg-danger/10 border border-danger/20 rounded text-danger font-semibold">P1: {riskStats.p1}</span>
|
||||
<span className="px-2 py-1 bg-warning/10 border border-warning/20 rounded text-warning font-semibold">P2: {riskStats.p2}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab strip — in-page, no router */}
|
||||
<div className="flex gap-1 mb-4 border-b border-border">
|
||||
{([
|
||||
{ key: 'list', label: '预警列表' },
|
||||
{ key: 'stats', label: '风险统计' },
|
||||
] as const).map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`px-4 py-2 text-[13px] font-medium -mb-px border-b-2 transition-colors ${
|
||||
activeTab === tab.key
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === 'list' && (
|
||||
<>
|
||||
{/* Toolbar Row 1: Forecast + Fullscreen + Export */}
|
||||
<div className="card p-3 mb-3">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
@@ -468,7 +532,6 @@ export function AlertsDashboard() {
|
||||
showAlertMarkers={showAlertMarkers}
|
||||
showGrid={showGrid}
|
||||
filteredAlerts={filteredAlerts}
|
||||
riskRange={riskRange}
|
||||
isFullscreen={isFullscreen}
|
||||
/>
|
||||
)}
|
||||
@@ -492,15 +555,120 @@ export function AlertsDashboard() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'stats' && (
|
||||
<div className="space-y-4">
|
||||
{/* Risk distribution as StatCards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<StatCard label="高风险 (≥0.8)" value={riskStats.high} color="#ef4444" />
|
||||
<StatCard label="中高风险 (0.6-0.8)" value={riskStats.mediumHigh} color="#f59e0b" />
|
||||
<StatCard label="中风险 (0.4-0.6)" value={riskStats.medium} color="#3b82f6" />
|
||||
<StatCard label="平均风险" value={`${(riskStats.avgRisk * 100).toFixed(1)}%`} />
|
||||
</div>
|
||||
|
||||
{/* Risk trend chart (real data from /api/analysis/trend) */}
|
||||
{trendLoading ? (
|
||||
<div className="card p-8 text-center text-text-secondary text-[13px]">趋势加载中...</div>
|
||||
) : trendError ? (
|
||||
<div className="card p-8 text-center text-danger text-[13px]">{trendError}</div>
|
||||
) : trendData.length === 0 ? (
|
||||
<div className="card p-8 text-center text-text-muted text-[13px]">暂无风险趋势数据</div>
|
||||
) : (
|
||||
<StatisticalCharts
|
||||
data={trendData}
|
||||
showCases={false}
|
||||
showRisk
|
||||
height={280}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Top high-risk districts bar */}
|
||||
<div className="card p-4">
|
||||
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
|
||||
高风险区域 Top 5
|
||||
</div>
|
||||
{riskStats.topDistricts.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{riskStats.topDistricts.map(([district, count]) => (
|
||||
<div key={district}>
|
||||
<div className="flex items-center justify-between text-[12px] mb-1">
|
||||
<span className="text-text-primary font-medium">{district}</span>
|
||||
<span className="text-text-muted">{count} 条</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-danger rounded-full"
|
||||
style={{ width: `${topDistrictMax > 0 ? (count / topDistrictMax) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-text-muted text-[13px]">暂无区域数据</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Alert severity donut (P1/P2) */}
|
||||
<div className="card p-4">
|
||||
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
|
||||
预警严重度分布
|
||||
</div>
|
||||
{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 #E2E8F0',
|
||||
borderRadius: '8px',
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cell info panel - shown when clicking grid cell without alert */}
|
||||
{cellInfo && !selectedAlertData && nearestGrid && (
|
||||
{cellInfo && !selectedAlertData && (
|
||||
<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">网格详情</span>
|
||||
<span className="text-[14px] font-semibold text-text-primary">网格详情 (100m)</span>
|
||||
<button onClick={clearCellInfo} 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>
|
||||
@@ -514,15 +682,15 @@ export function AlertsDashboard() {
|
||||
<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]">{(nearestGrid.risk_1d * 100).toFixed(0)}%</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]">{(nearestGrid.risk_3d * 100).toFixed(0)}%</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]">{(nearestGrid.risk_7d * 100).toFixed(0)}%</div>
|
||||
<div className="font-bold text-[13px]">{(cellInfo.risk_7d * 100).toFixed(0)}%</div>
|
||||
</div>
|
||||
</div>
|
||||
{cellInfo.nearestAlertId && (
|
||||
|
||||
46
frontend/src/pages/CLAUDE.md
Normal file
46
frontend/src/pages/CLAUDE.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Pages — Route-Level Views
|
||||
|
||||
## Pattern
|
||||
|
||||
Each page is a route-level component that orchestrates data fetching, state, and child components:
|
||||
|
||||
```tsx
|
||||
export function SomeDashboard({ defaultStartDate, defaultEndDate }: SomePageProps) {
|
||||
// 1. Store hooks (Zustand)
|
||||
const { data, setData } = useSomeStore();
|
||||
|
||||
// 2. Local state
|
||||
const [localState, setLocalState] = useState<Type>(initial);
|
||||
|
||||
// 3. Data fetching in useEffect
|
||||
useEffect(() => { fetchData(); }, [deps]);
|
||||
|
||||
// 4. Render child components with props
|
||||
return <ChildComponent data={data} onAction={handler} />;
|
||||
}
|
||||
```
|
||||
|
||||
## Page List
|
||||
|
||||
| Page | Store Dependencies | Key Features |
|
||||
|------|-------------------|--------------|
|
||||
| `MonitoringDashboard` | timeline, monitoring, disease, drilldown | Timeline player, disease filter, case map, charts |
|
||||
| `AlertsDashboard` | — | Alert feed, risk map, stats |
|
||||
| `DistrictComparison` | — | District-level metrics |
|
||||
| `TrendAnalysis` | — | Time-series charts |
|
||||
| `Insights` | — | AI-generated insights display |
|
||||
| `ReportsCenter` | reports | Report generation, export |
|
||||
| `Login` | — | Auth form, redirect |
|
||||
|
||||
## Data Fetching
|
||||
|
||||
- Fetch data in `useEffect`, store results in Zustand or local state
|
||||
- Handle loading/error states: `<ErrorBanner>` for errors, conditional rendering for loading
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
- Don't fetch data inside child components — pages are the data boundary
|
||||
- Don't call axios directly — use `services/api.ts` wrappers
|
||||
- Don't pass store actions directly to components — wrap in page-level callbacks
|
||||
- Don't skip error boundary handling — every page should use `<ErrorBanner>`
|
||||
- Don't create pages that do the same thing as an existing page — check the list first
|
||||
366
frontend/src/pages/DemographicAnalysis.tsx
Normal file
366
frontend/src/pages/DemographicAnalysis.tsx
Normal file
@@ -0,0 +1,366 @@
|
||||
import { Fragment, useEffect, useState, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import { Users, Activity } from 'lucide-react';
|
||||
import { caseApi } from '@/services/api';
|
||||
import { ErrorBanner } from '@/components/ErrorBanner';
|
||||
import type { DemographicsResponse, AgeBin, AgeDiagnosisMatrixItem } from '@/types';
|
||||
|
||||
// --- Chart 3 helpers ---
|
||||
const AGE_GROUPS = ['0-1', '1-3', '3-6', '6-12', '12-18'];
|
||||
|
||||
function buildHeatmapMatrix(data: AgeDiagnosisMatrixItem[]): {
|
||||
diagnoses: string[];
|
||||
matrix: number[][];
|
||||
totals: number[];
|
||||
} {
|
||||
// Pivot: count per age_group x diagnosis
|
||||
const map: Record<string, Record<string, number>> = {};
|
||||
for (const ag of AGE_GROUPS) {
|
||||
map[ag] = {};
|
||||
}
|
||||
for (const item of data) {
|
||||
if (map[item.age_group] !== undefined) {
|
||||
map[item.age_group][item.diagnosis] = item.inpatient;
|
||||
}
|
||||
}
|
||||
|
||||
// Collect all diagnoses and their total counts
|
||||
const diagTotals: Record<string, number> = {};
|
||||
for (const ag of AGE_GROUPS) {
|
||||
for (const [diag, count] of Object.entries(map[ag])) {
|
||||
diagTotals[diag] = (diagTotals[diag] || 0) + count;
|
||||
}
|
||||
}
|
||||
|
||||
// Top 8 diagnoses by total count
|
||||
const top8 = Object.entries(diagTotals)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 8)
|
||||
.map(([diag]) => diag);
|
||||
|
||||
const matrix = AGE_GROUPS.map((ag) => top8.map((diag) => map[ag][diag] || 0));
|
||||
const totals = top8.map((diag) => diagTotals[diag]);
|
||||
|
||||
return { diagnoses: top8, matrix, totals };
|
||||
}
|
||||
|
||||
function getColorClass(value: number, maxValue: number): string {
|
||||
if (maxValue === 0) return 'bg-blue-50 text-gray-800';
|
||||
const ratio = value / maxValue;
|
||||
if (ratio === 0) return 'bg-blue-50 text-gray-800';
|
||||
if (ratio <= 0.125) return 'bg-blue-100 text-gray-800';
|
||||
if (ratio <= 0.25) return 'bg-blue-200 text-gray-800';
|
||||
if (ratio <= 0.375) return 'bg-blue-300 text-gray-800';
|
||||
if (ratio <= 0.5) return 'bg-blue-400 text-white';
|
||||
if (ratio <= 0.625) return 'bg-blue-500 text-white';
|
||||
if (ratio <= 0.75) return 'bg-blue-600 text-white';
|
||||
if (ratio <= 0.875) return 'bg-blue-700 text-white';
|
||||
return 'bg-blue-800 text-white';
|
||||
}
|
||||
|
||||
export function DemographicAnalysis() {
|
||||
const [data, setData] = useState<DemographicsResponse | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await caseApi.getDemographics();
|
||||
setData(result);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '数据加载失败');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const run = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await caseApi.getDemographics();
|
||||
if (!cancelled) setData(result);
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : '数据加载失败');
|
||||
} finally {
|
||||
if (!cancelled) setIsLoading(false);
|
||||
}
|
||||
};
|
||||
run();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// --- Derived data ---
|
||||
const ageData: AgeBin[] = useMemo(() => {
|
||||
if (!data) return [];
|
||||
return data.age_distribution.map((d) => ({
|
||||
age_bin: d.age_bin,
|
||||
outpatient: d.outpatient,
|
||||
inpatient: d.inpatient,
|
||||
}));
|
||||
}, [data]);
|
||||
|
||||
const genderData = useMemo(() => {
|
||||
if (!data) return [];
|
||||
const male = data.gender_split.male.inpatient;
|
||||
const female = data.gender_split.female.inpatient;
|
||||
return [
|
||||
{ name: '男性', value: male, color: '#3B82F6' },
|
||||
{ name: '女性', value: female, color: '#EC4899' },
|
||||
];
|
||||
}, [data]);
|
||||
|
||||
const genderTotal = useMemo(() => {
|
||||
return genderData.reduce((s, d) => s + d.value, 0);
|
||||
}, [genderData]);
|
||||
|
||||
const heatmapData = useMemo(() => {
|
||||
if (!data) return { diagnoses: [], matrix: [], totals: [] };
|
||||
return buildHeatmapMatrix(data.age_diagnosis_matrix);
|
||||
}, [data]);
|
||||
|
||||
const heatmapMax = useMemo(() => {
|
||||
let max = 0;
|
||||
for (const row of heatmapData.matrix) {
|
||||
for (const v of row) {
|
||||
if (v > max) max = v;
|
||||
}
|
||||
}
|
||||
return max;
|
||||
}, [heatmapData]);
|
||||
|
||||
// --- Loading state ---
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isEmpty =
|
||||
!data ||
|
||||
(ageData.length === 0 &&
|
||||
genderData.length === 0 &&
|
||||
heatmapData.matrix.length === 0);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-auto">
|
||||
{error && (
|
||||
<div className="px-6 pt-4">
|
||||
<ErrorBanner
|
||||
error={error}
|
||||
onRetry={fetchData}
|
||||
onDismiss={() => setError(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Page header */}
|
||||
<div>
|
||||
<h1 className="font-display text-[18px] font-semibold mb-1 flex items-center gap-2">
|
||||
<Users className="w-5 h-5 text-primary" />
|
||||
人群分析
|
||||
</h1>
|
||||
<p className="text-[12px] text-gray-500">住院患者年龄、性别与诊断分布分析</p>
|
||||
</div>
|
||||
|
||||
{isEmpty ? (
|
||||
<div className="card p-8 text-center text-gray-400 text-sm">
|
||||
<Activity className="w-8 h-8 mx-auto mb-3 opacity-30" />
|
||||
暂无人口统计数据
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Chart 1: Age Distribution Histogram */}
|
||||
<div className="card p-4">
|
||||
<div className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-1">
|
||||
年龄分布
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-400 mb-4">
|
||||
仅住院数据(门诊数据无人口统计信息)
|
||||
</p>
|
||||
{ageData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart
|
||||
data={ageData}
|
||||
margin={{ top: 5, right: 10, left: 0, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
|
||||
<XAxis
|
||||
dataKey="age_bin"
|
||||
tick={{ fontSize: 11, fill: '#64748B' }}
|
||||
axisLine={{ stroke: '#E2E8F0' }}
|
||||
label={{ value: '年龄(岁)', position: 'insideBottom', offset: -5, fontSize: 11, fill: '#64748B' }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 11, fill: '#64748B' }}
|
||||
axisLine={{ stroke: '#E2E8F0' }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#FFFFFF',
|
||||
border: '1px solid #E2E8F0',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
formatter={(value: number) => [value.toLocaleString(), '住院病例']}
|
||||
labelFormatter={(label: number) => `${label} 岁`}
|
||||
/>
|
||||
<Bar dataKey="inpatient" fill="#3B82F6" name="住院" barSize={28} radius={[3, 3, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-400 text-sm">暂无数据</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Chart 2: Gender Distribution Donut */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="card p-4">
|
||||
<div className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-4">
|
||||
性别分布
|
||||
</div>
|
||||
{genderData.length > 0 && genderTotal > 0 ? (
|
||||
<div className="relative">
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={genderData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={55}
|
||||
outerRadius={90}
|
||||
paddingAngle={3}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
>
|
||||
{genderData.map((entry, idx) => (
|
||||
<Cell key={idx} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#FFFFFF',
|
||||
border: '1px solid #E2E8F0',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
formatter={(value: number, name: string) => [
|
||||
`${value.toLocaleString()} (${((value / genderTotal) * 100).toFixed(1)}%)`,
|
||||
name,
|
||||
]}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
{/* Center label */}
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div className="text-center">
|
||||
<div className="text-[24px] font-semibold text-gray-800">
|
||||
{genderTotal.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-[11px] text-gray-500">总计</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Legend below */}
|
||||
<div className="flex justify-center gap-6 mt-2">
|
||||
{genderData.map((d) => (
|
||||
<div key={d.name} className="flex items-center gap-2 text-[13px] text-gray-700">
|
||||
<span
|
||||
className="w-3 h-3 rounded-full inline-block"
|
||||
style={{ backgroundColor: d.color }}
|
||||
/>
|
||||
{d.name}: {d.value.toLocaleString()} ({((d.value / genderTotal) * 100).toFixed(1)}%)
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-400 text-sm">暂无数据</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Placeholder for future chart or spacing */}
|
||||
<div className="hidden lg:block" />
|
||||
</div>
|
||||
|
||||
{/* Chart 3: Age x Diagnosis Heatmap */}
|
||||
<div className="card p-4">
|
||||
<div className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-1">
|
||||
年龄 × 诊断热力图
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-400 mb-4">
|
||||
仅住院数据 — 显示各年龄段最常见的8种诊断
|
||||
</p>
|
||||
{heatmapData.matrix.length > 0 && heatmapData.diagnoses.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<div
|
||||
className="grid gap-px bg-gray-200 border border-gray-200 rounded-lg overflow-hidden"
|
||||
style={{
|
||||
gridTemplateColumns: `80px repeat(${heatmapData.diagnoses.length}, minmax(60px, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{/* Header row */}
|
||||
<div className="bg-gray-100 px-2 py-2 text-[11px] font-medium text-gray-600">
|
||||
年龄
|
||||
</div>
|
||||
{heatmapData.diagnoses.map((diag) => (
|
||||
<div
|
||||
key={diag}
|
||||
className="bg-gray-100 px-2 py-2 text-[11px] font-medium text-gray-600 text-center"
|
||||
title={diag}
|
||||
>
|
||||
{diag.length > 6 ? `${diag.slice(0, 6)}…` : diag}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Data rows */}
|
||||
{AGE_GROUPS.map((ag, rowIdx) => (
|
||||
<Fragment key={ag}>
|
||||
<div
|
||||
key={`label-${ag}`}
|
||||
className="bg-white px-2 py-2 text-[12px] text-gray-700 font-medium flex items-center"
|
||||
>
|
||||
{ag}
|
||||
</div>
|
||||
{heatmapData.matrix[rowIdx].map((value, colIdx) => (
|
||||
<div
|
||||
key={`${ag}-${colIdx}`}
|
||||
className={`${getColorClass(value, heatmapMax)} px-2 py-2 text-center text-[12px] font-medium transition-colors cursor-default`}
|
||||
title={`${ag}岁 | ${heatmapData.diagnoses[colIdx]}: ${value} 例`}
|
||||
>
|
||||
{value > 0 ? value.toLocaleString() : '-'}
|
||||
</div>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-400 text-sm">暂无数据</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
501
frontend/src/pages/DiseaseAnalysis.tsx
Normal file
501
frontend/src/pages/DiseaseAnalysis.tsx
Normal file
@@ -0,0 +1,501 @@
|
||||
import { useEffect, useState, Fragment } from 'react';
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
Cell,
|
||||
ReferenceLine,
|
||||
} from 'recharts';
|
||||
import { Stethoscope, Activity } from 'lucide-react';
|
||||
import { caseApi } from '@/services/api';
|
||||
import { ErrorBanner } from '@/components/ErrorBanner';
|
||||
import type {
|
||||
DiagnosisDistributionItem,
|
||||
DiseaseSeasonalityPoint,
|
||||
DistrictCaseData,
|
||||
} from '@/types';
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function truncate(s: string, max: number): string {
|
||||
return s.length > max ? s.slice(0, max) + '…' : s;
|
||||
}
|
||||
|
||||
/** Blue intensity scale: 0 -> #EFF6FF, max -> #1D4ED8 */
|
||||
function blueIntensity(value: number, domainMin: number, domainMax: number): string {
|
||||
if (domainMax === domainMin) return '#3B82F6';
|
||||
const norm = (value - domainMin) / (domainMax - domainMin);
|
||||
const r = Math.round(29 + (239 - 29) * (1 - norm));
|
||||
const g = Math.round(78 + (246 - 78) * (1 - norm));
|
||||
const b = Math.round(216 + (255 - 216) * (1 - norm));
|
||||
return `rgb(${r},${g},${b})`;
|
||||
}
|
||||
|
||||
// --- Chart 1: Diagnosis Distribution (Horizontal Percentage Bar) ---
|
||||
|
||||
function DiagnosisDistributionChart({ data }: { data: DiagnosisDistributionItem[] }) {
|
||||
if (!data || data.length === 0) {
|
||||
return <div className="text-center py-8 text-gray-400 text-sm">暂无数据</div>;
|
||||
}
|
||||
|
||||
const sorted = [...data].sort((a, b) => b.total - a.total);
|
||||
const grandTotal = sorted.reduce((s, d) => s + d.total, 0);
|
||||
|
||||
const chartData = sorted.map((d) => ({
|
||||
...d,
|
||||
displayName: truncate(d.diagnosis, 8),
|
||||
outpatientPct: grandTotal > 0 ? (d.outpatient / grandTotal) * 100 : 0,
|
||||
inpatientPct: grandTotal > 0 ? (d.inpatient / grandTotal) * 100 : 0,
|
||||
}));
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={400}>
|
||||
<BarChart
|
||||
data={[...chartData].reverse()}
|
||||
layout="vertical"
|
||||
margin={{ top: 5, right: 20, left: 40, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" horizontal={false} />
|
||||
<XAxis
|
||||
type="number"
|
||||
tick={{ fontSize: 10, fill: '#64748B' }}
|
||||
tickFormatter={(v) => `${v.toFixed(1)}%`}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="displayName"
|
||||
tick={{ fontSize: 10, fill: '#374151' }}
|
||||
width={70}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#FFFFFF',
|
||||
border: '1px solid #E2E8F0',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
formatter={(value: number, name: string) => {
|
||||
const label = name === 'outpatientPct' ? '门诊占总病例比' : '住院占总病例比';
|
||||
return [`${value.toFixed(1)}%`, label];
|
||||
}}
|
||||
/>
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: '11px' }}
|
||||
payload={[
|
||||
{ value: '门诊', type: 'rect', color: '#3B82F6' },
|
||||
{ value: '住院', type: 'rect', color: '#EF4444' },
|
||||
]}
|
||||
/>
|
||||
<Bar dataKey="outpatientPct" stackId="a" fill="#3B82F6" name="outpatientPct" barSize={18} />
|
||||
<Bar dataKey="inpatientPct" stackId="a" fill="#EF4444" name="inpatientPct" barSize={18} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Chart 2: Diagnosis Seasonality Heatmap ---
|
||||
|
||||
function SeasonalityHeatmap({ data }: { data: DiseaseSeasonalityPoint[] }) {
|
||||
if (!data || data.length === 0) {
|
||||
return <div className="text-center py-8 text-gray-400 text-sm">暂无数据</div>;
|
||||
}
|
||||
|
||||
// Get unique diagnoses (top 10 by total) and months that actually have cases.
|
||||
// Backend always emits all 12 months (zero-filled), so only count months
|
||||
// where some diagnosis has total > 0 to detect genuine single-month coverage.
|
||||
const diagTotals = new Map<string, number>();
|
||||
const monthSet = new Set<number>();
|
||||
for (const d of data) {
|
||||
diagTotals.set(d.diagnosis, (diagTotals.get(d.diagnosis) || 0) + d.total);
|
||||
if (d.total > 0) monthSet.add(d.month);
|
||||
}
|
||||
|
||||
const topDiags = [...diagTotals.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10)
|
||||
.map(([name]) => name);
|
||||
|
||||
const months = [...monthSet].sort((a, b) => a - b);
|
||||
const uniqueMonths = months.length;
|
||||
|
||||
// Check if only one month has data
|
||||
const singleMonthNote = uniqueMonths <= 1;
|
||||
|
||||
// Build lookup: diagnosis -> month -> total
|
||||
const lookup = new Map<string, Map<number, number>>();
|
||||
for (const d of data) {
|
||||
if (!lookup.has(d.diagnosis)) lookup.set(d.diagnosis, new Map());
|
||||
lookup.get(d.diagnosis)!.set(d.month, d.total);
|
||||
}
|
||||
|
||||
// Find min/max for color scale
|
||||
let minVal = Infinity;
|
||||
let maxVal = -Infinity;
|
||||
for (const d of data) {
|
||||
if (d.total < minVal) minVal = d.total;
|
||||
if (d.total > maxVal) maxVal = d.total;
|
||||
}
|
||||
if (minVal === Infinity) minVal = 0;
|
||||
if (maxVal === -Infinity) maxVal = 0;
|
||||
|
||||
const monthLabels = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{singleMonthNote && (
|
||||
<div className="mb-3 text-xs text-amber-600 bg-amber-50 border border-amber-200 rounded px-3 py-2">
|
||||
当前数据仅覆盖单月,完整季节性分析需要全年数据
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="grid gap-px bg-gray-200 border border-gray-200 rounded overflow-hidden"
|
||||
style={{
|
||||
gridTemplateColumns: `minmax(90px, auto) repeat(${uniqueMonths > 0 ? uniqueMonths : 12}, 1fr)`,
|
||||
}}
|
||||
>
|
||||
{/* Header row */}
|
||||
<div className="bg-gray-100 text-[11px] font-medium text-gray-600 px-2 py-2 text-center">
|
||||
诊断
|
||||
</div>
|
||||
{(uniqueMonths > 0 ? months : Array.from({ length: 12 }, (_, i) => i + 1)).map((m) => (
|
||||
<div
|
||||
key={m}
|
||||
className="bg-gray-100 text-[10px] font-medium text-gray-600 px-1 py-2 text-center"
|
||||
>
|
||||
{monthLabels[m - 1]}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Data rows */}
|
||||
{topDiags.map((diag) => (
|
||||
<Fragment key={diag}>
|
||||
<div className="bg-white text-[11px] text-gray-700 px-2 py-2 flex items-center truncate">
|
||||
{truncate(diag, 10)}
|
||||
</div>
|
||||
{(uniqueMonths > 0 ? months : Array.from({ length: 12 }, (_, i) => i + 1)).map((m) => {
|
||||
const val = lookup.get(diag)?.get(m) ?? 0;
|
||||
const isZero = val === 0;
|
||||
return (
|
||||
<div
|
||||
key={m}
|
||||
className="text-[10px] font-medium text-center py-2 px-1"
|
||||
style={{
|
||||
backgroundColor: isZero ? '#F9FAFB' : blueIntensity(val, minVal, maxVal),
|
||||
color: isZero ? '#D1D5DB' : val > (maxVal * 0.7) ? '#FFFFFF' : '#1E293B',
|
||||
}}
|
||||
>
|
||||
{val > 0 ? val.toLocaleString() : '-'}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Chart 3: O/I Ratio by Diagnosis ---
|
||||
|
||||
function OIRatioChart({
|
||||
data,
|
||||
avgRatio,
|
||||
}: {
|
||||
data: DiagnosisDistributionItem[];
|
||||
avgRatio: number;
|
||||
}) {
|
||||
if (!data || data.length === 0) {
|
||||
return <div className="text-center py-8 text-gray-400 text-sm">暂无数据</div>;
|
||||
}
|
||||
|
||||
const chartData = [...data]
|
||||
.map((d) => ({
|
||||
diagnosis: d.diagnosis,
|
||||
displayName: truncate(d.diagnosis, 8),
|
||||
// inpatient>0: real O/I ratio (0 = inpatient-only = most severe).
|
||||
// inpatient=0, outpatient>0: outpatient-only (no inpatient) -> Infinity, no data.
|
||||
// both 0: no data -> NaN.
|
||||
ratio: d.inpatient > 0 ? d.outpatient / d.inpatient : d.outpatient > 0 ? Infinity : NaN,
|
||||
outpatient: d.outpatient,
|
||||
inpatient: d.inpatient,
|
||||
}))
|
||||
// Keep inpatient-only diagnoses (ratio 0, most severe); only drop no-data rows.
|
||||
.filter((d) => Number.isFinite(d.ratio))
|
||||
.sort((a, b) => a.ratio - b.ratio)
|
||||
.slice(0, 15);
|
||||
|
||||
const maxRatio = Math.max(...chartData.map((d) => d.ratio), avgRatio);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="text-[11px] text-gray-500 mb-3">
|
||||
城市平均O/I比率:<span className="font-semibold text-gray-700">{avgRatio.toFixed(1)}</span>
|
||||
<span className="ml-2 text-[10px] text-gray-400">
|
||||
O/I比率越低说明疾病越严重(住院比例高)
|
||||
</span>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={400}>
|
||||
<BarChart
|
||||
data={[...chartData].reverse()}
|
||||
layout="vertical"
|
||||
margin={{ top: 5, right: 20, left: 50, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" horizontal={false} />
|
||||
<XAxis
|
||||
type="number"
|
||||
domain={[0, maxRatio * 1.15]}
|
||||
tick={{ fontSize: 10, fill: '#64748B' }}
|
||||
tickFormatter={(v) => v.toFixed(1)}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="displayName"
|
||||
tick={{ fontSize: 10, fill: '#374151' }}
|
||||
width={70}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#FFFFFF',
|
||||
border: '1px solid #E2E8F0',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
formatter={(value: number, name: string) => {
|
||||
if (name === 'ratio') return [value.toFixed(1), 'O/I比率'];
|
||||
return [value, ''];
|
||||
}}
|
||||
/>
|
||||
<ReferenceLine x={avgRatio} stroke="#F59E0B" strokeDasharray="6 4" strokeWidth={1.5} />
|
||||
<Bar dataKey="ratio" name="ratio" barSize={16}>
|
||||
{chartData.map((entry, idx) => (
|
||||
<Cell
|
||||
key={idx}
|
||||
fill={entry.ratio >= avgRatio ? '#22C55E' : '#EF4444'}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="flex items-center gap-4 mt-2 text-[10px] text-gray-400 px-4">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-3 h-3 bg-green-500 rounded-sm" /> 高于均值 = 门诊比例高(较轻)
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-3 h-3 bg-red-500 rounded-sm" /> 低于均值 = 住院比例高(较重)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Chart 4: Diagnosis Summary Table ---
|
||||
|
||||
function DiagnosisSummaryTable({
|
||||
diagnoses,
|
||||
districtsData,
|
||||
}: {
|
||||
diagnoses: DiagnosisDistributionItem[];
|
||||
districtsData: DistrictCaseData[];
|
||||
}) {
|
||||
if (!diagnoses || diagnoses.length === 0) {
|
||||
return <div className="text-center py-8 text-gray-400 text-sm">暂无数据</div>;
|
||||
}
|
||||
|
||||
const top5 = diagnoses.slice(0, 5);
|
||||
|
||||
// Per-diagnosis top district is not exposed by the API; show the city-wide
|
||||
// highest-caseload district honestly (same for every row, labeled as such).
|
||||
const topDistrict = districtsData.length > 0
|
||||
? districtsData.reduce((best, d) => (d.total > best.total ? d : best), districtsData[0]).district
|
||||
: '--';
|
||||
|
||||
// Color for total column: green (low) -> yellow (mid) -> red (high)
|
||||
function totalColor(total: number, allTotals: number[]): string {
|
||||
if (allTotals.length === 0) return '#9CA3AF';
|
||||
const mn = Math.min(...allTotals);
|
||||
const mx = Math.max(...allTotals);
|
||||
if (mx === mn) return '#22C55E';
|
||||
const norm = (total - mn) / (mx - mn);
|
||||
if (norm < 0.33) return '#22C55E';
|
||||
if (norm < 0.66) return '#EAB308';
|
||||
return '#EF4444';
|
||||
}
|
||||
|
||||
const totals = top5.map((d) => d.total);
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[12px] border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200">
|
||||
<th className="text-left px-3 py-2 text-[11px] font-medium text-gray-500 uppercase tracking-wide">
|
||||
诊断
|
||||
</th>
|
||||
<th className="text-right px-3 py-2 text-[11px] font-medium text-gray-500 uppercase tracking-wide">
|
||||
门诊
|
||||
</th>
|
||||
<th className="text-right px-3 py-2 text-[11px] font-medium text-gray-500 uppercase tracking-wide">
|
||||
住院
|
||||
</th>
|
||||
<th className="text-right px-3 py-2 text-[11px] font-medium text-gray-500 uppercase tracking-wide">
|
||||
合计
|
||||
</th>
|
||||
<th className="text-right px-3 py-2 text-[11px] font-medium text-gray-500 uppercase tracking-wide">
|
||||
全市最高就诊区
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{top5.map((d) => (
|
||||
<tr key={d.diagnosis} className="border-b border-gray-100 hover:bg-gray-50">
|
||||
<td className="px-3 py-2.5 text-gray-700">{d.diagnosis}</td>
|
||||
<td className="px-3 py-2.5 text-right text-blue-600">{d.outpatient.toLocaleString()}</td>
|
||||
<td className="px-3 py-2.5 text-right text-red-600">{d.inpatient.toLocaleString()}</td>
|
||||
<td className="px-3 py-2.5 text-right font-semibold" style={{ color: totalColor(d.total, totals) }}>
|
||||
{d.total.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right text-gray-500">{topDistrict}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Page Component ---
|
||||
|
||||
export function DiseaseAnalysis() {
|
||||
const [diagDistribution, setDiagDistribution] = useState<DiagnosisDistributionItem[]>([]);
|
||||
const [seasonality, setSeasonality] = useState<DiseaseSeasonalityPoint[]>([]);
|
||||
const [districts, setDistricts] = useState<DistrictCaseData[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [errors, setErrors] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const fetchAll = async () => {
|
||||
setIsLoading(true);
|
||||
setErrors([]);
|
||||
|
||||
const [distR, seasonR, districtR] = await Promise.allSettled([
|
||||
caseApi.getDiagnosisDistribution(15),
|
||||
caseApi.getDiseaseSeasonality(),
|
||||
caseApi.getDistricts(),
|
||||
]);
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
const newErrors: string[] = [];
|
||||
|
||||
if (distR.status === 'fulfilled') {
|
||||
setDiagDistribution(distR.value.diagnoses || []);
|
||||
} else {
|
||||
newErrors.push('诊断分布数据加载失败');
|
||||
}
|
||||
|
||||
if (seasonR.status === 'fulfilled') {
|
||||
setSeasonality(seasonR.value.seasonality || []);
|
||||
} else {
|
||||
newErrors.push('季节性数据加载失败');
|
||||
}
|
||||
|
||||
if (districtR.status === 'fulfilled') {
|
||||
setDistricts(districtR.value.districts || []);
|
||||
} else {
|
||||
newErrors.push('区县数据加载失败');
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
fetchAll();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// --- Loading ---
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Compute average O/I ratio
|
||||
const totalOut = diagDistribution.reduce((s, d) => s + d.outpatient, 0);
|
||||
const totalIn = diagDistribution.reduce((s, d) => s + d.inpatient, 0);
|
||||
const avgOIRatio = totalIn > 0 ? totalOut / totalIn : 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-auto">
|
||||
{errors.length > 0 && (
|
||||
<div className="px-6 pt-4">
|
||||
<ErrorBanner
|
||||
error={errors.join(';')}
|
||||
onRetry={() => window.location.reload()}
|
||||
onDismiss={() => setErrors([])}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Page header */}
|
||||
<div>
|
||||
<h1 className="font-display text-[18px] font-semibold mb-1 flex items-center gap-2">
|
||||
<Stethoscope className="w-5 h-5 text-primary" />
|
||||
疾病分析
|
||||
</h1>
|
||||
<p className="text-[12px] text-gray-500">诊断分布、季节性及门诊/住院比率分析</p>
|
||||
</div>
|
||||
|
||||
{/* Chart 1: Diagnosis Distribution */}
|
||||
<div className="card p-4">
|
||||
<div className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-4">
|
||||
诊断分布(门诊/住院百分比堆叠)
|
||||
</div>
|
||||
<DiagnosisDistributionChart data={diagDistribution} />
|
||||
</div>
|
||||
|
||||
{/* Chart 2: Seasonality Heatmap */}
|
||||
<div className="card p-4">
|
||||
<div className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-4">
|
||||
诊断季节性热力图
|
||||
</div>
|
||||
<SeasonalityHeatmap data={seasonality} />
|
||||
</div>
|
||||
|
||||
{/* Chart 3: O/I Ratio */}
|
||||
<div className="card p-4">
|
||||
<div className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-4">
|
||||
门诊/住院比率(O/I Ratio)
|
||||
</div>
|
||||
<OIRatioChart data={diagDistribution} avgRatio={avgOIRatio} />
|
||||
</div>
|
||||
|
||||
{/* Chart 4: Diagnosis Summary Table */}
|
||||
<div className="card p-4">
|
||||
<div className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-4 flex items-center gap-2">
|
||||
<Activity className="w-3.5 h-3.5 text-gray-400" />
|
||||
Top 5 诊断概览表
|
||||
</div>
|
||||
<DiagnosisSummaryTable diagnoses={diagDistribution} districtsData={districts} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
@@ -8,10 +8,14 @@ import {
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Cell,
|
||||
ReferenceLine,
|
||||
} from 'recharts';
|
||||
import { useAnalysisStore } from '@/stores/analysisStore';
|
||||
import { caseApi } from '@/services/api';
|
||||
import { ErrorBanner } from '@/components/ErrorBanner';
|
||||
import { MetricHeatmapTable } from '@/components/MetricHeatmapTable';
|
||||
import { BarChart3, MapPin, Users, Shield } from 'lucide-react';
|
||||
import type { DistrictCaseData } from '@/types';
|
||||
|
||||
const COLORS = ['#DC2626', '#D97706', '#2563EB', '#059669', '#7C3AED', '#0891B2', '#EA580C', '#84CC16'];
|
||||
|
||||
@@ -28,11 +32,34 @@ export function DistrictComparison() {
|
||||
const clearError = useAnalysisStore((s) => s.clearError);
|
||||
const fetchDistricts = useAnalysisStore((s) => s.fetchDistricts);
|
||||
const [metric, setMetric] = useState<'avg_aqi' | 'avg_risk' | 'high_risk_count'>('avg_aqi');
|
||||
const [caseDistrictData, setCaseDistrictData] = useState<DistrictCaseData[]>([]);
|
||||
const [caseDataLoading, setCaseDataLoading] = useState(false);
|
||||
const [caseDataError, setCaseDataError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDistricts();
|
||||
}, [fetchDistricts]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setCaseDataLoading(true);
|
||||
setCaseDataError(null);
|
||||
caseApi.getDistricts()
|
||||
.then((res) => {
|
||||
if (!cancelled) {
|
||||
setCaseDistrictData(res.districts || []);
|
||||
setCaseDataLoading(false);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) {
|
||||
setCaseDataError((e as Error).message || '加载病例数据失败');
|
||||
setCaseDataLoading(false);
|
||||
}
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const metricConfig = {
|
||||
avg_aqi: { label: '平均AQI', color: '#2563EB', unit: '' },
|
||||
avg_risk: { label: '平均风险', color: '#DC2626', unit: '' },
|
||||
@@ -45,6 +72,54 @@ export function DistrictComparison() {
|
||||
return bVal - aVal;
|
||||
});
|
||||
|
||||
const populationNormalizedData = useMemo(() => {
|
||||
const data = districtData
|
||||
.filter((d) => d.population > 0)
|
||||
.map((d) => ({
|
||||
district: d.district,
|
||||
casesPer100K: Math.round((d.total_cases / d.population) * 100000),
|
||||
}))
|
||||
.sort((a, b) => b.casesPer100K - a.casesPer100K);
|
||||
const filtered = districtData.filter((d) => d.population > 0);
|
||||
const totalCases = filtered.reduce((sum, d) => sum + d.total_cases, 0);
|
||||
const totalPop = filtered.reduce((sum, d) => sum + d.population, 0);
|
||||
const cityAvg = totalPop > 0 ? Math.round((totalCases / totalPop) * 100000) : 0;
|
||||
return { data, cityAvg, totalCases, totalPop };
|
||||
}, [districtData]);
|
||||
|
||||
const oiRatioData = useMemo(() => {
|
||||
const data = caseDistrictData
|
||||
.map((d) => ({
|
||||
district: d.district,
|
||||
ratio: d.outpatient_ratio && d.inpatient_ratio && d.inpatient_ratio > 0
|
||||
? Number((d.outpatient_ratio / d.inpatient_ratio).toFixed(2))
|
||||
: 0,
|
||||
}))
|
||||
.filter((d) => d.ratio > 0)
|
||||
.sort((a, b) => b.ratio - a.ratio);
|
||||
const cityOIAvg = data.length > 0
|
||||
? Number((data.reduce((sum, d) => sum + d.ratio, 0) / data.length).toFixed(2))
|
||||
: 0;
|
||||
return { data, cityOIAvg };
|
||||
}, [caseDistrictData]);
|
||||
|
||||
const heatmapMetrics = useMemo(() => {
|
||||
const caseMap = new Map(caseDistrictData.map((d) => [d.district, d]));
|
||||
const rows: string[] = [];
|
||||
const map: Record<string, Record<string, number>> = {};
|
||||
for (const d of districtData) {
|
||||
rows.push(d.district);
|
||||
const c = caseMap.get(d.district);
|
||||
map[d.district] = {
|
||||
total_cases: d.total_cases,
|
||||
outpatient_ratio: c?.outpatient_ratio ?? 0,
|
||||
avg_risk: Math.round(d.avg_risk * 100),
|
||||
high_risk_count: d.high_risk_count,
|
||||
};
|
||||
}
|
||||
return { rows, map };
|
||||
}, [districtData, caseDistrictData]);
|
||||
|
||||
const getRiskLevel = (risk: number) => {
|
||||
if (risk >= 0.7) return 'high';
|
||||
if (risk >= 0.4) return 'medium';
|
||||
@@ -60,6 +135,25 @@ export function DistrictComparison() {
|
||||
onDismiss={clearError}
|
||||
/>
|
||||
)}
|
||||
{caseDataError && (
|
||||
<ErrorBanner
|
||||
error={caseDataError}
|
||||
onRetry={() => {
|
||||
setCaseDataError(null);
|
||||
setCaseDataLoading(true);
|
||||
caseApi.getDistricts()
|
||||
.then((res) => {
|
||||
setCaseDistrictData(res.districts || []);
|
||||
setCaseDataLoading(false);
|
||||
})
|
||||
.catch((e) => {
|
||||
setCaseDataError((e as Error).message || '加载病例数据失败');
|
||||
setCaseDataLoading(false);
|
||||
});
|
||||
}}
|
||||
onDismiss={() => setCaseDataError(null)}
|
||||
/>
|
||||
)}
|
||||
<div className="mb-5">
|
||||
<h1 className="font-display text-[18px] font-semibold mb-1 flex items-center gap-2">
|
||||
<BarChart3 className="w-5 h-5 text-primary" />
|
||||
@@ -227,6 +321,149 @@ export function DistrictComparison() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Population-Normalized Rates Bar Chart */}
|
||||
<div className="card p-4 mb-4">
|
||||
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
|
||||
每10万人病例数 区域排名
|
||||
</div>
|
||||
{populationNormalizedData.data.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={350}>
|
||||
<BarChart
|
||||
data={populationNormalizedData.data}
|
||||
margin={{ top: 5, right: 20, left: 10, bottom: 5 }}
|
||||
layout="vertical"
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" horizontal={false} />
|
||||
<XAxis
|
||||
type="number"
|
||||
tick={{ fontSize: 12, fill: '#64748B' }}
|
||||
axisLine={{ stroke: '#E2E8F0' }}
|
||||
label={{ value: '每10万人病例数', position: 'insideBottom', offset: -5, fontSize: 12, fill: '#64748B' }}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="district"
|
||||
tick={{ fontSize: 12, fill: '#1E293B', fontWeight: 500 }}
|
||||
axisLine={{ stroke: '#E2E8F0' }}
|
||||
width={80}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#FFFFFF',
|
||||
border: '1px solid #E2E8F0',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
formatter={(value: number) => [
|
||||
`${value.toLocaleString()} 每10万人`,
|
||||
'病例率',
|
||||
]}
|
||||
/>
|
||||
<ReferenceLine
|
||||
x={populationNormalizedData.cityAvg}
|
||||
stroke="#64748B"
|
||||
strokeDasharray="6 4"
|
||||
label={{
|
||||
value: `全市平均: ${populationNormalizedData.cityAvg.toLocaleString()}`,
|
||||
position: 'top',
|
||||
fontSize: 11,
|
||||
fill: '#64748B',
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="casesPer100K" name="病例率" radius={[0, 4, 4, 0]} maxBarSize={32} fill="#3B82F6" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="text-center py-8 text-text-secondary">无人口数据</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* O/I Ratio Comparison Bar Chart */}
|
||||
<div className="card p-4 mb-4">
|
||||
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
|
||||
门诊/住院比 (O/I Ratio) 区域排名
|
||||
</div>
|
||||
{caseDataLoading && (
|
||||
<div className="text-center py-8 text-text-secondary">病例数据加载中...</div>
|
||||
)}
|
||||
{!caseDataLoading && oiRatioData.data.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={350}>
|
||||
<BarChart
|
||||
data={oiRatioData.data}
|
||||
margin={{ top: 5, right: 20, left: 10, bottom: 5 }}
|
||||
layout="vertical"
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" horizontal={false} />
|
||||
<XAxis
|
||||
type="number"
|
||||
tick={{ fontSize: 12, fill: '#64748B' }}
|
||||
axisLine={{ stroke: '#E2E8F0' }}
|
||||
label={{ value: 'O/I Ratio', position: 'insideBottom', offset: -5, fontSize: 12, fill: '#64748B' }}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="district"
|
||||
tick={{ fontSize: 12, fill: '#1E293B', fontWeight: 500 }}
|
||||
axisLine={{ stroke: '#E2E8F0' }}
|
||||
width={80}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#FFFFFF',
|
||||
border: '1px solid #E2E8F0',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
formatter={(value: number) => [
|
||||
value.toFixed(2),
|
||||
'O/I Ratio',
|
||||
]}
|
||||
/>
|
||||
<ReferenceLine
|
||||
x={oiRatioData.cityOIAvg}
|
||||
stroke="#64748B"
|
||||
strokeDasharray="6 4"
|
||||
label={{
|
||||
value: `全市平均: ${oiRatioData.cityOIAvg.toFixed(2)}`,
|
||||
position: 'top',
|
||||
fontSize: 11,
|
||||
fill: '#64748B',
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="ratio" name="O/I Ratio" radius={[0, 4, 4, 0]} maxBarSize={32}>
|
||||
{oiRatioData.data.map((entry) => (
|
||||
<Cell
|
||||
key={entry.district}
|
||||
fill={entry.ratio >= oiRatioData.cityOIAvg ? '#10B981' : '#EF4444'}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
!caseDataLoading && (
|
||||
<div className="text-center py-8 text-text-secondary">无病例数据</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* District Metric Heatmap Table */}
|
||||
<div className="card p-4 mb-4">
|
||||
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
|
||||
区域指标热力图
|
||||
</div>
|
||||
<MetricHeatmapTable
|
||||
rows={heatmapMetrics.rows}
|
||||
columns={[
|
||||
{ key: 'total_cases', label: '总病例' },
|
||||
{ key: 'outpatient_ratio', label: '门诊占比(%)' },
|
||||
{ key: 'avg_risk', label: '平均风险(%)' },
|
||||
{ key: 'high_risk_count', label: '高风险网格' },
|
||||
]}
|
||||
data={heatmapMetrics.map}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
712
frontend/src/pages/EnvironmentalHealth.tsx
Normal file
712
frontend/src/pages/EnvironmentalHealth.tsx
Normal file
@@ -0,0 +1,712 @@
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
ReferenceLine,
|
||||
Cell,
|
||||
} from 'recharts';
|
||||
import { Wind } from 'lucide-react';
|
||||
import { envApi, caseApi } from '@/services/api';
|
||||
import { ErrorBanner } from '@/components/ErrorBanner';
|
||||
import { CalendarHeatmap } from '@/components/CalendarHeatmap';
|
||||
import type {
|
||||
LagCorrelationItem,
|
||||
PollutantPoint,
|
||||
CaseTrendPoint,
|
||||
} from '@/types';
|
||||
|
||||
// --- Constants ---
|
||||
|
||||
const POLLUTANT_OPTIONS = [
|
||||
{ key: 'PM25', label: 'PM2.5', color: '#DC2626', unit: 'μg/m³' },
|
||||
{ key: 'PM10', label: 'PM10', color: '#D97706', unit: 'μg/m³' },
|
||||
{ key: 'SO2', label: 'SO₂', color: '#7C3AED', unit: 'μg/m³' },
|
||||
{ key: 'NO2', label: 'NO₂', color: '#059669', unit: 'μg/m³' },
|
||||
{ key: 'O3', label: 'O₃', color: '#EA580C', unit: 'μg/m³' },
|
||||
{ key: 'CO', label: 'CO', color: '#0891B2', unit: 'mg/m³' },
|
||||
];
|
||||
|
||||
const AQI_CATEGORIES = [
|
||||
{ label: '优', range: [0, 50] as [number, number], color: '#10B981' },
|
||||
{ label: '良', range: [50, 100] as [number, number], color: '#F59E0B' },
|
||||
{ label: '轻度污染', range: [100, 150] as [number, number], color: '#F97316' },
|
||||
{ label: '中度污染', range: [150, 200] as [number, number], color: '#EF4444' },
|
||||
{ label: '重度污染', range: [200, 9999] as [number, number], color: '#7C3AED' },
|
||||
];
|
||||
|
||||
const LAG_DAYS = [1, 2, 3, 5, 7, 14];
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function formatDateLabel(dateStr: string): string {
|
||||
const d = new Date(dateStr);
|
||||
return `${d.getMonth() + 1}/${d.getDate()}`;
|
||||
}
|
||||
|
||||
function getAQICategory(aqi: number): typeof AQI_CATEGORIES[number] {
|
||||
for (const cat of AQI_CATEGORIES) {
|
||||
if (aqi >= cat.range[0] && aqi < cat.range[1]) return cat;
|
||||
}
|
||||
return AQI_CATEGORIES[AQI_CATEGORIES.length - 1];
|
||||
}
|
||||
|
||||
function findMaxLag(correlations: LagCorrelationItem[], pollutant: string): number | null {
|
||||
if (correlations.length === 0) return null;
|
||||
const pollData = correlations.filter(
|
||||
(c) => c.pollutant === pollutant && c.lag_days > 0,
|
||||
);
|
||||
if (pollData.length === 0) return null;
|
||||
let maxAbs = 0;
|
||||
let bestLag = 0;
|
||||
for (const c of pollData) {
|
||||
if (Math.abs(c.correlation) > Math.abs(maxAbs)) {
|
||||
maxAbs = Math.abs(c.correlation);
|
||||
bestLag = c.lag_days;
|
||||
}
|
||||
}
|
||||
return bestLag;
|
||||
}
|
||||
|
||||
// --- Page ---
|
||||
|
||||
export function EnvironmentalHealth() {
|
||||
// Data states
|
||||
const [lagData, setLagData] = useState<LagCorrelationItem[]>([]);
|
||||
const [pollutants365, setPollutants365] = useState<PollutantPoint[]>([]);
|
||||
const [pollutants30, setPollutants30] = useState<PollutantPoint[]>([]);
|
||||
const [caseTrend, setCaseTrend] = useState<CaseTrendPoint[]>([]);
|
||||
|
||||
// UI states
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [errors, setErrors] = useState<string[]>([]);
|
||||
const [selectedPollutants, setSelectedPollutants] = useState<string[]>([
|
||||
'PM25',
|
||||
'PM10',
|
||||
'SO2',
|
||||
'NO2',
|
||||
'O3',
|
||||
'CO',
|
||||
]);
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const fetchAll = async () => {
|
||||
setIsLoading(true);
|
||||
setErrors([]);
|
||||
|
||||
const [lagR, p365R, p30R, caseTrendR] = await Promise.allSettled([
|
||||
envApi.getLagCorrelations(),
|
||||
envApi.getPollutants(365),
|
||||
envApi.getPollutants(30),
|
||||
caseApi.getTrend({ group_by: 'day' }),
|
||||
]);
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
const newErrors: string[] = [];
|
||||
|
||||
if (lagR.status === 'fulfilled') {
|
||||
// Normalize pollutant keys to the canonical set used across this page
|
||||
// (PollutantPoint keys): the correlation API uses "PM2.5" -> "PM25".
|
||||
const normalized = (lagR.value.correlations || []).map((c) => ({
|
||||
...c,
|
||||
pollutant: c.pollutant === 'PM2.5' ? 'PM25' : c.pollutant,
|
||||
}));
|
||||
setLagData(normalized);
|
||||
} else {
|
||||
newErrors.push('滞后相关性数据加载失败');
|
||||
}
|
||||
|
||||
if (p365R.status === 'fulfilled') {
|
||||
setPollutants365(p365R.value.data || []);
|
||||
} else {
|
||||
newErrors.push('环境数据加载失败');
|
||||
}
|
||||
|
||||
if (p30R.status === 'fulfilled') {
|
||||
setPollutants30(p30R.value.data || []);
|
||||
} else {
|
||||
newErrors.push('30天污染物数据加载失败');
|
||||
}
|
||||
|
||||
if (caseTrendR.status === 'fulfilled') {
|
||||
setCaseTrend(caseTrendR.value.trend || []);
|
||||
} else {
|
||||
newErrors.push('病例趋势数据加载失败');
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
fetchAll();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const togglePollutant = (key: string) => {
|
||||
setSelectedPollutants((prev) =>
|
||||
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
|
||||
);
|
||||
};
|
||||
|
||||
// --- Chart 1: Pollutant-Case Correlation (lag=1) ---
|
||||
const lag1Correlations = useMemo(() => {
|
||||
return lagData
|
||||
.filter((c) => c.lag_days === 1)
|
||||
.sort((a, b) => Math.abs(b.correlation) - Math.abs(a.correlation));
|
||||
}, [lagData]);
|
||||
|
||||
// --- Chart 2: PM2.5 + PM10 lag series ---
|
||||
const pmLagSeries = useMemo(() => {
|
||||
return LAG_DAYS.map((lag) => {
|
||||
const pm25 = lagData.find(
|
||||
(c) => c.pollutant === 'PM25' && c.lag_days === lag,
|
||||
);
|
||||
const pm10 = lagData.find(
|
||||
(c) => c.pollutant === 'PM10' && c.lag_days === lag,
|
||||
);
|
||||
return {
|
||||
lag_days: lag,
|
||||
PM25Corr: pm25?.correlation ?? 0,
|
||||
PM10Corr: pm10?.correlation ?? 0,
|
||||
};
|
||||
});
|
||||
}, [lagData]);
|
||||
|
||||
const maxLagInsight = useMemo(() => {
|
||||
const pm25Lag = findMaxLag(lagData, 'PM25');
|
||||
const pm10Lag = findMaxLag(lagData, 'PM10');
|
||||
if (pm25Lag === null && pm10Lag === null) return '';
|
||||
const parts: string[] = [];
|
||||
if (pm25Lag !== null) parts.push(`PM2.5滞后${pm25Lag}天`);
|
||||
if (pm10Lag !== null) parts.push(`PM10滞后${pm10Lag}天`);
|
||||
return `PM2.5和PM10对健康影响的最强效应出现在${parts.join('和')}`;
|
||||
}, [lagData]);
|
||||
|
||||
// --- Chart 3: Pollution Episode Impact ---
|
||||
const episodeData = useMemo(() => {
|
||||
if (pollutants365.length === 0 || caseTrend.length === 0) return [];
|
||||
|
||||
// Build date->AQI map from 365-day pollutants
|
||||
const aqiByDate = new Map<string, number>();
|
||||
for (const p of pollutants365) {
|
||||
aqiByDate.set(p.date, p.AQI || 0);
|
||||
}
|
||||
|
||||
// Build date->cases map from case trend
|
||||
const casesByDate = new Map<string, number>();
|
||||
for (const c of caseTrend) {
|
||||
casesByDate.set(c.date, c.total);
|
||||
}
|
||||
|
||||
// Group by AQI category
|
||||
const groups: Record<string, { totalCases: number; dayCount: number }> = {};
|
||||
for (const cat of AQI_CATEGORIES) {
|
||||
groups[cat.label] = { totalCases: 0, dayCount: 0 };
|
||||
}
|
||||
|
||||
// Only count dates that exist in BOTH series. Dates with no matching case
|
||||
// record are gaps, not zero-case days, and would otherwise drag averages down.
|
||||
for (const [date, aqi] of aqiByDate) {
|
||||
const cases = casesByDate.get(date);
|
||||
if (cases === undefined) continue;
|
||||
const cat = getAQICategory(aqi);
|
||||
groups[cat.label].totalCases += cases;
|
||||
groups[cat.label].dayCount += 1;
|
||||
}
|
||||
|
||||
return AQI_CATEGORIES.map((cat) => ({
|
||||
category: cat.label,
|
||||
avgCases:
|
||||
groups[cat.label].dayCount > 0
|
||||
? Math.round(groups[cat.label].totalCases / groups[cat.label].dayCount)
|
||||
: 0,
|
||||
fill: cat.color,
|
||||
}));
|
||||
}, [pollutants365, caseTrend]);
|
||||
|
||||
// --- Chart 4: AQI Calendar Heatmap ---
|
||||
const heatmapData = useMemo(() => {
|
||||
return pollutants365.map((p) => ({
|
||||
date: p.date,
|
||||
value: p.AQI || 0,
|
||||
}));
|
||||
}, [pollutants365]);
|
||||
|
||||
// Derive the calendar year from the data (data is historical, not current year).
|
||||
const heatmapYear = useMemo(() => {
|
||||
if (pollutants365.length === 0) return currentYear;
|
||||
const last = new Date(pollutants365[pollutants365.length - 1].date);
|
||||
return isNaN(last.getTime()) ? currentYear : last.getFullYear();
|
||||
}, [pollutants365, currentYear]);
|
||||
|
||||
// --- Chart 5: Multi-Pollutant Time Series ---
|
||||
const timeSeriesData = useMemo(() => {
|
||||
return pollutants30.map((p) => ({
|
||||
date: p.date,
|
||||
PM25: p.PM25,
|
||||
PM10: p.PM10,
|
||||
SO2: p.SO2,
|
||||
NO2: p.NO2,
|
||||
O3: p.O3,
|
||||
CO: p.CO,
|
||||
}));
|
||||
}, [pollutants30]);
|
||||
|
||||
// --- Loading state ---
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-auto">
|
||||
{/* Error banner */}
|
||||
{errors.length > 0 && (
|
||||
<div className="px-6 pt-4">
|
||||
<ErrorBanner
|
||||
error={errors.join(';')}
|
||||
onRetry={() => window.location.reload()}
|
||||
onDismiss={() => setErrors([])}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Page header */}
|
||||
<div>
|
||||
<h1 className="font-display text-[18px] font-semibold mb-1 flex items-center gap-2">
|
||||
<Wind className="w-5 h-5 text-primary" />
|
||||
环境健康
|
||||
</h1>
|
||||
<p className="text-[12px] text-text-muted">
|
||||
空气质量与儿童呼吸健康关联分析
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Chart 1: Pollutant-Case Correlation Bar Chart */}
|
||||
<div className="card p-4">
|
||||
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
|
||||
污染物-病例相关性
|
||||
</div>
|
||||
{lag1Correlations.length > 0 ? (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart
|
||||
data={lag1Correlations}
|
||||
layout="vertical"
|
||||
margin={{ top: 5, right: 20, left: 50, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="#E2E8F0"
|
||||
horizontal={false}
|
||||
/>
|
||||
<XAxis
|
||||
type="number"
|
||||
domain={[-1, 1]}
|
||||
tick={{ fontSize: 11, fill: '#64748B' }}
|
||||
axisLine={{ stroke: '#E2E8F0' }}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="pollutant"
|
||||
tickFormatter={(v: string) => (v === 'PM25' ? 'PM2.5' : v)}
|
||||
tick={{ fontSize: 11, fill: '#374151' }}
|
||||
width={50}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#FFFFFF',
|
||||
border: '1px solid #E2E8F0',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
formatter={(value: number) => [
|
||||
value.toFixed(3),
|
||||
'相关系数',
|
||||
]}
|
||||
/>
|
||||
<ReferenceLine
|
||||
x={0}
|
||||
stroke="#94A3B8"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<Bar dataKey="correlation" barSize={20} radius={[0, 4, 4, 0]}>
|
||||
{lag1Correlations.map((entry, idx) => (
|
||||
<Cell
|
||||
key={idx}
|
||||
fill={
|
||||
entry.correlation >= 0
|
||||
? '#DC2626'
|
||||
: '#2563EB'
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<p className="text-[10px] text-text-muted mt-3 text-center">
|
||||
基于Pearson相关系数,滞后1天
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-8 text-text-muted text-sm">
|
||||
暂无数据
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Chart 2: Lag Correlation Analysis */}
|
||||
<div className="card p-4">
|
||||
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
|
||||
滞后相关性分析
|
||||
</div>
|
||||
{pmLagSeries.length > 0 ? (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart
|
||||
data={pmLagSeries}
|
||||
margin={{ top: 5, right: 20, left: 10, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
|
||||
<XAxis
|
||||
dataKey="lag_days"
|
||||
label={{
|
||||
value: '滞后天数',
|
||||
position: 'insideBottom',
|
||||
offset: -5,
|
||||
fontSize: 11,
|
||||
fill: '#64748B',
|
||||
}}
|
||||
tick={{ fontSize: 11, fill: '#64748B' }}
|
||||
axisLine={{ stroke: '#E2E8F0' }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 11, fill: '#64748B' }}
|
||||
axisLine={{ stroke: '#E2E8F0' }}
|
||||
domain={[-0.5, 0.5]}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#FFFFFF',
|
||||
border: '1px solid #E2E8F0',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
formatter={(value: number) => [
|
||||
value.toFixed(3),
|
||||
'相关系数',
|
||||
]}
|
||||
/>
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: '11px' }}
|
||||
/>
|
||||
<ReferenceLine
|
||||
y={0}
|
||||
stroke="#94A3B8"
|
||||
strokeWidth={1}
|
||||
strokeDasharray="4 4"
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="PM25Corr"
|
||||
name="PM2.5"
|
||||
stroke="#DC2626"
|
||||
strokeWidth={2}
|
||||
dot={{ r: 4, fill: '#DC2626' }}
|
||||
activeDot={{ r: 5 }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="PM10Corr"
|
||||
name="PM10"
|
||||
stroke="#D97706"
|
||||
strokeWidth={2}
|
||||
dot={{ r: 4, fill: '#D97706' }}
|
||||
activeDot={{ r: 5 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
{maxLagInsight && (
|
||||
<p className="text-[10px] text-text-muted mt-3 text-center">
|
||||
{maxLagInsight}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-8 text-text-muted text-sm">
|
||||
暂无数据
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Chart 3: Pollution Episode Impact */}
|
||||
<div className="card p-4">
|
||||
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
|
||||
污染等级与日均病例数
|
||||
</div>
|
||||
{episodeData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={350}>
|
||||
<BarChart
|
||||
data={episodeData}
|
||||
margin={{ top: 5, right: 20, left: 10, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
|
||||
<XAxis
|
||||
dataKey="category"
|
||||
tick={{ fontSize: 11, fill: '#64748B' }}
|
||||
axisLine={{ stroke: '#E2E8F0' }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 11, fill: '#64748B' }}
|
||||
axisLine={{ stroke: '#E2E8F0' }}
|
||||
label={{
|
||||
value: '日均病例数',
|
||||
angle: -90,
|
||||
position: 'insideLeft',
|
||||
offset: 0,
|
||||
fontSize: 11,
|
||||
fill: '#64748B',
|
||||
}}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#FFFFFF',
|
||||
border: '1px solid #E2E8F0',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
formatter={(value: number) => [value.toLocaleString(), '日均病例数']}
|
||||
/>
|
||||
<Bar dataKey="avgCases" barSize={40} radius={[4, 4, 0, 0]}>
|
||||
{episodeData.map((entry, idx) => (
|
||||
<Cell key={idx} fill={entry.fill} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="text-center py-8 text-text-muted text-sm">
|
||||
暂无数据
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Chart 4: AQI Calendar Heatmap */}
|
||||
<div className="card p-4">
|
||||
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
|
||||
AQI 日历热力图
|
||||
</div>
|
||||
{heatmapData.length > 0 ? (
|
||||
<CalendarHeatmap data={heatmapData} year={heatmapYear} />
|
||||
) : (
|
||||
<div className="text-center py-8 text-text-muted text-sm">
|
||||
暂无数据
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Chart 5: Multi-Pollutant Time Series */}
|
||||
<div className="card p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
|
||||
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide">
|
||||
多污染物时间序列
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{POLLUTANT_OPTIONS.map((p) => (
|
||||
<button
|
||||
key={p.key}
|
||||
onClick={() => togglePollutant(p.key)}
|
||||
className={`flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-medium transition-all ${
|
||||
selectedPollutants.includes(p.key)
|
||||
? 'bg-bg-active text-text-primary'
|
||||
: 'bg-bg-page text-text-muted hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: p.color }}
|
||||
/>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{timeSeriesData.length > 0 ? (
|
||||
<>
|
||||
{/* Primary pollutants (PM2.5, PM10) */}
|
||||
{selectedPollutants.some((k) => k === 'PM25' || k === 'PM10') && (
|
||||
<div className="mb-4">
|
||||
<div className="text-[10px] font-medium text-text-muted mb-2">
|
||||
主要污染物
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart
|
||||
data={timeSeriesData}
|
||||
margin={{ top: 5, right: 20, left: 10, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={formatDateLabel}
|
||||
tick={{ fontSize: 10, fill: '#64748B' }}
|
||||
interval="preserveStartEnd"
|
||||
axisLine={{ stroke: '#E2E8F0' }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 10, fill: '#64748B' }}
|
||||
axisLine={{ stroke: '#E2E8F0' }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#FFFFFF',
|
||||
border: '1px solid #E2E8F0',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
/>
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: '10px' }}
|
||||
/>
|
||||
{selectedPollutants.includes('PM25') && (
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="PM25"
|
||||
name="PM2.5"
|
||||
stroke="#DC2626"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 3 }}
|
||||
/>
|
||||
)}
|
||||
{selectedPollutants.includes('PM10') && (
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="PM10"
|
||||
name="PM10"
|
||||
stroke="#D97706"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 3 }}
|
||||
/>
|
||||
)}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Secondary pollutants (SO2, NO2, O3, CO) */}
|
||||
{selectedPollutants.some((k) =>
|
||||
['SO2', 'NO2', 'O3', 'CO'].includes(k),
|
||||
) && (
|
||||
<div>
|
||||
<div className="text-[10px] font-medium text-text-muted mb-2">
|
||||
其他污染物
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart
|
||||
data={timeSeriesData}
|
||||
margin={{ top: 5, right: 20, left: 10, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={formatDateLabel}
|
||||
tick={{ fontSize: 10, fill: '#64748B' }}
|
||||
interval="preserveStartEnd"
|
||||
axisLine={{ stroke: '#E2E8F0' }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 10, fill: '#64748B' }}
|
||||
axisLine={{ stroke: '#E2E8F0' }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#FFFFFF',
|
||||
border: '1px solid #E2E8F0',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
/>
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: '10px' }}
|
||||
/>
|
||||
{selectedPollutants.includes('SO2') && (
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="SO2"
|
||||
name="SO₂"
|
||||
stroke="#7C3AED"
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
activeDot={{ r: 3 }}
|
||||
/>
|
||||
)}
|
||||
{selectedPollutants.includes('NO2') && (
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="NO2"
|
||||
name="NO₂"
|
||||
stroke="#059669"
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
activeDot={{ r: 3 }}
|
||||
/>
|
||||
)}
|
||||
{selectedPollutants.includes('O3') && (
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="O3"
|
||||
name="O₃"
|
||||
stroke="#EA580C"
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
activeDot={{ r: 3 }}
|
||||
/>
|
||||
)}
|
||||
{selectedPollutants.includes('CO') && (
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="CO"
|
||||
name="CO"
|
||||
stroke="#0891B2"
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
activeDot={{ r: 3 }}
|
||||
/>
|
||||
)}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-8 text-text-muted text-sm">
|
||||
暂无数据
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { useAnalysisStore } from '@/stores/analysisStore';
|
||||
import { ErrorBanner } from '@/components/ErrorBanner';
|
||||
import { ChatBot } from '@/components/ChatBot';
|
||||
import { caseApi } from '@/services/api';
|
||||
import type { CaseTrendPoint } from '@/types';
|
||||
import {
|
||||
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||
Scatter,
|
||||
} from 'recharts';
|
||||
import {
|
||||
Lightbulb,
|
||||
AlertTriangle,
|
||||
@@ -11,6 +17,7 @@ import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Clock,
|
||||
BarChart3,
|
||||
} from 'lucide-react';
|
||||
|
||||
const TYPE_CONFIG = {
|
||||
@@ -51,10 +58,100 @@ export function Insights() {
|
||||
const clearError = useAnalysisStore((s) => s.clearError);
|
||||
const fetchInsights = useAnalysisStore((s) => s.fetchInsights);
|
||||
|
||||
// Anomaly detection state
|
||||
const [anomalyTrend, setAnomalyTrend] = useState<CaseTrendPoint[]>([]);
|
||||
const [anomalyLoading, setAnomalyLoading] = useState(true);
|
||||
const [anomalyError, setAnomalyError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchInsights();
|
||||
}, [fetchInsights]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setAnomalyLoading(true);
|
||||
setAnomalyError(null);
|
||||
|
||||
const end = new Date();
|
||||
const start = new Date();
|
||||
start.setDate(start.getDate() - 90);
|
||||
const startStr = start.toISOString().slice(0, 10);
|
||||
const endStr = end.toISOString().slice(0, 10);
|
||||
|
||||
caseApi.getTrend({ start_date: startStr, end_date: endStr, group_by: 'day' })
|
||||
.then((data) => {
|
||||
if (!cancelled) {
|
||||
setAnomalyTrend(data.trend || []);
|
||||
setAnomalyLoading(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setAnomalyError('异常检测数据加载失败');
|
||||
setAnomalyLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
// Compute 30-day rolling mean/std and anomalies
|
||||
const anomalyData = useMemo(() => {
|
||||
if (anomalyTrend.length < 30) return { chartData: [], anomalyDates: new Set<string>(), anomalies: [] };
|
||||
|
||||
const rollingMean: number[] = [];
|
||||
const rollingStd: number[] = [];
|
||||
const values = anomalyTrend.map((p) => p.total);
|
||||
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
if (i < 30) {
|
||||
rollingMean.push(NaN);
|
||||
rollingStd.push(NaN);
|
||||
} else {
|
||||
// Trailing window: the prior 30 days, excluding the current point.
|
||||
const window = values.slice(i - 30, i);
|
||||
const mean = window.reduce((a, b) => a + b, 0) / window.length;
|
||||
const variance =
|
||||
window.length > 1
|
||||
? window.reduce((s, v) => s + (v - mean) ** 2, 0) / (window.length - 1)
|
||||
: 0;
|
||||
const std = Math.sqrt(variance);
|
||||
rollingMean.push(mean);
|
||||
rollingStd.push(std);
|
||||
}
|
||||
}
|
||||
|
||||
const anomalyDates = new Set<string>();
|
||||
const anomalies: Array<{ date: string; cases: number; deviationPct: number }> = [];
|
||||
|
||||
for (let i = 30; i < values.length; i++) {
|
||||
const threshold = 2 * rollingStd[i];
|
||||
if (threshold > 0 && Math.abs(values[i] - rollingMean[i]) > threshold) {
|
||||
anomalyDates.add(anomalyTrend[i].date);
|
||||
const deviationPct = rollingMean[i] > 0
|
||||
? Math.round(((values[i] - rollingMean[i]) / rollingMean[i]) * 100)
|
||||
: 100;
|
||||
anomalies.push({
|
||||
date: anomalyTrend[i].date,
|
||||
cases: values[i],
|
||||
deviationPct,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Keep top 5 anomalies by deviation magnitude
|
||||
anomalies.sort((a, b) => Math.abs(b.deviationPct) - Math.abs(a.deviationPct));
|
||||
|
||||
const chartData = anomalyTrend.map((p, i) => ({
|
||||
date: p.date,
|
||||
cases: p.total,
|
||||
rollingMean: rollingMean[i] !== undefined && !isNaN(rollingMean[i]) ? Math.round(rollingMean[i] * 10) / 10 : undefined,
|
||||
anomaly: anomalyDates.has(p.date) ? p.total : undefined,
|
||||
}));
|
||||
|
||||
return { chartData, anomalyDates, anomalies: anomalies.slice(0, 5) };
|
||||
}, [anomalyTrend]);
|
||||
|
||||
const stats = insights
|
||||
? [
|
||||
{
|
||||
@@ -65,8 +162,8 @@ export function Insights() {
|
||||
bg: 'bg-primary-muted',
|
||||
},
|
||||
{
|
||||
label: '预警',
|
||||
value: (insights.warning_count || 0) + ((insights as any).danger_count || 0),
|
||||
label: '预警/紧急',
|
||||
value: (insights.warning_count || 0) + (insights.danger_count || 0),
|
||||
icon: AlertTriangle,
|
||||
color: 'text-warning',
|
||||
bg: 'bg-warning-light',
|
||||
@@ -199,6 +296,134 @@ export function Insights() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Anomaly Detection Section */}
|
||||
<div className="mb-5 mt-6">
|
||||
<h2 className="font-display text-[16px] font-semibold mb-1 flex items-center gap-2">
|
||||
<BarChart3 className="w-5 h-5 text-warning" />
|
||||
异常检测
|
||||
</h2>
|
||||
<p className="text-[12px] text-text-muted mb-4">
|
||||
基于30天滚动均值的病例异常波动检测
|
||||
</p>
|
||||
|
||||
{anomalyLoading && (
|
||||
<div className="card p-8 text-center">
|
||||
<span className="text-text-secondary">异常检测数据加载中...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{anomalyError && (
|
||||
<div className="card p-8 text-center">
|
||||
<AlertTriangle className="w-8 h-8 text-warning mx-auto mb-2" />
|
||||
<p className="text-text-secondary text-sm">{anomalyError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!anomalyLoading && !anomalyError && anomalyTrend.length === 0 && (
|
||||
<div className="card p-8 text-center">
|
||||
<BarChart3 className="w-12 h-12 text-text-muted mx-auto mb-3" />
|
||||
<p className="text-text-secondary">暂无病例数据用于异常检测</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!anomalyLoading && !anomalyError && anomalyTrend.length > 0 && anomalyTrend.length < 30 && (
|
||||
<div className="card p-8 text-center">
|
||||
<BarChart3 className="w-12 h-12 text-text-muted mx-auto mb-3" />
|
||||
<p className="text-text-secondary">数据不足(需≥30天)</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!anomalyLoading && !anomalyError && anomalyData.chartData.length > 0 && (
|
||||
<>
|
||||
<div className="card p-4 mb-4">
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<LineChart data={anomalyData.chartData} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 10, fill: '#9ca3af' }} interval="preserveStartEnd" />
|
||||
<YAxis tick={{ fontSize: 10, fill: '#9ca3af' }} />
|
||||
<Tooltip
|
||||
contentStyle={{ fontSize: 12, borderRadius: 8, border: '1px solid #e5e7eb' }}
|
||||
formatter={(value: number, name: string) => {
|
||||
if (name === 'anomaly') return [value, '异常值'];
|
||||
if (name === 'rollingMean') return [value, '30日均值'];
|
||||
return [value, '病例数'];
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="cases"
|
||||
name="每日病例"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="rollingMean"
|
||||
name="30日均值"
|
||||
stroke="#9ca3af"
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="5 5"
|
||||
dot={false}
|
||||
/>
|
||||
<Scatter
|
||||
dataKey="anomaly"
|
||||
name="异常"
|
||||
fill="#ef4444"
|
||||
shape="circle"
|
||||
legendType="none"
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="flex items-center gap-4 mt-2 pt-2 border-t border-border">
|
||||
<div className="flex items-center gap-1.5 text-xs text-text-muted">
|
||||
<span className="w-4 h-0.5 bg-blue-500 inline-block" />
|
||||
每日病例
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-text-muted">
|
||||
<span className="w-4 h-0.5 bg-gray-400 inline-block" style={{ borderTop: '1.5px dashed #9ca3af' }} />
|
||||
30日均值
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-text-muted">
|
||||
<span className="w-2 h-2 rounded-full bg-red-500 inline-block" />
|
||||
异常点
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{anomalyData.anomalies.length > 0 && (
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{anomalyData.anomalies.map((a) => (
|
||||
<div
|
||||
key={a.date}
|
||||
className="card p-3 flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-2 h-2 rounded-full bg-red-500 shrink-0" />
|
||||
<div>
|
||||
<span className="text-[13px] font-semibold text-text-primary">{a.date}</span>
|
||||
<span className="text-[12px] text-text-muted ml-3">
|
||||
病例数: {a.cases.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`text-[12px] font-semibold ${a.deviationPct >= 0 ? 'text-danger' : 'text-success'}`}>
|
||||
{a.deviationPct >= 0 ? '高于' : '低于'}均值 {Math.abs(a.deviationPct)}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{anomalyData.anomalies.length === 0 && (
|
||||
<div className="card p-4 text-center">
|
||||
<p className="text-[13px] text-text-muted">未检测到显著异常波动</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ChatBot />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,15 +1,45 @@
|
||||
import { useEffect, useState, useMemo, useRef, useCallback, memo } from 'react';
|
||||
import { Calendar, Activity, TrendingUp, TrendingDown, Stethoscope, Building2 } from 'lucide-react';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import { Calendar, Activity, TrendingUp, TrendingDown, Stethoscope, Zap, BarChart3 } from 'lucide-react';
|
||||
import { useTimelineStore, useMonitoringStore } from '@/stores';
|
||||
import { useDiseaseStore } from '@/stores/diseaseStore';
|
||||
import { useDrilldownStore } from '@/stores/drilldownStore';
|
||||
import { gridApi, caseApi } from '@/services/api';
|
||||
import { gridApi, caseApi, envApi } from '@/services/api';
|
||||
import { ErrorBanner } from '@/components/ErrorBanner';
|
||||
import { TimelinePlayer } from '@/components/TimelinePlayer';
|
||||
import { StatisticalCharts } from '@/components/StatisticalCharts';
|
||||
import { CaseLocationMap } from '@/components/CaseLocationMap';
|
||||
import { DiseaseFilter } from '@/components/DiseaseFilter';
|
||||
import { AdminBreadcrumb } from '@/components/AdminBreadcrumb';
|
||||
import { StatCard } from '@/components/StatCard';
|
||||
import { CalendarHeatmap } from '@/components/CalendarHeatmap';
|
||||
import { MetricHeatmapTable } from '@/components/MetricHeatmapTable';
|
||||
import type { DistrictCaseData } from '@/types';
|
||||
|
||||
type MonitoringTab = 'overview' | 'cases' | 'districts';
|
||||
|
||||
interface TopDiagnosis {
|
||||
diagnosis: string;
|
||||
outpatient: number;
|
||||
inpatient: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
function formatDateLabel(dateStr: string): string {
|
||||
const d = new Date(dateStr);
|
||||
return `${d.getMonth() + 1}/${d.getDate()}`;
|
||||
}
|
||||
|
||||
interface MonitoringDashboardProps {
|
||||
defaultStartDate?: string;
|
||||
@@ -22,6 +52,25 @@ export function MonitoringDashboard({
|
||||
}: MonitoringDashboardProps) {
|
||||
const [chartData, setChartData] = useState<Array<{ date: string; cases: number; aqi?: number }>>([]);
|
||||
|
||||
// In-page tab strip (local state, no router — mirrors the existing activePage pattern)
|
||||
const [activeTab, setActiveTab] = useState<MonitoringTab>('overview');
|
||||
|
||||
// --- 病例统计 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 {
|
||||
currentDate,
|
||||
isPlaying,
|
||||
@@ -48,15 +97,15 @@ export function MonitoringDashboard({
|
||||
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const loadChartData = useCallback((district?: string) => {
|
||||
const end = new Date(defaultEndDate);
|
||||
const start = new Date(defaultEndDate);
|
||||
// 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) {
|
||||
// Use caseApi for diagnosis-filtered data
|
||||
caseApi.getTrend({
|
||||
start_date: startStr,
|
||||
end_date: endStr,
|
||||
@@ -69,7 +118,6 @@ export function MonitoringDashboard({
|
||||
);
|
||||
}).catch((e) => { console.error('Failed to load chart data:', e); });
|
||||
} else {
|
||||
// Use gridApi for unfiltered data (or too many diagnoses selected)
|
||||
gridApi.getHistoricalAggregated(startStr, endStr, 'daily', district)
|
||||
.then((data) => {
|
||||
const rows = data.aggregations || [];
|
||||
@@ -85,39 +133,205 @@ export function MonitoringDashboard({
|
||||
}).catch((e) => { console.error('Failed to load chart data:', e); });
|
||||
}
|
||||
|
||||
// Fetch districtCases with diagnosis filter
|
||||
fetchDistrictCases(selectedDiagnoses.length > 0 ? selectedDiagnoses.join(',') : undefined);
|
||||
}, [defaultEndDate, fetchDistrictCases, selectedDiagnoses]);
|
||||
// Fetch districtCases with date filter (single day = currentDate)
|
||||
const diagnosisParam = selectedDiagnoses.length > 0 ? selectedDiagnoses.join(',') : undefined;
|
||||
fetchDistrictCases(diagnosisParam, undefined, refDate);
|
||||
}, [fetchDistrictCases, selectedDiagnoses]);
|
||||
|
||||
// Re-fetch when currentDate, district, or diagnoses change
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
loadChartData(selectedDistrict || undefined);
|
||||
loadChartData(currentDate, selectedDistrict || undefined);
|
||||
}, 300);
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [selectedDistrict, loadChartData]);
|
||||
}, [currentDate, selectedDistrict, loadChartData]);
|
||||
|
||||
// Enhanced stats: window stats + current-date snapshot
|
||||
const stats = useMemo(() => {
|
||||
if (chartData.length === 0) return null;
|
||||
const noData = chartData.length === 0;
|
||||
|
||||
const totalCases = chartData.reduce((sum, d) => sum + d.cases, 0);
|
||||
const avgCases = totalCases / chartData.length;
|
||||
const maxDay = chartData.reduce((max, d) => d.cases > max.cases ? d : max, chartData[0]);
|
||||
const totalCases = noData ? 0 : chartData.reduce((sum, d) => sum + d.cases, 0);
|
||||
const avgCases = noData ? 0 : Math.round(totalCases / chartData.length);
|
||||
|
||||
const firstHalf = chartData.slice(0, Math.floor(chartData.length / 2));
|
||||
const secondHalf = chartData.slice(Math.floor(chartData.length / 2));
|
||||
const firstAvg = firstHalf.reduce((s, d) => s + d.cases, 0) / firstHalf.length;
|
||||
const secondAvg = secondHalf.reduce((s, d) => s + d.cases, 0) / secondHalf.length;
|
||||
const trend = secondAvg > firstAvg * 1.1 ? 'up' : secondAvg < firstAvg * 0.9 ? 'down' : 'stable';
|
||||
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: Math.round(avgCases), maxDay, trend, totalOutpatient, totalInpatient };
|
||||
}, [chartData, districtCases]);
|
||||
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]);
|
||||
|
||||
const handleDateChange = useCallback((date: string) => {
|
||||
setCurrentDate(date);
|
||||
@@ -135,127 +349,270 @@ export function MonitoringDashboard({
|
||||
error={error}
|
||||
onRetry={() => {
|
||||
clearError();
|
||||
loadChartData(selectedDistrict || undefined);
|
||||
loadChartData(currentDate, selectedDistrict || undefined);
|
||||
}}
|
||||
onDismiss={clearError}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Top stats bar */}
|
||||
<div className="bg-white border-b border-gray-200 px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-8">
|
||||
{stats && (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="w-5 h-5 text-blue-600" />
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">累计病例</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{stats.totalCases.toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-5 h-5 text-green-600" />
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">日均病例</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{stats.avgCases}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{stats.trend === 'up' ? (
|
||||
<TrendingUp className="w-5 h-5 text-red-500" />
|
||||
) : stats.trend === 'down' ? (
|
||||
<TrendingDown className="w-5 h-5 text-green-500" />
|
||||
) : (
|
||||
<Activity className="w-5 h-5 text-gray-400" />
|
||||
)}
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">趋势</div>
|
||||
<div className={`text-2xl font-bold ${
|
||||
stats.trend === 'up' ? 'text-red-600' :
|
||||
stats.trend === 'down' ? 'text-green-600' :
|
||||
'text-gray-600'
|
||||
}`}>
|
||||
{stats.trend === 'up' ? '上升' : stats.trend === 'down' ? '下降' : '平稳'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-8 bg-gray-200" />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Stethoscope className="w-5 h-5 text-orange-500" />
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">门诊</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{stats.totalOutpatient.toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="w-5 h-5 text-red-500" />
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">住院</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{stats.totalInpatient.toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{/* Top stats bar — standardized with StatCard */}
|
||||
<div className="bg-white border-b border-gray-200 px-6 py-4 shrink-0">
|
||||
<div className="flex items-start justify-between flex-wrap gap-x-4 gap-y-3">
|
||||
<div className="flex-1 min-w-0 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
<StatCard
|
||||
icon={<Calendar className="w-4 h-4 text-blue-600" />}
|
||||
label="当日病例"
|
||||
value={stats.todayCases !== null ? stats.todayCases.toLocaleString() : '--'}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Activity className="w-4 h-4 text-indigo-600" />}
|
||||
label="7日均值"
|
||||
value={stats.avg7d.toLocaleString()}
|
||||
sparkline={sparkline7d.length >= 2 ? { data: sparkline7d, color: '#6366F1' } : undefined}
|
||||
/>
|
||||
<StatCard
|
||||
icon={
|
||||
stats.trend === 'up' ? <TrendingUp className="w-4 h-4 text-red-500" /> :
|
||||
stats.trend === 'down' ? <TrendingDown className="w-4 h-4 text-green-500" /> :
|
||||
<Activity className="w-4 h-4 text-gray-400" />
|
||||
}
|
||||
label="趋势"
|
||||
value={stats.trend === 'up' ? '上升' : stats.trend === 'down' ? '下降' : '平稳'}
|
||||
trend={{
|
||||
direction: stats.trend === 'up' ? 'up' : stats.trend === 'down' ? 'down' : 'stable',
|
||||
value: stats.trend === 'up' ? '上升' : stats.trend === 'down' ? '下降' : '平稳',
|
||||
}}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Zap className="w-4 h-4 text-amber-500" />}
|
||||
label="峰值日"
|
||||
value={`${stats.maxDay.cases.toLocaleString()} (${stats.maxDay.date.slice(5)})`}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<BarChart3 className="w-4 h-4 text-purple-500" />}
|
||||
label="标准差"
|
||||
value={stats.stdDev.toLocaleString()}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Stethoscope className="w-4 h-4 text-orange-500" />}
|
||||
label="门诊 / 住院"
|
||||
value={`${stats.totalOutpatient.toLocaleString()} / ${stats.totalInpatient.toLocaleString()}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Disease filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{stats.noData && (
|
||||
<span className="text-xs text-amber-600 bg-amber-50 px-2 py-1 rounded">该时段暂无数据</span>
|
||||
)}
|
||||
<DiseaseFilter onFilterChange={() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
loadChartData(selectedDistrict || undefined);
|
||||
loadChartData(currentDate, selectedDistrict || undefined);
|
||||
}, 300);
|
||||
}} />
|
||||
{/* District filter - AdminBreadcrumb for drill-down */}
|
||||
<AdminBreadcrumb />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* In-page tab strip */}
|
||||
<div className="flex items-center gap-1 mt-4 border-b border-gray-100 -mb-4">
|
||||
{([
|
||||
{ key: 'overview', label: '概览' },
|
||||
{ key: 'cases', label: '病例统计' },
|
||||
{ key: 'districts', label: '区域统计' },
|
||||
] as const).map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === tab.key
|
||||
? 'border-blue-600 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content — bottom padding for floating player */}
|
||||
<div className="flex-1 overflow-auto p-6 pb-24">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Case Location Map */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">病例分布地图</h3>
|
||||
<CaseLocationMap height="400px" district={selectedDistrict} street={selectedStreet} />
|
||||
{/* 概览 tab — unchanged Monitoring content */}
|
||||
{activeTab === 'overview' && (
|
||||
isLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
|
||||
{/* Statistical Charts */}
|
||||
<StatisticalCharts
|
||||
data={chartData}
|
||||
height={350}
|
||||
showCases={true}
|
||||
showAQI={true}
|
||||
/>
|
||||
|
||||
{/* District breakdown */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">区县病例分布</h3>
|
||||
<div className="space-y-2">
|
||||
<DistrictBreakdown districtCases={districtCases} selectedDistrict={selectedDistrict} />
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Case Location Map */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">病例分布地图</h3>
|
||||
<CaseLocationMap height="400px" district={selectedDistrict} street={selectedStreet} date={currentDate} />
|
||||
</div>
|
||||
<div className="flex items-center gap-4 mt-3 pt-2 border-t border-gray-100">
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-500">
|
||||
<span className="w-3 h-3 bg-orange-400 rounded-sm" />门诊
|
||||
|
||||
{/* Statistical Charts */}
|
||||
<StatisticalCharts
|
||||
data={chartData}
|
||||
height={350}
|
||||
showCases={true}
|
||||
showAQI={true}
|
||||
/>
|
||||
|
||||
{/* District breakdown */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">区县病例分布</h3>
|
||||
<div className="space-y-2">
|
||||
<DistrictBreakdown districtCases={districtCases} selectedDistrict={selectedDistrict} />
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-500">
|
||||
<span className="w-3 h-3 bg-red-400 rounded-sm" />住院
|
||||
<div className="flex items-center gap-4 mt-3 pt-2 border-t border-gray-100">
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-500">
|
||||
<span className="w-3 h-3 bg-orange-400 rounded-sm" />门诊
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-500">
|
||||
<span className="w-3 h-3 bg-red-400 rounded-sm" />住院
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* 病例统计 tab */}
|
||||
{activeTab === 'cases' && (
|
||||
casesTabLoading && !casesTabLoaded ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{casesTabError && (
|
||||
<ErrorBanner
|
||||
error={casesTabError}
|
||||
onRetry={() => loadCasesTab(currentDate)}
|
||||
onDismiss={() => setCasesTabError(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Top 5 诊断分布 */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Top 5 诊断分布</h3>
|
||||
{topDiagnoses.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<BarChart
|
||||
data={[...topDiagnoses].reverse()}
|
||||
layout="vertical"
|
||||
margin={{ top: 0, right: 10, left: 60, bottom: 0 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" horizontal={false} />
|
||||
<XAxis type="number" tick={{ fontSize: 10, fill: '#64748B' }} />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="diagnosis"
|
||||
tick={{ fontSize: 11, fill: '#374151' }}
|
||||
width={100}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#FFFFFF', border: '1px solid #E2E8F0', borderRadius: '8px', fontSize: '12px' }}
|
||||
formatter={(value: number) => [value.toLocaleString(), '病例数']}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: '11px' }} />
|
||||
<Bar dataKey="outpatient" stackId="a" fill="#3B82F6" name="门诊" barSize={16} />
|
||||
<Bar dataKey="inpatient" stackId="a" fill="#EF4444" name="住院" barSize={16} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-400 text-sm">暂无数据</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 近30日病例与AQI趋势 (driven off Monitoring timeline currentDate) */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-1">病例与AQI趋势</h3>
|
||||
<p className="text-xs text-gray-500 mb-4">截至 {currentDate} 的近30日窗口</p>
|
||||
{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="#E2E8F0" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={formatDateLabel}
|
||||
tick={{ fontSize: 10, fill: '#64748B' }}
|
||||
interval="preserveStartEnd"
|
||||
axisLine={{ stroke: '#E2E8F0' }}
|
||||
/>
|
||||
<YAxis yAxisId="left" tick={{ fontSize: 10, fill: '#64748B' }} axisLine={{ stroke: '#E2E8F0' }} />
|
||||
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 10, fill: '#F59E0B' }} axisLine={{ stroke: '#E2E8F0' }} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#FFFFFF', border: '1px solid #E2E8F0', borderRadius: '8px', fontSize: '12px' }}
|
||||
labelStyle={{ color: '#1E293B', fontWeight: 600 }}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: '11px' }} />
|
||||
<Line yAxisId="left" type="monotone" dataKey="cases" name="病例数" stroke="#3B82F6" strokeWidth={2} dot={false} activeDot={{ r: 3 }} />
|
||||
<Line yAxisId="right" type="monotone" dataKey="aqi" name="AQI" stroke="#F59E0B" strokeWidth={2} strokeDasharray="5 5" dot={false} activeDot={{ r: 3 }} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-400 text-sm">暂无数据</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 日历热力图 (year derived from data) */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
{heatmapYear ? `${heatmapYear}年 ` : ''}每日病例日历
|
||||
</h3>
|
||||
{heatmapYear && heatmapData.length > 0 ? (
|
||||
<CalendarHeatmap data={heatmapData} year={heatmapYear} />
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-400 text-sm">暂无数据</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* 区域统计 tab */}
|
||||
{activeTab === 'districts' && (
|
||||
districtTabLoading && !districtTabLoaded ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{districtTabError && (
|
||||
<ErrorBanner
|
||||
error={districtTabError}
|
||||
onRetry={() => loadDistrictTab()}
|
||||
onDismiss={() => setDistrictTabError(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-1">区域指标热力表</h3>
|
||||
<p className="text-xs text-gray-500 mb-4">点击列标题排序</p>
|
||||
{districtTableRows.length > 0 ? (
|
||||
<MetricHeatmapTable
|
||||
rows={districtTableRows}
|
||||
columns={[
|
||||
{ key: 'total', label: '病例' },
|
||||
{ key: 'outpatient', label: '门诊' },
|
||||
{ key: 'inpatient', label: '住院' },
|
||||
{ key: 'inpatient_ratio', label: '住院占比%' },
|
||||
]}
|
||||
data={districtTableData}
|
||||
onSort={(col) => setDistrictSortKey(col)}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-400 text-sm">暂无数据</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -326,4 +683,4 @@ const DistrictBreakdown = memo(function DistrictBreakdown({ districtCases, selec
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
501
frontend/src/pages/OverviewDashboard.tsx
Normal file
501
frontend/src/pages/OverviewDashboard.tsx
Normal file
@@ -0,0 +1,501 @@
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
BarChart,
|
||||
Bar,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
Droplets,
|
||||
Building2,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import { caseApi, riskApi, alertApi, envApi } from '@/services/api';
|
||||
import { StatCard } from '@/components/StatCard';
|
||||
import { ErrorBanner } from '@/components/ErrorBanner';
|
||||
import type {
|
||||
CaseTrendPoint,
|
||||
DistrictCaseData,
|
||||
PollutantPoint,
|
||||
DiagnosisBreakdown,
|
||||
Alert,
|
||||
} from '@/types';
|
||||
|
||||
// --- Types for fetched data ---
|
||||
interface KpiData {
|
||||
totalCases: number;
|
||||
todayCases: number;
|
||||
changeRatio: number | null;
|
||||
activeAlerts: number;
|
||||
highRiskGrids: number;
|
||||
avgAQI: number;
|
||||
}
|
||||
|
||||
interface MergedTrendItem {
|
||||
date: string;
|
||||
cases: number;
|
||||
aqi: number;
|
||||
}
|
||||
|
||||
function formatDateLabel(dateStr: string): string {
|
||||
const d = new Date(dateStr);
|
||||
return `${d.getMonth() + 1}/${d.getDate()}`;
|
||||
}
|
||||
|
||||
function computeChangeRatio(trend: CaseTrendPoint[]): number | null {
|
||||
if (trend.length < 8) return null;
|
||||
const recent7 = trend.slice(-7).reduce((s, p) => s + p.total, 0);
|
||||
const prior7 = trend.slice(-14, -7).reduce((s, p) => s + p.total, 0);
|
||||
if (prior7 === 0) return null;
|
||||
return ((recent7 - prior7) / prior7) * 100;
|
||||
}
|
||||
|
||||
export function OverviewDashboard() {
|
||||
const [kpi, setKpi] = useState<KpiData | null>(null);
|
||||
const [mergedTrend, setMergedTrend] = useState<MergedTrendItem[]>([]);
|
||||
const [topDistricts, setTopDistricts] = useState<DistrictCaseData[]>([]);
|
||||
const [topDiagnoses, setTopDiagnoses] = useState<DiagnosisBreakdown[]>([]);
|
||||
const [alertPie, setAlertPie] = useState<{ name: string; value: number; color: string }[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [errors, setErrors] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const fetchAll = async () => {
|
||||
setIsLoading(true);
|
||||
setErrors([]);
|
||||
|
||||
const now = new Date();
|
||||
const endStr = now.toISOString().split('T')[0];
|
||||
const start14 = new Date(now);
|
||||
start14.setDate(start14.getDate() - 14);
|
||||
const start14Str = start14.toISOString().split('T')[0];
|
||||
const start30 = new Date(now);
|
||||
start30.setDate(start30.getDate() - 30);
|
||||
const start30Str = start30.toISOString().split('T')[0];
|
||||
|
||||
// KPI sources — Promise.allSettled to survive individual failures
|
||||
const [statsR, trend14R, alertsR, riskStatsR, pollutantsR] = await Promise.allSettled([
|
||||
caseApi.getStats(),
|
||||
caseApi.getTrend({ start_date: start14Str, end_date: endStr, group_by: 'day' }),
|
||||
alertApi.getAlerts(),
|
||||
riskApi.getStats(),
|
||||
envApi.getPollutants(7),
|
||||
]);
|
||||
|
||||
// Trend sources
|
||||
const [trend30R, districtsR, diagStatsR] = await Promise.allSettled([
|
||||
caseApi.getTrend({ start_date: start30Str, end_date: endStr, group_by: 'day' }),
|
||||
caseApi.getDistricts(),
|
||||
caseApi.getStats(), // reuse for top_diagnoses
|
||||
]);
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
const newErrors: string[] = [];
|
||||
|
||||
// --- Build KPI ---
|
||||
let totalCases = 0;
|
||||
if (statsR.status === 'fulfilled') {
|
||||
const s = statsR.value;
|
||||
totalCases = (s.total_outpatient || 0) + (s.total_inpatient || 0);
|
||||
} else {
|
||||
newErrors.push('累计病例数据加载失败');
|
||||
}
|
||||
|
||||
let todayCases = 0;
|
||||
let changeRatio: number | null = null;
|
||||
if (trend14R.status === 'fulfilled') {
|
||||
const trend = trend14R.value.trend || [];
|
||||
if (trend.length > 0) {
|
||||
todayCases = trend[trend.length - 1].total;
|
||||
}
|
||||
changeRatio = computeChangeRatio(trend);
|
||||
} else {
|
||||
newErrors.push('今日病例数据加载失败');
|
||||
}
|
||||
|
||||
let activeAlerts = 0;
|
||||
let alertList: Alert[] = [];
|
||||
if (alertsR.status === 'fulfilled') {
|
||||
alertList = alertsR.value.alerts || [];
|
||||
activeAlerts = alertList.length;
|
||||
} else {
|
||||
newErrors.push('预警数据加载失败');
|
||||
}
|
||||
|
||||
let highRiskGrids = 0;
|
||||
if (riskStatsR.status === 'fulfilled') {
|
||||
highRiskGrids = riskStatsR.value.high_risk_count || 0;
|
||||
} else {
|
||||
newErrors.push('风险网格数据加载失败');
|
||||
}
|
||||
|
||||
let avgAQI = 0;
|
||||
let pollutantData: PollutantPoint[] = [];
|
||||
if (pollutantsR.status === 'fulfilled') {
|
||||
pollutantData = pollutantsR.value.data || [];
|
||||
if (pollutantData.length > 0) {
|
||||
const sumAQI = pollutantData.reduce((s, p) => s + (p.AQI || 0), 0);
|
||||
avgAQI = Math.round(sumAQI / pollutantData.length);
|
||||
}
|
||||
} else {
|
||||
newErrors.push('AQI数据加载失败');
|
||||
}
|
||||
|
||||
setKpi({ totalCases, todayCases, changeRatio, activeAlerts, highRiskGrids, avgAQI });
|
||||
setErrors(newErrors);
|
||||
|
||||
// --- Merge case trend + AQI ---
|
||||
if (trend30R.status === 'fulfilled') {
|
||||
const trend30 = trend30R.value.trend || [];
|
||||
const aqiMap: Record<string, number> = {};
|
||||
if (pollutantsR.status === 'fulfilled') {
|
||||
for (const p of pollutantData) {
|
||||
aqiMap[p.date] = p.AQI || 0;
|
||||
}
|
||||
}
|
||||
// Only use data from the last 30 days for display
|
||||
const merged: MergedTrendItem[] = trend30.map((t) => ({
|
||||
date: t.date,
|
||||
cases: t.total,
|
||||
aqi: aqiMap[t.date] || 0,
|
||||
}));
|
||||
setMergedTrend(merged);
|
||||
} else if (!newErrors.includes('今日病例数据加载失败')) {
|
||||
newErrors.push('趋势数据加载失败');
|
||||
}
|
||||
|
||||
// --- Top 5 Districts ---
|
||||
if (districtsR.status === 'fulfilled') {
|
||||
const districts = districtsR.value.districts || [];
|
||||
const sorted = [...districts].sort((a, b) => b.total - a.total);
|
||||
setTopDistricts(sorted.slice(0, 5));
|
||||
}
|
||||
|
||||
// --- Top 5 Diagnoses ---
|
||||
if (diagStatsR.status === 'fulfilled') {
|
||||
const topDiag = diagStatsR.value.top_diagnoses || [];
|
||||
setTopDiagnoses(
|
||||
topDiag.slice(0, 5).map((d) => ({
|
||||
diagnosis: d.diagnosis,
|
||||
outpatient: d.outpatient,
|
||||
inpatient: d.inpatient,
|
||||
total: d.outpatient + d.inpatient,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
// --- Alert severity donut ---
|
||||
const p1 = alertList.filter((a) => a.priority === 'P1').length;
|
||||
const p2 = alertList.filter((a) => a.priority === 'P2').length;
|
||||
setAlertPie([
|
||||
{ name: 'P1 紧急', value: p1, color: '#EF4444' },
|
||||
{ name: 'P2 关注', value: p2, color: '#F59E0B' },
|
||||
]);
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
fetchAll();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const changeTrend = useMemo(() => {
|
||||
if (kpi?.changeRatio == null) return undefined;
|
||||
if (kpi.changeRatio > 0) {
|
||||
return { direction: 'up' as const, value: `${kpi.changeRatio.toFixed(1)}%` };
|
||||
}
|
||||
if (kpi.changeRatio < 0) {
|
||||
return { direction: 'down' as const, value: `${Math.abs(kpi.changeRatio).toFixed(1)}%` };
|
||||
}
|
||||
return { direction: 'stable' as const, value: '0%' };
|
||||
}, [kpi?.changeRatio]);
|
||||
|
||||
// --- Loading state ---
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-auto">
|
||||
{/* Error banner */}
|
||||
{errors.length > 0 && (
|
||||
<div className="px-6 pt-4">
|
||||
<ErrorBanner
|
||||
error={errors.join(';')}
|
||||
onRetry={() => window.location.reload()}
|
||||
onDismiss={() => setErrors([])}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Page header */}
|
||||
<div>
|
||||
<h1 className="font-display text-[18px] font-semibold mb-1 flex items-center gap-2">
|
||||
<Activity className="w-5 h-5 text-primary" />
|
||||
综合概览
|
||||
</h1>
|
||||
<p className="text-[12px] text-gray-500">病例、环境与预警关键指标总览</p>
|
||||
</div>
|
||||
|
||||
{/* Section 1: KPI Row */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
<StatCard
|
||||
icon={<Users className="w-4 h-4 text-blue-600" />}
|
||||
label="累计病例总数"
|
||||
value={kpi?.totalCases?.toLocaleString() ?? '--'}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Activity className="w-4 h-4 text-green-600" />}
|
||||
label="今日病例"
|
||||
value={kpi?.todayCases?.toLocaleString() ?? '--'}
|
||||
/>
|
||||
<StatCard
|
||||
icon={
|
||||
(changeTrend?.direction === 'up' && <TrendingUp className="w-4 h-4 text-red-500" />) ||
|
||||
(changeTrend?.direction === 'down' && <TrendingDown className="w-4 h-4 text-green-500" />) || (
|
||||
<Activity className="w-4 h-4 text-gray-400" />
|
||||
)
|
||||
}
|
||||
label="7日变化率"
|
||||
value={changeTrend ? changeTrend.value : '--'}
|
||||
trend={changeTrend}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<AlertTriangle className="w-4 h-4 text-orange-500" />}
|
||||
label="活跃预警数"
|
||||
value={kpi?.activeAlerts?.toLocaleString() ?? '--'}
|
||||
color={kpi && kpi.activeAlerts > 0 ? '#EF4444' : undefined}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Building2 className="w-4 h-4 text-red-500" />}
|
||||
label="高风险网格"
|
||||
value={kpi?.highRiskGrids?.toLocaleString() ?? '--'}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Droplets className="w-4 h-4 text-cyan-500" />}
|
||||
label="平均AQI"
|
||||
value={kpi?.avgAQI?.toLocaleString() ?? '--'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Section 2: Case + AQI Mini Trend */}
|
||||
<div className="card p-4">
|
||||
<div className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-4">
|
||||
近30日病例与AQI趋势
|
||||
</div>
|
||||
{mergedTrend.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart data={mergedTrend} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={formatDateLabel}
|
||||
tick={{ fontSize: 10, fill: '#64748B' }}
|
||||
interval="preserveStartEnd"
|
||||
axisLine={{ stroke: '#E2E8F0' }}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="left"
|
||||
tick={{ fontSize: 10, fill: '#64748B' }}
|
||||
axisLine={{ stroke: '#E2E8F0' }}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
tick={{ fontSize: 10, fill: '#F59E0B' }}
|
||||
axisLine={{ stroke: '#E2E8F0' }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#FFFFFF',
|
||||
border: '1px solid #E2E8F0',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
labelStyle={{ color: '#1E293B', fontWeight: 600 }}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: '11px' }} />
|
||||
<Line
|
||||
yAxisId="left"
|
||||
type="monotone"
|
||||
dataKey="cases"
|
||||
name="病例数"
|
||||
stroke="#3B82F6"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 3 }}
|
||||
/>
|
||||
<Line
|
||||
yAxisId="right"
|
||||
type="monotone"
|
||||
dataKey="aqi"
|
||||
name="AQI"
|
||||
stroke="#F59E0B"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="5 5"
|
||||
dot={false}
|
||||
activeDot={{ r: 3 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-400 text-sm">暂无数据</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Section 3 + 4: Top Districts + Top Diagnoses side by side */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Section 3: Top 5 Districts */}
|
||||
<div className="card p-4">
|
||||
<div className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-4">
|
||||
Top 5 区县病例分布
|
||||
</div>
|
||||
{topDistricts.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart
|
||||
data={[...topDistricts].reverse()}
|
||||
layout="vertical"
|
||||
margin={{ top: 0, right: 10, left: 30, bottom: 0 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" horizontal={false} />
|
||||
<XAxis type="number" tick={{ fontSize: 10, fill: '#64748B' }} />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="district"
|
||||
tick={{ fontSize: 11, fill: '#374151' }}
|
||||
width={60}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#FFFFFF',
|
||||
border: '1px solid #E2E8F0',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
formatter={(value: number) => [value.toLocaleString(), '病例数']}
|
||||
/>
|
||||
<Bar dataKey="outpatient" stackId="a" fill="#3B82F6" name="门诊" barSize={20} />
|
||||
<Bar dataKey="inpatient" stackId="a" fill="#EF4444" name="住院" barSize={20} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-400 text-sm">暂无数据</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Section 4: Top 5 Diagnoses */}
|
||||
<div className="card p-4">
|
||||
<div className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-4">
|
||||
Top 5 诊断分布
|
||||
</div>
|
||||
{topDiagnoses.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart
|
||||
data={[...topDiagnoses].reverse()}
|
||||
layout="vertical"
|
||||
margin={{ top: 0, right: 10, left: 60, bottom: 0 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" horizontal={false} />
|
||||
<XAxis type="number" tick={{ fontSize: 10, fill: '#64748B' }} />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="diagnosis"
|
||||
tick={{ fontSize: 11, fill: '#374151' }}
|
||||
width={100}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#FFFFFF',
|
||||
border: '1px solid #E2E8F0',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
formatter={(value: number) => [value.toLocaleString(), '病例数']}
|
||||
/>
|
||||
<Bar dataKey="outpatient" stackId="a" fill="#3B82F6" name="门诊" barSize={16} />
|
||||
<Bar dataKey="inpatient" stackId="a" fill="#EF4444" name="住院" barSize={16} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-400 text-sm">暂无数据</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 5: Alert Severity Donut */}
|
||||
<div className="card p-4">
|
||||
<div className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-4">
|
||||
预警严重度分布
|
||||
</div>
|
||||
{alertPie[0].value > 0 || alertPie[1].value > 0 ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<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, idx) => (
|
||||
<Cell key={idx} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#FFFFFF',
|
||||
border: '1px solid #E2E8F0',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
formatter={(value: number, name: string) => [value, name]}
|
||||
/>
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: '12px' }}
|
||||
formatter={(value: string) => (
|
||||
<span className="text-gray-700">{value}</span>
|
||||
)}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-400 text-sm">暂无预警数据</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -148,7 +148,12 @@ function ReportDetail({ reportId, onBack }: { reportId: string; onBack: () => vo
|
||||
const fetchReport = useReportsStore((s) => s.fetchReport);
|
||||
const { selectedDiagnoses } = useDiseaseStore();
|
||||
|
||||
useEffect(() => { fetchReport(reportId); }, [reportId]);
|
||||
useEffect(() => {
|
||||
// Skip refetch if the report is already loaded (e.g. just generated) to avoid clobbering it.
|
||||
if (useReportsStore.getState().currentReport?.metadata?.report_id !== reportId) {
|
||||
fetchReport(reportId);
|
||||
}
|
||||
}, [reportId]);
|
||||
|
||||
if (error) return <ErrorBanner error={error} onRetry={() => { clearError(); fetchReport(reportId); }} onDismiss={clearError} />;
|
||||
if (isLoading || !currentReport) return <div className="text-center py-8 text-sm text-gray-500">加载报告...</div>;
|
||||
@@ -176,12 +181,29 @@ function ReportDetail({ reportId, onBack }: { reportId: string; onBack: () => vo
|
||||
<span>生成: {metadata.generated_at?.slice(0, 10)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => downloadCSV(currentReport)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-green-600 text-white rounded hover:bg-green-700 transition-colors"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" /> 导出CSV
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => downloadCSV(currentReport)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-green-600 text-white rounded hover:bg-green-700 transition-colors"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" /> 导出CSV
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const json = JSON.stringify(currentReport, null, 2);
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${reportId}.json`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" /> 导出JSON
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary cards */}
|
||||
@@ -285,7 +307,11 @@ export function ReportsCenter() {
|
||||
setIsGenerating(true);
|
||||
try {
|
||||
await generateReport(genType);
|
||||
setView('detail');
|
||||
const generated = useReportsStore.getState().currentReport;
|
||||
if (generated) {
|
||||
setSelectedReportId(generated.metadata.report_id);
|
||||
setView('detail');
|
||||
}
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
|
||||
@@ -10,10 +10,15 @@ import {
|
||||
ResponsiveContainer,
|
||||
AreaChart,
|
||||
Area,
|
||||
BarChart,
|
||||
Bar,
|
||||
ReferenceLine,
|
||||
} from 'recharts';
|
||||
import { useAnalysisStore } from '@/stores/analysisStore';
|
||||
import { caseApi } from '@/services/api';
|
||||
import { ErrorBanner } from '@/components/ErrorBanner';
|
||||
import { TrendingUp, Calendar, Activity } from 'lucide-react';
|
||||
import type { CaseTrendPoint } from '@/types';
|
||||
|
||||
const POLLUTANT_OPTIONS = [
|
||||
{ key: 'aqi', label: 'AQI', color: '#2563EB', unit: '' },
|
||||
@@ -40,11 +45,85 @@ export function TrendAnalysis() {
|
||||
const setSelectedDays = useAnalysisStore((s) => s.setSelectedDays);
|
||||
const fetchTrend = useAnalysisStore((s) => s.fetchTrend);
|
||||
const [selectedPollutants, setSelectedPollutants] = useState<string[]>(['aqi', 'pm25']);
|
||||
const [multiYearData, setMultiYearData] = useState<Record<string, CaseTrendPoint[]>>({});
|
||||
const [multiYearLoading, setMultiYearLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTrend(selectedDays);
|
||||
}, [selectedDays, fetchTrend]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const years = [2022, 2023, 2024];
|
||||
const fetchYears = async () => {
|
||||
setMultiYearLoading(true);
|
||||
const results: Record<string, CaseTrendPoint[]> = {};
|
||||
for (const year of years) {
|
||||
try {
|
||||
const data = await caseApi.getTrend({
|
||||
start_date: `${year}-01-01`,
|
||||
end_date: `${year}-12-31`,
|
||||
group_by: 'month',
|
||||
});
|
||||
if (!cancelled && data.trend && data.trend.length > 0) {
|
||||
results[String(year)] = data.trend;
|
||||
}
|
||||
} catch {
|
||||
// skip years with no data
|
||||
}
|
||||
}
|
||||
if (!cancelled) {
|
||||
setMultiYearData(results);
|
||||
setMultiYearLoading(false);
|
||||
}
|
||||
};
|
||||
fetchYears();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
// Merge multi-year data by month (data is monthly) over a fixed 1..12 sequence
|
||||
const mergedMultiYearData = (() => {
|
||||
const yearColors: Record<string, string> = { '2022': '#94A3B8', '2023': '#3B82F6', '2024': '#EF4444' };
|
||||
// month (1..12) -> { 2022?: total, 2023?: total, 2024?: total }
|
||||
const byMonth: Record<number, Record<string, number>> = {};
|
||||
for (const [year, points] of Object.entries(multiYearData)) {
|
||||
for (const point of points) {
|
||||
const month = new Date(point.date).getMonth() + 1;
|
||||
if (isNaN(month)) continue;
|
||||
if (!byMonth[month]) byMonth[month] = {};
|
||||
byMonth[month][year] = point.total;
|
||||
}
|
||||
}
|
||||
const monthLabels = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'];
|
||||
const chartData = Array.from({ length: 12 }, (_, i) => {
|
||||
const month = i + 1;
|
||||
return { md: monthLabels[i], ...(byMonth[month] || {}) };
|
||||
});
|
||||
return { chartData, yearColors };
|
||||
})();
|
||||
|
||||
// Day-of-week computation from store's trendData (case data via fetchCaseTrend)
|
||||
const dayOfWeekData = (() => {
|
||||
const dayNames = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
|
||||
const dayTotals: Record<string, { total: number; count: number }> = {};
|
||||
for (const name of dayNames) {
|
||||
dayTotals[name] = { total: 0, count: 0 };
|
||||
}
|
||||
for (const point of trendData) {
|
||||
const d = new Date(point.date);
|
||||
if (isNaN(d.getTime())) continue;
|
||||
const dayName = dayNames[d.getDay()];
|
||||
dayTotals[dayName].total += point.aqi || 0;
|
||||
dayTotals[dayName].count += 1;
|
||||
}
|
||||
const result = dayNames.map((name) => ({
|
||||
day: name,
|
||||
avg: dayTotals[name].count > 0 ? Math.round(dayTotals[name].total / dayTotals[name].count) : 0,
|
||||
}));
|
||||
const totalAvg = result.reduce((sum, d) => sum + d.avg, 0) / result.length || 0;
|
||||
return { data: result, mean: Math.round(totalAvg) };
|
||||
})();
|
||||
|
||||
const togglePollutant = (key: string) => {
|
||||
setSelectedPollutants((prev) =>
|
||||
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]
|
||||
@@ -63,7 +142,8 @@ export function TrendAnalysis() {
|
||||
if (!latestData || !firstData) return 0;
|
||||
const latest = latestData[key as keyof typeof latestData] as number;
|
||||
const first = firstData[key as keyof typeof firstData] as number;
|
||||
if (!first) return 0;
|
||||
const EPSILON = 1e-6;
|
||||
if (!Number.isFinite(first) || !Number.isFinite(latest) || Math.abs(first) < EPSILON) return 0;
|
||||
return ((latest - first) / first) * 100;
|
||||
};
|
||||
|
||||
@@ -138,7 +218,7 @@ export function TrendAnalysis() {
|
||||
|
||||
<div className="card p-4 mb-4">
|
||||
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
|
||||
污染物浓度趋势
|
||||
空气质量趋势
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={360}>
|
||||
<LineChart data={trendData} margin={{ top: 5, right: 20, left: 10, bottom: 5 }}>
|
||||
@@ -263,6 +343,106 @@ export function TrendAnalysis() {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Multi-Year Comparison */}
|
||||
<div className="card p-4 mb-4">
|
||||
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
|
||||
多年度病例对比
|
||||
</div>
|
||||
{multiYearLoading ? (
|
||||
<div className="text-center py-8 text-text-secondary text-sm">数据加载中...</div>
|
||||
) : Object.keys(multiYearData).length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart
|
||||
data={mergedMultiYearData.chartData}
|
||||
margin={{ top: 5, right: 20, left: 10, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
|
||||
<XAxis
|
||||
dataKey="md"
|
||||
tick={{ fontSize: 12, fill: '#64748B' }}
|
||||
axisLine={{ stroke: '#E2E8F0' }}
|
||||
label={{ value: '月份', position: 'insideBottom', offset: -5, fontSize: 11, fill: '#64748B' }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12, fill: '#64748B' }}
|
||||
axisLine={{ stroke: '#E2E8F0' }}
|
||||
label={{ value: '病例数', angle: -90, position: 'insideLeft', offset: 0, fontSize: 11, fill: '#64748B' }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#FFFFFF',
|
||||
border: '1px solid #E2E8F0',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: '12px', paddingTop: '12px' }} />
|
||||
{Object.keys(multiYearData).map((year) => (
|
||||
<Line
|
||||
key={year}
|
||||
type="monotone"
|
||||
dataKey={year}
|
||||
name={`${year}年`}
|
||||
stroke={mergedMultiYearData.yearColors[year] || '#94A3B8'}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 3 }}
|
||||
connectNulls
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="text-center py-8 text-text-muted text-sm">暂无多年病例数据</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Day-of-Week Pattern */}
|
||||
<div className="card p-4 mb-4">
|
||||
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
|
||||
周内分布模式
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<BarChart
|
||||
data={dayOfWeekData.data}
|
||||
margin={{ top: 5, right: 20, left: 10, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
|
||||
<XAxis
|
||||
dataKey="day"
|
||||
tick={{ fontSize: 12, fill: '#64748B' }}
|
||||
axisLine={{ stroke: '#E2E8F0' }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12, fill: '#64748B' }}
|
||||
axisLine={{ stroke: '#E2E8F0' }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#FFFFFF',
|
||||
border: '1px solid #E2E8F0',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
formatter={(value: number) => [value, '日均AQI']}
|
||||
/>
|
||||
<ReferenceLine
|
||||
y={dayOfWeekData.mean}
|
||||
stroke="#EF4444"
|
||||
strokeDasharray="4 4"
|
||||
strokeWidth={1.5}
|
||||
label={{
|
||||
value: `均值 ${dayOfWeekData.mean}`,
|
||||
position: 'right',
|
||||
fontSize: 11,
|
||||
fill: '#EF4444',
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="avg" fill="#3B82F6" barSize={36} radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* US-005: API client (api.ts) unit tests.
|
||||
* Tests caching, request deduplication, and cache management.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
vi.mock('axios', () => {
|
||||
const mockAxiosInstance = {
|
||||
@@ -26,7 +26,7 @@ describe('getCacheKey', () => {
|
||||
let getCacheKey: Function;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mod = await import('@/services/api');
|
||||
await import('@/services/api');
|
||||
// Access internal function via module scope eval
|
||||
// Since getCacheKey is not exported, we test its behavior through cachedGet
|
||||
getCacheKey = (url: string, params?: Record<string, any>) => {
|
||||
@@ -86,7 +86,7 @@ describe('cachedPost', () => {
|
||||
const mockInstance = (axios.create as any).mock.results[0].value;
|
||||
mockInstance.post.mockResolvedValueOnce({ data: { ok: true } });
|
||||
|
||||
const result = await cachedPost('/test', { foo: 'bar' });
|
||||
await cachedPost('/test', { foo: 'bar' });
|
||||
expect(mockInstance.post).toHaveBeenCalledWith('/test', { foo: 'bar' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,11 @@ import type {
|
||||
ReportListResponse,
|
||||
ReportResponse,
|
||||
ReportSummary,
|
||||
DemographicsResponse,
|
||||
DiseaseSeasonalityResponse,
|
||||
LagCorrelationResponse,
|
||||
PollutantResponse,
|
||||
DiagnosisDistributionResponse,
|
||||
} from '@/types';
|
||||
|
||||
interface CacheEntry<T> {
|
||||
@@ -23,6 +28,7 @@ interface CacheEntry<T> {
|
||||
}
|
||||
|
||||
const CACHE_TTL = 30000;
|
||||
const CACHE_MAX_ENTRIES = 30;
|
||||
const cache = new Map<string, CacheEntry<any>>();
|
||||
const pendingControllers = new Map<string, AbortController>();
|
||||
|
||||
@@ -43,11 +49,20 @@ function getCached<T>(key: string): T | undefined {
|
||||
cache.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
// Refresh recency for LRU: re-insert so this key becomes most-recently-used.
|
||||
cache.delete(key);
|
||||
cache.set(key, entry);
|
||||
return entry.data;
|
||||
}
|
||||
|
||||
function setCache<T>(key: string, data: T): void {
|
||||
cache.set(key, { data, timestamp: Date.now() });
|
||||
// LRU cap: evict oldest entries (Map preserves insertion order) beyond the cap.
|
||||
while (cache.size > CACHE_MAX_ENTRIES) {
|
||||
const oldest = cache.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
cache.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
function clearPending(key: string): void {
|
||||
@@ -69,10 +84,14 @@ api.interceptors.request.use((config) => {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
const key = getCacheKey(config.url || '', config.params);
|
||||
const controller = new AbortController();
|
||||
config.signal = controller.signal;
|
||||
clearPending(key);
|
||||
pendingControllers.set(key, controller);
|
||||
// If a caller supplied an explicit signal (e.g. cachedGet's signal param),
|
||||
// honor it instead of overriding with the internal dedup controller.
|
||||
if (!config.signal) {
|
||||
const controller = new AbortController();
|
||||
config.signal = controller.signal;
|
||||
clearPending(key);
|
||||
pendingControllers.set(key, controller);
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
@@ -87,24 +106,49 @@ api.interceptors.response.use(
|
||||
const key = getCacheKey(error.config.url || '', error.config.params);
|
||||
pendingControllers.delete(key);
|
||||
}
|
||||
// Session expired / invalid token: clear it and re-render the login screen.
|
||||
// Exclude the login request itself so bad-credential errors still surface.
|
||||
if (error.response?.status === 401 && !error.config?.url?.includes('/auth/login')) {
|
||||
localStorage.removeItem('cbpoa_token');
|
||||
cache.clear();
|
||||
window.location.reload();
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export async function cachedGet<T>(url: string, params?: Record<string, any>): Promise<T> {
|
||||
export async function cachedGet<T>(url: string, params?: Record<string, any>, signal?: AbortSignal): Promise<T> {
|
||||
const key = getCacheKey(url, params);
|
||||
const cached = getCached<T>(key);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
// Requests with a caller-supplied abort signal bypass the shared in-flight
|
||||
// promise/cache write so an abort can't reject other callers; the response is
|
||||
// still written to the TTL cache on success.
|
||||
if (signal) {
|
||||
return api.get<T>(url, { params, signal })
|
||||
.then((res) => {
|
||||
setCache(key, res.data);
|
||||
return res.data;
|
||||
});
|
||||
}
|
||||
|
||||
const entry = cache.get(key);
|
||||
if (entry?.promise) return entry.promise;
|
||||
|
||||
const promise = api.get<T>(url, { params }).then((res) => {
|
||||
setCache(key, res.data);
|
||||
const updated = cache.get(key);
|
||||
if (updated) updated.promise = undefined;
|
||||
return res.data;
|
||||
});
|
||||
const promise = api.get<T>(url, { params })
|
||||
.then((res) => {
|
||||
setCache(key, res.data);
|
||||
const updated = cache.get(key);
|
||||
if (updated) updated.promise = undefined;
|
||||
return res.data;
|
||||
})
|
||||
.catch((err) => {
|
||||
// Evict the failed/aborted entry so the next call retries instead of
|
||||
// replaying this rejected promise for the rest of the TTL window.
|
||||
cache.delete(key);
|
||||
throw err;
|
||||
});
|
||||
|
||||
cache.set(key, { data: undefined as T, timestamp: Date.now(), promise });
|
||||
return promise;
|
||||
@@ -133,8 +177,50 @@ export const riskApi = {
|
||||
cachedGet(`/risk/grid/${encodeURIComponent(gridId)}`),
|
||||
|
||||
getStats: (): Promise<Stats> => cachedGet('/risk/stats'),
|
||||
|
||||
// Full-Wuhan 100m risk grid served as XYZ raster tiles. Returns a Leaflet
|
||||
// URL template (NOT an axios call) — the browser fetches PNGs directly.
|
||||
tileUrlTemplate: (day: 1 | 3 | 7, date?: string): string => {
|
||||
const base = import.meta.env.VITE_API_URL || '/api';
|
||||
const dateParam = date ? `&date=${date}` : '';
|
||||
return `${base}/risk/tiles/{z}/{x}/{y}.png?day=${day}${dateParam}`;
|
||||
},
|
||||
|
||||
getCell: (
|
||||
lat: number,
|
||||
lon: number,
|
||||
day: 1 | 3 | 7 = 1,
|
||||
date?: string
|
||||
): Promise<RiskCell> => cachedGet('/risk/cell', { lat, lon, day, date }),
|
||||
|
||||
getGridStats: (day: 1 | 3 | 7 = 1, date?: string): Promise<RiskGridStats> =>
|
||||
cachedGet('/risk/grid-stats', { day, date }),
|
||||
};
|
||||
|
||||
export interface RiskCell {
|
||||
grid_id: string;
|
||||
row: number;
|
||||
col: number;
|
||||
center_lat: number;
|
||||
center_lon: number;
|
||||
risk_value: number;
|
||||
risk_1d: number;
|
||||
risk_3d: number;
|
||||
risk_7d: number;
|
||||
in_boundary: boolean;
|
||||
forecast_day: number;
|
||||
date: string;
|
||||
}
|
||||
|
||||
export interface RiskGridStats {
|
||||
cell_count: number;
|
||||
avg_risk: number;
|
||||
max_risk: number;
|
||||
high_risk_count: number;
|
||||
forecast_day: number;
|
||||
date: string;
|
||||
}
|
||||
|
||||
export const alertApi = {
|
||||
getAlerts: (params?: {
|
||||
min_risk?: number;
|
||||
@@ -164,17 +250,34 @@ export const caseApi = {
|
||||
diagnosis?: string;
|
||||
}): Promise<CaseTrendResponse> => cachedGet('/cases/trend', params),
|
||||
|
||||
getDistricts: (params?: { diagnosis?: string }): Promise<DistrictCaseResponse> => cachedGet('/cases/districts', params),
|
||||
getDistricts: (params?: { diagnosis?: string; start_date?: string; end_date?: string }): Promise<DistrictCaseResponse> => cachedGet('/cases/districts', params),
|
||||
|
||||
getStats: (): Promise<CaseStatsResponse> => cachedGet('/cases/stats'),
|
||||
|
||||
getDiagnoses: (): Promise<{ diagnoses: string[] }> => cachedGet('/cases/diagnoses'),
|
||||
|
||||
getDemographics: (): Promise<DemographicsResponse> => cachedGet('/cases/demographics'),
|
||||
|
||||
getDiseaseSeasonality: (): Promise<DiseaseSeasonalityResponse> => cachedGet('/cases/disease-seasonality'),
|
||||
|
||||
getDiagnosisDistribution: (limit?: number): Promise<DiagnosisDistributionResponse> => {
|
||||
const params = limit ? { limit } : undefined;
|
||||
return cachedGet('/cases/diagnosis-distribution', params);
|
||||
},
|
||||
};
|
||||
|
||||
export const envApi = {
|
||||
getLagCorrelations: (): Promise<LagCorrelationResponse> => cachedGet('/environment/lag-correlations'),
|
||||
getPollutants: (days?: number): Promise<PollutantResponse> => {
|
||||
const params = days ? { days } : undefined;
|
||||
return cachedGet('/environment/pollutants', params);
|
||||
},
|
||||
};
|
||||
|
||||
export const geocodedApi = {
|
||||
getGrid: (): Promise<CaseGridResponse> => cachedGet('/geocoded/grid'),
|
||||
|
||||
getGeocoded: (params?: { limit?: number; district?: string }): Promise<GeocodedCasesResponse> =>
|
||||
getGeocoded: (params?: { limit?: number; district?: string; date?: string }): Promise<GeocodedCasesResponse> =>
|
||||
cachedGet('/geocoded/geocoded', params),
|
||||
|
||||
getStreets: (district: string): Promise<{ streets: StreetData[] }> =>
|
||||
|
||||
57
frontend/src/stores/CLAUDE.md
Normal file
57
frontend/src/stores/CLAUDE.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Stores — Zustand State Management
|
||||
|
||||
## Pattern
|
||||
|
||||
Every store follows the same Zustand `create<T>()` pattern with typed state + actions:
|
||||
|
||||
```typescript
|
||||
import { create } from 'zustand';
|
||||
import { someApi } from '@/services/api';
|
||||
|
||||
interface SomeState {
|
||||
data: SomeType[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
fetchData: () => Promise<void>;
|
||||
setData: (data: SomeType[]) => void;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
export const useSomeStore = create<SomeState>((set, get) => ({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
fetchData: async () => { ... },
|
||||
setData: (data) => set({ data }),
|
||||
clearError: () => set({ error: null }),
|
||||
}));
|
||||
```
|
||||
|
||||
## Store List
|
||||
|
||||
| Store | Purpose |
|
||||
|-------|---------|
|
||||
| `index.ts` | Exports: `useTimelineStore`, `useMonitoringStore` |
|
||||
| `diseaseStore.ts` | Disease diagnosis filtering: fetch list, toggle selection |
|
||||
| `drilldownStore.ts` | Admin drill-down: district → street → community hierarchy |
|
||||
| `analysisStore.ts` | Trend analysis: time series, statistics |
|
||||
| `reportsStore.ts` | Report generation: status, download |
|
||||
|
||||
## Conventions
|
||||
|
||||
- Cache already-loaded data: `if (get().data.length > 0) return;`
|
||||
- Always set `isLoading: true` before async, `isLoading: false` after
|
||||
- `error` is always `string | null` — set on catch, clear on success
|
||||
- Stores are imported as named exports: `import { useDiseaseStore } from '@/stores/diseaseStore'`
|
||||
|
||||
## Testing
|
||||
|
||||
Store tests in `stores/index.test.ts`. Test state transitions, not implementation details.
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
- Don't call `set()` outside of store actions — keep mutations in the store
|
||||
- Don't mix API concerns across stores — each store owns its domain
|
||||
- Don't skip error state — every async action needs `try/catch` with `set({ error })`
|
||||
- Don't duplicate data between stores — pick one owner
|
||||
- Don't subscribe to stores in components that only pass data down — use props
|
||||
@@ -1,6 +1,13 @@
|
||||
import { create } from 'zustand';
|
||||
import axios from 'axios';
|
||||
import { analysisApi, insightsApi } from '@/services/api';
|
||||
import { analysisApi, insightsApi, caseApi, envApi } from '@/services/api';
|
||||
import type {
|
||||
DemographicsResponse,
|
||||
DiseaseSeasonalityResponse,
|
||||
LagCorrelationResponse,
|
||||
CaseTrendResponse,
|
||||
PollutantPoint,
|
||||
} from '@/types';
|
||||
|
||||
function isCancelError(e: unknown): boolean {
|
||||
return axios.isCancel(e) || (e as Error)?.message === 'canceled';
|
||||
@@ -19,10 +26,12 @@ interface TrendDataPoint {
|
||||
|
||||
interface DistrictData {
|
||||
district: string;
|
||||
avg_aqi: number;
|
||||
avg_risk: number;
|
||||
high_risk_count: number;
|
||||
avg_aqi: number;
|
||||
population: number;
|
||||
high_risk_count: number;
|
||||
total_grids: number;
|
||||
total_cases: number;
|
||||
}
|
||||
|
||||
interface InsightCard {
|
||||
@@ -40,6 +49,7 @@ interface InsightsOverview {
|
||||
warning_count: number;
|
||||
info_count: number;
|
||||
success_count: number;
|
||||
danger_count: number;
|
||||
cards: InsightCard[];
|
||||
}
|
||||
|
||||
@@ -50,11 +60,23 @@ interface AnalysisState {
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
selectedDays: number;
|
||||
caseTrendError: string | null;
|
||||
seasonalError: string | null;
|
||||
correlationError: string | null;
|
||||
diagnosisDistributionError: string | null;
|
||||
caseTrendData: CaseTrendResponse | null;
|
||||
seasonalData: DiseaseSeasonalityResponse | null;
|
||||
correlationData: LagCorrelationResponse | null;
|
||||
diagnosisDistributionData: DemographicsResponse | null;
|
||||
setSelectedDays: (days: number) => void;
|
||||
fetchTrend: (days?: number) => Promise<void>;
|
||||
fetchDistricts: () => Promise<void>;
|
||||
fetchInsights: () => Promise<void>;
|
||||
clearError: () => void;
|
||||
fetchCaseTrend: (params?: { start_date?: string; end_date?: string; group_by?: 'day' | 'week' | 'month'; diagnosis?: string }) => Promise<void>;
|
||||
fetchSeasonal: () => Promise<void>;
|
||||
fetchDiagnosisDistribution: () => Promise<void>;
|
||||
fetchCorrelations: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useAnalysisStore = create<AnalysisState>((set, get) => ({
|
||||
@@ -64,6 +86,14 @@ export const useAnalysisStore = create<AnalysisState>((set, get) => ({
|
||||
isLoading: false,
|
||||
error: null,
|
||||
selectedDays: 7,
|
||||
caseTrendError: null,
|
||||
seasonalError: null,
|
||||
correlationError: null,
|
||||
diagnosisDistributionError: null,
|
||||
caseTrendData: null,
|
||||
seasonalData: null,
|
||||
correlationData: null,
|
||||
diagnosisDistributionData: null,
|
||||
|
||||
setSelectedDays: (days) => {
|
||||
set({ selectedDays: days });
|
||||
@@ -75,16 +105,17 @@ export const useAnalysisStore = create<AnalysisState>((set, get) => ({
|
||||
fetchTrend: async (days = 7) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const data = await analysisApi.getTrend(days);
|
||||
const trendData: TrendDataPoint[] = (data.dates || []).map((date: string, i: number) => ({
|
||||
date,
|
||||
aqi: Math.round((data.values?.[i] || 0.5) * 200),
|
||||
pm25: Math.round((data.values?.[i] || 0.5) * 100),
|
||||
pm10: Math.round((data.values?.[i] || 0.5) * 150),
|
||||
so2: Math.round((data.values?.[i] || 0.5) * 30),
|
||||
no2: Math.round((data.values?.[i] || 0.5) * 80),
|
||||
co: Math.round((data.values?.[i] || 0.5) * 2 * 100) / 100,
|
||||
o3: Math.round((data.values?.[i] || 0.5) * 150),
|
||||
// Real pollutant time series (AQI/PM25/PM10/SO2/NO2/O3/CO per date).
|
||||
const data = await envApi.getPollutants(days);
|
||||
const trendData: TrendDataPoint[] = (data.data || []).map((p: PollutantPoint) => ({
|
||||
date: p.date,
|
||||
aqi: p.AQI,
|
||||
pm25: p.PM25,
|
||||
pm10: p.PM10,
|
||||
so2: p.SO2,
|
||||
no2: p.NO2,
|
||||
co: p.CO,
|
||||
o3: p.O3,
|
||||
}));
|
||||
set({ trendData, isLoading: false });
|
||||
} catch (e) {
|
||||
@@ -114,4 +145,48 @@ export const useAnalysisStore = create<AnalysisState>((set, get) => ({
|
||||
set({ error: (e as Error).message || '加载洞察数据失败', isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
fetchCaseTrend: async (params) => {
|
||||
set({ isLoading: true, caseTrendError: null });
|
||||
try {
|
||||
const data = await caseApi.getTrend(params);
|
||||
set({ caseTrendData: data, caseTrendError: null, isLoading: false });
|
||||
} catch (e) {
|
||||
if (isCancelError(e)) return;
|
||||
set({ caseTrendError: (e as Error).message, isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
fetchSeasonal: async () => {
|
||||
set({ isLoading: true, seasonalError: null });
|
||||
try {
|
||||
const data = await caseApi.getDiseaseSeasonality();
|
||||
set({ seasonalData: data, seasonalError: null, isLoading: false });
|
||||
} catch (e) {
|
||||
if (isCancelError(e)) return;
|
||||
set({ seasonalError: (e as Error).message, isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
fetchDiagnosisDistribution: async () => {
|
||||
set({ isLoading: true, diagnosisDistributionError: null });
|
||||
try {
|
||||
const data = await caseApi.getDemographics();
|
||||
set({ diagnosisDistributionData: data, diagnosisDistributionError: null, isLoading: false });
|
||||
} catch (e) {
|
||||
if (isCancelError(e)) return;
|
||||
set({ diagnosisDistributionError: (e as Error).message, isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
fetchCorrelations: async () => {
|
||||
set({ isLoading: true, correlationError: null });
|
||||
try {
|
||||
const data = await envApi.getLagCorrelations();
|
||||
set({ correlationData: data, correlationError: null, isLoading: false });
|
||||
} catch (e) {
|
||||
if (isCancelError(e)) return;
|
||||
set({ correlationError: (e as Error).message, isLoading: false });
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -172,7 +172,7 @@ interface MonitoringState {
|
||||
error: string | null;
|
||||
fetchGridFeatures: (date: string) => Promise<void>;
|
||||
fetchAggregatedData: (startDate: string, endDate: string, district?: string) => Promise<void>;
|
||||
fetchDistrictCases: (diagnosis?: string) => Promise<void>;
|
||||
fetchDistrictCases: (diagnosis?: string, startDate?: string, endDate?: string) => Promise<void>;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
@@ -219,10 +219,14 @@ export const useMonitoringStore = create<MonitoringState>((set) => ({
|
||||
}
|
||||
},
|
||||
|
||||
fetchDistrictCases: async (diagnosis) => {
|
||||
fetchDistrictCases: async (diagnosis?: string, startDate?: string, endDate?: string) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const data = await caseApi.getDistricts(diagnosis ? { diagnosis } : undefined);
|
||||
const params: Record<string, string> = {};
|
||||
if (diagnosis) params.diagnosis = diagnosis;
|
||||
if (startDate) params.start_date = startDate;
|
||||
if (endDate) params.end_date = endDate;
|
||||
const data = await caseApi.getDistricts(Object.keys(params).length > 0 ? params : undefined);
|
||||
const districts = Array.isArray(data) ? data : (data as any).districts || [];
|
||||
set({ districtCases: districts, isLoading: false });
|
||||
} catch (e) {
|
||||
|
||||
@@ -92,6 +92,8 @@ export interface DistrictCaseData {
|
||||
outpatient: number;
|
||||
inpatient: number;
|
||||
total: number;
|
||||
outpatient_ratio: number;
|
||||
inpatient_ratio: number;
|
||||
prev_period_total?: number;
|
||||
change_pct?: number;
|
||||
}
|
||||
@@ -129,7 +131,7 @@ export interface CaseTrendResponse {
|
||||
|
||||
export interface DistrictCaseResponse {
|
||||
districts: DistrictCaseData[];
|
||||
timestamp: string;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface CaseStatsResponse {
|
||||
@@ -240,3 +242,92 @@ export interface ReportListResponse {
|
||||
total: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// --- Diagnosis Distribution ---
|
||||
export interface DiagnosisDistributionItem {
|
||||
diagnosis: string;
|
||||
outpatient: number;
|
||||
inpatient: number;
|
||||
total: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export interface DiagnosisDistributionResponse {
|
||||
diagnoses: DiagnosisDistributionItem[];
|
||||
total_cases: number;
|
||||
}
|
||||
|
||||
// --- Demographics ---
|
||||
export interface AgeBin {
|
||||
age_bin: number;
|
||||
outpatient: number;
|
||||
inpatient: number;
|
||||
}
|
||||
|
||||
export interface GenderSplit {
|
||||
outpatient: number;
|
||||
inpatient: number;
|
||||
}
|
||||
|
||||
export interface GenderSplitData {
|
||||
male: GenderSplit;
|
||||
female: GenderSplit;
|
||||
}
|
||||
|
||||
export interface AgeDiagnosisMatrixItem {
|
||||
age_group: string;
|
||||
diagnosis: string;
|
||||
outpatient: number;
|
||||
inpatient: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface DemographicsResponse {
|
||||
age_distribution: AgeBin[];
|
||||
gender_split: GenderSplitData;
|
||||
age_diagnosis_matrix: AgeDiagnosisMatrixItem[];
|
||||
}
|
||||
|
||||
// --- Disease Seasonality ---
|
||||
export interface DiseaseSeasonalityPoint {
|
||||
diagnosis: string;
|
||||
month: number;
|
||||
month_label: string;
|
||||
outpatient: number;
|
||||
inpatient: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface DiseaseSeasonalityResponse {
|
||||
seasonality: DiseaseSeasonalityPoint[];
|
||||
diagnoses: string[];
|
||||
}
|
||||
|
||||
// --- Environment ---
|
||||
export interface LagCorrelationItem {
|
||||
pollutant: string;
|
||||
lag_days: number;
|
||||
correlation: number;
|
||||
}
|
||||
|
||||
export interface LagCorrelationResponse {
|
||||
correlations: LagCorrelationItem[];
|
||||
data_note: string;
|
||||
}
|
||||
|
||||
export interface PollutantPoint {
|
||||
date: string;
|
||||
AQI: number;
|
||||
PM25: number;
|
||||
PM10: number;
|
||||
SO2: number;
|
||||
NO2: number;
|
||||
O3: number;
|
||||
CO: number;
|
||||
}
|
||||
|
||||
export interface PollutantResponse {
|
||||
data: PollutantPoint[];
|
||||
station_count: number;
|
||||
date_range: { start: string; end: string };
|
||||
}
|
||||
|
||||
BIN
processed/cases_combined.parquet
LFS
Normal file
BIN
processed/cases_combined.parquet
LFS
Normal file
Binary file not shown.
BIN
processed/cases_inpatient.parquet
LFS
Normal file
BIN
processed/cases_inpatient.parquet
LFS
Normal file
Binary file not shown.
BIN
processed/cases_outpatient.parquet
LFS
Normal file
BIN
processed/cases_outpatient.parquet
LFS
Normal file
Binary file not shown.
15
pyrightconfig.json
Normal file
15
pyrightconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"include": ["backend"],
|
||||
"extraPaths": ["backend"],
|
||||
"pythonVersion": "3.11",
|
||||
"typeCheckingMode": "standard",
|
||||
"reportUnknownMemberType": false,
|
||||
"reportUnknownArgumentType": false,
|
||||
"reportUnknownVariableType": false,
|
||||
"reportUnknownParameterType": false,
|
||||
"reportMissingTypeStubs": false,
|
||||
"reportAttributeAccessIssue": false,
|
||||
"reportArgumentType": false,
|
||||
"reportCallIssue": false,
|
||||
"reportOperatorIssue": false
|
||||
}
|
||||
Reference in New Issue
Block a user