思维导图布局算法 -- 组织架构图
思维导图布局其实是非常常见的内容, 最常见的有类似, 思维导图, 组织架构图等。
思维导图中, 作为单独产品的不多,后续示例都已解析 Xmind 为准。 Xmind 本身支持混合布局, 计划是一篇文章直接写完所有内容,但是在写作过程中发现,篇幅太长,难以讲述清楚每一个细节,所以拆分为多篇来依次讲述。本篇仅讲述组织架构图的布局算法,组织架构图的概要实现。
所有代码都已在 github, 查看链接
组织架构图示例
(图1)
技术选型
开发之前, 首选要做的就是选择一个可行的技术方案, 基于对应的技术方案去解决目标问题
目标
- 支持重叠(如下图所示)
- 布局支持混排(虽然此篇不会讲述,但仍然需要考虑设计)
(图2)
问题分析
问题拆解
- 首先需要需要实现多个子布局, 多个子布局, 每个子布局实现策略多样, 实现方法可选择性非常多, 无论是采用flex, 正常的流式嵌套元素, grid, 定位等都可以实现.
- 每个子布局之间存在相同的布局计算抽象接口, 这样才能满足混排, 例如最简单的系统的流式布局也是一种默认抽象的实现.
- 定义抽象接口之间组合的方式 -> 定义混排策略
- 重叠的概要应该如何实现 -> 考虑重叠场景
分析比较
对比 | 系统布局 | 自定义计算布局 |
---|---|---|
布局实现难度 | 简单, 利用系统默认布局方式既可 | 中等,需要手动计算元素位置,可能会较复杂 |
能否支持混排 | 支持, 不同子树采用不同布局类型既可 | 支持, 同流式布局策略基本一致 |
能否支持重叠 | 不支持, 浏览器的任何布局都不为重叠而设计 | 支持, 定位是实现重叠最好的方式 |
扩展性 | 一般,受制于布局系统的能力 | 强,理论上可以实现任意布局 |
扩展功能 | 一般,依然需要重复计算 | 强,布局信息已知,基于此扩展较容易 |
综上, 决定采用自定义计算的布局实现方式
设计
基础布局设计
先实现一个不考虑概要的布局组织架构图设计 (图3)
需求分析: 在上图中,采用红色线框,将所有的节点框选了,可以很明显的发现,每个子树所占据的空间,由当前节点和子节点大小组成,父子节点之间存在间距,兄弟节点之间也存在间距,如果用 css 盒模型去实现组织架构图的话,那么大致逻辑如下:
- 每个节点都是一个单独的盒子
- 每个子树都是一个单独的 box
- 每个子树的父节点都会包含子节点,层层递进(递归)。
- 最终所有子节点会撑开父节点,最后撑开整个组织架构图
定义如下
typescript
interface BaseNode {
children: TreeNode[]
/**
* node width
* 节点的宽高
*/
contentWidth: number;
/**
* node height
* 节点的高度
*/
contentHeight: number;
left: number;
top: number;
/**
* 计算布局中,缓存当前节点相对于以当前节点为根节点的子树的左侧起始偏移
*/
cLeft: number;
cTop: number;
/**
* subTree width
* 子树的宽度
*/
layoutWidth: number;
/**
* subTree height
* 子树的高度
*/
layoutHeight: number;
}
// 先定义一个布局算法抽象类,为混排做准备
export abstract class layoutBase{
/**
* 每一层之间的间距, 父子节点间距
*/
parentSpacing = 40;
/**
* 兄弟节点间距
*/
brotherSpacing = 30;
/**
* 在子节点大小已知的情况下, 计算当前子树的大小
* 注意: 子节点并非和当前子树属于同一类型
*/
computeLayout(node: TreeNode): TreeNode{
throw new Error('请实现 layout ')
return node
}
/**
* 在知道父节点位置的情况下, 计算子树的容器的位置, 根节点位置 (0,0)
*/
computePosition(node: TreeNode):TreeNode{
throw new Error('请实现 computePosition')
return node
}
}
实现最基础的算法, 父节点的布局大小是由子节点决定的。
typescript
// 组织架构图的布局实现
class Organization extends layoutBase {
override brotherSpacing = 4;
override computeLayout(node: TreeNode) {
// 存在多个子节点,获取子节点之间的间隔的总和
const spacingWidth = (node.children.length - 1) * this.brotherSpacing;
// 计算子节点的子树大小之和
const contentWidth = sum(node.children.map((item) => item.layoutWidth));
// 存在子节点,那么子树需要添加父子间距
const spacingHeight = node.children.length ? this.parentSpacing : 0;
// 当前节点的子树大小, 宽度为所有子节点之后和父节点内容大小, 取最大值 (可能父节点比多个子节点还更宽)
node.layoutWidth = Math.max(spacingWidth + contentWidth, node.contentWidth);
// 子树高度,为父节点和最高子树之和并加上间距
node.layoutHeight =
Math.max(...node.children.map((item) => item.layoutHeight), 0) +
spacingHeight +
node.contentHeight;
return node;
}
}
在(图4)中,可以观察到,Xmind 的子树的父节点的位置是在所有子节点的中间, 请仔细观察(图4)分支主题1 的红色外框,会发现,分支主题1 的位置并非是在它所在的子树的中央位置,而是它的直接子主题的中间位置。
父节点的位置是相对于子节点来确定的,子节点的容器位置是通过根节点来分配的
定义一个额外属性,cleft, ctop 表示当前节点在以当前节点为根节点的数中相对位置的偏移 示例, 为了更好的说明,看下图示例
javascript
override computeLayout(node: TreeNode) {
const spacingWidth = (node.children.length - 1) * this.brotherSpacing;
const contentWidth = sum(node.children.map((item) => item.layoutWidth));
const spacingHeight = node.children.length ? this.parentSpacing : 0;
node.layoutWidth = Math.max(spacingWidth + contentWidth, node.contentWidth);
node.layoutHeight =
Math.max(...node.children.map((item) => item.layoutHeight), 0) +
spacingHeight +
node.contentHeight;
// 计算当前节点在当前子树的相对位置,递归计算,那么每次子节点的位置其实都是确定的
// 以下为新增代码
const len = node.children.length
// 当子节点数量大于 2
if(len>=2){
// 最后一个子节点
const last = node.children[len - 1]
// 第一个子节点的 cleft
const firstChildrenCenter = node.children[0].cLeft
// 最后一个子节点的 cleft
const lastChildCenter = node.layoutWidth - last.layoutWidth + last.cLeft
// 得到当前节点的cleft
node.cLeft = (firstChildrenCenter + lastChildCenter ) >> 1
}
if(len === 0){
node.cLeft = node.contentHeight >> 1
}
if(len === 1){
node.cLeft = Math.max( node.children[0].contentWidth, node.contentWidth ) >> 1
}
return node;
}
override computePosition(node: TreeNode) {
let subTreeStart: number
// 根节点的情况直接输入坐标
if(node.isRoot){
subTreeStart = -node.cLeft
node.left = 0
node.top = 0
}else {
// 非根节点获取当前节点的定位计算得到当前节点子树的左起点
subTreeStart = node.left + node.contentWidth/2 - node.cLeft
}
// 原地算法
node.children.forEach(item=>{
// 当前节点子树的左起点, 当前子树内偏移,得到节点的正确坐标
item.left = subTreeStart + item.cLeft - node.contentWidth/2
item.top = node.top + node.contentHeight + this.parentSpacing
subTreeStart += item.layoutWidth + this.brotherSpacing
})
return node;
}
效果如图所示
用(产)户(品)的需求是永远都琢磨不透的,所以对于组织架构图也实现了另外一个版本,通过均分布局实现的。
包含概要的布局分析
在讲解概要布局分析之前, 先讲解概要的几个特点
- 概要只能对父节点相同的节点做概括.(先不关注)
- 当节点层级不同,或层级相同,但是在不同子树上时, 并不能添加概要(先不关注)
- 概要在创建之后,可以自由修改关联的同一层级的节点 (先不关注)
- 热知识,根节点不能创建概要 (先不关注)
- 同一层级的概要允许重叠
- 概要会影响概括的子节点的布局,概要会让节点的盒模型变大, 那么概要本身应该也是盒模型的一环
- 不同层级的概要,计算会叠加, 计算规则直接相加
emmmmm.... 能够看到这里的小伙伴都是真正的勇士, 敢于直面各种奇怪的设计和内容。
问题分析,概要本身也是可以叠加计算,新建一个新的概念
summaryBox: 概要布局容器盒子 , 表示概要的大小,为什么不能使用 layoutBox ? 概要允许重叠,计算方式和 layoutBox 本身并不一致。由于summaryBox 可以重叠计算,并且是外层包裹里层 (这里是不是和layoutBox 有些类似),summaryBox 也应该是图的大小计算的一环,由于 summaryBox 本身的位置依附于概扩的节点,那么应该在概括的节点的位置计算完成之后,才能计算概要的位置。
在上述的不包含概要的实现中,计算子节点的位置,需要先计算确定父节点的位置。 假设概要存放在关联的节点中,由于是一对多(概要可以同时概括多个节点), 并不是属于一个好的存放位置,放在父节点是一个更好的选择。
实现
tips: 由于概要总是和关联的节点的中间对齐,那么可以先计算关联节点所在层级的每个节点的中心位置。通过 layoutWidth - mid(中心位置), 既可快速获得左右区域空间。获取关联的第一个和最后一个节点。那么就得到了总结相对于布局的水平坐标,垂直坐标就比较容易了,直接获取关联节点的 summaryHeight 既可
typescript
class OrganizationXMind extends layoutBase {
override brotherSpacing = 4;
public summarySpacing = 20;
override computeLayout(node: TreeNode) {
const spacingWidth = (node.children.length - 1) * this.brotherSpacing;
const contentWidth = sum(node.children.map((item) => item.layoutWidth));
const spacingHeight = node.children.length ? this.parentSpacing : 0;
node.layoutWidth = Math.max(spacingWidth + contentWidth, node.contentWidth);
node.layoutHeight =
Math.max(...node.children.map((item) => item.summaryHeight), 0) +
spacingHeight +
node.contentHeight;
// 计算当前节点在当前子树的相对位置,递归计算,那么每次子节点的位置其实都是确定的
const len = node.children.length;
if (len >= 2) {
const last = node.children[len - 1];
const firstChildrenCenter = node.children[0].cLeft;
const lastChildCenter = node.layoutWidth - last.layoutWidth + last.cLeft;
node.cLeft = (firstChildrenCenter + lastChildCenter) >> 1;
}
if (len === 0) {
node.cLeft = node.contentWidth >> 1;
}
if (len === 1) {
node.cLeft =
Math.max(node.children[0].contentWidth, node.contentWidth) >> 1;
}
// summary 概要计算, 先初始化
node.summaryWidth = node.layoutWidth;
node.summaryHeight = node.layoutHeight;
// 将节点转换为 map,可以快速计算出是否超出
const centerMap = new Map<string, number>();
// 当前节点 layout mid 位置
let offsetCount = 0;
node.children.forEach((item) => {
centerMap.set(item.nodeId, offsetCount + item.summaryWidth / 2);
offsetCount += item.summaryWidth + this.brotherSpacing;
});
// 左侧超出的最大值, 右侧超出的最大值
let leftOut = 0;
let rightOut = 0;
node.summary.forEach((summaryNode) => {
const { contentWidth, contentHeight, ids } = summaryNode;
// 通过关联 ids, 找到所有关联的节点
const relationNodes = ids.map((id) => centerMap.get(id)) as number[];
// 可能是同一个
const firstMid = relationNodes[0];
const lastMid = relationNodes[relationNodes.length - 1];
// 概要中点位置
const summaryMid = (firstMid + lastMid) / 2;
// 右侧剩余空间
const rightSpacing = node.layoutWidth - summaryMid;
const halfWidth = contentWidth >> 1;
// 概要在当前子树下的相对偏移
summaryNode.left = summaryMid;
// 左侧超出了
if (halfWidth > summaryMid) {
leftOut = Math.max(leftOut, halfWidth - summaryMid);
}
// 右侧超出了
if (halfWidth > rightSpacing) {
rightOut = Math.max(rightOut, halfWidth - rightSpacing);
}
node.summaryLeft = leftOut;
// 高度仅需叠加
node.summaryHeight = Math.max(
node.summaryHeight,
node.layoutHeight + this.summarySpacing + contentHeight
);
//
node.summaryWidth = node.layoutWidth + leftOut + rightOut;
});
return node;
}
override computePosition(node: TreeNode) {
let subTreeStart: number;
if (node.isRoot) {
subTreeStart = -node.cLeft;
node.left = 0;
node.top = 0;
} else {
subTreeStart =
node.left + node.contentWidth / 2 - node.cLeft + node.summaryLeft;
}
const layoutStart = subTreeStart;
const topStart = node.top + node.contentHeight + this.parentSpacing;
const nodeIdMap = new Map<string, number>();
// 原地算法
node.children.forEach((item) => {
// 计算节点的左侧坐标
item.left = subTreeStart + item.cLeft - (node.contentWidth >> 1);
item.top = topStart;
subTreeStart += item.summaryWidth + this.brotherSpacing;
nodeIdMap.set(item.nodeId, item.summaryHeight);
});
node.summary.forEach((item) => {
const { ids } = item;
const relationNodesHeight = ids.map((id) =>
nodeIdMap.get(id)
) as number[];
item.left = layoutStart + item.left - (item.contentWidth >> 1);
item.top =
Math.max(...relationNodesHeight) + this.summarySpacing + topStart;
});
return node;
}
}
最终实现内容
需要看示例的, 查看链接
结束
Xmind 的布局虽然不是特别复杂,但也着实不简单,希望下一篇混排能够轻松实现。 大佬们,来点点赞,关注吧。
也欢迎各位大佬指正,提问,(有些草草收场的意思)。