2025-11-19 22:34:27 +03:00
|
|
|
|
/**
|
|
|
|
|
|
* CircleTimeline Component
|
|
|
|
|
|
* Круговая временная шкала с периодами
|
|
|
|
|
|
*
|
|
|
|
|
|
* @module CircleTimeline
|
|
|
|
|
|
* @description Компонент отображает временные периоды на круговой диаграмме.
|
|
|
|
|
|
* Активный период автоматически поворачивается в заданную позицию с помощью GSAP анимации.
|
|
|
|
|
|
* Поддерживает клик по точкам для переключения периодов.
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
2025-11-23 15:39:04 +03:00
|
|
|
|
import { useGSAP } from '@gsap/react'
|
2025-11-20 16:07:18 +03:00
|
|
|
|
import classNames from 'classnames'
|
2025-11-23 14:13:52 +03:00
|
|
|
|
import { gsap } from 'gsap'
|
2025-11-23 15:39:04 +03:00
|
|
|
|
import { memo, useCallback, useMemo, useRef } from 'react'
|
2025-11-19 22:34:27 +03:00
|
|
|
|
|
|
|
|
|
|
import styles from './CircleTimeline.module.scss'
|
2025-11-20 16:07:18 +03:00
|
|
|
|
import { calculateCoordinates } from '../../lib/utils/calculateCoordinates/calculateCoordinates'
|
|
|
|
|
|
import { ANIMATION_DURATION, ANIMATION_EASE } from '../../model'
|
2025-11-19 22:34:27 +03:00
|
|
|
|
|
|
|
|
|
|
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)[]>([])
|
|
|
|
|
|
|
2025-11-23 13:14:20 +03:00
|
|
|
|
// Реф для заголовков периодов
|
|
|
|
|
|
const titlesRef = useRef<(HTMLSpanElement | null)[]>([])
|
|
|
|
|
|
|
2025-11-19 22:34:27 +03:00
|
|
|
|
/**
|
2025-11-23 15:39:04 +03:00
|
|
|
|
* Анимация поворота круга и контр-поворота точек
|
|
|
|
|
|
* Использует useGSAP hook для автоматической очистки анимаций
|
2025-11-19 22:34:27 +03:00
|
|
|
|
*/
|
2025-11-23 15:39:04 +03:00
|
|
|
|
useGSAP(
|
|
|
|
|
|
() => {
|
|
|
|
|
|
// Анимация поворота контейнера круга
|
|
|
|
|
|
if (circleRef.current) {
|
|
|
|
|
|
gsap.to(circleRef.current, {
|
|
|
|
|
|
rotation,
|
|
|
|
|
|
duration: ANIMATION_DURATION,
|
2025-11-19 22:34:27 +03:00
|
|
|
|
ease: ANIMATION_EASE,
|
|
|
|
|
|
})
|
2025-11-23 15:39:04 +03:00
|
|
|
|
}
|
2025-11-23 13:14:20 +03:00
|
|
|
|
|
2025-11-23 15:39:04 +03:00
|
|
|
|
// Контр-поворот точек, чтобы текст оставался читаемым
|
|
|
|
|
|
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,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
2025-11-23 13:14:20 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-11-23 15:39:04 +03:00
|
|
|
|
})
|
|
|
|
|
|
},
|
|
|
|
|
|
{ dependencies: [rotation, activeIndex] }
|
|
|
|
|
|
)
|
2025-11-19 22:34:27 +03:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Мемоизированный расчет позиций точек на круге
|
|
|
|
|
|
* Пересчитывается только при изменении количества периодов
|
|
|
|
|
|
*/
|
|
|
|
|
|
const pointPositions = useMemo(() => {
|
2025-11-20 16:07:18 +03:00
|
|
|
|
return periods.map((_, index, array) =>
|
|
|
|
|
|
calculateCoordinates(array.length, index)
|
|
|
|
|
|
)
|
2025-11-19 22:34:27 +03:00
|
|
|
|
}, [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
|
|
|
|
|
|
}}
|
2025-11-20 16:07:18 +03:00
|
|
|
|
className={classNames(styles.point, {
|
|
|
|
|
|
[styles.active]: index === activeIndex,
|
|
|
|
|
|
})}
|
2025-11-19 22:34:27 +03:00
|
|
|
|
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'}
|
|
|
|
|
|
>
|
2025-11-23 13:14:20 +03:00
|
|
|
|
<span className={styles.number}>{index + 1}</span>
|
|
|
|
|
|
<span
|
|
|
|
|
|
className={styles.title}
|
|
|
|
|
|
ref={(el) => {
|
|
|
|
|
|
titlesRef.current[index] = el
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{period.label}
|
|
|
|
|
|
</span>
|
2025-11-19 22:34:27 +03:00
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
})
|