目标效果
源码地址
如果急用, 可先cv走代码修改, 哪里没明白再回来看哪里.
介绍下 D3.js
d3 是一个大而全的图形库, 集成了svg 元素操作和常见图表(图形)的数据结构.
本文基于v5 版本的 d3 编写, d3 的功能都是拆分成独立包的, 我们这里只需要引入d3-hierarch和d3-shape生成拓扑的数据结构.
// hierarchy 用来生成d3的树形对象, 同时挂载一些方法, 比如获取子元素descendants
// tree 用来给节点分配x/y坐标
// linkHorizontal用来生成水平连接线
import {hierarchy, tree} from 'd3-hierarchy';
import {linkHorizontal} from 'd3-shape';
复制代码
代码架构
- 使用 d3 生成拓扑数据结构(其实就是树形, 每个节点会生成 x/y 坐标).
- 使用 vue 进行 svg 的 dom 结构渲染(也可用 canvas 进行渲染).
- 使用 any-touch 添加"拖拽"和"点击关闭/展开子节点"功能.
- 简单封装一个动画函数, 实现关闭/展开动画, 真的很简单, 仅仅为了锦上添花, 如果不需要动画此处内容可以跳过.
代码解读👨🏫
下面代码看起来长, 但是主要都是注释.
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 件事:
- 循环生成节点, 用
<foreignObject>
元素实现 svg 内部可以嵌套普通 html 元素. - 循环生成节点间的连线.
<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函数)
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组件并开源, 一切看大家反馈, 如果支持这个计划请下方留言☎️.
🔥typescript系列课程
基础教程从这里开始
特别篇, 在vue3🔥源码中学会typescript🦕 - "is"
第六课, 什么是声明文件(declare)? 🦕 - 全局声明篇
新手前端学🔥typescript - 实战篇, 实现浏览器全屏(59行)
🔥往期热门文章
真.1px边框, 🚀 支持任意数量边和圆角, 1 个万金油的方法
微博
刚玩微博, 咱们可以互相关注, 嘿嘿