fix(timeline): use backend timestep
This commit is contained in:
@@ -30,6 +30,7 @@ import { FiSkipBack, FiSkipForward } from "react-icons/fi";
|
|||||||
import { config, NETWORK_NAME } from "@/config/config";
|
import { config, NETWORK_NAME } from "@/config/config";
|
||||||
import { apiFetch } from "@/lib/apiFetch";
|
import { apiFetch } from "@/lib/apiFetch";
|
||||||
import { useMap } from "@components/olmap/core/MapComponent";
|
import { useMap } from "@components/olmap/core/MapComponent";
|
||||||
|
import { useTimelineTimeConfig } from "@components/olmap/core/Controls/useTimelineTimeConfig";
|
||||||
import { useHealthRisk } from "./HealthRiskContext";
|
import { useHealthRisk } from "./HealthRiskContext";
|
||||||
import {
|
import {
|
||||||
PredictionResult,
|
PredictionResult,
|
||||||
@@ -38,10 +39,11 @@ import {
|
|||||||
RISK_BREAKS,
|
RISK_BREAKS,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
// 辅助函数:将日期向下取整到最近的15分钟
|
// 辅助函数:将日期向下取整到配置的水力时间步长
|
||||||
const getRoundedDate = (date: Date): Date => {
|
const getRoundedDate = (date: Date, stepMinutes: number): Date => {
|
||||||
|
const safeStep = stepMinutes > 0 ? stepMinutes : 15;
|
||||||
const minutes = date.getHours() * 60 + date.getMinutes();
|
const minutes = date.getHours() * 60 + date.getMinutes();
|
||||||
const roundedMinutes = Math.floor(minutes / 15) * 15;
|
const roundedMinutes = Math.floor(minutes / safeStep) * safeStep;
|
||||||
const roundedDate = new Date(date);
|
const roundedDate = new Date(date);
|
||||||
roundedDate.setHours(
|
roundedDate.setHours(
|
||||||
Math.floor(roundedMinutes / 60),
|
Math.floor(roundedMinutes / 60),
|
||||||
@@ -91,6 +93,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
||||||
const [playInterval, setPlayInterval] = useState<number>(5000); // 毫秒
|
const [playInterval, setPlayInterval] = useState<number>(5000); // 毫秒
|
||||||
const [isPredicting, setIsPredicting] = useState<boolean>(false);
|
const [isPredicting, setIsPredicting] = useState<boolean>(false);
|
||||||
|
const { stepMinutes } = useTimelineTimeConfig();
|
||||||
|
|
||||||
// 使用 ref 存储当前的健康数据,供事件监听器读取,避免重复绑定
|
// 使用 ref 存储当前的健康数据,供事件监听器读取,避免重复绑定
|
||||||
const healthDataRef = useRef<Map<string, number>>(new Map());
|
const healthDataRef = useRef<Map<string, number>>(new Map());
|
||||||
@@ -200,11 +203,14 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
}, [minTime, maxTime, setCurrentYear]);
|
}, [minTime, maxTime, setCurrentYear]);
|
||||||
|
|
||||||
// 日期时间选择处理
|
// 日期时间选择处理
|
||||||
const handleDateTimeChange = useCallback((newDate: Date | null) => {
|
const handleDateTimeChange = useCallback(
|
||||||
|
(newDate: Date | null) => {
|
||||||
if (newDate) {
|
if (newDate) {
|
||||||
setSelectedDateTime(getRoundedDate(newDate));
|
setSelectedDateTime(getRoundedDate(newDate, stepMinutes));
|
||||||
}
|
}
|
||||||
}, []);
|
},
|
||||||
|
[stepMinutes],
|
||||||
|
);
|
||||||
|
|
||||||
// 播放间隔改变处理
|
// 播放间隔改变处理
|
||||||
const handleIntervalChange = useCallback(
|
const handleIntervalChange = useCallback(
|
||||||
@@ -227,9 +233,9 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
[isPlaying, maxTime, minTime, setCurrentYear],
|
[isPlaying, maxTime, minTime, setCurrentYear],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 组件加载时设置初始时间为当前时间的最近15分钟
|
// 组件加载时设置初始时间为当前时间的配置时间步长
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedDateTime(getRoundedDate(new Date()));
|
setSelectedDateTime(getRoundedDate(new Date(), stepMinutes));
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (intervalRef.current) {
|
if (intervalRef.current) {
|
||||||
@@ -239,7 +245,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
clearTimeout(debounceRef.current);
|
clearTimeout(debounceRef.current);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, [stepMinutes]);
|
||||||
|
|
||||||
// 获取地图实例
|
// 获取地图实例
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
@@ -513,7 +519,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
}
|
}
|
||||||
format="YYYY-MM-DD HH:mm"
|
format="YYYY-MM-DD HH:mm"
|
||||||
views={["year", "month", "day", "hours", "minutes"]}
|
views={["year", "month", "day", "hours", "minutes"]}
|
||||||
minutesStep={15}
|
minutesStep={stepMinutes}
|
||||||
sx={{ width: 200 }}
|
sx={{ width: 200 }}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
textField: {
|
textField: {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
import React, { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||||
import { useNotification } from "@refinedev/core";
|
import { useNotification } from "@refinedev/core";
|
||||||
import Draggable from "react-draggable";
|
import Draggable from "react-draggable";
|
||||||
|
|
||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
getTimelineDisabledRangePercentages,
|
getTimelineDisabledRangePercentages,
|
||||||
normalizeTimelineMinutes,
|
normalizeTimelineMinutes,
|
||||||
} from "./timelineTime";
|
} from "./timelineTime";
|
||||||
|
import { useTimelineTimeConfig } from "./useTimelineTimeConfig";
|
||||||
|
|
||||||
interface TimelineProps {
|
interface TimelineProps {
|
||||||
schemeDate?: Date;
|
schemeDate?: Date;
|
||||||
@@ -64,6 +65,13 @@ const timelineIconButtonSx = {
|
|||||||
|
|
||||||
const NOOP_SET_CURRENT_TIME = (_: any) => undefined;
|
const NOOP_SET_CURRENT_TIME = (_: any) => undefined;
|
||||||
const NOOP_SET_SELECTED_DATE = (_: any) => undefined;
|
const NOOP_SET_SELECTED_DATE = (_: any) => undefined;
|
||||||
|
const BASE_CALCULATED_INTERVAL_OPTIONS = [
|
||||||
|
{ value: 1440, label: "1 天" },
|
||||||
|
{ value: 60, label: "1 小时" },
|
||||||
|
{ value: 30, label: "30 分钟" },
|
||||||
|
{ value: 15, label: "15 分钟" },
|
||||||
|
{ value: 5, label: "5 分钟" },
|
||||||
|
];
|
||||||
|
|
||||||
const Timeline: React.FC<TimelineProps> = ({
|
const Timeline: React.FC<TimelineProps> = ({
|
||||||
schemeDate,
|
schemeDate,
|
||||||
@@ -93,18 +101,28 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
const pipeText = data?.pipeText ?? "";
|
const pipeText = data?.pipeText ?? "";
|
||||||
const setForceStyleAutoApplyVersion = data?.setForceStyleAutoApplyVersion;
|
const setForceStyleAutoApplyVersion = data?.setForceStyleAutoApplyVersion;
|
||||||
const { open } = useNotification();
|
const { open } = useNotification();
|
||||||
|
const { durationMinutes, stepMinutes } = useTimelineTimeConfig();
|
||||||
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
||||||
const [playInterval, setPlayInterval] = useState<number>(15000); // 毫秒
|
const [playInterval, setPlayInterval] = useState<number>(15000); // 毫秒
|
||||||
const [calculatedInterval, setCalculatedInterval] = useState<number>(15); // 分钟
|
const [calculatedInterval, setCalculatedInterval] =
|
||||||
|
useState<number>(stepMinutes); // 分钟
|
||||||
const [isCalculating, setIsCalculating] = useState<boolean>(false);
|
const [isCalculating, setIsCalculating] = useState<boolean>(false);
|
||||||
|
|
||||||
// 计算时间轴范围
|
// 计算时间轴范围
|
||||||
const minTime = timeRange
|
const minTime = timeRange
|
||||||
? timeRange.start.getHours() * 60 + timeRange.start.getMinutes()
|
? normalizeTimelineMinutes(
|
||||||
|
timeRange.start.getHours() * 60 + timeRange.start.getMinutes(),
|
||||||
|
0,
|
||||||
|
durationMinutes,
|
||||||
|
)
|
||||||
: 0;
|
: 0;
|
||||||
const maxTime = timeRange
|
const maxTime = timeRange
|
||||||
? timeRange.end.getHours() * 60 + timeRange.end.getMinutes()
|
? normalizeTimelineMinutes(
|
||||||
: 1440;
|
timeRange.end.getHours() * 60 + timeRange.end.getMinutes(),
|
||||||
|
0,
|
||||||
|
durationMinutes,
|
||||||
|
)
|
||||||
|
: durationMinutes;
|
||||||
const timelineCurrentTime = normalizeTimelineMinutes(
|
const timelineCurrentTime = normalizeTimelineMinutes(
|
||||||
currentTime,
|
currentTime,
|
||||||
minTime,
|
minTime,
|
||||||
@@ -113,6 +131,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
const disabledRangePercentages = getTimelineDisabledRangePercentages(
|
const disabledRangePercentages = getTimelineDisabledRangePercentages(
|
||||||
minTime,
|
minTime,
|
||||||
maxTime,
|
maxTime,
|
||||||
|
durationMinutes,
|
||||||
);
|
);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (schemeDate) {
|
if (schemeDate) {
|
||||||
@@ -334,16 +353,28 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
[disableDateSelection, fetchDataBySource, isCompareMode]
|
[disableDateSelection, fetchDataBySource, isCompareMode]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 时间刻度数组 (每5分钟一个刻度)
|
|
||||||
const timeMarks = Array.from({ length: 288 }, (_, i) => ({
|
|
||||||
value: i * 5,
|
|
||||||
label: i % 24 === 0 ? formatTime(i * 5) : "",
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 格式化时间显示
|
// 格式化时间显示
|
||||||
function formatTime(minutes: number): string {
|
const formatTime = useCallback((minutes: number): string => {
|
||||||
return formatTimelineTime(minutes, minTime, maxTime);
|
return formatTimelineTime(minutes, minTime, maxTime);
|
||||||
|
}, [maxTime, minTime]);
|
||||||
|
|
||||||
|
const timeMarks = useMemo(() => {
|
||||||
|
const marks = [];
|
||||||
|
const markInterval = 120;
|
||||||
|
for (let value = 0; value <= durationMinutes; value += markInterval) {
|
||||||
|
marks.push({
|
||||||
|
value,
|
||||||
|
label: formatTime(value),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
if (marks.at(-1)?.value !== durationMinutes) {
|
||||||
|
marks.push({
|
||||||
|
value: durationMinutes,
|
||||||
|
label: formatTime(durationMinutes),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return marks;
|
||||||
|
}, [durationMinutes, formatTime]);
|
||||||
|
|
||||||
const currentTimeToDate = useCallback((selectedDate: Date, minutes: number): Date => {
|
const currentTimeToDate = useCallback((selectedDate: Date, minutes: number): Date => {
|
||||||
const date = new Date(selectedDate);
|
const date = new Date(selectedDate);
|
||||||
@@ -362,13 +393,31 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
{ value: 20000, label: "20秒" },
|
{ value: 20000, label: "20秒" },
|
||||||
];
|
];
|
||||||
// 强制计算时间段选项
|
// 强制计算时间段选项
|
||||||
const calculatedIntervalOptions = [
|
const resolvedCalculatedIntervalOptions = useMemo(() => {
|
||||||
{ value: 1440, label: "1 天" },
|
const options = BASE_CALCULATED_INTERVAL_OPTIONS.filter(
|
||||||
{ value: 60, label: "1 小时" },
|
(option) => option.value >= stepMinutes,
|
||||||
{ value: 30, label: "30 分钟" },
|
);
|
||||||
{ value: 15, label: "15 分钟" },
|
if (!options.some((option) => option.value === stepMinutes)) {
|
||||||
{ value: 5, label: "5 分钟" },
|
options.push({ value: stepMinutes, label: `${stepMinutes} 分钟` });
|
||||||
];
|
}
|
||||||
|
return options.sort((a, b) => b.value - a.value);
|
||||||
|
}, [stepMinutes]);
|
||||||
|
|
||||||
|
const advanceTimelineTime = useCallback(
|
||||||
|
(previousTime: number, direction: 1 | -1 = 1) => {
|
||||||
|
const baseTime = Number.isFinite(previousTime) ? previousTime : minTime;
|
||||||
|
let next = baseTime + stepMinutes * direction;
|
||||||
|
if (timeRange) {
|
||||||
|
if (next > maxTime) next = minTime;
|
||||||
|
if (next < minTime) next = maxTime;
|
||||||
|
} else {
|
||||||
|
if (next > durationMinutes) next = 0;
|
||||||
|
if (next < 0) next = durationMinutes;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
},
|
||||||
|
[durationMinutes, maxTime, minTime, stepMinutes, timeRange],
|
||||||
|
);
|
||||||
|
|
||||||
// 处理时间轴滑动
|
// 处理时间轴滑动
|
||||||
const handleSliderChange = useCallback(
|
const handleSliderChange = useCallback(
|
||||||
@@ -403,17 +452,11 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
|
|
||||||
intervalRef.current = setInterval(() => {
|
intervalRef.current = setInterval(() => {
|
||||||
setCurrentTime((prev) => {
|
setCurrentTime((prev) => {
|
||||||
let next = prev + 15;
|
return advanceTimelineTime(prev);
|
||||||
if (timeRange) {
|
|
||||||
if (next > maxTime) next = minTime;
|
|
||||||
} else {
|
|
||||||
if (next >= 1440) next = 0;
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
});
|
||||||
}, playInterval);
|
}, playInterval);
|
||||||
}
|
}
|
||||||
}, [isPlaying, playInterval, timeRange, maxTime, minTime, setCurrentTime]);
|
}, [advanceTimelineTime, isPlaying, playInterval, setCurrentTime]);
|
||||||
|
|
||||||
const handlePause = useCallback(() => {
|
const handlePause = useCallback(() => {
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
@@ -426,12 +469,14 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
const handleStop = useCallback(() => {
|
const handleStop = useCallback(() => {
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
// 设置为当前时间
|
// 设置为当前时间
|
||||||
setCurrentTime(getRoundedCurrentTimelineMinutes()); // 重置为当前时间
|
setCurrentTime(
|
||||||
|
getRoundedCurrentTimelineMinutes(new Date(), stepMinutes, durationMinutes),
|
||||||
|
); // 重置为当前时间
|
||||||
if (intervalRef.current) {
|
if (intervalRef.current) {
|
||||||
clearInterval(intervalRef.current);
|
clearInterval(intervalRef.current);
|
||||||
intervalRef.current = null;
|
intervalRef.current = null;
|
||||||
}
|
}
|
||||||
}, [setCurrentTime]);
|
}, [durationMinutes, setCurrentTime, stepMinutes]);
|
||||||
|
|
||||||
// 步进控制
|
// 步进控制
|
||||||
const handleDayStepBackward = useCallback(() => {
|
const handleDayStepBackward = useCallback(() => {
|
||||||
@@ -450,27 +495,15 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
}, [setSelectedDate]);
|
}, [setSelectedDate]);
|
||||||
const handleStepBackward = useCallback(() => {
|
const handleStepBackward = useCallback(() => {
|
||||||
setCurrentTime((prev) => {
|
setCurrentTime((prev) => {
|
||||||
let next = prev - 15;
|
return advanceTimelineTime(prev, -1);
|
||||||
if (timeRange) {
|
|
||||||
if (next < minTime) next = maxTime;
|
|
||||||
} else {
|
|
||||||
if (next < 0) next += 1440;
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
});
|
||||||
}, [timeRange, minTime, maxTime, setCurrentTime]);
|
}, [advanceTimelineTime, setCurrentTime]);
|
||||||
|
|
||||||
const handleStepForward = useCallback(() => {
|
const handleStepForward = useCallback(() => {
|
||||||
setCurrentTime((prev) => {
|
setCurrentTime((prev) => {
|
||||||
let next = prev + 15;
|
return advanceTimelineTime(prev);
|
||||||
if (timeRange) {
|
|
||||||
if (next > maxTime) next = minTime;
|
|
||||||
} else {
|
|
||||||
if (next >= 1440) next = 0;
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
});
|
||||||
}, [timeRange, minTime, maxTime, setCurrentTime]);
|
}, [advanceTimelineTime, setCurrentTime]);
|
||||||
|
|
||||||
// 日期选择处理
|
// 日期选择处理
|
||||||
const handleDateChange = useCallback((newDate: Date | null) => {
|
const handleDateChange = useCallback((newDate: Date | null) => {
|
||||||
@@ -490,18 +523,12 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
clearInterval(intervalRef.current);
|
clearInterval(intervalRef.current);
|
||||||
intervalRef.current = setInterval(() => {
|
intervalRef.current = setInterval(() => {
|
||||||
setCurrentTime((prev) => {
|
setCurrentTime((prev) => {
|
||||||
let next = prev + 15;
|
return advanceTimelineTime(prev);
|
||||||
if (timeRange) {
|
|
||||||
if (next > maxTime) next = minTime;
|
|
||||||
} else {
|
|
||||||
if (next >= 1440) next = 0;
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
});
|
||||||
}, newInterval);
|
}, newInterval);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isPlaying, timeRange, maxTime, minTime, setCurrentTime],
|
[advanceTimelineTime, isPlaying, setCurrentTime],
|
||||||
);
|
);
|
||||||
// 计算时间段改变处理
|
// 计算时间段改变处理
|
||||||
const handleCalculatedIntervalChange = useCallback((event: any) => {
|
const handleCalculatedIntervalChange = useCallback((event: any) => {
|
||||||
@@ -509,6 +536,10 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
setCalculatedInterval(newInterval);
|
setCalculatedInterval(newInterval);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCalculatedInterval(stepMinutes);
|
||||||
|
}, [stepMinutes]);
|
||||||
|
|
||||||
// 添加 useEffect 来监听 currentTime 和 selectedDate 的变化,并获取数据
|
// 添加 useEffect 来监听 currentTime 和 selectedDate 的变化,并获取数据
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 首次加载时,如果 selectedDate 或 currentTime 未定义,则跳过执行,避免报错
|
// 首次加载时,如果 selectedDate 或 currentTime 未定义,则跳过执行,避免报错
|
||||||
@@ -546,7 +577,9 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
// 组件卸载时清理定时器和防抖
|
// 组件卸载时清理定时器和防抖
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 设置为当前时间
|
// 设置为当前时间
|
||||||
setCurrentTime(getRoundedCurrentTimelineMinutes()); // 初始化为当前时间
|
setCurrentTime(
|
||||||
|
getRoundedCurrentTimelineMinutes(new Date(), stepMinutes, durationMinutes),
|
||||||
|
); // 初始化为当前时间
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (intervalRef.current) {
|
if (intervalRef.current) {
|
||||||
@@ -556,7 +589,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
clearTimeout(debounceRef.current);
|
clearTimeout(debounceRef.current);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [setCurrentTime]);
|
}, [durationMinutes, setCurrentTime, stepMinutes]);
|
||||||
|
|
||||||
// 当 timeRange 改变时,设置 currentTime 到 minTime
|
// 当 timeRange 改变时,设置 currentTime 到 minTime
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -868,7 +901,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
label="强制计算时间段"
|
label="强制计算时间段"
|
||||||
onChange={handleCalculatedIntervalChange}
|
onChange={handleCalculatedIntervalChange}
|
||||||
>
|
>
|
||||||
{calculatedIntervalOptions.map((option) => (
|
{resolvedCalculatedIntervalOptions.map((option) => (
|
||||||
<MenuItem key={option.value} value={option.value}>
|
<MenuItem key={option.value} value={option.value}>
|
||||||
{option.label}
|
{option.label}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
@@ -906,9 +939,9 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
<Slider
|
<Slider
|
||||||
value={timelineCurrentTime}
|
value={timelineCurrentTime}
|
||||||
min={0}
|
min={0}
|
||||||
max={1440} // 24:00 = 1440分钟
|
max={durationMinutes}
|
||||||
step={15} // 每15分钟一个步进
|
step={stepMinutes}
|
||||||
marks={timeMarks.filter((_, index) => index % 12 === 0)} // 每小时显示一个标记
|
marks={timeMarks}
|
||||||
onChange={handleSliderChange}
|
onChange={handleSliderChange}
|
||||||
valueLabelDisplay="auto"
|
valueLabelDisplay="auto"
|
||||||
valueLabelFormat={formatTime}
|
valueLabelFormat={formatTime}
|
||||||
@@ -976,7 +1009,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{/* 右侧禁用区域 */}
|
{/* 右侧禁用区域 */}
|
||||||
{maxTime < 1440 && (
|
{maxTime < durationMinutes && (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
|
coerceTimelineDurationMinutes,
|
||||||
formatTimelineTime,
|
formatTimelineTime,
|
||||||
getRoundedCurrentTimelineMinutes,
|
getRoundedCurrentTimelineMinutes,
|
||||||
getTimelineDisabledRangePercentages,
|
getTimelineDisabledRangePercentages,
|
||||||
normalizeTimelineMinutes,
|
normalizeTimelineMinutes,
|
||||||
|
parseTimelineDurationMinutes,
|
||||||
} from "./timelineTime";
|
} from "./timelineTime";
|
||||||
|
|
||||||
describe("timelineTime", () => {
|
describe("timelineTime", () => {
|
||||||
@@ -20,6 +22,28 @@ describe("timelineTime", () => {
|
|||||||
expect(getRoundedCurrentTimelineMinutes(new Date("2026-07-16T10:29:00"))).toBe(615);
|
expect(getRoundedCurrentTimelineMinutes(new Date("2026-07-16T10:29:00"))).toBe(615);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rounds the current time down to a custom timeline step", () => {
|
||||||
|
expect(
|
||||||
|
getRoundedCurrentTimelineMinutes(
|
||||||
|
new Date("2026-07-16T10:29:00"),
|
||||||
|
60,
|
||||||
|
),
|
||||||
|
).toBe(600);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses EPANET-style duration values as minutes", () => {
|
||||||
|
expect(parseTimelineDurationMinutes("0:05")).toBe(5);
|
||||||
|
expect(parseTimelineDurationMinutes("1:00")).toBe(60);
|
||||||
|
expect(parseTimelineDurationMinutes("24:00")).toBe(1440);
|
||||||
|
expect(parseTimelineDurationMinutes("0:05:00")).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back for invalid, missing, and zero duration values", () => {
|
||||||
|
expect(coerceTimelineDurationMinutes("invalid", 1440)).toBe(1440);
|
||||||
|
expect(coerceTimelineDurationMinutes(undefined, 1440)).toBe(1440);
|
||||||
|
expect(coerceTimelineDurationMinutes("0:00", 1440)).toBe(1440);
|
||||||
|
});
|
||||||
|
|
||||||
it("calculates disabled range as full-day percentages", () => {
|
it("calculates disabled range as full-day percentages", () => {
|
||||||
expect(getTimelineDisabledRangePercentages(360, 1080)).toEqual({
|
expect(getTimelineDisabledRangePercentages(360, 1080)).toEqual({
|
||||||
leftWidth: 25,
|
leftWidth: 25,
|
||||||
|
|||||||
@@ -1,6 +1,41 @@
|
|||||||
export const TIMELINE_MINUTES_PER_DAY = 1440;
|
export const TIMELINE_MINUTES_PER_DAY = 1440;
|
||||||
export const TIMELINE_STEP_MINUTES = 15;
|
export const TIMELINE_STEP_MINUTES = 15;
|
||||||
|
|
||||||
|
export const parseTimelineDurationMinutes = (
|
||||||
|
value: unknown,
|
||||||
|
): number | undefined => {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = value
|
||||||
|
.trim()
|
||||||
|
.split(":")
|
||||||
|
.map((part) => Number(part));
|
||||||
|
|
||||||
|
if (
|
||||||
|
(parts.length !== 2 && parts.length !== 3) ||
|
||||||
|
parts.some((part) => !Number.isFinite(part) || part < 0)
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [hours, minutes, seconds = 0] = parts;
|
||||||
|
if (minutes >= 60 || seconds >= 60) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hours * 60 + minutes + Math.floor(seconds / 60);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const coerceTimelineDurationMinutes = (
|
||||||
|
value: unknown,
|
||||||
|
fallbackMinutes: number,
|
||||||
|
) => {
|
||||||
|
const minutes = parseTimelineDurationMinutes(value);
|
||||||
|
return minutes && minutes > 0 ? minutes : fallbackMinutes;
|
||||||
|
};
|
||||||
|
|
||||||
export const normalizeTimelineMinutes = (
|
export const normalizeTimelineMinutes = (
|
||||||
minutes: number | undefined,
|
minutes: number | undefined,
|
||||||
minTime = 0,
|
minTime = 0,
|
||||||
@@ -13,9 +48,18 @@ export const normalizeTimelineMinutes = (
|
|||||||
return Math.min(Math.max(minutes, minTime), maxTime);
|
return Math.min(Math.max(minutes, minTime), maxTime);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getRoundedCurrentTimelineMinutes = (date = new Date()) => {
|
export const getRoundedCurrentTimelineMinutes = (
|
||||||
|
date = new Date(),
|
||||||
|
stepMinutes = TIMELINE_STEP_MINUTES,
|
||||||
|
maxMinutes = TIMELINE_MINUTES_PER_DAY,
|
||||||
|
) => {
|
||||||
|
const safeStep = stepMinutes > 0 ? stepMinutes : TIMELINE_STEP_MINUTES;
|
||||||
const minutes = date.getHours() * 60 + date.getMinutes();
|
const minutes = date.getHours() * 60 + date.getMinutes();
|
||||||
return Math.floor(minutes / TIMELINE_STEP_MINUTES) * TIMELINE_STEP_MINUTES;
|
return normalizeTimelineMinutes(
|
||||||
|
Math.floor(minutes / safeStep) * safeStep,
|
||||||
|
0,
|
||||||
|
maxMinutes,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formatTimelineTime = (
|
export const formatTimelineTime = (
|
||||||
@@ -34,14 +78,17 @@ export const formatTimelineTime = (
|
|||||||
export const getTimelineDisabledRangePercentages = (
|
export const getTimelineDisabledRangePercentages = (
|
||||||
minTime: number,
|
minTime: number,
|
||||||
maxTime: number,
|
maxTime: number,
|
||||||
|
totalMinutes = TIMELINE_MINUTES_PER_DAY,
|
||||||
) => {
|
) => {
|
||||||
const rangeStart = normalizeTimelineMinutes(minTime);
|
const safeTotalMinutes =
|
||||||
const rangeEnd = normalizeTimelineMinutes(maxTime);
|
totalMinutes > 0 ? totalMinutes : TIMELINE_MINUTES_PER_DAY;
|
||||||
|
const rangeStart = normalizeTimelineMinutes(minTime, 0, safeTotalMinutes);
|
||||||
|
const rangeEnd = normalizeTimelineMinutes(maxTime, 0, safeTotalMinutes);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
leftWidth: (rangeStart / TIMELINE_MINUTES_PER_DAY) * 100,
|
leftWidth: (rangeStart / safeTotalMinutes) * 100,
|
||||||
rightLeft: (rangeEnd / TIMELINE_MINUTES_PER_DAY) * 100,
|
rightLeft: (rangeEnd / safeTotalMinutes) * 100,
|
||||||
rightWidth:
|
rightWidth:
|
||||||
((TIMELINE_MINUTES_PER_DAY - rangeEnd) / TIMELINE_MINUTES_PER_DAY) * 100,
|
((safeTotalMinutes - rangeEnd) / safeTotalMinutes) * 100,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import { useTimelineTimeConfig, toTimelineTimeConfig } from "./useTimelineTimeConfig";
|
||||||
|
|
||||||
|
const apiFetch = jest.fn();
|
||||||
|
|
||||||
|
jest.mock("@/lib/apiFetch", () => ({
|
||||||
|
apiFetch: (...args: unknown[]) => apiFetch(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@/contexts/ProjectContext", () => ({
|
||||||
|
useProject: () => ({ networkName: "test-network" }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("useTimelineTimeConfig", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
apiFetch.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives duration and step from backend time properties", async () => {
|
||||||
|
apiFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
DURATION: "24:00",
|
||||||
|
"HYDRAULIC TIMESTEP": "1:00",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useTimelineTimeConfig());
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.stepMinutes).toBe(60);
|
||||||
|
});
|
||||||
|
expect(result.current.durationMinutes).toBe(1440);
|
||||||
|
expect(String(apiFetch.mock.calls[0][0])).toContain(
|
||||||
|
"/api/v1/gettimeproperties/?network=test-network",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back when fetching time properties fails", async () => {
|
||||||
|
apiFetch.mockRejectedValueOnce(new Error("network failure"));
|
||||||
|
const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useTimelineTimeConfig());
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(apiFetch).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
expect(result.current).toMatchObject({
|
||||||
|
durationMinutes: 1440,
|
||||||
|
stepMinutes: 15,
|
||||||
|
});
|
||||||
|
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses wrapped times payloads", () => {
|
||||||
|
expect(
|
||||||
|
toTimelineTimeConfig({
|
||||||
|
times: {
|
||||||
|
DURATION: "24:00",
|
||||||
|
"HYDRAULIC TIMESTEP": "0:05",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
durationMinutes: 1440,
|
||||||
|
stepMinutes: 5,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { config, NETWORK_NAME } from "@/config/config";
|
||||||
|
import { useProject } from "@/contexts/ProjectContext";
|
||||||
|
import { apiFetch } from "@/lib/apiFetch";
|
||||||
|
import {
|
||||||
|
coerceTimelineDurationMinutes,
|
||||||
|
TIMELINE_MINUTES_PER_DAY,
|
||||||
|
TIMELINE_STEP_MINUTES,
|
||||||
|
} from "./timelineTime";
|
||||||
|
|
||||||
|
export interface TimelineTimeConfig {
|
||||||
|
durationMinutes: number;
|
||||||
|
stepMinutes: number;
|
||||||
|
properties: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_TIMELINE_TIME_CONFIG: TimelineTimeConfig = {
|
||||||
|
durationMinutes: TIMELINE_MINUTES_PER_DAY,
|
||||||
|
stepMinutes: TIMELINE_STEP_MINUTES,
|
||||||
|
properties: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveTimeProperties = (data: unknown): Record<string, unknown> => {
|
||||||
|
if (!data || typeof data !== "object") {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = data as Record<string, unknown>;
|
||||||
|
if (record.times && typeof record.times === "object") {
|
||||||
|
return record.times as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return record;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toTimelineTimeConfig = (data: unknown): TimelineTimeConfig => {
|
||||||
|
const properties = resolveTimeProperties(data);
|
||||||
|
|
||||||
|
return {
|
||||||
|
durationMinutes: coerceTimelineDurationMinutes(
|
||||||
|
properties.DURATION,
|
||||||
|
TIMELINE_MINUTES_PER_DAY,
|
||||||
|
),
|
||||||
|
stepMinutes: coerceTimelineDurationMinutes(
|
||||||
|
properties["HYDRAULIC TIMESTEP"],
|
||||||
|
TIMELINE_STEP_MINUTES,
|
||||||
|
),
|
||||||
|
properties,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useTimelineTimeConfig = (): TimelineTimeConfig => {
|
||||||
|
const project = useProject();
|
||||||
|
const networkName = project?.networkName || NETWORK_NAME;
|
||||||
|
const [timeConfig, setTimeConfig] = useState<TimelineTimeConfig>(
|
||||||
|
DEFAULT_TIMELINE_TIME_CONFIG,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!networkName) {
|
||||||
|
setTimeConfig(DEFAULT_TIMELINE_TIME_CONFIG);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let isMounted = true;
|
||||||
|
const fetchTimeProperties = async () => {
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${config.BACKEND_URL}/api/v1/gettimeproperties/?network=${encodeURIComponent(
|
||||||
|
networkName,
|
||||||
|
)}`,
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch time properties: ${response.status}`);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
if (isMounted) {
|
||||||
|
setTimeConfig(toTimelineTimeConfig(data));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Failed to load timeline time properties:", error);
|
||||||
|
if (isMounted) {
|
||||||
|
setTimeConfig(DEFAULT_TIMELINE_TIME_CONFIG);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchTimeProperties();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, [networkName]);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
durationMinutes: timeConfig.durationMinutes,
|
||||||
|
stepMinutes: timeConfig.stepMinutes,
|
||||||
|
properties: timeConfig.properties,
|
||||||
|
}),
|
||||||
|
[timeConfig],
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
} from "./mapLifecycle";
|
} from "./mapLifecycle";
|
||||||
import { createOperationalMapResources } from "./operationalLayers";
|
import { createOperationalMapResources } from "./operationalLayers";
|
||||||
import { getRoundedCurrentTimelineMinutes } from "./Controls/timelineTime";
|
import { getRoundedCurrentTimelineMinutes } from "./Controls/timelineTime";
|
||||||
|
import { useTimelineTimeConfig } from "./Controls/useTimelineTimeConfig";
|
||||||
|
|
||||||
interface MapComponentProps {
|
interface MapComponentProps {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
@@ -140,6 +141,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
];
|
];
|
||||||
const MAP_URL = config.MAP_URL;
|
const MAP_URL = config.MAP_URL;
|
||||||
const MAP_VIEW_STORAGE_KEY = `${MAP_WORKSPACE}_map_view`; // 持久化 key
|
const MAP_VIEW_STORAGE_KEY = `${MAP_WORKSPACE}_map_view`; // 持久化 key
|
||||||
|
const { durationMinutes, stepMinutes } = useTimelineTimeConfig();
|
||||||
|
|
||||||
const mapRef = useRef<HTMLDivElement | null>(null);
|
const mapRef = useRef<HTMLDivElement | null>(null);
|
||||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
@@ -778,7 +780,9 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
}, [compareMap, isCompareMode, map]);
|
}, [compareMap, isCompareMode, map]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCurrentTime(getRoundedCurrentTimelineMinutes());
|
setCurrentTime(
|
||||||
|
getRoundedCurrentTimelineMinutes(new Date(), stepMinutes, durationMinutes),
|
||||||
|
);
|
||||||
setSelectedDate(new Date());
|
setSelectedDate(new Date());
|
||||||
setSchemeName("");
|
setSchemeName("");
|
||||||
setCurrentJunctionCalData([]);
|
setCurrentJunctionCalData([]);
|
||||||
@@ -803,7 +807,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
deckLayerRef.current?.resetSessionLayers();
|
deckLayerRef.current?.resetSessionLayers();
|
||||||
operationalResources.resetStyles();
|
operationalResources.resetStyles();
|
||||||
};
|
};
|
||||||
}, [pathname, map, operationalResources]);
|
}, [durationMinutes, pathname, map, operationalResources, stepMinutes]);
|
||||||
|
|
||||||
// 当数据变化时,更新 deck.gl 图层
|
// 当数据变化时,更新 deck.gl 图层
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user