Skip to content

Commit 5dd8c22

Browse files
committed
feat(profile): 活跃度热力图 52 周 GitHub 风格
- 新增 ActivityHeatmap server component,无 JS - 52 列 × 7 行小格子,色阶分 5 档(0 / 1-2 / 3-5 / 6-10 / 10+),硬红(#CC0000)系列 - 数据走 leaderboard.json 的 dailyCounts(零运行时 DB 查询,和 docs git-based 一致) - 月份刻度 + 周几标签(周一/四/六) - page.tsx Bento grid 下方独立一行展示,仅当有贡献数据时渲染
1 parent 3503e39 commit 5dd8c22

1 file changed

Lines changed: 171 additions & 0 deletions

File tree

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
/**
2+
* 活跃度热力图 — GitHub 贡献图风格。
3+
* 数据源:leaderboard JSON 里的 dailyCounts(build-time 生成,零运行时 DB 查询)。
4+
*
5+
* 渲染规则:
6+
* - 固定显示过去 52 周(364 天)
7+
* - 列 = 周(左老右新),行 = 周一到周日
8+
* - 色阶按当日贡献次数分 5 档:0 / 1-2 / 3-5 / 6-10 / 10+
9+
* - 无 JS 交互,纯 server component,CSS title 原生 tooltip
10+
*/
11+
12+
interface Props {
13+
/** 键 = "YYYY-MM-DD",值 = 当日贡献次数(commits) */
14+
dailyCounts: Record<string, number>;
15+
}
16+
17+
const WEEKS = 52;
18+
const DAY_MS = 24 * 60 * 60 * 1000;
19+
20+
function getBucket(count: number): number {
21+
if (count <= 0) return 0;
22+
if (count <= 2) return 1;
23+
if (count <= 5) return 2;
24+
if (count <= 10) return 3;
25+
return 4;
26+
}
27+
28+
function formatDay(d: Date): string {
29+
return d.toISOString().slice(0, 10);
30+
}
31+
32+
export function ActivityHeatmap({ dailyCounts }: Props) {
33+
// 以今天为右边界,往前 52 周;按周对齐:从上上周日起(GitHub 图的起点)
34+
const today = new Date();
35+
today.setUTCHours(0, 0, 0, 0);
36+
37+
// 找到本周日作为右上角的终点
38+
const todayDow = today.getUTCDay(); // 0=Sun
39+
const lastSun = new Date(today.getTime() - todayDow * DAY_MS);
40+
const start = new Date(lastSun.getTime() - (WEEKS - 1) * 7 * DAY_MS);
41+
42+
// 构造 WEEKS × 7 的二维网格
43+
const grid: Array<Array<{ day: string; count: number } | null>> = [];
44+
let total = 0;
45+
let activeDays = 0;
46+
47+
for (let w = 0; w < WEEKS; w++) {
48+
const week: Array<{ day: string; count: number } | null> = [];
49+
for (let d = 0; d < 7; d++) {
50+
const date = new Date(start.getTime() + (w * 7 + d) * DAY_MS);
51+
if (date > today) {
52+
week.push(null); // 未来的格子留空
53+
continue;
54+
}
55+
const key = formatDay(date);
56+
const count = dailyCounts[key] ?? 0;
57+
total += count;
58+
if (count > 0) activeDays++;
59+
week.push({ day: key, count });
60+
}
61+
grid.push(week);
62+
}
63+
64+
// 月份标签:找到每列第一个周日的月份变化点
65+
const monthLabels: Array<{ col: number; label: string }> = [];
66+
let lastMonth = -1;
67+
for (let w = 0; w < WEEKS; w++) {
68+
const first = grid[w][0];
69+
if (!first) continue;
70+
const d = new Date(first.day);
71+
const m = d.getUTCMonth();
72+
if (m !== lastMonth) {
73+
monthLabels.push({ col: w, label: `${m + 1}月` });
74+
lastMonth = m;
75+
}
76+
}
77+
78+
return (
79+
<section className="border border-[var(--foreground)] p-6 lg:p-8 flex flex-col gap-4">
80+
<div className="flex items-baseline justify-between gap-3 flex-wrap">
81+
<div>
82+
<div className="font-mono text-[10px] uppercase tracking-widest text-neutral-500">
83+
SEC. ACTIVITY · 005
84+
</div>
85+
<h3 className="font-serif text-xl font-black uppercase mt-1 text-[var(--foreground)]">
86+
活跃度 · 最近 52 周
87+
</h3>
88+
</div>
89+
<div className="font-mono text-[11px] text-neutral-500">
90+
{activeDays.toLocaleString()} 天有贡献 · 合计 {total.toLocaleString()}{" "}
91+
commits
92+
</div>
93+
</div>
94+
95+
{/* 月份刻度 */}
96+
<div
97+
className="grid gap-[3px] text-[9px] font-mono text-neutral-500 pl-4"
98+
style={{ gridTemplateColumns: `repeat(${WEEKS}, minmax(0, 1fr))` }}
99+
>
100+
{Array.from({ length: WEEKS }).map((_, w) => {
101+
const label = monthLabels.find((m) => m.col === w);
102+
return (
103+
<div key={w} className="min-w-0">
104+
{label ? label.label : ""}
105+
</div>
106+
);
107+
})}
108+
</div>
109+
110+
{/* 网格 + 左侧周几刻度 */}
111+
<div className="flex gap-1">
112+
{/* 周几刻度(周一 / 周四) */}
113+
<div className="flex flex-col justify-between font-mono text-[9px] text-neutral-500 py-0.5">
114+
<span></span>
115+
<span>周一</span>
116+
<span></span>
117+
<span>周四</span>
118+
<span></span>
119+
<span>周六</span>
120+
<span></span>
121+
</div>
122+
123+
{/* 主体网格:52 列 × 7 行 */}
124+
<div
125+
className="grid gap-[3px] flex-1"
126+
style={{ gridTemplateColumns: `repeat(${WEEKS}, minmax(0, 1fr))` }}
127+
>
128+
{grid.map((week, w) => (
129+
<div key={w} className="grid grid-rows-7 gap-[3px]">
130+
{week.map((cell, d) => {
131+
if (!cell) {
132+
return <div key={d} className="aspect-square" aria-hidden />;
133+
}
134+
const bucket = getBucket(cell.count);
135+
return (
136+
<div
137+
key={d}
138+
title={`${cell.day}: ${cell.count} commits`}
139+
className={[
140+
"aspect-square border border-[var(--foreground)]/30",
141+
bucket === 0
142+
? "bg-[var(--background)]"
143+
: bucket === 1
144+
? "bg-[#CC0000]/20"
145+
: bucket === 2
146+
? "bg-[#CC0000]/40"
147+
: bucket === 3
148+
? "bg-[#CC0000]/70"
149+
: "bg-[#CC0000]",
150+
].join(" ")}
151+
/>
152+
);
153+
})}
154+
</div>
155+
))}
156+
</div>
157+
</div>
158+
159+
{/* 图例 */}
160+
<div className="flex items-center gap-2 font-mono text-[9px] text-neutral-500 self-end">
161+
<span>Less</span>
162+
<span className="w-3 h-3 border border-[var(--foreground)]/30 bg-[var(--background)]" />
163+
<span className="w-3 h-3 border border-[var(--foreground)]/30 bg-[#CC0000]/20" />
164+
<span className="w-3 h-3 border border-[var(--foreground)]/30 bg-[#CC0000]/40" />
165+
<span className="w-3 h-3 border border-[var(--foreground)]/30 bg-[#CC0000]/70" />
166+
<span className="w-3 h-3 border border-[var(--foreground)]/30 bg-[#CC0000]" />
167+
<span>More</span>
168+
</div>
169+
</section>
170+
);
171+
}

0 commit comments

Comments
 (0)