| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717 |
- <template>
- <!-- 全 Canvas 关系图谱 - Force-directed 物理布局 -->
- <view class="family-graph-wrapper" v-if="simNodes && simNodes.length >= 1">
- <!-- 右上角小齿轮 -->
- <view class="gear-icon" @click="onManageTap">
- <text class="gear-text">⚙</text>
- </view>
- <canvas
- class="family-graph-canvas"
- canvas-id="forceGraphCanvas"
- id="forceGraphCanvas"
- type="2d"
- :style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
- @touchstart="onTouchStart"
- @touchmove="onTouchMove"
- @touchend="onTouchEnd"
- @click="onCanvasClick" />
- <!-- PC 端悬停检测层 -->
- <view
- v-if="isPC"
- class="hover-layer"
- :style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
- @mousemove="onHoverMove"
- @mouseleave="onHoverLeave" />
- <!-- + 添加成员提示(触摸/悬停时显示在中心节点旁) -->
- <view class="add-hint" :class="{ 'add-hint-visible': touchingSelf || hoveringSelf }" :style="addHintPosition">
- <text class="add-hint-text">+</text>
- </view>
- </view>
- <!-- 空状态 -->
- <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: {
- dimensionCode: {
- type: String,
- required: true,
- validator: function(val) {
- return ['body', 'mind', 'action', 'home', 'wisdom'].indexOf(val) !== -1
- }
- },
- selfId: {
- type: [Number, String],
- default: null
- },
- members: {
- type: Array,
- default: function() { return [] }
- },
- energyMap: {
- type: Object,
- default: function() { return {} }
- },
- intimacyMap: {
- type: Object,
- default: function() { return {} }
- },
- interactive: {
- type: Boolean,
- default: true
- },
- compatibilityMap: {
- type: Object,
- default: function() { return {} }
- }
- },
- data: function() {
- return {
- 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,
- // 当前用户触摸状态(显示+号)
- touchingSelf: false,
- // PC 端鼠标悬停状态(显示 + 号)
- hoveringSelf: false,
- // 是否 PC 环境(h5 编译)
- isPC: false,
- // 动画
- animFrameId: null,
- isSimulating: false,
- // 主题色
- themeColors: {
- body: '#8D6E63',
- mind: '#FF6B35',
- action: '#4CAF50',
- home: '#5B9BD5',
- wisdom: '#FFD700'
- }
- }
- },
- computed: {
- themeColor: function() {
- return this.themeColors[this.dimensionCode] || '#8D6E63'
- },
- // + 提示位置(跟随中心节点偏移)
- addHintPosition: function() {
- var selfNode = this.simNodes.find(function(n) { return n.isSelf })
- if (!selfNode) return {}
- return {
- left: (selfNode.x + selfNode.radius * 0.7) + 'px',
- top: (selfNode.y - selfNode.radius * 0.5) + 'px'
- }
- },
- // 将 members 转换为 simNodes
- nodeData: function() {
- var self = this
- 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 = isSelf ? '我' : 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
- }
- })
- },
- // 构建边 (self 连接到其他所有成员)
- edges: function() {
- var self = this
- 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: {
- handler: function() {
- this.adjustHeight()
- this.resetSimulation()
- },
- deep: true
- },
- canvasWidth: function() {
- this.resetSimulation()
- },
- canvasHeight: function() {
- this.resetSimulation()
- }
- },
- mounted: function() {
- var self = this
- this.dpr = uni.getSystemInfoSync().pixelRatio || 1
- // 检测是否 PC 端 h5 编译(微信小程序无 mouse 事件,走触摸路径即可)
- this.isPC = typeof window !== 'undefined' && typeof document !== 'undefined'
- this.$nextTick(function() {
- self.adjustHeight()
- self.updateCanvasSize()
- self.$nextTick(function() {
- self.initSimulation()
- })
- })
- },
- beforeDestroy: function() {
- this.stopSimulation()
- },
- methods: {
- // ===== 物理引擎 =====
- 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()
- },
- 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) {
- clearTimeout(this.animFrameId)
- this.animFrameId = null
- }
- },
- simulationTick: function() {
- if (!this.isSimulating) return
- var self = this
- var nodes = this.simNodes
- var p = this.physics
- // 中心坐标(提前定义,供多处使用)
- var cx = this.canvasWidth / 2
- var cy = this.canvasHeight / 2
- // 1. 清零力
- for (var i = 0; i < nodes.length; i++) {
- var n = nodes[i]
- if (n.isSelf) {
- // 当前用户固定在中心
- n.x = cx
- n.y = cy
- n.vx = 0
- n.vy = 0
- n.fx = 0
- n.fy = 0
- continue
- }
- n.fx = 0
- n.fy = 0
- }
- // 2. 斥力(当前用户除外)
- for (var a = 0; a < nodes.length; a++) {
- if (nodes[a].isSelf) continue
- for (var b = a + 1; b < nodes.length; b++) {
- if (nodes[b].isSelf) continue
- 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
- // 只让目标节点受力(其他成员被弹簧拉向自己)
- t.fx -= fx
- t.fy -= fy
- }
- // 4. 中心引力(当前用户除外)
- for (var n = 0; n < nodes.length; n++) {
- if (nodes[n].isSelf) continue
- 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]
- if (node.isSelf) continue
- 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. 下一帧 (小程序无 requestAnimationFrame,用 setTimeout 模拟 60fps)
- this.animFrameId = setTimeout(function() {
- self.simulationTick()
- }, 16)
- },
- // ===== 渲染 =====
- render: function() {
- var self = this
- var query = uni.createSelectorQuery().in(this)
- 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()
- }
- },
- 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
- 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.strokeStyle = 'hsla(' + hue + ', 75%, 45%, 0.6)'
- ctx.lineWidth = 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.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)
- }
- 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)
- 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.getTouchNode(x, y)
- if (node) {
- if (node.isSelf) {
- self.touchingSelf = true
- } else {
- self.dragging = { node: node, offsetX: x - node.x, offsetY: y - node.y }
- self.dragStartPos = { x: x, y: y }
- }
- }
- }
- })
- },
- 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
- 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
- }
- })
- },
- onTouchEnd: function(e) {
- var wasTouchingSelf = this.touchingSelf
- this.touchingSelf = false
- if (wasTouchingSelf) {
- // 触摸的是中心"我"节点 → 添加成员
- if (this.interactive) {
- this.$emit('addMemberTap')
- }
- this.dragging = null
- this.dragStartPos = null
- return
- }
- 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
- },
- // ===== 工具方法 =====
- 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) {
- var defaults = { closeness: 75, communication: 50, trust: 85 }
- return defaults[key]
- }
- var val = data[key]
- return (typeof val !== 'undefined' && val !== null) ? val : 75
- },
- 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()
- },
- // 点击右上角小齿轮 → 跳转成员管理页
- onManageTap: function() {
- uni.navigateTo({
- url: '/pages/profile/family-members'
- })
- }
- }
- }
- </script>
- <style scoped>
- .family-graph-wrapper {
- position: relative;
- width: 100%;
- height: 280px;
- margin: 10rpx 0;
- background: #FFFFFF;
- border-radius: 24rpx;
- overflow: hidden;
- }
- .family-graph-canvas {
- position: absolute;
- top: 0;
- left: 0;
- z-index: 1;
- }
- /* 右上角小齿轮 */
- .gear-icon {
- position: absolute;
- top: 12rpx;
- right: 12rpx;
- z-index: 10;
- width: 56rpx;
- height: 56rpx;
- display: flex;
- align-items: center;
- justify-content: center;
- background: rgba(255, 255, 255, 0.9);
- border-radius: 50%;
- box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
- }
- .gear-text {
- font-size: 32rpx;
- color: #94A3B8;
- }
- /* + 添加成员提示 */
- .add-hint {
- position: absolute;
- z-index: 5;
- width: 40rpx;
- height: 40rpx;
- border-radius: 50%;
- background: #10B981;
- display: flex;
- align-items: center;
- justify-content: center;
- opacity: 0;
- transform: scale(0.5);
- transition: opacity 0.15s, transform 0.15s;
- pointer-events: none;
- }
- .add-hint.add-hint-visible {
- opacity: 1;
- transform: scale(1);
- }
- .add-hint-text {
- font-size: 28rpx;
- color: #FFFFFF;
- font-weight: bold;
- line-height: 1;
- }
- .family-graph-empty {
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 60rpx 0;
- background: #FFFFFF;
- border-radius: 24rpx;
- margin: 10rpx 0;
- }
- .empty-text {
- font-size: 28rpx;
- color: #94A3B8;
- }
- </style>
|