|
|
@@ -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>
|