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

119
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,119 @@
import { useEffect, useState, Component, ReactNode, Suspense, lazy, useCallback } from 'react';
import { TopNav } from '@/components/TopNav';
import { SideNav } from '@/components/SideNav';
import { useRiskStore } from '@/stores';
import { Login } from '@/pages/Login';
const MonitoringDashboard = lazy(() => import('@/pages/MonitoringDashboard').then(m => ({ default: m.MonitoringDashboard })));
const AlertsDashboard = lazy(() => import('@/pages/AlertsDashboard').then(m => ({ default: m.AlertsDashboard })));
const TrendAnalysis = lazy(() => import('@/pages/TrendAnalysis').then(m => ({ default: m.TrendAnalysis })));
const DistrictComparison = lazy(() => import('@/pages/DistrictComparison').then(m => ({ default: m.DistrictComparison })));
const Insights = lazy(() => import('@/pages/Insights').then(m => ({ default: m.Insights })));
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
error: string | null;
}
class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error) {
return { hasError: true, error: error.message };
}
render() {
if (this.state.hasError) {
return (
<div className="min-h-screen bg-bg-page flex items-center justify-center">
<div className="text-center">
<div className="text-danger text-lg mb-2"></div>
<div className="text-text-muted text-sm">{this.state.error}</div>
<button
onClick={() => window.location.reload()}
className="mt-4 px-4 py-2 bg-primary text-white rounded"
>
</button>
</div>
</div>
);
}
return this.props.children;
}
}
function PageLoader() {
return (
<div className="flex items-center justify-center h-[60vh]">
<div className="text-text-secondary text-[13px]">...</div>
</div>
);
}
function App() {
const [activePage, setActivePage] = useState('monitoring');
const [token, setToken] = useState<string | null>(() => localStorage.getItem('cbpoa_token'));
const { alerts, fetchAlerts } = useRiskStore();
useEffect(() => {
if (token) fetchAlerts();
}, [fetchAlerts, token]);
const handlePageChange = useCallback((page: string) => {
setActivePage(page);
}, []);
const handleLogin = useCallback((newToken: string) => {
setToken(newToken);
}, []);
const handleLogout = useCallback(() => {
localStorage.removeItem('cbpoa_token');
setToken(null);
}, []);
if (!token) {
return (
<ErrorBoundary>
<Login onLogin={handleLogin} />
</ErrorBoundary>
);
}
return (
<ErrorBoundary>
<div className="min-h-screen bg-bg-page">
<TopNav onLogout={handleLogout} />
<div className="flex pt-[52px]">
<SideNav
activePage={activePage}
onPageChange={handlePageChange}
alertCount={alerts.length}
/>
<main className="flex-1 ml-[200px] p-5">
<Suspense fallback={<PageLoader />}>
{activePage === 'monitoring' && <MonitoringDashboard />}
{activePage === 'alerts' && <AlertsDashboard />}
{activePage === 'trend-analysis' && <TrendAnalysis />}
{activePage === 'district-comparison' && <DistrictComparison />}
{activePage === 'insights' && <Insights />}
</Suspense>
</main>
</div>
</div>
</ErrorBoundary>
);
}
export default App;

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

View File

@@ -0,0 +1,100 @@
import { useState, useEffect, useRef, useCallback } from 'react';
export interface LodGridResult {
grids: number[][];
count: number;
avgRisk: number;
maxRisk: number;
loading: boolean;
}
const EMPTY_RESULT: LodGridResult = {
grids: [],
count: 0,
avgRisk: 0,
maxRisk: 0,
loading: false,
};
export interface MapBounds {
min_lat: number;
max_lat: number;
min_lon: number;
max_lon: number;
}
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 fetchData = useCallback(async (z: number, day: 1 | 3 | 7, b?: MapBounds) => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setResult((prev) => ({ ...prev, loading: true }));
try {
let url = `/api/risk/lod-grid?zoom=${z}&forecast_day=${day}`;
if (b && z >= 10) {
url += `&min_lat=${b.min_lat}&max_lat=${b.max_lat}&min_lon=${b.min_lon}&max_lon=${b.max_lon}`;
}
const resp = await fetch(url, {
signal: controller.signal,
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
const grids: number[][] = data.grids || [];
const count = data.total_count || grids.length;
const riskIndex = day === 1 ? 2 : day === 3 ? 3 : 4;
let sum = 0;
let max = 0;
for (const g of grids) {
const r = g[riskIndex] ?? 0;
sum += r;
if (r > max) max = r;
}
const newResult: LodGridResult = {
grids,
count,
avgRisk: grids.length > 0 ? sum / grids.length : 0,
maxRisk: max,
loading: false,
};
prevResultRef.current = newResult;
setResult(newResult);
} catch (err: unknown) {
if ((err as Error)?.name === 'AbortError') return;
// Keep previous data on error, just stop loading
setResult((prev) => ({ ...prev, loading: false }));
}
}, []);
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
const roundedZoom = Math.round(zoom);
fetchData(roundedZoom, forecastDay, bounds);
}, 150);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [zoom, forecastDay, bounds, fetchData]);
// Cleanup on unmount
useEffect(() => {
return () => {
abortRef.current?.abort();
};
}, []);
return result;
}

38
frontend/src/index.css Normal file
View File

@@ -0,0 +1,38 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-bg-page text-text-primary font-sans;
}
}
@layer components {
.card {
@apply bg-bg-card border border-border rounded-lg;
}
.btn-primary {
@apply bg-primary text-white px-4 py-2 rounded-md text-sm font-medium
hover:bg-primary-light transition-colors;
}
.btn-secondary {
@apply bg-bg-page text-text-secondary px-4 py-2 rounded-md text-sm font-medium
border border-border hover:border-primary hover:text-primary transition-colors;
}
}
/* Leaflet overrides */
.leaflet-container {
font-family: inherit;
}
.leaflet-popup-content-wrapper {
@apply rounded-lg shadow-lg;
}
.leaflet-popup-content {
@apply m-0;
}

10
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@@ -0,0 +1,628 @@
import { useState, useMemo, useCallback, useEffect } 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';
interface ExtendedAlert {
alert_id: string;
grid_id: string;
region: string;
street: string;
latitude: number;
longitude: number;
risk_value: number;
risk_level: 'high' | 'medium_high' | 'medium' | 'medium_low' | 'low';
priority: 'P1' | 'P2';
forecast_horizon: number;
forecast_time: string;
reason: string;
timestamp: string;
}
const HORIZON_LABELS: Record<number, string> = {
1: '1 天后',
3: '3 天后',
7: '7 天后',
};
export function AlertsDashboard() {
const { alerts, isLoading, error, clearError, fetchRiskMap, fetchAlerts } = useRiskStore();
const [selectedHorizon, setSelectedHorizon] = useState<number | 'all'>('all');
const [selectedPriority, setSelectedPriority] = useState<'all' | 'P1' | 'P2'>('all');
const [sortBy, setSortBy] = useState<'risk' | 'time'>('risk');
const [showMap, setShowMap] = useState(true);
const [showAlertMarkers, setShowAlertMarkers] = useState(true);
const [selectedAlert, setSelectedAlert] = useState<string | null>(null);
const [riskRange, setRiskRange] = useState<[number, number]>([0.6, 1.0]);
const [forecastDay, setForecastDay] = useState<1 | 3 | 7>(1);
const [isFullscreen, setIsFullscreen] = useState(false);
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);
// Fetch grids (for map) and alerts (for side panel) on mount
useEffect(() => {
fetchRiskMap();
fetchAlerts();
}, [fetchRiskMap, fetchAlerts]);
const extendedAlerts: ExtendedAlert[] = useMemo(() => {
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 horizon = diffDays <= 1 ? 1 : diffDays <= 3 ? 3 : 7;
return {
...alert,
latitude: alert.latitude || 0,
longitude: alert.longitude || 0,
forecast_horizon: horizon,
};
});
}, [alerts]);
const filteredAlerts = useMemo(() => {
return extendedAlerts
.filter((alert) => {
const horizonMatch = selectedHorizon === 'all' || alert.forecast_horizon === selectedHorizon;
const priorityMatch = selectedPriority === 'all' || alert.priority === selectedPriority;
const riskMatch = alert.risk_value >= riskRange[0] && alert.risk_value <= riskRange[1];
return horizonMatch && priorityMatch && riskMatch;
})
.sort((a, b) => {
if (sortBy === 'risk') {
return b.risk_value - a.risk_value;
}
return new Date(b.forecast_time).getTime() - new Date(a.forecast_time).getTime();
});
}, [extendedAlerts, selectedHorizon, selectedPriority, sortBy, riskRange]);
const p1Count = extendedAlerts.filter((a) => a.priority === 'P1').length;
const p2Count = extendedAlerts.filter((a) => a.priority === 'P2').length;
// Risk distribution stats
const riskStats = useMemo(() => {
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;
const byDistrict: Record<string, number> = {};
for (const a of filteredAlerts) {
const d = a.region || '未知';
byDistrict[d] = (byDistrict[d] || 0) + 1;
}
const topDistricts = Object.entries(byDistrict)
.sort((a, b) => b[1] - a[1])
.slice(0, 5);
return { high, mediumHigh, medium, avgRisk, topDistricts };
}, [filteredAlerts]);
const selectedAlertData = useMemo(() => {
return filteredAlerts.find(a => a.alert_id === selectedAlert);
}, [filteredAlerts, selectedAlert]);
const selectedGridId = useMemo(() => {
if (!selectedAlert) return null;
const alert = filteredAlerts.find(a => a.alert_id === selectedAlert);
return alert?.grid_id ?? null;
}, [filteredAlerts, selectedAlert]);
const handleGridClick = useCallback((gridId: string) => {
const alertForGrid = filteredAlerts.find(a => a.grid_id === gridId);
if (alertForGrid) {
setSelectedAlert(alertForGrid.alert_id);
}
}, [filteredAlerts]);
const handleAlertCardClick = useCallback((id: string) => {
setSelectedAlert(id);
}, []);
const clearSelectedAlert = useCallback(() => {
setSelectedAlert(null);
}, []);
const handleCellInfo = useCallback((info: CellInfo) => {
setCellInfo(info);
setSelectedAlert(null); // Close alert modal if open
}, []);
const clearCellInfo = useCallback(() => {
setCellInfo(null);
}, []);
// Export utilities
const exportToCsv = useCallback(() => {
const headers = ['alert_id', 'grid_id', 'region', 'street', 'latitude', 'longitude', 'risk_value', 'priority', 'forecast_horizon', 'reason', 'timestamp'];
const rows = filteredAlerts.map(a => [
a.alert_id, a.grid_id, a.region, a.street,
a.latitude, a.longitude, a.risk_value, a.priority,
a.forecast_horizon, `"${a.reason}"`, a.timestamp,
]);
const csv = [headers.join(','), ...rows.map(r => r.join(','))].join('\n');
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `alerts_${new Date().toISOString().split('T')[0]}.csv`;
a.click();
URL.revokeObjectURL(url);
}, [filteredAlerts]);
const exportToJson = useCallback(() => {
const json = JSON.stringify(filteredAlerts, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `alerts_${new Date().toISOString().split('T')[0]}.json`;
a.click();
URL.revokeObjectURL(url);
}, [filteredAlerts]);
return (
<div className={isFullscreen ? 'fixed inset-0 z-40 bg-bg-page pt-[52px] p-5' : 'p-5'}>
{error && (
<ErrorBanner
error={error}
onRetry={() => { clearError(); fetchRiskMap(); fetchAlerts(); }}
onDismiss={clearError}
/>
)}
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="font-display text-[18px] font-semibold mb-1"></h1>
<p className="text-[12px] text-text-muted">
100m网格风险预测 · · -
</p>
</div>
<div className="flex items-center gap-3 text-[11px]">
<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: {p1Count}</span>
<span className="px-2 py-1 bg-warning/10 border border-warning/20 rounded text-warning font-semibold">P2: {p2Count}</span>
</div>
</div>
{/* Toolbar Row 1: Forecast + Fullscreen + Export */}
<div className="card p-3 mb-3">
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-2">
<span className="text-[12px] text-text-muted"></span>
<div className="flex gap-0.5 bg-bg-page p-0.5 rounded">
{([1, 3, 7] as const).map((day) => (
<button
key={day}
onClick={() => setForecastDay(day)}
className={`px-3 py-1 text-[12px] font-medium rounded transition-colors ${
forecastDay === day
? 'bg-bg-card text-primary shadow-sm'
: 'text-text-secondary hover:text-text-primary'
}`}
>
{day}
</button>
))}
</div>
</div>
<div className="w-px h-6 bg-border" />
<button
onClick={() => setIsFullscreen(!isFullscreen)}
className={`px-3 py-1.5 text-[12px] font-medium rounded transition-colors ${
isFullscreen
? 'bg-bg-card text-primary border border-primary'
: 'bg-bg-page text-text-secondary border border-border'
}`}
>
{isFullscreen ? '退出全屏' : '全屏'}
</button>
<div className="w-px h-6 bg-border" />
<button
onClick={exportToCsv}
className="px-3 py-1.5 text-[12px] font-medium rounded bg-bg-page text-text-secondary border border-border hover:border-primary transition-colors"
>
CSV
</button>
<button
onClick={exportToJson}
className="px-3 py-1.5 text-[12px] font-medium rounded bg-bg-page text-text-secondary border border-border hover:border-primary transition-colors"
>
JSON
</button>
</div>
</div>
{/* Toolbar Row 2: Filters */}
<div className="card p-3 mb-4">
<div className="flex items-center gap-4 flex-wrap">
<div className="flex items-center gap-2">
<span className="text-[12px] text-text-muted"></span>
<div className="flex gap-1">
{(['all', 1, 3, 7] as const).map((horizon) => (
<button
key={horizon}
onClick={() => setSelectedHorizon(horizon)}
className={`px-3 py-1.5 text-[12px] font-medium rounded transition-colors ${
selectedHorizon === horizon
? 'bg-primary text-white'
: 'bg-bg-page text-text-secondary border border-border hover:border-primary'
}`}
>
{horizon === 'all' ? '全部' : HORIZON_LABELS[horizon]}
</button>
))}
</div>
</div>
<div className="w-px h-6 bg-border" />
<div className="flex items-center gap-2">
<span className="text-[12px] text-text-muted"></span>
<div className="flex gap-1">
{(['all', 'P1', 'P2'] as const).map((priority) => (
<button
key={priority}
onClick={() => setSelectedPriority(priority)}
className={`px-3 py-1.5 text-[12px] font-medium rounded transition-colors ${
selectedPriority === priority
? priority === 'P1'
? 'bg-danger text-white'
: priority === 'P2'
? 'bg-warning text-white'
: 'bg-primary text-white'
: 'bg-bg-page text-text-secondary border border-border hover:border-primary'
}`}
>
{priority === 'all' ? '全部' : priority}
</button>
))}
</div>
</div>
<div className="w-px h-6 bg-border" />
<div className="flex items-center gap-2">
<span className="text-[12px] text-text-muted"></span>
<div className="flex items-center gap-2">
<input
type="number"
min={0}
max={1}
step={0.05}
value={riskRange[0]}
onChange={(e) => setRiskRange([parseFloat(e.target.value) || 0, riskRange[1]])}
className="w-16 px-2 py-1.5 text-[12px] border border-border rounded bg-bg-page text-text-primary focus:outline-none focus:border-primary"
/>
<span className="text-[12px] text-text-muted">-</span>
<input
type="number"
min={0}
max={1}
step={0.05}
value={riskRange[1]}
onChange={(e) => setRiskRange([riskRange[0], parseFloat(e.target.value) || 1])}
className="w-16 px-2 py-1.5 text-[12px] border border-border rounded bg-bg-page text-text-primary focus:outline-none focus:border-primary"
/>
</div>
</div>
<div className="w-px h-6 bg-border" />
<div className="flex items-center gap-1">
<button
onClick={() => setShowMap(!showMap)}
className={`px-2.5 py-1.5 text-[12px] font-medium rounded transition-colors ${
showMap
? 'bg-primary/10 text-primary border border-primary/30'
: 'bg-bg-page text-text-muted border border-border'
}`}
>
</button>
<button
onClick={() => setShowAlertMarkers(!showAlertMarkers)}
className={`px-2.5 py-1.5 text-[12px] font-medium rounded transition-colors ${
showAlertMarkers
? 'bg-primary/10 text-primary border border-primary/30'
: 'bg-bg-page text-text-muted border border-border'
}`}
>
</button>
<button
onClick={() => setShowGrid(!showGrid)}
className={`px-2.5 py-1.5 text-[12px] font-medium rounded transition-colors ${
showGrid
? 'bg-primary/10 text-primary border border-primary/30'
: 'bg-bg-page text-text-muted border border-border'
}`}
>
</button>
</div>
<div className="w-px h-6 bg-border" />
<div className="flex items-center gap-2">
<span className="text-[12px] text-text-muted"></span>
<div className="flex gap-1">
<button
onClick={() => setSortBy('risk')}
className={`px-3 py-1.5 text-[12px] font-medium rounded transition-colors ${
sortBy === 'risk'
? 'bg-bg-card text-primary border border-primary'
: 'bg-bg-page text-text-secondary border border-border hover:border-primary'
}`}
>
</button>
<button
onClick={() => setSortBy('time')}
className={`px-3 py-1.5 text-[12px] font-medium rounded transition-colors ${
sortBy === 'time'
? 'bg-bg-card text-primary border border-primary'
: 'bg-bg-page text-text-secondary border border-border hover:border-primary'
}`}
>
</button>
</div>
</div>
</div>
</div>
{/* Risk distribution summary */}
<div className="grid grid-cols-4 gap-3 mb-4">
<div className="card p-3">
<div className="text-[11px] text-text-muted mb-1"> (0.8)</div>
<div className="text-xl font-bold text-danger">{riskStats.high}</div>
<div className="mt-1.5 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full bg-danger rounded-full" style={{ width: `${filteredAlerts.length > 0 ? (riskStats.high / filteredAlerts.length) * 100 : 0}%` }} />
</div>
</div>
<div className="card p-3">
<div className="text-[11px] text-text-muted mb-1"> (0.6-0.8)</div>
<div className="text-xl font-bold text-warning">{riskStats.mediumHigh}</div>
<div className="mt-1.5 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full bg-warning rounded-full" style={{ width: `${filteredAlerts.length > 0 ? (riskStats.mediumHigh / filteredAlerts.length) * 100 : 0}%` }} />
</div>
</div>
<div className="card p-3">
<div className="text-[11px] text-text-muted mb-1"> (0.4-0.6)</div>
<div className="text-xl font-bold text-primary">{riskStats.medium}</div>
<div className="mt-1.5 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full bg-primary rounded-full" style={{ width: `${filteredAlerts.length > 0 ? (riskStats.medium / filteredAlerts.length) * 100 : 0}%` }} />
</div>
</div>
<div className="card p-3">
<div className="text-[11px] text-text-muted mb-1"></div>
<div className="text-xl font-bold text-text-primary">{(riskStats.avgRisk * 100).toFixed(1)}%</div>
<div className="mt-1.5 text-[10px] text-text-muted">
: {riskStats.topDistricts.slice(0, 2).map(([d, n]) => `${d}(${n})`).join(', ')}
</div>
</div>
</div>
{isLoading ? (
<div className="card p-8 text-center">
<div className="text-text-secondary text-[13px]">...</div>
</div>
) : filteredAlerts.length === 0 ? (
<div className="card p-8 text-center">
<svg className="w-12 h-12 mx-auto mb-3 text-text-muted opacity-50" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/>
</svg>
<div className="text-text-muted text-[13px]"></div>
</div>
) : (
<div className={`grid gap-4 ${isFullscreen ? 'grid-cols-1' : 'grid-cols-[1fr_400px]'}`}>
{showMap && (
<AlertMap
selectedGridId={selectedGridId}
onGridClick={handleGridClick}
onCellInfo={handleCellInfo}
forecastDay={forecastDay}
showAlertMarkers={showAlertMarkers}
showGrid={showGrid}
filteredAlerts={filteredAlerts}
riskRange={riskRange}
isFullscreen={isFullscreen}
/>
)}
{!isFullscreen && (
<div className="space-y-3 max-h-[calc(100vh-280px)] overflow-y-auto">
{filteredAlerts.slice(0, 50).map((alert) => (
<AlertCard
key={alert.alert_id}
alert={alert}
isSelected={selectedAlert === alert.alert_id}
onClick={() => handleAlertCardClick(alert.alert_id)}
/>
))}
{filteredAlerts.length > 50 && (
<div className="text-center text-text-muted text-[12px] py-2">
{filteredAlerts.length - 50}
</div>
)}
</div>
)}
</div>
)}
{/* Cell info panel - shown when clicking grid cell without alert */}
{cellInfo && !selectedAlertData && (() => {
// Find nearest LOD grid cell for multi-day risk display
// grids are [lat, lon, risk_1d, risk_3d, risk_7d]
let nearest: { lat: number; lon: number; risk_1d: number; risk_3d: number; risk_7d: number } | null = null;
let minDist = Infinity;
for (const g of lodGrids) {
const d = Math.sqrt((g[0] - cellInfo.lat) ** 2 + (g[1] - cellInfo.lon) ** 2);
if (d < minDist) {
minDist = d;
nearest = { lat: g[0], lon: g[1], risk_1d: g[2] ?? 0, risk_3d: g[3] ?? 0, risk_7d: g[4] ?? 0 };
}
}
return (
<div className="fixed bottom-5 left-1/2 -translate-x-1/2 bg-bg-card rounded-lg border border-border-light shadow-lg z-50 px-5 py-4 min-w-[320px]">
<div className="flex items-center justify-between mb-3">
<span className="text-[14px] font-semibold text-text-primary"></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.lat.toFixed(4)}, {cellInfo.lon.toFixed(4)}</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span className={`font-bold ${cellInfo.risk >= 0.8 ? 'text-danger' : cellInfo.risk >= 0.6 ? 'text-warning' : cellInfo.risk >= 0.4 ? 'text-primary' : 'text-success'}`}>
{(cellInfo.risk * 100).toFixed(1)}%
</span>
</div>
{nearest && (
<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]">{(nearest.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]">{(nearest.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]">{(nearest.risk_7d * 100).toFixed(0)}%</div>
</div>
</div>
)}
{cellInfo.nearestAlertId && (
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span className="text-text-primary">{(cellInfo.nearestAlertDist * 111).toFixed(1)} km</span>
</div>
)}
{!cellInfo.nearestAlertId && (
<div className="text-[11px] text-text-muted mt-1 pt-2 border-t border-border">
</div>
)}
</div>
</div>
);
})()}
{/* Alert detail modal */}
{selectedAlertData && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center" onClick={clearSelectedAlert}>
<div className="bg-bg-card rounded-lg p-6 max-w-md w-full mx-4" onClick={e => e.stopPropagation()}>
<h3 className="font-display text-[16px] font-semibold mb-3"></h3>
<div className="space-y-2 text-[13px]">
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span className={`font-bold ${selectedAlertData.priority === 'P1' ? 'text-danger' : 'text-warning'}`}>
{selectedAlertData.priority}
</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span className="font-bold">{Math.round(selectedAlertData.risk_value * 100)}%</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span>{HORIZON_LABELS[selectedAlertData.forecast_horizon]}</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span>{selectedAlertData.region}</span>
</div>
<div className="pt-2 border-t border-border">
<div className="text-text-muted mb-1"></div>
<div className="text-[12px]">{selectedAlertData.reason}</div>
</div>
</div>
<button
onClick={clearSelectedAlert}
className="mt-4 w-full px-4 py-2 bg-primary text-white rounded hover:bg-primary/80 transition-colors text-[13px]"
>
</button>
</div>
</div>
)}
</div>
);
}
interface AlertCardProps {
alert: ExtendedAlert;
isSelected?: boolean;
onClick?: () => void;
}
function AlertCard({ alert, isSelected, onClick }: AlertCardProps) {
const isP1 = alert.priority === 'P1';
const riskPercent = Math.round(alert.risk_value * 100);
return (
<div
className={`card overflow-hidden transition-colors cursor-pointer ${
isSelected ? 'border-primary ring-1 ring-primary' : 'hover:border-primary'
}`}
onClick={onClick}
>
<div className={`px-4 py-3 border-b ${isP1 ? 'bg-danger/5 border-danger/20' : 'bg-warning/5 border-warning/20'}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${isP1 ? 'bg-danger' : 'bg-warning'}`} />
<span className={`text-[11px] font-bold ${isP1 ? 'text-danger' : 'text-warning'}`}>
{alert.priority}
</span>
<span className="text-[10px] text-text-muted">
{HORIZON_LABELS[alert.forecast_horizon] || '未知'}
</span>
</div>
<span className={`text-[18px] font-bold ${isP1 ? 'text-danger' : 'text-warning'}`}>
{riskPercent}%
</span>
</div>
</div>
<div className="p-4">
<div className="mb-3">
<div className="text-[13px] font-semibold mb-1">
{alert.region} - {alert.street}
</div>
<div className="text-[11px] text-text-muted">
{alert.grid_id}
</div>
</div>
<div className={`text-[12px] px-3 py-2 rounded mb-3 ${
isP1 ? 'bg-danger/10 text-danger' : 'bg-warning/10 text-warning'
}`}>
{alert.reason}
</div>
<div className="flex items-center justify-between text-[11px] text-text-muted">
<span>{alert.forecast_time}</span>
<span>{alert.timestamp}</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,228 @@
import { useEffect, useState } from 'react';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Cell,
} from 'recharts';
import { useAnalysisStore } from '@/stores/analysisStore';
import { ErrorBanner } from '@/components/ErrorBanner';
import { BarChart3, MapPin, Users, Shield } from 'lucide-react';
const COLORS = ['#DC2626', '#D97706', '#2563EB', '#059669', '#7C3AED', '#0891B2', '#EA580C', '#84CC16'];
const RISK_COLORS: Record<string, string> = {
high: '#DC2626',
medium: '#D97706',
low: '#059669',
};
export function DistrictComparison() {
const { districtData, isLoading, error, clearError, fetchDistricts } = useAnalysisStore();
const [metric, setMetric] = useState<'avg_aqi' | 'avg_risk' | 'high_risk_count'>('avg_aqi');
useEffect(() => {
fetchDistricts();
}, []);
const metricConfig = {
avg_aqi: { label: '平均AQI', color: '#2563EB', unit: '' },
avg_risk: { label: '平均风险', color: '#DC2626', unit: '' },
high_risk_count: { label: '高风险数', color: '#D97706', unit: '个' },
};
const sortedData = [...districtData].sort((a, b) => {
const aVal = a[metric] as number;
const bVal = b[metric] as number;
return bVal - aVal;
});
const getRiskLevel = (risk: number) => {
if (risk >= 0.7) return 'high';
if (risk >= 0.4) return 'medium';
return 'low';
};
return (
<div>
{error && (
<ErrorBanner
error={error}
onRetry={() => { clearError(); fetchDistricts(); }}
onDismiss={clearError}
/>
)}
<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" />
</h1>
<p className="text-[12px] text-text-muted">
</p>
</div>
<div className="flex items-center gap-2 mb-4">
<span className="text-[13px] text-text-secondary">:</span>
<div className="flex gap-1 bg-bg-page p-0.5 rounded">
{(Object.keys(metricConfig) as Array<keyof typeof metricConfig>).map((key) => (
<button
key={key}
onClick={() => setMetric(key)}
className={`px-3 py-1 text-[12px] font-medium rounded transition-colors ${
metric === key
? 'bg-bg-card text-primary shadow-sm'
: 'text-text-secondary hover:text-text-primary'
}`}
>
{metricConfig[key].label}
</button>
))}
</div>
</div>
{isLoading && (
<div className="mb-4 text-center py-8 bg-bg-card rounded-lg border border-border">
<span className="text-text-secondary">...</span>
</div>
)}
<div className="card p-4 mb-4">
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
{metricConfig[metric].label}
</div>
<ResponsiveContainer width="100%" height={380}>
<BarChart
data={sortedData}
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' }}
/>
<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(metric === 'avg_risk' ? 2 : 0)}${metricConfig[metric].unit}`,
metricConfig[metric].label,
]}
/>
<Bar
dataKey={metric}
name={metricConfig[metric].label}
radius={[0, 4, 4, 0]}
maxBarSize={32}
>
{sortedData.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={metric === 'avg_risk'
? RISK_COLORS[getRiskLevel(entry.avg_risk)]
: COLORS[index % COLORS.length]
}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
<div className="grid grid-cols-4 gap-4">
{sortedData.map((district, index) => (
<div key={district.district} className="card p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4 text-primary" />
<span className="text-[14px] font-semibold text-text-primary">
{district.district}
</span>
</div>
<span
className={`text-[11px] font-semibold px-2 py-0.5 rounded ${
district.avg_risk >= 0.7
? 'bg-danger-light text-danger'
: district.avg_risk >= 0.4
? 'bg-warning-light text-warning'
: 'bg-success-light text-success'
}`}
>
#{index + 1}
</span>
</div>
<div className="space-y-2.5">
<div className="flex items-center justify-between">
<span className="text-[12px] text-text-secondary">AQI</span>
<span className="text-[13px] font-semibold text-text-primary">
{district.avg_aqi}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-[12px] text-text-secondary"></span>
<span className="text-[13px] font-semibold text-text-primary">
{(district.avg_risk * 100).toFixed(0)}%
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-[12px] text-text-secondary"></span>
<span className="text-[13px] font-semibold text-danger">
{district.high_risk_count}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-[12px] text-text-secondary flex items-center gap-1">
<Users className="w-3 h-3" />
</span>
<span className="text-[13px] font-semibold text-text-primary">
{(district.population / 10000).toFixed(0)}
</span>
</div>
</div>
<div className="mt-3">
<div className="flex items-center justify-between mb-1">
<span className="text-[11px] text-text-muted"></span>
<span className="text-[11px] font-medium text-text-secondary">
<Shield className="w-3 h-3 inline mr-0.5" />
{(district.avg_risk * 100).toFixed(0)}%
</span>
</div>
<div className="h-[4px] bg-bg-page rounded overflow-hidden">
<div
className={`h-full rounded transition-all ${
district.avg_risk >= 0.7
? 'bg-danger'
: district.avg_risk >= 0.4
? 'bg-warning'
: 'bg-success'
}`}
style={{ width: `${district.avg_risk * 100}%` }}
/>
</div>
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,198 @@
import { useEffect } from 'react';
import { useAnalysisStore } from '@/stores/analysisStore';
import { ErrorBanner } from '@/components/ErrorBanner';
import {
Lightbulb,
AlertTriangle,
CheckCircle,
Info,
XCircle,
TrendingUp,
TrendingDown,
Clock,
} from 'lucide-react';
const TYPE_CONFIG = {
warning: {
icon: AlertTriangle,
bg: 'bg-warning-light',
border: 'border-warning',
iconColor: 'text-warning',
badge: 'bg-warning text-white',
},
danger: {
icon: XCircle,
bg: 'bg-danger-light',
border: 'border-danger',
iconColor: 'text-danger',
badge: 'bg-danger text-white',
},
success: {
icon: CheckCircle,
bg: 'bg-success-light',
border: 'border-success',
iconColor: 'text-success',
badge: 'bg-success text-white',
},
info: {
icon: Info,
bg: 'bg-primary-muted',
border: 'border-primary',
iconColor: 'text-primary',
badge: 'bg-primary text-white',
},
};
export function Insights() {
const { insights, isLoading, error, clearError, fetchInsights } = useAnalysisStore();
useEffect(() => {
fetchInsights();
}, []);
const stats = insights
? [
{
label: '总洞察数',
value: insights.total_insights,
icon: Lightbulb,
color: 'text-primary',
bg: 'bg-primary-muted',
},
{
label: '预警',
value: insights.warning_count + ((insights as any).danger_count || 0),
icon: AlertTriangle,
color: 'text-warning',
bg: 'bg-warning-light',
},
{
label: '正常',
value: insights.success_count,
icon: CheckCircle,
color: 'text-success',
bg: 'bg-success-light',
},
{
label: '信息',
value: insights.info_count,
icon: Info,
color: 'text-primary',
bg: 'bg-primary-muted',
},
]
: [];
return (
<div>
{error && (
<ErrorBanner
error={error}
onRetry={() => { clearError(); fetchInsights(); }}
onDismiss={clearError}
/>
)}
<div className="mb-5">
<h1 className="font-display text-[18px] font-semibold mb-1 flex items-center gap-2">
<Lightbulb className="w-5 h-5 text-primary" />
</h1>
<p className="text-[12px] text-text-muted">
</p>
</div>
{isLoading && (
<div className="mb-4 text-center py-8 bg-bg-card rounded-lg border border-border">
<span className="text-text-secondary">...</span>
</div>
)}
{insights && (
<div className="grid grid-cols-4 gap-4 mb-4">
{stats.map((stat) => (
<div key={stat.label} className="card p-4">
<div className="flex items-center gap-2 mb-2">
<div className={`w-8 h-8 rounded-lg ${stat.bg} flex items-center justify-center`}>
<stat.icon className={`w-4 h-4 ${stat.color}`} />
</div>
<span className="text-[11px] font-medium text-text-muted uppercase tracking-wide">
{stat.label}
</span>
</div>
<div className="font-display text-[26px] font-bold text-text-primary">
{stat.value}
</div>
</div>
))}
</div>
)}
{insights && (
<div className="grid grid-cols-2 gap-4">
{insights.cards.map((card) => {
const config = TYPE_CONFIG[card.type];
const Icon = config.icon;
return (
<div
key={card.id}
className={`card p-4 border-l-4 ${config.border} hover:shadow-md transition-shadow`}
>
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
<div className={`w-8 h-8 rounded-lg ${config.bg} flex items-center justify-center`}>
<Icon className={`w-4 h-4 ${config.iconColor}`} />
</div>
<div>
<h3 className="text-[14px] font-semibold text-text-primary">
{card.title}
</h3>
<span className="text-[11px] text-text-muted flex items-center gap-1">
<Clock className="w-3 h-3" />
{card.timestamp}
</span>
</div>
</div>
<span className={`text-[10px] font-semibold px-2 py-0.5 rounded ${config.badge}`}>
{card.type === 'warning' ? '预警' : card.type === 'danger' ? '紧急' : card.type === 'success' ? '正常' : '信息'}
</span>
</div>
<p className="text-[13px] text-text-secondary leading-relaxed mb-3">
{card.description}
</p>
{card.metric && card.metricValue && (
<div className="flex items-center gap-2 pt-3 border-t border-border">
<span className="text-[12px] text-text-muted">{card.metric}:</span>
<span className={`text-[14px] font-bold flex items-center gap-1 ${
card.type === 'warning' || card.type === 'danger'
? 'text-danger'
: card.type === 'success'
? 'text-success'
: 'text-primary'
}`}>
{card.metricValue.includes('+') ? (
<TrendingUp className="w-3.5 h-3.5" />
) : card.metricValue.includes('-') ? (
<TrendingDown className="w-3.5 h-3.5" />
) : null}
{card.metricValue}
</span>
</div>
)}
</div>
);
})}
</div>
)}
{!insights && !isLoading && (
<div className="card p-8 text-center">
<Lightbulb className="w-12 h-12 text-text-muted mx-auto mb-3" />
<p className="text-text-secondary"></p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,78 @@
import { useState, FormEvent } from 'react';
import api from '@/services/api';
interface LoginProps {
onLogin: (token: string) => void;
}
export function Login({ onLogin }: LoginProps) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const res = await api.post('/auth/login', { username, password });
const token = res.data.access_token;
localStorage.setItem('cbpoa_token', token);
onLogin(token);
} catch {
setError('用户名或密码错误');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-bg-page flex items-center justify-center">
<form
onSubmit={handleSubmit}
className="bg-white rounded-lg shadow-md p-8 w-full max-w-sm"
>
<h1 className="text-xl font-semibold text-text-primary mb-6 text-center">
CBPOA
</h1>
{error && (
<div className="mb-4 p-2 bg-red-50 text-danger text-sm rounded">
{error}
</div>
)}
<label className="block mb-4">
<span className="text-text-secondary text-sm"></span>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="mt-1 block w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
required
/>
</label>
<label className="block mb-6">
<span className="text-text-secondary text-sm"></span>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 block w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
required
/>
</label>
<button
type="submit"
disabled={loading}
className="w-full py-2 bg-primary text-white rounded text-sm font-medium hover:bg-primary/90 disabled:opacity-50"
>
{loading ? '登录中...' : '登录'}
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,300 @@
import { useEffect, useState, useMemo, useRef, useCallback } from 'react';
import { Calendar, Activity, TrendingUp, TrendingDown, Stethoscope, Building2 } from 'lucide-react';
import { useTimelineStore, useMonitoringStore } from '@/stores';
import { gridApi } from '@/services/api';
import { ErrorBanner } from '@/components/ErrorBanner';
import { TimelinePlayer } from '@/components/TimelinePlayer';
import { StatisticalCharts } from '@/components/StatisticalCharts';
import { CaseLocationMap } from '@/components/CaseLocationMap';
interface MonitoringDashboardProps {
defaultStartDate?: string;
defaultEndDate?: string;
}
const WUHAN_DISTRICTS = [
'江岸区', '江汉区', '硚口区', '汉阳区', '武昌区',
'青山区', '洪山区', '东西湖区', '汉南区', '蔡甸区',
'江夏区', '黄陂区', '新洲区',
];
export function MonitoringDashboard({
defaultStartDate = '2022-12-01',
defaultEndDate = '2024-12-30',
}: MonitoringDashboardProps) {
const [selectedDistrict, setSelectedDistrict] = useState<string | null>(null);
const [chartData, setChartData] = useState<Array<{ date: string; cases: number; aqi?: number }>>([]);
const {
currentDate,
isPlaying,
playbackSpeed,
setCurrentDate,
setPlaying,
setPlaybackSpeed,
setDateRange,
} = useTimelineStore();
const {
districtCases,
error,
clearError,
fetchDistrictCases,
isLoading,
} = useMonitoringStore();
useEffect(() => {
setDateRange(defaultStartDate, defaultEndDate);
setCurrentDate(defaultEndDate);
}, [defaultStartDate, defaultEndDate, setDateRange, setCurrentDate]);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const loadChartData = useCallback((district?: string) => {
const end = new Date(defaultEndDate);
const start = new Date(defaultEndDate);
start.setDate(start.getDate() - 90);
gridApi.getHistoricalAggregated(
start.toISOString().split('T')[0],
end.toISOString().split('T')[0],
'daily',
district,
).then((data) => {
const rows = data.aggregations || [];
const dailyCases: Record<string, number> = {};
rows.forEach((item: { date: string; total_cases: number }) => {
dailyCases[item.date] = (dailyCases[item.date] || 0) + item.total_cases;
});
setChartData(
Object.entries(dailyCases)
.map(([date, cases]) => ({ date, cases }))
.sort((a, b) => a.date.localeCompare(b.date))
);
}).catch(() => {});
fetchDistrictCases();
}, [defaultEndDate, fetchDistrictCases]);
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
loadChartData(selectedDistrict || undefined);
}, 300);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [selectedDistrict, loadChartData]);
const stats = useMemo(() => {
if (chartData.length === 0) return null;
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 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';
// 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]);
const handleDateChange = useCallback((date: string) => {
setCurrentDate(date);
}, [setCurrentDate]);
const handlePlayPause = useCallback((playing: boolean) => {
setPlaying(playing);
}, [setPlaying]);
return (
<div className="flex flex-col h-full">
{error && (
<div className="px-6 pt-4">
<ErrorBanner
error={error}
onRetry={() => {
clearError();
loadChartData(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>
</>
)}
</div>
{/* District filter */}
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">:</span>
<select
value={selectedDistrict || ''}
onChange={(e) => setSelectedDistrict(e.target.value || null)}
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value=""></option>
{WUHAN_DISTRICTS.map((d) => (
<option key={d} value={d}>{d}</option>
))}
</select>
</div>
</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" />
</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">
{(() => {
const maxTotal = Math.max(...districtCases.map(d => d.total), 1);
return districtCases
.sort((a, b) => b.total - a.total)
.map((d) => {
const outPct = d.total > 0 ? (d.outpatient / d.total) * 100 : 0;
const inPct = d.total > 0 ? (d.inpatient / d.total) * 100 : 0;
const barWidth = (d.total / maxTotal) * 100;
return (
<div
key={d.district}
className={`flex items-center gap-3 p-2 rounded cursor-pointer transition-colors ${
selectedDistrict === d.district ? 'bg-blue-50' : 'hover:bg-gray-50'
}`}
onClick={() => setSelectedDistrict(
selectedDistrict === d.district ? null : d.district
)}
>
<div className="w-16 text-sm text-gray-700 text-right shrink-0">{d.district}</div>
<div className="flex-1 h-6 bg-gray-100 rounded overflow-hidden flex">
<div
className="bg-orange-400 h-full transition-all"
style={{ width: `${barWidth * outPct / 100}%` }}
title={`门诊: ${d.outpatient.toLocaleString()}`}
/>
<div
className="bg-red-400 h-full transition-all"
style={{ width: `${barWidth * inPct / 100}%` }}
title={`住院: ${d.inpatient.toLocaleString()}`}
/>
</div>
<div className="w-20 text-right text-sm font-medium text-gray-900 shrink-0">
{d.total.toLocaleString()}
</div>
</div>
);
});
})()}
</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" />
</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>
{/* Timeline Player */}
<TimelinePlayer
startDate={defaultStartDate}
endDate={defaultEndDate}
currentDate={currentDate}
onDateChange={handleDateChange}
isPlaying={isPlaying}
speed={playbackSpeed}
onSpeedChange={setPlaybackSpeed}
onPlayPause={handlePlayPause}
/>
</div>
);
}

View File

@@ -0,0 +1,262 @@
import { useEffect, useState } from 'react';
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
AreaChart,
Area,
} from 'recharts';
import { useAnalysisStore } from '@/stores/analysisStore';
import { ErrorBanner } from '@/components/ErrorBanner';
import { TrendingUp, Calendar, Activity } from 'lucide-react';
const POLLUTANT_OPTIONS = [
{ key: 'aqi', label: 'AQI', color: '#2563EB', unit: '' },
{ 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: 'co', label: 'CO', color: '#0891B2', unit: 'mg/m³' },
{ key: 'o3', label: 'O₃', color: '#EA580C', unit: 'μg/m³' },
];
const DAY_OPTIONS = [
{ label: '7天', value: 7 },
{ label: '14天', value: 14 },
{ label: '30天', value: 30 },
];
export function TrendAnalysis() {
const { trendData, isLoading, error, clearError, selectedDays, setSelectedDays, fetchTrend } = useAnalysisStore();
const [selectedPollutants, setSelectedPollutants] = useState<string[]>(['aqi', 'pm25']);
useEffect(() => {
fetchTrend(selectedDays);
}, [selectedDays, fetchTrend]);
const togglePollutant = (key: string) => {
setSelectedPollutants((prev) =>
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]
);
};
const formatDate = (dateStr: string) => {
const d = new Date(dateStr);
return `${d.getMonth() + 1}/${d.getDate()}`;
};
const latestData = trendData[trendData.length - 1];
const firstData = trendData[0];
const getChange = (key: string) => {
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;
return ((latest - first) / first) * 100;
};
return (
<div>
{error && (
<ErrorBanner
error={error}
onRetry={() => { clearError(); fetchTrend(selectedDays); }}
onDismiss={clearError}
/>
)}
<div className="mb-5">
<h1 className="font-display text-[18px] font-semibold mb-1 flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-primary" />
</h1>
<p className="text-[12px] text-text-muted">
</p>
</div>
<div className="flex flex-wrap items-center gap-4 mb-4">
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-text-muted" />
<span className="text-[13px] text-text-secondary">:</span>
<div className="flex gap-1 bg-bg-page p-0.5 rounded">
{DAY_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => setSelectedDays(opt.value)}
className={`px-3 py-1 text-[12px] font-medium rounded transition-colors ${
selectedDays === opt.value
? 'bg-bg-card text-primary shadow-sm'
: 'text-text-secondary hover:text-text-primary'
}`}
>
{opt.label}
</button>
))}
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 mb-4">
<Activity className="w-4 h-4 text-text-muted" />
<span className="text-[13px] text-text-secondary">:</span>
{POLLUTANT_OPTIONS.map((p) => (
<button
key={p.key}
onClick={() => togglePollutant(p.key)}
className={`flex items-center gap-1.5 px-2.5 py-1 rounded text-[12px] 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.5 h-2.5 rounded-full"
style={{ backgroundColor: p.color }}
/>
{p.label}
</button>
))}
</div>
{isLoading && (
<div className="mb-4 text-center py-8 bg-bg-card rounded-lg border border-border">
<span className="text-text-secondary">...</span>
</div>
)}
<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 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
<XAxis
dataKey="date"
tickFormatter={formatDate}
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',
}}
labelStyle={{ color: '#1E293B', fontWeight: 600 }}
/>
<Legend
wrapperStyle={{ fontSize: '12px', paddingTop: '12px' }}
/>
{POLLUTANT_OPTIONS.filter((p) => selectedPollutants.includes(p.key)).map(
(p) => (
<Line
key={p.key}
type="monotone"
dataKey={p.key}
name={p.label}
stroke={p.color}
strokeWidth={2}
dot={{ r: 3, fill: p.color }}
activeDot={{ r: 5 }}
/>
)
)}
</LineChart>
</ResponsiveContainer>
</div>
{selectedPollutants.includes('aqi') && (
<div className="card p-4 mb-4">
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
AQI
</div>
<ResponsiveContainer width="100%" height={240}>
<AreaChart data={trendData} margin={{ top: 5, right: 20, left: 10, bottom: 5 }}>
<defs>
<linearGradient id="aqiGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#2563EB" stopOpacity={0.3} />
<stop offset="95%" stopColor="#2563EB" stopOpacity={0.05} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
<XAxis
dataKey="date"
tickFormatter={formatDate}
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',
}}
/>
<Area
type="monotone"
dataKey="aqi"
name="AQI"
stroke="#2563EB"
strokeWidth={2}
fill="url(#aqiGradient)"
dot={{ r: 3, fill: '#2563EB' }}
/>
</AreaChart>
</ResponsiveContainer>
</div>
)}
{latestData && (
<div className="grid grid-cols-4 gap-4">
{POLLUTANT_OPTIONS.filter((p) => selectedPollutants.includes(p.key)).slice(0, 4).map((p) => {
const value = latestData[p.key as keyof typeof latestData] as number;
const change = getChange(p.key);
return (
<div key={p.key} className="card p-4">
<div className="flex items-center gap-2 mb-2">
<span
className="w-2.5 h-2.5 rounded-full"
style={{ backgroundColor: p.color }}
/>
<span className="text-[11px] font-medium text-text-muted uppercase tracking-wide">
{p.label}
</span>
</div>
<div className="font-display text-[24px] font-bold text-text-primary mb-1">
{typeof value === 'number' ? value.toFixed(p.key === 'co' ? 1 : 0) : value}
<span className="text-[12px] font-normal text-text-muted ml-1">
{p.unit}
</span>
</div>
<div
className={`text-[11px] font-medium ${
change > 0 ? 'text-danger' : change < 0 ? 'text-success' : 'text-text-muted'
}`}
>
{change > 0 ? '↑' : change < 0 ? '↓' : '→'} {Math.abs(change).toFixed(1)}%
</div>
</div>
);
})}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,203 @@
import axios from 'axios';
import type {
RiskMapResponse,
GridDetailResponse,
AlertResponse,
Stats,
ForecastDay,
CaseTrendResponse,
DistrictCaseResponse,
CaseStatsResponse,
CaseGridResponse,
GeocodedCasesResponse,
} from '@/types';
interface CacheEntry<T> {
data: T;
timestamp: number;
promise?: Promise<T>;
}
const CACHE_TTL = 30000;
const cache = new Map<string, CacheEntry<any>>();
const pendingControllers = new Map<string, AbortController>();
function getCacheKey(url: string, params?: Record<string, any>): string {
if (!params) return url;
const sorted = Object.entries(params)
.filter(([, v]) => v !== undefined)
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]) => `${k}=${v}`)
.join('&');
return sorted ? `${url}?${sorted}` : url;
}
function getCached<T>(key: string): T | undefined {
const entry = cache.get(key);
if (!entry) return undefined;
if (Date.now() - entry.timestamp > CACHE_TTL) {
cache.delete(key);
return undefined;
}
return entry.data;
}
function setCache<T>(key: string, data: T): void {
cache.set(key, { data, timestamp: Date.now() });
}
function clearPending(key: string): void {
const controller = pendingControllers.get(key);
if (controller) {
controller.abort();
pendingControllers.delete(key);
}
}
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || '/api',
timeout: 30000,
});
api.interceptors.request.use((config) => {
const token = localStorage.getItem('cbpoa_token');
if (token) {
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);
return config;
});
api.interceptors.response.use(
(response) => {
const key = getCacheKey(response.config.url || '', response.config.params);
pendingControllers.delete(key);
return response;
},
(error) => {
if (error.config) {
const key = getCacheKey(error.config.url || '', error.config.params);
pendingControllers.delete(key);
}
return Promise.reject(error);
}
);
async function cachedGet<T>(url: string, params?: Record<string, any>): Promise<T> {
const key = getCacheKey(url, params);
const cached = getCached<T>(key);
if (cached !== undefined) return cached;
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;
});
cache.set(key, { data: undefined as T, timestamp: Date.now(), promise });
return promise;
}
export const riskApi = {
getCurrentRiskMap: (): Promise<RiskMapResponse> => cachedGet('/risk/current'),
getForecast: (days: ForecastDay): Promise<RiskMapResponse> => {
const d = days === 0 ? '' : `/${days}`;
return cachedGet(`/risk/forecast${d}`);
},
getGridDetail: (gridId: string): Promise<GridDetailResponse> =>
cachedGet(`/risk/grid/${encodeURIComponent(gridId)}`),
getStats: (): Promise<Stats> => cachedGet('/risk/stats'),
};
export const alertApi = {
getAlerts: (params?: {
min_risk?: number;
priority?: string;
region?: string;
}): Promise<AlertResponse> => cachedGet('/alerts', params),
getAlertRules: (): Promise<any> => cachedGet('/alerts/rules'),
};
export const historyApi = {
getHistory: (params: {
grid_id?: string;
region?: string;
days?: number;
}): Promise<any> => cachedGet('/history', params),
getTrend: (gridId: string, days: number = 7): Promise<any> =>
cachedGet('/history/trend', { grid_id: gridId, days }),
};
export const caseApi = {
getTrend: (days: number = 7): Promise<CaseTrendResponse> =>
cachedGet('/cases/trend', { days }),
getDistricts: (): Promise<DistrictCaseResponse> => cachedGet('/cases/districts'),
getStats: (): Promise<CaseStatsResponse> => cachedGet('/cases/stats'),
getGrid: (): Promise<CaseGridResponse> => cachedGet('/cases/grid'),
getGeocoded: (limit: number = 5000): Promise<GeocodedCasesResponse> =>
cachedGet('/cases/geocoded', { limit }),
};
export function clearApiCache(): void {
cache.clear();
}
export function cancelPendingRequests(): void {
pendingControllers.forEach((controller) => controller.abort());
pendingControllers.clear();
}
export const gridApi = {
getHistoricalAggregated: (
startDate: string,
endDate: string,
aggregation: 'daily' | 'weekly' | 'monthly' = 'daily',
district?: string
): Promise<any> => {
const params: Record<string, string> = { start_date: startDate, end_date: endDate, aggregation };
if (district) params.district = district;
return cachedGet('/history/aggregated', params);
},
getGridsGeoJSON: (date: string, district?: string): Promise<any> => {
const params: Record<string, string> = { date };
if (district) params.district = district;
return cachedGet('/grids/geojson', params);
},
getMultiDayPrediction: async (date: string, days: number = 7, district?: string): Promise<any> => {
const response = await api.post('/predict/multi-day', { date, days, district });
return response.data;
},
getGridHistory: (gridId: string, days: number = 30): Promise<any> =>
cachedGet(`/grids/${encodeURIComponent(gridId)}/history`, { days }),
};
export const analysisApi = {
getTrend: (days: number = 7): Promise<any> => cachedGet('/analysis/trend', { days }),
getDistricts: (): Promise<any> => cachedGet('/analysis/districts'),
};
export const insightsApi = {
getOverview: (): Promise<any> => cachedGet('/insights/overview'),
};
export default api;

View File

@@ -0,0 +1,117 @@
import { create } from 'zustand';
import axios from 'axios';
import { analysisApi, insightsApi } from '@/services/api';
function isCancelError(e: unknown): boolean {
return axios.isCancel(e) || (e as Error)?.message === 'canceled';
}
interface TrendDataPoint {
date: string;
aqi: number;
pm25: number;
pm10: number;
so2: number;
no2: number;
co: number;
o3: number;
}
interface DistrictData {
district: string;
avg_aqi: number;
avg_risk: number;
high_risk_count: number;
population: number;
}
interface InsightCard {
id: string;
title: string;
description: string;
type: 'warning' | 'info' | 'success' | 'danger';
metric?: string;
metricValue?: string;
timestamp: string;
}
interface InsightsOverview {
total_insights: number;
warning_count: number;
info_count: number;
success_count: number;
cards: InsightCard[];
}
interface AnalysisState {
trendData: TrendDataPoint[];
districtData: DistrictData[];
insights: InsightsOverview | null;
isLoading: boolean;
error: string | null;
selectedDays: number;
setSelectedDays: (days: number) => void;
fetchTrend: (days?: number) => Promise<void>;
fetchDistricts: () => Promise<void>;
fetchInsights: () => Promise<void>;
clearError: () => void;
}
export const useAnalysisStore = create<AnalysisState>((set, get) => ({
trendData: [],
districtData: [],
insights: null,
isLoading: false,
error: null,
selectedDays: 7,
setSelectedDays: (days) => {
set({ selectedDays: days });
get().fetchTrend(days);
},
clearError: () => set({ error: null }),
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),
}));
set({ trendData, isLoading: false });
} catch (e) {
if (isCancelError(e)) return;
set({ error: (e as Error).message || '加载趋势数据失败', isLoading: false });
}
},
fetchDistricts: async () => {
set({ isLoading: true, error: null });
try {
const data = await analysisApi.getDistricts();
set({ districtData: data.districts || [], isLoading: false });
} catch (e) {
if (isCancelError(e)) return;
set({ error: (e as Error).message || '加载区域数据失败', isLoading: false });
}
},
fetchInsights: async () => {
set({ isLoading: true, error: null });
try {
const data = await insightsApi.getOverview();
set({ insights: data, isLoading: false });
} catch (e) {
if (isCancelError(e)) return;
set({ error: (e as Error).message || '加载洞察数据失败', isLoading: false });
}
},
}));

View File

@@ -0,0 +1,276 @@
import { create } from 'zustand';
import axios from 'axios';
import type { GridRisk, GridDetail, Alert, Stats, ForecastDay } from '@/types';
import { riskApi, alertApi, gridApi } from '@/services/api';
function isCancelError(e: unknown): boolean {
return axios.isCancel(e) || (e as Error)?.message === 'canceled';
}
interface RiskState {
grids: GridRisk[];
selectedGrid: GridDetail | null;
selectedGridId: string | null;
alerts: Alert[];
stats: Stats | null;
forecastDay: ForecastDay;
isLoading: boolean;
error: string | null;
showFullscreen: boolean;
setForecastDay: (day: ForecastDay) => void;
setSelectedGridId: (id: string | null) => void;
setShowFullscreen: (show: boolean) => void;
fetchRiskMap: () => Promise<void>;
fetchGridDetail: (gridId: string) => Promise<void>;
fetchAlerts: () => Promise<void>;
fetchStats: () => Promise<void>;
clearError: () => void;
}
export const useRiskStore = create<RiskState>((set, get) => ({
grids: [],
selectedGrid: null,
selectedGridId: null,
alerts: [],
stats: null,
forecastDay: 0,
isLoading: false,
error: null,
showFullscreen: false,
setForecastDay: (day) => {
set({ forecastDay: day });
get().fetchRiskMap();
},
setSelectedGridId: (id) => {
set({ selectedGridId: id });
if (id) get().fetchGridDetail(id);
else set({ selectedGrid: null });
},
setShowFullscreen: (show) => set({ showFullscreen: show }),
clearError: () => set({ error: null }),
fetchRiskMap: async () => {
set({ isLoading: true, error: null });
try {
const { forecastDay } = get();
const data = forecastDay === 0
? await riskApi.getCurrentRiskMap()
: await riskApi.getForecast(forecastDay);
set({ grids: data.grids || [], isLoading: false });
} catch (e) {
if (isCancelError(e)) return;
set({ error: (e as Error).message || '加载风险地图失败', isLoading: false });
}
},
fetchGridDetail: async (gridId) => {
set({ isLoading: true, error: null });
try {
const data = await riskApi.getGridDetail(gridId);
set({ selectedGrid: data.grid, isLoading: false });
} catch (e) {
if (isCancelError(e)) return;
set({ error: (e as Error).message || '加载网格详情失败', isLoading: false });
}
},
fetchAlerts: async () => {
try {
const data = await alertApi.getAlerts({ min_risk: 0.6 });
set({ alerts: data.alerts || [] });
} catch (e) {
if (isCancelError(e)) return;
set({ error: (e as Error).message || '加载预警数据失败' });
}
},
fetchStats: async () => {
try {
const data = await riskApi.getStats();
set({ stats: data });
} catch (e) {
if (isCancelError(e)) return;
set({ error: (e as Error).message || '加载统计数据失败' });
}
},
}));
export { useAnalysisStore } from './analysisStore';
interface TimelineState {
currentDate: string;
startDate: string;
endDate: string;
isPlaying: boolean;
playbackSpeed: number;
setCurrentDate: (date: string) => void;
setDateRange: (start: string, end: string) => void;
setPlaying: (playing: boolean) => void;
setPlaybackSpeed: (speed: number) => void;
goToNextDay: () => void;
goToPrevDay: () => void;
}
export const useTimelineStore = create<TimelineState>((set, get) => ({
currentDate: new Date().toISOString().split('T')[0],
startDate: '2022-12-01',
endDate: '2024-12-30',
isPlaying: false,
playbackSpeed: 1,
setCurrentDate: (date) => set({ currentDate: date }),
setDateRange: (start, end) => set({ startDate: start, endDate: end }),
setPlaying: (playing) => set({ isPlaying: playing }),
setPlaybackSpeed: (speed) => set({ playbackSpeed: speed }),
goToNextDay: () => {
const { currentDate, endDate } = get();
const next = new Date(currentDate);
next.setDate(next.getDate() + 1);
if (next.toISOString().split('T')[0] <= endDate) {
set({ currentDate: next.toISOString().split('T')[0] });
}
},
goToPrevDay: () => {
const { currentDate, startDate } = get();
const prev = new Date(currentDate);
prev.setDate(prev.getDate() - 1);
if (prev.toISOString().split('T')[0] >= startDate) {
set({ currentDate: prev.toISOString().split('T')[0] });
}
},
}));
interface GridFeature {
grid_id: string;
latitude: number;
longitude: number;
district: string;
AQI: number;
PM25: number;
PM10: number;
total_cases: number;
}
interface MonitoringState {
gridFeatures: GridFeature[];
aggregatedData: Array<{ date: string; district: string; total_cases: number; avg_AQI: number }>;
districtCases: Array<{ district: string; total: number; outpatient: number; inpatient: number }>;
selectedDistrict: string | null;
isLoading: boolean;
error: string | null;
fetchGridFeatures: (date: string) => Promise<void>;
fetchAggregatedData: (startDate: string, endDate: string, district?: string) => Promise<void>;
fetchDistrictCases: () => Promise<void>;
setSelectedDistrict: (district: string | null) => void;
clearError: () => void;
}
export const useMonitoringStore = create<MonitoringState>((set) => ({
gridFeatures: [],
aggregatedData: [],
districtCases: [],
selectedDistrict: null,
isLoading: false,
error: null,
clearError: () => set({ error: null }),
fetchGridFeatures: async (date) => {
set({ isLoading: true, error: null });
try {
const data = await gridApi.getGridsGeoJSON(date);
const features: GridFeature[] = data.features.map((f: any) => ({
grid_id: f.properties.grid_id,
latitude: f.properties.latitude,
longitude: f.properties.longitude,
district: f.properties.district,
AQI: f.properties.AQI || 0,
PM25: f.properties.PM25 || 0,
PM10: f.properties.PM10 || 0,
total_cases: f.properties.total_cases || 0,
}));
set({ gridFeatures: features, isLoading: false });
} catch (e) {
if (isCancelError(e)) return;
set({ error: (e as Error).message || '加载网格数据失败', isLoading: false });
}
},
fetchAggregatedData: async (startDate, endDate, district) => {
set({ isLoading: true, error: null });
try {
const data = await gridApi.getHistoricalAggregated(startDate, endDate, 'daily', district);
set({ aggregatedData: data.aggregations || [], isLoading: false });
} catch (e) {
if (isCancelError(e)) return;
set({ error: (e as Error).message || '加载聚合数据失败', isLoading: false });
}
},
fetchDistrictCases: async () => {
set({ isLoading: true, error: null });
try {
const { caseApi } = await import('@/services/api');
const data = await caseApi.getDistricts();
set({ districtCases: data.districts || [], isLoading: false });
} catch (e) {
if (isCancelError(e)) return;
set({ error: (e as Error).message || '加载区县病例数据失败', isLoading: false });
}
},
setSelectedDistrict: (district) => set({ selectedDistrict: district }),
}));
interface PredictionState {
predictions: GridPrediction[];
predictionDays: number;
isLoading: boolean;
error: string | null;
fetchPredictions: (date: string, days: number, district?: string) => Promise<void>;
clearError: () => void;
}
interface GridPrediction {
grid_id: string;
latitude: number;
longitude: number;
risk_1day: number;
risk_3day: number;
risk_7day: number;
risk_level: string;
}
export const usePredictionStore = create<PredictionState>((set) => ({
predictions: [],
predictionDays: 7,
isLoading: false,
error: null,
clearError: () => set({ error: null }),
fetchPredictions: async (date, days, district) => {
set({ isLoading: true, error: null });
try {
const data = await gridApi.getMultiDayPrediction(date, days, district);
set({ predictions: data.predictions || [], isLoading: false });
} catch (e) {
if (isCancelError(e)) return;
set({ error: (e as Error).message || '加载预测数据失败', isLoading: false });
}
},
}));

169
frontend/src/types/index.ts Normal file
View File

@@ -0,0 +1,169 @@
export interface GridRisk {
grid_id: string;
latitude: number;
longitude: number;
risk_value: number;
risk_level: RiskLevel;
}
export type RiskLevel = 'high' | 'medium_high' | 'medium' | 'medium_low' | 'low';
export interface GridDetail extends GridRisk {
region: string;
street: string;
population_density: number;
nearby_schools: number;
nearby_schools_distance: number;
nearby_hospitals: number;
nearby_hospitals_distance: number;
traffic_flow: string;
green_coverage: number;
building_density: number;
air_quality: string;
humidity: number;
wind_speed: number;
temperature: number;
trend: string;
forecast_1day: number;
forecast_3day: number;
forecast_7day: number;
timestamp: string;
}
export interface RiskMapResponse {
grids: GridRisk[];
total_count: number;
timestamp: string;
}
export interface GridDetailResponse {
grid: GridDetail;
history_risk: { date: string; risk_value: number }[];
}
export interface Alert {
alert_id: string;
grid_id: string;
region: string;
street: string;
latitude: number;
longitude: number;
risk_value: number;
risk_level: RiskLevel;
priority: 'P1' | 'P2';
reason: string;
timestamp: string;
forecast_time: string;
}
export interface AlertResponse {
alerts: Alert[];
total: number;
timestamp: string;
}
export interface Stats {
total_grids: number;
avg_risk: number;
distribution: {
high: number;
medium_high: number;
medium: number;
medium_low: number;
low: number;
};
high_risk_count: number;
timestamp: string;
}
export type ForecastDay = 0 | 1 | 3 | 7;
// --- Case Monitoring Types ---
export interface CaseTrendPoint {
date: string;
outpatient: number;
inpatient: number;
total: number;
}
export interface DistrictCaseData {
district: string;
outpatient: number;
inpatient: number;
total: number;
prev_period_total?: number;
change_pct?: number;
}
export interface CaseStats {
total_outpatient: number;
total_inpatient: number;
total_cases: number;
new_outpatient_7d: number;
new_inpatient_7d: number;
period_days: number;
timestamp: string;
}
export interface CaseInsight {
id: string;
type: 'warning' | 'info' | 'success' | 'danger';
title: string;
description: string;
metric?: string;
metricValue?: string;
district?: string;
}
export interface CaseTrendResponse {
data: CaseTrendPoint[];
days: number;
timestamp: string;
}
export interface DistrictCaseResponse {
districts: DistrictCaseData[];
timestamp: string;
}
export interface CaseStatsResponse {
stats: CaseStats;
timestamp: string;
}
// --- High-Resolution Geocoded Case Types ---
export interface CaseGrid {
grid_id: number;
latitude: number;
longitude: number;
total_cases: number;
outpatient_cases: number;
inpatient_cases: number;
case_density: number;
risk_index: number;
risk_level: string;
}
export interface GeocodedCase {
case_id: string;
case_type: string;
latitude: number;
longitude: number;
district: string;
street?: string;
geocode_method: string;
confidence: number;
}
export interface CaseGridResponse {
grids: CaseGrid[];
total_count: number;
total_cases: number;
}
export interface GeocodedCasesResponse {
cases: GeocodedCase[];
total_count: number;
}

View File

@@ -0,0 +1,42 @@
export const breakpoints = {
sm: 640,
md: 768,
lg: 1024,
xl: 1280,
xxl: 1536,
} as const;
export const responsiveClass = {
grid: {
base: 'grid grid-cols-1',
sm: 'sm:grid-cols-2',
md: 'md:grid-cols-3',
lg: 'lg:grid-cols-4',
xl: 'xl:grid-cols-6',
},
flex: {
base: 'flex flex-col',
sm: 'sm:flex-row',
md: 'md:flex-row',
lg: 'lg:flex-row',
},
};
export function useResponsive() {
const getColumns = (count: number) => {
return `grid-cols-1 sm:grid-cols-2 lg:grid-cols-${Math.min(count, 4)}`;
};
return { getColumns, breakpoints };
}
export function getScreenSize(): 'sm' | 'md' | 'lg' | 'xl' | 'xxl' {
if (typeof window === 'undefined') return 'lg';
const width = window.innerWidth;
if (width < breakpoints.sm) return 'sm';
if (width < breakpoints.md) return 'md';
if (width < breakpoints.lg) return 'lg';
if (width < breakpoints.xl) return 'xl';
return 'xxl';
}

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />