FamilyRelationGraph.vue 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869
  1. <template>
  2. <!-- 全 Canvas 关系图谱 - Force-directed 物理布局 -->
  3. <view class="family-graph-wrapper" v-if="simNodes && simNodes.length >= 1">
  4. <!-- 右上角小齿轮 -->
  5. <view class="gear-icon" @click="onManageTap">
  6. <text class="gear-text">⚙</text>
  7. </view>
  8. <canvas
  9. class="family-graph-canvas"
  10. canvas-id="forceGraphCanvas"
  11. id="forceGraphCanvas"
  12. type="2d"
  13. :style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
  14. @touchstart="onTouchStart"
  15. @touchmove="onTouchMove"
  16. @touchend="onTouchEnd"
  17. @click="onCanvasClick" />
  18. <!-- PC 端悬停检测层 -->
  19. <view
  20. v-if="isPC"
  21. class="hover-layer"
  22. :style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
  23. @mousemove="onHoverMove"
  24. @mouseleave="onHoverLeave" />
  25. <!-- + 添加成员提示(触摸/悬停时显示在中心节点旁) -->
  26. <view class="add-hint" :class="{ 'add-hint-visible': touchingSelf || hoveringSelf }" :style="addHintPosition">
  27. <text class="add-hint-text">+</text>
  28. </view>
  29. </view>
  30. <!-- 空状态 -->
  31. <view class="family-graph-empty" v-else-if="members && members.length === 0">
  32. <text class="empty-text">暂无家庭成员</text>
  33. </view>
  34. </template>
  35. <script>
  36. /**
  37. * FamilyRelationGraph - 全 Canvas force-directed 家庭关系图
  38. *
  39. * 物理引擎: 手动实现的 force-directed 布局
  40. * - 节点间斥力 (repulsion)
  41. * - 边弹簧引力 (spring attraction)
  42. * - 中心引力 (centering)
  43. * - 阻尼 (damping)
  44. *
  45. * 渲染: 全 Canvas 绘制 (节点 + 连线)
  46. * 交互: 节点拖拽 + 点击检测
  47. */
  48. export default {
  49. name: 'FamilyRelationGraph',
  50. props: {
  51. dimensionCode: {
  52. type: String,
  53. required: true,
  54. validator: function(val) {
  55. return ['body', 'mind', 'action', 'home', 'wisdom'].indexOf(val) !== -1
  56. }
  57. },
  58. selfId: {
  59. type: [Number, String],
  60. default: null
  61. },
  62. members: {
  63. type: Array,
  64. default: function() { return [] }
  65. },
  66. energyMap: {
  67. type: Object,
  68. default: function() { return {} }
  69. },
  70. intimacyMap: {
  71. type: Object,
  72. default: function() { return {} }
  73. },
  74. interactive: {
  75. type: Boolean,
  76. default: true
  77. },
  78. compatibilityMap: {
  79. type: Object,
  80. default: function() { return {} }
  81. }
  82. },
  83. data: function() {
  84. return {
  85. canvasWidth: 340,
  86. canvasHeight: 280,
  87. dpr: 1,
  88. // 物理模拟节点
  89. simNodes: [],
  90. // 物理参数
  91. physics: {
  92. repulsion: 800,
  93. springLength: 120,
  94. springStrength: 0.04,
  95. centering: 0.03,
  96. damping: 0.88,
  97. maxSpeed: 12,
  98. minDistance: 30
  99. },
  100. // 拖拽状态
  101. dragging: null,
  102. dragStartPos: null,
  103. // 动画
  104. animFrameId: null,
  105. isSimulating: false,
  106. // 触摸/悬停状态(显示 + 号)
  107. touchingSelf: false,
  108. hoveringSelf: false,
  109. // 是否 PC 环境(h5 编译)
  110. isPC: false,
  111. // 主题色
  112. themeColors: {
  113. body: '#8D6E63',
  114. mind: '#FF6B35',
  115. action: '#4CAF50',
  116. home: '#5B9BD5',
  117. wisdom: '#FFD700'
  118. }
  119. }
  120. },
  121. computed: {
  122. themeColor: function() {
  123. return this.themeColors[this.dimensionCode] || '#8D6E63'
  124. },
  125. // + 提示位置(跟随中心节点偏移)
  126. addHintPosition: function() {
  127. var selfNode = this.simNodes.find(function(n) { return n.isSelf })
  128. if (!selfNode) return {}
  129. return {
  130. left: (selfNode.x + selfNode.radius * 0.7) + 'px',
  131. top: (selfNode.y - selfNode.radius * 0.5) + 'px'
  132. }
  133. },
  134. // 将 members 转换为 simNodes
  135. nodeData: function() {
  136. var self = this
  137. if (!this.members || this.members.length === 0) return []
  138. var centerX = this.canvasWidth / 2
  139. var centerY = this.canvasHeight / 2
  140. return this.members.map(function(m, idx) {
  141. var fid = m.memberId || m.id
  142. var energy = self.getEnergy(fid)
  143. var radius = 24 + (Math.max(0, Math.min(100, energy)) / 100) * 16
  144. var nick = m.nickname || m.name || '成员'
  145. if (nick === '用户') nick = '成员'
  146. var isSelf = (fid == self.selfId)
  147. var displayName = nick.charAt(0)
  148. // 圆形布局初始位置
  149. var angle = (2 * Math.PI * idx) / Math.max(1, self.members.length)
  150. var r = Math.min(self.canvasWidth, self.canvasHeight) * 0.32
  151. var initX = centerX + r * Math.cos(angle)
  152. var initY = centerY + r * Math.sin(angle)
  153. return {
  154. id: fid,
  155. nickname: nick,
  156. displayName: displayName,
  157. isSelf: isSelf,
  158. memberType: m.memberType || 'child',
  159. relationshipType: m.relationshipType || '',
  160. radius: radius,
  161. x: initX,
  162. y: initY,
  163. vx: 0,
  164. vy: 0,
  165. fx: 0,
  166. fy: 0
  167. }
  168. })
  169. },
  170. // 构建边 (self 连接到其他所有成员)
  171. edges: function() {
  172. var self = this
  173. var selfNode = this.nodeData.find(function(n) { return n.isSelf })
  174. if (!selfNode) return []
  175. var result = []
  176. this.nodeData.forEach(function(n) {
  177. if (n.id !== selfNode.id) {
  178. var pairKey = selfNode.id < n.id
  179. ? selfNode.id + '-' + n.id
  180. : n.id + '-' + selfNode.id
  181. var comp = self.compatibilityMap[pairKey]
  182. var trust = self.getIntimacy(n.id, 'trust')
  183. var comm = self.getIntimacy(n.id, 'communication')
  184. var closeness = self.getIntimacy(n.id, 'closeness')
  185. result.push({
  186. source: selfNode,
  187. target: n,
  188. trust: trust,
  189. communication: comm,
  190. closeness: closeness,
  191. compatibility: comp && comp.overallScore !== undefined ? comp.overallScore : null
  192. })
  193. }
  194. })
  195. return result
  196. }
  197. },
  198. watch: {
  199. members: function() {
  200. this.resetSimulation()
  201. },
  202. canvasWidth: function() {
  203. this.resetSimulation()
  204. },
  205. canvasHeight: function() {
  206. this.resetSimulation()
  207. }
  208. },
  209. mounted: function() {
  210. var self = this
  211. this.dpr = uni.getSystemInfoSync().pixelRatio || 1
  212. this.$nextTick(function() {
  213. self.updateCanvasSize()
  214. self.$nextTick(function() {
  215. self.initSimulation()
  216. })
  217. })
  218. },
  219. beforeDestroy: function() {
  220. this.stopSimulation()
  221. },
  222. methods: {
  223. // ===== 物理引擎 =====
  224. resetSimulation: function() {
  225. this.stopSimulation()
  226. var centerX = this.canvasWidth / 2
  227. var centerY = this.canvasHeight / 2
  228. var self = this
  229. this.nodeData.forEach(function(n, idx) {
  230. var angle = (2 * Math.PI * idx) / Math.max(1, self.nodeData.length)
  231. var r = Math.min(self.canvasWidth, self.canvasHeight) * 0.32
  232. n.x = centerX + r * Math.cos(angle)
  233. n.y = centerY + r * Math.sin(angle)
  234. n.vx = 0
  235. n.vy = 0
  236. n.fx = 0
  237. n.fy = 0
  238. })
  239. this.startSimulation()
  240. },
  241. initSimulation: function() {
  242. if (!this.nodeData || this.nodeData.length === 0) return
  243. // 复制到 simNodes
  244. this.simNodes = this.nodeData.slice()
  245. this.startSimulation()
  246. },
  247. startSimulation: function() {
  248. if (this.isSimulating) return
  249. this.isSimulating = true
  250. // 延迟一帧启动,确保DOM已更新
  251. var self = this
  252. setTimeout(function() {
  253. self.simulationTick()
  254. }, 50)
  255. },
  256. stopSimulation: function() {
  257. this.isSimulating = false
  258. if (this.animFrameId) {
  259. clearTimeout(this.animFrameId)
  260. this.animFrameId = null
  261. }
  262. },
  263. simulationTick: function() {
  264. if (!this.isSimulating) return
  265. var self = this
  266. var nodes = this.simNodes
  267. var p = this.physics
  268. // 1. 清零力
  269. for (var i = 0; i < nodes.length; i++) {
  270. nodes[i].fx = 0
  271. nodes[i].fy = 0
  272. }
  273. // 2. 斥力
  274. for (var a = 0; a < nodes.length; a++) {
  275. for (var b = a + 1; b < nodes.length; b++) {
  276. var dx = nodes[b].x - nodes[a].x
  277. var dy = nodes[b].y - nodes[a].y
  278. var distSq = dx * dx + dy * dy
  279. var dist = Math.sqrt(distSq) || 0.1
  280. var force = p.repulsion / distSq
  281. var fx = (dx / dist) * force
  282. var fy = (dy / dist) * force
  283. nodes[a].fx -= fx
  284. nodes[a].fy -= fy
  285. nodes[b].fx += fx
  286. nodes[b].fy += fy
  287. }
  288. }
  289. // 3. 弹簧引力
  290. for (var e = 0; e < this.edges.length; e++) {
  291. var edge = this.edges[e]
  292. var s = edge.source
  293. var t = edge.target
  294. var dx = t.x - s.x
  295. var dy = t.y - s.y
  296. var dist = Math.sqrt(dx * dx + dy * dy) || 0.1
  297. var displacement = dist - p.springLength
  298. var force = p.springStrength * displacement
  299. var fx = (dx / dist) * force
  300. var fy = (dy / dist) * force
  301. s.fx += fx
  302. s.fy += fy
  303. t.fx -= fx
  304. t.fy -= fy
  305. }
  306. // 4. 中心引力
  307. var cx = this.canvasWidth / 2
  308. var cy = this.canvasHeight / 2
  309. for (var n = 0; n < nodes.length; n++) {
  310. nodes[n].fx += (cx - nodes[n].x) * p.centering
  311. nodes[n].fy += (cy - nodes[n].y) * p.centering
  312. }
  313. // 5. 更新速度 + 位置
  314. for (var i = 0; i < nodes.length; i++) {
  315. var node = nodes[i]
  316. node.vx += node.fx
  317. node.vy += node.fy
  318. node.vx *= p.damping
  319. node.vy *= p.damping
  320. var speed = Math.sqrt(node.vx * node.vx + node.vy * node.vy)
  321. if (speed > p.maxSpeed) {
  322. node.vx = (node.vx / speed) * p.maxSpeed
  323. node.vy = (node.vy / speed) * p.maxSpeed
  324. }
  325. node.x += node.vx
  326. node.y += node.vy
  327. // 边界
  328. var r = node.radius + 4
  329. if (node.x < r) { node.x = r; node.vx *= -0.5 }
  330. if (node.x > this.canvasWidth - r) { node.x = this.canvasWidth - r; node.vx *= -0.5 }
  331. if (node.y < r) { node.y = r; node.vy *= -0.5 }
  332. if (node.y > this.canvasHeight - r) { node.y = this.canvasHeight - r; node.vy *= -0.5 }
  333. }
  334. // 6. 渲染
  335. this.render()
  336. // 7. 下一帧(使用 setTimeout 代替 requestAnimationFrame)
  337. this.animFrameId = setTimeout(function() {
  338. self.simulationTick()
  339. }, 33)
  340. },
  341. // ===== 渲染 =====
  342. render: function() {
  343. var self = this
  344. var query = uni.createSelectorQuery().in(this)
  345. query.select('#forceGraphCanvas')
  346. .fields({ node: true, size: true })
  347. .exec(function(res) {
  348. if (!res || !res[0] || !res[0].node) {
  349. // Canvas 节点尚未就绪,延迟重试
  350. if (self._retryCount === undefined) self._retryCount = 0
  351. if (self._retryCount < 5) {
  352. self._retryCount++
  353. setTimeout(function() {
  354. self.render()
  355. }, 200)
  356. }
  357. return
  358. }
  359. self._retryCount = 0
  360. var canvas = res[0].node
  361. var ctx = canvas.getContext('2d')
  362. canvas.width = self.canvasWidth * self.dpr
  363. canvas.height = self.canvasHeight * self.dpr
  364. ctx.scale(self.dpr, self.dpr)
  365. ctx.clearRect(0, 0, self.canvasWidth, self.canvasHeight)
  366. self.drawEdges(ctx)
  367. self.drawNodes(ctx)
  368. })
  369. },
  370. drawEdges: function(ctx) {
  371. var edges = this.edges
  372. for (var i = 0; i < edges.length; i++) {
  373. var edge = edges[i]
  374. var s = edge.source
  375. var t = edge.target
  376. var trust = edge.trust || 75
  377. var comm = edge.communication || 50
  378. var hue = 120 * trust / 100
  379. ctx.strokeStyle = 'hsla(' + hue + ', 75%, 45%, 0.6)'
  380. ctx.lineWidth = 1 + (comm / 100) * 4
  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.simNodes
  389. for (var i = 0; i < nodes.length; i++) {
  390. var node = nodes[i]
  391. var r = node.radius
  392. if (node.isSelf) {
  393. // 自我:双层圆环
  394. ctx.beginPath()
  395. ctx.arc(node.x, node.y, r, 0, 2 * Math.PI)
  396. ctx.fillStyle = '#FFFFFF'
  397. ctx.fill()
  398. ctx.strokeStyle = this.themeColor
  399. ctx.lineWidth = 3
  400. ctx.stroke()
  401. // 内环
  402. ctx.beginPath()
  403. ctx.arc(node.x, node.y, r * 0.7, 0, 2 * Math.PI)
  404. ctx.strokeStyle = this.themeColor + '88'
  405. ctx.lineWidth = 2
  406. ctx.stroke()
  407. } else if (node.relationshipType === 'spouse') {
  408. // 配偶:心形
  409. this.drawHeart(ctx, node.x, node.y, r)
  410. ctx.fillStyle = this.themeColor + '22'
  411. ctx.fill()
  412. } else if (node.relationshipType === 'parent') {
  413. // 父母:圆角方形
  414. this.drawRoundedRect(ctx, node.x, node.y, r * 2, r * 2, r * 0.3)
  415. ctx.fillStyle = this.themeColor + '22'
  416. ctx.fill()
  417. } else if (node.relationshipType === 'sibling') {
  418. // 兄弟姐妹:八边形
  419. this.drawPolygon(ctx, node.x, node.y, r, 8)
  420. ctx.fillStyle = this.themeColor + '22'
  421. ctx.fill()
  422. } else {
  423. // 孩子/默认:圆形
  424. ctx.beginPath()
  425. ctx.arc(node.x, node.y, r, 0, 2 * Math.PI)
  426. ctx.fillStyle = this.themeColor + '22'
  427. ctx.fill()
  428. }
  429. ctx.fillStyle = node.isSelf ? this.themeColor : '#4A5568'
  430. ctx.font = 'bold ' + (r * 0.9) + 'px sans-serif'
  431. ctx.textAlign = 'center'
  432. ctx.textBaseline = 'middle'
  433. ctx.fillText(node.displayName, node.x, node.y)
  434. ctx.fillStyle = node.isSelf ? this.themeColor : '#94A3B8'
  435. ctx.font = (r * 0.4) + 'px sans-serif'
  436. ctx.fillText(node.nickname.substring(0, 3), node.x, node.y + r + 10)
  437. }
  438. },
  439. renderLegacy: function() {
  440. var ctx = uni.createCanvasContext('forceGraphCanvas', this)
  441. if (!ctx) return
  442. // hex转rgba辅助
  443. function hexToRgba(hex, alpha) {
  444. if (hex && hex.length === 7) {
  445. var r = parseInt(hex.slice(1, 3), 16)
  446. var g = parseInt(hex.slice(3, 5), 16)
  447. var b = parseInt(hex.slice(5, 7), 16)
  448. return 'rgba(' + r + ',' + g + ',' + b + ',' + alpha + ')'
  449. }
  450. return hex || '#FF6B9D'
  451. }
  452. ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
  453. var edges = this.edges
  454. for (var i = 0; i < edges.length; i++) {
  455. var edge = edges[i]
  456. var comm = edge.communication || 50
  457. var hue = 120 * (edge.trust || 75) / 100
  458. ctx.strokeStyle = 'hsla(' + hue + ', 75%, 45%, 0.6)'
  459. ctx.lineWidth = 1 + (comm / 100) * 4
  460. ctx.beginPath()
  461. ctx.moveTo(edge.source.x, edge.source.y)
  462. ctx.lineTo(edge.target.x, edge.target.y)
  463. ctx.stroke()
  464. }
  465. var nodes = this.simNodes
  466. for (var j = 0; j < nodes.length; j++) {
  467. var node = nodes[j]
  468. var r = node.radius
  469. if (node.isSelf) {
  470. ctx.beginPath()
  471. ctx.arc(node.x, node.y, r, 0, 2 * Math.PI)
  472. ctx.fillStyle = '#FFFFFF'
  473. ctx.fill()
  474. ctx.strokeStyle = this.themeColor
  475. ctx.lineWidth = 3
  476. ctx.stroke()
  477. // inner ring
  478. ctx.beginPath()
  479. ctx.arc(node.x, node.y, r * 0.7, 0, 2 * Math.PI)
  480. ctx.strokeStyle = hexToRgba(this.themeColor, 0.53)
  481. ctx.lineWidth = 2
  482. ctx.stroke()
  483. } else if (node.relationshipType === 'spouse') {
  484. this.drawHeartLegacy(ctx, node.x, node.y, r)
  485. ctx.fillStyle = hexToRgba(this.themeColor, 0.13)
  486. ctx.fill()
  487. } else if (node.relationshipType === 'parent') {
  488. this.drawRoundedRectLegacy(ctx, node.x, node.y, r * 2, r * 2, r * 0.3)
  489. ctx.fillStyle = hexToRgba(this.themeColor, 0.13)
  490. ctx.fill()
  491. } else if (node.relationshipType === 'sibling') {
  492. this.drawPolygonLegacy(ctx, node.x, node.y, r, 8)
  493. ctx.fillStyle = hexToRgba(this.themeColor, 0.13)
  494. ctx.fill()
  495. } else {
  496. ctx.beginPath()
  497. ctx.arc(node.x, node.y, r, 0, 2 * Math.PI)
  498. ctx.fillStyle = hexToRgba(this.themeColor, 0.13)
  499. ctx.fill()
  500. }
  501. ctx.fillStyle = node.isSelf ? this.themeColor : '#4A5568'
  502. ctx.font = 'bold ' + (r * 0.9) + 'px sans-serif'
  503. ctx.textAlign = 'center'
  504. ctx.textBaseline = 'middle'
  505. ctx.fillText(node.displayName, node.x, node.y)
  506. ctx.fillStyle = node.isSelf ? this.themeColor : '#94A3B8'
  507. ctx.font = (r * 0.4) + 'px sans-serif'
  508. ctx.fillText(node.nickname.substring(0, 3), node.x, node.y + r + 10)
  509. }
  510. ctx.draw()
  511. },
  512. // ===== 交互 =====
  513. getTouchNode: function(touchX, touchY) {
  514. var nodes = this.simNodes
  515. for (var i = 0; i < nodes.length; i++) {
  516. var node = nodes[i]
  517. var dx = touchX - node.x
  518. var dy = touchY - node.y
  519. var dist = Math.sqrt(dx * dx + dy * dy)
  520. if (dist <= node.radius + 8) {
  521. return node
  522. }
  523. }
  524. return null
  525. },
  526. onTouchStart: function(e) {
  527. if (!this.interactive) return
  528. var touch = e.touches ? e.touches[0] : e.detail
  529. if (!touch) return
  530. var self = this
  531. var rect = {}
  532. var query = uni.createSelectorQuery().in(this)
  533. query.select('#forceGraphCanvas').boundingClientRect()
  534. query.exec(function(res) {
  535. if (res && res[0]) {
  536. rect = res[0]
  537. var x = touch.clientX - rect.left
  538. var y = touch.clientY - rect.top
  539. var node = self.getTouchNode(x, y)
  540. if (node) {
  541. if (node.isSelf) {
  542. self.touchingSelf = true
  543. } else {
  544. self.dragging = { node: node, offsetX: x - node.x, offsetY: y - node.y }
  545. self.dragStartPos = { x: x, y: y }
  546. }
  547. }
  548. }
  549. })
  550. },
  551. onTouchMove: function(e) {
  552. if (!this.dragging) return
  553. e.preventDefault && e.preventDefault()
  554. var touch = e.touches ? e.touches[0] : e.detail
  555. if (!touch) return
  556. var self = this
  557. var rect = {}
  558. var query = uni.createSelectorQuery().in(this)
  559. query.select('#forceGraphCanvas').boundingClientRect()
  560. query.exec(function(res) {
  561. if (res && res[0]) {
  562. rect = res[0]
  563. var x = touch.clientX - rect.left
  564. var y = touch.clientY - rect.top
  565. var node = self.dragging.node
  566. node.x = x - self.dragging.offsetX
  567. node.y = y - self.dragging.offsetY
  568. }
  569. })
  570. },
  571. onTouchEnd: function(e) {
  572. var wasTouchingSelf = this.touchingSelf
  573. this.touchingSelf = false
  574. if (wasTouchingSelf) {
  575. // 触摸的是中心"我"节点 → 跳转添加成员页面
  576. if (this.interactive) {
  577. this.$emit('addMemberTap')
  578. this.goAddMember()
  579. }
  580. this.dragging = null
  581. this.dragStartPos = null
  582. return
  583. }
  584. if (!this.dragging) return
  585. var wasDragged = this.dragStartPos && (
  586. Math.abs(this.dragging.node.x - this.dragStartPos.x) > 5 ||
  587. Math.abs(this.dragging.node.y - this.dragStartPos.y) > 5
  588. )
  589. var node = this.dragging.node
  590. if (!wasDragged && this.interactive) {
  591. this.$emit('memberTap', {
  592. memberId: node.id,
  593. memberType: node.memberType,
  594. nickname: node.nickname
  595. })
  596. }
  597. this.dragging = null
  598. this.dragStartPos = null
  599. },
  600. // ===== 工具方法 =====
  601. getEnergy: function(memberId) {
  602. var data = this.energyMap[memberId]
  603. if (!data) return 0
  604. var key = this.dimensionCode + 'Score'
  605. var val = data[key]
  606. return (typeof val !== 'undefined' && val !== null) ? val : 0
  607. },
  608. getIntimacy: function(memberId, key) {
  609. var data = this.intimacyMap[memberId]
  610. if (!data) {
  611. var defaults = { closeness: 75, communication: 50, trust: 85 }
  612. return defaults[key]
  613. }
  614. var val = data[key]
  615. return (typeof val !== 'undefined' && val !== null) ? val : 75
  616. },
  617. // 跳转到添加成员页面
  618. goAddMember: function() {
  619. uni.navigateTo({
  620. url: '/pages/family/add-member'
  621. })
  622. },
  623. // 点击右上角小齿轮 → 跳转成员管理页
  624. onManageTap: function() {
  625. uni.navigateTo({
  626. url: '/pages/profile/family-members'
  627. })
  628. },
  629. updateCanvasSize: function() {
  630. var self = this
  631. var query = uni.createSelectorQuery().in(this)
  632. query.select('.family-graph-wrapper').boundingClientRect(function(rect) {
  633. if (rect) {
  634. self.canvasWidth = rect.width || 340
  635. self.canvasHeight = rect.height || 280
  636. }
  637. }).exec()
  638. },
  639. // ===== 形状绘制辅助方法 =====
  640. drawHeart: function(ctx, cx, cy, size) {
  641. ctx.beginPath()
  642. var topX = cx
  643. var topY = cy - size * 0.4
  644. ctx.moveTo(topX, topY)
  645. // 左曲线
  646. ctx.bezierCurveTo(
  647. cx - size * 0.9, cy - size * 0.7,
  648. cx - size * 0.6, cy + size * 0.5,
  649. cx, cy + size * 0.8
  650. )
  651. // 右曲线
  652. ctx.bezierCurveTo(
  653. cx + size * 0.6, cy + size * 0.5,
  654. cx + size * 0.9, cy - size * 0.7,
  655. cx, cy - size * 0.4
  656. )
  657. ctx.closePath()
  658. },
  659. drawRoundedRect: function(ctx, cx, cy, w, h, r) {
  660. ctx.beginPath()
  661. var x = cx - w / 2
  662. var y = cy - h / 2
  663. ctx.moveTo(x + r, y)
  664. ctx.arcTo(x + w, y, x + w, y + h, r)
  665. ctx.arcTo(x + w, y + h, x, y + h, r)
  666. ctx.arcTo(x, y + h, x, y, r)
  667. ctx.arcTo(x, y, x + w, y, r)
  668. ctx.closePath()
  669. },
  670. drawPolygon: function(ctx, cx, cy, r, sides) {
  671. ctx.beginPath()
  672. var startAngle = -Math.PI / 2
  673. for (var i = 0; i < sides; i++) {
  674. var angle = startAngle + (2 * Math.PI * i) / sides
  675. var px = cx + r * Math.cos(angle)
  676. var py = cy + r * Math.sin(angle)
  677. if (i === 0) {
  678. ctx.moveTo(px, py)
  679. } else {
  680. ctx.lineTo(px, py)
  681. }
  682. }
  683. ctx.closePath()
  684. },
  685. drawHeartLegacy: function(ctx, cx, cy, size) {
  686. ctx.beginPath()
  687. ctx.moveTo(cx, cy - size * 0.4)
  688. ctx.bezierCurveTo(cx - size * 0.9, cy - size * 0.7, cx - size * 0.6, cy + size * 0.5, cx, cy + size * 0.8)
  689. ctx.bezierCurveTo(cx + size * 0.6, cy + size * 0.5, cx + size * 0.9, cy - size * 0.7, cx, cy - size * 0.4)
  690. ctx.closePath()
  691. },
  692. drawRoundedRectLegacy: function(ctx, cx, cy, w, h, r) {
  693. ctx.beginPath()
  694. var x = cx - w / 2
  695. var y = cy - h / 2
  696. ctx.moveTo(x + r, y)
  697. ctx.arcTo(x + w, y, x + w, y + h, r)
  698. ctx.arcTo(x + w, y + h, x, y + h, r)
  699. ctx.arcTo(x, y + h, x, y, r)
  700. ctx.arcTo(x, y, x + w, y, r)
  701. ctx.closePath()
  702. },
  703. drawPolygonLegacy: function(ctx, cx, cy, r, sides) {
  704. ctx.beginPath()
  705. var startAngle = -Math.PI / 2
  706. for (var i = 0; i < sides; i++) {
  707. var angle = startAngle + (2 * Math.PI * i) / sides
  708. var px = cx + r * Math.cos(angle)
  709. var py = cy + r * Math.sin(angle)
  710. if (i === 0) {
  711. ctx.moveTo(px, py)
  712. } else {
  713. ctx.lineTo(px, py)
  714. }
  715. }
  716. ctx.closePath()
  717. }
  718. }
  719. }
  720. </script>
  721. <style scoped>
  722. .family-graph-wrapper {
  723. position: relative;
  724. width: 100%;
  725. min-height: 200rpx;
  726. margin: 10rpx 0;
  727. background: #FFFFFF;
  728. border-radius: 24rpx;
  729. overflow: hidden;
  730. }
  731. .family-graph-canvas {
  732. position: absolute;
  733. top: 0;
  734. left: 0;
  735. z-index: 1;
  736. }
  737. /* PC 端悬停检测覆盖层 */
  738. .hover-layer {
  739. position: absolute;
  740. top: 0;
  741. left: 0;
  742. z-index: 2;
  743. background: transparent;
  744. pointer-events: auto;
  745. }
  746. /* 右上角小齿轮 */
  747. .gear-icon {
  748. position: absolute;
  749. top: 12rpx;
  750. right: 12rpx;
  751. z-index: 10;
  752. width: 56rpx;
  753. height: 56rpx;
  754. display: flex;
  755. align-items: center;
  756. justify-content: center;
  757. background: rgba(255, 255, 255, 0.9);
  758. border-radius: 50%;
  759. box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
  760. }
  761. .gear-text {
  762. font-size: 32rpx;
  763. color: #94A3B8;
  764. }
  765. /* + 添加成员提示 */
  766. .add-hint {
  767. position: absolute;
  768. z-index: 5;
  769. width: 40rpx;
  770. height: 40rpx;
  771. border-radius: 50%;
  772. background: #10B981;
  773. display: flex;
  774. align-items: center;
  775. justify-content: center;
  776. opacity: 0;
  777. transform: scale(0.5);
  778. transition: opacity 0.15s, transform 0.15s;
  779. pointer-events: none;
  780. }
  781. .add-hint.add-hint-visible {
  782. opacity: 1;
  783. transform: scale(1);
  784. }
  785. .add-hint-text {
  786. font-size: 28rpx;
  787. color: #FFFFFF;
  788. font-weight: bold;
  789. line-height: 1;
  790. }
  791. .family-graph-empty {
  792. display: flex;
  793. align-items: center;
  794. justify-content: center;
  795. padding: 60rpx 0;
  796. background: #FFFFFF;
  797. border-radius: 24rpx;
  798. margin: 10rpx 0;
  799. }
  800. .empty-text {
  801. font-size: 28rpx;
  802. color: #94A3B8;
  803. }
  804. </style>