开发思维导图(一)——绘制树图

1,398 阅读16分钟

事情的起因是这样的。有一天我正盯着桌上的苹果发呆,认真思考着它想不想被我吃掉的问题。突然有个同事过来跟我说,他很好奇如何用 canvas 绘图并添加交互。于是热心的我决定满足他的好奇心,用 canvas 来制作一个思维导图程序,并记录开发的过程。

第一篇文章的目标是计算出思维导图中各个树节点的位置,并绘制出静态树图。最终的效果会是这个样子:

最终效果图

如果你对这个系列的文章感兴趣,可以通过点赞等方式让我知道你在催更。另外,你可以通过这个链接体验最新的效果:mind-mapping.xingyu1993.cn/

创建项目

首先我们要创建一个 H5 项目,如果这让你觉得我在水字数,请你一定不要相信自己的感觉,毕竟很多时候感觉是很不靠谱的东西(比如我此刻感觉这篇文章会收到 30 个赞)。

我们可以使用 vite 来创建项目,官网地址是:cn.vitejs.dev/guide/#scaf…

创建时选择 Vanilla 项目(原生 JS 项目),并可以选择使用 Typescript。

$ npm create vite@latest
✔ Project name: … tree-demo
✔ Select a framework: › Vanilla
✔ Select a variant: › TypeScript

然后,我们把 main.ts 清空,并把 css 重置一下:

html,
body,
canvas {
  display: block;
  width: 100%;
  height: 100%;
  margin: 0;
  border-color: #99f;
}

是的,我们计划仅使用一个占满窗口的 <canvas> 来绘制我们的程序。

创建画布

我们动态创建一个画布,并把它添加到 <body> 中。

const canvas = document.createElement("canvas");
document.body.append(canvas);
const ctx = canvas.getContext("2d") as CanvasRenderingContext2D;

然后,在窗口大小改变时,重新调整 <canvas> 的大小。

const resizeCanvas = () => {
  const { width, height } = canvas.getBoundingClientRect();
  canvas.width = width;
  canvas.height = height;
  // 重置画布后需要重新设置画布参数
  ctx.font = "20px Arial";
  ctx.textAlign = "center";
  ctx.textBaseline = "middle";
};
window.addEventListener("resize", resizeCanvas);
window.addEventListener("load", resizeCanvas, { once: true });

虽然现在看起来屏幕上仍是一片空白,但你确实拥有一个自适应大小的画布了。

定义常量

为了灵活控制最终绘制的效果,我们需要定义一组常量。

// 节点之间的水平间距
const NODE_SPACING_X = 100;
// 节点之间的垂直间距
const NODE_SPACING_Y = 20;
// 节点水平内边距
const NODE_PADDING_X = 16;
// 节点垂直内边距
const NODE_PADDING_Y = 8;
// 节点内文本的行高
const LINE_HEIGHT = 30;

各个常量的具体含义如下:

常量示意图1

常量示意图2

定义节点

class TreeNode {
  // 节点宽度
  width: number;

  // 节点高度
  height: number;

  // 节点横坐标
  x = 0;

  // 节点纵坐标
  y = 0;

  // 父节点
  parent: TreeNode | null = null;

  // 子节点
  children: TreeNode[] = [];

  constructor(public text: string) {
    const { width } = ctx.measureText(text);
    this.width = width + NODE_PADDING_X * 2;
    this.height = LINE_HEIGHT + NODE_PADDING_Y * 2;
  }
}

widthheight 为节点的宽高,创建节点时,根据内容文本的宽高决定。节点的宽度和高度可以是动态的,它们是计算节点位置的依据。

xy 是节点最终在画布上的位置,也是节点中心点的坐标。这里初始化为 0,但构建好树结构后,需要重新计算位置坐标。

parentchildren 用于存储树结构。

节点属性示意图

接着,我们参照 DOM API 给这个节点类添加一个 append 方法,用于构造树结构。

class TreeNode {
  // ...
  append(node: TreeNode) {
    // 如果已经挂载到父节点,需要先从父节点移除
    if (node.parent) {
      const nodeIndex = node.parent.children.indexOf(node);
      if (nodeIndex !== -1) {
        node.parent.children.splice(nodeIndex, 1);
      }
    }
    node.parent = this;
    this.children.push(node);
  }
}

与 DOM 节点类似,一个节点只能有一个父节点。如果已经有一个父节点,append 时就需要先从之前的父节点移除。

绘制节点

接下来绘制节点。

function drawNode(node: TreeNode) {
  // 绘制边框,颜色取画布的 css 边框颜色
  ctx.strokeStyle = getComputedStyle(canvas).borderColor;
  ctx.strokeRect(
    node.x - node.width / 2,
    node.y - node.height / 2,
    node.width,
    node.height
  );
  // 绘制文字,颜色取画布的 css 文本颜色
  ctx.fillStyle = getComputedStyle(canvas).color;
  ctx.fillText(node.text, node.x, node.y, node.width);
  for (const childNode of node.children) {
    drawNode(childNode);
  }
}

然后试试创建一棵树,并把它绘制出来。

function drawDemo() {
  const root = new TreeNode("ROOT");
  root.append(new TreeNode("A1"));
  root.append(new TreeNode("A2"));

  ctx.clearRect(0, 0, canvas.width, canvas.height);
  drawNode(root);
}

resizeCanvas 中绘制。

const resizeCanvas = () => {
  // ...
  drawDemo();
};

节点绘制到了左上角

我们发现,所有的节点都绘制在了初始坐标 (0, 0) 的位置,这让我们有些看不清。我们把初始坐标设置到画布左侧垂直居中的位置。

class TreeNode {
  // ...
  // 节点横坐标
  x = 80;
  // 节点纵坐标
  y = canvas.getBoundingClientRect().height / 2;
  // ...
}

节点重叠在一起了

计算节点横坐标

接下来我们计算各个节点的横坐标,同一层级中的节点横坐标是一样的。层级的宽度是该层级中最宽的那个节点的宽度,层级之间的水平间距是常量 NODE_SPACING_X 的值。

横坐标计算示意图

function calcChildrenPositionX(node: TreeNode) {
  // 当前层级中的所有节点
  const nodes = new Set([node]);
  while (nodes.size) {
    // 当前层级的横坐标是层级中任意节点的水平坐标
    const parentLevelX = [...nodes][0].x;
    // 当前层级的宽度是层级中最宽的那个节点的宽度
    const parentLevelWidth = Math.max(
      ...Array.from(nodes, (parentNode) => parentNode.width)
    );
    // 这里需要定义一个函数来把 nodes 中的节点换成下一个层级中的节点
    fillWithNextLevel(nodes);
    // 计算子层级的宽度
    const childLevelWidth = Math.max(
      ...Array.from(nodes, (childNode) => childNode.width)
    );
    // 计算子层级的横坐标
    const childLevelX =
      parentLevelX +
      parentLevelWidth / 2 +
      NODE_SPACING_X +
      childLevelWidth / 2;
    // 设置子层级中所有节点的横坐标,然后在 while 循环中计算所有后代节点横坐标
    for (const childNode of nodes) {
      childNode.x = childLevelX;
    }
  }
}

然后是 fillWithNextLevel 函数的实现,这个函数把当前层级中的节点替换成下一个层级中的节点。

function fillWithNextLevel(nodes: Set<TreeNode>) {
  // 计算下一层级的节点
  const nextLevelNodes = new Set<TreeNode>();
  for (const parentNode of nodes) {
    for (const childNode of parentNode.children) {
      nextLevelNodes.add(childNode);
    }
  }
  // 将下一层级的节点移动到当前层级
  nodes.clear();
  for (const childNode of nextLevelNodes) {
    nodes.add(childNode);
  }
  nextLevelNodes.clear();
}

然后我们在绘制节点之前计算一下横坐标。

function drawDemo() {
  const root = new TreeNode("ROOT");
  root.append(new TreeNode("A1"));
  root.append(new TreeNode("A2"));

  calcChildrenPositionX(root);

  ctx.clearRect(0, 0, canvas.width, canvas.height);
  drawNode(root);
}

顺便把节点之间的连线画出来,方法是对每个子节点,从父节点右侧中点画一条线到子节点左侧中点。

function drawNode(node: TreeNode) {
  if (node.parent) {
    // 绘制连接线
    ctx.moveTo(node.parent.x + node.parent.width / 2, node.parent.y);
    ctx.lineTo(node.x - node.width / 2, node.y);
    ctx.strokeStyle = "#fcc";
    ctx.stroke();
  }
  // 绘制边框
  // 绘制文字
}

计算节点横坐标效果图

注意后绘制的内容会覆盖先绘制的内容,所以我们需要先绘制次要的元素,后绘制更重要的元素。

构建一棵复杂的树

由于纵坐标的计算比较复杂,我们需要构建一棵更加复杂的树来讨论复杂的情况。

我们先定义一下树结构:

type NodeData =
  | string
  | {
      text: string;
      children?: NodeData[];
    };

const data: NodeData[] = [
  {
    text: "A1",
    children: [
      "B1",
      "B2",
      {
        text: "B3",
        children: ["F1", "F2", "F3", "F4"],
      },
      "B4",
    ],
  },
  "A2",
  "乱入的节点",
  {
    text: "A3",
    children: ["C1", "C2"],
  },
  {
    text: "A4",
    children: [
      {
        text: "D1",
        children: ["E1", "E2", "E3", "E4", "E5", "E6"],
      },
    ],
  },
];

然后我们写一个函数来将这个定义的树结构转换成真实的树节点。

function appendChild(parent: TreeNode, child: NodeData) {
  if (typeof child === "string") {
    // 挂载文本格式的子节点
    parent.append(new TreeNode(child));
  } else {
    // 挂载对象格式的子节点
    const childNode = new TreeNode(child.text);
    parent.append(childNode);
    // 递归挂载后代节点
    child.children?.forEach((node) => {
      appendChild(childNode, node);
    });
  }
}

然后构建这棵复杂的树。

function drawDemo() {
  const root = new TreeNode("ROOT");

  data.forEach((node) => {
    appendChild(root, node);
  });

  calcChildrenPositionX(root);

  ctx.clearRect(0, 0, canvas.width, canvas.height);
  drawNode(root);
}

由于目前还没有计算各个节点的纵坐标,所以这个树被画成了这个样子:

在同一个纵坐标上的树

节点纵坐标计算规则探究

参照横坐标的计算方式,我们很容易想到一个类似的方式来计算节点的纵坐标。这个规则是,相邻的兄弟节点之间的纵向距离为一个固定值,即常量 NODE_SPACING_Y 的值。

节点纵向坐标简单计算规则示意图

但是这个规则并不完善,因为子树比较复杂的时候是会重叠的。

子树重叠示意图

我们考虑两个相邻兄弟节点的最近距离,需要把子树考虑进去。当两棵子树互相靠近的时候,需要确保每一个层级都不重叠,且距离需不小于常量 NODE_SPACING_Y 的值。

两棵子树的最小距离示意图

这样,我们就可以确定相邻的两个兄弟节点之间的距离了。

但是,只考虑相邻兄弟节点之间的距离是不够的,因为不相邻的兄弟节点的子树也有可能重叠。我们考虑 3 个毗邻的兄弟节点 A1、A2、A3,先计算 A1 和 A2 的距离,使得这两个节点及其子树不重叠。再计算 A2 和 A3 的距离,让 A2 和 A3 的子树也不重叠,但 A1 和 A3 的子树却重叠了,如下图所示。

非相邻兄弟节点重叠示意图

所以我们不得不再次改进计算规则,每加入一个节点时,需要从近到远依次和它前面的兄弟节点比较。当对比到某个节点发现需要下移自身位置时,需要把这个下移的距离分摊给它们之间的其他节点。

只有两个节点时的位置

第3个节点和第2个节点对比

第3个节点和第1个节点对比

均分节点之间的距离

我们总结一下,我们需要先有一个计算兄弟节点距离的方法。首先为这两个兄弟节点构建好子树,这可以使用递归的方法。然后对比每一个层级的位置,确保每个层级都不重叠,从而倒推出这两个兄弟节点之间应有的最小距离。

然后,我们依次加入兄弟节点。每加入一个节点的时候,从后往前依次与前面的兄弟节点对比,计算与它们的最小距离,从而不断调整位置。

经过以上的方式,我们就可以算出兄弟节点之间的距离了,也就是任意一个节点的子节点之间的距离。确定了子节点之间的距离,就可以确定子层级的高度,而父节点的位置是根据子层级的高度确定的,也就是子层级中间的位置。

父节点纵向位置示意图

计算节点纵坐标

计算纵坐标的关键是计算兄弟节点之间的距离。所以我们需要分两步,先计算并存储每个节点与上一个兄弟节点之间的相对距离,然后再把这个相对距离转换成画布上的绝对坐标。不过为了更方便计算,可以改成存储每个节点与父节点之间的相对纵坐标(实际上是在编码过程中发现这样存储最方便)。

另外,在计算兄弟节点距离的时候,需要对比每个层级的位置,根据这些层级的位置来倒推这两个兄弟节点的距离。所以,我们为某个节点构建子树的时候,需要存储每个层级的顶部和底部相对当前节点的坐标。

class TreeNode {
  // ...

  // 相对于父节点的纵坐标
  offsetY = 0;

  // 子层级相对本节点的纵坐标,每个数组项代表一个层级,子数组项为顶部坐标和底部坐标
  sublevelOffsetY: [number, number][] = [];

  // ...
}

相对纵坐标示意图

然后我们编写函数来计算相对坐标,再编写一个函数把相对坐标转换成绝对坐标。

/**
 * 计算子树中所有节点的相对纵坐标,即子节点的offsetY和当前节点的sublevelOffsetY
 * @param node 根节点
 */
function calcChildrenOffsetY(node: TreeNode) {
  // 为所有子节点构建子树,即计算子层级的相对纵坐标
  for (const childNode of node.children) {
    calcChildrenOffsetY(childNode);
  }
  if (node.children.length) {
    // TODO: 计算子节点的offsetY属性
  }
  // TODO: 计算传入节点的sublevelOffsetY属性
}

// 相对坐标转为绝对坐标
function calcChildrenPositionY(node: TreeNode) {
  if (node.children.length) {
    for (const childNode of node.children) {
      childNode.y = node.y + childNode.offsetY;
      calcChildrenPositionY(childNode);
    }
  }
}

function drawDemo() {
  // ...
  calcChildrenPositionX(root);
  calcChildrenOffsetY(root);
  calcChildrenPositionY(root);
  // ...
}

我们先完成计算 sublevelOffsetY 的代码,因为它相对简单。

function calcChildrenOffsetY(node: TreeNode) {
  if (node.children.length) {
    // TODO: 计算子节点的offsetY属性
  }
  // 计算传入节点的sublevelOffsetY属性
  // 根层级的坐标由根节点的高度决定
  node.sublevelOffsetY = [[-node.height / 2, node.height / 2]];
  // 其他层级的坐标根据子节点的sublevelOffsetY计算
  for (const childNode of node.children) {
    for (const [level, [top, bottom]] of childNode.sublevelOffsetY.entries()) {
      // 将当前子节点在某个层级的顶部和底部坐标转换成相对于根节点的坐标
      const levelTop = childNode.offsetY + top;
      const levelBottom = childNode.offsetY + bottom;
      // 子节点的level层级等于父节点的level+1层级
      if (!node.sublevelOffsetY[level + 1]) {
        // 靠前的子节点在此层级的顶部坐标是最高的
        node.sublevelOffsetY[level + 1] = [levelTop, levelBottom];
      } else {
        // 靠后的子节点在此层级的底部坐标是更低的
        node.sublevelOffsetY[level + 1][1] = levelBottom;
      }
    }
  }
}

计算子节点的 offsetY 属性需要分为两步。首先计算子节点之间的距离,由这个相对距离可以推算出父节点的纵坐标,从而再计算每个子节点相对于父节点的纵向坐标。

function calcChildrenOffsetY(node: TreeNode) {
  if (node.children.length) {
    // TODO: 计算子节点相对第一个子节点的纵坐标,临时存入offsetY

    // 计算子节点相对父节点的纵坐标
    const firstChild = node.children[0];
    const lastChild = node.children.slice(-1)[0];
    const childLevelTop = firstChild.offsetY - firstChild.height / 2;
    const childLevelBottom = lastChild.offsetY + lastChild.height / 2;
    // 子层级垂直中点坐标(相对于第一个子节点),即父节点的纵坐标
    const childLevelMiddleY = (childLevelTop + childLevelBottom) / 2;
    for (const childNode of node.children) {
      // offsetY原本是相对于firstChild的纵坐标,现在转换成相对于父节点
      childNode.offsetY -= childLevelMiddleY;
    }
  }
}

然后计算子节点相对于第一个子节点的纵坐标。

function calcChildrenOffsetY(node: TreeNode) {
  // ...
  if (node.children.length) {
    // 计算子节点相对第一个子节点的纵坐标,临时存入offsetY
    const firstChild = node.children[0];
    // 第一个子节点的相对于自己的纵坐标是 0
    firstChild.offsetY = 0;
    for (
      let currentNodeIndex = 1;
      currentNodeIndex < node.children.length;
      currentNodeIndex++
    ) {
      // 依次加入子节点
      const currentNode = node.children[currentNodeIndex];
      for (
        let previousNodeIndex = currentNodeIndex - 1;
        previousNodeIndex >= 0;
        previousNodeIndex--
      ) {
        // 依次计算当前加入的子节点和之前兄弟节点的距离,不断调整offsetY的值
        const previousNode = node.children[previousNodeIndex];
        // 使用一个函数来计算这两个节点之间的距离
        const distanceY = getDistanceY(previousNode, currentNode);
        const minOffsetY = previousNode.offsetY + distanceY;
        if (minOffsetY > currentNode.offsetY) {
          // 当前节点与对比的节点之间的每个节点均分需增加的间距
          const deltaY =
            (minOffsetY - currentNode.offsetY) /
            (currentNodeIndex - previousNodeIndex);
          for (
            let nodeIndex = previousNodeIndex + 1;
            nodeIndex <= currentNodeIndex;
            nodeIndex++
          ) {
            node.children[nodeIndex].offsetY +=
              deltaY * (nodeIndex - previousNodeIndex);
          }
        }
      }
    }

    // 计算子节点相对父节点的纵坐标
    // ...
  }
  // ...
}

最后来实现一下计算节点间距离的函数。

function getDistanceY(node1: TreeNode, node2: TreeNode) {
  let distanceY = 0;
  for (let level = 0; level < node1.sublevelOffsetY.length; level++) {
    // 每一个层级都不能重叠
    const node1LevelBottom = node1.sublevelOffsetY[level]?.[1];
    const node2LevelTop = node2.sublevelOffsetY[level]?.[0];
    const distanceYAccordingToLevel =
      node1LevelBottom + NODE_SPACING_Y - node2LevelTop;
    if (distanceYAccordingToLevel > distanceY) {
      distanceY = distanceYAccordingToLevel;
    }
  }
  return distanceY;
}

效果图如下:

计算节点总坐标效果图

绘制圆滑的连接线

思维导图的连线一般会比较圆滑,而不是直接用直线连过去。我们改造一下连线的代码,加入一段二次贝塞尔曲线。

function drawNode(node: TreeNode) {
  if (node.parent) {
    // 绘制连接线
    ctx.moveTo(node.parent.x + node.parent.width / 2, node.parent.y);
    ctx.quadraticCurveTo(
      node.parent.x + node.parent.width / 2 + NODE_SPACING_X / 4,
      node.y,
      node.parent.x + node.parent.width / 2 + NODE_SPACING_X / 2,
      node.y
    );
    ctx.lineTo(node.x - node.width / 2, node.y);
    ctx.strokeStyle = "#fcc";
    ctx.stroke();
  }
}

模糊的效果图

解决高分屏模糊的问题

在高分屏上,绘制出来可能会有点模糊,曲线也不够圆滑。我们通过缩放画布来改善这个问题。

const resizeCanvas = () => {
  const { width, height } = canvas.getBoundingClientRect();
  const dpr = window.devicePixelRatio;
  canvas.width = width * dpr;
  canvas.height = height * dpr;
  // 重置画布后需要重新设置画布参数
  ctx.font = "20px Arial";
  ctx.textAlign = "center";
  ctx.textBaseline = "middle";
  ctx.scale(dpr, dpr);
  drawDemo();
};

最终效果图

完整代码

最后记录一下完整的 ts 代码。

// 创建canvas
const canvas = document.createElement("canvas");
document.body.append(canvas);
const ctx = canvas.getContext("2d") as CanvasRenderingContext2D;

// 自动调整canvas大小
const resizeCanvas = () => {
  const { width, height } = canvas.getBoundingClientRect();
  const dpr = window.devicePixelRatio;
  canvas.width = width * dpr;
  canvas.height = height * dpr;
  // 重置画布后需要重新设置画布参数
  ctx.font = "20px Arial";
  ctx.textAlign = "center";
  ctx.textBaseline = "middle";
  ctx.scale(dpr, dpr);
  drawDemo();
};
window.addEventListener("resize", resizeCanvas);
window.addEventListener("load", resizeCanvas, { once: true });

// 设置常量
// 节点之间的水平间距
const NODE_SPACING_X = 100;
// 节点之间的垂直间距
const NODE_SPACING_Y = 20;
// 节点水平内边距
const NODE_PADDING_X = 16;
// 节点垂直内边距
const NODE_PADDING_Y = 8;
// 节点内文本的行高
const LINE_HEIGHT = 30;

// 定义节点
class TreeNode {
  // 节点宽度
  width: number;

  // 节点高度
  height: number;

  // 节点横坐标
  x = 80;

  // 节点纵坐标
  y = canvas.getBoundingClientRect().height / 2;

  // 相对于父节点的纵坐标
  offsetY = 0;

  // 子层级相对本节点的纵坐标,每个数组项代表一个层级,子数组项为顶部坐标和底部坐标
  sublevelOffsetY: [number, number][] = [];

  // 父节点
  parent: TreeNode | null = null;

  // 子节点
  children: TreeNode[] = [];

  constructor(public text: string) {
    const { width } = ctx.measureText(text);
    this.width = width + NODE_PADDING_X * 2;
    this.height = LINE_HEIGHT + NODE_PADDING_Y * 2;
  }

  append(node: TreeNode) {
    // 如果已经挂载到父节点,需要先从父节点移除
    if (node.parent) {
      const nodeIndex = node.parent.children.indexOf(node);
      if (nodeIndex !== -1) {
        node.parent.children.splice(nodeIndex, 1);
      }
    }
    node.parent = this;
    this.children.push(node);
  }
}

function calcChildrenPositionX(node: TreeNode) {
  // 当前层级中的所有节点
  const nodes = new Set([node]);
  while (nodes.size) {
    // 当前层级的横坐标是层级中任意节点的水平坐标
    const parentLevelX = [...nodes][0].x;
    // 当前层级的宽度是层级中最宽的那个节点的宽度
    const parentLevelWidth = Math.max(
      ...Array.from(nodes, (parentNode) => parentNode.width)
    );
    // 这里需要定义一个函数来把 nodes 中的节点换成下一个层级中的节点
    fillWithNextLevel(nodes);
    // 计算子层级的宽度
    const childLevelWidth = Math.max(
      ...Array.from(nodes, (childNode) => childNode.width)
    );
    // 计算子层级的横坐标
    const childLevelX =
      parentLevelX +
      parentLevelWidth / 2 +
      NODE_SPACING_X +
      childLevelWidth / 2;
    // 设置子层级中所有节点的横坐标,然后在 while 循环中计算所有后代节点横坐标
    for (const childNode of nodes) {
      childNode.x = childLevelX;
    }
  }
}

function fillWithNextLevel(nodes: Set<TreeNode>) {
  // 计算下一层级的节点
  const nextLevelNodes = new Set<TreeNode>();
  for (const parentNode of nodes) {
    for (const childNode of parentNode.children) {
      nextLevelNodes.add(childNode);
    }
  }
  // 将下一层级的节点移动到当前层级
  nodes.clear();
  for (const childNode of nextLevelNodes) {
    nodes.add(childNode);
  }
  nextLevelNodes.clear();
}

/**
 * 计算子树中所有节点的相对纵坐标,即子节点的offsetY和当前节点的sublevelOffsetY
 * @param node 根节点
 */
function calcChildrenOffsetY(node: TreeNode) {
  // 为所有子节点构建子树,即计算子层级的相对纵坐标
  for (const childNode of node.children) {
    calcChildrenOffsetY(childNode);
  }
  if (node.children.length) {
    // 计算子节点相对第一个子节点的纵坐标,临时存入offsetY
    const firstChild = node.children[0];
    // 第一个子节点的相对于自己的纵坐标是 0
    firstChild.offsetY = 0;
    for (
      let currentNodeIndex = 1;
      currentNodeIndex < node.children.length;
      currentNodeIndex++
    ) {
      // 依次加入子节点
      const currentNode = node.children[currentNodeIndex];
      for (
        let previousNodeIndex = currentNodeIndex - 1;
        previousNodeIndex >= 0;
        previousNodeIndex--
      ) {
        // 依次计算当前加入的子节点和之前兄弟节点的距离,不断调整offsetY的值
        const previousNode = node.children[previousNodeIndex];
        const distanceY = getDistanceY(previousNode, currentNode);
        const minOffsetY = previousNode.offsetY + distanceY;
        if (minOffsetY > currentNode.offsetY) {
          // 当前节点与对比的节点之间的每个节点均分需增加的间距
          const deltaY =
            (minOffsetY - currentNode.offsetY) /
            (currentNodeIndex - previousNodeIndex);
          for (
            let nodeIndex = previousNodeIndex + 1;
            nodeIndex <= currentNodeIndex;
            nodeIndex++
          ) {
            node.children[nodeIndex].offsetY +=
              deltaY * (nodeIndex - previousNodeIndex);
          }
        }
      }
    }

    // 计算子节点相对父节点的纵坐标
    const lastChild = node.children.slice(-1)[0];
    const childLevelTop = firstChild.offsetY - firstChild.height / 2;
    const childLevelBottom = lastChild.offsetY + lastChild.height / 2;
    // 子层级垂直中点坐标(相对于第一个子节点),即父节点的纵坐标
    const childLevelMiddleY = (childLevelTop + childLevelBottom) / 2;
    for (const childNode of node.children) {
      // offsetY原本是相对于firstChild的纵坐标,现在转换成相对于父节点
      childNode.offsetY -= childLevelMiddleY;
    }
  }
  // 计算传入节点的sublevelOffsetY属性
  // 根层级的坐标由根节点的高度决定
  node.sublevelOffsetY = [[-node.height / 2, node.height / 2]];
  // 其他层级的坐标根据子节点的sublevelOffsetY计算
  for (const childNode of node.children) {
    for (const [level, [top, bottom]] of childNode.sublevelOffsetY.entries()) {
      // 将当前子节点在某个层级的顶部和底部坐标转换成相对于根节点的坐标
      const levelTop = childNode.offsetY + top;
      const levelBottom = childNode.offsetY + bottom;
      // 子节点的level层级等于父节点的level+1层级
      if (!node.sublevelOffsetY[level + 1]) {
        // 靠前的子节点在此层级的顶部坐标是最高的
        node.sublevelOffsetY[level + 1] = [levelTop, levelBottom];
      } else {
        // 靠后的子节点在此层级的底部坐标是更低的
        node.sublevelOffsetY[level + 1][1] = levelBottom;
      }
    }
  }
}

function getDistanceY(node1: TreeNode, node2: TreeNode) {
  let distanceY = 0;
  for (let level = 0; level < node1.sublevelOffsetY.length; level++) {
    // 每一个层级都不能重叠
    const node1LevelBottom = node1.sublevelOffsetY[level]?.[1];
    const node2LevelTop = node2.sublevelOffsetY[level]?.[0];
    const distanceYAccordingToLevel =
      node1LevelBottom + NODE_SPACING_Y - node2LevelTop;
    if (distanceYAccordingToLevel > distanceY) {
      distanceY = distanceYAccordingToLevel;
    }
  }
  return distanceY;
}

// 相对坐标转为绝对坐标
function calcChildrenPositionY(node: TreeNode) {
  if (node.children.length) {
    for (const childNode of node.children) {
      childNode.y = node.y + childNode.offsetY;
      calcChildrenPositionY(childNode);
    }
  }
}

function drawNode(node: TreeNode) {
  if (node.parent) {
    // 绘制连接线
    ctx.moveTo(node.parent.x + node.parent.width / 2, node.parent.y);
    ctx.quadraticCurveTo(
      node.parent.x + node.parent.width / 2 + NODE_SPACING_X / 4,
      node.y,
      node.parent.x + node.parent.width / 2 + NODE_SPACING_X / 2,
      node.y
    );
    ctx.lineTo(node.x - node.width / 2, node.y);
    ctx.strokeStyle = "#fcc";
    ctx.stroke();
  }
  // 绘制边框,颜色取画布的 css 边框颜色
  ctx.strokeStyle = getComputedStyle(canvas).borderColor;
  ctx.strokeRect(
    node.x - node.width / 2,
    node.y - node.height / 2,
    node.width,
    node.height
  );
  // 绘制文字,颜色取画布的 css 文本颜色
  ctx.fillStyle = getComputedStyle(canvas).color;
  ctx.fillText(node.text, node.x, node.y, node.width);
  for (const childNode of node.children) {
    drawNode(childNode);
  }
}

type NodeData =
  | string
  | {
      text: string;
      children?: NodeData[];
    };

const data: NodeData[] = [
  {
    text: "A1",
    children: [
      "B1",
      "B2",
      {
        text: "B3",
        children: ["F1", "F2", "F3", "F4"],
      },
      "B4",
    ],
  },
  "A2",
  "乱入的节点",
  {
    text: "A3",
    children: ["C1", "C2"],
  },
  {
    text: "A4",
    children: [
      {
        text: "D1",
        children: ["E1", "E2", "E3", "E4", "E5", "E6"],
      },
    ],
  },
];

function appendChild(parent: TreeNode, child: NodeData) {
  if (typeof child === "string") {
    // 挂载文本格式的子节点
    parent.append(new TreeNode(child));
  } else {
    // 挂载对象格式的子节点
    const childNode = new TreeNode(child.text);
    parent.append(childNode);
    // 递归挂载后代节点
    child.children?.forEach((node) => {
      appendChild(childNode, node);
    });
  }
}

function drawDemo() {
  const root = new TreeNode("ROOT");

  data.forEach((node) => {
    appendChild(root, node);
  });

  calcChildrenPositionX(root);
  calcChildrenOffsetY(root);
  calcChildrenPositionY(root);

  ctx.clearRect(0, 0, canvas.width, canvas.height);
  drawNode(root);
}