选择集会用了,但画图还得靠比例尺把数据映射到像素。这篇接着往下写。
比例尺是什么
拿到的数据和要画的像素、颜色不是一回事。QPS 从 0 到 5000,圆点半径想控制在 0 到 40 像素之间,直接把 QPS 数值当半径用肯定不行,5000 像素的圆早就超出屏幕了。中间需要一个函数,把 QPS 的取值范围换算成像素的取值范围。
D3 里这个函数由 d3.scale.linear() 生成:
var radiusScale = d3.scale.linear()
.domain([0, 5000])
.range([0, 40]);
radiusScale(2500); // 20
radiusScale(5000); // 40
radiusScale(0); // 0domain 是数据本身的取值范围,range 是映射之后的目标范围。d3.scale.linear() 调用完返回的是一个函数,radiusScale 存的就是这个函数。之后每次调用 radiusScale(某个 QPS 值),返回的都是按线性比例换算出来的像素数。domain 和 range 两头的区间长度不需要相等,中间会自动做线性插值,2500 落在 0-5000 的正中间,换算到 0-40 的 range 里也是正中间,也就是 20。
用在圆点半径和颜色上
假设有一批服务节点的 QPS 数据:
var services = [
{ name: "waimai-order", qps: 1200 },
{ name: "waimai-cart", qps: 300 },
{ name: "waimai-search", qps: 4800 },
{ name: "waimai-user", qps: 50 }
];先算出这批数据里 QPS 的实际范围,不手写死区间,用 d3.max 和 d3.min 取:
var maxQps = d3.max(services, function(d) { return d.qps; });
var minQps = d3.min(services, function(d) { return d.qps; });
var radiusScale = d3.scale.linear()
.domain([minQps, maxQps])
.range([8, 40]);range 从 8 开始而不是 0,是因为半径为 0 的圆点在图上看不见,最小的节点也得有个能点得中的大小。
选择集绑定数据之后,圆的半径直接调用这个比例尺:
svg.selectAll("circle")
.data(services)
.enter()
.append("circle")
.attr("cx", function(d, i) { return i * 100 + 50; })
.attr("cy", 100)
.attr("r", function(d) { return radiusScale(d.qps); });颜色的映射方式一样,range 里换成两个颜色字符串:
var colorScale = d3.scale.linear()
.domain([minQps, maxQps])
.range(["#fee8c8", "#e34a33"]);
svg.selectAll("circle")
.attr("fill", function(d) { return colorScale(d.qps); });d3.scale.linear() 的 range 填两个颜色字符串时,D3 会把颜色当成一个可插值的区间处理,中间值对应的颜色在这两个颜色之间做插值,不需要自己算颜色渐变。一开始没想到这个函数还能这么用,以为线性比例尺只能处理数字,试了一下发现直接能用在颜色上,把几个不同 QPS 的节点摆一起看,颜色确实是从浅黄到深红过渡的,没有跳变。
ordinal scale 和等宽分类
上面这种是连续数值的映射,服务名称这种分类数据不适用,没有大小关系,也没法插值。这种场景用 d3.scale.ordinal():
var names = services.map(function(d) { return d.name; });
var xScale = d3.scale.ordinal()
.domain(names)
.rangeRoundBands([0, 400], 0.1);
xScale("waimai-order"); // 某个具体的像素值
xScale("waimai-cart"); // 下一个位置rangeRoundBands 第一个参数是总的像素范围,第二个参数控制条与条之间的间隔比例(严格说是间隔占每段步长,即条宽加间隔之和的比例,0.1 时条与条之间留出大约一成条宽的空隙,值越大间隔越宽)。domain 里有几个名称,这个范围就自动按名称个数等分成几段,每个名称对应一段固定宽度的位置。画柱状图的时候用它给每根柱子分配横坐标,画分类坐标轴的时候也是它。
SVG 坐标系的坑
SVG 里 y 轴正方向是向下的,原点在左上角。日常画图直觉里 y 往上是正方向,这两个是反的。
第一次画柱状图的时候直接用比例尺算高度然后设 y 属性,柱子全从画布顶部往下长,QPS 越高的柱子看着反而越往下压得深,方向整个是反的:
// 错误的写法,柱子从上往下长,值越大越往下
// rect 是已绑定 services 数据的矩形选择集:
// svg.selectAll("rect").data(services).enter().append("rect")
var heightScale = d3.scale.linear()
.domain([0, maxQps])
.range([0, 200]);
rect.attr("y", 0)
.attr("height", function(d) { return heightScale(d.qps); });要让柱子从底部往上长,y 坐标得用画布高度减去算出来的高度:
var heightScale = d3.scale.linear()
.domain([0, maxQps])
.range([0, 200]);
rect.attr("y", function(d) { return 200 - heightScale(d.qps); })
.attr("height", function(d) { return heightScale(d.qps); });y 是矩形左上角的坐标,height 是往下延伸的长度,两者配合才能让矩形底边固定在 200 这条线上,看起来才是往上长的。
用 d3.svg.axis() 生成坐标轴
坐标轴本身也是画一堆刻度线和文字,自己一条条画很麻烦,D3 封装了这一步:
var xAxis = d3.svg.axis()
.scale(xScale)
.orient("bottom");
svg.append("g")
.attr("class", "x axis")
.attr("transform", "translate(0,200)")
.call(xAxis);d3.svg.axis() 生成一个坐标轴生成器,把比例尺传给它,刻度该画在哪些位置就由它按这个比例尺算出来。orient("bottom") 是指刻度文字画在轴线下方,对应横轴的常见样式。最后一步 .call(xAxis) 是把这个坐标轴生成器应用到一个 <g> 元素上,实际的刻度线和文字就是这时候生成并插入到这个 <g> 里的。
后来在 demo 里实际用到的比例尺
这个系列后面做了一个可运行的服务大盘 demo,回头看,里面一共接了四个比例尺,全是这篇讲的思路的直接应用:
- 节点半径:
domain是节点 QPS 范围,range是 12 到 30 像素 - 节点颜色:
domain同上,range是蓝到红两个颜色字符串 - 连线粗细和颜色:
domain是调用 QPS 范围,range分别是像素宽度和颜色区间 - 连线上流动白点的速度:
domain是调用 QPS,range是每秒走过的线段比例
有一点超出这篇讲过的范围,补记一下:半径没有用线性比例尺,用了开方比例尺。原因是圆的面积和半径是平方关系,QPS 差 4 倍的两个服务,如果半径也差 4 倍,面积就差了 16 倍,视觉上小的那个小得不成比例。用开方比例尺让面积随 QPS 线性增长,节点之间的大小对比才和 QPS 对比一致。这是可视化里的一个惯例:用圆的大小表达数值时,锚定面积而不是半径。
留个问题
上面这些都是单个图元怎么摆放的问题,节点和颜色能定下来了,但节点之间怎么排布还没解决。热力图大盘要画的是一张有向图,服务之间的调用关系摆在一起,节点不能随便定坐标乱放,位置得让相互调用的节点尽量靠近、不重叠。比例尺管不了这个,得看 d3.layout.force()。下一篇打算研究一下这个力导向布局怎么把一堆节点和连线自动排开,再看看调用线上那个沿方向移动的白点具体怎么做,目前设想的思路是用定时器不断更新点在线段上的位置,但这个点怎么算出线段中间某个百分比对应的坐标,还没有头绪。

