GitHub风格文章热力图 2.0
实现了 多 RSS 源支持的完整代码。
主要改进点:
聚合逻辑:支持
RSS_URLS数组,自动合并多个 RSS 源的数据。高性能渲染:使用
innerHTML批量注入和requestAnimationFrame优化,消除渲染卡顿。健壮性:兼容了 RSS 的
<item>和 Atom 的<entry>标签,以及多种日期格式。
<div class="container">
<div class="heatmap-layout">
<div class="week-labels-container">
<div class="spacer"></div>
<div class="week-label">周一</div>
<div class="week-label">周二</div>
<div class="week-label">周三</div>
<div class="week-label">周四</div>
<div class="week-label">周五</div>
<div class="week-label">周六</div>
<div class="week-label">周日</div>
</div>
<div class="flex-1">
<div id="month-labels"></div>
<div id="contribution-grid" class="contribution-grid"></div>
</div>
</div>
<div class="legend">
<span class="legend-text">更少</span>
<div class="contribution-cell level-0 legend-cell"></div>
<div class="contribution-cell level-1 legend-cell"></div>
<div class="contribution-cell level-2 legend-cell"></div>
<div class="contribution-cell level-3 legend-cell"></div>
<div class="contribution-cell level-4 legend-cell"></div>
<span>更多</span>
</div>
</div>
<script>
// 1. 配置多个 RSS 地址
const RSS_URLS = [
'https://blog.iiain.top/rss.xml', // Halo 文章
'https://blog.iiain.top/feed/moments/rss.xml' // 瞬间插件
];
function getLastYearDates() {
const dates = [];
const today = new Date();
for (let i = 371; i >= 0; i--) {
const d = new Date(today);
d.setDate(today.getDate() - i);
if (d <= today) dates.push(d);
}
return dates;
}
function formatDate(date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
function generateMockActivityData() {
const activityCounts = {};
let maxCount = 0;
const dates = getLastYearDates();
dates.forEach(date => {
if (Math.random() < 0.12) {
const count = Math.floor(Math.random() * 4) + 1;
const key = formatDate(date);
activityCounts[key] = count;
if (count > maxCount) maxCount = count;
}
});
return { activityCounts, maxCount: maxCount || 1 };
}
// 2. 多源数据抓取与聚合
async function fetchAllActivityData(urls) {
const totalActivityCounts = {};
let globalMaxCount = 0;
const fetchPromises = urls.map(async (url) => {
try {
const response = await fetch(url);
if (!response.ok) throw new Error('Network error');
const text = await response.text();
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(text, "text/xml");
// 兼容 RSS item 和 Atom entry
const items = [...xmlDoc.querySelectorAll('item'), ...xmlDoc.querySelectorAll('entry')];
items.forEach(post => {
const dateEl = post.querySelector('pubDate') ||
post.querySelector('updated') ||
post.querySelector('published');
if (dateEl) {
const date = new Date(dateEl.textContent);
if (!isNaN(date.getTime())) {
const key = formatDate(date);
totalActivityCounts[key] = (totalActivityCounts[key] || 0) + 1;
}
}
});
} catch (e) {
console.warn(`无法加载源: ${url}`, e);
}
});
await Promise.allSettled(fetchPromises);
// 重新计算最大值
const counts = Object.values(totalActivityCounts);
globalMaxCount = counts.length > 0 ? Math.max(...counts) : 1;
if (Object.keys(totalActivityCounts).length === 0) {
return generateMockActivityData();
}
return { activityCounts: totalActivityCounts, maxCount: globalMaxCount };
}
// 3. 高性能渲染逻辑
function renderHeatmap(activityCounts, maxCount) {
const gridEl = document.getElementById('contribution-grid');
const monthLabelsEl = document.getElementById('month-labels');
const dates = getLastYearDates();
const firstDate = dates[0];
const MONTH_NAMES = ["1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月", "9月", "10月", "11月", "12月"];
const dayOfWeekOffset = (firstDate.getDay() + 6) % 7;
const totalCols = Math.ceil((dates.length + dayOfWeekOffset) / 7);
// 设置 Grid 布局列数
const colTemplate = `repeat(${totalCols}, 10px)`;
gridEl.style.gridTemplateColumns = colTemplate;
monthLabelsEl.style.gridTemplateColumns = colTemplate;
let gridHtml = '';
let monthHtml = '';
let currentMonth = -1;
// 生成前置空位
for (let i = 0; i < dayOfWeekOffset; i++) {
gridHtml += `<div class="contribution-cell level-0" style="grid-row: ${i + 1}; grid-column: 1;"></div>`;
}
// 批量构建 HTML 字符串
dates.forEach((date, index) => {
const dayKey = formatDate(date);
const count = activityCounts[dayKey] || 0;
let level = 0;
if (count > 0) {
const ratio = count / maxCount;
level = ratio >= 0.75 ? 4 : ratio >= 0.5 ? 3 : ratio >= 0.25 ? 2 : 1;
}
const rowIndex = (date.getDay() + 6) % 7;
const colIndex = Math.floor((index + dayOfWeekOffset) / 7) + 1;
gridHtml += `
<div class="contribution-cell level-${level}"
title="${date.getMonth() + 1}月${date.getDate()}日, ${count > 0 ? count + ' 条动态' : '无动态'}"
style="grid-row: ${rowIndex + 1}; grid-column: ${colIndex};">
</div>`;
if (date.getMonth() !== currentMonth) {
monthHtml += `<div class="month-label-item" style="grid-column: ${colIndex} / span 3;">${MONTH_NAMES[date.getMonth()]}</div>`;
currentMonth = date.getMonth();
}
});
// 异步写入,防止阻塞 UI
requestAnimationFrame(() => {
gridEl.innerHTML = gridHtml;
monthLabelsEl.innerHTML = monthHtml;
});
}
window.onload = async () => {
const data = await fetchAllActivityData(RSS_URLS);
renderHeatmap(data.activityCounts, data.maxCount);
};
</script>
<style>
body {
background-color: #1B1B1F;
font-family: 'Inter', sans-serif;
margin: 0;
}
.container {
height: 136px;
width: fit-content;
padding: 10px;
}
.heatmap-layout {
display: flex;
}
.week-labels-container {
display: flex;
flex-direction: column;
justify-content: space-between;
height: 94px;
margin-top: 30px;
margin-right: 8px;
font-size: 10px;
color: #8b949e;
flex-shrink: 0;
}
.week-labels-container .spacer { height: 16px; }
.week-label { height: 13px; display: flex; align-items: center; }
.contribution-grid {
display: grid;
grid-template-rows: repeat(7, 10px);
gap: 3px;
}
.contribution-cell {
width: 10px;
height: 10px;
border-radius: 1px;
background-color: rgba(214, 214, 214, 0.45);
cursor: pointer;
position: relative;
}
/* 颜色配置 */
.level-0 { background-color: rgba(214, 214, 214, 0.45); }
.level-1 { background-color: #3700b3; }
.level-2 { background-color: #5a38d9; }
.level-3 { background-color: #7b5cf5; }
.level-4 { background-color: #9c83ff; }
/* Tooltip */
.contribution-cell:hover::after {
content: attr(title);
position: absolute;
bottom: 125%;
left: 50%;
transform: translateX(-50%);
background: rgba(48, 54, 61, 0.95);
color: #c9d1d9;
padding: 4px 8px;
border-radius: 2px;
font-size: 11px;
white-space: nowrap;
z-index: 100;
}
#month-labels {
display: grid;
gap: 3px;
margin-bottom: 6px;
height: 30px;
align-items: end;
}
.month-label-item {
font-size: 10px;
color: #8b949e;
text-align: left;
}
.legend {
display: flex;
align-items: center;
gap: 4px;
margin-top: 5px;
font-size: 12px;
color: #8b949e;
justify-content: flex-end; /* 图例靠右对齐 */
}
.legend-cell { width: 10px; height: 10px; }
</style>