最近来了一个新需求,需要将 DOM 容器通过正交线连接起来,从而展示元素之间的关联关系,样式有点像脑图,设计稿大概如下所示:
思路分析
通过观察发现,要实现这个需求其实还是挺简单的,因为只涉及到左侧容器和右侧容器进行连接,是一个一对多的连接关系,并且左右两侧的容器的位置都是相对固定的,不存在拖拽调整位置的情况。
所以我们可以通过使用 SVG 或者 canvas 来绘制连线,这里我使用的是 SVG,然后将 SVG 视图层定位到 DOM 元素视图层下方即可,通过下面这张 3D 示意图,我们会更加容易理解:
效果预览
既然要做这个连线效果,我们不妨给它再多添加一点花样:
- 线条类型:支持实线和虚线2种类型
- 支持自定义线条样式,包括:线条颜色、线条粗细、虚线样式
- 支持自定义拐角圆弧半径
- 支持连线开启飞行标记动画效果
- 支持连线开启蚂蚁线动画效果
- 支持同时开启蚂蚁线动画和飞行标记动画效果
实线:
虚线:
飞行标记物动画效果:
蚂蚁线动画效果:
蚂蚁线结合飞行标记效果:
动态操作:
最终代码:
代码实现
元素布局:
<style>
.box {
width: 100px;
height: 100px;
padding: 10px;
margin: 10px;
border: 10px solid #aaa;
position: absolute;
display: flex;
align-items: center;
justify-content: center;
}
#app {
width: 700px;
height: 700px;
margin: 0 auto;
border: 1px solid #ddd;
position: relative;
}
</style>
<div id="app">
<div class="dom">
<div id="container1" class="box" style="top: 50px; left: 50px; background-color: lightblue;">
container1
</div>
<div id="container2" class="box" style="top: 50px; left: 400px; background-color: lightgreen;">
container2
</div>
<div id="container3" class="box" style="top: 200px; left: 400px; background-color: lightcoral;">
container3
</div>
<div id="container4" class="box" style="top: 350px; left: 400px; background-color: lightpink;">
container4
</div>
</div>
</div>
为了方便生成 svg 的 path 数据,所以我们首先定义一个工具类:
class SVGPath {
constructor() {
this.pathData = [];
}
// 将绘图光标移动到指定的点 (x, y)
moveTo(x, y) {
this.pathData.push(`M ${x} ${y}`);
return this;
}
// 从当前点画一条直线到指定的点 (x, y)
lineTo(x, y) {
this.pathData.push(`L ${x} ${y}`);
return this;
}
// 从当前点画一条二次贝塞尔曲线到指定的点 (x, y),控制点为 (cx, cy)
quadraticCurveTo(cx, cy, x, y) {
this.pathData.push(`Q ${cx} ${cy} ${x} ${y}`);
return this;
}
// 从当前点画一条三次贝塞尔曲线到指定的点 (x, y),两个控制点分别为 (c1x, c1y) 和 (c2x, c2y)
bezierCurveTo(c1x, c1y, c2x, c2y, x, y) {
this.pathData.push(`C ${c1x} ${c1y} ${c2x} ${c2y} ${x} ${y}`);
return this;
}
// 从当前点画一条椭圆弧线到指定的点 (x, y),椭圆的半径为 (rx, ry),旋转角度为 xAxisRotation,大弧标志为 largeArcFlag,扫掠标志为 sweepFlag
/*
rx ry:椭圆的 x 轴和 y 轴半径。
x-axis-rotation:椭圆旋转的角度(通常为 0)。
large-arc-flag:决定使用大弧还是小弧(0 表示小弧,1 表示大弧)。
sweep-flag:决定弧的方向(0 表示逆时针,1 表示顺时针)。
x y:弧的终点坐标。
*/
arcTo(rx, ry, xAxisRotation, largeArcFlag, sweepFlag, x, y) {
this.pathData.push(`A ${rx} ${ry} ${xAxisRotation} ${largeArcFlag} ${sweepFlag} ${x} ${y}`);
return this;
}
// 关闭当前路径,使其形成一个封闭的形状
closePath() {
this.pathData.push('Z');
return this;
}
// 返回生成的路径数据字符串
toString() {
return this.pathData.join(' ');
}
};
// 示例用法
const path = new SVGPath()
.moveTo(10, 10)
.lineTo(100, 10)
.quadraticCurveTo(150, 50, 100, 90)
.bezierCurveTo(50, 130, 10, 130, 10, 90)
.closePath();
console.log(path.toString());
接下来定义 ConnectLine 类,来实现绘制:
class ConnectLine {
constructor(wrapper, options = {
stroke: "#000", // 连线颜色
strokeWidth: 1, // 线宽
radius: 20, // 拐角圆弧半径
lineType: "solid", // 连线类型 solid/dashed
lineDash: [5, 5], // 虚线样式
markAnimate: false, // 是否开启飞行标记动画
markAnimateDuration: '1s', // 飞行标记动画时长
antLineAnimate: false, // 是否开启蚂蚁线动画效果
antLineAnimateDuration: '10s', // 蚂蚁线动画时长
}) {
this.$wrapper = this.verify_element(wrapper);
this.$wrapper.style.position = "relative";
this.$svgElement = this.initSvgElement();
this.options = options;
this.initParams();
}
verify_element(element) {
if (typeof element === "string") {
return document.querySelector(element);
} else if (element instanceof HTMLElement) {
return element;
} else {
throw new TypeError(`${element} only supports usage of a string CSS selector, HTML DOM element for the 'element' parameter`);
}
}
initSvgElement() {
const svgNS = "http://www.w3.org/2000/svg";
const svgElement = document.createElementNS(svgNS, "svg");
svgElement.style = "position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: -1;";
this.$wrapper.appendChild(svgElement);
return svgElement;
}
initParams() {
if (this.options.lineType === "dashed" || this.options.antLineAnimate) {
if (!this.options.lineDash) {
this.options.lineDash = 5;
}
}
// 如果开启了飞行标记动画
if (this.options.markAnimate) {
if (!this.options.markAnimateDuration) {
this.options.markAnimateDuration = '3s';
}
}
// 如果开启了蚂蚁线动画
if (this.options.antLineAnimate) {
if (!this.options.antLineAnimateDuration) {
this.options.antLineAnimateDuration = '10s';
}
// 添加CSS动画
const style = document.createElement('style');
style.innerHTML = `
@keyframes ant-line {
from {
stroke-dashoffset: 100%;
}
to {
stroke-dashoffset: 0;
}
}
`;
document.head.appendChild(style);
}
}
connect(sourceWrap, targetWrap) {
const sourceEle = this.verify_element(sourceWrap);
const targetEle = this.verify_element(targetWrap);
if (!sourceEle || !targetEle) return;
// 获取容器的位置和尺寸
const sourceRect = {
width: sourceEle.offsetWidth,
height: sourceEle.offsetHeight,
top: sourceEle.offsetTop,
left: sourceEle.offsetLeft,
right: sourceEle.offsetLeft + sourceEle.offsetWidth,
};
const targetRect = {
width: targetEle.offsetWidth,
height: targetEle.offsetHeight,
top: targetEle.offsetTop,
left: targetEle.offsetLeft,
right: targetEle.offsetLeft + targetEle.offsetWidth,
};
// 计算中心点
const sourceCenter = {
x: sourceRect.left + sourceRect.width / 2,
y: sourceRect.top + sourceRect.height / 2,
};
const targetCenter = {
x: targetRect.left + targetRect.width / 2,
y: targetRect.top + targetRect.height / 2,
};
// 生成唯一的路径 ID
const pathId = `path-${Math.random().toString(36).substring(2, 15)}`;
// 创建 SVG path 路径
const pathEle = document.createElementNS("http://www.w3.org/2000/svg", "path");
pathEle.setAttribute("id", pathId);
pathEle.setAttribute("fill", "none");
// 设置线的颜色
pathEle.setAttribute("stroke", this.options.stroke);
// 设置线的宽度
pathEle.setAttribute("stroke-width", this.options.strokeWidth);
pathEle.setAttribute("stroke-linecap", "round");
pathEle.setAttribute("stroke-linejoin", "round");
// 设置虚线模式
if (this.options.lineType === "dashed") {
pathEle.setAttribute("stroke-dasharray", this.options.lineDash);
}
let dPath = "";
if (targetCenter.y - sourceCenter.y === 0) {
// 在同一水平线上
dPath = new SVGPath()
.moveTo(sourceRect.right, sourceCenter.y)
.lineTo(targetRect.left, targetCenter.y)
} else {
// 不在同一水平线上
// 绘制正交连线
dPath = new SVGPath()
.moveTo(sourceRect.right, sourceCenter.y)
.lineTo((targetRect.left - sourceRect.right) / 2 + sourceRect.right - this.options.radius, sourceCenter.y)
// 向左拐,顺时针
.arcTo(this.options.radius, this.options.radius, 0, 0, 1, (targetRect.left - sourceRect.right) / 2 + sourceRect.right, sourceCenter.y + this.options.radius)
.lineTo((targetRect.left - sourceRect.right) / 2 + sourceRect.right, targetCenter.y - this.options.radius)
// 向右拐,逆时针
.arcTo(this.options.radius, this.options.radius, 0, 0, 0, (targetRect.left - sourceRect.right) / 2 + sourceRect.right + this.options.radius, targetCenter.y)
.lineTo(targetRect.left, targetCenter.y)
}
const d = dPath.toString();
pathEle.setAttribute("d", d);
// 添加蚂蚁线动画效果(行进线效果)
if (this.options.antLineAnimate) {
pathEle.setAttribute("stroke-dasharray", this.options.lineDash); // 设置dasharray为路径长度
pathEle.setAttribute("stroke-dashoffset", this.options.lineDash); // 设置dashoffset为路径长度
pathEle.style.animation = `ant-line ${this.options.antLineAnimateDuration} linear infinite`; // 添加动画效果
}
this.$svgElement.appendChild(pathEle);
// 添加飞行标记动画效果
if (this.options.markAnimate) {
const marker = document.createElementNS("http://www.w3.org/2000/svg", "circle");
marker.setAttribute("id", `marker_${pathId}`);
marker.setAttribute("r", 5);
marker.setAttribute("cx", 0);
marker.setAttribute("cy", 0);
marker.setAttribute("fill", '#c3d5f9');
const animateMotion = document.createElementNS("http://www.w3.org/2000/svg", "animateMotion");
animateMotion.setAttribute("dur", this.options.markAnimateDuration);
animateMotion.setAttribute("repeatCount", "indefinite");
animateMotion.setAttribute("path", d);
marker.appendChild(animateMotion);
this.$svgElement.appendChild(marker);
}
return pathId;
}
// 取消连接
disconnect(pathId) {
const pathEle = document.getElementById(pathId);
if (pathEle) {
this.$svgElement.removeChild(pathEle);
}
// 移除飞行标记
if (this.options.markAnimate) {
const markerEle = document.getElementById(`marker_${pathId}`);
if (markerEle) {
this.$svgElement.removeChild(markerEle);
}
}
}
}
如何使用:
// 实例化
const int = new ConnectLine('#app', {
stroke: "#CFD8E5", // 连线颜色
strokeWidth: 1, // 线宽
radius: 15, // 拐角圆弧半径
lineType: "dashed", // 线类型,可选值:solid(实线), dashed(虚线)
lineDash: 7, // 虚线模式下,表示虚线和实线之间的间隔
markAnimate: true, // 是否添加飞行标记
markAnimateDuration: '2s', // 飞行标记动画时长
antLineAnimate: true, // 是否添加蚂蚁线动画效果(行进线效果)
antLineAnimateDuration: '10s', // 蚂蚁线动画时长
});
const pathId1 = int.connect(container1, container2);
const pathId2 = int.connect(container1, container3);
const pathId3 = int.connect(container1, container4);
addBtn.onclick = () => {
const box = document.createElement('div');
box.className = 'box';
box.style = "top: 550px; left: 400px; background-color: lightblue;";
app.appendChild(box);
int.connect(container1, box);
addBtn.onclick = null;
}
总结
通过本篇文章,我们实现了一个 ConnectLine 的插件,用于连线多个 DOM 容器,并且可以通过配置项自定义线条的样式和动画效果。这里的功能基本可以满足我的开发需求了,但是该插件还有很多可以改造升级的空间,我这里就抛砖引玉了,希望可以给大家提供一些思路和借鉴。