Grid 布局之实现 GitHub 贡献热力图

发布时间:

最后更新:

网格(display: grid)是我非常喜欢的一种 CSS 布局,它可以同时控制两个坐标轴的位置,相比传统的一维布局可以省掉大量的包装元素;对于做表格而言,也比<table>简单得多。

最近实现了一个仿 GitHub 的热力图,再次体验了一把网格布局强大的功能,结果如图:

GitHub 的GitHub 的

咱的咱的

接近像素级复刻了,而代码仅需 170 行(JSX + CSS,不含注释),如果用<table>做没个 250 行是很难搞定的。

完整的源码见 https://github.com/Kaciras/Resume2/components/ContributionCalendar.jsx

背景 #

惯例说一下为什么要搞这个,就是最近更新简历,想把 GitHub 的热力图加进去,毕竟每天都写代码,全绿,多看看对视力好。

最开始想到的是直接复制 GitHub 的 HTML,这当然可行,而且我见过一些地方就这么搞;还有直接截图的,不过这样做没啥意思,既然我会前端,那就自己实现一个看看这玩意有多少含金量。

实现过程 #

好的废话不多说,直接来看怎么搞吧。首先一眼就知道 GitHub 的热力图是张表,横轴是月份,纵轴星期;再加上下面的图例。

几个区域几个区域

该表显示了一年中每天的贡献数,按星期分列,同时后面可能会多不完整的一周所以一共是 53 周,加上表头一共 54 列。行的数量就是 7 + 1个表头 + 1行下面的图例(为了方便把它也放进去),总共 9 行。

是不是很简单?确定了布局,后面要解决的就是填元素了。

54 x 9 的网格布局54 x 9 的网格布局

数据结构 #

既然决定自己做,就肯定要支持动态生成,那么数据结构得想好,首先我们看看直接从 GitHub 抓取用户的贡献。

遗憾的是 GitHub 并没有提供相关的 API,点击右侧的年份后它会请求https://github.com/users/<用户名>/contributions,返回的是 HTML,得自己解析一下。

分析下源码可以看出 GitHub 使用<table>来实现的,<td>元素的data-datedata-level分别表示日期和颜色深度,后面跟一个<tool-tip>元素包含了具体的贡献数。结构很清晰,用正则就能搞定:

javascript
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:

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 的功能,换成别的框架或者原生应该也很容易。

jsx
// 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 里自定义的气泡提示。

jsx
// 生成气泡提示的内容,主要就是处理英语就的复数,中文就没这破事。
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元素将划定父级网格的一个子区域,这个区域继承父级的网格,同时子元素的排布被限制在区域内,使得它们不会漏到表头里。

css
/* 中间的格子区域,使用子网格划定布局范围 */
.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的值有无变化即可。

jsx
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>
	);
}
css
/* 月份标签元素,放在第一行即 row: 1/2 */
.month {
	grid-row: 1/2;

    /* 负边距抵消 grid 的 gap */
	margin-bottom: -3px;
}

月份标签有两个特殊情况要处理,首先如果第二列就产生了变化,那么月份会在连续的两个格子里,出现重叠,GitHub 对此的做法是不显示前面的,所以要做个检查。

其次如果标签处于最后一列,因为三个字母至少有两格宽,所以会超出布局范围,这里跟 GitHub 一样直接隐藏。

首月可能重叠首月可能重叠

月份在最后的情况月份在最后的情况

左侧:星期 #

左侧有三个星期标签,分别是一三五。周一所在的行为第三行(从 1 开始 + 表头 + 周末),再往下两行为周三,然后是周五。

由于只有固定的三个元素,所以可以用+选择符来匹配,实际的代码:

html
<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>
css
/* 星期标签,放在第一列即 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 不同,我这没必要链接到文档,所以左下角改成了显示总数,其它需要注意的就是:

jsx
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>
	);
}
css
/* 左下的图例 */
.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.jsxContributionCalendar.module.scss

评论加载中