心情数组每条包含三个属性

  • count 当天的心情,用在气泡提示里。
  • date 日期,保存为时间戳。
  • level 情绪状态(对应0-4五种心情)。

容器

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;
}

中间的方块

由于网格布局能够自动排列元素,所以直接把小方块往里塞就行。另外为了省事,本项目直接用了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;
}

.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; }
}

月份

月份标签是在第一行(周末)所处的月变动的位置显示,计算的部分可以写进已有的循环里,判断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;
}

星期

左侧有三个星期标签,分别是一三五。周一所在的行为第三行(从 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;
	}
}

由于上述是基于一年数据进行展示的,在小程序端并不美观,所以后续进行了修改,改为6个月的了;

Bug记录

TypeError: Cannot read property 'month' of undefined

配置后首先报错,原因是import styles from "./MoodCalendar.scss"; 中的styles是undefined,后来改成了普通导入,在需要使用className的地方直接使用”month”而非{styles.month},问题解决

Page route 错误

这个错误表示 小程序在切换页面路由时,尝试操作了一个已经销毁或不存在的页面(WebView)。具体来说:

  • webviewId 76:框架为每个页面分配的唯一标识,此处 76 代表某个页面的 WebView ID。
  • not the current page:当前显示的页面已经不是该 WebView ID 对应的页面,通常是因为页面已被提前销毁或跳转逻辑存在冲突。

清除缓存后报错解除

WXMLRT_$gwx:./base.wxml:template:27:20: Template tmpl_0_div not found.

使用

compiler: 'webpack5',
    compiler: {
      type: 'webpack5',
      prebundle: {
        enable: false,
        force: true,
      },
    },

无法解决,查询资料后得知,这属于 Taro 框架在编译 React 到小程序时出现的模板渲染问题。原因出在组件return了HTML原生标签,需要将这些元素替换为Taro组件

替换 HTML 原生标签:

jsx
复制
// 将原有 HTML 元素替换为 Taro 组件
return (
  <View {...rest} className={clsx("container", className)}>
    {months}
    <Text className="week">Mon</Text>
    <Text className="week">Wed</Text>
    <Text className="week">Fri</Text>

    <View className="tiles">{tiles}</View>

    <View className="total">
      {total} contributions in the last two months
    </View>
    <View className="legend">
      Less
      <View className="tile" data-level={0}/>
      ...
    </View>
  </View>
);

调整动态元素生成逻辑:

jsx
复制
// 修改月份标签生成(确保始终有效元素)
months.push(
  MONTH[month] ? (
    <Text
      className="month"
      key={`month-${i}-${month}`}
      style={{ gridColumn }}
    >
      {MONTH[month]}
    </Text>
  ) : null
);

// 使用过滤去除 null
{months.filter(Boolean)}

避免 cloneElement:

jsx
复制
// 替换原有克隆逻辑const firstTileStyle = tiles.length > 0 ? { gridRow: startRow + 1 } : {};

const tiles = filteredContribs.map((c, i) => (
  <View
    className="tile"
    key={`tile-${i}`}
    data-level={c.level}
    title={getTooltip(c, new Date(c.date))}
    style={i === 0 ? firstTileStyle : {}}
  />
));

日期处理兼容性调整:

jsx
复制
// 统一使用 Taro 的日期处理import Taro from '@tarojs/taro';

const now = Taro.getSystemInfoSync().timeStamp ?
  new Date(Taro.getSystemInfoSync().timeStamp) :
  new Date();

到此热力图可以正常显示

Logo

助力广东及东莞地区开发者,代码托管、在线学习与竞赛、技术交流与分享、资源共享、职业发展,成为松山湖开发者首选的工作与学习平台

更多推荐