- 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>
127 lines
4.6 KiB
TypeScript
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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|