From d596576356fd59853e29bcf83fea021894cf1324 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Thu, 20 Nov 2025 16:11:26 +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=20TimeFrameSlider=20-=20=D0=B3=D0=BB=D0=B0=D0=B2=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=BA=D0=BE=D0=BC=D0=BF=D0=BE=D0=BD=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=20=D0=BE=D0=B1=D1=8A=D0=B5=D0=B4=D0=B8=D0=BD=D1=8F=D1=8E?= =?UTF-8?q?=D1=89=D0=B8=D0=B9=20=D0=BA=D1=80=D1=83=D0=B3=D0=BE=D0=B2=D1=83?= =?UTF-8?q?=D1=8E=20=D0=B4=D0=B8=D0=B0=D0=B3=D1=80=D0=B0=D0=BC=D1=83=20?= =?UTF-8?q?=D0=B8=20=D0=BA=D0=B0=D1=80=D1=83=D1=81=D0=B5=D0=BB=D1=8C=20?= =?UTF-8?q?=D1=81=D0=BE=D0=B1=D1=8B=D1=82=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/App.tsx | 4 +- src/widgets/TimeFrameSlider/index.ts | 6 + .../TimeFrameSlider.module.scss | 162 +++++++++++++++++ .../ui/TimeFrameSlider/TimeFrameSlider.tsx | 165 ++++++++++++++++++ .../ui/TimeFrameSlider/constants.ts | 8 + .../ui/TimeFrameSlider/index.ts | 1 + 6 files changed, 345 insertions(+), 1 deletion(-) create mode 100644 src/widgets/TimeFrameSlider/index.ts create mode 100644 src/widgets/TimeFrameSlider/ui/TimeFrameSlider/TimeFrameSlider.module.scss create mode 100644 src/widgets/TimeFrameSlider/ui/TimeFrameSlider/TimeFrameSlider.tsx create mode 100644 src/widgets/TimeFrameSlider/ui/TimeFrameSlider/constants.ts create mode 100644 src/widgets/TimeFrameSlider/ui/TimeFrameSlider/index.ts diff --git a/src/app/App.tsx b/src/app/App.tsx index 6f11351..772ee46 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,7 +1,9 @@ import './styles/index.scss' +import { TimeFrameSlider } from '@/widgets/TimeFrameSlider' + const App = () => { - return
Test
+ return } export default App diff --git a/src/widgets/TimeFrameSlider/index.ts b/src/widgets/TimeFrameSlider/index.ts new file mode 100644 index 0000000..1484132 --- /dev/null +++ b/src/widgets/TimeFrameSlider/index.ts @@ -0,0 +1,6 @@ +/** + * Widget: TimeFrameSlider + * Public API + */ + +export { TimeFrameSlider } from './ui/TimeFrameSlider' diff --git a/src/widgets/TimeFrameSlider/ui/TimeFrameSlider/TimeFrameSlider.module.scss b/src/widgets/TimeFrameSlider/ui/TimeFrameSlider/TimeFrameSlider.module.scss new file mode 100644 index 0000000..b8c5bdb --- /dev/null +++ b/src/widgets/TimeFrameSlider/ui/TimeFrameSlider/TimeFrameSlider.module.scss @@ -0,0 +1,162 @@ +.container { + position: relative; + + display: flex; + flex-direction: column; + justify-content: center; + + width: 100%; + max-width: 1440px; + min-height: 100vh; + margin: 0 auto; + padding: 0 20px; + + color: var(--color-text); + font-family: var(--font-family-main); + + border-right: 1px solid var(--color-border); + border-left: 1px solid var(--color-border); + + overflow: hidden; + + @media (width <=768px) { + min-height: auto; + padding: 20px; + } +} + +.title { + position: relative; + z-index: 2; + + margin-bottom: 40px; + padding-left: 60px; + + font-weight: 700; + font-size: 56px; + line-height: 120%; + + @media (width <=768px) { + margin-bottom: 20px; + padding-left: 0; + + font-size: 20px; + } +} + +.content { + position: relative; + + display: grid; + grid-template-columns: 1fr; + + height: 600px; + + @media (width <=768px) { + display: flex; + flex-direction: column; + + height: auto; + } +} + +.controls { + position: absolute; + left: 60px; + bottom: 50px; + z-index: 10; + + display: flex; + flex-direction: column; + gap: 20px; + + @media (width <=768px) { + position: static; + + order: 2; + + margin-top: 20px; + padding: 0; + } +} + +.pagination { + margin-bottom: 10px; + + font-size: 14px; +} + +.buttons { + display: flex; + gap: 20px; +} + +.rotated { + transform: rotate(180deg); +} + +.centerDate { + position: absolute; + top: 50%; + left: 50%; + z-index: 0; + + display: flex; + gap: 40px; + + color: var(--color-text); + font-weight: 700; + font-size: 200px; + line-height: 160px; + + transform: translate(-50%, -50%); + + pointer-events: none; + + @media (width <=768px) { + position: static; + + gap: 20px; + justify-content: center; + + margin-bottom: 40px; + + font-size: 56px; + + transform: none; + } + + span:first-child { + color: #5d5fef; + } + + span:last-child { + color: #ef5da8; + } +} + +.periodLabel { + display: none; + + @media (width <=768px) { + order: 1; + + display: block; + + margin-bottom: 40px; + padding-top: 40px; + + color: var(--color-text); + font-weight: 700; + font-size: 20px; + text-align: center; + + border-top: 1px solid var(--color-border); + } +} + +.circleContainer { + @media (width <=768px) { + display: none; + } +} \ No newline at end of file diff --git a/src/widgets/TimeFrameSlider/ui/TimeFrameSlider/TimeFrameSlider.tsx b/src/widgets/TimeFrameSlider/ui/TimeFrameSlider/TimeFrameSlider.tsx new file mode 100644 index 0000000..9ff0d38 --- /dev/null +++ b/src/widgets/TimeFrameSlider/ui/TimeFrameSlider/TimeFrameSlider.tsx @@ -0,0 +1,165 @@ +/** + * TimeFrameSlider Component + * Главный компонент временной шкалы с круговой диаграммой и каруселью событий + */ + +import gsap from 'gsap' +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' + +import { HISTORICAL_PERIODS } from '@/entities/TimePeriod' +import ChevronSvg from '@/shared/assets/chevron--left.svg' +import { Button } from '@/shared/ui/Button' + +import { ACTIVE_POSITION_ANGLE } from './constants' +import styles from './TimeFrameSlider.module.scss' +import { CircleTimeline } from '../CircleTimeline/CircleTimeline' +import { EventsCarousel } from '../EventsCarousel/EventsCarousel' + +/** + * Компонент временной шкалы с интерактивной круговой диаграммой + * + * Отображает исторические периоды на круговой диаграмме с возможностью + * переключения между ними. Для каждого периода показывается карусель событий. + * Центральные даты анимируются при смене периода с помощью GSAP. + * + * @example + * ```tsx + * + * ``` + */ +export const TimeFrameSlider = memo(() => { + const [activePeriod, setActivePeriod] = useState(0) + const [rotation, setRotation] = useState(0) + const prevRotation = useRef(0) + const startYearRef = useRef(null) + const endYearRef = useRef(null) + const containerRef = useRef(null) + + // Мемоизированные константы + const totalPeriods = useMemo(() => HISTORICAL_PERIODS.length, []) + const anglePerPoint = useMemo(() => 360 / totalPeriods, [totalPeriods]) + + // Текущий период + const currentPeriod = useMemo( + () => HISTORICAL_PERIODS[activePeriod], + [activePeriod] + ) + + /** + * Расчет поворота при изменении активного периода + * Использует кратчайший путь для анимации + */ + useEffect(() => { + const targetRotation = ACTIVE_POSITION_ANGLE - activePeriod * anglePerPoint + const current = prevRotation.current + const adjustedTarget = + targetRotation - 360 * Math.round((targetRotation - current) / 360) + + setRotation(adjustedTarget) + prevRotation.current = adjustedTarget + }, [activePeriod, anglePerPoint]) + + /** + * Анимация центральных дат с использованием GSAP + * Плавно изменяет числа при смене периода + */ + useEffect(() => { + if (!containerRef.current) return + + const ctx = gsap.context(() => { + if (startYearRef.current) { + gsap.to(startYearRef.current, { + innerText: currentPeriod.yearFrom, + snap: { innerText: 1 }, + duration: 1, + ease: 'power2.inOut', + }) + } + + if (endYearRef.current) { + gsap.to(endYearRef.current, { + innerText: currentPeriod.yearTo, + snap: { innerText: 1 }, + duration: 1, + ease: 'power2.inOut', + }) + } + }, containerRef) + + return () => ctx.revert() + }, [currentPeriod.yearFrom, currentPeriod.yearTo]) + + /** + * Переключение на предыдущий период + * Использует циклическую навигацию + */ + const handlePrev = useCallback(() => { + setActivePeriod((prev) => (prev - 1 + totalPeriods) % totalPeriods) + }, [totalPeriods]) + + /** + * Переключение на следующий период + * Использует циклическую навигацию + */ + const handleNext = useCallback(() => { + setActivePeriod((prev) => (prev + 1) % totalPeriods) + }, [totalPeriods]) + + return ( +
+

Исторические даты

+ +
+
+ {currentPeriod.yearFrom} + {currentPeriod.yearTo} +
+ +
{currentPeriod.label}
+ + + +
+
+ {String(activePeriod + 1).padStart(2, '0')}/ + {String(totalPeriods).padStart(2, '0')} +
+
+ + +
+
+
+ + +
+ ) +}) + +TimeFrameSlider.displayName = 'TimeFrameSlider' diff --git a/src/widgets/TimeFrameSlider/ui/TimeFrameSlider/constants.ts b/src/widgets/TimeFrameSlider/ui/TimeFrameSlider/constants.ts new file mode 100644 index 0000000..737ede5 --- /dev/null +++ b/src/widgets/TimeFrameSlider/ui/TimeFrameSlider/constants.ts @@ -0,0 +1,8 @@ +/** + * Константы для компонента TimeFrameSlider + */ + +/** + * Угол позиции активного элемента (верхний правый угол) + */ +export const ACTIVE_POSITION_ANGLE = -60 diff --git a/src/widgets/TimeFrameSlider/ui/TimeFrameSlider/index.ts b/src/widgets/TimeFrameSlider/ui/TimeFrameSlider/index.ts new file mode 100644 index 0000000..43600a7 --- /dev/null +++ b/src/widgets/TimeFrameSlider/ui/TimeFrameSlider/index.ts @@ -0,0 +1 @@ +export { TimeFrameSlider } from './TimeFrameSlider'