feat: 机器人

This commit is contained in:
zhanghao 2026-05-26 14:45:49 +08:00
parent 5b4cdb7140
commit 4de5e21929
11 changed files with 1030 additions and 682 deletions

File diff suppressed because one or more lines are too long

1
public/3.smap Normal file

File diff suppressed because one or more lines are too long

54
public/bot.svg Normal file
View File

@ -0,0 +1,54 @@
<svg
viewBox="0 0 22 22"
xmlns="http://www.w3.org/2000/svg"
fill="none"
>
<rect width="22" height="22" x="0" y="0" />
<path
d="M11 7.33335L11 3.66669L7.33331 3.66669"
fill-rule="nonzero"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.833333"
/>
<rect
width="14.666667"
height="11.000000"
x="3.666687"
y="7.333344"
rx="1.833333"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.833333"
/>
<path
d="M1.83331 12.8333L3.66665 12.8333"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.833333"
/>
<path
d="M18.3333 12.8333L20.1666 12.8333"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.833333"
/>
<path
d="M13.75 11.9167L13.75 13.75"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.833333"
/>
<path
d="M8.25 11.9167L8.25 13.75"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.833333"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,37 @@
import { update } from 'lodash'
import { defineStore } from 'pinia'
export const useInspectionStore = defineStore('inspection', {
state: () => ({
gridOffscreen: null, // 栅格图像离屏Canvas
hoveredElement: null, // 当前悬停的元素 {kind, index}
animationTimeSeconds: 0, // 全局动画时间戳(秒),用于脉冲动画效果
camera: { // 相机参数
centerX: 0, // 相机中心的世界坐标 X
centerY: 0, // 相机中心的世界坐标 Y
pixelsPerMeter: 40, // 缩放:每米对应的屏幕像素数
},
canvasWidthPx: 0, // Canvas CSS 逻辑宽度(像素)
canvasHeightPx: 0, // Canvas CSS 逻辑高度(像素)
devicePixelRatio: 1, // 设备像素比高DPI屏幕 > 1
}),
actions: {
updateCamera(value) {
this.camera = { ...this.camera, ...value }
},
updateCanvasSize(width, height) {
this.canvasWidthPx = width
this.canvasHeightPx = height
this.devicePixelRatio = window.devicePixelRatio || 1
},
async getCanvasWH() {
const data = {
canvasWidthPx: this.canvasWidthPx,
canvasHeightPx: this.canvasHeightPx,
devicePixelRatio: this.devicePixelRatio,
}
return data
}
}
}
)

View File

@ -0,0 +1,3 @@
<template>
<div>告警记录</div>
</template>

View File

@ -0,0 +1,599 @@
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) => {
const camera = inspectionStore.camera;
const screenX = (worldX - camera.centerX) * camera.pixelsPerMeter + inspectionStore.canvasWidthPx / 2; // 水平偏移 + 居中
const screenY = -(worldY - camera.centerY) * camera.pixelsPerMeter + inspectionStore.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) => {
const camera = inspectionStore.camera;
const worldX = (screenX - inspectionStore.canvasWidthPx / 2) / camera.pixelsPerMeter + camera.centerX; // 水平反算
const worldY = -(screenY - inspectionStore.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
*/
export const drawGrid = (canvasCtx, map) => {
const topLeftScreen = worldToScreen(map.originX, map.maxY); // 图像左上角(世界坐标→屏幕)
const bottomRightScreen = worldToScreen(map.maxX, map.originY); // 图像右下角(世界坐标→屏幕)
canvasCtx.imageSmoothingEnabled = false; // 关闭抗锯齿(保持像素清晰)
canvasCtx.drawImage(
inspectionStore.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) => {
// 计算当前可视范围的世界坐标
const viewTopLeft = screenToWorld(0, 0); // 视口左上角世界坐标
const viewBottomRight = screenToWorld(inspectionStore.canvasWidthPx, inspectionStore.canvasHeightPx); // 视口右下角世界坐标
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);
canvasCtx.moveTo(screenPos.x, 0); // 从画布顶部
canvasCtx.lineTo(screenPos.x, inspectionStore.canvasHeightPx); // 到画布底部
}
for (let worldY = viewMinY; worldY <= viewMaxY; worldY++) { // 逐行绘制水平线
const screenPos = worldToScreen(0, worldY);
canvasCtx.moveTo(0, screenPos.y); // 从画布左边
canvasCtx.lineTo(inspectionStore.canvasWidthPx, screenPos.y); // 到画布右边
}
canvasCtx.stroke();
}
/**
* 绘制高级区域列表
* @param {*} canvasCtx
* @param {*} map
*/
export const drawAdvancedAreaList = (canvasCtx, map) => {
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); // 第一个顶点的屏幕位置
canvasCtx.moveTo(firstVertexScreen.x, firstVertexScreen.y); // 移动到起点
for (let i = 1; i < vertices.length; i++) { // 依次连接后续顶点
const vertexScreen = worldToScreen(vertices[i].x, vertices[i].y);
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);
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) => {
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);
const endScreen = worldToScreen(lineData.endPos.x, lineData.endPos.y);
// ── 根据 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
*/
export const drawNormalLines = (canvasCtx, map) => {
for (let lineIndex = 0; lineIndex < map.normalLines.length; lineIndex++) {
const line = map.normalLines[lineIndex]; // 当前直线
const startScreen = worldToScreen(line.startPos.x, line.startPos.y); // 起点屏幕位置
const endScreen = worldToScreen(line.endPos.x, line.endPos.y); // 终点屏幕位置
// 绘制发光直线
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
*/
export const drawAdvancedCurveList = (canvasCtx, map) => {
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);
const endScreen = worldToScreen(endPoint.x, endPoint.y);
const cp1Screen = controlPoint1 ? worldToScreen(controlPoint1.x, controlPoint1.y) : null; // 可能不存在
const cp2Screen = controlPoint2 ? worldToScreen(controlPoint2.x, controlPoint2.y) : 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
*/
export const drawAdvancedPointList = (canvasCtx, map) => {
for (let pointIndex = 0; pointIndex < map.advancedPoints.length; pointIndex++) {
const advPoint = map.advancedPoints[pointIndex]; // 当前高级点
const pointScreen = worldToScreen(advPoint.pos.x, advPoint.pos.y); // 屏幕位置
// 判断是否被鼠标悬停
const isHovered = inspectionStore.hoveredElement &&
inspectionStore.hoveredElement.kind === 'point' &&
inspectionStore.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(inspectionStore.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
*/
export const drawMapBoundary = (canvasCtx, map) => {
const boundaryTopLeft = worldToScreen(map.originX, map.maxY); // 边界左上角屏幕位置
const boundaryBottomRight = worldToScreen(map.maxX, map.originY); // 边界右下角屏幕位置
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;
}
// import bot from '@/assets/icons/svg/bot.svg'
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) {
console.log('imageUrl', imageUrl)
const response = await fetch(imageUrl);
let svgText = await response.text();
console.log('svgText', svgText)
// 替换颜色
svgText = svgText.replace(/currentColor/g, color);
svgText = svgText.replace(/fill="[^"]*"/g, `fill="${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;
}
// 绘制到canvas上下文 (屏幕坐标)
draw(ctx, screenX, screenY, scale = 1) {
console.log('imgElement', this.imgElement)
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 = '8px "JetBrains Mono"';
ctx.fillStyle = '#a0c0f0';
ctx.shadowBlur = 0;
ctx.fillText(this.name, screenX - 10, screenY - 10);
}
// 更新位置
setPosition(x, y) { this.x = x; this.y = y; }
setAngle(rad) { this.angle = rad; }
}

View File

@ -4,7 +4,8 @@
<div class="robot-container">
<div class="title">选择机器人</div>
<div class="robot-box">
<div v-for="item, index in 4" class="robot" :class="{ active: activeBot === index }" @click="changeBot(index)">
<div v-for="item, index in 4" class="robot" :class="{ active: activeBot === index }"
@click="changeBot(index)">
<div>
<SvgIcon name="bot" :color="colorList[index]" />
</div>
@ -78,16 +79,67 @@
<el-radio :value="3">姿态控制</el-radio>
</el-radio-group>
<div class="leftHand">左手</div>
<div v-if="[1,3].includes(activeRoboticArm)" class="coordinate-system" ref="coordinateSystem">
<div class="control-buttons">
<button v-for="(pos, index) in buttonPositions" :key="index" class="pos-btn"
:data-target="pos.target" :style="{ left: `${pos.left}px`, top: `${pos.top}px` }">
{{ pos.label }}
</button>
<div class="mode-tabs">
<div class="mode-tab active">左手</div>
<div class="mode-tab">右手</div>
</div>
<div class="robotic-arm-box">
<div class="robotic-arm-status">
<div class="coord-display">
<div class="coord-item">
<div class="coord-key key-x">X</div>
<div class="coord-val" id="coordX">+124.50</div>
</div>
<div class="coord-item">
<div class="coord-key key-y">Y</div>
<div class="coord-val" id="coordY">38.20</div>
</div>
<div class="coord-item">
<div class="coord-key key-z">Z</div>
<div class="coord-val" id="coordZ">+502.00</div>
</div>
</div>
</div>
<div class="axis-group">
<!-- X Axis -->
<div class="axis-row x">
<div class="axis-label x">X</div>
<div class="axis-slider-wrap">
<div class="axis-slider-top">
<span class="axis-name">前后平移</span>
<span class="axis-value" id="valX">0.00</span>
</div>
<el-slider v-model="speed" :max="2" :step="0.1" size="small" />
</div>
</div>
<!-- Y Axis -->
<div class="axis-row y">
<div class="axis-label y">Y</div>
<div class="axis-slider-wrap">
<div class="axis-slider-top">
<span class="axis-name">左右平移</span>
<span class="axis-value" id="valY">0.00</span>
</div>
<el-slider v-model="speed" :max="2" :step="0.1" size="small" />
</div>
</div>
<!-- Z Axis -->
<div class="axis-row z">
<div class="axis-label z">Z</div>
<div class="axis-slider-wrap">
<div class="axis-slider-top">
<span class="axis-name">上下平移</span>
<span class="axis-value" id="valZ">0.00</span>
</div>
<el-slider v-model="speed" :max="2" :step="0.1" size="small" />
</div>
</div>
</div>
</div>
<div class="rightHand">右手</div>
</div>
</div>
@ -102,7 +154,7 @@
<div class="monitor-container"></div>
<div class="split-line"></div>
<div class="container-title">语音对话</div>
</div>
</div>
</template>
@ -127,107 +179,18 @@ const speed = ref(0.8)
const activeRoboticArm = ref(1)
const coordinateSystem = ref(null); //
const buttonPositions = ref([]); //
const buttonDirections = [
{ target: "z_plus", direction: "top", label: "Z+" }, // left
{ target: "x_plus", direction: "right", label: "X+" }, // bottom
{ target: "y_minus", direction: "bottom", label: "Y-" },
{ target: "z_minus", direction: "bottom", label: "Z-" },
{ target: "x_minus", direction: "left", label: "X-" },
{ target: "y_plus", direction: "left", label: "Y+" }, // right
];
//
const ORIGINAL_IMAGE_WIDTH = 1025;
const ORIGINAL_IMAGE_HEIGHT = 817;
const handlerController = (value) => {
if (value === 2 && [1, 3].includes(activeRoboticArm.value)) {
updateButtonPositions()
}
}
const handlerArm = (value) => {
if ([1, 3].includes(value)) {
updateButtonPositions()
}
}
const updateButtonPositions = () => {
if (coordinateSystem.value) {
const containerRect = coordinateSystem.value.getBoundingClientRect();
const containerWidth = containerRect.width;
const containerHeight = containerRect.height;
// background-size: contain
const imageAspectRatio = ORIGINAL_IMAGE_WIDTH / ORIGINAL_IMAGE_HEIGHT;
const containerAspectRatio = containerWidth / containerHeight;
let imageWidth, imageHeight, imageLeft, imageTop;
if (containerAspectRatio > imageAspectRatio) {
imageHeight = containerHeight;
imageWidth = imageHeight * imageAspectRatio;
imageLeft = (containerWidth - imageWidth) / 2;
imageTop = 0;
} else {
imageWidth = containerWidth;
imageHeight = imageWidth / imageAspectRatio;
imageLeft = 0;
imageTop = (containerHeight - imageHeight) / 2;
}
//
const centerX = imageLeft + imageWidth / 2;
const centerY = imageTop + imageHeight / 2;
//
const radiusScale = 0.85; // 0-1
const radius = Math.min(imageWidth, imageHeight) * radiusScale / 2;
// top0
const directionAngles = {
"top": 0,
"top-right": Math.PI / 6, // 30
"right": Math.PI / 3, // 60
"bottom-right": Math.PI / 2, // 90
"bottom": 2 * Math.PI / 3, // 120
"bottom-left": 5 * Math.PI / 6, // 150
"left": Math.PI, // 180
"top-left": 7 * Math.PI / 6, // 210
};
// 12360/12=30Math.PI/6
const totalButtons = buttonDirections.length;
const angleStep = 2 * Math.PI / totalButtons;
//
buttonPositions.value = buttonDirections.map((btn, index) => {
// top
const angle = index * angleStep;
//
const x = centerX + radius * Math.sin(angle);
const y = centerY - radius * Math.cos(angle);
return {
...btn,
left: x,
top: y,
};
});
console.log('buttonPositions', buttonPositions.value)
}
};
onMounted(() => {
//
window.addEventListener("resize", updateButtonPositions);
});
onUnmounted(() => {
window.removeEventListener("resize", updateButtonPositions);
});
</script>
<style lang="scss" scoped>
@ -418,42 +381,186 @@ onUnmounted(() => {
justify-content: space-between;
}
.coordinate-system {
height: 200px;
.mode-tabs {
display: flex;
justify-content: center;
align-items: center;
position: relative;
background-image: url("@/assets/images/arm.png");
background-size: contain;
background-position: center;
background-repeat: no-repeat;
padding: 12px 20px 0;
gap: 6px;
.control-buttons {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
.mode-tab {
flex: 1;
padding: 8px 0;
text-align: center;
font-size: 12px;
font-weight: 500;
color: #4a5580;
background: transparent;
border: 1px solid transparent;
border-bottom: none;
border-radius: 8px 8px 0 0;
cursor: pointer;
transition: all 0.25s ease;
position: relative;
user-select: none;
.pos-btn {
position: absolute;
width: 28px;
height: 28px;
background: #4076ff;
color: white;
border: none;
cursor: pointer;
font-size: 14px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transform: translate(-50%, -50%);
/* 按钮中心对齐坐标 */
&:hover {
color: #8b97bf;
background: #3b82f60d;
}
}
.active {
color: #3b82f6;
background: #111a36;
border-color: #1c2a52;
font-weight: 700;
&:after {
content: '';
position: absolute;
bottom: 0;
left: 20%;
right: 20%;
height: 2px;
background: var(--accent);
border-radius: 1px;
box-shadow: 0 0 8px var(--accent-glow);
}
}
}
.robotic-arm-box {
.robotic-arm-status {
.coord-display {
display: flex;
gap: 8px;
padding: 12px;
background: #070b1a;
border: 1px solid #1c2a52;
border-radius: 8px;
margin-bottom: 16px;
.coord-item {
flex: 1;
text-align: center;
padding: 6px 0;
border-radius: 6px;
background: rgba(255, 255, 255, 0.02);
.coord-key {
font-size: 12px;
font-weight: bold;
margin-bottom: 3px;
}
.key-x {
color: #f87171;
}
.key-y {
color: #34d399;
;
}
.key-z {
color: #38bdf8;
;
}
.coord-val {
font-size: 14px;
font-weight: bold;
color: #e8edf8;
}
}
}
}
.axis-group {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 16px;
.axis-row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: #0c1228;
border: 1px solid #1c2a52;
border-radius: 10px;
transition: all 0.2s ease;
&:hover {
border-color: #2a3f7a;
background: #3b82f608;
}
.axis-label {
width: 28px;
height: 28px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 700;
flex-shrink: 0;
}
.x {
background: #f8717126;
color: #f87171;
border: 1px solid #f871714d;
}
.y {
background: #34d39926;
color: #34d399;
border: 1px solid #34d3994d;
}
.z {
background: #38bdf826;
color: #38bdf8;
border: 1px solid #38bdf84d;
}
.axis-slider-wrap {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
.axis-slider-top {
display: flex;
justify-content: space-between;
align-items: center;
.axis-name {
font-size: 11px;
color: #8b97bf;
font-weight: 500;
}
.axis-value {
font-size: 12px;
font-weight: 500;
color: #e8edf8;
min-width: 52px;
text-align: right;
}
}
:deep(.el-slider__button) {
scale: 0.5;
}
}
}
}
}
}
}

View File

@ -7,74 +7,43 @@
<script setup>
import { onMounted, onUnmounted } from 'vue';
//
// 1.
//
import {
worldToScreen,
screenToWorld,
drawGrid,
drawGridlines,
drawAdvancedAreaList,
drawAdvancedLineList,
drawNormalLines,
drawAdvancedCurveList,
drawAdvancedPointList,
drawMapBoundary,
buildOccupancyGridImage,
MobileRobot
} from '../canvasUtils';
/**
* 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
*/
function decodeArgbInteger(argbInt) {
if (typeof argbInt !== 'number') return null; //
return {
a: (argbInt >>> 24) & 0xFF, // 8Alpha
r: (argbInt >>> 16) & 0xFF, // 8Red
g: (argbInt >>> 8) & 0xFF, // 8Green
b: argbInt & 0xFF, // 8Blue
};
}
import { useInspectionStore } from "@/store/modules/inspection";
const inspectionStore = useInspectionStore();
/**
* 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)"
*/
function 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})`;
}
/**
* 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})`;
}
//
// 2.
//
/**
* 应用全局状态对象集中管理所有可变数据
* - parsedMap: 解析后的地图数据结构
* - gridOffscreen: 离屏 Canvas存储绘制好的占据栅格图像
* - camera: 相机状态中心世界坐标 + 缩放级别
* - layerVisibility: 各图层的可见性开关
* - mouseState: 鼠标位置与拖拽状态
* - hoveredElement: 当前鼠标悬停命中的元素
* - isMapLoaded: 地图是否已加载完成
*/
const applicationState = {
parsedMap: null, //
gridOffscreen: null, // Canvas
camera: { //
centerX: 0, // X
centerY: 0, // Y
pixelsPerMeter: 40, //
mouseState: { //
screenX: 0, // Canvas X
screenY: 0, // Canvas Y
worldX: 0, // X
worldY: 0, // Y
isDragging: false, //
},
isMapLoaded: false, // ,
layerVisibility: { //
grid: true, //
edges: true, //
@ -84,23 +53,13 @@ const applicationState = {
curves: true, // advancedCurveList
points: true, // advancedPointList
areas: true, // advancedAreaList
robots: true, //
},
mouseState: { //
screenX: 0, // Canvas X
screenY: 0, // Canvas Y
worldX: 0, // X
worldY: 0, // Y
isDragging: false, //
},
hoveredElement: null, // {kind, index}
isMapLoaded: false, //
robots: [] //
};
let mapCanvas; // DOM
let canvasCtx; // 2D
let canvasWidthPx = 0; // Canvas CSS
let canvasHeightPx = 0; // Canvas CSS
let devicePixelRatio = 1; // DPI > 1
const init = () => {
mapCanvas = document.getElementById('map-canvas');
@ -114,41 +73,14 @@ const init = () => {
function resizeCanvasToContainer() {
const containerRect = mapCanvas.parentElement.getBoundingClientRect(); //
devicePixelRatio = window.devicePixelRatio || 1; //
canvasWidthPx = containerRect.width; //
canvasHeightPx = containerRect.height; // = -
mapCanvas.width = canvasWidthPx * devicePixelRatio; // Canvas
mapCanvas.height = canvasHeightPx * devicePixelRatio; // Canvas
mapCanvas.style.width = canvasWidthPx + 'px'; // CSS
mapCanvas.style.height = canvasHeightPx + 'px'; // CSS
inspectionStore.canvasWidthPx = containerRect.width; //
inspectionStore.canvasHeightPx = containerRect.height; // = -
mapCanvas.width = inspectionStore.canvasWidthPx * devicePixelRatio; // Canvas
mapCanvas.height = inspectionStore.canvasHeightPx * devicePixelRatio; // Canvas
mapCanvas.style.width = inspectionStore.canvasWidthPx + 'px'; // CSS
mapCanvas.style.height = inspectionStore.canvasHeightPx + 'px'; // CSS
}
/**
* 将世界坐标转换为 Canvas 屏幕像素坐标
* 注意世界坐标 Y 轴向上Canvas Y 轴向下因此需要翻转
* @param {number} worldX - 世界坐标 X
* @param {number} worldY - 世界坐标 Y
* @returns {{x: number, y: number}} Canvas 上的屏幕像素坐标
*/
function worldToScreen(worldX, worldY) {
const camera = applicationState.camera;
const screenX = (worldX - camera.centerX) * camera.pixelsPerMeter + canvasWidthPx / 2; // +
const screenY = -(worldY - camera.centerY) * camera.pixelsPerMeter + canvasHeightPx / 2; // +
return { x: screenX, y: screenY };
}
/**
* Canvas 屏幕像素坐标反算为世界坐标
* worldToScreen 互为逆运算
* @param {number} screenX - Canvas 像素 X
* @param {number} screenY - Canvas 像素 Y
* @returns {{x: number, y: number}} 世界坐标
*/
function screenToWorld(screenX, screenY) {
const camera = applicationState.camera;
const worldX = (screenX - canvasWidthPx / 2) / camera.pixelsPerMeter + camera.centerX; //
const worldY = -(screenY - canvasHeightPx / 2) / camera.pixelsPerMeter + camera.centerY; //
return { x: worldX, y: worldY };
}
/**
* 解析 SEER smap JSON 文件为内部地图数据结构
@ -181,22 +113,16 @@ function parseSmapJson(rawJson) {
maxY: maximumPos.y, // Y
gridWidth: gridWidthPx, //
gridHeight: gridHeightPx, //
// normalPosList:
normalPositions: rawJson.normalPosList || [],
// normalLineList: {startPos:{x,y}, endPos:{x,y}}
normalLines: rawJson.normalLineList || [],
// advancedPointList: {className, instanceName, pos:{x,y}, dir, desc, property:[]}
advancedPoints: rawJson.advancedPointList || [],
// advancedLineList: {className, instanceName, line:{startPos, endPos}}
advancedLines: rawJson.advancedLineList || [],
// advancedCurveList: {className, instanceName, controlPos1, controlPos2, startPos, endPos}
advancedCurves: rawJson.advancedCurveList || [],
// advancedAreaList: {className, instanceName, dir, posGroup:[], attribute:{}}
advancedAreas: rawJson.advancedAreaList || [],
};
@ -204,98 +130,6 @@ function parseSmapJson(rawJson) {
return parsedMap;
}
//
// 7.
// normalPosList Canvas
//
/**
* normalPosList 构建占据栅格图像
*
* 原理normalPosList 中每个点标记一个自由通行格子
* 未出现在列表中的格子 = 障碍物墙壁
* 墙壁格子如果紧邻自由格子则标记为"墙壁边缘"渲染高亮
*
* @param {Object} parsedMap - parseSmapJson 的输出
* @param {boolean} showEdgeGlow - 是否计算墙壁边缘高亮效果
* @returns {HTMLCanvasElement} 离屏 Canvas 元素包含渲染好的栅格图像
*/
function 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;
}
/**
* 加载地图到应用构建栅格图像设置相机更新UI
* @param {Object} parsedMap - parseSmapJson 的输出
@ -306,28 +140,23 @@ async function loadMapIntoApplication(parsedMap) {
//
applicationState.parsedMap = parsedMap;
//
applicationState.gridOffscreen = buildOccupancyGridImage(parsedMap, applicationState.layerVisibility.edges);
inspectionStore.gridOffscreen = buildOccupancyGridImage(parsedMap, applicationState.layerVisibility.edges);
//
applicationState.camera.centerX = (parsedMap.originX + parsedMap.maxX) / 2; //
applicationState.camera.centerY = (parsedMap.originY + parsedMap.maxY) / 2; //
inspectionStore.camera.centerX = (parsedMap.originX + parsedMap.maxX) / 2; //
inspectionStore.camera.centerY = (parsedMap.originY + parsedMap.maxY) / 2; //
// 使
const worldWidthMeters = parsedMap.maxX - parsedMap.originX; //
const worldHeightMeters = parsedMap.maxY - parsedMap.originY; //
const fitScaleX = canvasWidthPx / worldWidthMeters; //
const fitScaleY = canvasHeightPx / worldHeightMeters; //
applicationState.camera.pixelsPerMeter = Math.max(1, Math.min(fitScaleX, fitScaleY) * 0.88); // 12%
const fitScaleX = inspectionStore.canvasWidthPx / worldWidthMeters; //
const fitScaleY = inspectionStore.canvasHeightPx / worldHeightMeters; //
inspectionStore.camera.pixelsPerMeter = Math.max(1, Math.min(fitScaleX, fitScaleY) * 0.88); // 12%
// UI
applicationState.isMapLoaded = true;
}
//
// 9.
//
/** 全局动画时间戳(秒),用于脉冲动画效果 */
let animationTimeSeconds = 0;
/**
* 主渲染函数 requestAnimationFrame 循环调用
@ -337,11 +166,11 @@ let animationTimeSeconds = 0;
*/
function renderFrame(timestamp) {
//
animationTimeSeconds = (timestamp || 0) / 1000;
inspectionStore.animationTimeSeconds = (timestamp || 0) / 1000;
// Canvas
canvasCtx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
canvasCtx.clearRect(0, 0, canvasWidthPx, canvasHeightPx); //
canvasCtx.setTransform(inspectionStore.devicePixelRatio, 0, 0, inspectionStore.devicePixelRatio, 0, 0);
canvasCtx.clearRect(0, 0, inspectionStore.canvasWidthPx, inspectionStore.canvasHeightPx); //
//
if (!applicationState.isMapLoaded) {
@ -352,373 +181,65 @@ function renderFrame(timestamp) {
const map = applicationState.parsedMap; //
const layers = applicationState.layerVisibility; //
//
// 1:
//
if (layers.grid && applicationState.gridOffscreen) {
const topLeftScreen = worldToScreen(map.originX, map.maxY); //
const bottomRightScreen = worldToScreen(map.maxX, map.originY); //
canvasCtx.imageSmoothingEnabled = false; // 齿
canvasCtx.drawImage(
applicationState.gridOffscreen, // Canvas
topLeftScreen.x, topLeftScreen.y, //
bottomRightScreen.x - topLeftScreen.x, //
bottomRightScreen.y - topLeftScreen.y //
);
if (layers.grid && inspectionStore.gridOffscreen) {
drawGrid(canvasCtx, map);
}
//
// 2: 线1
//
if (layers.gridlines) {
//
const viewTopLeft = screenToWorld(0, 0); //
const viewBottomRight = screenToWorld(canvasWidthPx, canvasHeightPx); //
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);
canvasCtx.moveTo(screenPos.x, 0); //
canvasCtx.lineTo(screenPos.x, canvasHeightPx); //
}
for (let worldY = viewMinY; worldY <= viewMaxY; worldY++) { // 线
const screenPos = worldToScreen(0, worldY);
canvasCtx.moveTo(0, screenPos.y); //
canvasCtx.lineTo(canvasWidthPx, screenPos.y); //
}
canvasCtx.stroke();
drawGridlines(canvasCtx)
}
//
// 3: advancedAreaList
//
if (layers.areas) {
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); //
canvasCtx.moveTo(firstVertexScreen.x, firstVertexScreen.y); //
for (let i = 1; i < vertices.length; i++) { //
const vertexScreen = worldToScreen(vertices[i].x, vertices[i].y);
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);
canvasCtx.font = '500 8px "JetBrains Mono"'; //
canvasCtx.fillStyle = strokeColorCss; //
canvasCtx.textAlign = 'center'; //
canvasCtx.fillText(area.instanceName || '', centroidScreen.x, centroidScreen.y + 3); //
}
drawAdvancedAreaList(canvasCtx, map)
}
//
// 4: normalLineList线 #00e5ff
//
if (layers.normalLines) {
for (let lineIndex = 0; lineIndex < map.normalLines.length; lineIndex++) {
const line = map.normalLines[lineIndex]; // 线
const startScreen = worldToScreen(line.startPos.x, line.startPos.y); //
const endScreen = worldToScreen(line.endPos.x, line.endPos.y); //
// 线
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();
}
drawNormalLines(canvasCtx, map);
}
//
// 5: advancedLineList线 ForbiddenLine
//
if (layers.advLines) {
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);
const endScreen = worldToScreen(lineData.endPos.x, lineData.endPos.y);
// 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);
}
drawAdvancedLineList(canvasCtx, map);
}
//
// 6: advancedCurveList线绿 #76ff03
//
if (layers.curves) {
for (let curveIndex = 0; curveIndex < map.advancedCurves.length; curveIndex++) {
const curve = map.advancedCurves[curveIndex]; // 线
// posstartPos/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);
const endScreen = worldToScreen(endPoint.x, endPoint.y);
const cp1Screen = controlPoint1 ? worldToScreen(controlPoint1.x, controlPoint1.y) : null; //
const cp2Screen = controlPoint2 ? worldToScreen(controlPoint2.x, controlPoint2.y) : 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);
}
drawAdvancedCurveList(canvasCtx, map);
}
//
// 7: advancedPointList
//
if (layers.points) {
for (let pointIndex = 0; pointIndex < map.advancedPoints.length; pointIndex++) {
const advPoint = map.advancedPoints[pointIndex]; //
const pointScreen = worldToScreen(advPoint.pos.x, advPoint.pos.y); //
//
const isHovered = applicationState.hoveredElement &&
applicationState.hoveredElement.kind === 'point' &&
applicationState.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(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);
}
drawAdvancedPointList(canvasCtx, map);
}
if (layers.robots) {
drawAllRobots(canvasCtx)
}
//
// 线
//
const boundaryTopLeft = worldToScreen(map.originX, map.maxY); //
const boundaryBottomRight = worldToScreen(map.maxX, map.originY); //
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([]); // 线
drawMapBoundary(canvasCtx, map);
//
requestAnimationFrame(renderFrame);
}
const drawAllRobots = (ctx) => {
if (!applicationState.layerVisibility.robots) return;
for (const robot of applicationState.robots) {
console.log(1222)
const screenPos = worldToScreen(robot.x, robot.y);
//
if (screenPos.x + 20 < 0 || screenPos.x - 20 > inspectionStore.canvasWidthPx || screenPos.y + 20 < 0 || screenPos.y - 20 > inspectionStore.canvasHeightPx) {
continue;
}
robot.draw(ctx, screenPos.x, screenPos.y);
}
}
const bindEvent = () => {
//
mapCanvas.addEventListener('mousemove', (mouseEvent) => {
@ -732,8 +253,8 @@ const bindEvent = () => {
//
if (applicationState.mouseState.isDragging) {
applicationState.camera.centerX -= mouseEvent.movementX / applicationState.camera.pixelsPerMeter; //
applicationState.camera.centerY += mouseEvent.movementY / applicationState.camera.pixelsPerMeter; // Y
inspectionStore.camera.centerX -= mouseEvent.movementX / inspectionStore.camera.pixelsPerMeter; //
inspectionStore.camera.centerY += mouseEvent.movementY / inspectionStore.camera.pixelsPerMeter; // Y
}
});
@ -757,11 +278,11 @@ const bindEvent = () => {
wheelEvent.preventDefault(); //
const zoomFactor = wheelEvent.deltaY > 0 ? 0.88 : 1.12; // ==
const mouseWorldBefore = screenToWorld(applicationState.mouseState.screenX, applicationState.mouseState.screenY); //
applicationState.camera.pixelsPerMeter = Math.max(0.5, Math.min(2000, applicationState.camera.pixelsPerMeter * zoomFactor)); //
inspectionStore.camera.pixelsPerMeter = Math.max(0.5, Math.min(2000, inspectionStore.camera.pixelsPerMeter * zoomFactor)); //
const mouseWorldAfter = screenToWorld(applicationState.mouseState.screenX, applicationState.mouseState.screenY); //
// 使
applicationState.camera.centerX += mouseWorldBefore.x - mouseWorldAfter.x;
applicationState.camera.centerY += mouseWorldBefore.y - mouseWorldAfter.y;
inspectionStore.camera.centerX += mouseWorldBefore.x - mouseWorldAfter.x;
inspectionStore.camera.centerY += mouseWorldBefore.y - mouseWorldAfter.y;
}, { passive: false }); // passive:false preventDefault
}
@ -776,7 +297,7 @@ const loadSourceMap = async (url) => {
*/
async function loadUserFile() {
try {
const fileTextContent = await loadSourceMap('/2.smap');
const fileTextContent = await loadSourceMap('/3.smap');
const rawJsonObject = JSON.parse(fileTextContent); // JSON
const parsedMap = parseSmapJson(rawJsonObject); //
await loadMapIntoApplication(parsedMap); //
@ -785,6 +306,23 @@ async function loadUserFile() {
}
}
const addRobot = () => {
const map = applicationState.parsedMap;
console.log('map', map)
//
const centerX = (map.originX + map.maxX) / 2;
const centerY = (map.originY + map.maxY) / 2;
const id = 0;
const robot = new MobileRobot(id, `Robot${id}`, centerX + (Math.random() - 0.5) * 3, centerY + (Math.random() - 0.5) * 3, 0);
applicationState.robots.push(robot);
}
const moveRobot = (robot, dx, dy, dtheta = 0) => {
robot.x += dx;
robot.y += dy;
robot.angle += dtheta;
}
onMounted(() => {
init()
//
@ -794,6 +332,7 @@ onMounted(() => {
resizeCanvasToContainer(); //
setTimeout(() => {
bindEvent()
addRobot()
}, 100)
})

View File

@ -0,0 +1,3 @@
<template>
<div>地图管理</div>
</template>

View File

@ -0,0 +1,3 @@
<template>
<div>点位管理</div>
</template>

View File

@ -0,0 +1,3 @@
<template>
<div>任务管理</div>
</template>