123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565 |
- <template>
- <div class="container">
- <el-card class="full-width-card">
- <div class="toolbar">
- <p>三维图数据可视化</p>
- <el-button type="primary" style="margin-left: auto;" @click="resetCamera">对正视角</el-button>
- <el-button type="primary" @click="captureView">保存视图</el-button>
- </div>
- <div class="graph-container" ref="graphContainer">
- <Renderer ref="renderer" antialias orbit-ctrl resize :alpha="true" :background-alpha="0"
- :renderer="{ preserveDrawingBuffer: true }">
- <Camera :position="initialCameraPos" />
- <Scene ref="scene">
- <!-- 使用空组件的 ref 绑定场景 -->
- <Group ref="nodesGroup" />
- </Scene>
- </Renderer>
- </div>
- </el-card>
- <div v-for="info in infoBoxes" :key="info.id" class="info-box" :style="info.position">
- <div class="close-btn" @click="closeInfoBox(info.id)">×</div>
- <div class="content">节点ID: {{ info.nodeId }}</div>
- </div>
- </div>
- </template>
- <script setup>
- import { onMounted, ref, reactive, watch, nextTick, onBeforeUnmount } from 'vue';
- import { saveAs } from 'file-saver';
- import { postData, getData } from '@/api/axios.js'
- import { ElMessage } from 'element-plus'
- import { Renderer, Camera, Scene, Group } from 'troisjs';
- import { MeshBasicMaterial } from 'three';
- import * as THREE from 'three';
- import html2canvas from 'html2canvas';
- const props = defineProps({
- result: {
- type: Number,
- required: true,
- }
- })
- // 状态管理
- const loading = ref(true)
- const nodes = ref([])
- const edges = ref([])
- const lines = ref([])
- const renderer = ref(null)
- const scene = ref(null)
- const nodesGroup = ref(null)
- const infoBoxes = ref([])
- const selectedNodes = ref(new Set())
- const connectionLines = ref([])
- // 三维相关
- const raycaster = new THREE.Raycaster()
- const mouse = new THREE.Vector2()
- const initialCameraPos = ref({ x: 0, y: 0, z: 1 })
- const sceneBoundingSphere = new THREE.Sphere()
- // 相机初始状态
- const initialCameraState = ref({
- position: new THREE.Vector3(),
- target: new THREE.Vector3()
- })
- // 显示视图容器尺寸监控
- const graphContainer = ref(null)
- const containerRect = ref({
- left: 0,
- top: 0,
- width: 1,
- height: 1
- })
- // 信息框布局逻辑
- const layoutInfo = reactive({
- left: [],
- right: []
- })
- // 计算信息框位置
- const getBoxPosition = () => {
- const container = graphContainer.value
- const containerRect = container.getBoundingClientRect()
- const boxHeight = 80 // 信息框高度+间距
- const maxVisible = Math.floor((containerRect.height - 50) / boxHeight)
- // 优先填充左侧
- let side = 'left'
- if (layoutInfo.left.length >= maxVisible) {
- side = 'right'
- }
- // 计算新位置
- const baseY = 50 + layoutInfo[side].length * boxHeight
- layoutInfo[side].push(baseY)
- return {
- side,
- style: {
- [side === 'left' ? 'left' : 'right']: '20px',
- top: `${baseY}px`
- }
- }
- }
- const updateContainerRect = () => {
- if (!graphContainer.value) return
- const rect = graphContainer.value.getBoundingClientRect()
- containerRect.value = {
- left: rect.left,
- top: rect.top,
- width: rect.width,
- height: rect.height
- }
- }
- // 用于显示的各种颜色
- const COLORS = {
- NODE_DEFAULT: 0x0000FF, // 蓝色
- NODE_SELECTED: 0xFFA500,
- NODE_HOVER: 0x00FF00, // 绿色
- NODE_RELATED: 0xFFD700, // 金色
- EDGE_DEFAULT: 0x800080, // 紫色
- EDGE_HOVER: 0xFFA500 // 橙色
- }
- // 用于透明化的样式
- const STYLE = {
- HIGHLIGHT_OPACITY: 1.0,
- DIM_OPACITY: 0.05,
- NODE_COLOR: 0x0000FF,
- EDGE_COLOR: 0x800080,
- HOVER_NODE_COLOR: 0x00FF00,
- RELATED_NODE_COLOR: 0xFFD700,
- HOVER_EDGE_COLOR: 0xFFA500
- }
- // 动态创建节点与边的函数
- const createEdges = () => {
- // 清理旧对象
- if (nodesGroup.value?.group) {
- nodesGroup.value.group.children = [];
- }
- // 创建节点
- nodes.value.forEach(node => {
- const geometry = new THREE.SphereGeometry(0.2)
- const material = new THREE.MeshBasicMaterial({
- color: STYLE.NODE_COLOR,
- transparent: true,
- opacity: STYLE.HIGHLIGHT_OPACITY,
- })
- const sphere = new THREE.Mesh(geometry, material)
- sphere.position.set(...Object.values(node.coordinates))
- sphere.userData = { type: 'node', id: node.id }
- nodesGroup.value.group.add(sphere)
- })
- // 创建边(添加鼠标事件支持)
- lines.value.forEach(line => scene.value.scene.remove(line))
- lines.value = []
- edges.value.forEach(edge => {
- const points = edge.map(p => new THREE.Vector3(...Object.values(p.coordinates)))
- const geometry = new THREE.BufferGeometry().setFromPoints(points)
- const material = new THREE.LineBasicMaterial({
- color: STYLE.EDGE_COLOR,
- linewidth: 2,
- transparent: true,
- opacity: STYLE.HIGHLIGHT_OPACITY
- })
- const line = new THREE.Line(geometry, material)
- line.userData = {
- type: 'edge',
- from: edge[0].id, // 假设edge数据包含节点ID
- to: edge[1].id // 需要根据实际数据结构调整
- }
- scene.value.scene.add(line)
- lines.value.push(line)
- })
- // 计算包围球
- const box = new THREE.Box3().setFromObject(nodesGroup.value.group)
- box.getBoundingSphere(sceneBoundingSphere)
- // 动态设置相机初始位置
- const aspect = window.innerWidth / window.innerHeight
- initialCameraPos.value.z = sceneBoundingSphere.radius * 2.5 * (aspect > 1 ? 1 : 1.5)
- }
- // 鼠标悬浮时高亮描边
- const handleHover = (event) => {
- if (!graphContainer.value) return
- updateContainerRect()
- // 重置所有元素的样式
- nodesGroup.value.group.children.forEach(child => {
- if (!selectedNodes.value.has(child.userData.id)) {
- child.material.color.set(COLORS.NODE_DEFAULT)
- }
- child.material.opacity = STYLE.HIGHLIGHT_OPACITY
- })
- lines.value.forEach(line => {
- line.material.color.set(STYLE.EDGE_COLOR)
- line.material.opacity = STYLE.HIGHLIGHT_OPACITY
- })
- // 计算标准化设备坐标
- const x = (event.clientX - containerRect.value.left) / containerRect.value.width * 2 - 1
- const y = -(event.clientY - containerRect.value.top) / containerRect.value.height * 2 + 1
- // 射线检测
- raycaster.setFromCamera(new THREE.Vector2(x, y), renderer.value.camera)
- const intersects = raycaster.intersectObjects(nodesGroup.value.group.children)
- if (intersects.length > 0) {
- const currentNode = intersects[0].object
- const relatedNodes = new Set()
- const relatedEdges = []
- // 查找相关边和节点
- lines.value.forEach(line => {
- const edgeData = line.userData
- if (edgeData.from === currentNode.userData.id || edgeData.to === currentNode.userData.id) {
- relatedEdges.push(line)
- const relatedId = edgeData.from === currentNode.userData.id ? edgeData.to : edgeData.from
- relatedNodes.add(relatedId)
- }
- })
- // 设置高亮样式
- if (!selectedNodes.value.has(currentNode.userData.id)) {
- currentNode.material.color.set(STYLE.HOVER_NODE_COLOR)
- }
- // 高亮相关边
- // relatedEdges.forEach(line => {
- // line.material.color.set(STYLE.HOVER_EDGE_COLOR)
- // line.material.opacity = STYLE.HIGHLIGHT_OPACITY
- // })
- // // 高亮相关节点
- // nodesGroup.value.group.children.forEach(node => {
- // if (relatedNodes.has(node.userData.id)) {
- // node.material.color.set(STYLE.RELATED_NODE_COLOR)
- // node.material.opacity = STYLE.HIGHLIGHT_OPACITY
- // }
- // })
- // 淡化无关元素
- nodesGroup.value.group.children.forEach(node => {
- if (node !== currentNode && !relatedNodes.has(node.userData.id)) {
- node.material.opacity = STYLE.DIM_OPACITY
- }
- })
- lines.value.forEach(line => {
- if (!relatedEdges.includes(line)) {
- line.material.opacity = STYLE.DIM_OPACITY
- }
- })
- }
- }
- // 用于生成信息框ID
- const generateId = () => Date.now().toString(36) + Math.random().toString(36).substr(2)
- // 添加信息框显示
- const addInfoBox = (node, nodeId) => {
- const position = getBoxPosition();
- infoBoxes.value.push({
- id: generateId(),
- nodeId,
- position: position.style,
- side: position.side
- });
- // 更新节点状态
- selectedNodes.value.add(nodeId);
- node.material.color.set(COLORS.NODE_SELECTED);
- };
- // 移除信息框显示
- const removeInfoBox = (nodeId) => {
- const removedBox = infoBoxes.value.find(b => b.nodeId == nodeId)
- closeInfoBox(removedBox.id)
- }
- const closeInfoBox = (boxId) => {
- const boxIndex = infoBoxes.value.findIndex(b => b.id === boxId)
- if (boxIndex === -1) return
- // 移除信息框并重置布局计数器
- const removedBox = infoBoxes.value.splice(boxIndex, 1)[0]
- // 从布局数组中移除位置
- const posIndex = layoutInfo[removedBox.side].indexOf(parseInt(removedBox.position.top))
- if (posIndex > -1) {
- layoutInfo[removedBox.side].splice(posIndex, 1)
- }
- // 重新排列同侧信息框
- infoBoxes.value
- .filter(b => b.side === removedBox.side)
- .forEach((box, index) => {
- const newTop = 50 + index * 80
- box.position.top = `${newTop}px`
- layoutInfo[removedBox.side][index] = newTop
- })
- selectedNodes.value.delete(removedBox.nodeId)
- const targetNode = nodesGroup.value.group.children.find(
- n => n.userData.id === removedBox.nodeId
- )
- if (targetNode) {
- targetNode.material.color.set(COLORS.NODE_DEFAULT)
- }
- }
- const handleClick = (event) => {
- if (!graphContainer.value) return
- updateContainerRect()
- // 计算标准化设备坐标
- const x = (event.clientX - containerRect.value.left) / containerRect.value.width * 2 - 1
- const y = -(event.clientY - containerRect.value.top) / containerRect.value.height * 2 + 1
- raycaster.setFromCamera(new THREE.Vector2(x, y), renderer.value.camera)
- const intersects = raycaster.intersectObjects([
- ...nodesGroup.value.group.children
- ])
- if (intersects.length > 0) {
- const obj = intersects[0].object
- const nodeId = obj.userData.id
- if (selectedNodes.value.has(nodeId)) {
- removeInfoBox(nodeId)
- } else {
- addInfoBox(obj, nodeId)
- }
- }
- }
- const handleResize = () => {
- nextTick(() => {
- if (renderer.value?.renderer) {
- const container = document.querySelector('.graph-container');
- // 保持宽高比避免变形
- renderer.value.camera.aspect = container.clientWidth / container.clientHeight;
- renderer.value.camera.updateProjectionMatrix();
- renderer.value.renderer.setSize(container.clientWidth, container.clientHeight);
- }
- });
- };
- // 重置视角函数
- const resetCamera = () => {
- console.log(initialCameraState.value.position)
- // 恢复到保存的初始状态
- renderer.value.camera.position.copy(initialCameraState.value.position);
- renderer.value.three.cameraCtrl.target.copy(initialCameraState.value.target);
- // 必须执行以下操作
- renderer.value.three.cameraCtrl.update();
- renderer.value.three.cameraCtrl.reset(); // 调用原生重置方法
- renderer.value.camera.lookAt(initialCameraState.value.target);
- };
- // 截图视图函数
- const captureView = async () => {
- // 渲染最终帧
- renderer.value.renderer.render(scene.value.scene, renderer.value.camera);
- // 获取基础画布
- const mainCanvas = renderer.value.renderer.domElement;
- const finalCanvas = document.createElement('canvas');
- const ctx = finalCanvas.getContext('2d');
- // 设置画布尺寸
- finalCanvas.width = mainCanvas.width;
- finalCanvas.height = mainCanvas.height;
- // 绘制三维内容
- ctx.drawImage(mainCanvas, 0, 0);
- // 绘制信息框
- await Promise.all(infoBoxes.value.map(async (info, index) => {
- const box = document.querySelector(`.info-box:nth-child(${index + 2})`);
- const boxCanvas = await html2canvas(box);
- ctx.drawImage(boxCanvas,
- box.offsetLeft * window.devicePixelRatio,
- box.offsetTop * window.devicePixelRatio
- );
- }));
- // 保存结果
- finalCanvas.toBlob(blob => {
- saveAs(blob, `visualization_${Date.now()}.png`);
- });
- };
- onMounted(() => {
- window.addEventListener('resize', handleResize);
- window.addEventListener('mousemove', handleHover);
- window.addEventListener('click', handleClick);
- // 用于监控视图容器变化
- window.addEventListener('resize', updateContainerRect)
- window.addEventListener('scroll', updateContainerRect, true)
- // 根据result的id获取图像结果
- getData('/generateGraph', { method: 'web', result: props.result }).then(response => {
- nodes.value = []
- edges.value = []
- response.data.nodes.forEach(node => {
- nodes.value.push(node)
- })
- response.data.edges.forEach(edge => {
- edges.value.push([{
- coordinates: response.data.nodes.find(n => n.id == edge.from).coordinates,
- id: edge.from
- }, {
- coordinates: response.data.nodes.find(n => n.id == edge.to).coordinates,
- id: edge.to
- }])
- })
- nextTick(() => {
- createEdges();
- handleResize();
- // 计算场景包围盒
- const box = new THREE.Box3().setFromObject(nodesGroup.value.group);
- const center = box.getCenter(new THREE.Vector3());
- const size = box.getSize(new THREE.Vector3()).length();
- // 保存初始参数
- initialCameraState.value.position.set(
- center.x,
- center.y,
- center.z + size * 1.5 // 初始距离
- );
- initialCameraState.value.target.copy(center);
- // 强制更新控制器
- renderer.value.three.cameraCtrl.target.copy(center);
- renderer.value.three.cameraCtrl.update();
- renderer.value.three.cameraCtrl.saveState(); // 关键:保存初始状态
- });
- }).catch(error => {
- ElMessage.error("获取图数据失败")
- console.log(error)
- })
- })
- onBeforeUnmount(() => {
- window.removeEventListener('resize', handleResize);
- window.removeEventListener('mousemove', handleHover);
- window.removeEventListener('click', handleClick);
- window.removeEventListener('resize', updateContainerRect)
- window.removeEventListener('scroll', updateContainerRect, true)
- });
- </script>
- <style scoped>
- .el-card {
- height: 100%;
- }
- .graph-container {
- position: relative;
- height: 100%;
- overflow-y: hidden;
- /* 自动显示滚动条 */
- }
- canvas {
- margin: auto;
- display: block;
- background: transparent !important;
- mix-blend-mode: normal !important;
- }
- .container {
- height: 600px;
- display: flex;
- flex-direction: column;
- }
- .full-width-card {
- flex: 1;
- min-height: 0;
- }
- :deep(.el-card__body) {
- height: 100%;
- padding: 0px;
- }
- .toolbar {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 0 20px;
- }
- .button-group {
- margin-left: auto;
- display: flex;
- gap: 10px;
- }
- .info-box {
- position: absolute;
- background: rgba(255, 255, 255, 0.95);
- border: 2px solid #409EFF;
- border-radius: 8px;
- padding: 15px;
- width: 200px;
- min-height: 60px;
- box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
- z-index: 1000;
- pointer-events: auto;
- transition: top 0.3s ease;
- }
- .info-box[left] {
- left: 20px;
- }
- .info-box[right] {
- right: 20px;
- }
- .close-btn {
- position: absolute;
- right: 8px;
- top: 8px;
- cursor: pointer;
- font-size: 18px;
- color: #666;
- transition: color 0.2s;
- }
- .close-btn:hover {
- color: #409EFF;
- }
- .content {
- font-size: 14px;
- color: #303133;
- word-break: break-all;
- }
- .connection-line {
- position: absolute;
- pointer-events: none;
- }
- </style>
|