Files
TJWaterFrontend_Refine/src/app/OlMap/Controls/Timeline_health_risk_analysis.tsx
2025-12-18 14:49:11 +08:00

674 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 { parseColor } from "@/utils/parseColor";
import { calculateClassification } from "@/utils/breaks_classification";
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 { 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 "../MapComponent";
import { config, NETWORK_NAME } from "@/config/config";
import { useMap } from "../MapComponent";
const backendUrl = config.BACKEND_URL;
// 预测结果数据类型
interface SurvivalFunction {
x: number[]; // 时间点(年)
y: number[]; // 生存概率
a: number;
b: number;
}
interface PredictionResult {
link_id: string;
diameter: number;
velocity: number;
pressure: number;
survival_function: SurvivalFunction;
}
// 彩虹色配置
const RAINBOW_COLORS = [
"rgba(142, 68, 173, 1)", // 紫
"rgba(63, 81, 181, 1)", // 靛青
"rgba(33, 150, 243, 1)", // 天蓝
"rgba(0, 188, 212, 1)", // 青色
"rgba(0, 158, 115, 1)", // 青绿
"rgba(76, 175, 80, 1)", // 中绿
"rgba(199, 224, 0, 1)", // 黄绿
"rgba(255, 215, 0, 1)", // 金黄
"rgba(255, 127, 0, 1)", // 橙
"rgba(255, 0, 0, 1)", // 红
];
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 [selectedDateTime, setSelectedDateTime] = useState<Date>(new Date());
const [currentYear, setCurrentYear] = useState<number>(1); // 时间轴当前值 (1-100)
const [isPlaying, setIsPlaying] = useState<boolean>(false);
const [playInterval, setPlayInterval] = useState<number>(15000); // 毫秒
const [isPredicting, setIsPredicting] = useState<boolean>(false);
const [predictionResults, setPredictionResults] = useState<
PredictionResult[]
>([]);
const [pipeLayer, setPipeLayer] = useState<WebGLVectorTileLayer | null>(null);
// 计算时间轴范围 (1-100)
const minTime = 1;
const maxTime = 100;
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const timelineRef = useRef<HTMLDivElement>(null);
// 添加防抖引用
const debounceRef = useRef<NodeJS.Timeout | null>(null);
// 时间刻度数组 (1-100每10个单位一个刻度)
const valueMarks = Array.from({ length: 10 }, (_, i) => ({
value: (i + 1) * 10,
label: `${(i + 1) * 10}`,
}));
// 播放时间间隔选项
const intervalOptions = [
{ value: 5000, label: "5秒" },
{ value: 10000, label: "10秒" },
{ value: 15000, label: "15秒" },
{ value: 20000, label: "20秒" },
];
// 处理时间轴滑动
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(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;
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) {
// 将时间向下取整到最近的15分钟
const minutes = newDate.getHours() * 60 + newDate.getMinutes();
const roundedMinutes = Math.floor(minutes / 15) * 15;
const roundedDate = new Date(newDate);
roundedDate.setHours(
Math.floor(roundedMinutes / 60),
roundedMinutes % 60,
0,
0
);
setSelectedDateTime(roundedDate);
}
}, []);
// 播放间隔改变处理
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(() => {
const now = new Date();
const minutes = now.getHours() * 60 + now.getMinutes();
// 向下取整到最近的15分钟刻度
const roundedMinutes = Math.floor(minutes / 15) * 15;
const roundedDate = new Date(now);
roundedDate.setHours(
Math.floor(roundedMinutes / 60),
roundedMinutes % 60,
0,
0
);
setSelectedDateTime(roundedDate);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
};
}, []);
// 获取地图实例
const map = useMap();
// 根据年份从 survival_function 中插值获取生存概率
const getSurvivalProbabilityAtYear = useCallback(
(survivalFunc: SurvivalFunction, year: number): number => {
const { x, y } = survivalFunc;
if (x.length === 0 || y.length === 0) return 1;
// 如果年份小于最小值,返回第一个概率
if (year <= x[0]) return y[0];
// 如果年份大于最大值,返回最后一个概率
if (year >= x[x.length - 1]) return y[y.length - 1];
// 线性插值
for (let i = 0; i < x.length - 1; i++) {
if (year >= x[i] && year <= x[i + 1]) {
const ratio = (year - x[i]) / (x[i + 1] - x[i]);
return y[i] + ratio * (y[i + 1] - y[i]);
}
}
return 1;
},
[]
);
// 应用样式到管道图层
const applyPipeHealthStyle = useCallback(() => {
if (!pipeLayer || predictionResults.length === 0) {
return;
}
// 为每条管道计算当前年份的生存概率
const pipeHealthData: { [key: string]: number } = {};
predictionResults.forEach((result) => {
const probability = getSurvivalProbabilityAtYear(
result.survival_function,
currentYear
);
pipeHealthData[result.link_id] = probability;
});
// 获取所有概率值用于分类
const probabilities = Object.values(pipeHealthData);
if (probabilities.length === 0) return;
// 使用优雅分段方法计算断点10个分段
const segments = 10;
const breaks = calculateClassification(
probabilities,
segments,
"pretty_breaks"
);
// 确保包含最小值和最大值
const minVal = Math.min(...probabilities);
const maxVal = Math.max(...probabilities);
if (!breaks.includes(minVal)) {
breaks.push(minVal);
breaks.sort((a, b) => a - b);
}
if (!breaks.includes(maxVal)) {
breaks.push(maxVal);
breaks.sort((a, b) => a - b);
}
// 生成彩虹色(从紫色到红色,低生存概率=高风险=红色)
const colors = RAINBOW_COLORS;
// 构建 WebGL 样式表达式
const colorCases: any[] = [];
const widthCases: any[] = [];
breaks.forEach((breakValue, index) => {
if (index < breaks.length - 1) {
const colorIndex = Math.floor(
(index / (breaks.length - 1)) * (colors.length - 1)
);
const color = parseColor(colors[colorIndex]);
// 线宽根据健康风险调整:低生存概率(高风险)用粗线
const width = 2 + (1 - index / (breaks.length - 1)) * 4;
colorCases.push(
["between", ["get", "healthRisk"], breakValue, breaks[index + 1]],
[color.r / 255, color.g / 255, color.b / 255, 1]
);
widthCases.push(
["between", ["get", "healthRisk"], breakValue, breaks[index + 1]],
width
);
}
});
// 应用样式到图层
pipeLayer.setStyle({
"stroke-color": ["case", ...colorCases, [0.5, 0.5, 0.5, 1]],
"stroke-width": ["case", ...widthCases, 2],
});
console.log(
`已应用健康风险样式,年份: ${currentYear}, 分段: ${breaks.length}`
);
}, [pipeLayer, predictionResults, currentYear, getSurvivalProbabilityAtYear]);
// 初始化管道图层
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);
console.log("管道图层已找到");
}
}, [map]);
// 监听时间轴变化,更新样式
useEffect(() => {
if (predictionResults.length > 0 && pipeLayer) {
applyPipeHealthStyle();
}
}, [currentYear, predictionResults, pipeLayer, applyPipeHealthStyle]);
// 监听预测结果变化,首次应用样式
useEffect(() => {
if (predictionResults.length > 0 && pipeLayer) {
console.log("预测结果已更新,应用样式");
applyPipeHealthStyle();
}
}, [predictionResults, pipeLayer, 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);
console.log("预测结果:", 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">
<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={1}
max={100} // 1-100的范围
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;