FamilyRelationGraph.vue 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993
  1. <template>
  2. <!-- 全 Canvas 关系图谱 - Force-directed 物理布局 -->
  3. <view class="family-graph-wrapper" v-if="members && members.length >= 1" :style="{ minHeight: canvasHeight + 'px' }">
  4. <!-- 右上角小齿轮 → 成员管理 -->
  5. <view class="gear-icon" @click="onManageTap" @tap.stop="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. <!-- ⚙ 维护页提示(触摸自身节点时显示) -->
  18. <view class="add-hint" :class="{ 'add-hint-visible': touchingSelf }" :style="addHintPosition">
  19. <text class="add-hint-text">⚙</text>
  20. </view>
  21. </view>
  22. <!-- 空状态 -->
  23. <view class="family-graph-empty" v-else-if="members && members.length === 0">
  24. <text class="empty-text">暂无家庭成员</text>
  25. </view>
  26. </template>
  27. <script>
  28. import { BrandColors } from '../utils/brand-colors.js'
  29. /**
  30. * FamilyRelationGraph - 全 Canvas force-directed 家庭关系图
  31. *
  32. * 物理引擎: 手动实现的 force-directed 布局
  33. * - 节点间斥力 (repulsion)
  34. * - 边弹簧引力 (spring attraction)
  35. * - 中心引力 (centering)
  36. * - 阻尼 (damping)
  37. *
  38. * 渲染: 全 Canvas 绘制 (节点 + 连线)
  39. * 交互: 节点拖拽 + 点击检测
  40. */
  41. export default {
  42. name: 'FamilyRelationGraph',
  43. props: {
  44. dimensionCode: {
  45. type: String,
  46. required: true,
  47. validator: function(val) {
  48. return ['body', 'mind', 'action', 'home', 'wisdom'].indexOf(val) !== -1
  49. }
  50. },
  51. selfId: {
  52. type: [Number, String],
  53. default: null
  54. },
  55. members: {
  56. type: Array,
  57. default: function() { return [] }
  58. },
  59. energyMap: {
  60. type: Object,
  61. default: function() { return {} }
  62. },
  63. intimacyMap: {
  64. type: Object,
  65. default: function() { return {} }
  66. },
  67. interactive: {
  68. type: Boolean,
  69. default: true
  70. },
  71. compatibilityMap: {
  72. type: Object,
  73. default: function() { return {} }
  74. }
  75. },
  76. data: function() {
  77. return {
  78. canvasWidth: 340,
  79. canvasHeight: 280,
  80. dpr: 1,
  81. // 物理参数
  82. physics: {
  83. repulsion: 800, // 节点间斥力强度
  84. springLength: 120, // 弹簧自然长度
  85. springStrength: 0.04, // 弹簧劲度系数
  86. centering: 0.03, // 中心引力
  87. damping: 0.88, // 速度阻尼
  88. maxSpeed: 12, // 最大速度
  89. minDistance: 30 // 最小节点距离
  90. },
  91. // 拖拽状态
  92. dragging: null, // { nodeIndex, offsetX, offsetY }
  93. dragStartPos: null, // 用于判断是否真的拖拽了
  94. // 触摸/悬停状态(显示 ⚙ 提示)
  95. touchingSelf: false,
  96. hoveringSelf: false,
  97. // 是否 PC 环境(h5 编译)
  98. isPC: false,
  99. // 动画
  100. animFrameId: null,
  101. isSimulating: false,
  102. // 动能收敛检测(布局收敛后自动停止,避免永续占用主线程)
  103. stillFrames: 0, // 连续低动能帧计数
  104. energyThreshold: 2, // 总动能阈值(各节点速度平方和)
  105. stillFrameLimit: 30, // 连续 30 帧低动能(约 0.5s)→ 停止
  106. // 主题色
  107. themeColors: {
  108. body: BrandColors.brownWarm,
  109. mind: BrandColors.mindAccent,
  110. action: BrandColors.matGreen,
  111. home: BrandColors.primary,
  112. wisdom: '#FFD700'
  113. }
  114. }
  115. },
  116. computed: {
  117. themeColor: function() {
  118. return this.themeColors[this.dimensionCode] || BrandColors.brownWarm
  119. },
  120. // ⚙ 提示位置(跟随自身节点偏移)
  121. addHintPosition: function() {
  122. var selfNode = this.nodeData.find(function(n) { return n.isSelf })
  123. if (!selfNode) return {}
  124. return {
  125. left: (selfNode.x + selfNode.radius * 0.7) + 'px',
  126. top: (selfNode.y - selfNode.radius * 0.5) + 'px'
  127. }
  128. },
  129. // 将 members 转换为 simNodes
  130. nodeData: function() {
  131. var self = this
  132. if (!this.members || this.members.length === 0) return []
  133. var selfMemberId = null
  134. var centerX = this.canvasWidth / 2
  135. var centerY = this.canvasHeight / 2
  136. return this.members.map(function(m, idx) {
  137. var fid = m.memberId || m.id
  138. if (fid == self.selfId) selfMemberId = fid
  139. var energy = self.getEnergy(fid)
  140. var radius = 24 + (Math.max(0, Math.min(100, energy)) / 100) * 16 // 24-40rpx
  141. var nick = m.nickname || m.name || '成员'
  142. if (nick === '用户') nick = '成员'
  143. var isSelf = (fid == self.selfId)
  144. var displayName = nick.charAt(0)
  145. // 圆形布局初始位置(避免初始重叠)
  146. var angle = (2 * Math.PI * idx) / self.members.length
  147. var r = Math.min(self.canvasWidth, self.canvasHeight) * 0.32
  148. var initX = centerX + r * Math.cos(angle)
  149. var initY = centerY + r * Math.sin(angle)
  150. return {
  151. id: fid,
  152. nickname: nick,
  153. displayName: displayName,
  154. isSelf: isSelf,
  155. memberType: m.memberType || 'child',
  156. effectiveRole: m.effectiveRole || m.relativeLabel || '',
  157. gender: m.gender || '',
  158. relativeLabel: m.relativeLabel || '',
  159. radius: radius,
  160. x: initX,
  161. y: initY,
  162. vx: 0,
  163. vy: 0,
  164. fx: 0,
  165. fy: 0
  166. }
  167. })
  168. },
  169. // 构建边 (self 连接到其他所有成员)
  170. edges: function() {
  171. var self = this
  172. var selfNode = this.nodeData.find(function(n) { return n.isSelf })
  173. if (!selfNode) return []
  174. var result = []
  175. this.nodeData.forEach(function(n) {
  176. if (n.id !== selfNode.id) {
  177. // 获取亲密度
  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. // 边权重用于可视化
  189. trust: trust,
  190. communication: comm,
  191. closeness: closeness,
  192. compatibility: comp && comp.overallScore !== undefined ? comp.overallScore : null
  193. })
  194. }
  195. })
  196. return result
  197. }
  198. },
  199. watch: {
  200. members: function() {
  201. this.resetSimulation()
  202. },
  203. canvasWidth: function() {
  204. this.resetSimulation()
  205. },
  206. canvasHeight: function() {
  207. this.resetSimulation()
  208. }
  209. },
  210. mounted: function() {
  211. this._destroyed = false
  212. var self = this
  213. try {
  214. this.dpr = uni.getWindowInfo().pixelRatio || 1
  215. } catch (e) {
  216. this.dpr = 1
  217. }
  218. this.$nextTick(function() {
  219. if (self._destroyed) return
  220. self.updateCanvasSize()
  221. self.$nextTick(function() {
  222. if (!self._destroyed) {
  223. self.initSimulation()
  224. }
  225. })
  226. })
  227. },
  228. beforeDestroy: function() {
  229. this._destroyed = true
  230. this.stopSimulation()
  231. },
  232. methods: {
  233. // ===== 物理引擎 =====
  234. resetSimulation: function() {
  235. this.stopSimulation()
  236. // 重新初始化节点位置
  237. var centerX = this.canvasWidth / 2
  238. var centerY = this.canvasHeight / 2
  239. var self = this
  240. this.nodeData.forEach(function(n, idx) {
  241. var angle = (2 * Math.PI * idx) / self.nodeData.length
  242. var r = Math.min(self.canvasWidth, self.canvasHeight) * 0.32
  243. n.x = centerX + r * Math.cos(angle)
  244. n.y = centerY + r * Math.sin(angle)
  245. n.vx = 0
  246. n.vy = 0
  247. n.fx = 0
  248. n.fy = 0
  249. })
  250. this.startSimulation()
  251. },
  252. initSimulation: function() {
  253. if (!this.nodeData || this.nodeData.length === 0) return
  254. this.startSimulation()
  255. },
  256. // hex → rgba(小程序不支持 8 位 hex 颜色 #RRGGBBAA)
  257. hexToRgba: function(hex, alpha) {
  258. var r = parseInt(hex.slice(1, 3), 16)
  259. var g = parseInt(hex.slice(3, 5), 16)
  260. var b = parseInt(hex.slice(5, 7), 16)
  261. return 'rgba(' + r + ',' + g + ',' + b + ',' + alpha + ')'
  262. },
  263. startSimulation: function() {
  264. if (this.isSimulating) return
  265. this.isSimulating = true
  266. this.stillFrames = 0
  267. this.simulationTick()
  268. },
  269. stopSimulation: function() {
  270. this.isSimulating = false
  271. if (this.animFrameId) {
  272. clearTimeout(this.animFrameId)
  273. this.animFrameId = null
  274. }
  275. },
  276. simulationTick: function() {
  277. if (!this.isSimulating) return
  278. if (this._destroyed) {
  279. this.isSimulating = false
  280. return
  281. }
  282. var self = this
  283. var nodes = this.nodeData
  284. var edges = this.edges
  285. var p = this.physics
  286. // 1. 清零力
  287. for (var i = 0; i < nodes.length; i++) {
  288. if (!nodes[i].fixed) {
  289. nodes[i].fx = 0
  290. nodes[i].fy = 0
  291. }
  292. }
  293. // 2. 斥力 (所有节点对之间)
  294. for (var a = 0; a < nodes.length; a++) {
  295. for (var b = a + 1; b < nodes.length; b++) {
  296. var dx = nodes[b].x - nodes[a].x
  297. var dy = nodes[b].y - nodes[a].y
  298. var distSq = dx * dx + dy * dy
  299. var dist = Math.sqrt(distSq) || 0.1
  300. // F = repulsion / dist^2
  301. var force = p.repulsion / distSq
  302. var fx = (dx / dist) * force
  303. var fy = (dy / dist) * force
  304. if (!nodes[a].fixed) {
  305. nodes[a].fx -= fx
  306. nodes[a].fy -= fy
  307. }
  308. if (!nodes[b].fixed) {
  309. nodes[b].fx += fx
  310. nodes[b].fy += fy
  311. }
  312. }
  313. }
  314. // 3. 弹簧引力 (边)
  315. for (var e = 0; e < edges.length; e++) {
  316. var edge = edges[e]
  317. var s = edge.source
  318. var t = edge.target
  319. var dx = t.x - s.x
  320. var dy = t.y - s.y
  321. var dist = Math.sqrt(dx * dx + dy * dy) || 0.1
  322. var displacement = dist - p.springLength
  323. var force = p.springStrength * displacement
  324. var fx = (dx / dist) * force
  325. var fy = (dy / dist) * force
  326. if (!s.fixed) {
  327. s.fx += fx
  328. s.fy += fy
  329. }
  330. if (!t.fixed) {
  331. t.fx -= fx
  332. t.fy -= fy
  333. }
  334. }
  335. // 4. 中心引力
  336. var cx = this.canvasWidth / 2
  337. var cy = this.canvasHeight / 2
  338. for (var n = 0; n < nodes.length; n++) {
  339. if (!nodes[n].fixed) {
  340. nodes[n].fx += (cx - nodes[n].x) * p.centering
  341. nodes[n].fy += (cy - nodes[n].y) * p.centering
  342. }
  343. }
  344. // 5. 更新速度 + 位置
  345. var totalEnergy = 0
  346. for (var i = 0; i < nodes.length; i++) {
  347. var node = nodes[i]
  348. if (node.fixed) continue
  349. node.vx += node.fx
  350. node.vy += node.fy
  351. // 阻尼
  352. node.vx *= p.damping
  353. node.vy *= p.damping
  354. // 限速
  355. var speed = Math.sqrt(node.vx * node.vx + node.vy * node.vy)
  356. if (speed > p.maxSpeed) {
  357. node.vx = (node.vx / speed) * p.maxSpeed
  358. node.vy = (node.vy / speed) * p.maxSpeed
  359. }
  360. // 最小距离约束
  361. for (var j = 0; j < nodes.length; j++) {
  362. if (i === j) continue
  363. var other = nodes[j]
  364. var dx = other.x - node.x
  365. var dy = other.y - node.y
  366. var dist = Math.sqrt(dx * dx + dy * dy) || 0.1
  367. var minDist = node.radius + other.radius
  368. if (dist < minDist && dist > 0) {
  369. var overlap = (minDist - dist) / 2
  370. var nx = dx / dist
  371. var ny = dy / dist
  372. node.x -= nx * overlap
  373. node.y -= ny * overlap
  374. other.x += nx * overlap
  375. other.y += ny * overlap
  376. }
  377. }
  378. node.x += node.vx
  379. node.y += node.vy
  380. // 累计动能(用于收敛检测)
  381. totalEnergy += node.vx * node.vx + node.vy * node.vy
  382. // 边界约束
  383. var r = node.radius + 4
  384. if (node.x < r) { node.x = r; node.vx *= -0.5 }
  385. if (node.x > this.canvasWidth - r) { node.x = this.canvasWidth - r; node.vx *= -0.5 }
  386. if (node.y < r) { node.y = r; node.vy *= -0.5 }
  387. if (node.y > this.canvasHeight - r) { node.y = this.canvasHeight - r; node.vy *= -0.5 }
  388. }
  389. // 5.5 动能收敛检测:布局基本稳定后自动停止,避免永续占用主线程(模拟器会判定长时间无响应)
  390. if (totalEnergy < this.energyThreshold) {
  391. this.stillFrames++
  392. if (this.stillFrames >= this.stillFrameLimit) {
  393. this.stopSimulation()
  394. return
  395. }
  396. } else {
  397. this.stillFrames = 0
  398. }
  399. // 6. 渲染
  400. this.render()
  401. // 7. 下一帧(小程序无 requestAnimationFrame,用 setTimeout 降级)
  402. this.animFrameId = setTimeout(function() {
  403. self.simulationTick()
  404. }, 16)
  405. },
  406. // ===== 渲染 =====
  407. render: function() {
  408. var self = this
  409. var query = uni.createSelectorQuery().in(this)
  410. query.select('#forceGraphCanvas')
  411. .fields({ node: true, size: true })
  412. .exec(function(res) {
  413. if (!res || !res[0] || !res[0].node) {
  414. self.renderLegacy()
  415. return
  416. }
  417. var canvas = res[0].node
  418. var ctx = canvas.getContext('2d')
  419. canvas.width = self.canvasWidth * self.dpr
  420. canvas.height = self.canvasHeight * self.dpr
  421. ctx.scale(self.dpr, self.dpr)
  422. // 清空
  423. ctx.clearRect(0, 0, self.canvasWidth, self.canvasHeight)
  424. // 画边
  425. self.drawEdges(ctx)
  426. // 画节点
  427. self.drawNodes(ctx)
  428. })
  429. },
  430. drawEdges: function(ctx) {
  431. var edges = this.edges
  432. for (var i = 0; i < edges.length; i++) {
  433. var edge = edges[i]
  434. var s = edge.source
  435. var t = edge.target
  436. var intimacy = edge.closeness // 亲密度 → 连线粗细
  437. var trust = edge.trust // 信任度 → 连线颜色(低红高绿)
  438. var comm = edge.communication // 沟通度 → 线形(越多越实)
  439. var hasData = intimacy !== null || trust !== null || comm !== null
  440. if (!hasData) {
  441. // 无数据: 默认黑色实线 1px
  442. ctx.lineWidth = 1
  443. ctx.setLineDash([])
  444. ctx.strokeStyle = BrandColors.black
  445. } else {
  446. // 连线粗细 = 亲密度 (1-6px, 越高越粗)
  447. var intVal = intimacy != null ? intimacy : 50
  448. ctx.lineWidth = 1 + (intVal / 100) * 5
  449. // 连线颜色 = 信任度 (低=红, 高=绿)
  450. var trustVal = trust != null ? trust : 50
  451. var green = Math.round((trustVal / 100) * 180)
  452. var red = Math.round((1 - trustVal / 100) * 200 + 55)
  453. ctx.strokeStyle = 'rgba(' + red + ',' + green + ', 60, 0.7)'
  454. // 虚线密度 = 沟通度 (越多越实)
  455. var commVal = comm != null ? comm : 50
  456. if (commVal >= 90) {
  457. ctx.setLineDash([])
  458. } else if (commVal >= 70) {
  459. ctx.setLineDash([8, 3])
  460. } else if (commVal >= 50) {
  461. ctx.setLineDash([5, 5])
  462. } else if (commVal >= 30) {
  463. ctx.setLineDash([3, 8])
  464. } else {
  465. ctx.setLineDash([2, 12])
  466. }
  467. }
  468. ctx.beginPath()
  469. ctx.moveTo(s.x, s.y)
  470. ctx.lineTo(t.x, t.y)
  471. ctx.stroke()
  472. }
  473. },
  474. // ===== 身份 → 形状映射 =====
  475. getNodeShapeType: function(node) {
  476. var role = node.effectiveRole || node.relativeLabel || ''
  477. var type = node.memberType || ''
  478. // 长辈 (爷爷/奶奶/外公/外婆)
  479. if (role.indexOf('爷爷') !== -1 || role.indexOf('奶奶') !== -1 ||
  480. role.indexOf('外公') !== -1 || role.indexOf('外婆') !== -1 ||
  481. role.indexOf('祖父') !== -1 || role.indexOf('祖母') !== -1) {
  482. return 'diamond'
  483. }
  484. // 父亲/男性家长
  485. if (role.indexOf('爸爸') !== -1 || role.indexOf('父亲') !== -1 ||
  486. role.indexOf('爸') !== -1 || role.indexOf('爹') !== -1) {
  487. return 'roundedRect'
  488. }
  489. // 母亲/女性家长
  490. if (role.indexOf('妈妈') !== -1 || role.indexOf('母亲') !== -1 ||
  491. role.indexOf('妈') !== -1 || role.indexOf('娘') !== -1) {
  492. return 'circle'
  493. }
  494. // 通用 parent → 根据性别区分
  495. if (type === 'parent') {
  496. return node.gender === 'male' ? 'roundedRect' : 'circle'
  497. }
  498. // child → 圆形
  499. if (type === 'child') {
  500. return 'childCircle'
  501. }
  502. return 'circle'
  503. },
  504. // 绘制圆角矩形
  505. drawRoundedRectNode: function(ctx, x, y, r) {
  506. var w = r * 1.8
  507. var h = r * 1.8
  508. var cr = r * 0.3
  509. ctx.beginPath()
  510. ctx.moveTo(x - w/2 + cr, y - h/2)
  511. ctx.lineTo(x + w/2 - cr, y - h/2)
  512. ctx.arcTo(x + w/2, y - h/2, x + w/2, y - h/2 + cr, cr)
  513. ctx.lineTo(x + w/2, y + h/2 - cr)
  514. ctx.arcTo(x + w/2, y + h/2, x + w/2 - cr, y + h/2, cr)
  515. ctx.lineTo(x - w/2 + cr, y + h/2)
  516. ctx.arcTo(x - w/2, y + h/2, x - w/2, y + h/2 - cr, cr)
  517. ctx.lineTo(x - w/2, y - h/2 + cr)
  518. ctx.arcTo(x - w/2, y - h/2, x - w/2 + cr, y - h/2, cr)
  519. ctx.closePath()
  520. },
  521. // 绘制菱形
  522. drawDiamondNode: function(ctx, x, y, r) {
  523. ctx.beginPath()
  524. ctx.moveTo(x, y - r * 1.2)
  525. ctx.lineTo(x + r * 1.2, y)
  526. ctx.lineTo(x, y + r * 1.2)
  527. ctx.lineTo(x - r * 1.2, y)
  528. ctx.closePath()
  529. },
  530. drawNodes: function(ctx) {
  531. var nodes = this.nodeData
  532. for (var i = 0; i < nodes.length; i++) {
  533. var node = nodes[i]
  534. var r = node.radius
  535. var shapeType = this.getNodeShapeType(node)
  536. // 按身份绘制不同形状
  537. if (shapeType === 'roundedRect') {
  538. this.drawRoundedRectNode(ctx, node.x, node.y, r)
  539. } else if (shapeType === 'diamond') {
  540. this.drawDiamondNode(ctx, node.x, node.y, r)
  541. } else {
  542. // 默认圆形 (母亲/女性家长/孩子)
  543. ctx.beginPath()
  544. ctx.arc(node.x, node.y, r, 0, 2 * Math.PI)
  545. }
  546. // 填充 + 描边
  547. if (node.isSelf) {
  548. ctx.fillStyle = BrandColors.white
  549. ctx.fill()
  550. ctx.strokeStyle = this.themeColor
  551. ctx.lineWidth = 3
  552. ctx.stroke()
  553. } else {
  554. ctx.fillStyle = this.hexToRgba(this.themeColor, 0.13)
  555. ctx.fill()
  556. }
  557. // 第一字符 (昵称首字)
  558. ctx.fillStyle = node.isSelf ? this.themeColor : BrandColors.gray700
  559. ctx.font = 'bold ' + (r * 0.9) + 'px sans-serif'
  560. ctx.textAlign = 'center'
  561. ctx.textBaseline = 'middle'
  562. ctx.fillText(node.displayName, node.x, node.y)
  563. // 昵称
  564. ctx.fillStyle = node.isSelf ? this.themeColor : BrandColors.appTextSubtle
  565. ctx.font = (r * 0.4) + 'px sans-serif'
  566. ctx.fillText(node.nickname.substring(0, 3), node.x, node.y + r + 10)
  567. }
  568. },
  569. // WeChat mini-program compatible fallback renderer.
  570. // Note: uni-app legacy canvas uses setFontSize(size), not setFont().
  571. renderLegacy: function() {
  572. if (this._destroyed) return
  573. try {
  574. var ctx = uni.createCanvasContext('forceGraphCanvas', this)
  575. var self = this
  576. ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
  577. var edges = this.edges
  578. for (var i = 0; i < edges.length; i++) {
  579. var edge = edges[i]
  580. var intimacy = edge.closeness // 亲密度 → 连线粗细
  581. var trust = edge.trust // 信任度 → 连线颜色(低红高绿)
  582. var comm = edge.communication // 沟通度 → 线形(越多越实)
  583. var hasData = intimacy !== null || trust !== null || comm !== null
  584. if (!hasData) {
  585. // 无数据: 默认黑色实线 1px
  586. ctx.setLineWidth(1)
  587. ctx.setLineDash([])
  588. ctx.setStrokeStyle(BrandColors.black)
  589. } else {
  590. // 连线粗细 = 亲密度
  591. var intVal = intimacy != null ? intimacy : 50
  592. ctx.setLineWidth(1 + (intVal / 100) * 5)
  593. // 连线颜色 = 信任度 (低=红, 高=绿)
  594. var trustVal = trust != null ? trust : 50
  595. var green = Math.round((trustVal / 100) * 180)
  596. var red = Math.round((1 - trustVal / 100) * 200 + 55)
  597. ctx.setStrokeStyle('rgba(' + red + ',' + green + ', 60, 0.7)')
  598. // 虚线密度 = 沟通度 (越多越实)
  599. var commVal = comm != null ? comm : 50
  600. if (commVal >= 90) {
  601. ctx.setLineDash([])
  602. } else if (commVal >= 70) {
  603. ctx.setLineDash([8, 3])
  604. } else if (commVal >= 50) {
  605. ctx.setLineDash([5, 5])
  606. } else if (commVal >= 30) {
  607. ctx.setLineDash([3, 8])
  608. } else {
  609. ctx.setLineDash([2, 12])
  610. }
  611. }
  612. ctx.beginPath()
  613. ctx.moveTo(edge.source.x, edge.source.y)
  614. ctx.lineTo(edge.target.x, edge.target.y)
  615. ctx.stroke()
  616. }
  617. var nodes = this.nodeData
  618. for (var j = 0; j < nodes.length; j++) {
  619. var node = nodes[j]
  620. var r = node.radius
  621. var shapeType = this.getNodeShapeType(node)
  622. // legacy API 的形状绘制
  623. if (shapeType === 'roundedRect') {
  624. var w = r * 1.8
  625. var h = r * 1.8
  626. var cr = r * 0.3
  627. ctx.beginPath()
  628. ctx.moveTo(node.x - w/2 + cr, node.y - h/2)
  629. ctx.lineTo(node.x + w/2 - cr, node.y - h/2)
  630. ctx.arcTo(node.x + w/2, node.y - h/2, node.x + w/2, node.y - h/2 + cr, cr)
  631. ctx.lineTo(node.x + w/2, node.y + h/2 - cr)
  632. ctx.arcTo(node.x + w/2, node.y + h/2, node.x + w/2 - cr, node.y + h/2, cr)
  633. ctx.lineTo(node.x - w/2 + cr, node.y + h/2)
  634. ctx.arcTo(node.x - w/2, node.y + h/2, node.x - w/2, node.y + h/2 - cr, cr)
  635. ctx.lineTo(node.x - w/2, node.y - h/2 + cr)
  636. ctx.arcTo(node.x - w/2, node.y - h/2, node.x - w/2 + cr, node.y - h/2, cr)
  637. ctx.closePath()
  638. } else if (shapeType === 'diamond') {
  639. ctx.beginPath()
  640. ctx.moveTo(node.x, node.y - r * 1.2)
  641. ctx.lineTo(node.x + r * 1.2, node.y)
  642. ctx.lineTo(node.x, node.y + r * 1.2)
  643. ctx.lineTo(node.x - r * 1.2, node.y)
  644. ctx.closePath()
  645. } else {
  646. ctx.beginPath()
  647. ctx.arc(node.x, node.y, r, 0, 2 * Math.PI)
  648. }
  649. if (node.isSelf) {
  650. ctx.setFillStyle(BrandColors.white)
  651. ctx.fill()
  652. ctx.setStrokeStyle(this.themeColor)
  653. ctx.setLineWidth(3)
  654. ctx.stroke()
  655. } else {
  656. ctx.setFillStyle(this.hexToRgba(this.themeColor, 0.13))
  657. ctx.fill()
  658. }
  659. ctx.setFillStyle(node.isSelf ? this.themeColor : BrandColors.gray700)
  660. ctx.setFontSize(Math.max(10, Math.round(r * 0.9)))
  661. ctx.setTextAlign('center')
  662. ctx.setTextBaseline('middle')
  663. ctx.fillText(node.displayName, node.x, node.y)
  664. ctx.setFillStyle(node.isSelf ? this.themeColor : BrandColors.appTextSubtle)
  665. ctx.setFontSize(Math.max(9, Math.round(r * 0.4)))
  666. ctx.fillText((node.nickname || '').substring(0, 3), node.x, node.y + r + 10)
  667. }
  668. ctx.draw()
  669. } catch (err) {
  670. // Legacy canvas fallback must never crash page initialization.
  671. }
  672. },
  673. // ===== 交互 =====
  674. getTouchNode: function(touchX, touchY) {
  675. var nodes = this.nodeData
  676. for (var i = 0; i < nodes.length; i++) {
  677. var node = nodes[i]
  678. var dx = touchX - node.x
  679. var dy = touchY - node.y
  680. var dist = Math.sqrt(dx * dx + dy * dy)
  681. if (dist <= node.radius + 8) {
  682. return node
  683. }
  684. }
  685. return null
  686. },
  687. onTouchStart: function(e) {
  688. if (!this.interactive) return
  689. var touch = e.touches ? e.touches[0] : e.detail
  690. if (!touch) return
  691. var self = this
  692. var rect = {}
  693. var query = uni.createSelectorQuery().in(this)
  694. query.select('#forceGraphCanvas').boundingClientRect()
  695. query.exec(function(res) {
  696. if (res && res[0]) {
  697. rect = res[0]
  698. var x = touch.clientX - rect.left
  699. var y = touch.clientY - rect.top
  700. var node = self.getTouchNode(x, y)
  701. if (node) {
  702. if (node.isSelf) {
  703. // 自身节点 → 显示 + 号
  704. self.touchingSelf = true
  705. } else {
  706. // 其他节点 → 拖拽(若模拟已因收敛停止,则唤醒)
  707. if (!self.isSimulating) {
  708. self.startSimulation()
  709. }
  710. self.dragging = {
  711. node: node,
  712. offsetX: x - node.x,
  713. offsetY: y - node.y
  714. }
  715. self.dragStartPos = { x: x, y: y }
  716. node.fx = node.x
  717. node.fy = node.y
  718. node.fixed = true
  719. }
  720. }
  721. }
  722. }.bind(this))
  723. },
  724. onTouchMove: function(e) {
  725. if (!this.dragging) return
  726. e.preventDefault && e.preventDefault()
  727. var touch = e.touches ? e.touches[0] : e.detail
  728. if (!touch) return
  729. var rect = {}
  730. var self = this
  731. var query = uni.createSelectorQuery().in(this)
  732. query.select('#forceGraphCanvas').boundingClientRect()
  733. query.exec(function(res) {
  734. if (res && res[0]) {
  735. rect = res[0]
  736. var x = touch.clientX - rect.left
  737. var y = touch.clientY - rect.top
  738. var node = self.dragging.node
  739. node.x = x - self.dragging.offsetX
  740. node.y = y - self.dragging.offsetY
  741. node.fx = node.x
  742. node.fy = node.y
  743. }
  744. }.bind(this))
  745. },
  746. onTouchEnd: function(e) {
  747. // 如果正在触摸自身节点 → 跳转成员维护页
  748. if (this.touchingSelf) {
  749. this.touchingSelf = false
  750. if (this.interactive) {
  751. this.goSelfMaintenance()
  752. }
  753. this.dragging = null
  754. this.dragStartPos = null
  755. return
  756. }
  757. if (!this.dragging) return
  758. var wasDragged = this.dragStartPos && (
  759. Math.abs(this.dragging.node.x - this.dragStartPos.x) > 5 ||
  760. Math.abs(this.dragging.node.y - this.dragStartPos.y) > 5
  761. )
  762. var node = this.dragging.node
  763. node.fixed = false
  764. node.fx = 0
  765. node.fy = 0
  766. // 如果没有真正拖拽(点击),触发 memberTap
  767. if (!wasDragged && this.interactive) {
  768. this.$emit('memberTap', {
  769. memberId: node.id,
  770. memberType: node.memberType,
  771. nickname: node.nickname
  772. })
  773. }
  774. this.dragging = null
  775. this.dragStartPos = null
  776. },
  777. // 跳转到成员维护页面 (点自己进入维护)
  778. goSelfMaintenance: function() {
  779. uni.navigateTo({
  780. url: '/pages/profile-extra/family-members'
  781. })
  782. },
  783. // 点击右上角小齿轮 → 跳转成员管理页
  784. onManageTap: function() {
  785. uni.navigateTo({
  786. url: '/pages/profile-extra/family-members'
  787. })
  788. },
  789. // ===== 工具方法 =====
  790. getEnergy: function(memberId) {
  791. var data = this.energyMap[memberId]
  792. if (!data) return 0
  793. var key = this.dimensionCode + 'Score'
  794. var val = data[key]
  795. return (typeof val !== 'undefined' && val !== null) ? val : 0
  796. },
  797. getIntimacy: function(memberId, key) {
  798. var data = this.intimacyMap[memberId]
  799. if (!data) return null
  800. var val = data[key]
  801. return (typeof val !== 'undefined' && val !== null) ? val : null
  802. },
  803. updateCanvasSize: function() {
  804. var self = this
  805. var cnt = this.members ? this.members.length : 0
  806. self.canvasHeight = Math.max(200, cnt * 90)
  807. var query = uni.createSelectorQuery().in(this)
  808. query.select('.family-graph-wrapper').boundingClientRect(function(rect) {
  809. if (rect) {
  810. self.canvasWidth = rect.width || 340
  811. }
  812. }).exec()
  813. }
  814. }
  815. }
  816. </script>
  817. <style scoped lang="scss">
  818. .family-graph-wrapper {
  819. position: relative;
  820. width: 100%;
  821. min-height: 200px;
  822. height: auto;
  823. margin: 10rpx 0;
  824. background: $app-surface;
  825. border-radius: 24rpx;
  826. overflow: hidden;
  827. }
  828. .family-graph-canvas {
  829. position: absolute;
  830. top: 0;
  831. left: 0;
  832. z-index: 1;
  833. }
  834. /* 右上角小齿轮 */
  835. .gear-icon {
  836. position: absolute;
  837. top: 12rpx;
  838. right: 12rpx;
  839. z-index: 10;
  840. width: 56rpx;
  841. height: 56rpx;
  842. display: flex;
  843. align-items: center;
  844. justify-content: center;
  845. background: rgba(255, 255, 255, 0.9);
  846. border-radius: 50%;
  847. box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
  848. }
  849. .gear-text {
  850. font-size: 32rpx;
  851. color: $app-text-subtle;
  852. }
  853. /* ⚙ 维护页提示 */
  854. .add-hint {
  855. position: absolute;
  856. z-index: 5;
  857. width: 40rpx;
  858. height: 40rpx;
  859. border-radius: 50%;
  860. background: $status-success;
  861. display: flex;
  862. align-items: center;
  863. justify-content: center;
  864. opacity: 0;
  865. transform: scale(0.5);
  866. transition: opacity 0.15s, transform 0.15s;
  867. pointer-events: none;
  868. }
  869. .add-hint.add-hint-visible {
  870. opacity: 1;
  871. transform: scale(1);
  872. }
  873. .add-hint-text {
  874. font-size: 28rpx;
  875. color: $app-surface;
  876. font-weight: bold;
  877. line-height: 1;
  878. }
  879. .family-graph-empty {
  880. display: flex;
  881. align-items: center;
  882. justify-content: center;
  883. padding: 60rpx 0;
  884. background: $app-surface;
  885. border-radius: 24rpx;
  886. margin: 10rpx 0;
  887. }
  888. .empty-text {
  889. font-size: 28rpx;
  890. color: $app-text-subtle;
  891. }
  892. </style>