Browse Source

refactor(pointCloud): 重构雷达点云组件为受控组件并优化交互

- 将WebSocket连接逻辑移至父组件,使点云组件变为受控组件
- 添加props接口定义,支持外部控制数据和加载状态
- 优化视图控制按钮布局和提示文本
- 添加点击外部关闭配置面板的功能
- 清理未使用的代码和注释
liujia 1 month ago
parent
commit
73321e2

+ 79 - 90
src/components/radarPointCloud/index.vue

@@ -40,8 +40,8 @@
         <!-- 按钮区域 -->
         <!-- 按钮区域 -->
         <a-space class="control-space" :size="12">
         <a-space class="control-space" :size="12">
           <ZoomInOutlined @click="zoomIn" :disabled="isLoading" title="放大" />
           <ZoomInOutlined @click="zoomIn" :disabled="isLoading" title="放大" />
-          <RedoOutlined @click="resetView" :disabled="isLoading" title="重置" />
           <ZoomOutOutlined @click="zoomOut" :disabled="isLoading" title="缩小" />
           <ZoomOutOutlined @click="zoomOut" :disabled="isLoading" title="缩小" />
+          <RedoOutlined @click="resetView" :disabled="isLoading" title="重置视图" />
           <CodeSandboxOutlined
           <CodeSandboxOutlined
             @click="configPanelVisible = !configPanelVisible"
             @click="configPanelVisible = !configPanelVisible"
             :disabled="isLoading"
             :disabled="isLoading"
@@ -96,7 +96,7 @@
 
 
 <script setup lang="ts">
 <script setup lang="ts">
 // 导入Vue相关依赖
 // 导入Vue相关依赖
-import { ref, onMounted, onUnmounted, reactive, toRefs, nextTick } from 'vue'
+import { ref, onMounted, onUnmounted, reactive, toRefs, nextTick, watch } from 'vue'
 // 导入Three.js核心库和相关扩展
 // 导入Three.js核心库和相关扩展
 import * as THREE from 'three'
 import * as THREE from 'three'
 import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
 import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
@@ -141,6 +141,25 @@ interface RgbColor {
   b: number // 蓝色通道(0-1)
   b: number // 蓝色通道(0-1)
 }
 }
 
 
+type Props = {
+  data: RadarData | null
+  loadingText: string
+  isLoading: boolean
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  data: () => ({
+    ID: '',
+    Payload: {
+      RawPoints: [],
+      timestamp: '',
+    },
+    Type: '',
+  }),
+  loadingText: '正在加载雷达点云数据...',
+  isLoading: true,
+})
+
 // 存储初始相机位置和目标点
 // 存储初始相机位置和目标点
 let initialCameraPosition = new THREE.Vector3()
 let initialCameraPosition = new THREE.Vector3()
 let initialCameraTarget = new THREE.Vector3()
 let initialCameraTarget = new THREE.Vector3()
@@ -156,7 +175,7 @@ const state = reactive({
 })
 })
 
 
 // 将响应式状态转换为ref以便在模板中使用
 // 将响应式状态转换为ref以便在模板中使用
-const { isLoading, loadingText, pointCount, lastUpdate } = toRefs(state)
+const { pointCount, lastUpdate } = toRefs(state)
 
 
 // DOM引用 - 用于获取渲染容器
 // DOM引用 - 用于获取渲染容器
 const container = ref<HTMLDivElement | null>(null)
 const container = ref<HTMLDivElement | null>(null)
@@ -168,7 +187,6 @@ let renderer: THREE.WebGLRenderer | null = null // 渲染器
 let controls: OrbitControls | null = null // 控制器
 let controls: OrbitControls | null = null // 控制器
 let composer: EffectComposer | null = null // 后期处理合成器
 let composer: EffectComposer | null = null // 后期处理合成器
 let pointCloud: THREE.Points | null = null // 点云对象
 let pointCloud: THREE.Points | null = null // 点云对象
-let ws: WebSocket | null = null // WebSocket连接
 let animationId: number | null = null // 动画帧ID
 let animationId: number | null = null // 动画帧ID
 let glassWalls: THREE.Mesh[] = [] // 玻璃墙集合
 let glassWalls: THREE.Mesh[] = [] // 玻璃墙集合
 let floorPoints: THREE.Points | null = null // 地板点
 let floorPoints: THREE.Points | null = null // 地板点
@@ -186,7 +204,7 @@ const roomDimensions = ref<RoomDimensions>({
  * 安全地释放材质资源
  * 安全地释放材质资源
  * @param material 要释放的材质,可以是单个材质或材质数组
  * @param material 要释放的材质,可以是单个材质或材质数组
  */
  */
-function disposeMaterial(material: THREE.Material | THREE.Material[] | null) {
+const disposeMaterial = (material: THREE.Material | THREE.Material[] | null) => {
   if (!material) return
   if (!material) return
 
 
   if (Array.isArray(material)) {
   if (Array.isArray(material)) {
@@ -215,7 +233,7 @@ const getRoomBoundingBox = (): THREE.Box3 => {
  * 计算「刚好能完整显示整个房间」的相机距离
  * 计算「刚好能完整显示整个房间」的相机距离
  * 用于自动调整和重置视图
  * 用于自动调整和重置视图
  */
  */
-function calculateOptimalDistance() {
+const calculateOptimalDistance = () => {
   if (!camera || !container.value) return 0
   if (!camera || !container.value) return 0
 
 
   const roomBbox = getRoomBoundingBox()
   const roomBbox = getRoomBoundingBox()
@@ -242,7 +260,7 @@ function calculateOptimalDistance() {
  * 创建场景、相机、渲染器等核心组件
  * 创建场景、相机、渲染器等核心组件
  * @returns 是否初始化成功
  * @returns 是否初始化成功
  */
  */
-async function initThreeJs() {
+const initThreeJs = async () => {
   initializationAttempts++
   initializationAttempts++
 
 
   // 超过最大尝试次数则停止
   // 超过最大尝试次数则停止
@@ -337,7 +355,7 @@ async function initThreeJs() {
  * 设置相机和控制器初始状态
  * 设置相机和控制器初始状态
  * 恢复正视角度
  * 恢复正视角度
  */
  */
-function setupCameraAndControls() {
+const setupCameraAndControls = () => {
   if (!camera || !controls) {
   if (!camera || !controls) {
     console.error('相机或控制器未初始化,无法设置初始位置')
     console.error('相机或控制器未初始化,无法设置初始位置')
     return
     return
@@ -377,17 +395,15 @@ function setupCameraAndControls() {
  * 重置视图到初始状态
  * 重置视图到初始状态
  * 回到自动调整的最佳视角
  * 回到自动调整的最佳视角
  */
  */
-function resetView() {
+const resetView = () => {
   if (!camera || !controls) {
   if (!camera || !controls) {
     console.warn('无法重置视图:相机或控制器未初始化')
     console.warn('无法重置视图:相机或控制器未初始化')
     return
     return
   }
   }
 
 
-  // 计算当前画布尺寸下的最佳距离
-  const optimalDistance = calculateOptimalDistance()
-
   // 计算目标位置(保持初始方向)
   // 计算目标位置(保持初始方向)
   const zoomNumber = 0.75 // 视图远近系数 数值越小,视角越近
   const zoomNumber = 0.75 // 视图远近系数 数值越小,视角越近
+  const optimalDistance = calculateOptimalDistance()
   const targetPosition = new THREE.Vector3()
   const targetPosition = new THREE.Vector3()
   targetPosition.copy(initialCameraTarget)
   targetPosition.copy(initialCameraTarget)
   targetPosition.addScaledVector(initialCameraDirection, optimalDistance * zoomNumber)
   targetPosition.addScaledVector(initialCameraDirection, optimalDistance * zoomNumber)
@@ -420,7 +436,7 @@ function resetView() {
 /**
 /**
  * 放大视图
  * 放大视图
  */
  */
-function zoomIn() {
+const zoomIn = () => {
   if (!controls || !camera) return
   if (!controls || !camera) return
 
 
   // 获取当前相机到目标点的距离
   // 获取当前相机到目标点的距离
@@ -439,7 +455,7 @@ function zoomIn() {
 /**
 /**
  * 缩小视图
  * 缩小视图
  */
  */
-function zoomOut() {
+const zoomOut = () => {
   if (!controls || !camera) return
   if (!controls || !camera) return
 
 
   // 获取当前相机到目标点的距离
   // 获取当前相机到目标点的距离
@@ -457,7 +473,7 @@ function zoomOut() {
 /**
 /**
  * 创建房间的玻璃墙壁
  * 创建房间的玻璃墙壁
  */
  */
-function createRoom() {
+const createRoom = () => {
   if (!scene) return
   if (!scene) return
 
 
   const room = new THREE.Group()
   const room = new THREE.Group()
@@ -526,7 +542,7 @@ function createRoom() {
 /**
 /**
  * 创建地板粒子网格
  * 创建地板粒子网格
  */
  */
-function createFloorPoints() {
+const createFloorPoints = () => {
   if (!scene) return
   if (!scene) return
 
 
   // 创建平面几何体作为地板
   // 创建平面几何体作为地板
@@ -557,7 +573,7 @@ function createFloorPoints() {
  * @param width 画布宽度
  * @param width 画布宽度
  * @param height 画布高度
  * @param height 画布高度
  */
  */
-function setupPostProcessing(width: number, height: number) {
+const setupPostProcessing = (width: number, height: number) => {
   if (!renderer || !scene || !camera) return
   if (!renderer || !scene || !camera) return
 
 
   try {
   try {
@@ -602,7 +618,7 @@ function setupPostProcessing(width: number, height: number) {
  * 更新点云数据
  * 更新点云数据
  * @param rawPoints 原始点数据数组
  * @param rawPoints 原始点数据数组
  */
  */
-function updatePointCloud(rawPoints: [number, number, number, number][]) {
+const updatePointCloud = (rawPoints: [number, number, number, number][]) => {
   if (!pointCloud) {
   if (!pointCloud) {
     console.error('pointCloud未初始化,无法更新点云数据')
     console.error('pointCloud未初始化,无法更新点云数据')
     state.loadingText = '点云组件未准备好,请稍候...'
     state.loadingText = '点云组件未准备好,请稍候...'
@@ -635,9 +651,6 @@ function updatePointCloud(rawPoints: [number, number, number, number][]) {
       const idx = index * 3
       const idx = index * 3
 
 
       // 设置位置(原始单位m 需要转为cm * 100)
       // 设置位置(原始单位m 需要转为cm * 100)
-      // positions[idx] = x
-      // positions[idx + 1] = y
-      // positions[idx + 2] = z
       positions[idx] = x * 100
       positions[idx] = x * 100
       positions[idx + 1] = y * 100
       positions[idx + 1] = y * 100
       positions[idx + 2] = z * 100
       positions[idx + 2] = z * 100
@@ -686,14 +699,8 @@ const intensityGradientSmooth = [
   { value: 1.0, color: '#FF0000' }, // 红
   { value: 1.0, color: '#FF0000' }, // 红
 ]
 ]
 
 
-// 预计算成 RGB,减少运行时开销
-const gradientStops = intensityGradientSmooth.map((s) => ({
-  value: s.value,
-  rgb: hexToRgb(s.color),
-}))
-
 /** 将十六进制颜色转换为 0–1 的 RGB */
 /** 将十六进制颜色转换为 0–1 的 RGB */
-function hexToRgb(hex: string): RgbColor {
+const hexToRgb = (hex: string): RgbColor => {
   const n = parseInt(hex.slice(1), 16)
   const n = parseInt(hex.slice(1), 16)
   return {
   return {
     r: ((n >> 16) & 255) / 255,
     r: ((n >> 16) & 255) / 255,
@@ -702,8 +709,14 @@ function hexToRgb(hex: string): RgbColor {
   }
   }
 }
 }
 
 
+// 预计算成 RGB,减少运行时开销
+const gradientStops = intensityGradientSmooth.map((s) => ({
+  value: s.value,
+  rgb: hexToRgb(s.color),
+}))
+
 /** 线性插值 */
 /** 线性插值 */
-function lerp(a: number, b: number, t: number) {
+const lerp = (a: number, b: number, t: number) => {
   return a + (b - a) * t
   return a + (b - a) * t
 }
 }
 
 
@@ -712,7 +725,7 @@ function lerp(a: number, b: number, t: number) {
  * @param value01 已在外部归一化到 0–1 的强度
  * @param value01 已在外部归一化到 0–1 的强度
  * @returns RGB (0–1)
  * @returns RGB (0–1)
  */
  */
-function getInterpolatedColor(value01: number): RgbColor {
+const getInterpolatedColor = (value01: number): RgbColor => {
   const v = Math.min(Math.max(value01, 0), 1) // clamp 0–1
   const v = Math.min(Math.max(value01, 0), 1) // clamp 0–1
 
 
   // 落在哪个区间
   // 落在哪个区间
@@ -732,62 +745,21 @@ function getInterpolatedColor(value01: number): RgbColor {
   return gradientStops[gradientStops.length - 1].rgb
   return gradientStops[gradientStops.length - 1].rgb
 }
 }
 
 
-/**
- * 连接WebSocket获取实时数据
- */
-function connectWebSocket() {
-  // 关闭现有连接
-  if (ws) {
-    ws.close()
-    ws = null
-  }
-
-  // 创建新连接
-  ws = new WebSocket('ws://localhost:8080')
-
-  // 连接成功回调
-  ws.onopen = () => {
-    console.log('WebSocket已连接')
-    loadingText.value = '已连接,接收点云数据中...'
-  }
-
-  // 接收消息回调
-  ws.onmessage = (msg) => {
-    try {
-      if (!pointCloud) {
-        console.warn('点云组件尚未准备好,暂不处理数据')
-        return
-      }
-
-      // 解析雷达数据
-      const radarData: RadarData = JSON.parse(msg.data)
-      if (radarData.Payload && radarData.Payload.RawPoints) {
-        updatePointCloud(radarData.Payload.RawPoints)
-        isLoading.value = false
-      }
-    } catch (e) {
-      console.error('解析雷达数据失败', e)
+watch(
+  () => props.data,
+  (newData) => {
+    if (newData && newData.Payload && newData.Payload.RawPoints) {
+      updatePointCloud(newData.Payload.RawPoints)
+      state.isLoading = false
     }
     }
-  }
-
-  // 连接关闭回调
-  ws.onclose = () => {
-    console.log('WebSocket已断开,尝试重连...')
-    loadingText.value = '连接断开,尝试重连...'
-    isLoading.value = true
-    setTimeout(connectWebSocket, 3000)
-  }
-
-  // 错误处理回调
-  ws.onerror = (error) => {
-    console.error('WebSocket错误:', error)
-  }
-}
+  },
+  { immediate: true, deep: true }
+)
 
 
 /**
 /**
  * 动画循环
  * 动画循环
  */
  */
-function animate() {
+const animate = () => {
   // 取消上一帧动画
   // 取消上一帧动画
   if (animationId) {
   if (animationId) {
     cancelAnimationFrame(animationId)
     cancelAnimationFrame(animationId)
@@ -818,7 +790,7 @@ function animate() {
  * 窗口大小变化处理函数
  * 窗口大小变化处理函数
  * 自动调整到最佳视角,但不限制用户后续操作
  * 自动调整到最佳视角,但不限制用户后续操作
  */
  */
-function onWindowResize() {
+const onWindowResize = () => {
   if (!container.value || !renderer || !camera) return
   if (!container.value || !renderer || !camera) return
 
 
   const { offsetWidth: width, offsetHeight: height } = container.value
   const { offsetWidth: width, offsetHeight: height } = container.value
@@ -849,7 +821,7 @@ function onWindowResize() {
 /**
 /**
  * 清理Three.js资源
  * 清理Three.js资源
  */
  */
-function cleanupThreeJs() {
+const cleanupThreeJs = () => {
   // 取消动画循环
   // 取消动画循环
   if (animationId) {
   if (animationId) {
     cancelAnimationFrame(animationId)
     cancelAnimationFrame(animationId)
@@ -886,7 +858,7 @@ const configPanelVisible = ref(false)
  * 应用新的房间尺寸设置
  * 应用新的房间尺寸设置
  * 重新创建房间墙壁和地板,并重置视图
  * 重新创建房间墙壁和地板,并重置视图
  */
  */
-function applyRoomDimensions() {
+const applyRoomDimensions = () => {
   if (!scene) return
   if (!scene) return
 
 
   // 移除现有的房间墙壁
   // 移除现有的房间墙壁
@@ -915,14 +887,35 @@ function applyRoomDimensions() {
   resetView()
   resetView()
 }
 }
 
 
+/**
+ * 点击信息面板之前的区域,关闭房间尺寸设置
+ * @param event 点击事件
+ */
+const handleClickOutside = (event: MouseEvent) => {
+  // 获取overlay和configPanel元素
+  const overlayElement = document.querySelector('.overlay') as HTMLElement
+  const configPanelElement = document.querySelector('.config-panel') as HTMLElement
+
+  // 检查点击是否发生在overlay或configPanel外部
+  if (configPanelVisible.value && overlayElement && configPanelElement) {
+    // 检查点击目标是否是overlay或其后代元素
+    const isClickInOverlay = overlayElement.contains(event.target as Node)
+
+    // 如果点击在overlay之外,则关闭配置面板
+    if (!isClickInOverlay) {
+      configPanelVisible.value = false
+    }
+  }
+}
+
 // 组件挂载时初始化
 // 组件挂载时初始化
 onMounted(() => {
 onMounted(() => {
   nextTick().then(() => {
   nextTick().then(() => {
     initThreeJs().then((success) => {
     initThreeJs().then((success) => {
       if (success) {
       if (success) {
-        connectWebSocket()
         animate()
         animate()
         window.addEventListener('resize', onWindowResize)
         window.addEventListener('resize', onWindowResize)
+        document.addEventListener('click', handleClickOutside)
       }
       }
     })
     })
   })
   })
@@ -930,13 +923,9 @@ onMounted(() => {
 
 
 // 组件卸载时清理
 // 组件卸载时清理
 onUnmounted(() => {
 onUnmounted(() => {
-  if (ws) {
-    ws.close()
-    ws = null
-  }
-
   cleanupThreeJs()
   cleanupThreeJs()
   window.removeEventListener('resize', onWindowResize)
   window.removeEventListener('resize', onWindowResize)
+  document.removeEventListener('click', handleClickOutside)
 })
 })
 </script>
 </script>
 
 

+ 3 - 20
src/views/device/detail/index.vue

@@ -1,17 +1,6 @@
 <template>
 <template>
   <a-spin :spinning="spinning">
   <a-spin :spinning="spinning">
     <div class="deviceDetail">
     <div class="deviceDetail">
-      <!-- <info-card title="雷达云点图(模拟数据)">
-        <template #extra>
-          <div class="extraIcon">
-            <ExportOutlined @click="openWindow" />
-          </div>
-        </template>
-        <div class="pointCloudMap">
-          <RadarPointCloud></RadarPointCloud>
-        </div>
-      </info-card> -->
-
       <info-card title="实时点位图">
       <info-card title="实时点位图">
         <template #extra>
         <template #extra>
           <a-space>
           <a-space>
@@ -257,7 +246,7 @@
 import infoCard from './components/infoCard/index.vue'
 import infoCard from './components/infoCard/index.vue'
 import infoItem from './components/infoItem/index.vue'
 import infoItem from './components/infoItem/index.vue'
 import { ref, reactive, onMounted, onUnmounted, computed } from 'vue'
 import { ref, reactive, onMounted, onUnmounted, computed } from 'vue'
-import { useRoute, useRouter } from 'vue-router'
+import { useRoute } from 'vue-router'
 import { message } from 'ant-design-vue'
 import { message } from 'ant-design-vue'
 import * as roomApi from '@/api/room'
 import * as roomApi from '@/api/room'
 import type { Furniture } from '@/api/room/types'
 import type { Furniture } from '@/api/room/types'
@@ -269,7 +258,7 @@ import deviceConfigDrawer from './components/deviceConfig/index.vue'
 import deviceStatsDrawer from './components/deviceStatsDrawer/index.vue'
 import deviceStatsDrawer from './components/deviceStatsDrawer/index.vue'
 import BreathLineChart from './components/breathLineChart/index.vue'
 import BreathLineChart from './components/breathLineChart/index.vue'
 import { formatDateTime } from '@/utils'
 import { formatDateTime } from '@/utils'
-import { FullscreenOutlined, ExportOutlined } from '@ant-design/icons-vue'
+import { FullscreenOutlined } from '@ant-design/icons-vue'
 import FullViewModal from './components/fullViewModal/index.vue'
 import FullViewModal from './components/fullViewModal/index.vue'
 
 
 defineOptions({
 defineOptions({
@@ -277,7 +266,7 @@ defineOptions({
 })
 })
 
 
 const route = useRoute()
 const route = useRoute()
-const router = useRouter()
+// const router = useRouter()
 const devId = ref<string>((route.query.devId as string) || '') // 设备id
 const devId = ref<string>((route.query.devId as string) || '') // 设备id
 const clientId = ref<string>((route.query.clientId as string) || '') // 设备id
 const clientId = ref<string>((route.query.clientId as string) || '') // 设备id
 
 
@@ -565,12 +554,6 @@ onUnmounted(() => {
 })
 })
 
 
 const openFullView = ref(false)
 const openFullView = ref(false)
-
-const openWindow = () => {
-  console.log('openWindow')
-  const url = router.resolve({ name: 'pointCloud' }).href
-  window.open(url, '_blank')
-}
 </script>
 </script>
 
 
 <style scoped lang="less">
 <style scoped lang="less">

+ 80 - 1
src/views/pointCloudMap/index.vue

@@ -1,13 +1,92 @@
 <template>
 <template>
   <div class="pointCloudMap">
   <div class="pointCloudMap">
-    <RadarPointCloud></RadarPointCloud>
+    <RadarPointCloud
+      :data="radarData"
+      :loadingText="loadingText"
+      :isLoading="isLoading"
+    ></RadarPointCloud>
   </div>
   </div>
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
+import { onMounted, onUnmounted, ref } from 'vue'
+
 defineOptions({
 defineOptions({
   name: 'PointCloudMap',
   name: 'PointCloudMap',
 })
 })
+
+// 类型定义
+interface RadarDataPayload {
+  RawPoints: [number, number, number, number][] // 原始点数据[x, y, z, 强度]
+  timestamp: string // 时间戳
+}
+
+interface RadarData {
+  ID: string // 数据ID
+  Payload: RadarDataPayload // 数据负载
+  Type: string // 数据类型
+}
+
+let ws: WebSocket | null = null // WebSocket连接
+const radarData = ref<RadarData | null>(null) // 存储雷达数据
+const isLoading = ref<boolean>(true) // 加载状态
+const loadingText = ref<string>('正在加载雷达点云数据...') // 加载提示文本
+/**
+ * 连接WebSocket获取实时数据
+ */
+function connectWebSocket() {
+  // 关闭现有连接
+  if (ws) {
+    ws.close()
+    ws = null
+  }
+
+  // 创建新连接
+  ws = new WebSocket('ws://localhost:8080')
+
+  // 连接成功回调
+  ws.onopen = () => {
+    console.log('WebSocket已连接')
+    loadingText.value = '已连接,接收点云数据中...'
+  }
+
+  // 接收消息回调
+  ws.onmessage = (msg) => {
+    try {
+      // 解析雷达数据
+      radarData.value = JSON.parse(msg.data)
+      isLoading.value = false
+    } catch (e) {
+      console.error('解析雷达数据失败', e)
+    }
+  }
+
+  // 连接关闭回调
+  ws.onclose = () => {
+    console.log('WebSocket已断开,尝试重连...')
+    loadingText.value = '连接断开,尝试重连...'
+    isLoading.value = true
+    setTimeout(connectWebSocket, 3000)
+  }
+
+  // 错误处理回调
+  ws.onerror = (error) => {
+    console.error('WebSocket错误:', error)
+  }
+}
+
+// 组件挂载时初始化
+onMounted(() => {
+  connectWebSocket()
+})
+
+// 组件卸载时清理
+onUnmounted(() => {
+  if (ws) {
+    ws.close()
+    ws = null
+  }
+})
 </script>
 </script>
 
 
 <style scoped lang="less">
 <style scoped lang="less">