no message

This commit is contained in:
KuroSago 2025-04-28 17:29:10 +08:00
parent 1c52c7c2df
commit 9ccaa418a2
3 changed files with 13361 additions and 0 deletions

View 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

View 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

File diff suppressed because it is too large Load Diff