diff --git a/simple-mind-map/src/layouts/ForceDirected.js b/simple-mind-map/src/layouts/ForceDirected.js index 80e74af9..cd9fc48e 100644 --- a/simple-mind-map/src/layouts/ForceDirected.js +++ b/simple-mind-map/src/layouts/ForceDirected.js @@ -14,8 +14,9 @@ import { } from 'd3-force' /** - * 力导向图布局,子节点全方位环形包围父节点 + * 力导向图布局 * 使用 D3.js 的 d3-force 模块实现高质量的力导向算法 + * 支持一次性布局和渐进式动画布局 */ class ForceDirected extends Base { constructor(opt = {}) { @@ -25,13 +26,13 @@ class ForceDirected extends Base { this.forceConfig = { // 基础参数 idealDistance: 100, // 理想连接距离 - repulsionStrength: 800, // 节点间斥力强度 - attractionStrength: 0.3, // 节点间引力强度 - centerStrength: 0.05, // 中心引力强度 - collideRadius: 1.2, // 碰撞半径系数 + repulsionStrength: 100, // 节点间斥力强度 + attractionStrength: 0.5, // 节点间引力强度 + centerStrength: 0.08, // 中心引力强度 + collideRadius: 1.3, // 碰撞半径系数 // 迭代参数 - iterations: 120, // 默认迭代次数 + iterations: 300, // 默认迭代次数(一次性布局时使用) alphaDecay: 0.0228, // alpha衰减率 velocityDecay: 0.4, // 速度衰减率(阻尼) @@ -40,19 +41,29 @@ class ForceDirected extends Base { initialExpandAngle: Math.PI * 2, // 初始展开角度范围 // 布局调优参数 - layerDistance: 100, // 层级间距 - siblingDistance: 30, // 兄弟节点间距 + layerDistance: 50, // 层级间距 + siblingDistance: 50, // 兄弟节点间距 descendantEffect: 0.15, // 子孙节点对父节点的引力影响 nodeSize: 1.2, // 节点大小影响系数 textSize: 0.5, // 文本大小影响系数 // 连线参数 linkStyle: 'curve', // 连线样式:curve(曲线)、straight(直线) - linkCurvature: 0.4, // 连线曲率 + linkCurvature: 0.5, // 连线曲率 // 树形结构约束 - treeSpread: 0.8, // 树形展开程度 - verticalCorrection: 0.5 // 垂直方向修正系数 + treeSpread: 1.2, // 树形展开程度 + verticalCorrection: 0.5, // 垂直方向修正系数 + + // 动画参数 + enableAnimation: false, // 是否启用动画布局 + animationDuration: 800, // 动画持续时间(毫秒) + animationSteps: 60, // 动画步数 + + // 高级参数 + adaptiveIterations: true, // 自适应迭代次数 + overlapAvoidanceIterations: 0, // 避免重叠的迭代次数 + stabilizationFactor: 0.2 // 稳定因子(减少震荡) } // 合并自定义配置 @@ -69,12 +80,25 @@ class ForceDirected extends Base { // 初始布局计算标志 this.isInitialLayout = true + + // 动画相关变量 + this.isAnimating = false + this.animationFrameId = null + this.animationStep = 0 + this.animationStartPositions = new Map() + this.animationTargetPositions = new Map() + + // 节点ID映射(用于提高查询性能) + this.nodeIdMap = new Map() } /** * 执行布局 */ doLayout(callback) { + // 取消正在进行的动画(如果有) + this.cancelAnimation() + const task = [ // 1. 计算节点基础信息 () => { @@ -82,11 +106,21 @@ class ForceDirected extends Base { }, // 2. 应用力导向布局 () => { - this.applyForceDirected() + if (this.forceConfig.enableAnimation) { + // 使用动画布局 + this.applyAnimatedForceDirected(callback) + // 由于动画是异步的,这里直接返回,不触发回调 + return true + } else { + // 使用一次性布局 + this.applyForceDirected() + } }, - // 3. 完成布局后回调 + // 3. 完成布局后回调(仅当不使用动画时执行) () => { - callback(this.root) + if (!this.forceConfig.enableAnimation) { + callback(this.root) + } } ] asyncRun(task) @@ -125,10 +159,13 @@ class ForceDirected extends Base { true, 0 ) + + // 清除节点ID映射 + this.nodeIdMap.clear() } /** - * 应用力导向布局算法 + * 应用力导向布局算法(一次性完成所有迭代) */ applyForceDirected() { // 1. 准备节点和连接数据 @@ -137,18 +174,202 @@ class ForceDirected extends Base { // 2. 设置初始布局(树形结构作为初始布局效果更佳) this.setInitialPositions() - // 3. 运行力导向模拟 - this.runForceSimulation() + // 3. 计算迭代次数(可自适应) + const iterations = this.calculateIterations() - // 4. 更新节点最终位置 + // 4. 运行力导向模拟 + this.runForceSimulation(iterations) + + // 5. 更新节点最终位置 this.updateNodePositions() - // 5. 布局后处理(避免重叠等) + // 6. 布局后处理(避免重叠等) this.postLayoutAdjustment() // 非第一次布局后重置标志 this.isInitialLayout = false } + + /** + * 计算适合当前节点数的迭代次数 + */ + calculateIterations() { + if (!this.forceConfig.adaptiveIterations) { + return this.forceConfig.iterations + } + + // 根据节点数量动态调整迭代次数 + const nodeCount = this.d3Nodes.length + + if (nodeCount <= 10) { + return Math.min(this.forceConfig.iterations, 100) + } else if (nodeCount <= 50) { + return Math.min(this.forceConfig.iterations, 200) + } else if (nodeCount <= 200) { + return Math.min(this.forceConfig.iterations, 300) + } else { + return Math.min(this.forceConfig.iterations, 500) + } + } + + /** + * 应用渐进式动画力导向布局 + */ + applyAnimatedForceDirected(callback) { + // 1. 准备节点和连接数据 + this.prepareD3Data() + + // 2. 设置初始布局 + this.setInitialPositions() + + // 3. 保存当前节点位置作为动画起点 + this.saveStartPositions() + + // 4. 运行力导向模拟 + this.runForceSimulation(this.calculateIterations()) + + // 5. 保存模拟后的位置作为动画终点 + this.saveTargetPositions() + + // 6. 执行动画 + this.startLayoutAnimation(callback) + } + + /** + * 保存动画起始位置 + */ + saveStartPositions() { + this.animationStartPositions.clear() + + walk( + this.root, + null, + (node) => { + // 只处理展开的节点 + if (node.isRoot || (node.parent && node.parent.getData('expand'))) { + // 存储起始位置 + this.animationStartPositions.set(node.id, { + left: node.left, + top: node.top + }) + } + }, + null, + true + ) + } + + /** + * 保存动画目标位置 + */ + saveTargetPositions() { + this.animationTargetPositions.clear() + + // 将D3模拟结果作为目标位置 + this.d3Nodes.forEach(d3Node => { + const node = d3Node.node + + if (!node.hasCustomPosition()) { + // 计算目标位置(从中心点坐标转换为左上角坐标) + const targetLeft = d3Node.x - node.width / 2 + const targetTop = d3Node.y - node.height / 2 + + this.animationTargetPositions.set(node.id, { + left: targetLeft, + top: targetTop + }) + } + }) + + // 重置节点到起始位置 + for (const [id, position] of this.animationStartPositions) { + const node = this.d3Nodes.find(n => n.node.id === id)?.node + if (node && !node.hasCustomPosition()) { + node.left = position.left + node.top = position.top + } + } + } + + /** + * 开始布局动画 + */ + startLayoutAnimation(callback) { + // 初始化动画参数 + this.isAnimating = true + this.animationStep = 0 + const totalSteps = this.forceConfig.animationSteps + const stepDuration = this.forceConfig.animationDuration / totalSteps + + // 定义动画帧函数 + const animate = () => { + if (!this.isAnimating || this.animationStep >= totalSteps) { + // 动画结束,执行最终布局调整 + this.postLayoutAdjustment() + + // 调用回调函数 + callback && callback(this.root) + + // 重置动画状态 + this.isAnimating = false + this.animationFrameId = null + return + } + + // 增加步数 + this.animationStep++ + + // 计算当前进度(0-1之间) + const progress = this.animationStep / totalSteps + + // 应用缓动函数 - 先慢后快再慢 + const easedProgress = this.easeInOutCubic(progress) + + // 更新每个节点的位置 + this.d3Nodes.forEach(d3Node => { + const node = d3Node.node + if (!node.hasCustomPosition()) { + const startPos = this.animationStartPositions.get(node.id) + const targetPos = this.animationTargetPositions.get(node.id) + + if (startPos && targetPos) { + // 线性插值计算当前位置 + node.left = startPos.left + (targetPos.left - startPos.left) * easedProgress + node.top = startPos.top + (targetPos.top - startPos.top) * easedProgress + } + } + }) + + // 触发重绘 + this.mindMap.emit('vie:change') + + // 安排下一帧 + this.animationFrameId = setTimeout(animate, stepDuration) + } + + // 开始动画 + animate() + } + + /** + * 缓动函数:先慢后快再慢 + */ + easeInOutCubic(t) { + return t < 0.5 + ? 4 * t * t * t + : 1 - Math.pow(-2 * t + 2, 3) / 2 + } + + /** + * 取消正在进行的动画 + */ + cancelAnimation() { + if (this.isAnimating && this.animationFrameId) { + clearTimeout(this.animationFrameId) + this.animationFrameId = null + this.isAnimating = false + } + } /** * 准备D3力导向布局需要的数据结构 @@ -188,7 +409,9 @@ class ForceDirected extends Base { // 是否固定位置 fixed: node.hasCustomPosition(), // 文本长度因子 - textFactor: (node.getData('text') || '').length * this.forceConfig.textSize + textFactor: (node.getData('text') || '').length * this.forceConfig.textSize, + // 节点重要度评分 + importance: this.calculateNodeImportance(node) } // 如果节点位置已自定义,则固定它 @@ -197,7 +420,9 @@ class ForceDirected extends Base { d3Node.fy = nodeY } + // 将节点添加到数组和映射 this.d3Nodes.push(d3Node) + this.nodeIdMap.set(node.id, d3Node) // 为非根节点创建与父节点的连接 if (!node.isRoot) { @@ -219,6 +444,33 @@ class ForceDirected extends Base { return { nodes: this.d3Nodes, links: this.d3Links } } + /** + * 计算节点重要度 - 影响布局决策 + */ + calculateNodeImportance(node) { + // 基础重要度分数 + let score = 1 + + // 节点层级影响 + score *= Math.pow(0.9, node.layerIndex) + + // 子节点数量影响 + const childrenCount = node.children ? node.children.length : 0 + score *= (1 + childrenCount / 10) + + // 文本长度影响 + const textLength = (node.getData('text') || '').length + score *= (1 + Math.min(1, textLength / 50)) + + // 扩展属性影响 + if (node.getData('priority')) { + score *= 1.5 + } + + // 缩放到合理范围 0.1-10 + return Math.max(0.1, Math.min(10, score)) + } + /** * 计算连接强度 - 影响节点间吸引力 */ @@ -237,7 +489,12 @@ class ForceDirected extends Base { const childrenCount = node.children ? node.children.length : 0 const childrenFactor = Math.min(1.5, 1 + childrenCount / 20) - return strength * depthFactor * childrenFactor * (1 + textFactor) + // 节点面积因子 + const size = node.width * node.height + const sizeFactor = Math.min(1.5, 1 + Math.sqrt(size) / 200) + + // 综合计算 + return strength * depthFactor * childrenFactor * (1 + textFactor) * sizeFactor } /** @@ -251,19 +508,27 @@ class ForceDirected extends Base { const layerFactor = 1 + (node.layerIndex - 1) * 0.2 // 根据节点大小调整距离 - const sizeFactor = Math.sqrt( - (node.width * node.height + node.parent.width * node.parent.height) / 2 - ) / 100 + const nodeSize = Math.sqrt(node.width * node.height) + const parentSize = Math.sqrt(node.parent.width * node.parent.height) + const sizeFactor = Math.sqrt(nodeSize * parentSize) / 100 + + // 根据子节点数量调整 + const childrenCount = node.parent.children ? node.parent.children.length : 0 + const childrenFactor = Math.max(0.7, Math.min(1.2, Math.log(childrenCount + 1) / Math.log(10))) + + // 根据文本长度调整 + const textLength = (node.getData('text') || '').length + const textFactor = Math.min(1.3, 1 + textLength / 100) // 综合计算理想距离 - return distance * layerFactor * (1 + sizeFactor) + return distance * layerFactor * (1 + sizeFactor) * childrenFactor * textFactor } /** - * 设置节点的初始位置 - 使用简单的径向布局作为力导向的初始位置 + * 设置节点的初始位置 - 使用树形布局作为力导向的初始位置 */ setInitialPositions() { - // 使用自定义的树形初始布局可以加速力导向算法收敛 + // 使用树形初始布局可以加速力导向算法收敛 // 按层级组织节点 const nodesByLevel = {} @@ -277,8 +542,7 @@ class ForceDirected extends Base { // 从根节点开始 const rootNode = this.d3Nodes.find(n => n.node.isRoot) - const rootX = rootNode.x - const rootY = rootNode.y + if (!rootNode) return // 对每个层级进行处理 Object.keys(nodesByLevel).forEach(level => { @@ -288,7 +552,7 @@ class ForceDirected extends Base { // 在每个层级中,按照父节点分组 const nodesByParent = {} nodesAtLevel.forEach(node => { - const parentId = this.d3Links.find(link => link.target === node.id)?.source + const parentId = this.findParentId(node.id) if (parentId !== undefined) { if (!nodesByParent[parentId]) { nodesByParent[parentId] = [] @@ -299,33 +563,41 @@ class ForceDirected extends Base { // 对于每组有共同父节点的节点,按径向布局排列 Object.entries(nodesByParent).forEach(([parentId, childNodes]) => { - const parent = this.d3Nodes.find(n => n.id === Number(parentId)) + const parent = this.nodeIdMap.get(Number(parentId)) + if (!parent) return + const count = childNodes.length // 如果此父节点只有单个子节点,以特定偏移放置 if (count === 1) { const child = childNodes[0] - const angle = Math.random() * Math.PI * 2 // 随机角度 + if (child.fixed) return + + // 根据节点ID选择一个稳定的随机角度 + const angle = ((child.id * 10007) % 360) * (Math.PI / 180) 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 - } + // 应用位置 + child.x = parent.x + Math.cos(angle) * radius + child.y = parent.y + Math.sin(angle) * radius } else { // 多个子节点,在父节点周围环形布局 const angleStep = this.forceConfig.initialExpandAngle / count + // 根据节点重要度排序 + childNodes.sort((a, b) => b.importance - a.importance) + + // 分配位置 childNodes.forEach((child, i) => { - if (child.fixed) return // 跳过固定位置的节点 + if (child.fixed) return // 计算径向位置 const angle = angleStep * i - // 半径随层级和节点数增加 + // 半径根据层级、节点重要度和大小调整 const radius = this.forceConfig.layerDistance * - (child.depth + child.textFactor * 0.1) * - this.forceConfig.initialRadius + child.depth * + this.forceConfig.initialRadius * + (0.8 + 0.4 * child.importance / 10) // 应用位置 child.x = parent.x + Math.cos(angle) * radius @@ -334,12 +606,40 @@ class ForceDirected extends Base { } }) }) + + // 添加微小随机偏移,避免初始完全对称 + this.addRandomOffset() + } + + /** + * 添加微小随机偏移 + */ + addRandomOffset() { + this.d3Nodes.forEach(d3Node => { + if (!d3Node.fixed && !d3Node.node.isRoot) { + // 添加微小随机偏移,基于节点ID确保稳定性 + const seed = d3Node.id * 10007 + const offsetX = (((seed % 100) / 100) - 0.5) * 5 + const offsetY = ((((seed / 100) % 100) / 100) - 0.5) * 5 + + d3Node.x += offsetX + d3Node.y += offsetY + } + }) + } + + /** + * 找到节点的父节点ID + */ + findParentId(nodeId) { + const link = this.d3Links.find(link => link.target === nodeId) + return link ? link.source : undefined } /** * 运行D3力导向模拟 */ - runForceSimulation() { + runForceSimulation(iterations) { // 创建力导向模拟器 this.simulation = forceSimulation(this.d3Nodes) // 设置衰减参数 @@ -360,74 +660,107 @@ class ForceDirected extends Base { // 节点间斥力 .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 - }) + .strength(d => this.calculateRepulsionStrength(d)) .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) + .radius(d => this.calculateCollideRadius(d)) + .strength(0.7 + 0.3 * this.forceConfig.stabilizationFactor) .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 - })) + // 沿X轴的力 - 使子节点倾向于分散 + .force('x', forceX().strength(d => this.calculateXForceStrength(d)) + .x(d => this.calculateXTarget(d))) - // 沿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 - })) + // 沿Y轴的力 - 使子节点倾向于有层次感 + .force('y', forceY().strength(d => this.calculateYForceStrength(d)) + .y(d => this.calculateYTarget(d))) // 一次性运行所有迭代 - for (let i = 0; i < this.forceConfig.iterations; i++) { + for (let i = 0; i < iterations; i++) { this.simulation.tick() } } + + /** + * 计算节点斥力强度 + */ + calculateRepulsionStrength(d) { + // 节点斥力随深度、子节点数量和文本长度调整 + const depthFactor = Math.pow(0.8, d.depth) + const sizeFactor = d.size / 50 + 1 + const importanceFactor = d.importance / 5 + 0.5 + + return -this.forceConfig.repulsionStrength * depthFactor * sizeFactor * importanceFactor + } + + /** + * 计算节点碰撞半径 + */ + calculateCollideRadius(d) { + // 碰撞半径 = 节点大小 + 边距 + const nodeSize = Math.max(d.node.width, d.node.height) / 2 + return nodeSize * this.forceConfig.collideRadius * Math.sqrt(d.importance / 5 + 0.5) + } + + /** + * 计算X轴力的强度 + */ + calculateXForceStrength(d) { + if (d.node.isRoot || !d.node.parent) return 0 + + // 水平方向的力,使子节点分散 + return 0.03 * this.forceConfig.treeSpread * (1 - this.forceConfig.stabilizationFactor * 0.5) + } + + /** + * 计算Y轴力的强度 + */ + calculateYForceStrength(d) { + // 对根节点无影响 + if (d.node.isRoot) return 0 + + // 垂直方向吸引力,与层级和重要度相关 + return 0.04 * this.forceConfig.verticalCorrection * (1 + d.depth * 0.1) * (1 - d.importance / 20) + } + + /** + * 计算X轴目标位置 + */ + calculateXTarget(d) { + // 如果是根节点或无父节点,返回当前位置 + if (d.node.isRoot || !d.node.parent) return d.x + + // 获取父节点位置 + const parentD3 = this.nodeIdMap.get(d.node.parent.id) + if (!parentD3) return d.x + + // 应用偏移以打破对称性(使用节点ID确保稳定性) + const offset = ((d.id % 2 === 0 ? 1 : -1) * + this.forceConfig.siblingDistance * + (1 + d.textFactor * 2) * + (0.5 + d.importance / 10)) + + return parentD3.x + offset + } + + /** + * 计算Y轴目标位置 + */ + calculateYTarget(d) { + if (d.node.isRoot || !d.node.parent) return d.y + + // 获取父节点位置 + const parentD3 = this.nodeIdMap.get(d.node.parent.id) + if (!parentD3) return d.y + + // 向下偏移,层级越深偏移越大 + const levelOffset = d.depth * this.forceConfig.layerDistance * + (0.6 + 0.4 * (d.importance / 10)) + + return parentD3.y + levelOffset + } /** * 更新节点位置数据 @@ -457,11 +790,12 @@ class ForceDirected extends Base { */ resolveOverlapping() { // 获取所有非根节点 - const nodes = this.d3Nodes.filter(d => !d.node.isRoot && !d.fixed) - .map(d => d.node) + const nodes = this.d3Nodes + .filter(d => !d.node.isRoot && !d.fixed) + .map(d => d.node) // 向外移动来解决重叠 - const iterations = 5 + const iterations = this.forceConfig.overlapAvoidanceIterations for (let iter = 0; iter < iterations; iter++) { let hasAdjusted = false @@ -519,6 +853,8 @@ class ForceDirected extends Base { * 分离重叠的节点 */ separateNodes(nodeA, nodeB) { + if (nodeA.hasCustomPosition() || nodeB.hasCustomPosition()) return + // 计算两个节点中心点 const centerAx = nodeA.left + nodeA.width / 2 const centerAy = nodeA.top + nodeA.height / 2 @@ -528,7 +864,7 @@ class ForceDirected extends Base { // 计算两节点之间的向量 const dx = centerBx - centerAx const dy = centerBy - centerAy - const distance = Math.sqrt(dx * dx + dy * dy) + const distance = Math.max(1, Math.sqrt(dx * dx + dy * dy)) if (distance < 1) { // 如果节点中心重合,则添加微小的随机偏移 @@ -563,6 +899,7 @@ class ForceDirected extends Base { 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 @@ -581,7 +918,7 @@ class ForceDirected extends Base { * 绘制连接线 - 从父节点到子节点 */ renderLine(node, lines, style) { - // 根据配置选择连线风格 + // 根据线条风格选择相应的渲染方法 if (this.forceConfig.linkStyle === 'straight') { this.renderLineStraight(node, lines, style) } else { @@ -631,10 +968,8 @@ class ForceDirected extends Base { 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 controlDistance = distance * curvature const cx1 = x1 + Math.cos(angle) * distance * 0.3 const cy1 = y1 + Math.sin(angle) * distance * 0.3 @@ -648,83 +983,65 @@ class ForceDirected extends Base { } /** - * 渲染展开/收起按钮 + * 渲染展开收起按钮 */ 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) } + + // 创建概要节点 + renderGeneralization(list) { + list.forEach(item => { + let isLeft = item.node.dir === CONSTANTS.LAYOUT_GROW_DIR.LEFT + let { + top, + bottom, + left, + right, + generalizationLineMargin, + generalizationNodeMargin + } = this.getNodeGeneralizationRenderBoundaries(item, 'h') + let x = isLeft + ? left - generalizationLineMargin + : right + generalizationLineMargin + let x1 = x + let y1 = top + let x2 = x + let y2 = bottom + let cx = x1 + (isLeft ? -20 : 20) + let cy = y1 + (y2 - y1) / 2 + let path = `M ${x1},${y1} Q ${cx},${cy} ${x2},${y2}` + item.generalizationLine.plot(path) + item.generalizationNode.left = + x + + (isLeft ? -generalizationNodeMargin : generalizationNodeMargin) - + (isLeft ? item.generalizationNode.width : 0) + item.generalizationNode.top = + top + (bottom - top - item.generalizationNode.height) / 2 + }) + } } export default ForceDirected