feat: Добавлен компонент CircleTimeline для отображения категорий для временных промежутков

This commit is contained in:
Ilia Mashkov
2025-11-19 22:34:27 +03:00
parent 0006a20a61
commit e440005e60
7 changed files with 396 additions and 1 deletions

View File

@@ -7,6 +7,7 @@ import type { TimePeriod } from './types'
export const HISTORICAL_PERIODS: readonly TimePeriod[] = [
{
id: crypto.randomUUID(),
yearFrom: 1980,
yearTo: 1986,
label: 'Science',
@@ -28,6 +29,7 @@ export const HISTORICAL_PERIODS: readonly TimePeriod[] = [
],
},
{
id: crypto.randomUUID(),
yearFrom: 1987,
yearTo: 1991,
label: 'Cinema',
@@ -41,6 +43,7 @@ export const HISTORICAL_PERIODS: readonly TimePeriod[] = [
],
},
{
id: crypto.randomUUID(),
yearFrom: 1992,
yearTo: 1997,
label: 'Tech',
@@ -51,6 +54,7 @@ export const HISTORICAL_PERIODS: readonly TimePeriod[] = [
],
},
{
id: crypto.randomUUID(),
yearFrom: 1999,
yearTo: 2004,
label: 'Music',
@@ -60,6 +64,7 @@ export const HISTORICAL_PERIODS: readonly TimePeriod[] = [
],
},
{
id: crypto.randomUUID(),
yearFrom: 2005,
yearTo: 2014,
label: 'World',
@@ -69,6 +74,7 @@ export const HISTORICAL_PERIODS: readonly TimePeriod[] = [
],
},
{
id: crypto.randomUUID(),
yearFrom: 2015,
yearTo: 2022,
label: 'Pandemic',

View File

@@ -15,6 +15,10 @@ export interface HistoricalEvent {
}
export interface TimePeriod {
/**
* Уникальный идентификатор
*/
readonly id: string
/**
* Год начала периода
*/
@@ -28,7 +32,7 @@ export interface TimePeriod {
*/
readonly label: string
/**
* События, связанные с этим периодом
* События, связанные с этим периодом и категорией
*/
readonly events: readonly HistoricalEvent[]
}

View 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

View File

@@ -0,0 +1,8 @@
export {
FULL_CIRCLE_DEGREES,
HALF_CIRCLE_DEGREES,
CIRCLE_RADIUS,
ANIMATION_DURATION,
ANIMATION_EASE,
ACTIVE_POSITION_DEGREES,
} from './constants'

View File

@@ -0,0 +1,62 @@
.circleContainer {
$border-color: var(--color-primary);
position: absolute;
top: 50%;
left: 50%;
width: calc(var(--circle-radius, 265px) * 2);
height: calc(var(--circle-radius, 265px) * 2);
border: 1px solid rgb($border-color / 20%);
border-radius: 50%;
transform: translate(-50%, -50%);
}
.point {
position: absolute;
width: 6px;
height: 6px;
margin-top: -3px;
margin-left: -3px;
cursor: pointer;
background: var(--color-text);
border-radius: 50%;
transition: all 0.3s ease;
transform-origin: center;
&:hover,
&.active {
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
margin-top: -28px;
margin-left: -28px;
border: 1px solid rgb(48 62 88 / 50%);
background: #F4F5F9;
}
.label {
display: none;
font-size: 20px;
color: var(--color-text);
}
&:hover .label,
&.active .label {
display: block;
}
}

View File

@@ -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>
),
],
}

View File

@@ -0,0 +1,159 @@
/**
* CircleTimeline Component
* Круговая временная шкала с периодами
*
* @module CircleTimeline
* @description Компонент отображает временные периоды на круговой диаграмме.
* Активный период автоматически поворачивается в заданную позицию с помощью GSAP анимации.
* Поддерживает клик по точкам для переключения периодов.
*/
import gsap from 'gsap'
import { memo, useCallback, useEffect, useMemo, useRef } from 'react'
import styles from './CircleTimeline.module.scss'
import {
ANIMATION_DURATION,
ANIMATION_EASE,
CIRCLE_RADIUS,
FULL_CIRCLE_DEGREES,
HALF_CIRCLE_DEGREES,
} 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: ANIMATION_DURATION,
ease: ANIMATION_EASE,
})
}
})
}, [rotation])
/**
* Мемоизированный расчет позиций точек на круге
* Пересчитывается только при изменении количества периодов
*/
const pointPositions = useMemo(() => {
return periods.map((_, index) => {
// Угол для текущей точки (в градусах)
const angle = (FULL_CIRCLE_DEGREES / periods.length) * index
// Конвертация в радианы для тригонометрических функций
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 }
})
}, [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={`${styles.point} ${index === activeIndex ? styles.active : ''}`}
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>
)
})