Files
TJWaterFrontend_Refine/src/components/olmap/BurstDetection/DetectionResults.tsx
T

439 lines
15 KiB
TypeScript

"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 {
CheckCircleOutline as CheckCircleIcon,
ErrorOutline as ErrorOutlineIcon,
FormatListBulleted,
InfoOutlined as InfoOutlinedIcon,
Room as RoomIcon,
ShowChart as ShowChartIcon,
} from "@mui/icons-material";
import ReactECharts from "echarts-for-react";
import dayjs from "dayjs";
import Feature from "ol/Feature";
import { GeoJSON } from "ol/format";
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 { useMap } from "@components/olmap/core/MapComponent";
import { queryFeaturesByIds } from "@/utils/mapQueryService";
import { BurstDetectionResult, BurstDetectionRow } from "./types";
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
export interface BurstDetectionResultsState {
selectedDay: number | null;
}
export const createBurstDetectionResultsState =
(): BurstDetectionResultsState => ({ selectedDay: null });
interface Props {
result: BurstDetectionResult | null;
state?: BurstDetectionResultsState;
onStateChange?: (state: BurstDetectionResultsState) => void;
}
interface MetricCardProps {
label: string;
value: string;
hint?: string;
tone: "blue" | "orange" | "purple" | "green";
}
const toneStyles: Record<MetricCardProps["tone"], string> = {
blue: "border-blue-200 from-blue-50 to-blue-100 text-blue-900",
orange: "border-orange-200 from-orange-50 to-orange-100 text-orange-900",
purple: "border-purple-200 from-purple-50 to-purple-100 text-purple-900",
green: "border-green-200 from-green-50 to-green-100 text-green-900",
};
const MetricCard = ({ label, value, hint, tone }: MetricCardProps) => (
<Box
className={`rounded-lg border bg-gradient-to-br p-3 shadow-sm ${toneStyles[tone]}`}
>
<Typography variant="caption" className="mb-1 block font-semibold">
{label}
</Typography>
<Typography variant="body2" className="font-bold">
{value}
</Typography>
{hint ? (
<Typography variant="caption" className="mt-0.5 block opacity-75">
{hint}
</Typography>
) : null}
</Box>
);
const formatDateTime = (value?: string) =>
value ? dayjs(value).format("YYYY-MM-DD HH:mm") : "-";
const EmptyState = () => (
<PanelEmptyState
icon={<ShowChartIcon />}
title="尚未生成侦测结果"
description="请在“侦测参数”中运行分析,或在“方案查询”中打开历史结果。"
/>
);
const DetectionResults: React.FC<Props> = ({ result, state, onStateChange }) => {
const map = useMap();
const highlightLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
const [internalState, setInternalState] = useState<BurstDetectionResultsState>(
createBurstDetectionResultsState,
);
const resultsState = state ?? internalState;
const setSelectedDay = (selectedDay: number | null) => {
const nextState = { selectedDay };
if (state === undefined) setInternalState(nextState);
onStateChange?.(nextState);
};
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",
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 sortedRows = useMemo(
() => [...(result?.rows ?? [])].sort((a, b) => a.Day - b.Day),
[result],
);
const timestampForRow = (row: BurstDetectionRow) => {
if (row.Timestamp) return row.Timestamp;
const start = dayjs(result?.scada_window?.start);
return start.isValid() ? start.add(row.Day, "day").toISOString() : undefined;
};
const scoreThreshold = result?.summary.score_threshold ?? 0;
const scoreSeries = sortedRows.map((row) => ({
day: row.Day,
value: [
timestampForRow(row)
? dayjs(timestampForRow(row)).format("MM-DD HH:mm")
: `第 ${row.Day} 天`,
Number(row.Score.toFixed(4)),
],
itemStyle: {
color:
row.Role === "target"
? row.IsBurst
? "#ef4444"
: "#2563eb"
: "#94a3b8",
},
symbolSize: row.Role === "target" ? 11 : 7,
}));
const rankingSeries = useMemo(
() =>
[...(result?.summary.latest_sensor_rankings ?? [])]
.sort(
(a, b) =>
(a.standardized_deviation ?? a.latest_high_frequency_value) -
(b.standardized_deviation ?? b.latest_high_frequency_value),
)
.map((item) => ({
name: item.sensor_node,
value: Number(
(item.standardized_deviation ?? item.latest_high_frequency_value).toFixed(3),
),
})),
[result],
);
const locateSensors = async (sensorIds: string[]) => {
if (!map || sensorIds.length === 0) return;
const features = await queryFeaturesByIds(sensorIds, "junctions");
if (features.length === 0) return;
setHighlightFeatures(features);
const format = new GeoJSON();
const geojsonFeatures = features.map((feature) =>
format.writeFeatureObject(feature),
);
// @ts-ignore turf accepts OpenLayers GeoJSON feature objects
const extent = bbox(featureCollection(geojsonFeatures));
map.getView().fit(extent, {
maxZoom: 18,
duration: 1000,
padding: [100, 100, 100, 100],
});
};
if (!result) return <EmptyState />;
const targetRow = sortedRows.find((row) => row.Role === "target") ?? sortedRows.at(-1);
const isBurstDetected = result.summary.burst_detected;
const targetRank = result.summary.target_rank;
const excludedCount = result.data_quality?.excluded_sensors.length ?? 0;
const chartOption = {
tooltip: {
trigger: "axis",
formatter: (params: Array<{ data: { day: number; value: [string, number] } }>) => {
const data = params[0]?.data;
return data
? `${data.value[0]}<br/>${data.day === 15 ? "目标时刻" : "参考日"}<br/>异常分数:${data.value[1]}`
: "-";
},
},
grid: { top: 30, left: 48, right: 20, bottom: 48 },
xAxis: {
type: "category",
name: "同刻日期",
boundaryGap: false,
data: scoreSeries.map((item) => item.value[0]),
axisLabel: { fontSize: 10, interval: 2, rotate: 25 },
},
yAxis: { type: "value", name: "异常分数", axisLabel: { fontSize: 10 } },
series: [
{
type: "line",
data: scoreSeries,
lineStyle: { color: "#94a3b8", width: 2 },
markLine: {
symbol: "none",
lineStyle: { type: "dashed", color: "#ef4444" },
data: [{ yAxis: scoreThreshold, name: "报警阈值" }],
},
},
],
};
const rankingOption = {
tooltip: { trigger: "axis", axisPointer: { type: "shadow" } },
grid: { top: 12, left: 82, right: 20, bottom: 25 },
xAxis: { type: "value", name: "标准化偏离", 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 ? "#ef4444" : "#f59e0b" },
})),
barWidth: 14,
},
],
};
const columns: GridColDef[] = [
{
field: "Timestamp",
headerName: "同刻日期",
minWidth: 145,
flex: 1,
valueGetter: (_value, row) => formatDateTime(timestampForRow(row)),
},
{
field: "Role",
headerName: "角色",
width: 90,
valueFormatter: (value?: string) => (value === "target" ? "目标" : "参考"),
},
{
field: "Score",
headerName: "异常分数",
width: 110,
valueFormatter: (value?: number) =>
typeof value === "number" ? value.toFixed(4) : "-",
},
{
field: "IsBurst",
headerName: "目标判定",
width: 110,
renderCell: ({ value, row }) =>
row.Role === "target" || row.Day === result.day_count ? (
<Chip
size="small"
label={value ? "爆管异常" : "正常"}
color={value ? "error" : "success"}
variant="outlined"
/>
) : (
<Typography variant="caption" color="text.secondary">
参考
</Typography>
),
},
];
const tableRows = sortedRows.map((row) => ({ id: row.Day, ...row }));
return (
<Box className="h-full overflow-auto p-1">
<Box className="mb-4 space-y-3">
<Box
className={`flex items-center gap-3 rounded-lg border px-4 py-3 ${
isBurstDetected
? "border-red-100 bg-red-50 text-red-900"
: "border-green-100 bg-green-50 text-green-900"
}`}
>
{isBurstDetected ? <ErrorOutlineIcon /> : <CheckCircleIcon />}
<Box className="flex-1">
<Typography variant="subtitle2" className="font-bold">
{isBurstDetected ? "目标时刻侦测到爆管异常" : "目标时刻未侦测到爆管异常"}
</Typography>
<Typography variant="caption" className="opacity-80">
目标:{formatDateTime(result.target_time ?? result.summary.target_time)};分数越低越异常
</Typography>
</Box>
</Box>
<Box className="flex items-center justify-between gap-2 px-1">
<Typography variant="h6" className="min-w-0 flex-1 font-bold text-gray-900">
爆管侦测结果
</Typography>
<Button
size="small"
variant="outlined"
startIcon={<RoomIcon />}
onClick={() =>
void locateSensors(
result.summary.latest_sensor_rankings
.slice(0, 5)
.map((item) => item.sensor_node),
)
}
sx={{ flexShrink: 0, whiteSpace: "nowrap" }}
>
定位异常测点
</Button>
</Box>
<Box className="grid grid-cols-2 gap-3">
<MetricCard
label="目标异常分数"
value={targetRow ? targetRow.Score.toFixed(4) : "-"}
hint={`报警阈值 ≤ ${scoreThreshold.toFixed(2)}`}
tone={isBurstDetected ? "orange" : "green"}
/>
<MetricCard
label="目标异常排名"
value={targetRank ? `${targetRank} / ${result.day_count}` : "-"}
hint="在目标日与 14 个参考日中排序"
tone="purple"
/>
<MetricCard
label="参考区间"
value={`${formatDateTime(result.reference_window?.start)} ~ ${formatDateTime(result.reference_window?.end)}`}
hint={`${result.reference_window?.day_count ?? 14} 个同刻参考日`}
tone="blue"
/>
<MetricCard
label="有效 / 排除测点"
value={`${result.sensor_nodes.length} / ${excludedCount}`}
hint={`${result.sampling_interval_minutes ?? 15} 分钟采样,${result.points_per_day} 点/天`}
tone="blue"
/>
</Box>
</Box>
<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 px-4 py-3">
<Box className="flex items-center gap-2">
<ShowChartIcon className="text-blue-600" />
<Typography variant="subtitle1" className="font-bold">
15 天同刻异常分数
</Typography>
</Box>
<Tooltip title="灰色点为前 14 天参考,最后一个点为本次目标。">
<InfoOutlinedIcon fontSize="small" className="text-gray-400" />
</Tooltip>
</Box>
<Box sx={{ height: 270, px: 1.5, py: 1 }}>
<ReactECharts
option={chartOption}
style={{ height: "100%", width: "100%" }}
onEvents={{
click: (params: { data?: { day?: number } }) =>
setSelectedDay(params.data?.day ?? null),
}}
/>
</Box>
</Box>
{rankingSeries.length > 0 ? (
<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 px-4 py-3">
<Typography variant="subtitle1" className="font-bold">
目标测点压力偏离
</Typography>
<Typography variant="caption" color="text.secondary">
负值越小,压降相对历史越明显
</Typography>
</Box>
<Box sx={{ height: 280, px: 1.5, py: 1 }}>
<ReactECharts option={rankingOption} style={{ height: "100%", width: "100%" }} />
</Box>
</Box>
) : null}
<Box className="mb-4 overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm">
<Box className="flex items-center gap-2 border-b border-gray-100 px-4 py-3">
<FormatListBulleted className="text-blue-600" />
<Typography variant="subtitle1" className="font-bold">
同刻对照明细
</Typography>
</Box>
<Box sx={{ height: 360, px: 1, py: 1 }}>
<DataGrid
rows={tableRows}
columns={columns}
localeText={zhCN.components.MuiDataGrid.defaultProps.localeText}
pageSizeOptions={[15]}
initialState={{ pagination: { paginationModel: { pageSize: 15, page: 0 } } }}
disableRowSelectionOnClick
onRowClick={(params) => setSelectedDay(Number(params.row.Day))}
getRowClassName={(params) =>
params.row.Day === resultsState.selectedDay ? "bg-blue-50" : ""
}
sx={{ border: "none" }}
/>
</Box>
</Box>
</Box>
);
};
export default DetectionResults;