feat: add reports center, admin drill-down, disease filter + bug fixes + perf optimization
Frontend features:
- 报表中心 (ReportsCenter): list/detail views, diagnosis breakdown chart, CSV export
- 多级行政下钻 (AdminBreadcrumb): 湖北省→武汉市→区→街道 hierarchical drill-down
- 按病种筛选 (DiseaseFilter): multi-select diagnosis filter on monitoring + reports pages
Backend:
- Add /forecast/{days} endpoint, diagnosis filter params on cases endpoints
- Add /streets aggregation endpoint, enrich reports with real case data
- Extract shared case_loader module
Bug fixes (14):
- Fix missing /risk/forecast route (404), historyApi pointing to non-existent router
- Fix min_risk filter silently ignored in insights/hotspots
- Fix type mismatches: CaseTrendResponse, CaseStatsResponse shapes
- Fix silent .catch(() => {}) swallowing errors, fetchAlerts not clearing stale state
- Fix lru_cache caching exceptions, generateReport used cachedGet for write op
- Fix missing useEffect deps in Insights, DistrictComparison, ReportsCenter
Performance (9):
- Zustand selectors across 9 components (eliminate re-render cascades)
- Fix districtCases.sort() mutating store state, inline IIFE → memo'd component
- CaseLocationMap: React.memo, race protection, correct deps
- AlertCard: stable callbacks, TimelinePlayer: useMemo, TopNav: clock isolation
- SideNav: modules array to module scope, DiseaseFilter: memoized filter
2026-06-08 18:40:08 +08:00
|
|
|
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
feat: Initial CBPOA commit — 武汉儿童呼吸疾病风险评估系统
Context: Build a spatial risk assessment system correlating air quality
data with children's respiratory disease incidence across Wuhan.
Approach: FastAPI backend serving PostGIS spatial queries, React
frontend with Deck.gl maps, and a PyTorch SpatialTemporalGCN pipeline
for multi-day (1d/3d/7d) risk prediction.
Changes:
- backend/ — FastAPI API with auth (JWT), alerts, risk analysis,
geocoded case data, grid statistics, and report endpoints
- frontend/ — React dashboard with interactive risk maps, alert
monitoring, district comparison charts, and timeline player
- models/ — SpatialTemporalGCN model with trained weights and ONNX
export for inference
- scripts/ — ETL pipeline for weather + medical data, grid generation,
feature engineering, training, and daily inference
- deploy/ — Docker Compose configs for backend, frontend, and MLflow
- docs/ — API docs, deployment guide, user guide, and code review
Impact: Enables spatial risk visualization, alert monitoring, and
ML-driven health risk forecasting for environmental health teams.
2026-06-05 02:13:49 +08:00
|
|
|
import { Play, Pause, SkipBack, SkipForward } from 'lucide-react';
|
|
|
|
|
|
|
|
|
|
interface TimelinePlayerProps {
|
|
|
|
|
startDate: string;
|
|
|
|
|
endDate: string;
|
|
|
|
|
currentDate: string;
|
|
|
|
|
onDateChange: (date: string) => void;
|
|
|
|
|
isPlaying?: boolean;
|
|
|
|
|
speed?: number;
|
|
|
|
|
onSpeedChange?: (speed: number) => void;
|
|
|
|
|
onPlayPause?: (playing: boolean) => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const SPEEDS = [0.5, 1, 2, 5, 10];
|
|
|
|
|
|
|
|
|
|
export function TimelinePlayer({
|
|
|
|
|
startDate,
|
|
|
|
|
endDate,
|
|
|
|
|
currentDate,
|
|
|
|
|
onDateChange,
|
|
|
|
|
isPlaying = false,
|
|
|
|
|
speed = 1,
|
|
|
|
|
onSpeedChange,
|
|
|
|
|
onPlayPause,
|
|
|
|
|
}: TimelinePlayerProps) {
|
|
|
|
|
const [playing, setPlaying] = useState(isPlaying);
|
|
|
|
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
|
|
|
|
|
|
|
|
const generateDateRange = useCallback((start: string, end: string) => {
|
|
|
|
|
const dates: string[] = [];
|
|
|
|
|
const current = new Date(start);
|
|
|
|
|
const final = new Date(end);
|
|
|
|
|
|
|
|
|
|
while (current <= final) {
|
|
|
|
|
dates.push(current.toISOString().split('T')[0]);
|
|
|
|
|
current.setDate(current.getDate() + 1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return dates;
|
|
|
|
|
}, []);
|
|
|
|
|
|
feat: add reports center, admin drill-down, disease filter + bug fixes + perf optimization
Frontend features:
- 报表中心 (ReportsCenter): list/detail views, diagnosis breakdown chart, CSV export
- 多级行政下钻 (AdminBreadcrumb): 湖北省→武汉市→区→街道 hierarchical drill-down
- 按病种筛选 (DiseaseFilter): multi-select diagnosis filter on monitoring + reports pages
Backend:
- Add /forecast/{days} endpoint, diagnosis filter params on cases endpoints
- Add /streets aggregation endpoint, enrich reports with real case data
- Extract shared case_loader module
Bug fixes (14):
- Fix missing /risk/forecast route (404), historyApi pointing to non-existent router
- Fix min_risk filter silently ignored in insights/hotspots
- Fix type mismatches: CaseTrendResponse, CaseStatsResponse shapes
- Fix silent .catch(() => {}) swallowing errors, fetchAlerts not clearing stale state
- Fix lru_cache caching exceptions, generateReport used cachedGet for write op
- Fix missing useEffect deps in Insights, DistrictComparison, ReportsCenter
Performance (9):
- Zustand selectors across 9 components (eliminate re-render cascades)
- Fix districtCases.sort() mutating store state, inline IIFE → memo'd component
- CaseLocationMap: React.memo, race protection, correct deps
- AlertCard: stable callbacks, TimelinePlayer: useMemo, TopNav: clock isolation
- SideNav: modules array to module scope, DiseaseFilter: memoized filter
2026-06-08 18:40:08 +08:00
|
|
|
const dateRange = useMemo(() => generateDateRange(startDate, endDate), [startDate, endDate, generateDateRange]);
|
|
|
|
|
const currentIndex = useMemo(() => dateRange.indexOf(currentDate), [dateRange, currentDate]);
|
|
|
|
|
const progress = useMemo(() => ((currentIndex + 1) / dateRange.length) * 100, [currentIndex, dateRange.length]);
|
feat: Initial CBPOA commit — 武汉儿童呼吸疾病风险评估系统
Context: Build a spatial risk assessment system correlating air quality
data with children's respiratory disease incidence across Wuhan.
Approach: FastAPI backend serving PostGIS spatial queries, React
frontend with Deck.gl maps, and a PyTorch SpatialTemporalGCN pipeline
for multi-day (1d/3d/7d) risk prediction.
Changes:
- backend/ — FastAPI API with auth (JWT), alerts, risk analysis,
geocoded case data, grid statistics, and report endpoints
- frontend/ — React dashboard with interactive risk maps, alert
monitoring, district comparison charts, and timeline player
- models/ — SpatialTemporalGCN model with trained weights and ONNX
export for inference
- scripts/ — ETL pipeline for weather + medical data, grid generation,
feature engineering, training, and daily inference
- deploy/ — Docker Compose configs for backend, frontend, and MLflow
- docs/ — API docs, deployment guide, user guide, and code review
Impact: Enables spatial risk visualization, alert monitoring, and
ML-driven health risk forecasting for environmental health teams.
2026-06-05 02:13:49 +08:00
|
|
|
|
|
|
|
|
const play = useCallback(() => {
|
|
|
|
|
setPlaying(true);
|
|
|
|
|
onPlayPause?.(true);
|
|
|
|
|
}, [onPlayPause]);
|
|
|
|
|
|
|
|
|
|
const pause = useCallback(() => {
|
|
|
|
|
setPlaying(false);
|
|
|
|
|
onPlayPause?.(false);
|
|
|
|
|
}, [onPlayPause]);
|
|
|
|
|
|
|
|
|
|
const togglePlay = () => {
|
|
|
|
|
if (playing) {
|
|
|
|
|
pause();
|
|
|
|
|
} else {
|
|
|
|
|
play();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const goToNext = useCallback(() => {
|
|
|
|
|
const nextIndex = Math.min(currentIndex + 1, dateRange.length - 1);
|
|
|
|
|
onDateChange(dateRange[nextIndex]);
|
|
|
|
|
}, [currentIndex, dateRange, onDateChange]);
|
|
|
|
|
|
|
|
|
|
const goToStart = () => {
|
|
|
|
|
onDateChange(dateRange[0]);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (playing) {
|
|
|
|
|
const interval = 1000 / speed;
|
|
|
|
|
|
|
|
|
|
timerRef.current = setInterval(() => {
|
|
|
|
|
goToNext();
|
|
|
|
|
}, interval);
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
if (timerRef.current) {
|
|
|
|
|
clearInterval(timerRef.current);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}, [playing, speed, goToNext]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (currentIndex >= dateRange.length - 1) {
|
|
|
|
|
pause();
|
|
|
|
|
}
|
|
|
|
|
}, [currentIndex, dateRange.length, pause]);
|
|
|
|
|
|
|
|
|
|
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
|
|
|
const index = Math.round((Number(e.target.value) / 100) * (dateRange.length - 1));
|
|
|
|
|
onDateChange(dateRange[index]);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleSpeedChange = () => {
|
|
|
|
|
const currentIndex = SPEEDS.indexOf(speed);
|
|
|
|
|
const nextIndex = (currentIndex + 1) % SPEEDS.length;
|
|
|
|
|
onSpeedChange?.(SPEEDS[nextIndex]);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const formatSpeed = (s: number) => {
|
|
|
|
|
return s >= 1 ? `${s}x` : `${s.toFixed(1)}x`;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const formatDate = (dateStr: string) => {
|
|
|
|
|
const date = new Date(dateStr);
|
|
|
|
|
const today = new Date();
|
|
|
|
|
const isToday = date.toDateString() === today.toDateString();
|
|
|
|
|
|
|
|
|
|
if (isToday) {
|
|
|
|
|
return `今天 ${date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return date.toLocaleDateString('zh-CN', {
|
|
|
|
|
year: 'numeric',
|
|
|
|
|
month: '2-digit',
|
|
|
|
|
day: '2-digit',
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="fixed right-4 top-1/2 -translate-y-1/2 z-[9999] w-64">
|
|
|
|
|
<div className="bg-white/95 backdrop-blur-xl border border-gray-200/80 rounded-2xl shadow-[0_8px_32px_rgba(0,0,0,0.12)] px-4 py-3">
|
|
|
|
|
{/* Date display */}
|
|
|
|
|
<div className="text-center mb-3">
|
|
|
|
|
<div className="font-medium text-gray-900 text-sm">{formatDate(currentDate)}</div>
|
|
|
|
|
<div className="text-xs text-gray-400 mt-0.5">
|
|
|
|
|
第 {currentIndex + 1} / {dateRange.length} 天
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Vertical slider */}
|
|
|
|
|
<div className="flex justify-center mb-3">
|
|
|
|
|
<input
|
|
|
|
|
type="range"
|
|
|
|
|
min="0"
|
|
|
|
|
max="100"
|
|
|
|
|
value={progress}
|
|
|
|
|
onChange={handleSliderChange}
|
|
|
|
|
className="h-1.5 w-full bg-gray-200 rounded-full appearance-none cursor-pointer accent-blue-600"
|
|
|
|
|
style={{
|
|
|
|
|
background: `linear-gradient(to right, #2563eb 0%, #2563eb ${progress}%, #e5e7eb ${progress}%, #e5e7eb 100%)`,
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex justify-between text-[10px] text-gray-400 mb-3">
|
|
|
|
|
<span>{new Date(startDate).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })}</span>
|
|
|
|
|
<span>{new Date(endDate).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })}</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Transport controls */}
|
|
|
|
|
<div className="flex items-center justify-center gap-2">
|
|
|
|
|
<button
|
|
|
|
|
onClick={goToStart}
|
|
|
|
|
className="p-1.5 text-gray-400 hover:text-gray-700 hover:bg-gray-100 rounded-full transition-colors"
|
|
|
|
|
title="跳到开始"
|
|
|
|
|
>
|
|
|
|
|
<SkipBack className="w-4 h-4" />
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
onClick={togglePlay}
|
|
|
|
|
className="p-2.5 bg-blue-600 text-white rounded-full hover:bg-blue-700 transition-colors shadow-md"
|
|
|
|
|
>
|
|
|
|
|
{playing ? (
|
|
|
|
|
<Pause className="w-5 h-5" />
|
|
|
|
|
) : (
|
|
|
|
|
<Play className="w-5 h-5 ml-0.5" />
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
onClick={goToNext}
|
|
|
|
|
className="p-1.5 text-gray-400 hover:text-gray-700 hover:bg-gray-100 rounded-full transition-colors"
|
|
|
|
|
title="跳到下一天"
|
|
|
|
|
>
|
|
|
|
|
<SkipForward className="w-4 h-4" />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Speed */}
|
|
|
|
|
<div className="flex items-center justify-center gap-2 mt-2">
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleSpeedChange}
|
|
|
|
|
className="px-2 py-0.5 text-xs font-medium text-gray-600 bg-gray-100/80 rounded-full hover:bg-gray-200 transition-colors"
|
|
|
|
|
title="调整播放速度"
|
|
|
|
|
>
|
|
|
|
|
{formatSpeed(speed)}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|