|
|
@@ -0,0 +1,919 @@
|
|
|
+<template>
|
|
|
+ <!-- 章鱼图 - 基于 Canvas force-directed 物理引擎 -->
|
|
|
+ <view class="octopus-graph-wrapper" v-if="tentacles && tentacles.length > 0" :style="{ minHeight: canvasHeight + 'px' }">
|
|
|
+ <canvas
|
|
|
+ class="octopus-graph-canvas"
|
|
|
+ canvas-id="octopusGraphCanvas"
|
|
|
+ id="octopusGraphCanvas"
|
|
|
+ type="2d"
|
|
|
+ :style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
|
|
|
+ @touchstart="onTouchStart"
|
|
|
+ @touchmove="onTouchMove"
|
|
|
+ @touchend="onTouchEnd" />
|
|
|
+ <!-- 点击节点时显示详情弹窗 -->
|
|
|
+ <view class="octopus-detail-popup" v-if="selectedTentacle" :style="popupPosition">
|
|
|
+ <view class="popup-content">
|
|
|
+ <view class="popup-header">
|
|
|
+ <image class="popup-avatar" :src="selectedTentacle.avatar || defaultAvatar" mode="aspectFill" />
|
|
|
+ <view class="popup-info">
|
|
|
+ <text class="popup-name">{{ selectedTentacle.name }}</text>
|
|
|
+ <text class="popup-relation" v-if="selectedTentacle.relationshipType">{{ selectedTentacle.relationshipType }}</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ <view class="popup-stats">
|
|
|
+ <view class="stat-item">
|
|
|
+ <text class="stat-value">{{ selectedTentacle.helpCount }}</text>
|
|
|
+ <text class="stat-label">次数</text>
|
|
|
+ </view>
|
|
|
+ <view class="stat-divider"></view>
|
|
|
+ <view class="stat-item">
|
|
|
+ <text class="stat-value">¥{{ formatAmount(selectedTentacle.totalAmount) }}</text>
|
|
|
+ <text class="stat-label">金额</text>
|
|
|
+ </view>
|
|
|
+ <view class="stat-divider"></view>
|
|
|
+ <view class="stat-item">
|
|
|
+ <text class="stat-value">{{ selectedTentacle.mainHelpTypeName }}</text>
|
|
|
+ <text class="stat-label">主导类型</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ <view class="popup-actions">
|
|
|
+ <text class="action-btn" @tap="viewRecords(selectedTentacle.contactId)">查看明细</text>
|
|
|
+ </view>
|
|
|
+ <view class="popup-arrow" :style="arrowPosition"></view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ <!-- 空状态 -->
|
|
|
+ <view class="octopus-graph-empty" v-else-if="tentacles && tentacles.length === 0">
|
|
|
+ <text class="empty-icon">🐙</text>
|
|
|
+ <text class="empty-text">暂无章鱼图数据</text>
|
|
|
+ <text class="empty-hint">记录谁帮助过你,画出你的触手图</text>
|
|
|
+ <text class="action-btn" @tap="goAddHelp">+ 记录一次帮助</text>
|
|
|
+ </view>
|
|
|
+ <!-- 加载状态 -->
|
|
|
+ <view class="octopus-loading" v-else>
|
|
|
+ <text class="loading-text">加载章鱼图...</text>
|
|
|
+ </view>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+/**
|
|
|
+ * OctopusDiagram - 章鱼图 Canvas 组件
|
|
|
+ *
|
|
|
+ * 复用 FamilyRelationGraph 的 force-directed 物理引擎:
|
|
|
+ * - 中心节点 = 当前用户(固定不动)
|
|
|
+ * - 触手节点 = 帮助过我的人(分布在四周)
|
|
|
+ * - 节点大小 = 帮助次数
|
|
|
+ * - 节点颜色 = 主导帮助类型
|
|
|
+ * - 触手末端标注 = 帮助金额
|
|
|
+ * - 点击节点 → 显示帮助明细
|
|
|
+ *
|
|
|
+ * 数据源:GET /api/octopus/tentacles
|
|
|
+ * 返回字段:contactId, name, avatar, relationshipType, helpCount, totalAmount,
|
|
|
+ * lastHelpedAt, mainHelpType, mainHelpTypeName, radius
|
|
|
+ */
|
|
|
+export default {
|
|
|
+ name: 'OctopusDiagram',
|
|
|
+ props: {
|
|
|
+ /** 当前用户ID(可从 storage 获取,也可由父组件传入) */
|
|
|
+ selfId: {
|
|
|
+ type: [Number, String],
|
|
|
+ default: null
|
|
|
+ },
|
|
|
+ /** 画布宽度 rpx */
|
|
|
+ canvasWidth: {
|
|
|
+ type: Number,
|
|
|
+ default: 340
|
|
|
+ },
|
|
|
+ /** 画布高度 rpx */
|
|
|
+ canvasHeight: {
|
|
|
+ type: Number,
|
|
|
+ default: 280
|
|
|
+ },
|
|
|
+ /** 是否允许交互(点击节点查看明细) */
|
|
|
+ interactive: {
|
|
|
+ type: Boolean,
|
|
|
+ default: true
|
|
|
+ }
|
|
|
+ },
|
|
|
+ data: function() {
|
|
|
+ return {
|
|
|
+ dpr: 1,
|
|
|
+ // 物理参数
|
|
|
+ physics: {
|
|
|
+ repulsion: 600, // 节点间斥力强度
|
|
|
+ springLength: 140, // 弹簧自然长度(中心到触手距离)
|
|
|
+ springStrength: 0.05, // 弹簧劲度系数
|
|
|
+ centering: 0.02, // 中心引力
|
|
|
+ damping: 0.90, // 速度阻尼
|
|
|
+ maxSpeed: 10, // 最大速度
|
|
|
+ minDistance: 40 // 最小节点距离
|
|
|
+ },
|
|
|
+ // 动画状态
|
|
|
+ dragging: null, // { nodeIndex, offsetX, offsetY }
|
|
|
+ dragStartPos: null,
|
|
|
+ animFrameId: null,
|
|
|
+ isSimulating: false,
|
|
|
+ stillFrames: 0,
|
|
|
+ energyThreshold: 1.5,
|
|
|
+ stillFrameLimit: 30,
|
|
|
+ // 节点数据
|
|
|
+ nodeData: [],
|
|
|
+ // 边数据(中心到每个触手)
|
|
|
+ edges: [],
|
|
|
+ // 触手数据(从 API 获取)
|
|
|
+ tentacles: [],
|
|
|
+ // 选中的触手(用于显示弹窗)
|
|
|
+ selectedTentacle: null,
|
|
|
+ // 默认头像
|
|
|
+ defaultAvatar: '/static/default-avatar.png',
|
|
|
+ // 是否销毁
|
|
|
+ _destroyed: false,
|
|
|
+ // 类型颜色映射
|
|
|
+ typeColors: {
|
|
|
+ MONEY: '#FF8C42', // 橙 - 金钱
|
|
|
+ ITEM: '#10B981', // 绿 - 物质
|
|
|
+ EMOTION: '#FF6B9D', // 粉 - 情感
|
|
|
+ RESOURCE: '#6366F1', // 蓝 - 资源
|
|
|
+ SKILL: '#F59E0B', // 金 - 技能
|
|
|
+ TIME: '#8B5CF6', // 紫 - 时间
|
|
|
+ OTHER: '#94A3B8' // 灰 - 其他
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ computed: {
|
|
|
+ // 中心节点(自己)
|
|
|
+ centerNode: function() {
|
|
|
+ return this.nodeData.find(function(n) { return n.isCenter })
|
|
|
+ }
|
|
|
+ },
|
|
|
+ watch: {
|
|
|
+ tentacles: {
|
|
|
+ handler: function() {
|
|
|
+ this.buildNodesAndEdges()
|
|
|
+ this.resetSimulation()
|
|
|
+ },
|
|
|
+ immediate: true,
|
|
|
+ deep: true
|
|
|
+ },
|
|
|
+ canvasWidth: function() {
|
|
|
+ this.resetSimulation()
|
|
|
+ },
|
|
|
+ canvasHeight: function() {
|
|
|
+ this.resetSimulation()
|
|
|
+ }
|
|
|
+ },
|
|
|
+ mounted: function() {
|
|
|
+ this._destroyed = false
|
|
|
+ var self = this
|
|
|
+ try {
|
|
|
+ this.dpr = uni.getWindowInfo().pixelRatio || 1
|
|
|
+ } catch (e) {
|
|
|
+ this.dpr = 1
|
|
|
+ }
|
|
|
+ // 延迟初始化 canvas(等待 DOM 布局完成)
|
|
|
+ var retryCount = 0
|
|
|
+ var maxRetry = 5
|
|
|
+ function tryInit() {
|
|
|
+ if (self._destroyed) return
|
|
|
+ self.initCanvas(function(success) {
|
|
|
+ if (self._destroyed) return
|
|
|
+ if (!success && retryCount < maxRetry) {
|
|
|
+ retryCount++
|
|
|
+ setTimeout(tryInit, 60)
|
|
|
+ } else if (!success) {
|
|
|
+ console.log('[OctopusDiagram] canvas 初始化失败,重试 ' + maxRetry + ' 次后放弃')
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+ this.$nextTick(function() {
|
|
|
+ if (self._destroyed) return
|
|
|
+ self.updateCanvasSize()
|
|
|
+ self.$nextTick(function() {
|
|
|
+ if (!self._destroyed) {
|
|
|
+ tryInit()
|
|
|
+ }
|
|
|
+ })
|
|
|
+ })
|
|
|
+ },
|
|
|
+ beforeDestroy: function() {
|
|
|
+ this._destroyed = true
|
|
|
+ this.stopSimulation()
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ /* ===========================
|
|
|
+ * 数据构建
|
|
|
+ * =========================== */
|
|
|
+
|
|
|
+ buildNodesAndEdges: function() {
|
|
|
+ if (!this.tentacles || this.tentacles.length === 0) {
|
|
|
+ this.nodeData = []
|
|
|
+ this.edges = []
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ var centerX = this.canvasWidth / 2
|
|
|
+ var centerY = this.canvasHeight / 2
|
|
|
+ var self = this
|
|
|
+
|
|
|
+ // 1. 中心节点(自己)
|
|
|
+ var centerNode = {
|
|
|
+ id: 'center',
|
|
|
+ isCenter: true,
|
|
|
+ nickname: '我',
|
|
|
+ displayName: '我',
|
|
|
+ radius: 32,
|
|
|
+ x: centerX,
|
|
|
+ y: centerY,
|
|
|
+ vx: 0,
|
|
|
+ vy: 0,
|
|
|
+ fx: 0,
|
|
|
+ fy: 0,
|
|
|
+ fixed: true
|
|
|
+ }
|
|
|
+
|
|
|
+ // 2. 触手节点(帮助过我的人)
|
|
|
+ var tentacleNodes = this.tentacles.map(function(t, idx) {
|
|
|
+ // 初始位置:圆形分布
|
|
|
+ var angle = (2 * Math.PI * idx) / self.tentacles.length
|
|
|
+ var r = Math.min(self.canvasWidth, self.canvasHeight) * 0.38
|
|
|
+ var initX = centerX + r * Math.cos(angle)
|
|
|
+ var initY = centerY + r * Math.sin(angle)
|
|
|
+
|
|
|
+ return {
|
|
|
+ id: 'tentacle_' + t.contactId,
|
|
|
+ isCenter: false,
|
|
|
+ contactId: t.contactId,
|
|
|
+ nickname: t.name,
|
|
|
+ displayName: t.name ? t.name.charAt(0) : '?',
|
|
|
+ avatar: t.avatar,
|
|
|
+ relationshipType: t.relationshipType,
|
|
|
+ helpCount: t.helpCount,
|
|
|
+ totalAmount: t.totalAmount,
|
|
|
+ mainHelpType: t.mainHelpType,
|
|
|
+ mainHelpTypeName: t.mainHelpTypeName,
|
|
|
+ radius: t.radius || 28,
|
|
|
+ color: self.typeColors[t.mainHelpType] || self.typeColors.OTHER,
|
|
|
+ x: initX,
|
|
|
+ y: initY,
|
|
|
+ vx: 0,
|
|
|
+ vy: 0,
|
|
|
+ fx: 0,
|
|
|
+ fy: 0,
|
|
|
+ fixed: false
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ this.nodeData = [centerNode].concat(tentacleNodes)
|
|
|
+
|
|
|
+ // 3. 边:中心连接到每个触手
|
|
|
+ this.edges = tentacleNodes.map(function(n) {
|
|
|
+ return {
|
|
|
+ source: centerNode,
|
|
|
+ target: n,
|
|
|
+ helpCount: n.helpCount,
|
|
|
+ totalAmount: n.totalAmount
|
|
|
+ }
|
|
|
+ })
|
|
|
+ },
|
|
|
+
|
|
|
+ /* ===========================
|
|
|
+ * 物理引擎
|
|
|
+ * =========================== */
|
|
|
+
|
|
|
+ resetSimulation: function() {
|
|
|
+ this.stopSimulation()
|
|
|
+ // 重新初始化位置
|
|
|
+ var centerX = this.canvasWidth / 2
|
|
|
+ var centerY = this.canvasHeight / 2
|
|
|
+ var self = this
|
|
|
+ this.nodeData.forEach(function(n, idx) {
|
|
|
+ if (!n.isCenter) {
|
|
|
+ var angle = (2 * Math.PI * (idx - 1)) / Math.max(1, self.nodeData.length - 1)
|
|
|
+ var r = Math.min(self.canvasWidth, self.canvasHeight) * 0.38
|
|
|
+ n.x = centerX + r * Math.cos(angle)
|
|
|
+ n.y = centerY + r * Math.sin(angle)
|
|
|
+ } else {
|
|
|
+ n.x = centerX
|
|
|
+ n.y = centerY
|
|
|
+ }
|
|
|
+ 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()
|
|
|
+ },
|
|
|
+
|
|
|
+ startSimulation: function() {
|
|
|
+ if (this.isSimulating) return
|
|
|
+ this.isSimulating = true
|
|
|
+ this.stillFrames = 0
|
|
|
+ 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 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 i = 1; i < nodes.length; i++) { // 跳过中心节点(索引0)
|
|
|
+ for (var j = i + 1; j < nodes.length; j++) {
|
|
|
+ var dx = nodes[j].x - nodes[i].x
|
|
|
+ var dy = nodes[j].y - nodes[i].y
|
|
|
+ var dist = Math.sqrt(dx * dx + dy * dy) || 0.1
|
|
|
+ if (dist < p.minDistance) {
|
|
|
+ var force = p.repulsion / (dist * dist)
|
|
|
+ var fx = force * dx / dist
|
|
|
+ var fy = force * dy / dist
|
|
|
+ nodes[i].fx -= fx
|
|
|
+ nodes[i].fy -= fy
|
|
|
+ nodes[j].fx += fx
|
|
|
+ nodes[j].fy += fy
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 3. 边弹簧引力(中心 -> 触手)
|
|
|
+ for (var k = 0; k < edges.length; k++) {
|
|
|
+ var e = edges[k]
|
|
|
+ var dx = e.target.x - e.source.x
|
|
|
+ var dy = e.target.y - e.source.y
|
|
|
+ var dist = Math.sqrt(dx * dx + dy * dy) || 0.1
|
|
|
+ var force = p.springStrength * (dist - p.springLength)
|
|
|
+ var fx = force * dx / dist
|
|
|
+ var fy = force * dy / dist
|
|
|
+ if (!e.target.fixed) {
|
|
|
+ e.target.fx += fx
|
|
|
+ e.target.fy += fy
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 4. 中心引力(将触手轻轻拉向画布中心)
|
|
|
+ for (var m = 1; m < nodes.length; m++) {
|
|
|
+ var dx = (this.canvasWidth / 2) - nodes[m].x
|
|
|
+ var dy = (this.canvasHeight / 2) - nodes[m].y
|
|
|
+ var dist = Math.sqrt(dx * dx + dy * dy) || 0.1
|
|
|
+ var force = p.centering * dist
|
|
|
+ if (!nodes[m].fixed) {
|
|
|
+ nodes[m].fx += force * dx / dist
|
|
|
+ nodes[m].fy += force * dy / dist
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 5. 更新速度和位置
|
|
|
+ var totalEnergy = 0
|
|
|
+ for (var n = 1; n < nodes.length; n++) { // 跳过中心节点
|
|
|
+ if (nodes[n].fixed) continue
|
|
|
+ nodes[n].vx = (nodes[n].vx + nodes[n].fx) * p.damping
|
|
|
+ nodes[n].vy = (nodes[n].vy + nodes[n].fy) * p.damping
|
|
|
+ var speed = Math.sqrt(nodes[n].vx * nodes[n].vx + nodes[n].vy * nodes[n].vy)
|
|
|
+ if (speed > p.maxSpeed) {
|
|
|
+ nodes[n].vx = nodes[n].vx / speed * p.maxSpeed
|
|
|
+ nodes[n].vy = nodes[n].vy / speed * p.maxSpeed
|
|
|
+ }
|
|
|
+ nodes[n].x += nodes[n].vx
|
|
|
+ nodes[n].y += nodes[n].vy
|
|
|
+ totalEnergy += speed * speed
|
|
|
+ }
|
|
|
+
|
|
|
+ // 6. 渲染
|
|
|
+ this._doRender()
|
|
|
+
|
|
|
+ // 7. 收敛检测
|
|
|
+ if (totalEnergy < p.energyThreshold) {
|
|
|
+ this.stillFrames++
|
|
|
+ } else {
|
|
|
+ this.stillFrames = 0
|
|
|
+ }
|
|
|
+ if (this.stillFrames >= p.stillFrameLimit) {
|
|
|
+ this.stopSimulation()
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // 8. 下一帧
|
|
|
+ var self = this
|
|
|
+ this.animFrameId = setTimeout(function() {
|
|
|
+ self.simulationTick()
|
|
|
+ }, 16)
|
|
|
+ },
|
|
|
+
|
|
|
+ /* ===========================
|
|
|
+ * 渲染
|
|
|
+ * =========================== */
|
|
|
+
|
|
|
+ _doRender: function() {
|
|
|
+ if (!this.ctx) { return }
|
|
|
+ var ctx = this.ctx
|
|
|
+ var w = this.canvasWidth
|
|
|
+ var h = this.canvasHeight
|
|
|
+
|
|
|
+ ctx.clearRect(0, 0, w, h)
|
|
|
+
|
|
|
+ // 1. 绘制边(触手线条)
|
|
|
+ this._drawEdges(ctx)
|
|
|
+
|
|
|
+ // 2. 绘制节点
|
|
|
+ for (var i = 0; i < this.nodeData.length; i++) {
|
|
|
+ this._drawNode(ctx, this.nodeData[i])
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ _drawEdges: function(ctx) {
|
|
|
+ var self = this
|
|
|
+ this.edges.forEach(function(e) {
|
|
|
+ var source = e.source
|
|
|
+ var target = e.target
|
|
|
+
|
|
|
+ // 贝塞尔曲线模拟触手弯曲
|
|
|
+ var cx = (source.x + target.x) / 2
|
|
|
+ var cy = (source.y + target.y) / 2
|
|
|
+ // 偏移控制点,制造弯曲感
|
|
|
+ var offsetX = (target.y - source.y) * 0.15
|
|
|
+ var offsetY = -(target.x - source.x) * 0.15
|
|
|
+
|
|
|
+ ctx.beginPath()
|
|
|
+ ctx.moveTo(source.x, source.y)
|
|
|
+ ctx.quadraticCurveTo(cx + offsetX, cy + offsetY, target.x, target.y)
|
|
|
+
|
|
|
+ // 线条宽度随帮助次数变化
|
|
|
+ var lineWidth = 2 + Math.min(4, e.helpCount * 0.5)
|
|
|
+ ctx.lineWidth = lineWidth
|
|
|
+ // 线条颜色用目标节点颜色,透明度递减
|
|
|
+ ctx.strokeStyle = self._hexToRgba(target.color, 0.4)
|
|
|
+ ctx.stroke()
|
|
|
+
|
|
|
+ // 金额标注(在触手末端附近)
|
|
|
+ if (e.totalAmount && e.totalAmount > 0) {
|
|
|
+ var labelX = (source.x + target.x) / 2
|
|
|
+ var labelY = (source.y + target.y) / 2 - 20
|
|
|
+ ctx.font = '12px PingFang SC, sans-serif'
|
|
|
+ ctx.fillStyle = '#FF8C42'
|
|
|
+ ctx.textAlign = 'center'
|
|
|
+ ctx.fillText('¥' + self._formatAmountShort(e.totalAmount), labelX, labelY)
|
|
|
+ }
|
|
|
+ })
|
|
|
+ },
|
|
|
+
|
|
|
+ _drawNode: function(ctx, node) {
|
|
|
+ var r = node.radius
|
|
|
+ var x = node.x
|
|
|
+ var y = node.y
|
|
|
+
|
|
|
+ // 裁剪到画布范围
|
|
|
+ if (x - r > this.canvasWidth || x + r < 0 || y - r > this.canvasHeight || y + r < 0) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if (node.isCenter) {
|
|
|
+ // 中心节点:实心圆 + "我" 字
|
|
|
+ ctx.beginPath()
|
|
|
+ ctx.arc(x, y, r, 0, Math.PI * 2)
|
|
|
+ ctx.fillStyle = '#10B981'
|
|
|
+ ctx.fill()
|
|
|
+ ctx.strokeStyle = '#FFFFFF'
|
|
|
+ ctx.lineWidth = 2
|
|
|
+ ctx.stroke()
|
|
|
+
|
|
|
+ ctx.font = 'bold 18px PingFang SC, sans-serif'
|
|
|
+ ctx.fillStyle = '#FFFFFF'
|
|
|
+ ctx.textAlign = 'center'
|
|
|
+ ctx.textBaseline = 'middle'
|
|
|
+ ctx.fillText('我', x, y + 1)
|
|
|
+ } else {
|
|
|
+ // 触手节点:实心圆 + 首字
|
|
|
+ ctx.beginPath()
|
|
|
+ ctx.arc(x, y, r, 0, Math.PI * 2)
|
|
|
+ ctx.fillStyle = node.color
|
|
|
+ ctx.fill()
|
|
|
+
|
|
|
+ // 白边
|
|
|
+ ctx.strokeStyle = '#FFFFFF'
|
|
|
+ ctx.lineWidth = 2
|
|
|
+ ctx.stroke()
|
|
|
+
|
|
|
+ // 首字
|
|
|
+ ctx.font = 'bold ' + Math.max(14, r * 0.7) + 'px PingFang SC, sans-serif'
|
|
|
+ ctx.fillStyle = '#FFFFFF'
|
|
|
+ ctx.textAlign = 'center'
|
|
|
+ ctx.textBaseline = 'middle'
|
|
|
+ ctx.fillText(node.displayName, x, y + 1)
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ /* ===========================
|
|
|
+ * 交互
|
|
|
+ * =========================== */
|
|
|
+
|
|
|
+ _hitTest: function(x, y) {
|
|
|
+ // 从后往前测试,优先触手节点
|
|
|
+ for (var i = this.nodeData.length - 1; i >= 0; i--) {
|
|
|
+ var node = this.nodeData[i]
|
|
|
+ var dx = x - node.x
|
|
|
+ var dy = y - node.y
|
|
|
+ if (dx * dx + dy * dy <= node.radius * node.radius) {
|
|
|
+ return i
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return -1
|
|
|
+ },
|
|
|
+
|
|
|
+ onTouchStart: function(e) {
|
|
|
+ if (!this.interactive) return
|
|
|
+ var touches = e.touches || e.changedTouches
|
|
|
+ if (!touches || touches.length === 0) return
|
|
|
+ var touch = touches[0]
|
|
|
+ var canvasRect = this._getCanvasRect()
|
|
|
+ if (!canvasRect) return
|
|
|
+ var x = touch.clientX - canvasRect.left
|
|
|
+ var y = touch.clientY - canvasRect.top
|
|
|
+
|
|
|
+ var hitIndex = this._hitTest(x, y)
|
|
|
+ if (hitIndex >= 0 && !this.nodeData[hitIndex].isCenter) {
|
|
|
+ this.dragging = {
|
|
|
+ nodeIndex: hitIndex,
|
|
|
+ offsetX: x - this.nodeData[hitIndex].x,
|
|
|
+ offsetY: y - this.nodeData[hitIndex].y
|
|
|
+ }
|
|
|
+ this.dragStartPos = { x: x, y: y }
|
|
|
+ this.nodeData[hitIndex].fixed = true
|
|
|
+ } else if (hitIndex >= 0 && this.nodeData[hitIndex].isCenter) {
|
|
|
+ // 点击中心节点不拖拽
|
|
|
+ } else {
|
|
|
+ // 点击空白处关闭弹窗
|
|
|
+ this.selectedTentacle = null
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ onTouchMove: function(e) {
|
|
|
+ if (!this.dragging) return
|
|
|
+ var touches = e.touches || e.changedTouches
|
|
|
+ if (!touches || touches.length === 0) return
|
|
|
+ var touch = touches[0]
|
|
|
+ var canvasRect = this._getCanvasRect()
|
|
|
+ if (!canvasRect) return
|
|
|
+ var x = touch.clientX - canvasRect.left
|
|
|
+ var y = touch.clientY - canvasRect.top
|
|
|
+
|
|
|
+ var node = this.nodeData[this.dragging.nodeIndex]
|
|
|
+ node.x = x - this.dragging.offsetX
|
|
|
+ node.y = y - this.dragging.offsetY
|
|
|
+ node.vx = 0
|
|
|
+ node.vy = 0
|
|
|
+
|
|
|
+ if (!this.isSimulating) {
|
|
|
+ this._doRender()
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ onTouchEnd: function(e) {
|
|
|
+ if (this.dragging) {
|
|
|
+ var node = this.nodeData[this.dragging.nodeIndex]
|
|
|
+ node.fixed = false
|
|
|
+ this.dragging = null
|
|
|
+
|
|
|
+ // 判断是否点击(移动距离很小)
|
|
|
+ if (this.dragStartPos) {
|
|
|
+ var touches = e.changedTouches
|
|
|
+ if (touches && touches.length > 0) {
|
|
|
+ var canvasRect = this._getCanvasRect()
|
|
|
+ if (canvasRect) {
|
|
|
+ var x = touches[0].clientX - canvasRect.left
|
|
|
+ var y = touches[0].clientY - canvasRect.top
|
|
|
+ var dx = x - this.dragStartPos.x
|
|
|
+ var dy = y - this.dragStartPos.y
|
|
|
+ if (dx * dx + dy * dy < 100) { // 10px 以内算点击
|
|
|
+ this._onNodeClick(this.dragging.nodeIndex)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!this.isSimulating) {
|
|
|
+ this.startSimulation()
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ _onNodeClick: function(nodeIndex) {
|
|
|
+ if (nodeIndex <= 0) return // 中心节点不响应
|
|
|
+ var node = this.nodeData[nodeIndex]
|
|
|
+ if (!node.isCenter) {
|
|
|
+ // 找到对应的触手数据
|
|
|
+ var tentacle = this.tentacles.find(function(t) { return t.contactId == node.contactId })
|
|
|
+ if (tentacle) {
|
|
|
+ this.selectedTentacle = Object.assign({}, tentacle, {
|
|
|
+ contactId: node.contactId
|
|
|
+ })
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ viewRecords: function(contactId) {
|
|
|
+ this.$emit('view-records', contactId)
|
|
|
+ },
|
|
|
+
|
|
|
+ goAddHelp: function() {
|
|
|
+ this.$emit('add-help')
|
|
|
+ },
|
|
|
+
|
|
|
+ /* ===========================
|
|
|
+ * 工具方法
|
|
|
+ * =========================== */
|
|
|
+
|
|
|
+ _getCanvasRect: function() {
|
|
|
+ if (this._canvasRect) {
|
|
|
+ return {
|
|
|
+ left: this._canvasRect.left,
|
|
|
+ top: this._canvasRect.top,
|
|
|
+ width: this.canvasWidth,
|
|
|
+ height: this.canvasHeight
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return {
|
|
|
+ left: 0,
|
|
|
+ top: 0,
|
|
|
+ width: this.canvasWidth,
|
|
|
+ height: this.canvasHeight
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ _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 + ')'
|
|
|
+ },
|
|
|
+
|
|
|
+ _formatAmountShort: function(amount) {
|
|
|
+ var num = typeof amount === 'string' ? parseFloat(amount) : amount
|
|
|
+ if (num >= 10000) {
|
|
|
+ return (num / 10000).toFixed(1) + 'w'
|
|
|
+ }
|
|
|
+ return String(num)
|
|
|
+ },
|
|
|
+
|
|
|
+ formatAmount: function(amount) {
|
|
|
+ var num = typeof amount === 'string' ? parseFloat(amount) : amount
|
|
|
+ return num.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 2 })
|
|
|
+ },
|
|
|
+
|
|
|
+ /* ===========================
|
|
|
+ * Canvas 初始化
|
|
|
+ * =========================== */
|
|
|
+
|
|
|
+ updateCanvasSize: function() {
|
|
|
+ var self = this
|
|
|
+ var query = uni.createSelectorQuery().in(this)
|
|
|
+ query.select('.octopus-graph-wrapper').boundingClientRect(function(rect) {
|
|
|
+ if (rect) {
|
|
|
+ self.canvasWidth = rect.width || 340
|
|
|
+ }
|
|
|
+ }).exec()
|
|
|
+ },
|
|
|
+
|
|
|
+ initCanvas: function(cb) {
|
|
|
+ var self = this
|
|
|
+ uni.createSelectorQuery().in(this)
|
|
|
+ .select('#' + 'octopusGraphCanvas')
|
|
|
+ .fields({ node: true, size: true, rect: true })
|
|
|
+ .exec(function(res) {
|
|
|
+ try {
|
|
|
+ if (self._destroyed) return
|
|
|
+ if (!res || !res[0]) {
|
|
|
+ if (cb) cb(false)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ var info = res[0]
|
|
|
+ if (!info.node) {
|
|
|
+ if (cb) cb(false)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ var canvas = info.node
|
|
|
+ var width = info.width
|
|
|
+ var height = info.height
|
|
|
+ if (!width || !height) {
|
|
|
+ var winWidth = 375
|
|
|
+ try {
|
|
|
+ var winInfo = uni.getWindowInfo()
|
|
|
+ winWidth = winInfo.windowWidth || 375
|
|
|
+ } catch (e2) {}
|
|
|
+ width = Math.round((self.canvasWidth || 340) * winWidth / 750)
|
|
|
+ height = Math.round((self.canvasHeight || 280) * winWidth / 750)
|
|
|
+ }
|
|
|
+
|
|
|
+ var pixelRatio = 2
|
|
|
+ try {
|
|
|
+ var system = uni.getWindowInfo()
|
|
|
+ pixelRatio = system.pixelRatio || 2
|
|
|
+ } catch (e) {}
|
|
|
+
|
|
|
+ canvas.width = width * pixelRatio
|
|
|
+ canvas.height = height * pixelRatio
|
|
|
+
|
|
|
+ var ctx = canvas.getContext('2d')
|
|
|
+ if (!ctx) {
|
|
|
+ if (cb) cb(false)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ ctx.scale(pixelRatio, pixelRatio)
|
|
|
+
|
|
|
+ self.ctx = ctx
|
|
|
+ self._canvasNode = canvas
|
|
|
+ self.canvasWidth = width
|
|
|
+ self.canvasHeight = height
|
|
|
+ self.dpr = pixelRatio
|
|
|
+ self.canvasReady = true
|
|
|
+ // 存储 canvas 在页面中的真实位置(触摸坐标转换用)
|
|
|
+ if (info.rect) {
|
|
|
+ self._canvasRect = { left: info.rect.left || 0, top: info.rect.top || 0 }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (Array.isArray(self.tentacles) && self.tentacles.length > 0) {
|
|
|
+ self.buildNodesAndEdges()
|
|
|
+ self.startSimulation()
|
|
|
+ } else {
|
|
|
+ self._doRender()
|
|
|
+ }
|
|
|
+ if (cb) cb(true)
|
|
|
+ } catch (e) {
|
|
|
+ if (cb) cb(false)
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.octopus-graph-wrapper {
|
|
|
+ position: relative;
|
|
|
+ width: 100%;
|
|
|
+ min-height: 200px;
|
|
|
+ height: auto;
|
|
|
+ margin: 10rpx 0;
|
|
|
+ background: #FFFFFF;
|
|
|
+ border-radius: 24rpx;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+.octopus-graph-canvas {
|
|
|
+ position: absolute;
|
|
|
+ top: 0;
|
|
|
+ left: 0;
|
|
|
+ z-index: 1;
|
|
|
+}
|
|
|
+
|
|
|
+/* 详情弹窗 */
|
|
|
+.octopus-detail-popup {
|
|
|
+ position: absolute;
|
|
|
+ z-index: 10;
|
|
|
+ pointer-events: none;
|
|
|
+ transform: translate(-50%, -100%);
|
|
|
+ margin-top: -16rpx;
|
|
|
+}
|
|
|
+.popup-content {
|
|
|
+ background: #FFFFFF;
|
|
|
+ border-radius: 16rpx;
|
|
|
+ padding: 20rpx;
|
|
|
+ box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.12);
|
|
|
+ min-width: 200rpx;
|
|
|
+ max-width: 280rpx;
|
|
|
+ pointer-events: auto;
|
|
|
+}
|
|
|
+.popup-header {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ margin-bottom: 16rpx;
|
|
|
+}
|
|
|
+.popup-avatar {
|
|
|
+ width: 56rpx;
|
|
|
+ height: 56rpx;
|
|
|
+ border-radius: 50%;
|
|
|
+ margin-right: 12rpx;
|
|
|
+}
|
|
|
+.popup-info {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+}
|
|
|
+.popup-name {
|
|
|
+ font-size: 28rpx;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #1E293B;
|
|
|
+}
|
|
|
+.popup-relation {
|
|
|
+ font-size: 22rpx;
|
|
|
+ color: #64748B;
|
|
|
+ margin-top: 2rpx;
|
|
|
+}
|
|
|
+.popup-stats {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-around;
|
|
|
+ padding: 12rpx 0;
|
|
|
+ border-top: 1rpx solid #F1F5F9;
|
|
|
+ border-bottom: 1rpx solid #F1F5F9;
|
|
|
+ margin-bottom: 16rpx;
|
|
|
+}
|
|
|
+.stat-item {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+}
|
|
|
+.stat-value {
|
|
|
+ font-size: 24rpx;
|
|
|
+ font-weight: 700;
|
|
|
+ color: #10B981;
|
|
|
+}
|
|
|
+.stat-label {
|
|
|
+ font-size: 20rpx;
|
|
|
+ color: #94A3B8;
|
|
|
+ margin-top: 4rpx;
|
|
|
+}
|
|
|
+.stat-divider {
|
|
|
+ width: 1rpx;
|
|
|
+ height: 32rpx;
|
|
|
+ background: linear-gradient(180deg, transparent, #E2E8F0, transparent);
|
|
|
+}
|
|
|
+.popup-actions {
|
|
|
+ text-align: center;
|
|
|
+}
|
|
|
+.action-btn {
|
|
|
+ display: inline-block;
|
|
|
+ padding: 10rpx 24rpx;
|
|
|
+ background: #10B981;
|
|
|
+ color: #FFFFFF;
|
|
|
+ border-radius: 20rpx;
|
|
|
+ font-size: 24rpx;
|
|
|
+}
|
|
|
+.popup-arrow {
|
|
|
+ position: absolute;
|
|
|
+ bottom: -8rpx;
|
|
|
+ left: 50%;
|
|
|
+ margin-left: -8rpx;
|
|
|
+ width: 0;
|
|
|
+ height: 0;
|
|
|
+ border-left: 8rpx solid transparent;
|
|
|
+ border-right: 8rpx solid transparent;
|
|
|
+ border-top: 8rpx solid #FFFFFF;
|
|
|
+}
|
|
|
+
|
|
|
+/* 空状态 */
|
|
|
+.octopus-graph-empty,
|
|
|
+.octopus-loading {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ padding: 60rpx 20rpx;
|
|
|
+ background: #FFFFFF;
|
|
|
+ border-radius: 24rpx;
|
|
|
+ margin: 10rpx 0;
|
|
|
+}
|
|
|
+.empty-icon {
|
|
|
+ font-size: 80rpx;
|
|
|
+ margin-bottom: 16rpx;
|
|
|
+}
|
|
|
+.empty-text {
|
|
|
+ font-size: 28rpx;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #1E293B;
|
|
|
+ margin-bottom: 8rpx;
|
|
|
+}
|
|
|
+.empty-hint {
|
|
|
+ font-size: 24rpx;
|
|
|
+ color: #94A3B8;
|
|
|
+ margin-bottom: 24rpx;
|
|
|
+}
|
|
|
+.loading-text {
|
|
|
+ font-size: 28rpx;
|
|
|
+ color: #64748B;
|
|
|
+}
|
|
|
+</style>
|