no message
This commit is contained in:
parent
1c52c7c2df
commit
9ccaa418a2
730
simple-mind-map/src/layouts/ForceDirected.js
Normal file
730
simple-mind-map/src/layouts/ForceDirected.js
Normal file
@ -0,0 +1,730 @@
|
||||
import Base from './Base'
|
||||
import { walk, asyncRun } from '../utils'
|
||||
import { CONSTANTS } from '../constants/constant'
|
||||
|
||||
// 导入 d3-force 模块需要的函数
|
||||
import {
|
||||
forceSimulation,
|
||||
forceManyBody,
|
||||
forceLink,
|
||||
forceCenter,
|
||||
forceCollide,
|
||||
forceX,
|
||||
forceY
|
||||
} from 'd3-force'
|
||||
|
||||
/**
|
||||
* 力导向图布局,子节点全方位环形包围父节点
|
||||
* 使用 D3.js 的 d3-force 模块实现高质量的力导向算法
|
||||
*/
|
||||
class ForceDirected extends Base {
|
||||
constructor(opt = {}) {
|
||||
super(opt)
|
||||
|
||||
// 力导向图配置参数
|
||||
this.forceConfig = {
|
||||
// 基础参数
|
||||
idealDistance: 100, // 理想连接距离
|
||||
repulsionStrength: 800, // 节点间斥力强度
|
||||
attractionStrength: 0.3, // 节点间引力强度
|
||||
centerStrength: 0.05, // 中心引力强度
|
||||
collideRadius: 1.2, // 碰撞半径系数
|
||||
|
||||
// 迭代参数
|
||||
iterations: 120, // 默认迭代次数
|
||||
alphaDecay: 0.0228, // alpha衰减率
|
||||
velocityDecay: 0.4, // 速度衰减率(阻尼)
|
||||
|
||||
// 初始布局参数
|
||||
initialRadius: 10, // 初始布局的半径增量
|
||||
initialExpandAngle: Math.PI * 2, // 初始展开角度范围
|
||||
|
||||
// 布局调优参数
|
||||
layerDistance: 100, // 层级间距
|
||||
siblingDistance: 30, // 兄弟节点间距
|
||||
descendantEffect: 0.15, // 子孙节点对父节点的引力影响
|
||||
nodeSize: 1.2, // 节点大小影响系数
|
||||
textSize: 0.5, // 文本大小影响系数
|
||||
|
||||
// 连线参数
|
||||
linkStyle: 'curve', // 连线样式:curve(曲线)、straight(直线)
|
||||
linkCurvature: 0.4, // 连线曲率
|
||||
|
||||
// 树形结构约束
|
||||
treeSpread: 0.8, // 树形展开程度
|
||||
verticalCorrection: 0.5 // 垂直方向修正系数
|
||||
}
|
||||
|
||||
// 合并自定义配置
|
||||
if (opt.forceConfig) {
|
||||
Object.assign(this.forceConfig, opt.forceConfig)
|
||||
}
|
||||
|
||||
// D3力导向模拟器实例
|
||||
this.simulation = null
|
||||
|
||||
// 储存节点和连接的数组
|
||||
this.d3Nodes = []
|
||||
this.d3Links = []
|
||||
|
||||
// 初始布局计算标志
|
||||
this.isInitialLayout = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行布局
|
||||
*/
|
||||
doLayout(callback) {
|
||||
const task = [
|
||||
// 1. 计算节点基础信息
|
||||
() => {
|
||||
this.computedBaseValue()
|
||||
},
|
||||
// 2. 应用力导向布局
|
||||
() => {
|
||||
this.applyForceDirected()
|
||||
},
|
||||
// 3. 完成布局后回调
|
||||
() => {
|
||||
callback(this.root)
|
||||
}
|
||||
]
|
||||
asyncRun(task)
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算节点基础值,包括大小、初始位置等
|
||||
*/
|
||||
computedBaseValue() {
|
||||
// 递归遍历渲染树,创建节点
|
||||
walk(
|
||||
this.renderer.renderTree,
|
||||
null,
|
||||
(cur, parent, isRoot, layerIndex, index, ancestors) => {
|
||||
// 创建节点
|
||||
const newNode = this.createNode(
|
||||
cur,
|
||||
parent,
|
||||
isRoot,
|
||||
layerIndex,
|
||||
index,
|
||||
ancestors
|
||||
)
|
||||
|
||||
// 根节点定位在画布中心
|
||||
if (isRoot) {
|
||||
this.setNodeCenter(newNode)
|
||||
}
|
||||
|
||||
// 如果节点是折叠状态,不处理其子节点
|
||||
if (!cur.data.expand) {
|
||||
return true
|
||||
}
|
||||
},
|
||||
null,
|
||||
true,
|
||||
0
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用力导向布局算法
|
||||
*/
|
||||
applyForceDirected() {
|
||||
// 1. 准备节点和连接数据
|
||||
this.prepareD3Data()
|
||||
|
||||
// 2. 设置初始布局(树形结构作为初始布局效果更佳)
|
||||
this.setInitialPositions()
|
||||
|
||||
// 3. 运行力导向模拟
|
||||
this.runForceSimulation()
|
||||
|
||||
// 4. 更新节点最终位置
|
||||
this.updateNodePositions()
|
||||
|
||||
// 5. 布局后处理(避免重叠等)
|
||||
this.postLayoutAdjustment()
|
||||
|
||||
// 非第一次布局后重置标志
|
||||
this.isInitialLayout = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 准备D3力导向布局需要的数据结构
|
||||
*/
|
||||
prepareD3Data() {
|
||||
this.d3Nodes = []
|
||||
this.d3Links = []
|
||||
let nodeId = 0
|
||||
|
||||
// 遍历所有节点,建立节点和连接的映射关系
|
||||
walk(
|
||||
this.root,
|
||||
null,
|
||||
(node) => {
|
||||
// 只处理展开的节点
|
||||
if (node.isRoot || (node.parent && node.parent.getData('expand'))) {
|
||||
// 给每个节点分配一个唯一ID
|
||||
node.id = nodeId++
|
||||
|
||||
// 记录节点坐标,以中心点为准
|
||||
const nodeX = node.left + node.width / 2
|
||||
const nodeY = node.top + node.height / 2
|
||||
|
||||
// 创建D3节点对象
|
||||
const d3Node = {
|
||||
id: node.id,
|
||||
node: node, // 引用原始节点
|
||||
x: nodeX,
|
||||
y: nodeY,
|
||||
// 固定根节点位置
|
||||
fx: node.isRoot ? nodeX : undefined,
|
||||
fy: node.isRoot ? nodeY : undefined,
|
||||
// 节点大小影响力的大小
|
||||
size: Math.sqrt(node.width * node.height) * this.forceConfig.nodeSize,
|
||||
// 节点在层次结构中的深度
|
||||
depth: node.layerIndex,
|
||||
// 是否固定位置
|
||||
fixed: node.hasCustomPosition(),
|
||||
// 文本长度因子
|
||||
textFactor: (node.getData('text') || '').length * this.forceConfig.textSize
|
||||
}
|
||||
|
||||
// 如果节点位置已自定义,则固定它
|
||||
if (d3Node.fixed) {
|
||||
d3Node.fx = nodeX
|
||||
d3Node.fy = nodeY
|
||||
}
|
||||
|
||||
this.d3Nodes.push(d3Node)
|
||||
|
||||
// 为非根节点创建与父节点的连接
|
||||
if (!node.isRoot) {
|
||||
this.d3Links.push({
|
||||
source: node.parent.id,
|
||||
target: node.id,
|
||||
// 连接强度可以基于层级深度和节点大小进行调整
|
||||
strength: this.calculateLinkStrength(node),
|
||||
// 理想距离 - 考虑节点大小和层级
|
||||
distance: this.calculateLinkDistance(node)
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
null,
|
||||
true
|
||||
)
|
||||
|
||||
return { nodes: this.d3Nodes, links: this.d3Links }
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算连接强度 - 影响节点间吸引力
|
||||
*/
|
||||
calculateLinkStrength(node) {
|
||||
// 基础强度
|
||||
let strength = this.forceConfig.attractionStrength
|
||||
|
||||
// 根据节点文本长度调整强度
|
||||
const textLength = (node.getData('text') || '').length
|
||||
const textFactor = Math.min(1, Math.log(textLength + 1) / 10)
|
||||
|
||||
// 根据节点层级调整强度 - 层级越深连接越弱,给予更多自由度
|
||||
const depthFactor = Math.max(0.2, 1 - (node.layerIndex - 1) * 0.1)
|
||||
|
||||
// 节点子节点数量因子 - 子节点越多,连接越强,保持子树的稳定
|
||||
const childrenCount = node.children ? node.children.length : 0
|
||||
const childrenFactor = Math.min(1.5, 1 + childrenCount / 20)
|
||||
|
||||
return strength * depthFactor * childrenFactor * (1 + textFactor)
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算连接的理想距离
|
||||
*/
|
||||
calculateLinkDistance(node) {
|
||||
// 基础距离
|
||||
let distance = this.forceConfig.idealDistance
|
||||
|
||||
// 根据层级调整距离
|
||||
const layerFactor = 1 + (node.layerIndex - 1) * 0.2
|
||||
|
||||
// 根据节点大小调整距离
|
||||
const sizeFactor = Math.sqrt(
|
||||
(node.width * node.height + node.parent.width * node.parent.height) / 2
|
||||
) / 100
|
||||
|
||||
// 综合计算理想距离
|
||||
return distance * layerFactor * (1 + sizeFactor)
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置节点的初始位置 - 使用简单的径向布局作为力导向的初始位置
|
||||
*/
|
||||
setInitialPositions() {
|
||||
// 使用自定义的树形初始布局可以加速力导向算法收敛
|
||||
|
||||
// 按层级组织节点
|
||||
const nodesByLevel = {}
|
||||
this.d3Nodes.forEach(d3Node => {
|
||||
const level = d3Node.depth
|
||||
if (!nodesByLevel[level]) {
|
||||
nodesByLevel[level] = []
|
||||
}
|
||||
nodesByLevel[level].push(d3Node)
|
||||
})
|
||||
|
||||
// 从根节点开始
|
||||
const rootNode = this.d3Nodes.find(n => n.node.isRoot)
|
||||
const rootX = rootNode.x
|
||||
const rootY = rootNode.y
|
||||
|
||||
// 对每个层级进行处理
|
||||
Object.keys(nodesByLevel).forEach(level => {
|
||||
const nodesAtLevel = nodesByLevel[level]
|
||||
if (level == 0) return // 跳过根节点
|
||||
|
||||
// 在每个层级中,按照父节点分组
|
||||
const nodesByParent = {}
|
||||
nodesAtLevel.forEach(node => {
|
||||
const parentId = this.d3Links.find(link => link.target === node.id)?.source
|
||||
if (parentId !== undefined) {
|
||||
if (!nodesByParent[parentId]) {
|
||||
nodesByParent[parentId] = []
|
||||
}
|
||||
nodesByParent[parentId].push(node)
|
||||
}
|
||||
})
|
||||
|
||||
// 对于每组有共同父节点的节点,按径向布局排列
|
||||
Object.entries(nodesByParent).forEach(([parentId, childNodes]) => {
|
||||
const parent = this.d3Nodes.find(n => n.id === Number(parentId))
|
||||
const count = childNodes.length
|
||||
|
||||
// 如果此父节点只有单个子节点,以特定偏移放置
|
||||
if (count === 1) {
|
||||
const child = childNodes[0]
|
||||
const angle = Math.random() * Math.PI * 2 // 随机角度
|
||||
const radius = this.forceConfig.layerDistance * child.depth * this.forceConfig.initialRadius
|
||||
|
||||
// 如果节点未固定位置
|
||||
if (!child.fixed) {
|
||||
child.x = parent.x + Math.cos(angle) * radius
|
||||
child.y = parent.y + Math.sin(angle) * radius
|
||||
}
|
||||
} else {
|
||||
// 多个子节点,在父节点周围环形布局
|
||||
const angleStep = this.forceConfig.initialExpandAngle / count
|
||||
|
||||
childNodes.forEach((child, i) => {
|
||||
if (child.fixed) return // 跳过固定位置的节点
|
||||
|
||||
// 计算径向位置
|
||||
const angle = angleStep * i
|
||||
// 半径随层级和节点数增加
|
||||
const radius = this.forceConfig.layerDistance *
|
||||
(child.depth + child.textFactor * 0.1) *
|
||||
this.forceConfig.initialRadius
|
||||
|
||||
// 应用位置
|
||||
child.x = parent.x + Math.cos(angle) * radius
|
||||
child.y = parent.y + Math.sin(angle) * radius
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行D3力导向模拟
|
||||
*/
|
||||
runForceSimulation() {
|
||||
// 创建力导向模拟器
|
||||
this.simulation = forceSimulation(this.d3Nodes)
|
||||
// 设置衰减参数
|
||||
.alphaDecay(this.forceConfig.alphaDecay)
|
||||
.velocityDecay(this.forceConfig.velocityDecay)
|
||||
|
||||
// 向心力 - 将图形整体拉向中心
|
||||
.force('center', forceCenter(
|
||||
this.root.left + this.root.width / 2,
|
||||
this.root.top + this.root.height / 2
|
||||
).strength(this.forceConfig.centerStrength))
|
||||
|
||||
// 节点间的连接力
|
||||
.force('link', forceLink(this.d3Links)
|
||||
.id(d => d.id)
|
||||
.distance(link => link.distance)
|
||||
.strength(link => link.strength))
|
||||
|
||||
// 节点间斥力
|
||||
.force('charge', forceManyBody()
|
||||
.strength(d => {
|
||||
// 节点斥力随深度、子节点数量和文本长度调整
|
||||
const depthFactor = Math.pow(0.8, d.depth)
|
||||
const sizeFactor = d.size / 50 + 1
|
||||
const childrenFactor = d.node.children ? d.node.children.length / 5 + 1 : 1
|
||||
|
||||
return -this.forceConfig.repulsionStrength * depthFactor * sizeFactor * childrenFactor
|
||||
})
|
||||
.distanceMax(1000)) // 限制斥力的最大作用距离
|
||||
|
||||
// 碰撞力 - 避免节点重叠
|
||||
.force('collide', forceCollide()
|
||||
.radius(d => {
|
||||
// 碰撞半径 = 节点大小 + 边距
|
||||
const nodeSize = Math.max(d.node.width, d.node.height) / 2
|
||||
return nodeSize * this.forceConfig.collideRadius
|
||||
})
|
||||
.strength(0.8)
|
||||
.iterations(2))
|
||||
|
||||
// 沿X轴的力 - 使子节点倾向于在父节点两侧分布
|
||||
.force('x', forceX().strength(d => {
|
||||
if (d.node.isRoot || !d.node.parent) return 0
|
||||
|
||||
// 获取父节点D3数据
|
||||
const parentD3 = this.d3Nodes.find(n => n.id === d.node.parent.id)
|
||||
if (!parentD3) return 0
|
||||
|
||||
// 水平分布的力,使子节点分散在父节点两侧
|
||||
return 0.02 * this.forceConfig.treeSpread
|
||||
}).x(d => {
|
||||
// 如果是根节点或无父节点,返回当前位置
|
||||
if (d.node.isRoot || !d.node.parent) return d.x
|
||||
|
||||
// 获取父节点位置
|
||||
const parentD3 = this.d3Nodes.find(n => n.id === d.node.parent.id)
|
||||
if (!parentD3) return d.x
|
||||
|
||||
// 应用随机偏移以打破对称性
|
||||
const offset = (d.id % 2 === 0 ? 1 : -1) * this.forceConfig.siblingDistance *
|
||||
(1 + d.node.getData('text').length / 20)
|
||||
return parentD3.x + offset
|
||||
}))
|
||||
|
||||
// 沿Y轴的力 - 使子节点倾向于在父节点下方
|
||||
.force('y', forceY().strength(d => {
|
||||
// 对根节点无影响
|
||||
if (d.node.isRoot) return 0
|
||||
|
||||
// 垂直方向吸引力,与层级和子节点数相关
|
||||
return 0.04 * this.forceConfig.verticalCorrection * (1 + d.depth * 0.1)
|
||||
}).y(d => {
|
||||
if (d.node.isRoot || !d.node.parent) return d.y
|
||||
|
||||
// 获取父节点位置
|
||||
const parentD3 = this.d3Nodes.find(n => n.id === d.node.parent.id)
|
||||
if (!parentD3) return d.y
|
||||
|
||||
// 向下偏移,层级越深偏移越大
|
||||
const levelOffset = d.depth * this.forceConfig.layerDistance * 0.6
|
||||
return parentD3.y + levelOffset
|
||||
}))
|
||||
|
||||
// 一次性运行所有迭代
|
||||
for (let i = 0; i < this.forceConfig.iterations; i++) {
|
||||
this.simulation.tick()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新节点位置数据
|
||||
*/
|
||||
updateNodePositions() {
|
||||
// 将D3模拟结果应用到实际节点
|
||||
this.d3Nodes.forEach(d3Node => {
|
||||
const node = d3Node.node
|
||||
if (!node.hasCustomPosition()) {
|
||||
// 将D3节点中心点坐标转换为左上角坐标
|
||||
node.left = d3Node.x - node.width / 2
|
||||
node.top = d3Node.y - node.height / 2
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 布局后处理调整 - 解决重叠和优化布局
|
||||
*/
|
||||
postLayoutAdjustment() {
|
||||
// 迭代解决重叠问题
|
||||
this.resolveOverlapping()
|
||||
}
|
||||
|
||||
/**
|
||||
* 解决节点重叠问题
|
||||
*/
|
||||
resolveOverlapping() {
|
||||
// 获取所有非根节点
|
||||
const nodes = this.d3Nodes.filter(d => !d.node.isRoot && !d.fixed)
|
||||
.map(d => d.node)
|
||||
|
||||
// 向外移动来解决重叠
|
||||
const iterations = 5
|
||||
for (let iter = 0; iter < iterations; iter++) {
|
||||
let hasAdjusted = false
|
||||
|
||||
// 检查每对节点
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const nodeA = nodes[i]
|
||||
|
||||
for (let j = i + 1; j < nodes.length; j++) {
|
||||
const nodeB = nodes[j]
|
||||
|
||||
// 检查是否重叠
|
||||
if (this.checkOverlap(nodeA, nodeB)) {
|
||||
this.separateNodes(nodeA, nodeB)
|
||||
hasAdjusted = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有调整则提前结束
|
||||
if (!hasAdjusted) break
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查两个节点是否重叠
|
||||
*/
|
||||
checkOverlap(nodeA, nodeB) {
|
||||
// 获取节点间距
|
||||
const marginX = Math.max(
|
||||
this.getMarginX(nodeA.layerIndex),
|
||||
this.getMarginX(nodeB.layerIndex)
|
||||
)
|
||||
|
||||
const marginY = Math.max(
|
||||
this.getMarginY(nodeA.layerIndex),
|
||||
this.getMarginY(nodeB.layerIndex)
|
||||
)
|
||||
|
||||
// 计算节点矩形
|
||||
const aLeft = nodeA.left - marginX / 2
|
||||
const aRight = nodeA.left + nodeA.width + marginX / 2
|
||||
const aTop = nodeA.top - marginY / 2
|
||||
const aBottom = nodeA.top + nodeA.height + marginY / 2
|
||||
|
||||
const bLeft = nodeB.left - marginX / 2
|
||||
const bRight = nodeB.left + nodeB.width + marginX / 2
|
||||
const bTop = nodeB.top - marginY / 2
|
||||
const bBottom = nodeB.top + nodeB.height + marginY / 2
|
||||
|
||||
// 检查重叠
|
||||
return !(aRight < bLeft || aLeft > bRight || aBottom < bTop || aTop > bBottom)
|
||||
}
|
||||
|
||||
/**
|
||||
* 分离重叠的节点
|
||||
*/
|
||||
separateNodes(nodeA, nodeB) {
|
||||
// 计算两个节点中心点
|
||||
const centerAx = nodeA.left + nodeA.width / 2
|
||||
const centerAy = nodeA.top + nodeA.height / 2
|
||||
const centerBx = nodeB.left + nodeB.width / 2
|
||||
const centerBy = nodeB.top + nodeB.height / 2
|
||||
|
||||
// 计算两节点之间的向量
|
||||
const dx = centerBx - centerAx
|
||||
const dy = centerBy - centerAy
|
||||
const distance = Math.sqrt(dx * dx + dy * dy)
|
||||
|
||||
if (distance < 1) {
|
||||
// 如果节点中心重合,则添加微小的随机偏移
|
||||
const angle = Math.random() * Math.PI * 2
|
||||
nodeA.left -= Math.cos(angle) * 10
|
||||
nodeA.top -= Math.sin(angle) * 10
|
||||
nodeB.left += Math.cos(angle) * 10
|
||||
nodeB.top += Math.sin(angle) * 10
|
||||
return
|
||||
}
|
||||
|
||||
// 计算所需的最小距离
|
||||
const marginX = Math.max(this.getMarginX(nodeA.layerIndex), this.getMarginX(nodeB.layerIndex))
|
||||
const marginY = Math.max(this.getMarginY(nodeA.layerIndex), this.getMarginY(nodeB.layerIndex))
|
||||
|
||||
const requiredX = (nodeA.width + nodeB.width) / 2 + marginX
|
||||
const requiredY = (nodeA.height + nodeB.height) / 2 + marginY
|
||||
|
||||
// 根据节点间夹角计算最小所需距离
|
||||
const angle = Math.atan2(Math.abs(dy), Math.abs(dx))
|
||||
const minDistance = Math.max(
|
||||
requiredX * Math.cos(angle) + requiredY * Math.sin(angle),
|
||||
requiredY * Math.cos(angle) + requiredX * Math.sin(angle)
|
||||
)
|
||||
|
||||
// 如果距离不足,移动节点
|
||||
if (distance < minDistance) {
|
||||
const factor = (minDistance - distance) / distance
|
||||
|
||||
// 计算移动向量
|
||||
const moveX = dx * factor / 2
|
||||
const moveY = dy * factor / 2
|
||||
|
||||
// 移动节点(考虑移动距离的权重分配)
|
||||
const weightA = nodeA.children ? nodeA.children.length : 0
|
||||
const weightB = nodeB.children ? nodeB.children.length : 0
|
||||
const totalWeight = weightA + weightB + 2
|
||||
const ratioA = (weightB + 1) / totalWeight
|
||||
const ratioB = (weightA + 1) / totalWeight
|
||||
|
||||
// 应用移动
|
||||
nodeA.left -= moveX * ratioA
|
||||
nodeA.top -= moveY * ratioA
|
||||
nodeB.left += moveX * ratioB
|
||||
nodeB.top += moveY * ratioB
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制连接线 - 从父节点到子节点
|
||||
*/
|
||||
renderLine(node, lines, style) {
|
||||
// 根据配置选择连线风格
|
||||
if (this.forceConfig.linkStyle === 'straight') {
|
||||
this.renderLineStraight(node, lines, style)
|
||||
} else {
|
||||
this.renderLineCurve(node, lines, style)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制直线连接
|
||||
*/
|
||||
renderLineStraight(node, lines, style) {
|
||||
if (node.children.length <= 0) return
|
||||
|
||||
const { left, top, width, height } = node
|
||||
const nodeUseLineStyle = this.mindMap.themeConfig.nodeUseLineStyle
|
||||
|
||||
node.children.forEach((item, index) => {
|
||||
const x1 = left + width / 2
|
||||
const y1 = top + height / 2
|
||||
const x2 = item.left + item.width / 2
|
||||
const y2 = item.top + item.height / 2
|
||||
|
||||
const path = `M ${x1},${y1} L ${x2},${y2}`
|
||||
this.setLineStyle(style, lines[index], path, item)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制曲线连接
|
||||
*/
|
||||
renderLineCurve(node, lines, style) {
|
||||
if (node.children.length <= 0) return
|
||||
|
||||
const { left, top, width, height } = node
|
||||
const nodeUseLineStyle = this.mindMap.themeConfig.nodeUseLineStyle
|
||||
const curvature = this.forceConfig.linkCurvature
|
||||
|
||||
node.children.forEach((item, index) => {
|
||||
const x1 = left + width / 2
|
||||
const y1 = top + height / 2
|
||||
const x2 = item.left + item.width / 2
|
||||
const y2 = item.top + item.height / 2
|
||||
|
||||
// 计算两点间距离
|
||||
const dx = x2 - x1
|
||||
const dy = y2 - y1
|
||||
const distance = Math.sqrt(dx * dx + dy * dy)
|
||||
|
||||
// 计算控制点
|
||||
// 控制点根据连线方向和曲率系数计算
|
||||
const controlDistance = distance * curvature
|
||||
const angle = Math.atan2(dy, dx)
|
||||
const perpendicular = angle + Math.PI / 2
|
||||
|
||||
const cx1 = x1 + Math.cos(angle) * distance * 0.3
|
||||
const cy1 = y1 + Math.sin(angle) * distance * 0.3
|
||||
const cx2 = x2 - Math.cos(angle) * distance * 0.3
|
||||
const cy2 = y2 - Math.sin(angle) * distance * 0.3
|
||||
|
||||
// 生成曲线路径
|
||||
const path = `M ${x1},${y1} C ${cx1},${cy1} ${cx2},${cy2} ${x2},${y2}`
|
||||
this.setLineStyle(style, lines[index], path, item)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染展开/收起按钮
|
||||
*/
|
||||
renderExpandBtn(node, btn) {
|
||||
const { width, height, expandBtnSize } = node
|
||||
let { translateX, translateY } = btn.transform()
|
||||
|
||||
// 在力导向布局中,统一将展开按钮放在节点右侧
|
||||
const _x = width
|
||||
const _y = height / 2
|
||||
|
||||
// 位置未改变无需更新
|
||||
if (_x === translateX && _y === translateY) {
|
||||
return
|
||||
}
|
||||
|
||||
// 计算偏移量并应用
|
||||
const x = _x - translateX
|
||||
const y = _y - translateY
|
||||
btn.translate(x, y)
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染概要节点
|
||||
*/
|
||||
renderGeneralization(list) {
|
||||
list.forEach(item => {
|
||||
const {
|
||||
top,
|
||||
bottom,
|
||||
left,
|
||||
right,
|
||||
generalizationLineMargin,
|
||||
generalizationNodeMargin
|
||||
} = this.getNodeGeneralizationRenderBoundaries(item, 'h')
|
||||
|
||||
// 计算中心点
|
||||
const centerX = (left + right) / 2
|
||||
const centerY = (top + bottom) / 2
|
||||
|
||||
// 计算概要节点方向
|
||||
const nodeX = item.node.left + item.node.width / 2
|
||||
const nodeY = item.node.top + item.node.height / 2
|
||||
|
||||
// 计算方向向量
|
||||
const dx = centerX - nodeX
|
||||
const dy = centerY - nodeY
|
||||
const distance = Math.sqrt(dx * dx + dy * dy)
|
||||
const nx = dx / distance
|
||||
const ny = dy / distance
|
||||
|
||||
// 计算概要线起止点
|
||||
const x1 = centerX + nx * generalizationLineMargin
|
||||
const y1 = centerY + ny * generalizationLineMargin
|
||||
const x2 = centerX - nx * generalizationLineMargin
|
||||
const y2 = centerY - ny * generalizationLineMargin
|
||||
|
||||
// 控制点 - 垂直于方向向量
|
||||
const cx = (x1 + x2) / 2 + ny * 30
|
||||
const cy = (y1 + y2) / 2 - nx * 30
|
||||
|
||||
// 绘制概要线
|
||||
const path = `M ${x1},${y1} Q ${cx},${cy} ${x2},${y2}`
|
||||
item.generalizationLine.plot(path)
|
||||
|
||||
// 定位概要节点
|
||||
item.generalizationNode.left = x2 - item.generalizationNode.width / 2
|
||||
item.generalizationNode.top = y2 - item.generalizationNode.height / 2
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染展开收起按钮的占位元素
|
||||
*/
|
||||
renderExpandBtnRect(rect, expandBtnSize, width, height, node) {
|
||||
// 在力导向布局中,展开按钮统一在节点右侧
|
||||
rect.size(expandBtnSize, height).x(width).y(0)
|
||||
}
|
||||
}
|
||||
|
||||
export default ForceDirected
|
||||
825
simple-mind-map/src/layouts/ForceDirected2.js
Normal file
825
simple-mind-map/src/layouts/ForceDirected2.js
Normal file
@ -0,0 +1,825 @@
|
||||
import Base from './Base'
|
||||
import { walk, asyncRun, getNodeIndexInNodeList } from '../utils'
|
||||
import { CONSTANTS } from '../constants/constant'
|
||||
|
||||
// 力导向图布局,子节点全方位环形包围父节点
|
||||
class ForceDirected extends Base {
|
||||
constructor(opt = {}) {
|
||||
super(opt)
|
||||
|
||||
// 力导向图配置参数
|
||||
this.forceConfig = {
|
||||
// 理想节点间距
|
||||
idealDistance: 100,
|
||||
// 节点间斥力系数
|
||||
repulsion: 10,
|
||||
// 基础迭代次数(将根据数据规模动态调整)
|
||||
baseIterations: 30,
|
||||
// 最小迭代次数
|
||||
minIterations: 10,
|
||||
// 最大迭代次数
|
||||
maxIterations: 100,
|
||||
// 环形半径系数
|
||||
radiusCoefficient: 1.5,
|
||||
// 环形分布的起始角度(弧度)- 仅在不使用智能分布时生效
|
||||
startAngle: 0,
|
||||
// 启用节点智能分布,根据父节点位置动态调整子节点分布方向
|
||||
useSmartNodeDistribution: true,
|
||||
// 角度范围,控制子节点分布的范围,默认全圆 2π
|
||||
angleRange: Math.PI * 2,
|
||||
// 自身引力系数 - 子节点数量影响
|
||||
selfGravityChildrenFactor: 15,
|
||||
// 自身引力系数 - 文本长度影响
|
||||
selfGravityTextFactor: 2,
|
||||
// Y轴方向的空间系数,增大可使节点在Y轴方向更分散
|
||||
ySpacingFactor: 1.2,
|
||||
// 避免连线交叉的系数
|
||||
lineCrossingAvoidanceFactor: 0.8,
|
||||
// 节点间最小间距系数(相对于默认间距)
|
||||
nodeSpacingFactor: 1.2,
|
||||
// 节点与连接线之间的最小距离
|
||||
nodeLineMinDistance: 10,
|
||||
// 解决重叠问题的最大迭代次数
|
||||
overlapIterations: 8,
|
||||
// 避免节点与连接线重叠的权重系数
|
||||
nodeLineAvoidanceWeight: 0.6
|
||||
}
|
||||
|
||||
// 合并自定义配置
|
||||
if (opt.forceConfig) {
|
||||
Object.assign(this.forceConfig, opt.forceConfig)
|
||||
}
|
||||
}
|
||||
|
||||
// 布局
|
||||
doLayout(callback) {
|
||||
let task = [
|
||||
() => {
|
||||
this.computedBaseValue()
|
||||
},
|
||||
() => {
|
||||
this.applyForceDirected()
|
||||
},
|
||||
() => {
|
||||
callback(this.root)
|
||||
}
|
||||
]
|
||||
asyncRun(task)
|
||||
}
|
||||
|
||||
// 遍历数据计算节点的初始宽高
|
||||
computedBaseValue() {
|
||||
walk(
|
||||
this.renderer.renderTree,
|
||||
null,
|
||||
(cur, parent, isRoot, layerIndex, index, ancestors) => {
|
||||
let newNode = this.createNode(
|
||||
cur,
|
||||
parent,
|
||||
isRoot,
|
||||
layerIndex,
|
||||
index,
|
||||
ancestors
|
||||
)
|
||||
|
||||
// 根节点定位在画布中心位置
|
||||
if (isRoot) {
|
||||
this.setNodeCenter(newNode)
|
||||
}
|
||||
|
||||
if (!cur.data.expand) {
|
||||
return true
|
||||
}
|
||||
},
|
||||
null,
|
||||
true,
|
||||
0
|
||||
)
|
||||
}
|
||||
|
||||
// 应用力导向布局
|
||||
applyForceDirected() {
|
||||
// 先按层级环形分布节点
|
||||
this.distributeNodesInCircle(this.root)
|
||||
|
||||
// 应用力导向算法进行调整
|
||||
this.applyForceAlgorithm()
|
||||
}
|
||||
|
||||
// 将同一级子节点环形分布在父节点周围
|
||||
distributeNodesInCircle(node) {
|
||||
if (node && node.children && node.children.length > 0 && node.getData('expand')) {
|
||||
const children = node.children
|
||||
const childCount = children.length
|
||||
|
||||
if (childCount === 0) return
|
||||
|
||||
// 计算环形半径,根据子节点数量和大小动态调整
|
||||
const avgChildSize = children.reduce((sum, child) => sum + Math.max(child.width, child.height), 0) / childCount
|
||||
|
||||
// 为每个子节点计算自身引力因子
|
||||
children.forEach(child => {
|
||||
// 计算自身引力因子 - 基于子节点数量和文本长度
|
||||
child.selfGravityFactor = this.calculateSelfGravityFactor(child)
|
||||
})
|
||||
|
||||
// 排序子节点,使得引力因子大的节点排在前面(这样它们会均匀分布)
|
||||
const sortedChildren = [...children].sort((a, b) => b.selfGravityFactor - a.selfGravityFactor)
|
||||
|
||||
// 基础半径
|
||||
const baseRadius = Math.max(node.width, node.height) + avgChildSize + this.getMarginX(node.layerIndex + 1) * this.forceConfig.radiusCoefficient
|
||||
|
||||
// 智能确定起始角度和角度范围
|
||||
let startAngle, angleRange
|
||||
|
||||
// 判断是否使用智能分布
|
||||
if (this.forceConfig.useSmartNodeDistribution) {
|
||||
// 如果是根节点,使用完整360度范围
|
||||
if (node.isRoot) {
|
||||
startAngle = 0
|
||||
angleRange = Math.PI * 2
|
||||
} else if (node.parent) {
|
||||
// 非根节点,根据当前节点相对父节点的位置来确定子节点的分布方向
|
||||
// 计算节点与其父节点的相对角度
|
||||
const parentX = node.parent.left + node.parent.width / 2
|
||||
const parentY = node.parent.top + node.parent.height / 2
|
||||
const nodeX = node.left + node.width / 2
|
||||
const nodeY = node.top + node.height / 2
|
||||
|
||||
// 计算当前节点相对于父节点的角度
|
||||
const dx = nodeX - parentX
|
||||
const dy = nodeY - parentY
|
||||
const nodeAngle = Math.atan2(dy, dx)
|
||||
|
||||
// 确定子节点分布的起始角度为该节点相对于父节点的反方向,再偏移半个角度范围
|
||||
// 这样子节点主要分布在远离父节点的方向
|
||||
startAngle = nodeAngle + Math.PI - this.forceConfig.angleRange / 2
|
||||
angleRange = this.forceConfig.angleRange
|
||||
} else {
|
||||
// 没有父节点但不是根节点的情况,使用默认角度
|
||||
startAngle = this.forceConfig.startAngle
|
||||
angleRange = this.forceConfig.angleRange
|
||||
}
|
||||
} else {
|
||||
// 不使用智能分布,使用默认配置
|
||||
startAngle = this.forceConfig.startAngle
|
||||
angleRange = this.forceConfig.angleRange
|
||||
}
|
||||
|
||||
// 将子节点环形分布在父节点周围
|
||||
sortedChildren.forEach((child, index) => {
|
||||
// 计算子节点在环形上的角度
|
||||
const angle = startAngle + (angleRange * index) / childCount
|
||||
|
||||
// 根据自身引力调整半径,Y轴方向使用额外的空间系数
|
||||
const adjustedRadius = baseRadius * (1 + child.selfGravityFactor)
|
||||
|
||||
// 在Y轴方向上增加系数,使节点在垂直方向更加分散
|
||||
const yFactor = this.forceConfig.ySpacingFactor
|
||||
const xOffset = Math.cos(angle) * adjustedRadius
|
||||
const yOffset = Math.sin(angle) * adjustedRadius * yFactor
|
||||
|
||||
// 计算子节点位置
|
||||
child.left = node.left + node.width / 2 - child.width / 2 + xOffset
|
||||
child.top = node.top + node.height / 2 - child.height / 2 + yOffset
|
||||
|
||||
// 存储子节点的分布角度,用于后续连线交叉检测
|
||||
child.distributionAngle = angle
|
||||
|
||||
// 递归处理子节点的子节点
|
||||
this.distributeNodesInCircle(child)
|
||||
})
|
||||
|
||||
// 避免连线交叉 - 调整节点位置
|
||||
if (childCount > 2 && this.forceConfig.lineCrossingAvoidanceFactor > 0) {
|
||||
this.avoidLineCrossing(node, sortedChildren)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 避免连线交叉算法
|
||||
avoidLineCrossing(parent, children) {
|
||||
if (!parent || children.length <= 2) return
|
||||
|
||||
const parentCenterX = parent.left + parent.width / 2
|
||||
const parentCenterY = parent.top + parent.height / 2
|
||||
|
||||
// 计算所有子节点的向量(从父节点到子节点)
|
||||
const vectors = children.map(child => {
|
||||
const childCenterX = child.left + child.width / 2
|
||||
const childCenterY = child.top + child.height / 2
|
||||
return {
|
||||
node: child,
|
||||
dx: childCenterX - parentCenterX,
|
||||
dy: childCenterY - parentCenterY,
|
||||
angle: Math.atan2(childCenterY - parentCenterY, childCenterX - parentCenterX)
|
||||
}
|
||||
})
|
||||
|
||||
// 按角度排序
|
||||
vectors.sort((a, b) => a.angle - b.angle)
|
||||
|
||||
// 检查每一对相邻节点,确保它们的连线不会交叉
|
||||
for (let i = 0; i < vectors.length - 1; i++) {
|
||||
const current = vectors[i]
|
||||
const next = vectors[i + 1]
|
||||
|
||||
// 如果角度相差太小,稍微调整它们的位置
|
||||
if (Math.abs(next.angle - current.angle) < 0.1) {
|
||||
const factor = this.forceConfig.lineCrossingAvoidanceFactor
|
||||
const distance = Math.sqrt(current.dx * current.dx + current.dy * current.dy)
|
||||
|
||||
// 调整current向后移动
|
||||
const adjustAngle = current.angle - 0.05
|
||||
current.node.left = parentCenterX + Math.cos(adjustAngle) * distance - current.node.width / 2
|
||||
current.node.top = parentCenterY + Math.sin(adjustAngle) * distance - current.node.height / 2
|
||||
|
||||
// 调整next向前移动
|
||||
const nextAdjustAngle = next.angle + 0.05
|
||||
next.node.left = parentCenterX + Math.cos(nextAdjustAngle) * distance - next.node.width / 2
|
||||
next.node.top = parentCenterY + Math.sin(nextAdjustAngle) * distance - next.node.height / 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 计算节点的自身引力因子
|
||||
calculateSelfGravityFactor(node) {
|
||||
const { selfGravityChildrenFactor, selfGravityTextFactor } = this.forceConfig
|
||||
|
||||
// 1. 基于子节点数量的引力(子节点越多,引力越大)
|
||||
const childrenCount = node.children ? node.children.length : 0
|
||||
const childrenFactor = Math.log(childrenCount + 1) / 10 * selfGravityChildrenFactor
|
||||
|
||||
// 2. 基于节点文本长度的引力(文本越长,引力越大)
|
||||
const text = node.getData('text') || ''
|
||||
const textLength = text.length
|
||||
const textFactor = Math.log(textLength + 1) / 10 * selfGravityTextFactor
|
||||
|
||||
// 3. 综合考虑这些因素
|
||||
return (childrenFactor + textFactor) / 100
|
||||
}
|
||||
|
||||
// 应用力导向算法调整节点位置
|
||||
applyForceAlgorithm() {
|
||||
// 扁平化所有节点列表(除根节点外)
|
||||
const allNodes = []
|
||||
walk(
|
||||
this.root,
|
||||
null,
|
||||
(node) => {
|
||||
if (!node.isRoot && !node.hasCustomPosition()) {
|
||||
allNodes.push(node)
|
||||
}
|
||||
},
|
||||
null,
|
||||
true
|
||||
)
|
||||
|
||||
// 根据节点数量动态计算迭代次数
|
||||
const nodeCount = allNodes.length;
|
||||
let iterations = this.calculateDynamicIterations(nodeCount);
|
||||
|
||||
// 应用力导向算法进行多次迭代
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
// 计算节点间斥力和引力
|
||||
this.calculateForces(allNodes)
|
||||
|
||||
// 更新节点位置
|
||||
this.updatePositions(allNodes)
|
||||
}
|
||||
|
||||
// 处理可能的节点重叠
|
||||
this.resolveOverlaps(allNodes)
|
||||
}
|
||||
|
||||
// 根据节点数量动态计算所需迭代次数
|
||||
calculateDynamicIterations(nodeCount) {
|
||||
const { baseIterations, minIterations, maxIterations } = this.forceConfig;
|
||||
|
||||
if (nodeCount <= 0) return 0;
|
||||
|
||||
// 节点较少时使用较少迭代次数,节点较多时增加迭代次数
|
||||
// 使用对数关系来平衡大量节点时的性能
|
||||
let iterations;
|
||||
|
||||
if (nodeCount <= 10) {
|
||||
// 节点较少,使用基础迭代次数
|
||||
iterations = baseIterations;
|
||||
} else if (nodeCount <= 50) {
|
||||
// 中等节点数量,线性增加迭代次数
|
||||
iterations = baseIterations + (nodeCount - 10) * 0.5;
|
||||
} else {
|
||||
// 节点数量较大,对数增长避免过度计算
|
||||
iterations = baseIterations + 20 + Math.log10(nodeCount - 49) * 10;
|
||||
}
|
||||
|
||||
// 确保迭代次数在合理范围内
|
||||
return Math.max(minIterations, Math.min(Math.round(iterations), maxIterations));
|
||||
}
|
||||
|
||||
// 计算节点间力的作用
|
||||
calculateForces(nodes) {
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const nodeA = nodes[i]
|
||||
// 初始化力
|
||||
nodeA.fx = 0
|
||||
nodeA.fy = 0
|
||||
|
||||
// 父子节点间的引力(向父节点方向)
|
||||
if (nodeA.parent) {
|
||||
const parent = nodeA.parent
|
||||
const dx = nodeA.left + nodeA.width / 2 - (parent.left + parent.width / 2)
|
||||
const dy = nodeA.top + nodeA.height / 2 - (parent.top + parent.height / 2)
|
||||
const distance = Math.sqrt(dx * dx + dy * dy)
|
||||
|
||||
// 考虑自身引力因子调整理想距离
|
||||
const selfGravityFactor = nodeA.selfGravityFactor || this.calculateSelfGravityFactor(nodeA)
|
||||
const idealDist = this.forceConfig.idealDistance * (1 + 0.2 * (nodeA.layerIndex - 1)) * (1 + selfGravityFactor)
|
||||
|
||||
// 引力 = (实际距离 - 理想距离) / 实际距离
|
||||
const force = (distance - idealDist) / distance
|
||||
|
||||
// 水平和垂直方向上的引力可以不同,增强Y轴方向的力量分布
|
||||
const ySpacingFactor = this.forceConfig.ySpacingFactor
|
||||
nodeA.fx -= dx * force * 0.1
|
||||
nodeA.fy -= dy * force * 0.1 * ySpacingFactor
|
||||
}
|
||||
|
||||
// 兄弟节点间的斥力
|
||||
for (let j = 0; j < nodes.length; j++) {
|
||||
if (i === j) continue
|
||||
|
||||
const nodeB = nodes[j]
|
||||
const dx = nodeA.left + nodeA.width / 2 - (nodeB.left + nodeB.width / 2)
|
||||
const dy = nodeA.top + nodeA.height / 2 - (nodeB.top + nodeB.height / 2)
|
||||
const distance = Math.max(5, Math.sqrt(dx * dx + dy * dy))
|
||||
|
||||
// 添加斥力,防止节点重叠
|
||||
// 考虑节点自身引力因子增强斥力
|
||||
const nodeAFactor = nodeA.selfGravityFactor || this.calculateSelfGravityFactor(nodeA)
|
||||
const nodeBFactor = nodeB.selfGravityFactor || this.calculateSelfGravityFactor(nodeB)
|
||||
const combinedFactor = 1 + (nodeAFactor + nodeBFactor) / 2
|
||||
|
||||
// 增强Y轴方向的斥力
|
||||
const ySpacingFactor = this.forceConfig.ySpacingFactor
|
||||
const repulsionX = this.forceConfig.repulsion * combinedFactor / (distance * distance)
|
||||
const repulsionY = repulsionX * ySpacingFactor
|
||||
|
||||
// 计算方向向量的单位向量
|
||||
const magnitude = Math.sqrt(dx * dx + dy * dy)
|
||||
const unitDx = dx / magnitude
|
||||
const unitDy = dy / magnitude
|
||||
|
||||
// 应用斥力
|
||||
nodeA.fx += unitDx * repulsionX
|
||||
nodeA.fy += unitDy * repulsionY
|
||||
|
||||
// 额外处理垂直方向上的节点,防止节点垂直堆叠
|
||||
if (Math.abs(dx) < (nodeA.width + nodeB.width) / 2 && Math.abs(dy) > nodeA.height) {
|
||||
// 垂直方向上节点相对接近,增加额外的横向斥力
|
||||
const horizRepulsion = 0.5 * this.forceConfig.repulsion * combinedFactor / Math.abs(dy)
|
||||
nodeA.fx += (dx >= 0 ? 1 : -1) * horizRepulsion
|
||||
}
|
||||
}
|
||||
|
||||
// 添加额外随机微扰,避免对称死锁
|
||||
if (i % 3 === 0) { // 只应用于部分节点,避免过度扰动
|
||||
const randomFactor = 0.02
|
||||
nodeA.fx += (Math.random() - 0.5) * randomFactor
|
||||
nodeA.fy += (Math.random() - 0.5) * randomFactor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新节点位置
|
||||
updatePositions(nodes) {
|
||||
nodes.forEach(node => {
|
||||
// 更新节点位置
|
||||
if (!node.hasCustomPosition()) {
|
||||
node.left += node.fx
|
||||
node.top += node.fy
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 解决节点重叠问题
|
||||
resolveOverlaps(nodes) {
|
||||
const iterations = this.forceConfig.overlapIterations || 8
|
||||
let overlapsFound = true
|
||||
|
||||
// 多次迭代处理重叠问题
|
||||
for (let iter = 0; iter < iterations && overlapsFound; iter++) {
|
||||
overlapsFound = false
|
||||
|
||||
// 解决节点之间的重叠
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const nodeA = nodes[i]
|
||||
if (nodeA.hasCustomPosition()) continue
|
||||
|
||||
// 检查与其他节点的重叠
|
||||
for (let j = i + 1; j < nodes.length; j++) {
|
||||
const nodeB = nodes[j]
|
||||
if (nodeB.hasCustomPosition()) continue
|
||||
|
||||
// 检查两个节点是否重叠
|
||||
if (this.checkOverlap(nodeA, nodeB)) {
|
||||
overlapsFound = true
|
||||
this.resolveNodeOverlap(nodeA, nodeB)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理节点与连线重叠的问题
|
||||
this.resolveNodeLineOverlaps(nodes);
|
||||
}
|
||||
|
||||
return overlapsFound
|
||||
}
|
||||
|
||||
// 检查两个节点是否重叠
|
||||
checkOverlap(nodeA, nodeB) {
|
||||
// 应用节点间距系数,确保节点之间有足够的空间
|
||||
const marginX = this.getMarginX(Math.max(nodeA.layerIndex, nodeB.layerIndex)) * this.forceConfig.nodeSpacingFactor;
|
||||
const marginY = this.getMarginY(Math.max(nodeA.layerIndex, nodeB.layerIndex)) * this.forceConfig.nodeSpacingFactor;
|
||||
|
||||
// 节点实际边界,考虑边距
|
||||
const aLeft = nodeA.left - marginX / 2;
|
||||
const aRight = nodeA.left + nodeA.width + marginX / 2;
|
||||
const aTop = nodeA.top - marginY / 2;
|
||||
const aBottom = nodeA.top + nodeA.height + marginY / 2;
|
||||
|
||||
const bLeft = nodeB.left - marginX / 2;
|
||||
const bRight = nodeB.left + nodeB.width + marginX / 2;
|
||||
const bTop = nodeB.top - marginY / 2;
|
||||
const bBottom = nodeB.top + nodeB.height + marginY / 2;
|
||||
|
||||
// 判断重叠
|
||||
return !(
|
||||
aRight < bLeft ||
|
||||
aLeft > bRight ||
|
||||
aBottom < bTop ||
|
||||
aTop > bBottom
|
||||
);
|
||||
}
|
||||
|
||||
// 解决两个节点的重叠
|
||||
resolveNodeOverlap(nodeA, nodeB) {
|
||||
if (nodeA.hasCustomPosition() || nodeB.hasCustomPosition()) return
|
||||
|
||||
// 计算节点中心点
|
||||
const centerAx = nodeA.left + nodeA.width / 2
|
||||
const centerAy = nodeA.top + nodeA.height / 2
|
||||
const centerBx = nodeB.left + nodeB.width / 2
|
||||
const centerBy = nodeB.top + nodeB.height / 2
|
||||
|
||||
// 计算两节点中心之间的向量
|
||||
const dx = centerBx - centerAx
|
||||
const dy = centerBy - centerAy
|
||||
const distance = Math.max(1, Math.sqrt(dx * dx + dy * dy))
|
||||
|
||||
// 计算两节点之间的最小所需距离(考虑边距和节点间距系数)
|
||||
const marginX = this.getMarginX(Math.max(nodeA.layerIndex, nodeB.layerIndex)) * this.forceConfig.nodeSpacingFactor;
|
||||
const marginY = this.getMarginY(Math.max(nodeA.layerIndex, nodeB.layerIndex)) * this.forceConfig.nodeSpacingFactor;
|
||||
|
||||
// 计算水平和垂直方向所需的最小距离
|
||||
const minDistanceX = (nodeA.width + nodeB.width) / 2 + marginX;
|
||||
const minDistanceY = (nodeA.height + nodeB.height) / 2 + marginY;
|
||||
|
||||
// 基于两节点中心之间的角度,计算综合的最小距离
|
||||
const angle = Math.atan2(Math.abs(dy), Math.abs(dx));
|
||||
const minDistance = Math.min(
|
||||
Math.abs(minDistanceX / Math.cos(angle)),
|
||||
Math.abs(minDistanceY / Math.sin(angle + 0.0001)) // 添加极小值避免除零错误
|
||||
);
|
||||
|
||||
// 如果当前距离小于最小距离,则移动节点
|
||||
if (distance < minDistance) {
|
||||
// 计算需要移动的距离
|
||||
const moveDistance = (minDistance - distance) / 2;
|
||||
|
||||
// 计算移动方向的单位向量
|
||||
const unitDx = dx / distance;
|
||||
const unitDy = dy / distance;
|
||||
|
||||
// 应用移动
|
||||
// 考虑节点的层级关系,父节点或更重要的节点移动较少
|
||||
const weightA = nodeA.selfGravityFactor || 0.1;
|
||||
const weightB = nodeB.selfGravityFactor || 0.1;
|
||||
const totalWeight = weightA + weightB;
|
||||
|
||||
// 根据权重确定移动比例
|
||||
const ratioA = weightB / totalWeight;
|
||||
const ratioB = weightA / totalWeight;
|
||||
|
||||
// 移动节点
|
||||
nodeA.left -= unitDx * moveDistance * ratioA;
|
||||
nodeA.top -= unitDy * moveDistance * ratioA;
|
||||
nodeB.left += unitDx * moveDistance * ratioB;
|
||||
nodeB.top += unitDy * moveDistance * ratioB;
|
||||
}
|
||||
}
|
||||
|
||||
// 处理节点与连线的重叠
|
||||
resolveNodeLineOverlaps(nodes) {
|
||||
// 收集所有的连线信息
|
||||
const allLines = this.collectAllLines(nodes);
|
||||
|
||||
// 对每个节点检测与所有连线的重叠
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const node = nodes[i];
|
||||
if (node.hasCustomPosition()) continue;
|
||||
|
||||
// 检查此节点与每条不直接相连的线的关系
|
||||
for (const line of allLines) {
|
||||
// 排除节点自身相连的线
|
||||
if (line.fromNode === node || line.toNode === node) continue;
|
||||
|
||||
// 检测节点与线的重叠
|
||||
if (this.checkNodeLineOverlap(node, line)) {
|
||||
// 移动节点,避开连线
|
||||
this.resolveNodeLineOverlap(node, line);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 收集所有的连线信息
|
||||
collectAllLines(nodes) {
|
||||
const lines = [];
|
||||
|
||||
// 遍历所有的父子节点关系,构建连线信息
|
||||
nodes.forEach(node => {
|
||||
if (node.parent && node.parent.children) {
|
||||
// 创建连线对象,包含起点、终点和连线参数
|
||||
const fromNode = node.parent;
|
||||
const toNode = node;
|
||||
|
||||
// 计算连线的起点和终点坐标(节点中心点)
|
||||
const fromX = fromNode.left + fromNode.width / 2;
|
||||
const fromY = fromNode.top + fromNode.height / 2;
|
||||
const toX = toNode.left + toNode.width / 2;
|
||||
const toY = toNode.top + toNode.height / 2;
|
||||
|
||||
lines.push({
|
||||
fromNode,
|
||||
toNode,
|
||||
fromX,
|
||||
fromY,
|
||||
toX,
|
||||
toY,
|
||||
// 保存贝塞尔曲线控制点,用于节点-线重叠检测
|
||||
// 计算控制点,与 cubicBezierPath 方法类似
|
||||
controlPoints: this.calculateBezierControlPoints(fromX, fromY, toX, toY)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
// 计算贝塞尔曲线控制点
|
||||
calculateBezierControlPoints(x1, y1, x2, y2) {
|
||||
// 计算两点之间的距离
|
||||
const dx = x2 - x1;
|
||||
const dy = y2 - y1;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
// 控制点距离
|
||||
const controlDistance = distance * 0.5;
|
||||
|
||||
// 计算控制点方向的角度
|
||||
const angle = Math.atan2(dy, dx);
|
||||
|
||||
// 根据角度调整控制点
|
||||
const controlAngle1 = angle * 0.8;
|
||||
const controlAngle2 = angle * 1.2;
|
||||
|
||||
// 计算控制点
|
||||
const cx1 = x1 + Math.cos(controlAngle1) * controlDistance;
|
||||
const cy1 = y1 + Math.sin(controlAngle1) * controlDistance * this.forceConfig.ySpacingFactor;
|
||||
const cx2 = x2 - Math.cos(controlAngle2) * controlDistance;
|
||||
const cy2 = y2 - Math.sin(controlAngle2) * controlDistance * this.forceConfig.ySpacingFactor;
|
||||
|
||||
return { cx1, cy1, cx2, cy2 };
|
||||
}
|
||||
|
||||
// 检查节点是否与连线重叠
|
||||
checkNodeLineOverlap(node, line) {
|
||||
// 节点不应该与连接到其父节点或子节点的线检查重叠
|
||||
if (
|
||||
line.fromNode === node ||
|
||||
line.toNode === node ||
|
||||
(node.parent && line.fromNode === node.parent) ||
|
||||
(node.children && node.children.includes(line.toNode))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 节点中心点
|
||||
const nodeCenterX = node.left + node.width / 2;
|
||||
const nodeCenterY = node.top + node.height / 2;
|
||||
|
||||
// 节点的边界矩形(考虑一定的边距)
|
||||
const nodeLeft = node.left - this.forceConfig.nodeLineMinDistance;
|
||||
const nodeRight = node.left + node.width + this.forceConfig.nodeLineMinDistance;
|
||||
const nodeTop = node.top - this.forceConfig.nodeLineMinDistance;
|
||||
const nodeBottom = node.top + node.height + this.forceConfig.nodeLineMinDistance;
|
||||
|
||||
// 快速边界框检查 - 如果连线的起点和终点都在节点的同一侧,则不可能相交
|
||||
if (
|
||||
(line.fromX < nodeLeft && line.toX < nodeLeft) ||
|
||||
(line.fromX > nodeRight && line.toX > nodeRight) ||
|
||||
(line.fromY < nodeTop && line.toY < nodeTop) ||
|
||||
(line.fromY > nodeBottom && line.toY > nodeBottom)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 贝塞尔曲线与矩形的碰撞检测比较复杂
|
||||
// 这里使用简化的近似检测:检查曲线上的多个点是否在节点矩形内
|
||||
|
||||
// 从贝塞尔曲线上采样多个点
|
||||
const { cx1, cy1, cx2, cy2 } = line.controlPoints;
|
||||
const samples = 10; // 采样点数量
|
||||
|
||||
for (let i = 0; i <= samples; i++) {
|
||||
const t = i / samples;
|
||||
|
||||
// 三次贝塞尔曲线公式 B(t) = (1-t)³P₀ + 3(1-t)²tP₁ + 3(1-t)t²P₂ + t³P₃
|
||||
const mt = 1 - t;
|
||||
const mt2 = mt * mt;
|
||||
const mt3 = mt2 * mt;
|
||||
const t2 = t * t;
|
||||
const t3 = t2 * t;
|
||||
|
||||
const x = mt3 * line.fromX + 3 * mt2 * t * cx1 + 3 * mt * t2 * cx2 + t3 * line.toX;
|
||||
const y = mt3 * line.fromY + 3 * mt2 * t * cy1 + 3 * mt * t2 * cy2 + t3 * line.toY;
|
||||
|
||||
// 检查点是否在节点矩形内
|
||||
if (x >= nodeLeft && x <= nodeRight && y >= nodeTop && y <= nodeBottom) {
|
||||
return true; // 找到一个重叠点
|
||||
}
|
||||
}
|
||||
|
||||
return false; // 没有重叠
|
||||
}
|
||||
|
||||
// 解决节点与连线的重叠
|
||||
resolveNodeLineOverlap(node, line) {
|
||||
// 计算节点中心点
|
||||
const nodeCenterX = node.left + node.width / 2;
|
||||
const nodeCenterY = node.top + node.height / 2;
|
||||
|
||||
// 计算连线的中心点(简化为起点和终点的中点)
|
||||
const lineCenterX = (line.fromX + line.toX) / 2;
|
||||
const lineCenterY = (line.fromY + line.toY) / 2;
|
||||
|
||||
// 计算从线中心到节点中心的方向向量
|
||||
const dx = nodeCenterX - lineCenterX;
|
||||
const dy = nodeCenterY - lineCenterY;
|
||||
const distance = Math.max(1, Math.sqrt(dx * dx + dy * dy));
|
||||
|
||||
// 计算单位向量
|
||||
const unitDx = dx / distance;
|
||||
const unitDy = dy / distance;
|
||||
|
||||
// 移动距离与权重系数
|
||||
const moveDistance = this.forceConfig.nodeLineMinDistance * this.forceConfig.nodeLineAvoidanceWeight;
|
||||
|
||||
// 沿着垂直于连线的方向移动节点,以避开连线
|
||||
node.left += unitDx * moveDistance;
|
||||
node.top += unitDy * moveDistance;
|
||||
}
|
||||
|
||||
// 绘制连线,连接该节点到其子节点
|
||||
renderLine(node, lines, style, ) { // lineStyle
|
||||
this.renderLineCurve(node, lines, style)
|
||||
// if (lineStyle === 'curve') {
|
||||
// this.renderLineCurve(node, lines, style)
|
||||
// } else if (lineStyle === 'direct') {
|
||||
// this.renderLineDirect(node, lines, style)
|
||||
// } else {
|
||||
// this.renderLineStraight(node, lines, style)
|
||||
// }
|
||||
}
|
||||
|
||||
// 直线风格连线
|
||||
renderLineStraight(node, lines, style) {
|
||||
if (node.children.length <= 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const { left, top, width, height } = node
|
||||
const nodeUseLineStyle = this.mindMap.themeConfig.nodeUseLineStyle
|
||||
|
||||
node.children.forEach((item, index) => {
|
||||
const x1 = left + width / 2
|
||||
const y1 = top + height / 2
|
||||
const x2 = item.left + item.width / 2
|
||||
const y2 = item.top + item.height / 2
|
||||
|
||||
const path = `M ${x1},${y1} L ${x2},${y2}`
|
||||
this.setLineStyle(style, lines[index], path, item)
|
||||
})
|
||||
}
|
||||
|
||||
// 直连风格
|
||||
renderLineDirect(node, lines, style) {
|
||||
this.renderLineStraight(node, lines, style)
|
||||
}
|
||||
|
||||
// 曲线风格连线
|
||||
renderLineCurve(node, lines, style) {
|
||||
if (node.children.length <= 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const { left, top, width, height } = node
|
||||
const nodeUseLineStyle = this.mindMap.themeConfig.nodeUseLineStyle
|
||||
|
||||
node.children.forEach((item, index) => {
|
||||
const x1 = left + width / 2
|
||||
const y1 = top + height / 2
|
||||
const x2 = item.left + item.width / 2
|
||||
const y2 = item.top + item.height / 2
|
||||
|
||||
// 创建曲线连接
|
||||
const path = this.cubicBezierPath(x1, y1, x2, y2)
|
||||
this.setLineStyle(style, lines[index], path, item)
|
||||
})
|
||||
}
|
||||
|
||||
// 渲染按钮
|
||||
renderExpandBtn(node, btn) {
|
||||
const { width, height, expandBtnSize } = node
|
||||
let { translateX, translateY } = btn.transform()
|
||||
|
||||
// 计算按钮位置 - 在环形布局中,位于节点的右侧
|
||||
const _x = width
|
||||
const _y = height / 2
|
||||
|
||||
// 位置没有变化则返回
|
||||
if (_x === translateX && _y === translateY) {
|
||||
return
|
||||
}
|
||||
|
||||
const x = _x - translateX
|
||||
const y = _y - translateY
|
||||
btn.translate(x, y)
|
||||
}
|
||||
|
||||
// 创建概要节点
|
||||
renderGeneralization(list) {
|
||||
list.forEach(item => {
|
||||
const {
|
||||
top,
|
||||
bottom,
|
||||
left,
|
||||
right,
|
||||
generalizationLineMargin,
|
||||
generalizationNodeMargin
|
||||
} = this.getNodeGeneralizationRenderBoundaries(item, 'h')
|
||||
|
||||
// 找到中心点
|
||||
const centerX = (left + right) / 2
|
||||
const centerY = (top + bottom) / 2
|
||||
|
||||
// 找到概要节点相对于中心的方向
|
||||
const nodeX = item.node.left + item.node.width / 2
|
||||
const nodeY = item.node.top + item.node.height / 2
|
||||
|
||||
// 计算概要线方向和位置
|
||||
const dx = centerX - nodeX
|
||||
const dy = centerY - nodeY
|
||||
const distance = Math.sqrt(dx * dx + dy * dy)
|
||||
const normalizedDx = dx / distance
|
||||
const normalizedDy = dy / distance
|
||||
|
||||
// 概要线的起止点
|
||||
const x1 = centerX + normalizedDx * generalizationLineMargin
|
||||
const y1 = centerY + normalizedDy * generalizationLineMargin
|
||||
const x2 = centerX - normalizedDx * generalizationLineMargin
|
||||
const y2 = centerY - normalizedDy * generalizationLineMargin
|
||||
|
||||
// 控制点
|
||||
const cx = (x1 + x2) / 2 + normalizedDy * 30
|
||||
const cy = (y1 + y2) / 2 - normalizedDx * 30
|
||||
|
||||
// 绘制概要线
|
||||
const path = `M ${x1},${y1} Q ${cx},${cy} ${x2},${y2}`
|
||||
item.generalizationLine.plot(path)
|
||||
|
||||
// 定位概要节点
|
||||
item.generalizationNode.left = x2 - item.generalizationNode.width / 2
|
||||
item.generalizationNode.top = y2 - item.generalizationNode.height / 2
|
||||
})
|
||||
}
|
||||
|
||||
// 渲染展开收起按钮的隐藏占位元素
|
||||
renderExpandBtnRect(rect, expandBtnSize, width, height, node) {
|
||||
// 在环形布局中,统一放在右侧
|
||||
rect.size(expandBtnSize, height).x(width).y(0)
|
||||
}
|
||||
}
|
||||
|
||||
export default ForceDirected
|
||||
11806
web/pnpm-lock.yaml
generated
Normal file
11806
web/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user