Xmind 思维导图的概要实现设计

877 阅读9分钟

思维导图布局算法 -- 组织架构图

思维导图布局其实是非常常见的内容, 最常见的有类似, 思维导图, 组织架构图等。

思维导图中, 作为单独产品的不多,后续示例都已解析 Xmind 为准。 Xmind 本身支持混合布局, 计划是一篇文章直接写完所有内容,但是在写作过程中发现,篇幅太长,难以讲述清楚每一个细节,所以拆分为多篇来依次讲述。本篇仅讲述组织架构图的布局算法,组织架构图的概要实现。

所有代码都已在 github, 查看链接

组织架构图示例

(图1)

image.png

技术选型

开发之前, 首选要做的就是选择一个可行的技术方案, 基于对应的技术方案去解决目标问题

目标

  • 支持重叠(如下图所示)
  • 布局支持混排(虽然此篇不会讲述,但仍然需要考虑设计)

(图2)

image.png

问题分析

问题拆解

  1. 首先需要需要实现多个子布局, 多个子布局, 每个子布局实现策略多样, 实现方法可选择性非常多, 无论是采用flex, 正常的流式嵌套元素, grid, 定位等都可以实现.
  2. 每个子布局之间存在相同的布局计算抽象接口, 这样才能满足混排, 例如最简单的系统的流式布局也是一种默认抽象的实现.
  3. 定义抽象接口之间组合的方式 -> 定义混排策略
  4. 重叠的概要应该如何实现 -> 考虑重叠场景

分析比较

对比系统布局自定义计算布局
布局实现难度简单, 利用系统默认布局方式既可中等,需要手动计算元素位置,可能会较复杂
能否支持混排支持, 不同子树采用不同布局类型既可支持, 同流式布局策略基本一致
能否支持重叠不支持, 浏览器的任何布局都不为重叠而设计支持, 定位是实现重叠最好的方式
扩展性一般,受制于布局系统的能力强,理论上可以实现任意布局
扩展功能一般,依然需要重复计算强,布局信息已知,基于此扩展较容易

综上, 决定采用自定义计算的布局实现方式

设计

基础布局设计

先实现一个不考虑概要的布局组织架构图设计 (图3)

image.png

需求分析: 在上图中,采用红色线框,将所有的节点框选了,可以很明显的发现,每个子树所占据的空间,由当前节点和子节点大小组成,父子节点之间存在间距,兄弟节点之间也存在间距,如果用 css 盒模型去实现组织架构图的话,那么大致逻辑如下:

  1. 每个节点都是一个单独的盒子
  2. 每个子树都是一个单独的 box
  3. 每个子树的父节点都会包含子节点,层层递进(递归)。
  4. 最终所有子节点会撑开父节点,最后撑开整个组织架构图

定义如下

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
  }

}

实现最基础的算法, 父节点的布局大小是由子节点决定的。


// 组织架构图的布局实现
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 的位置并非是在它所在的子树的中央位置,而是它的直接子主题的中间位置。

image.png

父节点的位置是相对于子节点来确定的,子节点的容器位置是通过根节点来分配的

定义一个额外属性,cleft, ctop 表示当前节点在以当前节点为根节点的数中相对位置的偏移 示例, 为了更好的说明,看下图示例

image.png

   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;
  }

效果如图所示

image.png

用(产)户(品)的需求是永远都琢磨不透的,所以对于组织架构图也实现了另外一个版本,通过均分布局实现的。

image.png

包含概要的布局分析

在讲解概要布局分析之前, 先讲解概要的几个特点

  • 概要只能对父节点相同的节点做概括.(先不关注)
  • 当节点层级不同,或层级相同,但是在不同子树上时, 并不能添加概要(先不关注)
  • 概要在创建之后,可以自由修改关联的同一层级的节点 (先不关注)
  • 热知识,根节点不能创建概要 (先不关注)
  • 同一层级的概要允许重叠

image.png

  • 概要会影响概括的子节点的布局,概要会让节点的盒模型变大, 那么概要本身应该也是盒模型的一环

image.png

  • 不同层级的概要,计算会叠加, 计算规则直接相加

image.png

emmmmm.... 能够看到这里的小伙伴都是真正的勇士, 敢于直面各种奇怪的设计和内容。

问题分析,概要本身也是可以叠加计算,新建一个新的概念

summaryBox: 概要布局容器盒子 , 表示概要的大小,为什么不能使用 layoutBox ? 概要允许重叠,计算方式和 layoutBox 本身并不一致。由于summaryBox 可以重叠计算,并且是外层包裹里层 (这里是不是和layoutBox 有些类似),summaryBox 也应该是图的大小计算的一环,由于 summaryBox 本身的位置依附于概扩的节点,那么应该在概括的节点的位置计算完成之后,才能计算概要的位置。

在上述的不包含概要的实现中,计算子节点的位置,需要先计算确定父节点的位置。 假设概要存放在关联的节点中,由于是一对多(概要可以同时概括多个节点), 并不是属于一个好的存放位置,放在父节点是一个更好的选择。

image.png

实现

tips: 由于概要总是和关联的节点的中间对齐,那么可以先计算关联节点所在层级的每个节点的中心位置。通过 layoutWidth - mid(中心位置), 既可快速获得左右区域空间。获取关联的第一个和最后一个节点。那么就得到了总结相对于布局的水平坐标,垂直坐标就比较容易了,直接获取关联节点的 summaryHeight 既可

image.png

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;
  }
}

最终实现内容

image.png

需要看示例的, 查看链接

结束

Xmind 的布局虽然不是特别复杂,但也着实不简单,希望下一篇混排能够轻松实现。 大佬们,来点点赞,关注吧。

也欢迎各位大佬指正,提问,(有些草草收场的意思)。