Задача
Скопировано«GitHub-плитка» — легко узнаваемый компонент на странице профиля пользователя GitHub.
Создадим подобный компонент для отображения активности коммитов в репозиторий.
Готовое решение
СкопированоВ ходе воплощения идеи мы будем использовать:
- GitHub-API;
- манипуляции с датами и цветами;
- рендеринг html-элементов с помощью JavaScript.

Готовое решение
СкопированоСоздадим основу html-разметки:
<div id="mainContainer" class="main-container"> <div class="description"> <span class="loading"> Загрузка... </span> </div> <div class="total-row hidden"> <span class="total-label"> Общее количество коммитов за год: </span> </div> <div class="tooltip hidden" role="tooltip" id="tooltip" data-position="top" /></div>
<div id="mainContainer" class="main-container"> <div class="description"> <span class="loading"> Загрузка... </span> </div> <div class="total-row hidden"> <span class="total-label"> Общее количество коммитов за год: </span> </div> <div class="tooltip hidden" role="tooltip" id="tooltip" data-position="top" /> </div>
Добавим стили. Большинство из них потребуются в дальнейшем:
html { color-scheme: dark; } body { min-height: 100vh; margin: 0; padding: 50px; display: flex; align-items: center; justify-content: center; box-sizing: border-box; background-color: #18191C; color: #FFFFFF; font-family: "Roboto", sans-serif; } .main-container { display: flex; flex-direction: column; gap: .5rem; min-width: 200px; padding: 1rem; position: relative; background-color: #18191C; } .main-container div { box-sizing: border-box; } .description { display: flex; flex-direction: row; gap: .5rem; font-size: 1.5rem; } .repo-name, .total-row, .tooltip, .error-value { color: #FFF; } .description, .loading, .total-label { color: #C56FFF; } .loading { font-size: 1.5rem; margin: auto; } .year { flex: 1 1 ; display: flex; max-width: 100%; min-width: 100px; overflow-y: hidden; overflow-x: auto; padding: 2rem 1rem 1rem 1rem; background-color: #0C0C0E6B; border: 1px solid #363636; border-radius: 5px; } .weekday-label, .month-label { font-size: .75rem; color: #FFF; } .weekday-label { margin: 10px 4px 0 0; } .month-label { width: 0; margin-top: -1rem; overflow-x: visible; } .weekday-labels, .week { display: flex; flex-direction: column; flex: 0 0 auto; } .day { width: 10px; height: 10px; margin: 1px; border: 1px solid #88888818; border-radius: 2px; } .day:hover { border-color: #8885; } .day.has-commits:hover { border-color: #888a; } .cell { background-color: #28e628; } .tooltip { position: absolute; width: max-content; max-width: 400px; padding: 8px 16px; border-radius: 4px; background-color: #555; } .hidden { visibility: hidden; }
html { color-scheme: dark; } body { min-height: 100vh; margin: 0; padding: 50px; display: flex; align-items: center; justify-content: center; box-sizing: border-box; background-color: #18191C; color: #FFFFFF; font-family: "Roboto", sans-serif; } .main-container { display: flex; flex-direction: column; gap: .5rem; min-width: 200px; padding: 1rem; position: relative; background-color: #18191C; } .main-container div { box-sizing: border-box; } .description { display: flex; flex-direction: row; gap: .5rem; font-size: 1.5rem; } .repo-name, .total-row, .tooltip, .error-value { color: #FFF; } .description, .loading, .total-label { color: #C56FFF; } .loading { font-size: 1.5rem; margin: auto; } .year { flex: 1 1 ; display: flex; max-width: 100%; min-width: 100px; overflow-y: hidden; overflow-x: auto; padding: 2rem 1rem 1rem 1rem; background-color: #0C0C0E6B; border: 1px solid #363636; border-radius: 5px; } .weekday-label, .month-label { font-size: .75rem; color: #FFF; } .weekday-label { margin: 10px 4px 0 0; } .month-label { width: 0; margin-top: -1rem; overflow-x: visible; } .weekday-labels, .week { display: flex; flex-direction: column; flex: 0 0 auto; } .day { width: 10px; height: 10px; margin: 1px; border: 1px solid #88888818; border-radius: 2px; } .day:hover { border-color: #8885; } .day.has-commits:hover { border-color: #888a; } .cell { background-color: #28e628; } .tooltip { position: absolute; width: max-content; max-width: 400px; padding: 8px 16px; border-radius: 4px; background-color: #555; } .hidden { visibility: hidden; }
Основную работу по созданию всего компонента будет выполнять JS. Рассмотрим его далее.
Итоговый результат выглядит так:
Разбор решения
СкопированоНашу задачу можно разделить на несколько частей:
- получение данных с помощью запроса к GitHub API;
- преобразование полученных данных;
- определение цветов;
- отображение плитки.
Получение данных с помощью запроса к GitHub API
СкопированоGitHub предоставляет удобное API и документацию по его использованию. Мы будем использовать запрос для получения данных об активности коммитов за последний (прошедший начиная с сегодняшнего дня) год.
Вот как выглядит запрос:
GET https://api.github.com/repos/{owner}/{repo}/stats/commit_activity
где:
{owner}
- имя владельца репозитория
{repo}
- имя репозитория
Мы будем получать данные об активности репозитория с контентом Доки.
Напишем функцию формирования пути запроса. Мы используем функцию вместо константы, чтобы иметь возможность проще расширять функционал в дальнейшем:
// имя в формате owner/repoconst REPO = 'doka-guide/content'function createURL(repo = REPO) { return `https://api.github.com/repos/${repo}/stats/commit_activity`}
// имя в формате owner/repo const REPO = 'doka-guide/content' function createURL(repo = REPO) { return `https://api.github.com/repos/${repo}/stats/commit_activity` }
Документация описывает необходимые заголовки (headers) API-запрса:
'
— версия API. Этот параметр гарантирует получение задукоментированной структуры ответа, даже при изменении этой структуры в других версиях API;X - Git Hub - Api - Version' : '2022 - 11 - 28' '
— формат файла ответа. Документация рекомендует добавлять заголовокAccept' : 'application / vnd . github+json' Accept
для уточнения желаемого формата данных.
Напишем функцию выполнения запроса. Функция принимает в качестве параметра путь запроса и возвращает промис:
function requestGitHubData(url) { try { return fetch(url, { method: 'GET', headers: { 'Accept': 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' } }) } catch (error) { console.error(error) return [] }}
function requestGitHubData(url) { try { return fetch(url, { method: 'GET', headers: { 'Accept': 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' } }) } catch (error) { console.error(error) return [] } }
Документация предупреждает о возможности получить пустой ответ со статусом 202. Чтобы корректно обработать такую ситуацию, предусмотрим возможность выполнения повторных запросов с задержкой.
Для этого нам понадобится функция, проверяющая статус ответа и выполняющая повторные запросы. Функция принимает объект параметров выполнения запроса:
// количество повторных попытокconst REQ_MAX_ATTEMPTS = 3// пауза между попытками, мсconst REQ_ATTEMPT_TIMEOUT = 20000// сообщение об ошибке в случае получения ответа со статусом 202const ERR_202 = 'Данные не готовы'async function fetchCommitActivity(requestParams = {}) { try { const { url, requestAttempts = REQ_MAX_ATTEMPTS, requestAttemptTimeout = REQ_ATTEMPT_TIMEOUT } = requestParams const response = await requestGitHubData(url) if (response.status === 202) { if (requestAttempts > 0) { // пауза перед повторным запросом await new Promise(r => setTimeout(r, requestAttemptTimeout)); // рекурсивно вызываем функцию, уменьшая счётчик повторов return fetchCommitActivity({ url, requestAttempts: requestAttempts - 1 }) } // кидаем ошибку если все попытки были безуспешны throw new Error(ERR_202) } if (response.ok) { return await response.json() } } catch (error) { console.error(error) return Promise.reject(error) }}
// количество повторных попыток const REQ_MAX_ATTEMPTS = 3 // пауза между попытками, мс const REQ_ATTEMPT_TIMEOUT = 20000 // сообщение об ошибке в случае получения ответа со статусом 202 const ERR_202 = 'Данные не готовы' async function fetchCommitActivity(requestParams = {}) { try { const { url, requestAttempts = REQ_MAX_ATTEMPTS, requestAttemptTimeout = REQ_ATTEMPT_TIMEOUT } = requestParams const response = await requestGitHubData(url) if (response.status === 202) { if (requestAttempts > 0) { // пауза перед повторным запросом await new Promise(r => setTimeout(r, requestAttemptTimeout)); // рекурсивно вызываем функцию, уменьшая счётчик повторов return fetchCommitActivity({ url, requestAttempts: requestAttempts - 1 }) } // кидаем ошибку если все попытки были безуспешны throw new Error(ERR_202) } if (response.ok) { return await response.json() } } catch (error) { console.error(error) return Promise.reject(error) } }
Преобразование полученных данных
СкопированоУспешный ответ запроса будет содержит массив объектов, например:
[ { "days": [ 0, 3, 26, 20, 39, 1, 0 ], "total": 89, "week": 1336280400 }, ...]
[ { "days": [ 0, 3, 26, 20, 39, 1, 0 ], "total": 89, "week": 1336280400 }, ... ]
где каждый элемент массива, это объект с информацией о неделе:
days — массив значений количества коммитов за каждый день недели (начиная с воскресения);
total — общее количество коммитов за неделю;
week — дата первого дня недели в виде Unix timestamp
Преобразуем полученные данные для удобства отображения названий дней недели, месяцев и даты дня в формат:
[ { "total": number, "weekDate": Date // Date-объект для даты начала недели "days": [ { count: number, // количество коммитов в вс. dateFormated: string // дата в формате `ГГГГ.MM.ДД` }, { count: number // количество коммитов в пн. dateFormated: string }, ... ], month: string // сокращённое название месяца (для первой недели месяца) }, ...]
[ { "total": number, "weekDate": Date // Date-объект для даты начала недели "days": [ { count: number, // количество коммитов в вс. dateFormated: string // дата в формате `ГГГГ.MM.ДД` }, { count: number // количество коммитов в пн. dateFormated: string }, ... ], month: string // сокращённое название месяца (для первой недели месяца) }, ... ]
Нам понадобятся функции форматирования дат:
// формат `ГГГГ.MM.ДД`const DATE_FORMATTER = new Intl.DateTimeFormat('ru', { year: 'numeric', month: 'numeric', day: 'numeric',})// сокращённое имя месяцаconst DATE_MONTH_FORMATTER = new Intl.DateTimeFormat('ru', { month: 'short',})// Функция преобразования timestamp в экземпляр Datefunction getWeekDate(weekTimestamp) { return new Date(weekTimestamp * 1000)}// Функция преобразования экземпляра Date в строку формата `ГГГГ.MM.ДД`function getDateFormat(date) { return DATE_FORMATTER.format(date)}// Функция преобразования экземпляра Date в строку с сокращённым именем месяцаfunction getMonthName(date) { return DATE_MONTH_FORMATTER.format(date)}
// формат `ГГГГ.MM.ДД` const DATE_FORMATTER = new Intl.DateTimeFormat('ru', { year: 'numeric', month: 'numeric', day: 'numeric', }) // сокращённое имя месяца const DATE_MONTH_FORMATTER = new Intl.DateTimeFormat('ru', { month: 'short', }) // Функция преобразования timestamp в экземпляр Date function getWeekDate(weekTimestamp) { return new Date(weekTimestamp * 1000) } // Функция преобразования экземпляра Date в строку формата `ГГГГ.MM.ДД` function getDateFormat(date) { return DATE_FORMATTER.format(date) } // Функция преобразования экземпляра Date в строку с сокращённым именем месяца function getMonthName(date) { return DATE_MONTH_FORMATTER.format(date) }
Создадим функцию преобразования полученных данных:
function parseCommitActivity(responseData = []) { if (!Array.isArray(responseData)) { throw new Error('Данные не найдены') } // текущая дата const currDate = new Date() let isFirstWeekOfMonth // массив данных о неделях return responseData.map((weekItem, weekIndex) => { const { total, days: commitsPerDay, week: weekTimestamp } = weekItem // Date-объект первого дня недели const weekDate = getWeekDate(weekTimestamp) // число месяца первого дня недели const firstWeekDay = weekDate.getDate() let dayDate // массив данных по дням недели const days = commitsPerDay.map((count, dayIndex) => { // Date-объект для дня недели dayDate = new Date(weekDate) dayDate.setDate(firstWeekDay + dayIndex) // если этот день ещё не наступил, возвращяем пустой объект if (dayDate > currDate) { return {} } // дата дня в формате `ГГГГ.MM.ДД` const dateFormated = getDateFormat(dayDate) return { count, dateFormated } }) // число месяца последнего дня недели const lastDay = dayDate.getDate() // для этой недели требуется отображать название месяца? showMonthName = (weekIndex === 0 && firstWeekDay < 10) || lastDay <= 7 return { total, weekDate, days, month: showMonthName ? getMonthName(dayDate) : '' } })}
function parseCommitActivity(responseData = []) { if (!Array.isArray(responseData)) { throw new Error('Данные не найдены') } // текущая дата const currDate = new Date() let isFirstWeekOfMonth // массив данных о неделях return responseData.map((weekItem, weekIndex) => { const { total, days: commitsPerDay, week: weekTimestamp } = weekItem // Date-объект первого дня недели const weekDate = getWeekDate(weekTimestamp) // число месяца первого дня недели const firstWeekDay = weekDate.getDate() let dayDate // массив данных по дням недели const days = commitsPerDay.map((count, dayIndex) => { // Date-объект для дня недели dayDate = new Date(weekDate) dayDate.setDate(firstWeekDay + dayIndex) // если этот день ещё не наступил, возвращяем пустой объект if (dayDate > currDate) { return {} } // дата дня в формате `ГГГГ.MM.ДД` const dateFormated = getDateFormat(dayDate) return { count, dateFormated } }) // число месяца последнего дня недели const lastDay = dayDate.getDate() // для этой недели требуется отображать название месяца? showMonthName = (weekIndex === 0 && firstWeekDay < 10) || lastDay <= 7 return { total, weekDate, days, month: showMonthName ? getMonthName(dayDate) : '' } }) }
Мы хотим отображать общее количество коммитов за год.
Кроме этого определим минимальное (min
) и максимальное (max
) число коммитов за день. Добавим функцию получения этих данных из массива сформированного ранее:
function analyzeCommits(commitsData = []) { return commitsData.reduce((acc, weekData) => { const counts = weekData.days.map(item => item.count ?? 0) acc.min = Math.min(...counts, acc.min) acc.max = Math.max(...counts, acc.max) acc.total += weekData.total return acc }, {min: Number.POSITIVE_INFINITY, max: 0, total: 0})}
function analyzeCommits(commitsData = []) { return commitsData.reduce((acc, weekData) => { const counts = weekData.days.map(item => item.count ?? 0) acc.min = Math.min(...counts, acc.min) acc.max = Math.max(...counts, acc.max) acc.total += weekData.total return acc }, {min: Number.POSITIVE_INFINITY, max: 0, total: 0}) }
TODO! описать функцию создания массива дней недели
function createWeekDays(commitsData = []) { const [firstWeek] = commitsData if (!firstWeek) return const {days, weekDate} = firstWeek const firstWeekDay = weekDate.getDate() const weekDays = days.reduce((acc, dayData, dayIndex) => { if (dayIndex % 2) { let dayDate = new Date(weekDate) dayDate.setDate(firstWeekDay + dayIndex) acc.push(getWeekDayFormat(dayDate)) } return acc }, []) return weekDays}
function createWeekDays(commitsData = []) { const [firstWeek] = commitsData if (!firstWeek) return const {days, weekDate} = firstWeek const firstWeekDay = weekDate.getDate() const weekDays = days.reduce((acc, dayData, dayIndex) => { if (dayIndex % 2) { let dayDate = new Date(weekDate) dayDate.setDate(firstWeekDay + dayIndex) acc.push(getWeekDayFormat(dayDate)) } return acc }, []) return weekDays }
Определение цветов
СкопированоКак отмечалось ранее, идея тепловой карты состоит в отображении данных с помощью цвета. В нашем случае данные — это число коммитов за день. Чем больше это число, тем ярче цвет плитки.
Будем для краткости использовать термин «палитра» — набор цветов (оттенков), созданных на основе базового цвета. Цвета в нашей палитре располагаются от тёмного ( цвет для значения 0
) к светлому (соответствует максимальному значению в диапазоне).
При формировании палитры следует учесть зависимость необходимого количества цветов от диапазона чисел. Так как размер диапазона чисел заранее неизвестен, возникает вопрос: как правильно подобрать палитру? Чем больший диапазон чисел, тем больше цветов потребуется. При этом может ухудшится наглядность, так как будет меньше заметна разница оттенков. С другой стороны, если диапазон значений слишком мал, в палитре окажется всего несколько цветов. В этом случае контраст оттенков не будет соответствовать незначительной разнице значений. Чтобы избежать этих крайностей, добавим константы, определяющие минимальное и максимальное количество цветов.
const COLOR_MAX_STEPS = 15const COLOR_MIN_STEPS = 5
const COLOR_MAX_STEPS = 15 const COLOR_MIN_STEPS = 5
Определим базовый цвет палитры (цвет соответствующий максимальному количеству коммитов). Для цветов мы будем использовать цветовую модель HSL. Эта модель подходит для нашей задачи, так как позволяет получать новый оттенок изменяя параметр светлоты (Lightness) базового цвета.
const BASE_HSL = [112, 100, 80] // ярко зелёный
const BASE_HSL = [112, 100, 80] // ярко зелёный
Вынос этих параметров в константы позволит в дальнейшем упростить изменение палитры цветов.
Определим требуемое количество цветов (steps
) и сформируем палитру на основе полученных ранее значений min
и max
:
function makeColors(commitCounts, baseColor = BASE_HSL) { const {min, max} = commitCounts const steps = Math.max(Math.min(max - min, COLOR_MAX_STEPS), COLOR_MIN_STEPS) return generatePalette(baseColor, steps)}
function makeColors(commitCounts, baseColor = BASE_HSL) { const {min, max} = commitCounts const steps = Math.max(Math.min(max - min, COLOR_MAX_STEPS), COLOR_MIN_STEPS) return generatePalette(baseColor, steps) }
function generatePalette(hslColor, steps) { const [h, s, lBase] = hslColor const palette = [] let lStep for (let i = 0; i < steps - 1; i++) { // определяем светлоту цвета lStep = lBase - (lBase * i) / steps lStep = Math.max(0, lStep) // добавляем в палитру цвет на основе базового palette.push(`hsl(${h}, ${s}%, ${Math.round(lStep)}%)`) } // добавим в палитру цвет, соответствующий отсутствию коммитов const lEmpty = Math.min(9, Math.max(9, Math.round(lStep/2))) palette.push(`hsl(${Math.round(h/1.05)}, ${Math.round(s/4)}%, ${lEmpty}%)`) // return palette.reverse()}
function generatePalette(hslColor, steps) { const [h, s, lBase] = hslColor const palette = [] let lStep for (let i = 0; i < steps - 1; i++) { // определяем светлоту цвета lStep = lBase - (lBase * i) / steps lStep = Math.max(0, lStep) // добавляем в палитру цвет на основе базового palette.push(`hsl(${h}, ${s}%, ${Math.round(lStep)}%)`) } // добавим в палитру цвет, соответствующий отсутствию коммитов const lEmpty = Math.min(9, Math.max(9, Math.round(lStep/2))) palette.push(`hsl(${Math.round(h/1.05)}, ${Math.round(s/4)}%, ${lEmpty}%)`) // return palette.reverse() }