kcg-monitoring/frontend/src/components/EventStrip.tsx
htlee 2534faa488 feat: 프론트엔드 모노레포 이관 + signal-batch 연동 + Tailwind/i18n/테마 전환
- frontend/ 폴더로 프론트엔드 전체 이관
- signal-batch API 연동 (한국 선박 위치 데이터)
- Tailwind CSS 4 + CSS 변수 테마 토큰 (dark/light)
- i18next 다국어 (ko/en) 인프라 + 28개 컴포넌트 적용
- 레이어 패널 트리 구조 재설계 (카테고리별 온/오프, 범례)
- Google OAuth 로그인 화면 + DEV LOGIN 우회
- 외부 API CORS 프록시 전환 (Airplanes.live, OpenSky, CelesTrak)
- ShipLayer 이미지 탭 전환 (signal-batch / MarineTraffic)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 13:54:41 +09:00

143 lines
4.6 KiB
TypeScript

import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { GeoEvent } from '../types';
interface Props {
events: GeoEvent[];
currentTime: number;
startTime: number;
endTime: number;
onEventClick: (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_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_KEYS: Record<string, string> = {
US: 'source.US',
IL: 'source.IL',
IR: 'source.IR',
proxy: 'source.proxy',
};
interface EventGroup {
dateKey: string; // "2026-03-01"
dateLabel: string; // "03/01 (토)"
events: GeoEvent[];
}
const DAY_NAME_KEYS = [
'dayNames.sun', 'dayNames.mon', 'dayNames.tue', 'dayNames.wed',
'dayNames.thu', 'dayNames.fri', 'dayNames.sat',
];
export function EventStrip({ events, currentTime, onEventClick }: Props) {
const [openDate, setOpenDate] = useState<string | null>(null);
const { t } = useTranslation();
const groups = useMemo(() => {
const sorted = [...events].sort((a, b) => a.timestamp - b.timestamp);
const map = new Map<string, GeoEvent[]>();
for (const ev of sorted) {
const d = new Date(ev.timestamp + KST_OFFSET);
const key = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`;
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(ev);
}
const result: EventGroup[] = [];
for (const [dateKey, evs] of map) {
const d = new Date(evs[0].timestamp + KST_OFFSET);
const dayName = t(DAY_NAME_KEYS[d.getUTCDay()]);
const dateLabel = `${String(d.getUTCMonth() + 1).padStart(2, '0')}/${String(d.getUTCDate()).padStart(2, '0')} (${dayName})`;
result.push({ dateKey, dateLabel, events: evs });
}
return result;
}, [events, t]);
// Auto-open the first group if none selected
const effectiveOpen = openDate ?? (groups.length > 0 ? groups[0].dateKey : null);
const formatTimeKST = (ts: number) => {
const d = new Date(ts + KST_OFFSET);
return `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}`;
};
return (
<div className="event-strip">
{/* Date tabs */}
<div className="es-tabs">
<span className="es-label">STRIKES</span>
{groups.map(g => {
const isActive = effectiveOpen === g.dateKey;
const passedCount = g.events.filter(e => e.timestamp <= currentTime).length;
return (
<button
key={g.dateKey}
className={`es-tab ${isActive ? 'active' : ''}`}
onClick={() => setOpenDate(isActive ? null : g.dateKey)}
>
<span className="es-tab-date">{g.dateLabel}</span>
<span className="es-tab-count">{passedCount}/{g.events.length}</span>
</button>
);
})}
</div>
{/* Expanded event list for selected date */}
{effectiveOpen && (() => {
const group = groups.find(g => g.dateKey === effectiveOpen);
if (!group) return null;
return (
<div className="es-events">
{group.events.map(ev => {
const isPast = ev.timestamp <= currentTime;
const color = TYPE_COLORS[ev.type] || '#888';
const source = ev.source ? t(SOURCE_KEYS[ev.source] ?? ev.source) : '';
const typeLabel = t(TYPE_KEYS[ev.type] ?? ev.type);
return (
<button
key={ev.id}
className={`es-event ${isPast ? 'past' : 'future'}`}
style={{ '--dot-color': color } as React.CSSProperties}
onClick={() => onEventClick(ev)}
title={ev.description || ev.label}
>
<span className="es-dot" />
<span className="es-time">{formatTimeKST(ev.timestamp)}</span>
{source && (
<span className="es-source" style={{ background: color }}>{source}</span>
)}
<span className="es-name">{ev.label}</span>
<span className="es-type">{typeLabel}</span>
</button>
);
})}
</div>
);
})()}
</div>
);
}