Files
gsap-carousel/src/widgets/TimeFrameSlider/ui/CircleTimeline/CircleTimeline.tsx

185 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* CircleTimeline Component
* Круговая временная шкала с периодами
*
* @module CircleTimeline
* @description Компонент отображает временные периоды на круговой диаграмме.
* Активный период автоматически поворачивается в заданную позицию с помощью GSAP анимации.
* Поддерживает клик по точкам для переключения периодов.
*/
import { useGSAP } from '@gsap/react'
import classNames from 'classnames'
import { gsap } from 'gsap'
import { memo, useCallback, 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)[]>([])
// Реф для заголовков периодов
const titlesRef = useRef<(HTMLSpanElement | null)[]>([])
/**
* Анимация поворота круга и контр-поворота точек
* Использует useGSAP hook для автоматической очистки анимаций
*/
useGSAP(
() => {
// Анимация поворота контейнера круга
if (circleRef.current) {
gsap.to(circleRef.current, {
rotation,
duration: ANIMATION_DURATION,
ease: ANIMATION_EASE,
})
}
// Контр-поворот точек, чтобы текст оставался читаемым
pointsRef.current.forEach((point, index) => {
if (point) {
gsap.to(point, {
rotation: -rotation,
duration: 0,
ease: ANIMATION_EASE,
})
// Анимация заголовка
const title = titlesRef.current[index]
if (title) {
// Останавливаем предыдущие анимации для предотвращения конфликтов
gsap.killTweensOf(title)
if (index === activeIndex) {
gsap.to(title, {
opacity: 1,
visibility: 'visible',
duration: 0.5,
delay: ANIMATION_DURATION, // Ждем окончания вращения
})
} else {
gsap.to(title, {
opacity: 0,
visibility: 'hidden',
duration: 0.2,
})
}
}
}
})
},
{ dependencies: [rotation, activeIndex] }
)
/**
* Мемоизированный расчет позиций точек на круге
* Пересчитывается только при изменении количества периодов
*/
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.number}>{index + 1}</span>
<span
className={styles.title}
ref={(el) => {
titlesRef.current[index] = el
}}
>
{period.label}
</span>
</div>
)
})}
</div>
)
})