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

refactor: rewrite FamilyRelationGraph with View+Canvas hybrid layout

Replace pure Canvas polar-coordinate layout with View+Canvas hybrid:
- View layer (flexbox) for nodes — conditional layout per member count
- Canvas layer (type=2d) for self-to-other relation lines
- Energy value mapped to avatar size (not ring progress bar)
- Native View @tap replaces manual Canvas hit detection
- Removes: polarLayout, drawMember, drawMemberLegacy, drawMemberShape, drawEnergyRing, initCanvas, onCanvasTap

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Sisyphus 4 месяцев назад
Родитель
Сommit
4098f59b8e

+ 89 - 0
cfc-frontend/__tests__/components/FamilyRelationGraph.test.js

@@ -0,0 +1,89 @@
+var assert = require('assert')
+
+function getEnergy(energyMap, memberId, dimensionCode) {
+  var data = energyMap[memberId]
+  if (!data) return 0
+  var key = dimensionCode + 'Score'
+  var val = data[key]
+  return (typeof val !== 'undefined' && val !== null) ? val : 0
+}
+
+function getAvatarSize(energy, isSelf) {
+  var clamped = Math.max(0, Math.min(100, energy))
+  var base = 64 + (clamped / 100) * 56
+  if (isSelf) return Math.max(96, base + 16)
+  return base
+}
+
+function getLayoutMode(memberCount) {
+  if (memberCount <= 3) return 'row'
+  return 'multi-row'
+}
+
+function getDisplayOrder(members, selfId) {
+  var self = null
+  var others = []
+  for (var i = 0; i < members.length; i++) {
+    if (members[i].id == selfId) {
+      self = members[i]
+    } else {
+      others.push(members[i])
+    }
+  }
+  if (members.length === 2) return [self, others[0]]
+  if (members.length === 3 && others.length >= 2) return [others[0], self, others[1]]
+  return [self].concat(others)
+}
+
+function getLineEndPoints(selfCX, selfCY, selfR, otherCX, otherCY, otherR) {
+  var dx = otherCX - selfCX
+  var dy = otherCY - selfCY
+  var dist = Math.sqrt(dx * dx + dy * dy)
+  if (dist < 1) return null
+  var ux = dx / dist
+  var uy = dy / dist
+  return {
+    x1: selfCX + ux * selfR,
+    y1: selfCY + uy * selfR,
+    x2: otherCX - ux * otherR,
+    y2: otherCY - uy * otherR
+  }
+}
+
+assert.strictEqual(getAvatarSize(0, false), 64, 'min size for other at energy 0')
+assert.strictEqual(getAvatarSize(100, false), 120, 'max size for other at energy 100')
+assert.strictEqual(getAvatarSize(50, false), 92, 'mid size for other at energy 50')
+assert.strictEqual(getAvatarSize(0, true), 96, 'self min is 96')
+assert.strictEqual(getAvatarSize(100, true), 136, 'self max energy: 64+56+16=136')
+assert.strictEqual(getAvatarSize(-10, false), 64, 'negative energy clamped to 0')
+assert.strictEqual(getAvatarSize(150, false), 120, 'energy >100 clamped to 100')
+
+assert.strictEqual(getLayoutMode(0), 'row', '0 members → row (but v-if hides)')
+assert.strictEqual(getLayoutMode(2), 'row', '2 members → row')
+assert.strictEqual(getLayoutMode(3), 'row', '3 members → row')
+assert.strictEqual(getLayoutMode(4), 'multi-row', '4 members → multi-row')
+assert.strictEqual(getLayoutMode(10), 'multi-row', '10 members → multi-row')
+
+var m2 = [{ id: 1, nickname: '爸' }, { id: 2, nickname: '我' }]
+var order2 = getDisplayOrder(m2, 2)
+assert.strictEqual(order2[0].id, 2, '2-person: self first')
+assert.strictEqual(order2[1].id, 1, '2-person: other second')
+
+var m3 = [{ id: 1, nickname: '爸' }, { id: 2, nickname: '我' }, { id: 3, nickname: '妈' }]
+var order3 = getDisplayOrder(m3, 2)
+assert.strictEqual(order3[0].id, 1, '3-person: other1 left')
+assert.strictEqual(order3[1].id, 2, '3-person: self center')
+assert.strictEqual(order3[2].id, 3, '3-person: other2 right')
+
+var pts = getLineEndPoints(100, 100, 30, 300, 100, 20)
+assert.strictEqual(pts.x1, 130, 'line start x = self center + self radius')
+assert.strictEqual(pts.x2, 280, 'line end x = other center - other radius')
+assert.strictEqual(pts.y1, 100, 'line start y = self center y')
+assert.strictEqual(pts.y2, 100, 'line end y = other center y')
+
+var em = { 1: { bodyScore: 80, mindScore: 40 } }
+assert.strictEqual(getEnergy(em, 1, 'body'), 80, 'get body energy')
+assert.strictEqual(getEnergy(em, 1, 'mind'), 40, 'get mind energy')
+assert.strictEqual(getEnergy(em, 2, 'body'), 0, 'missing member returns 0')
+
+console.log('All FamilyRelationGraph tests passed!')

+ 352 - 397
cfc-frontend/components/FamilyRelationGraph.vue

@@ -1,20 +1,99 @@
 <template>
 <template>
-  <view class="family-relation-graph">
-    <!-- 空状态 -->
-    <view class="graph-empty" v-if="!members || members.length === 0">
-      <text class="empty-text">暂无家庭成员</text>
-    </view>
-    <!-- Canvas 画布 -->
+  <!-- 1人不渲染 -->
+  <view class="family-relation-graph" v-if="members && members.length >= 2">
+    <!-- Canvas 连线层 -->
     <canvas
     <canvas
-      v-else
+      class="graph-canvas"
       canvas-id="relationGraphCanvas"
       canvas-id="relationGraphCanvas"
       id="relationGraphCanvas"
       id="relationGraphCanvas"
       type="2d"
       type="2d"
-      @tap="onCanvasTap"
       :style="{
       :style="{
         width: canvasWidth + 'px',
         width: canvasWidth + 'px',
         height: canvasHeight + '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>
+  </view>
+  <!-- 0人空状态 -->
+  <view class="family-relation-graph graph-empty" v-else-if="members && members.length === 0">
+    <text class="empty-text">暂无家庭成员</text>
   </view>
   </view>
 </template>
 </template>
 
 
@@ -52,11 +131,9 @@ export default {
   },
   },
   data: function() {
   data: function() {
     return {
     return {
-      canvasWidth: 240,
-      canvasHeight: 240,
+      canvasWidth: 300,
+      canvasHeight: 300,
       dpr: 1,
       dpr: 1,
-      positionedMembers: [],
-      selfPosition: { x: 0, y: 0 },
       themeColors: {
       themeColors: {
         body: '#8D6E63',
         body: '#8D6E63',
         mind: '#FF6B35',
         mind: '#FF6B35',
@@ -70,39 +147,85 @@ export default {
       return this.themeColors[this.dimensionCode] || '#8D6E63'
       return this.themeColors[this.dimensionCode] || '#8D6E63'
     },
     },
     themeColorRgba: function() {
     themeColorRgba: function() {
-      // 主题色15%透明度 for fill
-      var c = this.themeColor
-      return c + '26'  // 15% alpha hex
+      return this.themeColor + '26'
+    },
+    layoutMode: function() {
+      if (!this.members || this.members.length <= 3) return 'row'
+      return 'multi-row'
+    },
+    selfMember: function() {
+      var self = this
+      var found = null
+      for (var i = 0; i < this.members.length; i++) {
+        if (this.members[i].id == this.selfId) {
+          found = this.members[i]
+          break
+        }
+      }
+      if (!found && this.members.length > 0) {
+        found = this.members[0]
+      }
+      if (!found) return { id: null, nickname: '我', memberType: 'child', isSelf: true, displayName: '我', avatarSize: 120 }
+      var energy = this.getEnergy(found.id)
+      var size = Math.max(96, 80 + (Math.max(0, Math.min(100, energy)) / 100) * 56)
+      return {
+        id: found.id,
+        nickname: found.nickname || found.name || '我',
+        memberType: found.memberType || 'child',
+        isSelf: true,
+        displayName: (found.nickname && found.nickname.charAt(0)) || (found.name && found.name.charAt(0)) || '我',
+        avatarSize: size
+      }
     },
     },
-    themeColorStroke: function() {
-      var c = this.themeColor
-      return c + '66'  // 40% alpha hex
+    otherMembers: function() {
+      var self = this
+      return this.members
+        .filter(function(m) { return m.id != self.selfId })
+        .map(function(m) {
+          var energy = self.getEnergy(m.id)
+          var size = 64 + (Math.max(0, Math.min(100, energy)) / 100) * 56
+          return {
+            id: m.id,
+            nickname: m.nickname || m.name || '成员',
+            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)
     }
     }
   },
   },
   watch: {
   watch: {
-    members: function() { this.draw() },
-    energyMap: function() { this.draw() },
-    intimacyMap: function() { this.draw() }
+    members: function() { this.scheduleDraw() },
+    energyMap: function() { this.scheduleDraw() },
+    intimacyMap: function() { this.scheduleDraw() }
   },
   },
   mounted: function() {
   mounted: function() {
-    this.initCanvas()
+    this.dpr = uni.getSystemInfoSync().pixelRatio || 1
+    this.scheduleDraw()
   },
   },
   methods: {
   methods: {
-    initCanvas: function() {
-      var sysInfo = uni.getSystemInfoSync()
-      this.dpr = sysInfo.pixelRatio || 1
-      var size = uni.upx2px(480)
-      this.canvasWidth = size
-      this.canvasHeight = size
-      var self = this
-      this.$nextTick(function() {
-        if (self.members && self.members.length > 0) {
-          self.draw()
-        }
-      })
+    // 获取能量值
+    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
     },
     },
 
 
-    // 获取亲密度数据(有则用,无则返回默认值)
+    // 获取亲密度数据
     getIntimacy: function(memberId, key) {
     getIntimacy: function(memberId, key) {
       var data = this.intimacyMap[memberId]
       var data = this.intimacyMap[memberId]
       if (!data) {
       if (!data) {
@@ -113,388 +236,154 @@ export default {
       return (typeof val !== 'undefined' && val !== null) ? val : 75
       return (typeof val !== 'undefined' && val !== null) ? val : 75
     },
     },
 
 
-    // 获取能量值
-    getEnergy: function(memberId) {
-      var data = this.energyMap[memberId]
-      if (!data) return 0
-      var key = this.dimensionCode + 'Score'  // bodyScore / mindScore / actionScore
-      var val = data[key]
-      return (typeof val !== 'undefined' && val !== null) ? val : 0
-    },
-
-    // 极坐标布局 — 计算每个成员的位置
-    polarLayout: function() {
-      var centerX = this.canvasWidth / 2
-      var centerY = this.canvasHeight / 2
-      this.selfPosition = { x: centerX, y: centerY }
-
-      var N = this.members.length
-      if (N === 0) {
-        this.positionedMembers = []
-        return
-      }
-
-      var R_INNER = uni.upx2px(100)   // ~46px
-      var R_OUTER = uni.upx2px(320)   // ~148px
-      var positioned = []
-
-      for (var i = 0; i < N; i++) {
-        var member = this.members[i]
-        var intimacy = this.getIntimacy(member.id, 'closeness')
-        // 亲密度0→半径最大,100→半径最小
-        var radius = R_INNER + (100 - intimacy) / 100 * (R_OUTER - R_INNER)
-        // 从正上方(12点钟)开始均匀分布
-        var angle = (2 * Math.PI * i) / N - Math.PI / 2
-        var x = centerX + radius * Math.cos(angle)
-        var y = centerY + radius * Math.sin(angle)
-
-        var isSelf = (member.id === this.selfId)
-        var memberType = member.memberType || 'child'
-
-        positioned.push({
-          id: member.id,
-          nickname: member.nickname || member.name || '成员',
-          memberType: memberType,
-          canvasX: x,
-          canvasY: y,
-          radius: isSelf ? uni.upx2px(80) : uni.upx2px(60),  // smaller nodes
-          hitRadius: isSelf ? uni.upx2px(100) : uni.upx2px(80),
-          isSelf: isSelf,
-          intimacy: this.getIntimacy(member.id, 'closeness'),
-          communication: this.getIntimacy(member.id, 'communication'),
-          trust: this.getIntimacy(member.id, 'trust')
+    // 触发重绘:等 View 布局完成 → 读坐标 → 在 Canvas 画线
+    scheduleDraw: function() {
+      var self = this
+      this.$nextTick(function() {
+        self.updateCanvasSize()
+        self.$nextTick(function() {
+          self.drawLines()
         })
         })
-      }
-      this.positionedMembers = positioned
+      })
     },
     },
 
 
-    // 主绘制流程
-    draw: function() {
-      this.polarLayout()
-      if (this.positionedMembers.length === 0) return
-
+    // 读取 View 节点层尺寸,设置 Canvas 覆盖尺寸
+    updateCanvasSize: function() {
       var self = this
       var self = this
       var query = uni.createSelectorQuery().in(this)
       var query = uni.createSelectorQuery().in(this)
-
-      // 使用 type="2d" 的 node API
-      query.select('#relationGraphCanvas')
-        .fields({ node: true, size: true })
-        .exec(function(res) {
-          if (!res || !res[0]) {
-            // 降级方案:使用 CanvasContext
-            self.drawLegacy()
-            return
-          }
-          var canvas = res[0].node
-          if (!canvas) {
-            self.drawLegacy()
-            return
-          }
-          var ctx = canvas.getContext('2d')
-
-          // 设置 canvas 实际像素(适配高清屏)
-          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.drawLines(ctx)
-
-          // 绘制成员
-          for (var i = 0; i < self.positionedMembers.length; i++) {
-            self.drawMember(ctx, self.positionedMembers[i])
-          }
-        })
+      query.select('.graph-nodes').boundingClientRect(function(rect) {
+        if (!rect) return
+        self.canvasWidth = rect.width
+        self.canvasHeight = rect.height
+      }).exec()
     },
     },
 
 
-    // 降级绘制(使用 CanvasContext 兼容旧版基础库)
-    drawLegacy: function() {
-      var ctx = uni.createCanvasContext('relationGraphCanvas', this)
+    // Canvas 连线绘制(type=2d 优先,降级 CanvasContext)
+    drawLines: function() {
       var self = this
       var self = this
+      if (!this.members || this.members.length < 2) return
 
 
-      // 清除
-      ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
-
-      // 连线
-      self.drawLinesLegacy(ctx)
-
-      // 成员
-      for (var i = 0; i < self.positionedMembers.length; i++) {
-        self.drawMemberLegacy(ctx, self.positionedMembers[i])
-      }
-
-      ctx.draw()
+      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.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
+
+              // 信任度 → 线颜色 (红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
+          })
+      })
     },
     },
 
 
-    // 绘制连线(type=2d mode)
-    drawLines: function(ctx) {
-      var selfPos = this.selfPosition
-      var members = this.positionedMembers
-      for (var i = 0; i < members.length; i++) {
-        var m = members[i]
-        if (m.isSelf) continue
-
-        // 信任度 → 色相 (红0°→黄60°→绿120°)
-        var trust = Math.max(0, Math.min(100, m.trust || 0))
-        var hue = (120 * trust / 100)
-        ctx.strokeStyle = 'hsl(' + hue + ', 75%, 45%)'
-        ctx.globalAlpha = 0.8
-
-        // 沟通度 → 线宽 2~10px
-        var comm = Math.max(0, Math.min(100, m.communication || 0))
-        ctx.lineWidth = 2 + (comm / 100) * 8
+    // 降级连线(CanvasContext)
+    drawLinesLegacy: function(selfCX, selfCY, selfR, otherRects, containerLeft, containerTop) {
+      var ctx = uni.createCanvasContext('relationGraphCanvas', this)
+      var self = this
 
 
-        // 绘制从 self 到成员的线段
-        ctx.beginPath()
-        ctx.moveTo(selfPos.x, selfPos.y)
-        ctx.lineTo(m.canvasX, m.canvasY)
-        ctx.stroke()
-      }
-      ctx.globalAlpha = 1.0
-    },
+      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
 
 
-    // 绘制连线(legacy mode)
-    drawLinesLegacy: function(ctx) {
-      var selfPos = this.selfPosition
-      var members = this.positionedMembers
-      for (var i = 0; i < members.length; i++) {
-        var m = members[i]
-        if (m.isSelf) continue
+        var otherMember = self.otherMembers[j]
+        if (!otherMember) continue
 
 
-        var trust = Math.max(0, Math.min(100, m.trust || 0))
+        var trust = Math.max(0, Math.min(100, self.getIntimacy(otherMember.id, 'trust')))
         var hue = (120 * trust / 100)
         var hue = (120 * trust / 100)
         ctx.setStrokeStyle('hsl(' + hue + ', 75%, 45%)')
         ctx.setStrokeStyle('hsl(' + hue + ', 75%, 45%)')
-        ctx.globalAlpha = 0.8
+        ctx.globalAlpha = 0.6
 
 
-        var comm = Math.max(0, Math.min(100, m.communication || 0))
+        var comm = Math.max(0, Math.min(100, self.getIntimacy(otherMember.id, 'communication')))
         ctx.setLineWidth(2 + (comm / 100) * 8)
         ctx.setLineWidth(2 + (comm / 100) * 8)
 
 
-        ctx.beginPath()
-        ctx.moveTo(selfPos.x, selfPos.y)
-        ctx.lineTo(m.canvasX, m.canvasY)
-        ctx.stroke()
-      }
-    },
-
-    // 根据成员类型绘制形状 path
-    drawMemberShape: function(ctx, x, y, r, memberType) {
-      ctx.beginPath()
-      if (memberType === 'parent' || memberType === 'PARENT') {
-        // 圆角正方形
-        var cr = r * 0.25
-        var s = r * 0.8
-        ctx.moveTo(x - s + cr, y - s)
-        ctx.lineTo(x + s - cr, y - s)
-        ctx.arcTo(x + s, y - s, x + s, y - s + cr, cr)
-        ctx.lineTo(x + s, y + s - cr)
-        ctx.arcTo(x + s, y + s, x + s - cr, y + s, cr)
-        ctx.lineTo(x - s + cr, y + s)
-        ctx.arcTo(x - s, y + s, x - s, y + s - cr, cr)
-        ctx.lineTo(x - s, y - s + cr)
-        ctx.arcTo(x - s, y - s, x - s + cr, y - s, cr)
-        ctx.closePath()
-      } else if (memberType === 'child' || memberType === 'CHILD' || memberType === 'children') {
-        // 圆形
-        ctx.arc(x, y, r * 0.8, 0, 2 * Math.PI)
-      } else if (memberType === 'partner' || memberType === 'spouse' || memberType === 'PARTNER') {
-        // 简化心形
-        var hr = r * 0.48
-        // 左上弧(左心耳)
-        ctx.arc(x - hr * 0.5, y - hr * 0.2, hr, 0, Math.PI * 1.7, true)
-        // 右上弧(右心耳)
-        ctx.arc(x + hr * 0.5, y - hr * 0.2, hr, Math.PI * 1.3, Math.PI * 0.5, true)
-        ctx.closePath()
-      } else {
-        // 默认圆形
-        ctx.arc(x, y, r * 0.8, 0, 2 * Math.PI)
-      }
-    },
-
-    // 绘制环形能量进度条
-    drawEnergyRing: function(ctx, x, y, radius, fillRatio, color) {
-      var lineWidth = 2
-      // 灰色底圈
-      ctx.beginPath()
-      ctx.arc(x, y, radius, 0, 2 * Math.PI)
-      ctx.strokeStyle = 'rgba(200, 200, 200, 0.35)'
-      ctx.lineWidth = lineWidth
-      ctx.stroke()
-
-      if (fillRatio <= 0) return
-
-      // 彩色进度圈
-      ctx.beginPath()
-      var startAngle = -Math.PI / 2
-      var endAngle = startAngle + fillRatio * 2 * Math.PI
-      ctx.arc(x, y, radius, startAngle, endAngle)
-      ctx.strokeStyle = color
-      ctx.lineWidth = lineWidth
-      ctx.lineCap = 'round'
-      ctx.stroke()
-      ctx.lineCap = 'butt'  // reset
-    },
-
-    // 绘制成员形状(type=2d mode)
-    drawMember: function(ctx, member) {
-      var themeColor = this.themeColor
-      var x = member.canvasX
-      var y = member.canvasY
-      var r = member.radius
-      var energy = this.getEnergy(member.id)
-      var fillRatio = Math.max(0, Math.min(1, energy / 100))
-
-      ctx.save()
-
-      if (member.isSelf) {
-        // self:外层发光描边
-        ctx.beginPath()
-        ctx.arc(x, y, r + 3, 0, 2 * Math.PI)
-        ctx.strokeStyle = themeColor
-        ctx.lineWidth = 2
-        ctx.globalAlpha = 0.4
-        ctx.stroke()
-        ctx.globalAlpha = 1.0
-
-        // 内圆填充
-        ctx.beginPath()
-        ctx.arc(x, y, r, 0, 2 * Math.PI)
-        ctx.fillStyle = '#FFFFFF'
-        ctx.fill()
-        ctx.strokeStyle = themeColor
-        ctx.lineWidth = 2
-        ctx.stroke()
-
-        // 中心文字(昵称首字)
-        ctx.fillStyle = themeColor
-        ctx.font = 'bold ' + Math.round(r * 0.9) + 'px sans-serif'
-        ctx.textAlign = 'center'
-        ctx.textBaseline = 'middle'
-        var displayName = (member.nickname && member.nickname.charAt(0)) || '我'
-        ctx.fillText(displayName, x, y)
-      } else {
-        // 其他成员:根据 memberType 选择形状
-        this.drawMemberShape(ctx, x, y, r, member.memberType)
-
-        // 填充主题色(淡)
-        ctx.fillStyle = this.themeColorRgba
-        ctx.fill()
-        ctx.strokeStyle = this.themeColorStroke
-        ctx.lineWidth = 2
-        ctx.stroke()
-
-        // 环形能量进度条(在形状外围)
-        this.drawEnergyRing(ctx, x, y, r + 3, fillRatio, themeColor)
-
-        // 中心昵称首字
-        ctx.fillStyle = '#4A5568'
-        ctx.font = 'bold ' + Math.round(r * 0.65) + 'px sans-serif'
-        ctx.textAlign = 'center'
-        ctx.textBaseline = 'middle'
-        var displayName = (member.nickname && member.nickname.charAt(0)) || '?'
-        ctx.fillText(displayName, x, y)
-      }
-
-      ctx.restore()
-    },
-
-    // 绘制成员(legacy mode)
-    drawMemberLegacy: function(ctx, member) {
-      var themeColor = this.themeColor
-      var x = member.canvasX
-      var y = member.canvasY
-      var r = member.radius
-      var energy = this.getEnergy(member.id)
-      var fillRatio = Math.max(0, Math.min(1, energy / 100))
+        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
 
 
-      if (member.isSelf) {
         ctx.beginPath()
         ctx.beginPath()
-        ctx.arc(x, y, r + 3, 0, 2 * Math.PI)
-        ctx.setStrokeStyle(themeColor)
-        ctx.setLineWidth(2)
-        ctx.setGlobalAlpha(0.4)
+        ctx.moveTo(selfCX + ux * selfR, selfCY + uy * selfR)
+        ctx.lineTo(otherCX - ux * otherR, otherCY - uy * otherR)
         ctx.stroke()
         ctx.stroke()
-        ctx.setGlobalAlpha(1.0)
-
-        ctx.beginPath()
-        ctx.arc(x, y, r, 0, 2 * Math.PI)
-        ctx.setFillStyle('#FFFFFF')
-        ctx.fill()
-        ctx.setStrokeStyle(themeColor)
-        ctx.setLineWidth(2)
-        ctx.stroke()
-
-        ctx.setFillStyle(themeColor)
-        ctx.font = 'bold ' + Math.round(r * 0.9) + 'px sans-serif'
-        ctx.setTextAlign('center')
-        ctx.setTextBaseline('middle')
-        var name = (member.nickname && member.nickname.charAt(0)) || '我'
-        ctx.fillText(name, x, y)
-      } else {
-        this.drawMemberShape(ctx, x, y, r, member.memberType)
-        ctx.setFillStyle(this.themeColorRgba)
-        ctx.fill()
-        ctx.setStrokeStyle(this.themeColorStroke)
-        ctx.setLineWidth(2)
-        ctx.stroke()
-
-        // 环形进度条
-        ctx.beginPath()
-        ctx.arc(x, y, r + 3, 0, 2 * Math.PI)
-        ctx.setStrokeStyle('rgba(200, 200, 200, 0.35)')
-        ctx.setLineWidth(2)
-        ctx.stroke()
-
-        if (fillRatio > 0) {
-          ctx.beginPath()
-          var startAngle = -Math.PI / 2
-          var endAngle = startAngle + fillRatio * 2 * Math.PI
-          ctx.arc(x, y, r + 3, startAngle, endAngle)
-          ctx.setStrokeStyle(themeColor)
-          ctx.setLineWidth(2)
-          ctx.setLineCap('round')
-          ctx.stroke()
-          ctx.setLineCap('butt')
-        }
-
-        ctx.setFillStyle('#4A5568')
-        ctx.font = 'bold ' + Math.round(r * 0.65) + 'px sans-serif'
-        ctx.setTextAlign('center')
-        ctx.setTextBaseline('middle')
-        var name = (member.nickname && member.nickname.charAt(0)) || '?'
-        ctx.fillText(name, x, y)
       }
       }
+      ctx.draw()
     },
     },
 
 
-    // Canvas 点击命中检测
-    onCanvasTap: function(e) {
+    // View 节点点击事件
+    onMemberTap: function(e) {
       if (!this.interactive) return
       if (!this.interactive) return
-      if (!e || !e.detail || typeof e.detail.x === 'undefined') return
-
-      var self = this
-      var query = uni.createSelectorQuery().in(this)
-      query.select('#relationGraphCanvas').boundingClientRect(function(rect) {
-        if (!rect) return
-        var tapX = e.detail.x - (rect.left || 0)
-        var tapY = e.detail.y - (rect.top || 0)
-
-        for (var i = 0; i < self.positionedMembers.length; i++) {
-          var m = self.positionedMembers[i]
-          var dx = tapX - m.canvasX
-          var dy = tapY - m.canvasY
-          var dist = Math.sqrt(dx * dx + dy * dy)
-          if (dist <= m.hitRadius) {
-            self.$emit('memberTap', {
-              memberId: m.id,
-              memberType: m.memberType,
-              nickname: m.nickname
-            })
-            return
-          }
-        }
-      }).exec()
+      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
+      })
     }
     }
   }
   }
 }
 }
@@ -502,23 +391,89 @@ export default {
 
 
 <style scoped>
 <style scoped>
 .family-relation-graph {
 .family-relation-graph {
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  padding: 20rpx 0;
+  position: relative;
+  padding: 24rpx 30rpx;
   margin: 10rpx 0;
   margin: 10rpx 0;
   background: #FFFFFF;
   background: #FFFFFF;
   border-radius: 24rpx;
   border-radius: 24rpx;
-  position: relative;
 }
 }
 .graph-empty {
 .graph-empty {
-  padding: 60rpx 0;
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
   justify-content: center;
   justify-content: center;
+  padding: 60rpx 0;
 }
 }
 .empty-text {
 .empty-text {
   font-size: 28rpx;
   font-size: 28rpx;
   color: #94A3B8;
   color: #94A3B8;
 }
 }
+/* Canvas 层: absolute, 只画线 */
+.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;
+  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;
+}
+/* 昵称 */
+.member-name {
+  font-size: 22rpx;
+  margin-top: 6rpx;
+  max-width: 120rpx;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  text-align: center;
+}
 </style>
 </style>