feat: Добавлен компонент TimeFrameSlider - главный компонент объединяющий круговую диаграму и карусель событий
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
import './styles/index.scss'
|
||||
|
||||
import { TimeFrameSlider } from '@/widgets/TimeFrameSlider'
|
||||
|
||||
const App = () => {
|
||||
return <div>Test</div>
|
||||
return <TimeFrameSlider />
|
||||
}
|
||||
|
||||
export default App
|
||||
|
||||
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,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'
|
||||
Reference in New Issue
Block a user