418 lines
15 KiB
TypeScript
418 lines
15 KiB
TypeScript
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<string, string> = {
|
||
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<HTMLDivElement>(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 (
|
||
<aside className="vtimeline-wrapper">
|
||
<div className="vtimeline-controls">
|
||
<button type="button" onClick={zoomIn} title="Zoom in">
|
||
+
|
||
</button>
|
||
<button type="button" onClick={zoomOut} title="Zoom out">
|
||
−
|
||
</button>
|
||
<button type="button" onClick={resetView} title="Reset view">
|
||
⟲
|
||
</button>
|
||
<span className="vtimeline-range">
|
||
{formatYear(viewStart)}
|
||
<br />—<br />
|
||
{formatYear(viewEnd)}
|
||
</span>
|
||
</div>
|
||
|
||
<div
|
||
ref={containerRef}
|
||
className={`vtimeline-container${lifespanBand ? ' vtimeline-container-lifespan-hover' : ''}`}
|
||
onWheel={handleWheel}
|
||
onMouseDown={(e) => handleMouseDown(e, 'pan')}
|
||
>
|
||
<div className="vtimeline-track">
|
||
{eras.map((era) => {
|
||
const bottom = yearToBottomPercent(Math.max(era.start_year, viewStart), viewStart, viewEnd);
|
||
const top = yearToBottomPercent(Math.min(era.end_year, viewEnd), viewStart, viewEnd);
|
||
if (top <= 0 || bottom >= 100) return null;
|
||
const height = Math.min(100, top) - Math.max(0, bottom);
|
||
return (
|
||
<button
|
||
key={era.id}
|
||
type="button"
|
||
className="vtimeline-era-block"
|
||
style={{
|
||
bottom: `${Math.max(0, bottom)}%`,
|
||
height: `${height}%`,
|
||
borderBottom: era.start_definite ? '2px solid rgba(255,255,255,0.6)' : undefined,
|
||
borderTop: era.end_definite ? '2px solid rgba(255,255,255,0.6)' : undefined,
|
||
background: `linear-gradient(0deg,
|
||
${era.start_definite ? 'var(--era-color)' : 'transparent'} 0%,
|
||
var(--era-color) 15%,
|
||
var(--era-color) 85%,
|
||
${era.end_definite ? 'var(--era-color)' : 'transparent'} 100%)`,
|
||
['--era-color' as string]: getEraColor(era.name),
|
||
}}
|
||
title={`${era.name}: ${formatYear(era.start_year)} – ${formatYear(era.end_year)}`}
|
||
onClick={(e) => zoomToEra(era, e)}
|
||
onMouseDown={(e) => e.stopPropagation()}
|
||
>
|
||
<span className="vtimeline-era-label">{era.name}</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{lifespanBand && (
|
||
<div className="vtimeline-lifespan-overlays" aria-hidden>
|
||
{lifespanBand.bottom > 0 && (
|
||
<div className="vtimeline-lifespan-dim" style={{ bottom: 0, height: `${lifespanBand.bottom}%` }} />
|
||
)}
|
||
{lifespanBand.bottom + lifespanBand.height < 100 && (
|
||
<div
|
||
className="vtimeline-lifespan-dim"
|
||
style={{
|
||
bottom: `${lifespanBand.bottom + lifespanBand.height}%`,
|
||
height: `${100 - lifespanBand.bottom - lifespanBand.height}%`,
|
||
}}
|
||
/>
|
||
)}
|
||
<div
|
||
className="vtimeline-lifespan-highlight"
|
||
style={{
|
||
bottom: `${lifespanBand.bottom}%`,
|
||
height: `${lifespanBand.height}%`,
|
||
}}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
<div className="vtimeline-events">
|
||
{visibleEvents.map(({ event, showLabel }) => {
|
||
const end = eventEndYear(event);
|
||
const isSpan = event.endYear != null && event.endYear !== event.startYear;
|
||
if (isSpan) {
|
||
const bottom = yearToBottomPercent(Math.max(event.startYear, viewStart), viewStart, viewEnd);
|
||
const top = yearToBottomPercent(Math.min(end, viewEnd), viewStart, viewEnd);
|
||
if (top <= bottom) return null;
|
||
return (
|
||
<button
|
||
key={event.id}
|
||
type="button"
|
||
className="vtimeline-event-span"
|
||
style={{ bottom: `${bottom}%`, height: `${top - bottom}%` }}
|
||
title={event.name}
|
||
onClick={(e) => zoomToEvent(event, e)}
|
||
onMouseDown={(e) => e.stopPropagation()}
|
||
>
|
||
{showLabel && (
|
||
<span className="vtimeline-event-label">{event.shortLabel ?? event.name}</span>
|
||
)}
|
||
</button>
|
||
);
|
||
}
|
||
const bottom = yearToBottomPercent(event.startYear, viewStart, viewEnd);
|
||
if (bottom < 0 || bottom > 100) return null;
|
||
return (
|
||
<button
|
||
key={event.id}
|
||
type="button"
|
||
className="vtimeline-event-mark"
|
||
style={{ bottom: `${bottom}%` }}
|
||
title={event.name}
|
||
onClick={(e) => zoomToEvent(event, e)}
|
||
onMouseDown={(e) => e.stopPropagation()}
|
||
>
|
||
{showLabel && (
|
||
<span className="vtimeline-event-label">{event.shortLabel ?? event.name}</span>
|
||
)}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<div className="vtimeline-ticks">
|
||
{ticks.map((year) => (
|
||
<div
|
||
key={year}
|
||
className="vtimeline-tick"
|
||
style={{ bottom: `${yearToBottomPercent(year, viewStart, viewEnd)}%` }}
|
||
>
|
||
<span>{formatYear(year)}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div
|
||
className="vtimeline-brush vtimeline-brush-start"
|
||
onMouseDown={(e) => {
|
||
e.stopPropagation();
|
||
handleMouseDown(e, 'start');
|
||
}}
|
||
/>
|
||
<div
|
||
className="vtimeline-brush vtimeline-brush-end"
|
||
onMouseDown={(e) => {
|
||
e.stopPropagation();
|
||
handleMouseDown(e, 'end');
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
<p className="vtimeline-hint">{t('captionVerticalTimeline')}</p>
|
||
</aside>
|
||
);
|
||
}
|