feat(sensor-placement): add scheme engineering editor

This commit is contained in:
2026-07-30 16:16:51 +08:00
parent 04557b9363
commit d643f09fcf
13 changed files with 2724 additions and 159 deletions
@@ -9,6 +9,7 @@ import {
Typography, Typography,
IconButton, IconButton,
Tooltip, Tooltip,
CircularProgress,
} from "@mui/material"; } from "@mui/material";
import { import {
ChevronRight, ChevronRight,
@@ -16,6 +17,7 @@ import {
Sensors as SensorsIcon, Sensors as SensorsIcon,
Analytics as AnalyticsIcon, Analytics as AnalyticsIcon,
Search as SearchIcon, Search as SearchIcon,
TableView as TableIcon,
} from "@mui/icons-material"; } from "@mui/icons-material";
import OptimizationParameters, { import OptimizationParameters, {
createOptimizationParametersState, createOptimizationParametersState,
@@ -25,16 +27,12 @@ import SchemeQuery, {
createMonitoringSchemeQueryState, createMonitoringSchemeQueryState,
type MonitoringSchemeQueryState, type MonitoringSchemeQueryState,
} from "./SchemeQuery"; } from "./SchemeQuery";
import SchemeEditor from "./SchemeEditor";
interface SchemeRecord { import { getSensorPlacementScheme } from "./schemeApi";
id: number; import type { SchemeRecord, SensorPlacementScheme } from "./types";
schemeName: string; import { NETWORK_NAME } from "@/config/config";
sensorNumber: number; import { useNotification } from "@refinedev/core";
minDiameter: number; import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
user: string;
create_time: string;
sensorLocation?: string[];
}
interface TabPanelProps { interface TabPanelProps {
children?: React.ReactNode; children?: React.ReactNode;
@@ -49,9 +47,7 @@ const TabPanel: React.FC<TabPanelProps> = ({ children, value, index }) => {
hidden={value !== index} hidden={value !== index}
className="flex-1 overflow-hidden flex flex-col" className="flex-1 overflow-hidden flex flex-col"
> >
{value === index && (
<Box className="flex-1 overflow-auto p-4">{children}</Box> <Box className="flex-1 overflow-auto p-4">{children}</Box>
)}
</div> </div>
); );
}; };
@@ -66,6 +62,10 @@ const MonitoringPlaceOptimizationPanel: React.FC<
> = ({ open: controlledOpen, onToggle }) => { > = ({ open: controlledOpen, onToggle }) => {
const [internalOpen, setInternalOpen] = useState(true); const [internalOpen, setInternalOpen] = useState(true);
const [currentTab, setCurrentTab] = useState(0); const [currentTab, setCurrentTab] = useState(0);
const [activeScheme, setActiveScheme] =
useState<SensorPlacementScheme | null>(null);
const [loadingScheme, setLoadingScheme] = useState(false);
const { open: notify } = useNotification();
// 持久化方案查询结果 // 持久化方案查询结果
const [schemes, setSchemes] = useState<SchemeRecord[]>([]); const [schemes, setSchemes] = useState<SchemeRecord[]>([]);
@@ -89,7 +89,42 @@ const MonitoringPlaceOptimizationPanel: React.FC<
setCurrentTab(newValue); 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 ( return (
<> <>
@@ -132,6 +167,7 @@ const MonitoringPlaceOptimizationPanel: React.FC<
right: 16, right: 16,
height: "calc(100vh - 32px)", height: "calc(100vh - 32px)",
maxHeight: "850px", maxHeight: "850px",
maxWidth: "calc(100vw - 32px)",
borderRadius: "12px", borderRadius: "12px",
boxShadow: boxShadow:
"0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)", "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" iconPosition="start"
label="优化要件" label="优化要件"
/> />
<Tab
icon={<TableIcon fontSize="small" />}
iconPosition="start"
label="结果编辑"
/>
<Tab <Tab
icon={<SearchIcon fontSize="small" />} icon={<SearchIcon fontSize="small" />}
iconPosition="start" iconPosition="start"
@@ -206,19 +247,59 @@ const MonitoringPlaceOptimizationPanel: React.FC<
<OptimizationParameters <OptimizationParameters
state={optimizationState} state={optimizationState}
onStateChange={setOptimizationState} onStateChange={setOptimizationState}
onSchemeCreated={(scheme) => {
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,
},
]);
}}
/> />
</TabPanel> </TabPanel>
<TabPanel value={currentTab} index={1}> <TabPanel value={currentTab} index={1}>
{loadingScheme ? (
<Box
sx={{
minHeight: 320,
display: "grid",
placeItems: "center",
}}
>
<CircularProgress size={30} />
</Box>
) : activeScheme ? (
<SchemeEditor
scheme={activeScheme}
network={NETWORK_NAME}
active={isOpen && currentTab === 1}
onSaved={handleSchemeSaved}
/>
) : (
<PanelEmptyState
icon={<TableIcon />}
title="暂无可编辑结果"
description="请先在“优化要件”中创建方案,或在“方案查询”中打开已有方案。"
/>
)}
</TabPanel>
<TabPanel value={currentTab} index={2}>
<SchemeQuery <SchemeQuery
schemes={schemes} schemes={schemes}
onSchemesChange={setSchemes} onSchemesChange={setSchemes}
state={queryState} state={queryState}
onStateChange={setQueryState} onStateChange={setQueryState}
onLocate={(id) => { onEdit={handleOpenScheme}
console.log("定位方案:", id);
// TODO: 在地图上定位
}}
/> />
</TabPanel> </TabPanel>
</Box> </Box>
@@ -7,22 +7,15 @@ import {
Button, Button,
Typography, Typography,
MenuItem, MenuItem,
Stack,
} from "@mui/material"; } from "@mui/material";
import { PlayArrow as PlayArrowIcon } from "@mui/icons-material"; import { PlayArrow as PlayArrowIcon } from "@mui/icons-material";
import { useNotification } from "@refinedev/core"; import { useNotification } from "@refinedev/core";
import { useGetIdentity } from "@refinedev/core"; import { NETWORK_NAME } from "@/config/config";
import { api } from "@/lib/api";
import { config, NETWORK_NAME } from "@/config/config";
import { useControllableObjectState } from "@components/olmap/core/useControllableState"; import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import { optimizeSensorPlacement } from "./schemeApi";
type IUser = { import type { SensorPlacementScheme } from "./types";
id: string;
name?: string;
};
export interface OptimizationParametersState { export interface OptimizationParametersState {
sensorType: string;
method: string; method: string;
sensorCount: number; sensorCount: number;
minDiameter: number; minDiameter: number;
@@ -31,7 +24,6 @@ export interface OptimizationParametersState {
export const createOptimizationParametersState = export const createOptimizationParametersState =
(): OptimizationParametersState => ({ (): OptimizationParametersState => ({
sensorType: "pressure",
method: "kmeans", method: "kmeans",
sensorCount: 5, sensorCount: 5,
minDiameter: 5, minDiameter: 5,
@@ -41,45 +33,32 @@ export const createOptimizationParametersState =
interface OptimizationParametersProps { interface OptimizationParametersProps {
state?: OptimizationParametersState; state?: OptimizationParametersState;
onStateChange?: (state: OptimizationParametersState) => void; onStateChange?: (state: OptimizationParametersState) => void;
onSchemeCreated?: (scheme: SensorPlacementScheme) => void;
} }
const OptimizationParameters: React.FC<OptimizationParametersProps> = ({ const OptimizationParameters: React.FC<OptimizationParametersProps> = ({
state, state,
onStateChange, onStateChange,
onSchemeCreated,
}) => { }) => {
const { open } = useNotification(); const { open } = useNotification();
const { data: user } = useGetIdentity<IUser>();
const [parametersState, , setFormField] = useControllableObjectState( const [parametersState, , setFormField] = useControllableObjectState(
state, state,
onStateChange, onStateChange,
createOptimizationParametersState(), createOptimizationParametersState(),
); );
const { sensorType, method, sensorCount, minDiameter, schemeName } = const { method, sensorCount, minDiameter, schemeName } =
parametersState; parametersState;
const [network] = useState<string>(NETWORK_NAME); const network = NETWORK_NAME;
const [analyzing, setAnalyzing] = useState<boolean>(false); const [analyzing, setAnalyzing] = useState<boolean>(false);
// 传感器类型选项
const sensorTypeOptions = [
{ value: "pressure", label: "压力" },
{ value: "flow", label: "流量" },
];
// 方法选项 // 方法选项
const methodOptions = [ const methodOptions = [
{ value: "kmeans", label: "聚类分析" }, { value: "kmeans", label: "聚类分析" },
{ value: "sensitivity", label: "灵敏度分析" }, { value: "sensitivity", label: "灵敏度分析" },
]; ];
// 获取传感器类型的中文标签
const getSensorTypeLabel = (value: string) => {
return (
sensorTypeOptions.find((option) => option.value === value)?.label || value
);
};
// 创建方案 // 创建方案
const handleCreateScheme = async () => { const handleCreateScheme = async () => {
// 验证输入 // 验证输入
@@ -109,48 +88,22 @@ const OptimizationParameters: React.FC<OptimizationParametersProps> = ({
setAnalyzing(true); setAnalyzing(true);
if (!user || !user.id) {
open?.({
type: "error",
message: "用户信息无效",
});
return;
}
try { try {
// 发送优化请求 const created = await optimizeSensorPlacement({
const response = await api.post( network,
`${config.BACKEND_URL}/api/v1/sensor-placement-schemes`,
null,
{
params: {
network: network,
scheme_name: schemeName, scheme_name: schemeName,
sensor_type: sensorType, sensor_type: "pressure",
method: method, method: method as "sensitivity" | "kmeans",
sensor_count: sensorCount, sensor_count: sensorCount,
min_diameter: minDiameter, 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?.({ open?.({
type: "success", type: "success",
message: "方案创建成功", message: "方案创建成功",
description: `方案 "${schemeName}" 已完成优化分析`, description: `方案 "${schemeName}" 已完成优化分析`,
}); });
onSchemeCreated?.(created);
// 重置方案名称
setFormField("schemeName", "Fangan" + new Date().getTime()); setFormField("schemeName", "Fangan" + new Date().getTime());
} else {
throw new Error(response.data?.message || "创建失败");
}
} catch (error: any) { } catch (error: any) {
console.error("创建方案失败:", error); console.error("创建方案失败:", error);
open?.({ open?.({
@@ -175,11 +128,10 @@ const OptimizationParameters: React.FC<OptimizationParametersProps> = ({
</Typography> </Typography>
<TextField <TextField
select
fullWidth fullWidth
size="small" size="small"
value={sensorType} value="压力"
onChange={(e) => setFormField("sensorType", e.target.value)} slotProps={{ input: { readOnly: true } }}
sx={{ sx={{
"& .MuiOutlinedInput-root": { "& .MuiOutlinedInput-root": {
"&:hover fieldset": { "&:hover fieldset": {
@@ -190,13 +142,7 @@ const OptimizationParameters: React.FC<OptimizationParametersProps> = ({
}, },
}, },
}} }}
> />
{sensorTypeOptions.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</TextField>
</Box> </Box>
{/* 方法选择 */} {/* 方法选择 */}
@@ -270,7 +216,7 @@ const OptimizationParameters: React.FC<OptimizationParametersProps> = ({
variant="subtitle2" variant="subtitle2"
className="mb-2 font-semibold text-gray-700" className="mb-2 font-semibold text-gray-700"
> >
{getSensorTypeLabel(sensorType)}
</Typography> </Typography>
<TextField <TextField
fullWidth fullWidth
@@ -0,0 +1,665 @@
"use client";
import React, { useEffect, useMemo, useState } from "react";
import {
Alert,
Box,
Button,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControlLabel,
Radio,
RadioGroup,
Typography,
} from "@mui/material";
import {
Download as DownloadIcon,
Print as PrintIcon,
} from "@mui/icons-material";
import OlMap from "ol/Map";
import View from "ol/View";
import LayerGroup from "ol/layer/Group";
import TileLayer from "ol/layer/Tile";
import VectorTileLayer from "ol/layer/VectorTile";
import type TileSource from "ol/source/Tile";
import type VectorTileSource from "ol/source/VectorTile";
import type { StyleLike } from "ol/style/Style";
import {
TileFeatureIndex,
clipLineStringPartsToExtent,
lineStringFromFlatCoordinates,
} from "@components/olmap/core/tileFeatureIndex";
import {
A3_LANDSCAPE_WIDTH,
canvasToPngBlob,
getPaddedDrawingExtent,
renderEngineeringDrawing,
} from "./engineeringDrawing";
import type {
NetworkDrawingData,
SensorPlacementScheme,
SensorPointRow,
} from "./types";
interface SchemeDrawingDialogProps {
open: boolean;
onClose: () => 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<TileSource>[] => {
const cloned: TileLayer<TileSource>[] = [];
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<void>((resolve, reject) => {
let timeout: ReturnType<typeof setTimeout>;
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<HTMLCanvasElement>(".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<string, string>();
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) =>
({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;",
})[character] ?? character,
);
const SchemeDrawingDialog: React.FC<SchemeDrawingDialogProps> = ({
open,
onClose,
scheme,
rows,
network,
map,
dirty,
}) => {
const [networkData, setNetworkData] = useState<NetworkDrawingData | null>(
null,
);
const [drawingMode, setDrawingMode] = useState<DrawingMode>("linework");
const [mapCanvas, setMapCanvas] = useState<HTMLCanvasElement | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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(
'<!doctype html><html lang="zh-CN"><body style="font-family: sans-serif; padding: 24px;">正在生成 A3 工程图...</body></html>',
);
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(`
<!doctype html>
<html lang="zh-CN">
<head>
<title>${safeSchemeName} 布置图</title>
<style>
@page { size: A3 landscape; margin: 0; }
html, body { margin: 0; width: 100%; height: 100%; }
img { display: block; width: 100%; height: 100%; object-fit: contain; }
</style>
</head>
<body><img src="${url}" alt="${safeSchemeName} 压力监测点布置图"></body>
</html>
`);
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 (
<Dialog
open={open}
onClose={onClose}
fullWidth
maxWidth="lg"
aria-labelledby="scheme-drawing-title"
PaperProps={{ sx: { borderRadius: 3, overflow: "hidden" } }}
>
<DialogTitle id="scheme-drawing-title" sx={{ pb: 1 }}>
</DialogTitle>
<DialogContent sx={{ bgcolor: "#eef2f5", pt: 1.5 }}>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 2,
mb: 1.5,
flexWrap: "wrap",
}}
>
<RadioGroup
row
value={drawingMode}
onChange={(event) =>
setDrawingMode(event.target.value as DrawingMode)
}
aria-label="工程图底图模式"
>
<FormControlLabel
value="linework"
control={<Radio size="small" />}
label="简化管网线稿"
/>
<FormControlLabel
value="basemap"
control={<Radio size="small" />}
label="地图底图 + 管网"
/>
</RadioGroup>
<Typography
variant="caption"
sx={{ color: "text.secondary", fontVariantNumeric: "tabular-nums" }}
>
A3 · 300 DPI · 4961 × 3508
</Typography>
</Box>
{error && (
<Alert severity="warning" sx={{ mb: 1.5 }}>
{error}
</Alert>
)}
<Box
sx={{
position: "relative",
aspectRatio: `${4961} / ${3508}`,
bgcolor: "#fff",
boxShadow: "0 8px 28px rgba(15, 23, 42, 0.14)",
overflow: "hidden",
}}
>
{previewUrl && (
<Box
component="img"
src={previewUrl}
alt={`${scheme.scheme_name} 压力监测点布置图预览`}
sx={{ width: "100%", height: "100%", objectFit: "contain" }}
/>
)}
{loading && (
<Box
sx={{
position: "absolute",
inset: 0,
display: "grid",
placeItems: "center",
bgcolor: "rgba(255,255,255,0.72)",
}}
>
<CircularProgress size={30} />
</Box>
)}
</Box>
</DialogContent>
<DialogActions sx={{ px: 3, py: 2 }}>
<Button onClick={onClose}></Button>
<Button
variant="outlined"
startIcon={<DownloadIcon />}
onClick={handleDownload}
disabled={loading || !networkData}
>
PNG
</Button>
<Button
variant="contained"
startIcon={<PrintIcon />}
onClick={handlePrint}
disabled={loading || !networkData}
>
</Button>
</DialogActions>
</Dialog>
);
};
export default SchemeDrawingDialog;
@@ -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<AdjustmentStatus, string> = {
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<SchemeEditorProps> = ({
scheme,
network,
active = true,
onSaved,
}) => {
const map = useMap();
const { open } = useNotification();
const [editor, setEditor] = useState(() => createSchemeEditorState(scheme));
const [mode, setMode] = useState<EditMode>("idle");
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [replaceSourceId, setReplaceSourceId] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [exporting, setExporting] = useState(false);
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
const [drawingOpen, setDrawingOpen] = useState(false);
const markerLayerRef = useRef<VectorLayer<VectorSource> | 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<SensorPoint | null> => {
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<GridColDef<SensorPointRow>[]>(
() => [
{
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 (
<Chip
label={STATUS_LABELS[status]}
color={STATUS_COLORS[status]}
variant="outlined"
size="small"
/>
);
},
},
{
field: "actions",
headerName: "操作",
width: scheme.can_edit ? 138 : 54,
sortable: false,
filterable: false,
renderCell: ({ row }) => (
<Stack direction="row" spacing={0.25} sx={{ alignItems: "center" }}>
<Tooltip title="地图定位">
<IconButton
aria-label={`定位节点 ${row.node_id}`}
size="small"
onClick={() => locateRow(row)}
sx={{ width: 40, height: 40 }}
>
<LocateIcon fontSize="small" />
</IconButton>
</Tooltip>
{scheme.can_edit && (
<>
<Tooltip title="替换节点">
<IconButton
aria-label={`替换节点 ${row.node_id}`}
size="small"
onClick={() => activateMode("replace", row.node_id)}
sx={{ width: 40, height: 40 }}
>
<ReplaceIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="删除节点">
<span>
<IconButton
aria-label={`删除节点 ${row.node_id}`}
size="small"
onClick={() => handleDelete(row.node_id)}
disabled={rows.length <= 1}
sx={{ width: 40, height: 40 }}
>
<DeleteIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
</>
)}
</Stack>
),
},
],
[activateMode, handleDelete, locateRow, rows.length, scheme.can_edit],
);
const instruction =
mode === "add"
? "点击地图中的管网节点添加监测点"
: mode === "replace"
? replaceSourceId
? `已选择 ${replaceSourceId},请点击新的管网节点`
: "请先点击要替换的监测点"
: mode === "delete"
? "点击地图中的监测点删除"
: null;
return (
<Box sx={{ display: "flex", flexDirection: "column", height: "100%", gap: 1.5 }}>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 1.5,
flexWrap: "wrap",
}}
>
<Box sx={{ minWidth: 0 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }} noWrap>
{scheme.scheme_name}
</Typography>
<Typography variant="caption" color="text.secondary">
{scheme.username} · {rows.length}
{dirty ? " · 未保存草稿" : ""}
</Typography>
</Box>
<Stack direction="row" spacing={0.75} sx={{ flexWrap: "wrap" }}>
<Button
size="small"
variant="outlined"
startIcon={<DownloadIcon />}
onClick={handleExport}
disabled={exporting}
>
{exporting ? "导出中" : "Excel"}
</Button>
<Button
size="small"
variant="outlined"
startIcon={<MapIcon />}
onClick={() => setDrawingOpen(true)}
>
</Button>
{scheme.can_edit && (
<Button
size="small"
variant="contained"
startIcon={<SaveIcon />}
disabled={!dirty}
onClick={() => setSaveDialogOpen(true)}
>
</Button>
)}
</Stack>
</Box>
{!scheme.can_edit && (
<Alert severity="info"></Alert>
)}
{scheme.can_edit && (
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 0.75,
flexWrap: "wrap",
p: 1,
bgcolor: "#f3f6f8",
borderRadius: 2,
}}
>
<Button
size="small"
variant={mode === "add" ? "contained" : "text"}
startIcon={<AddIcon />}
onClick={() => activateMode("add")}
>
</Button>
<Button
size="small"
variant={mode === "replace" ? "contained" : "text"}
startIcon={<ReplaceIcon />}
onClick={() => activateMode("replace")}
>
</Button>
<Button
size="small"
color="error"
variant={mode === "delete" ? "contained" : "text"}
startIcon={<DeleteIcon />}
onClick={() => activateMode("delete")}
>
</Button>
<Box sx={{ flex: 1 }} />
<Tooltip title="撤销上一步">
<span>
<IconButton
aria-label="撤销上一步"
size="small"
disabled={!editor.history.length}
onClick={() => setEditor((current) => undoSchemeEdit(current))}
sx={{ width: 40, height: 40 }}
>
<UndoIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
<Tooltip title="重置为服务器版本">
<span>
<IconButton
aria-label="重置为服务器版本"
size="small"
disabled={!dirty}
onClick={() => setEditor((current) => resetSchemeEdit(current))}
sx={{ width: 40, height: 40 }}
>
<ResetIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
</Box>
)}
{instruction && (
<Alert
severity="info"
action={
<Button
color="inherit"
size="small"
onClick={() => {
setMode("idle");
setReplaceSourceId(null);
}}
>
</Button>
}
>
{instruction}
</Alert>
)}
<Box sx={{ flex: 1, minHeight: 320 }}>
<DataGrid
rows={rows}
columns={columns}
localeText={zhCN.components.MuiDataGrid.defaultProps.localeText}
getRowId={(row) => 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)",
},
}}
/>
</Box>
<Dialog
open={saveDialogOpen}
onClose={() => !saving && setSaveDialogOpen(false)}
aria-labelledby="overwrite-scheme-title"
>
<DialogTitle id="overwrite-scheme-title"></DialogTitle>
<DialogContent>
<Typography variant="body2" sx={{ lineHeight: 1.8 }}>
{changes.added} {" "}
{changes.replaced} {changes.removed} {rows.length}
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setSaveDialogOpen(false)} disabled={saving}>
</Button>
<Button variant="contained" onClick={handleSave} disabled={saving}>
{saving ? "保存中" : "确认覆盖"}
</Button>
</DialogActions>
</Dialog>
<SchemeDrawingDialog
open={drawingOpen}
onClose={() => setDrawingOpen(false)}
scheme={scheme}
rows={rows}
network={network}
map={map ?? null}
dirty={dirty}
/>
</Box>
);
};
export default SchemeEditor;
@@ -17,7 +17,7 @@ import {
} from "@mui/material"; } from "@mui/material";
import { import {
Info as InfoIcon, Info as InfoIcon,
LocationOn as LocationIcon, EditLocationAlt as EditIcon,
} from "@mui/icons-material"; } from "@mui/icons-material";
import { DatePicker } from "@mui/x-date-pickers/DatePicker"; import { DatePicker } from "@mui/x-date-pickers/DatePicker";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; 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 { Style, Icon, Circle, Fill, Stroke } from "ol/style";
import Feature, { FeatureLike } from "ol/Feature"; import Feature, { FeatureLike } from "ol/Feature";
import { bbox, featureCollection } from "@turf/turf"; import { bbox, featureCollection } from "@turf/turf";
import type { SchemeRecord } from "./types";
import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName"; import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName";
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState"; 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 { interface SchemaItem {
id: number; id: number;
scheme_name: string; scheme_name: string;
@@ -63,7 +54,7 @@ interface SchemaItem {
interface SchemeQueryProps { interface SchemeQueryProps {
schemes?: SchemeRecord[]; schemes?: SchemeRecord[];
onSchemesChange?: (schemes: SchemeRecord[]) => void; onSchemesChange?: (schemes: SchemeRecord[]) => void;
onLocate?: (id: number) => void; onEdit?: (id: number) => void;
network?: string; network?: string;
state?: MonitoringSchemeQueryState; state?: MonitoringSchemeQueryState;
onStateChange?: (state: MonitoringSchemeQueryState) => void; onStateChange?: (state: MonitoringSchemeQueryState) => void;
@@ -87,7 +78,7 @@ export const createMonitoringSchemeQueryState =
const SchemeQuery: React.FC<SchemeQueryProps> = ({ const SchemeQuery: React.FC<SchemeQueryProps> = ({
schemes: externalSchemes, schemes: externalSchemes,
onSchemesChange, onSchemesChange,
onLocate, onEdit,
network = NETWORK_NAME, network = NETWORK_NAME,
state, state,
onStateChange, onStateChange,
@@ -205,7 +196,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
schemeName: item.scheme_name, schemeName: item.scheme_name,
sensorNumber: item.sensor_number, sensorNumber: item.sensor_number,
minDiameter: item.min_diameter, minDiameter: item.min_diameter,
user: item.username, username: item.username,
create_time: item.create_time, create_time: item.create_time,
sensorLocation: item.sensor_location, sensorLocation: item.sensor_location,
})); }));
@@ -275,15 +266,6 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
setQueryField("expandedId", expandedId === id ? null : id); setQueryField("expandedId", expandedId === id ? null : id);
}; };
// 保存方案(示例功能)
const handleSaveScheme = (scheme: SchemeRecord) => {
open?.({
type: "success",
message: "保存成功",
description: `方案 "${scheme.schemeName}" 已保存`,
});
};
return ( return (
<Box className="flex flex-col h-full"> <Box className="flex flex-col h-full">
{/* 查询条件 - 单行布局 */} {/* 查询条件 - 单行布局 */}
@@ -379,8 +361,8 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
variant="caption" variant="caption"
className="text-gray-500 block" className="text-gray-500 block"
> >
: {scheme.minDiameter} · :{" "} : {scheme.minDiameter} · :{" "}
{creatorName(scheme.user)} · :{" "} {creatorName(scheme.username)} · :{" "}
{formatShortDate(scheme.create_time)} {formatShortDate(scheme.create_time)}
</Typography> </Typography>
</Box> </Box>
@@ -405,16 +387,6 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
<InfoIcon fontSize="small" /> <InfoIcon fontSize="small" />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
{/* <Tooltip title="定位">
<IconButton
size="small"
onClick={() => onLocate?.(scheme.id)}
color="primary"
className="p-1"
>
<LocationIcon fontSize="small" />
</IconButton>
</Tooltip> */}
</Box> </Box>
</Box> </Box>
@@ -445,7 +417,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
variant="caption" variant="caption"
className="text-gray-600 min-w-[70px]" className="text-gray-600 min-w-[70px]"
> >
: :
</Typography> </Typography>
<Typography <Typography
variant="caption" variant="caption"
@@ -471,7 +443,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
variant="caption" variant="caption"
className="font-medium text-gray-900" className="font-medium text-gray-900"
> >
{creatorName(scheme.user)} {creatorName(scheme.username)}
</Typography> </Typography>
</Box> </Box>
<Box className="flex items-center gap-2"> <Box className="flex items-center gap-2">
@@ -552,13 +524,14 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
fullWidth fullWidth
size="small" size="small"
className="bg-blue-600 hover:bg-blue-700" className="bg-blue-600 hover:bg-blue-700"
onClick={() => handleSaveScheme(scheme)} startIcon={<EditIcon />}
onClick={() => onEdit?.(scheme.id)}
sx={{ sx={{
textTransform: "none", textTransform: "none",
fontWeight: 500, fontWeight: 500,
}} }}
> >
</Button> </Button>
</Box> </Box>
</Box> </Box>
@@ -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<string, unknown>();
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),
);
});
});
@@ -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<HTMLCanvasElement> => {
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<Blob>((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) resolve(blob);
else reject(new Error("PNG 生成失败"));
}, "image/png");
});
@@ -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<SensorPlacementScheme> => {
const response = await api.post<SensorPlacementScheme>(
`${config.BACKEND_URL}/api/v1/sensor-placement-schemes/optimize`,
input,
);
return response.data;
};
export const getSensorPlacementScheme = async (
network: string,
schemeId: number,
): Promise<SensorPlacementScheme> => {
const response = await api.get<SensorPlacementScheme>(
`${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<SensorPlacementScheme> => {
const response = await api.put<SensorPlacementScheme>(
`${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<string, AdjustmentStatus>,
) => {
const response = await api.post<Blob>(
`${config.BACKEND_URL}/api/v1/sensor-placement-schemes/${schemeId}/exports/excel`,
{
sensor_location: sensorLocation,
adjustment_status: adjustmentStatus,
},
{
params: { network },
responseType: "blob",
},
);
return response.data;
};
@@ -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([]);
});
});
@@ -0,0 +1,158 @@
import type {
AdjustmentStatus,
SensorPlacementScheme,
SensorPoint,
SensorPointRow,
} from "./types";
export interface SchemeEditorSnapshot {
points: SensorPoint[];
statuses: Record<string, AdjustmentStatus>;
}
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<string, AdjustmentStatus>,
): 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,
};
};
@@ -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];
}
@@ -3,7 +3,7 @@ jest.mock("../MapComponent", () => ({
useMap: jest.fn(), useMap: jest.fn(),
})); }));
jest.mock("../mapLifecycle", () => ({ jest.mock("../mapLifecycle", () => ({
markMapResourcePersistent: <T,>(resource: T) => resource, markMapResourcePersistent: <T>(resource: T) => resource,
})); }));
jest.mock("ol/source/XYZ.js", () => ({ jest.mock("ol/source/XYZ.js", () => ({
@@ -19,7 +19,9 @@ jest.mock("ol/layer/Tile.js", () => ({
constructor(options: any) { constructor(options: any) {
this.source = options.source; this.source = options.source;
} }
getSource() { return this.source; } getSource() {
return this.source;
}
}, },
})); }));
jest.mock("ol/layer/Group", () => ({ jest.mock("ol/layer/Group", () => ({
@@ -29,14 +31,13 @@ jest.mock("ol/layer/Group", () => ({
constructor(options: any) { constructor(options: any) {
this.layers = options.layers; this.layers = options.layers;
} }
getLayers() { return { getArray: () => this.layers }; } getLayers() {
return { getArray: () => this.layers };
}
}, },
})); }));
import { import { createBaseLayerEntries, createBaseLayerSources } from "./BaseLayers";
createBaseLayerEntries,
createBaseLayerSources,
} from "./BaseLayers";
const getLeafSources = (layer: any): unknown[] => { const getLeafSources = (layer: any): unknown[] => {
const childLayers = layer.getLayers?.().getArray?.(); const childLayers = layer.getLayers?.().getArray?.();
@@ -47,6 +48,17 @@ const getLeafSources = (layer: any): unknown[] => {
}; };
describe("base layer resources", () => { 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", () => { it("creates independent layers backed by one shared source pool", () => {
const sources = createBaseLayerSources(); const sources = createBaseLayerSources();
const primary = createBaseLayerEntries(sources); const primary = createBaseLayerEntries(sources);
@@ -33,6 +33,7 @@ const BASE_LAYER_METADATA = [
const createTileSource = (url: string, attributions: string) => const createTileSource = (url: string, attributions: string) =>
new XYZ({ new XYZ({
url, url,
crossOrigin: "anonymous",
tileSize: 512, tileSize: 512,
maxZoom: 20, maxZoom: 20,
projection: "EPSG:3857", projection: "EPSG:3857",
@@ -58,21 +59,25 @@ export const createBaseLayerSources = () => ({
), ),
tiandituVector: new XYZ({ 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}`, 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", projection: "EPSG:3857",
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>', attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
}), }),
tiandituVectorAnnotation: new XYZ({ 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}`, 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", projection: "EPSG:3857",
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>', attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
}), }),
tiandituImage: new XYZ({ 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}`, 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", projection: "EPSG:3857",
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>', attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
}), }),
tiandituImageAnnotation: new XYZ({ 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}`, 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", projection: "EPSG:3857",
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>', attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
}), }),
@@ -132,7 +137,9 @@ const BaseLayers: React.FC = () => {
return map ? [map] : []; return map ? [map] : [];
}, [data?.maps, map]); }, [data?.maps, map]);
const sharedSources = useMemo(() => createBaseLayerSources(), []); const sharedSources = useMemo(() => createBaseLayerSources(), []);
const layerSetsRef = useRef(new WeakMap<OlMap, ReturnType<typeof createBaseLayerEntries>>()); const layerSetsRef = useRef(
new WeakMap<OlMap, ReturnType<typeof createBaseLayerEntries>>(),
);
const [isShow, setShow] = useState(false); const [isShow, setShow] = useState(false);
const [isExpanded, setExpanded] = useState(false); const [isExpanded, setExpanded] = useState(false);
const [activeId, setActiveId] = useState(INITIAL_LAYER); const [activeId, setActiveId] = useState(INITIAL_LAYER);
@@ -244,7 +251,7 @@ const BaseLayers: React.FC = () => {
<div <div
className={clsx( className={clsx(
"absolute flex right-24 bottom-0 w-132 h-25 bg-white rounded-xl drop-shadow-xl shadow-black transition-all duration-300", "absolute flex right-24 bottom-0 w-132 h-25 bg-white rounded-xl drop-shadow-xl shadow-black transition-all duration-300",
isShow ? "opacity-100" : "opacity-0" isShow ? "opacity-100" : "opacity-0",
)} )}
onMouseEnter={handleEnter} onMouseEnter={handleEnter}
onMouseLeave={handleLeave} onMouseLeave={handleLeave}
@@ -265,7 +272,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", "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, "ring-1 ring-blue-300": activeId === item.id,
} },
)} )}
/> />
<span className="pt-1">{item.name}</span> <span className="pt-1">{item.name}</span>