From e440005e602a83723620a61b9cc65c4f8db0ff64 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Wed, 19 Nov 2025 22:34:27 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=20=D0=BA=D0=BE=D0=BC=D0=BF=D0=BE=D0=BD=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=20CircleTimeline=20=D0=B4=D0=BB=D1=8F=20=D0=BE=D1=82?= =?UTF-8?q?=D0=BE=D0=B1=D1=80=D0=B0=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BA?= =?UTF-8?q?=D0=B0=D1=82=D0=B5=D0=B3=D0=BE=D1=80=D0=B8=D0=B9=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20=D0=B2=D1=80=D0=B5=D0=BC=D0=B5=D0=BD=D0=BD=D1=8B=D1=85?= =?UTF-8?q?=20=D0=BF=D1=80=D0=BE=D0=BC=D0=B5=D0=B6=D1=83=D1=82=D0=BA=D0=BE?= =?UTF-8?q?=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/entities/TimePeriod/model/mockData.ts | 6 + src/entities/TimePeriod/model/types.ts | 6 +- .../TimeFrameSlider/model/constants.ts | 35 ++++ src/widgets/TimeFrameSlider/model/index.ts | 8 + .../CircleTimeline/CircleTimeline.module.scss | 62 +++++++ .../CircleTimeline/CircleTimeline.stories.tsx | 121 +++++++++++++ .../ui/CircleTimeline/CircleTimeline.tsx | 159 ++++++++++++++++++ 7 files changed, 396 insertions(+), 1 deletion(-) create mode 100644 src/widgets/TimeFrameSlider/model/constants.ts create mode 100644 src/widgets/TimeFrameSlider/model/index.ts create mode 100644 src/widgets/TimeFrameSlider/ui/CircleTimeline/CircleTimeline.module.scss create mode 100644 src/widgets/TimeFrameSlider/ui/CircleTimeline/CircleTimeline.stories.tsx create mode 100644 src/widgets/TimeFrameSlider/ui/CircleTimeline/CircleTimeline.tsx diff --git a/src/entities/TimePeriod/model/mockData.ts b/src/entities/TimePeriod/model/mockData.ts index 1cafccf..53652a0 100644 --- a/src/entities/TimePeriod/model/mockData.ts +++ b/src/entities/TimePeriod/model/mockData.ts @@ -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', diff --git a/src/entities/TimePeriod/model/types.ts b/src/entities/TimePeriod/model/types.ts index 963f12a..d6a61d4 100644 --- a/src/entities/TimePeriod/model/types.ts +++ b/src/entities/TimePeriod/model/types.ts @@ -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[] } diff --git a/src/widgets/TimeFrameSlider/model/constants.ts b/src/widgets/TimeFrameSlider/model/constants.ts new file mode 100644 index 0000000..ee8635b --- /dev/null +++ b/src/widgets/TimeFrameSlider/model/constants.ts @@ -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 diff --git a/src/widgets/TimeFrameSlider/model/index.ts b/src/widgets/TimeFrameSlider/model/index.ts new file mode 100644 index 0000000..c790ac6 --- /dev/null +++ b/src/widgets/TimeFrameSlider/model/index.ts @@ -0,0 +1,8 @@ +export { + FULL_CIRCLE_DEGREES, + HALF_CIRCLE_DEGREES, + CIRCLE_RADIUS, + ANIMATION_DURATION, + ANIMATION_EASE, + ACTIVE_POSITION_DEGREES, +} from './constants' diff --git a/src/widgets/TimeFrameSlider/ui/CircleTimeline/CircleTimeline.module.scss b/src/widgets/TimeFrameSlider/ui/CircleTimeline/CircleTimeline.module.scss new file mode 100644 index 0000000..a5e26ad --- /dev/null +++ b/src/widgets/TimeFrameSlider/ui/CircleTimeline/CircleTimeline.module.scss @@ -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; + } +} \ No newline at end of file diff --git a/src/widgets/TimeFrameSlider/ui/CircleTimeline/CircleTimeline.stories.tsx b/src/widgets/TimeFrameSlider/ui/CircleTimeline/CircleTimeline.stories.tsx new file mode 100644 index 0000000..94b4205 --- /dev/null +++ b/src/widgets/TimeFrameSlider/ui/CircleTimeline/CircleTimeline.stories.tsx @@ -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 + +export default meta +type Story = StoryObj +type CustomStory = Partial & Pick + +/** + * Интерактивный компонент-обертка для управления состоянием + */ +function CircleTimelineWithState() { + const [activeIndex, setActiveIndex] = useState(0) + + // Расчет угла поворота на основе активного индекса + // Каждый период занимает 360/6 = 60 градусов + // Активная позиция находится на -60 градусах (верхний правый угол) + const rotation = + ACTIVE_POSITION_DEGREES - + (FULL_CIRCLE_DEGREES / HISTORICAL_PERIODS.length) * activeIndex + + return ( +
+ +
+ ) +} + +/** + * Интерактивный вариант со всеми 6 периодами + */ +export const Default: CustomStory = { + render: () => , + parameters: { + docs: { + description: { + story: + 'Интерактивная демонстрация с возможностью переключения между периодами', + }, + }, + }, +} + +/** + * Первый период (Science) активен + */ +export const FirstPeriod: Story = { + args: { + periods: HISTORICAL_PERIODS, + activeIndex: 0, + onPeriodChange: () => {}, + rotation: ACTIVE_POSITION_DEGREES, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} + +/** + * Третий период (Tech) активен + */ +export const ThirdPeriod: Story = { + args: { + periods: HISTORICAL_PERIODS, + activeIndex: 2, + onPeriodChange: () => {}, + rotation: ACTIVE_POSITION_DEGREES - (FULL_CIRCLE_DEGREES / 6) * 2, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} + +/** + * Вариант с 3 периодами для демонстрации гибкости + */ +export const FewPeriods: Story = { + args: { + periods: HISTORICAL_PERIODS.slice(0, 3), + activeIndex: 0, + onPeriodChange: () => {}, + rotation: -60, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} diff --git a/src/widgets/TimeFrameSlider/ui/CircleTimeline/CircleTimeline.tsx b/src/widgets/TimeFrameSlider/ui/CircleTimeline/CircleTimeline.tsx new file mode 100644 index 0000000..6b908a0 --- /dev/null +++ b/src/widgets/TimeFrameSlider/ui/CircleTimeline/CircleTimeline.tsx @@ -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 + * setActiveIndex(index)} + * rotation={-60} + * /> + * ``` + */ +export const CircleTimeline = memo(function CircleTimeline({ + periods, + activeIndex, + onPeriodChange, + rotation, +}: CircleTimelineProps) { + // Реф для контейнера круга + const circleRef = useRef(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 ( +
+ {periods.map((period, index) => { + const { x, y } = pointPositions[index] + + return ( +
{ + 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'} + > + {index + 1} +
+ ) + })} +
+ ) +})