比例尺学完了,颜色和粗细这两个映射问题算是解决了。剩下的问题更麻烦:几十个服务节点摆在哪个坐标上,连线怎么表示是谁调用谁,怎么让人一眼看出流量在往哪个方向流动。这三个问题不解决,大盘就只能是一堆静态方块,看不出拓扑关系。这篇整理清楚力导向布局、箭头和方向动画这几块,D3 扫盲系列到这里就收尾。
节点坐标由力模拟逐帧算出来
一开始想自己写个算法分层摆放节点,把服务调用关系整理成树状结构,一层一层往下排。试了一下发现行不通,外卖这边的服务调用关系不是树,是网状的:A 调 B,B 反过来也可能被 C 调,中间还有环。自己写布局算法工作量太大,而且节点一多,摆的位置很容易全部挤在一起,或者连线相互交叉得没法看。
D3 自带一个 d3.layout.force(),专门解决这个问题,思路是物理模拟:每个节点当成带电粒子,粒子之间互相排斥,节点不会叠在一起;有调用关系的两个节点之间再加一根弹簧,把它们往中间拉。每一帧重新计算所有节点受到的合力,更新一点点位置,几十帧下来整个系统趋于稳定,节点该散开的散开,该聚拢的聚拢,连线不会全部打结在一起。这个过程叫 force layout,力导向布局。
配置代码大致是这样:
var width = 960;
var height = 600;
var force = d3.layout.force()
.nodes(nodes)
.links(links)
.size([width, height])
.linkDistance(80)
.charge(-300)
.on("tick", tick)
.start();nodes 和 links 是两个数组,nodes 放服务节点,每个节点是个对象,比如 {name: "order-service", qps: 1200};links 放调用关系,每条边是 {source: 0, target: 1} 这种结构,source 和 target 是节点在 nodes 数组里的索引(也可以直接传节点对象,D3 内部会处理索引转换)。
.charge(-300) 是斥力,负值表示排斥,绝对值越大(越负)排斥力越强,节点会散得更开。一开始设成 -30,几十个节点全挤在一坨,调到 -300 才看出层次。.linkDistance(80) 是弹簧的自然长度,表示有调用关系的两个节点大概会稳定在多远的距离,这个值只是倾向值,不强制生效,最终的稳定距离还受斥力和其他连线拉扯影响。.linkStrength() 没写,默认是 1,某些调用关系想让它们贴得更紧,可以按需调这个值。.size([width, height]) 的作用是设定布局的重力中心为画布中心,节点会被弱引力往中心聚拢;它不做边界限制,节点仍然可能被斥力推出画布,需要的话得在 tick 回调里手动限制坐标范围。
最后的 .start() 启动模拟,每一帧都会调用 .on("tick", ...) 注册的回调,直到系统的动能衰减到很小(D3 内部有一个默认的冷却机制,交给回调的只是每一帧的通知)。节点数量不多的话,几十次 tick 之后画面基本就不动了。画面不动说明模拟已经停了:D3 内部的冷却参数 alpha 每帧衰减,低于阈值(约 0.005)后模拟自动结束并触发 end 事件,tick 回调不再触发。之后数据有变化,要重新调 .start() 或调大 .alpha() 才会恢复布局。
tick 回调里要自己写视图更新
这是容易卡住的一个地方:力学计算和 DOM 更新是完全分开的两件事。d3.layout.force() 只负责算出每个节点该在哪个坐标,它不会帮你把这个坐标写到 SVG 元素上,这部分要在 tick 回调里自己写:
var svg = d3.select("svg");
var link = svg.selectAll(".link")
.data(links)
.enter().append("line")
.attr("class", "link");
var node = svg.selectAll(".node")
.data(nodes)
.enter().append("circle")
.attr("class", "node")
.attr("r", 10);
function tick() {
link
.attr("x1", function(d) { return d.source.x; })
.attr("y1", function(d) { return d.source.y; })
.attr("x2", function(d) { return d.target.x; })
.attr("y2", function(d) { return d.target.y; });
node
.attr("cx", function(d) { return d.x; })
.attr("cy", function(d) { return d.y; });
}d.x、d.y 是力模拟每一帧算出来的最新坐标,直接挂在每个 node 对象上。传进 links 的每条边,D3 会把 source 和 target 从索引换成实际的节点对象引用,所以 d.source.x 能直接拿到调用方节点当前的横坐标。这一层转换搞明白之后,tick 里的代码其实很直白:node 更新圆心坐标,line 更新两个端点坐标,仅此而已。
节点半径可以按 QPS 定,连线的颜色和粗细按调用 QPS 走比例尺,这块跟上一篇的比例尺代码衔接,.attr("stroke", function(d) { return colorScale(d.qps); }) 这种写法,不重复贴。
用 marker 画箭头表示调用方向
节点和连线摆好之后,图上还看不出方向,一条线连着 A 和 B,谁调用谁完全靠猜。这个问题不用靠 D3 的图形生成器,SVG 自带的 <marker> 元素就能解决。
思路是先在 <defs> 里定义一个箭头形状,再在需要显示箭头的线段上引用它:
svg.append("defs").append("marker")
.attr("id", "arrow")
.attr("viewBox", "0 -5 10 10")
.attr("refX", 15)
.attr("refY", 0)
.attr("markerWidth", 6)
.attr("markerHeight", 6)
.attr("orient", "auto")
.append("path")
.attr("d", "M0,-5L10,0L0,5")
.attr("fill", "#999");
link.attr("marker-end", "url(#arrow)");viewBox 定义箭头图形自己的坐标系,d="M0,-5L10,0L0,5" 画一个尖朝右的三角形。orient="auto" 是关键,它让箭头自动根据线段的方向旋转,不用自己算角度。refX 控制箭头相对线段末端的偏移。线段的末端在目标节点的圆心,不做偏移的话箭头尖端会落在圆心位置,被节点的圆圈盖住。refX 要取不小于节点半径的值,把箭头从圆心沿线段往回拉,尖端才能停在圆圈边缘附近。上面代码里 refX 取 15、节点半径是 10,就是按这个原则给的值。
link.attr("marker-end", "url(#arrow)") 把定义好的箭头挂到每条线的末端,每条 <line> 渲染出来自带一个指向 target 的箭头,图上就能看出谁调用了谁。
沿线跑动的小白点
箭头解决了静态方向问题,但大盘想要的是动态效果,一个小白点顺着调用方向持续移动,看起来像数据真的在流动。SVG 有 <animateMotion> 可以做路径动画,试下来在几十个节点同时跑的场景下兼容性和性能都不太稳,索性用 D3 自己写。
D3 提供一个 d3.timer(),注册一个函数进去,它会在每一帧反复调用这个函数,直到函数返回 true(表示这个定时器可以停了)。借助它自己实现路径插值:
var dot = svg.selectAll(".flow-dot")
.data(links)
.enter().append("circle")
.attr("class", "flow-dot")
.attr("r", 3)
.attr("fill", "#fff");
links.forEach(function(d) {
d.progress = Math.random(); // 每条边随机一个起始进度,避免所有点同步跑
});
d3.timer(function() {
dot.attr("cx", function(d) {
d.progress += 0.01;
if (d.progress > 1) {
d.progress = 0;
}
return d.source.x + (d.target.x - d.source.x) * d.progress;
})
.attr("cy", function(d) {
return d.source.y + (d.target.y - d.source.y) * d.progress;
});
});d.progress 是每条边自己维护的一个 0 到 1 之间的进度值,每帧加 0.01,超过 1 就归零,循环往复。x = x1 + (x2 - x1) * t 是最基本的线性插值公式,t 取 0 时在起点,取 1 时在终点,取中间值时按比例落在线段上对应的位置。cy 的算法一样,换成 y 坐标。
有个地方容易漏:起点终点不能写死。节点位置由力模拟持续调整(哪怕后期变化很小),如果初始化的时候把 d.source.x 存成一个固定值,后面节点稍微挪动了,小白点的路径就和实际连线对不上,看起来像是飘在线外面。上面代码里每次都直接读 d.source.x、d.target.x 的当前值,点的轨迹会跟着连线实时对齐,不会跑偏。
.progress 加多少决定了点跑得多快,加 0.01 时按 60 帧一秒算,每秒走 60% 的路程,跑完一条边全程大约 1.7 秒(实际取决于浏览器的刷新频率,一般在 60 次一秒上下)。想让调用 QPS 高的边跑得更快,可以把递增值也做成一个比例尺,用调用量映射出一个稍大的步进值,QPS 越高点跑得越快,这样能在动画速度上叠加一层信息,不过大盘第一版先用固定步进跑通再说。
拖拽交互如果需要加,d3.behavior.drag() 可以直接挂在 node 的选择集上,配合 force 的 .drag() 方法,被拖拽的节点会临时固定住不受力的影响,这块暂时用不上,先不展开。
后来做 demo 时补的几个细节
这套思路后来用一个可运行的 demo 验证过(数据是编的,点这里可以玩,节点能拖)。真正拼起来的时候,有几个这篇里没写到的细节值得补记一下。
拖拽最后还是加了。 写法是给节点挂拖拽行为,拖开始的时候把节点的 fx/fy(固定坐标)设成当前位置,拖动中跟着鼠标更新,松开时清掉。被固定的节点不再受力的作用,但其他节点会被它挤开,整个图还是活的。还有一个容易漏的点:拖动的瞬间要调一下 simulation.alphaTarget(0.3).restart() 把模拟重新”加热”,否则模拟早就冷却停止了,拖着节点走的时候周围节点不会有任何反应,图看起来像死了一样。
箭头被节点盖住的问题,换了种解法。 前面用的是把 refX 调大、让箭头从圆心往回缩。但节点半径是按 QPS 变化的,每个节点不一样,一个固定的 refX 对不上所有节点。demo 里改成了在 tick 回调里直接把连线两端各自缩短一个节点半径的距离,线段本来就只画到圆圈边缘,箭头用固定的 refX 就行了。顺带还踩了一个坑:marker 的默认单位是 strokeWidth,箭头大小会跟着线宽缩放,调用量大的线又粗箭头又大,很丑。把 markerUnits 设成 userSpaceOnUse,箭头尺寸就固定了,和线宽脱钩。
白点速度按 QPS 映射,效果比预想的好。 前面说”第一版先用固定步进跑通”,demo 里直接把递增值也接了一个比例尺,调用 QPS 高的边白点跑得快。这么一来方向上又多了一层信息:主干链路上的白点明显更急,一眼能看出流量分布,不需要去看线的粗细对比。
帧间隔不能假设恒定。 白点位置的更新一开始是每帧固定加 0.01,在演示页面上没问题,但浏览器标签页切走再切回来,d3.timer 回调里的时间戳会跳变,如果按真实时间差算进度,白点会瞬移一大段;如果按固定步长算,切走期间白点位置又会和动画时长对不上。demo 里的处理是每帧记录上一次的时间戳,单帧步长设一个上限(50ms),超过就按上限算,白点最多慢下来,不会跳。
小结
到这里,节点自动排布、连线方向、流量动画这三块都有了可用的实现思路。加上前两篇的选择集和比例尺,服务稳定性大盘要用的可视化基础差不多齐了,接下来把这几段代码搬到实际项目里整合。
搬过去之前几个问题还没答案。一个是节点数量上来之后力模拟的性能,现在测试数据就十几个节点,跑起来很轻松,真实环境的服务节点数量比这个多不少,tick 回调里每帧都要重新计算所有 DOM 属性,节点一多浏览器会不会跟不上,需要拿真实节点数量实测一下,必要的话得考虑节点数超过阈值后是否还要保持全量动画,或者分批渲染。另一个是数据刷新频率,QPS 数据不是毫秒级的,大盘要多久轮询一次后端接口,轮询间隔和布局重新计算的时机怎么配合,是完全重新跑一次 force 布局,还是只更新节点的颜色和连线粗细、坐标保持不变,这两种做法效果和成本都不一样。第三是数据接口本身,QPS 这些指标现在是从监控平台读的,怎么整理成大盘需要的 nodes/links 格式,这一层数据转换等前端骨架搭完再回头对接。

