Responsive Image

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

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

    python
      1import os
      2import re
      3import json
      4import jieba
      5import frontmatter
      6from datetime import datetime, date
      7from dateutil.parser import parse as parse_date
      8from collections import defaultdict
      9
     10# 配置
     11POSTS_DIR = 'content/posts'        # Hugo posts 目录
     12MOMENTS_DIR = 'content/moments'    # Hugo moments 目录
     13OUTPUT_FILE = 'static/data/posts_heatmap.json'  # 输出 JSON 文件路径
     14
     15if os.path.exists(OUTPUT_FILE):
     16    os.remove(OUTPUT_FILE)
     17
     18# 初始化数据结构: key=日期(字符串),value=字典
     19heatmap_data = defaultdict(lambda: {
     20    'posts': [],
     21    'moments': [],
     22    'totalWords': 0.0,
     23    'momentCount': 0
     24})
     25
     26def compute_words_count_by_jieba(markdown_content):
     27    """
     28    示例: 使用 jieba 对文本进行分词,统计词数并换算成“千字”。
     29    """
     30    text = re.sub(r'(```.+?```)', ' ', markdown_content, flags=re.S)
     31    text = re.sub(r'(`[^`]+`)', ' ', text)
     32    text = re.sub(r'\[[^\]]+\]\([^\)]+\)', ' ', text)
     33
     34    tokens = list(jieba.cut(text, cut_all=False))
     35    tokens = [tok for tok in tokens if tok.strip()]  # 去掉纯空白
     36    return round(len(tokens)/1000.0, 2)
     37
     38def convert_dates(obj):
     39    """
     40    递归地将字典或列表中的 date/datetime 对象转换为字符串。
     41    防止写入 JSON 时出现 “Object of type date is not JSON serializable”。
     42    """
     43    if isinstance(obj, dict):
     44        return {k: convert_dates(v) for k, v in obj.items()}
     45    elif isinstance(obj, list):
     46        return [convert_dates(item) for item in obj]
     47    elif isinstance(obj, (date, datetime)):
     48        return obj.strftime('%Y-%m-%d')
     49    else:
     50        return obj
     51
     52def process_files(directory, category):
     53    """
     54    遍历指定目录,读取 Markdown 文件并插入 heatmap_data。
     55    category 为 'posts' 或 'moments'。
     56    """
     57    if not os.path.exists(directory):
     58        print(f"目录不存在: {directory}")
     59        return
     60
     61    for root, dirs, files in os.walk(directory):
     62        for filename in files:
     63            if category == 'posts':
     64                # 仅处理 'index.md' + 排除 _index.md
     65                if filename == 'index.md' and not filename.startswith('_'):
     66                    file_path = os.path.join(root, filename)
     67                else:
     68                    continue
     69            else:
     70                # moments: 处理 .md 文件,排除 _index.md
     71                if filename.endswith('.md') and not filename.startswith('_'):
     72                    file_path = os.path.join(root, filename)
     73                else:
     74                    continue
     75
     76            try:
     77                with open(file_path, 'r', encoding='utf-8') as f:
     78                    post = frontmatter.load(f)
     79
     80                date_field = post.get('date')
     81                if not date_field:
     82                    print(f"文件 {file_path} 缺少 'date' 字段,跳过。")
     83                    continue
     84
     85                # 统一解析 date 并存成字符串
     86                try:
     87                    # 先转成字符串,再用 parse_date
     88                    post_date = parse_date(str(date_field))
     89                    date_str = post_date.strftime('%Y-%m-%d')
     90                except (ValueError, TypeError) as e:
     91                    print(f"无法解析文件 {file_path} 的日期:{date_field} => {e}")
     92                    continue
     93
     94                # 计算字数
     95                words_count = compute_words_count_by_jieba(post.content)
     96
     97                # 获取 slug
     98                slug = post.get('slug')
     99                if not slug:
    100                    if category == 'posts':
    101                        slug = os.path.basename(root)
    102                    else:
    103                        slug = os.path.splitext(filename)[0]
    104
    105                # 获取 title
    106                if category == 'moments':
    107                    title = slug  # moments 不关心 title => 用 slug
    108                else:
    109                    title = post.get('title', '未命名')
    110
    111                # 链接(仅 posts)
    112                link = f"/posts/{slug}/" if category == 'posts' else ""
    113
    114                # 初始化 date_str
    115                if date_str not in heatmap_data:
    116                    heatmap_data[date_str] = {
    117                        'posts': [],
    118                        'moments': [],
    119                        'totalWords': 0.0,
    120                        'momentCount': 0
    121                    }
    122
    123                if category == 'posts':
    124                    heatmap_data[date_str]['posts'].append({
    125                        'title': title,
    126                        'words': words_count,
    127                        'link': link
    128                    })
    129                else:
    130                    # moments
    131                    heatmap_data[date_str]['moments'].append({
    132                        'title': title,
    133                        'words': words_count
    134                    })
    135
    136            except Exception as e:
    137                print(f"处理文件 {file_path} 时发生错误:{e}")
    138
    139def main():
    140    # 分别处理 posts 和 moments
    141    process_files(POSTS_DIR, 'posts')
    142    process_files(MOMENTS_DIR, 'moments')
    143
    144    # 统计 totalWords 和 momentCount
    145    for date_str, data in heatmap_data.items():
    146        total = 0.0
    147        total += sum(x['words'] for x in data['posts'])
    148        total += sum(x['words'] for x in data['moments'])
    149        data['totalWords'] = round(total, 2)
    150        data['momentCount'] = len(data['moments'])
    151
    152    # 将 defaultdict 转为普通字典
    153    heatmap_dict = dict(heatmap_data)
    154    for k in heatmap_dict:
    155        heatmap_dict[k] = dict(heatmap_dict[k])
    156
    157    # 在写出之前,递归地将 date/datetime 全转成字符串
    158    # 因为 heatmap_dict 的 key 已经是字符串 date_str,所以不需特地改它们
    159    # 但为保险起见,依然可以把里面的任何残余 date 转掉
    160    final_data = convert_dates(heatmap_dict)
    161
    162    # 写入 JSON
    163    os.makedirs(os.path.dirname(OUTPUT_FILE), exist_ok=True)
    164    try:
    165        with open(OUTPUT_FILE, 'w', encoding='utf-8') as f:
    166            json.dump(final_data, f, ensure_ascii=False, indent=2)
    167        print(f"\n已成功生成 {OUTPUT_FILE}")
    168    except Exception as e:
    169        print(f"写入 JSON 文件时出错: {e}")
    170
    171if __name__ == "__main__":
    172    main()

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

    json
     1{
     2  "2024-12-04": {
     3    "posts": [
     4      {
     5        "title": "XXXXX",
     6        "words": 1.41,
     7        "link": "/posts/{{slug}}"
     8      }
     9    ],
    10    "moments": [],
    11    "totalWords": 1.41,
    12    "momentCount": 0
    13  },
    14  "2024-12-20": {
    15    "posts": [],
    16    "moments": [
    17      {
    18        "title": {{slug}},
    19        "words": 0.04
    20      }
    21    ],
    22    "totalWords": 0.04,
    23    "momentCount": 1
    24  }
    25}

    Step 2 - 定义 HeatMap 样式

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

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

    layouts/partials/heatmap.html

    html
      1<!-- 热力图容器 -->
      2<div class="heatmap-container">
      3  <div id="heatmap"></div>
      4</div>
      5
      6<!-- 引入 ECharts 的 JS 库 -->
      7<script src="https://lib.baomitu.com/echarts/5.3.3/echarts.min.js"></script>
      8
      9<!-- 模态窗口结构 -->
     10<div id="articleModal" class="modal">
     11  <div class="modal-content">
     12    <span class="close">&times;</span>
     13    <h2 id="modalDate" style="color: #04271f;">日期</h2>
     14    <ul id="modalPosts" class="article-list">
     15      <!-- 文章列表将在这里生成 -->
     16    </ul>
     17  </div>
     18</div>
     19
     20<!-- 样式 -->
     21<style>
     22  /* 让容器固定高度 250px,去除原先的 padding-bottom */
     23  .heatmap-container {
     24    width: 100%;
     25    height: 200px; /* 固定高度 250px */
     26    position: relative;
     27    overflow: hidden; /* 可选:若要隐藏溢出内容 */
     28  }
     29  #heatmap {
     30    position: absolute;
     31    top: 0;
     32    left: 0;
     33    width: 100%;
     34    height: 100%;
     35  }
     36
     37  /* 模态窗口的背景 */
     38  .modal {
     39    display: none; /* 默认隐藏 */
     40    position: fixed; /* 固定位置 */
     41    z-index: 1000;   /* 置于顶层 */
     42    left: 0;
     43    top: 0;
     44    width: 100%;     /* 全屏宽度 */
     45    height: 100%;    /* 全屏高度 */
     46    overflow: auto;  /* 如果需要滚动 */
     47    background-color: rgba(0, 0, 0, 0.6); /* 半透明黑色背景 */
     48    transition: opacity 0.3s ease;
     49  }
     50
     51  /* 模态内容 */
     52  .modal-content {
     53    background-color: #ffffff;
     54    margin: 5% auto; /* 5% 从顶部居中 */
     55    padding: 20px;
     56    border: none;
     57    border-radius: 10px;
     58    width: 90%;
     59    max-width: 600px;
     60    box-shadow: 0 5px 15px rgba(0,0,0,0.3);
     61    animation: slideIn 0.4s ease;
     62  }
     63
     64  /* 弹入动画 */
     65  @keyframes slideIn {
     66    from { transform: translateY(-50px); opacity: 0; }
     67    to { transform: translateY(0); opacity: 1; }
     68  }
     69
     70  /* 关闭按钮 */
     71  .close {
     72    color: #aaa;
     73    float: right;
     74    font-size: 28px;
     75    font-weight: bold;
     76    cursor: pointer;
     77    transition: color 0.2s ease;
     78  }
     79
     80  .close:hover,
     81  .close:focus {
     82    color: #000;
     83    text-decoration: none;
     84  }
     85
     86  /* 文章列表样式 */
     87  .article-list {
     88    list-style-type: none;
     89    padding: 0;
     90    margin-top: 10px;
     91  }
     92
     93  .article-list li {
     94    margin: 8px 0;
     95    padding-bottom: 8px;
     96    border-bottom: 1px solid #e0e0e0;
     97  }
     98
     99  .article-list li:last-child {
    100    border-bottom: none;
    101  }
    102
    103  .article-list a {
    104    text-decoration: none;
    105    color: #196127;
    106    font-size: 16px;
    107    transition: color 0.2s ease;
    108  }
    109
    110  .article-list a:hover {
    111    color: #7bc96f;
    112    text-decoration: underline;
    113  }
    114
    115  /* 响应式调整 */
    116  @media (max-width: 600px) {
    117    .modal-content {
    118      width: 95%;
    119      padding: 15px;
    120    }
    121
    122    .close {
    123      font-size: 24px;
    124    }
    125
    126    .article-list a {
    127      font-size: 14px;
    128    }
    129
    130    /* 移动端只显示3个月数据 */
    131    .calendar {
    132      range: [echarts.format.formatTime('yyyy-MM-dd', new Date(new Date().setMonth(new Date().getMonth() - 3))), echarts.format.formatTime('yyyy-MM-dd', new Date())];
    133    }
    134  }
    135</style>
    136
    137<!-- 热力图初始化脚本 -->
    138<script type="text/javascript">
    139  document.addEventListener("DOMContentLoaded", function() {
    140    var chartDom = document.getElementById('heatmap');
    141    var myChart = echarts.init(chartDom);
    142
    143    // 因为使用固定高度 250px,不再需要 setChartHeight / resize 处理
    144    // 也无需监听 resize,如果您想在窗口变化时自适应宽度可以保留 myChart.resize()
    145
    146    var option;
    147
    148    // 初始化 Modal
    149    var modal = document.getElementById("articleModal");
    150    var span = document.getElementsByClassName("close")[0];
    151
    152    // 点击 (x) 关闭 Modal
    153    span.onclick = function() {
    154      modal.style.display = "none";
    155    }
    156
    157    // 点击模态外部关闭
    158    window.onclick = function(event) {
    159      if (event.target == modal) {
    160        modal.style.display = "none";
    161      }
    162    }
    163
    164    // 从 posts_heatmap.json 获取数据
    165    fetch("/data/posts_heatmap.json")
    166      .then(response => {
    167        if (!response.ok) {
    168          throw new Error("Network response was not ok " + response.statusText);
    169        }
    170        return response.json();
    171      })
    172      .then(data => {
    173        // 准备 ECharts 的数据
    174        var heatmapData = [];
    175        for (const [date, categories] of Object.entries(data)) {
    176          heatmapData.push([date, categories.totalWords]);
    177        }
    178
    179        // 计算最近日期范围(桌面端6个月,移动端3个月)
    180        var endDate = new Date();
    181        var startDate = new Date();
    182        var isMobile = window.matchMedia('(max-width: 600px)').matches;
    183        startDate.setMonth(startDate.getMonth() - (isMobile ? 3 : 9));
    184        var startDateStr = echarts.format.formatTime('yyyy-MM-dd', startDate);
    185        var endDateStr = echarts.format.formatTime('yyyy-MM-dd', endDate);
    186
    187        option = {
    188          title: {
    189            show: false,
    190            text: '摸鱼日记',
    191            left: 'center',
    192            top: 20,
    193            textStyle: {
    194              fontSize: 18,
    195              fontWeight: 'bold',
    196              color: '#333'
    197            }
    198          },
    199          tooltip: {
    200            trigger: 'item',
    201            formatter: function (params) {
    202              const date = params.data[0];
    203              const totalWords = params.data[1];
    204              const postCount = data[date] ? data[date].posts.length : 0;
    205              const momentCount = data[date] ? data[date].momentCount : 0;
    206
    207              let tooltipContent = `<strong>${date}</strong><br/>总字数: ${totalWords} 千字`;
    208              if (postCount > 0) tooltipContent += `<br/>文章数量: ${postCount}`;
    209              if (momentCount > 0) tooltipContent += `<br/>瞬间数量: ${momentCount}`;
    210              return tooltipContent;
    211            }
    212          },
    213          visualMap: {
    214            show: false,
    215            min: 0,
    216            max: 1, // 根据实际数据范围调
    217            type: 'piecewise',
    218            orient: 'horizontal',
    219            left: 'center',
    220            top: 60,
    221            inRange: {
    222              // GitHub 风格绿色梯度
    223              color: ['#c6e48b', '#7bc96f', '#239a3b', '#196127']
    224            },
    225            splitNumber: 4,
    226            text: ['', ''],
    227            showLabel: true,
    228            itemGap: 20,
    229            padding: [0, 0, 10, 0]
    230          },
    231          calendar: {
    232            top: 50,
    233            left: 20,
    234            right: 4,
    235            cellSize: ['auto', 20],
    236            range: [startDateStr, endDateStr],
    237            itemStyle: {
    238              color: '#f5f5f500',
    239              borderWidth: 1,
    240              borderColor: '#e6e6e675',
    241              borderRadius: 0,
    242              shadowBlur: 1,
    243              shadowColor: 'rgba(0,0,0,0.1)'
    244            },
    245            yearLabel: { show: true },
    246            monthLabel: { nameMap: 'cn', color: '#999', fontSize: 12 },
    247            dayLabel: {
    248              firstDay: 0,
    249              nameMap: ['日', '一', '二', '三', '四', '五', '六'],
    250              color: '#999',
    251              fontSize: 12
    252            },
    253            splitLine: {
    254              lineStyle: {
    255                color: 'rgba(0, 0, 0, 0.0)',
    256              }
    257            }
    258          },
    259          series: {
    260            type: 'heatmap',
    261            coordinateSystem: 'calendar',
    262            data: heatmapData,
    263          }
    264        };
    265        myChart.setOption(option);
    266
    267        // 添加点击事件,显示 Modal
    268        myChart.on('click', function(params) {
    269          if (params.componentType === 'series') {
    270            const date = params.data[0];
    271            const postCount = data[date] ? data[date].posts.length : 0;
    272            if (data[date] && postCount > 0) {
    273              document.getElementById('modalDate').innerText = date;
    274              var postsList = document.getElementById('modalPosts');
    275              postsList.innerHTML = '';
    276
    277              // 列出当日的所有 posts
    278              data[date].posts.forEach(function(post) {
    279                var li = document.createElement('li');
    280                li.innerHTML = `<a href="${post.link}" target="_blank">${post.title}${post.words}千字</a>`;
    281                postsList.appendChild(li);
    282              });
    283              modal.style.display = "block";
    284            }
    285          }
    286        });
    287      })
    288      .catch(error => {
    289        console.error("加载 posts_heatmap.json 时出错:", error);
    290      });
    291  });
    292</script>

    Step 3 - 修改 hugo.yaml

    hugo.yaml最后添加:

    yaml
    1markup:
    2  goldmark:
    3    renderer:
    4      unsafe: true

    Step 4 - 将热力图插入首页

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

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

    我的layouts/partials/home-info.html

    html
     1{{- with site.Params.homeInfoParams }}
     2<article class="first-entry home-info">
     3    <header class="entry-header">
     4        <h1>{{ .Title | markdownify }}</h1>
     5    </header>
     6    <section class="heatmap-section">
     7        {{ partial "heatmap.html" . }}
     8    </section>
     9    <div class="entry-content">
    10        {{ .Content | markdownify }}
    11    </div>
    12    <footer class="entry-footer">
    13        {{ partial "social_icons.html" (dict "align" site.Params.homeInfoParams.AlignSocialIconsTo) }}
    14    </footer>
    15</article>
    16{{- end -}}

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

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

    requirements.txt

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

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

    Responsive Image