作为程序员,一定知道 Github 这个源码托管网站。在 Github 的个人信息页面,会提供一个类似日历,由一大堆小方块组成的热力图。通过它可以很直观的展现出在过去一段时间里代码的提交状况。我想用 D3.js 来实现一个类似的图表。
开始之前说明一下,本文不再涉及 D3.js 的基础用法和解释,需要这方面信息的请通过以下几篇入门级文章来了解:
理论分析
下面是我的 Github 热力图:
我的 Github 热力图
一眼就能看出来,已经是荒废状态。看这个图的组成,主要有三部分:
- 月份的横坐标。
- 周的纵坐标。
- 日期方块。
月份和周的这两个坐标轴很简单,只要准备好坐标数据,使用 D3 提供的 Scale 功能就能解决。所以重点是日期方块的数据生成和绘制。
日期方块也是有规律的,比如从上到下的数量是 7,代表了周一到周日。所以画格子的时候要注意两点:
- 画的方向是从上往下,满 7 个格子后横向增加一个格子的距离,然后再次从上往下开始。
- 第一个格子的日期是星期几,要确保画在正确的位置。这样后面的格子只需要顺序摆放就行了。
生成数据
日期是这个图表的核心数据,所以先来解决数据生成的问题。考虑到图表的用途,我定义了一个简单的数据结构:
[
{date: '2022-07-04', total: 0},
]
更简单的方式是使用字典,并把日期作为 Key。我刚开始就是这么想的,不过实际用时发现数据操作上有些不方便,所以换成了上面的数组方式。
完成后的数据生成代码如下:
const generateDataset = (forwardMonth, options={}) => {
const config = Object.assign({}, {
endDate: null,
fill: {},
}, options)
const months = []
const days = []
// 计算需要填充的日期
for (let i=forwardMonth; i>0; i--) {
let referDate = config.endDate
? new Date(config.endDate)
: new Date()
referDate.setMonth(referDate.getMonth()-i+2)
referDate.setDate(0)
let month = referDate.getMonth()+1
month = month < 10 ? '0'+month : month
for (let d=1; d<=referDate.getDate(); d++) {
let day = d < 10 ? '0'+d : d
let data = {
date: referDate.getFullYear()+'-'+month+'-'+day,
}
if (config.fill.hasOwnProperty(data.date)) {
data.total = config.fill[data.date]
}
days.push(data)
}
months.push(referDate.getFullYear()+'-'+month)
}
// 确保第一个日期是从星期一开始
// 不是星期一就向前追加相应的天数
let firstDate = days[0].date
let d = new Date(firstDate)
let day = d.getDay()
if (day == 0) {
day = 7
}
for (let i=1; i<day; i++) {
let d = new Date(firstDate)
d.setDate(d.getDate()-i)
let v = [d.getFullYear(), d.getMonth()+1, d.getDate()]
if (v[1] < 10) {
v[1] = '0'+v[1];
}
if (v[2] < 10) {
v[2] = '0'+v[2];
}
days.unshift({date: v.join('-')})
}
return {days: days, months: months}
}
这是个函数,通过以下方式调用:
const dataset = generateDataset(6)
函数的第一个参数是要前推的月份,默认是以当前时间往前推。如果要指定日期,可以在第二个字典参数中设置。
const dataset = generateDataset(6, {endDate: '2021-12-01'})
另外在第二个参数中,还可以自定义任意日期下的 total
值,这是实现格子高亮时必须的数据。
const dataset = generateDataset(6, {
fill: {
'2022-01-03': 1,
'2022-01-04': 2,
'2022-02-07': 1,
}
})
考虑到月份和日期是关联的,这个函数最后会返回一个字典值,里面包含有日期和月份两项数据。
绘制月份坐标
数据准备妥当,接下来正式开始画图流程。先来看看完成月份坐标绘制的代码:
// 定义数据集
var dataset = generateDataset(12, {
// 定义要高亮显示的日期数据
fill: {
'2022-01-03': 1,
'2022-01-04': 2,
'2022-02-07': 1,
'2022-03-12': 1,
'2022-03-13': 1,
'2022-04-23': 2,
'2022-04-24': 1,
'2022-04-25': 1,
'2022-04-26': 1,
'2022-04-27': 3,
'2022-04-28': 1,
'2022-04-29': 1,
'2022-04-30': 5,
}
})
// 设置图表参数
const width = 1000
const height = 180
const margin = 30
const weekBoxWidth = 20
const monthBoxHeight = 20
const svg = d3.select('svg')
.attr('width', width)
.attr('height', height)
// 绘制月份坐标
const monthBox = svg.append('g').attr(
'transform',
'translate('+(margin+weekBoxWidth)+', '+margin+')')
const monthScale = d3.scaleLinear()
.domain([0, dataset.months.length])
.range([0, width-margin-weekBoxWidth+10])
monthBox.selectAll('text').data(dataset.months).enter()
.append('text')
.text(v => { return v})
.attr('font-size', '0.9em')
.attr('font-family', 'monospace')
.attr('fill', '#999')
.attr('x', (v, i) => {
return monthScale(i)
})
如果看了开头三篇 D3 入门级的图表绘制文章,这里的代码一看就能明白。代码相关位置写了注释,这里就不重复了。来看看代码执行后的效果:
绘制周坐标
月份绘制时已经完成了图表的初始化工作,所以周坐标的代码就看起来没那么多了。
// 设置周坐标数据
const weeks = ['一', '三', '五', '日']
// 绘制周坐标
const weekBox = svg.append('g').attr(
'transform',
'translate('+(margin-10)+', '+(margin+monthBoxHeight)+')')
const weekScale = d3.scaleLinear()
.domain([0, weeks.length])
.range([0, height-margin-monthBoxHeight+14])
weekBox.selectAll('text').data(weeks).enter()
.append('text')
.text(v => { return v})
.attr('font-size', '0.85em')
.attr('fill', '#CCC')
.attr('y', (v, i) => {
return weekScale(i)
})
代码中的 -10
,+14
是显示位置上的调整,没有特别的含义。看看加上周坐标后的显示效果:
绘制日期方块
这是最后的关键部分,先看代码,后面再解释一些关键点:
// 绘制日期方块
const cellBox = svg.append('g').attr(
'transform',
'translate('+(margin+weekBoxWidth)+', '+(margin+10)+')')
// 设置方块间距
const cellMargin = 3
// 计算方块大小
const cellSize = (height-margin-monthBoxHeight-cellMargin*6-10)/7
// 方块列计数器
var cellCol = 0
var cell = cellBox.selectAll('rect').data(dataset.days).enter()
.append('rect')
.attr('width', cellSize)
.attr('height', cellSize)
.attr('rx', 3)
.attr('fill', v => {
if (v.total == undefined) {
return '#EFEFEF'
}
if (v.total > 1) {
return '#F96'
}
return '#FC9'
})
.attr('x', (v, i) => {
if (i%7 == 0) {
cellCol++
}
var x = (cellCol-1)*cellSize
return cellCol > 1 ? x+cellMargin*(cellCol-1) : x
})
.attr('y', (v, i) => {
var y = i%7
return y>0 ? y*cellSize+cellMargin*y : y*cellSize
})
日期方块的绘制时主要有两个关键点:
- 从上到下画满 7 个方块后转到新的列,这个通过引入
cellCol
变量来解决。 - 为了美观性,方块之前需要有适量的间距,要注意在方块的坐标计算时加入间距变量。
格子的高亮显示只要在 fill
属性中通过迭代闭包函数的传参判断 total
值,然后返回对应的颜色。来看看最终的效果:
最后对比 Github 上的热力图,貌似缺少了鼠标移入时的数据提示功能。这里提供一个「缩水版」:
// 日期方块添加鼠标移入时的数据提示
cell.append('title').text(v => {
let message = '没有内容'
if (v.total) {
message = "有 "+v.total+" 篇内容"
}
return v.date + "\n" + message
})