Mobile wallpaper 1Mobile wallpaper 2Mobile wallpaper 3Mobile wallpaper 4Mobile wallpaper 5Mobile wallpaper 6
1070 字
5 分钟
周末做了个小玩意:GitHub 贡献图生成器
2026-02-01
统计加载中...

起因#

周末闲着没事,想给博客加个”代码贡献统计”的页面。GitHub 自带的贡献图挺好看,但样式固定,不能自定义。

于是花了一个下午,用 Canvas API 撸了个生成器。

效果预览#

贡献图示例

支持的功能:

  • ✅ 自定义颜色主题(GitHub 绿、蓝色、紫色等)
  • ✅ 调整格子大小和间距
  • ✅ 显示/隐藏月份标签
  • ✅ 导出为 PNG 或 SVG
  • ✅ 响应式设计,移动端友好

技术实现#

数据结构#

首先定义贡献数据的结构:

interface Contribution {
date: string; // 'YYYY-MM-DD'
count: number; // 贡献次数
level: 0 | 1 | 2 | 3 | 4; // 颜色等级
}
interface ContributionData {
contributions: Contribution[];
totalCount: number;
startDate: string;
endDate: string;
}

Canvas 绘制#

核心绘制逻辑:

class ContributionGraph {
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
private cellSize = 10;
private cellGap = 2;
private colors = {
0: "#161b22", // 无贡献
1: "#0e4429", // 1-3 次
2: "#006d32", // 4-6 次
3: "#26a641", // 7-9 次
4: "#39d353", // 10+ 次
};
constructor(canvas: HTMLCanvasElement) {
this.canvas = canvas;
this.ctx = canvas.getContext("2d")!;
}
draw(data: ContributionData) {
const weeks = this.groupByWeek(data.contributions);
const width = weeks.length * (this.cellSize + this.cellGap);
const height = 7 * (this.cellSize + this.cellGap);
this.canvas.width = width;
this.canvas.height = height;
weeks.forEach((week, weekIndex) => {
week.forEach((day, dayIndex) => {
const x = weekIndex * (this.cellSize + this.cellGap);
const y = dayIndex * (this.cellSize + this.cellGap);
this.ctx.fillStyle = this.colors[day.level];
this.ctx.fillRect(x, y, this.cellSize, this.cellSize);
});
});
}
private groupByWeek(contributions: Contribution[]) {
// 按周分组逻辑
const weeks: Contribution[][] = [];
let currentWeek: Contribution[] = [];
contributions.forEach((contrib, index) => {
const date = new Date(contrib.date);
const dayOfWeek = date.getDay();
if (dayOfWeek === 0 && currentWeek.length > 0) {
weeks.push(currentWeek);
currentWeek = [];
}
currentWeek.push(contrib);
});
if (currentWeek.length > 0) {
weeks.push(currentWeek);
}
return weeks;
}
}

交互功能#

添加鼠标悬停提示:

canvas.addEventListener("mousemove", (e) => {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const weekIndex = Math.floor(x / (cellSize + cellGap));
const dayIndex = Math.floor(y / (cellSize + cellGap));
const contribution = weeks[weekIndex]?.[dayIndex];
if (contribution) {
showTooltip(e.clientX, e.clientY, {
date: contribution.date,
count: contribution.count,
});
} else {
hideTooltip();
}
});

导出功能#

导出为 PNG:

function exportToPNG() {
const link = document.createElement("a");
link.download = "contribution-graph.png";
link.href = canvas.toDataURL("image/png");
link.click();
}

导出为 SVG(更清晰):

function exportToSVG(data: ContributionData) {
const weeks = groupByWeek(data.contributions);
const width = weeks.length * (cellSize + cellGap);
const height = 7 * (cellSize + cellGap);
let svg = `<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">`;
weeks.forEach((week, weekIndex) => {
week.forEach((day, dayIndex) => {
const x = weekIndex * (cellSize + cellGap);
const y = dayIndex * (cellSize + cellGap);
const color = colors[day.level];
svg += `<rect x="${x}" y="${y}" width="${cellSize}" height="${cellSize}" fill="${color}" />`;
});
});
svg += "</svg>";
const blob = new Blob([svg], { type: "image/svg+xml" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.download = "contribution-graph.svg";
link.href = url;
link.click();
}

优化点#

1. 性能优化#

最开始每次鼠标移动都重绘整个 Canvas,很卡。优化后只在必要时重绘:

let lastHoveredCell: [number, number] | null = null;
canvas.addEventListener("mousemove", (e) => {
const cell = getCellAtPosition(e.clientX, e.clientY);
if (!isSameCell(cell, lastHoveredCell)) {
redrawHighlight(cell);
lastHoveredCell = cell;
}
});

2. 响应式设计#

根据容器宽度自动调整格子大小:

function calculateCellSize(containerWidth: number, weeks: number) {
const availableWidth = containerWidth - 40; // 留出边距
const totalGaps = (weeks - 1) * cellGap;
return Math.floor((availableWidth - totalGaps) / weeks);
}

3. 主题切换#

支持多种配色方案:

const themes = {
github: {
0: "#161b22",
1: "#0e4429",
2: "#006d32",
3: "#26a641",
4: "#39d353",
},
blue: {
0: "#161b22",
1: "#0a3069",
2: "#0969da",
3: "#54aeff",
4: "#79c0ff",
},
purple: {
0: "#161b22",
1: "#3d1f47",
2: "#6e40aa",
3: "#a06cd5",
4: "#d896ff",
},
};

使用方法#

安装#

npm install github-contribution-graph

基础用法#

import { ContributionGraph } from "github-contribution-graph";
const canvas = document.getElementById("graph") as HTMLCanvasElement;
const graph = new ContributionGraph(canvas);
// 从 GitHub API 获取数据
const data = await fetchGitHubContributions("username");
graph.draw(data);

自定义配置#

const graph = new ContributionGraph(canvas, {
cellSize: 12,
cellGap: 3,
theme: "blue",
showMonthLabels: true,
showWeekdayLabels: true,
});

遇到的坑#

坑1:日期计算#

JavaScript 的 Date 对象有很多坑,特别是时区问题。最后用了 date-fns 库:

import { startOfWeek, endOfWeek, eachDayOfInterval } from "date-fns";
const days = eachDayOfInterval({
start: startOfWeek(startDate),
end: endOfWeek(endDate),
});

坑2:Canvas 模糊#

在高 DPI 屏幕上,Canvas 会模糊。解决方案:

const dpr = window.devicePixelRatio || 1;
canvas.width = width * dpr;
canvas.height = height * dpr;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
ctx.scale(dpr, dpr);

坑3:SVG 导出中文乱码#

SVG 导出时,中文会乱码。需要指定编码:

const blob = new Blob([svg], {
type: "image/svg+xml;charset=utf-8",
});

后续计划#

  • 支持更多主题(暗黑模式、彩虹色等)
  • 添加动画效果(渐入、波浪等)
  • 支持自定义数据源(不只是 GitHub)
  • 做成 Web Component,方便集成
  • 发布到 npm

开源地址#

代码已开源:github.com/example/contribution-graph

欢迎 star 和 PR!

在线体验#

做了个在线 Demo:contribution-graph.demo.com

可以直接输入 GitHub 用户名生成贡献图。


技术栈:

  • TypeScript
  • Canvas API
  • date-fns
  • Vite

开发时间:

  • 核心功能:4 小时
  • 优化和测试:2 小时
  • 文档和 Demo:2 小时

总共一个周末搞定,还是挺有成就感的 😄

周末做了个小玩意:GitHub 贡献图生成器
https://sylviz.cn/posts/16/
作者
kiwi
发布于
2026-02-01
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

封面
Sample Song
Sample Artist
封面
Sample Song
Sample Artist
0:00 / 0:00