当前内容所在位置:
- 第五章 饼图布局与堆叠布局 ✔️
- 5.1 饼图和环形图的创建 ✔️
- 5.1.1 准备阶段(一)
- 5.1.2 饼图布局生成器(二)
- 5.1.3 圆弧的绘制(三)
- 5.1.4 数据标签的添加(四)
- 5.1.5 DIY-在 Observable 平台实现多个 D3 环形图的绘制 ✔️
文章目录
- DIY 实战:在 Observable 平台利用饼图布局函数实现 D3 多个环形图的绘制
- 1. 需求描述
- 2. 具体步骤
- 2.1 上传并初始化数据集
- 2.2 按边距约定声明所需尺寸和常量
- 2.3 迁移环形图中的比例尺
- 2.4 绘制环形图模块的迁移
- 2.4.1 明确总体思路
- 2.4.2 添加 SVG 容器及内部绘图区
- 2.4.3 指定年份的遍历逻辑
- 2.4.4 基于年份生成带注解的环形图数据集
- 2.4.5 单个环形图容器的绘制
- 2.4.6 初始化圆弧生成工具
- 2.4.7 绘制各环形片段的 path 元素
- 2.4.8 每个环形图数据标签的绘制
- 2.4.9 为每个环形图添加年份标签
- 2.5 绘制 HTML 模板并渲染环形图
《D3.js in Action》全新第三版封面
DIY 实战:在 Observable 平台利用饼图布局函数实现 D3 多个环形图的绘制
【写在前面】
最近一直在忙着更新我的 CSS 专栏《CSS in Depth 2》,趁着今天冬至顺利进军全书最后一个模块的学习,回头再来复盘一下《D3.js in Action》第五章 5.1 小节的知识点。时隔半月有余,这一节中关于多个拼图的绘制方法已经有所生疏了,真是应了那句 “拳不离手曲不离口” 的老话。没关系,之前有基础,多练练就找回感觉了。
1. 需求描述
将《D3.js in Action》第 3 版 5.1 节中演示的三个环形图,原封不动地迁移到 Observable 平台,如图 1 所示:
【图 1 将在 Observable 平台同步实现的 5.1 节多个环形图效果】
2. 具体步骤
2.1 上传并初始化数据集
首先登录 Observable
平台(https://observablehq.com/),并新建一个记事本页面。然后将 5.1 节附带的原始数据集文件(data.csv
)上传至平台(随书源码详见我上传到 CSDN 下载频道的 zip压缩包):
【图 2:将原始数据集上传至 Observable 平台】
接着,利用 FileAttachment
语法完成数据集的赋值:
data = await FileAttachment("data.csv").csv({ typed: true })
2.2 按边距约定声明所需尺寸和常量
由于本节的页面复杂度陡然提升,演示案例又没有借助任何前端框架,导致所有的全局变量(比如边距约定需要的 margin
对象等)都只能通过 <script>
标签引入,给项目练习和往 Observable
平台迁移带来的些许不便。因为用不了 import
/ export
语法,只能来回切换不同的 js
文件:
【图 3:主页面 index.html 引入的各个 JavaScript 文件】
迁移时,我将作者提供的共享常量模块 shared-constants.js
一并放入 D3 边距约定中,并赋给常量 sizes
:
sizes = {
const margin = { top: 50, right: 0, bottom: 50, left: 70 };
const width = 1200;
const height = 350;
const innerWidth = width - margin.left - margin.right;
const innerHeight = height - margin.top - margin.bottom;
const formatsInfo = [
{ id: "vinyl", label: "Vinyl", color: "#76B6C2" },
{ id: "eight_track", label: "8-Track", color: "#4CDDF7" },
{ id: "cassette", label: "Cassette", color: "#20B9BC" },
{ id: "cd", label: "CD", color: "#2F8999" },
{ id: "download", label: "Download", color: "#E39F94" },
{ id: "streaming", label: "Streaming", color: "#ED7864" },
{ id: "other", label: "Other", color: "#ABABAB" }
];
return {
formatsInfo,
margin,
width,
height,
innerWidth,
innerHeight
};
}
这样就完成了两个全局变量的声明:
【图 4:完成迁移的原始数据集 data 以及边距约定尺寸对象 sizes】
2.3 迁移环形图中的比例尺
根据作者的思路,环形图比例尺的初始化是在 scales.js
中实现的。有了之前的 data
常量,就可以将环形图需要的两个比例尺——分别用于设置横轴的 D3 分段比例尺,以及定义色阶用到的 D3 序数比例尺 —— 统一放到 scales
常量中备用:
scales = {
const xScale = d3.scaleBand()
.domain(data.map((d) => d.year))
.range([0, innerWidth]);
const colorScale = d3
.scaleOrdinal()
.domain(sizes.formatsInfo.map((d) => d.id))
.range(sizes.formatsInfo.map((d) => d.color));
return { xScale, colorScale };
}
这样,就只剩下环形图绘制模块 donut-charts.js
了。这也是迁移过程中最麻烦的一步。
2.4 绘制环形图模块的迁移
由于 Observable
平台无需定义专门的函数来控制 D3 图形渲染的开关,定义好一个单元格,导出的模块即在全局可用。因此,本地示例页中的启动模块(load-data.js
)可以直接忽略。不过也可以用于模仿学习其中的命名规范:
// Load data
d3.csv("../data/data.csv", d3.autoType).then(data => {
defineScales(data); // scales.js
drawDonutCharts(data); // donut-charts.js
drawStackedBars(data); // 绘制堆积柱状图,与环形图无关,略
drawStreamGraph(data); // 绘制流图,与环形图无关,略
addLegend(); // 绘制图例,与环形图无关,略
});
而按照最终的环形图绘制模块,需要将 drawDonutCharts(data)
方法完整平移到 Observable
平台。这里先给出迁移前的本地版实现:
const drawDonutCharts = (data) => {
/*******************************/
/* Append the containers */
/*******************************/
// Append the SVG container
const svg = d3.select("#donut")
.append("svg")
.attr("viewBox", `0 0 ${width} ${height}`);
// Append the group that will contain the inner chart
const donutContainers = svg
.append("g")
.attr("transform", `translate(${margin.left}, ${margin.top})`);
/**********************************/
/* Create a donut chart for */
/* each year of interest */
/**********************************/
const years = [1975, 1995, 2013];
const formats = data.columns.filter(format => format !== "year");
years.forEach(year => {
// Append a group for each year
// and translate it to the proper position
const donutContainer = donutContainers
.append("g")
.attr("transform", `translate(${xScale(year)}, ${innerHeight/2})`);
// Prepare the data for the pie generator
const yearData = data.find(d => d.year === year);
const formattedData = [];
formats.forEach(format => {
formattedData.push({ format: format, sales: yearData[format] });
});
console.log("formattedData", formattedData);
// Initialize the pie layout generator
const pieGenerator = d3.pie()
.value(d => d.sales);
// Call the pie generator to obtain the annotated data
const annotatedData = pieGenerator(formattedData);
console.log("annotatedData", annotatedData)
// Initialize the arc generator
const arcGenerator = d3.arc()
.startAngle(d => d.startAngle)
.endAngle(d => d.endAngle)
.innerRadius(60)
.outerRadius(100)
.padAngle(0.02)
.cornerRadius(3);
// Append the arcs
const arcs = donutContainer
.selectAll(`.arc-${year}`)
.data(annotatedData)
// .join("path")
// .attr("class", `arc-${year}`)
// .attr("d", arcGenerator)
// .attr("fill", d => colorScale(d.data.format));
.join("g")
.attr("class", `arc-${year}`);
arcs
.append("path")
.attr("d", arcGenerator)
.attr("fill", d => colorScale(d.data.format));
arcs
.append("text")
.text(d => {
d["percentage"] = (d.endAngle - d.startAngle) / (2 * Math.PI);
return d3.format(".0%")(d.percentage);
})
.attr("x", d => {
d["centroid"] = arcGenerator
.startAngle(d.startAngle)
.endAngle(d.endAngle)
.centroid();
return d.centroid[0];
})
.attr("y", d => d.centroid[1])
.attr("text-anchor", "middle")
.attr("dominant-baseline", "middle")
.attr("fill", "#f6fafc")
.attr("fill-opacity", d => d.percentage < 0.05 ? 0 : 1)
.style("font-size", "16px")
.style("font-weight", 500);
// Append year labels
donutContainer
.append("text")
.text(year)
.attr("text-anchor", "middle")
.attr("dominant-baseline", "middle")
.style("font-size", "24px")
.style("font-weight", 500);
});
};
可以看到,绘制逻辑还是相当繁琐的。接下来一步步拆分。
2.4.1 明确总体思路
环形图的绘制主要分为以下步骤:
- 添加 SVG 容器及内部绘图区(即 SVG 分组元素
g
); - 遍历要绘制的三个年份,在循环体中依次绘制每个环形图 ——
- 添加单个环形图容器(
g
元素); - 数据源及辅助工具准备:
- 使用饼图布局函数,获取带标注信息的数据集(
annotatedData
); - 初始化圆弧生成器(
arcGenerator
);
- 使用饼图布局函数,获取带标注信息的数据集(
- 将单个环形图的各个片段归为一组,以便后续分别设置
path
元素与数据标签元素text
——- 添加各环形片段的分组元素(记为
arcs
); - 基于
arcs
绘制各段圆弧(添加path
并确定d
属性值); - 基于
arcs
绘制各段圆弧的数据标签(添加text
并确定各标签的横纵坐标)。
- 添加各环形片段的分组元素(记为
- 导出 SVG 节点,方便页面其他单元格渲染完整图形。
- 添加单个环形图容器(
2.4.2 添加 SVG 容器及内部绘图区
Observable
平台无需 DOM 操作,利用 d3.create('svg')
初始化 SVG 容器后导出即可:
svg = {
const svg = d3
.create("svg")
.attr("viewBox", `0 0 ${sizes.width} ${sizes.height}`);
// inner container
const innerContainer = svg
.append("g")
.attr("transform", `translate(${sizes.margin.left}, ${sizes.margin.top})`);
return svg;
}
2.4.3 指定年份的遍历逻辑
剩余步骤可按照以下结构先声明再逐步实现:
svg = {
// svg & inner container part:
// const svg = ...
// const innerContainer = ...
// annotated data
const years = [1975, 1995, 2013];
years.forEach((year) => {
const annotatedData = getAnnotedData(year);
// append donut container for each year's donut chart
const donutContainer = appendDonutContainer(innerContainer, year);
// arc generator
const arcGenerator = getArcGenerator();
// donut chart
const arcs = donutContainer
.selectAll(`.donut-${year}`)
.data(annotatedData)
.join("g")
.attr("class", `donut-${year}`);
// slice path
const slicePaths = appendSlicePaths(arcs, arcGenerator);
// labels for each slice
const sliceLabels = appendSliceLabels(arcs, arcGenerator);
// year label
const yearLabel = appendYearLabel(year, donutContainer);
});
return svg;
}
2.4.4 基于年份生成带注解的环形图数据集
利用循环遍历的年份 year
,筛选出当年的原始数据,然后与饼图布局函数结合,得到带注解的饼图数据集:
function getAnnotedData(year, rawData = data, fmts = formats) {
const yearData = rawData.find((d) => d.year === year);
const formattedData = fmts.map((format) => ({
format,
sales: yearData[format]
}));
// pie generator
const pieGenerator = d3.pie().value((d) => d.sales);
const annotatedData = pieGenerator(formattedData);
return annotatedData;
}
这里的 pieGenerator
就是 D3 的饼图布局函数。
2.4.5 单个环形图容器的绘制
按照 2.4.1 确定的总思路,接下来实现每个环形图的容器逻辑:
function appendDonutContainer(innerContainer, year) {
const offsetX = scales.xScale(year);
const offsetY = sizes.innerHeight / 2;
const donutContainer = innerContainer
.append("g")
.attr("transform", `translate(${offsetX}, ${offsetY})`);
return donutContainer;
}
2.4.6 初始化圆弧生成工具
即实现 svg
模块中的这一句:
const arcGenerator = getArcGenerator();
其中的 getArcGenerator
由以下单元格定义:
function getArcGenerator() {
return d3
.arc()
.startAngle((d) => d.startAngle)
.endAngle((d) => d.endAngle)
.innerRadius(60)
.outerRadius(100)
.padAngle(0.02)
.cornerRadius(3);
}
注意:这里回调函数中的 d
是带注解的数据集,不是最初的原始数据集。
2.4.7 绘制各环形片段的 path 元素
这一步即实现:
// slice path
const slicePaths = appendSlicePaths(arcs, arcGenerator);
函数 appendSlicePaths
由以下单元格实现:
function appendSlicePaths(arcs, arcGenerator) {
const { colorScale } = scales;
const slicePaths = arcs
.append("path")
.attr("d", arcGenerator)
.attr("fill", (d) => colorScale(d.data.format));
return slicePaths;
}
2.4.8 每个环形图数据标签的绘制
即实现:
// labels for each slice
const sliceLabels = appendSliceLabels(arcs, arcGenerator);
其中 appendSliceLabels
定义如下:
function appendSliceLabels(arcs, arcGenerator) {
const textFormatter = (d) => {
d.percentage = (d.endAngle - d.startAngle) / (2 * Math.PI);
const formatter = d3.format(".0%");
return formatter(d.percentage);
};
const showLabelAbove5Pct = (d) => (d.percentage < 0.05 ? 0 : 1);
const getCentroidX = initCentroidXAccessor(arcGenerator);
const getCentroidY = (d) => d.centroid[1];
const sliceLabels = arcs
.append("text")
.text(textFormatter)
.attr("x", getCentroidX)
.attr("y", getCentroidY)
.attr("text-anchor", "middle")
.attr("dominant-baseline", "middle")
.attr("fill", "#f6fafc")
.attr("fill-opacity", showLabelAbove5Pct)
.style("font-size", "1em")
.style("font-weight", 500);
return sliceLabels;
}
这一步是整个迁移过程中的难点。arcs
其实是一个 g
容器元素,但绑定的数据集是当前年份各环形片段的子数据集,因此第 14 行中的 .append("text")
其实是一次性添加了多个数据标签,数量与环形片段的个数相等。
另外,数据标签的 x
与 y
坐标,均是基于当前片段的形心(这里可以理解为重心)定位的。其中第 10 行使用了一个高阶函数来初始化形心 x
坐标的访问器函数:
function initCentroidXAccessor(arcGenerator) {
return (d) => {
d.centroid = arcGenerator
.startAngle(d.startAngle)
.endAngle(d.endAngle)
.centroid();
return d.centroid[0];
};
}
可见,每个片段都利用了圆弧生成工具 arcGenerator
以及当前绑定的数据项 d
来初始化。
2.4.9 为每个环形图添加年份标签
最后是年份标签的添加,即实现 svg
中的这一行逻辑:
// year label
const yearLabel = appendYearLabel(year, donutContainer);
其中 appendYearLabel
定义如下:
function appendYearLabel(year, container) {
const label = container
.append("text")
.text(year)
.attr("text-anchor", "middle")
.attr("dominant-baseline", "middle")
.style("font-size", "1.5em")
.style("font-weight", 500);
return label;
}
这里为了让年份标签具有响应式特征,字号使用了 1.5em
而非参考代码中的 24px
。
2.5 绘制 HTML 模板并渲染环形图
最后创建一个单元格,用于在 Observable
平台渲染 HTML 模板。Observable
内置了 HTML 模板工具 htl
,具体实现如下(HTML 节选自 index.html
):
htl.html`
<div class="container">
<h1 class="chart">Visualizing 40 Years of Music Industry Sales</h1>
<div class="intro">This project visualizes the music sales in the United States by format between 1973 and 2019. These formats range from physical supports like <span class="support support-vinyl">vinyl</span>, <span class="support support-eigth-track">8-track</span>, <span class="support support-cassette">cassette</span>, and <span class="support support-cd">cd</span>, to music consumed digitally, with <span class="support support-download">downloads</span> and <span class="support support-streaming">streaming services</span>. The category <span class="support support-other">other</span> includes less prominent sources of revenue like music videos (physical), synchronization, and royalties.</div>
<div id="donut">${svg}</div>
<div class="source">
<p>Data Source: <a class="demo" href="https://data.world/makeovermonday/2020w21-visualizing-40-years-of-music-industry-sales">MakeoverMonday 2020/W21</a></p>
<p>Inspiration for the dataviz layout: <a class="demo" href="https://www.makeovermonday.co.uk/week-21-2020/">Submission of Laura Elliot</a></p>
</div>
</div>`
注意第 5 行,这里引入了 2.4 步中导出的 svg
节点常量。
关于 CSS 的设置,也可以用类似方法插入一个 <style>
标签(具体内容直接从 base.css
中复制粘贴即可):
htl.html`<style>
/* CSS styles */
</style>`
需要注意的是,通过这种方式设置的 CSS 样式会与 Observable
平台内置的 CSS 样式发生冲突,因此需要根据平台的渲染情况进行微调,比如将基础样式中的链接元素 a
和 a:hover
、a:focus
调整为 a.demo
、a.demo:hover
、a.demo:focus
,并同步更新 HTML 模板,添加对应的类名 demo
。
这样,就大功告成了!
【图 5:D3 环形图的最终效果】