1 Commits

Author SHA1 Message Date
e95e2f1338 feat: add analysis pages and raster risk map
Ship a new app version with broader analytics, restructured
dashboards, and a server-rendered risk map.

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

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

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

1
backend/__init__.py Normal file
View File

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

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

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

View File

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

View File

@@ -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]

View File

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

View File

@@ -13,7 +13,7 @@ from logging_config import setup_logging
from middleware.request_logger import RequestLoggerMiddleware
from auth.router import router as auth_router
from auth.service import seed_default_admin
from routers import risk, alerts, analysis, insights, reports, cases, geocoded, grid, chat
from routers import risk, alerts, analysis, insights, reports, cases, geocoded, grid, chat, environment
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("/")

View File

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

View File

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

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

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

View File

@@ -5,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(

View File

@@ -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(),
)

View File

@@ -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,
)

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
from fastapi import APIRouter, HTTPException, Query
import asyncio
from fastapi import APIRouter, HTTPException, Query, Response
from datetime import datetime, timedelta
from functools import lru_cache
from pathlib import Path
@@ -6,6 +7,7 @@ from typing import Optional
import logging
import sys
import math
import pandas as pd
PROJECT_ROOT = Path(__file__).parent.parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
@@ -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)

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

44
deploy/CLAUDE.md Normal file
View File

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

View File

@@ -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>

View File

@@ -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();
});
});

View File

@@ -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">&lt;25% </span>
</div>
</div>
</div>
</div>

View File

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

View File

@@ -0,0 +1,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

View File

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

View File

@@ -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">

View File

@@ -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;
}

View File

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

View File

@@ -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);

View File

@@ -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: '环境健康' },
],
},
];

View File

@@ -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>&#9650;</span>}
{trend.direction === 'down' && <span aria-hidden>&#9660;</span>}
{trend.direction === 'stable' && <span aria-hidden>&#9644;</span>}
{trend.value}
</span>
) : null;
const sparklineSvg = sparkline && sparkline.data.length >= 2 ? (
<svg
width="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>
);
}
});

View File

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

View File

@@ -26,6 +26,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) {

View File

@@ -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(() => {

View File

@@ -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;
}

View File

@@ -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">&times;</button>
</div>
<div className="space-y-2 text-[12px]">
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span className="font-mono text-text-primary">{cellInfo.grid_id}</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span className="font-mono text-text-primary">{cellInfo.lat.toFixed(4)}, {cellInfo.lon.toFixed(4)}</span>
@@ -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 && (

View 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

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>
);

View File

@@ -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
})}
</>
);
});
});

View 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">
30AQI趋势
</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>
);
}

View File

@@ -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);
}

View File

@@ -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>
);
}

View File

@@ -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' });
});
});

View File

@@ -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[] }> =>

View 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

View File

@@ -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 });
}
},
}));

View File

@@ -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) {

View File

@@ -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

Binary file not shown.

BIN
processed/cases_inpatient.parquet LFS Normal file

Binary file not shown.

Binary file not shown.

15
pyrightconfig.json Normal file
View 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
}