519 lines
17 KiB
TypeScript
519 lines
17 KiB
TypeScript
"use client";
|
|
|
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
import {
|
|
Box,
|
|
Typography,
|
|
Chip,
|
|
IconButton,
|
|
Tooltip,
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableRow,
|
|
Button,
|
|
Link,
|
|
} from "@mui/material";
|
|
import {
|
|
FormatListBulleted,
|
|
LocationOn as LocationOnIcon,
|
|
Map as MapIcon,
|
|
} from "@mui/icons-material";
|
|
import dayjs from "dayjs";
|
|
import { useMap } from "@components/olmap/core/MapComponent";
|
|
import { queryFeaturesByIds } from "@/utils/mapQueryService";
|
|
import { GeoJSON } from "ol/format";
|
|
import Feature from "ol/Feature";
|
|
import VectorLayer from "ol/layer/Vector";
|
|
import VectorSource from "ol/source/Vector";
|
|
import { Stroke, Style, Circle, Fill } from "ol/style";
|
|
import { bbox, featureCollection } from "@turf/turf";
|
|
import { BurstCandidate, BurstLocationResult } from "./types";
|
|
import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units";
|
|
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
|
|
|
|
interface Props {
|
|
result: BurstLocationResult | null;
|
|
}
|
|
|
|
interface MetricCardProps {
|
|
label: string;
|
|
value: string;
|
|
hint?: string;
|
|
tone: "blue" | "orange" | "purple" | "green";
|
|
}
|
|
|
|
const toneStyles: Record<
|
|
MetricCardProps["tone"],
|
|
{ bg: string; border: string; text: string; darkText: string }
|
|
> = {
|
|
blue: {
|
|
bg: "from-blue-50 to-blue-100",
|
|
border: "border-blue-200",
|
|
text: "text-blue-700",
|
|
darkText: "text-blue-900",
|
|
},
|
|
orange: {
|
|
bg: "from-orange-50 to-orange-100",
|
|
border: "border-orange-200",
|
|
text: "text-orange-700",
|
|
darkText: "text-orange-900",
|
|
},
|
|
purple: {
|
|
bg: "from-purple-50 to-purple-100",
|
|
border: "border-purple-200",
|
|
text: "text-purple-700",
|
|
darkText: "text-purple-900",
|
|
},
|
|
green: {
|
|
bg: "from-green-50 to-green-100",
|
|
border: "border-green-200",
|
|
text: "text-green-700",
|
|
darkText: "text-green-900",
|
|
},
|
|
};
|
|
|
|
const formatDateTime = (value?: string) =>
|
|
value ? dayjs(value).format("MM-DD HH:mm") : "-";
|
|
|
|
const formatDateTimeRange = (start?: string, end?: string) => {
|
|
if (!start || !end) return "-";
|
|
return `${formatDateTime(start)} 至 ${formatDateTime(end)}`;
|
|
};
|
|
|
|
const getDataSourceLabel = (result: BurstLocationResult) =>
|
|
result.data_source === "simulation" ? "模拟方案" : "监测数据";
|
|
|
|
const getNormalDataDescription = (result: BurstLocationResult) => {
|
|
switch (result.observed_source) {
|
|
case "simulation_scheme_burst_realtime_normal_timerange":
|
|
return "正常数据读取实时模拟结果,与爆管数据使用同一时间窗。";
|
|
case "scada_burst_scada_normal_timerange":
|
|
return "正常数据读取监测数据;未单独指定正常时间窗时,默认使用爆管时段前一天同一时段。";
|
|
case "scada_burst_payload_normal_timerange":
|
|
return "爆管数据读取监测数据,正常数据来自请求载荷。";
|
|
case "simulation_scheme_timerange":
|
|
return "历史方案记录:爆管数据和正常数据均读取方案模拟结果。";
|
|
case "scada_timerange":
|
|
return "历史方案记录:爆管数据和正常数据使用同一监测时间窗。";
|
|
case "request_payload":
|
|
return "爆管数据和正常数据均来自请求载荷。";
|
|
default:
|
|
return "正常数据按后端返回的数据源规则选择。";
|
|
}
|
|
};
|
|
|
|
const MetricCard = ({ label, value, hint, tone }: MetricCardProps) => {
|
|
const style = toneStyles[tone];
|
|
return (
|
|
<Box
|
|
className={`rounded-lg border bg-gradient-to-br p-3 shadow-sm ${style.bg} ${style.border}`}
|
|
>
|
|
<Typography
|
|
variant="caption"
|
|
className={`mb-1 block text-xs font-semibold uppercase tracking-wide ${style.text}`}
|
|
>
|
|
{label}
|
|
</Typography>
|
|
<Typography variant="body2" className={`font-bold ${style.darkText}`}>
|
|
{value}
|
|
</Typography>
|
|
{hint ? (
|
|
<Typography variant="caption" className={`mt-0.5 block text-xs opacity-80 ${style.text}`}>
|
|
{hint}
|
|
</Typography>
|
|
) : null}
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
const EmptyState = () => (
|
|
<PanelEmptyState
|
|
icon={<MapIcon />}
|
|
title="尚未生成定位结果"
|
|
description="请在“定位参数”中运行分析,或在“方案查询”中打开历史结果。"
|
|
/>
|
|
);
|
|
|
|
const LocationResults: React.FC<Props> = ({ result }) => {
|
|
const map = useMap();
|
|
const highlightLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
|
|
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
|
|
|
|
const candidatePipes = useMemo<BurstCandidate[]>(() => {
|
|
if (!result) return [];
|
|
const base = result.top_candidates ?? [];
|
|
const hasLocated = base.some((item) => item.pipe_id === result.located_pipe);
|
|
if (result.located_pipe && !hasLocated) {
|
|
return [{ pipe_id: result.located_pipe, similarity: 1 }, ...base];
|
|
}
|
|
return base;
|
|
}, [result]);
|
|
|
|
const allCandidatePipeIds = (() => {
|
|
const ids = candidatePipes.map((item) => item.pipe_id);
|
|
if (result?.located_pipe) {
|
|
ids.unshift(result.located_pipe);
|
|
}
|
|
return Array.from(new Set(ids.filter(Boolean)));
|
|
})();
|
|
|
|
useEffect(() => {
|
|
if (!map) return;
|
|
|
|
const layer = new VectorLayer({
|
|
source: new VectorSource(),
|
|
style: new Style({
|
|
stroke: new Stroke({
|
|
color: "#ef4444",
|
|
width: 6,
|
|
}),
|
|
image: new Circle({
|
|
radius: 8,
|
|
fill: new Fill({ color: "#ef4444" }),
|
|
stroke: new Stroke({ color: "#fff", width: 2 }),
|
|
}),
|
|
zIndex: 999,
|
|
}),
|
|
properties: {
|
|
name: "爆管定位高亮",
|
|
value: "burst_location_highlight",
|
|
queryable: false,
|
|
},
|
|
});
|
|
map.addLayer(layer);
|
|
highlightLayerRef.current = layer;
|
|
|
|
return () => {
|
|
highlightLayerRef.current = null;
|
|
map.removeLayer(layer);
|
|
};
|
|
}, [map]);
|
|
|
|
useEffect(() => {
|
|
const source = highlightLayerRef.current?.getSource();
|
|
if (!source) return;
|
|
source.clear();
|
|
highlightFeatures.forEach((feature) => source.addFeature(feature));
|
|
}, [highlightFeatures]);
|
|
|
|
const locatePipes = async (pipeIds: string[]) => {
|
|
if (!pipeIds.length || !map) return;
|
|
|
|
try {
|
|
const features = await queryFeaturesByIds(pipeIds, "pipes");
|
|
if (features.length === 0) return;
|
|
|
|
setHighlightFeatures(features);
|
|
|
|
const geojsonFormat = new GeoJSON();
|
|
const geojsonFeatures = features.map((feature) => geojsonFormat.writeFeatureObject(feature));
|
|
// @ts-ignore turf typing with ol geojson objects
|
|
const extent = bbox(featureCollection(geojsonFeatures));
|
|
map.getView().fit(extent, {
|
|
maxZoom: 19,
|
|
duration: 1000,
|
|
padding: [100, 100, 100, 100],
|
|
});
|
|
} catch (error) {
|
|
console.error("Locate failed", error);
|
|
}
|
|
};
|
|
|
|
if (!result) {
|
|
return <EmptyState />;
|
|
}
|
|
|
|
const burstSamples = result.pressure_samples?.burst ?? 0;
|
|
const normalSamples = result.pressure_samples?.normal ?? 0;
|
|
const elapsedText =
|
|
result.elapsed_seconds && result.elapsed_seconds > 0
|
|
? `${result.elapsed_seconds.toFixed(1)} s`
|
|
: "-";
|
|
const bestSimilarity = candidatePipes[0]?.similarity ?? 0;
|
|
const burstTime = result.scada_window?.burst_start
|
|
? formatDateTime(result.scada_window.burst_start)
|
|
: "-";
|
|
const burstWindow = formatDateTimeRange(
|
|
result.scada_window?.burst_start,
|
|
result.scada_window?.burst_end,
|
|
);
|
|
const normalWindow = formatDateTimeRange(
|
|
result.scada_window?.normal_start,
|
|
result.scada_window?.normal_end,
|
|
);
|
|
const sourceLabel = getDataSourceLabel(result);
|
|
const normalDataDescription = getNormalDataDescription(result);
|
|
const simulationBurstIds = result.simulation_scheme?.burst_ids ?? [];
|
|
|
|
return (
|
|
<Box className="h-full overflow-auto p-1">
|
|
{/* Header & Metrics */}
|
|
<Box className="mb-4 space-y-3">
|
|
<Box className="flex items-center justify-between px-1">
|
|
<Box className="flex items-center gap-2">
|
|
<Box className="h-4 w-1 rounded-full bg-blue-600" />
|
|
<Typography
|
|
variant="h6"
|
|
className="truncate font-bold text-gray-900"
|
|
sx={{ fontSize: "1.1rem" }}
|
|
title={result.scheme_name}
|
|
>
|
|
{result.scheme_name || "爆管定位结果"}
|
|
</Typography>
|
|
</Box>
|
|
<Box className="flex items-center gap-2">
|
|
{result.username ? (
|
|
<Chip
|
|
label={result.username}
|
|
size="small"
|
|
sx={{
|
|
height: 24,
|
|
backgroundColor: "#f3f4f6",
|
|
color: "#4b5563",
|
|
border: "none",
|
|
fontWeight: 500,
|
|
}}
|
|
/>
|
|
) : null}
|
|
<Button
|
|
size="small"
|
|
variant="outlined"
|
|
startIcon={<LocationOnIcon />}
|
|
onClick={() => locatePipes([result.located_pipe])}
|
|
disabled={!result.located_pipe}
|
|
sx={{
|
|
height: 24,
|
|
minWidth: 0,
|
|
padding: "0 8px",
|
|
borderColor: "#bfdbfe",
|
|
color: "#2563eb",
|
|
fontSize: "0.75rem",
|
|
"&:hover": { borderColor: "#60a5fa", backgroundColor: "#eff6ff" },
|
|
}}
|
|
>
|
|
定位
|
|
</Button>
|
|
</Box>
|
|
</Box>
|
|
|
|
<Box className="grid grid-cols-2 gap-3">
|
|
<MetricCard
|
|
label="定位管段"
|
|
value={result.located_pipe || "-"}
|
|
tone="blue"
|
|
/>
|
|
<MetricCard
|
|
label="估计漏损量"
|
|
value={`${toM3h(result.burst_leakage, "m³/s").toFixed(2)} ${FLOW_DISPLAY_UNIT}`}
|
|
tone="orange"
|
|
/>
|
|
<MetricCard
|
|
label="最佳相似度"
|
|
value={`${(bestSimilarity * 100).toFixed(1)}%`}
|
|
tone="purple"
|
|
/>
|
|
<MetricCard
|
|
label="爆管时间"
|
|
value={burstTime}
|
|
tone="green"
|
|
/>
|
|
</Box>
|
|
|
|
<Box className="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2">
|
|
<Box className="mb-1 flex flex-wrap items-center gap-1.5">
|
|
<Chip
|
|
size="small"
|
|
label={sourceLabel}
|
|
color={result.data_source === "simulation" ? "secondary" : "primary"}
|
|
sx={{ height: 22, fontSize: "0.72rem", fontWeight: 600 }}
|
|
/>
|
|
<Typography variant="caption" className="text-gray-600">
|
|
正常数据选择说明
|
|
</Typography>
|
|
</Box>
|
|
<Typography variant="caption" className="block leading-5 text-gray-700">
|
|
{normalDataDescription}
|
|
</Typography>
|
|
<Typography variant="caption" className="mt-1 block leading-5 text-gray-500">
|
|
爆管窗口: {burstWindow};正常窗口: {normalWindow}
|
|
</Typography>
|
|
{result.data_source === "simulation" && result.simulation_scheme?.name ? (
|
|
<Typography variant="caption" className="mt-1 block truncate text-purple-600">
|
|
爆管方案: {result.simulation_scheme.name}
|
|
</Typography>
|
|
) : null}
|
|
{simulationBurstIds.length > 0 ? (
|
|
<Box className="mt-1 flex flex-wrap items-center gap-1">
|
|
<Typography variant="caption" className="text-purple-600">
|
|
模拟管段:
|
|
</Typography>
|
|
{simulationBurstIds.map((pipeId) => (
|
|
<Link
|
|
key={pipeId}
|
|
component="button"
|
|
variant="caption"
|
|
onClick={() => locatePipes([pipeId])}
|
|
title={pipeId}
|
|
sx={{
|
|
maxWidth: 132,
|
|
color: "#7c3aed",
|
|
fontSize: "0.75rem",
|
|
fontWeight: 700,
|
|
lineHeight: "22px",
|
|
cursor: "pointer",
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
whiteSpace: "nowrap",
|
|
textDecoration: "underline",
|
|
textUnderlineOffset: "2px",
|
|
"&:hover": {
|
|
color: "#5b21b6",
|
|
},
|
|
}}
|
|
>
|
|
{pipeId}
|
|
</Link>
|
|
))}
|
|
<Button
|
|
size="small"
|
|
variant="text"
|
|
startIcon={<LocationOnIcon />}
|
|
onClick={() => locatePipes(simulationBurstIds)}
|
|
sx={{
|
|
minWidth: 0,
|
|
px: 0.5,
|
|
py: 0,
|
|
color: "#7c3aed",
|
|
fontSize: "0.72rem",
|
|
}}
|
|
>
|
|
定位全部
|
|
</Button>
|
|
</Box>
|
|
) : null}
|
|
</Box>
|
|
</Box>
|
|
|
|
{/* Candidate List */}
|
|
<Box className="overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm">
|
|
<Box className="flex items-center justify-between border-b border-gray-100 bg-white px-4 py-3">
|
|
<Box className="flex items-center gap-2">
|
|
<FormatListBulleted className="h-5 w-5 text-blue-600" />
|
|
<Typography variant="subtitle1" className="font-bold text-gray-800">
|
|
候选管段列表
|
|
</Typography>
|
|
</Box>
|
|
<Box className="flex items-center gap-1">
|
|
<Chip
|
|
size="small"
|
|
label={`${candidatePipes.length} 条`}
|
|
sx={{
|
|
height: 22,
|
|
backgroundColor: "rgba(37, 99, 235, 0.08)",
|
|
color: "#2563eb",
|
|
fontWeight: 600,
|
|
fontSize: "0.75rem",
|
|
border: "none",
|
|
}}
|
|
/>
|
|
<Tooltip title="定位所有管段">
|
|
<span>
|
|
<IconButton
|
|
size="small"
|
|
onClick={() => locatePipes(allCandidatePipeIds)}
|
|
disabled={allCandidatePipeIds.length === 0}
|
|
className="text-blue-600 hover:bg-blue-50 disabled:text-gray-300"
|
|
>
|
|
<LocationOnIcon fontSize="small" />
|
|
</IconButton>
|
|
</span>
|
|
</Tooltip>
|
|
</Box>
|
|
</Box>
|
|
<Table size="small">
|
|
<TableHead>
|
|
<TableRow sx={{ backgroundColor: "#f8fafc" }}>
|
|
<TableCell sx={{ fontWeight: 600, color: "#64748b", py: 1.5, pl: 3 }}>
|
|
排名
|
|
</TableCell>
|
|
<TableCell sx={{ fontWeight: 600, color: "#64748b", py: 1.5 }}>
|
|
管段 ID
|
|
</TableCell>
|
|
<TableCell align="right" sx={{ fontWeight: 600, color: "#64748b", py: 1.5 }}>
|
|
相似度
|
|
</TableCell>
|
|
<TableCell align="right" sx={{ fontWeight: 600, color: "#64748b", py: 1.5, pr: 3 }}>
|
|
操作
|
|
</TableCell>
|
|
</TableRow>
|
|
</TableHead>
|
|
<TableBody>
|
|
{candidatePipes.map((candidate, index) => {
|
|
const similarityPercent = candidate.similarity * 100;
|
|
const isTop = index === 0;
|
|
return (
|
|
<TableRow
|
|
key={candidate.pipe_id}
|
|
hover
|
|
sx={{
|
|
"&:last-child td, &:last-child th": { border: 0 },
|
|
backgroundColor: isTop ? "#eff6ff" : "inherit",
|
|
}}
|
|
className="transition-colors"
|
|
>
|
|
<TableCell sx={{ pl: 3, py: 1.2 }}>
|
|
<Box
|
|
className={`flex h-5 w-5 items-center justify-center rounded-full text-xs font-bold ${isTop ? "bg-blue-600 text-white" : "bg-gray-200 text-gray-600"
|
|
}`}
|
|
>
|
|
{index + 1}
|
|
</Box>
|
|
</TableCell>
|
|
<TableCell sx={{ py: 1.2 }}>
|
|
<Typography
|
|
variant="body2"
|
|
className={`font-medium ${isTop ? "text-blue-700" : "text-gray-700"}`}
|
|
>
|
|
{candidate.pipe_id}
|
|
</Typography>
|
|
</TableCell>
|
|
<TableCell align="right" sx={{ py: 1.2 }}>
|
|
<Box className="flex flex-col items-end gap-1">
|
|
<Typography
|
|
variant="body2"
|
|
className={`font-medium ${isTop ? "text-blue-700" : "text-gray-700"}`}
|
|
>
|
|
{similarityPercent.toFixed(2)}%
|
|
</Typography>
|
|
<Box className="h-1.5 w-24 overflow-hidden rounded-full bg-gray-100">
|
|
<Box
|
|
className={`h-full rounded-full ${isTop ? "bg-blue-500" : "bg-gray-400"}`}
|
|
style={{ width: `${similarityPercent}%` }}
|
|
/>
|
|
</Box>
|
|
</Box>
|
|
</TableCell>
|
|
<TableCell align="right" sx={{ pr: 3, py: 1.2 }}>
|
|
<IconButton
|
|
size="small"
|
|
onClick={() => locatePipes([candidate.pipe_id])}
|
|
className="text-blue-600 hover:bg-blue-50"
|
|
title="定位"
|
|
>
|
|
<LocationOnIcon fontSize="small" />
|
|
</IconButton>
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
export default LocationResults;
|