FamilyRelationGraph.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688
  1. <template>
  2. <!-- 全 Canvas 关系图谱 - Force-directed 物理布局 -->
  3. <view class="family-graph-wrapper" v-if="members && members.length >= 1">
  4. <canvas
  5. class="family-graph-canvas"
  6. canvas-id="forceGraphCanvas"
  7. id="forceGraphCanvas"
  8. type="2d"
  9. :style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
  10. @touchstart="onTouchStart"
  11. @touchmove="onTouchMove"
  12. @touchend="onTouchEnd" />
  13. </view>
  14. <!-- 空状态 -->
  15. <view class="family-graph-empty" v-else-if="members && members.length === 0">
  16. <text class="empty-text">暂无家庭成员</text>
  17. </view>
  18. </template>
  19. <script>
  20. /**
  21. * FamilyRelationGraph - 全 Canvas force-directed 家庭关系图
  22. *
  23. * 物理引擎: 手动实现的 force-directed 布局
  24. * - 节点间斥力 (repulsion)
  25. * - 边弹簧引力 (spring attraction)
  26. * - 中心引力 (centering)
  27. * - 阻尼 (damping)
  28. *
  29. * 渲染: 全 Canvas 绘制 (节点 + 连线)
  30. * 交互: 节点拖拽 + 点击检测
  31. */
  32. export default {
  33. name: 'FamilyRelationGraph',
  34. props: {
  35. dimensionCode: {
  36. type: String,
  37. required: true,
  38. validator: function(val) {
  39. return ['body', 'mind', 'action', 'home', 'wisdom'].indexOf(val) !== -1
  40. }
  41. },
  42. selfId: {
  43. type: [Number, String],
  44. default: null
  45. },
  46. members: {
  47. type: Array,
  48. default: function() { return [] }
  49. },
  50. energyMap: {
  51. type: Object,
  52. default: function() { return {} }
  53. },
  54. intimacyMap: {
  55. type: Object,
  56. default: function() { return {} }
  57. },
  58. interactive: {
  59. type: Boolean,
  60. default: true
  61. },
  62. compatibilityMap: {
  63. type: Object,
  64. default: function() { return {} }
  65. }
  66. },
  67. data: function() {
  68. return {
  69. canvasWidth: 340,
  70. canvasHeight: 280,
  71. dpr: 1,
  72. // 物理模拟节点
  73. simNodes: [],
  74. // 物理参数
  75. physics: {
  76. repulsion: 800, // 节点间斥力强度
  77. springLength: 120, // 弹簧自然长度
  78. springStrength: 0.04, // 弹簧劲度系数
  79. centering: 0.03, // 中心引力
  80. damping: 0.88, // 速度阻尼
  81. maxSpeed: 12, // 最大速度
  82. minDistance: 30 // 最小节点距离
  83. },
  84. // 拖拽状态
  85. dragging: null, // { nodeIndex, offsetX, offsetY }
  86. dragStartPos: null, // 用于判断是否真的拖拽了
  87. // 动画
  88. animFrameId: null,
  89. isSimulating: false,
  90. // 主题色
  91. themeColors: {
  92. body: '#8D6E63',
  93. mind: '#FF6B35',
  94. action: '#4CAF50',
  95. home: '#5B9BD5',
  96. wisdom: '#FFD700'
  97. }
  98. }
  99. },
  100. computed: {
  101. themeColor: function() {
  102. return this.themeColors[this.dimensionCode] || '#8D6E63'
  103. },
  104. // 将 members 转换为 simNodes
  105. nodeData: function() {
  106. var self = this
  107. if (!this.members || this.members.length === 0) return []
  108. var selfMemberId = null
  109. var centerX = this.canvasWidth / 2
  110. var centerY = this.canvasHeight / 2
  111. return this.members.map(function(m, idx) {
  112. var fid = m.memberId || m.id
  113. if (fid == self.selfId) selfMemberId = fid
  114. var energy = self.getEnergy(fid)
  115. var radius = 24 + (Math.max(0, Math.min(100, energy)) / 100) * 16 // 24-40rpx
  116. var nick = m.nickname || m.name || '成员'
  117. if (nick === '用户') nick = '成员'
  118. var isSelf = (fid == self.selfId)
  119. var displayName = nick.charAt(0)
  120. // 圆形布局初始位置(避免初始重叠)
  121. var angle = (2 * Math.PI * idx) / self.members.length
  122. var r = Math.min(self.canvasWidth, self.canvasHeight) * 0.32
  123. var initX = centerX + r * Math.cos(angle)
  124. var initY = centerY + r * Math.sin(angle)
  125. return {
  126. id: fid,
  127. nickname: nick,
  128. displayName: displayName,
  129. isSelf: isSelf,
  130. memberType: m.memberType || 'child',
  131. radius: radius,
  132. x: initX,
  133. y: initY,
  134. vx: 0,
  135. vy: 0,
  136. fx: 0, // 固定 x (拖拽时)
  137. fy: 0 // 固定 y (拖拽时)
  138. }
  139. })
  140. },
  141. // 构建边 (self 连接到其他所有成员)
  142. edges: function() {
  143. var self = this
  144. var selfNode = this.nodeData.find(function(n) { return n.isSelf })
  145. if (!selfNode) return []
  146. var result = []
  147. this.nodeData.forEach(function(n) {
  148. if (n.id !== selfNode.id) {
  149. // 获取亲密度
  150. var pairKey = selfNode.id < n.id
  151. ? selfNode.id + '-' + n.id
  152. : n.id + '-' + selfNode.id
  153. var comp = self.compatibilityMap[pairKey]
  154. var trust = self.getIntimacy(n.id, 'trust')
  155. var comm = self.getIntimacy(n.id, 'communication')
  156. var closeness = self.getIntimacy(n.id, 'closeness')
  157. result.push({
  158. source: selfNode,
  159. target: n,
  160. // 边权重用于可视化
  161. trust: trust,
  162. communication: comm,
  163. closeness: closeness,
  164. compatibility: comp && comp.overallScore !== undefined ? comp.overallScore : null
  165. })
  166. }
  167. })
  168. return result
  169. }
  170. },
  171. watch: {
  172. members: function() {
  173. this.resetSimulation()
  174. },
  175. canvasWidth: function() {
  176. this.resetSimulation()
  177. },
  178. canvasHeight: function() {
  179. this.resetSimulation()
  180. }
  181. },
  182. mounted: function() {
  183. var self = this
  184. this.dpr = uni.getSystemInfoSync().pixelRatio || 1
  185. this.$nextTick(function() {
  186. self.updateCanvasSize()
  187. self.$nextTick(function() {
  188. self.initSimulation()
  189. })
  190. })
  191. },
  192. beforeDestroy: function() {
  193. this.stopSimulation()
  194. },
  195. methods: {
  196. // ===== 物理引擎 =====
  197. resetSimulation: function() {
  198. this.stopSimulation()
  199. // 重新初始化节点位置
  200. var centerX = this.canvasWidth / 2
  201. var centerY = this.canvasHeight / 2
  202. var self = this
  203. this.nodeData.forEach(function(n, idx) {
  204. var angle = (2 * Math.PI * idx) / self.nodeData.length
  205. var r = Math.min(self.canvasWidth, self.canvasHeight) * 0.32
  206. n.x = centerX + r * Math.cos(angle)
  207. n.y = centerY + r * Math.sin(angle)
  208. n.vx = 0
  209. n.vy = 0
  210. n.fx = 0
  211. n.fy = 0
  212. })
  213. this.startSimulation()
  214. },
  215. initSimulation: function() {
  216. if (!this.nodeData || this.nodeData.length === 0) return
  217. this.startSimulation()
  218. },
  219. startSimulation: function() {
  220. if (this.isSimulating) return
  221. this.isSimulating = true
  222. this.simulationTick()
  223. },
  224. stopSimulation: function() {
  225. this.isSimulating = false
  226. if (this.animFrameId) {
  227. cancelAnimationFrame(this.animFrameId)
  228. this.animFrameId = null
  229. }
  230. },
  231. simulationTick: function() {
  232. if (!this.isSimulating) return
  233. var self = this
  234. var nodes = this.nodeData
  235. var edges = this.edges
  236. var p = this.physics
  237. // 1. 清零力
  238. for (var i = 0; i < nodes.length; i++) {
  239. if (!nodes[i].fixed) {
  240. nodes[i].fx = 0
  241. nodes[i].fy = 0
  242. }
  243. }
  244. // 2. 斥力 (所有节点对之间)
  245. for (var a = 0; a < nodes.length; a++) {
  246. for (var b = a + 1; b < nodes.length; b++) {
  247. var dx = nodes[b].x - nodes[a].x
  248. var dy = nodes[b].y - nodes[a].y
  249. var distSq = dx * dx + dy * dy
  250. var dist = Math.sqrt(distSq) || 0.1
  251. // F = repulsion / dist^2
  252. var force = p.repulsion / distSq
  253. var fx = (dx / dist) * force
  254. var fy = (dy / dist) * force
  255. if (!nodes[a].fixed) {
  256. nodes[a].fx -= fx
  257. nodes[a].fy -= fy
  258. }
  259. if (!nodes[b].fixed) {
  260. nodes[b].fx += fx
  261. nodes[b].fy += fy
  262. }
  263. }
  264. }
  265. // 3. 弹簧引力 (边)
  266. for (var e = 0; e < edges.length; e++) {
  267. var edge = edges[e]
  268. var s = edge.source
  269. var t = edge.target
  270. var dx = t.x - s.x
  271. var dy = t.y - s.y
  272. var dist = Math.sqrt(dx * dx + dy * dy) || 0.1
  273. var displacement = dist - p.springLength
  274. var force = p.springStrength * displacement
  275. var fx = (dx / dist) * force
  276. var fy = (dy / dist) * force
  277. if (!s.fixed) {
  278. s.fx += fx
  279. s.fy += fy
  280. }
  281. if (!t.fixed) {
  282. t.fx -= fx
  283. t.fy -= fy
  284. }
  285. }
  286. // 4. 中心引力
  287. var cx = this.canvasWidth / 2
  288. var cy = this.canvasHeight / 2
  289. for (var n = 0; n < nodes.length; n++) {
  290. if (!nodes[n].fixed) {
  291. nodes[n].fx += (cx - nodes[n].x) * p.centering
  292. nodes[n].fy += (cy - nodes[n].y) * p.centering
  293. }
  294. }
  295. // 5. 更新速度 + 位置
  296. for (var i = 0; i < nodes.length; i++) {
  297. var node = nodes[i]
  298. if (node.fixed) continue
  299. node.vx += node.fx
  300. node.vy += node.fy
  301. // 阻尼
  302. node.vx *= p.damping
  303. node.vy *= p.damping
  304. // 限速
  305. var speed = Math.sqrt(node.vx * node.vx + node.vy * node.vy)
  306. if (speed > p.maxSpeed) {
  307. node.vx = (node.vx / speed) * p.maxSpeed
  308. node.vy = (node.vy / speed) * p.maxSpeed
  309. }
  310. // 最小距离约束
  311. for (var j = 0; j < nodes.length; j++) {
  312. if (i === j) continue
  313. var other = nodes[j]
  314. var dx = other.x - node.x
  315. var dy = other.y - node.y
  316. var dist = Math.sqrt(dx * dx + dy * dy) || 0.1
  317. var minDist = node.radius + other.radius
  318. if (dist < minDist && dist > 0) {
  319. var overlap = (minDist - dist) / 2
  320. var nx = dx / dist
  321. var ny = dy / dist
  322. node.x -= nx * overlap
  323. node.y -= ny * overlap
  324. other.x += nx * overlap
  325. other.y += ny * overlap
  326. }
  327. }
  328. node.x += node.vx
  329. node.y += node.vy
  330. // 边界约束
  331. var r = node.radius + 4
  332. if (node.x < r) { node.x = r; node.vx *= -0.5 }
  333. if (node.x > this.canvasWidth - r) { node.x = this.canvasWidth - r; node.vx *= -0.5 }
  334. if (node.y < r) { node.y = r; node.vy *= -0.5 }
  335. if (node.y > this.canvasHeight - r) { node.y = this.canvasHeight - r; node.vy *= -0.5 }
  336. }
  337. // 6. 渲染
  338. this.render()
  339. // 7. 下一帧
  340. this.animFrameId = requestAnimationFrame(function() {
  341. self.simulationTick()
  342. })
  343. },
  344. // ===== 渲染 =====
  345. render: function() {
  346. var self = this
  347. var query = uni.createSelectorQuery().in(this)
  348. query.select('#forceGraphCanvas')
  349. .fields({ node: true, size: true })
  350. .exec(function(res) {
  351. if (!res || !res[0] || !res[0].node) {
  352. self.renderLegacy()
  353. return
  354. }
  355. var canvas = res[0].node
  356. var ctx = canvas.getContext('2d')
  357. canvas.width = self.canvasWidth * self.dpr
  358. canvas.height = self.canvasHeight * self.dpr
  359. ctx.scale(self.dpr, self.dpr)
  360. // 清空
  361. ctx.clearRect(0, 0, self.canvasWidth, self.canvasHeight)
  362. // 画边
  363. self.drawEdges(ctx)
  364. // 画节点
  365. self.drawNodes(ctx)
  366. })
  367. },
  368. drawEdges: function(ctx) {
  369. var edges = this.edges
  370. for (var i = 0; i < edges.length; i++) {
  371. var edge = edges[i]
  372. var s = edge.source
  373. var t = edge.target
  374. // 边颜色
  375. var trust = edge.trust || 75
  376. var comm = edge.communication || 50
  377. var hue = 120 * trust / 100
  378. ctx.strokeStyle = 'hsla(' + hue + ', 75%, 45%, 0.6)'
  379. ctx.lineWidth = 1 + (comm / 100) * 4
  380. // 画线
  381. ctx.beginPath()
  382. ctx.moveTo(s.x, s.y)
  383. ctx.lineTo(t.x, t.y)
  384. ctx.stroke()
  385. }
  386. },
  387. drawNodes: function(ctx) {
  388. var nodes = this.nodeData
  389. for (var i = 0; i < nodes.length; i++) {
  390. var node = nodes[i]
  391. var r = node.radius
  392. // 节点背景
  393. ctx.beginPath()
  394. ctx.arc(node.x, node.y, r, 0, 2 * Math.PI)
  395. if (node.isSelf) {
  396. ctx.fillStyle = '#FFFFFF'
  397. ctx.fill()
  398. ctx.strokeStyle = this.themeColor
  399. ctx.lineWidth = 3
  400. ctx.stroke()
  401. } else {
  402. ctx.fillStyle = this.themeColor + '22'
  403. ctx.fill()
  404. }
  405. // 字母
  406. ctx.fillStyle = node.isSelf ? this.themeColor : '#4A5568'
  407. ctx.font = 'bold ' + (r * 0.9) + 'px sans-serif'
  408. ctx.textAlign = 'center'
  409. ctx.textBaseline = 'middle'
  410. ctx.fillText(node.displayName, node.x, node.y)
  411. // 昵称
  412. ctx.fillStyle = node.isSelf ? this.themeColor : '#94A3B8'
  413. ctx.font = (r * 0.4) + 'px sans-serif'
  414. ctx.fillText(node.nickname.substring(0, 3), node.x, node.y + r + 10)
  415. }
  416. },
  417. // 降级渲染 (CanvasContext)
  418. renderLegacy: function() {
  419. var ctx = uni.createCanvasContext('forceGraphCanvas', this)
  420. var self = this
  421. ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
  422. // 边
  423. var edges = this.edges
  424. for (var i = 0; i < edges.length; i++) {
  425. var edge = edges[i]
  426. var trust = edge.trust || 75
  427. var comm = edge.communication || 50
  428. var hue = 120 * trust / 100
  429. ctx.setStrokeStyle('hsla(' + hue + ', 75%, 45%, 0.6)')
  430. ctx.setLineWidth(1 + (comm / 100) * 4)
  431. ctx.beginPath()
  432. ctx.moveTo(edge.source.x, edge.source.y)
  433. ctx.lineTo(edge.target.x, edge.target.y)
  434. ctx.stroke()
  435. }
  436. // 节点
  437. var nodes = this.nodeData
  438. for (var j = 0; j < nodes.length; j++) {
  439. var node = nodes[j]
  440. var r = node.radius
  441. ctx.beginPath()
  442. ctx.arc(node.x, node.y, r, 0, 2 * Math.PI)
  443. if (node.isSelf) {
  444. ctx.setFillStyle('#FFFFFF')
  445. ctx.fill()
  446. ctx.setStrokeStyle(this.themeColor)
  447. ctx.setLineWidth(3)
  448. ctx.stroke()
  449. } else {
  450. ctx.setFillStyle(this.themeColor + '22')
  451. ctx.fill()
  452. }
  453. ctx.setFillStyle(node.isSelf ? this.themeColor : '#4A5568')
  454. ctx.setFont('bold ' + (r * 0.9) + 'px sans-serif')
  455. ctx.setTextAlign('center')
  456. ctx.setTextBaseline('middle')
  457. ctx.fillText(node.displayName, node.x, node.y)
  458. ctx.setFillStyle(node.isSelf ? this.themeColor : '#94A3B8')
  459. ctx.setFont((r * 0.4) + 'px sans-serif')
  460. ctx.fillText(node.nickname.substring(0, 3), node.x, node.y + r + 10)
  461. }
  462. ctx.draw()
  463. },
  464. // ===== 交互 =====
  465. getTouchNode: function(touchX, touchY) {
  466. var nodes = this.nodeData
  467. for (var i = 0; i < nodes.length; i++) {
  468. var node = nodes[i]
  469. var dx = touchX - node.x
  470. var dy = touchY - node.y
  471. var dist = Math.sqrt(dx * dx + dy * dy)
  472. if (dist <= node.radius + 8) {
  473. return node
  474. }
  475. }
  476. return null
  477. },
  478. onTouchStart: function(e) {
  479. if (!this.interactive) return
  480. var touch = e.touches ? e.touches[0] : e.detail
  481. if (!touch) return
  482. var rect = {}
  483. var query = uni.createSelectorQuery().in(this)
  484. query.select('#forceGraphCanvas').boundingClientRect()
  485. query.exec(function(res) {
  486. if (res && res[0]) {
  487. rect = res[0]
  488. var x = touch.clientX - rect.left
  489. var y = touch.clientY - rect.top
  490. var node = self.getTouchNode(x, y)
  491. if (node) {
  492. self.dragging = {
  493. node: node,
  494. offsetX: x - node.x,
  495. offsetY: y - node.y
  496. }
  497. self.dragStartPos = { x: x, y: y }
  498. // 固定节点
  499. node.fx = node.x
  500. node.fy = node.y
  501. node.fixed = true
  502. }
  503. }
  504. }.bind(this))
  505. },
  506. onTouchMove: function(e) {
  507. if (!this.dragging) return
  508. e.preventDefault && e.preventDefault()
  509. var touch = e.touches ? e.touches[0] : e.detail
  510. if (!touch) return
  511. var rect = {}
  512. var self = this
  513. var query = uni.createSelectorQuery().in(this)
  514. query.select('#forceGraphCanvas').boundingClientRect()
  515. query.exec(function(res) {
  516. if (res && res[0]) {
  517. rect = res[0]
  518. var x = touch.clientX - rect.left
  519. var y = touch.clientY - rect.top
  520. var node = self.dragging.node
  521. node.x = x - self.dragging.offsetX
  522. node.y = y - self.dragging.offsetY
  523. node.fx = node.x
  524. node.fy = node.y
  525. }
  526. }.bind(this))
  527. },
  528. onTouchEnd: function(e) {
  529. if (!this.dragging) return
  530. var wasDragged = this.dragStartPos && (
  531. Math.abs(this.dragging.node.x - this.dragStartPos.x) > 5 ||
  532. Math.abs(this.dragging.node.y - this.dragStartPos.y) > 5
  533. )
  534. var node = this.dragging.node
  535. node.fixed = false
  536. node.fx = 0
  537. node.fy = 0
  538. // 如果没有真正拖拽(点击),触发 memberTap
  539. if (!wasDragged && this.interactive) {
  540. this.$emit('memberTap', {
  541. memberId: node.id,
  542. memberType: node.memberType,
  543. nickname: node.nickname
  544. })
  545. }
  546. this.dragging = null
  547. this.dragStartPos = null
  548. },
  549. // ===== 工具方法 =====
  550. getEnergy: function(memberId) {
  551. var data = this.energyMap[memberId]
  552. if (!data) return 0
  553. var key = this.dimensionCode + 'Score'
  554. var val = data[key]
  555. return (typeof val !== 'undefined' && val !== null) ? val : 0
  556. },
  557. getIntimacy: function(memberId, key) {
  558. var data = this.intimacyMap[memberId]
  559. if (!data) {
  560. var defaults = { closeness: 75, communication: 50, trust: 85 }
  561. return defaults[key]
  562. }
  563. var val = data[key]
  564. return (typeof val !== 'undefined' && val !== null) ? val : 75
  565. },
  566. updateCanvasSize: function() {
  567. var self = this
  568. var query = uni.createSelectorQuery().in(this)
  569. query.select('.family-graph-wrapper').boundingClientRect(function(rect) {
  570. if (rect) {
  571. self.canvasWidth = rect.width || 340
  572. self.canvasHeight = rect.height || 280
  573. }
  574. }).exec()
  575. }
  576. }
  577. }
  578. </script>
  579. <style scoped>
  580. .family-graph-wrapper {
  581. position: relative;
  582. width: 100%;
  583. height: 280px;
  584. margin: 10rpx 0;
  585. background: #FFFFFF;
  586. border-radius: 24rpx;
  587. overflow: hidden;
  588. }
  589. .family-graph-canvas {
  590. position: absolute;
  591. top: 0;
  592. left: 0;
  593. z-index: 1;
  594. }
  595. .family-graph-empty {
  596. display: flex;
  597. align-items: center;
  598. justify-content: center;
  599. padding: 60rpx 0;
  600. background: #FFFFFF;
  601. border-radius: 24rpx;
  602. margin: 10rpx 0;
  603. }
  604. .empty-text {
  605. font-size: 28rpx;
  606. color: #94A3B8;
  607. }
  608. </style>