"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 = { 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) => ( {label} {value} {hint ? ( {hint} ) : null} ); const formatDateTime = (value?: string) => value ? dayjs(value).format("YYYY-MM-DD HH:mm") : "-"; const EmptyState = () => ( } title="尚未生成侦测结果" description="请在“侦测参数”中运行分析,或在“方案查询”中打开历史结果。" /> ); const DetectionResults: React.FC = ({ result, state, onStateChange }) => { const map = useMap(); const highlightLayerRef = useRef | null>(null); const [highlightFeatures, setHighlightFeatures] = useState([]); const [internalState, setInternalState] = useState( 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 ; 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]}
${data.day === 15 ? "目标时刻" : "参考日"}
异常分数:${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 ? ( ) : ( 参考 ), }, ]; const tableRows = sortedRows.map((row) => ({ id: row.Day, ...row })); return ( {isBurstDetected ? : } {isBurstDetected ? "目标时刻侦测到爆管异常" : "目标时刻未侦测到爆管异常"} 目标:{formatDateTime(result.target_time ?? result.summary.target_time)};分数越低越异常 爆管侦测结果 15 天同刻异常分数 setSelectedDay(params.data?.day ?? null), }} /> {rankingSeries.length > 0 ? ( 目标测点压力偏离 负值越小,压降相对历史越明显 ) : null} 同刻对照明细 setSelectedDay(Number(params.row.Day))} getRowClassName={(params) => params.row.Day === resultsState.selectedDay ? "bg-blue-50" : "" } sx={{ border: "none" }} /> ); }; export default DetectionResults;