kcg-monitoring/frontend/src/components/common/TimelineSlider.tsx
htlee 81cd094c56 fix(frontend): 컴포넌트 import 경로 수정 (vite build 실패 해결) (#42)
Co-authored-by: htlee <htlee@gcsc.co.kr>
Co-committed-by: htlee <htlee@gcsc.co.kr>
2026-03-18 08:21:42 +09:00

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>
);
}