新增健康预测数据查看组件

This commit is contained in:
JIANG
2025-12-18 18:12:03 +08:00
parent f01e870725
commit 22280a0df9
4 changed files with 398 additions and 38 deletions

View File

@@ -5,13 +5,18 @@ import Timeline from "@components/olmap/HealthRiskAnalysis/Timeline";
import MapToolbar from "@app/OlMap/Controls/Toolbar"; import MapToolbar from "@app/OlMap/Controls/Toolbar";
import { HealthRiskProvider } from "@components/olmap/HealthRiskAnalysis/HealthRiskContext"; import { HealthRiskProvider } from "@components/olmap/HealthRiskAnalysis/HealthRiskContext";
import HealthRiskPieChart from "@components/olmap/HealthRiskAnalysis/HealthRiskPieChart"; import HealthRiskPieChart from "@components/olmap/HealthRiskAnalysis/HealthRiskPieChart";
import HistoryDataPanel from "@components/olmap/HealthRiskAnalysis/HistoryDataPanel";
export default function Home() { export default function Home() {
return ( return (
<div className="relative w-full h-full overflow-hidden"> <div className="relative w-full h-full overflow-hidden">
<HealthRiskProvider> <HealthRiskProvider>
<MapComponent> <MapComponent>
<MapToolbar queryType="realtime" /> <MapToolbar
queryType="realtime"
hiddenButtons={["style"]}
HistoryPanel={HistoryDataPanel}
/>
<Timeline /> <Timeline />
<HealthRiskPieChart /> <HealthRiskPieChart />
</MapComponent> </MapComponent>

View File

@@ -26,8 +26,13 @@ const backendUrl = config.BACKEND_URL;
interface ToolbarProps { interface ToolbarProps {
hiddenButtons?: string[]; // 可选的隐藏按钮列表,例如 ['info', 'draw', 'style'] hiddenButtons?: string[]; // 可选的隐藏按钮列表,例如 ['info', 'draw', 'style']
queryType?: string; // 可选的查询类型参数 queryType?: string; // 可选的查询类型参数
HistoryPanel?: React.FC<any>; // 可选的自定义历史数据面板
} }
const Toolbar: React.FC<ToolbarProps> = ({ hiddenButtons, queryType }) => { const Toolbar: React.FC<ToolbarProps> = ({
hiddenButtons,
queryType,
HistoryPanel,
}) => {
const map = useMap(); const map = useMap();
const data = useData(); const data = useData();
if (!data) return null; if (!data) return null;
@@ -654,43 +659,80 @@ const Toolbar: React.FC<ToolbarProps> = ({ hiddenButtons, queryType }) => {
setLayerStyleStates={setLayerStyleStates} setLayerStyleStates={setLayerStyleStates}
/> />
)} )}
{showHistoryPanel && ( {showHistoryPanel &&
<HistoryDataPanel (HistoryPanel ? (
featureInfos={(() => { <HistoryPanel
if (!highlightFeature || !showHistoryPanel) return []; featureInfos={(() => {
const properties = highlightFeature.getProperties(); if (!highlightFeature || !showHistoryPanel) return [];
const id = properties.id; const properties = highlightFeature.getProperties();
if (!id) return []; const id = properties.id;
if (!id) return [];
// 从图层名称推断类型 // 从图层名称推断类型
const layerId = const layerId =
highlightFeature.getId()?.toString().split(".")[0] || ""; highlightFeature.getId()?.toString().split(".")[0] || "";
let type = "unknown"; let type = "unknown";
if (layerId.includes("pipe")) { if (layerId.includes("pipe")) {
type = "pipe"; type = "pipe";
} else if (layerId.includes("junction")) { } else if (layerId.includes("junction")) {
type = "junction"; type = "junction";
} else if (layerId.includes("tank")) { } else if (layerId.includes("tank")) {
type = "tank"; type = "tank";
} else if (layerId.includes("reservoir")) { } else if (layerId.includes("reservoir")) {
type = "reservoir"; type = "reservoir";
} else if (layerId.includes("pump")) { } else if (layerId.includes("pump")) {
type = "pump"; type = "pump";
} else if (layerId.includes("valve")) { } else if (layerId.includes("valve")) {
type = "valve"; type = "valve";
} }
// 仅处理 type 为 pipe 或 junction 的情况 // 仅处理 type 为 pipe 或 junction 的情况
if (type !== "pipe" && type !== "junction") { if (type !== "pipe" && type !== "junction") {
return []; return [];
} }
return [[id, type]]; return [[id, type]];
})()} })()}
scheme_type="burst_Analysis" scheme_type="burst_Analysis"
scheme_name={schemeName} scheme_name={schemeName}
type={queryType as "realtime" | "scheme" | "none"} type={queryType as "realtime" | "scheme" | "none"}
/> />
)} ) : (
<HistoryDataPanel
featureInfos={(() => {
if (!highlightFeature || !showHistoryPanel) return [];
const properties = highlightFeature.getProperties();
const id = properties.id;
if (!id) return [];
// 从图层名称推断类型
const layerId =
highlightFeature.getId()?.toString().split(".")[0] || "";
let type = "unknown";
if (layerId.includes("pipe")) {
type = "pipe";
} else if (layerId.includes("junction")) {
type = "junction";
} else if (layerId.includes("tank")) {
type = "tank";
} else if (layerId.includes("reservoir")) {
type = "reservoir";
} else if (layerId.includes("pump")) {
type = "pump";
} else if (layerId.includes("valve")) {
type = "valve";
}
// 仅处理 type 为 pipe 或 junction 的情况
if (type !== "pipe" && type !== "junction") {
return [];
}
return [[id, type]];
})()}
scheme_type="burst_Analysis"
scheme_name={schemeName}
type={queryType as "realtime" | "scheme" | "none"}
/>
))}
{/* 图例显示 */} {/* 图例显示 */}
{activeLegendConfigs.length > 0 && ( {activeLegendConfigs.length > 0 && (

View File

@@ -175,7 +175,7 @@ const HealthRiskPieChart: React.FC = () => {
</Fade> </Fade>
<Slide direction="left" in={isExpanded} mountOnEnter unmountOnExit> <Slide direction="left" in={isExpanded} mountOnEnter unmountOnExit>
<div className="absolute top-4 right-4 bg-white shadow-2xl rounded-xl overflow-hidden w-[450px] flex flex-col backdrop-blur-sm z-[1000] opacity-95 hover:opacity-100 transition-all duration-300"> <div className="absolute top-4 right-4 bg-white shadow-2xl rounded-xl overflow-hidden w-160 flex flex-col backdrop-blur-sm z-[1000] opacity-95 hover:opacity-100 transition-all duration-300">
{/* 头部 */} {/* 头部 */}
<div className="flex justify-between items-center px-5 py-4 bg-[#257DD4] text-white"> <div className="flex justify-between items-center px-5 py-4 bg-[#257DD4] text-white">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View File

@@ -0,0 +1,313 @@
"use client";
import React, { useMemo, useRef, useState } from "react";
import Draggable from "react-draggable";
import { Box, Chip, Stack, Tab, Tabs, Typography } from "@mui/material";
import { ShowChart, TableChart } from "@mui/icons-material";
import { DataGrid, GridColDef } from "@mui/x-data-grid";
import { zhCN } from "@mui/x-data-grid/locales";
import ReactECharts from "echarts-for-react";
import "dayjs/locale/zh-cn";
import { useHealthRisk } from "./HealthRiskContext";
export interface HistoryDataPanelProps {
/** 选中的要素信息列表,格式为 [[id, type], [id, type]] */
featureInfos: [string, string][];
/** 数据类型: realtime-查询模拟值和监测值, none-仅查询监测值, scheme-查询策略模拟值和监测值 */
type?: "realtime" | "scheme" | "none";
/** 策略类型 */
scheme_type?: string;
/** 策略名称 */
scheme_name?: string;
/** 默认展示的选项卡 */
defaultTab?: "chart" | "table";
/** Y 轴数值的小数位数 */
fractionDigits?: number;
}
type PanelTab = "chart" | "table";
const HistoryDataPanel: React.FC<HistoryDataPanelProps> = ({
featureInfos,
defaultTab = "chart",
fractionDigits = 4,
}) => {
const { predictionResults } = useHealthRisk();
const [activeTab, setActiveTab] = useState<PanelTab>(defaultTab);
const draggableRef = useRef<HTMLDivElement>(null);
// 提取选中的设备 ID
const selectedIds = useMemo(
() => featureInfos.map(([id]) => id),
[featureInfos]
);
// 过滤出选中管道的预测结果
const filteredResults = useMemo(() => {
return predictionResults.filter((res) => selectedIds.includes(res.link_id));
}, [predictionResults, selectedIds]);
const hasData = filteredResults.length > 0;
// 构建表格和图表所需的数据集
const dataset = useMemo(() => {
if (filteredResults.length === 0) return [];
// 获取所有唯一的时间点并排序
const allX = Array.from(
new Set(filteredResults.flatMap((res) => res.survival_function.x))
).sort((a, b) => a - b);
return allX.map((x) => {
const row: any = { x, label: `${x}` };
filteredResults.forEach((res) => {
const index = res.survival_function.x.indexOf(x);
if (index !== -1) {
row[res.link_id] = res.survival_function.y[index];
}
});
return row;
});
}, [filteredResults]);
const columns: GridColDef[] = useMemo(() => {
const base: GridColDef[] = [
{
field: "label",
headerName: "预测时长",
minWidth: 120,
flex: 1,
},
];
const dynamic = filteredResults.map((res) => ({
field: res.link_id,
headerName: `管道 ${res.link_id}`,
minWidth: 150,
flex: 1,
valueFormatter: (value: any) => {
if (value === null || value === undefined) return "--";
return Number(value).toFixed(fractionDigits);
},
}));
return [...base, ...dynamic];
}, [filteredResults, fractionDigits]);
const rows = useMemo(
() =>
dataset.map((item, index) => ({
id: index,
...item,
})),
[dataset]
);
const renderEmpty = () => (
<Box
sx={{
flex: 1,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
py: 8,
color: "text.secondary",
height: "100%",
}}
>
<ShowChart sx={{ fontSize: 64, mb: 2, opacity: 0.3 }} />
<Typography variant="h6" gutterBottom sx={{ fontWeight: 500 }}>
</Typography>
<Typography variant="body2" color="text.secondary">
</Typography>
</Box>
);
const renderChart = () => {
if (!hasData) return renderEmpty();
const colors = [
"#1976d2",
"#dc004e",
"#ff9800",
"#4caf50",
"#9c27b0",
"#00bcd4",
"#f44336",
"#8bc34a",
"#ff5722",
"#3f51b5",
];
const xData = dataset.map((item) => item.x);
const series = filteredResults.map((res, index) => ({
name: `管道 ${res.link_id}`,
type: "line",
smooth: true,
symbol: "circle",
symbolSize: 6,
itemStyle: {
color: colors[index % colors.length],
},
data: res.survival_function.y,
}));
const option = {
tooltip: {
trigger: "axis",
formatter: (params: any) => {
let res = `${params[0].name}年<br/>`;
params.forEach((item: any) => {
res += `${item.marker} ${item.seriesName}: ${item.value.toFixed(
fractionDigits
)}<br/>`;
});
return res;
},
},
legend: {
top: "top",
type: "scroll",
},
grid: {
left: "5%",
right: "5%",
bottom: "10%",
containLabel: true,
},
xAxis: {
type: "category",
name: "年",
boundaryGap: false,
data: xData,
},
yAxis: {
type: "value",
name: "生存概率",
min: 0,
max: 1,
},
series,
};
return (
<Box sx={{ width: "100%", height: "100%" }}>
<ReactECharts
option={option}
style={{ height: "100%", width: "100%" }}
notMerge={true}
/>
</Box>
);
};
const renderTable = () => {
if (!hasData) return renderEmpty();
return (
<DataGrid
rows={rows}
columns={columns}
localeText={zhCN.components.MuiDataGrid.defaultProps.localeText}
initialState={{
pagination: {
paginationModel: { pageSize: 10, page: 0 },
},
}}
pageSizeOptions={[10, 20, 50]}
sx={{
border: "none",
height: "100%",
}}
disableRowSelectionOnClick
/>
);
};
return (
<Draggable nodeRef={draggableRef}>
<Box
ref={draggableRef}
sx={{
position: "absolute",
right: "1rem",
top: "1rem",
width: "min(800px, calc(100vw - 2rem))",
height: "600px",
borderRadius: "12px",
boxShadow: 3,
backdropFilter: "blur(8px)",
zIndex: 1300,
backgroundColor: "white",
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
>
{/* Header */}
<Box
sx={{
p: 2,
backgroundColor: "primary.main",
color: "primary.contrastText",
}}
>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
>
<Stack direction="row" spacing={1} alignItems="center">
<ShowChart fontSize="small" />
<Typography variant="h6" sx={{ fontWeight: "bold" }}>
线
</Typography>
<Chip
size="small"
label={`${filteredResults.length}`}
sx={{
backgroundColor: "rgba(255,255,255,0.2)",
color: "primary.contrastText",
}}
/>
</Stack>
</Stack>
</Box>
{/* Tabs */}
<Box sx={{ borderBottom: 1, borderColor: "divider" }}>
<Tabs
value={activeTab}
onChange={(_, value: PanelTab) => setActiveTab(value)}
variant="fullWidth"
>
<Tab
value="chart"
icon={<ShowChart fontSize="small" />}
iconPosition="start"
label="预测曲线"
/>
<Tab
value="table"
icon={<TableChart fontSize="small" />}
iconPosition="start"
label="数据表格"
/>
</Tabs>
</Box>
{/* Content */}
<Box sx={{ flex: 1, p: 2, overflow: "hidden" }}>
{activeTab === "chart" ? renderChart() : renderTable()}
</Box>
</Box>
</Draggable>
);
};
export default HistoryDataPanel;