threeDView.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565
  1. <template>
  2. <div class="container">
  3. <el-card class="full-width-card">
  4. <div class="toolbar">
  5. <p>三维图数据可视化</p>
  6. <el-button type="primary" style="margin-left: auto;" @click="resetCamera">对正视角</el-button>
  7. <el-button type="primary" @click="captureView">保存视图</el-button>
  8. </div>
  9. <div class="graph-container" ref="graphContainer">
  10. <Renderer ref="renderer" antialias orbit-ctrl resize :alpha="true" :background-alpha="0"
  11. :renderer="{ preserveDrawingBuffer: true }">
  12. <Camera :position="initialCameraPos" />
  13. <Scene ref="scene">
  14. <!-- 使用空组件的 ref 绑定场景 -->
  15. <Group ref="nodesGroup" />
  16. </Scene>
  17. </Renderer>
  18. </div>
  19. </el-card>
  20. <div v-for="info in infoBoxes" :key="info.id" class="info-box" :style="info.position">
  21. <div class="close-btn" @click="closeInfoBox(info.id)">×</div>
  22. <div class="content">节点ID: {{ info.nodeId }}</div>
  23. </div>
  24. </div>
  25. </template>
  26. <script setup>
  27. import { onMounted, ref, reactive, watch, nextTick, onBeforeUnmount } from 'vue';
  28. import { saveAs } from 'file-saver';
  29. import { postData, getData } from '@/api/axios.js'
  30. import { ElMessage } from 'element-plus'
  31. import { Renderer, Camera, Scene, Group } from 'troisjs';
  32. import { MeshBasicMaterial } from 'three';
  33. import * as THREE from 'three';
  34. import html2canvas from 'html2canvas';
  35. const props = defineProps({
  36. result: {
  37. type: Number,
  38. required: true,
  39. }
  40. })
  41. // 状态管理
  42. const loading = ref(true)
  43. const nodes = ref([])
  44. const edges = ref([])
  45. const lines = ref([])
  46. const renderer = ref(null)
  47. const scene = ref(null)
  48. const nodesGroup = ref(null)
  49. const infoBoxes = ref([])
  50. const selectedNodes = ref(new Set())
  51. const connectionLines = ref([])
  52. // 三维相关
  53. const raycaster = new THREE.Raycaster()
  54. const mouse = new THREE.Vector2()
  55. const initialCameraPos = ref({ x: 0, y: 0, z: 1 })
  56. const sceneBoundingSphere = new THREE.Sphere()
  57. // 相机初始状态
  58. const initialCameraState = ref({
  59. position: new THREE.Vector3(),
  60. target: new THREE.Vector3()
  61. })
  62. // 显示视图容器尺寸监控
  63. const graphContainer = ref(null)
  64. const containerRect = ref({
  65. left: 0,
  66. top: 0,
  67. width: 1,
  68. height: 1
  69. })
  70. // 信息框布局逻辑
  71. const layoutInfo = reactive({
  72. left: [],
  73. right: []
  74. })
  75. // 计算信息框位置
  76. const getBoxPosition = () => {
  77. const container = graphContainer.value
  78. const containerRect = container.getBoundingClientRect()
  79. const boxHeight = 80 // 信息框高度+间距
  80. const maxVisible = Math.floor((containerRect.height - 50) / boxHeight)
  81. // 优先填充左侧
  82. let side = 'left'
  83. if (layoutInfo.left.length >= maxVisible) {
  84. side = 'right'
  85. }
  86. // 计算新位置
  87. const baseY = 50 + layoutInfo[side].length * boxHeight
  88. layoutInfo[side].push(baseY)
  89. return {
  90. side,
  91. style: {
  92. [side === 'left' ? 'left' : 'right']: '20px',
  93. top: `${baseY}px`
  94. }
  95. }
  96. }
  97. const updateContainerRect = () => {
  98. if (!graphContainer.value) return
  99. const rect = graphContainer.value.getBoundingClientRect()
  100. containerRect.value = {
  101. left: rect.left,
  102. top: rect.top,
  103. width: rect.width,
  104. height: rect.height
  105. }
  106. }
  107. // 用于显示的各种颜色
  108. const COLORS = {
  109. NODE_DEFAULT: 0x0000FF, // 蓝色
  110. NODE_SELECTED: 0xFFA500,
  111. NODE_HOVER: 0x00FF00, // 绿色
  112. NODE_RELATED: 0xFFD700, // 金色
  113. EDGE_DEFAULT: 0x800080, // 紫色
  114. EDGE_HOVER: 0xFFA500 // 橙色
  115. }
  116. // 用于透明化的样式
  117. const STYLE = {
  118. HIGHLIGHT_OPACITY: 1.0,
  119. DIM_OPACITY: 0.05,
  120. NODE_COLOR: 0x0000FF,
  121. EDGE_COLOR: 0x800080,
  122. HOVER_NODE_COLOR: 0x00FF00,
  123. RELATED_NODE_COLOR: 0xFFD700,
  124. HOVER_EDGE_COLOR: 0xFFA500
  125. }
  126. // 动态创建节点与边的函数
  127. const createEdges = () => {
  128. // 清理旧对象
  129. if (nodesGroup.value?.group) {
  130. nodesGroup.value.group.children = [];
  131. }
  132. // 创建节点
  133. nodes.value.forEach(node => {
  134. const geometry = new THREE.SphereGeometry(0.2)
  135. const material = new THREE.MeshBasicMaterial({
  136. color: STYLE.NODE_COLOR,
  137. transparent: true,
  138. opacity: STYLE.HIGHLIGHT_OPACITY,
  139. })
  140. const sphere = new THREE.Mesh(geometry, material)
  141. sphere.position.set(...Object.values(node.coordinates))
  142. sphere.userData = { type: 'node', id: node.id }
  143. nodesGroup.value.group.add(sphere)
  144. })
  145. // 创建边(添加鼠标事件支持)
  146. lines.value.forEach(line => scene.value.scene.remove(line))
  147. lines.value = []
  148. edges.value.forEach(edge => {
  149. const points = edge.map(p => new THREE.Vector3(...Object.values(p.coordinates)))
  150. const geometry = new THREE.BufferGeometry().setFromPoints(points)
  151. const material = new THREE.LineBasicMaterial({
  152. color: STYLE.EDGE_COLOR,
  153. linewidth: 2,
  154. transparent: true,
  155. opacity: STYLE.HIGHLIGHT_OPACITY
  156. })
  157. const line = new THREE.Line(geometry, material)
  158. line.userData = {
  159. type: 'edge',
  160. from: edge[0].id, // 假设edge数据包含节点ID
  161. to: edge[1].id // 需要根据实际数据结构调整
  162. }
  163. scene.value.scene.add(line)
  164. lines.value.push(line)
  165. })
  166. // 计算包围球
  167. const box = new THREE.Box3().setFromObject(nodesGroup.value.group)
  168. box.getBoundingSphere(sceneBoundingSphere)
  169. // 动态设置相机初始位置
  170. const aspect = window.innerWidth / window.innerHeight
  171. initialCameraPos.value.z = sceneBoundingSphere.radius * 2.5 * (aspect > 1 ? 1 : 1.5)
  172. }
  173. // 鼠标悬浮时高亮描边
  174. const handleHover = (event) => {
  175. if (!graphContainer.value) return
  176. updateContainerRect()
  177. // 重置所有元素的样式
  178. nodesGroup.value.group.children.forEach(child => {
  179. if (!selectedNodes.value.has(child.userData.id)) {
  180. child.material.color.set(COLORS.NODE_DEFAULT)
  181. }
  182. child.material.opacity = STYLE.HIGHLIGHT_OPACITY
  183. })
  184. lines.value.forEach(line => {
  185. line.material.color.set(STYLE.EDGE_COLOR)
  186. line.material.opacity = STYLE.HIGHLIGHT_OPACITY
  187. })
  188. // 计算标准化设备坐标
  189. const x = (event.clientX - containerRect.value.left) / containerRect.value.width * 2 - 1
  190. const y = -(event.clientY - containerRect.value.top) / containerRect.value.height * 2 + 1
  191. // 射线检测
  192. raycaster.setFromCamera(new THREE.Vector2(x, y), renderer.value.camera)
  193. const intersects = raycaster.intersectObjects(nodesGroup.value.group.children)
  194. if (intersects.length > 0) {
  195. const currentNode = intersects[0].object
  196. const relatedNodes = new Set()
  197. const relatedEdges = []
  198. // 查找相关边和节点
  199. lines.value.forEach(line => {
  200. const edgeData = line.userData
  201. if (edgeData.from === currentNode.userData.id || edgeData.to === currentNode.userData.id) {
  202. relatedEdges.push(line)
  203. const relatedId = edgeData.from === currentNode.userData.id ? edgeData.to : edgeData.from
  204. relatedNodes.add(relatedId)
  205. }
  206. })
  207. // 设置高亮样式
  208. if (!selectedNodes.value.has(currentNode.userData.id)) {
  209. currentNode.material.color.set(STYLE.HOVER_NODE_COLOR)
  210. }
  211. // 高亮相关边
  212. // relatedEdges.forEach(line => {
  213. // line.material.color.set(STYLE.HOVER_EDGE_COLOR)
  214. // line.material.opacity = STYLE.HIGHLIGHT_OPACITY
  215. // })
  216. // // 高亮相关节点
  217. // nodesGroup.value.group.children.forEach(node => {
  218. // if (relatedNodes.has(node.userData.id)) {
  219. // node.material.color.set(STYLE.RELATED_NODE_COLOR)
  220. // node.material.opacity = STYLE.HIGHLIGHT_OPACITY
  221. // }
  222. // })
  223. // 淡化无关元素
  224. nodesGroup.value.group.children.forEach(node => {
  225. if (node !== currentNode && !relatedNodes.has(node.userData.id)) {
  226. node.material.opacity = STYLE.DIM_OPACITY
  227. }
  228. })
  229. lines.value.forEach(line => {
  230. if (!relatedEdges.includes(line)) {
  231. line.material.opacity = STYLE.DIM_OPACITY
  232. }
  233. })
  234. }
  235. }
  236. // 用于生成信息框ID
  237. const generateId = () => Date.now().toString(36) + Math.random().toString(36).substr(2)
  238. // 添加信息框显示
  239. const addInfoBox = (node, nodeId) => {
  240. const position = getBoxPosition();
  241. infoBoxes.value.push({
  242. id: generateId(),
  243. nodeId,
  244. position: position.style,
  245. side: position.side
  246. });
  247. // 更新节点状态
  248. selectedNodes.value.add(nodeId);
  249. node.material.color.set(COLORS.NODE_SELECTED);
  250. };
  251. // 移除信息框显示
  252. const removeInfoBox = (nodeId) => {
  253. const removedBox = infoBoxes.value.find(b => b.nodeId == nodeId)
  254. closeInfoBox(removedBox.id)
  255. }
  256. const closeInfoBox = (boxId) => {
  257. const boxIndex = infoBoxes.value.findIndex(b => b.id === boxId)
  258. if (boxIndex === -1) return
  259. // 移除信息框并重置布局计数器
  260. const removedBox = infoBoxes.value.splice(boxIndex, 1)[0]
  261. // 从布局数组中移除位置
  262. const posIndex = layoutInfo[removedBox.side].indexOf(parseInt(removedBox.position.top))
  263. if (posIndex > -1) {
  264. layoutInfo[removedBox.side].splice(posIndex, 1)
  265. }
  266. // 重新排列同侧信息框
  267. infoBoxes.value
  268. .filter(b => b.side === removedBox.side)
  269. .forEach((box, index) => {
  270. const newTop = 50 + index * 80
  271. box.position.top = `${newTop}px`
  272. layoutInfo[removedBox.side][index] = newTop
  273. })
  274. selectedNodes.value.delete(removedBox.nodeId)
  275. const targetNode = nodesGroup.value.group.children.find(
  276. n => n.userData.id === removedBox.nodeId
  277. )
  278. if (targetNode) {
  279. targetNode.material.color.set(COLORS.NODE_DEFAULT)
  280. }
  281. }
  282. const handleClick = (event) => {
  283. if (!graphContainer.value) return
  284. updateContainerRect()
  285. // 计算标准化设备坐标
  286. const x = (event.clientX - containerRect.value.left) / containerRect.value.width * 2 - 1
  287. const y = -(event.clientY - containerRect.value.top) / containerRect.value.height * 2 + 1
  288. raycaster.setFromCamera(new THREE.Vector2(x, y), renderer.value.camera)
  289. const intersects = raycaster.intersectObjects([
  290. ...nodesGroup.value.group.children
  291. ])
  292. if (intersects.length > 0) {
  293. const obj = intersects[0].object
  294. const nodeId = obj.userData.id
  295. if (selectedNodes.value.has(nodeId)) {
  296. removeInfoBox(nodeId)
  297. } else {
  298. addInfoBox(obj, nodeId)
  299. }
  300. }
  301. }
  302. const handleResize = () => {
  303. nextTick(() => {
  304. if (renderer.value?.renderer) {
  305. const container = document.querySelector('.graph-container');
  306. // 保持宽高比避免变形
  307. renderer.value.camera.aspect = container.clientWidth / container.clientHeight;
  308. renderer.value.camera.updateProjectionMatrix();
  309. renderer.value.renderer.setSize(container.clientWidth, container.clientHeight);
  310. }
  311. });
  312. };
  313. // 重置视角函数
  314. const resetCamera = () => {
  315. console.log(initialCameraState.value.position)
  316. // 恢复到保存的初始状态
  317. renderer.value.camera.position.copy(initialCameraState.value.position);
  318. renderer.value.three.cameraCtrl.target.copy(initialCameraState.value.target);
  319. // 必须执行以下操作
  320. renderer.value.three.cameraCtrl.update();
  321. renderer.value.three.cameraCtrl.reset(); // 调用原生重置方法
  322. renderer.value.camera.lookAt(initialCameraState.value.target);
  323. };
  324. // 截图视图函数
  325. const captureView = async () => {
  326. // 渲染最终帧
  327. renderer.value.renderer.render(scene.value.scene, renderer.value.camera);
  328. // 获取基础画布
  329. const mainCanvas = renderer.value.renderer.domElement;
  330. const finalCanvas = document.createElement('canvas');
  331. const ctx = finalCanvas.getContext('2d');
  332. // 设置画布尺寸
  333. finalCanvas.width = mainCanvas.width;
  334. finalCanvas.height = mainCanvas.height;
  335. // 绘制三维内容
  336. ctx.drawImage(mainCanvas, 0, 0);
  337. // 绘制信息框
  338. await Promise.all(infoBoxes.value.map(async (info, index) => {
  339. const box = document.querySelector(`.info-box:nth-child(${index + 2})`);
  340. const boxCanvas = await html2canvas(box);
  341. ctx.drawImage(boxCanvas,
  342. box.offsetLeft * window.devicePixelRatio,
  343. box.offsetTop * window.devicePixelRatio
  344. );
  345. }));
  346. // 保存结果
  347. finalCanvas.toBlob(blob => {
  348. saveAs(blob, `visualization_${Date.now()}.png`);
  349. });
  350. };
  351. onMounted(() => {
  352. window.addEventListener('resize', handleResize);
  353. window.addEventListener('mousemove', handleHover);
  354. window.addEventListener('click', handleClick);
  355. // 用于监控视图容器变化
  356. window.addEventListener('resize', updateContainerRect)
  357. window.addEventListener('scroll', updateContainerRect, true)
  358. // 根据result的id获取图像结果
  359. getData('/generateGraph', { method: 'web', result: props.result }).then(response => {
  360. nodes.value = []
  361. edges.value = []
  362. response.data.nodes.forEach(node => {
  363. nodes.value.push(node)
  364. })
  365. response.data.edges.forEach(edge => {
  366. edges.value.push([{
  367. coordinates: response.data.nodes.find(n => n.id == edge.from).coordinates,
  368. id: edge.from
  369. }, {
  370. coordinates: response.data.nodes.find(n => n.id == edge.to).coordinates,
  371. id: edge.to
  372. }])
  373. })
  374. nextTick(() => {
  375. createEdges();
  376. handleResize();
  377. // 计算场景包围盒
  378. const box = new THREE.Box3().setFromObject(nodesGroup.value.group);
  379. const center = box.getCenter(new THREE.Vector3());
  380. const size = box.getSize(new THREE.Vector3()).length();
  381. // 保存初始参数
  382. initialCameraState.value.position.set(
  383. center.x,
  384. center.y,
  385. center.z + size * 1.5 // 初始距离
  386. );
  387. initialCameraState.value.target.copy(center);
  388. // 强制更新控制器
  389. renderer.value.three.cameraCtrl.target.copy(center);
  390. renderer.value.three.cameraCtrl.update();
  391. renderer.value.three.cameraCtrl.saveState(); // 关键:保存初始状态
  392. });
  393. }).catch(error => {
  394. ElMessage.error("获取图数据失败")
  395. console.log(error)
  396. })
  397. })
  398. onBeforeUnmount(() => {
  399. window.removeEventListener('resize', handleResize);
  400. window.removeEventListener('mousemove', handleHover);
  401. window.removeEventListener('click', handleClick);
  402. window.removeEventListener('resize', updateContainerRect)
  403. window.removeEventListener('scroll', updateContainerRect, true)
  404. });
  405. </script>
  406. <style scoped>
  407. .el-card {
  408. height: 100%;
  409. }
  410. .graph-container {
  411. position: relative;
  412. height: 100%;
  413. overflow-y: hidden;
  414. /* 自动显示滚动条 */
  415. }
  416. canvas {
  417. margin: auto;
  418. display: block;
  419. background: transparent !important;
  420. mix-blend-mode: normal !important;
  421. }
  422. .container {
  423. height: 600px;
  424. display: flex;
  425. flex-direction: column;
  426. }
  427. .full-width-card {
  428. flex: 1;
  429. min-height: 0;
  430. }
  431. :deep(.el-card__body) {
  432. height: 100%;
  433. padding: 0px;
  434. }
  435. .toolbar {
  436. display: flex;
  437. justify-content: space-between;
  438. align-items: center;
  439. padding: 0 20px;
  440. }
  441. .button-group {
  442. margin-left: auto;
  443. display: flex;
  444. gap: 10px;
  445. }
  446. .info-box {
  447. position: absolute;
  448. background: rgba(255, 255, 255, 0.95);
  449. border: 2px solid #409EFF;
  450. border-radius: 8px;
  451. padding: 15px;
  452. width: 200px;
  453. min-height: 60px;
  454. box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
  455. z-index: 1000;
  456. pointer-events: auto;
  457. transition: top 0.3s ease;
  458. }
  459. .info-box[left] {
  460. left: 20px;
  461. }
  462. .info-box[right] {
  463. right: 20px;
  464. }
  465. .close-btn {
  466. position: absolute;
  467. right: 8px;
  468. top: 8px;
  469. cursor: pointer;
  470. font-size: 18px;
  471. color: #666;
  472. transition: color 0.2s;
  473. }
  474. .close-btn:hover {
  475. color: #409EFF;
  476. }
  477. .content {
  478. font-size: 14px;
  479. color: #303133;
  480. word-break: break-all;
  481. }
  482. .connection-line {
  483. position: absolute;
  484. pointer-events: none;
  485. }
  486. </style>