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

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

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

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

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

组织架构图示例

(图1)

技术选型

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

目标

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

(图2)

问题分析

问题拆解

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

分析比较

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

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

设计

基础布局设计

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

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

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

定义如下

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

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

相关推荐
唐叔在学习4 分钟前
【唐叔学算法】第18天:解密选择排序的双重魅力-直接选择排序与堆排序的Java实现及性能剖析
数据结构·算法·排序算法
bloxed5 分钟前
前端文件下载多方式集合
前端·filedownload
余生H11 分钟前
前端Python应用指南(三)Django vs Flask:哪种框架适合构建你的下一个Web应用?
前端·python·django
LUwantAC19 分钟前
CSS(四)display和float
前端·css
cwtlw23 分钟前
CSS学习记录20
前端·css·笔记·学习
界面开发小八哥28 分钟前
「Java EE开发指南」如何用MyEclipse构建一个Web项目?(一)
java·前端·ide·java-ee·myeclipse
Kenneth風车36 分钟前
【机器学习(九)】分类和回归任务-多层感知机(Multilayer Perceptron,MLP)算法-Sentosa_DSML社区版 (1)11
算法·机器学习·分类
最后一个bug41 分钟前
rt-linux中使用mlockall与free的差异
linux·c语言·arm开发·单片机·嵌入式硬件·算法
米奇妙妙wuu43 分钟前
react使用sse流实现chat大模型问答,补充css样式
前端·css·react.js
傻小胖1 小时前
React 生命周期完整指南
前端·react.js