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; 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, dirty, width = A3_LANDSCAPE_WIDTH, }: DrawingOptions): Promise => { 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); 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, }); 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((resolve, reject) => { canvas.toBlob((blob) => { if (blob) resolve(blob); else reject(new Error("PNG 生成失败")); }, "image/png"); });