搜索文章

输入关键词开始搜索

如何快速绘制带动效的定制版流程图

D3.js#D3#数据可视化

上周有个业务组让我们做一个流程图,数据是固定不变的,但是要求流程之间的线条可以动起来。考虑到这个不通用,且我们这边也抽不出人力,因此我让他们自己的开发去学一下,自己写。

后面我空的时候想了下,这个需求应该还是有不少场景可能会用到,因此自己尝试写了下,这里做个笔记。

效果展示

先看下效果,类似这样:

技术选型

这个需求乍一看不难,但选型的时候有几个方向要排除。

第一个排除的方案是用 ECharts 或 AntV G6 这类现成的图库。 它们确实支持流程图,但动效是「节点高亮」或「连线绘制」这种,做不出「一个圆点沿着线条流动」的效果。要强行实现的话得 hack 内部的渲染逻辑,成本比从头写还高。

第二个排除的方案是纯 CSS 动画。 CSS 的 animation 配合 offset-path 可以让元素沿路径运动,但 offset-path 需要写死 SVG path 字符串,而我们的 path 是从 ProcessOn 导出的 SVG 里动态解析出来的,没法预先写死。

最终采用的方案,思路是「静态图交给工具,动效交给代码」:

  1. ProcessOn 画出静态流程图,导出为 SVG 文件
  2. 将 SVG 内容放入网页,形成一个静态 HTML
  3. 通过 JS 解析 SVG 中的线条信息,即 path 标签的 d 属性
  4. 通过 D3.js 画出圆点,并给其设置 transition 动画,修改 transform 属性来实现动效

这个方案的核心判断是:静态结构(节点、连线、文字)用工具画最快,动效用代码控制最灵活。两者通过 SVG 这个中间格式衔接。

实现思路和关键问题

问题一:怎么从 SVG 里提取路径坐标

ProcessOn 导出的 SVG 里,每条连线是一个 <path> 标签,它的 d 属性描述了路径。格式大概是这样:

M 100 100 L 200 100 L 200 200

M 是起点(Move to),L 是直线段(Line to)。一段折线就是多个 L 拼起来的。

我要做的就是把 d 属性解析成坐标数组,每个坐标点代表折线的一个拐点。圆点会沿着这些拐点依次移动。

问题二:折线怎么保持匀速

这个是实际写的时候才发现的坑。

一开始我的想法很简单:给整条路径设置一个固定的 duration,圆点从头跑到尾。但折线由多段长度不同的线段组成,如果每段用同样的时间,短线段上圆点会很慢,长线段上会很快,视觉上就是忽快忽慢。

解决方法是:根据每段线段的实际长度,按比例分配动画时间。 长的线段给更多时间,短的线段给更少时间,这样整体看起来就是匀速的。

问题三:圆弧和闭合路径怎么处理

ProcessOn 导出的 SVG 里,有些路径带 C(贝塞尔曲线)或 Z(闭合),这些和直线段的解析逻辑不一样。我当时的处理方式是简单粗暴——直接跳过:

if (dValue.includes('C') || dValue.includes('Z')) {
    return coordinates;
}

这是个权衡。完整支持圆弧需要把贝塞尔曲线离散化成小线段,计算量更大,但视觉收益不明显(圆点在弧线上流动和直线段差别不大)。当时时间有限,先跳过了,后续如果有需要再补。

代码

核心逻辑分三个部分。

第一部分:遍历所有 path,为每条线创建圆点

d3.selectAll('path')
    .attr('test', function () {
        let d = d3.select(this).attr('d');
        let id = d3.select(this).attr('id');
        // SvgjsPath1096  折线
        // SvgjsPath1124  直线

        if (id === 'SvgjsPath1096') {
            console.log(`%c ${d}`, 'color:red');
        } else {
            console.log(d);
        }
        
        let paths = dAttrToPath(d)
        if (paths.length <= 0) {
            return false;
        }
        
        let ball = d3.select(this.parentNode)
            .append('circle')
            .attr('r', 10)
            .attr('fill', 'red')
            .classed('ball', true)

        // 绑定球的移动动画
        play(paths, ball, 1)       
    })

这里有个细节:圆点是 appendpathparentNode(即 <svg><g>)上的,而不是 path 本身上。因为圆点要独立移动,不能继承路径的变换。

第二部分:解析 d 属性为坐标数组

/**
 * 将 d 属性转换为坐标数组
 * @param {*} dValue 
 */
function dAttrToPath(dValue) {
    let coordinates = [];
    // 临时先排除圆弧
    if (dValue.includes('C') || dValue.includes('Z')) {
        return coordinates;
    }
    dValue.replace('M', '').split('L').forEach(d => {
        let coord = d.trim().split(' ');
        // 剔除非线条的内容,比如方框
        if (coord.length > 1) {
            coordinates.push(d.trim().split(' '))
        }
    })
    
    // 去重
    for (let i = 1; i < coordinates.length; i ++) {                
        // coordinates[i - 1] 是为了确保上一个没被删除过
        if (coordinates[i - 1] && coordinates[i][0] === coordinates[i - 1][0] && coordinates[i][1] === coordinates[i - 1][1]) {            
            delete coordinates[i]
        }
    }

    let result = [];
    coordinates.forEach(d => result.push(d))

    return result;
}

去重这一步是必要的。ProcessOn 导出的 path 里,偶尔会有连续两个相同的坐标点(相当于一条长度为 0 的线段),不去重的话圆点会在原地卡一下。

第三部分:按线段长度分配时间的动画

// 因为路径可能是由多条线段组成的,所以要对每一段线段都绑定动画
// 为了保持匀速,绑定的时间应该根据线段的长度来计算
// 注意:这里的参数 i 是目标位置的 i,不是起点的 i
function play(paths, ball, i)
{
    if (i === 1) {
        ball.attr('transform', `translate(${paths[0][0]}, ${paths[0][1]})`)
    }
    let currentCoordinate = i === 0 ? paths[0] : paths[i - 1] 
    let nextCoordinate = paths[i]
    let duration = computeSpeed(currentCoordinate, nextCoordinate)
        
    try {
        ball.transition()
        .attr('transform', `translate(${paths[i][0]}, ${paths[i][1]})`)
        .duration(duration)
        // 平滑动画,详见:https://github.com/d3/d3-ease
        .ease(d3.easeLinear)
        .on('end', async function () {
            if (i === paths.length - 1) {
                play(paths, ball, 1)
            } else {
                play(paths, ball, i + 1)
            }
        })
    } catch (e) {
        console.log(`translate(${paths[i][0]}, ${paths[i][1]})`);
    }
}

/**
 * 计算 2 个坐标点的距离,返回动画时长
 */
function computeSpeed(coordinate1, coordinate2)
{
   let distance = Math.sqrt(Math.pow(coordinate1[0] - coordinate2[0], 2) + Math.pow(coordinate1[1] - coordinate2[1], 2))
   return distance * 20;
}

几个关键点:

  • d3.easeLinear 是必须的。默认的 ease 是缓入缓出,每段线段开头慢中间快结尾慢,拼起来看就是一顿一顿的。easeLinear 保证每段内部匀速。
  • duration = distance * 20 这个系数 20 是经验值,表示每像素 20 毫秒。可以根据需要的流速调整。
  • on('end', ...) 递归调用 实现了循环:到达终点后 i 重置为 1,圆点回到起点重新开始。

性能和可维护性

这套方案上线之后效果可以,但如果节点和连线数量多了,有几个性能点要注意。

圆点数量。 每条 path 一个圆点,同时跑 D3 的 transition。如果流程图有上百条线,同时跑上百个 transition,低端机上会掉帧。解决方案:限制同时动画的圆点数量,或者用 requestAnimationFrame 自己控制帧率,替代 D3 的 transition。

setTimeout vs transition 原始需求里提到的是「线条动起来」,我最终用的是 D3 的 transition 而不是 setTimeout 手动驱动。原因:transition 内部已经做了帧率优化和插值,比手写 setTimeout 平滑得多。只有在需要精细控制帧逻辑时才考虑 requestAnimationFrame 手动驱动。

DOM 操作开销。 每个圆点是一个 <circle> DOM 元素,修改 transform 触发的是合成层重绘,不走 layout,性能可以接受。如果圆点数量超过几百个,建议切到 Canvas 渲染,用 getContext('2d') 手绘圆点。

可维护性。 整个方案的代码量不大(核心逻辑不到 100 行),但强依赖 ProcessOn 导出的 SVG 格式。如果 ProcessOn 改了导出格式(比如 path 的 id 命名规则变了),解析逻辑需要跟着改。建议把 dAttrToPath 抽成独立的解析模块,方便单独测试和更新。

这个方案的本质

回头看,这个需求的核心不是「怎么画流程图」,而是「怎么让一个元素沿着已知的路径动起来」。

一旦把问题这样定义,方案就很清晰了:路径是已知的(SVG path),元素的运动就是「在路径的关键点上依次插值」。D3 的 transition 负责插值,d3.easeLinear 负责匀速,computeSpeed 负责按距离分配时间。

这个思路其实和很多场景是通的:地图上的车辆轨迹回放、数据流图里的数据流动、网络拓扑图里的数据包传输,本质都是「元素沿路径运动」。掌握了这套解析 + 动画的模式,这类需求都能快速搞定。

后续可以优化

  1. 支持圆弧路径。C(贝塞尔曲线)离散化成小线段,让圆点也能在弧线上流动。用 De Casteljau 算法采样即可。
  2. 支持多个圆点错峰流动。 同一条线上放多个圆点,间隔一定距离,形成「队列流动」的效果。需要在 play 函数里加 offset 逻辑。
  3. 支持方向可控。 目前圆点是从起点流向终点,如果流程图有回流(比如审批打回),需要支持反向流动。可以在 path 上标记方向,play 里判断正向还是反向。
  4. 抽成通用组件。 输入一个 SVG,自动为所有 path 添加流动圆点。可以封装成 D3 插件或独立的 JS 库。