feat: add analysis pages and raster risk map

Ship a new app version with broader analytics, restructured
dashboards, and a server-rendered risk map.

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

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

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

View File

@@ -10,6 +10,9 @@ const TrendAnalysis = lazy(() => import('@/pages/TrendAnalysis').then(m => ({ de
const DistrictComparison = lazy(() => import('@/pages/DistrictComparison').then(m => ({ default: m.DistrictComparison })));
const Insights = lazy(() => import('@/pages/Insights').then(m => ({ default: m.Insights })));
const ReportsCenter = lazy(() => import('@/pages/ReportsCenter').then(m => ({ default: m.ReportsCenter })));
const DemographicAnalysis = lazy(() => import('@/pages/DemographicAnalysis').then(m => ({ default: m.DemographicAnalysis })));
const DiseaseAnalysis = lazy(() => import('@/pages/DiseaseAnalysis').then(m => ({ default: m.DiseaseAnalysis })));
const EnvironmentalHealth = lazy(() => import('@/pages/EnvironmentalHealth').then(m => ({ default: m.EnvironmentalHealth })));
interface Props {
@@ -93,17 +96,17 @@ function App() {
return (
<ErrorBoundary>
<div className="min-h-screen bg-bg-page">
<div className="min-h-screen bg-bg-page flex flex-col">
<TopNav onLogout={handleLogout} />
<div className="flex pt-[52px]">
<div className="flex flex-1 pt-[52px]">
<SideNav
activePage={activePage}
onPageChange={handlePageChange}
alertCount={alerts.length}
/>
<main className="flex-1 ml-[200px] p-5">
<main className="flex-1 ml-[200px] p-5 min-w-0">
<Suspense fallback={<PageLoader />}>
{activePage === 'monitoring' && <MonitoringDashboard />}
{activePage === 'alerts' && <AlertsDashboard />}
@@ -111,6 +114,9 @@ function App() {
{activePage === 'district-comparison' && <DistrictComparison />}
{activePage === 'insights' && <Insights />}
{activePage === 'reports' && <ReportsCenter />}
{activePage === 'demographics' && <DemographicAnalysis />}
{activePage === 'disease' && <DiseaseAnalysis />}
{activePage === 'environment' && <EnvironmentalHealth />}
</Suspense>
</main>
</div>

View File

@@ -7,82 +7,97 @@ import { describe, it, expect } from 'vitest';
describe('Component exports', () => {
it('TopNav 可以被导入', async () => {
const mod = await import('@/components/TopNav');
expect(mod.default || mod.TopNav).toBeDefined();
expect((mod as any).default || mod.TopNav).toBeDefined();
});
it('SideNav 可以被导入', async () => {
const mod = await import('@/components/SideNav');
expect(mod.default || mod.SideNav).toBeDefined();
expect((mod as any).default || mod.SideNav).toBeDefined();
});
it('StatCard 可以被导入', async () => {
const mod = await import('@/components/StatCard');
expect(mod.default || mod.StatCard).toBeDefined();
expect((mod as any).default || mod.StatCard).toBeDefined();
});
it('ErrorBanner 可以被导入', async () => {
const mod = await import('@/components/ErrorBanner');
expect(mod.default || mod.ErrorBanner).toBeDefined();
expect((mod as any).default || mod.ErrorBanner).toBeDefined();
});
it('DiseaseFilter 可以被导入', async () => {
const mod = await import('@/components/DiseaseFilter');
expect(mod.default || mod.DiseaseFilter).toBeDefined();
expect((mod as any).default || mod.DiseaseFilter).toBeDefined();
});
it('ChatBot 可以被导入', async () => {
const mod = await import('@/components/ChatBot');
expect(mod.default || mod.ChatBot).toBeDefined();
expect((mod as any).default || mod.ChatBot).toBeDefined();
});
it('TimelinePlayer 可以被导入', async () => {
const mod = await import('@/components/TimelinePlayer');
expect(mod.default || mod.TimelinePlayer).toBeDefined();
expect((mod as any).default || mod.TimelinePlayer).toBeDefined();
});
it('StatisticalCharts 可以被导入', async () => {
const mod = await import('@/components/StatisticalCharts');
expect(mod.default || mod.StatisticalCharts).toBeDefined();
expect((mod as any).default || mod.StatisticalCharts).toBeDefined();
});
it('RiskMap 可以被导入', async () => {
const mod = await import('@/components/RiskMap');
expect(mod.default || mod.RiskMap).toBeDefined();
expect((mod as any).default || mod.RiskMap).toBeDefined();
});
it('AlertMap 可以被导入', async () => {
const mod = await import('@/components/AlertMap');
expect(mod.default || mod.AlertMap).toBeDefined();
expect((mod as any).default || mod.AlertMap).toBeDefined();
});
it('CaseLocationMap 可以被导入', async () => {
const mod = await import('@/components/CaseLocationMap');
expect(mod.default || mod.CaseLocationMap).toBeDefined();
expect((mod as any).default || mod.CaseLocationMap).toBeDefined();
});
it('CaseMap 可以被导入', async () => {
const mod = await import('@/components/CaseMap');
expect(mod.default || mod.CaseMap).toBeDefined();
expect((mod as any).default || mod.CaseMap).toBeDefined();
});
it('DistributionChart 可以被导入', async () => {
const mod = await import('@/components/DistributionChart');
expect(mod.default || mod.DistributionChart).toBeDefined();
expect((mod as any).default || mod.DistributionChart).toBeDefined();
});
it('GridStatsOverlay 可以被导入', async () => {
const mod = await import('@/components/GridStatsOverlay');
expect(mod.default || mod.GridStatsOverlay).toBeDefined();
expect((mod as any).default || mod.GridStatsOverlay).toBeDefined();
});
it('LodGridLayer 可以被导入', async () => {
const mod = await import('@/components/LodGridLayer');
expect(mod.default || mod.LodGridLayer).toBeDefined();
expect((mod as any).default || mod.LodGridLayer).toBeDefined();
});
it('AdminBreadcrumb 可以被导入', async () => {
const mod = await import('@/components/AdminBreadcrumb');
expect(mod.default || mod.AdminBreadcrumb).toBeDefined();
expect((mod as any).default || mod.AdminBreadcrumb).toBeDefined();
});
it('CalendarHeatmap 可以被导入', async () => {
const mod = await import('@/components/CalendarHeatmap');
expect((mod as any).default || mod.CalendarHeatmap).toBeDefined();
});
it('MetricHeatmapTable 可以被导入', async () => {
const mod = await import('@/components/MetricHeatmapTable');
expect((mod as any).default || mod.MetricHeatmapTable).toBeDefined();
});
it('AnomalyMarkers 可以被导入', async () => {
const mod = await import('@/components/AnomalyMarkers');
expect((mod as any).default || mod.AnomalyMarkers).toBeDefined();
});
});

View File

@@ -1,15 +1,18 @@
import { useEffect, useRef, useState, useCallback, memo } from 'react';
import L from 'leaflet';
import { useRiskStore } from '@/stores';
import { LodGridLayer } from '@/components/LodGridLayer';
import { GridStatsOverlay } from '@/components/GridStatsOverlay';
import { useLodGrid } from '@/hooks/useLodGrid';
import type { Alert, GridRisk } from '@/types';
import { riskApi } from '@/services/api';
import type { RiskGridStats } from '@/services/api';
import type { Alert } from '@/types';
export interface CellInfo {
lat: number;
lon: number;
risk: number;
grid_id: string;
risk_1d: number;
risk_3d: number;
risk_7d: number;
nearestAlertId: string | null;
nearestAlertDist: number;
}
@@ -22,16 +25,16 @@ interface AlertMapProps {
showAlertMarkers?: boolean;
showGrid?: boolean;
filteredAlerts?: Alert[];
riskRange?: [number, number];
isFullscreen?: boolean;
}
const WUHAN_CENTER: [number, number] = [30.59, 114.31];
const GRID_OPACITY = 0.72;
const RISK_COLORS: [number, number, string][] = [
[0.0, 0.2, '#22c55e'],
[0.2, 0.4, '#3b82f6'],
[0.4, 0.6, '#eab308'],
// Mirrors the server-side colormap in backend/utils/risk_raster.py.
const RISK_LEGEND: [number, number, string][] = [
[0.25, 0.4, '#38b000'],
[0.4, 0.6, '#facc15'],
[0.6, 0.8, '#f97316'],
[0.8, 1.0, '#ef4444'],
];
@@ -40,12 +43,9 @@ function getRiskLabel(value: number): string {
if (value >= 0.8) return '高风险';
if (value >= 0.6) return '中高';
if (value >= 0.4) return '中风险';
if (value >= 0.2) return '中低';
return '低风险';
}
const EMPTY_GRIDS: GridRisk[] = [];
function AlertMapComponent({
selectedGridId,
onGridClick,
@@ -54,26 +54,31 @@ function AlertMapComponent({
showAlertMarkers = true,
showGrid = true,
filteredAlerts = [],
riskRange,
isFullscreen = false,
}: AlertMapProps) {
const mapRef = useRef<HTMLDivElement>(null);
const mapInstanceRef = useRef<L.Map | null>(null);
const riskTileRef = useRef<L.TileLayer | null>(null);
const alertLayerRef = useRef<L.LayerGroup | null>(null);
const markerMapRef = useRef<Map<string, L.CircleMarker>>(new Map());
const selectedMarkerRef = useRef<L.Rectangle | null>(null);
const clickHandlerRef = useRef(onGridClick);
const [currentZoom, setCurrentZoom] = useState(10);
const cellInfoRef = useRef(onCellInfo);
const resizeObserverRef = useRef<ResizeObserver | null>(null);
// Latest inputs the once-subscribed map handlers read, so we never have to
// re-subscribe (and tear down listeners) when prop/callback identities change.
const inputsRef = useRef({ filteredAlerts, showAlertMarkers, forecastDay });
const grids = useRiskStore((s) => s.grids ?? EMPTY_GRIDS);
// LOD grid data for stats overlay
const { count, avgRisk, maxRisk, loading } = useLodGrid(currentZoom, forecastDay);
const [gridStats, setGridStats] = useState<RiskGridStats | null>(null);
const [statsLoading, setStatsLoading] = useState(false);
useEffect(() => { clickHandlerRef.current = onGridClick; }, [onGridClick]);
useEffect(() => { cellInfoRef.current = onCellInfo; }, [onCellInfo]);
useEffect(() => {
clickHandlerRef.current = onGridClick;
}, [onGridClick]);
inputsRef.current = { filteredAlerts, showAlertMarkers, forecastDay };
}, [filteredAlerts, showAlertMarkers, forecastDay]);
// Initialize map
// --- Initialize map once ---
useEffect(() => {
if (!mapRef.current || mapInstanceRef.current) return;
@@ -88,63 +93,137 @@ function AlertMapComponent({
maxZoom: 19,
}).addTo(map);
map.on('zoomend', () => {
setCurrentZoom(map.getZoom());
// Full-Wuhan 100m risk grid as raster tiles. The browser only fetches PNGs
// (Leaflet caches them per z/x/y); LOD is inherent in the tile pyramid.
const riskTiles = L.tileLayer(riskApi.tileUrlTemplate(forecastDay), {
opacity: showGrid ? GRID_OPACITY : 0,
maxNativeZoom: 16,
maxZoom: 19,
updateWhenZooming: false,
keepBuffer: 2,
zIndex: 200,
}).addTo(map);
riskTileRef.current = riskTiles;
const alertLayer = L.layerGroup().addTo(map);
alertLayerRef.current = alertLayer;
// Click-to-inspect: query the 100m cell under the cursor. Clicks on alert
// markers are consumed by the marker handler and never reach this.
map.on('click', async (e: L.LeafletMouseEvent) => {
const { lat, lng } = e.latlng;
const { filteredAlerts: alerts, forecastDay: day } = inputsRef.current;
try {
const cell = await riskApi.getCell(lat, lng, day);
// Nearest alert (squared degree distance — cheap, no sqrt).
let nearestId: string | null = null;
let minSq = Infinity;
for (const a of alerts) {
const dx = a.latitude - lat;
const dy = a.longitude - lng;
const d = dx * dx + dy * dy;
if (d < minSq) { minSq = d; nearestId = a.grid_id; }
}
const nearestDist = Math.sqrt(minSq);
if (nearestId && nearestDist < 0.01) {
clickHandlerRef.current(nearestId);
} else if (cellInfoRef.current) {
cellInfoRef.current({
lat, lon: lng,
risk: cell.risk_value,
grid_id: cell.grid_id,
risk_1d: cell.risk_1d,
risk_3d: cell.risk_3d,
risk_7d: cell.risk_7d,
nearestAlertId: nearestId,
nearestAlertDist: nearestDist,
});
}
} catch { /* transient fetch error — ignore the click */ }
});
mapInstanceRef.current = map;
const resizeObserver = new ResizeObserver(() => {
mapInstanceRef.current?.invalidateSize({ animate: false });
});
resizeObserver.observe(mapRef.current);
resizeObserverRef.current = resizeObserver;
return () => {
resizeObserver.disconnect();
resizeObserverRef.current = null;
markerMapRef.current.clear();
alertLayerRef.current = null;
riskTileRef.current = null;
map.remove();
mapInstanceRef.current = null;
};
}, []);
// Render alert markers overlay
// --- Update risk tiles + stats when the forecast horizon changes ---
useEffect(() => {
const layer = riskTileRef.current;
if (layer) layer.setUrl(riskApi.tileUrlTemplate(forecastDay));
let cancelled = false;
setStatsLoading(true);
riskApi.getGridStats(forecastDay)
.then((s) => { if (!cancelled) setGridStats(s); })
.catch(() => { if (!cancelled) setGridStats(null); })
.finally(() => { if (!cancelled) setStatsLoading(false); });
return () => { cancelled = true; };
}, [forecastDay]);
// --- Toggle grid visibility without rebuilding tiles ---
useEffect(() => {
riskTileRef.current?.setOpacity(showGrid ? GRID_OPACITY : 0);
}, [showGrid]);
// --- Render alert markers via diffing against a persistent layer group ---
const renderAlertMarkers = useCallback(() => {
const map = mapInstanceRef.current;
if (!map) return;
const layer = alertLayerRef.current;
if (!map || !layer) return;
if (alertLayerRef.current) {
try { map.removeLayer(alertLayerRef.current); } catch { /* ok */ }
alertLayerRef.current = null;
const markerMap = markerMapRef.current;
const { filteredAlerts: alerts, showAlertMarkers: showMarkers } = inputsRef.current;
if (!showMarkers || !alerts || alerts.length === 0) {
if (markerMap.size > 0) { layer.clearLayers(); markerMap.clear(); }
return;
}
if (!showAlertMarkers || !filteredAlerts || filteredAlerts.length === 0) return;
const layer = L.layerGroup();
const mapBounds = map.getBounds();
const b = map.getBounds();
const south = b.getSouth(), north = b.getNorth(), west = b.getWest(), east = b.getEast();
const maxMarkers = 500;
const step = Math.max(1, Math.floor(filteredAlerts.length / maxMarkers));
const step = Math.max(1, Math.floor(alerts.length / maxMarkers));
for (let i = 0; i < filteredAlerts.length; i += step) {
const alert = filteredAlerts[i];
const desired = new Map<string, Alert>();
for (let i = 0; i < alerts.length; i += step) {
const alert = alerts[i];
if (!alert.latitude || !alert.longitude) continue;
if (alert.latitude < south || alert.latitude > north ||
alert.longitude < west || alert.longitude > east) continue;
const key = alert.grid_id || `${alert.latitude},${alert.longitude},${i}`;
desired.set(key, alert);
}
// Skip if outside viewport
if (
alert.latitude < mapBounds.getSouth() ||
alert.latitude > mapBounds.getNorth() ||
alert.longitude < mapBounds.getWest() ||
alert.longitude > mapBounds.getEast()
) {
continue;
}
for (const [key, marker] of markerMap) {
if (!desired.has(key)) { layer.removeLayer(marker); markerMap.delete(key); }
}
for (const [key, alert] of desired) {
if (markerMap.has(key)) continue;
const isP1 = alert.priority === 'P1';
const marker = L.circleMarker(
[alert.latitude, alert.longitude],
{
radius: isP1 ? 6 : 4,
fillColor: isP1 ? '#ef4444' : '#f97316',
fillOpacity: 0.7,
color: isP1 ? '#ef4444' : '#f97316',
weight: 2,
dashArray: isP1 ? undefined : '4 2',
}
);
const marker = L.circleMarker([alert.latitude, alert.longitude], {
radius: isP1 ? 6 : 4,
fillColor: isP1 ? '#ef4444' : '#f97316',
fillOpacity: 0.7,
color: isP1 ? '#ef4444' : '#f97316',
weight: 2,
dashArray: isP1 ? undefined : '4 2',
});
marker.bindTooltip(
`<div style="font-size:12px;">
<strong>${alert.priority}</strong> · ${(alert.risk_value * 100).toFixed(0)}%<br/>
@@ -152,106 +231,60 @@ function AlertMapComponent({
</div>`,
{ direction: 'top', offset: [0, -5] }
);
marker.on('click', () => {
if (alert.grid_id) clickHandlerRef.current(alert.grid_id);
});
const gridId = alert.grid_id;
marker.on('click', () => { if (gridId) clickHandlerRef.current(gridId); });
marker.addTo(layer);
markerMap.set(key, marker);
}
}, []);
layer.addTo(map);
alertLayerRef.current = layer;
}, [filteredAlerts, showAlertMarkers]);
// Re-render alert markers when data changes
useEffect(() => {
renderAlertMarkers();
}, [renderAlertMarkers]);
}, [filteredAlerts, showAlertMarkers, renderAlertMarkers]);
// Also re-render on map zoom/pan
// Re-render markers on pan/zoom, throttled, subscribed once per map instance.
useEffect(() => {
const map = mapInstanceRef.current;
if (!map) return;
const handleMove = () => renderAlertMarkers();
let throttle: ReturnType<typeof setTimeout> | null = null;
const handleMove = () => {
if (throttle) return;
throttle = setTimeout(() => { throttle = null; renderAlertMarkers(); }, 150);
};
map.on('moveend', handleMove);
return () => { map.off('moveend', handleMove); };
return () => {
map.off('moveend', handleMove);
if (throttle) clearTimeout(throttle);
};
}, [renderAlertMarkers]);
// Selected grid highlight
// --- Selected alert highlight (located from the alert list, no grid scan) ---
useEffect(() => {
const map = mapInstanceRef.current;
if (!map) return;
if (selectedMarkerRef.current) {
try { map.removeLayer(selectedMarkerRef.current); } catch { /* ok */ }
selectedMarkerRef.current = null;
}
if (!selectedGridId) return;
const alert = filteredAlerts.find((a) => a.grid_id === selectedGridId);
if (!alert) return;
const latHalf = 0.00045, lonHalf = 0.00052;
const rect = L.rectangle(
[[alert.latitude - latHalf, alert.longitude - lonHalf],
[alert.latitude + latHalf, alert.longitude + lonHalf]],
{ fillColor: '#3b82f6', fillOpacity: 0.3, color: '#3b82f6', weight: 3 }
).addTo(map);
selectedMarkerRef.current = rect;
map.flyTo([alert.latitude, alert.longitude], Math.max(map.getZoom(), 13), { duration: 0.5 });
}, [selectedGridId, filteredAlerts]);
if (selectedGridId) {
let grid = grids.find((g) => g.grid_id === selectedGridId);
if (!grid) {
const selectedAlertObj = filteredAlerts.find((a) => a.grid_id === selectedGridId);
if (selectedAlertObj) {
grid = grids.find((g) =>
Math.abs(g.latitude - selectedAlertObj.latitude) < 0.001 &&
Math.abs(g.longitude - selectedAlertObj.longitude) < 0.001
);
}
}
if (grid) {
const latHalf = 0.00045;
const lonHalf = 0.00052;
const marker = L.rectangle(
[
[grid.latitude - latHalf, grid.longitude - lonHalf],
[grid.latitude + latHalf, grid.longitude + lonHalf],
],
{
fillColor: '#3b82f6',
fillOpacity: 0.3,
color: '#3b82f6',
weight: 3,
}
).addTo(map);
selectedMarkerRef.current = marker;
map.flyTo([grid.latitude, grid.longitude], Math.max(map.getZoom(), 12), { duration: 0.5 });
}
}
}, [selectedGridId, grids]);
// Handle LOD grid cell click → find nearest alert
const handleCellClick = useCallback(
(lat: number, lon: number, risk: number) => {
let nearestId: string | null = null;
let minDist = Infinity;
if (filteredAlerts) {
for (const a of filteredAlerts) {
const d = Math.sqrt((a.latitude - lat) ** 2 + (a.longitude - lon) ** 2);
if (d < minDist) {
minDist = d;
nearestId = a.grid_id;
}
}
}
if (nearestId && minDist < 0.01) {
clickHandlerRef.current(nearestId);
} else if (onCellInfo) {
onCellInfo({ lat, lon, risk, nearestAlertId: nearestId, nearestAlertDist: minDist });
}
},
[filteredAlerts, onCellInfo]
);
// Invalidate Leaflet size after fullscreen toggle
// --- Invalidate size after fullscreen toggle (CSS transition ~200ms) ---
useEffect(() => {
const map = mapInstanceRef.current;
if (!map) return;
const timer = setTimeout(() => map.invalidateSize({ animate: true }), 100);
map.invalidateSize({ animate: false });
const timer = setTimeout(() => map.invalidateSize({ animate: true }), 200);
return () => clearTimeout(timer);
}, [isFullscreen]);
@@ -261,29 +294,18 @@ function AlertMapComponent({
<div className="relative">
<div ref={mapRef} className="w-full rounded-lg overflow-hidden" style={{ height: containerHeight }} />
{/* LOD Grid Layer */}
<LodGridLayer
map={mapInstanceRef.current}
forecastDay={forecastDay}
visible={showGrid}
riskRange={riskRange}
onCellClick={handleCellClick}
/>
{/* Stats overlay */}
<GridStatsOverlay
count={count}
avgRisk={avgRisk}
maxRisk={maxRisk}
loading={loading}
count={gridStats?.cell_count ?? 0}
avgRisk={gridStats?.avg_risk ?? 0}
maxRisk={gridStats?.max_risk ?? 0}
loading={statsLoading}
forecastDay={forecastDay}
/>
{/* Legend */}
<div className="absolute bottom-4 right-4 bg-bg-card/90 backdrop-blur rounded-lg border border-border-light shadow-sm z-[1000] px-4 py-3">
<div className="text-[11px] font-semibold text-text-secondary mb-2"></div>
<div className="text-[11px] font-semibold text-text-secondary mb-2"> (100m )</div>
<div className="space-y-1.5">
{RISK_COLORS.slice().reverse().map(([min, max, color]) => (
{RISK_LEGEND.slice().reverse().map(([min, max, color]) => (
<div key={color} className="flex items-center gap-2">
<div className="w-4 h-4 rounded" style={{ backgroundColor: color }} />
<span className="text-[11px] text-text-secondary">
@@ -291,6 +313,10 @@ function AlertMapComponent({
</span>
</div>
))}
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded border border-border-light bg-transparent" />
<span className="text-[11px] text-text-muted">&lt;25% </span>
</div>
</div>
</div>
</div>

View File

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

View File

@@ -0,0 +1,38 @@
# Components — Reusable UI
## Conventions
- One component per file, PascalCase, default export
- Props interface: `{ComponentName}Props`, typed strictly (no `any`)
- Wrap pure display components in `memo()` for render optimization
- All styling via Tailwind utility classes — no CSS modules, no inline styles
## Component Types
**Map components** (`*Map.tsx`) — Leaflet-based maps:
- Use `react-leaflet` / direct Leaflet manipulation via `useRef`
- Risk coloring: centralized `RISK_COLORS` and `RISK_LABELS` constants
- Coordinate system: `[lat, lng]` (Leaflet convention, NOT `[lng, lat]`)
**Chart components** (`*Chart*.tsx`) — Recharts:
- Responsive containers with `width="100%" height={...}`
**Navigation** (`TopNav.tsx`, `SideNav.tsx`):
- No data fetching — pure navigation/presentation
**Overlay/Utility** (`GridStatsOverlay`, `ErrorBanner`, `StatCard`, `TimelinePlayer`):
- Small, focused, reusable across pages
## Data Flow
- Components receive data via props, never fetch directly
- Callbacks passed as props: `onGridSelect`, `onClosePanel`, `onForecastChange`
- Complex stateful behavior extracted to custom hooks (e.g., `useTimelineStore`)
## Anti-Patterns
- Don't fetch data in components — receive via props or store hooks
- Don't create god components (>200 lines) — extract sub-components
- Don't use `any` in prop types — use `unknown` and narrow
- Don't pass Leaflet map instances between components — each map manages its own instance
- Don't use CSS modules or inline styles — Tailwind only

View File

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

View File

@@ -9,13 +9,15 @@ interface CaseLocationMapProps {
height?: string;
district?: string | null;
street?: string | null;
date?: string | null;
}
function CaseLocationMapComponent({ height = '400px', district = null, street = null }: CaseLocationMapProps) {
function CaseLocationMapComponent({ height = '400px', district = null, street = null, date = null }: CaseLocationMapProps) {
const mapRef = useRef<HTMLDivElement>(null);
const mapInstanceRef = useRef<L.Map | null>(null);
const layerRef = useRef<L.LayerGroup | null>(null);
const cancelledRef = useRef(false);
const resizeObserverRef = useRef<ResizeObserver | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [caseCount, setCaseCount] = useState(0);
@@ -39,8 +41,19 @@ function CaseLocationMapComponent({ height = '400px', district = null, street =
mapInstanceRef.current = map;
layerRef.current = L.layerGroup().addTo(map);
// ResizeObserver: auto-invalidate when container size changes (window resize, layout shifts)
const resizeObserver = new ResizeObserver(() => {
if (mapInstanceRef.current) {
mapInstanceRef.current.invalidateSize({ animate: false });
}
});
if (mapRef.current) {
resizeObserver.observe(mapRef.current);
}
resizeObserverRef.current = resizeObserver;
// Fetch case locations
geocodedApi.getGeocoded({ limit: 5000, district: district || undefined })
geocodedApi.getGeocoded({ limit: 5000, district: district || undefined, date: date || undefined })
.then((data) => {
if (cancelledRef.current) return;
const cases: GeocodedCase[] = data.cases || [];
@@ -104,10 +117,14 @@ function CaseLocationMapComponent({ height = '400px', district = null, street =
return () => {
cancelledRef.current = true;
if (resizeObserverRef.current) {
resizeObserverRef.current.disconnect();
resizeObserverRef.current = null;
}
map.remove();
mapInstanceRef.current = null;
};
}, [district, street]);
}, [district, street, date]);
return (
<div className="relative">

View File

@@ -18,9 +18,9 @@ for (const [, , color] of RISK_COLORS) {
function getRiskColor(value: number): string {
for (const [min, max, color] of RISK_COLORS) {
if (value >= min && value <= max) return color;
if (value >= min && value < max) return color;
}
return '#22c55e';
return '#ef4444';
}
// 100m grid step in degrees
@@ -116,6 +116,7 @@ export function LodGridLayer({
canvas.style.width = '100%';
canvas.style.height = '100%';
canvas.style.pointerEvents = 'none';
canvas.style.display = visibleRef.current ? '' : 'none';
pane.appendChild(canvas);
canvasRef.current = canvas;
@@ -173,8 +174,8 @@ export function LodGridLayer({
canvas.style.transform = '';
drawnOriginRef.current = null;
if (!visibleRef.current) return;
// Visibility is controlled via canvas CSS display (see visible effect),
// so we still draw pixels even when hidden to keep them ready on re-show.
const currentGrids = gridsRef.current;
if (!currentGrids || currentGrids.length === 0) return;
@@ -354,13 +355,21 @@ export function LodGridLayer({
};
}, [map]);
// Trigger redraw when data changes
// Trigger redraw when data/geometry-affecting inputs change.
useEffect(() => {
const canvas = canvasRef.current;
if (canvas && (canvas as any).__lodRedraw) {
(canvas as any).__lodRedraw();
}
}, [grids, forecastDay, riskRange, visible]);
}, [grids, forecastDay, riskRange]);
// Visibility toggle: hide/show via CSS instead of a full geometry redraw.
useEffect(() => {
const canvas = canvasRef.current;
if (canvas) {
canvas.style.display = visible ? '' : 'none';
}
}, [visible]);
return null;
}

View File

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

View File

@@ -46,6 +46,22 @@ function debounce<T extends (...args: any[]) => void>(fn: T, ms: number) {
};
}
// Mercator helpers (avoid per-cell latLngToContainerPoint) — mirrors LodGridLayer.
function latToMercY(lat: number): number {
return 128 - (256 * Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI) / 360))) / (2 * Math.PI);
}
function lonToMercX(lon: number): number {
return ((lon + 180) / 360) * 256;
}
function riskColorForValue(riskValue: number): string {
if (riskValue >= 0.7) return RISK_COLORS.high;
if (riskValue >= 0.5) return RISK_COLORS.medium_high;
if (riskValue >= 0.3) return RISK_COLORS.medium_low;
return RISK_COLORS.low;
}
function RiskMapComponent(props: RiskMapProps) {
const {
grids,
@@ -60,9 +76,14 @@ function RiskMapComponent(props: RiskMapProps) {
const mapDivRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<any>(null);
const gridLayerRef = useRef<any>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const paneRef = useRef<HTMLElement | null>(null);
const animFrameRef = useRef<number>(0);
const redrawRef = useRef<() => void>(() => {});
const zoomRef = useRef(9);
const callbacksRef = useRef({ onGridSelect, onClosePanel, onFullscreen, onForecastChange });
const resizeObserverRef = useRef<ResizeObserver | null>(null);
const gridMapRef = useRef<Map<string, GridRisk>>(new Map());
useEffect(() => {
callbacksRef.current = { onGridSelect, onClosePanel, onFullscreen, onForecastChange };
@@ -70,6 +91,19 @@ function RiskMapComponent(props: RiskMapProps) {
const containerHeight = isFullscreen ? 'calc(100vh - 52px)' : '420px';
// Invalidate map size when container size changes (window resize, fullscreen, layout shifts)
useEffect(() => {
const map = mapRef.current;
if (!map) return;
// Fullscreen transition: wait for CSS transition to complete
const timer = setTimeout(() => {
map.invalidateSize({ animate: true });
}, 150);
return () => clearTimeout(timer);
}, [isFullscreen, containerHeight]);
const gridMap = useMemo(() => {
const map = new Map<string, GridRisk>();
grids.forEach((g) => {
@@ -79,6 +113,13 @@ function RiskMapComponent(props: RiskMapProps) {
return map;
}, [grids]);
// Keep gridMap accessible to the canvas render fn (read via ref, no re-init).
useEffect(() => {
gridMapRef.current = gridMap;
redrawRef.current();
}, [gridMap]);
// Create the map + tile layer + canvas overlay + handlers ONCE.
useEffect(() => {
if (!mapDivRef.current || mapRef.current) return;
@@ -95,6 +136,139 @@ function RiskMapComponent(props: RiskMapProps) {
mapRef.current = map;
// Canvas overlay pane for batched grid rendering (replaces per-cell rectangles).
const pane = map.createPane('risk-grid-pane');
pane.style.zIndex = '450';
pane.style.pointerEvents = 'none';
paneRef.current = pane;
const canvas = document.createElement('canvas');
canvas.style.position = 'absolute';
canvas.style.top = '0';
canvas.style.left = '0';
canvas.style.width = '100%';
canvas.style.height = '100%';
canvas.style.pointerEvents = 'none';
pane.appendChild(canvas);
canvasRef.current = canvas;
// ResizeObserver: auto-invalidate map size when container changes
const resizeObserver = new ResizeObserver(
debounce(() => {
if (mapRef.current) {
mapRef.current.invalidateSize({ animate: false });
}
}, 100)
);
if (mapDivRef.current) {
resizeObserver.observe(mapDivRef.current);
}
resizeObserverRef.current = resizeObserver;
// Batched canvas render: group cells by color and fillRect on one canvas.
function renderGridLayer() {
if (!mapRef.current || !canvasRef.current) return;
const map = mapRef.current;
const canvas = canvasRef.current;
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
animFrameRef.current = requestAnimationFrame(() => {
const container = map.getContainer();
const w = container.clientWidth;
const h = container.clientHeight;
const dpr = window.devicePixelRatio || 1;
canvas.width = w * dpr;
canvas.height = h * dpr;
canvas.style.width = w + 'px';
canvas.style.height = h + 'px';
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
const zoom = map.getZoom();
let cellSize: number;
let step: number;
if (zoom <= 8) { cellSize = 0.1; step = 10; }
else if (zoom <= 10) { cellSize = 0.025; step = 4; }
else if (zoom <= 12) { cellSize = 0.01; step = 2; }
else { cellSize = 0.005; step = 2; }
const bounds = map.getBounds();
const minLat = Math.max(bounds.getSouth(), WUHAN_BOUNDS.minLat);
const maxLat = Math.min(bounds.getNorth(), WUHAN_BOUNDS.maxLat);
const minLon = Math.max(bounds.getWest(), WUHAN_BOUNDS.minLon);
const maxLon = Math.min(bounds.getEast(), WUHAN_BOUNDS.maxLon);
const latStart = Math.floor((minLat - WUHAN_BOUNDS.minLat) / cellSize) * cellSize + WUHAN_BOUNDS.minLat;
const lonStart = Math.floor((minLon - WUHAN_BOUNDS.minLon) / cellSize) * cellSize + WUHAN_BOUNDS.minLon;
const currentGridMap = gridMapRef.current;
if (currentGridMap.size > 5000) {
console.warn(`[RiskMap] Data too dense: ${currentGridMap.size} grid cells, rendering may be slow`);
}
const scale = 2 ** zoom;
const origin = map.getPixelOrigin();
const cellDeg = cellSize * step;
// Group cells by color to minimize fillStyle changes.
const colorGroups: Record<string, { x: number; y: number; w: number; h: number }[]> = {};
let count = 0;
const maxCount = 1500;
for (let lat = latStart; lat < maxLat && count < maxCount; lat += cellDeg) {
for (let lon = lonStart; lon < maxLon && count < maxCount; lon += cellDeg) {
const key = `${lat.toFixed(4)}-${lon.toFixed(4)}`;
const grid = currentGridMap.get(key);
const riskValue = grid?.risk_value ?? 0.5;
const color = riskColorForValue(riskValue);
const lx = lonToMercX(lon) * scale - origin.x;
const rx = lonToMercX(lon + cellDeg) * scale - origin.x;
const ty = latToMercY(lat + cellDeg) * scale - origin.y;
const by = latToMercY(lat) * scale - origin.y;
if (!colorGroups[color]) colorGroups[color] = [];
colorGroups[color].push({ x: lx, y: ty, w: rx - lx, h: by - ty });
count++;
}
}
ctx.globalAlpha = 0.6;
for (const [color, cells] of Object.entries(colorGroups)) {
ctx.fillStyle = color;
for (const c of cells) {
ctx.fillRect(c.x, c.y, c.w, c.h);
}
}
ctx.globalAlpha = 1;
});
}
redrawRef.current = renderGridLayer;
// Single map-level click handler: nearest-cell lookup (replaces 1500 handlers).
const handleMapClick = (e: L.LeafletMouseEvent) => {
const currentGridMap = gridMapRef.current;
if (currentGridMap.size === 0) return;
const { lat, lng } = e.latlng;
let nearestDist = Infinity;
let nearestGrid: GridRisk | null = null;
for (const grid of currentGridMap.values()) {
const d = (grid.latitude - lat) ** 2 + (grid.longitude - lng) ** 2;
if (d < nearestDist) {
nearestDist = d;
nearestGrid = grid;
}
}
if (nearestGrid && nearestDist < 0.01 * 0.01) {
callbacksRef.current.onGridSelect(nearestGrid.grid_id);
}
};
const handleZoom = debounce(() => {
zoomRef.current = map.getZoom();
renderGridLayer();
@@ -106,112 +280,27 @@ function RiskMapComponent(props: RiskMapProps) {
map.on('zoomend', handleZoom);
map.on('moveend', handleMove);
function renderGridLayer() {
if (!mapRef.current) return;
const map = mapRef.current;
if (gridLayerRef.current) {
try {
map.removeLayer(gridLayerRef.current);
} catch {
// ignore
}
gridLayerRef.current = null;
}
const zoom = map.getZoom();
let cellSize: number;
let step: number;
if (zoom <= 8) {
cellSize = 0.1;
step = 10;
} else if (zoom <= 10) {
cellSize = 0.025;
step = 4;
} else if (zoom <= 12) {
cellSize = 0.01;
step = 2;
} else {
cellSize = 0.005;
step = 2;
}
const bounds = map.getBounds();
const minLat = Math.max(bounds.getSouth(), WUHAN_BOUNDS.minLat);
const maxLat = Math.min(bounds.getNorth(), WUHAN_BOUNDS.maxLat);
const minLon = Math.max(bounds.getWest(), WUHAN_BOUNDS.minLon);
const maxLon = Math.min(bounds.getEast(), WUHAN_BOUNDS.maxLon);
const latStart = Math.floor((minLat - WUHAN_BOUNDS.minLat) / cellSize) * cellSize + WUHAN_BOUNDS.minLat;
const lonStart = Math.floor((minLon - WUHAN_BOUNDS.minLon) / cellSize) * cellSize + WUHAN_BOUNDS.minLon;
const gridLayer = L.layerGroup();
const currentGridMap = gridMap;
let count = 0;
const maxCount = 1500;
if (currentGridMap.size > 5000) {
console.warn(`[RiskMap] Data too dense: ${currentGridMap.size} grid cells, rendering may be slow`);
}
for (let lat = latStart; lat < maxLat && count < maxCount; lat += cellSize * step) {
for (let lon = lonStart; lon < maxLon && count < maxCount; lon += cellSize * step) {
const key = `${lat.toFixed(4)}-${lon.toFixed(4)}`;
const grid = currentGridMap.get(key);
const riskValue = grid?.risk_value ?? 0.5;
let riskLevel = 'medium';
if (riskValue >= 0.7) riskLevel = 'high';
else if (riskValue >= 0.5) riskLevel = 'medium_high';
else if (riskValue >= 0.3) riskLevel = 'medium_low';
else riskLevel = 'low';
const color = RISK_COLORS[riskLevel];
const rect = L.rectangle(
[[lat, lon], [lat + cellSize * step, lon + cellSize * step]],
{
fillColor: color,
fillOpacity: 0.6,
color: 'transparent',
weight: 0,
}
);
if (grid) {
const gridId = grid.grid_id;
rect.bindTooltip(
`<b>${gridId}</b><br/>风险:${Math.round(riskValue * 100)}%`,
{ direction: 'center', permanent: false }
);
rect.on('click', () => {
callbacksRef.current.onGridSelect(gridId);
});
}
rect.addTo(gridLayer);
count++;
}
}
gridLayer.addTo(map);
gridLayerRef.current = gridLayer;
}
map.on('resize', renderGridLayer);
map.on('click', handleMapClick);
// Initial render
renderGridLayer();
return () => {
if (resizeObserverRef.current) {
resizeObserverRef.current.disconnect();
resizeObserverRef.current = null;
}
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
redrawRef.current = () => {};
if (mapRef.current) {
mapRef.current.remove();
mapRef.current = null;
gridLayerRef.current = null;
}
canvasRef.current = null;
paneRef.current = null;
};
}, [gridMap]);
}, []);
const handleForecastChange = useCallback((d: ForecastDay) => {
callbacksRef.current.onForecastChange(d);

View File

@@ -44,6 +44,9 @@ const modules: { id: string; label: string; icon: React.ReactNode; items: { id:
{ id: 'district-comparison', label: '区域对比' },
{ id: 'insights', label: '智能洞察' },
{ id: 'reports', label: '报表中心' },
{ id: 'demographics', label: '人群分析' },
{ id: 'disease', label: '疾病分析' },
{ id: 'environment', label: '环境健康' },
],
},
];

View File

@@ -1,44 +1,107 @@
import React from 'react';
interface StatCardProps {
icon?: React.ReactNode;
label: string;
value: string | number;
change?: string;
changeType?: 'up' | 'down' | 'neutral';
progress?: number;
progressColor?: string;
trend?: {
direction: 'up' | 'down' | 'stable';
value: string;
};
sparkline?: {
data: number[];
color: string;
};
color?: string;
onClick?: () => void;
}
export function StatCard({
export const StatCard = React.memo(function StatCard({
icon,
label,
value,
change,
changeType = 'neutral',
progress,
progressColor = 'bg-warning',
trend,
sparkline,
color,
onClick,
}: StatCardProps) {
const trendIndicator = trend ? (
<span
className={`inline-flex items-center gap-0.5 text-xs font-medium ${
trend.direction === 'up'
? 'text-green-600'
: trend.direction === 'down'
? 'text-red-600'
: 'text-gray-500'
}`}
>
{trend.direction === 'up' && <span aria-hidden>&#9650;</span>}
{trend.direction === 'down' && <span aria-hidden>&#9660;</span>}
{trend.direction === 'stable' && <span aria-hidden>&#9644;</span>}
{trend.value}
</span>
) : null;
const sparklineSvg = sparkline && sparkline.data.length >= 2 ? (
<svg
width="60"
height="24"
className="shrink-0"
aria-hidden="true"
>
<polyline
fill="none"
stroke={sparkline.color}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
points={sparkline.data
.map((val, i) => {
const x = (i / (sparkline.data.length - 1)) * 58 + 1;
const max = Math.max(...sparkline.data);
const min = Math.min(...sparkline.data);
const range = max - min || 1;
const y = 22 - ((val - min) / range) * 20 - 1;
return `${x},${y}`;
})
.join(' ')}
/>
</svg>
) : null;
return (
<div className="card p-4">
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-1.5">
{label}
<div
onClick={onClick}
role={onClick ? 'button' : undefined}
tabIndex={onClick ? 0 : undefined}
onKeyDown={
onClick
? (e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick();
}
}
: undefined
}
className={`bg-white rounded-lg border border-gray-200 p-4 ${
onClick ? 'cursor-pointer hover:shadow-md transition-shadow' : ''
}`}
>
<div className="flex items-center gap-2 text-sm text-gray-500 mb-1">
{icon}
<span>{label}</span>
</div>
<div className="font-display text-[26px] font-bold text-text-primary mb-1">
{value}
<div className="flex items-end justify-between gap-2">
<div
className="text-2xl font-bold text-gray-900"
style={color ? { color } : undefined}
>
{value}
</div>
{sparklineSvg}
</div>
{change && (
<div className={`text-[11px] ${
changeType === 'up' ? 'text-danger' :
changeType === 'down' ? 'text-success' : 'text-text-muted'
}`}>
{change}
</div>
)}
{progress !== undefined && (
<div className="h-[3px] bg-bg-page rounded mt-2.5 overflow-hidden">
<div
className={`h-full rounded ${progressColor}`}
style={{ width: `${progress}%` }}
/>
</div>
)}
{trendIndicator && <div className="mt-1">{trendIndicator}</div>}
</div>
);
}
});

View File

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

View File

@@ -26,6 +26,10 @@ export function TimelinePlayer({
}: TimelinePlayerProps) {
const [playing, setPlaying] = useState(isPlaying);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const advanceRef = useRef<() => void>(() => {});
// Keep internal play state in sync when the parent/store changes isPlaying.
useEffect(() => { setPlaying(isPlaying); }, [isPlaying]);
const generateDateRange = useCallback((start: string, end: string) => {
const dates: string[] = [];
@@ -41,7 +45,13 @@ export function TimelinePlayer({
}, []);
const dateRange = useMemo(() => generateDateRange(startDate, endDate), [startDate, endDate, generateDateRange]);
const currentIndex = useMemo(() => dateRange.indexOf(currentDate), [dateRange, currentDate]);
// Compute index arithmetically from the day difference instead of indexOf.
const currentIndex = useMemo(() => {
if (dateRange.length === 0) return -1;
const ms = new Date(currentDate).getTime() - new Date(startDate).getTime();
const idx = Math.round(ms / 86400000);
return idx >= 0 && idx < dateRange.length ? idx : dateRange.indexOf(currentDate);
}, [startDate, currentDate, dateRange]);
const progress = useMemo(() => ((currentIndex + 1) / dateRange.length) * 100, [currentIndex, dateRange.length]);
const play = useCallback(() => {
@@ -71,12 +81,19 @@ export function TimelinePlayer({
onDateChange(dateRange[0]);
};
// Keep the advance logic in a ref so the interval doesn't get recreated each
// tick when goToNext's identity changes.
useEffect(() => {
advanceRef.current = goToNext;
}, [goToNext]);
// Interval is created once per play/speed change (not per tick).
useEffect(() => {
if (playing) {
const interval = 1000 / speed;
timerRef.current = setInterval(() => {
goToNext();
advanceRef.current();
}, interval);
return () => {
@@ -85,7 +102,7 @@ export function TimelinePlayer({
}
};
}
}, [playing, speed, goToNext]);
}, [playing, speed]);
useEffect(() => {
if (currentIndex >= dateRange.length - 1) {

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { cachedGet } from '../services/api';
export interface LodGridResult {
@@ -24,13 +24,44 @@ export interface MapBounds {
max_lon: number;
}
// Snap bounds to a coarse grid so small pans don't produce a new identity.
const BOUNDS_SNAP = 0.05;
function snapBounds(b: MapBounds): MapBounds {
return {
min_lat: Math.floor(b.min_lat / BOUNDS_SNAP) * BOUNDS_SNAP,
max_lat: Math.ceil(b.max_lat / BOUNDS_SNAP) * BOUNDS_SNAP,
min_lon: Math.floor(b.min_lon / BOUNDS_SNAP) * BOUNDS_SNAP,
max_lon: Math.ceil(b.max_lon / BOUNDS_SNAP) * BOUNDS_SNAP,
};
}
// True if `inner` is fully contained within `outer`.
function boundsContains(outer: MapBounds, inner: MapBounds): boolean {
return (
outer.min_lat <= inner.min_lat &&
outer.max_lat >= inner.max_lat &&
outer.min_lon <= inner.min_lon &&
outer.max_lon >= inner.max_lon
);
}
export function useLodGrid(zoom: number, forecastDay: 1 | 3 | 7, bounds?: MapBounds): LodGridResult {
const [result, setResult] = useState<LodGridResult>(EMPTY_RESULT);
const prevResultRef = useRef<LodGridResult>(EMPTY_RESULT);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
const abortRef = useRef<AbortController>();
const seqRef = useRef(0);
// Track the snapped bounds + zoom of the last successful fetch so we can skip
// refetches when the new viewport is already contained in fetched data.
const lastFetchRef = useRef<{ zoom: number; day: 1 | 3 | 7; bounds?: MapBounds } | null>(null);
const fetchData = useCallback(async (z: number, day: 1 | 3 | 7, b?: MapBounds) => {
// Abort the previous in-flight request and bump the sequence guard.
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
const seq = ++seqRef.current;
setResult((prev) => ({ ...prev, loading: true }));
try {
@@ -41,7 +72,9 @@ export function useLodGrid(zoom: number, forecastDay: 1 | 3 | 7, bounds?: MapBou
params.min_lon = b.min_lon;
params.max_lon = b.max_lon;
}
const data = await cachedGet<any>('/risk/lod-grid', params);
const data = await cachedGet<any>('/risk/lod-grid', params, controller.signal);
// Out-of-order guard: only the latest request commits its result.
if (seq !== seqRef.current) return;
const grids: number[][] = data.grids || [];
const count = data.total_count || grids.length;
@@ -63,26 +96,50 @@ export function useLodGrid(zoom: number, forecastDay: 1 | 3 | 7, bounds?: MapBou
};
prevResultRef.current = newResult;
lastFetchRef.current = { zoom: z, day, bounds: b };
setResult(newResult);
} catch (err: unknown) {
if ((err as Error)?.name === 'AbortError') return;
if ((err as Error)?.name === 'AbortError' || (err as Error)?.name === 'CanceledError') return;
if (seq !== seqRef.current) return;
// Keep previous data on error, just stop loading
setResult((prev) => ({ ...prev, loading: false }));
}
}, []);
// Snap bounds and stabilize identity: the memo only changes when the snapped
// rounded values change, so small pans within the same coarse cell are no-ops.
const snapped = bounds ? snapBounds(bounds) : undefined;
const snappedKey = snapped
? `${snapped.min_lat},${snapped.max_lat},${snapped.min_lon},${snapped.max_lon}`
: '';
const stableBounds = useMemo(() => snapped, [snappedKey]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
const roundedZoom = Math.round(zoom);
// Skip refetch when the new viewport is already contained within the last
// fetched bounds at the same zoom/day (no new data needed).
const last = lastFetchRef.current;
if (
last &&
last.zoom === roundedZoom &&
last.day === forecastDay &&
((!stableBounds && !last.bounds) ||
(stableBounds && last.bounds && boundsContains(last.bounds, stableBounds)))
) {
return;
}
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
const roundedZoom = Math.round(zoom);
fetchData(roundedZoom, forecastDay, bounds);
fetchData(roundedZoom, forecastDay, stableBounds);
}, 150);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [zoom, forecastDay, bounds, fetchData]);
}, [zoom, forecastDay, stableBounds, fetchData]);
// Cleanup on unmount
useEffect(() => {

View File

@@ -24,11 +24,20 @@
}
}
/* Leaflet overrides */
/* Leaflet overrides — keep z-index below TopNav (z-50) and SideNav */
.leaflet-container {
font-family: inherit;
}
.leaflet-pane {
z-index: 1 !important;
}
.leaflet-top,
.leaflet-bottom {
z-index: 5 !important;
}
.leaflet-popup-content-wrapper {
@apply rounded-lg shadow-lg;
}

View File

@@ -1,10 +1,13 @@
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import React from 'react';
import { useRiskStore } from '@/stores';
import { useLodGrid } from '@/hooks/useLodGrid';
import { AlertMap } from '@/components/AlertMap';
import type { CellInfo } from '@/components/AlertMap';
import { ErrorBanner } from '@/components/ErrorBanner';
import { StatCard } from '@/components/StatCard';
import { StatisticalCharts } from '@/components/StatisticalCharts';
import { analysisApi } from '@/services/api';
import { PieChart, Pie, Cell, Tooltip as RechartsTooltip, Legend, ResponsiveContainer } from 'recharts';
interface ExtendedAlert {
alert_id: string;
@@ -48,8 +51,14 @@ export function AlertsDashboard() {
const [showGrid, setShowGrid] = useState(true);
const [cellInfo, setCellInfo] = useState<CellInfo | null>(null);
// LOD grid data for cell info lookup (1d/3d/7d risk values)
const { grids: lodGrids } = useLodGrid(10, forecastDay);
// In-page tab strip (no router) — matches existing activePage pattern
const [activeTab, setActiveTab] = useState<'list' | 'stats'>('list');
// Risk-trend data for the 风险统计 tab, fetched on demand
const [trendData, setTrendData] = useState<Array<{ date: string; cases: number; risk: number }>>([]);
const [trendLoading, setTrendLoading] = useState(false);
const [trendError, setTrendError] = useState<string | null>(null);
const [trendLoaded, setTrendLoaded] = useState(false);
// Debounce riskRange for filteredAlerts computation
useEffect(() => {
@@ -63,11 +72,36 @@ export function AlertsDashboard() {
fetchAlerts();
}, [fetchRiskMap, fetchAlerts]);
// Fetch real risk-trend data when the 风险统计 tab is first opened
useEffect(() => {
if (activeTab !== 'stats' || trendLoaded) return;
let cancelled = false;
setTrendLoading(true);
setTrendError(null);
analysisApi
.getTrend(14)
.then((res: { dates?: string[]; values?: number[] }) => {
if (cancelled) return;
const dates = res?.dates ?? [];
const values = res?.values ?? [];
setTrendData(dates.map((date, i) => ({ date, cases: 0, risk: values[i] ?? 0 })));
setTrendLoaded(true);
})
.catch((err: unknown) => {
if (cancelled) return;
setTrendError(err instanceof Error ? err.message : '加载风险趋势失败');
})
.finally(() => {
if (!cancelled) setTrendLoading(false);
});
return () => { cancelled = true; };
}, [activeTab, trendLoaded]);
const extendedAlerts: ExtendedAlert[] = useMemo(() => {
const now = Date.now();
return (alerts || []).map((alert) => {
const forecastDate = new Date(alert.forecast_time);
const now = new Date();
const diffDays = Math.ceil((forecastDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
const diffDays = Math.ceil((forecastDate.getTime() - now) / (1000 * 60 * 60 * 24));
const horizon = diffDays <= 1 ? 1 : diffDays <= 3 ? 3 : 7;
return {
@@ -95,22 +129,33 @@ export function AlertsDashboard() {
});
}, [extendedAlerts, selectedHorizon, selectedPriority, sortBy, debouncedRiskRange]);
// Risk distribution stats (includes p1/p2 counts)
// Risk distribution stats (includes p1/p2 counts) — single pass over each array
const riskStats = useMemo(() => {
const p1 = extendedAlerts.filter(a => a.priority === 'P1').length;
const p2 = extendedAlerts.filter(a => a.priority === 'P2').length;
const high = filteredAlerts.filter(a => a.risk_value >= 0.8).length;
const mediumHigh = filteredAlerts.filter(a => a.risk_value >= 0.6 && a.risk_value < 0.8).length;
const medium = filteredAlerts.filter(a => a.risk_value >= 0.4 && a.risk_value < 0.6).length;
const avgRisk = filteredAlerts.length > 0
? filteredAlerts.reduce((s, a) => s + a.risk_value, 0) / filteredAlerts.length
: 0;
// p1/p2 reflect the full (unfiltered) alert set
let p1 = 0;
let p2 = 0;
for (const a of extendedAlerts) {
if (a.priority === 'P1') p1++;
else if (a.priority === 'P2') p2++;
}
// Single pass over filteredAlerts: counters + sum + district map
let high = 0;
let mediumHigh = 0;
let medium = 0;
let sum = 0;
const byDistrict: Record<string, number> = {};
for (const a of filteredAlerts) {
const v = a.risk_value;
if (v >= 0.8) high++;
else if (v >= 0.6) mediumHigh++;
else if (v >= 0.4) medium++;
sum += v;
const d = a.region || '未知';
byDistrict[d] = (byDistrict[d] || 0) + 1;
}
const avgRisk = filteredAlerts.length > 0 ? sum / filteredAlerts.length : 0;
const topDistricts = Object.entries(byDistrict)
.sort((a, b) => b[1] - a[1])
.slice(0, 5);
@@ -122,6 +167,17 @@ export function AlertsDashboard() {
return filteredAlerts.find(a => a.alert_id === selectedAlert);
}, [filteredAlerts, selectedAlert]);
// Severity donut data (P1/P2) for the 风险统计 tab
const alertPie = useMemo(() => ([
{ name: 'P1 (紧急)', value: riskStats.p1, color: '#ef4444' },
{ name: 'P2 (关注)', value: riskStats.p2, color: '#f59e0b' },
]), [riskStats.p1, riskStats.p2]);
const topDistrictMax = useMemo(
() => riskStats.topDistricts.reduce((m, [, n]) => Math.max(m, n), 0),
[riskStats.topDistricts],
);
const selectedGridId = useMemo(() => {
if (!selectedAlert) return null;
const alert = filteredAlerts.find(a => a.alert_id === selectedAlert);
@@ -184,20 +240,6 @@ export function AlertsDashboard() {
URL.revokeObjectURL(url);
}, [filteredAlerts]);
const nearestGrid = useMemo(() => {
if (!cellInfo || !lodGrids.length) return null;
let best: { lat: number; lon: number; risk_1d: number; risk_3d: number; risk_7d: number } | null = null;
let bestDist = Infinity;
for (const g of lodGrids) {
const d = Math.sqrt((g[0] - cellInfo.lat) ** 2 + (g[1] - cellInfo.lon) ** 2);
if (d < bestDist) {
bestDist = d;
best = { lat: g[0], lon: g[1], risk_1d: g[2] ?? 0, risk_3d: g[3] ?? 0, risk_7d: g[4] ?? 0 };
}
}
return best;
}, [cellInfo, lodGrids]);
return (
<div className={isFullscreen ? 'fixed inset-0 z-40 bg-bg-page pt-[52px] p-5' : 'p-5'}>
{error && (
@@ -209,20 +251,42 @@ export function AlertsDashboard() {
)}
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div>
<div className="flex items-center justify-between mb-4 flex-wrap gap-x-4 gap-y-2">
<div className="min-w-0">
<h1 className="font-display text-[18px] font-semibold mb-1"></h1>
<p className="text-[12px] text-text-muted">
<p className="text-[12px] text-text-muted truncate">
100m网格风险预测 · · -
</p>
</div>
<div className="flex items-center gap-3 text-[11px]">
<div className="flex items-center gap-3 text-[11px] shrink-0 flex-wrap">
<span className="text-text-muted"> <span className="font-semibold text-text-primary">{filteredAlerts.length}</span> </span>
<span className="px-2 py-1 bg-danger/10 border border-danger/20 rounded text-danger font-semibold">P1: {riskStats.p1}</span>
<span className="px-2 py-1 bg-warning/10 border border-warning/20 rounded text-warning font-semibold">P2: {riskStats.p2}</span>
</div>
</div>
{/* Tab strip — in-page, no router */}
<div className="flex gap-1 mb-4 border-b border-border">
{([
{ key: 'list', label: '预警列表' },
{ key: 'stats', label: '风险统计' },
] as const).map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`px-4 py-2 text-[13px] font-medium -mb-px border-b-2 transition-colors ${
activeTab === tab.key
? 'border-primary text-primary'
: 'border-transparent text-text-secondary hover:text-text-primary'
}`}
>
{tab.label}
</button>
))}
</div>
{activeTab === 'list' && (
<>
{/* Toolbar Row 1: Forecast + Fullscreen + Export */}
<div className="card p-3 mb-3">
<div className="flex items-center gap-3 flex-wrap">
@@ -468,7 +532,6 @@ export function AlertsDashboard() {
showAlertMarkers={showAlertMarkers}
showGrid={showGrid}
filteredAlerts={filteredAlerts}
riskRange={riskRange}
isFullscreen={isFullscreen}
/>
)}
@@ -492,15 +555,120 @@ export function AlertsDashboard() {
)}
</div>
)}
</>
)}
{activeTab === 'stats' && (
<div className="space-y-4">
{/* Risk distribution as StatCards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<StatCard label="高风险 (≥0.8)" value={riskStats.high} color="#ef4444" />
<StatCard label="中高风险 (0.6-0.8)" value={riskStats.mediumHigh} color="#f59e0b" />
<StatCard label="中风险 (0.4-0.6)" value={riskStats.medium} color="#3b82f6" />
<StatCard label="平均风险" value={`${(riskStats.avgRisk * 100).toFixed(1)}%`} />
</div>
{/* Risk trend chart (real data from /api/analysis/trend) */}
{trendLoading ? (
<div className="card p-8 text-center text-text-secondary text-[13px]">...</div>
) : trendError ? (
<div className="card p-8 text-center text-danger text-[13px]">{trendError}</div>
) : trendData.length === 0 ? (
<div className="card p-8 text-center text-text-muted text-[13px]"></div>
) : (
<StatisticalCharts
data={trendData}
showCases={false}
showRisk
height={280}
/>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Top high-risk districts bar */}
<div className="card p-4">
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
Top 5
</div>
{riskStats.topDistricts.length > 0 ? (
<div className="space-y-3">
{riskStats.topDistricts.map(([district, count]) => (
<div key={district}>
<div className="flex items-center justify-between text-[12px] mb-1">
<span className="text-text-primary font-medium">{district}</span>
<span className="text-text-muted">{count} </span>
</div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-danger rounded-full"
style={{ width: `${topDistrictMax > 0 ? (count / topDistrictMax) * 100 : 0}%` }}
/>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-text-muted text-[13px]"></div>
)}
</div>
{/* Alert severity donut (P1/P2) */}
<div className="card p-4">
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
</div>
{riskStats.p1 > 0 || riskStats.p2 > 0 ? (
<ResponsiveContainer width="100%" height={240}>
<PieChart>
<Pie
data={alertPie}
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={80}
paddingAngle={4}
dataKey="value"
nameKey="name"
>
{alertPie.map((entry) => (
<Cell key={entry.name} fill={entry.color} />
))}
</Pie>
<RechartsTooltip
contentStyle={{
backgroundColor: '#FFFFFF',
border: '1px solid #E2E8F0',
borderRadius: '8px',
fontSize: '12px',
}}
formatter={(value: number, name: string) => [value, name]}
/>
<Legend
wrapperStyle={{ fontSize: '12px' }}
formatter={(value: string) => <span className="text-text-secondary">{value}</span>}
/>
</PieChart>
</ResponsiveContainer>
) : (
<div className="text-center py-8 text-text-muted text-[13px]"></div>
)}
</div>
</div>
</div>
)}
{/* Cell info panel - shown when clicking grid cell without alert */}
{cellInfo && !selectedAlertData && nearestGrid && (
{cellInfo && !selectedAlertData && (
<div className="fixed bottom-5 left-1/2 -translate-x-1/2 bg-bg-card rounded-lg border border-border-light shadow-lg z-50 px-5 py-4 min-w-[320px]">
<div className="flex items-center justify-between mb-3">
<span className="text-[14px] font-semibold text-text-primary"></span>
<span className="text-[14px] font-semibold text-text-primary"> (100m)</span>
<button onClick={clearCellInfo} className="text-text-muted hover:text-text-primary text-[18px] leading-none">&times;</button>
</div>
<div className="space-y-2 text-[12px]">
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span className="font-mono text-text-primary">{cellInfo.grid_id}</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span className="font-mono text-text-primary">{cellInfo.lat.toFixed(4)}, {cellInfo.lon.toFixed(4)}</span>
@@ -514,15 +682,15 @@ export function AlertsDashboard() {
<div className="flex gap-3 pt-1">
<div className="flex-1 text-center p-1.5 rounded bg-bg-page">
<div className="text-[10px] text-text-muted">1</div>
<div className="font-bold text-[13px]">{(nearestGrid.risk_1d * 100).toFixed(0)}%</div>
<div className="font-bold text-[13px]">{(cellInfo.risk_1d * 100).toFixed(0)}%</div>
</div>
<div className="flex-1 text-center p-1.5 rounded bg-bg-page">
<div className="text-[10px] text-text-muted">3</div>
<div className="font-bold text-[13px]">{(nearestGrid.risk_3d * 100).toFixed(0)}%</div>
<div className="font-bold text-[13px]">{(cellInfo.risk_3d * 100).toFixed(0)}%</div>
</div>
<div className="flex-1 text-center p-1.5 rounded bg-bg-page">
<div className="text-[10px] text-text-muted">7</div>
<div className="font-bold text-[13px]">{(nearestGrid.risk_7d * 100).toFixed(0)}%</div>
<div className="font-bold text-[13px]">{(cellInfo.risk_7d * 100).toFixed(0)}%</div>
</div>
</div>
{cellInfo.nearestAlertId && (

View File

@@ -0,0 +1,46 @@
# Pages — Route-Level Views
## Pattern
Each page is a route-level component that orchestrates data fetching, state, and child components:
```tsx
export function SomeDashboard({ defaultStartDate, defaultEndDate }: SomePageProps) {
// 1. Store hooks (Zustand)
const { data, setData } = useSomeStore();
// 2. Local state
const [localState, setLocalState] = useState<Type>(initial);
// 3. Data fetching in useEffect
useEffect(() => { fetchData(); }, [deps]);
// 4. Render child components with props
return <ChildComponent data={data} onAction={handler} />;
}
```
## Page List
| Page | Store Dependencies | Key Features |
|------|-------------------|--------------|
| `MonitoringDashboard` | timeline, monitoring, disease, drilldown | Timeline player, disease filter, case map, charts |
| `AlertsDashboard` | — | Alert feed, risk map, stats |
| `DistrictComparison` | — | District-level metrics |
| `TrendAnalysis` | — | Time-series charts |
| `Insights` | — | AI-generated insights display |
| `ReportsCenter` | reports | Report generation, export |
| `Login` | — | Auth form, redirect |
## Data Fetching
- Fetch data in `useEffect`, store results in Zustand or local state
- Handle loading/error states: `<ErrorBanner>` for errors, conditional rendering for loading
## Anti-Patterns
- Don't fetch data inside child components — pages are the data boundary
- Don't call axios directly — use `services/api.ts` wrappers
- Don't pass store actions directly to components — wrap in page-level callbacks
- Don't skip error boundary handling — every page should use `<ErrorBanner>`
- Don't create pages that do the same thing as an existing page — check the list first

View File

@@ -0,0 +1,366 @@
import { Fragment, useEffect, useState, useMemo, useCallback } from 'react';
import {
BarChart,
Bar,
PieChart,
Pie,
Cell,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
import { Users, Activity } from 'lucide-react';
import { caseApi } from '@/services/api';
import { ErrorBanner } from '@/components/ErrorBanner';
import type { DemographicsResponse, AgeBin, AgeDiagnosisMatrixItem } from '@/types';
// --- Chart 3 helpers ---
const AGE_GROUPS = ['0-1', '1-3', '3-6', '6-12', '12-18'];
function buildHeatmapMatrix(data: AgeDiagnosisMatrixItem[]): {
diagnoses: string[];
matrix: number[][];
totals: number[];
} {
// Pivot: count per age_group x diagnosis
const map: Record<string, Record<string, number>> = {};
for (const ag of AGE_GROUPS) {
map[ag] = {};
}
for (const item of data) {
if (map[item.age_group] !== undefined) {
map[item.age_group][item.diagnosis] = item.inpatient;
}
}
// Collect all diagnoses and their total counts
const diagTotals: Record<string, number> = {};
for (const ag of AGE_GROUPS) {
for (const [diag, count] of Object.entries(map[ag])) {
diagTotals[diag] = (diagTotals[diag] || 0) + count;
}
}
// Top 8 diagnoses by total count
const top8 = Object.entries(diagTotals)
.sort((a, b) => b[1] - a[1])
.slice(0, 8)
.map(([diag]) => diag);
const matrix = AGE_GROUPS.map((ag) => top8.map((diag) => map[ag][diag] || 0));
const totals = top8.map((diag) => diagTotals[diag]);
return { diagnoses: top8, matrix, totals };
}
function getColorClass(value: number, maxValue: number): string {
if (maxValue === 0) return 'bg-blue-50 text-gray-800';
const ratio = value / maxValue;
if (ratio === 0) return 'bg-blue-50 text-gray-800';
if (ratio <= 0.125) return 'bg-blue-100 text-gray-800';
if (ratio <= 0.25) return 'bg-blue-200 text-gray-800';
if (ratio <= 0.375) return 'bg-blue-300 text-gray-800';
if (ratio <= 0.5) return 'bg-blue-400 text-white';
if (ratio <= 0.625) return 'bg-blue-500 text-white';
if (ratio <= 0.75) return 'bg-blue-600 text-white';
if (ratio <= 0.875) return 'bg-blue-700 text-white';
return 'bg-blue-800 text-white';
}
export function DemographicAnalysis() {
const [data, setData] = useState<DemographicsResponse | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchData = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const result = await caseApi.getDemographics();
setData(result);
} catch (err) {
setError(err instanceof Error ? err.message : '数据加载失败');
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
let cancelled = false;
const run = async () => {
setIsLoading(true);
setError(null);
try {
const result = await caseApi.getDemographics();
if (!cancelled) setData(result);
} catch (err) {
if (!cancelled) setError(err instanceof Error ? err.message : '数据加载失败');
} finally {
if (!cancelled) setIsLoading(false);
}
};
run();
return () => {
cancelled = true;
};
}, []);
// --- Derived data ---
const ageData: AgeBin[] = useMemo(() => {
if (!data) return [];
return data.age_distribution.map((d) => ({
age_bin: d.age_bin,
outpatient: d.outpatient,
inpatient: d.inpatient,
}));
}, [data]);
const genderData = useMemo(() => {
if (!data) return [];
const male = data.gender_split.male.inpatient;
const female = data.gender_split.female.inpatient;
return [
{ name: '男性', value: male, color: '#3B82F6' },
{ name: '女性', value: female, color: '#EC4899' },
];
}, [data]);
const genderTotal = useMemo(() => {
return genderData.reduce((s, d) => s + d.value, 0);
}, [genderData]);
const heatmapData = useMemo(() => {
if (!data) return { diagnoses: [], matrix: [], totals: [] };
return buildHeatmapMatrix(data.age_diagnosis_matrix);
}, [data]);
const heatmapMax = useMemo(() => {
let max = 0;
for (const row of heatmapData.matrix) {
for (const v of row) {
if (v > max) max = v;
}
}
return max;
}, [heatmapData]);
// --- Loading state ---
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
</div>
);
}
const isEmpty =
!data ||
(ageData.length === 0 &&
genderData.length === 0 &&
heatmapData.matrix.length === 0);
return (
<div className="flex flex-col h-full overflow-auto">
{error && (
<div className="px-6 pt-4">
<ErrorBanner
error={error}
onRetry={fetchData}
onDismiss={() => setError(null)}
/>
</div>
)}
<div className="p-6 space-y-6">
{/* Page header */}
<div>
<h1 className="font-display text-[18px] font-semibold mb-1 flex items-center gap-2">
<Users className="w-5 h-5 text-primary" />
</h1>
<p className="text-[12px] text-gray-500"></p>
</div>
{isEmpty ? (
<div className="card p-8 text-center text-gray-400 text-sm">
<Activity className="w-8 h-8 mx-auto mb-3 opacity-30" />
</div>
) : (
<>
{/* Chart 1: Age Distribution Histogram */}
<div className="card p-4">
<div className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-1">
</div>
<p className="text-[11px] text-gray-400 mb-4">
</p>
{ageData.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<BarChart
data={ageData}
margin={{ top: 5, right: 10, left: 0, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
<XAxis
dataKey="age_bin"
tick={{ fontSize: 11, fill: '#64748B' }}
axisLine={{ stroke: '#E2E8F0' }}
label={{ value: '年龄(岁)', position: 'insideBottom', offset: -5, fontSize: 11, fill: '#64748B' }}
/>
<YAxis
tick={{ fontSize: 11, fill: '#64748B' }}
axisLine={{ stroke: '#E2E8F0' }}
/>
<Tooltip
contentStyle={{
backgroundColor: '#FFFFFF',
border: '1px solid #E2E8F0',
borderRadius: '8px',
fontSize: '12px',
}}
formatter={(value: number) => [value.toLocaleString(), '住院病例']}
labelFormatter={(label: number) => `${label}`}
/>
<Bar dataKey="inpatient" fill="#3B82F6" name="住院" barSize={28} radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
) : (
<div className="text-center py-8 text-gray-400 text-sm"></div>
)}
</div>
{/* Chart 2: Gender Distribution Donut */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="card p-4">
<div className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-4">
</div>
{genderData.length > 0 && genderTotal > 0 ? (
<div className="relative">
<ResponsiveContainer width="100%" height={280}>
<PieChart>
<Pie
data={genderData}
cx="50%"
cy="50%"
innerRadius={55}
outerRadius={90}
paddingAngle={3}
dataKey="value"
nameKey="name"
>
{genderData.map((entry, idx) => (
<Cell key={idx} fill={entry.color} />
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: '#FFFFFF',
border: '1px solid #E2E8F0',
borderRadius: '8px',
fontSize: '12px',
}}
formatter={(value: number, name: string) => [
`${value.toLocaleString()} (${((value / genderTotal) * 100).toFixed(1)}%)`,
name,
]}
/>
</PieChart>
</ResponsiveContainer>
{/* Center label */}
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="text-center">
<div className="text-[24px] font-semibold text-gray-800">
{genderTotal.toLocaleString()}
</div>
<div className="text-[11px] text-gray-500"></div>
</div>
</div>
{/* Legend below */}
<div className="flex justify-center gap-6 mt-2">
{genderData.map((d) => (
<div key={d.name} className="flex items-center gap-2 text-[13px] text-gray-700">
<span
className="w-3 h-3 rounded-full inline-block"
style={{ backgroundColor: d.color }}
/>
{d.name}: {d.value.toLocaleString()} ({((d.value / genderTotal) * 100).toFixed(1)}%)
</div>
))}
</div>
</div>
) : (
<div className="text-center py-8 text-gray-400 text-sm"></div>
)}
</div>
{/* Placeholder for future chart or spacing */}
<div className="hidden lg:block" />
</div>
{/* Chart 3: Age x Diagnosis Heatmap */}
<div className="card p-4">
<div className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-1">
×
</div>
<p className="text-[11px] text-gray-400 mb-4">
8
</p>
{heatmapData.matrix.length > 0 && heatmapData.diagnoses.length > 0 ? (
<div className="overflow-x-auto">
<div
className="grid gap-px bg-gray-200 border border-gray-200 rounded-lg overflow-hidden"
style={{
gridTemplateColumns: `80px repeat(${heatmapData.diagnoses.length}, minmax(60px, 1fr))`,
}}
>
{/* Header row */}
<div className="bg-gray-100 px-2 py-2 text-[11px] font-medium text-gray-600">
</div>
{heatmapData.diagnoses.map((diag) => (
<div
key={diag}
className="bg-gray-100 px-2 py-2 text-[11px] font-medium text-gray-600 text-center"
title={diag}
>
{diag.length > 6 ? `${diag.slice(0, 6)}` : diag}
</div>
))}
{/* Data rows */}
{AGE_GROUPS.map((ag, rowIdx) => (
<Fragment key={ag}>
<div
key={`label-${ag}`}
className="bg-white px-2 py-2 text-[12px] text-gray-700 font-medium flex items-center"
>
{ag}
</div>
{heatmapData.matrix[rowIdx].map((value, colIdx) => (
<div
key={`${ag}-${colIdx}`}
className={`${getColorClass(value, heatmapMax)} px-2 py-2 text-center text-[12px] font-medium transition-colors cursor-default`}
title={`${ag}岁 | ${heatmapData.diagnoses[colIdx]}: ${value}`}
>
{value > 0 ? value.toLocaleString() : '-'}
</div>
))}
</Fragment>
))}
</div>
</div>
) : (
<div className="text-center py-8 text-gray-400 text-sm"></div>
)}
</div>
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,501 @@
import { useEffect, useState, Fragment } from 'react';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
Cell,
ReferenceLine,
} from 'recharts';
import { Stethoscope, Activity } from 'lucide-react';
import { caseApi } from '@/services/api';
import { ErrorBanner } from '@/components/ErrorBanner';
import type {
DiagnosisDistributionItem,
DiseaseSeasonalityPoint,
DistrictCaseData,
} from '@/types';
// --- Helpers ---
function truncate(s: string, max: number): string {
return s.length > max ? s.slice(0, max) + '…' : s;
}
/** Blue intensity scale: 0 -> #EFF6FF, max -> #1D4ED8 */
function blueIntensity(value: number, domainMin: number, domainMax: number): string {
if (domainMax === domainMin) return '#3B82F6';
const norm = (value - domainMin) / (domainMax - domainMin);
const r = Math.round(29 + (239 - 29) * (1 - norm));
const g = Math.round(78 + (246 - 78) * (1 - norm));
const b = Math.round(216 + (255 - 216) * (1 - norm));
return `rgb(${r},${g},${b})`;
}
// --- Chart 1: Diagnosis Distribution (Horizontal Percentage Bar) ---
function DiagnosisDistributionChart({ data }: { data: DiagnosisDistributionItem[] }) {
if (!data || data.length === 0) {
return <div className="text-center py-8 text-gray-400 text-sm"></div>;
}
const sorted = [...data].sort((a, b) => b.total - a.total);
const grandTotal = sorted.reduce((s, d) => s + d.total, 0);
const chartData = sorted.map((d) => ({
...d,
displayName: truncate(d.diagnosis, 8),
outpatientPct: grandTotal > 0 ? (d.outpatient / grandTotal) * 100 : 0,
inpatientPct: grandTotal > 0 ? (d.inpatient / grandTotal) * 100 : 0,
}));
return (
<ResponsiveContainer width="100%" height={400}>
<BarChart
data={[...chartData].reverse()}
layout="vertical"
margin={{ top: 5, right: 20, left: 40, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" horizontal={false} />
<XAxis
type="number"
tick={{ fontSize: 10, fill: '#64748B' }}
tickFormatter={(v) => `${v.toFixed(1)}%`}
/>
<YAxis
type="category"
dataKey="displayName"
tick={{ fontSize: 10, fill: '#374151' }}
width={70}
axisLine={false}
tickLine={false}
/>
<Tooltip
contentStyle={{
backgroundColor: '#FFFFFF',
border: '1px solid #E2E8F0',
borderRadius: '8px',
fontSize: '12px',
}}
formatter={(value: number, name: string) => {
const label = name === 'outpatientPct' ? '门诊占总病例比' : '住院占总病例比';
return [`${value.toFixed(1)}%`, label];
}}
/>
<Legend
wrapperStyle={{ fontSize: '11px' }}
payload={[
{ value: '门诊', type: 'rect', color: '#3B82F6' },
{ value: '住院', type: 'rect', color: '#EF4444' },
]}
/>
<Bar dataKey="outpatientPct" stackId="a" fill="#3B82F6" name="outpatientPct" barSize={18} />
<Bar dataKey="inpatientPct" stackId="a" fill="#EF4444" name="inpatientPct" barSize={18} />
</BarChart>
</ResponsiveContainer>
);
}
// --- Chart 2: Diagnosis Seasonality Heatmap ---
function SeasonalityHeatmap({ data }: { data: DiseaseSeasonalityPoint[] }) {
if (!data || data.length === 0) {
return <div className="text-center py-8 text-gray-400 text-sm"></div>;
}
// Get unique diagnoses (top 10 by total) and months that actually have cases.
// Backend always emits all 12 months (zero-filled), so only count months
// where some diagnosis has total > 0 to detect genuine single-month coverage.
const diagTotals = new Map<string, number>();
const monthSet = new Set<number>();
for (const d of data) {
diagTotals.set(d.diagnosis, (diagTotals.get(d.diagnosis) || 0) + d.total);
if (d.total > 0) monthSet.add(d.month);
}
const topDiags = [...diagTotals.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([name]) => name);
const months = [...monthSet].sort((a, b) => a - b);
const uniqueMonths = months.length;
// Check if only one month has data
const singleMonthNote = uniqueMonths <= 1;
// Build lookup: diagnosis -> month -> total
const lookup = new Map<string, Map<number, number>>();
for (const d of data) {
if (!lookup.has(d.diagnosis)) lookup.set(d.diagnosis, new Map());
lookup.get(d.diagnosis)!.set(d.month, d.total);
}
// Find min/max for color scale
let minVal = Infinity;
let maxVal = -Infinity;
for (const d of data) {
if (d.total < minVal) minVal = d.total;
if (d.total > maxVal) maxVal = d.total;
}
if (minVal === Infinity) minVal = 0;
if (maxVal === -Infinity) maxVal = 0;
const monthLabels = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'];
return (
<div>
{singleMonthNote && (
<div className="mb-3 text-xs text-amber-600 bg-amber-50 border border-amber-200 rounded px-3 py-2">
</div>
)}
<div
className="grid gap-px bg-gray-200 border border-gray-200 rounded overflow-hidden"
style={{
gridTemplateColumns: `minmax(90px, auto) repeat(${uniqueMonths > 0 ? uniqueMonths : 12}, 1fr)`,
}}
>
{/* Header row */}
<div className="bg-gray-100 text-[11px] font-medium text-gray-600 px-2 py-2 text-center">
</div>
{(uniqueMonths > 0 ? months : Array.from({ length: 12 }, (_, i) => i + 1)).map((m) => (
<div
key={m}
className="bg-gray-100 text-[10px] font-medium text-gray-600 px-1 py-2 text-center"
>
{monthLabels[m - 1]}
</div>
))}
{/* Data rows */}
{topDiags.map((diag) => (
<Fragment key={diag}>
<div className="bg-white text-[11px] text-gray-700 px-2 py-2 flex items-center truncate">
{truncate(diag, 10)}
</div>
{(uniqueMonths > 0 ? months : Array.from({ length: 12 }, (_, i) => i + 1)).map((m) => {
const val = lookup.get(diag)?.get(m) ?? 0;
const isZero = val === 0;
return (
<div
key={m}
className="text-[10px] font-medium text-center py-2 px-1"
style={{
backgroundColor: isZero ? '#F9FAFB' : blueIntensity(val, minVal, maxVal),
color: isZero ? '#D1D5DB' : val > (maxVal * 0.7) ? '#FFFFFF' : '#1E293B',
}}
>
{val > 0 ? val.toLocaleString() : '-'}
</div>
);
})}
</Fragment>
))}
</div>
</div>
);
}
// --- Chart 3: O/I Ratio by Diagnosis ---
function OIRatioChart({
data,
avgRatio,
}: {
data: DiagnosisDistributionItem[];
avgRatio: number;
}) {
if (!data || data.length === 0) {
return <div className="text-center py-8 text-gray-400 text-sm"></div>;
}
const chartData = [...data]
.map((d) => ({
diagnosis: d.diagnosis,
displayName: truncate(d.diagnosis, 8),
// inpatient>0: real O/I ratio (0 = inpatient-only = most severe).
// inpatient=0, outpatient>0: outpatient-only (no inpatient) -> Infinity, no data.
// both 0: no data -> NaN.
ratio: d.inpatient > 0 ? d.outpatient / d.inpatient : d.outpatient > 0 ? Infinity : NaN,
outpatient: d.outpatient,
inpatient: d.inpatient,
}))
// Keep inpatient-only diagnoses (ratio 0, most severe); only drop no-data rows.
.filter((d) => Number.isFinite(d.ratio))
.sort((a, b) => a.ratio - b.ratio)
.slice(0, 15);
const maxRatio = Math.max(...chartData.map((d) => d.ratio), avgRatio);
return (
<div>
<div className="text-[11px] text-gray-500 mb-3">
O/I比率<span className="font-semibold text-gray-700">{avgRatio.toFixed(1)}</span>
<span className="ml-2 text-[10px] text-gray-400">
O/I比率越低说明疾病越严重
</span>
</div>
<ResponsiveContainer width="100%" height={400}>
<BarChart
data={[...chartData].reverse()}
layout="vertical"
margin={{ top: 5, right: 20, left: 50, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" horizontal={false} />
<XAxis
type="number"
domain={[0, maxRatio * 1.15]}
tick={{ fontSize: 10, fill: '#64748B' }}
tickFormatter={(v) => v.toFixed(1)}
/>
<YAxis
type="category"
dataKey="displayName"
tick={{ fontSize: 10, fill: '#374151' }}
width={70}
axisLine={false}
tickLine={false}
/>
<Tooltip
contentStyle={{
backgroundColor: '#FFFFFF',
border: '1px solid #E2E8F0',
borderRadius: '8px',
fontSize: '12px',
}}
formatter={(value: number, name: string) => {
if (name === 'ratio') return [value.toFixed(1), 'O/I比率'];
return [value, ''];
}}
/>
<ReferenceLine x={avgRatio} stroke="#F59E0B" strokeDasharray="6 4" strokeWidth={1.5} />
<Bar dataKey="ratio" name="ratio" barSize={16}>
{chartData.map((entry, idx) => (
<Cell
key={idx}
fill={entry.ratio >= avgRatio ? '#22C55E' : '#EF4444'}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
<div className="flex items-center gap-4 mt-2 text-[10px] text-gray-400 px-4">
<span className="flex items-center gap-1">
<span className="w-3 h-3 bg-green-500 rounded-sm" /> =
</span>
<span className="flex items-center gap-1">
<span className="w-3 h-3 bg-red-500 rounded-sm" /> =
</span>
</div>
</div>
);
}
// --- Chart 4: Diagnosis Summary Table ---
function DiagnosisSummaryTable({
diagnoses,
districtsData,
}: {
diagnoses: DiagnosisDistributionItem[];
districtsData: DistrictCaseData[];
}) {
if (!diagnoses || diagnoses.length === 0) {
return <div className="text-center py-8 text-gray-400 text-sm"></div>;
}
const top5 = diagnoses.slice(0, 5);
// Per-diagnosis top district is not exposed by the API; show the city-wide
// highest-caseload district honestly (same for every row, labeled as such).
const topDistrict = districtsData.length > 0
? districtsData.reduce((best, d) => (d.total > best.total ? d : best), districtsData[0]).district
: '--';
// Color for total column: green (low) -> yellow (mid) -> red (high)
function totalColor(total: number, allTotals: number[]): string {
if (allTotals.length === 0) return '#9CA3AF';
const mn = Math.min(...allTotals);
const mx = Math.max(...allTotals);
if (mx === mn) return '#22C55E';
const norm = (total - mn) / (mx - mn);
if (norm < 0.33) return '#22C55E';
if (norm < 0.66) return '#EAB308';
return '#EF4444';
}
const totals = top5.map((d) => d.total);
return (
<div className="overflow-x-auto">
<table className="w-full text-[12px] border-collapse">
<thead>
<tr className="border-b border-gray-200">
<th className="text-left px-3 py-2 text-[11px] font-medium text-gray-500 uppercase tracking-wide">
</th>
<th className="text-right px-3 py-2 text-[11px] font-medium text-gray-500 uppercase tracking-wide">
</th>
<th className="text-right px-3 py-2 text-[11px] font-medium text-gray-500 uppercase tracking-wide">
</th>
<th className="text-right px-3 py-2 text-[11px] font-medium text-gray-500 uppercase tracking-wide">
</th>
<th className="text-right px-3 py-2 text-[11px] font-medium text-gray-500 uppercase tracking-wide">
</th>
</tr>
</thead>
<tbody>
{top5.map((d) => (
<tr key={d.diagnosis} className="border-b border-gray-100 hover:bg-gray-50">
<td className="px-3 py-2.5 text-gray-700">{d.diagnosis}</td>
<td className="px-3 py-2.5 text-right text-blue-600">{d.outpatient.toLocaleString()}</td>
<td className="px-3 py-2.5 text-right text-red-600">{d.inpatient.toLocaleString()}</td>
<td className="px-3 py-2.5 text-right font-semibold" style={{ color: totalColor(d.total, totals) }}>
{d.total.toLocaleString()}
</td>
<td className="px-3 py-2.5 text-right text-gray-500">{topDistrict}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
// --- Page Component ---
export function DiseaseAnalysis() {
const [diagDistribution, setDiagDistribution] = useState<DiagnosisDistributionItem[]>([]);
const [seasonality, setSeasonality] = useState<DiseaseSeasonalityPoint[]>([]);
const [districts, setDistricts] = useState<DistrictCaseData[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [errors, setErrors] = useState<string[]>([]);
useEffect(() => {
let cancelled = false;
const fetchAll = async () => {
setIsLoading(true);
setErrors([]);
const [distR, seasonR, districtR] = await Promise.allSettled([
caseApi.getDiagnosisDistribution(15),
caseApi.getDiseaseSeasonality(),
caseApi.getDistricts(),
]);
if (cancelled) return;
const newErrors: string[] = [];
if (distR.status === 'fulfilled') {
setDiagDistribution(distR.value.diagnoses || []);
} else {
newErrors.push('诊断分布数据加载失败');
}
if (seasonR.status === 'fulfilled') {
setSeasonality(seasonR.value.seasonality || []);
} else {
newErrors.push('季节性数据加载失败');
}
if (districtR.status === 'fulfilled') {
setDistricts(districtR.value.districts || []);
} else {
newErrors.push('区县数据加载失败');
}
setErrors(newErrors);
setIsLoading(false);
};
fetchAll();
return () => {
cancelled = true;
};
}, []);
// --- Loading ---
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
</div>
);
}
// Compute average O/I ratio
const totalOut = diagDistribution.reduce((s, d) => s + d.outpatient, 0);
const totalIn = diagDistribution.reduce((s, d) => s + d.inpatient, 0);
const avgOIRatio = totalIn > 0 ? totalOut / totalIn : 0;
return (
<div className="flex flex-col h-full overflow-auto">
{errors.length > 0 && (
<div className="px-6 pt-4">
<ErrorBanner
error={errors.join('')}
onRetry={() => window.location.reload()}
onDismiss={() => setErrors([])}
/>
</div>
)}
<div className="p-6 space-y-6">
{/* Page header */}
<div>
<h1 className="font-display text-[18px] font-semibold mb-1 flex items-center gap-2">
<Stethoscope className="w-5 h-5 text-primary" />
</h1>
<p className="text-[12px] text-gray-500">/</p>
</div>
{/* Chart 1: Diagnosis Distribution */}
<div className="card p-4">
<div className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-4">
/
</div>
<DiagnosisDistributionChart data={diagDistribution} />
</div>
{/* Chart 2: Seasonality Heatmap */}
<div className="card p-4">
<div className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-4">
</div>
<SeasonalityHeatmap data={seasonality} />
</div>
{/* Chart 3: O/I Ratio */}
<div className="card p-4">
<div className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-4">
/O/I Ratio
</div>
<OIRatioChart data={diagDistribution} avgRatio={avgOIRatio} />
</div>
{/* Chart 4: Diagnosis Summary Table */}
<div className="card p-4">
<div className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-4 flex items-center gap-2">
<Activity className="w-3.5 h-3.5 text-gray-400" />
Top 5
</div>
<DiagnosisSummaryTable diagnoses={diagDistribution} districtsData={districts} />
</div>
</div>
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import {
BarChart,
Bar,
@@ -8,10 +8,14 @@ import {
Tooltip,
ResponsiveContainer,
Cell,
ReferenceLine,
} from 'recharts';
import { useAnalysisStore } from '@/stores/analysisStore';
import { caseApi } from '@/services/api';
import { ErrorBanner } from '@/components/ErrorBanner';
import { MetricHeatmapTable } from '@/components/MetricHeatmapTable';
import { BarChart3, MapPin, Users, Shield } from 'lucide-react';
import type { DistrictCaseData } from '@/types';
const COLORS = ['#DC2626', '#D97706', '#2563EB', '#059669', '#7C3AED', '#0891B2', '#EA580C', '#84CC16'];
@@ -28,11 +32,34 @@ export function DistrictComparison() {
const clearError = useAnalysisStore((s) => s.clearError);
const fetchDistricts = useAnalysisStore((s) => s.fetchDistricts);
const [metric, setMetric] = useState<'avg_aqi' | 'avg_risk' | 'high_risk_count'>('avg_aqi');
const [caseDistrictData, setCaseDistrictData] = useState<DistrictCaseData[]>([]);
const [caseDataLoading, setCaseDataLoading] = useState(false);
const [caseDataError, setCaseDataError] = useState<string | null>(null);
useEffect(() => {
fetchDistricts();
}, [fetchDistricts]);
useEffect(() => {
let cancelled = false;
setCaseDataLoading(true);
setCaseDataError(null);
caseApi.getDistricts()
.then((res) => {
if (!cancelled) {
setCaseDistrictData(res.districts || []);
setCaseDataLoading(false);
}
})
.catch((e) => {
if (!cancelled) {
setCaseDataError((e as Error).message || '加载病例数据失败');
setCaseDataLoading(false);
}
});
return () => { cancelled = true; };
}, []);
const metricConfig = {
avg_aqi: { label: '平均AQI', color: '#2563EB', unit: '' },
avg_risk: { label: '平均风险', color: '#DC2626', unit: '' },
@@ -45,6 +72,54 @@ export function DistrictComparison() {
return bVal - aVal;
});
const populationNormalizedData = useMemo(() => {
const data = districtData
.filter((d) => d.population > 0)
.map((d) => ({
district: d.district,
casesPer100K: Math.round((d.total_cases / d.population) * 100000),
}))
.sort((a, b) => b.casesPer100K - a.casesPer100K);
const filtered = districtData.filter((d) => d.population > 0);
const totalCases = filtered.reduce((sum, d) => sum + d.total_cases, 0);
const totalPop = filtered.reduce((sum, d) => sum + d.population, 0);
const cityAvg = totalPop > 0 ? Math.round((totalCases / totalPop) * 100000) : 0;
return { data, cityAvg, totalCases, totalPop };
}, [districtData]);
const oiRatioData = useMemo(() => {
const data = caseDistrictData
.map((d) => ({
district: d.district,
ratio: d.outpatient_ratio && d.inpatient_ratio && d.inpatient_ratio > 0
? Number((d.outpatient_ratio / d.inpatient_ratio).toFixed(2))
: 0,
}))
.filter((d) => d.ratio > 0)
.sort((a, b) => b.ratio - a.ratio);
const cityOIAvg = data.length > 0
? Number((data.reduce((sum, d) => sum + d.ratio, 0) / data.length).toFixed(2))
: 0;
return { data, cityOIAvg };
}, [caseDistrictData]);
const heatmapMetrics = useMemo(() => {
const caseMap = new Map(caseDistrictData.map((d) => [d.district, d]));
const rows: string[] = [];
const map: Record<string, Record<string, number>> = {};
for (const d of districtData) {
rows.push(d.district);
const c = caseMap.get(d.district);
map[d.district] = {
total_cases: d.total_cases,
outpatient_ratio: c?.outpatient_ratio ?? 0,
avg_risk: Math.round(d.avg_risk * 100),
high_risk_count: d.high_risk_count,
};
}
return { rows, map };
}, [districtData, caseDistrictData]);
const getRiskLevel = (risk: number) => {
if (risk >= 0.7) return 'high';
if (risk >= 0.4) return 'medium';
@@ -60,6 +135,25 @@ export function DistrictComparison() {
onDismiss={clearError}
/>
)}
{caseDataError && (
<ErrorBanner
error={caseDataError}
onRetry={() => {
setCaseDataError(null);
setCaseDataLoading(true);
caseApi.getDistricts()
.then((res) => {
setCaseDistrictData(res.districts || []);
setCaseDataLoading(false);
})
.catch((e) => {
setCaseDataError((e as Error).message || '加载病例数据失败');
setCaseDataLoading(false);
});
}}
onDismiss={() => setCaseDataError(null)}
/>
)}
<div className="mb-5">
<h1 className="font-display text-[18px] font-semibold mb-1 flex items-center gap-2">
<BarChart3 className="w-5 h-5 text-primary" />
@@ -227,6 +321,149 @@ export function DistrictComparison() {
</div>
))}
</div>
{/* Population-Normalized Rates Bar Chart */}
<div className="card p-4 mb-4">
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
10
</div>
{populationNormalizedData.data.length > 0 ? (
<ResponsiveContainer width="100%" height={350}>
<BarChart
data={populationNormalizedData.data}
margin={{ top: 5, right: 20, left: 10, bottom: 5 }}
layout="vertical"
>
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" horizontal={false} />
<XAxis
type="number"
tick={{ fontSize: 12, fill: '#64748B' }}
axisLine={{ stroke: '#E2E8F0' }}
label={{ value: '每10万人病例数', position: 'insideBottom', offset: -5, fontSize: 12, fill: '#64748B' }}
/>
<YAxis
type="category"
dataKey="district"
tick={{ fontSize: 12, fill: '#1E293B', fontWeight: 500 }}
axisLine={{ stroke: '#E2E8F0' }}
width={80}
/>
<Tooltip
contentStyle={{
backgroundColor: '#FFFFFF',
border: '1px solid #E2E8F0',
borderRadius: '8px',
fontSize: '12px',
}}
formatter={(value: number) => [
`${value.toLocaleString()} 每10万人`,
'病例率',
]}
/>
<ReferenceLine
x={populationNormalizedData.cityAvg}
stroke="#64748B"
strokeDasharray="6 4"
label={{
value: `全市平均: ${populationNormalizedData.cityAvg.toLocaleString()}`,
position: 'top',
fontSize: 11,
fill: '#64748B',
}}
/>
<Bar dataKey="casesPer100K" name="病例率" radius={[0, 4, 4, 0]} maxBarSize={32} fill="#3B82F6" />
</BarChart>
</ResponsiveContainer>
) : (
<div className="text-center py-8 text-text-secondary"></div>
)}
</div>
{/* O/I Ratio Comparison Bar Chart */}
<div className="card p-4 mb-4">
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
/ (O/I Ratio)
</div>
{caseDataLoading && (
<div className="text-center py-8 text-text-secondary">...</div>
)}
{!caseDataLoading && oiRatioData.data.length > 0 ? (
<ResponsiveContainer width="100%" height={350}>
<BarChart
data={oiRatioData.data}
margin={{ top: 5, right: 20, left: 10, bottom: 5 }}
layout="vertical"
>
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" horizontal={false} />
<XAxis
type="number"
tick={{ fontSize: 12, fill: '#64748B' }}
axisLine={{ stroke: '#E2E8F0' }}
label={{ value: 'O/I Ratio', position: 'insideBottom', offset: -5, fontSize: 12, fill: '#64748B' }}
/>
<YAxis
type="category"
dataKey="district"
tick={{ fontSize: 12, fill: '#1E293B', fontWeight: 500 }}
axisLine={{ stroke: '#E2E8F0' }}
width={80}
/>
<Tooltip
contentStyle={{
backgroundColor: '#FFFFFF',
border: '1px solid #E2E8F0',
borderRadius: '8px',
fontSize: '12px',
}}
formatter={(value: number) => [
value.toFixed(2),
'O/I Ratio',
]}
/>
<ReferenceLine
x={oiRatioData.cityOIAvg}
stroke="#64748B"
strokeDasharray="6 4"
label={{
value: `全市平均: ${oiRatioData.cityOIAvg.toFixed(2)}`,
position: 'top',
fontSize: 11,
fill: '#64748B',
}}
/>
<Bar dataKey="ratio" name="O/I Ratio" radius={[0, 4, 4, 0]} maxBarSize={32}>
{oiRatioData.data.map((entry) => (
<Cell
key={entry.district}
fill={entry.ratio >= oiRatioData.cityOIAvg ? '#10B981' : '#EF4444'}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
) : (
!caseDataLoading && (
<div className="text-center py-8 text-text-secondary"></div>
)
)}
</div>
{/* District Metric Heatmap Table */}
<div className="card p-4 mb-4">
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
</div>
<MetricHeatmapTable
rows={heatmapMetrics.rows}
columns={[
{ key: 'total_cases', label: '总病例' },
{ key: 'outpatient_ratio', label: '门诊占比(%)' },
{ key: 'avg_risk', label: '平均风险(%)' },
{ key: 'high_risk_count', label: '高风险网格' },
]}
data={heatmapMetrics.map}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,712 @@
import { useEffect, useState, useMemo } from 'react';
import {
LineChart,
Line,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
ReferenceLine,
Cell,
} from 'recharts';
import { Wind } from 'lucide-react';
import { envApi, caseApi } from '@/services/api';
import { ErrorBanner } from '@/components/ErrorBanner';
import { CalendarHeatmap } from '@/components/CalendarHeatmap';
import type {
LagCorrelationItem,
PollutantPoint,
CaseTrendPoint,
} from '@/types';
// --- Constants ---
const POLLUTANT_OPTIONS = [
{ key: 'PM25', label: 'PM2.5', color: '#DC2626', unit: 'μg/m³' },
{ key: 'PM10', label: 'PM10', color: '#D97706', unit: 'μg/m³' },
{ key: 'SO2', label: 'SO₂', color: '#7C3AED', unit: 'μg/m³' },
{ key: 'NO2', label: 'NO₂', color: '#059669', unit: 'μg/m³' },
{ key: 'O3', label: 'O₃', color: '#EA580C', unit: 'μg/m³' },
{ key: 'CO', label: 'CO', color: '#0891B2', unit: 'mg/m³' },
];
const AQI_CATEGORIES = [
{ label: '优', range: [0, 50] as [number, number], color: '#10B981' },
{ label: '良', range: [50, 100] as [number, number], color: '#F59E0B' },
{ label: '轻度污染', range: [100, 150] as [number, number], color: '#F97316' },
{ label: '中度污染', range: [150, 200] as [number, number], color: '#EF4444' },
{ label: '重度污染', range: [200, 9999] as [number, number], color: '#7C3AED' },
];
const LAG_DAYS = [1, 2, 3, 5, 7, 14];
// --- Helpers ---
function formatDateLabel(dateStr: string): string {
const d = new Date(dateStr);
return `${d.getMonth() + 1}/${d.getDate()}`;
}
function getAQICategory(aqi: number): typeof AQI_CATEGORIES[number] {
for (const cat of AQI_CATEGORIES) {
if (aqi >= cat.range[0] && aqi < cat.range[1]) return cat;
}
return AQI_CATEGORIES[AQI_CATEGORIES.length - 1];
}
function findMaxLag(correlations: LagCorrelationItem[], pollutant: string): number | null {
if (correlations.length === 0) return null;
const pollData = correlations.filter(
(c) => c.pollutant === pollutant && c.lag_days > 0,
);
if (pollData.length === 0) return null;
let maxAbs = 0;
let bestLag = 0;
for (const c of pollData) {
if (Math.abs(c.correlation) > Math.abs(maxAbs)) {
maxAbs = Math.abs(c.correlation);
bestLag = c.lag_days;
}
}
return bestLag;
}
// --- Page ---
export function EnvironmentalHealth() {
// Data states
const [lagData, setLagData] = useState<LagCorrelationItem[]>([]);
const [pollutants365, setPollutants365] = useState<PollutantPoint[]>([]);
const [pollutants30, setPollutants30] = useState<PollutantPoint[]>([]);
const [caseTrend, setCaseTrend] = useState<CaseTrendPoint[]>([]);
// UI states
const [isLoading, setIsLoading] = useState(true);
const [errors, setErrors] = useState<string[]>([]);
const [selectedPollutants, setSelectedPollutants] = useState<string[]>([
'PM25',
'PM10',
'SO2',
'NO2',
'O3',
'CO',
]);
const currentYear = new Date().getFullYear();
useEffect(() => {
let cancelled = false;
const fetchAll = async () => {
setIsLoading(true);
setErrors([]);
const [lagR, p365R, p30R, caseTrendR] = await Promise.allSettled([
envApi.getLagCorrelations(),
envApi.getPollutants(365),
envApi.getPollutants(30),
caseApi.getTrend({ group_by: 'day' }),
]);
if (cancelled) return;
const newErrors: string[] = [];
if (lagR.status === 'fulfilled') {
// Normalize pollutant keys to the canonical set used across this page
// (PollutantPoint keys): the correlation API uses "PM2.5" -> "PM25".
const normalized = (lagR.value.correlations || []).map((c) => ({
...c,
pollutant: c.pollutant === 'PM2.5' ? 'PM25' : c.pollutant,
}));
setLagData(normalized);
} else {
newErrors.push('滞后相关性数据加载失败');
}
if (p365R.status === 'fulfilled') {
setPollutants365(p365R.value.data || []);
} else {
newErrors.push('环境数据加载失败');
}
if (p30R.status === 'fulfilled') {
setPollutants30(p30R.value.data || []);
} else {
newErrors.push('30天污染物数据加载失败');
}
if (caseTrendR.status === 'fulfilled') {
setCaseTrend(caseTrendR.value.trend || []);
} else {
newErrors.push('病例趋势数据加载失败');
}
setErrors(newErrors);
setIsLoading(false);
};
fetchAll();
return () => {
cancelled = true;
};
}, []);
const togglePollutant = (key: string) => {
setSelectedPollutants((prev) =>
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
);
};
// --- Chart 1: Pollutant-Case Correlation (lag=1) ---
const lag1Correlations = useMemo(() => {
return lagData
.filter((c) => c.lag_days === 1)
.sort((a, b) => Math.abs(b.correlation) - Math.abs(a.correlation));
}, [lagData]);
// --- Chart 2: PM2.5 + PM10 lag series ---
const pmLagSeries = useMemo(() => {
return LAG_DAYS.map((lag) => {
const pm25 = lagData.find(
(c) => c.pollutant === 'PM25' && c.lag_days === lag,
);
const pm10 = lagData.find(
(c) => c.pollutant === 'PM10' && c.lag_days === lag,
);
return {
lag_days: lag,
PM25Corr: pm25?.correlation ?? 0,
PM10Corr: pm10?.correlation ?? 0,
};
});
}, [lagData]);
const maxLagInsight = useMemo(() => {
const pm25Lag = findMaxLag(lagData, 'PM25');
const pm10Lag = findMaxLag(lagData, 'PM10');
if (pm25Lag === null && pm10Lag === null) return '';
const parts: string[] = [];
if (pm25Lag !== null) parts.push(`PM2.5滞后${pm25Lag}`);
if (pm10Lag !== null) parts.push(`PM10滞后${pm10Lag}`);
return `PM2.5和PM10对健康影响的最强效应出现在${parts.join('和')}`;
}, [lagData]);
// --- Chart 3: Pollution Episode Impact ---
const episodeData = useMemo(() => {
if (pollutants365.length === 0 || caseTrend.length === 0) return [];
// Build date->AQI map from 365-day pollutants
const aqiByDate = new Map<string, number>();
for (const p of pollutants365) {
aqiByDate.set(p.date, p.AQI || 0);
}
// Build date->cases map from case trend
const casesByDate = new Map<string, number>();
for (const c of caseTrend) {
casesByDate.set(c.date, c.total);
}
// Group by AQI category
const groups: Record<string, { totalCases: number; dayCount: number }> = {};
for (const cat of AQI_CATEGORIES) {
groups[cat.label] = { totalCases: 0, dayCount: 0 };
}
// Only count dates that exist in BOTH series. Dates with no matching case
// record are gaps, not zero-case days, and would otherwise drag averages down.
for (const [date, aqi] of aqiByDate) {
const cases = casesByDate.get(date);
if (cases === undefined) continue;
const cat = getAQICategory(aqi);
groups[cat.label].totalCases += cases;
groups[cat.label].dayCount += 1;
}
return AQI_CATEGORIES.map((cat) => ({
category: cat.label,
avgCases:
groups[cat.label].dayCount > 0
? Math.round(groups[cat.label].totalCases / groups[cat.label].dayCount)
: 0,
fill: cat.color,
}));
}, [pollutants365, caseTrend]);
// --- Chart 4: AQI Calendar Heatmap ---
const heatmapData = useMemo(() => {
return pollutants365.map((p) => ({
date: p.date,
value: p.AQI || 0,
}));
}, [pollutants365]);
// Derive the calendar year from the data (data is historical, not current year).
const heatmapYear = useMemo(() => {
if (pollutants365.length === 0) return currentYear;
const last = new Date(pollutants365[pollutants365.length - 1].date);
return isNaN(last.getTime()) ? currentYear : last.getFullYear();
}, [pollutants365, currentYear]);
// --- Chart 5: Multi-Pollutant Time Series ---
const timeSeriesData = useMemo(() => {
return pollutants30.map((p) => ({
date: p.date,
PM25: p.PM25,
PM10: p.PM10,
SO2: p.SO2,
NO2: p.NO2,
O3: p.O3,
CO: p.CO,
}));
}, [pollutants30]);
// --- Loading state ---
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
</div>
);
}
return (
<div className="flex flex-col h-full overflow-auto">
{/* Error banner */}
{errors.length > 0 && (
<div className="px-6 pt-4">
<ErrorBanner
error={errors.join('')}
onRetry={() => window.location.reload()}
onDismiss={() => setErrors([])}
/>
</div>
)}
<div className="p-6 space-y-6">
{/* Page header */}
<div>
<h1 className="font-display text-[18px] font-semibold mb-1 flex items-center gap-2">
<Wind className="w-5 h-5 text-primary" />
</h1>
<p className="text-[12px] text-text-muted">
</p>
</div>
{/* Chart 1: Pollutant-Case Correlation Bar Chart */}
<div className="card p-4">
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
-
</div>
{lag1Correlations.length > 0 ? (
<>
<ResponsiveContainer width="100%" height={300}>
<BarChart
data={lag1Correlations}
layout="vertical"
margin={{ top: 5, right: 20, left: 50, bottom: 5 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="#E2E8F0"
horizontal={false}
/>
<XAxis
type="number"
domain={[-1, 1]}
tick={{ fontSize: 11, fill: '#64748B' }}
axisLine={{ stroke: '#E2E8F0' }}
/>
<YAxis
type="category"
dataKey="pollutant"
tickFormatter={(v: string) => (v === 'PM25' ? 'PM2.5' : v)}
tick={{ fontSize: 11, fill: '#374151' }}
width={50}
axisLine={false}
tickLine={false}
/>
<Tooltip
contentStyle={{
backgroundColor: '#FFFFFF',
border: '1px solid #E2E8F0',
borderRadius: '8px',
fontSize: '12px',
}}
formatter={(value: number) => [
value.toFixed(3),
'相关系数',
]}
/>
<ReferenceLine
x={0}
stroke="#94A3B8"
strokeWidth={1}
/>
<Bar dataKey="correlation" barSize={20} radius={[0, 4, 4, 0]}>
{lag1Correlations.map((entry, idx) => (
<Cell
key={idx}
fill={
entry.correlation >= 0
? '#DC2626'
: '#2563EB'
}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
<p className="text-[10px] text-text-muted mt-3 text-center">
Pearson相关系数1
</p>
</>
) : (
<div className="text-center py-8 text-text-muted text-sm">
</div>
)}
</div>
{/* Chart 2: Lag Correlation Analysis */}
<div className="card p-4">
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
</div>
{pmLagSeries.length > 0 ? (
<>
<ResponsiveContainer width="100%" height={300}>
<LineChart
data={pmLagSeries}
margin={{ top: 5, right: 20, left: 10, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
<XAxis
dataKey="lag_days"
label={{
value: '滞后天数',
position: 'insideBottom',
offset: -5,
fontSize: 11,
fill: '#64748B',
}}
tick={{ fontSize: 11, fill: '#64748B' }}
axisLine={{ stroke: '#E2E8F0' }}
/>
<YAxis
tick={{ fontSize: 11, fill: '#64748B' }}
axisLine={{ stroke: '#E2E8F0' }}
domain={[-0.5, 0.5]}
/>
<Tooltip
contentStyle={{
backgroundColor: '#FFFFFF',
border: '1px solid #E2E8F0',
borderRadius: '8px',
fontSize: '12px',
}}
formatter={(value: number) => [
value.toFixed(3),
'相关系数',
]}
/>
<Legend
wrapperStyle={{ fontSize: '11px' }}
/>
<ReferenceLine
y={0}
stroke="#94A3B8"
strokeWidth={1}
strokeDasharray="4 4"
/>
<Line
type="monotone"
dataKey="PM25Corr"
name="PM2.5"
stroke="#DC2626"
strokeWidth={2}
dot={{ r: 4, fill: '#DC2626' }}
activeDot={{ r: 5 }}
/>
<Line
type="monotone"
dataKey="PM10Corr"
name="PM10"
stroke="#D97706"
strokeWidth={2}
dot={{ r: 4, fill: '#D97706' }}
activeDot={{ r: 5 }}
/>
</LineChart>
</ResponsiveContainer>
{maxLagInsight && (
<p className="text-[10px] text-text-muted mt-3 text-center">
{maxLagInsight}
</p>
)}
</>
) : (
<div className="text-center py-8 text-text-muted text-sm">
</div>
)}
</div>
{/* Chart 3: Pollution Episode Impact */}
<div className="card p-4">
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
</div>
{episodeData.length > 0 ? (
<ResponsiveContainer width="100%" height={350}>
<BarChart
data={episodeData}
margin={{ top: 5, right: 20, left: 10, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
<XAxis
dataKey="category"
tick={{ fontSize: 11, fill: '#64748B' }}
axisLine={{ stroke: '#E2E8F0' }}
/>
<YAxis
tick={{ fontSize: 11, fill: '#64748B' }}
axisLine={{ stroke: '#E2E8F0' }}
label={{
value: '日均病例数',
angle: -90,
position: 'insideLeft',
offset: 0,
fontSize: 11,
fill: '#64748B',
}}
/>
<Tooltip
contentStyle={{
backgroundColor: '#FFFFFF',
border: '1px solid #E2E8F0',
borderRadius: '8px',
fontSize: '12px',
}}
formatter={(value: number) => [value.toLocaleString(), '日均病例数']}
/>
<Bar dataKey="avgCases" barSize={40} radius={[4, 4, 0, 0]}>
{episodeData.map((entry, idx) => (
<Cell key={idx} fill={entry.fill} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
) : (
<div className="text-center py-8 text-text-muted text-sm">
</div>
)}
</div>
{/* Chart 4: AQI Calendar Heatmap */}
<div className="card p-4">
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
AQI
</div>
{heatmapData.length > 0 ? (
<CalendarHeatmap data={heatmapData} year={heatmapYear} />
) : (
<div className="text-center py-8 text-text-muted text-sm">
</div>
)}
</div>
{/* Chart 5: Multi-Pollutant Time Series */}
<div className="card p-4">
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide">
</div>
<div className="flex flex-wrap gap-1.5">
{POLLUTANT_OPTIONS.map((p) => (
<button
key={p.key}
onClick={() => togglePollutant(p.key)}
className={`flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-medium transition-all ${
selectedPollutants.includes(p.key)
? 'bg-bg-active text-text-primary'
: 'bg-bg-page text-text-muted hover:text-text-secondary'
}`}
>
<span
className="w-2 h-2 rounded-full"
style={{ backgroundColor: p.color }}
/>
{p.label}
</button>
))}
</div>
</div>
{timeSeriesData.length > 0 ? (
<>
{/* Primary pollutants (PM2.5, PM10) */}
{selectedPollutants.some((k) => k === 'PM25' || k === 'PM10') && (
<div className="mb-4">
<div className="text-[10px] font-medium text-text-muted mb-2">
</div>
<ResponsiveContainer width="100%" height={200}>
<LineChart
data={timeSeriesData}
margin={{ top: 5, right: 20, left: 10, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
<XAxis
dataKey="date"
tickFormatter={formatDateLabel}
tick={{ fontSize: 10, fill: '#64748B' }}
interval="preserveStartEnd"
axisLine={{ stroke: '#E2E8F0' }}
/>
<YAxis
tick={{ fontSize: 10, fill: '#64748B' }}
axisLine={{ stroke: '#E2E8F0' }}
/>
<Tooltip
contentStyle={{
backgroundColor: '#FFFFFF',
border: '1px solid #E2E8F0',
borderRadius: '8px',
fontSize: '12px',
}}
/>
<Legend
wrapperStyle={{ fontSize: '10px' }}
/>
{selectedPollutants.includes('PM25') && (
<Line
type="monotone"
dataKey="PM25"
name="PM2.5"
stroke="#DC2626"
strokeWidth={2}
dot={false}
activeDot={{ r: 3 }}
/>
)}
{selectedPollutants.includes('PM10') && (
<Line
type="monotone"
dataKey="PM10"
name="PM10"
stroke="#D97706"
strokeWidth={2}
dot={false}
activeDot={{ r: 3 }}
/>
)}
</LineChart>
</ResponsiveContainer>
</div>
)}
{/* Secondary pollutants (SO2, NO2, O3, CO) */}
{selectedPollutants.some((k) =>
['SO2', 'NO2', 'O3', 'CO'].includes(k),
) && (
<div>
<div className="text-[10px] font-medium text-text-muted mb-2">
</div>
<ResponsiveContainer width="100%" height={200}>
<LineChart
data={timeSeriesData}
margin={{ top: 5, right: 20, left: 10, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
<XAxis
dataKey="date"
tickFormatter={formatDateLabel}
tick={{ fontSize: 10, fill: '#64748B' }}
interval="preserveStartEnd"
axisLine={{ stroke: '#E2E8F0' }}
/>
<YAxis
tick={{ fontSize: 10, fill: '#64748B' }}
axisLine={{ stroke: '#E2E8F0' }}
/>
<Tooltip
contentStyle={{
backgroundColor: '#FFFFFF',
border: '1px solid #E2E8F0',
borderRadius: '8px',
fontSize: '12px',
}}
/>
<Legend
wrapperStyle={{ fontSize: '10px' }}
/>
{selectedPollutants.includes('SO2') && (
<Line
type="monotone"
dataKey="SO2"
name="SO₂"
stroke="#7C3AED"
strokeWidth={1.5}
dot={false}
activeDot={{ r: 3 }}
/>
)}
{selectedPollutants.includes('NO2') && (
<Line
type="monotone"
dataKey="NO2"
name="NO₂"
stroke="#059669"
strokeWidth={1.5}
dot={false}
activeDot={{ r: 3 }}
/>
)}
{selectedPollutants.includes('O3') && (
<Line
type="monotone"
dataKey="O3"
name="O₃"
stroke="#EA580C"
strokeWidth={1.5}
dot={false}
activeDot={{ r: 3 }}
/>
)}
{selectedPollutants.includes('CO') && (
<Line
type="monotone"
dataKey="CO"
name="CO"
stroke="#0891B2"
strokeWidth={1.5}
dot={false}
activeDot={{ r: 3 }}
/>
)}
</LineChart>
</ResponsiveContainer>
</div>
)}
</>
) : (
<div className="text-center py-8 text-text-muted text-sm">
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,7 +1,13 @@
import { useEffect } from 'react';
import { useEffect, useState, useMemo } from 'react';
import { useAnalysisStore } from '@/stores/analysisStore';
import { ErrorBanner } from '@/components/ErrorBanner';
import { ChatBot } from '@/components/ChatBot';
import { caseApi } from '@/services/api';
import type { CaseTrendPoint } from '@/types';
import {
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
Scatter,
} from 'recharts';
import {
Lightbulb,
AlertTriangle,
@@ -11,6 +17,7 @@ import {
TrendingUp,
TrendingDown,
Clock,
BarChart3,
} from 'lucide-react';
const TYPE_CONFIG = {
@@ -51,10 +58,100 @@ export function Insights() {
const clearError = useAnalysisStore((s) => s.clearError);
const fetchInsights = useAnalysisStore((s) => s.fetchInsights);
// Anomaly detection state
const [anomalyTrend, setAnomalyTrend] = useState<CaseTrendPoint[]>([]);
const [anomalyLoading, setAnomalyLoading] = useState(true);
const [anomalyError, setAnomalyError] = useState<string | null>(null);
useEffect(() => {
fetchInsights();
}, [fetchInsights]);
useEffect(() => {
let cancelled = false;
setAnomalyLoading(true);
setAnomalyError(null);
const end = new Date();
const start = new Date();
start.setDate(start.getDate() - 90);
const startStr = start.toISOString().slice(0, 10);
const endStr = end.toISOString().slice(0, 10);
caseApi.getTrend({ start_date: startStr, end_date: endStr, group_by: 'day' })
.then((data) => {
if (!cancelled) {
setAnomalyTrend(data.trend || []);
setAnomalyLoading(false);
}
})
.catch(() => {
if (!cancelled) {
setAnomalyError('异常检测数据加载失败');
setAnomalyLoading(false);
}
});
return () => { cancelled = true; };
}, []);
// Compute 30-day rolling mean/std and anomalies
const anomalyData = useMemo(() => {
if (anomalyTrend.length < 30) return { chartData: [], anomalyDates: new Set<string>(), anomalies: [] };
const rollingMean: number[] = [];
const rollingStd: number[] = [];
const values = anomalyTrend.map((p) => p.total);
for (let i = 0; i < values.length; i++) {
if (i < 30) {
rollingMean.push(NaN);
rollingStd.push(NaN);
} else {
// Trailing window: the prior 30 days, excluding the current point.
const window = values.slice(i - 30, i);
const mean = window.reduce((a, b) => a + b, 0) / window.length;
const variance =
window.length > 1
? window.reduce((s, v) => s + (v - mean) ** 2, 0) / (window.length - 1)
: 0;
const std = Math.sqrt(variance);
rollingMean.push(mean);
rollingStd.push(std);
}
}
const anomalyDates = new Set<string>();
const anomalies: Array<{ date: string; cases: number; deviationPct: number }> = [];
for (let i = 30; i < values.length; i++) {
const threshold = 2 * rollingStd[i];
if (threshold > 0 && Math.abs(values[i] - rollingMean[i]) > threshold) {
anomalyDates.add(anomalyTrend[i].date);
const deviationPct = rollingMean[i] > 0
? Math.round(((values[i] - rollingMean[i]) / rollingMean[i]) * 100)
: 100;
anomalies.push({
date: anomalyTrend[i].date,
cases: values[i],
deviationPct,
});
}
}
// Keep top 5 anomalies by deviation magnitude
anomalies.sort((a, b) => Math.abs(b.deviationPct) - Math.abs(a.deviationPct));
const chartData = anomalyTrend.map((p, i) => ({
date: p.date,
cases: p.total,
rollingMean: rollingMean[i] !== undefined && !isNaN(rollingMean[i]) ? Math.round(rollingMean[i] * 10) / 10 : undefined,
anomaly: anomalyDates.has(p.date) ? p.total : undefined,
}));
return { chartData, anomalyDates, anomalies: anomalies.slice(0, 5) };
}, [anomalyTrend]);
const stats = insights
? [
{
@@ -65,8 +162,8 @@ export function Insights() {
bg: 'bg-primary-muted',
},
{
label: '预警',
value: (insights.warning_count || 0) + ((insights as any).danger_count || 0),
label: '预警/紧急',
value: (insights.warning_count || 0) + (insights.danger_count || 0),
icon: AlertTriangle,
color: 'text-warning',
bg: 'bg-warning-light',
@@ -199,6 +296,134 @@ export function Insights() {
</div>
)}
{/* Anomaly Detection Section */}
<div className="mb-5 mt-6">
<h2 className="font-display text-[16px] font-semibold mb-1 flex items-center gap-2">
<BarChart3 className="w-5 h-5 text-warning" />
</h2>
<p className="text-[12px] text-text-muted mb-4">
30
</p>
{anomalyLoading && (
<div className="card p-8 text-center">
<span className="text-text-secondary">...</span>
</div>
)}
{anomalyError && (
<div className="card p-8 text-center">
<AlertTriangle className="w-8 h-8 text-warning mx-auto mb-2" />
<p className="text-text-secondary text-sm">{anomalyError}</p>
</div>
)}
{!anomalyLoading && !anomalyError && anomalyTrend.length === 0 && (
<div className="card p-8 text-center">
<BarChart3 className="w-12 h-12 text-text-muted mx-auto mb-3" />
<p className="text-text-secondary"></p>
</div>
)}
{!anomalyLoading && !anomalyError && anomalyTrend.length > 0 && anomalyTrend.length < 30 && (
<div className="card p-8 text-center">
<BarChart3 className="w-12 h-12 text-text-muted mx-auto mb-3" />
<p className="text-text-secondary">30</p>
</div>
)}
{!anomalyLoading && !anomalyError && anomalyData.chartData.length > 0 && (
<>
<div className="card p-4 mb-4">
<ResponsiveContainer width="100%" height={250}>
<LineChart data={anomalyData.chartData} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis dataKey="date" tick={{ fontSize: 10, fill: '#9ca3af' }} interval="preserveStartEnd" />
<YAxis tick={{ fontSize: 10, fill: '#9ca3af' }} />
<Tooltip
contentStyle={{ fontSize: 12, borderRadius: 8, border: '1px solid #e5e7eb' }}
formatter={(value: number, name: string) => {
if (name === 'anomaly') return [value, '异常值'];
if (name === 'rollingMean') return [value, '30日均值'];
return [value, '病例数'];
}}
/>
<Line
type="monotone"
dataKey="cases"
name="每日病例"
stroke="#3b82f6"
strokeWidth={1.5}
dot={false}
/>
<Line
type="monotone"
dataKey="rollingMean"
name="30日均值"
stroke="#9ca3af"
strokeWidth={1.5}
strokeDasharray="5 5"
dot={false}
/>
<Scatter
dataKey="anomaly"
name="异常"
fill="#ef4444"
shape="circle"
legendType="none"
/>
</LineChart>
</ResponsiveContainer>
<div className="flex items-center gap-4 mt-2 pt-2 border-t border-border">
<div className="flex items-center gap-1.5 text-xs text-text-muted">
<span className="w-4 h-0.5 bg-blue-500 inline-block" />
</div>
<div className="flex items-center gap-1.5 text-xs text-text-muted">
<span className="w-4 h-0.5 bg-gray-400 inline-block" style={{ borderTop: '1.5px dashed #9ca3af' }} />
30
</div>
<div className="flex items-center gap-1.5 text-xs text-text-muted">
<span className="w-2 h-2 rounded-full bg-red-500 inline-block" />
</div>
</div>
</div>
{anomalyData.anomalies.length > 0 && (
<div className="grid grid-cols-1 gap-2">
{anomalyData.anomalies.map((a) => (
<div
key={a.date}
className="card p-3 flex items-center justify-between"
>
<div className="flex items-center gap-3">
<div className="w-2 h-2 rounded-full bg-red-500 shrink-0" />
<div>
<span className="text-[13px] font-semibold text-text-primary">{a.date}</span>
<span className="text-[12px] text-text-muted ml-3">
: {a.cases.toLocaleString()}
</span>
</div>
</div>
<span className={`text-[12px] font-semibold ${a.deviationPct >= 0 ? 'text-danger' : 'text-success'}`}>
{a.deviationPct >= 0 ? '高于' : '低于'} {Math.abs(a.deviationPct)}%
</span>
</div>
))}
</div>
)}
{anomalyData.anomalies.length === 0 && (
<div className="card p-4 text-center">
<p className="text-[13px] text-text-muted"></p>
</div>
)}
</>
)}
</div>
<ChatBot />
</div>
);

View File

@@ -1,15 +1,45 @@
import { useEffect, useState, useMemo, useRef, useCallback, memo } from 'react';
import { Calendar, Activity, TrendingUp, TrendingDown, Stethoscope, Building2 } from 'lucide-react';
import {
LineChart,
Line,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
import { Calendar, Activity, TrendingUp, TrendingDown, Stethoscope, Zap, BarChart3 } from 'lucide-react';
import { useTimelineStore, useMonitoringStore } from '@/stores';
import { useDiseaseStore } from '@/stores/diseaseStore';
import { useDrilldownStore } from '@/stores/drilldownStore';
import { gridApi, caseApi } from '@/services/api';
import { gridApi, caseApi, envApi } from '@/services/api';
import { ErrorBanner } from '@/components/ErrorBanner';
import { TimelinePlayer } from '@/components/TimelinePlayer';
import { StatisticalCharts } from '@/components/StatisticalCharts';
import { CaseLocationMap } from '@/components/CaseLocationMap';
import { DiseaseFilter } from '@/components/DiseaseFilter';
import { AdminBreadcrumb } from '@/components/AdminBreadcrumb';
import { StatCard } from '@/components/StatCard';
import { CalendarHeatmap } from '@/components/CalendarHeatmap';
import { MetricHeatmapTable } from '@/components/MetricHeatmapTable';
import type { DistrictCaseData } from '@/types';
type MonitoringTab = 'overview' | 'cases' | 'districts';
interface TopDiagnosis {
diagnosis: string;
outpatient: number;
inpatient: number;
total: number;
}
function formatDateLabel(dateStr: string): string {
const d = new Date(dateStr);
return `${d.getMonth() + 1}/${d.getDate()}`;
}
interface MonitoringDashboardProps {
defaultStartDate?: string;
@@ -22,6 +52,25 @@ export function MonitoringDashboard({
}: MonitoringDashboardProps) {
const [chartData, setChartData] = useState<Array<{ date: string; cases: number; aqi?: number }>>([]);
// In-page tab strip (local state, no router — mirrors the existing activePage pattern)
const [activeTab, setActiveTab] = useState<MonitoringTab>('overview');
// --- 病例统计 tab state (fetched on demand) ---
const [topDiagnoses, setTopDiagnoses] = useState<TopDiagnosis[]>([]);
const [caseTrend, setCaseTrend] = useState<Array<{ date: string; cases: number; aqi: number }>>([]);
const [heatmapData, setHeatmapData] = useState<Array<{ date: string; value: number }>>([]);
const [heatmapYear, setHeatmapYear] = useState<number | null>(null);
const [casesTabLoaded, setCasesTabLoaded] = useState(false);
const [casesTabLoading, setCasesTabLoading] = useState(false);
const [casesTabError, setCasesTabError] = useState<string | null>(null);
// --- 区域统计 tab state (fetched on demand) ---
const [districtMetrics, setDistrictMetrics] = useState<DistrictCaseData[]>([]);
const [districtSortKey, setDistrictSortKey] = useState<string>('total');
const [districtTabLoaded, setDistrictTabLoaded] = useState(false);
const [districtTabLoading, setDistrictTabLoading] = useState(false);
const [districtTabError, setDistrictTabError] = useState<string | null>(null);
const {
currentDate,
isPlaying,
@@ -48,15 +97,15 @@ export function MonitoringDashboard({
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const loadChartData = useCallback((district?: string) => {
const end = new Date(defaultEndDate);
const start = new Date(defaultEndDate);
// Load chart data for 90-day window ending at the given reference date
const loadChartData = useCallback((refDate: string, district?: string) => {
const end = new Date(refDate);
const start = new Date(refDate);
start.setDate(start.getDate() - 90);
const startStr = start.toISOString().split('T')[0];
const endStr = end.toISOString().split('T')[0];
if (selectedDiagnoses.length > 0 && selectedDiagnoses.length <= 3) {
// Use caseApi for diagnosis-filtered data
caseApi.getTrend({
start_date: startStr,
end_date: endStr,
@@ -69,7 +118,6 @@ export function MonitoringDashboard({
);
}).catch((e) => { console.error('Failed to load chart data:', e); });
} else {
// Use gridApi for unfiltered data (or too many diagnoses selected)
gridApi.getHistoricalAggregated(startStr, endStr, 'daily', district)
.then((data) => {
const rows = data.aggregations || [];
@@ -85,39 +133,205 @@ export function MonitoringDashboard({
}).catch((e) => { console.error('Failed to load chart data:', e); });
}
// Fetch districtCases with diagnosis filter
fetchDistrictCases(selectedDiagnoses.length > 0 ? selectedDiagnoses.join(',') : undefined);
}, [defaultEndDate, fetchDistrictCases, selectedDiagnoses]);
// Fetch districtCases with date filter (single day = currentDate)
const diagnosisParam = selectedDiagnoses.length > 0 ? selectedDiagnoses.join(',') : undefined;
fetchDistrictCases(diagnosisParam, undefined, refDate);
}, [fetchDistrictCases, selectedDiagnoses]);
// Re-fetch when currentDate, district, or diagnoses change
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
loadChartData(selectedDistrict || undefined);
loadChartData(currentDate, selectedDistrict || undefined);
}, 300);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [selectedDistrict, loadChartData]);
}, [currentDate, selectedDistrict, loadChartData]);
// Enhanced stats: window stats + current-date snapshot
const stats = useMemo(() => {
if (chartData.length === 0) return null;
const noData = chartData.length === 0;
const totalCases = chartData.reduce((sum, d) => sum + d.cases, 0);
const avgCases = totalCases / chartData.length;
const maxDay = chartData.reduce((max, d) => d.cases > max.cases ? d : max, chartData[0]);
const totalCases = noData ? 0 : chartData.reduce((sum, d) => sum + d.cases, 0);
const avgCases = noData ? 0 : Math.round(totalCases / chartData.length);
const firstHalf = chartData.slice(0, Math.floor(chartData.length / 2));
const secondHalf = chartData.slice(Math.floor(chartData.length / 2));
const firstAvg = firstHalf.reduce((s, d) => s + d.cases, 0) / firstHalf.length;
const secondAvg = secondHalf.reduce((s, d) => s + d.cases, 0) / secondHalf.length;
const trend = secondAvg > firstAvg * 1.1 ? 'up' : secondAvg < firstAvg * 0.9 ? 'down' : 'stable';
let maxDay = { date: '--', cases: 0 };
let minDay = { date: '--', cases: 0 };
let stdDev = 0;
let trend: 'up' | 'down' | 'stable' = 'stable';
if (!noData) {
maxDay = chartData.reduce((max, d) => d.cases > max.cases ? d : max, chartData[0]);
minDay = chartData.reduce((min, d) => d.cases < min.cases ? d : min, chartData[0]);
const variance = chartData.reduce((sum, d) => sum + (d.cases - avgCases) ** 2, 0) / chartData.length;
stdDev = Math.round(Math.sqrt(variance));
const halfIdx = Math.floor(chartData.length / 2);
const firstHalf = chartData.slice(0, halfIdx);
const secondHalf = chartData.slice(halfIdx);
const firstAvg = firstHalf.reduce((s, d) => s + d.cases, 0) / firstHalf.length;
const secondAvg = secondHalf.reduce((s, d) => s + d.cases, 0) / secondHalf.length;
trend = secondAvg > firstAvg * 1.1 ? 'up' : secondAvg < firstAvg * 0.9 ? 'down' : 'stable';
}
// 7-day moving average (last 7 days of the window)
const last7 = chartData.slice(-7);
const avg7d = last7.length > 0 ? Math.round(last7.reduce((s, d) => s + d.cases, 0) / last7.length) : 0;
// Current date snapshot: find the data point matching currentDate
const todaySnapshot = chartData.find((d) => d.date === currentDate);
const todayCases = todaySnapshot?.cases ?? null;
// Case type breakdown from districtCases
const totalOutpatient = districtCases.reduce((s, d) => s + d.outpatient, 0);
const totalInpatient = districtCases.reduce((s, d) => s + d.inpatient, 0);
return { totalCases, avgCases: Math.round(avgCases), maxDay, trend, totalOutpatient, totalInpatient };
}, [chartData, districtCases]);
return {
totalCases, avgCases, maxDay, minDay,
stdDev, trend, totalOutpatient, totalInpatient,
avg7d, todayCases, noData,
};
}, [chartData, districtCases, currentDate]);
// 7-day sparkline for the StatCard bar (last 7 days of the loaded window)
const sparkline7d = useMemo(() => chartData.slice(-7).map((d) => d.cases), [chartData]);
// --- On-demand loader: 病例统计 tab ---
// Drives the trend off the Monitoring timeline (30-day window ending at currentDate),
// NOT a fixed now-30d window. Year for the heatmap is derived from the data.
const loadCasesTab = useCallback(async (refDate: string) => {
setCasesTabLoading(true);
setCasesTabError(null);
const end = new Date(refDate);
const start = new Date(refDate);
start.setDate(start.getDate() - 30);
const startStr = start.toISOString().split('T')[0];
const endStr = end.toISOString().split('T')[0];
const yearStart = `${end.getFullYear()}-01-01`;
const yearEnd = `${end.getFullYear()}-12-31`;
const [statsR, trendR, pollutantsR, yearTrendR] = await Promise.allSettled([
caseApi.getStats(),
caseApi.getTrend({ start_date: startStr, end_date: endStr, group_by: 'day' }),
envApi.getPollutants(30),
caseApi.getTrend({ start_date: yearStart, end_date: yearEnd, group_by: 'day' }),
]);
const errs: string[] = [];
if (statsR.status === 'fulfilled') {
const topDiag = statsR.value.top_diagnoses || [];
setTopDiagnoses(
topDiag.slice(0, 5).map((d) => ({
diagnosis: d.diagnosis,
outpatient: d.outpatient,
inpatient: d.inpatient,
total: d.outpatient + d.inpatient,
}))
);
} else {
errs.push('诊断分布加载失败');
}
const aqiMap: Record<string, number> = {};
if (pollutantsR.status === 'fulfilled') {
for (const p of pollutantsR.value.data || []) {
aqiMap[p.date] = p.AQI || 0;
}
}
if (trendR.status === 'fulfilled') {
const trend = trendR.value.trend || [];
setCaseTrend(
trend.map((t) => ({ date: t.date, cases: t.total, aqi: aqiMap[t.date] || 0 }))
);
} else {
errs.push('趋势数据加载失败');
}
// Calendar heatmap: daily cases for the data's actual year (derived from trend data)
if (yearTrendR.status === 'fulfilled') {
const yearTrend = yearTrendR.value.trend || [];
if (yearTrend.length > 0) {
const derivedYear = new Date(yearTrend[0].date).getFullYear();
setHeatmapYear(derivedYear);
setHeatmapData(yearTrend.map((t) => ({ date: t.date, value: t.total })));
} else {
setHeatmapYear(end.getFullYear());
setHeatmapData([]);
}
} else {
errs.push('日历热力图加载失败');
}
setCasesTabError(errs.length > 0 ? errs.join('') : null);
setCasesTabLoading(false);
setCasesTabLoaded(true);
}, []);
// --- On-demand loader: 区域统计 tab ---
const loadDistrictTab = useCallback(async () => {
setDistrictTabLoading(true);
setDistrictTabError(null);
try {
const res = await caseApi.getDistricts();
setDistrictMetrics(res.districts || []);
setDistrictTabError(null);
} catch {
setDistrictTabError('区域统计加载失败');
} finally {
setDistrictTabLoading(false);
setDistrictTabLoaded(true);
}
}, []);
// Fetch tab data the first time a tab is opened (avoids loading everything upfront)
useEffect(() => {
if (activeTab === 'cases' && !casesTabLoaded && !casesTabLoading) {
loadCasesTab(currentDate);
}
if (activeTab === 'districts' && !districtTabLoaded && !districtTabLoading) {
loadDistrictTab();
}
}, [activeTab, casesTabLoaded, casesTabLoading, districtTabLoaded, districtTabLoading, currentDate, loadCasesTab, loadDistrictTab]);
// When the timeline date moves, refresh an already-opened 病例统计 tab so its
// trend window tracks the Monitoring timeline rather than going stale.
useEffect(() => {
if (activeTab === 'cases' && casesTabLoaded) {
loadCasesTab(currentDate);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentDate]);
// 区域统计 table: sortable district rows + heatmap columns
const districtTableRows = useMemo(() => {
const sorted = [...districtMetrics].sort((a, b) => {
switch (districtSortKey) {
case 'outpatient': return b.outpatient - a.outpatient;
case 'inpatient': return b.inpatient - a.inpatient;
case 'inpatient_ratio': return (b.inpatient_ratio ?? 0) - (a.inpatient_ratio ?? 0);
default: return b.total - a.total;
}
});
return sorted.map((d) => d.district);
}, [districtMetrics, districtSortKey]);
const districtTableData = useMemo(() => {
const map: Record<string, Record<string, number>> = {};
for (const d of districtMetrics) {
map[d.district] = {
total: d.total,
outpatient: d.outpatient,
inpatient: d.inpatient,
inpatient_ratio: Math.round((d.inpatient_ratio ?? 0) * 1000) / 10,
};
}
return map;
}, [districtMetrics]);
const handleDateChange = useCallback((date: string) => {
setCurrentDate(date);
@@ -135,127 +349,270 @@ export function MonitoringDashboard({
error={error}
onRetry={() => {
clearError();
loadChartData(selectedDistrict || undefined);
loadChartData(currentDate, selectedDistrict || undefined);
}}
onDismiss={clearError}
/>
</div>
)}
{/* Top stats bar */}
<div className="bg-white border-b border-gray-200 px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-8">
{stats && (
<>
<div className="flex items-center gap-2">
<Activity className="w-5 h-5 text-blue-600" />
<div>
<div className="text-sm text-gray-500"></div>
<div className="text-2xl font-bold text-gray-900">{stats.totalCases.toLocaleString()}</div>
</div>
</div>
<div className="flex items-center gap-2">
<Calendar className="w-5 h-5 text-green-600" />
<div>
<div className="text-sm text-gray-500"></div>
<div className="text-2xl font-bold text-gray-900">{stats.avgCases}</div>
</div>
</div>
<div className="flex items-center gap-2">
{stats.trend === 'up' ? (
<TrendingUp className="w-5 h-5 text-red-500" />
) : stats.trend === 'down' ? (
<TrendingDown className="w-5 h-5 text-green-500" />
) : (
<Activity className="w-5 h-5 text-gray-400" />
)}
<div>
<div className="text-sm text-gray-500"></div>
<div className={`text-2xl font-bold ${
stats.trend === 'up' ? 'text-red-600' :
stats.trend === 'down' ? 'text-green-600' :
'text-gray-600'
}`}>
{stats.trend === 'up' ? '上升' : stats.trend === 'down' ? '下降' : '平稳'}
</div>
</div>
</div>
<div className="w-px h-8 bg-gray-200" />
<div className="flex items-center gap-2">
<Stethoscope className="w-5 h-5 text-orange-500" />
<div>
<div className="text-sm text-gray-500"></div>
<div className="text-2xl font-bold text-gray-900">{stats.totalOutpatient.toLocaleString()}</div>
</div>
</div>
<div className="flex items-center gap-2">
<Building2 className="w-5 h-5 text-red-500" />
<div>
<div className="text-sm text-gray-500"></div>
<div className="text-2xl font-bold text-gray-900">{stats.totalInpatient.toLocaleString()}</div>
</div>
</div>
</>
)}
{/* Top stats bar — standardized with StatCard */}
<div className="bg-white border-b border-gray-200 px-6 py-4 shrink-0">
<div className="flex items-start justify-between flex-wrap gap-x-4 gap-y-3">
<div className="flex-1 min-w-0 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
<StatCard
icon={<Calendar className="w-4 h-4 text-blue-600" />}
label="当日病例"
value={stats.todayCases !== null ? stats.todayCases.toLocaleString() : '--'}
/>
<StatCard
icon={<Activity className="w-4 h-4 text-indigo-600" />}
label="7日均值"
value={stats.avg7d.toLocaleString()}
sparkline={sparkline7d.length >= 2 ? { data: sparkline7d, color: '#6366F1' } : undefined}
/>
<StatCard
icon={
stats.trend === 'up' ? <TrendingUp className="w-4 h-4 text-red-500" /> :
stats.trend === 'down' ? <TrendingDown className="w-4 h-4 text-green-500" /> :
<Activity className="w-4 h-4 text-gray-400" />
}
label="趋势"
value={stats.trend === 'up' ? '上升' : stats.trend === 'down' ? '下降' : '平稳'}
trend={{
direction: stats.trend === 'up' ? 'up' : stats.trend === 'down' ? 'down' : 'stable',
value: stats.trend === 'up' ? '上升' : stats.trend === 'down' ? '下降' : '平稳',
}}
/>
<StatCard
icon={<Zap className="w-4 h-4 text-amber-500" />}
label="峰值日"
value={`${stats.maxDay.cases.toLocaleString()} (${stats.maxDay.date.slice(5)})`}
/>
<StatCard
icon={<BarChart3 className="w-4 h-4 text-purple-500" />}
label="标准差"
value={stats.stdDev.toLocaleString()}
/>
<StatCard
icon={<Stethoscope className="w-4 h-4 text-orange-500" />}
label="门诊 / 住院"
value={`${stats.totalOutpatient.toLocaleString()} / ${stats.totalInpatient.toLocaleString()}`}
/>
</div>
{/* Disease filter */}
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 shrink-0">
{stats.noData && (
<span className="text-xs text-amber-600 bg-amber-50 px-2 py-1 rounded"></span>
)}
<DiseaseFilter onFilterChange={() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
loadChartData(selectedDistrict || undefined);
loadChartData(currentDate, selectedDistrict || undefined);
}, 300);
}} />
{/* District filter - AdminBreadcrumb for drill-down */}
<AdminBreadcrumb />
</div>
</div>
{/* In-page tab strip */}
<div className="flex items-center gap-1 mt-4 border-b border-gray-100 -mb-4">
{([
{ key: 'overview', label: '概览' },
{ key: 'cases', label: '病例统计' },
{ key: 'districts', label: '区域统计' },
] as const).map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.key
? 'border-blue-600 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
{tab.label}
</button>
))}
</div>
</div>
{/* Main content — bottom padding for floating player */}
<div className="flex-1 overflow-auto p-6 pb-24">
{isLoading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
) : (
<div className="space-y-6">
{/* Case Location Map */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<h3 className="text-lg font-semibold text-gray-900 mb-4"></h3>
<CaseLocationMap height="400px" district={selectedDistrict} street={selectedStreet} />
{/* 概览 tab — unchanged Monitoring content */}
{activeTab === 'overview' && (
isLoading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
{/* Statistical Charts */}
<StatisticalCharts
data={chartData}
height={350}
showCases={true}
showAQI={true}
/>
{/* District breakdown */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<h3 className="text-lg font-semibold text-gray-900 mb-4"></h3>
<div className="space-y-2">
<DistrictBreakdown districtCases={districtCases} selectedDistrict={selectedDistrict} />
) : (
<div className="space-y-6">
{/* Case Location Map */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<h3 className="text-lg font-semibold text-gray-900 mb-4"></h3>
<CaseLocationMap height="400px" district={selectedDistrict} street={selectedStreet} date={currentDate} />
</div>
<div className="flex items-center gap-4 mt-3 pt-2 border-t border-gray-100">
<div className="flex items-center gap-1.5 text-xs text-gray-500">
<span className="w-3 h-3 bg-orange-400 rounded-sm" />
{/* Statistical Charts */}
<StatisticalCharts
data={chartData}
height={350}
showCases={true}
showAQI={true}
/>
{/* District breakdown */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<h3 className="text-lg font-semibold text-gray-900 mb-4"></h3>
<div className="space-y-2">
<DistrictBreakdown districtCases={districtCases} selectedDistrict={selectedDistrict} />
</div>
<div className="flex items-center gap-1.5 text-xs text-gray-500">
<span className="w-3 h-3 bg-red-400 rounded-sm" />
<div className="flex items-center gap-4 mt-3 pt-2 border-t border-gray-100">
<div className="flex items-center gap-1.5 text-xs text-gray-500">
<span className="w-3 h-3 bg-orange-400 rounded-sm" />
</div>
<div className="flex items-center gap-1.5 text-xs text-gray-500">
<span className="w-3 h-3 bg-red-400 rounded-sm" />
</div>
</div>
</div>
</div>
</div>
)
)}
{/* 病例统计 tab */}
{activeTab === 'cases' && (
casesTabLoading && !casesTabLoaded ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
) : (
<div className="space-y-6">
{casesTabError && (
<ErrorBanner
error={casesTabError}
onRetry={() => loadCasesTab(currentDate)}
onDismiss={() => setCasesTabError(null)}
/>
)}
{/* Top 5 诊断分布 */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Top 5 </h3>
{topDiagnoses.length > 0 ? (
<ResponsiveContainer width="100%" height={240}>
<BarChart
data={[...topDiagnoses].reverse()}
layout="vertical"
margin={{ top: 0, right: 10, left: 60, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" horizontal={false} />
<XAxis type="number" tick={{ fontSize: 10, fill: '#64748B' }} />
<YAxis
type="category"
dataKey="diagnosis"
tick={{ fontSize: 11, fill: '#374151' }}
width={100}
axisLine={false}
tickLine={false}
/>
<Tooltip
contentStyle={{ backgroundColor: '#FFFFFF', border: '1px solid #E2E8F0', borderRadius: '8px', fontSize: '12px' }}
formatter={(value: number) => [value.toLocaleString(), '病例数']}
/>
<Legend wrapperStyle={{ fontSize: '11px' }} />
<Bar dataKey="outpatient" stackId="a" fill="#3B82F6" name="门诊" barSize={16} />
<Bar dataKey="inpatient" stackId="a" fill="#EF4444" name="住院" barSize={16} />
</BarChart>
</ResponsiveContainer>
) : (
<div className="text-center py-8 text-gray-400 text-sm"></div>
)}
</div>
{/* 近30日病例与AQI趋势 (driven off Monitoring timeline currentDate) */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<h3 className="text-lg font-semibold text-gray-900 mb-1">AQI趋势</h3>
<p className="text-xs text-gray-500 mb-4"> {currentDate} 30</p>
{caseTrend.length > 0 ? (
<ResponsiveContainer width="100%" height={260}>
<LineChart data={caseTrend} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
<XAxis
dataKey="date"
tickFormatter={formatDateLabel}
tick={{ fontSize: 10, fill: '#64748B' }}
interval="preserveStartEnd"
axisLine={{ stroke: '#E2E8F0' }}
/>
<YAxis yAxisId="left" tick={{ fontSize: 10, fill: '#64748B' }} axisLine={{ stroke: '#E2E8F0' }} />
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 10, fill: '#F59E0B' }} axisLine={{ stroke: '#E2E8F0' }} />
<Tooltip
contentStyle={{ backgroundColor: '#FFFFFF', border: '1px solid #E2E8F0', borderRadius: '8px', fontSize: '12px' }}
labelStyle={{ color: '#1E293B', fontWeight: 600 }}
/>
<Legend wrapperStyle={{ fontSize: '11px' }} />
<Line yAxisId="left" type="monotone" dataKey="cases" name="病例数" stroke="#3B82F6" strokeWidth={2} dot={false} activeDot={{ r: 3 }} />
<Line yAxisId="right" type="monotone" dataKey="aqi" name="AQI" stroke="#F59E0B" strokeWidth={2} strokeDasharray="5 5" dot={false} activeDot={{ r: 3 }} />
</LineChart>
</ResponsiveContainer>
) : (
<div className="text-center py-8 text-gray-400 text-sm"></div>
)}
</div>
{/* 日历热力图 (year derived from data) */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
{heatmapYear ? `${heatmapYear}` : ''}
</h3>
{heatmapYear && heatmapData.length > 0 ? (
<CalendarHeatmap data={heatmapData} year={heatmapYear} />
) : (
<div className="text-center py-8 text-gray-400 text-sm"></div>
)}
</div>
</div>
)
)}
{/* 区域统计 tab */}
{activeTab === 'districts' && (
districtTabLoading && !districtTabLoaded ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
) : (
<div className="space-y-6">
{districtTabError && (
<ErrorBanner
error={districtTabError}
onRetry={() => loadDistrictTab()}
onDismiss={() => setDistrictTabError(null)}
/>
)}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<h3 className="text-lg font-semibold text-gray-900 mb-1"></h3>
<p className="text-xs text-gray-500 mb-4"></p>
{districtTableRows.length > 0 ? (
<MetricHeatmapTable
rows={districtTableRows}
columns={[
{ key: 'total', label: '病例' },
{ key: 'outpatient', label: '门诊' },
{ key: 'inpatient', label: '住院' },
{ key: 'inpatient_ratio', label: '住院占比%' },
]}
data={districtTableData}
onSort={(col) => setDistrictSortKey(col)}
/>
) : (
<div className="text-center py-8 text-gray-400 text-sm"></div>
)}
</div>
</div>
)
)}
</div>
@@ -326,4 +683,4 @@ const DistrictBreakdown = memo(function DistrictBreakdown({ districtCases, selec
})}
</>
);
});
});

View File

@@ -0,0 +1,501 @@
import { useEffect, useState, useMemo } from 'react';
import {
LineChart,
Line,
BarChart,
Bar,
PieChart,
Pie,
Cell,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
import {
Activity,
AlertTriangle,
Droplets,
Building2,
TrendingUp,
TrendingDown,
Users,
} from 'lucide-react';
import { caseApi, riskApi, alertApi, envApi } from '@/services/api';
import { StatCard } from '@/components/StatCard';
import { ErrorBanner } from '@/components/ErrorBanner';
import type {
CaseTrendPoint,
DistrictCaseData,
PollutantPoint,
DiagnosisBreakdown,
Alert,
} from '@/types';
// --- Types for fetched data ---
interface KpiData {
totalCases: number;
todayCases: number;
changeRatio: number | null;
activeAlerts: number;
highRiskGrids: number;
avgAQI: number;
}
interface MergedTrendItem {
date: string;
cases: number;
aqi: number;
}
function formatDateLabel(dateStr: string): string {
const d = new Date(dateStr);
return `${d.getMonth() + 1}/${d.getDate()}`;
}
function computeChangeRatio(trend: CaseTrendPoint[]): number | null {
if (trend.length < 8) return null;
const recent7 = trend.slice(-7).reduce((s, p) => s + p.total, 0);
const prior7 = trend.slice(-14, -7).reduce((s, p) => s + p.total, 0);
if (prior7 === 0) return null;
return ((recent7 - prior7) / prior7) * 100;
}
export function OverviewDashboard() {
const [kpi, setKpi] = useState<KpiData | null>(null);
const [mergedTrend, setMergedTrend] = useState<MergedTrendItem[]>([]);
const [topDistricts, setTopDistricts] = useState<DistrictCaseData[]>([]);
const [topDiagnoses, setTopDiagnoses] = useState<DiagnosisBreakdown[]>([]);
const [alertPie, setAlertPie] = useState<{ name: string; value: number; color: string }[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [errors, setErrors] = useState<string[]>([]);
useEffect(() => {
let cancelled = false;
const fetchAll = async () => {
setIsLoading(true);
setErrors([]);
const now = new Date();
const endStr = now.toISOString().split('T')[0];
const start14 = new Date(now);
start14.setDate(start14.getDate() - 14);
const start14Str = start14.toISOString().split('T')[0];
const start30 = new Date(now);
start30.setDate(start30.getDate() - 30);
const start30Str = start30.toISOString().split('T')[0];
// KPI sources — Promise.allSettled to survive individual failures
const [statsR, trend14R, alertsR, riskStatsR, pollutantsR] = await Promise.allSettled([
caseApi.getStats(),
caseApi.getTrend({ start_date: start14Str, end_date: endStr, group_by: 'day' }),
alertApi.getAlerts(),
riskApi.getStats(),
envApi.getPollutants(7),
]);
// Trend sources
const [trend30R, districtsR, diagStatsR] = await Promise.allSettled([
caseApi.getTrend({ start_date: start30Str, end_date: endStr, group_by: 'day' }),
caseApi.getDistricts(),
caseApi.getStats(), // reuse for top_diagnoses
]);
if (cancelled) return;
const newErrors: string[] = [];
// --- Build KPI ---
let totalCases = 0;
if (statsR.status === 'fulfilled') {
const s = statsR.value;
totalCases = (s.total_outpatient || 0) + (s.total_inpatient || 0);
} else {
newErrors.push('累计病例数据加载失败');
}
let todayCases = 0;
let changeRatio: number | null = null;
if (trend14R.status === 'fulfilled') {
const trend = trend14R.value.trend || [];
if (trend.length > 0) {
todayCases = trend[trend.length - 1].total;
}
changeRatio = computeChangeRatio(trend);
} else {
newErrors.push('今日病例数据加载失败');
}
let activeAlerts = 0;
let alertList: Alert[] = [];
if (alertsR.status === 'fulfilled') {
alertList = alertsR.value.alerts || [];
activeAlerts = alertList.length;
} else {
newErrors.push('预警数据加载失败');
}
let highRiskGrids = 0;
if (riskStatsR.status === 'fulfilled') {
highRiskGrids = riskStatsR.value.high_risk_count || 0;
} else {
newErrors.push('风险网格数据加载失败');
}
let avgAQI = 0;
let pollutantData: PollutantPoint[] = [];
if (pollutantsR.status === 'fulfilled') {
pollutantData = pollutantsR.value.data || [];
if (pollutantData.length > 0) {
const sumAQI = pollutantData.reduce((s, p) => s + (p.AQI || 0), 0);
avgAQI = Math.round(sumAQI / pollutantData.length);
}
} else {
newErrors.push('AQI数据加载失败');
}
setKpi({ totalCases, todayCases, changeRatio, activeAlerts, highRiskGrids, avgAQI });
setErrors(newErrors);
// --- Merge case trend + AQI ---
if (trend30R.status === 'fulfilled') {
const trend30 = trend30R.value.trend || [];
const aqiMap: Record<string, number> = {};
if (pollutantsR.status === 'fulfilled') {
for (const p of pollutantData) {
aqiMap[p.date] = p.AQI || 0;
}
}
// Only use data from the last 30 days for display
const merged: MergedTrendItem[] = trend30.map((t) => ({
date: t.date,
cases: t.total,
aqi: aqiMap[t.date] || 0,
}));
setMergedTrend(merged);
} else if (!newErrors.includes('今日病例数据加载失败')) {
newErrors.push('趋势数据加载失败');
}
// --- Top 5 Districts ---
if (districtsR.status === 'fulfilled') {
const districts = districtsR.value.districts || [];
const sorted = [...districts].sort((a, b) => b.total - a.total);
setTopDistricts(sorted.slice(0, 5));
}
// --- Top 5 Diagnoses ---
if (diagStatsR.status === 'fulfilled') {
const topDiag = diagStatsR.value.top_diagnoses || [];
setTopDiagnoses(
topDiag.slice(0, 5).map((d) => ({
diagnosis: d.diagnosis,
outpatient: d.outpatient,
inpatient: d.inpatient,
total: d.outpatient + d.inpatient,
}))
);
}
// --- Alert severity donut ---
const p1 = alertList.filter((a) => a.priority === 'P1').length;
const p2 = alertList.filter((a) => a.priority === 'P2').length;
setAlertPie([
{ name: 'P1 紧急', value: p1, color: '#EF4444' },
{ name: 'P2 关注', value: p2, color: '#F59E0B' },
]);
setIsLoading(false);
};
fetchAll();
return () => {
cancelled = true;
};
}, []);
const changeTrend = useMemo(() => {
if (kpi?.changeRatio == null) return undefined;
if (kpi.changeRatio > 0) {
return { direction: 'up' as const, value: `${kpi.changeRatio.toFixed(1)}%` };
}
if (kpi.changeRatio < 0) {
return { direction: 'down' as const, value: `${Math.abs(kpi.changeRatio).toFixed(1)}%` };
}
return { direction: 'stable' as const, value: '0%' };
}, [kpi?.changeRatio]);
// --- Loading state ---
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
</div>
);
}
return (
<div className="flex flex-col h-full overflow-auto">
{/* Error banner */}
{errors.length > 0 && (
<div className="px-6 pt-4">
<ErrorBanner
error={errors.join('')}
onRetry={() => window.location.reload()}
onDismiss={() => setErrors([])}
/>
</div>
)}
<div className="p-6 space-y-6">
{/* Page header */}
<div>
<h1 className="font-display text-[18px] font-semibold mb-1 flex items-center gap-2">
<Activity className="w-5 h-5 text-primary" />
</h1>
<p className="text-[12px] text-gray-500"></p>
</div>
{/* Section 1: KPI Row */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
<StatCard
icon={<Users className="w-4 h-4 text-blue-600" />}
label="累计病例总数"
value={kpi?.totalCases?.toLocaleString() ?? '--'}
/>
<StatCard
icon={<Activity className="w-4 h-4 text-green-600" />}
label="今日病例"
value={kpi?.todayCases?.toLocaleString() ?? '--'}
/>
<StatCard
icon={
(changeTrend?.direction === 'up' && <TrendingUp className="w-4 h-4 text-red-500" />) ||
(changeTrend?.direction === 'down' && <TrendingDown className="w-4 h-4 text-green-500" />) || (
<Activity className="w-4 h-4 text-gray-400" />
)
}
label="7日变化率"
value={changeTrend ? changeTrend.value : '--'}
trend={changeTrend}
/>
<StatCard
icon={<AlertTriangle className="w-4 h-4 text-orange-500" />}
label="活跃预警数"
value={kpi?.activeAlerts?.toLocaleString() ?? '--'}
color={kpi && kpi.activeAlerts > 0 ? '#EF4444' : undefined}
/>
<StatCard
icon={<Building2 className="w-4 h-4 text-red-500" />}
label="高风险网格"
value={kpi?.highRiskGrids?.toLocaleString() ?? '--'}
/>
<StatCard
icon={<Droplets className="w-4 h-4 text-cyan-500" />}
label="平均AQI"
value={kpi?.avgAQI?.toLocaleString() ?? '--'}
/>
</div>
{/* Section 2: Case + AQI Mini Trend */}
<div className="card p-4">
<div className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-4">
30AQI趋势
</div>
{mergedTrend.length > 0 ? (
<ResponsiveContainer width="100%" height={200}>
<LineChart data={mergedTrend} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
<XAxis
dataKey="date"
tickFormatter={formatDateLabel}
tick={{ fontSize: 10, fill: '#64748B' }}
interval="preserveStartEnd"
axisLine={{ stroke: '#E2E8F0' }}
/>
<YAxis
yAxisId="left"
tick={{ fontSize: 10, fill: '#64748B' }}
axisLine={{ stroke: '#E2E8F0' }}
/>
<YAxis
yAxisId="right"
orientation="right"
tick={{ fontSize: 10, fill: '#F59E0B' }}
axisLine={{ stroke: '#E2E8F0' }}
/>
<Tooltip
contentStyle={{
backgroundColor: '#FFFFFF',
border: '1px solid #E2E8F0',
borderRadius: '8px',
fontSize: '12px',
}}
labelStyle={{ color: '#1E293B', fontWeight: 600 }}
/>
<Legend wrapperStyle={{ fontSize: '11px' }} />
<Line
yAxisId="left"
type="monotone"
dataKey="cases"
name="病例数"
stroke="#3B82F6"
strokeWidth={2}
dot={false}
activeDot={{ r: 3 }}
/>
<Line
yAxisId="right"
type="monotone"
dataKey="aqi"
name="AQI"
stroke="#F59E0B"
strokeWidth={2}
strokeDasharray="5 5"
dot={false}
activeDot={{ r: 3 }}
/>
</LineChart>
</ResponsiveContainer>
) : (
<div className="text-center py-8 text-gray-400 text-sm"></div>
)}
</div>
{/* Section 3 + 4: Top Districts + Top Diagnoses side by side */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Section 3: Top 5 Districts */}
<div className="card p-4">
<div className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-4">
Top 5
</div>
{topDistricts.length > 0 ? (
<ResponsiveContainer width="100%" height={220}>
<BarChart
data={[...topDistricts].reverse()}
layout="vertical"
margin={{ top: 0, right: 10, left: 30, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" horizontal={false} />
<XAxis type="number" tick={{ fontSize: 10, fill: '#64748B' }} />
<YAxis
type="category"
dataKey="district"
tick={{ fontSize: 11, fill: '#374151' }}
width={60}
axisLine={false}
tickLine={false}
/>
<Tooltip
contentStyle={{
backgroundColor: '#FFFFFF',
border: '1px solid #E2E8F0',
borderRadius: '8px',
fontSize: '12px',
}}
formatter={(value: number) => [value.toLocaleString(), '病例数']}
/>
<Bar dataKey="outpatient" stackId="a" fill="#3B82F6" name="门诊" barSize={20} />
<Bar dataKey="inpatient" stackId="a" fill="#EF4444" name="住院" barSize={20} />
</BarChart>
</ResponsiveContainer>
) : (
<div className="text-center py-8 text-gray-400 text-sm"></div>
)}
</div>
{/* Section 4: Top 5 Diagnoses */}
<div className="card p-4">
<div className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-4">
Top 5
</div>
{topDiagnoses.length > 0 ? (
<ResponsiveContainer width="100%" height={220}>
<BarChart
data={[...topDiagnoses].reverse()}
layout="vertical"
margin={{ top: 0, right: 10, left: 60, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" horizontal={false} />
<XAxis type="number" tick={{ fontSize: 10, fill: '#64748B' }} />
<YAxis
type="category"
dataKey="diagnosis"
tick={{ fontSize: 11, fill: '#374151' }}
width={100}
axisLine={false}
tickLine={false}
/>
<Tooltip
contentStyle={{
backgroundColor: '#FFFFFF',
border: '1px solid #E2E8F0',
borderRadius: '8px',
fontSize: '12px',
}}
formatter={(value: number) => [value.toLocaleString(), '病例数']}
/>
<Bar dataKey="outpatient" stackId="a" fill="#3B82F6" name="门诊" barSize={16} />
<Bar dataKey="inpatient" stackId="a" fill="#EF4444" name="住院" barSize={16} />
</BarChart>
</ResponsiveContainer>
) : (
<div className="text-center py-8 text-gray-400 text-sm"></div>
)}
</div>
</div>
{/* Section 5: Alert Severity Donut */}
<div className="card p-4">
<div className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-4">
</div>
{alertPie[0].value > 0 || alertPie[1].value > 0 ? (
<div className="flex items-center justify-center">
<ResponsiveContainer width="100%" height={240}>
<PieChart>
<Pie
data={alertPie}
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={80}
paddingAngle={4}
dataKey="value"
nameKey="name"
>
{alertPie.map((entry, idx) => (
<Cell key={idx} fill={entry.color} />
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: '#FFFFFF',
border: '1px solid #E2E8F0',
borderRadius: '8px',
fontSize: '12px',
}}
formatter={(value: number, name: string) => [value, name]}
/>
<Legend
wrapperStyle={{ fontSize: '12px' }}
formatter={(value: string) => (
<span className="text-gray-700">{value}</span>
)}
/>
</PieChart>
</ResponsiveContainer>
</div>
) : (
<div className="text-center py-8 text-gray-400 text-sm"></div>
)}
</div>
</div>
</div>
);
}

View File

@@ -148,7 +148,12 @@ function ReportDetail({ reportId, onBack }: { reportId: string; onBack: () => vo
const fetchReport = useReportsStore((s) => s.fetchReport);
const { selectedDiagnoses } = useDiseaseStore();
useEffect(() => { fetchReport(reportId); }, [reportId]);
useEffect(() => {
// Skip refetch if the report is already loaded (e.g. just generated) to avoid clobbering it.
if (useReportsStore.getState().currentReport?.metadata?.report_id !== reportId) {
fetchReport(reportId);
}
}, [reportId]);
if (error) return <ErrorBanner error={error} onRetry={() => { clearError(); fetchReport(reportId); }} onDismiss={clearError} />;
if (isLoading || !currentReport) return <div className="text-center py-8 text-sm text-gray-500">...</div>;
@@ -176,12 +181,29 @@ function ReportDetail({ reportId, onBack }: { reportId: string; onBack: () => vo
<span>: {metadata.generated_at?.slice(0, 10)}</span>
</div>
</div>
<button
onClick={() => downloadCSV(currentReport)}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-green-600 text-white rounded hover:bg-green-700 transition-colors"
>
<Download className="w-3.5 h-3.5" /> CSV
</button>
<div className="flex items-center gap-2">
<button
onClick={() => downloadCSV(currentReport)}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-green-600 text-white rounded hover:bg-green-700 transition-colors"
>
<Download className="w-3.5 h-3.5" /> CSV
</button>
<button
onClick={() => {
const json = JSON.stringify(currentReport, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${reportId}.json`;
link.click();
URL.revokeObjectURL(url);
}}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors"
>
<Download className="w-3.5 h-3.5" /> JSON
</button>
</div>
</div>
{/* Summary cards */}
@@ -285,7 +307,11 @@ export function ReportsCenter() {
setIsGenerating(true);
try {
await generateReport(genType);
setView('detail');
const generated = useReportsStore.getState().currentReport;
if (generated) {
setSelectedReportId(generated.metadata.report_id);
setView('detail');
}
} finally {
setIsGenerating(false);
}

View File

@@ -10,10 +10,15 @@ import {
ResponsiveContainer,
AreaChart,
Area,
BarChart,
Bar,
ReferenceLine,
} from 'recharts';
import { useAnalysisStore } from '@/stores/analysisStore';
import { caseApi } from '@/services/api';
import { ErrorBanner } from '@/components/ErrorBanner';
import { TrendingUp, Calendar, Activity } from 'lucide-react';
import type { CaseTrendPoint } from '@/types';
const POLLUTANT_OPTIONS = [
{ key: 'aqi', label: 'AQI', color: '#2563EB', unit: '' },
@@ -40,11 +45,85 @@ export function TrendAnalysis() {
const setSelectedDays = useAnalysisStore((s) => s.setSelectedDays);
const fetchTrend = useAnalysisStore((s) => s.fetchTrend);
const [selectedPollutants, setSelectedPollutants] = useState<string[]>(['aqi', 'pm25']);
const [multiYearData, setMultiYearData] = useState<Record<string, CaseTrendPoint[]>>({});
const [multiYearLoading, setMultiYearLoading] = useState(false);
useEffect(() => {
fetchTrend(selectedDays);
}, [selectedDays, fetchTrend]);
useEffect(() => {
let cancelled = false;
const years = [2022, 2023, 2024];
const fetchYears = async () => {
setMultiYearLoading(true);
const results: Record<string, CaseTrendPoint[]> = {};
for (const year of years) {
try {
const data = await caseApi.getTrend({
start_date: `${year}-01-01`,
end_date: `${year}-12-31`,
group_by: 'month',
});
if (!cancelled && data.trend && data.trend.length > 0) {
results[String(year)] = data.trend;
}
} catch {
// skip years with no data
}
}
if (!cancelled) {
setMultiYearData(results);
setMultiYearLoading(false);
}
};
fetchYears();
return () => { cancelled = true; };
}, []);
// Merge multi-year data by month (data is monthly) over a fixed 1..12 sequence
const mergedMultiYearData = (() => {
const yearColors: Record<string, string> = { '2022': '#94A3B8', '2023': '#3B82F6', '2024': '#EF4444' };
// month (1..12) -> { 2022?: total, 2023?: total, 2024?: total }
const byMonth: Record<number, Record<string, number>> = {};
for (const [year, points] of Object.entries(multiYearData)) {
for (const point of points) {
const month = new Date(point.date).getMonth() + 1;
if (isNaN(month)) continue;
if (!byMonth[month]) byMonth[month] = {};
byMonth[month][year] = point.total;
}
}
const monthLabels = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'];
const chartData = Array.from({ length: 12 }, (_, i) => {
const month = i + 1;
return { md: monthLabels[i], ...(byMonth[month] || {}) };
});
return { chartData, yearColors };
})();
// Day-of-week computation from store's trendData (case data via fetchCaseTrend)
const dayOfWeekData = (() => {
const dayNames = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
const dayTotals: Record<string, { total: number; count: number }> = {};
for (const name of dayNames) {
dayTotals[name] = { total: 0, count: 0 };
}
for (const point of trendData) {
const d = new Date(point.date);
if (isNaN(d.getTime())) continue;
const dayName = dayNames[d.getDay()];
dayTotals[dayName].total += point.aqi || 0;
dayTotals[dayName].count += 1;
}
const result = dayNames.map((name) => ({
day: name,
avg: dayTotals[name].count > 0 ? Math.round(dayTotals[name].total / dayTotals[name].count) : 0,
}));
const totalAvg = result.reduce((sum, d) => sum + d.avg, 0) / result.length || 0;
return { data: result, mean: Math.round(totalAvg) };
})();
const togglePollutant = (key: string) => {
setSelectedPollutants((prev) =>
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]
@@ -63,7 +142,8 @@ export function TrendAnalysis() {
if (!latestData || !firstData) return 0;
const latest = latestData[key as keyof typeof latestData] as number;
const first = firstData[key as keyof typeof firstData] as number;
if (!first) return 0;
const EPSILON = 1e-6;
if (!Number.isFinite(first) || !Number.isFinite(latest) || Math.abs(first) < EPSILON) return 0;
return ((latest - first) / first) * 100;
};
@@ -138,7 +218,7 @@ export function TrendAnalysis() {
<div className="card p-4 mb-4">
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
</div>
<ResponsiveContainer width="100%" height={360}>
<LineChart data={trendData} margin={{ top: 5, right: 20, left: 10, bottom: 5 }}>
@@ -263,6 +343,106 @@ export function TrendAnalysis() {
})}
</div>
)}
{/* Multi-Year Comparison */}
<div className="card p-4 mb-4">
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
</div>
{multiYearLoading ? (
<div className="text-center py-8 text-text-secondary text-sm">...</div>
) : Object.keys(multiYearData).length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<LineChart
data={mergedMultiYearData.chartData}
margin={{ top: 5, right: 20, left: 10, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
<XAxis
dataKey="md"
tick={{ fontSize: 12, fill: '#64748B' }}
axisLine={{ stroke: '#E2E8F0' }}
label={{ value: '月份', position: 'insideBottom', offset: -5, fontSize: 11, fill: '#64748B' }}
/>
<YAxis
tick={{ fontSize: 12, fill: '#64748B' }}
axisLine={{ stroke: '#E2E8F0' }}
label={{ value: '病例数', angle: -90, position: 'insideLeft', offset: 0, fontSize: 11, fill: '#64748B' }}
/>
<Tooltip
contentStyle={{
backgroundColor: '#FFFFFF',
border: '1px solid #E2E8F0',
borderRadius: '8px',
fontSize: '12px',
}}
/>
<Legend wrapperStyle={{ fontSize: '12px', paddingTop: '12px' }} />
{Object.keys(multiYearData).map((year) => (
<Line
key={year}
type="monotone"
dataKey={year}
name={`${year}`}
stroke={mergedMultiYearData.yearColors[year] || '#94A3B8'}
strokeWidth={2}
dot={false}
activeDot={{ r: 3 }}
connectNulls
/>
))}
</LineChart>
</ResponsiveContainer>
) : (
<div className="text-center py-8 text-text-muted text-sm"></div>
)}
</div>
{/* Day-of-Week Pattern */}
<div className="card p-4 mb-4">
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
</div>
<ResponsiveContainer width="100%" height={250}>
<BarChart
data={dayOfWeekData.data}
margin={{ top: 5, right: 20, left: 10, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
<XAxis
dataKey="day"
tick={{ fontSize: 12, fill: '#64748B' }}
axisLine={{ stroke: '#E2E8F0' }}
/>
<YAxis
tick={{ fontSize: 12, fill: '#64748B' }}
axisLine={{ stroke: '#E2E8F0' }}
/>
<Tooltip
contentStyle={{
backgroundColor: '#FFFFFF',
border: '1px solid #E2E8F0',
borderRadius: '8px',
fontSize: '12px',
}}
formatter={(value: number) => [value, '日均AQI']}
/>
<ReferenceLine
y={dayOfWeekData.mean}
stroke="#EF4444"
strokeDasharray="4 4"
strokeWidth={1.5}
label={{
value: `均值 ${dayOfWeekData.mean}`,
position: 'right',
fontSize: 11,
fill: '#EF4444',
}}
/>
<Bar dataKey="avg" fill="#3B82F6" barSize={36} radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
);
}

View File

@@ -2,7 +2,7 @@
* US-005: API client (api.ts) unit tests.
* Tests caching, request deduplication, and cache management.
*/
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
vi.mock('axios', () => {
const mockAxiosInstance = {
@@ -26,7 +26,7 @@ describe('getCacheKey', () => {
let getCacheKey: Function;
beforeEach(async () => {
const mod = await import('@/services/api');
await import('@/services/api');
// Access internal function via module scope eval
// Since getCacheKey is not exported, we test its behavior through cachedGet
getCacheKey = (url: string, params?: Record<string, any>) => {
@@ -86,7 +86,7 @@ describe('cachedPost', () => {
const mockInstance = (axios.create as any).mock.results[0].value;
mockInstance.post.mockResolvedValueOnce({ data: { ok: true } });
const result = await cachedPost('/test', { foo: 'bar' });
await cachedPost('/test', { foo: 'bar' });
expect(mockInstance.post).toHaveBeenCalledWith('/test', { foo: 'bar' });
});
});

View File

@@ -14,6 +14,11 @@ import type {
ReportListResponse,
ReportResponse,
ReportSummary,
DemographicsResponse,
DiseaseSeasonalityResponse,
LagCorrelationResponse,
PollutantResponse,
DiagnosisDistributionResponse,
} from '@/types';
interface CacheEntry<T> {
@@ -23,6 +28,7 @@ interface CacheEntry<T> {
}
const CACHE_TTL = 30000;
const CACHE_MAX_ENTRIES = 30;
const cache = new Map<string, CacheEntry<any>>();
const pendingControllers = new Map<string, AbortController>();
@@ -43,11 +49,20 @@ function getCached<T>(key: string): T | undefined {
cache.delete(key);
return undefined;
}
// Refresh recency for LRU: re-insert so this key becomes most-recently-used.
cache.delete(key);
cache.set(key, entry);
return entry.data;
}
function setCache<T>(key: string, data: T): void {
cache.set(key, { data, timestamp: Date.now() });
// LRU cap: evict oldest entries (Map preserves insertion order) beyond the cap.
while (cache.size > CACHE_MAX_ENTRIES) {
const oldest = cache.keys().next().value;
if (oldest === undefined) break;
cache.delete(oldest);
}
}
function clearPending(key: string): void {
@@ -69,10 +84,14 @@ api.interceptors.request.use((config) => {
config.headers.Authorization = `Bearer ${token}`;
}
const key = getCacheKey(config.url || '', config.params);
const controller = new AbortController();
config.signal = controller.signal;
clearPending(key);
pendingControllers.set(key, controller);
// If a caller supplied an explicit signal (e.g. cachedGet's signal param),
// honor it instead of overriding with the internal dedup controller.
if (!config.signal) {
const controller = new AbortController();
config.signal = controller.signal;
clearPending(key);
pendingControllers.set(key, controller);
}
return config;
});
@@ -87,24 +106,49 @@ api.interceptors.response.use(
const key = getCacheKey(error.config.url || '', error.config.params);
pendingControllers.delete(key);
}
// Session expired / invalid token: clear it and re-render the login screen.
// Exclude the login request itself so bad-credential errors still surface.
if (error.response?.status === 401 && !error.config?.url?.includes('/auth/login')) {
localStorage.removeItem('cbpoa_token');
cache.clear();
window.location.reload();
}
return Promise.reject(error);
}
);
export async function cachedGet<T>(url: string, params?: Record<string, any>): Promise<T> {
export async function cachedGet<T>(url: string, params?: Record<string, any>, signal?: AbortSignal): Promise<T> {
const key = getCacheKey(url, params);
const cached = getCached<T>(key);
if (cached !== undefined) return cached;
// Requests with a caller-supplied abort signal bypass the shared in-flight
// promise/cache write so an abort can't reject other callers; the response is
// still written to the TTL cache on success.
if (signal) {
return api.get<T>(url, { params, signal })
.then((res) => {
setCache(key, res.data);
return res.data;
});
}
const entry = cache.get(key);
if (entry?.promise) return entry.promise;
const promise = api.get<T>(url, { params }).then((res) => {
setCache(key, res.data);
const updated = cache.get(key);
if (updated) updated.promise = undefined;
return res.data;
});
const promise = api.get<T>(url, { params })
.then((res) => {
setCache(key, res.data);
const updated = cache.get(key);
if (updated) updated.promise = undefined;
return res.data;
})
.catch((err) => {
// Evict the failed/aborted entry so the next call retries instead of
// replaying this rejected promise for the rest of the TTL window.
cache.delete(key);
throw err;
});
cache.set(key, { data: undefined as T, timestamp: Date.now(), promise });
return promise;
@@ -133,8 +177,50 @@ export const riskApi = {
cachedGet(`/risk/grid/${encodeURIComponent(gridId)}`),
getStats: (): Promise<Stats> => cachedGet('/risk/stats'),
// Full-Wuhan 100m risk grid served as XYZ raster tiles. Returns a Leaflet
// URL template (NOT an axios call) — the browser fetches PNGs directly.
tileUrlTemplate: (day: 1 | 3 | 7, date?: string): string => {
const base = import.meta.env.VITE_API_URL || '/api';
const dateParam = date ? `&date=${date}` : '';
return `${base}/risk/tiles/{z}/{x}/{y}.png?day=${day}${dateParam}`;
},
getCell: (
lat: number,
lon: number,
day: 1 | 3 | 7 = 1,
date?: string
): Promise<RiskCell> => cachedGet('/risk/cell', { lat, lon, day, date }),
getGridStats: (day: 1 | 3 | 7 = 1, date?: string): Promise<RiskGridStats> =>
cachedGet('/risk/grid-stats', { day, date }),
};
export interface RiskCell {
grid_id: string;
row: number;
col: number;
center_lat: number;
center_lon: number;
risk_value: number;
risk_1d: number;
risk_3d: number;
risk_7d: number;
in_boundary: boolean;
forecast_day: number;
date: string;
}
export interface RiskGridStats {
cell_count: number;
avg_risk: number;
max_risk: number;
high_risk_count: number;
forecast_day: number;
date: string;
}
export const alertApi = {
getAlerts: (params?: {
min_risk?: number;
@@ -164,17 +250,34 @@ export const caseApi = {
diagnosis?: string;
}): Promise<CaseTrendResponse> => cachedGet('/cases/trend', params),
getDistricts: (params?: { diagnosis?: string }): Promise<DistrictCaseResponse> => cachedGet('/cases/districts', params),
getDistricts: (params?: { diagnosis?: string; start_date?: string; end_date?: string }): Promise<DistrictCaseResponse> => cachedGet('/cases/districts', params),
getStats: (): Promise<CaseStatsResponse> => cachedGet('/cases/stats'),
getDiagnoses: (): Promise<{ diagnoses: string[] }> => cachedGet('/cases/diagnoses'),
getDemographics: (): Promise<DemographicsResponse> => cachedGet('/cases/demographics'),
getDiseaseSeasonality: (): Promise<DiseaseSeasonalityResponse> => cachedGet('/cases/disease-seasonality'),
getDiagnosisDistribution: (limit?: number): Promise<DiagnosisDistributionResponse> => {
const params = limit ? { limit } : undefined;
return cachedGet('/cases/diagnosis-distribution', params);
},
};
export const envApi = {
getLagCorrelations: (): Promise<LagCorrelationResponse> => cachedGet('/environment/lag-correlations'),
getPollutants: (days?: number): Promise<PollutantResponse> => {
const params = days ? { days } : undefined;
return cachedGet('/environment/pollutants', params);
},
};
export const geocodedApi = {
getGrid: (): Promise<CaseGridResponse> => cachedGet('/geocoded/grid'),
getGeocoded: (params?: { limit?: number; district?: string }): Promise<GeocodedCasesResponse> =>
getGeocoded: (params?: { limit?: number; district?: string; date?: string }): Promise<GeocodedCasesResponse> =>
cachedGet('/geocoded/geocoded', params),
getStreets: (district: string): Promise<{ streets: StreetData[] }> =>

View File

@@ -0,0 +1,57 @@
# Stores — Zustand State Management
## Pattern
Every store follows the same Zustand `create<T>()` pattern with typed state + actions:
```typescript
import { create } from 'zustand';
import { someApi } from '@/services/api';
interface SomeState {
data: SomeType[];
isLoading: boolean;
error: string | null;
fetchData: () => Promise<void>;
setData: (data: SomeType[]) => void;
clearError: () => void;
}
export const useSomeStore = create<SomeState>((set, get) => ({
data: [],
isLoading: false,
error: null,
fetchData: async () => { ... },
setData: (data) => set({ data }),
clearError: () => set({ error: null }),
}));
```
## Store List
| Store | Purpose |
|-------|---------|
| `index.ts` | Exports: `useTimelineStore`, `useMonitoringStore` |
| `diseaseStore.ts` | Disease diagnosis filtering: fetch list, toggle selection |
| `drilldownStore.ts` | Admin drill-down: district → street → community hierarchy |
| `analysisStore.ts` | Trend analysis: time series, statistics |
| `reportsStore.ts` | Report generation: status, download |
## Conventions
- Cache already-loaded data: `if (get().data.length > 0) return;`
- Always set `isLoading: true` before async, `isLoading: false` after
- `error` is always `string | null` — set on catch, clear on success
- Stores are imported as named exports: `import { useDiseaseStore } from '@/stores/diseaseStore'`
## Testing
Store tests in `stores/index.test.ts`. Test state transitions, not implementation details.
## Anti-Patterns
- Don't call `set()` outside of store actions — keep mutations in the store
- Don't mix API concerns across stores — each store owns its domain
- Don't skip error state — every async action needs `try/catch` with `set({ error })`
- Don't duplicate data between stores — pick one owner
- Don't subscribe to stores in components that only pass data down — use props

View File

@@ -1,6 +1,13 @@
import { create } from 'zustand';
import axios from 'axios';
import { analysisApi, insightsApi } from '@/services/api';
import { analysisApi, insightsApi, caseApi, envApi } from '@/services/api';
import type {
DemographicsResponse,
DiseaseSeasonalityResponse,
LagCorrelationResponse,
CaseTrendResponse,
PollutantPoint,
} from '@/types';
function isCancelError(e: unknown): boolean {
return axios.isCancel(e) || (e as Error)?.message === 'canceled';
@@ -19,10 +26,12 @@ interface TrendDataPoint {
interface DistrictData {
district: string;
avg_aqi: number;
avg_risk: number;
high_risk_count: number;
avg_aqi: number;
population: number;
high_risk_count: number;
total_grids: number;
total_cases: number;
}
interface InsightCard {
@@ -40,6 +49,7 @@ interface InsightsOverview {
warning_count: number;
info_count: number;
success_count: number;
danger_count: number;
cards: InsightCard[];
}
@@ -50,11 +60,23 @@ interface AnalysisState {
isLoading: boolean;
error: string | null;
selectedDays: number;
caseTrendError: string | null;
seasonalError: string | null;
correlationError: string | null;
diagnosisDistributionError: string | null;
caseTrendData: CaseTrendResponse | null;
seasonalData: DiseaseSeasonalityResponse | null;
correlationData: LagCorrelationResponse | null;
diagnosisDistributionData: DemographicsResponse | null;
setSelectedDays: (days: number) => void;
fetchTrend: (days?: number) => Promise<void>;
fetchDistricts: () => Promise<void>;
fetchInsights: () => Promise<void>;
clearError: () => void;
fetchCaseTrend: (params?: { start_date?: string; end_date?: string; group_by?: 'day' | 'week' | 'month'; diagnosis?: string }) => Promise<void>;
fetchSeasonal: () => Promise<void>;
fetchDiagnosisDistribution: () => Promise<void>;
fetchCorrelations: () => Promise<void>;
}
export const useAnalysisStore = create<AnalysisState>((set, get) => ({
@@ -64,6 +86,14 @@ export const useAnalysisStore = create<AnalysisState>((set, get) => ({
isLoading: false,
error: null,
selectedDays: 7,
caseTrendError: null,
seasonalError: null,
correlationError: null,
diagnosisDistributionError: null,
caseTrendData: null,
seasonalData: null,
correlationData: null,
diagnosisDistributionData: null,
setSelectedDays: (days) => {
set({ selectedDays: days });
@@ -75,16 +105,17 @@ export const useAnalysisStore = create<AnalysisState>((set, get) => ({
fetchTrend: async (days = 7) => {
set({ isLoading: true, error: null });
try {
const data = await analysisApi.getTrend(days);
const trendData: TrendDataPoint[] = (data.dates || []).map((date: string, i: number) => ({
date,
aqi: Math.round((data.values?.[i] || 0.5) * 200),
pm25: Math.round((data.values?.[i] || 0.5) * 100),
pm10: Math.round((data.values?.[i] || 0.5) * 150),
so2: Math.round((data.values?.[i] || 0.5) * 30),
no2: Math.round((data.values?.[i] || 0.5) * 80),
co: Math.round((data.values?.[i] || 0.5) * 2 * 100) / 100,
o3: Math.round((data.values?.[i] || 0.5) * 150),
// Real pollutant time series (AQI/PM25/PM10/SO2/NO2/O3/CO per date).
const data = await envApi.getPollutants(days);
const trendData: TrendDataPoint[] = (data.data || []).map((p: PollutantPoint) => ({
date: p.date,
aqi: p.AQI,
pm25: p.PM25,
pm10: p.PM10,
so2: p.SO2,
no2: p.NO2,
co: p.CO,
o3: p.O3,
}));
set({ trendData, isLoading: false });
} catch (e) {
@@ -114,4 +145,48 @@ export const useAnalysisStore = create<AnalysisState>((set, get) => ({
set({ error: (e as Error).message || '加载洞察数据失败', isLoading: false });
}
},
fetchCaseTrend: async (params) => {
set({ isLoading: true, caseTrendError: null });
try {
const data = await caseApi.getTrend(params);
set({ caseTrendData: data, caseTrendError: null, isLoading: false });
} catch (e) {
if (isCancelError(e)) return;
set({ caseTrendError: (e as Error).message, isLoading: false });
}
},
fetchSeasonal: async () => {
set({ isLoading: true, seasonalError: null });
try {
const data = await caseApi.getDiseaseSeasonality();
set({ seasonalData: data, seasonalError: null, isLoading: false });
} catch (e) {
if (isCancelError(e)) return;
set({ seasonalError: (e as Error).message, isLoading: false });
}
},
fetchDiagnosisDistribution: async () => {
set({ isLoading: true, diagnosisDistributionError: null });
try {
const data = await caseApi.getDemographics();
set({ diagnosisDistributionData: data, diagnosisDistributionError: null, isLoading: false });
} catch (e) {
if (isCancelError(e)) return;
set({ diagnosisDistributionError: (e as Error).message, isLoading: false });
}
},
fetchCorrelations: async () => {
set({ isLoading: true, correlationError: null });
try {
const data = await envApi.getLagCorrelations();
set({ correlationData: data, correlationError: null, isLoading: false });
} catch (e) {
if (isCancelError(e)) return;
set({ correlationError: (e as Error).message, isLoading: false });
}
},
}));

View File

@@ -172,7 +172,7 @@ interface MonitoringState {
error: string | null;
fetchGridFeatures: (date: string) => Promise<void>;
fetchAggregatedData: (startDate: string, endDate: string, district?: string) => Promise<void>;
fetchDistrictCases: (diagnosis?: string) => Promise<void>;
fetchDistrictCases: (diagnosis?: string, startDate?: string, endDate?: string) => Promise<void>;
clearError: () => void;
}
@@ -219,10 +219,14 @@ export const useMonitoringStore = create<MonitoringState>((set) => ({
}
},
fetchDistrictCases: async (diagnosis) => {
fetchDistrictCases: async (diagnosis?: string, startDate?: string, endDate?: string) => {
set({ isLoading: true, error: null });
try {
const data = await caseApi.getDistricts(diagnosis ? { diagnosis } : undefined);
const params: Record<string, string> = {};
if (diagnosis) params.diagnosis = diagnosis;
if (startDate) params.start_date = startDate;
if (endDate) params.end_date = endDate;
const data = await caseApi.getDistricts(Object.keys(params).length > 0 ? params : undefined);
const districts = Array.isArray(data) ? data : (data as any).districts || [];
set({ districtCases: districts, isLoading: false });
} catch (e) {

View File

@@ -92,6 +92,8 @@ export interface DistrictCaseData {
outpatient: number;
inpatient: number;
total: number;
outpatient_ratio: number;
inpatient_ratio: number;
prev_period_total?: number;
change_pct?: number;
}
@@ -129,7 +131,7 @@ export interface CaseTrendResponse {
export interface DistrictCaseResponse {
districts: DistrictCaseData[];
timestamp: string;
total: number;
}
export interface CaseStatsResponse {
@@ -240,3 +242,92 @@ export interface ReportListResponse {
total: number;
timestamp: string;
}
// --- Diagnosis Distribution ---
export interface DiagnosisDistributionItem {
diagnosis: string;
outpatient: number;
inpatient: number;
total: number;
percentage: number;
}
export interface DiagnosisDistributionResponse {
diagnoses: DiagnosisDistributionItem[];
total_cases: number;
}
// --- Demographics ---
export interface AgeBin {
age_bin: number;
outpatient: number;
inpatient: number;
}
export interface GenderSplit {
outpatient: number;
inpatient: number;
}
export interface GenderSplitData {
male: GenderSplit;
female: GenderSplit;
}
export interface AgeDiagnosisMatrixItem {
age_group: string;
diagnosis: string;
outpatient: number;
inpatient: number;
total: number;
}
export interface DemographicsResponse {
age_distribution: AgeBin[];
gender_split: GenderSplitData;
age_diagnosis_matrix: AgeDiagnosisMatrixItem[];
}
// --- Disease Seasonality ---
export interface DiseaseSeasonalityPoint {
diagnosis: string;
month: number;
month_label: string;
outpatient: number;
inpatient: number;
total: number;
}
export interface DiseaseSeasonalityResponse {
seasonality: DiseaseSeasonalityPoint[];
diagnoses: string[];
}
// --- Environment ---
export interface LagCorrelationItem {
pollutant: string;
lag_days: number;
correlation: number;
}
export interface LagCorrelationResponse {
correlations: LagCorrelationItem[];
data_note: string;
}
export interface PollutantPoint {
date: string;
AQI: number;
PM25: number;
PM10: number;
SO2: number;
NO2: number;
O3: number;
CO: number;
}
export interface PollutantResponse {
data: PollutantPoint[];
station_count: number;
date_range: { start: string; end: string };
}