From ad893ac19dec2c806fdb984fed50158e23c45e2e Mon Sep 17 00:00:00 2001 From: JIANG Date: Thu, 23 Oct 2025 11:59:45 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E7=88=86=E7=AE=A1=E5=88=86?= =?UTF-8?q?=E6=9E=90=E9=9D=A2=E6=9D=BF=EF=BC=9B=E6=95=B4=E5=90=88=E5=9C=B0?= =?UTF-8?q?=E5=9B=BE=E6=9F=A5=E8=AF=A2=E5=87=BD=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/OlMap/Controls/Toolbar.tsx | 252 +------- .../BurstPipeAnalysis/AnalysisParameters.tsx | 292 +++++---- .../olmap/BurstPipeAnalysis/SchemeQuery.tsx | 597 +++++++++++++++--- .../olmap/BurstPipeAnalysisPanel.tsx | 32 +- src/config/config.ts | 2 +- src/utils/mapQueryService.js | 438 ------------- src/utils/mapQueryService.ts | 471 ++++++++++++++ 7 files changed, 1185 insertions(+), 899 deletions(-) delete mode 100644 src/utils/mapQueryService.js create mode 100644 src/utils/mapQueryService.ts diff --git a/src/app/OlMap/Controls/Toolbar.tsx b/src/app/OlMap/Controls/Toolbar.tsx index 33dc959..81e7d54 100644 --- a/src/app/OlMap/Controls/Toolbar.tsx +++ b/src/app/OlMap/Controls/Toolbar.tsx @@ -10,19 +10,11 @@ import DrawPanel from "./DrawPanel"; // 引入绘图面板组件 import VectorSource from "ol/source/Vector"; import VectorLayer from "ol/layer/Vector"; import { Style, Stroke, Fill, Circle } from "ol/style"; -import { Geometry } from "ol/geom"; -import { Point, LineString, Polygon } from "ol/geom"; import { FeatureLike } from "ol/Feature"; import Feature from "ol/Feature"; -import GeoJSON from "ol/format/GeoJSON"; import StyleEditorPanel from "./StyleEditorPanel"; -import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile"; -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 { handleMapClickSelectFeatures as mapClickSelectFeatures } from "@/utils/mapQueryService"; import { config } from "@/config/config"; const backendUrl = config.backendUrl; @@ -98,245 +90,15 @@ const Toolbar: React.FC = ({ hiddenButtons }) => { source.addFeature(highlightFeature); } }, [highlightFeature]); - // 将 RenderFeature 转换为 Feature - const renderFeature2Feature = (renderFeature: RenderFeature) => { - if (renderFeature) { - const geometry = renderFeature.getGeometry(); - - if (geometry) { - try { - let clonedGeometry; - - if (geometry instanceof Geometry) { - // 标准 Feature 的几何体 - clonedGeometry = geometry; - } else { - // RenderFeature 或其他类型的几何体 - const type = geometry.getType(); - const flatCoordinates = geometry.getFlatCoordinates(); - let coordinates: number[] | number[][] | number[][][]; - switch (type) { - case "Point": - // Point: [x, y] - coordinates = [flatCoordinates[0], flatCoordinates[1]]; - clonedGeometry = new Point(coordinates as number[]); - break; - case "LineString": - // LineString: [[x1, y1], [x2, y2], ...] - const lineCoords: number[][] = []; - for (let i = 0; i < flatCoordinates.length; i += 2) { - lineCoords.push([flatCoordinates[i], flatCoordinates[i + 1]]); - } - clonedGeometry = new LineString(lineCoords); - break; - case "Polygon": - // Polygon: [[[x1, y1], [x2, y2], ...]] - // 需要获取环的结束位置 - const ends = ( - geometry as { getEnds?: () => number[] } - ).getEnds?.() || [flatCoordinates.length]; - const rings: number[][][] = []; - let start = 0; - - for (const end of ends) { - const ring: number[][] = []; - for (let i = start; i < end; i += 2) { - ring.push([flatCoordinates[i], flatCoordinates[i + 1]]); - } - rings.push(ring); - start = end; - } - clonedGeometry = new Polygon(rings); - break; - default: - console.log("不支持的几何体类型:", type); - return; - } - } - const feature = new Feature({ - geometry: clonedGeometry, - ...renderFeature.getProperties(), - }); - return feature; - } catch (error) { - console.error("RenderFeature转换Feature时出错:", error); - } - } - } - }; - // 根据 IDs,通过 Geoserver WFS 服务查询要素 - const queryFeaturesByIds = async (ids: string[], layer?: string) => { - if (!ids.length) return []; - const geoserverUrl = "http://127.0.0.1:8080/geoserver"; - const network = "TJWater"; - const layers = ["geo_pipes_mat", "geo_junctions_mat"]; - const orFilter = ids.map((id) => `id=${id}`).join(" OR "); - - try { - if (!layer) { - // 遍历所有图层 - const promises = layers.map(async (layer) => { - try { - const url = - `${geoserverUrl}/${network}/ows?` + - `service=WFS&version=1.0.0&request=GetFeature&` + - `typeName=${network}:${layer}&outputFormat=application/json&` + - `CQL_FILTER=${encodeURIComponent(orFilter)}`; - const response = await fetch(url); - if (!response.ok) { - throw new Error(`请求失败: ${response.statusText}`); - } - return await response.json(); - } catch (error) { - console.error(`图层 ${layer} 查询失败:`, error); - return null; // 返回 null 表示该图层查询失败 - } - }); - - const results = await Promise.all(promises); - const features = results - .filter((json) => json !== null) // 过滤掉失败的请求 - .flatMap((json) => new GeoJSON().readFeatures(json)); - // console.log("查询到的要素:", features); - return features; - } else { - // 查询指定图层 - const url = - `${geoserverUrl}/${network}/ows?` + - `service=WFS&version=1.0.0&request=GetFeature&` + - `typeName=${network}:${layer}&outputFormat=application/json&` + - `CQL_FILTER=${encodeURIComponent(orFilter)}`; - const response = await fetch(url); - if (!response.ok) { - throw new Error(`请求失败: ${response.statusText}`); - } - const json = await response.json(); - const features = new GeoJSON().readFeatures(json); - // console.log("查询到的要素:", features); - return features; - } - } catch (error) { - console.error("根据 IDs 查询要素时出错:", error); - return []; - } - }; - // 处理地图点击选择要素 + // 地图点击选择要素事件处理函数 const handleMapClickSelectFeatures = useCallback( - (event: { coordinate: number[] }) => { + async (event: { coordinate: number[] }) => { if (!map) return; - const coord = event.coordinate; - let z = Math.floor(map.getView().getZoom() || 0) - 1; // 确保 z 是整数 - const projection = map.getView().getProjection(); // 获取地图的投影 - const pixelRatio = window.devicePixelRatio; // 获取设备像素比率 - const [x, y] = coord; - - // 遍历所有的 VectorTileSources - const vectorTileSources = map - .getAllLayers() - .filter((layer) => layer instanceof WebGLVectorTileLayer) - .map((layer) => layer.getSource() as VectorTileSource) - .filter((source) => source); - if (!vectorTileSources.length) return; - - // 按几何类型分类,优先处理级别 - const points: any[] = []; - const lines: any[] = []; - const others: any[] = []; - - vectorTileSources.forEach((vectorTileSource) => { - const tileGrid = vectorTileSource.getTileGrid(); - if (tileGrid) { - const minZoom = tileGrid.getMinZoom(); // 最小缩放级别 - const maxZoom = tileGrid.getMaxZoom(); // 最大缩放级别 - // 确保 z 在有效范围内 - if (z < minZoom) z = minZoom; - if (z > maxZoom) z = maxZoom; - } else { - return; - } - - const tileCoord = tileGrid.getTileCoordForCoordAndZ([x, y], z); - // 设置 resolution 用于基于屏幕像素的 buffer 容差计算 - const resolution = tileGrid.getResolution(tileCoord[0]); - const hitTolerance = 5; // 像素容差 - const hitPoint = point(toLonLat(coord)); - const buffered = buffer(hitPoint, resolution * hitTolerance, { - units: "meters", - }); - // 获取 VectorRenderTile - const vectorRenderTile = vectorTileSource.getTile( - tileCoord[0], - tileCoord[1], - tileCoord[2], - pixelRatio, - projection - ); - - // 获取 SourceTiles - const vectorTiles = vectorTileSource.getSourceTiles( - pixelRatio, - projection, - vectorRenderTile - ); - - vectorTiles.forEach((vectorTile) => { - if (vectorTile.getState() === TileState.LOADED) { - const renderFeatures = vectorTile.getFeatures(); - - const selectedFeatures = renderFeatures - .map( - (renderFeature) => - renderFeature2Feature(renderFeature) as Feature - ) - .filter((feature) => { - if (feature && buffered) { - const geoJSONGeometry = new GeoJSON().writeGeometryObject( - feature.getGeometry() - ); - const bufferedGeometry = buffered.geometry; - return booleanIntersects( - toWgs84(geoJSONGeometry), - bufferedGeometry - ); - } - return false; - }); - selectedFeatures.forEach((selectedFeature) => { - const geometryType = selectedFeature.getGeometry()?.getType(); - if (geometryType === "Point") { - points.push(selectedFeature); - } else if (geometryType === "LineString") { - lines.push(selectedFeature); - } else { - others.push(selectedFeature); - } - }); - } - }); - }); - // 按优先级处理:点 > 线 > 其他 - const selectedFeatures = [...points, ...lines, ...others]; - const firstFeature = selectedFeatures[0] as Feature; - const queryId = firstFeature?.getProperties().id; - // console.log(queryId, "queryId"); - if (queryId) { - queryFeaturesByIds([queryId]).then((features) => { - // console.log("查询到的要素:", features); - setHighlightFeature(features[0]); - }); - } else { - setHighlightFeature(null); - } + const feature = await mapClickSelectFeatures(event, map); // 调用导入的函数 + setHighlightFeature(feature); }, - [map, highlightLayer, setHighlightFeature] + [map, 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/components/olmap/BurstPipeAnalysis/AnalysisParameters.tsx b/src/components/olmap/BurstPipeAnalysis/AnalysisParameters.tsx index 9dc087c..db1319d 100644 --- a/src/components/olmap/BurstPipeAnalysis/AnalysisParameters.tsx +++ b/src/components/olmap/BurstPipeAnalysis/AnalysisParameters.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useRef, useEffect } from "react"; +import React, { useState, useRef, useEffect, useCallback } from "react"; import { Box, TextField, @@ -20,12 +20,11 @@ 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; +import { handleMapClickSelectFeatures as mapClickSelectFeatures } from "@/utils/mapQueryService"; +import Feature from "ol/Feature"; +import { useNotification } from "@refinedev/core"; +import axios from "axios"; +import { config, NETWORK_NAME } from "@/config/config"; interface PipePoint { id: string; @@ -34,36 +33,23 @@ interface PipePoint { 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 AnalysisParameters: React.FC = () => { const map = useMap(); + const { open, close } = useNotification(); - 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 [pipePoints, setPipePoints] = useState([]); + const [startTime, setStartTime] = useState(dayjs(new Date())); const [duration, setDuration] = useState(3000); - const [schemeName, setSchemeName] = useState("Fangan1021100506"); + const [schemeName, setSchemeName] = useState( + "FANGAN" + new Date().getTime() + ); + const [network, setNetwork] = useState(NETWORK_NAME); const [isSelecting, setIsSelecting] = useState(false); - const highlightLayerRef = useRef | null>(null); - const clickListenerRef = useRef<((evt: any) => void) | null>(null); - + const [highlightLayer, setHighlightLayer] = + useState | null>(null); + const [highlightFeatures, setHighlightFeatures] = useState([]); + const [analyzing, setAnalyzing] = useState(false); // 初始化管道图层和高亮图层 useEffect(() => { if (!map) return; @@ -71,80 +57,130 @@ const AnalysisParameters: React.FC = ({ // 创建高亮图层 const highlightLayer = new VectorLayer({ source: new VectorSource(), - style: new Style({ - stroke: new Stroke({ - color: "#ff0000", - width: 5, + style: [ + // 外层发光效果(底层) + new Style({ + stroke: new Stroke({ + color: "rgba(255, 0, 0, 0.3)", + width: 12, + }), }), - }), + // 主线条 - 使用虚线表示爆管 + new Style({ + stroke: new Stroke({ + color: "#ff0000", + width: 6, + lineDash: [15, 10], // 虚线样式,表示管道损坏/爆管 + }), + }), + // 内层高亮线 + new Style({ + stroke: new Stroke({ + color: "#ff6666", + width: 3, + lineDash: [15, 10], + }), + }), + ], properties: { name: "高亮管道", value: "highlight_pipeline", }, - zIndex: 999, }); map.addLayer(highlightLayer); - highlightLayerRef.current = highlightLayer; + setHighlightLayer(highlightLayer); return () => { map.removeLayer(highlightLayer); - if (clickListenerRef.current) { - map.un("click", clickListenerRef.current); - } + map.un("click", handleMapClickSelectFeatures); }; }, [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]); + + // 同步高亮要素和爆管点信息 + useEffect(() => { + setPipePoints((prevPipes) => { + // 移除不在highlightFeatures中的 + const filtered = prevPipes.filter((pipe) => + highlightFeatures.some( + (feature) => feature.getProperties().id === pipe.id + ) + ); + // 添加新的 + const newPipes = highlightFeatures + .filter( + (feature) => + !filtered.some((p) => p.id === feature.getProperties().id) + ) + .map((feature) => { + const properties = feature.getProperties(); + console.log("管道属性:", feature, properties); + return { + id: properties.id, + diameter: properties.diameter || 0, + area: 15, + feature: feature, + }; + }); + return [...filtered, ...newPipes]; + }); + }, [highlightFeatures]); + + // 地图点击选择要素事件处理函数 + const handleMapClickSelectFeatures = useCallback( + async (event: { coordinate: number[] }) => { + if (!map) return; + const feature = await mapClickSelectFeatures(event, map); + if (!feature) return; + if (feature.getGeometry()?.getType() === "Point") { + // 点类型几何不处理 + open?.({ + type: "error", + message: "请选择线类型管道要素。", + }); + return; + } + const featureId = feature.getProperties().id; + setHighlightFeatures((prev) => { + const existingIndex = prev.findIndex( + (f) => f.getProperties().id === featureId + ); + if (existingIndex !== -1) { + // 如果已存在,移除 + return prev.filter((_, i) => i !== existingIndex); + } else { + // 如果不存在,添加 + return [...prev, feature]; + } + }); + }, + [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); + map.on("click", handleMapClickSelectFeatures); }; // 结束选择管道 @@ -154,26 +190,14 @@ const AnalysisParameters: React.FC = ({ setIsSelecting(false); // 移除点击事件 - if (clickListenerRef.current) { - map.un("click", clickListenerRef.current); - clickListenerRef.current = null; - } + map.un("click", handleMapClickSelectFeatures); }; 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)); + // 从高亮features中移除 + setHighlightFeatures((prev) => + prev.filter((f) => f.getProperties().id !== id) + ); }; const handleAreaChange = (id: string, value: string) => { @@ -183,14 +207,55 @@ const AnalysisParameters: React.FC = ({ ); }; - const handleAnalyze = () => { - if (onAnalyze) { - onAnalyze({ - pipePoints, - startTime, - duration, - schemeName, + const handleAnalyze = async () => { + setAnalyzing(true); + + const burst_ID = pipePoints.map((pipe) => pipe.id); + const burst_size = pipePoints.map((pipe) => + parseInt(pipe.area.toString(), 10) + ); + const modify_pattern_start_time = startTime + ? startTime.format("YYYY-MM-DDTHH:mm:ssZ") + : ""; + const modify_total_duration = duration; + + const body = { + name: network, + modify_pattern_start_time: modify_pattern_start_time, + burst_ID: burst_ID, + burst_size: burst_size, + modify_total_duration: modify_total_duration, + scheme_Name: schemeName, + }; + + try { + await axios.post(`${config.backendUrl}/burst_analysis/`, body, { + headers: { + "Accept-Encoding": "gzip", + "Content-Type": "application/json", + }, }); + + // 更新弹窗为成功状态 + open?.({ + key: "burst-analysis", + type: "success", + message: "分析请求提交成功", + description: "方案已成功提交,正在进行分析", + }); + } catch (error) { + console.error("分析请求失败:", error); + + // 更新弹窗为失败状态 + open?.({ + key: "burst-analysis", + type: "error", + message: "提交分析失败", + description: + error instanceof Error ? error.message : "请检查网络连接或稍后重试", + }); + } finally { + setAnalyzing(false); } }; @@ -339,7 +404,7 @@ const AnalysisParameters: React.FC = ({ {/* 方案名称 */} - + 方案名称 @@ -359,9 +424,10 @@ const AnalysisParameters: React.FC = ({ variant="contained" size="large" onClick={handleAnalyze} + disabled={analyzing} className="bg-blue-600 hover:bg-blue-700" > - 方案分析 + {analyzing ? "方案提交中..." : "方案分析"} diff --git a/src/components/olmap/BurstPipeAnalysis/SchemeQuery.tsx b/src/components/olmap/BurstPipeAnalysis/SchemeQuery.tsx index bb59a72..7f7f491 100644 --- a/src/components/olmap/BurstPipeAnalysis/SchemeQuery.tsx +++ b/src/components/olmap/BurstPipeAnalysis/SchemeQuery.tsx @@ -1,20 +1,19 @@ "use client"; -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { Box, Button, Typography, Checkbox, FormControlLabel, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, - Paper, IconButton, + Card, + CardContent, + Chip, + Tooltip, + Collapse, + Link, } from "@mui/material"; import { Info as InfoIcon, @@ -25,77 +24,276 @@ 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 axios from "axios"; +import moment from "moment"; +import { config, NETWORK_NAME } from "@config/config"; +import { useNotification } from "@refinedev/core"; + +import { queryFeaturesByIds } from "@/utils/mapQueryService"; +import { useMap } from "@app/OlMap/MapComponent"; +import * as turf from "@turf/turf"; +import { GeoJSON } from "ol/format"; +import VectorLayer from "ol/layer/Vector"; +import VectorSource from "ol/source/Vector"; +import { Stroke, Style } from "ol/style"; +import Feature from "ol/Feature"; +import { set } from "ol/transform"; + +interface SchemeDetail { + burst_ID: string[]; + burst_size: number[]; + modify_total_duration: number; + modify_fixed_pump_pattern: any; + modify_valve_opening: any; + modify_variable_pump_pattern: any; +} interface SchemeRecord { id: number; schemeName: string; type: string; user: string; - createTime: string; + create_time: string; startTime: string; + // 详情信息 + schemeDetail?: SchemeDetail; +} + +interface SchemaItem { + scheme_id: number; + scheme_name: string; + scheme_type: string; + username: string; + create_time: string; + scheme_start_time: string; + scheme_detail?: SchemeDetail; } interface SchemeQueryProps { + schemes?: SchemeRecord[]; + onSchemesChange?: (schemes: SchemeRecord[]) => void; onViewDetails?: (id: number) => void; onLocate?: (id: number) => void; + network?: string; } const SchemeQuery: React.FC = ({ + schemes: externalSchemes, + onSchemesChange, onViewDetails, onLocate, + network = NETWORK_NAME, }) => { const [queryAll, setQueryAll] = useState(true); - const [queryDate, setQueryDate] = useState(dayjs("2025-10-21")); - const [schemes, setSchemes] = useState([]); + const [queryDate, setQueryDate] = useState(dayjs(new Date())); + const [internalSchemes, setInternalSchemes] = useState([]); + const [loading, setLoading] = useState(false); + const [expandedId, setExpandedId] = useState(null); + const { open } = useNotification(); - const handleQuery = () => { - // TODO: 实际查询逻辑 - console.log("查询方案", { queryAll, queryDate }); - // 这里应该调用API获取数据 + const [highlightLayer, setHighlightLayer] = + useState | null>(null); + const [highlightFeatures, setHighlightFeatures] = useState([]); + const map = useMap(); + + // 使用外部提供的 schemes 或内部状态 + const schemes = + externalSchemes !== undefined ? externalSchemes : internalSchemes; + const setSchemes = onSchemesChange || setInternalSchemes; + + // 格式化日期为简短格式 + const formatTime = (timeStr: string) => { + const time = moment(timeStr); + return time.format("MM-DD"); }; + const handleQuery = async () => { + if (!queryAll && !queryDate) return; + + setLoading(true); + try { + const response = await axios.get( + `${config.backendUrl}/getallschemes/?network=${network}` + ); + let filteredResults = response.data; + + if (!queryAll) { + const formattedDate = queryDate!.format("YYYY-MM-DD"); + filteredResults = response.data.filter((item: SchemaItem) => { + const itemDate = moment(item.create_time).format("YYYY-MM-DD"); + return itemDate === formattedDate; + }); + } + + setSchemes( + filteredResults.map((item: SchemaItem) => ({ + id: item.scheme_id, + schemeName: item.scheme_name, + type: item.scheme_type, + user: item.username, + create_time: item.create_time, + startTime: item.scheme_start_time, + schemeDetail: item.scheme_detail, + })) + ); + + if (filteredResults.length === 0) { + open?.({ + type: "error", + message: "查询结果", + description: queryAll + ? "没有找到任何方案" + : `${queryDate!.format("YYYY-MM-DD")} 没有找到相关方案`, + }); + } + } catch (error) { + console.error("查询请求失败:", error); + open?.({ + type: "error", + message: "查询失败", + description: "获取方案列表失败,请稍后重试", + }); + } finally { + setLoading(false); + } + }; + + const handleLocatePipes = (pipeIds: string[]) => { + if (pipeIds.length > 0) { + queryFeaturesByIds(pipeIds).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 = turf.bbox( + turf.featureCollection(geojsonFeatures as any) + ); + + if (extent) { + map?.getView().fit(extent, { maxZoom: 18, duration: 1000 }); + } + } + }); + } + }; + // 初始化管道图层和高亮图层 + useEffect(() => { + if (!map) return; + + // 创建高亮图层 - 爆管管段标识样式 + const highlightLayer = new VectorLayer({ + source: new VectorSource(), + style: [ + // 外层发光效果(底层) + new Style({ + stroke: new Stroke({ + color: "rgba(255, 0, 0, 0.3)", + width: 12, + }), + }), + // 主线条 - 使用虚线表示爆管 + new Style({ + stroke: new Stroke({ + color: "#ff0000", + width: 6, + lineDash: [15, 10], // 虚线样式,表示管道损坏/爆管 + }), + }), + // 内层高亮线 + new Style({ + stroke: new Stroke({ + color: "#ff6666", + width: 3, + lineDash: [15, 10], + }), + }), + ], + properties: { + name: "爆管管段高亮", + value: "burst_pipe_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]); + return ( - {/* 查询条件 */} - - - setQueryAll(e.target.checked)} - /> - } - label="查询全部" - /> - - - value && dayjs.isDayjs(value) && setQueryDate(value) + {/* 查询条件 - 单行布局 */} + + + + setQueryAll(e.target.checked)} + size="small" + /> } - format="YYYY-MM-DD" - disabled={queryAll} - slotProps={{ - textField: { - size: "small", - className: "flex-1", - }, - }} + label={查询全部} + className="m-0" /> - + + + value && dayjs.isDayjs(value) && setQueryDate(value) + } + format="YYYY-MM-DD" + disabled={queryAll} + slotProps={{ + textField: { + size: "small", + sx: { width: 200 }, + }, + }} + /> + + + - {/* 结果列表 */} @@ -135,52 +333,259 @@ const SchemeQuery: React.FC = ({ ) : ( - - - - - ID - 方案名称 - 类型 - 用户 - 创建时间 - 开始时间 - 详情 - 定位 - - - - {schemes.map((scheme) => ( - - {scheme.id} - {scheme.schemeName} - {scheme.type} - {scheme.user} - {scheme.createTime} - {scheme.startTime} - - onViewDetails?.(scheme.id)} - color="primary" + + + 共 {schemes.length} 条记录 + + {schemes.map((scheme) => ( + + + {/* 主要信息行 */} + + + + + {scheme.schemeName} + + + + - - - - - onLocate?.(scheme.id)} - color="primary" + ID: {scheme.id} · 日期: {formatTime(scheme.create_time)} + + + {/* 操作按钮 */} + + - - - - - ))} - -
-
+ + setExpandedId( + expandedId === scheme.id ? null : scheme.id + ) + } + color="primary" + className="p-1" + > + + + + + onLocate?.(scheme.id)} + color="primary" + className="p-1" + > + + + + + + + {/* 可折叠的详细信息 */} + + + {/* 信息网格布局 */} + + {/* 爆管详情列 */} + + + + + 管段ID: + + + {scheme.schemeDetail?.burst_ID?.length ? ( + scheme.schemeDetail.burst_ID.map( + (pipeId, index) => ( + { + e.preventDefault(); + handleLocatePipes?.([pipeId]); + }} + > + {pipeId} + + ) + ) + ) : ( + + N/A + + )} + + + + + 管径: + + + 560 mm + + + + + 爆管面积: + + + {scheme.schemeDetail?.burst_size?.[0] || "N/A"}{" "} + cm² + + + + + 持续时间: + + + {scheme.schemeDetail?.modify_total_duration || + "N/A"}{" "} + 秒 + + + + + + {/* 方案信息列 */} + + + + + 用户: + + + {scheme.user} + + + + + 创建时间: + + + {moment(scheme.create_time).format( + "YYYY-MM-DD HH:mm" + )} + + + + + 开始时间: + + + {moment(scheme.startTime).format( + "YYYY-MM-DD HH:mm" + )} + + + + + + + {/* 操作按钮区域 */} + + {scheme.schemeDetail?.burst_ID?.length ? ( + + ) : null} + + + + + + + ))} + )} diff --git a/src/components/olmap/BurstPipeAnalysisPanel.tsx b/src/components/olmap/BurstPipeAnalysisPanel.tsx index db97f1d..952dffd 100644 --- a/src/components/olmap/BurstPipeAnalysisPanel.tsx +++ b/src/components/olmap/BurstPipeAnalysisPanel.tsx @@ -13,6 +13,26 @@ import AnalysisParameters from "./BurstPipeAnalysis/AnalysisParameters"; import SchemeQuery from "./BurstPipeAnalysis/SchemeQuery"; import LocationResults from "./BurstPipeAnalysis/LocationResults"; +interface SchemeDetail { + burst_ID: string[]; + burst_size: number[]; + modify_total_duration: number; + modify_fixed_pump_pattern: any; + modify_valve_opening: any; + modify_variable_pump_pattern: any; +} + +interface SchemeRecord { + id: number; + schemeName: string; + type: string; + user: string; + create_time: string; + startTime: string; + // 详情信息 + schemeDetail?: SchemeDetail; +} + interface TabPanelProps { children?: React.ReactNode; index: number; @@ -45,6 +65,9 @@ const BurstPipeAnalysisPanel: React.FC = ({ const [internalOpen, setInternalOpen] = useState(true); const [currentTab, setCurrentTab] = useState(0); + // 持久化方案查询结果 + const [schemes, setSchemes] = useState([]); + // 使用受控或非受控状态 const isOpen = controlledOpen !== undefined ? controlledOpen : internalOpen; const handleToggle = () => { @@ -175,16 +198,13 @@ const BurstPipeAnalysisPanel: React.FC = ({ {/* Tab 内容 */} - { - console.log("开始分析:", params); - // TODO: 调用分析API - }} - /> + { console.log("查看详情:", id); // TODO: 显示方案详情 diff --git a/src/config/config.ts b/src/config/config.ts index 265e5a5..81a4ddf 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -21,7 +21,7 @@ export const config = { }, // 添加其他配置项... }; - +export const NETWORK_NAME = process.env.NETWORK_NAME || "tjwater"; export const MAPBOX_TOKEN = process.env.MAPBOX_TOKEN || "pk.eyJ1IjoiemhpZnUiLCJhIjoiY205azNyNGY1MGkyZDJxcTJleDUwaHV1ZCJ9.wOmSdOnDDdre-mB1Lpy6Fg"; diff --git a/src/utils/mapQueryService.js b/src/utils/mapQueryService.js deleted file mode 100644 index 5683f74..0000000 --- a/src/utils/mapQueryService.js +++ /dev/null @@ -1,438 +0,0 @@ -/** - * 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 diff --git a/src/utils/mapQueryService.ts b/src/utils/mapQueryService.ts new file mode 100644 index 0000000..76752d8 --- /dev/null +++ b/src/utils/mapQueryService.ts @@ -0,0 +1,471 @@ +/** + * OpenLayers 地图工具函数集合 + * 提供地图要素查询、选择和处理功能 + * + * @module mapQueryService + */ + +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"; +import RenderFeature from "ol/render/Feature"; +import Map from "ol/Map"; +import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile"; +import VectorTileSource from "ol/source/VectorTile"; + +// ========== 类型定义 ========== + +/** + * 几何类型枚举 + */ +enum GeometryType { + POINT = "Point", + LINE_STRING = "LineString", + POLYGON = "Polygon", +} + +/** + * 地图点击事件 + */ +interface MapClickEvent { + coordinate: number[]; +} + +// ========== 常量配置 ========== + +/** + * GeoServer 服务配置 + */ +const GEOSERVER_CONFIG = { + url: config.mapUrl, + workspace: "TJWater", + layers: ["geo_pipes_mat", "geo_junctions_mat"], + wfsVersion: "1.0.0", + outputFormat: "application/json", +} as const; + +/** + * 地图交互配置 + */ +const MAP_CONFIG = { + hitTolerance: 5, // 像素容差 + bufferUnits: "meters" as const, +} as const; + +// ========== 辅助函数 ========== + +/** + * 将扁平坐标数组转换为点坐标 + * @param flatCoordinates 扁平坐标数组 + * @returns 点坐标 [x, y] + */ +const flatCoordinatesToPoint = (flatCoordinates: number[]): number[] => { + return [flatCoordinates[0], flatCoordinates[1]]; +}; + +/** + * 将扁平坐标数组转换为线坐标 + * @param flatCoordinates 扁平坐标数组 + * @returns 线坐标数组 [[x1, y1], [x2, y2], ...] + */ +const flatCoordinatesToLineString = (flatCoordinates: number[]): number[][] => { + const lineCoords: number[][] = []; + for (let i = 0; i < flatCoordinates.length; i += 2) { + lineCoords.push([flatCoordinates[i], flatCoordinates[i + 1]]); + } + return lineCoords; +}; + +/** + * 将扁平坐标数组转换为多边形坐标 + * @param flatCoordinates 扁平坐标数组 + * @param ends 环的结束位置数组 + * @returns 多边形坐标数组 [[[x1, y1], [x2, y2], ...]] + */ +const flatCoordinatesToPolygon = ( + flatCoordinates: number[], + ends: number[] +): number[][][] => { + const rings: number[][][] = []; + let start = 0; + + for (const end of ends) { + const ring: number[][] = []; + for (let i = start; i < end; i += 2) { + ring.push([flatCoordinates[i], flatCoordinates[i + 1]]); + } + rings.push(ring); + start = end; + } + return rings; +}; + +/** + * 将 RenderFeature 转换为标准 Feature + * @param renderFeature OpenLayers 渲染要素 + * @returns 标准 Feature 对象或 undefined + */ +const convertRenderFeatureToFeature = ( + renderFeature: RenderFeature +): Feature | undefined => { + if (!renderFeature) { + return undefined; + } + + const geometry = renderFeature.getGeometry(); + if (!geometry) { + return undefined; + } + + try { + let clonedGeometry: Geometry; + + // 检查是否为标准几何体 + if (geometry instanceof Geometry) { + clonedGeometry = geometry; + } else { + // 处理 RenderFeature 的几何体 + const type = geometry.getType(); + const flatCoordinates = geometry.getFlatCoordinates(); + + switch (type) { + case GeometryType.POINT: + clonedGeometry = new Point(flatCoordinatesToPoint(flatCoordinates)); + break; + + case GeometryType.LINE_STRING: + clonedGeometry = new LineString( + flatCoordinatesToLineString(flatCoordinates) + ); + break; + + case GeometryType.POLYGON: + const ends = (geometry as any).getEnds?.() || [ + flatCoordinates.length, + ]; + clonedGeometry = new Polygon( + flatCoordinatesToPolygon(flatCoordinates, ends) + ); + break; + + default: + console.warn(`不支持的几何体类型: ${type}`); + return undefined; + } + } + + return new Feature({ + geometry: clonedGeometry, + ...renderFeature.getProperties(), + }); + } catch (error) { + console.error("RenderFeature 转换为 Feature 时出错:", error); + return undefined; + } +}; +/** + * 构建 WFS 查询 URL + * @param layer 图层名称 + * @param orFilter CQL 过滤条件 + * @returns WFS 查询 URL + */ +const buildWfsUrl = (layer: string, orFilter: string): string => { + const { url, workspace, wfsVersion, outputFormat } = GEOSERVER_CONFIG; + const params = new URLSearchParams({ + service: "WFS", + version: wfsVersion, + request: "GetFeature", + typeName: `${workspace}:${layer}`, + outputFormat: outputFormat, + CQL_FILTER: orFilter, + }); + return `${url}/${workspace}/ows?${params.toString()}`; +}; + +/** + * 从指定图层查询要素 + * @param layer 图层名称 + * @param orFilter CQL 过滤条件 + * @returns GeoJSON Feature 数组 + */ +const fetchFeaturesFromLayer = async ( + layer: string, + orFilter: string +): Promise => { + try { + const url = buildWfsUrl(layer, orFilter); + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`HTTP 错误: ${response.status} ${response.statusText}`); + } + + const json = await response.json(); + return new GeoJSON().readFeatures(json); + } catch (error) { + console.error(`图层 ${layer} 查询失败:`, error); + return []; + } +}; + +/** + * 根据 ID 列表通过 GeoServer WFS 服务查询要素 + * @param ids 要素 ID 数组 + * @param layer 可选的指定图层名称 + * @returns Feature 数组 + */ +const queryFeaturesByIds = async ( + ids: string[], + layer?: string +): Promise => { + if (!ids.length) { + return []; + } + + const orFilter = ids.map((id) => `id=${id}`).join(" OR "); + + try { + if (!layer) { + // 查询所有配置的图层 + const promises = GEOSERVER_CONFIG.layers.map((layerName) => + fetchFeaturesFromLayer(layerName, orFilter) + ); + + const results = await Promise.all(promises); + return results.flat(); + } else { + // 查询指定图层 + return await fetchFeaturesFromLayer(layer, orFilter); + } + } catch (error) { + console.error("根据 IDs 查询要素时出错:", error); + return []; + } +}; +/** + * 获取地图上所有 VectorTileSource + * @param map OpenLayers 地图对象 + * @returns VectorTileSource 数组 + */ +const getVectorTileSources = (map: Map): VectorTileSource[] => { + return map + .getAllLayers() + .filter((layer) => layer instanceof WebGLVectorTileLayer) + .map((layer) => layer.getSource() as VectorTileSource) + .filter((source) => source !== null); +}; + +/** + * 确保缩放级别在有效范围内 + * @param z 原始缩放级别 + * @param minZoom 最小缩放级别 + * @param maxZoom 最大缩放级别 + * @returns 调整后的缩放级别 + */ +const clampZoomLevel = ( + z: number, + minZoom: number, + maxZoom: number +): number => { + return Math.max(minZoom, Math.min(maxZoom, z)); +}; + +/** + * 按几何类型对要素进行分类 + * @param features 要素数组 + * @returns 分类后的要素对象 + */ +const classifyFeaturesByGeometry = ( + features: Feature[] +): { + points: Feature[]; + lines: Feature[]; + others: Feature[]; +} => { + const points: Feature[] = []; + const lines: Feature[] = []; + const others: Feature[] = []; + + features.forEach((feature) => { + const geometryType = feature.getGeometry()?.getType(); + switch (geometryType) { + case GeometryType.POINT: + points.push(feature); + break; + case GeometryType.LINE_STRING: + lines.push(feature); + break; + default: + others.push(feature); + } + }); + + return { points, lines, others }; +}; + +/** + * 检查要素是否与缓冲区相交 + * @param feature 要素 + * @param buffered 缓冲区几何对象 + * @returns 是否相交 + */ +const isFeatureIntersectsBuffer = ( + feature: Feature, + buffered: any +): boolean => { + if (!feature || !buffered) { + return false; + } + + try { + const geoJSONGeometry = new GeoJSON().writeGeometryObject( + feature.getGeometry()! + ); + const bufferedGeometry = buffered.geometry; + return booleanIntersects(toWgs84(geoJSONGeometry), bufferedGeometry); + } catch (error) { + console.error("要素相交检测失败:", error); + return false; + } +}; + +/** + * 从 VectorTile 中提取选中的要素 + * @param vectorTiles 矢量瓦片数组 + * @param buffered 缓冲区几何对象 + * @returns 选中的要素数组 + */ +const extractSelectedFeatures = ( + vectorTiles: any[], + buffered: any +): Feature[] => { + const allFeatures: Feature[] = []; + + vectorTiles.forEach((vectorTile) => { + if (vectorTile.getState() !== TileState.LOADED) { + return; + } + + const renderFeatures = vectorTile.getFeatures(); + const selectedFeatures = renderFeatures + .map((renderFeature: RenderFeature) => + convertRenderFeatureToFeature(renderFeature) + ) + .filter( + (feature: Feature | undefined): feature is Feature => + feature !== undefined && isFeatureIntersectsBuffer(feature, buffered) + ); + + allFeatures.push(...selectedFeatures); + }); + + return allFeatures; +}; + +/** + * 处理地图点击选择要素 + * @param event 地图点击事件 + * @param map OpenLayers 地图对象 + * @returns 选中的第一个要素的 Promise,如果没有选中则返回 null + */ +const handleMapClickSelectFeatures = async ( + event: MapClickEvent, + map: Map +): Promise => { + if (!map) { + return null; + } + + const coord = event.coordinate; + const view = map.getView(); + const projection = view.getProjection(); + const pixelRatio = window.devicePixelRatio; + + // 获取缩放级别并确保为整数 + let z = Math.floor(view.getZoom() || 0) - 1; + + // 获取所有 VectorTileSource + const vectorTileSources = getVectorTileSources(map); + if (!vectorTileSources.length) { + return null; + } + + // 存储所有选中的要素 + const allSelectedFeatures: Feature[] = []; + + // 遍历所有 VectorTileSource + for (const vectorTileSource of vectorTileSources) { + const tileGrid = vectorTileSource.getTileGrid(); + if (!tileGrid) { + continue; + } + + // 调整缩放级别到有效范围 + const minZoom = tileGrid.getMinZoom(); + const maxZoom = tileGrid.getMaxZoom(); + z = clampZoomLevel(z, minZoom, maxZoom); + + // 获取瓦片坐标 + const tileCoord = tileGrid.getTileCoordForCoordAndZ(coord, z); + const resolution = tileGrid.getResolution(tileCoord[0]); + + // 创建点击点的缓冲区 + const hitPoint = point(toLonLat(coord)); + const buffered = buffer(hitPoint, resolution * MAP_CONFIG.hitTolerance, { + units: MAP_CONFIG.bufferUnits, + }); + + // 获取矢量瓦片 + const vectorRenderTile = vectorTileSource.getTile( + tileCoord[0], + tileCoord[1], + tileCoord[2], + pixelRatio, + projection + ); + + const vectorTiles = vectorTileSource.getSourceTiles( + pixelRatio, + projection, + vectorRenderTile + ); + + // 提取选中的要素 + const selectedFeatures = extractSelectedFeatures(vectorTiles, buffered); + allSelectedFeatures.push(...selectedFeatures); + } + + // 按几何类型优先级排序:点 > 线 > 其他 + const { points, lines, others } = + classifyFeaturesByGeometry(allSelectedFeatures); + const prioritizedFeatures = [...points, ...lines, ...others]; + + // 获取第一个要素的 ID 并查询完整信息 + const firstFeature = prioritizedFeatures[0]; + if (!firstFeature) { + return null; + } + + const queryId = firstFeature.getProperties().id; + if (!queryId) { + return null; + } + + try { + const features = await queryFeaturesByIds([queryId]); + return features[0] || null; + } catch (error) { + console.error("查询要素详情失败:", error); + return null; + } +}; + +// ========== 导出 ========== + +export { handleMapClickSelectFeatures, queryFeaturesByIds }; +export type { MapClickEvent };