Chapter 5. Layout(布局)
(接上篇:《Getting Started with D3》填坑之旅(八):第四章(下))
本章分两个示例,简要介绍了 D3.js
的三个常见布局:力导向图布局、统计直方图布局 及 堆积柱状图布局。作为包含示例的最后一个章节,同样也部署了几个小坑。一起来看看吧。
示例1:地铁站点关联图谱
本例的应用场景,来自纽约交管局制定的通用交通工具数据传输规范。作者将到站时间和站点信息整合成一个站点图数据源,其中包含两类数据:节点 (nodes) 和 链接 (links)。节点 就是各站点的名称信息构成的数组,链接 则为标记了 始发站 和 终点站 索引值的对象数组:
{
"links": [
{
"source": 0,
"target": 264
},
// ...
],
"nodes": [
{
"name": "St George"
},
// ...
]
}
关系图绘制过程中,每个链接代表一个线段(line
),每条线段连接两个节点(circle
)。前期的初始化工作相当轻松:
var width = 1500,
height = 1500;
// add SVG
var svg = d3.select('body')
.append('svg')
.attr('width', width)
.attr('height', height);
// bind nodes
var node = svg.selectAll('circle.node')
.data(data.nodes)
.enter()
.append('circle')
.attr('class', 'node')
.attr('r', 12);
// bind links
var link = svg.selectAll('line.link')
.data(data.links)
.enter()
.append('line')
.attr('class', 'link')
.style('stroke', 'black');
接下来实现力的导向图。D3.js
内置了力导向图的布局工具,初始化后配置相应的参数即可:
// create the layout
var force = d3.layout.force()
.charge(-120)
.linkDistance(30)
.size([width, height])
.nodes(data.nodes)
.links(data.links)
.start();
// bind tick event handler
force.on('tick', function() {
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; })
});
// make draggable
node.call(force.drag);
这里的 charge
和 linkDistance
在新版 D3.js
中有较大改动,所以只需知道这是力导向图的初始化步骤之一即可,分别代表电荷力与初始间距。在力的导向图中,每个节点假设都受到其余各节点的斥力,以及相关链接提供的引力。通过模拟物理学力的合成与分解的算法,最终各节点将达到动态平衡。力图的平衡过程就是节点在相关算法下位置坐标不断变换的过程。这个过程是通过绑定一个特定事件实现的:tick
。该事件表征力图布局的单步运算,里面设置了每条线、每个点的相应位置;众多个步骤按 D3
特定的时间间隔单位依次执行,就能描述所有节点构成的力图所经历的动态平衡过程。
由于 tick
事件的写法和之前中规中矩的静态方法不同,作者给了一个温馨小贴士:如何确定里面回调函数的参数呢?多试试 console.log
吧——在控制台打出 d
和 i
的取值,很多问题就不攻自破了。
此外,D3
还提供了单个节点的拖拽事件快速绑定方法,这样就无需重复造轮子了。
然后作者说就能有这样的效果:
然而实际情况却是:
好吧,最后一章了,CSS
样式自己去找源文件补吧,补上这个坑,就一模一样了:
circle {
stroke:black;
stroke-width:1px;
fill:MediumSeaGreen;
opacity:0.5;
}
完整示例 JS
如下:
let json = null;
function draw(data) {
"use strict";
// visualization code goes here
json = data;
var width = 1500,
height = 1500;
// add SVG
var svg = d3.select('body')
.append('svg')
.attr('width', width)
.attr('height', height);
// bind nodes
var node = svg.selectAll('circle.node')
.data(data.nodes)
.enter()
.append('circle')
.attr('class', 'node')
.attr('r', 12);
// bind links
var link = svg.selectAll('line.link')
.data(data.links)
.enter()
.append('line')
.attr('class', 'link')
.style('stroke', 'black');
// create the layout
var force = d3.layout.force()
.charge(-120)
.linkDistance(30)
.size([width, height])
.nodes(data.nodes)
.links(data.links)
.start();
// bind tick event handler
force.on('tick', function() {
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; })
});
// make draggable
node.call(force.drag);
}
d3.json("/demos/data/stations_graph.json", draw);
示例2:预计等车时间分布
来看本书最后一个示例——统计汇总五条地铁线的等车时间分布。
顺便说一下,本示例最大的特点,在于只讲重点,具体实现细节需自行脑补(参考源文件)。
案例中使用的数据源结构也很简洁明了,分别是每条线路指明线路名称(route_id
)、以及该线路一段固定时间内各次到站前所记录的等候分钟数数组(interarrival_times
):
[
{
"interarrival_times": [
19.0,
20.0,
...
20.0,
20.0
],
"route_id": "F"
},
...
]
接下来要做的,就是对出现过的时间间隔做计数统计,看看各条线的等候时间都集中在哪个范围,再来进行后续比较或评估。
由于是把五条线的间隔放到一张图表中展示,结合本章讨论的布局主题,这里采用的是 统计直方图 布局。D3.js
对直方图也做了优化,初始化并配置相应参数即可:
var histogram = d3.layout.histogram()
.bins(d3.range(1.5, 23 , 2.2))
.frequency(false);
有了布局小工具,计数环节也有内置方法:
var counts = data.map(function(d){
return histogram(d.interarrival_times)
});
再次强调,由于是五条线路同时作图,作者考虑将所有线路的等候时间都统计下来,用不同颜色区分各地铁线。因此接下来需要绘制一个堆积柱状图。由此引入第三个布局小工具:堆积柱状图布局 工具,和前两个布局的初始化套路差不多:
var stack = d3.layout.stack();
最后,是将统计出的次数,按代表的分钟数、顺次放置在象征各个时间间隔的 x
轴上;而统计的该间隔上的次数,则与直方图各个小方块的高相关联,逐一往 y
轴方向堆积:
svg.selectAll("g")
.data(stack(counts))
.enter()
.append("g")
.attr("class",function(d,i){return lines[i]})
.selectAll("rect")
.data(function(d){return d})
.enter()
.append("rect")
.attr("x",function(d){return x_scale(d.x) })
.attr("y",function(d){return count_scale(d.y) - (height - margin - count_scale(d.y0))})
.attr("width", function(d){return x_scale(d.x + d.dx) - x_scale(d.x)})
.attr("height", function(d){return height - margin - count_scale(d.y)});
是的,这是堆积柱状直方图数据绑定的核心逻辑,相关的什么比例尺啊、布局工具啊,这里都一笔带过。尤其需要注意的是第 7 行和第 9 行:前者表示在指定线路的小方块中再次绑定一个专属该线路的统计数据;后者表示将这一统计数据同一个 rect 小方块进行绑定,再分别指定好该方块在直方图中的准确位置(左上顶点坐标及对应宽高)。
最后的最后,根据线路标识符 route_id
,去与 CSS
颜色样式文件 train_colours.css
作匹配,给直方图上色,就得到了如下效果:
本着一切从简的原则,这里也顾不上给出图例了。蓝色、绿色、红色、灰色、橙色分别代表:C号线、G号线、1号线、L号线 以及 F号线。可以看到,间隔时间越短、频次高度占比最高的是灰色 L 号线;等得最久的是蓝色 C 号线。另外,为了消除个别线路因为站点众多造成的不公平,这里的直方图已经做了标准化处理,所以结论还是比较可靠的。(丑是丑了点,凑合着看吧,毕竟最后一个案例了。。。)
完整 JS 代码如下:
function draw(data) {
"use strict";
var width = 800,
height = 300,
margin = 50;
var stack = d3.layout.stack()
var bar_width = 2.2,
bar_max = 23;
var histogram = d3.layout.histogram()
.bins(d3.range(1.5,bar_max,bar_width))
.frequency(false)
var lines = data.map(function(d){return "Line_" + d.route_id});
var counts = data.map(
function(d){
return histogram(d.interarrival_times)
}
);
function nested_stat(d, stat, accessor){
return stat(counts, function(d){
return stat(d.map(accessor))
})
}
var max_count = 2
var count_scale = d3.scale.linear()
.domain([0, max_count])
.range([height-margin, margin])
.nice();
var x_scale = d3.scale.linear()
.domain([
nested_stat(counts, d3.min, function(di){return di.x}),
nested_stat(counts, d3.max, function(di){return di.x})
])
.range([margin, width])
var xaxis = d3.svg.axis().scale(x_scale),
yaxis = d3.svg.axis().scale(count_scale).orient('left');
var svg = d3.select('body')
.append('svg')
.attr('width', width)
.attr('height', height);
svg.selectAll('g')
.data(stack(counts))
.enter()
.append('g')
.attr('class',function(d,i){return lines[i]})
.selectAll('rect')
.data(function(d){return d})
.enter()
.append('rect')
.attr('x',function(d){return x_scale(d.x) })
.attr('y',function(d){return count_scale(d.y) - (height - margin - count_scale(d.y0))})
.attr('width', function(d){return x_scale(d.x + d.dx) - x_scale(d.x)})
.attr('height', function(d){return height - margin - count_scale(d.y)});
svg.append('g').attr('transform','translate(0,' + (height-margin) + ')').call(xaxis)
svg.append('text').attr('x',x_scale(10)).attr('y', height - margin/5).text('scheduled wait time (minutes)')
}
d3.json('data/interarrival_times.json', draw);
至此,这本介绍 D3.js
的入门小册子就全部剖析完了,第六章就是全书(小册子)的一个内容回顾和未来展望,只有半页篇幅,也自然没有任何代码,这里就不单独翻译讲解了。只需记住前面 1 至 5 章提到的重点知识即可,比如贯穿始终的一个 D3.js
经典写法:
d3.selectAll('element')
.data(data)
.enter()
.append('element')
.attr('class', 'className')
// ...
后续我将会把这本小册子专栏文章用到的相关代码放到 Gitee
上方便后期维护,也衷心感谢能关注本专栏到这里的每一个小伙伴。
咱们有缘江湖再会!