'use client'; import { useEffect, useState, useRef } from 'react'; import { api } from '@/lib/api'; interface TimelineEvent { id: string; type: string; emoji: string; name: string; date: string; display: string; days: number; hours: number; minutes: number; yearsPassed?: number; daysPassed?: number; startYear?: number; } interface TimelineGridItem { days: number; date: string; weekday: string; } interface MonthMarker { name: string; days: number; } export function Timeline() { const [events, setEvents] = useState([]); const [timelineGrid, setTimelineGrid] = useState([]); const [months, setMonths] = useState([]); const [hoveredGrid, setHoveredGrid] = useState(null); const [hoveredEvent, setHoveredEvent] = useState(null); const timelineRef = useRef(null); const eventRefs = useRef>(new Map()); const daysInFirstZone = 14; const weeksInSecondZone = 40; const firstZonePercent = 25.0; const secondZonePercent = 75.0; const maxDays = daysInFirstZone + weeksInSecondZone * 7; const REPULSION_RADIUS = 80; const REPULSION_STRENGTH = 20; useEffect(() => { loadCountdownData(); }, []); useEffect(() => { // Добавляем эффект отталкивания для эмодзи const timeline = timelineRef.current; if (!timeline) return; const eventElements = Array.from(eventRefs.current.values()); if (eventElements.length === 0) return; // Для каждого элемента добавляем обработчики eventElements.forEach((hoverItem) => { const handleMouseEnter = () => { const hoveredRect = hoverItem.getBoundingClientRect(); const timelineRect = timeline.getBoundingClientRect(); const hoveredX = hoveredRect.left + hoveredRect.width / 2 - timelineRect.left; const hoveredY = hoveredRect.top + hoveredRect.height / 2 - timelineRect.top; // Отталкиваем все остальные элементы eventElements.forEach((item) => { if (item === hoverItem) return; const itemRect = item.getBoundingClientRect(); const itemX = itemRect.left + itemRect.width / 2 - timelineRect.left; const itemY = itemRect.top + itemRect.height / 2 - timelineRect.top; const dx = itemX - hoveredX; const dy = itemY - hoveredY; const distance = Math.sqrt(dx * dx + dy * dy); if (distance < REPULSION_RADIUS && distance > 0) { const nx = dx / distance; const ny = dy / distance; const force = (1 - distance / REPULSION_RADIUS) * REPULSION_STRENGTH; const offsetX = nx * force; const offsetY = ny * force; item.style.transform = `translate(calc(-50% + ${offsetX}px), calc(-50% + ${offsetY}px))`; } }); }; const handleMouseLeave = () => { // Возвращаем ВСЕ элементы на свои исходные позиции eventElements.forEach((item) => { item.style.transform = 'translate(-50%, -50%)'; }); }; hoverItem.addEventListener('mouseenter', handleMouseEnter); hoverItem.addEventListener('mouseleave', handleMouseLeave); // Сохраняем обработчики для cleanup (hoverItem as any)._repulsionHandlers = { handleMouseEnter, handleMouseLeave }; }); // Cleanup return () => { eventElements.forEach((element) => { const handlers = (element as any)._repulsionHandlers; if (handlers) { element.removeEventListener('mouseenter', handlers.handleMouseEnter); element.removeEventListener('mouseleave', handlers.handleMouseLeave); delete (element as any)._repulsionHandlers; } }); }; }, [events, REPULSION_RADIUS, REPULSION_STRENGTH]); // Пересоздаем обработчики при изменении событий const loadCountdownData = async () => { try { const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/countdown`); const data = await response.json(); setEvents(data.events || []); setTimelineGrid(data.timelineGrid || []); setMonths(data.months || []); } catch (error) { console.error('Failed to load countdown data:', error); } }; const calculatePosition = (days: number): number => { if (days >= maxDays) return 100; if (days < daysInFirstZone) { // Зона 1: 0-14 дней -> 0-25% шкалы return (days / daysInFirstZone) * firstZonePercent; } else { // Зона 2: 14+ дней -> 25-100% шкалы const daysAfterZ1 = days - daysInFirstZone; const weeksInZ2 = daysAfterZ1 / 7; const posInZ2 = (weeksInZ2 / weeksInSecondZone) * secondZonePercent; return firstZonePercent + posInZ2; } }; const calculateGridWidth = (days: number): number => { if (days < daysInFirstZone) { return firstZonePercent / daysInFirstZone; } else { return secondZonePercent / weeksInSecondZone; } }; const getShakeClass = (position: number): string => { if (position <= 5) return 'animate-shake-very-strong'; if (position <= 12) return 'animate-shake-strong'; if (position <= 18) return 'animate-shake-medium'; if (position <= 25) return 'animate-shake-weak'; return ''; }; const formatCountdown = (event: TimelineEvent) => { const days = event.days; if (days >= 30) { const months = Math.floor(days / 30); const remainingDays = days % 30; return (
{months}
{months === 1 ? 'месяц' : months >= 2 && months <= 4 ? 'месяца' : 'месяцев'}
{remainingDays}
{remainingDays === 1 ? 'день' : remainingDays >= 2 && remainingDays <= 4 ? 'дня' : 'дней'}
); } else { return (
{days}
{days === 1 ? 'день' : days >= 2 && days <= 4 ? 'дня' : 'дней'}
{event.hours}
{event.hours === 1 ? 'час' : event.hours >= 2 && event.hours <= 4 ? 'часа' : 'часов'}
); } }; return (

📅 Временная шкала

{/* Линия времени - Зона 1 (0-25%) */}
{/* Линия времени - Зона 2 (25-100%) */}
{/* Маркер "Сегодня" */}
🔥
{/* События */} {events.map((event) => { if (event.days >= maxDays) return null; const position = calculatePosition(event.days); const shakeClass = getShakeClass(position); return (
{ if (el) { eventRefs.current.set(event.id, el); } else { eventRefs.current.delete(event.id); } }} className={`absolute top-[35%] -translate-x-1/2 -translate-y-1/2 cursor-pointer z-10 transition-all duration-300 ${shakeClass}`} style={{ left: `${position}%` }} onMouseEnter={() => setHoveredEvent(event.id)} onMouseLeave={() => setHoveredEvent(null)} > {/* Emoji */}
{event.emoji}
{/* Tooltip */} {hoveredEvent === event.id && (
{/* Название */}
{event.emoji}
{event.name}
{/* Дата */}
📅 Дата:
{event.date}
{/* Годовщина */} {event.type === 'anniversary' && event.yearsPassed && (
🎉 Годовщина
{event.yearsPassed}{' '} {event.yearsPassed % 10 === 1 && event.yearsPassed % 100 !== 11 ? 'год' : event.yearsPassed % 10 >= 2 && event.yearsPassed % 10 <= 4 && (event.yearsPassed % 100 < 12 || event.yearsPassed % 100 > 14) ? 'года' : 'лет'}
)} {/* Countdown */}
⏰ Осталось
{formatCountdown(event)}
{/* Стрелка */}
)}
); })} {/* Интерактивная сетка */} {timelineGrid.map((gridItem, index) => { if (gridItem.days >= maxDays) return null; const position = calculatePosition(gridItem.days); const width = calculateGridWidth(gridItem.days); return (
setHoveredGrid(index)} onMouseLeave={() => setHoveredGrid(null)} > {/* Grid tooltip */} {hoveredGrid === index && (
{gridItem.weekday}
📅 {gridItem.date}
{/* Стрелка */}
)}
); })} {/* Метки месяцев */} {months.map((month, index) => { if (month.days >= maxDays) return null; const position = calculatePosition(month.days); return (
{month.name}
); })}
); }