kcg-monitoring/frontend/src/components/OsintMapLayer.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

127 lines
4.6 KiB
TypeScript

import { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Marker, Popup } from 'react-map-gl/maplibre';
import type { OsintItem } from '../services/osint';
const CAT_COLOR: Record<string, string> = {
maritime_accident: '#ef4444',
fishing: '#22c55e',
maritime_traffic: '#3b82f6',
military: '#f97316',
shipping: '#eab308',
};
const CAT_ICON: Record<string, string> = {
maritime_accident: '🚨',
fishing: '🐟',
maritime_traffic: '🚢',
military: '🎯',
shipping: '🚢',
};
function useTimeAgo() {
const { t } = useTranslation();
return (ts: number): string => {
const diff = Date.now() - ts;
const m = Math.floor(diff / 60000);
if (m < 60) return t('time.minutesAgo', { count: m });
const h = Math.floor(m / 60);
if (h < 24) return t('time.hoursAgo', { count: h });
return t('time.daysAgo', { count: Math.floor(h / 24) });
};
}
interface Props {
osintFeed: OsintItem[];
currentTime: number;
}
const THREE_HOURS = 3 * 60 * 60 * 1000;
const ONE_HOUR = 3600000;
const MAP_CATEGORIES = new Set(['maritime_accident', 'fishing', 'maritime_traffic', 'shipping', 'military']);
export function OsintMapLayer({ osintFeed, currentTime }: Props) {
const [selected, setSelected] = useState<OsintItem | null>(null);
const { t } = useTranslation();
const timeAgo = useTimeAgo();
const geoItems = useMemo(() => osintFeed.filter(
(item): item is OsintItem & { lat: number; lng: number } =>
item.lat != null && item.lng != null
&& MAP_CATEGORIES.has(item.category)
&& (currentTime - item.timestamp) < THREE_HOURS
), [osintFeed, currentTime]);
return (
<>
{geoItems.map(item => {
const color = CAT_COLOR[item.category] || '#888';
const isRecent = currentTime - item.timestamp < ONE_HOUR;
return (
<Marker key={item.id} longitude={item.lng} latitude={item.lat} anchor="center"
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(item); }}>
<div style={{
cursor: 'pointer',
filter: `drop-shadow(0 0 6px ${color}aa)`,
}} className="flex flex-col items-center">
<div style={{
border: `2px solid ${color}`,
animation: isRecent ? 'pulse 2s ease-in-out infinite' : undefined,
}} className="flex size-[22px] items-center justify-center rounded-full bg-black/60 text-xs">
{CAT_ICON[item.category] || '📰'}
</div>
{isRecent && (
<div style={{
fontSize: 5, color,
textShadow: `0 0 3px ${color}, 0 0 2px #000`,
}} className="mt-px font-bold tracking-wide">
NEW
</div>
)}
</div>
</Marker>
);
})}
{selected && selected.lat != null && selected.lng != null && (
<Popup longitude={selected.lng} latitude={selected.lat}
onClose={() => setSelected(null)} closeOnClick={false}
anchor="bottom" maxWidth="320px" className="gl-popup">
<div className="min-w-[240px] font-mono text-xs">
<div style={{
background: CAT_COLOR[selected.category] || '#888',
}} className="-mx-2.5 -mt-2.5 mb-2 flex items-center gap-1.5 rounded-t px-2 py-1 text-[11px] font-bold text-white">
<span>{CAT_ICON[selected.category] || '📰'}</span>
OSINT
</div>
<div className="mb-1.5 text-[11px] leading-snug text-kcg-text-secondary">
{selected.title}
</div>
<div className="mb-1.5 flex flex-wrap gap-1">
<span style={{
background: CAT_COLOR[selected.category] || '#888',
}} className="rounded-sm px-1.5 py-px text-[9px] font-bold text-white">
{selected.category.replace('_', ' ').toUpperCase()}
</span>
<span className="rounded-sm bg-kcg-card px-1.5 py-px text-[9px] text-kcg-muted">
{selected.source}
</span>
<span className="rounded-sm bg-kcg-card px-1.5 py-px text-[9px] text-kcg-dim">
{timeAgo(selected.timestamp)}
</span>
</div>
{selected.url && (
<a
href={selected.url}
target="_blank"
rel="noopener noreferrer"
className="text-[10px] text-kcg-accent underline"
>{t('osintMap.viewOriginal')}</a>
)}
</div>
</Popup>
)}
</>
);
}