feat: Добавлен компонент TimeFrameSlider - главный компонент объединяющий круговую диаграму и карусель событий
This commit is contained in:
@@ -1,7 +1,9 @@
|
|||||||
import './styles/index.scss'
|
import './styles/index.scss'
|
||||||
|
|
||||||
|
import { TimeFrameSlider } from '@/widgets/TimeFrameSlider'
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
return <div>Test</div>
|
return <TimeFrameSlider />
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App
|
export default App
|
||||||
|
|||||||
6
src/widgets/TimeFrameSlider/index.ts
Normal file
6
src/widgets/TimeFrameSlider/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* Widget: TimeFrameSlider
|
||||||
|
* Public API
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { TimeFrameSlider } from './ui/TimeFrameSlider'
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
* <TimeFrameSlider />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const TimeFrameSlider = memo(() => {
|
||||||
|
const [activePeriod, setActivePeriod] = useState(0)
|
||||||
|
const [rotation, setRotation] = useState(0)
|
||||||
|
const prevRotation = useRef(0)
|
||||||
|
const startYearRef = useRef<HTMLSpanElement>(null)
|
||||||
|
const endYearRef = useRef<HTMLSpanElement>(null)
|
||||||
|
const containerRef = useRef<HTMLDivElement>(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 (
|
||||||
|
<div className={styles.container} ref={containerRef}>
|
||||||
|
<h1 className={styles.title}>Исторические даты</h1>
|
||||||
|
|
||||||
|
<div className={styles.content}>
|
||||||
|
<div className={styles.centerDate}>
|
||||||
|
<span ref={startYearRef}>{currentPeriod.yearFrom}</span>
|
||||||
|
<span ref={endYearRef}>{currentPeriod.yearTo}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.periodLabel}>{currentPeriod.label}</div>
|
||||||
|
|
||||||
|
<CircleTimeline
|
||||||
|
periods={HISTORICAL_PERIODS}
|
||||||
|
activeIndex={activePeriod}
|
||||||
|
onPeriodChange={setActivePeriod}
|
||||||
|
rotation={rotation}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={styles.controls}>
|
||||||
|
<div className={styles.pagination}>
|
||||||
|
{String(activePeriod + 1).padStart(2, '0')}/
|
||||||
|
{String(totalPeriods).padStart(2, '0')}
|
||||||
|
</div>
|
||||||
|
<div className={styles.buttons}>
|
||||||
|
<Button
|
||||||
|
variant='round'
|
||||||
|
size='medium'
|
||||||
|
colorScheme='primary'
|
||||||
|
onClick={handlePrev}
|
||||||
|
aria-label='Предыдущий период'
|
||||||
|
>
|
||||||
|
<ChevronSvg width={6.25} height={12.5} stroke='#42567A' />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='round'
|
||||||
|
size='medium'
|
||||||
|
colorScheme='primary'
|
||||||
|
onClick={handleNext}
|
||||||
|
aria-label='Следующий период'
|
||||||
|
>
|
||||||
|
<ChevronSvg
|
||||||
|
width={6.25}
|
||||||
|
height={12.5}
|
||||||
|
stroke='#42567A'
|
||||||
|
className={styles.rotated}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EventsCarousel events={currentPeriod.events} visible />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
TimeFrameSlider.displayName = 'TimeFrameSlider'
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Константы для компонента TimeFrameSlider
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Угол позиции активного элемента (верхний правый угол)
|
||||||
|
*/
|
||||||
|
export const ACTIVE_POSITION_ANGLE = -60
|
||||||
1
src/widgets/TimeFrameSlider/ui/TimeFrameSlider/index.ts
Normal file
1
src/widgets/TimeFrameSlider/ui/TimeFrameSlider/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { TimeFrameSlider } from './TimeFrameSlider'
|
||||||
Reference in New Issue
Block a user