feat: add wordcount heatmap

This commit is contained in:
草师傅 2025-08-06 19:04:29 +08:00
parent 8773b9b0df
commit b79e3e2e47
Signed by: gb
GPG key ID: 43330A030E2D6478
9 changed files with 351 additions and 1 deletions

View file

@ -0,0 +1,155 @@
---
// If you want to fetch data from GitHub or Forgejo, uncomment the lines containing the GitHub/Forgejo.
// Since echarts does not support different color for different data sources,
// we will use only local data for now.
//import { fetchDataForAllYears } from "../../plugins/heatmapdata/github";
//import { fetchForgejoData } from "../../plugins/heatmapdata/forgejo";
import { generateLocalData } from "../../plugins/heatmapdata/local";
interface Props {
GithubUserName?: string;
ForgejoInfo?: {
instance: string;
username: string;
}[];
}
const { GithubUserName, ForgejoInfo } = Astro.props;
//const GithubData = GithubUserName ? await fetchDataForAllYears(GithubUserName) : null;
//const ForgejoData = ForgejoInfo ? (await Promise.all(ForgejoInfo.map(info => fetchForgejoData(info.instance, info.username)))) : null;
const LocalData = await generateLocalData();
const allData = [
// {
// type: "heatmap",
// coordinateSystem: 'calendar',
// data: GithubData
//
// },
// {
// type: "heatmap",
// coordinateSystem: 'calendar',
// data: ForgejoData
// },
{
type: "heatmap",
coordinateSystem: 'calendar',
data: LocalData
}
]
---
<div class="heatmap">
<div class="chart" id="heatmap-chart"></div>
<div data-chartdata={JSON.stringify(allData)} id="heatmap-data" class="hidden">
<!-- This div holds the chart data in JSON format for ECharts to use -->
</div>
</div>
<script>
import * as echarts from 'echarts';
console.log(JSON.parse(document.getElementById('heatmap-data')?.getAttribute('data-chartdata')))
function setupHeatmap() {
const chartElement = document.getElementById('heatmap-chart');
if (!chartElement) {
console.error('Heatmap chart element not found');
return;
}
const chart = echarts.init(chartElement);
let monthsAgo = 12; // One year ago
function getRangeArr() {
const windowWidth = window.innerWidth;
if (windowWidth >= 600) {
monthsAgo = 12; // One year ago
} else if (windowWidth >= 400) {
monthsAgo = 9; // Nine months ago
} else {
monthsAgo = 6;
}
}
getRangeArr();
const startDate = echarts.time.format(new Date(Date.now() - 30 * 24 * 60 * 60 * 1000 * monthsAgo),'{yyyy}-{MM}-{dd}', false); // One year before today
const endDate = echarts.time.format(new Date(),'{yyyy}-{MM}-{dd}', false)
const textColor = window.getComputedStyle(document.documentElement).getPropertyValue('--secondary-text-color') || '#000';
const options = {
title: {
top: 0,
left: 'center',
text: 'Blog Post Heatmap',
textStyle: {
color: textColor,
fontFamily: 'monospace',
}
},
tooltip: {
hideDelay: 1000,
enterable: true,
},
visualMap: {
min: 0,
max: 5,
type: 'piecewise',
orient: 'horizontal',
left: 'center',
top: 25,
inRange: {
// [floor color, ceiling color]
color: ['#90a8c0', '#486090']
},
textStyle: {
color: textColor,
fontFamily: 'monospace',
},
splitNumber: 4,
text: ['k words', ''],
showLabel: true,
itemGap: 20,
},
calendar: {
top: 80,
left: 20,
right: 4,
cellSize: ['auto', 12],
range: [startDate, endDate],
itemStyle: {
color: 'rgba(255, 255, 255, 0.3)', // transparent background
borderWidth: 1.5,
borderColor: '#fafafa',
},
dayLabel: {
color: textColor,
fontFamily: 'monospace',
},
monthLabel: {
color: textColor,
fontFamily: 'monospace',
margin: 10,
fontSize: 12,
},
yearLabel: { show: false },
// the splitline between months. set to transparent for now.
splitLine: {
lineStyle: {
color: 'rgba(0, 0, 0, 0.0)',
}
}
},
series: JSON.parse(document.getElementById('heatmap-data')?.getAttribute('data-chartdata') || '[]')
}
chart.setOption(options)
// Handle resize
window.addEventListener("resize", () => {
chart.resize();
});
}
document.addEventListener('DOMContentLoaded', setupHeatmap);
// Re-initialize when Astro's view transitions occur, this provides fix for SPA navigation
document.addEventListener('astro:page-load', setupHeatmap);
</script>
<style>
.chart {
width: 100%;
height: 200px;
}
.hidden {
display: none;
}
</style>

View file

@ -0,0 +1,7 @@
---
title: 'Now'
description: 'This is a test page'
---
import HeatMap from "../../components/shortcodes/HeatMap.astro"
<HeatMap GithubUserName='GrassBlock1' ForgejoInfo={[{instance:"codeberg.org",username:"grassblock"}]} />

View file

@ -0,0 +1,17 @@
export async function fetchForgejoData(instance, username) {
const response = await fetch(`https://${instance}/api/v1/users/${username}/heatmap`);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const data = []
const rdata = await response.json();
Object.values(rdata).forEach((s) => {
data.push([
new Date(s.timestamp * 1000).toISOString().split('T')[0], // Convert seconds to milliseconds
s.contributions,
])
})
return data
}

View file

@ -0,0 +1,94 @@
import {parse} from "ultrahtml"
import { querySelector, querySelectorAll } from "ultrahtml/selector";
async function fetchYears(username) {
const data = await fetch(`https://github.com/${username}?tab=contributions`, {
headers: {
"x-requested-with": "XMLHttpRequest"
}
});
const body = await data.text();
const rhtml = parse(body);
return querySelectorAll(rhtml,".js-year-link.filter-item")
.map((a) => {
const aEle = querySelector(a, "a");
const href = aEle.attributes.href;
const githubUrl = new URL(`https://github.com${href}`);
githubUrl.searchParams.set("tab", "contributions");
const formattedHref = `${githubUrl.pathname}${githubUrl.search}`;
return {
href: formattedHref,
text: aEle.text
};
});
}
async function fetchDataForYear(url, year, format) {
const data = await fetch(`https://github.com${url}`, {
headers: {
"x-requested-with": "XMLHttpRequest"
}
});
const rhtml = parse(await data.text());
const days = querySelectorAll(rhtml, "table.ContributionCalendar-grid td.ContributionCalendar-day");
const contribText = querySelector(rhtml,".js-yearly-contributions h2").children[0].value
.trim()
.match(/^([0-9,]+)\s/);
let contribCount;
if (contribText) {
[contribCount] = contribText;
contribCount = parseInt(contribCount.replace(/,/g, ""), 10);
}
return {
year,
total: contribCount || 0,
range: {
start: days[0].attributes['data-date'],
end: days[days.length - 1].attributes['data-date']
},
contributions: (() => {
const parseDay = (day, index) => {
const date = day.attributes['data-date'].split("-")
.map((d) => parseInt(d, 10));
const value = {
date: day.attributes['data-date'],
count: parseInt(day.attributes['data-level']) || 0
};
return { date, value };
};
if (format !== "nested") {
return days.map((day, index) => parseDay(day, index).value);
}
return days.reduce((o, day, index) => {
const { date, value } = parseDay(day, index);
const [y, m, d] = date;
if (!o[y]) o[y] = {};
if (!o[y][m]) o[y][m] = {};
o[y][m][d] = value;
return o;
}, {});
})()
};
}
export async function fetchDataForAllYears(username, format) {
const years = await fetchYears(username);
return Promise.all(
years.map((year) => fetchDataForYear(year.href, year.text, format))
).then((resp) => {
// ECharts compatible format: [[date, value], ...]
return resp
.reduce((list, curr) => [...list, ...curr.contributions], [])
.filter((item) => item.count > 0)
.sort((a, b) => {
if (a.date < b.date) return -1;
else if (a.date > b.date) return 1;
return 0;
})
.map(item => [item.date, item.count]);
});
}

View file

@ -0,0 +1,30 @@
import { getCollection, render } from 'astro:content';
async function fetchPostsData() {
const posts = await getCollection('posts');
const entriesData = {};
for (const post of posts) {
const { remarkPluginFrontmatter } = await post.render();
const dateKey = post.data.pubDate.toISOString().split('T')[0]; // "2025-07-25"
entriesData[dateKey] = {
wordCount: remarkPluginFrontmatter.wordcount.words / 1000 || 0,
link: `/blog/${post.slug}`,
title: post.data.title
};
}
return entriesData;
}
export async function generateLocalData() {
const postsData = await fetchPostsData();
const data = []
Object.entries(postsData).forEach(([dateKey, entry]) => {
data.push([
new Date(dateKey).toISOString().split('T')[0], // Convert to YYYY-MM-DD format
entry.wordCount || 0,
])
})
return data;
}

9
src/plugins/wordcount.js Normal file
View file

@ -0,0 +1,9 @@
import {toString} from 'mdast-util-to-string';
import countWords from 'reading-time'
export function remarkWordCount() {
return function (tree, { data }) {
const textOnPage = toString(tree);
data.astro.frontmatter.wordcount = countWords(textOnPage);
};
}