zzxworld

使用 D3.js(v7) 绘制日历热力图

作为程序员,一定知道 Github 这个源码托管网站。在 Github 的个人信息页面,会提供一个类似日历,由一大堆小方块组成的热力图。通过它可以很直观的展现出在过去一段时间里代码的提交状况。我想用 D3.js 来实现一个类似的图表。

开始之前说明一下,本文不再涉及 D3.js 的基础用法和解释,需要这方面信息的请通过以下几篇入门级文章来了解:

理论分析

下面是我的 Github 热力图:

Github 热力图 我的 Github 热力图

一眼就能看出来,已经是荒废状态。看这个图的组成,主要有三部分:

  1. 月份的横坐标。
  2. 周的纵坐标。
  3. 日期方块。

月份和周的这两个坐标轴很简单,只要准备好坐标数据,使用 D3 提供的 Scale 功能就能解决。所以重点是日期方块的数据生成和绘制。

日期方块也是有规律的,比如从上到下的数量是 7,代表了周一到周日。所以画格子的时候要注意两点:

  1. 画的方向是从上往下,满 7 个格子后横向增加一个格子的距离,然后再次从上往下开始。
  2. 第一个格子的日期是星期几,要确保画在正确的位置。这样后面的格子只需要顺序摆放就行了。

生成数据

日期是这个图表的核心数据,所以先来解决数据生成的问题。考虑到图表的用途,我定义了一个简单的数据结构:

[
    {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
    })

日期方块的绘制时主要有两个关键点:

  1. 从上到下画满 7 个方块后转到新的列,这个通过引入 cellCol 变量来解决。
  2. 为了美观性,方块之前需要有适量的间距,要注意在方块的坐标计算时加入间距变量。

格子的高亮显示只要在 fill 属性中通过迭代闭包函数的传参判断 total 值,然后返回对应的颜色。来看看最终的效果:

日热力图

最后对比 Github 上的热力图,貌似缺少了鼠标移入时的数据提示功能。这里提供一个「缩水版」:

// 日期方块添加鼠标移入时的数据提示
cell.append('title').text(v => {
    let message = '没有内容'
    if (v.total) {
        message = "有 "+v.total+" 篇内容"
    }

    return v.date + "\n" + message
})