新增水质分析功能模块

This commit is contained in:
JIANG
2026-01-30 15:22:53 +08:00
parent c28325e997
commit d584268acd
12 changed files with 346 additions and 238 deletions
@@ -22,7 +22,7 @@ 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 { Style, Stroke, Fill, Circle as CircleStyle, Icon } from "ol/style";
import Feature from "ol/Feature";
import { handleMapClickSelectFeatures as mapClickSelectFeatures } from "@/utils/mapQueryService";
@@ -31,10 +31,13 @@ const AnalysisParameters: React.FC = () => {
const { open } = useNotification();
const network = NETWORK_NAME;
const [schemeName, setSchemeName] = useState<string>(
"WQ_" + new Date().getTime(),
);
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 [concentration, setConcentration] = useState<number>(100);
const [duration, setDuration] = useState<number>(3600);
const [pattern, setPattern] = useState<string>("");
const [isSelecting, setIsSelecting] = useState<boolean>(false);
const [submitting, setSubmitting] = useState<boolean>(false);
@@ -42,7 +45,7 @@ const AnalysisParameters: React.FC = () => {
const [highlightLayer, setHighlightLayer] =
useState<VectorLayer<VectorSource> | null>(null);
const [highlightFeature, setHighlightFeature] = useState<Feature | null>(
null
null,
);
const isFormValid = useMemo(() => {
@@ -51,20 +54,41 @@ const AnalysisParameters: React.FC = () => {
Boolean(startTime) &&
Boolean(sourceNode) &&
concentration > 0 &&
duration > 0
duration > 0 &&
schemeName.trim() !== ""
);
}, [network, startTime, sourceNode, concentration, duration]);
}, [network, startTime, sourceNode, concentration, duration, schemeName]);
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 themeColor = "rgba(3, 168, 107"; // #03a86b
const sourceStyle = [
// 外层扩散光圈
new Style({
image: new CircleStyle({
radius: 12,
fill: new Fill({ color: `${themeColor}, 0.2)` }),
}),
}),
});
// 中层扩散背景
new Style({
image: new CircleStyle({
radius: 8,
stroke: new Stroke({ color: `${themeColor}, 0.5)`, width: 2 }),
fill: new Fill({ color: `${themeColor}, 0.3)` }),
}),
}),
// 上层图标
new Style({
image: new Icon({
src: "/icons/contaminant_source.svg",
scale: 0.2,
anchor: [0.5, 1],
}),
}),
];
const layer = new VectorLayer({
source: new VectorSource(),
@@ -117,7 +141,7 @@ const AnalysisParameters: React.FC = () => {
setIsSelecting(false);
map.un("click", handleMapClickSelectFeatures);
},
[map, open]
[map, open],
);
const handleStartSelection = () => {
@@ -146,15 +170,19 @@ const AnalysisParameters: React.FC = () => {
message: "方案提交分析中",
undoableTimeout: 3,
});
// 格式化开始时间,去除秒部分
const start_time = startTime
? startTime.format("YYYY-MM-DDTHH:mm:00Z")
: "";
try {
const params = {
network,
start_time: startTime.toISOString(),
start_time: start_time,
source: sourceNode,
concentration,
duration,
pattern: pattern || undefined,
scheme_name: schemeName,
};
await axios.get(`${config.BACKEND_URL}/api/v1/contaminant_simulation/`, {
@@ -297,13 +325,14 @@ const AnalysisParameters: React.FC = () => {
<Box className="mb-4">
<Typography variant="subtitle2" className="mb-2 font-medium">
</Typography>
<TextField
fullWidth
size="small"
value={network}
disabled
value={schemeName}
onChange={(e) => setSchemeName(e.target.value)}
placeholder="输入方案名称"
/>
</Box>
@@ -31,24 +31,36 @@ import { useNotification } from "@refinedev/core";
import { config, NETWORK_NAME } from "@config/config";
import { queryFeaturesByIds } from "@/utils/mapQueryService";
import { useData, useMap } from "@app/OlMap/MapComponent";
import { GeoJSON } from "ol/format";
import VectorLayer from "ol/layer/Vector";
import VectorSource from "ol/source/Vector";
import { Style, Icon, Circle, Fill, Stroke } from "ol/style";
import Feature from "ol/Feature";
import { bbox, featureCollection } from "@turf/turf";
import Timeline from "@app/OlMap/Controls/Timeline";
import { ContaminantSchemaItem, ContaminantSchemeRecord } from "./types";
interface SchemeQueryProps {
schemes?: ContaminantSchemeRecord[];
onSchemesChange?: (schemes: ContaminantSchemeRecord[]) => void;
onViewResults?: () => void;
network?: string;
}
const SCHEME_TYPE = "contaminant_simulation";
const SCHEME_TYPE = "contaminant_analysis";
const SchemeQuery: React.FC<SchemeQueryProps> = ({
schemes: externalSchemes,
onSchemesChange,
onViewResults,
network = NETWORK_NAME,
}) => {
const [queryAll, setQueryAll] = useState<boolean>(true);
const [queryDate, setQueryDate] = useState<Dayjs | null>(dayjs(new Date()));
const [highlightLayer, setHighlightLayer] =
useState<VectorLayer<VectorSource> | null>(null);
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
const [showTimeline, setShowTimeline] = useState(false);
const [selectedDate, setSelectedDate] = useState<Date | undefined>(undefined);
const [timeRange, setTimeRange] = useState<
@@ -64,8 +76,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
const { open } = useNotification();
const map = useMap();
const data = useData();
const { schemeName, setSchemeName, setJunctionText, setPipeText } =
data || {};
const { schemeName, setSchemeName } = data || {};
const schemes =
externalSchemes !== undefined ? externalSchemes : internalSchemes;
@@ -79,6 +90,83 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
}
}, [map]);
// 初始化高亮图层
useEffect(() => {
if (!map) return;
const themeColor = "rgba(3, 168, 107"; // #03a86b
const sourceStyle = [
// 外层扩散光圈
new Style({
image: new Circle({
radius: 12,
fill: new Fill({
color: `${themeColor}, 0.2)`,
}),
}),
}),
// 中层扩散背景
new Style({
image: new Circle({
radius: 8,
stroke: new Stroke({
color: `${themeColor}, 0.5)`,
width: 2,
}),
fill: new Fill({
color: `${themeColor}, 0.3)`,
}),
}),
}),
// 上层图标
new Style({
image: new Icon({
src: "/icons/contaminant_source.svg",
scale: 0.2,
anchor: [0.5, 1],
}),
}),
];
const highlightLayer = new VectorLayer({
source: new VectorSource(),
style: sourceStyle,
maxZoom: 24,
minZoom: 12,
properties: {
name: "污染源高亮",
value: "contaminant_source_highlight",
},
});
map.addLayer(highlightLayer);
setHighlightLayer(highlightLayer);
return () => {
map.removeLayer(highlightLayer);
};
}, [map]);
// 高亮要素的函数
useEffect(() => {
if (!highlightLayer) {
return;
}
const source = highlightLayer.getSource();
if (!source) {
return;
}
// 清除之前的高亮
source.clear();
// 添加新的高亮要素
highlightFeatures.forEach((feature) => {
if (feature instanceof Feature) {
source.addFeature(feature);
}
});
}, [highlightFeatures, highlightLayer]);
const formatTime = (timeStr: string) => {
const time = moment(timeStr);
return time.format("MM-DD");
@@ -93,16 +181,18 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
setLoading(true);
try {
const response = await axios.get(
`${config.BACKEND_URL}/api/v1/getallschemes/?network=${network}`
`${config.BACKEND_URL}/api/v1/getallschemes/?network=${network}`,
);
let filteredResults = response.data;
if (!queryAll) {
const formattedDate = queryDate!.format("YYYY-MM-DD");
filteredResults = response.data.filter((item: ContaminantSchemaItem) => {
const itemDate = moment(item.create_time).format("YYYY-MM-DD");
return itemDate === formattedDate;
});
filteredResults = response.data.filter(
(item: ContaminantSchemaItem) => {
const itemDate = moment(item.create_time).format("YYYY-MM-DD");
return itemDate === formattedDate;
},
);
}
setSchemes(
@@ -114,7 +204,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
create_time: item.create_time,
startTime: item.scheme_start_time,
schemeDetail: item.scheme_detail,
}))
})),
);
if (filteredResults.length === 0) {
@@ -138,19 +228,29 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
}
};
const handleLocateSource = (sourceId?: string) => {
if (!sourceId) return;
queryFeaturesByIds([sourceId], "geo_junctions_mat").then((features) => {
if (features.length > 0) {
const extent = features[0].getGeometry()?.getExtent();
if (extent) {
map?.getView().fit(extent, {
maxZoom: 18,
duration: 1000,
});
const handleLocateSource = (sourceIds: string[]) => {
if (sourceIds.length > 0) {
queryFeaturesByIds(sourceIds, "geo_junctions_mat").then((features) => {
if (features.length > 0) {
// 设置高亮要素
setHighlightFeatures(features);
// 将 OpenLayers Feature 转换为 GeoJSON Feature
const geojsonFormat = new GeoJSON();
const geojsonFeatures = features.map((feature) =>
geojsonFormat.writeFeatureObject(feature),
);
const extent = bbox(featureCollection(geojsonFeatures as any));
if (extent) {
map?.getView().fit(extent, {
maxZoom: 18,
duration: 1000,
});
}
}
}
});
});
}
};
const handleViewDetails = (id: number) => {
@@ -158,17 +258,21 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
if (!scheme) return;
setShowTimeline(true);
const schemeDate = scheme.startTime ? new Date(scheme.startTime) : undefined;
const schemeDate = scheme.startTime
? new Date(scheme.startTime)
: undefined;
if (scheme.startTime && scheme.schemeDetail?.duration) {
const start = new Date(scheme.startTime);
const end = new Date(start.getTime() + scheme.schemeDetail.duration * 1000);
const end = new Date(
start.getTime() + scheme.schemeDetail.duration * 1000,
);
setSelectedDate(schemeDate);
setTimeRange({ start, end });
}
setSchemeName?.(scheme.schemeName);
setJunctionText?.("quality");
setPipeText?.("quality");
handleLocateSource(scheme.schemeDetail?.source);
if (scheme.schemeDetail?.source) {
handleLocateSource([scheme.schemeDetail.source]);
}
};
return (
@@ -183,7 +287,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
schemeName={schemeName}
schemeType={SCHEME_TYPE}
/>,
mapContainer
mapContainer,
)}
<Box className="flex flex-col h-full">
<Box className="mb-2 p-2 bg-gray-50 rounded">
@@ -291,7 +395,11 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
{scheme.schemeName}
</Typography>
<Chip
label={scheme.type}
label={
scheme.type === "contaminant_analysis"
? "水质模拟"
: scheme.type
}
size="small"
className="h-5"
color="primary"
@@ -302,18 +410,21 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
variant="caption"
className="text-gray-500 block"
>
ID: {scheme.id} · : {formatTime(scheme.create_time)}
ID: {scheme.id} · :{" "}
{formatTime(scheme.create_time)}
</Typography>
</Box>
<Box className="flex gap-1 ml-2">
<Tooltip
title={expandedId === scheme.id ? "收起详情" : "查看详情"}
title={
expandedId === scheme.id ? "收起详情" : "查看详情"
}
>
<IconButton
size="small"
onClick={() =>
setExpandedId(
expandedId === scheme.id ? null : scheme.id
expandedId === scheme.id ? null : scheme.id,
)
}
color="primary"
@@ -322,16 +433,19 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
<InfoIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="定位污染源">
{/* <Tooltip title="定位污染源">
<IconButton
size="small"
onClick={() => handleLocateSource(scheme.schemeDetail?.source)}
onClick={() =>
scheme.schemeDetail?.source &&
handleLocateSource([scheme.schemeDetail.source])
}
color="primary"
className="p-1"
>
<LocationIcon fontSize="small" />
</IconButton>
</Tooltip>
</Tooltip> */}
</Box>
</Box>
@@ -355,7 +469,9 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
className="font-medium text-blue-600 hover:text-blue-800 underline cursor-pointer"
onClick={(e) => {
e.preventDefault();
handleLocateSource(scheme.schemeDetail?.source);
handleLocateSource([
scheme.schemeDetail!.source!,
]);
}}
>
{scheme.schemeDetail.source}
@@ -381,7 +497,8 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
variant="caption"
className="font-medium text-gray-900"
>
{scheme.schemeDetail?.concentration ?? "N/A"} mg/L
{scheme.schemeDetail?.concentration ?? "N/A"}{" "}
mg/L
</Typography>
</Box>
<Box className="flex items-center gap-2">
@@ -443,7 +560,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
className="font-medium text-gray-900"
>
{moment(scheme.create_time).format(
"YYYY-MM-DD HH:mm"
"YYYY-MM-DD HH:mm",
)}
</Typography>
</Box>
@@ -459,7 +576,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
className="font-medium text-gray-900"
>
{moment(scheme.startTime).format(
"YYYY-MM-DD HH:mm"
"YYYY-MM-DD HH:mm",
)}
</Typography>
</Box>
@@ -473,7 +590,10 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
fullWidth
size="small"
className="border-blue-600 text-blue-600 hover:bg-blue-50"
onClick={() => handleLocateSource(scheme.schemeDetail?.source)}
onClick={() =>
scheme.schemeDetail?.source &&
handleLocateSource([scheme.schemeDetail.source])
}
sx={{
textTransform: "none",
fontWeight: 500,
@@ -3,7 +3,6 @@ export interface ContaminantSchemeDetail {
concentration: number;
duration: number;
pattern?: string | null;
start_time?: string;
}
export interface ContaminantSchemeRecord {