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.
This commit is contained in:
2026-06-05 02:13:49 +08:00
commit fc468464b2
117 changed files with 18282 additions and 0 deletions

View File

@@ -0,0 +1,302 @@
import { useEffect, useRef, useState, useCallback } 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 } from '@/types';
export interface CellInfo {
lat: number;
lon: number;
risk: number;
nearestAlertId: string | null;
nearestAlertDist: number;
}
interface AlertMapProps {
selectedGridId: string | null;
onGridClick: (id: string) => void;
onCellInfo?: (info: CellInfo) => void;
forecastDay?: 1 | 3 | 7;
showAlertMarkers?: boolean;
showGrid?: boolean;
filteredAlerts?: Alert[];
riskRange?: [number, number];
isFullscreen?: boolean;
}
const WUHAN_CENTER: [number, number] = [30.59, 114.31];
const RISK_COLORS: [number, number, string][] = [
[0.0, 0.2, '#22c55e'],
[0.2, 0.4, '#3b82f6'],
[0.4, 0.6, '#eab308'],
[0.6, 0.8, '#f97316'],
[0.8, 1.0, '#ef4444'],
];
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 '低风险';
}
function AlertMapComponent({
selectedGridId,
onGridClick,
onCellInfo,
forecastDay = 1,
showAlertMarkers = true,
showGrid = true,
filteredAlerts = [],
riskRange,
isFullscreen = false,
}: AlertMapProps) {
const mapRef = useRef<HTMLDivElement>(null);
const mapInstanceRef = useRef<L.Map | null>(null);
const alertLayerRef = useRef<L.LayerGroup | null>(null);
const selectedMarkerRef = useRef<L.Rectangle | null>(null);
const clickHandlerRef = useRef(onGridClick);
const [currentZoom, setCurrentZoom] = useState(10);
const grids = useRiskStore((s) => s.grids ?? []);
// LOD grid data for stats overlay
const { count, avgRisk, maxRisk, loading } = useLodGrid(currentZoom, forecastDay);
useEffect(() => {
clickHandlerRef.current = onGridClick;
}, [onGridClick]);
// Initialize map
useEffect(() => {
if (!mapRef.current || mapInstanceRef.current) return;
const map = L.map(mapRef.current, {
center: WUHAN_CENTER,
zoom: 9,
zoomControl: true,
preferCanvas: true,
});
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
maxZoom: 19,
}).addTo(map);
map.on('zoomend', () => {
setCurrentZoom(map.getZoom());
});
mapInstanceRef.current = map;
return () => {
map.remove();
mapInstanceRef.current = null;
};
}, []);
// Render alert markers overlay
const renderAlertMarkers = useCallback(() => {
const map = mapInstanceRef.current;
if (!map) return;
if (alertLayerRef.current) {
try { map.removeLayer(alertLayerRef.current); } catch { /* ok */ }
alertLayerRef.current = null;
}
if (!showAlertMarkers || !filteredAlerts || filteredAlerts.length === 0) return;
const layer = L.layerGroup();
const mapBounds = map.getBounds();
const maxMarkers = 500;
const step = Math.max(1, Math.floor(filteredAlerts.length / maxMarkers));
for (let i = 0; i < filteredAlerts.length; i += step) {
const alert = filteredAlerts[i];
if (!alert.latitude || !alert.longitude) continue;
// Skip if outside viewport
if (
alert.latitude < mapBounds.getSouth() ||
alert.latitude > mapBounds.getNorth() ||
alert.longitude < mapBounds.getWest() ||
alert.longitude > mapBounds.getEast()
) {
continue;
}
const isP1 = alert.priority === 'P1';
const latHalf = 0.00045;
const lonHalf = 0.00052;
const rect = L.rectangle(
[
[alert.latitude - latHalf, alert.longitude - lonHalf],
[alert.latitude + latHalf, alert.longitude + lonHalf],
],
{
fillColor: isP1 ? '#ef4444' : '#f97316',
fillOpacity: 0.4,
color: isP1 ? '#ef4444' : '#f97316',
weight: 2,
dashArray: isP1 ? undefined : '4 2',
}
);
rect.bindTooltip(
`<div style="font-size:12px;">
<strong>${alert.priority}</strong> · ${(alert.risk_value * 100).toFixed(0)}%<br/>
${alert.region || ''} ${alert.street || ''}
</div>`,
{ direction: 'top', offset: [0, -5] }
);
rect.on('click', () => {
if (alert.grid_id) clickHandlerRef.current(alert.grid_id);
});
rect.addTo(layer);
}
layer.addTo(map);
alertLayerRef.current = layer;
}, [filteredAlerts, showAlertMarkers]);
// Re-render alert markers when data changes
useEffect(() => {
renderAlertMarkers();
}, [renderAlertMarkers]);
// Also re-render on map zoom/pan
useEffect(() => {
const map = mapInstanceRef.current;
if (!map) return;
const handleMove = () => renderAlertMarkers();
map.on('moveend', handleMove);
return () => { map.off('moveend', handleMove); };
}, [renderAlertMarkers]);
// Selected grid highlight
useEffect(() => {
const map = mapInstanceRef.current;
if (!map) return;
if (selectedMarkerRef.current) {
try { map.removeLayer(selectedMarkerRef.current); } catch { /* ok */ }
selectedMarkerRef.current = null;
}
if (selectedGridId) {
let grid = grids.find((g) => g.grid_id === selectedGridId);
if (!grid) {
const selectedAlertObj = filteredAlerts.find((a) => a.grid_id === selectedGridId);
if (selectedAlertObj) {
grid = grids.find((g) =>
Math.abs(g.latitude - selectedAlertObj.latitude) < 0.001 &&
Math.abs(g.longitude - selectedAlertObj.longitude) < 0.001
);
}
}
if (grid) {
const latHalf = 0.00045;
const lonHalf = 0.00052;
const marker = L.rectangle(
[
[grid.latitude - latHalf, grid.longitude - lonHalf],
[grid.latitude + latHalf, grid.longitude + lonHalf],
],
{
fillColor: '#3b82f6',
fillOpacity: 0.3,
color: '#3b82f6',
weight: 3,
}
).addTo(map);
selectedMarkerRef.current = marker;
map.flyTo([grid.latitude, grid.longitude], Math.max(map.getZoom(), 12), { duration: 0.5 });
}
}
}, [selectedGridId, grids]);
// Handle LOD grid cell click → find nearest alert
const handleCellClick = useCallback(
(lat: number, lon: number, risk: number) => {
let nearestId: string | null = null;
let minDist = Infinity;
if (filteredAlerts) {
for (const a of filteredAlerts) {
const d = Math.sqrt((a.latitude - lat) ** 2 + (a.longitude - lon) ** 2);
if (d < minDist) {
minDist = d;
nearestId = a.grid_id;
}
}
}
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
useEffect(() => {
const map = mapInstanceRef.current;
if (!map) return;
const timer = setTimeout(() => map.invalidateSize({ animate: true }), 100);
return () => clearTimeout(timer);
}, [isFullscreen]);
const containerHeight = isFullscreen ? 'calc(100vh - 120px)' : 'calc(100vh - 280px)';
return (
<div className="relative">
<div ref={mapRef} className="w-full rounded-lg overflow-hidden" style={{ height: containerHeight }} />
{/* LOD Grid Layer */}
<LodGridLayer
map={mapInstanceRef.current}
forecastDay={forecastDay}
visible={showGrid}
riskRange={riskRange}
onCellClick={handleCellClick}
/>
{/* Stats overlay */}
<GridStatsOverlay
count={count}
avgRisk={avgRisk}
maxRisk={maxRisk}
loading={loading}
forecastDay={forecastDay}
/>
{/* Legend */}
<div className="absolute bottom-4 right-4 bg-bg-card/90 backdrop-blur rounded-lg border border-border-light shadow-sm z-[1000] px-4 py-3">
<div className="text-[11px] font-semibold text-text-secondary mb-2"></div>
<div className="space-y-1.5">
{RISK_COLORS.slice().reverse().map(([min, max, color]) => (
<div key={color} className="flex items-center gap-2">
<div className="w-4 h-4 rounded" style={{ backgroundColor: color }} />
<span className="text-[11px] text-text-secondary">
{getRiskLabel((min + max) / 2)} ({(min * 100).toFixed(0)}-{(max * 100).toFixed(0)}%)
</span>
</div>
))}
</div>
</div>
</div>
);
}
export const AlertMap = AlertMapComponent;

View File

@@ -0,0 +1,116 @@
import { useEffect, useRef, useState } from 'react';
import L from 'leaflet';
interface CaseLocation {
case_id: string;
case_type: string;
latitude: number;
longitude: number;
district: string;
street: string;
}
const WUHAN_CENTER: [number, number] = [30.59, 114.31];
export function CaseLocationMap({ height = '400px' }: { height?: string }) {
const mapRef = useRef<HTMLDivElement>(null);
const mapInstanceRef = useRef<L.Map | null>(null);
const layerRef = useRef<L.LayerGroup | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [caseCount, setCaseCount] = useState(0);
useEffect(() => {
if (!mapRef.current || mapInstanceRef.current) return;
const map = L.map(mapRef.current, {
center: WUHAN_CENTER,
zoom: 11,
zoomControl: true,
});
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap',
maxZoom: 18,
}).addTo(map);
mapInstanceRef.current = map;
layerRef.current = L.layerGroup().addTo(map);
// Fetch case locations
fetch('/api/geocoded/geocoded?limit=5000')
.then((res) => res.json())
.then((data) => {
const cases: CaseLocation[] = data.cases || [];
const layer = layerRef.current;
if (!layer) return;
layer.clearLayers();
// Deduplicate by case_id to avoid overlapping markers
const seen = new Set<string>();
const unique: CaseLocation[] = [];
for (const c of cases) {
if (!seen.has(c.case_id)) {
seen.add(c.case_id);
unique.push(c);
}
}
for (const c of unique) {
if (!c.latitude || !c.longitude) continue;
const color = c.case_type === 'inpatient' ? '#ef4444' : '#3b82f6';
const marker = L.circleMarker([c.latitude, c.longitude], {
radius: 3,
fillColor: color,
fillOpacity: 0.6,
color: color,
weight: 1,
});
marker.bindTooltip(
`<div style="font-size:12px">
<strong>${c.district}</strong> ${c.street}<br/>
类型: ${c.case_type === 'inpatient' ? '住院' : '门诊'}
</div>`,
{ direction: 'top', offset: [0, -4] }
);
marker.addTo(layer);
}
setCaseCount(unique.length);
setIsLoading(false);
// Fit bounds to case locations
if (unique.length > 0) {
const bounds = L.latLngBounds(unique.map((c) => [c.latitude, c.longitude]));
map.fitBounds(bounds, { padding: [30, 30] });
}
})
.catch(() => setIsLoading(false));
return () => {
map.remove();
mapInstanceRef.current = null;
};
}, []);
return (
<div className="relative">
<div ref={mapRef} style={{ height, width: '100%', borderRadius: '8px' }} />
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-white/80 rounded-lg">
<div className="text-sm text-gray-500">...</div>
</div>
)}
{!isLoading && (
<div className="absolute top-2 right-2 bg-white/90 px-3 py-1.5 rounded shadow text-xs">
<span className="text-blue-600 font-semibold">{caseCount.toLocaleString()}</span>
<span className="ml-2 text-red-500"> </span>
<span className="ml-1 text-blue-500"> </span>
</div>
)}
</div>
);
}

View File

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

View File

@@ -0,0 +1,51 @@
interface DistributionChartProps {
distribution: {
high: number;
medium_high: number;
medium: number;
medium_low: number;
low: number;
};
}
const LEVELS = [
{ key: 'high', label: '高风险 (86-100%)', color: 'bg-danger' },
{ key: 'medium_high', label: '中高风险 (71-85%)', color: 'bg-[#FB923C]' },
{ key: 'medium', label: '中风险 (51-70%)', color: 'bg-warning' },
{ key: 'medium_low', label: '中低风险 (31-50%)', color: 'bg-[#7DD3FC]' },
{ key: 'low', label: '低风险 (0-30%)', color: 'bg-success' },
];
export function DistributionChart({ distribution }: DistributionChartProps) {
const total = Object.values(distribution).reduce((sum, val) => sum + val, 0);
return (
<div className="card p-4 h-fit">
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
</div>
{LEVELS.map((level) => {
const value = distribution[level.key as keyof typeof distribution];
const percentage = total > 0 ? (value / total) * 100 : 0;
return (
<div key={level.key} className="mb-3.5 last:mb-0">
<div className="flex justify-between mb-1.5">
<span className="text-[12px] text-text-secondary">{level.label}</span>
<span className="text-[12px] font-semibold">
{value} ({percentage.toFixed(1)}%)
</span>
</div>
<div className="h-[5px] bg-bg-page rounded overflow-hidden">
<div
className={`h-full rounded ${level.color}`}
style={{ width: `${percentage}%` }}
/>
</div>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,35 @@
import { AlertCircle, RefreshCw, X } from 'lucide-react';
interface ErrorBannerProps {
error: string;
onRetry?: () => void;
onDismiss?: () => void;
}
export function ErrorBanner({ error, onRetry, onDismiss }: ErrorBannerProps) {
return (
<div className="mb-4 flex items-center gap-3 rounded-lg border border-danger/20 bg-danger-light px-4 py-3">
<AlertCircle className="h-5 w-5 shrink-0 text-danger" />
<span className="flex-1 text-[13px] text-danger">{error}</span>
<div className="flex items-center gap-2">
{onRetry && (
<button
onClick={onRetry}
className="flex items-center gap-1 rounded px-2.5 py-1 text-[12px] font-medium text-danger transition-colors hover:bg-danger/10"
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
)}
{onDismiss && (
<button
onClick={onDismiss}
className="rounded p-1 text-danger transition-colors hover:bg-danger/10"
>
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,30 @@
interface GridStatsOverlayProps {
count: number;
avgRisk: number;
maxRisk: number;
loading?: boolean;
forecastDay?: 1 | 3 | 7;
}
export function GridStatsOverlay({ count, avgRisk, maxRisk, loading, forecastDay }: GridStatsOverlayProps) {
return (
<div className="absolute top-3 left-3 bg-bg-card/90 backdrop-blur rounded-lg border border-border-light shadow-sm z-[1000] px-3 py-2">
<div className="text-[11px] text-text-secondary space-y-1">
{forecastDay && (
<div className="font-semibold text-text-primary mb-1">
{forecastDay} · LOD网格
</div>
)}
<div>
<span className="font-semibold text-text-primary">{loading ? '...' : count.toLocaleString()}</span>
</div>
<div>
<span className="font-semibold text-text-primary">{loading ? '...' : `${(avgRisk * 100).toFixed(1)}%`}</span>
</div>
<div>
<span className="font-semibold text-text-primary">{loading ? '...' : `${(maxRisk * 100).toFixed(1)}%`}</span>
</div>
</div>
</div>
);
}

View File

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

View File

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

View File

@@ -0,0 +1,112 @@
import { useState } from 'react';
interface SideNavProps {
activePage: string;
onPageChange: (page: string) => void;
alertCount?: number;
}
export function SideNav({
activePage,
onPageChange,
alertCount = 0,
}: SideNavProps) {
const [expanded, setExpanded] = useState<string | null>('monitoring');
const modules: { id: string; label: string; icon: React.ReactNode; items: { id: string; label: string }[] }[] = [
{
id: 'monitoring',
label: '监测',
icon: (
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z"/>
</svg>
),
items: [
{ id: 'monitoring', label: '监测面板' },
],
},
{
id: 'alert',
label: '预警',
icon: (
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/>
</svg>
),
items: [
{ id: 'alerts', label: '预警地图' },
],
},
{
id: 'analysis',
label: '分析',
icon: (
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM9 17H7v-7h2v7zm4 0h-2V7h2v10zm4 0h-2v-4h2v4z"/>
</svg>
),
items: [
{ id: 'trend-analysis', label: '趋势分析' },
{ id: 'district-comparison', label: '区域对比' },
{ id: 'insights', label: '智能洞察' },
],
},
];
const handleItemClick = (moduleId: string, itemId: string) => {
setExpanded(moduleId);
onPageChange(itemId);
};
const isActiveModule = (moduleId: string) => {
const module = modules.find(m => m.id === moduleId);
if (!module) return false;
return module.items.some(item => item.id === activePage);
};
return (
<aside className="w-[200px] bg-bg-card border-r border-border fixed top-[52px] left-0 bottom-0 overflow-y-auto py-4 px-2">
{modules.map((module) => (
<div key={module.id} className="mb-4">
<button
onClick={() => setExpanded(expanded === module.id ? null : module.id)}
className={`w-full flex items-center gap-[10px] px-3 py-[9px] rounded-md text-[14px] font-semibold transition-colors ${
isActiveModule(module.id)
? 'bg-primary-muted text-primary'
: 'text-text-primary hover:bg-bg-hover'
}`}
>
<span className="w-4 h-4 flex items-center justify-center">
{module.icon}
</span>
<span>{module.label}</span>
{module.id === 'alert' && alertCount > 0 && (
<span className="ml-auto bg-danger-light text-danger text-[10px] font-semibold px-[5px] py-[2px] rounded">
{alertCount > 99 ? '99+' : alertCount}
</span>
)}
</button>
{expanded === module.id && (
<div className="mt-1 pl-7">
{module.items.map((item) => (
<button
key={item.id}
onClick={() => handleItemClick(module.id, item.id)}
className={`w-full text-left px-3 py-[7px] rounded text-[13px] font-medium transition-colors ${
activePage === item.id
? 'bg-bg-active text-primary'
: 'text-text-secondary hover:bg-bg-hover hover:text-text-primary'
}`}
>
{item.label}
</button>
))}
</div>
)}
</div>
))}
</aside>
);
}

View File

@@ -0,0 +1,44 @@
interface StatCardProps {
label: string;
value: string | number;
change?: string;
changeType?: 'up' | 'down' | 'neutral';
progress?: number;
progressColor?: string;
}
export function StatCard({
label,
value,
change,
changeType = 'neutral',
progress,
progressColor = 'bg-warning',
}: StatCardProps) {
return (
<div className="card p-4">
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-1.5">
{label}
</div>
<div className="font-display text-[26px] font-bold text-text-primary mb-1">
{value}
</div>
{change && (
<div className={`text-[11px] ${
changeType === 'up' ? 'text-danger' :
changeType === 'down' ? 'text-success' : 'text-text-muted'
}`}>
{change}
</div>
)}
{progress !== undefined && (
<div className="h-[3px] bg-bg-page rounded mt-2.5 overflow-hidden">
<div
className={`h-full rounded ${progressColor}`}
style={{ width: `${progress}%` }}
/>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,225 @@
import { useState, useMemo } from 'react';
import { TrendingUp, Activity } from 'lucide-react';
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
interface StatisticalChartsProps {
data: Array<{
date: string;
cases: number;
risk?: number;
aqi?: number;
}>;
height?: number;
showCases?: boolean;
showRisk?: boolean;
showAQI?: boolean;
}
export function StatisticalCharts({
data,
height = 300,
showCases = true,
showRisk = false,
showAQI = false,
}: StatisticalChartsProps) {
const [activeChart, setActiveChart] = useState<'cases' | 'risk' | 'aqi'>('cases');
const chartData = useMemo(() => {
return data.map((item) => ({
...item,
date: new Date(item.date).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' }),
}));
}, [data]);
const calculateTrend = (values: number[]) => {
if (values.length < 2) return 'stable';
const firstHalf = values.slice(0, Math.floor(values.length / 2));
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 change = ((secondAvg - firstAvg) / firstAvg) * 100;
if (change > 10) return 'up';
if (change < -10) return 'down';
return 'stable';
};
const stats = useMemo(() => {
if (data.length === 0) return null;
const totalCases = data.reduce((sum, item) => sum + item.cases, 0);
const avgCases = totalCases / data.length;
const maxCases = Math.max(...data.map((item) => item.cases));
const trend = calculateTrend(data.map((item) => item.cases));
return {
totalCases,
avgCases: Math.round(avgCases),
maxCases,
trend,
};
}, [data]);
const getTrendIcon = () => {
if (!stats) return null;
switch (stats.trend) {
case 'up':
return <TrendingUp className="w-5 h-5 text-red-500" />;
case 'down':
return <TrendingUp className="w-5 h-5 text-green-500 rotate-180" />;
default:
return <Activity className="w-5 h-5 text-gray-500" />;
}
};
const getTrendLabel = () => {
if (!stats) return '';
switch (stats.trend) {
case 'up':
return '上升趋势';
case 'down':
return '下降趋势';
default:
return '平稳';
}
};
return (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold text-gray-900"></h3>
{getTrendIcon()}
<span className={`text-sm font-medium ${
stats?.trend === 'up' ? 'text-red-600' :
stats?.trend === 'down' ? 'text-green-600' :
'text-gray-600'
}`}>
{getTrendLabel()}
</span>
</div>
<div className="flex gap-2">
{showCases && (
<button
onClick={() => setActiveChart('cases')}
className={`px-3 py-1.5 text-sm font-medium rounded transition-colors ${
activeChart === 'cases'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
</button>
)}
{showRisk && (
<button
onClick={() => setActiveChart('risk')}
className={`px-3 py-1.5 text-sm font-medium rounded transition-colors ${
activeChart === 'risk'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
</button>
)}
{showAQI && (
<button
onClick={() => setActiveChart('aqi')}
className={`px-3 py-1.5 text-sm font-medium rounded transition-colors ${
activeChart === 'aqi'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
AQI
</button>
)}
</div>
</div>
{/* Stats cards */}
{stats && activeChart === 'cases' && (
<div className="grid grid-cols-3 gap-4 mb-4">
<div className="bg-blue-50 rounded-lg p-3">
<div className="text-sm text-gray-600"></div>
<div className="text-2xl font-bold text-blue-600">{stats.totalCases}</div>
</div>
<div className="bg-green-50 rounded-lg p-3">
<div className="text-sm text-gray-600"></div>
<div className="text-2xl font-bold text-green-600">{stats.avgCases}</div>
</div>
<div className="bg-purple-50 rounded-lg p-3">
<div className="text-sm text-gray-600"></div>
<div className="text-2xl font-bold text-purple-600">{stats.maxCases}</div>
</div>
</div>
)}
{/* Chart */}
<div style={{ height }}>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData}>
<defs>
<linearGradient id="colorCases" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
</linearGradient>
<linearGradient id="colorRisk" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#ef4444" stopOpacity={0.3} />
<stop offset="95%" stopColor="#ef4444" stopOpacity={0} />
</linearGradient>
<linearGradient id="colorAQI" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#f59e0b" stopOpacity={0.3} />
<stop offset="95%" stopColor="#f59e0b" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis
dataKey="date"
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={false}
/>
<YAxis
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={false}
tickFormatter={(value) => Math.round(value).toString()}
/>
<Tooltip
contentStyle={{
backgroundColor: 'white',
border: '1px solid #e5e7eb',
borderRadius: '8px',
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
}}
/>
<Area
type="monotone"
dataKey={activeChart === 'cases' ? 'cases' : activeChart === 'risk' ? 'risk' : 'aqi'}
stroke={
activeChart === 'cases' ? '#3b82f6' :
activeChart === 'risk' ? '#ef4444' :
'#f59e0b'
}
fill={
activeChart === 'cases' ? 'url(#colorCases)' :
activeChart === 'risk' ? 'url(#colorRisk)' :
'url(#colorAQI)'
}
strokeWidth={2}
/>
</AreaChart>
</ResponsiveContainer>
</div>
</div>
);
}

View File

@@ -0,0 +1,200 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { Play, Pause, SkipBack, SkipForward } from 'lucide-react';
interface TimelinePlayerProps {
startDate: string;
endDate: string;
currentDate: string;
onDateChange: (date: string) => void;
isPlaying?: boolean;
speed?: number;
onSpeedChange?: (speed: number) => void;
onPlayPause?: (playing: boolean) => void;
}
const SPEEDS = [0.5, 1, 2, 5, 10];
export function TimelinePlayer({
startDate,
endDate,
currentDate,
onDateChange,
isPlaying = false,
speed = 1,
onSpeedChange,
onPlayPause,
}: TimelinePlayerProps) {
const [playing, setPlaying] = useState(isPlaying);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const generateDateRange = useCallback((start: string, end: string) => {
const dates: string[] = [];
const current = new Date(start);
const final = new Date(end);
while (current <= final) {
dates.push(current.toISOString().split('T')[0]);
current.setDate(current.getDate() + 1);
}
return dates;
}, []);
const dateRange = generateDateRange(startDate, endDate);
const currentIndex = dateRange.indexOf(currentDate);
const progress = ((currentIndex + 1) / dateRange.length) * 100;
const play = useCallback(() => {
setPlaying(true);
onPlayPause?.(true);
}, [onPlayPause]);
const pause = useCallback(() => {
setPlaying(false);
onPlayPause?.(false);
}, [onPlayPause]);
const togglePlay = () => {
if (playing) {
pause();
} else {
play();
}
};
const goToNext = useCallback(() => {
const nextIndex = Math.min(currentIndex + 1, dateRange.length - 1);
onDateChange(dateRange[nextIndex]);
}, [currentIndex, dateRange, onDateChange]);
const goToStart = () => {
onDateChange(dateRange[0]);
};
useEffect(() => {
if (playing) {
const interval = 1000 / speed;
timerRef.current = setInterval(() => {
goToNext();
}, interval);
return () => {
if (timerRef.current) {
clearInterval(timerRef.current);
}
};
}
}, [playing, speed, goToNext]);
useEffect(() => {
if (currentIndex >= dateRange.length - 1) {
pause();
}
}, [currentIndex, dateRange.length, pause]);
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const index = Math.round((Number(e.target.value) / 100) * (dateRange.length - 1));
onDateChange(dateRange[index]);
};
const handleSpeedChange = () => {
const currentIndex = SPEEDS.indexOf(speed);
const nextIndex = (currentIndex + 1) % SPEEDS.length;
onSpeedChange?.(SPEEDS[nextIndex]);
};
const formatSpeed = (s: number) => {
return s >= 1 ? `${s}x` : `${s.toFixed(1)}x`;
};
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
const today = new Date();
const isToday = date.toDateString() === today.toDateString();
if (isToday) {
return `今天 ${date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}`;
}
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
};
return (
<div className="fixed right-4 top-1/2 -translate-y-1/2 z-[9999] w-64">
<div className="bg-white/95 backdrop-blur-xl border border-gray-200/80 rounded-2xl shadow-[0_8px_32px_rgba(0,0,0,0.12)] px-4 py-3">
{/* Date display */}
<div className="text-center mb-3">
<div className="font-medium text-gray-900 text-sm">{formatDate(currentDate)}</div>
<div className="text-xs text-gray-400 mt-0.5">
{currentIndex + 1} / {dateRange.length}
</div>
</div>
{/* Vertical slider */}
<div className="flex justify-center mb-3">
<input
type="range"
min="0"
max="100"
value={progress}
onChange={handleSliderChange}
className="h-1.5 w-full bg-gray-200 rounded-full appearance-none cursor-pointer accent-blue-600"
style={{
background: `linear-gradient(to right, #2563eb 0%, #2563eb ${progress}%, #e5e7eb ${progress}%, #e5e7eb 100%)`,
}}
/>
</div>
<div className="flex justify-between text-[10px] text-gray-400 mb-3">
<span>{new Date(startDate).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })}</span>
<span>{new Date(endDate).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })}</span>
</div>
{/* Transport controls */}
<div className="flex items-center justify-center gap-2">
<button
onClick={goToStart}
className="p-1.5 text-gray-400 hover:text-gray-700 hover:bg-gray-100 rounded-full transition-colors"
title="跳到开始"
>
<SkipBack className="w-4 h-4" />
</button>
<button
onClick={togglePlay}
className="p-2.5 bg-blue-600 text-white rounded-full hover:bg-blue-700 transition-colors shadow-md"
>
{playing ? (
<Pause className="w-5 h-5" />
) : (
<Play className="w-5 h-5 ml-0.5" />
)}
</button>
<button
onClick={goToNext}
className="p-1.5 text-gray-400 hover:text-gray-700 hover:bg-gray-100 rounded-full transition-colors"
title="跳到下一天"
>
<SkipForward className="w-4 h-4" />
</button>
</div>
{/* Speed */}
<div className="flex items-center justify-center gap-2 mt-2">
<button
onClick={handleSpeedChange}
className="px-2 py-0.5 text-xs font-medium text-gray-600 bg-gray-100/80 rounded-full hover:bg-gray-200 transition-colors"
title="调整播放速度"
>
{formatSpeed(speed)}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,57 @@
import { useState, useEffect } from 'react';
interface TopNavProps {
onLogout?: () => void;
}
export function TopNav({ onLogout }: TopNavProps) {
const [currentTime, setCurrentTime] = useState('');
useEffect(() => {
const update = () => setCurrentTime(new Date().toLocaleString('zh-CN'));
update();
const timer = setInterval(update, 1000);
return () => clearInterval(timer);
}, []);
return (
<nav className="h-[52px] bg-bg-card border-b border-border flex items-center px-5 fixed top-0 left-0 right-0 z-50">
<div className="flex items-center gap-3">
<div className="w-7 h-7 bg-primary rounded-md flex items-center justify-center">
<svg className="w-4 h-4 fill-white" viewBox="0 0 24 24">
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-7 3c1.93 0 3.5 1.57 3.5 3.5S13.93 13 12 13s-3.5-1.57-3.5-3.5S10.07 6 12 6zm7 13H5v-.23c0-.62.28-1.2.76-1.58C7.47 15.82 9.64 15 12 15s4.53.82 6.24 2.19c.48.38.76.97.76 1.58V19z"/>
</svg>
</div>
<span className="font-display font-semibold text-[15px] text-text-primary">
WuhanChildRisk
</span>
</div>
<div className="w-px h-5 bg-border ml-4 mr-4" />
<span className="text-[13px] text-text-secondary">
</span>
<div className="ml-auto flex items-center gap-5">
<span className="text-[12px] text-text-muted">
{currentTime}
</span>
<div className="flex items-center gap-2 text-[13px] text-text-secondary">
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
</svg>
admin
</div>
{onLogout && (
<button
onClick={onLogout}
className="text-[12px] text-text-muted hover:text-danger transition-colors"
>
退
</button>
)}
</div>
</nav>
);
}