如何快速绘制带动效的定制版流程图
上周有个业务组让我们做一个流程图,数据是固定不变的,但是要求流程之间的线条可以动起来。考虑到这个不通用,且我们这边也抽不出人力,因此我让他们自己的开发去学一下,自己写。
后面我空的时候想了下,这个需求应该还是有不少场景可能会用到,因此自己尝试写了下,这里做个笔记。
效果展示
先看下效果,类似这样:
技术选型
这个需求乍一看不难,但选型的时候有几个方向要排除。
第一个排除的方案是用 ECharts 或 AntV G6 这类现成的图库。 它们确实支持流程图,但动效是「节点高亮」或「连线绘制」这种,做不出「一个圆点沿着线条流动」的效果。要强行实现的话得 hack 内部的渲染逻辑,成本比从头写还高。
第二个排除的方案是纯 CSS 动画。 CSS 的 animation 配合 offset-path 可以让元素沿路径运动,但 offset-path 需要写死 SVG path 字符串,而我们的 path 是从 ProcessOn 导出的 SVG 里动态解析出来的,没法预先写死。
最终采用的方案,思路是「静态图交给工具,动效交给代码」:
- 用
ProcessOn画出静态流程图,导出为 SVG 文件 - 将 SVG 内容放入网页,形成一个静态 HTML
- 通过 JS 解析 SVG 中的线条信息,即
path标签的d属性 - 通过 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)
})
这里有个细节:圆点是 append 到 path 的 parentNode(即 <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 负责按距离分配时间。
这个思路其实和很多场景是通的:地图上的车辆轨迹回放、数据流图里的数据流动、网络拓扑图里的数据包传输,本质都是「元素沿路径运动」。掌握了这套解析 + 动画的模式,这类需求都能快速搞定。
后续可以优化
- 支持圆弧路径。 把
C(贝塞尔曲线)离散化成小线段,让圆点也能在弧线上流动。用 De Casteljau 算法采样即可。 - 支持多个圆点错峰流动。 同一条线上放多个圆点,间隔一定距离,形成「队列流动」的效果。需要在
play函数里加 offset 逻辑。 - 支持方向可控。 目前圆点是从起点流向终点,如果流程图有回流(比如审批打回),需要支持反向流动。可以在 path 上标记方向,
play里判断正向还是反向。 - 抽成通用组件。 输入一个 SVG,自动为所有 path 添加流动圆点。可以封装成 D3 插件或独立的 JS 库。