GitHub давно задаёт тренды не только в разработке, но и в визуализации активности. Их знаменитая сетка активности – маленькие квадратики, плавно меняющие цвет в зависимости от количества событий, стала настоящим символом продуктивности.
В этой статье мы пошагово создадим такую сетку своими руками с помощью чистого JavaScript и CSS Grid, без использования сторонних библиотек или фреймворков. Мы научимся:
- строить сетку дней и недель с помощью CSS Grid,
- генерировать данные активности,
- настраивать оттенки в зависимости от "нагрузки",
- аккуратно оформлять визуальную часть с минимальным количеством CSS.
Как и в прошлых статьях, всю логику мы аккуратно реализуем внутри единого класса ContributionsChart, однако на этот раз нам также понадобится добавить немного CSS магии.
Что у нас получится в итоге
Интерактивная, лёгкая и полностью настраиваемая таблица активности, где можно:
- выбрать собственную цветовую палитру (или оставить вариант с GitHub);
- задать произвольную дату начала периода (ведь не у всех активности начинаются с 1 января, верно?).
Вот пример, как будет выглядеть использование нашего класса:
const config = {
block: document.getElementById("contributions-chart"),
colors: ["#eff2f5", "#aceebb", "#4ac26b", "#2da44e", "#116329"],
startYear: 2024,
startMonth: 4
}
const contributions = [
{date: new Date(2025, 4, 26), count: 2},
{date: new Date(2025, 4, 20), count: 1},
{date: new Date(2025, 4, 10), count: 7},
...
]
const contributionsChart = new ContributionsChart(config)
contributionsChart.plot(contributions)
Теперь давайте шаг за шагом построим этот компонент – от простой HTML-структуры до аккуратной и стильной диаграммы.
Шаг 0. Подготовка
Начнём с создания трёх файлов:
contributions_chart.html– для вёрстки,contributions_chart.css– для стилизации,-
contributions_chart.js– для логики.
В html файле подключим стили, скрипт, а также добавим контейнер для нашей сетки:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Сетка активности в стиле GitHub</title>
<link rel="stylesheet" type="text/css" href="contributions_chart.css">
</head>
<body>
<div class="contributions-chart" id="contributions-chart"></div>
<script type="text/javascript" src="contributions_chart.js"></script>
<script></script>
</body>
</html>
В JavaScript-файле создадим класс ContributionsChart, конструктор которого будет принимать конфигурацию и сохранять её, а метод plot реализует основную логику
class ContributionsChart {
constructor(config) {
this.block = config.block
this.colors = config.colors ?? ["#eff2f5", "#aceebb", "#4ac26b", "#2da44e", "#116329"]
this.weekDayNames = ["пн", "вт", "ср", "чт", "пт", "сб", "вс"]
this.monthNames = ["Янв", "Фев", "Мар", "Апр", "Май", "Июн", "Июл", "Авг", "Сен", "Окт", "Ноя", "Дек"]
// рассчитываем начальную и конечную даты
const year = config.startYear ?? new Date().getFullYear() // если не передали, то отображаем текущий год
const month = config.startMonth ?? 1 // если не передали, то начинаем с января
this.startDate = new Date(year, month - 1, 1) // начинаем с первого числа месяца
this.endDate = new Date(year + 1, month - 1, 1) // заканчиваем ровно через год
}
plot(contributions) {
}
}
Файл стилей пока оставим пустым – к нему вернёмся чуть позже.
Шаг 1. Верстаем сетку с помощью CSS Grid
Наша сетка активности будет представлять собой таблицу: каждый столбец – это неделя, каждая строка – день недели. Таблицу будем строить с помощью CSS Grid, потому что это позволит нам:
- легко управлять количеством строк и столбцов;
- удобно задавать размеры ячеек;
- с лёгкостью адаптировать сетку под любой размер блока.
Основная идея следующая: контейнер будет отображаться как сетка (grid) из восьми строк (название месяца + 7 дней недели) с автоматическим размещением ячеек по строкам (grid-auto-flow: row). Внутри контенера разместим квадратные ячейки размером 15х15 пикселей следующих типов:
weekday– ячейки с днём недели (пн, вт, ср, ...);month– ячейки с названиями месяцев, которые будут растягиваться на несколько столбцов с помощьюgrid-column-end;day– ячейки с данными активности, окрашенные в зависимости от количества событий;skip– пустые ячейки для выравнивания начала месяца по нужному дню недели.
В HTML структура будет выглядеть примерно так:
<div class="contributions-chart">
<div class="contributions-chart-weekday"></div>
<div class="contributions-chart-weekday">ПН</div>
<div class="contributions-chart-weekday">ВТ</div>
<div class="contributions-chart-weekday">СР</div>
<div class="contributions-chart-weekday">ЧТ</div>
<div class="contributions-chart-weekday">ПТ</div>
<div class="contributions-chart-weekday">СБ</div>
<div class="contributions-chart-weekday">ВС</div>
<div class="contributions-chart-month">Янв</div>
<div class="contributions-chart-skip"></div>
<div class="contributions-chart-skip"></div>
<div class="contributions-chart-day"></div>
<!-- и так далее -->
</div>
Реализуем это в CSS файле:
.contributions-chart {
display: inline-grid;
grid-auto-flow: column;
grid-template-rows: repeat(8, 1fr);
grid-gap: 4px;
}
.contribution-cell {
width: 15px;
height: 15px;
border-radius: 4px;
}
.contribution-cell-weekday, .contribution-cell-month {
font-family: sans-serif;
font-size: 10px;
font-weight: bold;
}
Добавляем ячейки с днями недели
После базовой вёрстки перейдём к JavaScript. Для начала создадим восемь ячеек с днями недели (первая пустая). Введём вспомогательный метод addCell, который добавляет ячейку нужного типа с заданным текстом, а затем с его поомщью реализуем метод addWeekDays:
plot(contributions) {
this.addWeekDays()
}
addWeekDays() {
this.addCell("contribution-cell-weekday", "")
for (const day of this.weekDayNames)
this.addCell("contribution-cell-weekday", day)
}
addCell(className, content) {
const cell = document.createElement("div")
cell.classList.add("contribution-cell")
cell.classList.add(className)
cell.innerText = content
this.block.appendChild(cell)
return cell
}
В результате у нас получится что-то такое:
Добавляем ячейки календаря
Теперь займёмся заполнением основной части календаря.
Так как ячейки месяцев растягиваются на несколько недель, нам нужно правильно рассчитывать их ширину. Логика будет такой:
- запоминаем, с какого столбца начинается месяц,
- при переходе к новому месяцу обновляем ширину предыдущей ячейки.
Также необходимо добавить пустые ячейки в начале, если первый день месяца выпадает не на понедельник (если первое число выпадает на вторник, то нам нужно добавить одну пустую ячейку, а если первое число окажется воскресеньем, то нам нужно добавить 6 пустых ячеек).
В JavaScript узнать день недели можно через date.getDay(), где 0 – воскресенье, 1 – понедельник и так далее. Чтобы получить количество пустых ячеек перед началом месяца, воспользуемся формулой: (getDay() + 6) % 7:
plot(contributions) {
this.addWeekDays()
const skip = (this.startDate.getDay() + 6) % 7
let month = this.startDate.getMonth()
let monthCell = this.addCell("contribution-cell-month", this.monthNames[month])
let monthColumn = 0
let index = skip
// добавляем пустые ячейки
for (let i = 0; i < skip; i++)
this.addCell("contribution-cell-skip", "")
for (let date = this.startDate; date < this.endDate; date.setDate(date.getDate() + 1)) {
if (index % 7 == 0 && date.getMonth() != month) {
monthCell.style.gridColumnEnd = `span ${Math.floor(index / 7) - monthColumn}`
month = date.getMonth()
monthCell = this.addCell("contribution-cell-month", this.monthNames[month])
monthColumn = Math.floor(index / 7)
}
this.addCell("contribution-cell-day", "")
index++
}
monthCell.style.gridColumnEnd = `span ${Math.floor((index + 6) / 7) - monthColumn}` // обновляем ячейку последнего месяца
}
Теперь у нас есть заготовка календаря со всеми ячейками. Остаётся лишь задать им цвет в соответствии с количеством действий в соответствующий день.
Окрашиваем ячейки
Теперь окрасим ячейки в зависимости от количества событий в день.
Чтобы определить цвет:
- Найдём максимальное количество событий за весь период.
- Для каждой ячейки выберем цвет на основе относительного количества событий.
Чтобы пустые ячейки отличались от заполненных, используем специальную формулу в методе getColor:
getColor(count, max) {
if (count == 0)
return this.colors[0] // пустая ячейка всегда окрашена в нулевой цвет
return this.colors[1 + Math.floor(count / max * (this.colors.length - 2))]
}
В методе plot добавим вычисление максимального количества событий, а также для эффективного получения количества событий в заданную дату, преобразуем переданный массив contributions в словарь вида date -> count:
plot(contributions) {
const date2count = Object.fromEntries(contributions.map(contribution => [contribution.date, contribution.count]))
const max = Math.max(...contributions.map(contribution => contribution.count))
...
}
Теперь остаётся лишь добавить свойство background для каждой ячейки и можно считать таблицу почти завершённой:
...
const count = date2count[date] ?? 0
const cell = this.addCell("contribution-cell-day", "")
cell.style.background = this.getColor(count, max)}`
...
Добавляем подсказки при наведении
Чтобы сделать таблицу ещё удобнее, добавим всплывающие подсказки при наведении на ячейку. Реализуем это, задавая атрибут title ячейке:
plot(contributions) {
...
const count = date2count[date] ?? 0
const cell = this.addCell("contribution-cell-day", "")
cell.style.background = this.getColor(count, max)
cell.title = this.getTitle(date, count)
...
}
getTitle(date, count) {
const day = `${date.getDate()}`.padStart(2, "0")
const month = `${date.getMonth() + 1}`.padStart(2, "0")
const year = `${date.getFullYear()}`
return `${day}.${month}.${year}: ${count}`
}
Готово! Вы восхитительны!
Итого
Всего за ~80 строк JavaScript и ~20 строк CSS мы создали красивую таблицу активности, аналогичную GitHub Contributions Chart, которую не стыдно показать друзьям или использовать в своём проекте!
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Сетка активности в стиле GitHub</title>
<link rel="stylesheet" type="text/css" href="contributions_chart.css">
</head>
<body>
<div class="contributions-chart" id="contributions-chart">
</div>
<script type="text/javascript" src="contributions_chart.js"></script>
<script>
const config = {
block: document.getElementById("contributions-chart"),
colors: ["#eff2f5", "#aceebb", "#4ac26b", "#2da44e", "#116329"],
startYear: 2024,
startMonth: 4
}
const contributions = []
// добавляем случайные события
for (let date = new Date(2024, 3, 1); date < new Date(2025, 2, 30); date.setDate(date.getDate() + 1))
if (Math.random() < 0.5)
contributions.push({date: new Date(date), count: Math.floor(1 + Math.random() * 25)})
const contributionsChart = new ContributionsChart(config)
contributionsChart.plot(contributions)
</script>
</body>
</html>
class ContributionsChart {
constructor(config) {
this.block = config.block
this.colors = config.colors ?? ["#eff2f5", "#aceebb", "#4ac26b", "#2da44e", "#116329"]
this.weekDayNames = ["пн", "вт", "ср", "чт", "пт", "сб", "вс"]
this.monthNames = ["Янв", "Фев", "Мар", "Апр", "Май", "Июн", "Июл", "Авг", "Сен", "Окт", "Ноя", "Дек"]
const year = config.startYear ?? new Date().getFullYear()
const month = config.startMonth ?? 1
this.startDate = new Date(year, month - 1, 1)
this.endDate = new Date(year + 1, month - 1, 1)
}
plot(contributions) {
const date2count = Object.fromEntries(contributions.map(contribution => [contribution.date, contribution.count]))
const max = Math.max(...contributions.map(contribution => contribution.count))
this.addWeekDays()
const skip = (this.startDate.getDay() + 6) % 7
let month = this.startDate.getMonth()
let monthCell = this.addCell("contribution-cell-month", this.monthNames[month])
let monthColumn = 0
let index = skip
for (let i = 0; i < skip; i++)
this.addCell("contribution-cell-skip", "")
for (let date = this.startDate; date < this.endDate; date.setDate(date.getDate() + 1)) {
if (index % 7 == 0 && date.getMonth() != month) {
monthCell.style.gridColumnEnd = `span ${Math.floor(index / 7) - monthColumn}`
monthColumn = Math.floor(index / 7)
month = date.getMonth()
monthCell = this.addCell("contribution-cell-month", this.monthNames[month])
}
const count = date2count[date] ?? 0
const cell = this.addCell("contribution-cell-day", "")
cell.style.background = this.getColor(count, max)
cell.title = this.getTitle(date, count)
index++
}
monthCell.style.gridColumnEnd = `span ${Math.floor((index + 6) / 7) - monthColumn}`
}
addWeekDays() {
this.addCell("contribution-cell-weekday", "")
for (const day of this.weekDayNames)
this.addCell("contribution-cell-weekday", day)
}
addCell(className, content) {
const cell = document.createElement("div")
cell.classList.add("contribution-cell")
cell.classList.add(className)
cell.innerText = content
this.block.appendChild(cell)
return cell
}
getColor(count, max) {
if (count == 0)
return this.colors[0]
return this.colors[1 + Math.floor(count / max * (this.colors.length - 2))]
}
getTitle(date, count) {
const day = `${date.getDate()}`.padStart(2, "0")
const month = `${date.getMonth() + 1}`.padStart(2, "0")
const year = `${date.getFullYear()}`
return `${day}.${month}.${year}: ${count}`
}
}
.contributions-chart {
display: inline-grid;
grid-auto-flow: column;
grid-template-rows: repeat(8, 1fr);
grid-gap: 4px;
}
.contribution-cell {
width: 15px;
height: 15px;
border-radius: 4px;
}
.contribution-cell-weekday, .contribution-cell-month {
font-family: sans-serif;
font-size: 10px;
font-weight: bold;
}
