164 lines
5.7 KiB
TypeScript
164 lines
5.7 KiB
TypeScript
import { useMemo, useState, useCallback } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import type { GeoEvent } from '../../types';
|
|
|
|
interface Props {
|
|
currentTime: number;
|
|
startTime: number;
|
|
endTime: number;
|
|
events: GeoEvent[];
|
|
onSeek: (time: number) => void;
|
|
onEventFlyTo?: (event: GeoEvent) => void;
|
|
}
|
|
|
|
const KST_OFFSET = 9 * 3600_000;
|
|
|
|
const TYPE_COLORS: Record<string, string> = {
|
|
airstrike: '#ef4444',
|
|
explosion: '#f97316',
|
|
missile_launch: '#eab308',
|
|
intercept: '#3b82f6',
|
|
alert: '#a855f7',
|
|
impact: '#ff0000',
|
|
osint: '#06b6d4',
|
|
};
|
|
|
|
const TYPE_I18N_KEYS: Record<string, string> = {
|
|
airstrike: 'event.airstrike',
|
|
explosion: 'event.explosion',
|
|
missile_launch: 'event.missileLaunch',
|
|
intercept: 'event.intercept',
|
|
alert: 'event.alert',
|
|
impact: 'event.impact',
|
|
osint: 'event.osint',
|
|
};
|
|
|
|
const SOURCE_I18N_KEYS: Record<string, string> = {
|
|
US: 'source.US',
|
|
IL: 'source.IL',
|
|
IR: 'source.IR',
|
|
proxy: 'source.proxy',
|
|
};
|
|
|
|
export function TimelineSlider({ currentTime, startTime, endTime, events, onSeek, onEventFlyTo }: Props) {
|
|
const { t } = useTranslation();
|
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
|
|
const progress = ((currentTime - startTime) / (endTime - startTime)) * 100;
|
|
|
|
const eventMarkers = useMemo(() => {
|
|
return events.map(e => ({
|
|
id: e.id,
|
|
position: ((e.timestamp - startTime) / (endTime - startTime)) * 100,
|
|
type: e.type,
|
|
label: e.label,
|
|
}));
|
|
}, [events, startTime, endTime]);
|
|
|
|
const formatTime = (ts: number) => {
|
|
const d = new Date(ts + KST_OFFSET);
|
|
return d.toISOString().slice(0, 16).replace('T', ' ') + ' KST';
|
|
};
|
|
|
|
const formatTimeShort = (ts: number) => {
|
|
const d = new Date(ts + KST_OFFSET);
|
|
return `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}`;
|
|
};
|
|
|
|
const handleTrackClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
const pct = (e.clientX - rect.left) / rect.width;
|
|
onSeek(startTime + pct * (endTime - startTime));
|
|
};
|
|
|
|
// When a marker is clicked: select it + seek to its time
|
|
const handleMarkerClick = useCallback((e: React.MouseEvent, ev: GeoEvent) => {
|
|
e.stopPropagation(); // don't trigger track seek
|
|
setSelectedId(prev => prev === ev.id ? null : ev.id);
|
|
onSeek(ev.timestamp);
|
|
}, [onSeek]);
|
|
|
|
// Find events near the selected event (within 30 min)
|
|
const selectedCluster = useMemo(() => {
|
|
if (!selectedId) return [];
|
|
const sel = events.find(e => e.id === selectedId);
|
|
if (!sel) return [];
|
|
const WINDOW = 30 * 60_000; // 30 min
|
|
return events
|
|
.filter(e => Math.abs(e.timestamp - sel.timestamp) <= WINDOW)
|
|
.sort((a, b) => a.timestamp - b.timestamp);
|
|
}, [selectedId, events]);
|
|
|
|
const handleEventCardClick = useCallback((ev: GeoEvent) => {
|
|
onSeek(ev.timestamp);
|
|
onEventFlyTo?.(ev);
|
|
}, [onSeek, onEventFlyTo]);
|
|
|
|
return (
|
|
<div className="timeline-slider">
|
|
<div className="timeline-labels">
|
|
<span>{formatTime(startTime)}</span>
|
|
<span className="timeline-current">{formatTime(currentTime)}</span>
|
|
<span>{formatTime(endTime)}</span>
|
|
</div>
|
|
<div className="timeline-track" onClick={handleTrackClick}>
|
|
<div className="timeline-progress" style={{ width: `${progress}%` }} />
|
|
<div className="timeline-playhead" style={{ left: `${progress}%` }} />
|
|
{eventMarkers.map(m => {
|
|
const ev = events.find(e => e.id === m.id)!;
|
|
const isSelected = selectedId === m.id;
|
|
const isInCluster = selectedCluster.some(c => c.id === m.id);
|
|
return (
|
|
<div
|
|
key={m.id}
|
|
className={`tl-marker ${isSelected ? 'selected' : ''} ${isInCluster && !isSelected ? 'in-cluster' : ''}`}
|
|
style={{
|
|
left: `${m.position}%`,
|
|
'--marker-color': TYPE_COLORS[m.type] || '#888',
|
|
} as React.CSSProperties}
|
|
title={m.label}
|
|
onClick={(e) => handleMarkerClick(e, ev)}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Event detail strip — shown when a marker is selected */}
|
|
{selectedCluster.length > 0 && (
|
|
<div className="tl-detail-strip">
|
|
{selectedCluster.map(ev => {
|
|
const color = TYPE_COLORS[ev.type] || '#888';
|
|
const isPast = ev.timestamp <= currentTime;
|
|
const isActive = ev.id === selectedId;
|
|
const sourceKey = ev.source ? SOURCE_I18N_KEYS[ev.source] : '';
|
|
const source = sourceKey ? t(sourceKey) : '';
|
|
const typeKey = TYPE_I18N_KEYS[ev.type];
|
|
const typeLabel = typeKey ? t(typeKey) : ev.type;
|
|
|
|
return (
|
|
<button
|
|
key={ev.id}
|
|
className={`tl-event-card ${isActive ? 'active' : ''} ${isPast ? 'past' : 'future'}`}
|
|
style={{ '--card-color': color } as React.CSSProperties}
|
|
onClick={() => handleEventCardClick(ev)}
|
|
title={t('timeline.flyToTooltip')}
|
|
>
|
|
<span className="tl-card-dot" />
|
|
<span className="tl-card-time">{formatTimeShort(ev.timestamp)}</span>
|
|
{source && (
|
|
<span className="tl-card-source" style={{ background: color }}>{source}</span>
|
|
)}
|
|
<span className="tl-card-name">{ev.label}</span>
|
|
<span className="tl-card-type">{typeLabel}</span>
|
|
<svg className="tl-card-goto" viewBox="0 0 16 16" width="10" height="10">
|
|
<path d="M8 1L14 8L8 15M14 8H1" stroke="currentColor" strokeWidth="2" fill="none"/>
|
|
</svg>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|