基于Vue+ElementUI设计的一款GitHub风格的热力图。
示例
代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>热力图</title>
<link rel="stylesheet" href="https://unpkg.com/element-ui/lib/theme-chalk/index.css">
<style>
.heatmap {
width: 100%;
max-width: 900px;
user-select: none;
}
.heatmap-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.heatmap-title {
font-size: 16px;
font-weight: 600;
}
.heatmap-months {
display: grid;
grid-auto-flow: column;
gap: 3px;
margin-left: 30px;
margin-bottom: 6px;
font-size: 12px;
color: #59636e;
overflow: hidden;
height: 18px;
line-height: 18px;
}
.heatmap-body {
display: flex;
}
.heatmap-weekdays {
display: grid;
grid-template-rows: repeat(7, 14px);
gap: 3px;
margin-right: 6px;
font-size: 12px;
color: #59636e;
align-content: start;
}
.heatmap-weekday-label {
height: 14px;
line-height: 14px;
}
.heatmap-grid {
display: grid;
grid-template-rows: repeat(7, 14px);
grid-auto-flow: column;
gap: 3px;
flex: 1;
overflow: hidden;
}
.heatmap-cell {
width: 14px;
height: 14px;
border-radius: 2px;
cursor: pointer;
outline: none;
}
.heatmap-cell:hover {
outline: 1px solid rgba(0, 0, 0, 0.2);
}
.heatmap-legend {
display: flex;
align-items: center;
gap: 4px;
margin-top: 8px;
margin-left: 30px;
font-size: 12px;
color: #59636e;
}
.heatmap-legend-cell {
width: 14px;
height: 14px;
border-radius: 2px;
}
</style>
</head>
<body>
<div id="app">
<div th:fragment="heatmap" class="heatmap" ref="heatmap">
<!-- 控制栏 -->
<div class="heatmap-header">
<el-button round icon="el-icon-arrow-left" size="mini" @click="shiftMonth(-1)">上月
</el-button>
<span class="heatmap-title" v-text="currentYear+'年'+(currentMonth + 1)+'月'"></span>
<el-button round icon="el-icon-arrow-right" size="mini" @click="shiftMonth(1)" :disabled="!canNext">下月
</el-button>
<el-button round icon="el-icon-refresh" size="mini" @click="shiftMonth(0)">重置</el-button>
<div class="heatmap-legend">
<span>少</span>
<div class="heatmap-legend-cell" v-for="(color,i) in colors" :key="i" :style="{ backgroundColor: color }"></div>
<span>多</span>
</div>
</div>
<!-- 月份标签 -->
<div class="heatmap-months">
<span v-for="(m, i) in visibleMonthLabels" :key="i"
:style="{ gridColumn: 'span ' + m.span }" v-text="m.name"></span>
</div>
<!-- 热力图 -->
<div class="heatmap-body">
<!-- 星期 -->
<div class="heatmap-weekdays">
<div class="heatmap-weekday-label"></div>
<div class="heatmap-weekday-label">周一</div>
<div class="heatmap-weekday-label"></div>
<div class="heatmap-weekday-label">周三</div>
<div class="heatmap-weekday-label"></div>
<div class="heatmap-weekday-label">周五</div>
<div class="heatmap-weekday-label"></div>
</div>
<!-- 热力图数据 -->
<div class="heatmap-grid">
<el-tooltip
v-for="(item, idx) in visibleData"
:key="idx"
:content="item.date+' 发布 '+ item.count + ' 篇'"
:placement="getPlacement(idx)"
effect="dark"
:open-delay="200"
:enterable="false">
<div class="heatmap-cell"
:style="{ backgroundColor: getColor(item.count) }"></div>
</el-tooltip>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/vue@2.7.14/dist/vue.js"></script>
<script src="https://unpkg.com/element-ui/lib/index.js"></script>
<script>
new Vue({
el: '#app',
data :{
currentYear: null,
currentMonth: null,
visibleWeeks: 26,
heatmapDataMap:{},
resizeObserver: null,
colors: ['#ebedf0', '#9be9a8', '#40c463', '#30a14e', '#216e39']
},
computed: {
// 可见数据
visibleData() {
let lastDayOfMonth = new Date(this.currentYear, this.currentMonth + 1, 0);
let endDayOfWeek = lastDayOfMonth.getDay();
let endDate = new Date(lastDayOfMonth);
endDate.setDate(endDate.getDate() + (6 - endDayOfWeek));
let totalDays = this.visibleWeeks * 7;
let startDate = new Date(endDate);
startDate.setDate(startDate.getDate() - totalDays + 1);
let result = [];
let current = new Date(startDate);
while (current <= endDate) {
let dateStr = this.formatDate(current);
result.push({date: dateStr, count: this.heatmapDataMap[dateStr] || 0});
current.setDate(current.getDate() + 1);
}
return result;
},
// 可见月份
visibleMonthLabels() {
if (!this.visibleData.length) return [];
let labels = [];
let currentMonth = -1;
let cnMonths = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'];
this.visibleData.forEach(function (item, idx) {
let col = Math.floor(idx / 7);
let month = parseInt(item.date.slice(5, 7)) - 1;
if (month !== currentMonth) {
labels.push({name: cnMonths[month], span: 1, startCol: col});
currentMonth = month;
} else {
labels[labels.length - 1].span = col - labels[labels.length - 1].startCol + 1;
}
})
return labels;
},
canNext() {
let now = new Date();
return !(this.currentYear === now.getFullYear() && this.currentMonth === now.getMonth());
}
},
mounted () {
this.getCurrentTime();
this.getHeatmapData();
},
beforeDestroy () {
if (this.resizeObserver) this.resizeObserver.disconnect();
},
methods: {
// 初始化热力图
initHeatmap(){
// 当前时间
this.getCurrentTime();
// 获取数据
this.getHeatmapData();
// 计算可见周数
this.calcVisibleWeeks();
let self = this;
this.resizeObserver = new ResizeObserver(function () {
self.calcVisibleWeeks();
})
this.resizeObserver.observe(this.$refs.heatmap);
},
// 获取当前时间
getCurrentTime() {
this.currentYear = new Date().getFullYear();
this.currentMonth = new Date().getMonth();
},
// 由调用方重写
getHeatmapData() {
this.heatmapDataMap['2026-04-01'] = 1;
this.heatmapDataMap['2026-05-02'] = 6;
this.heatmapDataMap['2026-06-03'] = 3;
this.heatmapDataMap['2026-07-04'] = 8;
this.heatmapDataMap['2026-08-05'] = 2;
this.heatmapDataMap['2026-08-15'] = 10;
this.heatmapDataMap['2026-08-25'] = 6;
},
// 计算可见周数
calcVisibleWeeks() {
if(this.heatmapDataMap.size >0){
let containerWidth = this.$refs.heatmap.clientWidth;
let availableWidth = containerWidth - 30 - 6;
this.visibleWeeks = Math.max(4, Math.floor(availableWidth / 17));
}
},
// 获取 tooltip 显示位置
getPlacement: function (idx) {
let col = Math.floor(idx / 7);
let totalCols = Math.ceil(this.visibleData.length / 7);
return col < totalCols / 2 ? 'top-start' : 'top-end';
},
// 切换月份
shiftMonth(delta) {
if (delta === 0) {
this.getCurrentTime();
return;
}
let m = this.currentMonth + delta;
let y = this.currentYear;
if (m < 0) {
m = 11;
y--;
}
if (m > 11) {
m = 0;
y++;
}
this.currentYear = y;
this.currentMonth = m;
},
// 获取颜色
getColor(count) {
if (count === 0) return this.colors[0];
if (count <= 2) return this.colors[1];
if (count <= 4) return this.colors[2];
if (count <= 8) return this.colors[3];
return this.colors[4];
},
// 格式化日期
formatDate(d) {
let y = d.getFullYear();
let m = String(d.getMonth() + 1).padStart(2, '0');
let day = String(d.getDate()).padStart(2, '0');
return y + '-' + m + '-' + day;
}
}
})
</script>
</body>
</html>