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:
119
frontend/src/App.tsx
Normal file
119
frontend/src/App.tsx
Normal 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;
|
||||
302
frontend/src/components/AlertMap.tsx
Normal file
302
frontend/src/components/AlertMap.tsx
Normal 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;
|
||||
116
frontend/src/components/CaseLocationMap.tsx
Normal file
116
frontend/src/components/CaseLocationMap.tsx
Normal 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: '© 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>
|
||||
);
|
||||
}
|
||||
376
frontend/src/components/CaseMap.tsx
Normal file
376
frontend/src/components/CaseMap.tsx
Normal 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">高风险 (>67%)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded" style={{ backgroundColor: RISK_COLORS.medium }} />
|
||||
<span className="text-[11px] text-text-secondary">中风险 (33-67%)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded" style={{ backgroundColor: RISK_COLORS.low }} />
|
||||
<span className="text-[11px] text-text-secondary">低风险 (<33%)</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-[11px] font-semibold text-text-secondary mb-2">病例类型</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: '#DC2626' }} />
|
||||
<span className="text-[11px] text-text-secondary">住院病例</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: '#2563EB' }} />
|
||||
<span className="text-[11px] text-text-secondary">门诊病例</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="absolute top-4 left-4 space-y-2 z-[1000]">
|
||||
<div className="bg-bg-card/90 backdrop-blur rounded-lg border border-border-light shadow-sm px-3 py-2">
|
||||
<div className="text-[11px] text-text-secondary">
|
||||
{isLoading ? (
|
||||
<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);
|
||||
51
frontend/src/components/DistributionChart.tsx
Normal file
51
frontend/src/components/DistributionChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
frontend/src/components/ErrorBanner.tsx
Normal file
35
frontend/src/components/ErrorBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
frontend/src/components/GridStatsOverlay.tsx
Normal file
30
frontend/src/components/GridStatsOverlay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
358
frontend/src/components/LodGridLayer.tsx
Normal file
358
frontend/src/components/LodGridLayer.tsx
Normal 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;
|
||||
}
|
||||
314
frontend/src/components/RiskMap.tsx
Normal file
314
frontend/src/components/RiskMap.tsx
Normal 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);
|
||||
112
frontend/src/components/SideNav.tsx
Normal file
112
frontend/src/components/SideNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
frontend/src/components/StatCard.tsx
Normal file
44
frontend/src/components/StatCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
225
frontend/src/components/StatisticalCharts.tsx
Normal file
225
frontend/src/components/StatisticalCharts.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
200
frontend/src/components/TimelinePlayer.tsx
Normal file
200
frontend/src/components/TimelinePlayer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
frontend/src/components/TopNav.tsx
Normal file
57
frontend/src/components/TopNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
100
frontend/src/hooks/useLodGrid.ts
Normal file
100
frontend/src/hooks/useLodGrid.ts
Normal 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
38
frontend/src/index.css
Normal 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
10
frontend/src/main.tsx
Normal 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>,
|
||||
)
|
||||
628
frontend/src/pages/AlertsDashboard.tsx
Normal file
628
frontend/src/pages/AlertsDashboard.tsx
Normal 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">×</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>
|
||||
);
|
||||
}
|
||||
228
frontend/src/pages/DistrictComparison.tsx
Normal file
228
frontend/src/pages/DistrictComparison.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
198
frontend/src/pages/Insights.tsx
Normal file
198
frontend/src/pages/Insights.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
frontend/src/pages/Login.tsx
Normal file
78
frontend/src/pages/Login.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
300
frontend/src/pages/MonitoringDashboard.tsx
Normal file
300
frontend/src/pages/MonitoringDashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
262
frontend/src/pages/TrendAnalysis.tsx
Normal file
262
frontend/src/pages/TrendAnalysis.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
203
frontend/src/services/api.ts
Normal file
203
frontend/src/services/api.ts
Normal 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;
|
||||
117
frontend/src/stores/analysisStore.ts
Normal file
117
frontend/src/stores/analysisStore.ts
Normal 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 });
|
||||
}
|
||||
},
|
||||
}));
|
||||
276
frontend/src/stores/index.ts
Normal file
276
frontend/src/stores/index.ts
Normal 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
169
frontend/src/types/index.ts
Normal 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;
|
||||
}
|
||||
42
frontend/src/utils/responsive.ts
Normal file
42
frontend/src/utils/responsive.ts
Normal 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
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user