likes
comments
collection
share

[canvas]原来画树状图没那么难,下次别找库了

作者站长头像
站长
· 阅读数 17

先看成果

[canvas]原来画树状图没那么难,下次别找库了

场景

组织结构图、文件图等。

canvas 基础

多的不介绍,只介绍这次用到的一些API,详细的API就点这里看看。

moveTo

移动canvas画笔的点到指定坐标,可以理解为笔从纸上拿起来,然后移动到指定位置。

  const canvas = document.getElementById('canvas')
  const cxt = canvas.getContext('2d');
  cxt.moveTo(100, 100);

lineTo

移动canvas画笔的点到指定坐标,可以理解为笔接触到纸,然后移动到指定位置。

  const canvas = document.getElementById('canvas')
  const cxt = canvas.getContext('2d');
  cxt.lineTo(200, 100);

stroke

将所有坐标用一条线连起来(beginPath新开路径之后的点)

<canvas
  id="canvas"
  width="300"
  height="500"
>你的浏览器不支持canvas</canvas>

<script>
  const canvas = document.getElementById('canvas')
  // 1、获取 canvas 上下文环境对象
  const cxt = canvas.getContext('2d')
  // 2、绘制图形
  cxt.moveTo(100, 100)
  cxt.lineTo(200, 100)
  cxt.stroke() // 将起点和终点连接起来
</script>

rect

画个矩形

  const cxt = cnv.getContext('2d');
  ctx.rect(startX, startY, width, height);

类型

树节点类型

// 非常的简单明了,什么多余的东西都没有
export interface TreeNode {
  // 显示标题
  title: string;
  // 下一级
  children: TreeNode[];
}

绘制

先建个新的vue页面吧

<script setup name="TreeView" lang="ts">
import { reactive, watch, ref, onMounted, shallowRef } from "vue";

// 保存 canvas 的ref
const canvasRef = shallowRef<HTMLCanvasElement>();
// 保存 canvasContext 的ref
const canvasContext = shallowRef<CanvasRenderingContext2D | null>();
// 方便后面方法中取context,不然就得一直用 `canvasContext.value!`
function getCtx(): CanvasRenderingContext2D {
  return canvasContext.value!;
}
onMounted(() => {
  // 组件挂载时初始化 context
  canvasContext.value = canvasRef.value?.getContext("2d");
});
</script>

<template>
  <canvas ref="canvasRef" id="canvas" width="500" height="500">
      你的浏览器不支持canvas。
  </canvas>
</template>

绘制一个普通节点

function drawTextItem({
  startX,
  startY,
  width,
  height,
  title,
}: {
  startX: number;
  startY: number;
  width: number;
  height: number;
  title: string;
}) {
  const ctx = getCtx();
  // 重新开辟一个线路
  ctx.beginPath();

  // 画一个外边框
  ctx.rect(startX, startY, width, height);
  ctx.strokeStyle = "red";
  ctx.fillStyle = "pink";
  ctx.stroke();
  ctx.fill();
  
  // 写字
  ctx.fillStyle = "red";
  ctx.textBaseline = "middle";
  // fillText(text, x, y, maxWidth),maxWidth最大宽度
  ctx.fillText(title, startX, startY, width);
}
调用一下
drawTextItem({
  startX: 10,
  startY: 10,
  width: 100,
  height: 50,
  title: '一个普通节点',
});

[canvas]原来画树状图没那么难,下次别找库了

🎉🎉🎉看起来非常的简单,我们似乎已经实现了一半的需求!(另一半就是画画线把节点连接起来了😉),接下来简单设计一下树结构。

树结构
// type.ts
export interface TreeNode {
  // 显示标题
  title: string;
  // 下一级
  children?: TreeNode[];
}

然后随便准备点数据

const tree: TreeNode = {
  title: "一个普通节点",
  children: [
    {
      title: "普通的男孩",
    },
    {
      title: "普通的女孩",
    },
  ],
};

有了一个很简单的树,然后就可以开始绘制根节点下面的子节点了。

绘制子节点

简单讲一下思路,上面这个树的根节点有两个子节点,所以我们只要随便在一个地方画上根节点,然后在根节点的下方把子节点排排站画上就好了。

/**
 *
 * @param startX 当前节点起始坐标X
 * @param startY 当前节点起始坐标Y
 * @param node 节点
 */
function render(startX: number, startY: number, node: TreeNode) {
  // 绘制一个节点需要的高度
  const nodeHeight = 50;
  // 绘制一个节点需要的宽度
  const nodeWidth = 100;
  // 节点之间留出来点垂直方向的间隙
  const nodeMarginY = 10;
  // 节点之间留出来点水平方向的间隙
  const nodeMarginX = 10;

  // 绘制节点
  drawTextItem({
    startX,
    startY,
    width: nodeWidth,
    height: nodeHeight,
    title: node.title,
  });

  // 如果有子节点,绘制子节点
  if (node.children && node.children.length > 0) {
    // 计算当前节点的子节点的 Y 坐标的起始位置
    // 其实就是父节点的 startY 加上一个节点高度和一个margin
    const childrenStartY = startY + nodeHeight + nodeMarginY;

    // 子节点的 startX
    // 绘制完一个之后,会重新设置这个值
    let lastStartX = startX;
    node.children.forEach((e) => {
      // 遍历渲染子节点
      render(lastStartX, childrenStartY, e);
      // 重新设置一下 lastStartX,给下一个子节点用,这里也是加上一个节点宽度和margin
      lastStartX = lastStartX + nodeWidth + nodeMarginX;
    });
  }
}

看看效果

[canvas]原来画树状图没那么难,下次别找库了 🎉看起来还不错,有点内味了,不过就是不居中。修改一下lastStartX,让子节点居中渲染。

// 子节点的 startX
// 绘制完一个之后,会重新设置这个值
// 初始值,将居中点对齐
// 父节点的中心点
const parentCenterX = startX + nodeWidth / 2;
// 子节点一半的宽度
const childListHalfWidth = (node.children.length * (nodeWidth + nodeMarginX) - nodeMarginX) / 2;
// 父节点中心点减去子节点一半宽度,相当于把 子节点往左平移一半距离,达到居中目的。
let lastStartX = parentCenterX - childListHalfWidth;

[canvas]原来画树状图没那么难,下次别找库了 往左移直接移出画布了😅,没关系,把根节点startX设大一下就好了。

render(150, 10, newVal);

[canvas]原来画树状图没那么难,下次别找库了 看起来已经完成了任务,让我们试试绘制一下省市区结构吧。

一个省两个市四个区👇

const tree: TreeNode = {
  title: "浙江省",
  children: [
    {
      title: "杭州市",
      children: [
        {
          title: "拱墅区",
        },
        {
          title: "余杭区",
        },
      ],
    },
    {
      title: "宁波市",
      children: [
        {
          title: "海曙区",
        },
        {
          title: "江北区",
        },
      ],
    },
  ],
};

[canvas]原来画树状图没那么难,下次别找库了 问题出现了,杭州市下面的余杭区被宁波的海曙区盖住了,看来上面渲染函数的逻辑出现了问题。

思考

如何合理的在一张纸上绘制一个树,这个问题困扰了我差不多一周时间。主要是现实生活里我不知道怎么均匀的、不冲突的把树的所有节点绘制在一张上,我拿着白板笔在玻璃上擦擦画画了一周才想出来思路,可能是博主太笨了。如果你相信自己的智商的话,可以先自己独立思考一下,找个比较结构比较深的json(没有现成的json对照的话自己都不知道要画啥),拿张纸画一画,找找思路。

下面介绍一下我的思路👇

空间利用思路

简单把问题拆解一下,海曙区覆盖余杭区的根本原因就是宁波市的起始坐标startX离杭州市的结束坐标太近了,或者说,在上面的逻辑中我没有正确的计算杭州市的结束坐标,正确计算应该是将杭州市及所有子孙节点看成一个整体来计算,即:

结束坐标 = 开始坐标startX + 杭州市包含子孙节点整个区块的宽度

计算子孙节点还得考虑到一个递归的问题,想要计算出区块浙江省的宽度,就要先计算出区块杭州市的宽度,想要计算出区块杭州市的宽度,就要先计算出区块拱墅区的宽度,拱墅区没有子节点了,就直接向上返回自己文本节点宽度就可以了。

然后再由区块杭州市将所有子节点上报的宽度加起来作为整个区块的宽度,再上报给上层浙江省浙江省拿到杭州市的宽度,自然就能推算出宁波市的起始坐标startX了。

光说可能不容易理解,看代码吧👇

定义类型

// type.ts
/**
 * 新增三个类型
 * NodePosition: 定义一个单位的位置信息和宽高还有类型
 * 'text'类型为文本节点,'block'类型为区块节点
 *
 * TextPosition:文本节点,继承NodePosition
 *
 * BlockPosition:区块节点,继承NodePosition,title 属性为这个区块的头部节点
 */
export interface NodePosition {
  startX: number;
  startY: number;
  height: number;
  width: number;
  type: "text" | "block";
}

export interface TextPosition extends NodePosition {
  type: "text";
  // 文本节点内容
  title: string;
}

export interface BlockPosition extends NodePosition {
  type: "block";
  // 区块节点头部的文本,以杭州市为例,"杭州市"这三个字作为一个文本节点,放在title上
  title: TextPosition;
  // “拱墅区”、“余杭区”作为子元素,放在children里面
  children: NodePosition[];
}

这里定义三个类型,是为了根据json树生成一个带有位置信息的node树,然后再单独去渲染这个node树,为什么这么做后面再说。

生成node树

// 一个区块/节点的起始位置信息
interface StartPosition {
  startX: number;
  startY: number;
}

// 生成节点树
function renderNode(
  startPosition: StartPosition,
  treeNode: TreeNode
): TextPosition | BlockPosition {
  // 有子节点,定义为block节点
  if (treeNode.children && treeNode.children.length > 0) {
    // 区块宽度,累加
    let blockWidth = 0;

    // 区块高度,取子节点最大值
    // 这里给一个默认的值
    let blockHeight = nodeHeight + nodeMarginY + nodeHeight;

    // 画节点时保存下一个节点的起始位置
    let lastItemPosition = {
      // 第一个子节点,起始 startX 就是上层传进来的 startX
      nextStartX: startPosition.startX,
      // 子节点的起始 startY 应该预留出头部的空间
      // 比如 杭州市的块节点,拱墅区和余杭区的 startY 应该在文本节点 “杭州市”下面
      nextStartY: startPosition.startY + nodeHeight + nodeMarginY,
    };

    // 存储子节点的数组
    const children: NodePosition[] = [];

    // 遍历子节点
    treeNode.children.forEach((e) => {
      // 递归生成子节点的位置信息,拿到位置信息去处理下一个子节点
      const childPosition = renderNode(
        {
          startX: lastItemPosition.nextStartX,
          startY: lastItemPosition.nextStartY,
        },
        e
      );

      // 这里取所有子节点的最大高度,作为区块的高度
      if (childPosition.height > blockHeight) {
        blockHeight = childPosition.height;
      }

      // 累加子节点宽度,作为区块宽度
      // 这里最后会多加出来一个nodeMarginX,最后要减去
      blockWidth += childPosition.width + nodeMarginX;

      // 给下一个兄弟节点准备起始位置
      lastItemPosition = {
        // 兄弟节点的startY都是一样的,不需要动
        nextStartY: childPosition.startY,
        // 兄弟节点的startX要累加前当前的宽度,再预留出点边距
        nextStartX: childPosition.startX + childPosition.width + nodeMarginX,
      };

      // 将位置信息添加到数组
      children.push(childPosition);
    });

    // 返回数据给上一层
    return {
      startX: startPosition.startX,
      startY: startPosition.startY,
      type: "block",
      // 减去一个多余的nodeMarginX
      width: blockWidth - nodeMarginX,
      height: blockHeight,
      children: children,
      title: {
        startX: startPosition.startX,
        startY: startPosition.startY,
        type: "text",
        title: treeNode.title,
        width: nodeWidth,
        height: nodeHeight,
      },
    };
  }

  // 没有子节点,就是普通的文本节点,直接返回
  return {
    startX: startPosition.startX,
    startY: startPosition.startY,
    type: "text",
    width: nodeWidth,
    height: nodeHeight,
    title: treeNode.title,
  };
}

代码稍长,不过我都写了注释。

绘制node树

function draw(node: NodePosition) {
  // 文本节点直接绘制
  if (node.type === "text") {
    drawTextItem(node as TextPosition);
  } else {
    const blockNode = node as BlockPosition;
    // 区块节点,先绘制区块头部的文本节点
    drawTextItem(blockNode.title);
    // 再绘制子节点
    blockNode.children.forEach((e) => {
      draw(e);
    });
  }
}

对比生成树节点代码,这部分显得非常简单了,反正位置信息都是现成的,只要无脑画画画就ok了。

调用下看看效果👇

const node = renderNode(
  {
    startX: 10,
    startY: 10,
  },
  tree
);
draw(node);

[canvas]原来画树状图没那么难,下次别找库了 🎉看起来还不错,宁波市这次能跟在杭州市后面渲染了,没再出现上面覆盖渲染的情况。

但是现在又出现了之前碰到的居中问题了,而这也是我选择要先生成node树再单独渲染的原因。如果你觉着看到这里有点吃力的话,可以先休息一下再接着往下看。如果你觉着还ok的话,那就再看看我如何处理居中的吧😉。

居中

思路

居中实现我也思考挺长时间,因为我不太知道我是想要啥样的效果, 纠结挺长时间我最后得出两种想法:

  1. 区块的头部,根据区块整体宽度做居中移动
  2. 区块的头部,根据子元素位置做居中移动

第一种简单粗暴,区块整体宽度是有的,直接移动就可以了,但是如果树元素分布不均匀的话,就挺丑。

第二种又涉及到递归,还是要先从树最下面的元素开始算,最后一层层递归居中到顶层根元素,也是挺绕。

这两种我都实现过,最后选择了第二种。

代码

// 摇树一下
function shakeTree(nodePosition: NodePosition) {
  // 普通文本节点没有居中的必要,直接返回坐标
  if (nodePosition.type === "text") {
    return nodePosition.startX;
  }

  // 块节点,要对头部的文本节点进行居中操作
  const blockNode = nodePosition as BlockPosition;

  // 保存子节点第一个和最后一个的居中StarX
  let firstChildCenterStarX = 0,
    lastChildCenterStartX = 0;

  // 遍历递归子节点
  blockNode.children.forEach((e, index) => {
    // 先摇动子节点,让最下层的子节点完成居中,然后依次处理上层
    const centerStartX = shakeTree(e);

    if (index === 0) {
      firstChildCenterStarX = centerStartX;
    }

    if (index === blockNode.children.length - 1) {
      lastChildCenterStartX = centerStartX;
    }
  });

  // 区块标题的居中点
  // 即 `最后一个子节点` 与 `第一个子节点的位置` 这段距离 的 `中间位置`
  const newStartX =
    (lastChildCenterStartX - firstChildCenterStarX) / 2 + firstChildCenterStarX;

  // 赋值给区块的头部文本
  blockNode.title.startX = newStartX;

  // 将这个区块的头部文本节点的居中位置返回给上层
  // 上层拿到后再做上层的居中操作
  return newStartX;
}

调用看看效果👇

//...省略其他代码
const node = renderNode(
  {
    startX: 10,
    startY: 10,
  },
  tree
);
shakeTree(node);
draw(node);

[canvas]原来画树状图没那么难,下次别找库了 节点分布不均匀的情况👇 [canvas]原来画树状图没那么难,下次别找库了 效果还可以,接下来就剩最后一步了,给这些文本节点画上线,不过这次不需要递归调用了。

连接线

思路

思路挺简单朴素,就是从区块的头下面中心的位置,画到子元素节点上面中心的位置。所有元素节点位置宽高信息都有了,随便画画就可以了。

画线代码

// 判断是否文本节点,告诉编辑器e是TextPosition类型
// 用的时候就不需要 e as TextPosition 强转
function isTextNode(e: NodePosition): e is TextPosition {
  return e.type === "text";
}

// 判断是否块本节点,告诉编辑器e是BlockPosition类型
// 用的时候就不需要 e as BlockPosition 强转
function isBlockNode(e: NodePosition): e is BlockPosition {
  return e.type === "block";
}

// 画线
function drawLine(block: BlockPosition) {
  const ctx = getCtx();

  ctx.beginPath();

  // 计算画线起点
  // startX,是区块头部文本节点的中心位置
  const lineStartX = block.title.startX + block.title.width / 2;

  // startY,是区块头部文本节点的底部
  const lineStartY = block.title.startY + block.title.height;

  // Y转折点,如果有多个子节点,需要在中间分叉
  const halfY = lineStartY + nodeMarginY / 2;

  // 画笔移动起点
  ctx.moveTo(lineStartX, lineStartY);

  // 先把线画到转折点
  ctx.lineTo(lineStartX, halfY);

  // 把转折点和子元素连接起来
  block.children.forEach((e) => {
    // 画完一个子节点后,需要重新回到分叉点,第一次不需要
    ctx.moveTo(lineStartX, halfY);

    // 如果是文本节点
    if (isTextNode(e)) {
      // 直接去文本中心位置
      const childCenterX = e.startX + e.width / 2;
      // 画个转折点
      ctx.lineTo(childCenterX, halfY);
      // 画到文本上方
      ctx.lineTo(childCenterX, e.startY);
      return;
    }

    // 如果是块节点
    if (isBlockNode(e)) {
      // 用上面的方法判断类型,可以隐形的让 e 转为 BlockPosition 类型,这里可以直接取title了
      const childTitle = e.title;
      // 取到头部的中心点位置
      const childTitleCenterX = childTitle.startX + childTitle.width / 2;
      // 画个转折点
      ctx.lineTo(childTitleCenterX, halfY);
      // 画到头部文本上方
      ctx.lineTo(childTitleCenterX, childTitle.startY);
    }
  });
  // 最后别忘了stroke
  ctx.stroke();
}

调用

/**
 * 绘制node树
 */
function draw(node: NodePosition) {
  // 文本节点直接绘制
  if (node.type === "text") {
    drawTextItem(node as TextPosition);
  } else {
    const blockNode = node as BlockPosition;
    // 区块节点,先绘制区块头部的文本节点
    drawTextItem(blockNode.title);
    // 再绘制子节点
    blockNode.children.forEach((e) => {
      draw(e);
    });
    // 主要是块头部画到子节点
    drawLine(blockNode);
  }
}

看看效果

[canvas]原来画树状图没那么难,下次别找库了 看起来不错,就是有点挤,把nodeMarginY的值设置大一点会更好看。 [canvas]原来画树状图没那么难,下次别找库了 我把nodeMarginY改成了18。

点击事件

也许你的树需要一点增删改的操作,就需要用户直接点击树节点交互了,但我碰到的业务暂时不需要点击操作,所以我也就没写,如果你需要的话可以自行研究一下,这里讲一下大致思路。

其实就跟draw方法差不多,我们已经根据json生成了一个node树,有所有节点的位置和长宽信息,那么就可以在点击事件进来时判断事件的xy是否与树中的某个节点的位置信息有交叉了,如果有的话,那么就可以接着往下递归查找自己的子节点是否有交叉,直到找到最后一个没有子节点的节点,即用户点击的节点。

举个例子来说: 如果用户点击的是拱墅区,那么在比较点击事件与位置信息是否有交叉时,要从树的根节点浙江省开始比较,发现有交叉,那么接着往下找,然后就找到杭州市,接着往下找到拱墅区,大体思路就是这个样子,不过也要注意区块头部也能捕获点击事件。

总结

因为没有具体的参照物,很多时候我只在脑子里空想,忘了参考一下市面上已有的图表图片了,如果看着现成的图思考起来会轻松很多,感觉具体的问题比模糊的问题好解决很多。

绘制思路我没有参考过市面上的库之类的,经验主要还是来自早年做安卓时用到的自定义布局view的经历。如果有更好的思路,可以在评论区讨论一下🤞。

全部代码

切换到script标签看👇

转载自:https://juejin.cn/post/7207094325895757861
评论
请登录