开始

最近我在写自己的网站,需要日历热度图来丰富点内容;所以在网上找了许多参考,如下:

将两个结合就是我想要的。

现在是这样:

image

代码

vue3组件

从组件开始。

首先是js。

这里使用defineProps定义了属性的类型和默认值;
原先我想在js中设置默认值,但是发现Booleanprop即使没有设置也有默认值false,为了代码统一就全在defineProps中设置了。

再下面的函数renderHeatmap()用于初始化变量,将props的值设置给变量。
这一段赋值代码让我想起了jquery,同样在函数上设置属性,使得它可以调方法,也可以自调用。

import calendarHeatmap from './calendarHeatmap.js'
import { onMounted, watch } from 'vue'

const props = defineProps({
  entries: {
    type: Array,
    default: () => []
  },
  colorRange: {
    type: Array,
    default: () => ['#D8E6E7', '#218380']
  },
  tooltipEnabled: {
    type: Boolean,
    default: true
  },
  locale: {
    type: [Object, String],
    default: () => 'en'
  },
  max: {
    type: Number,
    default: null
  },
  onClick: {
    type: Function,
    default: () => { }
  },
  selector: {
    type: String,
    default: 'heatmap'
  },
  width: {
    type: Number,
    default: 1000
  },
  height: {
    type: Number,
    default: 180
  },
  cellMargin: {
    type: Number,
    default: 2
  },
  cellRadius: {
    type: Number,
    default: 0
  }
})

let entries = props.entries
onMounted(renderHeatMap)
watch(() => entries, renderHeatMap)

function renderHeatMap() {
  calendarHeatmap.init()
    .dataset(props.entries)
    .selector(`.${props.selector}`)
    .colorRange(props.colorRange)
    .tooltipEnabled(props.tooltipEnabled)
    .locale(props.locale)
    .width(props.width)
    .height(props.height)
    .cellMargin(props.cellMargin)
    .cellRadius(props.cellRadius)
    .max(props.max)
    .onClick(props.onClick)
    ()
}

然后是组件模板。
提供热力图和悬浮提示盒子即可,后续使用d3添加子元素和内容。

<template>
  <div class="heatmap" :class="selector"></div>
  <div id="heatmap-tooltip"></div>
</template>

组件的最后css。
我十分喜欢scss&


<style lang="scss">
.calendar-heatmap {
  margin: 10px;
  background-color: #e7ece1;
  box-shadow: 0 4px 4px rgba(0, 0, 0, 0.25);
  border-radius: 3px;
}

.heatmap-month-text {
  fill: #094e27;
  font-size: 14px;
  font-family: Helvetica, arial, 'Open Sans', sans-serif;
  font-weight: bold;
}

.heatmap-week-text {
  font-size: 12px;
  font-family: Helvetica, arial, 'Open Sans', sans-serif;
}

.heatmap-day-cell {

  stroke: #b2bbb5;
  stroke-width: 1px;

  &:hover {
    stroke: #1f7243;
    stroke-width: 1px;
  }
}

#heatmap-tooltip {
  visibility: hidden;
  position: absolute;
  z-index: 900;
  text-align: center;
  border: 1px solid #cccccc;
  background-color: #ffffff;
  width: 150px;
  padding: 0.833em 1em;
  font-weight: normal;
  font-style: normal;
  border-radius: 0.2857rem;
  box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.1);
  transition: all 0.3s ease;


  &::before {
    content: "";
    bottom: -0.325em;
    left: 50%;
    margin-left: -0.325em;
    position: absolute;
    width: 0.75em;
    height: 0.75em;
    background: #ffffff;
    transform: rotate(45deg);
    z-index: 2;
    box-shadow: 1px 1px 0px 0px #b3b3b3;

    filter: drop-shadow(0px 2px 4px rgba(0, 0, 0, 0.1));
  }
}


.heatmap-tooltip-date {
  color: #094e27;
}

.heatmap-tooltip-total {
  font-weight: bold;
}
</style>

js使用D3

先将全部代码贴出,然后我们再分析里面的片段。

import * as d3 from 'd3'

function generateDataset(data, forward = 12) {
  const months = [], days = []

  for (let i = forward; i > 0; i--) {
    let referDate = new Date()

    referDate.setMonth(referDate.getMonth() - i + 2)

    if (i !== 1) referDate.setDate(0)
    else referDate.setMonth(referDate.getMonth() - 1)

    let month = referDate.getMonth() + 1
    months.push(month)
    month = month < 10 ? '0' + month : month

    for (let d = 1; d <= referDate.getDate(); d++) {
      let day = d < 10 ? '0' + d : d
      const date = referDate.getFullYear() + '-' + month + '-' + day
      const total = data.find(v => v.created_at === date)?.counting || 0
      days.push({ date, total })
    }

  }

  let firstDate = days[0].date
  let d = new Date(firstDate)
  let day = d.getDay() == 0 ? 7 : d.getDay()

  for (let i = 1; i < day; i++) {
    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]
    const date = v.join('-')
    const total = data.find(v => v.created_at === date)?.counting || 0
    days.unshift({ date, total })
  }

  return { days, months }
}


export default {

  init() {

    let width, height, margin = 30
    let weekBoxWidth = 20, monthBoxHeight = 20
    let dataset = []
    let max, selector, colorRange, tooltipEnabled
    let onClick = null
    let cellMargin, cellRadius
    let locales = {
      'en': {
        months: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
        days: ['Sun', 'Tue', 'Thu', 'Sat'],
      },
      'zh-CN': {
        months: ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月'],
        days: ['一', '三', '五', '日'],
      }

    }
    let locale = locales.en

    chart.width = function (value) { return arguments.length ? (width = value, chart) : width }
    chart.height = function (value) { return arguments.length ? (height = value, chart) : height }
    chart.margin = function (value) { return arguments.length ? (margin = value, chart) : margin }
    chart.cellMargin = function (value) { return arguments.length ? (cellMargin = value, chart) : cellMargin }
    chart.cellRadius = function (value) { return arguments.length ? (cellRadius = value, chart) : cellRadius }
    chart.max = function (value) { return arguments.length ? (max = value, chart) : max }
    chart.colorRange = function (value) { return arguments.length ? (colorRange = value, chart) : colorRange }
    chart.dataset = function (value) { return arguments.length ? (dataset = generateDataset(value), chart) : dataset }
    chart.tooltipEnabled = function (value) { return arguments.length ? (tooltipEnabled = value, chart) : tooltipEnabled }
    chart.selector = function (value) { return arguments.length ? (selector = value, chart) : selector }
    chart.onClick = function (value) { return arguments.length ? (onClick = value, chart) : onClick }
    chart.locale = function (value) {
      if (arguments.length) {
        if (value === 'en' || value === 'zh-CN') locale = locales[value]
        else locale = value
        return chart
      }
      return locale
    }

    function chart() {
      const svg = d3.select(selector)
        .style('position', 'relative')
        .append('svg')
        .attr('width', width)
        .attr('height', height)
        .attr('class', 'calendar-heatmap')

      if (max === null) max = d3.max(dataset.days, v => v.total)
      max = max === 0 ? 5 : max

      let color = d3.scaleLinear()
        .range(colorRange)
        .domain([0, max])

      drawMonth()
      drawWeek()
      drawDay()

      function drawMonth() {
        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])

        monthBox.selectAll('text').data(dataset.months).enter()
          .append('text')
          .text(v => { return locale.months[v - 1] })
          .attr('class', 'heatmap-month-text')
          .attr('x', (v, i) => monthScale(i))
      }

      function drawWeek() {
        const weekBox = svg.append('g')
          .attr('class', 'heatmap-week-box')
          .attr(
            'transform',
            'translate(' + (margin - 10) + ', ' + (margin + monthBoxHeight) + ')'
          )
        const weekScale = d3.scaleLinear()
          .domain([0, locale.days.length])
          .range([0, height - margin - monthBoxHeight + 14])

        weekBox.selectAll('text').data(locale.days).enter()
          .append('text')
          .text(v => { return v })
          .attr('class', 'heatmap-week-text')
          .attr('y', (v, i) => weekScale(i))
      }

      function drawDay() {
        const cellBox = svg.append('g')
          .attr('class', 'heatmap-day-box')
          .attr(
            'transform',
            'translate(' + (margin + weekBoxWidth) + ', ' + (margin + 10) + ')'
          )

        const cellSize = (height - margin - monthBoxHeight - cellMargin * 6 - 10) / 7
        let cellCol = 0
        let cell = cellBox.selectAll('rect')
          .data(dataset.days).enter()
          .append('rect')
          .attr('class', 'heatmap-day-cell')
          .attr('width', cellSize)
          .attr('height', cellSize)
          .attr('rx', cellRadius)
          .attr('fill', v => color(v.total))
          .attr('x', (v, i) => {
            if (i % 7 == 0) cellCol++
            let x = (cellCol - 1) * cellSize
            return cellCol > 1 ? x + cellMargin * (cellCol - 1) : x
          })
          .attr('y', (v, i) => {
            let y = i % 7
            return y > 0 ? y * cellSize + cellMargin * y : y * cellSize
          })

        let tooltip = document.getElementById('heatmap-tooltip')
        registerEvent()

        function registerEvent() {
          cell.on('click', onclick)
          if (!tooltipEnabled) return
          cell.on('mouseover', function (d, d1) {
            tooltip.innerHTML = tooltipHTMLForDate(d1)
            const rect = this.getBoundingClientRect()
            tooltip.style.visibility = 'visible'
            tooltip.style.opacity = 1
            tooltip.style.left = rect.x - 60 - cellSize / 2 + 'px'
            tooltip.style.top = d.pageY - cellSize - 55 + 'px'
          })
          cell.on('mouseout', function () {
            tooltip.style.visibility = 'hidden'
            tooltip.style.opacity = 0
          })
        }
      }
    }

    return chart

    function tooltipHTMLForDate(d) {
      return `
        <div class="heatmap-tooltip-date">${d.date}</div>
        <div class="heatmap-tooltip-total">${d.total}</div>
      `
    }
  }

}

函数generateDateset

此函数将传入组件的entries转换成实际使用的数据,分为月份数据和每天的数据。

此函数可以看参考的第一条,具体思路与它一致。

init方法

此函数头部是需要用到的变量,包括svg宽高、日期格子外边距、弧度、点击事件等。

同时,提供了本地化的数据,locales是预设值,实际使用的是locale

再下面的是gettersetter,使用三目和逗号压缩成了一行(和写ahk一样x-x)。

chart方法

在此方法中绘制日期和周数,以及日期格子。

这里都是D3的使用,总共就三个方法:

  • drawMonth()
  • drawWeek()
  • drawDay()

同时,在drawDay()中注册了点击和悬浮提示的事件。

最后

后续还要适配暗色模式和年份切换,但是现在勉强用着吧。

posted on 2024-07-17 23:50  落寞的雪  阅读(83)  评论(0编辑  收藏  举报