The scene previously returned early when realtime rows were absent and selected map-tier assets for network mode, hiding SCADA markers and station interiors and floors. Use SCADA timestamps as valid frames, render node-aligned monitor markers, and pin the full network context in the manifest regression test. Keep the timeline close action inside its drag header.
440 lines
12 KiB
TypeScript
440 lines
12 KiB
TypeScript
import { apiFetch } from "@/lib/apiFetch";
|
|
import { fetchNetworkResultUnits } from "@/hooks/useNetworkResultUnits";
|
|
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_SCENE_MODEL_ID = "zjb-water-network-v23";
|
|
export const SCENE_FRAME_TOLERANCE_MS = 2_000;
|
|
|
|
export const supportsThreeDimensionalScene = (projectCode?: string | null) =>
|
|
projectCode?.trim().toLowerCase() === ZJB_PROJECT_CODE;
|
|
|
|
export type SceneModelIndex = {
|
|
modelId: string;
|
|
nodeIds: ReadonlySet<string>;
|
|
linkIds: ReadonlySet<string>;
|
|
};
|
|
|
|
export type SceneNodeResult = {
|
|
pressure: number;
|
|
source: "simulation" | "scada";
|
|
deviceId?: string;
|
|
simulationPressure?: number;
|
|
};
|
|
|
|
export type SceneLinkResult = {
|
|
velocity?: number;
|
|
flow?: number;
|
|
status?: "open" | "closed" | "active";
|
|
direction?: -1 | 0 | 1;
|
|
};
|
|
|
|
export type SceneResultsPayload = {
|
|
modelId: string;
|
|
units: {
|
|
velocity: "m/s";
|
|
pressure: "m";
|
|
flow: "m³/h";
|
|
};
|
|
timestamp: string;
|
|
nodes: Record<string, SceneNodeResult>;
|
|
links: Record<string, SceneLinkResult>;
|
|
};
|
|
|
|
export type SceneFrameStats = {
|
|
simulationNodes: number;
|
|
simulationLinks: number;
|
|
scadaOverrides: number;
|
|
missingNodes: number;
|
|
missingLinks: number;
|
|
ignoredElements: number;
|
|
};
|
|
|
|
export type SceneFrame = {
|
|
selectedTime: string;
|
|
resultTime: string | null;
|
|
payload: SceneResultsPayload | null;
|
|
stats: SceneFrameStats;
|
|
warnings: string[];
|
|
};
|
|
|
|
export type PressureDevice = {
|
|
device_id: string;
|
|
device_type: string;
|
|
node_id: string;
|
|
measurement_unit?: string;
|
|
};
|
|
|
|
type RawPressureDevice = Omit<PressureDevice, "node_id"> & {
|
|
node_id?: string | null;
|
|
};
|
|
|
|
type Page<T> = {
|
|
items: T[];
|
|
limit: number;
|
|
offset: number;
|
|
total: number;
|
|
};
|
|
|
|
type RealtimeNodeRow = {
|
|
time: string;
|
|
node_id: string;
|
|
pressure: number | null;
|
|
};
|
|
|
|
type RealtimeLinkRow = {
|
|
time: string;
|
|
link_id: string;
|
|
velocity: number | null;
|
|
flow: number | null;
|
|
status: number | null;
|
|
};
|
|
|
|
type ScadaReadingRow = {
|
|
time: string;
|
|
device_id: string;
|
|
monitored_value: number | null;
|
|
cleaned_value: number | null;
|
|
};
|
|
|
|
const emptyStats = (model: SceneModelIndex): SceneFrameStats => ({
|
|
simulationNodes: 0,
|
|
simulationLinks: 0,
|
|
scadaOverrides: 0,
|
|
missingNodes: model.nodeIds.size,
|
|
missingLinks: model.linkIds.size,
|
|
ignoredElements: 0,
|
|
});
|
|
|
|
const toTimestamp = (value: string) => {
|
|
const timestamp = Date.parse(value);
|
|
return Number.isFinite(timestamp) ? timestamp : null;
|
|
};
|
|
|
|
const isFiniteNumber = (value: unknown): value is number =>
|
|
typeof value === "number" && Number.isFinite(value);
|
|
|
|
const frameWindow = (queryTime: Date) => ({
|
|
startTime: new Date(queryTime.getTime() - SCENE_FRAME_TOLERANCE_MS),
|
|
endTime: new Date(queryTime.getTime() + SCENE_FRAME_TOLERANCE_MS),
|
|
});
|
|
|
|
const rowsAtTime = <T extends { time: string }>(rows: T[], timestamp: number) =>
|
|
rows.filter((row) => toTimestamp(row.time) === timestamp);
|
|
|
|
const resolveCommonFrameTime = (
|
|
queryTime: Date,
|
|
nodeRows: RealtimeNodeRow[],
|
|
linkRows: RealtimeLinkRow[],
|
|
) => {
|
|
const target = queryTime.getTime();
|
|
const nodeTimes = new Set(
|
|
nodeRows
|
|
.map((row) => toTimestamp(row.time))
|
|
.filter((time): time is number => time !== null),
|
|
);
|
|
const commonTimes = Array.from(
|
|
new Set(
|
|
linkRows
|
|
.map((row) => toTimestamp(row.time))
|
|
.filter(
|
|
(time): time is number =>
|
|
time !== null &&
|
|
nodeTimes.has(time) &&
|
|
Math.abs(time - target) <= SCENE_FRAME_TOLERANCE_MS,
|
|
),
|
|
),
|
|
);
|
|
|
|
if (commonTimes.length === 0) return null;
|
|
return commonTimes.sort(
|
|
(left, right) => Math.abs(left - target) - Math.abs(right - target),
|
|
)[0];
|
|
};
|
|
|
|
const resolveNearestRowTime = <T extends { time: string }>(
|
|
queryTime: Date,
|
|
rows: T[],
|
|
) => {
|
|
const target = queryTime.getTime();
|
|
const candidates = Array.from(
|
|
new Set(
|
|
rows
|
|
.map((row) => toTimestamp(row.time))
|
|
.filter(
|
|
(time): time is number =>
|
|
time !== null &&
|
|
Math.abs(time - target) <= SCENE_FRAME_TOLERANCE_MS,
|
|
),
|
|
),
|
|
);
|
|
if (candidates.length === 0) return null;
|
|
return candidates.sort(
|
|
(left, right) => Math.abs(left - target) - Math.abs(right - target),
|
|
)[0];
|
|
};
|
|
|
|
const normalizeLinkStatus = (
|
|
value: number | null,
|
|
): SceneLinkResult["status"] => {
|
|
if (!isFiniteNumber(value)) return undefined;
|
|
if (value <= 0) return "closed";
|
|
if (value === 1) return "open";
|
|
return "active";
|
|
};
|
|
|
|
const nearestScadaReadings = (
|
|
rows: ScadaReadingRow[],
|
|
resultTimestamp: number,
|
|
) => {
|
|
const byDevice = new Map<string, ScadaReadingRow>();
|
|
rows.forEach((row) => {
|
|
const timestamp = toTimestamp(row.time);
|
|
if (
|
|
timestamp === null ||
|
|
Math.abs(timestamp - resultTimestamp) > SCENE_FRAME_TOLERANCE_MS
|
|
) {
|
|
return;
|
|
}
|
|
const existing = byDevice.get(row.device_id);
|
|
const existingTimestamp = existing ? toTimestamp(existing.time) : null;
|
|
if (
|
|
existingTimestamp === null ||
|
|
Math.abs(timestamp - resultTimestamp) <
|
|
Math.abs(existingTimestamp - resultTimestamp)
|
|
) {
|
|
byDevice.set(row.device_id, row);
|
|
}
|
|
});
|
|
return byDevice;
|
|
};
|
|
|
|
export const buildSceneFrame = ({
|
|
queryTime,
|
|
model,
|
|
nodeRows,
|
|
linkRows,
|
|
pressureDevices,
|
|
scadaRows,
|
|
simulationUnits = DEFAULT_NETWORK_RESULT_UNITS,
|
|
}: {
|
|
queryTime: Date;
|
|
model: SceneModelIndex;
|
|
nodeRows: RealtimeNodeRow[];
|
|
linkRows: RealtimeLinkRow[];
|
|
pressureDevices: PressureDevice[];
|
|
scadaRows: ScadaReadingRow[];
|
|
simulationUnits?: NetworkResultUnits;
|
|
}): SceneFrame => {
|
|
const selectedTime = queryTime.toISOString();
|
|
const simulationFrameTime = resolveCommonFrameTime(
|
|
queryTime,
|
|
nodeRows,
|
|
linkRows,
|
|
);
|
|
const frameTime =
|
|
simulationFrameTime ?? resolveNearestRowTime(queryTime, scadaRows);
|
|
if (frameTime === null) {
|
|
return {
|
|
selectedTime,
|
|
resultTime: null,
|
|
payload: null,
|
|
stats: emptyStats(model),
|
|
warnings: [],
|
|
};
|
|
}
|
|
|
|
const nodes: Record<string, SceneNodeResult> = {};
|
|
const links: Record<string, SceneLinkResult> = {};
|
|
let ignoredElements = 0;
|
|
|
|
rowsAtTime(nodeRows, frameTime).forEach((row) => {
|
|
const id = String(row.node_id);
|
|
if (!model.nodeIds.has(id)) {
|
|
ignoredElements += 1;
|
|
return;
|
|
}
|
|
if (isFiniteNumber(row.pressure)) {
|
|
nodes[id] = {
|
|
pressure: toDisplayValue(
|
|
row.pressure,
|
|
"pressure",
|
|
simulationUnits.pressure,
|
|
)!,
|
|
source: "simulation",
|
|
};
|
|
}
|
|
});
|
|
|
|
rowsAtTime(linkRows, frameTime).forEach((row) => {
|
|
const id = String(row.link_id);
|
|
if (!model.linkIds.has(id)) {
|
|
ignoredElements += 1;
|
|
return;
|
|
}
|
|
const result: SceneLinkResult = {};
|
|
if (isFiniteNumber(row.velocity)) {
|
|
result.velocity = toDisplayValue(
|
|
row.velocity,
|
|
"velocity",
|
|
simulationUnits.velocity,
|
|
)!;
|
|
}
|
|
if (isFiniteNumber(row.flow)) {
|
|
result.flow = toDisplayValue(row.flow, "flow", simulationUnits.flow)!;
|
|
result.direction = Math.sign(row.flow) as -1 | 0 | 1;
|
|
}
|
|
const status = normalizeLinkStatus(row.status);
|
|
if (status) result.status = status;
|
|
links[id] = result;
|
|
});
|
|
|
|
const simulationNodeCount = Object.keys(nodes).length;
|
|
const readingsByDevice = nearestScadaReadings(scadaRows, frameTime);
|
|
let scadaOverrides = 0;
|
|
pressureDevices.forEach((device) => {
|
|
if (!model.nodeIds.has(device.node_id)) {
|
|
ignoredElements += 1;
|
|
return;
|
|
}
|
|
const reading = readingsByDevice.get(device.device_id);
|
|
if (!reading) return;
|
|
const value = isFiniteNumber(reading.cleaned_value)
|
|
? reading.cleaned_value
|
|
: reading.monitored_value;
|
|
if (!isFiniteNumber(value)) return;
|
|
const simulationPressure = nodes[device.node_id]?.pressure;
|
|
nodes[device.node_id] = {
|
|
pressure: toDisplayValue(
|
|
value,
|
|
"pressure",
|
|
device.measurement_unit || "m",
|
|
)!,
|
|
source: "scada",
|
|
deviceId: device.device_id,
|
|
...(simulationPressure === undefined ? {} : { simulationPressure }),
|
|
};
|
|
scadaOverrides += 1;
|
|
});
|
|
|
|
const resultTime = new Date(frameTime).toISOString();
|
|
return {
|
|
selectedTime,
|
|
resultTime,
|
|
payload: {
|
|
modelId: model.modelId,
|
|
units: { velocity: "m/s", pressure: "m", flow: "m³/h" },
|
|
timestamp: resultTime,
|
|
nodes,
|
|
links,
|
|
},
|
|
stats: {
|
|
simulationNodes: simulationNodeCount,
|
|
simulationLinks: Object.keys(links).length,
|
|
scadaOverrides,
|
|
missingNodes: Math.max(0, model.nodeIds.size - Object.keys(nodes).length),
|
|
missingLinks: Math.max(0, model.linkIds.size - Object.keys(links).length),
|
|
ignoredElements,
|
|
},
|
|
warnings:
|
|
simulationFrameTime === null
|
|
? ["当前时刻无在线模拟结果,已显示 SCADA 监测点。"]
|
|
: [],
|
|
};
|
|
};
|
|
|
|
const readJson = async <T>(url: string, signal: AbortSignal): Promise<T> => {
|
|
const response = await apiFetch(url, { signal });
|
|
if (!response.ok) {
|
|
const detail = await response.text().catch(() => "");
|
|
throw new Error(detail || `请求失败:HTTP ${response.status}`);
|
|
}
|
|
return (await response.json()) as T;
|
|
};
|
|
|
|
export const fetchPressureDevices = async (signal: AbortSignal) => {
|
|
const response = await readJson<Page<RawPressureDevice> | RawPressureDevice[]>(
|
|
`${config.BACKEND_URL}/api/v1/scada-devices?limit=1000&offset=0`,
|
|
signal,
|
|
);
|
|
const items = Array.isArray(response) ? response : response.items;
|
|
return items
|
|
.filter(
|
|
(device): device is PressureDevice =>
|
|
device.device_type?.trim().toLowerCase() === "pressure" &&
|
|
typeof device.node_id === "string" &&
|
|
device.node_id.trim().length > 0,
|
|
)
|
|
.map((device) => ({
|
|
...device,
|
|
node_id: device.node_id.trim(),
|
|
measurement_unit: device.measurement_unit?.trim() || "m",
|
|
}));
|
|
};
|
|
|
|
export const fetchSceneFrame = async ({
|
|
queryTime,
|
|
model,
|
|
pressureDevices,
|
|
signal,
|
|
}: {
|
|
queryTime: Date;
|
|
model: SceneModelIndex;
|
|
pressureDevices: PressureDevice[];
|
|
signal: AbortSignal;
|
|
}) => {
|
|
const { startTime, endTime } = frameWindow(queryTime);
|
|
const range = new URLSearchParams({
|
|
start_time: startTime.toISOString(),
|
|
end_time: endTime.toISOString(),
|
|
});
|
|
const scadaRange = new URLSearchParams(range);
|
|
scadaRange.set(
|
|
"device_ids",
|
|
pressureDevices.map((device) => device.device_id).join(","),
|
|
);
|
|
|
|
const [nodeRows, linkRows, networkOptions] = await Promise.all([
|
|
readJson<RealtimeNodeRow[]>(
|
|
`${config.BACKEND_URL}/api/v1/timeseries/realtime/nodes?${range}`,
|
|
signal,
|
|
),
|
|
readJson<RealtimeLinkRow[]>(
|
|
`${config.BACKEND_URL}/api/v1/timeseries/realtime/links?${range}`,
|
|
signal,
|
|
),
|
|
fetchNetworkResultUnits(ZJB_PROJECT_CODE, signal),
|
|
]);
|
|
|
|
let scadaRows: ScadaReadingRow[] = [];
|
|
const warnings: string[] = [];
|
|
if (pressureDevices.length > 0) {
|
|
try {
|
|
const rows = await readJson<ScadaReadingRow[]>(
|
|
`${config.BACKEND_URL}/api/v1/timeseries/scada-readings?${scadaRange}`,
|
|
signal,
|
|
);
|
|
scadaRows = Array.isArray(rows) ? rows : [];
|
|
} catch (error) {
|
|
if (signal.aborted) throw error;
|
|
warnings.push("SCADA 数据暂不可用,当前仅显示模拟结果。");
|
|
}
|
|
}
|
|
|
|
const frame = buildSceneFrame({
|
|
queryTime,
|
|
model,
|
|
nodeRows: Array.isArray(nodeRows) ? nodeRows : [],
|
|
linkRows: Array.isArray(linkRows) ? linkRows : [],
|
|
pressureDevices,
|
|
scadaRows,
|
|
simulationUnits: networkOptions,
|
|
});
|
|
return { ...frame, warnings: [...frame.warnings, ...warnings] };
|
|
};
|