import { useInspectionStore } from "@/store/modules/inspection"; const inspectionStore = useInspectionStore(); /** * 6位十六进制颜色字符串转为 CSS rgba 字符串 * @param {string} hex - 如 "#ff6e40" * @param {number} alpha - 透明度 0~1 * @returns {string} CSS rgba 字符串 */ function hexToCssRgba(hexString, alpha) { const r = parseInt(hexString.slice(1, 3), 16); // 提取 R 分量 const g = parseInt(hexString.slice(3, 5), 16); // 提取 G 分量 const b = parseInt(hexString.slice(5, 7), 16); // 提取 B 分量 return `rgba(${r},${g},${b},${alpha})`; } /** * 将世界坐标转换为 Canvas 屏幕像素坐标 * 注意:世界坐标 Y 轴向上,Canvas Y 轴向下,因此需要翻转 * @param {number} worldX - 世界坐标 X(米) * @param {number} worldY - 世界坐标 Y(米) * @returns {{x: number, y: number}} Canvas 上的屏幕像素坐标 */ export const worldToScreen = (worldX, worldY, dataStore) => { const store = dataStore || inspectionStore; const camera = store.camera const screenX = (worldX - camera.centerX) * camera.pixelsPerMeter + store.canvasWidthPx / 2; // 水平偏移 + 居中 const screenY = -(worldY - camera.centerY) * camera.pixelsPerMeter + store.canvasHeightPx / 2; // 垂直翻转 + 居中 return { x: screenX, y: screenY }; }; /** * 将 Canvas 屏幕像素坐标反算为世界坐标 * 与 worldToScreen 互为逆运算 * @param {number} screenX - Canvas 像素 X * @param {number} screenY - Canvas 像素 Y * @returns {{x: number, y: number}} 世界坐标(米) */ export const screenToWorld = (screenX, screenY, dataStore) => { const store = dataStore || inspectionStore; const camera = store.camera; const worldX = (screenX - store.canvasWidthPx / 2) / camera.pixelsPerMeter + camera.centerX; // 水平反算 const worldY = -(screenY - store.canvasHeightPx / 2) / camera.pixelsPerMeter + camera.centerY; // 垂直反算(带翻转) return { x: worldX, y: worldY }; }; /** * 0xAARRGGBB 整数颜色解码为 {r, g, b, a} 对象 * @param {number} argbInt - 32位 ARGB 整数,如 0xCCFF0040 * @returns {{r:number, g:number, b:number, a:number}|null} RGBA 分量 (0~255),无效输入返回 null */ export const decodeArgbInteger = (argbInt) => { if (typeof argbInt !== 'number') return null; // 非数字类型直接返回 return { a: (argbInt >>> 24) & 0xFF, // 高8位:Alpha 透明度 r: (argbInt >>> 16) & 0xFF, // 次高8位:Red g: (argbInt >>> 8) & 0xFF, // 次低8位:Green b: argbInt & 0xFF, // 低8位:Blue }; } /** * 将 decodeArgbInteger 的结果转为 CSS rgba() 字符串 * @param {{r,g,b,a}} colorObj - RGBA 分量对象 * @param {number} [alphaOverride] - 可选,覆盖 alpha 值(0~1) * @returns {string|null} CSS rgba 字符串,如 "rgba(255,0,64,0.3)" */ export const toCssRgba = (colorObj, alphaOverride) => { if (!colorObj) return null; // 空对象返回 null const alpha = alphaOverride !== undefined // 如果提供了覆盖值 ? alphaOverride // 使用覆盖值 : colorObj.a / 255; // 否则将 0~255 映射到 0~1 return `rgba(${colorObj.r},${colorObj.g},${colorObj.b},${alpha})`; } /** * 栅格底图绘制函数 * 将预渲染的离屏 Canvas 图像绘制到主 Canvas 上,并根据当前地图范围进行缩放和平移 * @param {*} canvasCtx * @param {*} map * @param {*} gridOffscreen */ export const drawGrid = (canvasCtx, map, dataStore) => { const store = dataStore || inspectionStore; const topLeftScreen = worldToScreen(map.originX, map.maxY, store); // 图像左上角(世界坐标→屏幕) const bottomRightScreen = worldToScreen(map.maxX, map.originY, store); // 图像右下角(世界坐标→屏幕) canvasCtx.imageSmoothingEnabled = false; // 关闭抗锯齿(保持像素清晰) canvasCtx.drawImage( store.gridOffscreen, // 离屏 Canvas 图像源 topLeftScreen.x, topLeftScreen.y, // 目标左上角 bottomRightScreen.x - topLeftScreen.x, // 目标宽度 bottomRightScreen.y - topLeftScreen.y // 目标高度 ); } /** * 网格辅助线(每1米一条) * @param {*} canvasCtx * @param {*} canvasWidthPx * @param {*} canvasHeightPx */ export const drawGridlines = (canvasCtx, dataStore) => { // 计算当前可视范围的世界坐标 const store = dataStore || inspectionStore; const viewTopLeft = screenToWorld(0, 0, store); // 视口左上角世界坐标 const viewBottomRight = screenToWorld(store.canvasWidthPx, store.canvasHeightPx, store); // 视口右下角世界坐标 const viewMinX = Math.floor(Math.min(viewTopLeft.x, viewBottomRight.x)); // 可视X最小整数 const viewMaxX = Math.ceil(Math.max(viewTopLeft.x, viewBottomRight.x)); // 可视X最大整数 const viewMinY = Math.floor(Math.min(viewTopLeft.y, viewBottomRight.y)); // 可视Y最小整数 const viewMaxY = Math.ceil(Math.max(viewTopLeft.y, viewBottomRight.y)); // 可视Y最大整数 // 绘制1米间隔的浅色网格线 canvasCtx.strokeStyle = 'rgba(30,50,80,0.25)'; // 淡蓝灰色 canvasCtx.lineWidth = 0.5; // 细线 canvasCtx.beginPath(); for (let worldX = viewMinX; worldX <= viewMaxX; worldX++) { // 逐列绘制垂直线 const screenPos = worldToScreen(worldX, 0, store); canvasCtx.moveTo(screenPos.x, 0); // 从画布顶部 canvasCtx.lineTo(screenPos.x, store.canvasHeightPx); // 到画布底部 } for (let worldY = viewMinY; worldY <= viewMaxY; worldY++) { // 逐行绘制水平线 const screenPos = worldToScreen(0, worldY, store); canvasCtx.moveTo(0, screenPos.y); // 从画布左边 canvasCtx.lineTo(store.canvasWidthPx, screenPos.y); // 到画布右边 } canvasCtx.stroke(); } /** * 绘制高级区域列表 * @param {*} canvasCtx * @param {*} map * @param {*} dataStore */ export const drawAdvancedAreaList = (canvasCtx, map, dataStore) => { const store = dataStore || inspectionStore; for (let areaIndex = 0; areaIndex < map.advancedAreas.length; areaIndex++) { const area = map.advancedAreas[areaIndex]; // 当前区域 const vertices = area.posGroup || []; // 多边形顶点数组 if (vertices.length < 3) continue; // 少于3个顶点无法构成多边形,跳过 // ── 颜色:优先使用 attribute 中的 ARGB 整数,否则用默认品红色 ── let fillColorCss = 'rgba(224,64,251,0.12)'; // 默认填充(品红半透明) let strokeColorCss = 'rgba(224,64,251,0.5)'; // 默认描边(品红较不透明) if (area.attribute) { const brushColor = decodeArgbInteger(area.attribute.colorBrush); // 解码填充色 const penColor = decodeArgbInteger(area.attribute.colorPen); // 解码描边色 if (brushColor) { // 使用原始 alpha 值的一半作为填充透明度,最小 0.06 fillColorCss = toCssRgba(brushColor, Math.max(0.06, (brushColor.a / 255) * 0.5)); } if (penColor) { strokeColorCss = toCssRgba(penColor, 0.6); // 描边60%不透明 } } // ── 绘制多边形 ── canvasCtx.fillStyle = fillColorCss; // 设置填充色 canvasCtx.strokeStyle = strokeColorCss; // 设置描边色 canvasCtx.lineWidth = 1.2; // 描边宽度 canvasCtx.beginPath(); const firstVertexScreen = worldToScreen(vertices[0].x, vertices[0].y, store); // 第一个顶点的屏幕位置 canvasCtx.moveTo(firstVertexScreen.x, firstVertexScreen.y); // 移动到起点 for (let i = 1; i < vertices.length; i++) { // 依次连接后续顶点 const vertexScreen = worldToScreen(vertices[i].x, vertices[i].y, store); canvasCtx.lineTo(vertexScreen.x, vertexScreen.y); } canvasCtx.closePath(); // 闭合路径 canvasCtx.fill(); // 填充 canvasCtx.stroke(); // 描边 // ── 在多边形质心处绘制区域名称标签 ── let centroidWorldX = 0, centroidWorldY = 0; // 质心世界坐标 for (const v of vertices) { centroidWorldX += v.x; centroidWorldY += v.y; } // 累加顶点坐标 centroidWorldX /= vertices.length; // 求平均得到质心 X centroidWorldY /= vertices.length; // 求平均得到质心 Y const centroidScreen = worldToScreen(centroidWorldX, centroidWorldY, store); canvasCtx.font = '500 8px "JetBrains Mono"'; // 标签字体 canvasCtx.fillStyle = strokeColorCss; // 与描边同色 canvasCtx.textAlign = 'center'; // 居中对齐 canvasCtx.fillText(area.instanceName || '', centroidScreen.x, centroidScreen.y + 3); // 绘制文字 } }; /** * 绘制高级线列表 * @param {*} canvasCtx * @param {*} map */ export const drawAdvancedLineList = (canvasCtx, map, dataStore) => { const store = dataStore || inspectionStore; for (let advLineIndex = 0; advLineIndex < map.advancedLines.length; advLineIndex++) { const advLine = map.advancedLines[advLineIndex]; // 当前高级线 const lineData = advLine.line; // 内部的 line 对象 if (!lineData || !lineData.startPos || !lineData.endPos) continue; // 缺少端点则跳过 const startScreen = worldToScreen(lineData.startPos.x, lineData.startPos.y, store); const endScreen = worldToScreen(lineData.endPos.x, lineData.endPos.y, store); // ── 根据 className 决定颜色和线型 ── let lineColorHex = '#ff6e40'; // 默认橙色 let lineDashPattern = []; // 默认实线(无虚线) const classNameLower = (advLine.className || '').toLowerCase(); if (classNameLower.includes('forbidden')) { // 禁行线 → 红色虚线 lineColorHex = '#ff1744'; lineDashPattern = [8, 4]; // 虚线模式:8px实 4px空 } else if (classNameLower.includes('virtual')) { // 虚拟线 → 橙色虚线 lineColorHex = '#ffab40'; lineDashPattern = [4, 4]; } // 绘制高级线 canvasCtx.save(); canvasCtx.shadowColor = lineColorHex; // 辉光色与线条同色 canvasCtx.shadowBlur = 4; // 辉光半径 canvasCtx.strokeStyle = lineColorHex; // 线条颜色 canvasCtx.lineWidth = 2.5; // 线宽(比基础线粗) canvasCtx.setLineDash(lineDashPattern); // 设置虚线模式 canvasCtx.globalAlpha = 0.8; // 80%不透明 canvasCtx.beginPath(); canvasCtx.moveTo(startScreen.x, startScreen.y); canvasCtx.lineTo(endScreen.x, endScreen.y); canvasCtx.stroke(); canvasCtx.setLineDash([]); // 恢复实线 canvasCtx.restore(); // 在中点绘制名称标签 // const midpointScreenX = (startScreen.x + endScreen.x) / 2; // const midpointScreenY = (startScreen.y + endScreen.y) / 2; // canvasCtx.font = '500 8px "JetBrains Mono"'; // canvasCtx.fillStyle = hexToCssRgba(lineColorHex, 0.55); // 55%不透明的文字 // canvasCtx.textAlign = 'center'; // canvasCtx.fillText(advLine.instanceName || advLine.className || '', midpointScreenX, midpointScreenY - 6); } }; /** * 绘制基础直线列表 * @param {*} canvasCtx * @param {*} map * @param {*} dataStore */ export const drawNormalLines = (canvasCtx, map, dataStore) => { const store = dataStore || inspectionStore; for (let lineIndex = 0; lineIndex < map.normalLines.length; lineIndex++) { const line = map.normalLines[lineIndex]; // 当前直线 const startScreen = worldToScreen(line.startPos.x, line.startPos.y, store); // 起点屏幕位置 const endScreen = worldToScreen(line.endPos.x, line.endPos.y, store); // 终点屏幕位置 // 绘制发光直线 canvasCtx.save(); canvasCtx.shadowColor = '#00e5ff'; // 阴影颜色(产生辉光) canvasCtx.shadowBlur = 5; // 辉光扩散半径 canvasCtx.strokeStyle = '#00e5ff'; // 线条颜色:青色 canvasCtx.lineWidth = 2; // 线宽 canvasCtx.globalAlpha = 0.8; // 80%不透明 canvasCtx.beginPath(); canvasCtx.moveTo(startScreen.x, startScreen.y); // 从起点 canvasCtx.lineTo(endScreen.x, endScreen.y); // 到终点 canvasCtx.stroke(); canvasCtx.restore(); // 在中点绘制方向箭头 const midpointX = (startScreen.x + endScreen.x) / 2; // 中点X const midpointY = (startScreen.y + endScreen.y) / 2; // 中点Y const arrowAngle = Math.atan2(endScreen.y - startScreen.y, endScreen.x - startScreen.x); // 线段方向角 const arrowLength = 5; // 箭头长度 canvasCtx.fillStyle = 'rgba(0,229,255,0.6)'; // 箭头填充色 canvasCtx.beginPath(); canvasCtx.moveTo(midpointX + Math.cos(arrowAngle) * arrowLength, midpointY + Math.sin(arrowAngle) * arrowLength); // 箭头尖端 canvasCtx.lineTo(midpointX + Math.cos(arrowAngle + 2.5) * 4, midpointY + Math.sin(arrowAngle + 2.5) * 4); // 左翼 canvasCtx.lineTo(midpointX + Math.cos(arrowAngle - 2.5) * 4, midpointY + Math.sin(arrowAngle - 2.5) * 4); // 右翼 canvasCtx.closePath(); canvasCtx.fill(); } }; /** * 绘制高级曲线列表 * @param {*} canvasCtx * @param {*} map * @param {*} dataStore */ export const drawAdvancedCurveList = (canvasCtx, map, dataStore) => { const store = dataStore || inspectionStore; for (let curveIndex = 0; curveIndex < map.advancedCurves.length; curveIndex++) { const curve = map.advancedCurves[curveIndex]; // 当前曲线 // 提取起点和终点的 pos(注意:startPos/endPos 是 {instanceName, pos:{x,y}} 结构) const startPoint = curve.startPos?.pos; // 起点世界坐标 const endPoint = curve.endPos?.pos; // 终点世界坐标 const controlPoint1 = curve.controlPos1; // 第一控制点(直接 {x,y}) const controlPoint2 = curve.controlPos2; // 第二控制点(直接 {x,y}) if (!startPoint || !endPoint) continue; // 缺少端点则跳过 // 转换为屏幕坐标 const startScreen = worldToScreen(startPoint.x, startPoint.y, store); const endScreen = worldToScreen(endPoint.x, endPoint.y, store); const cp1Screen = controlPoint1 ? worldToScreen(controlPoint1.x, controlPoint1.y, store) : null; // 可能不存在 const cp2Screen = controlPoint2 ? worldToScreen(controlPoint2.x, controlPoint2.y, store) : null; // ── 绘制贝塞尔曲线(虚线)── canvasCtx.save(); canvasCtx.shadowColor = '#76ff03'; // 酸绿色辉光 canvasCtx.shadowBlur = 5; canvasCtx.strokeStyle = '#76ff03'; // 酸绿色线条 canvasCtx.lineWidth = 2; canvasCtx.setLineDash([8, 5]); // 虚线模式 canvasCtx.globalAlpha = 0.85; canvasCtx.beginPath(); canvasCtx.moveTo(startScreen.x, startScreen.y); // 起点 if (cp1Screen && cp2Screen) { // 有2个控制点 → 三次贝塞尔 (cubic bezier) canvasCtx.bezierCurveTo(cp1Screen.x, cp1Screen.y, cp2Screen.x, cp2Screen.y, endScreen.x, endScreen.y); } else if (cp1Screen) { // 仅1个控制点 → 二次贝塞尔 (quadratic bezier) canvasCtx.quadraticCurveTo(cp1Screen.x, cp1Screen.y, endScreen.x, endScreen.y); } else { // 无控制点 → 直线 canvasCtx.lineTo(endScreen.x, endScreen.y); } canvasCtx.stroke(); canvasCtx.setLineDash([]); // 恢复实线 canvasCtx.restore(); // ── 在 t=0.5 处绘制方向箭头 ── if (cp1Screen && cp2Screen) { const t = 0.5; // 参数 t = 0.5(曲线中点) const oneMinusT = 0.5; // 1-t = 0.5 // 三次贝塞尔公式计算中点坐标 const arrowX = oneMinusT ** 3 * startScreen.x + 3 * oneMinusT ** 2 * t * cp1Screen.x + 3 * oneMinusT * t ** 2 * cp2Screen.x + t ** 3 * endScreen.x; const arrowY = oneMinusT ** 3 * startScreen.y + 3 * oneMinusT ** 2 * t * cp1Screen.y + 3 * oneMinusT * t ** 2 * cp2Screen.y + t ** 3 * endScreen.y; // 三次贝塞尔的一阶导数(切线方向) const tangentX = 3 * oneMinusT ** 2 * (cp1Screen.x - startScreen.x) + 6 * oneMinusT * t * (cp2Screen.x - cp1Screen.x) + 3 * t ** 2 * (endScreen.x - cp2Screen.x); const tangentY = 3 * oneMinusT ** 2 * (cp1Screen.y - startScreen.y) + 6 * oneMinusT * t * (cp2Screen.y - cp1Screen.y) + 3 * t ** 2 * (endScreen.y - cp2Screen.y); const tangentAngle = Math.atan2(tangentY, tangentX); // 切线角度 // 绘制箭头 canvasCtx.fillStyle = 'rgba(118,255,3,0.7)'; canvasCtx.beginPath(); canvasCtx.moveTo(arrowX + Math.cos(tangentAngle) * 5, arrowY + Math.sin(tangentAngle) * 5); canvasCtx.lineTo(arrowX + Math.cos(tangentAngle + 2.5) * 4, arrowY + Math.sin(tangentAngle + 2.5) * 4); canvasCtx.lineTo(arrowX + Math.cos(tangentAngle - 2.5) * 4, arrowY + Math.sin(tangentAngle - 2.5) * 4); canvasCtx.closePath(); canvasCtx.fill(); // 绘制控制点小圆(辅助调试用) canvasCtx.fillStyle = 'rgba(118,255,3,0.25)'; // 浅绿色半透明 for (const controlScreen of [cp1Screen, cp2Screen]) { canvasCtx.beginPath(); canvasCtx.arc(controlScreen.x, controlScreen.y, 2.5, 0, Math.PI * 2); // 半径2.5px的小圆 canvasCtx.fill(); } } // ── 曲线名称标签 ── // canvasCtx.font = '500 7px "JetBrains Mono"'; // canvasCtx.fillStyle = 'rgba(118,255,3,0.5)'; // canvasCtx.textAlign = 'center'; // const labelScreenY = Math.min(startScreen.y, endScreen.y) - 8; // 标签放在最高点上方 // canvasCtx.fillText(curve.instanceName || '', (startScreen.x + endScreen.x) / 2, labelScreenY); } }; /** * 绘制高级点列表 * @param {*} canvasCtx * @param {*} map * @param {*} dataStore */ export const drawAdvancedPointList = (canvasCtx, map, dataStore) => { const store = dataStore || inspectionStore; for (let pointIndex = 0; pointIndex < map.advancedPoints.length; pointIndex++) { const advPoint = map.advancedPoints[pointIndex]; // 当前高级点 const pointScreen = worldToScreen(advPoint.pos.x, advPoint.pos.y, store); // 屏幕位置 // 判断是否被鼠标悬停 const isHovered = store.hoveredElement && store.hoveredElement.kind === 'point' && store.hoveredElement.index === pointIndex; // ── 根据 className 决定颜色 ── let pointColorHex = '#ffd600'; // 默认琥珀色 const classNameLower = (advPoint.className || '').toLowerCase(); if (classNameLower.includes('landmark') || classNameLower.includes('land')) { pointColorHex = '#ffd600'; // LandMark → 琥珀色 } else if (classNameLower.includes('charge')) { pointColorHex = '#22c55e'; // 充电点 → 绿色 } else if (classNameLower.includes('load')) { pointColorHex = '#3b82f6'; // 装载点 → 蓝色 } else if (classNameLower.includes('work') || classNameLower.includes('process')) { pointColorHex = '#a855f7'; // 工位 → 紫色 } else if (classNameLower.includes('station')) { pointColorHex = '#00e5ff'; // 站点 → 青色 } // ── 绘制菱形标记 ── const diamondRadius = isHovered ? 8 : 5; // 悬停时放大 const pulseAlpha = isHovered ? Math.sin(store.animationTimeSeconds * 5) * 0.2 + 0.8 : 1; // 悬停时脉冲闪烁 canvasCtx.save(); canvasCtx.globalAlpha = pulseAlpha; // 应用透明度 canvasCtx.shadowColor = pointColorHex; // 辉光颜色 canvasCtx.shadowBlur = isHovered ? 18 : 8; // 悬停时增强辉光 // 绘制旋转45度的正方形(菱形) canvasCtx.fillStyle = pointColorHex; canvasCtx.beginPath(); canvasCtx.moveTo(pointScreen.x, pointScreen.y - diamondRadius); // 上顶点 canvasCtx.lineTo(pointScreen.x + diamondRadius * 0.7, pointScreen.y); // 右顶点 canvasCtx.lineTo(pointScreen.x, pointScreen.y + diamondRadius); // 下顶点 canvasCtx.lineTo(pointScreen.x - diamondRadius * 0.7, pointScreen.y); // 左顶点 canvasCtx.closePath(); canvasCtx.fill(); // 中心白色小圆点(指示精确位置) canvasCtx.shadowBlur = 0; canvasCtx.fillStyle = '#fff'; canvasCtx.globalAlpha = 0.9; canvasCtx.beginPath(); canvasCtx.arc(pointScreen.x, pointScreen.y, 1.8, 0, Math.PI * 2); // 半径1.8px canvasCtx.fill(); canvasCtx.restore(); // ── 方向箭头(基于 dir 字段,弧度)── if (advPoint.dir !== undefined && advPoint.dir !== null) { // dir 可能是字符串(smap 样例中为 "-1.5759999999999958")或数字 const dirRadians = typeof advPoint.dir === 'string' ? parseFloat(advPoint.dir) : advPoint.dir; if (!isNaN(dirRadians)) { // 有效方向角 const arrowLengthPx = 12; // 箭头长度(屏幕像素) canvasCtx.strokeStyle = hexToCssRgba(pointColorHex, 0.5); // 半透明的线条 canvasCtx.lineWidth = 1; canvasCtx.beginPath(); canvasCtx.moveTo(pointScreen.x, pointScreen.y); // 从点中心出发 // 注意:世界坐标 Y 向上,但 atan2 使用的 dir 是标准数学角,屏幕 Y 翻转后取负 canvasCtx.lineTo( pointScreen.x + Math.cos(dirRadians) * arrowLengthPx, pointScreen.y - Math.sin(dirRadians) * arrowLengthPx // Y 翻转 ); canvasCtx.stroke(); } } // ── 名称标签 ── canvasCtx.font = '500 7px "JetBrains Mono"'; canvasCtx.fillStyle = hexToCssRgba(pointColorHex, 0.7); canvasCtx.textAlign = 'center'; canvasCtx.fillText(advPoint.instanceName || '', pointScreen.x, pointScreen.y - diamondRadius - 4); } } /** * 绘制地图边界 * @param {*} canvasCtx * @param {*} map * @param {*} dataStore */ export const drawMapBoundary = (canvasCtx, map, dataStore) => { const store = dataStore || inspectionStore; const boundaryTopLeft = worldToScreen(map.originX, map.maxY, store); // 边界左上角屏幕位置 const boundaryBottomRight = worldToScreen(map.maxX, map.originY, store); // 边界右下角屏幕位置 canvasCtx.strokeStyle = 'rgba(0,229,160,0.1)'; // 极淡的青绿色 canvasCtx.lineWidth = 1; canvasCtx.setLineDash([5, 4]); // 虚线 canvasCtx.strokeRect( boundaryTopLeft.x, boundaryTopLeft.y, boundaryBottomRight.x - boundaryTopLeft.x, boundaryBottomRight.y - boundaryTopLeft.y ); canvasCtx.setLineDash([]); // 恢复实线 }; /** * 从 normalPosList 构建占据栅格图像 * * 原理:normalPosList 中每个点标记一个自由通行格子。 * 未出现在列表中的格子 = 障碍物(墙壁) * 墙壁格子如果紧邻自由格子,则标记为"墙壁边缘",渲染高亮 * * @param {Object} parsedMap - parseSmapJson 的输出 * @param {boolean} showEdgeGlow - 是否计算墙壁边缘高亮效果 * @returns {HTMLCanvasElement} 离屏 Canvas 元素,包含渲染好的栅格图像 */ export const buildOccupancyGridImage = (parsedMap, showEdgeGlow) => { const gridW = parsedMap.gridWidth; // 栅格宽度(像素) const gridH = parsedMap.gridHeight; // 栅格高度(像素) const originX = parsedMap.originX; // 世界坐标原点 X const originY = parsedMap.originY; // 世界坐标原点 Y const resolution = parsedMap.resolution; // 分辨率 // ── 第一步:遍历 normalPosList,标记自由空间 ── const occupancyGrid = new Uint8Array(gridW * gridH); // 0=墙壁, 1=自由空间 for (const freeSpacePoint of parsedMap.normalPositions) { // 将世界坐标转换为栅格像素坐标 const gridColX = Math.floor((freeSpacePoint.x - originX) / resolution); // 列号(从左到右) const pixelRowFromBottom = Math.floor((freeSpacePoint.y - originY) / resolution); // 行号(从下到上,世界坐标系) const imageRowY = gridH - 1 - pixelRowFromBottom; // 翻转为图像行号(从上到下) // 安全边界检查后标记为自由空间 if (gridColX >= 0 && gridColX < gridW && imageRowY >= 0 && imageRowY < gridH) { occupancyGrid[imageRowY * gridW + gridColX] = 1; // 标记该格为自由 } } // ── 第二步:遍历每个格子,按类型着色 ── const offscreenCanvas = document.createElement('canvas'); // 创建离屏 Canvas offscreenCanvas.width = gridW; // 设置尺寸 offscreenCanvas.height = gridH; const offscreenCtx = offscreenCanvas.getContext('2d'); // 获取上下文 const imageData = offscreenCtx.createImageData(gridW, gridH);// 创建 ImageData const pixelData = imageData.data; // 像素数据数组 (RGBA 扁平排列) // 定义三种颜色 const FREE_SPACE_R = 10, FREE_SPACE_G = 16, FREE_SPACE_B = 30; // 自由空间:深蓝黑 const WALL_INTERIOR_R = 22, WALL_INTERIOR_G = 36, WALL_INTERIOR_B = 64; // 墙壁内部:深蓝 const WALL_EDGE_R = 45, WALL_EDGE_G = 130, WALL_EDGE_B = 255; // 墙壁边缘:亮蓝 for (let rowY = 0; rowY < gridH; rowY++) { // 逐行扫描 for (let colX = 0; colX < gridW; colX++) { // 逐列扫描 const gridIndex = rowY * gridW + colX; // 一维数组索引 const pixelOffset = gridIndex << 2; // RGBA 偏移(×4) if (occupancyGrid[gridIndex] === 1) { // ── 自由空间 ── pixelData[pixelOffset] = FREE_SPACE_R; // R pixelData[pixelOffset + 1] = FREE_SPACE_G; // G pixelData[pixelOffset + 2] = FREE_SPACE_B; // B pixelData[pixelOffset + 3] = 255; // A(不透明) } else { // ── 障物(墙壁)── 判断是否为边缘 let isAdjacentToFreeSpace = false; // 是否紧邻自由空间 if (showEdgeGlow) { // 仅在启用边缘效果时检测 if (rowY > 0 && occupancyGrid[(rowY - 1) * gridW + colX] === 1) isAdjacentToFreeSpace = true; // 上方 else if (rowY < gridH - 1 && occupancyGrid[(rowY + 1) * gridW + colX] === 1) isAdjacentToFreeSpace = true; // 下方 else if (colX > 0 && occupancyGrid[rowY * gridW + colX - 1] === 1) isAdjacentToFreeSpace = true; // 左方 else if (colX < gridW - 1 && occupancyGrid[rowY * gridW + colX + 1] === 1) isAdjacentToFreeSpace = true; // 右方 } if (isAdjacentToFreeSpace) { // 墙壁边缘:亮蓝色 pixelData[pixelOffset] = WALL_EDGE_R; pixelData[pixelOffset + 1] = WALL_EDGE_G; pixelData[pixelOffset + 2] = WALL_EDGE_B; pixelData[pixelOffset + 3] = 255; } else { // 墙壁内部:深蓝色 pixelData[pixelOffset] = WALL_INTERIOR_R; pixelData[pixelOffset + 1] = WALL_INTERIOR_G; pixelData[pixelOffset + 2] = WALL_INTERIOR_B; pixelData[pixelOffset + 3] = 255; } } } } // 将 ImageData 绘制到离屏 Canvas 上 offscreenCtx.putImageData(imageData, 0, 0); return offscreenCanvas; } export class MobileRobot { constructor(id, name, x, y, angleRad = 0, color= "#00D4FF", imageUrl = null) { this.id = id; this.name = name; this.x = x; // 世界坐标X (米) this.y = y; // 世界坐标Y (米) this.angle = angleRad; // 朝向弧度 this.imageUrl = imageUrl || '/bot.svg'; // 预加载图片 (可选) this.imgElement = null; if (this.imageUrl) { this.init(color, this.imageUrl) } } async init(color, imageUrl) { const response = await fetch(imageUrl); let svgText = await response.text(); // 替换颜色 svgText = svgText.replace(/currentColor/g, color); svgText = svgText.replace(/stroke="[^"]*"/g, `stroke="${color}"`); // 创建Blob URL const blob = new Blob([svgText], { type: 'image/svg+xml' }); const url = URL.createObjectURL(blob); this.imgElement = new Image(); this.imgElement.src = url; } // 命中检测: 检查世界坐标是否在机器人区域内 (半径0.35米) hitTest(worldX, worldY, pixelsPerMeter){ const dx = worldX - this.x; const dy = worldY - this.y; const distance = Math.hypot(dx, dy); const hitRadius = 0.35; // 米 return distance < hitRadius; } // 绘制到canvas上下文 (屏幕坐标) draw(ctx, screenX, screenY, scale = 1) { const size = 18; // 绘制大小px if (this.imgElement && this.imgElement.complete) { ctx.save(); ctx.translate(screenX, screenY); ctx.rotate(-this.angle); // canvas Y向下, 角度翻转适配 ctx.drawImage(this.imgElement, -size / 2, -size / 2, size, size); ctx.restore(); } // 名字标签 ctx.font = '12px "JetBrains Mono"'; ctx.fillStyle = '#a0c0f0'; ctx.shadowBlur = 0; ctx.fillText(this.name, screenX - 30, screenY - 20); } // 更新位置 setPosition(x, y) { this.x = x; this.y = y; } setAngle(rad) { this.angle = rad; } }