image.png
代码简单,可根据需要自行修改。
index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
<style>
.node-rect:hover {
fill: rgba(78, 194, 255, 0.16);
}
.node-tools {
cursor: pointer;
}
svg text {
-webkit-user-select: none;
-moz-user-select: none;
-khtml-user-select: none;
-ms-user-select: none;
}
</style>
</head>
<body>
<div id="box" style="width:100vw;height:100vh;overflow: auto"></div>
<!--创建svg元素-->
<script src="./makeSVG.js"></script>
<!--处理数据为树形结构 -->
<script src="./organ.js"></script>
<!--节点:大小、坐标、绘制矩形文字等-->
<script src="./node.js"></script>
<!-- 画图方法 -->
<script src="./draw.js"></script>
<!--测试数据 -->
<script src="./data.js"></script>
<!--实例调用 -->
<script>
const box = document.getElementById('box'),
organ = new OrganModel()
organ.originData = originData // data.js中的数据
organ.handleData()
new Draw({
data: organ.tableData,
$box: box,
toolsHandle : (node, type) => { // 操作按钮
alert(type)
}
}).create()
</script>
</body>
</html>
1、makeSVG.js 生成svg元素
const makeSVG = (tag, attrs = {}) => {
const ns = 'http://www.w3.org/2000/svg', xlinkns = 'http://www.w3.org/1999/xlink';
let el = document.createElementNS(ns, tag);
if (tag === 'svg') {
el.setAttribute('xmlns:xlink', xlinkns);
el.setAttribute('draggable', true);
}
for (let k in attrs) {
k === 'xlink:href' ? el.setAttributeNS(xlinkns, k, attrs[k]) : el.setAttribute(k, attrs[k]);
}
return el;
}
2、organ.js 数据处理为树形结构,接口一般返回的是一维数组,这里根据实际需要
class OrganModel {
originData = []; // 接口返回的原始数据
tableData = []; // 处理过层级的数据
// 处理数据为树形结构
handleData() {
let data = JSON.parse(JSON.stringify(this.originData))
let findChild = (rootEl) => {
let a = data.filter(v => v.parent_id === rootEl.id)
let b = data.filter(v => v.parent_id !== rootEl.id)
data = b
rootEl.children = a
let level = isNaN(rootEl.level) ? 1 : (Number(rootEl.level) + 1)
for (let v of rootEl.children) {
v.level = level
findChild(v)
}
}
let top = {id: 0, level: 0}
findChild(top)
this.tableData = top.children || []
}
}
3、node.js 节点:一个节点包含矩形框、文本、操作按钮、父子节点连线
const lineColor = '#4ec2ff'; // 线条颜色
const lineWidth = 2; // 线条宽度
const collapseSize = 6; // 折叠点圆圈尺寸
const fontSize = 13; // 节点文本字号
const paddingSize = 5; // 一个节点的padding值
const marginSize = 40; // 一个节点的右边距
const line1 = 30; // 父子节点间距(上方)
const line2 = 80; // 父子节点间距(下方)
const maxWidth = 200; // 节点矩形框最大宽度
const maxHeight = 200; // 节点矩形框最大高度
const lineHeight = 10; // 文字行间距
const letterSpacing = 3; // 文字字符间距
const toolsHeight = 30
class NodeOrgan {
line1 = line1;
line2 = line2;
marginSize = marginSize;
xStart; // x 坐标
yStart; // y 坐标
direction = 'horizontal'; // 文字排列方向 horizontal:水平 vertical:垂直
nodeText = []; // 节点主文本 有换行的情况,需要分段显示
width; // 节点最终宽度
height; // 节点最终高度
middle;
prevNode; // 前一个兄弟节点
parentNode; // 父节点
constructor(props = {}) {
for (let k in props) this[k] = props[k]
this.setNodeText()
}
// 设置节点文本
setNodeText() {
if(!this.name) return
let name = `${this.name}`
let nodeText, w, h, isHor = this.direction === 'horizontal' // 文字水平排列
let compareLength = name.length
let maxWords = ((isHor ? maxWidth : maxHeight) - paddingSize) / (fontSize + letterSpacing) - 1 // 一行或一竖最多可显示字数
if(compareLength > maxWords) {
let lines = Math.ceil(compareLength / maxWords)
if(isHor) {
w = maxWidth
h = lines * (fontSize + lineHeight) + paddingSize + fontSize
}else {
w = lines * (fontSize + lineHeight) + paddingSize + fontSize
h = maxHeight
}
nodeText = []
let func = (str) => {
if(!str) return
nodeText.push(str.substring(0, maxWords))
func(str.substring(maxWords))
}
func(name)
}else {
if(isHor) {
w = paddingSize + (fontSize + letterSpacing) * compareLength
h = paddingSize + fontSize + lineHeight + fontSize
}else {
w = paddingSize + fontSize + lineHeight + fontSize
h = paddingSize + (fontSize + letterSpacing) * compareLength
}
nodeText = [name]
}
if(w < 90) w = 90 // 按钮宽度预留
this.width = w
this.height = h + toolsHeight // 此处的fontSize 表示负责人一横数据高度
this.nodeText = nodeText
}
// 节点矩形框
createRect() {
this.middle = this.xStart + this.width / 2 // 中间位置
return makeSVG('rect', {
x: this.xStart,
y: this.yStart,
width: this.width,
height: this.height,
rx: 5,
class: 'node-rect',
fill: 'white',
stroke: lineColor,
'stroke-width': lineWidth,
})
}
// 节点文字
createText() {
let startY = this.yStart + paddingSize + fontSize
let startX = this.xStart + fontSize
let textGroup = makeSVG('g', )
let setAttrs = (i) => {
return this.direction === 'horizontal' ?
{
x: startX,
y: startY + (fontSize + lineHeight) * i,
} :
{
x: startX + (fontSize + letterSpacing + 10) * i,
y: startY,
transform: `rotate(90, ${startX + (fontSize + letterSpacing + 10) * i}, ${startY})`,
rotate: '-90',
}
}
if(this.nodeText.length === 1) {
let text = makeSVG('text', {
fill: 'black',
stroke: 'none',
'font-size': fontSize,
'letter-spacing': letterSpacing,
'text-anchor': 'middle',
'dominant-baseline': "middle",
x: this.middle,
y: startY
})
text.innerHTML = this.nodeText[0]
textGroup.appendChild(text)
}else {
for(let i in this.nodeText) {
let text = makeSVG('text', {
fill: 'black',
stroke: 'none',
'font-size': fontSize,
'letter-spacing': letterSpacing,
...setAttrs(i)
})
text.innerHTML = this.nodeText[i]
textGroup.appendChild(text)
}
}
return textGroup
}
// 节点操作按钮
createTools() {
let tools = makeSVG('text', {
x: this.middle,
y: this.yStart + this.height - 10,
fill: 'black',
class: 'node-tools',
'font-size': 12,
'text-anchor': 'middle',
'dominant-baseline': "middle",
})
let createBtn = (type) => {
let btn = makeSVG('tspan', {
fill: {'add': '#4ec2ff', 'edit': '#5fb878', 'del': '#ff5722'}[type],
class: 'node-tools-item'
})
btn.innerHTML = {'add': '新增 ', 'edit': '编辑 ', 'del': '删除'}[type]
btn.addEventListener('click', () => {
this.toolsHandle && this.toolsHandle(this, type)
})
tools.appendChild(btn)
}
if (this.level < 6) createBtn('add') // 最多新增6级
createBtn('edit')
if (this.level > 1) createBtn('del') // 第一级不可删除
return tools
}
// 父子节点连接线
createLine() {
let lines = makeSVG('g', {
close: 'open' // 折叠节点使用
})
let parent = this.parentNode
if (parent) {
let startYParent = parent.yStart + parent.height + line1
// 折叠节点下方的竖线:line2 竖线
lines.appendChild(makeSVG('path', {
d: `M ${this.middle} ${startYParent} L ${this.middle} ${this.yStart} z`,
fill: 'none',
stroke: lineColor,
'stroke-width': lineWidth,
}))
// 折叠节点下方的横线:向左画(第一个节点不需要画) 横线
// 寻找前一个节点
// console.log(this.prevNode)
let prev = this.prevNode
if(prev) {
let start = `${prev.middle} ${startYParent}`,
end = `${this.middle} ${startYParent}`
let lineChilds = makeSVG('path', {
d: `M ${start} L ${end} z`,
stroke: lineColor,
'stroke-width': lineWidth,
fill: 'none',
})
lines.appendChild(lineChilds)
}
}
if (!this.children || this.children.length <= 0) return lines
let startY = this.yStart + this.height + line1
// 与子节点的第一段连线
lines.appendChild(makeSVG('path', {
d: `M ${this.middle} ${this.yStart + this.height} L ${this.middle} ${startY} z`,
stroke: lineColor,
'stroke-width': lineWidth,
fill: 'none'
}))
// 添加一个折叠节点
let collapse = makeSVG('circle', {
cx: this.middle,
cy: startY,
r: collapseSize,
stroke: lineColor,
fill: 'white',
'stroke-width': 1,
style: 'cursor: pointer',
})
let iconText = makeSVG('text', {
x: this.middle,
y: startY,
'font-size': 12,
fill: lineColor,
'text-anchor': 'middle',
'dominant-baseline': "middle",
style: 'cursor: pointer',
})
iconText.innerHTML = '-'
let clickFunc = function () {
let close = lines.getAttribute('close') === 'close',
brother = lines.parentNode.childNodes
lines.setAttribute('close', close ? 'open' : 'close')
iconText.innerHTML = close ? '-' : '+'
brother.forEach((v) => {
if (v.tagName === 'g' && v.getAttribute('collapse') === 'yes') v.style.display = close ? '' : 'none'
})
}
collapse.addEventListener('click', clickFunc)
iconText.addEventListener('click', clickFunc)
lines.appendChild(collapse)
lines.appendChild(iconText)
return lines
}
}
4、draw.js 根据数据,绘制节点并连接在一起,写入页面
class Draw {
hasCreated = false; // 是否已经绘制过
data; // 要绘制的数据
$box; // 生成的svg要填充到的 dom元素
$svg; // 生成的svg元素
constructor(options = {}) {
if (options.data) this.setData(options.data)
if (options.$box) this.$box = options.$box
if (options.toolsHandle) this.toolsHandle = options.toolsHandle
}
setData(tableData) {
if (!tableData || tableData.length === 0) {
this.data = []
return
}
this.data = JSON.parse(JSON.stringify(tableData)) // 深拷贝原始数据
}
// 画图
create() {
if (!this.data || this.data.length <= 0) return
// if (this.hasCreated) return; // 避免重复创建
// this.hasCreated = true
this.$box.innerHTML = ''
this.$svg = makeSVG('svg')
this.setAxis()
this.createSvg(this.data)
this.$box.append(this.$svg)
// 滚动到中心位置
// box.scrollTo(svgWidth / 2 - box.offsetWidth / 2, 0)
}
// 操作按钮
toolsHandle () {
}
// 设置节点坐标
setAxis() {
let levelXStart = {}, // 寻找同级节点的离当前线最近的x坐标,防止节点重叠
xStart = 100,
svgWidth = 0,
svgHeight = 0
let func = (arr, parent) => {
if (!arr || arr.length <= 0) return
let y = parent ? (parent.yStart + parent.line1 + parent.line2 + parent.height) : 0 // line1 line2 参见 node.js
arr.forEach((v, i) => {
let node = new NodeOrgan(v)
node.yStart = y
node.parentNode = parent
node.prevNode = arr[i - 1]
node.toolsHandle = this.toolsHandle // 操作按钮
arr[i] = node
if (levelXStart[v.level] > xStart) xStart = levelXStart[v.level]
if (node.children && node.children.length) {
let minXStart = xStart
func(node.children, node)
let end = node.children[node.children.length - 1], first = node.children[0]
let nowXStart = (end.xStart + end.width - first.xStart - node.width) / 2 + first.xStart
if (nowXStart < minXStart) { // 可能有重叠块,重新计算一下位置
let num = minXStart - nowXStart
let resetAxis = (childs) => {
for (let v of childs) {
v.xStart += num
let x = v.xStart + v.width + v.marginSize
if (levelXStart[v.level] < x) levelXStart[v.level] = x
if (v.children && v.children.length) resetAxis(v.children)
}
}
resetAxis(node.children)
xStart = minXStart
} else {
xStart = nowXStart
}
node.xStart = xStart
if (arr[i + 1]) xStart += node.width + node.marginSize
} else {
node.xStart = xStart
xStart += node.width + node.marginSize
}
levelXStart[v.level] = xStart
})
// 画布大小设置
if (y > svgHeight) svgHeight = y
if (xStart > svgWidth) svgWidth = xStart
}
func(this.data)
// console.log(this.data)
this.$svg.setAttribute('width', svgWidth + 300)
this.$svg.setAttribute('height', svgHeight + 300)
}
// 生成各节点实例
createSvg(arr, parentDiv) {
if (!arr || arr.length <= 0) return []
for (let v of arr) {
let g = makeSVG('g', {
collapse: 'yes',
id: `level-${v.level}-${v.id}`
})
// 节点矩形框
g.appendChild(v.createRect())
// 节点文本
g.appendChild(v.createText())
// 操作:新增、编辑、删除
g.appendChild(v.createTools())
let hasChild = v.children && v.children.length > 0
if (hasChild) this.createSvg(v.children, g)
// 节点与父节点的连线
if (parentDiv || hasChild) g.appendChild(v.createLine())
let top = parentDiv || this.$svg
top.appendChild(g)
}
}
}
5、data.js 测试数据
const originData = [
{
"id": 28,
"name": "大名称",
"parent_id": 0,
},
{
"id": 37,
"name": "总经办",
"parent_id": 28,
},
{
"id": 38,
"name": "行政中心",
"parent_id": 28,
},
{
"id": 40,
"name": "三层",
"parent_id": 38,
},
{
"id": 41,
"name": "四层",
"parent_id": 40,
},
{
"id": 42,
"name": "五层",
"parent_id": 41,
},
{
"id": 43,
"name": "八层",
"parent_id": 42,
},
{
"id": 63,
"name": "二层节点",
"parent_id": 28,
},
{
"id": 64,
"name": "三层节点",
"parent_id": 63,
},
{
"id": 65,
"name": "二层2",
"parent_id": 28,
},
{
"id": 66,
"name": "二层3",
"parent_id": 28,
},
{
"id": 67,
"name": "三层3-1",
"parent_id": 66,
},
{
"id": 68,
"name": "二层4",
"parent_id": 28,
},
{
"id": 71,
"name": "二层5",
"parent_id": 28,
},
{
"id": 72,
"name": "二层6",
"parent_id": 28,
},
{
"id": 73,
"name": "二层5-1",
"parent_id": 71,
},
{
"id": 76,
"name": "1212",
"parent_id": 65,
},
{
"id": 81,
"name": "2121",
"parent_id": 65,
}
]