CMVR-IOT-UI/src/views/inspection/cockpit/components/MapCanvas.vue
2026-05-25 09:10:10 +08:00

811 lines
47 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>
</template>
<script setup>
import { onMounted, onUnmounted } from 'vue';
// ───────────────────────────────────────────────
// 1. 常量:颜色定义
// ───────────────────────────────────────────────
/**
* 0xAARRGGBB 整数颜色解码为 {r, g, b, a} 对象
* @param {number} argbInt - 32位 ARGB 整数,如 0xCCFF0040
* @returns {{r:number, g:number, b:number, a:number}|null} RGBA 分量 (0~255),无效输入返回 null
*/
function decodeArgbInteger(argbInt) {
if (typeof argbInt !== 'number') return null; // 非数字类型直接返回
return {
a: (argbInt >>> 24) & 0xFF, // 高8位Alpha 透明度
r: (argbInt >>> 16) & 0xFF, // 次高8位Red
g: (argbInt >>> 8) & 0xFF, // 次低8位Green
b: argbInt & 0xFF, // 低8位Blue
};
}
/**
* 将 decodeArgbInteger 的结果转为 CSS rgba() 字符串
* @param {{r,g,b,a}} colorObj - RGBA 分量对象
* @param {number} [alphaOverride] - 可选,覆盖 alpha 值0~1
* @returns {string|null} CSS rgba 字符串,如 "rgba(255,0,64,0.3)"
*/
function toCssRgba(colorObj, alphaOverride) {
if (!colorObj) return null; // 空对象返回 null
const alpha = alphaOverride !== undefined // 如果提供了覆盖值
? alphaOverride // 使用覆盖值
: colorObj.a / 255; // 否则将 0~255 映射到 0~1
return `rgba(${colorObj.r},${colorObj.g},${colorObj.b},${alpha})`;
}
/**
* 6位十六进制颜色字符串转为 CSS rgba 字符串
* @param {string} hex - 如 "#ff6e40"
* @param {number} alpha - 透明度 0~1
* @returns {string} CSS rgba 字符串
*/
function hexToCssRgba(hexString, alpha) {
const r = parseInt(hexString.slice(1, 3), 16); // 提取 R 分量
const g = parseInt(hexString.slice(3, 5), 16); // 提取 G 分量
const b = parseInt(hexString.slice(5, 7), 16); // 提取 B 分量
return `rgba(${r},${g},${b},${alpha})`;
}
// ───────────────────────────────────────────────
// 2. 应用全局状态
// ───────────────────────────────────────────────
/**
* 应用全局状态对象,集中管理所有可变数据
* - parsedMap: 解析后的地图数据结构
* - gridOffscreen: 离屏 Canvas存储绘制好的占据栅格图像
* - camera: 相机状态(中心世界坐标 + 缩放级别)
* - layerVisibility: 各图层的可见性开关
* - mouseState: 鼠标位置与拖拽状态
* - hoveredElement: 当前鼠标悬停命中的元素
* - isMapLoaded: 地图是否已加载完成
*/
const applicationState = {
parsedMap: null, // 解析后的完整地图数据
gridOffscreen: null, // 栅格图像离屏Canvas
camera: { // 相机参数
centerX: 0, // 相机中心的世界坐标 X
centerY: 0, // 相机中心的世界坐标 Y
pixelsPerMeter: 40, // 缩放:每米对应的屏幕像素数
},
layerVisibility: { // 各图层是否可见
grid: true, // 占据栅格底图
edges: true, // 墙壁边缘发光
gridlines: true, // 1米网格辅助线
normalLines: true, // normalLineList
advLines: true, // advancedLineList
curves: true, // advancedCurveList
points: true, // advancedPointList
areas: true, // advancedAreaList
},
mouseState: { // 鼠标交互状态
screenX: 0, // 鼠标在 Canvas 上的像素 X
screenY: 0, // 鼠标在 Canvas 上的像素 Y
worldX: 0, // 鼠标对应的世界坐标 X
worldY: 0, // 鼠标对应的世界坐标 Y
isDragging: false, // 是否正在拖拽平移
},
hoveredElement: null, // 当前悬停的元素 {kind, index}
isMapLoaded: false, // 地图加载完成标志
};
let mapCanvas; // 主渲染画布 DOM 元素
let canvasCtx; // 2D 渲染上下文
let canvasWidthPx = 0; // Canvas CSS 逻辑宽度(像素)
let canvasHeightPx = 0; // Canvas CSS 逻辑高度(像素)
let devicePixelRatio = 1; // 设备像素比高DPI屏幕 > 1
const init = () => {
mapCanvas = document.getElementById('map-canvas');
canvasCtx = mapCanvas.getContext('2d');
}
/**
* 根据容器尺寸和设备像素比调整 Canvas 大小
* 在窗口 resize 时自动调用
*/
function resizeCanvasToContainer() {
const containerRect = mapCanvas.parentElement.getBoundingClientRect(); // 获取容器实际尺寸
devicePixelRatio = window.devicePixelRatio || 1; // 获取设备像素比
canvasWidthPx = containerRect.width; // 更新逻辑宽度
canvasHeightPx = containerRect.height; // 逻辑高度 = 容器高度 - 状态栏高度
mapCanvas.width = canvasWidthPx * devicePixelRatio; // Canvas 物理像素宽
mapCanvas.height = canvasHeightPx * devicePixelRatio; // Canvas 物理像素高
mapCanvas.style.width = canvasWidthPx + 'px'; // CSS 显示宽度
mapCanvas.style.height = canvasHeightPx + 'px'; // CSS 显示高度
}
/**
* 将世界坐标转换为 Canvas 屏幕像素坐标
* 注意:世界坐标 Y 轴向上Canvas Y 轴向下,因此需要翻转
* @param {number} worldX - 世界坐标 X
* @param {number} worldY - 世界坐标 Y
* @returns {{x: number, y: number}} Canvas 上的屏幕像素坐标
*/
function worldToScreen(worldX, worldY) {
const camera = applicationState.camera;
const screenX = (worldX - camera.centerX) * camera.pixelsPerMeter + canvasWidthPx / 2; // 水平偏移 + 居中
const screenY = -(worldY - camera.centerY) * camera.pixelsPerMeter + canvasHeightPx / 2; // 垂直翻转 + 居中
return { x: screenX, y: screenY };
}
/**
* 将 Canvas 屏幕像素坐标反算为世界坐标
* 与 worldToScreen 互为逆运算
* @param {number} screenX - Canvas 像素 X
* @param {number} screenY - Canvas 像素 Y
* @returns {{x: number, y: number}} 世界坐标(米)
*/
function screenToWorld(screenX, screenY) {
const camera = applicationState.camera;
const worldX = (screenX - canvasWidthPx / 2) / camera.pixelsPerMeter + camera.centerX; // 水平反算
const worldY = -(screenY - canvasHeightPx / 2) / camera.pixelsPerMeter + camera.centerY; // 垂直反算(带翻转)
return { x: worldX, y: worldY };
}
/**
* 解析 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;
}
// ═══════════════════════════════════════════════════════════════════════
// 7. 栅格图像构建器
// 从 normalPosList 反向构建占据栅格并渲染为 Canvas 图像
// ═══════════════════════════════════════════════════════════════════════
/**
* 从 normalPosList 构建占据栅格图像
*
* 原理normalPosList 中每个点标记一个自由通行格子。
* 未出现在列表中的格子 = 障碍物(墙壁)
* 墙壁格子如果紧邻自由格子,则标记为"墙壁边缘",渲染高亮
*
* @param {Object} parsedMap - parseSmapJson 的输出
* @param {boolean} showEdgeGlow - 是否计算墙壁边缘高亮效果
* @returns {HTMLCanvasElement} 离屏 Canvas 元素,包含渲染好的栅格图像
*/
function buildOccupancyGridImage(parsedMap, showEdgeGlow) {
const gridW = parsedMap.gridWidth; // 栅格宽度(像素)
const gridH = parsedMap.gridHeight; // 栅格高度(像素)
const originX = parsedMap.originX; // 世界坐标原点 X
const originY = parsedMap.originY; // 世界坐标原点 Y
const resolution = parsedMap.resolution; // 分辨率
// ── 第一步:遍历 normalPosList标记自由空间 ──
const occupancyGrid = new Uint8Array(gridW * gridH); // 0=墙壁, 1=自由空间
for (const freeSpacePoint of parsedMap.normalPositions) {
// 将世界坐标转换为栅格像素坐标
const gridColX = Math.floor((freeSpacePoint.x - originX) / resolution); // 列号(从左到右)
const pixelRowFromBottom = Math.floor((freeSpacePoint.y - originY) / resolution); // 行号(从下到上,世界坐标系)
const imageRowY = gridH - 1 - pixelRowFromBottom; // 翻转为图像行号(从上到下)
// 安全边界检查后标记为自由空间
if (gridColX >= 0 && gridColX < gridW && imageRowY >= 0 && imageRowY < gridH) {
occupancyGrid[imageRowY * gridW + gridColX] = 1; // 标记该格为自由
}
}
// ── 第二步:遍历每个格子,按类型着色 ──
const offscreenCanvas = document.createElement('canvas'); // 创建离屏 Canvas
offscreenCanvas.width = gridW; // 设置尺寸
offscreenCanvas.height = gridH;
const offscreenCtx = offscreenCanvas.getContext('2d'); // 获取上下文
const imageData = offscreenCtx.createImageData(gridW, gridH);// 创建 ImageData
const pixelData = imageData.data; // 像素数据数组 (RGBA 扁平排列)
// 定义三种颜色
const FREE_SPACE_R = 10, FREE_SPACE_G = 16, FREE_SPACE_B = 30; // 自由空间:深蓝黑
const WALL_INTERIOR_R = 22, WALL_INTERIOR_G = 36, WALL_INTERIOR_B = 64; // 墙壁内部:深蓝
const WALL_EDGE_R = 45, WALL_EDGE_G = 130, WALL_EDGE_B = 255; // 墙壁边缘:亮蓝
for (let rowY = 0; rowY < gridH; rowY++) { // 逐行扫描
for (let colX = 0; colX < gridW; colX++) { // 逐列扫描
const gridIndex = rowY * gridW + colX; // 一维数组索引
const pixelOffset = gridIndex << 2; // RGBA 偏移×4
if (occupancyGrid[gridIndex] === 1) {
// ── 自由空间 ──
pixelData[pixelOffset] = FREE_SPACE_R; // R
pixelData[pixelOffset + 1] = FREE_SPACE_G; // G
pixelData[pixelOffset + 2] = FREE_SPACE_B; // B
pixelData[pixelOffset + 3] = 255; // A不透明
} else {
// ── 障物(墙壁)── 判断是否为边缘
let isAdjacentToFreeSpace = false; // 是否紧邻自由空间
if (showEdgeGlow) { // 仅在启用边缘效果时检测
if (rowY > 0 && occupancyGrid[(rowY - 1) * gridW + colX] === 1) isAdjacentToFreeSpace = true; // 上方
else if (rowY < gridH - 1 && occupancyGrid[(rowY + 1) * gridW + colX] === 1) isAdjacentToFreeSpace = true; // 下方
else if (colX > 0 && occupancyGrid[rowY * gridW + colX - 1] === 1) isAdjacentToFreeSpace = true; // 左方
else if (colX < gridW - 1 && occupancyGrid[rowY * gridW + colX + 1] === 1) isAdjacentToFreeSpace = true; // 右方
}
if (isAdjacentToFreeSpace) {
// 墙壁边缘:亮蓝色
pixelData[pixelOffset] = WALL_EDGE_R;
pixelData[pixelOffset + 1] = WALL_EDGE_G;
pixelData[pixelOffset + 2] = WALL_EDGE_B;
pixelData[pixelOffset + 3] = 255;
} else {
// 墙壁内部:深蓝色
pixelData[pixelOffset] = WALL_INTERIOR_R;
pixelData[pixelOffset + 1] = WALL_INTERIOR_G;
pixelData[pixelOffset + 2] = WALL_INTERIOR_B;
pixelData[pixelOffset + 3] = 255;
}
}
}
}
// 将 ImageData 绘制到离屏 Canvas 上
offscreenCtx.putImageData(imageData, 0, 0);
return offscreenCanvas;
}
/**
* 加载地图到应用构建栅格图像、设置相机、更新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.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 = canvasWidthPx / worldWidthMeters; // 水平方向恰好填满的缩放
const fitScaleY = canvasHeightPx / worldHeightMeters; // 垂直方向恰好填满的缩放
applicationState.camera.pixelsPerMeter = Math.max(1, Math.min(fitScaleX, fitScaleY) * 0.88); // 取较小值并留12%边距
// 标记加载完成并更新UI
applicationState.isMapLoaded = true;
}
// ═══════════════════════════════════════════════════════════════════════
// 9. 渲染器 — 每帧调用,绘制所有图层
// ═══════════════════════════════════════════════════════════════════════
/** 全局动画时间戳(秒),用于脉冲动画效果 */
let animationTimeSeconds = 0;
/**
* 主渲染函数,由 requestAnimationFrame 循环调用
* 按层顺序绘制:栅格 → 网格线 → 区域 → 基础线 → 高级线 → 曲线 → 点 → 边界
*
* @param {number} timestamp - requestAnimationFrame 提供的时间戳(毫秒)
*/
function renderFrame(timestamp) {
// 更新动画时间
animationTimeSeconds = (timestamp || 0) / 1000;
// 设置 Canvas 变换矩阵(考虑设备像素比)
canvasCtx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
canvasCtx.clearRect(0, 0, canvasWidthPx, canvasHeightPx); // 清空画布
// 如果地图未加载,跳过绘制
if (!applicationState.isMapLoaded) {
requestAnimationFrame(renderFrame); // 继续请求下一帧
return;
}
const map = applicationState.parsedMap; // 当前地图数据
const layers = applicationState.layerVisibility; // 图层可见性
// ─────────────────────────────────────────
// 图层1: 占据栅格底图
// ─────────────────────────────────────────
if (layers.grid && applicationState.gridOffscreen) {
const topLeftScreen = worldToScreen(map.originX, map.maxY); // 图像左上角(世界坐标→屏幕)
const bottomRightScreen = worldToScreen(map.maxX, map.originY); // 图像右下角(世界坐标→屏幕)
canvasCtx.imageSmoothingEnabled = false; // 关闭抗锯齿(保持像素清晰)
canvasCtx.drawImage(
applicationState.gridOffscreen, // 离屏 Canvas 图像源
topLeftScreen.x, topLeftScreen.y, // 目标左上角
bottomRightScreen.x - topLeftScreen.x, // 目标宽度
bottomRightScreen.y - topLeftScreen.y // 目标高度
);
}
// ─────────────────────────────────────────
// 图层2: 网格辅助线每1米一条
// ─────────────────────────────────────────
if (layers.gridlines) {
// 计算当前可视范围的世界坐标
const viewTopLeft = screenToWorld(0, 0); // 视口左上角世界坐标
const viewBottomRight = screenToWorld(canvasWidthPx, canvasHeightPx); // 视口右下角世界坐标
const viewMinX = Math.floor(Math.min(viewTopLeft.x, viewBottomRight.x)); // 可视X最小整数
const viewMaxX = Math.ceil(Math.max(viewTopLeft.x, viewBottomRight.x)); // 可视X最大整数
const viewMinY = Math.floor(Math.min(viewTopLeft.y, viewBottomRight.y)); // 可视Y最小整数
const viewMaxY = Math.ceil(Math.max(viewTopLeft.y, viewBottomRight.y)); // 可视Y最大整数
// 绘制1米间隔的浅色网格线
canvasCtx.strokeStyle = 'rgba(30,50,80,0.25)'; // 淡蓝灰色
canvasCtx.lineWidth = 0.5; // 细线
canvasCtx.beginPath();
for (let worldX = viewMinX; worldX <= viewMaxX; worldX++) { // 逐列绘制垂直线
const screenPos = worldToScreen(worldX, 0);
canvasCtx.moveTo(screenPos.x, 0); // 从画布顶部
canvasCtx.lineTo(screenPos.x, canvasHeightPx); // 到画布底部
}
for (let worldY = viewMinY; worldY <= viewMaxY; worldY++) { // 逐行绘制水平线
const screenPos = worldToScreen(0, worldY);
canvasCtx.moveTo(0, screenPos.y); // 从画布左边
canvasCtx.lineTo(canvasWidthPx, screenPos.y); // 到画布右边
}
canvasCtx.stroke();
}
// ─────────────────────────────────────────
// 图层3: advancedAreaList半透明多边形区域
// ─────────────────────────────────────────
if (layers.areas) {
for (let areaIndex = 0; areaIndex < map.advancedAreas.length; areaIndex++) {
const area = map.advancedAreas[areaIndex]; // 当前区域
const vertices = area.posGroup || []; // 多边形顶点数组
if (vertices.length < 3) continue; // 少于3个顶点无法构成多边形跳过
// ── 颜色:优先使用 attribute 中的 ARGB 整数,否则用默认品红色 ──
let fillColorCss = 'rgba(224,64,251,0.12)'; // 默认填充(品红半透明)
let strokeColorCss = 'rgba(224,64,251,0.5)'; // 默认描边(品红较不透明)
if (area.attribute) {
const brushColor = decodeArgbInteger(area.attribute.colorBrush); // 解码填充色
const penColor = decodeArgbInteger(area.attribute.colorPen); // 解码描边色
if (brushColor) {
// 使用原始 alpha 值的一半作为填充透明度,最小 0.06
fillColorCss = toCssRgba(brushColor, Math.max(0.06, (brushColor.a / 255) * 0.5));
}
if (penColor) {
strokeColorCss = toCssRgba(penColor, 0.6); // 描边60%不透明
}
}
// ── 绘制多边形 ──
canvasCtx.fillStyle = fillColorCss; // 设置填充色
canvasCtx.strokeStyle = strokeColorCss; // 设置描边色
canvasCtx.lineWidth = 1.2; // 描边宽度
canvasCtx.beginPath();
const firstVertexScreen = worldToScreen(vertices[0].x, vertices[0].y); // 第一个顶点的屏幕位置
canvasCtx.moveTo(firstVertexScreen.x, firstVertexScreen.y); // 移动到起点
for (let i = 1; i < vertices.length; i++) { // 依次连接后续顶点
const vertexScreen = worldToScreen(vertices[i].x, vertices[i].y);
canvasCtx.lineTo(vertexScreen.x, vertexScreen.y);
}
canvasCtx.closePath(); // 闭合路径
canvasCtx.fill(); // 填充
canvasCtx.stroke(); // 描边
// ── 在多边形质心处绘制区域名称标签 ──
let centroidWorldX = 0, centroidWorldY = 0; // 质心世界坐标
for (const v of vertices) { centroidWorldX += v.x; centroidWorldY += v.y; } // 累加顶点坐标
centroidWorldX /= vertices.length; // 求平均得到质心 X
centroidWorldY /= vertices.length; // 求平均得到质心 Y
const centroidScreen = worldToScreen(centroidWorldX, centroidWorldY);
canvasCtx.font = '500 8px "JetBrains Mono"'; // 标签字体
canvasCtx.fillStyle = strokeColorCss; // 与描边同色
canvasCtx.textAlign = 'center'; // 居中对齐
canvasCtx.fillText(area.instanceName || '', centroidScreen.x, centroidScreen.y + 3); // 绘制文字
}
}
// ─────────────────────────────────────────
// 图层4: normalLineList基础直线青色 #00e5ff
// ─────────────────────────────────────────
if (layers.normalLines) {
for (let lineIndex = 0; lineIndex < map.normalLines.length; lineIndex++) {
const line = map.normalLines[lineIndex]; // 当前直线
const startScreen = worldToScreen(line.startPos.x, line.startPos.y); // 起点屏幕位置
const endScreen = worldToScreen(line.endPos.x, line.endPos.y); // 终点屏幕位置
// 绘制发光直线
canvasCtx.save();
canvasCtx.shadowColor = '#00e5ff'; // 阴影颜色(产生辉光)
canvasCtx.shadowBlur = 5; // 辉光扩散半径
canvasCtx.strokeStyle = '#00e5ff'; // 线条颜色:青色
canvasCtx.lineWidth = 2; // 线宽
canvasCtx.globalAlpha = 0.8; // 80%不透明
canvasCtx.beginPath();
canvasCtx.moveTo(startScreen.x, startScreen.y); // 从起点
canvasCtx.lineTo(endScreen.x, endScreen.y); // 到终点
canvasCtx.stroke();
canvasCtx.restore();
// 在中点绘制方向箭头
const midpointX = (startScreen.x + endScreen.x) / 2; // 中点X
const midpointY = (startScreen.y + endScreen.y) / 2; // 中点Y
const arrowAngle = Math.atan2(endScreen.y - startScreen.y, endScreen.x - startScreen.x); // 线段方向角
const arrowLength = 5; // 箭头长度
canvasCtx.fillStyle = 'rgba(0,229,255,0.6)'; // 箭头填充色
canvasCtx.beginPath();
canvasCtx.moveTo(midpointX + Math.cos(arrowAngle) * arrowLength, midpointY + Math.sin(arrowAngle) * arrowLength); // 箭头尖端
canvasCtx.lineTo(midpointX + Math.cos(arrowAngle + 2.5) * 4, midpointY + Math.sin(arrowAngle + 2.5) * 4); // 左翼
canvasCtx.lineTo(midpointX + Math.cos(arrowAngle - 2.5) * 4, midpointY + Math.sin(arrowAngle - 2.5) * 4); // 右翼
canvasCtx.closePath();
canvasCtx.fill();
}
}
// ─────────────────────────────────────────
// 图层5: advancedLineList高级线如 ForbiddenLine
// ─────────────────────────────────────────
if (layers.advLines) {
for (let advLineIndex = 0; advLineIndex < map.advancedLines.length; advLineIndex++) {
const advLine = map.advancedLines[advLineIndex]; // 当前高级线
const lineData = advLine.line; // 内部的 line 对象
if (!lineData || !lineData.startPos || !lineData.endPos) continue; // 缺少端点则跳过
const startScreen = worldToScreen(lineData.startPos.x, lineData.startPos.y);
const endScreen = worldToScreen(lineData.endPos.x, lineData.endPos.y);
// ── 根据 className 决定颜色和线型 ──
let lineColorHex = '#ff6e40'; // 默认橙色
let lineDashPattern = []; // 默认实线(无虚线)
const classNameLower = (advLine.className || '').toLowerCase();
if (classNameLower.includes('forbidden')) { // 禁行线 → 红色虚线
lineColorHex = '#ff1744';
lineDashPattern = [8, 4]; // 虚线模式8px实 4px空
} else if (classNameLower.includes('virtual')) { // 虚拟线 → 橙色虚线
lineColorHex = '#ffab40';
lineDashPattern = [4, 4];
}
// 绘制高级线
canvasCtx.save();
canvasCtx.shadowColor = lineColorHex; // 辉光色与线条同色
canvasCtx.shadowBlur = 4; // 辉光半径
canvasCtx.strokeStyle = lineColorHex; // 线条颜色
canvasCtx.lineWidth = 2.5; // 线宽(比基础线粗)
canvasCtx.setLineDash(lineDashPattern); // 设置虚线模式
canvasCtx.globalAlpha = 0.8; // 80%不透明
canvasCtx.beginPath();
canvasCtx.moveTo(startScreen.x, startScreen.y);
canvasCtx.lineTo(endScreen.x, endScreen.y);
canvasCtx.stroke();
canvasCtx.setLineDash([]); // 恢复实线
canvasCtx.restore();
// 在中点绘制名称标签
const midpointScreenX = (startScreen.x + endScreen.x) / 2;
const midpointScreenY = (startScreen.y + endScreen.y) / 2;
canvasCtx.font = '500 8px "JetBrains Mono"';
canvasCtx.fillStyle = hexToCssRgba(lineColorHex, 0.55); // 55%不透明的文字
canvasCtx.textAlign = 'center';
canvasCtx.fillText(advLine.instanceName || advLine.className || '', midpointScreenX, midpointScreenY - 6);
}
}
// ─────────────────────────────────────────
// 图层6: advancedCurveList贝塞尔曲线路径酸绿色 #76ff03
// ─────────────────────────────────────────
if (layers.curves) {
for (let curveIndex = 0; curveIndex < map.advancedCurves.length; curveIndex++) {
const curve = map.advancedCurves[curveIndex]; // 当前曲线
// 提取起点和终点的 pos注意startPos/endPos 是 {instanceName, pos:{x,y}} 结构)
const startPoint = curve.startPos?.pos; // 起点世界坐标
const endPoint = curve.endPos?.pos; // 终点世界坐标
const controlPoint1 = curve.controlPos1; // 第一控制点(直接 {x,y}
const controlPoint2 = curve.controlPos2; // 第二控制点(直接 {x,y}
if (!startPoint || !endPoint) continue; // 缺少端点则跳过
// 转换为屏幕坐标
const startScreen = worldToScreen(startPoint.x, startPoint.y);
const endScreen = worldToScreen(endPoint.x, endPoint.y);
const cp1Screen = controlPoint1 ? worldToScreen(controlPoint1.x, controlPoint1.y) : null; // 可能不存在
const cp2Screen = controlPoint2 ? worldToScreen(controlPoint2.x, controlPoint2.y) : null;
// ── 绘制贝塞尔曲线(虚线)──
canvasCtx.save();
canvasCtx.shadowColor = '#76ff03'; // 酸绿色辉光
canvasCtx.shadowBlur = 5;
canvasCtx.strokeStyle = '#76ff03'; // 酸绿色线条
canvasCtx.lineWidth = 2;
canvasCtx.setLineDash([8, 5]); // 虚线模式
canvasCtx.globalAlpha = 0.85;
canvasCtx.beginPath();
canvasCtx.moveTo(startScreen.x, startScreen.y); // 起点
if (cp1Screen && cp2Screen) {
// 有2个控制点 → 三次贝塞尔 (cubic bezier)
canvasCtx.bezierCurveTo(cp1Screen.x, cp1Screen.y, cp2Screen.x, cp2Screen.y, endScreen.x, endScreen.y);
} else if (cp1Screen) {
// 仅1个控制点 → 二次贝塞尔 (quadratic bezier)
canvasCtx.quadraticCurveTo(cp1Screen.x, cp1Screen.y, endScreen.x, endScreen.y);
} else {
// 无控制点 → 直线
canvasCtx.lineTo(endScreen.x, endScreen.y);
}
canvasCtx.stroke();
canvasCtx.setLineDash([]); // 恢复实线
canvasCtx.restore();
// ── 在 t=0.5 处绘制方向箭头 ──
if (cp1Screen && cp2Screen) {
const t = 0.5; // 参数 t = 0.5(曲线中点)
const oneMinusT = 0.5; // 1-t = 0.5
// 三次贝塞尔公式计算中点坐标
const arrowX = oneMinusT ** 3 * startScreen.x + 3 * oneMinusT ** 2 * t * cp1Screen.x + 3 * oneMinusT * t ** 2 * cp2Screen.x + t ** 3 * endScreen.x;
const arrowY = oneMinusT ** 3 * startScreen.y + 3 * oneMinusT ** 2 * t * cp1Screen.y + 3 * oneMinusT * t ** 2 * cp2Screen.y + t ** 3 * endScreen.y;
// 三次贝塞尔的一阶导数(切线方向)
const tangentX = 3 * oneMinusT ** 2 * (cp1Screen.x - startScreen.x) + 6 * oneMinusT * t * (cp2Screen.x - cp1Screen.x) + 3 * t ** 2 * (endScreen.x - cp2Screen.x);
const tangentY = 3 * oneMinusT ** 2 * (cp1Screen.y - startScreen.y) + 6 * oneMinusT * t * (cp2Screen.y - cp1Screen.y) + 3 * t ** 2 * (endScreen.y - cp2Screen.y);
const tangentAngle = Math.atan2(tangentY, tangentX); // 切线角度
// 绘制箭头
canvasCtx.fillStyle = 'rgba(118,255,3,0.7)';
canvasCtx.beginPath();
canvasCtx.moveTo(arrowX + Math.cos(tangentAngle) * 5, arrowY + Math.sin(tangentAngle) * 5);
canvasCtx.lineTo(arrowX + Math.cos(tangentAngle + 2.5) * 4, arrowY + Math.sin(tangentAngle + 2.5) * 4);
canvasCtx.lineTo(arrowX + Math.cos(tangentAngle - 2.5) * 4, arrowY + Math.sin(tangentAngle - 2.5) * 4);
canvasCtx.closePath();
canvasCtx.fill();
// 绘制控制点小圆(辅助调试用)
canvasCtx.fillStyle = 'rgba(118,255,3,0.25)'; // 浅绿色半透明
for (const controlScreen of [cp1Screen, cp2Screen]) {
canvasCtx.beginPath();
canvasCtx.arc(controlScreen.x, controlScreen.y, 2.5, 0, Math.PI * 2); // 半径2.5px的小圆
canvasCtx.fill();
}
}
// ── 曲线名称标签 ──
canvasCtx.font = '500 7px "JetBrains Mono"';
canvasCtx.fillStyle = 'rgba(118,255,3,0.5)';
canvasCtx.textAlign = 'center';
const labelScreenY = Math.min(startScreen.y, endScreen.y) - 8; // 标签放在最高点上方
canvasCtx.fillText(curve.instanceName || '', (startScreen.x + endScreen.x) / 2, labelScreenY);
}
}
// ─────────────────────────────────────────
// 图层7: advancedPointList高级点标记菱形
// ─────────────────────────────────────────
if (layers.points) {
for (let pointIndex = 0; pointIndex < map.advancedPoints.length; pointIndex++) {
const advPoint = map.advancedPoints[pointIndex]; // 当前高级点
const pointScreen = worldToScreen(advPoint.pos.x, advPoint.pos.y); // 屏幕位置
// 判断是否被鼠标悬停
const isHovered = applicationState.hoveredElement &&
applicationState.hoveredElement.kind === 'point' &&
applicationState.hoveredElement.index === pointIndex;
// ── 根据 className 决定颜色 ──
let pointColorHex = '#ffd600'; // 默认琥珀色
const classNameLower = (advPoint.className || '').toLowerCase();
if (classNameLower.includes('landmark') || classNameLower.includes('land')) {
pointColorHex = '#ffd600'; // LandMark → 琥珀色
} else if (classNameLower.includes('charge')) {
pointColorHex = '#22c55e'; // 充电点 → 绿色
} else if (classNameLower.includes('load')) {
pointColorHex = '#3b82f6'; // 装载点 → 蓝色
} else if (classNameLower.includes('work') || classNameLower.includes('process')) {
pointColorHex = '#a855f7'; // 工位 → 紫色
} else if (classNameLower.includes('station')) {
pointColorHex = '#00e5ff'; // 站点 → 青色
}
// ── 绘制菱形标记 ──
const diamondRadius = isHovered ? 8 : 5; // 悬停时放大
const pulseAlpha = isHovered ? Math.sin(animationTimeSeconds * 5) * 0.2 + 0.8 : 1; // 悬停时脉冲闪烁
canvasCtx.save();
canvasCtx.globalAlpha = pulseAlpha; // 应用透明度
canvasCtx.shadowColor = pointColorHex; // 辉光颜色
canvasCtx.shadowBlur = isHovered ? 18 : 8; // 悬停时增强辉光
// 绘制旋转45度的正方形菱形
canvasCtx.fillStyle = pointColorHex;
canvasCtx.beginPath();
canvasCtx.moveTo(pointScreen.x, pointScreen.y - diamondRadius); // 上顶点
canvasCtx.lineTo(pointScreen.x + diamondRadius * 0.7, pointScreen.y); // 右顶点
canvasCtx.lineTo(pointScreen.x, pointScreen.y + diamondRadius); // 下顶点
canvasCtx.lineTo(pointScreen.x - diamondRadius * 0.7, pointScreen.y); // 左顶点
canvasCtx.closePath();
canvasCtx.fill();
// 中心白色小圆点(指示精确位置)
canvasCtx.shadowBlur = 0;
canvasCtx.fillStyle = '#fff';
canvasCtx.globalAlpha = 0.9;
canvasCtx.beginPath();
canvasCtx.arc(pointScreen.x, pointScreen.y, 1.8, 0, Math.PI * 2); // 半径1.8px
canvasCtx.fill();
canvasCtx.restore();
// ── 方向箭头(基于 dir 字段,弧度)──
if (advPoint.dir !== undefined && advPoint.dir !== null) {
// dir 可能是字符串smap 样例中为 "-1.5759999999999958")或数字
const dirRadians = typeof advPoint.dir === 'string' ? parseFloat(advPoint.dir) : advPoint.dir;
if (!isNaN(dirRadians)) { // 有效方向角
const arrowLengthPx = 12; // 箭头长度(屏幕像素)
canvasCtx.strokeStyle = hexToCssRgba(pointColorHex, 0.5); // 半透明的线条
canvasCtx.lineWidth = 1;
canvasCtx.beginPath();
canvasCtx.moveTo(pointScreen.x, pointScreen.y); // 从点中心出发
// 注意:世界坐标 Y 向上,但 atan2 使用的 dir 是标准数学角,屏幕 Y 翻转后取负
canvasCtx.lineTo(
pointScreen.x + Math.cos(dirRadians) * arrowLengthPx,
pointScreen.y - Math.sin(dirRadians) * arrowLengthPx // Y 翻转
);
canvasCtx.stroke();
}
}
// ── 名称标签 ──
canvasCtx.font = '500 7px "JetBrains Mono"';
canvasCtx.fillStyle = hexToCssRgba(pointColorHex, 0.7);
canvasCtx.textAlign = 'center';
canvasCtx.fillText(advPoint.instanceName || '', pointScreen.x, pointScreen.y - diamondRadius - 4);
}
}
// ─────────────────────────────────────────
// 地图边界虚线框
// ─────────────────────────────────────────
const boundaryTopLeft = worldToScreen(map.originX, map.maxY); // 边界左上角屏幕位置
const boundaryBottomRight = worldToScreen(map.maxX, map.originY); // 边界右下角屏幕位置
canvasCtx.strokeStyle = 'rgba(0,229,160,0.1)'; // 极淡的青绿色
canvasCtx.lineWidth = 1;
canvasCtx.setLineDash([5, 4]); // 虚线
canvasCtx.strokeRect(
boundaryTopLeft.x, boundaryTopLeft.y,
boundaryBottomRight.x - boundaryTopLeft.x,
boundaryBottomRight.y - boundaryTopLeft.y
);
canvasCtx.setLineDash([]); // 恢复实线
// 请求下一帧渲染
requestAnimationFrame(renderFrame);
}
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) {
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.camera.pixelsPerMeter = Math.max(0.5, Math.min(2000, applicationState.camera.pixelsPerMeter * zoomFactor)); // 应用缩放并钳制范围
const mouseWorldAfter = screenToWorld(applicationState.mouseState.screenX, applicationState.mouseState.screenY); // 缩放后鼠标世界坐标
// 调整相机中心,使鼠标指向的世界坐标保持不变
applicationState.camera.centerX += mouseWorldBefore.x - mouseWorldAfter.x;
applicationState.camera.centerY += mouseWorldBefore.y - mouseWorldAfter.y;
}, { passive: false }); // passive:false 允许 preventDefault
}
const loadSourceMap = async (url) => {
const response = await fetch(url);
if (!response.ok) throw new Error(`加载失败:${response.status}`);
return response.text();
};
/**
* 加载 smap JSON 文件
*/
async function loadUserFile() {
try {
const fileTextContent = await loadSourceMap('/2.smap');
const rawJsonObject = JSON.parse(fileTextContent); // 解析 JSON
const parsedMap = parseSmapJson(rawJsonObject); // 转换为内部结构
await loadMapIntoApplication(parsedMap); // 加载到应用
} catch (parseError) {
alert('Parse error: ' + parseError.message); // 显示错误信息
}
}
onMounted(() => {
init()
// 启动渲染循环
loadUserFile()
requestAnimationFrame(renderFrame);
window.addEventListener('resize', resizeCanvasToContainer); // 监听窗口大小变化
resizeCanvasToContainer(); // 初始调用一次
setTimeout(() => {
bindEvent()
}, 100)
})
onUnmounted(() => {
window.removeEventListener('resize', resizeCanvasToContainer)
canvasCtx = null
})
</script>
<style lang="scss" scoped>
.container {
width: 100%;
height: 100%;
}
</style>