graph.vue 15 KB

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