368 lines
11 KiB
TypeScript
368 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|
import {
|
|
Box,
|
|
Button,
|
|
IconButton,
|
|
Stack,
|
|
TextField,
|
|
Typography,
|
|
} from "@mui/material";
|
|
import { Close as CloseIcon } from "@mui/icons-material";
|
|
import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker";
|
|
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
|
|
import { zhCN as pickerZhCN } from "@mui/x-date-pickers/locales";
|
|
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
|
import "dayjs/locale/zh-cn";
|
|
import dayjs, { Dayjs } from "dayjs";
|
|
import { useNotification } from "@refinedev/core";
|
|
import axios from "axios";
|
|
import { config, NETWORK_NAME } from "@/config/config";
|
|
import { useMap } from "@app/OlMap/MapComponent";
|
|
import VectorLayer from "ol/layer/Vector";
|
|
import VectorSource from "ol/source/Vector";
|
|
import { Style, Stroke, Fill, Circle as CircleStyle } from "ol/style";
|
|
import Feature from "ol/Feature";
|
|
import { handleMapClickSelectFeatures as mapClickSelectFeatures } from "@/utils/mapQueryService";
|
|
|
|
const AnalysisParameters: React.FC = () => {
|
|
const map = useMap();
|
|
const { open } = useNotification();
|
|
|
|
const network = NETWORK_NAME;
|
|
const [startTime, setStartTime] = useState<Dayjs | null>(dayjs(new Date()));
|
|
const [sourceNode, setSourceNode] = useState<string>("");
|
|
const [concentration, setConcentration] = useState<number>(1);
|
|
const [duration, setDuration] = useState<number>(900);
|
|
const [pattern, setPattern] = useState<string>("");
|
|
const [isSelecting, setIsSelecting] = useState<boolean>(false);
|
|
const [submitting, setSubmitting] = useState<boolean>(false);
|
|
|
|
const [highlightLayer, setHighlightLayer] =
|
|
useState<VectorLayer<VectorSource> | null>(null);
|
|
const [highlightFeature, setHighlightFeature] = useState<Feature | null>(
|
|
null
|
|
);
|
|
|
|
const isFormValid = useMemo(() => {
|
|
return (
|
|
Boolean(network) &&
|
|
Boolean(startTime) &&
|
|
Boolean(sourceNode) &&
|
|
concentration > 0 &&
|
|
duration > 0
|
|
);
|
|
}, [network, startTime, sourceNode, concentration, duration]);
|
|
|
|
useEffect(() => {
|
|
if (!map) return;
|
|
|
|
const sourceStyle = new Style({
|
|
image: new CircleStyle({
|
|
radius: 10,
|
|
fill: new Fill({ color: "rgba(37, 125, 212, 0.35)" }),
|
|
stroke: new Stroke({ color: "rgba(37, 125, 212, 1)", width: 3 }),
|
|
}),
|
|
});
|
|
|
|
const layer = new VectorLayer({
|
|
source: new VectorSource(),
|
|
style: sourceStyle,
|
|
properties: {
|
|
name: "污染源节点",
|
|
value: "contaminant_source_highlight",
|
|
},
|
|
});
|
|
|
|
map.addLayer(layer);
|
|
setHighlightLayer(layer);
|
|
|
|
return () => {
|
|
map.removeLayer(layer);
|
|
map.un("click", handleMapClickSelectFeatures);
|
|
};
|
|
}, [map]);
|
|
|
|
useEffect(() => {
|
|
if (!highlightLayer) return;
|
|
const source = highlightLayer.getSource();
|
|
if (!source) return;
|
|
source.clear();
|
|
if (highlightFeature) {
|
|
source.addFeature(highlightFeature);
|
|
}
|
|
}, [highlightFeature, highlightLayer]);
|
|
|
|
const handleMapClickSelectFeatures = useCallback(
|
|
async (event: { coordinate: number[] }) => {
|
|
if (!map) return;
|
|
const feature = await mapClickSelectFeatures(event, map);
|
|
if (!feature) return;
|
|
|
|
const layerId = feature.getId()?.toString().split(".")[0] || "";
|
|
const isJunction = layerId.includes("junction");
|
|
if (!isJunction) {
|
|
open?.({
|
|
type: "error",
|
|
message: "请选择节点类型要素作为污染源。",
|
|
});
|
|
return;
|
|
}
|
|
|
|
const id = feature.getProperties().id;
|
|
if (!id) return;
|
|
setSourceNode(id);
|
|
setHighlightFeature(feature);
|
|
setIsSelecting(false);
|
|
map.un("click", handleMapClickSelectFeatures);
|
|
},
|
|
[map, open]
|
|
);
|
|
|
|
const handleStartSelection = () => {
|
|
if (!map) return;
|
|
setIsSelecting(true);
|
|
map.on("click", handleMapClickSelectFeatures);
|
|
};
|
|
|
|
const handleEndSelection = () => {
|
|
if (!map) return;
|
|
setIsSelecting(false);
|
|
map.un("click", handleMapClickSelectFeatures);
|
|
};
|
|
|
|
const handleClearSource = () => {
|
|
setSourceNode("");
|
|
setHighlightFeature(null);
|
|
};
|
|
|
|
const handleAnalyze = async () => {
|
|
if (!startTime) return;
|
|
setSubmitting(true);
|
|
open?.({
|
|
key: "contaminant-analysis",
|
|
type: "progress",
|
|
message: "方案提交分析中",
|
|
undoableTimeout: 3,
|
|
});
|
|
|
|
try {
|
|
const params = {
|
|
network,
|
|
start_time: startTime.toISOString(),
|
|
source: sourceNode,
|
|
concentration,
|
|
duration,
|
|
pattern: pattern || undefined,
|
|
};
|
|
|
|
await axios.get(`${config.BACKEND_URL}/api/v1/contaminant_simulation/`, {
|
|
params,
|
|
});
|
|
|
|
open?.({
|
|
key: "contaminant-analysis",
|
|
type: "success",
|
|
message: "方案分析成功",
|
|
description: "水质模拟完成,请在方案查询中查看结果。",
|
|
});
|
|
} catch (error) {
|
|
console.error("水质模拟请求失败:", error);
|
|
open?.({
|
|
key: "contaminant-analysis",
|
|
type: "error",
|
|
message: "提交分析失败",
|
|
description:
|
|
error instanceof Error ? error.message : "请检查网络连接或稍后重试",
|
|
});
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Box className="flex flex-col h-full">
|
|
<Box className="mb-4">
|
|
<Box className="flex items-center justify-between mb-2">
|
|
<Typography variant="subtitle2" className="font-medium">
|
|
选择污染源节点
|
|
</Typography>
|
|
{!isSelecting ? (
|
|
<Button
|
|
variant="outlined"
|
|
size="small"
|
|
onClick={handleStartSelection}
|
|
className="border-blue-500 text-blue-600 hover:bg-blue-50"
|
|
startIcon={
|
|
<svg
|
|
className="w-4 h-4"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5M7.188 2.239l.777 2.897M5.136 7.965l-2.898-.777M13.95 4.05l-2.122 2.122m-5.657 5.656l-2.12 2.122"
|
|
/>
|
|
</svg>
|
|
}
|
|
>
|
|
开始选择
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
variant="contained"
|
|
size="small"
|
|
onClick={handleEndSelection}
|
|
className="bg-red-500 hover:bg-red-600"
|
|
startIcon={
|
|
<svg
|
|
className="w-4 h-4"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M6 18L18 6M6 6l12 12"
|
|
/>
|
|
</svg>
|
|
}
|
|
>
|
|
结束选择
|
|
</Button>
|
|
)}
|
|
</Box>
|
|
|
|
{isSelecting && (
|
|
<Box className="mb-2 p-2 bg-blue-50 border border-blue-200 rounded text-xs text-blue-700">
|
|
💡 点击地图上的节点作为污染源
|
|
</Box>
|
|
)}
|
|
|
|
<Stack spacing={2}>
|
|
{sourceNode ? (
|
|
<Box className="flex items-center gap-2 p-2 bg-gray-50 rounded">
|
|
<Typography className="flex-shrink-0 text-sm">
|
|
{sourceNode}
|
|
</Typography>
|
|
<Typography className="flex-shrink-0 text-sm text-gray-600">
|
|
污染源节点
|
|
</Typography>
|
|
<IconButton
|
|
size="small"
|
|
onClick={handleClearSource}
|
|
className="ml-auto"
|
|
>
|
|
<CloseIcon fontSize="small" />
|
|
</IconButton>
|
|
</Box>
|
|
) : (
|
|
<Box className="p-3 rounded border border-dashed border-gray-200 text-sm text-gray-400">
|
|
暂未选择污染源节点
|
|
</Box>
|
|
)}
|
|
</Stack>
|
|
</Box>
|
|
|
|
<Box className="mb-4">
|
|
<Typography variant="subtitle2" className="mb-2 font-medium">
|
|
选择开始时间
|
|
</Typography>
|
|
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn">
|
|
<DateTimePicker
|
|
value={startTime}
|
|
onChange={(value) =>
|
|
value && dayjs.isDayjs(value) && setStartTime(value)
|
|
}
|
|
format="YYYY-MM-DD HH:mm"
|
|
slotProps={{
|
|
textField: {
|
|
size: "small",
|
|
fullWidth: true,
|
|
},
|
|
}}
|
|
localeText={
|
|
pickerZhCN.components.MuiLocalizationProvider.defaultProps
|
|
.localeText
|
|
}
|
|
/>
|
|
</LocalizationProvider>
|
|
</Box>
|
|
|
|
<Box className="mb-4">
|
|
<Typography variant="subtitle2" className="mb-2 font-medium">
|
|
管网名称
|
|
</Typography>
|
|
<TextField
|
|
fullWidth
|
|
size="small"
|
|
value={network}
|
|
disabled
|
|
/>
|
|
</Box>
|
|
|
|
<Box className="mb-4">
|
|
<Typography variant="subtitle2" className="mb-2 font-medium">
|
|
污染源浓度 (mg/L)
|
|
</Typography>
|
|
<TextField
|
|
fullWidth
|
|
size="small"
|
|
type="number"
|
|
value={concentration}
|
|
onChange={(e) => setConcentration(parseFloat(e.target.value) || 0)}
|
|
placeholder="输入浓度"
|
|
/>
|
|
</Box>
|
|
|
|
<Box className="mb-4">
|
|
<Typography variant="subtitle2" className="mb-2 font-medium">
|
|
持续时长 (秒)
|
|
</Typography>
|
|
<TextField
|
|
fullWidth
|
|
size="small"
|
|
type="number"
|
|
value={duration}
|
|
onChange={(e) => setDuration(parseInt(e.target.value, 10) || 0)}
|
|
placeholder="输入持续时长"
|
|
/>
|
|
</Box>
|
|
|
|
<Box className="mb-4">
|
|
<Typography variant="subtitle2" className="mb-2 font-medium">
|
|
时间模式
|
|
</Typography>
|
|
<TextField
|
|
fullWidth
|
|
size="small"
|
|
value={pattern}
|
|
onChange={(e) => setPattern(e.target.value)}
|
|
placeholder="可选,输入 pattern 名称"
|
|
/>
|
|
</Box>
|
|
|
|
<Box className="mt-auto">
|
|
<Button
|
|
fullWidth
|
|
variant="contained"
|
|
size="large"
|
|
onClick={handleAnalyze}
|
|
disabled={submitting || !isFormValid}
|
|
className="bg-blue-600 hover:bg-blue-700"
|
|
>
|
|
{submitting ? "方案提交分析中..." : "水质模拟"}
|
|
</Button>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
export default AnalysisParameters;
|