加功能
This commit is contained in:
@@ -13,6 +13,7 @@ export const stopMapping = () => post('teaching/stopMapping', {})
|
||||
export const getLocalMaps = () => post('teaching/getLocalMaps', {})
|
||||
export const oneClickDeployment = (map) => post('teaching/oneClickDeployment?mapName=' + map, {})
|
||||
export const abandonMapping = () => post('teaching/abandonMapping', {})
|
||||
export const sendAutoBack = () => post('teaching/sendAutoBack', {})
|
||||
|
||||
// 操作
|
||||
export const queryStation = () => post('api/operate/queryStation', {})
|
||||
|
||||
@@ -192,4 +192,11 @@ export const queryErrorDataByCode = (code) => {
|
||||
]
|
||||
}
|
||||
return res
|
||||
}
|
||||
export const setStation = (sn, code) => {
|
||||
let res = {
|
||||
code: 200,
|
||||
message: 'ok'
|
||||
}
|
||||
return res
|
||||
}
|
||||
@@ -113,5 +113,11 @@ module.exports = {
|
||||
Syncsuccessfully: 'Sync successfully',
|
||||
EnablePointCloud: 'Enable Point Cloud',
|
||||
DisablePointCloud: 'Disable Point Cloud',
|
||||
CartPosition: 'Cart Position'
|
||||
CartPosition: 'Cart Position',
|
||||
Whetherdrivebackpoint: 'Whether to automatically drive back to the previous point?',
|
||||
yes: 'Yes',
|
||||
no: 'No',
|
||||
Dispatchnotconnected: 'Dispatch is not connected',
|
||||
Vehiclenotconnected: 'Vehicle body is not connected',
|
||||
Dispatchvehiclenotconnected: 'Dispatch and the vehicle body are not connected.'
|
||||
}
|
||||
|
||||
@@ -113,5 +113,11 @@ module.exports = {
|
||||
Syncsuccessfully: '同步成功',
|
||||
EnablePointCloud: '开启点云',
|
||||
DisablePointCloud: '关闭点云',
|
||||
CartPosition: '小车位置'
|
||||
CartPosition: '小车位置',
|
||||
Whetherdrivebackpoint: '是否自动开回上一个点?',
|
||||
yes: '是',
|
||||
no: '否',
|
||||
Dispatchnotconnected: '调度未连接',
|
||||
Vehiclenotconnected: '车辆本体未连接',
|
||||
Dispatchvehiclenotconnected: '调度和车辆本体未连接'
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<el-col :span="8">
|
||||
<el-row type="flex" justify="end">
|
||||
<button class="button_control" @click="$router.push('/index/home')"><p>{{$t('AbandonMapbuild')}}</p></button>
|
||||
<button id="v-step-3" class="button_control" style="margin-left: 10px" :disabled="disabled" @click="stopMappingConfirm"><p>{{$t('FinishMapbuild')}}</p></button>
|
||||
<button id="v-step-3" class="button_control" :class="{'button_control_gray': autoLoopEnable !== '1'}" style="margin-left: 10px" :disabled="disabled" @click="stopMappingConfirm"><p>{{$t('FinishMapbuild')}}</p></button>
|
||||
</el-row>
|
||||
</el-col>
|
||||
</el-row>
|
||||
@@ -56,11 +56,11 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import GlMap from './gl-map-4.vue'
|
||||
import GlMap from './gl-map.vue'
|
||||
import { driver } from 'driver.js'
|
||||
import 'driver.js/dist/driver.css'
|
||||
// import { startMapping } from '../../config/mork.js'
|
||||
import { startMapping, stopMapping, getMappingStatus, setStation, oneClickDeployment, abandonMapping } from '../../config/getData.js'
|
||||
import { startMapping, stopMapping, getMappingStatus, setStation, oneClickDeployment, abandonMapping, sendAutoBack } from '../../config/getData.js'
|
||||
import { mapGetters } from 'vuex'
|
||||
export default {
|
||||
name: 'ModuleBuilding',
|
||||
@@ -137,11 +137,38 @@ export default {
|
||||
showProgress: false,
|
||||
warnTip: false,
|
||||
percentage: 0,
|
||||
intervalId: null // 用于存储定时器ID
|
||||
intervalId: null, // 用于存储定时器ID
|
||||
backActive: false // 打点完成后新增一个弹框 提示用户是否自动开回上一个点
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(['isTop', 'carPosition'])
|
||||
...mapGetters(['isTop', 'carPosition', 'autoLoopEnable', 'autoBackEnable'])
|
||||
},
|
||||
watch: {
|
||||
autoBackEnable (val) {
|
||||
if (this.backActive && val === '0') {
|
||||
this.loading = this.$loading({
|
||||
lock: true,
|
||||
spinner: 'el-icon-loading',
|
||||
background: 'rgba(0, 0, 0, 0.6)'
|
||||
})
|
||||
}
|
||||
if (this.backActive && val === '1') {
|
||||
this.loading.close()
|
||||
}
|
||||
if (this.backActive && val === '2') {
|
||||
this.$confirm(this.$t('Whetherdrivebackpoint'), this.$t('Prompt'), {
|
||||
confirmButtonText: this.$t('yes'),
|
||||
cancelButtonText: this.$t('no'),
|
||||
type: 'warning'
|
||||
}).then(() => {
|
||||
this._sendAutoBack()
|
||||
this.backActive = false
|
||||
}).catch(() => {
|
||||
this.backActive = false
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
beforeDestroy () {
|
||||
document.removeEventListener('keydown', this.handleKeydown);
|
||||
@@ -297,8 +324,10 @@ export default {
|
||||
message: res.message
|
||||
})
|
||||
this.keyPoints.push(this.dataForm.stationCode)
|
||||
this.backActive = true
|
||||
} else {
|
||||
this.$message.error(res.message)
|
||||
this.backActive = false
|
||||
}
|
||||
}
|
||||
this.dialogVisible = false
|
||||
@@ -307,9 +336,11 @@ export default {
|
||||
this.$message.error(e)
|
||||
this.dialogVisible = false
|
||||
this.disabled = false
|
||||
this.backActive = false
|
||||
}
|
||||
},
|
||||
stopMappingConfirm () {
|
||||
if (this.autoLoopEnable !== '1') return
|
||||
if (this.driverActive) return
|
||||
this.$confirm(this.$t('sureendbuilding'), this.$t('Prompt'), {
|
||||
confirmButtonText: this.$t('Confirm'),
|
||||
@@ -443,6 +474,26 @@ export default {
|
||||
this.$refs.glMap.init()
|
||||
})
|
||||
}
|
||||
},
|
||||
// 打点功能点击反馈成功后
|
||||
async _sendAutoBack () {
|
||||
try {
|
||||
this.loading = this.$loading({
|
||||
lock: true,
|
||||
spinner: 'el-icon-loading',
|
||||
background: 'rgba(0, 0, 0, 0.6)'
|
||||
})
|
||||
let res = await sendAutoBack()
|
||||
if (res && res.code === 200) {
|
||||
this.$message({
|
||||
type: 'success',
|
||||
message: res.message
|
||||
})
|
||||
}
|
||||
this.loading.close()
|
||||
} catch (e) {
|
||||
this.loading.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -468,7 +519,7 @@ export default {
|
||||
z-index 2003
|
||||
display flex
|
||||
align-items center
|
||||
font-size .16rem
|
||||
font-size .2rem
|
||||
list-height 1
|
||||
p
|
||||
margin-left 10px
|
||||
@@ -512,6 +563,9 @@ export default {
|
||||
line-height: 0.36rem;
|
||||
color: #fff
|
||||
overflow: hidden
|
||||
.button_control
|
||||
p
|
||||
font-size .26rem
|
||||
.enClass
|
||||
.button_control
|
||||
p
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<el-col class="pp_item" v-for="(e, i) in newData" :key="'new' + i">
|
||||
<el-row type="flex" align="middle">
|
||||
<i class="el-icon-caret-right icon-caret-right" :style="i === 0 ? 'display: none' : ''"></i>
|
||||
<div class="point_item point_checked">
|
||||
<div class="relative point_item point_checked">
|
||||
<el-row type="flex" align="middle" class="zbox">
|
||||
<el-col :span="17">
|
||||
<p class="point_checked_p1">{{ e.station_name }}</p>
|
||||
@@ -46,6 +46,7 @@
|
||||
<p class="point_checked_p2">{{ radioOption | findByValue(e.action_type) }}</p>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-button class="absolute icon-delt" type="danger" icon="el-icon-delete" circle @click="deletePoint(i)"></el-button>
|
||||
</div>
|
||||
</el-row>
|
||||
</el-col>
|
||||
@@ -352,7 +353,10 @@ export default {
|
||||
this.loading.close()
|
||||
this.disabled = false
|
||||
}
|
||||
}
|
||||
},
|
||||
deletePoint (index) {
|
||||
this.newData.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -476,6 +480,17 @@ export default {
|
||||
.button_control_s
|
||||
p
|
||||
font-size .27rem
|
||||
.icon-delt
|
||||
padding 0
|
||||
height: 0.3rem;
|
||||
line-height: .3rem;
|
||||
width: 0.3rem;
|
||||
font-size: .16rem;
|
||||
right: -0.1rem;
|
||||
top: -0.1rem;
|
||||
&:focus, &:hover
|
||||
background-color: #c3390c;
|
||||
border-color: #ed865c;
|
||||
.enClass
|
||||
.button_control p
|
||||
line-height .22rem
|
||||
|
||||
@@ -1,576 +0,0 @@
|
||||
<template>
|
||||
<div class="point-cloud-map">
|
||||
<div ref="canvasContainer" class="canvas-container"></div>
|
||||
<!-- 加载状态指示器 -->
|
||||
<div v-if="isLoading" class="loading-indicator">
|
||||
<i class="fa fa-circle-o-notch fa-spin"></i> 加载中...
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as THREE from 'three'
|
||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
|
||||
import { mapGetters } from 'vuex'
|
||||
// import { points } from '../../config/point.js'
|
||||
// import { points1 } from '../../config/point1.js'
|
||||
// import { points2 } from '../../config/point2.js'
|
||||
export default {
|
||||
/* eslint-disable */
|
||||
data () {
|
||||
return {
|
||||
// Three.js核心对象
|
||||
scene: null,
|
||||
camera: null,
|
||||
renderer: null,
|
||||
controls: null,
|
||||
// 点云相关
|
||||
pointCloudGeometry: null,
|
||||
pointCloudMaterial: null,
|
||||
pointCloudMesh: null,
|
||||
allPoints: [], // 存储所有累加的点云数据
|
||||
pointCount: 0,
|
||||
pointScale: 1.0,
|
||||
// 小车相关
|
||||
vehicleImage: require('../../images/new/agv.png'),
|
||||
carMesh: null,
|
||||
carTexture: null,
|
||||
// WebSocket相关
|
||||
socket: null,
|
||||
isLoading: true,
|
||||
reconnectTimer: null, // 重连计时器
|
||||
// 动画与性能
|
||||
viewSize: 20,
|
||||
animationId: null,
|
||||
lastUpdateTime: 0,
|
||||
updateInterval: 800, // 限制更新频率,毫秒
|
||||
// 点云边界:记录所有点的最大/最小x、y
|
||||
pointBounds: {
|
||||
minX: Infinity, // 初始设为无穷大,方便后续比较
|
||||
maxX: -Infinity,
|
||||
minY: Infinity,
|
||||
maxY: -Infinity
|
||||
},
|
||||
boundMargin: 0.1, // 边界边距(避免点贴屏幕边缘,值为10%的范围)
|
||||
defaultViewSize: 20, // 无点云时的默认可视范围
|
||||
// 标记组件是否已销毁
|
||||
isDestroyed: false,
|
||||
minZoomRatio: 0.5,
|
||||
maxZoomRatio: 3,
|
||||
baseViewSize: 0
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(['serverUrl', , 'userRole', 'carPosition']),
|
||||
},
|
||||
watch: {
|
||||
carPosition: {
|
||||
handler(newVal) {
|
||||
this.updateCarPosition(newVal)
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
// 初始化Three.js场景
|
||||
this.initThreeJs();
|
||||
// 初始化控制器(拖动和缩放)
|
||||
this.initControls();
|
||||
// 初始化点云
|
||||
this.initPointCloud();
|
||||
// 初始化小车模型
|
||||
this.initCar();
|
||||
// 启动动画循环
|
||||
this.startAnimationLoop();
|
||||
// 监听窗口大小变化
|
||||
window.addEventListener('resize', this.handleResize);
|
||||
// 初始加载完成
|
||||
setTimeout(() => {
|
||||
this.isLoading = false;
|
||||
}, 1000);
|
||||
},
|
||||
beforeDestroy () {
|
||||
// 先标记组件已销毁,阻止新消息处理
|
||||
this.isDestroyed = true;
|
||||
// 1. 清理WebSocket(先移除事件监听,再关闭连接)
|
||||
this.closeWebSocket()
|
||||
// 2. 停止动画循环并清理
|
||||
if (this.animationId) {
|
||||
cancelAnimationFrame(this.animationId);
|
||||
this.animationId = null;
|
||||
}
|
||||
// 3. 清理OrbitControls(关键!控制器有内部事件监听)
|
||||
if (this.controls) {
|
||||
this.controls.dispose(); // 控制器自带的清理方法,移除事件监听
|
||||
this.controls = null;
|
||||
}
|
||||
// 4. 清理Three.js场景对象(先从场景移除,再释放资源)
|
||||
if (this.scene) {
|
||||
// 移除点云网格
|
||||
if (this.pointCloudMesh) {
|
||||
this.scene.remove(this.pointCloudMesh);
|
||||
}
|
||||
// 移除小车模型
|
||||
if (this.carMesh) {
|
||||
this.scene.remove(this.carMesh);
|
||||
}
|
||||
// 清空场景(彻底释放所有子对象引用)
|
||||
this.scene.clear();
|
||||
this.scene = null;
|
||||
}
|
||||
// 5. 释放Three.js渲染器及DOM
|
||||
if (this.renderer) {
|
||||
// 从DOM中移除canvas元素(避免残留DOM节点)
|
||||
const container = this.$refs.canvasContainer;
|
||||
if (container && this.renderer.domElement) {
|
||||
container.removeChild(this.renderer.domElement);
|
||||
}
|
||||
// 释放渲染器资源
|
||||
this.renderer.dispose();
|
||||
this.renderer.forceContextLoss(); // 强制释放WebGL上下文(关键!)
|
||||
this.renderer = null;
|
||||
}
|
||||
// 6. 释放几何体和材质(确保先移除引用)
|
||||
if (this.pointCloudGeometry) {
|
||||
this.pointCloudGeometry.dispose();
|
||||
this.pointCloudGeometry = null;
|
||||
}
|
||||
if (this.pointCloudMaterial) {
|
||||
// 先清除材质的纹理引用(避免纹理无法释放)
|
||||
this.pointCloudMaterial.map = null;
|
||||
this.pointCloudMaterial.dispose();
|
||||
this.pointCloudMaterial = null;
|
||||
}
|
||||
// 7. 释放小车纹理(确保材质不再引用)
|
||||
if (this.carTexture) {
|
||||
this.carTexture.dispose();
|
||||
this.carTexture = null;
|
||||
}
|
||||
// 8. 移除窗口大小变化监听(避免重复监听)
|
||||
window.removeEventListener('resize', this.handleResize);
|
||||
// 9. 清空其他引用(帮助GC回收)
|
||||
this.camera = null;
|
||||
this.carMesh = null;
|
||||
this.pointCloudMesh = null;
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* 初始化Three.js场景、相机和渲染器
|
||||
*/
|
||||
initThreeJs() {
|
||||
const container = this.$refs.canvasContainer;
|
||||
const aspect = container.clientWidth / container.clientHeight
|
||||
|
||||
// 创建场景
|
||||
this.scene = new THREE.Scene();
|
||||
|
||||
// 创建正交相机(适合2D场景)
|
||||
this.camera = new THREE.OrthographicCamera(
|
||||
-this.viewSize * aspect / 2, // left
|
||||
this.viewSize * aspect / 2, // right
|
||||
this.viewSize / 2, // top
|
||||
-this.viewSize / 2, // bottom
|
||||
0.1, // near
|
||||
1000 // far
|
||||
);
|
||||
this.camera.position.z = 100; // 保持在Z轴上方,不影响2D视图
|
||||
|
||||
// 创建渲染器 - 启用alpha通道实现透明背景
|
||||
this.renderer = new THREE.WebGLRenderer({
|
||||
antialias: true,
|
||||
alpha: true, // 关键:启用alpha通道
|
||||
transparent: true // 关键:允许透明
|
||||
});
|
||||
this.renderer.setSize(container.clientWidth, container.clientHeight);
|
||||
this.renderer.setPixelRatio(window.devicePixelRatio);
|
||||
// 可选:设置clearAlpha确保完全透明
|
||||
this.renderer.setClearAlpha(0);
|
||||
container.appendChild(this.renderer.domElement);
|
||||
},
|
||||
|
||||
/**
|
||||
* 初始化控制器,支持拖动和缩放
|
||||
*/
|
||||
initControls() {
|
||||
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
|
||||
this.controls.enableRotate = false; // 禁用旋转,保持2D视图
|
||||
this.controls.enableZoom = true; // 启用缩放
|
||||
this.controls.enablePan = true; // 启用平移(拖动)
|
||||
this.controls.screenSpacePanning = true; // 2D平移模式
|
||||
this.controls.touchZoomSpeed = 1;
|
||||
this.controls.panSpeed = 1;
|
||||
this.controls.touches = {
|
||||
ONE: THREE.TOUCH.PAN, // 单指拖动为平移
|
||||
TWO: THREE.TOUCH.DOLLY_PAN // 双指拖动为缩放和平移
|
||||
};
|
||||
|
||||
// 添加错误处理逻辑
|
||||
this.controls.addEventListener('error', (event) => {
|
||||
console.error('OrbitControls error:', event);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 初始化点云
|
||||
*/
|
||||
initPointCloud() {
|
||||
// 使用BufferGeometry提高大量点的渲染性能
|
||||
this.pointCloudGeometry = new THREE.BufferGeometry();
|
||||
|
||||
// 点材质设置
|
||||
this.pointCloudMaterial = new THREE.PointsMaterial({
|
||||
color: 0xFFFFFF,
|
||||
size: 1.5, // 初始值会被动态覆盖
|
||||
sizeAttenuation: true, // 关键!
|
||||
transparent: true,
|
||||
opacity: 1
|
||||
});
|
||||
|
||||
// 创建点云网格
|
||||
this.pointCloudMesh = new THREE.Points(
|
||||
this.pointCloudGeometry,
|
||||
this.pointCloudMaterial
|
||||
);
|
||||
this.scene.add(this.pointCloudMesh);
|
||||
// 初始化历史数据为空
|
||||
this.allPoints = [];
|
||||
},
|
||||
|
||||
/**
|
||||
* 初始化小车模型
|
||||
*/
|
||||
initCar() {
|
||||
// 加载小车图片作为精灵
|
||||
const loader = new THREE.TextureLoader();
|
||||
this.carTexture = loader.load(this.vehicleImage, (texture) => {
|
||||
const material = new THREE.SpriteMaterial({
|
||||
map: texture,
|
||||
transparent: true, // 保留图片透明区域
|
||||
opacity: 1,
|
||||
depthWrite: false // 避免被点云遮挡(可选,根据层级需求调整)
|
||||
});
|
||||
|
||||
this.carMesh = new THREE.Sprite(material);
|
||||
this.scene.add(this.carMesh);
|
||||
|
||||
// 初始计算小车尺寸
|
||||
this.calculateCarSize();
|
||||
|
||||
// 初始位置角度更新
|
||||
if (this.carPosition) {
|
||||
this.updateCarPosition(this.carPosition);
|
||||
}
|
||||
}, undefined, (error) => {
|
||||
console.error('小车图片加载失败:', error);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 单独的小车尺寸计算方法,确保窗口缩放时可复用
|
||||
*/
|
||||
calculateCarSize() {
|
||||
if (!this.carMesh) return;
|
||||
|
||||
const container = this.$refs.canvasContainer;
|
||||
const canvasWidth = container.clientWidth;
|
||||
const canvasHeight = container.clientHeight;
|
||||
const aspect = canvasWidth / canvasHeight;
|
||||
|
||||
// 核心计算:将50px转换为Three.js世界坐标
|
||||
// 公式:(目标像素尺寸 / 画布尺寸) * 相机可视范围 * 校正系数
|
||||
const spriteSizeX = (50 / canvasWidth) * this.viewSize * aspect;
|
||||
const spriteSizeY = (50 / canvasHeight) * this.viewSize;
|
||||
|
||||
this.carMesh.scale.set(spriteSizeX, spriteSizeY, 1);
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新小车位置和角度
|
||||
*/
|
||||
updateCarPosition(position) {
|
||||
if (this.carMesh && position) {
|
||||
this.carMesh.position.x = position.x * this.pointScale;
|
||||
this.carMesh.position.y = position.y * this.pointScale;
|
||||
this.carMesh.position.z = 1;
|
||||
// 转换角度为弧度并调整方向(Three.js使用弧度)
|
||||
this.carMesh.rotation.z = position.angle;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 初始化WebSocket连接
|
||||
*/
|
||||
initWebSocket() {
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
|
||||
const wsHost = this.serverUrl.replace(/^https?:\/\//, '')
|
||||
this.socket = new WebSocket(`ws://${wsHost}/webSocket/PointCloudData/${this.userRole}`);
|
||||
|
||||
this.socket.onopen = () => {
|
||||
console.log('WebSocket连接已建立');
|
||||
this.isLoading = false;
|
||||
};
|
||||
|
||||
this.socket.onmessage = (event) => {
|
||||
// 组件已销毁则直接返回,不处理消息
|
||||
if (this.isDestroyed) return
|
||||
try {
|
||||
// 限制更新频率,优化性能
|
||||
const now = Date.now();
|
||||
if (now - this.lastUpdateTime < this.updateInterval) {
|
||||
return;
|
||||
}
|
||||
this.lastUpdateTime = now;
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
|
||||
const pointData = JSON.parse(event.data);
|
||||
this.updatePointCloud(pointData.data);
|
||||
} catch (error) {
|
||||
console.error('解析点云数据失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
this.socket.onclose = (event) => {
|
||||
console.log('WebSocket连接已关闭,代码:', event.code);
|
||||
this.isLoading = true;
|
||||
// 自动重连
|
||||
this.reconnectTimer = setTimeout(() => this.initWebSocket(), 3000);
|
||||
};
|
||||
|
||||
this.socket.onerror = (error) => {
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
|
||||
console.error('WebSocket错误:', error);
|
||||
this.isLoading = true;
|
||||
};
|
||||
},
|
||||
|
||||
closeWebSocket () {
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
|
||||
if (this.socket) {
|
||||
this.socket.onopen = null;
|
||||
this.socket.onmessage = null;
|
||||
this.socket.onclose = null;
|
||||
this.socket.onerror = null;
|
||||
this.socket.close(1000, '组件销毁');
|
||||
this.socket = null;
|
||||
}
|
||||
this.allPoints = []
|
||||
},
|
||||
|
||||
init () {
|
||||
this.initWebSocket();
|
||||
// const pointData = points.data
|
||||
// this.updatePointCloud(pointData);
|
||||
// setTimeout(() => {
|
||||
// const arr = points1.data
|
||||
// this.updatePointCloud(arr);
|
||||
// }, 1000)
|
||||
// setTimeout(() => {
|
||||
// const arr = points2.data
|
||||
// this.updatePointCloud(arr);
|
||||
// }, 2000)
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新点云数据,优化大量点的渲染性能
|
||||
*/
|
||||
updatePointCloud(points) {
|
||||
if (!Array.isArray(points) || points.length === 0) return;
|
||||
|
||||
// 用于跟踪已存在的点,使用"x,y"作为唯一标识
|
||||
const existingPoints = {};
|
||||
// 先将已有点添加到跟踪对象
|
||||
this.allPoints.forEach(point => {
|
||||
const key = `${point.x},${point.y}`;
|
||||
existingPoints[key] = true;
|
||||
});
|
||||
|
||||
// 过滤新点中的重复点
|
||||
const newUniquePoints = points.filter(point => {
|
||||
const key = `${point.x},${point.y}`;
|
||||
if (!existingPoints[key]) {
|
||||
existingPoints[key] = true;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
this.allPoints = [...this.allPoints, ...newUniquePoints];
|
||||
this.pointCount = this.allPoints.length; // 更新总点数
|
||||
|
||||
// 重新计算所有点云的边界
|
||||
this.pointBounds = { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity };
|
||||
this.allPoints.forEach(point => {
|
||||
const scaledX = (point.x || 0) * this.pointScale; // 缩放后的实际坐标
|
||||
const scaledY = (point.y || 0) * this.pointScale;
|
||||
// 更新最大/最小值
|
||||
this.pointBounds.minX = Math.min(this.pointBounds.minX, scaledX);
|
||||
this.pointBounds.maxX = Math.max(this.pointBounds.maxX, scaledX);
|
||||
this.pointBounds.minY = Math.min(this.pointBounds.minY, scaledY);
|
||||
this.pointBounds.maxY = Math.max(this.pointBounds.maxY, scaledY);
|
||||
});
|
||||
this.adjustCameraByBounds();
|
||||
|
||||
// 动态扩展缓冲区
|
||||
const positionAttribute = this.pointCloudGeometry.getAttribute('position');
|
||||
let positions;
|
||||
|
||||
if (!positionAttribute) {
|
||||
// 首次初始化:创建缓冲区
|
||||
positions = new Float32Array(this.pointCount * 3);
|
||||
this.pointCloudGeometry.setAttribute(
|
||||
'position',
|
||||
new THREE.BufferAttribute(positions, 3)
|
||||
);
|
||||
} else {
|
||||
// 后续更新:扩展现有缓冲区(避免重建)
|
||||
positions = positionAttribute.array;
|
||||
const newLength = this.pointCount * 3;
|
||||
if (newLength > positions.length) {
|
||||
// 扩展时多预留20%空间,减少频繁扩展
|
||||
const newPositions = new Float32Array(Math.ceil(newLength * 1.2));
|
||||
newPositions.set(positions); // 复制原有数据
|
||||
positions = newPositions;
|
||||
this.pointCloudGeometry.setAttribute(
|
||||
'position',
|
||||
new THREE.BufferAttribute(positions, 3)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 填充新数据(仅更新新增部分,减少重复计算)
|
||||
const startIndex = (this.pointCount - newUniquePoints.length) * 3; // 从新增点开始
|
||||
for (let i = 0; i < newUniquePoints.length; i++) {
|
||||
const point = newUniquePoints[i];
|
||||
const index = startIndex + i * 3;
|
||||
positions[index] = (point.x || 0) * this.pointScale;
|
||||
positions[index + 1] = (point.y || 0) * this.pointScale;
|
||||
positions[index + 2] = 0;
|
||||
}
|
||||
|
||||
// 标记更新
|
||||
this.pointCloudGeometry.getAttribute('position').needsUpdate = true;
|
||||
},
|
||||
|
||||
/**
|
||||
* 根据边界动态调整相机可视范围
|
||||
*/
|
||||
adjustCameraByBounds() {
|
||||
const container = this.$refs.canvasContainer;
|
||||
const aspect = container.clientWidth / container.clientHeight; // 画布宽高比
|
||||
|
||||
// 1. 计算点云的实际范围(宽和高)
|
||||
const pointRangeX = this.pointBounds.maxX - this.pointBounds.minX;
|
||||
const pointRangeY = this.pointBounds.maxY - this.pointBounds.minY;
|
||||
|
||||
// 2. 计算“基准可视范围”(刚好覆盖点云+边距的最小范围)
|
||||
let baseViewSize = this.defaultViewSize; // 默认值(无点云时)
|
||||
if (this.allPoints.length > 0 && !(pointRangeX === 0 && pointRangeY === 0)) {
|
||||
const requiredViewSizeX = pointRangeX * (1 + this.boundMargin); // X方向需覆盖范围
|
||||
const requiredViewSizeY = pointRangeY * (1 + this.boundMargin); // Y方向需覆盖范围
|
||||
// 基准可视范围:取X/Y方向中较大值(确保适配宽高比后覆盖所有点)
|
||||
baseViewSize = Math.max(requiredViewSizeX / aspect, requiredViewSizeY);
|
||||
}
|
||||
this.baseViewSize = baseViewSize; // 缓存基准值,用于后续缩放限制
|
||||
|
||||
// 3. 更新相机可视范围(基于基准值)
|
||||
this.viewSize = baseViewSize; // 初始可视范围=基准范围
|
||||
this.camera.left = -this.viewSize * aspect / 2;
|
||||
this.camera.right = this.viewSize * aspect / 2;
|
||||
this.camera.top = this.viewSize / 2;
|
||||
this.camera.bottom = -this.viewSize / 2;
|
||||
this.camera.updateProjectionMatrix(); // 必须更新投影矩阵,否则不生效
|
||||
|
||||
// 4. 让相机焦点对准点云中心(可选,确保点云在屏幕中间)
|
||||
const centerX = (this.pointBounds.minX + this.pointBounds.maxX) / 2;
|
||||
const centerY = (this.pointBounds.minY + this.pointBounds.maxY) / 2;
|
||||
this.controls.target.set(centerX, centerY, 0); // 控制器目标对准点云中心
|
||||
this.camera.position.set(centerX, centerY, 100); // 相机位置也对准中心(Z轴不变)
|
||||
|
||||
// 5. 关键:设置OrbitControls缩放限制(正交相机专用)
|
||||
if (this.controls) {
|
||||
// 5.1 正交相机缩放限制:minZoom/maxZoom(基于基准可视范围的倍数)
|
||||
// 逻辑:viewSize = baseViewSize / zoomRatio → 缩放倍数越大,viewSize越小(画面放大)
|
||||
this.controls.minZoom = this.minZoomRatio; // 最小缩放倍数→最大viewSize(缩小画面)
|
||||
this.controls.maxZoom = this.maxZoomRatio; // 最大缩放倍数→最小viewSize(放大画面)
|
||||
|
||||
// 5.2 兼容控制器逻辑:设置minDistance/maxDistance(虽正交相机不用,但避免异常)
|
||||
this.controls.minDistance = 50; // 相机最小Z轴距离(小于此值可能穿模)
|
||||
this.controls.maxDistance = 200; // 相机最大Z轴距离(大于此值画面过小)
|
||||
|
||||
// 5.3 锁定缩放中心(可选:确保缩放时以点云中心为基准)
|
||||
this.controls.zoomToCursor = false; // 关闭“鼠标位置缩放”,避免缩放偏移
|
||||
}
|
||||
|
||||
// 相机参数更新后,同步更新小车尺寸
|
||||
this.calculateCarSize();
|
||||
},
|
||||
|
||||
/**
|
||||
* 处理窗口大小变化
|
||||
*/
|
||||
handleResize() {
|
||||
const container = this.$refs.canvasContainer;
|
||||
const width = container.clientWidth;
|
||||
const height = container.clientHeight;
|
||||
|
||||
// 原有渲染器尺寸更新
|
||||
this.renderer.setSize(width, height);
|
||||
|
||||
// 基于当前点云边界重新适配相机(关键)
|
||||
this.adjustCameraByBounds();
|
||||
|
||||
// 窗口缩放时重新计算小车尺寸
|
||||
this.calculateCarSize();
|
||||
},
|
||||
|
||||
/**
|
||||
* 启动动画循环
|
||||
*/
|
||||
startAnimationLoop() {
|
||||
const animate = () => {
|
||||
this.animationId = requestAnimationFrame(animate);
|
||||
|
||||
// 更新控制器(用于阻尼效果)
|
||||
if (this.controls) {
|
||||
this.controls.update();
|
||||
}
|
||||
|
||||
// 渲染场景
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
};
|
||||
|
||||
animate();
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="stylus" scoped>
|
||||
.point-cloud-map
|
||||
position relative
|
||||
width 100%
|
||||
height 100%
|
||||
background-color rgba(4, 33, 58, 70%)
|
||||
box-shadow inset 1px 1px 7px 2px #4d9bcd
|
||||
overflow hidden
|
||||
.canvas-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
.loading-indicator {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
padding: .01rem .02rem;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
font-size: .12rem;
|
||||
color: #333;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,610 +0,0 @@
|
||||
<template>
|
||||
<div class="point-cloud-map">
|
||||
<div ref="canvasContainer" class="canvas-container"></div>
|
||||
<!-- 加载状态指示器 -->
|
||||
<div v-if="isLoading" class="loading-indicator">
|
||||
<i class="fa fa-circle-o-notch fa-spin"></i> {{ $t('Loading') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as THREE from 'three'
|
||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
|
||||
import { mapGetters } from 'vuex'
|
||||
// import { points } from '../../config/point.js'
|
||||
// import { points1 } from '../../config/point1.js'
|
||||
// import { points2 } from '../../config/point2.js'
|
||||
|
||||
// 常量定义
|
||||
const UPDATE_INTERVAL = 800 // 限制更新频率,毫秒
|
||||
const DEFAULT_VIEW_SIZE = 20 // 无点云时的默认可视范围
|
||||
const BOUND_MARGIN = 0.1 // 边界边距(避免点贴屏幕边缘,值为10%的范围)
|
||||
const MIN_ZOOM_RATIO = 0.5
|
||||
const MAX_ZOOM_RATIO = 3
|
||||
|
||||
export default {
|
||||
name: 'PointCloudMap',
|
||||
data() {
|
||||
return {
|
||||
// Three.js核心对象
|
||||
scene: null,
|
||||
camera: null,
|
||||
renderer: null,
|
||||
controls: null,
|
||||
|
||||
// 点云相关
|
||||
pointCloudGeometry: null,
|
||||
pointCloudMaterial: null,
|
||||
pointCloudMesh: null,
|
||||
allPoints: [], // 存储所有累加的点云数据
|
||||
pointCount: 0,
|
||||
pointScale: 1.0,
|
||||
|
||||
// 小车相关
|
||||
vehicleImage: require('../../images/new/agv.png'),
|
||||
carMesh: null,
|
||||
carTexture: null,
|
||||
|
||||
// WebSocket相关
|
||||
socket: null,
|
||||
isLoading: true,
|
||||
reconnectTimer: null, // 重连计时器
|
||||
|
||||
// 动画与性能
|
||||
viewSize: DEFAULT_VIEW_SIZE,
|
||||
animationId: null,
|
||||
lastUpdateTime: 0,
|
||||
updateInterval: UPDATE_INTERVAL,
|
||||
|
||||
// 点云边界:记录所有点的最大/最小x、y
|
||||
pointBounds: {
|
||||
minX: Infinity, // 初始设为无穷大,方便后续比较
|
||||
maxX: -Infinity,
|
||||
minY: Infinity,
|
||||
maxY: -Infinity
|
||||
},
|
||||
|
||||
// 标记组件是否已销毁
|
||||
isDestroyed: false,
|
||||
baseViewSize: 0
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(['serverUrl', 'userRole', 'carPosition']),
|
||||
},
|
||||
watch: {
|
||||
carPosition: {
|
||||
handler(newVal) {
|
||||
this.updateCarPosition(newVal)
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.initializeComponent()
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.cleanup()
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* 初始化组件
|
||||
*/
|
||||
initializeComponent() {
|
||||
this.initThreeJs()
|
||||
this.initControls()
|
||||
this.initPointCloud()
|
||||
this.initCar()
|
||||
this.startAnimationLoop()
|
||||
window.addEventListener('resize', this.handleResize)
|
||||
|
||||
// 初始加载完成
|
||||
setTimeout(() => {
|
||||
this.isLoading = false
|
||||
}, 1000)
|
||||
},
|
||||
|
||||
/**
|
||||
* 初始化Three.js场景、相机和渲染器
|
||||
*/
|
||||
initThreeJs() {
|
||||
const container = this.$refs.canvasContainer
|
||||
const aspect = container.clientWidth / container.clientHeight
|
||||
|
||||
// 创建场景
|
||||
this.scene = new THREE.Scene()
|
||||
|
||||
// 创建正交相机(适合2D场景)
|
||||
this.camera = new THREE.OrthographicCamera(
|
||||
-this.viewSize * aspect / 2, // left
|
||||
this.viewSize * aspect / 2, // right
|
||||
this.viewSize / 2, // top
|
||||
-this.viewSize / 2, // bottom
|
||||
0.1, // near
|
||||
1000 // far
|
||||
)
|
||||
this.camera.position.z = 100 // 保持在Z轴上方,不影响2D视图
|
||||
|
||||
// 创建渲染器 - 启用alpha通道实现透明背景
|
||||
this.renderer = new THREE.WebGLRenderer({
|
||||
antialias: true,
|
||||
alpha: true, // 关键:启用alpha通道
|
||||
transparent: true // 关键:允许透明
|
||||
})
|
||||
this.renderer.setSize(container.clientWidth, container.clientHeight)
|
||||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) // 限制最大像素比
|
||||
// 可选:设置clearAlpha确保完全透明
|
||||
this.renderer.setClearAlpha(0)
|
||||
container.appendChild(this.renderer.domElement)
|
||||
},
|
||||
|
||||
/**
|
||||
* 初始化控制器,支持拖动和缩放
|
||||
*/
|
||||
initControls() {
|
||||
this.controls = new OrbitControls(this.camera, this.renderer.domElement)
|
||||
this.controls.enableRotate = false // 禁用旋转,保持2D视图
|
||||
this.controls.enableZoom = true // 启用缩放
|
||||
this.controls.enablePan = true // 启用平移(拖动)
|
||||
this.controls.screenSpacePanning = true // 2D平移模式
|
||||
this.controls.touchZoomSpeed = 1
|
||||
this.controls.panSpeed = 1
|
||||
this.controls.touches = {
|
||||
ONE: THREE.TOUCH.PAN, // 单指拖动为平移
|
||||
TWO: THREE.TOUCH.DOLLY_PAN // 双指拖动为缩放和平移
|
||||
}
|
||||
|
||||
// 添加错误处理逻辑
|
||||
this.controls.addEventListener('error', (event) => {
|
||||
console.error('OrbitControls error:', event)
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 初始化点云
|
||||
*/
|
||||
initPointCloud() {
|
||||
// 使用BufferGeometry提高大量点的渲染性能
|
||||
this.pointCloudGeometry = new THREE.BufferGeometry()
|
||||
|
||||
// 点材质设置
|
||||
this.pointCloudMaterial = new THREE.PointsMaterial({
|
||||
color: 0xFFFFFF,
|
||||
size: 1, // 初始值会被动态覆盖
|
||||
sizeAttenuation: true, // 关键!
|
||||
transparent: true,
|
||||
opacity: 1
|
||||
})
|
||||
|
||||
// 创建点云网格
|
||||
this.pointCloudMesh = new THREE.Points(
|
||||
this.pointCloudGeometry,
|
||||
this.pointCloudMaterial
|
||||
)
|
||||
this.scene.add(this.pointCloudMesh)
|
||||
// 初始化历史数据为空
|
||||
this.allPoints = []
|
||||
},
|
||||
|
||||
/**
|
||||
* 初始化小车模型
|
||||
*/
|
||||
initCar() {
|
||||
// 加载小车图片作为精灵
|
||||
const loader = new THREE.TextureLoader()
|
||||
this.carTexture = loader.load(this.vehicleImage, (texture) => {
|
||||
const material = new THREE.SpriteMaterial({
|
||||
map: texture,
|
||||
transparent: true, // 保留图片透明区域
|
||||
opacity: 1,
|
||||
depthWrite: false // 避免被点云遮挡(可选,根据层级需求调整)
|
||||
})
|
||||
|
||||
this.carMesh = new THREE.Sprite(material)
|
||||
this.scene.add(this.carMesh)
|
||||
|
||||
// 初始计算小车尺寸
|
||||
this.calculateCarSize()
|
||||
|
||||
// 初始位置角度更新
|
||||
if (this.carPosition) {
|
||||
this.updateCarPosition(this.carPosition)
|
||||
}
|
||||
}, undefined, (error) => {
|
||||
console.error('小车图片加载失败:', error)
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 单独的小车尺寸计算方法,确保窗口缩放时可复用
|
||||
*/
|
||||
calculateCarSize() {
|
||||
if (!this.carMesh) return
|
||||
|
||||
const container = this.$refs.canvasContainer
|
||||
const canvasWidth = container.clientWidth
|
||||
const canvasHeight = container.clientHeight
|
||||
const aspect = canvasWidth / canvasHeight
|
||||
|
||||
// 核心计算:将50px转换为Three.js世界坐标
|
||||
// 公式:(目标像素尺寸 / 画布尺寸) * 相机可视范围 * 校正系数
|
||||
const spriteSizeX = (50 / canvasWidth) * this.viewSize * aspect
|
||||
const spriteSizeY = (50 / canvasHeight) * this.viewSize
|
||||
|
||||
this.carMesh.scale.set(spriteSizeX, spriteSizeY, 1)
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新小车位置和角度
|
||||
*/
|
||||
updateCarPosition(position) {
|
||||
if (this.carMesh && position) {
|
||||
this.carMesh.position.x = position.x * this.pointScale
|
||||
this.carMesh.position.y = position.y * this.pointScale
|
||||
this.carMesh.position.z = 1
|
||||
// 转换角度为弧度并调整方向(Three.js使用弧度)
|
||||
this.carMesh.rotation.z = position.angle
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 初始化WebSocket连接
|
||||
*/
|
||||
initWebSocket() {
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
|
||||
const wsHost = this.serverUrl.replace(/^https?:\/\//, '')
|
||||
this.socket = new WebSocket(`ws://${wsHost}/webSocket/PointCloudData/${this.userRole}`)
|
||||
|
||||
this.socket.onopen = () => {
|
||||
console.log('WebSocket连接已建立')
|
||||
this.isLoading = false
|
||||
}
|
||||
|
||||
this.socket.onmessage = (event) => {
|
||||
// 组件已销毁则直接返回,不处理消息
|
||||
if (this.isDestroyed) return
|
||||
try {
|
||||
// 限制更新频率,优化性能
|
||||
const now = Date.now()
|
||||
if (now - this.lastUpdateTime < this.updateInterval) {
|
||||
return
|
||||
}
|
||||
this.lastUpdateTime = now
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
|
||||
const pointData = JSON.parse(event.data)
|
||||
this.updatePointCloud(pointData.data)
|
||||
} catch (error) {
|
||||
console.error('解析点云数据失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
this.socket.onclose = (event) => {
|
||||
console.log('WebSocket连接已关闭,代码:', event.code)
|
||||
this.isLoading = true
|
||||
// 自动重连
|
||||
this.reconnectTimer = setTimeout(() => this.initWebSocket(), 3000)
|
||||
}
|
||||
|
||||
this.socket.onerror = (error) => {
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
|
||||
console.error('WebSocket错误:', error)
|
||||
this.isLoading = true
|
||||
}
|
||||
},
|
||||
|
||||
closeWebSocket() {
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
|
||||
if (this.socket) {
|
||||
this.socket.onopen = null
|
||||
this.socket.onmessage = null
|
||||
this.socket.onclose = null
|
||||
this.socket.onerror = null
|
||||
this.socket.close(1000, '组件销毁')
|
||||
this.socket = null
|
||||
}
|
||||
this.allPoints = []
|
||||
},
|
||||
|
||||
init() {
|
||||
this.initWebSocket()
|
||||
// const pointData = points.data
|
||||
// this.updatePointCloud(pointData);
|
||||
// setTimeout(() => {
|
||||
// const arr = points1.data
|
||||
// this.updatePointCloud(arr);
|
||||
// }, 1000)
|
||||
// setTimeout(() => {
|
||||
// const arr = points2.data
|
||||
// this.updatePointCloud(arr);
|
||||
// }, 2000)
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新点云数据,优化大量点的渲染性能
|
||||
*/
|
||||
updatePointCloud(points) {
|
||||
if (!Array.isArray(points) || points.length === 0) return
|
||||
|
||||
// 使用Set数据结构提高查找性能
|
||||
const existingPoints = new Set(this.allPoints.map(p => `${p.x},${p.y}`))
|
||||
|
||||
// 过滤新点中的重复点
|
||||
const newUniquePoints = points.filter(point => {
|
||||
const key = `${point.x},${point.y}`
|
||||
return !existingPoints.has(key)
|
||||
})
|
||||
|
||||
// 如果没有新点,直接返回
|
||||
if (newUniquePoints.length === 0) return
|
||||
|
||||
this.allPoints = [...this.allPoints, ...newUniquePoints]
|
||||
this.pointCount = this.allPoints.length // 更新总点数
|
||||
|
||||
// 批量更新边界计算
|
||||
this.updatePointBounds(newUniquePoints)
|
||||
|
||||
// 更新点云几何数据
|
||||
this.updatePointCloudGeometry(newUniquePoints)
|
||||
},
|
||||
|
||||
/**
|
||||
* 批量更新点云边界
|
||||
*/
|
||||
updatePointBounds(newPoints) {
|
||||
// 批量处理新点,减少循环次数
|
||||
for (const point of newPoints) {
|
||||
const scaledX = (point.x || 0) * this.pointScale
|
||||
const scaledY = (point.y || 0) * this.pointScale
|
||||
|
||||
// 更新最大/最小值
|
||||
if (scaledX < this.pointBounds.minX) this.pointBounds.minX = scaledX
|
||||
if (scaledX > this.pointBounds.maxX) this.pointBounds.maxX = scaledX
|
||||
if (scaledY < this.pointBounds.minY) this.pointBounds.minY = scaledY
|
||||
if (scaledY > this.pointBounds.maxY) this.pointBounds.maxY = scaledY
|
||||
}
|
||||
|
||||
this.adjustCameraByBounds()
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新点云几何数据
|
||||
*/
|
||||
updatePointCloudGeometry(newUniquePoints) {
|
||||
const positionAttribute = this.pointCloudGeometry.getAttribute('position')
|
||||
let positions
|
||||
const newPointsCount = newUniquePoints.length
|
||||
|
||||
if (!positionAttribute) {
|
||||
// 首次初始化:创建缓冲区
|
||||
positions = new Float32Array(this.pointCount * 3)
|
||||
this.pointCloudGeometry.setAttribute(
|
||||
'position',
|
||||
new THREE.BufferAttribute(positions, 3)
|
||||
)
|
||||
} else {
|
||||
// 后续更新:扩展现有缓冲区(避免重建)
|
||||
positions = positionAttribute.array
|
||||
const newLength = this.pointCount * 3
|
||||
|
||||
if (newLength > positions.length) {
|
||||
// 扩展时多预留20%空间,减少频繁扩展
|
||||
const newPositions = new Float32Array(Math.ceil(newLength * 1.2))
|
||||
newPositions.set(positions) // 复制原有数据
|
||||
positions = newPositions
|
||||
this.pointCloudGeometry.setAttribute(
|
||||
'position',
|
||||
new THREE.BufferAttribute(positions, 3)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 填充新数据(仅更新新增部分,减少重复计算)
|
||||
const startIndex = (this.pointCount - newPointsCount) * 3
|
||||
|
||||
// 使用for循环而不是forEach,性能更好
|
||||
for (let i = 0; i < newPointsCount; i++) {
|
||||
const point = newUniquePoints[i]
|
||||
const index = startIndex + i * 3
|
||||
positions[index] = (point.x || 0) * this.pointScale
|
||||
positions[index + 1] = (point.y || 0) * this.pointScale
|
||||
positions[index + 2] = 0
|
||||
}
|
||||
|
||||
// 标记更新
|
||||
this.pointCloudGeometry.getAttribute('position').needsUpdate = true
|
||||
},
|
||||
|
||||
/**
|
||||
* 根据边界动态调整相机可视范围
|
||||
*/
|
||||
adjustCameraByBounds() {
|
||||
const container = this.$refs.canvasContainer
|
||||
const aspect = container.clientWidth / container.clientHeight // 画布宽高比
|
||||
|
||||
// 1. 计算点云的实际范围(宽和高)
|
||||
const pointRangeX = this.pointBounds.maxX - this.pointBounds.minX
|
||||
const pointRangeY = this.pointBounds.maxY - this.pointBounds.minY
|
||||
|
||||
// 2. 计算"基准可视范围"
|
||||
let baseViewSize = DEFAULT_VIEW_SIZE // 默认值(无点云时)
|
||||
if (this.allPoints.length > 0 && !(pointRangeX === 0 && pointRangeY === 0)) {
|
||||
const requiredViewSizeX = pointRangeX * (1 + BOUND_MARGIN) // X方向需覆盖范围
|
||||
const requiredViewSizeY = pointRangeY * (1 + BOUND_MARGIN) // Y方向需覆盖范围
|
||||
// 基准可视范围:取X/Y方向中较大值(确保适配宽高比后覆盖所有点)
|
||||
baseViewSize = Math.max(requiredViewSizeX / aspect, requiredViewSizeY)
|
||||
}
|
||||
this.baseViewSize = baseViewSize // 缓存基准值,用于后续缩放限制
|
||||
|
||||
// 3. 更新相机可视范围(基于基准值)
|
||||
this.viewSize = baseViewSize // 初始可视范围=基准范围
|
||||
this.camera.left = -this.viewSize * aspect / 2
|
||||
this.camera.right = this.viewSize * aspect / 2
|
||||
this.camera.top = this.viewSize / 2
|
||||
this.camera.bottom = -this.viewSize / 2
|
||||
this.camera.updateProjectionMatrix() // 必须更新投影矩阵,否则不生效
|
||||
|
||||
// 4. 让相机焦点对准点云中心
|
||||
const centerX = (this.pointBounds.minX + this.pointBounds.maxX) / 2
|
||||
const centerY = (this.pointBounds.minY + this.pointBounds.maxY) / 2
|
||||
this.controls.target.set(centerX, centerY, 0) // 控制器目标对准点云中心
|
||||
this.camera.position.set(centerX, centerY, 100) // 相机位置也对准中心(Z轴不变)
|
||||
|
||||
// 5. 关键:设置OrbitControls缩放限制(正交相机专用)
|
||||
if (this.controls) {
|
||||
this.controls.minZoom = MIN_ZOOM_RATIO
|
||||
this.controls.maxZoom = MAX_ZOOM_RATIO
|
||||
this.controls.minDistance = 50
|
||||
this.controls.maxDistance = 200
|
||||
this.controls.zoomToCursor = false // 关闭"鼠标位置缩放",避免缩放偏移
|
||||
}
|
||||
|
||||
// 相机参数更新后,同步更新小车尺寸
|
||||
this.calculateCarSize()
|
||||
},
|
||||
|
||||
/**
|
||||
* 处理窗口大小变化
|
||||
*/
|
||||
handleResize() {
|
||||
const container = this.$refs.canvasContainer
|
||||
const width = container.clientWidth
|
||||
const height = container.clientHeight
|
||||
|
||||
// 原有渲染器尺寸更新
|
||||
this.renderer.setSize(width, height)
|
||||
|
||||
// 基于当前点云边界重新适配相机(关键)
|
||||
this.adjustCameraByBounds()
|
||||
|
||||
// 窗口缩放时重新计算小车尺寸
|
||||
this.calculateCarSize()
|
||||
},
|
||||
|
||||
/**
|
||||
* 启动动画循环
|
||||
*/
|
||||
startAnimationLoop() {
|
||||
const animate = () => {
|
||||
this.animationId = requestAnimationFrame(animate)
|
||||
|
||||
// 更新控制器(用于阻尼效果)
|
||||
if (this.controls) {
|
||||
this.controls.update()
|
||||
}
|
||||
|
||||
// 渲染场景
|
||||
this.renderer.render(this.scene, this.camera)
|
||||
}
|
||||
|
||||
animate()
|
||||
},
|
||||
|
||||
/**
|
||||
* 清理资源
|
||||
*/
|
||||
cleanup() {
|
||||
// 先标记组件已销毁,阻止新消息处理
|
||||
this.isDestroyed = true
|
||||
|
||||
// 1. 清理WebSocket
|
||||
this.closeWebSocket()
|
||||
|
||||
// 2. 停止动画循环
|
||||
if (this.animationId) {
|
||||
cancelAnimationFrame(this.animationId)
|
||||
this.animationId = null
|
||||
}
|
||||
|
||||
// 3. 清理OrbitControls
|
||||
if (this.controls) {
|
||||
this.controls.dispose()
|
||||
this.controls = null
|
||||
}
|
||||
|
||||
// 4. 清理Three.js场景对象
|
||||
if (this.scene) {
|
||||
// 移除点云网格
|
||||
if (this.pointCloudMesh) {
|
||||
this.scene.remove(this.pointCloudMesh)
|
||||
}
|
||||
// 移除小车模型
|
||||
if (this.carMesh) {
|
||||
this.scene.remove(this.carMesh)
|
||||
}
|
||||
// 清空场景
|
||||
this.scene.clear()
|
||||
this.scene = null
|
||||
}
|
||||
|
||||
// 5. 释放Three.js渲染器及DOM
|
||||
if (this.renderer) {
|
||||
const container = this.$refs.canvasContainer
|
||||
if (container && this.renderer.domElement) {
|
||||
container.removeChild(this.renderer.domElement)
|
||||
}
|
||||
this.renderer.dispose()
|
||||
this.renderer.forceContextLoss()
|
||||
this.renderer = null
|
||||
}
|
||||
|
||||
// 6. 释放几何体和材质
|
||||
if (this.pointCloudGeometry) {
|
||||
this.pointCloudGeometry.dispose()
|
||||
this.pointCloudGeometry = null
|
||||
}
|
||||
|
||||
if (this.pointCloudMaterial) {
|
||||
this.pointCloudMaterial.map = null
|
||||
this.pointCloudMaterial.dispose()
|
||||
this.pointCloudMaterial = null
|
||||
}
|
||||
|
||||
// 7. 释放小车纹理
|
||||
if (this.carTexture) {
|
||||
this.carTexture.dispose()
|
||||
this.carTexture = null
|
||||
}
|
||||
|
||||
// 8. 移除窗口大小变化监听
|
||||
window.removeEventListener('resize', this.handleResize)
|
||||
|
||||
// 9. 清空其他引用
|
||||
this.camera = null
|
||||
this.carMesh = null
|
||||
this.pointCloudMesh = null
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="stylus" scoped>
|
||||
.point-cloud-map
|
||||
position relative
|
||||
width 100%
|
||||
height 100%
|
||||
background-color rgba(4, 33, 58, 70%)
|
||||
box-shadow inset 1px 1px 7px 2px #4d9bcd
|
||||
overflow hidden
|
||||
|
||||
.canvas-container
|
||||
width: 100%
|
||||
height: 100%
|
||||
position: relative
|
||||
z-index: 0
|
||||
|
||||
.loading-indicator
|
||||
position: absolute
|
||||
top: 50%
|
||||
left: 50%
|
||||
transform: translate(-50%, -50%)
|
||||
background-color: rgba(255, 255, 255, 0.8)
|
||||
padding: .01rem .02rem
|
||||
border-radius: 4px
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1)
|
||||
font-size: .12rem
|
||||
color: #333
|
||||
z-index: 100
|
||||
display: flex
|
||||
align-items: center
|
||||
</style>
|
||||
@@ -1,576 +0,0 @@
|
||||
<template>
|
||||
<div class="point-cloud-map">
|
||||
<div ref="canvasContainer" class="canvas-container"></div>
|
||||
<!-- 定位按钮 -->
|
||||
<el-button icon="el-icon-location" size="mini" class="locate-button" @click="locateCarToCenter">
|
||||
</el-button>
|
||||
<div v-if="isLoading" class="loading-indicator">
|
||||
<i class="fa fa-circle-o-notch fa-spin"></i> {{ $t('Loading') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as THREE from 'three'
|
||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
|
||||
import { mapGetters } from 'vuex'
|
||||
// import { points } from '../../config/point.js'
|
||||
// import { points1 } from '../../config/point1.js'
|
||||
// import { points2 } from '../../config/point2.js'
|
||||
|
||||
// 常量定义
|
||||
const UPDATE_INTERVAL = 800
|
||||
const DEFAULT_VIEW_SIZE = 20
|
||||
const BOUND_MARGIN = 0.1
|
||||
const MIN_ZOOM_RATIO = 0.5
|
||||
const MAX_ZOOM_RATIO = 3
|
||||
const AUTO_ADJUST_THRESHOLD = 100 // 每新增100个点检查是否需要调整视口
|
||||
|
||||
export default {
|
||||
name: 'PointCloudMap',
|
||||
data() {
|
||||
return {
|
||||
// Three.js核心对象
|
||||
scene: null,
|
||||
camera: null,
|
||||
renderer: null,
|
||||
controls: null,
|
||||
|
||||
// 点云相关
|
||||
pointCloudGeometry: null,
|
||||
pointCloudMaterial: null,
|
||||
pointCloudMesh: null,
|
||||
allPoints: [],
|
||||
pointCount: 0,
|
||||
pointScale: 1.0,
|
||||
newPointsSinceLastAdjust: 0, // 新增点计数器
|
||||
|
||||
// 小车相关
|
||||
vehicleImage: require('../../images/new/agv.png'),
|
||||
carMesh: null,
|
||||
carTexture: null,
|
||||
|
||||
// WebSocket相关
|
||||
socket: null,
|
||||
isLoading: true,
|
||||
reconnectTimer: null,
|
||||
|
||||
// 动画与性能
|
||||
viewSize: DEFAULT_VIEW_SIZE,
|
||||
animationId: null,
|
||||
lastUpdateTime: 0,
|
||||
updateInterval: UPDATE_INTERVAL,
|
||||
|
||||
// 点云边界
|
||||
pointBounds: {
|
||||
minX: Infinity,
|
||||
maxX: -Infinity,
|
||||
minY: Infinity,
|
||||
maxY: -Infinity
|
||||
},
|
||||
|
||||
isDestroyed: false,
|
||||
baseViewSize: 0,
|
||||
// 画布尺寸(正方形)
|
||||
canvasSize: {
|
||||
width: 0,
|
||||
height: 0
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(['serverUrl', 'userRole', 'carPosition']),
|
||||
},
|
||||
watch: {
|
||||
carPosition: {
|
||||
handler(newVal) {
|
||||
this.updateCarPosition(newVal)
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.initializeComponent()
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.cleanup()
|
||||
},
|
||||
methods: {
|
||||
initializeComponent() {
|
||||
// 设置画布为正方形(仅初始化时设置一次)
|
||||
this.setCanvasSizeAsSquare()
|
||||
this.initThreeJs()
|
||||
this.initControls()
|
||||
this.initPointCloud()
|
||||
this.initCar()
|
||||
this.startAnimationLoop()
|
||||
},
|
||||
|
||||
/**
|
||||
* 设置画布为正方形(宽高等于容器初始宽度)
|
||||
*/
|
||||
setCanvasSizeAsSquare() {
|
||||
const container = this.$refs.canvasContainer
|
||||
// 获取容器初始宽度
|
||||
const containerWidth = container.clientWidth
|
||||
// 设置宽高相等(正方形),仅初始化时计算一次
|
||||
this.canvasSize.width = containerWidth
|
||||
this.canvasSize.height = containerWidth
|
||||
},
|
||||
|
||||
initThreeJs() {
|
||||
const container = this.$refs.canvasContainer
|
||||
const aspect = this.canvasSize.width / this.canvasSize.height // 正方形画布,aspect=1
|
||||
|
||||
this.scene = new THREE.Scene()
|
||||
|
||||
this.camera = new THREE.OrthographicCamera(
|
||||
-this.viewSize * aspect / 2,
|
||||
this.viewSize * aspect / 2,
|
||||
this.viewSize / 2,
|
||||
-this.viewSize / 2,
|
||||
0.1,
|
||||
1000
|
||||
)
|
||||
this.camera.position.z = 100
|
||||
|
||||
this.renderer = new THREE.WebGLRenderer({
|
||||
antialias: true,
|
||||
alpha: true,
|
||||
transparent: true
|
||||
})
|
||||
// 使用初始化时计算的正方形尺寸
|
||||
this.renderer.setSize(this.canvasSize.width, this.canvasSize.height)
|
||||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
|
||||
this.renderer.setClearAlpha(0)
|
||||
container.appendChild(this.renderer.domElement)
|
||||
},
|
||||
|
||||
initControls() {
|
||||
this.controls = new OrbitControls(this.camera, this.renderer.domElement)
|
||||
this.controls.enableRotate = false
|
||||
this.controls.enableZoom = true
|
||||
this.controls.enablePan = true
|
||||
this.controls.screenSpacePanning = true
|
||||
this.controls.touchZoomSpeed = 1
|
||||
this.controls.panSpeed = 1
|
||||
this.controls.touches = {
|
||||
ONE: THREE.TOUCH.PAN,
|
||||
TWO: THREE.TOUCH.DOLLY_PAN
|
||||
}
|
||||
|
||||
this.controls.addEventListener('error', (event) => {
|
||||
console.error('OrbitControls error:', event)
|
||||
})
|
||||
},
|
||||
|
||||
initPointCloud() {
|
||||
this.pointCloudGeometry = new THREE.BufferGeometry()
|
||||
this.pointCloudMaterial = new THREE.PointsMaterial({
|
||||
color: 0xFFFFFF,
|
||||
size: 1,
|
||||
sizeAttenuation: true,
|
||||
transparent: true,
|
||||
opacity: 1
|
||||
})
|
||||
this.pointCloudMesh = new THREE.Points(
|
||||
this.pointCloudGeometry,
|
||||
this.pointCloudMaterial
|
||||
)
|
||||
this.scene.add(this.pointCloudMesh)
|
||||
this.allPoints = []
|
||||
},
|
||||
|
||||
initCar() {
|
||||
const loader = new THREE.TextureLoader()
|
||||
this.carTexture = loader.load(this.vehicleImage, (texture) => {
|
||||
const material = new THREE.SpriteMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
opacity: 1,
|
||||
depthWrite: false
|
||||
})
|
||||
|
||||
this.carMesh = new THREE.Sprite(material)
|
||||
this.scene.add(this.carMesh)
|
||||
|
||||
this.calculateCarSize()
|
||||
|
||||
if (this.carPosition) {
|
||||
this.updateCarPosition(this.carPosition)
|
||||
// 初始时将小车定位到中心
|
||||
this.locateCarToCenter()
|
||||
}
|
||||
}, undefined, (error) => {
|
||||
console.error('小车图片加载失败:', error)
|
||||
})
|
||||
},
|
||||
|
||||
calculateCarSize() {
|
||||
if (!this.carMesh) return
|
||||
|
||||
// 基于初始化的正方形画布计算小车尺寸(不再更新)
|
||||
const spriteSizeX = (50 / this.canvasSize.width) * this.viewSize * (this.canvasSize.width / this.canvasSize.height)
|
||||
const spriteSizeY = (50 / this.canvasSize.height) * this.viewSize
|
||||
|
||||
this.carMesh.scale.set(spriteSizeX, spriteSizeY, 1)
|
||||
},
|
||||
|
||||
updateCarPosition(position) {
|
||||
if (this.carMesh && position) {
|
||||
this.carMesh.position.x = position.x * this.pointScale
|
||||
this.carMesh.position.y = position.y * this.pointScale
|
||||
this.carMesh.position.z = 1
|
||||
this.carMesh.rotation.z = position.angle
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 定位按钮点击事件 - 将小车置于视窗中心
|
||||
*/
|
||||
locateCarToCenter() {
|
||||
if (!this.carMesh || !this.carPosition || !this.controls) return
|
||||
|
||||
// 计算小车在世界坐标系中的位置
|
||||
const carWorldX = this.carPosition.x * this.pointScale
|
||||
const carWorldY = this.carPosition.y * this.pointScale
|
||||
|
||||
// 将相机和控制器目标设置为小车位置,实现居中
|
||||
this.controls.target.set(carWorldX, carWorldY, 0)
|
||||
this.camera.position.set(carWorldX, carWorldY, 100)
|
||||
this.controls.update() // 立即更新控制器状态
|
||||
},
|
||||
|
||||
initWebSocket() {
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
|
||||
const protocol = this.serverUrl.startsWith('https') ? 'wss' : 'ws'
|
||||
const wsHost = this.serverUrl.replace(/^https?:\/\//, '')
|
||||
this.socket = new WebSocket(`${protocol}://${wsHost}/webSocket/PointCloudData/${this.userRole}`)
|
||||
|
||||
this.socket.onopen = () => {
|
||||
console.log('WebSocket连接已建立')
|
||||
this.isLoading = false
|
||||
}
|
||||
|
||||
this.socket.onmessage = (event) => {
|
||||
if (this.isDestroyed) return
|
||||
try {
|
||||
const now = Date.now()
|
||||
if (now - this.lastUpdateTime < this.updateInterval) {
|
||||
return
|
||||
}
|
||||
this.lastUpdateTime = now
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
|
||||
const pointData = JSON.parse(event.data)
|
||||
this.updatePointCloud(pointData.data)
|
||||
} catch (error) {
|
||||
console.error('解析点云数据失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
this.socket.onclose = (event) => {
|
||||
console.log('WebSocket连接已关闭,代码:', event.code)
|
||||
this.isLoading = true
|
||||
this.reconnectTimer = setTimeout(() => this.initWebSocket(), 3000)
|
||||
}
|
||||
|
||||
this.socket.onerror = (error) => {
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
|
||||
console.error('WebSocket错误:', error)
|
||||
this.isLoading = true
|
||||
}
|
||||
},
|
||||
|
||||
closeWebSocket() {
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
|
||||
if (this.socket) {
|
||||
this.socket.onopen = null
|
||||
this.socket.onmessage = null
|
||||
this.socket.onclose = null
|
||||
this.socket.onerror = null
|
||||
this.socket.close(1000, '组件销毁')
|
||||
this.socket = null
|
||||
}
|
||||
this.allPoints = []
|
||||
},
|
||||
|
||||
init() {
|
||||
this.initWebSocket()
|
||||
// this.isLoading = false;
|
||||
// const pointData = points.data
|
||||
// this.updatePointCloud(pointData);
|
||||
// setTimeout(() => {
|
||||
// const arr = points1.data
|
||||
// this.updatePointCloud(arr);
|
||||
// }, 1000)
|
||||
// setTimeout(() => {
|
||||
// const arr = points2.data
|
||||
// this.updatePointCloud(arr);
|
||||
// }, 2000)
|
||||
},
|
||||
|
||||
updatePointCloud(points) {
|
||||
if (!Array.isArray(points) || points.length === 0) return
|
||||
|
||||
const existingPoints = new Set(this.allPoints.map(p => `${p.x},${p.y}`))
|
||||
const newUniquePoints = points.filter(point => {
|
||||
const key = `${point.x},${point.y}`
|
||||
return !existingPoints.has(key)
|
||||
})
|
||||
|
||||
if (newUniquePoints.length === 0) return
|
||||
|
||||
this.allPoints = [...this.allPoints, ...newUniquePoints]
|
||||
this.pointCount = this.allPoints.length
|
||||
this.newPointsSinceLastAdjust += newUniquePoints.length
|
||||
|
||||
this.updatePointBounds(newUniquePoints)
|
||||
|
||||
this.updatePointCloudGeometry(newUniquePoints)
|
||||
|
||||
// 定期检查是否需要调整视口
|
||||
if (this.newPointsSinceLastAdjust >= AUTO_ADJUST_THRESHOLD) {
|
||||
this.checkAndAdjustViewport()
|
||||
this.newPointsSinceLastAdjust = 0
|
||||
}
|
||||
},
|
||||
|
||||
updatePointBounds(newPoints) {
|
||||
for (const point of newPoints) {
|
||||
const scaledX = (point.x || 0) * this.pointScale
|
||||
const scaledY = (point.y || 0) * this.pointScale
|
||||
|
||||
if (scaledX < this.pointBounds.minX) this.pointBounds.minX = scaledX
|
||||
if (scaledX > this.pointBounds.maxX) this.pointBounds.maxX = scaledX
|
||||
if (scaledY < this.pointBounds.minY) this.pointBounds.minY = scaledY
|
||||
if (scaledY > this.pointBounds.maxY) this.pointBounds.maxY = scaledY
|
||||
}
|
||||
|
||||
// 仅在首次加载点云时自动适配
|
||||
if (this.pointCount === newPoints.length) {
|
||||
this.adjustCameraByBounds()
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 检查并调整视口
|
||||
*/
|
||||
checkAndAdjustViewport() {
|
||||
const aspect = this.canvasSize.width / this.canvasSize.height
|
||||
const pointRangeX = this.pointBounds.maxX - this.pointBounds.minX
|
||||
const pointRangeY = this.pointBounds.maxY - this.pointBounds.minY
|
||||
|
||||
// 计算当前视口能显示的范围
|
||||
const currentViewWidth = this.viewSize * aspect
|
||||
const currentViewHeight = this.viewSize
|
||||
|
||||
// 如果点云范围超过当前视口的80%,则调整视口
|
||||
if (pointRangeX > currentViewWidth * 0.8 || pointRangeY > currentViewHeight * 0.8) {
|
||||
this.adjustCameraByBounds()
|
||||
}
|
||||
},
|
||||
|
||||
updatePointCloudGeometry(newUniquePoints) {
|
||||
const positionAttribute = this.pointCloudGeometry.getAttribute('position')
|
||||
let positions
|
||||
const newPointsCount = newUniquePoints.length
|
||||
|
||||
if (!positionAttribute) {
|
||||
positions = new Float32Array(this.pointCount * 3)
|
||||
this.pointCloudGeometry.setAttribute(
|
||||
'position',
|
||||
new THREE.BufferAttribute(positions, 3)
|
||||
)
|
||||
} else {
|
||||
positions = positionAttribute.array
|
||||
const newLength = this.pointCount * 3
|
||||
if (newLength > positions.length) {
|
||||
const newPositions = new Float32Array(Math.ceil(newLength * 1.2))
|
||||
newPositions.set(positions)
|
||||
positions = newPositions
|
||||
this.pointCloudGeometry.setAttribute(
|
||||
'position',
|
||||
new THREE.BufferAttribute(positions, 3)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const startIndex = (this.pointCount - newPointsCount) * 3
|
||||
for (let i = 0; i < newPointsCount; i++) {
|
||||
const point = newUniquePoints[i]
|
||||
const index = startIndex + i * 3
|
||||
positions[index] = (point.x || 0) * this.pointScale
|
||||
positions[index + 1] = (point.y || 0) * this.pointScale
|
||||
positions[index + 2] = 0
|
||||
}
|
||||
|
||||
this.pointCloudGeometry.getAttribute('position').needsUpdate = true
|
||||
},
|
||||
|
||||
adjustCameraByBounds() {
|
||||
const aspect = this.canvasSize.width / this.canvasSize.height
|
||||
|
||||
const pointRangeX = this.pointBounds.maxX - this.pointBounds.minX
|
||||
const pointRangeY = this.pointBounds.maxY - this.pointBounds.minY
|
||||
|
||||
let baseViewSize = DEFAULT_VIEW_SIZE
|
||||
if (this.allPoints.length > 0 && !(pointRangeX === 0 && pointRangeY === 0)) {
|
||||
const requiredViewSizeX = pointRangeX * (1 + BOUND_MARGIN)
|
||||
const requiredViewSizeY = pointRangeY * (1 + BOUND_MARGIN)
|
||||
baseViewSize = Math.max(requiredViewSizeX / aspect, requiredViewSizeY)
|
||||
|
||||
// 确保视口不会太小
|
||||
baseViewSize = Math.max(baseViewSize, DEFAULT_VIEW_SIZE)
|
||||
}
|
||||
this.baseViewSize = baseViewSize
|
||||
|
||||
this.viewSize = baseViewSize
|
||||
this.camera.left = -this.viewSize * aspect / 2
|
||||
this.camera.right = this.viewSize * aspect / 2
|
||||
this.camera.top = this.viewSize / 2
|
||||
this.camera.bottom = -this.viewSize / 2
|
||||
this.camera.updateProjectionMatrix()
|
||||
|
||||
// 初始时将小车定位到中心
|
||||
this.locateCarToCenter()
|
||||
|
||||
if (this.controls) {
|
||||
this.controls.minZoom = MIN_ZOOM_RATIO
|
||||
this.controls.maxZoom = MAX_ZOOM_RATIO
|
||||
this.controls.minDistance = 50
|
||||
this.controls.maxDistance = 200
|
||||
this.controls.zoomToCursor = false
|
||||
}
|
||||
|
||||
this.calculateCarSize()
|
||||
},
|
||||
|
||||
startAnimationLoop() {
|
||||
const animate = () => {
|
||||
this.animationId = requestAnimationFrame(animate)
|
||||
if (this.controls) {
|
||||
this.controls.update()
|
||||
}
|
||||
this.renderer.render(this.scene, this.camera)
|
||||
}
|
||||
animate()
|
||||
},
|
||||
|
||||
cleanup() {
|
||||
this.isDestroyed = true
|
||||
|
||||
this.closeWebSocket()
|
||||
|
||||
if (this.animationId) {
|
||||
cancelAnimationFrame(this.animationId)
|
||||
this.animationId = null
|
||||
}
|
||||
|
||||
if (this.controls) {
|
||||
this.controls.dispose()
|
||||
this.controls = null
|
||||
}
|
||||
|
||||
if (this.scene) {
|
||||
if (this.pointCloudMesh) {
|
||||
this.scene.remove(this.pointCloudMesh)
|
||||
}
|
||||
if (this.carMesh) {
|
||||
this.scene.remove(this.carMesh)
|
||||
}
|
||||
this.scene.clear()
|
||||
this.scene = null
|
||||
}
|
||||
|
||||
if (this.renderer) {
|
||||
const container = this.$refs.canvasContainer
|
||||
if (container && this.renderer.domElement) {
|
||||
container.removeChild(this.renderer.domElement)
|
||||
}
|
||||
this.renderer.dispose()
|
||||
this.renderer.forceContextLoss()
|
||||
this.renderer = null
|
||||
}
|
||||
|
||||
if (this.pointCloudGeometry) {
|
||||
this.pointCloudGeometry.dispose()
|
||||
this.pointCloudGeometry = null
|
||||
}
|
||||
|
||||
if (this.pointCloudMaterial) {
|
||||
this.pointCloudMaterial.map = null
|
||||
this.pointCloudMaterial.dispose()
|
||||
this.pointCloudMaterial = null
|
||||
}
|
||||
|
||||
if (this.carTexture) {
|
||||
this.carTexture.dispose()
|
||||
this.carTexture = null
|
||||
}
|
||||
|
||||
this.camera = null
|
||||
this.carMesh = null
|
||||
this.pointCloudMesh = null
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="stylus" scoped>
|
||||
.point-cloud-map
|
||||
position relative
|
||||
width 100%
|
||||
height 100%
|
||||
background-color rgba(4, 33, 58, 70%)
|
||||
box-shadow inset 1px 1px 7px 2px #4d9bcd
|
||||
overflow hidden
|
||||
|
||||
.canvas-container
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%
|
||||
height: 100%
|
||||
position: relative
|
||||
z-index: 0
|
||||
|
||||
// 定位按钮样式
|
||||
.locate-button
|
||||
position: absolute
|
||||
bottom: 20px
|
||||
right: 20px
|
||||
width: 40px
|
||||
height: 40px
|
||||
border-radius: 50%
|
||||
background-color: rgba(255, 255, 255, 0.8)
|
||||
border: none
|
||||
color: #333
|
||||
font-size: 18px
|
||||
cursor: pointer
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2)
|
||||
z-index: 100
|
||||
transition: all 0.2s ease
|
||||
&:hover
|
||||
background-color: white
|
||||
transform: scale(1.05)
|
||||
&:active
|
||||
transform: scale(0.95)
|
||||
|
||||
.loading-indicator
|
||||
position: absolute
|
||||
top: 50%
|
||||
left: 50%
|
||||
transform: translate(-50%, -50%)
|
||||
background-color: rgba(255, 255, 255, 0.8)
|
||||
padding: .01rem .02rem
|
||||
border-radius: 4px
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1)
|
||||
font-size: .12rem
|
||||
color: #333
|
||||
z-index: 100
|
||||
display: flex
|
||||
align-items: center
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,542 +0,0 @@
|
||||
|
||||
<template>
|
||||
<div class="page_container">
|
||||
<div class="canvas-container">
|
||||
<canvas
|
||||
id="mapCanvas"
|
||||
ref="mapCanvas"
|
||||
@wheel="handleZoom"
|
||||
@click="handleCanvasClick"
|
||||
@touchstart="handleTouchStart"
|
||||
@touchmove="handleTouchMove"
|
||||
@mousedown="handleMouseDown"
|
||||
@mousemove="handleMouseMove"
|
||||
@mouseup="handleMouseUp"
|
||||
@mouseleave="handleMouseUp"
|
||||
></canvas>
|
||||
<!-- <el-row type="flex" justify="end" class="map_tools">
|
||||
<el-button type="primary" :disabled="zoomPercentage === 2" icon="el-icon-minus" size="mini" style="border: 0;border-radius: 0;" @click="zoomOut"></el-button>
|
||||
<div class="zoom_data">{{ zoomPercentage }}%</div>
|
||||
<el-button type="primary" :disabled="zoomPercentage === 200" icon="el-icon-plus" size="mini" style="border: 0;border-radius: 0;" @click="zoomIn"></el-button>
|
||||
</el-row> -->
|
||||
</div>
|
||||
<div
|
||||
v-if="showPopup"
|
||||
class="point-popup"
|
||||
:style="popupStyle"
|
||||
@click.stop
|
||||
>
|
||||
<el-row type="flex" justify="space-between" class="popup-content" style="border-bottom: 1px solid #fff;">
|
||||
<el-col :span="10"><h3>编号</h3></el-col>
|
||||
<el-col :span="14"><p>{{selectedPoint.station_code}}</p></el-col>
|
||||
</el-row>
|
||||
<el-row type="flex" justify="space-between" class="popup-content">
|
||||
<el-col :span="10"><h3>别名</h3></el-col>
|
||||
<el-col :span="14"><p>{{selectedPoint.station_name}}</p></el-col>
|
||||
</el-row>
|
||||
<el-row type="flex" justify="space-between" class="popup-content">
|
||||
<el-col :span="10"><h3>X坐标</h3></el-col>
|
||||
<el-col :span="14"><p>{{ selectedPoint.x }}</p></el-col>
|
||||
</el-row>
|
||||
<el-row type="flex" justify="space-between" class="popup-content">
|
||||
<el-col :span="10"><h3>Y坐标</h3></el-col>
|
||||
<el-col :span="14"><p>{{ selectedPoint.y }}</p></el-col>
|
||||
</el-row>
|
||||
<el-row type="flex" justify="space-between" class="popup-content">
|
||||
<el-col :span="10"><h3>角度值</h3></el-col>
|
||||
<el-col :span="14"><p>{{ selectedPoint.angle }}</p></el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
<div
|
||||
v-if="showPathPopup"
|
||||
class="point-popup"
|
||||
:style="pathPopupStyle"
|
||||
@click.stop
|
||||
>
|
||||
<el-row type="flex" justify="space-between" class="popup-content" style="border-bottom: 1px solid #fff;">
|
||||
<el-col :span="10"><h3>路径ID</h3></el-col>
|
||||
<el-col :span="14"><p>{{selectedPath.route_id}}</p></el-col>
|
||||
</el-row>
|
||||
<el-row type="flex" justify="space-between" class="popup-content">
|
||||
<el-col :span="10"><h3>起点编码</h3></el-col>
|
||||
<el-col :span="14"><p>{{getStationNameById(selectedPath.start_id)}}</p></el-col>
|
||||
</el-row>
|
||||
<el-row type="flex" justify="space-between" class="popup-content">
|
||||
<el-col :span="10"><h3>终点编码</h3></el-col>
|
||||
<el-col :span="14"><p>{{getStationNameById(selectedPath.end_id)}}</p></el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { throttle } from 'lodash'
|
||||
import markerImage from '../../images/new/station.png'
|
||||
import carImage from '../../images/new/agv.png'
|
||||
import { getMapInfoByCode, getRouteInfo, queryMapAllStation } from '../../config/getData.js'
|
||||
import canvasZoomDrag from '../../config/canvasZoomDrag'
|
||||
import { mapGetters } from 'vuex'
|
||||
export default {
|
||||
name: 'ModuleMap',
|
||||
data () {
|
||||
return {
|
||||
canvas: null, // Canvas 元素
|
||||
ctx: null, // Canvas 绘图上下文
|
||||
mapData: null, // 地图数据
|
||||
pathData: null, // 路径数据
|
||||
pointData: null, // 点位数据
|
||||
showPopup: false,
|
||||
selectedPoint: {},
|
||||
popupStyle: {left: '0px', top: '0px'},
|
||||
selectedPointId: null,
|
||||
cachedImages: {
|
||||
map: null,
|
||||
marker: null,
|
||||
car: null
|
||||
},
|
||||
imageLoadStatus: {
|
||||
map: false,
|
||||
marker: false,
|
||||
car: false
|
||||
},
|
||||
showPathPopup: false,
|
||||
selectedPath: {route_id: null},
|
||||
pathPopupStyle: { left: '0px', top: '0px' },
|
||||
pathClickThreshold: 5
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(['serverUrl', 'carPosition'])
|
||||
},
|
||||
watch: {
|
||||
carPosition: {
|
||||
handler() {
|
||||
this.redrawCanvas()
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
},
|
||||
mixins: [canvasZoomDrag],
|
||||
mounted () {
|
||||
this.preloadMarkerImage()
|
||||
this.preloadCarImage()
|
||||
this.loadAllDataInParallel()
|
||||
document.addEventListener('click', this.handleDocumentClick)
|
||||
},
|
||||
beforeDestroy () {
|
||||
document.removeEventListener('click', this.handleDocumentClick)
|
||||
},
|
||||
methods: {
|
||||
preloadCarImage () {
|
||||
if (this.cachedImages.car) {
|
||||
this.imageLoadStatus.car = true
|
||||
return
|
||||
}
|
||||
const img = new Image()
|
||||
img.src = carImage
|
||||
img.onload = () => {
|
||||
this.cachedImages.car = img
|
||||
this.imageLoadStatus.car = true
|
||||
this.checkImagesLoadedAndRedraw()
|
||||
}
|
||||
img.onerror = () => {
|
||||
console.error('小车图片加载失败')
|
||||
this.imageLoadStatus.car = true
|
||||
this.checkImagesLoadedAndRedraw()
|
||||
}
|
||||
},
|
||||
preloadMarkerImage () {
|
||||
if (this.cachedImages.marker) {
|
||||
this.imageLoadStatus.marker = true
|
||||
return
|
||||
}
|
||||
const img = new Image()
|
||||
img.src = markerImage
|
||||
img.onload = () => {
|
||||
this.cachedImages.marker = img
|
||||
this.imageLoadStatus.marker = true
|
||||
this.checkImagesLoadedAndRedraw()
|
||||
}
|
||||
img.onerror = () => {
|
||||
console.error('标记图片加载失败')
|
||||
this.imageLoadStatus.marker = true
|
||||
this.checkImagesLoadedAndRedraw()
|
||||
}
|
||||
},
|
||||
loadMapImage () {
|
||||
if (!this.mapData.mapImageAddress) {
|
||||
return Promise.reject(new Error('地图数据缺失'))
|
||||
}
|
||||
if (this.cachedImages.map && this.imageLoadStatus.map) {
|
||||
return Promise.resolve(this.cachedImages.map)
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image()
|
||||
img.src = `${this.serverUrl}${this.mapData.mapImageAddress}`
|
||||
// img.src = this.mapData.mapImageAddress
|
||||
img.onload = () => {
|
||||
this.cachedImages.map = img
|
||||
this.imageLoadStatus.map = true
|
||||
this.checkImagesLoadedAndRedraw()
|
||||
resolve(img)
|
||||
}
|
||||
img.onerror = (error) => {
|
||||
console.error('地图图片加载失败:', error)
|
||||
this.imageLoadStatus.map = true
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
},
|
||||
checkImagesLoadedAndRedraw () {
|
||||
if (this.imageLoadStatus.map && this.imageLoadStatus.marker && this.imageLoadStatus.car) {
|
||||
this.redrawCanvas()
|
||||
}
|
||||
},
|
||||
async loadAllDataInParallel () {
|
||||
try {
|
||||
this.loading = this.$loading({
|
||||
lock: true,
|
||||
spinner: 'el-icon-loading',
|
||||
background: 'rgba(0, 0, 0, 0.6)'
|
||||
})
|
||||
const [mapData, pathData, stations] = await Promise.all([
|
||||
this._getMapInfoByCode(),
|
||||
this._getRouteInfo(),
|
||||
this._queryMapAllStation()
|
||||
])
|
||||
this.mapData = mapData
|
||||
this.pathData = [...pathData]
|
||||
this.pointData = this.filterPointData(pathData, stations)
|
||||
this.initCanvas()
|
||||
await this.loadMapImage()
|
||||
this.loading.close()
|
||||
} catch (e) {
|
||||
this.$message.error(`数据加载失败: ${e.message || '未知错误'}`)
|
||||
this.loading.close()
|
||||
}
|
||||
},
|
||||
async _getMapInfoByCode () {
|
||||
try {
|
||||
const res = await getMapInfoByCode()
|
||||
if (!res) throw new Error('地图信息为空')
|
||||
return res
|
||||
} catch (e) {
|
||||
console.error('获取地图信息失败:', e)
|
||||
throw new Error(`获取地图信息失败: ${e.message}`)
|
||||
}
|
||||
},
|
||||
async _getRouteInfo () {
|
||||
try {
|
||||
const res = await getRouteInfo()
|
||||
if (!res) throw new Error('路径信息为空')
|
||||
return res
|
||||
} catch (e) {
|
||||
console.error('获取路径信息失败:', e)
|
||||
throw new Error(`获取路径信息失败: ${e.message}`)
|
||||
}
|
||||
},
|
||||
async _queryMapAllStation () {
|
||||
try {
|
||||
const res = await queryMapAllStation()
|
||||
if (!res) throw new Error('站点信息为空')
|
||||
return res
|
||||
} catch (e) {
|
||||
console.error('获取站点信息失败:', e)
|
||||
throw new Error(`获取站点信息失败: ${e.message}`)
|
||||
}
|
||||
},
|
||||
filterPointData (routes, stations) {
|
||||
const result = []
|
||||
const seenStationIds = new Set()
|
||||
routes.forEach(route => {
|
||||
const startStation = stations.find(s => s.station_id === route.start_id)
|
||||
const endStation = stations.find(s => s.station_id === route.end_id)
|
||||
if (startStation && !seenStationIds.has(startStation.station_id)) {
|
||||
result.push(startStation)
|
||||
seenStationIds.add(startStation.station_id)
|
||||
}
|
||||
if (endStation && !seenStationIds.has(endStation.station_id)) {
|
||||
result.push(endStation)
|
||||
seenStationIds.add(endStation.station_id)
|
||||
}
|
||||
})
|
||||
return result
|
||||
},
|
||||
initCanvas () {
|
||||
this.canvas = this.$refs.mapCanvas
|
||||
this.ctx = this.canvas.getContext('2d')
|
||||
this.canvas.width = this.mapData.width
|
||||
this.canvas.height = this.mapData.height
|
||||
this.redrawCanvas()
|
||||
},
|
||||
redrawCanvas: throttle(function () {
|
||||
if (!this.ctx || !this.mapData) return
|
||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
|
||||
this.ctx.save()
|
||||
// 绘制地图背景
|
||||
if (this.cachedImages.map) {
|
||||
this.ctx.drawImage(
|
||||
this.cachedImages.map,
|
||||
0, 0,
|
||||
this.canvas.width,
|
||||
this.canvas.height
|
||||
)
|
||||
} else {
|
||||
this.ctx.fillStyle = '#ccc'
|
||||
this.ctx.fillText('地图加载中...', 50, 50)
|
||||
}
|
||||
this.drawPath()
|
||||
this.drawMarkers()
|
||||
this.drawCar()
|
||||
this.ctx.restore()
|
||||
}, 30),
|
||||
drawPath () {
|
||||
if (!this.pathData.length) return
|
||||
this.pathData.forEach(point => {
|
||||
const startX = (point.start_x - this.mapData.x) / this.mapData.resolution
|
||||
const startY = this.mapData.height - (point.start_y - this.mapData.y) / this.mapData.resolution
|
||||
const endX = (point.end_x - this.mapData.x) / this.mapData.resolution
|
||||
const endY = this.mapData.height - (point.end_y - this.mapData.y) / this.mapData.resolution
|
||||
this.ctx.beginPath()
|
||||
this.ctx.moveTo(startX, startY)
|
||||
this.ctx.lineTo(endX, endY)
|
||||
if (this.selectedPath.route_id === point.route_id) {
|
||||
this.ctx.strokeStyle = '#ff5722' // 橙色高亮
|
||||
this.ctx.lineWidth = 4
|
||||
} else {
|
||||
this.ctx.strokeStyle = '#009de5' // 默认蓝色
|
||||
this.ctx.lineWidth = 2
|
||||
}
|
||||
this.ctx.stroke()
|
||||
})
|
||||
},
|
||||
drawMarkers () {
|
||||
if (!this.pointData.length) return
|
||||
const markerSize = 10 // 标记大小随缩放变化
|
||||
this.pointData.forEach(point => {
|
||||
const x = (point.x - this.mapData.x) / this.mapData.resolution
|
||||
const y = this.mapData.height - (point.y - this.mapData.y) / this.mapData.resolution
|
||||
// 绘制选中状态
|
||||
if (point.station_id === this.selectedPointId) {
|
||||
this.ctx.beginPath()
|
||||
this.ctx.arc(x, y, 5, 0, Math.PI * 2)
|
||||
this.ctx.fillStyle = '#59ccd2'
|
||||
this.ctx.fill()
|
||||
} else if (this.cachedImages.marker) {
|
||||
this.ctx.drawImage(
|
||||
this.cachedImages.marker,
|
||||
x - markerSize / 2, // 居中对齐
|
||||
y - markerSize / 2,
|
||||
markerSize,
|
||||
markerSize
|
||||
)
|
||||
}
|
||||
// 绘制点位名称(文字大小随缩放变化)
|
||||
if (point.station_type === 'Station') {
|
||||
this.ctx.font = '12px Arial'
|
||||
this.ctx.fillStyle = 'white'
|
||||
this.ctx.textAlign = 'center'
|
||||
this.ctx.fillText(point.station_name, x, y + 18)
|
||||
}
|
||||
})
|
||||
},
|
||||
drawCar () {
|
||||
if (!this.cachedImages.car || !this.mapData) return
|
||||
const carX = (this.carPosition.x - this.mapData.x) / this.mapData.resolution
|
||||
const carY = this.mapData.height - (this.carPosition.y - this.mapData.y) / this.mapData.resolution
|
||||
this.ctx.save()
|
||||
this.ctx.translate(carX, carY)
|
||||
this.ctx.rotate(-this.carPosition.angle)
|
||||
this.ctx.drawImage(
|
||||
this.cachedImages.car,
|
||||
-10,
|
||||
-10,
|
||||
20,
|
||||
20
|
||||
)
|
||||
// this.ctx.restore()
|
||||
},
|
||||
handleCanvasClick (event) {
|
||||
const rect = this.canvas.getBoundingClientRect()
|
||||
const mouseX = (event.clientX - rect.left) / this.scale
|
||||
const mouseY = (event.clientY - rect.top) / this.scale
|
||||
// 1. 优先检测点位点击
|
||||
let pointClicked = false
|
||||
this.pointData.forEach(point => {
|
||||
let x = (point.x - this.mapData.x) / this.mapData.resolution
|
||||
let y = this.mapData.height - (point.y - this.mapData.y) / this.mapData.resolution
|
||||
x = Math.abs(x) === 0 ? 0 : x
|
||||
y = Math.abs(y) === 0 ? 0 : y
|
||||
// 检查点击位置是否在标记图片内
|
||||
if (mouseX >= x - 10 && mouseX <= x + 10 && mouseY >= y - 10 && mouseY <= y + 10) {
|
||||
this.handlePointSelect(point, event)
|
||||
event.stopPropagation()
|
||||
pointClicked = true
|
||||
}
|
||||
})
|
||||
// 2. 只有点位未被点击时,才检测路径点击
|
||||
if (!pointClicked) {
|
||||
const clickedPath = this.detectPathClick(mouseX, mouseY)
|
||||
if (clickedPath) {
|
||||
this.handlePathSelect(clickedPath, event)
|
||||
event.stopPropagation()
|
||||
}
|
||||
}
|
||||
},
|
||||
// 检测路径点击
|
||||
detectPathClick (mouseX, mouseY) {
|
||||
if (!this.pathData.length) return null
|
||||
for (const path of this.pathData) {
|
||||
// 计算路径线段的Canvas坐标
|
||||
const startX = (path.start_x - this.mapData.x) / this.mapData.resolution
|
||||
const startY = this.mapData.height - (path.start_y - this.mapData.y) / this.mapData.resolution
|
||||
const endX = (path.end_x - this.mapData.x) / this.mapData.resolution
|
||||
const endY = this.mapData.height - (path.end_y - this.mapData.y) / this.mapData.resolution
|
||||
// 计算点击点到线段的距离
|
||||
const distance = this.pointToLineDistance(
|
||||
{ x: mouseX, y: mouseY },
|
||||
{ x: startX, y: startY },
|
||||
{ x: endX, y: endY }
|
||||
)
|
||||
// 距离小于阈值则认为点击了该路径
|
||||
if (distance <= this.pathClickThreshold) {
|
||||
return path
|
||||
}
|
||||
}
|
||||
return null
|
||||
},
|
||||
// 计算点到线段的最短距离
|
||||
pointToLineDistance (point, lineStart, lineEnd) {
|
||||
// 线段向量
|
||||
const dx = lineEnd.x - lineStart.x
|
||||
const dy = lineEnd.y - lineStart.y
|
||||
// 线段长度的平方
|
||||
const lengthSquared = dx * dx + dy * dy
|
||||
// 线段长度为0(起点终点重合),直接返回点到起点的距离
|
||||
if (lengthSquared === 0) {
|
||||
return Math.hypot(point.x - lineStart.x, point.y - lineStart.y)
|
||||
}
|
||||
// 计算点击点在投影到线段上的位置比例(0~1之间为在线段上)
|
||||
let t = ((point.x - lineStart.x) * dx + (point.y - lineStart.y) * dy) / lengthSquared
|
||||
t = Math.max(0, Math.min(1, t)) // 限制在0~1范围内
|
||||
// 投影点坐标
|
||||
const projectionX = lineStart.x + t * dx
|
||||
const projectionY = lineStart.y + t * dy
|
||||
// 点到投影点的距离
|
||||
return Math.hypot(point.x - projectionX, point.y - projectionY)
|
||||
},
|
||||
handlePointSelect (point, event) {
|
||||
this.resetPathSelection()
|
||||
if (this.selectedPointId === point.station_id) {
|
||||
this.resetSelection()
|
||||
} else {
|
||||
this.selectedPointId = point.station_id
|
||||
this.selectedPoint = point
|
||||
this.showPopup = true
|
||||
this.calculatePopupPosition(event)
|
||||
this.redrawCanvas()
|
||||
}
|
||||
},
|
||||
// 处理路径选中
|
||||
handlePathSelect (path, event) {
|
||||
// 关闭点位弹窗(避免冲突)
|
||||
this.resetPointSelection()
|
||||
if (this.selectedPath.route_id === path.route_id) {
|
||||
// 再次点击同一路径关闭弹窗
|
||||
this.resetPathSelection()
|
||||
} else {
|
||||
this.selectedPath = path
|
||||
this.showPathPopup = true
|
||||
this.calculatePathPopupPosition(event)
|
||||
this.redrawCanvas() // 高亮显示选中路径
|
||||
}
|
||||
},
|
||||
calculatePopupPosition (event) {
|
||||
const popupWidth = 200
|
||||
const popupHeight = 180
|
||||
let left = event.clientX - 40
|
||||
let top = event.clientY - 150
|
||||
|
||||
// 限制弹窗在窗口可视范围内
|
||||
left = Math.max(10, Math.min(left, window.innerWidth - popupWidth - 10))
|
||||
top = Math.max(10, Math.min(top, window.innerHeight - popupHeight - 10))
|
||||
|
||||
this.popupStyle = { left: `${left}px`, top: `${top}px` }
|
||||
},
|
||||
// 计算路径弹窗位置
|
||||
calculatePathPopupPosition (event) {
|
||||
const popupWidth = 160
|
||||
const popupHeight = 100
|
||||
let left = event.clientX - 40
|
||||
let top = event.clientY - 120
|
||||
// 限制弹窗在窗口可视范围内
|
||||
left = Math.max(10, Math.min(left, window.innerWidth - popupWidth - 10))
|
||||
top = Math.max(10, Math.min(top, window.innerHeight - popupHeight - 10))
|
||||
this.pathPopupStyle = { left: `${left}px`, top: `${top}px` }
|
||||
},
|
||||
handleDocumentClick () {
|
||||
this.resetSelection()
|
||||
},
|
||||
resetPointSelection () {
|
||||
if (this.selectedPointId) {
|
||||
this.selectedPointId = null
|
||||
this.selectedPoint = null
|
||||
this.showPopup = false
|
||||
}
|
||||
},
|
||||
// 重置路径选中状态
|
||||
resetPathSelection () {
|
||||
if (this.selectedPath) {
|
||||
this.selectedPath = {route_id: null}
|
||||
this.showPathPopup = false
|
||||
}
|
||||
},
|
||||
resetSelection () {
|
||||
this.resetPointSelection()
|
||||
this.resetPathSelection()
|
||||
this.redrawCanvas()
|
||||
},
|
||||
getStationNameById (stationId) {
|
||||
if (!stationId || !this.pointData || !this.pointData.length) return null
|
||||
const station = this.pointData.find(point => point.station_id === stationId)
|
||||
return station ? station.station_name : null
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="stylus" scoped>
|
||||
.canvas-container
|
||||
position relative
|
||||
display flex
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height 100%
|
||||
background-color rgba(4, 33, 58, 70%)
|
||||
box-shadow inset 1px 1px 7px 2px #4d9bcd
|
||||
overflow hidden
|
||||
.point-popup
|
||||
position fixed
|
||||
background rgba(0, 0, 0, 70%)
|
||||
border 1px solid rgba(255, 255, 255, .3)
|
||||
border-radius: 8px;
|
||||
padding 10px
|
||||
box-shadow 0 0px 4px 2px rgba(255,255,255,0.1)
|
||||
z-index 100
|
||||
min-width 160px
|
||||
animation fadeIn 0.2s ease-out
|
||||
h3
|
||||
color #fff
|
||||
font-size 14px
|
||||
line-height 24px
|
||||
text-align center
|
||||
p
|
||||
color #fff
|
||||
font-size 14px
|
||||
line-height 24px
|
||||
text-align center
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(5px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
</style>
|
||||
@@ -30,7 +30,7 @@
|
||||
<div class="error-tips-t">{{$t('ErrorPrompt')}}:{{ exception }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-row type="flex" class="relative main-conatiner" :style="{'paddingTop': exception !== '' ? '.3rem' : '0', 'paddingBottom': taskSeq.length > 0 ? '.6rem': '0'}">
|
||||
<el-row type="flex" class="relative main-conatiner" :style="{'paddingTop': exception !== '' ? '.4rem' : '0', 'paddingBottom': taskSeq.length > 0 ? '.6rem': '0'}">
|
||||
<div class="absolute hud_left"></div>
|
||||
<div class="absolute hud_left hud_right"></div>
|
||||
<router-view></router-view>
|
||||
@@ -107,7 +107,9 @@ export default {
|
||||
// "en_error_description": null,
|
||||
// "error_type": 1}
|
||||
// ],
|
||||
// forkTipObstacles: '1'
|
||||
// forkTipObstacles: '1',
|
||||
// auto_loop_enable: '0',
|
||||
// auto_back_enable: ''
|
||||
// },
|
||||
taskSeq: [],
|
||||
currentStep: null,
|
||||
@@ -184,6 +186,11 @@ export default {
|
||||
},
|
||||
// 初始化WebSocket连接
|
||||
initWebSocket () {
|
||||
this.loading = this.$loading({
|
||||
lock: true,
|
||||
spinner: 'el-icon-loading',
|
||||
background: 'rgba(0, 0, 0, 0.6)'
|
||||
})
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
|
||||
const wsHost = this.serverUrl.replace(/^https?:\/\//, '')
|
||||
const lang = this.$i18n.locale.slice(0, 2)
|
||||
@@ -194,6 +201,16 @@ export default {
|
||||
this.websocket.onmessage = (event) => {
|
||||
try {
|
||||
const res = JSON.parse(event.data)
|
||||
if (!res.data.rcsConnected && !res.data.vehicleConnected) {
|
||||
this.$message.error(this.$t('Dispatchvehiclenotconnected'))
|
||||
} else if (!res.data.rcsConnected && res.data.vehicleConnected) {
|
||||
this.$message.error(this.$t('Dispatchnotconnected'))
|
||||
} else if (!res.data.vehicleConnected && res.data.rcsConnected) {
|
||||
this.$message.error(this.$t('Vehiclenotconnected'))
|
||||
}
|
||||
if (res.data.rcsConnected && res.data.vehicleConnected) {
|
||||
this.loading.close()
|
||||
}
|
||||
this.handleWebSocketMessage(res)
|
||||
} catch (error) {
|
||||
console.error('WebSocket消息解析失败:', error)
|
||||
@@ -203,6 +220,7 @@ export default {
|
||||
this.$message.error(this.$t('WebSocketerror') + ':', error)
|
||||
}
|
||||
this.websocket.onclose = () => {
|
||||
this.loading.close()
|
||||
this.topInfo = {}
|
||||
this.taskSeq = []
|
||||
this.currentStep = null
|
||||
@@ -233,7 +251,7 @@ export default {
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.$message.error(this.$t('AttreconnectWebSocket'))
|
||||
this.initWebSocket()
|
||||
}, 5000) // 5秒后重连
|
||||
}, 3000) // 3秒后重连
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -295,8 +313,8 @@ export default {
|
||||
top .48rem
|
||||
left 0
|
||||
width 100%
|
||||
height .3rem
|
||||
line-height .3rem
|
||||
height .4rem
|
||||
line-height .4rem
|
||||
overflow hidden
|
||||
white-space nowrap
|
||||
background-color rgba(253, 246, 236, .4)
|
||||
@@ -310,7 +328,7 @@ export default {
|
||||
margin-right .05rem
|
||||
.error-tips-t
|
||||
display inline-block
|
||||
font-size .16rem
|
||||
font-size .24rem
|
||||
color #e6bb3c
|
||||
.task_wraper
|
||||
position fixed
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
|
||||
// element css
|
||||
.el-loading-spinner i
|
||||
font-size .24rem
|
||||
font-size .5rem
|
||||
color #fff
|
||||
.el-button
|
||||
font-size .2rem
|
||||
@@ -255,7 +255,7 @@
|
||||
.driver-popover.driverjs-theme .driver-popover-description,
|
||||
.driver-popover.driverjs-theme .driver-popover-progress-text {
|
||||
color: #000;
|
||||
font-size: 18px;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.driver-popover.driverjs-theme button {
|
||||
@@ -265,7 +265,7 @@
|
||||
color: #ffffff;
|
||||
border: 2px solid #000;
|
||||
text-shadow: none;
|
||||
font-size: 14px;
|
||||
font-size: 20px;
|
||||
padding: 5px 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
@@ -346,7 +346,7 @@
|
||||
.main-conatiner
|
||||
width 100%
|
||||
height calc(100% - .48rem)
|
||||
padding .3rem 0 .6rem 0
|
||||
padding .4rem 0 .6rem 0
|
||||
.page_container
|
||||
_wh(96%, 100%)
|
||||
padding .09rem
|
||||
|
||||
@@ -4,14 +4,18 @@ const state = {
|
||||
carPosition: {x: '', y: '', angle: ''},
|
||||
isTop: false,
|
||||
errorData: [],
|
||||
backIoStatus: '0'
|
||||
backIoStatus: '0',
|
||||
autoLoopEnable: '0',
|
||||
autoBackEnable: ''
|
||||
}
|
||||
|
||||
const getters = {
|
||||
carPosition: state => state.carPosition,
|
||||
isTop: state => state.isTop,
|
||||
errorData: state => state.errorData,
|
||||
backIoStatus: state => state.backIoStatus
|
||||
backIoStatus: state => state.backIoStatus,
|
||||
autoLoopEnable: state => state.autoLoopEnable,
|
||||
autoBackEnable: state => state.autoBackEnable
|
||||
}
|
||||
|
||||
const actions = {
|
||||
@@ -37,6 +41,12 @@ const mutations = {
|
||||
if (Object.prototype.hasOwnProperty.call(data, 'forkTipObstacles')) {
|
||||
state.backIoStatus = data.forkTipObstacles
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(data, 'auto_loop_enable')) {
|
||||
state.autoLoopEnable = data.auto_loop_enable
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(data, 'auto_back_enable')) {
|
||||
state.autoBackEnable = data.auto_back_enable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user