CMVR-IOT-UI/src/views/inspection/map/index.vue
2026-06-02 09:52:53 +08:00

579 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="app-container">
<div ref="topContainerRef">
<el-form :model="queryParams" :inline="true" v-show="showSearch" label-width="68px">
<el-form-item label="地图名称" prop="mapName">
<el-input v-model="queryParams.mapName" placeholder="请输入地图名称" clearable @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="地图描述" prop="mapDesc">
<el-input v-model="queryParams.mapDesc" placeholder="请输入地图描述" clearable @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item>
<el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button type="primary" plain icon="Plus" @click="handleAdd">新增</el-button>
</el-col>
<right-toolbar v-model:showSearch="showSearch" @queryTable="getList"></right-toolbar>
</el-row>
</div>
<div :style="containerHeight">
<el-table v-loading="loading" height="100%" style="width: 100%" :data="tableDataList">
<el-table-column label="地图名称" align="center" prop="mapName" />
<el-table-column label="地图描述" align="center" prop="mapDesc" min-width="160" show-overflow-tooltip />
<el-table-column label="创建人" align="center" prop="createBy" />
<el-table-column label="创建时间" align="center" prop="createTime" />
<el-table-column label="操作" class-name="small-padding fixed-width" width="200" fixed="right">
<template #default="scope">
<el-button link type="primary" icon="Edit" @click="handleUpdate(scope.row)">修改</el-button>
<el-button link type="primary" icon="Delete" @click="handleDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<pagination v-show="total > 0" :total="total" v-model:page="queryParams.pageNum"
v-model:limit="queryParams.pageSize" @pagination="getList" />
</div>
<!-- 添加或修改语料库对话框 -->
<el-dialog :title="title" v-model="open" width="580px" append-to-body>
<el-form ref="mapRef" :model="form" :rules="rules" label-width="120px">
<el-form-item label="地图名称" prop="mapName">
<el-input v-model="form.mapName" placeholder="请输入地图名称" />
</el-form-item>
<el-form-item label="地图描述" prop="mapDesc">
<el-input v-model="form.mapDesc" type="textarea" placeholder="请输入地图描述" />
</el-form-item>
<el-form-item label="选择机器人地图">
<el-col :span="11">
<el-form-item prop="mapSourceRobotId">
<el-select v-model="form.mapSourceRobotId" placeholder="请选择机器人" clearable style="width: 100%"
@change="robotChange">
<el-option v-for="robot in robotList" :key="robot.value" :label="robot.label" :value="robot.value" />
</el-select>
</el-form-item>
</el-col>
<el-col class="text-center" :span="2">
<span class="text-gray-500">-</span>
</el-col>
<el-col :span="11">
<el-form-item prop="mapSourceName">
<el-select v-model="form.mapSourceName" placeholder="请选择地图" clearable style="width: 100%"
@change="mapChange">
<el-option v-for="map in mapList" :key="map.value" :label="map.label" :value="map.value" />
</el-select>
</el-form-item>
</el-col>
</el-form-item>
<div class="map-container">
<canvas id="mapCanvas"></canvas>
</div>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button type="primary" @click="submitForm">确 定</el-button>
<el-button @click="cancel">取 消</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup name="Map">
import { useContainerHeight } from "@/hooks/tableHeight";
import { addMap, updateMap, deleteMap, getMapList } from "../../../api/inspection/map";
import { getRobotList } from "../../../api/inspection/robot";
import { onMounted } from "vue";
import {
worldToScreen,
screenToWorld,
drawGrid,
drawGridlines,
drawAdvancedAreaList,
drawAdvancedLineList,
drawNormalLines,
drawAdvancedCurveList,
drawAdvancedPointList,
drawMapBoundary,
buildOccupancyGridImage,
MobileRobot
} from '../cockpit/canvasUtils';
import { de } from "element-plus/es/locale/index.mjs";
import { ElMessage, ElMessageBox } from "element-plus";
const topContainerRef = ref();
const containerHeight = useContainerHeight(topContainerRef);
const mapRef = ref();
const isNew = ref(true);
const tableDataList = ref([]);
const open = ref(false);
const loading = ref(true);
const showSearch = ref(true);
const total = ref(0);
const title = ref("");
const data = reactive({
form: {
mapName: "",
mapDesc: "",
mapSourceRobotId: "",
mapSourceName: "",
},
queryParams: {
pageNum: 1,
pageSize: 10,
mapDesc: null,
mapName: null,
},
rules: {
mapName: [
{ required: true, message: "请输入地图名称", trigger: "change" },
],
mapDesc: [
{ required: true, message: "请输入地图描述", trigger: "blur" },
]
},
});
const { queryParams, form, rules } = toRefs(data);
/** 查询列表 */
function getList() {
loading.value = true;
getMapList(queryParams.value).then((response) => {
tableDataList.value = response.rows;
total.value = response.total;
loading.value = false;
}).catch(() => {
loading.value = false;
}).finally(() => {
loading.value = false;
});
}
// 取消按钮
function cancel() {
open.value = false;
reset();
if (animationId) {
cancelAnimationFrame(animationId);
animationId = null;
}
window.removeEventListener('resize', resizeCanvasToContainer)
canvasCtx = null
}
// 表单重置
function reset() {
mapRef.value.resetFields();
form.value = {
mapName: "",
mapDesc: "",
mapSourceRobotId: "",
mapSourceName: "",
}
}
/** 搜索按钮操作 */
function handleQuery() {
queryParams.value.pageNum = 1;
getList();
}
/** 重置按钮操作 */
function resetQuery() {
queryParams.value.mapName = null;
queryParams.value.mapDesc = null;
handleQuery();
}
/** 新增按钮操作 */
function handleAdd() {
open.value = true;
isNew.value = true;
title.value = "添加地图";
setTimeout(() => {
init();
}, 300);
}
/** 修改按钮操作 */
function handleUpdate(row) {
form.value = { ...row };
isNew.value = false;
open.value = true;
title.value = "修改地图";
setTimeout(() => {
init();
if (form.value.mapSourceName) {
mapChange()
}
}, 300);
}
/** 提交按钮 */
function submitForm() {
mapRef.value.validate((valid) => {
if (valid) {
if (isNew.value) {
// 调用新增接口
addMap(form.value).then(() => {
ElMessage.success("地图添加成功!");
getList();
reset();
open.value = false;
});
} else {
// 调用修改接口
updateMap(form.value).then(() => {
ElMessage.success("地图修改成功!");
getList();
reset();
open.value = false;
});
}
}
});
}
/** 删除按钮操作 */
function handleDelete(row) {
ElMessageBox.confirm('确定要删除该地图吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}).then(() => {
deleteMap(row.id).then(() => {
ElMessage.success("地图删除成功!");
getList();
});
}).catch(() => {
// 用户取消删除
});
}
const loadSourceMap = async (url) => {
const response = await fetch(url);
if (!response.ok) throw new Error(`加载失败:${response.status}`);
return response.text();
};
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;
}
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
}
};
let mapCanvas; // 主渲染画布 DOM 元素
let canvasCtx; // 2D 渲染上下文
const init = () => {
mapCanvas = document.getElementById('mapCanvas');
canvasCtx = mapCanvas.getContext('2d');
}
/**
* 根据容器尺寸和设备像素比调整 Canvas 大小
* 在窗口 resize 时自动调用
*/
function resizeCanvasToContainer() {
const containerRect = mapCanvas.parentElement.getBoundingClientRect(); // 获取容器实际尺寸
console.log('containerRect', containerRect)
devicePixelRatio = window.devicePixelRatio || 1; // 获取设备像素比
applicationState.canvasWidthPx = containerRect.width; // 更新逻辑宽度
applicationState.canvasHeightPx = containerRect.height; // 逻辑高度 = 容器高度 - 状态栏高度
mapCanvas.width = applicationState.canvasWidthPx * devicePixelRatio; // Canvas 物理像素宽
mapCanvas.height = applicationState.canvasHeightPx * devicePixelRatio; // Canvas 物理像素高
mapCanvas.style.width = applicationState.canvasWidthPx + 'px'; // CSS 显示宽度
mapCanvas.style.height = applicationState.canvasHeightPx + 'px'; // CSS 显示高度
}
async function loadMapIntoApplication(parsedMap) {
await new Promise(resolve => setTimeout(resolve, 30)); // 短暂等待让 UI 有机会刷新
// 保存地图数据到全局状态
applicationState.parsedMap = parsedMap;
// 构建栅格图像(带边缘高亮)
applicationState.gridOffscreen = buildOccupancyGridImage(parsedMap, applicationState.layerVisibility.edges);
// ── 设置相机初始位置(地图中心)──
applicationState.camera.centerX = (parsedMap.originX + parsedMap.maxX) / 2; // 水平居中
applicationState.camera.centerY = (parsedMap.originY + parsedMap.maxY) / 2; // 垂直居中
// 计算合适的缩放级别,使地图完整显示在画布内
const worldWidthMeters = parsedMap.maxX - parsedMap.originX; // 地图世界宽度
const worldHeightMeters = parsedMap.maxY - parsedMap.originY; // 地图世界高度
const fitScaleX = applicationState.canvasWidthPx / worldWidthMeters; // 水平方向恰好填满的缩放
const fitScaleY = applicationState.canvasHeightPx / worldHeightMeters; // 垂直方向恰好填满的缩放
applicationState.camera.pixelsPerMeter = Math.max(1, Math.min(fitScaleX, fitScaleY)); // 取较小值并留12%边距
// 标记加载完成并更新UI
applicationState.isMapLoaded = true;
}
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);
applicationState.mouseState.worldX = worldPos.x;
applicationState.mouseState.worldY = worldPos.y;
// 如果正在拖拽,平移相机
if (applicationState.mouseState.isDragging) {
applicationState.camera.centerX -= mouseEvent.movementX / applicationState.camera.pixelsPerMeter; // 水平平移
applicationState.camera.centerY += mouseEvent.movementY / applicationState.camera.pixelsPerMeter; // 垂直平移Y翻转
}
});
// ── 鼠标按下:开始拖拽 ──
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, 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
}
/**
* 加载 smap JSON 文件
*/
const loadUserFile = async () => {
try {
const filemapDesc = await loadSourceMap('/3.smap');
const rawJsonObject = JSON.parse(filemapDesc); // 解析 JSON
const parsedMap = parseSmapJson(rawJsonObject); // 转换为内部结构
await loadMapIntoApplication(parsedMap); // 加载到应用
} catch (parseError) {
alert('Parse error: ' + parseError.message); // 显示错误信息
}
}
// 声明一个变量保存 requestAnimationFrame 的 ID
let animationId = null;
function renderFrame(timestamp) {
// 更新动画时间
applicationState.animationTimeSeconds = (timestamp || 0) / 1000;
// 设置 Canvas 变换矩阵(考虑设备像素比)
canvasCtx.setTransform(applicationState.devicePixelRatio, 0, 0, applicationState.devicePixelRatio, 0, 0);
canvasCtx.clearRect(0, 0, applicationState.canvasWidthPx, applicationState.canvasHeightPx); // 清空画布
// 如果地图未加载,跳过绘制
if (!applicationState.isMapLoaded) {
animationId = 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基础直线青色 #00e5ff
if (layers.normalLines) {
drawNormalLines(canvasCtx, map, applicationState);
}
// 图层5: advancedLineList高级线如 ForbiddenLine
if (layers.advLines) {
drawAdvancedLineList(canvasCtx, map, applicationState);
}
// 图层6: advancedCurveList贝塞尔曲线路径酸绿色 #76ff03
if (layers.curves) {
drawAdvancedCurveList(canvasCtx, map, applicationState);
}
// 图层7: advancedPointList高级点标记菱形
if (layers.points) {
drawAdvancedPointList(canvasCtx, map, applicationState);
}
// 地图边界虚线框
drawMapBoundary(canvasCtx, map, applicationState);
// 请求下一帧渲染
animationId = requestAnimationFrame(renderFrame);
}
const robotList = ref([])
const mapList = ref([])
const getRobotArr = () => {
getRobotList({
pageNum: 1,
pageSize: 10000,
}).then((response) => {
robotList.value = response.rows.map(item => ({
label: item.robotName,
value: item.id
}));
});
}
const getMapArr = (robotId) => {
// TODO: 这里可以根据 robotId 来过滤地图列表,目前接口没有提供这个功能,所以暂时获取全部地图
mapList.value = [
{ label: "地图1", value: "map1" },
{ label: "地图2", value: "map2" },
{ label: "地图3", value: "map3" },
]
}
const robotChange = (value) => {
form.value.mapSourceRobotId = value; // 更新选中的机器人ID
form.value.mapSourceName = ""; // 清空地图选择
if (canvasCtx) {
canvasCtx.clearRect(0, 0, applicationState.canvasWidthPx, applicationState.canvasHeightPx); // 清空画布
}
getMapArr(value);
}
const mapChange = () => {
loadUserFile();
if (animationId) {
cancelAnimationFrame(animationId);
}
animationId = requestAnimationFrame(renderFrame);
window.addEventListener('resize', resizeCanvasToContainer); // 监听窗口大小变化
resizeCanvasToContainer();
setTimeout(() => {
bindEvent()
}, 100)
}
onMounted(() => {
getList();
getRobotArr();
});
</script>
<style lang="scss" scoped>
.map-container {
width: 100%;
height: 300px;
border: 1px solid #ccc;
}
</style>