GitHub风格文章热力图 2.0

16

实现了 多 RSS 源支持的完整代码。

主要改进点:

  1. 聚合逻辑:支持 RSS_URLS 数组,自动合并多个 RSS 源的数据。

  2. 高性能渲染:使用 innerHTML 批量注入和 requestAnimationFrame 优化,消除渲染卡顿。

  3. 健壮性:兼容了 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>