graph.vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638
  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-debt stats-icon"></view> <text class="stat-item">债务</text></view>
  58. <view class="stats-box"> <view class="stats-icon-borrower stats-icon"></view> <text class="stat-item">借款人</text></view>
  59. <view class="stats-box"> <view class="stats-icon-guarantor stats-icon"></view> <text class="stat-item">担保人</text></view>
  60. <view class="stats-box"> <view class="stats-icon-collateral stats-icon"></view> <text class="stat-item">抵押物</text></view>
  61. <!-- <view class="stats-box"> <view class="stats-icon-company 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','nodeType':1, name: '债务', type: 'center', x: 275, y: 150, size: 20, color: '#ff6b35' },
  113. { id: 'node2','nodeType':2, name: '大连迈世农业发展有限公司', type: 'user', x: 275, y: 150, size: 15, color: '#8b5cf6' },
  114. { id: 'node3','nodeType':3, name: '迈世集团有限公司', type: 'product', x: 275, y: 150, size: 15, color: '#3b82f6' },
  115. { id: 'node4','nodeType':4, name: '宜居园81号2单元1层1号房屋', type: 'category', x: 275, y: 150, size: 15, color: '#10b981' },
  116. { id: 'node6','nodeType':4, name: '景山东园7号13层2号房屋', type: 'category', x: 275, y: 150, size: 15, color: '#10b981' },
  117. { id: 'node5','nodeType':5, name: '中信银行股份有限公司大连分行', type: 'service', x: 275, y: 150, size: 15, color: '#6366f1' }
  118. ],
  119. links: [
  120. { source: 'node1', target: 'node2' },
  121. { source: 'node1', target: 'node3' },
  122. { source: 'node1', target: 'node4' },
  123. { source: 'node1', target: 'node5' },
  124. { source: 'node1', target: 'node6' },
  125. { source: 'node2', target: 'node4' },
  126. { source: 'node3', target: 'node6' },
  127. ]
  128. };
  129. },
  130. startAnimation() {
  131. const animate = () => {
  132. this.updateLayout();
  133. this.drawGraph();
  134. // this.animationId = requestAnimationFrame(animate);
  135. };
  136. animate();
  137. },
  138. stopAnimation() {
  139. if (this.animationId) {
  140. cancelAnimationFrame(this.animationId);
  141. }
  142. },
  143. updateLayout() {
  144. // 简单的力导向布局模拟
  145. if (this.layoutIndex === 0) {
  146. this.applyForceDirectedLayout();
  147. } else if (this.layoutIndex === 1) {
  148. this.applyCircularLayout();
  149. } else if (this.layoutIndex === 2) {
  150. this.applyTreeLayout();
  151. }
  152. },
  153. applyForceDirectedLayout() {
  154. const centerX = this.canvasWidth / 2 + this.offsetX;
  155. const centerY = this.canvasHeight / 2 + this.offsetY;
  156. // 简化的力导向布局
  157. this.graphData.nodes.forEach((node, i) => {
  158. if (node.id === 'node1') {
  159. // 中心节点保持在中心
  160. node.x = centerX;
  161. node.y = centerY;
  162. return;
  163. }
  164. // 向中心节点的引力
  165. const centerNode = this.graphData.nodes[0];
  166. const dx = centerNode.x - node.x;
  167. const dy = centerNode.y - node.y;
  168. const distance = Math.sqrt(dx * dx + dy * dy);
  169. if (distance > 0) {
  170. const force = 0.1;
  171. node.x += dx * force;
  172. node.y += dy * force;
  173. }
  174. // 节点间的斥力
  175. this.graphData.nodes.forEach((otherNode, j) => {
  176. if (i !== j) {
  177. const dx = node.x - otherNode.x;
  178. const dy = node.y - otherNode.y;
  179. const distance = Math.sqrt(dx * dx + dy * dy);
  180. if (distance > 0 && distance < 100) {
  181. const force = 50 / (distance * distance);
  182. node.x += dx * force * 0.1;
  183. node.y += dy * force * 0.1;
  184. }
  185. }
  186. });
  187. });
  188. },
  189. applyCircularLayout() {
  190. const centerX = this.canvasWidth / 2 + this.offsetX;
  191. const centerY = this.canvasHeight / 2 + this.offsetY;
  192. const radius = 150;
  193. this.graphData.nodes.forEach((node, i) => {
  194. if (node.id === 'node1') {
  195. node.x = centerX;
  196. node.y = centerY;
  197. } else {
  198. const angle = ((i - 1) / (this.graphData.nodes.length - 1)) * Math.PI * 2;
  199. node.x = centerX + Math.cos(angle) * radius;
  200. node.y = centerY + Math.sin(angle) * radius;
  201. }
  202. });
  203. },
  204. applyTreeLayout() {
  205. const centerX = this.canvasWidth / 2 + this.offsetX;
  206. const centerY = this.canvasHeight / 2 + this.offsetY;
  207. this.graphData.nodes.forEach((node, i) => {
  208. if (node.id === 'node1') {
  209. node.x = centerX;
  210. node.y = centerY;
  211. } else {
  212. const angle = ((i - 1) / (this.graphData.nodes.length - 1)) * Math.PI * 2;
  213. const radius = 120;
  214. node.x = centerX + Math.cos(angle) * radius;
  215. node.y = centerY + Math.sin(angle) * radius;
  216. }
  217. });
  218. },
  219. drawGraph() {
  220. if (!this.ctx) return;
  221. // 清空画布
  222. this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
  223. // 设置缩放
  224. this.ctx.scale(this.scale, this.scale);
  225. // 绘制连接线
  226. this.drawLinks();
  227. // 绘制节点
  228. this.drawNodes();
  229. // 绘制标签
  230. if (this.showLabels) {
  231. this.drawLabels();
  232. }
  233. this.ctx.draw();
  234. },
  235. drawLinks() {
  236. this.graphData.links.forEach(link => {
  237. const sourceNode = this.graphData.nodes.find(n => n.id === link.source);
  238. const targetNode = this.graphData.nodes.find(n => n.id === link.target);
  239. if (sourceNode && targetNode) {
  240. const isHighlighted = this.isLinkHighlighted(link);
  241. this.ctx.beginPath();
  242. this.ctx.moveTo(sourceNode.x, sourceNode.y);
  243. this.ctx.lineTo(targetNode.x, targetNode.y);
  244. this.ctx.strokeStyle = isHighlighted ? '#ff6b6b' : '#cccccc';
  245. this.ctx.lineWidth = isHighlighted ? 3 : 2;
  246. this.ctx.stroke();
  247. }
  248. });
  249. },
  250. drawNodes() {
  251. this.graphData.nodes.forEach(node => {
  252. const isSelected = this.selectedNode && this.selectedNode.id === node.id;
  253. const isHovered = this.hoveredNode && this.hoveredNode.id === node.id;
  254. const isHighlighted = isSelected || isHovered || this.isNodeConnected(node);
  255. // 绘制节点
  256. this.ctx.beginPath();
  257. this.ctx.arc(node.x, node.y, isHighlighted ? node.size + 3 : node.size, 0, 2 * Math.PI);
  258. this.ctx.fillStyle = node.color;
  259. this.ctx.fill();
  260. // 绘制边框
  261. this.ctx.strokeStyle = isHighlighted ? '#ffffff' : node.color;
  262. this.ctx.lineWidth = isHighlighted ? 3 : 2;
  263. this.ctx.stroke();
  264. });
  265. },
  266. drawLabels() {
  267. this.graphData.nodes.forEach(node => {
  268. this.ctx.setFontSize(12);
  269. this.ctx.setFillStyle('#333333');
  270. this.ctx.setTextAlign('center');
  271. this.ctx.fillText(node.name, node.x, node.y - node.size - 8);
  272. });
  273. },
  274. isNodeConnected(node) {
  275. if (!this.selectedNode && !this.hoveredNode) return false;
  276. const targetNode = this.selectedNode || this.hoveredNode;
  277. return this.graphData.links.some(link =>
  278. (link.source === node.id && link.target === targetNode.id) ||
  279. (link.target === node.id && link.source === targetNode.id)
  280. );
  281. },
  282. isLinkHighlighted(link) {
  283. if (!this.selectedNode && !this.hoveredNode) return false;
  284. const targetNode = this.selectedNode || this.hoveredNode;
  285. return link.source === targetNode.id || link.target === targetNode.id;
  286. },
  287. getNodeDegree(node) {
  288. return this.graphData.links.filter(link =>
  289. link.source === node.id || link.target === node.id
  290. ).length;
  291. },
  292. onTouchStart(e) {
  293. this.isDragging = true;
  294. this.dragStartX = e.touches[0].x;
  295. this.dragStartY = e.touches[0].y;
  296. // 检测点击的节点
  297. const touchX = e.touches[0].x / this.scale;
  298. const touchY = e.touches[0].y / this.scale;
  299. this.selectedNode = this.getNodeAtPosition(touchX, touchY);
  300. if (this.selectedNode) {
  301. this.$emit('setNode',this.selectedNode)
  302. // uni.vibrateShort(); // 触觉反馈
  303. }
  304. },
  305. onTouchMove(e) {
  306. if (!this.isDragging) return;
  307. const touchX = e.touches[0].x;
  308. const touchY = e.touches[0].y;
  309. if (this.selectedNode) {
  310. // 拖动节点
  311. this.selectedNode.x = touchX / this.scale;
  312. this.selectedNode.y = touchY / this.scale;
  313. } else {
  314. // 拖动画布
  315. const deltaX = touchX - this.dragStartX;
  316. const deltaY = touchY - this.dragStartY;
  317. this.offsetX += deltaX;
  318. this.offsetY += deltaY;
  319. this.dragStartX = touchX;
  320. this.dragStartY = touchY;
  321. }
  322. },
  323. onTouchEnd() {
  324. this.isDragging = false;
  325. this.hoveredNode = null;
  326. },
  327. getNodeAtPosition(x, y) {
  328. for (let i = this.graphData.nodes.length - 1; i >= 0; i--) {
  329. const node = this.graphData.nodes[i];
  330. const distance = Math.sqrt(Math.pow(node.x - x, 2) + Math.pow(node.y - y, 2));
  331. if (distance <= node.size + 5) {
  332. return node;
  333. }
  334. }
  335. return null;
  336. },
  337. onLayoutChange(e) {
  338. this.layoutIndex = parseInt(e.detail.value);
  339. this.resetView();
  340. },
  341. toggleLabels(e) {
  342. this.showLabels = e.detail.value;
  343. },
  344. addRandomNode() {
  345. const nodeTypes = ['user', 'product', 'category', 'service'];
  346. const colors = ['#4ecdc4', '#45b7d1', '#96ceb4', '#feca57'];
  347. const newNode = {
  348. id: `node${this.graphData.nodes.length + 1}`,
  349. name: `新节点${this.graphData.nodes.length}`,
  350. type: nodeTypes[Math.floor(Math.random() * nodeTypes.length)],
  351. x: Math.random() * this.canvasWidth,
  352. y: Math.random() * this.canvasHeight,
  353. size: 12 + Math.random() * 8,
  354. color: colors[Math.floor(Math.random() * colors.length)]
  355. };
  356. this.graphData.nodes.push(newNode);
  357. // 连接到随机现有节点
  358. if (this.graphData.nodes.length > 1) {
  359. const randomIndex = Math.floor(Math.random() * (this.graphData.nodes.length - 1));
  360. this.graphData.links.push({
  361. source: newNode.id,
  362. target: this.graphData.nodes[randomIndex].id
  363. });
  364. }
  365. },
  366. resetView() {
  367. this.offsetX = 0;
  368. this.offsetY = 0;
  369. this.scale = 1;
  370. this.selectedNode = null;
  371. },
  372. deselectNode() {
  373. this.selectedNode = null;
  374. },
  375. exportData() {
  376. const exportData = {
  377. nodes: this.graphData.nodes,
  378. links: this.graphData.links
  379. };
  380. uni.showModal({
  381. title: '导出数据',
  382. content: JSON.stringify(exportData, null, 2),
  383. showCancel: false,
  384. confirmText: '确定'
  385. });
  386. }
  387. }
  388. }
  389. </script>
  390. <style scoped>
  391. .graph-container {
  392. display: flex;
  393. flex-direction: column;
  394. height: calc(100vh - 295px);
  395. background: #f5f5f5;
  396. margin: 0 20rpx ;
  397. border-radius: 50rpx;
  398. }
  399. .canvas-wrapper {
  400. flex: 1;
  401. background: white;
  402. position: relative;
  403. }
  404. .graph-canvas {
  405. width: 100%;
  406. height: 100%;
  407. display: block;
  408. }
  409. .control-panel {
  410. background: white;
  411. padding: 20rpx;
  412. border-top: 1rpx solid #eee;
  413. }
  414. .control-group {
  415. display: flex;
  416. align-items: center;
  417. justify-content: space-between;
  418. margin-bottom: 20rpx;
  419. padding: 15rpx 0;
  420. }
  421. .control-label {
  422. font-size: 28rpx;
  423. color: #333;
  424. }
  425. .picker {
  426. background: #f0f0f0;
  427. padding: 10rpx 20rpx;
  428. border-radius: 8rpx;
  429. font-size: 26rpx;
  430. }
  431. .button-group {
  432. display: flex;
  433. justify-content: space-between;
  434. }
  435. .btn {
  436. flex: 1;
  437. margin: 0 10rpx;
  438. padding: 20rpx;
  439. border: none;
  440. border-radius: 8rpx;
  441. font-size: 26rpx;
  442. }
  443. .btn.primary {
  444. background: #4ecdc4;
  445. color: white;
  446. }
  447. .btn.secondary {
  448. background: #45b7d1;
  449. color: white;
  450. }
  451. .btn.warning {
  452. background: #ff6b6b;
  453. color: white;
  454. }
  455. .node-info-panel {
  456. position: absolute;
  457. top: 20rpx;
  458. right: 20rpx;
  459. background: rgba(255, 255, 255, 0.95);
  460. padding: 30rpx;
  461. border-radius: 16rpx;
  462. box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
  463. max-width: 300rpx;
  464. z-index: 100;
  465. }
  466. .panel-header {
  467. display: flex;
  468. justify-content: space-between;
  469. align-items: center;
  470. margin-bottom: 20rpx;
  471. }
  472. .panel-title {
  473. font-size: 30rpx;
  474. font-weight: bold;
  475. color: #333;
  476. }
  477. .close-btn {
  478. font-size: 40rpx;
  479. color: #999;
  480. cursor: pointer;
  481. }
  482. .node-details {
  483. margin-bottom: 20rpx;
  484. }
  485. .detail-item {
  486. display: flex;
  487. justify-content: space-between;
  488. margin-bottom: 10rpx;
  489. }
  490. .detail-label {
  491. font-size: 26rpx;
  492. color: #666;
  493. }
  494. .detail-value {
  495. font-size: 26rpx;
  496. color: #333;
  497. font-weight: 500;
  498. }
  499. .stats-panel {
  500. position: absolute;
  501. bottom: 40rpx;
  502. left: 30rpx;
  503. background: rgba(0, 0, 0, 0.7);
  504. padding: 20rpx;
  505. border-radius: 8rpx;
  506. z-index: 100;
  507. display: flex;
  508. justify-content: start;
  509. width: 180rpx;
  510. flex-wrap: wrap;
  511. }
  512. .stats-box{
  513. width: 90rpx;
  514. display: flex;
  515. justify-content: start;
  516. align-items: center;
  517. }
  518. .stats-icon{
  519. width: 10rpx;
  520. height: 10rpx;
  521. margin-right: 8rpx;
  522. background-color:#6b7280 ;
  523. }
  524. .stats-icon-debt{
  525. background-color:#ff6b35 ;
  526. }
  527. .stats-icon-borrower{
  528. background-color:#8b5cf6 ;
  529. }
  530. .stats-icon-guarantor{
  531. background-color:#3b82f6 ;
  532. }
  533. .stats-icon-collateral{
  534. background-color:#10b981 ;
  535. }
  536. .stats-icon-property{
  537. background-color:#f59e0b ;
  538. }
  539. .stats-icon-company{
  540. background-color:#6366f1 ;
  541. }
  542. .stat-item {
  543. display: block;
  544. color: white;
  545. font-size: 24rpx;
  546. margin-bottom: 5rpx;
  547. }
  548. </style>