811 lines
47 KiB
Vue
811 lines
47 KiB
Vue
|
|
<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>
|