fix(sensor-placement): refine drawing export and editor density
Build Push and Deploy / docker-image (push) Failing after 23s
Build Push and Deploy / deploy-fallback-log (push) Successful in 1s

This commit is contained in:
2026-07-30 21:39:27 +08:00
parent 81cc5dcae1
commit 65ed1fd212
5 changed files with 230 additions and 314 deletions
@@ -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<TileSource>[]; attribution: string | null } => {
const cloned: TileLayer<TileSource>[] = [];
const attributions = new Set<string>();
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<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],
@@ -383,6 +281,27 @@ const escapeHtml = (value: string) =>
})[character] ?? character,
);
export const DrawingPreview = ({
src,
label,
}: {
src: string;
label: string;
}) => (
<Box
role="img"
aria-label={label}
sx={{
width: "100%",
height: "100%",
backgroundImage: `url("${src}")`,
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
backgroundSize: "contain",
}}
/>
);
const SchemeDrawingDialog: React.FC<SchemeDrawingDialogProps> = ({
open,
onClose,
@@ -395,25 +314,40 @@ const SchemeDrawingDialog: React.FC<SchemeDrawingDialogProps> = ({
const [networkData, setNetworkData] = useState<NetworkDrawingData | null>(
null,
);
const [drawingMode, setDrawingMode] = useState<DrawingMode>("linework");
const [mapCanvas, setMapCanvas] = useState<HTMLCanvasElement | null>(null);
const [mapAttribution, setMapAttribution] = useState<string | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const previewUrlRef = useRef<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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<SchemeDrawingDialogProps> = ({
}
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<SchemeDrawingDialogProps> = ({
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<SchemeDrawingDialogProps> = ({
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<SchemeDrawingDialogProps> = ({
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<SchemeDrawingDialogProps> = ({
});
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<SchemeDrawingDialogProps> = ({
rows,
network,
networkData,
mapCanvas,
mapAttribution,
dirty,
width: A3_LANDSCAPE_WIDTH,
});
@@ -600,78 +514,88 @@ const SchemeDrawingDialog: React.FC<SchemeDrawingDialogProps> = ({
<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="工程图底图模式"
<DialogContent sx={{ bgcolor: "#eef2f5", p: 0 }}>
<Box data-testid="drawing-content-spacing" sx={{ px: 3, pt: 1.5, pb: 2 }}>
<Box
data-testid="drawing-format-summary"
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
columnGap: 2,
rowGap: 0.75,
mb: 1.5,
px: 1.5,
py: 1,
minHeight: 44,
bgcolor: "rgba(37, 125, 212, 0.06)",
border: "1px solid rgba(37, 125, 212, 0.14)",
borderRadius: 2,
flexWrap: "wrap",
}}
>
<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} />
<Typography variant="body2" sx={{ lineHeight: 1.5 }}>
<Box component="span" sx={{ color: "text.secondary", mr: 1 }}>
</Box>
<Box component="span" sx={{ color: "text.primary", fontWeight: 700 }}>
线稿
</Box>
</Typography>
<Stack
direction="row"
spacing={1}
divider={
<Box
aria-hidden="true"
sx={{ width: 1, height: 14, bgcolor: "rgba(15, 23, 42, 0.14)" }}
/>
}
sx={{
color: "text.secondary",
fontVariantNumeric: "tabular-nums",
whiteSpace: "nowrap",
}}
>
<Typography variant="caption">A3 </Typography>
<Typography variant="caption">300 DPI</Typography>
<Typography variant="caption">4961 × 3508</Typography>
</Stack>
</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 && (
<DrawingPreview
src={previewUrl}
label={`${scheme.scheme_name} 压力监测点布置图预览`}
/>
)}
{loading && (
<Box
sx={{
position: "absolute",
inset: 0,
display: "grid",
placeItems: "center",
bgcolor: "rgba(255,255,255,0.72)",
}}
>
<CircularProgress size={30} />
</Box>
)}
</Box>
</Box>
</DialogContent>
<DialogActions sx={{ px: 3, py: 2 }}>
@@ -31,7 +31,20 @@ jest.mock("@components/olmap/core/MapComponent", () => ({
}));
jest.mock("@mui/x-data-grid", () => ({
DataGrid: () => <div data-testid="scheme-grid" />,
DataGrid: (props: {
density?: string;
initialState?: { density?: string };
rowHeight?: number;
columnHeaderHeight?: number;
}) => (
<div
data-testid="scheme-grid"
data-density={props.density ?? "uncontrolled"}
data-initial-density={props.initialState?.density ?? ""}
data-row-height={props.rowHeight ?? "automatic"}
data-column-header-height={props.columnHeaderHeight ?? "automatic"}
/>
),
GridToolbar: () => null,
}));
@@ -173,4 +186,25 @@ describe("SchemeEditor notifications", () => {
message: "请先点击要替换的监测点",
});
});
it("lets the Data Grid density selector control row sizing", () => {
render(<SchemeEditor scheme={scheme} network="fengyang" />);
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",
);
});
});
@@ -748,9 +748,7 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
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) =>
@@ -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),
);
});
});
@@ -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<HTMLCanvasElement> => {
@@ -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;