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(./**/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"
},
"dependencies": {
"@rollup/rollup-darwin-arm64": "^4.59.0",
"@tailwindcss/vite": "^4.2.1",
"@types/leaflet": "^1.9.21",
"date-fns": "^4.1.0",
"hls.js": "^1.6.15",
"i18next": "^25.8.18",
"leaflet": "^1.9.4",
"maplibre-gl": "^5.19.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-i18next": "^16.5.8",
"react-leaflet": "^5.0.0",
"react-map-gl": "^8.1.0",
"recharts": "^3.8.0",
"satellite.js": "^6.0.2"
"satellite.js": "^6.0.2",
"tailwindcss": "^4.2.1"
},
"devDependencies": {
"@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 { propagateAircraft, propagateShips } from './services/propagation';
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';
// MarineTraffic-style ship classification
@ -74,6 +78,32 @@ function getMarineTrafficCategory(typecode?: string, category?: string): string
}
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 [events, setEvents] = useState<GeoEvent[]>([]);
const [sensorData, setSensorData] = useState<SensorLog[]>([]);
@ -100,6 +130,47 @@ function App() {
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);
// 1시간마다 전체 데이터 강제 리프레시 (호르무즈 해협 실시간 정보 업데이트)
@ -125,6 +196,11 @@ function App() {
const replay = useReplay();
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';
@ -211,7 +287,7 @@ function App() {
return () => clearInterval(interval);
}, [appMode, refreshKey]);
// Fetch Korea region ship data (separate pipeline)
// Fetch Korea region ship data (signal-batch, 4-min cycle)
useEffect(() => {
const load = async () => {
try {
@ -220,7 +296,7 @@ function App() {
} catch { /* keep previous */ }
};
load();
const interval = setInterval(load, 15_000);
const interval = setInterval(load, 240_000);
return () => clearInterval(interval);
}, [appMode, refreshKey]);
@ -376,6 +452,24 @@ function App() {
[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) => {
setLayers(prev => ({ ...prev, [key]: !prev[key] }));
}, []);
@ -398,12 +492,17 @@ function App() {
() => aircraft.filter(a => a.category !== 'civilian' && a.category !== 'unknown').length,
[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 counts: Record<string, number> = {};
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;
}, [ships]);
@ -424,12 +523,21 @@ function App() {
const koreaChineseShips = useMemo(() => koreaShips.filter(s => s.flag === 'CN'), [koreaShips]);
const koreaShipsByCategory = useMemo(() => {
const counts: Record<string, number> = {};
for (const s of koreaKoreanShips) {
for (const s of koreaShips) {
const mtCat = getMarineTrafficCategory(s.typecode, s.category);
counts[mtCat] = (counts[mtCat] || 0) + 1;
}
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)
const anyFilterOn = koreaFilters.illegalFishing || koreaFilters.illegalTransship || koreaFilters.darkVessel || koreaFilters.cableWatch || koreaFilters.dokdoWatch || koreaFilters.ferryWatch;
@ -701,8 +809,8 @@ function App() {
}, [koreaShips, koreaFilters.dokdoWatch, currentTime]);
const koreaFilteredShips = useMemo(() => {
if (!anyFilterOn) return koreaShips;
return koreaShips.filter(s => {
if (!anyFilterOn) return visibleKoreaShips;
return visibleKoreaShips.filter(s => {
const mtCat = getMarineTrafficCategory(s.typecode, s.category);
if (koreaFilters.illegalFishing && mtCat === 'fishing' && s.flag !== 'KR') 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;
return false;
});
}, [koreaShips, koreaFilters, anyFilterOn, transshipSuspects, darkVesselSet, cableWatchSet, dokdoWatchSet]);
}, [visibleKoreaShips, koreaFilters, anyFilterOn, transshipSuspects, darkVesselSet, cableWatchSet, dokdoWatchSet]);
return (
<div className={`app ${isLive ? 'app-live' : ''}`}>
@ -724,28 +832,22 @@ function App() {
onClick={() => setDashboardTab('iran')}
>
<span className="dash-tab-flag">🇮🇷</span>
{t('tabs.iran')}
</button>
<button
className={`dash-tab ${dashboardTab === 'korea' ? 'active' : ''}`}
onClick={() => setDashboardTab('korea')}
>
<span className="dash-tab-flag">🇰🇷</span>
{t('tabs.korea')}
</button>
</div>
{/* Mode Toggle */}
{dashboardTab === 'iran' && (
<div className="mode-toggle">
<div style={{
display: 'flex', alignItems: 'center', gap: 6,
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>
<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">
<span className="text-[13px]"></span>
D+{Math.floor((currentTime - new Date('2026-03-01T00:00:00Z').getTime()) / (1000 * 60 * 60 * 24))}
</div>
<button
@ -753,14 +855,14 @@ function App() {
onClick={() => setAppMode('live')}
>
<span className="mode-dot-icon" />
LIVE
{t('mode.live')}
</button>
<button
className={`mode-btn ${appMode === 'replay' ? 'active' : ''}`}
onClick={() => setAppMode('replay')}
>
<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>
</div>
)}
@ -770,50 +872,50 @@ function App() {
<button
className={`mode-btn ${koreaFilters.illegalFishing ? 'active live' : ''}`}
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
className={`mode-btn ${koreaFilters.illegalTransship ? 'active live' : ''}`}
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
className={`mode-btn ${koreaFilters.darkVessel ? 'active live' : ''}`}
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
className={`mode-btn ${koreaFilters.cableWatch ? 'active live' : ''}`}
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
className={`mode-btn ${koreaFilters.dokdoWatch ? 'active live' : ''}`}
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
className={`mode-btn ${koreaFilters.ferryWatch ? 'active live' : ''}`}
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>
</div>
)}
@ -825,35 +927,43 @@ function App() {
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>
FLAT MAP
{t('mapMode.flat')}
</button>
<button
className={`map-mode-btn ${mapMode === 'globe' ? 'active' : ''}`}
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>
GLOBE
{t('mapMode.globe')}
</button>
<button
className={`map-mode-btn ${mapMode === 'satellite' ? 'active' : ''}`}
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>
{t('mapMode.satellite')}
</button>
</div>
)}
<div className="header-info">
<div className="header-counts">
<span className="count-item ac-count">{aircraft.length} AC</span>
<span className="count-item mil-count">{militaryCount} MIL</span>
<span className="count-item ship-count">{ships.length} SHIP</span>
<span className="count-item sat-count">{satPositions.length} SAT</span>
<span className="count-item ac-count">{dashboardTab === 'iran' ? aircraft.length : aircraftKorea.length} AC</span>
<span className="count-item mil-count">{dashboardTab === 'iran' ? militaryCount : koreaMilitaryCount} MIL</span>
<span className="count-item ship-count">{dashboardTab === 'iran' ? ships.length : koreaShips.length} SHIP</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 className="header-status">
<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>
</header>
@ -870,9 +980,9 @@ function App() {
key="map-iran"
events={isLive ? [] : mergedEvents}
currentTime={currentTime}
aircraft={aircraft}
aircraft={visibleAircraft}
satellites={satPositions}
ships={ships}
ships={visibleShips}
layers={layers}
flyToTarget={flyToTarget}
onFlyToDone={() => setFlyToTarget(null)}
@ -881,32 +991,41 @@ function App() {
<GlobeMap
events={isLive ? [] : mergedEvents}
currentTime={currentTime}
aircraft={aircraft}
aircraft={visibleAircraft}
satellites={satPositions}
ships={ships}
ships={visibleShips}
layers={layers}
/>
) : (
<SatelliteMap
events={isLive ? [] : mergedEvents}
currentTime={currentTime}
aircraft={aircraft}
aircraft={visibleAircraft}
satellites={satPositions}
ships={ships}
ships={visibleShips}
layers={layers}
/>
)}
<div className="map-overlay-left">
<LayerPanel
layers={layers}
onToggle={toggleLayer}
aircraftCount={aircraft.length}
militaryCount={militaryCount}
satelliteCount={satPositions.length}
shipCount={ships.length}
koreanShipCount={koreanShips.length}
layers={layers as unknown as Record<string, boolean>}
onToggle={toggleLayer as (key: string) => void}
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>
@ -983,7 +1102,45 @@ function App() {
<>
<main className="app-main">
<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>
<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 { Marker, Popup, Source, Layer } from 'react-map-gl/maplibre';
import { useTranslation } from 'react-i18next';
import type { Aircraft, AircraftCategory } from '../types';
interface Props {
@ -55,7 +56,7 @@ const ALT_COLORS: [number, string][] = [
[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',
};
@ -68,22 +69,18 @@ function getAltitudeColor(altMeters: number): string {
}
function getAircraftColor(ac: Aircraft): string {
const milColor = MIL_COLORS[ac.category];
const milColor = MIL_HEX[ac.category];
if (milColor) return milColor;
if (ac.onGround) return '#555555';
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 ═══
interface PhotoResult { url: string; photographer: string; link: string; }
const photoCache = new Map<string, PhotoResult | null>();
function AircraftPhoto({ hex }: { hex: string }) {
const { t } = useTranslation('ships');
const [photo, setPhoto] = useState<PhotoResult | null | undefined>(
photoCache.has(hex) ? photoCache.get(hex) : undefined,
);
@ -119,19 +116,19 @@ function AircraftPhoto({ hex }: { hex: string }) {
}, [hex, photo]);
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;
return (
<div style={{ marginBottom: 6 }}>
<div className="mb-1.5">
<a href={photo.link} target="_blank" rel="noopener noreferrer">
<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'; }}
/>
</a>
{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}
</div>
)}
@ -188,6 +185,7 @@ export function AircraftLayer({ aircraft, militaryOnly }: Props) {
// ═══ Aircraft Marker ═══
const AircraftMarker = memo(function AircraftMarker({ ac }: { ac: Aircraft }) {
const { t } = useTranslation('ships');
const [showPopup, setShowPopup] = useState(false);
const color = getAircraftColor(ac);
const shape = getShape(ac);
@ -198,10 +196,11 @@ const AircraftMarker = memo(function AircraftMarker({ ac }: { ac: Aircraft }) {
return (
<>
<Marker longitude={ac.lng} latitude={ac.lat} anchor="center">
<div style={{ position: 'relative' }}>
<div className="relative">
<div
className="cursor-pointer"
style={{
width: size, height: size, cursor: 'pointer',
width: size, height: size,
transform: `rotate(${ac.heading}deg)`,
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}
onClose={() => setShowPopup(false)} closeOnClick={false}
anchor="bottom" maxWidth="300px" className="gl-popup">
<div style={{ minWidth: 240, maxWidth: 300, fontFamily: 'monospace', fontSize: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<strong style={{ fontSize: 14 }}>{ac.callsign || 'N/A'}</strong>
<span style={{
background: color, color: '#000', padding: '1px 6px',
borderRadius: 3, fontSize: 10, fontWeight: 700, marginLeft: 'auto',
}}>
{CATEGORY_LABELS[ac.category]}
<div className="min-w-[240px] max-w-[300px] font-mono text-xs">
<div className="flex items-center gap-2 mb-1.5">
<strong className="text-sm">{ac.callsign || 'N/A'}</strong>
<span
className="px-1.5 py-px rounded text-[10px] font-bold ml-auto text-black"
style={{ background: color }}
>
{t(`aircraftLabel.${ac.category}`)}
</span>
</div>
<AircraftPhoto hex={ac.icao24} />
<table style={{ width: '100%', fontSize: 11, borderCollapse: 'collapse' }}>
<table className="w-full text-[11px] border-collapse">
<tbody>
<tr><td style={{ color: '#888', paddingRight: 8 }}>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.operator && <tr><td style={{ color: '#888' }}>Operator</td><td>{ac.operator}</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 className="text-kcg-muted">{t('aircraftPopup.reg')}</td><td><strong>{ac.registration}</strong></td></tr>}
{ac.operator && <tr><td className="text-kcg-muted">{t('aircraftPopup.operator')}</td><td>{ac.operator}</td></tr>}
{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>
)}
{ac.squawk && <tr><td style={{ color: '#888' }}>Squawk</td><td>{ac.squawk}</td></tr>}
<tr><td style={{ color: '#888' }}>Alt</td>
<td>{ac.onGround ? '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 style={{ color: '#888' }}>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>
{ac.squawk && <tr><td className="text-kcg-muted">{t('aircraftPopup.squawk')}</td><td>{ac.squawk}</td></tr>}
<tr><td className="text-kcg-muted">{t('aircraftPopup.alt')}</td>
<td>{ac.onGround ? t('aircraftPopup.ground') : `${Math.round(ac.altitude * 3.281).toLocaleString()} ft`}</td></tr>
<tr><td className="text-kcg-muted">{t('aircraftPopup.speed')}</td><td>{Math.round(ac.velocity * 1.944)} kts</td></tr>
<tr><td className="text-kcg-muted">{t('aircraftPopup.hdg')}</td><td>{Math.round(ac.heading)}&deg;</td></tr>
<tr><td className="text-kcg-muted">{t('aircraftPopup.verticalSpeed')}</td><td>{Math.round(ac.verticalRate * 196.85)} fpm</td></tr>
</tbody>
</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}`}
target="_blank" rel="noopener noreferrer" style={{ color: '#60a5fa' }}>
target="_blank" rel="noopener noreferrer" className="text-kcg-accent">
Airplanes.live &rarr;
</a>
</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 { useTranslation } from 'react-i18next';
import { Marker, Popup } from 'react-map-gl/maplibre';
import { COAST_GUARD_FACILITIES, CG_TYPE_LABEL } 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';
if (isVts) {
// VTS: 레이더/안테나 아이콘
return (
<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" />
{/* 안테나 */}
<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" />
{/* 전파 */}
<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" />
</svg>
);
}
// 해경 로고: 방패 + 앵커
return (
<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"
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" />
<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" />
<line x1="9" y1="13" x2="15" y2="13" stroke="#fff" strokeWidth="0.8" />
{/* 별 (본청/지방청) */}
{(type === 'hq' || type === 'regional') && (
<circle cx="12" cy="9" r="1" fill={color} />
)}
@ -60,6 +54,7 @@ function CoastGuardIcon({ type, size }: { type: CoastGuardType; size: number })
export function CoastGuardLayer() {
const [selected, setSelected] = useState<CoastGuardFacility | null>(null);
const { t } = useTranslation();
return (
<>
@ -69,25 +64,23 @@ export function CoastGuardLayer() {
<Marker key={f.id} longitude={f.lng} latitude={f.lat} anchor="center"
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(f); }}>
<div style={{
cursor: 'pointer', display: 'flex', flexDirection: 'column', alignItems: 'center',
cursor: 'pointer',
filter: `drop-shadow(0 0 3px ${TYPE_COLOR[f.type]}66)`,
}}>
}} className="flex flex-col items-center">
<CoastGuardIcon type={f.type} size={size} />
{(f.type === 'hq' || f.type === 'regional') && (
<div style={{
fontSize: 6, color: '#fff', marginTop: 1,
fontSize: 6,
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() || '본청'}
</div>
)}
{f.type === 'vts' && (
<div style={{
fontSize: 5, color: '#da77f2', marginTop: 0,
fontSize: 5,
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
</div>
)}
@ -100,27 +93,23 @@ export function CoastGuardLayer() {
<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 className="min-w-[200px] font-mono text-xs">
<div style={{
background: TYPE_COLOR[selected.type], 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,
}}>
background: TYPE_COLOR[selected.type],
}} 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">
{selected.name}
</div>
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
<div className="mb-1.5 flex flex-wrap gap-1">
<span style={{
background: TYPE_COLOR[selected.type], color: '#000',
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700,
}}>{CG_TYPE_LABEL[selected.type]}</span>
<span style={{
background: '#1a1a2e', color: '#4dabf7',
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700,
}}></span>
background: TYPE_COLOR[selected.type],
}} className="rounded-sm px-1.5 py-px text-[10px] font-bold text-black">
{CG_TYPE_LABEL[selected.type]}
</span>
<span className="rounded-sm bg-kcg-card px-1.5 py-px text-[10px] font-bold text-[#4dabf7]">
{t('coastGuard.agency')}
</span>
</div>
<div style={{ fontSize: 9, color: '#666' }}>
<div className="text-[9px] text-kcg-dim">
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
</div>
</div>

파일 보기

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

파일 보기

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

파일 보기

@ -98,7 +98,6 @@ export function GlobeMap({ events, currentTime, aircraft, satellites, ships, lay
map.addControl(new maplibregl.NavigationControl(), 'top-right');
// 한글 국가명 라벨
map.on('load', () => {
map.addSource('country-labels', { type: 'geojson', data: countryLabelsGeoJSON() });
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(() => {
const map = mapRef.current;
if (!map) return;
@ -227,6 +226,6 @@ export function GlobeMap({ events, currentTime, aircraft, satellites, ships, lay
}, [events, currentTime, aircraft, satellites, ships, layers]);
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 { useTranslation } from 'react-i18next';
import { Marker, Popup } from 'react-map-gl/maplibre';
import { KOREAN_AIRPORTS } from '../services/airports';
import type { KoreanAirport } from '../services/airports';
export function KoreaAirportLayer() {
const [selected, setSelected] = useState<KoreanAirport | null>(null);
const { t } = useTranslation();
return (
<>
@ -16,20 +18,18 @@ export function KoreaAirportLayer() {
<Marker key={ap.id} longitude={ap.lng} latitude={ap.lat} anchor="center"
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(ap); }}>
<div style={{
cursor: 'pointer', display: 'flex', flexDirection: 'column', alignItems: 'center',
cursor: 'pointer',
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">
<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"
fill={color} stroke="#fff" strokeWidth="0.3" />
</svg>
<div style={{
fontSize: 6, color: '#fff', marginTop: 1,
fontSize: 6,
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('공항', '')}
</div>
</div>
@ -41,35 +41,28 @@ export function KoreaAirportLayer() {
<Popup longitude={selected.lng} latitude={selected.lat}
onClose={() => setSelected(null)} closeOnClick={false}
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={{
background: selected.intl ? '#a78bfa' : '#7c8aaa', 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,
}}>
background: selected.intl ? '#a78bfa' : '#7c8aaa',
}} 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">
{selected.nameKo}
</div>
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
<div className="mb-1.5 flex flex-wrap gap-1">
{selected.intl && (
<span style={{
background: '#a78bfa', color: '#000',
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700,
}}></span>
<span className="rounded-sm bg-[#a78bfa] px-1.5 py-px text-[10px] font-bold text-black">
{t('airport.international')}
</span>
)}
{selected.domestic && (
<span style={{
background: '#7c8aaa', color: '#000',
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700,
}}></span>
<span className="rounded-sm bg-[#7c8aaa] px-1.5 py-px text-[10px] font-bold text-black">
{t('airport.domestic')}
</span>
)}
<span style={{
background: '#1a1a2e', color: '#888',
padding: '1px 6px', borderRadius: 3, fontSize: 10,
}}>{selected.id} / {selected.icao}</span>
<span className="rounded-sm bg-kcg-card px-1.5 py-px text-[10px] text-kcg-muted">
{selected.id} / {selected.icao}
</span>
</div>
<div style={{ fontSize: 9, color: '#666' }}>
<div className="text-[9px] text-kcg-dim">
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
</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 { useTranslation } from 'react-i18next';
interface Props {
currentTime: number;
@ -23,21 +24,22 @@ export function LiveControls({
historyMinutes,
onHistoryChange,
}: Props) {
const { t } = useTranslation();
const kstTime = format(new Date(currentTime + 9 * 3600_000), "yyyy-MM-dd HH:mm:ss 'KST'");
return (
<div className="live-controls">
<div className="live-indicator">
<span className="live-dot" />
<span className="live-label">LIVE</span>
<span className="live-label">{t('header.live')}</span>
</div>
<div className="live-clock">{kstTime}</div>
<div style={{ flex: 1 }} />
<div className="flex-1" />
<div className="history-controls">
<span className="history-label">HISTORY</span>
<span className="history-label">{t('time.history')}</span>
<div className="history-presets">
{HISTORY_PRESETS.map(p => (
<button

파일 보기

@ -1,4 +1,5 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Marker, Popup } from 'react-map-gl/maplibre';
import { NAV_WARNINGS, NW_LEVEL_LABEL, NW_ORG_LABEL } 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 (
<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" />
@ -43,6 +43,7 @@ function WarningIcon({ level, org, size }: { level: NavWarningLevel; org: Traini
export function NavWarningLayer() {
const [selected, setSelected] = useState<NavWarning | null>(null);
const { t } = useTranslation();
return (
<>
@ -53,15 +54,14 @@ export function NavWarningLayer() {
<Marker key={w.id} longitude={w.lng} latitude={w.lat} anchor="center"
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(w); }}>
<div style={{
cursor: 'pointer', display: 'flex', flexDirection: 'column', alignItems: 'center',
cursor: 'pointer',
filter: `drop-shadow(0 0 4px ${color}88)`,
}}>
}} className="flex flex-col items-center">
<WarningIcon level={w.level} org={w.org} size={size} />
<div style={{
fontSize: 5, color, marginTop: 0,
fontSize: 5, color,
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}
</div>
</div>
@ -73,48 +73,43 @@ export function NavWarningLayer() {
<Popup longitude={selected.lng} latitude={selected.lat}
onClose={() => setSelected(null)} closeOnClick={false}
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={{
background: ORG_COLOR[selected.org], color: '#fff',
padding: '4px 8px', borderRadius: '4px 4px 0 0',
margin: '-10px -10px 8px -10px',
fontWeight: 700, fontSize: 12,
display: 'flex', alignItems: 'center', gap: 6,
}}>
background: ORG_COLOR[selected.org],
}} 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">
{selected.title}
</div>
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
<div className="mb-1.5 flex flex-wrap gap-1">
<span style={{
background: LEVEL_COLOR[selected.level], color: '#fff',
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700,
}}>{NW_LEVEL_LABEL[selected.level]}</span>
background: LEVEL_COLOR[selected.level],
}} className="rounded-sm px-1.5 py-px text-[10px] font-bold text-white">
{NW_LEVEL_LABEL[selected.level]}
</span>
<span style={{
background: ORG_COLOR[selected.org] + '33', color: ORG_COLOR[selected.org],
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700,
background: ORG_COLOR[selected.org] + '33',
color: ORG_COLOR[selected.org],
border: `1px solid ${ORG_COLOR[selected.org]}44`,
}}>{NW_ORG_LABEL[selected.org]}</span>
<span style={{
background: '#1a1a2e', color: '#888',
padding: '1px 6px', borderRadius: 3, fontSize: 10,
}}>{selected.area}</span>
}} className="rounded-sm px-1.5 py-px text-[10px] font-bold">
{NW_ORG_LABEL[selected.org]}
</span>
<span className="rounded-sm bg-kcg-card px-1.5 py-px text-[10px] text-kcg-muted">
{selected.area}
</span>
</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}
</div>
<div style={{ fontSize: 9, color: '#666', display: 'flex', flexDirection: 'column', gap: 2 }}>
<div>: {selected.altitude}</div>
<div className="flex flex-col gap-0.5 text-[9px] text-kcg-dim">
<div>{t('navWarning.altitude')}: {selected.altitude}</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>
<a
href="https://www.khoa.go.kr/nwb/mainPage.do?lang=ko"
target="_blank"
rel="noopener noreferrer"
style={{
display: 'block', marginTop: 6,
fontSize: 10, color: '#3b82f6', textDecoration: 'underline',
}}
>KHOA </a>
className="mt-1.5 block text-[10px] text-kcg-accent underline"
>{t('navWarning.khoaLink')}</a>
</div>
</Popup>
)}

파일 보기

@ -1,5 +1,6 @@
import { memo, useState } from 'react';
import { Marker, Popup } from 'react-map-gl/maplibre';
import { useTranslation } from 'react-i18next';
import type { OilFacility, OilFacilityType } from '../types';
interface Props {
@ -12,11 +13,6 @@ const TYPE_COLORS: Record<OilFacilityType, string> = {
terminal: '#ec4899', petrochemical: '#8b5cf6', desalination: '#06b6d4',
};
const TYPE_LABELS: Record<OilFacilityType, string> = {
refinery: '정유소', oilfield: '유전', gasfield: '가스전',
terminal: '수출터미널', petrochemical: '석유화학', desalination: '담수화시설',
};
function formatNumber(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
@ -54,11 +50,9 @@ function DamageOverlay() {
// SVG icon renderers (JSX versions)
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;
return (
<svg viewBox="0 0 36 36" width={size} height={size}>
{/* Gradient circle background */}
<defs>
<linearGradient id={`refGrad-${damaged ? 'd' : 'n'}`} x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor={color} stopOpacity={0.5} />
@ -67,23 +61,16 @@ function RefineryIcon({ size, color, damaged }: { size: number; color: string; d
</defs>
<circle cx={18} cy={18} r={17} fill={`url(#refGrad-${damaged ? 'd' : 'n'})`}
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} />
{/* Tall chimney/tower (center) */}
<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} />
{/* Medium tower (right) */}
<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={18} cy={5} r={2} 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={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)" />
{/* Pipe details */}
<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} />
{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 }) {
// Oil pumpjack (nodding donkey) icon — transparent style
const sc = damaged ? '#ff0000' : color;
return (
<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} />
{/* 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={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} />
{/* Walking beam (horizontal arm) */}
<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} />
{/* 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} />
{/* 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} />
{/* 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} />
{/* Crank arm + pitman arm */}
<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} />
{/* 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} />
{/* 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"
fill={color} opacity={0.85} />
{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 }) {
// Spherical gas storage tank with support legs (transparent style)
return (
<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={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={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={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} />
{/* Spherical tank body */}
<ellipse cx={18} cy={16} rx={12} ry={10} fill={color} opacity={0.45}
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} />
{/* Equator band */}
<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} />
<line x1={18} y1={4} x2={18} y2={6} stroke={color} strokeWidth={1.5} opacity={0.7} />
{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 }) {
// Water drop + faucet + filter container — desalination plant (transparent)
const sc = damaged ? '#ff0000' : color;
return (
<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"
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"
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} />
<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"
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={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}
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={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} />
{/* Output pipe from container */}
<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} />
{damaged && <DamageOverlay />}
</svg>
@ -247,6 +203,7 @@ export const OilFacilityLayer = memo(function OilFacilityLayer({ facilities, cur
});
function FacilityMarker({ facility, currentTime }: { facility: OilFacility; currentTime: number }) {
const { t } = useTranslation('ships');
const [showPopup, setShowPopup] = useState(false);
const color = TYPE_COLORS[facility.type];
const isDamaged = !!(facility.damaged && facility.damagedAt && currentTime >= facility.damagedAt);
@ -256,33 +213,32 @@ function FacilityMarker({ facility, currentTime }: { facility: OilFacility; curr
return (
<>
<Marker longitude={facility.lng} latitude={facility.lat} anchor="center">
<div style={{ position: 'relative' }}>
<div className="relative">
{/* Planned strike targeting ring */}
{isPlanned && (
<div style={{
position: 'absolute', top: '50%', left: '50%',
transform: 'translate(-50%, -50%)',
width: 36, height: 36, borderRadius: '50%',
<div
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"
style={{
border: '2px dashed #ff6600',
animation: 'planned-pulse 2s ease-in-out infinite',
pointerEvents: 'none',
}}>
}}
>
{/* Crosshair lines */}
<div style={{ position: 'absolute', top: -6, left: '50%', transform: 'translateX(-50%)', width: 1, height: 6, background: '#ff6600', opacity: 0.7 }} />
<div style={{ position: 'absolute', bottom: -6, left: '50%', transform: 'translateX(-50%)', width: 1, height: 6, background: '#ff6600', opacity: 0.7 }} />
<div style={{ position: 'absolute', left: -6, top: '50%', transform: 'translateY(-50%)', width: 6, height: 1, background: '#ff6600', opacity: 0.7 }} />
<div style={{ position: 'absolute', right: -6, top: '50%', transform: 'translateY(-50%)', width: 6, height: 1, 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 className="absolute -bottom-1.5 left-1/2 -translate-x-1/2 w-px h-1.5 bg-[#ff6600] opacity-70" />
<div className="absolute -left-1.5 top-1/2 -translate-y-1/2 w-1.5 h-px bg-[#ff6600] opacity-70" />
<div className="absolute -right-1.5 top-1/2 -translate-y-1/2 w-1.5 h-px bg-[#ff6600] opacity-70" />
</div>
)}
<div style={{ cursor: 'pointer' }}
<div className="cursor-pointer"
onClick={(e) => { e.stopPropagation(); setShowPopup(true); }}>
<FacilityIconSvg facility={facility} damaged={isDamaged} />
</div>
<div className="gl-marker-label" style={{
color: isDamaged ? '#ff0000' : isPlanned ? '#ff6600' : color, fontSize: 8,
<div className="gl-marker-label text-[8px]" style={{
color: isDamaged ? '#ff0000' : isPlanned ? '#ff6600' : color,
}}>
{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>
</Marker>
@ -290,69 +246,60 @@ function FacilityMarker({ facility, currentTime }: { facility: OilFacility; curr
<Popup longitude={facility.lng} latitude={facility.lat}
onClose={() => setShowPopup(false)} closeOnClick={false}
anchor="bottom" maxWidth="280px" className="gl-popup">
<div style={{ minWidth: 220, fontFamily: 'monospace', fontSize: 12 }}>
<div style={{ display: 'flex', gap: 4, alignItems: 'center', marginBottom: 6 }}>
<span style={{
background: color, color: '#fff', padding: '2px 6px',
borderRadius: 3, fontSize: 10, fontWeight: 700,
}}>{TYPE_LABELS[facility.type]}</span>
<div className="min-w-[220px] font-mono text-xs">
<div className="flex gap-1 items-center mb-1.5">
<span
className="px-1.5 py-0.5 rounded text-[10px] font-bold text-white"
style={{ background: color }}
>{t(`facility.type.${facility.type}`)}</span>
{isDamaged && (
<span style={{
background: '#ff0000', color: '#fff', padding: '2px 6px',
borderRadius: 3, fontSize: 10, fontWeight: 700,
}}></span>
<span className="px-1.5 py-0.5 rounded text-[10px] font-bold text-white bg-[#ff0000]">
{t('facility.damaged')}
</span>
)}
{isPlanned && (
<span style={{
background: '#ff6600', color: '#fff', padding: '2px 6px',
borderRadius: 3, fontSize: 10, fontWeight: 700,
}}> </span>
<span className="px-1.5 py-0.5 rounded text-[10px] font-bold text-white bg-[#ff6600]">
{t('facility.plannedStrike')}
</span>
)}
</div>
<div style={{ fontWeight: 700, fontSize: 13, margin: '4px 0' }}>{facility.nameKo}</div>
<div style={{ fontSize: 10, color: '#888', marginBottom: 6 }}>{facility.name}</div>
<div style={{
background: 'rgba(0,0,0,0.3)', borderRadius: 4, padding: '6px 8px',
display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 12px', fontSize: 11,
}}>
<div className="font-bold text-[13px] my-1">{facility.nameKo}</div>
<div className="text-[10px] text-kcg-muted mb-1.5">{facility.name}</div>
<div className="bg-black/30 rounded p-1.5 grid grid-cols-2 gap-x-3 gap-y-1 text-[11px]">
{facility.capacityBpd != null && (
<><span style={{ color: '#888' }}>/</span>
<span style={{ color: '#fff', fontWeight: 600 }}>{formatNumber(facility.capacityBpd)} bpd</span></>
<><span className="text-kcg-muted">{t('facility.production')}</span>
<span className="text-white font-semibold">{formatNumber(facility.capacityBpd)} bpd</span></>
)}
{facility.capacityMgd != null && (
<><span style={{ color: '#888' }}></span>
<span style={{ color: '#fff', fontWeight: 600 }}>{formatNumber(facility.capacityMgd)} MGD</span></>
<><span className="text-kcg-muted">{t('facility.desalProduction')}</span>
<span className="text-white font-semibold">{formatNumber(facility.capacityMgd)} MGD</span></>
)}
{facility.capacityMcfd != null && (
<><span style={{ color: '#888' }}></span>
<span style={{ color: '#fff', fontWeight: 600 }}>{formatNumber(facility.capacityMcfd)} Mcf/d</span></>
<><span className="text-kcg-muted">{t('facility.gasProduction')}</span>
<span className="text-white font-semibold">{formatNumber(facility.capacityMcfd)} Mcf/d</span></>
)}
{facility.reservesBbl != null && (
<><span style={{ color: '#888' }}>()</span>
<span style={{ color: '#fff', fontWeight: 600 }}>{facility.reservesBbl}B </span></>
<><span className="text-kcg-muted">{t('facility.reserveOil')}</span>
<span className="text-white font-semibold">{facility.reservesBbl}B {t('facility.barrels')}</span></>
)}
{facility.reservesTcf != null && (
<><span style={{ color: '#888' }}>()</span>
<span style={{ color: '#fff', fontWeight: 600 }}>{facility.reservesTcf} Tcf</span></>
<><span className="text-kcg-muted">{t('facility.reserveGas')}</span>
<span className="text-white font-semibold">{facility.reservesTcf} Tcf</span></>
)}
{facility.operator && (
<><span style={{ color: '#888' }}></span>
<span style={{ color: '#fff' }}>{facility.operator}</span></>
<><span className="text-kcg-muted">{t('facility.operator')}</span>
<span className="text-white">{facility.operator}</span></>
)}
</div>
{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 && (
<div style={{
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,
}}>
<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]">
{facility.plannedLabel}
</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
</div>
</div>

파일 보기

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

파일 보기

@ -1,4 +1,5 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Marker, Popup } from 'react-map-gl/maplibre';
import { PIRACY_ZONES, PIRACY_LEVEL_COLOR, PIRACY_LEVEL_LABEL } 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 }) {
return (
<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" />
{/* eyes */}
<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" />
{/* nose */}
<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" />
{/* crossbones */}
<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" />
</svg>
@ -24,6 +20,7 @@ function SkullIcon({ color, size }: { color: string; size: number }) {
export function PiracyLayer() {
const [selected, setSelected] = useState<PiracyZone | null>(null);
const { t } = useTranslation();
return (
<>
@ -34,17 +31,15 @@ export function PiracyLayer() {
<Marker key={zone.id} longitude={zone.lng} latitude={zone.lat} anchor="center"
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(zone); }}>
<div style={{
cursor: 'pointer', display: 'flex', flexDirection: 'column', alignItems: 'center',
cursor: 'pointer',
filter: `drop-shadow(0 0 8px ${color}aa)`,
animation: zone.level === 'critical' ? 'pulse 2s ease-in-out infinite' : undefined,
}}>
}} className="flex flex-col items-center">
<SkullIcon color={color} size={size} />
<div style={{
fontSize: 7, color, marginTop: 1,
fontSize: 7, color,
textShadow: `0 0 3px ${color}, 0 0 2px #000`,
whiteSpace: 'nowrap', fontWeight: 700, letterSpacing: 0.3,
fontFamily: 'monospace',
}}>
}} className="mt-px whitespace-nowrap font-mono font-bold tracking-wide">
{PIRACY_LEVEL_LABEL[zone.level]}
</div>
</div>
@ -56,43 +51,40 @@ export function PiracyLayer() {
<Popup longitude={selected.lng} latitude={selected.lat}
onClose={() => setSelected(null)} closeOnClick={false}
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={{
background: PIRACY_LEVEL_COLOR[selected.level], color: '#fff',
padding: '5px 10px', borderRadius: '4px 4px 0 0',
margin: '-10px -10px 8px -10px',
fontWeight: 700, fontSize: 12,
display: 'flex', alignItems: 'center', gap: 6,
}}>
<span style={{ fontSize: 14 }}></span>
background: PIRACY_LEVEL_COLOR[selected.level],
}} 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">
<span className="text-sm"></span>
{selected.nameKo}
</div>
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
<div className="mb-1.5 flex flex-wrap gap-1">
<span style={{
background: PIRACY_LEVEL_COLOR[selected.level], color: '#fff',
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700,
}}>{PIRACY_LEVEL_LABEL[selected.level]}</span>
<span style={{
background: '#1a1a2e', color: '#888',
padding: '1px 6px', borderRadius: 3, fontSize: 10,
}}>{selected.name}</span>
background: PIRACY_LEVEL_COLOR[selected.level],
}} className="rounded-sm px-1.5 py-px text-[10px] font-bold text-white">
{PIRACY_LEVEL_LABEL[selected.level]}
</span>
<span className="rounded-sm bg-kcg-card px-1.5 py-px text-[10px] text-kcg-muted">
{selected.name}
</span>
{selected.recentIncidents != null && (
<span style={{
background: '#1a1a2e', color: PIRACY_LEVEL_COLOR[selected.level],
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700,
color: PIRACY_LEVEL_COLOR[selected.level],
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 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}
</div>
<div style={{ fontSize: 10, color: '#999', lineHeight: 1.4 }}>
<div className="text-[10px] leading-snug text-[#999]">
{selected.detail}
</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
</div>
</div>

파일 보기

@ -1,4 +1,5 @@
import { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
interface Props {
isPlaying: boolean;
@ -51,6 +52,7 @@ export function ReplayControls({
onSpeedChange,
onRangeChange,
}: Props) {
const { t } = useTranslation();
const [showPicker, setShowPicker] = useState(false);
const [customStart, setCustomStart] = useState(toKSTInput(startTime));
const [customEnd, setCustomEnd] = useState(toKSTInput(endTime));
@ -76,7 +78,7 @@ export function ReplayControls({
return (
<div className="replay-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">
<path d="M1 4v6h6" />
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
@ -109,7 +111,7 @@ export function ReplayControls({
</div>
{/* Spacer */}
<div style={{ flex: 1 }} />
<div className="flex-1" />
{/* Right: range presets + custom picker */}
<div className="range-controls">
@ -126,7 +128,7 @@ export function ReplayControls({
<button
className={`range-btn custom-btn ${showPicker ? 'active' : ''}`}
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">
<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-row">
<label>
<span>FROM (KST)</span>
<span>{t('controls.from')}</span>
<input
type="datetime-local"
value={customStart}
@ -149,7 +151,7 @@ export function ReplayControls({
/>
</label>
<label>
<span>TO (KST)</span>
<span>{t('controls.to')}</span>
<input
type="datetime-local"
value={customEnd}
@ -157,7 +159,7 @@ export function ReplayControls({
/>
</label>
<button className="range-apply-btn" onClick={handleCustomApply}>
APPLY
{t('controls.apply')}
</button>
</div>
</div>

파일 보기

@ -1,4 +1,5 @@
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 type { MapRef } from 'react-map-gl/maplibre';
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) {
const { t } = useTranslation();
const mapRef = useRef<MapRef>(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" />
{/* 한글 국가명 라벨 */}
<Source id="country-labels" type="geojson" data={countryLabelsGeoJSON()}>
<Layer
id="country-label-lg"
@ -266,9 +267,9 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
const size = EVENT_RADIUS[event.type] * 5;
return (
<Marker key={`pulse-${event.id}`} longitude={event.lng} latitude={event.lat} anchor="center">
<div className="gl-pulse-ring" style={{
width: size, height: size, borderRadius: '50%',
border: `2px solid ${color}`, pointerEvents: 'none',
<div className="gl-pulse-ring rounded-full pointer-events-none" style={{
width: size, height: size,
border: `2px solid ${color}`,
}} />
</Marker>
);
@ -279,9 +280,9 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
const size = event.type === 'impact' ? 100 : 70;
return (
<Marker key={`shock-${event.id}`} longitude={event.lng} latitude={event.lat} anchor="center">
<div className="gl-shockwave" style={{
width: size, height: size, borderRadius: '50%',
border: `3px solid ${color}`, pointerEvents: 'none',
<div className="gl-shockwave rounded-full pointer-events-none" style={{
width: size, height: size,
border: `3px solid ${color}`,
}} />
</Marker>
);
@ -292,9 +293,9 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
const size = event.type === 'impact' ? 40 : 30;
return (
<Marker key={`flash-${event.id}`} longitude={event.lng} latitude={event.lat} anchor="center">
<div className="gl-strike-flash" style={{
width: size, height: size, borderRadius: '50%',
background: color, opacity: 0.6, pointerEvents: 'none',
<div className="gl-strike-flash rounded-full opacity-60 pointer-events-none" style={{
width: size, height: size,
background: color,
}} />
</Marker>
);
@ -304,11 +305,10 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
const ageMs = currentTime - event.timestamp;
const ageHours = ageMs / 3600_000;
const DAY_H = 24;
// 최근 1일 이내: 진하게 (opacity 0.85~1.0), 그 이후: 흐리게 (0.15~0.4)
const isRecent = ageHours <= DAY_H;
const opacity = isRecent
? Math.max(0.85, 1 - ageHours * 0.006) // 1일 내: 1.0→0.85
: Math.max(0.15, 0.4 - (ageHours - DAY_H) * 0.005); // 1일 후: 0.4→0.15
? Math.max(0.85, 1 - ageHours * 0.006)
: Math.max(0.15, 0.4 - (ageHours - DAY_H) * 0.005);
const color = getEventColor(event);
const isNew = ageMs >= 0 && ageMs < 600_000;
const baseR = EVENT_RADIUS[event.type];
@ -318,8 +318,7 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
return (
<Marker key={event.id} longitude={event.lng} latitude={event.lat} anchor="center">
<div
className={isNew ? 'gl-event-flash' : undefined}
style={{ cursor: 'pointer' }}
className={`cursor-pointer ${isNew ? 'gl-event-flash' : ''}`}
onClick={(e) => { e.stopPropagation(); setSelectedEventId(event.id); }}
>
<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 ageHours = ageMs / 3600_000;
const isRecent = ageHours <= 24;
// 최근 1일: 진하게, 이후: 흐리게
const impactOpacity = isRecent
? Math.max(0.8, 1 - ageHours * 0.008)
: 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;
return (
<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); }}>
<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} />
@ -378,47 +376,43 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
maxWidth="320px"
className="gl-popup"
>
<div style={{ minWidth: 200, maxWidth: 320 }}>
<div className="min-w-[200px] max-w-[320px]">
{selectedEvent.source && (
<span style={{
background: getEventColor(selectedEvent), color: '#fff', padding: '1px 6px',
borderRadius: 3, fontSize: 10, fontWeight: 700, marginBottom: 4, display: 'inline-block',
}}>
{{ US: '미국', IL: '이스라엘', IR: '이란', proxy: '대리세력' }[selectedEvent.source]}
<span
className="inline-block rounded-sm text-[10px] font-bold text-white mb-1 px-1.5 py-px"
style={{ background: getEventColor(selectedEvent) }}
>
{t(`source.${selectedEvent.source}`)}
</span>
)}
{selectedEvent.type === 'impact' && (
<div style={{
background: '#ff0000', color: '#fff', padding: '3px 8px',
borderRadius: 3, fontSize: 11, fontWeight: 700, marginBottom: 6,
display: 'inline-block',
}}>
IMPACT SITE
<div className="inline-block rounded-sm text-[11px] font-bold text-white mb-1.5 px-2 py-[3px] bg-kcg-event-impact">
{t('popup.impactSite')}
</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
</span>
{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 && (
<div style={{ marginTop: 8 }}>
<div className="mt-2">
<img
src={selectedEvent.imageUrl}
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'; }}
/>
{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>
)}
{selectedEvent.type === 'impact' && (
<div style={{ fontSize: 10, color: '#888', marginTop: 6 }}>
{selectedEvent.lat.toFixed(4)}°N, {selectedEvent.lng.toFixed(4)}°E
<div className="text-[10px] text-kcg-muted mt-1.5">
{selectedEvent.lat.toFixed(4)}&deg;N, {selectedEvent.lng.toFixed(4)}&deg;E
</div>
)}
</div>

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -1,5 +1,6 @@
import { memo, useMemo, useState, useEffect } from 'react';
import { Marker, Popup, Source, Layer, useMap } from 'react-map-gl/maplibre';
import { useTranslation } from 'react-i18next';
import type { Ship, ShipCategory } from '../types';
import maplibregl from 'maplibre-gl';
@ -9,17 +10,30 @@ interface Props {
koreanOnly?: boolean;
}
// ── MarineTraffic-style vessel type colors ──
// ── MarineTraffic-style vessel type colors (CSS variable references) ──
const MT_TYPE_COLORS: Record<string, string> = {
cargo: '#f0a830', // orange-yellow
tanker: '#e74c3c', // red
passenger: '#4caf50', // green
fishing: '#42a5f5', // light blue
pleasure: '#e91e8c', // pink/magenta
military: '#d32f2f', // dark red
tug_special: '#2e7d32', // dark green
other: '#5c6bc0', // indigo/blue
unknown: '#9e9e9e', // grey
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)',
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
@ -63,21 +77,6 @@ const NAVY_COLORS: Record<string, string> = {
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> = {
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}',
@ -104,6 +103,10 @@ function getShipColor(ship: Ship): string {
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 ──
const LOCAL_SHIP_PHOTOS: Record<string, string> = {
'440034000': '/ships/440034000.jpg',
@ -126,34 +129,96 @@ const LOCAL_SHIP_PHOTOS: Record<string, string> = {
interface VesselPhotoData { url: string; }
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 [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;
});
useEffect(() => {
if (localUrl) return;
if (photo !== undefined) return;
if (activeTab !== 'marinetraffic') return;
if (mtPhoto !== undefined) return;
const imgUrl = `https://photos.marinetraffic.com/ais/showphoto.aspx?mmsi=${mmsi}&size=thumb300`;
const img = new Image();
img.onload = () => { const result = { url: imgUrl }; vesselPhotoCache.set(mmsi, result); setPhoto(result); };
img.onerror = () => { vesselPhotoCache.set(mmsi, null); setPhoto(null); };
img.onload = () => { const result = { url: imgUrl }; vesselPhotoCache.set(mmsi, result); setMtPhoto(result); };
img.onerror = () => { vesselPhotoCache.set(mmsi, null); setMtPhoto(null); };
img.src = imgUrl;
}, [mmsi, photo, localUrl]);
}, [mmsi, activeTab, mtPhoto]);
if (!photo) return null;
// 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 style={{ marginBottom: 6 }}>
<img src={photo.url} alt="Vessel"
style={{ width: '100%', borderRadius: 4, display: 'block' }}
<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>
);
}
return (
<div className="mb-1.5">
<div className="flex gap-0.5 mb-1 rounded bg-kcg-bg p-0.5">
{hasSignalBatch && (
<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>
);
}
function formatCoord(lat: number, lng: number): string {
const latDir = lat >= 0 ? 'N' : 'S';
const lngDir = lng >= 0 ? 'E' : 'W';
@ -215,7 +280,7 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly }: Props) {
type: 'Feature' as const,
properties: {
mmsi: ship.mmsi,
color: getShipColor(ship),
color: getShipHex(ship),
size: SIZE_MAP[ship.category],
isMil: isMilitary(ship.category) ? 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 { t } = useTranslation('ships');
const mtType = getMTType(ship);
const color = MT_TYPE_COLORS[mtType] || MT_TYPE_COLORS.unknown;
const isMil = isMilitary(ship.category);
const navyLabel = isMil && ship.flag && FLAG_LABELS[ship.flag] ? FLAG_LABELS[ship.flag] : undefined;
const navyAccent = isMil && ship.flag && NAVY_COLORS[ship.flag] ? NAVY_COLORS[ship.flag] : color;
const navyLabel = isMil && ship.flag ? t(`navyLabel.${ship.flag}`, { defaultValue: '' }) : undefined;
const navyAccent = isMil && ship.flag && NAVY_COLORS[ship.flag] ? NAVY_COLORS[ship.flag] : undefined;
const flagEmoji = ship.flag ? FLAG_EMOJI[ship.flag] || '' : '';
return (
<Popup longitude={ship.lng} latitude={ship.lat}
onClose={onClose} closeOnClick={false}
anchor="bottom" maxWidth="340px" className="gl-popup">
<div style={{ minWidth: 280, maxWidth: 340, fontFamily: 'monospace', fontSize: 12 }}>
<div style={{
background: isMil ? '#1a1a2e' : '#1565c0', color: '#fff',
padding: '6px 10px', borderRadius: '4px 4px 0 0',
margin: '-10px -10px 8px -10px',
display: 'flex', alignItems: 'center', gap: 8,
}}>
{flagEmoji && <span style={{ fontSize: 16 }}>{flagEmoji}</span>}
<strong style={{ fontSize: 13, flex: 1 }}>{ship.name}</strong>
<div className="min-w-[280px] max-w-[340px] font-mono text-xs">
<div
className="flex items-center gap-2 px-2.5 py-1.5 rounded-t text-white -mx-2.5 -mt-2.5 mb-2"
style={{ background: isMil ? 'var(--kcg-card)' : '#1565c0' }}
>
{flagEmoji && <span className="text-base">{flagEmoji}</span>}
<strong className="text-[13px] flex-1">{ship.name}</strong>
{navyLabel && (
<span style={{
background: navyAccent, color: '#000', padding: '1px 6px',
borderRadius: 3, fontSize: 10, fontWeight: 700,
}}>{navyLabel}</span>
<span
className="px-1.5 py-px rounded text-[10px] font-bold text-black"
style={{ background: navyAccent || color }}
>{navyLabel}</span>
)}
</div>
<VesselPhoto mmsi={ship.mmsi} />
<div style={{
display: 'flex', gap: 4, marginBottom: 6,
borderBottom: '1px solid #ddd', paddingBottom: 4,
}}>
<span style={{
background: color, color: '#fff', padding: '1px 6px',
borderRadius: 3, fontSize: 10, fontWeight: 700,
}}>{MT_TYPE_LABELS[mtType] || 'Unknown'}</span>
<span style={{
background: '#333', color: '#ccc', padding: '1px 6px',
borderRadius: 3, fontSize: 10,
}}>{CATEGORY_LABELS[ship.category]}</span>
<VesselPhoto mmsi={ship.mmsi} imo={ship.imo} shipImagePath={ship.shipImagePath} />
<div className="flex gap-1 mb-1.5 border-b border-kcg-border-light pb-1">
<span
className="px-1.5 py-px rounded text-[10px] font-bold text-white"
style={{ background: color }}
>{t(`mtTypeLabel.${mtType}`, { defaultValue: 'Unknown' })}</span>
<span className="px-1.5 py-px rounded text-[10px] bg-kcg-border text-kcg-text-secondary">
{t(`categoryLabel.${ship.category}`)}
</span>
{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 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><span style={{ color: '#888' }}>MMSI : </span>{ship.mmsi}</div>
{ship.callSign && <div><span style={{ color: '#888' }}>Call Sign : </span>{ship.callSign}</div>}
{ship.imo && <div><span style={{ color: '#888' }}>IMO : </span>{ship.imo}</div>}
{ship.status && <div><span style={{ color: '#888' }}>Status : </span>{ship.status}</div>}
{ship.length && <div><span style={{ color: '#888' }}>Length : </span>{ship.length}m</div>}
{ship.width && <div><span style={{ color: '#888' }}>Width : </span>{ship.width}m</div>}
{ship.draught && <div><span style={{ color: '#888' }}>Draught : </span>{ship.draught}m</div>}
<div><span className="text-kcg-muted">{t('popup.mmsi')} : </span>{ship.mmsi}</div>
{ship.callSign && <div><span className="text-kcg-muted">{t('popup.callSign')} : </span>{ship.callSign}</div>}
{ship.imo && <div><span className="text-kcg-muted">{t('popup.imo')} : </span>{ship.imo}</div>}
{ship.status && <div><span className="text-kcg-muted">{t('popup.status')} : </span>{ship.status}</div>}
{ship.length && <div><span className="text-kcg-muted">{t('popup.length')} : </span>{ship.length}m</div>}
{ship.width && <div><span className="text-kcg-muted">{t('popup.width')} : </span>{ship.width}m</div>}
{ship.draught && <div><span className="text-kcg-muted">{t('popup.draught')} : </span>{ship.draught}m</div>}
</div>
<div>
<div><span style={{ color: '#888' }}>Heading : </span>{ship.heading.toFixed(1)}&deg;</div>
<div><span style={{ color: '#888' }}>Course : </span>{ship.course.toFixed(1)}&deg;</div>
<div><span style={{ color: '#888' }}>Speed : </span>{ship.speed.toFixed(1)} kn</div>
<div><span style={{ color: '#888' }}>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>
{ship.destination && <div><span style={{ color: '#888' }}>Dest : </span>{ship.destination}</div>}
{ship.eta && <div><span style={{ color: '#888' }}>ETA : </span>{new Date(ship.eta).toLocaleString()}</div>}
<div><span className="text-kcg-muted">{t('popup.heading')} : </span>{ship.heading.toFixed(1)}&deg;</div>
<div><span className="text-kcg-muted">{t('popup.course')} : </span>{ship.course.toFixed(1)}&deg;</div>
<div><span className="text-kcg-muted">{t('popup.speed')} : </span>{ship.speed.toFixed(1)} kn</div>
<div><span className="text-kcg-muted">{t('popup.lat')} : </span>{formatCoord(ship.lat, 0).split(',')[0]}</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 className="text-kcg-muted">{t('popup.destination')} : </span>{ship.destination}</div>}
{ship.eta && <div><span className="text-kcg-muted">{t('popup.eta')} : </span>{new Date(ship.eta).toLocaleString()}</div>}
</div>
</div>
<div style={{ marginTop: 6, fontSize: 9, color: '#999', textAlign: 'right' }}>
Last Update : {new Date(ship.lastSeen).toLocaleString()}
<div className="mt-1.5 text-[9px] text-[#999] text-right">
{t('popup.lastUpdate')} : {new Date(ship.lastSeen).toLocaleString()}
</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}`}
target="_blank" rel="noopener noreferrer" style={{ color: '#60a5fa' }}>
target="_blank" rel="noopener noreferrer" className="text-kcg-accent">
MarineTraffic &rarr;
</a>
</div>

파일 보기

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

파일 보기

@ -1,7 +1,7 @@
import type { Aircraft, AircraftCategory } from '../types';
// 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
const MILITARY_TYPES: Record<string, AircraftCategory> = {
@ -83,6 +83,7 @@ export async function fetchMilitaryAircraft(): Promise<Aircraft[]> {
// Airplanes.live military endpoint - Middle East area
const url = `${ADSBX_BASE}/mil`;
const res = await fetch(url);
if (res.status === 429) return [];
if (!res.ok) throw new Error(`Airplanes.live ${res.status}`);
const data = await res.json();
@ -101,6 +102,7 @@ export async function fetchMilitaryAircraftKorea(): Promise<Aircraft[]> {
try {
const url = `${ADSBX_BASE}/mil`;
const res = await fetch(url);
if (res.status === 429) return [];
if (!res.ok) throw new Error(`Airplanes.live mil ${res.status}`);
const data = await res.json();
return parseAirplanesLive(data).filter(
@ -133,7 +135,7 @@ async function doKrInitialLoad(): Promise<void> {
console.log('Airplanes.live Korea: initial load...');
for (let i = 0; i < KR_QUERIES.length; i++) {
try {
if (i > 0) await delay(800);
if (i > 0) await delay(1500);
const ac = await fetchOneRegion(KR_QUERIES[i]);
krLiveCache.set(`kr-${i}`, { ac, ts: Date.now() });
} catch { /* skip */ }
@ -216,6 +218,12 @@ function delay(ms: number) {
async function fetchOneRegion(q: { lat: number; lon: number; radius: number }): Promise<Aircraft[]> {
const url = `${ADSBX_BASE}/point/${q.lat}/${q.lon}/${q.radius}`;
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}`);
const data = await res.json();
return parseAirplanesLive(data);
@ -226,7 +234,7 @@ async function doInitialLoad(): Promise<void> {
console.log('Airplanes.live: initial load — fetching 10 regions in background...');
for (let i = 0; i < LIVE_QUERIES.length; i++) {
try {
if (i > 0) await delay(800);
if (i > 0) await delay(1500);
const ac = await fetchOneRegion(LIVE_QUERIES[i]);
liveCache.set(`${i}`, { ac, ts: Date.now() });
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)
for (const { group, category } of CELESTRAK_GROUPS) {
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);
if (!res.ok) {
console.warn(`CelesTrak ${group}: ${res.status}`);
@ -181,7 +181,7 @@ export async function fetchSatelliteTLEKorea(): Promise<Satellite[]> {
for (const { group, category } of CELESTRAK_GROUPS) {
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);
if (!res.ok) continue;
const text = await res.text();

파일 보기

@ -1,7 +1,7 @@
import type { Aircraft, AircraftCategory } from '../types';
// 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)
const ME_BOUNDS = {
@ -75,13 +75,30 @@ function parseOpenSkyResponse(data: { time: number; states: unknown[][] | null }
});
}
export async function fetchAircraftOpenSky(): Promise<Aircraft[]> {
try {
const url = `${OPENSKY_BASE}/states/all?lamin=${ME_BOUNDS.lamin}&lomin=${ME_BOUNDS.lomin}&lamax=${ME_BOUNDS.lamax}&lomax=${ME_BOUNDS.lomax}`;
// 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[]> {
try {
const url = `${OPENSKY_BASE}/states/all?lamin=${ME_BOUNDS.lamin}&lomin=${ME_BOUNDS.lomin}&lamax=${ME_BOUNDS.lamax}&lomax=${ME_BOUNDS.lomax}`;
return await throttledOpenSkyFetch(url);
} catch (err) {
console.warn('OpenSky fetch failed, using sample data:', err);
return getSampleAircraft();
@ -94,10 +111,7 @@ const KR_BOUNDS = { lamin: 20, lamax: 45, lomin: 115, lomax: 145 };
export async function fetchAircraftOpenSkyKorea(): Promise<Aircraft[]> {
try {
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);
if (!res.ok) throw new Error(`OpenSky Korea ${res.status}`);
const data = await res.json();
return parseOpenSkyResponse(data);
return await throttledOpenSkyFetch(url);
} catch (err) {
console.warn('OpenSky Korea fetch failed:', err);
return [];

파일 보기

@ -333,7 +333,7 @@ async function fetchXCentcom(): Promise<OsintItem[]> {
for (const url of rssUrls) {
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;
const text = await res.text();
const parser = new DOMParser();

파일 보기

@ -26,8 +26,8 @@ const KR_BOUNDS = {
maxLng: 145,
};
// How far back to look for vessel positions (seconds)
const SINCE_SECONDS = 3600; // last 1 hour
// S&P API sinceSeconds — currently disabled, will be removed with S&P code
// const SINCE_SECONDS = 3600;
// MMSI country prefix → flag code (MID = Maritime Identification Digits)
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
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'],
[/\bSSN\b|\bSS\b.*SUBMARINE/i, 'submarine'],
[/\bCG\b|CRUISER|TICONDEROGA/i, 'warship'],
@ -277,29 +277,10 @@ function parseAISTarget(t: SPGAISTarget): Ship {
// ═══ Primary API: GetTargetsInAreaEnhanced ═══
// Returns vessels within a bounding box updated in the last N seconds
export async function fetchShipsFromSPG(): Promise<Ship[]> {
if (!SPG_USERNAME || !SPG_PASSWORD) {
console.warn('VITE_SPG_USERNAME / VITE_SPG_PASSWORD not set, using sample data');
// TODO: signal-batch 전환 후 제거 — S&P API 비활성화
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 ═══
// Useful for tracking known military vessels that may not appear in area query
export async function fetchShipsByMMSI(mmsiList: string[]): Promise<Ship[]> {
@ -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)
async function fetchShipsFromSPGKorea(): Promise<Ship[]> {
if (!SPG_USERNAME || !SPG_PASSWORD) return [];
const SIGNAL_BATCH_BASE = '/signal-batch';
// 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 {
const targets = await callSPGAPI('GetTargetsInAreaEnhanced', {
sinceSeconds: SINCE_SECONDS,
minLat: KR_BOUNDS.minLat,
maxLat: KR_BOUNDS.maxLat,
minLong: KR_BOUNDS.minLng,
maxLong: KR_BOUNDS.maxLng,
const body: RecentPositionDetailRequest = {
minutes: 5,
coordinates: [
[KR_BOUNDS.minLng, KR_BOUNDS.minLat],
[KR_BOUNDS.maxLng, KR_BOUNDS.minLat],
[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)
.map(parseAISTarget);
if (!res.ok) throw new Error(`signal-batch API ${res.status}`);
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) {
console.warn('S&P AIS API (Korea region) failed:', err);
console.warn('signal-batch API (Korea region) failed:', err);
return [];
}
}
export async function fetchShipsKorea(): Promise<Ship[]> {
const sample = getSampleShipsKorea();
const real = await fetchShipsFromSPGKorea();
const real = await fetchShipsFromSignalBatch();
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));
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
activeStart?: number; // unix ms - when ship enters 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

파일 보기

@ -1,9 +1,10 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
plugins: [tailwindcss(), react()],
server: {
proxy: {
'/api/ais': {
@ -78,6 +79,42 @@ export default defineConfig({
rewrite: (path) => path.replace(/^\/api\/publish-twitter/, ''),
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