From d643f09fcff0a5fc2e18b3020494560a58aba830 Mon Sep 17 00:00:00 2001 From: Huarch Date: Thu, 30 Jul 2026 16:16:51 +0800 Subject: [PATCH] feat(sensor-placement): add scheme engineering editor --- .../MonitoringPlaceOptimizationPanel.tsx | 117 ++- .../OptimizationParameters.tsx | 106 +-- .../SchemeDrawingDialog.tsx | 665 +++++++++++++++ .../SchemeEditor.tsx | 804 ++++++++++++++++++ .../SchemeQuery.tsx | 51 +- .../engineeringDrawing.test.ts | 335 ++++++++ .../engineeringDrawing.ts | 382 +++++++++ .../MonitoringPlaceOptimization/schemeApi.ts | 73 ++ .../schemeEditor.test.ts | 84 ++ .../schemeEditor.ts | 158 ++++ .../MonitoringPlaceOptimization/types.ts | 45 + .../olmap/core/Controls/BaseLayers.test.ts | 26 +- .../olmap/core/Controls/BaseLayers.tsx | 37 +- 13 files changed, 2724 insertions(+), 159 deletions(-) create mode 100644 src/components/olmap/MonitoringPlaceOptimization/SchemeDrawingDialog.tsx create mode 100644 src/components/olmap/MonitoringPlaceOptimization/SchemeEditor.tsx create mode 100644 src/components/olmap/MonitoringPlaceOptimization/engineeringDrawing.test.ts create mode 100644 src/components/olmap/MonitoringPlaceOptimization/engineeringDrawing.ts create mode 100644 src/components/olmap/MonitoringPlaceOptimization/schemeApi.ts create mode 100644 src/components/olmap/MonitoringPlaceOptimization/schemeEditor.test.ts create mode 100644 src/components/olmap/MonitoringPlaceOptimization/schemeEditor.ts create mode 100644 src/components/olmap/MonitoringPlaceOptimization/types.ts diff --git a/src/components/olmap/MonitoringPlaceOptimization/MonitoringPlaceOptimizationPanel.tsx b/src/components/olmap/MonitoringPlaceOptimization/MonitoringPlaceOptimizationPanel.tsx index a7e9a95..772a720 100644 --- a/src/components/olmap/MonitoringPlaceOptimization/MonitoringPlaceOptimizationPanel.tsx +++ b/src/components/olmap/MonitoringPlaceOptimization/MonitoringPlaceOptimizationPanel.tsx @@ -9,6 +9,7 @@ import { Typography, IconButton, Tooltip, + CircularProgress, } from "@mui/material"; import { ChevronRight, @@ -16,6 +17,7 @@ import { Sensors as SensorsIcon, Analytics as AnalyticsIcon, Search as SearchIcon, + TableView as TableIcon, } from "@mui/icons-material"; import OptimizationParameters, { createOptimizationParametersState, @@ -25,16 +27,12 @@ import SchemeQuery, { createMonitoringSchemeQueryState, type MonitoringSchemeQueryState, } from "./SchemeQuery"; - -interface SchemeRecord { - id: number; - schemeName: string; - sensorNumber: number; - minDiameter: number; - user: string; - create_time: string; - sensorLocation?: string[]; -} +import SchemeEditor from "./SchemeEditor"; +import { getSensorPlacementScheme } from "./schemeApi"; +import type { SchemeRecord, SensorPlacementScheme } from "./types"; +import { NETWORK_NAME } from "@/config/config"; +import { useNotification } from "@refinedev/core"; +import PanelEmptyState from "@components/olmap/common/PanelEmptyState"; interface TabPanelProps { children?: React.ReactNode; @@ -49,9 +47,7 @@ const TabPanel: React.FC = ({ children, value, index }) => { hidden={value !== index} className="flex-1 overflow-hidden flex flex-col" > - {value === index && ( - {children} - )} + {children} ); }; @@ -66,6 +62,10 @@ const MonitoringPlaceOptimizationPanel: React.FC< > = ({ open: controlledOpen, onToggle }) => { const [internalOpen, setInternalOpen] = useState(true); const [currentTab, setCurrentTab] = useState(0); + const [activeScheme, setActiveScheme] = + useState(null); + const [loadingScheme, setLoadingScheme] = useState(false); + const { open: notify } = useNotification(); // 持久化方案查询结果 const [schemes, setSchemes] = useState([]); @@ -89,7 +89,42 @@ const MonitoringPlaceOptimizationPanel: React.FC< setCurrentTab(newValue); }; - const drawerWidth = 520; + const drawerWidth = currentTab === 1 ? 820 : 520; + + const handleOpenScheme = async (schemeId: number) => { + setLoadingScheme(true); + setCurrentTab(1); + try { + setActiveScheme(await getSensorPlacementScheme(NETWORK_NAME, schemeId)); + } catch (error) { + const detail = + (error as { response?: { data?: { detail?: string } } }).response?.data + ?.detail || "无法读取方案详情"; + notify?.({ + type: "error", + message: "方案加载失败", + description: detail, + }); + setCurrentTab(2); + } finally { + setLoadingScheme(false); + } + }; + + const handleSchemeSaved = (scheme: SensorPlacementScheme) => { + setActiveScheme(scheme); + setSchemes((current) => + current.map((record) => + record.id === scheme.id + ? { + ...record, + sensorNumber: scheme.sensor_number, + sensorLocation: scheme.sensor_location, + } + : record, + ), + ); + }; return ( <> @@ -132,6 +167,7 @@ const MonitoringPlaceOptimizationPanel: React.FC< right: 16, height: "calc(100vh - 32px)", maxHeight: "850px", + maxWidth: "calc(100vw - 32px)", borderRadius: "12px", boxShadow: "0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)", @@ -193,6 +229,11 @@ const MonitoringPlaceOptimizationPanel: React.FC< iconPosition="start" label="优化要件" /> + } + iconPosition="start" + label="结果编辑" + /> } iconPosition="start" @@ -206,19 +247,59 @@ const MonitoringPlaceOptimizationPanel: React.FC< { + setActiveScheme(scheme); + setCurrentTab(1); + setSchemes((current) => [ + ...current, + { + id: scheme.id, + schemeName: scheme.scheme_name, + sensorNumber: scheme.sensor_number, + minDiameter: scheme.min_diameter, + username: scheme.username, + create_time: scheme.create_time, + sensorLocation: scheme.sensor_location, + }, + ]); + }} /> + {loadingScheme ? ( + + + + ) : activeScheme ? ( + + ) : ( + } + title="暂无可编辑结果" + description="请先在“优化要件”中创建方案,或在“方案查询”中打开已有方案。" + /> + )} + + + { - console.log("定位方案:", id); - // TODO: 在地图上定位 - }} + onEdit={handleOpenScheme} /> diff --git a/src/components/olmap/MonitoringPlaceOptimization/OptimizationParameters.tsx b/src/components/olmap/MonitoringPlaceOptimization/OptimizationParameters.tsx index 3edeea9..adfd3fc 100644 --- a/src/components/olmap/MonitoringPlaceOptimization/OptimizationParameters.tsx +++ b/src/components/olmap/MonitoringPlaceOptimization/OptimizationParameters.tsx @@ -7,22 +7,15 @@ import { Button, Typography, MenuItem, - Stack, } from "@mui/material"; import { PlayArrow as PlayArrowIcon } from "@mui/icons-material"; import { useNotification } from "@refinedev/core"; -import { useGetIdentity } from "@refinedev/core"; -import { api } from "@/lib/api"; -import { config, NETWORK_NAME } from "@/config/config"; +import { NETWORK_NAME } from "@/config/config"; import { useControllableObjectState } from "@components/olmap/core/useControllableState"; - -type IUser = { - id: string; - name?: string; -}; +import { optimizeSensorPlacement } from "./schemeApi"; +import type { SensorPlacementScheme } from "./types"; export interface OptimizationParametersState { - sensorType: string; method: string; sensorCount: number; minDiameter: number; @@ -31,7 +24,6 @@ export interface OptimizationParametersState { export const createOptimizationParametersState = (): OptimizationParametersState => ({ - sensorType: "pressure", method: "kmeans", sensorCount: 5, minDiameter: 5, @@ -41,45 +33,32 @@ export const createOptimizationParametersState = interface OptimizationParametersProps { state?: OptimizationParametersState; onStateChange?: (state: OptimizationParametersState) => void; + onSchemeCreated?: (scheme: SensorPlacementScheme) => void; } const OptimizationParameters: React.FC = ({ state, onStateChange, + onSchemeCreated, }) => { const { open } = useNotification(); - const { data: user } = useGetIdentity(); const [parametersState, , setFormField] = useControllableObjectState( state, onStateChange, createOptimizationParametersState(), ); - const { sensorType, method, sensorCount, minDiameter, schemeName } = + const { method, sensorCount, minDiameter, schemeName } = parametersState; - const [network] = useState(NETWORK_NAME); + const network = NETWORK_NAME; const [analyzing, setAnalyzing] = useState(false); - - // 传感器类型选项 - const sensorTypeOptions = [ - { value: "pressure", label: "压力" }, - { value: "flow", label: "流量" }, - ]; - // 方法选项 const methodOptions = [ { value: "kmeans", label: "聚类分析" }, { value: "sensitivity", label: "灵敏度分析" }, ]; - // 获取传感器类型的中文标签 - const getSensorTypeLabel = (value: string) => { - return ( - sensorTypeOptions.find((option) => option.value === value)?.label || value - ); - }; - // 创建方案 const handleCreateScheme = async () => { // 验证输入 @@ -109,48 +88,22 @@ const OptimizationParameters: React.FC = ({ setAnalyzing(true); - if (!user || !user.id) { - open?.({ - type: "error", - message: "用户信息无效", - }); - return; - } - try { - // 发送优化请求 - const response = await api.post( - `${config.BACKEND_URL}/api/v1/sensor-placement-schemes`, - null, - { - params: { - network: network, - scheme_name: schemeName, - sensor_type: sensorType, - method: method, - sensor_count: sensorCount, - min_diameter: minDiameter, - user_id: user.id, - user_name: user.name, - }, - } - ); - - console.log("响应数据:", response.data); // 添加日志以便调试 - - // 兼容后端返回字符串 "success" 或对象 { success: true } - if (response.data.success === true || response.data === "success") { - open?.({ - type: "success", - message: "方案创建成功", - description: `方案 "${schemeName}" 已完成优化分析`, - }); - - // 重置方案名称 - setFormField("schemeName", "Fangan" + new Date().getTime()); - } else { - throw new Error(response.data?.message || "创建失败"); - } + const created = await optimizeSensorPlacement({ + network, + scheme_name: schemeName, + sensor_type: "pressure", + method: method as "sensitivity" | "kmeans", + sensor_count: sensorCount, + min_diameter: minDiameter, + }); + open?.({ + type: "success", + message: "方案创建成功", + description: `方案 "${schemeName}" 已完成优化分析`, + }); + onSchemeCreated?.(created); + setFormField("schemeName", "Fangan" + new Date().getTime()); } catch (error: any) { console.error("创建方案失败:", error); open?.({ @@ -175,11 +128,10 @@ const OptimizationParameters: React.FC = ({ 类型 setFormField("sensorType", e.target.value)} + value="压力" + slotProps={{ input: { readOnly: true } }} sx={{ "& .MuiOutlinedInput-root": { "&:hover fieldset": { @@ -190,13 +142,7 @@ const OptimizationParameters: React.FC = ({ }, }, }} - > - {sensorTypeOptions.map((option) => ( - - {option.label} - - ))} - + /> {/* 方法选择 */} @@ -270,7 +216,7 @@ const OptimizationParameters: React.FC = ({ variant="subtitle2" className="mb-2 font-semibold text-gray-700" > - {getSensorTypeLabel(sensorType)}监测点安装最小管径(可选) + 压力监测点安装最小管径(可选) void; + scheme: SensorPlacementScheme; + rows: SensorPointRow[]; + network: string; + map: OlMap | null; + dirty: boolean; +} + +const DRAWING_MAP_RENDER_TIMEOUT_MS = 10_000; +const DRAWING_MAP_SIZE: [number, number] = [1600, 981]; + +type DrawingMode = "linework" | "basemap"; + +type DrawingPipeLayer = { + getSource?: () => VectorTileSource | null; + getExtent?: () => number[] | undefined; + getStyle?: () => StyleLike | null | undefined; +}; + +const getDrawingPipeLayer = (map: OlMap) => + map.getAllLayers().find((layer) => layer.get("value") === "pipes") as + DrawingPipeLayer | undefined; + +export const fitMapToFullNetwork = ( + map: OlMap, +): [number, number, number, number] => { + const extent = getDrawingPipeLayer(map)?.getExtent?.(); + if ( + !extent || + extent.length !== 4 || + !extent.every(Number.isFinite) || + extent[0] >= extent[2] || + extent[1] >= extent[3] + ) { + throw new Error("主地图中未配置有效的管网完整范围"); + } + const size = map.getSize(); + if (!size) throw new Error("地图尺寸不可用"); + const networkExtent: [number, number, number, number] = [ + extent[0], + extent[1], + extent[2], + extent[3], + ]; + const view = map.getView(); + const drawingExtent = getPaddedDrawingExtent( + networkExtent, + size[0] / size[1], + ); + view.cancelAnimations(); + view.fit(drawingExtent, { + padding: [0, 0, 0, 0], + size, + }); + return networkExtent; +}; + +interface DrawingMapSession { + map: OlMap; + extent: [number, number, number, number]; + dispose: () => void; +} + +const cloneVisibleBaseLayers = (mainMap: OlMap): TileLayer[] => { + const cloned: TileLayer[] = []; + const collect = (layer: unknown, parentVisible = true) => { + const candidate = layer as { + getVisible?: () => boolean; + getLayers?: () => { getArray: () => unknown[] }; + }; + if (!parentVisible || candidate.getVisible?.() === false) return; + if (layer instanceof LayerGroup) { + candidate.getLayers?.().getArray().forEach((child) => collect(child)); + return; + } + if (!(layer instanceof TileLayer)) return; + const source = layer.getSource(); + if (!source) return; + cloned.push( + new TileLayer({ + source, + opacity: layer.getOpacity(), + visible: true, + }), + ); + }; + mainMap.getLayers().getArray().forEach((layer) => collect(layer)); + return cloned; +}; + +const createDrawingMapSession = ( + mainMap: OlMap, + mode: DrawingMode, +): DrawingMapSession => { + const mainPipeLayer = getDrawingPipeLayer(mainMap); + const source = mainPipeLayer?.getSource?.(); + const extent = mainPipeLayer?.getExtent?.(); + if (!source) throw new Error("主地图中未找到管网图层"); + if ( + !extent || + extent.length !== 4 || + !extent.every(Number.isFinite) || + extent[0] >= extent[2] || + extent[1] >= extent[3] + ) { + throw new Error("主地图中未配置有效的管网完整范围"); + } + + const target = document.createElement("div"); + target.setAttribute("aria-hidden", "true"); + Object.assign(target.style, { + position: "fixed", + left: "-100000px", + top: "0", + width: `${DRAWING_MAP_SIZE[0]}px`, + height: `${DRAWING_MAP_SIZE[1]}px`, + pointerEvents: "none", + }); + document.body.appendChild(target); + + let drawingMap: OlMap | null = null; + try { + const networkExtent: [number, number, number, number] = [ + extent[0], + extent[1], + extent[2], + extent[3], + ]; + const pipeLayer = new VectorTileLayer({ + source, + style: mainPipeLayer?.getStyle?.(), + }); + pipeLayer.set("value", "pipes"); + pipeLayer.setExtent(networkExtent); + const layers = + mode === "basemap" + ? [...cloneVisibleBaseLayers(mainMap), pipeLayer] + : [pipeLayer]; + drawingMap = new OlMap({ + target, + view: new View({ + projection: mainMap.getView().getProjection(), + }), + layers, + controls: [], + interactions: [], + }); + drawingMap.setSize(DRAWING_MAP_SIZE); + fitMapToFullNetwork(drawingMap); + + let disposed = false; + return { + map: drawingMap, + extent: networkExtent, + dispose: () => { + if (disposed) return; + disposed = true; + drawingMap?.setTarget(undefined); + drawingMap?.getLayers().clear(); + target.remove(); + }, + }; + } catch (reason) { + drawingMap?.setTarget(undefined); + target.remove(); + throw reason; + } +}; + +const waitForDrawingMapRender = (map: OlMap) => + new Promise((resolve, reject) => { + let timeout: ReturnType; + const cleanup = () => { + clearTimeout(timeout); + map.un("moveend", requestRender); + map.un("rendercomplete", handleRenderComplete); + }; + const handleRenderComplete = () => { + cleanup(); + resolve(); + }; + const requestRender = () => { + map.once("rendercomplete", handleRenderComplete); + map.render(); + }; + + timeout = setTimeout(() => { + cleanup(); + reject(new Error("工程图管网加载超时,请稍后重试")); + }, DRAWING_MAP_RENDER_TIMEOUT_MS); + + if (map.getView().getAnimating()) { + map.once("moveend", requestRender); + } else { + requestRender(); + } + }); + +const captureMapCanvas = (map: OlMap): HTMLCanvasElement => { + const size = map.getSize(); + if (!size) throw new Error("工程图地图尺寸不可用"); + const output = document.createElement("canvas"); + output.width = size[0]; + output.height = size[1]; + const context = output.getContext("2d"); + if (!context) throw new Error("无法创建地图底图画布"); + context.fillStyle = "#ffffff"; + context.fillRect(0, 0, output.width, output.height); + + const canvases = map + .getViewport() + .querySelectorAll(".ol-layer canvas, canvas.ol-layer"); + canvases.forEach((canvas) => { + if (!canvas.width || !canvas.height) return; + const opacityText = + canvas.parentElement?.style.opacity || canvas.style.opacity || "1"; + const opacity = Number(opacityText); + context.globalAlpha = Number.isFinite(opacity) ? opacity : 1; + + const matrix = canvas.style.transform + .match(/^matrix\(([^)]+)\)$/)?.[1] + .split(",") + .map(Number); + if (matrix?.length === 6 && matrix.every(Number.isFinite)) { + context.setTransform( + matrix[0], + matrix[1], + matrix[2], + matrix[3], + matrix[4], + matrix[5], + ); + } else { + const width = Number.parseFloat(canvas.style.width) || canvas.width; + const height = Number.parseFloat(canvas.style.height) || canvas.height; + context.setTransform( + width / canvas.width, + 0, + 0, + height / canvas.height, + 0, + 0, + ); + } + context.drawImage(canvas, 0, 0); + }); + context.setTransform(1, 0, 0, 1, 0, 0); + context.globalAlpha = 1; + return output; +}; + +const readNetworkDataFromMap = ( + map: OlMap, + extent: [number, number, number, number], +): NetworkDrawingData => { + const pipeLayer = getDrawingPipeLayer(map); + const source = pipeLayer?.getSource?.(); + if (!source) throw new Error("工程图中未找到管网图层"); + + const index = new TileFeatureIndex("engineering-drawing-pipes", source); + index.scanLoadedTiles(); + const snapshot = index.getSnapshot(map, map.getView().getZoom() ?? 0); + const paths = snapshot.instances.flatMap((instance) => { + if (!instance.geometryType.includes("LineString")) return []; + return clipLineStringPartsToExtent( + lineStringFromFlatCoordinates(instance.flatCoordinates, instance.stride), + instance.tileExtent, + ); + }); + if (!paths.length) throw new Error("工程图管网尚未加载完成,请稍后重试"); + + const nodes: string[] = []; + const links: string[] = []; + const nodeIds = new globalThis.Map(); + const getNodeId = ([x, y]: number[]) => { + const key = `${x}:${y}`; + const existing = nodeIds.get(key); + if (existing) return existing; + const nodeId = `drawing_node_${nodeIds.size + 1}`; + nodeIds.set(key, nodeId); + nodes.push(`${nodeId}:junction:${x}:${y}`); + return nodeId; + }; + + paths.forEach((path) => { + for (let index = 1; index < path.length; index += 1) { + const startId = getNodeId(path[index - 1]); + const endId = getNodeId(path[index]); + links.push(`drawing_link_${links.length + 1}:pipe:${startId}:${endId}`); + } + }); + return { nodes, links, extent }; +}; + +const downloadBlob = (blob: Blob, filename: string) => { + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); +}; + +const escapeHtml = (value: string) => + value.replace( + /[&<>"']/g, + (character) => + ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + })[character] ?? character, + ); + +const SchemeDrawingDialog: React.FC = ({ + open, + onClose, + scheme, + rows, + network, + map, + dirty, +}) => { + const [networkData, setNetworkData] = useState( + null, + ); + const [drawingMode, setDrawingMode] = useState("linework"); + const [mapCanvas, setMapCanvas] = useState(null); + const [previewUrl, setPreviewUrl] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const filename = useMemo( + () => `${scheme.scheme_name}${dirty ? "_未保存草稿" : ""}_布置图.png`, + [dirty, scheme.scheme_name], + ); + + useEffect(() => { + if (!open) return; + let cancelled = false; + setLoading(true); + setError(null); + setNetworkData(null); + setMapCanvas(null); + if (!map) { + setError("主地图尚未初始化,请稍后重试"); + setLoading(false); + return; + } + let drawingSession: DrawingMapSession; + try { + drawingSession = createDrawingMapSession(map, drawingMode); + } catch (reason) { + setError( + reason instanceof Error ? reason.message : "无法适配完整管网范围", + ); + setLoading(false); + return; + } + waitForDrawingMapRender(drawingSession.map) + .then(() => { + const data = readNetworkDataFromMap( + drawingSession.map, + drawingSession.extent, + ); + const captured = + drawingMode === "basemap" + ? captureMapCanvas(drawingSession.map) + : null; + return { data, captured }; + }) + .then(({ data, captured }) => { + if (!cancelled) { + setNetworkData(data); + setMapCanvas(captured); + } + }) + .catch((reason) => { + if (!cancelled) { + setError( + reason instanceof Error ? reason.message : "无法读取工程图管网数据", + ); + } + }) + .finally(() => { + drawingSession.dispose(); + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + drawingSession.dispose(); + }; + }, [drawingMode, map, network, open]); + + useEffect(() => { + if (!open || !networkData) return; + let cancelled = false; + let currentUrl: string | null = null; + setLoading(true); + setError(null); + renderEngineeringDrawing({ + scheme, + rows, + network, + networkData, + mapCanvas, + dirty, + width: 1600, + }) + .then(canvasToPngBlob) + .then((blob) => { + if (cancelled) return; + currentUrl = URL.createObjectURL(blob); + setPreviewUrl((previous) => { + if (previous) URL.revokeObjectURL(previous); + return currentUrl; + }); + }) + .catch((reason) => { + if (!cancelled) + setError(reason instanceof Error ? reason.message : "出图失败"); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + if (currentUrl) URL.revokeObjectURL(currentUrl); + }; + }, [dirty, mapCanvas, network, networkData, open, rows, scheme]); + + const createFullResolutionBlob = async () => { + if (!networkData) throw new Error("管网数据尚未加载"); + const canvas = await renderEngineeringDrawing({ + scheme, + rows, + network, + networkData, + mapCanvas, + dirty, + width: A3_LANDSCAPE_WIDTH, + }); + return canvasToPngBlob(canvas); + }; + + const handleDownload = async () => { + setLoading(true); + setError(null); + try { + downloadBlob(await createFullResolutionBlob(), filename); + } catch (reason) { + setError(reason instanceof Error ? reason.message : "PNG 下载失败"); + } finally { + setLoading(false); + } + }; + + const handlePrint = async () => { + const printWindow = window.open("", "_blank"); + if (!printWindow) { + setError("浏览器阻止了打印窗口,请允许本站打开新窗口"); + return; + } + printWindow.opener = null; + printWindow.document.write( + '正在生成 A3 工程图...', + ); + printWindow.document.close(); + setLoading(true); + setError(null); + try { + const blob = await createFullResolutionBlob(); + const url = URL.createObjectURL(blob); + const safeSchemeName = escapeHtml(scheme.scheme_name); + printWindow.document.open(); + printWindow.document.write(` + + + + ${safeSchemeName} 布置图 + + + ${safeSchemeName} 压力监测点布置图 + + `); + printWindow.document.close(); + const image = printWindow.document.querySelector("img"); + image?.addEventListener("load", () => { + printWindow.focus(); + printWindow.print(); + URL.revokeObjectURL(url); + }); + } catch (reason) { + printWindow.close(); + setError(reason instanceof Error ? reason.message : "打印失败"); + } finally { + setLoading(false); + } + }; + + return ( + + + 工程布置图预览 + + + + + setDrawingMode(event.target.value as DrawingMode) + } + aria-label="工程图底图模式" + > + } + label="简化管网线稿" + /> + } + label="地图底图 + 管网" + /> + + + A3 横向 · 300 DPI · 4961 × 3508 + + + {error && ( + + {error} + + )} + + {previewUrl && ( + + )} + {loading && ( + + + + )} + + + + + + + + + ); +}; + +export default SchemeDrawingDialog; diff --git a/src/components/olmap/MonitoringPlaceOptimization/SchemeEditor.tsx b/src/components/olmap/MonitoringPlaceOptimization/SchemeEditor.tsx new file mode 100644 index 0000000..993874f --- /dev/null +++ b/src/components/olmap/MonitoringPlaceOptimization/SchemeEditor.tsx @@ -0,0 +1,804 @@ +"use client"; + +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + Alert, + Box, + Button, + Chip, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + Stack, + Tooltip, + Typography, +} from "@mui/material"; +import { + AddLocationAlt as AddIcon, + DeleteOutline as DeleteIcon, + Download as DownloadIcon, + EditLocationAlt as ReplaceIcon, + LocationOn as LocateIcon, + Map as MapIcon, + Redo as ResetIcon, + Save as SaveIcon, + Undo as UndoIcon, +} from "@mui/icons-material"; +import { + DataGrid, + GridToolbar, + type GridColDef, + type GridRowSelectionModel, +} from "@mui/x-data-grid"; +import { zhCN } from "@mui/x-data-grid/locales"; +import Feature, { type FeatureLike } from "ol/Feature"; +import Point from "ol/geom/Point"; +import VectorLayer from "ol/layer/Vector"; +import VectorSource from "ol/source/Vector"; +import { Circle, Fill, Stroke, Style, Text } from "ol/style"; +import { fromLonLat, toLonLat } from "ol/proj"; +import { useNotification } from "@refinedev/core"; +import { api } from "@/lib/api"; +import { config } from "@/config/config"; +import { useMap } from "@components/olmap/core/MapComponent"; +import { handleMapClickSelectFeatures } from "@/utils/mapQueryService"; +import { + addSensorPoint, + createSchemeEditorState, + deleteSensorPoint, + isSchemeDirty, + replaceSensorPoint, + resetSchemeEdit, + summarizeChanges, + toSensorPointRows, + undoSchemeEdit, +} from "./schemeEditor"; +import { + exportSensorPlacementExcel, + overwriteSensorPlacementScheme, +} from "./schemeApi"; +import SchemeDrawingDialog from "./SchemeDrawingDialog"; +import type { + AdjustmentStatus, + SensorPlacementScheme, + SensorPoint, + SensorPointRow, +} from "./types"; + +type EditMode = "idle" | "add" | "replace" | "delete"; + +interface SchemeEditorProps { + scheme: SensorPlacementScheme; + network: string; + active?: boolean; + onSaved?: (scheme: SensorPlacementScheme) => void; +} + +const STATUS_LABELS: Record = { + current: "当前方案", + original: "原方案", + added: "新增", + replaced: "替换", +}; + +const STATUS_COLORS: Record< + AdjustmentStatus, + "default" | "success" | "warning" | "info" +> = { + current: "default", + original: "info", + added: "success", + replaced: "warning", +}; + +const markerStyle = (feature: FeatureLike) => { + const status = feature.get("adjustment_status") as AdjustmentStatus; + const selected = Boolean(feature.get("selected")); + const color = + status === "added" ? "#16865b" : status === "replaced" ? "#d06b16" : "#c9252d"; + return new Style({ + image: new Circle({ + radius: selected ? 11 : 9, + fill: new Fill({ color: "#ffffff" }), + stroke: new Stroke({ color, width: selected ? 4 : 3 }), + }), + text: new Text({ + text: String(feature.get("sequence") ?? ""), + font: '700 11px -apple-system, "PingFang SC", sans-serif', + fill: new Fill({ color }), + offsetY: 0.5, + }), + zIndex: selected ? 20 : 10, + }); +}; + +const errorDescription = (error: unknown) => { + const candidate = error as { + response?: { data?: { detail?: string } }; + message?: string; + }; + return candidate.response?.data?.detail || candidate.message || "请求失败"; +}; + +const SchemeEditor: React.FC = ({ + scheme, + network, + active = true, + onSaved, +}) => { + const map = useMap(); + const { open } = useNotification(); + const [editor, setEditor] = useState(() => createSchemeEditorState(scheme)); + const [mode, setMode] = useState("idle"); + const [selectedNodeId, setSelectedNodeId] = useState(null); + const [replaceSourceId, setReplaceSourceId] = useState(null); + const [saving, setSaving] = useState(false); + const [exporting, setExporting] = useState(false); + const [saveDialogOpen, setSaveDialogOpen] = useState(false); + const [drawingOpen, setDrawingOpen] = useState(false); + const markerLayerRef = useRef | null>(null); + + useEffect(() => { + setEditor(createSchemeEditorState(scheme)); + setMode("idle"); + setReplaceSourceId(null); + setSelectedNodeId(null); + }, [scheme]); + + const rows = useMemo(() => toSensorPointRows(editor), [editor]); + const dirty = isSchemeDirty(editor); + const changes = summarizeChanges(editor); + + useEffect(() => { + if (!map) return; + const layer = new VectorLayer({ + source: new VectorSource(), + style: markerStyle, + properties: { + name: "监测点方案编辑", + value: "sensor_scheme_editor", + queryable: false, + }, + zIndex: 120, + }); + markerLayerRef.current = layer; + map.addLayer(layer); + return () => { + markerLayerRef.current = null; + map.removeLayer(layer); + }; + }, [map]); + + useEffect(() => { + markerLayerRef.current?.setVisible(active); + }, [active]); + + useEffect(() => { + const source = markerLayerRef.current?.getSource(); + if (!source) return; + source.clear(); + rows.forEach((row) => { + const feature = new Feature({ + geometry: new Point([row.map_x, row.map_y]), + node_id: row.node_id, + sequence: row.sequence, + adjustment_status: row.adjustment_status, + selected: row.node_id === selectedNodeId, + }); + feature.setId(`sensor-scheme-${row.node_id}`); + source.addFeature(feature); + }); + }, [rows, selectedNodeId]); + + const resolveJunction = useCallback( + async (event: any): Promise => { + if (!map) return null; + const feature = await handleMapClickSelectFeatures(event, map); + const nodeId = String(feature?.get("id") ?? feature?.getId() ?? "").trim(); + if (!nodeId) return null; + const featureGeometry = feature?.getGeometry(); + const featureCoordinate = + featureGeometry instanceof Point + ? featureGeometry.getCoordinates() + : event.coordinate; + const projectedFeatureCoordinate = + Math.abs(featureCoordinate[0]) <= 180 && + Math.abs(featureCoordinate[1]) <= 90 + ? fromLonLat(featureCoordinate) + : featureCoordinate; + const resolution = map.getView().getResolution() ?? 1; + const isAlignedWithMap = + Math.hypot( + projectedFeatureCoordinate[0] - event.coordinate[0], + projectedFeatureCoordinate[1] - event.coordinate[1], + ) <= + resolution * 20; + const [mapX, mapY] = isAlignedWithMap + ? projectedFeatureCoordinate + : event.coordinate; + try { + const response = await api.get<{ + id: string; + x: number; + y: number; + elevation: number; + }>(`${config.BACKEND_URL}/api/v1/getjunctionproperties/`, { + params: { network, junction: nodeId }, + }); + if (!response.data?.id) return null; + const [longitude, latitude] = toLonLat([mapX, mapY]); + return { + node_id: String(response.data.id), + project_x: Number(response.data.x), + project_y: Number(response.data.y), + map_x: mapX, + map_y: mapY, + longitude, + latitude, + elevation: Number(response.data.elevation), + }; + } catch { + return null; + } + }, + [map, network], + ); + + useEffect(() => { + if (!active || !map || mode === "idle" || !scheme.can_edit) return; + let handling = false; + const processClick = async (event: any) => { + if (handling) return; + event.stopPropagation?.(); + handling = true; + try { + const markerLayer = markerLayerRef.current; + const marker = markerLayer + ? map.forEachFeatureAtPixel( + event.pixel, + (feature) => feature as Feature, + { + hitTolerance: 8, + layerFilter: (layer) => layer === markerLayer, + }, + ) + : undefined; + const markerNodeId = marker ? String(marker.get("node_id")) : null; + + if (mode === "delete") { + if (!markerNodeId) { + open?.({ type: "progress", message: "请点击要删除的监测点" }); + return; + } + setEditor((current) => deleteSensorPoint(current, markerNodeId)); + setSelectedNodeId(null); + return; + } + + if (mode === "replace" && !replaceSourceId) { + if (!markerNodeId) { + open?.({ type: "progress", message: "请先点击要替换的监测点" }); + return; + } + setReplaceSourceId(markerNodeId); + setSelectedNodeId(markerNodeId); + return; + } + + const candidate = await resolveJunction(event); + if (!candidate) { + open?.({ type: "progress", message: "请选择有效的管网节点" }); + return; + } + + if (mode === "add") { + const exists = editor.points.some( + (point) => point.node_id === candidate.node_id, + ); + if (exists) { + open?.({ type: "progress", message: "该节点已在当前方案中" }); + return; + } + setEditor((current) => addSensorPoint(current, candidate)); + setSelectedNodeId(candidate.node_id); + } else if (mode === "replace" && replaceSourceId) { + const duplicate = editor.points.some( + (point) => + point.node_id === candidate.node_id && + point.node_id !== replaceSourceId, + ); + if (duplicate) { + open?.({ type: "progress", message: "目标节点已在当前方案中" }); + return; + } + setEditor((current) => + replaceSensorPoint(current, replaceSourceId, candidate), + ); + setSelectedNodeId(candidate.node_id); + setReplaceSourceId(null); + setMode("idle"); + } + } finally { + handling = false; + } + }; + const handleClick = (event: any) => { + void processClick(event); + }; + map.on("singleclick", handleClick); + return () => { + map.un("singleclick", handleClick); + }; + }, [ + editor.points, + active, + map, + mode, + network, + open, + replaceSourceId, + resolveJunction, + scheme.can_edit, + ]); + + const activateMode = useCallback( + (nextMode: EditMode, sourceNodeId?: string) => { + setMode((current) => + current === nextMode && !sourceNodeId ? "idle" : nextMode, + ); + setReplaceSourceId(sourceNodeId ?? null); + if (sourceNodeId) setSelectedNodeId(sourceNodeId); + }, + [], + ); + + const handleDelete = useCallback((nodeId: string) => { + setEditor((current) => deleteSensorPoint(current, nodeId)); + if (selectedNodeId === nodeId) setSelectedNodeId(null); + }, [selectedNodeId]); + + const locateRow = useCallback((row: SensorPointRow) => { + setSelectedNodeId(row.node_id); + if (!map) return; + map.getView().animate({ + center: [row.map_x, row.map_y], + zoom: Math.max(map.getView().getZoom() ?? 16, 18), + duration: 300, + }); + }, [map]); + + const handleSave = async () => { + setSaving(true); + try { + const updated = await overwriteSensorPlacementScheme( + network, + scheme.id, + editor.baseline.map((point) => point.node_id), + editor.points.map((point) => point.node_id), + ); + setEditor(createSchemeEditorState(updated)); + onSaved?.(updated); + setSaveDialogOpen(false); + open?.({ + type: "success", + message: "方案已覆盖保存", + description: `当前共 ${updated.sensor_number} 个监测点`, + }); + } catch (error) { + open?.({ + type: "error", + message: + (error as { response?: { status?: number } }).response?.status === 409 + ? "方案已发生变化" + : "方案保存失败", + description: errorDescription(error), + }); + } finally { + setSaving(false); + } + }; + + const handleExport = async () => { + setExporting(true); + try { + const blob = await exportSensorPlacementExcel( + network, + scheme.id, + editor.points.map((point) => point.node_id), + editor.statuses, + ); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `${scheme.scheme_name}${dirty ? "_未保存草稿" : ""}_监测点清单.xlsx`; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + } catch (error) { + open?.({ + type: "error", + message: "Excel 导出失败", + description: errorDescription(error), + }); + } finally { + setExporting(false); + } + }; + + const columns = useMemo[]>( + () => [ + { + field: "sequence", + headerName: "序号", + width: 64, + align: "center", + headerAlign: "center", + sortable: false, + }, + { field: "node_id", headerName: "节点 ID", minWidth: 120, flex: 0.8 }, + { + field: "longitude", + headerName: "经度", + minWidth: 125, + flex: 1, + align: "right", + headerAlign: "right", + valueFormatter: (value) => Number(value).toFixed(6), + }, + { + field: "latitude", + headerName: "纬度", + minWidth: 125, + flex: 1, + align: "right", + headerAlign: "right", + valueFormatter: (value) => Number(value).toFixed(6), + }, + { + field: "project_x", + headerName: "工程 X", + minWidth: 130, + flex: 1, + align: "right", + headerAlign: "right", + valueFormatter: (value) => Number(value).toFixed(3), + }, + { + field: "project_y", + headerName: "工程 Y", + minWidth: 130, + flex: 1, + align: "right", + headerAlign: "right", + valueFormatter: (value) => Number(value).toFixed(3), + }, + { + field: "map_x", + headerName: "地图 X", + minWidth: 130, + flex: 1, + align: "right", + headerAlign: "right", + valueFormatter: (value) => Number(value).toFixed(3), + }, + { + field: "map_y", + headerName: "地图 Y", + minWidth: 130, + flex: 1, + align: "right", + headerAlign: "right", + valueFormatter: (value) => Number(value).toFixed(3), + }, + { + field: "elevation", + headerName: "高程", + width: 92, + align: "right", + headerAlign: "right", + valueFormatter: (value) => Number(value).toFixed(3), + }, + { + field: "adjustment_status", + headerName: "调整状态", + width: 108, + renderCell: ({ value }) => { + const status = value as AdjustmentStatus; + return ( + + ); + }, + }, + { + field: "actions", + headerName: "操作", + width: scheme.can_edit ? 138 : 54, + sortable: false, + filterable: false, + renderCell: ({ row }) => ( + + + locateRow(row)} + sx={{ width: 40, height: 40 }} + > + + + + {scheme.can_edit && ( + <> + + activateMode("replace", row.node_id)} + sx={{ width: 40, height: 40 }} + > + + + + + + handleDelete(row.node_id)} + disabled={rows.length <= 1} + sx={{ width: 40, height: 40 }} + > + + + + + + )} + + ), + }, + ], + [activateMode, handleDelete, locateRow, rows.length, scheme.can_edit], + ); + + const instruction = + mode === "add" + ? "点击地图中的管网节点添加监测点" + : mode === "replace" + ? replaceSourceId + ? `已选择 ${replaceSourceId},请点击新的管网节点` + : "请先点击要替换的监测点" + : mode === "delete" + ? "点击地图中的监测点删除" + : null; + + return ( + + + + + {scheme.scheme_name} + + + {scheme.username} · {rows.length} 个监测点 + {dirty ? " · 未保存草稿" : ""} + + + + + + {scheme.can_edit && ( + + )} + + + + {!scheme.can_edit && ( + 当前为只读方案,创建人或管理员可以微调并保存。 + )} + + {scheme.can_edit && ( + + + + + + + + setEditor((current) => undoSchemeEdit(current))} + sx={{ width: 40, height: 40 }} + > + + + + + + + setEditor((current) => resetSchemeEdit(current))} + sx={{ width: 40, height: 40 }} + > + + + + + + )} + + {instruction && ( + { + setMode("idle"); + setReplaceSourceId(null); + }} + > + 取消 + + } + > + {instruction} + + )} + + + row.node_id} + rowHeight={48} + columnHeaderHeight={44} + density="compact" + disableRowSelectionOnClick={false} + rowSelectionModel={selectedNodeId ? [selectedNodeId] : []} + onRowSelectionModelChange={(selection: GridRowSelectionModel) => + setSelectedNodeId(selection.length ? String(selection[0]) : null) + } + onRowDoubleClick={({ row }) => locateRow(row)} + slots={{ toolbar: GridToolbar }} + slotProps={{ + toolbar: { + showQuickFilter: true, + printOptions: { disableToolbarButton: true }, + csvOptions: { disableToolbarButton: true }, + }, + }} + sx={{ + borderColor: "rgba(15, 23, 42, 0.10)", + "& .MuiDataGrid-columnHeaders": { bgcolor: "#edf3f7" }, + "& .MuiDataGrid-cell": { + borderColor: "rgba(15, 23, 42, 0.06)", + fontVariantNumeric: "tabular-nums", + }, + "& .MuiDataGrid-row.Mui-selected": { + bgcolor: "rgba(37, 125, 212, 0.10)", + }, + }} + /> + + + !saving && setSaveDialogOpen(false)} + aria-labelledby="overwrite-scheme-title" + > + 覆盖当前方案 + + + 保存后将覆盖原方案节点。此次调整包含:新增 {changes.added} 个,替换{" "} + {changes.replaced} 个,删除 {changes.removed} 个,保存后共 {rows.length} 个。 + + + + + + + + + setDrawingOpen(false)} + scheme={scheme} + rows={rows} + network={network} + map={map ?? null} + dirty={dirty} + /> + + ); +}; + +export default SchemeEditor; diff --git a/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx b/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx index 0036bd6..4aa4670 100644 --- a/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx +++ b/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx @@ -17,7 +17,7 @@ import { } from "@mui/material"; import { Info as InfoIcon, - LocationOn as LocationIcon, + EditLocationAlt as EditIcon, } from "@mui/icons-material"; import { DatePicker } from "@mui/x-date-pickers/DatePicker"; import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; @@ -37,19 +37,10 @@ import VectorSource from "ol/source/Vector"; import { Style, Icon, Circle, Fill, Stroke } from "ol/style"; import Feature, { FeatureLike } from "ol/Feature"; import { bbox, featureCollection } from "@turf/turf"; +import type { SchemeRecord } from "./types"; import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName"; import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState"; -interface SchemeRecord { - id: number; - schemeName: string; - sensorNumber: number; - minDiameter: number; - user: string; - create_time: string; - sensorLocation?: string[]; -} - interface SchemaItem { id: number; scheme_name: string; @@ -63,7 +54,7 @@ interface SchemaItem { interface SchemeQueryProps { schemes?: SchemeRecord[]; onSchemesChange?: (schemes: SchemeRecord[]) => void; - onLocate?: (id: number) => void; + onEdit?: (id: number) => void; network?: string; state?: MonitoringSchemeQueryState; onStateChange?: (state: MonitoringSchemeQueryState) => void; @@ -87,7 +78,7 @@ export const createMonitoringSchemeQueryState = const SchemeQuery: React.FC = ({ schemes: externalSchemes, onSchemesChange, - onLocate, + onEdit, network = NETWORK_NAME, state, onStateChange, @@ -205,7 +196,7 @@ const SchemeQuery: React.FC = ({ schemeName: item.scheme_name, sensorNumber: item.sensor_number, minDiameter: item.min_diameter, - user: item.username, + username: item.username, create_time: item.create_time, sensorLocation: item.sensor_location, })); @@ -275,15 +266,6 @@ const SchemeQuery: React.FC = ({ setQueryField("expandedId", expandedId === id ? null : id); }; - // 保存方案(示例功能) - const handleSaveScheme = (scheme: SchemeRecord) => { - open?.({ - type: "success", - message: "保存成功", - description: `方案 "${scheme.schemeName}" 已保存`, - }); - }; - return ( {/* 查询条件 - 单行布局 */} @@ -379,8 +361,8 @@ const SchemeQuery: React.FC = ({ variant="caption" className="text-gray-500 block" > - 最小半径: {scheme.minDiameter} · 用户:{" "} - {creatorName(scheme.user)} · 日期:{" "} + 最小管径: {scheme.minDiameter} · 用户:{" "} + {creatorName(scheme.username)} · 日期:{" "} {formatShortDate(scheme.create_time)} @@ -405,16 +387,6 @@ const SchemeQuery: React.FC = ({ - {/* - onLocate?.(scheme.id)} - color="primary" - className="p-1" - > - - - */} @@ -445,7 +417,7 @@ const SchemeQuery: React.FC = ({ variant="caption" className="text-gray-600 min-w-[70px]" > - 最小半径: + 最小管径: = ({ variant="caption" className="font-medium text-gray-900" > - {creatorName(scheme.user)} + {creatorName(scheme.username)} @@ -552,13 +524,14 @@ const SchemeQuery: React.FC = ({ fullWidth size="small" className="bg-blue-600 hover:bg-blue-700" - onClick={() => handleSaveScheme(scheme)} + startIcon={} + onClick={() => onEdit?.(scheme.id)} sx={{ textTransform: "none", fontWeight: 500, }} > - 保存方案 + 打开结果编辑 diff --git a/src/components/olmap/MonitoringPlaceOptimization/engineeringDrawing.test.ts b/src/components/olmap/MonitoringPlaceOptimization/engineeringDrawing.test.ts new file mode 100644 index 0000000..a0243b1 --- /dev/null +++ b/src/components/olmap/MonitoringPlaceOptimization/engineeringDrawing.test.ts @@ -0,0 +1,335 @@ +jest.mock("@components/olmap/core/tileFeatureIndex", () => ({ + TileFeatureIndex: jest.fn(), + clipLineStringPartsToExtent: jest.fn(), + lineStringFromFlatCoordinates: jest.fn(), +})); + +jest.mock("ol/View", () => ({ + __esModule: true, + default: jest.fn().mockImplementation(() => ({ + cancelAnimations: jest.fn(), + fit: jest.fn(), + getAnimating: () => false, + getProjection: () => "EPSG:3857", + getZoom: () => 12, + })), +})); + +jest.mock("ol/layer/VectorTile", () => ({ + __esModule: true, + default: jest.fn().mockImplementation(({ source }) => { + let extent: number[] | undefined; + const properties = new Map(); + return { + get: (key: string) => properties.get(key), + getExtent: () => extent, + getSource: () => source, + set: (key: string, value: unknown) => properties.set(key, value), + setExtent: (value: number[]) => { + extent = value; + }, + }; + }), +})); + +jest.mock("ol/layer/Group", () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mock("ol/layer/Tile", () => ({ + __esModule: true, + default: jest.fn().mockImplementation(({ source }) => ({ + getOpacity: () => 1, + getSource: () => source, + })), +})); + +jest.mock("ol/Map", () => ({ + __esModule: true, + default: jest.fn().mockImplementation(({ layers, view }) => { + let size: number[] | undefined; + let renderComplete: (() => void) | undefined; + return { + getAllLayers: () => layers, + getLayers: () => ({ clear: jest.fn() }), + getSize: () => size, + getView: () => view, + once: (event: string, callback: () => void) => { + if (event === "rendercomplete") renderComplete = callback; + }, + render: () => renderComplete?.(), + setSize: (value: number[]) => { + size = value; + }, + setTarget: jest.fn(), + un: jest.fn(), + }; + }), +})); + +import { render, screen, waitFor } from "@testing-library/react"; +import { createElement } from "react"; +import { + getPaddedDrawingExtent, + renderEngineeringDrawing, +} from "./engineeringDrawing"; +import SchemeDrawingDialog, { + fitMapToFullNetwork, +} from "./SchemeDrawingDialog"; +import type { SensorPlacementScheme, SensorPointRow } from "./types"; + +const createContext = () => ({ + arc: jest.fn(), + beginPath: jest.fn(), + clip: jest.fn(), + closePath: jest.fn(), + drawImage: jest.fn(), + fill: jest.fn(), + fillRect: jest.fn(), + fillText: jest.fn(), + lineTo: jest.fn(), + moveTo: jest.fn(), + rect: jest.fn(), + restore: jest.fn(), + save: jest.fn(), + setTransform: jest.fn(), + stroke: jest.fn(), + strokeRect: jest.fn(), + strokeText: jest.fn(), +}); + +const point = ( + node_id: string, + sequence: number, + map_x: number, + map_y: number, +): SensorPointRow => ({ + node_id, + sequence, + map_x, + map_y, + project_x: map_x, + project_y: map_y, + longitude: 121, + latitude: 31, + elevation: 5, + adjustment_status: "current", +}); + +const rows = [point("A", 1, 400, 200), point("B", 2, 500, 300)]; + +const scheme: SensorPlacementScheme = { + id: 1, + scheme_name: "测试方案", + sensor_number: rows.length, + min_diameter: 300, + username: "tester", + create_time: "2026-07-30T08:00:00+08:00", + sensor_location: rows.map((row) => row.node_id), + sensor_points: rows, + can_edit: true, +}; + +describe("engineering drawing map framing", () => { + it("does not change the visible main-map view while opening the drawing", async () => { + const cancelAnimations = jest.fn(); + const fit = jest.fn(); + const setCenter = jest.fn(); + const setResolution = jest.fn(); + const setRotation = jest.fn(); + const getAllLayers = jest.fn(() => [ + { + get: (key: string) => (key === "value" ? "pipes" : undefined), + getExtent: () => [0, 0, 1000, 1000], + getSource: () => ({}), + }, + ]); + let renderComplete: (() => void) | undefined; + const map = { + getAllLayers, + getSize: () => [1200, 800], + getView: () => ({ + cancelAnimations, + fit, + getAnimating: () => false, + getCenter: () => [500, 500], + getResolution: () => 10, + getRotation: () => 0, + getProjection: () => "EPSG:3857", + getZoom: () => 12, + setCenter, + setResolution, + setRotation, + }), + once: (event: string, callback: () => void) => { + if (event === "rendercomplete") renderComplete = callback; + }, + un: jest.fn(), + render: jest.fn(() => renderComplete?.()), + }; + + render( + createElement(SchemeDrawingDialog, { + open: true, + onClose: jest.fn(), + scheme, + rows, + network: "test", + map: map as never, + dirty: false, + }), + ); + + await waitFor(() => expect(getAllLayers).toHaveBeenCalled()); + expect(screen.getByLabelText("简化管网线稿")).toBeChecked(); + expect(screen.getByLabelText("地图底图 + 管网")).toBeInTheDocument(); + expect(fit).not.toHaveBeenCalled(); + expect(setCenter).not.toHaveBeenCalled(); + expect(setResolution).not.toHaveBeenCalled(); + expect(setRotation).not.toHaveBeenCalled(); + }); + + it("fits the OpenLayers map to the complete pipe layer extent", () => { + const extent = [13500000, 3600000, 13600000, 3700000]; + const cancelAnimations = jest.fn(); + const fit = jest.fn(); + const map = { + getAllLayers: () => [ + { + get: (key: string) => (key === "value" ? "pipes" : undefined), + getExtent: () => extent, + }, + ], + getSize: () => [1200, 800], + getView: () => ({ cancelAnimations, fit }), + }; + + expect(fitMapToFullNetwork(map as never)).toEqual(extent); + expect(cancelAnimations).toHaveBeenCalledTimes(1); + expect(fit).toHaveBeenCalledWith( + getPaddedDrawingExtent(extent, 1200 / 800), + { + padding: [0, 0, 0, 0], + size: [1200, 800], + }, + ); + expect(cancelAnimations.mock.invocationCallOrder[0]).toBeLessThan( + fit.mock.invocationCallOrder[0], + ); + }); +}); + +describe("engineering drawing", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("draws the complete network even when sensors occupy a local area", async () => { + const context = createContext(); + jest + .spyOn(HTMLCanvasElement.prototype, "getContext") + .mockReturnValue(context as unknown as CanvasRenderingContext2D); + + await renderEngineeringDrawing({ + scheme, + rows, + network: "test", + networkData: { + nodes: ["A:junction:8000:4000", "B:junction:9000:4500"], + links: ["P1:pipe:A:B"], + extent: [0, 0, 10000, 5000], + }, + dirty: false, + width: 1600, + }); + + // The first path is the remote pipe. A sensor-derived extent would skip it, + // leaving the title-block divider as the first path near the sheet bottom. + const [pipeStartX, pipeStartY] = context.moveTo.mock.calls[0]; + const [pipeEndX, pipeEndY] = context.lineTo.mock.calls[0]; + expect(pipeStartX).toBeGreaterThan(1200); + expect(pipeStartY).toBeLessThan(400); + expect(pipeEndX).toBeGreaterThan(pipeStartX); + expect(pipeEndY).toBeLessThan(pipeStartY); + }); + + it("uses one scale for linework coordinates", async () => { + const context = createContext(); + jest + .spyOn(HTMLCanvasElement.prototype, "getContext") + .mockReturnValue(context as unknown as CanvasRenderingContext2D); + + await renderEngineeringDrawing({ + scheme, + rows, + network: "test", + networkData: { + nodes: ["A:junction:400:200", "B:junction:500:300"], + links: ["P1:pipe:A:B"], + extent: [0, 0, 1000, 1000], + }, + dirty: false, + width: 1600, + }); + + const [startX, startY] = context.moveTo.mock.calls[0]; + const [endX, endY] = context.lineTo.mock.calls[0]; + expect(Math.abs(endX - startX)).toBeCloseTo(Math.abs(endY - startY), 6); + }); + + it("keeps visible space on the left and right of the complete network", async () => { + const context = createContext(); + jest + .spyOn(HTMLCanvasElement.prototype, "getContext") + .mockReturnValue(context as unknown as CanvasRenderingContext2D); + + await renderEngineeringDrawing({ + scheme, + rows, + network: "test", + networkData: { + nodes: ["A:junction:0:0", "B:junction:1631:1000"], + links: ["P1:pipe:A:B"], + extent: [0, 0, 1631, 1000], + }, + dirty: false, + width: 1600, + }); + + const [startX] = context.moveTo.mock.calls[0]; + const [endX] = context.lineTo.mock.calls[0]; + expect(startX).toBeGreaterThan(100); + expect(endX).toBeLessThan(1500); + }); + + it("uses the captured OpenLayers map for the basemap drawing mode", async () => { + const context = createContext(); + jest + .spyOn(HTMLCanvasElement.prototype, "getContext") + .mockReturnValue(context as unknown as CanvasRenderingContext2D); + const mapCanvas = document.createElement("canvas"); + + await renderEngineeringDrawing({ + scheme, + rows, + network: "test", + networkData: { + nodes: ["A:junction:0:0", "B:junction:1000:1000"], + links: ["P1:pipe:A:B"], + extent: [0, 0, 1000, 1000], + }, + mapCanvas, + dirty: false, + width: 1600, + }); + + expect(context.drawImage).toHaveBeenCalledWith( + mapCanvas, + expect.any(Number), + expect.any(Number), + expect.any(Number), + expect.any(Number), + ); + }); +}); diff --git a/src/components/olmap/MonitoringPlaceOptimization/engineeringDrawing.ts b/src/components/olmap/MonitoringPlaceOptimization/engineeringDrawing.ts new file mode 100644 index 0000000..d1d6fcd --- /dev/null +++ b/src/components/olmap/MonitoringPlaceOptimization/engineeringDrawing.ts @@ -0,0 +1,382 @@ +import type { + NetworkDrawingData, + SensorPlacementScheme, + SensorPointRow, +} from "./types"; + +export const A3_LANDSCAPE_WIDTH = 4961; +export const A3_LANDSCAPE_HEIGHT = 3508; + +interface DrawingOptions { + scheme: SensorPlacementScheme; + rows: SensorPointRow[]; + network: string; + networkData: NetworkDrawingData; + mapCanvas?: HTMLCanvasElement | null; + dirty: boolean; + width?: number; +} + +interface ParsedNode { + id: string; + x: number; + y: number; +} + +type DrawingExtent = [number, number, number, number]; +export const DRAWING_CONTENT_PADDING_RATIO = 0.04; + +const toDrawingExtent = (extent: number[]): DrawingExtent => { + if ( + extent.length !== 4 || + !extent.every(Number.isFinite) || + extent[0] >= extent[2] || + extent[1] >= extent[3] + ) { + throw new Error("地图范围不可用"); + } + return [extent[0], extent[1], extent[2], extent[3]]; +}; + +const parseNode = (value: string): ParsedNode | null => { + const [id, , x, y] = value.split(":"); + const parsedX = Number(x); + const parsedY = Number(y); + if (!id || !Number.isFinite(parsedX) || !Number.isFinite(parsedY)) + return null; + return { id, x: parsedX, y: parsedY }; +}; + +const fitExtentToAspectRatio = ( + extent: DrawingExtent, + targetAspectRatio: number, +): DrawingExtent => { + const [minX, minY, maxX, maxY] = extent; + const centerX = (minX + maxX) / 2; + const centerY = (minY + maxY) / 2; + let spanX = maxX - minX; + let spanY = maxY - minY; + + if (spanX / spanY > targetAspectRatio) { + spanY = spanX / targetAspectRatio; + } else { + spanX = spanY * targetAspectRatio; + } + + return [ + centerX - spanX / 2, + centerY - spanY / 2, + centerX + spanX / 2, + centerY + spanY / 2, + ]; +}; + +export const getPaddedDrawingExtent = ( + extent: number[], + targetAspectRatio: number, +): DrawingExtent => { + const fitted = fitExtentToAspectRatio( + toDrawingExtent(extent), + targetAspectRatio, + ); + const [minX, minY, maxX, maxY] = fitted; + const horizontalPadding = + ((maxX - minX) * DRAWING_CONTENT_PADDING_RATIO) / + (1 - DRAWING_CONTENT_PADDING_RATIO * 2); + const verticalPadding = + ((maxY - minY) * DRAWING_CONTENT_PADDING_RATIO) / + (1 - DRAWING_CONTENT_PADDING_RATIO * 2); + return [ + minX - horizontalPadding, + minY - verticalPadding, + maxX + horizontalPadding, + maxY + verticalPadding, + ]; +}; + +const drawText = ( + context: CanvasRenderingContext2D, + value: string, + x: number, + y: number, + size: number, + weight = 400, + align: CanvasTextAlign = "left", + outlineColor?: string, + outlineWidth = 0, +) => { + context.font = `${weight} ${size}px -apple-system, "PingFang SC", "Noto Sans SC", sans-serif`; + context.textAlign = align; + context.textBaseline = "middle"; + if (outlineColor && outlineWidth > 0) { + context.strokeStyle = outlineColor; + context.lineWidth = outlineWidth; + context.lineJoin = "round"; + context.strokeText(value, x, y); + } + context.fillText(value, x, y); +}; + +export const renderEngineeringDrawing = async ({ + scheme, + rows, + network, + networkData, + mapCanvas, + dirty, + width = A3_LANDSCAPE_WIDTH, +}: DrawingOptions): Promise => { + if (!rows.length) throw new Error("方案没有可出图的监测点"); + const height = Math.round((width * A3_LANDSCAPE_HEIGHT) / A3_LANDSCAPE_WIDTH); + const scale = width / A3_LANDSCAPE_WIDTH; + const px = (value: number) => value * scale; + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext("2d"); + if (!context) throw new Error("无法创建工程图画布"); + + context.fillStyle = "#ffffff"; + context.fillRect(0, 0, width, height); + context.strokeStyle = "#111827"; + context.lineWidth = px(8); + context.strokeRect(px(70), px(70), width - px(140), height - px(140)); + + const mapBox = { + x: px(150), + y: px(150), + width: width - px(300), + height: height - px(650), + }; + context.save(); + context.beginPath(); + context.rect(mapBox.x, mapBox.y, mapBox.width, mapBox.height); + context.clip(); + + const extent = getPaddedDrawingExtent( + networkData.extent, + mapBox.width / mapBox.height, + ); + context.fillStyle = "#fbfcfd"; + context.fillRect(mapBox.x, mapBox.y, mapBox.width, mapBox.height); + if (mapCanvas) { + context.drawImage( + mapCanvas, + mapBox.x, + mapBox.y, + mapBox.width, + mapBox.height, + ); + } + + const nodes = networkData.nodes + .map(parseNode) + .filter((node): node is ParsedNode => Boolean(node)); + const nodesById = new globalThis.Map( + nodes.map((node) => [node.id, node] as const), + ); + const [minX, minY, maxX, maxY] = extent; + const toCanvas = (x: number, y: number) => ({ + x: mapBox.x + ((x - minX) / (maxX - minX)) * mapBox.width, + y: + mapBox.y + mapBox.height - ((y - minY) / (maxY - minY)) * mapBox.height, + }); + + if (!mapCanvas) { + context.strokeStyle = "#45677a"; + context.lineWidth = px(5); + for (const link of networkData.links) { + const [, , startId, endId] = link.split(":"); + const start = nodesById.get(startId); + const end = nodesById.get(endId); + if (!start || !end) continue; + if ( + Math.max(start.x, end.x) < minX || + Math.min(start.x, end.x) > maxX || + Math.max(start.y, end.y) < minY || + Math.min(start.y, end.y) > maxY + ) { + continue; + } + const startPoint = toCanvas(start.x, start.y); + const endPoint = toCanvas(end.x, end.y); + context.beginPath(); + context.moveTo(startPoint.x, startPoint.y); + context.lineTo(endPoint.x, endPoint.y); + context.stroke(); + } + + context.fillStyle = "#668697"; + for (const node of nodes) { + if (node.x < minX || node.x > maxX || node.y < minY || node.y > maxY) { + continue; + } + const point = toCanvas(node.x, node.y); + context.beginPath(); + context.arc(point.x, point.y, px(4), 0, Math.PI * 2); + context.fill(); + } + } + + rows.forEach((row) => { + const point = toCanvas(row.map_x, row.map_y); + context.fillStyle = "#ffffff"; + context.beginPath(); + context.arc(point.x, point.y, px(36), 0, Math.PI * 2); + context.fill(); + + context.strokeStyle = "rgba(255,255,255,0.96)"; + context.lineWidth = px(14); + context.stroke(); + context.strokeStyle = "#c9252d"; + context.lineWidth = px(5); + context.stroke(); + + context.fillStyle = "#a51f28"; + drawText( + context, + String(row.sequence), + point.x, + point.y, + px(32), + 700, + "center", + ); + + context.fillStyle = "#273746"; + drawText( + context, + row.node_id, + point.x, + point.y + px(62), + px(25), + 650, + "center", + "rgba(255,255,255,0.98)", + px(9), + ); + }); + context.restore(); + + context.strokeStyle = "#111827"; + context.lineWidth = px(4); + context.strokeRect(mapBox.x, mapBox.y, mapBox.width, mapBox.height); + + for (let index = 0; index <= 4; index += 1) { + const ratio = index / 4; + const x = mapBox.x + ratio * mapBox.width; + const y = mapBox.y + mapBox.height - ratio * mapBox.height; + context.fillStyle = "#334155"; + drawText( + context, + (minX + ratio * (maxX - minX)).toFixed(0), + x, + mapBox.y + mapBox.height + px(32), + px(20), + 500, + "center", + ); + drawText( + context, + (minY + ratio * (maxY - minY)).toFixed(0), + mapBox.x - px(18), + y, + px(20), + 500, + "right", + ); + } + + const titleTop = height - px(420); + context.strokeStyle = "#111827"; + context.lineWidth = px(4); + context.strokeRect(px(150), titleTop, width - px(300), px(270)); + context.beginPath(); + context.moveTo(width - px(1700), titleTop); + context.lineTo(width - px(1700), titleTop + px(270)); + context.stroke(); + + context.fillStyle = "#111827"; + drawText( + context, + `${scheme.scheme_name} 压力监测点布置图`, + px(220), + titleTop + px(70), + px(48), + 700, + ); + drawText( + context, + `项目:${network} 监测点:${rows.length} 个 地图:EPSG:3857 经纬度:WGS84`, + px(220), + titleTop + px(145), + px(26), + 500, + ); + drawText( + context, + dirty ? "状态:未保存草稿" : "状态:当前方案", + px(220), + titleTop + px(215), + px(26), + dirty ? 700 : 500, + ); + + const infoX = width - px(1620); + drawText( + context, + `创建人:${scheme.username}`, + infoX, + titleTop + px(55), + px(24), + 500, + ); + drawText( + context, + `创建时间:${new Date(scheme.create_time).toLocaleString("zh-CN")}`, + infoX, + titleTop + px(115), + px(24), + 500, + ); + drawText( + context, + `制图时间:${new Date().toLocaleString("zh-CN")}`, + infoX, + titleTop + px(175), + px(24), + 500, + ); + drawText( + context, + "图幅:A3 横向 300 DPI", + infoX, + titleTop + px(235), + px(24), + 500, + ); + + context.strokeStyle = "#111827"; + context.lineWidth = px(5); + const northX = width - px(260); + const northY = px(270); + context.beginPath(); + context.moveTo(northX, northY - px(70)); + context.lineTo(northX - px(32), northY + px(35)); + context.lineTo(northX, northY + px(15)); + context.lineTo(northX + px(32), northY + px(35)); + context.closePath(); + context.stroke(); + context.fillStyle = "#111827"; + drawText(context, "N", northX, northY - px(110), px(30), 700, "center"); + + return canvas; +}; + +export const canvasToPngBlob = (canvas: HTMLCanvasElement) => + new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (blob) resolve(blob); + else reject(new Error("PNG 生成失败")); + }, "image/png"); + }); diff --git a/src/components/olmap/MonitoringPlaceOptimization/schemeApi.ts b/src/components/olmap/MonitoringPlaceOptimization/schemeApi.ts new file mode 100644 index 0000000..6998797 --- /dev/null +++ b/src/components/olmap/MonitoringPlaceOptimization/schemeApi.ts @@ -0,0 +1,73 @@ +import { api } from "@/lib/api"; +import { config } from "@/config/config"; +import type { + AdjustmentStatus, + SensorPlacementScheme, +} from "./types"; + +export interface OptimizeSchemeInput { + network: string; + scheme_name: string; + sensor_type: "pressure"; + method: "sensitivity" | "kmeans"; + sensor_count: number; + min_diameter: number; +} + +export const optimizeSensorPlacement = async ( + input: OptimizeSchemeInput, +): Promise => { + const response = await api.post( + `${config.BACKEND_URL}/api/v1/sensor-placement-schemes/optimize`, + input, + ); + return response.data; +}; + +export const getSensorPlacementScheme = async ( + network: string, + schemeId: number, +): Promise => { + const response = await api.get( + `${config.BACKEND_URL}/api/v1/sensor-placement-schemes/${schemeId}`, + { params: { network } }, + ); + return response.data; +}; + +export const overwriteSensorPlacementScheme = async ( + network: string, + schemeId: number, + expectedSensorLocation: string[], + sensorLocation: string[], +): Promise => { + const response = await api.put( + `${config.BACKEND_URL}/api/v1/sensor-placement-schemes/${schemeId}`, + { + expected_sensor_location: expectedSensorLocation, + sensor_location: sensorLocation, + }, + { params: { network } }, + ); + return response.data; +}; + +export const exportSensorPlacementExcel = async ( + network: string, + schemeId: number, + sensorLocation: string[], + adjustmentStatus: Record, +) => { + const response = await api.post( + `${config.BACKEND_URL}/api/v1/sensor-placement-schemes/${schemeId}/exports/excel`, + { + sensor_location: sensorLocation, + adjustment_status: adjustmentStatus, + }, + { + params: { network }, + responseType: "blob", + }, + ); + return response.data; +}; diff --git a/src/components/olmap/MonitoringPlaceOptimization/schemeEditor.test.ts b/src/components/olmap/MonitoringPlaceOptimization/schemeEditor.test.ts new file mode 100644 index 0000000..3f77cb9 --- /dev/null +++ b/src/components/olmap/MonitoringPlaceOptimization/schemeEditor.test.ts @@ -0,0 +1,84 @@ +import { + addSensorPoint, + createSchemeEditorState, + deleteSensorPoint, + isSchemeDirty, + replaceSensorPoint, + resetSchemeEdit, + summarizeChanges, + undoSchemeEdit, +} from "./schemeEditor"; +import type { SensorPlacementScheme, SensorPoint } from "./types"; + +const point = (node_id: string): SensorPoint => ({ + node_id, + project_x: Number(node_id.slice(1)) * 10, + project_y: Number(node_id.slice(1)) * 20, + map_x: 13500000 + Number(node_id.slice(1)) * 10, + map_y: 3600000 + Number(node_id.slice(1)) * 20, + longitude: 121, + latitude: 31, + elevation: 5, +}); + +const scheme: SensorPlacementScheme = { + id: 1, + scheme_name: "测试方案", + sensor_number: 2, + min_diameter: 300, + username: "alice", + create_time: "2026-07-30T08:00:00+08:00", + sensor_location: ["J1", "J2"], + sensor_points: [point("J1"), point("J2")], + can_edit: true, +}; + +describe("scheme editor", () => { + it("adds unique nodes and can undo", () => { + const initial = createSchemeEditorState(scheme); + const added = addSensorPoint(initial, point("J3")); + + expect(added.points.map((item) => item.node_id)).toEqual(["J1", "J2", "J3"]); + expect(added.statuses.J3).toBe("added"); + expect(isSchemeDirty(added)).toBe(true); + expect(undoSchemeEdit(added).points).toEqual(initial.points); + }); + + it("replaces a node without changing row order", () => { + const initial = createSchemeEditorState(scheme); + const replaced = replaceSensorPoint(initial, "J1", point("J3")); + + expect(replaced.points.map((item) => item.node_id)).toEqual(["J3", "J2"]); + expect(replaced.statuses.J3).toBe("replaced"); + expect(summarizeChanges(replaced)).toEqual({ + added: 0, + removed: 0, + replaced: 1, + }); + }); + + it("keeps an added status when replacing a newly added node", () => { + const added = addSensorPoint(createSchemeEditorState(scheme), point("J3")); + const replaced = replaceSensorPoint(added, "J3", point("J4")); + + expect(replaced.statuses.J3).toBeUndefined(); + expect(replaced.statuses.J4).toBe("added"); + }); + + it("rejects duplicate replacements and deleting the final row", () => { + const initial = createSchemeEditorState(scheme); + expect(replaceSensorPoint(initial, "J1", point("J2"))).toBe(initial); + + const oneLeft = deleteSensorPoint(initial, "J1"); + expect(deleteSensorPoint(oneLeft, "J2")).toBe(oneLeft); + }); + + it("resets to the loaded baseline", () => { + const edited = addSensorPoint(createSchemeEditorState(scheme), point("J3")); + const reset = resetSchemeEdit(edited); + + expect(reset.points.map((item) => item.node_id)).toEqual(["J1", "J2"]); + expect(isSchemeDirty(reset)).toBe(false); + expect(reset.history).toEqual([]); + }); +}); diff --git a/src/components/olmap/MonitoringPlaceOptimization/schemeEditor.ts b/src/components/olmap/MonitoringPlaceOptimization/schemeEditor.ts new file mode 100644 index 0000000..e3dab4a --- /dev/null +++ b/src/components/olmap/MonitoringPlaceOptimization/schemeEditor.ts @@ -0,0 +1,158 @@ +import type { + AdjustmentStatus, + SensorPlacementScheme, + SensorPoint, + SensorPointRow, +} from "./types"; + +export interface SchemeEditorSnapshot { + points: SensorPoint[]; + statuses: Record; +} + +export interface SchemeEditorState extends SchemeEditorSnapshot { + baseline: SensorPoint[]; + history: SchemeEditorSnapshot[]; +} + +const clonePoints = (points: SensorPoint[]) => points.map((point) => ({ ...point })); + +const currentStatuses = (points: SensorPoint[]) => + Object.fromEntries(points.map((point) => [point.node_id, "current"])) as Record< + string, + AdjustmentStatus + >; + +export const createSchemeEditorState = ( + scheme: SensorPlacementScheme, +): SchemeEditorState => ({ + baseline: clonePoints(scheme.sensor_points), + points: clonePoints(scheme.sensor_points), + statuses: currentStatuses(scheme.sensor_points), + history: [], +}); + +const snapshot = (state: SchemeEditorState): SchemeEditorSnapshot => ({ + points: clonePoints(state.points), + statuses: { ...state.statuses }, +}); + +const withHistory = ( + state: SchemeEditorState, + points: SensorPoint[], + statuses: Record, +): SchemeEditorState => ({ + ...state, + points, + statuses, + history: [...state.history, snapshot(state)], +}); + +export const addSensorPoint = ( + state: SchemeEditorState, + point: SensorPoint, +): SchemeEditorState => { + if (state.points.some((item) => item.node_id === point.node_id)) return state; + return withHistory( + state, + [...state.points, { ...point }], + { ...state.statuses, [point.node_id]: "added" }, + ); +}; + +export const replaceSensorPoint = ( + state: SchemeEditorState, + sourceNodeId: string, + point: SensorPoint, +): SchemeEditorState => { + const sourceIndex = state.points.findIndex( + (item) => item.node_id === sourceNodeId, + ); + if (sourceIndex < 0) return state; + if ( + sourceNodeId !== point.node_id && + state.points.some((item) => item.node_id === point.node_id) + ) { + return state; + } + + const nextPoints = clonePoints(state.points); + nextPoints[sourceIndex] = { ...point }; + const nextStatuses = { ...state.statuses }; + const sourceStatus = nextStatuses[sourceNodeId] ?? "current"; + delete nextStatuses[sourceNodeId]; + const baselineIds = new Set(state.baseline.map((item) => item.node_id)); + nextStatuses[point.node_id] = + sourceStatus === "added" + ? "added" + : baselineIds.has(point.node_id) + ? "original" + : "replaced"; + return withHistory(state, nextPoints, nextStatuses); +}; + +export const deleteSensorPoint = ( + state: SchemeEditorState, + nodeId: string, +): SchemeEditorState => { + if (state.points.length <= 1) return state; + if (!state.points.some((item) => item.node_id === nodeId)) return state; + const nextStatuses = { ...state.statuses }; + delete nextStatuses[nodeId]; + return withHistory( + state, + state.points.filter((item) => item.node_id !== nodeId), + nextStatuses, + ); +}; + +export const undoSchemeEdit = (state: SchemeEditorState): SchemeEditorState => { + const previous = state.history[state.history.length - 1]; + if (!previous) return state; + return { + ...state, + points: clonePoints(previous.points), + statuses: { ...previous.statuses }, + history: state.history.slice(0, -1), + }; +}; + +export const resetSchemeEdit = (state: SchemeEditorState): SchemeEditorState => ({ + ...state, + points: clonePoints(state.baseline), + statuses: currentStatuses(state.baseline), + history: [], +}); + +export const isSchemeDirty = (state: SchemeEditorState) => { + const baselineIds = state.baseline.map((point) => point.node_id); + const currentIds = state.points.map((point) => point.node_id); + return ( + baselineIds.length !== currentIds.length || + baselineIds.some((nodeId, index) => nodeId !== currentIds[index]) + ); +}; + +export const toSensorPointRows = ( + state: SchemeEditorState, +): SensorPointRow[] => + state.points.map((point, index) => ({ + ...point, + sequence: index + 1, + adjustment_status: state.statuses[point.node_id] ?? "current", + })); + +export const summarizeChanges = (state: SchemeEditorState) => { + const baselineIds = new Set(state.baseline.map((point) => point.node_id)); + const currentIds = new Set(state.points.map((point) => point.node_id)); + const added = [...currentIds].filter((nodeId) => !baselineIds.has(nodeId)).length; + const removed = [...baselineIds].filter( + (nodeId) => !currentIds.has(nodeId), + ).length; + const replaced = Math.min(added, removed); + return { + added: Math.max(0, added - replaced), + removed: Math.max(0, removed - replaced), + replaced, + }; +}; diff --git a/src/components/olmap/MonitoringPlaceOptimization/types.ts b/src/components/olmap/MonitoringPlaceOptimization/types.ts new file mode 100644 index 0000000..1c3b058 --- /dev/null +++ b/src/components/olmap/MonitoringPlaceOptimization/types.ts @@ -0,0 +1,45 @@ +export type AdjustmentStatus = "current" | "original" | "added" | "replaced"; + +export interface SensorPoint { + node_id: string; + project_x: number; + project_y: number; + map_x: number; + map_y: number; + longitude: number; + latitude: number; + elevation: number; +} + +export interface SensorPlacementScheme { + id: number; + scheme_name: string; + sensor_number: number; + min_diameter: number; + username: string; + create_time: string; + sensor_location: string[]; + sensor_points: SensorPoint[]; + can_edit: boolean; +} + +export interface SchemeRecord { + id: number; + schemeName: string; + sensorNumber: number; + minDiameter: number; + username: string; + create_time: string; + sensorLocation?: string[]; +} + +export interface SensorPointRow extends SensorPoint { + sequence: number; + adjustment_status: AdjustmentStatus; +} + +export interface NetworkDrawingData { + nodes: string[]; + links: string[]; + extent: [number, number, number, number]; +} diff --git a/src/components/olmap/core/Controls/BaseLayers.test.ts b/src/components/olmap/core/Controls/BaseLayers.test.ts index facafef..5008031 100644 --- a/src/components/olmap/core/Controls/BaseLayers.test.ts +++ b/src/components/olmap/core/Controls/BaseLayers.test.ts @@ -3,7 +3,7 @@ jest.mock("../MapComponent", () => ({ useMap: jest.fn(), })); jest.mock("../mapLifecycle", () => ({ - markMapResourcePersistent: (resource: T) => resource, + markMapResourcePersistent: (resource: T) => resource, })); jest.mock("ol/source/XYZ.js", () => ({ @@ -19,7 +19,9 @@ jest.mock("ol/layer/Tile.js", () => ({ constructor(options: any) { this.source = options.source; } - getSource() { return this.source; } + getSource() { + return this.source; + } }, })); jest.mock("ol/layer/Group", () => ({ @@ -29,14 +31,13 @@ jest.mock("ol/layer/Group", () => ({ constructor(options: any) { this.layers = options.layers; } - getLayers() { return { getArray: () => this.layers }; } + getLayers() { + return { getArray: () => this.layers }; + } }, })); -import { - createBaseLayerEntries, - createBaseLayerSources, -} from "./BaseLayers"; +import { createBaseLayerEntries, createBaseLayerSources } from "./BaseLayers"; const getLeafSources = (layer: any): unknown[] => { const childLayers = layer.getLayers?.().getArray?.(); @@ -47,6 +48,17 @@ const getLeafSources = (layer: any): unknown[] => { }; describe("base layer resources", () => { + it("loads every tile source with anonymous CORS for canvas export", () => { + const sources = createBaseLayerSources(); + + Object.values(sources).forEach((source) => { + expect( + (source as unknown as { options: { crossOrigin?: string } }).options + .crossOrigin, + ).toBe("anonymous"); + }); + }); + it("creates independent layers backed by one shared source pool", () => { const sources = createBaseLayerSources(); const primary = createBaseLayerEntries(sources); diff --git a/src/components/olmap/core/Controls/BaseLayers.tsx b/src/components/olmap/core/Controls/BaseLayers.tsx index 64483a6..ebb001b 100644 --- a/src/components/olmap/core/Controls/BaseLayers.tsx +++ b/src/components/olmap/core/Controls/BaseLayers.tsx @@ -33,6 +33,7 @@ const BASE_LAYER_METADATA = [ const createTileSource = (url: string, attributions: string) => new XYZ({ url, + crossOrigin: "anonymous", tileSize: 512, maxZoom: 20, projection: "EPSG:3857", @@ -57,24 +58,28 @@ export const createBaseLayerSources = () => ({ '数据来源:Mapbox & OpenStreetMap', ), tiandituVector: new XYZ({ - url: `https://t0.tianditu.gov.cn/vec_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=vec&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`, - projection: "EPSG:3857", - attributions: '数据来源:天地图', + url: `https://t0.tianditu.gov.cn/vec_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=vec&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`, + crossOrigin: "anonymous", + projection: "EPSG:3857", + attributions: '数据来源:天地图', }), tiandituVectorAnnotation: new XYZ({ - url: `https://t0.tianditu.gov.cn/cva_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cva&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`, - projection: "EPSG:3857", - attributions: '数据来源:天地图', + url: `https://t0.tianditu.gov.cn/cva_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cva&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`, + crossOrigin: "anonymous", + projection: "EPSG:3857", + attributions: '数据来源:天地图', }), tiandituImage: new XYZ({ - url: `https://t0.tianditu.gov.cn/img_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=img&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`, - projection: "EPSG:3857", - attributions: '数据来源:天地图', + url: `https://t0.tianditu.gov.cn/img_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=img&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`, + crossOrigin: "anonymous", + projection: "EPSG:3857", + attributions: '数据来源:天地图', }), tiandituImageAnnotation: new XYZ({ - url: `https://t0.tianditu.gov.cn/cia_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cia&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`, - projection: "EPSG:3857", - attributions: '数据来源:天地图', + url: `https://t0.tianditu.gov.cn/cia_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cia&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`, + crossOrigin: "anonymous", + projection: "EPSG:3857", + attributions: '数据来源:天地图', }), }); @@ -132,7 +137,9 @@ const BaseLayers: React.FC = () => { return map ? [map] : []; }, [data?.maps, map]); const sharedSources = useMemo(() => createBaseLayerSources(), []); - const layerSetsRef = useRef(new WeakMap>()); + const layerSetsRef = useRef( + new WeakMap>(), + ); const [isShow, setShow] = useState(false); const [isExpanded, setExpanded] = useState(false); const [activeId, setActiveId] = useState(INITIAL_LAYER); @@ -244,7 +251,7 @@ const BaseLayers: React.FC = () => {
{ "object-cover object-left w-16 h-16 rounded-md border-2 border-white hover:ring-2 ring-blue-300", { "ring-1 ring-blue-300": activeId === item.id, - } + }, )} /> {item.name}