实现功能:
根据颜色深浅表现当日字数的多少 鼠标悬停展示当日总字数、文章数和瞬间数 点击弹出当天写的文章列表
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个月数据。
增加响应式设计,并在移动端只显示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">×</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 自动化构建
- 在项目根目录添加依赖项文件:
requirements.txt
jieba
python-frontmatter
python-dateutil
- 修改 Cloudflare 构建配置:
构建命令:pip install -r requirements.txt && python3 heatmap.py && hugo