比例尺学完了,颜色和粗细这两个映射问题算是解决了。剩下的问题更麻烦:几十个服务节点摆在哪个坐标上,连线怎么表示是谁调用谁,怎么让人一眼看出流量在往哪个方向流动。这三个问题不解决,大盘就只能是一堆静态方块,看不出拓扑关系。这篇整理清楚力导向布局、箭头和方向动画这几块,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]) 告诉布局画布多大,避免节点跑到画布外面。
最后的 .start() 启动模拟,每一帧都会调用 .on("tick", ...) 注册的回调,直到系统的动能衰减到很小(D3 内部有一个默认的冷却机制,交给回调的只是每一帧的通知)。节点数量不多的话,几十次 tick 之后画面基本就不动了,看起来像是布局完成,实际上布局是持续在跑,只是每帧的位移小到肉眼看不出来。
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 设成比节点半径略小的值可以让箭头刚好贴在圆圈边缘。
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 次一秒上下)。想让调用 QPS 高的边跑得更快,可以把递增值也做成一个比例尺,用调用量映射出一个稍大的步进值,QPS 越高点跑得越快,这样能在动画速度上叠加一层信息,不过大盘第一版先用固定步进跑通再说。
拖拽交互如果需要加,d3.behavior.drag() 可以直接挂在 node 的选择集上,配合 force 的 .drag() 方法,被拖拽的节点会临时固定住不受力的影响,这块暂时用不上,先不展开。
小结
到这里,节点自动排布、连线方向、流量动画这三块都有了可用的实现思路。选择集解决了怎么把数据绑到 DOM 上,比例尺解决了数值怎么映射成视觉属性,这一篇解决了节点怎么摆、方向怎么表示。三篇拼起来,服务稳定性大盘要用到的可视化基础差不多齐了,接下来是把这几段代码搬到实际项目里整合。
搬过去之前几个问题还没答案。一个是节点数量上来之后力模拟的性能,现在测试数据就十几个节点,跑起来很轻松,真实环境的服务节点数量比这个多不少,tick 回调里每帧都要重新计算所有 DOM 属性,节点一多浏览器会不会跟不上,需要拿真实节点数量实测一下,必要的话得考虑节点数超过阈值后是否还要保持全量动画,或者分批渲染。另一个是数据刷新频率,QPS 数据不是毫秒级的,大盘要多久轮询一次后端接口,轮询间隔和布局重新计算的时机怎么配合,是完全重新跑一次 force 布局,还是只更新节点的颜色和连线粗细、坐标保持不变,这两种做法效果和成本都不一样。第三是数据接口本身,QPS 这些指标现在是从监控平台读的,怎么整理成大盘需要的 nodes/links 格式,这一层数据转换等前端骨架搭完再回头对接。

