diff --git a/src/app/(main)/burst-pipe-analysis/page.tsx b/src/app/(main)/burst-pipe-analysis/page.tsx new file mode 100644 index 0000000..ebc6433 --- /dev/null +++ b/src/app/(main)/burst-pipe-analysis/page.tsx @@ -0,0 +1,16 @@ +"use client"; + +import MapComponent from "@app/OlMap/MapComponent"; +import MapToolbar from "@app/OlMap/Controls/Toolbar"; +import BurstPipeAnalysisPanel from "@/components/olmap/BurstPipeAnalysisPanel"; + +export default function Home() { + return ( +
+ + {/* */} + + +
+ ); +} diff --git a/src/app/(main)/network-simulation/page.tsx b/src/app/(main)/network-simulation/page.tsx index c19d6e3..f65129e 100644 --- a/src/app/(main)/network-simulation/page.tsx +++ b/src/app/(main)/network-simulation/page.tsx @@ -78,7 +78,7 @@ export default function Home() {
-
+
diff --git a/src/app/OlMap/Controls/StyleEditorPanel.tsx b/src/app/OlMap/Controls/StyleEditorPanel.tsx index a92167a..9bce045 100644 --- a/src/app/OlMap/Controls/StyleEditorPanel.tsx +++ b/src/app/OlMap/Controls/StyleEditorPanel.tsx @@ -29,6 +29,7 @@ import { calculateClassification } from "@utils/breaks_classification"; import { parseColor } from "@utils/parseColor"; import { VectorTile } from "ol"; import { useNotification } from "@refinedev/core"; +import { config } from "@/config/config"; interface StyleConfig { property: string; @@ -64,6 +65,9 @@ const SINGLE_COLOR_PALETTES = [ { color: "rgba(51, 153, 204, 1)", }, + { + color: "rgba(255, 138, 92, 1)", + }, { color: "rgba(204, 51, 51, 1)", }, @@ -405,6 +409,25 @@ const StyleEditorPanel: React.FC = () => { conditions.push(dimensions[i]); } conditions.push(dimensions[dimensions.length - 1]); + console.log("生成的尺寸条件表达式:", conditions); + return conditions; + }; + const generateDimensionPointConditions = (property: string): any[] => { + const conditions: any[] = ["case"]; + for (let i = 0; i < breaks.length; i++) { + conditions.push(["<=", ["get", property], breaks[i]]); + conditions.push([ + "interpolate", + ["linear"], + ["zoom"], + 12, + 1, // 使用配置的最小尺寸 + 24, + dimensions[i], + ]); + } + conditions.push(dimensions[dimensions.length - 1]); + console.log("生成的点尺寸条件表达式:", conditions); return conditions; }; // 创建基于 breaks 的动态 FlatStyle @@ -422,7 +445,7 @@ const StyleEditorPanel: React.FC = () => { dynamicStyle["circle-fill-color"] = generateColorConditions( styleConfig.property ); - dynamicStyle["circle-radius"] = generateDimensionConditions( + dynamicStyle["circle-radius"] = generateDimensionPointConditions( styleConfig.property ); dynamicStyle["circle-stroke-color"] = generateColorConditions( @@ -456,21 +479,7 @@ const StyleEditorPanel: React.FC = () => { const resetStyle = useCallback(() => { if (!selectedRenderLayer) return; // 重置 WebGL 图层样式 - const defaultFlatStyle: FlatStyleLike = { - "stroke-width": 3, - "stroke-color": "rgba(51, 153, 204, 0.9)", - "circle-fill-color": "rgba(255,255,255,0.4)", - "circle-stroke-color": "rgba(255,255,255,0.9)", - "circle-radius": [ - "interpolate", - ["linear"], - ["zoom"], - 12, - 1, // 在缩放级别 12 时,圆形半径为 1px - 24, - 12, // 在缩放级别 24 时,圆形半径为 12px - ], - }; + const defaultFlatStyle: FlatStyleLike = config.mapDefaultStyle; selectedRenderLayer.setStyle(defaultFlatStyle); // 删除对应图层的样式状态,从而移除图例显示 @@ -531,6 +540,7 @@ const StyleEditorPanel: React.FC = () => { const [tileLoadListeners, setTileLoadListeners] = useState< Map void> >(new Map()); + const attachVectorTileSourceLoadedEvent = ( layerId: string, property: string, diff --git a/src/app/OlMap/Controls/Timeline.tsx b/src/app/OlMap/Controls/Timeline.tsx index b0d6b83..135b39b 100644 --- a/src/app/OlMap/Controls/Timeline.tsx +++ b/src/app/OlMap/Controls/Timeline.tsx @@ -23,6 +23,7 @@ import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFns"; import { zhCN } from "date-fns/locale"; import { PlayArrow, Pause, Stop, Refresh } from "@mui/icons-material"; import { TbRewindBackward15, TbRewindForward15 } from "react-icons/tb"; +import { FiSkipBack, FiSkipForward } from "react-icons/fi"; import { useData } from "../MapComponent"; import { config } from "@/config/config"; import { useMap } from "../MapComponent"; @@ -210,7 +211,7 @@ const Timeline: React.FC = () => { type: "error", message: "请至少设定并应用一个图层的样式。", }); - return; + // return; } setIsPlaying(true); @@ -233,7 +234,10 @@ const Timeline: React.FC = () => { const handleStop = useCallback(() => { setIsPlaying(false); - setCurrentTime(0); + // 设置为当前时间 + const currentTime = new Date(); + const minutes = currentTime.getHours() * 60 + currentTime.getMinutes(); + setCurrentTime(minutes); // 组件卸载时重置时间 if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; @@ -241,6 +245,20 @@ const Timeline: React.FC = () => { }, []); // 步进控制 + const handleDayStepBackward = useCallback(() => { + setSelectedDate((prev) => { + const newDate = new Date(prev); + newDate.setDate(newDate.getDate() - 1); + return newDate; + }); + }, []); + const handleDayStepForward = useCallback(() => { + setSelectedDate((prev) => { + const newDate = new Date(prev); + newDate.setDate(newDate.getDate() + 1); + return newDate; + }); + }, []); const handleStepBackward = useCallback(() => { setCurrentTime((prev) => { const next = prev <= 0 ? 1440 : prev - 15; @@ -299,7 +317,7 @@ const Timeline: React.FC = () => { type: "error", message: "请至少设定并应用一个图层的样式。", }); - return; + // return; } fetchFrameData( currentTimeToDate(selectedDate, currentTime), @@ -311,7 +329,12 @@ const Timeline: React.FC = () => { // 组件卸载时清理定时器和防抖 useEffect(() => { - setCurrentTime(0); // 组件卸载时重置时间 + // 设置为当前时间 + const currentTime = new Date(); + const minutes = currentTime.getHours() * 60 + currentTime.getMinutes(); + // 找到最近的前15分钟刻度 + const roundedMinutes = Math.floor(minutes / 15) * 15; + setCurrentTime(roundedMinutes); // 组件卸载时重置时间 return () => { if (intervalRef.current) { @@ -363,6 +386,15 @@ const Timeline: React.FC = () => { alignItems="center" sx={{ mb: 2, flexWrap: "wrap", gap: 1 }} > + + + + + {/* 日期选择器 */} { sx={{ width: 180, "& .MuiInputBase-root": { height: 40 } }} maxDate={new Date()} // 禁止选取未来的日期 /> - + + + + + {/* 播放控制按钮 */} - + {/* 播放间隔选择 */} 播放间隔 @@ -434,7 +477,7 @@ const Timeline: React.FC = () => { - + {/* 强制计算时间段 */} 计算时间段 diff --git a/src/app/OlMap/Controls/Toolbar.tsx b/src/app/OlMap/Controls/Toolbar.tsx index 7353b07..33dc959 100644 --- a/src/app/OlMap/Controls/Toolbar.tsx +++ b/src/app/OlMap/Controls/Toolbar.tsx @@ -21,6 +21,7 @@ import VectorTileSource from "ol/source/VectorTile"; import TileState from "ol/TileState"; import { toLonLat } from "ol/proj"; import { booleanIntersects, buffer, point, toWgs84 } from "@turf/turf"; +// import { handleMapClickSelectFeatures as mapClickSelectFeatures } from "@/utils/mapQueryService"; import RenderFeature from "ol/render/Feature"; import { config } from "@/config/config"; @@ -329,7 +330,13 @@ const Toolbar: React.FC = ({ hiddenButtons }) => { }, [map, highlightLayer, setHighlightFeature] ); - + // const handleMapClickSelectFeatures = useCallback( + // (event: { coordinate: number[] }) => { + // if (!map) return; + // mapClickSelectFeatures(event, map, setHighlightFeature); // 调用导入的函数 + // }, + // [map, setHighlightFeature] + // ); // 添加矢量属性查询事件监听器 useEffect(() => { if (!activeTools.includes("info") || !map) return; diff --git a/src/app/OlMap/MapComponent.tsx b/src/app/OlMap/MapComponent.tsx index 65ec286..2c6992f 100644 --- a/src/app/OlMap/MapComponent.tsx +++ b/src/app/OlMap/MapComponent.tsx @@ -104,9 +104,9 @@ const MapComponent: React.FC = ({ children }) => { const [map, setMap] = useState(); // currentCalData 用于存储当前计算结果 - const [currentTime, setCurrentTime] = useState(-1); // 默认-1表示未选择时间 - const [selectedDate, setSelectedDate] = useState(new Date("2025-9-17")); - // const [selectedDate, setSelectedDate] = useState(new Date()); // 默认今天 + const [currentTime, setCurrentTime] = useState(-1); // 默认选择当前时间 + // const [selectedDate, setSelectedDate] = useState(new Date("2025-9-17")); + const [selectedDate, setSelectedDate] = useState(new Date()); // 默认今天 const [currentJunctionCalData, setCurrentJunctionCalData] = useState( [] @@ -174,21 +174,7 @@ const MapComponent: React.FC = ({ children }) => { setPipeDataState((prev) => [...prev, ...uniqueNewData]); } }; - const defaultFlatStyle: FlatStyleLike = { - "stroke-width": 3, - "stroke-color": "rgba(51, 153, 204, 0.9)", - "circle-fill-color": "rgba(255,255,255,0.4)", - "circle-stroke-color": "rgba(255,255,255,0.9)", - "circle-radius": [ - "interpolate", - ["linear"], - ["zoom"], - 12, - 1, // 在缩放级别 12 时,圆形半径为 1px - 24, - 12, // 在缩放级别 24 时,圆形半径为 12px - ], - }; + const defaultFlatStyle: FlatStyleLike = config.mapDefaultStyle; // 矢量瓦片数据源和图层 const junctionSource = new VectorTileSource({ url: `${mapUrl}/gwc/service/tms/1.0.0/TJWater:geo_junctions_mat@WebMercatorQuad@pbf/{z}/{x}/{-y}.pbf`, // 替换为你的 MVT 瓦片服务 URL @@ -435,9 +421,9 @@ const MapComponent: React.FC = ({ children }) => { fontFamily: "Monaco, monospace", getText: (d: any) => d[junctionText] ? (d[junctionText] as number).toFixed(3) : "", - getSize: 14, + getSize: 18, fontWeight: "bold", - getColor: [150, 150, 255], + getColor: [255, 138, 92], getAngle: 0, getTextAnchor: "middle", getAlignmentBaseline: "center", @@ -455,8 +441,8 @@ const MapComponent: React.FC = ({ children }) => { fontSize: 64, buffer: 6, }, - outlineWidth: 10, - outlineColor: [255, 255, 255, 255], + // outlineWidth: 10, + // outlineColor: [242, 244, 246, 255], }), new TextLayer({ id: "pipeTextLayer", @@ -466,9 +452,9 @@ const MapComponent: React.FC = ({ children }) => { fontFamily: "Monaco, monospace", getText: (d: any) => d[pipeText] ? (d[pipeText] as number).toFixed(3) : "", - getSize: 12, + getSize: 18, fontWeight: "bold", - getColor: [120, 128, 181], + getColor: [51, 153, 204], getAngle: (d: any) => d.angle || 0, getPixelOffset: [0, -8], getTextAnchor: "middle", @@ -485,8 +471,8 @@ const MapComponent: React.FC = ({ children }) => { fontSize: 64, buffer: 6, }, - outlineWidth: 10, - outlineColor: [255, 255, 255, 255], + // outlineWidth: 10, + // outlineColor: [242, 244, 246, 255], }), ]; deck.setProps({ layers: newLayers }); diff --git a/src/components/olmap/BurstPipeAnalysis/AnalysisParameters.tsx b/src/components/olmap/BurstPipeAnalysis/AnalysisParameters.tsx new file mode 100644 index 0000000..9dc087c --- /dev/null +++ b/src/components/olmap/BurstPipeAnalysis/AnalysisParameters.tsx @@ -0,0 +1,371 @@ +"use client"; + +import React, { useState, useRef, useEffect } from "react"; +import { + Box, + TextField, + Button, + Typography, + IconButton, + Stack, +} 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 { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; +import dayjs, { Dayjs } from "dayjs"; +import "dayjs/locale/zh-cn"; +import { useMap } from "@app/OlMap/MapComponent"; +import VectorLayer from "ol/layer/Vector"; +import VectorSource from "ol/source/Vector"; +import Style from "ol/style/Style"; +import Stroke from "ol/style/Stroke"; +import GeoJson from "ol/format/GeoJSON"; +import config from "@config/config"; +import type { Feature } from "ol"; +import type { Geometry } from "ol/geom"; + +const mapUrl = config.mapUrl; + +interface PipePoint { + id: string; + diameter: number; + area: number; + feature?: any; // 存储管道要素用于高亮 +} + +interface AnalysisParametersProps { + onAnalyze?: (params: AnalysisParams) => void; +} + +interface AnalysisParams { + pipePoints: PipePoint[]; + startTime: Dayjs | null; + duration: number; + schemeName: string; +} + +const AnalysisParameters: React.FC = ({ + onAnalyze, +}) => { + const map = useMap(); + + const [pipePoints, setPipePoints] = useState([ + { id: "541022", diameter: 110, area: 15 }, + { id: "532748", diameter: 110, area: 15 }, + ]); + const [startTime, setStartTime] = useState( + dayjs("2025-10-21T00:00:00") + ); + const [duration, setDuration] = useState(3000); + const [schemeName, setSchemeName] = useState("Fangan1021100506"); + const [isSelecting, setIsSelecting] = useState(false); + + const highlightLayerRef = useRef | null>(null); + const clickListenerRef = useRef<((evt: any) => void) | null>(null); + + // 初始化管道图层和高亮图层 + useEffect(() => { + if (!map) return; + + // 创建高亮图层 + const highlightLayer = new VectorLayer({ + source: new VectorSource(), + style: new Style({ + stroke: new Stroke({ + color: "#ff0000", + width: 5, + }), + }), + properties: { + name: "高亮管道", + value: "highlight_pipeline", + }, + zIndex: 999, + }); + + map.addLayer(highlightLayer); + highlightLayerRef.current = highlightLayer; + + return () => { + map.removeLayer(highlightLayer); + if (clickListenerRef.current) { + map.un("click", clickListenerRef.current); + } + }; + }, [map]); + + // 开始选择管道 + const handleStartSelection = () => { + if (!map) return; + + setIsSelecting(true); + // 显示管道图层 + + // 注册点击事件 + const clickListener = (evt: any) => { + let clickedFeature: any = null; + + map.forEachFeatureAtPixel( + evt.pixel, + (feature) => { + if (!clickedFeature) { + clickedFeature = feature; + } + return true; + }, + { hitTolerance: 5 } + ); + + if (clickedFeature) { + const properties = clickedFeature.getProperties(); + const pipeId = properties.Id || properties.id || properties.ID; + const diameter = properties.Diameter || properties.diameter || 100; + + // 检查是否已存在 + const exists = pipePoints.some((pipe) => pipe.id === pipeId); + if (!exists && pipeId) { + const newPipe: PipePoint = { + id: String(pipeId), + diameter: Number(diameter), + area: 15, + feature: clickedFeature, + }; + + setPipePoints((prev) => [...prev, newPipe]); + + // 添加到高亮图层 + const highlightSource = highlightLayerRef.current?.getSource(); + if (highlightSource) { + highlightSource.addFeature(clickedFeature); + } + } + } + }; + + clickListenerRef.current = clickListener; + map.on("click", clickListener); + }; + + // 结束选择管道 + const handleEndSelection = () => { + if (!map) return; + + setIsSelecting(false); + + // 移除点击事件 + if (clickListenerRef.current) { + map.un("click", clickListenerRef.current); + clickListenerRef.current = null; + } + }; + + const handleRemovePipe = (id: string) => { + // 找到要删除的管道 + const pipeToRemove = pipePoints.find((pipe) => pipe.id === id); + + // 从高亮图层中移除对应的要素 + if (pipeToRemove && pipeToRemove.feature && highlightLayerRef.current) { + const highlightSource = highlightLayerRef.current.getSource(); + if (highlightSource) { + highlightSource.removeFeature(pipeToRemove.feature); + } + } + + // 从状态中移除 + setPipePoints((prev) => prev.filter((pipe) => pipe.id !== id)); + }; + + const handleAreaChange = (id: string, value: string) => { + const numValue = parseFloat(value) || 0; + setPipePoints((prev) => + prev.map((pipe) => (pipe.id === id ? { ...pipe, area: numValue } : pipe)) + ); + }; + + const handleAnalyze = () => { + if (onAnalyze) { + onAnalyze({ + pipePoints, + startTime, + duration, + schemeName, + }); + } + }; + + return ( + + {/* 选择爆管点 */} + + + + 选择爆管点 + + {/* 开始/结束选择按钮 */} + {!isSelecting ? ( + + ) : ( + + )} + + + {isSelecting && ( + + 💡 点击地图上的管道添加爆管点 + + )} + + + {pipePoints.map((pipe) => ( + + + {pipe.id} + + + 管径: {pipe.diameter} mm + + + 爆管点面积 + + handleAreaChange(pipe.id, e.target.value)} + type="number" + className="w-25" + slotProps={{ + input: { + endAdornment: ( + cm² + ), + }, + }} + /> + handleRemovePipe(pipe.id)} + className="ml-auto" + > + + + + ))} + + + + {/* 选择开始时间 */} + + + 选择开始时间 + + + + value && dayjs.isDayjs(value) && setStartTime(value) + } + format="YYYY-MM-DD HH:mm" + slotProps={{ + textField: { + size: "small", + fullWidth: true, + }, + }} + /> + + + + {/* 持续时长 */} + + + 持续时长 (秒) + + setDuration(parseInt(e.target.value) || 0)} + placeholder="输入持续时长" + /> + + + {/* 方案名称 */} + + + 方案名称 + + setSchemeName(e.target.value)} + placeholder="输入方案名称" + /> + + + {/* 方案分析按钮 */} + + + + + ); +}; + +export default AnalysisParameters; diff --git a/src/components/olmap/BurstPipeAnalysis/LocationResults.tsx b/src/components/olmap/BurstPipeAnalysis/LocationResults.tsx new file mode 100644 index 0000000..44e142a --- /dev/null +++ b/src/components/olmap/BurstPipeAnalysis/LocationResults.tsx @@ -0,0 +1,205 @@ +'use client'; + +import React, { useState } from 'react'; +import { + Box, + Typography, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Paper, + Chip, + IconButton, + Tooltip, +} from '@mui/material'; +import { LocationOn as LocationIcon, Visibility as VisibilityIcon } from '@mui/icons-material'; + +interface LocationResult { + id: number; + nodeName: string; + nodeId: string; + pressure: number; + waterLevel: number; + flow: number; + status: 'normal' | 'warning' | 'danger'; + coordinates: [number, number]; +} + +interface LocationResultsProps { + onLocate?: (coordinates: [number, number]) => void; + onViewDetail?: (id: number) => void; +} + +const LocationResults: React.FC = ({ onLocate, onViewDetail }) => { + const [results, setResults] = useState([ + // 示例数据 + // { + // id: 1, + // nodeName: '节点A', + // nodeId: 'N001', + // pressure: 0.35, + // waterLevel: 12.5, + // flow: 85.3, + // status: 'normal', + // coordinates: [120.15, 30.25], + // }, + ]); + + const getStatusColor = (status: string) => { + switch (status) { + case 'normal': + return 'success'; + case 'warning': + return 'warning'; + case 'danger': + return 'error'; + default: + return 'default'; + } + }; + + const getStatusText = (status: string) => { + switch (status) { + case 'normal': + return '正常'; + case 'warning': + return '预警'; + case 'danger': + return '危险'; + default: + return '未知'; + } + }; + + return ( + + {/* 统计信息 */} + + + 定位结果统计 + + + + + 总数 + + + {results.length} + + + + + 正常 + + + {results.filter((r) => r.status === 'normal').length} + + + + + 预警 + + + {results.filter((r) => r.status === 'warning').length} + + + + + 危险 + + + {results.filter((r) => r.status === 'danger').length} + + + + + + {/* 结果列表 */} + + {results.length === 0 ? ( + + + + + + + + + + + + 暂无定位结果 + + 请先执行方案分析 + + + ) : ( + + + + + 节点名称 + 节点ID + 压力 (MPa) + 水位 (m) + 流量 (m³/h) + 状态 + 操作 + + + + {results.map((result) => ( + + {result.nodeName} + {result.nodeId} + {result.pressure.toFixed(2)} + {result.waterLevel.toFixed(2)} + {result.flow.toFixed(1)} + + + + + + onLocate?.(result.coordinates)} + color="primary" + > + + + + + onViewDetail?.(result.id)} + color="primary" + > + + + + + + ))} + +
+
+ )} +
+
+ ); +}; + +export default LocationResults; diff --git a/src/components/olmap/BurstPipeAnalysis/SchemeQuery.tsx b/src/components/olmap/BurstPipeAnalysis/SchemeQuery.tsx new file mode 100644 index 0000000..bb59a72 --- /dev/null +++ b/src/components/olmap/BurstPipeAnalysis/SchemeQuery.tsx @@ -0,0 +1,190 @@ +"use client"; + +import React, { useState } from "react"; +import { + Box, + Button, + Typography, + Checkbox, + FormControlLabel, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Paper, + IconButton, +} from "@mui/material"; +import { + Info as InfoIcon, + LocationOn as LocationIcon, +} from "@mui/icons-material"; +import { DatePicker } from "@mui/x-date-pickers/DatePicker"; +import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; +import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; +import dayjs, { Dayjs } from "dayjs"; +import "dayjs/locale/zh-cn"; + +interface SchemeRecord { + id: number; + schemeName: string; + type: string; + user: string; + createTime: string; + startTime: string; +} + +interface SchemeQueryProps { + onViewDetails?: (id: number) => void; + onLocate?: (id: number) => void; +} + +const SchemeQuery: React.FC = ({ + onViewDetails, + onLocate, +}) => { + const [queryAll, setQueryAll] = useState(true); + const [queryDate, setQueryDate] = useState(dayjs("2025-10-21")); + const [schemes, setSchemes] = useState([]); + + const handleQuery = () => { + // TODO: 实际查询逻辑 + console.log("查询方案", { queryAll, queryDate }); + // 这里应该调用API获取数据 + }; + + return ( + + {/* 查询条件 */} + + + setQueryAll(e.target.checked)} + /> + } + label="查询全部" + /> + + + value && dayjs.isDayjs(value) && setQueryDate(value) + } + format="YYYY-MM-DD" + disabled={queryAll} + slotProps={{ + textField: { + size: "small", + className: "flex-1", + }, + }} + /> + + + + + + {/* 结果列表 */} + + {schemes.length === 0 ? ( + + + + + + + + 总共 0 条 + + No data + + + ) : ( + + + + + ID + 方案名称 + 类型 + 用户 + 创建时间 + 开始时间 + 详情 + 定位 + + + + {schemes.map((scheme) => ( + + {scheme.id} + {scheme.schemeName} + {scheme.type} + {scheme.user} + {scheme.createTime} + {scheme.startTime} + + onViewDetails?.(scheme.id)} + color="primary" + > + + + + + onLocate?.(scheme.id)} + color="primary" + > + + + + + ))} + +
+
+ )} +
+
+ ); +}; + +export default SchemeQuery; diff --git a/src/components/olmap/BurstPipeAnalysisPanel.tsx b/src/components/olmap/BurstPipeAnalysisPanel.tsx new file mode 100644 index 0000000..db97f1d --- /dev/null +++ b/src/components/olmap/BurstPipeAnalysisPanel.tsx @@ -0,0 +1,217 @@ +"use client"; + +import React, { useState } from "react"; +import { Box, Drawer, Tabs, Tab, Typography, IconButton } from "@mui/material"; +import { + ChevronRight as ChevronRightIcon, + ChevronLeft as ChevronLeftIcon, + Analytics as AnalyticsIcon, + Search as SearchIcon, + MyLocation as MyLocationIcon, +} from "@mui/icons-material"; +import AnalysisParameters from "./BurstPipeAnalysis/AnalysisParameters"; +import SchemeQuery from "./BurstPipeAnalysis/SchemeQuery"; +import LocationResults from "./BurstPipeAnalysis/LocationResults"; + +interface TabPanelProps { + children?: React.ReactNode; + index: number; + value: number; +} + +const TabPanel: React.FC = ({ children, value, index }) => { + return ( + + ); +}; + +interface BurstPipeAnalysisPanelProps { + open?: boolean; + onToggle?: () => void; +} + +const BurstPipeAnalysisPanel: React.FC = ({ + open: controlledOpen, + onToggle, +}) => { + const [internalOpen, setInternalOpen] = useState(true); + const [currentTab, setCurrentTab] = useState(0); + + // 使用受控或非受控状态 + const isOpen = controlledOpen !== undefined ? controlledOpen : internalOpen; + const handleToggle = () => { + if (onToggle) { + onToggle(); + } else { + setInternalOpen(!internalOpen); + } + }; + + const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => { + setCurrentTab(newValue); + }; + + const drawerWidth = 520; + + return ( + <> + {/* 收起时的触发按钮 */} + {!isOpen && ( + + + + + 爆管分析 + + + + + )} + + {/* 主面板 */} + + + {/* 头部 */} + + + + + 爆管分析 + + + + + + + + {/* Tabs 导航 */} + + + } + iconPosition="start" + label="分析要件" + /> + } + iconPosition="start" + label="方案查询" + /> + } + iconPosition="start" + label="定位结果" + /> + + + + {/* Tab 内容 */} + + { + console.log("开始分析:", params); + // TODO: 调用分析API + }} + /> + + + + { + console.log("查看详情:", id); + // TODO: 显示方案详情 + }} + onLocate={(id) => { + console.log("定位方案:", id); + // TODO: 在地图上定位 + }} + /> + + + + { + console.log("定位到:", coordinates); + // TODO: 地图定位到指定坐标 + }} + onViewDetail={(id) => { + console.log("查看节点详情:", id); + // TODO: 显示节点详细信息 + }} + /> + + + + + ); +}; + +export default BurstPipeAnalysisPanel; diff --git a/src/config/config.ts b/src/config/config.ts index 4b296cd..265e5a5 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -1,18 +1,33 @@ export const config = { - backendUrl: process.env.NEXT_PUBLIC_BACKEND_URL || "http://192.168.1.42:8000", - mapUrl: process.env.NEXT_PUBLIC_MAP_URL || "http://127.0.0.1:8080/geoserver", - mapExtent: [13508849, 3608035.75, 13555781, 3633812.75], + backendUrl: process.env.BACKEND_URL || "http://192.168.1.42:8000", + mapUrl: process.env.MAP_URL || "http://127.0.0.1:8080/geoserver", + mapExtent: process.env.MAP_EXTENT + ? process.env.MAP_EXTENT.split(",").map(Number) + : [13508849, 3608035.75, 13555781, 3633812.75], + mapDefaultStyle: { + "stroke-width": 3, + "stroke-color": "rgba(51, 153, 204, 0.9)", + "circle-fill-color": "rgba(255,138, 92,0.8)", + "circle-stroke-color": "rgba(255,138, 92,0.9)", + "circle-radius": [ + "interpolate", + ["linear"], + ["zoom"], + 12, + 1, // 在缩放级别 12 时,圆形半径为 1px + 24, + 12, // 在缩放级别 24 时,圆形半径为 12px + ], + }, // 添加其他配置项... }; export const MAPBOX_TOKEN = - process.env.NEXT_PUBLIC_MAPBOX_TOKEN || + process.env.MAPBOX_TOKEN || "pk.eyJ1IjoiemhpZnUiLCJhIjoiY205azNyNGY1MGkyZDJxcTJleDUwaHV1ZCJ9.wOmSdOnDDdre-mB1Lpy6Fg"; export const TIANDITU_TOKEN = - process.env.NEXT_PUBLIC_TIANDITU_TOKEN || "e3e8ad95ee911741fa71ed7bff2717ec"; -export const PROJECT_TITLE = - process.env.NEXT_PUBLIC_PROJECT_TITLE || "TJWater Project"; - + process.env.TIANDITU_TOKEN || "e3e8ad95ee911741fa71ed7bff2717ec"; +export const PROJECT_TITLE = process.env.PROJECT_TITLE || "TJWater Project"; export const META_DATA = { title: PROJECT_TITLE, description: "Developed by TJWATER", diff --git a/src/utils/mapQueryService.js b/src/utils/mapQueryService.js new file mode 100644 index 0000000..5683f74 --- /dev/null +++ b/src/utils/mapQueryService.js @@ -0,0 +1,438 @@ +/** + * OpenLayers 地图工具函数集合 + * 提供地图要素查询、选择和处理功能 + */ + +import { GeoJSON } from 'ol/format'; +import { Feature } from 'ol'; +import { Point, LineString, Polygon } from 'ol/geom'; +import Geometry from 'ol/geom/Geometry'; +import TileState from 'ol/TileState'; +import { toLonLat } from 'ol/proj'; +import { booleanIntersects, buffer, point, toWgs84 } from "@turf/turf"; +import config from "@config/config"; +// ========== 常量配置 ========== + +const GEOSERVER_CONFIG = { + url: config.GEOSERVER_URL, + network: config.GEOSERVER_NETWORK, + layers: ['geo_pipes_mat', 'geo_junctions_mat'], + wfsVersion: '1.0.0', + outputFormat: 'application/json', +}; + +const MAP_CONFIG = { + hitTolerance: 5, // 像素容差 + bufferUnits: 'meters', +}; + +// ========== 几何类型枚举 ========== + +const GEOMETRY_TYPES = { + POINT: 'Point', + LINE_STRING: 'LineString', + POLYGON: 'Polygon', +}; + +// ========== 工具函数 ========== + +/** + * 构建 WFS 查询 URL + * @param {string} layer - 图层名称 + * @param {string} cqlFilter - CQL 过滤条件 + * @returns {string} 完整的 WFS 查询 URL + */ +const buildWfsUrl = (layer, cqlFilter) => { + const { url, network, wfsVersion, outputFormat } = GEOSERVER_CONFIG; + const params = new URLSearchParams({ + service: 'WFS', + version: wfsVersion, + request: 'GetFeature', + typeName: `${network}:${layer}`, + outputFormat, + CQL_FILTER: cqlFilter, + }); + return `${url}/${network}/ows?${params.toString()}`; +}; + +/** + * 查询单个图层的要素 + * @param {string} layer - 图层名称 + * @param {string} cqlFilter - CQL 过滤条件 + * @returns {Promise} 要素数组 + */ +const queryLayerFeatures = async (layer, cqlFilter) => { + try { + const url = buildWfsUrl(layer, cqlFilter); + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`请求失败: ${response.statusText}`); + } + + const json = await response.json(); + return new GeoJSON().readFeatures(json); + } catch (error) { + console.error(`图层 ${layer} 查询失败:`, error); + return []; + } +}; + +/** + * 根据 IDs,通过 Geoserver WFS 服务查询要素 + * @param {string[]} ids - 要素 ID 数组 + * @param {string} [layer] - 可选的特定图层名称,不传则查询所有图层 + * @returns {Promise} 查询到的要素数组 + */ +export const queryFeaturesByIds = async (ids, layer = null) => { + if (!ids || !ids.length) { + return []; + } + + const cqlFilter = ids.map((id) => `id=${id}`).join(' OR '); + + try { + if (layer) { + // 查询指定图层 + return await queryLayerFeatures(layer, cqlFilter); + } + + // 查询所有图层 + const { layers } = GEOSERVER_CONFIG; + const promises = layers.map((layerName) => + queryLayerFeatures(layerName, cqlFilter) + ); + + const results = await Promise.all(promises); + const features = results.flat(); + + return features; + } catch (error) { + console.error('根据 IDs 查询要素时出错:', error); + return []; + } +}; + +/** + * 将扁平坐标数组转换为坐标对数组 + * @param {number[]} flatCoordinates - 扁平坐标数组 [x1, y1, x2, y2, ...] + * @returns {number[][]} 坐标对数组 [[x1, y1], [x2, y2], ...] + */ +const flatCoordinatesToPairs = (flatCoordinates) => { + const pairs = []; + for (let i = 0; i < flatCoordinates.length; i += 2) { + pairs.push([flatCoordinates[i], flatCoordinates[i + 1]]); + } + return pairs; +}; + +/** + * 创建点几何对象 + * @param {number[]} flatCoordinates - 扁平坐标数组 + * @returns {Point} 点几何对象 + */ +const createPointGeometry = (flatCoordinates) => { + const coordinates = [flatCoordinates[0], flatCoordinates[1]]; + return new Point(coordinates); +}; + +/** + * 创建线几何对象 + * @param {number[]} flatCoordinates - 扁平坐标数组 + * @returns {LineString} 线几何对象 + */ +const createLineStringGeometry = (flatCoordinates) => { + const lineCoords = flatCoordinatesToPairs(flatCoordinates); + return new LineString(lineCoords); +}; + +/** + * 创建面几何对象 + * @param {number[]} flatCoordinates - 扁平坐标数组 + * @param {Object} geometry - 原始几何对象 + * @returns {Polygon} 面几何对象 + */ +const createPolygonGeometry = (flatCoordinates, geometry) => { + // 获取环的结束位置 + const ends = geometry.getEnds ? geometry.getEnds() : [flatCoordinates.length]; + const rings = []; + let start = 0; + + for (const end of ends) { + const ring = []; + for (let i = start; i < end; i += 2) { + ring.push([flatCoordinates[i], flatCoordinates[i + 1]]); + } + rings.push(ring); + start = end; + } + + return new Polygon(rings); +}; + +/** + * 将 RenderFeature 转换为标准 Feature + * @param {Object} renderFeature - 渲染要素对象 + * @returns {Feature|null} OpenLayers Feature 对象,转换失败返回 null + */ +const renderFeature2Feature = (renderFeature) => { + if (!renderFeature) { + return null; + } + + const geometry = renderFeature.getGeometry(); + if (!geometry) { + return null; + } + + try { + let clonedGeometry; + + if (geometry instanceof Geometry) { + // 标准 Feature 的几何体,直接使用 + clonedGeometry = geometry; + } else { + // RenderFeature 的几何体,需要转换 + const type = geometry.getType(); + const flatCoordinates = geometry.getFlatCoordinates(); + + switch (type) { + case 'Point': + clonedGeometry = createPointGeometry(flatCoordinates); + break; + + case 'LineString': + clonedGeometry = createLineStringGeometry(flatCoordinates); + break; + + case 'Polygon': + clonedGeometry = createPolygonGeometry(flatCoordinates, geometry); + break; + + default: + console.warn('不支持的几何体类型:', type); + return null; + } + } + + // 创建新的 Feature,包含几何体和属性 + const feature = new Feature({ + geometry: clonedGeometry, + ...renderFeature.getProperties(), + }); + + return feature; + } catch (error) { + console.error('RenderFeature 转换 Feature 时出错:', error); + return null; + } +}; + +/** + * 对要素按几何类型进行分类 + * @param {Feature} feature - OpenLayers 要素 + * @param {Object} categorized - 分类存储对象 + */ +const categorizeFeatureByGeometry = (feature, categorized) => { + const geometryType = feature.getGeometry()?.getType(); + + if (geometryType === GEOMETRY_TYPES.POINT) { + categorized.points.push(feature); + } else if (geometryType === GEOMETRY_TYPES.LINE_STRING) { + categorized.lines.push(feature); + } else { + categorized.others.push(feature); + } +}; + +/** + * 检查要素是否在缓冲区内 + * @param {Feature} feature - OpenLayers 要素 + * @param {Object} bufferedGeometry - 缓冲区几何对象 + * @returns {boolean} 是否相交 + */ +const isFeatureInBuffer = (feature, bufferedGeometry) => { + if (!feature || !bufferedGeometry) { + return false; + } + + try { + const geoJSONGeometry = new GeoJSON().writeGeometryObject( + feature.getGeometry() + ); + return booleanIntersects(toWgs84(geoJSONGeometry), bufferedGeometry); + } catch (error) { + console.error('要素缓冲区检查失败:', error); + return false; + } +}; + +/** + * 处理矢量瓦片,提取符合条件的要素 + * @param {Object} vectorTile - 矢量瓦片对象 + * @param {Object} buffered - 缓冲区对象 + * @param {Object} categorized - 分类存储对象 + */ +const processVectorTile = (vectorTile, buffered, categorized) => { + if (vectorTile.getState() !== TileState.LOADED) { + return; + } + + const renderFeatures = vectorTile.getFeatures(); + if (!renderFeatures || renderFeatures.length === 0) { + return; + } + + const selectedFeatures = renderFeatures + .map((renderFeature) => renderFeature2Feature(renderFeature)) + .filter((feature) => feature !== null) // 过滤转换失败的要素 + .filter((feature) => isFeatureInBuffer(feature, buffered?.geometry)); + + selectedFeatures.forEach((feature) => + categorizeFeatureByGeometry(feature, categorized) + ); +}; + +/** + * 处理矢量瓦片源,提取所有符合条件的要素 + * @param {Object} vectorTileSource - 矢量瓦片源 + * @param {number[]} coord - 坐标 + * @param {number} z - 缩放级别 + * @param {Object} projection - 投影 + * @param {Object} categorized - 分类存储对象 + */ +const processVectorTileSource = ( + vectorTileSource, + coord, + z, + projection, + categorized +) => { + const tileGrid = vectorTileSource.getTileGrid(); + + if (!tileGrid) { + return; + } + + // 确保缩放级别在有效范围内 + const minZoom = tileGrid.getMinZoom(); + const maxZoom = tileGrid.getMaxZoom(); + const validZ = Math.max(minZoom, Math.min(z, maxZoom)); + + const [x, y] = coord; + const tileCoord = tileGrid.getTileCoordForCoordAndZ([x, y], validZ); + const resolution = tileGrid.getResolution(tileCoord[0]); + + // 创建缓冲区用于容差计算 + const { hitTolerance, bufferUnits } = MAP_CONFIG; + const hitPoint = point(toLonLat(coord)); + const buffered = buffer(hitPoint, resolution * hitTolerance, { + units: bufferUnits, + }); + + // 获取矢量渲染瓦片 + const pixelRatio = window.devicePixelRatio; + const vectorRenderTile = vectorTileSource.getTile( + tileCoord[0], + tileCoord[1], + tileCoord[2], + pixelRatio, + projection + ); + // 检查 vectorRenderTile 是否有效 + if (!vectorRenderTile) { + return; + } + // 获取源瓦片 + const vectorTiles = typeof vectorTileSource.getSourceTiles === 'function' ? vectorTileSource.getSourceTiles( + pixelRatio, + projection, + vectorRenderTile + ) : []; + + vectorTiles.forEach((vectorTile) => + processVectorTile(vectorTile, buffered, categorized) + ); +}; + +/** + * 处理地图点击事件,选择要素 + * @param {Object} event - 地图点击事件 + * @param {Object} map - OpenLayers 地图对象 + * @param {Function} setHighlightFeature - 设置高亮要素的回调函数 + */ +export const handleMapClickSelectFeatures = ( + event, + map, + setHighlightFeature +) => { + if (!map || !event?.coordinate) { + return; + } + + const coord = event.coordinate; + const view = map.getView(); + const currentZoom = view.getZoom() || 0; + const z = Math.floor(currentZoom) - 1; + const projection = view.getProjection(); + + // 获取所有矢量瓦片图层源 + const vectorTileSources = map + .getAllLayers() + .filter((layer) => layer.getSource && layer.getSource()) + .map((layer) => layer.getSource()) + .filter((source) => source && source.getTileGrid); + + if (!vectorTileSources.length) { + return; + } + + // 按几何类型分类存储要素 + const categorized = { + points: [], + lines: [], + others: [], + }; + + // 处理所有矢量瓦片源 + vectorTileSources.forEach((vectorTileSource) => + processVectorTileSource(vectorTileSource, coord, z, projection, categorized) + ); + + // 按优先级合并要素:点 > 线 > 其他 + const selectedFeatures = [ + ...categorized.points, + ...categorized.lines, + ...categorized.others, + ]; + + // 处理选中的第一个要素 + if (selectedFeatures.length > 0) { + const firstFeature = selectedFeatures[0]; + const queryId = firstFeature?.getProperties()?.id; + + if (queryId) { + queryFeaturesByIds([queryId]) + .then((features) => { + if (features && features.length > 0) { + setHighlightFeature(features[0]); + } else { + setHighlightFeature(null); + } + }) + .catch((error) => { + console.error('查询要素详情失败:', error); + setHighlightFeature(null); + }); + } else { + setHighlightFeature(null); + } + } else { + setHighlightFeature(null); + } +}; + +export default { + handleMapClickSelectFeatures, + queryFeaturesByIds, +}; \ No newline at end of file