Grid 布局之实现 GitHub 贡献热力图
发布时间:
最后更新:
网格(display: grid
)是我非常喜欢的一种 CSS 布局,它可以同时控制两个坐标轴的位置,相比传统的一维布局可以省掉大量的包装元素;对于做表格而言,也比<table>
简单得多。
最近实现了一个仿 GitHub 的热力图,再次体验了一把网格布局强大的功能,结果如图:
接近像素级复刻了,而代码仅需 170 行(JSX + CSS,不含注释),如果用<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);
}
该函数返回贡献数组,每条包含三个属性:
count
当天的贡献数量,用在气泡提示里。date
日期,保存为时间戳。level
等级(每块的颜色深度),我看有些项目的实现是自己划了几个档,但根据官方的回答具体的计算方式并未公开,所以本文还以它的为准。
最后由于匹配到的数据是按行(星期)排序的,所以这里还要重新排一下,变成日期顺序。虽说按行排序也能用,但不符合直觉。
容器 #
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 的功能,换成别的框架或者原生应该也很容易。
// ContributionCalendar.jsx
import styles from "./ContributionCalendar.module.css";
// 假设抓取的数据保存在 JSON 文件里。
import commits from "../lib/commits.json" with { type: "json" };
export default function ContributionCalendar(props) {
return (
<div className={styles.container}>内容待填</div>
);
};
中间的方块 #
由于网格布局能够自动排列元素,所以直接把小方块往里塞就行。另外为了省事,本项目直接用了title
代替 GitHub 里自定义的气泡提示。
// 生成气泡提示的内容,主要就是处理英语就的复数,中文就没这破事。
function getTooltip(oneDay, date) {
const s = date.toISOString().split("T")[0];
switch (oneDay.count) {
case 0:
return `No contributions on ${s}`;
case 1:
return `1 contribution on ${s}`;
default:
return `${oneDay.count} contributions on ${s}`;
}
}
export default function ContributionCalendar(props) {
const firstDate = new Date(props.contributions[0].date);
const startRow = firstDate.getDay();
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)}
/>
);
});
// 第一格不一定是周日,此时前面会有空白,需要设置下起始行。
tiles[0] = React.cloneElement(tiles[0], {
style: { gridRow: startRow + 1 },
});
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 ContributionCalendar(props) {
const firstDate = new Date(props.contributions[0].date);
const startRow = firstDate.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 + startRow) / 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)}
/>
);
});
// 第一格不一定是周日,此时前面会有空白,需要设置下起始行。
tiles[0] = React.cloneElement(tiles[0], {
style: { gridRow: startRow + 1 },
});
// 如果第一格不是周日,则首月可能跑到第二列,需要再检查下。
if (MONTH[firstDate.getMonth()] === months[0].props.children) {
months[0].props.style.gridColumn = 2;
}
// 俩月份之间至少隔三格,避免重叠,只可能出现在第一个月。
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 ContributionCalendar(props) {
let total = 0;
const tiles = commits.map((c, i) => {
total += c.count;
// 其它代码省略......
});
return (
<div className={styles.container}>
{/* 前边的省略了... */}
<div className={styles.total}>
{total} 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
。
网格布局相比于传统的<table>
有一个缺点就是它没有语义,可能不适用于阅读器之类的工具,而这一点是否能用 ARIA 属性来弥补我还没有研究。
还有一个问题是 GitHub 的月份通常是放在在改变的列,但也发现了一个例外就是首月可能会前移,这是否正确暂且不知道,本文没有跟 GitHub 一样这么做。
上图十月的标签放在九月的最后一列,但后边九月的标签却没有放在八月的最后一列上。
总结 #
这种热力图组件看似挺复杂,但实际做了就会发现相当简单,特别是新出的(也不算新了)网格布局十分强大,想把元素放到哪格直接声明即可,不用再像<table>
一样去计算元素在 DOM 中的位置,让布局和 DOM 彻底解耦!
如果你也想弄一个可以直接复制 ContributionCalendar.jsx 和 ContributionCalendar.module.scss。