feat(sensor-placement): add scheme engineering editor
This commit is contained in:
@@ -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");
|
||||
});
|
||||
Reference in New Issue
Block a user