新增爆管侦测面板及相关功能模块

This commit is contained in:
JIANG
2026-03-11 16:40:09 +08:00
parent f0f9d3f4f9
commit e2ea1853f1
8 changed files with 1692 additions and 2 deletions
@@ -0,0 +1,610 @@
"use client";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Box, Button, Chip, Tooltip, Typography } from "@mui/material";
import { DataGrid, GridColDef } from "@mui/x-data-grid";
import { zhCN } from "@mui/x-data-grid/locales";
import {
FormatListBulleted,
InfoOutlined as InfoOutlinedIcon,
Room as RoomIcon,
ShowChart as ShowChartIcon,
CheckCircleOutline as CheckCircleIcon,
ErrorOutline as ErrorOutlineIcon,
} from "@mui/icons-material";
import ReactECharts from "echarts-for-react";
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 { Circle, Fill, Stroke, Style } from "ol/style";
import { bbox, featureCollection } from "@turf/turf";
import { BurstDetectionResult, BurstDetectionRow } from "./types";
interface Props {
result: BurstDetectionResult | 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 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">
<ShowChartIcon 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 getScoreLevel = (score: number) => {
if (score <= -0.6) return { label: "高风险", color: "error" as const };
if (score <= -0.2) return { label: "需关注", color: "warning" as const };
return { label: "正常", color: "success" as const };
};
const formatDateTime = (value?: string) => (value ? dayjs(value).format("YYYY-MM-DD HH:mm") : "-");
const DetectionResults: React.FC<Props> = ({ result }) => {
const map = useMap();
const highlightLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
const [selectedDay, setSelectedDay] = useState<number | null>(null);
useEffect(() => {
if (!map) return;
const layer = new VectorLayer({
source: new VectorSource(),
style: new Style({
stroke: new Stroke({ color: "#ef4444", width: 4 }),
image: new Circle({
radius: 7,
fill: new Fill({ color: "#ef4444" }),
stroke: new Stroke({ color: "#fff", width: 2 }),
}),
zIndex: 999,
}),
properties: {
name: "爆管侦测高亮",
value: "burst_detection_highlight",
},
});
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 defaultSelectedDay = useMemo(
() =>
result?.summary?.most_anomalous_day ??
result?.summary?.latest_day?.Day ??
result?.rows[0]?.Day ??
null,
[result],
);
const activeSelectedDay = selectedDay ?? defaultSelectedDay;
const selectedRow = useMemo<BurstDetectionRow | null>(() => {
if (!result || activeSelectedDay === null) return null;
return result.rows.find((row) => row.Day === activeSelectedDay) ?? null;
}, [activeSelectedDay, result]);
const scoreSeries = useMemo(
() =>
result?.rows.map((row) => ({
value: [row.Day, Number(row.Score.toFixed(4))],
itemStyle: {
color: row.IsBurst ? "#ef4444" : row.Score <= -0.2 ? "#f59e0b" : "#10b981",
},
})) ?? [],
[result],
);
const rankingSeries = useMemo(
() =>
[...(result?.summary?.latest_sensor_rankings ?? [])]
.sort((a, b) => a.latest_high_frequency_value - b.latest_high_frequency_value)
.map((item) => ({
name: item.sensor_node,
value: Number(item.latest_high_frequency_value.toFixed(4)),
})),
[result],
);
const locateSensors = async (sensorIds: string[]) => {
if (!map || sensorIds.length === 0) return;
let features = await queryFeaturesByIds(sensorIds, "geo_junctions_mat");
if (features.length === 0) {
features = await queryFeaturesByIds(sensorIds, "geo_junctions");
}
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: 18,
duration: 1000,
padding: [100, 100, 100, 100],
});
};
if (!result) {
return <EmptyState />;
}
const latestDay = result.summary?.latest_day;
const latestLevel = latestDay ? getScoreLevel(latestDay.Score) : getScoreLevel(0);
const mostAnomalousRow = result.rows.find((row) => row.Day === result.summary?.most_anomalous_day) ?? null;
const mostAnomalousLevel = getScoreLevel(mostAnomalousRow?.Score ?? 0);
const isBurstDetected = result.summary.burst_detected;
const chartOption = {
tooltip: {
trigger: "axis",
formatter: (params: Array<{ data: { value: [number, number] } }>) => {
const point = params[0]?.data?.value;
if (!point) return "-";
return `侦测日第 ${point[0]} 天<br/>异常分数:${point[1]}`;
},
},
grid: { top: 30, left: 40, right: 20, bottom: 35 },
xAxis: {
type: "category",
name: "侦测日",
data: result.rows.map((row) => row.Day),
axisLabel: { fontSize: 10 },
},
yAxis: {
type: "value",
name: "异常分数",
axisLabel: { fontSize: 10 },
},
series: [
{
type: "line",
smooth: true,
symbolSize: 8,
data: scoreSeries,
lineStyle: { color: "#2563eb", width: 2 },
markLine: {
symbol: "none",
lineStyle: { type: "dashed", color: "#94a3b8" },
data: [{ yAxis: 0 }],
},
},
],
};
const rankingOption = {
tooltip: {
trigger: "axis",
axisPointer: { type: "shadow" },
},
grid: { top: 20, left: 70, right: 20, bottom: 20 },
xAxis: { type: "value", axisLabel: { fontSize: 10 } },
yAxis: {
type: "category",
data: rankingSeries.map((item) => item.name),
axisLabel: { fontSize: 10 },
},
series: [
{
type: "bar",
data: rankingSeries.map((item) => ({
value: item.value,
itemStyle: {
color: item.value <= -0.6 ? "#ef4444" : item.value <= -0.2 ? "#f59e0b" : "#10b981",
},
})),
barWidth: 14,
},
],
};
const columns: GridColDef[] = [
{
field: "Day",
headerName: "侦测日",
width: 96,
valueFormatter: (value?: number) => (typeof value === "number" ? `${value}` : "-"),
},
{
field: "Score",
headerName: "异常分数",
width: 120,
valueFormatter: (value?: number) => (typeof value === "number" ? value.toFixed(4) : "-"),
},
{
field: "IsBurst",
headerName: "判定结果",
width: 120,
renderCell: ({ value }) => {
const level = value ? { label: "爆管异常", color: "error" as const } : { label: "正常", color: "success" as const };
return <Chip size="small" label={level.label} color={level.color} variant="outlined" />;
},
},
];
const rows = result.rows.map((row) => ({ id: row.Day, ...row }));
return (
<Box className="h-full overflow-auto p-1">
<Box className="mb-4 space-y-3">
{/* Status Banner */}
<Box
className={`rounded-lg px-4 py-3 flex items-center gap-3 border ${isBurstDetected
? "bg-red-50 border-red-100 text-red-900"
: "bg-green-50 border-green-100 text-green-900"
}`}
>
{isBurstDetected ? (
<ErrorOutlineIcon className="text-red-600" />
) : (
<CheckCircleIcon className="text-green-600" />
)}
<Box className="flex-1">
<Typography variant="subtitle2" className="font-bold">
{isBurstDetected
? `侦测到异常信号 (共 ${result.summary.anomaly_day_count} 天)`
: "未侦测到爆管异常"}
</Typography>
<Typography variant="caption" className="opacity-80">
{isBurstDetected
? "建议检查异常日期的压力波动情况"
: "当前时间窗口内数据特征平稳,符合历史模式"}
</Typography>
</Box>
</Box>
{/* Header */}
<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" }}>
{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={<RoomIcon />}
onClick={() =>
locateSensors(result.summary.latest_sensor_rankings.map((item) => item.sensor_node).slice(0, 5))
}
sx={{
height: 24,
minWidth: 0,
padding: "0 8px",
borderColor: "#bfdbfe",
color: "#2563eb",
fontSize: "0.75rem",
"&:hover": { borderColor: "#60a5fa", backgroundColor: "#eff6ff" },
}}
>
</Button>
</Box>
</Box>
{/* Configuration Summary */}
<Box className="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-lg border border-gray-100 bg-gray-50/50 px-3 py-2 text-xs text-gray-600">
<Box className="flex items-center gap-1.5">
<Box className="h-1.5 w-1.5 rounded-full bg-blue-400" />
<span className="font-medium text-gray-700"></span>
<span className="font-mono text-gray-600">
{formatDateTime(result.scada_window?.start)} ~ {formatDateTime(result.scada_window?.end)}
</span>
</Box>
<Box className="flex items-center gap-1.5">
<Box className="h-1.5 w-1.5 rounded-full bg-purple-400" />
<span className="font-medium text-gray-700"></span>
<span className="text-gray-600">
{(() => {
const ds = result.data_source;
const os = result.observed_source;
if (ds === "simulation") return "模拟数据";
if (ds === "monitoring") return "监测数据";
if (os === "simulation_scheme_timerange") return "模拟数据";
if (os === "backend_timerange") return "监测数据";
return os || "-";
})()}
</span>
</Box>
</Box>
{/* Metrics Grid */}
<Box className="grid grid-cols-2 gap-3">
<MetricCard
label="异常天数"
value={`${result.summary.anomaly_day_count} / ${result.day_count}`}
hint={`异常日:${result.summary.anomaly_days.join(", ") || "无"}`}
tone={result.summary.anomaly_day_count > 0 ? "orange" : "green"}
/>
<MetricCard
label="最异常日"
value={
result.summary.burst_detected && result.summary.most_anomalous_day
? `${result.summary.most_anomalous_day}`
: "无"
}
hint={
result.summary.burst_detected && mostAnomalousRow
? `分数 ${mostAnomalousRow.Score.toFixed(4)} · ${mostAnomalousLevel.label}`
: "-"
}
tone="purple"
/>
<MetricCard
label="最新状态"
value={latestLevel.label}
hint={latestDay ? `${latestDay.Day} 天 · 分数 ${latestDay.Score.toFixed(4)}` : "-"}
tone={latestLevel.color === "success" ? "green" : "orange"}
/>
<MetricCard
label="测点 / 样本"
value={`${result.sensor_nodes.length} / ${result.sample_count}`}
hint={`每日采样点数:${result.points_per_day}`}
tone="blue"
/>
</Box>
</Box>
{/* Score Trend Chart */}
<Box className="mb-4 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">
<ShowChartIcon className="h-5 w-5 text-blue-600" />
<Typography variant="subtitle1" className="font-bold text-gray-800">
</Typography>
</Box>
<Tooltip title="分数越小越异常,0 以下通常意味着更值得关注。">
<InfoOutlinedIcon fontSize="small" className="text-gray-400" />
</Tooltip>
</Box>
<Box sx={{ height: 250, px: 1.5, py: 1 }}>
<ReactECharts
option={chartOption}
style={{ height: "100%", width: "100%" }}
onEvents={{
click: (params: { data?: { value?: [number, number] } }) => {
const day = params?.data?.value?.[0];
if (typeof day === "number") {
setSelectedDay(day);
}
},
}}
/>
</Box>
</Box>
{/* Selected Day Interpretation */}
<Box className="mb-4 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">
<Typography variant="subtitle1" className="font-bold text-gray-800">
</Typography>
{selectedRow ? (
<Chip
size="small"
label={`${selectedRow.Day}`}
sx={{
height: 22,
backgroundColor: "rgba(37, 99, 235, 0.08)",
color: "#2563eb",
fontWeight: 600,
fontSize: "0.75rem",
border: "none",
}}
/>
) : null}
</Box>
{selectedRow ? (
<Box className="space-y-3 px-4 py-3">
<Box className="flex items-center gap-2">
<Chip
label={getScoreLevel(selectedRow.Score).label}
color={getScoreLevel(selectedRow.Score).color}
variant="filled"
/>
</Box>
<Typography variant="body2" className="text-gray-700">
<span className="font-semibold">{selectedRow.Score.toFixed(4)}</span>
</Typography>
<Typography variant="body2" className="text-gray-700">
{selectedRow.IsBurst ? "异常日(Prediction = -1" : "正常日(Prediction = 1"}
</Typography>
<Typography variant="body2" className="text-gray-700">
{selectedRow.Score <= -0.6
? "高风险异常,建议优先复核对应测点的原始压力曲线与现场工况。"
: selectedRow.Score <= -0.2
? "存在可疑波动,建议结合相邻测点和调度记录进一步确认。"
: "未见明显异常,可作为基线日参考。"}
</Typography>
</Box>
) : (
<Typography variant="body2" className="px-4 py-3 text-gray-500">
</Typography>
)}
</Box>
{/* Latest Sensor Rankings */}
<Box className="mb-4 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">
<Typography variant="subtitle1" className="font-bold text-gray-800">
</Typography>
<Typography variant="caption" className="text-gray-500">
</Typography>
</Box>
<Box sx={{ height: 260, px: 1.5, py: 1 }}>
<ReactECharts option={rankingOption} style={{ height: "100%", width: "100%" }} />
</Box>
<Box className="flex flex-wrap gap-2 border-t border-gray-100 px-4 py-3">
{result.summary.latest_sensor_rankings.slice(0, 5).map((item) => (
<Button
key={item.sensor_node}
size="small"
variant="outlined"
onClick={() => locateSensors([item.sensor_node])}
sx={{
borderColor: "#bfdbfe",
color: "#2563eb",
"&:hover": { borderColor: "#60a5fa", backgroundColor: "#eff6ff" },
}}
>
{item.sensor_node}
</Button>
))}
</Box>
</Box>
{/* Results Table */}
<Box className="mb-4 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>
<Chip
size="small"
label={`${rows.length}`}
sx={{
height: 22,
backgroundColor: "rgba(37, 99, 235, 0.08)",
color: "#2563eb",
fontWeight: 600,
fontSize: "0.75rem",
border: "none",
}}
/>
</Box>
<Box sx={{ height: 320, px: 1, py: 1 }}>
<DataGrid
rows={rows}
columns={columns}
columnBufferPx={100}
localeText={zhCN.components.MuiDataGrid.defaultProps.localeText}
initialState={{
pagination: { paginationModel: { pageSize: 50, page: 0 } },
}}
pageSizeOptions={[50]}
hideFooterSelectedRowCount
sx={{
border: "none",
"& .MuiDataGrid-cell": { borderColor: "#f0f0f0" },
"& .MuiDataGrid-columnHeaders": { backgroundColor: "#fafafa" },
"& .MuiDataGrid-row:hover": { backgroundColor: "#f8fafc" },
// Hide the rows per page selector since it's fixed to 50
"& .MuiTablePagination-selectLabel": { display: "none" },
"& .MuiTablePagination-input": { display: "none" },
}}
disableRowSelectionOnClick
onRowClick={(params) => setSelectedDay(Number(params.row.Day))}
/>
</Box>
</Box>
</Box>
);
};
export default DetectionResults;