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