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() {
|
||||
const svgrLoader = {
|
||||
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
|
||||
|
||||
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",
|
||||
"lint": "eslint src --ext .ts,.tsx",
|
||||
"lint:styles": "stylelint 'src/**/*.{css,scss}' --allow-empty-input",
|
||||
"test:unit": "jest --config ./config/jest/jest.config.ts",
|
||||
"type-check": "tsc --noEmit",
|
||||
"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": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"classnames": "^2.5.1",
|
||||
"gsap": "^3.13.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
"react-dom": "^19.0.0",
|
||||
"swiper": "^12.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.26.0",
|
||||
"husky": "^9.1.0",
|
||||
"@babel/preset-env": "^7.26.0",
|
||||
"@babel/preset-react": "^7.26.0",
|
||||
"@babel/preset-typescript": "^7.26.0",
|
||||
"@eslint/js": "^9.15.0",
|
||||
"@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",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
@@ -46,16 +61,19 @@
|
||||
"eslint-plugin-prettier": "^5.2.0",
|
||||
"eslint-plugin-react": "^7.37.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",
|
||||
"globals": "^15.12.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",
|
||||
"prettier": "^3.4.0",
|
||||
"react-refresh": "^0.14.2",
|
||||
"regenerator-runtime": "^0.14.1",
|
||||
"sass": "^1.81.0",
|
||||
"sass-loader": "^16.0.0",
|
||||
"storybook": "^8.6.14",
|
||||
"style-loader": "^4.0.0",
|
||||
"stylelint": "^16.11.0",
|
||||
"stylelint-config-standard-scss": "^13.1.0",
|
||||
@@ -64,6 +82,7 @@
|
||||
"ts-loader": "^9.5.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.7.0",
|
||||
"typescript-eslint": "^8.16.0",
|
||||
"webpack": "^5.96.0",
|
||||
"webpack-bundle-analyzer": "^4.10.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 App from './App'
|
||||
import App from './app/App'
|
||||
|
||||
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": [
|
||||
"src"
|
||||
"src",
|
||||
"src/**/*",
|
||||
],
|
||||
"exclude": [
|
||||
".fttemplates/**/*",
|
||||
|
||||
Reference in New Issue
Block a user