|
|
@@ -1,20 +1,99 @@
|
|
|
<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
|
|
|
- v-else
|
|
|
+ class="graph-canvas"
|
|
|
canvas-id="relationGraphCanvas"
|
|
|
id="relationGraphCanvas"
|
|
|
type="2d"
|
|
|
- @tap="onCanvasTap"
|
|
|
: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>
|
|
|
+ </view>
|
|
|
+ <!-- 0人空状态 -->
|
|
|
+ <view class="family-relation-graph graph-empty" v-else-if="members && members.length === 0">
|
|
|
+ <text class="empty-text">暂无家庭成员</text>
|
|
|
</view>
|
|
|
</template>
|
|
|
|
|
|
@@ -52,11 +131,9 @@ export default {
|
|
|
},
|
|
|
data: function() {
|
|
|
return {
|
|
|
- canvasWidth: 240,
|
|
|
- canvasHeight: 240,
|
|
|
+ canvasWidth: 300,
|
|
|
+ canvasHeight: 300,
|
|
|
dpr: 1,
|
|
|
- positionedMembers: [],
|
|
|
- selfPosition: { x: 0, y: 0 },
|
|
|
themeColors: {
|
|
|
body: '#8D6E63',
|
|
|
mind: '#FF6B35',
|
|
|
@@ -70,39 +147,85 @@ export default {
|
|
|
return this.themeColors[this.dimensionCode] || '#8D6E63'
|
|
|
},
|
|
|
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: {
|
|
|
- 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() {
|
|
|
- this.initCanvas()
|
|
|
+ this.dpr = uni.getSystemInfoSync().pixelRatio || 1
|
|
|
+ this.scheduleDraw()
|
|
|
},
|
|
|
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) {
|
|
|
var data = this.intimacyMap[memberId]
|
|
|
if (!data) {
|
|
|
@@ -113,388 +236,154 @@ export default {
|
|
|
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 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
|
|
|
+ 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)
|
|
|
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.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.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.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 (!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>
|
|
|
.family-relation-graph {
|
|
|
- display: flex;
|
|
|
- align-items: center;
|
|
|
- justify-content: center;
|
|
|
- padding: 20rpx 0;
|
|
|
+ position: relative;
|
|
|
+ padding: 24rpx 30rpx;
|
|
|
margin: 10rpx 0;
|
|
|
background: #FFFFFF;
|
|
|
border-radius: 24rpx;
|
|
|
- position: relative;
|
|
|
}
|
|
|
.graph-empty {
|
|
|
- padding: 60rpx 0;
|
|
|
display: flex;
|
|
|
align-items: center;
|
|
|
justify-content: center;
|
|
|
+ padding: 60rpx 0;
|
|
|
}
|
|
|
.empty-text {
|
|
|
font-size: 28rpx;
|
|
|
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>
|