首页 > 其他分享 >[VUE3] 使用D3实现日历热力图

[VUE3] 使用D3实现日历热力图

时间:2024-07-17 23:51:09浏览次数:21  
标签:function return attr 日历 chart value let VUE3 D3

开始

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

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

现在是这样:

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()中注册了点击和悬浮提示的事件。

最后

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

标签:function,return,attr,日历,chart,value,let,VUE3,D3
From: https://www.cnblogs.com/refiz/p/18308538

相关文章

  • vue3 封装svg图标
    安装插件npmivite-plugin-svg-icons1.修改 vite.config.jsimport{resolve}from'path'import{createSvgIconsPlugin}from'vite-plugin-svg-icons';exportdefaultdefineConfig({  plugins:[    vue(),    createSvgIconsPlugin({......
  • Vue3 - 微信公众号H5网站使用微信扫一扫(微信扫码),苹果报错 {“errMsg“:“scanQRCode
    前言关于此问题网上的教程都无法解决,如果您的报错信息与我相似,即可解决。在vue3|nuxt3微信公众号网页开发中,微信移动端h5网页使用JS-SDK中的“微信扫码(微信扫一扫)”wx.scanQRCode接口,苹果ios系统真机测试时出现报错:“errMsg”:“scanQRCode:thepermissi......
  • vue3,生产环境,禁止调试输出(重写console的方式)
    如果你想在生产环境中直接重写console的方法以禁止其输出,你可以在你的主入口文件(通常是main.js或main.ts)中添加一些代码来实现这一点。但是,请注意,直接修改全局对象(如console)可能不是最佳实践,因为它可能会与其他库或框架产生冲突。然而,如果你确实想这样做,下面是一个简单的例子,说明......
  • vue3 | 通信组件之provide 与 inject实现兄弟组件通信
    一、vue3|通信组件之provide与inject实现兄弟组件通信 通过共同祖先组件使用provide与inject来提供和注入状态,从而实现兄弟组件通信的示例。例子:祖先组件提供了一个名为 sharedState 的响应式状态,并通过 provide 函数将其提供给所有子组件。ChildA 和 ChildB ......
  • vue3+TS从0到1手撸后台管理系统
    1.路由配置1.1路由组件的雏形src\views\home\index.vue(以home组件为例)1.2路由配置1.2.1路由index文件src\router\index.ts//通过vue-router插件实现模板路由配置import{createRouter,createWebHashHistory}from'vue-router'import{constantRoute}from'./route......
  • vue3 widthDefaults 设置props默认值
    测试代码<template><divclass="box"><div><i-ep-edit/></div><div><i-ep-chat-dot-round/></div><div><i-ep-close/></div></div></template><scriptl......
  • Vue3项目配置Vue-Router
            在使用 Vue 作为前端开发框架时,我们通常以单页面应用(SPA)的形式进行开发。而单页面应用中,我们通常通过路由跳转的方式来实现我们页面上组件之间的跳转。在本文中,博主将详细介绍在Vue3 项目中,如何进行Vue-Router的安装与配置。在开始安装和配置Vue-Ro......
  • Vue3新特性defineOptions和defineModel 面试总结
    在Vue3中,defineOptions和defineModel是两个重要的新特性,它们分别在组件定义和v-model双向绑定方面提供了更为便捷和高效的解决方案。defineOptions定义与用途:defineOptions是Vue3.3+版本中引入的一个宏(macro),用于在<scriptsetup>语法糖中定义组件的选项,如组件名(name)、透传属......
  • 一个专为Android平台设计的高度可定制的日历库
    大家好,今天给大家分享一个高度可定制的日历库kizitonwose/Calendar。Calendar专为Android平台设计,支持RecyclerView和Compose框架。它提供了丰富的功能,允许开发者根据需求定制日历的外观和功能。项目介绍此库是开发Android应用时,实现日历功能的一个强大工具,特别适合那些需要......
  • vue3+element-plus+typescript
    1.vue3+ts+elementui-plushttps://blog.csdn.net/qq_41737571/article/details/1390730852.自动调整font-size大小https://blog.csdn.net/qq_41737571/article/details/1401586143.简单小众电商购物项目模板:基于Vue3和Vant4的纯前端开发方案https://web-hls.blog.csdn.......