inspection-host-computer/src/views/chassis/MapCanvas.vue
2026-08-05 15:11:31 +08:00

541 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 ref="containerRef" class="container">
<canvas ref="mapCanvasRef" id="map-canvas"></canvas>
<div id="tooltip">
<div class="tooltip-title"></div>
<div class="tooltip-detail"></div>
</div>
</div>
</template>
<script setup>
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import {
worldToScreen,
screenToWorld,
drawGrid,
drawGridlines,
drawAdvancedAreaList,
drawAdvancedLineList,
drawNormalLines,
drawAdvancedCurveList,
drawAdvancedPointList,
drawMapBoundary,
buildOccupancyGridImage,
MobileRobot
} from './canvasUtils';
import { getCurrentMap, getRobotState } from '@/api/agv.js'
import { ElMessage } from 'element-plus';
import { useRobotStore } from '@/stores/robot'
import { getThemeColor } from '@/utils/themeColors.js'
const props = defineProps({
mapName: {
type: String,
default: ''
},
isDragging: {
type: Boolean,
default: true
}
})
// const VITE_AGV_DEVICE_ID = import.meta.env.VITE_AGV_DEVICE_ID
const robotStore = useRobotStore()
/**
* 应用全局状态对象,集中管理所有可变数据
* - parsedMap: 解析后的地图数据结构
* - layerVisibility: 各图层的可见性开关
* - mouseState: 鼠标位置与拖拽状态
* - isMapLoaded: 地图是否已加载完成
*/
const applicationState = {
parsedMap: null, // 解析后的完整地图数据
gridOffscreen: null, // 栅格图像离屏Canvas
hoveredElement: null, // 当前悬停的元素 {kind, index}
animationTimeSeconds: 0, // 全局动画时间戳(秒),用于脉冲动画效果
camera: { // 相机参数
centerX: 0, // 相机中心的世界坐标 X
centerY: 0, // 相机中心的世界坐标 Y
pixelsPerMeter: 20, // 缩放:每米对应的屏幕像素数
},
canvasWidthPx: 0, // Canvas CSS 逻辑宽度(像素)
canvasHeightPx: 0, // Canvas CSS 逻辑高度(像素)
devicePixelRatio: 1, // 设备像素比高DPI屏幕 > 1
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 渲染上下文
const containerRef = ref(null)
const mapCanvasRef = ref(null)
let resizeObserver
let eventsBound = false
const init = () => {
mapCanvas = mapCanvasRef.value;
canvasCtx = mapCanvas.getContext('2d');
}
/**
* 根据容器尺寸和设备像素比调整 Canvas 大小
* 在窗口 resize 时自动调用
*/
function resizeCanvasToContainer() {
if (!mapCanvas || !containerRef.value) return;
const containerRect = containerRef.value.getBoundingClientRect(); // 获取容器实际尺寸
if (!containerRect.width || !containerRect.height) return;
applicationState.devicePixelRatio = window.devicePixelRatio || 1; // 获取设备像素比
applicationState.canvasWidthPx = containerRect.width; // 更新逻辑宽度
applicationState.canvasHeightPx = containerRect.height; // 逻辑高度 = 容器高度 - 状态栏高度
mapCanvas.width = applicationState.canvasWidthPx * applicationState.devicePixelRatio; // Canvas 物理像素宽
mapCanvas.height = applicationState.canvasHeightPx * applicationState.devicePixelRatio; // Canvas 物理像素高
mapCanvas.style.width = applicationState.canvasWidthPx + 'px'; // CSS 显示宽度
mapCanvas.style.height = applicationState.canvasHeightPx + 'px'; // CSS 显示高度
if (applicationState.parsedMap) {
fitMapToCanvas(applicationState.parsedMap)
}
}
function fitMapToCanvas(parsedMap) {
const worldWidthMeters = parsedMap.maxX - parsedMap.originX;
const worldHeightMeters = parsedMap.maxY - parsedMap.originY;
if (!worldWidthMeters || !worldHeightMeters || !applicationState.canvasWidthPx || !applicationState.canvasHeightPx) return;
applicationState.camera.centerX = (parsedMap.originX + parsedMap.maxX) / 2;
applicationState.camera.centerY = (parsedMap.originY + parsedMap.maxY) / 2;
const fitScaleX = applicationState.canvasWidthPx / worldWidthMeters;
const fitScaleY = applicationState.canvasHeightPx / worldHeightMeters;
// 等比例覆盖整个 Canvas比例不一致时超出 Canvas 的方向由容器裁切。
applicationState.camera.pixelsPerMeter = Math.max(1, Math.max(fitScaleX, fitScaleY));
}
/**
* 解析 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;
// 构建栅格图像(带边缘高亮)
applicationState.gridOffscreen = buildOccupancyGridImage(parsedMap, applicationState.layerVisibility.edges, applicationState);
// ── 设置相机初始位置(地图中心)──
fitMapToCanvas(parsedMap);
// 标记加载完成并更新UI
applicationState.isMapLoaded = true;
}
/**
* 主渲染函数,由 requestAnimationFrame 循环调用
* 按层顺序绘制:栅格 → 网格线 → 区域 → 基础线 → 高级线 → 曲线 → 点 → 边界
*
* @param {number} timestamp - requestAnimationFrame 提供的时间戳(毫秒)
*/
function renderFrame(timestamp) {
// 更新动画时间
applicationState.animationTimeSeconds = (timestamp || 0) / 1000;
// 设置 Canvas 变换矩阵(考虑设备像素比)
canvasCtx.setTransform(applicationState.devicePixelRatio, 0, 0, applicationState.devicePixelRatio, 0, 0);
canvasCtx.clearRect(0, 0, 10000, 10000); // 清空画布
// 如果地图未加载,跳过绘制
if (!applicationState.isMapLoaded) {
requestAnimationFrame(renderFrame); // 继续请求下一帧
return;
}
const map = applicationState.parsedMap; // 当前地图数据
const layers = applicationState.layerVisibility; // 图层可见性
// 图层1: 占据栅格底图
if (layers.grid && applicationState.gridOffscreen) {
drawGrid(canvasCtx, map, applicationState);
}
// 图层2: 网格辅助线每1米一条
if (layers.gridlines) {
drawGridlines(canvasCtx, applicationState)
}
// 图层3: advancedAreaList半透明多边形区域
if (layers.areas) {
drawAdvancedAreaList(canvasCtx, map, applicationState)
}
// 图层4: normalLineList基础青色直线
if (layers.normalLines) {
drawNormalLines(canvasCtx, map, applicationState);
}
// 图层5: advancedLineList高级线如 ForbiddenLine
if (layers.advLines) {
drawAdvancedLineList(canvasCtx, map, applicationState);
}
// 图层6: advancedCurveList酸绿色贝塞尔曲线路径
if (layers.curves) {
drawAdvancedCurveList(canvasCtx, map, applicationState);
}
// 图层7: advancedPointList高级点标记菱形
if (layers.points) {
drawAdvancedPointList(canvasCtx, map, applicationState);
}
if (layers.robots) {
drawAllRobots(canvasCtx)
}
// 地图边界虚线框
drawMapBoundary(canvasCtx, map, applicationState);
// 请求下一帧渲染
requestAnimationFrame(renderFrame);
}
const drawAllRobots = (ctx) => {
if (!applicationState.layerVisibility.robots) return;
for (const robot of applicationState.robots) {
const screenPos = worldToScreen(robot.x, robot.y, applicationState);
// 裁剪超出画布简易跳过
if (screenPos.x + 20 < 0 || screenPos.x - 20 > applicationState.canvasWidthPx || screenPos.y + 20 < 0 || screenPos.y - 20 > applicationState.canvasHeightPx) {
continue;
}
robot.draw(ctx, screenPos.x, screenPos.y);
}
}
const bindEvent = () => {
if (eventsBound) return
eventsBound = true
let activePointerId = null
let lastPointerX = 0
let lastPointerY = 0
// Pointer Events 同时支持鼠标、触控笔和 Pad 触摸。
mapCanvas.addEventListener('pointermove', (pointerEvent) => {
const canvasRect = mapCanvas.getBoundingClientRect(); // Canvas 在页面中的位置
applicationState.mouseState.screenX = pointerEvent.clientX - canvasRect.left;
applicationState.mouseState.screenY = pointerEvent.clientY - canvasRect.top;
// 反算世界坐标
const worldPos = screenToWorld(applicationState.mouseState.screenX, applicationState.mouseState.screenY, applicationState);
applicationState.mouseState.worldX = worldPos.x;
applicationState.mouseState.worldY = worldPos.y;
if (applicationState.mouseState.isDragging && pointerEvent.pointerId === activePointerId) {
pointerEvent.preventDefault()
const movementX = pointerEvent.clientX - lastPointerX
const movementY = pointerEvent.clientY - lastPointerY
applicationState.camera.centerX -= movementX / applicationState.camera.pixelsPerMeter;
applicationState.camera.centerY += movementY / applicationState.camera.pixelsPerMeter;
lastPointerX = pointerEvent.clientX
lastPointerY = pointerEvent.clientY
}
if (pointerEvent.pointerType === 'mouse') {
updateTooltipDisplay(hitTestRobots(worldPos.x, worldPos.y, applicationState));
}
});
mapCanvas.addEventListener('click', (mouseEvent) => {
const canvasRect = mapCanvas.getBoundingClientRect(); // Canvas 在页面中的位置
const screenX = mouseEvent.clientX - canvasRect.left; // 鼠标在 Canvas 上的 X
const screenY = mouseEvent.clientY - canvasRect.top; // 鼠标在 Canvas 上的 Y
const worldPos = screenToWorld(screenX, screenY, applicationState);
})
mapCanvas.addEventListener('pointerdown', (pointerEvent) => {
if (!props.isDragging) {
applicationState.mouseState.isDragging = false;
return
}
pointerEvent.preventDefault()
activePointerId = pointerEvent.pointerId
lastPointerX = pointerEvent.clientX
lastPointerY = pointerEvent.clientY
applicationState.mouseState.isDragging = true;
mapCanvas.setPointerCapture(pointerEvent.pointerId)
});
const endPointerDrag = (pointerEvent) => {
if (pointerEvent.pointerId !== activePointerId) return
applicationState.mouseState.isDragging = false;
if (mapCanvas.hasPointerCapture(pointerEvent.pointerId)) {
mapCanvas.releasePointerCapture(pointerEvent.pointerId)
}
activePointerId = null
}
mapCanvas.addEventListener('pointerup', endPointerDrag);
mapCanvas.addEventListener('pointercancel', endPointerDrag);
// ── 滚轮:缩放(向鼠标位置缩放)──
mapCanvas.addEventListener('wheel', (wheelEvent) => {
wheelEvent.preventDefault(); // 阻止页面滚动
const zoomFactor = wheelEvent.deltaY > 0 ? 0.88 : 1.12; // 向下滚动=缩小,向上滚动=放大
const mouseWorldBefore = screenToWorld(applicationState.mouseState.screenX, applicationState.mouseState.screenY, applicationState); // 缩放前鼠标世界坐标
applicationState.camera.pixelsPerMeter = Math.max(0.5, Math.min(2000, applicationState.camera.pixelsPerMeter * zoomFactor)); // 应用缩放并钳制范围
const mouseWorldAfter = screenToWorld(applicationState.mouseState.screenX, applicationState.mouseState.screenY, applicationState); // 缩放后鼠标世界坐标
// 调整相机中心,使鼠标指向的世界坐标保持不变
applicationState.camera.centerX += mouseWorldBefore.x - mouseWorldAfter.x;
applicationState.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';
applicationState.hoveredElement = null;
return;
}
applicationState.hoveredElement = hitResult;
let title = '', details = '';
if (hitResult.kind === 'robot') {
const robot = hitResult.robot;
title = `${robot.name}`;
details = `<div class="tooltip-detail">位置: (${robot.x.toFixed(3)}, ${robot.y.toFixed(3)})</div>
<div class="tooltip-detail">朝向: ${(robot.angle * 180 / Math.PI).toFixed(1)}°</div>
<div class="tooltip-detail">电量: ${(robotStore.battery.percentage* 100).toFixed(1)}%</div>
<div class="tooltip-detail">ID: ${robot.id}</div>`;
}
tooltipEl.innerHTML = `<div class="tooltip-title">${title}</div>${details}`;
tooltipEl.style.display = 'block';
tooltipEl.style.left = Math.min(applicationState.mouseState.screenX + 14, applicationState.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(applicationState.canvasWidthPx / 2, applicationState.canvasHeightPx / 2, applicationState);
applicationState.camera.pixelsPerMeter = Math.max(0.5, Math.min(2000, applicationState.camera.pixelsPerMeter * zoomFactor));
const worldAfter = screenToWorld(applicationState.canvasWidthPx / 2, applicationState.canvasHeightPx / 2, applicationState);
applicationState.camera.centerX += worldBefore.x - worldAfter.x;
applicationState.camera.centerY += worldBefore.y - worldAfter.y;
}
const centerCanvasView = () => {
const map = applicationState.parsedMap;
if (!map) return;
applicationState.camera.centerX = (map.originX + map.maxX) / 2;
applicationState.camera.centerY = (map.originY + map.maxY) / 2;
}
const loadSourceMap = async (mapName) => {
await nextTick()
if (!mapCanvas) init()
resizeCanvasToContainer()
const res = await getCurrentMap({
ip: robotStore.ip,
deviceId: robotStore.agvDeviceId,
mapName: mapName
})
if (res.code === 200) {
try {
const rawJsonObject = JSON.parse(res.data); // 解析 JSON
const parsedMap = parseSmapJson(rawJsonObject); // 转换为内部结构
await loadMapIntoApplication(parsedMap); // 加载到应用
setTimeout(() => {
bindEvent()
}, 300)
} catch (parseError) {
ElMessage.error('Parse error: ' + parseError.message); // 显示错误信息
}
}
};
/**
*
* @param id 机器人编号
* @param name 机器人名称
* @param angleRad 角度
* @param color 颜色
*/
const addRobot = (id, name,x, y, angleRad, color) => {
const robot = new MobileRobot(id, name, x, y, angleRad, color);
applicationState.robots.push(robot);
}
const moveRobot = (robot, dx, dy, dtheta = 0) => {
robot.x += dx;
robot.y += dy;
robot.angle += dtheta;
}
onMounted(() => {
init()
resizeCanvasToContainer()
resizeObserver = new ResizeObserver(resizeCanvasToContainer)
resizeObserver.observe(containerRef.value)
window.addEventListener('resize', resizeCanvasToContainer)
})
watch(() => props.mapName,
async (newVal) => {
if (newVal) {
await loadSourceMap(newVal)
addRobot(robotStore.ip, robotStore.ip, robotStore.position.x, robotStore.position.y, robotStore.position.theta, getThemeColor('--color-canvas-robot'))
requestAnimationFrame(renderFrame);
}
}, {
immediate: true
}
)
defineExpose({
zoomCanvas,
centerCanvasView
})
onUnmounted(() => {
window.removeEventListener('resize', resizeCanvasToContainer)
resizeObserver?.disconnect()
canvasCtx = null
})
</script>
<style lang="scss" scoped>
.container {
position: absolute;
inset: 0;
width: auto;
height: auto;
min-width: 0;
min-height: 0;
overflow: hidden;
box-sizing: border-box;
#map-canvas {
position: absolute;
inset: 0;
display: block;
width: 100% !important;
height: 100% !important;
touch-action: none;
user-select: none;
-webkit-user-select: none;
}
#tooltip {
position: absolute;
display: none;
background: $color-background-tooltip-translucent;
border: 1px solid $color-accent-border-header;
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 $color-shadow-tooltip;
.tooltip-title {
font-weight: 700;
font-size: 14px;
margin-bottom: 5px;
color: $color-canvas-tooltip-title;
}
:deep(.tooltip-detail) {
color: $color-text-tooltip;
padding: 2px 0;
font-size: 12px;
word-break: break-all;
}
}
}
</style>