活跃度热力图能够直观地展示用户在一段时间内的活动频率,通过颜色深浅的变化让用户快速识别出活跃的高峰期和低谷期。这种可视化方式在 GitHub 个人主页上广为人知,它将复杂的活动数据转化为易于理解的视觉表现,帮助用户快速把握自己的行为模式。热力图通过颜色深浅直观反映数据密度,让用户一眼就能识别出活跃的高峰期和低谷期。本文将介绍如何使用 HTML、CSS 和 JavaScript 实现活跃度热力图。效果演示
热力图以网格形式展示过去一年的活跃度数据。每个小方块代表一天的活跃情况,颜色越深表示活跃度越高。鼠标悬停在方块上时会显示具体日期和活跃次数,底部的图例显示了不同颜色对应的活跃度等级。顶部有月份标记帮助用户定位时间,左侧有星期标签便于识别星期几。
页面结构
页面主要包括热力图网格(core)、图例(legend)和提示框(tip)三个部分。<div id="core"> <div id="week-label"></div> <div id="grid"></div></div>
<div id="legend"> <span style="margin-right:8px">少</span> <div class="cell lv0"></div> <div class="cell lv1"></div> <div class="cell lv2"></div> <div class="cell lv3"></div> <div class="cell lv4"></div> <span style="margin-left:8px">多</span></div>
核心功能实现
数据按周分组
groupByWeeks 函数将一年的数据按周进行分组,使数据能够按星期对齐显示。这个函数处理了起始日期可能不在周一开始的情况,通过在第一周前面添加空值来对齐。function groupByWeeks(data) { const weeks = []; let week = new Array(7).fill(null); const start = data[0].date; const mon = (start.getDay() + 6) % 7; for (let i = 0; i < mon; i++) { week[i] = null; } for (let i = mon; i < 7; i++) { week[i] = data.shift(); } weeks.push(week); while (data.length) { week = new Array(7); for (let i = 0; i < 7 && data.length; i++) { week[i] = data.shift(); } if (data.length === 0 && week.length < 7) { for (let i = week.length; i < 7; i++) { week[i] = null; } } weeks.push(week); } return weeks;}
渲染热力图网格
renderGrid 函数根据分组后的数据渲染网格,为每个日期单元格分配对应的颜色等级。这个函数还处理了月份标记的显示,每到每月15号就在对应单元格上显示月份名称。function renderGrid(weeks) { const monthNames = ['一月','二月','三月','四月','五月','六月','七月','八月','九月','十月','十一月','十二月']; const grid = document.getElementById('grid'); weeks.forEach(wk => { wk.forEach(day => { const cell = document.createElement('div'); cell.className = 'cell lv0'; if (day) { cell.className = 'cell lv' + day.level; cell.dataset.date = day.date.toLocaleDateString('zh-CN'); cell.dataset.count = day.count; } else { cell.style.visibility = 'hidden'; } if (day && day.date.getDate() === 15) { const monthMarker = document.createElement('div'); monthMarker.className = 'month-marker'; monthMarker.textContent = monthNames[day.date.getMonth()]; cell.appendChild(monthMarker); } grid.appendChild(cell); }); });}
实现交互提示功能
setupTooltip 函数为热力图添加鼠标悬停提示功能,显示具体日期和活跃次数。同时处理提示框的位置,确保其始终在可视区域内显示。function setupTooltip() { const grid = document.getElementById('grid'); const tip = document.getElementById('tip'); grid.addEventListener('mouseover', e => { if (!e.target.dataset.date) return; tip.textContent = `${e.target.dataset.date} 活跃 ${e.target.dataset.count} 次`; tip.style.display = 'block'; }); grid.addEventListener('mousemove', e => { const pad = 6; let x = e.pageX + pad; let y = e.pageY - tip.offsetHeight - pad; if (x + tip.offsetWidth > window.scrollX + window.innerWidth) x = e.pageX - tip.offsetWidth - pad; if (y < window.scrollY) y = e.pageY + pad; tip.style.left = x + 'px'; tip.style.top = y + 'px'; }); grid.addEventListener('mouseout', () => tip.style.display = 'none');}
扩展建议
完整代码
git地址:https://gitee.com/ironpro/hjdemo/blob/master/heatmap/index.html<!DOCTYPE html><html lang="zh-CN"><head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>活跃度热力图</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { background: #f5f5f5; padding: 20px; min-height: 100vh; color: #333; } .container { background: white; padding: 20px; width: 1200px; margin: 0 auto; box-shadow: 0 4px 14px rgba(0, 0, 0, 0.08); } h1 { color: #2c3e50; font-size: 22px; margin-bottom: 25px; text-align: center; border-bottom: 1px solid #eee; padding-bottom: 15px; } #cal-wrap { margin: 0 auto; width: max-content; position: relative; padding-top: 30px; } #core { display: flex; align-items: flex-start; } #week-label { display: grid; grid-template-rows: repeat(7, 16px); gap: 3px; margin-right: 8px; font-size: 11px; color: #666666; text-align: right; } #week-label span { display: flex; align-items: center; height: 16px; justify-content: flex-end; } #grid { display: grid; grid-template-rows: repeat(7, 16px); grid-auto-flow: column; gap: 3px; position: relative; } .cell { width: 16px; height: 16px; cursor: pointer; } .lv0 {background: #ebedf0;} .lv1 {background: #9be9a8;} .lv2 {background: #40c463;} .lv3 {background: #30a14e;} .lv4 {background: #216e39;} #legend { margin-top: 20px; display: flex; align-items: center; font-size: 14px; color: #666666; justify-content: end; } #legend .cell { margin: 0 1px; } #tip { position: absolute; background: rgba(0, 0, 0, 0.95); color: #fff; font-size: 13px; padding: 8px 14px; white-space: nowrap; pointer-events: none; display: none; z-index: 999; font-weight: 400; box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); } .month-marker { position: absolute; top: -25px; font-size: 14px; color: #666666; transform: translateX(-50%); white-space: nowrap; font-weight: 500; margin-left: 14px; } </style></head><body><div class="container"> <h1>活跃度热力图</h1> <div id="cal-wrap"> <div id="core"> <div id="week-label"></div> <div id="grid"></div> </div> <div id="legend"> <span style="margin-right:8px">少</span> <div class="cell lv0"></div> <div class="cell lv1"></div> <div class="cell lv2"></div> <div class="cell lv3"></div> <div class="cell lv4"></div> <span style="margin-left:8px">多</span> </div> </div></div>
<div id="tip"></div>
<script> function generateYearData() { const today = new Date(); const start = new Date(today); start.setDate(today.getDate() - 365); const data = []; for (let d = new Date(start); d <= today; d.setDate(d.getDate() + 1)) { const count = Math.floor(Math.random() * 25); let level = 0; if (count === 0) level = 0; else if (count < 5) level = 1; else if (count < 10) level = 2; else if (count < 20) level = 3; else level = 4; data.push({ date: new Date(d), count, level }); } return data; } function groupByWeeks(data) { const weeks = []; let week = new Array(7).fill(null); const start = data[0].date; const mon = (start.getDay() + 6) % 7; for (let i = 0; i < mon; i++) { week[i] = null; } for (let i = mon; i < 7; i++) { week[i] = data.shift(); } weeks.push(week); while (data.length) { week = new Array(7); for (let i = 0; i < 7 && data.length; i++) { week[i] = data.shift(); } if (data.length === 0 && week.length < 7) { for (let i = week.length; i < 7; i++) { week[i] = null; } } weeks.push(week); } return weeks; } function renderWeekLabels() { const weekLabel = document.getElementById('week-label'); const weekDays = ['周一','周二','周三','周四','周五','周六','周日']; weekDays.forEach((day, i) => { const s = document.createElement('span'); if (i % 2 === 0) s.textContent = day; weekLabel.appendChild(s); }); } function renderGrid(weeks) { const monthNames = ['一月','二月','三月','四月','五月','六月','七月','八月','九月','十月','十一月','十二月']; const grid = document.getElementById('grid'); weeks.forEach(wk => { wk.forEach(day => { const cell = document.createElement('div'); cell.className = 'cell lv0'; if (day) { cell.className = 'cell lv' + day.level; cell.dataset.date = day.date.toLocaleDateString('zh-CN'); cell.dataset.count = day.count; } else { cell.style.visibility = 'hidden'; } if (day && day.date.getDate() === 15) { const monthMarker = document.createElement('div'); monthMarker.className = 'month-marker'; monthMarker.textContent = monthNames[day.date.getMonth()]; cell.appendChild(monthMarker); } grid.appendChild(cell); }); }); } function setupTooltip() { const grid = document.getElementById('grid'); const tip = document.getElementById('tip'); grid.addEventListener('mouseover', e => { if (!e.target.dataset.date) return; tip.textContent = `${e.target.dataset.date} 活跃 ${e.target.dataset.count} 次`; tip.style.display = 'block'; }); grid.addEventListener('mousemove', e => { const pad = 6; let x = e.pageX + pad; let y = e.pageY - tip.offsetHeight - pad; if (x + tip.offsetWidth > window.scrollX + window.innerWidth) x = e.pageX - tip.offsetWidth - pad; if (y < window.scrollY) y = e.pageY + pad; tip.style.left = x + 'px'; tip.style.top = y + 'px'; }); grid.addEventListener('mouseout', () => tip.style.display = 'none'); } function initHeatmap() { const data = generateYearData(); const weeks = groupByWeeks(data); renderWeekLabels(); renderGrid(weeks); setupTooltip(); } initHeatmap()</script></body></html>
阅读原文:原文链接
该文章在 2026/1/6 18:30:32 编辑过