CMVR-IOT-UI/src/views/inspection/cockpit/components/MapCanvas.vue
2026-08-06 16:50:12 +08:00

528 lines
22 KiB
Vue
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.

<template>
<div class="container">
<canvas id="map-canvas"></canvas>
<div id="tooltip">
<div class="tooltip-title"></div>
<div class="tooltip-detail"></div>
</div>
</div>
</template>
<script setup>
import { onMounted, onUnmounted, watch } from 'vue';
import {
worldToScreen,
screenToWorld,
drawGrid,
drawGridlines,
drawAdvancedAreaList,
drawAdvancedLineList,
drawNormalLines,
drawAdvancedCurveList,
drawAdvancedPointList,
drawMapBoundary,
buildOccupancyGridImage,
MobileRobot
} from '../canvasUtils';
import { getMapJson } from '@/api/inspection/robot'
import { useInspectionStore } from "@/store/modules/inspection";
import { ElMessage } from 'element-plus';
const props = defineProps({
robotList: {
type: Array,
default: () => []
}
})
const inspectionStore = useInspectionStore();
/**
* 应用全局状态对象,集中管理所有可变数据
* - parsedMap: 解析后的地图数据结构
* - layerVisibility: 各图层的可见性开关
* - mouseState: 鼠标位置与拖拽状态
* - isMapLoaded: 地图是否已加载完成
*/
const applicationState = {
parsedMap: null, // 解析后的完整地图数据
mouseState: { // 鼠标交互状态
screenX: 0, // 鼠标在 Canvas 上的像素 X
screenY: 0, // 鼠标在 Canvas 上的像素 Y
worldX: 0, // 鼠标对应的世界坐标 X
worldY: 0, // 鼠标对应的世界坐标 Y
isDragging: false, // 是否正在拖拽平移
},
isMapLoaded: false, // 地图加载完成标志,
layerVisibility: { // 各图层是否可见
grid: true, // 占据栅格底图
edges: true, // 墙壁边缘发光
gridlines: true, // 1米网格辅助线
normalLines: true, // normalLineList
advLines: true, // advancedLineList
curves: true, // advancedCurveList
points: true, // advancedPointList
areas: true, // advancedAreaList
robots: true, // 机器人位置和朝向
},
robots: [] // 存储机器人实例
};
let mapCanvas; // 主渲染画布 DOM 元素
let canvasCtx; // 2D 渲染上下文
// 保存当前动画帧 ID组件卸载时用于停止 Canvas 渲染循环。
let animationFrameId;
// 地图请求版本号:当多个地图请求并发时,只允许最后一次请求更新画布。
let mapLoadVersion = 0;
const init = () => {
mapCanvas = document.getElementById('map-canvas');
canvasCtx = mapCanvas.getContext('2d');
}
/**
* 根据容器尺寸和设备像素比调整 Canvas 大小
* 在窗口 resize 时自动调用
*/
function resizeCanvasToContainer() {
const containerRect = mapCanvas.parentElement.getBoundingClientRect(); // 获取容器实际尺寸
devicePixelRatio = window.devicePixelRatio || 1; // 获取设备像素比
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 显示高度
}
/**
* 解析 SEER smap JSON 文件为内部地图数据结构
*
* @param {Object} rawJson - 原始 JSON 解析后的对象
* @returns {Object} 解析后的统一地图数据结构
*/
function parseSmapJson(rawJson) {
// ── 解析 header ──
const header = rawJson.header || rawJson; // 兼容 header 嵌套或平铺
const minimumPos = header.minPos || { x: 0, y: 0 }; // 地图左下角(世界坐标)
const maximumPos = header.maxPos || { x: 1, y: 1 }; // 地图右上角(世界坐标)
const resolution = header.resolution || 0.05; // 每像素代表的世界长度(米)
// ── 计算栅格尺寸 ──
const worldWidth = maximumPos.x - minimumPos.x; // 地图世界宽度(米)
const worldHeight = maximumPos.y - minimumPos.y; // 地图世界高度(米)
const gridWidthPx = Math.ceil(worldWidth / resolution) + 1; // 栅格像素宽度
const gridHeightPx = Math.ceil(worldHeight / resolution) + 1;// 栅格像素高度
// ── 构建统一数据结构 ──
const parsedMap = {
name: header.mapName || 'Untitled', // 地图名称
version: header.version || '', // 版本号
mapType: header.mapType || '', // 地图类型(如 "2D-Map"
resolution: resolution, // 分辨率(米/像素)
originX: minimumPos.x, // 地图左下角 X
originY: minimumPos.y, // 地图左下角 Y
maxX: maximumPos.x, // 地图右上角 X
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 || [],
};
return parsedMap;
}
/**
* 加载地图到应用构建栅格图像、设置相机、更新UI
* @param {Object} parsedMap - parseSmapJson 的输出
*/
async function loadMapIntoApplication(parsedMap) {
await new Promise(resolve => setTimeout(resolve, 30)); // 短暂等待让 UI 有机会刷新
// 保存地图数据到全局状态
applicationState.parsedMap = parsedMap;
// 构建栅格图像(带边缘高亮)
inspectionStore.gridOffscreen = buildOccupancyGridImage(parsedMap, applicationState.layerVisibility.edges);
// ── 设置相机初始位置(地图中心)──
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 = inspectionStore.canvasWidthPx / worldWidthMeters; // 水平方向恰好填满的缩放
const fitScaleY = inspectionStore.canvasHeightPx / worldHeightMeters; // 垂直方向恰好填满的缩放
inspectionStore.camera.pixelsPerMeter = Math.max(1, Math.max(fitScaleX, fitScaleY) * 1); // 取较小值
// 标记加载完成并更新UI
applicationState.isMapLoaded = true;
}
/**
* 主渲染函数,由 requestAnimationFrame 循环调用
* 按层顺序绘制:栅格 → 网格线 → 区域 → 基础线 → 高级线 → 曲线 → 点 → 边界
*
* @param {number} timestamp - requestAnimationFrame 提供的时间戳(毫秒)
*/
function renderFrame(timestamp) {
// 更新动画时间
inspectionStore.animationTimeSeconds = (timestamp || 0) / 1000;
// 设置 Canvas 变换矩阵(考虑设备像素比)
canvasCtx.setTransform(inspectionStore.devicePixelRatio, 0, 0, inspectionStore.devicePixelRatio, 0, 0);
// canvasCtx.clearRect(0, 0, inspectionStore.canvasWidthPx, inspectionStore.canvasHeightPx); // 清空画布
canvasCtx.clearRect(0, 0, 10000, 10000); // 清空画布
// 如果地图未加载,跳过绘制
if (!applicationState.isMapLoaded) {
animationFrameId = requestAnimationFrame(renderFrame); // 继续请求下一帧
return;
}
const map = applicationState.parsedMap; // 当前地图数据
const layers = applicationState.layerVisibility; // 图层可见性
// 图层1: 占据栅格底图
if (layers.grid && inspectionStore.gridOffscreen) {
drawGrid(canvasCtx, map);
}
// 图层2: 网格辅助线每1米一条
if (layers.gridlines) {
drawGridlines(canvasCtx)
}
// 图层3: advancedAreaList半透明多边形区域
if (layers.areas) {
drawAdvancedAreaList(canvasCtx, map)
}
// 图层4: normalLineList基础直线青色 #00e5ff
if (layers.normalLines) {
drawNormalLines(canvasCtx, map);
}
// 图层5: advancedLineList高级线如 ForbiddenLine
if (layers.advLines) {
drawAdvancedLineList(canvasCtx, map);
}
// 图层6: advancedCurveList贝塞尔曲线路径酸绿色 #76ff03
if (layers.curves) {
drawAdvancedCurveList(canvasCtx, map);
}
// 图层7: advancedPointList高级点标记菱形
if (layers.points) {
drawAdvancedPointList(canvasCtx, map);
}
if (layers.robots) {
drawAllRobots(canvasCtx)
}
// 地图边界虚线框
drawMapBoundary(canvasCtx, map);
// 请求下一帧渲染
animationFrameId = requestAnimationFrame(renderFrame);
}
const drawAllRobots = (ctx) => {
if (!applicationState.layerVisibility.robots) return;
for (const robot of applicationState.robots) {
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, robot.color, 1);
}
}
const bindEvent = () => {
// ── 鼠标移动:更新坐标、拖拽平移、悬停检测 ──
mapCanvas.addEventListener('mousemove', (mouseEvent) => {
const canvasRect = mapCanvas.getBoundingClientRect(); // Canvas 在页面中的位置
applicationState.mouseState.screenX = mouseEvent.clientX - canvasRect.left; // 鼠标在 Canvas 上的 X
applicationState.mouseState.screenY = mouseEvent.clientY - canvasRect.top; // 鼠标在 Canvas 上的 Y
// 反算世界坐标
const worldPos = screenToWorld(applicationState.mouseState.screenX, applicationState.mouseState.screenY);
applicationState.mouseState.worldX = worldPos.x;
applicationState.mouseState.worldY = worldPos.y;
// 如果正在拖拽,平移相机
if (applicationState.mouseState.isDragging) {
inspectionStore.camera.centerX -= mouseEvent.movementX / inspectionStore.camera.pixelsPerMeter; // 水平平移
inspectionStore.camera.centerY += mouseEvent.movementY / inspectionStore.camera.pixelsPerMeter; // 垂直平移Y翻转
}
let hit = hitTestRobots(worldPos.x, worldPos.y);
updateTooltipDisplay(hit);
});
// ── 鼠标按下:开始拖拽 ──
mapCanvas.addEventListener('mousedown', () => {
applicationState.mouseState.isDragging = true; // 标记拖拽开始
});
// ── 鼠标抬起:结束拖拽 ──
mapCanvas.addEventListener('mouseup', () => {
applicationState.mouseState.isDragging = false; // 标记拖拽结束
});
// ── 鼠标离开画布:结束拖拽并隐藏 Tooltip ──
mapCanvas.addEventListener('mouseleave', () => {
applicationState.mouseState.isDragging = false;
});
// ── 滚轮:缩放(向鼠标位置缩放)──
mapCanvas.addEventListener('wheel', (wheelEvent) => {
wheelEvent.preventDefault(); // 阻止页面滚动
const zoomFactor = wheelEvent.deltaY > 0 ? 0.88 : 1.12; // 向下滚动=缩小,向上滚动=放大
const mouseWorldBefore = screenToWorld(applicationState.mouseState.screenX, applicationState.mouseState.screenY); // 缩放前鼠标世界坐标
inspectionStore.camera.pixelsPerMeter = Math.max(0.5, Math.min(2000, inspectionStore.camera.pixelsPerMeter * zoomFactor)); // 应用缩放并钳制范围
const mouseWorldAfter = screenToWorld(applicationState.mouseState.screenX, applicationState.mouseState.screenY); // 缩放后鼠标世界坐标
// 调整相机中心,使鼠标指向的世界坐标保持不变
inspectionStore.camera.centerX += mouseWorldBefore.x - mouseWorldAfter.x;
inspectionStore.camera.centerY += mouseWorldBefore.y - mouseWorldAfter.y;
}, { passive: false }); // passive:false 允许 preventDefault
}
const updateTooltipDisplay = (hitResult) => {
const tooltipEl = document.getElementById('tooltip');
if (!hitResult) {
tooltipEl.style.display = 'none';
inspectionStore.hoveredElement = null;
return;
}
inspectionStore.hoveredElement = hitResult;
let title = '', details = '';
if (hitResult.kind === 'robot') {
const robot = hitResult.robot;
title = `${robot.name}`;
details = `<div class="tooltip-detail">位置: (${robot.x}, ${robot.y})</div>
<div class="tooltip-detail">朝向: ${(robot.angle * 180 / Math.PI).toFixed(1)}°</div>
<div class="tooltip-detail">ID: ${robot.id}</div>`;
}
tooltipEl.innerHTML = `<div class="tooltip-title" style="color:#00e5a0">${title}</div>${details}`;
tooltipEl.style.display = 'block';
tooltipEl.style.left = Math.min(applicationState.mouseState.screenX + 14, inspectionStore.canvasWidthPx - 220) + 'px';
tooltipEl.style.top = Math.max(4, applicationState.mouseState.screenY - 8) + 'px';
}
const hitTestRobots = (worldX, worldY) => {
for (let i = 0; i < applicationState.robots.length; i++) {
const robot = applicationState.robots[i];
if (robot.hitTest(worldX, worldY)) {
return { kind: 'robot', index: i, robot: robot };
}
}
return null;
}
const zoomCanvas = (zoomIn) => {
const zoomFactor = zoomIn ? 1.12 : 0.88;
const worldBefore = screenToWorld(inspectionStore.canvasWidthPx / 2, inspectionStore.canvasHeightPx / 2);
inspectionStore.camera.pixelsPerMeter = Math.max(0.5, Math.min(2000, inspectionStore.camera.pixelsPerMeter * zoomFactor));
const worldAfter = screenToWorld(inspectionStore.canvasWidthPx / 2, inspectionStore.canvasHeightPx / 2);
inspectionStore.camera.centerX += worldBefore.x - worldAfter.x;
inspectionStore.camera.centerY += worldBefore.y - worldAfter.y;
}
const centerCanvasView = () => {
const map = applicationState.parsedMap;
if (!map) return;
inspectionStore.camera.centerX = (map.originX + map.maxX) / 2;
inspectionStore.camera.centerY = (map.originY + map.maxY) / 2;
}
/**
* 从机器人列表中选出地图数据来源。
* 当前沿用原有规则:使用第一台已连接的机器人获取地图。
*/
const getConnectedRobot = (robotList = props.robotList) => {
return robotList.find(item => item?.connectStatus == 1)
}
/**
* 生成地图来源的稳定标识。
* 机器人轮询时位置等字段会不断变化,但机器人 ID 和地图名称不变,
* 因此监听该标识可以避免位置更新触发底图重新加载。
*/
const getMapIdentity = (robotList = props.robotList) => {
const robot = getConnectedRobot(robotList)
return robot ? `${robot.id ?? ''}:${robot.robotMapName ?? ''}` : ''
}
/**
* 请求并加载机器人当前使用的地图。
* @param {Object} connectedRobot 用于提供地图的已连接机器人
*/
const loadSourceMap = async (connectedRobot = getConnectedRobot()) => {
if (!connectedRobot) return;
// 记录本次请求版本。若等待响应期间又发起了新请求,则丢弃本次旧结果。
const currentLoadVersion = ++mapLoadVersion;
const res = await getMapJson({
robotId: connectedRobot.id,
mapName: connectedRobot.robotMapName
});
// 防止较慢返回的旧地图覆盖用户刚切换的新地图。
if (currentLoadVersion !== mapLoadVersion) return;
if (res.code === 200) {
try {
const rawJsonObject = JSON.parse(res.data); // 解析 JSON
const parsedMap = parseSmapJson(rawJsonObject); // 转换为内部结构
await loadMapIntoApplication(parsedMap); // 加载到应用
} catch (parseError) {
ElMessage.error('Parse error: ' + parseError.message); // 显示错误信息
}
}
};
/**
* 将接口返回的 "x,y,angle" 字符串转换为数值坐标。
* 无位置或包含非法数值时返回 null避免无效机器人进入绘制列表。
*/
const parseRobotPosition = (currentPosition) => {
if (!currentPosition) return null
const values = String(currentPosition).split(',').map(Number)
if (values.length < 3 || values.some(value => !Number.isFinite(value))) return null
return values
}
/**
* 将最新机器人列表同步到 Canvas 使用的机器人实例中。
*
* 已存在的机器人会复用原 MobileRobot 实例,仅修改位置和名称;新增机器人
* 创建实例,接口中已移除或没有有效位置的机器人不会进入 nextRobots。
* 该函数不会加载地图或重置相机,因此可安全用于高频轮询。
*/
const syncRobots = (robotList) => {
// 用 ID 建立索引,避免为每台机器人遍历整个旧列表。
const previousRobots = new Map(applicationState.robots.map(robot => [robot.id, robot]));
const nextRobots = [];
robotList.forEach(item => {
const position = parseRobotPosition(item.currentPosition)
if (!position) return
const [x, y, angle] = position
const robot = previousRobots.get(item.id)
if (robot) {
// 保留原实例和颜色,只更新轮询产生的动态信息。
robot.name = item.robotName
robot.x = x
robot.y = y
robot.angle = angle
nextRobots.push(robot)
return
}
// 列表中首次出现的机器人需要创建绘制实例并分配颜色。
nextRobots.push(new MobileRobot(
item.id,
item.robotName,
x,
y,
angle,
colorList[nextRobots.length % colorList.length]
))
})
// 整体替换绘制列表,同时自然移除本轮接口中不存在的机器人。
applicationState.robots = nextRobots
}
onMounted(() => {
init()
window.addEventListener('resize', resizeCanvasToContainer);
resizeCanvasToContainer();
bindEvent()
syncRobots(props.robotList)
loadSourceMap()
animationFrameId = requestAnimationFrame(renderFrame);
})
const colorList = ['#00D4FF', '#FFB300', '#00FF88', '#ff5100', '#fff', '#15ff00', '#ff00d4', '#00d4ff']
// 轮询只同步机器人实例,不重新请求、解析或重置地图。
watch(() => props.robotList, syncRobots)
// 只有用于加载地图的机器人或地图名称改变时,才重新加载底图。
watch(
() => getMapIdentity(props.robotList),
(newIdentity, oldIdentity) => {
if (newIdentity && newIdentity !== oldIdentity) loadSourceMap()
}
)
defineExpose({
zoomCanvas,
centerCanvasView
})
onUnmounted(() => {
window.removeEventListener('resize', resizeCanvasToContainer)
// 停止渲染循环,并使仍在进行中的地图请求全部失效。
cancelAnimationFrame(animationFrameId)
mapLoadVersion += 1
canvasCtx = null
})
</script>
<style lang="scss" scoped>
.container {
width: 100%;
height: 100%;
position: relative;
#tooltip {
position: absolute;
display: none;
background: #0b1120;
border: 1px solid #2a3a5c;
padding: 8px 12px;
border-radius: 3px;
pointer-events: none;
z-index: 20;
font-size: 10px;
min-width: 180px;
max-width: 280px;
box-shadow: 0 4px 16px rgba(0, 0, 0, .5);
.tooltip-title {
font-weight: 700;
font-size: 11px;
margin-bottom: 5px;
}
:deep(.tooltip-detail) {
color: #94a3b8;
padding: 2px 0;
font-size: 9px;
word-break: break-all;
}
}
}
</style>