| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968 |
- <template>
- <!-- 全 Canvas 关系图谱 - Force-directed 物理布局 -->
- <view class="family-graph-wrapper" v-if="members && members.length >= 1" :style="{ minHeight: canvasHeight + 'px' }">
- <!-- 右上角小齿轮 → 成员管理 -->
- <view class="gear-icon" @click="onManageTap" @tap.stop="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" />
- <!-- ⚙ 维护页提示(触摸自身节点时显示) -->
- <view class="add-hint" :class="{ 'add-hint-visible': touchingSelf }" :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,
- // 物理参数
- physics: {
- repulsion: 800, // 节点间斥力强度
- springLength: 120, // 弹簧自然长度
- springStrength: 0.04, // 弹簧劲度系数
- centering: 0.03, // 中心引力
- damping: 0.88, // 速度阻尼
- maxSpeed: 12, // 最大速度
- minDistance: 30 // 最小节点距离
- },
- // 拖拽状态
- dragging: null, // { nodeIndex, offsetX, offsetY }
- dragStartPos: null, // 用于判断是否真的拖拽了
- // 触摸/悬停状态(显示 ⚙ 提示)
- touchingSelf: false,
- 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.nodeData.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 selfMemberId = null
- var centerX = this.canvasWidth / 2
- var centerY = this.canvasHeight / 2
- return this.members.map(function(m, idx) {
- var fid = m.memberId || m.id
- if (fid == self.selfId) selfMemberId = fid
- var energy = self.getEnergy(fid)
- var radius = 24 + (Math.max(0, Math.min(100, energy)) / 100) * 16 // 24-40rpx
- 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) / 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',
- effectiveRole: m.effectiveRole || m.relativeLabel || '',
- gender: m.gender || '',
- relativeLabel: m.relativeLabel || '',
- 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: function() {
- this.resetSimulation()
- },
- canvasWidth: function() {
- this.resetSimulation()
- },
- canvasHeight: function() {
- this.resetSimulation()
- }
- },
- mounted: function() {
- this._destroyed = false
- var self = this
- try {
- this.dpr = uni.getSystemInfoSync().pixelRatio || 1
- } catch (e) {
- this.dpr = 1
- }
- this.$nextTick(function() {
- if (self._destroyed) return
- self.updateCanvasSize()
- self.$nextTick(function() {
- if (!self._destroyed) {
- self.initSimulation()
- }
- })
- })
- },
- beforeDestroy: function() {
- this._destroyed = true
- 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) / 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
- this.startSimulation()
- },
- // hex → rgba(小程序不支持 8 位 hex 颜色 #RRGGBBAA)
- hexToRgba: function(hex, alpha) {
- var r = parseInt(hex.slice(1, 3), 16)
- var g = parseInt(hex.slice(3, 5), 16)
- var b = parseInt(hex.slice(5, 7), 16)
- return 'rgba(' + r + ',' + g + ',' + b + ',' + alpha + ')'
- },
- 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
- if (this._destroyed) {
- this.isSimulating = false
- return
- }
- var self = this
- var nodes = this.nodeData
- var edges = this.edges
- var p = this.physics
- // 1. 清零力
- for (var i = 0; i < nodes.length; i++) {
- if (!nodes[i].fixed) {
- 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
- // F = repulsion / dist^2
- var force = p.repulsion / distSq
- var fx = (dx / dist) * force
- var fy = (dy / dist) * force
- if (!nodes[a].fixed) {
- nodes[a].fx -= fx
- nodes[a].fy -= fy
- }
- if (!nodes[b].fixed) {
- nodes[b].fx += fx
- nodes[b].fy += fy
- }
- }
- }
- // 3. 弹簧引力 (边)
- for (var e = 0; e < edges.length; e++) {
- var edge = 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
- if (!s.fixed) {
- s.fx += fx
- s.fy += fy
- }
- if (!t.fixed) {
- 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++) {
- if (!nodes[n].fixed) {
- 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.fixed) 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
- }
- // 最小距离约束
- for (var j = 0; j < nodes.length; j++) {
- if (i === j) continue
- var other = nodes[j]
- var dx = other.x - node.x
- var dy = other.y - node.y
- var dist = Math.sqrt(dx * dx + dy * dy) || 0.1
- var minDist = node.radius + other.radius
- if (dist < minDist && dist > 0) {
- var overlap = (minDist - dist) / 2
- var nx = dx / dist
- var ny = dy / dist
- node.x -= nx * overlap
- node.y -= ny * overlap
- other.x += nx * overlap
- other.y += ny * overlap
- }
- }
- 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 降级)
- 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 intimacy = edge.closeness // 亲密度 → 连线粗细
- var trust = edge.trust // 信任度 → 连线颜色(低红高绿)
- var comm = edge.communication // 沟通度 → 线形(越多越实)
- var hasData = intimacy !== null || trust !== null || comm !== null
- if (!hasData) {
- // 无数据: 默认黑色实线 1px
- ctx.lineWidth = 1
- ctx.setLineDash([])
- ctx.strokeStyle = '#000000'
- } else {
- // 连线粗细 = 亲密度 (1-6px, 越高越粗)
- var intVal = intimacy != null ? intimacy : 50
- ctx.lineWidth = 1 + (intVal / 100) * 5
- // 连线颜色 = 信任度 (低=红, 高=绿)
- var trustVal = trust != null ? trust : 50
- var green = Math.round((trustVal / 100) * 180)
- var red = Math.round((1 - trustVal / 100) * 200 + 55)
- ctx.strokeStyle = 'rgba(' + red + ',' + green + ', 60, 0.7)'
- // 虚线密度 = 沟通度 (越多越实)
- var commVal = comm != null ? comm : 50
- if (commVal >= 90) {
- ctx.setLineDash([])
- } else if (commVal >= 70) {
- ctx.setLineDash([8, 3])
- } else if (commVal >= 50) {
- ctx.setLineDash([5, 5])
- } else if (commVal >= 30) {
- ctx.setLineDash([3, 8])
- } else {
- ctx.setLineDash([2, 12])
- }
- }
- ctx.beginPath()
- ctx.moveTo(s.x, s.y)
- ctx.lineTo(t.x, t.y)
- ctx.stroke()
- }
- },
- // ===== 身份 → 形状映射 =====
- getNodeShapeType: function(node) {
- var role = node.effectiveRole || node.relativeLabel || ''
- var type = node.memberType || ''
- // 长辈 (爷爷/奶奶/外公/外婆)
- if (role.indexOf('爷爷') !== -1 || role.indexOf('奶奶') !== -1 ||
- role.indexOf('外公') !== -1 || role.indexOf('外婆') !== -1 ||
- role.indexOf('祖父') !== -1 || role.indexOf('祖母') !== -1) {
- return 'diamond'
- }
- // 父亲/男性家长
- if (role.indexOf('爸爸') !== -1 || role.indexOf('父亲') !== -1 ||
- role.indexOf('爸') !== -1 || role.indexOf('爹') !== -1) {
- return 'roundedRect'
- }
- // 母亲/女性家长
- if (role.indexOf('妈妈') !== -1 || role.indexOf('母亲') !== -1 ||
- role.indexOf('妈') !== -1 || role.indexOf('娘') !== -1) {
- return 'circle'
- }
- // 通用 parent → 根据性别区分
- if (type === 'parent') {
- return node.gender === 'male' ? 'roundedRect' : 'circle'
- }
- // child → 圆形
- if (type === 'child') {
- return 'childCircle'
- }
- return 'circle'
- },
- // 绘制圆角矩形
- drawRoundedRectNode: function(ctx, x, y, r) {
- var w = r * 1.8
- var h = r * 1.8
- var cr = r * 0.3
- ctx.beginPath()
- ctx.moveTo(x - w/2 + cr, y - h/2)
- ctx.lineTo(x + w/2 - cr, y - h/2)
- ctx.arcTo(x + w/2, y - h/2, x + w/2, y - h/2 + cr, cr)
- ctx.lineTo(x + w/2, y + h/2 - cr)
- ctx.arcTo(x + w/2, y + h/2, x + w/2 - cr, y + h/2, cr)
- ctx.lineTo(x - w/2 + cr, y + h/2)
- ctx.arcTo(x - w/2, y + h/2, x - w/2, y + h/2 - cr, cr)
- ctx.lineTo(x - w/2, y - h/2 + cr)
- ctx.arcTo(x - w/2, y - h/2, x - w/2 + cr, y - h/2, cr)
- ctx.closePath()
- },
- // 绘制菱形
- drawDiamondNode: function(ctx, x, y, r) {
- ctx.beginPath()
- ctx.moveTo(x, y - r * 1.2)
- ctx.lineTo(x + r * 1.2, y)
- ctx.lineTo(x, y + r * 1.2)
- ctx.lineTo(x - r * 1.2, y)
- ctx.closePath()
- },
- drawNodes: function(ctx) {
- var nodes = this.nodeData
- for (var i = 0; i < nodes.length; i++) {
- var node = nodes[i]
- var r = node.radius
- var shapeType = this.getNodeShapeType(node)
- // 按身份绘制不同形状
- if (shapeType === 'roundedRect') {
- this.drawRoundedRectNode(ctx, node.x, node.y, r)
- } else if (shapeType === 'diamond') {
- this.drawDiamondNode(ctx, node.x, node.y, r)
- } else {
- // 默认圆形 (母亲/女性家长/孩子)
- 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.hexToRgba(this.themeColor, 0.13)
- 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)
- }
- },
- // WeChat mini-program compatible fallback renderer.
- // Note: uni-app legacy canvas uses setFontSize(size), not setFont().
- renderLegacy: function() {
- if (this._destroyed) return
- try {
- 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 intimacy = edge.closeness // 亲密度 → 连线粗细
- var trust = edge.trust // 信任度 → 连线颜色(低红高绿)
- var comm = edge.communication // 沟通度 → 线形(越多越实)
- var hasData = intimacy !== null || trust !== null || comm !== null
- if (!hasData) {
- // 无数据: 默认黑色实线 1px
- ctx.setLineWidth(1)
- ctx.setLineDash([])
- ctx.setStrokeStyle('#000000')
- } else {
- // 连线粗细 = 亲密度
- var intVal = intimacy != null ? intimacy : 50
- ctx.setLineWidth(1 + (intVal / 100) * 5)
- // 连线颜色 = 信任度 (低=红, 高=绿)
- var trustVal = trust != null ? trust : 50
- var green = Math.round((trustVal / 100) * 180)
- var red = Math.round((1 - trustVal / 100) * 200 + 55)
- ctx.setStrokeStyle('rgba(' + red + ',' + green + ', 60, 0.7)')
- // 虚线密度 = 沟通度 (越多越实)
- var commVal = comm != null ? comm : 50
- if (commVal >= 90) {
- ctx.setLineDash([])
- } else if (commVal >= 70) {
- ctx.setLineDash([8, 3])
- } else if (commVal >= 50) {
- ctx.setLineDash([5, 5])
- } else if (commVal >= 30) {
- ctx.setLineDash([3, 8])
- } else {
- ctx.setLineDash([2, 12])
- }
- }
- ctx.beginPath()
- ctx.moveTo(edge.source.x, edge.source.y)
- ctx.lineTo(edge.target.x, edge.target.y)
- ctx.stroke()
- }
- var nodes = this.nodeData
- for (var j = 0; j < nodes.length; j++) {
- var node = nodes[j]
- var r = node.radius
- var shapeType = this.getNodeShapeType(node)
- // legacy API 的形状绘制
- if (shapeType === 'roundedRect') {
- var w = r * 1.8
- var h = r * 1.8
- var cr = r * 0.3
- ctx.beginPath()
- ctx.moveTo(node.x - w/2 + cr, node.y - h/2)
- ctx.lineTo(node.x + w/2 - cr, node.y - h/2)
- ctx.arcTo(node.x + w/2, node.y - h/2, node.x + w/2, node.y - h/2 + cr, cr)
- ctx.lineTo(node.x + w/2, node.y + h/2 - cr)
- ctx.arcTo(node.x + w/2, node.y + h/2, node.x + w/2 - cr, node.y + h/2, cr)
- ctx.lineTo(node.x - w/2 + cr, node.y + h/2)
- ctx.arcTo(node.x - w/2, node.y + h/2, node.x - w/2, node.y + h/2 - cr, cr)
- ctx.lineTo(node.x - w/2, node.y - h/2 + cr)
- ctx.arcTo(node.x - w/2, node.y - h/2, node.x - w/2 + cr, node.y - h/2, cr)
- ctx.closePath()
- } else if (shapeType === 'diamond') {
- ctx.beginPath()
- ctx.moveTo(node.x, node.y - r * 1.2)
- ctx.lineTo(node.x + r * 1.2, node.y)
- ctx.lineTo(node.x, node.y + r * 1.2)
- ctx.lineTo(node.x - r * 1.2, node.y)
- ctx.closePath()
- } else {
- 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.hexToRgba(this.themeColor, 0.13))
- ctx.fill()
- }
- ctx.setFillStyle(node.isSelf ? this.themeColor : '#4A5568')
- ctx.setFontSize(Math.max(10, Math.round(r * 0.9)))
- ctx.setTextAlign('center')
- ctx.setTextBaseline('middle')
- ctx.fillText(node.displayName, node.x, node.y)
- ctx.setFillStyle(node.isSelf ? this.themeColor : '#94A3B8')
- ctx.setFontSize(Math.max(9, Math.round(r * 0.4)))
- ctx.fillText((node.nickname || '').substring(0, 3), node.x, node.y + r + 10)
- }
- ctx.draw()
- } catch (err) {
- // Legacy canvas fallback must never crash page initialization.
- }
- },
- // ===== 交互 =====
- getTouchNode: function(touchX, touchY) {
- var nodes = this.nodeData
- 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 }
- node.fx = node.x
- node.fy = node.y
- node.fixed = true
- }
- }
- }
- }.bind(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 rect = {}
- var self = this
- 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
- node.fx = node.x
- node.fy = node.y
- }
- }.bind(this))
- },
- onTouchEnd: function(e) {
- // 如果正在触摸自身节点 → 跳转成员维护页
- if (this.touchingSelf) {
- this.touchingSelf = false
- if (this.interactive) {
- this.goSelfMaintenance()
- }
- 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
- node.fixed = false
- node.fx = 0
- node.fy = 0
- // 如果没有真正拖拽(点击),触发 memberTap
- if (!wasDragged && this.interactive) {
- this.$emit('memberTap', {
- memberId: node.id,
- memberType: node.memberType,
- nickname: node.nickname
- })
- }
- this.dragging = null
- this.dragStartPos = null
- },
- // 跳转到成员维护页面 (点自己进入维护)
- goSelfMaintenance: function() {
- uni.navigateTo({
- url: '/pages/profile-extra/family-members'
- })
- },
- // 点击右上角小齿轮 → 跳转成员管理页
- onManageTap: function() {
- uni.navigateTo({
- url: '/pages/profile-extra/family-members'
- })
- },
- // ===== 工具方法 =====
- 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) return null
- var val = data[key]
- return (typeof val !== 'undefined' && val !== null) ? val : null
- },
- updateCanvasSize: function() {
- var self = this
- var cnt = this.members ? this.members.length : 0
- self.canvasHeight = Math.max(200, cnt * 90)
- var query = uni.createSelectorQuery().in(this)
- query.select('.family-graph-wrapper').boundingClientRect(function(rect) {
- if (rect) {
- self.canvasWidth = rect.width || 340
- }
- }).exec()
- }
- }
- }
- </script>
- <style scoped>
- .family-graph-wrapper {
- position: relative;
- width: 100%;
- min-height: 200px;
- height: auto;
- 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>
|