IT博客汇
  • 首页
  • 精华
  • 技术
  • 设计
  • 资讯
  • 扯淡
  • 权利声明
  • 登录 注册

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

    Kaciras 的博客发表于 2024-10-07 06:27:36
    love 0

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

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

    GitHub 的

    咱的

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

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

    需求背景

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

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

    实现过程

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

    几个区域

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

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

    54 x 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。



沪ICP备19023445号-2号
友情链接