feat(burst-detection): update analysis parameters
This commit is contained in:
@@ -5,23 +5,23 @@ 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,
|
||||
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 { 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";
|
||||
|
||||
export interface BurstDetectionResultsState {
|
||||
@@ -29,9 +29,7 @@ export interface BurstDetectionResultsState {
|
||||
}
|
||||
|
||||
export const createBurstDetectionResultsState =
|
||||
(): BurstDetectionResultsState => ({
|
||||
selectedDay: null,
|
||||
});
|
||||
(): BurstDetectionResultsState => ({ selectedDay: null });
|
||||
|
||||
interface Props {
|
||||
result: BurstDetectionResult | null;
|
||||
@@ -46,54 +44,33 @@ interface MetricCardProps {
|
||||
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 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) => {
|
||||
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}
|
||||
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>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
|
||||
const formatDateTime = (value?: string) =>
|
||||
value ? dayjs(value).format("YYYY-MM-DD HH:mm") : "-";
|
||||
|
||||
const EmptyState = () => (
|
||||
<Box className="flex h-full flex-col items-center justify-center bg-gray-50/50 p-6 text-center">
|
||||
@@ -104,42 +81,27 @@ const EmptyState = () => (
|
||||
等待侦测结果
|
||||
</Typography>
|
||||
<Typography variant="body2" className="max-w-xs text-gray-500">
|
||||
提交一次爆管侦测后,这里会展示异常天数、分数趋势、最新测点排名和结果表格。
|
||||
提交侦测后,这里会展示目标时刻状态、前 14 天参考分数和异常测点。
|
||||
</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,
|
||||
state,
|
||||
onStateChange,
|
||||
}) => {
|
||||
const DetectionResults: React.FC<Props> = ({ result, state, onStateChange }) => {
|
||||
const map = useMap();
|
||||
const highlightLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
|
||||
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
|
||||
const [internalResultsState, setInternalResultsState] =
|
||||
useState<BurstDetectionResultsState>(createBurstDetectionResultsState);
|
||||
const resultsState = state ?? internalResultsState;
|
||||
const selectedDay = resultsState.selectedDay;
|
||||
const setSelectedDay = (value: number | null) => {
|
||||
const nextState = { ...resultsState, selectedDay: value };
|
||||
if (state === undefined) {
|
||||
setInternalResultsState(nextState);
|
||||
}
|
||||
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({
|
||||
@@ -157,10 +119,8 @@ const DetectionResults: React.FC<Props> = ({
|
||||
queryable: false,
|
||||
},
|
||||
});
|
||||
|
||||
map.addLayer(layer);
|
||||
highlightLayerRef.current = layer;
|
||||
|
||||
return () => {
|
||||
highlightLayerRef.current = null;
|
||||
map.removeLayer(layer);
|
||||
@@ -174,58 +134,67 @@ const DetectionResults: React.FC<Props> = ({
|
||||
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,
|
||||
const sortedRows = useMemo(
|
||||
() => [...(result?.rows ?? [])].sort((a, b) => a.Day - b.Day),
|
||||
[result],
|
||||
);
|
||||
|
||||
const activeSelectedDay = selectedDay ?? defaultSelectedDay;
|
||||
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 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))],
|
||||
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.IsBurst ? "#ef4444" : row.Score <= -0.2 ? "#f59e0b" : "#10b981",
|
||||
color:
|
||||
row.Role === "target"
|
||||
? row.IsBurst
|
||||
? "#ef4444"
|
||||
: "#2563eb"
|
||||
: "#94a3b8",
|
||||
},
|
||||
})) ?? [],
|
||||
[result],
|
||||
);
|
||||
symbolSize: row.Role === "target" ? 11 : 7,
|
||||
}));
|
||||
|
||||
const rankingSeries = useMemo(
|
||||
() =>
|
||||
[...(result?.summary?.latest_sensor_rankings ?? [])]
|
||||
.sort((a, b) => a.latest_high_frequency_value - b.latest_high_frequency_value)
|
||||
[...(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.latest_high_frequency_value.toFixed(4)),
|
||||
value: Number(
|
||||
(item.standardized_deviation ?? item.latest_high_frequency_value).toFixed(3),
|
||||
),
|
||||
})),
|
||||
[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 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,
|
||||
@@ -234,60 +203,50 @@ const DetectionResults: React.FC<Props> = ({
|
||||
});
|
||||
};
|
||||
|
||||
if (!result) {
|
||||
return <EmptyState />;
|
||||
}
|
||||
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 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: { value: [number, number] } }>) => {
|
||||
const point = params[0]?.data?.value;
|
||||
if (!point) return "-";
|
||||
return `侦测日第 ${point[0]} 天<br/>异常分数:${point[1]}`;
|
||||
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: 40, right: 20, bottom: 35 },
|
||||
grid: { top: 30, left: 48, right: 20, bottom: 48 },
|
||||
xAxis: {
|
||||
type: "category",
|
||||
name: "侦测日",
|
||||
data: result.rows.map((row) => row.Day),
|
||||
axisLabel: { fontSize: 10 },
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
name: "异常分数",
|
||||
axisLabel: { fontSize: 10 },
|
||||
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",
|
||||
smooth: true,
|
||||
symbolSize: 8,
|
||||
data: scoreSeries,
|
||||
lineStyle: { color: "#2563eb", width: 2 },
|
||||
lineStyle: { color: "#94a3b8", width: 2 },
|
||||
markLine: {
|
||||
symbol: "none",
|
||||
lineStyle: { type: "dashed", color: "#94a3b8" },
|
||||
data: [{ yAxis: 0 }],
|
||||
lineStyle: { type: "dashed", color: "#ef4444" },
|
||||
data: [{ yAxis: scoreThreshold, name: "报警阈值" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const rankingOption = {
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
axisPointer: { type: "shadow" },
|
||||
},
|
||||
grid: { top: 20, left: 70, right: 20, bottom: 20 },
|
||||
xAxis: { type: "value", axisLabel: { fontSize: 10 } },
|
||||
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),
|
||||
@@ -298,9 +257,7 @@ const DetectionResults: React.FC<Props> = ({
|
||||
type: "bar",
|
||||
data: rankingSeries.map((item) => ({
|
||||
value: item.value,
|
||||
itemStyle: {
|
||||
color: item.value <= -0.6 ? "#ef4444" : item.value <= -0.2 ? "#f59e0b" : "#10b981",
|
||||
},
|
||||
itemStyle: { color: item.value < 0 ? "#ef4444" : "#f59e0b" },
|
||||
})),
|
||||
barWidth: 14,
|
||||
},
|
||||
@@ -309,323 +266,176 @@ const DetectionResults: React.FC<Props> = ({
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{
|
||||
field: "Day",
|
||||
headerName: "侦测日",
|
||||
width: 96,
|
||||
valueFormatter: (value?: number) => (typeof value === "number" ? `第 ${value} 天` : "-"),
|
||||
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: 120,
|
||||
valueFormatter: (value?: number) => (typeof value === "number" ? value.toFixed(4) : "-"),
|
||||
width: 110,
|
||||
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" />;
|
||||
},
|
||||
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 rows = result.rows.map((row) => ({ id: row.Day, ...row }));
|
||||
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">
|
||||
{/* 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"
|
||||
}`}
|
||||
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 className="text-red-600" />
|
||||
) : (
|
||||
<CheckCircleIcon className="text-green-600" />
|
||||
)}
|
||||
{isBurstDetected ? <ErrorOutlineIcon /> : <CheckCircleIcon />}
|
||||
<Box className="flex-1">
|
||||
<Typography variant="subtitle2" className="font-bold">
|
||||
{isBurstDetected
|
||||
? `侦测到异常信号 (共 ${result.summary.anomaly_day_count} 天)`
|
||||
: "未侦测到爆管异常"}
|
||||
{isBurstDetected ? "目标时刻侦测到爆管异常" : "目标时刻未侦测到爆管异常"}
|
||||
</Typography>
|
||||
<Typography variant="caption" className="opacity-80">
|
||||
{isBurstDetected
|
||||
? "建议检查异常日期的压力波动情况"
|
||||
: "当前时间窗口内数据特征平稳,符合历史模式"}
|
||||
目标:{formatDateTime(result.target_time ?? result.summary.target_time)};分数越低越异常
|
||||
</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 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>
|
||||
|
||||
{/* 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"}
|
||||
label="目标异常分数"
|
||||
value={targetRow ? targetRow.Score.toFixed(4) : "-"}
|
||||
hint={`报警阈值 ≤ ${scoreThreshold.toFixed(2)}`}
|
||||
tone={isBurstDetected ? "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}`
|
||||
: "-"
|
||||
}
|
||||
label="目标异常排名"
|
||||
value={targetRank ? `${targetRank} / ${result.day_count}` : "-"}
|
||||
hint="在目标日与 14 个参考日中排序"
|
||||
tone="purple"
|
||||
/>
|
||||
<MetricCard
|
||||
label="最新状态"
|
||||
value={latestLevel.label}
|
||||
hint={latestDay ? `第 ${latestDay.Day} 天 · 分数 ${latestDay.Score.toFixed(4)}` : "-"}
|
||||
tone={latestLevel.color === "success" ? "green" : "orange"}
|
||||
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} / ${result.sample_count}`}
|
||||
hint={`每日采样点数:${result.points_per_day}`}
|
||||
label="有效 / 排除测点"
|
||||
value={`${result.sensor_nodes.length} / ${excludedCount}`}
|
||||
hint={`${result.sampling_interval_minutes ?? 15} 分钟采样,${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 justify-between border-b border-gray-100 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">
|
||||
异常分数趋势
|
||||
<ShowChartIcon className="text-blue-600" />
|
||||
<Typography variant="subtitle1" className="font-bold">
|
||||
15 天同刻异常分数
|
||||
</Typography>
|
||||
</Box>
|
||||
<Tooltip title="分数越小越异常,0 以下通常意味着更值得关注。">
|
||||
<Tooltip title="灰色点为前 14 天参考,最后一个点为本次目标。">
|
||||
<InfoOutlinedIcon fontSize="small" className="text-gray-400" />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Box sx={{ height: 250, px: 1.5, py: 1 }}>
|
||||
<Box sx={{ height: 270, 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);
|
||||
}
|
||||
},
|
||||
click: (params: { data?: { day?: number } }) =>
|
||||
setSelectedDay(params.data?.day ?? null),
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
{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="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 variant="caption" color="text.secondary">
|
||||
负值越小,压降相对历史越明显
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body2" className="px-4 py-3 text-gray-500">
|
||||
请在趋势图或表格中选择一天查看详细解释。
|
||||
</Typography>
|
||||
)}
|
||||
</Box> */}
|
||||
<Box sx={{ height: 280, px: 1.5, py: 1 }}>
|
||||
<ReactECharts option={rankingOption} style={{ height: "100%", width: "100%" }} />
|
||||
</Box>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{/* 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 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: 320, px: 1, py: 1 }}>
|
||||
<Box sx={{ height: 360, px: 1, py: 1 }}>
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
rows={tableRows}
|
||||
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" },
|
||||
}}
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user