2026-06-26 10:51:01 +08:00
|
|
|
|
<template>
|
2026-08-05 11:08:44 +08:00
|
|
|
|
<div ref="containerRef" class="container">
|
|
|
|
|
|
<canvas ref="mapCanvasRef" id="map-canvas"></canvas>
|
2026-06-26 10:51:01 +08:00
|
|
|
|
<div id="tooltip">
|
|
|
|
|
|
<div class="tooltip-title"></div>
|
|
|
|
|
|
<div class="tooltip-detail"></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup>
|
2026-08-05 11:08:44 +08:00
|
|
|
|
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
2026-06-26 10:51:01 +08:00
|
|
|
|
|
|
|
|
|
|
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'
|
2026-07-21 14:00:56 +08:00
|
|
|
|
import { getThemeColor } from '@/utils/themeColors.js'
|
2026-06-26 10:51:01 +08:00
|
|
|
|
|
|
|
|
|
|
const props = defineProps({
|
|
|
|
|
|
mapName: {
|
|
|
|
|
|
type: String,
|
|
|
|
|
|
default: ''
|
|
|
|
|
|
},
|
|
|
|
|
|
isDragging: {
|
|
|
|
|
|
type: Boolean,
|
|
|
|
|
|
default: true
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-07-06 10:32:35 +08:00
|
|
|
|
// const VITE_AGV_DEVICE_ID = import.meta.env.VITE_AGV_DEVICE_ID
|
2026-06-26 10:51:01 +08:00
|
|
|
|
|
|
|
|
|
|
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 渲染上下文
|
|
|
|
|
|
|
2026-08-05 11:08:44 +08:00
|
|
|
|
const containerRef = ref(null)
|
|
|
|
|
|
const mapCanvasRef = ref(null)
|
|
|
|
|
|
let resizeObserver
|
2026-08-05 13:53:23 +08:00
|
|
|
|
let eventsBound = false
|
2026-08-05 11:08:44 +08:00
|
|
|
|
|
2026-06-26 10:51:01 +08:00
|
|
|
|
const init = () => {
|
2026-08-05 11:08:44 +08:00
|
|
|
|
mapCanvas = mapCanvasRef.value;
|
2026-06-26 10:51:01 +08:00
|
|
|
|
canvasCtx = mapCanvas.getContext('2d');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 根据容器尺寸和设备像素比调整 Canvas 大小
|
|
|
|
|
|
* 在窗口 resize 时自动调用
|
|
|
|
|
|
*/
|
|
|
|
|
|
function resizeCanvasToContainer() {
|
2026-08-05 11:08:44 +08:00
|
|
|
|
if (!mapCanvas || !containerRef.value) return;
|
|
|
|
|
|
const containerRect = containerRef.value.getBoundingClientRect(); // 获取容器实际尺寸
|
|
|
|
|
|
if (!containerRect.width || !containerRect.height) return;
|
|
|
|
|
|
applicationState.devicePixelRatio = window.devicePixelRatio || 1; // 获取设备像素比
|
2026-06-26 10:51:01 +08:00
|
|
|
|
applicationState.canvasWidthPx = containerRect.width; // 更新逻辑宽度
|
|
|
|
|
|
applicationState.canvasHeightPx = containerRect.height; // 逻辑高度 = 容器高度 - 状态栏高度
|
2026-08-05 11:08:44 +08:00
|
|
|
|
mapCanvas.width = applicationState.canvasWidthPx * applicationState.devicePixelRatio; // Canvas 物理像素宽
|
|
|
|
|
|
mapCanvas.height = applicationState.canvasHeightPx * applicationState.devicePixelRatio; // Canvas 物理像素高
|
2026-06-26 10:51:01 +08:00
|
|
|
|
mapCanvas.style.width = applicationState.canvasWidthPx + 'px'; // CSS 显示宽度
|
|
|
|
|
|
mapCanvas.style.height = applicationState.canvasHeightPx + 'px'; // CSS 显示高度
|
2026-08-05 11:08:44 +08:00
|
|
|
|
|
|
|
|
|
|
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));
|
2026-06-26 10:51:01 +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;
|
|
|
|
|
|
// 构建栅格图像(带边缘高亮)
|
|
|
|
|
|
applicationState.gridOffscreen = buildOccupancyGridImage(parsedMap, applicationState.layerVisibility.edges, applicationState);
|
|
|
|
|
|
|
|
|
|
|
|
// ── 设置相机初始位置(地图中心)──
|
2026-08-05 11:08:44 +08:00
|
|
|
|
fitMapToCanvas(parsedMap);
|
2026-06-26 10:51:01 +08:00
|
|
|
|
|
|
|
|
|
|
// 标记加载完成并更新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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-21 14:00:56 +08:00
|
|
|
|
// 图层4: normalLineList(基础青色直线)
|
2026-06-26 10:51:01 +08:00
|
|
|
|
if (layers.normalLines) {
|
|
|
|
|
|
drawNormalLines(canvasCtx, map, applicationState);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 图层5: advancedLineList(高级线,如 ForbiddenLine)
|
|
|
|
|
|
if (layers.advLines) {
|
|
|
|
|
|
drawAdvancedLineList(canvasCtx, map, applicationState);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-21 14:00:56 +08:00
|
|
|
|
// 图层6: advancedCurveList(酸绿色贝塞尔曲线路径)
|
2026-06-26 10:51:01 +08:00
|
|
|
|
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 = () => {
|
2026-08-05 13:53:23 +08:00
|
|
|
|
if (eventsBound) return
|
|
|
|
|
|
eventsBound = true
|
|
|
|
|
|
|
|
|
|
|
|
let activePointerId = null
|
|
|
|
|
|
let lastPointerX = 0
|
|
|
|
|
|
let lastPointerY = 0
|
|
|
|
|
|
|
|
|
|
|
|
// Pointer Events 同时支持鼠标、触控笔和 Pad 触摸。
|
|
|
|
|
|
mapCanvas.addEventListener('pointermove', (pointerEvent) => {
|
2026-06-26 10:51:01 +08:00
|
|
|
|
const canvasRect = mapCanvas.getBoundingClientRect(); // Canvas 在页面中的位置
|
2026-08-05 13:53:23 +08:00
|
|
|
|
applicationState.mouseState.screenX = pointerEvent.clientX - canvasRect.left;
|
|
|
|
|
|
applicationState.mouseState.screenY = pointerEvent.clientY - canvasRect.top;
|
2026-06-26 10:51:01 +08:00
|
|
|
|
// 反算世界坐标
|
|
|
|
|
|
const worldPos = screenToWorld(applicationState.mouseState.screenX, applicationState.mouseState.screenY, applicationState);
|
|
|
|
|
|
applicationState.mouseState.worldX = worldPos.x;
|
|
|
|
|
|
applicationState.mouseState.worldY = worldPos.y;
|
|
|
|
|
|
|
2026-08-05 13:53:23 +08:00
|
|
|
|
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
|
2026-06-26 10:51:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-05 13:53:23 +08:00
|
|
|
|
if (pointerEvent.pointerType === 'mouse') {
|
|
|
|
|
|
updateTooltipDisplay(hitTestRobots(worldPos.x, worldPos.y, applicationState));
|
|
|
|
|
|
}
|
2026-06-26 10:51:01 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-08-05 13:53:23 +08:00
|
|
|
|
mapCanvas.addEventListener('pointerdown', (pointerEvent) => {
|
2026-06-26 10:51:01 +08:00
|
|
|
|
if (!props.isDragging) {
|
|
|
|
|
|
applicationState.mouseState.isDragging = false;
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-08-05 13:53:23 +08:00
|
|
|
|
pointerEvent.preventDefault()
|
|
|
|
|
|
activePointerId = pointerEvent.pointerId
|
|
|
|
|
|
lastPointerX = pointerEvent.clientX
|
|
|
|
|
|
lastPointerY = pointerEvent.clientY
|
|
|
|
|
|
applicationState.mouseState.isDragging = true;
|
|
|
|
|
|
mapCanvas.setPointerCapture(pointerEvent.pointerId)
|
2026-06-26 10:51:01 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-05 13:53:23 +08:00
|
|
|
|
const endPointerDrag = (pointerEvent) => {
|
|
|
|
|
|
if (pointerEvent.pointerId !== activePointerId) return
|
2026-06-26 10:51:01 +08:00
|
|
|
|
applicationState.mouseState.isDragging = false;
|
2026-08-05 13:53:23 +08:00
|
|
|
|
if (mapCanvas.hasPointerCapture(pointerEvent.pointerId)) {
|
|
|
|
|
|
mapCanvas.releasePointerCapture(pointerEvent.pointerId)
|
|
|
|
|
|
}
|
|
|
|
|
|
activePointerId = null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
mapCanvas.addEventListener('pointerup', endPointerDrag);
|
|
|
|
|
|
mapCanvas.addEventListener('pointercancel', endPointerDrag);
|
2026-06-26 10:51:01 +08:00
|
|
|
|
|
|
|
|
|
|
// ── 滚轮:缩放(向鼠标位置缩放)──
|
|
|
|
|
|
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>
|
2026-08-05 15:11:31 +08:00
|
|
|
|
<div class="tooltip-detail">电量: ${(robotStore.battery.percentage* 100).toFixed(1)}%</div>
|
2026-06-26 10:51:01 +08:00
|
|
|
|
<div class="tooltip-detail">ID: ${robot.id}</div>`;
|
|
|
|
|
|
}
|
2026-07-21 14:00:56 +08:00
|
|
|
|
tooltipEl.innerHTML = `<div class="tooltip-title">${title}</div>${details}`;
|
2026-06-26 10:51:01 +08:00
|
|
|
|
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) => {
|
2026-08-05 11:08:44 +08:00
|
|
|
|
await nextTick()
|
|
|
|
|
|
if (!mapCanvas) init()
|
|
|
|
|
|
resizeCanvasToContainer()
|
|
|
|
|
|
|
2026-06-26 10:51:01 +08:00
|
|
|
|
const res = await getCurrentMap({
|
|
|
|
|
|
ip: robotStore.ip,
|
2026-07-06 10:32:35 +08:00
|
|
|
|
deviceId: robotStore.agvDeviceId,
|
2026-06-26 10:51:01 +08:00
|
|
|
|
mapName: mapName
|
|
|
|
|
|
})
|
|
|
|
|
|
if (res.code === 200) {
|
|
|
|
|
|
try {
|
2026-07-10 13:44:07 +08:00
|
|
|
|
const rawJsonObject = JSON.parse(res.data); // 解析 JSON
|
2026-06-26 10:51:01 +08:00
|
|
|
|
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()
|
2026-08-05 11:08:44 +08:00
|
|
|
|
resizeCanvasToContainer()
|
|
|
|
|
|
resizeObserver = new ResizeObserver(resizeCanvasToContainer)
|
|
|
|
|
|
resizeObserver.observe(containerRef.value)
|
|
|
|
|
|
window.addEventListener('resize', resizeCanvasToContainer)
|
2026-06-26 10:51:01 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
watch(() => props.mapName,
|
2026-07-10 13:44:07 +08:00
|
|
|
|
async (newVal) => {
|
|
|
|
|
|
if (newVal) {
|
|
|
|
|
|
await loadSourceMap(newVal)
|
2026-07-21 14:00:56 +08:00
|
|
|
|
addRobot(robotStore.ip, robotStore.ip, robotStore.position.x, robotStore.position.y, robotStore.position.theta, getThemeColor('--color-canvas-robot'))
|
2026-06-26 10:51:01 +08:00
|
|
|
|
requestAnimationFrame(renderFrame);
|
|
|
|
|
|
}
|
2026-07-10 13:44:07 +08:00
|
|
|
|
}, {
|
|
|
|
|
|
immediate: true
|
2026-06-26 10:51:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
defineExpose({
|
|
|
|
|
|
zoomCanvas,
|
|
|
|
|
|
centerCanvasView
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
onUnmounted(() => {
|
|
|
|
|
|
window.removeEventListener('resize', resizeCanvasToContainer)
|
2026-08-05 11:08:44 +08:00
|
|
|
|
resizeObserver?.disconnect()
|
2026-06-26 10:51:01 +08:00
|
|
|
|
canvasCtx = null
|
|
|
|
|
|
})
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style lang="scss" scoped>
|
|
|
|
|
|
.container {
|
2026-08-05 11:08:44 +08:00
|
|
|
|
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;
|
2026-08-05 13:53:23 +08:00
|
|
|
|
touch-action: none;
|
|
|
|
|
|
user-select: none;
|
|
|
|
|
|
-webkit-user-select: none;
|
2026-08-05 11:08:44 +08:00
|
|
|
|
}
|
2026-06-26 10:51:01 +08:00
|
|
|
|
|
|
|
|
|
|
#tooltip {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
display: none;
|
2026-07-21 14:00:56 +08:00
|
|
|
|
background: $color-background-tooltip-translucent;
|
|
|
|
|
|
border: 1px solid $color-accent-border-header;
|
2026-06-26 10:51:01 +08:00
|
|
|
|
padding: 8px 12px;
|
|
|
|
|
|
border-radius: 3px;
|
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
|
z-index: 20;
|
|
|
|
|
|
font-size: 10px;
|
|
|
|
|
|
min-width: 180px;
|
|
|
|
|
|
max-width: 280px;
|
2026-07-21 14:00:56 +08:00
|
|
|
|
box-shadow: 0 4px 16px $color-shadow-tooltip;
|
2026-06-26 10:51:01 +08:00
|
|
|
|
|
|
|
|
|
|
.tooltip-title {
|
|
|
|
|
|
font-weight: 700;
|
2026-08-05 15:11:31 +08:00
|
|
|
|
font-size: 14px;
|
2026-06-26 10:51:01 +08:00
|
|
|
|
margin-bottom: 5px;
|
2026-07-21 14:00:56 +08:00
|
|
|
|
color: $color-canvas-tooltip-title;
|
2026-06-26 10:51:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
:deep(.tooltip-detail) {
|
2026-07-21 14:00:56 +08:00
|
|
|
|
color: $color-text-tooltip;
|
2026-06-26 10:51:01 +08:00
|
|
|
|
padding: 2px 0;
|
2026-08-05 15:11:31 +08:00
|
|
|
|
font-size: 12px;
|
2026-06-26 10:51:01 +08:00
|
|
|
|
word-break: break-all;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-21 14:00:56 +08:00
|
|
|
|
</style>
|