feat: Initial CBPOA commit — 武汉儿童呼吸疾病风险评估系统
Context: Build a spatial risk assessment system correlating air quality
data with children's respiratory disease incidence across Wuhan.
Approach: FastAPI backend serving PostGIS spatial queries, React
frontend with Deck.gl maps, and a PyTorch SpatialTemporalGCN pipeline
for multi-day (1d/3d/7d) risk prediction.
Changes:
- backend/ — FastAPI API with auth (JWT), alerts, risk analysis,
geocoded case data, grid statistics, and report endpoints
- frontend/ — React dashboard with interactive risk maps, alert
monitoring, district comparison charts, and timeline player
- models/ — SpatialTemporalGCN model with trained weights and ONNX
export for inference
- scripts/ — ETL pipeline for weather + medical data, grid generation,
feature engineering, training, and daily inference
- deploy/ — Docker Compose configs for backend, frontend, and MLflow
- docs/ — API docs, deployment guide, user guide, and code review
Impact: Enables spatial risk visualization, alert monitoring, and
ML-driven health risk forecasting for environmental health teams.
2026-06-05 02:13:49 +08:00
|
|
|
|
import { memo, useEffect, useRef, useState, useCallback } from 'react';
|
2026-06-21 20:12:13 +08:00
|
|
|
|
import { Skeleton } from '@/components/ui';
|
feat: Initial CBPOA commit — 武汉儿童呼吸疾病风险评估系统
Context: Build a spatial risk assessment system correlating air quality
data with children's respiratory disease incidence across Wuhan.
Approach: FastAPI backend serving PostGIS spatial queries, React
frontend with Deck.gl maps, and a PyTorch SpatialTemporalGCN pipeline
for multi-day (1d/3d/7d) risk prediction.
Changes:
- backend/ — FastAPI API with auth (JWT), alerts, risk analysis,
geocoded case data, grid statistics, and report endpoints
- frontend/ — React dashboard with interactive risk maps, alert
monitoring, district comparison charts, and timeline player
- models/ — SpatialTemporalGCN model with trained weights and ONNX
export for inference
- scripts/ — ETL pipeline for weather + medical data, grid generation,
feature engineering, training, and daily inference
- deploy/ — Docker Compose configs for backend, frontend, and MLflow
- docs/ — API docs, deployment guide, user guide, and code review
Impact: Enables spatial risk visualization, alert monitoring, and
ML-driven health risk forecasting for environmental health teams.
2026-06-05 02:13:49 +08:00
|
|
|
|
import L from 'leaflet';
|
|
|
|
|
|
import 'leaflet/dist/leaflet.css';
|
feat: add reports center, admin drill-down, disease filter + bug fixes + perf optimization
Frontend features:
- 报表中心 (ReportsCenter): list/detail views, diagnosis breakdown chart, CSV export
- 多级行政下钻 (AdminBreadcrumb): 湖北省→武汉市→区→街道 hierarchical drill-down
- 按病种筛选 (DiseaseFilter): multi-select diagnosis filter on monitoring + reports pages
Backend:
- Add /forecast/{days} endpoint, diagnosis filter params on cases endpoints
- Add /streets aggregation endpoint, enrich reports with real case data
- Extract shared case_loader module
Bug fixes (14):
- Fix missing /risk/forecast route (404), historyApi pointing to non-existent router
- Fix min_risk filter silently ignored in insights/hotspots
- Fix type mismatches: CaseTrendResponse, CaseStatsResponse shapes
- Fix silent .catch(() => {}) swallowing errors, fetchAlerts not clearing stale state
- Fix lru_cache caching exceptions, generateReport used cachedGet for write op
- Fix missing useEffect deps in Insights, DistrictComparison, ReportsCenter
Performance (9):
- Zustand selectors across 9 components (eliminate re-render cascades)
- Fix districtCases.sort() mutating store state, inline IIFE → memo'd component
- CaseLocationMap: React.memo, race protection, correct deps
- AlertCard: stable callbacks, TimelinePlayer: useMemo, TopNav: clock isolation
- SideNav: modules array to module scope, DiseaseFilter: memoized filter
2026-06-08 18:40:08 +08:00
|
|
|
|
import { geocodedApi } from '@/services/api';
|
feat: Initial CBPOA commit — 武汉儿童呼吸疾病风险评估系统
Context: Build a spatial risk assessment system correlating air quality
data with children's respiratory disease incidence across Wuhan.
Approach: FastAPI backend serving PostGIS spatial queries, React
frontend with Deck.gl maps, and a PyTorch SpatialTemporalGCN pipeline
for multi-day (1d/3d/7d) risk prediction.
Changes:
- backend/ — FastAPI API with auth (JWT), alerts, risk analysis,
geocoded case data, grid statistics, and report endpoints
- frontend/ — React dashboard with interactive risk maps, alert
monitoring, district comparison charts, and timeline player
- models/ — SpatialTemporalGCN model with trained weights and ONNX
export for inference
- scripts/ — ETL pipeline for weather + medical data, grid generation,
feature engineering, training, and daily inference
- deploy/ — Docker Compose configs for backend, frontend, and MLflow
- docs/ — API docs, deployment guide, user guide, and code review
Impact: Enables spatial risk visualization, alert monitoring, and
ML-driven health risk forecasting for environmental health teams.
2026-06-05 02:13:49 +08:00
|
|
|
|
import type { CaseGrid, GeocodedCase } from '@/types';
|
|
|
|
|
|
|
|
|
|
|
|
interface CaseMapProps {
|
|
|
|
|
|
height?: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type ViewMode = 'grid' | 'point';
|
|
|
|
|
|
|
|
|
|
|
|
// Grid is 100m x 100m at Wuhan latitude (~30.5°N)
|
|
|
|
|
|
const GRID_HALF_SIZE_LAT = 0.00045; // ~50m in degrees
|
|
|
|
|
|
const GRID_HALF_SIZE_LON = 0.00052; // ~50m in degrees
|
|
|
|
|
|
|
|
|
|
|
|
function getGridBounds(g: { latitude: number; longitude: number }) {
|
|
|
|
|
|
if (typeof g.latitude !== 'number' || typeof g.longitude !== 'number') {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
return {
|
|
|
|
|
|
lat_min: g.latitude - GRID_HALF_SIZE_LAT,
|
|
|
|
|
|
lat_max: g.latitude + GRID_HALF_SIZE_LAT,
|
|
|
|
|
|
lon_min: g.longitude - GRID_HALF_SIZE_LON,
|
|
|
|
|
|
lon_max: g.longitude + GRID_HALF_SIZE_LON,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const RISK_COLORS = {
|
|
|
|
|
|
high: '#ff4444',
|
|
|
|
|
|
medium: '#ffaa44',
|
|
|
|
|
|
low: '#44bb44',
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
function getRiskColor(riskIndex: number): string {
|
|
|
|
|
|
if (riskIndex >= 0.67) return RISK_COLORS.high;
|
|
|
|
|
|
if (riskIndex >= 0.33) return RISK_COLORS.medium;
|
|
|
|
|
|
return RISK_COLORS.low;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getRiskLabel(riskIndex: number): string {
|
|
|
|
|
|
if (riskIndex >= 0.67) return '高风险';
|
|
|
|
|
|
if (riskIndex >= 0.33) return '中风险';
|
|
|
|
|
|
return '低风险';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function debounce<T extends (...args: any[]) => void>(fn: T, ms: number) {
|
|
|
|
|
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
|
|
return (...args: Parameters<T>) => {
|
|
|
|
|
|
if (timer) clearTimeout(timer);
|
|
|
|
|
|
timer = setTimeout(() => fn(...args), ms);
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function CaseMapComponent({ height = '480px' }: CaseMapProps) {
|
|
|
|
|
|
const mapDivRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
const mapRef = useRef<any>(null);
|
|
|
|
|
|
const gridLayerRef = useRef<any>(null);
|
|
|
|
|
|
const pointLayerRef = useRef<any>(null);
|
|
|
|
|
|
|
|
|
|
|
|
const [viewMode, setViewMode] = useState<ViewMode>('grid');
|
|
|
|
|
|
const [grids, setGrids] = useState<CaseGrid[]>([]);
|
|
|
|
|
|
const [cases, setCases] = useState<GeocodedCase[]>([]);
|
|
|
|
|
|
const [totalCases, setTotalCases] = useState(0);
|
|
|
|
|
|
const [gridCount, setGridCount] = useState(0);
|
|
|
|
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
|
|
|
|
|
|
|
async function fetchData() {
|
|
|
|
|
|
setIsLoading(true);
|
|
|
|
|
|
setError(null);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const [gridRes, geoRes] = await Promise.all([
|
feat: add reports center, admin drill-down, disease filter + bug fixes + perf optimization
Frontend features:
- 报表中心 (ReportsCenter): list/detail views, diagnosis breakdown chart, CSV export
- 多级行政下钻 (AdminBreadcrumb): 湖北省→武汉市→区→街道 hierarchical drill-down
- 按病种筛选 (DiseaseFilter): multi-select diagnosis filter on monitoring + reports pages
Backend:
- Add /forecast/{days} endpoint, diagnosis filter params on cases endpoints
- Add /streets aggregation endpoint, enrich reports with real case data
- Extract shared case_loader module
Bug fixes (14):
- Fix missing /risk/forecast route (404), historyApi pointing to non-existent router
- Fix min_risk filter silently ignored in insights/hotspots
- Fix type mismatches: CaseTrendResponse, CaseStatsResponse shapes
- Fix silent .catch(() => {}) swallowing errors, fetchAlerts not clearing stale state
- Fix lru_cache caching exceptions, generateReport used cachedGet for write op
- Fix missing useEffect deps in Insights, DistrictComparison, ReportsCenter
Performance (9):
- Zustand selectors across 9 components (eliminate re-render cascades)
- Fix districtCases.sort() mutating store state, inline IIFE → memo'd component
- CaseLocationMap: React.memo, race protection, correct deps
- AlertCard: stable callbacks, TimelinePlayer: useMemo, TopNav: clock isolation
- SideNav: modules array to module scope, DiseaseFilter: memoized filter
2026-06-08 18:40:08 +08:00
|
|
|
|
geocodedApi.getGrid(),
|
|
|
|
|
|
geocodedApi.getGeocoded({ limit: 5000 }),
|
feat: Initial CBPOA commit — 武汉儿童呼吸疾病风险评估系统
Context: Build a spatial risk assessment system correlating air quality
data with children's respiratory disease incidence across Wuhan.
Approach: FastAPI backend serving PostGIS spatial queries, React
frontend with Deck.gl maps, and a PyTorch SpatialTemporalGCN pipeline
for multi-day (1d/3d/7d) risk prediction.
Changes:
- backend/ — FastAPI API with auth (JWT), alerts, risk analysis,
geocoded case data, grid statistics, and report endpoints
- frontend/ — React dashboard with interactive risk maps, alert
monitoring, district comparison charts, and timeline player
- models/ — SpatialTemporalGCN model with trained weights and ONNX
export for inference
- scripts/ — ETL pipeline for weather + medical data, grid generation,
feature engineering, training, and daily inference
- deploy/ — Docker Compose configs for backend, frontend, and MLflow
- docs/ — API docs, deployment guide, user guide, and code review
Impact: Enables spatial risk visualization, alert monitoring, and
ML-driven health risk forecasting for environmental health teams.
2026-06-05 02:13:49 +08:00
|
|
|
|
]);
|
|
|
|
|
|
if (cancelled) return;
|
|
|
|
|
|
setGrids(gridRes.grids || []);
|
|
|
|
|
|
setGridCount(gridRes.total_count || 0);
|
|
|
|
|
|
setTotalCases(gridRes.total_cases || 0);
|
|
|
|
|
|
setCases(geoRes.cases || []);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
if (cancelled) return;
|
|
|
|
|
|
setError(err instanceof Error ? err.message : '加载失败');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
if (!cancelled) setIsLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fetchData();
|
|
|
|
|
|
return () => { cancelled = true; };
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!mapDivRef.current || mapRef.current) return;
|
|
|
|
|
|
|
|
|
|
|
|
const map = L.map(mapDivRef.current, {
|
|
|
|
|
|
center: [30.59, 114.31],
|
|
|
|
|
|
zoom: 11,
|
|
|
|
|
|
zoomControl: true,
|
|
|
|
|
|
preferCanvas: false,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
|
|
|
|
|
maxZoom: 19,
|
|
|
|
|
|
}).addTo(map);
|
|
|
|
|
|
|
|
|
|
|
|
mapRef.current = map;
|
|
|
|
|
|
|
|
|
|
|
|
const handleZoom = debounce(() => renderLayers(), 150);
|
|
|
|
|
|
const handleMove = debounce(() => renderLayers(), 150);
|
|
|
|
|
|
|
|
|
|
|
|
map.on('zoomend', handleZoom);
|
|
|
|
|
|
map.on('moveend', handleMove);
|
|
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
if (mapRef.current) {
|
|
|
|
|
|
mapRef.current.remove();
|
|
|
|
|
|
mapRef.current = null;
|
|
|
|
|
|
gridLayerRef.current = null;
|
|
|
|
|
|
pointLayerRef.current = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!mapRef.current) return;
|
|
|
|
|
|
renderLayers();
|
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
|
}, [grids, cases, viewMode]);
|
|
|
|
|
|
|
|
|
|
|
|
const renderLayers = useCallback(() => {
|
|
|
|
|
|
if (!mapRef.current) return;
|
|
|
|
|
|
const map = mapRef.current;
|
|
|
|
|
|
|
|
|
|
|
|
if (gridLayerRef.current) {
|
|
|
|
|
|
try { map.removeLayer(gridLayerRef.current); } catch { /* silent */ }
|
|
|
|
|
|
gridLayerRef.current = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (pointLayerRef.current) {
|
|
|
|
|
|
try { map.removeLayer(pointLayerRef.current); } catch { /* silent */ }
|
|
|
|
|
|
pointLayerRef.current = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const zoom = map.getZoom();
|
|
|
|
|
|
|
|
|
|
|
|
if (viewMode === 'grid') {
|
|
|
|
|
|
const gridLayer = L.layerGroup();
|
|
|
|
|
|
const bounds = map.getBounds();
|
|
|
|
|
|
|
|
|
|
|
|
let rendered = 0;
|
|
|
|
|
|
const maxRender = 5000;
|
|
|
|
|
|
|
|
|
|
|
|
for (const g of grids) {
|
|
|
|
|
|
if (rendered >= maxRender) break;
|
|
|
|
|
|
|
|
|
|
|
|
const gBounds = getGridBounds(g);
|
|
|
|
|
|
if (!gBounds) continue;
|
|
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
|
gBounds.lat_max < bounds.getSouth() ||
|
|
|
|
|
|
gBounds.lat_min > bounds.getNorth() ||
|
|
|
|
|
|
gBounds.lon_max < bounds.getWest() ||
|
|
|
|
|
|
gBounds.lon_min > bounds.getEast()
|
|
|
|
|
|
) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const color = getRiskColor(g.risk_index);
|
|
|
|
|
|
const opacity = 0.5 + g.risk_index * 0.35;
|
|
|
|
|
|
|
|
|
|
|
|
const rect = L.rectangle(
|
|
|
|
|
|
[[gBounds.lat_min, gBounds.lon_min], [gBounds.lat_max, gBounds.lon_max]],
|
|
|
|
|
|
{
|
|
|
|
|
|
fillColor: color,
|
|
|
|
|
|
fillOpacity: opacity,
|
|
|
|
|
|
color: color,
|
|
|
|
|
|
weight: zoom >= 14 ? 1 : 0,
|
|
|
|
|
|
opacity: 0.3,
|
|
|
|
|
|
}
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
rect.bindTooltip(
|
|
|
|
|
|
`<div style="font-size: 12px;">
|
|
|
|
|
|
<strong>网格 ${g.grid_id}</strong><br/>
|
|
|
|
|
|
病例数: ${g.total_cases.toLocaleString()}<br/>
|
|
|
|
|
|
风险指数: ${(g.risk_index * 100).toFixed(1)}%<br/>
|
|
|
|
|
|
<span style="color: ${color}; font-weight: 600;">${getRiskLabel(g.risk_index)}</span>
|
|
|
|
|
|
</div>`,
|
|
|
|
|
|
{ direction: 'top', offset: [0, -5] }
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
rect.addTo(gridLayer);
|
|
|
|
|
|
rendered++;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
gridLayer.addTo(map);
|
|
|
|
|
|
gridLayerRef.current = gridLayer;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const pointLayer = L.layerGroup();
|
|
|
|
|
|
const bounds = map.getBounds();
|
|
|
|
|
|
|
|
|
|
|
|
const caseColor = (c: GeocodedCase) =>
|
|
|
|
|
|
c.case_type === 'inpatient' ? '#DC2626' : '#2563EB';
|
|
|
|
|
|
|
|
|
|
|
|
// Viewport culling + maxRender to avoid Leaflet canvas intersects bug
|
|
|
|
|
|
const maxRender = 500;
|
|
|
|
|
|
let rendered = 0;
|
|
|
|
|
|
|
|
|
|
|
|
for (const c of cases) {
|
|
|
|
|
|
if (rendered >= maxRender) break;
|
|
|
|
|
|
if (typeof c.latitude !== 'number' || typeof c.longitude !== 'number') continue;
|
|
|
|
|
|
|
|
|
|
|
|
// Viewport culling - skip points outside visible area
|
|
|
|
|
|
if (
|
|
|
|
|
|
c.latitude < bounds.getSouth() ||
|
|
|
|
|
|
c.latitude > bounds.getNorth() ||
|
|
|
|
|
|
c.longitude < bounds.getWest() ||
|
|
|
|
|
|
c.longitude > bounds.getEast()
|
|
|
|
|
|
) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Use tiny rectangles instead of circleMarker to avoid Leaflet 1.9.4 intersects bug
|
|
|
|
|
|
const size = zoom >= 14 ? 0.00005 : zoom >= 12 ? 0.00003 : 0.00002;
|
|
|
|
|
|
const rect = L.rectangle(
|
|
|
|
|
|
[[c.latitude - size, c.longitude - size], [c.latitude + size, c.longitude + size]],
|
|
|
|
|
|
{
|
|
|
|
|
|
fillColor: caseColor(c),
|
|
|
|
|
|
fillOpacity: 0.8,
|
|
|
|
|
|
color: '#FFFFFF',
|
|
|
|
|
|
weight: 0.5,
|
|
|
|
|
|
}
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
rect.bindTooltip(
|
|
|
|
|
|
`<div style="font-size: 12px;">
|
|
|
|
|
|
<strong>${c.case_type === 'inpatient' ? '住院' : '门诊'}病例</strong><br/>
|
|
|
|
|
|
坐标:${c.latitude.toFixed(5)}, ${c.longitude.toFixed(5)}
|
|
|
|
|
|
</div>`,
|
|
|
|
|
|
{ direction: 'top', offset: [0, -5] }
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
rect.addTo(pointLayer);
|
|
|
|
|
|
rendered++;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pointLayer.addTo(map);
|
|
|
|
|
|
pointLayerRef.current = pointLayer;
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [grids, cases, viewMode]);
|
|
|
|
|
|
|
|
|
|
|
|
const handleToggle = useCallback((mode: ViewMode) => {
|
|
|
|
|
|
setViewMode(mode);
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="card">
|
|
|
|
|
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border-light">
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<svg className="w-4 h-4 text-primary" viewBox="0 0 24 24" fill="currentColor">
|
|
|
|
|
|
<path d="M20.5 3l-.16.03L15 5.1 9 3 3.36 4.9c-.21.07-.36.25-.36.48V20.5c0 .28.22.5.5.5l.16-.03L9 18.9l6 2.1 5.64-1.9c.21-.07.36-.25.36-.48V3.5c0-.28-.22-.5-.5-.5zM15 19l-6-2.11V5l6 2.11V19z"/>
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
<span className="font-medium text-[14px]">病例空间分布</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
|
<div className="flex gap-0.5 bg-bg-page p-0.5 rounded">
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => handleToggle('grid')}
|
|
|
|
|
|
className={`px-3 py-1 text-[12px] font-medium rounded transition-colors ${
|
|
|
|
|
|
viewMode === 'grid'
|
|
|
|
|
|
? 'bg-bg-card text-primary shadow-sm'
|
|
|
|
|
|
: 'text-text-secondary hover:text-text-primary'
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
网格视图
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => handleToggle('point')}
|
|
|
|
|
|
className={`px-3 py-1 text-[12px] font-medium rounded transition-colors ${
|
|
|
|
|
|
viewMode === 'point'
|
|
|
|
|
|
? 'bg-bg-card text-primary shadow-sm'
|
|
|
|
|
|
: 'text-text-secondary hover:text-text-primary'
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
点分布
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="text-[11px] text-text-muted">
|
|
|
|
|
|
{viewMode === 'grid' ? '100×100m 网格' : '个体病例定位'}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="relative" style={{ height }}>
|
|
|
|
|
|
<div ref={mapDivRef} className="w-full h-full overflow-hidden rounded-lg" />
|
|
|
|
|
|
|
|
|
|
|
|
<div className="absolute bottom-4 right-4 bg-bg-card/90 backdrop-blur rounded-lg border border-border-light shadow-sm z-[1000] px-4 py-3">
|
|
|
|
|
|
{viewMode === 'grid' ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div className="text-[11px] font-semibold text-text-secondary mb-2">风险等级</div>
|
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<div className="w-4 h-4 rounded" style={{ backgroundColor: RISK_COLORS.high }} />
|
|
|
|
|
|
<span className="text-[11px] text-text-secondary">高风险 (>67%)</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<div className="w-4 h-4 rounded" style={{ backgroundColor: RISK_COLORS.medium }} />
|
|
|
|
|
|
<span className="text-[11px] text-text-secondary">中风险 (33-67%)</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<div className="w-4 h-4 rounded" style={{ backgroundColor: RISK_COLORS.low }} />
|
|
|
|
|
|
<span className="text-[11px] text-text-secondary">低风险 (<33%)</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div className="text-[11px] font-semibold text-text-secondary mb-2">病例类型</div>
|
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: '#DC2626' }} />
|
|
|
|
|
|
<span className="text-[11px] text-text-secondary">住院病例</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: '#2563EB' }} />
|
|
|
|
|
|
<span className="text-[11px] text-text-secondary">门诊病例</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="absolute top-4 left-4 space-y-2 z-[1000]">
|
|
|
|
|
|
<div className="bg-bg-card/90 backdrop-blur rounded-lg border border-border-light shadow-sm px-3 py-2">
|
|
|
|
|
|
<div className="text-[11px] text-text-secondary">
|
|
|
|
|
|
{isLoading ? (
|
2026-06-21 20:12:13 +08:00
|
|
|
|
<Skeleton className="h-3 w-20 inline-block align-middle" />
|
feat: Initial CBPOA commit — 武汉儿童呼吸疾病风险评估系统
Context: Build a spatial risk assessment system correlating air quality
data with children's respiratory disease incidence across Wuhan.
Approach: FastAPI backend serving PostGIS spatial queries, React
frontend with Deck.gl maps, and a PyTorch SpatialTemporalGCN pipeline
for multi-day (1d/3d/7d) risk prediction.
Changes:
- backend/ — FastAPI API with auth (JWT), alerts, risk analysis,
geocoded case data, grid statistics, and report endpoints
- frontend/ — React dashboard with interactive risk maps, alert
monitoring, district comparison charts, and timeline player
- models/ — SpatialTemporalGCN model with trained weights and ONNX
export for inference
- scripts/ — ETL pipeline for weather + medical data, grid generation,
feature engineering, training, and daily inference
- deploy/ — Docker Compose configs for backend, frontend, and MLflow
- docs/ — API docs, deployment guide, user guide, and code review
Impact: Enables spatial risk visualization, alert monitoring, and
ML-driven health risk forecasting for environmental health teams.
2026-06-05 02:13:49 +08:00
|
|
|
|
) : error ? (
|
|
|
|
|
|
<span className="text-danger">加载失败: {error}</span>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<span className="font-semibold text-text-primary">{totalCases.toLocaleString()}</span> 例病例
|
|
|
|
|
|
<span className="mx-2 text-border">|</span>
|
|
|
|
|
|
{viewMode === 'grid' ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<span className="font-semibold text-text-primary">{gridCount.toLocaleString()}</span> 个网格
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<span className="font-semibold text-text-primary">{cases.length.toLocaleString()}</span> 个定位点
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{!isLoading && !error && viewMode === 'grid' && (
|
|
|
|
|
|
<div className="bg-success/10 backdrop-blur rounded-lg border border-success/30 shadow-sm px-3 py-2">
|
|
|
|
|
|
<div className="text-[11px] text-success font-medium">
|
|
|
|
|
|
基于真实病例地理编码数据
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export const CaseMap = memo(CaseMapComponent);
|