FamilyRelationGraph.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518
  1. <template>
  2. <view class="family-relation-graph">
  3. <!-- 空状态 -->
  4. <view class="graph-empty" v-if="!members || members.length === 0">
  5. <text class="empty-text">暂无家庭成员</text>
  6. </view>
  7. <!-- Canvas 画布 -->
  8. <canvas
  9. v-else
  10. canvas-id="relationGraphCanvas"
  11. id="relationGraphCanvas"
  12. type="2d"
  13. @tap="onCanvasTap"
  14. :style="{
  15. width: canvasWidth + 'px',
  16. height: canvasHeight + 'px'
  17. }" />
  18. </view>
  19. </template>
  20. <script>
  21. export default {
  22. name: 'FamilyRelationGraph',
  23. props: {
  24. dimensionCode: {
  25. type: String,
  26. required: true,
  27. validator: function(val) {
  28. return ['body', 'mind', 'action'].indexOf(val) !== -1
  29. }
  30. },
  31. selfId: {
  32. type: [Number, String],
  33. default: null
  34. },
  35. members: {
  36. type: Array,
  37. default: function() { return [] }
  38. },
  39. energyMap: {
  40. type: Object,
  41. default: function() { return {} }
  42. },
  43. intimacyMap: {
  44. type: Object,
  45. default: function() { return {} }
  46. }
  47. },
  48. data: function() {
  49. return {
  50. canvasWidth: 320,
  51. canvasHeight: 320,
  52. dpr: 1,
  53. positionedMembers: [],
  54. selfPosition: { x: 0, y: 0 },
  55. themeColors: {
  56. body: '#8D6E63',
  57. mind: '#FF6B35',
  58. action: '#4CAF50'
  59. }
  60. }
  61. },
  62. computed: {
  63. themeColor: function() {
  64. return this.themeColors[this.dimensionCode] || '#8D6E63'
  65. },
  66. themeColorRgba: function() {
  67. // 主题色15%透明度 for fill
  68. var c = this.themeColor
  69. return c + '26' // 15% alpha hex
  70. },
  71. themeColorStroke: function() {
  72. var c = this.themeColor
  73. return c + '66' // 40% alpha hex
  74. }
  75. },
  76. watch: {
  77. members: function() { this.draw() },
  78. energyMap: function() { this.draw() },
  79. intimacyMap: function() { this.draw() }
  80. },
  81. mounted: function() {
  82. this.initCanvas()
  83. },
  84. methods: {
  85. initCanvas: function() {
  86. var sysInfo = uni.getSystemInfoSync()
  87. this.dpr = sysInfo.pixelRatio || 1
  88. var size = uni.upx2px(690)
  89. this.canvasWidth = size
  90. this.canvasHeight = size
  91. var self = this
  92. this.$nextTick(function() {
  93. if (self.members && self.members.length > 0) {
  94. self.draw()
  95. }
  96. })
  97. },
  98. // 获取亲密度数据(有则用,无则返回默认值)
  99. getIntimacy: function(memberId, key) {
  100. var data = this.intimacyMap[memberId]
  101. if (!data) {
  102. var defaults = { closeness: 75, communication: 50, trust: 85 }
  103. return defaults[key]
  104. }
  105. var val = data[key]
  106. return (typeof val !== 'undefined' && val !== null) ? val : 75
  107. },
  108. // 获取能量值
  109. getEnergy: function(memberId) {
  110. var data = this.energyMap[memberId]
  111. if (!data) return 0
  112. var key = this.dimensionCode + 'Score' // bodyScore / mindScore / actionScore
  113. var val = data[key]
  114. return (typeof val !== 'undefined' && val !== null) ? val : 0
  115. },
  116. // 极坐标布局 — 计算每个成员的位置
  117. polarLayout: function() {
  118. var centerX = this.canvasWidth / 2
  119. var centerY = this.canvasHeight / 2
  120. this.selfPosition = { x: centerX, y: centerY }
  121. var N = this.members.length
  122. if (N === 0) {
  123. this.positionedMembers = []
  124. return
  125. }
  126. var R_INNER = uni.upx2px(160) // ~74px
  127. var R_OUTER = uni.upx2px(480) // ~222px
  128. var positioned = []
  129. for (var i = 0; i < N; i++) {
  130. var member = this.members[i]
  131. var intimacy = this.getIntimacy(member.id, 'closeness')
  132. // 亲密度0→半径最大,100→半径最小
  133. var radius = R_INNER + (100 - intimacy) / 100 * (R_OUTER - R_INNER)
  134. // 从正上方(12点钟)开始均匀分布
  135. var angle = (2 * Math.PI * i) / N - Math.PI / 2
  136. var x = centerX + radius * Math.cos(angle)
  137. var y = centerY + radius * Math.sin(angle)
  138. var isSelf = (member.id === this.selfId)
  139. var memberType = member.memberType || 'child'
  140. positioned.push({
  141. id: member.id,
  142. nickname: member.nickname || member.name || '成员',
  143. memberType: memberType,
  144. canvasX: x,
  145. canvasY: y,
  146. radius: isSelf ? uni.upx2px(150) : uni.upx2px(120), // self 72px, others 56px -> divide by 2
  147. hitRadius: isSelf ? uni.upx2px(180) : uni.upx2px(140),
  148. isSelf: isSelf,
  149. intimacy: this.getIntimacy(member.id, 'closeness'),
  150. communication: this.getIntimacy(member.id, 'communication'),
  151. trust: this.getIntimacy(member.id, 'trust')
  152. })
  153. }
  154. this.positionedMembers = positioned
  155. },
  156. // 主绘制流程
  157. draw: function() {
  158. this.polarLayout()
  159. if (this.positionedMembers.length === 0) return
  160. var self = this
  161. var query = uni.createSelectorQuery().in(this)
  162. // 使用 type="2d" 的 node API
  163. query.select('#relationGraphCanvas')
  164. .fields({ node: true, size: true })
  165. .exec(function(res) {
  166. if (!res || !res[0]) {
  167. // 降级方案:使用 CanvasContext
  168. self.drawLegacy()
  169. return
  170. }
  171. var canvas = res[0].node
  172. if (!canvas) {
  173. self.drawLegacy()
  174. return
  175. }
  176. var ctx = canvas.getContext('2d')
  177. // 设置 canvas 实际像素(适配高清屏)
  178. canvas.width = self.canvasWidth * self.dpr
  179. canvas.height = self.canvasHeight * self.dpr
  180. ctx.scale(self.dpr, self.dpr)
  181. // 清除画布
  182. ctx.clearRect(0, 0, self.canvasWidth, self.canvasHeight)
  183. // 绘制连线(先画线,让形状盖在线上)
  184. self.drawLines(ctx)
  185. // 绘制成员
  186. for (var i = 0; i < self.positionedMembers.length; i++) {
  187. self.drawMember(ctx, self.positionedMembers[i])
  188. }
  189. })
  190. },
  191. // 降级绘制(使用 CanvasContext 兼容旧版基础库)
  192. drawLegacy: function() {
  193. var ctx = uni.createCanvasContext('relationGraphCanvas', this)
  194. var self = this
  195. // 清除
  196. ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
  197. // 连线
  198. self.drawLinesLegacy(ctx)
  199. // 成员
  200. for (var i = 0; i < self.positionedMembers.length; i++) {
  201. self.drawMemberLegacy(ctx, self.positionedMembers[i])
  202. }
  203. ctx.draw()
  204. },
  205. // 绘制连线(type=2d mode)
  206. drawLines: function(ctx) {
  207. var selfPos = this.selfPosition
  208. var members = this.positionedMembers
  209. for (var i = 0; i < members.length; i++) {
  210. var m = members[i]
  211. if (m.isSelf) continue
  212. // 信任度 → 色相 (红0°→黄60°→绿120°)
  213. var trust = Math.max(0, Math.min(100, m.trust || 0))
  214. var hue = (120 * trust / 100)
  215. ctx.strokeStyle = 'hsl(' + hue + ', 75%, 45%)'
  216. ctx.globalAlpha = 0.8
  217. // 沟通度 → 线宽 2~10px
  218. var comm = Math.max(0, Math.min(100, m.communication || 0))
  219. ctx.lineWidth = 2 + (comm / 100) * 8
  220. // 绘制从 self 到成员的线段
  221. ctx.beginPath()
  222. ctx.moveTo(selfPos.x, selfPos.y)
  223. ctx.lineTo(m.canvasX, m.canvasY)
  224. ctx.stroke()
  225. }
  226. ctx.globalAlpha = 1.0
  227. },
  228. // 绘制连线(legacy mode)
  229. drawLinesLegacy: function(ctx) {
  230. var selfPos = this.selfPosition
  231. var members = this.positionedMembers
  232. for (var i = 0; i < members.length; i++) {
  233. var m = members[i]
  234. if (m.isSelf) continue
  235. var trust = Math.max(0, Math.min(100, m.trust || 0))
  236. var hue = (120 * trust / 100)
  237. ctx.setStrokeStyle('hsl(' + hue + ', 75%, 45%)')
  238. ctx.globalAlpha = 0.8
  239. var comm = Math.max(0, Math.min(100, m.communication || 0))
  240. ctx.setLineWidth(2 + (comm / 100) * 8)
  241. ctx.beginPath()
  242. ctx.moveTo(selfPos.x, selfPos.y)
  243. ctx.lineTo(m.canvasX, m.canvasY)
  244. ctx.stroke()
  245. }
  246. },
  247. // 根据成员类型绘制形状 path
  248. drawMemberShape: function(ctx, x, y, r, memberType) {
  249. ctx.beginPath()
  250. if (memberType === 'parent' || memberType === 'PARENT') {
  251. // 圆角正方形
  252. var cr = r * 0.25
  253. var s = r * 0.8
  254. ctx.moveTo(x - s + cr, y - s)
  255. ctx.lineTo(x + s - cr, y - s)
  256. ctx.arcTo(x + s, y - s, x + s, y - s + cr, cr)
  257. ctx.lineTo(x + s, y + s - cr)
  258. ctx.arcTo(x + s, y + s, x + s - cr, y + s, cr)
  259. ctx.lineTo(x - s + cr, y + s)
  260. ctx.arcTo(x - s, y + s, x - s, y + s - cr, cr)
  261. ctx.lineTo(x - s, y - s + cr)
  262. ctx.arcTo(x - s, y - s, x - s + cr, y - s, cr)
  263. ctx.closePath()
  264. } else if (memberType === 'child' || memberType === 'CHILD' || memberType === 'children') {
  265. // 圆形
  266. ctx.arc(x, y, r * 0.8, 0, 2 * Math.PI)
  267. } else if (memberType === 'partner' || memberType === 'spouse' || memberType === 'PARTNER') {
  268. // 简化心形
  269. var hr = r * 0.48
  270. // 左上弧(左心耳)
  271. ctx.arc(x - hr * 0.5, y - hr * 0.2, hr, 0, Math.PI * 1.7, true)
  272. // 右上弧(右心耳)
  273. ctx.arc(x + hr * 0.5, y - hr * 0.2, hr, Math.PI * 1.3, Math.PI * 0.5, true)
  274. ctx.closePath()
  275. } else {
  276. // 默认圆形
  277. ctx.arc(x, y, r * 0.8, 0, 2 * Math.PI)
  278. }
  279. },
  280. // 绘制环形能量进度条
  281. drawEnergyRing: function(ctx, x, y, radius, fillRatio, color) {
  282. var lineWidth = 4
  283. // 灰色底圈
  284. ctx.beginPath()
  285. ctx.arc(x, y, radius, 0, 2 * Math.PI)
  286. ctx.strokeStyle = 'rgba(200, 200, 200, 0.35)'
  287. ctx.lineWidth = lineWidth
  288. ctx.stroke()
  289. if (fillRatio <= 0) return
  290. // 彩色进度圈
  291. ctx.beginPath()
  292. var startAngle = -Math.PI / 2
  293. var endAngle = startAngle + fillRatio * 2 * Math.PI
  294. ctx.arc(x, y, radius, startAngle, endAngle)
  295. ctx.strokeStyle = color
  296. ctx.lineWidth = lineWidth
  297. ctx.lineCap = 'round'
  298. ctx.stroke()
  299. ctx.lineCap = 'butt' // reset
  300. },
  301. // 绘制成员形状(type=2d mode)
  302. drawMember: function(ctx, member) {
  303. var themeColor = this.themeColor
  304. var x = member.canvasX
  305. var y = member.canvasY
  306. var r = member.radius
  307. var energy = this.getEnergy(member.id)
  308. var fillRatio = Math.max(0, Math.min(1, energy / 100))
  309. ctx.save()
  310. if (member.isSelf) {
  311. // self:外层发光描边
  312. ctx.beginPath()
  313. ctx.arc(x, y, r + 6, 0, 2 * Math.PI)
  314. ctx.strokeStyle = themeColor
  315. ctx.lineWidth = 3
  316. ctx.globalAlpha = 0.4
  317. ctx.stroke()
  318. ctx.globalAlpha = 1.0
  319. // 内圆填充
  320. ctx.beginPath()
  321. ctx.arc(x, y, r, 0, 2 * Math.PI)
  322. ctx.fillStyle = '#FFFFFF'
  323. ctx.fill()
  324. ctx.strokeStyle = themeColor
  325. ctx.lineWidth = 3
  326. ctx.stroke()
  327. // 中心文字(昵称首字)
  328. ctx.fillStyle = themeColor
  329. ctx.font = 'bold ' + Math.round(r * 1.0) + 'px sans-serif'
  330. ctx.textAlign = 'center'
  331. ctx.textBaseline = 'middle'
  332. var displayName = (member.nickname && member.nickname.charAt(0)) || '我'
  333. ctx.fillText(displayName, x, y)
  334. } else {
  335. // 其他成员:根据 memberType 选择形状
  336. this.drawMemberShape(ctx, x, y, r, member.memberType)
  337. // 填充主题色(淡)
  338. ctx.fillStyle = this.themeColorRgba
  339. ctx.fill()
  340. ctx.strokeStyle = this.themeColorStroke
  341. ctx.lineWidth = 2
  342. ctx.stroke()
  343. // 环形能量进度条(在形状外围)
  344. this.drawEnergyRing(ctx, x, y, r + 5, fillRatio, themeColor)
  345. // 中心昵称首字
  346. ctx.fillStyle = '#4A5568'
  347. ctx.font = 'bold ' + Math.round(r * 0.7) + 'px sans-serif'
  348. ctx.textAlign = 'center'
  349. ctx.textBaseline = 'middle'
  350. var displayName = (member.nickname && member.nickname.charAt(0)) || '?'
  351. ctx.fillText(displayName, x, y)
  352. }
  353. ctx.restore()
  354. },
  355. // 绘制成员(legacy mode)
  356. drawMemberLegacy: function(ctx, member) {
  357. var themeColor = this.themeColor
  358. var x = member.canvasX
  359. var y = member.canvasY
  360. var r = member.radius
  361. var energy = this.getEnergy(member.id)
  362. var fillRatio = Math.max(0, Math.min(1, energy / 100))
  363. if (member.isSelf) {
  364. ctx.beginPath()
  365. ctx.arc(x, y, r + 6, 0, 2 * Math.PI)
  366. ctx.setStrokeStyle(themeColor)
  367. ctx.setLineWidth(3)
  368. ctx.setGlobalAlpha(0.4)
  369. ctx.stroke()
  370. ctx.setGlobalAlpha(1.0)
  371. ctx.beginPath()
  372. ctx.arc(x, y, r, 0, 2 * Math.PI)
  373. ctx.setFillStyle('#FFFFFF')
  374. ctx.fill()
  375. ctx.setStrokeStyle(themeColor)
  376. ctx.setLineWidth(3)
  377. ctx.stroke()
  378. ctx.setFillStyle(themeColor)
  379. ctx.font = 'bold ' + Math.round(r * 1.0) + 'px sans-serif'
  380. ctx.setTextAlign('center')
  381. ctx.setTextBaseline('middle')
  382. var name = (member.nickname && member.nickname.charAt(0)) || '我'
  383. ctx.fillText(name, x, y)
  384. } else {
  385. this.drawMemberShape(ctx, x, y, r, member.memberType)
  386. ctx.setFillStyle(this.themeColorRgba)
  387. ctx.fill()
  388. ctx.setStrokeStyle(this.themeColorStroke)
  389. ctx.setLineWidth(2)
  390. ctx.stroke()
  391. // 环形进度条
  392. ctx.beginPath()
  393. ctx.arc(x, y, r + 5, 0, 2 * Math.PI)
  394. ctx.setStrokeStyle('rgba(200, 200, 200, 0.35)')
  395. ctx.setLineWidth(4)
  396. ctx.stroke()
  397. if (fillRatio > 0) {
  398. ctx.beginPath()
  399. var startAngle = -Math.PI / 2
  400. var endAngle = startAngle + fillRatio * 2 * Math.PI
  401. ctx.arc(x, y, r + 5, startAngle, endAngle)
  402. ctx.setStrokeStyle(themeColor)
  403. ctx.setLineWidth(4)
  404. ctx.setLineCap('round')
  405. ctx.stroke()
  406. ctx.setLineCap('butt')
  407. }
  408. ctx.setFillStyle('#4A5568')
  409. ctx.font = 'bold ' + Math.round(r * 0.7) + 'px sans-serif'
  410. ctx.setTextAlign('center')
  411. ctx.setTextBaseline('middle')
  412. var name = (member.nickname && member.nickname.charAt(0)) || '?'
  413. ctx.fillText(name, x, y)
  414. }
  415. },
  416. // Canvas 点击命中检测
  417. onCanvasTap: function(e) {
  418. if (!e || !e.detail || typeof e.detail.x === 'undefined') return
  419. var self = this
  420. var query = uni.createSelectorQuery().in(this)
  421. query.select('#relationGraphCanvas').boundingClientRect(function(rect) {
  422. if (!rect) return
  423. var tapX = e.detail.x - (rect.left || 0)
  424. var tapY = e.detail.y - (rect.top || 0)
  425. for (var i = 0; i < self.positionedMembers.length; i++) {
  426. var m = self.positionedMembers[i]
  427. var dx = tapX - m.canvasX
  428. var dy = tapY - m.canvasY
  429. var dist = Math.sqrt(dx * dx + dy * dy)
  430. if (dist <= m.hitRadius) {
  431. self.$emit('memberTap', {
  432. memberId: m.id,
  433. memberType: m.memberType,
  434. nickname: m.nickname
  435. })
  436. return
  437. }
  438. }
  439. }).exec()
  440. }
  441. }
  442. }
  443. </script>
  444. <style scoped>
  445. .family-relation-graph {
  446. display: flex;
  447. align-items: center;
  448. justify-content: center;
  449. padding: 20rpx 0;
  450. margin: 10rpx 0;
  451. background: #FFFFFF;
  452. border-radius: 24rpx;
  453. position: relative;
  454. }
  455. .graph-empty {
  456. padding: 60rpx 0;
  457. display: flex;
  458. align-items: center;
  459. justify-content: center;
  460. }
  461. .empty-text {
  462. font-size: 28rpx;
  463. color: #94A3B8;
  464. }
  465. </style>