feat: Добавлен компонент TimeFrameSlider - главный компонент объединяющий круговую диаграму и карусель событий

This commit is contained in:
Ilia Mashkov
2025-11-20 16:11:26 +03:00
parent c970d9c6d0
commit d596576356
6 changed files with 345 additions and 1 deletions

View File

@@ -1,7 +1,9 @@
import './styles/index.scss'
import { TimeFrameSlider } from '@/widgets/TimeFrameSlider'
const App = () => {
return <div>Test</div>
return <TimeFrameSlider />
}
export default App

View File

@@ -0,0 +1,6 @@
/**
* Widget: TimeFrameSlider
* Public API
*/
export { TimeFrameSlider } from './ui/TimeFrameSlider'

View File

@@ -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;
}
}

View File

@@ -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'

View File

@@ -0,0 +1,8 @@
/**
* Константы для компонента TimeFrameSlider
*/
/**
* Угол позиции активного элемента (верхний правый угол)
*/
export const ACTIVE_POSITION_ANGLE = -60

View File

@@ -0,0 +1 @@
export { TimeFrameSlider } from './TimeFrameSlider'