制作hexo热力图

又是cyx!告诉了我有个人的博客做了热力图。我一开始没想做,因为我博文更新没那么勤,热力图做出来恐怕不会好看。但是到了晚上,我的折腾之心开始蠢蠢欲动!于是喊GPT帮我做。当天晚上debug到凌晨四点,还是没能做出来。今天跟cyx喝了个咖啡,回来继续捣鼓,居然成了!

完整代码

话不多说,放一下我的完整代码,这是在profile.ejs文件里的,因为我想把热力图放在主页的标题上方。

<div class="container profile-container">
<div class="intro">
<div class="avatar">
<a href="<%- url_for(theme.nav.Posts) %>"><img src="<%- url_for(theme.avatar) %>"></a>
</div>
<div id="heatmap-container"></div>

<script src="https://d3js.org/d3.v5.min.js"></script>

<script>
document.addEventListener("DOMContentLoaded", function () {
function getDateBefore(days) {
var currentDate = new Date();
currentDate.setDate(currentDate.getDate() - days);
var year = currentDate.getFullYear();
var month = String(currentDate.getMonth() + 1).padStart(2, '0');
var day = String(currentDate.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
<%
function convertWordCount(wordCountString) {
if (!wordCountString) {
return 0;
}

// Convert to string and check if the word count string contains 'k'
var lowerCaseString = String(wordCountString).toLowerCase();

if (lowerCaseString.includes('k')) {
return parseFloat(lowerCaseString) * 1000;
} else {
return parseFloat(lowerCaseString);
}
}
%>

var data = [
<% site.posts.each(function (post) { %>
{
date: "<%= post.date.format('YYYY-MM-DD') %>",
word_count: <%= convertWordCount(getWordCount(post.content)) %>,
link: "<%= url_for(post.path) %>"
},
<% }); %>
];


var margin = { top: 20, right: 20, bottom: 20, left: 20 };
var containerWidth = 600;
var cellSize = Math.min((containerWidth - margin.left - margin.right) / 45, (containerWidth - margin.top - margin.bottom) / 8);
var width = cellSize * 45;
var height = cellSize * 8;

var svg = d3
.select("#heatmap-container")
.append("svg")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom)
.append("g")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");

var xScale = d3.scaleBand().range([width, 0]).padding(0.1);
var yScale = d3.scaleBand().range([height, 0]).padding(0.1);

var exponent = 0.3; // Adjust the exponent as needed
var colorScale = d3.scaleSequential(d3.interpolate("lightblue", "#2d96bd"))
.domain([1, Math.pow(d3.max(data, function (d) { return d.word_count; }), exponent)]);

xScale.domain(d3.range(45));
yScale.domain(d3.range(8));

// 在渲染每个格子时设置渐变色的值
var cells = svg.selectAll(".cell")
.data(d3.cross(d3.range(8), d3.range(45)))
.enter().append("a")
.attr("href", function (d) {
var currentDate = getDateBefore(d[0] * 45 + d[1]);
var correspondingData = data.find(entry => entry.date === currentDate);
return correspondingData ? correspondingData.link : "#"; // 设置链接,如果没有链接就是 "#",即当前页面
})
.append("rect")
.attr("class", function (d) {
var currentDate = getDateBefore(d[0] * 45 + d[1]);
var correspondingData = data.find(entry => entry.date === currentDate);
return "cell" + (correspondingData && correspondingData.word_count > 0 ? " blue" : "");
})


.attr("x", function (d) { return xScale(d[1]); })
.attr("y", function (d) { return yScale(d[0]); })
.attr("width", cellSize)
.attr("height", cellSize)
.style("fill", function (d) {
var currentDate = getDateBefore(d[0] * 45 + d[1]);
var correspondingData = data.find(entry => entry.date === currentDate);
return correspondingData ? colorScale(Math.pow(correspondingData.word_count, exponent)) : "#ccc";
})
.attr("rx", 4)
.attr("ry", 4)
.attr("data-word_count", function (d) {
var currentDate = getDateBefore(d[0] * 45 + d[1]);
var correspondingData = data.find(entry => entry.date === currentDate);
return correspondingData ? correspondingData.word_count : null;
})
.on("click", function (event, d) {
var currentDate = getDateBefore(d[0] * 45 + d[1]);
var correspondingData = data.find(entry => entry.date === currentDate);
if (correspondingData && correspondingData.link) {
window.location.href = correspondingData.link;
}
});


function updateCellStyles() {
var isDarkTheme = document.body.classList.contains("dark-theme");
cells.style("stroke", isDarkTheme ? "#292a2d" : "#fff")
.style("stroke-width", "1px");
}

updateCellStyles();

document.body.addEventListener("themechange", function () {
updateCellStyles();
});
});
</script>

<style>
@media (max-width: 767px) {
#heatmap-container {
display: none;
}
}

/* 在桌面端时隐藏 .avatar */
@media (min-width: 768px) {
.avatar {
display: none;
}
}

.cell {
stroke: #fff !important;
stroke-width: 1px;
fill: #ccc;
cursor: default;
/* 添加这一行 */
}

.dark-theme .cell {
stroke: #292a2d !important;
stroke-width: 1px;
fill: #a9a9b3;
cursor: default;
/* 添加这一行 */
}

.blue {
cursor: pointer;
}

.dark-theme .blue {
cursor: pointer;
}
</style>


<div class="nickname"><%- theme.nickname %></div>
<div class="description"><%- markdown(theme.description) %></div>
<div class="links">
<% if (theme.links !==undefined) { %>
<% for (var key in theme.links){ %>
<a class="link-item" title="<%- key %>" href="<%= theme.links[key] %>">
<% if(theme.links_text_enable) { %>
<%= key %>
<% } %>
<% if(theme.links_icon_enable){ %>
<i class="iconfont icon-<%- key.toLowerCase() %>"></i>
<% } %>
</a>
<% } %>
<% } %>
</div>
</div>
</div>

思路

那个博主用的是echart库,我捣鼓了半天也未能成功,于是改用GPT推荐的D3库。然后我不想添加热力图标题、月份和星期等等文字内容,以适应我博客的极简风格,所以就没有加入这些东西,只设置了8*45=360个格子,展示我约一年来发博的情况。没有文章的时候为灰色,有文章的时候根据文章的字数显示为渐变的蓝色,最深为#2d96bd色,最浅为lightblue色。

此外,由于手机上显示的状况不佳,我希望在屏幕宽度为768像素以下时,不显示热力图,而显示我的照片:

@media (max-width: 767px) {
#heatmap-container {
display: none;
}
}

/* 在桌面端时隐藏 .avatar */
@media (min-width: 768px) {
.avatar {
display: none;
}
}

关于数据的抓取,我是用如下代码实现的:

var data = [
<% page.posts.each(function (post) { %>
{
date: "<%= post.date.format('YYYY-MM-DD') %>",
word_count: <%= convertWordCount(getWordCount(post.content)) %>,
link: "<%= url_for(post.path) %>"
},
<% }); %>
];

其中,getWordCount函数是hexo-wordcount插件里带的,如果想复刻我的代码,需要先安装这个插件。

其实一开始就想能够自动抓取了,但是一直不成功,于是改成了手动输入。但是又太麻烦,就又开始捣鼓自动抓取的事情。但无论怎么弄都成功不了,GPT也找不出问题。于是求助我男朋友。他发现了两个错误:(1)我抓取的字数格式是形如2.8k的,而需要的数据是形如2800的。(2)我在代码的有些地方用了words,在另一些地方用了word_count(估计是我开了很多新窗口让GPT给了我很多次代码,然后搞混了)。改正这两个错误后,热力图就正常显示了。(在此感谢!)

还有一些细节:设置了点击跳转的逻辑后,我发现鼠标放在灰色的格子上时,也会变成手指的样式。我希望它在且仅在蓝色的格子上时才变成手指。代码里有一些是做这个事情的:

.blue {
cursor: pointer;
}

.dark-theme .blue {
cursor: pointer;
}

我部署上去后,在夜间模式是没效果的,所以加了后一段。但是我感觉按道理应该没必要加的?

以下这段代码是用来设置格子边框的样式的:

.cell {
stroke: #fff !important;
stroke-width: 1px;
fill: #ccc;
cursor: default;
/* 添加这一行 */
}

.dark-theme .cell {
stroke: #292a2d !important;
stroke-width: 1px;
fill: #a9a9b3;
cursor: default;
/* 添加这一行 */
}

因为在夜间模式的时候,格子边框起初也是白色,看起来很丑。这个也弄了很久,要么就是日间模式和夜间模式的框全变白,要么就全变黑。最后不记得是怎么解决的了。

热力图做好后,发现由于一篇文章的字数太多,其他文章的蓝色就变得很浅,而且只有微妙的区别。于是叫GPT修改代码:

document.addEventListener("DOMContentLoaded", function () {
// ... (previous code)

var exponent = 0.3; // Adjust the exponent as needed
var colorScale = d3.scaleSequential(d3.interpolate("lightblue", "#2d96bd"))
.domain([1, Math.pow(d3.max(data, function (d) { return d.word_count; }), exponent)]);

// ... (remaining code)

var cells = svg.selectAll(".cell")
.data(d3.cross(d3.range(8), d3.range(45)))
.enter().append("a")
// ... (remaining code)
.style("fill", function (d) {
var currentDate = getDateBefore(d[0] * 45 + d[1]);
var correspondingData = data.find(entry => entry.date === currentDate);
return correspondingData ? colorScale(Math.pow(correspondingData.word_count, exponent)) : "#ccc";
})
// ... (remaining code)
});

我也看不懂,反正能跑。

至于D3相关代码,我是一点也不懂,就不妄加评论了。

就这样吧,想到什么再写!

微调(2024-1-27)

稍微调整了一下,把exponent改成了0.6,把lightblue改成了#add8e6。修改后的完整代码如下:

<div class="container profile-container">
<div class="intro">
<div class="avatar">
<a href="<%- url_for(theme.nav.Posts) %>"><img src="<%- url_for(theme.avatar) %>"></a>
</div>
<div id="heatmap-container"></div>

<script src="https://d3js.org/d3.v5.min.js"></script>

<script>
document.addEventListener("DOMContentLoaded", function () {
function getDateBefore(days) {
var currentDate = new Date();
currentDate.setDate(currentDate.getDate() - days);
var year = currentDate.getFullYear();
var month = String(currentDate.getMonth() + 1).padStart(2, '0');
var day = String(currentDate.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
<%
function convertWordCount(wordCountString) {
if (!wordCountString) {
return 0;
}

// Convert to string and check if the word count string contains 'k'
var lowerCaseString = String(wordCountString).toLowerCase();

if (lowerCaseString.includes('k')) {
return parseFloat(lowerCaseString) * 1000;
} else {
return parseFloat(lowerCaseString);
}
}
%>

var data = [
<% site.posts.each(function (post) { %>
{
date: "<%= post.date.format('YYYY-MM-DD') %>",
word_count: <%= convertWordCount(getWordCount(post.content)) %>,
link: "<%= url_for(post.path) %>"
},
<% }); %>
];


var margin = { top: 20, right: 20, bottom: 20, left: 20 };
var containerWidth = 600;
var cellSize = Math.min((containerWidth - margin.left - margin.right) / 45, (containerWidth - margin.top - margin.bottom) / 8);
var width = cellSize * 45;
var height = cellSize * 8;

var svg = d3
.select("#heatmap-container")
.append("svg")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom)
.append("g")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");

var xScale = d3.scaleBand().range([width, 0]).padding(0.1);
var yScale = d3.scaleBand().range([height, 0]).padding(0.1);

var exponent = 0.6; // Adjust the exponent as needed
var colorScale = d3.scaleSequential(d3.interpolate("#add8e6", "#2d96bd"))
.domain([1, Math.pow(d3.max(data, function (d) { return d.word_count; }), exponent)]);

xScale.domain(d3.range(45));
yScale.domain(d3.range(8));

// 在渲染每个格子时设置渐变色的值
var cells = svg.selectAll(".cell")
.data(d3.cross(d3.range(8), d3.range(45)))
.enter().append("a")
.attr("href", function (d) {
var currentDate = getDateBefore(d[0] * 45 + d[1]);
var correspondingData = data.find(entry => entry.date === currentDate);
return correspondingData ? correspondingData.link : "#"; // 设置链接,如果没有链接就是 "#",即当前页面
})
.append("rect")
.attr("class", function (d) {
var currentDate = getDateBefore(d[0] * 45 + d[1]);
var correspondingData = data.find(entry => entry.date === currentDate);
return "cell" + (correspondingData && correspondingData.word_count > 0 ? " blue" : "");
})


.attr("x", function (d) { return xScale(d[1]); })
.attr("y", function (d) { return yScale(d[0]); })
.attr("width", cellSize)
.attr("height", cellSize)
.style("fill", function (d) {
var currentDate = getDateBefore(d[0] * 45 + d[1]);
var correspondingData = data.find(entry => entry.date === currentDate);
return correspondingData ? colorScale(Math.pow(correspondingData.word_count, exponent)) : "#ccc";
})
.attr("rx", 4)
.attr("ry", 4)
.attr("data-word_count", function (d) {
var currentDate = getDateBefore(d[0] * 45 + d[1]);
var correspondingData = data.find(entry => entry.date === currentDate);
return correspondingData ? correspondingData.word_count : null;
})
.on("click", function (event, d) {
var currentDate = getDateBefore(d[0] * 45 + d[1]);
var correspondingData = data.find(entry => entry.date === currentDate);
if (correspondingData && correspondingData.link) {
window.location.href = correspondingData.link;
}
});


function updateCellStyles() {
var isDarkTheme = document.body.classList.contains("dark-theme");
cells.style("stroke", isDarkTheme ? "#292a2d" : "#fff")
.style("stroke-width", "1px");
}

updateCellStyles();

document.body.addEventListener("themechange", function () {
updateCellStyles();
});
});
</script>

<style>
@media (max-width: 767px) {
#heatmap-container {
display: none;
}
}

/* 在桌面端时隐藏 .avatar */
@media (min-width: 768px) {
.avatar {
display: none;
}
}

.cell {
stroke: #fff !important;
stroke-width: 1px;
fill: #ccc;
cursor: default;
/* 添加这一行 */
}

.dark-theme .cell {
stroke: #292a2d !important;
stroke-width: 1px;
fill: #a9a9b3;
cursor: default;
/* 添加这一行 */
}

.blue {
cursor: pointer;
}

.dark-theme .blue {
cursor: pointer;
}
</style>


<div class="nickname"><%- theme.nickname %></div>
<div class="description"><%- markdown(theme.description) %></div>
<div class="links">
<% if (theme.links !==undefined) { %>
<% for (var key in theme.links){ %>
<a class="link-item" title="<%- key %>" href="<%= theme.links[key] %>">
<% if(theme.links_text_enable) { %>
<%= key %>
<% } %>
<% if(theme.links_icon_enable){ %>
<i class="iconfont icon-<%- key.toLowerCase() %>"></i>
<% } %>
</a>
<% } %>
<% } %>
</div>
</div>
</div>

鼠标悬停在格子上时,显示文章标题和日期

因为原有的显示模式太不直观了,不知道哪个格子代表哪一天,又不想做成表格的形式,于是出此下策。效果如图:

效果图

修改后的代码如下:

<div class="container profile-container">
<div class="intro">
<div class="avatar">
<a href="<%- url_for(theme.nav.Posts) %>"><img src="<%- url_for(theme.avatar) %>"></a>
</div>
<div id="heatmap-container">
<div id="tooltip"></div>
</div>


<script src="https://d3js.org/d3.v5.min.js"></script>

<script>
document.addEventListener("DOMContentLoaded", function () {
function getDateBefore(days) {
var currentDate = new Date();
currentDate.setDate(currentDate.getDate() - days);
var year = currentDate.getFullYear();
var month = String(currentDate.getMonth() + 1).padStart(2, '0');
var day = String(currentDate.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
<%
function convertWordCount(wordCountString) {
if (!wordCountString) {
return 0;
}

// Convert to string and check if the word count string contains 'k'
var lowerCaseString = String(wordCountString).toLowerCase();

if (lowerCaseString.includes('k')) {
return parseFloat(lowerCaseString) * 1000;
} else {
return parseFloat(lowerCaseString);
}
}
%>

var data = [
<% site.posts.each(function (post) { %>
{
date: "<%= post.date.format('YYYY-MM-DD') %>",
word_count: <%= convertWordCount(getWordCount(post.content)) %>,
link: "<%= url_for(post.path) %>",
title: "<%= post.title %>"
},
<% }); %>
];


var margin = { top: 20, right: 20, bottom: 20, left: 20 };
var containerWidth = 600;
var cellSize = Math.min((containerWidth - margin.left - margin.right) / 45, (containerWidth - margin.top - margin.bottom) / 8);
var width = cellSize * 45;
var height = cellSize * 8;

var svg = d3
.select("#heatmap-container")
.append("svg")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom)
.append("g")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");

var xScale = d3.scaleBand().range([width, 0]).padding(0.1);
var yScale = d3.scaleBand().range([height, 0]).padding(0.1);

var exponent = 0.3; // Adjust the exponent as needed
var colorScale = d3.scaleSequential(d3.interpolate("lightblue", "#2d96bd"))
.domain([1, Math.pow(d3.max(data, function (d) { return d.word_count; }), exponent)]);

xScale.domain(d3.range(45));
yScale.domain(d3.range(8));

// 在渲染每个格子时设置渐变色的值
var cells = svg.selectAll(".cell")
.data(d3.cross(d3.range(8), d3.range(45)))
.enter().append("a")
.attr("href", function (d) {
var currentDate = getDateBefore(d[0] * 45 + d[1]);
var correspondingData = data.find(entry => entry.date === currentDate);
return correspondingData ? correspondingData.link : "#";
})
.append("rect")
.attr("class", function (d) {
var currentDate = getDateBefore(d[0] * 45 + d[1]);
var correspondingData = data.find(entry => entry.date === currentDate);
return "cell" + (correspondingData && correspondingData.word_count > 0 ? " blue" : "");
})
.attr("x", function (d) { return xScale(d[1]); })
.attr("y", function (d) { return yScale(d[0]); })
.attr("width", cellSize)
.attr("height", cellSize)
.style("fill", function (d) {
var currentDate = getDateBefore(d[0] * 45 + d[1]);
var correspondingData = data.find(entry => entry.date === currentDate);
return correspondingData ? colorScale(Math.pow(correspondingData.word_count, exponent)) : "#ccc";
})
.attr("rx", 4)
.attr("ry", 4)
.attr("data-word_count", function (d) {
var currentDate = getDateBefore(d[0] * 45 + d[1]);
var correspondingData = data.find(entry => entry.date === currentDate);
return correspondingData ? correspondingData.word_count : null;
})
.attr("title", function (d) {
var currentDate = getDateBefore(d[0] * 45 + d[1]);
var correspondingData = data.find(entry => entry.date === currentDate);
return correspondingData ? currentDate + "\n" + correspondingData.title : "";
})
.on("click", function (event, d) {
var currentDate = getDateBefore(d[0] * 45 + d[1]);
var correspondingData = data.find(entry => entry.date === currentDate);

if (correspondingData && correspondingData.link) {
// 点击直接跳转
window.location.href = correspondingData.link;
}
})
.on("mouseover", function (event, d) {
var title = d3.select(this).attr("title");
if (title) {
var tooltip = d3.select("#tooltip");
// 显示日期和标题
tooltip.transition()
.duration(200)
.style("opacity", 1);
var tooltipContent = title;
tooltip.html(tooltipContent);

// 获取蓝色格子的位置
var cellBoundingBox = this.getBoundingClientRect();

// 计算 tooltip 的位置,使其中心线与蓝色格子的中心线对齐
var tooltipWidth = tooltip.node().offsetWidth;
var xPosition = cellBoundingBox.left + cellBoundingBox.width / 2;
var yPosition = cellBoundingBox.top;
tooltip.style("left", xPosition + "px")
.style("top", yPosition + "px");
}
})

.on("mouseout", function (event, d) {
// 隐藏 tooltip
var tooltip = d3.select("#tooltip");
tooltip.transition()
.duration(200)
.style("opacity", 0)
.on("end", function () {
// 清除 tooltip 内容
tooltip.html("");
});
});

function updateCellStyles() {
var isDarkTheme = document.body.classList.contains("dark-theme");
cells.style("stroke", isDarkTheme ? "#292a2d" : "#fff")
.style("stroke-width", "1px");
}

updateCellStyles();

document.body.addEventListener("themechange", function () {
updateCellStyles();
});
});


</script>

<style>
@media (max-width: 767px) {
#heatmap-container {
display: none;
}
}

/* 在桌面端时隐藏 .avatar */
@media (min-width: 768px) {
.avatar {
display: none;
}
}

.cell {
stroke: #fff !important;
stroke-width: 1px;
fill: #ccc;
cursor: default;
/* 添加这一行 */
}

.dark-theme .cell {
stroke: #292a2d !important;
stroke-width: 1px;
fill: #a9a9b3;
cursor: default;
/* 添加这一行 */
}

.blue {
cursor: pointer;
}

.dark-theme .blue {
cursor: pointer;
}

#tooltip {
position: absolute;
background-color: white;
border: 1px solid #a9a9b3;
padding: 3px;
/* 调整文字与边框的间距 */
opacity: 0;
font-size: 10px;
/* 调整字体大小 */
transform: translate(-50%, -100%);
/* 将框定位到正上方居中 */
line-height: 1;
}

.dark-theme #tooltip {
background-color: #292a2d;
}
</style>

<div id="tooltip"></div>

<div class="nickname"><%- theme.nickname %></div>
<div class="description"><%- markdown(theme.description) %></div>
<div class="links">
<% if (theme.links !==undefined) { %>
<% for (var key in theme.links){ %>
<a class="link-item" title="<%- key %>" href="<%= theme.links[key] %>">
<% if(theme.links_text_enable) { %>
<%= key %>
<% } %>
<% if(theme.links_icon_enable){ %>
<i class="iconfont icon-<%- key.toLowerCase() %>"></i>
<% } %>
</a>
<% } %>
<% } %>
</div>
</div>
</div>