Просмотр исходного кода

refactor: 重构FamilyRelationGraph为全Canvas force-directed关系图

物理引擎: 节点斥力+弹簧引力+中心引力+阻尼;全Canvas渲染节点和连线;支持节点拖拽和点击检测

Ultraworked with Sisyphus

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
User 3 месяцев назад
Родитель
Сommit
98b540be5a
1 измененных файлов с 469 добавлено и 401 удалено
  1. 469 401
      cfc-frontend/components/FamilyRelationGraph.vue

+ 469 - 401
cfc-frontend/components/FamilyRelationGraph.vue

@@ -1,103 +1,35 @@
 <template>
-  <!-- 家庭关系图谱 -->
-  <view class="family-relation-graph" v-if="members && members.length >= 1">
-    <!-- Canvas 连线层 -->
+  <!-- 全 Canvas 关系图谱 - Force-directed 物理布局 -->
+  <view class="family-graph-wrapper" v-if="simNodes && simNodes.length >= 1">
     <canvas
-      class="graph-canvas"
-      canvas-id="relationGraphCanvas"
-      id="relationGraphCanvas"
+      class="family-graph-canvas"
+      canvas-id="forceGraphCanvas"
+      id="forceGraphCanvas"
       type="2d"
-      :style="{
-        width: canvasWidth + 'px',
-        height: canvasHeight + 'px'
-      }" />
-    <!-- View 节点层 -->
-    <view class="graph-nodes">
-      <!-- 2-3人: 单行布局 -->
-      <template v-if="layoutMode === 'row'">
-        <view class="row-layout">
-          <view
-            v-for="m in displayOrder"
-            :key="m.id"
-            class="member-node"
-            :class="{ 'is-self': m.isSelf, 'is-parent': m.memberType === 'parent' }"
-            :style="{ width: m.avatarSize + 'rpx' }"
-            :data-member-id="m.id"
-            :data-member-type="m.memberType"
-            :data-member-nickname="m.nickname"
-            @tap="onMemberTap">
-            <view
-              class="member-avatar"
-              :class="{ 'avatar-square': m.memberType === 'parent' }"
-              :style="{
-                width: m.avatarSize + 'rpx',
-                height: m.avatarSize + 'rpx',
-                backgroundColor: m.isSelf ? '#FFFFFF' : themeColorRgba,
-                borderColor: m.isSelf ? themeColor : 'transparent'
-              }">
-              <text class="avatar-letter" :style="{ color: m.isSelf ? themeColor : '#4A5568', fontSize: m.avatarSize * 0.45 + 'rpx' }">{{ m.displayName }}</text>
-            </view>
-            <text class="member-name" :style="{ color: m.isSelf ? themeColor : '#94A3B8', fontWeight: m.isSelf ? 'bold' : 'normal' }">{{ m.nickname }}</text>
-          </view>
-        </view>
-      </template>
-      <!-- 4+人: 多行布局 -->
-      <template v-else>
-        <view class="self-row">
-          <view
-            class="member-node is-self"
-            :style="{ width: selfMember.avatarSize + 'rpx' }"
-            :data-member-id="selfMember.id"
-            :data-member-type="selfMember.memberType"
-            :data-member-nickname="selfMember.nickname"
-            @tap="onMemberTap">
-            <view
-              class="member-avatar"
-              :style="{
-                width: selfMember.avatarSize + 'rpx',
-                height: selfMember.avatarSize + 'rpx',
-                backgroundColor: '#FFFFFF',
-                borderColor: themeColor
-              }">
-              <text class="avatar-letter" :style="{ color: themeColor, fontSize: selfMember.avatarSize * 0.45 + 'rpx' }">{{ selfMember.displayName }}</text>
-            </view>
-            <text class="member-name" :style="{ color: themeColor, fontWeight: 'bold' }">{{ selfMember.nickname }}</text>
-          </view>
-        </view>
-        <view class="others-row">
-          <view
-            v-for="m in otherMembers"
-            :key="m.id"
-            class="member-node"
-            :class="{ 'is-parent': m.memberType === 'parent' }"
-            :style="{ width: m.avatarSize + 'rpx' }"
-            :data-member-id="m.id"
-            :data-member-type="m.memberType"
-            :data-member-nickname="m.nickname"
-            @tap="onMemberTap">
-            <view
-              class="member-avatar"
-              :class="{ 'avatar-square': m.memberType === 'parent' }"
-              :style="{
-                width: m.avatarSize + 'rpx',
-                height: m.avatarSize + 'rpx',
-                backgroundColor: themeColorRgba
-              }">
-              <text class="avatar-letter" :style="{ color: '#4A5568', fontSize: m.avatarSize * 0.45 + 'rpx' }">{{ m.displayName }}</text>
-            </view>
-            <text class="member-name" :style="{ color: '#94A3B8' }">{{ m.nickname }}</text>
-          </view>
-        </view>
-      </template>
-    </view>
+      :style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
+      @touchstart="onTouchStart"
+      @touchmove="onTouchMove"
+      @touchend="onTouchEnd" />
   </view>
-  <!-- 0人空状态 -->
-  <view class="family-relation-graph graph-empty" v-else-if="members && members.length === 0">
+  <!-- 空状态 -->
+  <view class="family-graph-empty" v-else-if="members && members.length === 0">
     <text class="empty-text">暂无家庭成员</text>
   </view>
 </template>
 
 <script>
+/**
+ * FamilyRelationGraph - 全 Canvas force-directed 家庭关系图
+ *
+ * 物理引擎: 手动实现的 force-directed 布局
+ * - 节点间斥力 (repulsion)
+ * - 边弹簧引力 (spring attraction)
+ * - 中心引力 (centering)
+ * - 阻尼 (damping)
+ *
+ * 渲染: 全 Canvas 绘制 (节点 + 连线)
+ * 交互: 节点拖拽 + 点击检测
+ */
 export default {
   name: 'FamilyRelationGraph',
   props: {
@@ -135,9 +67,33 @@ export default {
   },
   data: function() {
     return {
-      canvasWidth: 300,
-      canvasHeight: 300,
+      canvasWidth: 340,
+      canvasHeight: 280,
       dpr: 1,
+
+      // 物理模拟节点
+      simNodes: [],
+
+      // 物理参数
+      physics: {
+        repulsion: 800,
+        springLength: 120,
+        springStrength: 0.04,
+        centering: 0.03,
+        damping: 0.88,
+        maxSpeed: 12,
+        minDistance: 30
+      },
+
+      // 拖拽状态
+      dragging: null,
+      dragStartPos: null,
+
+      // 动画
+      animFrameId: null,
+      isSimulating: false,
+
+      // 主题色
       themeColors: {
         body: '#8D6E63',
         mind: '#FF6B35',
@@ -151,373 +107,485 @@ export default {
     themeColor: function() {
       return this.themeColors[this.dimensionCode] || '#8D6E63'
     },
-    themeColorRgba: function() {
-      return this.themeColor + '26'
-    },
-    layoutMode: function() {
-      if (!this.members || this.members.length <= 3) return 'row'
-      return 'multi-row'
-    },
-    selfMember: function() {
+
+    // 将 members 转换为 simNodes
+    nodeData: function() {
       var self = this
-      var found = null
-      for (var i = 0; i < this.members.length; i++) {
-        var mid = this.members[i].memberId || this.members[i].id
-        if (mid != null && mid == this.selfId) {
-          found = this.members[i]
-          break
+      if (!this.members || this.members.length === 0) return []
+
+      var centerX = this.canvasWidth / 2
+      var centerY = this.canvasHeight / 2
+
+      return this.members.map(function(m, idx) {
+        var fid = m.memberId || m.id
+        var energy = self.getEnergy(fid)
+        var radius = 24 + (Math.max(0, Math.min(100, energy)) / 100) * 16
+
+        var nick = m.nickname || m.name || '成员'
+        if (nick === '用户') nick = '成员'
+
+        var isSelf = (fid == self.selfId)
+        var displayName = nick.charAt(0)
+
+        // 圆形布局初始位置
+        var angle = (2 * Math.PI * idx) / Math.max(1, self.members.length)
+        var r = Math.min(self.canvasWidth, self.canvasHeight) * 0.32
+        var initX = centerX + r * Math.cos(angle)
+        var initY = centerY + r * Math.sin(angle)
+
+        return {
+          id: fid,
+          nickname: nick,
+          displayName: displayName,
+          isSelf: isSelf,
+          memberType: m.memberType || 'child',
+          radius: radius,
+          x: initX,
+          y: initY,
+          vx: 0,
+          vy: 0,
+          fx: 0,
+          fy: 0
         }
-      }
-      if (!found && this.members.length > 0) {
-        found = this.members[0]
-      }
-      if (!found) return { id: null, nickname: '我', memberType: 'child', isSelf: true, displayName: '我', avatarSize: 120 }
-      var fid = found.memberId || found.id
-      var energy = this.getEnergy(fid)
-      var size = Math.max(96, 80 + (Math.max(0, Math.min(100, energy)) / 100) * 56)
-      var nick = found.nickname || found.name || '我'
-      if (nick === '用户') nick = '我'
-      return {
-        id: fid,
-        nickname: nick,
-        memberType: found.memberType || 'child',
-        isSelf: true,
-        displayName: (found.nickname && found.nickname.charAt(0)) || (found.name && found.name.charAt(0)) || '我',
-        avatarSize: size
-      }
+      })
     },
-    otherMembers: function() {
+
+    // 构建边 (self 连接到其他所有成员)
+    edges: function() {
       var self = this
-      return this.members
-        .filter(function(m) {
-          var mid = m.memberId || m.id
-          return mid != self.selfId
-        })
-        .map(function(m) {
-          var fid = m.memberId || m.id
-          var energy = self.getEnergy(fid)
-          var size = 64 + (Math.max(0, Math.min(100, energy)) / 100) * 56
-          var nick = m.nickname || m.name || '成员'
-          if (nick === '用户') nick = '成员'
-          return {
-            id: fid,
-            nickname: nick,
-            memberType: m.memberType || 'child',
-            isSelf: false,
-            displayName: (m.nickname && m.nickname.charAt(0)) || (m.name && m.name.charAt(0)) || '?',
-            avatarSize: size
-          }
-        })
-    },
-    displayOrder: function() {
-      if (this.layoutMode !== 'row') return []
-      var others = this.otherMembers
-      if (this.members.length === 2) {
-        return [this.selfMember].concat(others)
-      }
-      if (this.members.length === 3 && others.length >= 2) {
-        return [others[0], this.selfMember, others[1]]
-      }
-      return [this.selfMember].concat(others)
+      var selfNode = this.nodeData.find(function(n) { return n.isSelf })
+      if (!selfNode) return []
+
+      var result = []
+      this.nodeData.forEach(function(n) {
+        if (n.id !== selfNode.id) {
+          var pairKey = selfNode.id < n.id
+            ? selfNode.id + '-' + n.id
+            : n.id + '-' + selfNode.id
+          var comp = self.compatibilityMap[pairKey]
+          var trust = self.getIntimacy(n.id, 'trust')
+          var comm = self.getIntimacy(n.id, 'communication')
+          var closeness = self.getIntimacy(n.id, 'closeness')
+
+          result.push({
+            source: selfNode,
+            target: n,
+            trust: trust,
+            communication: comm,
+            closeness: closeness,
+            compatibility: comp && comp.overallScore !== undefined ? comp.overallScore : null
+          })
+        }
+      })
+      return result
     }
   },
   watch: {
-    members: function() { this.scheduleDraw() },
-    energyMap: function() { this.scheduleDraw() },
-    intimacyMap: function() { this.scheduleDraw() }
+    members: function() {
+      this.resetSimulation()
+    },
+    canvasWidth: function() {
+      this.resetSimulation()
+    },
+    canvasHeight: function() {
+      this.resetSimulation()
+    }
   },
   mounted: function() {
+    var self = this
     this.dpr = uni.getSystemInfoSync().pixelRatio || 1
-    this.scheduleDraw()
+
+    this.$nextTick(function() {
+      self.updateCanvasSize()
+      self.$nextTick(function() {
+        self.initSimulation()
+      })
+    })
+  },
+  beforeDestroy: function() {
+    this.stopSimulation()
   },
   methods: {
-    // 获取能量值
-    getEnergy: function(memberId) {
-      var data = this.energyMap[memberId]
-      if (!data) return 0
-      var key = this.dimensionCode + 'Score'
-      var val = data[key]
-      return (typeof val !== 'undefined' && val !== null) ? val : 0
+    // ===== 物理引擎 =====
+
+    resetSimulation: function() {
+      this.stopSimulation()
+      var centerX = this.canvasWidth / 2
+      var centerY = this.canvasHeight / 2
+      var self = this
+      this.nodeData.forEach(function(n, idx) {
+        var angle = (2 * Math.PI * idx) / Math.max(1, self.nodeData.length)
+        var r = Math.min(self.canvasWidth, self.canvasHeight) * 0.32
+        n.x = centerX + r * Math.cos(angle)
+        n.y = centerY + r * Math.sin(angle)
+        n.vx = 0
+        n.vy = 0
+        n.fx = 0
+        n.fy = 0
+      })
+      this.startSimulation()
     },
 
-    // 获取亲密度数据
-    getIntimacy: function(memberId, key) {
-      var data = this.intimacyMap[memberId]
-      if (!data) {
-        var defaults = { closeness: 75, communication: 50, trust: 85 }
-        return defaults[key]
+    initSimulation: function() {
+      if (!this.nodeData || this.nodeData.length === 0) return
+      // 复制到 simNodes
+      this.simNodes = this.nodeData.slice()
+      this.startSimulation()
+    },
+
+    startSimulation: function() {
+      if (this.isSimulating) return
+      this.isSimulating = true
+      this.simulationTick()
+    },
+
+    stopSimulation: function() {
+      this.isSimulating = false
+      if (this.animFrameId) {
+        cancelAnimationFrame(this.animFrameId)
+        this.animFrameId = null
       }
-      var val = data[key]
-      return (typeof val !== 'undefined' && val !== null) ? val : 75
     },
 
-    // 触发重绘:等 View 布局完成 → 读坐标 → 在 Canvas 画线
-    scheduleDraw: function() {
+    simulationTick: function() {
+      if (!this.isSimulating) return
+
       var self = this
-      this.$nextTick(function() {
-        self.updateCanvasSize()
-        self.$nextTick(function() {
-          self.drawLines()
-        })
+      var nodes = this.simNodes
+      var p = this.physics
+
+      // 1. 清零力
+      for (var i = 0; i < nodes.length; i++) {
+        nodes[i].fx = 0
+        nodes[i].fy = 0
+      }
+
+      // 2. 斥力
+      for (var a = 0; a < nodes.length; a++) {
+        for (var b = a + 1; b < nodes.length; b++) {
+          var dx = nodes[b].x - nodes[a].x
+          var dy = nodes[b].y - nodes[a].y
+          var distSq = dx * dx + dy * dy
+          var dist = Math.sqrt(distSq) || 0.1
+          var force = p.repulsion / distSq
+          var fx = (dx / dist) * force
+          var fy = (dy / dist) * force
+          nodes[a].fx -= fx
+          nodes[a].fy -= fy
+          nodes[b].fx += fx
+          nodes[b].fy += fy
+        }
+      }
+
+      // 3. 弹簧引力
+      for (var e = 0; e < this.edges.length; e++) {
+        var edge = this.edges[e]
+        var s = edge.source
+        var t = edge.target
+        var dx = t.x - s.x
+        var dy = t.y - s.y
+        var dist = Math.sqrt(dx * dx + dy * dy) || 0.1
+        var displacement = dist - p.springLength
+        var force = p.springStrength * displacement
+        var fx = (dx / dist) * force
+        var fy = (dy / dist) * force
+        s.fx += fx
+        s.fy += fy
+        t.fx -= fx
+        t.fy -= fy
+      }
+
+      // 4. 中心引力
+      var cx = this.canvasWidth / 2
+      var cy = this.canvasHeight / 2
+      for (var n = 0; n < nodes.length; n++) {
+        nodes[n].fx += (cx - nodes[n].x) * p.centering
+        nodes[n].fy += (cy - nodes[n].y) * p.centering
+      }
+
+      // 5. 更新速度 + 位置
+      for (var i = 0; i < nodes.length; i++) {
+        var node = nodes[i]
+        node.vx += node.fx
+        node.vy += node.fy
+        node.vx *= p.damping
+        node.vy *= p.damping
+        var speed = Math.sqrt(node.vx * node.vx + node.vy * node.vy)
+        if (speed > p.maxSpeed) {
+          node.vx = (node.vx / speed) * p.maxSpeed
+          node.vy = (node.vy / speed) * p.maxSpeed
+        }
+        node.x += node.vx
+        node.y += node.vy
+        // 边界
+        var r = node.radius + 4
+        if (node.x < r) { node.x = r; node.vx *= -0.5 }
+        if (node.x > this.canvasWidth - r) { node.x = this.canvasWidth - r; node.vx *= -0.5 }
+        if (node.y < r) { node.y = r; node.vy *= -0.5 }
+        if (node.y > this.canvasHeight - r) { node.y = this.canvasHeight - r; node.vy *= -0.5 }
+      }
+
+      // 6. 渲染
+      this.render()
+
+      // 7. 下一帧
+      this.animFrameId = requestAnimationFrame(function() {
+        self.simulationTick()
       })
     },
 
-    // 读取 View 节点层尺寸,设置 Canvas 覆盖尺寸
-    updateCanvasSize: function() {
+    // ===== 渲染 =====
+
+    render: function() {
       var self = this
       var query = uni.createSelectorQuery().in(this)
-      query.select('.graph-nodes').boundingClientRect(function(rect) {
-        if (!rect) return
-        self.canvasWidth = rect.width
-        self.canvasHeight = rect.height
-      }).exec()
+      query.select('#forceGraphCanvas')
+        .fields({ node: true, size: true })
+        .exec(function(res) {
+          if (!res || !res[0] || !res[0].node) {
+            self.renderLegacy()
+            return
+          }
+          var canvas = res[0].node
+          var ctx = canvas.getContext('2d')
+          canvas.width = self.canvasWidth * self.dpr
+          canvas.height = self.canvasHeight * self.dpr
+          ctx.scale(self.dpr, self.dpr)
+          ctx.clearRect(0, 0, self.canvasWidth, self.canvasHeight)
+          self.drawEdges(ctx)
+          self.drawNodes(ctx)
+        })
+    },
+
+    drawEdges: function(ctx) {
+      var edges = this.edges
+      for (var i = 0; i < edges.length; i++) {
+        var edge = edges[i]
+        var s = edge.source
+        var t = edge.target
+        var trust = edge.trust || 75
+        var comm = edge.communication || 50
+        var hue = 120 * trust / 100
+        ctx.strokeStyle = 'hsla(' + hue + ', 75%, 45%, 0.6)'
+        ctx.lineWidth = 1 + (comm / 100) * 4
+        ctx.beginPath()
+        ctx.moveTo(s.x, s.y)
+        ctx.lineTo(t.x, t.y)
+        ctx.stroke()
+      }
     },
 
-    // Canvas 连线绘制(type=2d 优先,降级 CanvasContext)
-    drawLines: function() {
+    drawNodes: function(ctx) {
+      var nodes = this.simNodes
+      for (var i = 0; i < nodes.length; i++) {
+        var node = nodes[i]
+        var r = node.radius
+        ctx.beginPath()
+        ctx.arc(node.x, node.y, r, 0, 2 * Math.PI)
+        if (node.isSelf) {
+          ctx.fillStyle = '#FFFFFF'
+          ctx.fill()
+          ctx.strokeStyle = this.themeColor
+          ctx.lineWidth = 3
+          ctx.stroke()
+        } else {
+          ctx.fillStyle = this.themeColor + '22'
+          ctx.fill()
+        }
+        ctx.fillStyle = node.isSelf ? this.themeColor : '#4A5568'
+        ctx.font = 'bold ' + (r * 0.9) + 'px sans-serif'
+        ctx.textAlign = 'center'
+        ctx.textBaseline = 'middle'
+        ctx.fillText(node.displayName, node.x, node.y)
+        ctx.fillStyle = node.isSelf ? this.themeColor : '#94A3B8'
+        ctx.font = (r * 0.4) + 'px sans-serif'
+        ctx.fillText(node.nickname.substring(0, 3), node.x, node.y + r + 10)
+      }
+    },
+
+    renderLegacy: function() {
+      var ctx = uni.createCanvasContext('forceGraphCanvas', this)
       var self = this
-      if (!this.members || this.members.length < 2) return
+      ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
+      var edges = this.edges
+      for (var i = 0; i < edges.length; i++) {
+        var edge = edges[i]
+        var trust = edge.trust || 75
+        var comm = edge.communication || 50
+        var hue = 120 * trust / 100
+        ctx.setStrokeStyle('hsla(' + hue + ', 75%, 45%, 0.6)')
+        ctx.setLineWidth(1 + (comm / 100) * 4)
+        ctx.beginPath()
+        ctx.moveTo(edge.source.x, edge.source.y)
+        ctx.lineTo(edge.target.x, edge.target.y)
+        ctx.stroke()
+      }
+      var nodes = this.simNodes
+      for (var j = 0; j < nodes.length; j++) {
+        var node = nodes[j]
+        var r = node.radius
+        ctx.beginPath()
+        ctx.arc(node.x, node.y, r, 0, 2 * Math.PI)
+        if (node.isSelf) {
+          ctx.setFillStyle('#FFFFFF')
+          ctx.fill()
+          ctx.setStrokeStyle(this.themeColor)
+          ctx.setLineWidth(3)
+          ctx.stroke()
+        } else {
+          ctx.setFillStyle(this.themeColor + '22')
+          ctx.fill()
+        }
+        ctx.setFillStyle(node.isSelf ? this.themeColor : '#4A5568')
+        ctx.setFont('bold ' + (r * 0.9) + 'px sans-serif')
+        ctx.setTextAlign('center')
+        ctx.setTextBaseline('middle')
+        ctx.fillText(node.displayName, node.x, node.y)
+        ctx.setFillStyle(node.isSelf ? this.themeColor : '#94A3B8')
+        ctx.setFont((r * 0.4) + 'px sans-serif')
+        ctx.fillText(node.nickname.substring(0, 3), node.x, node.y + r + 10)
+      }
+      ctx.draw()
+    },
+
+    // ===== 交互 =====
 
+    getTouchNode: function(touchX, touchY) {
+      var nodes = this.simNodes
+      for (var i = 0; i < nodes.length; i++) {
+        var node = nodes[i]
+        var dx = touchX - node.x
+        var dy = touchY - node.y
+        var dist = Math.sqrt(dx * dx + dy * dy)
+        if (dist <= node.radius + 8) {
+          return node
+        }
+      }
+      return null
+    },
+
+    onTouchStart: function(e) {
+      if (!this.interactive) return
+      var touch = e.touches ? e.touches[0] : e.detail
+      if (!touch) return
+      var self = this
+      var rect = {}
       var query = uni.createSelectorQuery().in(this)
-      // 查 self 头像、other 头像、容器 三个 rect
-      query.select('.is-self .member-avatar').boundingClientRect()
-      query.selectAll('.member-node:not(.is-self) .member-avatar').boundingClientRect()
-      query.select('.graph-nodes').boundingClientRect()
+      query.select('#forceGraphCanvas').boundingClientRect()
       query.exec(function(res) {
-        if (!res || !res[0]) return
-
-        var selfAvatarRect = res[0]
-        var otherAvatarRects = res[1] || []
-        var containerRect = res[2]
-        if (!selfAvatarRect || otherAvatarRects.length === 0) return
-
-        var containerLeft = containerRect ? containerRect.left : 0
-        var containerTop = containerRect ? containerRect.top : 0
-        var selfCX = selfAvatarRect.left + selfAvatarRect.width / 2 - containerLeft
-        var selfCY = selfAvatarRect.top + selfAvatarRect.height / 2 - containerTop
-        var selfR = selfAvatarRect.width / 2
-
-        // Canvas type=2d
-        var canvasQuery = uni.createSelectorQuery().in(self)
-        canvasQuery.select('#relationGraphCanvas')
-          .fields({ node: true, size: true })
-          .exec(function(canvasRes) {
-            if (!canvasRes || !canvasRes[0] || !canvasRes[0].node) {
-              self.drawLinesLegacy(selfCX, selfCY, selfR, otherAvatarRects, containerLeft, containerTop)
-              return
-            }
-            var canvas = canvasRes[0].node
-            var ctx = canvas.getContext('2d')
-
-            canvas.width = self.canvasWidth * self.dpr
-            canvas.height = self.canvasHeight * self.dpr
-            ctx.scale(self.dpr, self.dpr)
-            ctx.clearRect(0, 0, self.canvasWidth, self.canvasHeight)
-
-            for (var j = 0; j < otherAvatarRects.length; j++) {
-              var rect = otherAvatarRects[j]
-              var otherCX = rect.left + rect.width / 2 - containerLeft
-              var otherCY = rect.top + rect.height / 2 - containerTop
-              var otherR = rect.width / 2
-
-              var otherMember = self.otherMembers[j]
-              if (!otherMember) continue
-
-              var selfId = self.selfMember && self.selfMember.id
-              var pairKey = ''
-              if (selfId) {
-                pairKey = selfId < otherMember.id ? selfId + '-' + otherMember.id : otherMember.id + '-' + selfId
-              }
-              var comp = self.compatibilityMap[pairKey]
-
-              if (comp && comp.overallScore !== undefined) {
-                // 先天兼容度 → 线颜色 (红0→绿120, 紫90)
-                var cScore = Math.max(0, Math.min(100, comp.overallScore))
-                ctx.strokeStyle = 'hsl(' + (120 * cScore / 100) + ', 70%, 50%)'
-                ctx.globalAlpha = 0.55
-                ctx.lineWidth = 3 + (cScore / 100) * 6
-              } else {
-                // 信任度 → 线颜色 (红0°→绿120°)
-                var trust = Math.max(0, Math.min(100, self.getIntimacy(otherMember.id, 'trust')))
-                var hue = (120 * trust / 100)
-                ctx.strokeStyle = 'hsl(' + hue + ', 75%, 45%)'
-                ctx.globalAlpha = 0.6
-
-                // 沟通度 → 线粗细 (2~10px)
-                var comm = Math.max(0, Math.min(100, self.getIntimacy(otherMember.id, 'communication')))
-                ctx.lineWidth = 2 + (comm / 100) * 8
-              }
-
-              // 边缘起止点:沿中心向量缩进半径
-              var dx = otherCX - selfCX
-              var dy = otherCY - selfCY
-              var dist = Math.sqrt(dx * dx + dy * dy)
-              if (dist < 1) continue
-              var ux = dx / dist
-              var uy = dy / dist
-
-              ctx.beginPath()
-              ctx.moveTo(selfCX + ux * selfR, selfCY + uy * selfR)
-              ctx.lineTo(otherCX - ux * otherR, otherCY - uy * otherR)
-              ctx.stroke()
-            }
-            ctx.globalAlpha = 1.0
-          })
+        if (res && res[0]) {
+          rect = res[0]
+          var x = touch.clientX - rect.left
+          var y = touch.clientY - rect.top
+          var node = self.getTouchNode(x, y)
+          if (node) {
+            self.dragging = { node: node, offsetX: x - node.x, offsetY: y - node.y }
+            self.dragStartPos = { x: x, y: y }
+          }
+        }
       })
     },
 
-    // 降级连线(CanvasContext)
-    drawLinesLegacy: function(selfCX, selfCY, selfR, otherRects, containerLeft, containerTop) {
-      var ctx = uni.createCanvasContext('relationGraphCanvas', this)
+    onTouchMove: function(e) {
+      if (!this.dragging) return
+      e.preventDefault && e.preventDefault()
+      var touch = e.touches ? e.touches[0] : e.detail
+      if (!touch) return
       var self = this
-
-      for (var j = 0; j < otherRects.length; j++) {
-        var rect = otherRects[j]
-        var otherCX = rect.left + rect.width / 2 - containerLeft
-        var otherCY = rect.top + rect.height / 2 - containerTop
-        var otherR = rect.width / 2
-
-        var otherMember = self.otherMembers[j]
-        if (!otherMember) continue
-
-        var selfId = self.selfMember && self.selfMember.id
-        var pairKey = ''
-        if (selfId) {
-          pairKey = selfId < otherMember.id ? selfId + '-' + otherMember.id : otherMember.id + '-' + selfId
+      var rect = {}
+      var query = uni.createSelectorQuery().in(this)
+      query.select('#forceGraphCanvas').boundingClientRect()
+      query.exec(function(res) {
+        if (res && res[0]) {
+          rect = res[0]
+          var x = touch.clientX - rect.left
+          var y = touch.clientY - rect.top
+          var node = self.dragging.node
+          node.x = x - self.dragging.offsetX
+          node.y = y - self.dragging.offsetY
         }
-        var comp = self.compatibilityMap[pairKey]
+      })
+    },
 
-        if (comp && comp.overallScore !== undefined) {
-          var cScore = Math.max(0, Math.min(100, comp.overallScore))
-          ctx.setStrokeStyle('hsl(' + (120 * cScore / 100) + ', 70%, 50%)')
-          ctx.globalAlpha = 0.55
-          ctx.setLineWidth(3 + (cScore / 100) * 6)
-        } else {
-          var trust = Math.max(0, Math.min(100, self.getIntimacy(otherMember.id, 'trust')))
-          var hue = (120 * trust / 100)
-          ctx.setStrokeStyle('hsl(' + hue + ', 75%, 45%)')
-          ctx.globalAlpha = 0.6
+    onTouchEnd: function(e) {
+      if (!this.dragging) return
+      var wasDragged = this.dragStartPos && (
+        Math.abs(this.dragging.node.x - this.dragStartPos.x) > 5 ||
+        Math.abs(this.dragging.node.y - this.dragStartPos.y) > 5
+      )
+      var node = this.dragging.node
+      if (!wasDragged && this.interactive) {
+        this.$emit('memberTap', {
+          memberId: node.id,
+          memberType: node.memberType,
+          nickname: node.nickname
+        })
+      }
+      this.dragging = null
+      this.dragStartPos = null
+    },
 
-          var comm = Math.max(0, Math.min(100, self.getIntimacy(otherMember.id, 'communication')))
-          ctx.setLineWidth(2 + (comm / 100) * 8)
-        }
+    // ===== 工具方法 =====
 
-        var dx = otherCX - selfCX
-        var dy = otherCY - selfCY
-        var dist = Math.sqrt(dx * dx + dy * dy)
-        if (dist < 1) continue
-        var ux = dx / dist
-        var uy = dy / dist
+    getEnergy: function(memberId) {
+      var data = this.energyMap[memberId]
+      if (!data) return 0
+      var key = this.dimensionCode + 'Score'
+      var val = data[key]
+      return (typeof val !== 'undefined' && val !== null) ? val : 0
+    },
 
-        ctx.beginPath()
-        ctx.moveTo(selfCX + ux * selfR, selfCY + uy * selfR)
-        ctx.lineTo(otherCX - ux * otherR, otherCY - uy * otherR)
-        ctx.stroke()
+    getIntimacy: function(memberId, key) {
+      var data = this.intimacyMap[memberId]
+      if (!data) {
+        var defaults = { closeness: 75, communication: 50, trust: 85 }
+        return defaults[key]
       }
-      ctx.draw()
+      var val = data[key]
+      return (typeof val !== 'undefined' && val !== null) ? val : 75
     },
 
-    // View 节点点击事件
-    onMemberTap: function(e) {
-      if (!this.interactive) return
-      var target = e.currentTarget
-      if (!target || !target.dataset) return
-      var ds = target.dataset
-      this.$emit('memberTap', {
-        memberId: ds.memberId,
-        memberType: ds.memberType,
-        nickname: ds.memberNickname
-      })
+    updateCanvasSize: function() {
+      var self = this
+      var query = uni.createSelectorQuery().in(this)
+      query.select('.family-graph-wrapper').boundingClientRect(function(rect) {
+        if (rect) {
+          self.canvasWidth = rect.width || 340
+          self.canvasHeight = rect.height || 280
+        }
+      }).exec()
     }
   }
 }
 </script>
 
 <style scoped>
-.family-relation-graph {
+.family-graph-wrapper {
   position: relative;
-  padding: 24rpx 30rpx;
+  width: 100%;
+  height: 280px;
   margin: 10rpx 0;
   background: #FFFFFF;
   border-radius: 24rpx;
+  overflow: hidden;
 }
-.graph-empty {
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  padding: 60rpx 0;
-}
-.empty-text {
-  font-size: 28rpx;
-  color: #94A3B8;
-}
-/* Canvas 层: absolute, 只画线 */
-.graph-canvas {
+.family-graph-canvas {
   position: absolute;
   top: 0;
   left: 0;
   z-index: 1;
-  pointer-events: none;
 }
-/* View 节点层: relative, flex 布局 */
-.graph-nodes {
-  position: relative;
-  z-index: 2;
-}
-/* 2-3人单行 */
-.row-layout {
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  justify-content: space-around;
-}
-/* 4+人多行 */
-.self-row {
-  display: flex;
-  justify-content: center;
-  margin-bottom: 16rpx;
-}
-.others-row {
-  display: flex;
-  flex-wrap: wrap;
-  justify-content: center;
-  align-items: flex-start;
-}
-/* 成员节点 */
-.member-node {
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  margin: 0 12rpx;
-  flex-shrink: 0;
-}
-/* 头像 */
-.member-avatar {
-  border-radius: 50%;
-  border: 2rpx solid transparent;
+.family-graph-empty {
   display: flex;
   align-items: center;
   justify-content: center;
-  box-sizing: border-box;
-}
-.member-avatar.avatar-square {
-  border-radius: 16rpx;
-}
-.is-self .member-avatar {
-  border-style: solid;
-}
-.avatar-letter {
-  font-weight: bold;
-  text-align: center;
+  padding: 60rpx 0;
+  background: #FFFFFF;
+  border-radius: 24rpx;
+  margin: 10rpx 0;
 }
-/* 昵称 */
-.member-name {
-  font-size: 22rpx;
-  margin-top: 6rpx;
-  max-width: 120rpx;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-  text-align: center;
+.empty-text {
+  font-size: 28rpx;
+  color: #94A3B8;
 }
-</style>
+</style>