学习D3.js(二十二)日历热力图

746 阅读3分钟

我正在参加「掘金·启航计划」

引入D3模块

  • 引入整个D3模块。
<!-- D3模块 -->
<script src="https://d3js.org/d3.v7.min.js"></script>

数据

  • 在热力图中展示的数据。
    const dataArr = {
      '2022-01-15': 3,
      '2022-05-16': 2,
      '2022-05-17': 1,
      '2022-05-18': 3,
      '2022-05-19': 6,
      '2022-05-20': 5,
      '2022-07-21': 1,
      '2022-05-22': 2,
      '2022-05-23': 1,
      '2022-03-24': 3,
      '2022-05-25': 2,
      '2022-05-26': 5,
      '2022-05-27': 4,
      '2022-05-28': 6,
      '2022-05-29': 2,
      '2022-05-30': 1,
      '2022-06-31': 3
    }

添加画布

// 画布
const width = 1200
const height = 240
const margin = 40
const svg = d3
  .select('.d3Chart')
  .append('svg')
  .attr('width', width)
  .attr('height', height)
  .style('background-color', '#1a3055')
// 图
const chart = svg.append('g').attr('transform', `translate(${margin}, ${margin})`)

比例尺和配置信息

const scaleColor = d3
  .scaleLinear()
  .domain([0, d3.max(Object.values(dataArr))])
  .range(['#FC9', '#F96'])
  1. d3.max() 获取数组中的最大值。
  • 创建颜色线性比例尺。
/**
 * 数据组装
 * */
function generateDataset(options = { fill: {} }) {
  // 开始时间
  const startDate = options.startDate
    ? new Date(options.startDate)
    : new Date(new Date().getFullYear() + '-' + '01' + '-' + '01')
  // 结束时间
  const endDate = options.endDate ? new Date(options.endDate) : new Date()

  // 相隔天数
  const totalDays = Math.floor((endDate.getTime() - startDate.getTime()) / 86400000)

  // 循环天数
  let year, month
  let yearIndex = -1,
    monthIndex = -1
  let yearGroup = []
  let dayTem = 0
  while (dayTem <= totalDays) {
    const dateName = d3.timeFormat('%Y-%m-%d')(new Date(startDate.getTime() + 86400000 * dayTem))
    const dateArr = dateName.split('-')
    // 年
    if (!year || dateArr[0] !== year) {
      year = dateArr[0]
      yearGroup.push({
        name: dateArr[0],
        monthGroup: []
      })

      yearIndex++
      monthIndex = -1
    }
    // 月
    if (!month || dateArr[1] !== month) {
      month = dateArr[1]
      yearGroup[yearIndex].monthGroup.push({
        name: dateArr[0] + '-' + dateArr[1],
        dayGroup: []
      })
      monthIndex++
    }
    // 获取热力数据值 
    let total = null
    if (options.fill.hasOwnProperty(dateName)) {
      total = options.fill[dateName]
    }
    // 天
    yearGroup[yearIndex].monthGroup[monthIndex].dayGroup.push({
      name: dateName,
      dayTem: dayTem + startDate.getDay(),
      total
    })

    dayTem++
  }

  return yearGroup
}

// startDate:日历开始时间 endDate:日历结束时间 dataArr:要展示的数据 - 之前定义好的格式
const dayDatas = 
    generateDataset({ startDate: '2021-11-01', endDate: '2022-9-30', fill: dataArr })

image.png

  1. d3.timeFormat('%Y-%m-%d') 转换时间格式。
  • 根据传入参数得到,开始时间、结束时间。
  • 计算出间隔天数,循环所有天数,归类组装为数组格式数据。

绘制日历块

const yearSvg = chart
  .selectAll()
  .data(dayDatas)
  .enter()
  .append('g')
  .attr('class', (d) => 'year year-' + d.name)
  • 绑定年数据。
const monthSvg = yearSvg
  .selectAll()
  .data((d) => d.monthGroup)
  .enter()
  .append('g')
  .attr('class', (d) => 'month month-' + d.name)
  • 绑定年数据下的,月数据。
// 绘制方块
const daySvg = monthSvg
  .selectAll()
  .data((d) => d.dayGroup)
  .enter()
  .append('rect')
  .attr('width', 20)
  .attr('height', 20)
  .attr('rx', 3)
  .attr('fill', (d) => {
    if (!d.total) {
      return '#EFEFEF'
    }
    return scaleColor(d.total)
  })
  .attr('x', (d) => Math.floor(d.dayTem / 7) * 21)
  .attr('y', (d) => (d.dayTem % 7) * 21)
  • 绑定天数据,创建方块元素,计算位置按日历图布局。

image.png

绘制月和周

const title = chart.append('g')

// 绘制 周
const weeks = ['日', '一', '二', '三', '四', '五', '六']
title
  .append('g')
  .attr('class', 'week')
  .selectAll('.label')
  .data(weeks)
  .enter()
  .append('text')
  .attr('class', 'label')
  .attr('x', -25)
  .attr('y', 20 / 2)
  .attr('dy', (d, i) => i * 21 + 5)
  .attr('fill', '#EFEFEF')
  .text((d) => d)
  • 根据日历图布局,绘制周文本。
let monthAll = []
dayDatas.forEach((element) => {
  monthAll = monthAll.concat(element.monthGroup)
})

title
  .append('g')
  .attr('class', 'month-title')
  .selectAll()
  .data(monthAll)
  .enter()
  .append('text')
  .attr('x', (d, i) => {
    return i * 21 * 4.25 + 40
  })
  .attr('y', -10)
  .attr('fill', '#EFEFEF')
  .attr('font-size', '1em')
  .attr('font-family', 'monospace')
  .text((d) => d.name)

image.png

  • 获取月数据,在日历图上绘制。
  • i * 21 * 4.25 + 40 每个月文本的间隔。这里只是简单计算,不是每个月文本都和起始块对齐。

交互

var tooltips = d3
  .select('body')
  .append('div')
  .style('width', 'auto')
  .style('height', '40px')
  .style('background-color', '#fff')
  .style('dispaly', 'flex')
  .style('justify-content', 'center')
  .style('padding', '10px')
  .style('border-radius', '5px')
  .style('opacity', 0)

daySvg
  .on('mouseenter', (e, d) => {
    let message = '--'
    if (d.total) {
      message = '有 ' + d.total + ' 篇内容'
    }
    tooltips
      .html(d.name + '<br />' + message)
      .style('position', 'absolute')
      .style('left', `${e.clientX - 60}px`)
      .style('top', `${e.clientY - 60}px`)
      .style('opacity', 1)

    d3.select(d.target).attr('fill', 'red')
  })
  .on('mouseleave', (e, d) => {
    tooltips.style('opacity', 0).style('left', `0px`).style('top', `0px`)
    d3.select(d.target).attr('fill', '#EFEFEF')
  })

1.gif

  • 创建提示框。
  • 获取到所有方块元素,绑定鼠标事件。
  • 根据事件修改提示框数据和回复状态。

总结

这里只是简单的介绍了如何绘制日历热力图。还可以在此基础上,进行功能和交互的添加。如对每个月的块边界,进行颜色绘制或在创建块的时候,添加动画一个个的出现。