feat: Добавлен компонент CircleTimeline для отображения категорий для временных промежутков
This commit is contained in:
@@ -7,6 +7,7 @@ import type { TimePeriod } from './types'
|
|||||||
|
|
||||||
export const HISTORICAL_PERIODS: readonly TimePeriod[] = [
|
export const HISTORICAL_PERIODS: readonly TimePeriod[] = [
|
||||||
{
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
yearFrom: 1980,
|
yearFrom: 1980,
|
||||||
yearTo: 1986,
|
yearTo: 1986,
|
||||||
label: 'Science',
|
label: 'Science',
|
||||||
@@ -28,6 +29,7 @@ export const HISTORICAL_PERIODS: readonly TimePeriod[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
yearFrom: 1987,
|
yearFrom: 1987,
|
||||||
yearTo: 1991,
|
yearTo: 1991,
|
||||||
label: 'Cinema',
|
label: 'Cinema',
|
||||||
@@ -41,6 +43,7 @@ export const HISTORICAL_PERIODS: readonly TimePeriod[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
yearFrom: 1992,
|
yearFrom: 1992,
|
||||||
yearTo: 1997,
|
yearTo: 1997,
|
||||||
label: 'Tech',
|
label: 'Tech',
|
||||||
@@ -51,6 +54,7 @@ export const HISTORICAL_PERIODS: readonly TimePeriod[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
yearFrom: 1999,
|
yearFrom: 1999,
|
||||||
yearTo: 2004,
|
yearTo: 2004,
|
||||||
label: 'Music',
|
label: 'Music',
|
||||||
@@ -60,6 +64,7 @@ export const HISTORICAL_PERIODS: readonly TimePeriod[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
yearFrom: 2005,
|
yearFrom: 2005,
|
||||||
yearTo: 2014,
|
yearTo: 2014,
|
||||||
label: 'World',
|
label: 'World',
|
||||||
@@ -69,6 +74,7 @@ export const HISTORICAL_PERIODS: readonly TimePeriod[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
yearFrom: 2015,
|
yearFrom: 2015,
|
||||||
yearTo: 2022,
|
yearTo: 2022,
|
||||||
label: 'Pandemic',
|
label: 'Pandemic',
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ export interface HistoricalEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface TimePeriod {
|
export interface TimePeriod {
|
||||||
|
/**
|
||||||
|
* Уникальный идентификатор
|
||||||
|
*/
|
||||||
|
readonly id: string
|
||||||
/**
|
/**
|
||||||
* Год начала периода
|
* Год начала периода
|
||||||
*/
|
*/
|
||||||
@@ -28,7 +32,7 @@ export interface TimePeriod {
|
|||||||
*/
|
*/
|
||||||
readonly label: string
|
readonly label: string
|
||||||
/**
|
/**
|
||||||
* События, связанные с этим периодом
|
* События, связанные с этим периодом и категорией
|
||||||
*/
|
*/
|
||||||
readonly events: readonly HistoricalEvent[]
|
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