Веб-проект: часы столетия с потрясающей анимацией

Создаём универсальные часы с точностью от секунды до года

Веб-проект: часы столетия с потрясающей анимацией

Сегодня будем заниматься красотой и анимациями: сделаем универсальные часы с движущимся циферблатом, который покажет нам всё текущее время от года до секунд. А ещё прикрутим сюда выбор языка, чтобы название месяца и дня недели отображалось правильно в разных странах. Внутри много хардкора и кода, а на выходе будет такое:

Логика проекта

Обычно в наших проектах всё происходит только в скрипте, но не в этот раз: от стилей тут тоже много зависит — анимация, расположение элементов и их внешний вид. Поэтому будем делать так:

  1. Создадим сразу все нужные файлы — index.html, style.css и script.js. На старте они будут пустые, а в процессе мы их наполним.
  2. Разместим на странице в блоках два контейнера: один для циферблата, а второй — для выбора языка. Внутри циферблата создадим тоже много контейнеров, каждый из которых будет отвечать за что-то своё: минуты, секунды, дни недели и так далее.
  3. Добавим в эти контейнеры элементы с помощью скрипта.
  4. Настроим стили, которые превратят то, что выдал скрипт, в круглый циферблат.
  5. Добавим анимацию вращения.
  6. Наконец, добавим выбор языков (тоже с анимацией).

Работы много, поэтому приступим.

Наполняем index.html

Так как у нас будет много данных для вывода, создадим много контейнеров для их структурирования. Чтобы всем этим было проще управлять, данные о времени объединим в один большой контейнер — так мы сможем применять некоторые стили сразу ко всем контейнерам внутри.

В центр часов положим кнопку выбора языка и сделаем её в виде флага (но это чуть позже, в скрипте). Отдельно сделаем контейнер с диалоговым окном выбора языка — там будет кнопка закрытия и блок со всеми языками-флагами.

Из классики: подключение стилей в самом начале и скрипта — в конце. Вроде всё, пишем код. Внешне на странице почти ничего не изменится, но нам главное сейчас — собрать каркас:

<!DOCTYPE html>
<html lang="ru-RU">
<head>
  <!-- Базовые настройки документа -->
  <meta charset="UTF-8"> <!-- Кодировка UTF-8 -->
  <title>Часы на 100 лет вперёд</title> <!-- Заголовок страницы -->
  <link rel="stylesheet" href="style.css"> <!-- Подключение стилей -->
</head>
<body>
<!-- Основная структура часов -->
<div class="clock" data-date="2025-04-55">
  <!-- Контейнеры для каждого типа времени (годы, секунды и так далее) -->
  <div>
    <div data-clock="years" data-numbers="101" class="clock-face"></div> <!-- 100-летний круг -->
  </div>
  <div>
    <div data-clock="seconds" data-numbers="60" class="clock-face"></div> <!-- Секунды -->
  </div>
  <div>
    <div data-clock="minutes" data-numbers="60" class="clock-face"></div> <!-- Минуты -->
  </div>
  <div>
    <div data-clock="hours" data-numbers="24" class="clock-face"></div> <!-- Часы -->
  </div>
  <div>
    <div data-clock="days" data-numbers="31" class="clock-face"></div> <!-- Дни месяца -->
  </div>
  <div>
    <div data-clock="months" data-numbers="12" class="clock-face"></div> <!-- Месяцы -->
  </div>
  <div>
    <div data-clock="day-names" data-numbers="7" class="clock-face"></div> <!-- Дни недели -->
  </div>
  <!-- Кнопка выбора языка -->
  <button type="button" id="current-lang" class="current-lang-display">en</button>
</div>

<!-- Диалоговое окно выбора языка -->
<dialog id="language-dialog">
  <!-- Кнопка закрытия диалога -->
  <button type="button" id="btn-dialog-close" class="btn-dialog-close " autofocus>✕</button>
  <!-- Контейнер для вариантов языков -->
  <div id="language-options" class="language-options"></div>
</dialog>

<!-- Подключение основного скрипта -->
<script src="script.js"></script>
</body>
</html>

Подготавливаем скрипт

Раньше мы приступали к стилям после создания HTML-файла, но не в этот раз. Всё дело в том, что сейчас нам просто нечего оформлять: все данные будут генерироваться в скрипте. А вот когда сгенерируем — тогда и оформим.

Первое, что мы делаем, — создаём константы, которые будут потом часто использоваться в проекте. Самое большое — это локализация и выбор языка, поэтому опишем это в самом начале:

// Массив поддерживаемых языков с флагами
const languageFlags = [
  { code: 'ar-SA', name: 'Arabic (Saudi Arabia)', flag: '🇸🇦' },
  { code: 'cs-CZ', name: 'Czech (Czech Republic)', flag: '🇨🇿' },
  { code: 'da-DK', name: 'Danish (Denmark)', flag: '🇩🇰' },
  { code: 'de-DE', name: 'German (Germany)', flag: '🇩🇪' },
  { code: 'el-GR', name: 'Greek (Greece)', flag: '🇬🇷' },
  { code: 'en-US', name: 'English (US)', flag: '🇺🇸' },
  { code: 'en-GB', name: 'English (UK)', flag: '🇬🇧' },
  { code: 'es-ES', name: 'Spanish (Spain)', flag: '🇪🇸' },
  { code: 'es-MX', name: 'Spanish (Mexico)', flag: '🇲🇽' },
  { code: 'fi-FI', name: 'Finnish (Finland)', flag: '🇫🇮' },
  { code: 'fr-CA', name: 'French (Canada)', flag: '🇨🇦' },
  { code: 'fr-FR', name: 'French (France)', flag: '🇫🇷' },
  { code: 'he-IL', name: 'Hebrew (Israel)', flag: '🇮🇱' },
  { code: 'hi-IN', name: 'Hindi (India)', flag: '🇮🇳' },
  { code: 'hu-HU', name: 'Hungarian (Hungary)', flag: '🇭🇺' },
  { code: 'it-IT', name: 'Italian (Italy)', flag: '🇮🇹' },
  { code: 'ja-JP', name: 'Japanese (Japan)', flag: '🇯🇵' },
  { code: 'ko-KR', name: 'Korean (South Korea)', flag: '🇰🇷' },
  { code: 'nl-NL', name: 'Dutch (Netherlands)', flag: '🇳🇱' },
  { code: 'no-NO', name: 'Norwegian (Norway)', flag: '🇳🇴' },
  { code: 'pl-PL', name: 'Polish (Poland)', flag: '🇵🇱' },
  { code: 'pt-BR', name: 'Portuguese (Brazil)', flag: '🇧🇷' },
  { code: 'pt-PT', name: 'Portuguese (Portugal)', flag: '🇵🇹' },
  { code: 'ro-RO', name: 'Romanian (Romania)', flag: '🇷🇴' },
  { code: 'ru-RU', name: 'Russian (Russia)', flag: '🇷🇺' },
  { code: 'sv-SE', name: 'Swedish (Sweden)', flag: '🇸🇪' },
  { code: 'th-TH', name: 'Thai (Thailand)', flag: '🇹🇭' },
  { code: 'tr-TR', name: 'Turkish (Turkey)', flag: '🇹🇷' },
  { code: 'vi-VN', name: 'Vietnamese (Vietnam)', flag: '🇻🇳' },
  { code: 'zh-CN', name: 'Chinese (Simplified, China)', flag: '🇨🇳' },
];

Дальше зададим радиус круга для выбора языка (это пригодится позже) и получим доступ к разным элементам на странице по их Id (и начнём уже ими пользоваться):

const RADIUS = 140; // Радиус круга для кнопок выбора языка

// Получаем DOM-элементы по их Id
const currentLangDisplay = document.getElementById('current-lang');
const languageDialog = document.getElementById('language-dialog');
const languageOptionsContainer = document.getElementById('language-options');
const closeButton = document.getElementById('btn-dialog-close');

Выбираем стартовый язык

Так как мы решили поддерживать сразу много языков и давать пользователю возможность выбора, нам нужно научиться с ними работать. 

Логика тут такая:

  1. Нам нужно установить язык («локаль» по-программистски), который будет выводиться при запуске проекта.
  2. Для этого мы пишем функцию getLocale() — она определит стартовый язык из настроек страницы. Иногда функция определения может не срабатывать в некоторых браузерах, поэтому установим язык по умолчанию (английский). 
  3. Если язык в настройках страницы указан коротко, то с помощью defaultRegions мы найдём полное название языка и вернём его в функцию getLocale().
  4. JavaScript умеет автоматически работать с выбранными нами языками — это значит, что при смене локали скрипт сам подставит нужное название месяца и дня недели на выбранном языке. Вот такая удобная автоматизация.

Запишем это на JavaScript:

// Создание карты регионов по умолчанию
const defaultRegions = languageFlags.reduce((map, lang) => {
  const baseLang = lang.code.split('-')[0]; // Извлекаем базовый язык (например, 'en' из 'en-US')
  if (!map[baseLang]) {
    map[baseLang] = lang.code; // Сохраняем язык
  }
  return map;
}, {});

// Функция определения текущей локали
function getLocale() {
  // Получаем основной язык из navigator.languages или navigator.language
  let language = (navigator.languages && navigator.languages[0]) || navigator.language || 'en-US';

  // Если язык указан коротко (например, 'en'), добавляем регион из defaultRegions
  if (language.length === 2) {
    language = defaultRegions[language] || `${language}-${language.toUpperCase()}`;
  }
  // Возвращаем язык
  return language;
}

let locale = getLocale(); // Текущая локаль

На экране всё ещё ничего нет, зато мы определились с языком. Можно двигаться дальше.

Выводим данные для циферблата

Продолжаем работать со скриптом: теперь получим и выведем на экран все данные, которые нам нужны для работы часов. Для этого напишем функцию drawClockFaces(), которая даст нам всё что нужно для дальнейших действий.

Что делает эта функция:

  1. Получает текущую дату и время во всех подробностях от секунды до года.
  2. Получает название дня недели и месяца.
  3. Дальше идёт работа с каждым контейнером-циферблатом по отдельности.
  4. Получает количество элементов в каждом контейнере (их мы задали в HTML в блоке data-numbers).
  5. Задаёт радиус для каждого контейнера и его центр вращения.
  6. В каждый контейнер добавляет свои значения в зависимости от циферблата. Если это секунды и минуты — то значения от 0 до 59, если это годы — то от 2000 до 2100 и так далее.
  7. Создаёт на странице все элементы из каждого списка.
  8. Вычисляет их позицию и угол поворота в зависимости от места элемента и добавляет эти свойства к каждому элементу.
  9. Устанавливает все часы в стартовое значение по умолчанию.

👉 В скрипте есть сразу неочевидный момент: мы обращаемся к стилям и определяем их свойства, хотя у нас нет ещё заполненного файла style.css. Так можно делать, потому что скрипт создаёт стили на уже загруженной странице — и с ними тоже можно работать: создавать, менять значения и удалять.

// Функция отрисовки циферблатов
function drawClockFaces() {
    // Получаем доступ к элементам циферблата
    const clockFaces = document.querySelectorAll('.clock-face');

    // Получаем текущую дату
    const currentDate = new Date();
    const currentDay = currentDate.getDate();
    const currentMonth = currentDate.getMonth();
    const currentYear = currentDate.getFullYear();
    const currentWeekday = currentDate.getDay();
    const currentHours = currentDate.getHours();
    const currentMinutes = currentDate.getMinutes();
    const currentSeconds = currentDate.getSeconds();
    const totalDaysInMonth = new Date(currentYear, currentMonth + 1, 0).getDate();

    // Получаем названия дней недели и месяцев для текущей локали
    const weekdayNames = Array.from({ length: 7 }, (_, i) =>
        new Intl.DateTimeFormat(locale, { weekday: 'long' }).format(new Date(2021, 0, i + 3))
    );
    const monthNames = Array.from({ length: 12 }, (_, i) =>
        new Intl.DateTimeFormat(locale, { month: 'long' }).format(new Date(2021, i))
    );

    // Обрабатываем каждый циферблат
    clockFaces.forEach(clockFace => {
        clockFace.innerHTML = ''; // Очищаем содержимое

        const clockType = clockFace.getAttribute('data-clock'); // Тип циферблата
        const numbers = parseInt(clockFace.getAttribute('data-numbers'), 10); // Количество значений
        const RADIUS = (clockFace.offsetWidth / 2) - 20; // Радиус для позиционирования
        const center = clockFace.offsetWidth / 2; // Центр циферблата

        let valueSet; // Набор значений (секунды, минуты и так далее)
        let currentValue; // Текущее значение

        // Определяем набор значений в зависимости от типа циферблата
        switch (clockType) {
            case 'seconds':
                valueSet = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0'));
                currentValue = String(currentSeconds).padStart(2, '0');
                break;
            case 'minutes':
                valueSet = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0'));
                currentValue = String(currentMinutes).padStart(2, '0');
                break;
            case 'hours':
                valueSet = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'));
                currentValue = String(currentHours).padStart(2, '0');
                break;
            case 'days':
                valueSet = Array.from({ length: totalDaysInMonth }, (_, i) => i + 1);
                currentValue = currentDay;
                break;
            case 'months':
                valueSet = monthNames;
                currentValue = currentMonth;
                break;
            case 'years':
                valueSet = Array.from({ length: 101 }, (_, i) => 2000 + i);
                currentValue = currentYear;
                break;
            case 'day-names':
                valueSet = weekdayNames;
                currentValue = currentWeekday;
                break;
            default:
                return;
        }

        // Создаём элементы для каждого значения
        valueSet.forEach((value, i) => {
            const angle = (i * (360 / numbers)); // Угол для позиционирования
            const x = center + RADIUS * Math.cos((angle * Math.PI) / 180); // X координата
            const y = center + RADIUS * Math.sin((angle * Math.PI) / 180); // Y координата

            const element = document.createElement('span');
            element.classList.add('number');
            element.textContent = value;
            element.style.left = `${x}px`;
            element.style.top = `${y}px`;
            element.style.transform = `translate(-50%, -50%) rotate(${angle}deg)`;

            clockFace.appendChild(element);
        });

        // Вращаем циферблат, чтобы текущее значение было в позиции "3 часа"
        const currentIndex = valueSet.indexOf(
            typeof valueSet[0] === 'string' ? String(currentValue) : currentValue
        );
        const rotationAngle = -((currentIndex / numbers) * 360);
        clockFace.style.transform = `rotate(${rotationAngle}deg)`;
    });
}

Если мы сейчас вызовем эту функцию, то получим что-то очень странное на странице. Время подключать нормальные стили:

Настраиваем стили

Открываем файл style.css и первое, что делаем, — сбрасываем стандартные стили, задаём переменные и устанавливаем основные стили страницы: шрифт, цвета, отступы и фон:

/* Сброс стандартных стилей */
*,
::before,
::after {
  box-sizing: border-box; /* Упрощает расчёт размеров элементов */
}

/* CSS-переменные для цветов и размеров */
:root {
  --clr-bg: rgb(3 3 3); /* Цвет фона */
  --clock-size: 800px; /* Размер часов */
  --clock-clr: rgb(12, 74, 110); /* Основной цвет часов */
}

/* Основные стили страницы */
body {
  margin: 0;
  min-height: 100svh; /* Минимальная высота = высоте viewport */
  display: grid;
  place-content: center; /* Центрирование содержимого */
  font-family: system-ui; /* Системный шрифт */
  background-color: var(--clr-bg);
  background-image: radial-gradient(rgb(8, 47, 73),rgb(8, 47, 60));
  background-blend-mode: difference; /* Эффект наложения фона */
}
Стало не сильно лучше, зато появился какой-то стиль

Теперь займёмся основным контейнером со всеми циферблатами. Здесь нам нужно:

  1. Сделать круг (потому что это классические часы).
  2. Поместить его по центру.
  3. Сразу предусмотреть то, что проект могут открыть на мобилке.
  4. Добавить общие стили для каждого круга (с минутами, секундами и прочим) — радиус, центр и всё такое.
  5. Задать индивидуальные размеры для каждого кольца.

Запишем это в виде CSS-команд:

/* Основной контейнер часов */
.clock {
  position: fixed;
  inset: 0; /* Растягиваем на весь экран */
  margin: auto; /* Центрирование */
  width: var(--clock-size);
  height: var(--clock-size);
  aspect-ratio: 1; /* Сохраняем форму круга */
  place-content: center;
  background: var(--clock-clr);
  border-radius: 50%; /* Делаем круг */
}

/* Адаптация для мобильных */
@media (width < 800px) {
  .clock {
    left: 0;
    right: auto;
    translate: calc((50% - 2rem) * -1) 0; /* Сдвигаем влево */
  }
}

/* Стили для каждого кольца часов */
.clock > div {
  position: absolute;
  inset: 0;
  margin: auto;
  width: var(--clock-d);
  height: var(--clock-d);
  font-size: var(--f-size, 0.9rem);
  aspect-ratio: 1;
  isolation: isolate; /* Изолируем контекст наложения */
  border-radius: 50%;
}

/* Индивидуальные размеры для каждого кольца */
.clock > div:nth-of-type(1) { --clock-d: calc(var(--clock-size) - 20px); } /* Годы */
.clock > div:nth-of-type(2) { --clock-d: calc(var(--clock-size) - 130px); } /* Секунды */
.clock > div:nth-child(3) { --clock-d: calc(var(--clock-size) - 195px); } /* Минуты */
.clock > div:nth-child(4) { --clock-d: calc(var(--clock-size) - 260px); } /* Часы */
.clock > div:nth-child(5) { --clock-d: calc(var(--clock-size) - 350px); } /* Дни */
.clock > div:nth-child(6) { --clock-d: calc(var(--clock-size) - 470px); } /* Месяцы */
.clock > div:nth-child(7) { --clock-d: calc(var(--clock-size) - 600px); } /* Дни недели */
Стало не лучше, зато появился круг — это уже вселяет надежду

Теперь делаем основную красоту: добавим стили ко всем причастным контейнерам для того, чтобы они выровняли содержимое каждого блока по кругу. Для этого мы включаем вращение вокруг центра, запрещаем переносить текст и применяем это ко всем нужным контейнерам:

/* Стили циферблатов */
.clock-face {
  position: relative;
  width: 100%;
  height: 100%;
  aspect-ratio: 1;
  border-radius: 50%;
  transition: 300ms linear; /* Плавное вращение */
}

/* Стили цифр на циферблате */
.clock-face > * {
  position: absolute;
  transform-origin: center; /* Вращение вокруг центра */
  white-space: nowrap; /* Запрет переноса текста */
  color: white;
  opacity: 0.75;
}

/* Активная цифра (текущее значение) */
.clock-face > *.active {
  opacity: 1;
}
Ура, красота! Можно допиливать дальше до идеала…

После последних действий стало намного красивее, но это не предел. Сейчас мы видим, что иконка флага выглядит чужеродно: стоит слева, имеет белый фон и явно выбивается по стилю. Поправим это: поместим кнопку с флагом в центр, поменяем фон, добавим круглую рамку и поставим иконку поверх всего циферблата:

/* Кнопка текущего языка */
.clock > .current-lang-display {
  position: absolute;
  inset: 0;
  margin: auto;
  z-index: 100;
  display: grid;
  place-content: center;
  background-color: var(--clock-clr);
  border: 1px solid rgba(255 255 255 / 0.25);
  color: white;
  border-radius: 50%;
  width: 40px;
  height: 40px;
  aspect-ratio: 1/1;
  cursor: pointer;
  transition: 300ms ease-in-out;
  font-size: 1.5rem;
  outline: none;
}

Наконец, добавим красоты, чтобы подсветить текущее время: сделаем простую маску, чтобы она выделяла актуальные данные:

/* Полупрозрачная маска для подсветки текущего времени */
.clock::before {
  content: "";
  position: absolute;
  inset: 1px;
  margin: auto;
  background-color: rgba(0 0 0 / 0.85);
  clip-path: polygon(
    0 0,
    100% 0,
    100% 48%,
    50% 48%,
    50% 52%,
    100% 52%,
    100% 100%,
    0 100%
  ); /* Создаём "разрез" в маске */
  border-radius: 50%;
  z-index: 20; /* Поверх других элементов */
}

Работаем с языками

Перед тем как добавлять анимации, сделаем крутую фичу — выбор языка. JavaScript умеет это делать автоматически, поэтому наша задача — объяснить, что и где заменить. 

Первое, что для этого сделаем, — скрытие названия текущего языка, чтобы при выборе других он нам не мешал. Причём сделаем относительно абсурдно: сначала выведем название языка, потом предусмотрим, чтобы при наведении на другой флаг язык всё-таки показывался, и сделаем функцию скрытия названия для языка, чтобы не показывался текущий.

Запишем это на JS:

// Показ названия языка в центре
let titleDisplay = null;
function showTitle(languageName) {
  if (titleDisplay) {
    titleDisplay.remove();
  }
  titleDisplay = document.createElement('div');
  titleDisplay.classList.add('language-title');
  titleDisplay.textContent = languageName;
  languageOptionsContainer.appendChild(titleDisplay);
}

// Скрытие названия языка
function hideTitle() {
  if (titleDisplay) {
    titleDisplay.textContent = '';
  }
}

// Обновление отображения текущего языка
function setCurrentLangDisplay(lang) {
  currentLangDisplay.textContent = lang.flag;
  currentLangDisplay.title = lang.name;
  showTitle(lang.name);
}

Теперь добавим функции управления диалоговым окном, которое появится при нажатии на флаг в центре. У нас ещё нет обработчика этого события, поэтому это тоже предусмотрим в коде:

// Управление диалоговым окном
function openDialog() {
  languageDialog.showModal();
  createLanguageOptions();
  addDialogCloseListener();
}

// Закрыть диалоговое окно
function closeDialog() {
  languageDialog.close();
  removeLanguageOptions();
  removeDialogCloseListener();
}

// Убрать опции выбора языка
function removeLanguageOptions() {
  languageOptionsContainer.innerHTML = '';
}

// Добавляем обработчики событий для кликов снаружи поля выбора 
function addDialogCloseListener() {
  languageDialog.addEventListener('click', closeDialogOnClickOutside);
}

function removeDialogCloseListener() {
  languageDialog.removeEventListener('click', closeDialogOnClickOutside);
}

// Закрытие диалога при клике вне его области
function closeDialogOnClickOutside(e) {
  if (e.target === languageDialog) {
    closeDialog();
  }
}

Чтобы наши механики работали, добавим обработчики событий по кликам:

// Обработчики событий
closeButton.addEventListener('click', closeDialog);
currentLangDisplay.addEventListener('click', openDialog);

Наконец, напишем большую функцию, которая загрузит все флаги в диалоговое окно и даст пользователю возможность выбора языка. Логика такая:

  1. Загружаем все флаги из стартового массива.
  2. Считаем относительно круга позицию каждого флага (сейчас мы этого не увидим, потому что нет стилей, но в будущем пригодится).
  3. Каждый флаг добавляем на страницу в элемент label.
  4. Добавляем радиокнопку к каждому флагу, чтобы можно было выбрать только один язык из предложенных.
  5. Добавляем обработчики событий, если пользователь передумал выбирать язык.
  6. Пишем обработчик, который при выборе языка даст команду обновить надписи в нужных элементах.

Вот как это выглядит в коде:

// Создание кнопок выбора языка
function createLanguageOptions() {
  const centerX = languageOptionsContainer.offsetWidth / 2;
  const centerY = languageOptionsContainer.offsetHeight / 2;

  // Создаём кнопки для каждого языка
  languageFlags.forEach((lang, index, arr) => {
    const angle = (index / arr.length) * 2 * Math.PI; // Угол для позиционирования
    const x = centerX + RADIUS * Math.cos(angle); // X координата
    const y = centerY + RADIUS * Math.sin(angle); // Y координата

    // Создаём элемент label
    const radioWrapper = document.createElement('label');
    radioWrapper.title = lang.name;
    radioWrapper.style.left = `${x}px`;
    radioWrapper.style.top = `${y}px`;

    // Создаём радиокнопку
    const radioInput = document.createElement('input');
    radioInput.type = 'radio';
    radioInput.name = 'language';
    radioInput.value = lang.code;

    // Помечаем текущий язык
    if (lang.code === locale) {
      radioInput.checked = true;
      radioWrapper.classList.add('active');
    }

    // Создаём элемент флага
    const flag = document.createElement('span');
    flag.classList.add('flag-icon');
    flag.innerText = lang.flag;

    // Собираем структуру
    radioWrapper.appendChild(radioInput);
    radioWrapper.appendChild(flag);
    languageOptionsContainer.appendChild(radioWrapper);

    // Обработчики событий
    radioWrapper.addEventListener('mouseover', () => showTitle(lang.name, radioWrapper));
    radioWrapper.addEventListener('mouseleave', hideTitle);
  
    // Смена языка
    radioInput.addEventListener('change', () => {
      locale = radioInput.value;
      setCurrentLangDisplay(lang);
      drawClockFaces();
      document.querySelector('label.active')?.classList.remove('active');
      radioWrapper.classList.add('active');
      closeDialog();
    });
  });
}

После всех обновлений при клике на флаг получаем такую картинку — выглядит некрасиво, поэтому снова возвращаемся к стилям.

Добавляем стили к окну с языками

Возвращаемся к файлу style.css, чтобы настроить красивый внешний вид диалогового окна выбора языков. Вот что нам нужно тут сделать:

  1. Прописать общий стиль окна — сделать его круглым, как и всё остальное на странице.
  2. Добавить анимацию открытия и эффектов при наведении на другой флаг.
  3. Настроить внешний вид кнопки закрытия окна (если пользователь передумал выбирать язык) и убрать её из центра, чтобы она не закрывала текущий флаг.
  4. Оформить красиво флаги и добавить анимацию наведения на них.
  5. Написать название языка при наведении на флаг.
  6. Скрыть радиокнопки, чтобы они не мешали интерфейсу.

Как обычно, прокомментировали каждый блок, чтобы было проще понять, что происходит в каждом:

/* Стили диалогового окна */
dialog {
  width: min(calc(100% - 2rem), 380px); /* Адаптивная ширина */
  padding: 1rem;
  border: none;
  border-radius: 999px; /* Округлая форма */
  background: rgba(0 0 0 / 0.25);
  text-align: center;
  aspect-ratio: 1;
  overflow: visible;
}

/* Анимация открытия */
@starting-style {
  opacity: 0;
  scale: 0;
}
transition: opacity 500ms ease-in,
  scale 500ms cubic-bezier(0.28, -0.55, 0.27, 1.55);

/* Затемнение фона */
dialog[open]::backdrop {
  background-color: rgba(from black r g b / 0.5);
  backdrop-filter: blur(3px); /* Размытие фона */
  opacity: 1;
}

/* Кнопка закрытия диалога */
dialog .btn-dialog-close {
  position: absolute;
  top: 0rem;
  right: 25%;
  aspect-ratio: 1;
  width: 40px;
  height: 40px;
  border-radius: 50%;
  background-color: black;
  font-size: 1.2rem;
  color: white;
  border: none;
  outline: none;
  cursor: pointer;
  transition: rotate 300ms ease-in-out;
  z-index: 11;
}

/* Эффекты кнопки закрытия */
.btn-dialog-close:focus-visible,
.btn-dialog-close:hover {
  rotate: 90deg;
}

/* Контейнер вариантов языков */
.language-options {
  position: absolute;
  inset: 0;
  margin: auto;
  border-radius: 50%;
  aspect-ratio: 1/1;
  overflow: hidden;
}

/* Элементы выбора языка */
.language-options > label {
  position: absolute;
  transform: translate(-50%, -50%);
  cursor: pointer;
  font-size: 0.9rem;
  aspect-ratio: 1/1;
  border-radius: 50%;
  width: 36px;
  height: 36px;
  transition: 300ms ease-in-out;
  display: grid;
  place-content: center;
  transform-origin: center;
}

/* Активный язык */
.language-options > label.active {
  color: white;
  background: var(--clock-clr);
}

/* Эффекты при наведении */
.language-options > label:focus-visible,
.language-options > label:hover {
  scale: 1.1;
  z-index: 2;
}

/* Название языка в центре */
.language-title {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  pointer-events: none; /* Игнорирует клики */
  color: white;
  font-size: 1.2rem;
}

/* Анимация названия */
@starting-style {
  opacity: 0;
}
transition: opacity 300ms ease-in-out;

/* Иконки флагов */
.flag-icon {
  font-size: 1.5rem;
  display: grid;
  place-content: center;
}

/* Скрытие радиокнопок */
.language-options input[type="radio"] {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  border: 0;
  clip: rect(0, 0, 0, 0);
  overflow: hidden;
}

Смотрите, что получилось в итоге:

Добавляем разделители в циферблат

Сейчас визуально всё хорошо, но не хватает привычного двоеточия между минутами и секундами. Добавим это тоже в стилях:

/* Разделители времени (двоеточия) */
.current-lang-display::before,
.current-lang-display::after {
  content: ": ";
  color: white;
  position: absolute;
  z-index: 199;
  top: 50%;
  right: 0;
  font-size: 0.9rem;
  translate: 283px -10px;
}
Теперь всё на месте

Финал — добавляем анимацию

Последнее, что нам осталось сделать в проекте, — добавить анимацию вращения, чтобы часы обновлялись и всегда показывали верное время.

Что будем здесь делать:

  1. Получаем доступ ко всем циферблатам.
  2. Получаем текущие значения времени.
  3. Находим количество элементов в каждом циферблате.
  4. Вычисляем угол поворота при смене времени для каждого циферблата.
  5. Считаем углы поворотов для того, чтобы анимация получалась плавной, а не дёрганой и резкой.
  6. Сохраняем новый угол поворота.
  7. Обновляем активные элементы в каждом циферблате, чтобы знать, на сколько сейчас повёрнуто каждое поле.
  8. Обновляем кадры анимации.

Эта механика — сложная, и чтобы в ней разобраться, надо держать в уме две ключевые вещи: активный элемент и угол поворота. Но если вы помните немного из школьного курса геометрии, то собрать всё вместе будет не очень сложно.

Запишем код на JavaScript:

// Функция анимации вращения циферблатов
function rotateClockFaces() {
    const clockFaces = document.querySelectorAll('.clock-face');

    const lastAngles = {}; // Хранит последние углы для каждого циферблата
    
    function updateRotations() {
        const now = new Date();
        // Получаем текущие значения времени
        const currentSecond = now.getSeconds();
        const currentMinute = now.getMinutes();
        const currentHour = now.getHours();
        const currentDay = now.getDate();
        const currentMonth = now.getMonth();
        const currentYear = now.getFullYear();
        const currentWeekday = now.getDay();

        // Обрабатываем каждый элемент циферблата
        clockFaces.forEach(clockFace => {
            const clockType = clockFace.getAttribute('data-clock');
            // Получаем количество делений в каждом циферблате
            const totalNumbers = parseInt(clockFace.getAttribute('data-numbers'), 10);

            let currentValue;
            switch (clockType) {
                case 'seconds':
                    currentValue = currentSecond;
                    break;
                case 'minutes':
                    currentValue = currentMinute;
                    break;
                case 'hours':
                    currentValue = currentHour;
                    break;
                case 'days':
                    currentValue = currentDay - 1;
                    break;
                case 'months':
                    currentValue = currentMonth;
                    break;
                case 'years':
                    currentValue = currentYear - 2000;
                    break;
                case 'day-names':
                    currentValue = currentWeekday; // 0 = Sunday
                    break;
                default:
                    return;
            }
            // Вычисляем угол поворота на каждом шаге для каждого циферблата
            const targetAngle = (360 / totalNumbers) * currentValue;

            // Получаем последний угол для плавного вращения
            const clockId = clockFace.id || clockType;
            const lastAngle = lastAngles[clockId] || 0;

            // Вычисляем минимальный угол для плавного перехода
            const delta = targetAngle - lastAngle;
            const shortestDelta = ((delta + 540) % 360) - 180;

            // Обновляем вращение
            const newAngle = lastAngle + shortestDelta;
            // Обновляем стиль элемента
            clockFace.style.transform = `rotate(${newAngle * -1}deg)`;

            // Сохраняем новый угол
            lastAngles[clockId] = newAngle;

            // Обновляем активный элемент
            const numbers = clockFace.querySelectorAll('.number');
            numbers.forEach((number, index) => {
                if (index === currentValue) {
                    number.classList.add('active');
                } else {
                    number.classList.remove('active');
                }
            });
        });
        
        // Запрашиваем следующий кадр анимации
        requestAnimationFrame(updateRotations);
    }

    updateRotations();
}

Уф! Справились! Результат — на гифке ниже. Или можно посмотреть, как всё это работает, на странице проекта.

<!DOCTYPE html>
<html lang="ru-RU">
<head>
  <!-- Базовые настройки документа -->
  <meta charset="UTF-8"> <!-- Кодировка UTF-8 -->
  <title>Часы на 100 лет вперёд</title> <!-- Заголовок страницы -->
  <link rel="stylesheet" href="style.css"> <!-- Подключение стилей -->
</head>
<body>
<!-- Основная структура часов -->
<div class="clock" data-date="2025-04-55">
  <!-- Контейнеры для каждого типа времени (годы, секунды и так далее) -->
  <div>
    <div data-clock="years" data-numbers="101" class="clock-face"></div> <!-- 100-летний круг -->
  </div>
  <div>
    <div data-clock="seconds" data-numbers="60" class="clock-face"></div> <!-- Секунды -->
  </div>
  <div>
    <div data-clock="minutes" data-numbers="60" class="clock-face"></div> <!-- Минуты -->
  </div>
  <div>
    <div data-clock="hours" data-numbers="24" class="clock-face"></div> <!-- Часы -->
  </div>
  <div>
    <div data-clock="days" data-numbers="31" class="clock-face"></div> <!-- Дни месяца -->
  </div>
  <div>
    <div data-clock="months" data-numbers="12" class="clock-face"></div> <!-- Месяцы -->
  </div>
  <div>
    <div data-clock="day-names" data-numbers="7" class="clock-face"></div> <!-- Дни недели -->
  </div>
  <!-- Кнопка выбора языка -->
  <button type="button" id="current-lang" class="current-lang-display">en</button>
</div>

<!-- Диалоговое окно выбора языка -->
<dialog id="language-dialog">
  <!-- Кнопка закрытия диалога -->
  <button type="button" id="btn-dialog-close" class="btn-dialog-close " autofocus>✕</button>
  <!-- Контейнер для вариантов языков -->
  <div id="language-options" class="language-options"></div>
</dialog>

<!-- Подключение основного скрипта -->
<script src="script.js"></script>
</body>
</html>

/* Сброс стандартных стилей */
*,
::before,
::after {
  box-sizing: border-box; /* Упрощает расчёт размеров элементов */
}

/* CSS-переменные для цветов и размеров */
:root {
  --clr-bg: rgb(3 3 3); /* Цвет фона */
  --clock-size: 800px; /* Размер часов */
  --clock-clr: rgb(12, 74, 110); /* Основной цвет часов */
}

/* Основные стили страницы */
body {
  margin: 0;
  min-height: 100svh; /* Минимальная высота = высоте viewport */
  display: grid;
  place-content: center; /* Центрирование содержимого */
  font-family: system-ui; /* Системный шрифт */
  background-color: var(--clr-bg);
  background-image: radial-gradient(rgb(8, 47, 73),rgb(8, 47, 60));
  background-blend-mode: difference; /* Эффект наложения фона */
}

/* Основной контейнер часов */
.clock {
  position: fixed;
  inset: 0; /* Растягиваем на весь экран */
  margin: auto; /* Центрирование */
  width: var(--clock-size);
  height: var(--clock-size);
  aspect-ratio: 1; /* Сохраняем квадратную форму */
  place-content: center;
  background: var(--clock-clr);
  border-radius: 50%; /* Делаем круг */
}

/* Адаптация для мобильных */
@media (width < 800px) {
  .clock {
    left: 0;
    right: auto;
    translate: calc((50% - 2rem) * -1) 0; /* Сдвигаем влево */
  }
}

/* Полупрозрачная маска для подсветки текущего времени */
.clock::before {
  content: "";
  position: absolute;
  inset: 1px;
  margin: auto;
  background-color: rgba(0 0 0 / 0.85);
  clip-path: polygon(
    0 0,
    100% 0,
    100% 48%,
    50% 48%,
    50% 52%,
    100% 52%,
    100% 100%,
    0 100%
  ); /* Создаём "разрез" в маске */
  border-radius: 50%;
  z-index: 20; /* Поверх других элементов */
}

/* Стили для каждого кольца часов */
.clock > div {
  position: absolute;
  inset: 0;
  margin: auto;
  width: var(--clock-d);
  height: var(--clock-d);
  font-size: var(--f-size, 0.9rem);
  aspect-ratio: 1;
  isolation: isolate; /* Изолируем контекст наложения */
  border-radius: 50%;
}

/* Индивидуальные размеры для каждого кольца */
.clock > div:nth-of-type(1) { --clock-d: calc(var(--clock-size) - 20px); } /* Годы */
.clock > div:nth-of-type(2) { --clock-d: calc(var(--clock-size) - 130px); } /* Секунды */
.clock > div:nth-child(3) { --clock-d: calc(var(--clock-size) - 195px); } /* Минуты */
.clock > div:nth-child(4) { --clock-d: calc(var(--clock-size) - 260px); } /* Часы */
.clock > div:nth-child(5) { --clock-d: calc(var(--clock-size) - 350px); } /* Дни */
.clock > div:nth-child(6) { --clock-d: calc(var(--clock-size) - 470px); } /* Месяцы */
.clock > div:nth-child(7) { --clock-d: calc(var(--clock-size) - 600px); } /* Дни недели */

/* Стили циферблатов */
.clock-face {
  position: relative;
  width: 100%;
  height: 100%;
  aspect-ratio: 1;
  border-radius: 50%;
  transition: 300ms linear; /* Плавное вращение */
}

/* Стили цифр на циферблате */
.clock-face > * {
  position: absolute;
  transform-origin: center; /* Вращение вокруг центра */
  white-space: nowrap; /* Запрет переноса текста */
  color: white;
  opacity: 0.75;
}

/* Активная цифра (текущее значение) */
.clock-face > *.active {
  opacity: 1;
}

/* Кнопка текущего языка */
.clock > .current-lang-display {
  position: absolute;
  inset: 0;
  margin: auto;
  z-index: 100;
  display: grid;
  place-content: center;
  background-color: var(--clock-clr);
  border: 1px solid rgba(255 255 255 / 0.25);
  color: white;
  border-radius: 50%;
  width: 40px;
  height: 40px;
  aspect-ratio: 1/1;
  cursor: pointer;
  transition: 300ms ease-in-out;
  font-size: 1.5rem;
  outline: none;
}


/* Стили диалогового окна */
dialog {
  width: min(calc(100% - 2rem), 380px); /* Адаптивная ширина */
  padding: 1rem;
  border: none;
  border-radius: 999px; /* Округлая форма */
  background: rgba(0 0 0 / 0.25);
  text-align: center;
  aspect-ratio: 1;
  overflow: visible;
}

/* Анимация открытия */
@starting-style {
  opacity: 0;
  scale: 0;
}
transition: opacity 500ms ease-in,
  scale 500ms cubic-bezier(0.28, -0.55, 0.27, 1.55);

/* Затемнение фона */
dialog[open]::backdrop {
  background-color: rgba(from black r g b / 0.5);
  backdrop-filter: blur(3px); /* Размытие фона */
  opacity: 1;
}

/* Кнопка закрытия диалога */
dialog .btn-dialog-close {
  position: absolute;
  top: 0rem;
  right: 25%;
  aspect-ratio: 1;
  width: 40px;
  height: 40px;
  border-radius: 50%;
  background-color: black;
  font-size: 1.2rem;
  color: white;
  border: none;
  outline: none;
  cursor: pointer;
  transition: rotate 300ms ease-in-out;
  z-index: 11;
}

/* Эффекты кнопки закрытия */
.btn-dialog-close:focus-visible,
.btn-dialog-close:hover {
  rotate: 90deg;
}

/* Контейнер вариантов языков */
.language-options {
  position: absolute;
  inset: 0;
  margin: auto;
  border-radius: 50%;
  aspect-ratio: 1/1;
  overflow: hidden;
}

/* Элементы выбора языка */
.language-options > label {
  position: absolute;
  transform: translate(-50%, -50%);
  cursor: pointer;
  font-size: 0.9rem;
  aspect-ratio: 1/1;
  border-radius: 50%;
  width: 36px;
  height: 36px;
  transition: 300ms ease-in-out;
  display: grid;
  place-content: center;
  transform-origin: center;
}

/* Активный язык */
.language-options > label.active {
  color: white;
  background: var(--clock-clr);
}

/* Эффекты при наведении */
.language-options > label:focus-visible,
.language-options > label:hover {
  scale: 1.1;
  z-index: 2;
}

/* Название языка в центре */
.language-title {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  pointer-events: none; /* Игнорирует клики */
  color: white;
  font-size: 1.2rem;
}

/* Анимация названия */
@starting-style {
  opacity: 0;
}
transition: opacity 300ms ease-in-out;

/* Иконки флагов */
.flag-icon {
  font-size: 1.5rem;
  display: grid;
  place-content: center;
}

/* Скрытие радиокнопок */
.language-options input[type="radio"] {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  border: 0;
  clip: rect(0, 0, 0, 0);
  overflow: hidden;
}

/* Разделители времени (двоеточия) */
.current-lang-display::before,
.current-lang-display::after {
  content: ": ";
  color: white;
  position: absolute;
  z-index: 199;
  top: 50%;
  right: 0;
  font-size: 0.9rem;
  translate: 283px -10px;
}

// Массив поддерживаемых языков с флагами
const languageFlags = [
  { code: 'ar-SA', name: 'Arabic (Saudi Arabia)', flag: '🇸🇦' },
  { code: 'cs-CZ', name: 'Czech (Czech Republic)', flag: '🇨🇿' },
  { code: 'da-DK', name: 'Danish (Denmark)', flag: '🇩🇰' },
  { code: 'de-DE', name: 'German (Germany)', flag: '🇩🇪' },
  { code: 'el-GR', name: 'Greek (Greece)', flag: '🇬🇷' },
  { code: 'en-US', name: 'English (US)', flag: '🇺🇸' },
  { code: 'en-GB', name: 'English (UK)', flag: '🇬🇧' },
  { code: 'es-ES', name: 'Spanish (Spain)', flag: '🇪🇸' },
  { code: 'es-MX', name: 'Spanish (Mexico)', flag: '🇲🇽' },
  { code: 'fi-FI', name: 'Finnish (Finland)', flag: '🇫🇮' },
  { code: 'fr-CA', name: 'French (Canada)', flag: '🇨🇦' },
  { code: 'fr-FR', name: 'French (France)', flag: '🇫🇷' },
  { code: 'he-IL', name: 'Hebrew (Israel)', flag: '🇮🇱' },
  { code: 'hi-IN', name: 'Hindi (India)', flag: '🇮🇳' },
  { code: 'hu-HU', name: 'Hungarian (Hungary)', flag: '🇭🇺' },
  { code: 'it-IT', name: 'Italian (Italy)', flag: '🇮🇹' },
  { code: 'ja-JP', name: 'Japanese (Japan)', flag: '🇯🇵' },
  { code: 'ko-KR', name: 'Korean (South Korea)', flag: '🇰🇷' },
  { code: 'nl-NL', name: 'Dutch (Netherlands)', flag: '🇳🇱' },
  { code: 'no-NO', name: 'Norwegian (Norway)', flag: '🇳🇴' },
  { code: 'pl-PL', name: 'Polish (Poland)', flag: '🇵🇱' },
  { code: 'pt-BR', name: 'Portuguese (Brazil)', flag: '🇧🇷' },
  { code: 'pt-PT', name: 'Portuguese (Portugal)', flag: '🇵🇹' },
  { code: 'ro-RO', name: 'Romanian (Romania)', flag: '🇷🇴' },
  { code: 'ru-RU', name: 'Russian (Russia)', flag: '🇷🇺' },
  { code: 'sv-SE', name: 'Swedish (Sweden)', flag: '🇸🇪' },
  { code: 'th-TH', name: 'Thai (Thailand)', flag: '🇹🇭' },
  { code: 'tr-TR', name: 'Turkish (Turkey)', flag: '🇹🇷' },
  { code: 'vi-VN', name: 'Vietnamese (Vietnam)', flag: '🇻🇳' },
  { code: 'zh-CN', name: 'Chinese (Simplified, China)', flag: '🇨🇳' },
];

const RADIUS = 140; // Радиус круга для кнопок выбора языка

// Получаем DOM-элементы по их Id
const currentLangDisplay = document.getElementById('current-lang');
const languageDialog = document.getElementById('language-dialog');
const languageOptionsContainer = document.getElementById('language-options');
const closeButton = document.getElementById('btn-dialog-close');

// Создание карты регионов по умолчанию
const defaultRegions = languageFlags.reduce((map, lang) => {
  const baseLang = lang.code.split('-')[0]; // Извлекаем базовый язык (например, 'en' из 'en-US')
  if (!map[baseLang]) {
    map[baseLang] = lang.code; // Сохраняем язык
  }
  return map;
}, {});

// Функция определения текущей локали
function getLocale() {
  // Получаем основной язык из navigator.languages или navigator.language
  let language = (navigator.languages && navigator.languages[0]) || navigator.language || 'en-US';

  // Если язык указан коротко (например, 'en'), добавляем регион из defaultRegions
  if (language.length === 2) {
    language = defaultRegions[language] || `${language}-${language.toUpperCase()}`;
  }
  // Возвращаем язык
  return language;
}

let locale = getLocale(); // Текущая локаль

// Функция отрисовки циферблатов
function drawClockFaces() {
    // Получаем доступ к элементам циферблата
    const clockFaces = document.querySelectorAll('.clock-face');

    // Получаем текущую дату
    const currentDate = new Date();
    const currentDay = currentDate.getDate();
    const currentMonth = currentDate.getMonth();
    const currentYear = currentDate.getFullYear();
    const currentWeekday = currentDate.getDay();
    const currentHours = currentDate.getHours();
    const currentMinutes = currentDate.getMinutes();
    const currentSeconds = currentDate.getSeconds();
    const totalDaysInMonth = new Date(currentYear, currentMonth + 1, 0).getDate();

    // Получаем названия дней недели и месяцев для текущей локали
    const weekdayNames = Array.from({ length: 7 }, (_, i) =>
        new Intl.DateTimeFormat(locale, { weekday: 'long' }).format(new Date(2021, 0, i + 3))
    );
    const monthNames = Array.from({ length: 12 }, (_, i) =>
        new Intl.DateTimeFormat(locale, { month: 'long' }).format(new Date(2021, i))
    );

    // Обрабатываем каждый циферблат
    clockFaces.forEach(clockFace => {
        clockFace.innerHTML = ''; // Очищаем содержимое

        const clockType = clockFace.getAttribute('data-clock'); // Тип циферблата
        const numbers = parseInt(clockFace.getAttribute('data-numbers'), 10); // Количество значений
        const RADIUS = (clockFace.offsetWidth / 2) - 20; // Радиус для позиционирования
        const center = clockFace.offsetWidth / 2; // Центр циферблата

        let valueSet; // Набор значений (секунды, минуты и так далее)
        let currentValue; // Текущее значение

        // Определяем набор значений в зависимости от типа циферблата
        switch (clockType) {
            case 'seconds':
                valueSet = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0'));
                currentValue = String(currentSeconds).padStart(2, '0');
                break;
            case 'minutes':
                valueSet = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0'));
                currentValue = String(currentMinutes).padStart(2, '0');
                break;
            case 'hours':
                valueSet = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'));
                currentValue = String(currentHours).padStart(2, '0');
                break;
            case 'days':
                valueSet = Array.from({ length: totalDaysInMonth }, (_, i) => i + 1);
                currentValue = currentDay;
                break;
            case 'months':
                valueSet = monthNames;
                currentValue = currentMonth;
                break;
            case 'years':
                valueSet = Array.from({ length: 101 }, (_, i) => 2000 + i);
                currentValue = currentYear;
                break;
            case 'day-names':
                valueSet = weekdayNames;
                currentValue = currentWeekday;
                break;
            default:
                return;
        }

        // Создаём элементы для каждого значения
        valueSet.forEach((value, i) => {
            const angle = (i * (360 / numbers)); // Угол для позиционирования
            const x = center + RADIUS * Math.cos((angle * Math.PI) / 180); // X координата
            const y = center + RADIUS * Math.sin((angle * Math.PI) / 180); // Y координата

            const element = document.createElement('span');
            element.classList.add('number');
            element.textContent = value;
            element.style.left = `${x}px`;
            element.style.top = `${y}px`;
            element.style.transform = `translate(-50%, -50%) rotate(${angle}deg)`;

            clockFace.appendChild(element);
        });

        // Вращаем циферблат, чтобы текущее значение было в позиции "3 часа"
        const currentIndex = valueSet.indexOf(
            typeof valueSet[0] === 'string' ? String(currentValue) : currentValue
        );
        const rotationAngle = -((currentIndex / numbers) * 360);
        clockFace.style.transform = `rotate(${rotationAngle}deg)`;
    });
}

// Функция анимации вращения циферблатов
function rotateClockFaces() {
    const clockFaces = document.querySelectorAll('.clock-face');

    const lastAngles = {}; // Хранит последние углы для каждого циферблата
    
    function updateRotations() {
        const now = new Date();
        // Получаем текущие значения времени
        const currentSecond = now.getSeconds();
        const currentMinute = now.getMinutes();
        const currentHour = now.getHours();
        const currentDay = now.getDate();
        const currentMonth = now.getMonth();
        const currentYear = now.getFullYear();
        const currentWeekday = now.getDay();

        // Обрабатываем каждый элемент циферблата
        clockFaces.forEach(clockFace => {
            const clockType = clockFace.getAttribute('data-clock');
            // Получаем количество делений в каждом циферблате
            const totalNumbers = parseInt(clockFace.getAttribute('data-numbers'), 10);

            let currentValue;
            switch (clockType) {
                case 'seconds':
                    currentValue = currentSecond;
                    break;
                case 'minutes':
                    currentValue = currentMinute;
                    break;
                case 'hours':
                    currentValue = currentHour;
                    break;
                case 'days':
                    currentValue = currentDay - 1;
                    break;
                case 'months':
                    currentValue = currentMonth;
                    break;
                case 'years':
                    currentValue = currentYear - 2000;
                    break;
                case 'day-names':
                    currentValue = currentWeekday; // 0 = Sunday
                    break;
                default:
                    return;
            }
            // Вычисляем угол поворота на каждом шаге для каждого циферблата
            const targetAngle = (360 / totalNumbers) * currentValue;

            // Получаем последний угол для плавного вращения
            const clockId = clockFace.id || clockType;
            const lastAngle = lastAngles[clockId] || 0;

            // Вычисляем минимальный угол для плавного перехода
            const delta = targetAngle - lastAngle;
            const shortestDelta = ((delta + 540) % 360) - 180;

            // Обновляем вращение
            const newAngle = lastAngle + shortestDelta;
            // Обновляем стиль элемента
            clockFace.style.transform = `rotate(${newAngle * -1}deg)`;

            // Сохраняем новый угол
            lastAngles[clockId] = newAngle;

            // Обновляем активный элемент
            const numbers = clockFace.querySelectorAll('.number');
            numbers.forEach((number, index) => {
                if (index === currentValue) {
                    number.classList.add('active');
                } else {
                    number.classList.remove('active');
                }
            });
        });
        
        // Запрашиваем следующий кадр анимации
        requestAnimationFrame(updateRotations);
    }

    updateRotations();
}

// Создание кнопок выбора языка
function createLanguageOptions() {
  const centerX = languageOptionsContainer.offsetWidth / 2;
  const centerY = languageOptionsContainer.offsetHeight / 2;

  // Создаём кнопки для каждого языка
  languageFlags.forEach((lang, index, arr) => {
    const angle = (index / arr.length) * 2 * Math.PI; // Угол для позиционирования
    const x = centerX + RADIUS * Math.cos(angle); // X координата
    const y = centerY + RADIUS * Math.sin(angle); // Y координата

    // Создаём элемент label
    const radioWrapper = document.createElement('label');
    radioWrapper.title = lang.name;
    radioWrapper.style.left = `${x}px`;
    radioWrapper.style.top = `${y}px`;

    // Создаём радиокнопку
    const radioInput = document.createElement('input');
    radioInput.type = 'radio';
    radioInput.name = 'language';
    radioInput.value = lang.code;

    // Помечаем текущий язык
    if (lang.code === locale) {
      radioInput.checked = true;
      radioWrapper.classList.add('active');
    }

    // Создаём элемент флага
    const flag = document.createElement('span');
    flag.classList.add('flag-icon');
    flag.innerText = lang.flag;

    // Собираем структуру
    radioWrapper.appendChild(radioInput);
    radioWrapper.appendChild(flag);
    languageOptionsContainer.appendChild(radioWrapper);

    // Обработчики событий
    radioWrapper.addEventListener('mouseover', () => showTitle(lang.name, radioWrapper));
    radioWrapper.addEventListener('mouseleave', hideTitle);
  
    // Смена языка
    radioInput.addEventListener('change', () => {
      locale = radioInput.value;
      setCurrentLangDisplay(lang);
      drawClockFaces();
      document.querySelector('label.active')?.classList.remove('active');
      radioWrapper.classList.add('active');
      closeDialog();
    });
  });
}

// Показ названия языка в центре
let titleDisplay = null;
function showTitle(languageName) {
  if (titleDisplay) {
    titleDisplay.remove();
  }
  titleDisplay = document.createElement('div');
  titleDisplay.classList.add('language-title');
  titleDisplay.textContent = languageName;
  languageOptionsContainer.appendChild(titleDisplay);
}

// Скрытие названия языка
function hideTitle() {
  if (titleDisplay) {
    titleDisplay.textContent = '';
  }
}

// Обновление отображения текущего языка
function setCurrentLangDisplay(lang) {
  currentLangDisplay.textContent = lang.flag;
  currentLangDisplay.title = lang.name;
  showTitle(lang.name);
}

// Управление диалоговым окном
function openDialog() {
  languageDialog.showModal();
  createLanguageOptions();
  addDialogCloseListener();
}

// Закрыть диалоговое окно
function closeDialog() {
  languageDialog.close();
  removeLanguageOptions();
  removeDialogCloseListener();
}

// Убрать опции выбора языка
function removeLanguageOptions() {
  languageOptionsContainer.innerHTML = '';
}

// Добавляем обработчики событий для кликов снаружи поля выбора 
function addDialogCloseListener() {
  languageDialog.addEventListener('click', closeDialogOnClickOutside);
}

function removeDialogCloseListener() {
  languageDialog.removeEventListener('click', closeDialogOnClickOutside);
}

// Закрытие диалога при клике вне его области
function closeDialogOnClickOutside(e) {
  if (e.target === languageDialog) {
    closeDialog();
  }
}

// Обработчики событий
closeButton.addEventListener('click', closeDialog);
currentLangDisplay.addEventListener('click', openDialog);

// Инициализация
drawClockFaces();
rotateClockFaces();
setCurrentLangDisplay(languageFlags.find(lang => lang.code === locale));

Вам слово

Приходите к нам в соцсети поделиться своим мнением о проекте и почитать, что пишут другие. А ещё там выходит дополнительный контент, которого нет на сайте: шпаргалки, опросы и разная дурка. В общем, вот тележка, вот ВК — велком!

Код: Крис Болсон

Обложка:

Алексей Сухов

Корректор:

Елена Грицун

Вёрстка:

Кирилл Климентьев

Соцсети:

Юлия Зубарева

Получите ИТ-профессию
В «Яндекс Практикуме» можно стать разработчиком, тестировщиком, аналитиком и менеджером цифровых продуктов. Первая часть обучения всегда бесплатная, чтобы попробовать и найти то, что вам по душе. Дальше — программы трудоустройства.
А вы читали это?
hard
[anycomment]
Exit mobile version