CMVR-IOT-UI/src/views/inspection/cockpit/canvasUtils.js
2026-08-05 14:17:16 +08:00

621 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 32px "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 12px "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 = '#15ff00', 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.color = color; // 颜色
// 预加载图片 (可选)
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, color, scale = 1) {
const size = 24; // 绘制大小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 = color;
ctx.shadowBlur = 0;
ctx.fillText(this.name, screenX, screenY - 20);
}
// 更新位置
setPosition(x, y) { this.x = x; this.y = y; }
setAngle(rad) { this.angle = rad; }
}