Gird 是我很喜欢的一种 CSS 布局,它可以同时控制两个坐标轴的位置,相比传统的一维布局可以省掉大量的包装元素;对于做表格而言,也比<table>
元素简单得多。
最近实现了一个仿 GitHub 的热力图,再次体验了一把 Grid 布局强大的功能,结果如图:
接近像素级复刻了,而代码仅需 160 行(不含注释),如果用<table>
做没个 250 行是很难搞定的。
完整的源码见 https://github.com/Kaciras/Resume2/components/ContributionCalendar.jsx
惯例说一下为什么要搞这个,就是最近更新简历,想把 GitHub 的热力图加进去,毕竟每天都写代码,全绿,多看看对视力好。
最开始想到的是直接复制 GitHub 的 HTML,这当然可行,而且我见过一些地方就这么搞,不过没啥意思,既然我会前端,那就自己实现一个看看这玩意有多少含金量。
好的废话不多说,直接来看怎么搞吧。首先一眼就知道 GitHub 的热力图是张表,横轴是月份,纵轴星期;再加上下面的图例。
该表显示了一年中每天的贡献数,按星期分列,同时后面可能会多不完整的一周所以一共是 53 周,加上表头一共 54 列。行的数量就是 7 + 1个表头 + 1行下面的图例(为了方便把它也放进去),总共 9 行。
是不是很简单?确定了布局,后面要解决的就是填元素了。
既然决定自己做,就肯定要支持动态生成,那么数据结构得想好,首先我们看看直接从 GitHub 抓取用户的贡献。
遗憾的是 GitHub 并没有提供相关的 API,点击右侧的年份后它会请求https://github.com/users/<用户名>/contributions
,返回的是 HTML,得自己解析一下。
分析下源码可以看出 GitHub 使用<table>
来实现的,<td>
元素的data-date
和data-level
分别表示日期和颜色深度,后面跟一个<tool-tip>
元素包含了具体的贡献数。结构很清晰,用正则就能搞定:
export async function getGitHubContributions(user) {
const url = `https://github.com/users/${user}/contributions`;
const html = await (await fetch(url)).text();
const tooltips = html.matchAll(/>(\w+) contributions? on \w+ \w+/g);
const cells = html.matchAll(/data-date="(\d+-\d+-\d+)" .*? data-level="(\d+)"/g);
let contributions = [];
for (const [, count] of tooltips) {
let [, date, level] = cells.next().value;
contributions.push({
level: parseInt(level),
date: new Date(date).getTime(),
count: parseInt(count) || 0,
});
}
return contributions.sort((a, b) => a.date - b.date);
}
该函数返回贡献数组,每条包含日期、等级、数量三项。日期和数量不多说,等级(每块的颜色深度)我看有些项目的实现是自己划了几个档,但根据官方的回答具体的计算方式并未公开,所以本文还以它的为准。
最后由于匹配到的数据是按行(星期)排序的,所以这里还要重新排一下,变成日期顺序。虽说按行排序也能用,但不符合直觉。
GitHub 热力图中的小方块是 10x10 的正方形,53 行 x 7 列,间隔 3px。其它的区域大小都根据内容决定,设为auto
即可,故容器的 CSS:
.container {
display: grid;
grid-template-columns: auto repeat(53, 10px);
grid-template-rows: auto repeat(7, 10px) auto;
gap: 3px;
/* 让整个组件自适应大小,而不是撑满一行 */
width: fit-content;
/* 其它间距、字体等样式直接照抄 GitHub */
font-size: 12px;
padding: 14px;
border: solid 1px #D1D9E0;
border-radius: 0.375rem;
}
由于我的项目已经使用了 React,所以写得是 JSX,但并未使用任何 React 的功能,换成别的框架或者原生应该也很容易。
// CommitCalendar.jsx
import styles from "./CommitCalendar.module.css";
// 假设抓取的数据保存在 JSON 文件里。
import commits from "../lib/commits.json" with { type: "json" };
export default function CommitCalendar(props) {
return (
<div className={styles.container}>内容待填</div>
);
};
由于网格布局能够自动排列元素,所以直接把小方块往里塞就行。另外为了省事,本项目直接用了title
代替 GitHub 里自定义的气泡提示。
// 生成气泡提示的内容,主要就是处理英语就的复数,中文就没这破事。
function getTooltip(commit, date) {
const s = date.toISOString().split("T")[0];
switch (commit.count) {
case 0:
return `No contributions on ${s}`;
case 1:
return `1 contribution on ${s}`;
default:
return `${commit.count} contributions on ${s}`;
}
}
export default function CommitCalendar(props) {
const tiles = commits.map((c, i) => {
const date = new Date(c.date);
return (
<i
className={styles.tile}
key={i}
data-level={c.level}
title={getTooltip(c, date)}
/>
);
});
return (
<div className={styles.container}>
<div className={styles.tiles}>{tiles}</div>
</div>
);
};
CSS 方面有一点需要注意,这里的表头并未填满,月份和星期之间是有空的,如果把方块也放在一起会乱。
对于这个问题,有一个subgrid
属性专门解决它,通过设置display: grid
以及grid-template-*: subgrid
元素将划定父级网格的一个子区域,这个区域继承父级的网格,同时子元素的排布被限制在区域内,使得它们不会漏到表头里。
/* 中间的格子区域,使用子网格划定布局范围 */
.tiles {
/* 子区域范围为 2-9 行,2-55 列 */
grid-row: 2/9;
grid-column: 2/55;
display: grid;
grid-template-columns: subgrid;
grid-template-rows: subgrid;
/* 按列方向依次放置方块 */
grid-auto-flow: column;
}
/* 小方块的样式,直接抄 GitHub 的即可 */
.tile {
display: block;
width: 10px;
height: 10px;
border-radius: 2px;
outline: 1px solid rgba(27, 35, 36, 0.06);
outline-offset: -1px;
&[data-level="0"] { background: #EBEDF0; }
&[data-level="1"] { background: #9be9a8; }
&[data-level="2"] { background: #40C463; }
&[data-level="3"] { background: #30a14e; }
&[data-level="4"] { background: #216e39; }
}
GitHub 上的月份标签是在第一行(周末)所处的月变动的位置显示,计算的部分可以写进已有的循环里,判断Date.getMonth
的值有无变化即可。
const MONTH = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
export default function CommitCalendar(props) {
const padStart = new Date(commits[0].date).getDay();
const months = [];
let latestMonth = -1;
const tiles = commits.map((c, i) => {
const date = new Date(c.date);
const month = date.getMonth();
// 在星期天的月份出现变化的列上面显示月份。
if (date.getDay() === 0 && month !== latestMonth) {
// 计算月份对应的列,从 1 开始、左上角格子留空所以 +2
const gridColumn = 2 + Math.floor((i + padStart) / 7);
latestMonth = month;
months.push(
<div
className={styles.month}
key={i}
style={{ gridColumn }}
>
{MONTH[date.getMonth()]}
</div>,
);
}
return (
<i
className={styles.tile}
key={i}
data-level={c.level}
title={getTooltip(c, date)}
/>
);
});
// 俩月份之间至少隔三格,避免重叠,只可能出现在第一个月。
if (months[1].props.style.gridColumn - months[0].props.style.gridColumn < 3) {
months[0] = null;
}
// 如果最后一个月在最后一格,则会超出布局范围,故隐藏。
if (months.at(-1).props.style.gridColumn > 53) {
months[months.length - 1] = null;
}
return (
<div className={styles.container}>
{months}
<div className={styles.tiles}>{tiles}</div>
</div>
);
}
/* 月份标签元素,放在第一行即 row: 1/2 */
.month {
grid-row: 1/2;
/* 负边距抵消 grid 的 gap */
margin-bottom: -3px;
}
月份标签有两个特殊情况要处理,首先如果第二列就产生了变化,那么月份会在连续的两个格子里,出现重叠,GitHub 对此的做法是不显示前面的,所以要做个检查。
其次如果标签处于最后一列,因为三个字母至少有两格宽,所以会超出布局范围,这里跟 GitHub 一样直接隐藏。
左侧有三个星期标签,分别是一三五。周一所在的行为第三行(从 1 开始 + 表头 + 周末),再往下两行为周三,然后是周五。
由于只有固定的三个元素,所以可以用+
选择符来匹配,实际的代码:
<div className={styles.container}>
{months}
+ <span className={styles.week}>Mon</span>
+ <span className={styles.week}>Wed</span>
+ <span className={styles.week}>Fri</span>
<div className={styles.tiles}>{tiles}</div>
</div>
/* 星期标签,放在第一列即 column: 1/2 */
.week {
grid-column: 1/2;
line-height: 10px;
margin-right: 3px;
/* 第一个元素放在第三行 */
grid-row-start: 3;
/* 第二个(周三)在第五行 */
& + .week {
grid-row-start: 5;
}
/* 第三个(周五)在第七行 */
& + .week + .week {
grid-row-start: 7;
}
}
这里跟 GitHub 不同,我这没必要链接到文档,所以左下角改成了显示总数,其它需要注意的就是:
grid 布局的gap
是全网格统一的,不能单独设置,如果要单独调整的话得用margin
,正数增加间距,负数缩小。
下面的位置足够大,在中间的哪儿分割都行,我这选择了 30 列的位置。
export default function CommitCalendar(props) {
let totalCommits = 0;
const tiles = commits.map((c, i) => {
totalCommits += c.count;
// 其它代码省略......
});
return (
<div className={styles.container}>
{/* 前边的省略了... */}
<div className={styles.total}>
{totalCommits} contributions in the last year
</div>
<div className={styles.legend}>
Less
<i className={styles.tile} data-level={0}/>
<i className={styles.tile} data-level={1}/>
<i className={styles.tile} data-level={2}/>
<i className={styles.tile} data-level={3}/>
<i className={styles.tile} data-level={4}/>
More
</div>
</div>
);
}
/* 左下的图例 */
.total {
grid-column: 2/30;
margin-top: 4px;
}
/* 右下的图例 */
.legend {
/* GitHub 右下图例是对齐到倒数第二格的,即 53 */
grid-column: 30/53;
margin-top: 4px;
/* 用 flex 做下居中对齐 */
display: flex;
gap: 5px;
justify-content: right;
align-items: center;
}
性能方面的话,因为里面至少要循环 365 次创建格子元素,所以开销应该会有一点点,实测渲染时间 3ms,如果用 React 做的话可以考虑包一层React.memo
。
还有一个问题是 GitHub 的月份通常是放在在改变的列,但也发现了一个例外就是首月可能会前移,这是否正确暂且不知道,本文没有跟 GitHub 一样这么做。
上图十月的标签放在九月的最后一列,但后边九月的标签却没有放在八月的最后一列上。
这种热力图组件看似挺复杂,但实际做了就会发现相当简单,特别是新出的(也不算新了)网格布局十分强大,想把元素放到哪格直接声明即可,不用再像<table>
一样去计算元素在 DOM 中的位置,让布局和 DOM 彻底解耦!
如果你也想弄一个可以直接复制 ContributionCalendar.jsx 和 ContributionCalendar.module.scss。