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 { apiFetch } from "@/lib/apiFetch";
|
||||
import { useMap } from "@components/olmap/core/MapComponent";
|
||||
import { useTimelineTimeConfig } from "@components/olmap/core/Controls/useTimelineTimeConfig";
|
||||
import { useHealthRisk } from "./HealthRiskContext";
|
||||
import {
|
||||
PredictionResult,
|
||||
@@ -38,10 +39,11 @@ import {
|
||||
RISK_BREAKS,
|
||||
} 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 roundedMinutes = Math.floor(minutes / 15) * 15;
|
||||
const roundedMinutes = Math.floor(minutes / safeStep) * safeStep;
|
||||
const roundedDate = new Date(date);
|
||||
roundedDate.setHours(
|
||||
Math.floor(roundedMinutes / 60),
|
||||
@@ -91,6 +93,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
||||
const [playInterval, setPlayInterval] = useState<number>(5000); // 毫秒
|
||||
const [isPredicting, setIsPredicting] = useState<boolean>(false);
|
||||
const { stepMinutes } = useTimelineTimeConfig();
|
||||
|
||||
// 使用 ref 存储当前的健康数据,供事件监听器读取,避免重复绑定
|
||||
const healthDataRef = useRef<Map<string, number>>(new Map());
|
||||
@@ -200,11 +203,14 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
}, [minTime, maxTime, setCurrentYear]);
|
||||
|
||||
// 日期时间选择处理
|
||||
const handleDateTimeChange = useCallback((newDate: Date | null) => {
|
||||
if (newDate) {
|
||||
setSelectedDateTime(getRoundedDate(newDate));
|
||||
}
|
||||
}, []);
|
||||
const handleDateTimeChange = useCallback(
|
||||
(newDate: Date | null) => {
|
||||
if (newDate) {
|
||||
setSelectedDateTime(getRoundedDate(newDate, stepMinutes));
|
||||
}
|
||||
},
|
||||
[stepMinutes],
|
||||
);
|
||||
|
||||
// 播放间隔改变处理
|
||||
const handleIntervalChange = useCallback(
|
||||
@@ -227,9 +233,9 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
[isPlaying, maxTime, minTime, setCurrentYear],
|
||||
);
|
||||
|
||||
// 组件加载时设置初始时间为当前时间的最近15分钟
|
||||
// 组件加载时设置初始时间为当前时间的配置时间步长
|
||||
useEffect(() => {
|
||||
setSelectedDateTime(getRoundedDate(new Date()));
|
||||
setSelectedDateTime(getRoundedDate(new Date(), stepMinutes));
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
@@ -239,7 +245,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}, [stepMinutes]);
|
||||
|
||||
// 获取地图实例
|
||||
const map = useMap();
|
||||
@@ -513,7 +519,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
}
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
views={["year", "month", "day", "hours", "minutes"]}
|
||||
minutesStep={15}
|
||||
minutesStep={stepMinutes}
|
||||
sx={{ width: 200 }}
|
||||
slotProps={{
|
||||
textField: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"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 Draggable from "react-draggable";
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
getTimelineDisabledRangePercentages,
|
||||
normalizeTimelineMinutes,
|
||||
} from "./timelineTime";
|
||||
import { useTimelineTimeConfig } from "./useTimelineTimeConfig";
|
||||
|
||||
interface TimelineProps {
|
||||
schemeDate?: Date;
|
||||
@@ -64,6 +65,13 @@ const timelineIconButtonSx = {
|
||||
|
||||
const NOOP_SET_CURRENT_TIME = (_: 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> = ({
|
||||
schemeDate,
|
||||
@@ -93,18 +101,28 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
const pipeText = data?.pipeText ?? "";
|
||||
const setForceStyleAutoApplyVersion = data?.setForceStyleAutoApplyVersion;
|
||||
const { open } = useNotification();
|
||||
const { durationMinutes, stepMinutes } = useTimelineTimeConfig();
|
||||
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
||||
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 minTime = timeRange
|
||||
? timeRange.start.getHours() * 60 + timeRange.start.getMinutes()
|
||||
? normalizeTimelineMinutes(
|
||||
timeRange.start.getHours() * 60 + timeRange.start.getMinutes(),
|
||||
0,
|
||||
durationMinutes,
|
||||
)
|
||||
: 0;
|
||||
const maxTime = timeRange
|
||||
? timeRange.end.getHours() * 60 + timeRange.end.getMinutes()
|
||||
: 1440;
|
||||
? normalizeTimelineMinutes(
|
||||
timeRange.end.getHours() * 60 + timeRange.end.getMinutes(),
|
||||
0,
|
||||
durationMinutes,
|
||||
)
|
||||
: durationMinutes;
|
||||
const timelineCurrentTime = normalizeTimelineMinutes(
|
||||
currentTime,
|
||||
minTime,
|
||||
@@ -113,6 +131,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
const disabledRangePercentages = getTimelineDisabledRangePercentages(
|
||||
minTime,
|
||||
maxTime,
|
||||
durationMinutes,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (schemeDate) {
|
||||
@@ -334,16 +353,28 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
[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);
|
||||
}
|
||||
}, [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 date = new Date(selectedDate);
|
||||
@@ -362,13 +393,31 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
{ value: 20000, label: "20秒" },
|
||||
];
|
||||
// 强制计算时间段选项
|
||||
const calculatedIntervalOptions = [
|
||||
{ value: 1440, label: "1 天" },
|
||||
{ value: 60, label: "1 小时" },
|
||||
{ value: 30, label: "30 分钟" },
|
||||
{ value: 15, label: "15 分钟" },
|
||||
{ value: 5, label: "5 分钟" },
|
||||
];
|
||||
const resolvedCalculatedIntervalOptions = useMemo(() => {
|
||||
const options = BASE_CALCULATED_INTERVAL_OPTIONS.filter(
|
||||
(option) => option.value >= stepMinutes,
|
||||
);
|
||||
if (!options.some((option) => option.value === stepMinutes)) {
|
||||
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(
|
||||
@@ -403,17 +452,11 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
|
||||
intervalRef.current = setInterval(() => {
|
||||
setCurrentTime((prev) => {
|
||||
let next = prev + 15;
|
||||
if (timeRange) {
|
||||
if (next > maxTime) next = minTime;
|
||||
} else {
|
||||
if (next >= 1440) next = 0;
|
||||
}
|
||||
return next;
|
||||
return advanceTimelineTime(prev);
|
||||
});
|
||||
}, playInterval);
|
||||
}
|
||||
}, [isPlaying, playInterval, timeRange, maxTime, minTime, setCurrentTime]);
|
||||
}, [advanceTimelineTime, isPlaying, playInterval, setCurrentTime]);
|
||||
|
||||
const handlePause = useCallback(() => {
|
||||
setIsPlaying(false);
|
||||
@@ -426,12 +469,14 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
const handleStop = useCallback(() => {
|
||||
setIsPlaying(false);
|
||||
// 设置为当前时间
|
||||
setCurrentTime(getRoundedCurrentTimelineMinutes()); // 重置为当前时间
|
||||
setCurrentTime(
|
||||
getRoundedCurrentTimelineMinutes(new Date(), stepMinutes, durationMinutes),
|
||||
); // 重置为当前时间
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
}, [setCurrentTime]);
|
||||
}, [durationMinutes, setCurrentTime, stepMinutes]);
|
||||
|
||||
// 步进控制
|
||||
const handleDayStepBackward = useCallback(() => {
|
||||
@@ -450,27 +495,15 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
}, [setSelectedDate]);
|
||||
const handleStepBackward = useCallback(() => {
|
||||
setCurrentTime((prev) => {
|
||||
let next = prev - 15;
|
||||
if (timeRange) {
|
||||
if (next < minTime) next = maxTime;
|
||||
} else {
|
||||
if (next < 0) next += 1440;
|
||||
}
|
||||
return next;
|
||||
return advanceTimelineTime(prev, -1);
|
||||
});
|
||||
}, [timeRange, minTime, maxTime, setCurrentTime]);
|
||||
}, [advanceTimelineTime, setCurrentTime]);
|
||||
|
||||
const handleStepForward = useCallback(() => {
|
||||
setCurrentTime((prev) => {
|
||||
let next = prev + 15;
|
||||
if (timeRange) {
|
||||
if (next > maxTime) next = minTime;
|
||||
} else {
|
||||
if (next >= 1440) next = 0;
|
||||
}
|
||||
return next;
|
||||
return advanceTimelineTime(prev);
|
||||
});
|
||||
}, [timeRange, minTime, maxTime, setCurrentTime]);
|
||||
}, [advanceTimelineTime, setCurrentTime]);
|
||||
|
||||
// 日期选择处理
|
||||
const handleDateChange = useCallback((newDate: Date | null) => {
|
||||
@@ -490,18 +523,12 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = setInterval(() => {
|
||||
setCurrentTime((prev) => {
|
||||
let next = prev + 15;
|
||||
if (timeRange) {
|
||||
if (next > maxTime) next = minTime;
|
||||
} else {
|
||||
if (next >= 1440) next = 0;
|
||||
}
|
||||
return next;
|
||||
return advanceTimelineTime(prev);
|
||||
});
|
||||
}, newInterval);
|
||||
}
|
||||
},
|
||||
[isPlaying, timeRange, maxTime, minTime, setCurrentTime],
|
||||
[advanceTimelineTime, isPlaying, setCurrentTime],
|
||||
);
|
||||
// 计算时间段改变处理
|
||||
const handleCalculatedIntervalChange = useCallback((event: any) => {
|
||||
@@ -509,6 +536,10 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
setCalculatedInterval(newInterval);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setCalculatedInterval(stepMinutes);
|
||||
}, [stepMinutes]);
|
||||
|
||||
// 添加 useEffect 来监听 currentTime 和 selectedDate 的变化,并获取数据
|
||||
useEffect(() => {
|
||||
// 首次加载时,如果 selectedDate 或 currentTime 未定义,则跳过执行,避免报错
|
||||
@@ -546,7 +577,9 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
// 组件卸载时清理定时器和防抖
|
||||
useEffect(() => {
|
||||
// 设置为当前时间
|
||||
setCurrentTime(getRoundedCurrentTimelineMinutes()); // 初始化为当前时间
|
||||
setCurrentTime(
|
||||
getRoundedCurrentTimelineMinutes(new Date(), stepMinutes, durationMinutes),
|
||||
); // 初始化为当前时间
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
@@ -556,7 +589,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
};
|
||||
}, [setCurrentTime]);
|
||||
}, [durationMinutes, setCurrentTime, stepMinutes]);
|
||||
|
||||
// 当 timeRange 改变时,设置 currentTime 到 minTime
|
||||
useEffect(() => {
|
||||
@@ -868,7 +901,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
label="强制计算时间段"
|
||||
onChange={handleCalculatedIntervalChange}
|
||||
>
|
||||
{calculatedIntervalOptions.map((option) => (
|
||||
{resolvedCalculatedIntervalOptions.map((option) => (
|
||||
<MenuItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
@@ -906,9 +939,9 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
<Slider
|
||||
value={timelineCurrentTime}
|
||||
min={0}
|
||||
max={1440} // 24:00 = 1440分钟
|
||||
step={15} // 每15分钟一个步进
|
||||
marks={timeMarks.filter((_, index) => index % 12 === 0)} // 每小时显示一个标记
|
||||
max={durationMinutes}
|
||||
step={stepMinutes}
|
||||
marks={timeMarks}
|
||||
onChange={handleSliderChange}
|
||||
valueLabelDisplay="auto"
|
||||
valueLabelFormat={formatTime}
|
||||
@@ -976,7 +1009,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
/>
|
||||
)}
|
||||
{/* 右侧禁用区域 */}
|
||||
{maxTime < 1440 && (
|
||||
{maxTime < durationMinutes && (
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
coerceTimelineDurationMinutes,
|
||||
formatTimelineTime,
|
||||
getRoundedCurrentTimelineMinutes,
|
||||
getTimelineDisabledRangePercentages,
|
||||
normalizeTimelineMinutes,
|
||||
parseTimelineDurationMinutes,
|
||||
} from "./timelineTime";
|
||||
|
||||
describe("timelineTime", () => {
|
||||
@@ -20,6 +22,28 @@ describe("timelineTime", () => {
|
||||
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", () => {
|
||||
expect(getTimelineDisabledRangePercentages(360, 1080)).toEqual({
|
||||
leftWidth: 25,
|
||||
|
||||
@@ -1,6 +1,41 @@
|
||||
export const TIMELINE_MINUTES_PER_DAY = 1440;
|
||||
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 = (
|
||||
minutes: number | undefined,
|
||||
minTime = 0,
|
||||
@@ -13,9 +48,18 @@ export const normalizeTimelineMinutes = (
|
||||
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();
|
||||
return Math.floor(minutes / TIMELINE_STEP_MINUTES) * TIMELINE_STEP_MINUTES;
|
||||
return normalizeTimelineMinutes(
|
||||
Math.floor(minutes / safeStep) * safeStep,
|
||||
0,
|
||||
maxMinutes,
|
||||
);
|
||||
};
|
||||
|
||||
export const formatTimelineTime = (
|
||||
@@ -34,14 +78,17 @@ export const formatTimelineTime = (
|
||||
export const getTimelineDisabledRangePercentages = (
|
||||
minTime: number,
|
||||
maxTime: number,
|
||||
totalMinutes = TIMELINE_MINUTES_PER_DAY,
|
||||
) => {
|
||||
const rangeStart = normalizeTimelineMinutes(minTime);
|
||||
const rangeEnd = normalizeTimelineMinutes(maxTime);
|
||||
const safeTotalMinutes =
|
||||
totalMinutes > 0 ? totalMinutes : TIMELINE_MINUTES_PER_DAY;
|
||||
const rangeStart = normalizeTimelineMinutes(minTime, 0, safeTotalMinutes);
|
||||
const rangeEnd = normalizeTimelineMinutes(maxTime, 0, safeTotalMinutes);
|
||||
|
||||
return {
|
||||
leftWidth: (rangeStart / TIMELINE_MINUTES_PER_DAY) * 100,
|
||||
rightLeft: (rangeEnd / TIMELINE_MINUTES_PER_DAY) * 100,
|
||||
leftWidth: (rangeStart / safeTotalMinutes) * 100,
|
||||
rightLeft: (rangeEnd / safeTotalMinutes) * 100,
|
||||
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";
|
||||
import { createOperationalMapResources } from "./operationalLayers";
|
||||
import { getRoundedCurrentTimelineMinutes } from "./Controls/timelineTime";
|
||||
import { useTimelineTimeConfig } from "./Controls/useTimelineTimeConfig";
|
||||
|
||||
interface MapComponentProps {
|
||||
children?: React.ReactNode;
|
||||
@@ -140,6 +141,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||
];
|
||||
const MAP_URL = config.MAP_URL;
|
||||
const MAP_VIEW_STORAGE_KEY = `${MAP_WORKSPACE}_map_view`; // 持久化 key
|
||||
const { durationMinutes, stepMinutes } = useTimelineTimeConfig();
|
||||
|
||||
const mapRef = useRef<HTMLDivElement | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
@@ -778,7 +780,9 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||
}, [compareMap, isCompareMode, map]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentTime(getRoundedCurrentTimelineMinutes());
|
||||
setCurrentTime(
|
||||
getRoundedCurrentTimelineMinutes(new Date(), stepMinutes, durationMinutes),
|
||||
);
|
||||
setSelectedDate(new Date());
|
||||
setSchemeName("");
|
||||
setCurrentJunctionCalData([]);
|
||||
@@ -803,7 +807,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||
deckLayerRef.current?.resetSessionLayers();
|
||||
operationalResources.resetStyles();
|
||||
};
|
||||
}, [pathname, map, operationalResources]);
|
||||
}, [durationMinutes, pathname, map, operationalResources, stepMinutes]);
|
||||
|
||||
// 当数据变化时,更新 deck.gl 图层
|
||||
useEffect(() => {
|
||||
|
||||
Reference in New Issue
Block a user