FamilyRelationGraph.vue 15 KB

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