很纠结,望反馈
复现第一个可视化作品,其实只花了几天,复盘的文章却拖了挺久,其实古柳一直很纠结文章要写成什么样?
如果要讲解每行代码的作用,未免过于繁琐,花费的时间也要几倍于复现的时间。
之前翻译的 data sketch
上「百变小樱」
可视化作品的复现文章,代码开源在 GitHub 有上千行,但文章里却一行代码未出现,真就干讲,虽然很不习惯这么写文章,感觉言之无物,但姑且折中简单讲讲,默认大家对 d3.js
有一定了解,不清楚的地方可以查看代码,或群里问我(群二维码在文章末尾)。
先这么写试试水,毕竟第一篇复现文章,希望大家可以在评论或群里反馈,以便后续能有更好的呈现方式。
言归正传,之前古柳说过会复现许多优秀的可视化作品,相应的 GitHub
仓库早已建好,但迟迟没有更新。(本文代码也开源在此)https://github.com/DesertsX/dataviz-in-action
「百变小樱」
作品源码古柳也看过,作为第一个项目,还是过于复杂,且有些必备的知识点需要补充学习,只能往后排期,战略性地选择其他软柿子捏。
说起来,9月21号正好看到推特上有人发了 Nadieh Bremer
和索尼音乐合作的可视化作品,也是 径向图/radial chart
,也挺酷的。(似乎又到了该定期分享可视化作品的时候,正好群里分享过许多,可以整理作为第2期输出)
https://twitter.com/plan_bo/status/1307972808895614976
本以为是 Nadieh Bremer
最新的作品,分享到群里后,万能的群友找出了博客文章,原来6月份就写了实现过程。https://www.visualcinnamon.com/2020/06/sony-music-data-art
不过是商业项目没有开源代码,但古柳觉得和「百变小樱」
其实蛮像,毕竟"本质上就是一径向图",doge。知道径向图怎么实现、复现遍「百变小樱」
,应该也能自行复现出类似效果,当然数据如何获取和处理又是另说。
扯回来,其实软柿子早有“柿”选,很久以前看过的财新网「星空彩绘诺贝尔奖」
可视化作品,大概是古柳有印象的首个径向图,这次终于有机会进行复现!http://datanews.caixin.com/2013/nobel/index.html
动态展示过程录制成视频方面大家查看。
不过考虑到整体动态效果一并实现会麻烦许多,步子迈太大,怕......所以想了想还是先把最终静态的效果复现出来,等以后有机会再回头迭代出含动态轮播的完整版。
说起来,录视频时古柳才注意到有个小 bug
:当年份轮播到左半边时,如图选中1986
年时,文字并不是朝上显示,而国家名称都是朝上显示的。
难怪古柳一开始看源码,发现图表主体区域用了下图作为背景图,而非全部用 svg 绘制时,还很困惑,不懂为何要分开完成。
看到这个 bug
,古柳大概明白:可能开发者那时没能解决左侧文字朝上显示的问题,导致在国家名称和部分年份必须一直显示时不因此造成阅读障碍,只能提前画好,并以标量图+矢量图的奇怪方式结合到起来
, 副作用就是放大网页时图片内容会变模糊,也挺奇特,2333...
之所以在这点上讲了这么多,其实是因为自己复现时,毫无疑问是想用 d3.js
全部完全,而不是偷个懒,直接用背景图走个捷径。毕竟也没有天赐一个设计师以后帮忙画图(有的话请联系我)/PS、AI也不会;而且后续想做类似径向图作品时,如果这次都自己实现,到时候自然也更丝滑些。
而复现时真真切切遇到的第一个坎就是国家文字不知道如何旋转成原作品的样子,只不过那时古柳还没细究背景图为何分开画,也没注意到文字显示的bug
。
扯了这么多,终于要开始进入正题,讲讲古柳是如何从对径向图的实现方式一无所知,到最后一步步复现出简化版「星空彩绘诺贝尔奖」
可视化作品的。
一开始,古柳完全不知道径向图是怎么实现的,想来看这篇文章的人不少人可能也是如此。
为了先了解最基础的径向图背后实现原理,古柳找出 「 Fullstack D3 and Data Visualization」
一书的代码仓库里的两个雷达图/径向图来学习:Example 08: Common charts - Demo 3: Radar chart
简单基础些;Example 11: Radar weather chart
复杂些。https://github.com/TheRobBrennan/explore-data-visualization-with-D3
边理解边跟着敲一遍代码。知道怎么将圆心设为整个图形的坐标中心,而不是一般 svg
图表里x/y坐标轴以左上角为坐标原点;怎么画径向图里的轴线、折线曲线;怎么获取每条数据对应的角度/弧度,怎么根据弧度和半径转换成x/y坐标;怎么放置一圈的圆圈等等......
大概了解过后,就可以捋起袖子,开始真刀真枪地进行复现了。
重新看下这次要复现的作品,很明显整体包含由内到外多层信息,为方面后面依次实现每层内容,在一开始古柳先把每层半径等相关参数抽取出来放到一起。(其他的一些源码阅读和准备工作不再赘述,有疑惑可群里咨询)
const width = 640;
let dimensions = {
width: width,
height: width,
radius: width / 2,
pieInnerRadius: 95,
pieOuterRadius: 110,
ageInnerRadius: 125,
ageOuterRadius: 200,
countryBarRadius: 216,
yearRadius: 216 + 62,
};
有别于一般的柱形图/折线图/散点图,以x/y轴的交叉点为原点(svg
里常为画布左上角),径向图围绕圆心展开,所以这一步非常重要,通过 viewbox
属性的设置使得后续数据点都相对于圆心而非左上角。这里参考 observablehq
上的实现方式,更为简单,而不是 fullstack d3
里更为繁琐的通过设置 transform
的方式实现。
https://observablehq.com/search?query=Radial%20%20Chart
const bounds = d3
.select("#wrapper")
.append("svg")
.attr(
"viewBox",
`${-dimensions.width / 2} ${-dimensions.height / 2} ${dimensions.width} ${
dimensions.height
}`
)
.attr("width", dimensions.width)
.attr("height", dimensions.height)
.append("g");
按照由内到外的顺序,先画最内部的饼图。由于原始作品里,动态轮播时按最外层的年份,依次更新饼图中每个奖项的比例;而本次复现只用到最终全部奖项的数据,所以取数据里的最后一项 secondPieData[115]
即可,或者直接用 [201, 169, 210, 111, 75]
都行,原数据其实有7项,暂时按源码的方式填充颜色,但哪种方式本次都差别不大。
function drawPieChart() {
const arc = d3
.arc()
.innerRadius(dimensions.pieInnerRadius)
.outerRadius(dimensions.pieOuterRadius);
const pie = d3.pie().sort(null);
const allPath = bounds
.selectAll("path")
// [201, 169, 210, 111, 75, 0, 0]
.data(pie(secondPieData[115]))
.enter()
.append("path")
.attr("d", arc)
.attr("fill", (d, i) => {
if (i == 5) return "#5c5c5c";
if (i == 6) return "#FFFFFF";
return nobelDotColor[i];
});
}
drawPieChart();
本次复现不涉及年份轮播,所以最外层年份的刻度其实是可有可无的,只是为显得完整才加上。
但国家名称却是不可或缺的,仔细看的话,所有数据都是按照国家进行区分。由于是径向图,每个国家需要按其在数据中出现的顺序,先获得对应在圆圈中的角度/弧度,直接创建个 countryScale
比例尺(就是一个映射用的函数)即可。
然后创建个 getCoordinatesForAngle()
函数:根据弧度和不同层不同的半径,得到某个数据点在x/y直角坐标系里的坐标。
const countryScale = d3
.scaleLinear()
.domain([0, countryList.length])
.range([0, Math.PI * 2]);
const getCoordinatesForAngle = (angle, radius) => [
Math.cos(angle - Math.PI / 2) * radius,
Math.sin(angle - Math.PI / 2) * radius,
];
绘制5个圆圈,用 d3.range(5).map()
很简单就能实现;再加上 20/40/60/80
的年龄说明;最后加上和国家数一样多的直线。
const axis = bounds.append("g").attr("class", "grid-line");
function drawAxis() {
d3.range(5).map((i) => {
const cr =
dimensions.ageInnerRadius +
(i * (dimensions.ageOuterRadius - dimensions.ageInnerRadius)) / 4;
axis.append("circle").attr("r", cr);
axis
.append("text")
.attr("y", -cr)
.attr("dx", ".2em")
.attr("dy", "-.5em")
.text(i != 4 ? (i + 1) * 20 : "");
});
countryList.map((country, i) => {
const [x1, y1] = getCoordinatesForAngle(
countryScale(i),
dimensions.ageInnerRadius
);
const [x2, y2] = getCoordinatesForAngle(
countryScale(i),
dimensions.ageOuterRadius
);
axis
.append("line")
.attr("class", "grid-line")
.attr("x1", x1)
.attr("y1", y1)
.attr("x2", x2)
.attr("y2", y2);
});
}
drawAxis();
上文也提到,如何将文字旋转成合适的格式(指向圆心且左右均朝上显示),其实困惑了很久,最后还是参考 observablehq
上的例子,调整后才得以解决。https://observablehq.com/@d3/radial-stacked-bar-chart
function drawCountry() {
countryList.map((country, i) => {
const [x, y] = getCoordinatesForAngle(
countryScale(i),
dimensions.countryBarRadius
);
const countryGroup = bounds.append("g").attr(
"transform",
`rotate(${(countryScale(i) * 180) / Math.PI - 90}) translate(${
dimensions.countryBarRadius - 5
}, 0)`
);
countryGroup
.append("text")
.text(country)
.attr("fill", "#a29bfe")
.style("text-anchor", (d) =>
countryScale(i) < Math.PI ? "start" : "end"
)
.attr("transform", (d) =>
countryScale(i) < Math.PI
? "translate(0, 15)"
: "rotate(-180) translate(0,-7)"
);
});
}
drawCountry();
接着绘制国家名称边上的奖项堆叠柱形图,这里其实显示有 bug
,放大能看到很多奖项的柱形有重叠,但由于直到最后都没能找出 bug
在哪,只能留个坑,有发现原因的小伙伴可以告知下。
let countryPrize = personCountPerYP[114];
let new_countryPrize = [];
const keys = ["物理", "化学", "生理或医药", "文学", "经济"];
countryPrize.forEach((arr, i) => {
const new_item = {};
arr.forEach((item, j) => {
new_item[keys[j]] = item;
});
new_item["sum"] = d3.sum(arr);
new_countryPrize.push(new_item);
});
const stackedData = d3.stack().keys(keys)(new_countryPrize);
function drawCountryPrizeBarChart() {
const stackBar = bounds
.append("g")
.selectAll("g")
.data(stackedData)
.join("g")
.attr("fill", (d, idx) => nobelDotColor[idx]);
stackBar
.selectAll("rect")
.data((d) => d)
.join("rect")
.attr(
"transform",
(d, i) =>
`rotate(${(countryScale(i) * 180) / Math.PI - 90}) translate(${
dimensions.countryBarRadius - 4
}, 20)`
)
.attr("x", (d) => Math.sqrt(Math.sqrt(d[0])) * 10)
.attr("height", 10)
.attr("width", (d) => Math.sqrt(Math.sqrt(d[1] - d[0])) * 10);
}
drawCountryPrizeBarChart();
接着绘制每个国家每个年龄段的不同奖项获奖人数的圆圈。这里的计算方式偷懒直接用了源码里的,没自己去梳理。值得一提的是将某一维数据映射到圆圈大小时一般都会开个平方根,这里亦然;上面堆叠图甚至两次开平方根,也是值得注意的处理方式。
function drawEachPrizeCircle() {
// iAll 国家 / jAll 年龄段 / kAll 各奖项人数 / 审查可视化图表时根据 class 发现的
d3.range(countryList.length).map((iAll) => {
d3.range(5).map((jAll) => {
d3.range(5).map((kAll) => {
const angle =
countryScale(iAll) +
((kAll + 1) * ((2 * Math.PI) / countryList.length)) / 6;
const age = 10 + jAll * 20;
const personAgeToPoint = 90 - ((age - 20) / 80) * 75;
const radius = dimensions.countryBarRadius - personAgeToPoint;
const [x, y] = getCoordinatesForAngle(angle, radius);
bounds
.selectAll(`.AllPerson${iAll}`)
.data(nbAllPerson[iAll][jAll])
.join("circle")
.attr("class", `allPersonPoints_${iAll}_${jAll}_${kAll}`)
.attr("fill-opacity", 0.2)
.attr("fill", nobelDotColor[kAll])
.attr("cx", x)
.attr("cy", y)
.attr("r", 1.5 * Math.sqrt(nbAllPerson[iAll][jAll][kAll]));
});
});
});
}
drawEachPrizeCircle();
最后加上最外层可有可无的年份刻度圈(几乎就是照搬了源码);加上中心标题;加上奖项的 legend
图例等等,不再细说,比较简单与常规,直接看代码即可。
原本其实还想参考 Fullstack D3 复杂版径向天气图加上交互,但最终没再继续,等复现其他径向图作品时再练习下这部分的实现。
以下就是最终实现的效果,虽然仍不完美,但作为第一个复现出来的可视化作品,大概勉强合格吧,你给打几分呢?
代码开源地址:https://github.com/DesertsX/dataviz-in-action
一个复杂的可视化作品到底是如何实现的,看完本文,不知道大家有没有多了一些认知?
古柳写这篇文章时真的很纠结,写完也自知最终内容并不理想,很多地方也没解释清楚,但实在拖的有些久了,也暂无破局之计,别无他法,只能如此这般早早了结。希望大家读完能反馈感想,以便后续复现文章能有所改进!
欢迎所有对可视化感兴趣的小伙伴加群,对代码有疑惑的也可以群里咨询。
若满200人无法扫码进入了,可以加古柳微信「xiaoaizhj」
备注「加群」
最后,欢迎关注古柳的公众号「牛衣古柳」
,将会持续分享更多可视化相关干货内容。