Files
Art-gallery/client/src/components/VerticalTimeline.tsx
T

418 lines
15 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.
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>
);
}