- 依赖d3.js 以及 moment.js
function calendarHeatmap() {
// defaults
var width = 750
var height = 130
var legendWidth = 150
var selector = 'body'
var SQUARE_LENGTH = 12
var SQUARE_PADDING = 2
var MONTH_LABEL_PADDING = 6
var now = moment()
.endOf('day')
.toDate()
var yearAgo = moment()
.startOf('day')
.subtract(1, 'year')
.toDate()
var startDate = null
var data = []
var max = null
var colorRange = ['#D8E6E7', '#218380']
var tooltipEnabled = true
var tooltipUnit = 'contribution'
var legendEnabled = true
var onClick = null
var weekStart = 0 //0 for Sunday, 1 for Monday
var locale = {
months: [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
],
days: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
No: 'No',
on: 'on',
Less: 'Less',
More: 'More',
}
// setters and getters
chart.data = function(value) {
if (!arguments.length) {
return date
}
date = value
return chart
}
chart.max = function(value) {
if (!arguments.length) {
return max
}
max = value
return chart
}
chart.selector = function(value) {
if (!arguments.length) {
return selector
}
selector = value
return chart
}
chart.startDate = function(value) {
if (!arguments.length) {
return startDate
}
yearAgo = value
now = moment(value)
.endOf('day')
.add(1, 'year')
.toDate()
return chart
}
chart.colorRange = function(value) {
if (!arguments.length) {
return colorRange
}
colorRange = value
return chart
}
chart.tooltipEnabled = function(value) {
if (!arguments.length) {
return tooltipEnabled
}
tooltipEnabled = value
return chart
}
chart.tooltipUnit = function(value) {
if (!arguments.length) {
return tooltipUnit
}
tooltipUnit = value
return chart
}
chart.legendEnabled = function(value) {
if (!arguments.length) {
return legendEnabled
}
legendEnabled = value
return chart
}
chart.onClick = function(value) {
if (!arguments.length) {
return onClick()
}
onClick = value
return chart
}
chart.locale = function(value) {
if (!arguments.length) {
return locale
}
locale = value
return chart
}
function chart() {
d3.select(chart.selector())
.selectAll('svg.calendar-heatmap')
.remove() // remove the existing chart, if it exists
var dateRange = d3.time.days(yearAgo, now) // generates an array of date objects within the specified range
var monthRange = d3.time.months(
moment(yearAgo)
.startOf('month')
.toDate(),
now
) // it ignores the first month if the 1st date is after the start of the month
var firstDate = moment(dateRange[0])
if (max === null) {
max = d3.max(chart.data(), function(d) {
return d.count
})
} // max data value
// color range
var color = d3.scale
.linear()
.range(chart.colorRange())
.domain([0, max])
var tooltip
var dayRects
drawChart()
function drawChart() {
var svg = d3
.select(chart.selector())
.style('position', 'relative')
.append('svg')
.attr('width', width)
.attr('class', 'calendar-heatmap')
.attr('height', height)
.style('padding', '0px')
dayRects = svg.selectAll('.day-cell').data(dateRange) // array of days for the last yr
dayRects
.enter()
.append('rect')
.attr('class', 'day-cell')
.attr('width', SQUARE_LENGTH)
.attr('height', SQUARE_LENGTH)
.attr('fill', function(d) {
let colorNum
if (countForDate(d) === 0) {
colorNum = 0
} else if (30 > countForDate(d) && countForDate(d) > 0) {
colorNum = 30
} else if (60 > countForDate(d) && countForDate(d) >= 30) {
colorNum = 40
} else if (120 > countForDate(d) && countForDate(d) >= 60) {
colorNum = 60
} else if (countForDate(d) >= 120) {
colorNum = 120
}
return color(colorNum)
})
.attr('x', function(d, i) {
var cellDate = moment(d)
var result =
cellDate.week() -
firstDate.week() +
firstDate.weeksInYear() *
(cellDate.weekYear() - firstDate.weekYear())
return result * (SQUARE_LENGTH + SQUARE_PADDING)
})
.attr('y', function(d, i) {
return (
MONTH_LABEL_PADDING +
formatWeekday(d.getDay()) * (SQUARE_LENGTH + SQUARE_PADDING)
)
})
if (typeof onClick === 'function') {
dayRects.on('click', function(d) {
var count = countForDate(d)
onClick({ date: d, count: count })
})
}
if (chart.tooltipEnabled()) {
dayRects
.on('mouseenter', function(d, i) {
tooltip = d3
.select(chart.selector())
.append('div')
.attr('class', 'day-cell-tooltip')
.html(tooltipHTMLForDate(d))
.style('left', function() {
return Math.floor(i / 7) * SQUARE_LENGTH + 'px'
})
.style('top', function() {
return (
formatWeekday(d.getDay()) * SQUARE_LENGTH +
MONTH_LABEL_PADDING + 15 +
'px'
)
})
})
.on('mouseleave', function(d, i) {
tooltip.remove()
})
}
if (chart.legendEnabled()) {
var colorRange = [color(0)]
for (var i = 4; i > 0; i--) {
colorRange.push(color(max / i))
}
var legendGroup = svg.append('g')
legendGroup
.selectAll('.calendar-heatmap-legend')
.data(colorRange)
.enter()
.append('rect')
.attr('class', 'calendar-heatmap-legend')
.attr('width', SQUARE_LENGTH)
.attr('height', SQUARE_LENGTH)
.attr('x', function(d, i) {
return width - legendWidth + (i + 1) * 13
})
.attr('y', height - SQUARE_PADDING * 6)
.attr('fill', function(d) {
return d
})
.on('mouseover', function(d, i) {
legendTooltip = d3
.select(chart.selector())
.append('div')
.attr('class', 'day-legend-tooltip')
.html(legendTooltipHTMLForDate(i))
.style('left', function(d, i) {
return width - legendWidth + (i + 1) * 13 + 'px'
})
.style('top', function() {
return height - SQUARE_PADDING * 8 + 15 + 'px'
})
})
.on('mouseout', function(d, i) {
legendTooltip.remove()
})
legendGroup
.append('text')
.attr(
'class',
'calendar-heatmap-legend-text calendar-heatmap-legend-text-less'
)
.attr('x', width - legendWidth - 13)
.attr('y', height - SQUARE_PADDING)
.text(locale.Less)
legendGroup
.append('text')
.attr(
'class',
'calendar-heatmap-legend-text calendar-heatmap-legend-text-more'
)
.attr(
'x',
width - legendWidth + SQUARE_PADDING + (colorRange.length + 1) * 13
)
.attr('y', height - SQUARE_PADDING)
.text(locale.More)
}
dayRects.exit().remove()
var monthLabels = svg
.selectAll('.month')
.data(monthRange)
.enter()
.append('text')
.attr('class', 'month-name')
.style()
.text(function(d) {
return locale.months[d.getMonth()]
})
.attr('x', function(d, i) {
var matchIndex = 0
dateRange.find(function(element, index) {
matchIndex = index + 5
return (
moment(d).isSame(element, 'month') &&
moment(d).isSame(element, 'year')
)
})
return Math.floor(matchIndex / 7) * (SQUARE_LENGTH + SQUARE_PADDING)
})
.attr('y', -5) // fix these to the top
locale.days.forEach(function(day, index) {
index = formatWeekday(index)
if (index % 2) {
svg
.append('text')
.attr('class', 'day-initial')
.attr(
'transform',
'translate(-16,' +
(SQUARE_LENGTH + SQUARE_PADDING) * (index + 1) +
')'
)
.style('text-anchor', 'middle')
.attr('dy', '2')
.text(day)
}
})
}
function pluralizedTooltipUnit(count) {
if ('string' === typeof tooltipUnit) {
return tooltipUnit
}
for (var i in tooltipUnit) {
var _rule = tooltipUnit[i]
var _min = _rule.min
var _max = _rule.max || _rule.min
_max = _max === 'Infinity' ? Infinity : _max
if (count >= _min && count <= _max) {
return _rule.unit
}
}
}
function tooltipHTMLForDate(d) {
var dateStr = moment(d).format('YYYY-MM-DD')
var count = countForDate(d)
return (
'<span><strong>' +
(count ? count : 0) +
' ' +
pluralizedTooltipUnit(count) +
'</strong> ' +
locale.on +
' ' +
dateStr +
'</span>'
)
}
function legendTooltipHTMLForDate(num) {
switch (num) {
case 0:
return '<span><strong>有效学习时间为0</strong></span>'
break
case 1:
return '<span><strong>0min≤有效学习时间<30min</strong></span>'
break
case 2:
return '<span><strong>30min≤有效学习时间<60min</strong></span>'
break
case 3:
return '<span><strong>60min≤有效学习时间<120min</strong></span>'
break
case 4:
return '<span><strong>有效学习时间为≥120min</strong></span>'
break
default:
// statements_def
break
}
}
function countForDate(d) {
var count = 0
var match = chart.data().find(function(element, index) {
return moment(element.date).isSame(d, 'day')
})
if (match) {
count = match.count
}
return count
}
function formatWeekday(weekDay) {
if (weekStart === 1) {
if (weekDay === 0) {
return 6
} else {
return weekDay - 1
}
}
return weekDay
}
var daysOfChart = chart.data().map(function(day) {
return day.date.toDateString()
})
dayRects
.filter(function(d) {
return daysOfChart.indexOf(d.toDateString()) > -1
})
.attr('fill', function(d, i) {
let colorNum
if (chart.data()[i].count === 0) {
colorNum = 0
} else if (30 > chart.data()[i].count && chart.data()[i].count > 0) {
colorNum = 30
} else if (chart.data()[i].count >= 30 && chart.data()[i].count < 60) {
colorNum = 40
} else if (120 > chart.data()[i].count && chart.data()[i].count >= 60) {
colorNum = 60
} else if (chart.data()[i].count >= 120) {
colorNum = 120
}
return color(colorNum)
})
}
return chart
}
// polyfill for Array.find() method
/* jshint ignore:start */
if (!Array.prototype.find) {
Array.prototype.find = function(predicate) {
if (this === null) {
throw new TypeError('Array.prototype.find called on null or undefined')
}
if (typeof predicate !== 'function') {
throw new TypeError('predicate must be a function')
}
var list = Object(this)
var length = list.length >>> 0
var thisArg = arguments[1]
var value
for (var i = 0; i < length; i++) {
value = list[i]
if (predicate.call(thisArg, value, i, list)) {
return value
}
}
return undefined
}
}
/* jshint ignore:end */
使用方法:
<template>
<div class="user-heatmap">
<div class="user-record-head">
<div class="user-record-title">
我的学习记录
</div>
<div class="user-record-desc">
<span>
当前连续学习
<strong>
{{ userStudyRecord.current_studying_days }}
</strong>
天
</span>
<span>
最大连续学习
<strong>
{{ userStudyRecord.max_studying_days }}
</strong>
天
</span>
<span>
总学习天数
<strong>
{{ userStudyRecord.total_study_days }}
</strong>
天
</span>
</div>
</div>
<div
id="calendar-heatmap"
class="heatmap"
/>
<div class="year">
<i
v-if="leftBtn"
class="fa fa-caret-left"
@click="preYear"
/>
{{ startYear }}-{{ endYear }}
<i
v-if="rightBtn"
class="fa fa-caret-right"
@click="nextYear"
/>
</div>
</div>
</template>
<script>
import { mapActions } from 'vuex'
export default {
props: {
userStudyRecord: {
type: Object,
required: true,
},
},
data() {
return {
startYear: '',
endYear: '',
}
},
computed: {
query() {
return this.$route.query
},
},
async mounted() {
this.initYear()
this.getCalendarHeatmap(this.userStudyRecord.records, this.startYear)
},
methods: {
...mapActions('user', ['getUserStudyRecord']),
// 记录表的基本配置
getCalendarHeatmap(mapData, startTime) {
const chartData = []
for (let i = 0; i < mapData.length; i++) {
mapData[i].date = moment(mapData[i].timestamp * 1000).toDate()
chartData.push(mapData[i])
startTime = moment(startTime).toDate()
const heatmap = calendarHeatmap()
.data(chartData)
.selector('#calendar-heatmap')
.startDate(startTime)
.tooltipEnabled(true)
.tooltipUnit('min')
.max(120)
.colorRange(['#eee', '#08bf91'])
heatmap()
}
},
initYear() {
this.startYear = moment(
moment(this.userStudyRecord.records[0].timestamp * 1000).toDate()
).get('year')
this.endYear = moment(
moment(
this.userStudyRecord.records[this.userStudyRecord.records.length - 1]
.timestamp * 1000
).toDate()
).get('year')
},
},
}
</script>
<style lang="scss" scoped>
.user-heatmap {
position: relative;
height: 240px;
padding: 25px 27px;
background: white;
.user-record-head {
.user-record-title {
float: left;
font-size: 16px;
color: #565656;
}
.user-record-desc {
float: right;
font-size: 14px;
color: #999;
span {
margin-left: 10px;
}
}
}
.heatmap {
padding: 46px 0 0 15px;
}
.year {
position: absolute;
top: 182px;
left: 44px;
font-size: 14px;
color: #a4a4a4;
i {
cursor: pointer;
}
}
}
</style>
<style lang="scss">
.user-heatmap {
text.month-name,
text.calendar-heatmap-legend-text,
text.day-initial {
font-size: 10px;
fill: inherit;
font-family: Helvetica, arial, 'Open Sans', sans-serif;
}
rect.day-cell:hover {
stroke: #555555;
stroke-width: 1px;
}
.day-cell-tooltip,
.day-legend-tooltip {
position: absolute;
z-index: 9999;
padding: 5px 9px;
color: #bbbbbb;
font-size: 12px;
background: rgba(0, 0, 0, 0.85);
border-radius: 3px;
text-align: center;
}
.day-cell-tooltip > span,
.day-legend-tooltip > span {
display: inline-block;
white-space: nowrap;
font-family: Helvetica, arial, 'Open Sans', sans-serif;
}
.calendar-heatmap {
box-sizing: initial;
}
}
</style>
网友评论