30 Commits

Author SHA1 Message Date
Ilia Mashkov
1c81deaed0 feat: Добавлен файл README.md 2025-11-23 14:47:07 +03:00
Ilia Mashkov
246371c431 feat: Обновленная версия setupTests файла 2025-11-23 14:15:45 +03:00
Ilia Mashkov
e4c563075f feat: Оптимизация импортов 2025-11-23 14:13:52 +03:00
Ilia Mashkov
2a72e1077c feat: Настройки webpack для уменьшения размера бандла 2025-11-23 14:13:21 +03:00
Ilia Mashkov
013d32f09d feat: Настроен webpack плагин для сжатия prod сборки 2025-11-23 14:12:27 +03:00
Ilia Mashkov
6de84f3143 feat: Добавлены тесты React компонентов. Тесты базовой логики, Граничных тестов, стилей 2025-11-23 13:48:50 +03:00
Ilia Mashkov
ec6867d7a0 feat: 2025-11-23 13:46:48 +03:00
Ilia Mashkov
da0309f714 feat: Установлена библиотека identify-object-proxy 2025-11-23 13:46:06 +03:00
Ilia Mashkov
5c408ccff4 fix: Правки линтера 2025-11-23 13:34:40 +03:00
Ilia Mashkov
d27906fd47 refactor: Изменена конфигурация webpack для улучшения производительности и скорости сборки 2025-11-23 13:34:15 +03:00
Ilia Mashkov
c4a7653d7b fix: Правка в конфиг stylelint для глобальных стилей 2025-11-23 13:31:46 +03:00
Ilia Mashkov
eace670c50 feat: 2025-11-23 13:15:45 +03:00
Ilia Mashkov
e892e69277 feat: Добавлено название периода с анимацией появления для мобильного разрешения 2025-11-23 13:15:30 +03:00
Ilia Mashkov
38785cce94 feat: Добавлены названия периодов с анимацией появления 2025-11-23 13:14:20 +03:00
Ilia Mashkov
2958cc734a feature: Настроен адаптив виджета TimeFrameSlider 2025-11-21 15:42:39 +03:00
Ilia Mashkov
364c5e1ff1 feature: Настроен адаптив для карусели событий 2025-11-21 15:41:57 +03:00
Ilia Mashkov
f34f1a28de feature: Адаптивные стили для компонента Card 2025-11-21 15:40:27 +03:00
Ilia Mashkov
3f7f5d358e feature: Адаптивные стили для кнопки 2025-11-21 15:40:08 +03:00
Ilia Mashkov
bd1eb1eee9 Merge pull request #4 from e7f3/feature/minor-changes
Feature/minor changes
2025-11-21 13:02:45 +03:00
Ilia Mashkov
4ec072055f feat: В препуш хуки добавлен запуск unit тестов 2025-11-21 12:59:48 +03:00
Ilia Mashkov
93787eaf4d fix: Правки линтера 2025-11-21 12:57:07 +03:00
Ilia Mashkov
33763767d9 fix: Удален неиспользуемый импорт 2025-11-21 12:56:13 +03:00
Ilia Mashkov
f1118e5aec fix: Удален горизонтальный отступ у карточки 2025-11-21 12:55:54 +03:00
Ilia Mashkov
65630bae8a fix: Исправлена анимация карусели событий, смена событий триггерит анимацию. Исправлено расположение кнопок навигации 2025-11-21 12:55:22 +03:00
Ilia Mashkov
5a2a7642d3 fix: Исправлено расположение элементов, добавлены стили линий 2025-11-21 12:53:34 +03:00
Ilia Mashkov
8fec89a699 fix: Исправлена анимация смены года 2025-11-21 12:52:51 +03:00
Ilia Mashkov
e33cd1b139 refactor: конфиг анимации смены года вынесен в константы 2025-11-21 12:51:49 +03:00
Ilia Mashkov
d7e66eec71 fix: Исправлена переменная градиента 2025-11-21 12:51:02 +03:00
Ilia Mashkov
a344b6f4de fix: Исправлен импорт scss файлов в index.scss 2025-11-21 12:50:35 +03:00
Ilia Mashkov
fb0808bc1f Merge pull request #3 from e7f3/feature/time-frame-slider
Feature/time frame slider
2025-11-20 16:15:20 +03:00
29 changed files with 947 additions and 106 deletions

View File

@@ -12,6 +12,9 @@ pnpm lint || exit 1
echo "Проверка Stylelint..." echo "Проверка Stylelint..."
pnpm lint:styles || exit 1 pnpm lint:styles || exit 1
echo "Запуск Unit тестов..."
pnpm test:unit || exit 1
echo "Production сборка..." echo "Production сборка..."
pnpm build:prod || exit 1 pnpm build:prod || exit 1

231
README.md Normal file
View File

@@ -0,0 +1,231 @@
# Only Task - Интерактивная временная шкала
Современное React-приложение с интерактивной круговой временной шкалой и каруселью исторических событий.
## 🚀 Технологии
- **React 19** - библиотека для создания пользовательских интерфейсов
- **TypeScript 5** - типизированный JavaScript
- **GSAP** - библиотека для плавных анимаций
- **Swiper** - современная библиотека для каруселей
- **Webpack 5** - сборщик модулей
- **SCSS** - препроцессор CSS с модулями
- **Jest** - фреймворк для тестирования
- **ESLint + Prettier** - линтинг и форматирование кода
## 📋 Требования
- **Node.js** >= 18.0.0
- **pnpm** >= 8.0.0
## 🛠️ Установка
1. Клонируйте репозиторий:
```bash
git clone <repository-url>
cd only-task
```
2. Установите зависимости:
```bash
pnpm install
```
## 🎯 Доступные команды
### Разработка
```bash
# Запуск dev-сервера на порту 3000
pnpm dev
```
Приложение будет доступно по адресу: http://localhost:3000
### Сборка
```bash
# Production сборка
pnpm build:prod
# Development сборка
pnpm build:dev
```
Результат сборки будет в папке `dist/`
### Тестирование
```bash
# Запуск всех unit-тестов
pnpm test:unit
# Проверка типов TypeScript
pnpm type-check
```
### Линтинг
```bash
# Проверка JavaScript/TypeScript кода
pnpm lint
# Автоматическое исправление ошибок
pnpm lint --fix
# Проверка стилей (SCSS)
pnpm lint:styles
# Автоматическое исправление стилей
pnpm lint:styles --fix
```
### Storybook
```bash
# Запуск Storybook для разработки компонентов
pnpm storybook
# Сборка статической версии Storybook
pnpm build-storybook
```
### Pre-push проверки
```bash
# Запуск всех проверок перед push
pnpm pre-push
```
Эта команда выполняет:
- ✅ Проверку типов TypeScript
- ✅ Линтинг кода
- ✅ Линтинг стилей
- ✅ Запуск unit-тестов
- ✅ Production сборку
## 📁 Структура проекта
```
only-task/
├── config/ # Конфигурация сборки
│ ├── build/ # Webpack конфигурация
│ ├── jest/ # Jest конфигурация
│ └── storybook/ # Storybook конфигурация
├── dist/ # Production сборка (генерируется)
├── src/ # Исходный код
│ ├── app/ # Корневой компонент приложения
│ ├── entities/ # Бизнес-сущности (типы данных)
│ ├── shared/ # Переиспользуемые компоненты и утилиты
│ │ ├── assets/ # Статические ресурсы (SVG, изображения)
│ │ └── ui/ # UI компоненты (Button, Card)
│ └── widgets/ # Сложные компоненты (TimeFrameSlider)
├── .husky/ # Git hooks
├── package.json # Зависимости и скрипты
└── tsconfig.json # Конфигурация TypeScript
```
## 🎨 Основные компоненты
### TimeFrameSlider
Главный виджет приложения, объединяющий круговую временную шкалу и карусель событий.
**Особенности:**
- Интерактивная круговая диаграмма с периодами
- Плавные GSAP анимации при переключении
- Адаптивный дизайн для мобильных, планшетов и десктопа
- Кастомная навигация
### CircleTimeline
Круговая временная шкала с точками периодов.
**Особенности:**
- Автоматический поворот к активному периоду
- Клик по точкам для переключения
- Анимация заголовков периодов
### EventsCarousel
Карусель исторических событий с использованием Swiper.
**Особенности:**
- Адаптивное количество слайдов
- Кастомная навигация
- Плавная анимация появления/исчезновения
## ⚡ Оптимизация производительности
Проект оптимизирован для production:
-**Code splitting** - разделение на chunks (vendors, runtime, main)
-**Tree shaking** - удаление неиспользуемого кода
-**Gzip compression** - сжатие файлов (~68% уменьшение размера)
-**Minification** - минификация JS и CSS
-**CSS Modules** - изолированные стили компонентов
-**Babel caching** - кеширование для быстрой пересборки
**Размер бандла:**
- Uncompressed: ~371 KiB
- Gzipped: ~117 KiB
## 🧪 Тестирование
Проект покрыт unit-тестами:
- ✅ Тесты компонентов (Button, Card, CircleTimeline, TimeFrameSlider, EventsCarousel)
- ✅ Тесты утилит (calculateCoordinates)
- ✅ Моки для GSAP и Swiper
- ✅ 39 тестов, 100% успешных
## 📱 Адаптивность
Приложение адаптировано для всех устройств:
- **Desktop** (>1440px) - полная версия с круговой диаграммой
- **Tablet** (768px - 1024px) - оптимизированная версия
- **Mobile** (<768px) - упрощенная версия с навигацией внизу
## 🔧 Конфигурация
### Webpack
- Babel для транспиляции (быстрее ts-loader)
- CSS Modules для изоляции стилей
- SVGR для импорта SVG как React компонентов
- Hot Module Replacement для разработки
- Bundle Analyzer для анализа размера
### TypeScript
- Strict mode включен
- Path aliases (`@/*``src/*`)
- Проверка типов отдельно от сборки
### ESLint
- Правила для React, TypeScript, Jest
- Prettier интеграция
- Import sorting
## 🤝 Git Hooks
Pre-push hook автоматически запускает:
1. Type checking
2. ESLint
3. Stylelint
4. Unit tests
5. Production build
Это гарантирует, что в репозиторий попадает только рабочий код.
## 📝 Лицензия
ISC
## 👨‍💻 Разработка
Для разработки рекомендуется:
1. Запустить `pnpm dev` для dev-сервера
2. Использовать `pnpm storybook` для разработки компонентов в изоляции
3. Писать тесты для новых компонентов
4. Следовать существующей структуре проекта
---
**Приятной разработки! 🚀**

View File

@@ -4,7 +4,6 @@ import { buildBabelLoader } from './loaders/buildBabelLoader'
import { buildCssLoader } from './loaders/buildCssLoader' import { buildCssLoader } from './loaders/buildCssLoader'
import { buildFileLoader } from './loaders/buildFileLoader' import { buildFileLoader } from './loaders/buildFileLoader'
import { buildSvgrLoader } from './loaders/buildSvgrLoader' import { buildSvgrLoader } from './loaders/buildSvgrLoader'
import { buildTypescriptLoader } from './loaders/buildTypescriptLoader'
import { BuildOptions } from './types/config' import { BuildOptions } from './types/config'
/** /**
@@ -23,15 +22,17 @@ import { BuildOptions } from './types/config'
* @returns {webpack.RuleSetRule[]} Массив правил для webpack * @returns {webpack.RuleSetRule[]} Массив правил для webpack
*/ */
export function buildLoaders({ isDev }: BuildOptions): webpack.RuleSetRule[] { export function buildLoaders({ isDev }: BuildOptions): webpack.RuleSetRule[] {
const babelLoader = buildBabelLoader(isDev) // Используем babel-loader для обработки JS и TS файлов
// Это ускоряет сборку, так как babel работает быстрее ts-loader
// Проверка типов должна выполняться отдельно (например, через tsc --noEmit)
// Исключаем тестовые и storybook файлы из production сборки
const codeBabelLoader = buildBabelLoader(isDev)
const fileLoader = buildFileLoader() const fileLoader = buildFileLoader()
const svgrLoader = buildSvgrLoader() const svgrLoader = buildSvgrLoader()
const typescriptLoader = buildTypescriptLoader()
const cssLoader = buildCssLoader(isDev) const cssLoader = buildCssLoader(isDev)
return [fileLoader, svgrLoader, babelLoader, typescriptLoader, cssLoader] return [fileLoader, svgrLoader, codeBabelLoader, cssLoader]
} }

View File

@@ -1,4 +1,5 @@
import ReactRefreshWebpackPlugin from '@pmmmwh/react-refresh-webpack-plugin' import ReactRefreshWebpackPlugin from '@pmmmwh/react-refresh-webpack-plugin'
import CompressionPlugin from 'compression-webpack-plugin'
import HtmlWebpackPlugin from 'html-webpack-plugin' import HtmlWebpackPlugin from 'html-webpack-plugin'
import MiniCssExtractPlugin from 'mini-css-extract-plugin' import MiniCssExtractPlugin from 'mini-css-extract-plugin'
import webpack from 'webpack' import webpack from 'webpack'
@@ -22,6 +23,9 @@ import { BuildOptions } from './types/config'
* - ReactRefreshWebpackPlugin: обеспечивает быструю перезагрузку React компонентов * - ReactRefreshWebpackPlugin: обеспечивает быструю перезагрузку React компонентов
* - HotModuleReplacementPlugin: включает горячую замену модулей (HMR) * - HotModuleReplacementPlugin: включает горячую замену модулей (HMR)
* *
* Плагины только для production:
* - CompressionPlugin: создает gzip-сжатые версии файлов для уменьшения размера передачи
*
* @param {BuildOptions} options - Опции сборки * @param {BuildOptions} options - Опции сборки
* @param {BuildPaths} options.paths - Пути проекта * @param {BuildPaths} options.paths - Пути проекта
* @param {boolean} options.isDev - Флаг режима разработки * @param {boolean} options.isDev - Флаг режима разработки
@@ -68,6 +72,17 @@ export function buildPlugins({
) )
plugins.push(new ReactRefreshWebpackPlugin({ overlay: false })) plugins.push(new ReactRefreshWebpackPlugin({ overlay: false }))
plugins.push(new webpack.HotModuleReplacementPlugin()) plugins.push(new webpack.HotModuleReplacementPlugin())
} else {
// Сжатие файлов для production сборки
// Создает .gz файлы для всех JS и CSS файлов больше 10KB
plugins.push(
new CompressionPlugin({
algorithm: 'gzip',
test: /\.(js|css|html|svg)$/,
threshold: 10240, // Сжимаем только файлы больше 10KB
minRatio: 0.8, // Сжимаем только если размер уменьшается минимум на 20%
})
)
} }
return plugins return plugins
} }

View File

@@ -56,5 +56,11 @@ export function buildWebpackConfig(
plugins: buildPlugins(options), plugins: buildPlugins(options),
devtool: isDev ? 'inline-source-map' : undefined, devtool: isDev ? 'inline-source-map' : undefined,
devServer: isDev ? buildDevServer(options) : undefined, devServer: isDev ? buildDevServer(options) : undefined,
optimization: {
splitChunks: {
chunks: 'all', // Разделяем код на чанки для оптимизации загрузки
},
runtimeChunk: 'single', // Выносим рантайм webpack в отдельный файл
},
} }
} }

View File

@@ -13,12 +13,22 @@
*/ */
export function buildBabelLoader(isDev: boolean) { export function buildBabelLoader(isDev: boolean) {
const babelLoader = { const babelLoader = {
test: /\.(js|jsx|tsx)$/, test: /\.(js|jsx|tsx|ts)$/,
exclude: /node_modules/, exclude: [
/node_modules/,
/\.test\.(ts|tsx)$/, // Исключаем тестовые файлы
/\.spec\.(ts|tsx)$/, // Исключаем spec файлы
/\.stories\.(ts|tsx)$/, // Исключаем Storybook файлы
],
use: { use: {
loader: 'babel-loader', loader: 'babel-loader',
options: { options: {
presets: ['@babel/preset-env'], cacheDirectory: true, // Включаем кеширование для ускорения пересборки
presets: [
'@babel/preset-env',
['@babel/preset-react', { runtime: 'automatic' }], // Поддержка React 17+ (новый JSX transform)
'@babel/preset-typescript', // Поддержка TypeScript через Babel
],
plugins: [isDev && require.resolve('react-refresh/babel')].filter( plugins: [isDev && require.resolve('react-refresh/babel')].filter(
Boolean Boolean
), ),

View File

@@ -1,23 +0,0 @@
/**
* Конфигурация TypeScript loader для webpack
*
* Компилирует TypeScript файлы (.ts, .tsx) в JavaScript.
* Выполняет проверку типов во время сборки.
*
* @returns {Object} Конфигурация ts-loader
*
* @example
* // Обрабатывает файлы:
* // - Component.tsx
* // - utils.ts
* // - types.d.ts
*/
export function buildTypescriptLoader() {
const typescriptLoader = {
test: /\.tsx?$/,
use: 'ts-loader',
exclude: '/node-modules/',
}
return typescriptLoader
}

View File

@@ -1,2 +1,24 @@
import '@testing-library/jest-dom' import '@testing-library/jest-dom'
import 'regenerator-runtime/runtime' import 'regenerator-runtime/runtime'
// Глобальный мок для GSAP
jest.mock('gsap', () => {
const gsapMock = {
to: jest.fn(),
fromTo: jest.fn(),
killTweensOf: jest.fn(),
context: jest.fn(() => ({
revert: jest.fn(),
})),
Power2: {
easeOut: 'power2.out',
},
}
return {
__esModule: true,
default: gsapMock, // Для default import
gsap: gsapMock, // Для named import
Power2: gsapMock.Power2, // Экспортируем Power2 отдельно
}
})

View File

@@ -12,7 +12,7 @@
"test:unit": "jest --config ./config/jest/jest.config.ts", "test:unit": "jest --config ./config/jest/jest.config.ts",
"type-check": "tsc --noEmit", "type-check": "tsc --noEmit",
"prepare": "husky", "prepare": "husky",
"pre-push": "pnpm type-check && pnpm lint && pnpm lint:styles && pnpm build:prod", "pre-push": "pnpm type-check && pnpm lint && pnpm lint:styles && pnpm test:unit && pnpm build:prod",
"storybook": "storybook dev -p 6006 -c config/storybook", "storybook": "storybook dev -p 6006 -c config/storybook",
"build-storybook": "storybook build -c config/storybook" "build-storybook": "storybook build -c config/storybook"
}, },
@@ -51,6 +51,7 @@
"@typescript-eslint/eslint-plugin": "^8.16.0", "@typescript-eslint/eslint-plugin": "^8.16.0",
"@typescript-eslint/parser": "^8.16.0", "@typescript-eslint/parser": "^8.16.0",
"babel-loader": "^9.2.0", "babel-loader": "^9.2.0",
"compression-webpack-plugin": "^11.1.0",
"css-loader": "^7.1.0", "css-loader": "^7.1.0",
"eslint": "^9.15.0", "eslint": "^9.15.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
@@ -65,6 +66,7 @@
"globals": "^15.12.0", "globals": "^15.12.0",
"html-webpack-plugin": "^5.6.0", "html-webpack-plugin": "^5.6.0",
"husky": "^9.1.0", "husky": "^9.1.0",
"identity-obj-proxy": "^3.0.0",
"jest": "^30.2.0", "jest": "^30.2.0",
"jest-environment-jsdom": "^30.2.0", "jest-environment-jsdom": "^30.2.0",
"mini-css-extract-plugin": "^2.9.0", "mini-css-extract-plugin": "^2.9.0",

31
pnpm-lock.yaml generated
View File

@@ -96,6 +96,9 @@ importers:
babel-loader: babel-loader:
specifier: ^9.2.0 specifier: ^9.2.0
version: 9.2.1(@babel/core@7.28.5)(webpack@5.103.0) version: 9.2.1(@babel/core@7.28.5)(webpack@5.103.0)
compression-webpack-plugin:
specifier: ^11.1.0
version: 11.1.0(webpack@5.103.0)
css-loader: css-loader:
specifier: ^7.1.0 specifier: ^7.1.0
version: 7.1.2(webpack@5.103.0) version: 7.1.2(webpack@5.103.0)
@@ -138,6 +141,9 @@ importers:
husky: husky:
specifier: ^9.1.0 specifier: ^9.1.0
version: 9.1.7 version: 9.1.7
identity-obj-proxy:
specifier: ^3.0.0
version: 3.0.0
jest: jest:
specifier: ^30.2.0 specifier: ^30.2.0
version: 30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.25.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) version: 30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.25.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
@@ -2617,6 +2623,12 @@ packages:
resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
compression-webpack-plugin@11.1.0:
resolution: {integrity: sha512-zDOQYp10+upzLxW+VRSjEpRRwBXJdsb5lBMlRxx1g8hckIFBpe3DTI0en2w7h+beuq89576RVzfiXrkdPGrHhA==}
engines: {node: '>= 18.12.0'}
peerDependencies:
webpack: ^5.1.0
compression@1.8.1: compression@1.8.1:
resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==} resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
@@ -3517,6 +3529,9 @@ packages:
handle-thing@2.0.1: handle-thing@2.0.1:
resolution: {integrity: sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==} resolution: {integrity: sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==}
harmony-reflect@1.6.2:
resolution: {integrity: sha512-HIp/n38R9kQjDEziXyDTuW3vvoxxyxjxFzXLrBr18uB47GnSt+G9D29fqrpM5ZkspMcPICud3XsBJQ4Y2URg8g==}
has-bigints@1.1.0: has-bigints@1.1.0:
resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -3654,6 +3669,10 @@ packages:
peerDependencies: peerDependencies:
postcss: ^8.1.0 postcss: ^8.1.0
identity-obj-proxy@3.0.0:
resolution: {integrity: sha512-00n6YnVHKrinT9t0d9+5yZC6UBNJANpYEQvL2LlX6Ab9lnmxzIRcEmTPuyGScvl1+jKuCICX1Z0Ab1pPKKdikA==}
engines: {node: '>=4'}
ignore@5.3.2: ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'} engines: {node: '>= 4'}
@@ -8604,6 +8623,12 @@ snapshots:
dependencies: dependencies:
mime-db: 1.54.0 mime-db: 1.54.0
compression-webpack-plugin@11.1.0(webpack@5.103.0):
dependencies:
schema-utils: 4.3.3
serialize-javascript: 6.0.2
webpack: 5.103.0(esbuild@0.25.12)(webpack-cli@5.1.4)
compression@1.8.1: compression@1.8.1:
dependencies: dependencies:
bytes: 3.1.2 bytes: 3.1.2
@@ -9672,6 +9697,8 @@ snapshots:
handle-thing@2.0.1: {} handle-thing@2.0.1: {}
harmony-reflect@1.6.2: {}
has-bigints@1.1.0: {} has-bigints@1.1.0: {}
has-flag@4.0.0: {} has-flag@4.0.0: {}
@@ -9817,6 +9844,10 @@ snapshots:
dependencies: dependencies:
postcss: 8.5.6 postcss: 8.5.6
identity-obj-proxy@3.0.0:
dependencies:
harmony-reflect: 1.6.2
ignore@5.3.2: {} ignore@5.3.2: {}
ignore@7.0.5: {} ignore@7.0.5: {}

View File

@@ -1,3 +1,3 @@
@import './fonts'; @use 'fonts';
@import './variables'; @use 'variables';
@import './reset'; @use 'reset';

View File

@@ -9,7 +9,7 @@
--color-white: #FFF; --color-white: #FFF;
// Градиенты // Градиенты
--gradient-primary: linear-gradient(to right, #3877EE, #EF5DA8); --gradient-primary: linear-gradient(to bottom, #3877EE, #EF5DA8);
// Типографика // Типографика
--font-family-main: 'PT Sans', sans-serif; --font-family-main: 'PT Sans', sans-serif;

View File

@@ -40,18 +40,30 @@
height: 40px; height: 40px;
font-size: 14px; font-size: 14px;
@media (width <=768px) {
height: 20px;
}
} }
&.medium { &.medium {
height: 50px; height: 50px;
font-size: 18px; font-size: 18px;
@media (width <=768px) {
height: 25px;
}
} }
&.large { &.large {
height: 60px; height: 60px;
font-size: 24px; font-size: 24px;
@media (width <=768px) {
height: 30px;
}
} }
// Color Schemes // Color Schemes

View File

@@ -0,0 +1,34 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { Button } from './Button'
describe('Button', () => {
// Тест на рендеринг кнопки
it('должна рендериться корректно', () => {
render(<Button>Test Button</Button>)
expect(screen.getByText('Test Button')).toBeInTheDocument()
})
// Тест на применение класса варианта
it('должна применять класс варианта', () => {
render(<Button variant='regular'>Regular Button</Button>)
const button = screen.getByText('Regular Button')
// Проверяем наличие класса, который генерируется CSS модулями (частичное совпадение)
expect(button.className).toMatch(/regular/)
})
// Тест на обработку клика
it('должна вызывать обработчик onClick при клике', () => {
const handleClick = jest.fn()
render(<Button onClick={handleClick}>Click Me</Button>)
fireEvent.click(screen.getByText('Click Me'))
expect(handleClick).toHaveBeenCalledTimes(1)
})
// Тест на отключенное состояние
it('должна быть отключена при передаче пропса disabled', () => {
render(<Button disabled>Disabled Button</Button>)
expect(screen.getByText('Disabled Button')).toBeDisabled()
})
})

View File

@@ -1,5 +1,5 @@
.card { .card {
padding: 20px; padding: 20px 0;
} }
.title { .title {
@@ -9,11 +9,19 @@
font-weight: 400; font-weight: 400;
font-size: 25px; font-size: 25px;
line-height: 120%; line-height: 120%;
@media (width <=768px) {
font-size: 16px;
}
} }
.description { .description {
color: var(--color-text); color: var(--color-text);
font-weight: 400; font-weight: 400;
font-size: 20px; font-size: 20px;
line-height: 30px; line-height: 145%;
@media (width <=768px) {
font-size: 14px;
}
} }

View File

@@ -0,0 +1,18 @@
import { render, screen } from '@testing-library/react'
import { Card } from './Card'
describe('Card', () => {
// Тест на рендеринг заголовка и описания
it('должна рендерить заголовок и описание', () => {
render(<Card title='1992' description='Test Description' />)
expect(screen.getByText('1992')).toBeInTheDocument()
expect(screen.getByText('Test Description')).toBeInTheDocument()
})
// Тест на рендеринг числового заголовка
it('должна корректно рендерить числовой заголовок', () => {
render(<Card title={2023} description='Year Description' />)
expect(screen.getByText('2023')).toBeInTheDocument()
})
})

View File

@@ -46,15 +46,31 @@
background-clip: padding-box; background-clip: padding-box;
} }
&:hover .label, &:hover .number,
&.active .label { &.active .number {
display: block; display: block;
} }
.label { .number {
display: none; display: none;
color: var(--color-text); color: var(--color-text);
font-size: 20px; font-size: 20px;
} }
.title {
position: absolute;
left: 100%;
margin-left: 20px;
color: var(--color-text);
font-weight: 700;
font-size: 20px;
white-space: nowrap;
visibility: hidden;
opacity: 0;
}
} }

View File

@@ -0,0 +1,56 @@
import { fireEvent, render, screen } from '@testing-library/react'
import gsap from 'gsap'
import { HISTORICAL_PERIODS } from '@/entities/TimePeriod'
import { CircleTimeline } from './CircleTimeline'
describe('CircleTimeline', () => {
const mockOnPeriodChange = jest.fn()
const defaultProps = {
periods: HISTORICAL_PERIODS,
activeIndex: 0,
onPeriodChange: mockOnPeriodChange,
rotation: 0,
}
beforeEach(() => {
jest.clearAllMocks()
})
// Тест на рендеринг правильного количества точек
it('должна рендерить правильное количество точек', () => {
render(<CircleTimeline {...defaultProps} />)
const points = screen.getAllByRole('button')
expect(points).toHaveLength(HISTORICAL_PERIODS.length)
})
// Тест на активную точку
it('должна корректно отображать активную точку', () => {
render(<CircleTimeline {...defaultProps} activeIndex={1} />)
const points = screen.getAllByRole('button')
// Проверяем aria-current для доступности
expect(points[1]).toHaveAttribute('aria-current', 'true')
expect(points[0]).toHaveAttribute('aria-current', 'false')
})
// Тест на клик по точке
it('должна вызывать onPeriodChange при клике по точке', () => {
render(<CircleTimeline {...defaultProps} />)
const points = screen.getAllByRole('button')
fireEvent.click(points[2])
expect(mockOnPeriodChange).toHaveBeenCalledWith(2)
})
// Тест на вызов GSAP анимации
it('должна вызывать GSAP анимацию при изменении rotation', () => {
const { rerender } = render(<CircleTimeline {...defaultProps} />)
rerender(<CircleTimeline {...defaultProps} rotation={60} />)
// Проверяем, что gsap.to был вызван
expect(gsap.to).toHaveBeenCalled()
})
})

View File

@@ -9,7 +9,7 @@
*/ */
import classNames from 'classnames' import classNames from 'classnames'
import gsap from 'gsap' import { gsap } from 'gsap'
import { memo, useCallback, useEffect, useMemo, useRef } from 'react' import { memo, useCallback, useEffect, useMemo, useRef } from 'react'
import styles from './CircleTimeline.module.scss' import styles from './CircleTimeline.module.scss'
@@ -67,6 +67,9 @@ export const CircleTimeline = memo(function CircleTimeline({
// Реф для массива точек периодов // Реф для массива точек периодов
const pointsRef = useRef<(HTMLDivElement | null)[]>([]) const pointsRef = useRef<(HTMLDivElement | null)[]>([])
// Реф для заголовков периодов
const titlesRef = useRef<(HTMLSpanElement | null)[]>([])
/** /**
* Эффект для анимации поворота круга и контр-поворота точек * Эффект для анимации поворота круга и контр-поворота точек
* Запускается при изменении rotation * Запускается при изменении rotation
@@ -82,16 +85,38 @@ export const CircleTimeline = memo(function CircleTimeline({
} }
// Контр-поворот точек, чтобы текст оставался читаемым // Контр-поворот точек, чтобы текст оставался читаемым
pointsRef.current.forEach((point) => { pointsRef.current.forEach((point, index) => {
if (point) { if (point) {
gsap.to(point, { gsap.to(point, {
rotation: -rotation, rotation: -rotation,
duration: 0, duration: 0,
ease: ANIMATION_EASE, ease: ANIMATION_EASE,
}) })
// Анимация заголовка
const title = titlesRef.current[index]
if (title) {
// Сбрасываем предыдущие анимации для этого элемента
gsap.killTweensOf(title)
if (index === activeIndex) {
gsap.to(title, {
opacity: 1,
visibility: 'visible',
duration: 0.5,
delay: ANIMATION_DURATION, // Ждем окончания вращения
})
} else {
gsap.to(title, {
opacity: 0,
visibility: 'hidden',
duration: 0.2,
})
}
}
} }
}) })
}, [rotation]) }, [rotation, activeIndex])
/** /**
* Мемоизированный расчет позиций точек на круге * Мемоизированный расчет позиций точек на круге
@@ -138,7 +163,15 @@ export const CircleTimeline = memo(function CircleTimeline({
aria-label={`Period ${index + 1}: ${period.label}`} aria-label={`Period ${index + 1}: ${period.label}`}
aria-current={index === activeIndex ? 'true' : 'false'} aria-current={index === activeIndex ? 'true' : 'false'}
> >
<span className={styles.label}>{index + 1}</span> <span className={styles.number}>{index + 1}</span>
<span
className={styles.title}
ref={(el) => {
titlesRef.current[index] = el
}}
>
{period.label}
</span>
</div> </div>
) )
})} })}

View File

@@ -7,7 +7,7 @@
.prevButtonWrapper { .prevButtonWrapper {
position: absolute; position: absolute;
top: 50%; top: 50%;
left: -25px; left: -60px;
z-index: 10; z-index: 10;
transform: translateY(-50%); transform: translateY(-50%);
@@ -18,7 +18,7 @@
.nextButtonWrapper { .nextButtonWrapper {
position: absolute; position: absolute;
top: 50%; top: 50%;
right: -25px; right: -60px;
z-index: 10; z-index: 10;
transform: translateY(-50%) rotate(180deg); transform: translateY(-50%) rotate(180deg);
@@ -31,3 +31,29 @@
pointer-events: none; pointer-events: none;
} }
:global(.swiper) {
@media (width <=768px) {
padding: 0 20px;
}
}
:global(.swiper-slide-next) {
transition: opacity 0.3s ease;
@media (width <=768px) {
opacity: 0.4;
}
}
:global(.swiper-slide-prev) {
transition: opacity 0.3s ease;
@media (width <=768px) {
opacity: 0.4;
}
}
:global(.swiper-slide-active) {
opacity: 1;
}

View File

@@ -0,0 +1,52 @@
import { render, screen } from '@testing-library/react'
import { EventsCarousel } from './EventsCarousel'
// Мокаем Swiper
jest.mock('swiper/react', () => ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Swiper: ({ children }: any) => <div data-testid='swiper'>{children}</div>,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
SwiperSlide: ({ children }: any) => (
<div data-testid='swiper-slide'>{children}</div>
),
}))
jest.mock('swiper/modules', () => ({
Navigation: jest.fn(),
Pagination: jest.fn(),
FreeMode: jest.fn(),
}))
// Мокаем стили Swiper
jest.mock('swiper/css', () => ({}))
jest.mock('swiper/css/navigation', () => ({}))
jest.mock('swiper/css/pagination', () => ({}))
describe('EventsCarousel', () => {
const mockEvents = [
{ id: 1, year: 1990, description: 'Event 1' },
{ id: 2, year: 1991, description: 'Event 2' },
]
// Тест на рендеринг событий
it('должен рендерить переданные события', () => {
render(<EventsCarousel events={mockEvents} visible={true} />)
// Проверяем, что слайды отрендерились
const slides = screen.getAllByTestId('swiper-slide')
expect(slides).toHaveLength(mockEvents.length)
// Проверяем контент внутри слайдов (Card компонент)
expect(screen.getByText('1990')).toBeInTheDocument()
expect(screen.getByText('Event 1')).toBeInTheDocument()
})
// Тест на видимость
it('должен применять класс visible, когда visible=true', () => {
render(<EventsCarousel events={mockEvents} visible={true} />)
// В реальном компоненте класс применяется к контейнеру Swiper или обертке
// Здесь мы проверяем наличие контента, так как opacity управляется через CSS/GSAP
expect(screen.getByTestId('swiper')).toBeInTheDocument()
})
})

View File

@@ -5,7 +5,7 @@
*/ */
import classNames from 'classnames' import classNames from 'classnames'
import gsap from 'gsap' import { gsap } from 'gsap'
import { memo, useEffect, useRef, useState } from 'react' import { memo, useEffect, useRef, useState } from 'react'
import { Swiper, SwiperSlide } from 'swiper/react' import { Swiper, SwiperSlide } from 'swiper/react'
@@ -89,7 +89,7 @@ export const EventsCarousel = memo(
}, containerRef) }, containerRef)
return () => ctx.revert() return () => ctx.revert()
}, [visible]) }, [visible, events])
/** /**
* Обработчик инициализации Swiper * Обработчик инициализации Swiper
@@ -145,6 +145,7 @@ export const EventsCarousel = memo(
<Swiper <Swiper
{...EVENT_CAROUSEL_CONFIG} {...EVENT_CAROUSEL_CONFIG}
watchSlidesProgress
onInit={handleSwiperInit} onInit={handleSwiperInit}
onSlideChange={handleSlideChange} onSlideChange={handleSlideChange}
> >

View File

@@ -1,4 +1,3 @@
import gsap from 'gsap'
import { Navigation } from 'swiper/modules' import { Navigation } from 'swiper/modules'
import type { SwiperOptions } from 'swiper/types' import type { SwiperOptions } from 'swiper/types'
@@ -8,16 +7,38 @@ import type { SwiperOptions } from 'swiper/types'
*/ */
export const EVENT_CAROUSEL_CONFIG: SwiperOptions = { export const EVENT_CAROUSEL_CONFIG: SwiperOptions = {
modules: [Navigation], modules: [Navigation],
spaceBetween: 30,
slidesPerView: 1.5, slidesPerView: 1.5,
breakpoints: { spaceBetween: 25,
768: {
slidesPerView: 3.5,
},
},
navigation: { navigation: {
prevEl: '.swiper-button-prev-custom', prevEl: '.swiper-button-prev-custom',
nextEl: '.swiper-button-next-custom', nextEl: '.swiper-button-next-custom',
enabled: false,
},
breakpoints: {
576: {
slidesPerView: 2,
},
768: {
slidesPerView: 2,
navigation: {
enabled: true,
},
spaceBetween: 25,
},
1024: {
slidesPerView: 3,
navigation: {
enabled: true,
},
spaceBetween: 30,
},
1440: {
slidesPerView: 3,
navigation: {
enabled: true,
},
spaceBetween: 80,
},
}, },
} }

View File

@@ -3,13 +3,12 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center;
width: 100%; width: 100%;
max-width: 1440px; max-width: 1440px;
min-height: 100vh; min-height: 100vh;
margin: 0 auto; margin: 0 auto;
padding: 0 20px; padding-top: 180px;
color: var(--color-text); color: var(--color-text);
font-family: var(--font-family-main); font-family: var(--font-family-main);
@@ -17,30 +16,57 @@
border-right: 1px solid var(--color-border); border-right: 1px solid var(--color-border);
border-left: 1px solid var(--color-border); border-left: 1px solid var(--color-border);
background-image: linear-gradient(to right, rgba(#42567A, 0.1) 1px, transparent 1px);
background-repeat: no-repeat;
background-position: center top;
background-size: 1px 100%;
overflow: hidden; overflow: hidden;
@media (width <=1024px) {
padding-top: 100px;
}
@media (width <=768px) { @media (width <=768px) {
min-height: auto; padding: 60px 20px 20px;
padding: 20px;
background-image: unset;
} }
} }
.title { .title {
position: relative; position: absolute;
top: 170px;
left: 0;
z-index: 2; z-index: 2;
margin-bottom: 40px; max-width: 15ch;
padding-left: 60px; padding-left: 75px;
font-weight: 700; font-weight: 700;
font-size: 56px; font-size: 56px;
line-height: 120%; line-height: 120%;
border-left: 5px solid transparent;
border-image: var(--gradient-primary) 1;
@media (width <=1024px) {
top: 80px;
font-size: 40px;
}
@media (width <=768px) { @media (width <=768px) {
position: relative;
inset: unset;
margin-bottom: 20px; margin-bottom: 20px;
padding-left: 0; padding-left: 0;
font-size: 20px; font-size: 20px;
border: none;
} }
} }
@@ -50,45 +76,120 @@
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;
width: calc(100% + 40px);
height: 600px; height: 600px;
margin: 0 -20px;
background-image: linear-gradient(to bottom, rgba(#42567A, 0.1) 1px, transparent 1px);
background-repeat: no-repeat;
background-position: center;
background-size: 100% 1px;
@media (width <=768px) { @media (width <=768px) {
position: unset;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 100%;
height: auto; height: auto;
margin: 0;
background-image: unset;
} }
} }
.controls { .controls {
position: absolute; position: absolute;
left: 60px; left: 100px;
bottom: 50px; bottom: 0;
z-index: 10; z-index: 10;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 20px; gap: 20px;
transform-origin: left;
@media (width <=1024px) {
left: 100px;
bottom: 40px;
}
@media (width <=768px) { @media (width <=768px) {
position: static; left: 20px;
bottom: 13px;
order: 2; order: 2;
gap: 10px;
margin-top: 20px; margin-top: 20px;
padding: 0; padding: 0;
} }
} }
.pagination { .pagination {
margin-bottom: 10px; font-weight: 400;
font-size: 14px; font-size: 14px;
} }
.buttons { .buttons {
display: flex; display: flex;
gap: 20px; gap: 20px;
@media (width <=768px) {
gap: 8px;
}
}
.chevronIcon {
width: 9px;
height: 14px;
@media (width <=768px) {
width: 6px;
height: 11.5px;
}
}
.dots {
display: none;
@media (width <=768px) {
position: absolute;
left: 50%;
bottom: 32px;
display: flex;
gap: 10px;
justify-content: center;
width: 100%;
transform: translate(-50%, -50%);
}
}
.dot {
width: 6px;
height: 6px;
padding: 0;
border: none;
border-radius: 50%;
background-color: var(--color-primary);
cursor: pointer;
opacity: 0.4;
transition: opacity 0.3s ease;
&.activeDot {
opacity: 1;
}
} }
.rotated { .rotated {
@@ -102,7 +203,7 @@
z-index: 0; z-index: 0;
display: flex; display: flex;
gap: 40px; gap: 60px;
color: var(--color-text); color: var(--color-text);
font-weight: 700; font-weight: 700;
@@ -113,6 +214,13 @@
pointer-events: none; pointer-events: none;
@media (width <=1024px) {
gap: 40px;
font-size: 140px;
line-height: 120px;
}
@media (width <=768px) { @media (width <=768px) {
position: static; position: static;
@@ -143,20 +251,32 @@
display: block; display: block;
margin-bottom: 40px; padding-bottom: 20px;
padding-top: 40px;
color: var(--color-text); color: var(--color-text);
font-weight: 700; font-weight: 700;
font-size: 20px; font-size: 16px;
text-align: center; text-align: left;
border-top: 1px solid var(--color-border); border-bottom: 1px solid #C7CDD9;
} }
} }
.circleContainer { .circleContainer {
width: 100%;
height: 100%;
@media (width <=768px) { @media (width <=768px) {
display: none; display: none;
} }
} }
.carouselContainer {
padding: 55px 80px 105px;
@media (width <=768px) {
width: calc(100% + 40px);
margin: 0 -20px;
padding: 0;
}
}

View File

@@ -0,0 +1,82 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { HISTORICAL_PERIODS } from '@/entities/TimePeriod'
import { TimeFrameSlider } from './TimeFrameSlider'
// Мокаем дочерние компоненты, чтобы тестировать изолированно
jest.mock('../CircleTimeline/CircleTimeline', () => ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
CircleTimeline: ({ activeIndex, onPeriodChange }: any) => (
<div data-testid='circle-timeline'>
<button onClick={() => onPeriodChange(activeIndex + 1)}>
Next Period
</button>
<span>Active: {activeIndex}</span>
</div>
),
}))
jest.mock('../EventsCarousel/EventsCarousel', () => ({
EventsCarousel: () => <div data-testid='events-carousel'>Carousel</div>,
}))
describe('TimeFrameSlider', () => {
beforeEach(() => {
jest.clearAllMocks()
})
// Тест на рендеринг заголовка
it('должен рендерить заголовок', () => {
render(<TimeFrameSlider />)
expect(screen.getByText('Исторические даты')).toBeInTheDocument()
})
// Тест на отображение начального периода
it('должен отображать начальный период (первый в списке)', () => {
render(<TimeFrameSlider />)
const firstPeriod = HISTORICAL_PERIODS[0]
expect(screen.getByText(firstPeriod.yearFrom)).toBeInTheDocument()
expect(screen.getByText(firstPeriod.yearTo)).toBeInTheDocument()
})
// Тест на переключение вперед
it('должен переключать период вперед при клике на кнопку "Следующий"', () => {
render(<TimeFrameSlider />)
const nextButton = screen.getByLabelText('Следующий период')
fireEvent.click(nextButton)
// Проверяем, что отображается второй период
const secondPeriod = HISTORICAL_PERIODS[1]
expect(screen.getByText(secondPeriod.yearFrom)).toBeInTheDocument()
})
// Тест на переключение назад
it('должен переключать период назад при клике на кнопку "Предыдущий"', () => {
render(<TimeFrameSlider />)
// Сначала переключаем вперед, чтобы не быть на первом элементе (хотя логика циклична)
const nextButton = screen.getByLabelText('Следующий период')
fireEvent.click(nextButton)
// Теперь назад
const prevButton = screen.getByLabelText('Предыдущий период')
fireEvent.click(prevButton)
// Должны вернуться к первому периоду
const firstPeriod = HISTORICAL_PERIODS[0]
expect(screen.getByText(firstPeriod.yearFrom)).toBeInTheDocument()
})
// Тест на пагинацию (точки)
it('должен переключать период при клике на точку пагинации', () => {
render(<TimeFrameSlider />)
const dots = screen.getAllByLabelText(/Перейти к периоду/)
fireEvent.click(dots[2]) // Клик по 3-й точке
const thirdPeriod = HISTORICAL_PERIODS[2]
expect(screen.getByText(thirdPeriod.yearFrom)).toBeInTheDocument()
})
})

View File

@@ -3,14 +3,15 @@
* Главный компонент временной шкалы с круговой диаграммой и каруселью событий * Главный компонент временной шкалы с круговой диаграммой и каруселью событий
*/ */
import gsap from 'gsap' import classNames from 'classnames'
import { gsap } from 'gsap'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { HISTORICAL_PERIODS } from '@/entities/TimePeriod' import { HISTORICAL_PERIODS } from '@/entities/TimePeriod'
import ChevronSvg from '@/shared/assets/chevron--left.svg' import ChevronSvg from '@/shared/assets/chevron--left.svg'
import { Button } from '@/shared/ui/Button' import { Button } from '@/shared/ui/Button'
import { ACTIVE_POSITION_ANGLE } from './constants' import { ACTIVE_POSITION_ANGLE, GSAP_ANIMATION_CONFIG } from './constants'
import styles from './TimeFrameSlider.module.scss' import styles from './TimeFrameSlider.module.scss'
import { CircleTimeline } from '../CircleTimeline/CircleTimeline' import { CircleTimeline } from '../CircleTimeline/CircleTimeline'
import { EventsCarousel } from '../EventsCarousel/EventsCarousel' import { EventsCarousel } from '../EventsCarousel/EventsCarousel'
@@ -34,10 +35,7 @@ export const TimeFrameSlider = memo(() => {
const startYearRef = useRef<HTMLSpanElement>(null) const startYearRef = useRef<HTMLSpanElement>(null)
const endYearRef = useRef<HTMLSpanElement>(null) const endYearRef = useRef<HTMLSpanElement>(null)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const periodLabelRef = useRef<HTMLDivElement>(null)
// Мемоизированные константы
const totalPeriods = useMemo(() => HISTORICAL_PERIODS.length, [])
const anglePerPoint = useMemo(() => 360 / totalPeriods, [totalPeriods])
// Текущий период // Текущий период
const currentPeriod = useMemo( const currentPeriod = useMemo(
@@ -45,6 +43,14 @@ export const TimeFrameSlider = memo(() => {
[activePeriod] [activePeriod]
) )
// Мемоизированные константы
const totalPeriods = useMemo(() => HISTORICAL_PERIODS.length, [])
const anglePerPoint = useMemo(() => 360 / totalPeriods, [totalPeriods])
// Рефы для предыдущих значений периода
const prevYearFromRef = useRef(currentPeriod.yearFrom)
const prevYearToRef = useRef(currentPeriod.yearTo)
/** /**
* Расчет поворота при изменении активного периода * Расчет поворота при изменении активного периода
* Использует кратчайший путь для анимации * Использует кратчайший путь для анимации
@@ -68,21 +74,40 @@ export const TimeFrameSlider = memo(() => {
const ctx = gsap.context(() => { const ctx = gsap.context(() => {
if (startYearRef.current) { if (startYearRef.current) {
gsap.to(startYearRef.current, { gsap.fromTo(
innerText: currentPeriod.yearFrom, startYearRef.current,
snap: { innerText: 1 }, {
duration: 1, innerText: prevYearFromRef.current,
ease: 'power2.inOut', },
}) {
innerText: currentPeriod.yearFrom,
...GSAP_ANIMATION_CONFIG,
}
)
} }
if (endYearRef.current) { if (endYearRef.current) {
gsap.to(endYearRef.current, { gsap.fromTo(
innerText: currentPeriod.yearTo, endYearRef.current,
snap: { innerText: 1 }, {
duration: 1, innerText: prevYearToRef.current,
ease: 'power2.inOut', },
}) {
innerText: currentPeriod.yearTo,
...GSAP_ANIMATION_CONFIG,
}
)
}
prevYearFromRef.current = currentPeriod.yearFrom
prevYearToRef.current = currentPeriod.yearTo
if (periodLabelRef.current) {
gsap.fromTo(
periodLabelRef.current,
{ opacity: 0, visibility: 'hidden' },
{ opacity: 1, visibility: 'visible', duration: 1 }
)
} }
}, containerRef) }, containerRef)
@@ -115,14 +140,18 @@ export const TimeFrameSlider = memo(() => {
<span ref={endYearRef}>{currentPeriod.yearTo}</span> <span ref={endYearRef}>{currentPeriod.yearTo}</span>
</div> </div>
<div className={styles.periodLabel}>{currentPeriod.label}</div> <div className={styles.periodLabel} ref={periodLabelRef}>
{currentPeriod.label}
</div>
<CircleTimeline <div className={styles.circleContainer}>
periods={HISTORICAL_PERIODS} <CircleTimeline
activeIndex={activePeriod} periods={HISTORICAL_PERIODS}
onPeriodChange={setActivePeriod} activeIndex={activePeriod}
rotation={rotation} onPeriodChange={setActivePeriod}
/> rotation={rotation}
/>
</div>
<div className={styles.controls}> <div className={styles.controls}>
<div className={styles.pagination}> <div className={styles.pagination}>
@@ -137,7 +166,7 @@ export const TimeFrameSlider = memo(() => {
onClick={handlePrev} onClick={handlePrev}
aria-label='Предыдущий период' aria-label='Предыдущий период'
> >
<ChevronSvg width={6.25} height={12.5} stroke='#42567A' /> <ChevronSvg className={styles.chevronIcon} stroke='#42567A' />
</Button> </Button>
<Button <Button
variant='round' variant='round'
@@ -147,17 +176,30 @@ export const TimeFrameSlider = memo(() => {
aria-label='Следующий период' aria-label='Следующий период'
> >
<ChevronSvg <ChevronSvg
width={6.25} className={classNames(styles.chevronIcon, styles.rotated)}
height={12.5}
stroke='#42567A' stroke='#42567A'
className={styles.rotated}
/> />
</Button> </Button>
</div> </div>
</div> </div>
</div> </div>
<EventsCarousel events={currentPeriod.events} visible /> <div className={styles.carouselContainer}>
<EventsCarousel events={currentPeriod.events} visible />
</div>
<div className={styles.dots}>
{HISTORICAL_PERIODS.map((_, index) => (
<button
key={index}
className={classNames(styles.dot, {
[styles.activeDot]: index === activePeriod,
})}
onClick={() => setActivePeriod(index)}
aria-label={`Перейти к периоду ${index + 1}`}
/>
))}
</div>
</div> </div>
) )
}) })

View File

@@ -1,3 +1,5 @@
import { Power2 } from 'gsap'
/** /**
* Константы для компонента TimeFrameSlider * Константы для компонента TimeFrameSlider
*/ */
@@ -6,3 +8,12 @@
* Угол позиции активного элемента (верхний правый угол) * Угол позиции активного элемента (верхний правый угол)
*/ */
export const ACTIVE_POSITION_ANGLE = -60 export const ACTIVE_POSITION_ANGLE = -60
/**
* Конфигурация анимации для изменения значения года
*/
export const GSAP_ANIMATION_CONFIG: gsap.TweenVars = {
snap: { innerText: 1 },
duration: 1,
ease: Power2.easeInOut,
} as const

View File

@@ -53,5 +53,11 @@ module.exports = {
'order/properties-order': propertyOrdering, 'order/properties-order': propertyOrdering,
'declaration-empty-line-before': null, 'declaration-empty-line-before': null,
'no-descending-specificity': null, // Отключаем из-за конфликта с order/order 'no-descending-specificity': null, // Отключаем из-за конфликта с order/order
'selector-pseudo-class-no-unknown': [
true,
{
ignorePseudoClasses: ['global'],
},
],
}, },
} }

View File

@@ -18,7 +18,12 @@
"./src/*" "./src/*"
] ]
}, },
"strict": true "strict": true,
"types": [
"node",
"jest",
"@testing-library/jest-dom"
]
}, },
"ts-node": { "ts-node": {
"compilerOptions": { "compilerOptions": {