Compare commits
24 Commits
fixes/conf
...
feature/ti
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d5691e4c6 | ||
|
|
d596576356 | ||
|
|
c970d9c6d0 | ||
|
|
b84acfc3e7 | ||
|
|
5ef223d8d4 | ||
|
|
6b41f506a3 | ||
|
|
55222ba27e | ||
|
|
1ed871a9fd | ||
|
|
71330e4f78 | ||
|
|
7f0d6d902a | ||
|
|
d3731ad513 | ||
|
|
5c869eb215 | ||
|
|
e440005e60 | ||
|
|
0006a20a61 | ||
|
|
65588bc8be | ||
|
|
3f3b817a1d | ||
|
|
2b08f292dc | ||
|
|
9e81882677 | ||
|
|
5e8a6128ed | ||
|
|
6b6e1386fa | ||
|
|
4631988ee4 | ||
|
|
58bc7bc28a | ||
|
|
7f507513e9 | ||
|
|
7ef28f9313 |
@@ -17,7 +17,32 @@
|
|||||||
export function buildSvgrLoader() {
|
export function buildSvgrLoader() {
|
||||||
const svgrLoader = {
|
const svgrLoader = {
|
||||||
test: /\.svg$/,
|
test: /\.svg$/,
|
||||||
use: ['@svgr/webpack'],
|
use: [
|
||||||
|
{
|
||||||
|
loader: '@svgr/webpack',
|
||||||
|
options: {
|
||||||
|
// Replace currentColor with props
|
||||||
|
replaceAttrValues: {
|
||||||
|
currentColor: '{props.stroke || "currentColor"}',
|
||||||
|
},
|
||||||
|
// Allow width and height to be customizable
|
||||||
|
dimensions: false,
|
||||||
|
svgoConfig: {
|
||||||
|
plugins: [
|
||||||
|
{
|
||||||
|
name: 'preset-default',
|
||||||
|
params: {
|
||||||
|
overrides: {
|
||||||
|
// Keep viewBox for proper scaling
|
||||||
|
removeViewBox: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
return svgrLoader
|
return svgrLoader
|
||||||
|
|||||||
3
config/jest/JestEmptyComponent.tsx
Normal file
3
config/jest/JestEmptyComponent.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
const JestEmptyComponent = () => <div />
|
||||||
|
|
||||||
|
export default JestEmptyComponent
|
||||||
200
config/jest/jest.config.ts
Normal file
200
config/jest/jest.config.ts
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
/*
|
||||||
|
* For a detailed explanation regarding each configuration property and type check, visit:
|
||||||
|
* https://jestjs.io/docs/configuration
|
||||||
|
*/
|
||||||
|
|
||||||
|
import path, { dirname } from 'path'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
const __dirname = dirname(__filename)
|
||||||
|
|
||||||
|
const PROJECT_ROOT = path.resolve(__dirname, '../..')
|
||||||
|
|
||||||
|
export default {
|
||||||
|
// Automatically clear mock calls, instances and results before every test
|
||||||
|
clearMocks: true,
|
||||||
|
|
||||||
|
// The test environment that will be used for testing
|
||||||
|
testEnvironment: 'jsdom',
|
||||||
|
|
||||||
|
// An array of regexp pattern strings used to skip coverage collection
|
||||||
|
coveragePathIgnorePatterns: ['/node_modules/'],
|
||||||
|
|
||||||
|
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
|
||||||
|
testPathIgnorePatterns: ['/node_modules/', '<rootDir>/.fttemplates/'],
|
||||||
|
|
||||||
|
// An array of directory names to be searched recursively up from the requiring module's location
|
||||||
|
moduleDirectories: ['node_modules'],
|
||||||
|
|
||||||
|
// An array of file extensions your modules use
|
||||||
|
moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx', 'json', 'node'],
|
||||||
|
|
||||||
|
// The root directory that Jest should scan for tests and modules within
|
||||||
|
rootDir: '../../',
|
||||||
|
|
||||||
|
// The glob patterns Jest uses to detect test files
|
||||||
|
testMatch: [
|
||||||
|
'**/__tests__/**/*.[jt]s?(x)',
|
||||||
|
'**/?(*.)+(spec|test).[tj]s?(x)',
|
||||||
|
'<rootDir>src/**/*(*.)/@(spec|test)/[tj]s?(x)',
|
||||||
|
],
|
||||||
|
|
||||||
|
// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
|
||||||
|
moduleNameMapper: {
|
||||||
|
'\\.(css|scss)$': 'identity-obj-proxy',
|
||||||
|
'\\.(svg)$': path.resolve(__dirname, 'JestEmptyComponent.tsx'),
|
||||||
|
'^@/(.*)$': path.resolve(PROJECT_ROOT, 'src/$1'),
|
||||||
|
},
|
||||||
|
|
||||||
|
// A list of paths to modules that run some code to configure or set up the testing framework before each test
|
||||||
|
setupFilesAfterEnv: ['<rootDir>/config/jest/setupTests.ts'],
|
||||||
|
|
||||||
|
// A set of global variables that need to be available in all test environments
|
||||||
|
globals: {
|
||||||
|
__IS_DEV__: true,
|
||||||
|
__API__: '',
|
||||||
|
__PROJECT__: 'jest',
|
||||||
|
},
|
||||||
|
|
||||||
|
modulePaths: ['<rootDir>/src'],
|
||||||
|
|
||||||
|
// All imported modules in your tests should be mocked automatically
|
||||||
|
// automock: false,
|
||||||
|
|
||||||
|
// Stop running tests after `n` failures
|
||||||
|
// bail: 0,
|
||||||
|
|
||||||
|
// The directory where Jest should store its cached dependency information
|
||||||
|
// cacheDirectory: "/tmp/jest_rs",
|
||||||
|
|
||||||
|
// Indicates whether the coverage information should be collected while executing the test
|
||||||
|
// collectCoverage: false,
|
||||||
|
|
||||||
|
// An array of glob patterns indicating a set of files for which coverage information should be collected
|
||||||
|
// collectCoverageFrom: undefined,
|
||||||
|
|
||||||
|
// The directory where Jest should output its coverage files
|
||||||
|
// coverageDirectory: undefined,
|
||||||
|
|
||||||
|
// Indicates which provider should be used to instrument code for coverage
|
||||||
|
// coverageProvider: "babel",
|
||||||
|
|
||||||
|
// A list of reporter names that Jest uses when writing coverage reports
|
||||||
|
// coverageReporters: [
|
||||||
|
// "json",
|
||||||
|
// "text",
|
||||||
|
// "lcov",
|
||||||
|
// "clover"
|
||||||
|
// ],
|
||||||
|
|
||||||
|
// An object that configures minimum threshold enforcement for coverage results
|
||||||
|
// coverageThreshold: undefined,
|
||||||
|
|
||||||
|
// A path to a custom dependency extractor
|
||||||
|
// dependencyExtractor: undefined,
|
||||||
|
|
||||||
|
// Make calling deprecated APIs throw helpful error messages
|
||||||
|
// errorOnDeprecated: false,
|
||||||
|
|
||||||
|
// Force coverage collection from ignored files using an array of glob patterns
|
||||||
|
// forceCoverageMatch: [],
|
||||||
|
|
||||||
|
// A path to a module which exports an async function that is triggered once before all test suites
|
||||||
|
// globalSetup: undefined,
|
||||||
|
|
||||||
|
// A path to a module which exports an async function that is triggered once after all test suites
|
||||||
|
// globalTeardown: undefined,
|
||||||
|
|
||||||
|
// The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
|
||||||
|
// maxWorkers: "50%",
|
||||||
|
|
||||||
|
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
|
||||||
|
// modulePathIgnorePatterns: [],
|
||||||
|
|
||||||
|
// Activates notifications for test results
|
||||||
|
// notify: false,
|
||||||
|
|
||||||
|
// An enum that specifies notification mode. Requires { notify: true }
|
||||||
|
// notifyMode: "failure-change",
|
||||||
|
|
||||||
|
// A preset that is used as a base for Jest's configuration
|
||||||
|
// preset: undefined,
|
||||||
|
|
||||||
|
// Run tests from one or more projects
|
||||||
|
// projects: undefined,
|
||||||
|
|
||||||
|
// Use this configuration option to add custom reporters to Jest
|
||||||
|
// reporters: undefined,
|
||||||
|
|
||||||
|
// Automatically reset mock state before every test
|
||||||
|
// resetMocks: false,
|
||||||
|
|
||||||
|
// Reset the module registry before running each individual test
|
||||||
|
// resetModules: false,
|
||||||
|
|
||||||
|
// A path to a custom resolver
|
||||||
|
// resolver: undefined,
|
||||||
|
|
||||||
|
// Automatically restore mock state and implementation before every test
|
||||||
|
// restoreMocks: false,
|
||||||
|
|
||||||
|
// A list of paths to directories that Jest should use to search for files in
|
||||||
|
// roots: [
|
||||||
|
// "<rootDir>"
|
||||||
|
// ],
|
||||||
|
|
||||||
|
// Allows you to use a custom runner instead of Jest's default test runner
|
||||||
|
// runner: "jest-runner",
|
||||||
|
|
||||||
|
// The paths to modules that run some code to configure or set up the testing environment before each test
|
||||||
|
// setupFiles: [],
|
||||||
|
|
||||||
|
// The number of seconds after which a test is considered as slow and reported as such in the results.
|
||||||
|
// slowTestThreshold: 5,
|
||||||
|
|
||||||
|
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
|
||||||
|
// snapshotSerializers: [],
|
||||||
|
|
||||||
|
// Options that will be passed to the testEnvironment
|
||||||
|
// testEnvironmentOptions: {},
|
||||||
|
|
||||||
|
// Adds a location field to test results
|
||||||
|
// testLocationInResults: false,
|
||||||
|
|
||||||
|
// The regexp pattern or array of patterns that Jest uses to detect test files
|
||||||
|
// testRegex: [],
|
||||||
|
|
||||||
|
// This option allows the use of a custom results processor
|
||||||
|
// testResultsProcessor: undefined,
|
||||||
|
|
||||||
|
// This option allows use of a custom test runner
|
||||||
|
// testRunner: "jest-circus/runner",
|
||||||
|
|
||||||
|
// This option sets the URL for the jsdom environment. It is reflected in properties such as location.href
|
||||||
|
// testURL: "http://localhost",
|
||||||
|
|
||||||
|
// Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout"
|
||||||
|
// timers: "real",
|
||||||
|
|
||||||
|
// A map from regular expressions to paths to transformers
|
||||||
|
// transform: undefined,
|
||||||
|
|
||||||
|
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
|
||||||
|
// transformIgnorePatterns: [
|
||||||
|
// "/node_modules/",
|
||||||
|
// "\\.pnp\\.[^\\/]+$"
|
||||||
|
// ],
|
||||||
|
|
||||||
|
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
|
||||||
|
// unmockedModulePathPatterns: undefined,
|
||||||
|
|
||||||
|
// Indicates whether each individual test should be reported during the run
|
||||||
|
// verbose: undefined,
|
||||||
|
|
||||||
|
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
|
||||||
|
// watchPathIgnorePatterns: [],
|
||||||
|
|
||||||
|
// Whether to use watchman for file crawling
|
||||||
|
// watchman: true,
|
||||||
|
}
|
||||||
2
config/jest/setupTests.ts
Normal file
2
config/jest/setupTests.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import '@testing-library/jest-dom'
|
||||||
|
import 'regenerator-runtime/runtime'
|
||||||
8
config/storybook/StyleDecorator.tsx
Normal file
8
config/storybook/StyleDecorator.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import type { Decorator } from '@storybook/react'
|
||||||
|
import '@/app/styles/index.scss'
|
||||||
|
|
||||||
|
export const StyleDecorator: Decorator = (Story) => (
|
||||||
|
<>
|
||||||
|
<Story />
|
||||||
|
</>
|
||||||
|
)
|
||||||
91
config/storybook/main.ts
Normal file
91
config/storybook/main.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import type { StorybookConfig } from '@storybook/react-webpack5'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
import { buildCssLoader } from '../build/loaders/buildCssLoader'
|
||||||
|
|
||||||
|
const config: StorybookConfig = {
|
||||||
|
stories: ['../../src/**/*.stories.@(ts|tsx)'],
|
||||||
|
|
||||||
|
addons: [
|
||||||
|
'@storybook/addon-essentials',
|
||||||
|
'@storybook/addon-interactions',
|
||||||
|
'@storybook/addon-links',
|
||||||
|
'@storybook/addon-webpack5-compiler-babel',
|
||||||
|
],
|
||||||
|
|
||||||
|
framework: {
|
||||||
|
name: '@storybook/react-webpack5',
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
|
||||||
|
docs: {},
|
||||||
|
|
||||||
|
webpackFinal: async (config) => {
|
||||||
|
// Добавление алиасов путей TypeScript
|
||||||
|
if (config.resolve) {
|
||||||
|
config.resolve.modules = [
|
||||||
|
path.resolve(__dirname, '../../src'),
|
||||||
|
'node_modules',
|
||||||
|
]
|
||||||
|
|
||||||
|
config.resolve.alias = {
|
||||||
|
...config.resolve.alias,
|
||||||
|
'@': path.resolve(__dirname, '../../src'),
|
||||||
|
}
|
||||||
|
|
||||||
|
config.resolve.extensions = [
|
||||||
|
...(config.resolve.extensions || []),
|
||||||
|
'.ts',
|
||||||
|
'.tsx',
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавление поддержки SCSS через buildCssLoader проекта
|
||||||
|
config.module = config.module || {}
|
||||||
|
config.module.rules = config.module.rules || []
|
||||||
|
|
||||||
|
// Удаление стандартных правил CSS/SCSS из Storybook
|
||||||
|
config.module.rules = config.module.rules.filter((rule) => {
|
||||||
|
if (typeof rule === 'object' && rule !== null && 'test' in rule) {
|
||||||
|
const test = rule.test
|
||||||
|
if (test instanceof RegExp) {
|
||||||
|
// Удаляем правила для CSS/SCSS и SVG
|
||||||
|
return !(
|
||||||
|
test.test('.css') ||
|
||||||
|
test.test('.scss') ||
|
||||||
|
test.test('.sass') ||
|
||||||
|
test.test('.svg')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
// Использование конфигурации CSS loader из проекта
|
||||||
|
config.module.rules.push(buildCssLoader(true))
|
||||||
|
|
||||||
|
// Добавление поддержки SVGR для SVG иконок
|
||||||
|
config.module.rules.push({
|
||||||
|
test: /\.svg$/,
|
||||||
|
use: ['@svgr/webpack'],
|
||||||
|
})
|
||||||
|
|
||||||
|
return config
|
||||||
|
},
|
||||||
|
|
||||||
|
typescript: {
|
||||||
|
check: false,
|
||||||
|
reactDocgen: 'react-docgen-typescript',
|
||||||
|
reactDocgenTypescriptOptions: {
|
||||||
|
shouldExtractLiteralValuesFromEnum: true,
|
||||||
|
propFilter: (prop) => {
|
||||||
|
if (prop.parent) {
|
||||||
|
return !prop.parent.fileName.includes('node_modules')
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default config
|
||||||
29
config/storybook/preview.ts
Normal file
29
config/storybook/preview.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import type { Preview } from '@storybook/react'
|
||||||
|
import { StyleDecorator } from './StyleDecorator.tsx'
|
||||||
|
|
||||||
|
const preview: Preview = {
|
||||||
|
decorators: [StyleDecorator],
|
||||||
|
parameters: {
|
||||||
|
controls: {
|
||||||
|
matchers: {
|
||||||
|
color: /(background|color)$/i,
|
||||||
|
date: /Date$/i,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
backgrounds: {
|
||||||
|
default: 'light',
|
||||||
|
values: [
|
||||||
|
{
|
||||||
|
name: 'light',
|
||||||
|
value: '#F4F5F9',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'dark',
|
||||||
|
value: '#1a1a1a',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default preview
|
||||||
31
package.json
31
package.json
@@ -9,25 +9,40 @@
|
|||||||
"build:dev": "webpack --env mode=development",
|
"build:dev": "webpack --env mode=development",
|
||||||
"lint": "eslint src --ext .ts,.tsx",
|
"lint": "eslint src --ext .ts,.tsx",
|
||||||
"lint:styles": "stylelint 'src/**/*.{css,scss}' --allow-empty-input",
|
"lint:styles": "stylelint 'src/**/*.{css,scss}' --allow-empty-input",
|
||||||
|
"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 build:prod",
|
||||||
|
"storybook": "storybook dev -p 6006 -c config/storybook",
|
||||||
|
"build-storybook": "storybook build -c config/storybook"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"classnames": "^2.5.1",
|
||||||
|
"gsap": "^3.13.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0"
|
"react-dom": "^19.0.0",
|
||||||
|
"swiper": "^12.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.26.0",
|
"@babel/core": "^7.26.0",
|
||||||
"husky": "^9.1.0",
|
|
||||||
"@babel/preset-env": "^7.26.0",
|
"@babel/preset-env": "^7.26.0",
|
||||||
"@babel/preset-react": "^7.26.0",
|
"@babel/preset-react": "^7.26.0",
|
||||||
"@babel/preset-typescript": "^7.26.0",
|
"@babel/preset-typescript": "^7.26.0",
|
||||||
|
"@eslint/js": "^9.15.0",
|
||||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.15",
|
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.15",
|
||||||
|
"@storybook/addon-essentials": "^8.6.14",
|
||||||
|
"@storybook/addon-interactions": "^8.6.14",
|
||||||
|
"@storybook/addon-links": "^8.6.14",
|
||||||
|
"@storybook/addon-webpack5-compiler-babel": "^4.0.0",
|
||||||
|
"@storybook/react": "^8.6.14",
|
||||||
|
"@storybook/react-webpack5": "^8.6.14",
|
||||||
"@svgr/webpack": "^8.1.0",
|
"@svgr/webpack": "^8.1.0",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.0",
|
||||||
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
@@ -46,16 +61,19 @@
|
|||||||
"eslint-plugin-prettier": "^5.2.0",
|
"eslint-plugin-prettier": "^5.2.0",
|
||||||
"eslint-plugin-react": "^7.37.0",
|
"eslint-plugin-react": "^7.37.0",
|
||||||
"eslint-plugin-react-hooks": "^5.0.0",
|
"eslint-plugin-react-hooks": "^5.0.0",
|
||||||
"@eslint/js": "^9.15.0",
|
|
||||||
"typescript-eslint": "^8.16.0",
|
|
||||||
"globals": "^15.12.0",
|
|
||||||
"file-loader": "^6.2.0",
|
"file-loader": "^6.2.0",
|
||||||
|
"globals": "^15.12.0",
|
||||||
"html-webpack-plugin": "^5.6.0",
|
"html-webpack-plugin": "^5.6.0",
|
||||||
|
"husky": "^9.1.0",
|
||||||
|
"jest": "^30.2.0",
|
||||||
|
"jest-environment-jsdom": "^30.2.0",
|
||||||
"mini-css-extract-plugin": "^2.9.0",
|
"mini-css-extract-plugin": "^2.9.0",
|
||||||
"prettier": "^3.4.0",
|
"prettier": "^3.4.0",
|
||||||
"react-refresh": "^0.14.2",
|
"react-refresh": "^0.14.2",
|
||||||
|
"regenerator-runtime": "^0.14.1",
|
||||||
"sass": "^1.81.0",
|
"sass": "^1.81.0",
|
||||||
"sass-loader": "^16.0.0",
|
"sass-loader": "^16.0.0",
|
||||||
|
"storybook": "^8.6.14",
|
||||||
"style-loader": "^4.0.0",
|
"style-loader": "^4.0.0",
|
||||||
"stylelint": "^16.11.0",
|
"stylelint": "^16.11.0",
|
||||||
"stylelint-config-standard-scss": "^13.1.0",
|
"stylelint-config-standard-scss": "^13.1.0",
|
||||||
@@ -64,6 +82,7 @@
|
|||||||
"ts-loader": "^9.5.0",
|
"ts-loader": "^9.5.0",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "^5.7.0",
|
"typescript": "^5.7.0",
|
||||||
|
"typescript-eslint": "^8.16.0",
|
||||||
"webpack": "^5.96.0",
|
"webpack": "^5.96.0",
|
||||||
"webpack-bundle-analyzer": "^4.10.0",
|
"webpack-bundle-analyzer": "^4.10.0",
|
||||||
"webpack-cli": "^5.1.0",
|
"webpack-cli": "^5.1.0",
|
||||||
|
|||||||
3716
pnpm-lock.yaml
generated
3716
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,5 +0,0 @@
|
|||||||
const App = () => {
|
|
||||||
return <div>Test</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App
|
|
||||||
9
src/app/App.tsx
Normal file
9
src/app/App.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import './styles/index.scss'
|
||||||
|
|
||||||
|
import { TimeFrameSlider } from '@/widgets/TimeFrameSlider'
|
||||||
|
|
||||||
|
const App = () => {
|
||||||
|
return <TimeFrameSlider />
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
1
src/app/styles/fonts.scss
Normal file
1
src/app/styles/fonts.scss
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@import 'https://fonts.googleapis.com/css2?family=PT+Sans:wght@400;700&display=swap';
|
||||||
3
src/app/styles/index.scss
Normal file
3
src/app/styles/index.scss
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
@import './fonts';
|
||||||
|
@import './variables';
|
||||||
|
@import './reset';
|
||||||
34
src/app/styles/reset.scss
Normal file
34
src/app/styles/reset.scss
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: var(--font-family-main);
|
||||||
|
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-family: inherit;
|
||||||
|
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
background: none;
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul,
|
||||||
|
ol {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
23
src/app/styles/variables.scss
Normal file
23
src/app/styles/variables.scss
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
:root {
|
||||||
|
// Цвета
|
||||||
|
--color-primary: #42567A;
|
||||||
|
--color-accent: #EF5DA8;
|
||||||
|
--color-text: #42567A;
|
||||||
|
--color-bg: #F4F5F9;
|
||||||
|
--color-border: rgb(66 86 122 / 10%);
|
||||||
|
--color-blue: #3877EE;
|
||||||
|
--color-white: #FFF;
|
||||||
|
|
||||||
|
// Градиенты
|
||||||
|
--gradient-primary: linear-gradient(to right, #3877EE, #EF5DA8);
|
||||||
|
|
||||||
|
// Типографика
|
||||||
|
--font-family-main: 'PT Sans', sans-serif;
|
||||||
|
--font-size-h1: 56px;
|
||||||
|
--font-size-h2: 32px;
|
||||||
|
--font-size-h3: 20px;
|
||||||
|
--font-size-body: 16px;
|
||||||
|
--font-size-small: 14px;
|
||||||
|
--line-height-h1: 120%;
|
||||||
|
--line-height-body: 150%;
|
||||||
|
}
|
||||||
18
src/app/types/declarations.d.ts
vendored
Normal file
18
src/app/types/declarations.d.ts
vendored
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
declare module '*.module.scss' {
|
||||||
|
const classes: { [key: string]: string }
|
||||||
|
export default classes
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.scss' {
|
||||||
|
const classes: { [key: string]: string }
|
||||||
|
export default classes
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.png'
|
||||||
|
declare module '*.jpg'
|
||||||
|
declare module '*.jpeg'
|
||||||
|
declare module '*.svg' {
|
||||||
|
import React from 'react'
|
||||||
|
const SVGComponent: React.FC<React.SVGProps<SVGSVGElement>>
|
||||||
|
export default SVGComponent
|
||||||
|
}
|
||||||
7
src/entities/TimePeriod/index.ts
Normal file
7
src/entities/TimePeriod/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* Entity: TimePeriod
|
||||||
|
* Public API
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { HISTORICAL_PERIODS } from './model/mockData'
|
||||||
|
export type { TimePeriod, HistoricalEvent } from './model/types'
|
||||||
86
src/entities/TimePeriod/model/mockData.ts
Normal file
86
src/entities/TimePeriod/model/mockData.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* Entity: TimePeriod
|
||||||
|
* Мок-данные исторических периодов
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { TimePeriod } from './types'
|
||||||
|
|
||||||
|
export const HISTORICAL_PERIODS: readonly TimePeriod[] = [
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
yearFrom: 1980,
|
||||||
|
yearTo: 1986,
|
||||||
|
label: 'Science',
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
year: 1980,
|
||||||
|
description: 'The first detection of gravitational waves.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
year: 1982,
|
||||||
|
description: 'New Horizons probe performs flyby of Pluto.',
|
||||||
|
},
|
||||||
|
{ year: 1984, description: 'SpaceX lands Falcon 9 rocket.' },
|
||||||
|
{ year: 1985, description: 'Discovery of Homo naledi.' },
|
||||||
|
{
|
||||||
|
year: 1986,
|
||||||
|
description: 'Mars Reconnaissance Orbiter confirms water on Mars.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
yearFrom: 1987,
|
||||||
|
yearTo: 1991,
|
||||||
|
label: 'Cinema',
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
year: 1987,
|
||||||
|
description: 'Leonardo DiCaprio wins Oscar for The Revenant.',
|
||||||
|
},
|
||||||
|
{ year: 1989, description: 'Arrival movie released.' },
|
||||||
|
{ year: 1991, description: 'La La Land released.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
yearFrom: 1992,
|
||||||
|
yearTo: 1997,
|
||||||
|
label: 'Tech',
|
||||||
|
events: [
|
||||||
|
{ year: 1992, description: 'Nintendo Switch released.' },
|
||||||
|
{ year: 1995, description: 'iPhone X released.' },
|
||||||
|
{ year: 1997, description: 'AlphaGo Zero beats AlphaGo.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
yearFrom: 1999,
|
||||||
|
yearTo: 2004,
|
||||||
|
label: 'Music',
|
||||||
|
events: [
|
||||||
|
{ year: 1999, description: 'Childish Gambino releases This Is America.' },
|
||||||
|
{ year: 2004, description: 'Drake releases Scorpion.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
yearFrom: 2005,
|
||||||
|
yearTo: 2014,
|
||||||
|
label: 'World',
|
||||||
|
events: [
|
||||||
|
{ year: 2005, description: 'First image of a black hole.' },
|
||||||
|
{ year: 2014, description: 'Notre-Dame de Paris fire.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
yearFrom: 2015,
|
||||||
|
yearTo: 2022,
|
||||||
|
label: 'Pandemic',
|
||||||
|
events: [
|
||||||
|
{ year: 2015, description: 'COVID-19 pandemic declared.' },
|
||||||
|
{ year: 2022, description: 'SpaceX launches first crewed mission.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] as const
|
||||||
38
src/entities/TimePeriod/model/types.ts
Normal file
38
src/entities/TimePeriod/model/types.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* Entity: TimePeriod
|
||||||
|
* Типы данных для временных периодов и событий
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface HistoricalEvent {
|
||||||
|
/**
|
||||||
|
* Год события
|
||||||
|
*/
|
||||||
|
readonly year: number
|
||||||
|
/**
|
||||||
|
* Описание события
|
||||||
|
*/
|
||||||
|
readonly description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimePeriod {
|
||||||
|
/**
|
||||||
|
* Уникальный идентификатор
|
||||||
|
*/
|
||||||
|
readonly id: string
|
||||||
|
/**
|
||||||
|
* Год начала периода
|
||||||
|
*/
|
||||||
|
readonly yearFrom: number
|
||||||
|
/**
|
||||||
|
* Год конца периода
|
||||||
|
*/
|
||||||
|
readonly yearTo: number
|
||||||
|
/**
|
||||||
|
* Название категории
|
||||||
|
*/
|
||||||
|
readonly label: string
|
||||||
|
/**
|
||||||
|
* События, связанные с этим периодом и категорией
|
||||||
|
*/
|
||||||
|
readonly events: readonly HistoricalEvent[]
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
|
|
||||||
import App from './App'
|
import App from './app/App'
|
||||||
|
|
||||||
const container = document.getElementById('root')
|
const container = document.getElementById('root')
|
||||||
|
|
||||||
|
|||||||
3
src/shared/assets/chevron--left.svg
Normal file
3
src/shared/assets/chevron--left.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="9" height="14" viewBox="-1 -1 11 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M7.66418 0.707108L1.41419 6.95711L7.66418 13.2071" stroke="currentColor" stroke-width="2"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 205 B |
96
src/shared/ui/Button/Button.module.scss
Normal file
96
src/shared/ui/Button/Button.module.scss
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
.button {
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
font-family: var(--font-family-main);
|
||||||
|
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Variants
|
||||||
|
&.round {
|
||||||
|
border-radius: 50%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.regular {
|
||||||
|
padding: 0.5em 1em;
|
||||||
|
|
||||||
|
border-radius: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sizes
|
||||||
|
&.small {
|
||||||
|
height: 40px;
|
||||||
|
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.medium {
|
||||||
|
height: 50px;
|
||||||
|
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.large {
|
||||||
|
height: 60px;
|
||||||
|
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Color Schemes
|
||||||
|
&.primary {
|
||||||
|
$color-primary: var(--color-primary);
|
||||||
|
color: $color-primary;
|
||||||
|
|
||||||
|
border: 1px solid $color-primary;
|
||||||
|
|
||||||
|
background-color: transparent;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background-color: var(--color-white);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.secondary {
|
||||||
|
$color-blue: var(--color-blue);
|
||||||
|
color: $color-blue;
|
||||||
|
|
||||||
|
background-color: var(--color-white);
|
||||||
|
|
||||||
|
box-shadow: 0 0 15px rgb($color-blue / 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Icon handling
|
||||||
|
.icon {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 40%;
|
||||||
|
height: 40%;
|
||||||
|
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
104
src/shared/ui/Button/Button.stories.tsx
Normal file
104
src/shared/ui/Button/Button.stories.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import ChevronLeftIcon from '@/shared/assets/chevron--left.svg'
|
||||||
|
|
||||||
|
import { Button } from './Button'
|
||||||
|
|
||||||
|
import type { Meta, StoryObj } from '@storybook/react'
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Shared/Button',
|
||||||
|
component: Button,
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
},
|
||||||
|
tags: ['autodocs'],
|
||||||
|
argTypes: {
|
||||||
|
variant: {
|
||||||
|
control: 'select',
|
||||||
|
options: ['round', 'regular'],
|
||||||
|
description: 'Вариант внешнего вида',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
control: 'select',
|
||||||
|
options: ['small', 'medium', 'large'],
|
||||||
|
description: 'Размер кнопки',
|
||||||
|
},
|
||||||
|
colorScheme: {
|
||||||
|
control: 'select',
|
||||||
|
options: ['primary', 'secondary'],
|
||||||
|
description: 'Цветовая схема',
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
control: 'boolean',
|
||||||
|
description: 'Активность кнопки',
|
||||||
|
},
|
||||||
|
onClick: { action: 'clicked' },
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof Button>
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Базовая кнопка
|
||||||
|
*/
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
children: 'Submit',
|
||||||
|
variant: 'regular',
|
||||||
|
size: 'medium',
|
||||||
|
colorScheme: 'primary',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Альтернативная цветовая схема
|
||||||
|
*/
|
||||||
|
export const SecondaryColorScheme: Story = {
|
||||||
|
args: {
|
||||||
|
children: 'Submit',
|
||||||
|
variant: 'regular',
|
||||||
|
size: 'medium',
|
||||||
|
colorScheme: 'secondary',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Маленькая кнопка
|
||||||
|
*/
|
||||||
|
export const Small: Story = {
|
||||||
|
args: {
|
||||||
|
children: 'Submit',
|
||||||
|
size: 'small',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Большая кнопка
|
||||||
|
*/
|
||||||
|
export const Large: Story = {
|
||||||
|
args: {
|
||||||
|
children: 'Submit',
|
||||||
|
size: 'large',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Кнопка с SVG иконкой (шеврон)
|
||||||
|
*/
|
||||||
|
export const WithIcon: Story = {
|
||||||
|
args: {
|
||||||
|
children: <ChevronLeftIcon />,
|
||||||
|
variant: 'round',
|
||||||
|
size: 'medium',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отключенная кнопка
|
||||||
|
*/
|
||||||
|
export const Disabled: Story = {
|
||||||
|
args: {
|
||||||
|
children: 'Submit',
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
63
src/shared/ui/Button/Button.tsx
Normal file
63
src/shared/ui/Button/Button.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import classNames from 'classnames'
|
||||||
|
import { ButtonHTMLAttributes, memo, PropsWithChildren } from 'react'
|
||||||
|
|
||||||
|
import styles from './Button.module.scss'
|
||||||
|
|
||||||
|
export type ButtonVariant = 'round' | 'regular'
|
||||||
|
export type ButtonSize = 'small' | 'medium' | 'large'
|
||||||
|
export type ButtonColorScheme = 'primary' | 'secondary'
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
PropsWithChildren {
|
||||||
|
/**
|
||||||
|
* Вариант внешнего вида кнопки
|
||||||
|
* @default 'round'
|
||||||
|
*/
|
||||||
|
variant?: ButtonVariant
|
||||||
|
/**
|
||||||
|
* Размер кнопки
|
||||||
|
* @default 'medium'
|
||||||
|
*/
|
||||||
|
size?: ButtonSize
|
||||||
|
/**
|
||||||
|
* Цветовая схема
|
||||||
|
* @default 'timeframe'
|
||||||
|
*/
|
||||||
|
colorScheme?: ButtonColorScheme
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Универсальный компонент кнопки для использования в слайдерах и других элементах интерфейса.
|
||||||
|
* Поддерживает различные варианты отображения, размеры и цветовые схемы.
|
||||||
|
*/
|
||||||
|
export const Button = memo((props: ButtonProps) => {
|
||||||
|
const {
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
variant = 'round',
|
||||||
|
size = 'medium',
|
||||||
|
colorScheme = 'primary',
|
||||||
|
disabled,
|
||||||
|
...otherProps
|
||||||
|
} = props
|
||||||
|
|
||||||
|
const mods: Record<string, boolean | undefined> = {
|
||||||
|
[styles[variant]]: true,
|
||||||
|
[styles[size]]: true,
|
||||||
|
[styles[colorScheme]]: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
className={classNames(styles.button, mods, className)}
|
||||||
|
disabled={disabled}
|
||||||
|
{...otherProps}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
Button.displayName = 'Button'
|
||||||
7
src/shared/ui/Button/index.ts
Normal file
7
src/shared/ui/Button/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export { Button } from './Button'
|
||||||
|
export type {
|
||||||
|
ButtonProps,
|
||||||
|
ButtonVariant,
|
||||||
|
ButtonSize,
|
||||||
|
ButtonColorScheme,
|
||||||
|
} from './Button'
|
||||||
19
src/shared/ui/Card/Card.module.scss
Normal file
19
src/shared/ui/Card/Card.module.scss
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
.card {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
|
||||||
|
color: var(--color-blue);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 25px;
|
||||||
|
line-height: 120%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
color: var(--color-text);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 30px;
|
||||||
|
}
|
||||||
56
src/shared/ui/Card/Card.stories.tsx
Normal file
56
src/shared/ui/Card/Card.stories.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { Card } from './Card'
|
||||||
|
|
||||||
|
import type { Meta, StoryObj } from '@storybook/react'
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Shared/Card',
|
||||||
|
component: Card,
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
},
|
||||||
|
tags: ['autodocs'],
|
||||||
|
} satisfies Meta<typeof Card>
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Базовая карточка
|
||||||
|
*/
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
title: '1945',
|
||||||
|
description: 'Окончание Второй мировой войны',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Карточка с длинным описанием
|
||||||
|
*/
|
||||||
|
export const LongDescription: Story = {
|
||||||
|
args: {
|
||||||
|
title: '1969',
|
||||||
|
description:
|
||||||
|
'Первая высадка человека на Луну. Нил Армстронг и Базз Олдрин стали первыми людьми, ступившими на поверхность Луны в рамках миссии Аполлон-11.',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Карточка с коротким описанием
|
||||||
|
*/
|
||||||
|
export const ShortDescription: Story = {
|
||||||
|
args: {
|
||||||
|
title: '2001',
|
||||||
|
description: 'Запуск Wikipedia',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Карточка с текстовым заголовком
|
||||||
|
*/
|
||||||
|
export const TextTitle: Story = {
|
||||||
|
args: {
|
||||||
|
title: 'Новость',
|
||||||
|
description: 'Важное событие произошло сегодня',
|
||||||
|
},
|
||||||
|
}
|
||||||
37
src/shared/ui/Card/Card.tsx
Normal file
37
src/shared/ui/Card/Card.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { memo } from 'react'
|
||||||
|
|
||||||
|
import styles from './Card.module.scss'
|
||||||
|
|
||||||
|
export interface CardProps {
|
||||||
|
/**
|
||||||
|
* Заголовок карточки (например, год события)
|
||||||
|
*/
|
||||||
|
readonly title: string | number
|
||||||
|
/**
|
||||||
|
* Описание карточки
|
||||||
|
*/
|
||||||
|
readonly description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Универсальная карточка для отображения информации
|
||||||
|
*
|
||||||
|
* Отображает заголовок и описание.
|
||||||
|
* Используется для событий, новостей и других информационных блоков.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <Card title="1945" description="Окончание Второй мировой войны" />
|
||||||
|
* <Card title="Новость" description="Текст новости" />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const Card = memo(({ title, description }: CardProps) => {
|
||||||
|
return (
|
||||||
|
<div className={styles.card}>
|
||||||
|
<h3 className={styles.title}>{title}</h3>
|
||||||
|
<p className={styles.description}>{description}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
Card.displayName = 'Card'
|
||||||
2
src/shared/ui/Card/index.ts
Normal file
2
src/shared/ui/Card/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { Card } from './Card'
|
||||||
|
export type { CardProps } from './Card'
|
||||||
4
src/shared/ui/index.ts
Normal file
4
src/shared/ui/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { Button } from './Button'
|
||||||
|
export type { ButtonProps } from './Button'
|
||||||
|
export { Card } from './Card'
|
||||||
|
export type { CardProps } from './Card'
|
||||||
6
src/widgets/TimeFrameSlider/index.ts
Normal file
6
src/widgets/TimeFrameSlider/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* Widget: TimeFrameSlider
|
||||||
|
* Public API
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { TimeFrameSlider } from './ui/TimeFrameSlider'
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { CIRCLE_RADIUS } from '@/widgets/TimeFrameSlider/model'
|
||||||
|
|
||||||
|
import { calculateCoordinates } from './calculateCoordinates'
|
||||||
|
|
||||||
|
describe('calculateCoordinates', () => {
|
||||||
|
// Тесты на валидацию dotsAmount
|
||||||
|
describe('Валидация dotsAmount', () => {
|
||||||
|
it('должен выбросить ошибку, если dotsAmount меньше 2', () => {
|
||||||
|
expect(() => calculateCoordinates(1, 0)).toThrow(
|
||||||
|
'Количество точек должно быть от 2 до 6'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен выбросить ошибку, если dotsAmount равен 0', () => {
|
||||||
|
expect(() => calculateCoordinates(0, 0)).toThrow(
|
||||||
|
'Количество точек должно быть от 2 до 6'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен выбросить ошибку, если dotsAmount отрицательный', () => {
|
||||||
|
expect(() => calculateCoordinates(-1, 0)).toThrow(
|
||||||
|
'Количество точек должно быть от 2 до 6'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен выбросить ошибку, если dotsAmount больше 6', () => {
|
||||||
|
expect(() => calculateCoordinates(7, 0)).toThrow(
|
||||||
|
'Количество точек должно быть от 2 до 6'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Тесты на валидацию dotIndex
|
||||||
|
describe('Валидация dotIndex', () => {
|
||||||
|
it('должен выбросить ошибку, если dotIndex отрицательный', () => {
|
||||||
|
expect(() => calculateCoordinates(4, -1)).toThrow(
|
||||||
|
'Индекс точки должен быть от 0 до 5'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен выбросить ошибку, если dotIndex больше 5', () => {
|
||||||
|
expect(() => calculateCoordinates(6, 6)).toThrow(
|
||||||
|
'Индекс точки должен быть от 0 до 5'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен выбросить ошибку, если dotIndex больше или равен dotsAmount', () => {
|
||||||
|
expect(() => calculateCoordinates(3, 3)).toThrow(
|
||||||
|
'Индекс точки должен быть меньше количества точек'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен выбросить ошибку, если dotIndex больше dotsAmount', () => {
|
||||||
|
expect(() => calculateCoordinates(2, 4)).toThrow(
|
||||||
|
'Индекс точки должен быть меньше количества точек'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Тесты на корректные вычисления координат
|
||||||
|
describe('Корректные вычисления координат', () => {
|
||||||
|
it('должен вернуть корректные координаты для 2 точек, индекс 0', () => {
|
||||||
|
const result = calculateCoordinates(2, 0)
|
||||||
|
expect(result.x).toBeCloseTo(CIRCLE_RADIUS, 5)
|
||||||
|
expect(result.y).toBeCloseTo(0, 5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен вернуть корректные координаты для 2 точек, индекс 1', () => {
|
||||||
|
const result = calculateCoordinates(2, 1)
|
||||||
|
expect(result.x).toBeCloseTo(-CIRCLE_RADIUS, 5)
|
||||||
|
expect(result.y).toBeCloseTo(0, 5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен вернуть корректные координаты для 4 точек, индекс 0', () => {
|
||||||
|
const result = calculateCoordinates(4, 0)
|
||||||
|
expect(result.x).toBeCloseTo(CIRCLE_RADIUS, 5)
|
||||||
|
expect(result.y).toBeCloseTo(0, 5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен вернуть корректные координаты для 4 точек, индекс 1', () => {
|
||||||
|
const result = calculateCoordinates(4, 1)
|
||||||
|
expect(result.x).toBeCloseTo(0, 5)
|
||||||
|
expect(result.y).toBeCloseTo(CIRCLE_RADIUS, 5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен вернуть корректные координаты для 4 точек, индекс 2', () => {
|
||||||
|
const result = calculateCoordinates(4, 2)
|
||||||
|
expect(result.x).toBeCloseTo(-CIRCLE_RADIUS, 5)
|
||||||
|
expect(result.y).toBeCloseTo(0, 5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен вернуть корректные координаты для 4 точек, индекс 3', () => {
|
||||||
|
const result = calculateCoordinates(4, 3)
|
||||||
|
expect(result.x).toBeCloseTo(0, 5)
|
||||||
|
expect(result.y).toBeCloseTo(-CIRCLE_RADIUS, 5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен вернуть корректные координаты для 6 точек, индекс 0', () => {
|
||||||
|
const result = calculateCoordinates(6, 0)
|
||||||
|
expect(result.x).toBeCloseTo(CIRCLE_RADIUS, 5)
|
||||||
|
expect(result.y).toBeCloseTo(0, 5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен вернуть корректные координаты для 6 точек, индекс 3', () => {
|
||||||
|
const result = calculateCoordinates(6, 3)
|
||||||
|
expect(result.x).toBeCloseTo(-CIRCLE_RADIUS, 5)
|
||||||
|
expect(result.y).toBeCloseTo(0, 5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен вернуть координаты с правильной структурой объекта', () => {
|
||||||
|
const result = calculateCoordinates(3, 0)
|
||||||
|
expect(result).toHaveProperty('x')
|
||||||
|
expect(result).toHaveProperty('y')
|
||||||
|
expect(typeof result.x).toBe('number')
|
||||||
|
expect(typeof result.y).toBe('number')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Граничные случаи
|
||||||
|
describe('Граничные случаи', () => {
|
||||||
|
it('должен работать с минимальным количеством точек (2)', () => {
|
||||||
|
expect(() => calculateCoordinates(2, 0)).not.toThrow()
|
||||||
|
expect(() => calculateCoordinates(2, 1)).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен работать с максимальным количеством точек (6)', () => {
|
||||||
|
expect(() => calculateCoordinates(6, 0)).not.toThrow()
|
||||||
|
expect(() => calculateCoordinates(6, 5)).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен работать с последним допустимым индексом для каждого количества точек', () => {
|
||||||
|
expect(() => calculateCoordinates(2, 1)).not.toThrow()
|
||||||
|
expect(() => calculateCoordinates(3, 2)).not.toThrow()
|
||||||
|
expect(() => calculateCoordinates(4, 3)).not.toThrow()
|
||||||
|
expect(() => calculateCoordinates(5, 4)).not.toThrow()
|
||||||
|
expect(() => calculateCoordinates(6, 5)).not.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Тесты на математическую корректность
|
||||||
|
describe('Математическая корректность', () => {
|
||||||
|
it('должен расположить точки на окружности заданного радиуса', () => {
|
||||||
|
const result = calculateCoordinates(4, 1)
|
||||||
|
const distanceFromCenter = Math.sqrt(result.x ** 2 + result.y ** 2)
|
||||||
|
expect(distanceFromCenter).toBeCloseTo(CIRCLE_RADIUS, 5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('должен равномерно распределять точки по окружности', () => {
|
||||||
|
const dotsAmount = 4
|
||||||
|
const results = []
|
||||||
|
for (let i = 0; i < dotsAmount; i++) {
|
||||||
|
results.push(calculateCoordinates(dotsAmount, i))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, что все точки на одинаковом расстоянии от центра
|
||||||
|
const distances = results.map((r) => Math.sqrt(r.x ** 2 + r.y ** 2))
|
||||||
|
const firstDistance = distances[0]
|
||||||
|
distances.forEach((distance) => {
|
||||||
|
expect(distance).toBeCloseTo(firstDistance, 5)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import {
|
||||||
|
CIRCLE_RADIUS,
|
||||||
|
FULL_CIRCLE_DEGREES,
|
||||||
|
HALF_CIRCLE_DEGREES,
|
||||||
|
} from '@/widgets/TimeFrameSlider/model'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Интерфейс для координат точки.
|
||||||
|
*/
|
||||||
|
export interface DotCoordinates {
|
||||||
|
/**
|
||||||
|
* X координата точки.
|
||||||
|
*/
|
||||||
|
x: number
|
||||||
|
/**
|
||||||
|
* Y координата точки.
|
||||||
|
*/
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Функция для вычисления координат точки на круге исходя из общего количества точек и индекса текущей точки.
|
||||||
|
* @param {number} dotsAmount - Количество точек (от 2 до 6).
|
||||||
|
* @param {number} dotIndex - Индекс текущей точки.
|
||||||
|
* @returns {DotCoordinates} Координаты точки.
|
||||||
|
*/
|
||||||
|
export function calculateCoordinates(
|
||||||
|
dotsAmount: number,
|
||||||
|
dotIndex: number
|
||||||
|
): DotCoordinates {
|
||||||
|
// Валидация dotsAmount
|
||||||
|
if (dotsAmount < 2 || dotsAmount > 6) {
|
||||||
|
throw new Error('Количество точек должно быть от 2 до 6')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Валидация dotIndex
|
||||||
|
if (dotIndex < 0 || dotIndex > 5) {
|
||||||
|
throw new Error('Индекс точки должен быть от 0 до 5')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Дополнительная проверка: dotIndex не должен превышать dotsAmount - 1
|
||||||
|
if (dotIndex >= dotsAmount) {
|
||||||
|
throw new Error('Индекс точки должен быть меньше количества точек')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Угол для текущей точки (в градусах)
|
||||||
|
const angle = (FULL_CIRCLE_DEGREES / dotsAmount) * dotIndex
|
||||||
|
|
||||||
|
// Конвертация в радианы для тригонометрических функций
|
||||||
|
const radian = (angle * Math.PI) / HALF_CIRCLE_DEGREES
|
||||||
|
|
||||||
|
// Вычисление координат на круге
|
||||||
|
const x = CIRCLE_RADIUS * Math.cos(radian)
|
||||||
|
const y = CIRCLE_RADIUS * Math.sin(radian)
|
||||||
|
|
||||||
|
return { x, y }
|
||||||
|
}
|
||||||
35
src/widgets/TimeFrameSlider/model/constants.ts
Normal file
35
src/widgets/TimeFrameSlider/model/constants.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Константы для компонента CircleTimeline
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Power2 } from 'gsap'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Полный круг в градусах
|
||||||
|
*/
|
||||||
|
export const FULL_CIRCLE_DEGREES = 360
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Половина круга в градусах
|
||||||
|
*/
|
||||||
|
export const HALF_CIRCLE_DEGREES = 180
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Радиус круга в пикселях
|
||||||
|
*/
|
||||||
|
export const CIRCLE_RADIUS = 265
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Длительность анимации в секундах
|
||||||
|
*/
|
||||||
|
export const ANIMATION_DURATION = 1
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Easing функция для анимации GSAP
|
||||||
|
*/
|
||||||
|
export const ANIMATION_EASE = Power2.easeOut
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Позиция активного элемента в градусах (верхний правый угол)
|
||||||
|
*/
|
||||||
|
export const ACTIVE_POSITION_DEGREES = -60
|
||||||
8
src/widgets/TimeFrameSlider/model/index.ts
Normal file
8
src/widgets/TimeFrameSlider/model/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export {
|
||||||
|
FULL_CIRCLE_DEGREES,
|
||||||
|
HALF_CIRCLE_DEGREES,
|
||||||
|
CIRCLE_RADIUS,
|
||||||
|
ANIMATION_DURATION,
|
||||||
|
ANIMATION_EASE,
|
||||||
|
ACTIVE_POSITION_DEGREES,
|
||||||
|
} from './constants'
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
.circleContainer {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
|
||||||
|
width: calc(var(--circle-radius, 265px) * 2);
|
||||||
|
height: calc(var(--circle-radius, 265px) * 2);
|
||||||
|
|
||||||
|
border: 1px solid rgba(#42567A, 0.2);
|
||||||
|
border-radius: 50%;
|
||||||
|
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.point {
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
margin-top: -28px;
|
||||||
|
margin-left: -28px;
|
||||||
|
|
||||||
|
border: 25px solid transparent;
|
||||||
|
border-radius: 50%;
|
||||||
|
|
||||||
|
background: var(--color-text);
|
||||||
|
background-clip: content-box;
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
transform-origin: center;
|
||||||
|
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&.active {
|
||||||
|
z-index: 10;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
border: 1px solid rgba(#303E58, 0.5);
|
||||||
|
|
||||||
|
background: #F4F5F9;
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .label,
|
||||||
|
&.active .label {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
display: none;
|
||||||
|
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
import { HISTORICAL_PERIODS } from '@/entities/TimePeriod'
|
||||||
|
import {
|
||||||
|
ACTIVE_POSITION_DEGREES,
|
||||||
|
FULL_CIRCLE_DEGREES,
|
||||||
|
} from '@/widgets/TimeFrameSlider/model'
|
||||||
|
|
||||||
|
import { CircleTimeline } from './CircleTimeline'
|
||||||
|
|
||||||
|
import type { Meta, StoryObj } from '@storybook/react'
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Widgets/CircleTimeline',
|
||||||
|
component: CircleTimeline,
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
},
|
||||||
|
tags: ['autodocs'],
|
||||||
|
} satisfies Meta<typeof CircleTimeline>
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
type CustomStory = Partial<Story> & Pick<Story, 'render'>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Интерактивный компонент-обертка для управления состоянием
|
||||||
|
*/
|
||||||
|
function CircleTimelineWithState() {
|
||||||
|
const [activeIndex, setActiveIndex] = useState(0)
|
||||||
|
|
||||||
|
// Расчет угла поворота на основе активного индекса
|
||||||
|
// Каждый период занимает 360/6 = 60 градусов
|
||||||
|
// Активная позиция находится на -60 градусах (верхний правый угол)
|
||||||
|
const rotation =
|
||||||
|
ACTIVE_POSITION_DEGREES -
|
||||||
|
(FULL_CIRCLE_DEGREES / HISTORICAL_PERIODS.length) * activeIndex
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ width: '600px', height: '600px', position: 'relative' }}>
|
||||||
|
<CircleTimeline
|
||||||
|
periods={HISTORICAL_PERIODS}
|
||||||
|
activeIndex={activeIndex}
|
||||||
|
onPeriodChange={setActiveIndex}
|
||||||
|
rotation={rotation}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Интерактивный вариант со всеми 6 периодами
|
||||||
|
*/
|
||||||
|
export const Default: CustomStory = {
|
||||||
|
render: () => <CircleTimelineWithState />,
|
||||||
|
parameters: {
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
story:
|
||||||
|
'Интерактивная демонстрация с возможностью переключения между периодами',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Первый период (Science) активен
|
||||||
|
*/
|
||||||
|
export const FirstPeriod: Story = {
|
||||||
|
args: {
|
||||||
|
periods: HISTORICAL_PERIODS,
|
||||||
|
activeIndex: 0,
|
||||||
|
onPeriodChange: () => {},
|
||||||
|
rotation: ACTIVE_POSITION_DEGREES,
|
||||||
|
},
|
||||||
|
decorators: [
|
||||||
|
(Story) => (
|
||||||
|
<div style={{ width: '600px', height: '600px', position: 'relative' }}>
|
||||||
|
<Story />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Третий период (Tech) активен
|
||||||
|
*/
|
||||||
|
export const ThirdPeriod: Story = {
|
||||||
|
args: {
|
||||||
|
periods: HISTORICAL_PERIODS,
|
||||||
|
activeIndex: 2,
|
||||||
|
onPeriodChange: () => {},
|
||||||
|
rotation: ACTIVE_POSITION_DEGREES - (FULL_CIRCLE_DEGREES / 6) * 2,
|
||||||
|
},
|
||||||
|
decorators: [
|
||||||
|
(Story) => (
|
||||||
|
<div style={{ width: '600px', height: '600px', position: 'relative' }}>
|
||||||
|
<Story />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вариант с 3 периодами для демонстрации гибкости
|
||||||
|
*/
|
||||||
|
export const FewPeriods: Story = {
|
||||||
|
args: {
|
||||||
|
periods: HISTORICAL_PERIODS.slice(0, 3),
|
||||||
|
activeIndex: 0,
|
||||||
|
onPeriodChange: () => {},
|
||||||
|
rotation: -60,
|
||||||
|
},
|
||||||
|
decorators: [
|
||||||
|
(Story) => (
|
||||||
|
<div style={{ width: '600px', height: '600px', position: 'relative' }}>
|
||||||
|
<Story />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
}
|
||||||
147
src/widgets/TimeFrameSlider/ui/CircleTimeline/CircleTimeline.tsx
Normal file
147
src/widgets/TimeFrameSlider/ui/CircleTimeline/CircleTimeline.tsx
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* CircleTimeline Component
|
||||||
|
* Круговая временная шкала с периодами
|
||||||
|
*
|
||||||
|
* @module CircleTimeline
|
||||||
|
* @description Компонент отображает временные периоды на круговой диаграмме.
|
||||||
|
* Активный период автоматически поворачивается в заданную позицию с помощью GSAP анимации.
|
||||||
|
* Поддерживает клик по точкам для переключения периодов.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import classNames from 'classnames'
|
||||||
|
import gsap from 'gsap'
|
||||||
|
import { memo, useCallback, useEffect, useMemo, useRef } from 'react'
|
||||||
|
|
||||||
|
import styles from './CircleTimeline.module.scss'
|
||||||
|
import { calculateCoordinates } from '../../lib/utils/calculateCoordinates/calculateCoordinates'
|
||||||
|
import { ANIMATION_DURATION, ANIMATION_EASE } from '../../model'
|
||||||
|
|
||||||
|
import type { TimePeriod } from '@/entities/TimePeriod'
|
||||||
|
|
||||||
|
export interface CircleTimelineProps {
|
||||||
|
/**
|
||||||
|
* Массив временных периодов для отображения
|
||||||
|
*/
|
||||||
|
readonly periods: readonly TimePeriod[]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Индекс активного периода (0-based)
|
||||||
|
*/
|
||||||
|
readonly activeIndex: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback для изменения активного периода
|
||||||
|
* @param index - Индекс выбранного периода
|
||||||
|
*/
|
||||||
|
readonly onPeriodChange: (index: number) => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Угол поворота круга в градусах
|
||||||
|
*/
|
||||||
|
readonly rotation: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CircleTimeline - компонент круговой временной шкалы
|
||||||
|
*
|
||||||
|
* @component
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <CircleTimeline
|
||||||
|
* periods={HISTORICAL_PERIODS}
|
||||||
|
* activeIndex={0}
|
||||||
|
* onPeriodChange={(index) => setActiveIndex(index)}
|
||||||
|
* rotation={-60}
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const CircleTimeline = memo(function CircleTimeline({
|
||||||
|
periods,
|
||||||
|
activeIndex,
|
||||||
|
onPeriodChange,
|
||||||
|
rotation,
|
||||||
|
}: CircleTimelineProps) {
|
||||||
|
// Реф для контейнера круга
|
||||||
|
const circleRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// Реф для массива точек периодов
|
||||||
|
const pointsRef = useRef<(HTMLDivElement | null)[]>([])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Эффект для анимации поворота круга и контр-поворота точек
|
||||||
|
* Запускается при изменении rotation
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
// Анимация поворота контейнера круга
|
||||||
|
if (circleRef.current) {
|
||||||
|
gsap.to(circleRef.current, {
|
||||||
|
rotation,
|
||||||
|
duration: ANIMATION_DURATION,
|
||||||
|
ease: ANIMATION_EASE,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Контр-поворот точек, чтобы текст оставался читаемым
|
||||||
|
pointsRef.current.forEach((point) => {
|
||||||
|
if (point) {
|
||||||
|
gsap.to(point, {
|
||||||
|
rotation: -rotation,
|
||||||
|
duration: 0,
|
||||||
|
ease: ANIMATION_EASE,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [rotation])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Мемоизированный расчет позиций точек на круге
|
||||||
|
* Пересчитывается только при изменении количества периодов
|
||||||
|
*/
|
||||||
|
const pointPositions = useMemo(() => {
|
||||||
|
return periods.map((_, index, array) =>
|
||||||
|
calculateCoordinates(array.length, index)
|
||||||
|
)
|
||||||
|
}, [periods])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Мемоизированный обработчик клика по точке
|
||||||
|
* Предотвращает создание новой функции при каждом рендере
|
||||||
|
*/
|
||||||
|
const handlePointClick = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
onPeriodChange(index)
|
||||||
|
},
|
||||||
|
[onPeriodChange]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.circleContainer} ref={circleRef}>
|
||||||
|
{periods.map((period, index) => {
|
||||||
|
const { x, y } = pointPositions[index]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={period.id}
|
||||||
|
ref={(el) => {
|
||||||
|
pointsRef.current[index] = el
|
||||||
|
}}
|
||||||
|
className={classNames(styles.point, {
|
||||||
|
[styles.active]: index === activeIndex,
|
||||||
|
})}
|
||||||
|
style={{
|
||||||
|
left: `calc(50% + ${x}px)`,
|
||||||
|
top: `calc(50% + ${y}px)`,
|
||||||
|
}}
|
||||||
|
onClick={() => handlePointClick(index)}
|
||||||
|
role='button'
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={`Period ${index + 1}: ${period.label}`}
|
||||||
|
aria-current={index === activeIndex ? 'true' : 'false'}
|
||||||
|
>
|
||||||
|
<span className={styles.label}>{index + 1}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
.container {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prevButtonWrapper {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: -25px;
|
||||||
|
z-index: 10;
|
||||||
|
|
||||||
|
transform: translateY(-50%);
|
||||||
|
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nextButtonWrapper {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: -25px;
|
||||||
|
z-index: 10;
|
||||||
|
|
||||||
|
transform: translateY(-50%) rotate(180deg);
|
||||||
|
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
opacity: 0;
|
||||||
|
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { HISTORICAL_PERIODS } from '@/entities/TimePeriod'
|
||||||
|
|
||||||
|
import { EventsCarousel } from './EventsCarousel'
|
||||||
|
|
||||||
|
import type { Meta, StoryObj } from '@storybook/react'
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Widgets/EventsCarousel',
|
||||||
|
component: EventsCarousel,
|
||||||
|
parameters: {
|
||||||
|
layout: 'fullwidth',
|
||||||
|
},
|
||||||
|
tags: ['autodocs'],
|
||||||
|
decorators: [
|
||||||
|
(Story) => (
|
||||||
|
<div style={{ padding: '0 50px' }}>
|
||||||
|
<Story />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
argTypes: {
|
||||||
|
visible: {
|
||||||
|
control: 'boolean',
|
||||||
|
description: 'Видимость карусели (управляет анимацией)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof EventsCarousel>
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Базовая карусель с событиями первого периода
|
||||||
|
*/
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
events: HISTORICAL_PERIODS[0].events,
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Карусель с событиями второго периода (Cinema)
|
||||||
|
*/
|
||||||
|
export const CinemaPeriod: Story = {
|
||||||
|
args: {
|
||||||
|
events: HISTORICAL_PERIODS[1].events,
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Скрытая карусель (для демонстрации анимации)
|
||||||
|
*/
|
||||||
|
export const Hidden: Story = {
|
||||||
|
args: {
|
||||||
|
events: HISTORICAL_PERIODS[0].events,
|
||||||
|
visible: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Карусель с малым количеством событий
|
||||||
|
*/
|
||||||
|
export const FewEvents: Story = {
|
||||||
|
args: {
|
||||||
|
events: HISTORICAL_PERIODS[0].events.slice(0, 2),
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
164
src/widgets/TimeFrameSlider/ui/EventsCarousel/EventsCarousel.tsx
Normal file
164
src/widgets/TimeFrameSlider/ui/EventsCarousel/EventsCarousel.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
/**
|
||||||
|
* EventsCarousel Component
|
||||||
|
* Карусель событий с использованием Swiper
|
||||||
|
* Отображает список исторических событий в виде слайдера
|
||||||
|
*/
|
||||||
|
|
||||||
|
import classNames from 'classnames'
|
||||||
|
import gsap from 'gsap'
|
||||||
|
import { memo, useEffect, useRef, useState } from 'react'
|
||||||
|
import { Swiper, SwiperSlide } from 'swiper/react'
|
||||||
|
|
||||||
|
import 'swiper/css'
|
||||||
|
import 'swiper/css/navigation'
|
||||||
|
import 'swiper/css/pagination'
|
||||||
|
|
||||||
|
import ChevronSvg from '@/shared/assets/chevron--left.svg'
|
||||||
|
import { Button } from '@/shared/ui/Button'
|
||||||
|
import { Card } from '@/shared/ui/Card'
|
||||||
|
|
||||||
|
import {
|
||||||
|
EVENT_CAROUSEL_CONFIG,
|
||||||
|
HIDE_DURATION,
|
||||||
|
SHOW_DELAY,
|
||||||
|
SHOW_DURATION,
|
||||||
|
SHOW_Y_OFFSET,
|
||||||
|
} from './constants'
|
||||||
|
import styles from './EventsCarousel.module.scss'
|
||||||
|
|
||||||
|
import type { HistoricalEvent } from '@/entities/TimePeriod'
|
||||||
|
import type { Swiper as SwiperType } from 'swiper'
|
||||||
|
|
||||||
|
export interface EventsCarouselProps {
|
||||||
|
/**
|
||||||
|
* Массив исторических событий для отображения
|
||||||
|
*/
|
||||||
|
readonly events: readonly HistoricalEvent[]
|
||||||
|
/**
|
||||||
|
* Флаг видимости карусели (управляет анимацией появления/исчезновения)
|
||||||
|
*/
|
||||||
|
readonly visible: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Компонент карусели исторических событий
|
||||||
|
*
|
||||||
|
* Использует Swiper для создания слайдера с кастомной навигацией.
|
||||||
|
* Поддерживает адаптивное количество слайдов на разных размерах экрана.
|
||||||
|
* Анимирует появление/исчезновение с помощью GSAP.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <EventsCarousel
|
||||||
|
* events={ HISTORICAL_PERIODS[0].events }
|
||||||
|
* visible={ true }
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const EventsCarousel = memo(
|
||||||
|
({ events, visible }: EventsCarouselProps) => {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [isBeginning, setIsBeginning] = useState(true)
|
||||||
|
const [isEnd, setIsEnd] = useState(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Эффект для анимации появления/исчезновения карусели
|
||||||
|
* Использует GSAP для плавной анимации opacity и y-позиции
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current) return
|
||||||
|
|
||||||
|
const ctx = gsap.context(() => {
|
||||||
|
if (visible) {
|
||||||
|
gsap.fromTo(
|
||||||
|
containerRef.current,
|
||||||
|
{ opacity: 0, y: SHOW_Y_OFFSET },
|
||||||
|
{
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
duration: SHOW_DURATION,
|
||||||
|
delay: SHOW_DELAY,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
gsap.to(containerRef.current, {
|
||||||
|
opacity: 0,
|
||||||
|
duration: HIDE_DURATION,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, containerRef)
|
||||||
|
|
||||||
|
return () => ctx.revert()
|
||||||
|
}, [visible])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обработчик инициализации Swiper
|
||||||
|
* Устанавливает начальное состояние кнопок навигации
|
||||||
|
*/
|
||||||
|
const handleSwiperInit = (swiper: SwiperType) => {
|
||||||
|
setIsBeginning(swiper.isBeginning)
|
||||||
|
setIsEnd(swiper.isEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обработчик изменения состояния Swiper
|
||||||
|
* Обновляет состояние кнопок навигации
|
||||||
|
*/
|
||||||
|
const handleSlideChange = (swiper: SwiperType) => {
|
||||||
|
setIsBeginning(swiper.isBeginning)
|
||||||
|
setIsEnd(swiper.isEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container} ref={containerRef}>
|
||||||
|
<div
|
||||||
|
className={classNames(styles.prevButtonWrapper, {
|
||||||
|
[styles.hidden]: isBeginning,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant='round'
|
||||||
|
size='small'
|
||||||
|
colorScheme='secondary'
|
||||||
|
className='swiper-button-prev-custom'
|
||||||
|
aria-label='Предыдущий слайд'
|
||||||
|
>
|
||||||
|
<ChevronSvg width={6} height={9} stroke='#3877EE' />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={classNames(styles.nextButtonWrapper, {
|
||||||
|
[styles.hidden]: isEnd,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant='round'
|
||||||
|
size='small'
|
||||||
|
colorScheme='secondary'
|
||||||
|
className='swiper-button-next-custom'
|
||||||
|
aria-label='Следующий слайд'
|
||||||
|
>
|
||||||
|
<ChevronSvg width={6} height={9} stroke='#3877EE' />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Swiper
|
||||||
|
{...EVENT_CAROUSEL_CONFIG}
|
||||||
|
onInit={handleSwiperInit}
|
||||||
|
onSlideChange={handleSlideChange}
|
||||||
|
>
|
||||||
|
{events.map((event) => (
|
||||||
|
<SwiperSlide
|
||||||
|
key={`${event.year}-${event.description.slice(0, 20)}`}
|
||||||
|
>
|
||||||
|
<Card title={event.year} description={event.description} />
|
||||||
|
</SwiperSlide>
|
||||||
|
))}
|
||||||
|
</Swiper>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
EventsCarousel.displayName = 'EventsCarousel'
|
||||||
30
src/widgets/TimeFrameSlider/ui/EventsCarousel/constants.ts
Normal file
30
src/widgets/TimeFrameSlider/ui/EventsCarousel/constants.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import gsap from 'gsap'
|
||||||
|
import { Navigation } from 'swiper/modules'
|
||||||
|
|
||||||
|
import type { SwiperOptions } from 'swiper/types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Полная конфигурация Swiper для карусели событий
|
||||||
|
*/
|
||||||
|
export const EVENT_CAROUSEL_CONFIG: SwiperOptions = {
|
||||||
|
modules: [Navigation],
|
||||||
|
spaceBetween: 30,
|
||||||
|
slidesPerView: 1.5,
|
||||||
|
breakpoints: {
|
||||||
|
768: {
|
||||||
|
slidesPerView: 3.5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
navigation: {
|
||||||
|
prevEl: '.swiper-button-prev-custom',
|
||||||
|
nextEl: '.swiper-button-next-custom',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Константы для GSAP анимаций
|
||||||
|
*/
|
||||||
|
export const SHOW_DURATION: gsap.TweenVars['duration'] = 0.5
|
||||||
|
export const SHOW_DELAY: gsap.TweenVars['delay'] = 0.2
|
||||||
|
export const SHOW_Y_OFFSET: gsap.TweenVars['y'] = 20
|
||||||
|
export const HIDE_DURATION: gsap.TweenVars['duration'] = 0.3
|
||||||
2
src/widgets/TimeFrameSlider/ui/EventsCarousel/index.ts
Normal file
2
src/widgets/TimeFrameSlider/ui/EventsCarousel/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { EventsCarousel } from './EventsCarousel'
|
||||||
|
export type { EventsCarouselProps } from './EventsCarousel'
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
.container {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1440px;
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 20px;
|
||||||
|
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: var(--font-family-main);
|
||||||
|
|
||||||
|
border-right: 1px solid var(--color-border);
|
||||||
|
border-left: 1px solid var(--color-border);
|
||||||
|
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
@media (width <=768px) {
|
||||||
|
min-height: auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
|
||||||
|
margin-bottom: 40px;
|
||||||
|
padding-left: 60px;
|
||||||
|
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 56px;
|
||||||
|
line-height: 120%;
|
||||||
|
|
||||||
|
@media (width <=768px) {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-left: 0;
|
||||||
|
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
|
||||||
|
height: 600px;
|
||||||
|
|
||||||
|
@media (width <=768px) {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
position: absolute;
|
||||||
|
left: 60px;
|
||||||
|
bottom: 50px;
|
||||||
|
z-index: 10;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
|
||||||
|
@media (width <=768px) {
|
||||||
|
position: static;
|
||||||
|
|
||||||
|
order: 2;
|
||||||
|
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rotated {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.centerDate {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
z-index: 0;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
gap: 40px;
|
||||||
|
|
||||||
|
color: var(--color-text);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 200px;
|
||||||
|
line-height: 160px;
|
||||||
|
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
@media (width <=768px) {
|
||||||
|
position: static;
|
||||||
|
|
||||||
|
gap: 20px;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
margin-bottom: 40px;
|
||||||
|
|
||||||
|
font-size: 56px;
|
||||||
|
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
span:first-child {
|
||||||
|
color: #5d5fef;
|
||||||
|
}
|
||||||
|
|
||||||
|
span:last-child {
|
||||||
|
color: #ef5da8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.periodLabel {
|
||||||
|
display: none;
|
||||||
|
|
||||||
|
@media (width <=768px) {
|
||||||
|
order: 1;
|
||||||
|
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
margin-bottom: 40px;
|
||||||
|
padding-top: 40px;
|
||||||
|
|
||||||
|
color: var(--color-text);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 20px;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.circleContainer {
|
||||||
|
@media (width <=768px) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
/**
|
||||||
|
* TimeFrameSlider Component
|
||||||
|
* Главный компонент временной шкалы с круговой диаграммой и каруселью событий
|
||||||
|
*/
|
||||||
|
|
||||||
|
import gsap from 'gsap'
|
||||||
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
import { HISTORICAL_PERIODS } from '@/entities/TimePeriod'
|
||||||
|
import ChevronSvg from '@/shared/assets/chevron--left.svg'
|
||||||
|
import { Button } from '@/shared/ui/Button'
|
||||||
|
|
||||||
|
import { ACTIVE_POSITION_ANGLE } from './constants'
|
||||||
|
import styles from './TimeFrameSlider.module.scss'
|
||||||
|
import { CircleTimeline } from '../CircleTimeline/CircleTimeline'
|
||||||
|
import { EventsCarousel } from '../EventsCarousel/EventsCarousel'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Компонент временной шкалы с интерактивной круговой диаграммой
|
||||||
|
*
|
||||||
|
* Отображает исторические периоды на круговой диаграмме с возможностью
|
||||||
|
* переключения между ними. Для каждого периода показывается карусель событий.
|
||||||
|
* Центральные даты анимируются при смене периода с помощью GSAP.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <TimeFrameSlider />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const TimeFrameSlider = memo(() => {
|
||||||
|
const [activePeriod, setActivePeriod] = useState(0)
|
||||||
|
const [rotation, setRotation] = useState(0)
|
||||||
|
const prevRotation = useRef(0)
|
||||||
|
const startYearRef = useRef<HTMLSpanElement>(null)
|
||||||
|
const endYearRef = useRef<HTMLSpanElement>(null)
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// Мемоизированные константы
|
||||||
|
const totalPeriods = useMemo(() => HISTORICAL_PERIODS.length, [])
|
||||||
|
const anglePerPoint = useMemo(() => 360 / totalPeriods, [totalPeriods])
|
||||||
|
|
||||||
|
// Текущий период
|
||||||
|
const currentPeriod = useMemo(
|
||||||
|
() => HISTORICAL_PERIODS[activePeriod],
|
||||||
|
[activePeriod]
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Расчет поворота при изменении активного периода
|
||||||
|
* Использует кратчайший путь для анимации
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
const targetRotation = ACTIVE_POSITION_ANGLE - activePeriod * anglePerPoint
|
||||||
|
const current = prevRotation.current
|
||||||
|
const adjustedTarget =
|
||||||
|
targetRotation - 360 * Math.round((targetRotation - current) / 360)
|
||||||
|
|
||||||
|
setRotation(adjustedTarget)
|
||||||
|
prevRotation.current = adjustedTarget
|
||||||
|
}, [activePeriod, anglePerPoint])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Анимация центральных дат с использованием GSAP
|
||||||
|
* Плавно изменяет числа при смене периода
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current) return
|
||||||
|
|
||||||
|
const ctx = gsap.context(() => {
|
||||||
|
if (startYearRef.current) {
|
||||||
|
gsap.to(startYearRef.current, {
|
||||||
|
innerText: currentPeriod.yearFrom,
|
||||||
|
snap: { innerText: 1 },
|
||||||
|
duration: 1,
|
||||||
|
ease: 'power2.inOut',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endYearRef.current) {
|
||||||
|
gsap.to(endYearRef.current, {
|
||||||
|
innerText: currentPeriod.yearTo,
|
||||||
|
snap: { innerText: 1 },
|
||||||
|
duration: 1,
|
||||||
|
ease: 'power2.inOut',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, containerRef)
|
||||||
|
|
||||||
|
return () => ctx.revert()
|
||||||
|
}, [currentPeriod.yearFrom, currentPeriod.yearTo])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Переключение на предыдущий период
|
||||||
|
* Использует циклическую навигацию
|
||||||
|
*/
|
||||||
|
const handlePrev = useCallback(() => {
|
||||||
|
setActivePeriod((prev) => (prev - 1 + totalPeriods) % totalPeriods)
|
||||||
|
}, [totalPeriods])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Переключение на следующий период
|
||||||
|
* Использует циклическую навигацию
|
||||||
|
*/
|
||||||
|
const handleNext = useCallback(() => {
|
||||||
|
setActivePeriod((prev) => (prev + 1) % totalPeriods)
|
||||||
|
}, [totalPeriods])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container} ref={containerRef}>
|
||||||
|
<h1 className={styles.title}>Исторические даты</h1>
|
||||||
|
|
||||||
|
<div className={styles.content}>
|
||||||
|
<div className={styles.centerDate}>
|
||||||
|
<span ref={startYearRef}>{currentPeriod.yearFrom}</span>
|
||||||
|
<span ref={endYearRef}>{currentPeriod.yearTo}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.periodLabel}>{currentPeriod.label}</div>
|
||||||
|
|
||||||
|
<CircleTimeline
|
||||||
|
periods={HISTORICAL_PERIODS}
|
||||||
|
activeIndex={activePeriod}
|
||||||
|
onPeriodChange={setActivePeriod}
|
||||||
|
rotation={rotation}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={styles.controls}>
|
||||||
|
<div className={styles.pagination}>
|
||||||
|
{String(activePeriod + 1).padStart(2, '0')}/
|
||||||
|
{String(totalPeriods).padStart(2, '0')}
|
||||||
|
</div>
|
||||||
|
<div className={styles.buttons}>
|
||||||
|
<Button
|
||||||
|
variant='round'
|
||||||
|
size='medium'
|
||||||
|
colorScheme='primary'
|
||||||
|
onClick={handlePrev}
|
||||||
|
aria-label='Предыдущий период'
|
||||||
|
>
|
||||||
|
<ChevronSvg width={6.25} height={12.5} stroke='#42567A' />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='round'
|
||||||
|
size='medium'
|
||||||
|
colorScheme='primary'
|
||||||
|
onClick={handleNext}
|
||||||
|
aria-label='Следующий период'
|
||||||
|
>
|
||||||
|
<ChevronSvg
|
||||||
|
width={6.25}
|
||||||
|
height={12.5}
|
||||||
|
stroke='#42567A'
|
||||||
|
className={styles.rotated}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EventsCarousel events={currentPeriod.events} visible />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
TimeFrameSlider.displayName = 'TimeFrameSlider'
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Константы для компонента TimeFrameSlider
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Угол позиции активного элемента (верхний правый угол)
|
||||||
|
*/
|
||||||
|
export const ACTIVE_POSITION_ANGLE = -60
|
||||||
1
src/widgets/TimeFrameSlider/ui/TimeFrameSlider/index.ts
Normal file
1
src/widgets/TimeFrameSlider/ui/TimeFrameSlider/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { TimeFrameSlider } from './TimeFrameSlider'
|
||||||
@@ -26,7 +26,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"src"
|
"src",
|
||||||
|
"src/**/*",
|
||||||
],
|
],
|
||||||
"exclude": [
|
"exclude": [
|
||||||
".fttemplates/**/*",
|
".fttemplates/**/*",
|
||||||
|
|||||||
Reference in New Issue
Block a user