开始
最近我在写自己的网站,需要日历热度图来丰富点内容;所以在网上找了许多参考,如下:
- 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()
中注册了点击和悬浮提示的事件。
最后
后续还要适配暗色模式和年份切换,但是现在勉强用着吧。