import { useRef, useState, useCallback, useEffect, useMemo, useLayoutEffect } from 'react'; import { useTranslation } from 'react-i18next'; import type { HistoricalEra } from '../types'; import { HISTORICAL_EVENTS, eventEndYear, eventInView, type HistoricalEvent, } from '../data/historical-events'; import { buildTimelineTickYears, chooseTimelineTickInterval, } from '../utils/timelineView'; import './VerticalTimeline.css'; interface LifespanHighlight { birthYear: number; deathYear: number; color: string; } interface Props { eras: HistoricalEra[]; viewStart: number; viewEnd: number; onViewChange: (start: number, end: number) => void; absoluteMin: number; absoluteMax: number; lifespanHighlight?: LifespanHighlight | null; } /** Earlier years at the bottom (0%), later at the top (100%). */ function yearToBottomPercent(year: number, start: number, end: number): number { return ((year - start) / (end - start)) * 100; } function formatYear(year: number): string { if (year < 0) return `${Math.abs(year)} BCE`; return `${year} CE`; } function getEraColor(name: string): string { const colors: Record = { Ancient: 'rgba(139,115,85,0.7)', Medieval: 'rgba(74,85,104,0.7)', Renaissance: 'rgba(184,134,11,0.7)', Baroque: 'rgba(139,0,0,0.6)', 'Neoclassicism & Romanticism': 'rgba(70,130,180,0.6)', Modern: 'rgba(100,100,120,0.6)', Contemporary: 'rgba(60,60,80,0.7)', }; return colors[name] || 'rgba(100,100,100,0.5)'; } export default function VerticalTimeline({ eras, viewStart, viewEnd, onViewChange, absoluteMin, absoluteMax, lifespanHighlight, }: Props) { const { t } = useTranslation('home'); const containerRef = useRef(null); const [dragging, setDragging] = useState<'start' | 'end' | 'pan' | null>(null); const [containerHeight, setContainerHeight] = useState(600); const dragStart = useRef({ y: 0, viewStart: 0, viewEnd: 0 }); const span = viewEnd - viewStart; useLayoutEffect(() => { const el = containerRef.current; if (!el) return; const measure = () => { const h = el.getBoundingClientRect().height; if (h > 0) setContainerHeight(Math.round(h)); }; measure(); const ro = new ResizeObserver(() => measure()); ro.observe(el); window.addEventListener('resize', measure); return () => { ro.disconnect(); window.removeEventListener('resize', measure); }; }, []); const tickInterval = useMemo( () => chooseTimelineTickInterval(span, containerHeight, 56), [span, containerHeight] ); const ticks = useMemo( () => buildTimelineTickYears(viewStart, viewEnd, tickInterval), [viewStart, viewEnd, tickInterval] ); const handleWheel = useCallback( (e: React.WheelEvent) => { e.preventDefault(); const rect = containerRef.current?.getBoundingClientRect(); if (!rect) return; // Bottom = early: invert Y ratio so scroll-at-bottom zooms around early years. const ratioFromTop = (e.clientY - rect.top) / rect.height; const ratio = 1 - ratioFromTop; const centerYear = viewStart + ratio * span; const factor = e.deltaY > 0 ? 1.15 : 0.85; const newSpan = Math.max(10, Math.min(absoluteMax - absoluteMin, span * factor)); let newStart = centerYear - ratio * newSpan; let newEnd = centerYear + (1 - ratio) * newSpan; if (newStart < absoluteMin) { newEnd += absoluteMin - newStart; newStart = absoluteMin; } if (newEnd > absoluteMax) { newStart -= newEnd - absoluteMax; newEnd = absoluteMax; } onViewChange(Math.round(newStart), Math.round(newEnd)); }, [viewStart, span, absoluteMin, absoluteMax, onViewChange] ); const handleMouseDown = (e: React.MouseEvent, mode: 'start' | 'end' | 'pan') => { e.preventDefault(); setDragging(mode); dragStart.current = { y: e.clientY, viewStart, viewEnd }; }; useEffect(() => { if (!dragging) return; const onMove = (e: MouseEvent) => { const rect = containerRef.current?.getBoundingClientRect(); if (!rect) return; // Drag up (negative clientY delta) → toward later years at top → increase view. const dy = e.clientY - dragStart.current.y; const yearDelta = -(dy / rect.height) * span; if (dragging === 'pan') { let ns = dragStart.current.viewStart - yearDelta; let ne = dragStart.current.viewEnd - yearDelta; if (ns < absoluteMin) { ne += absoluteMin - ns; ns = absoluteMin; } if (ne > absoluteMax) { ns -= ne - absoluteMax; ne = absoluteMax; } onViewChange(Math.round(ns), Math.round(ne)); } else if (dragging === 'start') { const ns = Math.min(dragStart.current.viewEnd - 10, dragStart.current.viewStart + yearDelta); onViewChange(Math.round(ns), viewEnd); } else { const ne = Math.max(dragStart.current.viewStart + 10, dragStart.current.viewEnd + yearDelta); onViewChange(viewStart, Math.round(ne)); } }; const onUp = () => setDragging(null); window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); return () => { window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }; }, [dragging, span, viewStart, viewEnd, absoluteMin, absoluteMax, onViewChange]); const zoomIn = () => { const center = (viewStart + viewEnd) / 2; const newSpan = Math.max(10, span * 0.5); onViewChange(Math.round(center - newSpan / 2), Math.round(center + newSpan / 2)); }; const zoomOut = () => { const center = (viewStart + viewEnd) / 2; const newSpan = Math.min(absoluteMax - absoluteMin, span * 2); let ns = center - newSpan / 2; let ne = center + newSpan / 2; if (ns < absoluteMin) { ne += absoluteMin - ns; ns = absoluteMin; } if (ne > absoluteMax) { ns -= ne - absoluteMax; ne = absoluteMax; } onViewChange(Math.round(ns), Math.round(ne)); }; const resetView = () => onViewChange(absoluteMin, absoluteMax); const zoomToEra = useCallback( (era: HistoricalEra, e: React.MouseEvent) => { e.stopPropagation(); e.preventDefault(); const eraSpan = era.end_year - era.start_year; const padding = Math.max(5, Math.round(eraSpan * 0.03)); let start = Math.max(absoluteMin, era.start_year - padding); let end = Math.min(absoluteMax, era.end_year + padding); if (end - start < 10) { const center = (era.start_year + era.end_year) / 2; start = Math.max(absoluteMin, Math.round(center - 5)); end = Math.min(absoluteMax, Math.round(center + 5)); } onViewChange(Math.round(start), Math.round(end)); }, [absoluteMin, absoluteMax, onViewChange] ); const zoomToEvent = useCallback( (event: HistoricalEvent, e: React.MouseEvent) => { e.stopPropagation(); e.preventDefault(); const end = eventEndYear(event); const eventSpan = Math.max(end - event.startYear, 1); const padding = Math.max(8, Math.round(eventSpan * 0.2)); let start = Math.max(absoluteMin, event.startYear - padding); let endView = Math.min(absoluteMax, end + padding); if (endView - start < 10) { const center = (event.startYear + end) / 2; start = Math.max(absoluteMin, Math.round(center - 5)); endView = Math.min(absoluteMax, Math.round(center + 5)); } onViewChange(Math.round(start), Math.round(endView)); }, [absoluteMin, absoluteMax, onViewChange] ); const visibleEvents = useMemo(() => { const inView = HISTORICAL_EVENTS.filter((event) => eventInView(event, viewStart, viewEnd)); const minLabelGapYears = span > 200 ? 40 : span > 80 ? 18 : span > 30 ? 10 : 5; let lastLabelYear = -Infinity; return inView.map((event) => { const end = eventEndYear(event); const labelAnchor = event.endYear ? (event.startYear + end) / 2 : event.startYear; const showLabel = labelAnchor - lastLabelYear >= minLabelGapYears; if (showLabel) lastLabelYear = labelAnchor; return { event, showLabel }; }); }, [viewStart, viewEnd, span]); const lifespanBand = useMemo(() => { if (!lifespanHighlight) return null; const bottom = yearToBottomPercent( Math.max(lifespanHighlight.birthYear, viewStart), viewStart, viewEnd ); const top = yearToBottomPercent( Math.min(lifespanHighlight.deathYear, viewEnd), viewStart, viewEnd ); const height = top - bottom; if (height <= 0) return null; return { bottom, height, color: lifespanHighlight.color }; }, [lifespanHighlight, viewStart, viewEnd]); return ( ); }