diff --git a/src/components/olmap/core/Controls/HistoryDataPanel.test.ts b/src/components/olmap/core/Controls/HistoryDataPanel.test.ts index e5e3f79..7eb6d2c 100644 --- a/src/components/olmap/core/Controls/HistoryDataPanel.test.ts +++ b/src/components/olmap/core/Controls/HistoryDataPanel.test.ts @@ -1,21 +1,34 @@ import React from "react"; -import { render, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import dayjs from "dayjs"; +import ReactECharts from "echarts-for-react"; import { apiFetch } from "@/lib/apiFetch"; +import { getAnalysisScheme, toAnalysisSchemeRecord } from "@/lib/analysisRuns"; import HistoryDataPanel, { fetchHistoryData } from "./HistoryDataPanel"; jest.mock("@/lib/apiFetch", () => ({ apiFetch: jest.fn(), })); +jest.mock("@/lib/analysisRuns", () => ({ + ...jest.requireActual("@/lib/analysisRuns"), + getAnalysisScheme: jest.fn(), +})); jest.mock("@components/olmap/core/MapComponent", () => ({ useData: () => null, })); jest.mock("@mui/x-date-pickers", () => ({ - DateTimePicker: () => null, + DateTimePicker: ({ label, value, onChange }: { + label: string; value: dayjs.Dayjs; onChange: (value: dayjs.Dayjs) => void; + }) => React.createElement("input", { + "aria-label": label, + value: value.toISOString(), + onChange: (event: React.ChangeEvent) => onChange(dayjs(event.target.value)), + }), LocalizationProvider: ({ children }: { children: React.ReactNode }) => children, })); -jest.mock("echarts-for-react", () => () => null); +jest.mock("echarts-for-react", () => jest.fn(() => null)); const jsonResponse = (payload: unknown, status = 200) => ({ @@ -66,14 +79,63 @@ describe("fetchHistoryData", () => { "99dd4142-368b-54cb-bfca-d59ee48f6298", ); - expect(apiFetch).toHaveBeenCalledTimes(1); - const [, init] = jest.mocked(apiFetch).mock.calls[0]; - expect(JSON.parse(String(init?.body))).toMatchObject({ + expect(apiFetch).toHaveBeenCalledTimes(2); + const bodies = jest.mocked(apiFetch).mock.calls.map(([, init]) => JSON.parse(String(init?.body))); + expect(bodies[0]).toMatchObject({ mode: "realtime_comparison" }); + expect(bodies[0]).not.toHaveProperty("run_id"); + expect(bodies[1]).toMatchObject({ mode: "analysis_comparison", run_id: "99dd4142-368b-54cb-bfca-d59ee48f6298", }); }); + it("combines element simulations without SCADA and deduplicates monitoring", async () => { + const base = { + element_id: "P-1", element_type: "pipe", device_id: null, + metric: "flow", source_unit: "L/s", display_unit: "m³/h", unit_inferred: false, + }; + const observed = { + ...base, device_id: "F-1", source: "scada_raw", + points: [{ time: "2026-08-17T09:30:00Z", value: 5 }], + }; + const targets: [string, string][] = [["P-1", "pipe"], ["J-1", "junction"]]; + jest.mocked(apiFetch).mockImplementation(async (_url, init) => { + const { mode } = JSON.parse(String(init?.body)); + return jsonResponse({ series: mode === "analysis_comparison" ? [ + observed, + { ...base, source: "analysis_simulation", points: [ + { time: "2026-08-17T09:30:00Z", value: 20 }, + ] }, + ] : [ + observed, + { ...base, source: "realtime_simulation", points: [ + { time: "2026-08-17T09:00:00Z", value: 10 }, + { time: "2026-08-17T10:00:00Z", value: 12 }, + ] }, + { ...base, element_id: "J-1", element_type: "junction", metric: "pressure", + source_unit: "m", source: "realtime_simulation", points: [ + { time: "2026-08-17T09:00:00Z", value: 30 }, + ] }, + ] }); + }); + const result = await fetchHistoryData(targets, range, "scheme", "run-1"); + expect(result.series).toHaveLength(4); + expect(result.points[0].values).toEqual({ '["pipe","P-1","realtime_simulation"]': 36, '["junction","J-1","realtime_simulation"]': 30 }); + expect(result.points[1].values['["pipe","P-1","analysis_simulation"]']).toBe(72); + expect(result.points[2].values['["pipe","P-1","realtime_simulation"]']).toBeCloseTo(43.2); + expect(apiFetch).toHaveBeenCalledTimes(2); + for (const [, init] of jest.mocked(apiFetch).mock.calls) { + expect(JSON.parse(String(init?.body)).elements).toHaveLength(2); + } + }); + + it("reports an aggregate request failure instead of showing an incomplete comparison", async () => { + jest.mocked(apiFetch).mockResolvedValueOnce(jsonResponse({ series: [] })) + .mockResolvedValueOnce(jsonResponse({ detail: "analysis unavailable" }, 503)); + await expect(fetchHistoryData([["P-1", "pipe"]], range, "scheme", "run-1")) + .rejects.toThrow("analysis unavailable"); + }); + it("rejects oversized element selections before issuing requests", async () => { jest.mocked(apiFetch).mockResolvedValue(jsonResponse({})); const featureInfos = Array.from( @@ -103,10 +165,18 @@ describe("fetchHistoryData", () => { }); }); +const eventRun = (runId = "run-1", startedAt = "2026-08-17T09:00:00Z") => + toAnalysisSchemeRecord({ + run_id: runId, name: "event", run_type: "burst_analysis", created_by: "alice", + created_at: "2026-11-03T00:00:00Z", started_at: startedAt, + status: "completed", parameters: { modify_total_duration: 3600 }, + }); + describe("HistoryDataPanel requests", () => { beforeEach(() => { jest.clearAllMocks(); jest.mocked(apiFetch).mockResolvedValue(jsonResponse({ series: [] })); + jest.mocked(getAnalysisScheme).mockReset().mockResolvedValue(eventRun()); }); afterEach(() => jest.clearAllMocks()); @@ -137,4 +207,138 @@ describe("HistoryDataPanel requests", () => { ); await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2)); }); + + const props = { + featureInfos: [["P-1", "pipe"]] as [string, string][], + type: "scheme" as const, runId: "run-1", onClose: jest.fn(), + }; + + it("initializes a historical event with one hour of context on both sides", async () => { + render(React.createElement(HistoryDataPanel, props)); + await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2)); + for (const [, init] of jest.mocked(apiFetch).mock.calls) { + expect(JSON.parse(String(init?.body))).toMatchObject({ + start_time: "2026-08-17T08:00:00.000Z", end_time: "2026-08-17T11:00:00.000Z", + }); + } + expect(screen.getByText(/事件方案时段:2026-08-17 17:00/)).toBeInTheDocument(); + expect(screen.getByLabelText("开始时间")).toHaveValue("2026-08-17T08:00:00.000Z"); + }); + + it("keeps explicit query dates and issues one pair of requests per date change", async () => { + render(React.createElement(HistoryDataPanel, { + ...props, start_time: "2026-08-17T07:00:00Z", end_time: "2026-08-17T12:00:00Z", + })); + await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2)); + fireEvent.change(screen.getByLabelText("开始时间"), { target: { value: "2026-08-17T06:00:00Z" } }); + await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(4)); + expect(JSON.parse(String(jest.mocked(apiFetch).mock.calls[2][1]?.body))).toMatchObject({ + start_time: "2026-08-17T06:00:00.000Z", end_time: "2026-08-17T12:00:00.000Z", + }); + expect(jest.mocked(apiFetch).mock.calls[0][1]?.signal?.aborted).toBe(true); + expect(getAnalysisScheme).toHaveBeenCalledTimes(1); + }); + + it("ignores a stale scheme response after switching events", async () => { + let resolveOld!: (value: ReturnType) => void; + jest.mocked(getAnalysisScheme).mockReturnValueOnce(new Promise((resolve) => { resolveOld = resolve; })) + .mockResolvedValueOnce(eventRun("run-2", "2026-08-18T09:00:00Z")); + const { rerender } = render(React.createElement(HistoryDataPanel, props)); + expect(apiFetch).not.toHaveBeenCalled(); + rerender(React.createElement(HistoryDataPanel, { ...props, runId: "run-2" })); + await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2)); + await act(async () => { resolveOld(eventRun()); }); + expect(apiFetch).toHaveBeenCalledTimes(2); + expect(JSON.parse(String(jest.mocked(apiFetch).mock.calls[1][1]?.body))).toMatchObject({ + run_id: "run-2", start_time: "2026-08-18T08:00:00.000Z", + }); + }); + + it("preserves the manually chosen end date when only the start date prop changes", async () => { + const partialProps = { ...props, type: "none" as const, start_time: "2026-08-17T07:00:00Z" }; + const { rerender } = render(React.createElement(HistoryDataPanel, partialProps)); + await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(1)); + fireEvent.change(screen.getByLabelText("结束时间"), { target: { value: "2026-08-17T12:00:00Z" } }); + await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2)); + rerender(React.createElement(HistoryDataPanel, { ...partialProps, start_time: "2026-08-17T06:00:00Z" })); + await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(3)); + expect(screen.getByLabelText("结束时间")).toHaveValue("2026-08-17T12:00:00.000Z"); + expect(JSON.parse(String(jest.mocked(apiFetch).mock.calls[2][1]?.body))).toMatchObject({ + start_time: "2026-08-17T06:00:00.000Z", end_time: "2026-08-17T12:00:00.000Z", + }); + }); + + it("queries online element readings for an analysis without hydraulic results", async () => { + jest.mocked(getAnalysisScheme).mockResolvedValue({ ...eventRun(), run_type: "burst_detection" }); + render(React.createElement(HistoryDataPanel, props)); + await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(1)); + const body = JSON.parse(String(jest.mocked(apiFetch).mock.calls[0][1]?.body)); + expect(body.mode).toBe("realtime_comparison"); + expect(body).not.toHaveProperty("run_id"); + }); + + it("does not restore stale curves after the selected elements are cleared", async () => { + const pending: Array<(value: Response) => void> = []; + jest.mocked(apiFetch).mockImplementation(() => new Promise((resolve) => { pending.push(resolve); })); + const { rerender } = render(React.createElement(HistoryDataPanel, props)); + await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2)); + rerender(React.createElement(HistoryDataPanel, { ...props, featureInfos: [] })); + expect(jest.mocked(apiFetch).mock.calls[0][1]?.signal?.aborted).toBe(true); + expect(jest.mocked(apiFetch).mock.calls[1][1]?.signal?.aborted).toBe(true); + await act(async () => { + pending[0](jsonResponse({ series: [{ + element_id: "P-1", element_type: "pipe", device_id: null, metric: "flow", + source: "realtime_simulation", source_unit: "m3/h", display_unit: "m³/h", + unit_inferred: false, points: [{ time: "2026-08-17T09:00:00Z", value: 10 }], + }] })); + pending[1](jsonResponse({ series: [] })); + }); + expect(ReactECharts).not.toHaveBeenCalled(); + expect(screen.getByText("尚未选择地图要素")).toBeInTheDocument(); + }); + + it("allows a failed event metadata query to be retried", async () => { + jest.mocked(getAnalysisScheme).mockRejectedValueOnce(new Error("unavailable")); + render(React.createElement(HistoryDataPanel, props)); + await screen.findByText(/无法读取事件方案/); + expect(apiFetch).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "刷新" })); + await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2)); + expect(getAnalysisScheme).toHaveBeenCalledTimes(2); + }); + + it("plots native timestamps and explicit gaps with the event interval marked", async () => { + const series = { + element_id: "P-1", element_type: "pipe", device_id: null, metric: "flow", + source_unit: "m3/h", display_unit: "m³/h", unit_inferred: false, + }; + jest.mocked(apiFetch).mockImplementation(async (_url, init) => { + const { mode } = JSON.parse(String(init?.body)); + return jsonResponse({ series: [{ ...series, + source: mode === "analysis_comparison" ? "analysis_simulation" : "realtime_simulation", + points: mode === "analysis_comparison" ? [ + { time: "2026-08-17T09:15:00Z", value: 8 }, + ] : [ + { time: "2026-08-17T08:00:00Z", value: 10 }, + { time: "2026-08-17T09:00:00Z", value: null }, + { time: "2026-08-17T11:00:00Z", value: 12 }, + ], + }] }); + }); + render(React.createElement(HistoryDataPanel, props)); + await waitFor(() => expect(ReactECharts).toHaveBeenCalled()); + const option = jest.mocked(ReactECharts).mock.calls.at(-1)![0].option as any; + expect(option.xAxis).toMatchObject({ type: "time", min: Date.parse("2026-08-17T08:00:00Z"), max: Date.parse("2026-08-17T11:00:00Z") }); + expect(option.series[0].connectNulls).toBe(false); + expect(option.series[0].data).toEqual([ + [Date.parse("2026-08-17T08:00:00Z"), 10], + [Date.parse("2026-08-17T09:00:00Z"), null], + [Date.parse("2026-08-17T11:00:00Z"), 12], + ]); + expect(option.series[0].markArea.data[0]).toEqual([ + { name: "事件方案时段", xAxis: Date.parse("2026-08-17T09:00:00Z") }, + { xAxis: Date.parse("2026-08-17T10:00:00Z") }, + ]); + expect(option.series[1].data).toEqual([[Date.parse("2026-08-17T09:15:00Z"), 8]]); + }); }); diff --git a/src/components/olmap/core/Controls/HistoryDataPanel.tsx b/src/components/olmap/core/Controls/HistoryDataPanel.tsx index 26bacdf..b3ab868 100644 --- a/src/components/olmap/core/Controls/HistoryDataPanel.tsx +++ b/src/components/olmap/core/Controls/HistoryDataPanel.tsx @@ -41,8 +41,14 @@ import { fetchElementHistory, historySeriesKey, historySeriesLabel, + toTimeSeriesPoints, TimeSeriesPoint, } from "@/lib/elementHistory"; +import { + type AnalysisHistoryContext, + getAnalysisHistoryContext, + getAnalysisScheme, +} from "@/lib/analysisRuns"; import { FLOW_DISPLAY_UNIT, PRESSURE_DISPLAY_UNIT, @@ -54,7 +60,7 @@ dayjs.extend(timezone); export interface SCADADataPanelProps { /** 选中的要素信息列表,格式为 [[id, type], [id, type]] */ featureInfos: [string, string][]; - /** 数据类型: realtime-查询模拟值和监测值, none-仅查询监测值, scheme-查询策略模拟值和监测值 */ + /** 数据类型: realtime-在线模拟与监测, none-仅监测, scheme-方案、在线模拟与监测 */ type?: "realtime" | "scheme" | "none"; /** 默认展示的选项卡 */ defaultTab?: "chart" | "table"; @@ -107,6 +113,13 @@ export const fetchHistoryData = async ( ) { throw new Error("历史数据包含无效的管网元素 ID"); } + if ( + featureInfos.some( + ([, elementType]) => !["pipe", "junction"].includes(elementType.toLowerCase()), + ) + ) { + throw new Error("历史数据仅支持管道和节点"); + } const uniqueFeatureInfos = Array.from( new Map( @@ -118,25 +131,33 @@ export const fetchHistoryData = async ( throw new Error("历史方案缺少分析运行 ID,无法读取方案时序数据"); } - try { - return await fetchElementHistory( - uniqueFeatureInfos.map(([id, elementType]) => ({ - element_id: id.trim(), - element_type: elementType.toLowerCase() as "pipe" | "junction", - })), - range, - type === "none" - ? "observed" - : type === "scheme" - ? "analysis_comparison" - : "realtime_comparison", - schemeRunId, - signal, - ); - } catch (error) { - console.error("[HistoryDataPanel] 从后端获取数据失败:", error); - throw error; + const elements = uniqueFeatureInfos.map(([id, elementType]) => ({ + element_id: id.trim(), + element_type: elementType.toLowerCase() as "pipe" | "junction", + })); + if (type === "scheme") { + const [online, analysis] = await Promise.all([ + fetchElementHistory( + elements, range, "realtime_comparison", undefined, signal, + ), + fetchElementHistory( + elements, range, "analysis_comparison", schemeRunId, signal, + ), + ]); + // 两次聚合查询均返回监测序列,只保留在线查询中的一份。 + const series = [ + ...online.series, + ...analysis.series.filter((item) => item.source === "analysis_simulation"), + ]; + return { series, points: toTimeSeriesPoints(series) }; } + return fetchElementHistory( + elements, + range, + type === "none" ? "observed" : "realtime_comparison", + undefined, + signal, + ); }; const formatTimestamp = (timestamp: string) => @@ -186,11 +207,11 @@ const emptyStateMessages: Record< > = { chart: { title: "暂无时序数据", - subtitle: "请调整时间范围,或确认所选设备在该时段已有数据。", + subtitle: "请调整时间范围,或确认所选元素在该时段已有数据。", }, table: { title: "暂无表格数据", - subtitle: "请调整时间范围,或确认所选设备在该时段已有数据。", + subtitle: "请调整时间范围,或确认所选元素在该时段已有数据。", }, }; @@ -204,30 +225,65 @@ const SCADADataPanel: React.FC = ({ runId, onClose, }) => { - // 从 featureInfos 中提取设备 ID 列表 - const deviceIds = useMemo( - () => featureInfos.map(([id]) => id), - [featureInfos] - ); + const [schemeState, setSchemeState] = useState<{ + runId: string; + context?: AnalysisHistoryContext; + error?: string; + } | null>(null); + const [schemeReload, setSchemeReload] = useState(0); + const schemeReady = type !== "scheme" || !runId || schemeState?.runId === runId; + const schemeContext = + type === "scheme" && schemeState?.runId === runId + ? schemeState?.context + : undefined; + const schemeError = + type === "scheme" && schemeState?.runId === runId + ? schemeState?.error + : undefined; + const eventRange = schemeContext?.eventRange; + const rangeKey = JSON.stringify([type, runId]); + const defaultRange = useMemo(() => { + const now = dayjs(); + const start = start_time ? dayjs(start_time) : null; + const end = end_time ? dayjs(end_time) : null; + return { + from: start?.isValid() + ? start + : eventRange + ? dayjs(eventRange.start).subtract(1, "hour") + : now.subtract(1, "day"), + to: end?.isValid() + ? end + : eventRange + ? dayjs(eventRange.end).add(1, "hour") + : now, + }; + }, [start_time, end_time, eventRange]); + const [selectedRange, setSelectedRange] = useState<{ + key: string; + from: Dayjs; + to: Dayjs; + startTime?: string; + endTime?: string; + } | null>(null); + const rangeForContext = selectedRange?.key === rangeKey ? selectedRange : null; + const from = rangeForContext && ( + rangeForContext.startTime === start_time || !start_time || !dayjs(start_time).isValid() + ) ? rangeForContext.from : defaultRange.from; + const to = rangeForContext && ( + rangeForContext.endTime === end_time || !end_time || !dayjs(end_time).isValid() + ) ? rangeForContext.to : defaultRange.to; + const setFrom = (value: Dayjs) => + setSelectedRange({ key: rangeKey, from: value, to, startTime: start_time, endTime: end_time }); + const setTo = (value: Dayjs) => + setSelectedRange({ key: rangeKey, from, to: value, startTime: start_time, endTime: end_time }); - const [from, setFrom] = useState(() => { - if (start_time) { - const parsedStart = dayjs(start_time); - if (parsedStart.isValid()) { - return parsedStart; - } - } - return dayjs().subtract(1, "day"); - }); - const [to, setTo] = useState(() => { - if (end_time) { - const parsedEnd = dayjs(end_time); - if (parsedEnd.isValid()) { - return parsedEnd; - } - } - return dayjs(); - }); + useEffect(() => { + if (!rangeForContext || ( + rangeForContext.startTime === start_time && rangeForContext.endTime === end_time + )) return; + setSelectedRange({ key: rangeKey, from, to, startTime: start_time, endTime: end_time }); + }, [rangeForContext, rangeKey, from, to, start_time, end_time]); const [activeTab, setActiveTab] = useState(defaultTab); const [timeSeries, setTimeSeries] = useState([]); const [historySeries, setHistorySeries] = useState([]); @@ -241,20 +297,24 @@ const SCADADataPanel: React.FC = ({ }, [defaultTab]); useEffect(() => { - if (!start_time && !end_time) return; - if (start_time) { - const parsedStart = dayjs(start_time); - if (parsedStart.isValid()) { - setFrom((prev) => (parsedStart.isSame(prev) ? prev : parsedStart)); - } - } - if (end_time) { - const parsedEnd = dayjs(end_time); - if (parsedEnd.isValid()) { - setTo((prev) => (parsedEnd.isSame(prev) ? prev : parsedEnd)); - } - } - }, [start_time, end_time]); + if (type !== "scheme" || !runId) return; + let cancelled = false; + getAnalysisScheme(runId).then( + (run) => { + if (!cancelled) { + setSchemeState({ runId, context: getAnalysisHistoryContext(run) }); + } + }, + () => { + if (!cancelled) { + setSchemeState({ runId, error: "无法读取事件方案,请刷新重试" }); + } + }, + ); + return () => { + cancelled = true; + }; + }, [type, runId, schemeReload]); const normalizedRange = useMemo(() => ensureValidRange(from, to), [from, to]); @@ -272,9 +332,10 @@ const SCADADataPanel: React.FC = ({ ); const handleFetch = useCallback( - async (reason: string) => { - if (!hasDevices) { + async () => { + if (!hasDevices || !schemeReady || schemeError) { requestControllerRef.current?.abort(); + requestControllerRef.current = null; setTimeSeries([]); setHistorySeries([]); setLoadingState("idle"); @@ -295,26 +356,37 @@ const SCADADataPanel: React.FC = ({ from: rangeFrom.toDate(), to: rangeTo.toDate(), }, - type, - runId, + type === "scheme" && schemeContext && !schemeContext.simulationRunId + ? "realtime" + : type, + schemeContext?.simulationRunId, requestController.signal, ); - if (requestControllerRef.current !== requestController) return; + if ( + requestController.signal.aborted || + requestControllerRef.current !== requestController + ) return; setTimeSeries(result.points); setHistorySeries(result.series); setLoadingState("success"); } catch (err) { if ( requestControllerRef.current !== requestController || + requestController.signal.aborted || (err instanceof Error && err.name === "AbortError") ) { return; } + setTimeSeries([]); + setHistorySeries([]); setError(err instanceof Error ? err.message : "未知错误"); setLoadingState("error"); } }, - [requestedFeatureInfos, hasDevices, normalizedRange, runId, type], + [ + requestedFeatureInfos, hasDevices, normalizedRange, + schemeReady, schemeError, schemeContext, type, + ], ); useEffect( @@ -324,15 +396,10 @@ const SCADADataPanel: React.FC = ({ [], ); - // 设备变化时自动查询 + // 元素、方案或查询范围变化时自动查询。 useEffect(() => { - if (hasDevices) { - handleFetch("device-change"); - } else { - setTimeSeries([]); - setHistorySeries([]); - } - }, [handleFetch, hasDevices]); + void handleFetch(); + }, [handleFetch]); const columns: GridColDef[] = useMemo(() => { const base: GridColDef[] = [ @@ -424,7 +491,6 @@ const SCADADataPanel: React.FC = ({ "#3f51b5", // 靛蓝色 ]; - const xData = dataset.map((item) => item.label); const hasFlowSeries = historySeries.some((item) => item.metric === "flow"); const hasPressureSeries = historySeries.some( (item) => item.metric === "pressure", @@ -453,11 +519,17 @@ const SCADADataPanel: React.FC = ({ symbolSize: isObserved ? 7 : 0, showSymbol: isObserved, sampling: "lttb", - connectNulls: !isObserved, + connectNulls: false, itemStyle: { color: colors[index % colors.length], }, - data: dataset.map((item) => item[key]), + data: timeSeries + .filter((point) => + Object.prototype.hasOwnProperty.call(point.values, key), + ) + .map((point) => [ + new Date(point.timestamp).getTime(), point.values[key] ?? null, + ]), lineStyle: isObserved ? { width: 0 } : undefined, areaStyle: isObserved ? undefined @@ -507,9 +579,13 @@ const SCADADataPanel: React.FC = ({ }, }, xAxis: { - type: "category", + type: "time", boundaryGap: false, - data: xData, + min: normalizedRange.from.valueOf(), + max: normalizedRange.to.valueOf(), + axisLabel: { + formatter: (value: number) => formatTimestamp(new Date(value).toISOString()), + }, }, yAxis: [ ...(hasFlowSeries @@ -542,7 +618,21 @@ const SCADADataPanel: React.FC = ({ end: 100, }, ], - series: getSeries(), + series: getSeries().map((series, index) => ({ + ...series, + ...(index === 0 && eventRange + ? { + markArea: { + silent: true, + itemStyle: { color: "rgba(255, 152, 0, 0.10)" }, + data: [[ + { name: "事件方案时段", xAxis: Date.parse(eventRange.start) }, + { xAxis: Date.parse(eventRange.end) }, + ]], + }, + } + : {}), + })), }; return ( @@ -716,16 +806,6 @@ const SCADADataPanel: React.FC = ({ setFrom(value); } }} - onAccept={(value) => { - if ( - value && - dayjs.isDayjs(value) && - value.isValid() && - hasDevices - ) { - handleFetch("date-change"); - } - }} maxDateTime={to} slotProps={{ textField: { fullWidth: true, size: "small" }, @@ -739,16 +819,6 @@ const SCADADataPanel: React.FC = ({ setTo(value); } }} - onAccept={(value) => { - if ( - value && - dayjs.isDayjs(value) && - value.isValid() && - hasDevices - ) { - handleFetch("date-change"); - } - }} minDateTime={from} slotProps={{ textField: { fullWidth: true, size: "small" }, @@ -787,8 +857,17 @@ const SCADADataPanel: React.FC = ({ size="small" color="primary" startIcon={} - disabled={!hasDevices || loadingState === "loading"} - onClick={() => handleFetch("manual")} + disabled={ + !hasDevices || !schemeReady || loadingState === "loading" + } + onClick={() => { + if (schemeError) { + setSchemeState(null); + setSchemeReload((value) => value + 1); + } else { + void handleFetch(); + } + }} > 刷新 @@ -799,13 +878,18 @@ const SCADADataPanel: React.FC = ({ - {error && ( + {eventRange && ( + + 事件方案时段:{formatTimestamp(eventRange.start)} ~ {formatTimestamp(eventRange.end)} + + )} + {(schemeError || error) && ( - 获取数据失败:{error} + 获取数据失败:{schemeError || error} )} @@ -814,7 +898,7 @@ const SCADADataPanel: React.FC = ({ {/* Content */} - {loadingState === "loading" && ( + {(!schemeReady || loadingState === "loading") && ( { ); }); }); + +describe("analysis history context", () => { + const run = { + run_id: "run-1", name: "event", run_type: "burst_analysis", + created_by: "alice", created_at: "2026-11-03T05:00:00Z", + started_at: "2026-08-25T09:00:00+08:00", status: "completed", parameters: {}, + }; + + it.each([ + ["burst_analysis", { modify_total_duration: 3600 }], + ["flushing_analysis", { duration: 3600 }], + ["contaminant_analysis", { duration: 3600 }], + ])("uses the simulated interval of %s rather than its creation time", (run_type, parameters) => { + expect(getAnalysisHistoryContext({ ...run, run_type, parameters })).toEqual({ + simulationRunId: "run-1", + eventRange: { start: "2026-08-25T01:00:00.000Z", end: "2026-08-25T02:00:00.000Z" }, + }); + }); + + it("does not treat a detection run as a hydraulic simulation", () => { + expect(getAnalysisHistoryContext({ + ...run, run_type: "burst_detection", parameters: { + scada_window: { start: "2026-08-25T01:00:00Z", end: "2026-08-25T02:00:00Z" }, + }, + })).toEqual({ + eventRange: { start: "2026-08-25T01:00:00Z", end: "2026-08-25T02:00:00Z" }, + }); + }); + + it("uses the linked hydraulic run for a localization analysis", () => { + expect(getAnalysisHistoryContext({ + ...run, run_type: "burst_location", parameters: { + result_payload: { + simulation_run: { run_id: "simulation-1" }, + scada_window: { burst_start: "2026-08-25T01:00:00Z", burst_end: "2026-08-25T02:00:00Z" }, + }, + }, + }).simulationRunId).toBe("simulation-1"); + }); + + it("rejects invalid or missing event durations without inventing an interval", () => { + expect(getAnalysisHistoryContext(run).eventRange).toBeNull(); + expect(getAnalysisHistoryContext({ ...run, parameters: { duration: -1 } }).eventRange).toBeNull(); + expect(getAnalysisHistoryContext({ ...run, started_at: "invalid", parameters: { duration: 3600 } }).eventRange).toBeNull(); + }); +}); diff --git a/src/lib/analysisRuns.ts b/src/lib/analysisRuns.ts index 35da93b..a194b9b 100644 --- a/src/lib/analysisRuns.ts +++ b/src/lib/analysisRuns.ts @@ -11,6 +11,66 @@ export type AnalysisRun = { parameters: Record; }; +export type AnalysisHistoryContext = { + simulationRunId?: string; + eventRange: { start: string; end: string } | null; +}; + +const SIMULATION_RUN_TYPES = new Set([ + "burst_analysis", + "flushing_analysis", + "contaminant_analysis", + "valve_close_analysis", + "pressure_regulation", +]); + +export const getAnalysisHistoryContext = ( + run: AnalysisRun, +): AnalysisHistoryContext => { + const parameters = normalizeParameters(run.parameters); + if (SIMULATION_RUN_TYPES.has(run.run_type)) { + const start = Date.parse(run.started_at); + const duration = parameters.modify_total_duration ?? parameters.duration; + const end = new Date( + typeof duration === "number" ? start + duration * 1000 : NaN, + ).getTime(); + return { + simulationRunId: run.run_id, + eventRange: + Number.isFinite(start) && Number.isFinite(end) && end > start + ? { + start: new Date(start).toISOString(), + end: new Date(end).toISOString(), + } + : null, + }; + } + + const payload = normalizeParameters(parameters.result_payload); + const window = normalizeParameters( + parameters.scada_window ?? payload.scada_window, + ); + const start = window.burst_start ?? window.start ?? parameters.scada_start; + const end = window.burst_end ?? window.end ?? parameters.scada_end; + const simulation = normalizeParameters( + payload.simulation_run ?? payload.simulation_scheme, + ); + const simulationRunId = parameters.simulation_run_id ?? simulation.run_id; + return { + simulationRunId: + typeof simulationRunId === "string" && simulationRunId.trim() + ? simulationRunId.trim() + : undefined, + eventRange: + typeof start === "string" && + typeof end === "string" && + Number.isFinite(Date.parse(start)) && + Date.parse(end) > Date.parse(start) + ? { start, end } + : null, + }; +}; + export type AnalysisResult = { result_id: string; run_id: string; diff --git a/src/lib/elementHistory.test.ts b/src/lib/elementHistory.test.ts index 44933f3..48395f3 100644 --- a/src/lib/elementHistory.test.ts +++ b/src/lib/elementHistory.test.ts @@ -28,8 +28,46 @@ describe("element history display conversion", () => { ]; const points = toTimeSeriesPoints(series); - expect(points[0].timestamp).toBe("2026-09-01T00:00:00Z"); - expect(points[0].values["P-1_sim"]).toBeCloseTo(83.3333333334); + expect(points[0].timestamp).toBe("2026-09-01T00:00:00.000Z"); + expect(points[0].values['["pipe","P-1","realtime_simulation"]']).toBeCloseTo(83.3333333334); expect(points[0].values["pressure-1_clean"]).toBeCloseTo(1.019716213); }); + + it("aligns equivalent timestamps and keeps same-name pipes and junctions separate", () => { + const base: ElementHistorySeries = { + element_id: "shared-id", element_type: "pipe", device_id: null, + metric: "flow", source: "realtime_simulation", source_unit: "L/s", + display_unit: "m³/h", unit_inferred: false, + points: [{ time: "2026-09-01T08:00:00+08:00", value: 10 }], + }; + const points = toTimeSeriesPoints([ + base, + { ...base, element_type: "junction", metric: "pressure", source_unit: "m", + points: [{ time: "2026-09-01T00:00:00Z", value: 20 }] }, + { ...base, source: "analysis_simulation", points: [ + { time: "2026-09-01T00:00:00.000Z", value: null }, + ] }, + ]); + expect(points).toHaveLength(1); + expect(points[0].values).toEqual({ + '["pipe","shared-id","realtime_simulation"]': 36, + '["junction","shared-id","realtime_simulation"]': 20, + '["pipe","shared-id","analysis_simulation"]': null, + }); + }); + + it("keeps online IDs ending in _scheme distinct from another element's scheme series", () => { + const base: ElementHistorySeries = { + element_id: "P_scheme", element_type: "pipe", device_id: null, + metric: "flow", source: "realtime_simulation", source_unit: "m3/h", + display_unit: "m³/h", unit_inferred: false, + points: [{ time: "2026-09-01T00:00:00Z", value: 100 }], + }; + const points = toTimeSeriesPoints([ + base, + { ...base, element_id: "P", source: "analysis_simulation", + points: [{ time: "2026-09-01T00:00:00Z", value: 5 }] }, + ]); + expect(Object.values(points[0].values).sort((a, b) => Number(a) - Number(b))).toEqual([5, 100]); + }); }); diff --git a/src/lib/elementHistory.ts b/src/lib/elementHistory.ts index 52b23ee..3af2048 100644 --- a/src/lib/elementHistory.ts +++ b/src/lib/elementHistory.ts @@ -49,8 +49,10 @@ const SOURCE_SUFFIX: Record = { analysis_simulation: "scheme_sim", }; -export const historySeriesKey = (series: ElementHistorySeries) => - `${series.device_id ?? series.element_id}_${SOURCE_SUFFIX[series.source]}`; +export const historySeriesKey = (series: ElementHistorySeries) => { + if (series.device_id) return `${series.device_id}_${SOURCE_SUFFIX[series.source]}`; + return JSON.stringify([series.element_type, series.element_id, series.source]); +}; export const historySourceLabel = (source: HistorySource) => { switch (source) { @@ -65,8 +67,11 @@ export const historySourceLabel = (source: HistorySource) => { } }; -export const historySeriesLabel = (series: ElementHistorySeries) => - `${series.device_id ?? series.element_id} (${historySourceLabel(series.source)})`; +export const historySeriesLabel = (series: ElementHistorySeries) => { + const target = series.device_id ?? + `${series.element_type === "pipe" ? "管道" : "节点"} ${series.element_id}`; + return `${target} (${historySourceLabel(series.source)})`; +}; export const toTimeSeriesPoints = ( seriesList: ElementHistorySeries[], @@ -75,13 +80,14 @@ export const toTimeSeriesPoints = ( seriesList.forEach((series) => { const key = historySeriesKey(series); series.points.forEach((point) => { - const values = timeMap.get(point.time) ?? {}; + const timestamp = new Date(point.time).toISOString(); + const values = timeMap.get(timestamp) ?? {}; values[key] = toDisplayValue( point.value, series.metric, series.source_unit, ); - timeMap.set(point.time, values); + timeMap.set(timestamp, values); }); }); return Array.from(timeMap, ([timestamp, values]) => ({ timestamp, values })).sort(