原生小记:组织架构图生成(svg格式)

效果图:
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,
    }
]


©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 218,525评论 6 507
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,203评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,862评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,728评论 1 294
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,743评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,590评论 1 305
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,330评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,244评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,693评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,885评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,001评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,723评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,343评论 3 330
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,919评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,042评论 1 270
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,191评论 3 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,955评论 2 355

推荐阅读更多精彩内容