feat(history): align units and history data
This commit is contained in:
@@ -7,7 +7,7 @@
|
|||||||
},
|
},
|
||||||
"server": {
|
"server": {
|
||||||
"file": "server-v1.openapi.json",
|
"file": "server-v1.openapi.json",
|
||||||
"sha256": "d0364fb08c6f18fac2ea9c9980ef21115fc01f110cd10f25f90d97d0bc0e6367"
|
"sha256": "b565d841061c9091f48ff3118bcc0cbb1b918b0cb8c2316d177570e8b2d8ba29"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+591
-1513
File diff suppressed because it is too large
Load Diff
@@ -102,10 +102,10 @@ export function createAssetInspector({model,networkRoot,scene,style,onLocate,onS
|
|||||||
if(item.cad)pressureBasis='接入节点压力,非立管上端压力';
|
if(item.cad)pressureBasis='接入节点压力,非立管上端压力';
|
||||||
const pressureSource=result?.source==='scada'?'SCADA '+(result.deviceId??'监测'):pressure!=null?'在线模拟':'暂无数据';
|
const pressureSource=result?.source==='scada'?'SCADA '+(result.deviceId??'监测'):pressure!=null?'在线模拟':'暂无数据';
|
||||||
sections.push({title:'运行结果',fields:fields([
|
sections.push({title:'运行结果',fields:fields([
|
||||||
['运行状态',status(result?.status)],['压力',value(pressure,'mH₂O')],
|
['运行状态',status(result?.status)],['压力',value(pressure,'m')],
|
||||||
...(pressure!=null?[['压力依据',pressureBasis],['压力来源',pressureSource]]:[]),
|
...(pressure!=null?[['压力依据',pressureBasis],['压力来源',pressureSource]]:[]),
|
||||||
...(result?.source==='scada'&&Number.isFinite(result.simulationPressure)?[['同期模拟压力',value(result.simulationPressure,'mH₂O')]]:[]),
|
...(result?.source==='scada'&&Number.isFinite(result.simulationPressure)?[['同期模拟压力',value(result.simulationPressure,'m')]]:[]),
|
||||||
...(link?[['流速',value(result?.velocity==null?null:Math.abs(result.velocity),'m/s')],['流量',value(result?.flow,'L/s')],['流向',result?.status?.toLowerCase()==='closed'?'停流':result?.direction!=null||result?.flow!=null?((result.direction??Math.sign(result.flow))>0?'起点 → 终点':(result.direction??Math.sign(result.flow))<0?'终点 → 起点':'停流'):'暂无数据']]:[]),
|
...(link?[['流速',value(result?.velocity==null?null:Math.abs(result.velocity),'m/s')],['流量',value(result?.flow,'m³/h')],['流向',result?.status?.toLowerCase()==='closed'?'停流':result?.direction!=null||result?.flow!=null?((result.direction??Math.sign(result.flow))>0?'起点 → 终点':(result.direction??Math.sign(result.flow))<0?'终点 → 起点':'停流'):'暂无数据']]:[]),
|
||||||
])});
|
])});
|
||||||
sections.push({title:'来源说明',fields:fields([
|
sections.push({title:'来源说明',fields:fields([
|
||||||
['拓扑来源','已核对 INP;本页不求解水力'],['位置与高度','展示调整,不代表实测埋深'],
|
['拓扑来源','已核对 INP;本页不求解水力'],['位置与高度','展示调整,不代表实测埋深'],
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export const DEFAULT_STYLE=Object.freeze({scale:8,mode:'uniform',color:'#098ed0'
|
|||||||
const finite=v=>typeof v==='number'&&Number.isFinite(v);
|
const finite=v=>typeof v==='number'&&Number.isFinite(v);
|
||||||
export function validateResults(input,model){
|
export function validateResults(input,model){
|
||||||
if(input?.modelId!==model.modelId)throw Error('结果文件与当前管网模型编号不一致。');
|
if(input?.modelId!==model.modelId)throw Error('结果文件与当前管网模型编号不一致。');
|
||||||
if(input.units?.velocity!=='m/s'||input.units?.pressure!=='mH2O'||input.units?.flow!=='L/s')throw Error('结果单位须明确为 m/s、mH2O、L/s。');
|
if(input.units?.velocity!=='m/s'||input.units?.pressure!=='m'||input.units?.flow!=='m³/h')throw Error('结果单位须明确为 m/s、m、m³/h。');
|
||||||
const linkIds=new Set(model.links.map(l=>l.id)),nodeIds=new Set(model.nodes.map(n=>n.id));
|
const linkIds=new Set(model.links.map(l=>l.id)),nodeIds=new Set(model.nodes.map(n=>n.id));
|
||||||
for(const [table,ids,fields] of [[input.links??{},linkIds,['velocity','flow','pressure','direction']],[input.nodes??{},nodeIds,['pressure']]]){
|
for(const [table,ids,fields] of [[input.links??{},linkIds,['velocity','flow','pressure','direction']],[input.nodes??{},nodeIds,['pressure']]]){
|
||||||
if(typeof table!=='object'||table===null||Array.isArray(table))throw Error('结果必须按编号提供对象。');
|
if(typeof table!=='object'||table===null||Array.isArray(table))throw Error('结果必须按编号提供对象。');
|
||||||
|
|||||||
@@ -41,6 +41,19 @@ import { useNotification } from "@refinedev/core";
|
|||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { apiFetch } from "@/lib/apiFetch";
|
import { apiFetch } from "@/lib/apiFetch";
|
||||||
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
|
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(utc);
|
||||||
dayjs.extend(timezone);
|
dayjs.extend(timezone);
|
||||||
@@ -50,13 +63,6 @@ type IUser = {
|
|||||||
name?: string;
|
name?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface TimeSeriesPoint {
|
|
||||||
/** ISO8601 时间戳 */
|
|
||||||
timestamp: string;
|
|
||||||
/** 每个设备对应的值 */
|
|
||||||
values: Record<string, number | null | undefined>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SCADADataPanelProps {
|
export interface SCADADataPanelProps {
|
||||||
/** 选中的设备 ID 列表 */
|
/** 选中的设备 ID 列表 */
|
||||||
deviceIds: string[];
|
deviceIds: string[];
|
||||||
@@ -90,164 +96,75 @@ const panelHeaderActionSx = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
interface ScadaDeviceMetadata {
|
||||||
* 从后端 API 获取 SCADA 数据
|
device_id: string;
|
||||||
*/
|
device_type: string;
|
||||||
|
node_id: string | null;
|
||||||
|
link_id: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用设备元数据组装统一元素历史查询,一次返回监测与模拟数据。 */
|
||||||
const fetchFromBackend = async (
|
const fetchFromBackend = async (
|
||||||
deviceIds: string[],
|
deviceIds: string[],
|
||||||
range: { from: Date; to: Date },
|
range: { from: Date; to: Date },
|
||||||
): Promise<TimeSeriesPoint[]> => {
|
): Promise<ElementHistoryResult> => {
|
||||||
if (deviceIds.length === 0) {
|
if (deviceIds.length === 0) {
|
||||||
return [];
|
return { points: [], series: [] };
|
||||||
}
|
}
|
||||||
|
const metadataResponse = await apiFetch(
|
||||||
const device_ids = deviceIds.join(",");
|
`${config.BACKEND_URL}/api/v1/scada-devices`,
|
||||||
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(),
|
|
||||||
);
|
);
|
||||||
|
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) =>
|
const formatTimestamp = (timestamp: string) =>
|
||||||
@@ -337,83 +254,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
const { open } = useNotification();
|
const { open } = useNotification();
|
||||||
const { data: user } = useGetIdentity<IUser>();
|
const { data: user } = useGetIdentity<IUser>();
|
||||||
|
|
||||||
const customFetcher = useMemo(() => {
|
const customFetcher = fetchFromBackend;
|
||||||
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 [from, setFrom] = useState<Dayjs>(() => {
|
const [from, setFrom] = useState<Dayjs>(() => {
|
||||||
if (start_time) {
|
if (start_time) {
|
||||||
@@ -435,6 +276,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
});
|
});
|
||||||
const [activeTab, setActiveTab] = useState<PanelTab>(defaultTab);
|
const [activeTab, setActiveTab] = useState<PanelTab>(defaultTab);
|
||||||
const [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([]);
|
const [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([]);
|
||||||
|
const [historySeries, setHistorySeries] = useState<ElementHistorySeries[]>([]);
|
||||||
const [loadingState, setLoadingState] = useState<LoadingState>("idle");
|
const [loadingState, setLoadingState] = useState<LoadingState>("idle");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isExpanded, setIsExpanded] = useState<boolean>(true);
|
const [isExpanded, setIsExpanded] = useState<boolean>(true);
|
||||||
@@ -473,11 +315,30 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
() => buildDataset(timeSeries, deviceIds, fractionDigits, showCleaning),
|
() => buildDataset(timeSeries, deviceIds, fractionDigits, showCleaning),
|
||||||
[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(
|
const handleFetch = useCallback(
|
||||||
async (reason: string) => {
|
async (reason: string) => {
|
||||||
if (!hasDevices) {
|
if (!hasDevices) {
|
||||||
setTimeSeries([]);
|
setTimeSeries([]);
|
||||||
|
setHistorySeries([]);
|
||||||
setLoadingState("idle");
|
setLoadingState("idle");
|
||||||
setError(null);
|
setError(null);
|
||||||
return;
|
return;
|
||||||
@@ -491,7 +352,8 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
from: rangeFrom.toDate(),
|
from: rangeFrom.toDate(),
|
||||||
to: rangeTo.toDate(),
|
to: rangeTo.toDate(),
|
||||||
});
|
});
|
||||||
setTimeSeries(result);
|
setTimeSeries(result.points);
|
||||||
|
setHistorySeries(result.series);
|
||||||
setLoadingState("success");
|
setLoadingState("success");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "未知错误");
|
setError(err instanceof Error ? err.message : "未知错误");
|
||||||
@@ -583,6 +445,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
handleFetch("device-change");
|
handleFetch("device-change");
|
||||||
} else {
|
} else {
|
||||||
setTimeSeries([]);
|
setTimeSeries([]);
|
||||||
|
setHistorySeries([]);
|
||||||
}
|
}
|
||||||
}, [deviceIdsKey, handleFetch, hasDevices]);
|
}, [deviceIdsKey, handleFetch, hasDevices]);
|
||||||
|
|
||||||
@@ -610,7 +473,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
return deviceIds.flatMap<GridColDef>((id) => [
|
return deviceIds.flatMap<GridColDef>((id) => [
|
||||||
{
|
{
|
||||||
field: `${id}_raw`,
|
field: `${id}_raw`,
|
||||||
headerName: `${id} (原始)`,
|
headerName: `${id} (原始) [${unitForKey(`${id}_raw`)}]`,
|
||||||
minWidth: 140,
|
minWidth: 140,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
valueFormatter: (value: any) => {
|
valueFormatter: (value: any) => {
|
||||||
@@ -623,7 +486,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: `${id}_clean`,
|
field: `${id}_clean`,
|
||||||
headerName: `${id} (清洗)`,
|
headerName: `${id} (清洗) [${unitForKey(`${id}_clean`)}]`,
|
||||||
minWidth: 140,
|
minWidth: 140,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
valueFormatter: (value: any) => {
|
valueFormatter: (value: any) => {
|
||||||
@@ -636,7 +499,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: `${id}_sim`,
|
field: `${id}_sim`,
|
||||||
headerName: `${id} (模拟)`,
|
headerName: `${id} (模拟) [${unitForKey(`${id}_sim`)}]`,
|
||||||
minWidth: 140,
|
minWidth: 140,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
valueFormatter: (value: any) => {
|
valueFormatter: (value: any) => {
|
||||||
@@ -652,7 +515,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
// 单一数据源模式:只显示选中的数据源
|
// 单一数据源模式:只显示选中的数据源
|
||||||
return deviceIds.map<GridColDef>((id) => ({
|
return deviceIds.map<GridColDef>((id) => ({
|
||||||
field: `${id}_${selectedSource}`,
|
field: `${id}_${selectedSource}`,
|
||||||
headerName: id,
|
headerName: `${id} [${unitForKey(`${id}_${selectedSource}`)}]`,
|
||||||
minWidth: 140,
|
minWidth: 140,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
valueFormatter: (value: any) => {
|
valueFormatter: (value: any) => {
|
||||||
@@ -688,7 +551,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
if (hasData) {
|
if (hasData) {
|
||||||
cols.push({
|
cols.push({
|
||||||
field: fieldKey,
|
field: fieldKey,
|
||||||
headerName: `${deviceName} (${name})`,
|
headerName: `${deviceName} (${name}) [${unitForKey(fieldKey)}]`,
|
||||||
minWidth: 140,
|
minWidth: 140,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
valueFormatter: (value: any) => {
|
valueFormatter: (value: any) => {
|
||||||
@@ -708,7 +571,14 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
return [...base, ...dynamic];
|
return [...base, ...dynamic];
|
||||||
}, [deviceIds, fractionDigits, showCleaning, selectedSource, dataset]);
|
}, [
|
||||||
|
deviceIds,
|
||||||
|
fractionDigits,
|
||||||
|
showCleaning,
|
||||||
|
selectedSource,
|
||||||
|
dataset,
|
||||||
|
unitForKey,
|
||||||
|
]);
|
||||||
|
|
||||||
const rows = useMemo(
|
const rows = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -766,8 +636,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
if (selectedSource === "all") {
|
if (selectedSource === "all") {
|
||||||
return deviceIds.flatMap((id, index) => [
|
return deviceIds.flatMap((id, index) => [
|
||||||
{
|
{
|
||||||
name: `${id} (原始)`,
|
name: `${id} (原始) [${unitForKey(`${id}_raw`)}]`,
|
||||||
type: "line",
|
type: "line",
|
||||||
|
yAxisIndex: axisForKey(`${id}_raw`),
|
||||||
symbol: "none",
|
symbol: "none",
|
||||||
connectNulls: true,
|
connectNulls: true,
|
||||||
sampling: "lttb",
|
sampling: "lttb",
|
||||||
@@ -775,8 +646,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
data: dataset.map((item) => item[`${id}_raw`]),
|
data: dataset.map((item) => item[`${id}_raw`]),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: `${id} (清洗)`,
|
name: `${id} (清洗) [${unitForKey(`${id}_clean`)}]`,
|
||||||
type: "line",
|
type: "line",
|
||||||
|
yAxisIndex: axisForKey(`${id}_clean`),
|
||||||
symbol: "none",
|
symbol: "none",
|
||||||
connectNulls: true,
|
connectNulls: true,
|
||||||
sampling: "lttb",
|
sampling: "lttb",
|
||||||
@@ -784,8 +656,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
data: dataset.map((item) => item[`${id}_clean`]),
|
data: dataset.map((item) => item[`${id}_clean`]),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: `${id} (模拟)`,
|
name: `${id} (模拟) [${unitForKey(`${id}_sim`)}]`,
|
||||||
type: "line",
|
type: "line",
|
||||||
|
yAxisIndex: axisForKey(`${id}_sim`),
|
||||||
symbol: "none",
|
symbol: "none",
|
||||||
connectNulls: true,
|
connectNulls: true,
|
||||||
sampling: "lttb",
|
sampling: "lttb",
|
||||||
@@ -795,8 +668,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
return deviceIds.map((id, index) => ({
|
return deviceIds.map((id, index) => ({
|
||||||
name: id,
|
name: `${id} [${unitForKey(`${id}_${selectedSource}`)}]`,
|
||||||
type: "line",
|
type: "line",
|
||||||
|
yAxisIndex: axisForKey(`${id}_${selectedSource}`),
|
||||||
symbol: "none",
|
symbol: "none",
|
||||||
connectNulls: true,
|
connectNulls: true,
|
||||||
sampling: "lttb",
|
sampling: "lttb",
|
||||||
@@ -820,8 +694,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
: suffix === "clean"
|
: suffix === "clean"
|
||||||
? "清洗"
|
? "清洗"
|
||||||
: "模拟"
|
: "模拟"
|
||||||
})`,
|
}) [${unitForKey(key)}]`,
|
||||||
type: "line",
|
type: "line",
|
||||||
|
yAxisIndex: axisForKey(key),
|
||||||
symbol: "none",
|
symbol: "none",
|
||||||
connectNulls: true,
|
connectNulls: true,
|
||||||
sampling: "lttb",
|
sampling: "lttb",
|
||||||
@@ -908,10 +783,26 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
boundaryGap: false,
|
boundaryGap: false,
|
||||||
data: xData,
|
data: xData,
|
||||||
},
|
},
|
||||||
yAxis: {
|
yAxis: [
|
||||||
type: "value",
|
...(hasFlowSeries
|
||||||
scale: true,
|
? [
|
||||||
},
|
{
|
||||||
|
type: "value",
|
||||||
|
scale: true,
|
||||||
|
name: `流量 (${FLOW_DISPLAY_UNIT})`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(hasPressureSeries
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
type: "value",
|
||||||
|
scale: true,
|
||||||
|
name: `压力 (${PRESSURE_DISPLAY_UNIT})`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
dataZoom: [
|
dataZoom: [
|
||||||
{
|
{
|
||||||
type: "inside",
|
type: "inside",
|
||||||
|
|||||||
@@ -24,14 +24,8 @@ const range = {
|
|||||||
describe("fetchHistoryData", () => {
|
describe("fetchHistoryData", () => {
|
||||||
beforeEach(() => jest.clearAllMocks());
|
beforeEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
it("queries SCADA readings once per selected network element", async () => {
|
it("queries all selected elements in one batch request", async () => {
|
||||||
jest.mocked(apiFetch).mockImplementation(async (input) => {
|
jest.mocked(apiFetch).mockResolvedValue(jsonResponse({ series: [] }));
|
||||||
const url = new URL(String(input));
|
|
||||||
const elementId = url.searchParams.get("element_id") ?? "";
|
|
||||||
return jsonResponse({
|
|
||||||
[elementId]: [{ time: range.from.toISOString(), value: 1 }],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await fetchHistoryData(
|
await fetchHistoryData(
|
||||||
[
|
[
|
||||||
@@ -42,16 +36,20 @@ describe("fetchHistoryData", () => {
|
|||||||
"none",
|
"none",
|
||||||
);
|
);
|
||||||
|
|
||||||
const elementIds = jest
|
expect(apiFetch).toHaveBeenCalledTimes(1);
|
||||||
.mocked(apiFetch)
|
const [url, init] = jest.mocked(apiFetch).mock.calls[0];
|
||||||
.mock.calls.map(([input]) =>
|
expect(String(url)).toContain("/element-history/query");
|
||||||
new URL(String(input)).searchParams.get("element_id"),
|
expect(JSON.parse(String(init?.body))).toMatchObject({
|
||||||
);
|
mode: "observed",
|
||||||
expect(elementIds).toEqual(["J-1", "J-2", "J-1", "J-2"]);
|
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 () => {
|
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(
|
await fetchHistoryData(
|
||||||
[["P-1", "pipe"]],
|
[["P-1", "pipe"]],
|
||||||
@@ -60,15 +58,12 @@ describe("fetchHistoryData", () => {
|
|||||||
"99dd4142-368b-54cb-bfca-d59ee48f6298",
|
"99dd4142-368b-54cb-bfca-d59ee48f6298",
|
||||||
);
|
);
|
||||||
|
|
||||||
const simulationUrls = jest
|
expect(apiFetch).toHaveBeenCalledTimes(1);
|
||||||
.mocked(apiFetch)
|
const [, init] = jest.mocked(apiFetch).mock.calls[0];
|
||||||
.mock.calls.map(([input]) => new URL(String(input)))
|
expect(JSON.parse(String(init?.body))).toMatchObject({
|
||||||
.filter((url) => url.pathname.endsWith("/element-simulations"));
|
mode: "analysis_comparison",
|
||||||
|
run_id: "99dd4142-368b-54cb-bfca-d59ee48f6298",
|
||||||
expect(simulationUrls).toHaveLength(2);
|
});
|
||||||
expect(
|
|
||||||
simulationUrls.map((url) => url.searchParams.get("run_id")),
|
|
||||||
).toEqual([null, "99dd4142-368b-54cb-bfca-d59ee48f6298"]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects oversized element selections before issuing requests", async () => {
|
it("rejects oversized element selections before issuing requests", async () => {
|
||||||
@@ -84,17 +79,8 @@ describe("fetchHistoryData", () => {
|
|||||||
expect(apiFetch).not.toHaveBeenCalled();
|
expect(apiFetch).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("limits concurrent SCADA requests for multi-element history", async () => {
|
it("does not create N+1 requests for multi-element history", async () => {
|
||||||
let activeRequests = 0;
|
jest.mocked(apiFetch).mockResolvedValue(jsonResponse({ series: [] }));
|
||||||
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]: [] });
|
|
||||||
});
|
|
||||||
|
|
||||||
await fetchHistoryData(
|
await fetchHistoryData(
|
||||||
Array.from(
|
Array.from(
|
||||||
@@ -105,6 +91,6 @@ describe("fetchHistoryData", () => {
|
|||||||
"none",
|
"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 { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||||||
import { DateTimePicker, LocalizationProvider } from "@mui/x-date-pickers";
|
import { DateTimePicker, LocalizationProvider } from "@mui/x-date-pickers";
|
||||||
import { zhCN as pickerZhCN } from "@mui/x-date-pickers/locales";
|
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 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(utc);
|
||||||
dayjs.extend(timezone);
|
dayjs.extend(timezone);
|
||||||
|
|
||||||
export interface TimeSeriesPoint {
|
|
||||||
/** ISO8601 时间戳 */
|
|
||||||
timestamp: string;
|
|
||||||
/** 每个设备对应的值 */
|
|
||||||
values: Record<string, number | null | undefined>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SCADADataPanelProps {
|
export interface SCADADataPanelProps {
|
||||||
/** 选中的要素信息列表,格式为 [[id, type], [id, type]] */
|
/** 选中的要素信息列表,格式为 [[id, type], [id, type]] */
|
||||||
featureInfos: [string, string][];
|
featureInfos: [string, string][];
|
||||||
@@ -73,7 +76,6 @@ type LoadingState = "idle" | "loading" | "success" | "error";
|
|||||||
|
|
||||||
const MAX_HISTORY_ELEMENTS = 200;
|
const MAX_HISTORY_ELEMENTS = 200;
|
||||||
const MAX_HISTORY_ELEMENT_ID_LENGTH = 128;
|
const MAX_HISTORY_ELEMENT_ID_LENGTH = 128;
|
||||||
const HISTORY_SCADA_CONCURRENCY = 4;
|
|
||||||
|
|
||||||
const panelHeaderActionSx = {
|
const panelHeaderActionSx = {
|
||||||
color: "primary.contrastText",
|
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 获取管网元素的监测、实时模拟和方案模拟数据。 */
|
/** 从后端 API 获取管网元素的监测、实时模拟和方案模拟数据。 */
|
||||||
export const fetchHistoryData = async (
|
export const fetchHistoryData = async (
|
||||||
featureInfos: [string, string][],
|
featureInfos: [string, string][],
|
||||||
@@ -132,9 +92,9 @@ export const fetchHistoryData = async (
|
|||||||
type: "realtime" | "scheme" | "none",
|
type: "realtime" | "scheme" | "none",
|
||||||
schemeRunId?: string,
|
schemeRunId?: string,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<TimeSeriesPoint[]> => {
|
): Promise<ElementHistoryResult> => {
|
||||||
if (featureInfos.length === 0) {
|
if (featureInfos.length === 0) {
|
||||||
return [];
|
return { points: [], series: [] };
|
||||||
}
|
}
|
||||||
if (featureInfos.length > MAX_HISTORY_ELEMENTS) {
|
if (featureInfos.length > MAX_HISTORY_ELEMENTS) {
|
||||||
throw new Error(`历史数据一次最多查询 ${MAX_HISTORY_ELEMENTS} 个管网元素`);
|
throw new Error(`历史数据一次最多查询 ${MAX_HISTORY_ELEMENTS} 个管网元素`);
|
||||||
@@ -149,285 +109,36 @@ export const fetchHistoryData = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const uniqueFeatureInfos = Array.from(
|
const uniqueFeatureInfos = Array.from(
|
||||||
new Map(featureInfos.map((featureInfo) => [featureInfo[0], featureInfo])).values(),
|
new Map(
|
||||||
);
|
featureInfos.map((featureInfo) => [featureInfo.join(":"), featureInfo]),
|
||||||
const featureIds = uniqueFeatureInfos.map(([id]) => id);
|
).values(),
|
||||||
|
|
||||||
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 },
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (type === "scheme" && !schemeRunId) {
|
if (type === "scheme" && !schemeRunId) {
|
||||||
throw new Error("历史方案缺少分析运行 ID,无法读取方案时序数据");
|
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 {
|
try {
|
||||||
if (type === "none") {
|
return await fetchElementHistory(
|
||||||
// 查询清洗值和监测值
|
uniqueFeatureInfos.map(([id, elementType]) => ({
|
||||||
const [cleanedRes, rawRes] = await Promise.all([
|
element_id: id.trim(),
|
||||||
fetchElementScadaData(true),
|
element_type: elementType.toLowerCase() as "pipe" | "junction",
|
||||||
fetchElementScadaData(false),
|
})),
|
||||||
]);
|
range,
|
||||||
|
type === "none"
|
||||||
const cleanedData = transformBackendData(cleanedRes, featureIds);
|
? "observed"
|
||||||
// 如果清洗数据有值,则不显示原始监测值
|
: type === "scheme"
|
||||||
const rawData =
|
? "analysis_comparison"
|
||||||
cleanedData.length > 0 ? [] : transformBackendData(rawRes, featureIds);
|
: "realtime_comparison",
|
||||||
|
schemeRunId,
|
||||||
return mergeTimeSeriesData(
|
signal,
|
||||||
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;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[SCADADataPanel] 从后端获取数据失败:", error);
|
console.error("[HistoryDataPanel] 从后端获取数据失败:", error);
|
||||||
throw 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) =>
|
const formatTimestamp = (timestamp: string) =>
|
||||||
dayjs(timestamp).tz("Asia/Shanghai").format("YYYY-MM-DD HH:mm");
|
dayjs(timestamp).tz("Asia/Shanghai").format("YYYY-MM-DD HH:mm");
|
||||||
|
|
||||||
@@ -443,7 +154,7 @@ const ensureValidRange = (
|
|||||||
|
|
||||||
const buildDataset = (
|
const buildDataset = (
|
||||||
points: TimeSeriesPoint[],
|
points: TimeSeriesPoint[],
|
||||||
deviceIds: string[],
|
series: ElementHistorySeries[],
|
||||||
fractionDigits: number
|
fractionDigits: number
|
||||||
) => {
|
) => {
|
||||||
return points.map((point) => {
|
return points.map((point) => {
|
||||||
@@ -452,19 +163,17 @@ const buildDataset = (
|
|||||||
label: formatTimestamp(point.timestamp),
|
label: formatTimestamp(point.timestamp),
|
||||||
};
|
};
|
||||||
|
|
||||||
deviceIds.forEach((id) => {
|
series.forEach((metadata) => {
|
||||||
["clean", "raw", "sim", "scheme_sim"].forEach((suffix) => {
|
const key = historySeriesKey(metadata);
|
||||||
const key = `${id}_${suffix}`;
|
const value = point.values[key];
|
||||||
const value = point.values[key];
|
if (value !== undefined && value !== null) {
|
||||||
if (value !== undefined && value !== null) {
|
entry[key] =
|
||||||
entry[key] =
|
typeof value === "number"
|
||||||
typeof value === "number"
|
? Number.isFinite(value)
|
||||||
? Number.isFinite(value)
|
? parseFloat(value.toFixed(fractionDigits))
|
||||||
? parseFloat(value.toFixed(fractionDigits))
|
: null
|
||||||
: null
|
: value ?? null;
|
||||||
: value ?? null;
|
}
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return entry;
|
return entry;
|
||||||
@@ -521,11 +230,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
});
|
});
|
||||||
const [activeTab, setActiveTab] = useState<PanelTab>(defaultTab);
|
const [activeTab, setActiveTab] = useState<PanelTab>(defaultTab);
|
||||||
const [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([]);
|
const [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([]);
|
||||||
|
const [historySeries, setHistorySeries] = useState<ElementHistorySeries[]>([]);
|
||||||
const [loadingState, setLoadingState] = useState<LoadingState>("idle");
|
const [loadingState, setLoadingState] = useState<LoadingState>("idle");
|
||||||
const [error, setError] = useState<string | null>(null);
|
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 draggableRef = useRef<HTMLDivElement>(null);
|
||||||
const requestControllerRef = useRef<AbortController | null>(null);
|
const requestControllerRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
@@ -559,8 +266,8 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const dataset = useMemo(
|
const dataset = useMemo(
|
||||||
() => buildDataset(timeSeries, deviceIds, fractionDigits),
|
() => buildDataset(timeSeries, historySeries, fractionDigits),
|
||||||
[timeSeries, deviceIds, fractionDigits]
|
[timeSeries, historySeries, fractionDigits]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleFetch = useCallback(
|
const handleFetch = useCallback(
|
||||||
@@ -568,6 +275,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
if (!hasDevices) {
|
if (!hasDevices) {
|
||||||
requestControllerRef.current?.abort();
|
requestControllerRef.current?.abort();
|
||||||
setTimeSeries([]);
|
setTimeSeries([]);
|
||||||
|
setHistorySeries([]);
|
||||||
setLoadingState("idle");
|
setLoadingState("idle");
|
||||||
setError(null);
|
setError(null);
|
||||||
return;
|
return;
|
||||||
@@ -591,7 +299,8 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
requestController.signal,
|
requestController.signal,
|
||||||
);
|
);
|
||||||
if (requestControllerRef.current !== requestController) return;
|
if (requestControllerRef.current !== requestController) return;
|
||||||
setTimeSeries(result);
|
setTimeSeries(result.points);
|
||||||
|
setHistorySeries(result.series);
|
||||||
setLoadingState("success");
|
setLoadingState("success");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (
|
if (
|
||||||
@@ -620,16 +329,10 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
handleFetch("device-change");
|
handleFetch("device-change");
|
||||||
} else {
|
} else {
|
||||||
setTimeSeries([]);
|
setTimeSeries([]);
|
||||||
|
setHistorySeries([]);
|
||||||
}
|
}
|
||||||
}, [featureInfosKey, handleFetch, hasDevices]);
|
}, [featureInfosKey, handleFetch, hasDevices]);
|
||||||
|
|
||||||
// 当设备数量变化时,调整数据源选择
|
|
||||||
useEffect(() => {
|
|
||||||
if (featureInfos.length > 1 && selectedSource === "all") {
|
|
||||||
setSelectedSource("clean");
|
|
||||||
}
|
|
||||||
}, [featureInfos.length, selectedSource]);
|
|
||||||
|
|
||||||
const columns: GridColDef[] = useMemo(() => {
|
const columns: GridColDef[] = useMemo(() => {
|
||||||
const base: GridColDef[] = [
|
const base: GridColDef[] = [
|
||||||
{
|
{
|
||||||
@@ -643,45 +346,33 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
const dynamic = (() => {
|
const dynamic = (() => {
|
||||||
const cols: GridColDef[] = [];
|
const cols: GridColDef[] = [];
|
||||||
|
|
||||||
deviceIds.forEach((id) => {
|
historySeries.forEach((metadata) => {
|
||||||
// 为每个设备的每种数据类型创建列
|
const fieldKey = historySeriesKey(metadata);
|
||||||
const suffixes = [
|
const hasData = dataset.some(
|
||||||
{ key: "clean", name: "清洗值" },
|
(item) => item[fieldKey] !== null && item[fieldKey] !== undefined,
|
||||||
{ key: "raw", name: "监测值" },
|
);
|
||||||
{ key: "sim", name: "实时模拟值" },
|
if (hasData) {
|
||||||
{ key: "scheme_sim", name: "方案模拟值" },
|
cols.push({
|
||||||
];
|
field: fieldKey,
|
||||||
|
headerName: `${historySeriesLabel(metadata)} [${metadata.display_unit}]`,
|
||||||
suffixes.forEach(({ key, name }) => {
|
minWidth: 180,
|
||||||
const fieldKey = `${id}_${key}`;
|
flex: 1,
|
||||||
// 检查是否有该字段的数据
|
valueFormatter: (value: any) => {
|
||||||
const hasData = dataset.some(
|
if (value === null || value === undefined) return "--";
|
||||||
(item) => item[fieldKey] !== null && item[fieldKey] !== undefined
|
if (Number.isFinite(Number(value))) {
|
||||||
);
|
return Number(value).toFixed(fractionDigits);
|
||||||
|
}
|
||||||
if (hasData) {
|
return String(value);
|
||||||
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);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return cols;
|
return cols;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return [...base, ...dynamic];
|
return [...base, ...dynamic];
|
||||||
}, [deviceIds, fractionDigits, dataset]);
|
}, [historySeries, fractionDigits, dataset]);
|
||||||
|
|
||||||
const rows = useMemo(
|
const rows = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -733,91 +424,57 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
];
|
];
|
||||||
|
|
||||||
const xData = dataset.map((item) => item.label);
|
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 = () => {
|
const getSeries = () => {
|
||||||
return deviceIds.flatMap((id, index) => {
|
return historySeries.flatMap((metadata, index) => {
|
||||||
const series = [];
|
const key = historySeriesKey(metadata);
|
||||||
["clean", "raw", "sim", "scheme_sim"].forEach((suffix, sIndex) => {
|
const hasSeriesData = dataset.some(
|
||||||
const key = `${id}_${suffix}`;
|
(item) => item[key] !== null && item[key] !== undefined,
|
||||||
const hasData = dataset.some(
|
);
|
||||||
(item) => item[key] !== null && item[key] !== undefined
|
if (!hasSeriesData) return [];
|
||||||
);
|
const isObserved = metadata.source.startsWith("scada_");
|
||||||
if (hasData) {
|
return [
|
||||||
const displayName =
|
{
|
||||||
suffix === "clean"
|
name: `${historySeriesLabel(metadata)} [${metadata.display_unit}]`,
|
||||||
? "清洗值"
|
type: "line",
|
||||||
: suffix === "raw"
|
yAxisIndex:
|
||||||
? "监测值"
|
metadata.metric === "pressure" && hasFlowSeries ? 1 : 0,
|
||||||
: suffix === "sim"
|
symbol:
|
||||||
? "实时模拟"
|
metadata.source === "scada_cleaned"
|
||||||
: "方案模拟";
|
? "circle"
|
||||||
|
: metadata.source === "scada_raw"
|
||||||
series.push({
|
|
||||||
name: `${id} (${displayName})`,
|
|
||||||
type: "line",
|
|
||||||
symbol:
|
|
||||||
suffix === "clean"
|
|
||||||
? "circle"
|
|
||||||
: suffix === "raw"
|
|
||||||
? "diamond"
|
? "diamond"
|
||||||
: "none",
|
: "none",
|
||||||
symbolSize: suffix === "clean" || suffix === "raw" ? 7 : 0,
|
symbolSize: isObserved ? 7 : 0,
|
||||||
showSymbol: suffix === "clean" || suffix === "raw",
|
showSymbol: isObserved,
|
||||||
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",
|
|
||||||
sampling: "lttb",
|
sampling: "lttb",
|
||||||
connectNulls: true,
|
connectNulls: !isObserved,
|
||||||
itemStyle: { color: colors[index % colors.length] },
|
itemStyle: {
|
||||||
data: dataset.map((item) => item[id]),
|
color: colors[index % colors.length],
|
||||||
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,
|
|
||||||
},
|
},
|
||||||
});
|
data: dataset.map((item) => item[key]),
|
||||||
}
|
lineStyle: isObserved ? { width: 0 } : undefined,
|
||||||
return series;
|
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 = {
|
const option = {
|
||||||
@@ -853,10 +510,26 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
boundaryGap: false,
|
boundaryGap: false,
|
||||||
data: xData,
|
data: xData,
|
||||||
},
|
},
|
||||||
yAxis: {
|
yAxis: [
|
||||||
type: "value",
|
...(hasFlowSeries
|
||||||
scale: true,
|
? [
|
||||||
},
|
{
|
||||||
|
type: "value",
|
||||||
|
scale: true,
|
||||||
|
name: `流量 (${FLOW_DISPLAY_UNIT})`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(hasPressureSeries
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
type: "value",
|
||||||
|
scale: true,
|
||||||
|
name: `压力 (${PRESSURE_DISPLAY_UNIT})`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
dataZoom: [
|
dataZoom: [
|
||||||
{
|
{
|
||||||
type: "inside",
|
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,
|
onSave: handleValveSettingSave,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
|
data?.resultUnits,
|
||||||
),
|
),
|
||||||
[
|
[
|
||||||
selectedFeature,
|
selectedFeature,
|
||||||
@@ -848,6 +849,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
isValveStatusLoading,
|
isValveStatusLoading,
|
||||||
isValveStatusSaving,
|
isValveStatusSaving,
|
||||||
isValvePropertiesLoading,
|
isValvePropertiesLoading,
|
||||||
|
data?.resultUnits,
|
||||||
isValveSettingSaving,
|
isValveSettingSaving,
|
||||||
handleValveStatusSave,
|
handleValveStatusSave,
|
||||||
handleValveSettingSave,
|
handleValveSettingSave,
|
||||||
|
|||||||
@@ -173,6 +173,52 @@ describe("getSimulationElementType", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("buildFeatureProperties simulation values by hydraulic type", () => {
|
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", () => {
|
it("shows link simulation results for a point-rendered pump", () => {
|
||||||
const pump = createFeature("pumps", "P1", {
|
const pump = createFeature("pumps", "P1", {
|
||||||
node1: "J1",
|
node1: "J1",
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import Feature from "ol/Feature";
|
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 {
|
import {
|
||||||
getValveSettingHelperText,
|
getValveSettingHelperText,
|
||||||
VALVE_STATUS_OPTIONS,
|
VALVE_STATUS_OPTIONS,
|
||||||
@@ -163,11 +170,70 @@ export const inferHistoryFeatureInfos = (
|
|||||||
})
|
})
|
||||||
.filter(Boolean) as [string, string][];
|
.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 = (
|
export const buildFeatureProperties = (
|
||||||
highlightFeature: Feature | undefined,
|
highlightFeature: Feature | undefined,
|
||||||
computedProperties: Record<string, any>,
|
computedProperties: Record<string, any>,
|
||||||
valveStatus?: ValveStatusPropertyOptions,
|
valveStatus?: ValveStatusPropertyOptions,
|
||||||
valveSetting?: ValveSettingPropertyOptions,
|
valveSetting?: ValveSettingPropertyOptions,
|
||||||
|
resultUnits: NetworkResultUnits = DEFAULT_NETWORK_RESULT_UNITS,
|
||||||
): ToolbarPropertyPanelData => {
|
): ToolbarPropertyPanelData => {
|
||||||
if (!highlightFeature) return {};
|
if (!highlightFeature) return {};
|
||||||
|
|
||||||
@@ -182,12 +248,12 @@ export const buildFeatureProperties = (
|
|||||||
{ key: "reaction", label: "反应", unit: "1/d" },
|
{ key: "reaction", label: "反应", unit: "1/d" },
|
||||||
{ key: "setting", label: "设置", unit: "" },
|
{ key: "setting", label: "设置", unit: "" },
|
||||||
{ key: "status", label: "状态", unit: "" },
|
{ key: "status", label: "状态", unit: "" },
|
||||||
{ key: "velocity", label: "流速", unit: "m/s" },
|
{ key: "velocity", label: "流速", unit: VELOCITY_DISPLAY_UNIT },
|
||||||
];
|
];
|
||||||
const nodeComputedFields = [
|
const nodeComputedFields = [
|
||||||
{ key: "actual_demand", label: "实际需水量", unit: `${FLOW_DISPLAY_UNIT}` },
|
{ key: "actual_demand", label: "实际需水量", unit: `${FLOW_DISPLAY_UNIT}` },
|
||||||
{ key: "total_head", label: "水头", unit: "m" },
|
{ key: "total_head", label: "水头", unit: "m" },
|
||||||
{ key: "pressure", label: "压力", unit: "m" },
|
{ key: "pressure", label: "压力", unit: PRESSURE_DISPLAY_UNIT },
|
||||||
{ key: "quality", label: "水质", unit: "mg/L" },
|
{ key: "quality", label: "水质", unit: "mg/L" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -200,7 +266,10 @@ export const buildFeatureProperties = (
|
|||||||
|
|
||||||
let value = computedProperties[key];
|
let value = computedProperties[key];
|
||||||
if (key === "flow" && value !== undefined) {
|
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 (
|
if (
|
||||||
key === "unit_headloss" &&
|
key === "unit_headloss" &&
|
||||||
@@ -226,7 +295,10 @@ export const buildFeatureProperties = (
|
|||||||
|
|
||||||
let value = computedProperties[key];
|
let value = computedProperties[key];
|
||||||
if (key === "actual_demand") {
|
if (key === "actual_demand") {
|
||||||
value = toM3h(value, "lps");
|
value = toModelDisplayValue(value, key, resultUnits);
|
||||||
|
}
|
||||||
|
if (key === "pressure") {
|
||||||
|
value = toModelDisplayValue(value, key, resultUnits);
|
||||||
}
|
}
|
||||||
result.properties?.push({
|
result.properties?.push({
|
||||||
label,
|
label,
|
||||||
@@ -273,14 +345,15 @@ export const buildFeatureProperties = (
|
|||||||
{
|
{
|
||||||
label: "基本需水量",
|
label: "基本需水量",
|
||||||
value: Number.isFinite(Number(properties.base_demand))
|
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,
|
: properties.base_demand,
|
||||||
unit: "m³/h",
|
unit: FLOW_DISPLAY_UNIT,
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "需水配置",
|
|
||||||
value: properties.demands,
|
|
||||||
},
|
},
|
||||||
|
buildDemandProperty(properties.demands, resultUnits),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ import type { FlatStyleLike } from "ol/style/flat";
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import { config } from "@/config/config";
|
import { config } from "@/config/config";
|
||||||
import { isLpsFlowProperty, toM3h } from "@utils/units";
|
import {
|
||||||
|
type NetworkResultUnits,
|
||||||
|
toModelDisplayValue,
|
||||||
|
} from "@utils/units";
|
||||||
|
|
||||||
import { LayerStyleController } from "../layerStyleController";
|
import { LayerStyleController } from "../layerStyleController";
|
||||||
import { useData, useMap } from "../MapComponent";
|
import { useData, useMap } from "../MapComponent";
|
||||||
@@ -56,11 +59,15 @@ const configsEqual = (left?: StyleConfig, right?: StyleConfig) =>
|
|||||||
const hasSameTemplate = (left: StyleConfig, right: StyleConfig) =>
|
const hasSameTemplate = (left: StyleConfig, right: StyleConfig) =>
|
||||||
left.property === right.property && left.segments === right.segments;
|
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);
|
const numericValue = Number(value);
|
||||||
if (!Number.isFinite(numericValue)) return Number.NaN;
|
if (!Number.isFinite(numericValue)) return Number.NaN;
|
||||||
const displayValue = isLpsFlowProperty(property)
|
const displayValue = resultUnits
|
||||||
? toM3h(numericValue, "lps")
|
? toModelDisplayValue(numericValue, property, resultUnits)
|
||||||
: numericValue;
|
: numericValue;
|
||||||
return property === "flow" ? Math.abs(displayValue) : displayValue;
|
return property === "flow" ? Math.abs(displayValue) : displayValue;
|
||||||
};
|
};
|
||||||
@@ -140,6 +147,7 @@ export const useStyleEditor = ({
|
|||||||
const elevationRange = data?.elevationRange;
|
const elevationRange = data?.elevationRange;
|
||||||
const diameterRange = data?.diameterRange;
|
const diameterRange = data?.diameterRange;
|
||||||
const forceStyleAutoApplyVersion = data?.forceStyleAutoApplyVersion ?? 0;
|
const forceStyleAutoApplyVersion = data?.forceStyleAutoApplyVersion ?? 0;
|
||||||
|
const resultUnits = data?.resultUnits;
|
||||||
const setJunctionText = data?.setJunctionText;
|
const setJunctionText = data?.setJunctionText;
|
||||||
const setPipeText = data?.setPipeText;
|
const setPipeText = data?.setPipeText;
|
||||||
const setShowJunctionTextLayer = data?.setShowJunctionTextLayer;
|
const setShowJunctionTextLayer = data?.setShowJunctionTextLayer;
|
||||||
@@ -311,10 +319,18 @@ export const useStyleEditor = ({
|
|||||||
}
|
}
|
||||||
const records = layerId === "junctions" ? currentJunctionCalData : currentPipeCalData;
|
const records = layerId === "junctions" ? currentJunctionCalData : currentPipeCalData;
|
||||||
return (records || [])
|
return (records || [])
|
||||||
.map((item: any) => normalizeComputedStyleValue(property, item.value))
|
.map((item: any) =>
|
||||||
|
normalizeComputedStyleValue(property, item.value, resultUnits),
|
||||||
|
)
|
||||||
.filter(Number.isFinite);
|
.filter(Number.isFinite);
|
||||||
},
|
},
|
||||||
[currentJunctionCalData, currentPipeCalData, diameterRange, elevationRange],
|
[
|
||||||
|
currentJunctionCalData,
|
||||||
|
currentPipeCalData,
|
||||||
|
diameterRange,
|
||||||
|
elevationRange,
|
||||||
|
resultUnits,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const syncAuxiliaryLayers = useCallback(
|
const syncAuxiliaryLayers = useCallback(
|
||||||
@@ -425,7 +441,11 @@ export const useStyleEditor = ({
|
|||||||
records.forEach((record: any) => {
|
records.forEach((record: any) => {
|
||||||
const id = record.ID ?? record.id;
|
const id = record.ID ?? record.id;
|
||||||
if (id === undefined || id === null) return;
|
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);
|
if (Number.isFinite(value)) stateById.set(String(id), value);
|
||||||
});
|
});
|
||||||
const committed = await controller.applyRuntime(options, stateById);
|
const committed = await controller.applyRuntime(options, stateById);
|
||||||
@@ -465,6 +485,7 @@ export const useStyleEditor = ({
|
|||||||
getDataForMap,
|
getDataForMap,
|
||||||
getMapKey,
|
getMapKey,
|
||||||
getRenderLayersById,
|
getRenderLayersById,
|
||||||
|
resultUnits,
|
||||||
syncContoursForStyle,
|
syncContoursForStyle,
|
||||||
upsertLayerStyleState,
|
upsertLayerStyleState,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -23,7 +23,11 @@ import { TextLayer } from "@deck.gl/layers";
|
|||||||
import { TripsLayer } from "@deck.gl/geo-layers";
|
import { TripsLayer } from "@deck.gl/geo-layers";
|
||||||
import { CollisionFilterExtension } from "@deck.gl/extensions";
|
import { CollisionFilterExtension } from "@deck.gl/extensions";
|
||||||
import { ContourLayer } from "deck.gl";
|
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 { usePathname } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
cleanupTransientMapResources,
|
cleanupTransientMapResources,
|
||||||
@@ -125,6 +129,7 @@ interface DataContextType {
|
|||||||
elevationRange?: [number, number];
|
elevationRange?: [number, number];
|
||||||
forceStyleAutoApplyVersion?: number;
|
forceStyleAutoApplyVersion?: number;
|
||||||
setForceStyleAutoApplyVersion?: React.Dispatch<React.SetStateAction<number>>;
|
setForceStyleAutoApplyVersion?: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
resultUnits?: NetworkResultUnits;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 跨组件传递
|
// 跨组件传递
|
||||||
@@ -138,14 +143,13 @@ const mergeJunctionValues = (
|
|||||||
features: any[],
|
features: any[],
|
||||||
records: any[],
|
records: any[],
|
||||||
property: string,
|
property: string,
|
||||||
|
resultUnits: NetworkResultUnits,
|
||||||
) => {
|
) => {
|
||||||
const recordsById = indexCalculationRecords(records);
|
const recordsById = indexCalculationRecords(records);
|
||||||
return features.map((feature) => {
|
return features.map((feature) => {
|
||||||
const record = recordsById.get(String(feature.id));
|
const record = recordsById.get(String(feature.id));
|
||||||
if (!record) return feature;
|
if (!record) return feature;
|
||||||
const value = isLpsFlowProperty(property)
|
const value = toModelDisplayValue(record.value, property, resultUnits);
|
||||||
? toM3h(record.value, "lps")
|
|
||||||
: record.value;
|
|
||||||
return { ...feature, [property]: value };
|
return { ...feature, [property]: value };
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -154,13 +158,14 @@ const mergePipeValues = (
|
|||||||
features: any[],
|
features: any[],
|
||||||
records: any[],
|
records: any[],
|
||||||
property: string,
|
property: string,
|
||||||
|
resultUnits: NetworkResultUnits,
|
||||||
) => {
|
) => {
|
||||||
const recordsById = indexCalculationRecords(records);
|
const recordsById = indexCalculationRecords(records);
|
||||||
const isFlow = property === "flow";
|
const isFlow = property === "flow";
|
||||||
return features.map((feature) => {
|
return features.map((feature) => {
|
||||||
const record = recordsById.get(String(feature.id));
|
const record = recordsById.get(String(feature.id));
|
||||||
if (!record) return feature;
|
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;
|
const reverseFlow = isFlow && record.value < 0;
|
||||||
return {
|
return {
|
||||||
...feature,
|
...feature,
|
||||||
@@ -202,6 +207,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
const MAP_URL = config.MAP_URL;
|
const MAP_URL = config.MAP_URL;
|
||||||
const MAP_VIEW_STORAGE_KEY = `${MAP_WORKSPACE}_map_view`; // 持久化 key
|
const MAP_VIEW_STORAGE_KEY = `${MAP_WORKSPACE}_map_view`; // 持久化 key
|
||||||
const { durationMinutes, stepMinutes } = useTimelineTimeConfig();
|
const { durationMinutes, stepMinutes } = useTimelineTimeConfig();
|
||||||
|
const resultUnits = useNetworkResultUnits(project?.networkName);
|
||||||
|
|
||||||
const mapRef = useRef<HTMLDivElement | null>(null);
|
const mapRef = useRef<HTMLDivElement | null>(null);
|
||||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
@@ -288,37 +294,52 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
// 实时合并计算结果到基础地理数据中
|
// 实时合并计算结果到基础地理数据中
|
||||||
const mergedJunctionData = useMemo(
|
const mergedJunctionData = useMemo(
|
||||||
() =>
|
() =>
|
||||||
mergeJunctionValues(junctionData, currentJunctionCalData, junctionText),
|
mergeJunctionValues(
|
||||||
[junctionData, currentJunctionCalData, junctionText],
|
junctionData,
|
||||||
|
currentJunctionCalData,
|
||||||
|
junctionText,
|
||||||
|
resultUnits,
|
||||||
|
),
|
||||||
|
[junctionData, currentJunctionCalData, junctionText, resultUnits],
|
||||||
);
|
);
|
||||||
const mergedPipeData = useMemo(
|
const mergedPipeData = useMemo(
|
||||||
() => mergePipeValues(pipeData, currentPipeCalData, pipeText),
|
() => mergePipeValues(pipeData, currentPipeCalData, pipeText, resultUnits),
|
||||||
[pipeData, currentPipeCalData, pipeText],
|
[pipeData, currentPipeCalData, pipeText, resultUnits],
|
||||||
);
|
);
|
||||||
const mergedPipeFragments = useMemo(
|
const mergedPipeFragments = useMemo(
|
||||||
() => mergePipeValues(pipeFragments, currentPipeCalData, pipeText),
|
() => mergePipeValues(pipeFragments, currentPipeCalData, pipeText, resultUnits),
|
||||||
[pipeFragments, currentPipeCalData, pipeText],
|
[pipeFragments, currentPipeCalData, pipeText, resultUnits],
|
||||||
);
|
);
|
||||||
const mergedCompareJunctionData = useMemo(
|
const mergedCompareJunctionData = useMemo(
|
||||||
() =>
|
() =>
|
||||||
isCompareMode
|
isCompareMode
|
||||||
? mergeJunctionValues(junctionData, compareJunctionCalData, junctionText)
|
? mergeJunctionValues(
|
||||||
|
junctionData,
|
||||||
|
compareJunctionCalData,
|
||||||
|
junctionText,
|
||||||
|
resultUnits,
|
||||||
|
)
|
||||||
: [],
|
: [],
|
||||||
[isCompareMode, junctionData, compareJunctionCalData, junctionText],
|
[isCompareMode, junctionData, compareJunctionCalData, junctionText, resultUnits],
|
||||||
);
|
);
|
||||||
const mergedComparePipeData = useMemo(
|
const mergedComparePipeData = useMemo(
|
||||||
() =>
|
() =>
|
||||||
isCompareMode
|
isCompareMode
|
||||||
? mergePipeValues(pipeData, comparePipeCalData, pipeText)
|
? mergePipeValues(pipeData, comparePipeCalData, pipeText, resultUnits)
|
||||||
: [],
|
: [],
|
||||||
[isCompareMode, pipeData, comparePipeCalData, pipeText],
|
[isCompareMode, pipeData, comparePipeCalData, pipeText, resultUnits],
|
||||||
);
|
);
|
||||||
const mergedComparePipeFragments = useMemo(
|
const mergedComparePipeFragments = useMemo(
|
||||||
() =>
|
() =>
|
||||||
isCompareMode
|
isCompareMode
|
||||||
? mergePipeValues(pipeFragments, comparePipeCalData, pipeText)
|
? mergePipeValues(
|
||||||
|
pipeFragments,
|
||||||
|
comparePipeCalData,
|
||||||
|
pipeText,
|
||||||
|
resultUnits,
|
||||||
|
)
|
||||||
: [],
|
: [],
|
||||||
[isCompareMode, pipeFragments, comparePipeCalData, pipeText],
|
[isCompareMode, pipeFragments, comparePipeCalData, pipeText, resultUnits],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [diameterRange, setDiameterRange] = useState<
|
const [diameterRange, setDiameterRange] = useState<
|
||||||
@@ -1163,6 +1184,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
elevationRange,
|
elevationRange,
|
||||||
forceStyleAutoApplyVersion,
|
forceStyleAutoApplyVersion,
|
||||||
setForceStyleAutoApplyVersion,
|
setForceStyleAutoApplyVersion,
|
||||||
|
resultUnits,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MapContext.Provider value={map}>
|
<MapContext.Provider value={map}>
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ describe("buildSceneFrame", () => {
|
|||||||
});
|
});
|
||||||
expect(frame.payload?.links["P-1"]).toEqual({
|
expect(frame.payload?.links["P-1"]).toEqual({
|
||||||
velocity: 0.82,
|
velocity: 0.82,
|
||||||
flow: -18.4,
|
flow: -66.24,
|
||||||
direction: -1,
|
direction: -1,
|
||||||
status: "open",
|
status: "open",
|
||||||
});
|
});
|
||||||
@@ -196,7 +196,12 @@ describe("buildSceneFrame", () => {
|
|||||||
const devices = await fetchPressureDevices(new AbortController().signal);
|
const devices = await fetchPressureDevices(new AbortController().signal);
|
||||||
|
|
||||||
expect(devices).toEqual([
|
expect(devices).toEqual([
|
||||||
{ device_id: "S-1", device_type: "pressure", node_id: "J-1" },
|
{
|
||||||
|
device_id: "S-1",
|
||||||
|
device_type: "pressure",
|
||||||
|
node_id: "J-1",
|
||||||
|
measurement_unit: "m",
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
expect(mockApiFetch).toHaveBeenCalledWith(
|
expect(mockApiFetch).toHaveBeenCalledWith(
|
||||||
expect.stringContaining("/api/v1/scada-devices?limit=1000&offset=0"),
|
expect.stringContaining("/api/v1/scada-devices?limit=1000&offset=0"),
|
||||||
@@ -228,6 +233,10 @@ describe("buildSceneFrame", () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ FLOW_UNITS: "LPS", PRESSURE_UNITS: "METERS" }),
|
||||||
|
})
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
ok: false,
|
ok: false,
|
||||||
status: 503,
|
status: 503,
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { apiFetch } from "@/lib/apiFetch";
|
import { apiFetch } from "@/lib/apiFetch";
|
||||||
|
import { fetchNetworkResultUnits } from "@/hooks/useNetworkResultUnits";
|
||||||
import { config } from "@config/config";
|
import { config } from "@config/config";
|
||||||
|
import {
|
||||||
|
DEFAULT_NETWORK_RESULT_UNITS,
|
||||||
|
type NetworkResultUnits,
|
||||||
|
toDisplayValue,
|
||||||
|
} from "@/utils/units";
|
||||||
|
|
||||||
export const ZJB_PROJECT_CODE = "zjb";
|
export const ZJB_PROJECT_CODE = "zjb";
|
||||||
export const ZJB_SCENE_MODEL_ID = "zjb-water-network-v23";
|
export const ZJB_SCENE_MODEL_ID = "zjb-water-network-v23";
|
||||||
@@ -32,8 +38,8 @@ export type SceneResultsPayload = {
|
|||||||
modelId: string;
|
modelId: string;
|
||||||
units: {
|
units: {
|
||||||
velocity: "m/s";
|
velocity: "m/s";
|
||||||
pressure: "mH2O";
|
pressure: "m";
|
||||||
flow: "L/s";
|
flow: "m³/h";
|
||||||
};
|
};
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
nodes: Record<string, SceneNodeResult>;
|
nodes: Record<string, SceneNodeResult>;
|
||||||
@@ -61,6 +67,7 @@ export type PressureDevice = {
|
|||||||
device_id: string;
|
device_id: string;
|
||||||
device_type: string;
|
device_type: string;
|
||||||
node_id: string;
|
node_id: string;
|
||||||
|
measurement_unit?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type RawPressureDevice = Omit<PressureDevice, "node_id"> & {
|
type RawPressureDevice = Omit<PressureDevice, "node_id"> & {
|
||||||
@@ -192,6 +199,7 @@ export const buildSceneFrame = ({
|
|||||||
linkRows,
|
linkRows,
|
||||||
pressureDevices,
|
pressureDevices,
|
||||||
scadaRows,
|
scadaRows,
|
||||||
|
simulationUnits = DEFAULT_NETWORK_RESULT_UNITS,
|
||||||
}: {
|
}: {
|
||||||
queryTime: Date;
|
queryTime: Date;
|
||||||
model: SceneModelIndex;
|
model: SceneModelIndex;
|
||||||
@@ -199,6 +207,7 @@ export const buildSceneFrame = ({
|
|||||||
linkRows: RealtimeLinkRow[];
|
linkRows: RealtimeLinkRow[];
|
||||||
pressureDevices: PressureDevice[];
|
pressureDevices: PressureDevice[];
|
||||||
scadaRows: ScadaReadingRow[];
|
scadaRows: ScadaReadingRow[];
|
||||||
|
simulationUnits?: NetworkResultUnits;
|
||||||
}): SceneFrame => {
|
}): SceneFrame => {
|
||||||
const selectedTime = queryTime.toISOString();
|
const selectedTime = queryTime.toISOString();
|
||||||
const frameTime = resolveCommonFrameTime(queryTime, nodeRows, linkRows);
|
const frameTime = resolveCommonFrameTime(queryTime, nodeRows, linkRows);
|
||||||
@@ -223,7 +232,14 @@ export const buildSceneFrame = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isFiniteNumber(row.pressure)) {
|
if (isFiniteNumber(row.pressure)) {
|
||||||
nodes[id] = { pressure: row.pressure, source: "simulation" };
|
nodes[id] = {
|
||||||
|
pressure: toDisplayValue(
|
||||||
|
row.pressure,
|
||||||
|
"pressure",
|
||||||
|
simulationUnits.pressure,
|
||||||
|
)!,
|
||||||
|
source: "simulation",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -234,9 +250,15 @@ export const buildSceneFrame = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const result: SceneLinkResult = {};
|
const result: SceneLinkResult = {};
|
||||||
if (isFiniteNumber(row.velocity)) result.velocity = row.velocity;
|
if (isFiniteNumber(row.velocity)) {
|
||||||
|
result.velocity = toDisplayValue(
|
||||||
|
row.velocity,
|
||||||
|
"velocity",
|
||||||
|
simulationUnits.velocity,
|
||||||
|
)!;
|
||||||
|
}
|
||||||
if (isFiniteNumber(row.flow)) {
|
if (isFiniteNumber(row.flow)) {
|
||||||
result.flow = row.flow;
|
result.flow = toDisplayValue(row.flow, "flow", simulationUnits.flow)!;
|
||||||
result.direction = Math.sign(row.flow) as -1 | 0 | 1;
|
result.direction = Math.sign(row.flow) as -1 | 0 | 1;
|
||||||
}
|
}
|
||||||
const status = normalizeLinkStatus(row.status);
|
const status = normalizeLinkStatus(row.status);
|
||||||
@@ -260,7 +282,11 @@ export const buildSceneFrame = ({
|
|||||||
if (!isFiniteNumber(value)) return;
|
if (!isFiniteNumber(value)) return;
|
||||||
const simulationPressure = nodes[device.node_id]?.pressure;
|
const simulationPressure = nodes[device.node_id]?.pressure;
|
||||||
nodes[device.node_id] = {
|
nodes[device.node_id] = {
|
||||||
pressure: value,
|
pressure: toDisplayValue(
|
||||||
|
value,
|
||||||
|
"pressure",
|
||||||
|
device.measurement_unit || "m",
|
||||||
|
)!,
|
||||||
source: "scada",
|
source: "scada",
|
||||||
deviceId: device.device_id,
|
deviceId: device.device_id,
|
||||||
...(simulationPressure === undefined ? {} : { simulationPressure }),
|
...(simulationPressure === undefined ? {} : { simulationPressure }),
|
||||||
@@ -274,7 +300,7 @@ export const buildSceneFrame = ({
|
|||||||
resultTime,
|
resultTime,
|
||||||
payload: {
|
payload: {
|
||||||
modelId: model.modelId,
|
modelId: model.modelId,
|
||||||
units: { velocity: "m/s", pressure: "mH2O", flow: "L/s" },
|
units: { velocity: "m/s", pressure: "m", flow: "m³/h" },
|
||||||
timestamp: resultTime,
|
timestamp: resultTime,
|
||||||
nodes,
|
nodes,
|
||||||
links,
|
links,
|
||||||
@@ -301,18 +327,23 @@ const readJson = async <T>(url: string, signal: AbortSignal): Promise<T> => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const fetchPressureDevices = async (signal: AbortSignal) => {
|
export const fetchPressureDevices = async (signal: AbortSignal) => {
|
||||||
const page = await readJson<Page<RawPressureDevice>>(
|
const response = await readJson<Page<RawPressureDevice> | RawPressureDevice[]>(
|
||||||
`${config.BACKEND_URL}/api/v1/scada-devices?limit=1000&offset=0`,
|
`${config.BACKEND_URL}/api/v1/scada-devices?limit=1000&offset=0`,
|
||||||
signal,
|
signal,
|
||||||
);
|
);
|
||||||
return page.items
|
const items = Array.isArray(response) ? response : response.items;
|
||||||
|
return items
|
||||||
.filter(
|
.filter(
|
||||||
(device): device is PressureDevice =>
|
(device): device is PressureDevice =>
|
||||||
device.device_type?.trim().toLowerCase() === "pressure" &&
|
device.device_type?.trim().toLowerCase() === "pressure" &&
|
||||||
typeof device.node_id === "string" &&
|
typeof device.node_id === "string" &&
|
||||||
device.node_id.trim().length > 0,
|
device.node_id.trim().length > 0,
|
||||||
)
|
)
|
||||||
.map((device) => ({ ...device, node_id: device.node_id.trim() }));
|
.map((device) => ({
|
||||||
|
...device,
|
||||||
|
node_id: device.node_id.trim(),
|
||||||
|
measurement_unit: device.measurement_unit?.trim() || "m",
|
||||||
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchSceneFrame = async ({
|
export const fetchSceneFrame = async ({
|
||||||
@@ -337,7 +368,7 @@ export const fetchSceneFrame = async ({
|
|||||||
pressureDevices.map((device) => device.device_id).join(","),
|
pressureDevices.map((device) => device.device_id).join(","),
|
||||||
);
|
);
|
||||||
|
|
||||||
const [nodeRows, linkRows] = await Promise.all([
|
const [nodeRows, linkRows, networkOptions] = await Promise.all([
|
||||||
readJson<RealtimeNodeRow[]>(
|
readJson<RealtimeNodeRow[]>(
|
||||||
`${config.BACKEND_URL}/api/v1/timeseries/realtime/nodes?${range}`,
|
`${config.BACKEND_URL}/api/v1/timeseries/realtime/nodes?${range}`,
|
||||||
signal,
|
signal,
|
||||||
@@ -346,6 +377,7 @@ export const fetchSceneFrame = async ({
|
|||||||
`${config.BACKEND_URL}/api/v1/timeseries/realtime/links?${range}`,
|
`${config.BACKEND_URL}/api/v1/timeseries/realtime/links?${range}`,
|
||||||
signal,
|
signal,
|
||||||
),
|
),
|
||||||
|
fetchNetworkResultUnits(ZJB_PROJECT_CODE, signal),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let scadaRows: ScadaReadingRow[] = [];
|
let scadaRows: ScadaReadingRow[] = [];
|
||||||
@@ -370,6 +402,7 @@ export const fetchSceneFrame = async ({
|
|||||||
linkRows: Array.isArray(linkRows) ? linkRows : [],
|
linkRows: Array.isArray(linkRows) ? linkRows : [],
|
||||||
pressureDevices,
|
pressureDevices,
|
||||||
scadaRows,
|
scadaRows,
|
||||||
|
simulationUnits: networkOptions,
|
||||||
});
|
});
|
||||||
return { ...frame, warnings };
|
return { ...frame, warnings };
|
||||||
};
|
};
|
||||||
|
|||||||
+284
-1286
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,50 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import config from "@/config/config";
|
||||||
|
import { apiFetch } from "@/lib/apiFetch";
|
||||||
|
import {
|
||||||
|
DEFAULT_NETWORK_RESULT_UNITS,
|
||||||
|
type NetworkResultUnits,
|
||||||
|
networkResultUnitsFromOptions,
|
||||||
|
} from "@/utils/units";
|
||||||
|
|
||||||
|
export const fetchNetworkResultUnits = async (
|
||||||
|
networkName: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
) => {
|
||||||
|
const query = new URLSearchParams({ network: networkName });
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${config.BACKEND_URL}/api/v1/network-options?${query}`,
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`模型单位请求失败: HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
return networkResultUnitsFromOptions(
|
||||||
|
(await response.json()) as Record<string, string>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useNetworkResultUnits = (networkName?: string | null) => {
|
||||||
|
const [loaded, setLoaded] = useState<{
|
||||||
|
networkName: string;
|
||||||
|
units: NetworkResultUnits;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!networkName) return;
|
||||||
|
const controller = new AbortController();
|
||||||
|
void fetchNetworkResultUnits(networkName, controller.signal)
|
||||||
|
.then((units) => setLoaded({ networkName, units }))
|
||||||
|
.catch((error) => {
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
console.error("[units] 获取项目模型单位失败:", error);
|
||||||
|
setLoaded({ networkName, units: DEFAULT_NETWORK_RESULT_UNITS });
|
||||||
|
});
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [networkName]);
|
||||||
|
|
||||||
|
return loaded && loaded.networkName === networkName
|
||||||
|
? loaded.units
|
||||||
|
: DEFAULT_NETWORK_RESULT_UNITS;
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { toTimeSeriesPoints, type ElementHistorySeries } from "./elementHistory";
|
||||||
|
|
||||||
|
describe("element history display conversion", () => {
|
||||||
|
it("converts every series through the shared unit utility", () => {
|
||||||
|
const series: ElementHistorySeries[] = [
|
||||||
|
{
|
||||||
|
element_id: "P-1",
|
||||||
|
element_type: "pipe",
|
||||||
|
device_id: null,
|
||||||
|
metric: "flow",
|
||||||
|
source: "realtime_simulation",
|
||||||
|
source_unit: "MLD",
|
||||||
|
display_unit: "m³/h",
|
||||||
|
unit_inferred: false,
|
||||||
|
points: [{ time: "2026-09-01T00:00:00Z", value: 2 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element_id: "J-1",
|
||||||
|
element_type: "junction",
|
||||||
|
device_id: "pressure-1",
|
||||||
|
metric: "pressure",
|
||||||
|
source: "scada_cleaned",
|
||||||
|
source_unit: "KPA",
|
||||||
|
display_unit: "m",
|
||||||
|
unit_inferred: false,
|
||||||
|
points: [{ time: "2026-09-01T00:00:00Z", value: 10 }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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].values["pressure-1_clean"]).toBeCloseTo(1.019716213);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import config from "@/config/config";
|
||||||
|
import { apiFetch } from "@/lib/apiFetch";
|
||||||
|
import { toDisplayValue } from "@/utils/units";
|
||||||
|
|
||||||
|
export type HistoryElementType = "pipe" | "junction";
|
||||||
|
export type HistoryMode =
|
||||||
|
| "observed"
|
||||||
|
| "realtime_comparison"
|
||||||
|
| "analysis_comparison";
|
||||||
|
export type HistoryMetric = "flow" | "pressure";
|
||||||
|
export type HistorySource =
|
||||||
|
| "scada_raw"
|
||||||
|
| "scada_cleaned"
|
||||||
|
| "realtime_simulation"
|
||||||
|
| "analysis_simulation";
|
||||||
|
|
||||||
|
export interface ElementHistoryTarget {
|
||||||
|
element_id: string;
|
||||||
|
element_type: HistoryElementType;
|
||||||
|
device_ids?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ElementHistorySeries {
|
||||||
|
element_id: string;
|
||||||
|
element_type: HistoryElementType;
|
||||||
|
device_id: string | null;
|
||||||
|
metric: HistoryMetric;
|
||||||
|
source: HistorySource;
|
||||||
|
source_unit: string;
|
||||||
|
display_unit: string;
|
||||||
|
unit_inferred: boolean;
|
||||||
|
points: Array<{ time: string; value: number | null }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimeSeriesPoint {
|
||||||
|
timestamp: string;
|
||||||
|
values: Record<string, number | null | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ElementHistoryResult {
|
||||||
|
points: TimeSeriesPoint[];
|
||||||
|
series: ElementHistorySeries[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const SOURCE_SUFFIX: Record<HistorySource, string> = {
|
||||||
|
scada_raw: "raw",
|
||||||
|
scada_cleaned: "clean",
|
||||||
|
realtime_simulation: "sim",
|
||||||
|
analysis_simulation: "scheme_sim",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const historySeriesKey = (series: ElementHistorySeries) =>
|
||||||
|
`${series.device_id ?? series.element_id}_${SOURCE_SUFFIX[series.source]}`;
|
||||||
|
|
||||||
|
export const historySourceLabel = (source: HistorySource) => {
|
||||||
|
switch (source) {
|
||||||
|
case "scada_raw":
|
||||||
|
return "原始监测";
|
||||||
|
case "scada_cleaned":
|
||||||
|
return "清洗监测";
|
||||||
|
case "realtime_simulation":
|
||||||
|
return "实时模拟";
|
||||||
|
case "analysis_simulation":
|
||||||
|
return "方案模拟";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const historySeriesLabel = (series: ElementHistorySeries) =>
|
||||||
|
`${series.device_id ?? series.element_id} (${historySourceLabel(series.source)})`;
|
||||||
|
|
||||||
|
export const toTimeSeriesPoints = (
|
||||||
|
seriesList: ElementHistorySeries[],
|
||||||
|
): TimeSeriesPoint[] => {
|
||||||
|
const timeMap = new Map<string, Record<string, number | null>>();
|
||||||
|
seriesList.forEach((series) => {
|
||||||
|
const key = historySeriesKey(series);
|
||||||
|
series.points.forEach((point) => {
|
||||||
|
const values = timeMap.get(point.time) ?? {};
|
||||||
|
values[key] = toDisplayValue(
|
||||||
|
point.value,
|
||||||
|
series.metric,
|
||||||
|
series.source_unit,
|
||||||
|
);
|
||||||
|
timeMap.set(point.time, values);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return Array.from(timeMap, ([timestamp, values]) => ({ timestamp, values })).sort(
|
||||||
|
(left, right) =>
|
||||||
|
new Date(left.timestamp).getTime() - new Date(right.timestamp).getTime(),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchElementHistory = async (
|
||||||
|
elements: ElementHistoryTarget[],
|
||||||
|
range: { from: Date; to: Date },
|
||||||
|
mode: HistoryMode,
|
||||||
|
runId?: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<ElementHistoryResult> => {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${config.BACKEND_URL}/api/v1/timeseries/views/element-history/query`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
start_time: range.from.toISOString(),
|
||||||
|
end_time: range.to.toISOString(),
|
||||||
|
mode,
|
||||||
|
...(runId ? { run_id: runId } : {}),
|
||||||
|
elements,
|
||||||
|
}),
|
||||||
|
signal,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
const problem = await response.json().catch(() => null);
|
||||||
|
const detail =
|
||||||
|
typeof problem?.detail === "string"
|
||||||
|
? problem.detail
|
||||||
|
: `HTTP ${response.status}`;
|
||||||
|
throw new Error(`历史数据请求失败: ${detail}`);
|
||||||
|
}
|
||||||
|
const payload = (await response.json()) as { series?: ElementHistorySeries[] };
|
||||||
|
const series = Array.isArray(payload.series) ? payload.series : [];
|
||||||
|
return { series, points: toTimeSeriesPoints(series) };
|
||||||
|
};
|
||||||
+41
-6
@@ -1,4 +1,11 @@
|
|||||||
import { FLOW_DISPLAY_UNIT, isLpsFlowProperty, toM3h } from "./units";
|
import {
|
||||||
|
FLOW_DISPLAY_UNIT,
|
||||||
|
metricForResultProperty,
|
||||||
|
networkResultUnitsFromOptions,
|
||||||
|
toDisplayValue,
|
||||||
|
toM3h,
|
||||||
|
toModelDisplayValue,
|
||||||
|
} from "./units";
|
||||||
|
|
||||||
describe("flow display units", () => {
|
describe("flow display units", () => {
|
||||||
it("uses cubic meters per hour as the flow display unit", () => {
|
it("uses cubic meters per hour as the flow display unit", () => {
|
||||||
@@ -10,10 +17,38 @@ describe("flow display units", () => {
|
|||||||
expect(toM3h(10, "L/s")).toBe(36);
|
expect(toM3h(10, "L/s")).toBe(36);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("recognizes computed properties that arrive from the backend in L/s", () => {
|
it("centralizes model and SCADA conversion for all UI consumers", () => {
|
||||||
expect(isLpsFlowProperty("flow")).toBe(true);
|
expect(toDisplayValue(2, "flow", "MLD")).toBeCloseTo(83.3333333334);
|
||||||
expect(isLpsFlowProperty("actual_demand")).toBe(true);
|
expect(toDisplayValue(10, "pressure", "KPA")).toBeCloseTo(1.019716213);
|
||||||
expect(isLpsFlowProperty("actualdemand")).toBe(true);
|
expect(toDisplayValue(2, "velocity", "ft/s")).toBeCloseTo(0.6096);
|
||||||
expect(isLpsFlowProperty("pressure")).toBe(false);
|
expect(toDisplayValue(36, "flow", "m3/h")).toBe(36);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps result properties to their physical metrics", () => {
|
||||||
|
expect(metricForResultProperty("flow")).toBe("flow");
|
||||||
|
expect(metricForResultProperty("base_demand")).toBe("flow");
|
||||||
|
expect(metricForResultProperty("actual_demand")).toBe("flow");
|
||||||
|
expect(metricForResultProperty("pressure")).toBe("pressure");
|
||||||
|
expect(metricForResultProperty("velocity")).toBe("velocity");
|
||||||
|
expect(metricForResultProperty("headloss")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves legacy and v3 model options in one place", () => {
|
||||||
|
expect(
|
||||||
|
networkResultUnitsFromOptions({
|
||||||
|
FLOW_UNITS: "MLD",
|
||||||
|
PRESSURE_UNITS: "METERS",
|
||||||
|
}),
|
||||||
|
).toEqual({ flow: "MLD", pressure: "METERS", velocity: "m/s" });
|
||||||
|
expect(networkResultUnitsFromOptions({ UNITS: "GPM", PRESSURE: "PSI" }))
|
||||||
|
.toEqual({ flow: "GPM", pressure: "PSI", velocity: "ft/s" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts model properties using project result units", () => {
|
||||||
|
const units = { flow: "MLD", pressure: "KPA", velocity: "ft/s" };
|
||||||
|
expect(toModelDisplayValue(2, "flow", units)).toBeCloseTo(83.3333333334);
|
||||||
|
expect(toModelDisplayValue(10, "pressure", units)).toBeCloseTo(1.019716213);
|
||||||
|
expect(toModelDisplayValue(2, "velocity", units)).toBeCloseTo(0.6096);
|
||||||
|
expect(toModelDisplayValue(3, "headloss", units)).toBe(3);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+124
-9
@@ -1,17 +1,132 @@
|
|||||||
export const FLOW_DISPLAY_UNIT = "m³/h";
|
export const FLOW_DISPLAY_UNIT = "m³/h";
|
||||||
const M3H_FACTOR = 3600;
|
export const PRESSURE_DISPLAY_UNIT = "m";
|
||||||
const LPS_FLOW_PROPERTIES = new Set(["flow", "actual_demand", "actualdemand"]);
|
export const VELOCITY_DISPLAY_UNIT = "m/s";
|
||||||
|
export type MeasurementMetric = "flow" | "pressure" | "velocity";
|
||||||
|
export interface NetworkResultUnits {
|
||||||
|
flow: string;
|
||||||
|
pressure: string;
|
||||||
|
velocity: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const isLpsFlowProperty = (property: string) =>
|
export const DEFAULT_NETWORK_RESULT_UNITS: NetworkResultUnits = {
|
||||||
LPS_FLOW_PROPERTIES.has(property);
|
flow: "LPS",
|
||||||
|
pressure: "MTR",
|
||||||
|
velocity: "m/s",
|
||||||
|
};
|
||||||
|
|
||||||
|
const M3H_FACTOR = 3600;
|
||||||
|
const FLOW_PROPERTIES = new Set([
|
||||||
|
"flow",
|
||||||
|
"demand",
|
||||||
|
"base_demand",
|
||||||
|
"actual_demand",
|
||||||
|
"actualdemand",
|
||||||
|
]);
|
||||||
|
const PRESSURE_PROPERTIES = new Set(["pressure"]);
|
||||||
|
const VELOCITY_PROPERTIES = new Set(["velocity"]);
|
||||||
|
const IMPERIAL_FLOW_UNITS = new Set(["CFS", "GPM", "MGD", "IMGD", "AFD"]);
|
||||||
|
|
||||||
|
export const metricForResultProperty = (
|
||||||
|
property: string,
|
||||||
|
): MeasurementMetric | null => {
|
||||||
|
const normalizedProperty = property.trim().toLowerCase();
|
||||||
|
if (FLOW_PROPERTIES.has(normalizedProperty)) return "flow";
|
||||||
|
if (PRESSURE_PROPERTIES.has(normalizedProperty)) return "pressure";
|
||||||
|
if (VELOCITY_PROPERTIES.has(normalizedProperty)) return "velocity";
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const networkResultUnitsFromOptions = (
|
||||||
|
options: Record<string, string>,
|
||||||
|
): NetworkResultUnits => {
|
||||||
|
const flow = options.FLOW_UNITS || options.UNITS || "LPS";
|
||||||
|
const pressure = options.PRESSURE_UNITS || options.PRESSURE || "MTR";
|
||||||
|
return {
|
||||||
|
flow,
|
||||||
|
pressure,
|
||||||
|
velocity: IMPERIAL_FLOW_UNITS.has(flow.trim().toUpperCase())
|
||||||
|
? "ft/s"
|
||||||
|
: "m/s",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const toM3h = (value: number, sourceUnit: string = "m³/s") => {
|
export const toM3h = (value: number, sourceUnit: string = "m³/s") => {
|
||||||
if (!Number.isFinite(value)) return Number.NaN;
|
if (!Number.isFinite(value)) return Number.NaN;
|
||||||
const normalizedUnit = sourceUnit.trim().toLowerCase();
|
const normalizedUnit = sourceUnit.trim().toUpperCase().replace("³", "3");
|
||||||
if (normalizedUnit === "m³/h") return value;
|
const factors: Record<string, number> = {
|
||||||
if (normalizedUnit === "lps" || normalizedUnit === "l/s") return value * 3.6;
|
CFS: 101.9406477312,
|
||||||
if (normalizedUnit === "m³/s") return value * M3H_FACTOR;
|
GPM: 0.22712470704,
|
||||||
return value * M3H_FACTOR;
|
MGD: 157.725491,
|
||||||
|
IMGD: 189.4204167,
|
||||||
|
AFD: 51.39507656,
|
||||||
|
LPS: 3.6,
|
||||||
|
"L/S": 3.6,
|
||||||
|
LPM: 0.06,
|
||||||
|
MLD: 41.6666666667,
|
||||||
|
CMH: 1,
|
||||||
|
"M3/H": 1,
|
||||||
|
CMD: 1 / 24,
|
||||||
|
"M3/D": 1 / 24,
|
||||||
|
"M3/S": M3H_FACTOR,
|
||||||
|
};
|
||||||
|
const factor = factors[normalizedUnit];
|
||||||
|
if (factor === undefined) throw new Error(`不支持的流量单位: ${sourceUnit}`);
|
||||||
|
return value * factor;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toMeters = (value: number, sourceUnit: string = "m") => {
|
||||||
|
if (!Number.isFinite(value)) return Number.NaN;
|
||||||
|
const normalizedUnit = sourceUnit.trim().toUpperCase();
|
||||||
|
const factors: Record<string, number> = {
|
||||||
|
METERS: 1,
|
||||||
|
METRES: 1,
|
||||||
|
MTR: 1,
|
||||||
|
M: 1,
|
||||||
|
MH2O: 1,
|
||||||
|
"M H2O": 1,
|
||||||
|
KPA: 0.1019716213,
|
||||||
|
PSI: 0.7032496149,
|
||||||
|
};
|
||||||
|
const factor = factors[normalizedUnit];
|
||||||
|
if (factor === undefined) throw new Error(`不支持的压力单位: ${sourceUnit}`);
|
||||||
|
return value * factor;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toMetersPerSecond = (
|
||||||
|
value: number,
|
||||||
|
sourceUnit: string = "m/s",
|
||||||
|
) => {
|
||||||
|
if (!Number.isFinite(value)) return Number.NaN;
|
||||||
|
const normalizedUnit = sourceUnit.trim().toUpperCase();
|
||||||
|
if (["M/S", "MPS"].includes(normalizedUnit)) return value;
|
||||||
|
if (["FT/S", "FPS"].includes(normalizedUnit)) return value * 0.3048;
|
||||||
|
throw new Error(`不支持的流速单位: ${sourceUnit}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toDisplayValue = (
|
||||||
|
value: number | null | undefined,
|
||||||
|
metric: MeasurementMetric,
|
||||||
|
sourceUnit: string,
|
||||||
|
) => {
|
||||||
|
if (value === null || value === undefined) return null;
|
||||||
|
switch (metric) {
|
||||||
|
case "flow":
|
||||||
|
return toM3h(value, sourceUnit);
|
||||||
|
case "pressure":
|
||||||
|
return toMeters(value, sourceUnit);
|
||||||
|
case "velocity":
|
||||||
|
return toMetersPerSecond(value, sourceUnit);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toModelDisplayValue = (
|
||||||
|
value: number,
|
||||||
|
property: string,
|
||||||
|
units: NetworkResultUnits,
|
||||||
|
) => {
|
||||||
|
const metric = metricForResultProperty(property);
|
||||||
|
if (!metric) return value;
|
||||||
|
return toDisplayValue(value, metric, units[metric])!;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const toM3s = (value: number, sourceUnit: string = "m³/h") => {
|
export const toM3s = (value: number, sourceUnit: string = "m³/h") => {
|
||||||
|
|||||||
Reference in New Issue
Block a user