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>
This commit is contained in:
htlee 2026-03-17 13:54:41 +09:00
부모 130a12acb3
커밋 2534faa488
101개의 변경된 파일5099개의 추가작업 그리고 2515개의 파일을 삭제

파일 보기

@ -43,5 +43,42 @@
"Read(./**/.env.*)", "Read(./**/.env.*)",
"Read(./**/secrets/**)" "Read(./**/secrets/**)"
] ]
},
"hooks": {
"SessionStart": [
{
"matcher": "compact",
"hooks": [
{
"type": "command",
"command": "bash .claude/scripts/on-post-compact.sh",
"timeout": 10
}
]
}
],
"PreCompact": [
{
"hooks": [
{
"type": "command",
"command": "bash .claude/scripts/on-pre-compact.sh",
"timeout": 30
}
]
}
],
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash .claude/scripts/on-commit.sh",
"timeout": 15
}
]
}
]
} }
} }

파일 보기

@ -0,0 +1,6 @@
{
"applied_global_version": "1.6.1",
"applied_date": "2026-03-17",
"project_type": "react-ts",
"gitea_url": "https://gitea.gc-si.dev"
}

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 보기

@ -10,17 +10,22 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@rollup/rollup-darwin-arm64": "^4.59.0",
"@tailwindcss/vite": "^4.2.1",
"@types/leaflet": "^1.9.21", "@types/leaflet": "^1.9.21",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"hls.js": "^1.6.15", "hls.js": "^1.6.15",
"i18next": "^25.8.18",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"maplibre-gl": "^5.19.0", "maplibre-gl": "^5.19.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-i18next": "^16.5.8",
"react-leaflet": "^5.0.0", "react-leaflet": "^5.0.0",
"react-map-gl": "^8.1.0", "react-map-gl": "^8.1.0",
"recharts": "^3.8.0", "recharts": "^3.8.0",
"satellite.js": "^6.0.2" "satellite.js": "^6.0.2",
"tailwindcss": "^4.2.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",

파일 보기

Before

Width:  |  Height:  |  크기: 113 KiB

After

Width:  |  Height:  |  크기: 113 KiB

파일 보기

Before

Width:  |  Height:  |  크기: 112 KiB

After

Width:  |  Height:  |  크기: 112 KiB

파일 보기

Before

Width:  |  Height:  |  크기: 86 KiB

After

Width:  |  Height:  |  크기: 86 KiB

파일 보기

Before

Width:  |  Height:  |  크기: 1.2 MiB

After

Width:  |  Height:  |  크기: 1.2 MiB

파일 보기

Before

Width:  |  Height:  |  크기: 70 KiB

After

Width:  |  Height:  |  크기: 70 KiB

파일 보기

Before

Width:  |  Height:  |  크기: 98 KiB

After

Width:  |  Height:  |  크기: 98 KiB

파일 보기

Before

Width:  |  Height:  |  크기: 107 KiB

After

Width:  |  Height:  |  크기: 107 KiB

파일 보기

Before

Width:  |  Height:  |  크기: 114 KiB

After

Width:  |  Height:  |  크기: 114 KiB

파일 보기

Before

Width:  |  Height:  |  크기: 112 KiB

After

Width:  |  Height:  |  크기: 112 KiB

파일 보기

Before

Width:  |  Height:  |  크기: 112 KiB

After

Width:  |  Height:  |  크기: 112 KiB

파일 보기

Before

Width:  |  Height:  |  크기: 1.3 MiB

After

Width:  |  Height:  |  크기: 1.3 MiB

파일 보기

Before

Width:  |  Height:  |  크기: 123 KiB

After

Width:  |  Height:  |  크기: 123 KiB

파일 보기

Before

Width:  |  Height:  |  크기: 226 KiB

After

Width:  |  Height:  |  크기: 226 KiB

파일 보기

Before

Width:  |  Height:  |  크기: 119 KiB

After

Width:  |  Height:  |  크기: 119 KiB

파일 보기

Before

Width:  |  Height:  |  크기: 146 KiB

After

Width:  |  Height:  |  크기: 146 KiB

파일 보기

Before

Width:  |  Height:  |  크기: 2.4 MiB

After

Width:  |  Height:  |  크기: 2.4 MiB

파일 보기

Before

Width:  |  Height:  |  크기: 123 KiB

After

Width:  |  Height:  |  크기: 123 KiB

파일 보기

Before

Width:  |  Height:  |  크기: 1.5 KiB

After

Width:  |  Height:  |  크기: 1.5 KiB

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 보기

@ -23,6 +23,10 @@ import { KOREA_SUBMARINE_CABLES } from './services/submarineCable';
import type { OsintItem } from './services/osint'; import type { OsintItem } from './services/osint';
import { propagateAircraft, propagateShips } from './services/propagation'; import { propagateAircraft, propagateShips } from './services/propagation';
import type { GeoEvent, SensorLog, Aircraft, Ship, Satellite, SatellitePosition, LayerVisibility, AppMode } from './types'; import type { GeoEvent, SensorLog, Aircraft, Ship, Satellite, SatellitePosition, LayerVisibility, AppMode } from './types';
import { useTheme } from './hooks/useTheme';
import { useAuth } from './hooks/useAuth';
import { useTranslation } from 'react-i18next';
import LoginPage from './components/auth/LoginPage';
import './App.css'; import './App.css';
// MarineTraffic-style ship classification // MarineTraffic-style ship classification
@ -74,6 +78,32 @@ function getMarineTrafficCategory(typecode?: string, category?: string): string
} }
function App() { function App() {
const { user, isLoading: authLoading, isAuthenticated, login, devLogin, logout } = useAuth();
if (authLoading) {
return (
<div
className="flex min-h-screen items-center justify-center"
style={{ backgroundColor: 'var(--kcg-bg)', color: 'var(--kcg-muted)' }}
>
Loading...
</div>
);
}
if (!isAuthenticated) {
return <LoginPage onGoogleLogin={login} onDevLogin={devLogin} />;
}
return <AuthenticatedApp user={user} onLogout={logout} />;
}
interface AuthenticatedAppProps {
user: { email: string; name: string; picture?: string } | null;
onLogout: () => Promise<void>;
}
function AuthenticatedApp(_props: AuthenticatedAppProps) {
const [appMode, setAppMode] = useState<AppMode>('live'); const [appMode, setAppMode] = useState<AppMode>('live');
const [events, setEvents] = useState<GeoEvent[]>([]); const [events, setEvents] = useState<GeoEvent[]>([]);
const [sensorData, setSensorData] = useState<SensorLog[]>([]); const [sensorData, setSensorData] = useState<SensorLog[]>([]);
@ -100,6 +130,47 @@ function App() {
militaryOnly: false, militaryOnly: false,
}); });
// Korea tab layer visibility (lifted from KoreaMap)
const [koreaLayers, setKoreaLayers] = useState<Record<string, boolean>>({
ships: true,
aircraft: true,
satellites: true,
infra: true,
cables: true,
cctv: true,
airports: true,
coastGuard: true,
navWarning: true,
osint: true,
eez: true,
piracy: true,
militaryOnly: false,
});
const toggleKoreaLayer = useCallback((key: string) => {
setKoreaLayers(prev => ({ ...prev, [key]: !prev[key] }));
}, []);
// Category filter state (shared across tabs)
const [hiddenAcCategories, setHiddenAcCategories] = useState<Set<string>>(new Set());
const [hiddenShipCategories, setHiddenShipCategories] = useState<Set<string>>(new Set());
const toggleAcCategory = useCallback((cat: string) => {
setHiddenAcCategories(prev => {
const next = new Set(prev);
if (next.has(cat)) { next.delete(cat); } else { next.add(cat); }
return next;
});
}, []);
const toggleShipCategory = useCallback((cat: string) => {
setHiddenShipCategories(prev => {
const next = new Set(prev);
if (next.has(cat)) { next.delete(cat); } else { next.add(cat); }
return next;
});
}, []);
const [flyToTarget, setFlyToTarget] = useState<FlyToTarget | null>(null); const [flyToTarget, setFlyToTarget] = useState<FlyToTarget | null>(null);
// 1시간마다 전체 데이터 강제 리프레시 (호르무즈 해협 실시간 정보 업데이트) // 1시간마다 전체 데이터 강제 리프레시 (호르무즈 해협 실시간 정보 업데이트)
@ -125,6 +196,11 @@ function App() {
const replay = useReplay(); const replay = useReplay();
const monitor = useMonitor(); const monitor = useMonitor();
const { theme, toggleTheme } = useTheme();
const { t, i18n } = useTranslation();
const toggleLang = useCallback(() => {
i18n.changeLanguage(i18n.language === 'ko' ? 'en' : 'ko');
}, [i18n]);
const isLive = appMode === 'live'; const isLive = appMode === 'live';
@ -211,7 +287,7 @@ function App() {
return () => clearInterval(interval); return () => clearInterval(interval);
}, [appMode, refreshKey]); }, [appMode, refreshKey]);
// Fetch Korea region ship data (separate pipeline) // Fetch Korea region ship data (signal-batch, 4-min cycle)
useEffect(() => { useEffect(() => {
const load = async () => { const load = async () => {
try { try {
@ -220,7 +296,7 @@ function App() {
} catch { /* keep previous */ } } catch { /* keep previous */ }
}; };
load(); load();
const interval = setInterval(load, 15_000); const interval = setInterval(load, 240_000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [appMode, refreshKey]); }, [appMode, refreshKey]);
@ -376,6 +452,24 @@ function App() {
[baseShipsKorea, currentTime, isLive], [baseShipsKorea, currentTime, isLive],
); );
// Category-filtered data for map rendering
const visibleAircraft = useMemo(
() => aircraft.filter(a => !hiddenAcCategories.has(a.category)),
[aircraft, hiddenAcCategories],
);
const visibleShips = useMemo(
() => ships.filter(s => !hiddenShipCategories.has(getMarineTrafficCategory(s.typecode, s.category))),
[ships, hiddenShipCategories],
);
const visibleAircraftKorea = useMemo(
() => aircraftKorea.filter(a => !hiddenAcCategories.has(a.category)),
[aircraftKorea, hiddenAcCategories],
);
const visibleKoreaShips = useMemo(
() => koreaShips.filter(s => !hiddenShipCategories.has(getMarineTrafficCategory(s.typecode, s.category))),
[koreaShips, hiddenShipCategories],
);
const toggleLayer = useCallback((key: keyof LayerVisibility) => { const toggleLayer = useCallback((key: keyof LayerVisibility) => {
setLayers(prev => ({ ...prev, [key]: !prev[key] })); setLayers(prev => ({ ...prev, [key]: !prev[key] }));
}, []); }, []);
@ -398,12 +492,17 @@ function App() {
() => aircraft.filter(a => a.category !== 'civilian' && a.category !== 'unknown').length, () => aircraft.filter(a => a.category !== 'civilian' && a.category !== 'unknown').length,
[aircraft], [aircraft],
); );
const koreaMilitaryCount = useMemo(
() => aircraftKorea.filter(a => a.category !== 'civilian' && a.category !== 'unknown').length,
[aircraftKorea],
);
// Ship stats // Ship stats — MT classification (matches map icon colors)
const shipsByCategory = useMemo(() => { const shipsByCategory = useMemo(() => {
const counts: Record<string, number> = {}; const counts: Record<string, number> = {};
for (const s of ships) { for (const s of ships) {
counts[s.category] = (counts[s.category] || 0) + 1; const mtCat = getMarineTrafficCategory(s.typecode, s.category);
counts[mtCat] = (counts[mtCat] || 0) + 1;
} }
return counts; return counts;
}, [ships]); }, [ships]);
@ -424,12 +523,21 @@ function App() {
const koreaChineseShips = useMemo(() => koreaShips.filter(s => s.flag === 'CN'), [koreaShips]); const koreaChineseShips = useMemo(() => koreaShips.filter(s => s.flag === 'CN'), [koreaShips]);
const koreaShipsByCategory = useMemo(() => { const koreaShipsByCategory = useMemo(() => {
const counts: Record<string, number> = {}; const counts: Record<string, number> = {};
for (const s of koreaKoreanShips) { for (const s of koreaShips) {
const mtCat = getMarineTrafficCategory(s.typecode, s.category); const mtCat = getMarineTrafficCategory(s.typecode, s.category);
counts[mtCat] = (counts[mtCat] || 0) + 1; counts[mtCat] = (counts[mtCat] || 0) + 1;
} }
return counts; return counts;
}, [koreaKoreanShips]); }, [koreaShips]);
// Korea aircraft stats
const koreaAircraftByCategory = useMemo(() => {
const counts: Record<string, number> = {};
for (const ac of aircraftKorea) {
counts[ac.category] = (counts[ac.category] || 0) + 1;
}
return counts;
}, [aircraftKorea]);
// Korea filtered ships by monitoring mode (independent toggles, additive highlight) // Korea filtered ships by monitoring mode (independent toggles, additive highlight)
const anyFilterOn = koreaFilters.illegalFishing || koreaFilters.illegalTransship || koreaFilters.darkVessel || koreaFilters.cableWatch || koreaFilters.dokdoWatch || koreaFilters.ferryWatch; const anyFilterOn = koreaFilters.illegalFishing || koreaFilters.illegalTransship || koreaFilters.darkVessel || koreaFilters.cableWatch || koreaFilters.dokdoWatch || koreaFilters.ferryWatch;
@ -701,8 +809,8 @@ function App() {
}, [koreaShips, koreaFilters.dokdoWatch, currentTime]); }, [koreaShips, koreaFilters.dokdoWatch, currentTime]);
const koreaFilteredShips = useMemo(() => { const koreaFilteredShips = useMemo(() => {
if (!anyFilterOn) return koreaShips; if (!anyFilterOn) return visibleKoreaShips;
return koreaShips.filter(s => { return visibleKoreaShips.filter(s => {
const mtCat = getMarineTrafficCategory(s.typecode, s.category); const mtCat = getMarineTrafficCategory(s.typecode, s.category);
if (koreaFilters.illegalFishing && mtCat === 'fishing' && s.flag !== 'KR') return true; if (koreaFilters.illegalFishing && mtCat === 'fishing' && s.flag !== 'KR') return true;
if (koreaFilters.illegalTransship && transshipSuspects.has(s.mmsi)) return true; if (koreaFilters.illegalTransship && transshipSuspects.has(s.mmsi)) return true;
@ -712,7 +820,7 @@ function App() {
if (koreaFilters.ferryWatch && getMarineTrafficCategory(s.typecode, s.category) === 'passenger') return true; if (koreaFilters.ferryWatch && getMarineTrafficCategory(s.typecode, s.category) === 'passenger') return true;
return false; return false;
}); });
}, [koreaShips, koreaFilters, anyFilterOn, transshipSuspects, darkVesselSet, cableWatchSet, dokdoWatchSet]); }, [visibleKoreaShips, koreaFilters, anyFilterOn, transshipSuspects, darkVesselSet, cableWatchSet, dokdoWatchSet]);
return ( return (
<div className={`app ${isLive ? 'app-live' : ''}`}> <div className={`app ${isLive ? 'app-live' : ''}`}>
@ -724,28 +832,22 @@ function App() {
onClick={() => setDashboardTab('iran')} onClick={() => setDashboardTab('iran')}
> >
<span className="dash-tab-flag">🇮🇷</span> <span className="dash-tab-flag">🇮🇷</span>
{t('tabs.iran')}
</button> </button>
<button <button
className={`dash-tab ${dashboardTab === 'korea' ? 'active' : ''}`} className={`dash-tab ${dashboardTab === 'korea' ? 'active' : ''}`}
onClick={() => setDashboardTab('korea')} onClick={() => setDashboardTab('korea')}
> >
<span className="dash-tab-flag">🇰🇷</span> <span className="dash-tab-flag">🇰🇷</span>
{t('tabs.korea')}
</button> </button>
</div> </div>
{/* Mode Toggle */} {/* Mode Toggle */}
{dashboardTab === 'iran' && ( {dashboardTab === 'iran' && (
<div className="mode-toggle"> <div className="mode-toggle">
<div style={{ <div className="flex items-center gap-1.5 font-mono text-[11px] text-kcg-danger bg-kcg-danger-bg border border-kcg-danger/30 rounded-[6px] px-2.5 py-1 font-bold animate-pulse">
display: 'flex', alignItems: 'center', gap: 6, <span className="text-[13px]"></span>
fontFamily: 'monospace', fontSize: 11, color: '#ef4444',
background: 'rgba(239,68,68,0.1)', border: '1px solid rgba(239,68,68,0.3)',
borderRadius: 6, padding: '4px 10px', fontWeight: 700,
animation: 'pulse 3s ease-in-out infinite',
}}>
<span style={{ fontSize: 13 }}></span>
D+{Math.floor((currentTime - new Date('2026-03-01T00:00:00Z').getTime()) / (1000 * 60 * 60 * 24))} D+{Math.floor((currentTime - new Date('2026-03-01T00:00:00Z').getTime()) / (1000 * 60 * 60 * 24))}
</div> </div>
<button <button
@ -753,14 +855,14 @@ function App() {
onClick={() => setAppMode('live')} onClick={() => setAppMode('live')}
> >
<span className="mode-dot-icon" /> <span className="mode-dot-icon" />
LIVE {t('mode.live')}
</button> </button>
<button <button
className={`mode-btn ${appMode === 'replay' ? 'active' : ''}`} className={`mode-btn ${appMode === 'replay' ? 'active' : ''}`}
onClick={() => setAppMode('replay')} onClick={() => setAppMode('replay')}
> >
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><polygon points="5,3 19,12 5,21" /></svg> <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><polygon points="5,3 19,12 5,21" /></svg>
REPLAY {t('mode.replay')}
</button> </button>
</div> </div>
)} )}
@ -770,50 +872,50 @@ function App() {
<button <button
className={`mode-btn ${koreaFilters.illegalFishing ? 'active live' : ''}`} className={`mode-btn ${koreaFilters.illegalFishing ? 'active live' : ''}`}
onClick={() => setKoreaFilters(prev => ({ ...prev, illegalFishing: !prev.illegalFishing }))} onClick={() => setKoreaFilters(prev => ({ ...prev, illegalFishing: !prev.illegalFishing }))}
title="불법어선 감시" title={t('filters.illegalFishing')}
> >
<span style={{ fontSize: 11 }}>🚫🐟</span> <span className="text-[11px]">🚫🐟</span>
{t('filters.illegalFishing')}
</button> </button>
<button <button
className={`mode-btn ${koreaFilters.illegalTransship ? 'active live' : ''}`} className={`mode-btn ${koreaFilters.illegalTransship ? 'active live' : ''}`}
onClick={() => setKoreaFilters(prev => ({ ...prev, illegalTransship: !prev.illegalTransship }))} onClick={() => setKoreaFilters(prev => ({ ...prev, illegalTransship: !prev.illegalTransship }))}
title="불법환적 감시" title={t('filters.illegalTransship')}
> >
<span style={{ fontSize: 11 }}></span> <span className="text-[11px]"></span>
{t('filters.illegalTransship')}
</button> </button>
<button <button
className={`mode-btn ${koreaFilters.darkVessel ? 'active live' : ''}`} className={`mode-btn ${koreaFilters.darkVessel ? 'active live' : ''}`}
onClick={() => setKoreaFilters(prev => ({ ...prev, darkVessel: !prev.darkVessel }))} onClick={() => setKoreaFilters(prev => ({ ...prev, darkVessel: !prev.darkVessel }))}
title="다크베셀 (AIS 미송출)" title={t('filters.darkVessel')}
> >
<span style={{ fontSize: 11 }}>👻</span> <span className="text-[11px]">👻</span>
{t('filters.darkVessel')}
</button> </button>
<button <button
className={`mode-btn ${koreaFilters.cableWatch ? 'active live' : ''}`} className={`mode-btn ${koreaFilters.cableWatch ? 'active live' : ''}`}
onClick={() => setKoreaFilters(prev => ({ ...prev, cableWatch: !prev.cableWatch }))} onClick={() => setKoreaFilters(prev => ({ ...prev, cableWatch: !prev.cableWatch }))}
title="해저케이블 근처 의심선박 감시" title={t('filters.cableWatch')}
> >
<span style={{ fontSize: 11 }}>🔌</span> <span className="text-[11px]">🔌</span>
{t('filters.cableWatch')}
</button> </button>
<button <button
className={`mode-btn ${koreaFilters.dokdoWatch ? 'active live' : ''}`} className={`mode-btn ${koreaFilters.dokdoWatch ? 'active live' : ''}`}
onClick={() => setKoreaFilters(prev => ({ ...prev, dokdoWatch: !prev.dokdoWatch }))} onClick={() => setKoreaFilters(prev => ({ ...prev, dokdoWatch: !prev.dokdoWatch }))}
title="독도/울릉도 영해 접근 외국선박 감시" title={t('filters.dokdoWatch')}
> >
<span style={{ fontSize: 11 }}>🏝</span> <span className="text-[11px]">🏝</span>
{t('filters.dokdoWatch')}
</button> </button>
<button <button
className={`mode-btn ${koreaFilters.ferryWatch ? 'active live' : ''}`} className={`mode-btn ${koreaFilters.ferryWatch ? 'active live' : ''}`}
onClick={() => setKoreaFilters(prev => ({ ...prev, ferryWatch: !prev.ferryWatch }))} onClick={() => setKoreaFilters(prev => ({ ...prev, ferryWatch: !prev.ferryWatch }))}
title="여객선/크루즈/페리 위치 감시" title={t('filters.ferryWatch')}
> >
<span style={{ fontSize: 11 }}>🚢</span> <span className="text-[11px]">🚢</span>
{t('filters.ferryWatch')}
</button> </button>
</div> </div>
)} )}
@ -825,35 +927,43 @@ function App() {
onClick={() => setMapMode('flat')} onClick={() => setMapMode('flat')}
> >
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="1" y="1" width="12" height="12" rx="1" stroke="currentColor" strokeWidth="1.5"/><line x1="1" y1="5" x2="13" y2="5" stroke="currentColor" strokeWidth="1"/><line x1="1" y1="9" x2="13" y2="9" stroke="currentColor" strokeWidth="1"/><line x1="5" y1="1" x2="5" y2="13" stroke="currentColor" strokeWidth="1"/><line x1="9" y1="1" x2="9" y2="13" stroke="currentColor" strokeWidth="1"/></svg> <svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="1" y="1" width="12" height="12" rx="1" stroke="currentColor" strokeWidth="1.5"/><line x1="1" y1="5" x2="13" y2="5" stroke="currentColor" strokeWidth="1"/><line x1="1" y1="9" x2="13" y2="9" stroke="currentColor" strokeWidth="1"/><line x1="5" y1="1" x2="5" y2="13" stroke="currentColor" strokeWidth="1"/><line x1="9" y1="1" x2="9" y2="13" stroke="currentColor" strokeWidth="1"/></svg>
FLAT MAP {t('mapMode.flat')}
</button> </button>
<button <button
className={`map-mode-btn ${mapMode === 'globe' ? 'active' : ''}`} className={`map-mode-btn ${mapMode === 'globe' ? 'active' : ''}`}
onClick={() => setMapMode('globe')} onClick={() => setMapMode('globe')}
> >
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><circle cx="7" cy="7" r="6" stroke="currentColor" strokeWidth="1.5"/><ellipse cx="7" cy="7" rx="3" ry="6" stroke="currentColor" strokeWidth="1"/><line x1="1" y1="7" x2="13" y2="7" stroke="currentColor" strokeWidth="1"/></svg> <svg width="14" height="14" viewBox="0 0 14 14" fill="none"><circle cx="7" cy="7" r="6" stroke="currentColor" strokeWidth="1.5"/><ellipse cx="7" cy="7" rx="3" ry="6" stroke="currentColor" strokeWidth="1"/><line x1="1" y1="7" x2="13" y2="7" stroke="currentColor" strokeWidth="1"/></svg>
GLOBE {t('mapMode.globe')}
</button> </button>
<button <button
className={`map-mode-btn ${mapMode === 'satellite' ? 'active' : ''}`} className={`map-mode-btn ${mapMode === 'satellite' ? 'active' : ''}`}
onClick={() => setMapMode('satellite')} onClick={() => setMapMode('satellite')}
> >
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><circle cx="7" cy="7" r="6" stroke="currentColor" strokeWidth="1.5"/><path d="M3 4.5C4.5 3 6 2.5 7 2.5s3 1 4.5 2.5" stroke="currentColor" strokeWidth="0.8"/><path d="M2.5 7.5C4 6 6 5 7 5s3.5 1 5 2.5" stroke="currentColor" strokeWidth="0.8"/><path d="M3 10C4.5 8.5 6 8 7 8s3 .5 4 1.5" stroke="currentColor" strokeWidth="0.8"/><circle cx="7" cy="6" r="1.5" fill="currentColor" opacity="0.3"/></svg> <svg width="14" height="14" viewBox="0 0 14 14" fill="none"><circle cx="7" cy="7" r="6" stroke="currentColor" strokeWidth="1.5"/><path d="M3 4.5C4.5 3 6 2.5 7 2.5s3 1 4.5 2.5" stroke="currentColor" strokeWidth="0.8"/><path d="M2.5 7.5C4 6 6 5 7 5s3.5 1 5 2.5" stroke="currentColor" strokeWidth="0.8"/><path d="M3 10C4.5 8.5 6 8 7 8s3 .5 4 1.5" stroke="currentColor" strokeWidth="0.8"/><circle cx="7" cy="6" r="1.5" fill="currentColor" opacity="0.3"/></svg>
{t('mapMode.satellite')}
</button> </button>
</div> </div>
)} )}
<div className="header-info"> <div className="header-info">
<div className="header-counts"> <div className="header-counts">
<span className="count-item ac-count">{aircraft.length} AC</span> <span className="count-item ac-count">{dashboardTab === 'iran' ? aircraft.length : aircraftKorea.length} AC</span>
<span className="count-item mil-count">{militaryCount} MIL</span> <span className="count-item mil-count">{dashboardTab === 'iran' ? militaryCount : koreaMilitaryCount} MIL</span>
<span className="count-item ship-count">{ships.length} SHIP</span> <span className="count-item ship-count">{dashboardTab === 'iran' ? ships.length : koreaShips.length} SHIP</span>
<span className="count-item sat-count">{satPositions.length} SAT</span> <span className="count-item sat-count">{dashboardTab === 'iran' ? satPositions.length : satPositionsKorea.length} SAT</span>
</div>
<div className="header-toggles">
<button type="button" className="header-toggle-btn" onClick={toggleLang} title="Language">
{i18n.language === 'ko' ? 'KO' : 'EN'}
</button>
<button type="button" className="header-toggle-btn" onClick={toggleTheme} title="Theme">
{theme === 'dark' ? '🌙' : '☀️'}
</button>
</div> </div>
<div className="header-status"> <div className="header-status">
<span className={`status-dot ${isLive ? 'live' : replay.state.isPlaying ? 'live' : ''}`} /> <span className={`status-dot ${isLive ? 'live' : replay.state.isPlaying ? 'live' : ''}`} />
{isLive ? 'LIVE' : replay.state.isPlaying ? 'REPLAYING' : 'PAUSED'} {isLive ? t('header.live') : replay.state.isPlaying ? t('header.replaying') : t('header.paused')}
</div> </div>
</div> </div>
</header> </header>
@ -870,9 +980,9 @@ function App() {
key="map-iran" key="map-iran"
events={isLive ? [] : mergedEvents} events={isLive ? [] : mergedEvents}
currentTime={currentTime} currentTime={currentTime}
aircraft={aircraft} aircraft={visibleAircraft}
satellites={satPositions} satellites={satPositions}
ships={ships} ships={visibleShips}
layers={layers} layers={layers}
flyToTarget={flyToTarget} flyToTarget={flyToTarget}
onFlyToDone={() => setFlyToTarget(null)} onFlyToDone={() => setFlyToTarget(null)}
@ -881,32 +991,41 @@ function App() {
<GlobeMap <GlobeMap
events={isLive ? [] : mergedEvents} events={isLive ? [] : mergedEvents}
currentTime={currentTime} currentTime={currentTime}
aircraft={aircraft} aircraft={visibleAircraft}
satellites={satPositions} satellites={satPositions}
ships={ships} ships={visibleShips}
layers={layers} layers={layers}
/> />
) : ( ) : (
<SatelliteMap <SatelliteMap
events={isLive ? [] : mergedEvents} events={isLive ? [] : mergedEvents}
currentTime={currentTime} currentTime={currentTime}
aircraft={aircraft} aircraft={visibleAircraft}
satellites={satPositions} satellites={satPositions}
ships={ships} ships={visibleShips}
layers={layers} layers={layers}
/> />
)} )}
<div className="map-overlay-left"> <div className="map-overlay-left">
<LayerPanel <LayerPanel
layers={layers} layers={layers as unknown as Record<string, boolean>}
onToggle={toggleLayer} onToggle={toggleLayer as (key: string) => void}
aircraftCount={aircraft.length}
militaryCount={militaryCount}
satelliteCount={satPositions.length}
shipCount={ships.length}
koreanShipCount={koreanShips.length}
aircraftByCategory={aircraftByCategory} aircraftByCategory={aircraftByCategory}
shipsByCategory={shipsByCategory} aircraftTotal={aircraft.length}
shipsByMtCategory={shipsByCategory}
shipTotal={ships.length}
satelliteCount={satPositions.length}
extraLayers={[
{ key: 'events', label: t('layers.events'), color: '#a855f7' },
{ key: 'koreanShips', label: `\u{1F1F0}\u{1F1F7} ${t('layers.koreanShips')}`, color: '#00e5ff', count: koreanShips.length },
{ key: 'airports', label: t('layers.airports'), color: '#f59e0b' },
{ key: 'oilFacilities', label: t('layers.oilFacilities'), color: '#d97706' },
{ key: 'sensorCharts', label: t('layers.sensorCharts'), color: '#22c55e' },
]}
hiddenAcCategories={hiddenAcCategories}
hiddenShipCategories={hiddenShipCategories}
onAcCategoryToggle={toggleAcCategory}
onShipCategoryToggle={toggleShipCategory}
/> />
</div> </div>
</div> </div>
@ -983,7 +1102,45 @@ function App() {
<> <>
<main className="app-main"> <main className="app-main">
<div className="map-panel"> <div className="map-panel">
<KoreaMap ships={koreaFilteredShips} aircraft={aircraftKorea} satellites={satPositionsKorea} militaryOnly={layers.militaryOnly} osintFeed={osintFeed} currentTime={currentTime} koreaFilters={koreaFilters} transshipSuspects={transshipSuspects} cableWatchSuspects={cableWatchSet} dokdoWatchSuspects={dokdoWatchSet} dokdoAlerts={dokdoAlerts} /> <KoreaMap
ships={koreaFilteredShips}
aircraft={visibleAircraftKorea}
satellites={satPositionsKorea}
layers={koreaLayers}
osintFeed={osintFeed}
currentTime={currentTime}
koreaFilters={koreaFilters}
transshipSuspects={transshipSuspects}
cableWatchSuspects={cableWatchSet}
dokdoWatchSuspects={dokdoWatchSet}
dokdoAlerts={dokdoAlerts}
/>
<div className="map-overlay-left">
<LayerPanel
layers={koreaLayers}
onToggle={toggleKoreaLayer}
aircraftByCategory={koreaAircraftByCategory}
aircraftTotal={aircraftKorea.length}
shipsByMtCategory={koreaShipsByCategory}
shipTotal={koreaShips.length}
satelliteCount={satPositionsKorea.length}
extraLayers={[
{ key: 'infra', label: t('layers.infra'), color: '#ffc107' },
{ key: 'cables', label: t('layers.cables'), color: '#00e5ff' },
{ key: 'cctv', label: t('layers.cctv'), color: '#ff6b6b', count: 15 },
{ key: 'airports', label: t('layers.airports'), color: '#a78bfa', count: 16 },
{ key: 'coastGuard', label: t('layers.coastGuard'), color: '#4dabf7', count: 46 },
{ key: 'navWarning', label: t('layers.navWarning'), color: '#eab308' },
{ key: 'osint', label: t('layers.osint'), color: '#ef4444' },
{ key: 'eez', label: t('layers.eez'), color: '#3b82f6' },
{ key: 'piracy', label: t('layers.piracy'), color: '#ef4444' },
]}
hiddenAcCategories={hiddenAcCategories}
hiddenShipCategories={hiddenShipCategories}
onAcCategoryToggle={toggleAcCategory}
onShipCategoryToggle={toggleShipCategory}
/>
</div>
</div> </div>
<aside className="side-panel"> <aside className="side-panel">

파일 보기

Before

Width:  |  Height:  |  크기: 4.0 KiB

After

Width:  |  Height:  |  크기: 4.0 KiB

파일 보기

@ -1,5 +1,6 @@
import { memo, useMemo, useState, useEffect } from 'react'; import { memo, useMemo, useState, useEffect } from 'react';
import { Marker, Popup, Source, Layer } from 'react-map-gl/maplibre'; import { Marker, Popup, Source, Layer } from 'react-map-gl/maplibre';
import { useTranslation } from 'react-i18next';
import type { Aircraft, AircraftCategory } from '../types'; import type { Aircraft, AircraftCategory } from '../types';
interface Props { interface Props {
@ -55,7 +56,7 @@ const ALT_COLORS: [number, string][] = [
[9000, '#FF4500'], [12000, '#FF1493'], [15000, '#BA55D3'], [9000, '#FF4500'], [12000, '#FF1493'], [15000, '#BA55D3'],
]; ];
const MIL_COLORS: Partial<Record<AircraftCategory, string>> = { const MIL_HEX: Partial<Record<AircraftCategory, string>> = {
fighter: '#ff4444', military: '#ff6600', surveillance: '#ffcc00', tanker: '#00ccff', fighter: '#ff4444', military: '#ff6600', surveillance: '#ffcc00', tanker: '#00ccff',
}; };
@ -68,22 +69,18 @@ function getAltitudeColor(altMeters: number): string {
} }
function getAircraftColor(ac: Aircraft): string { function getAircraftColor(ac: Aircraft): string {
const milColor = MIL_COLORS[ac.category]; const milColor = MIL_HEX[ac.category];
if (milColor) return milColor; if (milColor) return milColor;
if (ac.onGround) return '#555555'; if (ac.onGround) return '#555555';
return getAltitudeColor(ac.altitude); return getAltitudeColor(ac.altitude);
} }
const CATEGORY_LABELS: Record<AircraftCategory, string> = {
fighter: 'FIGHTER', tanker: 'TANKER', surveillance: 'ISR',
cargo: 'CARGO', military: 'MIL', civilian: 'CIV', unknown: '???',
};
// ═══ Planespotters.net photo API ═══ // ═══ Planespotters.net photo API ═══
interface PhotoResult { url: string; photographer: string; link: string; } interface PhotoResult { url: string; photographer: string; link: string; }
const photoCache = new Map<string, PhotoResult | null>(); const photoCache = new Map<string, PhotoResult | null>();
function AircraftPhoto({ hex }: { hex: string }) { function AircraftPhoto({ hex }: { hex: string }) {
const { t } = useTranslation('ships');
const [photo, setPhoto] = useState<PhotoResult | null | undefined>( const [photo, setPhoto] = useState<PhotoResult | null | undefined>(
photoCache.has(hex) ? photoCache.get(hex) : undefined, photoCache.has(hex) ? photoCache.get(hex) : undefined,
); );
@ -119,19 +116,19 @@ function AircraftPhoto({ hex }: { hex: string }) {
}, [hex, photo]); }, [hex, photo]);
if (photo === undefined) { if (photo === undefined) {
return <div style={{ textAlign: 'center', padding: 8, color: '#888', fontSize: 10 }}>Loading photo...</div>; return <div className="text-center p-2 text-kcg-muted text-[10px]">{t('aircraftPopup.loadingPhoto')}</div>;
} }
if (!photo) return null; if (!photo) return null;
return ( return (
<div style={{ marginBottom: 6 }}> <div className="mb-1.5">
<a href={photo.link} target="_blank" rel="noopener noreferrer"> <a href={photo.link} target="_blank" rel="noopener noreferrer">
<img src={photo.url} alt="Aircraft" <img src={photo.url} alt="Aircraft"
style={{ width: '100%', borderRadius: 4, display: 'block' }} className="w-full rounded block"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/> />
</a> </a>
{photo.photographer && ( {photo.photographer && (
<div style={{ fontSize: 9, color: '#999', marginTop: 2, textAlign: 'right' }}> <div className="text-[9px] text-[#999] mt-0.5 text-right">
&copy; {photo.photographer} &copy; {photo.photographer}
</div> </div>
)} )}
@ -188,6 +185,7 @@ export function AircraftLayer({ aircraft, militaryOnly }: Props) {
// ═══ Aircraft Marker ═══ // ═══ Aircraft Marker ═══
const AircraftMarker = memo(function AircraftMarker({ ac }: { ac: Aircraft }) { const AircraftMarker = memo(function AircraftMarker({ ac }: { ac: Aircraft }) {
const { t } = useTranslation('ships');
const [showPopup, setShowPopup] = useState(false); const [showPopup, setShowPopup] = useState(false);
const color = getAircraftColor(ac); const color = getAircraftColor(ac);
const shape = getShape(ac); const shape = getShape(ac);
@ -198,10 +196,11 @@ const AircraftMarker = memo(function AircraftMarker({ ac }: { ac: Aircraft }) {
return ( return (
<> <>
<Marker longitude={ac.lng} latitude={ac.lat} anchor="center"> <Marker longitude={ac.lng} latitude={ac.lat} anchor="center">
<div style={{ position: 'relative' }}> <div className="relative">
<div <div
className="cursor-pointer"
style={{ style={{
width: size, height: size, cursor: 'pointer', width: size, height: size,
transform: `rotate(${ac.heading}deg)`, transform: `rotate(${ac.heading}deg)`,
filter: 'drop-shadow(0 0 2px rgba(0,0,0,0.7))', filter: 'drop-shadow(0 0 2px rgba(0,0,0,0.7))',
}} }}
@ -224,37 +223,37 @@ const AircraftMarker = memo(function AircraftMarker({ ac }: { ac: Aircraft }) {
<Popup longitude={ac.lng} latitude={ac.lat} <Popup longitude={ac.lng} latitude={ac.lat}
onClose={() => setShowPopup(false)} closeOnClick={false} onClose={() => setShowPopup(false)} closeOnClick={false}
anchor="bottom" maxWidth="300px" className="gl-popup"> anchor="bottom" maxWidth="300px" className="gl-popup">
<div style={{ minWidth: 240, maxWidth: 300, fontFamily: 'monospace', fontSize: 12 }}> <div className="min-w-[240px] max-w-[300px] font-mono text-xs">
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}> <div className="flex items-center gap-2 mb-1.5">
<strong style={{ fontSize: 14 }}>{ac.callsign || 'N/A'}</strong> <strong className="text-sm">{ac.callsign || 'N/A'}</strong>
<span style={{ <span
background: color, color: '#000', padding: '1px 6px', className="px-1.5 py-px rounded text-[10px] font-bold ml-auto text-black"
borderRadius: 3, fontSize: 10, fontWeight: 700, marginLeft: 'auto', style={{ background: color }}
}}> >
{CATEGORY_LABELS[ac.category]} {t(`aircraftLabel.${ac.category}`)}
</span> </span>
</div> </div>
<AircraftPhoto hex={ac.icao24} /> <AircraftPhoto hex={ac.icao24} />
<table style={{ width: '100%', fontSize: 11, borderCollapse: 'collapse' }}> <table className="w-full text-[11px] border-collapse">
<tbody> <tbody>
<tr><td style={{ color: '#888', paddingRight: 8 }}>Hex</td><td><strong>{ac.icao24.toUpperCase()}</strong></td></tr> <tr><td className="text-kcg-muted pr-2">{t('aircraftPopup.hex')}</td><td><strong>{ac.icao24.toUpperCase()}</strong></td></tr>
{ac.registration && <tr><td style={{ color: '#888' }}>Reg.</td><td><strong>{ac.registration}</strong></td></tr>} {ac.registration && <tr><td className="text-kcg-muted">{t('aircraftPopup.reg')}</td><td><strong>{ac.registration}</strong></td></tr>}
{ac.operator && <tr><td style={{ color: '#888' }}>Operator</td><td>{ac.operator}</td></tr>} {ac.operator && <tr><td className="text-kcg-muted">{t('aircraftPopup.operator')}</td><td>{ac.operator}</td></tr>}
{ac.typecode && ( {ac.typecode && (
<tr><td style={{ color: '#888' }}>Type</td> <tr><td className="text-kcg-muted">{t('aircraftPopup.type')}</td>
<td><strong>{ac.typecode}</strong>{ac.typeDesc ? `${ac.typeDesc}` : ''}</td></tr> <td><strong>{ac.typecode}</strong>{ac.typeDesc ? `${ac.typeDesc}` : ''}</td></tr>
)} )}
{ac.squawk && <tr><td style={{ color: '#888' }}>Squawk</td><td>{ac.squawk}</td></tr>} {ac.squawk && <tr><td className="text-kcg-muted">{t('aircraftPopup.squawk')}</td><td>{ac.squawk}</td></tr>}
<tr><td style={{ color: '#888' }}>Alt</td> <tr><td className="text-kcg-muted">{t('aircraftPopup.alt')}</td>
<td>{ac.onGround ? 'GROUND' : `${Math.round(ac.altitude * 3.281).toLocaleString()} ft`}</td></tr> <td>{ac.onGround ? t('aircraftPopup.ground') : `${Math.round(ac.altitude * 3.281).toLocaleString()} ft`}</td></tr>
<tr><td style={{ color: '#888' }}>Speed</td><td>{Math.round(ac.velocity * 1.944)} kts</td></tr> <tr><td className="text-kcg-muted">{t('aircraftPopup.speed')}</td><td>{Math.round(ac.velocity * 1.944)} kts</td></tr>
<tr><td style={{ color: '#888' }}>Hdg</td><td>{Math.round(ac.heading)}&deg;</td></tr> <tr><td className="text-kcg-muted">{t('aircraftPopup.hdg')}</td><td>{Math.round(ac.heading)}&deg;</td></tr>
<tr><td style={{ color: '#888' }}>V/S</td><td>{Math.round(ac.verticalRate * 196.85)} fpm</td></tr> <tr><td className="text-kcg-muted">{t('aircraftPopup.verticalSpeed')}</td><td>{Math.round(ac.verticalRate * 196.85)} fpm</td></tr>
</tbody> </tbody>
</table> </table>
<div style={{ marginTop: 6, fontSize: 10, textAlign: 'right' }}> <div className="mt-1.5 text-[10px] text-right">
<a href={`https://globe.airplanes.live/?icao=${ac.icao24}`} <a href={`https://globe.airplanes.live/?icao=${ac.icao24}`}
target="_blank" rel="noopener noreferrer" style={{ color: '#60a5fa' }}> target="_blank" rel="noopener noreferrer" className="text-kcg-accent">
Airplanes.live &rarr; Airplanes.live &rarr;
</a> </a>
</div> </div>

파일 보기

@ -0,0 +1,257 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { Marker, Popup } from 'react-map-gl/maplibre';
import { useTranslation } from 'react-i18next';
import Hls from 'hls.js';
import { KOREA_CCTV_CAMERAS } from '../services/cctv';
import type { CctvCamera } from '../services/cctv';
const REGION_COLOR: Record<string, string> = {
'제주': '#ff6b6b',
'남해': '#ffa94d',
'서해': '#69db7c',
'동해': '#74c0fc',
};
/** KHOA HLS → vite 프록시 경유 */
function toProxyUrl(cam: CctvCamera): string {
return cam.streamUrl.replace('https://www.khoa.go.kr', '/api/khoa-hls');
}
export function CctvLayer() {
const { t } = useTranslation('ships');
const [selected, setSelected] = useState<CctvCamera | null>(null);
const [streamCam, setStreamCam] = useState<CctvCamera | null>(null);
return (
<>
{KOREA_CCTV_CAMERAS.map(cam => {
const color = REGION_COLOR[cam.region] || '#aaa';
return (
<Marker key={cam.id} longitude={cam.lng} latitude={cam.lat} anchor="center"
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(cam); }}>
<div
className="relative cursor-pointer flex flex-col items-center"
style={{ filter: `drop-shadow(0 0 2px ${color}88)` }}
>
<svg width={14} height={14} viewBox="0 0 24 24" fill="none">
<rect x="2" y="5" width="15" height="13" rx="2" fill={color} stroke="#fff" strokeWidth="0.8" />
<polygon points="17,8 23,5 23,18 17,15" fill={color} stroke="#fff" strokeWidth="0.5" />
<circle cx="6" cy="8.5" r="2.5" fill="#ff0000">
<animate attributeName="opacity" values="1;0.3;1" dur="1.5s" repeatCount="indefinite" />
</circle>
</svg>
<div
className="text-[6px] text-white mt-0 whitespace-nowrap font-bold tracking-wide"
style={{ textShadow: `0 0 3px ${color}, 0 0 2px #000, 0 0 2px #000` }}
>
{cam.name.length > 8 ? cam.name.slice(0, 8) + '..' : cam.name}
</div>
</div>
</Marker>
);
})}
{selected && (
<Popup longitude={selected.lng} latitude={selected.lat}
onClose={() => setSelected(null)} closeOnClick={false}
anchor="bottom" maxWidth="280px" className="gl-popup">
<div className="font-mono text-xs min-w-[200px]">
<div
className="px-2 py-1 rounded-t font-bold text-[13px] flex items-center gap-1.5 -mx-2.5 -mt-2.5 mb-2 text-black"
style={{ background: REGION_COLOR[selected.region] || '#888' }}
>
<span>📹</span> {selected.name}
</div>
<div className="flex gap-1 mb-1.5 flex-wrap">
<span className="bg-kcg-success text-white px-1.5 py-px rounded text-[10px] font-bold">
{t('cctv.live')}
</span>
<span
className="px-1.5 py-px rounded text-[10px] font-bold text-black"
style={{ background: REGION_COLOR[selected.region] || '#888' }}
>{selected.region}</span>
<span className="bg-kcg-border text-kcg-text-secondary px-1.5 py-px rounded text-[10px]">
{t(`cctv.type.${selected.type}`, { defaultValue: selected.type })}
</span>
<span className="bg-kcg-card text-kcg-muted px-1.5 py-px rounded text-[10px]">
{t('cctv.khoa')}
</span>
</div>
<div className="text-[11px] flex flex-col gap-0.5">
<div className="text-[9px] text-kcg-dim">
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
</div>
<button
onClick={() => { setStreamCam(selected); setSelected(null); }}
className="inline-flex items-center justify-center gap-1 bg-kcg-accent text-white px-2.5 py-1 rounded text-[11px] font-bold mt-1 border-none cursor-pointer font-mono"
>
📺 {t('cctv.viewStream')}
</button>
</div>
</div>
</Popup>
)}
{/* CCTV HLS Stream Modal */}
{streamCam && (
<CctvStreamModal cam={streamCam} onClose={() => setStreamCam(null)} />
)}
</>
);
}
/** KHOA HLS 영상 모달 — 지도 하단 오버레이 */
function CctvStreamModal({ cam, onClose }: { cam: CctvCamera; onClose: () => void }) {
const { t } = useTranslation('ships');
const videoRef = useRef<HTMLVideoElement>(null);
const hlsRef = useRef<Hls | null>(null);
const [status, setStatus] = useState<'loading' | 'playing' | 'error'>('loading');
const destroyHls = useCallback(() => {
if (hlsRef.current) {
hlsRef.current.destroy();
hlsRef.current = null;
}
}, []);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const proxied = toProxyUrl(cam);
setStatus('loading');
if (Hls.isSupported()) {
destroyHls();
const hls = new Hls({
enableWorker: true,
lowLatencyMode: true,
maxBufferLength: 10,
maxMaxBufferLength: 30,
});
hlsRef.current = hls;
hls.loadSource(proxied);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
setStatus('playing');
video.play().catch(() => {});
});
hls.on(Hls.Events.ERROR, (_event, data) => {
if (data.fatal) setStatus('error');
});
return () => destroyHls();
}
// Safari 네이티브 HLS
if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = proxied;
const onLoaded = () => setStatus('playing');
const onError = () => setStatus('error');
video.addEventListener('loadeddata', onLoaded);
video.addEventListener('error', onError);
video.play().catch(() => {});
return () => {
video.removeEventListener('loadeddata', onLoaded);
video.removeEventListener('error', onError);
};
}
setStatus('error');
return () => destroyHls();
}, [cam, destroyHls]);
const color = REGION_COLOR[cam.region] || '#888';
return (
/* Backdrop */
<div
onClick={onClose}
className="fixed inset-0 z-[9999] flex items-center justify-center backdrop-blur-sm"
style={{ background: 'rgba(0,0,0,0.6)' }}
>
{/* Modal */}
<div
onClick={e => e.stopPropagation()}
className="w-[640px] max-w-[90vw] bg-kcg-bg rounded-lg overflow-hidden"
style={{
border: `1px solid ${color}`,
boxShadow: `0 0 40px ${color}33, 0 4px 30px rgba(0,0,0,0.8)`,
}}
>
{/* Header */}
<div className="flex items-center justify-between px-3.5 py-2 bg-kcg-overlay border-b border-[#222]">
<div className="flex items-center gap-2 font-mono text-[11px] text-kcg-text">
<span
className="text-white px-1.5 py-px rounded text-[9px] font-bold"
style={{
background: status === 'playing' ? '#22c55e' : status === 'loading' ? '#eab308' : '#ef4444',
}}
>
{status === 'playing' ? t('cctv.live') : status === 'loading' ? t('cctv.connecting') : 'ERROR'}
</span>
<span className="font-bold">📹 {cam.name}</span>
<span
className="px-1.5 py-px rounded text-[9px] font-bold text-black"
style={{ background: color }}
>{cam.region}</span>
</div>
<button
onClick={onClose}
className="bg-kcg-border border-none text-white w-6 h-6 rounded cursor-pointer text-sm font-bold flex items-center justify-center"
></button>
</div>
{/* Video */}
<div className="relative w-full bg-black" style={{ aspectRatio: '16/9' }}>
<video
ref={videoRef}
className="w-full h-full object-contain"
muted autoPlay playsInline
/>
{status === 'loading' && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/80">
<div className="text-[28px] opacity-40 mb-2">📹</div>
<div className="text-[11px] text-kcg-muted font-mono">{t('cctv.connectingEllipsis')}</div>
</div>
)}
{status === 'error' && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/80">
<div className="text-[28px] opacity-40 mb-2"></div>
<div className="text-xs text-kcg-danger font-mono mb-2">{t('cctv.connectionFailed')}</div>
<a
href={cam.url}
target="_blank"
rel="noopener noreferrer"
className="text-[10px] text-kcg-accent font-mono underline"
>{t('cctv.viewOnBadatime')}</a>
</div>
)}
{status === 'playing' && (
<>
<div className="absolute top-2.5 left-2.5 flex items-center gap-1.5">
<span className="text-[10px] font-bold font-mono px-2 py-0.5 rounded bg-black/70 text-white">
{cam.name}
</span>
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-[rgba(239,68,68,0.3)] text-[#f87171]">
{t('cctv.rec')}
</span>
</div>
<div className="absolute bottom-2.5 left-2.5 text-[9px] font-mono px-2 py-0.5 rounded bg-black/70 text-kcg-muted">
{cam.lat.toFixed(4)}°N {cam.lng.toFixed(4)}°E · {t('cctv.khoa')}
</div>
</>
)}
</div>
{/* Footer info */}
<div className="flex items-center justify-between px-3.5 py-1.5 bg-kcg-overlay border-t border-[#222] font-mono text-[9px] text-kcg-dim">
<span>{cam.lat.toFixed(4)}°N {cam.lng.toFixed(4)}°E</span>
<span>{t('cctv.khoaFull')}</span>
</div>
</div>
</div>
);
}

파일 보기

@ -1,4 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Marker, Popup } from 'react-map-gl/maplibre'; import { Marker, Popup } from 'react-map-gl/maplibre';
import { COAST_GUARD_FACILITIES, CG_TYPE_LABEL } from '../services/coastGuard'; import { COAST_GUARD_FACILITIES, CG_TYPE_LABEL } from '../services/coastGuard';
import type { CoastGuardFacility, CoastGuardType } from '../services/coastGuard'; import type { CoastGuardFacility, CoastGuardType } from '../services/coastGuard';
@ -25,32 +26,25 @@ function CoastGuardIcon({ type, size }: { type: CoastGuardType; size: number })
const isVts = type === 'vts'; const isVts = type === 'vts';
if (isVts) { if (isVts) {
// VTS: 레이더/안테나 아이콘
return ( return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none"> <svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke={color} strokeWidth="1.2" /> <circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke={color} strokeWidth="1.2" />
{/* 안테나 */}
<line x1="12" y1="18" x2="12" y2="10" stroke={color} strokeWidth="1.5" /> <line x1="12" y1="18" x2="12" y2="10" stroke={color} strokeWidth="1.5" />
<circle cx="12" cy="9" r="2" fill="none" stroke={color} strokeWidth="1" /> <circle cx="12" cy="9" r="2" fill="none" stroke={color} strokeWidth="1" />
{/* 전파 */}
<path d="M7 7 Q12 3 17 7" fill="none" stroke={color} strokeWidth="0.8" opacity="0.6" /> <path d="M7 7 Q12 3 17 7" fill="none" stroke={color} strokeWidth="0.8" opacity="0.6" />
<path d="M9 5.5 Q12 3 15 5.5" fill="none" stroke={color} strokeWidth="0.8" opacity="0.4" /> <path d="M9 5.5 Q12 3 15 5.5" fill="none" stroke={color} strokeWidth="0.8" opacity="0.4" />
</svg> </svg>
); );
} }
// 해경 로고: 방패 + 앵커
return ( return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none"> <svg width={size} height={size} viewBox="0 0 24 24" fill="none">
{/* 방패 배경 */}
<path d="M12 2 L20 6 L20 13 Q20 19 12 22 Q4 19 4 13 L4 6 Z" <path d="M12 2 L20 6 L20 13 Q20 19 12 22 Q4 19 4 13 L4 6 Z"
fill="rgba(0,0,0,0.6)" stroke={color} strokeWidth="1.2" /> fill="rgba(0,0,0,0.6)" stroke={color} strokeWidth="1.2" />
{/* 앵커 */}
<circle cx="12" cy="9" r="2" fill="none" stroke="#fff" strokeWidth="1" /> <circle cx="12" cy="9" r="2" fill="none" stroke="#fff" strokeWidth="1" />
<line x1="12" y1="11" x2="12" y2="18" stroke="#fff" strokeWidth="1" /> <line x1="12" y1="11" x2="12" y2="18" stroke="#fff" strokeWidth="1" />
<path d="M8 16 Q12 20 16 16" fill="none" stroke="#fff" strokeWidth="1" /> <path d="M8 16 Q12 20 16 16" fill="none" stroke="#fff" strokeWidth="1" />
<line x1="9" y1="13" x2="15" y2="13" stroke="#fff" strokeWidth="0.8" /> <line x1="9" y1="13" x2="15" y2="13" stroke="#fff" strokeWidth="0.8" />
{/* 별 (본청/지방청) */}
{(type === 'hq' || type === 'regional') && ( {(type === 'hq' || type === 'regional') && (
<circle cx="12" cy="9" r="1" fill={color} /> <circle cx="12" cy="9" r="1" fill={color} />
)} )}
@ -60,6 +54,7 @@ function CoastGuardIcon({ type, size }: { type: CoastGuardType; size: number })
export function CoastGuardLayer() { export function CoastGuardLayer() {
const [selected, setSelected] = useState<CoastGuardFacility | null>(null); const [selected, setSelected] = useState<CoastGuardFacility | null>(null);
const { t } = useTranslation();
return ( return (
<> <>
@ -69,25 +64,23 @@ export function CoastGuardLayer() {
<Marker key={f.id} longitude={f.lng} latitude={f.lat} anchor="center" <Marker key={f.id} longitude={f.lng} latitude={f.lat} anchor="center"
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(f); }}> onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(f); }}>
<div style={{ <div style={{
cursor: 'pointer', display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer',
filter: `drop-shadow(0 0 3px ${TYPE_COLOR[f.type]}66)`, filter: `drop-shadow(0 0 3px ${TYPE_COLOR[f.type]}66)`,
}}> }} className="flex flex-col items-center">
<CoastGuardIcon type={f.type} size={size} /> <CoastGuardIcon type={f.type} size={size} />
{(f.type === 'hq' || f.type === 'regional') && ( {(f.type === 'hq' || f.type === 'regional') && (
<div style={{ <div style={{
fontSize: 6, color: '#fff', marginTop: 1, fontSize: 6,
textShadow: `0 0 3px ${TYPE_COLOR[f.type]}, 0 0 2px #000`, textShadow: `0 0 3px ${TYPE_COLOR[f.type]}, 0 0 2px #000`,
whiteSpace: 'nowrap', fontWeight: 700, }} className="mt-px whitespace-nowrap font-bold text-white">
}}>
{f.name.replace('해양경찰청', '').replace('지방', '').trim() || '본청'} {f.name.replace('해양경찰청', '').replace('지방', '').trim() || '본청'}
</div> </div>
)} )}
{f.type === 'vts' && ( {f.type === 'vts' && (
<div style={{ <div style={{
fontSize: 5, color: '#da77f2', marginTop: 0, fontSize: 5,
textShadow: '0 0 3px #da77f2, 0 0 2px #000', textShadow: '0 0 3px #da77f2, 0 0 2px #000',
whiteSpace: 'nowrap', fontWeight: 700, letterSpacing: 0.5, }} className="whitespace-nowrap font-bold tracking-wider text-[#da77f2]">
}}>
VTS VTS
</div> </div>
)} )}
@ -100,27 +93,23 @@ export function CoastGuardLayer() {
<Popup longitude={selected.lng} latitude={selected.lat} <Popup longitude={selected.lng} latitude={selected.lat}
onClose={() => setSelected(null)} closeOnClick={false} onClose={() => setSelected(null)} closeOnClick={false}
anchor="bottom" maxWidth="280px" className="gl-popup"> anchor="bottom" maxWidth="280px" className="gl-popup">
<div style={{ fontFamily: 'monospace', fontSize: 12, minWidth: 200 }}> <div className="min-w-[200px] font-mono text-xs">
<div style={{ <div style={{
background: TYPE_COLOR[selected.type], color: '#000', background: TYPE_COLOR[selected.type],
padding: '4px 8px', borderRadius: '4px 4px 0 0', }} className="-mx-2.5 -mt-2.5 mb-2 flex items-center gap-1.5 rounded-t px-2 py-1 text-[13px] font-bold text-black">
margin: '-10px -10px 8px -10px',
fontWeight: 700, fontSize: 13,
display: 'flex', alignItems: 'center', gap: 6,
}}>
{selected.name} {selected.name}
</div> </div>
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}> <div className="mb-1.5 flex flex-wrap gap-1">
<span style={{ <span style={{
background: TYPE_COLOR[selected.type], color: '#000', background: TYPE_COLOR[selected.type],
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700, }} className="rounded-sm px-1.5 py-px text-[10px] font-bold text-black">
}}>{CG_TYPE_LABEL[selected.type]}</span> {CG_TYPE_LABEL[selected.type]}
<span style={{ </span>
background: '#1a1a2e', color: '#4dabf7', <span className="rounded-sm bg-kcg-card px-1.5 py-px text-[10px] font-bold text-[#4dabf7]">
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700, {t('coastGuard.agency')}
}}></span> </span>
</div> </div>
<div style={{ fontSize: 9, color: '#666' }}> <div className="text-[9px] text-kcg-dim">
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E {selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
</div> </div>
</div> </div>

파일 보기

@ -1,4 +1,5 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import type { GeoEvent, Ship } from '../types'; import type { GeoEvent, Ship } from '../types';
import type { OsintItem } from '../services/osint'; import type { OsintItem } from '../services/osint';
@ -250,16 +251,17 @@ const TYPE_LABELS: Record<GeoEvent['type'], string> = {
intercept: 'INTERCEPT', intercept: 'INTERCEPT',
alert: 'ALERT', alert: 'ALERT',
impact: 'IMPACT', impact: 'IMPACT',
osint: 'OSINT',
}; };
const TYPE_COLORS: Record<GeoEvent['type'], string> = { const TYPE_COLORS: Record<GeoEvent['type'], string> = {
airstrike: '#ef4444', airstrike: 'var(--kcg-event-airstrike)',
explosion: '#f97316', explosion: 'var(--kcg-event-explosion)',
missile_launch: '#eab308', missile_launch: 'var(--kcg-event-missile)',
intercept: '#3b82f6', intercept: 'var(--kcg-event-intercept)',
alert: '#a855f7', alert: 'var(--kcg-event-alert)',
impact: '#ff0000', impact: 'var(--kcg-event-impact)',
osint: '#06b6d4', osint: 'var(--kcg-event-osint)',
}; };
// MarineTraffic-style ship type classification // MarineTraffic-style ship type classification
@ -282,54 +284,81 @@ function getShipMTCategory(typecode?: string, category?: string): string {
return 'unspecified'; return 'unspecified';
} }
// MarineTraffic-style category labels and colors // MarineTraffic-style category colors (labels come from i18n)
const MT_CATEGORIES: Record<string, { label: string; color: string }> = { const MT_CATEGORY_COLORS: Record<string, string> = {
cargo: { label: '화물선', color: '#8bc34a' }, // green cargo: '#8bc34a',
tanker: { label: '유조선', color: '#e91e63' }, // red/pink tanker: '#e91e63',
passenger: { label: '여객선', color: '#2196f3' }, // blue passenger: '#2196f3',
high_speed: { label: '고속선', color: '#ff9800' }, // orange high_speed: '#ff9800',
tug_special: { label: '예인선/특수선', color: '#00bcd4' }, // teal tug_special: '#00bcd4',
fishing: { label: '어선', color: '#ff5722' }, // deep orange fishing: '#ff5722',
pleasure: { label: '레저선', color: '#9c27b0' }, // purple pleasure: '#9c27b0',
military: { label: '군함', color: '#607d8b' }, // blue-grey military: '#607d8b',
unspecified: { label: '미분류', color: '#9e9e9e' }, // grey unspecified: '#9e9e9e',
}; };
const NEWS_CATEGORY_STYLE: Record<BreakingNews['category'], { icon: string; color: string; label: string }> = { const NEWS_CATEGORY_ICONS: Record<BreakingNews['category'], string> = {
trump: { icon: '🇺🇸', color: '#ef4444', label: '트럼프' }, trump: '\u{1F1FA}\u{1F1F8}',
oil: { icon: '🛢️', color: '#f59e0b', label: '유가' }, oil: '\u{1F6E2}\u{FE0F}',
diplomacy: { icon: '🌐', color: '#8b5cf6', label: '외교' }, diplomacy: '\u{1F310}',
economy: { icon: '📊', color: '#3b82f6', label: '경제' }, economy: '\u{1F4CA}',
}; };
// OSINT category styles // OSINT category icons (labels come from i18n)
const OSINT_CAT_STYLE: Record<string, { icon: string; color: string; label: string }> = { const OSINT_CAT_ICONS: Record<string, string> = {
military: { icon: '🎯', color: '#ef4444', label: '군사' }, military: '\u{1F3AF}',
oil: { icon: '🛢', color: '#f59e0b', label: '에너지' }, oil: '\u{1F6E2}',
diplomacy: { icon: '🌐', color: '#8b5cf6', label: '외교' }, diplomacy: '\u{1F310}',
shipping: { icon: '🚢', color: '#06b6d4', label: '해운' }, shipping: '\u{1F6A2}',
nuclear: { icon: '☢', color: '#f97316', label: '핵' }, nuclear: '\u{2622}',
maritime_accident: { icon: '🚨', color: '#ef4444', label: '해양사고' }, maritime_accident: '\u{1F6A8}',
fishing: { icon: '🐟', color: '#22c55e', label: '어선/수산' }, fishing: '\u{1F41F}',
maritime_traffic: { icon: '🚢', color: '#3b82f6', label: '해상교통' }, maritime_traffic: '\u{1F6A2}',
general: { icon: '📰', color: '#6b7280', label: '일반' }, general: '\u{1F4F0}',
};
// OSINT category colors
const OSINT_CAT_COLORS: Record<string, string> = {
military: '#ef4444',
oil: '#f59e0b',
diplomacy: '#8b5cf6',
shipping: '#06b6d4',
nuclear: '#f97316',
maritime_accident: '#ef4444',
fishing: '#22c55e',
maritime_traffic: '#3b82f6',
general: '#6b7280',
};
// NEWS category colors
const NEWS_CATEGORY_COLORS: Record<BreakingNews['category'], string> = {
trump: '#ef4444',
oil: '#f59e0b',
diplomacy: '#8b5cf6',
economy: '#3b82f6',
}; };
const EMPTY_OSINT: OsintItem[] = []; const EMPTY_OSINT: OsintItem[] = [];
const EMPTY_SHIPS: import('../types').Ship[] = []; const EMPTY_SHIPS: import('../types').Ship[] = [];
function timeAgo(ts: number): string { function useTimeAgo() {
const diff = Date.now() - ts; const { t } = useTranslation('common');
const mins = Math.floor(diff / 60000); return (ts: number): string => {
if (mins < 1) return '방금'; const diff = Date.now() - ts;
if (mins < 60) return `${mins}분 전`; const mins = Math.floor(diff / 60000);
const hours = Math.floor(mins / 60); if (mins < 1) return t('time.justNow');
if (hours < 24) return `${hours}시간 전`; if (mins < 60) return t('time.minutesAgo', { count: mins });
const days = Math.floor(hours / 24); const hours = Math.floor(mins / 60);
return `${days}일 전`; if (hours < 24) return t('time.hoursAgo', { count: hours });
const days = Math.floor(hours / 24);
return t('time.daysAgo', { count: days });
};
} }
export function EventLog({ events, currentTime, totalShipCount: _totalShipCount, koreanShips, koreanShipsByCategory: _koreanShipsByCategory, chineseShips = [], osintFeed = EMPTY_OSINT, isLive = false, dashboardTab = 'iran', onTabChange: _onTabChange, ships = EMPTY_SHIPS }: Props) { export function EventLog({ events, currentTime, totalShipCount: _totalShipCount, koreanShips, koreanShipsByCategory: _koreanShipsByCategory, chineseShips = [], osintFeed = EMPTY_OSINT, isLive = false, dashboardTab = 'iran', onTabChange: _onTabChange, ships = EMPTY_SHIPS }: Props) {
const { t } = useTranslation(['common', 'events', 'ships']);
const timeAgo = useTimeAgo();
const visibleEvents = useMemo( const visibleEvents = useMemo(
() => events.filter(e => e.timestamp <= currentTime).reverse(), () => events.filter(e => e.timestamp <= currentTime).reverse(),
[events, currentTime], [events, currentTime],
@ -374,17 +403,18 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
<div className="breaking-news-section"> <div className="breaking-news-section">
<div className="breaking-news-header"> <div className="breaking-news-header">
<span className="breaking-flash">BREAKING</span> <span className="breaking-flash">BREAKING</span>
<span className="breaking-title"> / </span> <span className="breaking-title">{t('events:news.breakingTitle')}</span>
</div> </div>
<div className="breaking-news-list"> <div className="breaking-news-list">
{visibleNews.map(n => { {visibleNews.map(n => {
const style = NEWS_CATEGORY_STYLE[n.category]; const catColor = NEWS_CATEGORY_COLORS[n.category];
const catIcon = NEWS_CATEGORY_ICONS[n.category];
const isRecent = currentTime - n.timestamp < 2 * HOUR_MS; const isRecent = currentTime - n.timestamp < 2 * HOUR_MS;
return ( return (
<div key={n.id} className={`breaking-news-item${isRecent ? ' breaking-new' : ''}`}> <div key={n.id} className={`breaking-news-item${isRecent ? ' breaking-new' : ''}`}>
<div className="breaking-news-top"> <div className="breaking-news-top">
<span className="breaking-cat-tag" style={{ background: style.color }}> <span className="breaking-cat-tag" style={{ background: catColor }}>
{style.icon} {style.label} {catIcon} {t(`events:news.categoryLabel.${n.category}`)}
</span> </span>
<span className="breaking-news-time"> <span className="breaking-news-time">
{new Date(n.timestamp + 9 * 3600_000).toISOString().slice(5, 16).replace('T', ' ')} {new Date(n.timestamp + 9 * 3600_000).toISOString().slice(5, 16).replace('T', ' ')}
@ -403,24 +433,26 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
{koreanShips.length > 0 && ( {koreanShips.length > 0 && (
<div className="iran-ship-summary"> <div className="iran-ship-summary">
<div className="area-ship-header"> <div className="area-ship-header">
<span className="area-ship-icon">🇰🇷</span> <span className="area-ship-icon">{'\u{1F1F0}\u{1F1F7}'}</span>
<span className="area-ship-title"> </span> <span className="area-ship-title">{t('ships:shipStatus.koreanTitle')}</span>
<span className="area-ship-total" style={{ color: '#ef4444' }}>{koreanShips.length}</span> <span className="area-ship-total text-kcg-danger">{koreanShips.length}{t('common:units.vessels')}</span>
</div> </div>
<div className="iran-mil-list"> <div className="iran-mil-list">
{koreanShips.slice(0, 30).map(s => { {koreanShips.slice(0, 30).map(s => {
const mt = MT_CATEGORIES[getShipMTCategory(s.typecode, s.category)] || { label: '기타', color: '#888' }; const cat = getShipMTCategory(s.typecode, s.category);
const mtColor = MT_CATEGORY_COLORS[cat] || '#888';
const mtLabel = t(`ships:mtType.${cat}`, { defaultValue: t('ships:mtType.unspecified') });
return ( return (
<div key={s.mmsi} className="iran-mil-item"> <div key={s.mmsi} className="iran-mil-item">
<span className="iran-mil-flag">🇰🇷</span> <span className="iran-mil-flag">{'\u{1F1F0}\u{1F1F7}'}</span>
<span className="iran-mil-name">{s.name}</span> <span className="iran-mil-name">{s.name}</span>
<span className="iran-mil-cat" style={{ color: mt.color, background: `${mt.color}22` }}> <span className="iran-mil-cat" style={{ color: mtColor, background: `${mtColor}22` }}>
{mt.label} {mtLabel}
</span> </span>
{s.speed != null && s.speed > 0.5 ? ( {s.speed != null && s.speed > 0.5 ? (
<span style={{ fontSize: 9, color: '#22c55e', marginLeft: 'auto' }}>{s.speed.toFixed(1)}kn</span> <span className="ml-auto text-[9px] text-kcg-success">{s.speed.toFixed(1)}kn</span>
) : ( ) : (
<span style={{ fontSize: 9, color: '#ef4444', marginLeft: 'auto' }}></span> <span className="ml-auto text-[9px] text-kcg-danger">{t('ships:status.anchored')}</span>
)} )}
</div> </div>
); );
@ -434,12 +466,13 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
<> <>
<div className="osint-header"> <div className="osint-header">
<span className="osint-live-dot" /> <span className="osint-live-dot" />
<span className="osint-title">OSINT LIVE FEED</span> <span className="osint-title">{t('events:osint.liveTitle')}</span>
<span className="osint-count">{osintFeed.length}</span> <span className="osint-count">{osintFeed.length}</span>
</div> </div>
<div className="osint-list"> <div className="osint-list">
{osintFeed.map(item => { {osintFeed.map(item => {
const style = OSINT_CAT_STYLE[item.category] || OSINT_CAT_STYLE.general; const catColor = OSINT_CAT_COLORS[item.category] || OSINT_CAT_COLORS.general;
const catIcon = OSINT_CAT_ICONS[item.category] || OSINT_CAT_ICONS.general;
const isRecent = Date.now() - item.timestamp < 3600_000; const isRecent = Date.now() - item.timestamp < 3600_000;
return ( return (
<a <a
@ -450,8 +483,8 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<div className="osint-item-top"> <div className="osint-item-top">
<span className="osint-cat-tag" style={{ background: style.color }}> <span className="osint-cat-tag" style={{ background: catColor }}>
{style.icon} {style.label} {catIcon} {t(`events:osint.categoryLabel.${item.category}`, { defaultValue: t('events:osint.categoryLabel.general') })}
</span> </span>
{item.language === 'ko' && <span className="osint-lang-tag">KR</span>} {item.language === 'ko' && <span className="osint-lang-tag">KR</span>}
<span className="osint-time">{timeAgo(item.timestamp)}</span> <span className="osint-time">{timeAgo(item.timestamp)}</span>
@ -467,23 +500,23 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
{isLive && osintFeed.length === 0 && ( {isLive && osintFeed.length === 0 && (
<div className="osint-header"> <div className="osint-header">
<span className="osint-live-dot" /> <span className="osint-live-dot" />
<span className="osint-title">OSINT LIVE FEED</span> <span className="osint-title">{t('events:osint.liveTitle')}</span>
<span className="osint-loading">Loading...</span> <span className="osint-loading">{t('events:osint.loading')}</span>
</div> </div>
)} )}
{/* Event Log (replay mode) */} {/* Event Log (replay mode) */}
{!isLive && ( {!isLive && (
<> <>
<h3>Event Log</h3> <h3>{t('events:log.title')}</h3>
<div className="event-list"> <div className="event-list">
{visibleEvents.length === 0 && ( {visibleEvents.length === 0 && (
<div className="event-empty">No events yet. Press play to start replay.</div> <div className="event-empty">{t('events:log.noEvents')}</div>
)} )}
{visibleEvents.map(e => { {visibleEvents.map(e => {
const isNew = currentTime - e.timestamp < 86_400_000; const isNew = currentTime - e.timestamp < 86_400_000;
return ( return (
<div key={e.id} className="event-item" style={isNew ? { borderLeft: '3px solid #ff0000' } : undefined}> <div key={e.id} className="event-item" style={isNew ? { borderLeft: '3px solid var(--kcg-event-impact)' } : undefined}>
<span <span
className="event-tag" className="event-tag"
style={{ backgroundColor: TYPE_COLORS[e.type] }} style={{ backgroundColor: TYPE_COLORS[e.type] }}
@ -493,10 +526,7 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
<div className="event-content"> <div className="event-content">
<div className="event-label"> <div className="event-label">
{isNew && ( {isNew && (
<span style={{ <span className="inline-block rounded-sm bg-[var(--kcg-event-impact)] px-1 mr-1 text-[9px] font-bold text-white">{t('events:log.new')}</span>
background: '#ff0000', color: '#fff', padding: '0 4px',
borderRadius: 2, fontSize: 9, marginRight: 4, fontWeight: 700,
}}>NEW</span>
)} )}
{e.label} {e.label}
</div> </div>
@ -523,20 +553,21 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
<> <>
{/* 한국 속보 (replay) */} {/* 한국 속보 (replay) */}
{visibleNewsKR.length > 0 && ( {visibleNewsKR.length > 0 && (
<div className="breaking-news-section" style={{ borderLeftColor: '#3b82f6' }}> <div className="breaking-news-section" style={{ borderLeftColor: 'var(--kcg-accent)' }}>
<div className="breaking-news-header"> <div className="breaking-news-header">
<span className="breaking-flash" style={{ background: '#3b82f6' }}></span> <span className="breaking-flash bg-kcg-accent">{t('events:news.breaking')}</span>
<span className="breaking-title">🇰🇷 </span> <span className="breaking-title">{'\u{1F1F0}\u{1F1F7}'} {t('events:news.koreaTitle')}</span>
</div> </div>
<div className="breaking-news-list"> <div className="breaking-news-list">
{visibleNewsKR.map(n => { {visibleNewsKR.map(n => {
const style = NEWS_CATEGORY_STYLE[n.category]; const catColor = NEWS_CATEGORY_COLORS[n.category];
const catIcon = NEWS_CATEGORY_ICONS[n.category];
const isRecent = currentTime - n.timestamp < 2 * HOUR_MS; const isRecent = currentTime - n.timestamp < 2 * HOUR_MS;
return ( return (
<div key={n.id} className={`breaking-news-item${isRecent ? ' breaking-new' : ''}`}> <div key={n.id} className={`breaking-news-item${isRecent ? ' breaking-new' : ''}`}>
<div className="breaking-news-top"> <div className="breaking-news-top">
<span className="breaking-cat-tag" style={{ background: style.color }}> <span className="breaking-cat-tag" style={{ background: catColor }}>
{style.icon} {style.label} {catIcon} {t(`events:news.categoryLabel.${n.category}`)}
</span> </span>
<span className="breaking-news-time"> <span className="breaking-news-time">
{new Date(n.timestamp + 9 * 3600_000).toISOString().slice(5, 16).replace('T', ' ')} {new Date(n.timestamp + 9 * 3600_000).toISOString().slice(5, 16).replace('T', ' ')}
@ -554,52 +585,41 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
{/* 한국 선박 현황 — 선종별 분류 */} {/* 한국 선박 현황 — 선종별 분류 */}
<div className="iran-ship-summary"> <div className="iran-ship-summary">
<div className="area-ship-header"> <div className="area-ship-header">
<span className="area-ship-icon">🇰🇷</span> <span className="area-ship-icon">{'\u{1F1F0}\u{1F1F7}'}</span>
<span className="area-ship-title"> </span> <span className="area-ship-title">{t('ships:shipStatus.koreanTitle')}</span>
<span className="area-ship-total">{koreanShips.length}</span> <span className="area-ship-total">{koreanShips.length}{t('common:units.vessels')}</span>
</div> </div>
{koreanShips.length > 0 && (() => { {koreanShips.length > 0 && (() => {
// 선종별 그룹핑
const groups: Record<string, Ship[]> = {}; const groups: Record<string, Ship[]> = {};
for (const s of koreanShips) { for (const s of koreanShips) {
const cat = getShipMTCategory(s.typecode, s.category); const cat = getShipMTCategory(s.typecode, s.category);
if (!groups[cat]) groups[cat] = []; if (!groups[cat]) groups[cat] = [];
groups[cat].push(s); groups[cat].push(s);
} }
// 정렬 순서: 군함 → 유조선 → 화물선 → 여객선 → 어선 → 예인선 → 기타
const order = ['military', 'tanker', 'cargo', 'passenger', 'fishing', 'tug_special', 'high_speed', 'pleasure', 'unspecified']; const order = ['military', 'tanker', 'cargo', 'passenger', 'fishing', 'tug_special', 'high_speed', 'pleasure', 'unspecified'];
const sorted = order.filter(k => groups[k]?.length); const sorted = order.filter(k => groups[k]?.length);
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, padding: '4px 0' }}> <div className="flex flex-col gap-0.5 py-1">
{sorted.map(cat => { {sorted.map(cat => {
const mt = MT_CATEGORIES[cat] || MT_CATEGORIES.unspecified; const mtColor = MT_CATEGORY_COLORS[cat] || MT_CATEGORY_COLORS.unspecified;
const mtLabel = t(`ships:mtType.${cat}`, { defaultValue: t('ships:mtType.unspecified') });
const list = groups[cat]; const list = groups[cat];
const moving = list.filter(s => s.speed > 0.5).length; const moving = list.filter(s => s.speed > 0.5).length;
const anchored = list.length - moving; const anchored = list.length - moving;
return ( return (
<div key={cat} style={{ <div key={cat} className="flex items-center gap-1.5 px-2 py-1 rounded-r" style={{
display: 'flex', alignItems: 'center', gap: 6, background: `${mtColor}0a`,
padding: '4px 8px', borderLeft: `3px solid ${mtColor}`,
background: `${mt.color}0a`,
borderLeft: `3px solid ${mt.color}`,
borderRadius: '0 4px 4px 0',
}}> }}>
<span style={{ <span className="size-2 shrink-0 rounded-full" style={{ background: mtColor }} />
width: 8, height: 8, borderRadius: '50%', <span className="min-w-[70px] text-[11px] font-bold font-mono" style={{ color: mtColor }}>{mtLabel}</span>
background: mt.color, flexShrink: 0, <span className="text-[13px] font-bold font-mono text-kcg-text">
}} /> {list.length}<span className="text-[9px] font-normal text-kcg-muted">{t('common:units.vessels')}</span>
<span style={{ </span>
fontSize: 11, fontWeight: 700, color: mt.color, <span className="ml-auto flex gap-1.5 text-[9px] font-mono">
minWidth: 70, fontFamily: 'monospace', {moving > 0 && <span className="text-kcg-success">{t('ships:status.underway')} {moving}</span>}
}}>{mt.label}</span> {anchored > 0 && <span className="text-kcg-danger">{t('ships:status.anchored')} {anchored}</span>}
<span style={{
fontSize: 13, fontWeight: 700, color: '#fff',
fontFamily: 'monospace',
}}>{list.length}<span style={{ fontSize: 9, color: '#888', fontWeight: 400 }}></span></span>
<span style={{ marginLeft: 'auto', display: 'flex', gap: 6, fontSize: 9, fontFamily: 'monospace' }}>
{moving > 0 && <span style={{ color: '#22c55e' }}> {moving}</span>}
{anchored > 0 && <span style={{ color: '#ef4444' }}> {anchored}</span>}
</span> </span>
</div> </div>
); );
@ -612,9 +632,9 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
{/* 중국 선박 현황 */} {/* 중국 선박 현황 */}
<div className="iran-ship-summary"> <div className="iran-ship-summary">
<div className="area-ship-header"> <div className="area-ship-header">
<span className="area-ship-icon">🇨🇳</span> <span className="area-ship-icon">{'\u{1F1E8}\u{1F1F3}'}</span>
<span className="area-ship-title"> </span> <span className="area-ship-title">{t('ships:shipStatus.chineseTitle')}</span>
<span className="area-ship-total">{chineseShips.length}</span> <span className="area-ship-total">{chineseShips.length}{t('common:units.vessels')}</span>
</div> </div>
{chineseShips.length > 0 && (() => { {chineseShips.length > 0 && (() => {
const groups: Record<string, Ship[]> = {}; const groups: Record<string, Ship[]> = {};
@ -628,49 +648,34 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
const fishingCount = groups['fishing']?.length || 0; const fishingCount = groups['fishing']?.length || 0;
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, padding: '4px 0' }}> <div className="flex flex-col gap-0.5 py-1">
{fishingCount > 0 && ( {fishingCount > 0 && (
<div style={{ <div className="flex items-center gap-1.5 px-2 py-1.5 mb-0.5 rounded border border-kcg-danger/30 bg-kcg-danger/10">
display: 'flex', alignItems: 'center', gap: 6, <span className="text-sm">{'\u{1F6A8}'}</span>
padding: '6px 8px', marginBottom: 2, <span className="text-[11px] font-bold font-mono text-kcg-danger">
background: 'rgba(239,68,68,0.1)', {t('ships:shipStatus.chineseFishingAlert', { count: fishingCount })}
border: '1px solid rgba(239,68,68,0.3)',
borderRadius: 4,
}}>
<span style={{ fontSize: 14 }}>🚨</span>
<span style={{ fontSize: 11, fontWeight: 700, color: '#ef4444', fontFamily: 'monospace' }}>
{fishingCount}
</span> </span>
</div> </div>
)} )}
{sorted.map(cat => { {sorted.map(cat => {
const mt = MT_CATEGORIES[cat] || MT_CATEGORIES.unspecified; const mtColor = MT_CATEGORY_COLORS[cat] || MT_CATEGORY_COLORS.unspecified;
const mtLabel = t(`ships:mtType.${cat}`, { defaultValue: t('ships:mtType.unspecified') });
const list = groups[cat]; const list = groups[cat];
const moving = list.filter(s => s.speed > 0.5).length; const moving = list.filter(s => s.speed > 0.5).length;
const anchored = list.length - moving; const anchored = list.length - moving;
return ( return (
<div key={cat} style={{ <div key={cat} className="flex items-center gap-1.5 px-2 py-1 rounded-r" style={{
display: 'flex', alignItems: 'center', gap: 6, background: `${mtColor}0a`,
padding: '4px 8px', borderLeft: `3px solid ${mtColor}`,
background: `${mt.color}0a`,
borderLeft: `3px solid ${mt.color}`,
borderRadius: '0 4px 4px 0',
}}> }}>
<span style={{ <span className="size-2 shrink-0 rounded-full" style={{ background: mtColor }} />
width: 8, height: 8, borderRadius: '50%', <span className="min-w-[70px] text-[11px] font-bold font-mono" style={{ color: mtColor }}>{mtLabel}</span>
background: mt.color, flexShrink: 0, <span className="text-[13px] font-bold font-mono text-kcg-text">
}} /> {list.length}<span className="text-[9px] font-normal text-kcg-muted">{t('common:units.vessels')}</span>
<span style={{ </span>
fontSize: 11, fontWeight: 700, color: mt.color, <span className="ml-auto flex gap-1.5 text-[9px] font-mono">
minWidth: 70, fontFamily: 'monospace', {moving > 0 && <span className="text-kcg-success">{t('ships:status.underway')} {moving}</span>}
}}>{mt.label}</span> {anchored > 0 && <span className="text-kcg-danger">{t('ships:status.anchored')} {anchored}</span>}
<span style={{
fontSize: 13, fontWeight: 700, color: '#fff',
fontFamily: 'monospace',
}}>{list.length}<span style={{ fontSize: 9, color: '#888', fontWeight: 400 }}></span></span>
<span style={{ marginLeft: 'auto', display: 'flex', gap: 6, fontSize: 9, fontFamily: 'monospace' }}>
{moving > 0 && <span style={{ color: '#22c55e' }}> {moving}</span>}
{anchored > 0 && <span style={{ color: '#ef4444' }}> {anchored}</span>}
</span> </span>
</div> </div>
); );
@ -685,7 +690,7 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
<> <>
<div className="osint-header"> <div className="osint-header">
<span className="osint-live-dot" /> <span className="osint-live-dot" />
<span className="osint-title">🇰🇷 OSINT LIVE</span> <span className="osint-title">{'\u{1F1F0}\u{1F1F7}'} {t('events:osint.koreaLiveTitle')}</span>
<span className="osint-count">{(() => { <span className="osint-count">{(() => {
const filtered = osintFeed.filter(i => i.category !== 'general' && i.category !== 'oil'); const filtered = osintFeed.filter(i => i.category !== 'general' && i.category !== 'oil');
const seen = new Set<string>(); const seen = new Set<string>();
@ -708,7 +713,8 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
return true; return true;
}); });
})().map(item => { })().map(item => {
const style = OSINT_CAT_STYLE[item.category] || OSINT_CAT_STYLE.general; const catColor = OSINT_CAT_COLORS[item.category] || OSINT_CAT_COLORS.general;
const catIcon = OSINT_CAT_ICONS[item.category] || OSINT_CAT_ICONS.general;
const isRecent = Date.now() - item.timestamp < 3600_000; const isRecent = Date.now() - item.timestamp < 3600_000;
return ( return (
<a <a
@ -719,8 +725,8 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<div className="osint-item-top"> <div className="osint-item-top">
<span className="osint-cat-tag" style={{ background: style.color }}> <span className="osint-cat-tag" style={{ background: catColor }}>
{style.icon} {style.label} {catIcon} {t(`events:osint.categoryLabel.${item.category}`, { defaultValue: t('events:osint.categoryLabel.general') })}
</span> </span>
{item.language === 'ko' && <span className="osint-lang-tag">KR</span>} {item.language === 'ko' && <span className="osint-lang-tag">KR</span>}
<span className="osint-time">{timeAgo(item.timestamp)}</span> <span className="osint-time">{timeAgo(item.timestamp)}</span>
@ -736,8 +742,8 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
{osintFeed.length === 0 && ( {osintFeed.length === 0 && (
<div className="osint-header"> <div className="osint-header">
<span className="osint-live-dot" /> <span className="osint-live-dot" />
<span className="osint-title">🇰🇷 OSINT LIVE</span> <span className="osint-title">{'\u{1F1F0}\u{1F1F7}'} {t('events:osint.koreaLiveTitle')}</span>
<span className="osint-loading">Loading...</span> <span className="osint-loading">{t('events:osint.loading')}</span>
</div> </div>
)} )}
</> </>

파일 보기

@ -1,4 +1,5 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { GeoEvent } from '../types'; import type { GeoEvent } from '../types';
interface Props { interface Props {
@ -21,21 +22,21 @@ const TYPE_COLORS: Record<string, string> = {
osint: '#06b6d4', osint: '#06b6d4',
}; };
const TYPE_LABELS_KO: Record<string, string> = { const TYPE_KEYS: Record<string, string> = {
airstrike: '공습', airstrike: 'event.airstrike',
explosion: '폭발', explosion: 'event.explosion',
missile_launch: '미사일', missile_launch: 'event.missileLaunch',
intercept: '요격', intercept: 'event.intercept',
alert: '경보', alert: 'event.alert',
impact: '피격', impact: 'event.impact',
osint: 'OSINT', osint: 'event.osint',
}; };
const SOURCE_LABELS_KO: Record<string, string> = { const SOURCE_KEYS: Record<string, string> = {
US: '미국', US: 'source.US',
IL: '이스라엘', IL: 'source.IL',
IR: '이란', IR: 'source.IR',
proxy: '대리세력', proxy: 'source.proxy',
}; };
interface EventGroup { interface EventGroup {
@ -44,10 +45,14 @@ interface EventGroup {
events: GeoEvent[]; events: GeoEvent[];
} }
const DAY_NAMES = ['일', '월', '화', '수', '목', '금', '토']; 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) { export function EventStrip({ events, currentTime, onEventClick }: Props) {
const [openDate, setOpenDate] = useState<string | null>(null); const [openDate, setOpenDate] = useState<string | null>(null);
const { t } = useTranslation();
const groups = useMemo(() => { const groups = useMemo(() => {
const sorted = [...events].sort((a, b) => a.timestamp - b.timestamp); const sorted = [...events].sort((a, b) => a.timestamp - b.timestamp);
@ -63,12 +68,12 @@ export function EventStrip({ events, currentTime, onEventClick }: Props) {
const result: EventGroup[] = []; const result: EventGroup[] = [];
for (const [dateKey, evs] of map) { for (const [dateKey, evs] of map) {
const d = new Date(evs[0].timestamp + KST_OFFSET); const d = new Date(evs[0].timestamp + KST_OFFSET);
const dayName = DAY_NAMES[d.getUTCDay()]; const dayName = t(DAY_NAME_KEYS[d.getUTCDay()]);
const dateLabel = `${String(d.getUTCMonth() + 1).padStart(2, '0')}/${String(d.getUTCDate()).padStart(2, '0')} (${dayName})`; const dateLabel = `${String(d.getUTCMonth() + 1).padStart(2, '0')}/${String(d.getUTCDate()).padStart(2, '0')} (${dayName})`;
result.push({ dateKey, dateLabel, events: evs }); result.push({ dateKey, dateLabel, events: evs });
} }
return result; return result;
}, [events]); }, [events, t]);
// Auto-open the first group if none selected // Auto-open the first group if none selected
const effectiveOpen = openDate ?? (groups.length > 0 ? groups[0].dateKey : null); const effectiveOpen = openDate ?? (groups.length > 0 ? groups[0].dateKey : null);
@ -108,8 +113,8 @@ export function EventStrip({ events, currentTime, onEventClick }: Props) {
{group.events.map(ev => { {group.events.map(ev => {
const isPast = ev.timestamp <= currentTime; const isPast = ev.timestamp <= currentTime;
const color = TYPE_COLORS[ev.type] || '#888'; const color = TYPE_COLORS[ev.type] || '#888';
const source = ev.source ? SOURCE_LABELS_KO[ev.source] : ''; const source = ev.source ? t(SOURCE_KEYS[ev.source] ?? ev.source) : '';
const typeLabel = TYPE_LABELS_KO[ev.type] || ev.type; const typeLabel = t(TYPE_KEYS[ev.type] ?? ev.type);
return ( return (
<button <button

파일 보기

@ -98,7 +98,6 @@ export function GlobeMap({ events, currentTime, aircraft, satellites, ships, lay
map.addControl(new maplibregl.NavigationControl(), 'top-right'); map.addControl(new maplibregl.NavigationControl(), 'top-right');
// 한글 국가명 라벨
map.on('load', () => { map.on('load', () => {
map.addSource('country-labels', { type: 'geojson', data: countryLabelsGeoJSON() }); map.addSource('country-labels', { type: 'geojson', data: countryLabelsGeoJSON() });
map.addLayer({ map.addLayer({
@ -141,7 +140,7 @@ export function GlobeMap({ events, currentTime, aircraft, satellites, ships, lay
}; };
}, []); }, []);
// Update markers // Update markers — DOM direct manipulation, inline styles intentionally kept
useEffect(() => { useEffect(() => {
const map = mapRef.current; const map = mapRef.current;
if (!map) return; if (!map) return;
@ -227,6 +226,6 @@ export function GlobeMap({ events, currentTime, aircraft, satellites, ships, lay
}, [events, currentTime, aircraft, satellites, ships, layers]); }, [events, currentTime, aircraft, satellites, ships, layers]);
return ( return (
<div ref={containerRef} style={{ width: '100%', height: '100%' }} /> <div ref={containerRef} className="w-full h-full" />
); );
} }

파일 보기

@ -1,10 +1,12 @@
import { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Marker, Popup } from 'react-map-gl/maplibre'; import { Marker, Popup } from 'react-map-gl/maplibre';
import { KOREAN_AIRPORTS } from '../services/airports'; import { KOREAN_AIRPORTS } from '../services/airports';
import type { KoreanAirport } from '../services/airports'; import type { KoreanAirport } from '../services/airports';
export function KoreaAirportLayer() { export function KoreaAirportLayer() {
const [selected, setSelected] = useState<KoreanAirport | null>(null); const [selected, setSelected] = useState<KoreanAirport | null>(null);
const { t } = useTranslation();
return ( return (
<> <>
@ -16,20 +18,18 @@ export function KoreaAirportLayer() {
<Marker key={ap.id} longitude={ap.lng} latitude={ap.lat} anchor="center" <Marker key={ap.id} longitude={ap.lng} latitude={ap.lat} anchor="center"
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(ap); }}> onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(ap); }}>
<div style={{ <div style={{
cursor: 'pointer', display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer',
filter: `drop-shadow(0 0 3px ${color}88)`, filter: `drop-shadow(0 0 3px ${color}88)`,
}}> }} className="flex flex-col items-center">
<svg width={size} height={size} viewBox="0 0 24 24" fill="none"> <svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke={color} strokeWidth="1.2" /> <circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke={color} strokeWidth="1.2" />
{/* 비행기 모양 (위를 향한 여객기) */}
<path d="M12 5 C11.4 5 11 5.4 11 5.8 L11 10 L6 13 L6 14.2 L11 12.5 L11 16.5 L9.2 17.8 L9.2 18.8 L12 18 L14.8 18.8 L14.8 17.8 L13 16.5 L13 12.5 L18 14.2 L18 13 L13 10 L13 5.8 C13 5.4 12.6 5 12 5Z" <path d="M12 5 C11.4 5 11 5.4 11 5.8 L11 10 L6 13 L6 14.2 L11 12.5 L11 16.5 L9.2 17.8 L9.2 18.8 L12 18 L14.8 18.8 L14.8 17.8 L13 16.5 L13 12.5 L18 14.2 L18 13 L13 10 L13 5.8 C13 5.4 12.6 5 12 5Z"
fill={color} stroke="#fff" strokeWidth="0.3" /> fill={color} stroke="#fff" strokeWidth="0.3" />
</svg> </svg>
<div style={{ <div style={{
fontSize: 6, color: '#fff', marginTop: 1, fontSize: 6,
textShadow: `0 0 3px ${color}, 0 0 2px #000`, textShadow: `0 0 3px ${color}, 0 0 2px #000`,
whiteSpace: 'nowrap', fontWeight: 700, letterSpacing: 0.3, }} className="mt-px whitespace-nowrap font-bold tracking-wide text-white">
}}>
{ap.nameKo.replace('국제공항', '').replace('공항', '')} {ap.nameKo.replace('국제공항', '').replace('공항', '')}
</div> </div>
</div> </div>
@ -41,35 +41,28 @@ export function KoreaAirportLayer() {
<Popup longitude={selected.lng} latitude={selected.lat} <Popup longitude={selected.lng} latitude={selected.lat}
onClose={() => setSelected(null)} closeOnClick={false} onClose={() => setSelected(null)} closeOnClick={false}
anchor="bottom" maxWidth="260px" className="gl-popup"> anchor="bottom" maxWidth="260px" className="gl-popup">
<div style={{ fontFamily: 'monospace', fontSize: 12, minWidth: 180 }}> <div className="min-w-[180px] font-mono text-xs">
<div style={{ <div style={{
background: selected.intl ? '#a78bfa' : '#7c8aaa', color: '#000', background: selected.intl ? '#a78bfa' : '#7c8aaa',
padding: '4px 8px', borderRadius: '4px 4px 0 0', }} className="-mx-2.5 -mt-2.5 mb-2 flex items-center gap-1.5 rounded-t px-2 py-1 text-[13px] font-bold text-black">
margin: '-10px -10px 8px -10px',
fontWeight: 700, fontSize: 13,
display: 'flex', alignItems: 'center', gap: 6,
}}>
{selected.nameKo} {selected.nameKo}
</div> </div>
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}> <div className="mb-1.5 flex flex-wrap gap-1">
{selected.intl && ( {selected.intl && (
<span style={{ <span className="rounded-sm bg-[#a78bfa] px-1.5 py-px text-[10px] font-bold text-black">
background: '#a78bfa', color: '#000', {t('airport.international')}
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700, </span>
}}></span>
)} )}
{selected.domestic && ( {selected.domestic && (
<span style={{ <span className="rounded-sm bg-[#7c8aaa] px-1.5 py-px text-[10px] font-bold text-black">
background: '#7c8aaa', color: '#000', {t('airport.domestic')}
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700, </span>
}}></span>
)} )}
<span style={{ <span className="rounded-sm bg-kcg-card px-1.5 py-px text-[10px] text-kcg-muted">
background: '#1a1a2e', color: '#888', {selected.id} / {selected.icao}
padding: '1px 6px', borderRadius: 3, fontSize: 10, </span>
}}>{selected.id} / {selected.icao}</span>
</div> </div>
<div style={{ fontSize: 9, color: '#666' }}> <div className="text-[9px] text-kcg-dim">
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E {selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
</div> </div>
</div> </div>

파일 보기

@ -0,0 +1,321 @@
import { useRef, useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Map, NavigationControl, Marker, Source, Layer } from 'react-map-gl/maplibre';
import type { MapRef } from 'react-map-gl/maplibre';
import { ShipLayer } from './ShipLayer';
import { InfraLayer } from './InfraLayer';
import { SatelliteLayer } from './SatelliteLayer';
import { AircraftLayer } from './AircraftLayer';
import { SubmarineCableLayer } from './SubmarineCableLayer';
import { CctvLayer } from './CctvLayer';
import { KoreaAirportLayer } from './KoreaAirportLayer';
import { CoastGuardLayer } from './CoastGuardLayer';
import { NavWarningLayer } from './NavWarningLayer';
import { OsintMapLayer } from './OsintMapLayer';
import { EezLayer } from './EezLayer';
import { PiracyLayer } from './PiracyLayer';
import { fetchKoreaInfra } from '../services/infra';
import type { PowerFacility } from '../services/infra';
import type { Ship, Aircraft, SatellitePosition } from '../types';
import type { OsintItem } from '../services/osint';
import { countryLabelsGeoJSON } from '../data/countryLabels';
import 'maplibre-gl/dist/maplibre-gl.css';
export interface KoreaFiltersState {
illegalFishing: boolean;
illegalTransship: boolean;
darkVessel: boolean;
cableWatch: boolean;
dokdoWatch: boolean;
ferryWatch: boolean;
}
interface Props {
ships: Ship[];
aircraft: Aircraft[];
satellites: SatellitePosition[];
layers: Record<string, boolean>;
osintFeed: OsintItem[];
currentTime: number;
koreaFilters: KoreaFiltersState;
transshipSuspects: Set<string>;
cableWatchSuspects: Set<string>;
dokdoWatchSuspects: Set<string>;
dokdoAlerts: { mmsi: string; name: string; dist: number; time: number }[];
}
// MarineTraffic-style: satellite + dark ocean + nautical overlay
const MAP_STYLE = {
version: 8 as const,
sources: {
'satellite': {
type: 'raster' as const,
tiles: [
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
],
tileSize: 256,
maxzoom: 19,
attribution: '&copy; Esri, Maxar',
},
'carto-dark': {
type: 'raster' as const,
tiles: [
'https://a.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png',
'https://b.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png',
'https://c.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png',
],
tileSize: 256,
},
'opensea': {
type: 'raster' as const,
tiles: [
'https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png',
],
tileSize: 256,
maxzoom: 18,
},
},
layers: [
{ id: 'background', type: 'background' as const, paint: { 'background-color': '#0d1f3c' } },
{ id: 'satellite-base', type: 'raster' as const, source: 'satellite', paint: { 'raster-brightness-max': 0.75, 'raster-saturation': -0.1, 'raster-contrast': 0.05 } },
{ id: 'dark-overlay', type: 'raster' as const, source: 'carto-dark', paint: { 'raster-opacity': 0.25 } },
{ id: 'seamark', type: 'raster' as const, source: 'opensea', paint: { 'raster-opacity': 0.5 } },
],
};
// Korea-centered view
const KOREA_MAP_CENTER = { longitude: 127.5, latitude: 36 };
const KOREA_MAP_ZOOM = 6;
const FILTER_ICON: Record<string, string> = {
illegalFishing: '\u{1F6AB}\u{1F41F}',
illegalTransship: '\u2693',
darkVessel: '\u{1F47B}',
cableWatch: '\u{1F50C}',
dokdoWatch: '\u{1F3DD}\uFE0F',
ferryWatch: '\u{1F6A2}',
};
const FILTER_COLOR: Record<string, string> = {
illegalFishing: '#ef4444',
illegalTransship: '#f97316',
darkVessel: '#8b5cf6',
cableWatch: '#00e5ff',
dokdoWatch: '#22c55e',
ferryWatch: '#2196f3',
};
const FILTER_I18N_KEY: Record<string, string> = {
illegalFishing: 'filters.illegalFishingMonitor',
illegalTransship: 'filters.illegalTransshipMonitor',
darkVessel: 'filters.darkVesselMonitor',
cableWatch: 'filters.cableWatchMonitor',
dokdoWatch: 'filters.dokdoWatchMonitor',
ferryWatch: 'filters.ferryWatchMonitor',
};
export function KoreaMap({ ships, aircraft, satellites, layers, osintFeed, currentTime, koreaFilters, transshipSuspects, cableWatchSuspects, dokdoWatchSuspects, dokdoAlerts }: Props) {
const { t } = useTranslation();
const mapRef = useRef<MapRef>(null);
const [infra, setInfra] = useState<PowerFacility[]>([]);
useEffect(() => {
fetchKoreaInfra().then(setInfra).catch(() => {});
}, []);
return (
<Map
ref={mapRef}
initialViewState={{ ...KOREA_MAP_CENTER, zoom: KOREA_MAP_ZOOM }}
style={{ width: '100%', height: '100%' }}
mapStyle={MAP_STYLE}
>
<NavigationControl position="top-right" />
<Source id="country-labels" type="geojson" data={countryLabelsGeoJSON()}>
<Layer
id="country-label-lg"
type="symbol"
filter={['==', ['get', 'rank'], 1]}
layout={{
'text-field': ['get', 'name'],
'text-size': 15,
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
'text-allow-overlap': false,
'text-ignore-placement': false,
'text-padding': 6,
}}
paint={{
'text-color': '#e2e8f0',
'text-halo-color': '#000000',
'text-halo-width': 2,
'text-opacity': 0.9,
}}
/>
<Layer
id="country-label-md"
type="symbol"
filter={['==', ['get', 'rank'], 2]}
layout={{
'text-field': ['get', 'name'],
'text-size': 12,
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
'text-allow-overlap': false,
'text-ignore-placement': false,
'text-padding': 4,
}}
paint={{
'text-color': '#94a3b8',
'text-halo-color': '#000000',
'text-halo-width': 1.5,
'text-opacity': 0.85,
}}
/>
<Layer
id="country-label-sm"
type="symbol"
filter={['==', ['get', 'rank'], 3]}
minzoom={5}
layout={{
'text-field': ['get', 'name'],
'text-size': 10,
'text-font': ['Open Sans Regular', 'Arial Unicode MS Regular'],
'text-allow-overlap': false,
'text-ignore-placement': false,
'text-padding': 2,
}}
paint={{
'text-color': '#64748b',
'text-halo-color': '#000000',
'text-halo-width': 1,
'text-opacity': 0.75,
}}
/>
</Source>
{layers.ships && <ShipLayer ships={ships} militaryOnly={layers.militaryOnly} />}
{/* Transship suspect labels — Marker DOM, inline styles kept for dynamic border color */}
{transshipSuspects.size > 0 && ships.filter(s => transshipSuspects.has(s.mmsi)).map(s => (
<Marker key={`ts-${s.mmsi}`} longitude={s.lng} latitude={s.lat} anchor="bottom">
<div
className="rounded-sm text-[9px] font-bold font-mono whitespace-nowrap animate-pulse"
style={{
background: 'rgba(249,115,22,0.9)', color: '#fff',
padding: '1px 5px',
border: '1px solid #f97316',
textShadow: '0 0 2px #000',
}}
>
{`\u26A0 ${t('korea.transshipSuspect')}`}
</div>
</Marker>
))}
{/* Cable watch suspect labels */}
{cableWatchSuspects.size > 0 && ships.filter(s => cableWatchSuspects.has(s.mmsi)).map(s => (
<Marker key={`cw-${s.mmsi}`} longitude={s.lng} latitude={s.lat} anchor="bottom">
<div
className="rounded-sm text-[9px] font-bold font-mono whitespace-nowrap animate-pulse"
style={{
background: 'rgba(0,229,255,0.9)', color: '#000',
padding: '1px 5px',
border: '1px solid #00e5ff',
textShadow: '0 0 2px rgba(255,255,255,0.5)',
}}
>
{`\u{1F50C} ${t('korea.cableDanger')}`}
</div>
</Marker>
))}
{/* Dokdo watch labels (Japanese vessels) */}
{dokdoWatchSuspects.size > 0 && ships.filter(s => dokdoWatchSuspects.has(s.mmsi)).map(s => {
const dist = Math.round(Math.hypot(
(s.lng - 131.8647) * Math.cos((37.2417 * Math.PI) / 180),
s.lat - 37.2417,
) * 111);
const inTerritorial = dist < 22;
return (
<Marker key={`dk-${s.mmsi}`} longitude={s.lng} latitude={s.lat} anchor="bottom">
<div
className="rounded-sm text-[9px] font-bold font-mono whitespace-nowrap animate-pulse"
style={{
background: inTerritorial ? 'rgba(239,68,68,0.95)' : 'rgba(234,179,8,0.9)',
color: '#fff',
padding: '2px 6px',
border: `1px solid ${inTerritorial ? '#ef4444' : '#eab308'}`,
textShadow: '0 0 2px #000',
}}
>
{inTerritorial ? '\u{1F6A8}' : '\u26A0'} {'\u{1F1EF}\u{1F1F5}'} {inTerritorial ? t('korea.dokdoIntrusion') : t('korea.dokdoApproach')} {`${dist}km`}
</div>
</Marker>
);
})}
{layers.infra && infra.length > 0 && <InfraLayer facilities={infra} />}
{layers.satellites && satellites.length > 0 && <SatelliteLayer satellites={satellites} />}
{layers.aircraft && aircraft.length > 0 && <AircraftLayer aircraft={aircraft} militaryOnly={layers.militaryOnly} />}
{layers.cables && <SubmarineCableLayer />}
{layers.cctv && <CctvLayer />}
{layers.airports && <KoreaAirportLayer />}
{layers.coastGuard && <CoastGuardLayer />}
{layers.navWarning && <NavWarningLayer />}
{layers.osint && <OsintMapLayer osintFeed={osintFeed} currentTime={currentTime} />}
{layers.eez && <EezLayer />}
{layers.piracy && <PiracyLayer />}
{/* Filter Status Banner */}
{(() => {
const active = (Object.keys(koreaFilters) as (keyof KoreaFiltersState)[]).filter(k => koreaFilters[k]);
if (active.length === 0) return null;
return (
<div className="absolute top-2.5 left-1/2 -translate-x-1/2 z-20 flex gap-1.5 backdrop-blur-lg">
{active.map(k => {
const color = FILTER_COLOR[k];
return (
<div
key={k}
className="rounded-lg px-3 py-1.5 font-mono text-[11px] font-bold flex items-center gap-1.5 animate-pulse"
style={{
background: `${color}22`, border: `1px solid ${color}88`,
color,
}}
>
<span className="text-[13px]">{FILTER_ICON[k]}</span>
{t(FILTER_I18N_KEY[k])}
</div>
);
})}
<div className="rounded-lg px-3 py-1.5 font-mono text-xs font-bold flex items-center bg-kcg-glass border border-kcg-border-light text-white">
{t('korea.detected', { count: ships.length })}
</div>
</div>
);
})()}
{/* Dokdo alert panel */}
{dokdoAlerts.length > 0 && koreaFilters.dokdoWatch && (
<div className="absolute top-2.5 right-[50px] z-20 rounded-lg border border-kcg-danger px-2.5 py-2 font-mono text-[11px] min-w-[220px] max-h-[200px] overflow-y-auto bg-kcg-overlay backdrop-blur-lg shadow-[0_0_20px_rgba(239,68,68,0.3)]">
<div className="font-bold text-[10px] text-kcg-danger mb-1.5 tracking-widest flex items-center gap-1">
{`\u{1F6A8} ${t('korea.dokdoAlerts')}`}
</div>
{dokdoAlerts.map((a, i) => (
<div key={`${a.mmsi}-${i}`} className="flex flex-col gap-0.5" style={{
padding: '4px 0', borderBottom: i < dokdoAlerts.length - 1 ? '1px solid #222' : 'none',
}}>
<div className="flex justify-between items-center">
<span className="font-bold text-[10px]" style={{ color: a.dist < 22 ? '#ef4444' : '#eab308' }}>
{a.dist < 22 ? `\u{1F6A8} ${t('korea.territorialIntrusion')}` : `\u26A0 ${t('korea.approachWarning')}`}
</span>
<span className="text-kcg-dim text-[9px]">
{new Date(a.time).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</span>
</div>
<div className="text-kcg-text-secondary text-[10px]">
{`\u{1F1EF}\u{1F1F5} ${a.name} \u2014 ${t('korea.dokdoDistance', { dist: a.dist })}`}
</div>
</div>
))}
</div>
)}
</Map>
);
}

파일 보기

@ -0,0 +1,377 @@
import { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
// Aircraft category colors (matches AircraftLayer military fixed colors)
const AC_CAT_COLORS: Record<string, string> = {
fighter: '#ff4444',
military: '#ff6600',
surveillance: '#ffcc00',
tanker: '#00ccff',
cargo: '#a78bfa',
civilian: '#FFD700',
unknown: '#7CFC00',
};
// Altitude color legend (matches AircraftLayer gradient)
const ALT_LEGEND: [string, string][] = [
['Ground', '#555555'],
['< 2,000ft', '#00c000'],
['2,000ft', '#55EC55'],
['4,000ft', '#7CFC00'],
['6,000ft', '#BFFF00'],
['10,000ft', '#FFFF00'],
['20,000ft', '#FFD700'],
['30,000ft', '#FF8C00'],
['40,000ft', '#FF4500'],
['50,000ft+', '#BA55D3'],
];
// Military color legend
const MIL_LEGEND: [string, string][] = [
['Fighter', '#ff4444'],
['Military', '#ff6600'],
['ISR / Surveillance', '#ffcc00'],
['Tanker', '#00ccff'],
];
// Ship MT category color (matches ShipLayer MT_TYPE_COLORS)
const MT_CAT_COLORS: Record<string, string> = {
cargo: 'var(--kcg-ship-cargo)',
tanker: 'var(--kcg-ship-tanker)',
passenger: 'var(--kcg-ship-passenger)',
fishing: 'var(--kcg-ship-fishing)',
military: 'var(--kcg-ship-military)',
tug_special: 'var(--kcg-ship-tug)',
high_speed: 'var(--kcg-ship-highspeed)',
pleasure: 'var(--kcg-ship-pleasure)',
other: 'var(--kcg-ship-other)',
unspecified: 'var(--kcg-ship-unknown)',
unknown: 'var(--kcg-ship-unknown)',
};
// Ship type color legend (MarineTraffic style)
const SHIP_TYPE_LEGEND: [string, string][] = [
['cargo', 'var(--kcg-ship-cargo)'],
['tanker', 'var(--kcg-ship-tanker)'],
['passenger', 'var(--kcg-ship-passenger)'],
['fishing', 'var(--kcg-ship-fishing)'],
['pleasure', 'var(--kcg-ship-pleasure)'],
['military', 'var(--kcg-ship-military)'],
['tug_special', 'var(--kcg-ship-tug)'],
['other', 'var(--kcg-ship-other)'],
['unspecified', 'var(--kcg-ship-unknown)'],
];
const AC_CATEGORIES = ['fighter', 'military', 'surveillance', 'tanker', 'cargo', 'civilian', 'unknown'] as const;
const MT_CATEGORIES = ['cargo', 'tanker', 'passenger', 'fishing', 'military', 'tug_special', 'high_speed', 'pleasure', 'other', 'unspecified'] as const;
interface ExtraLayer {
key: string;
label: string;
color: string;
count?: number;
}
interface LayerPanelProps {
layers: Record<string, boolean>;
onToggle: (key: string) => void;
aircraftByCategory: Record<string, number>;
aircraftTotal: number;
shipsByMtCategory: Record<string, number>;
shipTotal: number;
satelliteCount: number;
extraLayers?: ExtraLayer[];
hiddenAcCategories: Set<string>;
hiddenShipCategories: Set<string>;
onAcCategoryToggle: (cat: string) => void;
onShipCategoryToggle: (cat: string) => void;
}
export function LayerPanel({
layers,
onToggle,
aircraftByCategory,
aircraftTotal,
shipsByMtCategory,
shipTotal,
satelliteCount,
extraLayers,
hiddenAcCategories,
hiddenShipCategories,
onAcCategoryToggle,
onShipCategoryToggle,
}: LayerPanelProps) {
const { t } = useTranslation(['common', 'ships']);
const [expanded, setExpanded] = useState<Set<string>>(new Set(['aircraft', 'ships']));
const [legendOpen, setLegendOpen] = useState<Set<string>>(new Set());
const toggleExpand = useCallback((key: string) => {
setExpanded(prev => {
const next = new Set(prev);
if (next.has(key)) { next.delete(key); } else { next.add(key); }
return next;
});
}, []);
const toggleLegend = useCallback((key: string) => {
setLegendOpen(prev => {
const next = new Set(prev);
if (next.has(key)) { next.delete(key); } else { next.add(key); }
return next;
});
}, []);
const militaryCount = Object.entries(aircraftByCategory)
.filter(([cat]) => cat !== 'civilian' && cat !== 'unknown')
.reduce((sum, [, c]) => sum + c, 0);
return (
<div className="layer-panel">
<h3>LAYERS</h3>
<div className="layer-items">
{/* Aircraft tree */}
<LayerTreeItem
layerKey="aircraft"
label={`${t('layers.aircraft')} (${aircraftTotal})`}
color="#22d3ee"
active={layers.aircraft}
expandable
isExpanded={expanded.has('aircraft')}
onToggle={() => onToggle('aircraft')}
onExpand={() => toggleExpand('aircraft')}
/>
{layers.aircraft && expanded.has('aircraft') && (
<div className="layer-tree-children">
{AC_CATEGORIES.map(cat => {
const count = aircraftByCategory[cat] || 0;
if (count === 0) return null;
return (
<CategoryToggle
key={cat}
label={t(`ships:aircraft.${cat}`, cat.toUpperCase())}
color={AC_CAT_COLORS[cat] || '#888'}
count={count}
hidden={hiddenAcCategories.has(cat)}
onClick={() => onAcCategoryToggle(cat)}
/>
);
})}
{/* Altitude legend */}
<button
type="button"
className="legend-toggle"
onClick={() => toggleLegend('altitude')}
>
{legendOpen.has('altitude') ? '\u25BC' : '\u25B6'} {t('legend.altitude')}
</button>
{legendOpen.has('altitude') && (
<div className="legend-content">
<div className="flex flex-col gap-px text-[9px] opacity-85">
{ALT_LEGEND.map(([label, color]) => (
<div key={label} className="flex items-center gap-1.5">
<span
className="inline-block w-2.5 h-2.5 shrink-0 rounded-sm"
style={{ background: color }}
/>
<span className="text-kcg-text">{label}</span>
</div>
))}
</div>
</div>
)}
{/* Military legend */}
<button
type="button"
className="legend-toggle"
onClick={() => toggleLegend('military')}
>
{legendOpen.has('military') ? '\u25BC' : '\u25B6'} {t('legend.military')}
</button>
{legendOpen.has('military') && (
<div className="legend-content">
<div className="flex flex-col gap-px text-[9px] opacity-85">
{MIL_LEGEND.map(([label, color]) => (
<div key={label} className="flex items-center gap-1.5">
<span
className="inline-block w-2.5 h-2.5 shrink-0 rounded-sm"
style={{ background: color }}
/>
<span className="text-kcg-text">{label}</span>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Ships tree */}
<LayerTreeItem
layerKey="ships"
label={`${t('layers.ships')} (${shipTotal})`}
color="#fb923c"
active={layers.ships}
expandable
isExpanded={expanded.has('ships')}
onToggle={() => onToggle('ships')}
onExpand={() => toggleExpand('ships')}
/>
{layers.ships && expanded.has('ships') && (
<div className="layer-tree-children">
{MT_CATEGORIES.map(cat => {
const count = shipsByMtCategory[cat] || 0;
if (count === 0) return null;
return (
<CategoryToggle
key={cat}
label={t(`ships:mtType.${cat}`, cat.toUpperCase())}
color={MT_CAT_COLORS[cat] || 'var(--kcg-muted)'}
count={count}
hidden={hiddenShipCategories.has(cat)}
onClick={() => onShipCategoryToggle(cat)}
/>
);
})}
{/* Ship type legend */}
<button
type="button"
className="legend-toggle"
onClick={() => toggleLegend('shipType')}
>
{legendOpen.has('shipType') ? '\u25BC' : '\u25B6'} {t('legend.vesselType')}
</button>
{legendOpen.has('shipType') && (
<div className="legend-content">
<div className="flex flex-col gap-px text-[9px] opacity-85">
{SHIP_TYPE_LEGEND.map(([key, color]) => (
<div key={key} className="flex items-center gap-1.5">
<span
className="shrink-0"
style={{
width: 0, height: 0,
borderLeft: '5px solid transparent',
borderRight: '5px solid transparent',
borderBottom: `10px solid ${color}`,
}}
/>
<span className="text-kcg-text">{t(`ships:mtType.${key}`, key)}</span>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Satellites (simple toggle) */}
<LayerTreeItem
layerKey="satellites"
label={`${t('layers.satellites')} (${satelliteCount})`}
color="#ef4444"
active={layers.satellites}
onToggle={() => onToggle('satellites')}
/>
{/* Extra layers (tab-specific) */}
{extraLayers && extraLayers.map(el => (
<LayerTreeItem
key={el.key}
layerKey={el.key}
label={el.count != null ? `${el.label} (${el.count})` : el.label}
color={el.color}
active={layers[el.key] ?? false}
onToggle={() => onToggle(el.key)}
/>
))}
<div className="layer-divider" />
{/* Military only filter */}
<LayerTreeItem
layerKey="militaryOnly"
label={`${t('layers.militaryOnly')} (${militaryCount})`}
color="#f97316"
active={layers.militaryOnly ?? false}
onToggle={() => onToggle('militaryOnly')}
/>
</div>
</div>
);
}
/* ── Sub-components ─────────────────────────────────── */
function LayerTreeItem({
layerKey,
label,
color,
active,
expandable,
isExpanded,
onToggle,
onExpand,
}: {
layerKey: string;
label: string;
color: string;
active: boolean;
expandable?: boolean;
isExpanded?: boolean;
onToggle: () => void;
onExpand?: () => void;
}) {
return (
<div className="layer-tree-header" data-layer={layerKey}>
{expandable ? (
<span
className={`layer-tree-arrow ${isExpanded ? 'expanded' : ''}`}
onClick={e => { e.stopPropagation(); onExpand?.(); }}
>
{'\u25B6'}
</span>
) : (
<span className="layer-tree-arrow" />
)}
<button
type="button"
className={`layer-toggle ${active ? 'active' : ''}`}
onClick={onToggle}
style={{ padding: 0, gap: '6px' }}
>
<span
className="layer-dot"
style={{ backgroundColor: active ? color : '#444' }}
/>
{label}
</button>
</div>
);
}
function CategoryToggle({
label,
color,
count,
hidden,
onClick,
}: {
label: string;
color: string;
count: number;
hidden: boolean;
onClick: () => void;
}) {
return (
<div
className={`category-toggle ${hidden ? 'hidden' : ''}`}
onClick={onClick}
>
<span className="category-dot" style={{ backgroundColor: color }} />
<span className="category-label">{label}</span>
<span className="category-count">{count}</span>
</div>
);
}

파일 보기

@ -1,4 +1,5 @@
import { format } from 'date-fns'; import { format } from 'date-fns';
import { useTranslation } from 'react-i18next';
interface Props { interface Props {
currentTime: number; currentTime: number;
@ -23,21 +24,22 @@ export function LiveControls({
historyMinutes, historyMinutes,
onHistoryChange, onHistoryChange,
}: Props) { }: Props) {
const { t } = useTranslation();
const kstTime = format(new Date(currentTime + 9 * 3600_000), "yyyy-MM-dd HH:mm:ss 'KST'"); const kstTime = format(new Date(currentTime + 9 * 3600_000), "yyyy-MM-dd HH:mm:ss 'KST'");
return ( return (
<div className="live-controls"> <div className="live-controls">
<div className="live-indicator"> <div className="live-indicator">
<span className="live-dot" /> <span className="live-dot" />
<span className="live-label">LIVE</span> <span className="live-label">{t('header.live')}</span>
</div> </div>
<div className="live-clock">{kstTime}</div> <div className="live-clock">{kstTime}</div>
<div style={{ flex: 1 }} /> <div className="flex-1" />
<div className="history-controls"> <div className="history-controls">
<span className="history-label">HISTORY</span> <span className="history-label">{t('time.history')}</span>
<div className="history-presets"> <div className="history-presets">
{HISTORY_PRESETS.map(p => ( {HISTORY_PRESETS.map(p => (
<button <button

파일 보기

@ -1,4 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Marker, Popup } from 'react-map-gl/maplibre'; import { Marker, Popup } from 'react-map-gl/maplibre';
import { NAV_WARNINGS, NW_LEVEL_LABEL, NW_ORG_LABEL } from '../services/navWarning'; import { NAV_WARNINGS, NW_LEVEL_LABEL, NW_ORG_LABEL } from '../services/navWarning';
import type { NavWarning, NavWarningLevel, TrainingOrg } from '../services/navWarning'; import type { NavWarning, NavWarningLevel, TrainingOrg } from '../services/navWarning';
@ -31,7 +32,6 @@ function WarningIcon({ level, org, size }: { level: NavWarningLevel; org: Traini
); );
} }
// caution (해경 등)
return ( return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none"> <svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<path d="M12 2 L22 12 L12 22 L2 12 Z" fill="rgba(0,0,0,0.5)" stroke={color} strokeWidth="1.2" /> <path d="M12 2 L22 12 L12 22 L2 12 Z" fill="rgba(0,0,0,0.5)" stroke={color} strokeWidth="1.2" />
@ -43,6 +43,7 @@ function WarningIcon({ level, org, size }: { level: NavWarningLevel; org: Traini
export function NavWarningLayer() { export function NavWarningLayer() {
const [selected, setSelected] = useState<NavWarning | null>(null); const [selected, setSelected] = useState<NavWarning | null>(null);
const { t } = useTranslation();
return ( return (
<> <>
@ -53,15 +54,14 @@ export function NavWarningLayer() {
<Marker key={w.id} longitude={w.lng} latitude={w.lat} anchor="center" <Marker key={w.id} longitude={w.lng} latitude={w.lat} anchor="center"
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(w); }}> onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(w); }}>
<div style={{ <div style={{
cursor: 'pointer', display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer',
filter: `drop-shadow(0 0 4px ${color}88)`, filter: `drop-shadow(0 0 4px ${color}88)`,
}}> }} className="flex flex-col items-center">
<WarningIcon level={w.level} org={w.org} size={size} /> <WarningIcon level={w.level} org={w.org} size={size} />
<div style={{ <div style={{
fontSize: 5, color, marginTop: 0, fontSize: 5, color,
textShadow: `0 0 3px ${color}, 0 0 2px #000`, textShadow: `0 0 3px ${color}, 0 0 2px #000`,
whiteSpace: 'nowrap', fontWeight: 700, letterSpacing: 0.3, }} className="whitespace-nowrap font-bold tracking-wide">
}}>
{w.id} {w.id}
</div> </div>
</div> </div>
@ -73,48 +73,43 @@ export function NavWarningLayer() {
<Popup longitude={selected.lng} latitude={selected.lat} <Popup longitude={selected.lng} latitude={selected.lat}
onClose={() => setSelected(null)} closeOnClick={false} onClose={() => setSelected(null)} closeOnClick={false}
anchor="bottom" maxWidth="320px" className="gl-popup"> anchor="bottom" maxWidth="320px" className="gl-popup">
<div style={{ fontFamily: 'monospace', fontSize: 12, minWidth: 240 }}> <div className="min-w-[240px] font-mono text-xs">
<div style={{ <div style={{
background: ORG_COLOR[selected.org], color: '#fff', background: ORG_COLOR[selected.org],
padding: '4px 8px', borderRadius: '4px 4px 0 0', }} className="-mx-2.5 -mt-2.5 mb-2 flex items-center gap-1.5 rounded-t px-2 py-1 text-xs font-bold text-white">
margin: '-10px -10px 8px -10px',
fontWeight: 700, fontSize: 12,
display: 'flex', alignItems: 'center', gap: 6,
}}>
{selected.title} {selected.title}
</div> </div>
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}> <div className="mb-1.5 flex flex-wrap gap-1">
<span style={{ <span style={{
background: LEVEL_COLOR[selected.level], color: '#fff', background: LEVEL_COLOR[selected.level],
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700, }} className="rounded-sm px-1.5 py-px text-[10px] font-bold text-white">
}}>{NW_LEVEL_LABEL[selected.level]}</span> {NW_LEVEL_LABEL[selected.level]}
</span>
<span style={{ <span style={{
background: ORG_COLOR[selected.org] + '33', color: ORG_COLOR[selected.org], background: ORG_COLOR[selected.org] + '33',
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700, color: ORG_COLOR[selected.org],
border: `1px solid ${ORG_COLOR[selected.org]}44`, border: `1px solid ${ORG_COLOR[selected.org]}44`,
}}>{NW_ORG_LABEL[selected.org]}</span> }} className="rounded-sm px-1.5 py-px text-[10px] font-bold">
<span style={{ {NW_ORG_LABEL[selected.org]}
background: '#1a1a2e', color: '#888', </span>
padding: '1px 6px', borderRadius: 3, fontSize: 10, <span className="rounded-sm bg-kcg-card px-1.5 py-px text-[10px] text-kcg-muted">
}}>{selected.area}</span> {selected.area}
</span>
</div> </div>
<div style={{ fontSize: 11, color: '#ccc', marginBottom: 6, lineHeight: 1.4 }}> <div className="mb-1.5 text-[11px] leading-snug text-kcg-text-secondary">
{selected.description} {selected.description}
</div> </div>
<div style={{ fontSize: 9, color: '#666', display: 'flex', flexDirection: 'column', gap: 2 }}> <div className="flex flex-col gap-0.5 text-[9px] text-kcg-dim">
<div>: {selected.altitude}</div> <div>{t('navWarning.altitude')}: {selected.altitude}</div>
<div>{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E</div> <div>{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E</div>
<div>: {selected.source}</div> <div>{t('navWarning.source')}: {selected.source}</div>
</div> </div>
<a <a
href="https://www.khoa.go.kr/nwb/mainPage.do?lang=ko" href="https://www.khoa.go.kr/nwb/mainPage.do?lang=ko"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
style={{ className="mt-1.5 block text-[10px] text-kcg-accent underline"
display: 'block', marginTop: 6, >{t('navWarning.khoaLink')}</a>
fontSize: 10, color: '#3b82f6', textDecoration: 'underline',
}}
>KHOA </a>
</div> </div>
</Popup> </Popup>
)} )}

파일 보기

@ -1,5 +1,6 @@
import { memo, useState } from 'react'; import { memo, useState } from 'react';
import { Marker, Popup } from 'react-map-gl/maplibre'; import { Marker, Popup } from 'react-map-gl/maplibre';
import { useTranslation } from 'react-i18next';
import type { OilFacility, OilFacilityType } from '../types'; import type { OilFacility, OilFacilityType } from '../types';
interface Props { interface Props {
@ -12,11 +13,6 @@ const TYPE_COLORS: Record<OilFacilityType, string> = {
terminal: '#ec4899', petrochemical: '#8b5cf6', desalination: '#06b6d4', terminal: '#ec4899', petrochemical: '#8b5cf6', desalination: '#06b6d4',
}; };
const TYPE_LABELS: Record<OilFacilityType, string> = {
refinery: '정유소', oilfield: '유전', gasfield: '가스전',
terminal: '수출터미널', petrochemical: '석유화학', desalination: '담수화시설',
};
function formatNumber(n: number): string { function formatNumber(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`; if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
@ -54,11 +50,9 @@ function DamageOverlay() {
// SVG icon renderers (JSX versions) // SVG icon renderers (JSX versions)
function RefineryIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) { function RefineryIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) {
// Factory/refinery silhouette on gradient circle background (no white)
const sc = damaged ? '#ff0000' : color; const sc = damaged ? '#ff0000' : color;
return ( return (
<svg viewBox="0 0 36 36" width={size} height={size}> <svg viewBox="0 0 36 36" width={size} height={size}>
{/* Gradient circle background */}
<defs> <defs>
<linearGradient id={`refGrad-${damaged ? 'd' : 'n'}`} x1="0" y1="0" x2="1" y2="1"> <linearGradient id={`refGrad-${damaged ? 'd' : 'n'}`} x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor={color} stopOpacity={0.5} /> <stop offset="0%" stopColor={color} stopOpacity={0.5} />
@ -67,23 +61,16 @@ function RefineryIcon({ size, color, damaged }: { size: number; color: string; d
</defs> </defs>
<circle cx={18} cy={18} r={17} fill={`url(#refGrad-${damaged ? 'd' : 'n'})`} <circle cx={18} cy={18} r={17} fill={`url(#refGrad-${damaged ? 'd' : 'n'})`}
stroke={sc} strokeWidth={damaged ? 2 : 1} opacity={0.9} /> stroke={sc} strokeWidth={damaged ? 2 : 1} opacity={0.9} />
{/* Factory building base */}
<rect x={6} y={19} width={24} height={11} rx={1} fill={color} opacity={0.6} /> <rect x={6} y={19} width={24} height={11} rx={1} fill={color} opacity={0.6} />
{/* Tall chimney/tower (center) */}
<rect x={16} y={7} width={4} height={13} fill={color} opacity={0.7} /> <rect x={16} y={7} width={4} height={13} fill={color} opacity={0.7} />
{/* Short tower (left) */}
<rect x={9} y={12} width={4} height={8} fill={color} opacity={0.65} /> <rect x={9} y={12} width={4} height={8} fill={color} opacity={0.65} />
{/* Medium tower (right) */}
<rect x={23} y={10} width={4} height={10} fill={color} opacity={0.65} /> <rect x={23} y={10} width={4} height={10} fill={color} opacity={0.65} />
{/* Smoke/emission from chimneys */}
<circle cx={11} cy={10} r={1.5} fill={color} opacity={0.3} /> <circle cx={11} cy={10} r={1.5} fill={color} opacity={0.3} />
<circle cx={18} cy={5} r={2} fill={color} opacity={0.3} /> <circle cx={18} cy={5} r={2} fill={color} opacity={0.3} />
<circle cx={25} cy={8} r={1.5} fill={color} opacity={0.3} /> <circle cx={25} cy={8} r={1.5} fill={color} opacity={0.3} />
{/* Windows on building */}
<rect x={10} y={22} width={3} height={3} rx={0.5} fill="rgba(0,0,0,0.4)" /> <rect x={10} y={22} width={3} height={3} rx={0.5} fill="rgba(0,0,0,0.4)" />
<rect x={16} y={22} width={3} height={3} rx={0.5} fill="rgba(0,0,0,0.4)" /> <rect x={16} y={22} width={3} height={3} rx={0.5} fill="rgba(0,0,0,0.4)" />
<rect x={23} y={22} width={3} height={3} rx={0.5} fill="rgba(0,0,0,0.4)" /> <rect x={23} y={22} width={3} height={3} rx={0.5} fill="rgba(0,0,0,0.4)" />
{/* Pipe details */}
<line x1={13} y1={15} x2={16} y2={15} stroke={color} strokeWidth={0.8} opacity={0.5} /> <line x1={13} y1={15} x2={16} y2={15} stroke={color} strokeWidth={0.8} opacity={0.5} />
<line x1={20} y1={13} x2={23} y2={13} stroke={color} strokeWidth={0.8} opacity={0.5} /> <line x1={20} y1={13} x2={23} y2={13} stroke={color} strokeWidth={0.8} opacity={0.5} />
{damaged && <DamageOverlay />} {damaged && <DamageOverlay />}
@ -92,34 +79,21 @@ function RefineryIcon({ size, color, damaged }: { size: number; color: string; d
} }
function OilFieldIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) { function OilFieldIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) {
// Oil pumpjack (nodding donkey) icon — transparent style
const sc = damaged ? '#ff0000' : color; const sc = damaged ? '#ff0000' : color;
return ( return (
<svg viewBox="0 0 36 36" width={size} height={size}> <svg viewBox="0 0 36 36" width={size} height={size}>
{/* Base platform */}
<rect x={4} y={31} width={28} height={2.5} rx={1} fill={color} opacity={0.7} /> <rect x={4} y={31} width={28} height={2.5} rx={1} fill={color} opacity={0.7} />
{/* Support A-frame (tripod legs) */}
<line x1={18} y1={14} x2={12} y2={31} stroke={sc} strokeWidth={1.5} opacity={0.85} /> <line x1={18} y1={14} x2={12} y2={31} stroke={sc} strokeWidth={1.5} opacity={0.85} />
<line x1={18} y1={14} x2={24} y2={31} stroke={sc} strokeWidth={1.5} opacity={0.85} /> <line x1={18} y1={14} x2={24} y2={31} stroke={sc} strokeWidth={1.5} opacity={0.85} />
{/* Cross brace on A-frame */}
<line x1={14} y1={25} x2={22} y2={25} stroke={color} strokeWidth={1} opacity={0.7} /> <line x1={14} y1={25} x2={22} y2={25} stroke={color} strokeWidth={1} opacity={0.7} />
{/* Walking beam (horizontal arm) */}
<line x1={4} y1={12} x2={28} y2={10} stroke={sc} strokeWidth={2} opacity={0.9} /> <line x1={4} y1={12} x2={28} y2={10} stroke={sc} strokeWidth={2} opacity={0.9} />
{/* Pivot point on top of A-frame */}
<circle cx={18} cy={13} r={2} fill={color} opacity={0.8} stroke={sc} strokeWidth={1} /> <circle cx={18} cy={13} r={2} fill={color} opacity={0.8} stroke={sc} strokeWidth={1} />
{/* Horse head (front end, left side) */}
<path d="M4 12 L4 17 L7 17 L7 12" fill="none" stroke={sc} strokeWidth={1.5} opacity={0.85} /> <path d="M4 12 L4 17 L7 17 L7 12" fill="none" stroke={sc} strokeWidth={1.5} opacity={0.85} />
{/* Polished rod (well string going down) */}
<line x1={5.5} y1={17} x2={5.5} y2={31} stroke={color} strokeWidth={1.2} opacity={0.75} /> <line x1={5.5} y1={17} x2={5.5} y2={31} stroke={color} strokeWidth={1.2} opacity={0.75} />
{/* Counterweight (back end, right side) */}
<rect x={25} y={10} width={5} height={6} rx={1} fill={color} opacity={0.6} stroke={sc} strokeWidth={1} /> <rect x={25} y={10} width={5} height={6} rx={1} fill={color} opacity={0.6} stroke={sc} strokeWidth={1} />
{/* Crank arm + pitman arm */}
<line x1={27.5} y1={16} x2={27.5} y2={24} stroke={color} strokeWidth={1.2} opacity={0.75} /> <line x1={27.5} y1={16} x2={27.5} y2={24} stroke={color} strokeWidth={1.2} opacity={0.75} />
{/* Motor/gear box */}
<rect x={24} y={24} width={7} height={5} rx={1} fill={color} opacity={0.55} stroke={sc} strokeWidth={1} /> <rect x={24} y={24} width={7} height={5} rx={1} fill={color} opacity={0.55} stroke={sc} strokeWidth={1} />
{/* Wellhead at bottom */}
<rect x={3} y={28} width={5} height={3} rx={0.5} fill={color} opacity={0.65} stroke={sc} strokeWidth={0.8} /> <rect x={3} y={28} width={5} height={3} rx={0.5} fill={color} opacity={0.65} stroke={sc} strokeWidth={0.8} />
{/* Oil drop symbol on wellhead */}
<path d="M5.5 29 C5.5 29 4.5 30 4.5 30.5 C4.5 31 5 31.2 5.5 31.2 C6 31.2 6.5 31 6.5 30.5 C6.5 30 5.5 29 5.5 29Z" <path d="M5.5 29 C5.5 29 4.5 30 4.5 30.5 C4.5 31 5 31.2 5.5 31.2 C6 31.2 6.5 31 6.5 30.5 C6.5 30 5.5 29 5.5 29Z"
fill={color} opacity={0.85} /> fill={color} opacity={0.85} />
{damaged && <DamageOverlay />} {damaged && <DamageOverlay />}
@ -128,27 +102,19 @@ function OilFieldIcon({ size, color, damaged }: { size: number; color: string; d
} }
function GasFieldIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) { function GasFieldIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) {
// Spherical gas storage tank with support legs (transparent style)
return ( return (
<svg viewBox="0 0 36 36" width={size} height={size}> <svg viewBox="0 0 36 36" width={size} height={size}>
{/* Support legs */}
<line x1={10} y1={24} x2={8} y2={33} stroke={color} strokeWidth={1.5} opacity={0.7} /> <line x1={10} y1={24} x2={8} y2={33} stroke={color} strokeWidth={1.5} opacity={0.7} />
<line x1={14} y1={25} x2={13} y2={33} stroke={color} strokeWidth={1.5} opacity={0.7} /> <line x1={14} y1={25} x2={13} y2={33} stroke={color} strokeWidth={1.5} opacity={0.7} />
<line x1={22} y1={25} x2={23} y2={33} stroke={color} strokeWidth={1.5} opacity={0.7} /> <line x1={22} y1={25} x2={23} y2={33} stroke={color} strokeWidth={1.5} opacity={0.7} />
<line x1={26} y1={24} x2={28} y2={33} stroke={color} strokeWidth={1.5} opacity={0.7} /> <line x1={26} y1={24} x2={28} y2={33} stroke={color} strokeWidth={1.5} opacity={0.7} />
{/* Cross braces */}
<line x1={9} y1={29} x2={14} y2={27} stroke={color} strokeWidth={0.8} opacity={0.55} /> <line x1={9} y1={29} x2={14} y2={27} stroke={color} strokeWidth={0.8} opacity={0.55} />
<line x1={22} y1={27} x2={27} y2={29} stroke={color} strokeWidth={0.8} opacity={0.55} /> <line x1={22} y1={27} x2={27} y2={29} stroke={color} strokeWidth={0.8} opacity={0.55} />
{/* Base platform */}
<line x1={7} y1={33} x2={29} y2={33} stroke={color} strokeWidth={1.2} opacity={0.6} /> <line x1={7} y1={33} x2={29} y2={33} stroke={color} strokeWidth={1.2} opacity={0.6} />
{/* Spherical tank body */}
<ellipse cx={18} cy={16} rx={12} ry={10} fill={color} opacity={0.45} <ellipse cx={18} cy={16} rx={12} ry={10} fill={color} opacity={0.45}
stroke={damaged ? '#ff0000' : color} strokeWidth={1.5} /> stroke={damaged ? '#ff0000' : color} strokeWidth={1.5} />
{/* Highlight arc (top reflection) */}
<ellipse cx={16} cy={12} rx={7} ry={5} fill={color} opacity={0.3} /> <ellipse cx={16} cy={12} rx={7} ry={5} fill={color} opacity={0.3} />
{/* Equator band */}
<ellipse cx={18} cy={16} rx={12} ry={2.5} fill="none" stroke={color} strokeWidth={0.8} opacity={0.55} /> <ellipse cx={18} cy={16} rx={12} ry={2.5} fill="none" stroke={color} strokeWidth={0.8} opacity={0.55} />
{/* Top valve/pipe */}
<rect x={16.5} y={4} width={3} height={3} rx={0.5} fill={color} opacity={0.7} /> <rect x={16.5} y={4} width={3} height={3} rx={0.5} fill={color} opacity={0.7} />
<line x1={18} y1={4} x2={18} y2={6} stroke={color} strokeWidth={1.5} opacity={0.7} /> <line x1={18} y1={4} x2={18} y2={6} stroke={color} strokeWidth={1.5} opacity={0.7} />
{damaged && <DamageOverlay />} {damaged && <DamageOverlay />}
@ -188,35 +154,25 @@ function PetrochemIcon({ size, color, damaged }: { size: number; color: string;
} }
function DesalIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) { function DesalIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) {
// Water drop + faucet + filter container — desalination plant (transparent)
const sc = damaged ? '#ff0000' : color; const sc = damaged ? '#ff0000' : color;
return ( return (
<svg viewBox="0 0 36 36" width={size} height={size}> <svg viewBox="0 0 36 36" width={size} height={size}>
{/* Large water drop (left side) */}
<path d="M14 3 C14 3 6 14 6 19 C6 23.5 9.5 27 14 27 C18.5 27 22 23.5 22 19 C22 14 14 3 14 3Z" <path d="M14 3 C14 3 6 14 6 19 C6 23.5 9.5 27 14 27 C18.5 27 22 23.5 22 19 C22 14 14 3 14 3Z"
fill={color} opacity={0.4} stroke={sc} strokeWidth={1.2} /> fill={color} opacity={0.4} stroke={sc} strokeWidth={1.2} />
{/* Inner drop ripple */}
<path d="M14 10 C14 10 10 16 10 19 C10 21.5 11.8 23.5 14 23.5 C16.2 23.5 18 21.5 18 19 C18 16 14 10 14 10Z" <path d="M14 10 C14 10 10 16 10 19 C10 21.5 11.8 23.5 14 23.5 C16.2 23.5 18 21.5 18 19 C18 16 14 10 14 10Z"
fill={color} opacity={0.3} /> fill={color} opacity={0.3} />
{/* Faucet/tap (top right) */}
<rect x={24} y={5} width={6} height={3} rx={1} fill={color} opacity={0.5} stroke={sc} strokeWidth={0.8} /> <rect x={24} y={5} width={6} height={3} rx={1} fill={color} opacity={0.5} stroke={sc} strokeWidth={0.8} />
<line x1={27} y1={8} x2={27} y2={12} stroke={sc} strokeWidth={1.5} opacity={0.7} /> <line x1={27} y1={8} x2={27} y2={12} stroke={sc} strokeWidth={1.5} opacity={0.7} />
<path d="M24 8 L24 10 Q24 12 26 12 L28 12 Q30 12 30 10 L30 8" <path d="M24 8 L24 10 Q24 12 26 12 L28 12 Q30 12 30 10 L30 8"
fill="none" stroke={sc} strokeWidth={0.8} opacity={0.6} /> fill="none" stroke={sc} strokeWidth={0.8} opacity={0.6} />
{/* Water drops from faucet */}
<circle cx={27} cy={14.5} r={1} fill={color} opacity={0.55} /> <circle cx={27} cy={14.5} r={1} fill={color} opacity={0.55} />
<circle cx={27} cy={17} r={0.7} fill={color} opacity={0.45} /> <circle cx={27} cy={17} r={0.7} fill={color} opacity={0.45} />
{/* Filter/treatment container (bottom right) */}
<rect x={23} y={20} width={9} height={12} rx={1.5} fill={color} opacity={0.4} <rect x={23} y={20} width={9} height={12} rx={1.5} fill={color} opacity={0.4}
stroke={sc} strokeWidth={1} /> stroke={sc} strokeWidth={1} />
{/* Filter layers inside container */}
<line x1={24} y1={24} x2={31} y2={24} stroke={color} strokeWidth={0.6} opacity={0.5} /> <line x1={24} y1={24} x2={31} y2={24} stroke={color} strokeWidth={0.6} opacity={0.5} />
<line x1={24} y1={27} x2={31} y2={27} stroke={color} strokeWidth={0.6} opacity={0.5} /> <line x1={24} y1={27} x2={31} y2={27} stroke={color} strokeWidth={0.6} opacity={0.5} />
{/* Pipe connecting drop to container */}
<path d="M18 22 L23 22" stroke={sc} strokeWidth={1} opacity={0.6} /> <path d="M18 22 L23 22" stroke={sc} strokeWidth={1} opacity={0.6} />
{/* Output pipe from container */}
<line x1={27.5} y1={32} x2={27.5} y2={34} stroke={color} strokeWidth={1} opacity={0.55} /> <line x1={27.5} y1={32} x2={27.5} y2={34} stroke={color} strokeWidth={1} opacity={0.55} />
{/* Base */}
<line x1={4} y1={34} x2={33} y2={34} stroke={color} strokeWidth={1} opacity={0.25} /> <line x1={4} y1={34} x2={33} y2={34} stroke={color} strokeWidth={1} opacity={0.25} />
{damaged && <DamageOverlay />} {damaged && <DamageOverlay />}
</svg> </svg>
@ -247,6 +203,7 @@ export const OilFacilityLayer = memo(function OilFacilityLayer({ facilities, cur
}); });
function FacilityMarker({ facility, currentTime }: { facility: OilFacility; currentTime: number }) { function FacilityMarker({ facility, currentTime }: { facility: OilFacility; currentTime: number }) {
const { t } = useTranslation('ships');
const [showPopup, setShowPopup] = useState(false); const [showPopup, setShowPopup] = useState(false);
const color = TYPE_COLORS[facility.type]; const color = TYPE_COLORS[facility.type];
const isDamaged = !!(facility.damaged && facility.damagedAt && currentTime >= facility.damagedAt); const isDamaged = !!(facility.damaged && facility.damagedAt && currentTime >= facility.damagedAt);
@ -256,33 +213,32 @@ function FacilityMarker({ facility, currentTime }: { facility: OilFacility; curr
return ( return (
<> <>
<Marker longitude={facility.lng} latitude={facility.lat} anchor="center"> <Marker longitude={facility.lng} latitude={facility.lat} anchor="center">
<div style={{ position: 'relative' }}> <div className="relative">
{/* Planned strike targeting ring */} {/* Planned strike targeting ring */}
{isPlanned && ( {isPlanned && (
<div style={{ <div
position: 'absolute', top: '50%', left: '50%', className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-9 h-9 rounded-full pointer-events-none"
transform: 'translate(-50%, -50%)', style={{
width: 36, height: 36, borderRadius: '50%', border: '2px dashed #ff6600',
border: '2px dashed #ff6600', animation: 'planned-pulse 2s ease-in-out infinite',
animation: 'planned-pulse 2s ease-in-out infinite', }}
pointerEvents: 'none', >
}}>
{/* Crosshair lines */} {/* Crosshair lines */}
<div style={{ position: 'absolute', top: -6, left: '50%', transform: 'translateX(-50%)', width: 1, height: 6, background: '#ff6600', opacity: 0.7 }} /> <div className="absolute -top-1.5 left-1/2 -translate-x-1/2 w-px h-1.5 bg-[#ff6600] opacity-70" />
<div style={{ position: 'absolute', bottom: -6, left: '50%', transform: 'translateX(-50%)', width: 1, height: 6, background: '#ff6600', opacity: 0.7 }} /> <div className="absolute -bottom-1.5 left-1/2 -translate-x-1/2 w-px h-1.5 bg-[#ff6600] opacity-70" />
<div style={{ position: 'absolute', left: -6, top: '50%', transform: 'translateY(-50%)', width: 6, height: 1, background: '#ff6600', opacity: 0.7 }} /> <div className="absolute -left-1.5 top-1/2 -translate-y-1/2 w-1.5 h-px bg-[#ff6600] opacity-70" />
<div style={{ position: 'absolute', right: -6, top: '50%', transform: 'translateY(-50%)', width: 6, height: 1, background: '#ff6600', opacity: 0.7 }} /> <div className="absolute -right-1.5 top-1/2 -translate-y-1/2 w-1.5 h-px bg-[#ff6600] opacity-70" />
</div> </div>
)} )}
<div style={{ cursor: 'pointer' }} <div className="cursor-pointer"
onClick={(e) => { e.stopPropagation(); setShowPopup(true); }}> onClick={(e) => { e.stopPropagation(); setShowPopup(true); }}>
<FacilityIconSvg facility={facility} damaged={isDamaged} /> <FacilityIconSvg facility={facility} damaged={isDamaged} />
</div> </div>
<div className="gl-marker-label" style={{ <div className="gl-marker-label text-[8px]" style={{
color: isDamaged ? '#ff0000' : isPlanned ? '#ff6600' : color, fontSize: 8, color: isDamaged ? '#ff0000' : isPlanned ? '#ff6600' : color,
}}> }}>
{isDamaged ? '\u{1F4A5} ' : isPlanned ? '\u{1F3AF} ' : ''}{facility.nameKo} {isDamaged ? '\u{1F4A5} ' : isPlanned ? '\u{1F3AF} ' : ''}{facility.nameKo}
{stat && <span style={{ color: '#aaa', fontSize: 7, marginLeft: 3 }}>{stat}</span>} {stat && <span className="text-[#aaa] text-[7px] ml-0.5">{stat}</span>}
</div> </div>
</div> </div>
</Marker> </Marker>
@ -290,69 +246,60 @@ function FacilityMarker({ facility, currentTime }: { facility: OilFacility; curr
<Popup longitude={facility.lng} latitude={facility.lat} <Popup longitude={facility.lng} latitude={facility.lat}
onClose={() => setShowPopup(false)} closeOnClick={false} onClose={() => setShowPopup(false)} closeOnClick={false}
anchor="bottom" maxWidth="280px" className="gl-popup"> anchor="bottom" maxWidth="280px" className="gl-popup">
<div style={{ minWidth: 220, fontFamily: 'monospace', fontSize: 12 }}> <div className="min-w-[220px] font-mono text-xs">
<div style={{ display: 'flex', gap: 4, alignItems: 'center', marginBottom: 6 }}> <div className="flex gap-1 items-center mb-1.5">
<span style={{ <span
background: color, color: '#fff', padding: '2px 6px', className="px-1.5 py-0.5 rounded text-[10px] font-bold text-white"
borderRadius: 3, fontSize: 10, fontWeight: 700, style={{ background: color }}
}}>{TYPE_LABELS[facility.type]}</span> >{t(`facility.type.${facility.type}`)}</span>
{isDamaged && ( {isDamaged && (
<span style={{ <span className="px-1.5 py-0.5 rounded text-[10px] font-bold text-white bg-[#ff0000]">
background: '#ff0000', color: '#fff', padding: '2px 6px', {t('facility.damaged')}
borderRadius: 3, fontSize: 10, fontWeight: 700, </span>
}}></span>
)} )}
{isPlanned && ( {isPlanned && (
<span style={{ <span className="px-1.5 py-0.5 rounded text-[10px] font-bold text-white bg-[#ff6600]">
background: '#ff6600', color: '#fff', padding: '2px 6px', {t('facility.plannedStrike')}
borderRadius: 3, fontSize: 10, fontWeight: 700, </span>
}}> </span>
)} )}
</div> </div>
<div style={{ fontWeight: 700, fontSize: 13, margin: '4px 0' }}>{facility.nameKo}</div> <div className="font-bold text-[13px] my-1">{facility.nameKo}</div>
<div style={{ fontSize: 10, color: '#888', marginBottom: 6 }}>{facility.name}</div> <div className="text-[10px] text-kcg-muted mb-1.5">{facility.name}</div>
<div style={{ <div className="bg-black/30 rounded p-1.5 grid grid-cols-2 gap-x-3 gap-y-1 text-[11px]">
background: 'rgba(0,0,0,0.3)', borderRadius: 4, padding: '6px 8px',
display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 12px', fontSize: 11,
}}>
{facility.capacityBpd != null && ( {facility.capacityBpd != null && (
<><span style={{ color: '#888' }}>/</span> <><span className="text-kcg-muted">{t('facility.production')}</span>
<span style={{ color: '#fff', fontWeight: 600 }}>{formatNumber(facility.capacityBpd)} bpd</span></> <span className="text-white font-semibold">{formatNumber(facility.capacityBpd)} bpd</span></>
)} )}
{facility.capacityMgd != null && ( {facility.capacityMgd != null && (
<><span style={{ color: '#888' }}></span> <><span className="text-kcg-muted">{t('facility.desalProduction')}</span>
<span style={{ color: '#fff', fontWeight: 600 }}>{formatNumber(facility.capacityMgd)} MGD</span></> <span className="text-white font-semibold">{formatNumber(facility.capacityMgd)} MGD</span></>
)} )}
{facility.capacityMcfd != null && ( {facility.capacityMcfd != null && (
<><span style={{ color: '#888' }}></span> <><span className="text-kcg-muted">{t('facility.gasProduction')}</span>
<span style={{ color: '#fff', fontWeight: 600 }}>{formatNumber(facility.capacityMcfd)} Mcf/d</span></> <span className="text-white font-semibold">{formatNumber(facility.capacityMcfd)} Mcf/d</span></>
)} )}
{facility.reservesBbl != null && ( {facility.reservesBbl != null && (
<><span style={{ color: '#888' }}>()</span> <><span className="text-kcg-muted">{t('facility.reserveOil')}</span>
<span style={{ color: '#fff', fontWeight: 600 }}>{facility.reservesBbl}B </span></> <span className="text-white font-semibold">{facility.reservesBbl}B {t('facility.barrels')}</span></>
)} )}
{facility.reservesTcf != null && ( {facility.reservesTcf != null && (
<><span style={{ color: '#888' }}>()</span> <><span className="text-kcg-muted">{t('facility.reserveGas')}</span>
<span style={{ color: '#fff', fontWeight: 600 }}>{facility.reservesTcf} Tcf</span></> <span className="text-white font-semibold">{facility.reservesTcf} Tcf</span></>
)} )}
{facility.operator && ( {facility.operator && (
<><span style={{ color: '#888' }}></span> <><span className="text-kcg-muted">{t('facility.operator')}</span>
<span style={{ color: '#fff' }}>{facility.operator}</span></> <span className="text-white">{facility.operator}</span></>
)} )}
</div> </div>
{facility.description && ( {facility.description && (
<p style={{ margin: '6px 0 0', fontSize: 11, color: '#ccc', lineHeight: 1.4 }}>{facility.description}</p> <p className="mt-1.5 mb-0 text-[11px] text-kcg-text-secondary leading-snug">{facility.description}</p>
)} )}
{isPlanned && facility.plannedLabel && ( {isPlanned && facility.plannedLabel && (
<div style={{ <div className="mt-1.5 px-2 py-1 text-[11px] rounded leading-snug bg-[rgba(255,102,0,0.15)] border border-[rgba(255,102,0,0.4)] text-[#ff9933]">
margin: '6px 0 0', padding: '4px 8px', fontSize: 11,
background: 'rgba(255,102,0,0.15)', border: '1px solid rgba(255,102,0,0.4)',
borderRadius: 4, color: '#ff9933', lineHeight: 1.4,
}}>
{facility.plannedLabel} {facility.plannedLabel}
</div> </div>
)} )}
<div style={{ fontSize: 10, color: '#666', marginTop: 6 }}> <div className="text-[10px] text-kcg-dim mt-1.5">
{facility.lat.toFixed(4)}°N, {facility.lng.toFixed(4)}°E {facility.lat.toFixed(4)}°N, {facility.lng.toFixed(4)}°E
</div> </div>
</div> </div>

파일 보기

@ -1,4 +1,5 @@
import { useState, useMemo } from 'react'; import { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Marker, Popup } from 'react-map-gl/maplibre'; import { Marker, Popup } from 'react-map-gl/maplibre';
import type { OsintItem } from '../services/osint'; import type { OsintItem } from '../services/osint';
@ -18,13 +19,16 @@ const CAT_ICON: Record<string, string> = {
shipping: '🚢', shipping: '🚢',
}; };
function timeAgo(ts: number): string { function useTimeAgo() {
const diff = Date.now() - ts; const { t } = useTranslation();
const m = Math.floor(diff / 60000); return (ts: number): string => {
if (m < 60) return `${m}분 전`; const diff = Date.now() - ts;
const h = Math.floor(m / 60); const m = Math.floor(diff / 60000);
if (h < 24) return `${h}시간 전`; if (m < 60) return t('time.minutesAgo', { count: m });
return `${Math.floor(h / 24)}일 전`; 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 { interface Props {
@ -38,8 +42,9 @@ const MAP_CATEGORIES = new Set(['maritime_accident', 'fishing', 'maritime_traffi
export function OsintMapLayer({ osintFeed, currentTime }: Props) { export function OsintMapLayer({ osintFeed, currentTime }: Props) {
const [selected, setSelected] = useState<OsintItem | null>(null); const [selected, setSelected] = useState<OsintItem | null>(null);
const { t } = useTranslation();
const timeAgo = useTimeAgo();
// 좌표가 있고, 해양 관련 카테고리이며, 최근 3시간 이내인 OSINT만 표시
const geoItems = useMemo(() => osintFeed.filter( const geoItems = useMemo(() => osintFeed.filter(
(item): item is OsintItem & { lat: number; lng: number } => (item): item is OsintItem & { lat: number; lng: number } =>
item.lat != null && item.lng != null item.lat != null && item.lng != null
@ -51,30 +56,25 @@ export function OsintMapLayer({ osintFeed, currentTime }: Props) {
<> <>
{geoItems.map(item => { {geoItems.map(item => {
const color = CAT_COLOR[item.category] || '#888'; const color = CAT_COLOR[item.category] || '#888';
const isRecent = currentTime - item.timestamp < ONE_HOUR; // 1시간 이내 const isRecent = currentTime - item.timestamp < ONE_HOUR;
return ( return (
<Marker key={item.id} longitude={item.lng} latitude={item.lat} anchor="center" <Marker key={item.id} longitude={item.lng} latitude={item.lat} anchor="center"
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(item); }}> onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(item); }}>
<div style={{ <div style={{
cursor: 'pointer', display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer',
filter: `drop-shadow(0 0 6px ${color}aa)`, filter: `drop-shadow(0 0 6px ${color}aa)`,
}}> }} className="flex flex-col items-center">
<div style={{ <div style={{
width: 22, height: 22, borderRadius: '50%',
background: `rgba(0,0,0,0.6)`,
border: `2px solid ${color}`, border: `2px solid ${color}`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 12,
animation: isRecent ? 'pulse 2s ease-in-out infinite' : undefined, 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] || '📰'} {CAT_ICON[item.category] || '📰'}
</div> </div>
{isRecent && ( {isRecent && (
<div style={{ <div style={{
fontSize: 5, color, marginTop: 1, fontSize: 5, color,
textShadow: `0 0 3px ${color}, 0 0 2px #000`, textShadow: `0 0 3px ${color}, 0 0 2px #000`,
fontWeight: 700, letterSpacing: 0.3, }} className="mt-px font-bold tracking-wide">
}}>
NEW NEW
</div> </div>
)} )}
@ -87,41 +87,36 @@ export function OsintMapLayer({ osintFeed, currentTime }: Props) {
<Popup longitude={selected.lng} latitude={selected.lat} <Popup longitude={selected.lng} latitude={selected.lat}
onClose={() => setSelected(null)} closeOnClick={false} onClose={() => setSelected(null)} closeOnClick={false}
anchor="bottom" maxWidth="320px" className="gl-popup"> anchor="bottom" maxWidth="320px" className="gl-popup">
<div style={{ fontFamily: 'monospace', fontSize: 12, minWidth: 240 }}> <div className="min-w-[240px] font-mono text-xs">
<div style={{ <div style={{
background: CAT_COLOR[selected.category] || '#888', color: '#fff', background: CAT_COLOR[selected.category] || '#888',
padding: '4px 8px', borderRadius: '4px 4px 0 0', }} 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">
margin: '-10px -10px 8px -10px',
fontWeight: 700, fontSize: 11,
display: 'flex', alignItems: 'center', gap: 6,
}}>
<span>{CAT_ICON[selected.category] || '📰'}</span> <span>{CAT_ICON[selected.category] || '📰'}</span>
OSINT OSINT
</div> </div>
<div style={{ fontSize: 11, color: '#ccc', marginBottom: 6, lineHeight: 1.4 }}> <div className="mb-1.5 text-[11px] leading-snug text-kcg-text-secondary">
{selected.title} {selected.title}
</div> </div>
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}> <div className="mb-1.5 flex flex-wrap gap-1">
<span style={{ <span style={{
background: CAT_COLOR[selected.category] || '#888', color: '#fff', background: CAT_COLOR[selected.category] || '#888',
padding: '1px 6px', borderRadius: 3, fontSize: 9, fontWeight: 700, }} className="rounded-sm px-1.5 py-px text-[9px] font-bold text-white">
}}>{selected.category.replace('_', ' ').toUpperCase()}</span> {selected.category.replace('_', ' ').toUpperCase()}
<span style={{ </span>
background: '#1a1a2e', color: '#888', <span className="rounded-sm bg-kcg-card px-1.5 py-px text-[9px] text-kcg-muted">
padding: '1px 6px', borderRadius: 3, fontSize: 9, {selected.source}
}}>{selected.source}</span> </span>
<span style={{ <span className="rounded-sm bg-kcg-card px-1.5 py-px text-[9px] text-kcg-dim">
background: '#1a1a2e', color: '#666', {timeAgo(selected.timestamp)}
padding: '1px 6px', borderRadius: 3, fontSize: 9, </span>
}}>{timeAgo(selected.timestamp)}</span>
</div> </div>
{selected.url && ( {selected.url && (
<a <a
href={selected.url} href={selected.url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
style={{ fontSize: 10, color: '#3b82f6', textDecoration: 'underline' }} className="text-[10px] text-kcg-accent underline"
> </a> >{t('osintMap.viewOriginal')}</a>
)} )}
</div> </div>
</Popup> </Popup>

파일 보기

@ -1,4 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Marker, Popup } from 'react-map-gl/maplibre'; import { Marker, Popup } from 'react-map-gl/maplibre';
import { PIRACY_ZONES, PIRACY_LEVEL_COLOR, PIRACY_LEVEL_LABEL } from '../services/piracy'; import { PIRACY_ZONES, PIRACY_LEVEL_COLOR, PIRACY_LEVEL_LABEL } from '../services/piracy';
import type { PiracyZone } from '../services/piracy'; import type { PiracyZone } from '../services/piracy';
@ -6,16 +7,11 @@ import type { PiracyZone } from '../services/piracy';
function SkullIcon({ color, size }: { color: string; size: number }) { function SkullIcon({ color, size }: { color: string; size: number }) {
return ( return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none"> <svg width={size} height={size} viewBox="0 0 24 24" fill="none">
{/* skull */}
<ellipse cx="12" cy="10" rx="8" ry="9" fill="rgba(0,0,0,0.6)" stroke={color} strokeWidth="1.5" /> <ellipse cx="12" cy="10" rx="8" ry="9" fill="rgba(0,0,0,0.6)" stroke={color} strokeWidth="1.5" />
{/* eyes */}
<ellipse cx="8.5" cy="9" rx="2" ry="2.2" fill={color} opacity="0.9" /> <ellipse cx="8.5" cy="9" rx="2" ry="2.2" fill={color} opacity="0.9" />
<ellipse cx="15.5" cy="9" rx="2" ry="2.2" fill={color} opacity="0.9" /> <ellipse cx="15.5" cy="9" rx="2" ry="2.2" fill={color} opacity="0.9" />
{/* nose */}
<path d="M11 13 L12 14.5 L13 13" stroke={color} strokeWidth="1" fill="none" /> <path d="M11 13 L12 14.5 L13 13" stroke={color} strokeWidth="1" fill="none" />
{/* jaw */}
<path d="M7 17 Q12 21 17 17" stroke={color} strokeWidth="1.2" fill="none" /> <path d="M7 17 Q12 21 17 17" stroke={color} strokeWidth="1.2" fill="none" />
{/* crossbones */}
<line x1="4" y1="20" x2="20" y2="24" stroke={color} strokeWidth="1.5" strokeLinecap="round" /> <line x1="4" y1="20" x2="20" y2="24" stroke={color} strokeWidth="1.5" strokeLinecap="round" />
<line x1="20" y1="20" x2="4" y2="24" stroke={color} strokeWidth="1.5" strokeLinecap="round" /> <line x1="20" y1="20" x2="4" y2="24" stroke={color} strokeWidth="1.5" strokeLinecap="round" />
</svg> </svg>
@ -24,6 +20,7 @@ function SkullIcon({ color, size }: { color: string; size: number }) {
export function PiracyLayer() { export function PiracyLayer() {
const [selected, setSelected] = useState<PiracyZone | null>(null); const [selected, setSelected] = useState<PiracyZone | null>(null);
const { t } = useTranslation();
return ( return (
<> <>
@ -34,17 +31,15 @@ export function PiracyLayer() {
<Marker key={zone.id} longitude={zone.lng} latitude={zone.lat} anchor="center" <Marker key={zone.id} longitude={zone.lng} latitude={zone.lat} anchor="center"
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(zone); }}> onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(zone); }}>
<div style={{ <div style={{
cursor: 'pointer', display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer',
filter: `drop-shadow(0 0 8px ${color}aa)`, filter: `drop-shadow(0 0 8px ${color}aa)`,
animation: zone.level === 'critical' ? 'pulse 2s ease-in-out infinite' : undefined, animation: zone.level === 'critical' ? 'pulse 2s ease-in-out infinite' : undefined,
}}> }} className="flex flex-col items-center">
<SkullIcon color={color} size={size} /> <SkullIcon color={color} size={size} />
<div style={{ <div style={{
fontSize: 7, color, marginTop: 1, fontSize: 7, color,
textShadow: `0 0 3px ${color}, 0 0 2px #000`, textShadow: `0 0 3px ${color}, 0 0 2px #000`,
whiteSpace: 'nowrap', fontWeight: 700, letterSpacing: 0.3, }} className="mt-px whitespace-nowrap font-mono font-bold tracking-wide">
fontFamily: 'monospace',
}}>
{PIRACY_LEVEL_LABEL[zone.level]} {PIRACY_LEVEL_LABEL[zone.level]}
</div> </div>
</div> </div>
@ -56,43 +51,40 @@ export function PiracyLayer() {
<Popup longitude={selected.lng} latitude={selected.lat} <Popup longitude={selected.lng} latitude={selected.lat}
onClose={() => setSelected(null)} closeOnClick={false} onClose={() => setSelected(null)} closeOnClick={false}
anchor="bottom" maxWidth="340px" className="gl-popup"> anchor="bottom" maxWidth="340px" className="gl-popup">
<div style={{ fontFamily: 'monospace', fontSize: 12, minWidth: 260 }}> <div className="min-w-[260px] font-mono text-xs">
<div style={{ <div style={{
background: PIRACY_LEVEL_COLOR[selected.level], color: '#fff', background: PIRACY_LEVEL_COLOR[selected.level],
padding: '5px 10px', borderRadius: '4px 4px 0 0', }} className="-mx-2.5 -mt-2.5 mb-2 flex items-center gap-1.5 rounded-t px-2.5 py-1.5 text-xs font-bold text-white">
margin: '-10px -10px 8px -10px', <span className="text-sm"></span>
fontWeight: 700, fontSize: 12,
display: 'flex', alignItems: 'center', gap: 6,
}}>
<span style={{ fontSize: 14 }}></span>
{selected.nameKo} {selected.nameKo}
</div> </div>
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}> <div className="mb-1.5 flex flex-wrap gap-1">
<span style={{ <span style={{
background: PIRACY_LEVEL_COLOR[selected.level], color: '#fff', background: PIRACY_LEVEL_COLOR[selected.level],
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700, }} className="rounded-sm px-1.5 py-px text-[10px] font-bold text-white">
}}>{PIRACY_LEVEL_LABEL[selected.level]}</span> {PIRACY_LEVEL_LABEL[selected.level]}
<span style={{ </span>
background: '#1a1a2e', color: '#888', <span className="rounded-sm bg-kcg-card px-1.5 py-px text-[10px] text-kcg-muted">
padding: '1px 6px', borderRadius: 3, fontSize: 10, {selected.name}
}}>{selected.name}</span> </span>
{selected.recentIncidents != null && ( {selected.recentIncidents != null && (
<span style={{ <span style={{
background: '#1a1a2e', color: PIRACY_LEVEL_COLOR[selected.level], color: PIRACY_LEVEL_COLOR[selected.level],
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700,
border: `1px solid ${PIRACY_LEVEL_COLOR[selected.level]}44`, border: `1px solid ${PIRACY_LEVEL_COLOR[selected.level]}44`,
}}> 1 {selected.recentIncidents}</span> }} className="rounded-sm bg-kcg-card px-1.5 py-px text-[10px] font-bold">
{t('piracy.recentIncidents', { count: selected.recentIncidents })}
</span>
)} )}
</div> </div>
<div style={{ fontSize: 11, color: '#ccc', marginBottom: 6, lineHeight: 1.5 }}> <div className="mb-1.5 text-[11px] leading-relaxed text-kcg-text-secondary">
{selected.description} {selected.description}
</div> </div>
<div style={{ fontSize: 10, color: '#999', lineHeight: 1.4 }}> <div className="text-[10px] leading-snug text-[#999]">
{selected.detail} {selected.detail}
</div> </div>
<div style={{ fontSize: 9, color: '#666', marginTop: 6 }}> <div className="mt-1.5 text-[9px] text-kcg-dim">
{selected.lat.toFixed(2)}°N, {selected.lng.toFixed(2)}°E {selected.lat.toFixed(2)}°N, {selected.lng.toFixed(2)}°E
</div> </div>
</div> </div>

파일 보기

@ -1,4 +1,5 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
interface Props { interface Props {
isPlaying: boolean; isPlaying: boolean;
@ -51,6 +52,7 @@ export function ReplayControls({
onSpeedChange, onSpeedChange,
onRangeChange, onRangeChange,
}: Props) { }: Props) {
const { t } = useTranslation();
const [showPicker, setShowPicker] = useState(false); const [showPicker, setShowPicker] = useState(false);
const [customStart, setCustomStart] = useState(toKSTInput(startTime)); const [customStart, setCustomStart] = useState(toKSTInput(startTime));
const [customEnd, setCustomEnd] = useState(toKSTInput(endTime)); const [customEnd, setCustomEnd] = useState(toKSTInput(endTime));
@ -76,7 +78,7 @@ export function ReplayControls({
return ( return (
<div className="replay-controls"> <div className="replay-controls">
{/* Left: transport controls */} {/* Left: transport controls */}
<button className="ctrl-btn" onClick={onReset} title="Reset"> <button className="ctrl-btn" onClick={onReset} title={t('controls.reset')}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M1 4v6h6" /> <path d="M1 4v6h6" />
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" /> <path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
@ -109,7 +111,7 @@ export function ReplayControls({
</div> </div>
{/* Spacer */} {/* Spacer */}
<div style={{ flex: 1 }} /> <div className="flex-1" />
{/* Right: range presets + custom picker */} {/* Right: range presets + custom picker */}
<div className="range-controls"> <div className="range-controls">
@ -126,7 +128,7 @@ export function ReplayControls({
<button <button
className={`range-btn custom-btn ${showPicker ? 'active' : ''}`} className={`range-btn custom-btn ${showPicker ? 'active' : ''}`}
onClick={() => setShowPicker(!showPicker)} onClick={() => setShowPicker(!showPicker)}
title="Custom range" title={t('controls.customRange')}
> >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="4" width="18" height="18" rx="2" /> <rect x="3" y="4" width="18" height="18" rx="2" />
@ -141,7 +143,7 @@ export function ReplayControls({
<div className="range-picker"> <div className="range-picker">
<div className="range-picker-row"> <div className="range-picker-row">
<label> <label>
<span>FROM (KST)</span> <span>{t('controls.from')}</span>
<input <input
type="datetime-local" type="datetime-local"
value={customStart} value={customStart}
@ -149,7 +151,7 @@ export function ReplayControls({
/> />
</label> </label>
<label> <label>
<span>TO (KST)</span> <span>{t('controls.to')}</span>
<input <input
type="datetime-local" type="datetime-local"
value={customEnd} value={customEnd}
@ -157,7 +159,7 @@ export function ReplayControls({
/> />
</label> </label>
<button className="range-apply-btn" onClick={handleCustomApply}> <button className="range-apply-btn" onClick={handleCustomApply}>
APPLY {t('controls.apply')}
</button> </button>
</div> </div>
</div> </div>

파일 보기

@ -1,4 +1,5 @@
import { useEffect, useMemo, useState, useRef } from 'react'; import { useEffect, useMemo, useState, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Map, Marker, Popup, Source, Layer, NavigationControl } from 'react-map-gl/maplibre'; import { Map, Marker, Popup, Source, Layer, NavigationControl } from 'react-map-gl/maplibre';
import type { MapRef } from 'react-map-gl/maplibre'; import type { MapRef } from 'react-map-gl/maplibre';
import { AircraftLayer } from './AircraftLayer'; import { AircraftLayer } from './AircraftLayer';
@ -105,6 +106,7 @@ const EVENT_RADIUS: Record<GeoEvent['type'], number> = {
}; };
export function ReplayMap({ events, currentTime, aircraft, satellites, ships, layers, flyToTarget, onFlyToDone, initialCenter, initialZoom }: Props) { export function ReplayMap({ events, currentTime, aircraft, satellites, ships, layers, flyToTarget, onFlyToDone, initialCenter, initialZoom }: Props) {
const { t } = useTranslation();
const mapRef = useRef<MapRef>(null); const mapRef = useRef<MapRef>(null);
const [selectedEventId, setSelectedEventId] = useState<string | null>(null); const [selectedEventId, setSelectedEventId] = useState<string | null>(null);
@ -182,7 +184,6 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
> >
<NavigationControl position="top-right" /> <NavigationControl position="top-right" />
{/* 한글 국가명 라벨 */}
<Source id="country-labels" type="geojson" data={countryLabelsGeoJSON()}> <Source id="country-labels" type="geojson" data={countryLabelsGeoJSON()}>
<Layer <Layer
id="country-label-lg" id="country-label-lg"
@ -266,9 +267,9 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
const size = EVENT_RADIUS[event.type] * 5; const size = EVENT_RADIUS[event.type] * 5;
return ( return (
<Marker key={`pulse-${event.id}`} longitude={event.lng} latitude={event.lat} anchor="center"> <Marker key={`pulse-${event.id}`} longitude={event.lng} latitude={event.lat} anchor="center">
<div className="gl-pulse-ring" style={{ <div className="gl-pulse-ring rounded-full pointer-events-none" style={{
width: size, height: size, borderRadius: '50%', width: size, height: size,
border: `2px solid ${color}`, pointerEvents: 'none', border: `2px solid ${color}`,
}} /> }} />
</Marker> </Marker>
); );
@ -279,9 +280,9 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
const size = event.type === 'impact' ? 100 : 70; const size = event.type === 'impact' ? 100 : 70;
return ( return (
<Marker key={`shock-${event.id}`} longitude={event.lng} latitude={event.lat} anchor="center"> <Marker key={`shock-${event.id}`} longitude={event.lng} latitude={event.lat} anchor="center">
<div className="gl-shockwave" style={{ <div className="gl-shockwave rounded-full pointer-events-none" style={{
width: size, height: size, borderRadius: '50%', width: size, height: size,
border: `3px solid ${color}`, pointerEvents: 'none', border: `3px solid ${color}`,
}} /> }} />
</Marker> </Marker>
); );
@ -292,9 +293,9 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
const size = event.type === 'impact' ? 40 : 30; const size = event.type === 'impact' ? 40 : 30;
return ( return (
<Marker key={`flash-${event.id}`} longitude={event.lng} latitude={event.lat} anchor="center"> <Marker key={`flash-${event.id}`} longitude={event.lng} latitude={event.lat} anchor="center">
<div className="gl-strike-flash" style={{ <div className="gl-strike-flash rounded-full opacity-60 pointer-events-none" style={{
width: size, height: size, borderRadius: '50%', width: size, height: size,
background: color, opacity: 0.6, pointerEvents: 'none', background: color,
}} /> }} />
</Marker> </Marker>
); );
@ -304,11 +305,10 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
const ageMs = currentTime - event.timestamp; const ageMs = currentTime - event.timestamp;
const ageHours = ageMs / 3600_000; const ageHours = ageMs / 3600_000;
const DAY_H = 24; const DAY_H = 24;
// 최근 1일 이내: 진하게 (opacity 0.85~1.0), 그 이후: 흐리게 (0.15~0.4)
const isRecent = ageHours <= DAY_H; const isRecent = ageHours <= DAY_H;
const opacity = isRecent const opacity = isRecent
? Math.max(0.85, 1 - ageHours * 0.006) // 1일 내: 1.0→0.85 ? Math.max(0.85, 1 - ageHours * 0.006)
: Math.max(0.15, 0.4 - (ageHours - DAY_H) * 0.005); // 1일 후: 0.4→0.15 : Math.max(0.15, 0.4 - (ageHours - DAY_H) * 0.005);
const color = getEventColor(event); const color = getEventColor(event);
const isNew = ageMs >= 0 && ageMs < 600_000; const isNew = ageMs >= 0 && ageMs < 600_000;
const baseR = EVENT_RADIUS[event.type]; const baseR = EVENT_RADIUS[event.type];
@ -318,8 +318,7 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
return ( return (
<Marker key={event.id} longitude={event.lng} latitude={event.lat} anchor="center"> <Marker key={event.id} longitude={event.lng} latitude={event.lat} anchor="center">
<div <div
className={isNew ? 'gl-event-flash' : undefined} className={`cursor-pointer ${isNew ? 'gl-event-flash' : ''}`}
style={{ cursor: 'pointer' }}
onClick={(e) => { e.stopPropagation(); setSelectedEventId(event.id); }} onClick={(e) => { e.stopPropagation(); setSelectedEventId(event.id); }}
> >
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}> <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
@ -339,7 +338,6 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
const ageMs = currentTime - event.timestamp; const ageMs = currentTime - event.timestamp;
const ageHours = ageMs / 3600_000; const ageHours = ageMs / 3600_000;
const isRecent = ageHours <= 24; const isRecent = ageHours <= 24;
// 최근 1일: 진하게, 이후: 흐리게
const impactOpacity = isRecent const impactOpacity = isRecent
? Math.max(0.8, 1 - ageHours * 0.008) ? Math.max(0.8, 1 - ageHours * 0.008)
: Math.max(0.2, 0.45 - (ageHours - 24) * 0.005); : Math.max(0.2, 0.45 - (ageHours - 24) * 0.005);
@ -348,7 +346,7 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
const sw = isRecent ? 1.5 : 1; const sw = isRecent ? 1.5 : 1;
return ( return (
<Marker key={event.id} longitude={event.lng} latitude={event.lat} anchor="center"> <Marker key={event.id} longitude={event.lng} latitude={event.lat} anchor="center">
<div style={{ position: 'relative', cursor: 'pointer', opacity: impactOpacity }} <div className="relative cursor-pointer" style={{ opacity: impactOpacity }}
onClick={(e) => { e.stopPropagation(); setSelectedEventId(event.id); }}> onClick={(e) => { e.stopPropagation(); setSelectedEventId(event.id); }}>
<svg viewBox={`0 0 ${s} ${s}`} width={s} height={s}> <svg viewBox={`0 0 ${s} ${s}`} width={s} height={s}>
<circle cx={c} cy={c} r={c * 0.77} fill="none" stroke="#ff0000" strokeWidth={sw} opacity={0.8} /> <circle cx={c} cy={c} r={c * 0.77} fill="none" stroke="#ff0000" strokeWidth={sw} opacity={0.8} />
@ -378,47 +376,43 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
maxWidth="320px" maxWidth="320px"
className="gl-popup" className="gl-popup"
> >
<div style={{ minWidth: 200, maxWidth: 320 }}> <div className="min-w-[200px] max-w-[320px]">
{selectedEvent.source && ( {selectedEvent.source && (
<span style={{ <span
background: getEventColor(selectedEvent), color: '#fff', padding: '1px 6px', className="inline-block rounded-sm text-[10px] font-bold text-white mb-1 px-1.5 py-px"
borderRadius: 3, fontSize: 10, fontWeight: 700, marginBottom: 4, display: 'inline-block', style={{ background: getEventColor(selectedEvent) }}
}}> >
{{ US: '미국', IL: '이스라엘', IR: '이란', proxy: '대리세력' }[selectedEvent.source]} {t(`source.${selectedEvent.source}`)}
</span> </span>
)} )}
{selectedEvent.type === 'impact' && ( {selectedEvent.type === 'impact' && (
<div style={{ <div className="inline-block rounded-sm text-[11px] font-bold text-white mb-1.5 px-2 py-[3px] bg-kcg-event-impact">
background: '#ff0000', color: '#fff', padding: '3px 8px', {t('popup.impactSite')}
borderRadius: 3, fontSize: 11, fontWeight: 700, marginBottom: 6,
display: 'inline-block',
}}>
IMPACT SITE
</div> </div>
)} )}
<div><strong>{selectedEvent.label}</strong></div> <div><strong>{selectedEvent.label}</strong></div>
<span style={{ fontSize: 12, color: '#888' }}> <span className="text-xs text-kcg-muted">
{new Date(selectedEvent.timestamp + 9 * 3600_000).toISOString().slice(0, 16).replace('T', ' ')} KST {new Date(selectedEvent.timestamp + 9 * 3600_000).toISOString().slice(0, 16).replace('T', ' ')} KST
</span> </span>
{selectedEvent.description && ( {selectedEvent.description && (
<p style={{ margin: '6px 0 0', fontSize: 13 }}>{selectedEvent.description}</p> <p className="mt-1.5 mb-0 text-[13px]">{selectedEvent.description}</p>
)} )}
{selectedEvent.imageUrl && ( {selectedEvent.imageUrl && (
<div style={{ marginTop: 8 }}> <div className="mt-2">
<img <img
src={selectedEvent.imageUrl} src={selectedEvent.imageUrl}
alt={selectedEvent.imageCaption || selectedEvent.label} alt={selectedEvent.imageCaption || selectedEvent.label}
style={{ width: '100%', borderRadius: 4, maxHeight: 180, objectFit: 'cover' }} className="w-full rounded max-h-[180px] object-cover"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/> />
{selectedEvent.imageCaption && ( {selectedEvent.imageCaption && (
<div style={{ fontSize: 10, color: '#aaa', marginTop: 3 }}>{selectedEvent.imageCaption}</div> <div className="text-[10px] text-kcg-muted mt-[3px]">{selectedEvent.imageCaption}</div>
)} )}
</div> </div>
)} )}
{selectedEvent.type === 'impact' && ( {selectedEvent.type === 'impact' && (
<div style={{ fontSize: 10, color: '#888', marginTop: 6 }}> <div className="text-[10px] text-kcg-muted mt-1.5">
{selectedEvent.lat.toFixed(4)}°N, {selectedEvent.lng.toFixed(4)}°E {selectedEvent.lat.toFixed(4)}&deg;N, {selectedEvent.lng.toFixed(4)}&deg;E
</div> </div>
)} )}
</div> </div>

파일 보기

@ -1,4 +1,5 @@
import { memo, useMemo, useState } from 'react'; import { memo, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Marker, Popup, Source, Layer } from 'react-map-gl/maplibre'; import { Marker, Popup, Source, Layer } from 'react-map-gl/maplibre';
import type { SatellitePosition } from '../types'; import type { SatellitePosition } from '../types';
@ -72,13 +73,11 @@ const SVG_MAP: Record<SatellitePosition['category'], React.ReactNode> = {
}; };
export function SatelliteLayer({ satellites }: Props) { export function SatelliteLayer({ satellites }: Props) {
// Ground tracks as GeoJSON
const trackData = useMemo(() => { const trackData = useMemo(() => {
const features: GeoJSON.Feature[] = []; const features: GeoJSON.Feature[] = [];
for (const sat of satellites) { for (const sat of satellites) {
if (!sat.groundTrack || sat.groundTrack.length < 2) continue; if (!sat.groundTrack || sat.groundTrack.length < 2) continue;
const color = CAT_COLORS[sat.category]; const color = CAT_COLORS[sat.category];
// Break at antimeridian crossings
let segment: [number, number][] = []; let segment: [number, number][] = [];
for (let i = 0; i < sat.groundTrack.length; i++) { for (let i = 0; i < sat.groundTrack.length; i++) {
const [lat, lng] = sat.groundTrack[i]; const [lat, lng] = sat.groundTrack[i];
@ -133,6 +132,7 @@ export function SatelliteLayer({ satellites }: Props) {
const SatelliteMarker = memo(function SatelliteMarker({ sat }: { sat: SatellitePosition }) { const SatelliteMarker = memo(function SatelliteMarker({ sat }: { sat: SatellitePosition }) {
const [showPopup, setShowPopup] = useState(false); const [showPopup, setShowPopup] = useState(false);
const { t } = useTranslation();
const color = CAT_COLORS[sat.category]; const color = CAT_COLORS[sat.category];
const svgBody = SVG_MAP[sat.category]; const svgBody = SVG_MAP[sat.category];
const size = 22; const size = 22;
@ -140,9 +140,9 @@ const SatelliteMarker = memo(function SatelliteMarker({ sat }: { sat: SatelliteP
return ( return (
<> <>
<Marker longitude={sat.lng} latitude={sat.lat} anchor="center"> <Marker longitude={sat.lng} latitude={sat.lat} anchor="center">
<div style={{ position: 'relative' }}> <div className="relative">
<div <div
style={{ width: size, height: size, color, cursor: 'pointer' }} style={{ color }} className="size-[22px] cursor-pointer"
onClick={(e) => { e.stopPropagation(); setShowPopup(true); }} onClick={(e) => { e.stopPropagation(); setShowPopup(true); }}
> >
<svg viewBox="0 0 24 24" width={size} height={size} style={{ color }}> <svg viewBox="0 0 24 24" width={size} height={size} style={{ color }}>
@ -158,20 +158,21 @@ const SatelliteMarker = memo(function SatelliteMarker({ sat }: { sat: SatelliteP
<Popup longitude={sat.lng} latitude={sat.lat} <Popup longitude={sat.lng} latitude={sat.lat}
onClose={() => setShowPopup(false)} closeOnClick={false} onClose={() => setShowPopup(false)} closeOnClick={false}
anchor="bottom" maxWidth="200px" className="gl-popup"> anchor="bottom" maxWidth="200px" className="gl-popup">
<div style={{ minWidth: 180, fontFamily: 'monospace', fontSize: 12 }}> <div className="min-w-[180px] font-mono text-xs">
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}> <div className="mb-1.5 flex items-center gap-2">
<span style={{ <span style={{
background: color, color: '#000', padding: '1px 6px', background: color,
borderRadius: 3, fontSize: 10, fontWeight: 700, }} className="rounded-sm px-1.5 py-px text-[10px] font-bold text-black">
}}>{CAT_LABELS[sat.category]}</span> {CAT_LABELS[sat.category]}
</span>
<strong>{sat.name}</strong> <strong>{sat.name}</strong>
</div> </div>
<table style={{ width: '100%', fontSize: 11 }}> <table className="w-full text-[11px]">
<tbody> <tbody>
<tr><td style={{ color: '#888' }}>NORAD</td><td>{sat.noradId}</td></tr> <tr><td className="text-kcg-muted">{t('satellite.norad')}</td><td>{sat.noradId}</td></tr>
<tr><td style={{ color: '#888' }}>Lat</td><td>{sat.lat.toFixed(2)}&deg;</td></tr> <tr><td className="text-kcg-muted">{t('satellite.lat')}</td><td>{sat.lat.toFixed(2)}&deg;</td></tr>
<tr><td style={{ color: '#888' }}>Lng</td><td>{sat.lng.toFixed(2)}&deg;</td></tr> <tr><td className="text-kcg-muted">{t('satellite.lng')}</td><td>{sat.lng.toFixed(2)}&deg;</td></tr>
<tr><td style={{ color: '#888' }}>Alt</td><td>{Math.round(sat.altitude)} km</td></tr> <tr><td className="text-kcg-muted">{t('satellite.alt')}</td><td>{Math.round(sat.altitude)} km</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>

파일 보기

@ -1,4 +1,5 @@
import { useMemo, useState, useRef } from 'react'; import { useMemo, useState, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Map, Marker, Popup, Source, Layer, NavigationControl } from 'react-map-gl/maplibre'; import { Map, Marker, Popup, Source, Layer, NavigationControl } from 'react-map-gl/maplibre';
import type { MapRef } from 'react-map-gl/maplibre'; import type { MapRef } from 'react-map-gl/maplibre';
import { AircraftLayer } from './AircraftLayer'; import { AircraftLayer } from './AircraftLayer';
@ -86,6 +87,7 @@ const EVENT_RADIUS: Record<GeoEvent['type'], number> = {
}; };
export function SatelliteMap({ events, currentTime, aircraft, satellites, ships, layers }: Props) { export function SatelliteMap({ events, currentTime, aircraft, satellites, ships, layers }: Props) {
const { t } = useTranslation();
const mapRef = useRef<MapRef>(null); const mapRef = useRef<MapRef>(null);
const [selectedEventId, setSelectedEventId] = useState<string | null>(null); const [selectedEventId, setSelectedEventId] = useState<string | null>(null);
@ -179,14 +181,13 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships,
onClick={e => { e.originalEvent.stopPropagation(); setSelectedEventId(ev.id); }} onClick={e => { e.originalEvent.stopPropagation(); setSelectedEventId(ev.id); }}
> >
<div <div
className="rounded-full cursor-pointer"
style={{ style={{
width: EVENT_RADIUS[ev.type], width: EVENT_RADIUS[ev.type],
height: EVENT_RADIUS[ev.type], height: EVENT_RADIUS[ev.type],
borderRadius: '50%',
background: getEventColor(ev), background: getEventColor(ev),
border: '2px solid rgba(255,255,255,0.8)', border: '2px solid rgba(255,255,255,0.8)',
boxShadow: `0 0 8px ${getEventColor(ev)}`, boxShadow: `0 0 8px ${getEventColor(ev)}`,
cursor: 'pointer',
}} }}
/> />
</Marker> </Marker>
@ -203,46 +204,42 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships,
maxWidth="320px" maxWidth="320px"
className="event-popup" className="event-popup"
> >
<div style={{ minWidth: 200, maxWidth: 320 }}> <div className="min-w-[200px] max-w-[320px]">
{selectedEvent.source && ( {selectedEvent.source && (
<span style={{ <span
background: getEventColor(selectedEvent), color: '#fff', padding: '1px 6px', className="inline-block rounded-sm text-[10px] font-bold text-white mb-1 px-1.5 py-px"
borderRadius: 3, fontSize: 10, fontWeight: 700, marginBottom: 4, display: 'inline-block', style={{ background: getEventColor(selectedEvent) }}
}}> >
{{ US: '미국', IL: '이스라엘', IR: '이란', proxy: '대리세력' }[selectedEvent.source]} {t(`source.${selectedEvent.source}`)}
</span> </span>
)} )}
{selectedEvent.type === 'impact' && ( {selectedEvent.type === 'impact' && (
<div style={{ <div className="inline-block rounded-sm text-[11px] font-bold text-white mb-1.5 px-2 py-[3px] bg-kcg-event-impact">
background: '#ff0000', color: '#fff', padding: '3px 8px', {t('popup.impactSite')}
borderRadius: 3, fontSize: 11, fontWeight: 700, marginBottom: 6,
display: 'inline-block',
}}>
IMPACT SITE
</div> </div>
)} )}
<div style={{ color: '#e0e0e0' }}><strong>{selectedEvent.label}</strong></div> <div className="text-kcg-text"><strong>{selectedEvent.label}</strong></div>
<span style={{ fontSize: 12, color: '#888' }}> <span className="text-xs text-kcg-muted">
{new Date(selectedEvent.timestamp + 9 * 3600_000).toISOString().slice(0, 16).replace('T', ' ')} KST {new Date(selectedEvent.timestamp + 9 * 3600_000).toISOString().slice(0, 16).replace('T', ' ')} KST
</span> </span>
{selectedEvent.description && ( {selectedEvent.description && (
<p style={{ margin: '6px 0 0', fontSize: 13, color: '#ccc' }}>{selectedEvent.description}</p> <p className="mt-1.5 mb-0 text-[13px] text-kcg-text-secondary">{selectedEvent.description}</p>
)} )}
{selectedEvent.imageUrl && ( {selectedEvent.imageUrl && (
<div style={{ marginTop: 8 }}> <div className="mt-2">
<img <img
src={selectedEvent.imageUrl} src={selectedEvent.imageUrl}
alt={selectedEvent.imageCaption || selectedEvent.label} alt={selectedEvent.imageCaption || selectedEvent.label}
style={{ width: '100%', borderRadius: 4, maxHeight: 180, objectFit: 'cover' }} className="w-full rounded max-h-[180px] object-cover"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/> />
{selectedEvent.imageCaption && ( {selectedEvent.imageCaption && (
<div style={{ fontSize: 10, color: '#aaa', marginTop: 3 }}>{selectedEvent.imageCaption}</div> <div className="text-[10px] text-kcg-muted mt-[3px]">{selectedEvent.imageCaption}</div>
)} )}
</div> </div>
)} )}
<div style={{ fontSize: 10, color: '#666', marginTop: 6 }}> <div className="text-[10px] text-kcg-dim mt-1.5">
{selectedEvent.lat.toFixed(4)}°N, {selectedEvent.lng.toFixed(4)}°E {selectedEvent.lat.toFixed(4)}&deg;N, {selectedEvent.lng.toFixed(4)}&deg;E
</div> </div>
</div> </div>
</Popup> </Popup>

파일 보기

@ -1,4 +1,5 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { import {
LineChart, LineChart,
Line, Line,
@ -19,6 +20,8 @@ interface Props {
} }
export function SensorChart({ data, currentTime, startTime }: Props) { export function SensorChart({ data, currentTime, startTime }: Props) {
const { t } = useTranslation();
const visibleData = useMemo( const visibleData = useMemo(
() => data.filter(d => d.timestamp <= currentTime), () => data.filter(d => d.timestamp <= currentTime),
[data, currentTime], [data, currentTime],
@ -35,10 +38,10 @@ export function SensorChart({ data, currentTime, startTime }: Props) {
return ( return (
<div className="sensor-chart"> <div className="sensor-chart">
<h3>Sensor Data</h3> <h3>{t('sensor.title')}</h3>
<div className="chart-grid"> <div className="chart-grid">
<div className="chart-item"> <div className="chart-item">
<h4>Seismic Activity</h4> <h4>{t('sensor.seismicActivity')}</h4>
<ResponsiveContainer width="100%" height={80}> <ResponsiveContainer width="100%" height={80}>
<LineChart data={chartData}> <LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#333" /> <CartesianGrid strokeDasharray="3 3" stroke="#333" />
@ -52,7 +55,7 @@ export function SensorChart({ data, currentTime, startTime }: Props) {
</div> </div>
<div className="chart-item"> <div className="chart-item">
<h4>Noise Level (dB)</h4> <h4>{t('sensor.noiseLevelDb')}</h4>
<ResponsiveContainer width="100%" height={80}> <ResponsiveContainer width="100%" height={80}>
<LineChart data={chartData}> <LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#333" /> <CartesianGrid strokeDasharray="3 3" stroke="#333" />
@ -65,7 +68,7 @@ export function SensorChart({ data, currentTime, startTime }: Props) {
</div> </div>
<div className="chart-item"> <div className="chart-item">
<h4>Air Pressure (hPa)</h4> <h4>{t('sensor.airPressureHpa')}</h4>
<ResponsiveContainer width="100%" height={80}> <ResponsiveContainer width="100%" height={80}>
<LineChart data={chartData}> <LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#333" /> <CartesianGrid strokeDasharray="3 3" stroke="#333" />
@ -78,7 +81,7 @@ export function SensorChart({ data, currentTime, startTime }: Props) {
</div> </div>
<div className="chart-item"> <div className="chart-item">
<h4>Radiation (uSv/h)</h4> <h4>{t('sensor.radiationUsv')}</h4>
<ResponsiveContainer width="100%" height={80}> <ResponsiveContainer width="100%" height={80}>
<LineChart data={chartData}> <LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#333" /> <CartesianGrid strokeDasharray="3 3" stroke="#333" />

파일 보기

@ -1,5 +1,6 @@
import { memo, useMemo, useState, useEffect } from 'react'; import { memo, useMemo, useState, useEffect } from 'react';
import { Marker, Popup, Source, Layer, useMap } from 'react-map-gl/maplibre'; import { Marker, Popup, Source, Layer, useMap } from 'react-map-gl/maplibre';
import { useTranslation } from 'react-i18next';
import type { Ship, ShipCategory } from '../types'; import type { Ship, ShipCategory } from '../types';
import maplibregl from 'maplibre-gl'; import maplibregl from 'maplibre-gl';
@ -9,17 +10,30 @@ interface Props {
koreanOnly?: boolean; koreanOnly?: boolean;
} }
// ── MarineTraffic-style vessel type colors ── // ── MarineTraffic-style vessel type colors (CSS variable references) ──
const MT_TYPE_COLORS: Record<string, string> = { const MT_TYPE_COLORS: Record<string, string> = {
cargo: '#f0a830', // orange-yellow cargo: 'var(--kcg-ship-cargo)',
tanker: '#e74c3c', // red tanker: 'var(--kcg-ship-tanker)',
passenger: '#4caf50', // green passenger: 'var(--kcg-ship-passenger)',
fishing: '#42a5f5', // light blue fishing: 'var(--kcg-ship-fishing)',
pleasure: '#e91e8c', // pink/magenta pleasure: 'var(--kcg-ship-pleasure)',
military: '#d32f2f', // dark red military: 'var(--kcg-ship-military)',
tug_special: '#2e7d32', // dark green tug_special: 'var(--kcg-ship-tug)',
other: '#5c6bc0', // indigo/blue other: 'var(--kcg-ship-other)',
unknown: '#9e9e9e', // grey unknown: 'var(--kcg-ship-unknown)',
};
// Resolved hex colors for MapLibre paint (which cannot use CSS vars)
const MT_TYPE_HEX: Record<string, string> = {
cargo: '#f0a830',
tanker: '#e74c3c',
passenger: '#4caf50',
fishing: '#42a5f5',
pleasure: '#e91e8c',
military: '#d32f2f',
tug_special: '#2e7d32',
other: '#5c6bc0',
unknown: '#9e9e9e',
}; };
// Map our internal ShipCategory + typecode → MT visual type // Map our internal ShipCategory + typecode → MT visual type
@ -63,21 +77,6 @@ const NAVY_COLORS: Record<string, string> = {
IR: '#2ecc40', JP: '#ff6b6b', AU: '#f4a261', DE: '#b5b5b5', IN: '#ff9f43', IR: '#2ecc40', JP: '#ff6b6b', AU: '#f4a261', DE: '#b5b5b5', IN: '#ff9f43',
}; };
const CATEGORY_LABELS: Record<ShipCategory, string> = {
carrier: 'CARRIER', destroyer: 'DDG', warship: 'WARSHIP', submarine: 'SUB',
patrol: 'PATROL', tanker: 'TANKER', cargo: 'CARGO', civilian: 'CIV', unknown: 'N/A',
};
const MT_TYPE_LABELS: Record<string, string> = {
cargo: 'Cargo', tanker: 'Tanker', passenger: 'Passenger', fishing: 'Fishing',
pleasure: 'Yacht', military: 'Military', tug_special: 'Tug/Special', other: 'Other', unknown: 'Unknown',
};
const FLAG_LABELS: Record<string, string> = {
US: 'USN', UK: 'RN', FR: 'MN', KR: 'ROKN', IR: 'IRIN',
JP: 'JMSDF', AU: 'RAN', DE: 'DM', IN: 'IN',
};
const FLAG_EMOJI: Record<string, string> = { const FLAG_EMOJI: Record<string, string> = {
US: '\u{1F1FA}\u{1F1F8}', UK: '\u{1F1EC}\u{1F1E7}', FR: '\u{1F1EB}\u{1F1F7}', US: '\u{1F1FA}\u{1F1F8}', UK: '\u{1F1EC}\u{1F1E7}', FR: '\u{1F1EB}\u{1F1F7}',
KR: '\u{1F1F0}\u{1F1F7}', IR: '\u{1F1EE}\u{1F1F7}', JP: '\u{1F1EF}\u{1F1F5}', KR: '\u{1F1F0}\u{1F1F7}', IR: '\u{1F1EE}\u{1F1F7}', JP: '\u{1F1EF}\u{1F1F5}',
@ -104,6 +103,10 @@ function getShipColor(ship: Ship): string {
return MT_TYPE_COLORS[getMTType(ship)] || MT_TYPE_COLORS.unknown; return MT_TYPE_COLORS[getMTType(ship)] || MT_TYPE_COLORS.unknown;
} }
function getShipHex(ship: Ship): string {
return MT_TYPE_HEX[getMTType(ship)] || MT_TYPE_HEX.unknown;
}
// ── Local Korean ship photos ── // ── Local Korean ship photos ──
const LOCAL_SHIP_PHOTOS: Record<string, string> = { const LOCAL_SHIP_PHOTOS: Record<string, string> = {
'440034000': '/ships/440034000.jpg', '440034000': '/ships/440034000.jpg',
@ -126,30 +129,92 @@ const LOCAL_SHIP_PHOTOS: Record<string, string> = {
interface VesselPhotoData { url: string; } interface VesselPhotoData { url: string; }
const vesselPhotoCache = new Map<string, VesselPhotoData | null>(); const vesselPhotoCache = new Map<string, VesselPhotoData | null>();
function VesselPhoto({ mmsi }: { mmsi: string }) { type PhotoSource = 'signal-batch' | 'marinetraffic';
interface VesselPhotoProps {
mmsi: string;
imo?: string;
shipImagePath?: string | null;
}
function VesselPhoto({ mmsi, shipImagePath }: VesselPhotoProps) {
const { t } = useTranslation('ships');
const localUrl = LOCAL_SHIP_PHOTOS[mmsi]; const localUrl = LOCAL_SHIP_PHOTOS[mmsi];
const [photo, setPhoto] = useState<VesselPhotoData | null | undefined>(() => {
if (localUrl) return { url: localUrl }; // Determine available tabs
const hasSignalBatch = !!shipImagePath;
const defaultTab: PhotoSource = hasSignalBatch ? 'signal-batch' : 'marinetraffic';
const [activeTab, setActiveTab] = useState<PhotoSource>(defaultTab);
// MarineTraffic image state (lazy loaded)
const [mtPhoto, setMtPhoto] = useState<VesselPhotoData | null | undefined>(() => {
return vesselPhotoCache.has(mmsi) ? vesselPhotoCache.get(mmsi) : undefined; return vesselPhotoCache.has(mmsi) ? vesselPhotoCache.get(mmsi) : undefined;
}); });
useEffect(() => { useEffect(() => {
if (localUrl) return; if (activeTab !== 'marinetraffic') return;
if (photo !== undefined) return; if (mtPhoto !== undefined) return;
const imgUrl = `https://photos.marinetraffic.com/ais/showphoto.aspx?mmsi=${mmsi}&size=thumb300`; const imgUrl = `https://photos.marinetraffic.com/ais/showphoto.aspx?mmsi=${mmsi}&size=thumb300`;
const img = new Image(); const img = new Image();
img.onload = () => { const result = { url: imgUrl }; vesselPhotoCache.set(mmsi, result); setPhoto(result); }; img.onload = () => { const result = { url: imgUrl }; vesselPhotoCache.set(mmsi, result); setMtPhoto(result); };
img.onerror = () => { vesselPhotoCache.set(mmsi, null); setPhoto(null); }; img.onerror = () => { vesselPhotoCache.set(mmsi, null); setMtPhoto(null); };
img.src = imgUrl; img.src = imgUrl;
}, [mmsi, photo, localUrl]); }, [mmsi, activeTab, mtPhoto]);
// Resolve current image URL
let currentUrl: string | null = null;
if (localUrl) {
currentUrl = localUrl;
} else if (activeTab === 'signal-batch' && shipImagePath) {
currentUrl = shipImagePath;
} else if (activeTab === 'marinetraffic' && mtPhoto) {
currentUrl = mtPhoto.url;
}
// If local photo exists, show it directly without tabs
if (localUrl) {
return (
<div className="mb-1.5">
<img src={localUrl} alt="Vessel"
className="w-full rounded block"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
</div>
);
}
if (!photo) return null;
return ( return (
<div style={{ marginBottom: 6 }}> <div className="mb-1.5">
<img src={photo.url} alt="Vessel" <div className="flex gap-0.5 mb-1 rounded bg-kcg-bg p-0.5">
style={{ width: '100%', borderRadius: 4, display: 'block' }} {hasSignalBatch && (
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} <div
/> className={`flex-1 py-0.5 text-center cursor-pointer rounded transition-all text-[9px] font-semibold ${
activeTab === 'signal-batch' ? 'bg-[#1565c0] text-white' : 'bg-transparent text-kcg-muted'
}`}
onClick={() => setActiveTab('signal-batch')}
>
signal-batch
</div>
)}
<div
className={`flex-1 py-0.5 text-center cursor-pointer rounded transition-all text-[9px] font-semibold ${
activeTab === 'marinetraffic' ? 'bg-[#1565c0] text-white' : 'bg-transparent text-kcg-muted'
}`}
onClick={() => setActiveTab('marinetraffic')}
>
MarineTraffic
</div>
</div>
{currentUrl ? (
<img src={currentUrl} alt="Vessel"
className="w-full rounded block"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
) : (
activeTab === 'marinetraffic' && mtPhoto === undefined
? <div className="text-center p-2 text-kcg-dim text-[10px]">{t('popup.loading')}</div>
: null
)}
</div> </div>
); );
} }
@ -215,7 +280,7 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly }: Props) {
type: 'Feature' as const, type: 'Feature' as const,
properties: { properties: {
mmsi: ship.mmsi, mmsi: ship.mmsi,
color: getShipColor(ship), color: getShipHex(ship),
size: SIZE_MAP[ship.category], size: SIZE_MAP[ship.category],
isMil: isMilitary(ship.category) ? 1 : 0, isMil: isMilitary(ship.category) ? 1 : 0,
isKorean: ship.flag === 'KR' ? 1 : 0, isKorean: ship.flag === 'KR' ? 1 : 0,
@ -328,76 +393,71 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly }: Props) {
} }
const ShipPopup = memo(function ShipPopup({ ship, onClose }: { ship: Ship; onClose: () => void }) { const ShipPopup = memo(function ShipPopup({ ship, onClose }: { ship: Ship; onClose: () => void }) {
const { t } = useTranslation('ships');
const mtType = getMTType(ship); const mtType = getMTType(ship);
const color = MT_TYPE_COLORS[mtType] || MT_TYPE_COLORS.unknown; const color = MT_TYPE_COLORS[mtType] || MT_TYPE_COLORS.unknown;
const isMil = isMilitary(ship.category); const isMil = isMilitary(ship.category);
const navyLabel = isMil && ship.flag && FLAG_LABELS[ship.flag] ? FLAG_LABELS[ship.flag] : undefined; const navyLabel = isMil && ship.flag ? t(`navyLabel.${ship.flag}`, { defaultValue: '' }) : undefined;
const navyAccent = isMil && ship.flag && NAVY_COLORS[ship.flag] ? NAVY_COLORS[ship.flag] : color; const navyAccent = isMil && ship.flag && NAVY_COLORS[ship.flag] ? NAVY_COLORS[ship.flag] : undefined;
const flagEmoji = ship.flag ? FLAG_EMOJI[ship.flag] || '' : ''; const flagEmoji = ship.flag ? FLAG_EMOJI[ship.flag] || '' : '';
return ( return (
<Popup longitude={ship.lng} latitude={ship.lat} <Popup longitude={ship.lng} latitude={ship.lat}
onClose={onClose} closeOnClick={false} onClose={onClose} closeOnClick={false}
anchor="bottom" maxWidth="340px" className="gl-popup"> anchor="bottom" maxWidth="340px" className="gl-popup">
<div style={{ minWidth: 280, maxWidth: 340, fontFamily: 'monospace', fontSize: 12 }}> <div className="min-w-[280px] max-w-[340px] font-mono text-xs">
<div style={{ <div
background: isMil ? '#1a1a2e' : '#1565c0', color: '#fff', className="flex items-center gap-2 px-2.5 py-1.5 rounded-t text-white -mx-2.5 -mt-2.5 mb-2"
padding: '6px 10px', borderRadius: '4px 4px 0 0', style={{ background: isMil ? 'var(--kcg-card)' : '#1565c0' }}
margin: '-10px -10px 8px -10px', >
display: 'flex', alignItems: 'center', gap: 8, {flagEmoji && <span className="text-base">{flagEmoji}</span>}
}}> <strong className="text-[13px] flex-1">{ship.name}</strong>
{flagEmoji && <span style={{ fontSize: 16 }}>{flagEmoji}</span>}
<strong style={{ fontSize: 13, flex: 1 }}>{ship.name}</strong>
{navyLabel && ( {navyLabel && (
<span style={{ <span
background: navyAccent, color: '#000', padding: '1px 6px', className="px-1.5 py-px rounded text-[10px] font-bold text-black"
borderRadius: 3, fontSize: 10, fontWeight: 700, style={{ background: navyAccent || color }}
}}>{navyLabel}</span> >{navyLabel}</span>
)} )}
</div> </div>
<VesselPhoto mmsi={ship.mmsi} /> <VesselPhoto mmsi={ship.mmsi} imo={ship.imo} shipImagePath={ship.shipImagePath} />
<div style={{ <div className="flex gap-1 mb-1.5 border-b border-kcg-border-light pb-1">
display: 'flex', gap: 4, marginBottom: 6, <span
borderBottom: '1px solid #ddd', paddingBottom: 4, className="px-1.5 py-px rounded text-[10px] font-bold text-white"
}}> style={{ background: color }}
<span style={{ >{t(`mtTypeLabel.${mtType}`, { defaultValue: 'Unknown' })}</span>
background: color, color: '#fff', padding: '1px 6px', <span className="px-1.5 py-px rounded text-[10px] bg-kcg-border text-kcg-text-secondary">
borderRadius: 3, fontSize: 10, fontWeight: 700, {t(`categoryLabel.${ship.category}`)}
}}>{MT_TYPE_LABELS[mtType] || 'Unknown'}</span> </span>
<span style={{
background: '#333', color: '#ccc', padding: '1px 6px',
borderRadius: 3, fontSize: 10,
}}>{CATEGORY_LABELS[ship.category]}</span>
{ship.typeDesc && ( {ship.typeDesc && (
<span style={{ color: '#666', fontSize: 10, lineHeight: '18px' }}>{ship.typeDesc}</span> <span className="text-kcg-dim text-[10px] leading-[18px]">{ship.typeDesc}</span>
)} )}
</div> </div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '2px 12px', fontSize: 11 }}> <div className="grid grid-cols-2 gap-x-3 gap-y-0.5 text-[11px]">
<div> <div>
<div><span style={{ color: '#888' }}>MMSI : </span>{ship.mmsi}</div> <div><span className="text-kcg-muted">{t('popup.mmsi')} : </span>{ship.mmsi}</div>
{ship.callSign && <div><span style={{ color: '#888' }}>Call Sign : </span>{ship.callSign}</div>} {ship.callSign && <div><span className="text-kcg-muted">{t('popup.callSign')} : </span>{ship.callSign}</div>}
{ship.imo && <div><span style={{ color: '#888' }}>IMO : </span>{ship.imo}</div>} {ship.imo && <div><span className="text-kcg-muted">{t('popup.imo')} : </span>{ship.imo}</div>}
{ship.status && <div><span style={{ color: '#888' }}>Status : </span>{ship.status}</div>} {ship.status && <div><span className="text-kcg-muted">{t('popup.status')} : </span>{ship.status}</div>}
{ship.length && <div><span style={{ color: '#888' }}>Length : </span>{ship.length}m</div>} {ship.length && <div><span className="text-kcg-muted">{t('popup.length')} : </span>{ship.length}m</div>}
{ship.width && <div><span style={{ color: '#888' }}>Width : </span>{ship.width}m</div>} {ship.width && <div><span className="text-kcg-muted">{t('popup.width')} : </span>{ship.width}m</div>}
{ship.draught && <div><span style={{ color: '#888' }}>Draught : </span>{ship.draught}m</div>} {ship.draught && <div><span className="text-kcg-muted">{t('popup.draught')} : </span>{ship.draught}m</div>}
</div> </div>
<div> <div>
<div><span style={{ color: '#888' }}>Heading : </span>{ship.heading.toFixed(1)}&deg;</div> <div><span className="text-kcg-muted">{t('popup.heading')} : </span>{ship.heading.toFixed(1)}&deg;</div>
<div><span style={{ color: '#888' }}>Course : </span>{ship.course.toFixed(1)}&deg;</div> <div><span className="text-kcg-muted">{t('popup.course')} : </span>{ship.course.toFixed(1)}&deg;</div>
<div><span style={{ color: '#888' }}>Speed : </span>{ship.speed.toFixed(1)} kn</div> <div><span className="text-kcg-muted">{t('popup.speed')} : </span>{ship.speed.toFixed(1)} kn</div>
<div><span style={{ color: '#888' }}>Lat : </span>{formatCoord(ship.lat, 0).split(',')[0]}</div> <div><span className="text-kcg-muted">{t('popup.lat')} : </span>{formatCoord(ship.lat, 0).split(',')[0]}</div>
<div><span style={{ color: '#888' }}>Lon : </span>{formatCoord(0, ship.lng).split(', ')[1] || ship.lng.toFixed(3)}</div> <div><span className="text-kcg-muted">{t('popup.lon')} : </span>{formatCoord(0, ship.lng).split(', ')[1] || ship.lng.toFixed(3)}</div>
{ship.destination && <div><span style={{ color: '#888' }}>Dest : </span>{ship.destination}</div>} {ship.destination && <div><span className="text-kcg-muted">{t('popup.destination')} : </span>{ship.destination}</div>}
{ship.eta && <div><span style={{ color: '#888' }}>ETA : </span>{new Date(ship.eta).toLocaleString()}</div>} {ship.eta && <div><span className="text-kcg-muted">{t('popup.eta')} : </span>{new Date(ship.eta).toLocaleString()}</div>}
</div> </div>
</div> </div>
<div style={{ marginTop: 6, fontSize: 9, color: '#999', textAlign: 'right' }}> <div className="mt-1.5 text-[9px] text-[#999] text-right">
Last Update : {new Date(ship.lastSeen).toLocaleString()} {t('popup.lastUpdate')} : {new Date(ship.lastSeen).toLocaleString()}
</div> </div>
<div style={{ marginTop: 4, fontSize: 10, textAlign: 'right' }}> <div className="mt-1 text-[10px] text-right">
<a href={`https://www.marinetraffic.com/en/ais/details/ships/mmsi:${ship.mmsi}`} <a href={`https://www.marinetraffic.com/en/ais/details/ships/mmsi:${ship.mmsi}`}
target="_blank" rel="noopener noreferrer" style={{ color: '#60a5fa' }}> target="_blank" rel="noopener noreferrer" className="text-kcg-accent">
MarineTraffic &rarr; MarineTraffic &rarr;
</a> </a>
</div> </div>

파일 보기

@ -1,4 +1,5 @@
import { useMemo, useState, useCallback } from 'react'; import { useMemo, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import type { GeoEvent } from '../types'; import type { GeoEvent } from '../types';
interface Props { interface Props {
@ -22,24 +23,25 @@ const TYPE_COLORS: Record<string, string> = {
osint: '#06b6d4', osint: '#06b6d4',
}; };
const TYPE_LABELS_KO: Record<string, string> = { const TYPE_I18N_KEYS: Record<string, string> = {
airstrike: '공습', airstrike: 'event.airstrike',
explosion: '폭발', explosion: 'event.explosion',
missile_launch: '미사일 발사', missile_launch: 'event.missileLaunch',
intercept: '요격', intercept: 'event.intercept',
alert: '경보', alert: 'event.alert',
impact: '피격', impact: 'event.impact',
osint: 'OSINT', osint: 'event.osint',
}; };
const SOURCE_LABELS_KO: Record<string, string> = { const SOURCE_I18N_KEYS: Record<string, string> = {
US: '미국', US: 'source.US',
IL: '이스라엘', IL: 'source.IL',
IR: '이란', IR: 'source.IR',
proxy: '대리세력', proxy: 'source.proxy',
}; };
export function TimelineSlider({ currentTime, startTime, endTime, events, onSeek, onEventFlyTo }: Props) { export function TimelineSlider({ currentTime, startTime, endTime, events, onSeek, onEventFlyTo }: Props) {
const { t } = useTranslation();
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedId, setSelectedId] = useState<string | null>(null);
const progress = ((currentTime - startTime) / (endTime - startTime)) * 100; const progress = ((currentTime - startTime) / (endTime - startTime)) * 100;
@ -53,13 +55,13 @@ export function TimelineSlider({ currentTime, startTime, endTime, events, onSeek
})); }));
}, [events, startTime, endTime]); }, [events, startTime, endTime]);
const formatTime = (t: number) => { const formatTime = (ts: number) => {
const d = new Date(t + KST_OFFSET); const d = new Date(ts + KST_OFFSET);
return d.toISOString().slice(0, 16).replace('T', ' ') + ' KST'; return d.toISOString().slice(0, 16).replace('T', ' ') + ' KST';
}; };
const formatTimeShort = (t: number) => { const formatTimeShort = (ts: number) => {
const d = new Date(t + KST_OFFSET); const d = new Date(ts + KST_OFFSET);
return `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}`; return `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}`;
}; };
@ -128,8 +130,10 @@ export function TimelineSlider({ currentTime, startTime, endTime, events, onSeek
const color = TYPE_COLORS[ev.type] || '#888'; const color = TYPE_COLORS[ev.type] || '#888';
const isPast = ev.timestamp <= currentTime; const isPast = ev.timestamp <= currentTime;
const isActive = ev.id === selectedId; const isActive = ev.id === selectedId;
const source = ev.source ? SOURCE_LABELS_KO[ev.source] : ''; const sourceKey = ev.source ? SOURCE_I18N_KEYS[ev.source] : '';
const typeLabel = TYPE_LABELS_KO[ev.type] || ev.type; const source = sourceKey ? t(sourceKey) : '';
const typeKey = TYPE_I18N_KEYS[ev.type];
const typeLabel = typeKey ? t(typeKey) : ev.type;
return ( return (
<button <button
@ -137,7 +141,7 @@ export function TimelineSlider({ currentTime, startTime, endTime, events, onSeek
className={`tl-event-card ${isActive ? 'active' : ''} ${isPast ? 'past' : 'future'}`} className={`tl-event-card ${isActive ? 'active' : ''} ${isPast ? 'past' : 'future'}`}
style={{ '--card-color': color } as React.CSSProperties} style={{ '--card-color': color } as React.CSSProperties}
onClick={() => handleEventCardClick(ev)} onClick={() => handleEventCardClick(ev)}
title="클릭하면 지도에서 해당 위치로 이동합니다" title={t('timeline.flyToTooltip')}
> >
<span className="tl-card-dot" /> <span className="tl-card-dot" />
<span className="tl-card-time">{formatTimeShort(ev.timestamp)}</span> <span className="tl-card-time">{formatTimeShort(ev.timestamp)}</span>

파일 보기

@ -0,0 +1,187 @@
import { useEffect, useRef, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
interface LoginPageProps {
onGoogleLogin: (credential: string) => Promise<void>;
onDevLogin: () => void;
}
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID;
const IS_DEV = import.meta.env.DEV;
function useGoogleIdentity(onCredential: (credential: string) => void) {
const btnRef = useRef<HTMLDivElement>(null);
const callbackRef = useRef(onCredential);
callbackRef.current = onCredential;
useEffect(() => {
if (!GOOGLE_CLIENT_ID) return;
const scriptId = 'google-gsi-script';
let script = document.getElementById(scriptId) as HTMLScriptElement | null;
const initGoogle = () => {
const google = (window as unknown as Record<string, unknown>).google as {
accounts: {
id: {
initialize: (config: {
client_id: string;
callback: (response: { credential: string }) => void;
}) => void;
renderButton: (
el: HTMLElement,
config: {
theme: string;
size: string;
width: number;
text: string;
},
) => void;
};
};
} | undefined;
if (!google?.accounts?.id || !btnRef.current) return;
google.accounts.id.initialize({
client_id: GOOGLE_CLIENT_ID,
callback: (response: { credential: string }) => {
callbackRef.current(response.credential);
},
});
google.accounts.id.renderButton(btnRef.current, {
theme: 'outline',
size: 'large',
width: 300,
text: 'signin_with',
});
};
if (!script) {
script = document.createElement('script');
script.id = scriptId;
script.src = 'https://accounts.google.com/gsi/client';
script.async = true;
script.defer = true;
script.onload = initGoogle;
document.head.appendChild(script);
} else {
initGoogle();
}
}, []);
return btnRef;
}
const LoginPage = ({ onGoogleLogin, onDevLogin }: LoginPageProps) => {
const { t } = useTranslation();
const [error, setError] = useState<string | null>(null);
const handleGoogleCredential = useCallback(
(credential: string) => {
setError(null);
onGoogleLogin(credential).catch(() => {
setError(t('auth.loginFailed'));
});
},
[onGoogleLogin, t],
);
const googleBtnRef = useGoogleIdentity(handleGoogleCredential);
return (
<div
className="flex min-h-screen items-center justify-center"
style={{ backgroundColor: 'var(--kcg-bg)' }}
>
<div
className="flex w-full max-w-sm flex-col items-center gap-6 rounded-xl border p-8"
style={{
backgroundColor: 'var(--kcg-card)',
borderColor: 'var(--kcg-border)',
}}
>
{/* Title */}
<div className="flex flex-col items-center gap-2">
<div className="text-3xl">&#x1f6e1;&#xfe0f;</div>
<h1
className="text-xl font-bold"
style={{ color: 'var(--kcg-text)' }}
>
{t('auth.title')}
</h1>
<p
className="text-sm"
style={{ color: 'var(--kcg-muted)' }}
>
{t('auth.subtitle')}
</p>
</div>
{/* Error */}
{error && (
<div
className="w-full rounded-lg px-4 py-2 text-center text-sm"
style={{
backgroundColor: 'var(--kcg-danger-bg)',
color: 'var(--kcg-danger)',
}}
>
{error}
</div>
)}
{/* Google Login Button */}
{GOOGLE_CLIENT_ID && (
<>
<div ref={googleBtnRef} />
<p
className="text-xs"
style={{ color: 'var(--kcg-dim)' }}
>
{t('auth.domainNotice')}
</p>
</>
)}
{/* Dev Login */}
{IS_DEV && (
<>
<div
className="w-full border-t pt-4 text-center"
style={{ borderColor: 'var(--kcg-border)' }}
>
<span
className="text-xs font-mono tracking-wider"
style={{ color: 'var(--kcg-dim)' }}
>
{t('auth.devNotice')}
</span>
</div>
<button
type="button"
onClick={onDevLogin}
className="w-full cursor-pointer rounded-lg border-2 px-4 py-3 text-sm font-bold transition-colors"
style={{
borderColor: 'var(--kcg-danger)',
color: 'var(--kcg-danger)',
backgroundColor: 'transparent',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = 'var(--kcg-danger-bg)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent';
}}
>
{t('auth.devLogin')}
</button>
</>
)}
</div>
</div>
);
};
export default LoginPage;

파일 보기

@ -2,6 +2,7 @@
interface ImportMetaEnv { interface ImportMetaEnv {
readonly VITE_SPG_API_KEY?: string; readonly VITE_SPG_API_KEY?: string;
readonly VITE_GOOGLE_CLIENT_ID?: string;
} }
interface ImportMeta { interface ImportMeta {

파일 보기

@ -0,0 +1,78 @@
import { useState, useEffect, useCallback } from 'react';
import {
googleLogin,
getMe,
logout as logoutApi,
} from '../services/authApi';
import type { AuthUser } from '../services/authApi';
interface AuthState {
user: AuthUser | null;
isLoading: boolean;
isAuthenticated: boolean;
}
const DEV_USER: AuthUser = {
email: 'dev@gcsc.co.kr',
name: 'Developer',
};
export function useAuth() {
const [state, setState] = useState<AuthState>({
user: null,
isLoading: true,
isAuthenticated: false,
});
useEffect(() => {
let cancelled = false;
getMe()
.then((user) => {
if (!cancelled) {
setState({ user, isLoading: false, isAuthenticated: true });
}
})
.catch(() => {
if (!cancelled) {
setState({ user: null, isLoading: false, isAuthenticated: false });
}
});
return () => {
cancelled = true;
};
}, []);
const login = useCallback(async (credential: string) => {
setState((prev) => ({ ...prev, isLoading: true }));
try {
const user = await googleLogin(credential);
setState({ user, isLoading: false, isAuthenticated: true });
} catch {
setState({ user: null, isLoading: false, isAuthenticated: false });
throw new Error('Login failed');
}
}, []);
const devLogin = useCallback(() => {
setState({ user: DEV_USER, isLoading: false, isAuthenticated: true });
}, []);
const logout = useCallback(async () => {
try {
await logoutApi();
} finally {
setState({ user: null, isLoading: false, isAuthenticated: false });
}
}, []);
return {
user: state.user,
isLoading: state.isLoading,
isAuthenticated: state.isAuthenticated,
login,
devLogin,
logout,
};
}

파일 보기

@ -0,0 +1,47 @@
import { useState, useCallback, useEffect } from 'react';
type Theme = 'dark' | 'light';
const STORAGE_KEY = 'kcg:theme';
const DEFAULT_THEME: Theme = 'dark';
function readStoredTheme(): Theme {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === 'dark' || stored === 'light') return stored;
} catch {
// localStorage unavailable
}
return DEFAULT_THEME;
}
function applyTheme(theme: Theme) {
document.documentElement.dataset.theme = theme;
}
export function useTheme() {
const [theme, setThemeState] = useState<Theme>(() => {
const t = readStoredTheme();
applyTheme(t);
return t;
});
useEffect(() => {
applyTheme(theme);
}, [theme]);
const setTheme = useCallback((t: Theme) => {
setThemeState(t);
try {
localStorage.setItem(STORAGE_KEY, t);
} catch {
// localStorage unavailable
}
}, []);
const toggleTheme = useCallback(() => {
setTheme(theme === 'dark' ? 'light' : 'dark');
}, [theme, setTheme]);
return { theme, setTheme, toggleTheme } as const;
}

파일 보기

@ -0,0 +1,44 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import koCommon from './locales/ko/common.json';
import koEvents from './locales/ko/events.json';
import koShips from './locales/ko/ships.json';
import enCommon from './locales/en/common.json';
import enEvents from './locales/en/events.json';
import enShips from './locales/en/ships.json';
const STORAGE_KEY = 'kcg:lang';
function readStoredLang(): string {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === 'ko' || stored === 'en') return stored;
} catch {
// localStorage unavailable
}
return 'ko';
}
i18n.use(initReactI18next).init({
resources: {
ko: { common: koCommon, events: koEvents, ships: koShips },
en: { common: enCommon, events: enEvents, ships: enShips },
},
lng: readStoredLang(),
fallbackLng: 'ko',
defaultNS: 'common',
ns: ['common', 'events', 'ships'],
interpolation: { escapeValue: false },
});
// Persist language changes
i18n.on('languageChanged', (lng) => {
try {
localStorage.setItem(STORAGE_KEY, lng);
} catch {
// localStorage unavailable
}
});
export default i18n;

파일 보기

@ -0,0 +1,227 @@
{
"tabs": {
"iran": "Iran Situation",
"korea": "Korea Status"
},
"mode": {
"live": "LIVE",
"replay": "REPLAY"
},
"mapMode": {
"flat": "FLAT MAP",
"globe": "GLOBE",
"satellite": "SATELLITE"
},
"filters": {
"illegalFishing": "Illegal Fishing",
"illegalTransship": "Illegal Transship",
"darkVessel": "Dark Vessel",
"cableWatch": "Subsea Cable",
"dokdoWatch": "Dokdo Watch",
"ferryWatch": "Ferry Watch",
"illegalFishingMonitor": "Illegal Fishing Watch",
"illegalTransshipMonitor": "Illegal Transship Watch",
"darkVesselMonitor": "Dark Vessel Watch",
"cableWatchMonitor": "Subsea Cable Watch",
"dokdoWatchMonitor": "Dokdo Watch",
"ferryWatchMonitor": "Ferry Watch"
},
"header": {
"ac": "AC",
"mil": "MIL",
"ship": "SHIP",
"sat": "SAT",
"live": "LIVE",
"replaying": "REPLAYING",
"paused": "PAUSED"
},
"time": {
"justNow": "Just now",
"minutesAgo": "{{count}}m ago",
"hoursAgo": "{{count}}h ago",
"daysAgo": "{{count}}d ago",
"currentTime": "Current Time",
"history": "History"
},
"units": {
"knots": "kn",
"meters": "m",
"km": "km",
"vessels": "vessels",
"aircraft": "aircraft"
},
"theme": {
"dark": "Dark",
"light": "Light"
},
"language": {
"ko": "한국어",
"en": "English"
},
"controls": {
"play": "Play",
"pause": "Pause",
"reset": "Reset",
"speed": "Speed",
"customRange": "Custom Range",
"from": "FROM (KST)",
"to": "TO (KST)",
"apply": "APPLY"
},
"layers": {
"events": "Events",
"aircraft": "Aircraft",
"satellites": "Satellites",
"ships": "Ships",
"koreanShips": "Korean Ships",
"airports": "Airports",
"sensorCharts": "Sensor Charts",
"oilFacilities": "Oil Facilities",
"militaryOnly": "Military Only",
"infra": "Power/Substation",
"cables": "Subsea Cable",
"cctv": "CCTV",
"coastGuard": "Coast Guard",
"navWarning": "Nav Warning",
"osint": "OSINT Incident",
"eez": "EEZ / NLL",
"piracy": "Piracy Zone"
},
"korea": {
"transshipSuspect": "Transship Suspect",
"cableDanger": "Cable Danger",
"dokdoIntrusion": "Dokdo Intrusion",
"dokdoApproach": "Dokdo Approach",
"dokdoAlerts": "Dokdo Watch Alerts",
"territorialIntrusion": "Territorial Intrusion",
"approachWarning": "Approach Warning",
"dokdoDistance": "Dokdo {{dist}}km",
"detected": "{{count}} vessels detected",
"filterMonitoring": "Monitoring"
},
"legend": {
"altitude": "ALTITUDE",
"military": "MILITARY",
"vesselType": "VESSEL TYPE"
},
"popup": {
"impactSite": "IMPACT SITE",
"coordinates": "{{lat}}°N, {{lng}}°E"
},
"sensor": {
"title": "Sensor Data",
"seismic": "Seismic",
"seismicActivity": "Seismic Activity",
"airPressure": "Air Pressure",
"airPressureHpa": "Air Pressure (hPa)",
"noiseLevel": "Noise Level",
"noiseLevelDb": "Noise Level (dB)",
"radiation": "Radiation",
"radiationUsv": "Radiation (uSv/h)"
},
"event": {
"airstrike": "Airstrike",
"explosion": "Explosion",
"missileLaunch": "Missile Launch",
"intercept": "Intercept",
"alert": "Alert",
"impact": "Impact",
"osint": "OSINT"
},
"source": {
"US": "United States",
"IL": "Israel",
"IR": "Iran",
"proxy": "Proxy"
},
"timeline": {
"flyToTooltip": "Click to fly to this location on the map"
},
"dayNames": {
"sun": "Sun",
"mon": "Mon",
"tue": "Tue",
"wed": "Wed",
"thu": "Thu",
"fri": "Fri",
"sat": "Sat"
},
"airport": {
"international": "International",
"domestic": "Domestic"
},
"coastGuard": {
"agency": "Korea Coast Guard"
},
"piracy": {
"recentIncidents": "{{count}} incidents in past year"
},
"navWarning": {
"altitude": "Altitude",
"source": "Source",
"khoaLink": "KHOA Nav Warning Dashboard"
},
"osintMap": {
"viewOriginal": "View Original Article"
},
"satellite": {
"norad": "NORAD",
"lat": "Lat",
"lng": "Lng",
"alt": "Alt"
},
"airportPopup": {
"iata": "IATA",
"icao": "ICAO",
"city": "City",
"country": "Country"
},
"damagedShip": {
"sunk": "Sunk",
"severe": "Severe",
"moderate": "Moderate",
"minor": "Minor",
"shipType": "Type",
"flag": "Flag",
"cause": "Cause",
"damagedAt": "Damaged"
},
"cable": {
"landingStation": "{{name}} Cable Landing Station",
"connectedCables": "Connected Cables: {{count}}",
"waypoints": "Waypoints",
"rfsYear": "RFS",
"totalLength": "Total Length",
"operator": "Operator",
"yearSuffix": ""
},
"auth": {
"title": "KCG Monitoring Dashboard",
"subtitle": "Maritime Situational Awareness",
"googleLogin": "Sign in with Google",
"domainNotice": "Only @gcsc.co.kr accounts are allowed",
"devLogin": "DEV LOGIN (Bypass Auth)",
"devNotice": "Development only",
"loading": "Checking authentication...",
"loginFailed": "Login failed",
"domainError": "Only gcsc.co.kr domain accounts can access",
"logout": "Logout"
},
"infra": {
"nuclear": "Nuclear",
"coal": "Coal",
"gas": "LNG",
"oil": "Oil",
"hydro": "Hydro",
"solar": "Solar",
"wind": "Wind",
"biomass": "Biomass",
"substation": "Substation",
"plant": "Power Plant",
"powerPlant": "Power Plant",
"output": "Output",
"voltage": "Voltage",
"operator": "Operator",
"fuel": "Fuel"
}
}

파일 보기

@ -0,0 +1,65 @@
{
"type": {
"airstrike": "Airstrike",
"explosion": "Explosion",
"missile_launch": "Missile Launch",
"intercept": "Intercept",
"alert": "Alert",
"impact": "Impact",
"osint": "OSINT"
},
"source": {
"US": "United States",
"IL": "Israel",
"IR": "Iran",
"proxy": "Proxy Forces"
},
"category": {
"military": "Military",
"shipping": "Shipping",
"oil": "Oil & Gas",
"nuclear": "Nuclear",
"diplomacy": "Diplomacy",
"economy": "Economy",
"cyber": "Cyber",
"humanitarian": "Humanitarian",
"political": "Political"
},
"news": {
"breaking": "Breaking",
"breakingTitle": "Breaking / Major News",
"koreaTitle": "Korea Major News",
"liveUpdates": "Live Updates",
"source": "Source",
"readMore": "Read More",
"categoryLabel": {
"trump": "Trump",
"oil": "Oil",
"diplomacy": "Diplomacy",
"economy": "Economy"
}
},
"osint": {
"title": "OSINT Feed",
"noData": "No OSINT data collected",
"liveTitle": "OSINT LIVE FEED",
"koreaLiveTitle": "OSINT LIVE",
"loading": "Loading...",
"categoryLabel": {
"military": "Military",
"oil": "Energy",
"diplomacy": "Diplomacy",
"shipping": "Shipping",
"nuclear": "Nuclear",
"maritime_accident": "Maritime Accident",
"fishing": "Fishing",
"maritime_traffic": "Maritime Traffic",
"general": "General"
}
},
"log": {
"title": "Event Log",
"noEvents": "No events yet. Press play to start replay.",
"new": "NEW"
}
}

파일 보기

@ -0,0 +1,197 @@
{
"category": {
"warship": "Warship",
"carrier": "Aircraft Carrier",
"destroyer": "Destroyer",
"submarine": "Submarine",
"cargo": "Cargo",
"tanker": "Tanker",
"patrol": "Patrol",
"civilian": "Civilian",
"unknown": "Unknown"
},
"categoryLabel": {
"carrier": "CARRIER",
"destroyer": "DDG",
"warship": "WARSHIP",
"submarine": "SUB",
"patrol": "PATROL",
"tanker": "TANKER",
"cargo": "CARGO",
"civilian": "CIV",
"unknown": "N/A"
},
"mtType": {
"cargo": "Cargo",
"tanker": "Tanker",
"passenger": "Passenger",
"fishing": "Fishing",
"military": "Military",
"tug_special": "Tug/Special",
"high_speed": "High Speed",
"pleasure": "Pleasure",
"other": "Other",
"unspecified": "Unspecified",
"unknown": "Unknown"
},
"mtTypeLabel": {
"cargo": "Cargo",
"tanker": "Tanker",
"passenger": "Passenger",
"fishing": "Fishing",
"pleasure": "Yacht",
"military": "Military",
"tug_special": "Tug/Special",
"other": "Other",
"unknown": "Unknown"
},
"flag": {
"KR": "South Korea",
"US": "United States",
"JP": "Japan",
"CN": "China",
"IR": "Iran",
"UK": "United Kingdom",
"FR": "France",
"DE": "Germany",
"PA": "Panama",
"LR": "Liberia",
"MH": "Marshall Islands",
"AU": "Australia",
"IN": "India",
"HK": "Hong Kong",
"SG": "Singapore",
"BZ": "Belize",
"OM": "Oman",
"AE": "UAE",
"SA": "Saudi Arabia",
"BH": "Bahrain",
"QA": "Qatar"
},
"navy": {
"US": "US NAVY",
"KR": "ROKN",
"JP": "JMSDF",
"CN": "PLAN",
"IR": "IRIN"
},
"navyLabel": {
"US": "USN",
"UK": "RN",
"FR": "MN",
"KR": "ROKN",
"IR": "IRIN",
"JP": "JMSDF",
"AU": "RAN",
"DE": "DM",
"IN": "IN"
},
"popup": {
"mmsi": "MMSI",
"imo": "IMO",
"callSign": "Call Sign",
"heading": "Heading",
"course": "Course",
"speed": "Speed",
"lat": "Lat",
"lon": "Lon",
"destination": "Dest",
"eta": "ETA",
"status": "Status",
"length": "Length",
"width": "Width",
"draught": "Draught",
"lastUpdate": "Last Update",
"loading": "Loading..."
},
"status": {
"anchored": "Anchored",
"underway": "Underway",
"moored": "Moored",
"total": "Total"
},
"monitoring": {
"illegalFishing": "Suspected Illegal Fishing",
"illegalTransship": "Suspected Illegal Transshipment",
"darkVessel": "Suspected AIS Off",
"cableWatch": "Near Subsea Cable",
"dokdoWatch": "Dokdo Territorial Approach",
"ferryWatch": "Ferry Location"
},
"aircraft": {
"fighter": "Fighter",
"military": "Military",
"surveillance": "Surveillance",
"tanker": "Tanker",
"cargo": "Transport",
"civilian": "Civilian",
"unknown": "Unknown"
},
"aircraftLabel": {
"fighter": "FIGHTER",
"tanker": "TANKER",
"surveillance": "ISR",
"cargo": "CARGO",
"military": "MIL",
"civilian": "CIV",
"unknown": "???"
},
"aircraftPopup": {
"hex": "Hex",
"reg": "Reg.",
"operator": "Operator",
"type": "Type",
"squawk": "Squawk",
"alt": "Alt",
"speed": "Speed",
"hdg": "Hdg",
"verticalSpeed": "V/S",
"ground": "GROUND",
"loadingPhoto": "Loading photo..."
},
"shipStatus": {
"koreanTitle": "Korean Ship Status",
"chineseTitle": "Chinese Ship Status",
"chineseFishingAlert": "{{count}} Chinese fishing vessels near our waters"
},
"cctv": {
"region": {
"jeju": "Jeju",
"south": "South Sea",
"west": "West Sea",
"east": "East Sea"
},
"type": {
"tide": "Tide Observation",
"fog": "Fog Observation"
},
"live": "LIVE",
"khoa": "KHOA",
"viewStream": "View Live Stream",
"connecting": "Connecting",
"connectionFailed": "Connection Failed",
"viewOnBadatime": "View on badatime.com",
"rec": "REC",
"khoaFull": "KHOA National Ocean Survey",
"connectingEllipsis": "Connecting..."
},
"facility": {
"type": {
"refinery": "Refinery",
"oilfield": "Oilfield",
"gasfield": "Gas Field",
"terminal": "Export Terminal",
"petrochemical": "Petrochemical",
"desalination": "Desalination"
},
"damaged": "Damaged",
"plannedStrike": "Planned Strike",
"production": "Production",
"desalProduction": "Desalination",
"gasProduction": "Gas Production",
"reserveOil": "Reserves (Oil)",
"reserveGas": "Reserves (Gas)",
"operator": "Operator",
"barrels": "barrels"
}
}

파일 보기

@ -0,0 +1,227 @@
{
"tabs": {
"iran": "이란 상황",
"korea": "한국 현황"
},
"mode": {
"live": "LIVE",
"replay": "REPLAY"
},
"mapMode": {
"flat": "FLAT MAP",
"globe": "GLOBE",
"satellite": "위성지도"
},
"filters": {
"illegalFishing": "불법어선",
"illegalTransship": "불법환적",
"darkVessel": "다크베셀",
"cableWatch": "해저케이블",
"dokdoWatch": "독도감시",
"ferryWatch": "여객선감시",
"illegalFishingMonitor": "불법어선 감시",
"illegalTransshipMonitor": "불법환적 감시",
"darkVesselMonitor": "다크베셀 감시",
"cableWatchMonitor": "해저케이블 감시",
"dokdoWatchMonitor": "독도감시",
"ferryWatchMonitor": "여객선감시"
},
"header": {
"ac": "AC",
"mil": "MIL",
"ship": "SHIP",
"sat": "SAT",
"live": "LIVE",
"replaying": "REPLAYING",
"paused": "PAUSED"
},
"time": {
"justNow": "방금",
"minutesAgo": "{{count}}분 전",
"hoursAgo": "{{count}}시간 전",
"daysAgo": "{{count}}일 전",
"currentTime": "현재 시각",
"history": "히스토리"
},
"units": {
"knots": "kn",
"meters": "m",
"km": "km",
"vessels": "척",
"aircraft": "대"
},
"theme": {
"dark": "다크",
"light": "라이트"
},
"language": {
"ko": "한국어",
"en": "English"
},
"controls": {
"play": "재생",
"pause": "일시정지",
"reset": "초기화",
"speed": "배속",
"customRange": "사용자 범위",
"from": "시작 (KST)",
"to": "종료 (KST)",
"apply": "적용"
},
"layers": {
"events": "이벤트",
"aircraft": "항공기",
"satellites": "위성",
"ships": "선박",
"koreanShips": "한국 선박",
"airports": "공항",
"sensorCharts": "센서 차트",
"oilFacilities": "유전시설",
"militaryOnly": "군용기만",
"infra": "발전/변전",
"cables": "해저케이블",
"cctv": "CCTV",
"coastGuard": "해경",
"navWarning": "항행경보",
"osint": "OSINT 사고",
"eez": "EEZ / NLL",
"piracy": "해적 위험해역"
},
"korea": {
"transshipSuspect": "환적의심",
"cableDanger": "케이블위험",
"dokdoIntrusion": "독도침범",
"dokdoApproach": "독도접근",
"dokdoAlerts": "독도감시 알림",
"territorialIntrusion": "영해침범",
"approachWarning": "접근경고",
"dokdoDistance": "독도 {{dist}}km",
"detected": "{{count}}척 탐지",
"filterMonitoring": "감시"
},
"legend": {
"altitude": "ALTITUDE",
"military": "MILITARY",
"vesselType": "VESSEL TYPE"
},
"popup": {
"impactSite": "IMPACT SITE",
"coordinates": "{{lat}}°N, {{lng}}°E"
},
"sensor": {
"title": "센서 데이터",
"seismic": "지진파",
"seismicActivity": "지진파 활동",
"airPressure": "기압",
"airPressureHpa": "기압 (hPa)",
"noiseLevel": "소음",
"noiseLevelDb": "소음 수준 (dB)",
"radiation": "방사선",
"radiationUsv": "방사선 (uSv/h)"
},
"event": {
"airstrike": "공습",
"explosion": "폭발",
"missileLaunch": "미사일 발사",
"intercept": "요격",
"alert": "경보",
"impact": "피격",
"osint": "OSINT"
},
"source": {
"US": "미국",
"IL": "이스라엘",
"IR": "이란",
"proxy": "대리세력"
},
"timeline": {
"flyToTooltip": "클릭하면 지도에서 해당 위치로 이동합니다"
},
"dayNames": {
"sun": "일",
"mon": "월",
"tue": "화",
"wed": "수",
"thu": "목",
"fri": "금",
"sat": "토"
},
"airport": {
"international": "국제선",
"domestic": "국내선"
},
"coastGuard": {
"agency": "해양경찰청"
},
"piracy": {
"recentIncidents": "최근 1년 {{count}}건"
},
"navWarning": {
"altitude": "사용고도",
"source": "출처",
"khoaLink": "KHOA 항행경보 상황판 바로가기"
},
"osintMap": {
"viewOriginal": "기사 원문 보기"
},
"satellite": {
"norad": "NORAD",
"lat": "Lat",
"lng": "Lng",
"alt": "Alt"
},
"airportPopup": {
"iata": "IATA",
"icao": "ICAO",
"city": "City",
"country": "Country"
},
"damagedShip": {
"sunk": "침몰",
"severe": "중파",
"moderate": "중손",
"minor": "경미",
"shipType": "선종",
"flag": "국적",
"cause": "원인",
"damagedAt": "피격"
},
"cable": {
"landingStation": "{{name}} 해저케이블 기지",
"connectedCables": "연결 케이블: {{count}}개",
"waypoints": "경유지",
"rfsYear": "개통",
"totalLength": "총 길이",
"operator": "운영",
"yearSuffix": "년"
},
"auth": {
"title": "KCG 모니터링 대시보드",
"subtitle": "해양 상황 인식 시스템",
"googleLogin": "Google로 로그인",
"domainNotice": "@gcsc.co.kr 계정만 허용됩니다",
"devLogin": "DEV LOGIN (인증 우회)",
"devNotice": "개발 환경 전용",
"loading": "인증 확인 중...",
"loginFailed": "로그인 실패",
"domainError": "gcsc.co.kr 도메인 계정만 접근할 수 있습니다",
"logout": "로그아웃"
},
"infra": {
"nuclear": "원자력",
"coal": "석탄",
"gas": "LNG",
"oil": "석유",
"hydro": "수력",
"solar": "태양광",
"wind": "풍력",
"biomass": "바이오",
"substation": "변전소",
"plant": "발전소",
"powerPlant": "발전소",
"output": "출력",
"voltage": "전압",
"operator": "운영",
"fuel": "연료"
}
}

파일 보기

@ -0,0 +1,65 @@
{
"type": {
"airstrike": "공습",
"explosion": "폭발",
"missile_launch": "미사일 발사",
"intercept": "요격",
"alert": "경보",
"impact": "피격",
"osint": "OSINT"
},
"source": {
"US": "미국",
"IL": "이스라엘",
"IR": "이란",
"proxy": "대리세력"
},
"category": {
"military": "군사",
"shipping": "해운",
"oil": "석유",
"nuclear": "핵",
"diplomacy": "외교",
"economy": "경제",
"cyber": "사이버",
"humanitarian": "인도주의",
"political": "정치"
},
"news": {
"breaking": "속보",
"breakingTitle": "속보 / 주요 뉴스",
"koreaTitle": "한국 주요 뉴스",
"liveUpdates": "실시간 업데이트",
"source": "출처",
"readMore": "자세히 보기",
"categoryLabel": {
"trump": "트럼프",
"oil": "유가",
"diplomacy": "외교",
"economy": "경제"
}
},
"osint": {
"title": "OSINT 피드",
"noData": "수집된 OSINT 정보가 없습니다",
"liveTitle": "OSINT LIVE FEED",
"koreaLiveTitle": "OSINT LIVE",
"loading": "Loading...",
"categoryLabel": {
"military": "군사",
"oil": "에너지",
"diplomacy": "외교",
"shipping": "해운",
"nuclear": "핵",
"maritime_accident": "해양사고",
"fishing": "어선/수산",
"maritime_traffic": "해상교통",
"general": "일반"
}
},
"log": {
"title": "Event Log",
"noEvents": "아직 이벤트가 없습니다. 재생을 눌러 시작하세요.",
"new": "NEW"
}
}

파일 보기

@ -0,0 +1,197 @@
{
"category": {
"warship": "군함",
"carrier": "항공모함",
"destroyer": "구축함",
"submarine": "잠수함",
"cargo": "화물선",
"tanker": "유조선",
"patrol": "초계함",
"civilian": "민간선",
"unknown": "미분류"
},
"categoryLabel": {
"carrier": "CARRIER",
"destroyer": "DDG",
"warship": "WARSHIP",
"submarine": "SUB",
"patrol": "PATROL",
"tanker": "TANKER",
"cargo": "CARGO",
"civilian": "CIV",
"unknown": "N/A"
},
"mtType": {
"cargo": "화물선",
"tanker": "유조선",
"passenger": "여객선",
"fishing": "어선",
"military": "군함",
"tug_special": "예인선",
"high_speed": "고속선",
"pleasure": "유람선",
"other": "기타",
"unspecified": "미분류",
"unknown": "미분류"
},
"mtTypeLabel": {
"cargo": "Cargo",
"tanker": "Tanker",
"passenger": "Passenger",
"fishing": "Fishing",
"pleasure": "Yacht",
"military": "Military",
"tug_special": "Tug/Special",
"other": "Other",
"unknown": "Unknown"
},
"flag": {
"KR": "한국",
"US": "미국",
"JP": "일본",
"CN": "중국",
"IR": "이란",
"UK": "영국",
"FR": "프랑스",
"DE": "독일",
"PA": "파나마",
"LR": "라이베리아",
"MH": "마셜제도",
"AU": "호주",
"IN": "인도",
"HK": "홍콩",
"SG": "싱가포르",
"BZ": "벨리즈",
"OM": "오만",
"AE": "UAE",
"SA": "사우디",
"BH": "바레인",
"QA": "카타르"
},
"navy": {
"US": "US NAVY",
"KR": "ROKN",
"JP": "JMSDF",
"CN": "PLAN",
"IR": "IRIN"
},
"navyLabel": {
"US": "USN",
"UK": "RN",
"FR": "MN",
"KR": "ROKN",
"IR": "IRIN",
"JP": "JMSDF",
"AU": "RAN",
"DE": "DM",
"IN": "IN"
},
"popup": {
"mmsi": "MMSI",
"imo": "IMO",
"callSign": "Call Sign",
"heading": "Heading",
"course": "Course",
"speed": "Speed",
"lat": "Lat",
"lon": "Lon",
"destination": "Dest",
"eta": "ETA",
"status": "Status",
"length": "Length",
"width": "Width",
"draught": "Draught",
"lastUpdate": "Last Update",
"loading": "Loading..."
},
"status": {
"anchored": "정박",
"underway": "항해",
"moored": "계류",
"total": "전체"
},
"monitoring": {
"illegalFishing": "불법어선 의심",
"illegalTransship": "불법환적 의심",
"darkVessel": "AIS 미송출 의심",
"cableWatch": "해저케이블 근접",
"dokdoWatch": "독도 영해 접근",
"ferryWatch": "여객선 위치"
},
"aircraft": {
"fighter": "전투기",
"military": "군용기",
"surveillance": "정찰기",
"tanker": "공중급유기",
"cargo": "수송기",
"civilian": "민간기",
"unknown": "미분류"
},
"aircraftLabel": {
"fighter": "FIGHTER",
"tanker": "TANKER",
"surveillance": "ISR",
"cargo": "CARGO",
"military": "MIL",
"civilian": "CIV",
"unknown": "???"
},
"aircraftPopup": {
"hex": "Hex",
"reg": "Reg.",
"operator": "Operator",
"type": "Type",
"squawk": "Squawk",
"alt": "Alt",
"speed": "Speed",
"hdg": "Hdg",
"verticalSpeed": "V/S",
"ground": "GROUND",
"loadingPhoto": "Loading photo..."
},
"shipStatus": {
"koreanTitle": "한국 선박 현황",
"chineseTitle": "중국 선박 현황",
"chineseFishingAlert": "중국어선 {{count}}척 우리 해역 근접"
},
"cctv": {
"region": {
"jeju": "제주",
"south": "남해",
"west": "서해",
"east": "동해"
},
"type": {
"tide": "조위관측",
"fog": "해무관측"
},
"live": "LIVE",
"khoa": "KHOA",
"viewStream": "실시간 영상 보기",
"connecting": "연결중",
"connectionFailed": "연결 실패",
"viewOnBadatime": "badatime.com에서 보기",
"rec": "REC",
"khoaFull": "KHOA 국립해양조사원",
"connectingEllipsis": "연결 중..."
},
"facility": {
"type": {
"refinery": "정유소",
"oilfield": "유전",
"gasfield": "가스전",
"terminal": "수출터미널",
"petrochemical": "석유화학",
"desalination": "담수화시설"
},
"damaged": "피격",
"plannedStrike": "공격 예정",
"production": "생산/처리",
"desalProduction": "담수생산",
"gasProduction": "가스생산",
"reserveOil": "매장량(유)",
"reserveGas": "매장량(가스)",
"operator": "운영사",
"barrels": "배럴"
}
}

30
frontend/src/index.css Normal file
파일 보기

@ -0,0 +1,30 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
/* Legacy aliases → kcg tokens (theme-reactive) */
--bg-primary: var(--kcg-bg);
--bg-secondary: var(--kcg-surface);
--bg-card: var(--kcg-card);
--text-primary: var(--kcg-text);
--text-secondary: var(--kcg-muted);
--accent: var(--kcg-accent);
--danger: var(--kcg-danger);
--warning: var(--kcg-warning);
}
body {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
overflow-x: hidden;
}
#root {
width: 100%;
height: 100vh;
}

파일 보기

@ -1,5 +1,7 @@
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import './i18n'
import './styles/tailwind.css'
import './index.css' import './index.css'
import App from './App.tsx' import App from './App.tsx'

파일 보기

@ -1,7 +1,7 @@
import type { Aircraft, AircraftCategory } from '../types'; import type { Aircraft, AircraftCategory } from '../types';
// Airplanes.live API - specializes in military aircraft tracking // Airplanes.live API - specializes in military aircraft tracking
const ADSBX_BASE = 'https://api.airplanes.live/v2'; const ADSBX_BASE = '/api/airplaneslive/v2';
// Known military type codes // Known military type codes
const MILITARY_TYPES: Record<string, AircraftCategory> = { const MILITARY_TYPES: Record<string, AircraftCategory> = {
@ -83,6 +83,7 @@ export async function fetchMilitaryAircraft(): Promise<Aircraft[]> {
// Airplanes.live military endpoint - Middle East area // Airplanes.live military endpoint - Middle East area
const url = `${ADSBX_BASE}/mil`; const url = `${ADSBX_BASE}/mil`;
const res = await fetch(url); const res = await fetch(url);
if (res.status === 429) return [];
if (!res.ok) throw new Error(`Airplanes.live ${res.status}`); if (!res.ok) throw new Error(`Airplanes.live ${res.status}`);
const data = await res.json(); const data = await res.json();
@ -101,6 +102,7 @@ export async function fetchMilitaryAircraftKorea(): Promise<Aircraft[]> {
try { try {
const url = `${ADSBX_BASE}/mil`; const url = `${ADSBX_BASE}/mil`;
const res = await fetch(url); const res = await fetch(url);
if (res.status === 429) return [];
if (!res.ok) throw new Error(`Airplanes.live mil ${res.status}`); if (!res.ok) throw new Error(`Airplanes.live mil ${res.status}`);
const data = await res.json(); const data = await res.json();
return parseAirplanesLive(data).filter( return parseAirplanesLive(data).filter(
@ -133,7 +135,7 @@ async function doKrInitialLoad(): Promise<void> {
console.log('Airplanes.live Korea: initial load...'); console.log('Airplanes.live Korea: initial load...');
for (let i = 0; i < KR_QUERIES.length; i++) { for (let i = 0; i < KR_QUERIES.length; i++) {
try { try {
if (i > 0) await delay(800); if (i > 0) await delay(1500);
const ac = await fetchOneRegion(KR_QUERIES[i]); const ac = await fetchOneRegion(KR_QUERIES[i]);
krLiveCache.set(`kr-${i}`, { ac, ts: Date.now() }); krLiveCache.set(`kr-${i}`, { ac, ts: Date.now() });
} catch { /* skip */ } } catch { /* skip */ }
@ -216,6 +218,12 @@ function delay(ms: number) {
async function fetchOneRegion(q: { lat: number; lon: number; radius: number }): Promise<Aircraft[]> { async function fetchOneRegion(q: { lat: number; lon: number; radius: number }): Promise<Aircraft[]> {
const url = `${ADSBX_BASE}/point/${q.lat}/${q.lon}/${q.radius}`; const url = `${ADSBX_BASE}/point/${q.lat}/${q.lon}/${q.radius}`;
const res = await fetch(url); const res = await fetch(url);
if (res.status === 429) {
// Rate limited — back off and return empty
console.warn('Airplanes.live rate limited (429), backing off');
await delay(5000);
return [];
}
if (!res.ok) throw new Error(`Airplanes.live ${res.status}`); if (!res.ok) throw new Error(`Airplanes.live ${res.status}`);
const data = await res.json(); const data = await res.json();
return parseAirplanesLive(data); return parseAirplanesLive(data);
@ -226,7 +234,7 @@ async function doInitialLoad(): Promise<void> {
console.log('Airplanes.live: initial load — fetching 10 regions in background...'); console.log('Airplanes.live: initial load — fetching 10 regions in background...');
for (let i = 0; i < LIVE_QUERIES.length; i++) { for (let i = 0; i < LIVE_QUERIES.length; i++) {
try { try {
if (i > 0) await delay(800); if (i > 0) await delay(1500);
const ac = await fetchOneRegion(LIVE_QUERIES[i]); const ac = await fetchOneRegion(LIVE_QUERIES[i]);
liveCache.set(`${i}`, { ac, ts: Date.now() }); liveCache.set(`${i}`, { ac, ts: Date.now() });
console.log(` Region ${i}: ${ac.length} aircraft`); console.log(` Region ${i}: ${ac.length} aircraft`);

파일 보기

@ -0,0 +1,41 @@
const AUTH_BASE = '/api/kcg/auth';
export interface AuthUser {
email: string;
name: string;
picture?: string;
}
export async function googleLogin(credential: string): Promise<AuthUser> {
const res = await fetch(`${AUTH_BASE}/google`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.message ?? 'Login failed');
}
return res.json();
}
export async function getMe(): Promise<AuthUser> {
const res = await fetch(`${AUTH_BASE}/me`, {
credentials: 'include',
});
if (!res.ok) {
throw new Error('Not authenticated');
}
return res.json();
}
export async function logout(): Promise<void> {
await fetch(`${AUTH_BASE}/logout`, {
method: 'POST',
credentials: 'include',
});
}

파일 보기

@ -92,7 +92,7 @@ export async function fetchSatelliteTLE(): Promise<Satellite[]> {
// Fetch TLE groups from CelesTrak sequentially (avoid hammering) // Fetch TLE groups from CelesTrak sequentially (avoid hammering)
for (const { group, category } of CELESTRAK_GROUPS) { for (const { group, category } of CELESTRAK_GROUPS) {
try { try {
const url = `https://celestrak.org/NORAD/elements/gp.php?GROUP=${group}&FORMAT=tle`; const url = `/api/celestrak/NORAD/elements/gp.php?GROUP=${group}&FORMAT=tle`;
const res = await fetch(url); const res = await fetch(url);
if (!res.ok) { if (!res.ok) {
console.warn(`CelesTrak ${group}: ${res.status}`); console.warn(`CelesTrak ${group}: ${res.status}`);
@ -181,7 +181,7 @@ export async function fetchSatelliteTLEKorea(): Promise<Satellite[]> {
for (const { group, category } of CELESTRAK_GROUPS) { for (const { group, category } of CELESTRAK_GROUPS) {
try { try {
const url = `https://celestrak.org/NORAD/elements/gp.php?GROUP=${group}&FORMAT=tle`; const url = `/api/celestrak/NORAD/elements/gp.php?GROUP=${group}&FORMAT=tle`;
const res = await fetch(url); const res = await fetch(url);
if (!res.ok) continue; if (!res.ok) continue;
const text = await res.text(); const text = await res.text();

파일 보기

@ -1,7 +1,7 @@
import type { Aircraft, AircraftCategory } from '../types'; import type { Aircraft, AircraftCategory } from '../types';
// OpenSky Network API - free tier, no auth needed for basic queries // OpenSky Network API - free tier, no auth needed for basic queries
const OPENSKY_BASE = 'https://opensky-network.org/api'; const OPENSKY_BASE = '/api/opensky/api';
// Middle East bounding box (lat_min, lat_max, lng_min, lng_max) // Middle East bounding box (lat_min, lat_max, lng_min, lng_max)
const ME_BOUNDS = { const ME_BOUNDS = {
@ -75,13 +75,30 @@ function parseOpenSkyResponse(data: { time: number; states: unknown[][] | null }
}); });
} }
// OpenSky free tier: ~1 request per 10s. Shared throttle to avoid 429.
let lastOpenSkyCall = 0;
const OPENSKY_MIN_INTERVAL = 12_000; // 12s between calls
async function throttledOpenSkyFetch(url: string): Promise<Aircraft[]> {
const now = Date.now();
const wait = OPENSKY_MIN_INTERVAL - (now - lastOpenSkyCall);
if (wait > 0) await new Promise(r => setTimeout(r, wait));
lastOpenSkyCall = Date.now();
const res = await fetch(url);
if (res.status === 429) {
console.warn('OpenSky rate limited (429), skipping');
return [];
}
if (!res.ok) throw new Error(`OpenSky ${res.status}`);
const data = await res.json();
return parseOpenSkyResponse(data);
}
export async function fetchAircraftOpenSky(): Promise<Aircraft[]> { export async function fetchAircraftOpenSky(): Promise<Aircraft[]> {
try { try {
const url = `${OPENSKY_BASE}/states/all?lamin=${ME_BOUNDS.lamin}&lomin=${ME_BOUNDS.lomin}&lamax=${ME_BOUNDS.lamax}&lomax=${ME_BOUNDS.lomax}`; const url = `${OPENSKY_BASE}/states/all?lamin=${ME_BOUNDS.lamin}&lomin=${ME_BOUNDS.lomin}&lamax=${ME_BOUNDS.lamax}&lomax=${ME_BOUNDS.lomax}`;
const res = await fetch(url); return await throttledOpenSkyFetch(url);
if (!res.ok) throw new Error(`OpenSky ${res.status}`);
const data = await res.json();
return parseOpenSkyResponse(data);
} catch (err) { } catch (err) {
console.warn('OpenSky fetch failed, using sample data:', err); console.warn('OpenSky fetch failed, using sample data:', err);
return getSampleAircraft(); return getSampleAircraft();
@ -94,10 +111,7 @@ const KR_BOUNDS = { lamin: 20, lamax: 45, lomin: 115, lomax: 145 };
export async function fetchAircraftOpenSkyKorea(): Promise<Aircraft[]> { export async function fetchAircraftOpenSkyKorea(): Promise<Aircraft[]> {
try { try {
const url = `${OPENSKY_BASE}/states/all?lamin=${KR_BOUNDS.lamin}&lomin=${KR_BOUNDS.lomin}&lamax=${KR_BOUNDS.lamax}&lomax=${KR_BOUNDS.lomax}`; const url = `${OPENSKY_BASE}/states/all?lamin=${KR_BOUNDS.lamin}&lomin=${KR_BOUNDS.lomin}&lamax=${KR_BOUNDS.lamax}&lomax=${KR_BOUNDS.lomax}`;
const res = await fetch(url); return await throttledOpenSkyFetch(url);
if (!res.ok) throw new Error(`OpenSky Korea ${res.status}`);
const data = await res.json();
return parseOpenSkyResponse(data);
} catch (err) { } catch (err) {
console.warn('OpenSky Korea fetch failed:', err); console.warn('OpenSky Korea fetch failed:', err);
return []; return [];

파일 보기

@ -333,7 +333,7 @@ async function fetchXCentcom(): Promise<OsintItem[]> {
for (const url of rssUrls) { for (const url of rssUrls) {
try { try {
const res = await fetch(url, { signal: AbortSignal.timeout(6000) }); const res = await fetch(url, { signal: AbortSignal.timeout(6000), redirect: 'error' });
if (!res.ok) continue; if (!res.ok) continue;
const text = await res.text(); const text = await res.text();
const parser = new DOMParser(); const parser = new DOMParser();

파일 보기

@ -26,8 +26,8 @@ const KR_BOUNDS = {
maxLng: 145, maxLng: 145,
}; };
// How far back to look for vessel positions (seconds) // S&P API sinceSeconds — currently disabled, will be removed with S&P code
const SINCE_SECONDS = 3600; // last 1 hour // const SINCE_SECONDS = 3600;
// MMSI country prefix → flag code (MID = Maritime Identification Digits) // MMSI country prefix → flag code (MID = Maritime Identification Digits)
const MMSI_FLAG_MAP: Record<string, string> = { const MMSI_FLAG_MAP: Record<string, string> = {
@ -57,7 +57,7 @@ const NAVAL_MMSI_PREFIXES: Record<string, { flag: string; category: ShipCategory
// Known vessel name patterns // Known vessel name patterns
const MILITARY_NAME_PATTERNS: [RegExp, ShipCategory][] = [ const MILITARY_NAME_PATTERNS: [RegExp, ShipCategory][] = [
[/\bCVN\b|NIMITZ|FORD|EISENHOWER|LINCOLN|REAGAN|VINSON|STENNIS|TRUMAN|WASHINGTON|BUSH/i, 'carrier'], [/\bCVN\b|NIMITZ|\bFORD\b|EISENHOWER|LINCOLN|REAGAN|VINSON|STENNIS|TRUMAN|WASHINGTON|BUSH/i, 'carrier'],
[/\bDDG\b|\bDDH\b|DESTROYER|ARLEIGH|BURKE|ZUMWALT|SEJONG|CHUNGMUGONG|GWANGGAETO/i, 'destroyer'], [/\bDDG\b|\bDDH\b|DESTROYER|ARLEIGH|BURKE|ZUMWALT|SEJONG|CHUNGMUGONG|GWANGGAETO/i, 'destroyer'],
[/\bSSN\b|\bSS\b.*SUBMARINE/i, 'submarine'], [/\bSSN\b|\bSS\b.*SUBMARINE/i, 'submarine'],
[/\bCG\b|CRUISER|TICONDEROGA/i, 'warship'], [/\bCG\b|CRUISER|TICONDEROGA/i, 'warship'],
@ -277,27 +277,8 @@ function parseAISTarget(t: SPGAISTarget): Ship {
// ═══ Primary API: GetTargetsInAreaEnhanced ═══ // ═══ Primary API: GetTargetsInAreaEnhanced ═══
// Returns vessels within a bounding box updated in the last N seconds // Returns vessels within a bounding box updated in the last N seconds
export async function fetchShipsFromSPG(): Promise<Ship[]> { export async function fetchShipsFromSPG(): Promise<Ship[]> {
if (!SPG_USERNAME || !SPG_PASSWORD) { // TODO: signal-batch 전환 후 제거 — S&P API 비활성화
console.warn('VITE_SPG_USERNAME / VITE_SPG_PASSWORD not set, using sample data'); return [];
return [];
}
try {
const targets = await callSPGAPI('GetTargetsInAreaEnhanced', {
sinceSeconds: SINCE_SECONDS,
minLat: ME_BOUNDS.minLat,
maxLat: ME_BOUNDS.maxLat,
minLong: ME_BOUNDS.minLng,
maxLong: ME_BOUNDS.maxLng,
});
return targets
.filter(t => t.Lat != null && t.Lon != null && t.Lat !== 0 && t.Lon !== 0)
.map(parseAISTarget);
} catch (err) {
console.warn('S&P AIS API (GetTargetsInAreaEnhanced) failed:', err);
return [];
}
} }
// ═══ Supplementary: fetch specific vessels by MMSI ═══ // ═══ Supplementary: fetch specific vessels by MMSI ═══
@ -601,34 +582,180 @@ function getSampleShips(): Ship[] {
} }
// ═══════════════════════════════════════ // ═══════════════════════════════════════
// KOREA REGION — separate data pipeline // KOREA REGION — signal-batch data pipeline
// ═══════════════════════════════════════ // ═══════════════════════════════════════
// S&P AIS API for Korea region (Vladivostok → South China Sea) const SIGNAL_BATCH_BASE = '/signal-batch';
async function fetchShipsFromSPGKorea(): Promise<Ship[]> {
if (!SPG_USERNAME || !SPG_PASSWORD) return []; // signal-batch POST /api/v1/vessels/recent-positions-detail
interface RecentPositionDetailRequest {
minutes: number;
coordinates: number[][]; // [[lon,lat], ...] polygon (first == last)
}
interface RecentPositionDetailDto {
mmsi: string;
imo: number | null;
lon: number;
lat: number;
sog: number;
cog: number;
shipNm: string;
shipTy: string;
shipKindCode: string;
nationalCode: string;
lastUpdate: string;
shipImagePath: string | null;
shipImageCount: number | null;
heading: number | null;
callSign: string | null;
status: string | null;
destination: string | null;
eta: string | null;
draught: number | null;
length: number | null;
width: number | null;
}
// AIS Ship and Cargo Type (0-99) → SPG-compatible vessel type string
// ITU-R M.1371-5, Table 53 — second digit encodes hazard category (x1-x4)
// Returns strings matching SPG_VESSEL_TYPE_MAP keys for classifyShip() compatibility
function aisTypeToVesselType(shipTy: string): string | undefined {
const code = parseInt(shipTy, 10);
if (isNaN(code) || code < 0 || code > 99) return undefined;
// 20-29: Wing in Ground effect
if (code >= 20 && code <= 29) return 'Wing In Ground-effect';
// 30: Fishing
if (code === 30) return 'Fishing';
// 31-32: Towing
if (code === 31 || code === 32) return 'Tug';
// 33: Dredging/underwater ops
if (code === 33) return 'Vessel';
// 34: Diving operations
if (code === 34) return 'Vessel';
// 35: Military operations — let name/MMSI classification take priority
if (code === 35) return 'N/A';
// 36: Sailing
if (code === 36) return 'Vessel';
// 37: Pleasure craft
if (code === 37) return 'Vessel';
// 38-39: Reserved
// 40-49: High Speed Craft (x0=all, x1=Haz A, x2=Haz B, x3=Haz C, x4=Haz D)
if (code >= 40 && code <= 49) return 'High Speed Craft';
// 50: Pilot vessel
if (code === 50) return 'Pilot Boat';
// 51: Search and Rescue
if (code === 51) return 'Search And Rescue';
// 52: Tug
if (code === 52) return 'Tug';
// 53: Port tender
if (code === 53) return 'Tender';
// 54: Anti-pollution equipment
if (code === 54) return 'Anti Pollution';
// 55: Law enforcement
if (code === 55) return 'Law Enforcement';
// 56-57: Spare (local assignment)
// 58: Medical transport
if (code === 58) return 'Medical Transport';
// 59: Noncombatant ship (RR Resolution No. 18)
if (code === 59) return 'N/A';
// 60-69: Passenger (x0=all, x1=Haz A, x2=Haz B, x3=Haz C, x4=Haz D)
if (code >= 60 && code <= 69) return 'Passenger';
// 70-79: Cargo (x0=all, x1=Haz A, x2=Haz B, x3=Haz C, x4=Haz D)
if (code >= 70 && code <= 79) return 'Cargo';
// 80-89: Tanker (x0=all, x1=Haz A, x2=Haz B, x3=Haz C, x4=Haz D)
if (code >= 80 && code <= 89) return 'Tanker';
// 90-99: Other (x0=all, x1=Haz A, ... same pattern)
if (code >= 90 && code <= 99) return 'Vessel';
return undefined;
}
function parseSignalBatchVessel(d: RecentPositionDetailDto): Ship {
const name = (d.shipNm || '').trim();
const mmsi = String(d.mmsi || '');
// nationalCode is MMSI prefix (3 digits) — reuse existing MMSI_FLAG_MAP
const flag = MMSI_FLAG_MAP[d.nationalCode] || getFlagFromMMSI(mmsi);
// shipTy can be either a SPG VesselType string ("Cargo", "Tanker") or AIS numeric code ("70", "80")
// Try as-is first (string), fall back to AIS numeric conversion
const rawShipTy = (d.shipTy || '').trim();
const isNumeric = /^\d+$/.test(rawShipTy);
const vesselType = isNumeric ? aisTypeToVesselType(rawShipTy) : (rawShipTy || undefined);
// Existing classification: name pattern → vesselType string → MMSI prefix
const category = classifyShip(name, mmsi, vesselType);
// lastUpdate is KST "yyyy-MM-dd HH:mm:ss" — append timezone offset
let lastSeen = Date.now();
if (d.lastUpdate) {
const parsed = new Date(d.lastUpdate.replace(' ', 'T') + '+09:00').getTime();
if (!isNaN(parsed)) lastSeen = parsed;
}
return {
mmsi,
name: name || `MMSI-${mmsi}`,
lat: d.lat,
lng: d.lon,
heading: d.heading ?? d.cog ?? 0,
speed: d.sog ?? 0,
course: d.cog ?? d.heading ?? 0,
category,
flag,
// vesselType string for getMarineTrafficCategory() matching
typecode: vesselType || undefined,
typeDesc: vesselType || undefined,
imo: d.imo ? String(d.imo) : undefined,
callSign: d.callSign || undefined,
status: d.status || undefined,
destination: (d.destination || '').trim() || undefined,
eta: d.eta || undefined,
draught: d.draught != null && d.draught > 0 ? d.draught : undefined,
length: d.length != null && d.length > 0 ? d.length : undefined,
width: d.width != null && d.width > 0 ? d.width : undefined,
lastSeen,
shipImagePath: d.shipImagePath || undefined,
shipImageCount: d.shipImageCount ?? undefined,
};
}
async function fetchShipsFromSignalBatch(): Promise<Ship[]> {
try { try {
const targets = await callSPGAPI('GetTargetsInAreaEnhanced', { const body: RecentPositionDetailRequest = {
sinceSeconds: SINCE_SECONDS, minutes: 5,
minLat: KR_BOUNDS.minLat, coordinates: [
maxLat: KR_BOUNDS.maxLat, [KR_BOUNDS.minLng, KR_BOUNDS.minLat],
minLong: KR_BOUNDS.minLng, [KR_BOUNDS.maxLng, KR_BOUNDS.minLat],
maxLong: KR_BOUNDS.maxLng, [KR_BOUNDS.maxLng, KR_BOUNDS.maxLat],
[KR_BOUNDS.minLng, KR_BOUNDS.maxLat],
[KR_BOUNDS.minLng, KR_BOUNDS.minLat],
],
};
const res = await fetch(`${SIGNAL_BATCH_BASE}/api/v1/vessels/recent-positions-detail`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
}); });
return targets
.filter(t => t.Lat != null && t.Lon != null && t.Lat !== 0 && t.Lon !== 0) if (!res.ok) throw new Error(`signal-batch API ${res.status}`);
.map(parseAISTarget); const data: RecentPositionDetailDto[] = await res.json();
return data
.filter(d => d.lat != null && d.lon != null && d.lat !== 0 && d.lon !== 0)
.map(parseSignalBatchVessel);
} catch (err) { } catch (err) {
console.warn('S&P AIS API (Korea region) failed:', err); console.warn('signal-batch API (Korea region) failed:', err);
return []; return [];
} }
} }
export async function fetchShipsKorea(): Promise<Ship[]> { export async function fetchShipsKorea(): Promise<Ship[]> {
const sample = getSampleShipsKorea(); const sample = getSampleShipsKorea();
const real = await fetchShipsFromSPGKorea(); const real = await fetchShipsFromSignalBatch();
if (real.length > 0) { if (real.length > 0) {
console.log(`S&P AIS API: ${real.length} vessels in Korea region`); console.log(`signal-batch: ${real.length} vessels in Korea region`);
const sampleMMSIs = new Set(sample.map(s => s.mmsi)); const sampleMMSIs = new Set(sample.map(s => s.mmsi));
return [...real.filter(s => !sampleMMSIs.has(s.mmsi)), ...sample]; return [...real.filter(s => !sampleMMSIs.has(s.mmsi)), ...sample];
} }

파일 보기

@ -0,0 +1,2 @@
@import 'tailwindcss';
@import './tokens.css';

파일 보기

@ -0,0 +1,181 @@
/* KCG Monitoring Design Tokens
* 모든 색상을 --kcg-* CSS 변수로 관리.
* data-theme 속성으로 dark/light 전환.
* @theme 블록으로 Tailwind 유틸리티 클래스에 매핑.
*/
/* ── Dark Theme (기본 — 현재 UI와 동일) ── */
:root,
[data-theme='dark'] {
/* 배경 */
--kcg-bg: #0a0a1a;
--kcg-surface: #111127;
--kcg-card: #1a1a2e;
--kcg-overlay: rgba(10, 10, 26, 0.96);
--kcg-subtle: rgba(255, 255, 255, 0.03);
/* 텍스트 */
--kcg-text: #e0e0e0;
--kcg-text-secondary: #ccc;
--kcg-muted: #888;
--kcg-dim: #666;
/* 보더 */
--kcg-border: #333;
--kcg-border-light: #444;
--kcg-border-heavy: #555;
/* 시맨틱 */
--kcg-accent: #3b82f6;
--kcg-accent-hover: #2563eb;
--kcg-danger: #ef4444;
--kcg-warning: #eab308;
--kcg-success: #22c55e;
--kcg-info: #06b6d4;
/* 이벤트 타입 */
--kcg-event-airstrike: #ef4444;
--kcg-event-explosion: #f97316;
--kcg-event-missile: #eab308;
--kcg-event-intercept: #3b82f6;
--kcg-event-impact: #ff0000;
--kcg-event-alert: #f97316;
--kcg-event-osint: #06b6d4;
/* 선박 MT 분류 */
--kcg-ship-cargo: #f0a830;
--kcg-ship-tanker: #e74c3c;
--kcg-ship-passenger: #4caf50;
--kcg-ship-fishing: #42a5f5;
--kcg-ship-military: #d32f2f;
--kcg-ship-tug: #2e7d32;
--kcg-ship-pleasure: #e91e8c;
--kcg-ship-highspeed: #ff9800;
--kcg-ship-other: #5c6bc0;
--kcg-ship-unknown: #9e9e9e;
/* 항공기 카테고리 */
--kcg-ac-fighter: #ff4444;
--kcg-ac-military: #ff6600;
--kcg-ac-surveillance: #ffcc00;
--kcg-ac-tanker: #00ccff;
--kcg-ac-cargo: #a78bfa;
--kcg-ac-civilian: #FFD700;
--kcg-ac-unknown: #7CFC00;
/* 국기/해군 */
--kcg-navy-us: #4a90d9;
--kcg-navy-kr: #00c73c;
--kcg-navy-jp: #ff6b6b;
--kcg-navy-cn: #ff4444;
--kcg-navy-ir: #ff8c00;
/* 글래스/투명 */
--kcg-glass: rgba(10, 10, 26, 0.92);
--kcg-glass-dense: rgba(10, 10, 26, 0.96);
--kcg-hover: rgba(255, 255, 255, 0.05);
--kcg-hover-strong: rgba(255, 255, 255, 0.08);
--kcg-accent-bg: rgba(59, 130, 246, 0.12);
--kcg-danger-bg: rgba(239, 68, 68, 0.15);
/* 패널 그림자 */
--kcg-panel-shadow: none;
}
/* ── Light Theme ── */
[data-theme='light'] {
/* 배경 */
--kcg-bg: #f0f2f5;
--kcg-surface: #ffffff;
--kcg-card: #f8fafc;
--kcg-overlay: rgba(255, 255, 255, 0.96);
--kcg-subtle: rgba(0, 0, 0, 0.02);
/* 텍스트 */
--kcg-text: #1a1a2e;
--kcg-text-secondary: #374151;
--kcg-muted: #6b7280;
--kcg-dim: #9ca3af;
/* 보더 */
--kcg-border: #d1d5db;
--kcg-border-light: #e5e7eb;
--kcg-border-heavy: #9ca3af;
/* 시맨틱 (데이터 시각화 색상은 테마 불변) */
--kcg-accent: #2563eb;
--kcg-accent-hover: #1d4ed8;
/* 글래스/투명 */
--kcg-glass: rgba(255, 255, 255, 0.95);
--kcg-glass-dense: rgba(255, 255, 255, 0.98);
--kcg-hover: rgba(0, 0, 0, 0.04);
--kcg-hover-strong: rgba(0, 0, 0, 0.08);
--kcg-accent-bg: rgba(37, 99, 235, 0.08);
--kcg-danger-bg: rgba(239, 68, 68, 0.08);
/* 패널 그림자 — light에서 영역 구분 강화 (outline 제거 — 폰트 가독성) */
--kcg-panel-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
}
/* ── Tailwind @theme mapping ── */
@theme {
/* 배경 */
--color-kcg-bg: var(--kcg-bg);
--color-kcg-surface: var(--kcg-surface);
--color-kcg-card: var(--kcg-card);
--color-kcg-overlay: var(--kcg-overlay);
--color-kcg-subtle: var(--kcg-subtle);
/* 텍스트 */
--color-kcg-text: var(--kcg-text);
--color-kcg-text-secondary: var(--kcg-text-secondary);
--color-kcg-muted: var(--kcg-muted);
--color-kcg-dim: var(--kcg-dim);
/* 보더 */
--color-kcg-border: var(--kcg-border);
--color-kcg-border-light: var(--kcg-border-light);
/* 시맨틱 */
--color-kcg-accent: var(--kcg-accent);
--color-kcg-danger: var(--kcg-danger);
--color-kcg-warning: var(--kcg-warning);
--color-kcg-success: var(--kcg-success);
--color-kcg-info: var(--kcg-info);
/* 이벤트 */
--color-kcg-event-airstrike: var(--kcg-event-airstrike);
--color-kcg-event-explosion: var(--kcg-event-explosion);
--color-kcg-event-missile: var(--kcg-event-missile);
--color-kcg-event-intercept: var(--kcg-event-intercept);
--color-kcg-event-impact: var(--kcg-event-impact);
--color-kcg-event-osint: var(--kcg-event-osint);
/* 선박 */
--color-kcg-ship-cargo: var(--kcg-ship-cargo);
--color-kcg-ship-tanker: var(--kcg-ship-tanker);
--color-kcg-ship-passenger: var(--kcg-ship-passenger);
--color-kcg-ship-fishing: var(--kcg-ship-fishing);
--color-kcg-ship-military: var(--kcg-ship-military);
--color-kcg-ship-tug: var(--kcg-ship-tug);
--color-kcg-ship-unknown: var(--kcg-ship-unknown);
/* 항공기 */
--color-kcg-ac-fighter: var(--kcg-ac-fighter);
--color-kcg-ac-military: var(--kcg-ac-military);
--color-kcg-ac-surveillance: var(--kcg-ac-surveillance);
--color-kcg-ac-tanker: var(--kcg-ac-tanker);
--color-kcg-ac-cargo: var(--kcg-ac-cargo);
--color-kcg-ac-civilian: var(--kcg-ac-civilian);
/* 글래스 */
--color-kcg-glass: var(--kcg-glass);
--color-kcg-hover: var(--kcg-hover);
--color-kcg-accent-bg: var(--kcg-accent-bg);
--color-kcg-danger-bg: var(--kcg-danger-bg);
/* 폰트 */
--font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
}

파일 보기

@ -105,6 +105,8 @@ export interface Ship {
lastSeen: number; // unix ms lastSeen: number; // unix ms
activeStart?: number; // unix ms - when ship enters area activeStart?: number; // unix ms - when ship enters area
activeEnd?: number; // unix ms - when ship leaves area activeEnd?: number; // unix ms - when ship leaves area
shipImagePath?: string | null; // signal-batch image path
shipImageCount?: number; // number of available images
} }
// Iran oil/gas facility // Iran oil/gas facility

파일 보기

@ -1,9 +1,10 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [tailwindcss(), react()],
server: { server: {
proxy: { proxy: {
'/api/ais': { '/api/ais': {
@ -78,6 +79,42 @@ export default defineConfig({
rewrite: (path) => path.replace(/^\/api\/publish-twitter/, ''), rewrite: (path) => path.replace(/^\/api\/publish-twitter/, ''),
secure: true, secure: true,
}, },
'/api/airplaneslive': {
target: 'https://api.airplanes.live',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/airplaneslive/, ''),
secure: true,
},
'/api/opensky': {
target: 'https://opensky-network.org',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/opensky/, ''),
secure: true,
},
'/api/celestrak': {
target: 'https://celestrak.org',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/celestrak/, ''),
secure: true,
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; KCG-Monitor/1.0)',
},
},
'/api/kcg': {
target: 'http://localhost:8080',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/kcg/, '/api'),
},
'/signal-batch': {
target: 'https://wing.gc-si.dev',
changeOrigin: true,
secure: false,
},
'/shipimg': {
target: 'https://wing.gc-si.dev',
changeOrigin: true,
secure: false,
},
}, },
}, },
}) })

파일 보기

@ -1,319 +0,0 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { Marker, Popup } from 'react-map-gl/maplibre';
import Hls from 'hls.js';
import { KOREA_CCTV_CAMERAS } from '../services/cctv';
import type { CctvCamera } from '../services/cctv';
const REGION_COLOR: Record<string, string> = {
'제주': '#ff6b6b',
'남해': '#ffa94d',
'서해': '#69db7c',
'동해': '#74c0fc',
};
const TYPE_LABEL: Record<string, string> = {
tide: '조위관측',
fog: '해무관측',
};
/** KHOA HLS → vite 프록시 경유 */
function toProxyUrl(cam: CctvCamera): string {
return cam.streamUrl.replace('https://www.khoa.go.kr', '/api/khoa-hls');
}
export function CctvLayer() {
const [selected, setSelected] = useState<CctvCamera | null>(null);
const [streamCam, setStreamCam] = useState<CctvCamera | null>(null);
return (
<>
{KOREA_CCTV_CAMERAS.map(cam => {
const color = REGION_COLOR[cam.region] || '#aaa';
return (
<Marker key={cam.id} longitude={cam.lng} latitude={cam.lat} anchor="center"
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(cam); }}>
<div style={{
position: 'relative', cursor: 'pointer',
display: 'flex', flexDirection: 'column', alignItems: 'center',
filter: `drop-shadow(0 0 2px ${color}88)`,
}}>
<svg width={14} height={14} viewBox="0 0 24 24" fill="none">
<rect x="2" y="5" width="15" height="13" rx="2" fill={color} stroke="#fff" strokeWidth="0.8" />
<polygon points="17,8 23,5 23,18 17,15" fill={color} stroke="#fff" strokeWidth="0.5" />
<circle cx="6" cy="8.5" r="2.5" fill="#ff0000">
<animate attributeName="opacity" values="1;0.3;1" dur="1.5s" repeatCount="indefinite" />
</circle>
</svg>
<div style={{
fontSize: 6, color: '#fff', marginTop: 0,
textShadow: `0 0 3px ${color}, 0 0 2px #000, 0 0 2px #000`,
whiteSpace: 'nowrap', fontWeight: 700,
letterSpacing: 0.2,
}}>
{cam.name.length > 8 ? cam.name.slice(0, 8) + '..' : cam.name}
</div>
</div>
</Marker>
);
})}
{selected && (
<Popup longitude={selected.lng} latitude={selected.lat}
onClose={() => setSelected(null)} closeOnClick={false}
anchor="bottom" maxWidth="280px" className="gl-popup">
<div style={{ fontFamily: 'monospace', fontSize: 12, minWidth: 200 }}>
<div style={{
background: REGION_COLOR[selected.region] || '#888', color: '#000',
padding: '4px 8px', borderRadius: '4px 4px 0 0',
margin: '-10px -10px 8px -10px',
fontWeight: 700, fontSize: 13,
display: 'flex', alignItems: 'center', gap: 6,
}}>
<span>📹</span> {selected.name}
</div>
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
<span style={{
background: '#22c55e', color: '#fff',
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700,
}}> LIVE</span>
<span style={{
background: REGION_COLOR[selected.region] || '#888', color: '#000',
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700,
}}>{selected.region}</span>
<span style={{
background: '#333', color: '#ccc',
padding: '1px 6px', borderRadius: 3, fontSize: 10,
}}>{TYPE_LABEL[selected.type] || selected.type}</span>
<span style={{
background: '#1a1a2e', color: '#888',
padding: '1px 6px', borderRadius: 3, fontSize: 10,
}}>KHOA</span>
</div>
<div style={{ fontSize: 11, display: 'flex', flexDirection: 'column', gap: 3 }}>
<div style={{ fontSize: 9, color: '#666' }}>
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
</div>
<button
onClick={() => { setStreamCam(selected); setSelected(null); }}
style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
background: '#2563eb', color: '#fff',
padding: '4px 10px', borderRadius: 4, fontSize: 11,
fontWeight: 700, marginTop: 4,
justifyContent: 'center', border: 'none', cursor: 'pointer',
fontFamily: 'monospace',
}}
>
📺
</button>
</div>
</div>
</Popup>
)}
{/* CCTV HLS Stream Modal */}
{streamCam && (
<CctvStreamModal cam={streamCam} onClose={() => setStreamCam(null)} />
)}
</>
);
}
/** KHOA HLS 영상 모달 — 지도 하단 오버레이 */
function CctvStreamModal({ cam, onClose }: { cam: CctvCamera; onClose: () => void }) {
const videoRef = useRef<HTMLVideoElement>(null);
const hlsRef = useRef<Hls | null>(null);
const [status, setStatus] = useState<'loading' | 'playing' | 'error'>('loading');
const destroyHls = useCallback(() => {
if (hlsRef.current) {
hlsRef.current.destroy();
hlsRef.current = null;
}
}, []);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const proxied = toProxyUrl(cam);
setStatus('loading');
if (Hls.isSupported()) {
destroyHls();
const hls = new Hls({
enableWorker: true,
lowLatencyMode: true,
maxBufferLength: 10,
maxMaxBufferLength: 30,
});
hlsRef.current = hls;
hls.loadSource(proxied);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
setStatus('playing');
video.play().catch(() => {});
});
hls.on(Hls.Events.ERROR, (_event, data) => {
if (data.fatal) setStatus('error');
});
return () => destroyHls();
}
// Safari 네이티브 HLS
if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = proxied;
const onLoaded = () => setStatus('playing');
const onError = () => setStatus('error');
video.addEventListener('loadeddata', onLoaded);
video.addEventListener('error', onError);
video.play().catch(() => {});
return () => {
video.removeEventListener('loadeddata', onLoaded);
video.removeEventListener('error', onError);
};
}
setStatus('error');
return () => destroyHls();
}, [cam, destroyHls]);
const color = REGION_COLOR[cam.region] || '#888';
return (
/* Backdrop */
<div
onClick={onClose}
style={{
position: 'fixed', inset: 0, zIndex: 9999,
background: 'rgba(0,0,0,0.6)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
backdropFilter: 'blur(4px)',
}}
>
{/* Modal */}
<div
onClick={e => e.stopPropagation()}
style={{
width: 640, maxWidth: '90vw',
background: '#0a0a1a',
border: `1px solid ${color}`,
borderRadius: 8,
overflow: 'hidden',
boxShadow: `0 0 40px ${color}33, 0 4px 30px rgba(0,0,0,0.8)`,
}}
>
{/* Header */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '8px 14px',
background: 'rgba(10,10,26,0.95)',
borderBottom: '1px solid #222',
}}>
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
fontFamily: 'monospace', fontSize: 11, color: '#ddd',
}}>
<span style={{
background: status === 'playing' ? '#22c55e' : status === 'loading' ? '#eab308' : '#ef4444',
color: '#fff', padding: '1px 6px', borderRadius: 3, fontSize: 9, fontWeight: 700,
}}>
{status === 'playing' ? 'LIVE' : status === 'loading' ? '연결중' : 'ERROR'}
</span>
<span style={{ fontWeight: 700 }}>📹 {cam.name}</span>
<span style={{
background: color, color: '#000',
padding: '1px 6px', borderRadius: 3, fontSize: 9, fontWeight: 700,
}}>{cam.region}</span>
</div>
<button
onClick={onClose}
style={{
background: '#333', border: 'none', color: '#fff',
width: 24, height: 24, borderRadius: 4,
cursor: 'pointer', fontSize: 14, fontWeight: 700,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
></button>
</div>
{/* Video */}
<div style={{ position: 'relative', width: '100%', aspectRatio: '16/9', background: '#000' }}>
<video
ref={videoRef}
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
muted autoPlay playsInline
/>
{status === 'loading' && (
<div style={{
position: 'absolute', inset: 0,
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
background: 'rgba(0,0,0,0.8)',
}}>
<div style={{ fontSize: 28, opacity: 0.4, marginBottom: 8 }}>📹</div>
<div style={{ fontSize: 11, color: '#888', fontFamily: 'monospace' }}> ...</div>
</div>
)}
{status === 'error' && (
<div style={{
position: 'absolute', inset: 0,
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
background: 'rgba(0,0,0,0.8)',
}}>
<div style={{ fontSize: 28, opacity: 0.4, marginBottom: 8 }}></div>
<div style={{ fontSize: 12, color: '#ef4444', fontFamily: 'monospace', marginBottom: 8 }}> </div>
<a
href={cam.url}
target="_blank"
rel="noopener noreferrer"
style={{ fontSize: 10, color: '#3b82f6', fontFamily: 'monospace', textDecoration: 'underline' }}
>badatime.com에서 </a>
</div>
)}
{status === 'playing' && (
<>
<div style={{
position: 'absolute', top: 10, left: 10,
display: 'flex', alignItems: 'center', gap: 6,
}}>
<span style={{
fontSize: 10, fontWeight: 700, fontFamily: 'monospace',
padding: '2px 8px', borderRadius: 3,
background: 'rgba(0,0,0,0.7)', color: '#fff',
}}>{cam.name}</span>
<span style={{
fontSize: 9, fontWeight: 700,
padding: '2px 6px', borderRadius: 3,
background: 'rgba(239,68,68,0.3)', color: '#f87171',
}}> REC</span>
</div>
<div style={{
position: 'absolute', bottom: 10, left: 10,
fontSize: 9, fontFamily: 'monospace',
padding: '2px 8px', borderRadius: 3,
background: 'rgba(0,0,0,0.7)', color: '#888',
}}>
{cam.lat.toFixed(4)}°N {cam.lng.toFixed(4)}°E · KHOA
</div>
</>
)}
</div>
{/* Footer info */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '6px 14px',
background: 'rgba(10,10,26,0.95)',
borderTop: '1px solid #222',
fontFamily: 'monospace', fontSize: 9, color: '#555',
}}>
<span>{cam.lat.toFixed(4)}°N {cam.lng.toFixed(4)}°E</span>
<span>KHOA </span>
</div>
</div>
</div>
);
}

파일 보기

@ -1,428 +0,0 @@
import { useRef, useState, useEffect, useCallback } from 'react';
import { Map, NavigationControl, Marker, Source, Layer } from 'react-map-gl/maplibre';
import type { MapRef } from 'react-map-gl/maplibre';
import { ShipLayer } from './ShipLayer';
import { InfraLayer } from './InfraLayer';
import { SatelliteLayer } from './SatelliteLayer';
import { AircraftLayer } from './AircraftLayer';
import { SubmarineCableLayer } from './SubmarineCableLayer';
import { CctvLayer } from './CctvLayer';
import { KoreaAirportLayer } from './KoreaAirportLayer';
import { CoastGuardLayer } from './CoastGuardLayer';
import { NavWarningLayer } from './NavWarningLayer';
import { OsintMapLayer } from './OsintMapLayer';
import { EezLayer } from './EezLayer';
import { PiracyLayer } from './PiracyLayer';
import { fetchKoreaInfra } from '../services/infra';
import type { PowerFacility } from '../services/infra';
import type { Ship, Aircraft, SatellitePosition } from '../types';
import type { OsintItem } from '../services/osint';
import { countryLabelsGeoJSON } from '../data/countryLabels';
import 'maplibre-gl/dist/maplibre-gl.css';
export interface KoreaFiltersState {
illegalFishing: boolean;
illegalTransship: boolean;
darkVessel: boolean;
cableWatch: boolean;
dokdoWatch: boolean;
ferryWatch: boolean;
}
interface Props {
ships: Ship[];
aircraft: Aircraft[];
satellites: SatellitePosition[];
militaryOnly: boolean;
osintFeed: OsintItem[];
currentTime: number;
koreaFilters: KoreaFiltersState;
transshipSuspects: Set<string>;
cableWatchSuspects: Set<string>;
dokdoWatchSuspects: Set<string>;
dokdoAlerts: { mmsi: string; name: string; dist: number; time: number }[];
}
interface KoreaLayers {
ships: boolean;
aircraft: boolean;
satellites: boolean;
infra: boolean;
cables: boolean;
cctv: boolean;
airports: boolean;
coastGuard: boolean;
navWarning: boolean;
osint: boolean;
eez: boolean;
piracy: boolean;
militaryOnly: boolean;
}
// MarineTraffic-style: satellite + dark ocean + nautical overlay
const MAP_STYLE = {
version: 8 as const,
sources: {
'satellite': {
type: 'raster' as const,
tiles: [
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
],
tileSize: 256,
maxzoom: 19,
attribution: '&copy; Esri, Maxar',
},
'carto-dark': {
type: 'raster' as const,
tiles: [
'https://a.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png',
'https://b.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png',
'https://c.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png',
],
tileSize: 256,
},
'opensea': {
type: 'raster' as const,
tiles: [
'https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png',
],
tileSize: 256,
maxzoom: 18,
},
},
layers: [
{ id: 'background', type: 'background' as const, paint: { 'background-color': '#0d1f3c' } },
{ id: 'satellite-base', type: 'raster' as const, source: 'satellite', paint: { 'raster-brightness-max': 0.75, 'raster-saturation': -0.1, 'raster-contrast': 0.05 } },
{ id: 'dark-overlay', type: 'raster' as const, source: 'carto-dark', paint: { 'raster-opacity': 0.25 } },
{ id: 'seamark', type: 'raster' as const, source: 'opensea', paint: { 'raster-opacity': 0.5 } },
],
};
// Korea-centered view
const KOREA_MAP_CENTER = { longitude: 127.5, latitude: 36 };
const KOREA_MAP_ZOOM = 6;
const FILTER_LABELS: Record<string, { label: string; color: string; icon: string }> = {
illegalFishing: { label: '불법어선 감시', color: '#ef4444', icon: '🚫🐟' },
illegalTransship: { label: '불법환적 감시', color: '#f97316', icon: '⚓' },
darkVessel: { label: '다크베셀 감시', color: '#8b5cf6', icon: '👻' },
cableWatch: { label: '해저케이블 감시', color: '#00e5ff', icon: '🔌' },
dokdoWatch: { label: '독도감시', color: '#22c55e', icon: '🏝️' },
ferryWatch: { label: '여객선감시', color: '#2196f3', icon: '🚢' },
};
export function KoreaMap({ ships, aircraft, satellites, militaryOnly, osintFeed, currentTime, koreaFilters, transshipSuspects, cableWatchSuspects, dokdoWatchSuspects, dokdoAlerts }: Props) {
const mapRef = useRef<MapRef>(null);
const [infra, setInfra] = useState<PowerFacility[]>([]);
const [layers, setLayers] = useState<KoreaLayers>({
ships: true,
aircraft: true,
satellites: true,
infra: true,
cables: true,
cctv: true,
airports: true,
coastGuard: true,
navWarning: true,
osint: true,
eez: true,
piracy: true,
militaryOnly: false,
});
useEffect(() => {
setLayers(prev => ({ ...prev, militaryOnly }));
}, [militaryOnly]);
useEffect(() => {
fetchKoreaInfra().then(setInfra).catch(() => {});
}, []);
const toggle = useCallback((key: keyof KoreaLayers) => {
setLayers(prev => ({ ...prev, [key]: !prev[key] }));
}, []);
const milCount = aircraft.filter(a => a.category !== 'civilian' && a.category !== 'unknown').length;
return (
<Map
ref={mapRef}
initialViewState={{ ...KOREA_MAP_CENTER, zoom: KOREA_MAP_ZOOM }}
style={{ width: '100%', height: '100%' }}
mapStyle={MAP_STYLE}
>
<NavigationControl position="top-right" />
{/* 한글 국가명 라벨 */}
<Source id="country-labels" type="geojson" data={countryLabelsGeoJSON()}>
<Layer
id="country-label-lg"
type="symbol"
filter={['==', ['get', 'rank'], 1]}
layout={{
'text-field': ['get', 'name'],
'text-size': 15,
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
'text-allow-overlap': false,
'text-ignore-placement': false,
'text-padding': 6,
}}
paint={{
'text-color': '#e2e8f0',
'text-halo-color': '#000000',
'text-halo-width': 2,
'text-opacity': 0.9,
}}
/>
<Layer
id="country-label-md"
type="symbol"
filter={['==', ['get', 'rank'], 2]}
layout={{
'text-field': ['get', 'name'],
'text-size': 12,
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
'text-allow-overlap': false,
'text-ignore-placement': false,
'text-padding': 4,
}}
paint={{
'text-color': '#94a3b8',
'text-halo-color': '#000000',
'text-halo-width': 1.5,
'text-opacity': 0.85,
}}
/>
<Layer
id="country-label-sm"
type="symbol"
filter={['==', ['get', 'rank'], 3]}
minzoom={5}
layout={{
'text-field': ['get', 'name'],
'text-size': 10,
'text-font': ['Open Sans Regular', 'Arial Unicode MS Regular'],
'text-allow-overlap': false,
'text-ignore-placement': false,
'text-padding': 2,
}}
paint={{
'text-color': '#64748b',
'text-halo-color': '#000000',
'text-halo-width': 1,
'text-opacity': 0.75,
}}
/>
</Source>
{layers.ships && <ShipLayer ships={ships} militaryOnly={layers.militaryOnly} />}
{/* 환적 의심 라벨 */}
{transshipSuspects.size > 0 && ships.filter(s => transshipSuspects.has(s.mmsi)).map(s => (
<Marker key={`ts-${s.mmsi}`} longitude={s.lng} latitude={s.lat} anchor="bottom">
<div style={{
background: 'rgba(249,115,22,0.9)', color: '#fff',
padding: '1px 5px', borderRadius: 3,
fontSize: 9, fontWeight: 700, fontFamily: 'monospace',
border: '1px solid #f97316',
textShadow: '0 0 2px #000',
whiteSpace: 'nowrap',
animation: 'pulse 2s ease-in-out infinite',
}}>
</div>
</Marker>
))}
{/* 해저케이블 의심 라벨 */}
{cableWatchSuspects.size > 0 && ships.filter(s => cableWatchSuspects.has(s.mmsi)).map(s => (
<Marker key={`cw-${s.mmsi}`} longitude={s.lng} latitude={s.lat} anchor="bottom">
<div style={{
background: 'rgba(0,229,255,0.9)', color: '#000',
padding: '1px 5px', borderRadius: 3,
fontSize: 9, fontWeight: 700, fontFamily: 'monospace',
border: '1px solid #00e5ff',
textShadow: '0 0 2px rgba(255,255,255,0.5)',
whiteSpace: 'nowrap',
animation: 'pulse 2s ease-in-out infinite',
}}>
🔌
</div>
</Marker>
))}
{/* 독도감시 라벨 (일본 선박) */}
{dokdoWatchSuspects.size > 0 && ships.filter(s => dokdoWatchSuspects.has(s.mmsi)).map(s => {
const dist = Math.round(Math.hypot(
(s.lng - 131.8647) * Math.cos((37.2417 * Math.PI) / 180),
s.lat - 37.2417,
) * 111);
const inTerritorial = dist < 22;
return (
<Marker key={`dk-${s.mmsi}`} longitude={s.lng} latitude={s.lat} anchor="bottom">
<div style={{
background: inTerritorial ? 'rgba(239,68,68,0.95)' : 'rgba(234,179,8,0.9)',
color: '#fff',
padding: '2px 6px', borderRadius: 3,
fontSize: 9, fontWeight: 700, fontFamily: 'monospace',
border: `1px solid ${inTerritorial ? '#ef4444' : '#eab308'}`,
textShadow: '0 0 2px #000',
whiteSpace: 'nowrap',
animation: 'pulse 1.5s ease-in-out infinite',
}}>
{inTerritorial ? '🚨' : '⚠'} 🇯🇵 {inTerritorial ? '침범' : '접근'} {dist}km
</div>
</Marker>
);
})}
{layers.infra && infra.length > 0 && <InfraLayer facilities={infra} />}
{layers.satellites && satellites.length > 0 && <SatelliteLayer satellites={satellites} />}
{layers.aircraft && aircraft.length > 0 && <AircraftLayer aircraft={aircraft} militaryOnly={layers.militaryOnly} />}
{layers.cables && <SubmarineCableLayer />}
{layers.cctv && <CctvLayer />}
{layers.airports && <KoreaAirportLayer />}
{layers.coastGuard && <CoastGuardLayer />}
{layers.navWarning && <NavWarningLayer />}
{layers.osint && <OsintMapLayer osintFeed={osintFeed} currentTime={currentTime} />}
{layers.eez && <EezLayer />}
{layers.piracy && <PiracyLayer />}
{/* Filter Status Banner */}
{(() => {
const active = (Object.keys(koreaFilters) as (keyof KoreaFiltersState)[]).filter(k => koreaFilters[k]);
if (active.length === 0) return null;
return (
<div style={{
position: 'absolute', top: 10, left: '50%', transform: 'translateX(-50%)', zIndex: 20,
display: 'flex', gap: 6, backdropFilter: 'blur(8px)',
}}>
{active.map(k => {
const f = FILTER_LABELS[k];
return (
<div key={k} style={{
background: `${f.color}22`, border: `1px solid ${f.color}88`,
borderRadius: 8, padding: '6px 12px',
fontFamily: 'monospace', fontSize: 11, color: f.color,
fontWeight: 700, display: 'flex', alignItems: 'center', gap: 6,
animation: 'pulse 2s ease-in-out infinite',
}}>
<span style={{ fontSize: 13 }}>{f.icon}</span>
{f.label}
</div>
);
})}
<div style={{
background: 'rgba(10,10,26,0.85)', border: '1px solid #555',
borderRadius: 8, padding: '6px 12px',
fontFamily: 'monospace', fontSize: 12, color: '#fff',
fontWeight: 700, display: 'flex', alignItems: 'center',
}}>
{ships.length}
</div>
</div>
);
})()}
{/* Layer Panel */}
<div style={{
position: 'absolute', top: 10, left: 10, zIndex: 10,
background: 'rgba(10,10,26,0.92)', borderRadius: 8,
border: '1px solid #333', padding: '10px 12px',
fontFamily: 'monospace', fontSize: 11, minWidth: 160,
backdropFilter: 'blur(8px)',
}}>
<div style={{ fontWeight: 700, fontSize: 10, color: '#888', marginBottom: 6, letterSpacing: 1 }}>LAYERS</div>
<LayerBtn label={`선박 (${ships.length})`} color="#fb923c" active={layers.ships} onClick={() => toggle('ships')} />
<LayerBtn label={`항공기 (${aircraft.length})`} color="#22d3ee" active={layers.aircraft} onClick={() => toggle('aircraft')} />
<LayerBtn label={`위성 (${satellites.length})`} color="#ef4444" active={layers.satellites} onClick={() => toggle('satellites')} />
<LayerBtn label={`발전/변전 (${infra.length})`} color="#ffc107" active={layers.infra} onClick={() => toggle('infra')} />
<LayerBtn label="해저케이블" color="#00e5ff" active={layers.cables} onClick={() => toggle('cables')} />
<LayerBtn label="CCTV (15)" color="#ff6b6b" active={layers.cctv} onClick={() => toggle('cctv')} />
<LayerBtn label="공항 (16)" color="#a78bfa" active={layers.airports} onClick={() => toggle('airports')} />
<LayerBtn label="해경 (46)" color="#4dabf7" active={layers.coastGuard} onClick={() => toggle('coastGuard')} />
<LayerBtn label="항행경보" color="#eab308" active={layers.navWarning} onClick={() => toggle('navWarning')} />
<LayerBtn label="OSINT 사고" color="#ef4444" active={layers.osint} onClick={() => toggle('osint')} />
<LayerBtn label="EEZ / NLL" color="#3b82f6" active={layers.eez} onClick={() => toggle('eez')} />
<LayerBtn label="해적 위험해역" color="#ef4444" active={layers.piracy} onClick={() => toggle('piracy')} />
<div style={{ borderTop: '1px solid #333', margin: '6px 0' }} />
<LayerBtn label={`군사만 (${milCount})`} color="#f97316" active={layers.militaryOnly} onClick={() => toggle('militaryOnly')} />
{layers.aircraft && aircraft.length > 0 && (
<div style={{ marginTop: 8 }}>
<div style={{ fontSize: 9, color: '#666', fontWeight: 700, marginBottom: 3 }}>AIRCRAFT</div>
{(['fighter', 'military', 'surveillance', 'tanker', 'cargo', 'civilian'] as const).map(cat => {
const count = aircraft.filter(a => a.category === cat).length;
if (count === 0) return null;
const colors: Record<string, string> = {
fighter: '#ff4444', military: '#ff6600', surveillance: '#ffcc00',
tanker: '#00ccff', cargo: '#a78bfa', civilian: '#FFD700',
};
return (
<div key={cat} style={{ display: 'flex', justifyContent: 'space-between', fontSize: 9, color: colors[cat] || '#888' }}>
<span>{cat.toUpperCase()}</span>
<span style={{ color: '#aaa' }}>{count}</span>
</div>
);
})}
</div>
)}
</div>
{/* 독도감시 알림 패널 */}
{dokdoAlerts.length > 0 && koreaFilters.dokdoWatch && (
<div style={{
position: 'absolute', top: 10, right: 50, zIndex: 20,
background: 'rgba(10,10,26,0.95)', borderRadius: 8,
border: '1px solid #ef4444', padding: '8px 10px',
fontFamily: 'monospace', fontSize: 11, minWidth: 220,
maxHeight: 200, overflowY: 'auto',
backdropFilter: 'blur(8px)',
boxShadow: '0 0 20px rgba(239,68,68,0.3)',
}}>
<div style={{ fontWeight: 700, fontSize: 10, color: '#ef4444', marginBottom: 6, letterSpacing: 1, display: 'flex', alignItems: 'center', gap: 4 }}>
🚨
</div>
{dokdoAlerts.map((a, i) => (
<div key={`${a.mmsi}-${i}`} style={{
padding: '4px 0', borderBottom: i < dokdoAlerts.length - 1 ? '1px solid #222' : 'none',
display: 'flex', flexDirection: 'column', gap: 2,
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ color: a.dist < 22 ? '#ef4444' : '#eab308', fontWeight: 700, fontSize: 10 }}>
{a.dist < 22 ? '🚨 영해침범' : '⚠ 접근경고'}
</span>
<span style={{ color: '#555', fontSize: 9 }}>
{new Date(a.time).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</span>
</div>
<div style={{ color: '#ccc', fontSize: 10 }}>
🇯🇵 {a.name} {a.dist}km
</div>
</div>
))}
</div>
)}
</Map>
);
}
function LayerBtn({ label, color, active, onClick }: {
label: string; color: string; active: boolean; onClick: () => void;
}) {
return (
<button
onClick={onClick}
style={{
display: 'flex', alignItems: 'center', gap: 6,
width: '100%', padding: '3px 0', border: 'none',
background: 'transparent', cursor: 'pointer', textAlign: 'left',
fontSize: 11, fontFamily: 'monospace',
color: active ? '#ddd' : '#555',
opacity: active ? 1 : 0.5,
}}
>
<span style={{
width: 8, height: 8, borderRadius: '50%',
background: active ? color : '#444', flexShrink: 0,
}} />
{label}
</button>
);
}

파일 보기

@ -1,241 +0,0 @@
import type { LayerVisibility } from '../types';
// Aircraft category colors (matches AircraftLayer military fixed colors)
const AC_CAT_COLORS: Record<string, string> = {
fighter: '#ff4444',
military: '#ff6600',
surveillance: '#ffcc00',
tanker: '#00ccff',
cargo: '#a78bfa',
civilian: '#FFD700', // mid-altitude yellow (representative)
unknown: '#7CFC00',
};
// Altitude color legend (matches AircraftLayer gradient)
const ALT_LEGEND: [string, string][] = [
['Ground', '#555555'],
['< 2,000ft', '#00c000'],
['2,000ft', '#55EC55'],
['4,000ft', '#7CFC00'],
['6,000ft', '#BFFF00'],
['10,000ft', '#FFFF00'],
['20,000ft', '#FFD700'],
['30,000ft', '#FF8C00'],
['40,000ft', '#FF4500'],
['50,000ft+', '#BA55D3'],
];
// Military color legend
const MIL_LEGEND: [string, string][] = [
['Fighter', '#ff4444'],
['Military', '#ff6600'],
['ISR / Surveillance', '#ffcc00'],
['Tanker', '#00ccff'],
];
// Ship type color legend (MarineTraffic style)
const SHIP_TYPE_LEGEND: [string, string][] = [
['Cargo', '#f0a830'],
['Tanker', '#e74c3c'],
['Passenger', '#4caf50'],
['Fishing', '#42a5f5'],
['Yacht', '#e91e8c'],
['Military', '#d32f2f'],
['Tug/Special', '#2e7d32'],
['Other', '#5c6bc0'],
['Unknown', '#9e9e9e'],
];
interface Props {
layers: LayerVisibility;
onToggle: (key: keyof LayerVisibility) => void;
aircraftCount: number;
militaryCount: number;
satelliteCount: number;
shipCount: number;
koreanShipCount: number;
aircraftByCategory: Record<string, number>;
shipsByCategory: Record<string, number>;
}
export function LayerPanel({
layers,
onToggle,
aircraftCount,
militaryCount,
satelliteCount,
shipCount,
koreanShipCount,
aircraftByCategory,
shipsByCategory,
}: Props) {
return (
<div className="layer-panel">
<h3>LAYERS</h3>
<div className="layer-items">
<LayerToggle
label="Events"
color="#a855f7"
active={layers.events}
onClick={() => onToggle('events')}
/>
<LayerToggle
label={`Aircraft (${aircraftCount})`}
color="#22d3ee"
active={layers.aircraft}
onClick={() => onToggle('aircraft')}
/>
<LayerToggle
label={`Satellites (${satelliteCount})`}
color="#ef4444"
active={layers.satellites}
onClick={() => onToggle('satellites')}
/>
<LayerToggle
label={`Ships (${shipCount})`}
color="#fb923c"
active={layers.ships}
onClick={() => onToggle('ships')}
/>
<LayerToggle
label={`\u{1F1F0}\u{1F1F7} 한국 선박 (${koreanShipCount})`}
color="#00e5ff"
active={layers.koreanShips}
onClick={() => onToggle('koreanShips')}
indent
/>
<LayerToggle
label="Airports"
color="#f59e0b"
active={layers.airports}
onClick={() => onToggle('airports')}
/>
<LayerToggle
label="Oil/Gas Facilities"
color="#d97706"
active={layers.oilFacilities}
onClick={() => onToggle('oilFacilities')}
/>
<LayerToggle
label="Sensor Charts"
color="#22c55e"
active={layers.sensorCharts}
onClick={() => onToggle('sensorCharts')}
/>
<div className="layer-divider" />
<LayerToggle
label={`Military Only (${militaryCount})`}
color="#f97316"
active={layers.militaryOnly}
onClick={() => onToggle('militaryOnly')}
/>
</div>
{layers.aircraft && (
<div className="layer-stats">
<div className="stat-header">AIRCRAFT</div>
{Object.entries(aircraftByCategory)
.filter(([, count]) => count > 0)
.sort(([, a], [, b]) => b - a)
.map(([cat, count]) => (
<div key={cat} className="stat-row">
<span className="stat-cat" style={{ color: AC_CAT_COLORS[cat] || '#888' }}>
{cat.toUpperCase()}
</span>
<span className="stat-count">{count}</span>
</div>
))}
{/* Altitude color legend */}
<div className="stat-header" style={{ marginTop: 6 }}>ALTITUDE</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 1, fontSize: 9, opacity: 0.85 }}>
{ALT_LEGEND.map(([label, color]) => (
<div key={label} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{
width: 10, height: 10, borderRadius: 2,
background: color, display: 'inline-block', flexShrink: 0,
}} />
<span style={{ color: '#aaa' }}>{label}</span>
</div>
))}
</div>
{/* Military color legend */}
<div className="stat-header" style={{ marginTop: 6 }}>MILITARY</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 1, fontSize: 9, opacity: 0.85 }}>
{MIL_LEGEND.map(([label, color]) => (
<div key={label} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{
width: 10, height: 10, borderRadius: 2,
background: color, display: 'inline-block', flexShrink: 0,
}} />
<span style={{ color: '#aaa' }}>{label}</span>
</div>
))}
</div>
</div>
)}
{layers.ships && (
<div className="layer-stats">
<div className="stat-header">SHIPS</div>
{Object.entries(shipsByCategory)
.filter(([, count]) => count > 0)
.sort(([, a], [, b]) => b - a)
.map(([cat, count]) => (
<div key={`ship-${cat}`} className="stat-row">
<span className="stat-cat">{cat.toUpperCase()}</span>
<span className="stat-count">{count}</span>
</div>
))}
{/* Ship type color legend */}
<div className="stat-header" style={{ marginTop: 6 }}>VESSEL TYPE</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 1, fontSize: 9, opacity: 0.85 }}>
{SHIP_TYPE_LEGEND.map(([label, color]) => (
<div key={label} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{
width: 0, height: 0, flexShrink: 0,
borderLeft: '5px solid transparent',
borderRight: '5px solid transparent',
borderBottom: `10px solid ${color}`,
}} />
<span style={{ color: '#aaa' }}>{label}</span>
</div>
))}
</div>
</div>
)}
</div>
);
}
function LayerToggle({
label,
color,
active,
onClick,
indent,
}: {
label: string;
color: string;
active: boolean;
onClick: () => void;
indent?: boolean;
}) {
return (
<button
className={`layer-toggle ${active ? 'active' : ''}`}
onClick={onClick}
style={indent ? { paddingLeft: 18, fontSize: 10 } : undefined}
>
<span
className="layer-dot"
style={{ backgroundColor: active ? color : '#444' }}
/>
{label}
</button>
);
}

Some files were not shown because too many files have changed in this diff Show More