Как создать сетку активности как на GitHub на чистом Javascript и CSS Grid

web development, javascript, css, css grid
Как создать сетку активности как на GitHub на чистом Javascript и CSS Grid

GitHub давно задаёт тренды не только в разработке, но и в визуализации активности. Их знаменитая сетка активности – маленькие квадратики, плавно меняющие цвет в зависимости от количества событий, стала настоящим символом продуктивности.

В этой статье мы пошагово создадим такую сетку своими руками с помощью чистого JavaScript и CSS Grid, без использования сторонних библиотек или фреймворков. Мы научимся:

  • строить сетку дней и недель с помощью CSS Grid,
  • генерировать данные активности,
  • настраивать оттенки в зависимости от "нагрузки",
  • аккуратно оформлять визуальную часть с минимальным количеством CSS.

Как и в прошлых статьях, всю логику мы аккуратно реализуем внутри единого класса ContributionsChart, однако на этот раз нам также понадобится добавить немного CSS магии.

Что у нас получится в итоге

Интерактивная, лёгкая и полностью настраиваемая таблица активности, где можно:

  • выбрать собственную цветовую палитру (или оставить вариант с GitHub);
  • задать произвольную дату начала периода (ведь не у всех активности начинаются с 1 января, верно?).
пн
вт
ср
чт
пт
сб
вс
Апр
Май
Июн
Июл
Авг
Сен
Окт
Ноя
Дек
Янв
Фев
Мар

Вот пример, как будет выглядеть использование нашего класса:

Пример использования класса ContributionsChart
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 файле подключим стили, скрипт, а также добавим контейнер для нашей сетки:

Файл contributions_chart.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 реализует основную логику

Файл contributions_js.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) {

    }
}

Файл стилей пока оставим пустым – к нему вернёмся чуть позже.

Шаг 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 файле:

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:

Вычисление максимального количества событий и преобразование contributions в словарь
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, которую не стыдно показать друзьям или использовать в своём проекте!

Итоговый файл contributions_chart.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>
        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>
Итоговый файл contributions_chart.js
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.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;
}