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>
@ -43,5 +43,42 @@
|
|||||||
"Read(./**/.env.*)",
|
"Read(./**/.env.*)",
|
||||||
"Read(./**/secrets/**)"
|
"Read(./**/secrets/**)"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"hooks": {
|
||||||
|
"SessionStart": [
|
||||||
|
{
|
||||||
|
"matcher": "compact",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bash .claude/scripts/on-post-compact.sh",
|
||||||
|
"timeout": 10
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PreCompact": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bash .claude/scripts/on-pre-compact.sh",
|
||||||
|
"timeout": 30
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PostToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Bash",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bash .claude/scripts/on-commit.sh",
|
||||||
|
"timeout": 15
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
6
.claude/workflow-version.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
1730
package-lock.json → frontend/package-lock.json
generated
@ -10,17 +10,22 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@rollup/rollup-darwin-arm64": "^4.59.0",
|
||||||
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"hls.js": "^1.6.15",
|
"hls.js": "^1.6.15",
|
||||||
|
"i18next": "^25.8.18",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"maplibre-gl": "^5.19.0",
|
"maplibre-gl": "^5.19.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
"react-i18next": "^16.5.8",
|
||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
"react-map-gl": "^8.1.0",
|
"react-map-gl": "^8.1.0",
|
||||||
"recharts": "^3.8.0",
|
"recharts": "^3.8.0",
|
||||||
"satellite.js": "^6.0.2"
|
"satellite.js": "^6.0.2",
|
||||||
|
"tailwindcss": "^4.2.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
|
Before Width: | Height: | 크기: 113 KiB After Width: | Height: | 크기: 113 KiB |
|
Before Width: | Height: | 크기: 112 KiB After Width: | Height: | 크기: 112 KiB |
|
Before Width: | Height: | 크기: 86 KiB After Width: | Height: | 크기: 86 KiB |
|
Before Width: | Height: | 크기: 1.2 MiB After Width: | Height: | 크기: 1.2 MiB |
|
Before Width: | Height: | 크기: 70 KiB After Width: | Height: | 크기: 70 KiB |
|
Before Width: | Height: | 크기: 98 KiB After Width: | Height: | 크기: 98 KiB |
|
Before Width: | Height: | 크기: 107 KiB After Width: | Height: | 크기: 107 KiB |
|
Before Width: | Height: | 크기: 114 KiB After Width: | Height: | 크기: 114 KiB |
|
Before Width: | Height: | 크기: 112 KiB After Width: | Height: | 크기: 112 KiB |
|
Before Width: | Height: | 크기: 112 KiB After Width: | Height: | 크기: 112 KiB |
|
Before Width: | Height: | 크기: 1.3 MiB After Width: | Height: | 크기: 1.3 MiB |
|
Before Width: | Height: | 크기: 123 KiB After Width: | Height: | 크기: 123 KiB |
|
Before Width: | Height: | 크기: 226 KiB After Width: | Height: | 크기: 226 KiB |
|
Before Width: | Height: | 크기: 119 KiB After Width: | Height: | 크기: 119 KiB |
|
Before Width: | Height: | 크기: 146 KiB After Width: | Height: | 크기: 146 KiB |
|
Before Width: | Height: | 크기: 2.4 MiB After Width: | Height: | 크기: 2.4 MiB |
|
Before Width: | Height: | 크기: 123 KiB After Width: | Height: | 크기: 123 KiB |
|
Before Width: | Height: | 크기: 1.5 KiB After Width: | Height: | 크기: 1.5 KiB |
@ -23,6 +23,10 @@ import { KOREA_SUBMARINE_CABLES } from './services/submarineCable';
|
|||||||
import type { OsintItem } from './services/osint';
|
import type { OsintItem } from './services/osint';
|
||||||
import { propagateAircraft, propagateShips } from './services/propagation';
|
import { propagateAircraft, propagateShips } from './services/propagation';
|
||||||
import type { GeoEvent, SensorLog, Aircraft, Ship, Satellite, SatellitePosition, LayerVisibility, AppMode } from './types';
|
import type { GeoEvent, SensorLog, Aircraft, Ship, Satellite, SatellitePosition, LayerVisibility, AppMode } from './types';
|
||||||
|
import { useTheme } from './hooks/useTheme';
|
||||||
|
import { useAuth } from './hooks/useAuth';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import LoginPage from './components/auth/LoginPage';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
// MarineTraffic-style ship classification
|
// MarineTraffic-style ship classification
|
||||||
@ -74,6 +78,32 @@ function getMarineTrafficCategory(typecode?: string, category?: string): string
|
|||||||
}
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const { user, isLoading: authLoading, isAuthenticated, login, devLogin, logout } = useAuth();
|
||||||
|
|
||||||
|
if (authLoading) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex min-h-screen items-center justify-center"
|
||||||
|
style={{ backgroundColor: 'var(--kcg-bg)', color: 'var(--kcg-muted)' }}
|
||||||
|
>
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <LoginPage onGoogleLogin={login} onDevLogin={devLogin} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <AuthenticatedApp user={user} onLogout={logout} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthenticatedAppProps {
|
||||||
|
user: { email: string; name: string; picture?: string } | null;
|
||||||
|
onLogout: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuthenticatedApp(_props: AuthenticatedAppProps) {
|
||||||
const [appMode, setAppMode] = useState<AppMode>('live');
|
const [appMode, setAppMode] = useState<AppMode>('live');
|
||||||
const [events, setEvents] = useState<GeoEvent[]>([]);
|
const [events, setEvents] = useState<GeoEvent[]>([]);
|
||||||
const [sensorData, setSensorData] = useState<SensorLog[]>([]);
|
const [sensorData, setSensorData] = useState<SensorLog[]>([]);
|
||||||
@ -100,6 +130,47 @@ function App() {
|
|||||||
militaryOnly: false,
|
militaryOnly: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Korea tab layer visibility (lifted from KoreaMap)
|
||||||
|
const [koreaLayers, setKoreaLayers] = useState<Record<string, boolean>>({
|
||||||
|
ships: true,
|
||||||
|
aircraft: true,
|
||||||
|
satellites: true,
|
||||||
|
infra: true,
|
||||||
|
cables: true,
|
||||||
|
cctv: true,
|
||||||
|
airports: true,
|
||||||
|
coastGuard: true,
|
||||||
|
navWarning: true,
|
||||||
|
osint: true,
|
||||||
|
eez: true,
|
||||||
|
piracy: true,
|
||||||
|
militaryOnly: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleKoreaLayer = useCallback((key: string) => {
|
||||||
|
setKoreaLayers(prev => ({ ...prev, [key]: !prev[key] }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Category filter state (shared across tabs)
|
||||||
|
const [hiddenAcCategories, setHiddenAcCategories] = useState<Set<string>>(new Set());
|
||||||
|
const [hiddenShipCategories, setHiddenShipCategories] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const toggleAcCategory = useCallback((cat: string) => {
|
||||||
|
setHiddenAcCategories(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(cat)) { next.delete(cat); } else { next.add(cat); }
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleShipCategory = useCallback((cat: string) => {
|
||||||
|
setHiddenShipCategories(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(cat)) { next.delete(cat); } else { next.add(cat); }
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [flyToTarget, setFlyToTarget] = useState<FlyToTarget | null>(null);
|
const [flyToTarget, setFlyToTarget] = useState<FlyToTarget | null>(null);
|
||||||
|
|
||||||
// 1시간마다 전체 데이터 강제 리프레시 (호르무즈 해협 실시간 정보 업데이트)
|
// 1시간마다 전체 데이터 강제 리프레시 (호르무즈 해협 실시간 정보 업데이트)
|
||||||
@ -125,6 +196,11 @@ function App() {
|
|||||||
|
|
||||||
const replay = useReplay();
|
const replay = useReplay();
|
||||||
const monitor = useMonitor();
|
const monitor = useMonitor();
|
||||||
|
const { theme, toggleTheme } = useTheme();
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const toggleLang = useCallback(() => {
|
||||||
|
i18n.changeLanguage(i18n.language === 'ko' ? 'en' : 'ko');
|
||||||
|
}, [i18n]);
|
||||||
|
|
||||||
const isLive = appMode === 'live';
|
const isLive = appMode === 'live';
|
||||||
|
|
||||||
@ -211,7 +287,7 @@ function App() {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [appMode, refreshKey]);
|
}, [appMode, refreshKey]);
|
||||||
|
|
||||||
// Fetch Korea region ship data (separate pipeline)
|
// Fetch Korea region ship data (signal-batch, 4-min cycle)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
@ -220,7 +296,7 @@ function App() {
|
|||||||
} catch { /* keep previous */ }
|
} catch { /* keep previous */ }
|
||||||
};
|
};
|
||||||
load();
|
load();
|
||||||
const interval = setInterval(load, 15_000);
|
const interval = setInterval(load, 240_000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [appMode, refreshKey]);
|
}, [appMode, refreshKey]);
|
||||||
|
|
||||||
@ -376,6 +452,24 @@ function App() {
|
|||||||
[baseShipsKorea, currentTime, isLive],
|
[baseShipsKorea, currentTime, isLive],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Category-filtered data for map rendering
|
||||||
|
const visibleAircraft = useMemo(
|
||||||
|
() => aircraft.filter(a => !hiddenAcCategories.has(a.category)),
|
||||||
|
[aircraft, hiddenAcCategories],
|
||||||
|
);
|
||||||
|
const visibleShips = useMemo(
|
||||||
|
() => ships.filter(s => !hiddenShipCategories.has(getMarineTrafficCategory(s.typecode, s.category))),
|
||||||
|
[ships, hiddenShipCategories],
|
||||||
|
);
|
||||||
|
const visibleAircraftKorea = useMemo(
|
||||||
|
() => aircraftKorea.filter(a => !hiddenAcCategories.has(a.category)),
|
||||||
|
[aircraftKorea, hiddenAcCategories],
|
||||||
|
);
|
||||||
|
const visibleKoreaShips = useMemo(
|
||||||
|
() => koreaShips.filter(s => !hiddenShipCategories.has(getMarineTrafficCategory(s.typecode, s.category))),
|
||||||
|
[koreaShips, hiddenShipCategories],
|
||||||
|
);
|
||||||
|
|
||||||
const toggleLayer = useCallback((key: keyof LayerVisibility) => {
|
const toggleLayer = useCallback((key: keyof LayerVisibility) => {
|
||||||
setLayers(prev => ({ ...prev, [key]: !prev[key] }));
|
setLayers(prev => ({ ...prev, [key]: !prev[key] }));
|
||||||
}, []);
|
}, []);
|
||||||
@ -398,12 +492,17 @@ function App() {
|
|||||||
() => aircraft.filter(a => a.category !== 'civilian' && a.category !== 'unknown').length,
|
() => aircraft.filter(a => a.category !== 'civilian' && a.category !== 'unknown').length,
|
||||||
[aircraft],
|
[aircraft],
|
||||||
);
|
);
|
||||||
|
const koreaMilitaryCount = useMemo(
|
||||||
|
() => aircraftKorea.filter(a => a.category !== 'civilian' && a.category !== 'unknown').length,
|
||||||
|
[aircraftKorea],
|
||||||
|
);
|
||||||
|
|
||||||
// Ship stats
|
// Ship stats — MT classification (matches map icon colors)
|
||||||
const shipsByCategory = useMemo(() => {
|
const shipsByCategory = useMemo(() => {
|
||||||
const counts: Record<string, number> = {};
|
const counts: Record<string, number> = {};
|
||||||
for (const s of ships) {
|
for (const s of ships) {
|
||||||
counts[s.category] = (counts[s.category] || 0) + 1;
|
const mtCat = getMarineTrafficCategory(s.typecode, s.category);
|
||||||
|
counts[mtCat] = (counts[mtCat] || 0) + 1;
|
||||||
}
|
}
|
||||||
return counts;
|
return counts;
|
||||||
}, [ships]);
|
}, [ships]);
|
||||||
@ -424,12 +523,21 @@ function App() {
|
|||||||
const koreaChineseShips = useMemo(() => koreaShips.filter(s => s.flag === 'CN'), [koreaShips]);
|
const koreaChineseShips = useMemo(() => koreaShips.filter(s => s.flag === 'CN'), [koreaShips]);
|
||||||
const koreaShipsByCategory = useMemo(() => {
|
const koreaShipsByCategory = useMemo(() => {
|
||||||
const counts: Record<string, number> = {};
|
const counts: Record<string, number> = {};
|
||||||
for (const s of koreaKoreanShips) {
|
for (const s of koreaShips) {
|
||||||
const mtCat = getMarineTrafficCategory(s.typecode, s.category);
|
const mtCat = getMarineTrafficCategory(s.typecode, s.category);
|
||||||
counts[mtCat] = (counts[mtCat] || 0) + 1;
|
counts[mtCat] = (counts[mtCat] || 0) + 1;
|
||||||
}
|
}
|
||||||
return counts;
|
return counts;
|
||||||
}, [koreaKoreanShips]);
|
}, [koreaShips]);
|
||||||
|
|
||||||
|
// Korea aircraft stats
|
||||||
|
const koreaAircraftByCategory = useMemo(() => {
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
for (const ac of aircraftKorea) {
|
||||||
|
counts[ac.category] = (counts[ac.category] || 0) + 1;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}, [aircraftKorea]);
|
||||||
|
|
||||||
// Korea filtered ships by monitoring mode (independent toggles, additive highlight)
|
// Korea filtered ships by monitoring mode (independent toggles, additive highlight)
|
||||||
const anyFilterOn = koreaFilters.illegalFishing || koreaFilters.illegalTransship || koreaFilters.darkVessel || koreaFilters.cableWatch || koreaFilters.dokdoWatch || koreaFilters.ferryWatch;
|
const anyFilterOn = koreaFilters.illegalFishing || koreaFilters.illegalTransship || koreaFilters.darkVessel || koreaFilters.cableWatch || koreaFilters.dokdoWatch || koreaFilters.ferryWatch;
|
||||||
@ -701,8 +809,8 @@ function App() {
|
|||||||
}, [koreaShips, koreaFilters.dokdoWatch, currentTime]);
|
}, [koreaShips, koreaFilters.dokdoWatch, currentTime]);
|
||||||
|
|
||||||
const koreaFilteredShips = useMemo(() => {
|
const koreaFilteredShips = useMemo(() => {
|
||||||
if (!anyFilterOn) return koreaShips;
|
if (!anyFilterOn) return visibleKoreaShips;
|
||||||
return koreaShips.filter(s => {
|
return visibleKoreaShips.filter(s => {
|
||||||
const mtCat = getMarineTrafficCategory(s.typecode, s.category);
|
const mtCat = getMarineTrafficCategory(s.typecode, s.category);
|
||||||
if (koreaFilters.illegalFishing && mtCat === 'fishing' && s.flag !== 'KR') return true;
|
if (koreaFilters.illegalFishing && mtCat === 'fishing' && s.flag !== 'KR') return true;
|
||||||
if (koreaFilters.illegalTransship && transshipSuspects.has(s.mmsi)) return true;
|
if (koreaFilters.illegalTransship && transshipSuspects.has(s.mmsi)) return true;
|
||||||
@ -712,7 +820,7 @@ function App() {
|
|||||||
if (koreaFilters.ferryWatch && getMarineTrafficCategory(s.typecode, s.category) === 'passenger') return true;
|
if (koreaFilters.ferryWatch && getMarineTrafficCategory(s.typecode, s.category) === 'passenger') return true;
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
}, [koreaShips, koreaFilters, anyFilterOn, transshipSuspects, darkVesselSet, cableWatchSet, dokdoWatchSet]);
|
}, [visibleKoreaShips, koreaFilters, anyFilterOn, transshipSuspects, darkVesselSet, cableWatchSet, dokdoWatchSet]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`app ${isLive ? 'app-live' : ''}`}>
|
<div className={`app ${isLive ? 'app-live' : ''}`}>
|
||||||
@ -724,28 +832,22 @@ function App() {
|
|||||||
onClick={() => setDashboardTab('iran')}
|
onClick={() => setDashboardTab('iran')}
|
||||||
>
|
>
|
||||||
<span className="dash-tab-flag">🇮🇷</span>
|
<span className="dash-tab-flag">🇮🇷</span>
|
||||||
이란 상황
|
{t('tabs.iran')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`dash-tab ${dashboardTab === 'korea' ? 'active' : ''}`}
|
className={`dash-tab ${dashboardTab === 'korea' ? 'active' : ''}`}
|
||||||
onClick={() => setDashboardTab('korea')}
|
onClick={() => setDashboardTab('korea')}
|
||||||
>
|
>
|
||||||
<span className="dash-tab-flag">🇰🇷</span>
|
<span className="dash-tab-flag">🇰🇷</span>
|
||||||
한국 현황
|
{t('tabs.korea')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mode Toggle */}
|
{/* Mode Toggle */}
|
||||||
{dashboardTab === 'iran' && (
|
{dashboardTab === 'iran' && (
|
||||||
<div className="mode-toggle">
|
<div className="mode-toggle">
|
||||||
<div style={{
|
<div className="flex items-center gap-1.5 font-mono text-[11px] text-kcg-danger bg-kcg-danger-bg border border-kcg-danger/30 rounded-[6px] px-2.5 py-1 font-bold animate-pulse">
|
||||||
display: 'flex', alignItems: 'center', gap: 6,
|
<span className="text-[13px]">⚔️</span>
|
||||||
fontFamily: 'monospace', fontSize: 11, color: '#ef4444',
|
|
||||||
background: 'rgba(239,68,68,0.1)', border: '1px solid rgba(239,68,68,0.3)',
|
|
||||||
borderRadius: 6, padding: '4px 10px', fontWeight: 700,
|
|
||||||
animation: 'pulse 3s ease-in-out infinite',
|
|
||||||
}}>
|
|
||||||
<span style={{ fontSize: 13 }}>⚔️</span>
|
|
||||||
D+{Math.floor((currentTime - new Date('2026-03-01T00:00:00Z').getTime()) / (1000 * 60 * 60 * 24))}
|
D+{Math.floor((currentTime - new Date('2026-03-01T00:00:00Z').getTime()) / (1000 * 60 * 60 * 24))}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@ -753,14 +855,14 @@ function App() {
|
|||||||
onClick={() => setAppMode('live')}
|
onClick={() => setAppMode('live')}
|
||||||
>
|
>
|
||||||
<span className="mode-dot-icon" />
|
<span className="mode-dot-icon" />
|
||||||
LIVE
|
{t('mode.live')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`mode-btn ${appMode === 'replay' ? 'active' : ''}`}
|
className={`mode-btn ${appMode === 'replay' ? 'active' : ''}`}
|
||||||
onClick={() => setAppMode('replay')}
|
onClick={() => setAppMode('replay')}
|
||||||
>
|
>
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><polygon points="5,3 19,12 5,21" /></svg>
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><polygon points="5,3 19,12 5,21" /></svg>
|
||||||
REPLAY
|
{t('mode.replay')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -770,50 +872,50 @@ function App() {
|
|||||||
<button
|
<button
|
||||||
className={`mode-btn ${koreaFilters.illegalFishing ? 'active live' : ''}`}
|
className={`mode-btn ${koreaFilters.illegalFishing ? 'active live' : ''}`}
|
||||||
onClick={() => setKoreaFilters(prev => ({ ...prev, illegalFishing: !prev.illegalFishing }))}
|
onClick={() => setKoreaFilters(prev => ({ ...prev, illegalFishing: !prev.illegalFishing }))}
|
||||||
title="불법어선 감시"
|
title={t('filters.illegalFishing')}
|
||||||
>
|
>
|
||||||
<span style={{ fontSize: 11 }}>🚫🐟</span>
|
<span className="text-[11px]">🚫🐟</span>
|
||||||
불법어선
|
{t('filters.illegalFishing')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`mode-btn ${koreaFilters.illegalTransship ? 'active live' : ''}`}
|
className={`mode-btn ${koreaFilters.illegalTransship ? 'active live' : ''}`}
|
||||||
onClick={() => setKoreaFilters(prev => ({ ...prev, illegalTransship: !prev.illegalTransship }))}
|
onClick={() => setKoreaFilters(prev => ({ ...prev, illegalTransship: !prev.illegalTransship }))}
|
||||||
title="불법환적 감시"
|
title={t('filters.illegalTransship')}
|
||||||
>
|
>
|
||||||
<span style={{ fontSize: 11 }}>⚓</span>
|
<span className="text-[11px]">⚓</span>
|
||||||
불법환적
|
{t('filters.illegalTransship')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`mode-btn ${koreaFilters.darkVessel ? 'active live' : ''}`}
|
className={`mode-btn ${koreaFilters.darkVessel ? 'active live' : ''}`}
|
||||||
onClick={() => setKoreaFilters(prev => ({ ...prev, darkVessel: !prev.darkVessel }))}
|
onClick={() => setKoreaFilters(prev => ({ ...prev, darkVessel: !prev.darkVessel }))}
|
||||||
title="다크베셀 (AIS 미송출)"
|
title={t('filters.darkVessel')}
|
||||||
>
|
>
|
||||||
<span style={{ fontSize: 11 }}>👻</span>
|
<span className="text-[11px]">👻</span>
|
||||||
다크베셀
|
{t('filters.darkVessel')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`mode-btn ${koreaFilters.cableWatch ? 'active live' : ''}`}
|
className={`mode-btn ${koreaFilters.cableWatch ? 'active live' : ''}`}
|
||||||
onClick={() => setKoreaFilters(prev => ({ ...prev, cableWatch: !prev.cableWatch }))}
|
onClick={() => setKoreaFilters(prev => ({ ...prev, cableWatch: !prev.cableWatch }))}
|
||||||
title="해저케이블 근처 의심선박 감시"
|
title={t('filters.cableWatch')}
|
||||||
>
|
>
|
||||||
<span style={{ fontSize: 11 }}>🔌</span>
|
<span className="text-[11px]">🔌</span>
|
||||||
해저케이블
|
{t('filters.cableWatch')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`mode-btn ${koreaFilters.dokdoWatch ? 'active live' : ''}`}
|
className={`mode-btn ${koreaFilters.dokdoWatch ? 'active live' : ''}`}
|
||||||
onClick={() => setKoreaFilters(prev => ({ ...prev, dokdoWatch: !prev.dokdoWatch }))}
|
onClick={() => setKoreaFilters(prev => ({ ...prev, dokdoWatch: !prev.dokdoWatch }))}
|
||||||
title="독도/울릉도 영해 접근 외국선박 감시"
|
title={t('filters.dokdoWatch')}
|
||||||
>
|
>
|
||||||
<span style={{ fontSize: 11 }}>🏝️</span>
|
<span className="text-[11px]">🏝️</span>
|
||||||
독도감시
|
{t('filters.dokdoWatch')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`mode-btn ${koreaFilters.ferryWatch ? 'active live' : ''}`}
|
className={`mode-btn ${koreaFilters.ferryWatch ? 'active live' : ''}`}
|
||||||
onClick={() => setKoreaFilters(prev => ({ ...prev, ferryWatch: !prev.ferryWatch }))}
|
onClick={() => setKoreaFilters(prev => ({ ...prev, ferryWatch: !prev.ferryWatch }))}
|
||||||
title="여객선/크루즈/페리 위치 감시"
|
title={t('filters.ferryWatch')}
|
||||||
>
|
>
|
||||||
<span style={{ fontSize: 11 }}>🚢</span>
|
<span className="text-[11px]">🚢</span>
|
||||||
여객선감시
|
{t('filters.ferryWatch')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -825,35 +927,43 @@ function App() {
|
|||||||
onClick={() => setMapMode('flat')}
|
onClick={() => setMapMode('flat')}
|
||||||
>
|
>
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="1" y="1" width="12" height="12" rx="1" stroke="currentColor" strokeWidth="1.5"/><line x1="1" y1="5" x2="13" y2="5" stroke="currentColor" strokeWidth="1"/><line x1="1" y1="9" x2="13" y2="9" stroke="currentColor" strokeWidth="1"/><line x1="5" y1="1" x2="5" y2="13" stroke="currentColor" strokeWidth="1"/><line x1="9" y1="1" x2="9" y2="13" stroke="currentColor" strokeWidth="1"/></svg>
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="1" y="1" width="12" height="12" rx="1" stroke="currentColor" strokeWidth="1.5"/><line x1="1" y1="5" x2="13" y2="5" stroke="currentColor" strokeWidth="1"/><line x1="1" y1="9" x2="13" y2="9" stroke="currentColor" strokeWidth="1"/><line x1="5" y1="1" x2="5" y2="13" stroke="currentColor" strokeWidth="1"/><line x1="9" y1="1" x2="9" y2="13" stroke="currentColor" strokeWidth="1"/></svg>
|
||||||
FLAT MAP
|
{t('mapMode.flat')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`map-mode-btn ${mapMode === 'globe' ? 'active' : ''}`}
|
className={`map-mode-btn ${mapMode === 'globe' ? 'active' : ''}`}
|
||||||
onClick={() => setMapMode('globe')}
|
onClick={() => setMapMode('globe')}
|
||||||
>
|
>
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><circle cx="7" cy="7" r="6" stroke="currentColor" strokeWidth="1.5"/><ellipse cx="7" cy="7" rx="3" ry="6" stroke="currentColor" strokeWidth="1"/><line x1="1" y1="7" x2="13" y2="7" stroke="currentColor" strokeWidth="1"/></svg>
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><circle cx="7" cy="7" r="6" stroke="currentColor" strokeWidth="1.5"/><ellipse cx="7" cy="7" rx="3" ry="6" stroke="currentColor" strokeWidth="1"/><line x1="1" y1="7" x2="13" y2="7" stroke="currentColor" strokeWidth="1"/></svg>
|
||||||
GLOBE
|
{t('mapMode.globe')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`map-mode-btn ${mapMode === 'satellite' ? 'active' : ''}`}
|
className={`map-mode-btn ${mapMode === 'satellite' ? 'active' : ''}`}
|
||||||
onClick={() => setMapMode('satellite')}
|
onClick={() => setMapMode('satellite')}
|
||||||
>
|
>
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><circle cx="7" cy="7" r="6" stroke="currentColor" strokeWidth="1.5"/><path d="M3 4.5C4.5 3 6 2.5 7 2.5s3 1 4.5 2.5" stroke="currentColor" strokeWidth="0.8"/><path d="M2.5 7.5C4 6 6 5 7 5s3.5 1 5 2.5" stroke="currentColor" strokeWidth="0.8"/><path d="M3 10C4.5 8.5 6 8 7 8s3 .5 4 1.5" stroke="currentColor" strokeWidth="0.8"/><circle cx="7" cy="6" r="1.5" fill="currentColor" opacity="0.3"/></svg>
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><circle cx="7" cy="7" r="6" stroke="currentColor" strokeWidth="1.5"/><path d="M3 4.5C4.5 3 6 2.5 7 2.5s3 1 4.5 2.5" stroke="currentColor" strokeWidth="0.8"/><path d="M2.5 7.5C4 6 6 5 7 5s3.5 1 5 2.5" stroke="currentColor" strokeWidth="0.8"/><path d="M3 10C4.5 8.5 6 8 7 8s3 .5 4 1.5" stroke="currentColor" strokeWidth="0.8"/><circle cx="7" cy="6" r="1.5" fill="currentColor" opacity="0.3"/></svg>
|
||||||
위성지도
|
{t('mapMode.satellite')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="header-info">
|
<div className="header-info">
|
||||||
<div className="header-counts">
|
<div className="header-counts">
|
||||||
<span className="count-item ac-count">{aircraft.length} AC</span>
|
<span className="count-item ac-count">{dashboardTab === 'iran' ? aircraft.length : aircraftKorea.length} AC</span>
|
||||||
<span className="count-item mil-count">{militaryCount} MIL</span>
|
<span className="count-item mil-count">{dashboardTab === 'iran' ? militaryCount : koreaMilitaryCount} MIL</span>
|
||||||
<span className="count-item ship-count">{ships.length} SHIP</span>
|
<span className="count-item ship-count">{dashboardTab === 'iran' ? ships.length : koreaShips.length} SHIP</span>
|
||||||
<span className="count-item sat-count">{satPositions.length} SAT</span>
|
<span className="count-item sat-count">{dashboardTab === 'iran' ? satPositions.length : satPositionsKorea.length} SAT</span>
|
||||||
|
</div>
|
||||||
|
<div className="header-toggles">
|
||||||
|
<button type="button" className="header-toggle-btn" onClick={toggleLang} title="Language">
|
||||||
|
{i18n.language === 'ko' ? 'KO' : 'EN'}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="header-toggle-btn" onClick={toggleTheme} title="Theme">
|
||||||
|
{theme === 'dark' ? '🌙' : '☀️'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="header-status">
|
<div className="header-status">
|
||||||
<span className={`status-dot ${isLive ? 'live' : replay.state.isPlaying ? 'live' : ''}`} />
|
<span className={`status-dot ${isLive ? 'live' : replay.state.isPlaying ? 'live' : ''}`} />
|
||||||
{isLive ? 'LIVE' : replay.state.isPlaying ? 'REPLAYING' : 'PAUSED'}
|
{isLive ? t('header.live') : replay.state.isPlaying ? t('header.replaying') : t('header.paused')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@ -870,9 +980,9 @@ function App() {
|
|||||||
key="map-iran"
|
key="map-iran"
|
||||||
events={isLive ? [] : mergedEvents}
|
events={isLive ? [] : mergedEvents}
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
aircraft={aircraft}
|
aircraft={visibleAircraft}
|
||||||
satellites={satPositions}
|
satellites={satPositions}
|
||||||
ships={ships}
|
ships={visibleShips}
|
||||||
layers={layers}
|
layers={layers}
|
||||||
flyToTarget={flyToTarget}
|
flyToTarget={flyToTarget}
|
||||||
onFlyToDone={() => setFlyToTarget(null)}
|
onFlyToDone={() => setFlyToTarget(null)}
|
||||||
@ -881,32 +991,41 @@ function App() {
|
|||||||
<GlobeMap
|
<GlobeMap
|
||||||
events={isLive ? [] : mergedEvents}
|
events={isLive ? [] : mergedEvents}
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
aircraft={aircraft}
|
aircraft={visibleAircraft}
|
||||||
satellites={satPositions}
|
satellites={satPositions}
|
||||||
ships={ships}
|
ships={visibleShips}
|
||||||
layers={layers}
|
layers={layers}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<SatelliteMap
|
<SatelliteMap
|
||||||
events={isLive ? [] : mergedEvents}
|
events={isLive ? [] : mergedEvents}
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
aircraft={aircraft}
|
aircraft={visibleAircraft}
|
||||||
satellites={satPositions}
|
satellites={satPositions}
|
||||||
ships={ships}
|
ships={visibleShips}
|
||||||
layers={layers}
|
layers={layers}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="map-overlay-left">
|
<div className="map-overlay-left">
|
||||||
<LayerPanel
|
<LayerPanel
|
||||||
layers={layers}
|
layers={layers as unknown as Record<string, boolean>}
|
||||||
onToggle={toggleLayer}
|
onToggle={toggleLayer as (key: string) => void}
|
||||||
aircraftCount={aircraft.length}
|
|
||||||
militaryCount={militaryCount}
|
|
||||||
satelliteCount={satPositions.length}
|
|
||||||
shipCount={ships.length}
|
|
||||||
koreanShipCount={koreanShips.length}
|
|
||||||
aircraftByCategory={aircraftByCategory}
|
aircraftByCategory={aircraftByCategory}
|
||||||
shipsByCategory={shipsByCategory}
|
aircraftTotal={aircraft.length}
|
||||||
|
shipsByMtCategory={shipsByCategory}
|
||||||
|
shipTotal={ships.length}
|
||||||
|
satelliteCount={satPositions.length}
|
||||||
|
extraLayers={[
|
||||||
|
{ key: 'events', label: t('layers.events'), color: '#a855f7' },
|
||||||
|
{ key: 'koreanShips', label: `\u{1F1F0}\u{1F1F7} ${t('layers.koreanShips')}`, color: '#00e5ff', count: koreanShips.length },
|
||||||
|
{ key: 'airports', label: t('layers.airports'), color: '#f59e0b' },
|
||||||
|
{ key: 'oilFacilities', label: t('layers.oilFacilities'), color: '#d97706' },
|
||||||
|
{ key: 'sensorCharts', label: t('layers.sensorCharts'), color: '#22c55e' },
|
||||||
|
]}
|
||||||
|
hiddenAcCategories={hiddenAcCategories}
|
||||||
|
hiddenShipCategories={hiddenShipCategories}
|
||||||
|
onAcCategoryToggle={toggleAcCategory}
|
||||||
|
onShipCategoryToggle={toggleShipCategory}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -983,7 +1102,45 @@ function App() {
|
|||||||
<>
|
<>
|
||||||
<main className="app-main">
|
<main className="app-main">
|
||||||
<div className="map-panel">
|
<div className="map-panel">
|
||||||
<KoreaMap ships={koreaFilteredShips} aircraft={aircraftKorea} satellites={satPositionsKorea} militaryOnly={layers.militaryOnly} osintFeed={osintFeed} currentTime={currentTime} koreaFilters={koreaFilters} transshipSuspects={transshipSuspects} cableWatchSuspects={cableWatchSet} dokdoWatchSuspects={dokdoWatchSet} dokdoAlerts={dokdoAlerts} />
|
<KoreaMap
|
||||||
|
ships={koreaFilteredShips}
|
||||||
|
aircraft={visibleAircraftKorea}
|
||||||
|
satellites={satPositionsKorea}
|
||||||
|
layers={koreaLayers}
|
||||||
|
osintFeed={osintFeed}
|
||||||
|
currentTime={currentTime}
|
||||||
|
koreaFilters={koreaFilters}
|
||||||
|
transshipSuspects={transshipSuspects}
|
||||||
|
cableWatchSuspects={cableWatchSet}
|
||||||
|
dokdoWatchSuspects={dokdoWatchSet}
|
||||||
|
dokdoAlerts={dokdoAlerts}
|
||||||
|
/>
|
||||||
|
<div className="map-overlay-left">
|
||||||
|
<LayerPanel
|
||||||
|
layers={koreaLayers}
|
||||||
|
onToggle={toggleKoreaLayer}
|
||||||
|
aircraftByCategory={koreaAircraftByCategory}
|
||||||
|
aircraftTotal={aircraftKorea.length}
|
||||||
|
shipsByMtCategory={koreaShipsByCategory}
|
||||||
|
shipTotal={koreaShips.length}
|
||||||
|
satelliteCount={satPositionsKorea.length}
|
||||||
|
extraLayers={[
|
||||||
|
{ key: 'infra', label: t('layers.infra'), color: '#ffc107' },
|
||||||
|
{ key: 'cables', label: t('layers.cables'), color: '#00e5ff' },
|
||||||
|
{ key: 'cctv', label: t('layers.cctv'), color: '#ff6b6b', count: 15 },
|
||||||
|
{ key: 'airports', label: t('layers.airports'), color: '#a78bfa', count: 16 },
|
||||||
|
{ key: 'coastGuard', label: t('layers.coastGuard'), color: '#4dabf7', count: 46 },
|
||||||
|
{ key: 'navWarning', label: t('layers.navWarning'), color: '#eab308' },
|
||||||
|
{ key: 'osint', label: t('layers.osint'), color: '#ef4444' },
|
||||||
|
{ key: 'eez', label: t('layers.eez'), color: '#3b82f6' },
|
||||||
|
{ key: 'piracy', label: t('layers.piracy'), color: '#ef4444' },
|
||||||
|
]}
|
||||||
|
hiddenAcCategories={hiddenAcCategories}
|
||||||
|
hiddenShipCategories={hiddenShipCategories}
|
||||||
|
onAcCategoryToggle={toggleAcCategory}
|
||||||
|
onShipCategoryToggle={toggleShipCategory}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside className="side-panel">
|
<aside className="side-panel">
|
||||||
|
Before Width: | Height: | 크기: 4.0 KiB After Width: | Height: | 크기: 4.0 KiB |
@ -1,5 +1,6 @@
|
|||||||
import { memo, useMemo, useState, useEffect } from 'react';
|
import { memo, useMemo, useState, useEffect } from 'react';
|
||||||
import { Marker, Popup, Source, Layer } from 'react-map-gl/maplibre';
|
import { Marker, Popup, Source, Layer } from 'react-map-gl/maplibre';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { Aircraft, AircraftCategory } from '../types';
|
import type { Aircraft, AircraftCategory } from '../types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -55,7 +56,7 @@ const ALT_COLORS: [number, string][] = [
|
|||||||
[9000, '#FF4500'], [12000, '#FF1493'], [15000, '#BA55D3'],
|
[9000, '#FF4500'], [12000, '#FF1493'], [15000, '#BA55D3'],
|
||||||
];
|
];
|
||||||
|
|
||||||
const MIL_COLORS: Partial<Record<AircraftCategory, string>> = {
|
const MIL_HEX: Partial<Record<AircraftCategory, string>> = {
|
||||||
fighter: '#ff4444', military: '#ff6600', surveillance: '#ffcc00', tanker: '#00ccff',
|
fighter: '#ff4444', military: '#ff6600', surveillance: '#ffcc00', tanker: '#00ccff',
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -68,22 +69,18 @@ function getAltitudeColor(altMeters: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getAircraftColor(ac: Aircraft): string {
|
function getAircraftColor(ac: Aircraft): string {
|
||||||
const milColor = MIL_COLORS[ac.category];
|
const milColor = MIL_HEX[ac.category];
|
||||||
if (milColor) return milColor;
|
if (milColor) return milColor;
|
||||||
if (ac.onGround) return '#555555';
|
if (ac.onGround) return '#555555';
|
||||||
return getAltitudeColor(ac.altitude);
|
return getAltitudeColor(ac.altitude);
|
||||||
}
|
}
|
||||||
|
|
||||||
const CATEGORY_LABELS: Record<AircraftCategory, string> = {
|
|
||||||
fighter: 'FIGHTER', tanker: 'TANKER', surveillance: 'ISR',
|
|
||||||
cargo: 'CARGO', military: 'MIL', civilian: 'CIV', unknown: '???',
|
|
||||||
};
|
|
||||||
|
|
||||||
// ═══ Planespotters.net photo API ═══
|
// ═══ Planespotters.net photo API ═══
|
||||||
interface PhotoResult { url: string; photographer: string; link: string; }
|
interface PhotoResult { url: string; photographer: string; link: string; }
|
||||||
const photoCache = new Map<string, PhotoResult | null>();
|
const photoCache = new Map<string, PhotoResult | null>();
|
||||||
|
|
||||||
function AircraftPhoto({ hex }: { hex: string }) {
|
function AircraftPhoto({ hex }: { hex: string }) {
|
||||||
|
const { t } = useTranslation('ships');
|
||||||
const [photo, setPhoto] = useState<PhotoResult | null | undefined>(
|
const [photo, setPhoto] = useState<PhotoResult | null | undefined>(
|
||||||
photoCache.has(hex) ? photoCache.get(hex) : undefined,
|
photoCache.has(hex) ? photoCache.get(hex) : undefined,
|
||||||
);
|
);
|
||||||
@ -119,19 +116,19 @@ function AircraftPhoto({ hex }: { hex: string }) {
|
|||||||
}, [hex, photo]);
|
}, [hex, photo]);
|
||||||
|
|
||||||
if (photo === undefined) {
|
if (photo === undefined) {
|
||||||
return <div style={{ textAlign: 'center', padding: 8, color: '#888', fontSize: 10 }}>Loading photo...</div>;
|
return <div className="text-center p-2 text-kcg-muted text-[10px]">{t('aircraftPopup.loadingPhoto')}</div>;
|
||||||
}
|
}
|
||||||
if (!photo) return null;
|
if (!photo) return null;
|
||||||
return (
|
return (
|
||||||
<div style={{ marginBottom: 6 }}>
|
<div className="mb-1.5">
|
||||||
<a href={photo.link} target="_blank" rel="noopener noreferrer">
|
<a href={photo.link} target="_blank" rel="noopener noreferrer">
|
||||||
<img src={photo.url} alt="Aircraft"
|
<img src={photo.url} alt="Aircraft"
|
||||||
style={{ width: '100%', borderRadius: 4, display: 'block' }}
|
className="w-full rounded block"
|
||||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
{photo.photographer && (
|
{photo.photographer && (
|
||||||
<div style={{ fontSize: 9, color: '#999', marginTop: 2, textAlign: 'right' }}>
|
<div className="text-[9px] text-[#999] mt-0.5 text-right">
|
||||||
© {photo.photographer}
|
© {photo.photographer}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -188,6 +185,7 @@ export function AircraftLayer({ aircraft, militaryOnly }: Props) {
|
|||||||
|
|
||||||
// ═══ Aircraft Marker ═══
|
// ═══ Aircraft Marker ═══
|
||||||
const AircraftMarker = memo(function AircraftMarker({ ac }: { ac: Aircraft }) {
|
const AircraftMarker = memo(function AircraftMarker({ ac }: { ac: Aircraft }) {
|
||||||
|
const { t } = useTranslation('ships');
|
||||||
const [showPopup, setShowPopup] = useState(false);
|
const [showPopup, setShowPopup] = useState(false);
|
||||||
const color = getAircraftColor(ac);
|
const color = getAircraftColor(ac);
|
||||||
const shape = getShape(ac);
|
const shape = getShape(ac);
|
||||||
@ -198,10 +196,11 @@ const AircraftMarker = memo(function AircraftMarker({ ac }: { ac: Aircraft }) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Marker longitude={ac.lng} latitude={ac.lat} anchor="center">
|
<Marker longitude={ac.lng} latitude={ac.lat} anchor="center">
|
||||||
<div style={{ position: 'relative' }}>
|
<div className="relative">
|
||||||
<div
|
<div
|
||||||
|
className="cursor-pointer"
|
||||||
style={{
|
style={{
|
||||||
width: size, height: size, cursor: 'pointer',
|
width: size, height: size,
|
||||||
transform: `rotate(${ac.heading}deg)`,
|
transform: `rotate(${ac.heading}deg)`,
|
||||||
filter: 'drop-shadow(0 0 2px rgba(0,0,0,0.7))',
|
filter: 'drop-shadow(0 0 2px rgba(0,0,0,0.7))',
|
||||||
}}
|
}}
|
||||||
@ -224,37 +223,37 @@ const AircraftMarker = memo(function AircraftMarker({ ac }: { ac: Aircraft }) {
|
|||||||
<Popup longitude={ac.lng} latitude={ac.lat}
|
<Popup longitude={ac.lng} latitude={ac.lat}
|
||||||
onClose={() => setShowPopup(false)} closeOnClick={false}
|
onClose={() => setShowPopup(false)} closeOnClick={false}
|
||||||
anchor="bottom" maxWidth="300px" className="gl-popup">
|
anchor="bottom" maxWidth="300px" className="gl-popup">
|
||||||
<div style={{ minWidth: 240, maxWidth: 300, fontFamily: 'monospace', fontSize: 12 }}>
|
<div className="min-w-[240px] max-w-[300px] font-mono text-xs">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
<strong style={{ fontSize: 14 }}>{ac.callsign || 'N/A'}</strong>
|
<strong className="text-sm">{ac.callsign || 'N/A'}</strong>
|
||||||
<span style={{
|
<span
|
||||||
background: color, color: '#000', padding: '1px 6px',
|
className="px-1.5 py-px rounded text-[10px] font-bold ml-auto text-black"
|
||||||
borderRadius: 3, fontSize: 10, fontWeight: 700, marginLeft: 'auto',
|
style={{ background: color }}
|
||||||
}}>
|
>
|
||||||
{CATEGORY_LABELS[ac.category]}
|
{t(`aircraftLabel.${ac.category}`)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<AircraftPhoto hex={ac.icao24} />
|
<AircraftPhoto hex={ac.icao24} />
|
||||||
<table style={{ width: '100%', fontSize: 11, borderCollapse: 'collapse' }}>
|
<table className="w-full text-[11px] border-collapse">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr><td style={{ color: '#888', paddingRight: 8 }}>Hex</td><td><strong>{ac.icao24.toUpperCase()}</strong></td></tr>
|
<tr><td className="text-kcg-muted pr-2">{t('aircraftPopup.hex')}</td><td><strong>{ac.icao24.toUpperCase()}</strong></td></tr>
|
||||||
{ac.registration && <tr><td style={{ color: '#888' }}>Reg.</td><td><strong>{ac.registration}</strong></td></tr>}
|
{ac.registration && <tr><td className="text-kcg-muted">{t('aircraftPopup.reg')}</td><td><strong>{ac.registration}</strong></td></tr>}
|
||||||
{ac.operator && <tr><td style={{ color: '#888' }}>Operator</td><td>{ac.operator}</td></tr>}
|
{ac.operator && <tr><td className="text-kcg-muted">{t('aircraftPopup.operator')}</td><td>{ac.operator}</td></tr>}
|
||||||
{ac.typecode && (
|
{ac.typecode && (
|
||||||
<tr><td style={{ color: '#888' }}>Type</td>
|
<tr><td className="text-kcg-muted">{t('aircraftPopup.type')}</td>
|
||||||
<td><strong>{ac.typecode}</strong>{ac.typeDesc ? ` — ${ac.typeDesc}` : ''}</td></tr>
|
<td><strong>{ac.typecode}</strong>{ac.typeDesc ? ` — ${ac.typeDesc}` : ''}</td></tr>
|
||||||
)}
|
)}
|
||||||
{ac.squawk && <tr><td style={{ color: '#888' }}>Squawk</td><td>{ac.squawk}</td></tr>}
|
{ac.squawk && <tr><td className="text-kcg-muted">{t('aircraftPopup.squawk')}</td><td>{ac.squawk}</td></tr>}
|
||||||
<tr><td style={{ color: '#888' }}>Alt</td>
|
<tr><td className="text-kcg-muted">{t('aircraftPopup.alt')}</td>
|
||||||
<td>{ac.onGround ? 'GROUND' : `${Math.round(ac.altitude * 3.281).toLocaleString()} ft`}</td></tr>
|
<td>{ac.onGround ? t('aircraftPopup.ground') : `${Math.round(ac.altitude * 3.281).toLocaleString()} ft`}</td></tr>
|
||||||
<tr><td style={{ color: '#888' }}>Speed</td><td>{Math.round(ac.velocity * 1.944)} kts</td></tr>
|
<tr><td className="text-kcg-muted">{t('aircraftPopup.speed')}</td><td>{Math.round(ac.velocity * 1.944)} kts</td></tr>
|
||||||
<tr><td style={{ color: '#888' }}>Hdg</td><td>{Math.round(ac.heading)}°</td></tr>
|
<tr><td className="text-kcg-muted">{t('aircraftPopup.hdg')}</td><td>{Math.round(ac.heading)}°</td></tr>
|
||||||
<tr><td style={{ color: '#888' }}>V/S</td><td>{Math.round(ac.verticalRate * 196.85)} fpm</td></tr>
|
<tr><td className="text-kcg-muted">{t('aircraftPopup.verticalSpeed')}</td><td>{Math.round(ac.verticalRate * 196.85)} fpm</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div style={{ marginTop: 6, fontSize: 10, textAlign: 'right' }}>
|
<div className="mt-1.5 text-[10px] text-right">
|
||||||
<a href={`https://globe.airplanes.live/?icao=${ac.icao24}`}
|
<a href={`https://globe.airplanes.live/?icao=${ac.icao24}`}
|
||||||
target="_blank" rel="noopener noreferrer" style={{ color: '#60a5fa' }}>
|
target="_blank" rel="noopener noreferrer" className="text-kcg-accent">
|
||||||
Airplanes.live →
|
Airplanes.live →
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
257
frontend/src/components/CctvLayer.tsx
Normal file
@ -0,0 +1,257 @@
|
|||||||
|
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
|
import { Marker, Popup } from 'react-map-gl/maplibre';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import Hls from 'hls.js';
|
||||||
|
import { KOREA_CCTV_CAMERAS } from '../services/cctv';
|
||||||
|
import type { CctvCamera } from '../services/cctv';
|
||||||
|
|
||||||
|
const REGION_COLOR: Record<string, string> = {
|
||||||
|
'제주': '#ff6b6b',
|
||||||
|
'남해': '#ffa94d',
|
||||||
|
'서해': '#69db7c',
|
||||||
|
'동해': '#74c0fc',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** KHOA HLS → vite 프록시 경유 */
|
||||||
|
function toProxyUrl(cam: CctvCamera): string {
|
||||||
|
return cam.streamUrl.replace('https://www.khoa.go.kr', '/api/khoa-hls');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CctvLayer() {
|
||||||
|
const { t } = useTranslation('ships');
|
||||||
|
const [selected, setSelected] = useState<CctvCamera | null>(null);
|
||||||
|
const [streamCam, setStreamCam] = useState<CctvCamera | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{KOREA_CCTV_CAMERAS.map(cam => {
|
||||||
|
const color = REGION_COLOR[cam.region] || '#aaa';
|
||||||
|
return (
|
||||||
|
<Marker key={cam.id} longitude={cam.lng} latitude={cam.lat} anchor="center"
|
||||||
|
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(cam); }}>
|
||||||
|
<div
|
||||||
|
className="relative cursor-pointer flex flex-col items-center"
|
||||||
|
style={{ filter: `drop-shadow(0 0 2px ${color}88)` }}
|
||||||
|
>
|
||||||
|
<svg width={14} height={14} viewBox="0 0 24 24" fill="none">
|
||||||
|
<rect x="2" y="5" width="15" height="13" rx="2" fill={color} stroke="#fff" strokeWidth="0.8" />
|
||||||
|
<polygon points="17,8 23,5 23,18 17,15" fill={color} stroke="#fff" strokeWidth="0.5" />
|
||||||
|
<circle cx="6" cy="8.5" r="2.5" fill="#ff0000">
|
||||||
|
<animate attributeName="opacity" values="1;0.3;1" dur="1.5s" repeatCount="indefinite" />
|
||||||
|
</circle>
|
||||||
|
</svg>
|
||||||
|
<div
|
||||||
|
className="text-[6px] text-white mt-0 whitespace-nowrap font-bold tracking-wide"
|
||||||
|
style={{ textShadow: `0 0 3px ${color}, 0 0 2px #000, 0 0 2px #000` }}
|
||||||
|
>
|
||||||
|
{cam.name.length > 8 ? cam.name.slice(0, 8) + '..' : cam.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Marker>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{selected && (
|
||||||
|
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||||
|
onClose={() => setSelected(null)} closeOnClick={false}
|
||||||
|
anchor="bottom" maxWidth="280px" className="gl-popup">
|
||||||
|
<div className="font-mono text-xs min-w-[200px]">
|
||||||
|
<div
|
||||||
|
className="px-2 py-1 rounded-t font-bold text-[13px] flex items-center gap-1.5 -mx-2.5 -mt-2.5 mb-2 text-black"
|
||||||
|
style={{ background: REGION_COLOR[selected.region] || '#888' }}
|
||||||
|
>
|
||||||
|
<span>📹</span> {selected.name}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 mb-1.5 flex-wrap">
|
||||||
|
<span className="bg-kcg-success text-white px-1.5 py-px rounded text-[10px] font-bold">
|
||||||
|
● {t('cctv.live')}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="px-1.5 py-px rounded text-[10px] font-bold text-black"
|
||||||
|
style={{ background: REGION_COLOR[selected.region] || '#888' }}
|
||||||
|
>{selected.region}</span>
|
||||||
|
<span className="bg-kcg-border text-kcg-text-secondary px-1.5 py-px rounded text-[10px]">
|
||||||
|
{t(`cctv.type.${selected.type}`, { defaultValue: selected.type })}
|
||||||
|
</span>
|
||||||
|
<span className="bg-kcg-card text-kcg-muted px-1.5 py-px rounded text-[10px]">
|
||||||
|
{t('cctv.khoa')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] flex flex-col gap-0.5">
|
||||||
|
<div className="text-[9px] text-kcg-dim">
|
||||||
|
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => { setStreamCam(selected); setSelected(null); }}
|
||||||
|
className="inline-flex items-center justify-center gap-1 bg-kcg-accent text-white px-2.5 py-1 rounded text-[11px] font-bold mt-1 border-none cursor-pointer font-mono"
|
||||||
|
>
|
||||||
|
📺 {t('cctv.viewStream')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* CCTV HLS Stream Modal */}
|
||||||
|
{streamCam && (
|
||||||
|
<CctvStreamModal cam={streamCam} onClose={() => setStreamCam(null)} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** KHOA HLS 영상 모달 — 지도 하단 오버레이 */
|
||||||
|
function CctvStreamModal({ cam, onClose }: { cam: CctvCamera; onClose: () => void }) {
|
||||||
|
const { t } = useTranslation('ships');
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const hlsRef = useRef<Hls | null>(null);
|
||||||
|
const [status, setStatus] = useState<'loading' | 'playing' | 'error'>('loading');
|
||||||
|
|
||||||
|
const destroyHls = useCallback(() => {
|
||||||
|
if (hlsRef.current) {
|
||||||
|
hlsRef.current.destroy();
|
||||||
|
hlsRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
|
||||||
|
const proxied = toProxyUrl(cam);
|
||||||
|
setStatus('loading');
|
||||||
|
|
||||||
|
if (Hls.isSupported()) {
|
||||||
|
destroyHls();
|
||||||
|
const hls = new Hls({
|
||||||
|
enableWorker: true,
|
||||||
|
lowLatencyMode: true,
|
||||||
|
maxBufferLength: 10,
|
||||||
|
maxMaxBufferLength: 30,
|
||||||
|
});
|
||||||
|
hlsRef.current = hls;
|
||||||
|
hls.loadSource(proxied);
|
||||||
|
hls.attachMedia(video);
|
||||||
|
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||||
|
setStatus('playing');
|
||||||
|
video.play().catch(() => {});
|
||||||
|
});
|
||||||
|
hls.on(Hls.Events.ERROR, (_event, data) => {
|
||||||
|
if (data.fatal) setStatus('error');
|
||||||
|
});
|
||||||
|
return () => destroyHls();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safari 네이티브 HLS
|
||||||
|
if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||||
|
video.src = proxied;
|
||||||
|
const onLoaded = () => setStatus('playing');
|
||||||
|
const onError = () => setStatus('error');
|
||||||
|
video.addEventListener('loadeddata', onLoaded);
|
||||||
|
video.addEventListener('error', onError);
|
||||||
|
video.play().catch(() => {});
|
||||||
|
return () => {
|
||||||
|
video.removeEventListener('loadeddata', onLoaded);
|
||||||
|
video.removeEventListener('error', onError);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus('error');
|
||||||
|
return () => destroyHls();
|
||||||
|
}, [cam, destroyHls]);
|
||||||
|
|
||||||
|
const color = REGION_COLOR[cam.region] || '#888';
|
||||||
|
|
||||||
|
return (
|
||||||
|
/* Backdrop */
|
||||||
|
<div
|
||||||
|
onClick={onClose}
|
||||||
|
className="fixed inset-0 z-[9999] flex items-center justify-center backdrop-blur-sm"
|
||||||
|
style={{ background: 'rgba(0,0,0,0.6)' }}
|
||||||
|
>
|
||||||
|
{/* Modal */}
|
||||||
|
<div
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
className="w-[640px] max-w-[90vw] bg-kcg-bg rounded-lg overflow-hidden"
|
||||||
|
style={{
|
||||||
|
border: `1px solid ${color}`,
|
||||||
|
boxShadow: `0 0 40px ${color}33, 0 4px 30px rgba(0,0,0,0.8)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-3.5 py-2 bg-kcg-overlay border-b border-[#222]">
|
||||||
|
<div className="flex items-center gap-2 font-mono text-[11px] text-kcg-text">
|
||||||
|
<span
|
||||||
|
className="text-white px-1.5 py-px rounded text-[9px] font-bold"
|
||||||
|
style={{
|
||||||
|
background: status === 'playing' ? '#22c55e' : status === 'loading' ? '#eab308' : '#ef4444',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
● {status === 'playing' ? t('cctv.live') : status === 'loading' ? t('cctv.connecting') : 'ERROR'}
|
||||||
|
</span>
|
||||||
|
<span className="font-bold">📹 {cam.name}</span>
|
||||||
|
<span
|
||||||
|
className="px-1.5 py-px rounded text-[9px] font-bold text-black"
|
||||||
|
style={{ background: color }}
|
||||||
|
>{cam.region}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="bg-kcg-border border-none text-white w-6 h-6 rounded cursor-pointer text-sm font-bold flex items-center justify-center"
|
||||||
|
>✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Video */}
|
||||||
|
<div className="relative w-full bg-black" style={{ aspectRatio: '16/9' }}>
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
className="w-full h-full object-contain"
|
||||||
|
muted autoPlay playsInline
|
||||||
|
/>
|
||||||
|
|
||||||
|
{status === 'loading' && (
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/80">
|
||||||
|
<div className="text-[28px] opacity-40 mb-2">📹</div>
|
||||||
|
<div className="text-[11px] text-kcg-muted font-mono">{t('cctv.connectingEllipsis')}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'error' && (
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/80">
|
||||||
|
<div className="text-[28px] opacity-40 mb-2">⚠️</div>
|
||||||
|
<div className="text-xs text-kcg-danger font-mono mb-2">{t('cctv.connectionFailed')}</div>
|
||||||
|
<a
|
||||||
|
href={cam.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-[10px] text-kcg-accent font-mono underline"
|
||||||
|
>{t('cctv.viewOnBadatime')}</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'playing' && (
|
||||||
|
<>
|
||||||
|
<div className="absolute top-2.5 left-2.5 flex items-center gap-1.5">
|
||||||
|
<span className="text-[10px] font-bold font-mono px-2 py-0.5 rounded bg-black/70 text-white">
|
||||||
|
{cam.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-[rgba(239,68,68,0.3)] text-[#f87171]">
|
||||||
|
● {t('cctv.rec')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="absolute bottom-2.5 left-2.5 text-[9px] font-mono px-2 py-0.5 rounded bg-black/70 text-kcg-muted">
|
||||||
|
{cam.lat.toFixed(4)}°N {cam.lng.toFixed(4)}°E · {t('cctv.khoa')}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer info */}
|
||||||
|
<div className="flex items-center justify-between px-3.5 py-1.5 bg-kcg-overlay border-t border-[#222] font-mono text-[9px] text-kcg-dim">
|
||||||
|
<span>{cam.lat.toFixed(4)}°N {cam.lng.toFixed(4)}°E</span>
|
||||||
|
<span>{t('cctv.khoaFull')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Marker, Popup } from 'react-map-gl/maplibre';
|
import { Marker, Popup } from 'react-map-gl/maplibre';
|
||||||
import { COAST_GUARD_FACILITIES, CG_TYPE_LABEL } from '../services/coastGuard';
|
import { COAST_GUARD_FACILITIES, CG_TYPE_LABEL } from '../services/coastGuard';
|
||||||
import type { CoastGuardFacility, CoastGuardType } from '../services/coastGuard';
|
import type { CoastGuardFacility, CoastGuardType } from '../services/coastGuard';
|
||||||
@ -25,32 +26,25 @@ function CoastGuardIcon({ type, size }: { type: CoastGuardType; size: number })
|
|||||||
const isVts = type === 'vts';
|
const isVts = type === 'vts';
|
||||||
|
|
||||||
if (isVts) {
|
if (isVts) {
|
||||||
// VTS: 레이더/안테나 아이콘
|
|
||||||
return (
|
return (
|
||||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke={color} strokeWidth="1.2" />
|
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke={color} strokeWidth="1.2" />
|
||||||
{/* 안테나 */}
|
|
||||||
<line x1="12" y1="18" x2="12" y2="10" stroke={color} strokeWidth="1.5" />
|
<line x1="12" y1="18" x2="12" y2="10" stroke={color} strokeWidth="1.5" />
|
||||||
<circle cx="12" cy="9" r="2" fill="none" stroke={color} strokeWidth="1" />
|
<circle cx="12" cy="9" r="2" fill="none" stroke={color} strokeWidth="1" />
|
||||||
{/* 전파 */}
|
|
||||||
<path d="M7 7 Q12 3 17 7" fill="none" stroke={color} strokeWidth="0.8" opacity="0.6" />
|
<path d="M7 7 Q12 3 17 7" fill="none" stroke={color} strokeWidth="0.8" opacity="0.6" />
|
||||||
<path d="M9 5.5 Q12 3 15 5.5" fill="none" stroke={color} strokeWidth="0.8" opacity="0.4" />
|
<path d="M9 5.5 Q12 3 15 5.5" fill="none" stroke={color} strokeWidth="0.8" opacity="0.4" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 해경 로고: 방패 + 앵커
|
|
||||||
return (
|
return (
|
||||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||||
{/* 방패 배경 */}
|
|
||||||
<path d="M12 2 L20 6 L20 13 Q20 19 12 22 Q4 19 4 13 L4 6 Z"
|
<path d="M12 2 L20 6 L20 13 Q20 19 12 22 Q4 19 4 13 L4 6 Z"
|
||||||
fill="rgba(0,0,0,0.6)" stroke={color} strokeWidth="1.2" />
|
fill="rgba(0,0,0,0.6)" stroke={color} strokeWidth="1.2" />
|
||||||
{/* 앵커 */}
|
|
||||||
<circle cx="12" cy="9" r="2" fill="none" stroke="#fff" strokeWidth="1" />
|
<circle cx="12" cy="9" r="2" fill="none" stroke="#fff" strokeWidth="1" />
|
||||||
<line x1="12" y1="11" x2="12" y2="18" stroke="#fff" strokeWidth="1" />
|
<line x1="12" y1="11" x2="12" y2="18" stroke="#fff" strokeWidth="1" />
|
||||||
<path d="M8 16 Q12 20 16 16" fill="none" stroke="#fff" strokeWidth="1" />
|
<path d="M8 16 Q12 20 16 16" fill="none" stroke="#fff" strokeWidth="1" />
|
||||||
<line x1="9" y1="13" x2="15" y2="13" stroke="#fff" strokeWidth="0.8" />
|
<line x1="9" y1="13" x2="15" y2="13" stroke="#fff" strokeWidth="0.8" />
|
||||||
{/* 별 (본청/지방청) */}
|
|
||||||
{(type === 'hq' || type === 'regional') && (
|
{(type === 'hq' || type === 'regional') && (
|
||||||
<circle cx="12" cy="9" r="1" fill={color} />
|
<circle cx="12" cy="9" r="1" fill={color} />
|
||||||
)}
|
)}
|
||||||
@ -60,6 +54,7 @@ function CoastGuardIcon({ type, size }: { type: CoastGuardType; size: number })
|
|||||||
|
|
||||||
export function CoastGuardLayer() {
|
export function CoastGuardLayer() {
|
||||||
const [selected, setSelected] = useState<CoastGuardFacility | null>(null);
|
const [selected, setSelected] = useState<CoastGuardFacility | null>(null);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -69,25 +64,23 @@ export function CoastGuardLayer() {
|
|||||||
<Marker key={f.id} longitude={f.lng} latitude={f.lat} anchor="center"
|
<Marker key={f.id} longitude={f.lng} latitude={f.lat} anchor="center"
|
||||||
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(f); }}>
|
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(f); }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
cursor: 'pointer', display: 'flex', flexDirection: 'column', alignItems: 'center',
|
cursor: 'pointer',
|
||||||
filter: `drop-shadow(0 0 3px ${TYPE_COLOR[f.type]}66)`,
|
filter: `drop-shadow(0 0 3px ${TYPE_COLOR[f.type]}66)`,
|
||||||
}}>
|
}} className="flex flex-col items-center">
|
||||||
<CoastGuardIcon type={f.type} size={size} />
|
<CoastGuardIcon type={f.type} size={size} />
|
||||||
{(f.type === 'hq' || f.type === 'regional') && (
|
{(f.type === 'hq' || f.type === 'regional') && (
|
||||||
<div style={{
|
<div style={{
|
||||||
fontSize: 6, color: '#fff', marginTop: 1,
|
fontSize: 6,
|
||||||
textShadow: `0 0 3px ${TYPE_COLOR[f.type]}, 0 0 2px #000`,
|
textShadow: `0 0 3px ${TYPE_COLOR[f.type]}, 0 0 2px #000`,
|
||||||
whiteSpace: 'nowrap', fontWeight: 700,
|
}} className="mt-px whitespace-nowrap font-bold text-white">
|
||||||
}}>
|
|
||||||
{f.name.replace('해양경찰청', '').replace('지방', '').trim() || '본청'}
|
{f.name.replace('해양경찰청', '').replace('지방', '').trim() || '본청'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{f.type === 'vts' && (
|
{f.type === 'vts' && (
|
||||||
<div style={{
|
<div style={{
|
||||||
fontSize: 5, color: '#da77f2', marginTop: 0,
|
fontSize: 5,
|
||||||
textShadow: '0 0 3px #da77f2, 0 0 2px #000',
|
textShadow: '0 0 3px #da77f2, 0 0 2px #000',
|
||||||
whiteSpace: 'nowrap', fontWeight: 700, letterSpacing: 0.5,
|
}} className="whitespace-nowrap font-bold tracking-wider text-[#da77f2]">
|
||||||
}}>
|
|
||||||
VTS
|
VTS
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -100,27 +93,23 @@ export function CoastGuardLayer() {
|
|||||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||||
onClose={() => setSelected(null)} closeOnClick={false}
|
onClose={() => setSelected(null)} closeOnClick={false}
|
||||||
anchor="bottom" maxWidth="280px" className="gl-popup">
|
anchor="bottom" maxWidth="280px" className="gl-popup">
|
||||||
<div style={{ fontFamily: 'monospace', fontSize: 12, minWidth: 200 }}>
|
<div className="min-w-[200px] font-mono text-xs">
|
||||||
<div style={{
|
<div style={{
|
||||||
background: TYPE_COLOR[selected.type], color: '#000',
|
background: TYPE_COLOR[selected.type],
|
||||||
padding: '4px 8px', borderRadius: '4px 4px 0 0',
|
}} className="-mx-2.5 -mt-2.5 mb-2 flex items-center gap-1.5 rounded-t px-2 py-1 text-[13px] font-bold text-black">
|
||||||
margin: '-10px -10px 8px -10px',
|
|
||||||
fontWeight: 700, fontSize: 13,
|
|
||||||
display: 'flex', alignItems: 'center', gap: 6,
|
|
||||||
}}>
|
|
||||||
{selected.name}
|
{selected.name}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
|
<div className="mb-1.5 flex flex-wrap gap-1">
|
||||||
<span style={{
|
<span style={{
|
||||||
background: TYPE_COLOR[selected.type], color: '#000',
|
background: TYPE_COLOR[selected.type],
|
||||||
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
}} className="rounded-sm px-1.5 py-px text-[10px] font-bold text-black">
|
||||||
}}>{CG_TYPE_LABEL[selected.type]}</span>
|
{CG_TYPE_LABEL[selected.type]}
|
||||||
<span style={{
|
</span>
|
||||||
background: '#1a1a2e', color: '#4dabf7',
|
<span className="rounded-sm bg-kcg-card px-1.5 py-px text-[10px] font-bold text-[#4dabf7]">
|
||||||
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
{t('coastGuard.agency')}
|
||||||
}}>해양경찰청</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 9, color: '#666' }}>
|
<div className="text-[9px] text-kcg-dim">
|
||||||
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
|
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { GeoEvent, Ship } from '../types';
|
import type { GeoEvent, Ship } from '../types';
|
||||||
import type { OsintItem } from '../services/osint';
|
import type { OsintItem } from '../services/osint';
|
||||||
|
|
||||||
@ -250,16 +251,17 @@ const TYPE_LABELS: Record<GeoEvent['type'], string> = {
|
|||||||
intercept: 'INTERCEPT',
|
intercept: 'INTERCEPT',
|
||||||
alert: 'ALERT',
|
alert: 'ALERT',
|
||||||
impact: 'IMPACT',
|
impact: 'IMPACT',
|
||||||
|
osint: 'OSINT',
|
||||||
};
|
};
|
||||||
|
|
||||||
const TYPE_COLORS: Record<GeoEvent['type'], string> = {
|
const TYPE_COLORS: Record<GeoEvent['type'], string> = {
|
||||||
airstrike: '#ef4444',
|
airstrike: 'var(--kcg-event-airstrike)',
|
||||||
explosion: '#f97316',
|
explosion: 'var(--kcg-event-explosion)',
|
||||||
missile_launch: '#eab308',
|
missile_launch: 'var(--kcg-event-missile)',
|
||||||
intercept: '#3b82f6',
|
intercept: 'var(--kcg-event-intercept)',
|
||||||
alert: '#a855f7',
|
alert: 'var(--kcg-event-alert)',
|
||||||
impact: '#ff0000',
|
impact: 'var(--kcg-event-impact)',
|
||||||
osint: '#06b6d4',
|
osint: 'var(--kcg-event-osint)',
|
||||||
};
|
};
|
||||||
|
|
||||||
// MarineTraffic-style ship type classification
|
// MarineTraffic-style ship type classification
|
||||||
@ -282,54 +284,81 @@ function getShipMTCategory(typecode?: string, category?: string): string {
|
|||||||
return 'unspecified';
|
return 'unspecified';
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarineTraffic-style category labels and colors
|
// MarineTraffic-style category colors (labels come from i18n)
|
||||||
const MT_CATEGORIES: Record<string, { label: string; color: string }> = {
|
const MT_CATEGORY_COLORS: Record<string, string> = {
|
||||||
cargo: { label: '화물선', color: '#8bc34a' }, // green
|
cargo: '#8bc34a',
|
||||||
tanker: { label: '유조선', color: '#e91e63' }, // red/pink
|
tanker: '#e91e63',
|
||||||
passenger: { label: '여객선', color: '#2196f3' }, // blue
|
passenger: '#2196f3',
|
||||||
high_speed: { label: '고속선', color: '#ff9800' }, // orange
|
high_speed: '#ff9800',
|
||||||
tug_special: { label: '예인선/특수선', color: '#00bcd4' }, // teal
|
tug_special: '#00bcd4',
|
||||||
fishing: { label: '어선', color: '#ff5722' }, // deep orange
|
fishing: '#ff5722',
|
||||||
pleasure: { label: '레저선', color: '#9c27b0' }, // purple
|
pleasure: '#9c27b0',
|
||||||
military: { label: '군함', color: '#607d8b' }, // blue-grey
|
military: '#607d8b',
|
||||||
unspecified: { label: '미분류', color: '#9e9e9e' }, // grey
|
unspecified: '#9e9e9e',
|
||||||
};
|
};
|
||||||
|
|
||||||
const NEWS_CATEGORY_STYLE: Record<BreakingNews['category'], { icon: string; color: string; label: string }> = {
|
const NEWS_CATEGORY_ICONS: Record<BreakingNews['category'], string> = {
|
||||||
trump: { icon: '🇺🇸', color: '#ef4444', label: '트럼프' },
|
trump: '\u{1F1FA}\u{1F1F8}',
|
||||||
oil: { icon: '🛢️', color: '#f59e0b', label: '유가' },
|
oil: '\u{1F6E2}\u{FE0F}',
|
||||||
diplomacy: { icon: '🌐', color: '#8b5cf6', label: '외교' },
|
diplomacy: '\u{1F310}',
|
||||||
economy: { icon: '📊', color: '#3b82f6', label: '경제' },
|
economy: '\u{1F4CA}',
|
||||||
};
|
};
|
||||||
|
|
||||||
// OSINT category styles
|
// OSINT category icons (labels come from i18n)
|
||||||
const OSINT_CAT_STYLE: Record<string, { icon: string; color: string; label: string }> = {
|
const OSINT_CAT_ICONS: Record<string, string> = {
|
||||||
military: { icon: '🎯', color: '#ef4444', label: '군사' },
|
military: '\u{1F3AF}',
|
||||||
oil: { icon: '🛢', color: '#f59e0b', label: '에너지' },
|
oil: '\u{1F6E2}',
|
||||||
diplomacy: { icon: '🌐', color: '#8b5cf6', label: '외교' },
|
diplomacy: '\u{1F310}',
|
||||||
shipping: { icon: '🚢', color: '#06b6d4', label: '해운' },
|
shipping: '\u{1F6A2}',
|
||||||
nuclear: { icon: '☢', color: '#f97316', label: '핵' },
|
nuclear: '\u{2622}',
|
||||||
maritime_accident: { icon: '🚨', color: '#ef4444', label: '해양사고' },
|
maritime_accident: '\u{1F6A8}',
|
||||||
fishing: { icon: '🐟', color: '#22c55e', label: '어선/수산' },
|
fishing: '\u{1F41F}',
|
||||||
maritime_traffic: { icon: '🚢', color: '#3b82f6', label: '해상교통' },
|
maritime_traffic: '\u{1F6A2}',
|
||||||
general: { icon: '📰', color: '#6b7280', label: '일반' },
|
general: '\u{1F4F0}',
|
||||||
|
};
|
||||||
|
|
||||||
|
// OSINT category colors
|
||||||
|
const OSINT_CAT_COLORS: Record<string, string> = {
|
||||||
|
military: '#ef4444',
|
||||||
|
oil: '#f59e0b',
|
||||||
|
diplomacy: '#8b5cf6',
|
||||||
|
shipping: '#06b6d4',
|
||||||
|
nuclear: '#f97316',
|
||||||
|
maritime_accident: '#ef4444',
|
||||||
|
fishing: '#22c55e',
|
||||||
|
maritime_traffic: '#3b82f6',
|
||||||
|
general: '#6b7280',
|
||||||
|
};
|
||||||
|
|
||||||
|
// NEWS category colors
|
||||||
|
const NEWS_CATEGORY_COLORS: Record<BreakingNews['category'], string> = {
|
||||||
|
trump: '#ef4444',
|
||||||
|
oil: '#f59e0b',
|
||||||
|
diplomacy: '#8b5cf6',
|
||||||
|
economy: '#3b82f6',
|
||||||
};
|
};
|
||||||
|
|
||||||
const EMPTY_OSINT: OsintItem[] = [];
|
const EMPTY_OSINT: OsintItem[] = [];
|
||||||
const EMPTY_SHIPS: import('../types').Ship[] = [];
|
const EMPTY_SHIPS: import('../types').Ship[] = [];
|
||||||
|
|
||||||
function timeAgo(ts: number): string {
|
function useTimeAgo() {
|
||||||
|
const { t } = useTranslation('common');
|
||||||
|
return (ts: number): string => {
|
||||||
const diff = Date.now() - ts;
|
const diff = Date.now() - ts;
|
||||||
const mins = Math.floor(diff / 60000);
|
const mins = Math.floor(diff / 60000);
|
||||||
if (mins < 1) return '방금';
|
if (mins < 1) return t('time.justNow');
|
||||||
if (mins < 60) return `${mins}분 전`;
|
if (mins < 60) return t('time.minutesAgo', { count: mins });
|
||||||
const hours = Math.floor(mins / 60);
|
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);
|
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) {
|
export function EventLog({ events, currentTime, totalShipCount: _totalShipCount, koreanShips, koreanShipsByCategory: _koreanShipsByCategory, chineseShips = [], osintFeed = EMPTY_OSINT, isLive = false, dashboardTab = 'iran', onTabChange: _onTabChange, ships = EMPTY_SHIPS }: Props) {
|
||||||
|
const { t } = useTranslation(['common', 'events', 'ships']);
|
||||||
|
const timeAgo = useTimeAgo();
|
||||||
|
|
||||||
const visibleEvents = useMemo(
|
const visibleEvents = useMemo(
|
||||||
() => events.filter(e => e.timestamp <= currentTime).reverse(),
|
() => events.filter(e => e.timestamp <= currentTime).reverse(),
|
||||||
[events, currentTime],
|
[events, currentTime],
|
||||||
@ -374,17 +403,18 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
|
|||||||
<div className="breaking-news-section">
|
<div className="breaking-news-section">
|
||||||
<div className="breaking-news-header">
|
<div className="breaking-news-header">
|
||||||
<span className="breaking-flash">BREAKING</span>
|
<span className="breaking-flash">BREAKING</span>
|
||||||
<span className="breaking-title">속보 / 주요 뉴스</span>
|
<span className="breaking-title">{t('events:news.breakingTitle')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="breaking-news-list">
|
<div className="breaking-news-list">
|
||||||
{visibleNews.map(n => {
|
{visibleNews.map(n => {
|
||||||
const style = NEWS_CATEGORY_STYLE[n.category];
|
const catColor = NEWS_CATEGORY_COLORS[n.category];
|
||||||
|
const catIcon = NEWS_CATEGORY_ICONS[n.category];
|
||||||
const isRecent = currentTime - n.timestamp < 2 * HOUR_MS;
|
const isRecent = currentTime - n.timestamp < 2 * HOUR_MS;
|
||||||
return (
|
return (
|
||||||
<div key={n.id} className={`breaking-news-item${isRecent ? ' breaking-new' : ''}`}>
|
<div key={n.id} className={`breaking-news-item${isRecent ? ' breaking-new' : ''}`}>
|
||||||
<div className="breaking-news-top">
|
<div className="breaking-news-top">
|
||||||
<span className="breaking-cat-tag" style={{ background: style.color }}>
|
<span className="breaking-cat-tag" style={{ background: catColor }}>
|
||||||
{style.icon} {style.label}
|
{catIcon} {t(`events:news.categoryLabel.${n.category}`)}
|
||||||
</span>
|
</span>
|
||||||
<span className="breaking-news-time">
|
<span className="breaking-news-time">
|
||||||
{new Date(n.timestamp + 9 * 3600_000).toISOString().slice(5, 16).replace('T', ' ')}
|
{new Date(n.timestamp + 9 * 3600_000).toISOString().slice(5, 16).replace('T', ' ')}
|
||||||
@ -403,24 +433,26 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
|
|||||||
{koreanShips.length > 0 && (
|
{koreanShips.length > 0 && (
|
||||||
<div className="iran-ship-summary">
|
<div className="iran-ship-summary">
|
||||||
<div className="area-ship-header">
|
<div className="area-ship-header">
|
||||||
<span className="area-ship-icon">🇰🇷</span>
|
<span className="area-ship-icon">{'\u{1F1F0}\u{1F1F7}'}</span>
|
||||||
<span className="area-ship-title">한국 선박 현황</span>
|
<span className="area-ship-title">{t('ships:shipStatus.koreanTitle')}</span>
|
||||||
<span className="area-ship-total" style={{ color: '#ef4444' }}>{koreanShips.length}척</span>
|
<span className="area-ship-total text-kcg-danger">{koreanShips.length}{t('common:units.vessels')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="iran-mil-list">
|
<div className="iran-mil-list">
|
||||||
{koreanShips.slice(0, 30).map(s => {
|
{koreanShips.slice(0, 30).map(s => {
|
||||||
const mt = MT_CATEGORIES[getShipMTCategory(s.typecode, s.category)] || { label: '기타', color: '#888' };
|
const cat = getShipMTCategory(s.typecode, s.category);
|
||||||
|
const mtColor = MT_CATEGORY_COLORS[cat] || '#888';
|
||||||
|
const mtLabel = t(`ships:mtType.${cat}`, { defaultValue: t('ships:mtType.unspecified') });
|
||||||
return (
|
return (
|
||||||
<div key={s.mmsi} className="iran-mil-item">
|
<div key={s.mmsi} className="iran-mil-item">
|
||||||
<span className="iran-mil-flag">🇰🇷</span>
|
<span className="iran-mil-flag">{'\u{1F1F0}\u{1F1F7}'}</span>
|
||||||
<span className="iran-mil-name">{s.name}</span>
|
<span className="iran-mil-name">{s.name}</span>
|
||||||
<span className="iran-mil-cat" style={{ color: mt.color, background: `${mt.color}22` }}>
|
<span className="iran-mil-cat" style={{ color: mtColor, background: `${mtColor}22` }}>
|
||||||
{mt.label}
|
{mtLabel}
|
||||||
</span>
|
</span>
|
||||||
{s.speed != null && s.speed > 0.5 ? (
|
{s.speed != null && s.speed > 0.5 ? (
|
||||||
<span style={{ fontSize: 9, color: '#22c55e', marginLeft: 'auto' }}>{s.speed.toFixed(1)}kn</span>
|
<span className="ml-auto text-[9px] text-kcg-success">{s.speed.toFixed(1)}kn</span>
|
||||||
) : (
|
) : (
|
||||||
<span style={{ fontSize: 9, color: '#ef4444', marginLeft: 'auto' }}>정박</span>
|
<span className="ml-auto text-[9px] text-kcg-danger">{t('ships:status.anchored')}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -434,12 +466,13 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
|
|||||||
<>
|
<>
|
||||||
<div className="osint-header">
|
<div className="osint-header">
|
||||||
<span className="osint-live-dot" />
|
<span className="osint-live-dot" />
|
||||||
<span className="osint-title">OSINT LIVE FEED</span>
|
<span className="osint-title">{t('events:osint.liveTitle')}</span>
|
||||||
<span className="osint-count">{osintFeed.length}</span>
|
<span className="osint-count">{osintFeed.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="osint-list">
|
<div className="osint-list">
|
||||||
{osintFeed.map(item => {
|
{osintFeed.map(item => {
|
||||||
const style = OSINT_CAT_STYLE[item.category] || OSINT_CAT_STYLE.general;
|
const catColor = OSINT_CAT_COLORS[item.category] || OSINT_CAT_COLORS.general;
|
||||||
|
const catIcon = OSINT_CAT_ICONS[item.category] || OSINT_CAT_ICONS.general;
|
||||||
const isRecent = Date.now() - item.timestamp < 3600_000;
|
const isRecent = Date.now() - item.timestamp < 3600_000;
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
@ -450,8 +483,8 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
<div className="osint-item-top">
|
<div className="osint-item-top">
|
||||||
<span className="osint-cat-tag" style={{ background: style.color }}>
|
<span className="osint-cat-tag" style={{ background: catColor }}>
|
||||||
{style.icon} {style.label}
|
{catIcon} {t(`events:osint.categoryLabel.${item.category}`, { defaultValue: t('events:osint.categoryLabel.general') })}
|
||||||
</span>
|
</span>
|
||||||
{item.language === 'ko' && <span className="osint-lang-tag">KR</span>}
|
{item.language === 'ko' && <span className="osint-lang-tag">KR</span>}
|
||||||
<span className="osint-time">{timeAgo(item.timestamp)}</span>
|
<span className="osint-time">{timeAgo(item.timestamp)}</span>
|
||||||
@ -467,23 +500,23 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
|
|||||||
{isLive && osintFeed.length === 0 && (
|
{isLive && osintFeed.length === 0 && (
|
||||||
<div className="osint-header">
|
<div className="osint-header">
|
||||||
<span className="osint-live-dot" />
|
<span className="osint-live-dot" />
|
||||||
<span className="osint-title">OSINT LIVE FEED</span>
|
<span className="osint-title">{t('events:osint.liveTitle')}</span>
|
||||||
<span className="osint-loading">Loading...</span>
|
<span className="osint-loading">{t('events:osint.loading')}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Event Log (replay mode) */}
|
{/* Event Log (replay mode) */}
|
||||||
{!isLive && (
|
{!isLive && (
|
||||||
<>
|
<>
|
||||||
<h3>Event Log</h3>
|
<h3>{t('events:log.title')}</h3>
|
||||||
<div className="event-list">
|
<div className="event-list">
|
||||||
{visibleEvents.length === 0 && (
|
{visibleEvents.length === 0 && (
|
||||||
<div className="event-empty">No events yet. Press play to start replay.</div>
|
<div className="event-empty">{t('events:log.noEvents')}</div>
|
||||||
)}
|
)}
|
||||||
{visibleEvents.map(e => {
|
{visibleEvents.map(e => {
|
||||||
const isNew = currentTime - e.timestamp < 86_400_000;
|
const isNew = currentTime - e.timestamp < 86_400_000;
|
||||||
return (
|
return (
|
||||||
<div key={e.id} className="event-item" style={isNew ? { borderLeft: '3px solid #ff0000' } : undefined}>
|
<div key={e.id} className="event-item" style={isNew ? { borderLeft: '3px solid var(--kcg-event-impact)' } : undefined}>
|
||||||
<span
|
<span
|
||||||
className="event-tag"
|
className="event-tag"
|
||||||
style={{ backgroundColor: TYPE_COLORS[e.type] }}
|
style={{ backgroundColor: TYPE_COLORS[e.type] }}
|
||||||
@ -493,10 +526,7 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
|
|||||||
<div className="event-content">
|
<div className="event-content">
|
||||||
<div className="event-label">
|
<div className="event-label">
|
||||||
{isNew && (
|
{isNew && (
|
||||||
<span style={{
|
<span className="inline-block rounded-sm bg-[var(--kcg-event-impact)] px-1 mr-1 text-[9px] font-bold text-white">{t('events:log.new')}</span>
|
||||||
background: '#ff0000', color: '#fff', padding: '0 4px',
|
|
||||||
borderRadius: 2, fontSize: 9, marginRight: 4, fontWeight: 700,
|
|
||||||
}}>NEW</span>
|
|
||||||
)}
|
)}
|
||||||
{e.label}
|
{e.label}
|
||||||
</div>
|
</div>
|
||||||
@ -523,20 +553,21 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
|
|||||||
<>
|
<>
|
||||||
{/* 한국 속보 (replay) */}
|
{/* 한국 속보 (replay) */}
|
||||||
{visibleNewsKR.length > 0 && (
|
{visibleNewsKR.length > 0 && (
|
||||||
<div className="breaking-news-section" style={{ borderLeftColor: '#3b82f6' }}>
|
<div className="breaking-news-section" style={{ borderLeftColor: 'var(--kcg-accent)' }}>
|
||||||
<div className="breaking-news-header">
|
<div className="breaking-news-header">
|
||||||
<span className="breaking-flash" style={{ background: '#3b82f6' }}>속보</span>
|
<span className="breaking-flash bg-kcg-accent">{t('events:news.breaking')}</span>
|
||||||
<span className="breaking-title">🇰🇷 한국 주요 뉴스</span>
|
<span className="breaking-title">{'\u{1F1F0}\u{1F1F7}'} {t('events:news.koreaTitle')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="breaking-news-list">
|
<div className="breaking-news-list">
|
||||||
{visibleNewsKR.map(n => {
|
{visibleNewsKR.map(n => {
|
||||||
const style = NEWS_CATEGORY_STYLE[n.category];
|
const catColor = NEWS_CATEGORY_COLORS[n.category];
|
||||||
|
const catIcon = NEWS_CATEGORY_ICONS[n.category];
|
||||||
const isRecent = currentTime - n.timestamp < 2 * HOUR_MS;
|
const isRecent = currentTime - n.timestamp < 2 * HOUR_MS;
|
||||||
return (
|
return (
|
||||||
<div key={n.id} className={`breaking-news-item${isRecent ? ' breaking-new' : ''}`}>
|
<div key={n.id} className={`breaking-news-item${isRecent ? ' breaking-new' : ''}`}>
|
||||||
<div className="breaking-news-top">
|
<div className="breaking-news-top">
|
||||||
<span className="breaking-cat-tag" style={{ background: style.color }}>
|
<span className="breaking-cat-tag" style={{ background: catColor }}>
|
||||||
{style.icon} {style.label}
|
{catIcon} {t(`events:news.categoryLabel.${n.category}`)}
|
||||||
</span>
|
</span>
|
||||||
<span className="breaking-news-time">
|
<span className="breaking-news-time">
|
||||||
{new Date(n.timestamp + 9 * 3600_000).toISOString().slice(5, 16).replace('T', ' ')}
|
{new Date(n.timestamp + 9 * 3600_000).toISOString().slice(5, 16).replace('T', ' ')}
|
||||||
@ -554,52 +585,41 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
|
|||||||
{/* 한국 선박 현황 — 선종별 분류 */}
|
{/* 한국 선박 현황 — 선종별 분류 */}
|
||||||
<div className="iran-ship-summary">
|
<div className="iran-ship-summary">
|
||||||
<div className="area-ship-header">
|
<div className="area-ship-header">
|
||||||
<span className="area-ship-icon">🇰🇷</span>
|
<span className="area-ship-icon">{'\u{1F1F0}\u{1F1F7}'}</span>
|
||||||
<span className="area-ship-title">한국 선박 현황</span>
|
<span className="area-ship-title">{t('ships:shipStatus.koreanTitle')}</span>
|
||||||
<span className="area-ship-total">{koreanShips.length}척</span>
|
<span className="area-ship-total">{koreanShips.length}{t('common:units.vessels')}</span>
|
||||||
</div>
|
</div>
|
||||||
{koreanShips.length > 0 && (() => {
|
{koreanShips.length > 0 && (() => {
|
||||||
// 선종별 그룹핑
|
|
||||||
const groups: Record<string, Ship[]> = {};
|
const groups: Record<string, Ship[]> = {};
|
||||||
for (const s of koreanShips) {
|
for (const s of koreanShips) {
|
||||||
const cat = getShipMTCategory(s.typecode, s.category);
|
const cat = getShipMTCategory(s.typecode, s.category);
|
||||||
if (!groups[cat]) groups[cat] = [];
|
if (!groups[cat]) groups[cat] = [];
|
||||||
groups[cat].push(s);
|
groups[cat].push(s);
|
||||||
}
|
}
|
||||||
// 정렬 순서: 군함 → 유조선 → 화물선 → 여객선 → 어선 → 예인선 → 기타
|
|
||||||
const order = ['military', 'tanker', 'cargo', 'passenger', 'fishing', 'tug_special', 'high_speed', 'pleasure', 'unspecified'];
|
const order = ['military', 'tanker', 'cargo', 'passenger', 'fishing', 'tug_special', 'high_speed', 'pleasure', 'unspecified'];
|
||||||
const sorted = order.filter(k => groups[k]?.length);
|
const sorted = order.filter(k => groups[k]?.length);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, padding: '4px 0' }}>
|
<div className="flex flex-col gap-0.5 py-1">
|
||||||
{sorted.map(cat => {
|
{sorted.map(cat => {
|
||||||
const mt = MT_CATEGORIES[cat] || MT_CATEGORIES.unspecified;
|
const mtColor = MT_CATEGORY_COLORS[cat] || MT_CATEGORY_COLORS.unspecified;
|
||||||
|
const mtLabel = t(`ships:mtType.${cat}`, { defaultValue: t('ships:mtType.unspecified') });
|
||||||
const list = groups[cat];
|
const list = groups[cat];
|
||||||
const moving = list.filter(s => s.speed > 0.5).length;
|
const moving = list.filter(s => s.speed > 0.5).length;
|
||||||
const anchored = list.length - moving;
|
const anchored = list.length - moving;
|
||||||
return (
|
return (
|
||||||
<div key={cat} style={{
|
<div key={cat} className="flex items-center gap-1.5 px-2 py-1 rounded-r" style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 6,
|
background: `${mtColor}0a`,
|
||||||
padding: '4px 8px',
|
borderLeft: `3px solid ${mtColor}`,
|
||||||
background: `${mt.color}0a`,
|
|
||||||
borderLeft: `3px solid ${mt.color}`,
|
|
||||||
borderRadius: '0 4px 4px 0',
|
|
||||||
}}>
|
}}>
|
||||||
<span style={{
|
<span className="size-2 shrink-0 rounded-full" style={{ background: mtColor }} />
|
||||||
width: 8, height: 8, borderRadius: '50%',
|
<span className="min-w-[70px] text-[11px] font-bold font-mono" style={{ color: mtColor }}>{mtLabel}</span>
|
||||||
background: mt.color, flexShrink: 0,
|
<span className="text-[13px] font-bold font-mono text-kcg-text">
|
||||||
}} />
|
{list.length}<span className="text-[9px] font-normal text-kcg-muted">{t('common:units.vessels')}</span>
|
||||||
<span style={{
|
</span>
|
||||||
fontSize: 11, fontWeight: 700, color: mt.color,
|
<span className="ml-auto flex gap-1.5 text-[9px] font-mono">
|
||||||
minWidth: 70, fontFamily: 'monospace',
|
{moving > 0 && <span className="text-kcg-success">{t('ships:status.underway')} {moving}</span>}
|
||||||
}}>{mt.label}</span>
|
{anchored > 0 && <span className="text-kcg-danger">{t('ships:status.anchored')} {anchored}</span>}
|
||||||
<span style={{
|
|
||||||
fontSize: 13, fontWeight: 700, color: '#fff',
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
}}>{list.length}<span style={{ fontSize: 9, color: '#888', fontWeight: 400 }}>척</span></span>
|
|
||||||
<span style={{ marginLeft: 'auto', display: 'flex', gap: 6, fontSize: 9, fontFamily: 'monospace' }}>
|
|
||||||
{moving > 0 && <span style={{ color: '#22c55e' }}>항해 {moving}</span>}
|
|
||||||
{anchored > 0 && <span style={{ color: '#ef4444' }}>정박 {anchored}</span>}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -612,9 +632,9 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
|
|||||||
{/* 중국 선박 현황 */}
|
{/* 중국 선박 현황 */}
|
||||||
<div className="iran-ship-summary">
|
<div className="iran-ship-summary">
|
||||||
<div className="area-ship-header">
|
<div className="area-ship-header">
|
||||||
<span className="area-ship-icon">🇨🇳</span>
|
<span className="area-ship-icon">{'\u{1F1E8}\u{1F1F3}'}</span>
|
||||||
<span className="area-ship-title">중국 선박 현황</span>
|
<span className="area-ship-title">{t('ships:shipStatus.chineseTitle')}</span>
|
||||||
<span className="area-ship-total">{chineseShips.length}척</span>
|
<span className="area-ship-total">{chineseShips.length}{t('common:units.vessels')}</span>
|
||||||
</div>
|
</div>
|
||||||
{chineseShips.length > 0 && (() => {
|
{chineseShips.length > 0 && (() => {
|
||||||
const groups: Record<string, Ship[]> = {};
|
const groups: Record<string, Ship[]> = {};
|
||||||
@ -628,49 +648,34 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
|
|||||||
const fishingCount = groups['fishing']?.length || 0;
|
const fishingCount = groups['fishing']?.length || 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, padding: '4px 0' }}>
|
<div className="flex flex-col gap-0.5 py-1">
|
||||||
{fishingCount > 0 && (
|
{fishingCount > 0 && (
|
||||||
<div style={{
|
<div className="flex items-center gap-1.5 px-2 py-1.5 mb-0.5 rounded border border-kcg-danger/30 bg-kcg-danger/10">
|
||||||
display: 'flex', alignItems: 'center', gap: 6,
|
<span className="text-sm">{'\u{1F6A8}'}</span>
|
||||||
padding: '6px 8px', marginBottom: 2,
|
<span className="text-[11px] font-bold font-mono text-kcg-danger">
|
||||||
background: 'rgba(239,68,68,0.1)',
|
{t('ships:shipStatus.chineseFishingAlert', { count: fishingCount })}
|
||||||
border: '1px solid rgba(239,68,68,0.3)',
|
|
||||||
borderRadius: 4,
|
|
||||||
}}>
|
|
||||||
<span style={{ fontSize: 14 }}>🚨</span>
|
|
||||||
<span style={{ fontSize: 11, fontWeight: 700, color: '#ef4444', fontFamily: 'monospace' }}>
|
|
||||||
중국어선 {fishingCount}척 우리 해역 근접
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{sorted.map(cat => {
|
{sorted.map(cat => {
|
||||||
const mt = MT_CATEGORIES[cat] || MT_CATEGORIES.unspecified;
|
const mtColor = MT_CATEGORY_COLORS[cat] || MT_CATEGORY_COLORS.unspecified;
|
||||||
|
const mtLabel = t(`ships:mtType.${cat}`, { defaultValue: t('ships:mtType.unspecified') });
|
||||||
const list = groups[cat];
|
const list = groups[cat];
|
||||||
const moving = list.filter(s => s.speed > 0.5).length;
|
const moving = list.filter(s => s.speed > 0.5).length;
|
||||||
const anchored = list.length - moving;
|
const anchored = list.length - moving;
|
||||||
return (
|
return (
|
||||||
<div key={cat} style={{
|
<div key={cat} className="flex items-center gap-1.5 px-2 py-1 rounded-r" style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 6,
|
background: `${mtColor}0a`,
|
||||||
padding: '4px 8px',
|
borderLeft: `3px solid ${mtColor}`,
|
||||||
background: `${mt.color}0a`,
|
|
||||||
borderLeft: `3px solid ${mt.color}`,
|
|
||||||
borderRadius: '0 4px 4px 0',
|
|
||||||
}}>
|
}}>
|
||||||
<span style={{
|
<span className="size-2 shrink-0 rounded-full" style={{ background: mtColor }} />
|
||||||
width: 8, height: 8, borderRadius: '50%',
|
<span className="min-w-[70px] text-[11px] font-bold font-mono" style={{ color: mtColor }}>{mtLabel}</span>
|
||||||
background: mt.color, flexShrink: 0,
|
<span className="text-[13px] font-bold font-mono text-kcg-text">
|
||||||
}} />
|
{list.length}<span className="text-[9px] font-normal text-kcg-muted">{t('common:units.vessels')}</span>
|
||||||
<span style={{
|
</span>
|
||||||
fontSize: 11, fontWeight: 700, color: mt.color,
|
<span className="ml-auto flex gap-1.5 text-[9px] font-mono">
|
||||||
minWidth: 70, fontFamily: 'monospace',
|
{moving > 0 && <span className="text-kcg-success">{t('ships:status.underway')} {moving}</span>}
|
||||||
}}>{mt.label}</span>
|
{anchored > 0 && <span className="text-kcg-danger">{t('ships:status.anchored')} {anchored}</span>}
|
||||||
<span style={{
|
|
||||||
fontSize: 13, fontWeight: 700, color: '#fff',
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
}}>{list.length}<span style={{ fontSize: 9, color: '#888', fontWeight: 400 }}>척</span></span>
|
|
||||||
<span style={{ marginLeft: 'auto', display: 'flex', gap: 6, fontSize: 9, fontFamily: 'monospace' }}>
|
|
||||||
{moving > 0 && <span style={{ color: '#22c55e' }}>항해 {moving}</span>}
|
|
||||||
{anchored > 0 && <span style={{ color: '#ef4444' }}>정박 {anchored}</span>}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -685,7 +690,7 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
|
|||||||
<>
|
<>
|
||||||
<div className="osint-header">
|
<div className="osint-header">
|
||||||
<span className="osint-live-dot" />
|
<span className="osint-live-dot" />
|
||||||
<span className="osint-title">🇰🇷 OSINT LIVE</span>
|
<span className="osint-title">{'\u{1F1F0}\u{1F1F7}'} {t('events:osint.koreaLiveTitle')}</span>
|
||||||
<span className="osint-count">{(() => {
|
<span className="osint-count">{(() => {
|
||||||
const filtered = osintFeed.filter(i => i.category !== 'general' && i.category !== 'oil');
|
const filtered = osintFeed.filter(i => i.category !== 'general' && i.category !== 'oil');
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
@ -708,7 +713,8 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
})().map(item => {
|
})().map(item => {
|
||||||
const style = OSINT_CAT_STYLE[item.category] || OSINT_CAT_STYLE.general;
|
const catColor = OSINT_CAT_COLORS[item.category] || OSINT_CAT_COLORS.general;
|
||||||
|
const catIcon = OSINT_CAT_ICONS[item.category] || OSINT_CAT_ICONS.general;
|
||||||
const isRecent = Date.now() - item.timestamp < 3600_000;
|
const isRecent = Date.now() - item.timestamp < 3600_000;
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
@ -719,8 +725,8 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
<div className="osint-item-top">
|
<div className="osint-item-top">
|
||||||
<span className="osint-cat-tag" style={{ background: style.color }}>
|
<span className="osint-cat-tag" style={{ background: catColor }}>
|
||||||
{style.icon} {style.label}
|
{catIcon} {t(`events:osint.categoryLabel.${item.category}`, { defaultValue: t('events:osint.categoryLabel.general') })}
|
||||||
</span>
|
</span>
|
||||||
{item.language === 'ko' && <span className="osint-lang-tag">KR</span>}
|
{item.language === 'ko' && <span className="osint-lang-tag">KR</span>}
|
||||||
<span className="osint-time">{timeAgo(item.timestamp)}</span>
|
<span className="osint-time">{timeAgo(item.timestamp)}</span>
|
||||||
@ -736,8 +742,8 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
|
|||||||
{osintFeed.length === 0 && (
|
{osintFeed.length === 0 && (
|
||||||
<div className="osint-header">
|
<div className="osint-header">
|
||||||
<span className="osint-live-dot" />
|
<span className="osint-live-dot" />
|
||||||
<span className="osint-title">🇰🇷 OSINT LIVE</span>
|
<span className="osint-title">{'\u{1F1F0}\u{1F1F7}'} {t('events:osint.koreaLiveTitle')}</span>
|
||||||
<span className="osint-loading">Loading...</span>
|
<span className="osint-loading">{t('events:osint.loading')}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { GeoEvent } from '../types';
|
import type { GeoEvent } from '../types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -21,21 +22,21 @@ const TYPE_COLORS: Record<string, string> = {
|
|||||||
osint: '#06b6d4',
|
osint: '#06b6d4',
|
||||||
};
|
};
|
||||||
|
|
||||||
const TYPE_LABELS_KO: Record<string, string> = {
|
const TYPE_KEYS: Record<string, string> = {
|
||||||
airstrike: '공습',
|
airstrike: 'event.airstrike',
|
||||||
explosion: '폭발',
|
explosion: 'event.explosion',
|
||||||
missile_launch: '미사일',
|
missile_launch: 'event.missileLaunch',
|
||||||
intercept: '요격',
|
intercept: 'event.intercept',
|
||||||
alert: '경보',
|
alert: 'event.alert',
|
||||||
impact: '피격',
|
impact: 'event.impact',
|
||||||
osint: 'OSINT',
|
osint: 'event.osint',
|
||||||
};
|
};
|
||||||
|
|
||||||
const SOURCE_LABELS_KO: Record<string, string> = {
|
const SOURCE_KEYS: Record<string, string> = {
|
||||||
US: '미국',
|
US: 'source.US',
|
||||||
IL: '이스라엘',
|
IL: 'source.IL',
|
||||||
IR: '이란',
|
IR: 'source.IR',
|
||||||
proxy: '대리세력',
|
proxy: 'source.proxy',
|
||||||
};
|
};
|
||||||
|
|
||||||
interface EventGroup {
|
interface EventGroup {
|
||||||
@ -44,10 +45,14 @@ interface EventGroup {
|
|||||||
events: GeoEvent[];
|
events: GeoEvent[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const DAY_NAMES = ['일', '월', '화', '수', '목', '금', '토'];
|
const DAY_NAME_KEYS = [
|
||||||
|
'dayNames.sun', 'dayNames.mon', 'dayNames.tue', 'dayNames.wed',
|
||||||
|
'dayNames.thu', 'dayNames.fri', 'dayNames.sat',
|
||||||
|
];
|
||||||
|
|
||||||
export function EventStrip({ events, currentTime, onEventClick }: Props) {
|
export function EventStrip({ events, currentTime, onEventClick }: Props) {
|
||||||
const [openDate, setOpenDate] = useState<string | null>(null);
|
const [openDate, setOpenDate] = useState<string | null>(null);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const groups = useMemo(() => {
|
const groups = useMemo(() => {
|
||||||
const sorted = [...events].sort((a, b) => a.timestamp - b.timestamp);
|
const sorted = [...events].sort((a, b) => a.timestamp - b.timestamp);
|
||||||
@ -63,12 +68,12 @@ export function EventStrip({ events, currentTime, onEventClick }: Props) {
|
|||||||
const result: EventGroup[] = [];
|
const result: EventGroup[] = [];
|
||||||
for (const [dateKey, evs] of map) {
|
for (const [dateKey, evs] of map) {
|
||||||
const d = new Date(evs[0].timestamp + KST_OFFSET);
|
const d = new Date(evs[0].timestamp + KST_OFFSET);
|
||||||
const dayName = DAY_NAMES[d.getUTCDay()];
|
const dayName = t(DAY_NAME_KEYS[d.getUTCDay()]);
|
||||||
const dateLabel = `${String(d.getUTCMonth() + 1).padStart(2, '0')}/${String(d.getUTCDate()).padStart(2, '0')} (${dayName})`;
|
const dateLabel = `${String(d.getUTCMonth() + 1).padStart(2, '0')}/${String(d.getUTCDate()).padStart(2, '0')} (${dayName})`;
|
||||||
result.push({ dateKey, dateLabel, events: evs });
|
result.push({ dateKey, dateLabel, events: evs });
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}, [events]);
|
}, [events, t]);
|
||||||
|
|
||||||
// Auto-open the first group if none selected
|
// Auto-open the first group if none selected
|
||||||
const effectiveOpen = openDate ?? (groups.length > 0 ? groups[0].dateKey : null);
|
const effectiveOpen = openDate ?? (groups.length > 0 ? groups[0].dateKey : null);
|
||||||
@ -108,8 +113,8 @@ export function EventStrip({ events, currentTime, onEventClick }: Props) {
|
|||||||
{group.events.map(ev => {
|
{group.events.map(ev => {
|
||||||
const isPast = ev.timestamp <= currentTime;
|
const isPast = ev.timestamp <= currentTime;
|
||||||
const color = TYPE_COLORS[ev.type] || '#888';
|
const color = TYPE_COLORS[ev.type] || '#888';
|
||||||
const source = ev.source ? SOURCE_LABELS_KO[ev.source] : '';
|
const source = ev.source ? t(SOURCE_KEYS[ev.source] ?? ev.source) : '';
|
||||||
const typeLabel = TYPE_LABELS_KO[ev.type] || ev.type;
|
const typeLabel = t(TYPE_KEYS[ev.type] ?? ev.type);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@ -98,7 +98,6 @@ export function GlobeMap({ events, currentTime, aircraft, satellites, ships, lay
|
|||||||
|
|
||||||
map.addControl(new maplibregl.NavigationControl(), 'top-right');
|
map.addControl(new maplibregl.NavigationControl(), 'top-right');
|
||||||
|
|
||||||
// 한글 국가명 라벨
|
|
||||||
map.on('load', () => {
|
map.on('load', () => {
|
||||||
map.addSource('country-labels', { type: 'geojson', data: countryLabelsGeoJSON() });
|
map.addSource('country-labels', { type: 'geojson', data: countryLabelsGeoJSON() });
|
||||||
map.addLayer({
|
map.addLayer({
|
||||||
@ -141,7 +140,7 @@ export function GlobeMap({ events, currentTime, aircraft, satellites, ships, lay
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Update markers
|
// Update markers — DOM direct manipulation, inline styles intentionally kept
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
@ -227,6 +226,6 @@ export function GlobeMap({ events, currentTime, aircraft, satellites, ships, lay
|
|||||||
}, [events, currentTime, aircraft, satellites, ships, layers]);
|
}, [events, currentTime, aircraft, satellites, ships, layers]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} style={{ width: '100%', height: '100%' }} />
|
<div ref={containerRef} className="w-full h-full" />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1,10 +1,12 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Marker, Popup } from 'react-map-gl/maplibre';
|
import { Marker, Popup } from 'react-map-gl/maplibre';
|
||||||
import { KOREAN_AIRPORTS } from '../services/airports';
|
import { KOREAN_AIRPORTS } from '../services/airports';
|
||||||
import type { KoreanAirport } from '../services/airports';
|
import type { KoreanAirport } from '../services/airports';
|
||||||
|
|
||||||
export function KoreaAirportLayer() {
|
export function KoreaAirportLayer() {
|
||||||
const [selected, setSelected] = useState<KoreanAirport | null>(null);
|
const [selected, setSelected] = useState<KoreanAirport | null>(null);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -16,20 +18,18 @@ export function KoreaAirportLayer() {
|
|||||||
<Marker key={ap.id} longitude={ap.lng} latitude={ap.lat} anchor="center"
|
<Marker key={ap.id} longitude={ap.lng} latitude={ap.lat} anchor="center"
|
||||||
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(ap); }}>
|
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(ap); }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
cursor: 'pointer', display: 'flex', flexDirection: 'column', alignItems: 'center',
|
cursor: 'pointer',
|
||||||
filter: `drop-shadow(0 0 3px ${color}88)`,
|
filter: `drop-shadow(0 0 3px ${color}88)`,
|
||||||
}}>
|
}} className="flex flex-col items-center">
|
||||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke={color} strokeWidth="1.2" />
|
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke={color} strokeWidth="1.2" />
|
||||||
{/* 비행기 모양 (위를 향한 여객기) */}
|
|
||||||
<path d="M12 5 C11.4 5 11 5.4 11 5.8 L11 10 L6 13 L6 14.2 L11 12.5 L11 16.5 L9.2 17.8 L9.2 18.8 L12 18 L14.8 18.8 L14.8 17.8 L13 16.5 L13 12.5 L18 14.2 L18 13 L13 10 L13 5.8 C13 5.4 12.6 5 12 5Z"
|
<path d="M12 5 C11.4 5 11 5.4 11 5.8 L11 10 L6 13 L6 14.2 L11 12.5 L11 16.5 L9.2 17.8 L9.2 18.8 L12 18 L14.8 18.8 L14.8 17.8 L13 16.5 L13 12.5 L18 14.2 L18 13 L13 10 L13 5.8 C13 5.4 12.6 5 12 5Z"
|
||||||
fill={color} stroke="#fff" strokeWidth="0.3" />
|
fill={color} stroke="#fff" strokeWidth="0.3" />
|
||||||
</svg>
|
</svg>
|
||||||
<div style={{
|
<div style={{
|
||||||
fontSize: 6, color: '#fff', marginTop: 1,
|
fontSize: 6,
|
||||||
textShadow: `0 0 3px ${color}, 0 0 2px #000`,
|
textShadow: `0 0 3px ${color}, 0 0 2px #000`,
|
||||||
whiteSpace: 'nowrap', fontWeight: 700, letterSpacing: 0.3,
|
}} className="mt-px whitespace-nowrap font-bold tracking-wide text-white">
|
||||||
}}>
|
|
||||||
{ap.nameKo.replace('국제공항', '').replace('공항', '')}
|
{ap.nameKo.replace('국제공항', '').replace('공항', '')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -41,35 +41,28 @@ export function KoreaAirportLayer() {
|
|||||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||||
onClose={() => setSelected(null)} closeOnClick={false}
|
onClose={() => setSelected(null)} closeOnClick={false}
|
||||||
anchor="bottom" maxWidth="260px" className="gl-popup">
|
anchor="bottom" maxWidth="260px" className="gl-popup">
|
||||||
<div style={{ fontFamily: 'monospace', fontSize: 12, minWidth: 180 }}>
|
<div className="min-w-[180px] font-mono text-xs">
|
||||||
<div style={{
|
<div style={{
|
||||||
background: selected.intl ? '#a78bfa' : '#7c8aaa', color: '#000',
|
background: selected.intl ? '#a78bfa' : '#7c8aaa',
|
||||||
padding: '4px 8px', borderRadius: '4px 4px 0 0',
|
}} className="-mx-2.5 -mt-2.5 mb-2 flex items-center gap-1.5 rounded-t px-2 py-1 text-[13px] font-bold text-black">
|
||||||
margin: '-10px -10px 8px -10px',
|
|
||||||
fontWeight: 700, fontSize: 13,
|
|
||||||
display: 'flex', alignItems: 'center', gap: 6,
|
|
||||||
}}>
|
|
||||||
{selected.nameKo}
|
{selected.nameKo}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
|
<div className="mb-1.5 flex flex-wrap gap-1">
|
||||||
{selected.intl && (
|
{selected.intl && (
|
||||||
<span style={{
|
<span className="rounded-sm bg-[#a78bfa] px-1.5 py-px text-[10px] font-bold text-black">
|
||||||
background: '#a78bfa', color: '#000',
|
{t('airport.international')}
|
||||||
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
</span>
|
||||||
}}>국제선</span>
|
|
||||||
)}
|
)}
|
||||||
{selected.domestic && (
|
{selected.domestic && (
|
||||||
<span style={{
|
<span className="rounded-sm bg-[#7c8aaa] px-1.5 py-px text-[10px] font-bold text-black">
|
||||||
background: '#7c8aaa', color: '#000',
|
{t('airport.domestic')}
|
||||||
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
</span>
|
||||||
}}>국내선</span>
|
|
||||||
)}
|
)}
|
||||||
<span style={{
|
<span className="rounded-sm bg-kcg-card px-1.5 py-px text-[10px] text-kcg-muted">
|
||||||
background: '#1a1a2e', color: '#888',
|
{selected.id} / {selected.icao}
|
||||||
padding: '1px 6px', borderRadius: 3, fontSize: 10,
|
</span>
|
||||||
}}>{selected.id} / {selected.icao}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 9, color: '#666' }}>
|
<div className="text-[9px] text-kcg-dim">
|
||||||
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
|
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
321
frontend/src/components/KoreaMap.tsx
Normal file
@ -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: '© 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
377
frontend/src/components/LayerPanel.tsx
Normal file
@ -0,0 +1,377 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
// Aircraft category colors (matches AircraftLayer military fixed colors)
|
||||||
|
const AC_CAT_COLORS: Record<string, string> = {
|
||||||
|
fighter: '#ff4444',
|
||||||
|
military: '#ff6600',
|
||||||
|
surveillance: '#ffcc00',
|
||||||
|
tanker: '#00ccff',
|
||||||
|
cargo: '#a78bfa',
|
||||||
|
civilian: '#FFD700',
|
||||||
|
unknown: '#7CFC00',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Altitude color legend (matches AircraftLayer gradient)
|
||||||
|
const ALT_LEGEND: [string, string][] = [
|
||||||
|
['Ground', '#555555'],
|
||||||
|
['< 2,000ft', '#00c000'],
|
||||||
|
['2,000ft', '#55EC55'],
|
||||||
|
['4,000ft', '#7CFC00'],
|
||||||
|
['6,000ft', '#BFFF00'],
|
||||||
|
['10,000ft', '#FFFF00'],
|
||||||
|
['20,000ft', '#FFD700'],
|
||||||
|
['30,000ft', '#FF8C00'],
|
||||||
|
['40,000ft', '#FF4500'],
|
||||||
|
['50,000ft+', '#BA55D3'],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Military color legend
|
||||||
|
const MIL_LEGEND: [string, string][] = [
|
||||||
|
['Fighter', '#ff4444'],
|
||||||
|
['Military', '#ff6600'],
|
||||||
|
['ISR / Surveillance', '#ffcc00'],
|
||||||
|
['Tanker', '#00ccff'],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Ship MT category color (matches ShipLayer MT_TYPE_COLORS)
|
||||||
|
const MT_CAT_COLORS: Record<string, string> = {
|
||||||
|
cargo: 'var(--kcg-ship-cargo)',
|
||||||
|
tanker: 'var(--kcg-ship-tanker)',
|
||||||
|
passenger: 'var(--kcg-ship-passenger)',
|
||||||
|
fishing: 'var(--kcg-ship-fishing)',
|
||||||
|
military: 'var(--kcg-ship-military)',
|
||||||
|
tug_special: 'var(--kcg-ship-tug)',
|
||||||
|
high_speed: 'var(--kcg-ship-highspeed)',
|
||||||
|
pleasure: 'var(--kcg-ship-pleasure)',
|
||||||
|
other: 'var(--kcg-ship-other)',
|
||||||
|
unspecified: 'var(--kcg-ship-unknown)',
|
||||||
|
unknown: 'var(--kcg-ship-unknown)',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ship type color legend (MarineTraffic style)
|
||||||
|
const SHIP_TYPE_LEGEND: [string, string][] = [
|
||||||
|
['cargo', 'var(--kcg-ship-cargo)'],
|
||||||
|
['tanker', 'var(--kcg-ship-tanker)'],
|
||||||
|
['passenger', 'var(--kcg-ship-passenger)'],
|
||||||
|
['fishing', 'var(--kcg-ship-fishing)'],
|
||||||
|
['pleasure', 'var(--kcg-ship-pleasure)'],
|
||||||
|
['military', 'var(--kcg-ship-military)'],
|
||||||
|
['tug_special', 'var(--kcg-ship-tug)'],
|
||||||
|
['other', 'var(--kcg-ship-other)'],
|
||||||
|
['unspecified', 'var(--kcg-ship-unknown)'],
|
||||||
|
];
|
||||||
|
|
||||||
|
const AC_CATEGORIES = ['fighter', 'military', 'surveillance', 'tanker', 'cargo', 'civilian', 'unknown'] as const;
|
||||||
|
const MT_CATEGORIES = ['cargo', 'tanker', 'passenger', 'fishing', 'military', 'tug_special', 'high_speed', 'pleasure', 'other', 'unspecified'] as const;
|
||||||
|
|
||||||
|
interface ExtraLayer {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LayerPanelProps {
|
||||||
|
layers: Record<string, boolean>;
|
||||||
|
onToggle: (key: string) => void;
|
||||||
|
aircraftByCategory: Record<string, number>;
|
||||||
|
aircraftTotal: number;
|
||||||
|
shipsByMtCategory: Record<string, number>;
|
||||||
|
shipTotal: number;
|
||||||
|
satelliteCount: number;
|
||||||
|
extraLayers?: ExtraLayer[];
|
||||||
|
hiddenAcCategories: Set<string>;
|
||||||
|
hiddenShipCategories: Set<string>;
|
||||||
|
onAcCategoryToggle: (cat: string) => void;
|
||||||
|
onShipCategoryToggle: (cat: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LayerPanel({
|
||||||
|
layers,
|
||||||
|
onToggle,
|
||||||
|
aircraftByCategory,
|
||||||
|
aircraftTotal,
|
||||||
|
shipsByMtCategory,
|
||||||
|
shipTotal,
|
||||||
|
satelliteCount,
|
||||||
|
extraLayers,
|
||||||
|
hiddenAcCategories,
|
||||||
|
hiddenShipCategories,
|
||||||
|
onAcCategoryToggle,
|
||||||
|
onShipCategoryToggle,
|
||||||
|
}: LayerPanelProps) {
|
||||||
|
const { t } = useTranslation(['common', 'ships']);
|
||||||
|
const [expanded, setExpanded] = useState<Set<string>>(new Set(['aircraft', 'ships']));
|
||||||
|
const [legendOpen, setLegendOpen] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const toggleExpand = useCallback((key: string) => {
|
||||||
|
setExpanded(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) { next.delete(key); } else { next.add(key); }
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleLegend = useCallback((key: string) => {
|
||||||
|
setLegendOpen(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) { next.delete(key); } else { next.add(key); }
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const militaryCount = Object.entries(aircraftByCategory)
|
||||||
|
.filter(([cat]) => cat !== 'civilian' && cat !== 'unknown')
|
||||||
|
.reduce((sum, [, c]) => sum + c, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="layer-panel">
|
||||||
|
<h3>LAYERS</h3>
|
||||||
|
<div className="layer-items">
|
||||||
|
{/* Aircraft tree */}
|
||||||
|
<LayerTreeItem
|
||||||
|
layerKey="aircraft"
|
||||||
|
label={`${t('layers.aircraft')} (${aircraftTotal})`}
|
||||||
|
color="#22d3ee"
|
||||||
|
active={layers.aircraft}
|
||||||
|
expandable
|
||||||
|
isExpanded={expanded.has('aircraft')}
|
||||||
|
onToggle={() => onToggle('aircraft')}
|
||||||
|
onExpand={() => toggleExpand('aircraft')}
|
||||||
|
/>
|
||||||
|
{layers.aircraft && expanded.has('aircraft') && (
|
||||||
|
<div className="layer-tree-children">
|
||||||
|
{AC_CATEGORIES.map(cat => {
|
||||||
|
const count = aircraftByCategory[cat] || 0;
|
||||||
|
if (count === 0) return null;
|
||||||
|
return (
|
||||||
|
<CategoryToggle
|
||||||
|
key={cat}
|
||||||
|
label={t(`ships:aircraft.${cat}`, cat.toUpperCase())}
|
||||||
|
color={AC_CAT_COLORS[cat] || '#888'}
|
||||||
|
count={count}
|
||||||
|
hidden={hiddenAcCategories.has(cat)}
|
||||||
|
onClick={() => onAcCategoryToggle(cat)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Altitude legend */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="legend-toggle"
|
||||||
|
onClick={() => toggleLegend('altitude')}
|
||||||
|
>
|
||||||
|
{legendOpen.has('altitude') ? '\u25BC' : '\u25B6'} {t('legend.altitude')}
|
||||||
|
</button>
|
||||||
|
{legendOpen.has('altitude') && (
|
||||||
|
<div className="legend-content">
|
||||||
|
<div className="flex flex-col gap-px text-[9px] opacity-85">
|
||||||
|
{ALT_LEGEND.map(([label, color]) => (
|
||||||
|
<div key={label} className="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className="inline-block w-2.5 h-2.5 shrink-0 rounded-sm"
|
||||||
|
style={{ background: color }}
|
||||||
|
/>
|
||||||
|
<span className="text-kcg-text">{label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Military legend */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="legend-toggle"
|
||||||
|
onClick={() => toggleLegend('military')}
|
||||||
|
>
|
||||||
|
{legendOpen.has('military') ? '\u25BC' : '\u25B6'} {t('legend.military')}
|
||||||
|
</button>
|
||||||
|
{legendOpen.has('military') && (
|
||||||
|
<div className="legend-content">
|
||||||
|
<div className="flex flex-col gap-px text-[9px] opacity-85">
|
||||||
|
{MIL_LEGEND.map(([label, color]) => (
|
||||||
|
<div key={label} className="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className="inline-block w-2.5 h-2.5 shrink-0 rounded-sm"
|
||||||
|
style={{ background: color }}
|
||||||
|
/>
|
||||||
|
<span className="text-kcg-text">{label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Ships tree */}
|
||||||
|
<LayerTreeItem
|
||||||
|
layerKey="ships"
|
||||||
|
label={`${t('layers.ships')} (${shipTotal})`}
|
||||||
|
color="#fb923c"
|
||||||
|
active={layers.ships}
|
||||||
|
expandable
|
||||||
|
isExpanded={expanded.has('ships')}
|
||||||
|
onToggle={() => onToggle('ships')}
|
||||||
|
onExpand={() => toggleExpand('ships')}
|
||||||
|
/>
|
||||||
|
{layers.ships && expanded.has('ships') && (
|
||||||
|
<div className="layer-tree-children">
|
||||||
|
{MT_CATEGORIES.map(cat => {
|
||||||
|
const count = shipsByMtCategory[cat] || 0;
|
||||||
|
if (count === 0) return null;
|
||||||
|
return (
|
||||||
|
<CategoryToggle
|
||||||
|
key={cat}
|
||||||
|
label={t(`ships:mtType.${cat}`, cat.toUpperCase())}
|
||||||
|
color={MT_CAT_COLORS[cat] || 'var(--kcg-muted)'}
|
||||||
|
count={count}
|
||||||
|
hidden={hiddenShipCategories.has(cat)}
|
||||||
|
onClick={() => onShipCategoryToggle(cat)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Ship type legend */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="legend-toggle"
|
||||||
|
onClick={() => toggleLegend('shipType')}
|
||||||
|
>
|
||||||
|
{legendOpen.has('shipType') ? '\u25BC' : '\u25B6'} {t('legend.vesselType')}
|
||||||
|
</button>
|
||||||
|
{legendOpen.has('shipType') && (
|
||||||
|
<div className="legend-content">
|
||||||
|
<div className="flex flex-col gap-px text-[9px] opacity-85">
|
||||||
|
{SHIP_TYPE_LEGEND.map(([key, color]) => (
|
||||||
|
<div key={key} className="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className="shrink-0"
|
||||||
|
style={{
|
||||||
|
width: 0, height: 0,
|
||||||
|
borderLeft: '5px solid transparent',
|
||||||
|
borderRight: '5px solid transparent',
|
||||||
|
borderBottom: `10px solid ${color}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-kcg-text">{t(`ships:mtType.${key}`, key)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Satellites (simple toggle) */}
|
||||||
|
<LayerTreeItem
|
||||||
|
layerKey="satellites"
|
||||||
|
label={`${t('layers.satellites')} (${satelliteCount})`}
|
||||||
|
color="#ef4444"
|
||||||
|
active={layers.satellites}
|
||||||
|
onToggle={() => onToggle('satellites')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Extra layers (tab-specific) */}
|
||||||
|
{extraLayers && extraLayers.map(el => (
|
||||||
|
<LayerTreeItem
|
||||||
|
key={el.key}
|
||||||
|
layerKey={el.key}
|
||||||
|
label={el.count != null ? `${el.label} (${el.count})` : el.label}
|
||||||
|
color={el.color}
|
||||||
|
active={layers[el.key] ?? false}
|
||||||
|
onToggle={() => onToggle(el.key)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="layer-divider" />
|
||||||
|
|
||||||
|
{/* Military only filter */}
|
||||||
|
<LayerTreeItem
|
||||||
|
layerKey="militaryOnly"
|
||||||
|
label={`${t('layers.militaryOnly')} (${militaryCount})`}
|
||||||
|
color="#f97316"
|
||||||
|
active={layers.militaryOnly ?? false}
|
||||||
|
onToggle={() => onToggle('militaryOnly')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sub-components ─────────────────────────────────── */
|
||||||
|
|
||||||
|
function LayerTreeItem({
|
||||||
|
layerKey,
|
||||||
|
label,
|
||||||
|
color,
|
||||||
|
active,
|
||||||
|
expandable,
|
||||||
|
isExpanded,
|
||||||
|
onToggle,
|
||||||
|
onExpand,
|
||||||
|
}: {
|
||||||
|
layerKey: string;
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
active: boolean;
|
||||||
|
expandable?: boolean;
|
||||||
|
isExpanded?: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
onExpand?: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="layer-tree-header" data-layer={layerKey}>
|
||||||
|
{expandable ? (
|
||||||
|
<span
|
||||||
|
className={`layer-tree-arrow ${isExpanded ? 'expanded' : ''}`}
|
||||||
|
onClick={e => { e.stopPropagation(); onExpand?.(); }}
|
||||||
|
>
|
||||||
|
{'\u25B6'}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="layer-tree-arrow" />
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`layer-toggle ${active ? 'active' : ''}`}
|
||||||
|
onClick={onToggle}
|
||||||
|
style={{ padding: 0, gap: '6px' }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="layer-dot"
|
||||||
|
style={{ backgroundColor: active ? color : '#444' }}
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CategoryToggle({
|
||||||
|
label,
|
||||||
|
color,
|
||||||
|
count,
|
||||||
|
hidden,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
count: number;
|
||||||
|
hidden: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`category-toggle ${hidden ? 'hidden' : ''}`}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<span className="category-dot" style={{ backgroundColor: color }} />
|
||||||
|
<span className="category-label">{label}</span>
|
||||||
|
<span className="category-count">{count}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
currentTime: number;
|
currentTime: number;
|
||||||
@ -23,21 +24,22 @@ export function LiveControls({
|
|||||||
historyMinutes,
|
historyMinutes,
|
||||||
onHistoryChange,
|
onHistoryChange,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const kstTime = format(new Date(currentTime + 9 * 3600_000), "yyyy-MM-dd HH:mm:ss 'KST'");
|
const kstTime = format(new Date(currentTime + 9 * 3600_000), "yyyy-MM-dd HH:mm:ss 'KST'");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="live-controls">
|
<div className="live-controls">
|
||||||
<div className="live-indicator">
|
<div className="live-indicator">
|
||||||
<span className="live-dot" />
|
<span className="live-dot" />
|
||||||
<span className="live-label">LIVE</span>
|
<span className="live-label">{t('header.live')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="live-clock">{kstTime}</div>
|
<div className="live-clock">{kstTime}</div>
|
||||||
|
|
||||||
<div style={{ flex: 1 }} />
|
<div className="flex-1" />
|
||||||
|
|
||||||
<div className="history-controls">
|
<div className="history-controls">
|
||||||
<span className="history-label">HISTORY</span>
|
<span className="history-label">{t('time.history')}</span>
|
||||||
<div className="history-presets">
|
<div className="history-presets">
|
||||||
{HISTORY_PRESETS.map(p => (
|
{HISTORY_PRESETS.map(p => (
|
||||||
<button
|
<button
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Marker, Popup } from 'react-map-gl/maplibre';
|
import { Marker, Popup } from 'react-map-gl/maplibre';
|
||||||
import { NAV_WARNINGS, NW_LEVEL_LABEL, NW_ORG_LABEL } from '../services/navWarning';
|
import { NAV_WARNINGS, NW_LEVEL_LABEL, NW_ORG_LABEL } from '../services/navWarning';
|
||||||
import type { NavWarning, NavWarningLevel, TrainingOrg } from '../services/navWarning';
|
import type { NavWarning, NavWarningLevel, TrainingOrg } from '../services/navWarning';
|
||||||
@ -31,7 +32,6 @@ function WarningIcon({ level, org, size }: { level: NavWarningLevel; org: Traini
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// caution (해경 등)
|
|
||||||
return (
|
return (
|
||||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||||
<path d="M12 2 L22 12 L12 22 L2 12 Z" fill="rgba(0,0,0,0.5)" stroke={color} strokeWidth="1.2" />
|
<path d="M12 2 L22 12 L12 22 L2 12 Z" fill="rgba(0,0,0,0.5)" stroke={color} strokeWidth="1.2" />
|
||||||
@ -43,6 +43,7 @@ function WarningIcon({ level, org, size }: { level: NavWarningLevel; org: Traini
|
|||||||
|
|
||||||
export function NavWarningLayer() {
|
export function NavWarningLayer() {
|
||||||
const [selected, setSelected] = useState<NavWarning | null>(null);
|
const [selected, setSelected] = useState<NavWarning | null>(null);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -53,15 +54,14 @@ export function NavWarningLayer() {
|
|||||||
<Marker key={w.id} longitude={w.lng} latitude={w.lat} anchor="center"
|
<Marker key={w.id} longitude={w.lng} latitude={w.lat} anchor="center"
|
||||||
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(w); }}>
|
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(w); }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
cursor: 'pointer', display: 'flex', flexDirection: 'column', alignItems: 'center',
|
cursor: 'pointer',
|
||||||
filter: `drop-shadow(0 0 4px ${color}88)`,
|
filter: `drop-shadow(0 0 4px ${color}88)`,
|
||||||
}}>
|
}} className="flex flex-col items-center">
|
||||||
<WarningIcon level={w.level} org={w.org} size={size} />
|
<WarningIcon level={w.level} org={w.org} size={size} />
|
||||||
<div style={{
|
<div style={{
|
||||||
fontSize: 5, color, marginTop: 0,
|
fontSize: 5, color,
|
||||||
textShadow: `0 0 3px ${color}, 0 0 2px #000`,
|
textShadow: `0 0 3px ${color}, 0 0 2px #000`,
|
||||||
whiteSpace: 'nowrap', fontWeight: 700, letterSpacing: 0.3,
|
}} className="whitespace-nowrap font-bold tracking-wide">
|
||||||
}}>
|
|
||||||
{w.id}
|
{w.id}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -73,48 +73,43 @@ export function NavWarningLayer() {
|
|||||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||||
onClose={() => setSelected(null)} closeOnClick={false}
|
onClose={() => setSelected(null)} closeOnClick={false}
|
||||||
anchor="bottom" maxWidth="320px" className="gl-popup">
|
anchor="bottom" maxWidth="320px" className="gl-popup">
|
||||||
<div style={{ fontFamily: 'monospace', fontSize: 12, minWidth: 240 }}>
|
<div className="min-w-[240px] font-mono text-xs">
|
||||||
<div style={{
|
<div style={{
|
||||||
background: ORG_COLOR[selected.org], color: '#fff',
|
background: ORG_COLOR[selected.org],
|
||||||
padding: '4px 8px', borderRadius: '4px 4px 0 0',
|
}} className="-mx-2.5 -mt-2.5 mb-2 flex items-center gap-1.5 rounded-t px-2 py-1 text-xs font-bold text-white">
|
||||||
margin: '-10px -10px 8px -10px',
|
|
||||||
fontWeight: 700, fontSize: 12,
|
|
||||||
display: 'flex', alignItems: 'center', gap: 6,
|
|
||||||
}}>
|
|
||||||
{selected.title}
|
{selected.title}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
|
<div className="mb-1.5 flex flex-wrap gap-1">
|
||||||
<span style={{
|
<span style={{
|
||||||
background: LEVEL_COLOR[selected.level], color: '#fff',
|
background: LEVEL_COLOR[selected.level],
|
||||||
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
}} className="rounded-sm px-1.5 py-px text-[10px] font-bold text-white">
|
||||||
}}>{NW_LEVEL_LABEL[selected.level]}</span>
|
{NW_LEVEL_LABEL[selected.level]}
|
||||||
|
</span>
|
||||||
<span style={{
|
<span style={{
|
||||||
background: ORG_COLOR[selected.org] + '33', color: ORG_COLOR[selected.org],
|
background: ORG_COLOR[selected.org] + '33',
|
||||||
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
color: ORG_COLOR[selected.org],
|
||||||
border: `1px solid ${ORG_COLOR[selected.org]}44`,
|
border: `1px solid ${ORG_COLOR[selected.org]}44`,
|
||||||
}}>{NW_ORG_LABEL[selected.org]}</span>
|
}} className="rounded-sm px-1.5 py-px text-[10px] font-bold">
|
||||||
<span style={{
|
{NW_ORG_LABEL[selected.org]}
|
||||||
background: '#1a1a2e', color: '#888',
|
</span>
|
||||||
padding: '1px 6px', borderRadius: 3, fontSize: 10,
|
<span className="rounded-sm bg-kcg-card px-1.5 py-px text-[10px] text-kcg-muted">
|
||||||
}}>{selected.area}</span>
|
{selected.area}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 11, color: '#ccc', marginBottom: 6, lineHeight: 1.4 }}>
|
<div className="mb-1.5 text-[11px] leading-snug text-kcg-text-secondary">
|
||||||
{selected.description}
|
{selected.description}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 9, color: '#666', display: 'flex', flexDirection: 'column', gap: 2 }}>
|
<div className="flex flex-col gap-0.5 text-[9px] text-kcg-dim">
|
||||||
<div>사용고도: {selected.altitude}</div>
|
<div>{t('navWarning.altitude')}: {selected.altitude}</div>
|
||||||
<div>{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E</div>
|
<div>{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E</div>
|
||||||
<div>출처: {selected.source}</div>
|
<div>{t('navWarning.source')}: {selected.source}</div>
|
||||||
</div>
|
</div>
|
||||||
<a
|
<a
|
||||||
href="https://www.khoa.go.kr/nwb/mainPage.do?lang=ko"
|
href="https://www.khoa.go.kr/nwb/mainPage.do?lang=ko"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
style={{
|
className="mt-1.5 block text-[10px] text-kcg-accent underline"
|
||||||
display: 'block', marginTop: 6,
|
>{t('navWarning.khoaLink')}</a>
|
||||||
fontSize: 10, color: '#3b82f6', textDecoration: 'underline',
|
|
||||||
}}
|
|
||||||
>KHOA 항행경보 상황판 바로가기</a>
|
|
||||||
</div>
|
</div>
|
||||||
</Popup>
|
</Popup>
|
||||||
)}
|
)}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import { memo, useState } from 'react';
|
import { memo, useState } from 'react';
|
||||||
import { Marker, Popup } from 'react-map-gl/maplibre';
|
import { Marker, Popup } from 'react-map-gl/maplibre';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { OilFacility, OilFacilityType } from '../types';
|
import type { OilFacility, OilFacilityType } from '../types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -12,11 +13,6 @@ const TYPE_COLORS: Record<OilFacilityType, string> = {
|
|||||||
terminal: '#ec4899', petrochemical: '#8b5cf6', desalination: '#06b6d4',
|
terminal: '#ec4899', petrochemical: '#8b5cf6', desalination: '#06b6d4',
|
||||||
};
|
};
|
||||||
|
|
||||||
const TYPE_LABELS: Record<OilFacilityType, string> = {
|
|
||||||
refinery: '정유소', oilfield: '유전', gasfield: '가스전',
|
|
||||||
terminal: '수출터미널', petrochemical: '석유화학', desalination: '담수화시설',
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatNumber(n: number): string {
|
function formatNumber(n: number): string {
|
||||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||||
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
|
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
|
||||||
@ -54,11 +50,9 @@ function DamageOverlay() {
|
|||||||
|
|
||||||
// SVG icon renderers (JSX versions)
|
// SVG icon renderers (JSX versions)
|
||||||
function RefineryIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) {
|
function RefineryIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) {
|
||||||
// Factory/refinery silhouette on gradient circle background (no white)
|
|
||||||
const sc = damaged ? '#ff0000' : color;
|
const sc = damaged ? '#ff0000' : color;
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 36 36" width={size} height={size}>
|
<svg viewBox="0 0 36 36" width={size} height={size}>
|
||||||
{/* Gradient circle background */}
|
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id={`refGrad-${damaged ? 'd' : 'n'}`} x1="0" y1="0" x2="1" y2="1">
|
<linearGradient id={`refGrad-${damaged ? 'd' : 'n'}`} x1="0" y1="0" x2="1" y2="1">
|
||||||
<stop offset="0%" stopColor={color} stopOpacity={0.5} />
|
<stop offset="0%" stopColor={color} stopOpacity={0.5} />
|
||||||
@ -67,23 +61,16 @@ function RefineryIcon({ size, color, damaged }: { size: number; color: string; d
|
|||||||
</defs>
|
</defs>
|
||||||
<circle cx={18} cy={18} r={17} fill={`url(#refGrad-${damaged ? 'd' : 'n'})`}
|
<circle cx={18} cy={18} r={17} fill={`url(#refGrad-${damaged ? 'd' : 'n'})`}
|
||||||
stroke={sc} strokeWidth={damaged ? 2 : 1} opacity={0.9} />
|
stroke={sc} strokeWidth={damaged ? 2 : 1} opacity={0.9} />
|
||||||
{/* Factory building base */}
|
|
||||||
<rect x={6} y={19} width={24} height={11} rx={1} fill={color} opacity={0.6} />
|
<rect x={6} y={19} width={24} height={11} rx={1} fill={color} opacity={0.6} />
|
||||||
{/* Tall chimney/tower (center) */}
|
|
||||||
<rect x={16} y={7} width={4} height={13} fill={color} opacity={0.7} />
|
<rect x={16} y={7} width={4} height={13} fill={color} opacity={0.7} />
|
||||||
{/* Short tower (left) */}
|
|
||||||
<rect x={9} y={12} width={4} height={8} fill={color} opacity={0.65} />
|
<rect x={9} y={12} width={4} height={8} fill={color} opacity={0.65} />
|
||||||
{/* Medium tower (right) */}
|
|
||||||
<rect x={23} y={10} width={4} height={10} fill={color} opacity={0.65} />
|
<rect x={23} y={10} width={4} height={10} fill={color} opacity={0.65} />
|
||||||
{/* Smoke/emission from chimneys */}
|
|
||||||
<circle cx={11} cy={10} r={1.5} fill={color} opacity={0.3} />
|
<circle cx={11} cy={10} r={1.5} fill={color} opacity={0.3} />
|
||||||
<circle cx={18} cy={5} r={2} fill={color} opacity={0.3} />
|
<circle cx={18} cy={5} r={2} fill={color} opacity={0.3} />
|
||||||
<circle cx={25} cy={8} r={1.5} fill={color} opacity={0.3} />
|
<circle cx={25} cy={8} r={1.5} fill={color} opacity={0.3} />
|
||||||
{/* Windows on building */}
|
|
||||||
<rect x={10} y={22} width={3} height={3} rx={0.5} fill="rgba(0,0,0,0.4)" />
|
<rect x={10} y={22} width={3} height={3} rx={0.5} fill="rgba(0,0,0,0.4)" />
|
||||||
<rect x={16} y={22} width={3} height={3} rx={0.5} fill="rgba(0,0,0,0.4)" />
|
<rect x={16} y={22} width={3} height={3} rx={0.5} fill="rgba(0,0,0,0.4)" />
|
||||||
<rect x={23} y={22} width={3} height={3} rx={0.5} fill="rgba(0,0,0,0.4)" />
|
<rect x={23} y={22} width={3} height={3} rx={0.5} fill="rgba(0,0,0,0.4)" />
|
||||||
{/* Pipe details */}
|
|
||||||
<line x1={13} y1={15} x2={16} y2={15} stroke={color} strokeWidth={0.8} opacity={0.5} />
|
<line x1={13} y1={15} x2={16} y2={15} stroke={color} strokeWidth={0.8} opacity={0.5} />
|
||||||
<line x1={20} y1={13} x2={23} y2={13} stroke={color} strokeWidth={0.8} opacity={0.5} />
|
<line x1={20} y1={13} x2={23} y2={13} stroke={color} strokeWidth={0.8} opacity={0.5} />
|
||||||
{damaged && <DamageOverlay />}
|
{damaged && <DamageOverlay />}
|
||||||
@ -92,34 +79,21 @@ function RefineryIcon({ size, color, damaged }: { size: number; color: string; d
|
|||||||
}
|
}
|
||||||
|
|
||||||
function OilFieldIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) {
|
function OilFieldIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) {
|
||||||
// Oil pumpjack (nodding donkey) icon — transparent style
|
|
||||||
const sc = damaged ? '#ff0000' : color;
|
const sc = damaged ? '#ff0000' : color;
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 36 36" width={size} height={size}>
|
<svg viewBox="0 0 36 36" width={size} height={size}>
|
||||||
{/* Base platform */}
|
|
||||||
<rect x={4} y={31} width={28} height={2.5} rx={1} fill={color} opacity={0.7} />
|
<rect x={4} y={31} width={28} height={2.5} rx={1} fill={color} opacity={0.7} />
|
||||||
{/* Support A-frame (tripod legs) */}
|
|
||||||
<line x1={18} y1={14} x2={12} y2={31} stroke={sc} strokeWidth={1.5} opacity={0.85} />
|
<line x1={18} y1={14} x2={12} y2={31} stroke={sc} strokeWidth={1.5} opacity={0.85} />
|
||||||
<line x1={18} y1={14} x2={24} y2={31} stroke={sc} strokeWidth={1.5} opacity={0.85} />
|
<line x1={18} y1={14} x2={24} y2={31} stroke={sc} strokeWidth={1.5} opacity={0.85} />
|
||||||
{/* Cross brace on A-frame */}
|
|
||||||
<line x1={14} y1={25} x2={22} y2={25} stroke={color} strokeWidth={1} opacity={0.7} />
|
<line x1={14} y1={25} x2={22} y2={25} stroke={color} strokeWidth={1} opacity={0.7} />
|
||||||
{/* Walking beam (horizontal arm) */}
|
|
||||||
<line x1={4} y1={12} x2={28} y2={10} stroke={sc} strokeWidth={2} opacity={0.9} />
|
<line x1={4} y1={12} x2={28} y2={10} stroke={sc} strokeWidth={2} opacity={0.9} />
|
||||||
{/* Pivot point on top of A-frame */}
|
|
||||||
<circle cx={18} cy={13} r={2} fill={color} opacity={0.8} stroke={sc} strokeWidth={1} />
|
<circle cx={18} cy={13} r={2} fill={color} opacity={0.8} stroke={sc} strokeWidth={1} />
|
||||||
{/* Horse head (front end, left side) */}
|
|
||||||
<path d="M4 12 L4 17 L7 17 L7 12" fill="none" stroke={sc} strokeWidth={1.5} opacity={0.85} />
|
<path d="M4 12 L4 17 L7 17 L7 12" fill="none" stroke={sc} strokeWidth={1.5} opacity={0.85} />
|
||||||
{/* Polished rod (well string going down) */}
|
|
||||||
<line x1={5.5} y1={17} x2={5.5} y2={31} stroke={color} strokeWidth={1.2} opacity={0.75} />
|
<line x1={5.5} y1={17} x2={5.5} y2={31} stroke={color} strokeWidth={1.2} opacity={0.75} />
|
||||||
{/* Counterweight (back end, right side) */}
|
|
||||||
<rect x={25} y={10} width={5} height={6} rx={1} fill={color} opacity={0.6} stroke={sc} strokeWidth={1} />
|
<rect x={25} y={10} width={5} height={6} rx={1} fill={color} opacity={0.6} stroke={sc} strokeWidth={1} />
|
||||||
{/* Crank arm + pitman arm */}
|
|
||||||
<line x1={27.5} y1={16} x2={27.5} y2={24} stroke={color} strokeWidth={1.2} opacity={0.75} />
|
<line x1={27.5} y1={16} x2={27.5} y2={24} stroke={color} strokeWidth={1.2} opacity={0.75} />
|
||||||
{/* Motor/gear box */}
|
|
||||||
<rect x={24} y={24} width={7} height={5} rx={1} fill={color} opacity={0.55} stroke={sc} strokeWidth={1} />
|
<rect x={24} y={24} width={7} height={5} rx={1} fill={color} opacity={0.55} stroke={sc} strokeWidth={1} />
|
||||||
{/* Wellhead at bottom */}
|
|
||||||
<rect x={3} y={28} width={5} height={3} rx={0.5} fill={color} opacity={0.65} stroke={sc} strokeWidth={0.8} />
|
<rect x={3} y={28} width={5} height={3} rx={0.5} fill={color} opacity={0.65} stroke={sc} strokeWidth={0.8} />
|
||||||
{/* Oil drop symbol on wellhead */}
|
|
||||||
<path d="M5.5 29 C5.5 29 4.5 30 4.5 30.5 C4.5 31 5 31.2 5.5 31.2 C6 31.2 6.5 31 6.5 30.5 C6.5 30 5.5 29 5.5 29Z"
|
<path d="M5.5 29 C5.5 29 4.5 30 4.5 30.5 C4.5 31 5 31.2 5.5 31.2 C6 31.2 6.5 31 6.5 30.5 C6.5 30 5.5 29 5.5 29Z"
|
||||||
fill={color} opacity={0.85} />
|
fill={color} opacity={0.85} />
|
||||||
{damaged && <DamageOverlay />}
|
{damaged && <DamageOverlay />}
|
||||||
@ -128,27 +102,19 @@ function OilFieldIcon({ size, color, damaged }: { size: number; color: string; d
|
|||||||
}
|
}
|
||||||
|
|
||||||
function GasFieldIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) {
|
function GasFieldIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) {
|
||||||
// Spherical gas storage tank with support legs (transparent style)
|
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 36 36" width={size} height={size}>
|
<svg viewBox="0 0 36 36" width={size} height={size}>
|
||||||
{/* Support legs */}
|
|
||||||
<line x1={10} y1={24} x2={8} y2={33} stroke={color} strokeWidth={1.5} opacity={0.7} />
|
<line x1={10} y1={24} x2={8} y2={33} stroke={color} strokeWidth={1.5} opacity={0.7} />
|
||||||
<line x1={14} y1={25} x2={13} y2={33} stroke={color} strokeWidth={1.5} opacity={0.7} />
|
<line x1={14} y1={25} x2={13} y2={33} stroke={color} strokeWidth={1.5} opacity={0.7} />
|
||||||
<line x1={22} y1={25} x2={23} y2={33} stroke={color} strokeWidth={1.5} opacity={0.7} />
|
<line x1={22} y1={25} x2={23} y2={33} stroke={color} strokeWidth={1.5} opacity={0.7} />
|
||||||
<line x1={26} y1={24} x2={28} y2={33} stroke={color} strokeWidth={1.5} opacity={0.7} />
|
<line x1={26} y1={24} x2={28} y2={33} stroke={color} strokeWidth={1.5} opacity={0.7} />
|
||||||
{/* Cross braces */}
|
|
||||||
<line x1={9} y1={29} x2={14} y2={27} stroke={color} strokeWidth={0.8} opacity={0.55} />
|
<line x1={9} y1={29} x2={14} y2={27} stroke={color} strokeWidth={0.8} opacity={0.55} />
|
||||||
<line x1={22} y1={27} x2={27} y2={29} stroke={color} strokeWidth={0.8} opacity={0.55} />
|
<line x1={22} y1={27} x2={27} y2={29} stroke={color} strokeWidth={0.8} opacity={0.55} />
|
||||||
{/* Base platform */}
|
|
||||||
<line x1={7} y1={33} x2={29} y2={33} stroke={color} strokeWidth={1.2} opacity={0.6} />
|
<line x1={7} y1={33} x2={29} y2={33} stroke={color} strokeWidth={1.2} opacity={0.6} />
|
||||||
{/* Spherical tank body */}
|
|
||||||
<ellipse cx={18} cy={16} rx={12} ry={10} fill={color} opacity={0.45}
|
<ellipse cx={18} cy={16} rx={12} ry={10} fill={color} opacity={0.45}
|
||||||
stroke={damaged ? '#ff0000' : color} strokeWidth={1.5} />
|
stroke={damaged ? '#ff0000' : color} strokeWidth={1.5} />
|
||||||
{/* Highlight arc (top reflection) */}
|
|
||||||
<ellipse cx={16} cy={12} rx={7} ry={5} fill={color} opacity={0.3} />
|
<ellipse cx={16} cy={12} rx={7} ry={5} fill={color} opacity={0.3} />
|
||||||
{/* Equator band */}
|
|
||||||
<ellipse cx={18} cy={16} rx={12} ry={2.5} fill="none" stroke={color} strokeWidth={0.8} opacity={0.55} />
|
<ellipse cx={18} cy={16} rx={12} ry={2.5} fill="none" stroke={color} strokeWidth={0.8} opacity={0.55} />
|
||||||
{/* Top valve/pipe */}
|
|
||||||
<rect x={16.5} y={4} width={3} height={3} rx={0.5} fill={color} opacity={0.7} />
|
<rect x={16.5} y={4} width={3} height={3} rx={0.5} fill={color} opacity={0.7} />
|
||||||
<line x1={18} y1={4} x2={18} y2={6} stroke={color} strokeWidth={1.5} opacity={0.7} />
|
<line x1={18} y1={4} x2={18} y2={6} stroke={color} strokeWidth={1.5} opacity={0.7} />
|
||||||
{damaged && <DamageOverlay />}
|
{damaged && <DamageOverlay />}
|
||||||
@ -188,35 +154,25 @@ function PetrochemIcon({ size, color, damaged }: { size: number; color: string;
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DesalIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) {
|
function DesalIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) {
|
||||||
// Water drop + faucet + filter container — desalination plant (transparent)
|
|
||||||
const sc = damaged ? '#ff0000' : color;
|
const sc = damaged ? '#ff0000' : color;
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 36 36" width={size} height={size}>
|
<svg viewBox="0 0 36 36" width={size} height={size}>
|
||||||
{/* Large water drop (left side) */}
|
|
||||||
<path d="M14 3 C14 3 6 14 6 19 C6 23.5 9.5 27 14 27 C18.5 27 22 23.5 22 19 C22 14 14 3 14 3Z"
|
<path d="M14 3 C14 3 6 14 6 19 C6 23.5 9.5 27 14 27 C18.5 27 22 23.5 22 19 C22 14 14 3 14 3Z"
|
||||||
fill={color} opacity={0.4} stroke={sc} strokeWidth={1.2} />
|
fill={color} opacity={0.4} stroke={sc} strokeWidth={1.2} />
|
||||||
{/* Inner drop ripple */}
|
|
||||||
<path d="M14 10 C14 10 10 16 10 19 C10 21.5 11.8 23.5 14 23.5 C16.2 23.5 18 21.5 18 19 C18 16 14 10 14 10Z"
|
<path d="M14 10 C14 10 10 16 10 19 C10 21.5 11.8 23.5 14 23.5 C16.2 23.5 18 21.5 18 19 C18 16 14 10 14 10Z"
|
||||||
fill={color} opacity={0.3} />
|
fill={color} opacity={0.3} />
|
||||||
{/* Faucet/tap (top right) */}
|
|
||||||
<rect x={24} y={5} width={6} height={3} rx={1} fill={color} opacity={0.5} stroke={sc} strokeWidth={0.8} />
|
<rect x={24} y={5} width={6} height={3} rx={1} fill={color} opacity={0.5} stroke={sc} strokeWidth={0.8} />
|
||||||
<line x1={27} y1={8} x2={27} y2={12} stroke={sc} strokeWidth={1.5} opacity={0.7} />
|
<line x1={27} y1={8} x2={27} y2={12} stroke={sc} strokeWidth={1.5} opacity={0.7} />
|
||||||
<path d="M24 8 L24 10 Q24 12 26 12 L28 12 Q30 12 30 10 L30 8"
|
<path d="M24 8 L24 10 Q24 12 26 12 L28 12 Q30 12 30 10 L30 8"
|
||||||
fill="none" stroke={sc} strokeWidth={0.8} opacity={0.6} />
|
fill="none" stroke={sc} strokeWidth={0.8} opacity={0.6} />
|
||||||
{/* Water drops from faucet */}
|
|
||||||
<circle cx={27} cy={14.5} r={1} fill={color} opacity={0.55} />
|
<circle cx={27} cy={14.5} r={1} fill={color} opacity={0.55} />
|
||||||
<circle cx={27} cy={17} r={0.7} fill={color} opacity={0.45} />
|
<circle cx={27} cy={17} r={0.7} fill={color} opacity={0.45} />
|
||||||
{/* Filter/treatment container (bottom right) */}
|
|
||||||
<rect x={23} y={20} width={9} height={12} rx={1.5} fill={color} opacity={0.4}
|
<rect x={23} y={20} width={9} height={12} rx={1.5} fill={color} opacity={0.4}
|
||||||
stroke={sc} strokeWidth={1} />
|
stroke={sc} strokeWidth={1} />
|
||||||
{/* Filter layers inside container */}
|
|
||||||
<line x1={24} y1={24} x2={31} y2={24} stroke={color} strokeWidth={0.6} opacity={0.5} />
|
<line x1={24} y1={24} x2={31} y2={24} stroke={color} strokeWidth={0.6} opacity={0.5} />
|
||||||
<line x1={24} y1={27} x2={31} y2={27} stroke={color} strokeWidth={0.6} opacity={0.5} />
|
<line x1={24} y1={27} x2={31} y2={27} stroke={color} strokeWidth={0.6} opacity={0.5} />
|
||||||
{/* Pipe connecting drop to container */}
|
|
||||||
<path d="M18 22 L23 22" stroke={sc} strokeWidth={1} opacity={0.6} />
|
<path d="M18 22 L23 22" stroke={sc} strokeWidth={1} opacity={0.6} />
|
||||||
{/* Output pipe from container */}
|
|
||||||
<line x1={27.5} y1={32} x2={27.5} y2={34} stroke={color} strokeWidth={1} opacity={0.55} />
|
<line x1={27.5} y1={32} x2={27.5} y2={34} stroke={color} strokeWidth={1} opacity={0.55} />
|
||||||
{/* Base */}
|
|
||||||
<line x1={4} y1={34} x2={33} y2={34} stroke={color} strokeWidth={1} opacity={0.25} />
|
<line x1={4} y1={34} x2={33} y2={34} stroke={color} strokeWidth={1} opacity={0.25} />
|
||||||
{damaged && <DamageOverlay />}
|
{damaged && <DamageOverlay />}
|
||||||
</svg>
|
</svg>
|
||||||
@ -247,6 +203,7 @@ export const OilFacilityLayer = memo(function OilFacilityLayer({ facilities, cur
|
|||||||
});
|
});
|
||||||
|
|
||||||
function FacilityMarker({ facility, currentTime }: { facility: OilFacility; currentTime: number }) {
|
function FacilityMarker({ facility, currentTime }: { facility: OilFacility; currentTime: number }) {
|
||||||
|
const { t } = useTranslation('ships');
|
||||||
const [showPopup, setShowPopup] = useState(false);
|
const [showPopup, setShowPopup] = useState(false);
|
||||||
const color = TYPE_COLORS[facility.type];
|
const color = TYPE_COLORS[facility.type];
|
||||||
const isDamaged = !!(facility.damaged && facility.damagedAt && currentTime >= facility.damagedAt);
|
const isDamaged = !!(facility.damaged && facility.damagedAt && currentTime >= facility.damagedAt);
|
||||||
@ -256,33 +213,32 @@ function FacilityMarker({ facility, currentTime }: { facility: OilFacility; curr
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Marker longitude={facility.lng} latitude={facility.lat} anchor="center">
|
<Marker longitude={facility.lng} latitude={facility.lat} anchor="center">
|
||||||
<div style={{ position: 'relative' }}>
|
<div className="relative">
|
||||||
{/* Planned strike targeting ring */}
|
{/* Planned strike targeting ring */}
|
||||||
{isPlanned && (
|
{isPlanned && (
|
||||||
<div style={{
|
<div
|
||||||
position: 'absolute', top: '50%', left: '50%',
|
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-9 h-9 rounded-full pointer-events-none"
|
||||||
transform: 'translate(-50%, -50%)',
|
style={{
|
||||||
width: 36, height: 36, borderRadius: '50%',
|
|
||||||
border: '2px dashed #ff6600',
|
border: '2px dashed #ff6600',
|
||||||
animation: 'planned-pulse 2s ease-in-out infinite',
|
animation: 'planned-pulse 2s ease-in-out infinite',
|
||||||
pointerEvents: 'none',
|
}}
|
||||||
}}>
|
>
|
||||||
{/* Crosshair lines */}
|
{/* Crosshair lines */}
|
||||||
<div style={{ position: 'absolute', top: -6, left: '50%', transform: 'translateX(-50%)', width: 1, height: 6, background: '#ff6600', opacity: 0.7 }} />
|
<div className="absolute -top-1.5 left-1/2 -translate-x-1/2 w-px h-1.5 bg-[#ff6600] opacity-70" />
|
||||||
<div style={{ position: 'absolute', bottom: -6, left: '50%', transform: 'translateX(-50%)', width: 1, height: 6, background: '#ff6600', opacity: 0.7 }} />
|
<div className="absolute -bottom-1.5 left-1/2 -translate-x-1/2 w-px h-1.5 bg-[#ff6600] opacity-70" />
|
||||||
<div style={{ position: 'absolute', left: -6, top: '50%', transform: 'translateY(-50%)', width: 6, height: 1, background: '#ff6600', opacity: 0.7 }} />
|
<div className="absolute -left-1.5 top-1/2 -translate-y-1/2 w-1.5 h-px bg-[#ff6600] opacity-70" />
|
||||||
<div style={{ position: 'absolute', right: -6, top: '50%', transform: 'translateY(-50%)', width: 6, height: 1, background: '#ff6600', opacity: 0.7 }} />
|
<div className="absolute -right-1.5 top-1/2 -translate-y-1/2 w-1.5 h-px bg-[#ff6600] opacity-70" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div style={{ cursor: 'pointer' }}
|
<div className="cursor-pointer"
|
||||||
onClick={(e) => { e.stopPropagation(); setShowPopup(true); }}>
|
onClick={(e) => { e.stopPropagation(); setShowPopup(true); }}>
|
||||||
<FacilityIconSvg facility={facility} damaged={isDamaged} />
|
<FacilityIconSvg facility={facility} damaged={isDamaged} />
|
||||||
</div>
|
</div>
|
||||||
<div className="gl-marker-label" style={{
|
<div className="gl-marker-label text-[8px]" style={{
|
||||||
color: isDamaged ? '#ff0000' : isPlanned ? '#ff6600' : color, fontSize: 8,
|
color: isDamaged ? '#ff0000' : isPlanned ? '#ff6600' : color,
|
||||||
}}>
|
}}>
|
||||||
{isDamaged ? '\u{1F4A5} ' : isPlanned ? '\u{1F3AF} ' : ''}{facility.nameKo}
|
{isDamaged ? '\u{1F4A5} ' : isPlanned ? '\u{1F3AF} ' : ''}{facility.nameKo}
|
||||||
{stat && <span style={{ color: '#aaa', fontSize: 7, marginLeft: 3 }}>{stat}</span>}
|
{stat && <span className="text-[#aaa] text-[7px] ml-0.5">{stat}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Marker>
|
</Marker>
|
||||||
@ -290,69 +246,60 @@ function FacilityMarker({ facility, currentTime }: { facility: OilFacility; curr
|
|||||||
<Popup longitude={facility.lng} latitude={facility.lat}
|
<Popup longitude={facility.lng} latitude={facility.lat}
|
||||||
onClose={() => setShowPopup(false)} closeOnClick={false}
|
onClose={() => setShowPopup(false)} closeOnClick={false}
|
||||||
anchor="bottom" maxWidth="280px" className="gl-popup">
|
anchor="bottom" maxWidth="280px" className="gl-popup">
|
||||||
<div style={{ minWidth: 220, fontFamily: 'monospace', fontSize: 12 }}>
|
<div className="min-w-[220px] font-mono text-xs">
|
||||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center', marginBottom: 6 }}>
|
<div className="flex gap-1 items-center mb-1.5">
|
||||||
<span style={{
|
<span
|
||||||
background: color, color: '#fff', padding: '2px 6px',
|
className="px-1.5 py-0.5 rounded text-[10px] font-bold text-white"
|
||||||
borderRadius: 3, fontSize: 10, fontWeight: 700,
|
style={{ background: color }}
|
||||||
}}>{TYPE_LABELS[facility.type]}</span>
|
>{t(`facility.type.${facility.type}`)}</span>
|
||||||
{isDamaged && (
|
{isDamaged && (
|
||||||
<span style={{
|
<span className="px-1.5 py-0.5 rounded text-[10px] font-bold text-white bg-[#ff0000]">
|
||||||
background: '#ff0000', color: '#fff', padding: '2px 6px',
|
{t('facility.damaged')}
|
||||||
borderRadius: 3, fontSize: 10, fontWeight: 700,
|
</span>
|
||||||
}}>피격</span>
|
|
||||||
)}
|
)}
|
||||||
{isPlanned && (
|
{isPlanned && (
|
||||||
<span style={{
|
<span className="px-1.5 py-0.5 rounded text-[10px] font-bold text-white bg-[#ff6600]">
|
||||||
background: '#ff6600', color: '#fff', padding: '2px 6px',
|
{t('facility.plannedStrike')}
|
||||||
borderRadius: 3, fontSize: 10, fontWeight: 700,
|
</span>
|
||||||
}}>공격 예정</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontWeight: 700, fontSize: 13, margin: '4px 0' }}>{facility.nameKo}</div>
|
<div className="font-bold text-[13px] my-1">{facility.nameKo}</div>
|
||||||
<div style={{ fontSize: 10, color: '#888', marginBottom: 6 }}>{facility.name}</div>
|
<div className="text-[10px] text-kcg-muted mb-1.5">{facility.name}</div>
|
||||||
<div style={{
|
<div className="bg-black/30 rounded p-1.5 grid grid-cols-2 gap-x-3 gap-y-1 text-[11px]">
|
||||||
background: 'rgba(0,0,0,0.3)', borderRadius: 4, padding: '6px 8px',
|
|
||||||
display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 12px', fontSize: 11,
|
|
||||||
}}>
|
|
||||||
{facility.capacityBpd != null && (
|
{facility.capacityBpd != null && (
|
||||||
<><span style={{ color: '#888' }}>생산/처리</span>
|
<><span className="text-kcg-muted">{t('facility.production')}</span>
|
||||||
<span style={{ color: '#fff', fontWeight: 600 }}>{formatNumber(facility.capacityBpd)} bpd</span></>
|
<span className="text-white font-semibold">{formatNumber(facility.capacityBpd)} bpd</span></>
|
||||||
)}
|
)}
|
||||||
{facility.capacityMgd != null && (
|
{facility.capacityMgd != null && (
|
||||||
<><span style={{ color: '#888' }}>담수생산</span>
|
<><span className="text-kcg-muted">{t('facility.desalProduction')}</span>
|
||||||
<span style={{ color: '#fff', fontWeight: 600 }}>{formatNumber(facility.capacityMgd)} MGD</span></>
|
<span className="text-white font-semibold">{formatNumber(facility.capacityMgd)} MGD</span></>
|
||||||
)}
|
)}
|
||||||
{facility.capacityMcfd != null && (
|
{facility.capacityMcfd != null && (
|
||||||
<><span style={{ color: '#888' }}>가스생산</span>
|
<><span className="text-kcg-muted">{t('facility.gasProduction')}</span>
|
||||||
<span style={{ color: '#fff', fontWeight: 600 }}>{formatNumber(facility.capacityMcfd)} Mcf/d</span></>
|
<span className="text-white font-semibold">{formatNumber(facility.capacityMcfd)} Mcf/d</span></>
|
||||||
)}
|
)}
|
||||||
{facility.reservesBbl != null && (
|
{facility.reservesBbl != null && (
|
||||||
<><span style={{ color: '#888' }}>매장량(유)</span>
|
<><span className="text-kcg-muted">{t('facility.reserveOil')}</span>
|
||||||
<span style={{ color: '#fff', fontWeight: 600 }}>{facility.reservesBbl}B 배럴</span></>
|
<span className="text-white font-semibold">{facility.reservesBbl}B {t('facility.barrels')}</span></>
|
||||||
)}
|
)}
|
||||||
{facility.reservesTcf != null && (
|
{facility.reservesTcf != null && (
|
||||||
<><span style={{ color: '#888' }}>매장량(가스)</span>
|
<><span className="text-kcg-muted">{t('facility.reserveGas')}</span>
|
||||||
<span style={{ color: '#fff', fontWeight: 600 }}>{facility.reservesTcf} Tcf</span></>
|
<span className="text-white font-semibold">{facility.reservesTcf} Tcf</span></>
|
||||||
)}
|
)}
|
||||||
{facility.operator && (
|
{facility.operator && (
|
||||||
<><span style={{ color: '#888' }}>운영사</span>
|
<><span className="text-kcg-muted">{t('facility.operator')}</span>
|
||||||
<span style={{ color: '#fff' }}>{facility.operator}</span></>
|
<span className="text-white">{facility.operator}</span></>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{facility.description && (
|
{facility.description && (
|
||||||
<p style={{ margin: '6px 0 0', fontSize: 11, color: '#ccc', lineHeight: 1.4 }}>{facility.description}</p>
|
<p className="mt-1.5 mb-0 text-[11px] text-kcg-text-secondary leading-snug">{facility.description}</p>
|
||||||
)}
|
)}
|
||||||
{isPlanned && facility.plannedLabel && (
|
{isPlanned && facility.plannedLabel && (
|
||||||
<div style={{
|
<div className="mt-1.5 px-2 py-1 text-[11px] rounded leading-snug bg-[rgba(255,102,0,0.15)] border border-[rgba(255,102,0,0.4)] text-[#ff9933]">
|
||||||
margin: '6px 0 0', padding: '4px 8px', fontSize: 11,
|
|
||||||
background: 'rgba(255,102,0,0.15)', border: '1px solid rgba(255,102,0,0.4)',
|
|
||||||
borderRadius: 4, color: '#ff9933', lineHeight: 1.4,
|
|
||||||
}}>
|
|
||||||
{facility.plannedLabel}
|
{facility.plannedLabel}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div style={{ fontSize: 10, color: '#666', marginTop: 6 }}>
|
<div className="text-[10px] text-kcg-dim mt-1.5">
|
||||||
{facility.lat.toFixed(4)}°N, {facility.lng.toFixed(4)}°E
|
{facility.lat.toFixed(4)}°N, {facility.lng.toFixed(4)}°E
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Marker, Popup } from 'react-map-gl/maplibre';
|
import { Marker, Popup } from 'react-map-gl/maplibre';
|
||||||
import type { OsintItem } from '../services/osint';
|
import type { OsintItem } from '../services/osint';
|
||||||
|
|
||||||
@ -18,13 +19,16 @@ const CAT_ICON: Record<string, string> = {
|
|||||||
shipping: '🚢',
|
shipping: '🚢',
|
||||||
};
|
};
|
||||||
|
|
||||||
function timeAgo(ts: number): string {
|
function useTimeAgo() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (ts: number): string => {
|
||||||
const diff = Date.now() - ts;
|
const diff = Date.now() - ts;
|
||||||
const m = Math.floor(diff / 60000);
|
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);
|
const h = Math.floor(m / 60);
|
||||||
if (h < 24) return `${h}시간 전`;
|
if (h < 24) return t('time.hoursAgo', { count: h });
|
||||||
return `${Math.floor(h / 24)}일 전`;
|
return t('time.daysAgo', { count: Math.floor(h / 24) });
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -38,8 +42,9 @@ const MAP_CATEGORIES = new Set(['maritime_accident', 'fishing', 'maritime_traffi
|
|||||||
|
|
||||||
export function OsintMapLayer({ osintFeed, currentTime }: Props) {
|
export function OsintMapLayer({ osintFeed, currentTime }: Props) {
|
||||||
const [selected, setSelected] = useState<OsintItem | null>(null);
|
const [selected, setSelected] = useState<OsintItem | null>(null);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const timeAgo = useTimeAgo();
|
||||||
|
|
||||||
// 좌표가 있고, 해양 관련 카테고리이며, 최근 3시간 이내인 OSINT만 표시
|
|
||||||
const geoItems = useMemo(() => osintFeed.filter(
|
const geoItems = useMemo(() => osintFeed.filter(
|
||||||
(item): item is OsintItem & { lat: number; lng: number } =>
|
(item): item is OsintItem & { lat: number; lng: number } =>
|
||||||
item.lat != null && item.lng != null
|
item.lat != null && item.lng != null
|
||||||
@ -51,30 +56,25 @@ export function OsintMapLayer({ osintFeed, currentTime }: Props) {
|
|||||||
<>
|
<>
|
||||||
{geoItems.map(item => {
|
{geoItems.map(item => {
|
||||||
const color = CAT_COLOR[item.category] || '#888';
|
const color = CAT_COLOR[item.category] || '#888';
|
||||||
const isRecent = currentTime - item.timestamp < ONE_HOUR; // 1시간 이내
|
const isRecent = currentTime - item.timestamp < ONE_HOUR;
|
||||||
return (
|
return (
|
||||||
<Marker key={item.id} longitude={item.lng} latitude={item.lat} anchor="center"
|
<Marker key={item.id} longitude={item.lng} latitude={item.lat} anchor="center"
|
||||||
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(item); }}>
|
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(item); }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
cursor: 'pointer', display: 'flex', flexDirection: 'column', alignItems: 'center',
|
cursor: 'pointer',
|
||||||
filter: `drop-shadow(0 0 6px ${color}aa)`,
|
filter: `drop-shadow(0 0 6px ${color}aa)`,
|
||||||
}}>
|
}} className="flex flex-col items-center">
|
||||||
<div style={{
|
<div style={{
|
||||||
width: 22, height: 22, borderRadius: '50%',
|
|
||||||
background: `rgba(0,0,0,0.6)`,
|
|
||||||
border: `2px solid ${color}`,
|
border: `2px solid ${color}`,
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
fontSize: 12,
|
|
||||||
animation: isRecent ? 'pulse 2s ease-in-out infinite' : undefined,
|
animation: isRecent ? 'pulse 2s ease-in-out infinite' : undefined,
|
||||||
}}>
|
}} className="flex size-[22px] items-center justify-center rounded-full bg-black/60 text-xs">
|
||||||
{CAT_ICON[item.category] || '📰'}
|
{CAT_ICON[item.category] || '📰'}
|
||||||
</div>
|
</div>
|
||||||
{isRecent && (
|
{isRecent && (
|
||||||
<div style={{
|
<div style={{
|
||||||
fontSize: 5, color, marginTop: 1,
|
fontSize: 5, color,
|
||||||
textShadow: `0 0 3px ${color}, 0 0 2px #000`,
|
textShadow: `0 0 3px ${color}, 0 0 2px #000`,
|
||||||
fontWeight: 700, letterSpacing: 0.3,
|
}} className="mt-px font-bold tracking-wide">
|
||||||
}}>
|
|
||||||
NEW
|
NEW
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -87,41 +87,36 @@ export function OsintMapLayer({ osintFeed, currentTime }: Props) {
|
|||||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||||
onClose={() => setSelected(null)} closeOnClick={false}
|
onClose={() => setSelected(null)} closeOnClick={false}
|
||||||
anchor="bottom" maxWidth="320px" className="gl-popup">
|
anchor="bottom" maxWidth="320px" className="gl-popup">
|
||||||
<div style={{ fontFamily: 'monospace', fontSize: 12, minWidth: 240 }}>
|
<div className="min-w-[240px] font-mono text-xs">
|
||||||
<div style={{
|
<div style={{
|
||||||
background: CAT_COLOR[selected.category] || '#888', color: '#fff',
|
background: CAT_COLOR[selected.category] || '#888',
|
||||||
padding: '4px 8px', borderRadius: '4px 4px 0 0',
|
}} className="-mx-2.5 -mt-2.5 mb-2 flex items-center gap-1.5 rounded-t px-2 py-1 text-[11px] font-bold text-white">
|
||||||
margin: '-10px -10px 8px -10px',
|
|
||||||
fontWeight: 700, fontSize: 11,
|
|
||||||
display: 'flex', alignItems: 'center', gap: 6,
|
|
||||||
}}>
|
|
||||||
<span>{CAT_ICON[selected.category] || '📰'}</span>
|
<span>{CAT_ICON[selected.category] || '📰'}</span>
|
||||||
OSINT
|
OSINT
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 11, color: '#ccc', marginBottom: 6, lineHeight: 1.4 }}>
|
<div className="mb-1.5 text-[11px] leading-snug text-kcg-text-secondary">
|
||||||
{selected.title}
|
{selected.title}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
|
<div className="mb-1.5 flex flex-wrap gap-1">
|
||||||
<span style={{
|
<span style={{
|
||||||
background: CAT_COLOR[selected.category] || '#888', color: '#fff',
|
background: CAT_COLOR[selected.category] || '#888',
|
||||||
padding: '1px 6px', borderRadius: 3, fontSize: 9, fontWeight: 700,
|
}} className="rounded-sm px-1.5 py-px text-[9px] font-bold text-white">
|
||||||
}}>{selected.category.replace('_', ' ').toUpperCase()}</span>
|
{selected.category.replace('_', ' ').toUpperCase()}
|
||||||
<span style={{
|
</span>
|
||||||
background: '#1a1a2e', color: '#888',
|
<span className="rounded-sm bg-kcg-card px-1.5 py-px text-[9px] text-kcg-muted">
|
||||||
padding: '1px 6px', borderRadius: 3, fontSize: 9,
|
{selected.source}
|
||||||
}}>{selected.source}</span>
|
</span>
|
||||||
<span style={{
|
<span className="rounded-sm bg-kcg-card px-1.5 py-px text-[9px] text-kcg-dim">
|
||||||
background: '#1a1a2e', color: '#666',
|
{timeAgo(selected.timestamp)}
|
||||||
padding: '1px 6px', borderRadius: 3, fontSize: 9,
|
</span>
|
||||||
}}>{timeAgo(selected.timestamp)}</span>
|
|
||||||
</div>
|
</div>
|
||||||
{selected.url && (
|
{selected.url && (
|
||||||
<a
|
<a
|
||||||
href={selected.url}
|
href={selected.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
style={{ fontSize: 10, color: '#3b82f6', textDecoration: 'underline' }}
|
className="text-[10px] text-kcg-accent underline"
|
||||||
>기사 원문 보기</a>
|
>{t('osintMap.viewOriginal')}</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Popup>
|
</Popup>
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Marker, Popup } from 'react-map-gl/maplibre';
|
import { Marker, Popup } from 'react-map-gl/maplibre';
|
||||||
import { PIRACY_ZONES, PIRACY_LEVEL_COLOR, PIRACY_LEVEL_LABEL } from '../services/piracy';
|
import { PIRACY_ZONES, PIRACY_LEVEL_COLOR, PIRACY_LEVEL_LABEL } from '../services/piracy';
|
||||||
import type { PiracyZone } from '../services/piracy';
|
import type { PiracyZone } from '../services/piracy';
|
||||||
@ -6,16 +7,11 @@ import type { PiracyZone } from '../services/piracy';
|
|||||||
function SkullIcon({ color, size }: { color: string; size: number }) {
|
function SkullIcon({ color, size }: { color: string; size: number }) {
|
||||||
return (
|
return (
|
||||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||||
{/* skull */}
|
|
||||||
<ellipse cx="12" cy="10" rx="8" ry="9" fill="rgba(0,0,0,0.6)" stroke={color} strokeWidth="1.5" />
|
<ellipse cx="12" cy="10" rx="8" ry="9" fill="rgba(0,0,0,0.6)" stroke={color} strokeWidth="1.5" />
|
||||||
{/* eyes */}
|
|
||||||
<ellipse cx="8.5" cy="9" rx="2" ry="2.2" fill={color} opacity="0.9" />
|
<ellipse cx="8.5" cy="9" rx="2" ry="2.2" fill={color} opacity="0.9" />
|
||||||
<ellipse cx="15.5" cy="9" rx="2" ry="2.2" fill={color} opacity="0.9" />
|
<ellipse cx="15.5" cy="9" rx="2" ry="2.2" fill={color} opacity="0.9" />
|
||||||
{/* nose */}
|
|
||||||
<path d="M11 13 L12 14.5 L13 13" stroke={color} strokeWidth="1" fill="none" />
|
<path d="M11 13 L12 14.5 L13 13" stroke={color} strokeWidth="1" fill="none" />
|
||||||
{/* jaw */}
|
|
||||||
<path d="M7 17 Q12 21 17 17" stroke={color} strokeWidth="1.2" fill="none" />
|
<path d="M7 17 Q12 21 17 17" stroke={color} strokeWidth="1.2" fill="none" />
|
||||||
{/* crossbones */}
|
|
||||||
<line x1="4" y1="20" x2="20" y2="24" stroke={color} strokeWidth="1.5" strokeLinecap="round" />
|
<line x1="4" y1="20" x2="20" y2="24" stroke={color} strokeWidth="1.5" strokeLinecap="round" />
|
||||||
<line x1="20" y1="20" x2="4" y2="24" stroke={color} strokeWidth="1.5" strokeLinecap="round" />
|
<line x1="20" y1="20" x2="4" y2="24" stroke={color} strokeWidth="1.5" strokeLinecap="round" />
|
||||||
</svg>
|
</svg>
|
||||||
@ -24,6 +20,7 @@ function SkullIcon({ color, size }: { color: string; size: number }) {
|
|||||||
|
|
||||||
export function PiracyLayer() {
|
export function PiracyLayer() {
|
||||||
const [selected, setSelected] = useState<PiracyZone | null>(null);
|
const [selected, setSelected] = useState<PiracyZone | null>(null);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -34,17 +31,15 @@ export function PiracyLayer() {
|
|||||||
<Marker key={zone.id} longitude={zone.lng} latitude={zone.lat} anchor="center"
|
<Marker key={zone.id} longitude={zone.lng} latitude={zone.lat} anchor="center"
|
||||||
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(zone); }}>
|
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(zone); }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
cursor: 'pointer', display: 'flex', flexDirection: 'column', alignItems: 'center',
|
cursor: 'pointer',
|
||||||
filter: `drop-shadow(0 0 8px ${color}aa)`,
|
filter: `drop-shadow(0 0 8px ${color}aa)`,
|
||||||
animation: zone.level === 'critical' ? 'pulse 2s ease-in-out infinite' : undefined,
|
animation: zone.level === 'critical' ? 'pulse 2s ease-in-out infinite' : undefined,
|
||||||
}}>
|
}} className="flex flex-col items-center">
|
||||||
<SkullIcon color={color} size={size} />
|
<SkullIcon color={color} size={size} />
|
||||||
<div style={{
|
<div style={{
|
||||||
fontSize: 7, color, marginTop: 1,
|
fontSize: 7, color,
|
||||||
textShadow: `0 0 3px ${color}, 0 0 2px #000`,
|
textShadow: `0 0 3px ${color}, 0 0 2px #000`,
|
||||||
whiteSpace: 'nowrap', fontWeight: 700, letterSpacing: 0.3,
|
}} className="mt-px whitespace-nowrap font-mono font-bold tracking-wide">
|
||||||
fontFamily: 'monospace',
|
|
||||||
}}>
|
|
||||||
{PIRACY_LEVEL_LABEL[zone.level]}
|
{PIRACY_LEVEL_LABEL[zone.level]}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -56,43 +51,40 @@ export function PiracyLayer() {
|
|||||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||||
onClose={() => setSelected(null)} closeOnClick={false}
|
onClose={() => setSelected(null)} closeOnClick={false}
|
||||||
anchor="bottom" maxWidth="340px" className="gl-popup">
|
anchor="bottom" maxWidth="340px" className="gl-popup">
|
||||||
<div style={{ fontFamily: 'monospace', fontSize: 12, minWidth: 260 }}>
|
<div className="min-w-[260px] font-mono text-xs">
|
||||||
<div style={{
|
<div style={{
|
||||||
background: PIRACY_LEVEL_COLOR[selected.level], color: '#fff',
|
background: PIRACY_LEVEL_COLOR[selected.level],
|
||||||
padding: '5px 10px', borderRadius: '4px 4px 0 0',
|
}} className="-mx-2.5 -mt-2.5 mb-2 flex items-center gap-1.5 rounded-t px-2.5 py-1.5 text-xs font-bold text-white">
|
||||||
margin: '-10px -10px 8px -10px',
|
<span className="text-sm">☠️</span>
|
||||||
fontWeight: 700, fontSize: 12,
|
|
||||||
display: 'flex', alignItems: 'center', gap: 6,
|
|
||||||
}}>
|
|
||||||
<span style={{ fontSize: 14 }}>☠️</span>
|
|
||||||
{selected.nameKo}
|
{selected.nameKo}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
|
<div className="mb-1.5 flex flex-wrap gap-1">
|
||||||
<span style={{
|
<span style={{
|
||||||
background: PIRACY_LEVEL_COLOR[selected.level], color: '#fff',
|
background: PIRACY_LEVEL_COLOR[selected.level],
|
||||||
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
}} className="rounded-sm px-1.5 py-px text-[10px] font-bold text-white">
|
||||||
}}>{PIRACY_LEVEL_LABEL[selected.level]}</span>
|
{PIRACY_LEVEL_LABEL[selected.level]}
|
||||||
<span style={{
|
</span>
|
||||||
background: '#1a1a2e', color: '#888',
|
<span className="rounded-sm bg-kcg-card px-1.5 py-px text-[10px] text-kcg-muted">
|
||||||
padding: '1px 6px', borderRadius: 3, fontSize: 10,
|
{selected.name}
|
||||||
}}>{selected.name}</span>
|
</span>
|
||||||
{selected.recentIncidents != null && (
|
{selected.recentIncidents != null && (
|
||||||
<span style={{
|
<span style={{
|
||||||
background: '#1a1a2e', color: PIRACY_LEVEL_COLOR[selected.level],
|
color: PIRACY_LEVEL_COLOR[selected.level],
|
||||||
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
|
||||||
border: `1px solid ${PIRACY_LEVEL_COLOR[selected.level]}44`,
|
border: `1px solid ${PIRACY_LEVEL_COLOR[selected.level]}44`,
|
||||||
}}>최근 1년 {selected.recentIncidents}건</span>
|
}} className="rounded-sm bg-kcg-card px-1.5 py-px text-[10px] font-bold">
|
||||||
|
{t('piracy.recentIncidents', { count: selected.recentIncidents })}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ fontSize: 11, color: '#ccc', marginBottom: 6, lineHeight: 1.5 }}>
|
<div className="mb-1.5 text-[11px] leading-relaxed text-kcg-text-secondary">
|
||||||
{selected.description}
|
{selected.description}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 10, color: '#999', lineHeight: 1.4 }}>
|
<div className="text-[10px] leading-snug text-[#999]">
|
||||||
{selected.detail}
|
{selected.detail}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 9, color: '#666', marginTop: 6 }}>
|
<div className="mt-1.5 text-[9px] text-kcg-dim">
|
||||||
{selected.lat.toFixed(2)}°N, {selected.lng.toFixed(2)}°E
|
{selected.lat.toFixed(2)}°N, {selected.lng.toFixed(2)}°E
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
@ -51,6 +52,7 @@ export function ReplayControls({
|
|||||||
onSpeedChange,
|
onSpeedChange,
|
||||||
onRangeChange,
|
onRangeChange,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [showPicker, setShowPicker] = useState(false);
|
const [showPicker, setShowPicker] = useState(false);
|
||||||
const [customStart, setCustomStart] = useState(toKSTInput(startTime));
|
const [customStart, setCustomStart] = useState(toKSTInput(startTime));
|
||||||
const [customEnd, setCustomEnd] = useState(toKSTInput(endTime));
|
const [customEnd, setCustomEnd] = useState(toKSTInput(endTime));
|
||||||
@ -76,7 +78,7 @@ export function ReplayControls({
|
|||||||
return (
|
return (
|
||||||
<div className="replay-controls">
|
<div className="replay-controls">
|
||||||
{/* Left: transport controls */}
|
{/* Left: transport controls */}
|
||||||
<button className="ctrl-btn" onClick={onReset} title="Reset">
|
<button className="ctrl-btn" onClick={onReset} title={t('controls.reset')}>
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<path d="M1 4v6h6" />
|
<path d="M1 4v6h6" />
|
||||||
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
|
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
|
||||||
@ -109,7 +111,7 @@ export function ReplayControls({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Spacer */}
|
{/* Spacer */}
|
||||||
<div style={{ flex: 1 }} />
|
<div className="flex-1" />
|
||||||
|
|
||||||
{/* Right: range presets + custom picker */}
|
{/* Right: range presets + custom picker */}
|
||||||
<div className="range-controls">
|
<div className="range-controls">
|
||||||
@ -126,7 +128,7 @@ export function ReplayControls({
|
|||||||
<button
|
<button
|
||||||
className={`range-btn custom-btn ${showPicker ? 'active' : ''}`}
|
className={`range-btn custom-btn ${showPicker ? 'active' : ''}`}
|
||||||
onClick={() => setShowPicker(!showPicker)}
|
onClick={() => setShowPicker(!showPicker)}
|
||||||
title="Custom range"
|
title={t('controls.customRange')}
|
||||||
>
|
>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<rect x="3" y="4" width="18" height="18" rx="2" />
|
<rect x="3" y="4" width="18" height="18" rx="2" />
|
||||||
@ -141,7 +143,7 @@ export function ReplayControls({
|
|||||||
<div className="range-picker">
|
<div className="range-picker">
|
||||||
<div className="range-picker-row">
|
<div className="range-picker-row">
|
||||||
<label>
|
<label>
|
||||||
<span>FROM (KST)</span>
|
<span>{t('controls.from')}</span>
|
||||||
<input
|
<input
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
value={customStart}
|
value={customStart}
|
||||||
@ -149,7 +151,7 @@ export function ReplayControls({
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>TO (KST)</span>
|
<span>{t('controls.to')}</span>
|
||||||
<input
|
<input
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
value={customEnd}
|
value={customEnd}
|
||||||
@ -157,7 +159,7 @@ export function ReplayControls({
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<button className="range-apply-btn" onClick={handleCustomApply}>
|
<button className="range-apply-btn" onClick={handleCustomApply}>
|
||||||
APPLY
|
{t('controls.apply')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState, useRef } from 'react';
|
import { useEffect, useMemo, useState, useRef } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Map, Marker, Popup, Source, Layer, NavigationControl } from 'react-map-gl/maplibre';
|
import { Map, Marker, Popup, Source, Layer, NavigationControl } from 'react-map-gl/maplibre';
|
||||||
import type { MapRef } from 'react-map-gl/maplibre';
|
import type { MapRef } from 'react-map-gl/maplibre';
|
||||||
import { AircraftLayer } from './AircraftLayer';
|
import { AircraftLayer } from './AircraftLayer';
|
||||||
@ -105,6 +106,7 @@ const EVENT_RADIUS: Record<GeoEvent['type'], number> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function ReplayMap({ events, currentTime, aircraft, satellites, ships, layers, flyToTarget, onFlyToDone, initialCenter, initialZoom }: Props) {
|
export function ReplayMap({ events, currentTime, aircraft, satellites, ships, layers, flyToTarget, onFlyToDone, initialCenter, initialZoom }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const mapRef = useRef<MapRef>(null);
|
const mapRef = useRef<MapRef>(null);
|
||||||
const [selectedEventId, setSelectedEventId] = useState<string | null>(null);
|
const [selectedEventId, setSelectedEventId] = useState<string | null>(null);
|
||||||
|
|
||||||
@ -182,7 +184,6 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
|
|||||||
>
|
>
|
||||||
<NavigationControl position="top-right" />
|
<NavigationControl position="top-right" />
|
||||||
|
|
||||||
{/* 한글 국가명 라벨 */}
|
|
||||||
<Source id="country-labels" type="geojson" data={countryLabelsGeoJSON()}>
|
<Source id="country-labels" type="geojson" data={countryLabelsGeoJSON()}>
|
||||||
<Layer
|
<Layer
|
||||||
id="country-label-lg"
|
id="country-label-lg"
|
||||||
@ -266,9 +267,9 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
|
|||||||
const size = EVENT_RADIUS[event.type] * 5;
|
const size = EVENT_RADIUS[event.type] * 5;
|
||||||
return (
|
return (
|
||||||
<Marker key={`pulse-${event.id}`} longitude={event.lng} latitude={event.lat} anchor="center">
|
<Marker key={`pulse-${event.id}`} longitude={event.lng} latitude={event.lat} anchor="center">
|
||||||
<div className="gl-pulse-ring" style={{
|
<div className="gl-pulse-ring rounded-full pointer-events-none" style={{
|
||||||
width: size, height: size, borderRadius: '50%',
|
width: size, height: size,
|
||||||
border: `2px solid ${color}`, pointerEvents: 'none',
|
border: `2px solid ${color}`,
|
||||||
}} />
|
}} />
|
||||||
</Marker>
|
</Marker>
|
||||||
);
|
);
|
||||||
@ -279,9 +280,9 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
|
|||||||
const size = event.type === 'impact' ? 100 : 70;
|
const size = event.type === 'impact' ? 100 : 70;
|
||||||
return (
|
return (
|
||||||
<Marker key={`shock-${event.id}`} longitude={event.lng} latitude={event.lat} anchor="center">
|
<Marker key={`shock-${event.id}`} longitude={event.lng} latitude={event.lat} anchor="center">
|
||||||
<div className="gl-shockwave" style={{
|
<div className="gl-shockwave rounded-full pointer-events-none" style={{
|
||||||
width: size, height: size, borderRadius: '50%',
|
width: size, height: size,
|
||||||
border: `3px solid ${color}`, pointerEvents: 'none',
|
border: `3px solid ${color}`,
|
||||||
}} />
|
}} />
|
||||||
</Marker>
|
</Marker>
|
||||||
);
|
);
|
||||||
@ -292,9 +293,9 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
|
|||||||
const size = event.type === 'impact' ? 40 : 30;
|
const size = event.type === 'impact' ? 40 : 30;
|
||||||
return (
|
return (
|
||||||
<Marker key={`flash-${event.id}`} longitude={event.lng} latitude={event.lat} anchor="center">
|
<Marker key={`flash-${event.id}`} longitude={event.lng} latitude={event.lat} anchor="center">
|
||||||
<div className="gl-strike-flash" style={{
|
<div className="gl-strike-flash rounded-full opacity-60 pointer-events-none" style={{
|
||||||
width: size, height: size, borderRadius: '50%',
|
width: size, height: size,
|
||||||
background: color, opacity: 0.6, pointerEvents: 'none',
|
background: color,
|
||||||
}} />
|
}} />
|
||||||
</Marker>
|
</Marker>
|
||||||
);
|
);
|
||||||
@ -304,11 +305,10 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
|
|||||||
const ageMs = currentTime - event.timestamp;
|
const ageMs = currentTime - event.timestamp;
|
||||||
const ageHours = ageMs / 3600_000;
|
const ageHours = ageMs / 3600_000;
|
||||||
const DAY_H = 24;
|
const DAY_H = 24;
|
||||||
// 최근 1일 이내: 진하게 (opacity 0.85~1.0), 그 이후: 흐리게 (0.15~0.4)
|
|
||||||
const isRecent = ageHours <= DAY_H;
|
const isRecent = ageHours <= DAY_H;
|
||||||
const opacity = isRecent
|
const opacity = isRecent
|
||||||
? Math.max(0.85, 1 - ageHours * 0.006) // 1일 내: 1.0→0.85
|
? Math.max(0.85, 1 - ageHours * 0.006)
|
||||||
: Math.max(0.15, 0.4 - (ageHours - DAY_H) * 0.005); // 1일 후: 0.4→0.15
|
: Math.max(0.15, 0.4 - (ageHours - DAY_H) * 0.005);
|
||||||
const color = getEventColor(event);
|
const color = getEventColor(event);
|
||||||
const isNew = ageMs >= 0 && ageMs < 600_000;
|
const isNew = ageMs >= 0 && ageMs < 600_000;
|
||||||
const baseR = EVENT_RADIUS[event.type];
|
const baseR = EVENT_RADIUS[event.type];
|
||||||
@ -318,8 +318,7 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
|
|||||||
return (
|
return (
|
||||||
<Marker key={event.id} longitude={event.lng} latitude={event.lat} anchor="center">
|
<Marker key={event.id} longitude={event.lng} latitude={event.lat} anchor="center">
|
||||||
<div
|
<div
|
||||||
className={isNew ? 'gl-event-flash' : undefined}
|
className={`cursor-pointer ${isNew ? 'gl-event-flash' : ''}`}
|
||||||
style={{ cursor: 'pointer' }}
|
|
||||||
onClick={(e) => { e.stopPropagation(); setSelectedEventId(event.id); }}
|
onClick={(e) => { e.stopPropagation(); setSelectedEventId(event.id); }}
|
||||||
>
|
>
|
||||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
||||||
@ -339,7 +338,6 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
|
|||||||
const ageMs = currentTime - event.timestamp;
|
const ageMs = currentTime - event.timestamp;
|
||||||
const ageHours = ageMs / 3600_000;
|
const ageHours = ageMs / 3600_000;
|
||||||
const isRecent = ageHours <= 24;
|
const isRecent = ageHours <= 24;
|
||||||
// 최근 1일: 진하게, 이후: 흐리게
|
|
||||||
const impactOpacity = isRecent
|
const impactOpacity = isRecent
|
||||||
? Math.max(0.8, 1 - ageHours * 0.008)
|
? Math.max(0.8, 1 - ageHours * 0.008)
|
||||||
: Math.max(0.2, 0.45 - (ageHours - 24) * 0.005);
|
: Math.max(0.2, 0.45 - (ageHours - 24) * 0.005);
|
||||||
@ -348,7 +346,7 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
|
|||||||
const sw = isRecent ? 1.5 : 1;
|
const sw = isRecent ? 1.5 : 1;
|
||||||
return (
|
return (
|
||||||
<Marker key={event.id} longitude={event.lng} latitude={event.lat} anchor="center">
|
<Marker key={event.id} longitude={event.lng} latitude={event.lat} anchor="center">
|
||||||
<div style={{ position: 'relative', cursor: 'pointer', opacity: impactOpacity }}
|
<div className="relative cursor-pointer" style={{ opacity: impactOpacity }}
|
||||||
onClick={(e) => { e.stopPropagation(); setSelectedEventId(event.id); }}>
|
onClick={(e) => { e.stopPropagation(); setSelectedEventId(event.id); }}>
|
||||||
<svg viewBox={`0 0 ${s} ${s}`} width={s} height={s}>
|
<svg viewBox={`0 0 ${s} ${s}`} width={s} height={s}>
|
||||||
<circle cx={c} cy={c} r={c * 0.77} fill="none" stroke="#ff0000" strokeWidth={sw} opacity={0.8} />
|
<circle cx={c} cy={c} r={c * 0.77} fill="none" stroke="#ff0000" strokeWidth={sw} opacity={0.8} />
|
||||||
@ -378,47 +376,43 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
|
|||||||
maxWidth="320px"
|
maxWidth="320px"
|
||||||
className="gl-popup"
|
className="gl-popup"
|
||||||
>
|
>
|
||||||
<div style={{ minWidth: 200, maxWidth: 320 }}>
|
<div className="min-w-[200px] max-w-[320px]">
|
||||||
{selectedEvent.source && (
|
{selectedEvent.source && (
|
||||||
<span style={{
|
<span
|
||||||
background: getEventColor(selectedEvent), color: '#fff', padding: '1px 6px',
|
className="inline-block rounded-sm text-[10px] font-bold text-white mb-1 px-1.5 py-px"
|
||||||
borderRadius: 3, fontSize: 10, fontWeight: 700, marginBottom: 4, display: 'inline-block',
|
style={{ background: getEventColor(selectedEvent) }}
|
||||||
}}>
|
>
|
||||||
{{ US: '미국', IL: '이스라엘', IR: '이란', proxy: '대리세력' }[selectedEvent.source]}
|
{t(`source.${selectedEvent.source}`)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{selectedEvent.type === 'impact' && (
|
{selectedEvent.type === 'impact' && (
|
||||||
<div style={{
|
<div className="inline-block rounded-sm text-[11px] font-bold text-white mb-1.5 px-2 py-[3px] bg-kcg-event-impact">
|
||||||
background: '#ff0000', color: '#fff', padding: '3px 8px',
|
{t('popup.impactSite')}
|
||||||
borderRadius: 3, fontSize: 11, fontWeight: 700, marginBottom: 6,
|
|
||||||
display: 'inline-block',
|
|
||||||
}}>
|
|
||||||
IMPACT SITE
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div><strong>{selectedEvent.label}</strong></div>
|
<div><strong>{selectedEvent.label}</strong></div>
|
||||||
<span style={{ fontSize: 12, color: '#888' }}>
|
<span className="text-xs text-kcg-muted">
|
||||||
{new Date(selectedEvent.timestamp + 9 * 3600_000).toISOString().slice(0, 16).replace('T', ' ')} KST
|
{new Date(selectedEvent.timestamp + 9 * 3600_000).toISOString().slice(0, 16).replace('T', ' ')} KST
|
||||||
</span>
|
</span>
|
||||||
{selectedEvent.description && (
|
{selectedEvent.description && (
|
||||||
<p style={{ margin: '6px 0 0', fontSize: 13 }}>{selectedEvent.description}</p>
|
<p className="mt-1.5 mb-0 text-[13px]">{selectedEvent.description}</p>
|
||||||
)}
|
)}
|
||||||
{selectedEvent.imageUrl && (
|
{selectedEvent.imageUrl && (
|
||||||
<div style={{ marginTop: 8 }}>
|
<div className="mt-2">
|
||||||
<img
|
<img
|
||||||
src={selectedEvent.imageUrl}
|
src={selectedEvent.imageUrl}
|
||||||
alt={selectedEvent.imageCaption || selectedEvent.label}
|
alt={selectedEvent.imageCaption || selectedEvent.label}
|
||||||
style={{ width: '100%', borderRadius: 4, maxHeight: 180, objectFit: 'cover' }}
|
className="w-full rounded max-h-[180px] object-cover"
|
||||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||||
/>
|
/>
|
||||||
{selectedEvent.imageCaption && (
|
{selectedEvent.imageCaption && (
|
||||||
<div style={{ fontSize: 10, color: '#aaa', marginTop: 3 }}>{selectedEvent.imageCaption}</div>
|
<div className="text-[10px] text-kcg-muted mt-[3px]">{selectedEvent.imageCaption}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{selectedEvent.type === 'impact' && (
|
{selectedEvent.type === 'impact' && (
|
||||||
<div style={{ fontSize: 10, color: '#888', marginTop: 6 }}>
|
<div className="text-[10px] text-kcg-muted mt-1.5">
|
||||||
{selectedEvent.lat.toFixed(4)}°N, {selectedEvent.lng.toFixed(4)}°E
|
{selectedEvent.lat.toFixed(4)}°N, {selectedEvent.lng.toFixed(4)}°E
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { memo, useMemo, useState } from 'react';
|
import { memo, useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Marker, Popup, Source, Layer } from 'react-map-gl/maplibre';
|
import { Marker, Popup, Source, Layer } from 'react-map-gl/maplibre';
|
||||||
import type { SatellitePosition } from '../types';
|
import type { SatellitePosition } from '../types';
|
||||||
|
|
||||||
@ -72,13 +73,11 @@ const SVG_MAP: Record<SatellitePosition['category'], React.ReactNode> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function SatelliteLayer({ satellites }: Props) {
|
export function SatelliteLayer({ satellites }: Props) {
|
||||||
// Ground tracks as GeoJSON
|
|
||||||
const trackData = useMemo(() => {
|
const trackData = useMemo(() => {
|
||||||
const features: GeoJSON.Feature[] = [];
|
const features: GeoJSON.Feature[] = [];
|
||||||
for (const sat of satellites) {
|
for (const sat of satellites) {
|
||||||
if (!sat.groundTrack || sat.groundTrack.length < 2) continue;
|
if (!sat.groundTrack || sat.groundTrack.length < 2) continue;
|
||||||
const color = CAT_COLORS[sat.category];
|
const color = CAT_COLORS[sat.category];
|
||||||
// Break at antimeridian crossings
|
|
||||||
let segment: [number, number][] = [];
|
let segment: [number, number][] = [];
|
||||||
for (let i = 0; i < sat.groundTrack.length; i++) {
|
for (let i = 0; i < sat.groundTrack.length; i++) {
|
||||||
const [lat, lng] = sat.groundTrack[i];
|
const [lat, lng] = sat.groundTrack[i];
|
||||||
@ -133,6 +132,7 @@ export function SatelliteLayer({ satellites }: Props) {
|
|||||||
|
|
||||||
const SatelliteMarker = memo(function SatelliteMarker({ sat }: { sat: SatellitePosition }) {
|
const SatelliteMarker = memo(function SatelliteMarker({ sat }: { sat: SatellitePosition }) {
|
||||||
const [showPopup, setShowPopup] = useState(false);
|
const [showPopup, setShowPopup] = useState(false);
|
||||||
|
const { t } = useTranslation();
|
||||||
const color = CAT_COLORS[sat.category];
|
const color = CAT_COLORS[sat.category];
|
||||||
const svgBody = SVG_MAP[sat.category];
|
const svgBody = SVG_MAP[sat.category];
|
||||||
const size = 22;
|
const size = 22;
|
||||||
@ -140,9 +140,9 @@ const SatelliteMarker = memo(function SatelliteMarker({ sat }: { sat: SatelliteP
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Marker longitude={sat.lng} latitude={sat.lat} anchor="center">
|
<Marker longitude={sat.lng} latitude={sat.lat} anchor="center">
|
||||||
<div style={{ position: 'relative' }}>
|
<div className="relative">
|
||||||
<div
|
<div
|
||||||
style={{ width: size, height: size, color, cursor: 'pointer' }}
|
style={{ color }} className="size-[22px] cursor-pointer"
|
||||||
onClick={(e) => { e.stopPropagation(); setShowPopup(true); }}
|
onClick={(e) => { e.stopPropagation(); setShowPopup(true); }}
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 24 24" width={size} height={size} style={{ color }}>
|
<svg viewBox="0 0 24 24" width={size} height={size} style={{ color }}>
|
||||||
@ -158,20 +158,21 @@ const SatelliteMarker = memo(function SatelliteMarker({ sat }: { sat: SatelliteP
|
|||||||
<Popup longitude={sat.lng} latitude={sat.lat}
|
<Popup longitude={sat.lng} latitude={sat.lat}
|
||||||
onClose={() => setShowPopup(false)} closeOnClick={false}
|
onClose={() => setShowPopup(false)} closeOnClick={false}
|
||||||
anchor="bottom" maxWidth="200px" className="gl-popup">
|
anchor="bottom" maxWidth="200px" className="gl-popup">
|
||||||
<div style={{ minWidth: 180, fontFamily: 'monospace', fontSize: 12 }}>
|
<div className="min-w-[180px] font-mono text-xs">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
<div className="mb-1.5 flex items-center gap-2">
|
||||||
<span style={{
|
<span style={{
|
||||||
background: color, color: '#000', padding: '1px 6px',
|
background: color,
|
||||||
borderRadius: 3, fontSize: 10, fontWeight: 700,
|
}} className="rounded-sm px-1.5 py-px text-[10px] font-bold text-black">
|
||||||
}}>{CAT_LABELS[sat.category]}</span>
|
{CAT_LABELS[sat.category]}
|
||||||
|
</span>
|
||||||
<strong>{sat.name}</strong>
|
<strong>{sat.name}</strong>
|
||||||
</div>
|
</div>
|
||||||
<table style={{ width: '100%', fontSize: 11 }}>
|
<table className="w-full text-[11px]">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr><td style={{ color: '#888' }}>NORAD</td><td>{sat.noradId}</td></tr>
|
<tr><td className="text-kcg-muted">{t('satellite.norad')}</td><td>{sat.noradId}</td></tr>
|
||||||
<tr><td style={{ color: '#888' }}>Lat</td><td>{sat.lat.toFixed(2)}°</td></tr>
|
<tr><td className="text-kcg-muted">{t('satellite.lat')}</td><td>{sat.lat.toFixed(2)}°</td></tr>
|
||||||
<tr><td style={{ color: '#888' }}>Lng</td><td>{sat.lng.toFixed(2)}°</td></tr>
|
<tr><td className="text-kcg-muted">{t('satellite.lng')}</td><td>{sat.lng.toFixed(2)}°</td></tr>
|
||||||
<tr><td style={{ color: '#888' }}>Alt</td><td>{Math.round(sat.altitude)} km</td></tr>
|
<tr><td className="text-kcg-muted">{t('satellite.alt')}</td><td>{Math.round(sat.altitude)} km</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { useMemo, useState, useRef } from 'react';
|
import { useMemo, useState, useRef } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Map, Marker, Popup, Source, Layer, NavigationControl } from 'react-map-gl/maplibre';
|
import { Map, Marker, Popup, Source, Layer, NavigationControl } from 'react-map-gl/maplibre';
|
||||||
import type { MapRef } from 'react-map-gl/maplibre';
|
import type { MapRef } from 'react-map-gl/maplibre';
|
||||||
import { AircraftLayer } from './AircraftLayer';
|
import { AircraftLayer } from './AircraftLayer';
|
||||||
@ -86,6 +87,7 @@ const EVENT_RADIUS: Record<GeoEvent['type'], number> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function SatelliteMap({ events, currentTime, aircraft, satellites, ships, layers }: Props) {
|
export function SatelliteMap({ events, currentTime, aircraft, satellites, ships, layers }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const mapRef = useRef<MapRef>(null);
|
const mapRef = useRef<MapRef>(null);
|
||||||
const [selectedEventId, setSelectedEventId] = useState<string | null>(null);
|
const [selectedEventId, setSelectedEventId] = useState<string | null>(null);
|
||||||
|
|
||||||
@ -179,14 +181,13 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships,
|
|||||||
onClick={e => { e.originalEvent.stopPropagation(); setSelectedEventId(ev.id); }}
|
onClick={e => { e.originalEvent.stopPropagation(); setSelectedEventId(ev.id); }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
className="rounded-full cursor-pointer"
|
||||||
style={{
|
style={{
|
||||||
width: EVENT_RADIUS[ev.type],
|
width: EVENT_RADIUS[ev.type],
|
||||||
height: EVENT_RADIUS[ev.type],
|
height: EVENT_RADIUS[ev.type],
|
||||||
borderRadius: '50%',
|
|
||||||
background: getEventColor(ev),
|
background: getEventColor(ev),
|
||||||
border: '2px solid rgba(255,255,255,0.8)',
|
border: '2px solid rgba(255,255,255,0.8)',
|
||||||
boxShadow: `0 0 8px ${getEventColor(ev)}`,
|
boxShadow: `0 0 8px ${getEventColor(ev)}`,
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Marker>
|
</Marker>
|
||||||
@ -203,46 +204,42 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships,
|
|||||||
maxWidth="320px"
|
maxWidth="320px"
|
||||||
className="event-popup"
|
className="event-popup"
|
||||||
>
|
>
|
||||||
<div style={{ minWidth: 200, maxWidth: 320 }}>
|
<div className="min-w-[200px] max-w-[320px]">
|
||||||
{selectedEvent.source && (
|
{selectedEvent.source && (
|
||||||
<span style={{
|
<span
|
||||||
background: getEventColor(selectedEvent), color: '#fff', padding: '1px 6px',
|
className="inline-block rounded-sm text-[10px] font-bold text-white mb-1 px-1.5 py-px"
|
||||||
borderRadius: 3, fontSize: 10, fontWeight: 700, marginBottom: 4, display: 'inline-block',
|
style={{ background: getEventColor(selectedEvent) }}
|
||||||
}}>
|
>
|
||||||
{{ US: '미국', IL: '이스라엘', IR: '이란', proxy: '대리세력' }[selectedEvent.source]}
|
{t(`source.${selectedEvent.source}`)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{selectedEvent.type === 'impact' && (
|
{selectedEvent.type === 'impact' && (
|
||||||
<div style={{
|
<div className="inline-block rounded-sm text-[11px] font-bold text-white mb-1.5 px-2 py-[3px] bg-kcg-event-impact">
|
||||||
background: '#ff0000', color: '#fff', padding: '3px 8px',
|
{t('popup.impactSite')}
|
||||||
borderRadius: 3, fontSize: 11, fontWeight: 700, marginBottom: 6,
|
|
||||||
display: 'inline-block',
|
|
||||||
}}>
|
|
||||||
IMPACT SITE
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div style={{ color: '#e0e0e0' }}><strong>{selectedEvent.label}</strong></div>
|
<div className="text-kcg-text"><strong>{selectedEvent.label}</strong></div>
|
||||||
<span style={{ fontSize: 12, color: '#888' }}>
|
<span className="text-xs text-kcg-muted">
|
||||||
{new Date(selectedEvent.timestamp + 9 * 3600_000).toISOString().slice(0, 16).replace('T', ' ')} KST
|
{new Date(selectedEvent.timestamp + 9 * 3600_000).toISOString().slice(0, 16).replace('T', ' ')} KST
|
||||||
</span>
|
</span>
|
||||||
{selectedEvent.description && (
|
{selectedEvent.description && (
|
||||||
<p style={{ margin: '6px 0 0', fontSize: 13, color: '#ccc' }}>{selectedEvent.description}</p>
|
<p className="mt-1.5 mb-0 text-[13px] text-kcg-text-secondary">{selectedEvent.description}</p>
|
||||||
)}
|
)}
|
||||||
{selectedEvent.imageUrl && (
|
{selectedEvent.imageUrl && (
|
||||||
<div style={{ marginTop: 8 }}>
|
<div className="mt-2">
|
||||||
<img
|
<img
|
||||||
src={selectedEvent.imageUrl}
|
src={selectedEvent.imageUrl}
|
||||||
alt={selectedEvent.imageCaption || selectedEvent.label}
|
alt={selectedEvent.imageCaption || selectedEvent.label}
|
||||||
style={{ width: '100%', borderRadius: 4, maxHeight: 180, objectFit: 'cover' }}
|
className="w-full rounded max-h-[180px] object-cover"
|
||||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||||
/>
|
/>
|
||||||
{selectedEvent.imageCaption && (
|
{selectedEvent.imageCaption && (
|
||||||
<div style={{ fontSize: 10, color: '#aaa', marginTop: 3 }}>{selectedEvent.imageCaption}</div>
|
<div className="text-[10px] text-kcg-muted mt-[3px]">{selectedEvent.imageCaption}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div style={{ fontSize: 10, color: '#666', marginTop: 6 }}>
|
<div className="text-[10px] text-kcg-dim mt-1.5">
|
||||||
{selectedEvent.lat.toFixed(4)}°N, {selectedEvent.lng.toFixed(4)}°E
|
{selectedEvent.lat.toFixed(4)}°N, {selectedEvent.lng.toFixed(4)}°E
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Popup>
|
</Popup>
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
LineChart,
|
LineChart,
|
||||||
Line,
|
Line,
|
||||||
@ -19,6 +20,8 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SensorChart({ data, currentTime, startTime }: Props) {
|
export function SensorChart({ data, currentTime, startTime }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const visibleData = useMemo(
|
const visibleData = useMemo(
|
||||||
() => data.filter(d => d.timestamp <= currentTime),
|
() => data.filter(d => d.timestamp <= currentTime),
|
||||||
[data, currentTime],
|
[data, currentTime],
|
||||||
@ -35,10 +38,10 @@ export function SensorChart({ data, currentTime, startTime }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sensor-chart">
|
<div className="sensor-chart">
|
||||||
<h3>Sensor Data</h3>
|
<h3>{t('sensor.title')}</h3>
|
||||||
<div className="chart-grid">
|
<div className="chart-grid">
|
||||||
<div className="chart-item">
|
<div className="chart-item">
|
||||||
<h4>Seismic Activity</h4>
|
<h4>{t('sensor.seismicActivity')}</h4>
|
||||||
<ResponsiveContainer width="100%" height={80}>
|
<ResponsiveContainer width="100%" height={80}>
|
||||||
<LineChart data={chartData}>
|
<LineChart data={chartData}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
|
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
|
||||||
@ -52,7 +55,7 @@ export function SensorChart({ data, currentTime, startTime }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="chart-item">
|
<div className="chart-item">
|
||||||
<h4>Noise Level (dB)</h4>
|
<h4>{t('sensor.noiseLevelDb')}</h4>
|
||||||
<ResponsiveContainer width="100%" height={80}>
|
<ResponsiveContainer width="100%" height={80}>
|
||||||
<LineChart data={chartData}>
|
<LineChart data={chartData}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
|
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
|
||||||
@ -65,7 +68,7 @@ export function SensorChart({ data, currentTime, startTime }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="chart-item">
|
<div className="chart-item">
|
||||||
<h4>Air Pressure (hPa)</h4>
|
<h4>{t('sensor.airPressureHpa')}</h4>
|
||||||
<ResponsiveContainer width="100%" height={80}>
|
<ResponsiveContainer width="100%" height={80}>
|
||||||
<LineChart data={chartData}>
|
<LineChart data={chartData}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
|
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
|
||||||
@ -78,7 +81,7 @@ export function SensorChart({ data, currentTime, startTime }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="chart-item">
|
<div className="chart-item">
|
||||||
<h4>Radiation (uSv/h)</h4>
|
<h4>{t('sensor.radiationUsv')}</h4>
|
||||||
<ResponsiveContainer width="100%" height={80}>
|
<ResponsiveContainer width="100%" height={80}>
|
||||||
<LineChart data={chartData}>
|
<LineChart data={chartData}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
|
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import { memo, useMemo, useState, useEffect } from 'react';
|
import { memo, useMemo, useState, useEffect } from 'react';
|
||||||
import { Marker, Popup, Source, Layer, useMap } from 'react-map-gl/maplibre';
|
import { Marker, Popup, Source, Layer, useMap } from 'react-map-gl/maplibre';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { Ship, ShipCategory } from '../types';
|
import type { Ship, ShipCategory } from '../types';
|
||||||
import maplibregl from 'maplibre-gl';
|
import maplibregl from 'maplibre-gl';
|
||||||
|
|
||||||
@ -9,17 +10,30 @@ interface Props {
|
|||||||
koreanOnly?: boolean;
|
koreanOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── MarineTraffic-style vessel type colors ──
|
// ── MarineTraffic-style vessel type colors (CSS variable references) ──
|
||||||
const MT_TYPE_COLORS: Record<string, string> = {
|
const MT_TYPE_COLORS: Record<string, string> = {
|
||||||
cargo: '#f0a830', // orange-yellow
|
cargo: 'var(--kcg-ship-cargo)',
|
||||||
tanker: '#e74c3c', // red
|
tanker: 'var(--kcg-ship-tanker)',
|
||||||
passenger: '#4caf50', // green
|
passenger: 'var(--kcg-ship-passenger)',
|
||||||
fishing: '#42a5f5', // light blue
|
fishing: 'var(--kcg-ship-fishing)',
|
||||||
pleasure: '#e91e8c', // pink/magenta
|
pleasure: 'var(--kcg-ship-pleasure)',
|
||||||
military: '#d32f2f', // dark red
|
military: 'var(--kcg-ship-military)',
|
||||||
tug_special: '#2e7d32', // dark green
|
tug_special: 'var(--kcg-ship-tug)',
|
||||||
other: '#5c6bc0', // indigo/blue
|
other: 'var(--kcg-ship-other)',
|
||||||
unknown: '#9e9e9e', // grey
|
unknown: 'var(--kcg-ship-unknown)',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resolved hex colors for MapLibre paint (which cannot use CSS vars)
|
||||||
|
const MT_TYPE_HEX: Record<string, string> = {
|
||||||
|
cargo: '#f0a830',
|
||||||
|
tanker: '#e74c3c',
|
||||||
|
passenger: '#4caf50',
|
||||||
|
fishing: '#42a5f5',
|
||||||
|
pleasure: '#e91e8c',
|
||||||
|
military: '#d32f2f',
|
||||||
|
tug_special: '#2e7d32',
|
||||||
|
other: '#5c6bc0',
|
||||||
|
unknown: '#9e9e9e',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Map our internal ShipCategory + typecode → MT visual type
|
// Map our internal ShipCategory + typecode → MT visual type
|
||||||
@ -63,21 +77,6 @@ const NAVY_COLORS: Record<string, string> = {
|
|||||||
IR: '#2ecc40', JP: '#ff6b6b', AU: '#f4a261', DE: '#b5b5b5', IN: '#ff9f43',
|
IR: '#2ecc40', JP: '#ff6b6b', AU: '#f4a261', DE: '#b5b5b5', IN: '#ff9f43',
|
||||||
};
|
};
|
||||||
|
|
||||||
const CATEGORY_LABELS: Record<ShipCategory, string> = {
|
|
||||||
carrier: 'CARRIER', destroyer: 'DDG', warship: 'WARSHIP', submarine: 'SUB',
|
|
||||||
patrol: 'PATROL', tanker: 'TANKER', cargo: 'CARGO', civilian: 'CIV', unknown: 'N/A',
|
|
||||||
};
|
|
||||||
|
|
||||||
const MT_TYPE_LABELS: Record<string, string> = {
|
|
||||||
cargo: 'Cargo', tanker: 'Tanker', passenger: 'Passenger', fishing: 'Fishing',
|
|
||||||
pleasure: 'Yacht', military: 'Military', tug_special: 'Tug/Special', other: 'Other', unknown: 'Unknown',
|
|
||||||
};
|
|
||||||
|
|
||||||
const FLAG_LABELS: Record<string, string> = {
|
|
||||||
US: 'USN', UK: 'RN', FR: 'MN', KR: 'ROKN', IR: 'IRIN',
|
|
||||||
JP: 'JMSDF', AU: 'RAN', DE: 'DM', IN: 'IN',
|
|
||||||
};
|
|
||||||
|
|
||||||
const FLAG_EMOJI: Record<string, string> = {
|
const FLAG_EMOJI: Record<string, string> = {
|
||||||
US: '\u{1F1FA}\u{1F1F8}', UK: '\u{1F1EC}\u{1F1E7}', FR: '\u{1F1EB}\u{1F1F7}',
|
US: '\u{1F1FA}\u{1F1F8}', UK: '\u{1F1EC}\u{1F1E7}', FR: '\u{1F1EB}\u{1F1F7}',
|
||||||
KR: '\u{1F1F0}\u{1F1F7}', IR: '\u{1F1EE}\u{1F1F7}', JP: '\u{1F1EF}\u{1F1F5}',
|
KR: '\u{1F1F0}\u{1F1F7}', IR: '\u{1F1EE}\u{1F1F7}', JP: '\u{1F1EF}\u{1F1F5}',
|
||||||
@ -104,6 +103,10 @@ function getShipColor(ship: Ship): string {
|
|||||||
return MT_TYPE_COLORS[getMTType(ship)] || MT_TYPE_COLORS.unknown;
|
return MT_TYPE_COLORS[getMTType(ship)] || MT_TYPE_COLORS.unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getShipHex(ship: Ship): string {
|
||||||
|
return MT_TYPE_HEX[getMTType(ship)] || MT_TYPE_HEX.unknown;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Local Korean ship photos ──
|
// ── Local Korean ship photos ──
|
||||||
const LOCAL_SHIP_PHOTOS: Record<string, string> = {
|
const LOCAL_SHIP_PHOTOS: Record<string, string> = {
|
||||||
'440034000': '/ships/440034000.jpg',
|
'440034000': '/ships/440034000.jpg',
|
||||||
@ -126,32 +129,94 @@ const LOCAL_SHIP_PHOTOS: Record<string, string> = {
|
|||||||
interface VesselPhotoData { url: string; }
|
interface VesselPhotoData { url: string; }
|
||||||
const vesselPhotoCache = new Map<string, VesselPhotoData | null>();
|
const vesselPhotoCache = new Map<string, VesselPhotoData | null>();
|
||||||
|
|
||||||
function VesselPhoto({ mmsi }: { mmsi: string }) {
|
type PhotoSource = 'signal-batch' | 'marinetraffic';
|
||||||
|
|
||||||
|
interface VesselPhotoProps {
|
||||||
|
mmsi: string;
|
||||||
|
imo?: string;
|
||||||
|
shipImagePath?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function VesselPhoto({ mmsi, shipImagePath }: VesselPhotoProps) {
|
||||||
|
const { t } = useTranslation('ships');
|
||||||
const localUrl = LOCAL_SHIP_PHOTOS[mmsi];
|
const localUrl = LOCAL_SHIP_PHOTOS[mmsi];
|
||||||
const [photo, setPhoto] = useState<VesselPhotoData | null | undefined>(() => {
|
|
||||||
if (localUrl) return { url: localUrl };
|
// Determine available tabs
|
||||||
|
const hasSignalBatch = !!shipImagePath;
|
||||||
|
const defaultTab: PhotoSource = hasSignalBatch ? 'signal-batch' : 'marinetraffic';
|
||||||
|
const [activeTab, setActiveTab] = useState<PhotoSource>(defaultTab);
|
||||||
|
|
||||||
|
// MarineTraffic image state (lazy loaded)
|
||||||
|
const [mtPhoto, setMtPhoto] = useState<VesselPhotoData | null | undefined>(() => {
|
||||||
return vesselPhotoCache.has(mmsi) ? vesselPhotoCache.get(mmsi) : undefined;
|
return vesselPhotoCache.has(mmsi) ? vesselPhotoCache.get(mmsi) : undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (localUrl) return;
|
if (activeTab !== 'marinetraffic') return;
|
||||||
if (photo !== undefined) return;
|
if (mtPhoto !== undefined) return;
|
||||||
const imgUrl = `https://photos.marinetraffic.com/ais/showphoto.aspx?mmsi=${mmsi}&size=thumb300`;
|
const imgUrl = `https://photos.marinetraffic.com/ais/showphoto.aspx?mmsi=${mmsi}&size=thumb300`;
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.onload = () => { const result = { url: imgUrl }; vesselPhotoCache.set(mmsi, result); setPhoto(result); };
|
img.onload = () => { const result = { url: imgUrl }; vesselPhotoCache.set(mmsi, result); setMtPhoto(result); };
|
||||||
img.onerror = () => { vesselPhotoCache.set(mmsi, null); setPhoto(null); };
|
img.onerror = () => { vesselPhotoCache.set(mmsi, null); setMtPhoto(null); };
|
||||||
img.src = imgUrl;
|
img.src = imgUrl;
|
||||||
}, [mmsi, photo, localUrl]);
|
}, [mmsi, activeTab, mtPhoto]);
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<div style={{ marginBottom: 6 }}>
|
<div className="mb-1.5">
|
||||||
<img src={photo.url} alt="Vessel"
|
<img src={localUrl} alt="Vessel"
|
||||||
style={{ width: '100%', borderRadius: 4, display: 'block' }}
|
className="w-full rounded block"
|
||||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 {
|
function formatCoord(lat: number, lng: number): string {
|
||||||
@ -215,7 +280,7 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly }: Props) {
|
|||||||
type: 'Feature' as const,
|
type: 'Feature' as const,
|
||||||
properties: {
|
properties: {
|
||||||
mmsi: ship.mmsi,
|
mmsi: ship.mmsi,
|
||||||
color: getShipColor(ship),
|
color: getShipHex(ship),
|
||||||
size: SIZE_MAP[ship.category],
|
size: SIZE_MAP[ship.category],
|
||||||
isMil: isMilitary(ship.category) ? 1 : 0,
|
isMil: isMilitary(ship.category) ? 1 : 0,
|
||||||
isKorean: ship.flag === 'KR' ? 1 : 0,
|
isKorean: ship.flag === 'KR' ? 1 : 0,
|
||||||
@ -328,76 +393,71 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ShipPopup = memo(function ShipPopup({ ship, onClose }: { ship: Ship; onClose: () => void }) {
|
const ShipPopup = memo(function ShipPopup({ ship, onClose }: { ship: Ship; onClose: () => void }) {
|
||||||
|
const { t } = useTranslation('ships');
|
||||||
const mtType = getMTType(ship);
|
const mtType = getMTType(ship);
|
||||||
const color = MT_TYPE_COLORS[mtType] || MT_TYPE_COLORS.unknown;
|
const color = MT_TYPE_COLORS[mtType] || MT_TYPE_COLORS.unknown;
|
||||||
const isMil = isMilitary(ship.category);
|
const isMil = isMilitary(ship.category);
|
||||||
const navyLabel = isMil && ship.flag && FLAG_LABELS[ship.flag] ? FLAG_LABELS[ship.flag] : undefined;
|
const navyLabel = isMil && ship.flag ? t(`navyLabel.${ship.flag}`, { defaultValue: '' }) : undefined;
|
||||||
const navyAccent = isMil && ship.flag && NAVY_COLORS[ship.flag] ? NAVY_COLORS[ship.flag] : color;
|
const navyAccent = isMil && ship.flag && NAVY_COLORS[ship.flag] ? NAVY_COLORS[ship.flag] : undefined;
|
||||||
const flagEmoji = ship.flag ? FLAG_EMOJI[ship.flag] || '' : '';
|
const flagEmoji = ship.flag ? FLAG_EMOJI[ship.flag] || '' : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popup longitude={ship.lng} latitude={ship.lat}
|
<Popup longitude={ship.lng} latitude={ship.lat}
|
||||||
onClose={onClose} closeOnClick={false}
|
onClose={onClose} closeOnClick={false}
|
||||||
anchor="bottom" maxWidth="340px" className="gl-popup">
|
anchor="bottom" maxWidth="340px" className="gl-popup">
|
||||||
<div style={{ minWidth: 280, maxWidth: 340, fontFamily: 'monospace', fontSize: 12 }}>
|
<div className="min-w-[280px] max-w-[340px] font-mono text-xs">
|
||||||
<div style={{
|
<div
|
||||||
background: isMil ? '#1a1a2e' : '#1565c0', color: '#fff',
|
className="flex items-center gap-2 px-2.5 py-1.5 rounded-t text-white -mx-2.5 -mt-2.5 mb-2"
|
||||||
padding: '6px 10px', borderRadius: '4px 4px 0 0',
|
style={{ background: isMil ? 'var(--kcg-card)' : '#1565c0' }}
|
||||||
margin: '-10px -10px 8px -10px',
|
>
|
||||||
display: 'flex', alignItems: 'center', gap: 8,
|
{flagEmoji && <span className="text-base">{flagEmoji}</span>}
|
||||||
}}>
|
<strong className="text-[13px] flex-1">{ship.name}</strong>
|
||||||
{flagEmoji && <span style={{ fontSize: 16 }}>{flagEmoji}</span>}
|
|
||||||
<strong style={{ fontSize: 13, flex: 1 }}>{ship.name}</strong>
|
|
||||||
{navyLabel && (
|
{navyLabel && (
|
||||||
<span style={{
|
<span
|
||||||
background: navyAccent, color: '#000', padding: '1px 6px',
|
className="px-1.5 py-px rounded text-[10px] font-bold text-black"
|
||||||
borderRadius: 3, fontSize: 10, fontWeight: 700,
|
style={{ background: navyAccent || color }}
|
||||||
}}>{navyLabel}</span>
|
>{navyLabel}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<VesselPhoto mmsi={ship.mmsi} />
|
<VesselPhoto mmsi={ship.mmsi} imo={ship.imo} shipImagePath={ship.shipImagePath} />
|
||||||
<div style={{
|
<div className="flex gap-1 mb-1.5 border-b border-kcg-border-light pb-1">
|
||||||
display: 'flex', gap: 4, marginBottom: 6,
|
<span
|
||||||
borderBottom: '1px solid #ddd', paddingBottom: 4,
|
className="px-1.5 py-px rounded text-[10px] font-bold text-white"
|
||||||
}}>
|
style={{ background: color }}
|
||||||
<span style={{
|
>{t(`mtTypeLabel.${mtType}`, { defaultValue: 'Unknown' })}</span>
|
||||||
background: color, color: '#fff', padding: '1px 6px',
|
<span className="px-1.5 py-px rounded text-[10px] bg-kcg-border text-kcg-text-secondary">
|
||||||
borderRadius: 3, fontSize: 10, fontWeight: 700,
|
{t(`categoryLabel.${ship.category}`)}
|
||||||
}}>{MT_TYPE_LABELS[mtType] || 'Unknown'}</span>
|
</span>
|
||||||
<span style={{
|
|
||||||
background: '#333', color: '#ccc', padding: '1px 6px',
|
|
||||||
borderRadius: 3, fontSize: 10,
|
|
||||||
}}>{CATEGORY_LABELS[ship.category]}</span>
|
|
||||||
{ship.typeDesc && (
|
{ship.typeDesc && (
|
||||||
<span style={{ color: '#666', fontSize: 10, lineHeight: '18px' }}>{ship.typeDesc}</span>
|
<span className="text-kcg-dim text-[10px] leading-[18px]">{ship.typeDesc}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '2px 12px', fontSize: 11 }}>
|
<div className="grid grid-cols-2 gap-x-3 gap-y-0.5 text-[11px]">
|
||||||
<div>
|
<div>
|
||||||
<div><span style={{ color: '#888' }}>MMSI : </span>{ship.mmsi}</div>
|
<div><span className="text-kcg-muted">{t('popup.mmsi')} : </span>{ship.mmsi}</div>
|
||||||
{ship.callSign && <div><span style={{ color: '#888' }}>Call Sign : </span>{ship.callSign}</div>}
|
{ship.callSign && <div><span className="text-kcg-muted">{t('popup.callSign')} : </span>{ship.callSign}</div>}
|
||||||
{ship.imo && <div><span style={{ color: '#888' }}>IMO : </span>{ship.imo}</div>}
|
{ship.imo && <div><span className="text-kcg-muted">{t('popup.imo')} : </span>{ship.imo}</div>}
|
||||||
{ship.status && <div><span style={{ color: '#888' }}>Status : </span>{ship.status}</div>}
|
{ship.status && <div><span className="text-kcg-muted">{t('popup.status')} : </span>{ship.status}</div>}
|
||||||
{ship.length && <div><span style={{ color: '#888' }}>Length : </span>{ship.length}m</div>}
|
{ship.length && <div><span className="text-kcg-muted">{t('popup.length')} : </span>{ship.length}m</div>}
|
||||||
{ship.width && <div><span style={{ color: '#888' }}>Width : </span>{ship.width}m</div>}
|
{ship.width && <div><span className="text-kcg-muted">{t('popup.width')} : </span>{ship.width}m</div>}
|
||||||
{ship.draught && <div><span style={{ color: '#888' }}>Draught : </span>{ship.draught}m</div>}
|
{ship.draught && <div><span className="text-kcg-muted">{t('popup.draught')} : </span>{ship.draught}m</div>}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div><span style={{ color: '#888' }}>Heading : </span>{ship.heading.toFixed(1)}°</div>
|
<div><span className="text-kcg-muted">{t('popup.heading')} : </span>{ship.heading.toFixed(1)}°</div>
|
||||||
<div><span style={{ color: '#888' }}>Course : </span>{ship.course.toFixed(1)}°</div>
|
<div><span className="text-kcg-muted">{t('popup.course')} : </span>{ship.course.toFixed(1)}°</div>
|
||||||
<div><span style={{ color: '#888' }}>Speed : </span>{ship.speed.toFixed(1)} kn</div>
|
<div><span className="text-kcg-muted">{t('popup.speed')} : </span>{ship.speed.toFixed(1)} kn</div>
|
||||||
<div><span style={{ color: '#888' }}>Lat : </span>{formatCoord(ship.lat, 0).split(',')[0]}</div>
|
<div><span className="text-kcg-muted">{t('popup.lat')} : </span>{formatCoord(ship.lat, 0).split(',')[0]}</div>
|
||||||
<div><span style={{ color: '#888' }}>Lon : </span>{formatCoord(0, ship.lng).split(', ')[1] || ship.lng.toFixed(3)}</div>
|
<div><span className="text-kcg-muted">{t('popup.lon')} : </span>{formatCoord(0, ship.lng).split(', ')[1] || ship.lng.toFixed(3)}</div>
|
||||||
{ship.destination && <div><span style={{ color: '#888' }}>Dest : </span>{ship.destination}</div>}
|
{ship.destination && <div><span className="text-kcg-muted">{t('popup.destination')} : </span>{ship.destination}</div>}
|
||||||
{ship.eta && <div><span style={{ color: '#888' }}>ETA : </span>{new Date(ship.eta).toLocaleString()}</div>}
|
{ship.eta && <div><span className="text-kcg-muted">{t('popup.eta')} : </span>{new Date(ship.eta).toLocaleString()}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: 6, fontSize: 9, color: '#999', textAlign: 'right' }}>
|
<div className="mt-1.5 text-[9px] text-[#999] text-right">
|
||||||
Last Update : {new Date(ship.lastSeen).toLocaleString()}
|
{t('popup.lastUpdate')} : {new Date(ship.lastSeen).toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: 4, fontSize: 10, textAlign: 'right' }}>
|
<div className="mt-1 text-[10px] text-right">
|
||||||
<a href={`https://www.marinetraffic.com/en/ais/details/ships/mmsi:${ship.mmsi}`}
|
<a href={`https://www.marinetraffic.com/en/ais/details/ships/mmsi:${ship.mmsi}`}
|
||||||
target="_blank" rel="noopener noreferrer" style={{ color: '#60a5fa' }}>
|
target="_blank" rel="noopener noreferrer" className="text-kcg-accent">
|
||||||
MarineTraffic →
|
MarineTraffic →
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { useMemo, useState, useCallback } from 'react';
|
import { useMemo, useState, useCallback } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { GeoEvent } from '../types';
|
import type { GeoEvent } from '../types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -22,24 +23,25 @@ const TYPE_COLORS: Record<string, string> = {
|
|||||||
osint: '#06b6d4',
|
osint: '#06b6d4',
|
||||||
};
|
};
|
||||||
|
|
||||||
const TYPE_LABELS_KO: Record<string, string> = {
|
const TYPE_I18N_KEYS: Record<string, string> = {
|
||||||
airstrike: '공습',
|
airstrike: 'event.airstrike',
|
||||||
explosion: '폭발',
|
explosion: 'event.explosion',
|
||||||
missile_launch: '미사일 발사',
|
missile_launch: 'event.missileLaunch',
|
||||||
intercept: '요격',
|
intercept: 'event.intercept',
|
||||||
alert: '경보',
|
alert: 'event.alert',
|
||||||
impact: '피격',
|
impact: 'event.impact',
|
||||||
osint: 'OSINT',
|
osint: 'event.osint',
|
||||||
};
|
};
|
||||||
|
|
||||||
const SOURCE_LABELS_KO: Record<string, string> = {
|
const SOURCE_I18N_KEYS: Record<string, string> = {
|
||||||
US: '미국',
|
US: 'source.US',
|
||||||
IL: '이스라엘',
|
IL: 'source.IL',
|
||||||
IR: '이란',
|
IR: 'source.IR',
|
||||||
proxy: '대리세력',
|
proxy: 'source.proxy',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function TimelineSlider({ currentTime, startTime, endTime, events, onSeek, onEventFlyTo }: Props) {
|
export function TimelineSlider({ currentTime, startTime, endTime, events, onSeek, onEventFlyTo }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
|
||||||
const progress = ((currentTime - startTime) / (endTime - startTime)) * 100;
|
const progress = ((currentTime - startTime) / (endTime - startTime)) * 100;
|
||||||
@ -53,13 +55,13 @@ export function TimelineSlider({ currentTime, startTime, endTime, events, onSeek
|
|||||||
}));
|
}));
|
||||||
}, [events, startTime, endTime]);
|
}, [events, startTime, endTime]);
|
||||||
|
|
||||||
const formatTime = (t: number) => {
|
const formatTime = (ts: number) => {
|
||||||
const d = new Date(t + KST_OFFSET);
|
const d = new Date(ts + KST_OFFSET);
|
||||||
return d.toISOString().slice(0, 16).replace('T', ' ') + ' KST';
|
return d.toISOString().slice(0, 16).replace('T', ' ') + ' KST';
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatTimeShort = (t: number) => {
|
const formatTimeShort = (ts: number) => {
|
||||||
const d = new Date(t + KST_OFFSET);
|
const d = new Date(ts + KST_OFFSET);
|
||||||
return `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}`;
|
return `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -128,8 +130,10 @@ export function TimelineSlider({ currentTime, startTime, endTime, events, onSeek
|
|||||||
const color = TYPE_COLORS[ev.type] || '#888';
|
const color = TYPE_COLORS[ev.type] || '#888';
|
||||||
const isPast = ev.timestamp <= currentTime;
|
const isPast = ev.timestamp <= currentTime;
|
||||||
const isActive = ev.id === selectedId;
|
const isActive = ev.id === selectedId;
|
||||||
const source = ev.source ? SOURCE_LABELS_KO[ev.source] : '';
|
const sourceKey = ev.source ? SOURCE_I18N_KEYS[ev.source] : '';
|
||||||
const typeLabel = TYPE_LABELS_KO[ev.type] || ev.type;
|
const source = sourceKey ? t(sourceKey) : '';
|
||||||
|
const typeKey = TYPE_I18N_KEYS[ev.type];
|
||||||
|
const typeLabel = typeKey ? t(typeKey) : ev.type;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@ -137,7 +141,7 @@ export function TimelineSlider({ currentTime, startTime, endTime, events, onSeek
|
|||||||
className={`tl-event-card ${isActive ? 'active' : ''} ${isPast ? 'past' : 'future'}`}
|
className={`tl-event-card ${isActive ? 'active' : ''} ${isPast ? 'past' : 'future'}`}
|
||||||
style={{ '--card-color': color } as React.CSSProperties}
|
style={{ '--card-color': color } as React.CSSProperties}
|
||||||
onClick={() => handleEventCardClick(ev)}
|
onClick={() => handleEventCardClick(ev)}
|
||||||
title="클릭하면 지도에서 해당 위치로 이동합니다"
|
title={t('timeline.flyToTooltip')}
|
||||||
>
|
>
|
||||||
<span className="tl-card-dot" />
|
<span className="tl-card-dot" />
|
||||||
<span className="tl-card-time">{formatTimeShort(ev.timestamp)}</span>
|
<span className="tl-card-time">{formatTimeShort(ev.timestamp)}</span>
|
||||||
187
frontend/src/components/auth/LoginPage.tsx
Normal file
@ -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">🛡️</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;
|
||||||
1
src/env.d.ts → frontend/src/env.d.ts
vendored
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly VITE_SPG_API_KEY?: string;
|
readonly VITE_SPG_API_KEY?: string;
|
||||||
|
readonly VITE_GOOGLE_CLIENT_ID?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
78
frontend/src/hooks/useAuth.ts
Normal file
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
47
frontend/src/hooks/useTheme.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
44
frontend/src/i18n/index.ts
Normal file
@ -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;
|
||||||
227
frontend/src/i18n/locales/en/common.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
65
frontend/src/i18n/locales/en/events.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
197
frontend/src/i18n/locales/en/ships.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
227
frontend/src/i18n/locales/ko/common.json
Normal file
@ -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": "연료"
|
||||||
|
}
|
||||||
|
}
|
||||||
65
frontend/src/i18n/locales/ko/events.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
197
frontend/src/i18n/locales/ko/ships.json
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
{
|
||||||
|
"category": {
|
||||||
|
"warship": "군함",
|
||||||
|
"carrier": "항공모함",
|
||||||
|
"destroyer": "구축함",
|
||||||
|
"submarine": "잠수함",
|
||||||
|
"cargo": "화물선",
|
||||||
|
"tanker": "유조선",
|
||||||
|
"patrol": "초계함",
|
||||||
|
"civilian": "민간선",
|
||||||
|
"unknown": "미분류"
|
||||||
|
},
|
||||||
|
"categoryLabel": {
|
||||||
|
"carrier": "CARRIER",
|
||||||
|
"destroyer": "DDG",
|
||||||
|
"warship": "WARSHIP",
|
||||||
|
"submarine": "SUB",
|
||||||
|
"patrol": "PATROL",
|
||||||
|
"tanker": "TANKER",
|
||||||
|
"cargo": "CARGO",
|
||||||
|
"civilian": "CIV",
|
||||||
|
"unknown": "N/A"
|
||||||
|
},
|
||||||
|
"mtType": {
|
||||||
|
"cargo": "화물선",
|
||||||
|
"tanker": "유조선",
|
||||||
|
"passenger": "여객선",
|
||||||
|
"fishing": "어선",
|
||||||
|
"military": "군함",
|
||||||
|
"tug_special": "예인선",
|
||||||
|
"high_speed": "고속선",
|
||||||
|
"pleasure": "유람선",
|
||||||
|
"other": "기타",
|
||||||
|
"unspecified": "미분류",
|
||||||
|
"unknown": "미분류"
|
||||||
|
},
|
||||||
|
"mtTypeLabel": {
|
||||||
|
"cargo": "Cargo",
|
||||||
|
"tanker": "Tanker",
|
||||||
|
"passenger": "Passenger",
|
||||||
|
"fishing": "Fishing",
|
||||||
|
"pleasure": "Yacht",
|
||||||
|
"military": "Military",
|
||||||
|
"tug_special": "Tug/Special",
|
||||||
|
"other": "Other",
|
||||||
|
"unknown": "Unknown"
|
||||||
|
},
|
||||||
|
"flag": {
|
||||||
|
"KR": "한국",
|
||||||
|
"US": "미국",
|
||||||
|
"JP": "일본",
|
||||||
|
"CN": "중국",
|
||||||
|
"IR": "이란",
|
||||||
|
"UK": "영국",
|
||||||
|
"FR": "프랑스",
|
||||||
|
"DE": "독일",
|
||||||
|
"PA": "파나마",
|
||||||
|
"LR": "라이베리아",
|
||||||
|
"MH": "마셜제도",
|
||||||
|
"AU": "호주",
|
||||||
|
"IN": "인도",
|
||||||
|
"HK": "홍콩",
|
||||||
|
"SG": "싱가포르",
|
||||||
|
"BZ": "벨리즈",
|
||||||
|
"OM": "오만",
|
||||||
|
"AE": "UAE",
|
||||||
|
"SA": "사우디",
|
||||||
|
"BH": "바레인",
|
||||||
|
"QA": "카타르"
|
||||||
|
},
|
||||||
|
"navy": {
|
||||||
|
"US": "US NAVY",
|
||||||
|
"KR": "ROKN",
|
||||||
|
"JP": "JMSDF",
|
||||||
|
"CN": "PLAN",
|
||||||
|
"IR": "IRIN"
|
||||||
|
},
|
||||||
|
"navyLabel": {
|
||||||
|
"US": "USN",
|
||||||
|
"UK": "RN",
|
||||||
|
"FR": "MN",
|
||||||
|
"KR": "ROKN",
|
||||||
|
"IR": "IRIN",
|
||||||
|
"JP": "JMSDF",
|
||||||
|
"AU": "RAN",
|
||||||
|
"DE": "DM",
|
||||||
|
"IN": "IN"
|
||||||
|
},
|
||||||
|
"popup": {
|
||||||
|
"mmsi": "MMSI",
|
||||||
|
"imo": "IMO",
|
||||||
|
"callSign": "Call Sign",
|
||||||
|
"heading": "Heading",
|
||||||
|
"course": "Course",
|
||||||
|
"speed": "Speed",
|
||||||
|
"lat": "Lat",
|
||||||
|
"lon": "Lon",
|
||||||
|
"destination": "Dest",
|
||||||
|
"eta": "ETA",
|
||||||
|
"status": "Status",
|
||||||
|
"length": "Length",
|
||||||
|
"width": "Width",
|
||||||
|
"draught": "Draught",
|
||||||
|
"lastUpdate": "Last Update",
|
||||||
|
"loading": "Loading..."
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"anchored": "정박",
|
||||||
|
"underway": "항해",
|
||||||
|
"moored": "계류",
|
||||||
|
"total": "전체"
|
||||||
|
},
|
||||||
|
"monitoring": {
|
||||||
|
"illegalFishing": "불법어선 의심",
|
||||||
|
"illegalTransship": "불법환적 의심",
|
||||||
|
"darkVessel": "AIS 미송출 의심",
|
||||||
|
"cableWatch": "해저케이블 근접",
|
||||||
|
"dokdoWatch": "독도 영해 접근",
|
||||||
|
"ferryWatch": "여객선 위치"
|
||||||
|
},
|
||||||
|
"aircraft": {
|
||||||
|
"fighter": "전투기",
|
||||||
|
"military": "군용기",
|
||||||
|
"surveillance": "정찰기",
|
||||||
|
"tanker": "공중급유기",
|
||||||
|
"cargo": "수송기",
|
||||||
|
"civilian": "민간기",
|
||||||
|
"unknown": "미분류"
|
||||||
|
},
|
||||||
|
"aircraftLabel": {
|
||||||
|
"fighter": "FIGHTER",
|
||||||
|
"tanker": "TANKER",
|
||||||
|
"surveillance": "ISR",
|
||||||
|
"cargo": "CARGO",
|
||||||
|
"military": "MIL",
|
||||||
|
"civilian": "CIV",
|
||||||
|
"unknown": "???"
|
||||||
|
},
|
||||||
|
"aircraftPopup": {
|
||||||
|
"hex": "Hex",
|
||||||
|
"reg": "Reg.",
|
||||||
|
"operator": "Operator",
|
||||||
|
"type": "Type",
|
||||||
|
"squawk": "Squawk",
|
||||||
|
"alt": "Alt",
|
||||||
|
"speed": "Speed",
|
||||||
|
"hdg": "Hdg",
|
||||||
|
"verticalSpeed": "V/S",
|
||||||
|
"ground": "GROUND",
|
||||||
|
"loadingPhoto": "Loading photo..."
|
||||||
|
},
|
||||||
|
"shipStatus": {
|
||||||
|
"koreanTitle": "한국 선박 현황",
|
||||||
|
"chineseTitle": "중국 선박 현황",
|
||||||
|
"chineseFishingAlert": "중국어선 {{count}}척 우리 해역 근접"
|
||||||
|
},
|
||||||
|
"cctv": {
|
||||||
|
"region": {
|
||||||
|
"jeju": "제주",
|
||||||
|
"south": "남해",
|
||||||
|
"west": "서해",
|
||||||
|
"east": "동해"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"tide": "조위관측",
|
||||||
|
"fog": "해무관측"
|
||||||
|
},
|
||||||
|
"live": "LIVE",
|
||||||
|
"khoa": "KHOA",
|
||||||
|
"viewStream": "실시간 영상 보기",
|
||||||
|
"connecting": "연결중",
|
||||||
|
"connectionFailed": "연결 실패",
|
||||||
|
"viewOnBadatime": "badatime.com에서 보기",
|
||||||
|
"rec": "REC",
|
||||||
|
"khoaFull": "KHOA 국립해양조사원",
|
||||||
|
"connectingEllipsis": "연결 중..."
|
||||||
|
},
|
||||||
|
"facility": {
|
||||||
|
"type": {
|
||||||
|
"refinery": "정유소",
|
||||||
|
"oilfield": "유전",
|
||||||
|
"gasfield": "가스전",
|
||||||
|
"terminal": "수출터미널",
|
||||||
|
"petrochemical": "석유화학",
|
||||||
|
"desalination": "담수화시설"
|
||||||
|
},
|
||||||
|
"damaged": "피격",
|
||||||
|
"plannedStrike": "공격 예정",
|
||||||
|
"production": "생산/처리",
|
||||||
|
"desalProduction": "담수생산",
|
||||||
|
"gasProduction": "가스생산",
|
||||||
|
"reserveOil": "매장량(유)",
|
||||||
|
"reserveGas": "매장량(가스)",
|
||||||
|
"operator": "운영사",
|
||||||
|
"barrels": "배럴"
|
||||||
|
}
|
||||||
|
}
|
||||||
30
frontend/src/index.css
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Legacy aliases → kcg tokens (theme-reactive) */
|
||||||
|
--bg-primary: var(--kcg-bg);
|
||||||
|
--bg-secondary: var(--kcg-surface);
|
||||||
|
--bg-card: var(--kcg-card);
|
||||||
|
--text-primary: var(--kcg-text);
|
||||||
|
--text-secondary: var(--kcg-muted);
|
||||||
|
--accent: var(--kcg-accent);
|
||||||
|
--danger: var(--kcg-danger);
|
||||||
|
--warning: var(--kcg-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
@ -1,5 +1,7 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './i18n'
|
||||||
|
import './styles/tailwind.css'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import type { Aircraft, AircraftCategory } from '../types';
|
import type { Aircraft, AircraftCategory } from '../types';
|
||||||
|
|
||||||
// Airplanes.live API - specializes in military aircraft tracking
|
// Airplanes.live API - specializes in military aircraft tracking
|
||||||
const ADSBX_BASE = 'https://api.airplanes.live/v2';
|
const ADSBX_BASE = '/api/airplaneslive/v2';
|
||||||
|
|
||||||
// Known military type codes
|
// Known military type codes
|
||||||
const MILITARY_TYPES: Record<string, AircraftCategory> = {
|
const MILITARY_TYPES: Record<string, AircraftCategory> = {
|
||||||
@ -83,6 +83,7 @@ export async function fetchMilitaryAircraft(): Promise<Aircraft[]> {
|
|||||||
// Airplanes.live military endpoint - Middle East area
|
// Airplanes.live military endpoint - Middle East area
|
||||||
const url = `${ADSBX_BASE}/mil`;
|
const url = `${ADSBX_BASE}/mil`;
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
|
if (res.status === 429) return [];
|
||||||
if (!res.ok) throw new Error(`Airplanes.live ${res.status}`);
|
if (!res.ok) throw new Error(`Airplanes.live ${res.status}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
@ -101,6 +102,7 @@ export async function fetchMilitaryAircraftKorea(): Promise<Aircraft[]> {
|
|||||||
try {
|
try {
|
||||||
const url = `${ADSBX_BASE}/mil`;
|
const url = `${ADSBX_BASE}/mil`;
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
|
if (res.status === 429) return [];
|
||||||
if (!res.ok) throw new Error(`Airplanes.live mil ${res.status}`);
|
if (!res.ok) throw new Error(`Airplanes.live mil ${res.status}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
return parseAirplanesLive(data).filter(
|
return parseAirplanesLive(data).filter(
|
||||||
@ -133,7 +135,7 @@ async function doKrInitialLoad(): Promise<void> {
|
|||||||
console.log('Airplanes.live Korea: initial load...');
|
console.log('Airplanes.live Korea: initial load...');
|
||||||
for (let i = 0; i < KR_QUERIES.length; i++) {
|
for (let i = 0; i < KR_QUERIES.length; i++) {
|
||||||
try {
|
try {
|
||||||
if (i > 0) await delay(800);
|
if (i > 0) await delay(1500);
|
||||||
const ac = await fetchOneRegion(KR_QUERIES[i]);
|
const ac = await fetchOneRegion(KR_QUERIES[i]);
|
||||||
krLiveCache.set(`kr-${i}`, { ac, ts: Date.now() });
|
krLiveCache.set(`kr-${i}`, { ac, ts: Date.now() });
|
||||||
} catch { /* skip */ }
|
} catch { /* skip */ }
|
||||||
@ -216,6 +218,12 @@ function delay(ms: number) {
|
|||||||
async function fetchOneRegion(q: { lat: number; lon: number; radius: number }): Promise<Aircraft[]> {
|
async function fetchOneRegion(q: { lat: number; lon: number; radius: number }): Promise<Aircraft[]> {
|
||||||
const url = `${ADSBX_BASE}/point/${q.lat}/${q.lon}/${q.radius}`;
|
const url = `${ADSBX_BASE}/point/${q.lat}/${q.lon}/${q.radius}`;
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
|
if (res.status === 429) {
|
||||||
|
// Rate limited — back off and return empty
|
||||||
|
console.warn('Airplanes.live rate limited (429), backing off');
|
||||||
|
await delay(5000);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
if (!res.ok) throw new Error(`Airplanes.live ${res.status}`);
|
if (!res.ok) throw new Error(`Airplanes.live ${res.status}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
return parseAirplanesLive(data);
|
return parseAirplanesLive(data);
|
||||||
@ -226,7 +234,7 @@ async function doInitialLoad(): Promise<void> {
|
|||||||
console.log('Airplanes.live: initial load — fetching 10 regions in background...');
|
console.log('Airplanes.live: initial load — fetching 10 regions in background...');
|
||||||
for (let i = 0; i < LIVE_QUERIES.length; i++) {
|
for (let i = 0; i < LIVE_QUERIES.length; i++) {
|
||||||
try {
|
try {
|
||||||
if (i > 0) await delay(800);
|
if (i > 0) await delay(1500);
|
||||||
const ac = await fetchOneRegion(LIVE_QUERIES[i]);
|
const ac = await fetchOneRegion(LIVE_QUERIES[i]);
|
||||||
liveCache.set(`${i}`, { ac, ts: Date.now() });
|
liveCache.set(`${i}`, { ac, ts: Date.now() });
|
||||||
console.log(` Region ${i}: ${ac.length} aircraft`);
|
console.log(` Region ${i}: ${ac.length} aircraft`);
|
||||||
41
frontend/src/services/authApi.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
const AUTH_BASE = '/api/kcg/auth';
|
||||||
|
|
||||||
|
export interface AuthUser {
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
picture?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function googleLogin(credential: string): Promise<AuthUser> {
|
||||||
|
const res = await fetch(`${AUTH_BASE}/google`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ credential }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(body.message ?? 'Login failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMe(): Promise<AuthUser> {
|
||||||
|
const res = await fetch(`${AUTH_BASE}/me`, {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error('Not authenticated');
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout(): Promise<void> {
|
||||||
|
await fetch(`${AUTH_BASE}/logout`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -92,7 +92,7 @@ export async function fetchSatelliteTLE(): Promise<Satellite[]> {
|
|||||||
// Fetch TLE groups from CelesTrak sequentially (avoid hammering)
|
// Fetch TLE groups from CelesTrak sequentially (avoid hammering)
|
||||||
for (const { group, category } of CELESTRAK_GROUPS) {
|
for (const { group, category } of CELESTRAK_GROUPS) {
|
||||||
try {
|
try {
|
||||||
const url = `https://celestrak.org/NORAD/elements/gp.php?GROUP=${group}&FORMAT=tle`;
|
const url = `/api/celestrak/NORAD/elements/gp.php?GROUP=${group}&FORMAT=tle`;
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
console.warn(`CelesTrak ${group}: ${res.status}`);
|
console.warn(`CelesTrak ${group}: ${res.status}`);
|
||||||
@ -181,7 +181,7 @@ export async function fetchSatelliteTLEKorea(): Promise<Satellite[]> {
|
|||||||
|
|
||||||
for (const { group, category } of CELESTRAK_GROUPS) {
|
for (const { group, category } of CELESTRAK_GROUPS) {
|
||||||
try {
|
try {
|
||||||
const url = `https://celestrak.org/NORAD/elements/gp.php?GROUP=${group}&FORMAT=tle`;
|
const url = `/api/celestrak/NORAD/elements/gp.php?GROUP=${group}&FORMAT=tle`;
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
if (!res.ok) continue;
|
if (!res.ok) continue;
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import type { Aircraft, AircraftCategory } from '../types';
|
import type { Aircraft, AircraftCategory } from '../types';
|
||||||
|
|
||||||
// OpenSky Network API - free tier, no auth needed for basic queries
|
// OpenSky Network API - free tier, no auth needed for basic queries
|
||||||
const OPENSKY_BASE = 'https://opensky-network.org/api';
|
const OPENSKY_BASE = '/api/opensky/api';
|
||||||
|
|
||||||
// Middle East bounding box (lat_min, lat_max, lng_min, lng_max)
|
// Middle East bounding box (lat_min, lat_max, lng_min, lng_max)
|
||||||
const ME_BOUNDS = {
|
const ME_BOUNDS = {
|
||||||
@ -75,13 +75,30 @@ function parseOpenSkyResponse(data: { time: number; states: unknown[][] | null }
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchAircraftOpenSky(): Promise<Aircraft[]> {
|
// OpenSky free tier: ~1 request per 10s. Shared throttle to avoid 429.
|
||||||
try {
|
let lastOpenSkyCall = 0;
|
||||||
const url = `${OPENSKY_BASE}/states/all?lamin=${ME_BOUNDS.lamin}&lomin=${ME_BOUNDS.lomin}&lamax=${ME_BOUNDS.lamax}&lomax=${ME_BOUNDS.lomax}`;
|
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);
|
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}`);
|
if (!res.ok) throw new Error(`OpenSky ${res.status}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
return parseOpenSkyResponse(data);
|
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) {
|
} catch (err) {
|
||||||
console.warn('OpenSky fetch failed, using sample data:', err);
|
console.warn('OpenSky fetch failed, using sample data:', err);
|
||||||
return getSampleAircraft();
|
return getSampleAircraft();
|
||||||
@ -94,10 +111,7 @@ const KR_BOUNDS = { lamin: 20, lamax: 45, lomin: 115, lomax: 145 };
|
|||||||
export async function fetchAircraftOpenSkyKorea(): Promise<Aircraft[]> {
|
export async function fetchAircraftOpenSkyKorea(): Promise<Aircraft[]> {
|
||||||
try {
|
try {
|
||||||
const url = `${OPENSKY_BASE}/states/all?lamin=${KR_BOUNDS.lamin}&lomin=${KR_BOUNDS.lomin}&lamax=${KR_BOUNDS.lamax}&lomax=${KR_BOUNDS.lomax}`;
|
const url = `${OPENSKY_BASE}/states/all?lamin=${KR_BOUNDS.lamin}&lomin=${KR_BOUNDS.lomin}&lamax=${KR_BOUNDS.lamax}&lomax=${KR_BOUNDS.lomax}`;
|
||||||
const res = await fetch(url);
|
return await throttledOpenSkyFetch(url);
|
||||||
if (!res.ok) throw new Error(`OpenSky Korea ${res.status}`);
|
|
||||||
const data = await res.json();
|
|
||||||
return parseOpenSkyResponse(data);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('OpenSky Korea fetch failed:', err);
|
console.warn('OpenSky Korea fetch failed:', err);
|
||||||
return [];
|
return [];
|
||||||
@ -333,7 +333,7 @@ async function fetchXCentcom(): Promise<OsintItem[]> {
|
|||||||
|
|
||||||
for (const url of rssUrls) {
|
for (const url of rssUrls) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, { signal: AbortSignal.timeout(6000) });
|
const res = await fetch(url, { signal: AbortSignal.timeout(6000), redirect: 'error' });
|
||||||
if (!res.ok) continue;
|
if (!res.ok) continue;
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
const parser = new DOMParser();
|
const parser = new DOMParser();
|
||||||
@ -26,8 +26,8 @@ const KR_BOUNDS = {
|
|||||||
maxLng: 145,
|
maxLng: 145,
|
||||||
};
|
};
|
||||||
|
|
||||||
// How far back to look for vessel positions (seconds)
|
// S&P API sinceSeconds — currently disabled, will be removed with S&P code
|
||||||
const SINCE_SECONDS = 3600; // last 1 hour
|
// const SINCE_SECONDS = 3600;
|
||||||
|
|
||||||
// MMSI country prefix → flag code (MID = Maritime Identification Digits)
|
// MMSI country prefix → flag code (MID = Maritime Identification Digits)
|
||||||
const MMSI_FLAG_MAP: Record<string, string> = {
|
const MMSI_FLAG_MAP: Record<string, string> = {
|
||||||
@ -57,7 +57,7 @@ const NAVAL_MMSI_PREFIXES: Record<string, { flag: string; category: ShipCategory
|
|||||||
|
|
||||||
// Known vessel name patterns
|
// Known vessel name patterns
|
||||||
const MILITARY_NAME_PATTERNS: [RegExp, ShipCategory][] = [
|
const MILITARY_NAME_PATTERNS: [RegExp, ShipCategory][] = [
|
||||||
[/\bCVN\b|NIMITZ|FORD|EISENHOWER|LINCOLN|REAGAN|VINSON|STENNIS|TRUMAN|WASHINGTON|BUSH/i, 'carrier'],
|
[/\bCVN\b|NIMITZ|\bFORD\b|EISENHOWER|LINCOLN|REAGAN|VINSON|STENNIS|TRUMAN|WASHINGTON|BUSH/i, 'carrier'],
|
||||||
[/\bDDG\b|\bDDH\b|DESTROYER|ARLEIGH|BURKE|ZUMWALT|SEJONG|CHUNGMUGONG|GWANGGAETO/i, 'destroyer'],
|
[/\bDDG\b|\bDDH\b|DESTROYER|ARLEIGH|BURKE|ZUMWALT|SEJONG|CHUNGMUGONG|GWANGGAETO/i, 'destroyer'],
|
||||||
[/\bSSN\b|\bSS\b.*SUBMARINE/i, 'submarine'],
|
[/\bSSN\b|\bSS\b.*SUBMARINE/i, 'submarine'],
|
||||||
[/\bCG\b|CRUISER|TICONDEROGA/i, 'warship'],
|
[/\bCG\b|CRUISER|TICONDEROGA/i, 'warship'],
|
||||||
@ -277,27 +277,8 @@ function parseAISTarget(t: SPGAISTarget): Ship {
|
|||||||
// ═══ Primary API: GetTargetsInAreaEnhanced ═══
|
// ═══ Primary API: GetTargetsInAreaEnhanced ═══
|
||||||
// Returns vessels within a bounding box updated in the last N seconds
|
// Returns vessels within a bounding box updated in the last N seconds
|
||||||
export async function fetchShipsFromSPG(): Promise<Ship[]> {
|
export async function fetchShipsFromSPG(): Promise<Ship[]> {
|
||||||
if (!SPG_USERNAME || !SPG_PASSWORD) {
|
// TODO: signal-batch 전환 후 제거 — S&P API 비활성화
|
||||||
console.warn('VITE_SPG_USERNAME / VITE_SPG_PASSWORD not set, using sample data');
|
|
||||||
return [];
|
return [];
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const targets = await callSPGAPI('GetTargetsInAreaEnhanced', {
|
|
||||||
sinceSeconds: SINCE_SECONDS,
|
|
||||||
minLat: ME_BOUNDS.minLat,
|
|
||||||
maxLat: ME_BOUNDS.maxLat,
|
|
||||||
minLong: ME_BOUNDS.minLng,
|
|
||||||
maxLong: ME_BOUNDS.maxLng,
|
|
||||||
});
|
|
||||||
|
|
||||||
return targets
|
|
||||||
.filter(t => t.Lat != null && t.Lon != null && t.Lat !== 0 && t.Lon !== 0)
|
|
||||||
.map(parseAISTarget);
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('S&P AIS API (GetTargetsInAreaEnhanced) failed:', err);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══ Supplementary: fetch specific vessels by MMSI ═══
|
// ═══ Supplementary: fetch specific vessels by MMSI ═══
|
||||||
@ -601,34 +582,180 @@ function getSampleShips(): Ship[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════
|
// ═══════════════════════════════════════
|
||||||
// KOREA REGION — separate data pipeline
|
// KOREA REGION — signal-batch data pipeline
|
||||||
// ═══════════════════════════════════════
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
// S&P AIS API for Korea region (Vladivostok → South China Sea)
|
const SIGNAL_BATCH_BASE = '/signal-batch';
|
||||||
async function fetchShipsFromSPGKorea(): Promise<Ship[]> {
|
|
||||||
if (!SPG_USERNAME || !SPG_PASSWORD) return [];
|
// signal-batch POST /api/v1/vessels/recent-positions-detail
|
||||||
|
interface RecentPositionDetailRequest {
|
||||||
|
minutes: number;
|
||||||
|
coordinates: number[][]; // [[lon,lat], ...] polygon (first == last)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecentPositionDetailDto {
|
||||||
|
mmsi: string;
|
||||||
|
imo: number | null;
|
||||||
|
lon: number;
|
||||||
|
lat: number;
|
||||||
|
sog: number;
|
||||||
|
cog: number;
|
||||||
|
shipNm: string;
|
||||||
|
shipTy: string;
|
||||||
|
shipKindCode: string;
|
||||||
|
nationalCode: string;
|
||||||
|
lastUpdate: string;
|
||||||
|
shipImagePath: string | null;
|
||||||
|
shipImageCount: number | null;
|
||||||
|
heading: number | null;
|
||||||
|
callSign: string | null;
|
||||||
|
status: string | null;
|
||||||
|
destination: string | null;
|
||||||
|
eta: string | null;
|
||||||
|
draught: number | null;
|
||||||
|
length: number | null;
|
||||||
|
width: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AIS Ship and Cargo Type (0-99) → SPG-compatible vessel type string
|
||||||
|
// ITU-R M.1371-5, Table 53 — second digit encodes hazard category (x1-x4)
|
||||||
|
// Returns strings matching SPG_VESSEL_TYPE_MAP keys for classifyShip() compatibility
|
||||||
|
function aisTypeToVesselType(shipTy: string): string | undefined {
|
||||||
|
const code = parseInt(shipTy, 10);
|
||||||
|
if (isNaN(code) || code < 0 || code > 99) return undefined;
|
||||||
|
|
||||||
|
// 20-29: Wing in Ground effect
|
||||||
|
if (code >= 20 && code <= 29) return 'Wing In Ground-effect';
|
||||||
|
// 30: Fishing
|
||||||
|
if (code === 30) return 'Fishing';
|
||||||
|
// 31-32: Towing
|
||||||
|
if (code === 31 || code === 32) return 'Tug';
|
||||||
|
// 33: Dredging/underwater ops
|
||||||
|
if (code === 33) return 'Vessel';
|
||||||
|
// 34: Diving operations
|
||||||
|
if (code === 34) return 'Vessel';
|
||||||
|
// 35: Military operations — let name/MMSI classification take priority
|
||||||
|
if (code === 35) return 'N/A';
|
||||||
|
// 36: Sailing
|
||||||
|
if (code === 36) return 'Vessel';
|
||||||
|
// 37: Pleasure craft
|
||||||
|
if (code === 37) return 'Vessel';
|
||||||
|
// 38-39: Reserved
|
||||||
|
// 40-49: High Speed Craft (x0=all, x1=Haz A, x2=Haz B, x3=Haz C, x4=Haz D)
|
||||||
|
if (code >= 40 && code <= 49) return 'High Speed Craft';
|
||||||
|
// 50: Pilot vessel
|
||||||
|
if (code === 50) return 'Pilot Boat';
|
||||||
|
// 51: Search and Rescue
|
||||||
|
if (code === 51) return 'Search And Rescue';
|
||||||
|
// 52: Tug
|
||||||
|
if (code === 52) return 'Tug';
|
||||||
|
// 53: Port tender
|
||||||
|
if (code === 53) return 'Tender';
|
||||||
|
// 54: Anti-pollution equipment
|
||||||
|
if (code === 54) return 'Anti Pollution';
|
||||||
|
// 55: Law enforcement
|
||||||
|
if (code === 55) return 'Law Enforcement';
|
||||||
|
// 56-57: Spare (local assignment)
|
||||||
|
// 58: Medical transport
|
||||||
|
if (code === 58) return 'Medical Transport';
|
||||||
|
// 59: Noncombatant ship (RR Resolution No. 18)
|
||||||
|
if (code === 59) return 'N/A';
|
||||||
|
// 60-69: Passenger (x0=all, x1=Haz A, x2=Haz B, x3=Haz C, x4=Haz D)
|
||||||
|
if (code >= 60 && code <= 69) return 'Passenger';
|
||||||
|
// 70-79: Cargo (x0=all, x1=Haz A, x2=Haz B, x3=Haz C, x4=Haz D)
|
||||||
|
if (code >= 70 && code <= 79) return 'Cargo';
|
||||||
|
// 80-89: Tanker (x0=all, x1=Haz A, x2=Haz B, x3=Haz C, x4=Haz D)
|
||||||
|
if (code >= 80 && code <= 89) return 'Tanker';
|
||||||
|
// 90-99: Other (x0=all, x1=Haz A, ... same pattern)
|
||||||
|
if (code >= 90 && code <= 99) return 'Vessel';
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSignalBatchVessel(d: RecentPositionDetailDto): Ship {
|
||||||
|
const name = (d.shipNm || '').trim();
|
||||||
|
const mmsi = String(d.mmsi || '');
|
||||||
|
// nationalCode is MMSI prefix (3 digits) — reuse existing MMSI_FLAG_MAP
|
||||||
|
const flag = MMSI_FLAG_MAP[d.nationalCode] || getFlagFromMMSI(mmsi);
|
||||||
|
|
||||||
|
// shipTy can be either a SPG VesselType string ("Cargo", "Tanker") or AIS numeric code ("70", "80")
|
||||||
|
// Try as-is first (string), fall back to AIS numeric conversion
|
||||||
|
const rawShipTy = (d.shipTy || '').trim();
|
||||||
|
const isNumeric = /^\d+$/.test(rawShipTy);
|
||||||
|
const vesselType = isNumeric ? aisTypeToVesselType(rawShipTy) : (rawShipTy || undefined);
|
||||||
|
|
||||||
|
// Existing classification: name pattern → vesselType string → MMSI prefix
|
||||||
|
const category = classifyShip(name, mmsi, vesselType);
|
||||||
|
|
||||||
|
// lastUpdate is KST "yyyy-MM-dd HH:mm:ss" — append timezone offset
|
||||||
|
let lastSeen = Date.now();
|
||||||
|
if (d.lastUpdate) {
|
||||||
|
const parsed = new Date(d.lastUpdate.replace(' ', 'T') + '+09:00').getTime();
|
||||||
|
if (!isNaN(parsed)) lastSeen = parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mmsi,
|
||||||
|
name: name || `MMSI-${mmsi}`,
|
||||||
|
lat: d.lat,
|
||||||
|
lng: d.lon,
|
||||||
|
heading: d.heading ?? d.cog ?? 0,
|
||||||
|
speed: d.sog ?? 0,
|
||||||
|
course: d.cog ?? d.heading ?? 0,
|
||||||
|
category,
|
||||||
|
flag,
|
||||||
|
// vesselType string for getMarineTrafficCategory() matching
|
||||||
|
typecode: vesselType || undefined,
|
||||||
|
typeDesc: vesselType || undefined,
|
||||||
|
imo: d.imo ? String(d.imo) : undefined,
|
||||||
|
callSign: d.callSign || undefined,
|
||||||
|
status: d.status || undefined,
|
||||||
|
destination: (d.destination || '').trim() || undefined,
|
||||||
|
eta: d.eta || undefined,
|
||||||
|
draught: d.draught != null && d.draught > 0 ? d.draught : undefined,
|
||||||
|
length: d.length != null && d.length > 0 ? d.length : undefined,
|
||||||
|
width: d.width != null && d.width > 0 ? d.width : undefined,
|
||||||
|
lastSeen,
|
||||||
|
shipImagePath: d.shipImagePath || undefined,
|
||||||
|
shipImageCount: d.shipImageCount ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchShipsFromSignalBatch(): Promise<Ship[]> {
|
||||||
try {
|
try {
|
||||||
const targets = await callSPGAPI('GetTargetsInAreaEnhanced', {
|
const body: RecentPositionDetailRequest = {
|
||||||
sinceSeconds: SINCE_SECONDS,
|
minutes: 5,
|
||||||
minLat: KR_BOUNDS.minLat,
|
coordinates: [
|
||||||
maxLat: KR_BOUNDS.maxLat,
|
[KR_BOUNDS.minLng, KR_BOUNDS.minLat],
|
||||||
minLong: KR_BOUNDS.minLng,
|
[KR_BOUNDS.maxLng, KR_BOUNDS.minLat],
|
||||||
maxLong: KR_BOUNDS.maxLng,
|
[KR_BOUNDS.maxLng, KR_BOUNDS.maxLat],
|
||||||
|
[KR_BOUNDS.minLng, KR_BOUNDS.maxLat],
|
||||||
|
[KR_BOUNDS.minLng, KR_BOUNDS.minLat],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetch(`${SIGNAL_BATCH_BASE}/api/v1/vessels/recent-positions-detail`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
return targets
|
|
||||||
.filter(t => t.Lat != null && t.Lon != null && t.Lat !== 0 && t.Lon !== 0)
|
if (!res.ok) throw new Error(`signal-batch API ${res.status}`);
|
||||||
.map(parseAISTarget);
|
const data: RecentPositionDetailDto[] = await res.json();
|
||||||
|
return data
|
||||||
|
.filter(d => d.lat != null && d.lon != null && d.lat !== 0 && d.lon !== 0)
|
||||||
|
.map(parseSignalBatchVessel);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('S&P AIS API (Korea region) failed:', err);
|
console.warn('signal-batch API (Korea region) failed:', err);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchShipsKorea(): Promise<Ship[]> {
|
export async function fetchShipsKorea(): Promise<Ship[]> {
|
||||||
const sample = getSampleShipsKorea();
|
const sample = getSampleShipsKorea();
|
||||||
const real = await fetchShipsFromSPGKorea();
|
const real = await fetchShipsFromSignalBatch();
|
||||||
if (real.length > 0) {
|
if (real.length > 0) {
|
||||||
console.log(`S&P AIS API: ${real.length} vessels in Korea region`);
|
console.log(`signal-batch: ${real.length} vessels in Korea region`);
|
||||||
const sampleMMSIs = new Set(sample.map(s => s.mmsi));
|
const sampleMMSIs = new Set(sample.map(s => s.mmsi));
|
||||||
return [...real.filter(s => !sampleMMSIs.has(s.mmsi)), ...sample];
|
return [...real.filter(s => !sampleMMSIs.has(s.mmsi)), ...sample];
|
||||||
}
|
}
|
||||||
2
frontend/src/styles/tailwind.css
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
@import './tokens.css';
|
||||||
181
frontend/src/styles/tokens.css
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
/* ═══ KCG Monitoring Design Tokens ═══
|
||||||
|
* 모든 색상을 --kcg-* CSS 변수로 관리.
|
||||||
|
* data-theme 속성으로 dark/light 전환.
|
||||||
|
* @theme 블록으로 Tailwind 유틸리티 클래스에 매핑.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ── Dark Theme (기본 — 현재 UI와 동일) ── */
|
||||||
|
:root,
|
||||||
|
[data-theme='dark'] {
|
||||||
|
/* 배경 */
|
||||||
|
--kcg-bg: #0a0a1a;
|
||||||
|
--kcg-surface: #111127;
|
||||||
|
--kcg-card: #1a1a2e;
|
||||||
|
--kcg-overlay: rgba(10, 10, 26, 0.96);
|
||||||
|
--kcg-subtle: rgba(255, 255, 255, 0.03);
|
||||||
|
|
||||||
|
/* 텍스트 */
|
||||||
|
--kcg-text: #e0e0e0;
|
||||||
|
--kcg-text-secondary: #ccc;
|
||||||
|
--kcg-muted: #888;
|
||||||
|
--kcg-dim: #666;
|
||||||
|
|
||||||
|
/* 보더 */
|
||||||
|
--kcg-border: #333;
|
||||||
|
--kcg-border-light: #444;
|
||||||
|
--kcg-border-heavy: #555;
|
||||||
|
|
||||||
|
/* 시맨틱 */
|
||||||
|
--kcg-accent: #3b82f6;
|
||||||
|
--kcg-accent-hover: #2563eb;
|
||||||
|
--kcg-danger: #ef4444;
|
||||||
|
--kcg-warning: #eab308;
|
||||||
|
--kcg-success: #22c55e;
|
||||||
|
--kcg-info: #06b6d4;
|
||||||
|
|
||||||
|
/* 이벤트 타입 */
|
||||||
|
--kcg-event-airstrike: #ef4444;
|
||||||
|
--kcg-event-explosion: #f97316;
|
||||||
|
--kcg-event-missile: #eab308;
|
||||||
|
--kcg-event-intercept: #3b82f6;
|
||||||
|
--kcg-event-impact: #ff0000;
|
||||||
|
--kcg-event-alert: #f97316;
|
||||||
|
--kcg-event-osint: #06b6d4;
|
||||||
|
|
||||||
|
/* 선박 MT 분류 */
|
||||||
|
--kcg-ship-cargo: #f0a830;
|
||||||
|
--kcg-ship-tanker: #e74c3c;
|
||||||
|
--kcg-ship-passenger: #4caf50;
|
||||||
|
--kcg-ship-fishing: #42a5f5;
|
||||||
|
--kcg-ship-military: #d32f2f;
|
||||||
|
--kcg-ship-tug: #2e7d32;
|
||||||
|
--kcg-ship-pleasure: #e91e8c;
|
||||||
|
--kcg-ship-highspeed: #ff9800;
|
||||||
|
--kcg-ship-other: #5c6bc0;
|
||||||
|
--kcg-ship-unknown: #9e9e9e;
|
||||||
|
|
||||||
|
/* 항공기 카테고리 */
|
||||||
|
--kcg-ac-fighter: #ff4444;
|
||||||
|
--kcg-ac-military: #ff6600;
|
||||||
|
--kcg-ac-surveillance: #ffcc00;
|
||||||
|
--kcg-ac-tanker: #00ccff;
|
||||||
|
--kcg-ac-cargo: #a78bfa;
|
||||||
|
--kcg-ac-civilian: #FFD700;
|
||||||
|
--kcg-ac-unknown: #7CFC00;
|
||||||
|
|
||||||
|
/* 국기/해군 */
|
||||||
|
--kcg-navy-us: #4a90d9;
|
||||||
|
--kcg-navy-kr: #00c73c;
|
||||||
|
--kcg-navy-jp: #ff6b6b;
|
||||||
|
--kcg-navy-cn: #ff4444;
|
||||||
|
--kcg-navy-ir: #ff8c00;
|
||||||
|
|
||||||
|
/* 글래스/투명 */
|
||||||
|
--kcg-glass: rgba(10, 10, 26, 0.92);
|
||||||
|
--kcg-glass-dense: rgba(10, 10, 26, 0.96);
|
||||||
|
--kcg-hover: rgba(255, 255, 255, 0.05);
|
||||||
|
--kcg-hover-strong: rgba(255, 255, 255, 0.08);
|
||||||
|
--kcg-accent-bg: rgba(59, 130, 246, 0.12);
|
||||||
|
--kcg-danger-bg: rgba(239, 68, 68, 0.15);
|
||||||
|
|
||||||
|
/* 패널 그림자 */
|
||||||
|
--kcg-panel-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Light Theme ── */
|
||||||
|
[data-theme='light'] {
|
||||||
|
/* 배경 */
|
||||||
|
--kcg-bg: #f0f2f5;
|
||||||
|
--kcg-surface: #ffffff;
|
||||||
|
--kcg-card: #f8fafc;
|
||||||
|
--kcg-overlay: rgba(255, 255, 255, 0.96);
|
||||||
|
--kcg-subtle: rgba(0, 0, 0, 0.02);
|
||||||
|
|
||||||
|
/* 텍스트 */
|
||||||
|
--kcg-text: #1a1a2e;
|
||||||
|
--kcg-text-secondary: #374151;
|
||||||
|
--kcg-muted: #6b7280;
|
||||||
|
--kcg-dim: #9ca3af;
|
||||||
|
|
||||||
|
/* 보더 */
|
||||||
|
--kcg-border: #d1d5db;
|
||||||
|
--kcg-border-light: #e5e7eb;
|
||||||
|
--kcg-border-heavy: #9ca3af;
|
||||||
|
|
||||||
|
/* 시맨틱 (데이터 시각화 색상은 테마 불변) */
|
||||||
|
--kcg-accent: #2563eb;
|
||||||
|
--kcg-accent-hover: #1d4ed8;
|
||||||
|
|
||||||
|
/* 글래스/투명 */
|
||||||
|
--kcg-glass: rgba(255, 255, 255, 0.95);
|
||||||
|
--kcg-glass-dense: rgba(255, 255, 255, 0.98);
|
||||||
|
--kcg-hover: rgba(0, 0, 0, 0.04);
|
||||||
|
--kcg-hover-strong: rgba(0, 0, 0, 0.08);
|
||||||
|
--kcg-accent-bg: rgba(37, 99, 235, 0.08);
|
||||||
|
--kcg-danger-bg: rgba(239, 68, 68, 0.08);
|
||||||
|
|
||||||
|
/* 패널 그림자 — light에서 영역 구분 강화 (outline 제거 — 폰트 가독성) */
|
||||||
|
--kcg-panel-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tailwind @theme mapping ── */
|
||||||
|
@theme {
|
||||||
|
/* 배경 */
|
||||||
|
--color-kcg-bg: var(--kcg-bg);
|
||||||
|
--color-kcg-surface: var(--kcg-surface);
|
||||||
|
--color-kcg-card: var(--kcg-card);
|
||||||
|
--color-kcg-overlay: var(--kcg-overlay);
|
||||||
|
--color-kcg-subtle: var(--kcg-subtle);
|
||||||
|
|
||||||
|
/* 텍스트 */
|
||||||
|
--color-kcg-text: var(--kcg-text);
|
||||||
|
--color-kcg-text-secondary: var(--kcg-text-secondary);
|
||||||
|
--color-kcg-muted: var(--kcg-muted);
|
||||||
|
--color-kcg-dim: var(--kcg-dim);
|
||||||
|
|
||||||
|
/* 보더 */
|
||||||
|
--color-kcg-border: var(--kcg-border);
|
||||||
|
--color-kcg-border-light: var(--kcg-border-light);
|
||||||
|
|
||||||
|
/* 시맨틱 */
|
||||||
|
--color-kcg-accent: var(--kcg-accent);
|
||||||
|
--color-kcg-danger: var(--kcg-danger);
|
||||||
|
--color-kcg-warning: var(--kcg-warning);
|
||||||
|
--color-kcg-success: var(--kcg-success);
|
||||||
|
--color-kcg-info: var(--kcg-info);
|
||||||
|
|
||||||
|
/* 이벤트 */
|
||||||
|
--color-kcg-event-airstrike: var(--kcg-event-airstrike);
|
||||||
|
--color-kcg-event-explosion: var(--kcg-event-explosion);
|
||||||
|
--color-kcg-event-missile: var(--kcg-event-missile);
|
||||||
|
--color-kcg-event-intercept: var(--kcg-event-intercept);
|
||||||
|
--color-kcg-event-impact: var(--kcg-event-impact);
|
||||||
|
--color-kcg-event-osint: var(--kcg-event-osint);
|
||||||
|
|
||||||
|
/* 선박 */
|
||||||
|
--color-kcg-ship-cargo: var(--kcg-ship-cargo);
|
||||||
|
--color-kcg-ship-tanker: var(--kcg-ship-tanker);
|
||||||
|
--color-kcg-ship-passenger: var(--kcg-ship-passenger);
|
||||||
|
--color-kcg-ship-fishing: var(--kcg-ship-fishing);
|
||||||
|
--color-kcg-ship-military: var(--kcg-ship-military);
|
||||||
|
--color-kcg-ship-tug: var(--kcg-ship-tug);
|
||||||
|
--color-kcg-ship-unknown: var(--kcg-ship-unknown);
|
||||||
|
|
||||||
|
/* 항공기 */
|
||||||
|
--color-kcg-ac-fighter: var(--kcg-ac-fighter);
|
||||||
|
--color-kcg-ac-military: var(--kcg-ac-military);
|
||||||
|
--color-kcg-ac-surveillance: var(--kcg-ac-surveillance);
|
||||||
|
--color-kcg-ac-tanker: var(--kcg-ac-tanker);
|
||||||
|
--color-kcg-ac-cargo: var(--kcg-ac-cargo);
|
||||||
|
--color-kcg-ac-civilian: var(--kcg-ac-civilian);
|
||||||
|
|
||||||
|
/* 글래스 */
|
||||||
|
--color-kcg-glass: var(--kcg-glass);
|
||||||
|
--color-kcg-hover: var(--kcg-hover);
|
||||||
|
--color-kcg-accent-bg: var(--kcg-accent-bg);
|
||||||
|
--color-kcg-danger-bg: var(--kcg-danger-bg);
|
||||||
|
|
||||||
|
/* 폰트 */
|
||||||
|
--font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||||
|
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
|
||||||
|
}
|
||||||
@ -105,6 +105,8 @@ export interface Ship {
|
|||||||
lastSeen: number; // unix ms
|
lastSeen: number; // unix ms
|
||||||
activeStart?: number; // unix ms - when ship enters area
|
activeStart?: number; // unix ms - when ship enters area
|
||||||
activeEnd?: number; // unix ms - when ship leaves area
|
activeEnd?: number; // unix ms - when ship leaves area
|
||||||
|
shipImagePath?: string | null; // signal-batch image path
|
||||||
|
shipImageCount?: number; // number of available images
|
||||||
}
|
}
|
||||||
|
|
||||||
// Iran oil/gas facility
|
// Iran oil/gas facility
|
||||||
@ -1,9 +1,10 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [tailwindcss(), react()],
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api/ais': {
|
'/api/ais': {
|
||||||
@ -78,6 +79,42 @@ export default defineConfig({
|
|||||||
rewrite: (path) => path.replace(/^\/api\/publish-twitter/, ''),
|
rewrite: (path) => path.replace(/^\/api\/publish-twitter/, ''),
|
||||||
secure: true,
|
secure: true,
|
||||||
},
|
},
|
||||||
|
'/api/airplaneslive': {
|
||||||
|
target: 'https://api.airplanes.live',
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/api\/airplaneslive/, ''),
|
||||||
|
secure: true,
|
||||||
|
},
|
||||||
|
'/api/opensky': {
|
||||||
|
target: 'https://opensky-network.org',
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/api\/opensky/, ''),
|
||||||
|
secure: true,
|
||||||
|
},
|
||||||
|
'/api/celestrak': {
|
||||||
|
target: 'https://celestrak.org',
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/api\/celestrak/, ''),
|
||||||
|
secure: true,
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (compatible; KCG-Monitor/1.0)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/api/kcg': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/api\/kcg/, '/api'),
|
||||||
|
},
|
||||||
|
'/signal-batch': {
|
||||||
|
target: 'https://wing.gc-si.dev',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
},
|
||||||
|
'/shipimg': {
|
||||||
|
target: 'https://wing.gc-si.dev',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ -1,319 +0,0 @@
|
|||||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
|
||||||
import { Marker, Popup } from 'react-map-gl/maplibre';
|
|
||||||
import Hls from 'hls.js';
|
|
||||||
import { KOREA_CCTV_CAMERAS } from '../services/cctv';
|
|
||||||
import type { CctvCamera } from '../services/cctv';
|
|
||||||
|
|
||||||
const REGION_COLOR: Record<string, string> = {
|
|
||||||
'제주': '#ff6b6b',
|
|
||||||
'남해': '#ffa94d',
|
|
||||||
'서해': '#69db7c',
|
|
||||||
'동해': '#74c0fc',
|
|
||||||
};
|
|
||||||
|
|
||||||
const TYPE_LABEL: Record<string, string> = {
|
|
||||||
tide: '조위관측',
|
|
||||||
fog: '해무관측',
|
|
||||||
};
|
|
||||||
|
|
||||||
/** KHOA HLS → vite 프록시 경유 */
|
|
||||||
function toProxyUrl(cam: CctvCamera): string {
|
|
||||||
return cam.streamUrl.replace('https://www.khoa.go.kr', '/api/khoa-hls');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CctvLayer() {
|
|
||||||
const [selected, setSelected] = useState<CctvCamera | null>(null);
|
|
||||||
const [streamCam, setStreamCam] = useState<CctvCamera | null>(null);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{KOREA_CCTV_CAMERAS.map(cam => {
|
|
||||||
const color = REGION_COLOR[cam.region] || '#aaa';
|
|
||||||
return (
|
|
||||||
<Marker key={cam.id} longitude={cam.lng} latitude={cam.lat} anchor="center"
|
|
||||||
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(cam); }}>
|
|
||||||
<div style={{
|
|
||||||
position: 'relative', cursor: 'pointer',
|
|
||||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
|
||||||
filter: `drop-shadow(0 0 2px ${color}88)`,
|
|
||||||
}}>
|
|
||||||
<svg width={14} height={14} viewBox="0 0 24 24" fill="none">
|
|
||||||
<rect x="2" y="5" width="15" height="13" rx="2" fill={color} stroke="#fff" strokeWidth="0.8" />
|
|
||||||
<polygon points="17,8 23,5 23,18 17,15" fill={color} stroke="#fff" strokeWidth="0.5" />
|
|
||||||
<circle cx="6" cy="8.5" r="2.5" fill="#ff0000">
|
|
||||||
<animate attributeName="opacity" values="1;0.3;1" dur="1.5s" repeatCount="indefinite" />
|
|
||||||
</circle>
|
|
||||||
</svg>
|
|
||||||
<div style={{
|
|
||||||
fontSize: 6, color: '#fff', marginTop: 0,
|
|
||||||
textShadow: `0 0 3px ${color}, 0 0 2px #000, 0 0 2px #000`,
|
|
||||||
whiteSpace: 'nowrap', fontWeight: 700,
|
|
||||||
letterSpacing: 0.2,
|
|
||||||
}}>
|
|
||||||
{cam.name.length > 8 ? cam.name.slice(0, 8) + '..' : cam.name}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Marker>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{selected && (
|
|
||||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
|
||||||
onClose={() => setSelected(null)} closeOnClick={false}
|
|
||||||
anchor="bottom" maxWidth="280px" className="gl-popup">
|
|
||||||
<div style={{ fontFamily: 'monospace', fontSize: 12, minWidth: 200 }}>
|
|
||||||
<div style={{
|
|
||||||
background: REGION_COLOR[selected.region] || '#888', color: '#000',
|
|
||||||
padding: '4px 8px', borderRadius: '4px 4px 0 0',
|
|
||||||
margin: '-10px -10px 8px -10px',
|
|
||||||
fontWeight: 700, fontSize: 13,
|
|
||||||
display: 'flex', alignItems: 'center', gap: 6,
|
|
||||||
}}>
|
|
||||||
<span>📹</span> {selected.name}
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
|
|
||||||
<span style={{
|
|
||||||
background: '#22c55e', color: '#fff',
|
|
||||||
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
|
||||||
}}>● LIVE</span>
|
|
||||||
<span style={{
|
|
||||||
background: REGION_COLOR[selected.region] || '#888', color: '#000',
|
|
||||||
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
|
||||||
}}>{selected.region}</span>
|
|
||||||
<span style={{
|
|
||||||
background: '#333', color: '#ccc',
|
|
||||||
padding: '1px 6px', borderRadius: 3, fontSize: 10,
|
|
||||||
}}>{TYPE_LABEL[selected.type] || selected.type}</span>
|
|
||||||
<span style={{
|
|
||||||
background: '#1a1a2e', color: '#888',
|
|
||||||
padding: '1px 6px', borderRadius: 3, fontSize: 10,
|
|
||||||
}}>KHOA</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 11, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
|
||||||
<div style={{ fontSize: 9, color: '#666' }}>
|
|
||||||
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => { setStreamCam(selected); setSelected(null); }}
|
|
||||||
style={{
|
|
||||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
|
||||||
background: '#2563eb', color: '#fff',
|
|
||||||
padding: '4px 10px', borderRadius: 4, fontSize: 11,
|
|
||||||
fontWeight: 700, marginTop: 4,
|
|
||||||
justifyContent: 'center', border: 'none', cursor: 'pointer',
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
📺 실시간 영상 보기
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Popup>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* CCTV HLS Stream Modal */}
|
|
||||||
{streamCam && (
|
|
||||||
<CctvStreamModal cam={streamCam} onClose={() => setStreamCam(null)} />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** KHOA HLS 영상 모달 — 지도 하단 오버레이 */
|
|
||||||
function CctvStreamModal({ cam, onClose }: { cam: CctvCamera; onClose: () => void }) {
|
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
|
||||||
const hlsRef = useRef<Hls | null>(null);
|
|
||||||
const [status, setStatus] = useState<'loading' | 'playing' | 'error'>('loading');
|
|
||||||
|
|
||||||
const destroyHls = useCallback(() => {
|
|
||||||
if (hlsRef.current) {
|
|
||||||
hlsRef.current.destroy();
|
|
||||||
hlsRef.current = null;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const video = videoRef.current;
|
|
||||||
if (!video) return;
|
|
||||||
|
|
||||||
const proxied = toProxyUrl(cam);
|
|
||||||
setStatus('loading');
|
|
||||||
|
|
||||||
if (Hls.isSupported()) {
|
|
||||||
destroyHls();
|
|
||||||
const hls = new Hls({
|
|
||||||
enableWorker: true,
|
|
||||||
lowLatencyMode: true,
|
|
||||||
maxBufferLength: 10,
|
|
||||||
maxMaxBufferLength: 30,
|
|
||||||
});
|
|
||||||
hlsRef.current = hls;
|
|
||||||
hls.loadSource(proxied);
|
|
||||||
hls.attachMedia(video);
|
|
||||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
|
||||||
setStatus('playing');
|
|
||||||
video.play().catch(() => {});
|
|
||||||
});
|
|
||||||
hls.on(Hls.Events.ERROR, (_event, data) => {
|
|
||||||
if (data.fatal) setStatus('error');
|
|
||||||
});
|
|
||||||
return () => destroyHls();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Safari 네이티브 HLS
|
|
||||||
if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
|
||||||
video.src = proxied;
|
|
||||||
const onLoaded = () => setStatus('playing');
|
|
||||||
const onError = () => setStatus('error');
|
|
||||||
video.addEventListener('loadeddata', onLoaded);
|
|
||||||
video.addEventListener('error', onError);
|
|
||||||
video.play().catch(() => {});
|
|
||||||
return () => {
|
|
||||||
video.removeEventListener('loadeddata', onLoaded);
|
|
||||||
video.removeEventListener('error', onError);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
setStatus('error');
|
|
||||||
return () => destroyHls();
|
|
||||||
}, [cam, destroyHls]);
|
|
||||||
|
|
||||||
const color = REGION_COLOR[cam.region] || '#888';
|
|
||||||
|
|
||||||
return (
|
|
||||||
/* Backdrop */
|
|
||||||
<div
|
|
||||||
onClick={onClose}
|
|
||||||
style={{
|
|
||||||
position: 'fixed', inset: 0, zIndex: 9999,
|
|
||||||
background: 'rgba(0,0,0,0.6)',
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
backdropFilter: 'blur(4px)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Modal */}
|
|
||||||
<div
|
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
style={{
|
|
||||||
width: 640, maxWidth: '90vw',
|
|
||||||
background: '#0a0a1a',
|
|
||||||
border: `1px solid ${color}`,
|
|
||||||
borderRadius: 8,
|
|
||||||
overflow: 'hidden',
|
|
||||||
boxShadow: `0 0 40px ${color}33, 0 4px 30px rgba(0,0,0,0.8)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div style={{
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
||||||
padding: '8px 14px',
|
|
||||||
background: 'rgba(10,10,26,0.95)',
|
|
||||||
borderBottom: '1px solid #222',
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
display: 'flex', alignItems: 'center', gap: 8,
|
|
||||||
fontFamily: 'monospace', fontSize: 11, color: '#ddd',
|
|
||||||
}}>
|
|
||||||
<span style={{
|
|
||||||
background: status === 'playing' ? '#22c55e' : status === 'loading' ? '#eab308' : '#ef4444',
|
|
||||||
color: '#fff', padding: '1px 6px', borderRadius: 3, fontSize: 9, fontWeight: 700,
|
|
||||||
}}>
|
|
||||||
● {status === 'playing' ? 'LIVE' : status === 'loading' ? '연결중' : 'ERROR'}
|
|
||||||
</span>
|
|
||||||
<span style={{ fontWeight: 700 }}>📹 {cam.name}</span>
|
|
||||||
<span style={{
|
|
||||||
background: color, color: '#000',
|
|
||||||
padding: '1px 6px', borderRadius: 3, fontSize: 9, fontWeight: 700,
|
|
||||||
}}>{cam.region}</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
style={{
|
|
||||||
background: '#333', border: 'none', color: '#fff',
|
|
||||||
width: 24, height: 24, borderRadius: 4,
|
|
||||||
cursor: 'pointer', fontSize: 14, fontWeight: 700,
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
}}
|
|
||||||
>✕</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Video */}
|
|
||||||
<div style={{ position: 'relative', width: '100%', aspectRatio: '16/9', background: '#000' }}>
|
|
||||||
<video
|
|
||||||
ref={videoRef}
|
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
|
||||||
muted autoPlay playsInline
|
|
||||||
/>
|
|
||||||
|
|
||||||
{status === 'loading' && (
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute', inset: 0,
|
|
||||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
|
||||||
background: 'rgba(0,0,0,0.8)',
|
|
||||||
}}>
|
|
||||||
<div style={{ fontSize: 28, opacity: 0.4, marginBottom: 8 }}>📹</div>
|
|
||||||
<div style={{ fontSize: 11, color: '#888', fontFamily: 'monospace' }}>연결 중...</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status === 'error' && (
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute', inset: 0,
|
|
||||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
|
||||||
background: 'rgba(0,0,0,0.8)',
|
|
||||||
}}>
|
|
||||||
<div style={{ fontSize: 28, opacity: 0.4, marginBottom: 8 }}>⚠️</div>
|
|
||||||
<div style={{ fontSize: 12, color: '#ef4444', fontFamily: 'monospace', marginBottom: 8 }}>연결 실패</div>
|
|
||||||
<a
|
|
||||||
href={cam.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
style={{ fontSize: 10, color: '#3b82f6', fontFamily: 'monospace', textDecoration: 'underline' }}
|
|
||||||
>badatime.com에서 보기</a>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status === 'playing' && (
|
|
||||||
<>
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute', top: 10, left: 10,
|
|
||||||
display: 'flex', alignItems: 'center', gap: 6,
|
|
||||||
}}>
|
|
||||||
<span style={{
|
|
||||||
fontSize: 10, fontWeight: 700, fontFamily: 'monospace',
|
|
||||||
padding: '2px 8px', borderRadius: 3,
|
|
||||||
background: 'rgba(0,0,0,0.7)', color: '#fff',
|
|
||||||
}}>{cam.name}</span>
|
|
||||||
<span style={{
|
|
||||||
fontSize: 9, fontWeight: 700,
|
|
||||||
padding: '2px 6px', borderRadius: 3,
|
|
||||||
background: 'rgba(239,68,68,0.3)', color: '#f87171',
|
|
||||||
}}>● REC</span>
|
|
||||||
</div>
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute', bottom: 10, left: 10,
|
|
||||||
fontSize: 9, fontFamily: 'monospace',
|
|
||||||
padding: '2px 8px', borderRadius: 3,
|
|
||||||
background: 'rgba(0,0,0,0.7)', color: '#888',
|
|
||||||
}}>
|
|
||||||
{cam.lat.toFixed(4)}°N {cam.lng.toFixed(4)}°E · KHOA
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer info */}
|
|
||||||
<div style={{
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
||||||
padding: '6px 14px',
|
|
||||||
background: 'rgba(10,10,26,0.95)',
|
|
||||||
borderTop: '1px solid #222',
|
|
||||||
fontFamily: 'monospace', fontSize: 9, color: '#555',
|
|
||||||
}}>
|
|
||||||
<span>{cam.lat.toFixed(4)}°N {cam.lng.toFixed(4)}°E</span>
|
|
||||||
<span>KHOA 국립해양조사원</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,428 +0,0 @@
|
|||||||
import { useRef, useState, useEffect, useCallback } from 'react';
|
|
||||||
import { Map, NavigationControl, Marker, Source, Layer } from 'react-map-gl/maplibre';
|
|
||||||
import type { MapRef } from 'react-map-gl/maplibre';
|
|
||||||
import { ShipLayer } from './ShipLayer';
|
|
||||||
import { InfraLayer } from './InfraLayer';
|
|
||||||
import { SatelliteLayer } from './SatelliteLayer';
|
|
||||||
import { AircraftLayer } from './AircraftLayer';
|
|
||||||
import { SubmarineCableLayer } from './SubmarineCableLayer';
|
|
||||||
import { CctvLayer } from './CctvLayer';
|
|
||||||
import { KoreaAirportLayer } from './KoreaAirportLayer';
|
|
||||||
import { CoastGuardLayer } from './CoastGuardLayer';
|
|
||||||
import { NavWarningLayer } from './NavWarningLayer';
|
|
||||||
import { OsintMapLayer } from './OsintMapLayer';
|
|
||||||
import { EezLayer } from './EezLayer';
|
|
||||||
import { PiracyLayer } from './PiracyLayer';
|
|
||||||
import { fetchKoreaInfra } from '../services/infra';
|
|
||||||
import type { PowerFacility } from '../services/infra';
|
|
||||||
import type { Ship, Aircraft, SatellitePosition } from '../types';
|
|
||||||
import type { OsintItem } from '../services/osint';
|
|
||||||
import { countryLabelsGeoJSON } from '../data/countryLabels';
|
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
|
||||||
|
|
||||||
export interface KoreaFiltersState {
|
|
||||||
illegalFishing: boolean;
|
|
||||||
illegalTransship: boolean;
|
|
||||||
darkVessel: boolean;
|
|
||||||
cableWatch: boolean;
|
|
||||||
dokdoWatch: boolean;
|
|
||||||
ferryWatch: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
ships: Ship[];
|
|
||||||
aircraft: Aircraft[];
|
|
||||||
satellites: SatellitePosition[];
|
|
||||||
militaryOnly: boolean;
|
|
||||||
osintFeed: OsintItem[];
|
|
||||||
currentTime: number;
|
|
||||||
koreaFilters: KoreaFiltersState;
|
|
||||||
transshipSuspects: Set<string>;
|
|
||||||
cableWatchSuspects: Set<string>;
|
|
||||||
dokdoWatchSuspects: Set<string>;
|
|
||||||
dokdoAlerts: { mmsi: string; name: string; dist: number; time: number }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface KoreaLayers {
|
|
||||||
ships: boolean;
|
|
||||||
aircraft: boolean;
|
|
||||||
satellites: boolean;
|
|
||||||
infra: boolean;
|
|
||||||
cables: boolean;
|
|
||||||
cctv: boolean;
|
|
||||||
airports: boolean;
|
|
||||||
coastGuard: boolean;
|
|
||||||
navWarning: boolean;
|
|
||||||
osint: boolean;
|
|
||||||
eez: boolean;
|
|
||||||
piracy: boolean;
|
|
||||||
militaryOnly: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// MarineTraffic-style: satellite + dark ocean + nautical overlay
|
|
||||||
const MAP_STYLE = {
|
|
||||||
version: 8 as const,
|
|
||||||
sources: {
|
|
||||||
'satellite': {
|
|
||||||
type: 'raster' as const,
|
|
||||||
tiles: [
|
|
||||||
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
|
||||||
],
|
|
||||||
tileSize: 256,
|
|
||||||
maxzoom: 19,
|
|
||||||
attribution: '© 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||