图表!除了社会学之外,我最不喜欢的科目。 但是在进入这个行业之前,你必须制作一个图表。 我不知道人们对图表有什么执念,但显然,没有一个柱状图来显示 Maggie 上个月的销售额,我们就无法建立文明,所以我们必须想尽一切办法制作图表。

为了让你为即将到来的 “OMG 我必须制作一个图表” 的存在危机做好准备,这种危机就像死亡一样,我们喜欢假装它永远不会发生,我要向你展示如何使用 D3.js 手动制作自己的散点图。 本文侧重于代码方面,你第一次看到完成的代码,可能会触发你的“战斗或逃跑”反应。 但是,如果你能读完这篇文章,我想你会对自己的 D3 理解程度感到惊讶,你会对自己能够制作其他你不想制作的图表充满信心。
不过,在我们开始之前,重要的是讨论一下为什么你想要自己动手制作图表。
构建与购买
当你确实需要制作图表时,你很可能会使用一些“开箱即用”的东西。 你绝对不会自己手动制作图表。 就好像你永远不会拿着锤子敲打自己的拇指一样,因为那很痛苦,而且还有更有效的方式使用锤子。 图表是相当复杂的 UI 元素。 这不像你只是在 div 中居中对齐一些文本。 像 Chart.js 或 Kendo UI 这样的库提供了预制图表,你可以直接用它们展示你的数据。 开发人员花费了数千小时来完善这些图表,你永远不会自己构建一个这样的图表。
或者你会吗?
图表库非常棒,但是它们确实会对你施加一定程度的限制……有时候它们甚至会让简单的操作变得更难。 正如彼得·帕克的祖父在他那过于夸张的蜘蛛侠死亡场景中所说,“伟大的图表库带来了巨大的灵活性权衡。”

当我与同事 Jasmine Greenaway 决定我们可以使用图表来找出谁是 @horse_js 时,我遇到了这种情况。 如果你还没有成为 @horse_js 的粉丝,它是一个 Twitter 模仿账号,会引用人们的断章取义。 它非常棒。
我们提取了 @horse_js 在过去两年的所有推文。 我们将它们存储在 Cosmos DB 数据库中,然后创建了一个 Azure Function 端点 来公开这些数据。
然后,我们心中充满了沉重的感觉,意识到我们需要一个图表。 我们希望能够看到数据随时间的变化情况。 我们认为,通过直观地查看数据的时间序列分析,我们可以识别出一些模式或获得关于该 Twitter 账户的一些见解。 事实上,我们做到了。
我们绘制了 @horse_js 在过去两年中发布的所有推文。 当我们在散点图上查看这些数据时,它看起来像这样
查看 CodePen 上 Burke Holland (@burkeholland) 的 wYxYNd。
巧合的是,这是我们将在本文中构建的内容。
每条推文都显示在 x
轴上的日期和 y
轴上的时间。 我以为这用图表库很容易实现,但我尝试的所有库都无法真正处理在 x
轴上使用日期,在 y
轴上使用时间的情况。 我也找不到任何人在网上发布的示例。 我是不是要开创新的领域? 我是不是数据可视化的先驱?
可能是的。 绝对是的。
所以,让我们看看如何使用 D3 来构建这个令人惊叹的散点图。
开始使用 D3
关于 D3 的一件事是:它看起来非常糟糕。 我只是想说出来,这样我们就可以停止假装 D3 代码看起来很有趣。 它不是。 说出来并不丢人。 现在我们已经邀请了这只房间里的“大象”参加茶话会,请允许我暗示,尽管 D3 代码看起来很糟糕,但实际上它并不糟糕。 只是代码量很大。
要开始,我们需要 D3。 我在这些示例中使用的是 D3 5 的 CDN 包含。 我也使用 Moment 来处理日期,我们稍后会讲到。
https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js
https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.22.2/moment.min.js
D3 与 SVG 配合使用。 这就是它所做的事情。 它基本上将 SVG 与数据结合起来,并提供一些方便的预制可视化机制,比如轴。 或者说是 Axees? Axises? 无论“轴”的复数形式是什么。 但是现在,只要知道它就像 SVG 的 jQuery 就可以了。
所以,我们需要的第一个东西是用于工作的 SVG 元素。
<svg id="chart"></svg>
好的。 现在我们可以开始使用 D3 来实现数据可视化。 我们要做的第一件事是将我们的散点图变成一个类。 我们希望这个东西尽可能通用,以便我们可以用其他数据集重复使用它。 我们将从一个构造函数开始,它接受两个参数。 第一个将是我们即将操作的元素的类或 ID(在本例中为 #chart
),第二个是一个对象,它允许我们传递任何可能在图表之间变化的参数(例如数据、宽度等)。
class ScatterPlot {
constructor(el, options) {
}
}
图表代码本身将放在一个 render
函数中,该函数还需要传递我们正在使用的数据集。
class ScatterPlot {
constructor(el, options) {
this.render(options.data);
}
render(data) {
}
}
我们在 render
方法中要做的第一件事是为我们的图表设置一些大小值和边距。
class ScatterPlot {
constructor(el, options) {
this.data = options.data || [];
this.width = options.width || 500;
this.height = options.height || 400;
this.render();
}
render() {
let margin = { top: 20, right: 20, bottom: 50, left: 60 };
let height = this.height || 400;
let width = (this.height || 400) - margin.top - margin.bottom;
let data = this.data;
}
}
我之前提到过 D3 就像 SVG 的 jQuery,我认为这个比喻很贴切。 所以你可以明白我的意思,让我们用 D3 绘制一个简单的 SVG 图形。
首先,你需要选择 SVG 将要使用的 DOM 元素。 完成后,你可以开始添加元素并设置它们的属性。 D3 与 jQuery 一样,都是基于链式调用的概念构建的,所以你调用的每个函数都会返回你调用它的元素的实例。 这样,你可以一直添加元素和属性,直到天荒地老。
例如,假设我们要绘制一个正方形。 使用 D3,我们可以绘制一个矩形(在 SVG 中,它是一个 rect
),并在绘制过程中添加必要的属性。
查看 CodePen 上 Burke Holland (@burkeholland) 的 zmdpJZ。
现在。 此时你可能会说,“但我不知道 SVG。” 好吧,我也不知道。 但是我知道如何使用 Google,而且网上有很多关于如何在 SVG 中做任何事情的文章。
那么,我们如何从一个矩形变成一个图表呢? 这就是 D3 超越 “用于绘制的 jQuery” 的地方。
首先,让我们创建一个图表。 我们在标记中从一个空的 SVG 元素开始。 我们使用 D3 选择这个空的 svg 元素(名为 #chart
),并定义它的宽度和高度以及边距。
// create the chart
this.chart = d3.select(this.el)
.attr('width', width + margin.right + margin.left)
.attr('height', height + margin.top + margin.bottom);
它看起来像这样
查看 CodePen 上 Burke Holland (@burkeholland) 的 EdpOqy。
太棒了! 什么都没有。 如果你打开开发者工具,你会看到那里确实有些东西。 它只是一个空的“东西”。 就好像我的灵魂一样。

这就是你的图表! 让我们开始往里面添加一些数据。 为此,我们需要定义我们的 x
轴和 y
轴。
在 D3 中,这很简单。 你调用 axisBottom
方法。 在这里,我还使用正确的日期格式来格式化刻度标记。
let xAxis = d3.axisBottom(x).tickFormat(d3.timeFormat('%b-%y'));
我还将一个 “x” 参数传递给 axisBottom
方法。 那是什么? 那就是所谓的比例。
D3 比例
D3 有一个叫做 **比例** 的东西。 比例只是告诉 D3 将数据放在哪里的一种方式,D3 有很多不同类型的比例。 最常见的类型是线性比例,比如从 1 到 10 的数据比例。 它还包含一个专门用于时间序列数据的比例,这正是我们这个图表所需的。 我们可以使用 scaleTime
方法来定义 x
轴的“比例”。
// define the x-axis
let minDateValue = d3.min(data, d => {
return new Date(moment(d.created_at).format('MM-DD-YYYY'));
});
let maxDateValue = d3.max(data, d => {
return new Date(moment(d.created_at).format('MM-DD-YYYY'));
});
let x = d3.scaleTime()
.domain([minDateValue, maxDateValue])
.range([0, width]);
let xAxis = d3.axisBottom(x).tickFormat(d3.timeFormat('%b-%y'));
D3 比例使用了一些术语,这些术语有点吓人。 这里有两个主要的概念需要理解:域和范围。
- **域:** 数据集中可能值的范围。 在我的例子中,我从数组中获取最小日期,以及数组中的最大日期。 数据集中的所有其他值都在这两个端点之间,所以这些 “端点” 定义了我的域。
- 范围:要显示数据集中数据的范围。换句话说,您希望数据有多分散?在本例中,我们希望将数据限制在图表宽度内,因此我们仅将
width
作为第二个参数传递。如果我们传递一个像10000
这样的值,我们的数据将超过 10,000 像素宽。如果我们根本不传递任何值,它将在图表的左侧将所有数据绘制在彼此之上……就像下面的图像一样。

y
轴的构建方式相同。只是针对它,我们将对数据进行时间格式化,而不是日期格式化。
// define y axis
let minTimeValue = new Date().setHours(0, 0, 0, 0);
let maxTimeValue = new Date().setHours(23, 59, 59, 999);
let y = d3.scaleTime()
.domain([minTimeValue, maxTimeValue])
.nice(d3.timeDay)
.range([height, 0]);
let yAxis = d3.axisLeft(y).ticks(24).tickFormat(d3.timeFormat('%H:%M'));
y
轴上的额外nice
方法调用告诉y
轴以一种漂亮的方式格式化此时间刻度。如果我们不包含它,它将不会为左侧最上面的刻度添加标签,因为它只到晚上 11:59:59,而不是一直到午夜。这只是一个怪癖,但我们这里不做垃圾。我们需要在所有刻度上添加标签。

现在,我们准备将轴绘制到图表上。请记住,我们的图表上有一些边距。为了正确地定位图表内部的项目,我们将创建一个分组 (g
) 元素并设置其宽度和高度。然后,我们可以在该容器中绘制所有元素。
let main = this.chart.append('g')
.attr('transform', `translate(${margin.left}, ${margin.top})`)
.attr('width', width)
.attr('height', height)
.attr('class', 'main');
我们正在绘制容器,考虑边距并设置其宽度和高度。是的。我知道。这很乏味。但这就是在浏览器中布局事物的状态。您上次尝试将内容在 div 中水平和垂直居中是什么时候?是的,在 Flexbox 和 CSS Grid 之前,这并不那么好。
现在,我们可以绘制x
轴
main.chart.append('g')
.attr('transform', `translate(0, ${height})`)
.attr('class', 'main axis date')
.call(xAxis);
我们创建一个容器元素,然后“调用”我们之前定义的xAxis
。D3 从左上角开始绘制事物,因此我们使用transform
属性将x
轴从顶部偏移,使其显示在底部。如果我们没有这样做,我们的图表将看起来像这样……

通过指定转换,我们将其推到底部。现在是y
轴
main.append('g')
.attr('class', 'main axis date')
.call(yAxis);
让我们看看到目前为止的所有代码,然后我们将看看它在屏幕上输出什么。
class ScatterPlot {
constructor(el, options) {
this.el = el;
if (options) {
this.data = options.data || [];
this.tooltip = options.tooltip;
this.pointClass = options.pointClass || '';
this.data = options.data || [];
this.width = options.width || 500;
this.height = options.height || 400;
this.render();
}
}
render() {
let margin = { top: 20, right: 15, bottom: 60, left: 60 };
let height = this.height || 400;
let width = (this.width || 500) - margin.right - margin.left;
let data = this.data;
// create the chart
let chart = d3.select(this.el)
.attr('width', width + margin.right + margin.left)
.attr('height', height + margin.top + margin.bottom);
// define the x-axis
let minDateValue = d3.min(data, d => {
return new Date(moment(d.created_at).format('MM-DD-YYYY'));
});
let maxDateValue = d3.max(data, d => {
return new Date(moment(d.created_at).format('MM-DD-YYYY'));
});
let x = d3.scaleTime()
.domain([minDateValue, maxDateValue])
.range([0, width]);
let xAxis = d3.axisBottom(x).tickFormat(d3.timeFormat('%b-%y'));
// define y axis
let minTimeValue = new Date().setHours(0, 0, 0, 0);
let maxTimeValue = new Date().setHours(23, 59, 59, 999);
let y = d3.scaleTime()
.domain([minTimeValue, maxTimeValue])
.nice(d3.timeDay)
.range([height, 0]);
let yAxis = d3.axisLeft(y).ticks(24).tickFormat(d3.timeFormat('%H:%M'));
// define our content area
let main = chart.append('g')
.attr('transform', `translate(${margin.left}, ${margin.top})`)
.attr('width', width)
.attr('height', height)
.attr('class', 'main');
// draw x axis
main.append('g')
.attr('transform', `translate(0, ${height})`)
.attr('class', 'main axis date')
.call(xAxis);
// draw y axis
main.append('g')
.attr('class', 'main axis date')
.call(yAxis);
}
}
查看 Pen oaeybM by Burke Holland (@burkeholland) on CodePen.
我们得到一个图表!给你的朋友打电话!给你的父母打电话!没有什么不可能的!
轴标签
现在让我们添加一些图表标签。到目前为止,您可能已经发现,在涉及 D3 时,您几乎都在手动完成所有操作。添加轴标签也不例外。我们要做的就是添加一个 svg text 元素,设置其值并定位它。仅此而已。
对于x
轴,我们可以使用translate
添加文本标签并定位它。我们将它的x
位置设置为图表的中间(宽度 / 2)。然后我们减去左侧边距,以确保我们恰好位于图表下方居中。我还使用axis-label
的 CSS 类,它具有text-anchor: middle
,以确保我们的文本来自文本元素的中心。
// text label for the x axis
chart.append("text")
.attr("transform",
"translate(" + ((width/2) + margin.left) + " ," +
(height + margin.top + margin.bottom) + ")")
.attr('class', 'axis-label')
.text("Date Of Tweet");
y
轴的概念相同 - 一个我们手动定位的 text 元素。这个是使用绝对x
和y
属性定位的。这是因为我们的 transform 用于旋转标签,因此我们使用x
和y
属性来定位它。
请记住:旋转元素后,x 和 y 会随其旋转。这意味着,当 text 元素像这里一样侧向时,y 现在将其向左和向右推动,而 x 将其向上和向下推动。困惑了吗?没关系,您有许多志同道合的人。
// text label for the y-axis
chart.append("text")
.attr("transform", "rotate(-90)")
.attr("y", 10)
.attr("x",0 - ((height / 2) + (margin.top + margin.bottom))
.attr('class', 'axis-label')
.text("Time of Tweet - CST (-6)");
查看 Pen oaeybM by Burke Holland (@burkeholland) on CodePen.
现在,就像我说的 - 代码非常多。这是不可否认的。但它并不复杂。它就像乐高:乐高积木很简单,但你可以用它们建造非常复杂的东西。我的意思是,这是一个高度复杂、相互关联的砖块系统。

现在我们已经有了图表,是时候绘制数据了。
绘制数据点
这相当简单。像往常一样,我们创建一个分组来放置所有圆圈。然后我们循环遍历数据集中的每个项目并绘制一个 SVG 圆圈。我们必须根据当前数据项的日期和时间值来设置每个圆圈的位置 (cx
和 cy
)。最后,我们设置它的半径 (r
),它控制圆圈的大小。
let circles = main.append('g');
data.forEach(item => {
circles.append('svg:circle')
.attr('class', this.pointClass)
.attr('cx', d => {
return x(new Date(item.created_at));
})
.attr('cy', d => {
let today = new Date();
let time = new Date(item.created_at);
return y(today.setHours(time.getHours(), time.getMinutes(), time.getSeconds(), time.getMilliseconds()));
})
.attr('r', 5);
});
当我们设置cx
和cy
值时,我们使用之前定义的刻度 (x
或 y
)。我们将此刻度传递给当前数据项的日期或时间值,刻度将返回该项在图表上的正确位置。
而且,我的好朋友,我们得到一个带有真实数据的真实图表。
查看 Pen VEzdrR by Burke Holland (@burkeholland) on CodePen.
最后,让我们在这个图表中添加一些动画。D3 有一些很好的缓动函数,我们可以在此处使用。我们要做的是在每个圆圈上定义一个过渡。基本上,在transition
方法之后的所有内容都将进行动画处理。由于 D3 从左上角开始绘制所有内容,因此我们可以先设置x
位置,然后对y
进行动画处理。结果是这些点看起来像是落到位的。我们可以使用 D3 的巧妙easeBounce
缓动函数,使这些点在落下时反弹。
data.forEach(item => {
circles.append('svg:circle')
.attr('class', this.pointClass)
.attr('cx', d => {
return x(new Date(item.created_at));
})
.transition()
.duration(Math.floor(Math.random() * (3000-2000) + 1000))
.ease(d3.easeBounce)
.attr('cy', d => {
let today = new Date();
let time = new Date(item.created_at);
return y(today.setHours(time.getHours(), time.getMinutes(), time.getSeconds(), time.getMilliseconds()));
})
.attr('r', 5);
好的,再一遍,现在大家都一起……
class ScatterPlot {
constructor(el, options) {
this.el = el;
this.data = options.data || [];
this.width = options.width || 960;
this.height = options.height || 500;
this.render();
}
render() {
let margin = { top: 20, right: 20, bottom: 50, left: 60 };
let height = this.height - margin.bottom - margin.top;
let width = this.width - margin.right - margin.left;
let data = this.data;
// create the chart
let chart = d3.select(this.el)
.attr('width', width + margin.right + margin.left)
.attr('height', height + margin.top + margin.bottom);
// define the x-axis
let minDateValue = d3.min(data, d => {
return new Date(moment(d.created_at).format('MM-DD-YYYY'));
});
let maxDateValue = d3.max(data, d => {
return new Date(moment(d.created_at).format('MM-DD-YYYY'));
});
let x = d3.scaleTime()
.domain([minDateValue, maxDateValue])
.range([0, width]);
let xAxis = d3.axisBottom(x).tickFormat(d3.timeFormat('%b-%y'));
// define y axis
let minTimeValue = new Date().setHours(0, 0, 0, 0);
let maxTimeValue = new Date().setHours(23, 59, 59, 999);
let y = d3.scaleTime()
.domain([minTimeValue, maxTimeValue])
.nice(d3.timeDay)
.range([height, 0]);
let yAxis = d3.axisLeft(y).ticks(24).tickFormat(d3.timeFormat('%H:%M'));
// define our content area
let main = chart.append('g')
.attr('transform', `translate(${margin.left}, ${margin.top})`)
.attr('width', width)
.attr('height', height)
.attr('class', 'main');
// draw x axis
main.append('g')
.attr('transform', `translate(0, ${height})`)
.attr('class', 'main axis date')
.call(xAxis);
// draw y axis
main.append('g')
.attr('class', 'main axis date')
.call(yAxis);
// text label for the y axis
chart.append("text")
.attr("transform", "rotate(-90)")
.attr("y", 10)
.attr("x",0 - ((height / 2) + margin.top + margin.bottom)
.attr('class', 'axis-label')
.text("Time of Tweet - CST (-6)");
// draw the data points
let circles = main.append('g');
data.forEach(item => {
circles.append('svg:circle')
.attr('class', this.pointClass)
.attr('cx', d => {
return x(new Date(item.created_at));
})
.transition()
.duration(Math.floor(Math.random() * (3000-2000) + 1000))
.ease(d3.easeBounce)
.attr('cy', d => {
let today = new Date();
let time = new Date(item.created_at);
return y(today.setHours(time.getHours(), time.getMinutes(), time.getSeconds(), time.getMilliseconds()));
})
.attr('r', 5);
});
}
}
我们现在可以调用一些数据并渲染此图表……
// get the data
let data = fetch('https://s3-us-west-2.amazonaws.com/s.cdpn.io/4548/time-series.json').then(d => d.json()).then(data => {
// massage the data a bit to get it in the right format
let horseData = data.map(item => {
return item.horse;
})
// create the chart
let chart = new ScatterPlot('#chart', {
data: horseData,
width: 960
});
});
这里就是整个内容,包括调用 Azure 函数从 Cosmos DB 返回数据。这是一个海量数据,因此请耐心等待,因为我们将占用您所有带宽。
查看 Pen GYvGep by Burke Holland (@burkeholland) on CodePen.
如果您走到这一步,我……好吧,我印象深刻。D3 并不是一件容易上手的东西。它看起来根本不像很有趣。但是,这里没有砸到任何拇指,我们现在完全控制了这个图表。我们可以随心所欲地处理它。
查看一些 D3 的其他资源,祝您的图表一切顺利。您可以做到!或者您做不到。无论哪种方式,都必须有人制作一个图表,它很可能就是您。
您的笔在 Firefox 中似乎存在轻微的显示问题。Firefox 要求严格遵守 JS 中日期的定义,即 ISO 格式。因此,当您使用美国标准定义 x 轴边界时,它将不会转换为日期。一个更全面的解决方案可能是将其更改为 ISO 日期格式,
YYYY-DD-MM
,以确保与所有现代浏览器兼容。我还将created_at
日期转换为 ISO 格式,因为 Moment.js 已将非 ISO 格式的转换列为已弃用。结果如下所示[codepen_embed height=”265″ theme_id=”0″ slug_hash=”VEVWwR” default_tab=”js,result” user=”Jacrys”]查看 Pen VEVWwR by Keith Lewis (@Jacrys) on CodePen.[/codepen_embed]