439 lines
15 KiB
TypeScript
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;
|