其实当前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
三、效果图展示,节点可以点击进行收缩(图二)