1.[VUE3] 使用D3实现日历热力图
2.[vue3组件] 实现一个提示组件开始
最近我在写自己的网站,需要日历热度图来丰富点内容;所以在网上找了许多参考,如下:
- https://www.zzxworld.com/posts/draw-calendar-of-heatmap-chart-with-d3js
- https://github.com/DominikAngerer/vue-heatmap/blob/master/README.md
将两个结合就是我想要的。
现在是这样:
代码
vue3组件
从组件开始。
首先是js。
这里使用defineProps
定义了属性的类型和默认值;
原先我想在js中设置默认值,但是发现Boolean
的prop
即使没有设置也有默认值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
。
再下面的是getter
和setter
,使用三目和逗号压缩成了一行(和写ahk一样x-x)。
chart方法
在此方法中绘制日期和周数,以及日期格子。
这里都是D3
的使用,总共就三个方法:
- drawMonth()
- drawWeek()
- drawDay()
同时,在drawDay()
中注册了点击和悬浮提示的事件。
最后
后续还要适配暗色模式和年份切换,但是现在勉强用着吧。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?