实现功能:
根据颜色深浅表现当日字数的多少 鼠标悬停展示当日总字数、文章数和瞬间数 点击弹出当天写的文章列表
Step 1 - 使用脚本生成 json 文件
heatmap.py(我放在网站项目根目录)
python
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
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个月数据。
增加响应式设计,并在移动端只显示3个月数据。
在layouts/partials
文件夹内添加heatmap.html
文件:
layouts/partials/heatmap.html
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">×</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
yaml
1markup:
2 goldmark:
3 renderer:
4 unsafe: true
Step 4 - 将热力图插入首页
在layouts/partials/home-info.html
中插入:
html
html
1 <section class="heatmap-section">
2 {{ partial "heatmap.html" . }}
3 </section>
我的layouts/partials/home-info.html
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 自动化构建
- 在项目根目录添加依赖项文件:
requirements.txt
text
text
1jieba
2python-frontmatter
3python-dateutil
- 修改 Cloudflare 构建配置:
构建命令:pip install -r requirements.txt && python3 heatmap.py && hugo