From 65ed1fd2122748a7286c6c5cf9dd3831b5484e0d Mon Sep 17 00:00:00 2001 From: Huarch Date: Thu, 30 Jul 2026 21:39:27 +0800 Subject: [PATCH] fix(sensor-placement): refine drawing export and editor density --- .../SchemeDrawingDialog.tsx | 354 +++++++----------- .../SchemeEditor.test.tsx | 36 +- .../SchemeEditor.tsx | 4 +- .../engineeringDrawing.test.ts | 63 ++-- .../engineeringDrawing.ts | 87 ++--- 5 files changed, 230 insertions(+), 314 deletions(-) diff --git a/src/components/olmap/MonitoringPlaceOptimization/SchemeDrawingDialog.tsx b/src/components/olmap/MonitoringPlaceOptimization/SchemeDrawingDialog.tsx index 34779ff..d997c31 100644 --- a/src/components/olmap/MonitoringPlaceOptimization/SchemeDrawingDialog.tsx +++ b/src/components/olmap/MonitoringPlaceOptimization/SchemeDrawingDialog.tsx @@ -1,6 +1,12 @@ "use client"; -import React, { useEffect, useMemo, useState } from "react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { Alert, Box, @@ -10,9 +16,7 @@ import { DialogActions, DialogContent, DialogTitle, - FormControlLabel, - Radio, - RadioGroup, + Stack, Typography, } from "@mui/material"; import { @@ -21,10 +25,7 @@ import { } 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 { @@ -61,8 +62,6 @@ const DRAWING_MAP_SIZE: [number, number] = [ A3_LANDSCAPE_HEIGHT - 650, ]; -type DrawingMode = "linework" | "basemap"; - type DrawingPipeLayer = { getSource?: () => VectorTileSource | null; getExtent?: () => number[] | undefined; @@ -110,52 +109,10 @@ export const fitMapToFullNetwork = ( interface DrawingMapSession { map: OlMap; extent: [number, number, number, number]; - attribution: string | null; dispose: () => void; } -const cloneVisibleBaseLayers = ( - mainMap: OlMap, -): { layers: TileLayer[]; attribution: string | null } => { - const cloned: TileLayer[] = []; - const attributions = new Set(); - const collect = (layer: unknown, parentVisible = true) => { - const candidate = layer as { - getVisible?: () => boolean; - get?: (key: string) => unknown; - getLayers?: () => { getArray: () => unknown[] }; - }; - if (!parentVisible || candidate.getVisible?.() === false) return; - const attribution = candidate.get?.("exportAttribution"); - if (typeof attribution === "string" && attribution) { - attributions.add(attribution); - } - 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 { - layers: cloned, - attribution: attributions.size ? [...attributions].join(" · ") : null, - }; -}; - -const createDrawingMapSession = ( - mainMap: OlMap, - mode: DrawingMode, -): DrawingMapSession => { +const createDrawingMapSession = (mainMap: OlMap): DrawingMapSession => { const mainPipeLayer = getDrawingPipeLayer(mainMap); const source = mainPipeLayer?.getSource?.(); const extent = mainPipeLayer?.getExtent?.(); @@ -196,18 +153,13 @@ const createDrawingMapSession = ( }); pipeLayer.set("value", "pipes"); pipeLayer.setExtent(networkExtent); - const basemap = - mode === "basemap" - ? cloneVisibleBaseLayers(mainMap) - : { layers: [], attribution: null }; - const layers = [...basemap.layers, pipeLayer]; drawingMap = new OlMap({ target, pixelRatio: 1, view: new View({ projection: mainMap.getView().getProjection(), }), - layers, + layers: [pipeLayer], controls: [], interactions: [], }); @@ -218,7 +170,6 @@ const createDrawingMapSession = ( return { map: drawingMap, extent: networkExtent, - attribution: basemap.attribution, dispose: () => { if (disposed) return; disposed = true; @@ -263,59 +214,6 @@ const waitForDrawingMapRender = (map: OlMap) => } }); -const captureMapCanvas = (map: OlMap): HTMLCanvasElement => { - const size = map.getSize(); - if (!size) throw new Error("工程图地图尺寸不可用"); - const output = document.createElement("canvas"); - output.width = size[0]; - output.height = size[1]; - const context = output.getContext("2d"); - if (!context) throw new Error("无法创建地图底图画布"); - context.fillStyle = "#ffffff"; - context.fillRect(0, 0, output.width, output.height); - - const canvases = map - .getViewport() - .querySelectorAll(".ol-layer canvas, canvas.ol-layer"); - canvases.forEach((canvas) => { - if (!canvas.width || !canvas.height) return; - const opacityText = - canvas.parentElement?.style.opacity || canvas.style.opacity || "1"; - const opacity = Number(opacityText); - context.globalAlpha = Number.isFinite(opacity) ? opacity : 1; - - const matrix = canvas.style.transform - .match(/^matrix\(([^)]+)\)$/)?.[1] - .split(",") - .map(Number); - if (matrix?.length === 6 && matrix.every(Number.isFinite)) { - context.setTransform( - matrix[0], - matrix[1], - matrix[2], - matrix[3], - matrix[4], - matrix[5], - ); - } else { - const width = Number.parseFloat(canvas.style.width) || canvas.width; - const height = Number.parseFloat(canvas.style.height) || canvas.height; - context.setTransform( - width / canvas.width, - 0, - 0, - height / canvas.height, - 0, - 0, - ); - } - context.drawImage(canvas, 0, 0); - }); - context.setTransform(1, 0, 0, 1, 0, 0); - context.globalAlpha = 1; - return output; -}; - const readNetworkDataFromMap = ( map: OlMap, extent: [number, number, number, number], @@ -383,6 +281,27 @@ const escapeHtml = (value: string) => })[character] ?? character, ); +export const DrawingPreview = ({ + src, + label, +}: { + src: string; + label: string; +}) => ( + +); + const SchemeDrawingDialog: React.FC = ({ open, onClose, @@ -395,25 +314,40 @@ const SchemeDrawingDialog: React.FC = ({ const [networkData, setNetworkData] = useState( null, ); - const [drawingMode, setDrawingMode] = useState("linework"); - const [mapCanvas, setMapCanvas] = useState(null); - const [mapAttribution, setMapAttribution] = useState(null); const [previewUrl, setPreviewUrl] = useState(null); + const previewUrlRef = useRef(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const replacePreviewUrl = useCallback((nextUrl: string | null) => { + const previousUrl = previewUrlRef.current; + previewUrlRef.current = nextUrl; + setPreviewUrl(nextUrl); + if (previousUrl && previousUrl !== nextUrl) { + URL.revokeObjectURL(previousUrl); + } + }, []); const filename = useMemo( () => `${scheme.scheme_name}${dirty ? "_未保存草稿" : ""}_布置图.png`, [dirty, scheme.scheme_name], ); + useEffect( + () => () => { + if (previewUrlRef.current) { + URL.revokeObjectURL(previewUrlRef.current); + previewUrlRef.current = null; + } + }, + [], + ); + useEffect(() => { if (!open) return; let cancelled = false; setLoading(true); setError(null); setNetworkData(null); - setMapCanvas(null); - setMapAttribution(null); + replacePreviewUrl(null); if (!map) { setError("主地图尚未初始化,请稍后重试"); setLoading(false); @@ -421,7 +355,7 @@ const SchemeDrawingDialog: React.FC = ({ } let drawingSession: DrawingMapSession; try { - drawingSession = createDrawingMapSession(map, drawingMode); + drawingSession = createDrawingMapSession(map); } catch (reason) { setError( reason instanceof Error ? reason.message : "无法适配完整管网范围", @@ -430,24 +364,15 @@ const SchemeDrawingDialog: React.FC = ({ return; } waitForDrawingMapRender(drawingSession.map) - .then(() => { - const data = readNetworkDataFromMap( + .then(() => + readNetworkDataFromMap( drawingSession.map, drawingSession.extent, - ); - const captured = - drawingMode === "basemap" - ? captureMapCanvas(drawingSession.map) - : null; - return { data, captured }; - }) - .then(({ data, captured }) => { + ), + ) + .then((data) => { if (!cancelled) { setNetworkData(data); - setMapCanvas(captured); - setMapAttribution( - drawingMode === "basemap" ? drawingSession.attribution : null, - ); } }) .catch((reason) => { @@ -465,12 +390,11 @@ const SchemeDrawingDialog: React.FC = ({ cancelled = true; drawingSession.dispose(); }; - }, [drawingMode, map, network, open]); + }, [map, network, open, replacePreviewUrl]); useEffect(() => { if (!open || !networkData) return; let cancelled = false; - let currentUrl: string | null = null; setLoading(true); setError(null); renderEngineeringDrawing({ @@ -478,19 +402,13 @@ const SchemeDrawingDialog: React.FC = ({ rows, network, networkData, - mapCanvas, - mapAttribution, dirty, width: 1600, }) .then(canvasToPngBlob) .then((blob) => { if (cancelled) return; - currentUrl = URL.createObjectURL(blob); - setPreviewUrl((previous) => { - if (previous) URL.revokeObjectURL(previous); - return currentUrl; - }); + replacePreviewUrl(URL.createObjectURL(blob)); }) .catch((reason) => { if (!cancelled) @@ -501,17 +419,15 @@ const SchemeDrawingDialog: React.FC = ({ }); return () => { cancelled = true; - if (currentUrl) URL.revokeObjectURL(currentUrl); }; }, [ dirty, - mapAttribution, - mapCanvas, network, networkData, open, rows, scheme, + replacePreviewUrl, ]); const createFullResolutionBlob = async () => { @@ -521,8 +437,6 @@ const SchemeDrawingDialog: React.FC = ({ rows, network, networkData, - mapCanvas, - mapAttribution, dirty, width: A3_LANDSCAPE_WIDTH, }); @@ -600,78 +514,88 @@ const SchemeDrawingDialog: React.FC = ({ 工程布置图预览 - - - - setDrawingMode(event.target.value as DrawingMode) - } - aria-label="工程图底图模式" + + + - } - label="简化管网线稿" - /> - } - label="地图底图 + 管网" - /> - - - A3 横向 · 300 DPI · 4961 × 3508 - - - {error && ( - - {error} - - )} - - {previewUrl && ( - - )} - {loading && ( - - + + + 输出样式 + + 简化管网线稿 + + + + + {error && ( + + {error} + )} + + {previewUrl && ( + + )} + {loading && ( + + + + )} + diff --git a/src/components/olmap/MonitoringPlaceOptimization/SchemeEditor.test.tsx b/src/components/olmap/MonitoringPlaceOptimization/SchemeEditor.test.tsx index 2553220..a49439d 100644 --- a/src/components/olmap/MonitoringPlaceOptimization/SchemeEditor.test.tsx +++ b/src/components/olmap/MonitoringPlaceOptimization/SchemeEditor.test.tsx @@ -31,7 +31,20 @@ jest.mock("@components/olmap/core/MapComponent", () => ({ })); jest.mock("@mui/x-data-grid", () => ({ - DataGrid: () =>
, + DataGrid: (props: { + density?: string; + initialState?: { density?: string }; + rowHeight?: number; + columnHeaderHeight?: number; + }) => ( +
+ ), GridToolbar: () => null, })); @@ -173,4 +186,25 @@ describe("SchemeEditor notifications", () => { message: "请先点击要替换的监测点", }); }); + + it("lets the Data Grid density selector control row sizing", () => { + render(); + + expect(screen.getByTestId("scheme-grid")).toHaveAttribute( + "data-density", + "uncontrolled", + ); + expect(screen.getByTestId("scheme-grid")).toHaveAttribute( + "data-initial-density", + "compact", + ); + expect(screen.getByTestId("scheme-grid")).toHaveAttribute( + "data-row-height", + "automatic", + ); + expect(screen.getByTestId("scheme-grid")).toHaveAttribute( + "data-column-header-height", + "automatic", + ); + }); }); diff --git a/src/components/olmap/MonitoringPlaceOptimization/SchemeEditor.tsx b/src/components/olmap/MonitoringPlaceOptimization/SchemeEditor.tsx index 90086a6..14895a1 100644 --- a/src/components/olmap/MonitoringPlaceOptimization/SchemeEditor.tsx +++ b/src/components/olmap/MonitoringPlaceOptimization/SchemeEditor.tsx @@ -748,9 +748,7 @@ const SchemeEditor: React.FC = ({ columns={columns} localeText={zhCN.components.MuiDataGrid.defaultProps.localeText} getRowId={(row) => row.node_id} - rowHeight={48} - columnHeaderHeight={44} - density="compact" + initialState={{ density: "compact" }} disableRowSelectionOnClick={false} rowSelectionModel={selectedNodeId ? [selectedNodeId] : []} onRowSelectionModelChange={(selection: GridRowSelectionModel) => diff --git a/src/components/olmap/MonitoringPlaceOptimization/engineeringDrawing.test.ts b/src/components/olmap/MonitoringPlaceOptimization/engineeringDrawing.test.ts index c25a3da..cc18913 100644 --- a/src/components/olmap/MonitoringPlaceOptimization/engineeringDrawing.test.ts +++ b/src/components/olmap/MonitoringPlaceOptimization/engineeringDrawing.test.ts @@ -75,6 +75,7 @@ import { renderEngineeringDrawing, } from "./engineeringDrawing"; import SchemeDrawingDialog, { + DrawingPreview, fitMapToFullNetwork, } from "./SchemeDrawingDialog"; import type { SensorPlacementScheme, SensorPointRow } from "./types"; @@ -182,14 +183,36 @@ describe("engineering drawing map framing", () => { ); await waitFor(() => expect(getAllLayers).toHaveBeenCalled()); - expect(screen.getByLabelText("简化管网线稿")).toBeChecked(); - expect(screen.getByLabelText("地图底图 + 管网")).toBeInTheDocument(); + expect(screen.getByText(/简化管网线稿/)).toBeInTheDocument(); + expect(screen.queryByText("地图底图 + 管网")).not.toBeInTheDocument(); + expect(screen.queryByTestId("HubOutlinedIcon")).not.toBeInTheDocument(); + expect(screen.getByTestId("drawing-content-spacing")).toHaveStyle({ + paddingTop: "12px", + }); + expect(screen.getByTestId("drawing-format-summary")).toHaveStyle({ + marginBottom: "12px", + }); expect(fit).not.toHaveBeenCalled(); expect(setCenter).not.toHaveBeenCalled(); expect(setResolution).not.toHaveBeenCalled(); expect(setRotation).not.toHaveBeenCalled(); }); + it("does not use a native image fallback for the generated preview", () => { + render( + createElement(DrawingPreview, { + src: "blob:engineering-drawing-preview", + label: "测试方案压力监测点布置图预览", + }), + ); + + const preview = screen.getByRole("img", { + name: "测试方案压力监测点布置图预览", + }); + expect(preview.tagName).toBe("DIV"); + expect(preview).not.toHaveAttribute("src"); + }); + it("fits the OpenLayers map to the complete pipe layer extent", () => { const extent = [13500000, 3600000, 13600000, 3700000]; const cancelAnimations = jest.fn(); @@ -276,6 +299,7 @@ describe("engineering drawing", () => { 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); + expect(context.drawImage).not.toHaveBeenCalled(); }); it("keeps visible space on the left and right of the complete network", async () => { @@ -303,39 +327,4 @@ describe("engineering drawing", () => { 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, - mapAttribution: "© Mapbox © OpenStreetMap", - dirty: false, - width: 1600, - }); - - expect(context.drawImage).toHaveBeenCalledWith( - mapCanvas, - expect.any(Number), - expect.any(Number), - expect.any(Number), - expect.any(Number), - ); - expect(context.fillText).toHaveBeenCalledWith( - "底图来源:© Mapbox © OpenStreetMap", - expect.any(Number), - expect.any(Number), - ); - }); }); diff --git a/src/components/olmap/MonitoringPlaceOptimization/engineeringDrawing.ts b/src/components/olmap/MonitoringPlaceOptimization/engineeringDrawing.ts index 3950f22..cccb808 100644 --- a/src/components/olmap/MonitoringPlaceOptimization/engineeringDrawing.ts +++ b/src/components/olmap/MonitoringPlaceOptimization/engineeringDrawing.ts @@ -12,8 +12,6 @@ interface DrawingOptions { rows: SensorPointRow[]; network: string; networkData: NetworkDrawingData; - mapCanvas?: HTMLCanvasElement | null; - mapAttribution?: string | null; dirty: boolean; width?: number; } @@ -123,8 +121,6 @@ export const renderEngineeringDrawing = async ({ rows, network, networkData, - mapCanvas, - mapAttribution, dirty, width = A3_LANDSCAPE_WIDTH, }: DrawingOptions): Promise => { @@ -161,15 +157,6 @@ export const renderEngineeringDrawing = async ({ ); 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) @@ -184,40 +171,38 @@ export const renderEngineeringDrawing = async ({ 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.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(); + 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) => { @@ -263,20 +248,6 @@ export const renderEngineeringDrawing = async ({ context.strokeStyle = "#111827"; context.lineWidth = px(4); context.strokeRect(mapBox.x, mapBox.y, mapBox.width, mapBox.height); - if (mapCanvas && mapAttribution) { - context.fillStyle = "#334155"; - drawText( - context, - `底图来源:${mapAttribution}`, - mapBox.x + mapBox.width - px(16), - mapBox.y + mapBox.height - px(18), - px(18), - 500, - "right", - "rgba(255,255,255,0.96)", - px(6), - ); - } for (let index = 0; index <= 4; index += 1) { const ratio = index / 4;