graph.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614
  1. <template>
  2. <view class="graph-container r-20">
  3. <view class="canvas-wrapper r-20">
  4. <canvas
  5. canvas-id="graphCanvas"
  6. class="graph-canvas r-20"
  7. @touchstart="onTouchStart"
  8. @touchmove="onTouchMove"
  9. @touchend="onTouchEnd"
  10. ></canvas>
  11. </view>
  12. <!-- 控制面板 -->
  13. <!-- <view class="control-panel">-->
  14. <!-- <view class="control-group">-->
  15. <!-- <text class="control-label">布局算法:</text>-->
  16. <!-- <picker @change="onLayoutChange" :value="layoutIndex" :range="layoutOptions">-->
  17. <!-- <view class="picker">{{ layoutOptions[layoutIndex] }}</view>-->
  18. <!-- </picker>-->
  19. <!-- </view>-->
  20. <!-- <view class="control-group">-->
  21. <!-- <text class="control-label">显示标签:</text>-->
  22. <!-- <switch :checked="showLabels" @change="toggleLabels" />-->
  23. <!-- </view>-->
  24. <!-- <view class="button-group">-->
  25. <!-- <button class="btn primary" @tap="addRandomNode">添加节点</button>-->
  26. <!-- <button class="btn secondary" @tap="resetView">重置视图</button>-->
  27. <!-- <button class="btn warning" @tap="exportData">导出数据</button>-->
  28. <!-- </view>-->
  29. <!-- </view>-->
  30. <!-- 节点信息面板 -->
  31. <!-- <view v-if="selectedNode" class="node-info-panel">-->
  32. <!-- <view class="panel-header">-->
  33. <!-- <text class="panel-title">节点详情</text>-->
  34. <!-- <text class="close-btn" @tap="deselectNode">×</text>-->
  35. <!-- </view>-->
  36. <!-- <view class="node-details">-->
  37. <!-- <view class="detail-item">-->
  38. <!-- <text class="detail-label">名称:</text>-->
  39. <!-- <text class="detail-value">{{ selectedNode.name }}</text>-->
  40. <!-- </view>-->
  41. <!-- <view class="detail-item">-->
  42. <!-- <text class="detail-label">ID:</text>-->
  43. <!-- <text class="detail-value">{{ selectedNode.id }}</text>-->
  44. <!-- </view>-->
  45. <!-- <view class="detail-item">-->
  46. <!-- <text class="detail-label">类型:</text>-->
  47. <!-- <text class="detail-value">{{ selectedNode.type }}</text>-->
  48. <!-- </view>-->
  49. <!-- <view class="detail-item">-->
  50. <!-- <text class="detail-label">连接数:</text>-->
  51. <!-- <text class="detail-value">{{ getNodeDegree(selectedNode) }}</text>-->
  52. <!-- </view>-->
  53. <!-- </view>-->
  54. <!-- </view>-->
  55. <!-- 统计信息 -->
  56. <view class="stats-panel">
  57. <view class="stats-box"> <view class="stats-icon-g stats-icon"></view> <text class="stat-item">资产</text></view>
  58. <view class="stats-box"> <view class="stats-icon-g stats-icon"></view> <text class="stat-item">资产</text></view>
  59. <view class="stats-box"> <view class="stats-icon-g stats-icon"></view> <text class="stat-item">资产</text></view>
  60. <view class="stats-box"> <view class="stats-icon-g stats-icon"></view> <text class="stat-item">资产</text></view>
  61. <view class="stats-box"> <view class="stats-icon-g stats-icon"></view> <text class="stat-item">资产</text></view>
  62. <view class="stats-box"> <view class="stats-icon-g stats-icon"></view> <text class="stat-item">资产</text></view>
  63. </view>
  64. </view>
  65. </template>
  66. <script>
  67. export default {
  68. name:'graph',
  69. data() {
  70. return {
  71. ctx: null,
  72. canvasWidth: 750,
  73. canvasHeight: 500,
  74. graphData: {
  75. nodes: [],
  76. links: []
  77. },
  78. selectedNode: null,
  79. hoveredNode: null,
  80. showLabels: true,
  81. layoutIndex: 2,
  82. layoutOptions: ['力导向布局', '环形布局', '树状布局'],
  83. isDragging: false,
  84. dragStartX: 0,
  85. dragStartY: 0,
  86. offsetX: 0,
  87. offsetY: 0,
  88. scale: 1,
  89. animationId: null
  90. }
  91. },
  92. onUnload() {
  93. this.stopAnimation();
  94. },
  95. mounted() {
  96. this.initCanvas();
  97. this.initGraphData();
  98. this.startAnimation();
  99. },
  100. methods: {
  101. initCanvas() {
  102. this.ctx = uni.createCanvasContext('graphCanvas', this);
  103. // 获取系统信息设置Canvas尺寸
  104. const systemInfo = uni.getSystemInfoSync();
  105. this.canvasWidth = systemInfo.windowWidth;
  106. this.canvasHeight = systemInfo.windowHeight * 0.6;
  107. },
  108. initGraphData() {
  109. // 示例数据
  110. this.graphData = {
  111. nodes: [
  112. { id: 'node1', name: '中心节点', type: 'center', x: 275, y: 150, size: 20, color: '#ff6b6b' },
  113. { id: 'node2', name: '用户节点', type: 'user', x: 275, y: 150, size: 15, color: '#4ecdc4' },
  114. { id: 'node3', name: '产品节点', type: 'product', x: 275, y: 150, size: 15, color: '#45b7d1' },
  115. { id: 'node4', name: '分类节点', type: 'category', x: 275, y: 150, size: 15, color: '#96ceb4' },
  116. { id: 'node5', name: '服务节点', type: 'service', x: 275, y: 150, size: 15, color: '#feca57' }
  117. ],
  118. links: [
  119. { source: 'node1', target: 'node2' },
  120. { source: 'node1', target: 'node3' },
  121. { source: 'node1', target: 'node4' },
  122. { source: 'node1', target: 'node5' },
  123. { source: 'node2', target: 'node3' },
  124. { source: 'node4', target: 'node5' }
  125. ]
  126. };
  127. },
  128. startAnimation() {
  129. const animate = () => {
  130. this.updateLayout();
  131. this.drawGraph();
  132. this.animationId = requestAnimationFrame(animate);
  133. };
  134. animate();
  135. },
  136. stopAnimation() {
  137. if (this.animationId) {
  138. cancelAnimationFrame(this.animationId);
  139. }
  140. },
  141. updateLayout() {
  142. // 简单的力导向布局模拟
  143. if (this.layoutIndex === 0) {
  144. this.applyForceDirectedLayout();
  145. } else if (this.layoutIndex === 1) {
  146. this.applyCircularLayout();
  147. } else if (this.layoutIndex === 2) {
  148. this.applyTreeLayout();
  149. }
  150. },
  151. applyForceDirectedLayout() {
  152. const centerX = this.canvasWidth / 2 + this.offsetX;
  153. const centerY = this.canvasHeight / 2 + this.offsetY;
  154. // 简化的力导向布局
  155. this.graphData.nodes.forEach((node, i) => {
  156. if (node.id === 'node1') {
  157. // 中心节点保持在中心
  158. node.x = centerX;
  159. node.y = centerY;
  160. return;
  161. }
  162. // 向中心节点的引力
  163. const centerNode = this.graphData.nodes[0];
  164. const dx = centerNode.x - node.x;
  165. const dy = centerNode.y - node.y;
  166. const distance = Math.sqrt(dx * dx + dy * dy);
  167. if (distance > 0) {
  168. const force = 0.1;
  169. node.x += dx * force;
  170. node.y += dy * force;
  171. }
  172. // 节点间的斥力
  173. this.graphData.nodes.forEach((otherNode, j) => {
  174. if (i !== j) {
  175. const dx = node.x - otherNode.x;
  176. const dy = node.y - otherNode.y;
  177. const distance = Math.sqrt(dx * dx + dy * dy);
  178. if (distance > 0 && distance < 100) {
  179. const force = 50 / (distance * distance);
  180. node.x += dx * force * 0.1;
  181. node.y += dy * force * 0.1;
  182. }
  183. }
  184. });
  185. });
  186. },
  187. applyCircularLayout() {
  188. const centerX = this.canvasWidth / 2 + this.offsetX;
  189. const centerY = this.canvasHeight / 2 + this.offsetY;
  190. const radius = 150;
  191. this.graphData.nodes.forEach((node, i) => {
  192. if (node.id === 'node1') {
  193. node.x = centerX;
  194. node.y = centerY;
  195. } else {
  196. const angle = ((i - 1) / (this.graphData.nodes.length - 1)) * Math.PI * 2;
  197. node.x = centerX + Math.cos(angle) * radius;
  198. node.y = centerY + Math.sin(angle) * radius;
  199. }
  200. });
  201. },
  202. applyTreeLayout() {
  203. const centerX = this.canvasWidth / 2 + this.offsetX;
  204. const centerY = this.canvasHeight / 2 + this.offsetY;
  205. this.graphData.nodes.forEach((node, i) => {
  206. if (node.id === 'node1') {
  207. node.x = centerX;
  208. node.y = centerY;
  209. } else {
  210. const angle = ((i - 1) / (this.graphData.nodes.length - 1)) * Math.PI * 2;
  211. const radius = 120;
  212. node.x = centerX + Math.cos(angle) * radius;
  213. node.y = centerY + Math.sin(angle) * radius;
  214. }
  215. });
  216. },
  217. drawGraph() {
  218. if (!this.ctx) return;
  219. // 清空画布
  220. this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
  221. // 设置缩放
  222. this.ctx.scale(this.scale, this.scale);
  223. // 绘制连接线
  224. this.drawLinks();
  225. // 绘制节点
  226. this.drawNodes();
  227. // 绘制标签
  228. if (this.showLabels) {
  229. this.drawLabels();
  230. }
  231. this.ctx.draw();
  232. },
  233. drawLinks() {
  234. this.graphData.links.forEach(link => {
  235. const sourceNode = this.graphData.nodes.find(n => n.id === link.source);
  236. const targetNode = this.graphData.nodes.find(n => n.id === link.target);
  237. if (sourceNode && targetNode) {
  238. const isHighlighted = this.isLinkHighlighted(link);
  239. this.ctx.beginPath();
  240. this.ctx.moveTo(sourceNode.x, sourceNode.y);
  241. this.ctx.lineTo(targetNode.x, targetNode.y);
  242. this.ctx.strokeStyle = isHighlighted ? '#ff6b6b' : '#cccccc';
  243. this.ctx.lineWidth = isHighlighted ? 3 : 2;
  244. this.ctx.stroke();
  245. }
  246. });
  247. },
  248. drawNodes() {
  249. this.graphData.nodes.forEach(node => {
  250. const isSelected = this.selectedNode && this.selectedNode.id === node.id;
  251. const isHovered = this.hoveredNode && this.hoveredNode.id === node.id;
  252. const isHighlighted = isSelected || isHovered || this.isNodeConnected(node);
  253. // 绘制节点
  254. this.ctx.beginPath();
  255. this.ctx.arc(node.x, node.y, isHighlighted ? node.size + 3 : node.size, 0, 2 * Math.PI);
  256. this.ctx.fillStyle = node.color;
  257. this.ctx.fill();
  258. // 绘制边框
  259. this.ctx.strokeStyle = isHighlighted ? '#ffffff' : node.color;
  260. this.ctx.lineWidth = isHighlighted ? 3 : 2;
  261. this.ctx.stroke();
  262. });
  263. },
  264. drawLabels() {
  265. this.graphData.nodes.forEach(node => {
  266. this.ctx.setFontSize(12);
  267. this.ctx.setFillStyle('#333333');
  268. this.ctx.setTextAlign('center');
  269. this.ctx.fillText(node.name, node.x, node.y - node.size - 8);
  270. });
  271. },
  272. isNodeConnected(node) {
  273. if (!this.selectedNode && !this.hoveredNode) return false;
  274. const targetNode = this.selectedNode || this.hoveredNode;
  275. return this.graphData.links.some(link =>
  276. (link.source === node.id && link.target === targetNode.id) ||
  277. (link.target === node.id && link.source === targetNode.id)
  278. );
  279. },
  280. isLinkHighlighted(link) {
  281. if (!this.selectedNode && !this.hoveredNode) return false;
  282. const targetNode = this.selectedNode || this.hoveredNode;
  283. return link.source === targetNode.id || link.target === targetNode.id;
  284. },
  285. getNodeDegree(node) {
  286. return this.graphData.links.filter(link =>
  287. link.source === node.id || link.target === node.id
  288. ).length;
  289. },
  290. onTouchStart(e) {
  291. this.isDragging = true;
  292. this.dragStartX = e.touches[0].x;
  293. this.dragStartY = e.touches[0].y;
  294. // 检测点击的节点
  295. const touchX = e.touches[0].x / this.scale;
  296. const touchY = e.touches[0].y / this.scale;
  297. this.selectedNode = this.getNodeAtPosition(touchX, touchY);
  298. if (this.selectedNode) {
  299. uni.vibrateShort(); // 触觉反馈
  300. }
  301. },
  302. onTouchMove(e) {
  303. if (!this.isDragging) return;
  304. const touchX = e.touches[0].x;
  305. const touchY = e.touches[0].y;
  306. if (this.selectedNode) {
  307. // 拖动节点
  308. this.selectedNode.x = touchX / this.scale;
  309. this.selectedNode.y = touchY / this.scale;
  310. } else {
  311. // 拖动画布
  312. const deltaX = touchX - this.dragStartX;
  313. const deltaY = touchY - this.dragStartY;
  314. this.offsetX += deltaX;
  315. this.offsetY += deltaY;
  316. this.dragStartX = touchX;
  317. this.dragStartY = touchY;
  318. }
  319. },
  320. onTouchEnd() {
  321. this.isDragging = false;
  322. this.hoveredNode = null;
  323. },
  324. getNodeAtPosition(x, y) {
  325. for (let i = this.graphData.nodes.length - 1; i >= 0; i--) {
  326. const node = this.graphData.nodes[i];
  327. const distance = Math.sqrt(Math.pow(node.x - x, 2) + Math.pow(node.y - y, 2));
  328. if (distance <= node.size + 5) {
  329. return node;
  330. }
  331. }
  332. return null;
  333. },
  334. onLayoutChange(e) {
  335. this.layoutIndex = parseInt(e.detail.value);
  336. this.resetView();
  337. },
  338. toggleLabels(e) {
  339. this.showLabels = e.detail.value;
  340. },
  341. addRandomNode() {
  342. const nodeTypes = ['user', 'product', 'category', 'service'];
  343. const colors = ['#4ecdc4', '#45b7d1', '#96ceb4', '#feca57'];
  344. const newNode = {
  345. id: `node${this.graphData.nodes.length + 1}`,
  346. name: `新节点${this.graphData.nodes.length}`,
  347. type: nodeTypes[Math.floor(Math.random() * nodeTypes.length)],
  348. x: Math.random() * this.canvasWidth,
  349. y: Math.random() * this.canvasHeight,
  350. size: 12 + Math.random() * 8,
  351. color: colors[Math.floor(Math.random() * colors.length)]
  352. };
  353. this.graphData.nodes.push(newNode);
  354. // 连接到随机现有节点
  355. if (this.graphData.nodes.length > 1) {
  356. const randomIndex = Math.floor(Math.random() * (this.graphData.nodes.length - 1));
  357. this.graphData.links.push({
  358. source: newNode.id,
  359. target: this.graphData.nodes[randomIndex].id
  360. });
  361. }
  362. },
  363. resetView() {
  364. this.offsetX = 0;
  365. this.offsetY = 0;
  366. this.scale = 1;
  367. this.selectedNode = null;
  368. },
  369. deselectNode() {
  370. this.selectedNode = null;
  371. },
  372. exportData() {
  373. const exportData = {
  374. nodes: this.graphData.nodes,
  375. links: this.graphData.links
  376. };
  377. uni.showModal({
  378. title: '导出数据',
  379. content: JSON.stringify(exportData, null, 2),
  380. showCancel: false,
  381. confirmText: '确定'
  382. });
  383. }
  384. }
  385. }
  386. </script>
  387. <style scoped>
  388. .graph-container {
  389. display: flex;
  390. flex-direction: column;
  391. height: calc(100vh - 225px);
  392. background: #f5f5f5;
  393. margin: 0 20rpx ;
  394. border-radius: 50rpx;
  395. }
  396. .canvas-wrapper {
  397. flex: 1;
  398. background: white;
  399. position: relative;
  400. }
  401. .graph-canvas {
  402. width: 100%;
  403. height: 100%;
  404. display: block;
  405. }
  406. .control-panel {
  407. background: white;
  408. padding: 20rpx;
  409. border-top: 1rpx solid #eee;
  410. }
  411. .control-group {
  412. display: flex;
  413. align-items: center;
  414. justify-content: space-between;
  415. margin-bottom: 20rpx;
  416. padding: 15rpx 0;
  417. }
  418. .control-label {
  419. font-size: 28rpx;
  420. color: #333;
  421. }
  422. .picker {
  423. background: #f0f0f0;
  424. padding: 10rpx 20rpx;
  425. border-radius: 8rpx;
  426. font-size: 26rpx;
  427. }
  428. .button-group {
  429. display: flex;
  430. justify-content: space-between;
  431. }
  432. .btn {
  433. flex: 1;
  434. margin: 0 10rpx;
  435. padding: 20rpx;
  436. border: none;
  437. border-radius: 8rpx;
  438. font-size: 26rpx;
  439. }
  440. .btn.primary {
  441. background: #4ecdc4;
  442. color: white;
  443. }
  444. .btn.secondary {
  445. background: #45b7d1;
  446. color: white;
  447. }
  448. .btn.warning {
  449. background: #ff6b6b;
  450. color: white;
  451. }
  452. .node-info-panel {
  453. position: absolute;
  454. top: 20rpx;
  455. right: 20rpx;
  456. background: rgba(255, 255, 255, 0.95);
  457. padding: 30rpx;
  458. border-radius: 16rpx;
  459. box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
  460. max-width: 300rpx;
  461. z-index: 100;
  462. }
  463. .panel-header {
  464. display: flex;
  465. justify-content: space-between;
  466. align-items: center;
  467. margin-bottom: 20rpx;
  468. }
  469. .panel-title {
  470. font-size: 30rpx;
  471. font-weight: bold;
  472. color: #333;
  473. }
  474. .close-btn {
  475. font-size: 40rpx;
  476. color: #999;
  477. cursor: pointer;
  478. }
  479. .node-details {
  480. margin-bottom: 20rpx;
  481. }
  482. .detail-item {
  483. display: flex;
  484. justify-content: space-between;
  485. margin-bottom: 10rpx;
  486. }
  487. .detail-label {
  488. font-size: 26rpx;
  489. color: #666;
  490. }
  491. .detail-value {
  492. font-size: 26rpx;
  493. color: #333;
  494. font-weight: 500;
  495. }
  496. .stats-panel {
  497. position: absolute;
  498. bottom: 40rpx;
  499. left: 30rpx;
  500. background: rgba(0, 0, 0, 0.7);
  501. padding: 20rpx;
  502. border-radius: 8rpx;
  503. z-index: 100;
  504. display: flex;
  505. justify-content: start;
  506. width: 180rpx;
  507. flex-wrap: wrap;
  508. }
  509. .stats-box{
  510. width: 90rpx;
  511. display: flex;
  512. justify-content: start;
  513. align-items: center;
  514. }
  515. .stats-icon{
  516. width: 10rpx;
  517. height: 10rpx;
  518. margin-right: 8rpx;
  519. }
  520. .stats-icon-g{
  521. background-color:#4ecdc4 ;
  522. }
  523. .stat-item {
  524. display: block;
  525. color: white;
  526. font-size: 24rpx;
  527. margin-bottom: 5rpx;
  528. }
  529. </style>