feat(sensor-placement): add scheme engineering editor
This commit is contained in:
+98
-17
@@ -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<TabPanelProps> = ({ children, value, index }) => {
|
||||
hidden={value !== index}
|
||||
className="flex-1 overflow-hidden flex flex-col"
|
||||
>
|
||||
{value === index && (
|
||||
<Box className="flex-1 overflow-auto p-4">{children}</Box>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<SensorPlacementScheme | null>(null);
|
||||
const [loadingScheme, setLoadingScheme] = useState(false);
|
||||
const { open: notify } = useNotification();
|
||||
|
||||
// 持久化方案查询结果
|
||||
const [schemes, setSchemes] = useState<SchemeRecord[]>([]);
|
||||
@@ -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="优化要件"
|
||||
/>
|
||||
<Tab
|
||||
icon={<TableIcon fontSize="small" />}
|
||||
iconPosition="start"
|
||||
label="结果编辑"
|
||||
/>
|
||||
<Tab
|
||||
icon={<SearchIcon fontSize="small" />}
|
||||
iconPosition="start"
|
||||
@@ -206,19 +247,59 @@ const MonitoringPlaceOptimizationPanel: React.FC<
|
||||
<OptimizationParameters
|
||||
state={optimizationState}
|
||||
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 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
|
||||
schemes={schemes}
|
||||
onSchemesChange={setSchemes}
|
||||
state={queryState}
|
||||
onStateChange={setQueryState}
|
||||
onLocate={(id) => {
|
||||
console.log("定位方案:", id);
|
||||
// TODO: 在地图上定位
|
||||
}}
|
||||
onEdit={handleOpenScheme}
|
||||
/>
|
||||
</TabPanel>
|
||||
</Box>
|
||||
|
||||
@@ -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<OptimizationParametersProps> = ({
|
||||
state,
|
||||
onStateChange,
|
||||
onSchemeCreated,
|
||||
}) => {
|
||||
const { open } = useNotification();
|
||||
const { data: user } = useGetIdentity<IUser>();
|
||||
|
||||
const [parametersState, , setFormField] = useControllableObjectState(
|
||||
state,
|
||||
onStateChange,
|
||||
createOptimizationParametersState(),
|
||||
);
|
||||
const { sensorType, method, sensorCount, minDiameter, schemeName } =
|
||||
const { method, sensorCount, minDiameter, schemeName } =
|
||||
parametersState;
|
||||
const [network] = useState<string>(NETWORK_NAME);
|
||||
const network = NETWORK_NAME;
|
||||
const [analyzing, setAnalyzing] = useState<boolean>(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<OptimizationParametersProps> = ({
|
||||
|
||||
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,
|
||||
const created = await optimizeSensorPlacement({
|
||||
network,
|
||||
scheme_name: schemeName,
|
||||
sensor_type: sensorType,
|
||||
method: method,
|
||||
sensor_type: "pressure",
|
||||
method: method as "sensitivity" | "kmeans",
|
||||
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}" 已完成优化分析`,
|
||||
});
|
||||
|
||||
// 重置方案名称
|
||||
onSchemeCreated?.(created);
|
||||
setFormField("schemeName", "Fangan" + new Date().getTime());
|
||||
} else {
|
||||
throw new Error(response.data?.message || "创建失败");
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("创建方案失败:", error);
|
||||
open?.({
|
||||
@@ -175,11 +128,10 @@ const OptimizationParameters: React.FC<OptimizationParametersProps> = ({
|
||||
类型
|
||||
</Typography>
|
||||
<TextField
|
||||
select
|
||||
fullWidth
|
||||
size="small"
|
||||
value={sensorType}
|
||||
onChange={(e) => setFormField("sensorType", e.target.value)}
|
||||
value="压力"
|
||||
slotProps={{ input: { readOnly: true } }}
|
||||
sx={{
|
||||
"& .MuiOutlinedInput-root": {
|
||||
"&: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>
|
||||
|
||||
{/* 方法选择 */}
|
||||
@@ -270,7 +216,7 @@ const OptimizationParameters: React.FC<OptimizationParametersProps> = ({
|
||||
variant="subtitle2"
|
||||
className="mb-2 font-semibold text-gray-700"
|
||||
>
|
||||
{getSensorTypeLabel(sensorType)}监测点安装最小管径(可选)
|
||||
压力监测点安装最小管径(可选)
|
||||
</Typography>
|
||||
<TextField
|
||||
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) =>
|
||||
({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
})[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";
|
||||
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<SchemeQueryProps> = ({
|
||||
schemes: externalSchemes,
|
||||
onSchemesChange,
|
||||
onLocate,
|
||||
onEdit,
|
||||
network = NETWORK_NAME,
|
||||
state,
|
||||
onStateChange,
|
||||
@@ -205,7 +196,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
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<SchemeQueryProps> = ({
|
||||
setQueryField("expandedId", expandedId === id ? null : id);
|
||||
};
|
||||
|
||||
// 保存方案(示例功能)
|
||||
const handleSaveScheme = (scheme: SchemeRecord) => {
|
||||
open?.({
|
||||
type: "success",
|
||||
message: "保存成功",
|
||||
description: `方案 "${scheme.schemeName}" 已保存`,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="flex flex-col h-full">
|
||||
{/* 查询条件 - 单行布局 */}
|
||||
@@ -379,8 +361,8 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
variant="caption"
|
||||
className="text-gray-500 block"
|
||||
>
|
||||
最小半径: {scheme.minDiameter} · 用户:{" "}
|
||||
{creatorName(scheme.user)} · 日期:{" "}
|
||||
最小管径: {scheme.minDiameter} · 用户:{" "}
|
||||
{creatorName(scheme.username)} · 日期:{" "}
|
||||
{formatShortDate(scheme.create_time)}
|
||||
</Typography>
|
||||
</Box>
|
||||
@@ -405,16 +387,6 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
<InfoIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{/* <Tooltip title="定位">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onLocate?.(scheme.id)}
|
||||
color="primary"
|
||||
className="p-1"
|
||||
>
|
||||
<LocationIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip> */}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -445,7 +417,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
variant="caption"
|
||||
className="text-gray-600 min-w-[70px]"
|
||||
>
|
||||
最小半径:
|
||||
最小管径:
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
@@ -471,7 +443,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
variant="caption"
|
||||
className="font-medium text-gray-900"
|
||||
>
|
||||
{creatorName(scheme.user)}
|
||||
{creatorName(scheme.username)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box className="flex items-center gap-2">
|
||||
@@ -552,13 +524,14 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
fullWidth
|
||||
size="small"
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
onClick={() => handleSaveScheme(scheme)}
|
||||
startIcon={<EditIcon />}
|
||||
onClick={() => onEdit?.(scheme.id)}
|
||||
sx={{
|
||||
textTransform: "none",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
保存方案
|
||||
打开结果编辑
|
||||
</Button>
|
||||
</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(),
|
||||
}));
|
||||
jest.mock("../mapLifecycle", () => ({
|
||||
markMapResourcePersistent: <T,>(resource: T) => resource,
|
||||
markMapResourcePersistent: <T>(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);
|
||||
|
||||
@@ -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",
|
||||
@@ -58,21 +59,25 @@ export const createBaseLayerSources = () => ({
|
||||
),
|
||||
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}`,
|
||||
crossOrigin: "anonymous",
|
||||
projection: "EPSG:3857",
|
||||
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
|
||||
}),
|
||||
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}`,
|
||||
crossOrigin: "anonymous",
|
||||
projection: "EPSG:3857",
|
||||
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
|
||||
}),
|
||||
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}`,
|
||||
crossOrigin: "anonymous",
|
||||
projection: "EPSG:3857",
|
||||
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
|
||||
}),
|
||||
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}`,
|
||||
crossOrigin: "anonymous",
|
||||
projection: "EPSG:3857",
|
||||
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
|
||||
}),
|
||||
@@ -132,7 +137,9 @@ const BaseLayers: React.FC = () => {
|
||||
return map ? [map] : [];
|
||||
}, [data?.maps, map]);
|
||||
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 [isExpanded, setExpanded] = useState(false);
|
||||
const [activeId, setActiveId] = useState(INITIAL_LAYER);
|
||||
@@ -244,7 +251,7 @@ const BaseLayers: React.FC = () => {
|
||||
<div
|
||||
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",
|
||||
isShow ? "opacity-100" : "opacity-0"
|
||||
isShow ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
onMouseEnter={handleEnter}
|
||||
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",
|
||||
{
|
||||
"ring-1 ring-blue-300": activeId === item.id,
|
||||
}
|
||||
},
|
||||
)}
|
||||
/>
|
||||
<span className="pt-1">{item.name}</span>
|
||||
|
||||
Reference in New Issue
Block a user