feat(history): align units and history data

This commit is contained in:
2026-09-14 12:30:57 +08:00
parent 35b6819459
commit 08ddb4453d
21 changed files with 1849 additions and 3618 deletions
+142 -251
View File
@@ -41,6 +41,19 @@ import { useNotification } from "@refinedev/core";
import { api } from "@/lib/api";
import { apiFetch } from "@/lib/apiFetch";
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
import {
ElementHistoryResult,
ElementHistorySeries,
ElementHistoryTarget,
fetchElementHistory,
historySeriesKey,
TimeSeriesPoint,
toTimeSeriesPoints,
} from "@/lib/elementHistory";
import {
FLOW_DISPLAY_UNIT,
PRESSURE_DISPLAY_UNIT,
} from "@/utils/units";
dayjs.extend(utc);
dayjs.extend(timezone);
@@ -50,13 +63,6 @@ type IUser = {
name?: string;
};
export interface TimeSeriesPoint {
/** ISO8601 时间戳 */
timestamp: string;
/** 每个设备对应的值 */
values: Record<string, number | null | undefined>;
}
export interface SCADADataPanelProps {
/** 选中的设备 ID 列表 */
deviceIds: string[];
@@ -90,164 +96,75 @@ const panelHeaderActionSx = {
},
};
/**
* 从后端 API 获取 SCADA 数据
*/
interface ScadaDeviceMetadata {
device_id: string;
device_type: string;
node_id: string | null;
link_id: string | null;
}
/** 用设备元数据组装统一元素历史查询,一次返回监测与模拟数据。 */
const fetchFromBackend = async (
deviceIds: string[],
range: { from: Date; to: Date },
): Promise<TimeSeriesPoint[]> => {
): Promise<ElementHistoryResult> => {
if (deviceIds.length === 0) {
return [];
return { points: [], series: [] };
}
const device_ids = deviceIds.join(",");
const start_time = dayjs(range.from).toISOString();
const end_time = dayjs(range.to).toISOString();
// 清洗数据接口
const cleaningDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/scada-readings/fields?device_ids=${device_ids}&field=cleaned_value&start_time=${start_time}&end_time=${end_time}`;
// 原始数据
const rawDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/scada-readings/fields?device_ids=${device_ids}&field=monitored_value&start_time=${start_time}&end_time=${end_time}`;
// 模拟数据接口
const simulationDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/views/scada-simulations?device_ids=${device_ids}&start_time=${start_time}&end_time=${end_time}`;
try {
// 优先查询清洗数据和模拟数据
const [cleaningRes, simulationRes] = await Promise.all([
apiFetch(cleaningDataUrl)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null),
apiFetch(simulationDataUrl)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null),
]);
const cleaningData = transformBackendData(cleaningRes, deviceIds);
const simulationData = transformBackendData(simulationRes, deviceIds);
// 如果清洗数据有数据,返回清洗和模拟数据
if (cleaningData.length > 0) {
return mergeTimeSeriesData(
cleaningData,
simulationData,
deviceIds,
"clean",
"sim",
);
} else {
// 如果清洗数据没有数据,查询原始数据,返回模拟和原始数据
const rawRes = await apiFetch(rawDataUrl)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null);
const rawData = transformBackendData(rawRes, deviceIds);
return mergeTimeSeriesData(
simulationData,
rawData,
deviceIds,
"sim",
"raw",
);
}
} catch (error) {
console.error("[SCADADataPanel] 从后端获取数据失败:", error);
throw error;
}
};
/**
* 转换后端数据格式
* 根据实际后端返回的数据结构进行调整
*/
const transformBackendData = (
backendData: any,
deviceIds: string[],
): TimeSeriesPoint[] => {
// 处理后端返回的对象格式: { deviceId: [{time: "...", value: ...}] }
if (backendData && !Array.isArray(backendData)) {
// 检查是否是设备ID为键的对象格式
const hasDeviceKeys = deviceIds.some((id) => id in backendData);
if (hasDeviceKeys) {
// 获取所有时间点的集合
const timeMap = new Map<string, Record<string, number | null>>();
deviceIds.forEach((deviceId) => {
const deviceData = backendData[deviceId];
if (Array.isArray(deviceData)) {
deviceData.forEach((item: any) => {
const timestamp = item.time || item.timestamp || item._time;
if (timestamp) {
if (!timeMap.has(timestamp)) {
timeMap.set(timestamp, {});
}
const values = timeMap.get(timestamp)!;
values[deviceId] =
typeof item.value === "number" ? item.value : null;
}
});
}
});
// 转换为 TimeSeriesPoint 数组并按时间排序
const result = Array.from(timeMap.entries()).map(
([timestamp, values]) => ({
timestamp,
values,
}),
);
result.sort(
(a, b) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
);
return result;
}
}
// 默认返回空数组
console.warn("[SCADADataPanel] 未知的后端数据格式:", backendData);
return [];
};
/**
* 合并两个时间序列数据,为每个设备添加后缀
*/
const mergeTimeSeriesData = (
data1: TimeSeriesPoint[],
data2: TimeSeriesPoint[],
deviceIds: string[],
suffix1: string,
suffix2: string,
): TimeSeriesPoint[] => {
const timeMap = new Map<string, Record<string, number | null>>();
const processData = (data: TimeSeriesPoint[], suffix: string) => {
data.forEach((point) => {
if (!timeMap.has(point.timestamp)) {
timeMap.set(point.timestamp, {});
}
const values = timeMap.get(point.timestamp)!;
deviceIds.forEach((deviceId) => {
const value = point.values[deviceId];
if (value !== undefined) {
values[`${deviceId}_${suffix}`] = value;
}
});
});
};
processData(data1, suffix1);
processData(data2, suffix2);
const result = Array.from(timeMap.entries()).map(([timestamp, values]) => ({
timestamp,
values,
}));
result.sort(
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
const metadataResponse = await apiFetch(
`${config.BACKEND_URL}/api/v1/scada-devices`,
);
if (!metadataResponse.ok) {
throw new Error(`SCADA 设备信息请求失败: HTTP ${metadataResponse.status}`);
}
const allDevices = (await metadataResponse.json()) as ScadaDeviceMetadata[];
const requested = new Set(deviceIds);
const devices = allDevices.filter((device) => requested.has(device.device_id));
const missing = deviceIds.filter(
(deviceId) => !devices.some((device) => device.device_id === deviceId),
);
if (missing.length > 0) {
throw new Error(`SCADA 设备不存在: ${missing.join(", ")}`);
}
return result;
const targetsByElement = new Map<string, ElementHistoryTarget>();
devices.forEach((device) => {
const isFlow = ["pipe_flow", "flow"].includes(
device.device_type.toLowerCase(),
);
const elementId = isFlow ? device.link_id : device.node_id;
const elementType = isFlow ? "pipe" : "junction";
if (!elementId) {
throw new Error(`SCADA 设备 ${device.device_id} 未关联管网元素`);
}
const key = `${elementType}:${elementId}`;
const target = targetsByElement.get(key) ?? {
element_id: elementId,
element_type: elementType,
device_ids: [],
};
target.device_ids!.push(device.device_id);
targetsByElement.set(key, target);
});
const targets = Array.from(targetsByElement.values());
const result = await fetchElementHistory(
targets,
range,
"realtime_comparison",
);
const expandedSeries = result.series.flatMap((series) => {
if (series.device_id || series.source.startsWith("scada_")) return [series];
const target = targets.find(
(item) =>
item.element_id === series.element_id &&
item.element_type === series.element_type,
);
return (target?.device_ids ?? []).map((deviceId) => ({
...series,
device_id: deviceId,
}));
});
return { series: expandedSeries, points: toTimeSeriesPoints(expandedSeries) };
};
const formatTimestamp = (timestamp: string) =>
@@ -337,83 +254,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
const { open } = useNotification();
const { data: user } = useGetIdentity<IUser>();
const customFetcher = useMemo(() => {
if (!showCleaning) {
return fetchFromBackend;
}
return async (
deviceIds: string[],
range: { from: Date; to: Date },
): Promise<TimeSeriesPoint[]> => {
const device_ids = deviceIds.join(",");
const start_time = dayjs(range.from).toISOString();
const end_time = dayjs(range.to).toISOString();
// 清洗数据接口
const cleaningDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/scada-readings/fields?device_ids=${device_ids}&field=cleaned_value&start_time=${start_time}&end_time=${end_time}`;
// 原始数据
const rawDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/scada-readings/fields?device_ids=${device_ids}&field=monitored_value&start_time=${start_time}&end_time=${end_time}`;
// 模拟数据接口
const simulationDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/views/scada-simulations?device_ids=${device_ids}&start_time=${start_time}&end_time=${end_time}`;
try {
const [cleanRes, rawRes, simRes] = await Promise.all([
apiFetch(cleaningDataUrl)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null),
apiFetch(rawDataUrl)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null),
apiFetch(simulationDataUrl)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null),
]);
const timeMap = new Map<string, Record<string, number | null>>();
const processData = (data: any, suffix: string) => {
if (!data) return;
deviceIds.forEach((deviceId) => {
const deviceData = data[deviceId];
if (Array.isArray(deviceData)) {
deviceData.forEach((item: any) => {
const timestamp = item.time || item.timestamp || item._time;
if (timestamp) {
if (!timeMap.has(timestamp)) {
timeMap.set(timestamp, {});
}
const values = timeMap.get(timestamp)!;
values[`${deviceId}_${suffix}`] =
typeof item.value === "number" ? item.value : null;
}
});
}
});
};
processData(cleanRes, "clean");
processData(rawRes, "raw");
processData(simRes, "sim");
const result = Array.from(timeMap.entries()).map(
([timestamp, values]) => ({
timestamp,
values,
}),
);
result.sort(
(a, b) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
);
return result;
} catch (error) {
console.error("[SCADADataPanel] 获取三种数据失败:", error);
throw error;
}
};
}, [showCleaning]);
const customFetcher = fetchFromBackend;
const [from, setFrom] = useState<Dayjs>(() => {
if (start_time) {
@@ -435,6 +276,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
});
const [activeTab, setActiveTab] = useState<PanelTab>(defaultTab);
const [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([]);
const [historySeries, setHistorySeries] = useState<ElementHistorySeries[]>([]);
const [loadingState, setLoadingState] = useState<LoadingState>("idle");
const [error, setError] = useState<string | null>(null);
const [isExpanded, setIsExpanded] = useState<boolean>(true);
@@ -473,11 +315,30 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
() => buildDataset(timeSeries, deviceIds, fractionDigits, showCleaning),
[timeSeries, deviceIds, fractionDigits, showCleaning],
);
const seriesByKey = useMemo(
() =>
new Map(historySeries.map((series) => [historySeriesKey(series), series])),
[historySeries],
);
const hasFlowSeries = historySeries.some((item) => item.metric === "flow");
const hasPressureSeries = historySeries.some(
(item) => item.metric === "pressure",
);
const unitForKey = useCallback(
(key: string) => seriesByKey.get(key)?.display_unit ?? "",
[seriesByKey],
);
const axisForKey = useCallback(
(key: string) =>
seriesByKey.get(key)?.metric === "pressure" && hasFlowSeries ? 1 : 0,
[hasFlowSeries, seriesByKey],
);
const handleFetch = useCallback(
async (reason: string) => {
if (!hasDevices) {
setTimeSeries([]);
setHistorySeries([]);
setLoadingState("idle");
setError(null);
return;
@@ -491,7 +352,8 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
from: rangeFrom.toDate(),
to: rangeTo.toDate(),
});
setTimeSeries(result);
setTimeSeries(result.points);
setHistorySeries(result.series);
setLoadingState("success");
} catch (err) {
setError(err instanceof Error ? err.message : "未知错误");
@@ -583,6 +445,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
handleFetch("device-change");
} else {
setTimeSeries([]);
setHistorySeries([]);
}
}, [deviceIdsKey, handleFetch, hasDevices]);
@@ -610,7 +473,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
return deviceIds.flatMap<GridColDef>((id) => [
{
field: `${id}_raw`,
headerName: `${id} (原始)`,
headerName: `${id} (原始) [${unitForKey(`${id}_raw`)}]`,
minWidth: 140,
flex: 1,
valueFormatter: (value: any) => {
@@ -623,7 +486,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
},
{
field: `${id}_clean`,
headerName: `${id} (清洗)`,
headerName: `${id} (清洗) [${unitForKey(`${id}_clean`)}]`,
minWidth: 140,
flex: 1,
valueFormatter: (value: any) => {
@@ -636,7 +499,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
},
{
field: `${id}_sim`,
headerName: `${id} (模拟)`,
headerName: `${id} (模拟) [${unitForKey(`${id}_sim`)}]`,
minWidth: 140,
flex: 1,
valueFormatter: (value: any) => {
@@ -652,7 +515,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
// 单一数据源模式:只显示选中的数据源
return deviceIds.map<GridColDef>((id) => ({
field: `${id}_${selectedSource}`,
headerName: id,
headerName: `${id} [${unitForKey(`${id}_${selectedSource}`)}]`,
minWidth: 140,
flex: 1,
valueFormatter: (value: any) => {
@@ -688,7 +551,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
if (hasData) {
cols.push({
field: fieldKey,
headerName: `${deviceName} (${name})`,
headerName: `${deviceName} (${name}) [${unitForKey(fieldKey)}]`,
minWidth: 140,
flex: 1,
valueFormatter: (value: any) => {
@@ -708,7 +571,14 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
})();
return [...base, ...dynamic];
}, [deviceIds, fractionDigits, showCleaning, selectedSource, dataset]);
}, [
deviceIds,
fractionDigits,
showCleaning,
selectedSource,
dataset,
unitForKey,
]);
const rows = useMemo(
() =>
@@ -766,8 +636,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
if (selectedSource === "all") {
return deviceIds.flatMap((id, index) => [
{
name: `${id} (原始)`,
name: `${id} (原始) [${unitForKey(`${id}_raw`)}]`,
type: "line",
yAxisIndex: axisForKey(`${id}_raw`),
symbol: "none",
connectNulls: true,
sampling: "lttb",
@@ -775,8 +646,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
data: dataset.map((item) => item[`${id}_raw`]),
},
{
name: `${id} (清洗)`,
name: `${id} (清洗) [${unitForKey(`${id}_clean`)}]`,
type: "line",
yAxisIndex: axisForKey(`${id}_clean`),
symbol: "none",
connectNulls: true,
sampling: "lttb",
@@ -784,8 +656,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
data: dataset.map((item) => item[`${id}_clean`]),
},
{
name: `${id} (模拟)`,
name: `${id} (模拟) [${unitForKey(`${id}_sim`)}]`,
type: "line",
yAxisIndex: axisForKey(`${id}_sim`),
symbol: "none",
connectNulls: true,
sampling: "lttb",
@@ -795,8 +668,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
]);
} else {
return deviceIds.map((id, index) => ({
name: id,
name: `${id} [${unitForKey(`${id}_${selectedSource}`)}]`,
type: "line",
yAxisIndex: axisForKey(`${id}_${selectedSource}`),
symbol: "none",
connectNulls: true,
sampling: "lttb",
@@ -820,8 +694,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
: suffix === "clean"
? "清洗"
: "模拟"
})`,
}) [${unitForKey(key)}]`,
type: "line",
yAxisIndex: axisForKey(key),
symbol: "none",
connectNulls: true,
sampling: "lttb",
@@ -908,10 +783,26 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
boundaryGap: false,
data: xData,
},
yAxis: {
type: "value",
scale: true,
},
yAxis: [
...(hasFlowSeries
? [
{
type: "value",
scale: true,
name: `流量 (${FLOW_DISPLAY_UNIT})`,
},
]
: []),
...(hasPressureSeries
? [
{
type: "value",
scale: true,
name: `压力 (${PRESSURE_DISPLAY_UNIT})`,
},
]
: []),
],
dataZoom: [
{
type: "inside",
@@ -24,14 +24,8 @@ const range = {
describe("fetchHistoryData", () => {
beforeEach(() => jest.clearAllMocks());
it("queries SCADA readings once per selected network element", async () => {
jest.mocked(apiFetch).mockImplementation(async (input) => {
const url = new URL(String(input));
const elementId = url.searchParams.get("element_id") ?? "";
return jsonResponse({
[elementId]: [{ time: range.from.toISOString(), value: 1 }],
});
});
it("queries all selected elements in one batch request", async () => {
jest.mocked(apiFetch).mockResolvedValue(jsonResponse({ series: [] }));
await fetchHistoryData(
[
@@ -42,16 +36,20 @@ describe("fetchHistoryData", () => {
"none",
);
const elementIds = jest
.mocked(apiFetch)
.mock.calls.map(([input]) =>
new URL(String(input)).searchParams.get("element_id"),
);
expect(elementIds).toEqual(["J-1", "J-2", "J-1", "J-2"]);
expect(apiFetch).toHaveBeenCalledTimes(1);
const [url, init] = jest.mocked(apiFetch).mock.calls[0];
expect(String(url)).toContain("/element-history/query");
expect(JSON.parse(String(init?.body))).toMatchObject({
mode: "observed",
elements: [
{ element_id: "J-1", element_type: "junction" },
{ element_id: "J-2", element_type: "junction" },
],
});
});
it("uses the analysis run ID for historical scheme simulation data", async () => {
jest.mocked(apiFetch).mockResolvedValue(jsonResponse({ "P-1": [] }));
jest.mocked(apiFetch).mockResolvedValue(jsonResponse({ series: [] }));
await fetchHistoryData(
[["P-1", "pipe"]],
@@ -60,15 +58,12 @@ describe("fetchHistoryData", () => {
"99dd4142-368b-54cb-bfca-d59ee48f6298",
);
const simulationUrls = jest
.mocked(apiFetch)
.mock.calls.map(([input]) => new URL(String(input)))
.filter((url) => url.pathname.endsWith("/element-simulations"));
expect(simulationUrls).toHaveLength(2);
expect(
simulationUrls.map((url) => url.searchParams.get("run_id")),
).toEqual([null, "99dd4142-368b-54cb-bfca-d59ee48f6298"]);
expect(apiFetch).toHaveBeenCalledTimes(1);
const [, init] = jest.mocked(apiFetch).mock.calls[0];
expect(JSON.parse(String(init?.body))).toMatchObject({
mode: "analysis_comparison",
run_id: "99dd4142-368b-54cb-bfca-d59ee48f6298",
});
});
it("rejects oversized element selections before issuing requests", async () => {
@@ -84,17 +79,8 @@ describe("fetchHistoryData", () => {
expect(apiFetch).not.toHaveBeenCalled();
});
it("limits concurrent SCADA requests for multi-element history", async () => {
let activeRequests = 0;
let peakRequests = 0;
jest.mocked(apiFetch).mockImplementation(async (input) => {
activeRequests += 1;
peakRequests = Math.max(peakRequests, activeRequests);
await new Promise((resolve) => setTimeout(resolve, 0));
activeRequests -= 1;
const elementId = new URL(String(input)).searchParams.get("element_id") ?? "";
return jsonResponse({ [elementId]: [] });
});
it("does not create N+1 requests for multi-element history", async () => {
jest.mocked(apiFetch).mockResolvedValue(jsonResponse({ series: [] }));
await fetchHistoryData(
Array.from(
@@ -105,6 +91,6 @@ describe("fetchHistoryData", () => {
"none",
);
expect(peakRequests).toBeLessThanOrEqual(8);
expect(apiFetch).toHaveBeenCalledTimes(1);
});
});
@@ -34,20 +34,23 @@ import timezone from "dayjs/plugin/timezone";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { DateTimePicker, LocalizationProvider } from "@mui/x-date-pickers";
import { zhCN as pickerZhCN } from "@mui/x-date-pickers/locales";
import config from "@/config/config";
import { apiFetch } from "@/lib/apiFetch";
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
import {
ElementHistoryResult,
ElementHistorySeries,
fetchElementHistory,
historySeriesKey,
historySeriesLabel,
TimeSeriesPoint,
} from "@/lib/elementHistory";
import {
FLOW_DISPLAY_UNIT,
PRESSURE_DISPLAY_UNIT,
} from "@/utils/units";
dayjs.extend(utc);
dayjs.extend(timezone);
export interface TimeSeriesPoint {
/** ISO8601 时间戳 */
timestamp: string;
/** 每个设备对应的值 */
values: Record<string, number | null | undefined>;
}
export interface SCADADataPanelProps {
/** 选中的要素信息列表,格式为 [[id, type], [id, type]] */
featureInfos: [string, string][];
@@ -73,7 +76,6 @@ type LoadingState = "idle" | "loading" | "success" | "error";
const MAX_HISTORY_ELEMENTS = 200;
const MAX_HISTORY_ELEMENT_ID_LENGTH = 128;
const HISTORY_SCADA_CONCURRENCY = 4;
const panelHeaderActionSx = {
color: "primary.contrastText",
@@ -83,48 +85,6 @@ const panelHeaderActionSx = {
},
};
const buildApiUrl = (
path: string,
params: Record<string, string | boolean>,
) => {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
searchParams.set(key, String(value));
});
return `${config.BACKEND_URL}${path}?${searchParams.toString()}`;
};
const fetchOptionalJson = async (url: string, signal?: AbortSignal) => {
const response = await apiFetch(url, { signal });
if (response.status === 404) return null;
if (!response.ok) {
throw new Error(`历史数据请求失败: HTTP ${response.status}`);
}
return response.json();
};
const mapWithConcurrency = async <Input, Output>(
items: Input[],
limit: number,
mapper: (item: Input, index: number) => Promise<Output>,
): Promise<Output[]> => {
const results = new Array<Output>(items.length);
let nextIndex = 0;
const workerCount = Math.min(limit, items.length);
await Promise.all(
Array.from({ length: workerCount }, async () => {
while (nextIndex < items.length) {
const currentIndex = nextIndex;
nextIndex += 1;
results[currentIndex] = await mapper(items[currentIndex], currentIndex);
}
}),
);
return results;
};
/** 从后端 API 获取管网元素的监测、实时模拟和方案模拟数据。 */
export const fetchHistoryData = async (
featureInfos: [string, string][],
@@ -132,9 +92,9 @@ export const fetchHistoryData = async (
type: "realtime" | "scheme" | "none",
schemeRunId?: string,
signal?: AbortSignal,
): Promise<TimeSeriesPoint[]> => {
): Promise<ElementHistoryResult> => {
if (featureInfos.length === 0) {
return [];
return { points: [], series: [] };
}
if (featureInfos.length > MAX_HISTORY_ELEMENTS) {
throw new Error(`历史数据一次最多查询 ${MAX_HISTORY_ELEMENTS} 个管网元素`);
@@ -149,285 +109,36 @@ export const fetchHistoryData = async (
}
const uniqueFeatureInfos = Array.from(
new Map(featureInfos.map((featureInfo) => [featureInfo[0], featureInfo])).values(),
);
const featureIds = uniqueFeatureInfos.map(([id]) => id);
const start_time = dayjs(range.from).toISOString();
const end_time = dayjs(range.to).toISOString();
// 将 featureInfos 转换为后端期望的格式: id1:type1,id2:type2
const feature_infos = uniqueFeatureInfos
.map(([id, type]) => `${id}:${type}`)
.join(",");
const fetchElementScadaData = async (useCleaned: boolean) => {
const results = await mapWithConcurrency(
featureIds,
HISTORY_SCADA_CONCURRENCY,
(elementId) =>
fetchOptionalJson(
buildApiUrl("/api/v1/timeseries/views/element-scada-readings", {
element_id: elementId,
start_time,
end_time,
use_cleaned: useCleaned,
}),
signal,
),
);
return Object.assign({}, ...results.filter(Boolean));
};
const simulationDataUrl = buildApiUrl(
"/api/v1/timeseries/views/element-simulations",
{ feature_infos, start_time, end_time },
new Map(
featureInfos.map((featureInfo) => [featureInfo.join(":"), featureInfo]),
).values(),
);
if (type === "scheme" && !schemeRunId) {
throw new Error("历史方案缺少分析运行 ID,无法读取方案时序数据");
}
const schemeSimulationDataUrl = schemeRunId
? buildApiUrl("/api/v1/timeseries/views/element-simulations", {
feature_infos,
start_time,
end_time,
run_id: schemeRunId,
})
: null;
try {
if (type === "none") {
// 查询清洗值和监测值
const [cleanedRes, rawRes] = await Promise.all([
fetchElementScadaData(true),
fetchElementScadaData(false),
]);
const cleanedData = transformBackendData(cleanedRes, featureIds);
// 如果清洗数据有值,则不显示原始监测值
const rawData =
cleanedData.length > 0 ? [] : transformBackendData(rawRes, featureIds);
return mergeTimeSeriesData(
cleanedData,
rawData,
featureIds,
"clean",
"raw"
);
} else if (type === "scheme") {
// 查询策略模拟值、实时模拟值、清洗值和监测值
const [cleanedRes, rawRes, simulationRes, schemeSimRes] = await Promise.all([
fetchElementScadaData(true),
fetchElementScadaData(false),
fetchOptionalJson(simulationDataUrl, signal),
fetchOptionalJson(schemeSimulationDataUrl!, signal),
]);
const cleanedData = transformBackendData(cleanedRes, featureIds);
// 如果清洗数据有值,则不显示原始监测值
const rawData =
cleanedData.length > 0 ? [] : transformBackendData(rawRes, featureIds);
const simulationData = transformBackendData(simulationRes, featureIds);
const schemeSimData = transformBackendData(schemeSimRes, featureIds);
return mergeMultipleTimeSeriesData(
[
{ data: cleanedData, suffix: "clean" },
{ data: rawData, suffix: "raw" },
{ data: simulationData, suffix: "sim" },
{ data: schemeSimData, suffix: "scheme_sim" },
],
featureIds
);
} else {
// realtime: 查询模拟值、清洗值和监测值
const [cleanedRes, rawRes, simulationRes] = await Promise.all([
fetchElementScadaData(true),
fetchElementScadaData(false),
fetchOptionalJson(simulationDataUrl, signal),
]);
const cleanedData = transformBackendData(cleanedRes, featureIds);
// 如果清洗数据有值,则不显示原始监测值
const rawData =
cleanedData.length > 0 ? [] : transformBackendData(rawRes, featureIds);
const simulationData = transformBackendData(simulationRes, featureIds);
// 合并三组数据
const timeMap = new Map<string, Record<string, number | null>>();
[cleanedData, rawData, simulationData].forEach((data, index) => {
const suffix = ["clean", "raw", "sim"][index];
data.forEach((point) => {
if (!timeMap.has(point.timestamp)) {
timeMap.set(point.timestamp, {});
}
const values = timeMap.get(point.timestamp)!;
featureIds.forEach((deviceId) => {
const value = point.values[deviceId];
if (value !== undefined) {
values[`${deviceId}_${suffix}`] = value;
}
});
});
});
const result = Array.from(timeMap.entries()).map(
([timestamp, values]) => ({
timestamp,
values,
})
);
result.sort(
(a, b) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
);
return result;
}
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("[SCADADataPanel] 从后端获取数据失败:", error);
console.error("[HistoryDataPanel] 从后端获取数据失败:", error);
throw error;
}
};
/**
* 转换后端数据格式
* 根据实际后端返回的数据结构进行调整
*/
const transformBackendData = (
backendData: any,
deviceIds: string[]
): TimeSeriesPoint[] => {
// 处理后端返回的对象格式: { deviceId: [{time: "...", value: ...}] }
if (backendData && !Array.isArray(backendData)) {
// 检查是否是设备ID为键的对象格式
const hasDeviceKeys = deviceIds.some((id) => id in backendData);
if (hasDeviceKeys) {
// 获取所有时间点的集合
const timeMap = new Map<string, Record<string, number | null>>();
deviceIds.forEach((deviceId) => {
const deviceData = backendData[deviceId];
if (Array.isArray(deviceData)) {
deviceData.forEach((item: any) => {
const timestamp = item.time || item.timestamp || item._time;
if (timestamp) {
if (!timeMap.has(timestamp)) {
timeMap.set(timestamp, {});
}
const values = timeMap.get(timestamp)!;
values[deviceId] =
typeof item.value === "number" ? item.value : null;
}
});
}
});
// 转换为 TimeSeriesPoint 数组并按时间排序
const result = Array.from(timeMap.entries()).map(
([timestamp, values]) => ({
timestamp,
values,
})
);
result.sort(
(a, b) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
);
return result;
}
}
// 默认返回空数组
console.warn("[SCADADataPanel] 未知的后端数据格式:", backendData);
return [];
};
/**
* 合并两个时间序列数据,为每个设备添加后缀
*/
const mergeTimeSeriesData = (
data1: TimeSeriesPoint[],
data2: TimeSeriesPoint[],
deviceIds: string[],
suffix1: string,
suffix2: string
): TimeSeriesPoint[] => {
const timeMap = new Map<string, Record<string, number | null>>();
const processData = (data: TimeSeriesPoint[], suffix: string) => {
data.forEach((point) => {
if (!timeMap.has(point.timestamp)) {
timeMap.set(point.timestamp, {});
}
const values = timeMap.get(point.timestamp)!;
deviceIds.forEach((deviceId) => {
const value = point.values[deviceId];
if (value !== undefined) {
values[`${deviceId}_${suffix}`] = value;
}
});
});
};
processData(data1, suffix1);
processData(data2, suffix2);
const result = Array.from(timeMap.entries()).map(([timestamp, values]) => ({
timestamp,
values,
}));
result.sort(
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
);
return result;
};
const mergeMultipleTimeSeriesData = (
datasets: Array<{
data: TimeSeriesPoint[];
suffix: string;
}>,
deviceIds: string[]
): TimeSeriesPoint[] => {
const timeMap = new Map<string, Record<string, number | null>>();
datasets.forEach(({ data, suffix }) => {
data.forEach((point) => {
if (!timeMap.has(point.timestamp)) {
timeMap.set(point.timestamp, {});
}
const values = timeMap.get(point.timestamp)!;
deviceIds.forEach((deviceId) => {
const value = point.values[deviceId];
if (value !== undefined) {
values[`${deviceId}_${suffix}`] = value;
}
});
});
});
const result = Array.from(timeMap.entries()).map(([timestamp, values]) => ({
timestamp,
values,
}));
result.sort(
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
);
return result;
};
const formatTimestamp = (timestamp: string) =>
dayjs(timestamp).tz("Asia/Shanghai").format("YYYY-MM-DD HH:mm");
@@ -443,7 +154,7 @@ const ensureValidRange = (
const buildDataset = (
points: TimeSeriesPoint[],
deviceIds: string[],
series: ElementHistorySeries[],
fractionDigits: number
) => {
return points.map((point) => {
@@ -452,19 +163,17 @@ const buildDataset = (
label: formatTimestamp(point.timestamp),
};
deviceIds.forEach((id) => {
["clean", "raw", "sim", "scheme_sim"].forEach((suffix) => {
const key = `${id}_${suffix}`;
const value = point.values[key];
if (value !== undefined && value !== null) {
entry[key] =
typeof value === "number"
? Number.isFinite(value)
? parseFloat(value.toFixed(fractionDigits))
: null
: value ?? null;
}
});
series.forEach((metadata) => {
const key = historySeriesKey(metadata);
const value = point.values[key];
if (value !== undefined && value !== null) {
entry[key] =
typeof value === "number"
? Number.isFinite(value)
? parseFloat(value.toFixed(fractionDigits))
: null
: value ?? null;
}
});
return entry;
@@ -521,11 +230,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
});
const [activeTab, setActiveTab] = useState<PanelTab>(defaultTab);
const [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([]);
const [historySeries, setHistorySeries] = useState<ElementHistorySeries[]>([]);
const [loadingState, setLoadingState] = useState<LoadingState>("idle");
const [error, setError] = useState<string | null>(null);
const [selectedSource, setSelectedSource] = useState<
"raw" | "clean" | "sim" | "all"
>(() => (featureInfos.length === 1 ? "all" : "clean"));
const draggableRef = useRef<HTMLDivElement>(null);
const requestControllerRef = useRef<AbortController | null>(null);
@@ -559,8 +266,8 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
);
const dataset = useMemo(
() => buildDataset(timeSeries, deviceIds, fractionDigits),
[timeSeries, deviceIds, fractionDigits]
() => buildDataset(timeSeries, historySeries, fractionDigits),
[timeSeries, historySeries, fractionDigits]
);
const handleFetch = useCallback(
@@ -568,6 +275,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
if (!hasDevices) {
requestControllerRef.current?.abort();
setTimeSeries([]);
setHistorySeries([]);
setLoadingState("idle");
setError(null);
return;
@@ -591,7 +299,8 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
requestController.signal,
);
if (requestControllerRef.current !== requestController) return;
setTimeSeries(result);
setTimeSeries(result.points);
setHistorySeries(result.series);
setLoadingState("success");
} catch (err) {
if (
@@ -620,16 +329,10 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
handleFetch("device-change");
} else {
setTimeSeries([]);
setHistorySeries([]);
}
}, [featureInfosKey, handleFetch, hasDevices]);
// 当设备数量变化时,调整数据源选择
useEffect(() => {
if (featureInfos.length > 1 && selectedSource === "all") {
setSelectedSource("clean");
}
}, [featureInfos.length, selectedSource]);
const columns: GridColDef[] = useMemo(() => {
const base: GridColDef[] = [
{
@@ -643,45 +346,33 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
const dynamic = (() => {
const cols: GridColDef[] = [];
deviceIds.forEach((id) => {
// 为每个设备的每种数据类型创建列
const suffixes = [
{ key: "clean", name: "清洗值" },
{ key: "raw", name: "监测值" },
{ key: "sim", name: "实时模拟值" },
{ key: "scheme_sim", name: "方案模拟值" },
];
suffixes.forEach(({ key, name }) => {
const fieldKey = `${id}_${key}`;
// 检查是否有该字段的数据
const hasData = dataset.some(
(item) => item[fieldKey] !== null && item[fieldKey] !== undefined
);
if (hasData) {
cols.push({
field: fieldKey,
headerName: `${id} (${name})`,
minWidth: 140,
flex: 1,
valueFormatter: (value: any) => {
if (value === null || value === undefined) return "--";
if (Number.isFinite(Number(value))) {
return Number(value).toFixed(fractionDigits);
}
return String(value);
},
});
}
});
historySeries.forEach((metadata) => {
const fieldKey = historySeriesKey(metadata);
const hasData = dataset.some(
(item) => item[fieldKey] !== null && item[fieldKey] !== undefined,
);
if (hasData) {
cols.push({
field: fieldKey,
headerName: `${historySeriesLabel(metadata)} [${metadata.display_unit}]`,
minWidth: 180,
flex: 1,
valueFormatter: (value: any) => {
if (value === null || value === undefined) return "--";
if (Number.isFinite(Number(value))) {
return Number(value).toFixed(fractionDigits);
}
return String(value);
},
});
}
});
return cols;
})();
return [...base, ...dynamic];
}, [deviceIds, fractionDigits, dataset]);
}, [historySeries, fractionDigits, dataset]);
const rows = useMemo(
() =>
@@ -733,91 +424,57 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
];
const xData = dataset.map((item) => item.label);
const hasFlowSeries = historySeries.some((item) => item.metric === "flow");
const hasPressureSeries = historySeries.some(
(item) => item.metric === "pressure",
);
const getSeries = () => {
return deviceIds.flatMap((id, index) => {
const series = [];
["clean", "raw", "sim", "scheme_sim"].forEach((suffix, sIndex) => {
const key = `${id}_${suffix}`;
const hasData = dataset.some(
(item) => item[key] !== null && item[key] !== undefined
);
if (hasData) {
const displayName =
suffix === "clean"
? "清洗值"
: suffix === "raw"
? "监测值"
: suffix === "sim"
? "实时模拟"
: "方案模拟";
series.push({
name: `${id} (${displayName})`,
type: "line",
symbol:
suffix === "clean"
? "circle"
: suffix === "raw"
return historySeries.flatMap((metadata, index) => {
const key = historySeriesKey(metadata);
const hasSeriesData = dataset.some(
(item) => item[key] !== null && item[key] !== undefined,
);
if (!hasSeriesData) return [];
const isObserved = metadata.source.startsWith("scada_");
return [
{
name: `${historySeriesLabel(metadata)} [${metadata.display_unit}]`,
type: "line",
yAxisIndex:
metadata.metric === "pressure" && hasFlowSeries ? 1 : 0,
symbol:
metadata.source === "scada_cleaned"
? "circle"
: metadata.source === "scada_raw"
? "diamond"
: "none",
symbolSize: suffix === "clean" || suffix === "raw" ? 7 : 0,
showSymbol: suffix === "clean" || suffix === "raw",
sampling: "lttb",
connectNulls: suffix !== "clean" && suffix !== "raw",
itemStyle: {
color: colors[(index * 4 + sIndex) % colors.length],
},
data: dataset.map((item) => item[key]),
lineStyle:
suffix === "clean" || suffix === "raw"
? { width: 0 }
: undefined,
areaStyle:
suffix === "clean" || suffix === "raw"
? undefined
: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: colors[(index * 4 + sIndex) % colors.length],
},
{
offset: 1,
color: "rgba(255, 255, 255, 0)",
},
]),
opacity: 0.3,
},
});
}
});
// 如果没有任何数据,则使用fallback
if (series.length === 0) {
series.push({
name: id,
type: "line",
symbol: "none",
symbolSize: isObserved ? 7 : 0,
showSymbol: isObserved,
sampling: "lttb",
connectNulls: true,
itemStyle: { color: colors[index % colors.length] },
data: dataset.map((item) => item[id]),
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: colors[index % colors.length],
},
{
offset: 1,
color: "rgba(255, 255, 255, 0)",
},
]),
opacity: 0.3,
connectNulls: !isObserved,
itemStyle: {
color: colors[index % colors.length],
},
});
}
return series;
data: dataset.map((item) => item[key]),
lineStyle: isObserved ? { width: 0 } : undefined,
areaStyle: isObserved
? undefined
: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: colors[index % colors.length],
},
{
offset: 1,
color: "rgba(255, 255, 255, 0)",
},
]),
opacity: 0.3,
},
},
];
});
};
const option = {
@@ -853,10 +510,26 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
boundaryGap: false,
data: xData,
},
yAxis: {
type: "value",
scale: true,
},
yAxis: [
...(hasFlowSeries
? [
{
type: "value",
scale: true,
name: `流量 (${FLOW_DISPLAY_UNIT})`,
},
]
: []),
...(hasPressureSeries
? [
{
type: "value",
scale: true,
name: `压力 (${PRESSURE_DISPLAY_UNIT})`,
},
]
: []),
],
dataZoom: [
{
type: "inside",
@@ -0,0 +1,38 @@
import { render, screen } from "@testing-library/react";
import type Feature from "ol/Feature";
import PropertyPanel from "./PropertyPanel";
import { buildFeatureProperties } from "./toolbarFeatureHelpers";
jest.mock("react-draggable", () => ({
__esModule: true,
default: ({ children }: { children: React.ReactNode }) => children,
}));
jest.mock("ol/Feature", () => ({
__esModule: true,
default: class Feature {},
}));
describe("PropertyPanel junction demands", () => {
it("shows a readable demand table instead of raw GeoServer JSON", () => {
const feature = {
getId: () => "junctions.n_001",
getProperties: () => ({
id: "n_001",
elevation: 42,
base_demand: 0,
demands:
'[{"category":null,"pattern_id":"PAT_BASE","base_demand":0,"sequence_no":0}]',
}),
} as unknown as Feature;
const panelData = buildFeatureProperties(feature, {});
render(<PropertyPanel {...panelData} onClose={jest.fn()} />);
expect(screen.getByText("PAT_BASE")).toBeInTheDocument();
expect(screen.getAllByText("0.000 m³/h")).toHaveLength(2);
expect(screen.getByText("未分类")).toBeInTheDocument();
expect(screen.queryByText(/\[{"category"/)).not.toBeInTheDocument();
});
});
@@ -835,6 +835,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
onSave: handleValveSettingSave,
}
: undefined,
data?.resultUnits,
),
[
selectedFeature,
@@ -848,6 +849,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
isValveStatusLoading,
isValveStatusSaving,
isValvePropertiesLoading,
data?.resultUnits,
isValveSettingSaving,
handleValveStatusSave,
handleValveSettingSave,
@@ -173,6 +173,52 @@ describe("getSimulationElementType", () => {
});
describe("buildFeatureProperties simulation values by hydraulic type", () => {
it("renders GeoServer demand JSON as a readable unit-aware table", () => {
const junction = createFeature("junctions", "n_001", {
base_demand: 0,
demands:
'[{"category":null,"pattern_id":"PAT_BASE","base_demand":0,"sequence_no":0}]',
});
const result = buildFeatureProperties(
junction,
{},
undefined,
undefined,
{ flow: "LPS", pressure: "METERS", velocity: "m/s" },
);
expect(result.properties).toEqual(
expect.arrayContaining([
{
type: "table",
label: "需水配置",
columns: ["序号", "基础需水量", "模式", "类别"],
rows: [[1, "0.000 m³/h", "PAT_BASE", "未分类"]],
},
]),
);
});
it("uses the selected project's MLD model unit for demand values", () => {
const junction = createFeature("junctions", "J1", { base_demand: 1 });
const result = buildFeatureProperties(
junction,
{ actual_demand: 2, pressure: 18 },
undefined,
undefined,
{ flow: "MLD", pressure: "METERS", velocity: "m/s" },
);
expect(result.properties).toEqual(
expect.arrayContaining([
expect.objectContaining({ label: "基本需水量", value: "41.667" }),
expect.objectContaining({ label: "实际需水量", value: "83.333" }),
]),
);
});
it("shows link simulation results for a point-rendered pump", () => {
const pump = createFeature("pumps", "P1", {
node1: "J1",
@@ -1,6 +1,13 @@
import Feature from "ol/Feature";
import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units";
import {
DEFAULT_NETWORK_RESULT_UNITS,
FLOW_DISPLAY_UNIT,
PRESSURE_DISPLAY_UNIT,
type NetworkResultUnits,
VELOCITY_DISPLAY_UNIT,
toModelDisplayValue,
} from "@utils/units";
import {
getValveSettingHelperText,
VALVE_STATUS_OPTIONS,
@@ -163,11 +170,70 @@ export const inferHistoryFeatureInfos = (
})
.filter(Boolean) as [string, string][];
type DemandEntry = {
sequence_no?: unknown;
base_demand?: unknown;
demand?: unknown;
pattern_id?: unknown;
pattern?: unknown;
category?: unknown;
};
const parseDemandEntries = (value: unknown): DemandEntry[] => {
let parsed = value;
if (typeof parsed === "string") {
try {
parsed = JSON.parse(parsed);
} catch {
return [];
}
}
if (!Array.isArray(parsed)) return [];
return parsed.filter(
(entry): entry is DemandEntry =>
typeof entry === "object" && entry !== null,
);
};
const buildDemandProperty = (
value: unknown,
resultUnits: NetworkResultUnits,
): ToolbarPropertyItem => {
const entries = parseDemandEntries(value);
if (entries.length === 0) {
return { label: "需水配置", value: "未配置" };
}
return {
type: "table",
label: "需水配置",
columns: ["序号", "基础需水量", "模式", "类别"],
rows: entries.map((entry, index) => {
const sourceDemand = Number(entry.base_demand ?? entry.demand);
const demand = Number.isFinite(sourceDemand)
? `${toModelDisplayValue(
sourceDemand,
"base_demand",
resultUnits,
).toFixed(3)} ${FLOW_DISPLAY_UNIT}`
: "未设置";
const sequence = Number(entry.sequence_no);
return [
Number.isInteger(sequence) ? sequence + 1 : index + 1,
demand,
String(entry.pattern_id ?? entry.pattern ?? "无"),
String(entry.category ?? "未分类"),
];
}),
};
};
export const buildFeatureProperties = (
highlightFeature: Feature | undefined,
computedProperties: Record<string, any>,
valveStatus?: ValveStatusPropertyOptions,
valveSetting?: ValveSettingPropertyOptions,
resultUnits: NetworkResultUnits = DEFAULT_NETWORK_RESULT_UNITS,
): ToolbarPropertyPanelData => {
if (!highlightFeature) return {};
@@ -182,12 +248,12 @@ export const buildFeatureProperties = (
{ key: "reaction", label: "反应", unit: "1/d" },
{ key: "setting", label: "设置", unit: "" },
{ key: "status", label: "状态", unit: "" },
{ key: "velocity", label: "流速", unit: "m/s" },
{ key: "velocity", label: "流速", unit: VELOCITY_DISPLAY_UNIT },
];
const nodeComputedFields = [
{ key: "actual_demand", label: "实际需水量", unit: `${FLOW_DISPLAY_UNIT}` },
{ key: "total_head", label: "水头", unit: "m" },
{ key: "pressure", label: "压力", unit: "m" },
{ key: "pressure", label: "压力", unit: PRESSURE_DISPLAY_UNIT },
{ key: "quality", label: "水质", unit: "mg/L" },
];
@@ -200,7 +266,10 @@ export const buildFeatureProperties = (
let value = computedProperties[key];
if (key === "flow" && value !== undefined) {
value = toM3h(value, "lps");
value = toModelDisplayValue(value, key, resultUnits);
}
if (key === "velocity" && value !== undefined) {
value = toModelDisplayValue(value, key, resultUnits);
}
if (
key === "unit_headloss" &&
@@ -226,7 +295,10 @@ export const buildFeatureProperties = (
let value = computedProperties[key];
if (key === "actual_demand") {
value = toM3h(value, "lps");
value = toModelDisplayValue(value, key, resultUnits);
}
if (key === "pressure") {
value = toModelDisplayValue(value, key, resultUnits);
}
result.properties?.push({
label,
@@ -273,14 +345,15 @@ export const buildFeatureProperties = (
{
label: "基本需水量",
value: Number.isFinite(Number(properties.base_demand))
? toM3h(Number(properties.base_demand), "lps").toFixed(3)
? toModelDisplayValue(
Number(properties.base_demand),
"base_demand",
resultUnits,
).toFixed(3)
: properties.base_demand,
unit: "m³/h",
},
{
label: "需水配置",
value: properties.demands,
unit: FLOW_DISPLAY_UNIT,
},
buildDemandProperty(properties.demands, resultUnits),
],
};
@@ -5,7 +5,10 @@ import type { FlatStyleLike } from "ol/style/flat";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { config } from "@/config/config";
import { isLpsFlowProperty, toM3h } from "@utils/units";
import {
type NetworkResultUnits,
toModelDisplayValue,
} from "@utils/units";
import { LayerStyleController } from "../layerStyleController";
import { useData, useMap } from "../MapComponent";
@@ -56,11 +59,15 @@ const configsEqual = (left?: StyleConfig, right?: StyleConfig) =>
const hasSameTemplate = (left: StyleConfig, right: StyleConfig) =>
left.property === right.property && left.segments === right.segments;
const normalizeComputedStyleValue = (property: string, value: unknown) => {
const normalizeComputedStyleValue = (
property: string,
value: unknown,
resultUnits?: NetworkResultUnits,
) => {
const numericValue = Number(value);
if (!Number.isFinite(numericValue)) return Number.NaN;
const displayValue = isLpsFlowProperty(property)
? toM3h(numericValue, "lps")
const displayValue = resultUnits
? toModelDisplayValue(numericValue, property, resultUnits)
: numericValue;
return property === "flow" ? Math.abs(displayValue) : displayValue;
};
@@ -140,6 +147,7 @@ export const useStyleEditor = ({
const elevationRange = data?.elevationRange;
const diameterRange = data?.diameterRange;
const forceStyleAutoApplyVersion = data?.forceStyleAutoApplyVersion ?? 0;
const resultUnits = data?.resultUnits;
const setJunctionText = data?.setJunctionText;
const setPipeText = data?.setPipeText;
const setShowJunctionTextLayer = data?.setShowJunctionTextLayer;
@@ -311,10 +319,18 @@ export const useStyleEditor = ({
}
const records = layerId === "junctions" ? currentJunctionCalData : currentPipeCalData;
return (records || [])
.map((item: any) => normalizeComputedStyleValue(property, item.value))
.map((item: any) =>
normalizeComputedStyleValue(property, item.value, resultUnits),
)
.filter(Number.isFinite);
},
[currentJunctionCalData, currentPipeCalData, diameterRange, elevationRange],
[
currentJunctionCalData,
currentPipeCalData,
diameterRange,
elevationRange,
resultUnits,
],
);
const syncAuxiliaryLayers = useCallback(
@@ -425,7 +441,11 @@ export const useStyleEditor = ({
records.forEach((record: any) => {
const id = record.ID ?? record.id;
if (id === undefined || id === null) return;
const value = normalizeComputedStyleValue(nextConfig.property, record.value);
const value = normalizeComputedStyleValue(
nextConfig.property,
record.value,
resultUnits,
);
if (Number.isFinite(value)) stateById.set(String(id), value);
});
const committed = await controller.applyRuntime(options, stateById);
@@ -465,6 +485,7 @@ export const useStyleEditor = ({
getDataForMap,
getMapKey,
getRenderLayersById,
resultUnits,
syncContoursForStyle,
upsertLayerStyleState,
],
+39 -17
View File
@@ -23,7 +23,11 @@ import { TextLayer } from "@deck.gl/layers";
import { TripsLayer } from "@deck.gl/geo-layers";
import { CollisionFilterExtension } from "@deck.gl/extensions";
import { ContourLayer } from "deck.gl";
import { isLpsFlowProperty, toM3h } from "@utils/units";
import {
type NetworkResultUnits,
toModelDisplayValue,
} from "@utils/units";
import { useNetworkResultUnits } from "@/hooks/useNetworkResultUnits";
import { usePathname } from "next/navigation";
import {
cleanupTransientMapResources,
@@ -125,6 +129,7 @@ interface DataContextType {
elevationRange?: [number, number];
forceStyleAutoApplyVersion?: number;
setForceStyleAutoApplyVersion?: React.Dispatch<React.SetStateAction<number>>;
resultUnits?: NetworkResultUnits;
}
// 跨组件传递
@@ -138,14 +143,13 @@ const mergeJunctionValues = (
features: any[],
records: any[],
property: string,
resultUnits: NetworkResultUnits,
) => {
const recordsById = indexCalculationRecords(records);
return features.map((feature) => {
const record = recordsById.get(String(feature.id));
if (!record) return feature;
const value = isLpsFlowProperty(property)
? toM3h(record.value, "lps")
: record.value;
const value = toModelDisplayValue(record.value, property, resultUnits);
return { ...feature, [property]: value };
});
};
@@ -154,13 +158,14 @@ const mergePipeValues = (
features: any[],
records: any[],
property: string,
resultUnits: NetworkResultUnits,
) => {
const recordsById = indexCalculationRecords(records);
const isFlow = property === "flow";
return features.map((feature) => {
const record = recordsById.get(String(feature.id));
if (!record) return feature;
const value = isFlow ? toM3h(record.value, "lps") : record.value;
const value = toModelDisplayValue(record.value, property, resultUnits);
const reverseFlow = isFlow && record.value < 0;
return {
...feature,
@@ -202,6 +207,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 resultUnits = useNetworkResultUnits(project?.networkName);
const mapRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
@@ -288,37 +294,52 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
// 实时合并计算结果到基础地理数据中
const mergedJunctionData = useMemo(
() =>
mergeJunctionValues(junctionData, currentJunctionCalData, junctionText),
[junctionData, currentJunctionCalData, junctionText],
mergeJunctionValues(
junctionData,
currentJunctionCalData,
junctionText,
resultUnits,
),
[junctionData, currentJunctionCalData, junctionText, resultUnits],
);
const mergedPipeData = useMemo(
() => mergePipeValues(pipeData, currentPipeCalData, pipeText),
[pipeData, currentPipeCalData, pipeText],
() => mergePipeValues(pipeData, currentPipeCalData, pipeText, resultUnits),
[pipeData, currentPipeCalData, pipeText, resultUnits],
);
const mergedPipeFragments = useMemo(
() => mergePipeValues(pipeFragments, currentPipeCalData, pipeText),
[pipeFragments, currentPipeCalData, pipeText],
() => mergePipeValues(pipeFragments, currentPipeCalData, pipeText, resultUnits),
[pipeFragments, currentPipeCalData, pipeText, resultUnits],
);
const mergedCompareJunctionData = useMemo(
() =>
isCompareMode
? mergeJunctionValues(junctionData, compareJunctionCalData, junctionText)
? mergeJunctionValues(
junctionData,
compareJunctionCalData,
junctionText,
resultUnits,
)
: [],
[isCompareMode, junctionData, compareJunctionCalData, junctionText],
[isCompareMode, junctionData, compareJunctionCalData, junctionText, resultUnits],
);
const mergedComparePipeData = useMemo(
() =>
isCompareMode
? mergePipeValues(pipeData, comparePipeCalData, pipeText)
? mergePipeValues(pipeData, comparePipeCalData, pipeText, resultUnits)
: [],
[isCompareMode, pipeData, comparePipeCalData, pipeText],
[isCompareMode, pipeData, comparePipeCalData, pipeText, resultUnits],
);
const mergedComparePipeFragments = useMemo(
() =>
isCompareMode
? mergePipeValues(pipeFragments, comparePipeCalData, pipeText)
? mergePipeValues(
pipeFragments,
comparePipeCalData,
pipeText,
resultUnits,
)
: [],
[isCompareMode, pipeFragments, comparePipeCalData, pipeText],
[isCompareMode, pipeFragments, comparePipeCalData, pipeText, resultUnits],
);
const [diameterRange, setDiameterRange] = useState<
@@ -1163,6 +1184,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
elevationRange,
forceStyleAutoApplyVersion,
setForceStyleAutoApplyVersion,
resultUnits,
}}
>
<MapContext.Provider value={map}>