Canvas原生绘制树状结构拓扑图

其实当前Web库实现Canvas绘制树状结构的组件很多,而且功能也很强大,但是难免有些场景无法实现需要自己开发,本文主要是提供一种思路

先附一个不错的拓扑图开发地址:https://www.zhihu.com/question/41026400

一、开发思路

开发最大的难点是如何计算每个节点所在的位置坐标,保证所有节点的居中对称性,如果有了坐标绘制起来就方便很多,具体可见下图

1. 将每个分支看作是一个组,比如节点1看错是一个Group,下面三个分支分别又是Group1、Group2、Group3,而Group1中又有三个Group(比如Group4 等等...)。

2. 对节点数据采用递归循环的方式找到最大的层数,列中为4层。

3. 再次递归循环源数据,判断如果当前层数据非最大层时,则自动补充一个虚拟节点到数据中,一直递归直到最大一层停止。

4. 到第3步可以说所有节点已经补充齐,然后再次递归第3步获取的数据,到最后一层节点时即向上报数,父节点收到消息后计算+1,同时给自己父节点上报消息,如此循环反复,即可准确获得每个节点所包含的最下面一层对应的节点个数,通过节点个数*节点宽度即可获得每个Group所需要的宽度,同时也就能计算各个节点自己的中心点坐标了,简易流程如图二

 二、上代码

1. 首先创建三个文件element.js、arrow.js、group.js分别代表元素、箭头和组 的类,可以通过对类的继承实现不同的元素效果,本文只展示简单的要素,就不演示继承的用法了

/**
 * 元素,包含元素的样式属性以及绘制范围等信息
 * 每个要素所有的子元素都被包含在Group要素中
 */
import util from "./util.js";
import Group from './group.js';

export default class Element {
  constructor(options) {
    // 横向文字距边框宽度
    this.rowPadding = 10;
    // 纵向文字距边框宽度
    this.coloumnPadding = 5;
    // 元素外边距
    this.margin = util.CON.MARGIN;

    this.fontSize = 20;

    this.width = util.CON.WIDTH;

    this.height = util.CON.HEIGHT;

    // 名称,可随机
    this.name = options.name;

    // ID值 可随机
    this.id = options.id || util.guid('element');
    
    // 显示文本内容
    this.text = options.text;

    // 所有子和孙辈数据个数
    this.chiCount = 0;

    // 标识该要素展开还是收缩的
    this.openFlag = false;

    // 元素的中心位置
    this.center = this.getCenter(options.xRange, options.yRange);

    this.coordinates = this.getCoordinate();

    this.group = this.creat(options.children || [], options.needBorder);

    // 收缩框数据集
    this.shrink = options.shrink;
  }

  /**
   * 获取要素的中心点
   * @param {*} xRange 
   * @param {*} yRange 
   */
  getCenter(xRange, yRange) {
    return [(xRange[1] - xRange[0]) / 2 + xRange[0], (yRange[1] - yRange[0]) / 2 + yRange[0]];
  }

  /**
   * 根据圆心以及边框宽高获取圆心
   * 规则为 [左上, 右上,右下,左下,左上]
   */
  getCoordinate() {
    return [
      [-this.width / 2, -this.height / 2],
      [this.width / 2, -this.height / 2],
      [this.width / 2, this.height / 2],
      [-this.width / 2, this.height / 2]
    ]
  }

  /**
   * 绘制矩形边框,包含圆角
   */
  draw() {
    const radius = 4;
    const coor = this.coordinates;
    const ctx = util.getContext();
    
    ctx.save();
    ctx.translate(this.center[0], this.center[1]);
    ctx.lineWidth = 2;
    ctx.strokeStyle = '#558dbd';
    ctx.fillStyle = '#f6fafd';
    ctx.shadowColor = '#a9d4f5';
    ctx.shadowBlur = 5;
    
    ctx.beginPath();
    ctx.arc(coor[0][0] + radius, coor[0][1] + radius, radius, Math.PI, Math.PI * 3 / 2);
    ctx.lineTo(coor[1][0] -radius, coor[1][1]);
    ctx.arc(coor[1][0] - radius, coor[0][1] + radius, radius, -Math.PI / 2, 0);
    ctx.lineTo(coor[2][0], coor[2][1] - radius);
    ctx.arc(coor[2][0] - radius, coor[2][1] - radius, radius, 0, Math.PI / 2);
    ctx.lineTo(coor[3][0] + radius, coor[3][1]);
    ctx.arc(coor[3][0] + radius, coor[3][1] - radius, radius, Math.PI / 2, Math.PI);
    ctx.closePath();
    ctx.stroke();
    ctx.fill();
    ctx.restore();

    if (this.group) {
      this.group.draw();
    }
    
    this.drawText();
    this.drawCircle();
  }

  /**
   * 绘制文本内容
   */
  drawText() {
    const ctx = util.getContext();
    ctx.save();
    ctx.translate(this.center[0], this.center[1]);
    ctx.font = this.fontSize + 'px serif';
    ctx.fillStyle = '#333';
    ctx.textAlign = 'center';
    ctx.fillText(this.text, 0, 0 + this.fontSize / 3, this.width - this.rowPadding * 2);
    ctx.restore();
  }

  /**
   * 绘制圆圈
   */
  drawCircle() {
    const circleRadius = 6;
    const ctx = util.getContext();
    ctx.save();
    ctx.strokeStyle = '#16ade7';
    ctx.lineWidth = 3;
    ctx.fillStyle = '#fff';
    ctx.translate(this.center[0], this.center[1]);
    
    if (this.group.children.length || this.openFlag) {
      ctx.beginPath();
      ctx.arc(0, this.height / 2, circleRadius, 0, Math.PI * 2);
      ctx.closePath();
      // ctx.strokeStyle = '#f18585';
      if (this.openFlag) {
        ctx.lineWidth = 1;
        ctx.moveTo(0, -circleRadius + 2 + this.height / 2);
        ctx.lineTo(0, circleRadius - 2 + this.height / 2);
        ctx.moveTo(-circleRadius + 2, this.height / 2);
        ctx.lineTo(circleRadius - 2, this.height / 2);
      } else {
        ctx.lineWidth = 1;
        ctx.moveTo(-circleRadius + 2, this.height / 2);
        ctx.lineTo(circleRadius - 2, this.height / 2);        
      }
      ctx.fill();
      ctx.stroke();
    }
    
    ctx.restore();
  }

  /**
   * 根据子元素生成对应的类
   */
  creat(children, needBorder) {
    return new Group({ children, needBorder, parent: this });
  }

  /**
   * 计算元素的宽和高,用于后续计算元素的位置
   */
  calWidthHeight() {
    const style = util.getContext().measureText(this.text);
    this.width = style.width + this.rowPadding * 2;
    this.height = this.fontSize + this.coloumnPadding * 2;
  }
}
element.js
import util from "./util.js";

/**
 * 箭头类
 * 主要通过箭头的起始要素和终点要素,以此来计算起点位置箭头的角度偏移量和方向
 */
export default class Arrow {
  constructor(options, fromEle, toEle) {
    this.id = options.id || util.guid('arrow');
    // 箭头起始要素
    this.from = fromEle;
    // 箭头结束要素
    this.to = toEle;
    // 箭头坐标信息
    this.coordinates = this.getCoordinate();
  }

  /**
   * 获取坐标信息
   * @returns 
   */
  getCoordinate() {
    const fromC = this.from.center;
    const toC = this.to.center;
    return [
      [fromC[0], fromC[1] + this.from.height / 2],
      [toC[0], toC[1] - (toC[1] - fromC[1]) / 2],
      [toC[0], toC[1] - this.to.height / 2]
    ]
  }

  /**
   * 主要绘制边框等信息内容
   */
  draw() {
    const coor = this.coordinates;
    const ctx = util.getContext();
    ctx.save();
    ctx.lineWidth = 1;
    ctx.strokeStyle = '#558dbd';

    ctx.setLineDash([5, 2]);
    ctx.beginPath();
    ctx.moveTo(coor[0][0], coor[0][1]);
    ctx.lineTo(coor[1][0], coor[1][1]);
    ctx.lineTo(coor[2][0], coor[2][1]);
    ctx.stroke();
    ctx.restore();
    this.drawArrow(ctx, coor[2]);
  }

  /**
   * 绘制箭头
   * @param {*} ctx 
   * @param {*} points 
   */
  drawArrow(ctx, points) {
    const angle = Math.PI * 20 / 180;
    const height = 12;
    
    ctx.save();

    ctx.lineWidth = 1;
    ctx.fillStyle = '#558dbd';
    ctx.translate(points[0], points[1]);
    ctx.beginPath();
    ctx.lineTo(0, 0);
    ctx.lineTo(Math.tan(angle) * height, -height);
    ctx.arc(0, -height * 1.5, height * 0.6, Math.PI / 2 - Math.PI / 4.8, Math.PI / 2 + Math.PI / 4.8);
    ctx.lineTo(-Math.tan(angle) * height, -height);
    ctx.closePath();
    ctx.fill();
    
    ctx.restore();
  }
}
arrow.js
import Element  from "./element.js";
import Arrow from "./arrow.js";
import util from "./util.js";

/**
 * 组类
 * 用于存储子节点和子节点的箭头对象
 */
export default class Group {
  constructor(options) {
    this.id = options.id || util.guid('group');
    
    // 是否需要绘制组边框
    this.needBorder = options.needBorder || false;
    
    // 当前组所有子和孙节点的个数
    this.chiCount = options.chiCount;

    // 组下的子元素以及箭头
    this.createObject(options.children, options.parent);

    // 计算中心点
    this.center = this.getCenter();

    // 计算坐标点信息
    this.coordinates = this.getCoordinate();
  }

  /**
   * 根据子元素获取子元素最大最小中心坐标用于计算Group的中心坐标和边框
   * @returns 
   */
  getMaxMin() {
    const coordX = [];
    const coordY = [];
    this.children.forEach(e => {
      coordX.push(e.center[0]);
      coordY.push(e.center[1]);
    })

    const maxX = Math.max(...coordX);
    const minX = Math.min(...coordX);
    const maxY = Math.max(...coordY);
    const minY = Math.min(...coordY);
    return { maxX, minX, maxY, minY };
  }

  /**
   * 获取要素的中心点
   */
  getCenter() {
    const maxMin = this.getMaxMin();
    return [(maxMin.maxX + maxMin.minX) / 2, (maxMin.maxY + maxMin.minY) / 2];
  }

  /**
   * 获取坐标信息
   * @returns 
   */
  getCoordinate() {
    const maxMin = this.getMaxMin();

    const maxX = maxMin.maxX;
    const minX = maxMin.minX;
    const maxY = maxMin.maxY;
    const minY = maxMin.minY;

    const width = util.CON.WIDTH / 2;
    const height = util.CON.HEIGHT / 2;
    const margin = util.CON.MARGIN / 3;
    const betaLong = (maxX - minX + width * 2) / 2;
    const betaHeight = (maxY - minY + height * 2) / 2;

    return [
      [-betaLong - margin, -betaHeight - margin],
      [betaLong + margin, -betaHeight - margin],
      [betaLong + margin, betaHeight + margin],
      [-betaLong - margin, betaHeight + margin],
    ]
  }

  /**
   * 创建子对象,包括Element和Arrow对象
   * @param {*} children 
   * @param {*} parent 
   */
  createObject(children, parent) {
    const arrows = [];
    const child = [];
    children.forEach(e => {
      if (!e.buildSelf) {
        const ele = new Element(e);
        child.push(ele);
        arrows.push(new Arrow(e, parent, ele));
      }
    })
    this.arrows = arrows;
    this.children = child;
  }

  /**
   * 主要绘制边框等信息内容
   */
  draw() {
    this.children.forEach(e => e.draw());
    this.arrows.forEach(e => e.draw());

    this.drawBorder();
  }

  /**
   * 绘制边框
   * @returns 
   */
  drawBorder() {
    if (this.children.length === 0 || !this.needBorder) {
      return;
    }
    const radius = 10;
    const coor = this.coordinates;
    const ctx = util.getContext();

    ctx.save();
    ctx.translate(this.center[0], this.center[1]);
    ctx.lineWidth = 2;
    ctx.strokeStyle = '#99745e';
    
    ctx.beginPath();
    ctx.arc(coor[0][0] + radius, coor[0][1] + radius, radius, Math.PI, Math.PI * 3 / 2);
    ctx.lineTo(coor[1][0] -radius, coor[1][1]);
    ctx.arc(coor[1][0] - radius, coor[0][1] + radius, radius, -Math.PI / 2, 0);
    ctx.lineTo(coor[2][0], coor[2][1] - radius);
    ctx.arc(coor[2][0] - radius, coor[2][1] - radius, radius, 0, Math.PI / 2);
    ctx.lineTo(coor[3][0] + radius, coor[3][1]);
    ctx.arc(coor[3][0] + radius, coor[3][1] - radius, radius, Math.PI / 2, Math.PI);
    ctx.closePath();
    ctx.stroke();
    ctx.restore();
  }
}
group.js

2. 再创建一个util.js文件,主要是对源数据做虚拟节点的增加和计算最低一层的子节点个数

const CANVASINFO = {};
import Element from './element.js';

const CON = {
  WIDTH: 120, // Element元素的宽度
  HEIGHT: 40, // Element元素的高度
  MARGIN: 30, // 两个Element元素的高度
  OUTERHEIGHT: 200  // 每行Element的高度
}

/**
 * 设置canvas对象,便于其他组件使用
 */
function setSanvas(canvas) {
  CANVASINFO.obj = canvas;
  CANVASINFO.context = canvas.getContext('2d');
  CANVASINFO.width = canvas.offsetWidth;
  CANVASINFO.height = canvas.offsetHeight;
}

/**
 * 获取canvas对象
 */
function getCanvas() {
  return CANVASINFO.obj;
}

function getContext() {
  return CANVASINFO.context;
}

/**
 * 获取UUID
 * @returns 
 */
function guid(prefix) {
  return prefix + '_xxxx-xxxx-yxxx'.replace(/[xy]/g, function (c) {
      var r = Math.random() * 16 | 0,
          v = c == 'x' ? r : (r & 0x3 | 0x8);
      return v.toString(16);
  });
}

/**
 * 获取最大的层级值,同时标记每层要素的层级值,方便后续计算虚拟节点使用
 * @param {*} src 
 * @returns 
 */
function getMaxLevel(src) {
  const INITLEVEL = 0;
  let maxLevel = INITLEVEL;

  function cal(src, level) {
    if (level > maxLevel) {
      maxLevel = level;
    }
    src.forEach(e => {
      e.topo_level = level;
      if (e.children && e.children.length) {
        cal(e.children, level + 1);
      }
    })
  }
  if (Array.isArray(src)) {
    cal(src, INITLEVEL);
  } else {
    cal([src], INITLEVEL);
  }
  return maxLevel;
}

/**
 * 计算每个节点包含的所有最大层级的子和孙子节点的个数
 * 原理是循环到最下层的Element对象,每个Element元素向上汇报计算+1
 * @param {*} data 
 */
function countNum(data) {
  const INITLEVEL = 0;
  let maxLevel = getMaxLevel(data);

  function count(src, level, parents = []) {
    src.forEach(e => {
      if (Number.isNaN(e.chiCount) || e.chiCount === undefined) {
        e.chiCount = 0;
      }
      if (e.children && e.children.length) {
        // 此处添加parents时不可以使用push,否则将会导致部分parent重复
        count(e.children, level + 1, parents.concat([e]));
      } else if (level < maxLevel) {
        // 通过buildSelf标识自插入属性,该对象不创建Ele对象
        e.children.push({ children: [], level: level + 1, buildSelf: true });
        count(e.children, level + 1, parents.concat([e]));
      } else if (level === maxLevel) {
        for (let i = parents.length - 1; i >= 0; i--) {
          parents[i].chiCount++;
        }
      }
    })
  }
  if (Array.isArray(data)) {
    count(data, INITLEVEL);
  } else {
    count([data], INITLEVEL);
  }

  return data;
}

/**
 * 将每个分支看作一组,计算每组的横纵坐标范围
 * @param {*} data 
 */
function calRange(data) {
  let eleWidth = CON.WIDTH + CON.MARGIN;
  const startX = 0;
  const startY = 0;

  function range(src, start) {
    src.forEach((e, i) => {
      e.yRange = [e.topo_level + startY, (e.topo_level + 1) * CON.OUTERHEIGHT + startY];
      if (e.children) {
        if (i === 0) {
          e.xRange = [start, start + eleWidth * (e.chiCount === 0 ? 1 : e.chiCount)];
          range(e.children, start);
        } else {
          e.xRange = [src[i - 1].xRange[1], src[i - 1].xRange[1] + eleWidth * (e.chiCount === 0 ? 1 :e.chiCount)];
          range(e.children, src[i - 1].xRange[1]);
        }
      }
    });
  }
  if (Array.isArray(data)) {
    range(data, startX);
  } else {
    range([data], startX);
  }
  return data;
}

/**
 * 获取无子节点的Element
 */
function flatElement(data) {
  const arr = [];

  function flat(src) {
    if (!src.group || src.group.children.length === 0) {
      arr.push(src);
    } else {
      src.group.children.forEach(e => flat(e));
    }
  }
  flat(data);
  return arr;
}

/**
 * 克隆数据
 * @param {*} data 
 * @returns 
 */
function clone(data) {
  return JSON.parse(JSON.stringify(data));
}


export default {
  CON,
  setSanvas,
  getCanvas,
  getContext,
  guid,
  clone,
  countNum,
  calRange,
  flatElement,
};
util.js

3. 创建一个main.js文件,主要是和用来创建元素对象以及绘制页面等功能

main.js

import util from "./util.js";
import Element from "./element.js";

let data = [];
let arrows = [];

function init(options) {
  util.setSanvas(document.getElementById(options.id));
  addEvent();

  // 克隆数据,避免数据污染
  const cloneData = util.clone(options.data);

  // 计算每个父节点包含的所有子和孙节点数据个数
  const numData = util.countNum(cloneData);

  // 计算每个节点的横纵坐标范围
  const rangeData = util.calRange(numData);
  // 创建要素集
  data = new Element(rangeData);

  redraw();
}


/**
 * 重新绘制
 */
function redraw() {
  const ctx = util.getContext();
  ctx.clearRect(0, 0, 1800, 800);
  
  arrows.forEach(e => e.draw());
  data.draw();
}

function addEvent() {
  let initWidth = 1800;
  let initHeight = 800;
  let init = 1;
  const beta = 0.1;
  const dom = util.getCanvas();
  const ctx = util.getContext();

  let startPos = [0, 0];
  let down = false;
  let lastPos = [0, 0];

  /**
   * 点击事件,计算合并或者展开
   * @param {*} event 
   */
  dom.onclick = (event) => {
    const clickX = event.offsetX;
    const clickY = event.offsetY;
    let selectEle = null;

    function getEle(e) {
      const upX = e.center[0];
      const upY =  e.center[1] + e.height / 2;
      if (Math.pow(clickX - upX, 2) + Math.pow(clickY - upY, 2) < 10) {
        selectEle = e;
      }
      if (!selectEle && e.group && e.group.children.length) {
        e.group.children.forEach(e => {
          getEle(e);
        })
      }
    }
    getEle(data);
    if (selectEle) {
      if (selectEle.openFlag) {
        selectEle.openFlag = false;
        selectEle.group.children = selectEle.srcChildren;
        selectEle.group.arrows = selectEle.srcArrows;

        delete selectEle.srcArrows;
        delete selectEle.srcChildren;
      } else {
        selectEle.openFlag = true;
        selectEle.srcChildren = selectEle.group.children;
        selectEle.srcArrows = selectEle.group.arrows;
        
        selectEle.group.children = [];
        selectEle.group.arrows = [];
      }
     
      redraw();
    }
  }

}

export default { init }

4. 新增一个页面index.html,用来渲染要素点

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Canvas绘制树状结构</title>
  <script src="./arrow.js" type="module"></script>
  <script src="./element.js" type="module"></script>
  <script src="./group.js" type="module"></script>
  <script src="./main.js" type="module"></script>
  <script src="./util.js" type="module"></script>
</head>
<body>
  <canvas id="canvas" width="1800" height="800" style="width: 1800px;height: 800px;border: 1px solid gray;margin: auto;display: flex;"></canvas>
</body>
</html>
<script type="module">
  import main from './main.js';
  // 数据格式如下,主要是children字段
  var s = {
    level: -1,
    children: [
      {
        level: 0,
        children: [
          {
            level: 1,
            needBorder: true,
            children: [
              {
                level: 2,
                children: []
              },
              {
                level: 2,
                children: []
              }
            ]
          }
        ]
      },
      {
        level: 0,
        children: [
          {
            level: 1,
            children: [
              {
                level: 2,
                children: [
                ],
              },
            ]
          }
        ]
      },
      {
        level: 0,
        children: []
      }
    ]
  };

  function setAttr(data, params, index) {
    data.text = params.text + '_' + index;
    if (data.children) {
      data.children.forEach((e, i) => {
        setAttr(e, { text: data.text }, i);
      })
    }
  }
  window.onload = (() => {
    setAttr(s, {text: '' }, 0);
    main.init({
      data: s,
      id: 'canvas'
    });
  })
</script>
index.html

三、效果图展示,节点可以点击进行收缩(图二)

 

 

posted @ 2023-03-16 19:28  火星写程序  阅读(1105)  评论(4编辑  收藏  举报