feat: Добавлен компонент CircleTimeline для отображения категорий для временных промежутков
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
35
src/widgets/TimeFrameSlider/model/constants.ts
Normal file
35
src/widgets/TimeFrameSlider/model/constants.ts
Normal 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
|
||||
8
src/widgets/TimeFrameSlider/model/index.ts
Normal file
8
src/widgets/TimeFrameSlider/model/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
FULL_CIRCLE_DEGREES,
|
||||
HALF_CIRCLE_DEGREES,
|
||||
CIRCLE_RADIUS,
|
||||
ANIMATION_DURATION,
|
||||
ANIMATION_EASE,
|
||||
ACTIVE_POSITION_DEGREES,
|
||||
} from './constants'
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
],
|
||||
}
|
||||
159
src/widgets/TimeFrameSlider/ui/CircleTimeline/CircleTimeline.tsx
Normal file
159
src/widgets/TimeFrameSlider/ui/CircleTimeline/CircleTimeline.tsx
Normal 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>
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user