手撸一个多容器正交连接线插件

956 阅读4分钟

最近来了一个新需求,需要将 DOM 容器通过正交线连接起来,从而展示元素之间的关联关系,样式有点像脑图,设计稿大概如下所示:

image.png

image.png

思路分析

通过观察发现,要实现这个需求其实还是挺简单的,因为只涉及到左侧容器和右侧容器进行连接,是一个一对多的连接关系,并且左右两侧的容器的位置都是相对固定的,不存在拖拽调整位置的情况。

所以我们可以通过使用 SVG 或者 canvas 来绘制连线,这里我使用的是 SVG,然后将 SVG 视图层定位到 DOM 元素视图层下方即可,通过下面这张 3D 示意图,我们会更加容易理解:

3D视图.gif

效果预览

既然要做这个连线效果,我们不妨给它再多添加一点花样:

  • 线条类型:支持实线和虚线2种类型
  • 支持自定义线条样式,包括:线条颜色、线条粗细、虚线样式
  • 支持自定义拐角圆弧半径
  • 支持连线开启飞行标记动画效果
  • 支持连线开启蚂蚁线动画效果
  • 支持同时开启蚂蚁线动画和飞行标记动画效果

实线: 实线.png

虚线: 1737531663510_F929BFAA-B45C-49fe-971C-A731D2C8F4CC.png

飞行标记物动画效果: 1737531825387_飞行标记.gif

蚂蚁线动画效果: 1737531914764_蚂蚁线效果.gif

蚂蚁线结合飞行标记效果: 1737532019221_蚂蚁线结合飞行标记效果.gif

动态操作: 1737532131924_动态设置.gif

最终代码:

代码实现

元素布局:

<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 容器,并且可以通过配置项自定义线条的样式和动画效果。这里的功能基本可以满足我的开发需求了,但是该插件还有很多可以改造升级的空间,我这里就抛砖引玉了,希望可以给大家提供一些思路和借鉴。