fix(timeline): use backend timestep

This commit is contained in:
2026-07-16 14:16:07 +08:00
parent d90ca7c951
commit dfaee645ff
7 changed files with 370 additions and 82 deletions
@@ -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: {
+93 -60
View File
@@ -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],
);
};
+6 -2
View File
@@ -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(() => {