528 lines
22 KiB
Vue
528 lines
22 KiB
Vue
<template>
|
||
<div class="container">
|
||
<canvas id="map-canvas"></canvas>
|
||
<div id="tooltip">
|
||
<div class="tooltip-title"></div>
|
||
<div class="tooltip-detail"></div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { onMounted, onUnmounted, watch } from 'vue';
|
||
|
||
import {
|
||
worldToScreen,
|
||
screenToWorld,
|
||
drawGrid,
|
||
drawGridlines,
|
||
drawAdvancedAreaList,
|
||
drawAdvancedLineList,
|
||
drawNormalLines,
|
||
drawAdvancedCurveList,
|
||
drawAdvancedPointList,
|
||
drawMapBoundary,
|
||
buildOccupancyGridImage,
|
||
MobileRobot
|
||
} from '../canvasUtils';
|
||
import { getMapJson } from '@/api/inspection/robot'
|
||
import { useInspectionStore } from "@/store/modules/inspection";
|
||
import { ElMessage } from 'element-plus';
|
||
|
||
const props = defineProps({
|
||
robotList: {
|
||
type: Array,
|
||
default: () => []
|
||
}
|
||
})
|
||
|
||
const inspectionStore = useInspectionStore();
|
||
|
||
|
||
/**
|
||
* 应用全局状态对象,集中管理所有可变数据
|
||
* - parsedMap: 解析后的地图数据结构
|
||
* - layerVisibility: 各图层的可见性开关
|
||
* - mouseState: 鼠标位置与拖拽状态
|
||
* - isMapLoaded: 地图是否已加载完成
|
||
*/
|
||
const applicationState = {
|
||
parsedMap: null, // 解析后的完整地图数据
|
||
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 渲染上下文
|
||
|
||
// 保存当前动画帧 ID,组件卸载时用于停止 Canvas 渲染循环。
|
||
let animationFrameId;
|
||
// 地图请求版本号:当多个地图请求并发时,只允许最后一次请求更新画布。
|
||
let mapLoadVersion = 0;
|
||
|
||
const init = () => {
|
||
mapCanvas = document.getElementById('map-canvas');
|
||
canvasCtx = mapCanvas.getContext('2d');
|
||
}
|
||
|
||
/**
|
||
* 根据容器尺寸和设备像素比调整 Canvas 大小
|
||
* 在窗口 resize 时自动调用
|
||
*/
|
||
function resizeCanvasToContainer() {
|
||
const containerRect = mapCanvas.parentElement.getBoundingClientRect(); // 获取容器实际尺寸
|
||
devicePixelRatio = window.devicePixelRatio || 1; // 获取设备像素比
|
||
inspectionStore.canvasWidthPx = containerRect.width; // 更新逻辑宽度
|
||
inspectionStore.canvasHeightPx = containerRect.height; // 逻辑高度 = 容器高度 - 状态栏高度
|
||
mapCanvas.width = inspectionStore.canvasWidthPx * devicePixelRatio; // Canvas 物理像素宽
|
||
mapCanvas.height = inspectionStore.canvasHeightPx * devicePixelRatio; // Canvas 物理像素高
|
||
mapCanvas.style.width = inspectionStore.canvasWidthPx + 'px'; // CSS 显示宽度
|
||
mapCanvas.style.height = inspectionStore.canvasHeightPx + 'px'; // CSS 显示高度
|
||
}
|
||
|
||
|
||
/**
|
||
* 解析 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;
|
||
// 构建栅格图像(带边缘高亮)
|
||
inspectionStore.gridOffscreen = buildOccupancyGridImage(parsedMap, applicationState.layerVisibility.edges);
|
||
|
||
// ── 设置相机初始位置(地图中心)──
|
||
inspectionStore.camera.centerX = (parsedMap.originX + parsedMap.maxX) / 2; // 水平居中
|
||
inspectionStore.camera.centerY = (parsedMap.originY + parsedMap.maxY) / 2; // 垂直居中
|
||
// 计算合适的缩放级别,使地图完整显示在画布内
|
||
const worldWidthMeters = parsedMap.maxX - parsedMap.originX; // 地图世界宽度
|
||
const worldHeightMeters = parsedMap.maxY - parsedMap.originY; // 地图世界高度
|
||
const fitScaleX = inspectionStore.canvasWidthPx / worldWidthMeters; // 水平方向恰好填满的缩放
|
||
const fitScaleY = inspectionStore.canvasHeightPx / worldHeightMeters; // 垂直方向恰好填满的缩放
|
||
inspectionStore.camera.pixelsPerMeter = Math.max(1, Math.max(fitScaleX, fitScaleY) * 1); // 取较小值
|
||
|
||
// 标记加载完成并更新UI
|
||
applicationState.isMapLoaded = true;
|
||
}
|
||
|
||
|
||
/**
|
||
* 主渲染函数,由 requestAnimationFrame 循环调用
|
||
* 按层顺序绘制:栅格 → 网格线 → 区域 → 基础线 → 高级线 → 曲线 → 点 → 边界
|
||
*
|
||
* @param {number} timestamp - requestAnimationFrame 提供的时间戳(毫秒)
|
||
*/
|
||
function renderFrame(timestamp) {
|
||
// 更新动画时间
|
||
inspectionStore.animationTimeSeconds = (timestamp || 0) / 1000;
|
||
|
||
// 设置 Canvas 变换矩阵(考虑设备像素比)
|
||
canvasCtx.setTransform(inspectionStore.devicePixelRatio, 0, 0, inspectionStore.devicePixelRatio, 0, 0);
|
||
// canvasCtx.clearRect(0, 0, inspectionStore.canvasWidthPx, inspectionStore.canvasHeightPx); // 清空画布
|
||
canvasCtx.clearRect(0, 0, 10000, 10000); // 清空画布
|
||
|
||
// 如果地图未加载,跳过绘制
|
||
if (!applicationState.isMapLoaded) {
|
||
animationFrameId = requestAnimationFrame(renderFrame); // 继续请求下一帧
|
||
return;
|
||
}
|
||
|
||
const map = applicationState.parsedMap; // 当前地图数据
|
||
const layers = applicationState.layerVisibility; // 图层可见性
|
||
|
||
// 图层1: 占据栅格底图
|
||
if (layers.grid && inspectionStore.gridOffscreen) {
|
||
drawGrid(canvasCtx, map);
|
||
}
|
||
|
||
// 图层2: 网格辅助线(每1米一条)
|
||
if (layers.gridlines) {
|
||
drawGridlines(canvasCtx)
|
||
}
|
||
|
||
// 图层3: advancedAreaList(半透明多边形区域)
|
||
if (layers.areas) {
|
||
drawAdvancedAreaList(canvasCtx, map)
|
||
}
|
||
|
||
// 图层4: normalLineList(基础直线,青色 #00e5ff)
|
||
if (layers.normalLines) {
|
||
drawNormalLines(canvasCtx, map);
|
||
}
|
||
|
||
// 图层5: advancedLineList(高级线,如 ForbiddenLine)
|
||
if (layers.advLines) {
|
||
drawAdvancedLineList(canvasCtx, map);
|
||
}
|
||
|
||
// 图层6: advancedCurveList(贝塞尔曲线路径,酸绿色 #76ff03)
|
||
if (layers.curves) {
|
||
drawAdvancedCurveList(canvasCtx, map);
|
||
}
|
||
|
||
// 图层7: advancedPointList(高级点标记,菱形)
|
||
if (layers.points) {
|
||
drawAdvancedPointList(canvasCtx, map);
|
||
}
|
||
|
||
if (layers.robots) {
|
||
drawAllRobots(canvasCtx)
|
||
}
|
||
|
||
// 地图边界虚线框
|
||
drawMapBoundary(canvasCtx, map);
|
||
|
||
// 请求下一帧渲染
|
||
animationFrameId = requestAnimationFrame(renderFrame);
|
||
}
|
||
|
||
const drawAllRobots = (ctx) => {
|
||
if (!applicationState.layerVisibility.robots) return;
|
||
for (const robot of applicationState.robots) {
|
||
const screenPos = worldToScreen(robot.x, robot.y);
|
||
// 裁剪超出画布简易跳过
|
||
if (screenPos.x + 20 < 0 || screenPos.x - 20 > inspectionStore.canvasWidthPx || screenPos.y + 20 < 0 || screenPos.y - 20 > inspectionStore.canvasHeightPx) {
|
||
continue;
|
||
}
|
||
robot.draw(ctx, screenPos.x, screenPos.y, robot.color, 1);
|
||
}
|
||
}
|
||
|
||
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) {
|
||
inspectionStore.camera.centerX -= mouseEvent.movementX / inspectionStore.camera.pixelsPerMeter; // 水平平移
|
||
inspectionStore.camera.centerY += mouseEvent.movementY / inspectionStore.camera.pixelsPerMeter; // 垂直平移(Y翻转)
|
||
}
|
||
|
||
let hit = hitTestRobots(worldPos.x, worldPos.y);
|
||
updateTooltipDisplay(hit);
|
||
});
|
||
|
||
// ── 鼠标按下:开始拖拽 ──
|
||
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); // 缩放前鼠标世界坐标
|
||
inspectionStore.camera.pixelsPerMeter = Math.max(0.5, Math.min(2000, inspectionStore.camera.pixelsPerMeter * zoomFactor)); // 应用缩放并钳制范围
|
||
const mouseWorldAfter = screenToWorld(applicationState.mouseState.screenX, applicationState.mouseState.screenY); // 缩放后鼠标世界坐标
|
||
// 调整相机中心,使鼠标指向的世界坐标保持不变
|
||
inspectionStore.camera.centerX += mouseWorldBefore.x - mouseWorldAfter.x;
|
||
inspectionStore.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';
|
||
inspectionStore.hoveredElement = null;
|
||
return;
|
||
}
|
||
inspectionStore.hoveredElement = hitResult;
|
||
let title = '', details = '';
|
||
if (hitResult.kind === 'robot') {
|
||
const robot = hitResult.robot;
|
||
title = `${robot.name}`;
|
||
details = `<div class="tooltip-detail">位置: (${robot.x}, ${robot.y})</div>
|
||
<div class="tooltip-detail">朝向: ${(robot.angle * 180 / Math.PI).toFixed(1)}°</div>
|
||
<div class="tooltip-detail">ID: ${robot.id}</div>`;
|
||
}
|
||
tooltipEl.innerHTML = `<div class="tooltip-title" style="color:#00e5a0">${title}</div>${details}`;
|
||
tooltipEl.style.display = 'block';
|
||
tooltipEl.style.left = Math.min(applicationState.mouseState.screenX + 14, inspectionStore.canvasWidthPx - 220) + 'px';
|
||
tooltipEl.style.top = Math.max(4, applicationState.mouseState.screenY - 8) + 'px';
|
||
}
|
||
|
||
const hitTestRobots = (worldX, worldY) => {
|
||
for (let i = 0; i < applicationState.robots.length; i++) {
|
||
const robot = applicationState.robots[i];
|
||
if (robot.hitTest(worldX, worldY)) {
|
||
return { kind: 'robot', index: i, robot: robot };
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
const zoomCanvas = (zoomIn) => {
|
||
const zoomFactor = zoomIn ? 1.12 : 0.88;
|
||
const worldBefore = screenToWorld(inspectionStore.canvasWidthPx / 2, inspectionStore.canvasHeightPx / 2);
|
||
inspectionStore.camera.pixelsPerMeter = Math.max(0.5, Math.min(2000, inspectionStore.camera.pixelsPerMeter * zoomFactor));
|
||
const worldAfter = screenToWorld(inspectionStore.canvasWidthPx / 2, inspectionStore.canvasHeightPx / 2);
|
||
inspectionStore.camera.centerX += worldBefore.x - worldAfter.x;
|
||
inspectionStore.camera.centerY += worldBefore.y - worldAfter.y;
|
||
}
|
||
|
||
const centerCanvasView = () => {
|
||
const map = applicationState.parsedMap;
|
||
if (!map) return;
|
||
inspectionStore.camera.centerX = (map.originX + map.maxX) / 2;
|
||
inspectionStore.camera.centerY = (map.originY + map.maxY) / 2;
|
||
}
|
||
|
||
/**
|
||
* 从机器人列表中选出地图数据来源。
|
||
* 当前沿用原有规则:使用第一台已连接的机器人获取地图。
|
||
*/
|
||
const getConnectedRobot = (robotList = props.robotList) => {
|
||
return robotList.find(item => item?.connectStatus == 1)
|
||
}
|
||
|
||
/**
|
||
* 生成地图来源的稳定标识。
|
||
* 机器人轮询时位置等字段会不断变化,但机器人 ID 和地图名称不变,
|
||
* 因此监听该标识可以避免位置更新触发底图重新加载。
|
||
*/
|
||
const getMapIdentity = (robotList = props.robotList) => {
|
||
const robot = getConnectedRobot(robotList)
|
||
return robot ? `${robot.id ?? ''}:${robot.robotMapName ?? ''}` : ''
|
||
}
|
||
|
||
/**
|
||
* 请求并加载机器人当前使用的地图。
|
||
* @param {Object} connectedRobot 用于提供地图的已连接机器人
|
||
*/
|
||
const loadSourceMap = async (connectedRobot = getConnectedRobot()) => {
|
||
if (!connectedRobot) return;
|
||
|
||
// 记录本次请求版本。若等待响应期间又发起了新请求,则丢弃本次旧结果。
|
||
const currentLoadVersion = ++mapLoadVersion;
|
||
|
||
const res = await getMapJson({
|
||
robotId: connectedRobot.id,
|
||
mapName: connectedRobot.robotMapName
|
||
});
|
||
// 防止较慢返回的旧地图覆盖用户刚切换的新地图。
|
||
if (currentLoadVersion !== mapLoadVersion) return;
|
||
if (res.code === 200) {
|
||
try {
|
||
const rawJsonObject = JSON.parse(res.data); // 解析 JSON
|
||
const parsedMap = parseSmapJson(rawJsonObject); // 转换为内部结构
|
||
await loadMapIntoApplication(parsedMap); // 加载到应用
|
||
} catch (parseError) {
|
||
ElMessage.error('Parse error: ' + parseError.message); // 显示错误信息
|
||
}
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 将接口返回的 "x,y,angle" 字符串转换为数值坐标。
|
||
* 无位置或包含非法数值时返回 null,避免无效机器人进入绘制列表。
|
||
*/
|
||
const parseRobotPosition = (currentPosition) => {
|
||
if (!currentPosition) return null
|
||
const values = String(currentPosition).split(',').map(Number)
|
||
if (values.length < 3 || values.some(value => !Number.isFinite(value))) return null
|
||
return values
|
||
}
|
||
|
||
/**
|
||
* 将最新机器人列表同步到 Canvas 使用的机器人实例中。
|
||
*
|
||
* 已存在的机器人会复用原 MobileRobot 实例,仅修改位置和名称;新增机器人
|
||
* 创建实例,接口中已移除或没有有效位置的机器人不会进入 nextRobots。
|
||
* 该函数不会加载地图或重置相机,因此可安全用于高频轮询。
|
||
*/
|
||
const syncRobots = (robotList) => {
|
||
// 用 ID 建立索引,避免为每台机器人遍历整个旧列表。
|
||
const previousRobots = new Map(applicationState.robots.map(robot => [robot.id, robot]));
|
||
const nextRobots = [];
|
||
|
||
robotList.forEach(item => {
|
||
const position = parseRobotPosition(item.currentPosition)
|
||
if (!position) return
|
||
|
||
const [x, y, angle] = position
|
||
const robot = previousRobots.get(item.id)
|
||
if (robot) {
|
||
// 保留原实例和颜色,只更新轮询产生的动态信息。
|
||
robot.name = item.robotName
|
||
robot.x = x
|
||
robot.y = y
|
||
robot.angle = angle
|
||
nextRobots.push(robot)
|
||
return
|
||
}
|
||
|
||
// 列表中首次出现的机器人需要创建绘制实例并分配颜色。
|
||
nextRobots.push(new MobileRobot(
|
||
item.id,
|
||
item.robotName,
|
||
x,
|
||
y,
|
||
angle,
|
||
colorList[nextRobots.length % colorList.length]
|
||
))
|
||
})
|
||
|
||
// 整体替换绘制列表,同时自然移除本轮接口中不存在的机器人。
|
||
applicationState.robots = nextRobots
|
||
}
|
||
|
||
onMounted(() => {
|
||
init()
|
||
window.addEventListener('resize', resizeCanvasToContainer);
|
||
resizeCanvasToContainer();
|
||
bindEvent()
|
||
syncRobots(props.robotList)
|
||
loadSourceMap()
|
||
animationFrameId = requestAnimationFrame(renderFrame);
|
||
})
|
||
|
||
const colorList = ['#00D4FF', '#FFB300', '#00FF88', '#ff5100', '#fff', '#15ff00', '#ff00d4', '#00d4ff']
|
||
|
||
// 轮询只同步机器人实例,不重新请求、解析或重置地图。
|
||
watch(() => props.robotList, syncRobots)
|
||
|
||
// 只有用于加载地图的机器人或地图名称改变时,才重新加载底图。
|
||
watch(
|
||
() => getMapIdentity(props.robotList),
|
||
(newIdentity, oldIdentity) => {
|
||
if (newIdentity && newIdentity !== oldIdentity) loadSourceMap()
|
||
}
|
||
)
|
||
|
||
defineExpose({
|
||
zoomCanvas,
|
||
centerCanvasView
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
window.removeEventListener('resize', resizeCanvasToContainer)
|
||
// 停止渲染循环,并使仍在进行中的地图请求全部失效。
|
||
cancelAnimationFrame(animationFrameId)
|
||
mapLoadVersion += 1
|
||
canvasCtx = null
|
||
})
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.container {
|
||
width: 100%;
|
||
height: 100%;
|
||
position: relative;
|
||
|
||
#tooltip {
|
||
position: absolute;
|
||
display: none;
|
||
background: #0b1120;
|
||
border: 1px solid #2a3a5c;
|
||
padding: 8px 12px;
|
||
border-radius: 3px;
|
||
pointer-events: none;
|
||
z-index: 20;
|
||
font-size: 10px;
|
||
min-width: 180px;
|
||
max-width: 280px;
|
||
box-shadow: 0 4px 16px rgba(0, 0, 0, .5);
|
||
|
||
.tooltip-title {
|
||
font-weight: 700;
|
||
font-size: 11px;
|
||
margin-bottom: 5px;
|
||
}
|
||
|
||
:deep(.tooltip-detail) {
|
||
color: #94a3b8;
|
||
padding: 2px 0;
|
||
font-size: 9px;
|
||
word-break: break-all;
|
||
}
|
||
}
|
||
}
|
||
</style>
|