目标效果

🚀 在线实例

源码地址

如果急用, 可先cv走代码修改, 哪里没明白再回来看哪里.

github.com/any86/any-t…

介绍下 D3.js

d3 是一个大而全的图形库, 集成了svg 元素操作和常见图表(图形)的数据结构.

本文基于v5 版本的 d3 编写, d3 的功能都是拆分成独立包的, 我们这里只需要引入d3-hierarchd3-shape生成拓扑的数据结构.

// hierarchy 用来生成d3的树形对象, 同时挂载一些方法, 比如获取子元素descendants
// tree 用来给节点分配x/y坐标
// linkHorizontal用来生成水平连接线
import {hierarchy, tree} from 'd3-hierarchy';
import {linkHorizontal} from 'd3-shape';
复制代码

代码架构

  1. 使用 d3 生成拓扑数据结构(其实就是树形, 每个节点会生成 x/y 坐标).
  2. 使用 vue 进行 svg 的 dom 结构渲染(也可用 canvas 进行渲染).
  3. 使用 any-touch 添加"拖拽"和"点击关闭/展开子节点"功能.
  4. 简单封装一个动画函数, 实现关闭/展开动画, 真的很简单, 仅仅为了锦上添花, 如果不需要动画此处内容可以跳过.

代码解读👨🏫

下面代码看起来长, 但是主要都是注释.

d3 生成拓扑数据结构

// 测试数据
// 普通树形
const dataset = {
    name: '第1级',
    children: [
        {
            name: '第2级',
            children: [
                {
                    name: '第3级A',
                    children: [
                        {
                            name: '第4级A',
                        },
                        {
                            name: '第4级B',
                        },
                    ],
                },
            ],
        },
    ],
};
复制代码

下面代码看起来很长, 但实际只有4个方法总20几行代码,剩余都是注释.

{
    /**
     * 把普通树形变成d3需要的树形
     */
    genTreeData(data) {
        const width = 1000;
        const height = 1000;
        // hierarchy把普通的树形数据变成d3的tree结构,
        // 这样tree就有了d3的方法, 可以通过方法获取子节点(tree.descendants)/父节点/节点数等信息
        const root = hierarchy(data);
        // 遍历子节点,descendants是后代的意思,
        // 但是其实也会包含当前节点本身.
        // 给节点增加hidden字段用来控制当前节点显示/隐藏.
        root.descendants().forEach((node) => {
            node.hidden = false;
        });
        // d3.tree运行后会返回一个函数,
        // 通过函数可以设置图形的一些尺寸(nodeSize)/位置间距(separation)信息
        // 这样在返回的函数中传入刚才输入的d3.tree结构数据, 比如上面的root,
        // 那么拓扑所需的数据就都全了.
        return (
            tree()
                .separation(function(a, b) {
                    // 同级元素调整间隙比例
                    // 一般就用2:1就好
                    return (a.parent == b.parent ? 2 : 1) / a.depth;
                })
                // 节点尺寸
                .nodeSize([110, width / (root.height + 1)])(root)
        );
    },

    /**
     * 生成节点数组 => [Node, Node]
     * 用来给模板渲染元素
     */
    updateNodes() {
        this.nodes = this.tree.descendants();
    },

    /**
     * 生成线
     */
    updateLinks() {
        // tree.links会根据节点数据生成连线数据
        this.linkPaths = this.tree.links().map((link) => {
            // d.linkHorizontal和上面的d3.tree一样,
            // 可以当做构造函数,
            // 其返回一个函数
            // 可以用函数上的x/y方法指定
            // 由于默认生成tree数据是上下结构的拓扑数据,
            // 所以为了生成左至右的线需要把X/Y数据颠倒
            // 最终生成线数据结构类似这样:{source:{},target:{}}
            if (!link.target.hidden) {
                return linkHorizontal()
                    .x((d) => d.y)
                    .y((d) => d.x)(link);
            }
        });
    },
    /**
     * 生成所需数据
     */
    renderTree() {
        this.tree = this.genTreeData(dataset);
        this.updateLinks();
        this.updateNodes();
    },
}
复制代码

使用 vue 生成 svg 的 dom 结构

dom 结构看起来太长了, 但实际只做了 2 件事:

  1. 循环生成节点, 用<foreignObject>元素实现 svg 内部可以嵌套普通 html 元素.
  2. 循环生成节点间的连线.
<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="1000" style="width:100%">
    <g transform="translate(100, 100)">
        <template v-for="(linkPath, index) in linkPaths">
            <path v-if="linkPath" :key="index" :d="linkPath" class="line" />
        </template>
    </g>

    <g transform="translate(100, 100)">
        <foreignObject
            v-for="(node,index) in nodes"
            v-show="!node.hidden"
            :class="{[`at-${action}`]:activeNode===node}"
            :key="'foreignObject'+index"
            :width="itemWidth"
            :height="itemHeight"
            :x="node.y - itemWidth/2"
            :y="node.x - itemHeight/2"
            @panstart="onPanstart(index,$event)"
            @panmove="onPanmove(index,$event)"
            @panend="onPanend"
            @pancancel="onPanend"
            @tap="onTap(index)"
        >
            <body xmlns="http://www.w3.org/1999/xhtml">
                <div class="text">
                    <p>节点层级: {{node.depth}}</p>
                    <p>节点顺序: {{index}}</p>
                </div>
            </body>
        </foreignObject>
    </g>
</svg>
复制代码

使用any-touch增加拖拽和点击功能

{
    /**
     * 拖拽开始, 记录当前节点
     */
    onPanstart(index, e) {
        const [item] = this.nodes.splice(index, 1);
        this.nodes.push(item);
        this.activeNode = item;
    },

    /**
     * 拖拽中
     * 变化节点坐标
     * 重新生成连线数据
     */
    onPanmove(index, e) {
        this.action = e.type;
        const { deltaX, deltaY } = e;
        const { length } = this.nodes;
        this.activeNode.x += deltaY;
        this.activeNode.y += deltaX;
        this.updateLinks();
    },

    /**
     * 取消当前节点激活
     */
    onPanend() {
        this.activeNode = null;
    },

    /**
     * 收起/展开子节点
     */
    onTap(index) {
            this.activeNode = this.nodes[index];
            // 当前节点记录是否收起/展开
            if (void 0 === this.activeNode.collapse) {
                this.$set(this.activeNode, 'collapse', true);
            } else {
                this.activeNode.collapse = !this.activeNode.collapse;
            }
            const { x, y, collapse } = this.activeNode;
            // descendants返回的子节点包含自己, 所以排除自己
            const [a, ...childNodes] = this.activeNode.descendants();
            // 根据节点折叠状态来展开/折叠子节点显示
            childNodes.forEach((node) => {
                if (collapse) {
                    const x1 = node.x;
                    const y1 = node.y;
                    // 存储展开时候的位置,
                    // 下次复原位置用
                    node._x = x1;
                    node._y = y1;
                    animate(1, 0, 200, (value, isDone) => {
                        node.x = x - (x - x1) * value;
                        node.y = y - (y - y1) * value;
                        if (isDone) {
                            node.hidden = true;
                        }
                        this.updateLinks();
                    });
                } else {
                    node.hidden = false;
                    // 此处让value从0 - 1在200ms内不停变化
                    // 从而让节点位置变化实现展开收缩动画
                    animate(0, 1, 200, (value) => {
                        node.x = x + (node._x - x) * value;
                        node.y = y + (node._y - y) * value;
                        this.updateLinks();
                    });
                }
            });
    }
}
复制代码

动画(animate函数)

源码: github.com/any86/any-t…

animate函数实现其实很简单, 主要说下easeInOut函数, 他其实就是一个"时间为x轴, 值为y轴的曲线", 是我百度搜的, 其实还有很多类似的曲线函数, 大家可自行搜索. 大家可以自己尝试写一个, 比如借助Math.sin.

动画在本例就是锦上添花, 逻辑也很简单不展开讲解, 如果有兴趣可留言讨论.

/**
 * t 时间
 * b 起始值
 * c 目标值
 * d 所需时间
* */
function easeInOut(t, b, c, d) {
    if ((t /= d / 2) < 1) return c / 2 * t * t + b;
    return -c / 2 * ((--t) * (t - 2) - 1) + b;
}

/**
 * 用requestAnimationFrame不断执行easeInOut
 * */
export function animate(from = 0, to = 0, duration = 1000, callback = () => void 0) {
    const startTime = window.performance.now();
    function run() {
        const timeDiff = window.performance.now() - startTime;
        const value = easeInOut(timeDiff, from, to - from, duration);
        if (timeDiff <= duration) {
            callback(value);
            requestAnimationFrame(run);
        } else {
            // 修正超出边界
            callback(to, true);
        }
    }
    run();
};
复制代码

全部代码: github.com/any86/any-t…

未来

计划封装成vue组件并开源, 一切看大家反馈, 如果支持这个计划请下方留言☎️.