Files
TJWaterFrontend_Refine/src/components/olmap/HealthRiskAnalysis/Timeline.tsx
2025-12-22 17:01:37 +08:00

671 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import React, { useState, useEffect, useRef, useCallback } from "react";
import { useNotification } from "@refinedev/core";
import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
import {
Box,
Button,
Slider,
Typography,
Paper,
MenuItem,
Select,
FormControl,
InputLabel,
IconButton,
Stack,
Tooltip,
} from "@mui/material";
import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import { zhCN as pickerZhCN } from "@mui/x-date-pickers/locales";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import "dayjs/locale/zh-cn"; // 引入中文包
import dayjs from "dayjs";
import { PlayArrow, Pause, Stop, Refresh } from "@mui/icons-material";
import { TbArrowBackUp, TbArrowForwardUp } from "react-icons/tb";
import { FiSkipBack, FiSkipForward } from "react-icons/fi";
import { useData } from "../../../app/OlMap/MapComponent";
import { config, NETWORK_NAME } from "@/config/config";
import { useMap } from "../../../app/OlMap/MapComponent";
import { useHealthRisk } from "./HealthRiskContext";
import {
PredictionResult,
SurvivalFunction,
RAINBOW_COLORS,
RISK_BREAKS,
} from "./types";
const backendUrl = config.BACKEND_URL;
// 辅助函数将日期向下取整到最近的15分钟
const getRoundedDate = (date: Date): Date => {
const minutes = date.getHours() * 60 + date.getMinutes();
const roundedMinutes = Math.floor(minutes / 15) * 15;
const roundedDate = new Date(date);
roundedDate.setHours(
Math.floor(roundedMinutes / 60),
roundedMinutes % 60,
0,
0
);
return roundedDate;
};
interface TimelineProps {
schemeDate?: Date;
timeRange?: { start: Date; end: Date };
disableDateSelection?: boolean;
schemeName?: string;
}
const Timeline: React.FC<TimelineProps> = ({
disableDateSelection = false,
}) => {
const data = useData();
if (!data) {
return <div>Loading...</div>; // 或其他占位符
}
const { open } = useNotification();
const {
predictionResults,
setPredictionResults,
currentYear,
setCurrentYear,
} = useHealthRisk();
const [selectedDateTime, setSelectedDateTime] = useState<Date>(new Date());
const [isPlaying, setIsPlaying] = useState<boolean>(false);
const [playInterval, setPlayInterval] = useState<number>(5000); // 毫秒
const [isPredicting, setIsPredicting] = useState<boolean>(false);
const [pipeLayer, setPipeLayer] = useState<WebGLVectorTileLayer | null>(null);
// 使用 ref 存储当前的健康数据,供事件监听器读取,避免重复绑定
const healthDataRef = useRef<Map<string, number>>(new Map());
// 计算时间轴范围 (4-73)
const minTime = 4;
const maxTime = 73;
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const timelineRef = useRef<HTMLDivElement>(null);
// 添加防抖引用
const debounceRef = useRef<NodeJS.Timeout | null>(null);
// 时间刻度数组 (4-73每3个单位一个刻度)
const valueMarks = Array.from({ length: 24 }, (_, i) => ({
value: 4 + i * 3,
label: `${4 + i * 3}`,
}));
// 播放时间间隔选项
const intervalOptions = [
{ value: 2000, label: "2秒" },
{ value: 5000, label: "5秒" },
{ value: 10000, label: "10秒" },
];
// 处理时间轴滑动
const handleSliderChange = useCallback(
(event: Event, newValue: number | number[]) => {
const value = Array.isArray(newValue) ? newValue[0] : newValue;
// 如果有时间范围限制,只允许在范围内拖动
if (value < minTime || value > maxTime) {
return;
}
// 防抖设置currentYear避免频繁触发数据获取
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
setCurrentYear(value);
}, 500); // 500ms 防抖延迟
},
[minTime, maxTime]
);
// 播放控制
const handlePlay = useCallback(() => {
if (!isPlaying) {
setIsPlaying(true);
intervalRef.current = setInterval(() => {
setCurrentYear((prev: number) => {
let next = prev + 1;
if (next > maxTime) next = minTime;
return next;
});
}, playInterval);
}
}, [isPlaying, playInterval]);
const handlePause = useCallback(() => {
setIsPlaying(false);
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
}, []);
const handleStop = useCallback(() => {
setIsPlaying(false);
setSelectedDateTime(getRoundedDate(new Date()));
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
}, []);
// 步进控制
const handleDayStepBackward = useCallback(() => {
setSelectedDateTime((prev: Date) => {
const newDate = new Date(prev);
newDate.setDate(newDate.getDate() - 1);
return newDate;
});
}, []);
const handleDayStepForward = useCallback(() => {
setSelectedDateTime((prev: Date) => {
const newDate = new Date(prev);
newDate.setDate(newDate.getDate() + 1);
return newDate;
});
}, []);
const handleStepBackward = useCallback(() => {
setCurrentYear((prev: number) => {
let next = prev - 1;
if (next < minTime) next += maxTime - minTime + 1;
return next;
});
}, [minTime, maxTime]);
const handleStepForward = useCallback(() => {
setCurrentYear((prev: number) => {
let next = prev + 1;
if (next > maxTime) next = minTime;
return next;
});
}, [minTime, maxTime]);
// 日期时间选择处理
const handleDateTimeChange = useCallback((newDate: Date | null) => {
if (newDate) {
setSelectedDateTime(getRoundedDate(newDate));
}
}, []);
// 播放间隔改变处理
const handleIntervalChange = useCallback(
(event: any) => {
const newInterval = event.target.value;
setPlayInterval(newInterval);
// 如果正在播放,重新启动定时器
if (isPlaying && intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = setInterval(() => {
setCurrentYear((prev: number) => {
let next = prev + 1;
if (next > maxTime) next = minTime;
return next;
});
}, newInterval);
}
},
[isPlaying]
);
// 组件加载时设置初始时间为当前时间的最近15分钟
useEffect(() => {
setSelectedDateTime(getRoundedDate(new Date()));
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
};
}, [pipeLayer]);
// 获取地图实例
const map = useMap();
// 根据索引从 survival_function 中获取生存概率
const getSurvivalProbabilityAtYear = useCallback(
(survivalFunc: SurvivalFunction, index: number): number => {
const { y } = survivalFunc;
if (y.length === 0) return 1;
// 确保索引在范围内
const safeIndex = Math.max(0, Math.min(index, y.length - 1));
return y[safeIndex];
},
[]
);
// 更新管道图层中的 healthRisk 属性
const updatePipeHealthData = useCallback(
(healthData: Map<string, number>) => {
if (!pipeLayer) return;
const source = pipeLayer.getSource() as any;
if (!source) return;
const sourceTiles = source.sourceTiles_;
if (!sourceTiles) return;
Object.values(sourceTiles).forEach((vectorTile: any) => {
const renderFeatures = vectorTile.getFeatures();
if (!renderFeatures || renderFeatures.length === 0) return;
renderFeatures.forEach((renderFeature: any) => {
const featureId = renderFeature.get("id");
const value = healthData.get(featureId);
if (value !== undefined) {
renderFeature.properties_["healthRisk"] = value;
}
});
});
},
[pipeLayer]
);
// 监听瓦片加载,为新瓦片设置 healthRisk 属性
// 只在 pipeLayer 变化时绑定一次,通过 ref 获取最新数据
useEffect(() => {
if (!pipeLayer) return;
const source = pipeLayer.getSource() as any;
if (!source) return;
const listener = (event: any) => {
const vectorTile = event.tile;
const renderFeatures = vectorTile.getFeatures();
if (!renderFeatures || renderFeatures.length === 0) return;
const healthData = healthDataRef.current;
renderFeatures.forEach((renderFeature: any) => {
const featureId = renderFeature.get("id");
const value = healthData.get(featureId);
if (value !== undefined) {
renderFeature.properties_["healthRisk"] = value;
}
});
};
source.on("tileloadend", listener);
return () => {
source.un("tileloadend", listener);
};
}, [pipeLayer]);
// 应用样式到管道图层
const applyPipeHealthStyle = useCallback(() => {
if (!pipeLayer || predictionResults.length === 0) {
return;
}
// 为每条管道计算当前年份的生存概率
const pipeHealthData = new Map<string, number>();
predictionResults.forEach((result) => {
const probability = getSurvivalProbabilityAtYear(
result.survival_function,
currentYear - 4 // 使用索引 (0-based)
);
pipeHealthData.set(result.link_id, probability);
});
// 更新 ref 数据
healthDataRef.current = pipeHealthData;
// 更新图层数据
updatePipeHealthData(pipeHealthData);
// 获取所有概率值用于分类
const probabilities = Array.from(pipeHealthData.values());
if (probabilities.length === 0) return;
// 使用等距分段从0-1分为十类
const breaks = RISK_BREAKS;
// 生成彩虹色(从紫色到红色,低生存概率=高风险=红色)
const colors = RAINBOW_COLORS;
// 构建 WebGL 样式表达式
const colorCases: any[] = [];
const widthCases: any[] = [];
breaks.forEach((breakValue, index) => {
const colorStr = colors[index];
// 线宽根据健康风险调整:低生存概率(高风险)用粗线
const width = 2 + (1 - index / (breaks.length - 1)) * 4;
colorCases.push(["<=", ["get", "healthRisk"], breakValue], colorStr);
widthCases.push(["<=", ["get", "healthRisk"], breakValue], width);
});
console.log(
`应用健康风险样式,年份: ${currentYear}, 分段: ${breaks.length}`
);
console.log("颜色表达式:", colorCases);
console.log("宽度表达式:", widthCases);
// 应用样式到图层
pipeLayer.setStyle({
"stroke-color": ["case", ...colorCases, "rgba(128, 128, 128, 1)"],
"stroke-width": ["case", ...widthCases, 2],
});
}, [
pipeLayer,
predictionResults,
currentYear,
getSurvivalProbabilityAtYear,
updatePipeHealthData,
]);
// 初始化管道图层
useEffect(() => {
if (!map) return;
const layers = map.getLayers().getArray();
const pipesLayer = layers.find(
(layer) =>
layer instanceof WebGLVectorTileLayer && layer.get("value") === "pipes"
) as WebGLVectorTileLayer | undefined;
if (pipesLayer) {
setPipeLayer(pipesLayer);
}
}, [map]);
// 监听依赖变化,更新样式
useEffect(() => {
if (predictionResults.length > 0 && pipeLayer) {
applyPipeHealthStyle();
}
}, [applyPipeHealthStyle]);
// 这里防止地图缩放时,瓦片重新加载引起的属性更新出错
useEffect(() => {
// 监听地图缩放事件,缩放时停止播放
if (map) {
const onZoom = () => {
handlePause();
};
map.getView().on("change:resolution", onZoom);
// 清理事件监听
return () => {
map.getView().un("change:resolution", onZoom);
};
}
}, [map, handlePause]);
const handleSimulatePrediction = async () => {
if (!NETWORK_NAME) {
open?.({
type: "error",
message: "管网名称缺失,无法进行模拟预测。",
});
return;
}
// 提前提取日期和时间值,避免异步操作期间被拖动改变
const calculationDateTime = selectedDateTime;
// 从日期时间选择器获取完整的日期时间并转换为UTC时间
const query_time = calculationDateTime.toISOString().slice(0, 19) + "Z";
setIsPredicting(true);
// 显示处理中的通知
open?.({
type: "progress",
message: "正在模拟预测,请稍候...",
undoableTimeout: 3,
});
try {
const response = await fetch(
`${backendUrl}/timescaledb/composite/pipeline-health-prediction?query_time=${query_time}&network_name=${NETWORK_NAME}`
);
if (response.ok) {
const results: PredictionResult[] = await response.json();
setPredictionResults(results);
open?.({
type: "success",
message: `模拟预测完成,获取到 ${results.length} 条管道数据`,
});
} else {
open?.({
type: "error",
message: "模拟预测失败",
});
}
} catch (error) {
console.error("Simulation prediction failed:", error);
open?.({
type: "error",
message: "模拟预测时发生错误",
});
} finally {
setIsPredicting(false);
}
};
return (
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 w-[920px] opacity-90 hover:opacity-100 transition-opacity duration-300">
<LocalizationProvider
dateAdapter={AdapterDayjs}
adapterLocale="zh-cn"
localeText={
pickerZhCN.components.MuiLocalizationProvider.defaultProps.localeText
}
>
<Paper
elevation={3}
sx={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
zIndex: 1000,
p: 2,
backgroundColor: "rgba(255, 255, 255, 0.95)",
backdropFilter: "blur(10px)",
}}
>
<Box sx={{ width: "100%" }}>
{/* 控制按钮栏 */}
<Stack
direction="row"
spacing={2}
alignItems="center"
sx={{ mb: 2, flexWrap: "wrap", gap: 1 }}
>
<Tooltip title="后退一天">
<IconButton
color="primary"
onClick={handleDayStepBackward}
size="small"
disabled={disableDateSelection}
>
<FiSkipBack />
</IconButton>
</Tooltip>
{/* 日期时间选择器 */}
<DateTimePicker
label="模拟数据日期时间"
value={dayjs(selectedDateTime)}
onChange={(value) =>
value && handleDateTimeChange(value.toDate())
}
format="YYYY-MM-DD HH:mm"
views={["year", "month", "day", "hours", "minutes"]}
minutesStep={15}
sx={{ width: 200 }}
slotProps={{
textField: {
size: "small",
},
}}
maxDateTime={dayjs(new Date())} // 禁止选取未来的日期时间
disabled={disableDateSelection}
ampm={false}
/>
<Tooltip title="前进一天">
<IconButton
color="primary"
onClick={handleDayStepForward}
size="small"
disabled={
disableDateSelection ||
selectedDateTime.toDateString() ===
new Date().toDateString()
}
>
<FiSkipForward />
</IconButton>
</Tooltip>
{/* 播放控制按钮 */}
<Box sx={{ display: "flex", gap: 1 }} className="ml-4">
{/* 播放间隔选择 */}
<FormControl size="small" sx={{ minWidth: 100 }}>
<InputLabel></InputLabel>
<Select
value={playInterval}
label="播放间隔"
onChange={handleIntervalChange}
>
{intervalOptions.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</Select>
</FormControl>
<Tooltip title="后退一步">
<IconButton
color="primary"
onClick={handleStepBackward}
size="small"
>
<TbArrowBackUp />
</IconButton>
</Tooltip>
<Tooltip title={isPlaying ? "暂停" : "播放"}>
<IconButton
color="primary"
onClick={isPlaying ? handlePause : handlePlay}
size="small"
>
{isPlaying ? <Pause /> : <PlayArrow />}
</IconButton>
</Tooltip>
<Tooltip title="前进一步">
<IconButton
color="primary"
onClick={handleStepForward}
size="small"
>
<TbArrowForwardUp />
</IconButton>
</Tooltip>
<Tooltip title="停止">
<IconButton
color="secondary"
onClick={handleStop}
size="small"
>
<Stop />
</IconButton>
</Tooltip>
</Box>
<Box sx={{ display: "flex", gap: 1 }} className="ml-4">
{/* 功能按钮 */}
<Tooltip title="模拟预测">
<Button
variant="outlined"
startIcon={<Refresh />}
onClick={handleSimulatePrediction}
disabled={isPredicting}
sx={{ height: 40 }}
>
</Button>
</Tooltip>
</Box>
{/* 当前时间显示 */}
<Typography
variant="h6"
sx={{
ml: "auto",
fontSize: "1.2rem",
fontWeight: "bold",
color: "primary.main",
}}
>
{currentYear}
</Typography>
</Stack>
<Box ref={timelineRef} sx={{ px: 2, position: "relative" }}>
<Slider
value={currentYear}
min={minTime}
max={maxTime} // 4-73的范围
step={1} // 每1个单位一个步进
marks={valueMarks} // 显示刻度
onChange={handleSliderChange}
valueLabelDisplay="auto"
sx={{
zIndex: 10,
height: 8,
"& .MuiSlider-track": {
backgroundColor: "primary.main",
height: 6,
display: "block",
},
"& .MuiSlider-rail": {
backgroundColor: "grey.300",
height: 6,
},
"& .MuiSlider-thumb": {
height: 20,
width: 20,
backgroundColor: "primary.main",
border: "2px solid #fff",
boxShadow: "0 2px 8px rgba(0,0,0,0.2)",
"&:hover": {
boxShadow: "0 4px 12px rgba(0,0,0,0.3)",
},
},
"& .MuiSlider-mark": {
backgroundColor: "grey.400",
height: 4,
width: 2,
},
"& .MuiSlider-markActive": {
backgroundColor: "primary.main",
},
"& .MuiSlider-markLabel": {
fontSize: "0.75rem",
color: "grey.600",
},
}}
/>
</Box>
</Box>
</Paper>
</LocalizationProvider>
</div>
);
};
export default Timeline;