FamilyRelationGraph.vue 28 KB

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