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; } }

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(); } }

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(); } }
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, };
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>
三、效果图展示,节点可以点击进行收缩(图二)
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?