Responsive Image

实现功能:
  • 根据颜色深浅表现当日字数的多少
  • 鼠标悬停展示当日总字数、文章数和瞬间数
  • 点击弹出当天写的文章列表
  • Step 1 - 使用脚本生成 json 文件

    heatmap.py(我放在网站项目根目录)

    import os
    import re
    import json
    import jieba
    import frontmatter
    from datetime import datetime, date
    from dateutil.parser import parse as parse_date
    from collections import defaultdict
    
    # 配置
    POSTS_DIR = 'content/posts'        # Hugo posts 目录
    MOMENTS_DIR = 'content/moments'    # Hugo moments 目录
    OUTPUT_FILE = 'static/data/posts_heatmap.json'  # 输出 JSON 文件路径
    
    if os.path.exists(OUTPUT_FILE):
        os.remove(OUTPUT_FILE)
    
    # 初始化数据结构: key=日期(字符串),value=字典
    heatmap_data = defaultdict(lambda: {
        'posts': [],
        'moments': [],
        'totalWords': 0.0,
        'momentCount': 0
    })
    
    def compute_words_count_by_jieba(markdown_content):
        """
        示例: 使用 jieba 对文本进行分词,统计词数并换算成“千字”。
        """
        text = re.sub(r'(```.+?```)', ' ', markdown_content, flags=re.S)
        text = re.sub(r'(`[^`]+`)', ' ', text)
        text = re.sub(r'\[[^\]]+\]\([^\)]+\)', ' ', text)
    
        tokens = list(jieba.cut(text, cut_all=False))
        tokens = [tok for tok in tokens if tok.strip()]  # 去掉纯空白
        return round(len(tokens)/1000.0, 2)
    
    def convert_dates(obj):
        """
        递归地将字典或列表中的 date/datetime 对象转换为字符串。
        防止写入 JSON 时出现 “Object of type date is not JSON serializable”。
        """
        if isinstance(obj, dict):
            return {k: convert_dates(v) for k, v in obj.items()}
        elif isinstance(obj, list):
            return [convert_dates(item) for item in obj]
        elif isinstance(obj, (date, datetime)):
            return obj.strftime('%Y-%m-%d')
        else:
            return obj
    
    def process_files(directory, category):
        """
        遍历指定目录,读取 Markdown 文件并插入 heatmap_data。
        category 为 'posts' 或 'moments'。
        """
        if not os.path.exists(directory):
            print(f"目录不存在: {directory}")
            return
    
        for root, dirs, files in os.walk(directory):
            for filename in files:
                if category == 'posts':
                    # 仅处理 'index.md' + 排除 _index.md
                    if filename == 'index.md' and not filename.startswith('_'):
                        file_path = os.path.join(root, filename)
                    else:
                        continue
                else:
                    # moments: 处理 .md 文件,排除 _index.md
                    if filename.endswith('.md') and not filename.startswith('_'):
                        file_path = os.path.join(root, filename)
                    else:
                        continue
    
                try:
                    with open(file_path, 'r', encoding='utf-8') as f:
                        post = frontmatter.load(f)
    
                    date_field = post.get('date')
                    if not date_field:
                        print(f"文件 {file_path} 缺少 'date' 字段,跳过。")
                        continue
    
                    # 统一解析 date 并存成字符串
                    try:
                        # 先转成字符串,再用 parse_date
                        post_date = parse_date(str(date_field))
                        date_str = post_date.strftime('%Y-%m-%d')
                    except (ValueError, TypeError) as e:
                        print(f"无法解析文件 {file_path} 的日期:{date_field} => {e}")
                        continue
    
                    # 计算字数
                    words_count = compute_words_count_by_jieba(post.content)
    
                    # 获取 slug
                    slug = post.get('slug')
                    if not slug:
                        if category == 'posts':
                            slug = os.path.basename(root)
                        else:
                            slug = os.path.splitext(filename)[0]
    
                    # 获取 title
                    if category == 'moments':
                        title = slug  # moments 不关心 title => 用 slug
                    else:
                        title = post.get('title', '未命名')
    
                    # 链接(仅 posts)
                    link = f"/posts/{slug}/" if category == 'posts' else ""
    
                    # 初始化 date_str
                    if date_str not in heatmap_data:
                        heatmap_data[date_str] = {
                            'posts': [],
                            'moments': [],
                            'totalWords': 0.0,
                            'momentCount': 0
                        }
    
                    if category == 'posts':
                        heatmap_data[date_str]['posts'].append({
                            'title': title,
                            'words': words_count,
                            'link': link
                        })
                    else:
                        # moments
                        heatmap_data[date_str]['moments'].append({
                            'title': title,
                            'words': words_count
                        })
    
                except Exception as e:
                    print(f"处理文件 {file_path} 时发生错误:{e}")
    
    def main():
        # 分别处理 posts 和 moments
        process_files(POSTS_DIR, 'posts')
        process_files(MOMENTS_DIR, 'moments')
    
        # 统计 totalWords 和 momentCount
        for date_str, data in heatmap_data.items():
            total = 0.0
            total += sum(x['words'] for x in data['posts'])
            total += sum(x['words'] for x in data['moments'])
            data['totalWords'] = round(total, 2)
            data['momentCount'] = len(data['moments'])
    
        # 将 defaultdict 转为普通字典
        heatmap_dict = dict(heatmap_data)
        for k in heatmap_dict:
            heatmap_dict[k] = dict(heatmap_dict[k])
    
        # 在写出之前,递归地将 date/datetime 全转成字符串
        # 因为 heatmap_dict 的 key 已经是字符串 date_str,所以不需特地改它们
        # 但为保险起见,依然可以把里面的任何残余 date 转掉
        final_data = convert_dates(heatmap_dict)
    
        # 写入 JSON
        os.makedirs(os.path.dirname(OUTPUT_FILE), exist_ok=True)
        try:
            with open(OUTPUT_FILE, 'w', encoding='utf-8') as f:
                json.dump(final_data, f, ensure_ascii=False, indent=2)
            print(f"\n已成功生成 {OUTPUT_FILE}")
        except Exception as e:
            print(f"写入 JSON 文件时出错: {e}")
    
    if __name__ == "__main__":
        main()
    

    生成的static/data/posts_heatmap.json应该类似这样:

    {
      "2024-12-04": {
        "posts": [
          {
            "title": "XXXXX",
            "words": 1.41,
            "link": "/posts/{{slug}}"
          }
        ],
        "moments": [],
        "totalWords": 1.41,
        "momentCount": 0
      },
      "2024-12-20": {
        "posts": [],
        "moments": [
          {
            "title": {{slug}},
            "words": 0.04
          }
        ],
        "totalWords": 0.04,
        "momentCount": 1
      }
    }
    

    Step 2 - 定义 HeatMap 样式

    20250402更新
    增加响应式设计,并在移动端只显示3个月数据。

    layouts/partials文件夹内添加heatmap.html文件:

    layouts/partials/heatmap.html

    <!-- 热力图容器 -->
    <div class="heatmap-container">
      <div id="heatmap"></div>
    </div>
    
    <!-- 引入 ECharts 的 JS 库 -->
    <script src="https://lib.baomitu.com/echarts/5.3.3/echarts.min.js"></script>
    
    <!-- 模态窗口结构 -->
    <div id="articleModal" class="modal">
      <div class="modal-content">
        <span class="close">&times;</span>
        <h2 id="modalDate" style="color: #04271f;">日期</h2>
        <ul id="modalPosts" class="article-list">
          <!-- 文章列表将在这里生成 -->
        </ul>
      </div>
    </div>
    
    <!-- 样式 -->
    <style>
      /* 让容器固定高度 250px,去除原先的 padding-bottom */
      .heatmap-container {
        width: 100%;
        height: 200px; /* 固定高度 250px */
        position: relative;
        overflow: hidden; /* 可选:若要隐藏溢出内容 */
      }
      #heatmap {
        position: absolute;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
      }
    
      /* 模态窗口的背景 */
      .modal {
        display: none; /* 默认隐藏 */
        position: fixed; /* 固定位置 */
        z-index: 1000;   /* 置于顶层 */
        left: 0;
        top: 0;
        width: 100%;     /* 全屏宽度 */
        height: 100%;    /* 全屏高度 */
        overflow: auto;  /* 如果需要滚动 */
        background-color: rgba(0, 0, 0, 0.6); /* 半透明黑色背景 */
        transition: opacity 0.3s ease;
      }
    
      /* 模态内容 */
      .modal-content {
        background-color: #ffffff;
        margin: 5% auto; /* 5% 从顶部居中 */
        padding: 20px;
        border: none;
        border-radius: 10px;
        width: 90%;
        max-width: 600px;
        box-shadow: 0 5px 15px rgba(0,0,0,0.3);
        animation: slideIn 0.4s ease;
      }
    
      /* 弹入动画 */
      @keyframes slideIn {
        from { transform: translateY(-50px); opacity: 0; }
        to { transform: translateY(0); opacity: 1; }
      }
    
      /* 关闭按钮 */
      .close {
        color: #aaa;
        float: right;
        font-size: 28px;
        font-weight: bold;
        cursor: pointer;
        transition: color 0.2s ease;
      }
    
      .close:hover,
      .close:focus {
        color: #000;
        text-decoration: none;
      }
    
      /* 文章列表样式 */
      .article-list {
        list-style-type: none;
        padding: 0;
        margin-top: 10px;
      }
    
      .article-list li {
        margin: 8px 0;
        padding-bottom: 8px;
        border-bottom: 1px solid #e0e0e0;
      }
    
      .article-list li:last-child {
        border-bottom: none;
      }
    
      .article-list a {
        text-decoration: none;
        color: #196127;
        font-size: 16px;
        transition: color 0.2s ease;
      }
    
      .article-list a:hover {
        color: #7bc96f;
        text-decoration: underline;
      }
    
      /* 响应式调整 */
      @media (max-width: 600px) {
        .modal-content {
          width: 95%;
          padding: 15px;
        }
    
        .close {
          font-size: 24px;
        }
    
        .article-list a {
          font-size: 14px;
        }
    
        /* 移动端只显示3个月数据 */
        .calendar {
          range: [echarts.format.formatTime('yyyy-MM-dd', new Date(new Date().setMonth(new Date().getMonth() - 3))), echarts.format.formatTime('yyyy-MM-dd', new Date())];
        }
      }
    </style>
    
    <!-- 热力图初始化脚本 -->
    <script type="text/javascript">
      document.addEventListener("DOMContentLoaded", function() {
        var chartDom = document.getElementById('heatmap');
        var myChart = echarts.init(chartDom);
    
        // 因为使用固定高度 250px,不再需要 setChartHeight / resize 处理
        // 也无需监听 resize,如果您想在窗口变化时自适应宽度可以保留 myChart.resize()
    
        var option;
    
        // 初始化 Modal
        var modal = document.getElementById("articleModal");
        var span = document.getElementsByClassName("close")[0];
    
        // 点击 (x) 关闭 Modal
        span.onclick = function() {
          modal.style.display = "none";
        }
    
        // 点击模态外部关闭
        window.onclick = function(event) {
          if (event.target == modal) {
            modal.style.display = "none";
          }
        }
    
        // 从 posts_heatmap.json 获取数据
        fetch("/data/posts_heatmap.json")
          .then(response => {
            if (!response.ok) {
              throw new Error("Network response was not ok " + response.statusText);
            }
            return response.json();
          })
          .then(data => {
            // 准备 ECharts 的数据
            var heatmapData = [];
            for (const [date, categories] of Object.entries(data)) {
              heatmapData.push([date, categories.totalWords]);
            }
    
            // 计算最近日期范围(桌面端6个月,移动端3个月)
            var endDate = new Date();
            var startDate = new Date();
            var isMobile = window.matchMedia('(max-width: 600px)').matches;
            startDate.setMonth(startDate.getMonth() - (isMobile ? 3 : 9));
            var startDateStr = echarts.format.formatTime('yyyy-MM-dd', startDate);
            var endDateStr = echarts.format.formatTime('yyyy-MM-dd', endDate);
    
            option = {
              title: {
                show: false,
                text: '摸鱼日记',
                left: 'center',
                top: 20,
                textStyle: {
                  fontSize: 18,
                  fontWeight: 'bold',
                  color: '#333'
                }
              },
              tooltip: {
                trigger: 'item',
                formatter: function (params) {
                  const date = params.data[0];
                  const totalWords = params.data[1];
                  const postCount = data[date] ? data[date].posts.length : 0;
                  const momentCount = data[date] ? data[date].momentCount : 0;
    
                  let tooltipContent = `<strong>${date}</strong><br/>总字数: ${totalWords} 千字`;
                  if (postCount > 0) tooltipContent += `<br/>文章数量: ${postCount}`;
                  if (momentCount > 0) tooltipContent += `<br/>瞬间数量: ${momentCount}`;
                  return tooltipContent;
                }
              },
              visualMap: {
                show: false,
                min: 0,
                max: 1, // 根据实际数据范围调
                type: 'piecewise',
                orient: 'horizontal',
                left: 'center',
                top: 60,
                inRange: {
                  // GitHub 风格绿色梯度
                  color: ['#c6e48b', '#7bc96f', '#239a3b', '#196127']
                },
                splitNumber: 4,
                text: ['', ''],
                showLabel: true,
                itemGap: 20,
                padding: [0, 0, 10, 0]
              },
              calendar: {
                top: 50,
                left: 20,
                right: 4,
                cellSize: ['auto', 20],
                range: [startDateStr, endDateStr],
                itemStyle: {
                  color: '#f5f5f500',
                  borderWidth: 1,
                  borderColor: '#e6e6e675',
                  borderRadius: 0,
                  shadowBlur: 1,
                  shadowColor: 'rgba(0,0,0,0.1)'
                },
                yearLabel: { show: true },
                monthLabel: { nameMap: 'cn', color: '#999', fontSize: 12 },
                dayLabel: {
                  firstDay: 0,
                  nameMap: ['日', '一', '二', '三', '四', '五', '六'],
                  color: '#999',
                  fontSize: 12
                },
                splitLine: {
                  lineStyle: {
                    color: 'rgba(0, 0, 0, 0.0)',
                  }
                }
              },
              series: {
                type: 'heatmap',
                coordinateSystem: 'calendar',
                data: heatmapData,
              }
            };
            myChart.setOption(option);
    
            // 添加点击事件,显示 Modal
            myChart.on('click', function(params) {
              if (params.componentType === 'series') {
                const date = params.data[0];
                const postCount = data[date] ? data[date].posts.length : 0;
                if (data[date] && postCount > 0) {
                  document.getElementById('modalDate').innerText = date;
                  var postsList = document.getElementById('modalPosts');
                  postsList.innerHTML = '';
    
                  // 列出当日的所有 posts
                  data[date].posts.forEach(function(post) {
                    var li = document.createElement('li');
                    li.innerHTML = `<a href="${post.link}" target="_blank">${post.title}${post.words}千字</a>`;
                    postsList.appendChild(li);
                  });
                  modal.style.display = "block";
                }
              }
            });
          })
          .catch(error => {
            console.error("加载 posts_heatmap.json 时出错:", error);
          });
      });
    </script>
    

    Step 3 - 修改 hugo.yaml

    hugo.yaml最后添加:

    markup:
      goldmark:
        renderer:
          unsafe: true
    

    Step 4 - 将热力图插入首页

    layouts/partials/home-info.html中插入:

        <section class="heatmap-section">
            {{ partial "heatmap.html" . }}
        </section>
    

    我的layouts/partials/home-info.html

    {{- with site.Params.homeInfoParams }}
    <article class="first-entry home-info">
        <header class="entry-header">
            <h1>{{ .Title | markdownify }}</h1>
        </header>
        <section class="heatmap-section">
            {{ partial "heatmap.html" . }}
        </section>
        <div class="entry-content">
            {{ .Content | markdownify }}
        </div>
        <footer class="entry-footer">
            {{ partial "social_icons.html" (dict "align" site.Params.homeInfoParams.AlignSocialIconsTo) }}
        </footer>
    </article>
    {{- end -}}
    

    Step 5 - 设置 Cloudflare Pages 自动化构建

    1. 在项目根目录添加依赖项文件:

    requirements.txt

    jieba
    python-frontmatter
    python-dateutil
    
    1. 修改 Cloudflare 构建配置:

    构建命令:pip install -r requirements.txt && python3 heatmap.py && hugo

    Responsive Image