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
+94 -61
View File
@@ -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],
);
};