D3 扫盲(二):比例尺与 SVG 图形基础

1 分钟阅读
·

选择集会用了,但画图还得靠比例尺把数据映射到像素。这篇接着往下写。

比例尺是什么

拿到的数据和要画的像素、颜色不是一回事。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);    // 0

domain 是数据本身的取值范围,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.maxd3.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 表示间隔占每个条宽度的 10%)。domain 里有几个名称,这个范围就自动按名称个数等分成几段,每个名称对应一段固定宽度的位置。画柱状图的时候用它给每根柱子分配横坐标,画分类坐标轴的时候也是它。

SVG 坐标系的坑

SVG 里 y 轴正方向是向下的,原点在左上角。日常画图直觉里 y 往上是正方向,这两个是反的。

第一次画柱状图的时候直接用比例尺算高度然后设 y 属性,柱子全从画布顶部往下长,QPS 越高的柱子看着反而越往下压得深,方向整个是反的:

// 错误的写法,柱子从上往下长,值越大越往下
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> 里的。

留个问题

上面这些都是单个图元怎么摆放的问题,节点和颜色能定下来了,但节点之间怎么排布还没解决。热力图大盘要画的是一张有向图,服务之间的调用关系摆在一起,节点不能随便定坐标乱放,位置得让相互调用的节点尽量靠近、不重叠。这不是比例尺能管的事,得看 d3.layout.force(),下一篇打算研究一下这个力导向布局怎么把一堆节点和连线自动排开,再看看调用线上那个沿方向移动的白点具体怎么做,目前设想的思路是用定时器不断更新点在线段上的位置,但这个点怎么算出线段中间某个百分比对应的坐标,还没有头绪。


168 字 · 25 段落
xi ming

Written by xi ming You should follow him on Github

评论与讨论