CMVR-IOT-UI/src/views/inspection/cockpit/components/MapCanvas.vue

437 lines
18 KiB
Vue
Raw Normal View History

2026-05-25 09:10:10 +08:00
<template>
<div class="container">
<canvas id="map-canvas"></canvas>
2026-05-29 13:54:45 +08:00
<div id="tooltip">
<div class="tooltip-title"></div>
<div class="tooltip-detail"></div>
</div>
2026-05-25 09:10:10 +08:00
</div>
</template>
<script setup>
import { onMounted, onUnmounted } from 'vue';
2026-05-26 14:45:49 +08:00
import {
worldToScreen,
screenToWorld,
drawGrid,
drawGridlines,
drawAdvancedAreaList,
drawAdvancedLineList,
drawNormalLines,
drawAdvancedCurveList,
drawAdvancedPointList,
drawMapBoundary,
buildOccupancyGridImage,
MobileRobot
} from '../canvasUtils';
import { useInspectionStore } from "@/store/modules/inspection";
const inspectionStore = useInspectionStore();
2026-05-25 09:10:10 +08:00
/**
* 应用全局状态对象集中管理所有可变数据
* - parsedMap: 解析后的地图数据结构
* - layerVisibility: 各图层的可见性开关
* - mouseState: 鼠标位置与拖拽状态
* - isMapLoaded: 地图是否已加载完成
*/
const applicationState = {
parsedMap: null, // 解析后的完整地图数据
2026-05-26 14:45:49 +08:00
mouseState: { // 鼠标交互状态
screenX: 0, // 鼠标在 Canvas 上的像素 X
screenY: 0, // 鼠标在 Canvas 上的像素 Y
worldX: 0, // 鼠标对应的世界坐标 X
worldY: 0, // 鼠标对应的世界坐标 Y
isDragging: false, // 是否正在拖拽平移
2026-05-25 09:10:10 +08:00
},
2026-05-26 14:45:49 +08:00
isMapLoaded: false, // 地图加载完成标志,
2026-05-25 09:10:10 +08:00
layerVisibility: { // 各图层是否可见
grid: true, // 占据栅格底图
edges: true, // 墙壁边缘发光
gridlines: true, // 1米网格辅助线
normalLines: true, // normalLineList
advLines: true, // advancedLineList
curves: true, // advancedCurveList
points: true, // advancedPointList
areas: true, // advancedAreaList
2026-05-26 14:45:49 +08:00
robots: true, // 机器人位置和朝向
2026-05-25 09:10:10 +08:00
},
2026-05-26 14:45:49 +08:00
robots: [] // 存储机器人实例
2026-05-25 09:10:10 +08:00
};
let mapCanvas; // 主渲染画布 DOM 元素
let canvasCtx; // 2D 渲染上下文
const init = () => {
mapCanvas = document.getElementById('map-canvas');
canvasCtx = mapCanvas.getContext('2d');
}
/**
* 根据容器尺寸和设备像素比调整 Canvas 大小
* 在窗口 resize 时自动调用
*/
function resizeCanvasToContainer() {
const containerRect = mapCanvas.parentElement.getBoundingClientRect(); // 获取容器实际尺寸
devicePixelRatio = window.devicePixelRatio || 1; // 获取设备像素比
2026-05-26 14:45:49 +08:00
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 显示高度
2026-05-25 09:10:10 +08:00
}
/**
* 解析 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;
// 构建栅格图像(带边缘高亮)
2026-05-26 14:45:49 +08:00
inspectionStore.gridOffscreen = buildOccupancyGridImage(parsedMap, applicationState.layerVisibility.edges);
2026-05-25 09:10:10 +08:00
// ── 设置相机初始位置(地图中心)──
2026-05-26 14:45:49 +08:00
inspectionStore.camera.centerX = (parsedMap.originX + parsedMap.maxX) / 2; // 水平居中
inspectionStore.camera.centerY = (parsedMap.originY + parsedMap.maxY) / 2; // 垂直居中
2026-05-25 09:10:10 +08:00
// 计算合适的缩放级别,使地图完整显示在画布内
const worldWidthMeters = parsedMap.maxX - parsedMap.originX; // 地图世界宽度
const worldHeightMeters = parsedMap.maxY - parsedMap.originY; // 地图世界高度
2026-05-26 14:45:49 +08:00
const fitScaleX = inspectionStore.canvasWidthPx / worldWidthMeters; // 水平方向恰好填满的缩放
const fitScaleY = inspectionStore.canvasHeightPx / worldHeightMeters; // 垂直方向恰好填满的缩放
inspectionStore.camera.pixelsPerMeter = Math.max(1, Math.min(fitScaleX, fitScaleY) * 0.88); // 取较小值并留12%边距
2026-05-25 09:10:10 +08:00
// 标记加载完成并更新UI
applicationState.isMapLoaded = true;
}
/**
* 主渲染函数 requestAnimationFrame 循环调用
* 按层顺序绘制栅格 网格线 区域 基础线 高级线 曲线 边界
*
* @param {number} timestamp - requestAnimationFrame 提供的时间戳毫秒
*/
function renderFrame(timestamp) {
// 更新动画时间
2026-05-26 14:45:49 +08:00
inspectionStore.animationTimeSeconds = (timestamp || 0) / 1000;
2026-05-25 09:10:10 +08:00
// 设置 Canvas 变换矩阵(考虑设备像素比)
2026-05-26 14:45:49 +08:00
canvasCtx.setTransform(inspectionStore.devicePixelRatio, 0, 0, inspectionStore.devicePixelRatio, 0, 0);
canvasCtx.clearRect(0, 0, inspectionStore.canvasWidthPx, inspectionStore.canvasHeightPx); // 清空画布
2026-05-25 09:10:10 +08:00
// 如果地图未加载,跳过绘制
if (!applicationState.isMapLoaded) {
requestAnimationFrame(renderFrame); // 继续请求下一帧
return;
}
const map = applicationState.parsedMap; // 当前地图数据
const layers = applicationState.layerVisibility; // 图层可见性
// 图层1: 占据栅格底图
2026-05-26 14:45:49 +08:00
if (layers.grid && inspectionStore.gridOffscreen) {
drawGrid(canvasCtx, map);
2026-05-25 09:10:10 +08:00
}
// 图层2: 网格辅助线每1米一条
if (layers.gridlines) {
2026-05-26 14:45:49 +08:00
drawGridlines(canvasCtx)
2026-05-25 09:10:10 +08:00
}
// 图层3: advancedAreaList半透明多边形区域
if (layers.areas) {
2026-05-26 14:45:49 +08:00
drawAdvancedAreaList(canvasCtx, map)
2026-05-25 09:10:10 +08:00
}
// 图层4: normalLineList基础直线青色 #00e5ff
if (layers.normalLines) {
2026-05-26 14:45:49 +08:00
drawNormalLines(canvasCtx, map);
2026-05-25 09:10:10 +08:00
}
// 图层5: advancedLineList高级线如 ForbiddenLine
if (layers.advLines) {
2026-05-26 14:45:49 +08:00
drawAdvancedLineList(canvasCtx, map);
2026-05-25 09:10:10 +08:00
}
// 图层6: advancedCurveList贝塞尔曲线路径酸绿色 #76ff03
if (layers.curves) {
2026-05-26 14:45:49 +08:00
drawAdvancedCurveList(canvasCtx, map);
2026-05-25 09:10:10 +08:00
}
// 图层7: advancedPointList高级点标记菱形
if (layers.points) {
2026-05-26 14:45:49 +08:00
drawAdvancedPointList(canvasCtx, map);
}
if (layers.robots) {
drawAllRobots(canvasCtx)
2026-05-25 09:10:10 +08:00
}
// 地图边界虚线框
2026-05-26 14:45:49 +08:00
drawMapBoundary(canvasCtx, map);
2026-05-25 09:10:10 +08:00
// 请求下一帧渲染
requestAnimationFrame(renderFrame);
}
2026-05-26 14:45:49 +08:00
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);
}
}
2026-05-25 09:10:10 +08:00
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) {
2026-05-26 14:45:49 +08:00
inspectionStore.camera.centerX -= mouseEvent.movementX / inspectionStore.camera.pixelsPerMeter; // 水平平移
inspectionStore.camera.centerY += mouseEvent.movementY / inspectionStore.camera.pixelsPerMeter; // 垂直平移Y翻转
2026-05-25 09:10:10 +08:00
}
2026-05-29 13:54:45 +08:00
let hit = hitTestRobots(worldPos.x, worldPos.y);
updateTooltipDisplay(hit);
2026-05-25 09:10:10 +08:00
});
// ── 鼠标按下:开始拖拽 ──
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); // 缩放前鼠标世界坐标
2026-05-26 14:45:49 +08:00
inspectionStore.camera.pixelsPerMeter = Math.max(0.5, Math.min(2000, inspectionStore.camera.pixelsPerMeter * zoomFactor)); // 应用缩放并钳制范围
2026-05-25 09:10:10 +08:00
const mouseWorldAfter = screenToWorld(applicationState.mouseState.screenX, applicationState.mouseState.screenY); // 缩放后鼠标世界坐标
// 调整相机中心,使鼠标指向的世界坐标保持不变
2026-05-26 14:45:49 +08:00
inspectionStore.camera.centerX += mouseWorldBefore.x - mouseWorldAfter.x;
inspectionStore.camera.centerY += mouseWorldBefore.y - mouseWorldAfter.y;
2026-05-25 09:10:10 +08:00
}, { passive: false }); // passive:false 允许 preventDefault
}
2026-05-29 13:54:45 +08:00
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.toFixed(3)}, ${robot.y.toFixed(3)})</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;
}
2026-05-25 09:10:10 +08:00
const loadSourceMap = async (url) => {
const response = await fetch(url);
if (!response.ok) throw new Error(`加载失败:${response.status}`);
return response.text();
};
/**
* 加载 smap JSON 文件
*/
2026-05-29 13:54:45 +08:00
const loadUserFile = async () => {
2026-05-25 09:10:10 +08:00
try {
2026-05-26 14:45:49 +08:00
const fileTextContent = await loadSourceMap('/3.smap');
2026-05-25 09:10:10 +08:00
const rawJsonObject = JSON.parse(fileTextContent); // 解析 JSON
const parsedMap = parseSmapJson(rawJsonObject); // 转换为内部结构
await loadMapIntoApplication(parsedMap); // 加载到应用
} catch (parseError) {
alert('Parse error: ' + parseError.message); // 显示错误信息
}
}
2026-05-29 13:54:45 +08:00
const addRobot = (id, name, angleRad, color) => {
2026-05-26 14:45:49 +08:00
const map = applicationState.parsedMap;
// 放置于地图中央附近
const centerX = (map.originX + map.maxX) / 2;
const centerY = (map.originY + map.maxY) / 2;
2026-05-29 13:54:45 +08:00
const robot = new MobileRobot(id, name, centerX + (Math.random() - 0.5) * 10, centerY + (Math.random() - 0.5) * 10, angleRad, color);
2026-05-26 14:45:49 +08:00
applicationState.robots.push(robot);
}
const moveRobot = (robot, dx, dy, dtheta = 0) => {
robot.x += dx;
robot.y += dy;
robot.angle += dtheta;
}
2026-05-25 09:10:10 +08:00
onMounted(() => {
init()
// 启动渲染循环
loadUserFile()
requestAnimationFrame(renderFrame);
window.addEventListener('resize', resizeCanvasToContainer); // 监听窗口大小变化
resizeCanvasToContainer(); // 初始调用一次
setTimeout(() => {
bindEvent()
2026-05-29 13:54:45 +08:00
addRobot(1, '巡检机器人#1', 0, '#00D4FF')
addRobot(2, '巡检机器人#2', 0, '#FFB300')
addRobot(3, '巡检机器人#3', 0, '#00FF88')
}, 300)
})
defineExpose({
zoomCanvas,
centerCanvasView
2026-05-25 09:10:10 +08:00
})
onUnmounted(() => {
window.removeEventListener('resize', resizeCanvasToContainer)
canvasCtx = null
})
</script>
<style lang="scss" scoped>
.container {
width: 100%;
height: 100%;
2026-05-29 13:54:45 +08:00
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;
}
}
2026-05-25 09:10:10 +08:00
}
</style>