Files
home-service/frontend/src/components/Timeline.tsx

492 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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<TimelineEvent[]>([]);
const [timelineGrid, setTimelineGrid] = useState<TimelineGridItem[]>([]);
const [months, setMonths] = useState<MonthMarker[]>([]);
const [hoveredGrid, setHoveredGrid] = useState<number | null>(null);
const [hoveredEvent, setHoveredEvent] = useState<string | null>(null);
const timelineRef = useRef<HTMLDivElement>(null);
const eventRefs = useRef<Map<string, HTMLDivElement>>(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 (
<div className="flex gap-3 justify-around">
<div className="text-center">
<div className="text-base font-bold text-pink-400">{months}</div>
<div className="text-[9px] opacity-60 mt-0.5">
{months === 1 ? 'месяц' : months >= 2 && months <= 4 ? 'месяца' : 'месяцев'}
</div>
</div>
<div className="text-center">
<div className="text-base font-bold text-orange-400">{remainingDays}</div>
<div className="text-[9px] opacity-60 mt-0.5">
{remainingDays === 1 ? 'день' : remainingDays >= 2 && remainingDays <= 4 ? 'дня' : 'дней'}
</div>
</div>
</div>
);
} else {
return (
<div className="flex gap-3 justify-around">
<div className="text-center">
<div className="text-base font-bold text-pink-400">{days}</div>
<div className="text-[9px] opacity-60 mt-0.5">
{days === 1 ? 'день' : days >= 2 && days <= 4 ? 'дня' : 'дней'}
</div>
</div>
<div className="text-center">
<div className="text-base font-bold text-orange-400">{event.hours}</div>
<div className="text-[9px] opacity-60 mt-0.5">
{event.hours === 1 ? 'час' : event.hours >= 2 && event.hours <= 4 ? 'часа' : 'часов'}
</div>
</div>
</div>
);
}
};
return (
<div className="bg-gray-900 rounded-xl p-6 shadow-xl">
<h2 className="text-2xl font-bold mb-6 text-white text-center">📅 Временная шкала</h2>
<div ref={timelineRef} className="relative h-[140px]">
{/* Линия времени - Зона 1 (0-25%) */}
<div
className="absolute left-0 top-1/2 -translate-y-1/2 h-[3px]"
style={{
width: '25%',
background: 'linear-gradient(to right, rgba(185, 28, 28, 0.4), rgba(128, 128, 128, 0.4))',
backgroundImage:
'linear-gradient(to right, rgba(185, 28, 28, 0.4), rgba(128, 128, 128, 0.4)), linear-gradient(to right, rgba(180, 180, 180, 0.6) 1px, transparent 1px)',
backgroundSize: '100% 100%, calc(100% / 14) 100%',
backgroundRepeat: 'no-repeat, repeat-x',
}}
/>
{/* Линия времени - Зона 2 (25-100%) */}
<div
className="absolute left-[25%] top-1/2 -translate-y-1/2 h-[3px]"
style={{
width: '75%',
backgroundColor: 'transparent',
backgroundImage:
'linear-gradient(to right, rgba(128,128,128,0.45) 1px, transparent 1px), linear-gradient(to right, rgba(128,128,128,0.75) 1px, transparent 1px)',
backgroundSize: 'calc(100% / 40) 100%, calc(100% / 10) 100%',
backgroundRepeat: 'repeat-x, repeat-x',
}}
/>
{/* Маркер "Сегодня" */}
<div
className="absolute left-0 top-1/2 -translate-x-1/2 -translate-y-1/2 text-2xl z-[15]"
style={{
textShadow: '0 0 8px rgba(255, 100, 0, 0.6)',
filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.3))',
}}
>
🔥
</div>
{/* События */}
{events.map((event) => {
if (event.days >= maxDays) return null;
const position = calculatePosition(event.days);
const shakeClass = getShakeClass(position);
return (
<div
key={event.id}
ref={(el) => {
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 */}
<div
className="text-[28px] transition-transform duration-300"
style={{
textShadow: '0 0 3px rgba(0,0,0,0.3), 0 2px 4px rgba(0,0,0,0.2)',
transform: hoveredEvent === event.id ? 'scale(1.4)' : 'scale(1)',
}}
>
{event.emoji}
</div>
{/* Tooltip */}
{hoveredEvent === event.id && (
<div
className="absolute bottom-[45px] left-1/2 -translate-x-1/2 min-w-[200px] z-50"
style={{
background: 'linear-gradient(135deg, rgba(20,20,25,0.98) 0%, rgba(30,30,40,0.98) 100%)',
boxShadow: '0 8px 24px rgba(0,0,0,0.4), 0 0 0 1px rgba(255,255,255,0.1)',
backdropFilter: 'blur(10px)',
}}
>
<div className="p-3 rounded-lg">
{/* Название */}
<div className="flex items-center gap-2 mb-2 pb-2 border-b border-white/15">
<div className="text-2xl">{event.emoji}</div>
<div className="font-bold text-sm text-white">{event.name}</div>
</div>
{/* Дата */}
<div className="mb-2 flex items-center gap-1.5 text-[11px]">
<div className="opacity-70">📅 Дата:</div>
<div className="font-semibold text-blue-400">{event.date}</div>
</div>
{/* Годовщина */}
{event.type === 'anniversary' && event.yearsPassed && (
<div className="bg-purple-500/15 p-2 rounded-md mb-2 border-l-2 border-purple-500">
<div className="opacity-90 text-[11px] mb-1">🎉 Годовщина</div>
<div className="font-bold text-[15px] text-purple-300">
{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)
? 'года'
: 'лет'}
</div>
</div>
)}
{/* Countdown */}
<div className="bg-white/8 p-2 rounded-md">
<div className="opacity-70 text-[10px] mb-1 uppercase tracking-wider"> Осталось</div>
{formatCountdown(event)}
</div>
{/* Стрелка */}
<div
className="absolute -bottom-1.5 left-1/2 -translate-x-1/2 w-0 h-0"
style={{
borderLeft: '6px solid transparent',
borderRight: '6px solid transparent',
borderTop: '6px solid rgba(20,20,25,0.98)',
}}
/>
</div>
</div>
)}
</div>
);
})}
{/* Интерактивная сетка */}
{timelineGrid.map((gridItem, index) => {
if (gridItem.days >= maxDays) return null;
const position = calculatePosition(gridItem.days);
const width = calculateGridWidth(gridItem.days);
return (
<div
key={index}
className="absolute top-[35%] -translate-y-1/2 h-10 cursor-pointer z-[5]"
style={{ left: `${position}%`, width: `${width}%` }}
onMouseEnter={() => setHoveredGrid(index)}
onMouseLeave={() => setHoveredGrid(null)}
>
{/* Grid tooltip */}
{hoveredGrid === index && (
<div
className="absolute bottom-[45px] left-1/2 -translate-x-1/2 z-50"
style={{
background: 'linear-gradient(135deg, rgba(30,30,40,0.95) 0%, rgba(40,40,50,0.95) 100%)',
boxShadow: '0 4px 12px rgba(0,0,0,0.3), 0 0 0 1px rgba(255,255,255,0.08)',
backdropFilter: 'blur(8px)',
}}
>
<div className="p-2 rounded-lg">
<div className="text-center mb-1 font-semibold text-blue-400 text-[10px] uppercase tracking-wide">
{gridItem.weekday}
</div>
<div className="text-center font-medium text-[13px] whitespace-nowrap">
📅 {gridItem.date}
</div>
{/* Стрелка */}
<div
className="absolute -bottom-[5px] left-1/2 -translate-x-1/2 w-0 h-0"
style={{
borderLeft: '5px solid transparent',
borderRight: '5px solid transparent',
borderTop: '5px solid rgba(30,30,40,0.95)',
}}
/>
</div>
</div>
)}
</div>
);
})}
{/* Метки месяцев */}
{months.map((month, index) => {
if (month.days >= maxDays) return null;
const position = calculatePosition(month.days);
return (
<div
key={index}
className="absolute top-[65%] -translate-x-1/2 text-[10px] text-gray-500 whitespace-nowrap"
style={{ left: `${position}%` }}
>
{month.name}
</div>
);
})}
</div>
<style jsx>{`
@keyframes shake-weak {
0%,
100% {
transform: translate(-50%, -50%) rotate(0deg);
}
25% {
transform: translate(-50%, -50%) rotate(0.5deg);
}
75% {
transform: translate(-50%, -50%) rotate(-0.5deg);
}
}
@keyframes shake-medium {
0%,
100% {
transform: translate(-50%, -50%) rotate(0deg);
}
25% {
transform: translate(-50%, -50%) rotate(1deg);
}
75% {
transform: translate(-50%, -50%) rotate(-1deg);
}
}
@keyframes shake-strong {
0%,
100% {
transform: translate(-50%, -50%) rotate(0deg);
}
25% {
transform: translate(-50%, -50%) rotate(2deg);
}
75% {
transform: translate(-50%, -50%) rotate(-2deg);
}
}
@keyframes shake-very-strong {
0%,
100% {
transform: translate(-50%, -50%) rotate(0deg);
}
10% {
transform: translate(-50%, -50%) rotate(3deg);
}
30% {
transform: translate(-50%, -50%) rotate(-3deg);
}
50% {
transform: translate(-50%, -50%) rotate(3deg);
}
70% {
transform: translate(-50%, -50%) rotate(-3deg);
}
90% {
transform: translate(-50%, -50%) rotate(3deg);
}
}
.animate-shake-weak {
animation: shake-weak 2s ease-in-out infinite;
}
.animate-shake-medium {
animation: shake-medium 1.5s ease-in-out infinite;
}
.animate-shake-strong {
animation: shake-strong 1s ease-in-out infinite;
}
.animate-shake-very-strong {
animation: shake-very-strong 0.5s ease-in-out infinite;
}
`}</style>
</div>
);
}