更新爆管定位功能,优化数据处理和展示
This commit is contained in:
@@ -1,20 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
Divider,
|
||||
IconButton,
|
||||
Paper,
|
||||
Tooltip,
|
||||
Typography
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Chip,
|
||||
IconButton,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Button,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
FormatListBulleted,
|
||||
LocationOn as LocationOnIcon,
|
||||
Map as MapIcon
|
||||
LocationOn as LocationOnIcon,
|
||||
Map as MapIcon,
|
||||
} from "@mui/icons-material";
|
||||
import dayjs from "dayjs";
|
||||
import { useMap } from "@app/OlMap/MapComponent";
|
||||
@@ -25,24 +27,102 @@ 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 { BurstLocationResult } from "./types";
|
||||
import { BurstCandidate, BurstLocationResult } from "./types";
|
||||
import { DMA_FLOW_DISPLAY_UNIT } from "../DMALeakDetection/utils";
|
||||
|
||||
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 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 = () => (
|
||||
<Box className="flex h-full flex-col items-center justify-center bg-gray-50/50 p-6 text-center">
|
||||
<Box className="mb-4 rounded-full bg-white p-6 shadow-sm">
|
||||
<MapIcon sx={{ fontSize: 48, color: "#cbd5e1" }} />
|
||||
</Box>
|
||||
<Typography variant="h6" className="mb-1 font-bold text-gray-700">
|
||||
等待定位结果
|
||||
</Typography>
|
||||
<Typography variant="body2" className="max-w-xs text-gray-500">
|
||||
请先提交爆管定位分析,结果面板将展示定位摘要、时间窗、采样情况和候选管段。
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const LocationResults: React.FC<Props> = ({ result }) => {
|
||||
const map = useMap();
|
||||
const [highlightLayer, setHighlightLayer] =
|
||||
useState<VectorLayer<VectorSource> | null>(null);
|
||||
const [highlightLayer, setHighlightLayer] = useState<VectorLayer<VectorSource> | null>(null);
|
||||
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
|
||||
|
||||
const candidatePipes = useMemo(() => {
|
||||
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.0 }, ...base];
|
||||
return [{ pipe_id: result.located_pipe, similarity: 1 }, ...base];
|
||||
}
|
||||
return base;
|
||||
}, [result]);
|
||||
@@ -86,133 +166,124 @@ const LocationResults: React.FC<Props> = ({ result }) => {
|
||||
|
||||
const locatePipes = async (pipeIds: string[]) => {
|
||||
if (!pipeIds.length || !map) return;
|
||||
|
||||
|
||||
try {
|
||||
// 尝试两个图层
|
||||
let features = await queryFeaturesByIds(pipeIds, "geo_pipes_mat");
|
||||
if (features.length === 0) {
|
||||
features = await queryFeaturesByIds(pipeIds, "geo_pipes");
|
||||
}
|
||||
|
||||
if (features.length === 0) return;
|
||||
|
||||
|
||||
setHighlightFeatures(features);
|
||||
|
||||
|
||||
const geojsonFormat = new GeoJSON();
|
||||
const geojsonFeatures = features.map((feature) =>
|
||||
geojsonFormat.writeFeatureObject(feature),
|
||||
);
|
||||
// @ts-ignore
|
||||
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 (e) {
|
||||
console.error("Locate failed", e);
|
||||
} catch (error) {
|
||||
console.error("Locate failed", error);
|
||||
}
|
||||
};
|
||||
|
||||
if (!result) {
|
||||
return (
|
||||
<Box className="flex flex-col items-center justify-center h-full bg-gray-50/50 p-6 text-center">
|
||||
<Box className="bg-white p-6 rounded-full shadow-sm mb-4">
|
||||
<MapIcon sx={{ fontSize: 48, color: "#cbd5e1" }} />
|
||||
</Box>
|
||||
<Typography variant="h6" className="text-gray-700 font-bold mb-1">等待定位</Typography>
|
||||
<Typography variant="body2" className="text-gray-500 max-w-xs">
|
||||
请在左侧面板配置传感器参数与时间范围,点击“开始定位”获取结果。
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
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)
|
||||
: "-";
|
||||
|
||||
return (
|
||||
<Box className="h-full overflow-y-auto bg-gray-50 p-3 space-y-3">
|
||||
{/* 1. 冠军卡片 */}
|
||||
<Box className="flex items-center justify-between px-1 mb-2">
|
||||
<Box className="flex items-center gap-2">
|
||||
<Box className="w-1 h-4 bg-blue-600 rounded-full" />
|
||||
<Typography
|
||||
variant="h6"
|
||||
className="font-bold text-gray-900 truncate"
|
||||
sx={{ fontSize: "1.1rem" }}
|
||||
title={result.scheme_name || "Burst Location Result"}
|
||||
>
|
||||
{result.scheme_name || "爆管定位结果"}
|
||||
</Typography>
|
||||
</Box>
|
||||
{result.username && (
|
||||
<Chip
|
||||
label={result.username}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 24,
|
||||
backgroundColor: "#f3f4f6",
|
||||
color: "#4b5563",
|
||||
border: "none",
|
||||
fontWeight: 500
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 2. 统计数据 */}
|
||||
<Box className="grid grid-cols-2 gap-3 mb-4">
|
||||
{/* 方案时间/创建时间 */}
|
||||
<Box className="bg-gradient-to-br from-blue-50 to-blue-100 rounded-lg p-3 border border-blue-200 shadow-sm">
|
||||
<Typography variant="caption" className="text-blue-700 font-semibold block mb-1 text-xs uppercase tracking-wide">
|
||||
方案时间
|
||||
</Typography>
|
||||
<Typography variant="body2" className="font-bold text-blue-900">
|
||||
{result.create_time ? dayjs(result.create_time).format("MM-DD HH:mm") : "-"}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* 漏损量 */}
|
||||
<Box className="bg-gradient-to-br from-orange-50 to-orange-100 rounded-lg p-3 border border-orange-200 shadow-sm">
|
||||
<Typography variant="caption" className="text-orange-700 font-semibold block mb-1 text-xs uppercase tracking-wide">
|
||||
漏损量
|
||||
</Typography>
|
||||
<Typography variant="body2" className="font-bold text-orange-900">
|
||||
{result.burst_leakage.toFixed(1)} L/s
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* 最佳匹配 */}
|
||||
<Box className="bg-gradient-to-br from-purple-50 to-purple-100 rounded-lg p-3 border border-purple-200 shadow-sm col-span-2">
|
||||
<Box className="flex items-center justify-between">
|
||||
<Box>
|
||||
<Typography variant="caption" className="text-purple-700 font-semibold block mb-1 text-xs uppercase tracking-wide">
|
||||
最佳匹配管段
|
||||
</Typography>
|
||||
<Typography variant="h6" className="font-bold text-purple-900">
|
||||
{result.located_pipe}
|
||||
</Typography>
|
||||
<Typography variant="caption" className="text-purple-600">
|
||||
置信度: {(candidatePipes[0]?.similarity * 100 || 0).toFixed(1)}% · 模式: {result.similarity_mode}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
startIcon={<LocationOnIcon />}
|
||||
onClick={() => locatePipes([result.located_pipe])}
|
||||
sx={{ backgroundColor: "#9333ea", "&:hover": { backgroundColor: "#7e22ce" } }}
|
||||
>
|
||||
定位
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 3. 候选列表 */}
|
||||
<Box className="rounded-xl border border-gray-100 bg-white shadow-sm overflow-hidden">
|
||||
<Box className="px-4 py-3 border-b border-gray-100 flex items-center justify-between bg-white">
|
||||
<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">
|
||||
<FormatListBulleted className="text-blue-600 w-5 h-5" />
|
||||
<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])}
|
||||
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={`${result.burst_leakage.toFixed(2)} ${DMA_FLOW_DISPLAY_UNIT}`}
|
||||
tone="orange"
|
||||
/>
|
||||
<MetricCard
|
||||
label="最佳相似度"
|
||||
value={`${(bestSimilarity * 100).toFixed(1)}%`}
|
||||
tone="purple"
|
||||
/>
|
||||
<MetricCard
|
||||
label="爆管时间"
|
||||
value={burstTime}
|
||||
tone="green"
|
||||
/>
|
||||
</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>
|
||||
@@ -230,35 +301,84 @@ const LocationResults: React.FC<Props> = ({ result }) => {
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box className="max-h-64 overflow-y-auto">
|
||||
{candidatePipes.map((candidate, idx) => (
|
||||
<Box
|
||||
key={candidate.pipe_id}
|
||||
className="flex items-center justify-between px-4 py-3 border-b border-gray-50 last:border-0 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Box className="flex items-center gap-3">
|
||||
<Box className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold ${idx === 0 ? "bg-yellow-100 text-yellow-700" : idx === 1 ? "bg-gray-100 text-gray-600" : idx === 2 ? "bg-orange-50 text-orange-600" : "bg-transparent text-gray-400"}`}>
|
||||
{idx + 1}
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="body2" className="font-bold text-gray-700">
|
||||
{candidate.pipe_id}
|
||||
</Typography>
|
||||
<Typography variant="caption" className="text-gray-400">
|
||||
相似度: {(candidate.similarity * 100).toFixed(2)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => locatePipes([candidate.pipe_id])}
|
||||
className="text-gray-400 hover:text-blue-600"
|
||||
<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"
|
||||
>
|
||||
<LocationOnIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
<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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user