Files
TJWaterFrontend_Refine/src/components/threeDimensional/sceneData.ts
T
jiang 1622eaa2da
Generic Container CI/CD / test-build-publish (push) Successful in 1m5s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m5s
fix(3d): restore complete SCADA scene context
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.
2026-09-14 16:57:41 +08:00

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] };
};